From 31ef9b47d4a9c1010fca4939cadf452c81a752d2 Mon Sep 17 00:00:00 2001 From: Olivier Hoenen Date: Fri, 23 Oct 2026 14:35:45 +0200 Subject: [PATCH 1/2] fix(backend): interpolate over a node of another IDS A node was only interpolated over nodes of the same IDS and path, but only the coordinates of the target are used, and their number and names are already checked. Each IDS has its own time vector, even in one data entry: core_profiles/global_quantities/ip, summary/global_quantities/ip/ value and equilibrium/time_slice[:]/global_quantities/ip of ITER 130013/10 have 108 points each, 189 together. Any target node is now accepted, so they can be put on common coordinates. A node in an AoS of an IDS with heterogeneous time (time_slice[:]/time) could not be plotted at all: its per-slice times were not converted to numbers before taking their union. Assisted-by: Claude/opus-5 --- .../ibex/data_source/imas_python_source.py | 15 ++- backend/tests/test_data_interpolation.py | 92 ++++++++++++++++++- 2 files changed, 96 insertions(+), 11 deletions(-) diff --git a/backend/ibex/data_source/imas_python_source.py b/backend/ibex/data_source/imas_python_source.py index 6fda60bd..70e171df 100644 --- a/backend/ibex/data_source/imas_python_source.py +++ b/backend/ibex/data_source/imas_python_source.py @@ -1165,6 +1165,9 @@ def convert_to_lists(data): return [convert_to_lists(d) for d in data] elif isinstance(data, (np.ndarray, IDSNumericArray)): return data.tolist() + elif isinstance(data, (IDSFloat0D, IDSInt0D)): + # e.g. time_slice[:]/time of an IDS with heterogeneous time + return data.value else: return data @@ -1225,15 +1228,9 @@ def convert_to_lists(data): for _uri in plot_data_query.interpolate_over: _uri_obj = IMAS_URI(_uri) - - if _uri_obj.ids_name != ids or _uri_obj.node_path != node_path: - if any(node_path == _uri_obj.node_path + m for m in ["_error_upper", "_error_lower"]): - # it is allowed to interpolate _error node over data node e.g. ip_error_upper over ip - ... - else: - raise InvalidParametersException( - "IDS name and node path should be the same for source and target URI when interpolating data" - ) + # Only the coordinates of the target node are used, so it may be any node of any IDS + # (e.g. summary ip over core_profiles ip, each IDS on its own time vector): the checks + # below reject targets whose coordinates differ in number or names. interpolate_to = None if store_other_signals_data: diff --git a/backend/tests/test_data_interpolation.py b/backend/tests/test_data_interpolation.py index a7e41271..48882092 100644 --- a/backend/tests/test_data_interpolation.py +++ b/backend/tests/test_data_interpolation.py @@ -1,3 +1,6 @@ +from itertools import permutations + +import imas import numpy as np import pytest from ibex.data_source.imas_python_source_utils import ( @@ -6,6 +9,53 @@ ) +IDS_TIME_BASES = { + "summary:0/global_quantities/ip/value": np.linspace(0.0, 1.0, 11), + "core_profiles:0/global_quantities/ip": np.asarray([0.05, 0.5, 1.5]), + "equilibrium:0/time_slice[:]/global_quantities/ip": np.asarray([0.25, 0.75]), + # heterogeneous time: the time of each slice is in time_slice[:]/time + "equilibrium:1/time_slice[:]/global_quantities/ip": np.asarray([0.3, 2.0]), +} + + +@pytest.fixture(scope="session") +def ids_time_bases_entry_path(tmp_path_factory): + """One entry where ip is in several IDSs, each on its own time vector (see IDS_TIME_BASES); ip = 1e6 + time.""" + uri = f"imas:hdf5?path={tmp_path_factory.mktemp('ids_time_bases_testdb')}/ids_time_bases_db" + times = list(IDS_TIME_BASES.values()) + + with imas.DBEntry(uri, mode="w") as entry: + summary = entry.factory.summary() + summary.ids_properties.homogeneous_time = 1 + summary.time = times[0] + summary.global_quantities.ip.value = 1e6 + times[0] + entry.put(summary) + + core_profiles = entry.factory.core_profiles() + core_profiles.ids_properties.homogeneous_time = 1 + core_profiles.time = times[1] + core_profiles.global_quantities.ip = 1e6 + times[1] + core_profiles.profiles_1d.resize(len(times[1])) + for profiles_1d in core_profiles.profiles_1d: + profiles_1d.grid.rho_tor_norm = np.asarray([0.0, 0.5, 1.0]) + profiles_1d.electrons.temperature = np.asarray([3.0, 2.0, 1.0]) + entry.put(core_profiles) + + for occurrence, time in enumerate(times[2:]): + equilibrium = entry.factory.equilibrium() + equilibrium.ids_properties.homogeneous_time = 1 - occurrence + if occurrence == 0: + equilibrium.time = time + equilibrium.time_slice.resize(len(time)) + for time_slice, slice_time in zip(equilibrium.time_slice, time): + if occurrence == 1: + time_slice.time = slice_time + time_slice.global_quantities.ip = 1e6 + slice_time + entry.put(equilibrium, occurrence) + + return uri + + def test_interpolation_workflow(interpolation_entry_path_directory): """ This function tests only returned data shape. It doesn't check values. @@ -156,13 +206,13 @@ def test_interpolation_codes(interpolation_entry_path_directory): response = pytest.test_client.get("/data/plot_data", params=parameters) assert response.status_code == 200 - # test interpolation ...psi over ...psi_error_upper (should return an error 466) + # test interpolation ...psi over ...psi_error_upper: only the coordinates of the target are used parameters = { "uri": f"{db_names[0]}/{uri_fragment}", "interpolate_over": [f"{db_names[1]}/{uri_fragment}_error_upper"], } response = pytest.test_client.get("/data/plot_data", params=parameters) - assert response.status_code == 466 + assert response.status_code == 200 # test interpolation ...psi_error_lower over ...psi_error_lower: the second node is empty, but its # coordinates still join the common ones, as when interpolating over the filled ...psi @@ -215,3 +265,41 @@ def test_error_band_interpolation_over_another_occurrence(occurrences_entry_path response = pytest.test_client.get("/data/plot_data", params=parameters) assert response.status_code == 200 assert len(response.json()["data"]["value"]) == 13 + + +@pytest.mark.parametrize("own, other", list(permutations(range(len(IDS_TIME_BASES)), 2))) +def test_interpolation_over_another_ids(ids_time_bases_entry_path, own, other): + """ + ip of several IDSs of one entry, each on its own time vector (equilibrium:1 with heterogeneous time), + is drawn on the union of their time vectors, each value at its own time. + """ + nodes = list(IDS_TIME_BASES) + parameters = { + "uri": f"{ids_time_bases_entry_path}#{nodes[own]}", + "interpolate_over": [f"{ids_time_bases_entry_path}#{nodes[other]}"], + "interpolation_method": "exact_value", + } + response = pytest.test_client.get("/data/plot_data", params=parameters) + assert response.status_code == 200 + + data = response.json()["data"] + own_time, other_time = IDS_TIME_BASES[nodes[own]], IDS_TIME_BASES[nodes[other]] + union = np.union1d(own_time, other_time) + assert data["coordinates"][0]["name"] == "time" + np.testing.assert_allclose(data["coordinates"][0]["value"], union) + + value = np.asarray(data["value"], dtype=float) + own_times = np.isin(union, own_time) + np.testing.assert_allclose(value[own_times], 1e6 + own_time) + assert np.isnan(value[~own_times]).all() + + +def test_interpolation_over_another_ids_coordinates_mismatch(ids_time_bases_entry_path): + """A node of another IDS whose coordinates differ from the node's cannot give it common coordinates.""" + parameters = { + "uri": f"{ids_time_bases_entry_path}#summary/global_quantities/ip/value", + "interpolate_over": [f"{ids_time_bases_entry_path}#core_profiles/profiles_1d[:]/electrons/temperature"], + "interpolation_method": "exact_value", + } + response = pytest.test_client.get("/data/plot_data", params=parameters) + assert response.status_code == 466 From ec56977d94a76867514daac96737297e1f59212a Mon Sep 17 00:00:00 2001 From: Olivier Hoenen Date: Fri, 23 Oct 2026 14:35:45 +0200 Subject: [PATCH 2/2] fix(plot): combine traces of different IDSs of one data entry core_profiles, summary and equilibrium ip could each be plotted but not on the same plot. The grid took the IDSs of one entry to share their coordinates, yet each IDS has its own time vector: no common coordinates were asked for, and the x axis check refused the time path of another IDS (#summary/time against #core_profiles/time). A trace is now interpolated over a node drawn from each source of another IDS, and the x axis check compares the time path only when there is nothing to interpolate over. This needs the back-end to accept a target node of another IDS. Assisted-by: Claude/opus-5 --- frontend/src/renderer/utils/plot.ts | 10 +++- .../src/renderer/utils/plotSources.test.ts | 48 ++++++++++------ frontend/src/renderer/utils/plotSources.ts | 57 +++++++++---------- 3 files changed, 66 insertions(+), 49 deletions(-) diff --git a/frontend/src/renderer/utils/plot.ts b/frontend/src/renderer/utils/plot.ts index 1049fbd9..4dcd7647 100644 --- a/frontend/src/renderer/utils/plot.ts +++ b/frontend/src/renderer/utils/plot.ts @@ -523,12 +523,18 @@ export const handleExistingPlot = async ( continue; } + // Interpolated over the grid's other sources, the trace is on their common + // coordinates even if its own come from another path: summary/time and + // core_profiles/time, say. The back-end has checked their names. + const xAxisPathMismatch = + urisToInterpolate.length === 0 && xAxis?.path !== xAxisResponsePath; + // Check if the xAxisData matches the first coordinate if (xAxis && coordsResponse.length > 0) { if ( xAxis.name !== coordsResponse[0].name || xAxis.unit !== coordsResponse[0].unit || - xAxis.path !== xAxisResponsePath + xAxisPathMismatch ) { showNotification({ title: 'Plot', @@ -549,7 +555,7 @@ export const handleExistingPlot = async ( // Check if the xAxisData matches the first coordinate (xAxis.name !== coordsResponse[0].name || xAxis.unit !== coordsResponse[0].unit || - xAxis.path !== xAxisResponsePath)); + xAxisPathMismatch)); if (xAxisMissingOrMismatch) { showNotification({ diff --git a/frontend/src/renderer/utils/plotSources.test.ts b/frontend/src/renderer/utils/plotSources.test.ts index 2c7c5eb7..a93aa262 100644 --- a/frontend/src/renderer/utils/plotSources.test.ts +++ b/frontend/src/renderer/utils/plotSources.test.ts @@ -11,7 +11,8 @@ const plots = (...nodeUris: string[]) => nodeUris.map((nodeUri) => ({ nodeUri }) as DataPlotly); const IP = 'global_quantities/ip/value'; -const TE = 'profiles_1d[:]/electrons/temperature'; +const CP_IP = 'global_quantities/ip'; +const EQ_IP = 'time_slice[:]/global_quantities/ip'; describe('sourceOf', () => { it('reads an occurrence left out as occurrence 0', () => { @@ -59,37 +60,52 @@ describe('getUrisToInterpolate', () => { ).to.deep.equal([]); }); - it('never takes the occurrence of another IDS of the same entry', () => { + it('takes the node drawn from another IDS of the same entry', () => { expect( getUrisToInterpolate( - `URI-1#core_profiles:0/${TE}`, + `URI-1#summary:0/${IP}`, + plots(`URI-1#core_profiles:0/${CP_IP}`, `URI-1#summary:0/${IP}`), + ), + ).to.deep.equal([`URI-1#core_profiles:0/${CP_IP}`]); + expect( + getUrisToInterpolate( + `URI-1#core_profiles:0/${CP_IP}`, plots(`URI-1#summary:1/${IP}`), ), - ).to.deep.equal([]); + ).to.deep.equal([`URI-1#summary:1/${IP}`]); + }); + + it('takes the node of another IDS with its indices normalized', () => { expect( getUrisToInterpolate( - `URI-1#summary:1/${IP}`, - plots(`URI-1#core_profiles:0/${TE}`), + `URI-0#summary:0/${IP}`, + plots(`URI-0#equilibrium:0/time_slice[0]/global_quantities/ip`), ), - ).to.deep.equal([]); + ).to.deep.equal([`URI-0#equilibrium:0/${EQ_IP}`]); }); - it('keeps its own occurrence in an entry where its IDS is not drawn', () => { + it('takes a single node of a source of another IDS', () => { expect( getUrisToInterpolate( - `URI-0#core_profiles:2/${TE}`, - plots(`URI-1#summary:1/${IP}`), + `URI-0#summary:0/${IP}`, + plots( + `URI-0#core_profiles:0/${CP_IP}`, + 'URI-0#core_profiles:0/global_quantities/v_loop', + ), ), - ).to.deep.equal([`URI-1#core_profiles:2/${TE}`]); + ).to.deep.equal([`URI-0#core_profiles:0/${CP_IP}`]); }); - it('takes the occurrences of its IDS drawn from another entry', () => { + it('takes its own node in the sources of its IDS only', () => { expect( getUrisToInterpolate( - `URI-0#core_profiles:0/${TE}`, - plots(`URI-1#summary:1/${IP}`, `URI-1#core_profiles:2/${TE}`), + `URI-0#core_profiles:0/${CP_IP}`, + plots(`URI-1#summary:1/${IP}`, `URI-1#core_profiles:2/${CP_IP}`), ), - ).to.deep.equal([`URI-1#core_profiles:2/${TE}`]); + ).to.deep.equal([ + `URI-1#summary:1/${IP}`, + `URI-1#core_profiles:2/${CP_IP}`, + ]); }); it('builds the node of the trace itself in every other source', () => { @@ -116,7 +132,7 @@ describe('commonCoordinatesChange', () => { expect( commonCoordinatesChange( plots(`URI-1#summary:1/${IP}`), - `URI-1#core_profiles:0/${TE}`, + `URI-1#core_profiles:0/${CP_IP}`, ), ).to.equal(true); }); diff --git a/frontend/src/renderer/utils/plotSources.ts b/frontend/src/renderer/utils/plotSources.ts index 179749c8..58032726 100644 --- a/frontend/src/renderer/utils/plotSources.ts +++ b/frontend/src/renderer/utils/plotSources.ts @@ -38,25 +38,37 @@ export const sourceOf = (uri: string): NodeSource => { const sourceKey = ({ entry, ids, occurrence }: NodeSource) => `${entry}#${ids}:${occurrence}`; -/** The sources a grid's traces come from, by key. */ +/** + * The sources a grid's traces come from, by key, each with the normalized URI + * of the first of its traces. + */ const sourcesOf = (plots: DataPlotly[]) => { - const sources = new Map(); + const sources = new Map(); for (const plot of plots) { const source = sourceOf(plot.nodeUri); - sources.set(sourceKey(source), source); + const key = sourceKey(source); + if (!sources.has(key)) { + sources.set(key, { source, uri: normalizeIndices(plot.nodeUri) }); + } } return sources; }; /** * The URIs to interpolate a trace over, for the `interpolate_over` parameter of - * `fetchDataPlot`: its own node in every other source of the grid. + * `fetchDataPlot`: one node of every other source of the grid. * * The back-end puts a trace on the union of its coordinates and those of the - * nodes it is interpolated over, which must have its path. Over the nodes of - * the grid sharing its path only, traces of two paths from the same entries - - * ip and tau_energy, say - ended up on two different unions while the grid - * draws them all against one. + * nodes it is interpolated over. In a source of its own IDS, that is its own + * node: over the nodes of the grid sharing its path only, traces of two paths + * from the same entries - ip and tau_energy, say - ended up on two different + * unions while the grid draws them all against one. + * + * Another IDS has its own coordinates, even in the same entry: core_profiles, + * summary and equilibrium each have their time vector. The trace's node may + * not exist there, so it is interpolated over a node drawn from that source, + * whose coordinates the grid shares; the back-end refuses it if their + * coordinates differ. * @param uriToAdd the trace's node URI * @param plots the grid's traces; the trace itself may be among them */ @@ -64,30 +76,13 @@ export const getUrisToInterpolate = (uriToAdd: string, plots: DataPlotly[]) => { const own = sourceOf(uriToAdd); const ownKey = sourceKey(own); const node = own.path ? `/${own.path}` : ''; - const others = [...sourcesOf(plots)] + return [...sourcesOf(plots)] .filter(([key]) => key !== ownKey) - .map(([, other]) => other); - // The back-end interpolates over the trace's own IDS only, so another IDS - // gives no occurrence to take: none in the trace's own entry, whose IDSs - // share their coordinates, and the trace's own one in an entry where its IDS - // is not drawn. - const entriesWithOwnIds = new Set( - others.filter((other) => other.ids === own.ids).map((other) => other.entry), - ); - return [ - ...new Set( - others.flatMap((other) => { - let occurrence = other.occurrence; - if (other.ids !== own.ids) { - if (other.entry === own.entry || entriesWithOwnIds.has(other.entry)) { - return []; - } - occurrence = own.occurrence; - } - return [`${other.entry}#${own.ids}:${occurrence}${node}`]; - }), - ), - ]; + .map(([, { source, uri }]) => + source.ids === own.ids + ? `${source.entry}#${own.ids}:${source.occurrence}${node}` + : uri, + ); }; /**