Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
59008f7
Add simple data operations
jwasikpsnc Jun 2, 2026
c86a6f2
Add subtraction. Check parameters before operation.
jwasikpsnc Jun 15, 2026
e67f0f1
Add operation priorities
jwasikpsnc Jun 15, 2026
fc9c0b3
Fix broken test
jwasikpsnc Jun 15, 2026
1c7e665
Apply linter
jwasikpsnc Jun 15, 2026
85c3a2b
Signal operations (WIP)
jwasikpsnc Jun 16, 2026
a105b66
Basic operations order (WIP)
jwasikpsnc Jun 16, 2026
f5da1cf
Fix wrong description
jwasikpsnc Jun 17, 2026
6639d45
Update docstring and test.
jwasikpsnc Jun 17, 2026
ca41656
Merge branch 'refs/heads/feature/binary_data_operation' into feature/…
jwasikpsnc Jun 17, 2026
d6b6227
Signal data operations (WIP)
jwasikpsnc Jun 18, 2026
d79fac5
Update docs. Mini-fixes.
jwasikpsnc Jun 18, 2026
4e3cec7
Signal operations (WIP)
jwasikpsnc Jun 16, 2026
25a4b1d
Signal data operations (WIP)
jwasikpsnc Jun 18, 2026
1455de1
Delete unused validator
jwasikpsnc Jun 18, 2026
3dc3aad
Merge remote-tracking branch 'origin/feature/signal_data_operation' i…
jwasikpsnc Jun 18, 2026
ae27462
Fix broken tests.
jwasikpsnc Jun 18, 2026
93fd74d
Signal operations
jwasikpsnc Jun 19, 2026
3b8f226
Update tests. Fix bug.
jwasikpsnc Jun 22, 2026
2499794
Delete unused operations
jwasikpsnc Jun 22, 2026
639c611
Merge remote-tracking branch 'refs/remotes/origin/develop' into featu…
jwasikpsnc Jun 30, 2026
56d3040
Apply linter. Fix bugs.
jwasikpsnc Jun 30, 2026
8cc64a4
Add validator for signal operations.
jwasikpsnc Jun 30, 2026
3d05817
Fix plot data shape after interpolation
Jul 20, 2026
e4f6f2c
Preserve NaN values in signal operations
Jul 20, 2026
ad0a37b
Propagate units through signal operations
Jul 20, 2026
c218ca2
Matching time coordinates for signal operations
bpalak Jul 21, 2026
405950c
Code refactoring
bpalak Jul 21, 2026
1c2d9b4
Merge remote-tracking branch 'refs/remotes/upstream/develop' into fea…
bpalak Jul 21, 2026
556cdb5
Removing NaN handling section from signal operations documentation.
Jul 22, 2026
8787daf
Merge remote-tracking branch 'origin/feature/signal_data_operation' i…
Jul 22, 2026
d213449
Signal operations: checking compatibility of all coordinates of operands
Jul 22, 2026
e9a9537
refactor: simplify signal operand data preparation
bpalak Jul 23, 2026
353af26
Reject add and sub operations with mismatched units
bpalak Jul 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions backend/ibex/core/data_manipulation_methods.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,3 +221,44 @@ class DataManipulationMethodsResponse(BaseModel):

simple_data_operations_description.method_parameters.append(data_operations_parameter)
available_methods.data_manipulation_methods.append(simple_data_operations_description)

# ====================== SIGNAL DATA OPERATIONS ======================

signal_data_operations_description = DataManipulationOperation(
name="Signal Data Operations",
description="Ordered list of signal operations applied to the dataset. "
"Execution order is determined by the order of parameters in the request. "
"Each value is a URI pointing to a signal.",
method_parameters=[],
)

signal_data_operations_parameter = DataManipulationParameter(
human_readable_name="Operations",
name="operations",
description="Ordered list of signal operations applied to every data point.",
type="list[object]",
group_label="Operation",
fields=[
DataManipulationParameter(
human_readable_name="Type",
name="operation_type",
description="Type of operation",
type="string",
possible_values=[
PossibleValue(value="add", description="Addition"),
PossibleValue(value="sub", description="Subtraction"),
PossibleValue(value="mul", description="Multiplication"),
PossibleValue(value="div", description="Division"),
],
),
DataManipulationParameter(
human_readable_name="Signal URI",
name="operation_value",
description="URI to signal data for the operation",
type="string",
),
],
)

signal_data_operations_description.method_parameters.append(signal_data_operations_parameter)
available_methods.data_manipulation_methods.append(signal_data_operations_description)
158 changes: 147 additions & 11 deletions backend/ibex/data_source/imas_python_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import imas # type: ignore
import numpy as np # type: ignore
import re # type: ignore
from copy import copy # type: ignore
from idstools.database import DBMaster # type: ignore
from imas.ids_metadata import IDSMetadata # type: ignore
from imas.ids_primitive import (
Expand Down Expand Up @@ -52,6 +51,8 @@
apply_gaussian_filter,
apply_simple_operations,
resolve_irregular_coordinate_data_shape,
apply_signal_operations,
combine_signal_units,
)
from ibex.core.data_manipulation_methods import SmoothingMethod, InterpolationMethod
from ibex.endpoints.schemas.request_data_schemas import PlotDataRequestModel
Expand Down Expand Up @@ -1055,6 +1056,7 @@ def get_plot_data(self, plot_data_query: PlotDataRequestModel) -> dict:
}
coordinates_to_be_returned.append(c)
first_value = find_first_value_in_list(ids_data)
result_unit = first_value.metadata.units or ""
data_to_be_returned = convert_ids_data_into_numpy_array(ids_data)

if first_value.metadata.ndim == 2:
Expand Down Expand Up @@ -1106,8 +1108,12 @@ def get_plot_data(self, plot_data_query: PlotDataRequestModel) -> dict:

# ============= END data smoothing =============

# ============= BEGIN resample data onto new time vector =============
try:
original_data_shape = np.asarray(data_to_be_returned).shape
Comment thread
prasad-sawantdesai marked this conversation as resolved.
except ValueError:
original_data_shape = "irregular"

# ============= BEGIN resample data onto new time vector =============
def convert_to_lists(data):
if isinstance(data, list):
return [convert_to_lists(d) for d in data]
Expand All @@ -1116,6 +1122,10 @@ def convert_to_lists(data):
else:
return data

# list of dicts used when combining signals after
# {uri:str, data:list[*], interpolated_data: list[*]}
others_signals_data = {}

if plot_data_query.interpolate_over:
# check for non-interpolatable coordinates
if plot_data_query.interpolation_method != InterpolationMethod.EXACT_VALUE:
Expand Down Expand Up @@ -1153,6 +1163,10 @@ def convert_to_lists(data):

original_coord_values.reverse()

store_other_signals_data = False # used for signal combining after interpolation
if plot_data_query.signal_operations:
store_other_signals_data = True

for _uri in plot_data_query.interpolate_over:
_uri_obj = IMAS_URI(_uri)

Expand All @@ -1165,11 +1179,20 @@ def convert_to_lists(data):
"IDS name and node path should be the same for source and target URI when interpolating data"
)

new_plot_data_query = copy(plot_data_query)
new_plot_data_query.uri = _uri
new_plot_data_query.interpolate_over = None
new_plot_data_query.smoothing_method = None
interpolate_to_coordinates = self.get_plot_data(new_plot_data_query)["data"]["coordinates"]
new_plot_data_query = PlotDataRequestModel(uri=_uri)
# interpolate_to will be used later with signal combining
interpolate_to = self.get_plot_data(new_plot_data_query)["data"]
interpolate_to_coordinates = interpolate_to["coordinates"]
if store_other_signals_data:
others_signals_data[_uri] = {
"uri": _uri,
"data": pad_to_rectangular(interpolate_to["value"]),
"coordinates": [
sorted(set(flatten(convert_to_lists(c["value"])))) for c in interpolate_to_coordinates
],
"shape": interpolate_to["shape"],
"unit": interpolate_to["unit"],
}

if len(interpolate_to_coordinates) != len(coordinates_to_be_returned):
message = "Interpolation error. Source and target nodes have different number of coordinates."
Expand Down Expand Up @@ -1215,10 +1238,112 @@ def convert_to_lists(data):

# ============= END resample data onto new time vector =============

# ============= BEGIN signal operations =============
#
# Steps performed in this block:
# 1. Collect all signal URIs referenced in signal_operations
# 2. Pad data to rectangular if shape is irregular
# 3. For each signal URI, fetch and prepare data:
# a. If the signal was already interpolated (stored during
# interpolation phase), skip fetching
# b. Otherwise fetch the signal and check shape compatibility
# 4. Prepare operand data for each signal:
# a. If interpolation was requested, resample onto common coords
# b. Otherwise normalize raw signal data to a NumPy array
# 5. Build a flat uri->array dict and apply all signal operations

if plot_data_query.signal_operations:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After Square / Multiplication operation shown below, should unit of the quantity remains same?

$ curl -G http://127.0.0.1:8000/data/plot_data     --data-urlencode "uri=$BASE"  --data-urlencode "interpolate_over=$OTHER" --data-urlencode "interpolation_method=exact_value" --data-urlencode "signal_operations=mul:$OTHER" | jq
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  7880  100  7880    0     0   769k      0 --:--:-- --:--:-- --:--:--  769k
{
  "data": {
    "name": "b0",
    "unit": "T",
    "shape": [
      300
    ],
    "downsampled_shape": [
      300
    ],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure concerning physical interpretation of mentioned operations. If we multiply signal1 x signal2 the unit should be unit^2 ?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure concerning physical interpretation of mentioned operations. If we multiply signal1 x signal2 the unit should be unit^2 ?

If signal1 and signal2 have the same unit, yes, this is correct. Combining units is only valid with your mul and div operators thought, with add and sub you shall make sure that units are the same or throw an error.
You may check this short video: https://www.youtube.com/watch?v=2DJjV3I4xGc

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DONE. ad0a37b

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add and sub you shall make sure that units are the same or throw an error. #90 (comment)

I think we are not checking if units are the same. and it did not throw exception.

[sawantp1@98dci4-srv-1005 frontend]$ echo $ANOTHER
imas:hdf5?path=/work/imas/shared/imasdb/ITER/3/134174/117#equilibrium/time_slice[0]/profiles_1d/psi
[sawantp1@98dci4-srv-1005 frontend]$ echo $ANOTHER1
imas:hdf5?path=/work/imas/shared/imasdb/ITER/3/134174/117#equilibrium/time_slice[0]/profiles_1d/pressure
$ curl -G http://127.0.0.1:8000/data/plot_data     --data-urlencode "uri=$ANOTHER1" --data-urlencode "signal_operations=add:$ANOTHER" | jq | head -n 20
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100 13737  100 13737    0     0   353k      0 --:--:-- --:--:-- --:--:--  353k
{
  "data": {
    "name": "pressure",
    "unit": "Pa",
    "shape": [
      299
    ],
    "downsampled_shape": [
      299
    ],
    "ndim": 1,
    "path": "#equilibrium/time_slice[0]/profiles_1d/pressure",
    "description": "Pressure",
    "coordinates": [
      {
        "name": "psi",
        "target": "#equilibrium/time_slice[0]/profiles_1d/pressure",
        "unit": "Wb",
        "shape": [
          299

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DONE

# Step 1: extract unique signal URIs from operation strings
signal_op_uris = set()
for op_str in plot_data_query.signal_operations or []:
_, op_uri = op_str.split(":", 1)
signal_op_uris.add(op_uri)

# Step 2: ensure rectangular data for downstream processing
if original_data_shape == "irregular":
data_to_be_returned = pad_to_rectangular(data_to_be_returned)

# Step 3: fetch and prepare each signal referenced in operations
for signal_uri in signal_op_uris:
if signal_uri not in others_signals_data:
# Signal was not pre-loaded during interpolation phase.
# Fetch it now and verify shape compatibility.
request = PlotDataRequestModel(uri=signal_uri)
other_signal = self.get_plot_data(request)

# Comparing signal shapes
if (
other_signal["data"]["shape"] == "irregular"
or other_signal["data"]["shape"] != original_data_shape
):
msg = (
f"Cannot apply operation on signal {signal_uri} without interpolation. "
"Signal and data shapes do not match. "
"Try interpolating the signal onto the data shape."
)
raise InvalidParametersException(msg)

# Comparing coordinates
coordinates_match = self._coordinates_match(
coordinates_to_be_returned, other_signal["data"]["coordinates"]
)

if not coordinates_match:
msg = (
f"Cannot apply operation on signal {signal_uri} without interpolation. "
"Coordinates do not match. "
"Try interpolating the signal onto the data coordinates."
)
raise InvalidParametersException(msg)

others_signals_data[signal_uri] = {
"uri": request.uri,
"data": other_signal["data"]["value"],
"coordinates": [
sorted(set(flatten(convert_to_lists(c["value"]))))
for c in other_signal["data"]["coordinates"]
],
"shape": other_signal["data"]["shape"],
"unit": other_signal["data"]["unit"],
}

# Step 4: prepare operand data (resampled or raw)
if plot_data_query.interpolate_over:
# Resample signal data onto the common coordinate grid
signal_data = resample_data_without_interpolation(
tuple(reversed(others_signals_data[signal_uri]["coordinates"])),
others_signals_data[signal_uri]["data"],
tuple(common_coords_values),
)
else:
signal_data = others_signals_data[signal_uri]["data"]

others_signals_data[signal_uri]["data"] = np.asarray(signal_data)

# Step 5: flatten dict and apply operations in order
signal_data_by_uri = {uri: info["data"] for uri, info in others_signals_data.items()}
for operation in plot_data_query.signal_operations:
operation_type, signal_uri = operation.split(":", 1)
result_unit = combine_signal_units(
result_unit,
others_signals_data[signal_uri]["unit"],
operation_type,
)
data_to_be_returned = apply_signal_operations(
data_to_be_returned, plot_data_query.signal_operations, signal_data_by_uri
)

# ============= END signal operations =============

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you know what I am doing wrong here?

import imas
import numpy as np

entries = [
    ("/tmp/ibex-grid-first",  [0.0, 0.5, 1.0], [10.0, 20.0, 30.0]),
    ("/tmp/ibex-grid-second", [0.0, 0.4, 0.8], [100.0, 200.0, 300.0]),
]

for path, rho, temperature in entries:
    with imas.DBEntry(f"imas:hdf5?path={path}", mode="w") as entry:
        cp = entry.factory.core_profiles()
        cp.ids_properties.homogeneous_time = 1
        cp.time = np.array([1.0])
        cp.profiles_1d.resize(1)
        cp.profiles_1d[0].grid.rho_tor_norm = np.array(rho)
        cp.profiles_1d[0].electrons.temperature = np.array(temperature)
        entry.put(cp)
# $ echo $FIRST
# imas:hdf5?path=/tmp/ibex-grid-first#core_profiles:0/profiles_1d[:]/electrons/temperature
# $ echo $SECOND
# imas:hdf5?path=/tmp/ibex-grid-second#core_profiles:0/profiles_1d[:]/electrons/temperature

# $ curl -sS -G http://127.0.0.1:8000/data/plot_data     --data-urlencode "uri=$FIRST"     --data-urlencode "interpolate_over=$FIRST"     --data-urlencode "interpolation_method=exact_value"     --data-urlencode "signal_operations=add:$SECOND"     | jq
# {
#   "message": "'interpolated_data'"
# }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am on it...

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Corrected

# Interpolation and signal operations can change the data shape.
# The response's ``shape`` describes the processed data before
# downsampling, rather than the raw shape used for compatibility
# checks above.
try:
original_data_shape = np.asarray(data_to_be_returned).shape
processed_data_shape = np.asarray(data_to_be_returned).shape
except ValueError:
original_data_shape = "irregular"
processed_data_shape = "irregular"

# Downsample only 1D data
if first_value.metadata.ndim == 1:
if coordinates_to_be_returned[0]["target"].split("/")[-1] == f"{node_path.split('/')[-1]}":
Expand Down Expand Up @@ -1250,8 +1375,8 @@ def convert_to_lists(data):
result = {
"data": {
"name": node_path.split("/")[-1],
"unit": first_value.metadata.units,
"shape": original_data_shape,
"unit": result_unit,
"shape": processed_data_shape,
"downsampled_shape": downsampled_shape,
"ndim": first_value.metadata.ndim,
"path": f"#{ids}/{node_path}",
Expand All @@ -1272,6 +1397,17 @@ def convert_to_lists(data):
coordinate["coordinates"] = new_shape_factors_list
return result

def _coordinates_match(self, coordinates_1, coordinates_2):
if len(coordinates_1) != len(coordinates_2):
return False

coordinates_match = all(
coordinate_1["name"] == coordinate_2["name"]
and np.array_equal(np.asarray(coordinate_1["value"]), np.asarray(coordinate_2["value"]), equal_nan=True)
for coordinate_1, coordinate_2 in zip(coordinates_1, coordinates_2)
)
return coordinates_match

def _is_empty(self, seq):
"""Checks if list is essentially empty (contains only empty lists or empty strings)"""
if isinstance(seq, (IDSNumericArray, IDSString0D, IDSString1D, IDSComplex0D, IDSFloat0D, IDSInt0D)):
Expand Down
Loading