From 35fe80ec878bbaa31fefb3edfca4cce34e7e8620 Mon Sep 17 00:00:00 2001 From: prasad-sawantdesai Date: Tue, 28 Apr 2026 15:57:59 +0200 Subject: [PATCH 01/45] added endpoint for data retrieval --- src/simdb/remote/apis/v1_2/__init__.py | 3 +- src/simdb/remote/apis/v1_2/simulation_data.py | 233 ++++++++++++++++++ 2 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 src/simdb/remote/apis/v1_2/simulation_data.py diff --git a/src/simdb/remote/apis/v1_2/__init__.py b/src/simdb/remote/apis/v1_2/__init__.py index 920f303b..6b8bc01d 100644 --- a/src/simdb/remote/apis/v1_2/__init__.py +++ b/src/simdb/remote/apis/v1_2/__init__.py @@ -10,6 +10,7 @@ from simdb.remote.core.typing import current_app from simdb.remote.models import StagingDirectoryResponse +from .simulation_data import api as data_ns from .simulations import api as sim_ns api = Api( @@ -31,7 +32,7 @@ ) api.add_namespace(sim_ns) -namespaces = [metadata_ns, watcher_ns, file_ns, sim_ns] +namespaces = [metadata_ns, watcher_ns, file_ns, sim_ns, data_ns] @api.route("/staging_dir", defaults={"sim_hex": None}) diff --git a/src/simdb/remote/apis/v1_2/simulation_data.py b/src/simdb/remote/apis/v1_2/simulation_data.py new file mode 100644 index 00000000..482f17a4 --- /dev/null +++ b/src/simdb/remote/apis/v1_2/simulation_data.py @@ -0,0 +1,233 @@ +"""Simulation IMAS data endpoint: /data. + +TODO: Temporal solution to retrive data (Use IBEX backend) +""" + +import re +import uuid as _uuid +from typing import Any + +import numpy as np +from flask import request +from flask_restx import Namespace, Resource +from imas.ids_primitive import IDSPrimitive + +from simdb.cli.manifest import DataObject +from simdb.database import DatabaseError +from simdb.imas.utils import FLOAT_MISSING_VALUE, INT_MISSING_VALUE, ImasError, open_imas +from simdb.remote.core.auth import User, requires_auth +from simdb.remote.core.cache import cache +from simdb.remote.core.typing import current_app +from simdb.uri import URI + +api = Namespace("data", path="/") + + +# Helpers + + +def _to_python(value: Any) -> Any: + """Convert a value returned by IDSPrimitive.value to a JSON-serialisable + Python object.""" + if isinstance(value, np.ndarray): + flat = value.tolist() + + def _clean(v): + if isinstance(v, float) and ( + v != v or v == float("inf") or v == float("-inf") or v == FLOAT_MISSING_VALUE + ): + return None + if isinstance(v, list): + return [_clean(x) for x in v] + return v + + return _clean(flat) + if isinstance(value, np.integer): + v = int(value) + return None if v == INT_MISSING_VALUE else v + if isinstance(value, np.floating): + v = float(value) + return None if (np.isnan(v) or np.isinf(v) or v == FLOAT_MISSING_VALUE) else v + if isinstance(value, np.complexfloating): + return {"real": float(value.real), "imag": float(value.imag)} + if isinstance(value, np.bool_): + return bool(value) + return value + +# TODO Replace this logic with slicing when supported by imas-python. +# TODO Add support for [:], [:-1], and [2:4:2] python slicing syntax. +def _traverse_path(entry, ids_name: str, field_segments: list, occurrence: int): + """Walk *field_segments* inside *ids_name* and return (value, shape, coordinate_path). + + Each segment is either: + - a non-negative integer string → array-of-structures index + - a plain name → attribute access (IDSStructure child node) + """ + ids_obj = entry.get( + ids_name, occurrence, + lazy=True, autoconvert=False, ignore_unknown_dd_version=True, + ) + node = ids_obj + for segment in field_segments: + if segment.isdigit(): + node = node[int(segment)] + else: + try: + node = getattr(node, segment) + except AttributeError: + raise ValueError(f"segment '{segment}' not found in IDS path") + if not isinstance(node, IDSPrimitive): + raise ValueError( + f"path does not point to a scalar/array leaf " + f"(reached {type(node).__name__}); add more path segments" + ) + if not node.has_value: + raise ValueError(f"field is not populated (no data written)") + + node_shape = list(node.shape) if node.metadata.ndim > 0 else None + + coordinate_path = None + try: + def _replace_placeholder(m, _segs=field_segments): + idx = next((s for s in _segs if s.isdigit()), "0") + return "/" + idx + "/" + + for coord in node.metadata.coordinates: + clean = re.sub(r"\([^)]+\)/", _replace_placeholder, str(coord)) + coordinate_path = ids_name + "/" + clean + break + except Exception: + pass + + return _to_python(node.value), node_shape, coordinate_path + + +def _fetch_field(uri_str: str, ids_name: str, field_segments: tuple, occurrence: int) -> tuple: + """Open the IMAS entry, traverse the path, and return (value, shape, coordinate_path). + + Scalar results (``shape is None``) are written into the response cache so + that repeated requests skip the IMAS open. Array values are intentionally + *not* cached: caching large numpy-derived lists would create persistent + memory pressure and could fill the cache backend with multi-MB payloads. + """ + if ids_name and not field_segments: # bare IDS name only – no leaf, skip cache probe + pass + else: + cache_key = ( + f"simdb:field:{uri_str}:{ids_name}:" + f"{'/' .join(field_segments)}:{occurrence}" + ) + cached = cache.get(cache_key) + if cached is not None: + return cached + + entry = open_imas(URI(uri_str)) + with entry: + result = _traverse_path(entry, ids_name, list(field_segments), occurrence) + + _value, shape, _coord = result + if shape is None: # scalar leaf – safe to persist in cache + cache.set(cache_key, result) # type: ignore[possibly-undefined] + return result + + +def _get_simulation_and_imas_file(sim_id: str, file_uuid_str: str | None): + try: + simulation = current_app.db.get_simulation(sim_id) + except DatabaseError as exc: + return None, None, ({"error": str(exc)}, 404) + + imas_outputs = [f for f in simulation.outputs if f.type == DataObject.Type.IMAS] + if not imas_outputs: + return None, None, ( + {"error": f"Simulation {sim_id} has no IMAS output files"}, 404 + ) + + if not file_uuid_str: + return simulation, imas_outputs[0], None + + try: + target_uuid = _uuid.UUID(file_uuid_str) + except ValueError: + return None, None, ({"error": f"Invalid file_uuid: {file_uuid_str!r}"}, 400) + + imas_file = next((f for f in imas_outputs if f.uuid == target_uuid), None) + if imas_file is None: + return None, None, ({"error": f"File {file_uuid_str} not found"}, 404) + + return simulation, imas_file, None + + +# Endpoints + +@api.route("/simulation//data") +class SimulationImasData(Resource): + @requires_auth() + def get(self, sim_id: str, user: User): + """Return the value at a given IDS path for a simulation's IMAS output. + + Query parameters + ---------------- + path (required) IDS path, e.g. ``core_profiles/profiles_1d/0/electrons/density`` + file_uuid (optional) UUID of an IMAS output file + occurrence (optional) IDS occurrence index (default 0) + """ + path = request.args.get("path", "").strip() + if not path: + return {"error": "Query parameter 'path' is required"}, 400 + + file_uuid_str = request.args.get("file_uuid", "").strip() or None + + try: + occurrence = int(request.args.get("occurrence", "0")) + except ValueError: + return {"error": "'occurrence' must be a non-negative integer"}, 400 + if occurrence < 0: + return {"error": "'occurrence' must be a non-negative integer"}, 400 + + simulation, imas_file, error = _get_simulation_and_imas_file( + sim_id, file_uuid_str + ) + if error: + payload, status = error + if file_uuid_str and status == 404 and "File " in payload["error"]: + return ( + { + "error": ( + f"File {file_uuid_str} not found or is not an IMAS " + "output for this simulation" + ) + }, + 404, + ) + return payload, status + + segments = [s for s in path.split("/") if s] + if not segments: + return {"error": "'path' must not be empty"}, 400 + + ids_name = segments[0] + field_segments = segments[1:] + + try: + value, shape, coordinate_path = _fetch_field( + str(imas_file.uri), ids_name, tuple(field_segments), occurrence + ) + except (ValueError, AttributeError, IndexError, KeyError) as exc: + return {"error": f"Invalid IDS path '{path}': {exc}"}, 400 + except (ImasError,) as exc: + return {"error": f"Failed to open IMAS data: {exc}"}, 500 + except Exception as exc: + msg = str(exc) + status = 404 if "is empty" in msg or "not found" in msg.lower() else 500 + return {"error": msg}, status + + return { + "simulation": str(simulation.uuid), + "file_uuid": str(imas_file.uuid), + "path": path, + "occurrence": occurrence, + "value": value, + "shape": shape, + "coordinate": coordinate_path, + } From 05b8818a65c67374363c2ed67c6dde3b6bd3227c Mon Sep 17 00:00:00 2001 From: prasad-sawantdesai Date: Tue, 28 Apr 2026 16:20:49 +0200 Subject: [PATCH 02/45] fixed formatting --- src/simdb/remote/apis/v1_2/simulation_data.py | 39 ++++++++++++++----- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/src/simdb/remote/apis/v1_2/simulation_data.py b/src/simdb/remote/apis/v1_2/simulation_data.py index 482f17a4..41fe5ce9 100644 --- a/src/simdb/remote/apis/v1_2/simulation_data.py +++ b/src/simdb/remote/apis/v1_2/simulation_data.py @@ -14,7 +14,12 @@ from simdb.cli.manifest import DataObject from simdb.database import DatabaseError -from simdb.imas.utils import FLOAT_MISSING_VALUE, INT_MISSING_VALUE, ImasError, open_imas +from simdb.imas.utils import ( + FLOAT_MISSING_VALUE, + INT_MISSING_VALUE, + ImasError, + open_imas, +) from simdb.remote.core.auth import User, requires_auth from simdb.remote.core.cache import cache from simdb.remote.core.typing import current_app @@ -34,7 +39,10 @@ def _to_python(value: Any) -> Any: def _clean(v): if isinstance(v, float) and ( - v != v or v == float("inf") or v == float("-inf") or v == FLOAT_MISSING_VALUE + v != v + or v == float("inf") + or v == float("-inf") + or v == FLOAT_MISSING_VALUE ): return None if isinstance(v, list): @@ -54,6 +62,7 @@ def _clean(v): return bool(value) return value + # TODO Replace this logic with slicing when supported by imas-python. # TODO Add support for [:], [:-1], and [2:4:2] python slicing syntax. def _traverse_path(entry, ids_name: str, field_segments: list, occurrence: int): @@ -64,8 +73,11 @@ def _traverse_path(entry, ids_name: str, field_segments: list, occurrence: int): - a plain name → attribute access (IDSStructure child node) """ ids_obj = entry.get( - ids_name, occurrence, - lazy=True, autoconvert=False, ignore_unknown_dd_version=True, + ids_name, + occurrence, + lazy=True, + autoconvert=False, + ignore_unknown_dd_version=True, ) node = ids_obj for segment in field_segments: @@ -88,6 +100,7 @@ def _traverse_path(entry, ids_name: str, field_segments: list, occurrence: int): coordinate_path = None try: + def _replace_placeholder(m, _segs=field_segments): idx = next((s for s in _segs if s.isdigit()), "0") return "/" + idx + "/" @@ -102,7 +115,9 @@ def _replace_placeholder(m, _segs=field_segments): return _to_python(node.value), node_shape, coordinate_path -def _fetch_field(uri_str: str, ids_name: str, field_segments: tuple, occurrence: int) -> tuple: +def _fetch_field( + uri_str: str, ids_name: str, field_segments: tuple, occurrence: int +) -> tuple: """Open the IMAS entry, traverse the path, and return (value, shape, coordinate_path). Scalar results (``shape is None``) are written into the response cache so @@ -110,12 +125,13 @@ def _fetch_field(uri_str: str, ids_name: str, field_segments: tuple, occurrence: *not* cached: caching large numpy-derived lists would create persistent memory pressure and could fill the cache backend with multi-MB payloads. """ - if ids_name and not field_segments: # bare IDS name only – no leaf, skip cache probe + if ( + ids_name and not field_segments + ): # bare IDS name only – no leaf, skip cache probe pass else: cache_key = ( - f"simdb:field:{uri_str}:{ids_name}:" - f"{'/' .join(field_segments)}:{occurrence}" + f"simdb:field:{uri_str}:{ids_name}:{'/'.join(field_segments)}:{occurrence}" ) cached = cache.get(cache_key) if cached is not None: @@ -139,8 +155,10 @@ def _get_simulation_and_imas_file(sim_id: str, file_uuid_str: str | None): imas_outputs = [f for f in simulation.outputs if f.type == DataObject.Type.IMAS] if not imas_outputs: - return None, None, ( - {"error": f"Simulation {sim_id} has no IMAS output files"}, 404 + return ( + None, + None, + ({"error": f"Simulation {sim_id} has no IMAS output files"}, 404), ) if not file_uuid_str: @@ -160,6 +178,7 @@ def _get_simulation_and_imas_file(sim_id: str, file_uuid_str: str | None): # Endpoints + @api.route("/simulation//data") class SimulationImasData(Resource): @requires_auth() From 66a6ba7d1d1b22a12ec00ba572fdb2aae77a3648 Mon Sep 17 00:00:00 2001 From: prasad-sawantdesai Date: Wed, 29 Apr 2026 09:20:56 +0200 Subject: [PATCH 03/45] fixed linting and typing issues --- src/simdb/remote/apis/v1_2/simulation_data.py | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src/simdb/remote/apis/v1_2/simulation_data.py b/src/simdb/remote/apis/v1_2/simulation_data.py index 41fe5ce9..1b67fdd5 100644 --- a/src/simdb/remote/apis/v1_2/simulation_data.py +++ b/src/simdb/remote/apis/v1_2/simulation_data.py @@ -5,7 +5,7 @@ import re import uuid as _uuid -from typing import Any +from typing import Any, Optional import numpy as np from flask import request @@ -66,11 +66,11 @@ def _clean(v): # TODO Replace this logic with slicing when supported by imas-python. # TODO Add support for [:], [:-1], and [2:4:2] python slicing syntax. def _traverse_path(entry, ids_name: str, field_segments: list, occurrence: int): - """Walk *field_segments* inside *ids_name* and return (value, shape, coordinate_path). + """Walk inside *ids_name* and return (value, shape, coordinate_path). Each segment is either: - - a non-negative integer string → array-of-structures index - - a plain name → attribute access (IDSStructure child node) + - a non-negative integer string: array-of-structures index + - a plain name: attribute access (IDSStructure child node) """ ids_obj = entry.get( ids_name, @@ -86,15 +86,15 @@ def _traverse_path(entry, ids_name: str, field_segments: list, occurrence: int): else: try: node = getattr(node, segment) - except AttributeError: - raise ValueError(f"segment '{segment}' not found in IDS path") + except AttributeError as err: + raise ValueError(f"segment '{segment}' not found in IDS path") from err if not isinstance(node, IDSPrimitive): raise ValueError( f"path does not point to a scalar/array leaf " f"(reached {type(node).__name__}); add more path segments" ) if not node.has_value: - raise ValueError(f"field is not populated (no data written)") + raise ValueError("field is not populated (no data written)") node_shape = list(node.shape) if node.metadata.ndim > 0 else None @@ -118,7 +118,7 @@ def _replace_placeholder(m, _segs=field_segments): def _fetch_field( uri_str: str, ids_name: str, field_segments: tuple, occurrence: int ) -> tuple: - """Open the IMAS entry, traverse the path, and return (value, shape, coordinate_path). + """Open the IMAS entry and return (value, shape, coordinate_path). Scalar results (``shape is None``) are written into the response cache so that repeated requests skip the IMAS open. Array values are intentionally @@ -127,7 +127,7 @@ def _fetch_field( """ if ( ids_name and not field_segments - ): # bare IDS name only – no leaf, skip cache probe + ): # bare IDS name only - no leaf, skip cache probe pass else: cache_key = ( @@ -142,12 +142,12 @@ def _fetch_field( result = _traverse_path(entry, ids_name, list(field_segments), occurrence) _value, shape, _coord = result - if shape is None: # scalar leaf – safe to persist in cache + if shape is None: # scalar leaf - safe to persist in cache cache.set(cache_key, result) # type: ignore[possibly-undefined] return result -def _get_simulation_and_imas_file(sim_id: str, file_uuid_str: str | None): +def _get_simulation_and_imas_file(sim_id: str, file_uuid_str: Optional[str]): try: simulation = current_app.db.get_simulation(sim_id) except DatabaseError as exc: @@ -187,7 +187,8 @@ def get(self, sim_id: str, user: User): Query parameters ---------------- - path (required) IDS path, e.g. ``core_profiles/profiles_1d/0/electrons/density`` + path (required) IDS path, e.g. + ``core_profiles/profiles_1d/0/electrons/density`` file_uuid (optional) UUID of an IMAS output file occurrence (optional) IDS occurrence index (default 0) """ @@ -234,7 +235,7 @@ def get(self, sim_id: str, user: User): ) except (ValueError, AttributeError, IndexError, KeyError) as exc: return {"error": f"Invalid IDS path '{path}': {exc}"}, 400 - except (ImasError,) as exc: + except ImasError as exc: return {"error": f"Failed to open IMAS data: {exc}"}, 500 except Exception as exc: msg = str(exc) From 0aec0153771dea0c4f963c9a8e493a93b669ff44 Mon Sep 17 00:00:00 2001 From: Prasad Date: Mon, 4 May 2026 10:27:04 +0200 Subject: [PATCH 04/45] Apply suggestions from code review Co-authored-by: Simon Pinches --- src/simdb/remote/apis/v1_2/simulation_data.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/simdb/remote/apis/v1_2/simulation_data.py b/src/simdb/remote/apis/v1_2/simulation_data.py index 1b67fdd5..afb1a331 100644 --- a/src/simdb/remote/apis/v1_2/simulation_data.py +++ b/src/simdb/remote/apis/v1_2/simulation_data.py @@ -1,6 +1,6 @@ -"""Simulation IMAS data endpoint: /data. +"""IMAS simulation data endpoint: /data. -TODO: Temporal solution to retrive data (Use IBEX backend) +TODO: Temporary solution to retrieve data (for IBEX backend) """ import re @@ -63,7 +63,7 @@ def _clean(v): return value -# TODO Replace this logic with slicing when supported by imas-python. +# TODO Replace this logic with slicing when supported by IMAS-Python. # TODO Add support for [:], [:-1], and [2:4:2] python slicing syntax. def _traverse_path(entry, ids_name: str, field_segments: list, occurrence: int): """Walk inside *ids_name* and return (value, shape, coordinate_path). From 8a9ee4af63a067d162c5a6af8da36743f580cd72 Mon Sep 17 00:00:00 2001 From: prasad-sawantdesai Date: Mon, 4 May 2026 13:45:44 +0200 Subject: [PATCH 05/45] use pydantic models for input and output --- src/simdb/remote/apis/v1_2/simulation_data.py | 124 +++++++----------- src/simdb/remote/models.py | 47 +++++++ 2 files changed, 95 insertions(+), 76 deletions(-) diff --git a/src/simdb/remote/apis/v1_2/simulation_data.py b/src/simdb/remote/apis/v1_2/simulation_data.py index afb1a331..13334b23 100644 --- a/src/simdb/remote/apis/v1_2/simulation_data.py +++ b/src/simdb/remote/apis/v1_2/simulation_data.py @@ -4,11 +4,10 @@ """ import re -import uuid as _uuid -from typing import Any, Optional +from typing import Annotated, Any, Optional +from uuid import UUID import numpy as np -from flask import request from flask_restx import Namespace, Resource from imas.ids_primitive import IDSPrimitive @@ -22,7 +21,14 @@ ) from simdb.remote.core.auth import User, requires_auth from simdb.remote.core.cache import cache +from simdb.remote.core.pydantic_utils import ( + Query, + ResponseException, + ServerException, + pydantic_validate, +) from simdb.remote.core.typing import current_app +from simdb.remote.models import ImasDataQueryParams, ImasDataResponse from simdb.uri import URI api = Namespace("data", path="/") @@ -147,33 +153,29 @@ def _fetch_field( return result -def _get_simulation_and_imas_file(sim_id: str, file_uuid_str: Optional[str]): +def _get_simulation_and_imas_file(sim_id: str, file_uuid: Optional[UUID]): try: simulation = current_app.db.get_simulation(sim_id) except DatabaseError as exc: - return None, None, ({"error": str(exc)}, 404) + raise ResponseException(str(exc), 404) from exc imas_outputs = [f for f in simulation.outputs if f.type == DataObject.Type.IMAS] if not imas_outputs: - return ( - None, - None, - ({"error": f"Simulation {sim_id} has no IMAS output files"}, 404), + raise ResponseException( + f"Simulation {sim_id} has no IMAS output files", 404 ) - if not file_uuid_str: - return simulation, imas_outputs[0], None - - try: - target_uuid = _uuid.UUID(file_uuid_str) - except ValueError: - return None, None, ({"error": f"Invalid file_uuid: {file_uuid_str!r}"}, 400) + if file_uuid is None: + return simulation, imas_outputs[0] - imas_file = next((f for f in imas_outputs if f.uuid == target_uuid), None) + imas_file = next((f for f in imas_outputs if f.uuid == file_uuid), None) if imas_file is None: - return None, None, ({"error": f"File {file_uuid_str} not found"}, 404) + raise ResponseException( + f"File {file_uuid} not found or is not an IMAS output for this simulation", + 404, + ) - return simulation, imas_file, None + return simulation, imas_file # Endpoints @@ -182,72 +184,42 @@ def _get_simulation_and_imas_file(sim_id: str, file_uuid_str: Optional[str]): @api.route("/simulation//data") class SimulationImasData(Resource): @requires_auth() - def get(self, sim_id: str, user: User): - """Return the value at a given IDS path for a simulation's IMAS output. - - Query parameters - ---------------- - path (required) IDS path, e.g. - ``core_profiles/profiles_1d/0/electrons/density`` - file_uuid (optional) UUID of an IMAS output file - occurrence (optional) IDS occurrence index (default 0) - """ - path = request.args.get("path", "").strip() - if not path: - return {"error": "Query parameter 'path' is required"}, 400 - - file_uuid_str = request.args.get("file_uuid", "").strip() or None - - try: - occurrence = int(request.args.get("occurrence", "0")) - except ValueError: - return {"error": "'occurrence' must be a non-negative integer"}, 400 - if occurrence < 0: - return {"error": "'occurrence' must be a non-negative integer"}, 400 - - simulation, imas_file, error = _get_simulation_and_imas_file( - sim_id, file_uuid_str + @pydantic_validate(api) + def get( + self, + sim_id: str, + user: User, + params: Annotated[ImasDataQueryParams, Query()], + ) -> ImasDataResponse: + """Return the value at a given IDS path for a simulation's IMAS output.""" + simulation, imas_file = _get_simulation_and_imas_file( + sim_id, params.file_uuid ) - if error: - payload, status = error - if file_uuid_str and status == 404 and "File " in payload["error"]: - return ( - { - "error": ( - f"File {file_uuid_str} not found or is not an IMAS " - "output for this simulation" - ) - }, - 404, - ) - return payload, status - - segments = [s for s in path.split("/") if s] - if not segments: - return {"error": "'path' must not be empty"}, 400 + segments = [s for s in params.path.split("/") if s] ids_name = segments[0] field_segments = segments[1:] try: value, shape, coordinate_path = _fetch_field( - str(imas_file.uri), ids_name, tuple(field_segments), occurrence + str(imas_file.uri), ids_name, tuple(field_segments), params.occurrence ) except (ValueError, AttributeError, IndexError, KeyError) as exc: - return {"error": f"Invalid IDS path '{path}': {exc}"}, 400 + raise ResponseException(f"Invalid IDS path '{params.path}': {exc}") except ImasError as exc: - return {"error": f"Failed to open IMAS data: {exc}"}, 500 + raise ServerException(f"Failed to open IMAS data: {exc}") except Exception as exc: msg = str(exc) - status = 404 if "is empty" in msg or "not found" in msg.lower() else 500 - return {"error": msg}, status - - return { - "simulation": str(simulation.uuid), - "file_uuid": str(imas_file.uuid), - "path": path, - "occurrence": occurrence, - "value": value, - "shape": shape, - "coordinate": coordinate_path, - } + if "is empty" in msg or "not found" in msg.lower(): + raise ResponseException(msg, 404) + raise ServerException(msg) + + return ImasDataResponse( + simulation=str(simulation.uuid), + file_uuid=str(imas_file.uuid), + path=params.path, + occurrence=params.occurrence, + value=value, + shape=shape, + coordinate=coordinate_path, + ) diff --git a/src/simdb/remote/models.py b/src/simdb/remote/models.py index 62cee413..2088b706 100644 --- a/src/simdb/remote/models.py +++ b/src/simdb/remote/models.py @@ -28,6 +28,7 @@ Field, InstanceOf, PlainSerializer, + field_validator, model_validator, ) from pydantic import ( @@ -553,6 +554,52 @@ class StagingDirectoryResponse(BaseModel): """Path to the staging dir.""" +class ImasDataQueryParams(BaseModel): + """Query parameters for the IMAS field-data endpoint.""" + + path: str + """IDS path, e.g. ``core_profiles/profiles_1d/0/electrons/density``.""" + file_uuid: Optional[UUID] = None + """UUID of a specific IMAS output file (optional).""" + occurrence: int = Field(0, ge=0) + """IDS occurrence index (default 0).""" + + @field_validator("path", mode="before") + @classmethod + def _strip_path(cls, v: Any) -> str: + v = str(v).strip() + if not v: + raise ValueError("must not be empty") + return v + + @field_validator("file_uuid", mode="before") + @classmethod + def _strip_file_uuid(cls, v: Any) -> Any: + if v is None: + return None + stripped = str(v).strip() + return stripped if stripped else None + + +class ImasDataResponse(BaseModel): + """Response from the IMAS field-data endpoint.""" + + simulation: str + """UUID of the simulation.""" + file_uuid: str + """UUID of the IMAS output file.""" + path: str + """Requested IDS path.""" + occurrence: int + """IDS occurrence index.""" + value: Any + """Value at the requested IDS path.""" + shape: Optional[List[int]] = None + """Shape of the returned array, or ``None`` for scalars.""" + coordinate: Optional[str] = None + """Coordinate path for the first dimension, if available.""" + + class ErrorResponse(BaseModel): """Response model for server errors.""" From 2c01fb1fe0bd92c00b057942984a05e7078625df Mon Sep 17 00:00:00 2001 From: prasad-sawantdesai Date: Tue, 5 May 2026 11:15:31 +0200 Subject: [PATCH 06/45] resolved pull request comments from Maarten --- src/simdb/remote/apis/v1_2/simulation_data.py | 179 ++++++++---------- src/simdb/remote/models.py | 28 ++- 2 files changed, 98 insertions(+), 109 deletions(-) diff --git a/src/simdb/remote/apis/v1_2/simulation_data.py b/src/simdb/remote/apis/v1_2/simulation_data.py index 13334b23..30bcb9ff 100644 --- a/src/simdb/remote/apis/v1_2/simulation_data.py +++ b/src/simdb/remote/apis/v1_2/simulation_data.py @@ -3,24 +3,21 @@ TODO: Temporary solution to retrieve data (for IBEX backend) """ -import re from typing import Annotated, Any, Optional from uuid import UUID import numpy as np from flask_restx import Namespace, Resource +from imas.ids_defs import EMPTY_COMPLEX, EMPTY_FLOAT, EMPTY_INT from imas.ids_primitive import IDSPrimitive from simdb.cli.manifest import DataObject from simdb.database import DatabaseError from simdb.imas.utils import ( - FLOAT_MISSING_VALUE, - INT_MISSING_VALUE, ImasError, open_imas, ) from simdb.remote.core.auth import User, requires_auth -from simdb.remote.core.cache import cache from simdb.remote.core.pydantic_utils import ( Query, ResponseException, @@ -28,7 +25,7 @@ pydantic_validate, ) from simdb.remote.core.typing import current_app -from simdb.remote.models import ImasDataQueryParams, ImasDataResponse +from simdb.remote.models import ImasDataQueryParams, ImasDataResponse, QuantityData from simdb.uri import URI api = Namespace("data", path="/") @@ -45,10 +42,7 @@ def _to_python(value: Any) -> Any: def _clean(v): if isinstance(v, float) and ( - v != v - or v == float("inf") - or v == float("-inf") - or v == FLOAT_MISSING_VALUE + v != v or v == float("inf") or v == float("-inf") or v == EMPTY_FLOAT ): return None if isinstance(v, list): @@ -58,26 +52,68 @@ def _clean(v): return _clean(flat) if isinstance(value, np.integer): v = int(value) - return None if v == INT_MISSING_VALUE else v + return None if v == EMPTY_INT else v if isinstance(value, np.floating): v = float(value) - return None if (np.isnan(v) or np.isinf(v) or v == FLOAT_MISSING_VALUE) else v + return None if (np.isnan(v) or np.isinf(v) or v == EMPTY_FLOAT) else v if isinstance(value, np.complexfloating): - return {"real": float(value.real), "imag": float(value.imag)} + r, i = float(value.real), float(value.imag) + if r == EMPTY_COMPLEX.real and i == EMPTY_COMPLEX.imag: + return None + return {"real": r, "imag": i} if isinstance(value, np.bool_): return bool(value) return value -# TODO Replace this logic with slicing when supported by IMAS-Python. -# TODO Add support for [:], [:-1], and [2:4:2] python slicing syntax. -def _traverse_path(entry, ids_name: str, field_segments: list, occurrence: int): - """Walk inside *ids_name* and return (value, shape, coordinate_path). +def _parse_ids_path(path: str) -> tuple: + """Parse ``ids_name[:occurrence][/ids_path]`` into a 3-tuple""" + head, _, ids_path = path.partition("/") + if ":" in head: + ids_name, occ_str = head.split(":", 1) + try: + occurrence = int(occ_str) + except ValueError as exc: + raise ValueError( + f"Invalid occurrence in path '{path}': '{occ_str}'" + ) from exc + else: + ids_name, occurrence = head, 0 + return ids_name, occurrence, ids_path + + +def _get_coordinates(node: IDSPrimitive, ids_name: str) -> list: + """Return a :class:`QuantityData` for each coordinate dimension of *node*.""" + coords = [] + for i in range(node.metadata.ndim): + coord = node.coordinates[i] + if isinstance(coord, IDSPrimitive): + data = ( + _to_python(coord.value) + if coord.has_value + else list(range(node.shape[i])) + ) + coords.append( + QuantityData( + name=f"{ids_name}/{coord._path}", + units=coord.metadata.units or "", + data=data, + ) + ) + else: + # Index-based coordinate: coord is already a numpy arange + coords.append( + QuantityData( + name=f"dim_{i + 1}", + units="", + data=coord.tolist(), + ) + ) + return coords - Each segment is either: - - a non-negative integer string: array-of-structures index - - a plain name: attribute access (IDSStructure child node) - """ + +def _get_ids_node(entry, ids_name: str, occurrence: int, ids_path: str) -> IDSPrimitive: + """Return the :class:`IDSPrimitive` leaf node at *ids_path* inside *ids_name*.""" ids_obj = entry.get( ids_name, occurrence, @@ -85,15 +121,7 @@ def _traverse_path(entry, ids_name: str, field_segments: list, occurrence: int): autoconvert=False, ignore_unknown_dd_version=True, ) - node = ids_obj - for segment in field_segments: - if segment.isdigit(): - node = node[int(segment)] - else: - try: - node = getattr(node, segment) - except AttributeError as err: - raise ValueError(f"segment '{segment}' not found in IDS path") from err + node = ids_obj[ids_path] if ids_path else ids_obj if not isinstance(node, IDSPrimitive): raise ValueError( f"path does not point to a scalar/array leaf " @@ -101,56 +129,7 @@ def _traverse_path(entry, ids_name: str, field_segments: list, occurrence: int): ) if not node.has_value: raise ValueError("field is not populated (no data written)") - - node_shape = list(node.shape) if node.metadata.ndim > 0 else None - - coordinate_path = None - try: - - def _replace_placeholder(m, _segs=field_segments): - idx = next((s for s in _segs if s.isdigit()), "0") - return "/" + idx + "/" - - for coord in node.metadata.coordinates: - clean = re.sub(r"\([^)]+\)/", _replace_placeholder, str(coord)) - coordinate_path = ids_name + "/" + clean - break - except Exception: - pass - - return _to_python(node.value), node_shape, coordinate_path - - -def _fetch_field( - uri_str: str, ids_name: str, field_segments: tuple, occurrence: int -) -> tuple: - """Open the IMAS entry and return (value, shape, coordinate_path). - - Scalar results (``shape is None``) are written into the response cache so - that repeated requests skip the IMAS open. Array values are intentionally - *not* cached: caching large numpy-derived lists would create persistent - memory pressure and could fill the cache backend with multi-MB payloads. - """ - if ( - ids_name and not field_segments - ): # bare IDS name only - no leaf, skip cache probe - pass - else: - cache_key = ( - f"simdb:field:{uri_str}:{ids_name}:{'/'.join(field_segments)}:{occurrence}" - ) - cached = cache.get(cache_key) - if cached is not None: - return cached - - entry = open_imas(URI(uri_str)) - with entry: - result = _traverse_path(entry, ids_name, list(field_segments), occurrence) - - _value, shape, _coord = result - if shape is None: # scalar leaf - safe to persist in cache - cache.set(cache_key, result) # type: ignore[possibly-undefined] - return result + return node def _get_simulation_and_imas_file(sim_id: str, file_uuid: Optional[UUID]): @@ -161,9 +140,7 @@ def _get_simulation_and_imas_file(sim_id: str, file_uuid: Optional[UUID]): imas_outputs = [f for f in simulation.outputs if f.type == DataObject.Type.IMAS] if not imas_outputs: - raise ResponseException( - f"Simulation {sim_id} has no IMAS output files", 404 - ) + raise ResponseException(f"Simulation {sim_id} has no IMAS output files", 404) if file_uuid is None: return simulation, imas_outputs[0] @@ -192,34 +169,38 @@ def get( params: Annotated[ImasDataQueryParams, Query()], ) -> ImasDataResponse: """Return the value at a given IDS path for a simulation's IMAS output.""" - simulation, imas_file = _get_simulation_and_imas_file( - sim_id, params.file_uuid - ) + simulation, imas_file = _get_simulation_and_imas_file(sim_id, params.file_uuid) - segments = [s for s in params.path.split("/") if s] - ids_name = segments[0] - field_segments = segments[1:] + try: + ids_name, occurrence, ids_path = _parse_ids_path(params.path) + except ValueError as exc: + raise ResponseException(str(exc)) from exc try: - value, shape, coordinate_path = _fetch_field( - str(imas_file.uri), ids_name, tuple(field_segments), params.occurrence - ) + entry = open_imas(URI(str(imas_file.uri))) + with entry: + node = _get_ids_node(entry, ids_name, occurrence, ids_path) + coordinates = _get_coordinates(node, ids_name) + field = QuantityData( + name=f"{ids_name}/{node._path}", + units=node.metadata.units or "", + data=_to_python(node.value), + ) except (ValueError, AttributeError, IndexError, KeyError) as exc: - raise ResponseException(f"Invalid IDS path '{params.path}': {exc}") + raise ResponseException(f"Invalid IDS path '{params.path}': {exc}") from exc except ImasError as exc: - raise ServerException(f"Failed to open IMAS data: {exc}") + raise ServerException(f"Failed to open IMAS data: {exc}") from exc except Exception as exc: msg = str(exc) if "is empty" in msg or "not found" in msg.lower(): - raise ResponseException(msg, 404) - raise ServerException(msg) + raise ResponseException(msg, 404) from exc + raise ServerException(msg) from exc return ImasDataResponse( simulation=str(simulation.uuid), file_uuid=str(imas_file.uuid), path=params.path, - occurrence=params.occurrence, - value=value, - shape=shape, - coordinate=coordinate_path, + occurrence=occurrence, + field=field, + coordinates=coordinates, ) diff --git a/src/simdb/remote/models.py b/src/simdb/remote/models.py index 2088b706..b8936cd3 100644 --- a/src/simdb/remote/models.py +++ b/src/simdb/remote/models.py @@ -558,11 +558,9 @@ class ImasDataQueryParams(BaseModel): """Query parameters for the IMAS field-data endpoint.""" path: str - """IDS path, e.g. ``core_profiles/profiles_1d/0/electrons/density``.""" + """Full IDS path including IDS name and optional occurrence.""" file_uuid: Optional[UUID] = None """UUID of a specific IMAS output file (optional).""" - occurrence: int = Field(0, ge=0) - """IDS occurrence index (default 0).""" @field_validator("path", mode="before") @classmethod @@ -578,7 +576,19 @@ def _strip_file_uuid(cls, v: Any) -> Any: if v is None: return None stripped = str(v).strip() - return stripped if stripped else None + return stripped or None + + +class QuantityData(BaseModel): + """A named, unit-bearing data quantity (field value or coordinate).""" + + name: str + """IDS path of this quantity relative to the IDS root""" + units: str + """Physical units of the quantity""" + data: Any + """Data value: a Python scalar for 0-D quantities, or a nested list for + arrays. """ class ImasDataResponse(BaseModel): @@ -592,12 +602,10 @@ class ImasDataResponse(BaseModel): """Requested IDS path.""" occurrence: int """IDS occurrence index.""" - value: Any - """Value at the requested IDS path.""" - shape: Optional[List[int]] = None - """Shape of the returned array, or ``None`` for scalars.""" - coordinate: Optional[str] = None - """Coordinate path for the first dimension, if available.""" + field: QuantityData + """The requested quantity""" + coordinates: List[QuantityData] + """Coordinates for each dimension of *field*, in dimension order.""" class ErrorResponse(BaseModel): From 4de51d93d33f45011c5251100281fc4910a8dbf1 Mon Sep 17 00:00:00 2001 From: prasad-sawantdesai Date: Tue, 5 May 2026 11:29:41 +0200 Subject: [PATCH 07/45] removed _bool check --- src/simdb/remote/apis/v1_2/simulation_data.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/simdb/remote/apis/v1_2/simulation_data.py b/src/simdb/remote/apis/v1_2/simulation_data.py index 30bcb9ff..76faf86a 100644 --- a/src/simdb/remote/apis/v1_2/simulation_data.py +++ b/src/simdb/remote/apis/v1_2/simulation_data.py @@ -61,8 +61,6 @@ def _clean(v): if r == EMPTY_COMPLEX.real and i == EMPTY_COMPLEX.imag: return None return {"real": r, "imag": i} - if isinstance(value, np.bool_): - return bool(value) return value From 678367628451cf5fa2583080827cd118db436ab6 Mon Sep 17 00:00:00 2001 From: prasad-sawantdesai Date: Tue, 5 May 2026 11:39:52 +0200 Subject: [PATCH 08/45] use namedtuple when returning function values --- src/simdb/remote/apis/v1_2/simulation_data.py | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/simdb/remote/apis/v1_2/simulation_data.py b/src/simdb/remote/apis/v1_2/simulation_data.py index 76faf86a..b3b141a6 100644 --- a/src/simdb/remote/apis/v1_2/simulation_data.py +++ b/src/simdb/remote/apis/v1_2/simulation_data.py @@ -3,7 +3,7 @@ TODO: Temporary solution to retrieve data (for IBEX backend) """ -from typing import Annotated, Any, Optional +from typing import Annotated, Any, NamedTuple, Optional from uuid import UUID import numpy as np @@ -130,7 +130,14 @@ def _get_ids_node(entry, ids_name: str, occurrence: int, ids_path: str) -> IDSPr return node -def _get_simulation_and_imas_file(sim_id: str, file_uuid: Optional[UUID]): +class _SimulationImasFile(NamedTuple): + simulation: Any + imas_file: Any + + +def _get_simulation_and_imas_file( + sim_id: str, file_uuid: Optional[UUID] +) -> _SimulationImasFile: try: simulation = current_app.db.get_simulation(sim_id) except DatabaseError as exc: @@ -141,7 +148,7 @@ def _get_simulation_and_imas_file(sim_id: str, file_uuid: Optional[UUID]): raise ResponseException(f"Simulation {sim_id} has no IMAS output files", 404) if file_uuid is None: - return simulation, imas_outputs[0] + return _SimulationImasFile(simulation, imas_outputs[0]) imas_file = next((f for f in imas_outputs if f.uuid == file_uuid), None) if imas_file is None: @@ -150,7 +157,7 @@ def _get_simulation_and_imas_file(sim_id: str, file_uuid: Optional[UUID]): 404, ) - return simulation, imas_file + return _SimulationImasFile(simulation, imas_file) # Endpoints @@ -167,7 +174,7 @@ def get( params: Annotated[ImasDataQueryParams, Query()], ) -> ImasDataResponse: """Return the value at a given IDS path for a simulation's IMAS output.""" - simulation, imas_file = _get_simulation_and_imas_file(sim_id, params.file_uuid) + result = _get_simulation_and_imas_file(sim_id, params.file_uuid) try: ids_name, occurrence, ids_path = _parse_ids_path(params.path) @@ -175,7 +182,7 @@ def get( raise ResponseException(str(exc)) from exc try: - entry = open_imas(URI(str(imas_file.uri))) + entry = open_imas(URI(str(result.imas_file.uri))) with entry: node = _get_ids_node(entry, ids_name, occurrence, ids_path) coordinates = _get_coordinates(node, ids_name) @@ -195,8 +202,8 @@ def get( raise ServerException(msg) from exc return ImasDataResponse( - simulation=str(simulation.uuid), - file_uuid=str(imas_file.uuid), + simulation=str(result.simulation.uuid), + file_uuid=str(result.imas_file.uuid), path=params.path, occurrence=occurrence, field=field, From ca0b9b4efa7117f83d2a1ec3e62481176ce83c9f Mon Sep 17 00:00:00 2001 From: prasad-sawantdesai Date: Tue, 5 May 2026 11:55:31 +0200 Subject: [PATCH 09/45] used node.has_value instead of manual checking scalar types --- src/simdb/remote/apis/v1_2/simulation_data.py | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/simdb/remote/apis/v1_2/simulation_data.py b/src/simdb/remote/apis/v1_2/simulation_data.py index b3b141a6..aad05bb2 100644 --- a/src/simdb/remote/apis/v1_2/simulation_data.py +++ b/src/simdb/remote/apis/v1_2/simulation_data.py @@ -8,7 +8,7 @@ import numpy as np from flask_restx import Namespace, Resource -from imas.ids_defs import EMPTY_COMPLEX, EMPTY_FLOAT, EMPTY_INT +from imas.ids_defs import EMPTY_FLOAT from imas.ids_primitive import IDSPrimitive from simdb.cli.manifest import DataObject @@ -37,6 +37,7 @@ def _to_python(value: Any) -> Any: """Convert a value returned by IDSPrimitive.value to a JSON-serialisable Python object.""" + print(type(value)) if isinstance(value, np.ndarray): flat = value.tolist() @@ -50,17 +51,6 @@ def _clean(v): return v return _clean(flat) - if isinstance(value, np.integer): - v = int(value) - return None if v == EMPTY_INT else v - if isinstance(value, np.floating): - v = float(value) - return None if (np.isnan(v) or np.isinf(v) or v == EMPTY_FLOAT) else v - if isinstance(value, np.complexfloating): - r, i = float(value.real), float(value.imag) - if r == EMPTY_COMPLEX.real and i == EMPTY_COMPLEX.imag: - return None - return {"real": r, "imag": i} return value From 9f2a7b00593f4c5b4110648f04ec70da3943bd46 Mon Sep 17 00:00:00 2001 From: prasad-sawantdesai Date: Tue, 5 May 2026 16:19:32 +0200 Subject: [PATCH 10/45] remove leftover print statement --- src/simdb/remote/apis/v1_2/simulation_data.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/simdb/remote/apis/v1_2/simulation_data.py b/src/simdb/remote/apis/v1_2/simulation_data.py index aad05bb2..b64bacfc 100644 --- a/src/simdb/remote/apis/v1_2/simulation_data.py +++ b/src/simdb/remote/apis/v1_2/simulation_data.py @@ -37,7 +37,6 @@ def _to_python(value: Any) -> Any: """Convert a value returned by IDSPrimitive.value to a JSON-serialisable Python object.""" - print(type(value)) if isinstance(value, np.ndarray): flat = value.tolist() From 3c5f45c23d0c60b6408049360b26fca31acbf02b Mon Sep 17 00:00:00 2001 From: prasad-sawantdesai Date: Thu, 21 May 2026 20:40:55 +0200 Subject: [PATCH 11/45] removed file_uuid parameter as we will always use available imas uri --- src/simdb/remote/apis/v1_2/simulation_data.py | 22 ++++--------------- src/simdb/remote/models.py | 12 ---------- 2 files changed, 4 insertions(+), 30 deletions(-) diff --git a/src/simdb/remote/apis/v1_2/simulation_data.py b/src/simdb/remote/apis/v1_2/simulation_data.py index b64bacfc..a35c87a0 100644 --- a/src/simdb/remote/apis/v1_2/simulation_data.py +++ b/src/simdb/remote/apis/v1_2/simulation_data.py @@ -3,8 +3,7 @@ TODO: Temporary solution to retrieve data (for IBEX backend) """ -from typing import Annotated, Any, NamedTuple, Optional -from uuid import UUID +from typing import Annotated, Any, NamedTuple import numpy as np from flask_restx import Namespace, Resource @@ -124,9 +123,7 @@ class _SimulationImasFile(NamedTuple): imas_file: Any -def _get_simulation_and_imas_file( - sim_id: str, file_uuid: Optional[UUID] -) -> _SimulationImasFile: +def _get_simulation_and_imas_file(sim_id: str) -> _SimulationImasFile: try: simulation = current_app.db.get_simulation(sim_id) except DatabaseError as exc: @@ -136,17 +133,7 @@ def _get_simulation_and_imas_file( if not imas_outputs: raise ResponseException(f"Simulation {sim_id} has no IMAS output files", 404) - if file_uuid is None: - return _SimulationImasFile(simulation, imas_outputs[0]) - - imas_file = next((f for f in imas_outputs if f.uuid == file_uuid), None) - if imas_file is None: - raise ResponseException( - f"File {file_uuid} not found or is not an IMAS output for this simulation", - 404, - ) - - return _SimulationImasFile(simulation, imas_file) + return _SimulationImasFile(simulation, imas_outputs[0]) # Endpoints @@ -163,7 +150,7 @@ def get( params: Annotated[ImasDataQueryParams, Query()], ) -> ImasDataResponse: """Return the value at a given IDS path for a simulation's IMAS output.""" - result = _get_simulation_and_imas_file(sim_id, params.file_uuid) + result = _get_simulation_and_imas_file(sim_id) try: ids_name, occurrence, ids_path = _parse_ids_path(params.path) @@ -192,7 +179,6 @@ def get( return ImasDataResponse( simulation=str(result.simulation.uuid), - file_uuid=str(result.imas_file.uuid), path=params.path, occurrence=occurrence, field=field, diff --git a/src/simdb/remote/models.py b/src/simdb/remote/models.py index b8936cd3..229edff4 100644 --- a/src/simdb/remote/models.py +++ b/src/simdb/remote/models.py @@ -559,8 +559,6 @@ class ImasDataQueryParams(BaseModel): path: str """Full IDS path including IDS name and optional occurrence.""" - file_uuid: Optional[UUID] = None - """UUID of a specific IMAS output file (optional).""" @field_validator("path", mode="before") @classmethod @@ -570,14 +568,6 @@ def _strip_path(cls, v: Any) -> str: raise ValueError("must not be empty") return v - @field_validator("file_uuid", mode="before") - @classmethod - def _strip_file_uuid(cls, v: Any) -> Any: - if v is None: - return None - stripped = str(v).strip() - return stripped or None - class QuantityData(BaseModel): """A named, unit-bearing data quantity (field value or coordinate).""" @@ -596,8 +586,6 @@ class ImasDataResponse(BaseModel): simulation: str """UUID of the simulation.""" - file_uuid: str - """UUID of the IMAS output file.""" path: str """Requested IDS path.""" occurrence: int From 00c21e275c1498eece0d61f80907a4dc18087172 Mon Sep 17 00:00:00 2001 From: prasad-sawantdesai Date: Fri, 22 May 2026 18:34:14 +0200 Subject: [PATCH 12/45] fix shape issue and cache_mode=none --- src/simdb/remote/apis/v1_2/simulation_data.py | 5 ++++- src/simdb/remote/models.py | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/simdb/remote/apis/v1_2/simulation_data.py b/src/simdb/remote/apis/v1_2/simulation_data.py index a35c87a0..ff1c8b26 100644 --- a/src/simdb/remote/apis/v1_2/simulation_data.py +++ b/src/simdb/remote/apis/v1_2/simulation_data.py @@ -158,7 +158,10 @@ def get( raise ResponseException(str(exc)) from exc try: - entry = open_imas(URI(str(result.imas_file.uri))) + imas_uri = URI(str(result.imas_file.uri)) + if imas_uri.authority.host and "cache_mode" not in imas_uri.query: + imas_uri.query.set("cache_mode", "none") + entry = open_imas(imas_uri) with entry: node = _get_ids_node(entry, ids_name, occurrence, ids_path) coordinates = _get_coordinates(node, ids_name) diff --git a/src/simdb/remote/models.py b/src/simdb/remote/models.py index f1b3535e..041648ba 100644 --- a/src/simdb/remote/models.py +++ b/src/simdb/remote/models.py @@ -134,7 +134,10 @@ def _deserialize_numpy(v: Any) -> Any: return v if isinstance(v, dict) and v.get("_type") == "numpy.ndarray": np_bytes = base64.b64decode(v["bytes"].encode()) - return np.frombuffer(np_bytes, dtype=v["dtype"]).reshape(v["shape"]) + arr = np.frombuffer(np_bytes, dtype=v["dtype"]) + if "shape" in v: + arr = arr.reshape(v["shape"]) + return arr raise ValueError(f"Cannot deserialize {v} to np.ndarray") From 8609fdbc481b1f083c9e8dcd0015e5b6d46430ce Mon Sep 17 00:00:00 2001 From: prasad-sawantdesai Date: Thu, 28 May 2026 08:30:17 +0200 Subject: [PATCH 13/45] fix import and added TypeAlias --- src/simdb/remote/models.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/simdb/remote/models.py b/src/simdb/remote/models.py index 939a8202..d6809ec5 100644 --- a/src/simdb/remote/models.py +++ b/src/simdb/remote/models.py @@ -12,6 +12,7 @@ List, Literal, Optional, + TypeAlias, TypeVar, Union, ) @@ -26,6 +27,7 @@ BeforeValidator, ConfigDict, Field, + InstanceOf, PlainSerializer, field_validator, model_validator, @@ -109,7 +111,7 @@ class RangeValue(BaseModel): max: float -MetadataValue = Union[ +MetadataValue: TypeAlias = Union[ CustomUUID, str, int, @@ -212,7 +214,7 @@ def _serialize_numpy(o: np.ndarray) -> dict: ] -MetadataValue = Union[ +MetadataValue: TypeAlias = Union[ CustomUUID, str, int, From 98479c7d91e71aebebd7bbed5b3fe89e098d4fd2 Mon Sep 17 00:00:00 2001 From: prasad-sawantdesai Date: Thu, 28 May 2026 09:06:26 +0200 Subject: [PATCH 14/45] remove typealias --- src/simdb/remote/models.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/simdb/remote/models.py b/src/simdb/remote/models.py index d6809ec5..693d6291 100644 --- a/src/simdb/remote/models.py +++ b/src/simdb/remote/models.py @@ -12,7 +12,6 @@ List, Literal, Optional, - TypeAlias, TypeVar, Union, ) @@ -111,7 +110,7 @@ class RangeValue(BaseModel): max: float -MetadataValue: TypeAlias = Union[ +MetadataValue = Union[ CustomUUID, str, int, @@ -214,7 +213,7 @@ def _serialize_numpy(o: np.ndarray) -> dict: ] -MetadataValue: TypeAlias = Union[ +MetadataValue = Union[ CustomUUID, str, int, From dfda0b9c4576fc9da57feeedf6dbed013d1374bb Mon Sep 17 00:00:00 2001 From: prasad-sawantdesai Date: Thu, 28 May 2026 10:39:45 +0200 Subject: [PATCH 15/45] support backward compatibility for metadata --- src/simdb/database/models/simulation.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/simdb/database/models/simulation.py b/src/simdb/database/models/simulation.py index 201e9bfc..3c0dcf9c 100644 --- a/src/simdb/database/models/simulation.py +++ b/src/simdb/database/models/simulation.py @@ -361,14 +361,17 @@ def from_data(cls, data: Dict[str, Union[str, Dict, List]]) -> "Simulation": outputs = checked_get(data, "outputs", list) simulation.outputs = [File.from_data(el) for el in outputs] if "metadata" in data: - metadata = checked_get(data, "metadata", list) - meta_dict = {} - for el in metadata: - if not isinstance(el, dict): - raise Exception("corrupted metadata element - expected dictionary") - if "element" in el and "value" in el: - meta_dict[el["element"]] = el["value"] - simulation._set_metadata_dict(meta_dict) + metadata = data.get("metadata") + if isinstance(metadata, list): + meta_dict = {} + for el in metadata: + if not isinstance(el, dict): + raise Exception("corrupted metadata element - expected dictionary") + if "element" in el and "value" in el: + meta_dict[el["element"]] = el["value"] + simulation._set_metadata_dict(meta_dict) + elif isinstance(metadata, dict): + simulation._set_metadata_dict(metadata) return simulation @classmethod From d08981380b7e5098a166567225de58e8bf32c41e Mon Sep 17 00:00:00 2001 From: prasad-sawantdesai Date: Thu, 28 May 2026 11:30:31 +0200 Subject: [PATCH 16/45] fixed formatting --- src/simdb/database/models/simulation.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/simdb/database/models/simulation.py b/src/simdb/database/models/simulation.py index 3c0dcf9c..6b8d8258 100644 --- a/src/simdb/database/models/simulation.py +++ b/src/simdb/database/models/simulation.py @@ -366,7 +366,9 @@ def from_data(cls, data: Dict[str, Union[str, Dict, List]]) -> "Simulation": meta_dict = {} for el in metadata: if not isinstance(el, dict): - raise Exception("corrupted metadata element - expected dictionary") + raise Exception( + "corrupted metadata element - expected dictionary" + ) if "element" in el and "value" in el: meta_dict[el["element"]] = el["value"] simulation._set_metadata_dict(meta_dict) From c661a54e4bc706484804fc6750b979d65876ba58 Mon Sep 17 00:00:00 2001 From: prasad-sawantdesai Date: Thu, 28 May 2026 13:30:08 +0200 Subject: [PATCH 17/45] added RageValue and added test for list in metadata --- src/simdb/remote/models.py | 6 ++++-- tests/remote/api/test_metadata.py | 24 ++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/simdb/remote/models.py b/src/simdb/remote/models.py index 693d6291..7d85d8eb 100644 --- a/src/simdb/remote/models.py +++ b/src/simdb/remote/models.py @@ -219,13 +219,15 @@ def _serialize_numpy(o: np.ndarray) -> dict: int, float, bool, + RangeValue, list, dict, NumpyArray, None, ] -"""Supported types for simulation metadata values. Numpy arrays and scalars are -automatically converted to their plain Python equivalents before validation.""" +"""Supported types for simulation metadata values. RangeValue, numpy arrays and +scalars are automatically converted to their plain Python equivalents before +validation.""" class MetadataData(BaseModel): diff --git a/tests/remote/api/test_metadata.py b/tests/remote/api/test_metadata.py index dbd99cee..f3e5e32d 100644 --- a/tests/remote/api/test_metadata.py +++ b/tests/remote/api/test_metadata.py @@ -51,6 +51,30 @@ def test_get_metadata_values(client): assert "machine-a" in rv.json or "machine-b" in rv.json +def test_get_metadata_list_value(client): + """Test that float lists are auto-converted to Range (new behavior).""" + list_data = [1.0, 2.5, 3.7] + simulation_data_1 = generate_simulation_data(metadata={"ip": list_data}) + rv_post_1 = post_simulation(client, simulation_data_1) + assert rv_post_1.status_code == 200 + + rv = client.get("/v1.2/metadata", headers=HEADERS) + assert rv.status_code == 200 + mkeys = MetadataKeyInfoList.model_validate_json(rv.data) + mkey = next((k for k in mkeys.root if k.name == "ip"), None) + assert mkey is not None, "ip key not found in metadata keys" + assert mkey.type == "Range" + + rv = client.get("/v1.2/metadata/ip", headers=HEADERS) + assert rv.status_code == 200 + mdata = MetadataValueList.model_validate_json(rv.data) + assert len(mdata.root) == 1 + a = mdata.root[0] + assert isinstance(a, RangeValue) + assert a.min == 1.0 + assert a.max == 3.7 + + def test_get_metadata_range_value(client): """Test metadata Range storage""" # Create a simulation with a range metadata value From c1797bf7e23fca05c3f974777550a992a274eff4 Mon Sep 17 00:00:00 2001 From: prasad-sawantdesai Date: Thu, 28 May 2026 14:08:04 +0200 Subject: [PATCH 18/45] removed duplicate metedataValue --- src/simdb/remote/models.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/src/simdb/remote/models.py b/src/simdb/remote/models.py index 7d85d8eb..01b8fe42 100644 --- a/src/simdb/remote/models.py +++ b/src/simdb/remote/models.py @@ -110,21 +110,6 @@ class RangeValue(BaseModel): max: float -MetadataValue = Union[ - CustomUUID, - str, - int, - float, - bool, - list, - RangeValue, - dict[str, Any], - None, -] -"""Supported types for simulation metadata values. Numpy arrays and regular arrays -containing numeric data are automatically converted to RangeValue.""" - - class StatusPatchData(BaseModel): """Post data for updating simulation status.""" From e62b61a6cd914ca4a3e3782f1ed4075242a2ccdb Mon Sep 17 00:00:00 2001 From: prasad-sawantdesai Date: Thu, 28 May 2026 15:46:22 +0200 Subject: [PATCH 19/45] make json encode backward compatible- numpy arrays, reshape with shape key because it is returning flat 1d array --- .gitignore | 7 ++++++- src/simdb/json.py | 17 ++++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 97025922..74957e4a 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ test_manifest.yml /test-reports/ /tests/cli/test.cfg /tests/cli/*.yaml +/tests/remote/api/test_data_files/ .eggs .coverage *.xml @@ -30,4 +31,8 @@ simdb-coverage-report src/simdb/_version.py *.egg-info *.egg -*.whl \ No newline at end of file +*.whl +myenv/* +qdrant/* +imaspulse/* +simdb-local/* diff --git a/src/simdb/json.py b/src/simdb/json.py index 89301c88..a50c7bda 100644 --- a/src/simdb/json.py +++ b/src/simdb/json.py @@ -22,7 +22,10 @@ def _custom_hook(obj: Dict[str, str]) -> Any: return uuid.UUID(obj["hex"]) elif obj["_type"] == "numpy.ndarray": np_bytes = base64.decodebytes(obj["bytes"].encode()) - return np.frombuffer(np_bytes, dtype=obj["dtype"]) + arr = np.frombuffer(np_bytes, dtype=obj["dtype"]) + if "shape" in obj: + arr = arr.reshape(obj["shape"]) + return arr else: obj_type = obj["_type"] raise ValueError(f"Unknown type to deserialise {obj_type}.") @@ -49,4 +52,16 @@ def default(self, o: Any) -> Any: return {"_type": "uuid.UUID", "hex": o.hex} elif isinstance(o, enum.Enum): return o.value + elif isinstance(o, np.ndarray): + encoded_bytes = base64.b64encode(o.data).decode() + return { + "_type": "numpy.ndarray", + "dtype": o.dtype.name, + "shape": o.shape, + "bytes": encoded_bytes, + } + elif isinstance(o, np.integer): + return int(o) + elif isinstance(o, np.floating): + return float(o) return super().default(o) From 419c0dd681873a2dc9c3b96141bf5a5cef801c1b Mon Sep 17 00:00:00 2001 From: prasad-sawantdesai Date: Thu, 28 May 2026 22:32:33 +0200 Subject: [PATCH 20/45] fixed logic of checking numpy arrays --- .gitignore | 6 +---- src/simdb/validation/validator.py | 38 ++++++++++++++++++++----------- 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/.gitignore b/.gitignore index 74957e4a..9267dcb7 100644 --- a/.gitignore +++ b/.gitignore @@ -31,8 +31,4 @@ simdb-coverage-report src/simdb/_version.py *.egg-info *.egg -*.whl -myenv/* -qdrant/* -imaspulse/* -simdb-local/* +*.whl \ No newline at end of file diff --git a/src/simdb/validation/validator.py b/src/simdb/validation/validator.py index 2daa21ca..834598d7 100644 --- a/src/simdb/validation/validator.py +++ b/src/simdb/validation/validator.py @@ -28,6 +28,22 @@ class CustomValidator(ValidatorBase): types_mapping = cast(Any, cerberus.Validator).types_mapping.copy() types_mapping["numpy"] = cerberus.TypeDefinition("numpy", (np.ndarray,), ()) + def _numeric_array(self, field, value) -> Optional[np.ndarray]: + if not isinstance(value, np.ndarray): + self._error(field, "Value is not a numpy array") + return None + try: + value = value.astype(float, copy=False) + except (TypeError, ValueError): + self._error(field, "Values in numpy array must be numeric") + return None + + value = value[~np.isnan(value)] + if value.size == 0: + self._error(field, "Values in numpy array are NaN or empty") + return None + return value + def _validate_exists(self, check_exists, field, value): """The rule's arguments are validated against this schema: {'type': ['string'], @@ -40,11 +56,9 @@ def _validate_min_value(self, min_value, field, value): {'type': 'float'} """ - if not isinstance(value, np.ndarray): - value = value[~np.isnan(value)] - if value.size == 0: - self._error(field, "Values in numpy array are NaN or empty") - self._error(field, "Value is not a numpy array") + value = self._numeric_array(field, value) + if value is None: + return if min_value is not None and value.min() < min_value: self._error(field, f"Minimum {value.min()} less than {min_value}") @@ -53,11 +67,9 @@ def _validate_max_value(self, max_value, field, value): {'type': 'float'} """ - if not isinstance(value, np.ndarray): - value = value[~np.isnan(value)] - if value.size == 0: - self._error(field, "Values in numpy array are NaN or empty") - self._error(field, "Value is not a numpy array") + value = self._numeric_array(field, value) + if value is None: + return if max_value is not None and value.max() > max_value: self._error(field, f"Maximum {value.max()} greater than {max_value}") @@ -65,9 +77,9 @@ def _compare(self, comparison, field, value, comparator: str, message: str): if comparison is None: return if isinstance(value, np.ndarray): - value = value[~np.isnan(value)] - if value.size == 0: - self._error(field, "Values in numpy array are NaN or empty") + value = self._numeric_array(field, value) + if value is None: + return if not getattr(value, comparator)(comparison).all(): self._error(field, f"Values are not {message} {comparison}") elif isinstance(value, float): From 3e0669690277307685eb7876e11b0b7ead848ed8 Mon Sep 17 00:00:00 2001 From: prasad-sawantdesai Date: Thu, 28 May 2026 23:22:42 +0200 Subject: [PATCH 21/45] check values when validation failed --- src/simdb/validation/validator.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/simdb/validation/validator.py b/src/simdb/validation/validator.py index 834598d7..91819182 100644 --- a/src/simdb/validation/validator.py +++ b/src/simdb/validation/validator.py @@ -28,14 +28,28 @@ class CustomValidator(ValidatorBase): types_mapping = cast(Any, cerberus.Validator).types_mapping.copy() types_mapping["numpy"] = cerberus.TypeDefinition("numpy", (np.ndarray,), ()) + @staticmethod + def _value_preview(value, max_length: int = 200) -> str: + preview = repr(value) + if len(preview) > max_length: + preview = f"{preview[:max_length]}..." + return f"{type(value).__name__} {preview}" + def _numeric_array(self, field, value) -> Optional[np.ndarray]: if not isinstance(value, np.ndarray): - self._error(field, "Value is not a numpy array") + self._error( + field, + f"Value is not a numpy array: {self._value_preview(value)}", + ) return None try: value = value.astype(float, copy=False) except (TypeError, ValueError): - self._error(field, "Values in numpy array must be numeric") + self._error( + field, + "Values in numpy array must be numeric: " + f"{self._value_preview(value)}", + ) return None value = value[~np.isnan(value)] From dd526fec5240a509055c47d907523ec8936c0696 Mon Sep 17 00:00:00 2001 From: prasad-sawantdesai Date: Thu, 28 May 2026 23:46:19 +0200 Subject: [PATCH 22/45] fix validator to understand RangeValue --- src/simdb/validation/validator.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/simdb/validation/validator.py b/src/simdb/validation/validator.py index 91819182..ef280985 100644 --- a/src/simdb/validation/validator.py +++ b/src/simdb/validation/validator.py @@ -28,6 +28,14 @@ class CustomValidator(ValidatorBase): types_mapping = cast(Any, cerberus.Validator).types_mapping.copy() types_mapping["numpy"] = cerberus.TypeDefinition("numpy", (np.ndarray,), ()) + @staticmethod + def _range_value(value) -> Optional[np.ndarray]: + if isinstance(value, dict) and {"min", "max"} <= value.keys(): + return np.array([value["min"], value["max"]]) + if hasattr(value, "min") and hasattr(value, "max"): + return np.array([value.min, value.max]) + return None + @staticmethod def _value_preview(value, max_length: int = 200) -> str: preview = repr(value) @@ -138,6 +146,9 @@ def _normalize_coerce_float(cls, value): def _normalize_coerce_numpy(cls, value): if isinstance(value, np.ndarray): return value + range_value = cls._range_value(value) + if range_value is not None: + return range_value elif isinstance(value, str): return np.fromstring(value[1:-1], sep=" ") else: From 07d18c3e9aaf1e2317b061116a52c9e00c542586 Mon Sep 17 00:00:00 2001 From: prasad-sawantdesai Date: Fri, 29 May 2026 16:52:34 +0200 Subject: [PATCH 23/45] added cli for calling data endpoint --- .gitignore | 8 +- pyproject.toml | 1 + src/simdb/cli/commands/simulation.py | 70 ++++++- src/simdb/cli/commands/utils.py | 232 ++++++++++++++++++++++- src/simdb/cli/remote_api.py | 5 + src/simdb/validation/validator.py | 3 +- tests/cli/test_cli_simulation_command.py | 52 +++++ 7 files changed, 366 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 9267dcb7..b275c5e5 100644 --- a/.gitignore +++ b/.gitignore @@ -31,4 +31,10 @@ simdb-coverage-report src/simdb/_version.py *.egg-info *.egg -*.whl \ No newline at end of file +*.whl +myenv/* +qdrant/* +imaspulse/* +simdb-local/* +imaspluse/* +ASTRA/* \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 2cc00bd9..d3090db8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ dependencies = [ "numpy>=1.14", "pydantic>=2.10.6", "python-dateutil>=2.6", + "plotext>=5.3.2", "pyyaml>=3.13", "requests>=2.27.0", "semantic-version>=2.8", diff --git a/src/simdb/cli/commands/simulation.py b/src/simdb/cli/commands/simulation.py index 7f442ce4..d28955ab 100644 --- a/src/simdb/cli/commands/simulation.py +++ b/src/simdb/cli/commands/simulation.py @@ -16,7 +16,12 @@ from simdb.validation import ValidationError, Validator from . import check_meta_args, pass_config -from .utils import print_simulations +from .utils import ( + is_numeric_1d, + print_quantity, + print_simulations, + show_quantity_textual_plot, +) from .validators import validate_non_negative @@ -353,6 +358,69 @@ def simulation_query( ) +@simulation.command("data", cls=n_required_args_adaptor(2)) +@pass_config +@click.argument("remote", required=False) +@click.argument("sim_id") +@click.argument("ids_path") +@click.option("--username", help="Username used to authenticate with the remote.") +@click.option("--password", help="Password used to authenticate with the remote.") +def simulation_data( + config: Config, + remote: Optional[str], + sim_id: str, + ids_path: str, + username: Optional[str], + password: Optional[str], +): + """Fetch IDS field data for simulation SIM_ID (UUID or alias) from REMOTE. + + \b + IDS_PATH format: + ids_name[:]/path/to/field + + \b + Examples: + simdb sim data iter 4dd781b... profiles_1d[0]/grid/rho_tor_norm + simdb sim data 4dd781b... equilibrium:0/time_slice[0]/profiles_1d/psi + """ + api = RemoteAPI(remote, username, password, config) + + try: + result = api.get_simulation_data(sim_id, ids_path) + except Exception as err: + raise click.ClickException(str(err)) from err + + click.echo(f"simulation : {result['simulation']}") + click.echo(f"path : {result['path']} (occurrence {result['occurrence']})") + + coordinates = result.get("coordinates") or [] + plot_coordinate = next( + ( + coord + for coord in coordinates + if isinstance(coord.get("data"), list) + and isinstance(result["field"].get("data"), list) + and len(coord["data"]) == len(result["field"]["data"]) + ), + None, + ) + field_is_1d = is_numeric_1d(result["field"].get("data")) + if field_is_1d: + show_quantity_textual_plot( + result["field"], label="field", x_quantity=plot_coordinate + ) + else: + print_quantity(result["field"], label="field") + + if config.verbose and coordinates: + for coord in coordinates: + if field_is_1d and is_numeric_1d(coord.get("data")): + continue + if isinstance(coord.get("data"), list): + print_quantity(coord, label=f"coord {coord['name']}", show_stats=False) + + @simulation.command("validate", cls=n_required_args_adaptor(1)) @pass_config @click.argument("remote", required=False) diff --git a/src/simdb/cli/commands/utils.py b/src/simdb/cli/commands/utils.py index ab2c919a..abd352fb 100644 --- a/src/simdb/cli/commands/utils.py +++ b/src/simdb/cli/commands/utils.py @@ -1,7 +1,12 @@ +import importlib from collections import OrderedDict -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, TypeVar +from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Tuple, TypeVar import click +from rich.console import Console, Group +from rich.panel import Panel +from rich.table import Table +from rich.text import Text if TYPE_CHECKING: # Only importing these for type checking and documentation generation in order to @@ -10,6 +15,231 @@ else: Config = TypeVar("Config") +_RICH_CONSOLE = Console() + + +def _get_shape(data: Any) -> Tuple[int, ...]: + """Recursively compute shape of a nested list""" + if not isinstance(data, list): + return () + if not data: + return (0,) + return (len(data), *_get_shape(data[0])) + + +def _fmt_val(v: Any) -> str: + if isinstance(v, float): + return f"{v:.6g}" + return str(v) + + +def _fmt_row(row: list) -> str: + """Format a 1-D list with numpy-style head/tail truncation.""" + if len(row) <= 8: + return " ".join(_fmt_val(v) for v in row) + head = " ".join(_fmt_val(v) for v in row[:3]) + tail = " ".join(_fmt_val(v) for v in row[-3:]) + return f"{head} ... {tail}" + + +def _is_numeric(v: Any) -> bool: + return isinstance(v, (int, float)) and not isinstance(v, bool) + + +def is_numeric_1d(data: Any) -> bool: + return isinstance(data, list) and bool(data) and all(_is_numeric(v) for v in data) + + +def _quantity_axis_label(q: dict, fallback: str = "") -> str: + name = q.get("name") or fallback + units = q.get("units") or "-" + label = str(name).rsplit("/", 1)[-1] or str(name) + return f"{label} [{units}]" + + +def _build_array_body(data: list, shape: Tuple[int, ...]) -> str: + """Build string for 1-D or 2-D arrays.""" + if len(shape) == 1: + return f"[{_fmt_row(data)}]" + + if len(shape) == 2: + rows = data if len(data) <= 8 else [*data[:3], ..., *data[-3:]] + lines = [] + for row in rows: + if row is ...: + lines.append(" ...") + else: + lines.append(f" [{_fmt_row(row)}]") + inner = "\n".join(lines) + return f"[\n{inner}\n]" + + return f"<{len(shape)}-D array, shape {shape}>" + + +def _iter_numeric(data: Any) -> Iterable[float]: + """Yield all numeric leaf values from a nested list, skipping None.""" + if isinstance(data, list): + for item in data: + yield from _iter_numeric(item) + elif isinstance(data, (int, float)) and data is not None: + yield float(data) + + +def _compute_stats(data: Any) -> Optional[Dict[str, float]]: + """Return basic statistics for numeric data, or None if not applicable.""" + values = list(_iter_numeric(data)) + if len(values) < 2: + return None + n = len(values) + vmin = min(values) + vmax = max(values) + mean = sum(values) / n + std = (sum((x - mean) ** 2 for x in values) / n) ** 0.5 + sorted_v = sorted(values) + mid = n // 2 + median = sorted_v[mid] if n % 2 else (sorted_v[mid - 1] + sorted_v[mid]) / 2 + return { + "n": n, + "min": vmin, + "max": vmax, + "mean": mean, + "std": std, + "median": median, + } + + +def _stats_table(stats: Dict[str, float]) -> Table: + table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2)) + for key in ("n", "min", "max", "mean", "std", "median"): + table.add_column(key, justify="right") + table.add_row( + str(int(stats["n"])), + _fmt_val(stats["min"]), + _fmt_val(stats["max"]), + _fmt_val(stats["mean"]), + _fmt_val(stats["std"]), + _fmt_val(stats["median"]), + ) + return table + + +def _plot_stats_table(stats: Dict[str, float], shape: Tuple[int, ...]) -> Table: + table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2)) + for key in ("n", "min", "max", "mean", "std", "median"): + table.add_column(key, justify="right") + table.add_row( + str(int(stats["n"])), + _fmt_val(stats["min"]), + _fmt_val(stats["max"]), + _fmt_val(stats["mean"]), + _fmt_val(stats["std"]), + _fmt_val(stats["median"]), + ) + return table + + +def _plot_panel( + *, + plot: Text, + title: str, + units: str, + stats: Optional[Dict[str, float]], + shape: Tuple[int, ...], +) -> None: + content = plot + if stats: + content = Group(plot, _plot_stats_table(stats, shape)) + + _RICH_CONSOLE.print( + Panel( + content, + title=f"[bold]{title}[/bold] [dim]\\[{units}][/dim]", + subtitle=f"shape {shape}", + ) + ) + + +def show_quantity_textual_plot( + q: dict, + label: str = "", + x_quantity: Optional[dict] = None, +) -> None: + """Print line plot for a 1-D numeric QuantityData dict.""" + name = q["name"] + units = q["units"] or "-" + data = q["data"] + if not is_numeric_1d(data): + print_quantity(q, label=label) + return + + try: + plotext = importlib.import_module("plotext") + except ImportError: + print_quantity(q, label=label) + return + + y_values = [float(value) for value in data] + shape = _get_shape(data) + x_values = None + xlabel = "index [-]" + if ( + x_quantity + and is_numeric_1d(x_quantity.get("data")) + and len(x_quantity["data"]) == len(y_values) + ): + x_values = [float(value) for value in x_quantity["data"]] + xlabel = _quantity_axis_label(x_quantity, fallback="x") + + title = label or name + if x_values is None: + x_values = [float(index) for index in range(len(y_values))] + + console_width = _RICH_CONSOLE.size.width + plot_width = max(48, min(70, console_width - 12)) + + plotext.clear_figure() + plotext.theme("clear") + plotext.plotsize(plot_width, 18) + plotext.xlabel(xlabel) + plotext.ylabel(_quantity_axis_label(q, fallback=label or "field")) + plotext.plot(x_values, y_values, marker="braille", color="cyan") + plot = Text.from_ansi(plotext.build()) + stats = _compute_stats(y_values) + _plot_panel( + plot=plot, + title=title, + units=units, + stats=stats, + shape=shape, + ) + + +def print_quantity(q: dict, label: str = "", show_stats: bool = True) -> None: + """Print a QuantityData dict with array display and stats.""" + name = q["name"] + units = q["units"] or "-" + data = q["data"] + title = f"[bold]{label or name}[/bold] [dim]\\[{units}][/dim]" + + if not isinstance(data, list): + _RICH_CONSOLE.print(Panel(f"{_fmt_val(data)}", title=title, subtitle="scalar")) + return + + shape = _get_shape(data) + stats = _compute_stats(data) + array_body = _build_array_body(data, shape) + subtitle = f"shape ({shape[0]},)" if len(shape) == 1 else f"shape {shape}" + if show_stats and stats: + _RICH_CONSOLE.print( + Panel( + Group(array_body, _stats_table(stats)), + title=title, + subtitle=subtitle, + ) + ) + else: + _RICH_CONSOLE.print(Panel(array_body, title=title, subtitle=subtitle)) + def _flatten_dict(values: Dict) -> List[Tuple[str, str]]: items = [] diff --git a/src/simdb/cli/remote_api.py b/src/simdb/cli/remote_api.py index a81d082c..28520b2f 100644 --- a/src/simdb/cli/remote_api.py +++ b/src/simdb/cli/remote_api.py @@ -663,6 +663,11 @@ def delete_metadata(self, sim_id: str, key: str) -> List[str]: res = self.delete("simulation/metadata/" + sim_id, {"key": key}) return [data["value"] for data in res.json()] + @try_request + def get_simulation_data(self, sim_id: str, path: str) -> Dict[str, Any]: + res = self.get(f"simulation/{sim_id}/data", params={"path": path}) + return res.json() + @try_request def get_directory(self) -> str: res = self.get("staging_dir") diff --git a/src/simdb/validation/validator.py b/src/simdb/validation/validator.py index ef280985..99f7edbf 100644 --- a/src/simdb/validation/validator.py +++ b/src/simdb/validation/validator.py @@ -55,8 +55,7 @@ def _numeric_array(self, field, value) -> Optional[np.ndarray]: except (TypeError, ValueError): self._error( field, - "Values in numpy array must be numeric: " - f"{self._value_preview(value)}", + f"Values in numpy array must be numeric: {self._value_preview(value)}", ) return None diff --git a/tests/cli/test_cli_simulation_command.py b/tests/cli/test_cli_simulation_command.py index 0120fc02..5f61c600 100644 --- a/tests/cli/test_cli_simulation_command.py +++ b/tests/cli/test_cli_simulation_command.py @@ -85,3 +85,55 @@ def test_simulation_validate_command(remote_api, get_local_db): runner = CliRunner() result = runner.invoke(cli, [f"--config-file={config_file}", "simulation"]) assert result.exception is None + + +@mock.patch("simdb.cli.commands.simulation.show_quantity_textual_plot") +@mock.patch("simdb.cli.commands.simulation.RemoteAPI") +def test_simulation_data_command(mock_remote_api_cls, mock_textual_plot): + """``simdb simulation data`` prints field info.""" + mock_api = mock_remote_api_cls.return_value + mock_api.get_simulation_data.return_value = { + "simulation": "a304a6955b3f11f1809bd4f5ef75ec04", + "path": "core_profiles/profiles_1d[0]/electrons/temperature", + "occurrence": 0, + "field": { + "name": "core_profiles/profiles_1d[0]/electrons/temperature", + "units": "eV", + "data": [1000.0, 1200.0, 900.0], + }, + "coordinates": [ + { + "name": "core_profiles/profiles_1d[0]/grid/rho_tor_norm", + "units": "", + "data": [0.0, 0.5, 1.0], + } + ], + } + + config_file = config_test_file() + runner = CliRunner() + result = runner.invoke( + cli, + [ + f"--config-file={config_file}", + "simulation", + "data", + "test_sim", + "core_profiles/profiles_1d[0]/electrons/temperature", + ], + ) + + assert result.exception is None, result.output + mock_api.get_simulation_data.assert_called_once_with( + "test_sim", "core_profiles/profiles_1d[0]/electrons/temperature" + ) + result_data = mock_api.get_simulation_data.return_value + mock_textual_plot.assert_called_once_with( + result_data["field"], + label="field", + x_quantity=result_data["coordinates"][0], + ) + assert "simulation : a304a6955b3f11f1809bd4f5ef75ec04" in result.output + assert "shape (3,)" not in result.output + assert "1000" not in result.output + assert "1200" not in result.output From 96ee78ded57ca739f4d5e0a5b4b0cca10c412f5c Mon Sep 17 00:00:00 2001 From: prasad-sawantdesai Date: Fri, 29 May 2026 16:53:19 +0200 Subject: [PATCH 24/45] reverted .gitignore --- .gitignore | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index b275c5e5..b4c12270 100644 --- a/.gitignore +++ b/.gitignore @@ -21,9 +21,7 @@ test_manifest.yml /htmlcov /test-reports/ /tests/cli/test.cfg -/tests/cli/*.yaml -/tests/remote/api/test_data_files/ -.eggs +/tests/cli/*.yaml.eggs .coverage *.xml simdb-coverage-report @@ -31,10 +29,4 @@ simdb-coverage-report src/simdb/_version.py *.egg-info *.egg -*.whl -myenv/* -qdrant/* -imaspulse/* -simdb-local/* -imaspluse/* -ASTRA/* \ No newline at end of file +*.whl \ No newline at end of file From c86a515564d22d7dcad5b06689de4d8c0f714240 Mon Sep 17 00:00:00 2001 From: Yannick de Jong Date: Wed, 3 Jun 2026 15:21:28 +0200 Subject: [PATCH 25/45] Make metadata non-optional --- src/simdb/remote/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/simdb/remote/models.py b/src/simdb/remote/models.py index 0a8218ce..d8d2d2d0 100644 --- a/src/simdb/remote/models.py +++ b/src/simdb/remote/models.py @@ -352,7 +352,7 @@ class SimulationListItem(BaseModel): """Alias of the simulation.""" datetime: str """Creation timestamp.""" - metadata: Optional[MetadataDataList] = None + metadata: MetadataDataList = MetadataDataList() """Simulation metadata.""" From ef7e7e023f9cba8553f5ada33d872cc6c8931743 Mon Sep 17 00:00:00 2001 From: prasad-sawantdesai Date: Fri, 19 Jun 2026 10:19:57 +0200 Subject: [PATCH 26/45] Reverted code which is taken care in #93 --- src/simdb/database/models/simulation.py | 21 ++++----- src/simdb/json.py | 17 +------ src/simdb/validation/validator.py | 62 ++++++------------------- 3 files changed, 22 insertions(+), 78 deletions(-) diff --git a/src/simdb/database/models/simulation.py b/src/simdb/database/models/simulation.py index 6b8d8258..201e9bfc 100644 --- a/src/simdb/database/models/simulation.py +++ b/src/simdb/database/models/simulation.py @@ -361,19 +361,14 @@ def from_data(cls, data: Dict[str, Union[str, Dict, List]]) -> "Simulation": outputs = checked_get(data, "outputs", list) simulation.outputs = [File.from_data(el) for el in outputs] if "metadata" in data: - metadata = data.get("metadata") - if isinstance(metadata, list): - meta_dict = {} - for el in metadata: - if not isinstance(el, dict): - raise Exception( - "corrupted metadata element - expected dictionary" - ) - if "element" in el and "value" in el: - meta_dict[el["element"]] = el["value"] - simulation._set_metadata_dict(meta_dict) - elif isinstance(metadata, dict): - simulation._set_metadata_dict(metadata) + metadata = checked_get(data, "metadata", list) + meta_dict = {} + for el in metadata: + if not isinstance(el, dict): + raise Exception("corrupted metadata element - expected dictionary") + if "element" in el and "value" in el: + meta_dict[el["element"]] = el["value"] + simulation._set_metadata_dict(meta_dict) return simulation @classmethod diff --git a/src/simdb/json.py b/src/simdb/json.py index a50c7bda..89301c88 100644 --- a/src/simdb/json.py +++ b/src/simdb/json.py @@ -22,10 +22,7 @@ def _custom_hook(obj: Dict[str, str]) -> Any: return uuid.UUID(obj["hex"]) elif obj["_type"] == "numpy.ndarray": np_bytes = base64.decodebytes(obj["bytes"].encode()) - arr = np.frombuffer(np_bytes, dtype=obj["dtype"]) - if "shape" in obj: - arr = arr.reshape(obj["shape"]) - return arr + return np.frombuffer(np_bytes, dtype=obj["dtype"]) else: obj_type = obj["_type"] raise ValueError(f"Unknown type to deserialise {obj_type}.") @@ -52,16 +49,4 @@ def default(self, o: Any) -> Any: return {"_type": "uuid.UUID", "hex": o.hex} elif isinstance(o, enum.Enum): return o.value - elif isinstance(o, np.ndarray): - encoded_bytes = base64.b64encode(o.data).decode() - return { - "_type": "numpy.ndarray", - "dtype": o.dtype.name, - "shape": o.shape, - "bytes": encoded_bytes, - } - elif isinstance(o, np.integer): - return int(o) - elif isinstance(o, np.floating): - return float(o) return super().default(o) diff --git a/src/simdb/validation/validator.py b/src/simdb/validation/validator.py index 99f7edbf..2daa21ca 100644 --- a/src/simdb/validation/validator.py +++ b/src/simdb/validation/validator.py @@ -28,43 +28,6 @@ class CustomValidator(ValidatorBase): types_mapping = cast(Any, cerberus.Validator).types_mapping.copy() types_mapping["numpy"] = cerberus.TypeDefinition("numpy", (np.ndarray,), ()) - @staticmethod - def _range_value(value) -> Optional[np.ndarray]: - if isinstance(value, dict) and {"min", "max"} <= value.keys(): - return np.array([value["min"], value["max"]]) - if hasattr(value, "min") and hasattr(value, "max"): - return np.array([value.min, value.max]) - return None - - @staticmethod - def _value_preview(value, max_length: int = 200) -> str: - preview = repr(value) - if len(preview) > max_length: - preview = f"{preview[:max_length]}..." - return f"{type(value).__name__} {preview}" - - def _numeric_array(self, field, value) -> Optional[np.ndarray]: - if not isinstance(value, np.ndarray): - self._error( - field, - f"Value is not a numpy array: {self._value_preview(value)}", - ) - return None - try: - value = value.astype(float, copy=False) - except (TypeError, ValueError): - self._error( - field, - f"Values in numpy array must be numeric: {self._value_preview(value)}", - ) - return None - - value = value[~np.isnan(value)] - if value.size == 0: - self._error(field, "Values in numpy array are NaN or empty") - return None - return value - def _validate_exists(self, check_exists, field, value): """The rule's arguments are validated against this schema: {'type': ['string'], @@ -77,9 +40,11 @@ def _validate_min_value(self, min_value, field, value): {'type': 'float'} """ - value = self._numeric_array(field, value) - if value is None: - return + if not isinstance(value, np.ndarray): + value = value[~np.isnan(value)] + if value.size == 0: + self._error(field, "Values in numpy array are NaN or empty") + self._error(field, "Value is not a numpy array") if min_value is not None and value.min() < min_value: self._error(field, f"Minimum {value.min()} less than {min_value}") @@ -88,9 +53,11 @@ def _validate_max_value(self, max_value, field, value): {'type': 'float'} """ - value = self._numeric_array(field, value) - if value is None: - return + if not isinstance(value, np.ndarray): + value = value[~np.isnan(value)] + if value.size == 0: + self._error(field, "Values in numpy array are NaN or empty") + self._error(field, "Value is not a numpy array") if max_value is not None and value.max() > max_value: self._error(field, f"Maximum {value.max()} greater than {max_value}") @@ -98,9 +65,9 @@ def _compare(self, comparison, field, value, comparator: str, message: str): if comparison is None: return if isinstance(value, np.ndarray): - value = self._numeric_array(field, value) - if value is None: - return + value = value[~np.isnan(value)] + if value.size == 0: + self._error(field, "Values in numpy array are NaN or empty") if not getattr(value, comparator)(comparison).all(): self._error(field, f"Values are not {message} {comparison}") elif isinstance(value, float): @@ -145,9 +112,6 @@ def _normalize_coerce_float(cls, value): def _normalize_coerce_numpy(cls, value): if isinstance(value, np.ndarray): return value - range_value = cls._range_value(value) - if range_value is not None: - return range_value elif isinstance(value, str): return np.fromstring(value[1:-1], sep=" ") else: From 65db6b77d7989daf77cb70d7a17cd9d6e8e5e331 Mon Sep 17 00:00:00 2001 From: prasad-sawantdesai Date: Thu, 25 Jun 2026 14:59:16 +0200 Subject: [PATCH 27/45] updated uv.lock --- uv.lock | 144 ++++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 118 insertions(+), 26 deletions(-) diff --git a/uv.lock b/uv.lock index 4a21152b..a480e70b 100644 --- a/uv.lock +++ b/uv.lock @@ -2661,6 +2661,7 @@ dependencies = [ { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "plotext" }, { name = "pydantic", version = "2.10.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "pydantic", version = "2.13.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "pyjwt", version = "2.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, @@ -2734,7 +2735,9 @@ build-docs = [ { name = "sphinx-autodoc-typehints", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "sphinx-autodoc-typehints", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "sphinx-autodoc-typehints", version = "3.10.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "sphinx-rtd-theme" }, + { name = "sphinx-immaterial", version = "0.11.14", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "sphinx-immaterial", version = "0.12.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "sphinx-immaterial", version = "0.13.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] imas-validator = [ { name = "imas-validator" }, @@ -2796,6 +2799,7 @@ requires-dist = [ { name = "myst-parser", marker = "extra == 'build-docs'", specifier = ">=0.18.0" }, { name = "nbsphinx", marker = "extra == 'build-docs'", specifier = ">=0.8.0" }, { name = "numpy", specifier = ">=1.14" }, + { name = "plotext", specifier = ">=5.3.2" }, { name = "psycopg2-binary", marker = "extra == 'postgres'", specifier = ">=2.8.0" }, { name = "pydantic", specifier = ">=2.10.6" }, { name = "pyjwt", specifier = ">=1.4.0" }, @@ -2811,7 +2815,7 @@ requires-dist = [ { name = "simplejson", marker = "extra == 'server'", specifier = "~=3.0" }, { name = "sphinx", marker = "extra == 'build-docs'", specifier = ">=4.5" }, { name = "sphinx-autodoc-typehints", marker = "extra == 'build-docs'", specifier = ">=1.12.0" }, - { name = "sphinx-rtd-theme", marker = "extra == 'build-docs'", specifier = ">=1.0.0" }, + { name = "sphinx-immaterial", marker = "extra == 'build-docs'", specifier = ">=0.11.14" }, { name = "sqlalchemy", specifier = ">=1.2.12,<2.0" }, { name = "urllib3", specifier = ">=1.26" }, { name = "werkzeug", marker = "extra == 'server'", specifier = "==2.0.3" }, @@ -4210,6 +4214,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, ] +[[package]] +name = "plotext" +version = "5.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/d7/f75f397af966fe252d0d34ffd3cae765317fce2134f925f95e7d6725d1ce/plotext-5.3.2.tar.gz", hash = "sha256:52d1e932e67c177bf357a3f0fe6ce14d1a96f7f7d5679d7b455b929df517068e", size = 61967, upload-time = "2024-09-24T15:13:37.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/1e/12fe7c40cd2099a1f454518754ed229b01beaf3bbb343127f0cc13ce6c22/plotext-5.3.2-py3-none-any.whl", hash = "sha256:394362349c1ddbf319548cfac17ca65e6d5dfc03200c40dfdc0503b3e95a2283", size = 64047, upload-time = "2024-09-24T15:13:36.296Z" }, +] + [[package]] name = "pluggy" version = "1.5.0" @@ -4765,6 +4778,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, ] +[[package]] +name = "pydantic-extra-types" +version = "2.10.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9' and platform_python_implementation != 'PyPy'", + "python_full_version < '3.9' and platform_python_implementation == 'PyPy'", +] +dependencies = [ + { name = "pydantic", version = "2.10.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/10/fb64987804cde41bcc39d9cd757cd5f2bb5d97b389d81aa70238b14b8a7e/pydantic_extra_types-2.10.6.tar.gz", hash = "sha256:c63d70bf684366e6bbe1f4ee3957952ebe6973d41e7802aea0b770d06b116aeb", size = 141858, upload-time = "2025-10-08T13:47:49.483Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/04/5c918669096da8d1c9ec7bb716bd72e755526103a61bc5e76a3e4fb23b53/pydantic_extra_types-2.10.6-py3-none-any.whl", hash = "sha256:6106c448316d30abf721b5b9fecc65e983ef2614399a24142d689c7546cc246a", size = 40949, upload-time = "2025-10-08T13:47:48.268Z" }, +] + +[[package]] +name = "pydantic-extra-types" +version = "2.11.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "(python_full_version >= '3.13' and platform_machine != 'ARM64') or (python_full_version >= '3.13' and sys_platform != 'win32')", + "python_full_version == '3.12.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "(python_full_version == '3.12.*' and platform_machine != 'ARM64') or (python_full_version == '3.12.*' and sys_platform != 'win32')", + "python_full_version == '3.11.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "(python_full_version == '3.11.*' and platform_machine != 'ARM64') or (python_full_version == '3.11.*' and sys_platform != 'win32')", + "python_full_version == '3.10.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", + "python_full_version > '3.9' and python_full_version < '3.10'", + "python_full_version == '3.9'", +] +dependencies = [ + { name = "pydantic", version = "2.13.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/71/dba38ee2651f84f7842206adbd2233d8bbdb59fb85e9fa14232486a8c471/pydantic_extra_types-2.11.1.tar.gz", hash = "sha256:46792d2307383859e923d8fcefa82108b1a141f8a9c0198982b3832ab5ef1049", size = 172002, upload-time = "2026-03-16T08:08:03.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/c1/3226e6d7f5a4f736f38ac11a6fbb262d701889802595cdb0f53a885ac2e0/pydantic_extra_types-2.11.1-py3-none-any.whl", hash = "sha256:1722ea2bddae5628ace25f2aa685b69978ef533123e5638cfbddb999e0100ec1", size = 79526, upload-time = "2026-03-16T08:08:02.533Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -6719,23 +6774,76 @@ wheels = [ ] [[package]] -name = "sphinx-rtd-theme" -version = "3.1.0" +name = "sphinx-immaterial" +version = "0.11.14" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9' and platform_python_implementation != 'PyPy'", + "python_full_version < '3.9' and platform_python_implementation == 'PyPy'", +] dependencies = [ - { name = "docutils", version = "0.20.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.11'" }, - { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "appdirs", marker = "python_full_version < '3.9'" }, + { name = "markupsafe", version = "2.1.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "pydantic", version = "2.10.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "pydantic-extra-types", version = "2.10.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "requests", version = "2.32.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "sphinx", version = "7.1.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/1f/5403cb6bd08f2f13c86d9b5367e56078dbe17d11c0bd91becdf34d454976/sphinx_immaterial-0.11.14.tar.gz", hash = "sha256:e1e8ba93c78a3e007743fede01a3be43f5ae97c5cc19b8e2a4d2aa058abead61", size = 8330984, upload-time = "2024-07-03T20:09:35.091Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/fa/db6916f719970ebdd433c5606bea98bbf899d71f255efced93992f944f1a/sphinx_immaterial-0.11.14-py3-none-any.whl", hash = "sha256:dd1a30614c8ecaa931155189e7d54f211232e31cf3e5c6d28ba9f04a4817f0a3", size = 10872122, upload-time = "2024-07-03T20:09:31.945Z" }, +] + +[[package]] +name = "sphinx-immaterial" +version = "0.12.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version > '3.9' and python_full_version < '3.10'", + "python_full_version == '3.9'", +] +dependencies = [ + { name = "appdirs", marker = "python_full_version == '3.9.*'" }, + { name = "markupsafe", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "pydantic", version = "2.13.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "pydantic-extra-types", version = "2.11.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "requests", version = "2.32.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/e8/c0ac85c8864b4aada1aa71c0c7a326cce1d8581689c18cb05348ce30bf24/sphinx_immaterial-0.12.5.tar.gz", hash = "sha256:a7c0c4be3dcb4960eb7b299dfee07cdf8a02bf56821f5d0d62e5d31b7b7b5ec5", size = 8349000, upload-time = "2025-01-30T22:51:51.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/99/90471644a1dfa18fb801544c9eb3663893801cec049defe077e0e6026c1e/sphinx_immaterial-0.12.5-py3-none-any.whl", hash = "sha256:4173b22ad343fd9c75b51baf305851d89b98b94603c474b428e30e8c8476673b", size = 10885262, upload-time = "2025-01-30T22:51:47.207Z" }, +] + +[[package]] +name = "sphinx-immaterial" +version = "0.13.9" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "(python_full_version >= '3.13' and platform_machine != 'ARM64') or (python_full_version >= '3.13' and sys_platform != 'win32')", + "python_full_version == '3.12.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "(python_full_version == '3.12.*' and platform_machine != 'ARM64') or (python_full_version == '3.12.*' and sys_platform != 'win32')", + "python_full_version == '3.11.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "(python_full_version == '3.11.*' and platform_machine != 'ARM64') or (python_full_version == '3.11.*' and sys_platform != 'win32')", + "python_full_version == '3.10.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", +] +dependencies = [ + { name = "appdirs", marker = "python_full_version >= '3.10'" }, + { name = "markupsafe", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pydantic", version = "2.13.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pydantic-extra-types", version = "2.11.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "sphinxcontrib-jquery" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/84/68/a1bfbf38c0f7bccc9b10bbf76b94606f64acb1552ae394f0b8285bfaea25/sphinx_rtd_theme-3.1.0.tar.gz", hash = "sha256:b44276f2c276e909239a4f6c955aa667aaafeb78597923b1c60babc76db78e4c", size = 7620915, upload-time = "2026-01-12T16:03:31.17Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/87/c7/b5c8015d823bfda1a346adb2c634a2101d50bb75d421eb6dcb31acd25ebc/sphinx_rtd_theme-3.1.0-py2.py3-none-any.whl", hash = "sha256:1785824ae8e6632060490f67cf3a72d404a85d2d9fc26bce3619944de5682b89", size = 7655617, upload-time = "2026-01-12T16:03:28.101Z" }, + { url = "https://files.pythonhosted.org/packages/3d/42/6e958fc5d80ccd18c87d1b7d7c0e17fed04c0ed8a72933dd41c8643622d4/sphinx_immaterial-0.13.9-py3-none-any.whl", hash = "sha256:5ea92d2ddc6befcd0fedbd3e6766ea4746e94d9a8a5cc0ab092a946e1fde4254", size = 13742592, upload-time = "2026-02-06T16:53:11.262Z" }, ] [[package]] @@ -6840,22 +6948,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, ] -[[package]] -name = "sphinxcontrib-jquery" -version = "4.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "sphinx", version = "7.1.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, - { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/de/f3/aa67467e051df70a6330fe7770894b3e4f09436dea6881ae0b4f3d87cad8/sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a", size = 122331, upload-time = "2023-03-14T15:01:01.944Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/76/85/749bd22d1a68db7291c89e2ebca53f4306c3f205853cf31e9de279034c3c/sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae", size = 121104, upload-time = "2023-03-14T15:01:00.356Z" }, -] - [[package]] name = "sphinxcontrib-jsmath" version = "1.0.1" From 85695745cb31509c6375d07a6afcff8275be1d5a Mon Sep 17 00:00:00 2001 From: Prasad Date: Thu, 25 Jun 2026 17:00:41 +0200 Subject: [PATCH 28/45] Apply suggestion from @Yannicked Co-authored-by: Yannick de Jong --- src/simdb/cli/commands/utils.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/simdb/cli/commands/utils.py b/src/simdb/cli/commands/utils.py index abd352fb..203937ad 100644 --- a/src/simdb/cli/commands/utils.py +++ b/src/simdb/cli/commands/utils.py @@ -62,18 +62,17 @@ def _build_array_body(data: list, shape: Tuple[int, ...]) -> str: if len(shape) == 1: return f"[{_fmt_row(data)}]" - if len(shape) == 2: - rows = data if len(data) <= 8 else [*data[:3], ..., *data[-3:]] - lines = [] - for row in rows: - if row is ...: - lines.append(" ...") - else: - lines.append(f" [{_fmt_row(row)}]") - inner = "\n".join(lines) - return f"[\n{inner}\n]" - - return f"<{len(shape)}-D array, shape {shape}>" + if len(shape) == 2: + if len(data) <= 8: + rows = data + lines = [f" [{_fmt_row(row)}]" for row in rows] + else: + lines = [f" [{_fmt_row(row)}]" for row in data[:3]] + lines.append(" ...") + lines += [f" [{_fmt_row(row)}]" for row in data[-3:]] + return f"[\n{'\n'.join(lines)}\n]" + + return f"<{len(shape)}-D array, shape {shape}>" def _iter_numeric(data: Any) -> Iterable[float]: From fece3b27cfc7fe48537af2349865163e01857722 Mon Sep 17 00:00:00 2001 From: Prasad Date: Thu, 25 Jun 2026 17:01:00 +0200 Subject: [PATCH 29/45] Apply suggestion from @Yannicked Co-authored-by: Yannick de Jong --- src/simdb/cli/commands/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/simdb/cli/commands/utils.py b/src/simdb/cli/commands/utils.py index 203937ad..83445b29 100644 --- a/src/simdb/cli/commands/utils.py +++ b/src/simdb/cli/commands/utils.py @@ -80,7 +80,7 @@ def _iter_numeric(data: Any) -> Iterable[float]: if isinstance(data, list): for item in data: yield from _iter_numeric(item) - elif isinstance(data, (int, float)) and data is not None: + elif _is_numeric(data): yield float(data) From a7b8d2d5f1c860afd027c7bd9328abeca34cf765 Mon Sep 17 00:00:00 2001 From: prasad-sawantdesai Date: Thu, 25 Jun 2026 17:08:54 +0200 Subject: [PATCH 30/45] fixed suggestions from yannic --- .gitignore | 3 ++- pyproject.toml | 2 +- src/simdb/cli/commands/utils.py | 9 ++------- uv.lock | 2 +- 4 files changed, 6 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index b4c12270..97025922 100644 --- a/.gitignore +++ b/.gitignore @@ -21,7 +21,8 @@ test_manifest.yml /htmlcov /test-reports/ /tests/cli/test.cfg -/tests/cli/*.yaml.eggs +/tests/cli/*.yaml +.eggs .coverage *.xml simdb-coverage-report diff --git a/pyproject.toml b/pyproject.toml index 171d8ed3..558f76c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ dependencies = [ "numpy>=1.14", "pydantic>=2.10.6", "python-dateutil>=2.6", - "plotext>=5.3.2", + "plotext==5.3.2", "pyyaml>=3.13", "requests>=2.27.0", "semantic-version>=2.8", diff --git a/src/simdb/cli/commands/utils.py b/src/simdb/cli/commands/utils.py index 83445b29..5d2e9f18 100644 --- a/src/simdb/cli/commands/utils.py +++ b/src/simdb/cli/commands/utils.py @@ -1,4 +1,4 @@ -import importlib +import plotext from collections import OrderedDict from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Tuple, TypeVar @@ -171,12 +171,6 @@ def show_quantity_textual_plot( print_quantity(q, label=label) return - try: - plotext = importlib.import_module("plotext") - except ImportError: - print_quantity(q, label=label) - return - y_values = [float(value) for value in data] shape = _get_shape(data) x_values = None @@ -211,6 +205,7 @@ def show_quantity_textual_plot( stats=stats, shape=shape, ) + print_quantity(q, label=label) def print_quantity(q: dict, label: str = "", show_stats: bool = True) -> None: diff --git a/uv.lock b/uv.lock index a480e70b..026fbc87 100644 --- a/uv.lock +++ b/uv.lock @@ -2799,7 +2799,7 @@ requires-dist = [ { name = "myst-parser", marker = "extra == 'build-docs'", specifier = ">=0.18.0" }, { name = "nbsphinx", marker = "extra == 'build-docs'", specifier = ">=0.8.0" }, { name = "numpy", specifier = ">=1.14" }, - { name = "plotext", specifier = ">=5.3.2" }, + { name = "plotext", specifier = "==5.3.2" }, { name = "psycopg2-binary", marker = "extra == 'postgres'", specifier = ">=2.8.0" }, { name = "pydantic", specifier = ">=2.10.6" }, { name = "pyjwt", specifier = ">=1.4.0" }, From 029836e5d887650818a37d52f2a25595a20e6dee Mon Sep 17 00:00:00 2001 From: prasad-sawantdesai Date: Thu, 25 Jun 2026 17:11:30 +0200 Subject: [PATCH 31/45] fixed imports --- src/simdb/cli/commands/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/simdb/cli/commands/utils.py b/src/simdb/cli/commands/utils.py index 5d2e9f18..02a006e4 100644 --- a/src/simdb/cli/commands/utils.py +++ b/src/simdb/cli/commands/utils.py @@ -1,8 +1,8 @@ -import plotext from collections import OrderedDict from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Tuple, TypeVar import click +import plotext from rich.console import Console, Group from rich.panel import Panel from rich.table import Table From 7a3dcc282e4ff8d246cc6446dcd7fb8da0e323f9 Mon Sep 17 00:00:00 2001 From: prasad-sawantdesai Date: Thu, 25 Jun 2026 18:27:16 +0200 Subject: [PATCH 32/45] fixes Python versions compatibility issue with f-strings --- src/simdb/cli/commands/utils.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/simdb/cli/commands/utils.py b/src/simdb/cli/commands/utils.py index 02a006e4..e1b42cb7 100644 --- a/src/simdb/cli/commands/utils.py +++ b/src/simdb/cli/commands/utils.py @@ -62,17 +62,18 @@ def _build_array_body(data: list, shape: Tuple[int, ...]) -> str: if len(shape) == 1: return f"[{_fmt_row(data)}]" - if len(shape) == 2: - if len(data) <= 8: - rows = data - lines = [f" [{_fmt_row(row)}]" for row in rows] - else: - lines = [f" [{_fmt_row(row)}]" for row in data[:3]] - lines.append(" ...") - lines += [f" [{_fmt_row(row)}]" for row in data[-3:]] - return f"[\n{'\n'.join(lines)}\n]" - - return f"<{len(shape)}-D array, shape {shape}>" + if len(shape) == 2: + if len(data) <= 8: + rows = data + lines = [f" [{_fmt_row(row)}]" for row in rows] + else: + lines = [f" [{_fmt_row(row)}]" for row in data[:3]] + lines.append(" ...") + lines += [f" [{_fmt_row(row)}]" for row in data[-3:]] + formatted_lines = "\n".join(lines) + return f"[\n{formatted_lines}\n]" + + return f"<{len(shape)}-D array, shape {shape}>" def _iter_numeric(data: Any) -> Iterable[float]: From d13870a22b0c020eb837c4fa489db44a7e7ad0ab Mon Sep 17 00:00:00 2001 From: prasad-sawantdesai Date: Fri, 26 Jun 2026 22:34:28 +0200 Subject: [PATCH 33/45] if ids path is not available then check for renamed path --- .gitignore | 7 +++- src/simdb/remote/apis/v1_2/simulation_data.py | 37 ++++++++++++++++++- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 97025922..c8e0eb9c 100644 --- a/.gitignore +++ b/.gitignore @@ -30,4 +30,9 @@ simdb-coverage-report src/simdb/_version.py *.egg-info *.egg -*.whl \ No newline at end of file +*.whl +.simdb-instances/ +_study +.serena +myenv +testpulse diff --git a/src/simdb/remote/apis/v1_2/simulation_data.py b/src/simdb/remote/apis/v1_2/simulation_data.py index ff1c8b26..1552a768 100644 --- a/src/simdb/remote/apis/v1_2/simulation_data.py +++ b/src/simdb/remote/apis/v1_2/simulation_data.py @@ -3,10 +3,12 @@ TODO: Temporary solution to retrieve data (for IBEX backend) """ -from typing import Annotated, Any, NamedTuple +from typing import Annotated, Any, NamedTuple, Optional import numpy as np from flask_restx import Namespace, Resource +from imas import IDSFactory +from imas.ids_convert import dd_version_map_from_factories from imas.ids_defs import EMPTY_FLOAT from imas.ids_primitive import IDSPrimitive @@ -98,6 +100,27 @@ def _get_coordinates(node: IDSPrimitive, ids_name: str) -> list: return coords +def _resolve_renamed_ids_path( + ids_obj: Any, ids_name: str, ids_path: str +) -> Optional[str]: + """Return the stored DD path for a requested current-DD path, if renamed.""" + if not ids_path: + return None + + stored_version = getattr(ids_obj, "_version", None) or getattr( + ids_obj, "_dd_version", None + ) + if not stored_version: + return None + + ddmap, _source_is_older = dd_version_map_from_factories( + ids_name, + IDSFactory(stored_version), + IDSFactory(), + ) + return ddmap.new_to_old.path.get(ids_path) + + def _get_ids_node(entry, ids_name: str, occurrence: int, ids_path: str) -> IDSPrimitive: """Return the :class:`IDSPrimitive` leaf node at *ids_path* inside *ids_name*.""" ids_obj = entry.get( @@ -107,7 +130,17 @@ def _get_ids_node(entry, ids_name: str, occurrence: int, ids_path: str) -> IDSPr autoconvert=False, ignore_unknown_dd_version=True, ) - node = ids_obj[ids_path] if ids_path else ids_obj + try: + node = ids_obj[ids_path] if ids_path else ids_obj + except (AttributeError, IndexError, KeyError) as exc: + renamed_path = _resolve_renamed_ids_path(ids_obj, ids_name, ids_path) + if not renamed_path: + raise exc + try: + node = ids_obj[renamed_path] + except (AttributeError, IndexError, KeyError): + raise exc from None + if not isinstance(node, IDSPrimitive): raise ValueError( f"path does not point to a scalar/array leaf " From e4aad3b9686853d8c9b4855fa6e6e27384c6200e Mon Sep 17 00:00:00 2001 From: prasad-sawantdesai Date: Wed, 1 Jul 2026 17:09:33 +0200 Subject: [PATCH 34/45] added autoconvert, dd_convert and dd_target_version parameters --- src/simdb/remote/apis/v1_2/simulation_data.py | 89 +++++++++++-------- src/simdb/remote/models.py | 17 ++++ 2 files changed, 67 insertions(+), 39 deletions(-) diff --git a/src/simdb/remote/apis/v1_2/simulation_data.py b/src/simdb/remote/apis/v1_2/simulation_data.py index 1552a768..2b1da9de 100644 --- a/src/simdb/remote/apis/v1_2/simulation_data.py +++ b/src/simdb/remote/apis/v1_2/simulation_data.py @@ -3,12 +3,11 @@ TODO: Temporary solution to retrieve data (for IBEX backend) """ -from typing import Annotated, Any, NamedTuple, Optional +from typing import Annotated, Any, NamedTuple +import imas import numpy as np from flask_restx import Namespace, Resource -from imas import IDSFactory -from imas.ids_convert import dd_version_map_from_factories from imas.ids_defs import EMPTY_FLOAT from imas.ids_primitive import IDSPrimitive @@ -100,47 +99,51 @@ def _get_coordinates(node: IDSPrimitive, ids_name: str) -> list: return coords -def _resolve_renamed_ids_path( - ids_obj: Any, ids_name: str, ids_path: str -) -> Optional[str]: - """Return the stored DD path for a requested current-DD path, if renamed.""" - if not ids_path: - return None - - stored_version = getattr(ids_obj, "_version", None) or getattr( - ids_obj, "_dd_version", None - ) - if not stored_version: - return None - - ddmap, _source_is_older = dd_version_map_from_factories( - ids_name, - IDSFactory(stored_version), - IDSFactory(), - ) - return ddmap.new_to_old.path.get(ids_path) - - -def _get_ids_node(entry, ids_name: str, occurrence: int, ids_path: str) -> IDSPrimitive: - """Return the :class:`IDSPrimitive` leaf node at *ids_path* inside *ids_name*.""" +def _get_ids_node( + entry, + ids_name: str, + occurrence: int, + ids_path: str, + autoconvert: bool = False, + dd_convert: bool = False, + dd_target_version: "str | None" = None, +) -> IDSPrimitive: + """Return the :class:`IDSPrimitive` leaf node at *ids_path* inside *ids_name*. + + Args: + entry: Open IMAS data entry. + ids_name: Name of the IDS to read. + occurrence: Occurrence index of the IDS. + ids_path: Slash-separated path within the IDS to the leaf node. + autoconvert: Passed to :meth:`~imas.DBEntry.get`. When ``True`` IMAS + automatically applies NBC path remapping at read time, returning + the IDS in the entry's factory version. Useful when the stored + and factory versions share the same major version (e.g. both + 3.x). Defaults to ``False`` so data is returned in its stored + DD version. + dd_convert: When ``True``, explicitly convert the IDS using + :func:`imas.convert_ids` after reading. The target version is + *dd_target_version* when given, otherwise the entry's factory + version. + dd_target_version: Target DD version string (e.g. ``"3.42.0"``) + for :func:`imas.convert_ids`. Only used when *dd_convert* is + ``True``. Falls back to ``entry.factory.version`` when ``None``. + """ ids_obj = entry.get( ids_name, occurrence, lazy=True, - autoconvert=False, + autoconvert=autoconvert, ignore_unknown_dd_version=True, ) - try: - node = ids_obj[ids_path] if ids_path else ids_obj - except (AttributeError, IndexError, KeyError) as exc: - renamed_path = _resolve_renamed_ids_path(ids_obj, ids_name, ids_path) - if not renamed_path: - raise exc - try: - node = ids_obj[renamed_path] - except (AttributeError, IndexError, KeyError): - raise exc from None - + if dd_convert: + target_version = dd_target_version or entry.factory.version + ids_obj = entry.get( + ids_name, + occurrence, + ) + ids_obj = imas.convert_ids(ids_obj, target_version) + node = ids_obj[ids_path] if ids_path else ids_obj if not isinstance(node, IDSPrimitive): raise ValueError( f"path does not point to a scalar/array leaf " @@ -196,7 +199,15 @@ def get( imas_uri.query.set("cache_mode", "none") entry = open_imas(imas_uri) with entry: - node = _get_ids_node(entry, ids_name, occurrence, ids_path) + node = _get_ids_node( + entry, + ids_name, + occurrence, + ids_path, + autoconvert=params.autoconvert, + dd_convert=params.dd_convert, + dd_target_version=params.dd_target_version, + ) coordinates = _get_coordinates(node, ids_name) field = QuantityData( name=f"{ids_name}/{node._path}", diff --git a/src/simdb/remote/models.py b/src/simdb/remote/models.py index 60e063b5..6984b3ff 100644 --- a/src/simdb/remote/models.py +++ b/src/simdb/remote/models.py @@ -612,6 +612,23 @@ class ImasDataQueryParams(BaseModel): path: str """Full IDS path including IDS name and optional occurrence.""" + autoconvert: bool = False + """When ``True``, IMAS applies NBC path remapping at read time and returns + the IDS in the entry's factory DD version. Useful when the stored and + factory versions share the same major version (e.g. both 3.x). Defaults + to ``False`` so data is returned in its stored DD version.""" + + dd_convert: bool = False + """When ``True``, explicitly convert the loaded IDS using + :func:`imas.convert_ids` after reading. The target DD version is + *dd_target_version* when provided, otherwise the entry's factory + version is used. Defaults to ``False``.""" + + dd_target_version: Optional[str] = None + """Target DD version string (e.g. ``"3.42.0"``) to pass to + :func:`imas.convert_ids` when *dd_convert* is ``True``. When omitted, + the conversion targets the entry's factory version.""" + @field_validator("path", mode="before") @classmethod def _strip_path(cls, v: Any) -> str: From 7522ed69e3299a14ff724f9db4edc5b4406c4d3d Mon Sep 17 00:00:00 2001 From: prasad-sawantdesai Date: Thu, 2 Jul 2026 09:51:47 +0200 Subject: [PATCH 35/45] use dd_convert only when lazy=False --- src/simdb/remote/apis/v1_2/simulation_data.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/simdb/remote/apis/v1_2/simulation_data.py b/src/simdb/remote/apis/v1_2/simulation_data.py index 2b1da9de..15a8fee6 100644 --- a/src/simdb/remote/apis/v1_2/simulation_data.py +++ b/src/simdb/remote/apis/v1_2/simulation_data.py @@ -132,16 +132,12 @@ def _get_ids_node( ids_obj = entry.get( ids_name, occurrence, - lazy=True, + lazy=not dd_convert, autoconvert=autoconvert, ignore_unknown_dd_version=True, ) if dd_convert: target_version = dd_target_version or entry.factory.version - ids_obj = entry.get( - ids_name, - occurrence, - ) ids_obj = imas.convert_ids(ids_obj, target_version) node = ids_obj[ids_path] if ids_path else ids_obj if not isinstance(node, IDSPrimitive): From cf0861dc10a113aea3c5b3ef2cca7e71a3346e43 Mon Sep 17 00:00:00 2001 From: prasad-sawantdesai Date: Thu, 2 Jul 2026 12:04:45 +0200 Subject: [PATCH 36/45] bring back renamed path logic --- src/simdb/remote/apis/v1_2/simulation_data.py | 67 ++++++++++++------- src/simdb/remote/models.py | 18 +---- 2 files changed, 46 insertions(+), 39 deletions(-) diff --git a/src/simdb/remote/apis/v1_2/simulation_data.py b/src/simdb/remote/apis/v1_2/simulation_data.py index 15a8fee6..18cc5cb7 100644 --- a/src/simdb/remote/apis/v1_2/simulation_data.py +++ b/src/simdb/remote/apis/v1_2/simulation_data.py @@ -3,11 +3,13 @@ TODO: Temporary solution to retrieve data (for IBEX backend) """ -from typing import Annotated, Any, NamedTuple +from typing import Annotated, Any, NamedTuple, Optional import imas import numpy as np from flask_restx import Namespace, Resource +from imas import IDSFactory +from imas.ids_convert import dd_version_map_from_factories from imas.ids_defs import EMPTY_FLOAT from imas.ids_primitive import IDSPrimitive @@ -99,13 +101,32 @@ def _get_coordinates(node: IDSPrimitive, ids_name: str) -> list: return coords +def _resolve_renamed_ids_path( + ids_obj: Any, ids_name: str, ids_path: str +) -> Optional[str]: + """Return the stored DD path for a requested current-DD path, if renamed.""" + if not ids_path: + return None + + stored_version = getattr(ids_obj, "_version", None) or getattr( + ids_obj, "_dd_version", None + ) + if not stored_version: + return None + + ddmap, _source_is_older = dd_version_map_from_factories( + ids_name, + IDSFactory(stored_version), + IDSFactory(), + ) + return ddmap.new_to_old.path.get(ids_path) + + def _get_ids_node( entry, ids_name: str, occurrence: int, ids_path: str, - autoconvert: bool = False, - dd_convert: bool = False, dd_target_version: "str | None" = None, ) -> IDSPrimitive: """Return the :class:`IDSPrimitive` leaf node at *ids_path* inside *ids_name*. @@ -115,31 +136,31 @@ def _get_ids_node( ids_name: Name of the IDS to read. occurrence: Occurrence index of the IDS. ids_path: Slash-separated path within the IDS to the leaf node. - autoconvert: Passed to :meth:`~imas.DBEntry.get`. When ``True`` IMAS - automatically applies NBC path remapping at read time, returning - the IDS in the entry's factory version. Useful when the stored - and factory versions share the same major version (e.g. both - 3.x). Defaults to ``False`` so data is returned in its stored - DD version. - dd_convert: When ``True``, explicitly convert the IDS using - :func:`imas.convert_ids` after reading. The target version is - *dd_target_version* when given, otherwise the entry's factory - version. - dd_target_version: Target DD version string (e.g. ``"3.42.0"``) - for :func:`imas.convert_ids`. Only used when *dd_convert* is - ``True``. Falls back to ``entry.factory.version`` when ``None``. + dd_target_version: When provided, explicitly convert the IDS to this + DD version string (e.g. ``"3.42.0"``) using + :func:`imas.convert_ids` after reading. Requires eager loading + because :func:`imas.convert_ids` does not support lazy IDSs. """ ids_obj = entry.get( ids_name, occurrence, - lazy=not dd_convert, - autoconvert=autoconvert, + lazy=dd_target_version is None, + autoconvert=False, ignore_unknown_dd_version=True, ) - if dd_convert: - target_version = dd_target_version or entry.factory.version - ids_obj = imas.convert_ids(ids_obj, target_version) - node = ids_obj[ids_path] if ids_path else ids_obj + if dd_target_version is not None: + ids_obj = imas.convert_ids(ids_obj, dd_target_version) + try: + node = ids_obj[ids_path] if ids_path else ids_obj + except (AttributeError, IndexError, KeyError) as exc: + renamed_path = _resolve_renamed_ids_path(ids_obj, ids_name, ids_path) + if not renamed_path: + raise exc + try: + node = ids_obj[renamed_path] + except (AttributeError, IndexError, KeyError): + raise exc from None + if not isinstance(node, IDSPrimitive): raise ValueError( f"path does not point to a scalar/array leaf " @@ -200,8 +221,6 @@ def get( ids_name, occurrence, ids_path, - autoconvert=params.autoconvert, - dd_convert=params.dd_convert, dd_target_version=params.dd_target_version, ) coordinates = _get_coordinates(node, ids_name) diff --git a/src/simdb/remote/models.py b/src/simdb/remote/models.py index 6984b3ff..5a139967 100644 --- a/src/simdb/remote/models.py +++ b/src/simdb/remote/models.py @@ -612,22 +612,10 @@ class ImasDataQueryParams(BaseModel): path: str """Full IDS path including IDS name and optional occurrence.""" - autoconvert: bool = False - """When ``True``, IMAS applies NBC path remapping at read time and returns - the IDS in the entry's factory DD version. Useful when the stored and - factory versions share the same major version (e.g. both 3.x). Defaults - to ``False`` so data is returned in its stored DD version.""" - - dd_convert: bool = False - """When ``True``, explicitly convert the loaded IDS using - :func:`imas.convert_ids` after reading. The target DD version is - *dd_target_version* when provided, otherwise the entry's factory - version is used. Defaults to ``False``.""" - dd_target_version: Optional[str] = None - """Target DD version string (e.g. ``"3.42.0"``) to pass to - :func:`imas.convert_ids` when *dd_convert* is ``True``. When omitted, - the conversion targets the entry's factory version.""" + """When provided, explicitly convert the loaded IDS to this DD version + string (e.g. ``"3.42.0"``) using :func:`imas.convert_ids` after + reading. When omitted, data is returned in its stored DD version.""" @field_validator("path", mode="before") @classmethod From 194fe06a647b0cb71288eeb66ea4bed72c272022 Mon Sep 17 00:00:00 2001 From: prasad-sawantdesai Date: Thu, 2 Jul 2026 17:34:41 +0200 Subject: [PATCH 37/45] Provide dd_version when field is not available in the data entry --- src/simdb/remote/apis/v1_2/simulation_data.py | 82 +++++++++++++++++-- src/simdb/remote/models.py | 2 +- 2 files changed, 74 insertions(+), 10 deletions(-) diff --git a/src/simdb/remote/apis/v1_2/simulation_data.py b/src/simdb/remote/apis/v1_2/simulation_data.py index 18cc5cb7..738dc419 100644 --- a/src/simdb/remote/apis/v1_2/simulation_data.py +++ b/src/simdb/remote/apis/v1_2/simulation_data.py @@ -11,7 +11,9 @@ from imas import IDSFactory from imas.ids_convert import dd_version_map_from_factories from imas.ids_defs import EMPTY_FLOAT +from imas.ids_path import IDSPath from imas.ids_primitive import IDSPrimitive +from imas.ids_toplevel import IDSToplevel from simdb.cli.manifest import DataObject from simdb.database import DatabaseError @@ -122,12 +124,60 @@ def _resolve_renamed_ids_path( return ddmap.new_to_old.path.get(ids_path) +def _copy_ids_properties(src_props: Any, dst_props: Any) -> None: + """Copy all populated scalar fields from *src_props* to *dst_props*.""" + for field in src_props._children: + src_node = getattr(src_props, field) + if isinstance(src_node, IDSPrimitive) and src_node.has_value: + getattr(dst_props, field).value = src_node.value + + +def _set_path_value(ids: IDSToplevel, node_path: Any, value: Any) -> None: + """Generic function to write *value* into *ids* at *node_path* considering + IDSStructArry. + """ + p = IDSPath(str(node_path)) + current: Any = ids + + # allocate all intermediate nodes (structs and arrays) along the path + for part, idx in zip(p.parts[:-1], p.indices[:-1]): + child = getattr(current, part) + if idx is not None: + if len(child) <= idx: + child.resize(idx + 1) + current = child[idx] + else: + current = child + # set the value at the leaf node, allocating array if needed + last_part, last_idx = p.parts[-1], p.indices[-1] + if last_idx is not None: + child = getattr(current, last_part) + if len(child) <= last_idx: + child.resize(last_idx + 1) + child[last_idx].value = value + else: + getattr(current, last_part).value = value + + +def _build_nonlazy_ids( + lazy_ids: Any, + ids_name: str, + resolved_node: IDSPrimitive, + stored_version: str, +) -> IDSToplevel: + """Return a non-lazy IDS in *stored_version*""" + nonlazy: IDSToplevel = IDSFactory(stored_version).new(ids_name) + _copy_ids_properties(lazy_ids.ids_properties, nonlazy.ids_properties) + _set_path_value(nonlazy, resolved_node._path, resolved_node.value) + return nonlazy + + def _get_ids_node( entry, ids_name: str, occurrence: int, ids_path: str, - dd_target_version: "str | None" = None, + dd_version: "str | None" = None, ) -> IDSPrimitive: """Return the :class:`IDSPrimitive` leaf node at *ids_path* inside *ids_name*. @@ -136,31 +186,45 @@ def _get_ids_node( ids_name: Name of the IDS to read. occurrence: Occurrence index of the IDS. ids_path: Slash-separated path within the IDS to the leaf node. - dd_target_version: When provided, explicitly convert the IDS to this - DD version string (e.g. ``"3.42.0"``) using - :func:`imas.convert_ids` after reading. Requires eager loading - because :func:`imas.convert_ids` does not support lazy IDSs. + dd_version: When provided, convert the field value to this DD + version (e.g. ``"3.42.0"`` or ``"4.1.1"``) before returning. """ ids_obj = entry.get( ids_name, occurrence, - lazy=dd_target_version is None, + lazy=True, autoconvert=False, ignore_unknown_dd_version=True, ) - if dd_target_version is not None: - ids_obj = imas.convert_ids(ids_obj, dd_target_version) try: node = ids_obj[ids_path] if ids_path else ids_obj except (AttributeError, IndexError, KeyError) as exc: renamed_path = _resolve_renamed_ids_path(ids_obj, ids_name, ids_path) if not renamed_path: raise exc + if dd_version is None: + raise ValueError( + f"Path '{ids_path}' does not exist in the stored DD version " + f"({getattr(ids_obj, '_version', None) or getattr(ids_obj, '_dd_version', 'unknown')})" + f" but is known under the name '{renamed_path}' in that version. " + f"Pass dd_version to request an explicit DD conversion. " + f"Original error: {type(exc).__name__}: {exc}" + ) from exc try: node = ids_obj[renamed_path] except (AttributeError, IndexError, KeyError): raise exc from None + if dd_version is not None: + stored_version = ( + getattr(ids_obj, "_version", None) + or getattr(ids_obj, "_dd_version", None) + or entry.factory.version + ) + minimal = _build_nonlazy_ids(ids_obj, ids_name, node, stored_version) + converted = imas.convert_ids(minimal, dd_version) + node = converted[ids_path] if ids_path else converted + if not isinstance(node, IDSPrimitive): raise ValueError( f"path does not point to a scalar/array leaf " @@ -221,7 +285,7 @@ def get( ids_name, occurrence, ids_path, - dd_target_version=params.dd_target_version, + dd_version=params.dd_version, ) coordinates = _get_coordinates(node, ids_name) field = QuantityData( diff --git a/src/simdb/remote/models.py b/src/simdb/remote/models.py index 5a139967..eef1e4f3 100644 --- a/src/simdb/remote/models.py +++ b/src/simdb/remote/models.py @@ -612,7 +612,7 @@ class ImasDataQueryParams(BaseModel): path: str """Full IDS path including IDS name and optional occurrence.""" - dd_target_version: Optional[str] = None + dd_version: Optional[str] = None """When provided, explicitly convert the loaded IDS to this DD version string (e.g. ``"3.42.0"``) using :func:`imas.convert_ids` after reading. When omitted, data is returned in its stored DD version.""" From b27bab3e2404cc904ae7c50e2157b140a6587984 Mon Sep 17 00:00:00 2001 From: prasad-sawantdesai Date: Fri, 3 Jul 2026 08:58:59 +0200 Subject: [PATCH 38/45] fixed formatting issue --- src/simdb/remote/apis/v1_2/simulation_data.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/simdb/remote/apis/v1_2/simulation_data.py b/src/simdb/remote/apis/v1_2/simulation_data.py index 738dc419..638c7bf5 100644 --- a/src/simdb/remote/apis/v1_2/simulation_data.py +++ b/src/simdb/remote/apis/v1_2/simulation_data.py @@ -203,9 +203,12 @@ def _get_ids_node( if not renamed_path: raise exc if dd_version is None: + _dd_version = getattr(ids_obj, "_version", None) or getattr( + ids_obj, "_dd_version", "unknown" + ) raise ValueError( f"Path '{ids_path}' does not exist in the stored DD version " - f"({getattr(ids_obj, '_version', None) or getattr(ids_obj, '_dd_version', 'unknown')})" + f"({_dd_version})" f" but is known under the name '{renamed_path}' in that version. " f"Pass dd_version to request an explicit DD conversion. " f"Original error: {type(exc).__name__}: {exc}" From 512e79046a01b7ad88add37925a8eed747c07735 Mon Sep 17 00:00:00 2001 From: prasad-sawantdesai Date: Fri, 3 Jul 2026 10:04:11 +0200 Subject: [PATCH 39/45] using SimDbUrl instead of URI class (pydantic change) --- .gitignore | 1 - src/simdb/remote/apis/v1_2/simulation_data.py | 24 +++++++++++++------ 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index c8e0eb9c..acf39fbf 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,3 @@ src/simdb/_version.py _study .serena myenv -testpulse diff --git a/src/simdb/remote/apis/v1_2/simulation_data.py b/src/simdb/remote/apis/v1_2/simulation_data.py index 638c7bf5..c2717ea8 100644 --- a/src/simdb/remote/apis/v1_2/simulation_data.py +++ b/src/simdb/remote/apis/v1_2/simulation_data.py @@ -19,6 +19,7 @@ from simdb.database import DatabaseError from simdb.imas.utils import ( ImasError, + SimDBUrl, open_imas, ) from simdb.remote.core.auth import User, requires_auth @@ -30,7 +31,6 @@ ) from simdb.remote.core.typing import current_app from simdb.remote.models import ImasDataQueryParams, ImasDataResponse, QuantityData -from simdb.uri import URI api = Namespace("data", path="/") @@ -134,7 +134,7 @@ def _copy_ids_properties(src_props: Any, dst_props: Any) -> None: def _set_path_value(ids: IDSToplevel, node_path: Any, value: Any) -> None: """Generic function to write *value* into *ids* at *node_path* considering - IDSStructArry. + IDSStructArray. """ p = IDSPath(str(node_path)) current: Any = ids @@ -142,7 +142,7 @@ def _set_path_value(ids: IDSToplevel, node_path: Any, value: Any) -> None: # allocate all intermediate nodes (structs and arrays) along the path for part, idx in zip(p.parts[:-1], p.indices[:-1]): child = getattr(current, part) - if idx is not None: + if isinstance(idx, int): if len(child) <= idx: child.resize(idx + 1) current = child[idx] @@ -150,7 +150,7 @@ def _set_path_value(ids: IDSToplevel, node_path: Any, value: Any) -> None: current = child # set the value at the leaf node, allocating array if needed last_part, last_idx = p.parts[-1], p.indices[-1] - if last_idx is not None: + if isinstance(last_idx, int): child = getattr(current, last_part) if len(child) <= last_idx: child.resize(last_idx + 1) @@ -278,9 +278,19 @@ def get( raise ResponseException(str(exc)) from exc try: - imas_uri = URI(str(result.imas_file.uri)) - if imas_uri.authority.host and "cache_mode" not in imas_uri.query: - imas_uri.query.set("cache_mode", "none") + imas_uri = SimDBUrl(str(result.imas_file.uri)) + if imas_uri.host: + qs = dict(imas_uri.query_params()) + if "cache_mode" not in qs: + qs["cache_mode"] = "none" + query_str = "&".join(f"{k}={v}" for k, v in qs.items()) + imas_uri = SimDBUrl.build( + scheme=imas_uri.scheme or "imas", + host=imas_uri.host, + port=imas_uri.port, + path=imas_uri.path or "", + query=query_str, + ) entry = open_imas(imas_uri) with entry: node = _get_ids_node( From a89fc14bc3ccf05cf9336c2450e62f046e072afb Mon Sep 17 00:00:00 2001 From: prasad-sawantdesai Date: Fri, 3 Jul 2026 10:04:53 +0200 Subject: [PATCH 40/45] upadated gitignore file --- .gitignore | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.gitignore b/.gitignore index acf39fbf..64eae04e 100644 --- a/.gitignore +++ b/.gitignore @@ -31,7 +31,3 @@ src/simdb/_version.py *.egg-info *.egg *.whl -.simdb-instances/ -_study -.serena -myenv From 6d51ee00df1182a4178622dc8c808c9ab2af2f86 Mon Sep 17 00:00:00 2001 From: prasad-sawantdesai Date: Fri, 3 Jul 2026 10:12:58 +0200 Subject: [PATCH 41/45] updated DataType pbject --- src/simdb/remote/apis/v1_2/simulation_data.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/simdb/remote/apis/v1_2/simulation_data.py b/src/simdb/remote/apis/v1_2/simulation_data.py index c2717ea8..03b5accd 100644 --- a/src/simdb/remote/apis/v1_2/simulation_data.py +++ b/src/simdb/remote/apis/v1_2/simulation_data.py @@ -15,7 +15,7 @@ from imas.ids_primitive import IDSPrimitive from imas.ids_toplevel import IDSToplevel -from simdb.cli.manifest import DataObject +from simdb.cli.manifest import DataObject, DataType from simdb.database import DatabaseError from simdb.imas.utils import ( ImasError, @@ -249,7 +249,7 @@ def _get_simulation_and_imas_file(sim_id: str) -> _SimulationImasFile: except DatabaseError as exc: raise ResponseException(str(exc), 404) from exc - imas_outputs = [f for f in simulation.outputs if f.type == DataObject.Type.IMAS] + imas_outputs = [f for f in simulation.outputs if f.type == DataType.IMAS] if not imas_outputs: raise ResponseException(f"Simulation {sim_id} has no IMAS output files", 404) From 04ee0383293fa3b93eda3d7139e649ba40b7132c Mon Sep 17 00:00:00 2001 From: prasad-sawantdesai Date: Fri, 3 Jul 2026 10:36:02 +0200 Subject: [PATCH 42/45] fixed linting issue and set cache_mode --- src/simdb/remote/apis/v1_2/simulation_data.py | 24 ++++++++----------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/src/simdb/remote/apis/v1_2/simulation_data.py b/src/simdb/remote/apis/v1_2/simulation_data.py index 03b5accd..b59235ba 100644 --- a/src/simdb/remote/apis/v1_2/simulation_data.py +++ b/src/simdb/remote/apis/v1_2/simulation_data.py @@ -15,7 +15,7 @@ from imas.ids_primitive import IDSPrimitive from imas.ids_toplevel import IDSToplevel -from simdb.cli.manifest import DataObject, DataType +from simdb.cli.manifest import DataType from simdb.database import DatabaseError from simdb.imas.utils import ( ImasError, @@ -282,15 +282,7 @@ def get( if imas_uri.host: qs = dict(imas_uri.query_params()) if "cache_mode" not in qs: - qs["cache_mode"] = "none" - query_str = "&".join(f"{k}={v}" for k, v in qs.items()) - imas_uri = SimDBUrl.build( - scheme=imas_uri.scheme or "imas", - host=imas_uri.host, - port=imas_uri.port, - path=imas_uri.path or "", - query=query_str, - ) + imas_uri = SimDBUrl(str(imas_uri) + "&cache_mode=none") entry = open_imas(imas_uri) with entry: node = _get_ids_node( @@ -307,14 +299,18 @@ def get( data=_to_python(node.value), ) except (ValueError, AttributeError, IndexError, KeyError) as exc: - raise ResponseException(f"Invalid IDS path '{params.path}': {exc}") from exc + raise ResponseException( + f"Invalid IDS path '{params.path}': {type(exc).__name__}: {exc}" + ) from exc except ImasError as exc: - raise ServerException(f"Failed to open IMAS data: {exc}") from exc + raise ServerException( + f"Failed to open IMAS data: {type(exc).__name__}: {exc}" + ) from exc except Exception as exc: msg = str(exc) if "is empty" in msg or "not found" in msg.lower(): - raise ResponseException(msg, 404) from exc - raise ServerException(msg) from exc + raise ResponseException(f"{type(exc).__name__}: {msg}", 404) from exc + raise ServerException(f"{type(exc).__name__}: {msg}") from exc return ImasDataResponse( simulation=str(result.simulation.uuid), From 780ef3ac0ac3c1fbe538b547eb477ae689d74997 Mon Sep 17 00:00:00 2001 From: prasad-sawantdesai Date: Fri, 3 Jul 2026 16:52:50 +0200 Subject: [PATCH 43/45] added test for data endpoint --- src/simdb/remote/models.py | 2 +- tests/cli/test_cli_simulation_command.py | 52 ------------------------ tests/remote/api/test_metadata.py | 24 ----------- tests/remote/api/test_simulation_data.py | 46 +++++++++++++++++++++ 4 files changed, 47 insertions(+), 77 deletions(-) create mode 100644 tests/remote/api/test_simulation_data.py diff --git a/src/simdb/remote/models.py b/src/simdb/remote/models.py index 8b0fd4e1..eb3827ae 100644 --- a/src/simdb/remote/models.py +++ b/src/simdb/remote/models.py @@ -182,7 +182,7 @@ def _deserialize_numpy(v: Any) -> Any: def _serialize_numpy(o: np.ndarray) -> dict: """Serialize numpy arrays to dict format for the web dashboard.""" - encoded_bytes = base64.b64encode(o.data).decode() + encoded_bytes = base64.b64encode(o.tobytes()).decode() return { "_type": "numpy.ndarray", "dtype": o.dtype.name, diff --git a/tests/cli/test_cli_simulation_command.py b/tests/cli/test_cli_simulation_command.py index 5f61c600..0120fc02 100644 --- a/tests/cli/test_cli_simulation_command.py +++ b/tests/cli/test_cli_simulation_command.py @@ -85,55 +85,3 @@ def test_simulation_validate_command(remote_api, get_local_db): runner = CliRunner() result = runner.invoke(cli, [f"--config-file={config_file}", "simulation"]) assert result.exception is None - - -@mock.patch("simdb.cli.commands.simulation.show_quantity_textual_plot") -@mock.patch("simdb.cli.commands.simulation.RemoteAPI") -def test_simulation_data_command(mock_remote_api_cls, mock_textual_plot): - """``simdb simulation data`` prints field info.""" - mock_api = mock_remote_api_cls.return_value - mock_api.get_simulation_data.return_value = { - "simulation": "a304a6955b3f11f1809bd4f5ef75ec04", - "path": "core_profiles/profiles_1d[0]/electrons/temperature", - "occurrence": 0, - "field": { - "name": "core_profiles/profiles_1d[0]/electrons/temperature", - "units": "eV", - "data": [1000.0, 1200.0, 900.0], - }, - "coordinates": [ - { - "name": "core_profiles/profiles_1d[0]/grid/rho_tor_norm", - "units": "", - "data": [0.0, 0.5, 1.0], - } - ], - } - - config_file = config_test_file() - runner = CliRunner() - result = runner.invoke( - cli, - [ - f"--config-file={config_file}", - "simulation", - "data", - "test_sim", - "core_profiles/profiles_1d[0]/electrons/temperature", - ], - ) - - assert result.exception is None, result.output - mock_api.get_simulation_data.assert_called_once_with( - "test_sim", "core_profiles/profiles_1d[0]/electrons/temperature" - ) - result_data = mock_api.get_simulation_data.return_value - mock_textual_plot.assert_called_once_with( - result_data["field"], - label="field", - x_quantity=result_data["coordinates"][0], - ) - assert "simulation : a304a6955b3f11f1809bd4f5ef75ec04" in result.output - assert "shape (3,)" not in result.output - assert "1000" not in result.output - assert "1200" not in result.output diff --git a/tests/remote/api/test_metadata.py b/tests/remote/api/test_metadata.py index f3e5e32d..dbd99cee 100644 --- a/tests/remote/api/test_metadata.py +++ b/tests/remote/api/test_metadata.py @@ -51,30 +51,6 @@ def test_get_metadata_values(client): assert "machine-a" in rv.json or "machine-b" in rv.json -def test_get_metadata_list_value(client): - """Test that float lists are auto-converted to Range (new behavior).""" - list_data = [1.0, 2.5, 3.7] - simulation_data_1 = generate_simulation_data(metadata={"ip": list_data}) - rv_post_1 = post_simulation(client, simulation_data_1) - assert rv_post_1.status_code == 200 - - rv = client.get("/v1.2/metadata", headers=HEADERS) - assert rv.status_code == 200 - mkeys = MetadataKeyInfoList.model_validate_json(rv.data) - mkey = next((k for k in mkeys.root if k.name == "ip"), None) - assert mkey is not None, "ip key not found in metadata keys" - assert mkey.type == "Range" - - rv = client.get("/v1.2/metadata/ip", headers=HEADERS) - assert rv.status_code == 200 - mdata = MetadataValueList.model_validate_json(rv.data) - assert len(mdata.root) == 1 - a = mdata.root[0] - assert isinstance(a, RangeValue) - assert a.min == 1.0 - assert a.max == 3.7 - - def test_get_metadata_range_value(client): """Test metadata Range storage""" # Create a simulation with a range metadata value diff --git a/tests/remote/api/test_simulation_data.py b/tests/remote/api/test_simulation_data.py new file mode 100644 index 00000000..6cc4346d --- /dev/null +++ b/tests/remote/api/test_simulation_data.py @@ -0,0 +1,46 @@ +"""Tests for the /simulation//data endpoint helpers.""" +import tempfile + +import imas +import pytest +from imas.ids_defs import IDS_TIME_MODE_HOMOGENEOUS + +from simdb.remote.apis.v1_2 import simulation_data + + +@pytest.fixture +def summary_entry(tmp_path): + """Write a minimal summary IDS to a temp HDF5 file and return a read-mode DBEntry.""" + uri = f"imas:hdf5?path={tmp_path}" + with imas.DBEntry(uri, "x") as entry: + summary = entry.factory.new("summary") + summary.ids_properties.homogeneous_time = IDS_TIME_MODE_HOMOGENEOUS + summary.global_quantities.ip.value = [1.5e6] + summary.time = [0.0] + entry.put(summary) + return imas.DBEntry(uri, "r") + + +def test_get_ids_node_returns_correct_value(summary_entry): + """Happy path: _get_ids_node returns the real IDSPrimitive with correct value.""" + with summary_entry as entry: + node = simulation_data._get_ids_node( + entry, "summary", 0, "global_quantities/ip/value" + ) + + assert node.has_value + assert list(node.value) == [1.5e6] + assert node.metadata.units == "A" + + +def test_get_ids_node_with_dd_version_returns_converted_value(summary_entry): + """When dd_version matches stored version, convert_ids round-trip preserves value.""" + with summary_entry as entry: + stored_version = entry.factory.version + node = simulation_data._get_ids_node( + entry, "summary", 0, "global_quantities/ip/value", + dd_version=stored_version, + ) + + assert node.has_value + assert list(node.value) == [1.5e6] From 75f564034907fa92c1c0acbe9cb10bedd96f211c Mon Sep 17 00:00:00 2001 From: prasad-sawantdesai Date: Fri, 3 Jul 2026 17:04:09 +0200 Subject: [PATCH 44/45] plotext formatting and test --- src/simdb/cli/commands/utils.py | 8 ++++++-- tests/remote/api/test_simulation_data.py | 10 ++++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/simdb/cli/commands/utils.py b/src/simdb/cli/commands/utils.py index e1b42cb7..8f87bd3e 100644 --- a/src/simdb/cli/commands/utils.py +++ b/src/simdb/cli/commands/utils.py @@ -190,10 +190,14 @@ def show_quantity_textual_plot( console_width = _RICH_CONSOLE.size.width plot_width = max(48, min(70, console_width - 12)) + _, terminal_height = plotext.terminal_size() + plot_height = max(12, min(24, terminal_height - 8)) plotext.clear_figure() - plotext.theme("clear") - plotext.plotsize(plot_width, 18) + plotext.canvas_color("default") + plotext.axes_color("default") + plotext.ticks_color("default") + plotext.plot_size(plot_width, plot_height) plotext.xlabel(xlabel) plotext.ylabel(_quantity_axis_label(q, fallback=label or "field")) plotext.plot(x_values, y_values, marker="braille", color="cyan") diff --git a/tests/remote/api/test_simulation_data.py b/tests/remote/api/test_simulation_data.py index 6cc4346d..316e82d5 100644 --- a/tests/remote/api/test_simulation_data.py +++ b/tests/remote/api/test_simulation_data.py @@ -1,5 +1,4 @@ """Tests for the /simulation//data endpoint helpers.""" -import tempfile import imas import pytest @@ -10,7 +9,7 @@ @pytest.fixture def summary_entry(tmp_path): - """Write a minimal summary IDS to a temp HDF5 file and return a read-mode DBEntry.""" + """Write a minimal summary IDS to HDF5, return a read-mode DBEntry.""" uri = f"imas:hdf5?path={tmp_path}" with imas.DBEntry(uri, "x") as entry: summary = entry.factory.new("summary") @@ -34,11 +33,14 @@ def test_get_ids_node_returns_correct_value(summary_entry): def test_get_ids_node_with_dd_version_returns_converted_value(summary_entry): - """When dd_version matches stored version, convert_ids round-trip preserves value.""" + """convert_ids round-trip with matching dd_version preserves value.""" with summary_entry as entry: stored_version = entry.factory.version node = simulation_data._get_ids_node( - entry, "summary", 0, "global_quantities/ip/value", + entry, + "summary", + 0, + "global_quantities/ip/value", dd_version=stored_version, ) From 01ab4372b0e383c31075285adc397b671eadcbb5 Mon Sep 17 00:00:00 2001 From: prasad-sawantdesai Date: Fri, 3 Jul 2026 17:50:11 +0200 Subject: [PATCH 45/45] support api --- .gitignore | 4 ++++ deploy/nginx.dev.conf.template | 7 ++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 64eae04e..acf39fbf 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,7 @@ src/simdb/_version.py *.egg-info *.egg *.whl +.simdb-instances/ +_study +.serena +myenv diff --git a/deploy/nginx.dev.conf.template b/deploy/nginx.dev.conf.template index 20dedc38..608b83a3 100644 --- a/deploy/nginx.dev.conf.template +++ b/deploy/nginx.dev.conf.template @@ -13,8 +13,13 @@ server { return 200 '{"deployment":"${SIMDB_DEPLOYMENT}","git_branch":"${SIMDB_GIT_BRANCH}","git_commit":"${SIMDB_GIT_COMMIT}","deployed_at":"${SIMDB_DEPLOYED_AT}"}'; } + location ~ ^/(v1|v1\.1|v1\.2)/$ { + default_type application/json; + return 200 '{"api":"simdb","api_version":"$1","server_version":"0.0.0","endpoints":["$scheme://$http_host/$1/simulations","$scheme://$http_host/$1/files","$scheme://$http_host/$1/validation_schema","$scheme://$http_host/$1/metadata","$scheme://$http_host/$1/upload_options"],"documentation":"$scheme://$http_host/$1/docs"}'; + } + location / { - proxy_set_header Host $host; + proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme;