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.
- imas/__init__.py +34 -0
- imas/__main__.py +17 -0
- imas/_util.py +260 -0
- imas/_version.py +16 -0
- imas/assets/IDSDef2MDSpreTree.xsl +161 -0
- imas/assets/IDS_fake_toplevel.xml +318 -0
- imas/assets/IDS_minimal.xml +37 -0
- imas/assets/IDS_minimal_2.xml +37 -0
- imas/assets/IDS_minimal_struct_array.xml +43 -0
- imas/assets/IDS_minimal_types.xml +169 -0
- imas/assets/ITER_134173_106_core_profiles.ids +2438 -0
- imas/assets/ITER_134173_106_equilibrium.ids +3172 -0
- imas/assets/README.md +5 -0
- imas/assets/core_profiles.ids +2438 -0
- imas/assets/equilibrium.ids +3172 -0
- imas/backends/__init__.py +12 -0
- imas/backends/db_entry_impl.py +122 -0
- imas/backends/imas_core/__init__.py +4 -0
- imas/backends/imas_core/al_context.py +391 -0
- imas/backends/imas_core/db_entry_al.py +405 -0
- imas/backends/imas_core/db_entry_helpers.py +200 -0
- imas/backends/imas_core/imas_interface.py +216 -0
- imas/backends/imas_core/mdsplus_model.py +367 -0
- imas/backends/imas_core/uda_support.py +56 -0
- imas/backends/netcdf/__init__.py +4 -0
- imas/backends/netcdf/db_entry_nc.py +176 -0
- imas/backends/netcdf/ids2nc.py +306 -0
- imas/backends/netcdf/nc2ids.py +302 -0
- imas/backends/netcdf/nc_metadata.py +357 -0
- imas/backends/netcdf/nc_validate.py +53 -0
- imas/command/cli.py +239 -0
- imas/command/db_analysis.py +290 -0
- imas/command/helpers.py +45 -0
- imas/command/timer.py +77 -0
- imas/db_entry.py +802 -0
- imas/dd_helpers.py +300 -0
- imas/dd_zip.py +297 -0
- imas/exception.py +107 -0
- imas/ids_base.py +149 -0
- imas/ids_convert.py +920 -0
- imas/ids_coordinates.py +400 -0
- imas/ids_data_type.py +115 -0
- imas/ids_defs.py +171 -0
- imas/ids_factory.py +108 -0
- imas/ids_identifiers.py +125 -0
- imas/ids_metadata.py +307 -0
- imas/ids_path.py +254 -0
- imas/ids_primitive.py +510 -0
- imas/ids_struct_array.py +233 -0
- imas/ids_structure.py +331 -0
- imas/ids_toplevel.py +381 -0
- imas/setup_logging.py +64 -0
- imas/test/test_all_dd_versions.py +28 -0
- imas/test/test_cli.py +44 -0
- imas/test/test_dbentry.py +104 -0
- imas/test/test_dd_helpers.py +134 -0
- imas/test/test_dd_zip.py +29 -0
- imas/test/test_deepcopy.py +25 -0
- imas/test/test_exception.py +12 -0
- imas/test/test_get_sample.py +388 -0
- imas/test/test_hash.py +188 -0
- imas/test/test_helpers.py +354 -0
- imas/test/test_identifiers.py +105 -0
- imas/test/test_ids2nc.py +166 -0
- imas/test/test_ids_ascii_data.py +39 -0
- imas/test/test_ids_convert.py +415 -0
- imas/test/test_ids_coordinates.py +165 -0
- imas/test/test_ids_data_type.py +40 -0
- imas/test/test_ids_factory.py +35 -0
- imas/test/test_ids_metadata.py +66 -0
- imas/test/test_ids_mixin.py +20 -0
- imas/test/test_ids_path.py +192 -0
- imas/test/test_ids_primitive.py +109 -0
- imas/test/test_ids_struct_array.py +89 -0
- imas/test/test_ids_structure.py +70 -0
- imas/test/test_ids_toplevel.py +55 -0
- imas/test/test_ids_validate.py +343 -0
- imas/test/test_latest_dd_autofill.py +74 -0
- imas/test/test_latest_dd_manual.py +38 -0
- imas/test/test_latest_dd_resample.py +102 -0
- imas/test/test_lazy_loading.py +201 -0
- imas/test/test_list_occurrences.py +82 -0
- imas/test/test_minimal.py +37 -0
- imas/test/test_minimal_conversion.py +44 -0
- imas/test/test_minimal_io.py +21 -0
- imas/test/test_minimal_struct_array_io.py +60 -0
- imas/test/test_minimal_types.py +436 -0
- imas/test/test_minimal_types_io.py +70 -0
- imas/test/test_minimal_types_io_automatic.py +66 -0
- imas/test/test_nbc_change.py +408 -0
- imas/test/test_nc_autofill.py +13 -0
- imas/test/test_nc_entry.py +28 -0
- imas/test/test_nc_metadata.py +153 -0
- imas/test/test_nc_validation.py +197 -0
- imas/test/test_snippets.py +25 -0
- imas/test/test_static_ids.py +39 -0
- imas/test/test_str_1d.py +99 -0
- imas/test/test_time_slicing.py +182 -0
- imas/test/test_uda_support.py +12 -0
- imas/test/test_util.py +209 -0
- imas/training.py +45 -0
- imas/util.py +526 -0
- imas_python-0.1.dev1618.dist-info/LICENSE.txt +165 -0
- imas_python-0.1.dev1618.dist-info/METADATA +288 -0
- imas_python-0.1.dev1618.dist-info/RECORD +108 -0
- imas_python-0.1.dev1618.dist-info/WHEEL +5 -0
- imas_python-0.1.dev1618.dist-info/entry_points.txt +3 -0
- 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
|