imas-python 0.1.dev1618__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of imas-python might be problematic. Click here for more details.

Files changed (108) hide show
  1. imas/__init__.py +34 -0
  2. imas/__main__.py +17 -0
  3. imas/_util.py +260 -0
  4. imas/_version.py +16 -0
  5. imas/assets/IDSDef2MDSpreTree.xsl +161 -0
  6. imas/assets/IDS_fake_toplevel.xml +318 -0
  7. imas/assets/IDS_minimal.xml +37 -0
  8. imas/assets/IDS_minimal_2.xml +37 -0
  9. imas/assets/IDS_minimal_struct_array.xml +43 -0
  10. imas/assets/IDS_minimal_types.xml +169 -0
  11. imas/assets/ITER_134173_106_core_profiles.ids +2438 -0
  12. imas/assets/ITER_134173_106_equilibrium.ids +3172 -0
  13. imas/assets/README.md +5 -0
  14. imas/assets/core_profiles.ids +2438 -0
  15. imas/assets/equilibrium.ids +3172 -0
  16. imas/backends/__init__.py +12 -0
  17. imas/backends/db_entry_impl.py +122 -0
  18. imas/backends/imas_core/__init__.py +4 -0
  19. imas/backends/imas_core/al_context.py +391 -0
  20. imas/backends/imas_core/db_entry_al.py +405 -0
  21. imas/backends/imas_core/db_entry_helpers.py +200 -0
  22. imas/backends/imas_core/imas_interface.py +216 -0
  23. imas/backends/imas_core/mdsplus_model.py +367 -0
  24. imas/backends/imas_core/uda_support.py +56 -0
  25. imas/backends/netcdf/__init__.py +4 -0
  26. imas/backends/netcdf/db_entry_nc.py +176 -0
  27. imas/backends/netcdf/ids2nc.py +306 -0
  28. imas/backends/netcdf/nc2ids.py +302 -0
  29. imas/backends/netcdf/nc_metadata.py +357 -0
  30. imas/backends/netcdf/nc_validate.py +53 -0
  31. imas/command/cli.py +239 -0
  32. imas/command/db_analysis.py +290 -0
  33. imas/command/helpers.py +45 -0
  34. imas/command/timer.py +77 -0
  35. imas/db_entry.py +802 -0
  36. imas/dd_helpers.py +300 -0
  37. imas/dd_zip.py +297 -0
  38. imas/exception.py +107 -0
  39. imas/ids_base.py +149 -0
  40. imas/ids_convert.py +920 -0
  41. imas/ids_coordinates.py +400 -0
  42. imas/ids_data_type.py +115 -0
  43. imas/ids_defs.py +171 -0
  44. imas/ids_factory.py +108 -0
  45. imas/ids_identifiers.py +125 -0
  46. imas/ids_metadata.py +307 -0
  47. imas/ids_path.py +254 -0
  48. imas/ids_primitive.py +510 -0
  49. imas/ids_struct_array.py +233 -0
  50. imas/ids_structure.py +331 -0
  51. imas/ids_toplevel.py +381 -0
  52. imas/setup_logging.py +64 -0
  53. imas/test/test_all_dd_versions.py +28 -0
  54. imas/test/test_cli.py +44 -0
  55. imas/test/test_dbentry.py +104 -0
  56. imas/test/test_dd_helpers.py +134 -0
  57. imas/test/test_dd_zip.py +29 -0
  58. imas/test/test_deepcopy.py +25 -0
  59. imas/test/test_exception.py +12 -0
  60. imas/test/test_get_sample.py +388 -0
  61. imas/test/test_hash.py +188 -0
  62. imas/test/test_helpers.py +354 -0
  63. imas/test/test_identifiers.py +105 -0
  64. imas/test/test_ids2nc.py +166 -0
  65. imas/test/test_ids_ascii_data.py +39 -0
  66. imas/test/test_ids_convert.py +415 -0
  67. imas/test/test_ids_coordinates.py +165 -0
  68. imas/test/test_ids_data_type.py +40 -0
  69. imas/test/test_ids_factory.py +35 -0
  70. imas/test/test_ids_metadata.py +66 -0
  71. imas/test/test_ids_mixin.py +20 -0
  72. imas/test/test_ids_path.py +192 -0
  73. imas/test/test_ids_primitive.py +109 -0
  74. imas/test/test_ids_struct_array.py +89 -0
  75. imas/test/test_ids_structure.py +70 -0
  76. imas/test/test_ids_toplevel.py +55 -0
  77. imas/test/test_ids_validate.py +343 -0
  78. imas/test/test_latest_dd_autofill.py +74 -0
  79. imas/test/test_latest_dd_manual.py +38 -0
  80. imas/test/test_latest_dd_resample.py +102 -0
  81. imas/test/test_lazy_loading.py +201 -0
  82. imas/test/test_list_occurrences.py +82 -0
  83. imas/test/test_minimal.py +37 -0
  84. imas/test/test_minimal_conversion.py +44 -0
  85. imas/test/test_minimal_io.py +21 -0
  86. imas/test/test_minimal_struct_array_io.py +60 -0
  87. imas/test/test_minimal_types.py +436 -0
  88. imas/test/test_minimal_types_io.py +70 -0
  89. imas/test/test_minimal_types_io_automatic.py +66 -0
  90. imas/test/test_nbc_change.py +408 -0
  91. imas/test/test_nc_autofill.py +13 -0
  92. imas/test/test_nc_entry.py +28 -0
  93. imas/test/test_nc_metadata.py +153 -0
  94. imas/test/test_nc_validation.py +197 -0
  95. imas/test/test_snippets.py +25 -0
  96. imas/test/test_static_ids.py +39 -0
  97. imas/test/test_str_1d.py +99 -0
  98. imas/test/test_time_slicing.py +182 -0
  99. imas/test/test_uda_support.py +12 -0
  100. imas/test/test_util.py +209 -0
  101. imas/training.py +45 -0
  102. imas/util.py +526 -0
  103. imas_python-0.1.dev1618.dist-info/LICENSE.txt +165 -0
  104. imas_python-0.1.dev1618.dist-info/METADATA +288 -0
  105. imas_python-0.1.dev1618.dist-info/RECORD +108 -0
  106. imas_python-0.1.dev1618.dist-info/WHEEL +5 -0
  107. imas_python-0.1.dev1618.dist-info/entry_points.txt +3 -0
  108. imas_python-0.1.dev1618.dist-info/top_level.txt +1 -0
@@ -0,0 +1,302 @@
1
+ import logging
2
+ import os
3
+ from typing import Iterator, List, Optional, Tuple
4
+
5
+ import netCDF4
6
+
7
+ from imas.backends.netcdf import ids2nc
8
+ from imas.backends.netcdf.nc_metadata import NCMetadata
9
+ from imas.exception import InvalidNetCDFEntry
10
+ from imas.ids_base import IDSBase
11
+ from imas.ids_data_type import IDSDataType
12
+ from imas.ids_defs import IDS_TIME_MODE_HOMOGENEOUS
13
+ from imas.ids_metadata import IDSMetadata
14
+ from imas.ids_structure import IDSStructure
15
+ from imas.ids_toplevel import IDSToplevel
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def variable_error(var, issue, value, expected=None) -> InvalidNetCDFEntry:
21
+ return InvalidNetCDFEntry(
22
+ f"Variable `{var.name}` has incorrect {issue}: `{value}`."
23
+ + (f" Was expecting `{expected}`." if expected is not None else "")
24
+ )
25
+
26
+
27
+ def split_on_aos(metadata: IDSMetadata):
28
+ paths = []
29
+ curpath = metadata.name
30
+
31
+ item = metadata
32
+ while item._parent.data_type is not None:
33
+ item = item._parent
34
+ if item.data_type is IDSDataType.STRUCT_ARRAY:
35
+ paths.append(curpath)
36
+ curpath = item.name
37
+ else:
38
+ curpath = f"{item.name}/{curpath}"
39
+ paths.append(curpath)
40
+ return paths[::-1]
41
+
42
+
43
+ IndexedNode = Tuple[Tuple[int, ...], IDSBase]
44
+
45
+
46
+ def tree_iter(structure: IDSStructure, metadata: IDSMetadata) -> Iterator[IndexedNode]:
47
+ paths = split_on_aos(metadata)
48
+ if len(paths) == 1:
49
+ yield (), structure[paths[0]]
50
+ else:
51
+ yield from _tree_iter(structure, paths, ())
52
+
53
+
54
+ def _tree_iter(
55
+ structure: IDSStructure, paths: List[str], curindex: Tuple[int, ...]
56
+ ) -> Iterator[IndexedNode]:
57
+ aos_path, *paths = paths
58
+ aos = structure[aos_path]
59
+
60
+ if len(paths) == 1:
61
+ path = paths[0]
62
+ for i, node in enumerate(aos):
63
+ yield curindex + (i,), node[path]
64
+
65
+ else:
66
+ for i, node in enumerate(aos):
67
+ yield from _tree_iter(node, paths, curindex + (i,))
68
+
69
+
70
+ class NC2IDS:
71
+ """Class responsible for reading an IDS from a NetCDF group."""
72
+
73
+ def __init__(self, group: netCDF4.Group, ids: IDSToplevel) -> None:
74
+ """Initialize NC2IDS converter.
75
+
76
+ Args:
77
+ group: NetCDF group that stores the IDS data.
78
+ ids: Corresponding IDS toplevel to store the data in.
79
+ """
80
+ self.group = group
81
+ """NetCDF Group that the IDS is stored in."""
82
+ self.ids = ids
83
+ """IDS to store the data in."""
84
+
85
+ self.ncmeta = NCMetadata(ids.metadata)
86
+ """NetCDF related metadata."""
87
+ self.variables = list(group.variables)
88
+ """List of variable names stored in the netCDF group."""
89
+ # Don't use masked arrays: they're slow and we'll handle most of the unset
90
+ # values through the `:shape` arrays
91
+ self.group.set_auto_mask(False)
92
+
93
+ # Validate and get value of ids_properties.homogeneous_time
94
+ self.homogeneous_time = True # Must be initialized for self._validate_variable
95
+ """True iff the IDS time mode is homogeneous."""
96
+
97
+ if "ids_properties.homogeneous_time" not in self.variables:
98
+ raise InvalidNetCDFEntry(
99
+ "Mandatory variable `ids_properties.homogeneous_time` does not exist."
100
+ )
101
+ var = group["ids_properties.homogeneous_time"]
102
+ self._validate_variable(var, ids.ids_properties.homogeneous_time.metadata)
103
+ if var[()] not in [0, 1, 2]:
104
+ raise InvalidNetCDFEntry(
105
+ f"Invalid value for ids_properties.homogeneous_time: {var[()]}. "
106
+ "Was expecting: 0, 1 or 2."
107
+ )
108
+ self.homogeneous_time = var[()] == IDS_TIME_MODE_HOMOGENEOUS
109
+
110
+ def run(self) -> None:
111
+ """Load the data from the netCDF group into the IDS."""
112
+ self.variables.sort()
113
+ self.validate_variables()
114
+ for var_name in self.variables:
115
+ if var_name.endswith(":shape"):
116
+ continue
117
+ metadata = self.ids.metadata[var_name]
118
+
119
+ if metadata.data_type is IDSDataType.STRUCTURE:
120
+ continue # This only contains DD metadata we already know
121
+
122
+ var = self.group[var_name]
123
+ if metadata.data_type is IDSDataType.STRUCT_ARRAY:
124
+ if "sparse" in var.ncattrs():
125
+ shapes = self.group[var_name + ":shape"][()]
126
+ for index, node in tree_iter(self.ids, metadata):
127
+ node.resize(shapes[index][0])
128
+
129
+ else:
130
+ # FIXME: extract dimension name from nc file?
131
+ dim = self.ncmeta.get_dimensions(
132
+ metadata.path_string, self.homogeneous_time
133
+ )[-1]
134
+ size = self.group.dimensions[dim].size
135
+ for _, node in tree_iter(self.ids, metadata):
136
+ node.resize(size)
137
+
138
+ continue
139
+
140
+ # FIXME: this may be a gigantic array, not required for sparse data
141
+ var = self.group[var_name]
142
+ data = var[()]
143
+
144
+ if "sparse" in var.ncattrs():
145
+ if metadata.ndim:
146
+ shapes = self.group[var_name + ":shape"][()]
147
+ for index, node in tree_iter(self.ids, metadata):
148
+ shape = shapes[index]
149
+ if shape.all():
150
+ node.value = data[index + tuple(map(slice, shapes[index]))]
151
+ else:
152
+ for index, node in tree_iter(self.ids, metadata):
153
+ value = data[index]
154
+ if value != getattr(var, "_FillValue", None):
155
+ node.value = data[index]
156
+
157
+ elif metadata.path_string not in self.ncmeta.aos:
158
+ # Shortcut for assigning untensorized data
159
+ self.ids[metadata.path] = data
160
+
161
+ else:
162
+ for index, node in tree_iter(self.ids, metadata):
163
+ node.value = data[index]
164
+
165
+ def validate_variables(self) -> None:
166
+ """Validate that all variables in the netCDF Group exist and match the DD."""
167
+ disable_validate = os.environ.get("IMASPY_DISABLE_NC_VALIDATE")
168
+ if disable_validate and disable_validate != "0":
169
+ logger.info(
170
+ "NetCDF file validation disabled: "
171
+ "This may lead to errors when reading data!"
172
+ )
173
+ return # validation checks are disabled
174
+
175
+ for var_name in self.variables:
176
+ if var_name.endswith(":shape"):
177
+ # Check that there is a corresponding variable
178
+ data_var = var_name.rpartition(":shape")[0]
179
+ if data_var not in self.variables:
180
+ raise InvalidNetCDFEntry(
181
+ f"Invalid netCDF variable: {var_name}. "
182
+ f"Shape information provided for non-existing {data_var}."
183
+ )
184
+ # Corresponding variable must be sparse
185
+ if "sparse" not in self.group[data_var].ncattrs():
186
+ raise InvalidNetCDFEntry(
187
+ f"Shape information provided for {data_var}, but this variable "
188
+ "is not sparse."
189
+ )
190
+ # That's all for :shape arrays here, rest is checked in
191
+ # _validate_variable (which defers to _validate_sparsity)
192
+ continue
193
+
194
+ # Check that the DD defines this variable, and validate its metadata
195
+ var = self.group[var_name]
196
+ try:
197
+ metadata = self.ids.metadata[var_name]
198
+ except KeyError:
199
+ raise InvalidNetCDFEntry(
200
+ f"Invalid variable {var_name}: no such variable exists in the "
201
+ f"{self.ids.metadata.name} IDS."
202
+ )
203
+ self._validate_variable(var, metadata)
204
+
205
+ def _validate_variable(self, var: netCDF4.Variable, metadata: IDSMetadata) -> None:
206
+ """Validate that the variable has correct metadata, raise an exception if not.
207
+
208
+ Args:
209
+ var: NetCDF variable
210
+ metadata: IDSMetadata of the corresponding IDS object
211
+ """
212
+ attrs: dict = vars(var).copy()
213
+ attrs.pop("_FillValue", None)
214
+ if metadata.data_type not in [IDSDataType.STRUCTURE, IDSDataType.STRUCT_ARRAY]:
215
+ # Data type
216
+ expected_dtype = ids2nc.dtypes[metadata.data_type]
217
+ if var.dtype != expected_dtype:
218
+ raise variable_error(var, "data type", var.dtype, expected_dtype)
219
+
220
+ # Dimensions
221
+ expected_dims = self.ncmeta.get_dimensions(
222
+ metadata.path_string, self.homogeneous_time
223
+ )
224
+ if var.dimensions != expected_dims:
225
+ raise variable_error(var, "dimensions", var.dimensions, expected_dims)
226
+
227
+ # Coordinates
228
+ coordinates = str(attrs.pop("coordinates", ""))
229
+ expected_coordinates = self.ncmeta.get_coordinates(
230
+ metadata.path_string, self.homogeneous_time
231
+ )
232
+ if any(coord not in expected_coordinates for coord in coordinates.split()):
233
+ raise variable_error(
234
+ var, "coordinates", coordinates, " ".join(expected_coordinates)
235
+ )
236
+
237
+ # Ancillary variables
238
+ ancvar = attrs.pop("ancillary_variables", None)
239
+ if ancvar:
240
+ allowed_ancvar = [f"{var.name}_error_upper", f"{var.name}_error_lower"]
241
+ if any(var not in allowed_ancvar for var in ancvar.split()):
242
+ raise variable_error(
243
+ var, "ancillary_variables", ancvar, " ".join(allowed_ancvar)
244
+ )
245
+
246
+ # Units
247
+ units = attrs.pop("units", None)
248
+ if metadata.units and metadata.units != units:
249
+ raise variable_error(var, "units", units, metadata.units)
250
+
251
+ # Sparse
252
+ sparse = attrs.pop("sparse", None)
253
+ if sparse is not None:
254
+ shape_name = f"{var.name}:shape"
255
+ shape_var = self.group[shape_name] if shape_name in self.variables else None
256
+ self._validate_sparsity(var, shape_var, metadata)
257
+
258
+ # Documentation
259
+ doc = attrs.pop("documentation", None)
260
+ if metadata.documentation != doc:
261
+ logger.warning("Documentation of variable %s differs from the DD", var.name)
262
+
263
+ # Unknown attrs
264
+ if attrs:
265
+ raise variable_error(var, "attributes", list(attrs.keys()))
266
+
267
+ def _validate_sparsity(
268
+ self,
269
+ var: netCDF4.Variable,
270
+ shape_var: Optional[netCDF4.Variable],
271
+ metadata: IDSMetadata,
272
+ ) -> None:
273
+ """Validate that the variable has correct sparsity.
274
+
275
+ Args:
276
+ var: Variable with a "sparse" attribute
277
+ shape_var: Corresponding shape array (if it exists in the NC group)
278
+ metadata: IDSMetadata of the corresponding IDS object
279
+ """
280
+ if metadata.ndim == 0:
281
+ return # Sparsity is stored with _Fillvalue, nothing to validate
282
+
283
+ # Dimensions
284
+ aos_dimensions = self.ncmeta.get_dimensions(
285
+ self.ncmeta.aos.get(metadata.path_string), self.homogeneous_time
286
+ )
287
+ shape_dimensions = shape_var.dimensions
288
+ if (
289
+ len(shape_dimensions) != len(aos_dimensions) + 1
290
+ or shape_dimensions[:-1] != aos_dimensions
291
+ or self.group.dimensions[shape_dimensions[-1]].size != metadata.ndim
292
+ ):
293
+ expected_dims = aos_dimensions + (f"{metadata.ndim}D",)
294
+ raise variable_error(
295
+ shape_var, "dimensions", shape_dimensions, expected_dims
296
+ )
297
+
298
+ # Data type
299
+ if shape_var.dtype.kind not in "ui": # should be (un)signed integer
300
+ raise variable_error(
301
+ shape_var, "dtype", shape_var.dtype, "any integer type"
302
+ )
@@ -0,0 +1,357 @@
1
+ # This file is part of imas-python.
2
+ # You should have received the imas-python LICENSE file with this project.
3
+ """NetCDF metadata for dimensions and tensorization of IDSs.
4
+ """
5
+
6
+ from functools import lru_cache
7
+ from typing import Dict, List, Optional, Set, Tuple
8
+
9
+ from imas.ids_coordinates import IDSCoordinate
10
+ from imas.ids_data_type import IDSDataType
11
+ from imas.ids_metadata import IDSMetadata
12
+
13
+
14
+ def _get_aos_label_coordinates(metadata: IDSMetadata) -> List[str]:
15
+ """Extract label coordinates from an Array of Structures metadata."""
16
+ coordinates = []
17
+ for child_name in ("name", "identifier", "label"):
18
+ if child_name in metadata._children:
19
+ label_meta = metadata._children[child_name]
20
+ if label_meta.data_type is IDSDataType.STR and label_meta.ndim == 0:
21
+ coordinates.append(label_meta.path_string.replace("/", "."))
22
+ return coordinates
23
+
24
+
25
+ class NCMetadata:
26
+ """NCMetadata contains additional netCDF metadata for an IDS data structure.
27
+
28
+ When constructing an NCMetadata, the complete IDS structure is scanned and all DD
29
+ coordinate information is parsed. This information is used to construct netCDF
30
+ dimension information for all quantities in the IDS.
31
+
32
+ Coordinate parsing is done in three phases:
33
+
34
+ 1. Traverse the full metadata tree and parse coordinate information for all
35
+ quantities. See ``_parse()`` and ``_parse_dimensions()``.
36
+ 2. Resolve shared dimensions. See ``_resolve_pending()``.
37
+ 3. Tensorize all quantities. See ``_tensorize_dimensions()``.
38
+ """
39
+
40
+ def __init__(self, ids_metadata: IDSMetadata) -> None:
41
+ if ids_metadata._parent is not None:
42
+ raise ValueError("Toplevel IDS metadata is required.")
43
+
44
+ self.ids_metadata = ids_metadata
45
+ """Metadata of the IDS toplevel that this NC metadata is for."""
46
+ self.time_dimensions: Set[str] = set()
47
+ """Set of all (inhomogeneous) time dimensions."""
48
+ self.dimensions: Dict[str, List[str]] = {}
49
+ """Mapping of paths to dimension names."""
50
+ self.coordinates: Dict[str, List[str]] = {}
51
+ """Mapping of paths to coordinate variable names."""
52
+ self.aos: Dict[str, str] = {}
53
+ """Mapping of paths to their nearest AoS parent."""
54
+ self.paths: List[str] = []
55
+ """List of all paths."""
56
+
57
+ # Temporary variables for parsing coordinates
58
+ # Pending coordinate references
59
+ self._pending = {} # (path, dimension): (coordinate_path, coordinate_dimension)
60
+ # Dimensions before tensorization
61
+ self._ut_dims = {} # path: [dim1, dim2, ...]
62
+ # Coordinates for each dimension
63
+ self._dim_coordinates = {} # dimension: [coor1, coor2, ...]
64
+ # Alternative coordinates
65
+ self._alternatives = {} # path: [alt1, alt2, ...]
66
+
67
+ # Parse the whole metadata tree
68
+ self._parse(ids_metadata, None, 0)
69
+ self._merge_alternatives()
70
+ try:
71
+ self._resolve_pending()
72
+ except RecursionError:
73
+ raise RuntimeError(
74
+ "Unable to resolve data dictionary coordinates, does the DD contain"
75
+ " circular coordinate references?"
76
+ ) from None
77
+ self._tensorize_dimensions()
78
+ # Delete temporary variables
79
+ del self._pending, self._ut_dims, self._dim_coordinates, self._alternatives
80
+
81
+ # Sanity check:
82
+ assert len(self.dimensions) == len(set(self.dimensions))
83
+
84
+ self.time_coordinates: Set[str] = {
85
+ dimension.partition(":")[0] for dimension in self.time_dimensions
86
+ }
87
+ """All coordinate variable names representing (inhomogeneous) time."""
88
+
89
+ # Add cache for public API
90
+ self.get_dimensions = lru_cache(maxsize=None)(self.get_dimensions)
91
+
92
+ def get_coordinates(self, path: str, homogeneous_time: bool) -> Tuple[str]:
93
+ """Get the coordinates (adhering to CF conventions) for a netCDF variable.
94
+
95
+ Args:
96
+ path: Data Dictionary path to the variable, e.g. ``ids_properties/comment``.
97
+ homogeneous_time: Use homogeneous time coordinates. When True,
98
+ ``ids_properties.homogeneous_time`` should be set to ``1``.
99
+ """
100
+ if path not in self.coordinates:
101
+ return ()
102
+
103
+ if not homogeneous_time:
104
+ return tuple(self.coordinates[path])
105
+
106
+ # Replace inhomogeneous time coordinates with root time:
107
+ return tuple(
108
+ "time" if coord in self.time_coordinates else coord
109
+ for coord in self.coordinates[path]
110
+ )
111
+
112
+ def get_dimensions(self, path: str, homogeneous_time: bool) -> Tuple[str]:
113
+ """Get the dimensions for a netCDF variable.
114
+
115
+ Args:
116
+ path: Data Dictionary path to the variable, e.g. ``ids_properties/comment``.
117
+ homogeneous_time: Use homogeneous time coordinates. When True,
118
+ ``ids_properties.homogeneous_time`` should be set to ``1``.
119
+ """
120
+ if path not in self.dimensions:
121
+ return ()
122
+
123
+ if not homogeneous_time:
124
+ return tuple(self.dimensions[path])
125
+
126
+ # Replace inhomogeneous time dimensions with root time:
127
+ return tuple(
128
+ "time" if dim in self.time_dimensions else dim
129
+ for dim in self.dimensions[path]
130
+ )
131
+
132
+ def _parse(
133
+ self, metadata: IDSMetadata, parent_aos: Optional[str], aos_level: int
134
+ ) -> None:
135
+ """Recursively parse DD coordinates."""
136
+ for child in metadata._children.values():
137
+ self.paths.append(child.path_string)
138
+ if parent_aos:
139
+ self.aos[child.path_string] = parent_aos
140
+
141
+ if child.data_type is IDSDataType.STRUCTURE:
142
+ self._parse(child, parent_aos, aos_level)
143
+ elif child.ndim:
144
+ self._parse_dimensions(child, aos_level)
145
+ if child.data_type is IDSDataType.STRUCT_ARRAY:
146
+ self._parse(child, child.path_string, aos_level + 1)
147
+ elif parent_aos:
148
+ # These 0D items will have dimensions after tensorizing
149
+ self._ut_dims[child.path_string] = []
150
+ # 0D items without a parent AOS don't have dimensions: we don't store them
151
+
152
+ def _parse_dimensions(self, metadata: IDSMetadata, aos_level: int) -> None:
153
+ """Parse dimensions and auxiliary coordinates from DD coordinate metadata.
154
+
155
+ DD coordinates come in different flavours (see also
156
+ :mod:`imas.ids_coordinates`), which we handle in this function:
157
+
158
+ 1. Coordinate is an index.
159
+
160
+ This is expressed in the Data Dictionary as ``coordinateX=1...N``, where
161
+ ``N`` can be an integer indicating the exact size of the dimension, or a
162
+ literal ``N`` when the dimension is unbounded.
163
+
164
+ Such an index will become its own netCDF dimension.
165
+
166
+ 2. Coordinate shares a dimension with another variable, but there is no
167
+ explicit coordinate variable in the DD.
168
+
169
+ This is expressed in the Data Dictionary as ``coordinateX=1...N`` (like in
170
+ case 1), but in addition there is an attribute ``coordinateX_same_as=...``
171
+ which indicates the variable it shares its dimension with.
172
+
173
+ 3. Coordinate refers to another quantity in the DD.
174
+
175
+ This is expressed in the Data Dictionary as ``coordinateX=quantity`` which
176
+ indicates the variable that is the coordinate. Note that a time coordinate
177
+ is treated specially, see below.
178
+
179
+ a. Starting in Data Dictionary version 4.0.0, the coordinate quantity can
180
+ indicate that there are alternatives for itself. This is expressed as
181
+ ``alternative_coordinate1=quantity1;quantity2;...``.
182
+
183
+ 4. Coordinate refers to multiple other quantities in the DD.
184
+
185
+ This is expressed in the Data Dictionary as ``coordinateX=quantity1 OR
186
+ quantity2 [OR ...]``.
187
+
188
+ Notes:
189
+
190
+ - It is assumed that there are no circular coordinate references, i.e. no two
191
+ quantities in the Data Dictionary point to eachother as a coordinate.
192
+ - Time dimensions and coordinate names are recorded separately. When using
193
+ homogeneous_time, all time coordinates point to the root ``time`` quantity
194
+ instead of the quantity recorded in the coordinate properties.
195
+ - Error bars of quantities (quantities whose name ends in ``_error_upper`` or
196
+ ``_error_lower``) always share the dimensions of the quantities they belong
197
+ to.
198
+ """
199
+ path = metadata.path_string
200
+ # Handle errorbar quantities
201
+ if path.endswith("_error_upper") or path.endswith("_error_lower"):
202
+ self._ut_dims[path] = [None] * metadata.ndim
203
+ # Note: only works because _error_upper/_error_lower both have length 12
204
+ coordinate_path = path[:-12]
205
+ for i in range(metadata.ndim):
206
+ self._pending[(path, i)] = (coordinate_path, i)
207
+ return
208
+
209
+ # Handle regular nodes
210
+ dimensions = []
211
+ for i, coord in enumerate(metadata.coordinates):
212
+ dim_name = None
213
+ if coord.references:
214
+ # ------ CASE 3 or 4: refers to other quantities ------
215
+ if metadata.path.is_ancestor_of(coord.references[0]):
216
+ # Coordinate is inside this AoS (and must be 0D): create dimension
217
+ # E.g. core_profiles IDS: profiles_1d -> profiles_1d/time
218
+ dim_name = ".".join(coord.references[0].parts)
219
+ is_time_dimension = coord.is_time_coordinate
220
+ coordinates = [dim_name]
221
+
222
+ else:
223
+ main_coord_path = self._handle_alternatives(coord)
224
+ self._pending[(path, i)] = (main_coord_path, 0)
225
+
226
+ else:
227
+ same_as = metadata.coordinates_same_as[i]
228
+ if same_as.references:
229
+ # ------ CASE 2: coordinate is same as another ------
230
+ # There currently is no case with alternative coordinate_same_as,
231
+ # if this ever changes we need to think how to handle this...
232
+ assert len(same_as.references) == 1
233
+ main_coord_path = self._handle_alternatives(same_as)
234
+ self._pending[(path, i)] = (main_coord_path, i)
235
+
236
+ else:
237
+ # ------ CASE 1: coordinate is an index ------
238
+ # Create a new dimension
239
+ dim_name = path.replace("/", ".")
240
+ coordinates = [dim_name]
241
+ if (
242
+ aos_level + metadata.ndim != 1
243
+ or metadata.data_type is IDSDataType.STRUCT_ARRAY
244
+ ):
245
+ # This variable is >1D after tensorization, or it is an AoS,
246
+ # so we cannot use our path as dimension name: add suffix
247
+ suffix = "ijklmn"[i]
248
+ dim_name = f"{dim_name}:{suffix}"
249
+ is_time_dimension = metadata.name == "time"
250
+ if metadata.data_type is IDSDataType.STRUCT_ARRAY:
251
+ # Check if name/identifier/label exists
252
+ coordinates = _get_aos_label_coordinates(metadata)
253
+
254
+ dimensions.append(dim_name)
255
+ if dim_name is not None:
256
+ if is_time_dimension:
257
+ # Record time dimension
258
+ self.time_dimensions.add(dim_name)
259
+ # Record coordinates for this dimension
260
+ self._dim_coordinates[dim_name] = coordinates
261
+
262
+ # Handle DDv4 alternative coordinates
263
+ if metadata.alternative_coordinates:
264
+ # Record for _merge_alternatives()
265
+ self._alternatives.setdefault(metadata.path_string, []).extend(
266
+ "/".join(ref.parts) for ref in metadata.alternative_coordinates
267
+ )
268
+ # Add alternatives to the coordinates for this dimension
269
+ self._dim_coordinates[dim_name].extend(
270
+ ".".join(ref.parts) for ref in metadata.alternative_coordinates
271
+ )
272
+
273
+ # Store untensorized dimensions
274
+ self._ut_dims[path] = dimensions
275
+
276
+ def _handle_alternatives(self, coord: IDSCoordinate) -> str:
277
+ """Handle alternative coordinates. Return main coordinate path."""
278
+ main, *others = ["/".join(ref.parts) for ref in coord.references]
279
+ if others:
280
+ if main in self._alternatives:
281
+ self._alternatives[main].extend(others)
282
+ else:
283
+ self._alternatives[main] = others
284
+ return main
285
+
286
+ def _merge_alternatives(self) -> None:
287
+ """Merge all alternative coordinates to use the same dimension."""
288
+ for path, alternatives in self._alternatives.items():
289
+ # Alternatives are only applicable for 1D nodes
290
+ assert len(self._ut_dims[path]) == 1
291
+ for alternative in alternatives:
292
+ # Alternatives are only applicable for 1D nodes
293
+ assert len(self._ut_dims[alternative]) == 1
294
+ if self._ut_dims[alternative][0] is None:
295
+ assert self._pending[(alternative, 0)] == (path, 0)
296
+ else:
297
+ self._ut_dims[alternative][0] = None
298
+ self._pending[(alternative, 0)] = (path, 0)
299
+
300
+ # Update coordinates
301
+ dim_name = self._ut_dims[path][0]
302
+ alternative_coordinates = [alt.replace("/", ".") for alt in alternatives]
303
+ # Only add items not already present
304
+ coordinates = self._dim_coordinates[dim_name]
305
+ for alt in alternative_coordinates:
306
+ if alt not in coordinates:
307
+ coordinates.append(alt)
308
+
309
+ def _resolve_pending(self):
310
+ """Resolve all pending dimension references."""
311
+ pending_items = self._pending.items()
312
+ self._pending = {}
313
+
314
+ for (path, dimension), (coor_path, coor_dimension) in pending_items:
315
+ dim = self._ut_dims[coor_path][coor_dimension]
316
+ if dim is None:
317
+ # We refer to a (still) unresolved coordinate, put back in the queue:
318
+ self._pending[(path, dimension)] = (coor_path, coor_dimension)
319
+ else:
320
+ # Reference is resolved:
321
+ self._ut_dims[path][dimension] = dim
322
+
323
+ # If we have any pending left, try to resolve them again.
324
+ # Note: if there are circular references we cannot resolve them, and this will
325
+ # at some point raise a RecursionError. This error is caught in __init__() and a
326
+ # more meaningful exception is raised instead.
327
+ if self._pending:
328
+ self._resolve_pending()
329
+
330
+ def _tensorize_dimensions(self):
331
+ """Create the final tensorized data structures.
332
+
333
+ This prepends all dimensions (coordinates) with the dimensions (coordinates) of
334
+ their ancestor Array of Structures.
335
+ """
336
+ for path in self._ut_dims:
337
+ aos_dims = coordinates = []
338
+ aos = self.aos.get(path)
339
+ if aos is not None:
340
+ # Note: by construction of self._ut_dims, we know that ancestor AOSs are
341
+ # always handled before their children. self.dimensions[aos] must
342
+ # therefore exist:
343
+ aos_dims = self.dimensions[aos]
344
+ coordinates = self.coordinates.get(aos, []).copy()
345
+ self.dimensions[path] = aos_dims + self._ut_dims[path]
346
+
347
+ # Set auxiliary coordinates
348
+ metadata = self.ids_metadata[path]
349
+ for coord, dim_name in zip(metadata.coordinates, self._ut_dims[path]):
350
+ if coord.references:
351
+ coordinates.extend(self._dim_coordinates[dim_name])
352
+
353
+ if metadata.data_type is IDSDataType.STRUCT_ARRAY:
354
+ # Check if name/identifier/label exists
355
+ coordinates.extend(_get_aos_label_coordinates(metadata))
356
+ if coordinates:
357
+ self.coordinates[path] = coordinates