Skip to content

Commit 5a72167

Browse files
authored
Feature/list filled paths (#104)
1 parent 6d03d68 commit 5a72167

8 files changed

Lines changed: 249 additions & 24 deletions

File tree

docs/source/intro.rst

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -86,17 +86,6 @@ get an error message if this is not possible:
8686
Load and store an IDS to disk with IMAS-Core
8787
''''''''''''''''''''''''''''''''''''''''''''
8888

89-
.. note::
90-
91-
- This functionality requires the IMAS-Core, until this library is openly available
92-
on GitHub you may need to fetch it from `git.iter.org <https://git.iter.org/>`_
93-
(requires to have an ITER account). Using IMAS-Core also enable slicing methods
94-
:py:meth:`~imas.db_entry.DBEntry.get_slice`,
95-
:py:meth:`~imas.db_entry.DBEntry.put_slice` and
96-
:py:meth:`~imas.db_entry.DBEntry.get_sample` (with IMAS-Core>=5.4).
97-
- If you can't have access to it, you can save IDS to disk with the built-in
98-
netCDF backend :ref:`Load and store an IDS to disk with netCDF`
99-
10089
To store an IDS to disk, we need to indicate the following URI to the
10190
IMAS-Core: ``imas:<backend>?path=<path_to_folder>`` or using the legacy query keys
10291
``imas:<backend>?user=<user>;database=<database>;version=<version>;pulse=<pulse>;run=<run>``
@@ -115,11 +104,9 @@ In IMAS-Python you do this as follows:
115104
>>> # now store the core_profiles IDS we just populated
116105
>>> dbentry.put(core_profiles)
117106
118-
.. image:: imas_structure.png
119-
120107
To load an IDS from disk, you need to specify the same information as
121108
when storing the IDS (see above). Once the data entry is opened, you
122-
can use ``<IDS>.get()`` to load IDS data from disk:
109+
can use ``dbentry.get()`` to load IDS data from disk:
123110

124111
.. code-block:: python
125112
@@ -146,11 +133,34 @@ In IMAS-Python you do this as follows:
146133
147134
To load an IDS from disk, you need to specify the same file information as
148135
when storing the IDS. Once the data entry is opened, you
149-
can use ``<IDS>.get()`` to load IDS data from disk:
136+
can use ``dbentry.get()`` to load IDS data from disk:
150137

151138
.. code-block:: python
152139
153140
>>> # Now load the core_profiles IDS back from disk
154141
>>> dbentry2 = imas.DBEntry("mypulsefile.nc","r")
155142
>>> core_profiles2 = dbentry2.get("core_profiles")
156143
>>> print(core_profiles2.ids_properties.comment.value)
144+
145+
146+
Data Entry API overview
147+
'''''''''''''''''''''''
148+
149+
See the documentation of :py:class:`imas.DBEntry <imas.db_entry.DBEntry>` for more
150+
details on reading and writing IDSs to disk. Useful functions include:
151+
152+
- :py:meth:`~imas.db_entry.DBEntry.put` and :py:meth:`~imas.db_entry.DBEntry.put_slice`
153+
to write a full IDS or write append a time slice to existing data.
154+
- :py:meth:`~imas.db_entry.DBEntry.get`, :py:meth:`~imas.db_entry.DBEntry.get_slice` and
155+
:py:meth:`~imas.db_entry.DBEntry.get_sample` to read all time slices, a single time
156+
slice, or a sample of time slices from disk. ``get_slice()`` and ``get_sample()`` can
157+
also interpolate data to a requested point in time.
158+
159+
All three ``get()`` methods have a ``lazy`` mode, which will only load data from disk
160+
when you need it. This can greatly speed up data access in some scenarios. See
161+
:ref:`Lazy loading` for more details.
162+
- :py:meth:`~imas.db_entry.DBEntry.list_all_occurrences` to query whether there are any
163+
occurrences of a certain IDS stored on disk.
164+
- :py:meth:`~imas.db_entry.DBEntry.list_filled_paths` to query which Data Dictionary
165+
paths have data filled inside a specific IDS.
166+

imas/backends/db_entry_impl.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,3 +120,11 @@ def delete_data(self, ids_name: str, occurrence: int) -> None:
120120
@abstractmethod
121121
def list_all_occurrences(self, ids_name: str) -> List[int]:
122122
"""Implement DBEntry.list_all_occurrences()"""
123+
124+
@abstractmethod
125+
def list_filled_paths(self, ids_name: str, occurrence: int) -> List[str]:
126+
"""Implement DBEntry.list_filled_paths().
127+
128+
N.B. DD conversion is handled in DBEntry.list_filled_paths(), this method
129+
returns the data paths as stored on-disk.
130+
"""

imas/backends/imas_core/al_context.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,17 @@ def list_all_occurrences(self, ids_name: str) -> List[int]:
174174
return list(occurrences)
175175
return []
176176

177+
def list_filled_paths(self, path: str) -> List[str]:
178+
"""List all filled paths in an IDS.
179+
180+
Args:
181+
path: IDS and occurrence as a string: <IDS>[/<occurrence>]
182+
"""
183+
status, result = ll_interface.list_filled_paths(self.ctx, path)
184+
if status != 0:
185+
raise LowlevelError(f"list filled paths for {path!r}", status)
186+
return result
187+
177188
def close(self):
178189
"""Close this ALContext."""
179190
ll_interface.end_action(self.ctx)

imas/backends/imas_core/db_entry_al.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,19 @@ def list_all_occurrences(self, ids_name: str) -> List[int]:
364364
) from None
365365
return occurrence_list
366366

367+
def list_filled_paths(self, ids_name: str, occurrence: int) -> List[str]:
368+
if self._db_ctx is None:
369+
raise RuntimeError("Database entry is not open.")
370+
ll_path = ids_name
371+
if occurrence != 0:
372+
ll_path += f"/{occurrence}"
373+
paths = self._db_ctx.list_filled_paths(ll_path)
374+
if not paths:
375+
raise DataEntryException(
376+
f"IDS {ids_name!r}, occurrence {occurrence} is empty."
377+
)
378+
return paths
379+
367380
def _check_uda_warnings(self, lazy: bool) -> None:
368381
"""Various checks / warnings for the UDA backend."""
369382
cache_mode = self._querydict.get("cache_mode")

imas/backends/imas_core/imas_interface.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,11 @@ def begin_timerange_action(
166166
):
167167
raise self._minimal_version("5.4")
168168

169+
# New method in AL 5.7
170+
171+
def list_filled_paths(self, ctx, path):
172+
raise self._minimal_version("5.7")
173+
169174

170175
# Dummy documentation for interface:
171176
for funcname in dir(LowlevelInterface):

imas/backends/netcdf/db_entry_nc.py

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,14 @@ def close(self, *, erase: bool = False) -> None:
9292
)
9393
self._dataset.close()
9494

95+
def _get_group(self, ids_name: str, occurrence: int) -> "netCDF4.Group":
96+
try:
97+
return self._dataset[f"{ids_name}/{occurrence}"]
98+
except LookupError as exc:
99+
raise DataEntryException(
100+
f"IDS {ids_name!r}, occurrence {occurrence} is not found."
101+
) from exc
102+
95103
def get(
96104
self,
97105
ids_name: str,
@@ -110,12 +118,7 @@ def get(
110118
raise NotImplementedError(f"`{func}` is not available for netCDF files.")
111119

112120
# Check if the IDS/occurrence exists, and obtain the group it is stored in
113-
try:
114-
group = self._dataset[f"{ids_name}/{occurrence}"]
115-
except KeyError:
116-
raise DataEntryException(
117-
f"IDS {ids_name!r}, occurrence {occurrence} is not found."
118-
)
121+
group = self._get_group(ids_name, occurrence)
119122

120123
# Load data into the destination IDS
121124
if self._ds_factory.dd_version == destination._dd_version:
@@ -183,3 +186,17 @@ def list_all_occurrences(self, ids_name: str) -> List[int]:
183186

184187
occurrence_list.sort()
185188
return occurrence_list
189+
190+
def list_filled_paths(self, ids_name: str, occurrence: int) -> List[str]:
191+
# Check if the IDS/occurrence exists, and obtain the group it is stored in
192+
group = self._get_group(ids_name, occurrence)
193+
194+
result = []
195+
for name, variable in group.variables.items():
196+
if variable.ndim == 0 and variable.dtype == "S1":
197+
continue # (Array of) Structure metadata node, no data
198+
if name.endswith(":shape"):
199+
continue # Shape data, not a DD path
200+
result.append(name.replace(".", "/"))
201+
202+
return result

imas/db_entry.py

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import logging
88
import os
99
import pathlib
10-
from typing import Any, Type, overload
10+
from typing import Any, Type, overload, List
1111

1212
import numpy
1313

@@ -197,14 +197,14 @@ def _select_implementation(uri: str | None) -> Type[DBEntryImpl]:
197197
from imas.backends.imas_core.db_entry_al import ALDBEntryImpl as impl
198198
return impl
199199

200-
def __enter__(self):
200+
def __enter__(self) -> "DBEntry":
201201
# Context manager protocol
202202
if self._dbe_impl is None:
203203
# Open if the DBEntry was not already opened or created
204204
self.open()
205205
return self
206206

207-
def __exit__(self, exc_type, exc_value, traceback):
207+
def __exit__(self, exc_type, exc_value, traceback) -> None:
208208
# Context manager protocol
209209
self.close()
210210

@@ -800,3 +800,63 @@ def list_all_occurrences(self, ids_name, node_path=None):
800800
self.get(ids_name, occ, lazy=True)[node_path] for occ in occurrence_list
801801
]
802802
return occurrence_list, node_content_list
803+
804+
def list_filled_paths(
805+
self, ids_name, occurrence: int = 0, *, autoconvert: bool = True
806+
) -> List[str]:
807+
"""Get a list of filled Data Dictionary paths from the backend.
808+
809+
Note that this is only supported by some backends (HDF5 and netCDF), and will
810+
result in an error on unsupported backends.
811+
812+
Args:
813+
ids_name: Name of the IDS to request filled data for.
814+
occurrence: Occurrence number of the IDS to request filled data for.
815+
816+
Keyword Args:
817+
autoconvert: If enabled (default), this method will take NBC renames into
818+
account in the returned list of filled paths. This argument corresponds
819+
to the :py:data:`~get.autoconvert` argument of :py:meth:`get`.
820+
821+
Returns:
822+
List of paths which have some data filled in the backend. For example, when
823+
``profiles_1d/ion/temperature`` is in this list, it means that there is at
824+
least one ``ion`` in one ``profiles_1d`` entry for which the temperature is
825+
filled.
826+
827+
The paths in this list may be ordered arbitrarily.
828+
829+
Example:
830+
>>> with imas.DBEntry("imas:hdf5?path=./path/to/data", "r") as entry:
831+
>>> print(entry.list_filled_paths("core_profiles"))
832+
['ids_properties/comment', 'ids_properties/homogeneous_time',
833+
'profiles_1d/grid/rho_tor_norm', 'profiles_1d/electrons/temperature',
834+
'profiles_1d/ion/temperature', 'time']
835+
"""
836+
if self._dbe_impl is None:
837+
raise RuntimeError("Database entry is not open.")
838+
paths = self._dbe_impl.list_filled_paths(ids_name, occurrence)
839+
if not autoconvert:
840+
return paths
841+
842+
# DD conversion?
843+
dd_version = self._dbe_impl.read_dd_version(ids_name, occurrence)
844+
if dd_version == self._ids_factory.dd_version:
845+
return paths # No conversion required
846+
847+
# Follow any NBC renames:
848+
ddmap, source_is_older = dd_version_map_from_factories(
849+
ids_name, IDSFactory(version=dd_version), self._ids_factory
850+
)
851+
nbc_map = ddmap.old_to_new if source_is_older else ddmap.new_to_old
852+
853+
converted_paths = []
854+
for path in paths:
855+
if path in nbc_map:
856+
new_name = nbc_map.path[path]
857+
if new_name is not None:
858+
converted_paths.append(new_name)
859+
else:
860+
converted_paths.append(path)
861+
862+
return converted_paths
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import pytest
2+
3+
import imas
4+
from imas_core import _al_lowlevel
5+
from imas.exception import DataEntryException
6+
from imas.ids_defs import IDS_TIME_MODE_HOMOGENEOUS, IDS_TIME_MODE_INDEPENDENT
7+
8+
9+
if not hasattr(_al_lowlevel, "al_list_filled_paths"):
10+
marker = pytest.mark.xfail(reason="list_filled_paths not available in imas_core")
11+
else:
12+
marker = []
13+
14+
15+
@pytest.fixture(params=["netcdf", pytest.param("hdf5", marks=marker)])
16+
def testuri(request, tmp_path):
17+
if request.param == "netcdf":
18+
return str(tmp_path / "list_filled_paths.nc")
19+
return f"imas:{request.param}?path={tmp_path}/list_filled_paths_{request.param}"
20+
21+
22+
def test_list_filled_paths(testuri):
23+
with imas.DBEntry(testuri, "w", dd_version="4.0.0") as dbentry:
24+
# No IDSs in the DBEntry yet, expect an exception
25+
with pytest.raises(DataEntryException):
26+
dbentry.list_filled_paths("core_profiles")
27+
28+
cp = dbentry.factory.core_profiles()
29+
cp.ids_properties.homogeneous_time = IDS_TIME_MODE_HOMOGENEOUS
30+
cp.ids_properties.comment = "comment"
31+
cp.time = [0.1, 0.2]
32+
cp.profiles_1d.resize(2)
33+
cp.profiles_1d[0].grid.rho_tor_norm = [1.0, 2.0]
34+
cp.profiles_1d[0].ion.resize(2)
35+
cp.profiles_1d[0].ion[1].temperature = [1.0, 2.0]
36+
cp.profiles_1d[1].grid.psi = [1.0, 2.0]
37+
cp.profiles_1d[1].q = [1.0, 2.0]
38+
cp.profiles_1d[1].e_field.radial = [1.0, 2.0]
39+
cp.profiles_1d[1].neutral.resize(2)
40+
cp.global_quantities.ip = [1.0, 2.0]
41+
42+
dbentry.put(cp)
43+
44+
filled_paths = dbentry.list_filled_paths("core_profiles")
45+
assert isinstance(filled_paths, list)
46+
assert set(filled_paths) == {
47+
"ids_properties/version_put/access_layer",
48+
"ids_properties/version_put/access_layer_language",
49+
"ids_properties/version_put/data_dictionary",
50+
"ids_properties/homogeneous_time",
51+
"ids_properties/comment",
52+
"time",
53+
"profiles_1d/grid/rho_tor_norm",
54+
"profiles_1d/ion/temperature",
55+
"profiles_1d/grid/psi",
56+
"profiles_1d/q",
57+
"profiles_1d/e_field/radial",
58+
"profiles_1d/e_field/radial",
59+
"global_quantities/ip",
60+
}
61+
# Other occurrence should still raise an error:
62+
with pytest.raises(DataEntryException):
63+
dbentry.list_filled_paths("core_profiles", 1)
64+
# Until we write data to the occurrence:
65+
dbentry.put(cp, 3)
66+
assert set(filled_paths) == set(dbentry.list_filled_paths("core_profiles", 3))
67+
68+
69+
def test_list_filled_paths_autoconvert(testuri):
70+
with imas.DBEntry(testuri, "w", dd_version="3.25.0") as entry:
71+
ps = entry.factory.pulse_schedule()
72+
ps.ids_properties.homogeneous_time = IDS_TIME_MODE_INDEPENDENT
73+
ps.ec.antenna.resize(1)
74+
ps.ec.antenna[0].launching_angle_pol.reference_name = "test"
75+
entry.put(ps)
76+
77+
filled_paths = entry.list_filled_paths("pulse_schedule")
78+
assert set(filled_paths) == {
79+
"ids_properties/version_put/access_layer",
80+
"ids_properties/version_put/access_layer_language",
81+
"ids_properties/version_put/data_dictionary",
82+
"ids_properties/homogeneous_time",
83+
"ec/antenna/launching_angle_pol/reference_name",
84+
}
85+
86+
# Check autoconvert with DD 3.28.0
87+
with imas.DBEntry(testuri, "r", dd_version="3.28.0") as entry:
88+
assert set(entry.list_filled_paths("pulse_schedule", autoconvert=False)) == {
89+
"ids_properties/version_put/access_layer",
90+
"ids_properties/version_put/access_layer_language",
91+
"ids_properties/version_put/data_dictionary",
92+
"ids_properties/homogeneous_time",
93+
"ec/antenna/launching_angle_pol/reference_name", # original name
94+
}
95+
assert set(entry.list_filled_paths("pulse_schedule")) == {
96+
"ids_properties/version_put/access_layer",
97+
"ids_properties/version_put/access_layer_language",
98+
"ids_properties/version_put/data_dictionary",
99+
"ids_properties/homogeneous_time",
100+
"ec/launcher/steering_angle_pol/reference_name", # autoconverted name
101+
}

0 commit comments

Comments
 (0)