Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 6 additions & 9 deletions backend/ibex/data_source/imas_python_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
92 changes: 90 additions & 2 deletions backend/tests/test_data_interpolation.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
10 changes: 8 additions & 2 deletions frontend/src/renderer/utils/plot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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({
Expand Down
48 changes: 32 additions & 16 deletions frontend/src/renderer/utils/plotSources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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);
});
Expand Down
57 changes: 26 additions & 31 deletions frontend/src/renderer/utils/plotSources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,56 +38,51 @@ 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<string, NodeSource>();
const sources = new Map<string, { source: NodeSource; uri: string }>();
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
*/
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,
);
};

/**
Expand Down
Loading