From 7aa5f808e3e4dfbb39dd2d5f7e1357e4417a7eda Mon Sep 17 00:00:00 2001 From: wasikj Date: Tue, 26 May 2026 10:32:23 +0200 Subject: [PATCH 01/14] Add geometry overlay nodes endpoint --- backend/ibex/core/ibex_service.py | 7 +++ .../ibex/data_source/data_source_interface.py | 10 +++++ .../ibex/data_source/imas_python_source.py | 45 +++++++++++++++++++ backend/ibex/endpoints/ids_info.py | 34 +++++++++++++- .../endpoints/schemas/ids_info_schemas.py | 12 +++++ backend/tests/conftest.py | 5 +++ backend/tests/test_ids_info_endpoints.py | 34 +++++++++++++- 7 files changed, 145 insertions(+), 2 deletions(-) diff --git a/backend/ibex/core/ibex_service.py b/backend/ibex/core/ibex_service.py index 6b4e5d2c..cdcfdf8d 100644 --- a/backend/ibex/core/ibex_service.py +++ b/backend/ibex/core/ibex_service.py @@ -183,3 +183,10 @@ def get_plot_data(uri: str, downsampling_method: str | None, downsampled_size: i downsampling_method=downsampling_method, downsampled_size=downsampled_size, ) + + +def get_geometry_overlay_nodes(uri: str) -> dict: + uri_obj = IMAS_URI(uri) + return data_source.get_geometry_overlay_nodes( + uri=uri_obj.uri_entry_identifiers, ids=uri_obj.ids_name, occurrence=uri_obj.occurrence + ) diff --git a/backend/ibex/data_source/data_source_interface.py b/backend/ibex/data_source/data_source_interface.py index 0189a539..f8ffe7ae 100644 --- a/backend/ibex/data_source/data_source_interface.py +++ b/backend/ibex/data_source/data_source_interface.py @@ -113,3 +113,13 @@ def list_db_entries( :return: dictionary {'entries': [, , ...]} """ ... + + @abstractmethod + def get_geometry_overlay_nodes(self, uri: str, ids: str, occurrence: int = 0) -> dict: + """ + Returns paths to filled geometry overlay nodes found in the entry metadata. + + :param uri: imas URI + :return: dictionary {'outline_nodes': ['#ids/path/to/node1', '#ids/path/to/node2', ...]} + """ + ... diff --git a/backend/ibex/data_source/imas_python_source.py b/backend/ibex/data_source/imas_python_source.py index bd462430..b742c62c 100644 --- a/backend/ibex/data_source/imas_python_source.py +++ b/backend/ibex/data_source/imas_python_source.py @@ -618,6 +618,51 @@ def _check_data_is_leaf_node(self, data) -> None: elif isinstance(data, IDSStructure): raise NotALeafNodeException("Cannot serialize non-leaf node") + def get_geometry_overlay_nodes(self, uri: str, ids: str, occurrence: int = 0) -> dict: + """ + Returns paths to metadata nodes that describe geometry overlays. + + A node is included when: + - its type is ``outline_2d_geometry_static``, or + - its name contains ``outline`` and its type is ``rz1d_static`` or ``rz1d_dynamic_aos``. + + :param uri: imas URI + :param ids: name of ids e.g. core_profiles + :param occurrence: ids occurrence number + :return: dictionary {'nodes': ['#ids/path/to/node1', '#ids/path/to/node2', ...]} + """ + + # ============ HELPER FUNCTION ============ + def _walk_outline_nodes(metadata: IDSMetadata, results: list[str]) -> None: + node_name = metadata.name + node_type = getattr(metadata, "structure_reference", None) + + is_outline_static = node_type == "outline_2d_geometry_static" + is_outline_rz = "outline" in node_name and node_type in {"rz1d_static", "rz1d_dynamic_aos"} + + if is_outline_static or is_outline_rz: + results.append(metadata.path_string) + + for child in metadata: + _walk_outline_nodes(child, results) + + # ============ END HELPER FUNCTION ============ + + with self._open_entry(uri) as entry: + ids_obj = self._get_ids_from_entry(entry, ids, occurrence) + outline_nodes = [] + _walk_outline_nodes(ids_obj.metadata, outline_nodes) + + try: + filled_paths = entry.list_filled_paths(ids, int(occurrence)) + outline_nodes = list(set(outline_nodes) & {path.rstrip("/") for path in filled_paths}) + except (AttributeError, imas.backends.imas_core.imas_interface.LLInterfaceError): + ... + # AttributeError - current version of IMAS-Python doesn't support list_filled paths + # LLInterfaceError - current version of IMAS-Core doesn't support list_filled paths + # proceed + return {"outline_nodes": outline_nodes} + def get_plot_data( self, uri: str, diff --git a/backend/ibex/endpoints/ids_info.py b/backend/ibex/endpoints/ids_info.py index 60edaf55..7f41703a 100644 --- a/backend/ibex/endpoints/ids_info.py +++ b/backend/ibex/endpoints/ids_info.py @@ -3,7 +3,12 @@ from fastapi import APIRouter # type: ignore from ibex.core import ibex_service -from ibex.endpoints.schemas.ids_info_schemas import NodeInfoResponse, FindPathsResponse, ArraySummaryResponse +from ibex.endpoints.schemas.ids_info_schemas import ( + NodeInfoResponse, + FindPathsResponse, + ArraySummaryResponse, + GeometryOverlayNodesResponse, +) router = APIRouter() @@ -108,3 +113,30 @@ def array_summary(uri: str) -> dict: """ return ibex_service.array_summary(uri.strip()) + + +@router.get( + "/ids_info/geometry_overlay_nodes", + status_code=200, + response_model=GeometryOverlayNodesResponse, + responses={ + 200: {"description": "Geometry overlay nodes returned successfully"}, + }, + description="Returns geometry overlay nodes metadata", +) +@ibex_service.measure_execution_time +def geometry_overlay_nodes(uri: str) -> dict: + """ + IBEX endpoint. Returns paths to geometry overlay nodes. + + | Response JSON is constructed as follows: + | { + | "outline_nodes" : [, , ...] + | } + + :param uri: IMAS URI + :rtype: dict (automatically converted to JSON by FastAPI) + :return: JSON response + + """ + return ibex_service.get_geometry_overlay_nodes(uri.strip()) diff --git a/backend/ibex/endpoints/schemas/ids_info_schemas.py b/backend/ibex/endpoints/schemas/ids_info_schemas.py index a52cfe87..5e1e00b9 100644 --- a/backend/ibex/endpoints/schemas/ids_info_schemas.py +++ b/backend/ibex/endpoints/schemas/ids_info_schemas.py @@ -56,3 +56,15 @@ class ArraySummaryResponse(BaseModel): max: float = Field(description="Maximum value from the array", examples=[1.2, 3.4]) mean: float = Field(description="Mean value from the array", examples=[1.2, 3.4]) standard_deviation: float = Field(description="Standard deviation value of the array", examples=[1.2, 3.4]) + + +# ========== GEOMETRY OVERLAY NODES ========== + + +class GeometryOverlayNodesResponse(BaseModel): + """Response for /ids_info/geometry_overlay_nodes endpoint""" + + outline_nodes: list[str] = Field( + description="List of filled geometry overlay node paths", + examples=[["description_2d/limiter/unit/outline", "description_2d/vessel/unit/element/outline"]], + ) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 5845d326..6ac15a4b 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -49,6 +49,11 @@ def entry_path(tmp_path_factory): i += 10 entry.put(core_profiles) + + wall = entry.factory.wall() + wall.ids_properties.homogeneous_time = 1 + entry.put(wall) + entry.close() return tmp_path diff --git a/backend/tests/test_ids_info_endpoints.py b/backend/tests/test_ids_info_endpoints.py index fef45fee..1f1684e7 100644 --- a/backend/tests/test_ids_info_endpoints.py +++ b/backend/tests/test_ids_info_endpoints.py @@ -84,11 +84,14 @@ def test_find_paths(entry_path): has_data_true = None # before AL-Core 5.7 IBEX returns None else: has_data_true = True - + print(response.json()["paths"]) assert response.json()["paths"] == [ {"path": "#core_profiles/ids_properties/version_put/data_dictionary", "has_data": has_data_true}, {"path": "#core_profiles/ids_properties/version_put/access_layer", "has_data": has_data_true}, {"path": "#core_profiles/ids_properties/version_put/access_layer_language", "has_data": has_data_true}, + {"path": "#wall/ids_properties/version_put/data_dictionary", "has_data": has_data_true}, + {"path": "#wall/ids_properties/version_put/access_layer", "has_data": has_data_true}, + {"path": "#wall/ids_properties/version_put/access_layer_language", "has_data": has_data_true}, ] @@ -105,6 +108,35 @@ def test_array_summary(entry_path): assert response.json()["mean"] == 3.0 +@pytest.mark.skipif(True, reason="Still to be finished") +def test_geometry_overlay_nodes(entry_path): + parameters = { + "uri": f"imas:hdf5?path={entry_path}#wall", + } + response = pytest.test_client.get("/ids_info/geometry_overlay_nodes", params=parameters) + + assert response.status_code == 200 + + if Version(imas_core.__version__) < Version("5.7"): + assert response.json() == { + "outline_nodes": [ + "description_2d/limiter/unit/outline", + "description_2d/vessel/unit/annular/outline_inner", + "description_2d/vessel/unit/annular/outline_outer", + "description_2d/vessel/unit/element/outline", + ] + } + else: + assert response.json() == { + "outline_nodes": [ + "description_2d/limiter/unit/outline", + "description_2d/vessel/unit/annular/outline_inner", + "description_2d/vessel/unit/annular/outline_outer", + "description_2d/vessel/unit/element/outline", + ] + } + + def test_show_error_bars_option(entry_path): parameters = { "uri": f"imas:hdf5?path={entry_path}#core_profiles/vacuum_toroidal_field", From b1011a515dc5c61a8e113fcc9446fe8bdbca8468 Mon Sep 17 00:00:00 2001 From: wasikj Date: Mon, 1 Jun 2026 06:56:20 +0200 Subject: [PATCH 02/14] Add show_structures parameter to geometry overlay nodes endpoint --- backend/ibex/core/ibex_service.py | 4 ++-- backend/ibex/data_source/data_source_interface.py | 2 +- backend/ibex/data_source/imas_python_source.py | 2 +- backend/ibex/endpoints/ids_info.py | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/ibex/core/ibex_service.py b/backend/ibex/core/ibex_service.py index cdcfdf8d..2b4ac147 100644 --- a/backend/ibex/core/ibex_service.py +++ b/backend/ibex/core/ibex_service.py @@ -185,8 +185,8 @@ def get_plot_data(uri: str, downsampling_method: str | None, downsampled_size: i ) -def get_geometry_overlay_nodes(uri: str) -> dict: +def get_geometry_overlay_nodes(uri: str, show_structures: bool) -> dict: uri_obj = IMAS_URI(uri) return data_source.get_geometry_overlay_nodes( - uri=uri_obj.uri_entry_identifiers, ids=uri_obj.ids_name, occurrence=uri_obj.occurrence + uri=uri_obj.uri_entry_identifiers, ids=uri_obj.ids_name, occurrence=uri_obj.occurrence, show_structures=show_structures ) diff --git a/backend/ibex/data_source/data_source_interface.py b/backend/ibex/data_source/data_source_interface.py index f8ffe7ae..0f5f3d65 100644 --- a/backend/ibex/data_source/data_source_interface.py +++ b/backend/ibex/data_source/data_source_interface.py @@ -115,7 +115,7 @@ def list_db_entries( ... @abstractmethod - def get_geometry_overlay_nodes(self, uri: str, ids: str, occurrence: int = 0) -> dict: + def get_geometry_overlay_nodes(self, uri: str, ids: str, occurrence: int = 0, show_structures : bool = False) -> dict: """ Returns paths to filled geometry overlay nodes found in the entry metadata. diff --git a/backend/ibex/data_source/imas_python_source.py b/backend/ibex/data_source/imas_python_source.py index b742c62c..20b4f37d 100644 --- a/backend/ibex/data_source/imas_python_source.py +++ b/backend/ibex/data_source/imas_python_source.py @@ -618,7 +618,7 @@ def _check_data_is_leaf_node(self, data) -> None: elif isinstance(data, IDSStructure): raise NotALeafNodeException("Cannot serialize non-leaf node") - def get_geometry_overlay_nodes(self, uri: str, ids: str, occurrence: int = 0) -> dict: + def get_geometry_overlay_nodes(self, uri: str, ids: str, occurrence: int = 0, show_structures : bool = False) -> dict: """ Returns paths to metadata nodes that describe geometry overlays. diff --git a/backend/ibex/endpoints/ids_info.py b/backend/ibex/endpoints/ids_info.py index 7f41703a..39dfdeb2 100644 --- a/backend/ibex/endpoints/ids_info.py +++ b/backend/ibex/endpoints/ids_info.py @@ -125,7 +125,7 @@ def array_summary(uri: str) -> dict: description="Returns geometry overlay nodes metadata", ) @ibex_service.measure_execution_time -def geometry_overlay_nodes(uri: str) -> dict: +def geometry_overlay_nodes(uri: str, show_structures: bool = False) -> dict: """ IBEX endpoint. Returns paths to geometry overlay nodes. @@ -139,4 +139,4 @@ def geometry_overlay_nodes(uri: str) -> dict: :return: JSON response """ - return ibex_service.get_geometry_overlay_nodes(uri.strip()) + return ibex_service.get_geometry_overlay_nodes(uri.strip(), show_structures=show_structures) From 97adfccdd7008e44dfb9bdafb1828ddb37520b66 Mon Sep 17 00:00:00 2001 From: wasikj Date: Mon, 1 Jun 2026 13:40:54 +0200 Subject: [PATCH 03/14] Geometry overlay nodes --- backend/ibex/core/ibex_service.py | 11 +++- .../ibex/data_source/data_source_interface.py | 16 ++++- .../ibex/data_source/imas_python_source.py | 56 ++++++++++++---- backend/ibex/endpoints/ids_info.py | 14 +++- backend/tests/conftest.py | 5 ++ backend/tests/test_ids_info_endpoints.py | 64 +++++++++++++------ 6 files changed, 127 insertions(+), 39 deletions(-) diff --git a/backend/ibex/core/ibex_service.py b/backend/ibex/core/ibex_service.py index 2b4ac147..2b6be044 100644 --- a/backend/ibex/core/ibex_service.py +++ b/backend/ibex/core/ibex_service.py @@ -185,8 +185,15 @@ def get_plot_data(uri: str, downsampling_method: str | None, downsampled_size: i ) -def get_geometry_overlay_nodes(uri: str, show_structures: bool) -> dict: +def get_geometry_overlay_nodes( + uri: str, show_empty_nodes: bool = False, show_error_bars: bool = False, show_structures: bool = False +) -> dict: uri_obj = IMAS_URI(uri) return data_source.get_geometry_overlay_nodes( - uri=uri_obj.uri_entry_identifiers, ids=uri_obj.ids_name, occurrence=uri_obj.occurrence, show_structures=show_structures + uri=uri_obj.uri_entry_identifiers, + ids=uri_obj.ids_name, + occurrence=uri_obj.occurrence, + show_empty_nodes=show_empty_nodes, + show_error_bars=show_error_bars, + show_structures=show_structures, ) diff --git a/backend/ibex/data_source/data_source_interface.py b/backend/ibex/data_source/data_source_interface.py index 0f5f3d65..45a68d8c 100644 --- a/backend/ibex/data_source/data_source_interface.py +++ b/backend/ibex/data_source/data_source_interface.py @@ -115,11 +115,23 @@ def list_db_entries( ... @abstractmethod - def get_geometry_overlay_nodes(self, uri: str, ids: str, occurrence: int = 0, show_structures : bool = False) -> dict: + def get_geometry_overlay_nodes( + self, + uri: str, + ids: str, + occurrence: int = 0, + show_error_nodes: bool = False, + show_structures: bool = False, + ) -> dict: """ Returns paths to filled geometry overlay nodes found in the entry metadata. :param uri: imas URI - :return: dictionary {'outline_nodes': ['#ids/path/to/node1', '#ids/path/to/node2', ...]} + :param ids: name of ids e.g. core_profiles + :param occurrence: ids occurrence number + :param show_empty_nodes: whether empty nodes should be returned, or not + :param show_error_nodes: whether error nodes should be returned, or not + :param show_structures: whether structure nodes should be returned, or not + :return: dictionary {'outline_nodes': ['path/to/node1', 'path/to/node2', ...]} """ ... diff --git a/backend/ibex/data_source/imas_python_source.py b/backend/ibex/data_source/imas_python_source.py index 20b4f37d..a23dafbd 100644 --- a/backend/ibex/data_source/imas_python_source.py +++ b/backend/ibex/data_source/imas_python_source.py @@ -618,49 +618,77 @@ def _check_data_is_leaf_node(self, data) -> None: elif isinstance(data, IDSStructure): raise NotALeafNodeException("Cannot serialize non-leaf node") - def get_geometry_overlay_nodes(self, uri: str, ids: str, occurrence: int = 0, show_structures : bool = False) -> dict: + def get_geometry_overlay_nodes( + self, + uri: str, + ids: str, + occurrence: int = 0, + show_empty_nodes: bool = False, + show_error_bars: bool = False, + show_structures: bool = False, + ) -> dict: """ Returns paths to metadata nodes that describe geometry overlays. A node is included when: - its type is ``outline_2d_geometry_static``, or - its name contains ``outline`` and its type is ``rz1d_static`` or ``rz1d_dynamic_aos``. + Error nodes are filtered out by default and can be included with ``show_error_nodes=True``. + Structure nodes are filtered out by default and can be included with ``show_structures=True``. :param uri: imas URI :param ids: name of ids e.g. core_profiles :param occurrence: ids occurrence number - :return: dictionary {'nodes': ['#ids/path/to/node1', '#ids/path/to/node2', ...]} + :param show_empty_nodes: whether empty nodes should be returned, or not + :param show_error_nodes: whether error nodes should be returned, or not + :param show_structures: whether structure nodes should be returned, or not + :return: dictionary {'outline_nodes': ['path/to/node1', 'path/to/node2', ...]} """ # ============ HELPER FUNCTION ============ - def _walk_outline_nodes(metadata: IDSMetadata, results: list[str]) -> None: + def _walk_outline_nodes( + metadata: IDSMetadata, + results: list[str], + show_error_bars: bool = False, + show_structures: bool = False, + ) -> None: node_name = metadata.name node_type = getattr(metadata, "structure_reference", None) is_outline_static = node_type == "outline_2d_geometry_static" is_outline_rz = "outline" in node_name and node_type in {"rz1d_static", "rz1d_dynamic_aos"} + is_structure = metadata.data_type in [IDSDataType.STRUCT_ARRAY, IDSDataType.STRUCTURE, None] if is_outline_static or is_outline_rz: - results.append(metadata.path_string) + if (is_structure and show_structures) or not is_structure: + results.append(metadata.path_string) + + for child in metadata: + is_error_node = any( + error_node in child.name for error_node in ["_error_upper", "_error_lower", "_error_index"] + ) + if show_error_bars or not is_error_node: + results.append(child.path_string) for child in metadata: - _walk_outline_nodes(child, results) + _walk_outline_nodes(child, results, show_error_bars, show_structures) # ============ END HELPER FUNCTION ============ with self._open_entry(uri) as entry: ids_obj = self._get_ids_from_entry(entry, ids, occurrence) outline_nodes = [] - _walk_outline_nodes(ids_obj.metadata, outline_nodes) + _walk_outline_nodes(ids_obj.metadata, outline_nodes, show_error_bars, show_structures) - try: - filled_paths = entry.list_filled_paths(ids, int(occurrence)) - outline_nodes = list(set(outline_nodes) & {path.rstrip("/") for path in filled_paths}) - except (AttributeError, imas.backends.imas_core.imas_interface.LLInterfaceError): - ... - # AttributeError - current version of IMAS-Python doesn't support list_filled paths - # LLInterfaceError - current version of IMAS-Core doesn't support list_filled paths - # proceed + if not show_empty_nodes: + try: + filled_paths = entry.list_filled_paths(ids, int(occurrence)) + outline_nodes = list(set(outline_nodes) & {path.rstrip("/") for path in filled_paths}) + except (AttributeError, imas.backends.imas_core.imas_interface.LLInterfaceError): + ... + # AttributeError - current version of IMAS-Python doesn't support list_filled paths + # LLInterfaceError - current version of IMAS-Core doesn't support list_filled paths + # proceed return {"outline_nodes": outline_nodes} def get_plot_data( diff --git a/backend/ibex/endpoints/ids_info.py b/backend/ibex/endpoints/ids_info.py index 39dfdeb2..ed4d8c1c 100644 --- a/backend/ibex/endpoints/ids_info.py +++ b/backend/ibex/endpoints/ids_info.py @@ -125,7 +125,9 @@ def array_summary(uri: str) -> dict: description="Returns geometry overlay nodes metadata", ) @ibex_service.measure_execution_time -def geometry_overlay_nodes(uri: str, show_structures: bool = False) -> dict: +def geometry_overlay_nodes( + uri: str, show_empty_nodes: bool = False, show_error_bars: bool = False, show_structures: bool = False +) -> dict: """ IBEX endpoint. Returns paths to geometry overlay nodes. @@ -135,8 +137,16 @@ def geometry_overlay_nodes(uri: str, show_structures: bool = False) -> dict: | } :param uri: IMAS URI + :param show_empty_nodes: switch used to hide empty nodes + :param show_error_bars: switch used to hide _error* nodes + :param show_structures: switch used to include structure nodes :rtype: dict (automatically converted to JSON by FastAPI) :return: JSON response """ - return ibex_service.get_geometry_overlay_nodes(uri.strip(), show_structures=show_structures) + return ibex_service.get_geometry_overlay_nodes( + uri.strip(), + show_empty_nodes=show_empty_nodes, + show_error_bars=show_error_bars, + show_structures=show_structures, + ) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 6ac15a4b..309fe1ae 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -50,8 +50,13 @@ def entry_path(tmp_path_factory): entry.put(core_profiles) + # ===== for geometry overlay ===== wall = entry.factory.wall() wall.ids_properties.homogeneous_time = 1 + wall.description_2d.resize(1) + wall.description_2d[0].limiter.unit.resize(1) + wall.description_2d[0].limiter.unit[0].outline.r = [1.0] + wall.description_2d[0].limiter.unit[0].outline.z = [1.0] entry.put(wall) entry.close() diff --git a/backend/tests/test_ids_info_endpoints.py b/backend/tests/test_ids_info_endpoints.py index 1f1684e7..f46d4dea 100644 --- a/backend/tests/test_ids_info_endpoints.py +++ b/backend/tests/test_ids_info_endpoints.py @@ -108,33 +108,59 @@ def test_array_summary(entry_path): assert response.json()["mean"] == 3.0 -@pytest.mark.skipif(True, reason="Still to be finished") def test_geometry_overlay_nodes(entry_path): + + structure_nodes = [ + "description_2d/limiter/unit/outline", + "description_2d/vessel/unit/annular/outline_inner", + "description_2d/vessel/unit/annular/outline_outer", + "description_2d/vessel/unit/element/outline", + ] + + leaf_nodes = [ + "description_2d/limiter/unit/outline/r", + "description_2d/limiter/unit/outline/z", + "description_2d/vessel/unit/annular/outline_inner/r", + "description_2d/vessel/unit/annular/outline_inner/z", + "description_2d/vessel/unit/annular/outline_outer/r", + "description_2d/vessel/unit/annular/outline_outer/z", + "description_2d/vessel/unit/element/outline/r", + "description_2d/vessel/unit/element/outline/z", + ] + + error_bars = [f"{x}_error_upper" for x in leaf_nodes] + [f"{x}_error_lower" for x in leaf_nodes] + parameters = { "uri": f"imas:hdf5?path={entry_path}#wall", + "show_empty_nodes": True, + "show_error_bars": False, + "show_structures": False, } + + response = pytest.test_client.get("/ids_info/geometry_overlay_nodes", params=parameters) + assert response.status_code == 200 + assert sorted(response.json()["outline_nodes"]) == sorted(leaf_nodes) + + parameters["show_structures"] = True response = pytest.test_client.get("/ids_info/geometry_overlay_nodes", params=parameters) + assert response.status_code == 200 + assert sorted(response.json()["outline_nodes"]) == sorted(leaf_nodes + structure_nodes) + parameters["show_error_bars"] = True + response = pytest.test_client.get("/ids_info/geometry_overlay_nodes", params=parameters) assert response.status_code == 200 + assert sorted(response.json()["outline_nodes"]) == sorted(leaf_nodes + structure_nodes + error_bars) - if Version(imas_core.__version__) < Version("5.7"): - assert response.json() == { - "outline_nodes": [ - "description_2d/limiter/unit/outline", - "description_2d/vessel/unit/annular/outline_inner", - "description_2d/vessel/unit/annular/outline_outer", - "description_2d/vessel/unit/element/outline", - ] - } - else: - assert response.json() == { - "outline_nodes": [ - "description_2d/limiter/unit/outline", - "description_2d/vessel/unit/annular/outline_inner", - "description_2d/vessel/unit/annular/outline_outer", - "description_2d/vessel/unit/element/outline", - ] - } + if Version(imas_core.__version__) >= Version("5.7"): + parameters["show_error_bars"] = False + parameters["show_empty_nodes"] = False + parameters["show_structures"] = False + response = pytest.test_client.get("/ids_info/geometry_overlay_nodes", params=parameters) + assert response.status_code == 200 + assert sorted(response.json()["outline_nodes"]) == [ + "description_2d/limiter/unit/outline/r", + "description_2d/limiter/unit/outline/z", + ] def test_show_error_bars_option(entry_path): From 482c6a7f1f455def2bad262858a0f249f9bf8334 Mon Sep 17 00:00:00 2001 From: wasikj Date: Tue, 2 Jun 2026 08:12:52 +0200 Subject: [PATCH 04/14] Fix broken test --- backend/tests/test_ids_info_endpoints.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/test_ids_info_endpoints.py b/backend/tests/test_ids_info_endpoints.py index 7c1129b4..a51bc1be 100644 --- a/backend/tests/test_ids_info_endpoints.py +++ b/backend/tests/test_ids_info_endpoints.py @@ -153,7 +153,7 @@ def test_geometry_overlay_nodes(entry_path): assert response.status_code == 200 assert sorted(response.json()["outline_nodes"]) == sorted(leaf_nodes + structure_nodes + error_bars) - if Version(imas_core.__version__) >= Version("5.7"): + if Version(imas_core.__version__) >= Version("5.7") and Version(imas.__version__) >= Version("2.2.2"): parameters["show_error_bars"] = False parameters["show_empty_nodes"] = False parameters["show_structures"] = False From ad769d5faa010c552f1e0d43462026de7463e991 Mon Sep 17 00:00:00 2001 From: wasikj Date: Mon, 8 Jun 2026 13:04:13 +0200 Subject: [PATCH 05/14] Alter geometry_nodes output --- backend/ibex/core/ibex_service.py | 11 +- .../ibex/data_source/data_source_interface.py | 20 ++-- .../ibex/data_source/imas_python_source.py | 113 +++++++++++++----- backend/ibex/endpoints/ids_info.py | 10 +- .../endpoints/schemas/ids_info_schemas.py | 15 ++- backend/pyproject.toml | 1 + backend/tests/test_ids_info_endpoints.py | 62 +++++----- 7 files changed, 145 insertions(+), 87 deletions(-) diff --git a/backend/ibex/core/ibex_service.py b/backend/ibex/core/ibex_service.py index 9e95fc88..795cac8b 100644 --- a/backend/ibex/core/ibex_service.py +++ b/backend/ibex/core/ibex_service.py @@ -136,15 +136,8 @@ def get_plot_data( ) -def get_geometry_overlay_nodes( - uri: str, show_empty_nodes: bool = False, show_error_bars: bool = False, show_structures: bool = False -) -> dict: +def get_geometry_overlay_nodes(uri: str, show_empty_nodes: bool = False, show_error_bars: bool = False) -> dict: uri_obj = IMAS_URI(uri) return data_source.get_geometry_overlay_nodes( - uri=uri_obj.uri_entry_identifiers, - ids=uri_obj.ids_name, - occurrence=uri_obj.occurrence, - show_empty_nodes=show_empty_nodes, - show_error_bars=show_error_bars, - show_structures=show_structures, + uri=uri_obj.uri_entry_identifiers, show_empty_nodes=show_empty_nodes, show_error_bars=show_error_bars ) diff --git a/backend/ibex/data_source/data_source_interface.py b/backend/ibex/data_source/data_source_interface.py index 13ed58be..9f3dca73 100644 --- a/backend/ibex/data_source/data_source_interface.py +++ b/backend/ibex/data_source/data_source_interface.py @@ -153,20 +153,20 @@ def get_plot_data( def get_geometry_overlay_nodes( self, uri: str, - ids: str, - occurrence: int = 0, - show_error_nodes: bool = False, - show_structures: bool = False, + show_empty_nodes: bool = False, + show_error_bars: bool = False, ) -> dict: """ - Returns paths to filled geometry overlay nodes found in the entry metadata. + Returns paths to metadata nodes that describe geometry overlays. + + A node is included when: + - its type is ``outline_2d_geometry_static``, or + - its name contains ``outline`` and its type is ``rz1d_static`` or ``rz1d_dynamic_aos``. + Error bar nodes are filtered out by default and can be included with ``show_error_bars=True``. :param uri: imas URI - :param ids: name of ids e.g. core_profiles - :param occurrence: ids occurrence number :param show_empty_nodes: whether empty nodes should be returned, or not - :param show_error_nodes: whether error nodes should be returned, or not - :param show_structures: whether structure nodes should be returned, or not - :return: dictionary {'outline_nodes': ['path/to/node1', 'path/to/node2', ...]} + :param show_error_bars: whether error bar nodes should be returned, or not + :return: dictionary {'outline_nodes': [{'geometry_node': '...', 'parameters': [...]}, ...]} """ ... diff --git a/backend/ibex/data_source/imas_python_source.py b/backend/ibex/data_source/imas_python_source.py index 92e35be4..ca485fd2 100644 --- a/backend/ibex/data_source/imas_python_source.py +++ b/backend/ibex/data_source/imas_python_source.py @@ -631,11 +631,8 @@ def _check_data_is_leaf_node(self, data) -> None: def get_geometry_overlay_nodes( self, uri: str, - ids: str, - occurrence: int = 0, show_empty_nodes: bool = False, show_error_bars: bool = False, - show_structures: bool = False, ) -> dict: """ Returns paths to metadata nodes that describe geometry overlays. @@ -643,63 +640,119 @@ def get_geometry_overlay_nodes( A node is included when: - its type is ``outline_2d_geometry_static``, or - its name contains ``outline`` and its type is ``rz1d_static`` or ``rz1d_dynamic_aos``. - Error nodes are filtered out by default and can be included with ``show_error_nodes=True``. - Structure nodes are filtered out by default and can be included with ``show_structures=True``. + Error bar nodes are filtered out by default and can be included with ``show_error_bars=True``. :param uri: imas URI - :param ids: name of ids e.g. core_profiles - :param occurrence: ids occurrence number :param show_empty_nodes: whether empty nodes should be returned, or not - :param show_error_nodes: whether error nodes should be returned, or not - :param show_structures: whether structure nodes should be returned, or not - :return: dictionary {'outline_nodes': ['path/to/node1', 'path/to/node2', ...]} + :param show_error_bars: whether error bar nodes should be returned, or not + :return: dictionary {'outline_nodes': [{'geometry_node': '...', 'parameters': [...]}, ...]} """ # ============ HELPER FUNCTION ============ def _walk_outline_nodes( + uri: str, + ids: str, + occurrence: int, + root_metadata: IDSMetadata, metadata: IDSMetadata, - results: list[str], + results: list[dict[str, list[str]]], show_error_bars: bool = False, - show_structures: bool = False, + filled_paths: list[str] | None = None, ) -> None: + """ + Recursively traverses IDS metadata tree and collects nodes describing geometry overlays. + + A node is collected when its type is ``outline_2d_geometry_static`` + or when its name contains ``outline`` and its type is ``rz1d_static`` or ``rz1d_dynamic_aos``. + + :param uri: imas URI + :param ids: name of IDS (e.g. core_profiles) + :param occurrence: IDS occurrence number + :param root_metadata: root metadata of the IDS (used to resolve tensorized paths) + :param metadata: current metadata node to inspect + :param results: list to which collected geometry overlay entries are appended + :param show_error_bars: whether to include error bar parameter names (e.g. ``_error_upper``) + :param filled_paths: optional list of filled paths; when given, only nodes with filled parameters are collected + """ node_name = metadata.name node_type = getattr(metadata, "structure_reference", None) is_outline_static = node_type == "outline_2d_geometry_static" is_outline_rz = "outline" in node_name and node_type in {"rz1d_static", "rz1d_dynamic_aos"} - is_structure = metadata.data_type in [IDSDataType.STRUCT_ARRAY, IDSDataType.STRUCTURE, None] if is_outline_static or is_outline_rz: - if (is_structure and show_structures) or not is_structure: - results.append(metadata.path_string) + tensorized_path = self._add_index_to_aos_in_path(root_metadata, metadata.path_string) + full_uri_with_path = f"{uri}#{ids}:{occurrence}/{tensorized_path}" + parameters_entry = {"geometry_node": full_uri_with_path, "parameters": []} for child in metadata: is_error_node = any( error_node in child.name for error_node in ["_error_upper", "_error_lower", "_error_index"] ) if show_error_bars or not is_error_node: - results.append(child.path_string) + parameters_entry["parameters"].append(child.name) + + if filled_paths is not None: + node_filled = any( + f"{metadata.path_string}/{parameter}" in filled_paths + for parameter in parameters_entry["parameters"] + ) + + if node_filled: + results.append(parameters_entry) + else: + results.append(parameters_entry) for child in metadata: - _walk_outline_nodes(child, results, show_error_bars, show_structures) + _walk_outline_nodes( + uri=uri, + ids=ids, + occurrence=occurrence, + root_metadata=root_metadata, + metadata=child, + results=results, + show_error_bars=show_error_bars, + filled_paths=filled_paths, + ) # ============ END HELPER FUNCTION ============ + # Iterate over all filled IDSes and their occurrences to collect geometry overlay nodes + filled_idses = self.list_idses(uri)["idses"] + with self._open_entry(uri) as entry: - ids_obj = self._get_ids_from_entry(entry, ids, occurrence) - outline_nodes = [] - _walk_outline_nodes(ids_obj.metadata, outline_nodes, show_error_bars, show_structures) + result = [] - if not show_empty_nodes: - try: - filled_paths = entry.list_filled_paths(ids, int(occurrence)) - outline_nodes = list(set(outline_nodes) & {path.rstrip("/") for path in filled_paths}) - except (AttributeError, imas.backends.imas_core.imas_interface.LLInterfaceError): - ... - # AttributeError - current version of IMAS-Python doesn't support list_filled paths - # LLInterfaceError - current version of IMAS-Core doesn't support list_filled paths - # proceed - return {"outline_nodes": outline_nodes} + for ids_dict in filled_idses: + # ids_dict = {'name': < name >, 'occurrences': [ < 0 >, < 1 >, ...]} + for occurrence in ids_dict["occurrences"]: + ids_obj = self._get_ids_from_entry(entry, ids_dict["name"], occurrence) + outline_nodes = [] + + filled_paths = None + if not show_empty_nodes: + try: + filled_paths = entry.list_filled_paths(ids_dict["name"], int(occurrence)) + except (AttributeError, imas.backends.imas_core.imas_interface.LLInterfaceError): + # AttributeError - current version of IMAS-Python doesn't support list_filled paths + # LLInterfaceError - current version of IMAS-Core doesn't support list_filled paths + # proceed without filtering empty nodes + ... + + _walk_outline_nodes( + uri=uri, + ids=ids_dict["name"], + occurrence=occurrence, + root_metadata=ids_obj.metadata, + metadata=ids_obj.metadata, + results=outline_nodes, + show_error_bars=show_error_bars, + filled_paths=filled_paths, + ) + + result.extend(outline_nodes) + + return {"outline_nodes": result} def get_plot_data( self, diff --git a/backend/ibex/endpoints/ids_info.py b/backend/ibex/endpoints/ids_info.py index ed4d8c1c..a7986f43 100644 --- a/backend/ibex/endpoints/ids_info.py +++ b/backend/ibex/endpoints/ids_info.py @@ -125,9 +125,7 @@ def array_summary(uri: str) -> dict: description="Returns geometry overlay nodes metadata", ) @ibex_service.measure_execution_time -def geometry_overlay_nodes( - uri: str, show_empty_nodes: bool = False, show_error_bars: bool = False, show_structures: bool = False -) -> dict: +def geometry_overlay_nodes(uri: str, show_empty_nodes: bool = False, show_error_bars: bool = False) -> dict: """ IBEX endpoint. Returns paths to geometry overlay nodes. @@ -139,14 +137,10 @@ def geometry_overlay_nodes( :param uri: IMAS URI :param show_empty_nodes: switch used to hide empty nodes :param show_error_bars: switch used to hide _error* nodes - :param show_structures: switch used to include structure nodes :rtype: dict (automatically converted to JSON by FastAPI) :return: JSON response """ return ibex_service.get_geometry_overlay_nodes( - uri.strip(), - show_empty_nodes=show_empty_nodes, - show_error_bars=show_error_bars, - show_structures=show_structures, + uri.strip(), show_empty_nodes=show_empty_nodes, show_error_bars=show_error_bars ) diff --git a/backend/ibex/endpoints/schemas/ids_info_schemas.py b/backend/ibex/endpoints/schemas/ids_info_schemas.py index 5e1e00b9..16395015 100644 --- a/backend/ibex/endpoints/schemas/ids_info_schemas.py +++ b/backend/ibex/endpoints/schemas/ids_info_schemas.py @@ -61,10 +61,19 @@ class ArraySummaryResponse(BaseModel): # ========== GEOMETRY OVERLAY NODES ========== +class GeometryOverlayNodesSingleEntry(BaseModel): + """Response for /ids_info/geometry_overlay_nodes endpoint""" + + geometry_node: str = Field( + description="Full uri pointing to a specific geometry node structure", + examples=["imas:hdf5?path=#equilibrium/time_slic[:]/profiles_2d[:]/psi"], + ) + parameters: list[str] = Field(description="Name of child node of geometry_node", examples=["r", "z", "width"]) + + class GeometryOverlayNodesResponse(BaseModel): """Response for /ids_info/geometry_overlay_nodes endpoint""" - outline_nodes: list[str] = Field( - description="List of filled geometry overlay node paths", - examples=[["description_2d/limiter/unit/outline", "description_2d/vessel/unit/element/outline"]], + outline_nodes: list[GeometryOverlayNodesSingleEntry] = Field( + description="List of dicts describing geometry overlay node paths", ) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index b1fb9655..27dca9b5 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -66,6 +66,7 @@ test = [ "pytest", "pytest-cov", "pytest-xdist", + "pytest-unordered", ] linting = [ diff --git a/backend/tests/test_ids_info_endpoints.py b/backend/tests/test_ids_info_endpoints.py index a51bc1be..bf2f470e 100644 --- a/backend/tests/test_ids_info_endpoints.py +++ b/backend/tests/test_ids_info_endpoints.py @@ -2,6 +2,7 @@ import imas_core import imas from packaging.version import Version +from pytest_unordered import unordered def test_node_info_coordinates(entry_path): @@ -86,7 +87,6 @@ def test_find_paths(entry_path): has_data_true = None # before AL-Core 5.7 IBEX returns None else: has_data_true = True - print(response.json()["paths"]) assert response.json()["paths"] == [ {"path": "#core_profiles/ids_properties/version_put/data_dictionary", "has_data": has_data_true}, {"path": "#core_profiles/ids_properties/version_put/access_layer", "has_data": has_data_true}, @@ -112,28 +112,44 @@ def test_array_summary(entry_path): def test_geometry_overlay_nodes(entry_path): + entry_uri = f"imas:hdf5?path={entry_path}#wall:0" structure_nodes = [ - "description_2d/limiter/unit/outline", - "description_2d/vessel/unit/annular/outline_inner", - "description_2d/vessel/unit/annular/outline_outer", - "description_2d/vessel/unit/element/outline", + f"{entry_uri}/description_2d[:]/limiter/unit[:]/outline", + f"{entry_uri}/description_2d[:]/vessel/unit[:]/annular/outline_inner", + f"{entry_uri}/description_2d[:]/vessel/unit[:]/annular/outline_outer", + f"{entry_uri}/description_2d[:]/vessel/unit[:]/element[:]/outline", ] - leaf_nodes = [ - "description_2d/limiter/unit/outline/r", - "description_2d/limiter/unit/outline/z", - "description_2d/vessel/unit/annular/outline_inner/r", - "description_2d/vessel/unit/annular/outline_inner/z", - "description_2d/vessel/unit/annular/outline_outer/r", - "description_2d/vessel/unit/annular/outline_outer/z", - "description_2d/vessel/unit/element/outline/r", - "description_2d/vessel/unit/element/outline/z", - ] + leaf_nodes = ["r", "z"] error_bars = [f"{x}_error_upper" for x in leaf_nodes] + [f"{x}_error_lower" for x in leaf_nodes] + expected_result_no_error_bars = { + "outline_nodes": unordered( + [{"geometry_node": stucture_node, "parameters": unordered(leaf_nodes)} for stucture_node in structure_nodes] + ) + } + expected_result_with_error_bars = { + "outline_nodes": unordered( + [ + {"geometry_node": stucture_node, "parameters": unordered(leaf_nodes + error_bars)} + for stucture_node in structure_nodes + ] + ) + } + expected_result_only_filled_nodes = { + "outline_nodes": unordered( + [ + { + "geometry_node": "imas:hdf5?path=/home/ITER/wasikj/Desktop/work/IBEX/testdb_pytest#wall:0/description_2d[:]/limiter/unit[:]/outline", + "parameters": unordered(["r", "z"]), + } + ] + ) + } + parameters = { - "uri": f"imas:hdf5?path={entry_path}#wall", + "uri": f"imas:hdf5?path={entry_path}", "show_empty_nodes": True, "show_error_bars": False, "show_structures": False, @@ -141,17 +157,12 @@ def test_geometry_overlay_nodes(entry_path): response = pytest.test_client.get("/ids_info/geometry_overlay_nodes", params=parameters) assert response.status_code == 200 - assert sorted(response.json()["outline_nodes"]) == sorted(leaf_nodes) - - parameters["show_structures"] = True - response = pytest.test_client.get("/ids_info/geometry_overlay_nodes", params=parameters) - assert response.status_code == 200 - assert sorted(response.json()["outline_nodes"]) == sorted(leaf_nodes + structure_nodes) + assert response.json() == expected_result_no_error_bars parameters["show_error_bars"] = True response = pytest.test_client.get("/ids_info/geometry_overlay_nodes", params=parameters) assert response.status_code == 200 - assert sorted(response.json()["outline_nodes"]) == sorted(leaf_nodes + structure_nodes + error_bars) + assert response.json() == expected_result_with_error_bars if Version(imas_core.__version__) >= Version("5.7") and Version(imas.__version__) >= Version("2.2.2"): parameters["show_error_bars"] = False @@ -159,10 +170,7 @@ def test_geometry_overlay_nodes(entry_path): parameters["show_structures"] = False response = pytest.test_client.get("/ids_info/geometry_overlay_nodes", params=parameters) assert response.status_code == 200 - assert sorted(response.json()["outline_nodes"]) == [ - "description_2d/limiter/unit/outline/r", - "description_2d/limiter/unit/outline/z", - ] + assert response.json() == expected_result_only_filled_nodes def test_show_error_bars_option(entry_path): From 77447a97d6e4e341ead1a38526d4c819dd2c7f81 Mon Sep 17 00:00:00 2001 From: wasikj Date: Tue, 9 Jun 2026 08:12:08 +0200 Subject: [PATCH 06/14] Geometry overlay nodes update --- .../ibex/data_source/imas_python_source.py | 60 ++++++++++++------- 1 file changed, 40 insertions(+), 20 deletions(-) diff --git a/backend/ibex/data_source/imas_python_source.py b/backend/ibex/data_source/imas_python_source.py index ca485fd2..e334f166 100644 --- a/backend/ibex/data_source/imas_python_source.py +++ b/backend/ibex/data_source/imas_python_source.py @@ -657,6 +657,7 @@ def _walk_outline_nodes( metadata: IDSMetadata, results: list[dict[str, list[str]]], show_error_bars: bool = False, + ignore_check: bool = False, filled_paths: list[str] | None = None, ) -> None: """ @@ -672,13 +673,16 @@ def _walk_outline_nodes( :param metadata: current metadata node to inspect :param results: list to which collected geometry overlay entries are appended :param show_error_bars: whether to include error bar parameter names (e.g. ``_error_upper``) + :param ignore_check: whether to ignore checking node for structure reference, or node_type :param filled_paths: optional list of filled paths; when given, only nodes with filled parameters are collected """ node_name = metadata.name node_type = getattr(metadata, "structure_reference", None) - is_outline_static = node_type == "outline_2d_geometry_static" - is_outline_rz = "outline" in node_name and node_type in {"rz1d_static", "rz1d_dynamic_aos"} + is_outline_static = (node_type == "outline_2d_geometry_static") or ignore_check + is_outline_rz = ( + "outline" in node_name and node_type in {"rz1d_static", "rz1d_dynamic_aos"} + ) or ignore_check if is_outline_static or is_outline_rz: tensorized_path = self._add_index_to_aos_in_path(root_metadata, metadata.path_string) @@ -686,11 +690,26 @@ def _walk_outline_nodes( parameters_entry = {"geometry_node": full_uri_with_path, "parameters": []} for child in metadata: - is_error_node = any( - error_node in child.name for error_node in ["_error_upper", "_error_lower", "_error_index"] - ) - if show_error_bars or not is_error_node: - parameters_entry["parameters"].append(child.name) + child_node_type = getattr(child, "structure_reference", None) + + if child_node_type is not None: + _walk_outline_nodes( + uri=uri, + ids=ids, + occurrence=occurrence, + root_metadata=root_metadata, + metadata=child, + results=results, + show_error_bars=show_error_bars, + ignore_check=True, + filled_paths=filled_paths, + ) + else: + is_error_node = any( + error_node in child.name for error_node in ["_error_upper", "_error_lower", "_error_index"] + ) + if (show_error_bars or not is_error_node) and child.name != "geometry_type": + parameters_entry["parameters"].append(child.name) if filled_paths is not None: node_filled = any( @@ -698,22 +717,23 @@ def _walk_outline_nodes( for parameter in parameters_entry["parameters"] ) - if node_filled: + if node_filled and parameters_entry["parameters"]: # don't put structures with empty "parameters" results.append(parameters_entry) - else: + elif parameters_entry["parameters"]: # don't put structures with empty "parameters" results.append(parameters_entry) - for child in metadata: - _walk_outline_nodes( - uri=uri, - ids=ids, - occurrence=occurrence, - root_metadata=root_metadata, - metadata=child, - results=results, - show_error_bars=show_error_bars, - filled_paths=filled_paths, - ) + else: + for child in metadata: + _walk_outline_nodes( + uri=uri, + ids=ids, + occurrence=occurrence, + root_metadata=root_metadata, + metadata=child, + results=results, + show_error_bars=show_error_bars, + filled_paths=filled_paths, + ) # ============ END HELPER FUNCTION ============ From cd4f8ee1f7799cc3e2f2c4231a652ecc8da8ede0 Mon Sep 17 00:00:00 2001 From: wasikj Date: Tue, 9 Jun 2026 08:30:44 +0200 Subject: [PATCH 07/14] Do not hide geometry type --- backend/ibex/data_source/imas_python_source.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/ibex/data_source/imas_python_source.py b/backend/ibex/data_source/imas_python_source.py index e334f166..8048dae0 100644 --- a/backend/ibex/data_source/imas_python_source.py +++ b/backend/ibex/data_source/imas_python_source.py @@ -708,7 +708,7 @@ def _walk_outline_nodes( is_error_node = any( error_node in child.name for error_node in ["_error_upper", "_error_lower", "_error_index"] ) - if (show_error_bars or not is_error_node) and child.name != "geometry_type": + if (show_error_bars or not is_error_node): parameters_entry["parameters"].append(child.name) if filled_paths is not None: From 7061c8d90b3bba451b80b9d5bb6d8d1218c4c0ef Mon Sep 17 00:00:00 2001 From: wasikj Date: Tue, 9 Jun 2026 08:33:15 +0200 Subject: [PATCH 08/14] Apply linter --- backend/ibex/data_source/imas_python_source.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/ibex/data_source/imas_python_source.py b/backend/ibex/data_source/imas_python_source.py index 8048dae0..272d91d0 100644 --- a/backend/ibex/data_source/imas_python_source.py +++ b/backend/ibex/data_source/imas_python_source.py @@ -708,7 +708,7 @@ def _walk_outline_nodes( is_error_node = any( error_node in child.name for error_node in ["_error_upper", "_error_lower", "_error_index"] ) - if (show_error_bars or not is_error_node): + if show_error_bars or not is_error_node: parameters_entry["parameters"].append(child.name) if filled_paths is not None: From f5809d7c2fe22ebd0e1a1bb17b9bd5bf36adb35b Mon Sep 17 00:00:00 2001 From: wasikj Date: Tue, 9 Jun 2026 10:31:58 +0200 Subject: [PATCH 09/14] Add geometry info to ids_info/node_info/ --- .../ibex/data_source/imas_python_source.py | 26 ++++++++++++++- .../endpoints/schemas/ids_info_schemas.py | 2 ++ backend/tests/test_ids_info_endpoints.py | 33 +++++++++++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/backend/ibex/data_source/imas_python_source.py b/backend/ibex/data_source/imas_python_source.py index 272d91d0..82d95320 100644 --- a/backend/ibex/data_source/imas_python_source.py +++ b/backend/ibex/data_source/imas_python_source.py @@ -160,18 +160,42 @@ def _jsonify_metadata(self, metadata: IDSMetadata, recursive: bool = False, show result["type"] = metadata.data_type or "structure" result["ndim"] = metadata.ndim result["shape"] = [] # empty for 0D data + result["is_geometry_node"] = self._is_geometry_node(metadata) if recursive: result["children"] = [self._jsonify_metadata(child, recursive) for child in metadata] else: result["children"] = [ - {"name": child.name, "type": child.data_type, "ndim": child.ndim} + { + "name": child.name, + "type": child.data_type, + "ndim": child.ndim, + "is_geometry_node": self._is_geometry_node(child), + } for child in metadata if show_error_bars or not any(x in child.name for x in ["_error_upper", "_error_lower", "_error_index"]) ] return result + def _is_geometry_node(self, metadata: IDSMetadata): + """ + Checks if node lies inside geometry structure + :param metadata: metadata of ids node + :return: + """ + if metadata is None: + return False + + node_type = getattr(metadata, "structure_reference", None) + is_outline_static = node_type == "outline_2d_geometry_static" + is_outline_rz = "outline" in metadata.name and node_type in {"rz1d_static", "rz1d_dynamic_aos"} + + if is_outline_rz or is_outline_static: + return True + + return self._is_geometry_node(metadata._parent) + def get_node_info( self, uri: str, diff --git a/backend/ibex/endpoints/schemas/ids_info_schemas.py b/backend/ibex/endpoints/schemas/ids_info_schemas.py index 16395015..4d4f8173 100644 --- a/backend/ibex/endpoints/schemas/ids_info_schemas.py +++ b/backend/ibex/endpoints/schemas/ids_info_schemas.py @@ -10,6 +10,7 @@ class NodeInfoChildModel(BaseModel): name: str = Field(description="Node name", examples=["t_i_average", "psi"]) type: str = Field(description="Node type", examples=["FLT", "STR"]) ndim: int = Field(description="Number of data dimensions stored in node", examples=[1, 5, 7]) + is_geometry_node: bool = Field(description="True if node is inside geometry structure", examples=[True, False]) has_data: Optional[bool] = Field(default=None, description="True if node contains data", examples=[True, False]) @@ -20,6 +21,7 @@ class NodeInfoResponse(BaseModel): type: str = Field(description="Node type", examples=["struct_array", "FLT", "STR"]) ndim: int = Field(description="Number of data dimensions stored in node", examples=[1, 5, 7]) shape: list[int] = Field(description="Shape of the data", examples=[[3], [5], [2, 5, 10]]) + is_geometry_node: bool = Field(description="True if node is inside geometry structure", examples=[True, False]) has_data: Optional[bool] = Field(default=None, description="True if node contains data", examples=[True, False]) children: list[NodeInfoChildModel] = Field(description="Node info about node's children") coordinates: list[str] = Field( diff --git a/backend/tests/test_ids_info_endpoints.py b/backend/tests/test_ids_info_endpoints.py index bf2f470e..7a049fb7 100644 --- a/backend/tests/test_ids_info_endpoints.py +++ b/backend/tests/test_ids_info_endpoints.py @@ -25,6 +25,39 @@ def test_node_info_coordinates(entry_path): ) +def test_node_info_geometry_nodes(entry_path): + + response = pytest.test_client.get( + "/ids_info/node_info", + params={ + "uri": f"imas:hdf5?path={entry_path}#core_profiles/profiles_2d[:]", + }, + ) + + assert response.status_code == 200 + assert not response.json()["is_geometry_node"] + + response = pytest.test_client.get( + "/ids_info/node_info", + params={ + "uri": f"imas:hdf5?path={entry_path}#wall/description_2d[:]/limiter/unit[:]/outline", + }, + ) + + assert response.status_code == 200 + assert response.json()["is_geometry_node"] + + response = pytest.test_client.get( + "/ids_info/node_info", + params={ + "uri": f"imas:hdf5?path={entry_path}#wall/description_2d[:]/limiter/unit[:]/outline/r", + }, + ) + + assert response.status_code == 200 + assert response.json()["is_geometry_node"] + + def test_node_info_empty_path(entry_path): parameters = { "uri": f"imas:hdf5?path={entry_path}#core_profiles", From 58a59e6325572caaa69754d485a5a4f842b14bc6 Mon Sep 17 00:00:00 2001 From: wasikj Date: Thu, 11 Jun 2026 13:00:43 +0200 Subject: [PATCH 10/14] Add is_geometry_node key to find_paths response --- .../ibex/data_source/imas_python_source.py | 9 ++++-- .../endpoints/schemas/ids_info_schemas.py | 1 + backend/tests/test_ids_info_endpoints.py | 32 +++++++++++++++---- 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/backend/ibex/data_source/imas_python_source.py b/backend/ibex/data_source/imas_python_source.py index 82d95320..ffe0319e 100644 --- a/backend/ibex/data_source/imas_python_source.py +++ b/backend/ibex/data_source/imas_python_source.py @@ -536,12 +536,17 @@ def find_paths(self, uri: str, searched_node: str, show_error_bars: bool = False node_data_type = ids_obj.metadata[path].data_type if node_data_type.value != "structure" and node_data_type.value != "struct_array": path_name = f"#{ids}/{self._add_index_to_aos_in_path(ids_obj.metadata, path)}" + is_geometry_node = self._is_geometry_node(ids_obj.metadata[path]) if not filled_paths: # every ids has at least one filled path. If not, it means functionality is not available. - found_paths.append({"path": path_name, "has_data": None}) + found_paths.append( + {"path": path_name, "has_data": None, "is_geometry_node": is_geometry_node} + ) else: path_has_data = path_in_filled_paths(path, filled_paths) - found_paths.append({"path": path_name, "has_data": path_has_data}) + found_paths.append( + {"path": path_name, "has_data": path_has_data, "is_geometry_node": is_geometry_node} + ) except imas.exception.DataEntryException: continue diff --git a/backend/ibex/endpoints/schemas/ids_info_schemas.py b/backend/ibex/endpoints/schemas/ids_info_schemas.py index 4d4f8173..2a087c0b 100644 --- a/backend/ibex/endpoints/schemas/ids_info_schemas.py +++ b/backend/ibex/endpoints/schemas/ids_info_schemas.py @@ -37,6 +37,7 @@ class FoundPathModel(BaseModel): path: str = Field(description="", examples=["t_i_average", "path"]) has_data: Optional[bool] = Field(default=None, description="True if node contains data", examples=[True, False]) + is_geometry_node: bool = Field(description="True if node contains geometry data", examples=[True, False]) class FindPathsResponse(BaseModel): diff --git a/backend/tests/test_ids_info_endpoints.py b/backend/tests/test_ids_info_endpoints.py index 7a049fb7..371fca20 100644 --- a/backend/tests/test_ids_info_endpoints.py +++ b/backend/tests/test_ids_info_endpoints.py @@ -121,12 +121,32 @@ def test_find_paths(entry_path): else: has_data_true = True assert response.json()["paths"] == [ - {"path": "#core_profiles/ids_properties/version_put/data_dictionary", "has_data": has_data_true}, - {"path": "#core_profiles/ids_properties/version_put/access_layer", "has_data": has_data_true}, - {"path": "#core_profiles/ids_properties/version_put/access_layer_language", "has_data": has_data_true}, - {"path": "#wall/ids_properties/version_put/data_dictionary", "has_data": has_data_true}, - {"path": "#wall/ids_properties/version_put/access_layer", "has_data": has_data_true}, - {"path": "#wall/ids_properties/version_put/access_layer_language", "has_data": has_data_true}, + { + "path": "#core_profiles/ids_properties/version_put/data_dictionary", + "has_data": has_data_true, + "is_geometry_node": False, + }, + { + "path": "#core_profiles/ids_properties/version_put/access_layer", + "has_data": has_data_true, + "is_geometry_node": False, + }, + { + "path": "#core_profiles/ids_properties/version_put/access_layer_language", + "has_data": has_data_true, + "is_geometry_node": False, + }, + { + "path": "#wall/ids_properties/version_put/data_dictionary", + "has_data": has_data_true, + "is_geometry_node": False, + }, + {"path": "#wall/ids_properties/version_put/access_layer", "has_data": has_data_true, "is_geometry_node": False}, + { + "path": "#wall/ids_properties/version_put/access_layer_language", + "has_data": has_data_true, + "is_geometry_node": False, + }, ] From e05c42607e7c018efbffceaa29c812441dae2a4d Mon Sep 17 00:00:00 2001 From: wasikj Date: Fri, 12 Jun 2026 12:31:41 +0200 Subject: [PATCH 11/14] Alter geometry_nodes response structure --- .../ibex/data_source/imas_python_source.py | 31 +++++++------------ 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/backend/ibex/data_source/imas_python_source.py b/backend/ibex/data_source/imas_python_source.py index ffe0319e..0a5c1611 100644 --- a/backend/ibex/data_source/imas_python_source.py +++ b/backend/ibex/data_source/imas_python_source.py @@ -686,7 +686,6 @@ def _walk_outline_nodes( metadata: IDSMetadata, results: list[dict[str, list[str]]], show_error_bars: bool = False, - ignore_check: bool = False, filled_paths: list[str] | None = None, ) -> None: """ @@ -702,16 +701,13 @@ def _walk_outline_nodes( :param metadata: current metadata node to inspect :param results: list to which collected geometry overlay entries are appended :param show_error_bars: whether to include error bar parameter names (e.g. ``_error_upper``) - :param ignore_check: whether to ignore checking node for structure reference, or node_type :param filled_paths: optional list of filled paths; when given, only nodes with filled parameters are collected """ node_name = metadata.name node_type = getattr(metadata, "structure_reference", None) - is_outline_static = (node_type == "outline_2d_geometry_static") or ignore_check - is_outline_rz = ( - "outline" in node_name and node_type in {"rz1d_static", "rz1d_dynamic_aos"} - ) or ignore_check + is_outline_static = node_type == "outline_2d_geometry_static" + is_outline_rz = "outline" in node_name and node_type in {"rz1d_static", "rz1d_dynamic_aos"} if is_outline_static or is_outline_rz: tensorized_path = self._add_index_to_aos_in_path(root_metadata, metadata.path_string) @@ -719,20 +715,15 @@ def _walk_outline_nodes( parameters_entry = {"geometry_node": full_uri_with_path, "parameters": []} for child in metadata: - child_node_type = getattr(child, "structure_reference", None) - - if child_node_type is not None: - _walk_outline_nodes( - uri=uri, - ids=ids, - occurrence=occurrence, - root_metadata=root_metadata, - metadata=child, - results=results, - show_error_bars=show_error_bars, - ignore_check=True, - filled_paths=filled_paths, - ) + if child.data_type == IDSDataType.STRUCTURE: + for grand_child in child: + is_error_node = any( + error_node in grand_child.name + for error_node in ["_error_upper", "_error_lower", "_error_index"] + ) + if show_error_bars or not is_error_node: + parameters_entry["parameters"].append(f"{child.name}/{grand_child.name}") + else: is_error_node = any( error_node in child.name for error_node in ["_error_upper", "_error_lower", "_error_index"] From 45776b88e9c81d6d3de40e28cc566cd76c35c238 Mon Sep 17 00:00:00 2001 From: wasikj Date: Fri, 12 Jun 2026 15:16:30 +0200 Subject: [PATCH 12/14] Fix --- .../ibex/data_source/imas_python_source.py | 32 +++++++++++-------- backend/tests/conftest.py | 16 ++++++++-- backend/tests/test_ids_info_endpoints.py | 15 +++++---- 3 files changed, 40 insertions(+), 23 deletions(-) diff --git a/backend/ibex/data_source/imas_python_source.py b/backend/ibex/data_source/imas_python_source.py index 0a5c1611..9342dc1d 100644 --- a/backend/ibex/data_source/imas_python_source.py +++ b/backend/ibex/data_source/imas_python_source.py @@ -678,6 +678,16 @@ def get_geometry_overlay_nodes( """ # ============ HELPER FUNCTION ============ + def _get_descendant_node_names(metadata: IDSMetadata): + + res = [] + if metadata.data_type == IDSDataType.STRUCTURE: + for child in metadata: + res.extend([f"{metadata.name}/{x}" for x in _get_descendant_node_names(child)]) + else: + res.append(f"{metadata.name}") + return res + def _walk_outline_nodes( uri: str, ids: str, @@ -714,22 +724,16 @@ def _walk_outline_nodes( full_uri_with_path = f"{uri}#{ids}:{occurrence}/{tensorized_path}" parameters_entry = {"geometry_node": full_uri_with_path, "parameters": []} + params = [] for child in metadata: - if child.data_type == IDSDataType.STRUCTURE: - for grand_child in child: - is_error_node = any( - error_node in grand_child.name - for error_node in ["_error_upper", "_error_lower", "_error_index"] - ) - if show_error_bars or not is_error_node: - parameters_entry["parameters"].append(f"{child.name}/{grand_child.name}") + params.extend(_get_descendant_node_names(child)) - else: - is_error_node = any( - error_node in child.name for error_node in ["_error_upper", "_error_lower", "_error_index"] - ) - if show_error_bars or not is_error_node: - parameters_entry["parameters"].append(child.name) + for param in params: + is_error_node = any( + error_node in param for error_node in ["_error_upper", "_error_lower", "_error_index"] + ) + if show_error_bars or not is_error_node: + parameters_entry["parameters"].append(param) if filled_paths is not None: node_filled = any( diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index a5d736f2..f5c79f00 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -85,9 +85,11 @@ def entry_path(tmp_path_factory): for ion in profiles_2d.ion: ion.name = f"random ion name {i}" - ion.temperature = np.array([[i, +1, i + 2], [i + 10, i + 11, i + 12], [i + 20, i + 21, i + 32]]) - profiles_2d.grid.dim1 = np.array([0, 1, 2]) - profiles_2d.grid.dim2 = np.array([0, 1, 2]) + ion.temperature = np.array( + [[i, +1, i + 2], [i + 10, i + 11, i + 12], [i + 20, i + 21, i + 32]], dtype=float + ) + profiles_2d.grid.dim1 = np.array([0, 1, 2], dtype=float) + profiles_2d.grid.dim2 = np.array([0, 1, 2], dtype=float) i += 10 entry.put(core_profiles) @@ -101,6 +103,14 @@ def entry_path(tmp_path_factory): wall.description_2d[0].limiter.unit[0].outline.z = [1.0] entry.put(wall) + equilibrium = entry.factory.equilibrium() + equilibrium.ids_properties.homogeneous_time = 1 + equilibrium.time = np.array([1.0], dtype=float) + equilibrium.time_slice.resize(1) + equilibrium.time_slice[0].boundary.outline.r = np.array([1.0], dtype=float) + equilibrium.time_slice[0].boundary.outline.z = np.array([2.0], dtype=float) + entry.put(equilibrium) + entry.close() return tmp_path diff --git a/backend/tests/test_ids_info_endpoints.py b/backend/tests/test_ids_info_endpoints.py index 371fca20..b1b5019e 100644 --- a/backend/tests/test_ids_info_endpoints.py +++ b/backend/tests/test_ids_info_endpoints.py @@ -120,7 +120,7 @@ def test_find_paths(entry_path): has_data_true = None # before AL-Core 5.7 IBEX returns None else: has_data_true = True - assert response.json()["paths"] == [ + expected_paths = [ { "path": "#core_profiles/ids_properties/version_put/data_dictionary", "has_data": has_data_true, @@ -148,6 +148,8 @@ def test_find_paths(entry_path): "is_geometry_node": False, }, ] + for expected in expected_paths: + assert expected in response.json()["paths"] def test_array_summary(entry_path): @@ -165,12 +167,13 @@ def test_array_summary(entry_path): def test_geometry_overlay_nodes(entry_path): - entry_uri = f"imas:hdf5?path={entry_path}#wall:0" + entry_uri = f"imas:hdf5?path={entry_path}" structure_nodes = [ - f"{entry_uri}/description_2d[:]/limiter/unit[:]/outline", - f"{entry_uri}/description_2d[:]/vessel/unit[:]/annular/outline_inner", - f"{entry_uri}/description_2d[:]/vessel/unit[:]/annular/outline_outer", - f"{entry_uri}/description_2d[:]/vessel/unit[:]/element[:]/outline", + f"{entry_uri}#wall:0/description_2d[:]/limiter/unit[:]/outline", + f"{entry_uri}#wall:0/description_2d[:]/vessel/unit[:]/annular/outline_inner", + f"{entry_uri}#wall:0/description_2d[:]/vessel/unit[:]/annular/outline_outer", + f"{entry_uri}#wall:0/description_2d[:]/vessel/unit[:]/element[:]/outline", + f"{entry_uri}#equilibrium:0/time_slice[:]/boundary/outline", ] leaf_nodes = ["r", "z"] From 82e1a14f571eedac72da2258aa6b558509274f95 Mon Sep 17 00:00:00 2001 From: wasikj Date: Mon, 22 Jun 2026 13:45:01 +0200 Subject: [PATCH 13/14] Fix test --- backend/tests/test_ids_info_endpoints.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/test_ids_info_endpoints.py b/backend/tests/test_ids_info_endpoints.py index b1b5019e..0b6a7a1c 100644 --- a/backend/tests/test_ids_info_endpoints.py +++ b/backend/tests/test_ids_info_endpoints.py @@ -197,7 +197,7 @@ def test_geometry_overlay_nodes(entry_path): "outline_nodes": unordered( [ { - "geometry_node": "imas:hdf5?path=/home/ITER/wasikj/Desktop/work/IBEX/testdb_pytest#wall:0/description_2d[:]/limiter/unit[:]/outline", + "geometry_node": f"{entry_uri}#wall:0/description_2d[:]/limiter/unit[:]/outline", "parameters": unordered(["r", "z"]), } ] From 1694a1c6c69376d867293fd462db16208e1e2367 Mon Sep 17 00:00:00 2001 From: wasikj Date: Tue, 23 Jun 2026 08:14:51 +0200 Subject: [PATCH 14/14] Fix test --- backend/tests/test_ids_info_endpoints.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/backend/tests/test_ids_info_endpoints.py b/backend/tests/test_ids_info_endpoints.py index 0b6a7a1c..8ad86fed 100644 --- a/backend/tests/test_ids_info_endpoints.py +++ b/backend/tests/test_ids_info_endpoints.py @@ -93,7 +93,7 @@ def test_node_info_filled_paths(entry_path): "ids_properties": True, "profiles_1d": True, "profiles_2d": True, - "global_quantities": False, + "global_quantities": True, "time": True, } @@ -196,10 +196,17 @@ def test_geometry_overlay_nodes(entry_path): expected_result_only_filled_nodes = { "outline_nodes": unordered( [ + { + "geometry_node": f"{entry_uri}#equilibrium:0/time_slice[:]/boundary/outline", + "parameters": [ + "r", + "z", + ], + }, { "geometry_node": f"{entry_uri}#wall:0/description_2d[:]/limiter/unit[:]/outline", "parameters": unordered(["r", "z"]), - } + }, ] ) }