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
imas/ids_convert.py ADDED
@@ -0,0 +1,920 @@
1
+ # This file is part of imas-python.
2
+ # You should have received the imas-python LICENSE file with this project.
3
+ """Functionality for converting IDSToplevels between DD versions.
4
+ """
5
+
6
+ import copy
7
+ import datetime
8
+ import logging
9
+ from functools import lru_cache
10
+ from pathlib import Path
11
+ from typing import Callable, Dict, Iterator, Optional, Set, Tuple
12
+ from xml.etree.ElementTree import Element, ElementTree
13
+
14
+ import numpy
15
+ from packaging.version import InvalidVersion, Version
16
+
17
+ import imas
18
+ from imas.dd_zip import parse_dd_version
19
+ from imas.ids_base import IDSBase
20
+ from imas.ids_data_type import IDSDataType
21
+ from imas.ids_factory import IDSFactory
22
+ from imas.ids_path import IDSPath
23
+ from imas.ids_primitive import (
24
+ IDSNumeric0D,
25
+ IDSNumericArray,
26
+ IDSPrimitive,
27
+ IDSString0D,
28
+ )
29
+ from imas.ids_struct_array import IDSStructArray
30
+ from imas.ids_structure import IDSStructure
31
+ from imas.ids_toplevel import IDSToplevel
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ def iter_parents(path: str) -> Iterator[str]:
37
+ """Iterate over parents of this path, starting with the highest-level parent.
38
+
39
+ Example:
40
+ >>> list(iter_parents("abc/def/ghi"))
41
+ ["abc", "abc/def"]
42
+
43
+ Args:
44
+ path: Path to get parents of.
45
+
46
+ Yields:
47
+ Parent paths of the provided path.
48
+ """
49
+ i_slash = path.find("/")
50
+ while i_slash != -1:
51
+ yield path[:i_slash]
52
+ i_slash = path.find("/", i_slash + 1)
53
+
54
+
55
+ class NBCPathMap:
56
+ """Object mapping paths in one DD version to path, timebasepath and context path."""
57
+
58
+ def __init__(self) -> None:
59
+ self.path: Dict[str, Optional[str]] = {}
60
+ """Dictionary mapping the ids path
61
+
62
+ - When no changes have occurred (which is assumed to be the default case), the
63
+ path is not present in the dictionary.
64
+ - When an element is renamed it maps the old to the new name (and vice versa).
65
+ - When an element does not exist in the other version, it is mapped to None.
66
+ """
67
+
68
+ self.tbp: Dict[str, str] = {}
69
+ """Map providing the timebasepath for renamed elements."""
70
+
71
+ self.ctxpath: Dict[str, str] = {}
72
+ """Map providing the lowlevel context path for renamed elements."""
73
+
74
+ self.type_change: Dict[str, Optional[Callable[[IDSBase, IDSBase], None]]] = {}
75
+ """Dictionary of paths that had a type change.
76
+
77
+ Type changes are mapped to None in :py:attr:`path`, this ``dict`` allows to
78
+ distinguish between a type change and a removed node.
79
+
80
+ Optionally, a function can be set to the path. This function can be called to
81
+ perform a supported conversion between the new and old type (or vice versa).
82
+ """
83
+
84
+ self.post_process: Dict[str, Callable[[IDSBase], None]] = {}
85
+ """Map providing postprocess functions for paths.
86
+
87
+ The postprocess function should be applied to the nodes after all the data is
88
+ converted.
89
+ """
90
+
91
+ self.ignore_missing_paths: Set[str] = set()
92
+ """Set of paths that should not be logged when data is present."""
93
+
94
+ def __setitem__(self, path: str, value: Tuple[Optional[str], str, str]) -> None:
95
+ self.path[path], self.tbp[path], self.ctxpath[path] = value
96
+
97
+ def __contains__(self, path: str) -> bool:
98
+ return path in self.path
99
+
100
+ def __iter__(self) -> Iterator[str]:
101
+ return iter(self.path)
102
+
103
+
104
+ # Expected typical use case is conversion between two versions only. With 74 IDSs
105
+ # (DD 3.39.0) a cache of 128 items should be big enough.
106
+ @lru_cache(maxsize=128)
107
+ def _DDVersionMap(*args) -> "DDVersionMap":
108
+ return DDVersionMap(*args)
109
+
110
+
111
+ class DDVersionMap:
112
+ """Mapping of an IDS between two Data Dictionary versions."""
113
+
114
+ RENAMED_DESCRIPTIONS = {"aos_renamed", "leaf_renamed", "structure_renamed"}
115
+ """Supported ``nbc_description`` values for renames."""
116
+ STRUCTURE_TYPES = {"structure", "struct_array"}
117
+ """Data dictionary ``data_type`` corresponding to structure-like types."""
118
+
119
+ def __init__(
120
+ self,
121
+ ids_name: str,
122
+ old_version: ElementTree,
123
+ new_version: ElementTree,
124
+ version_old: Version,
125
+ ):
126
+ self.ids_name = ids_name
127
+ self.old_version = old_version
128
+ self.new_version = new_version
129
+ self.version_old = version_old
130
+
131
+ self.old_to_new = NBCPathMap()
132
+ self.new_to_old = NBCPathMap()
133
+
134
+ old_ids_object = old_version.find(f"IDS[@name='{ids_name}']")
135
+ new_ids_object = new_version.find(f"IDS[@name='{ids_name}']")
136
+ if old_ids_object is None or new_ids_object is None:
137
+ raise ValueError(
138
+ f"Cannot find IDS {ids_name} in the provided DD definitions."
139
+ )
140
+ self._build_map(old_ids_object, new_ids_object)
141
+
142
+ def _check_data_type(self, old_item: Element, new_item: Element):
143
+ """Check if data type hasn't changed.
144
+
145
+ Record paths in mapping if data type change is unsupported.
146
+
147
+ Returns:
148
+ True iff the data type of both items are the same.
149
+ """
150
+ new_data_type = new_item.get("data_type")
151
+ old_data_type = old_item.get("data_type")
152
+ if IDSDataType.parse(new_data_type) == IDSDataType.parse(old_data_type):
153
+ return True
154
+
155
+ # Data type changed, record in type_change sets
156
+ new_path = new_item.get("path")
157
+ old_path = old_item.get("path")
158
+ assert new_path is not None
159
+ assert old_path is not None
160
+
161
+ # Check if we (maybe) support this type change
162
+ data_types = sorted([new_data_type, old_data_type])
163
+ if data_types == ["struct_array", "structure"]:
164
+ self.new_to_old.type_change[new_path] = _type_changed_structure_aos
165
+ self.old_to_new.type_change[old_path] = _type_changed_structure_aos
166
+ elif data_types in (
167
+ ["INT_0D", "INT_1D"],
168
+ ["FLT_0D", "FLT_1D"],
169
+ ["CPX_0D", "CPX_1D"],
170
+ ["STR_0D", "STR_1D"],
171
+ ):
172
+ self.new_to_old.type_change[new_path] = _type_changed_0d_1d_data
173
+ self.old_to_new.type_change[old_path] = _type_changed_0d_1d_data
174
+ elif old_data_type == "INT_1D" and new_item.get("doc_identifier"):
175
+ # DD3to4: Support change of grid_ggd/grid/space/coordinates_type from an
176
+ # INT_1D to a proper identifier
177
+ self.new_to_old.type_change[new_path] = _type_changed_to_identifier
178
+ self.old_to_new.type_change[old_path] = _type_changed_to_identifier
179
+ elif data_types == ["FLT_0D", "INT_0D"]:
180
+ # Support conversion of FLT_0D -> INT_0D when the float is integral
181
+ self.new_to_old.type_change[new_path] = _type_changed_flt0d_int0d
182
+ self.old_to_new.type_change[old_path] = _type_changed_flt0d_int0d
183
+ else:
184
+ logger.debug(
185
+ "Data type of %s changed from %s to %s. This change is not "
186
+ "supported by imas-python: no conversion will be done.",
187
+ new_item.get("path"),
188
+ old_item.get("data_type"),
189
+ new_item.get("data_type"),
190
+ )
191
+ self.new_to_old.path[new_path] = None
192
+ self.new_to_old.type_change[new_path] = None
193
+ self.old_to_new.path[old_path] = None
194
+ self.old_to_new.type_change[old_path] = None
195
+ return False
196
+ return True
197
+
198
+ def _build_map(self, old: Element, new: Element) -> None:
199
+ """Build the NBC translation map between old <-> new."""
200
+ old_paths = {field.get("path", ""): field for field in old.iterfind(".//field")}
201
+ new_paths = {field.get("path", ""): field for field in new.iterfind(".//field")}
202
+ old_path_set = set(old_paths)
203
+ new_path_set = set(new_paths)
204
+
205
+ def process_parent_renames(path: str) -> str:
206
+ # Apply any parent AoS/structure rename
207
+ # Loop in reverse order to find the closest parent which was renamed:
208
+ for parent in reversed(list(iter_parents(path))):
209
+ parent_rename = self.new_to_old.path.get(parent)
210
+ if parent_rename:
211
+ if new_paths[parent].get("data_type") in self.STRUCTURE_TYPES:
212
+ path = path.replace(parent, parent_rename, 1)
213
+ break
214
+ return path
215
+
216
+ def get_old_path(path: str, previous_name: str) -> str:
217
+ """Calculate old path from the path and change_nbc_previous_name"""
218
+ # Apply rename
219
+ i_slash = path.rfind("/")
220
+ if i_slash != -1:
221
+ old_path = path[:i_slash] + "/" + previous_name
222
+ else:
223
+ old_path = previous_name
224
+ return process_parent_renames(old_path)
225
+
226
+ def add_rename(old_path: str, new_path: str):
227
+ old_item = old_paths[old_path]
228
+ new_item = new_paths[new_path]
229
+ self.new_to_old[new_path] = (
230
+ old_path,
231
+ _get_tbp(old_item, old_paths),
232
+ _get_ctxpath(old_path, old_paths),
233
+ )
234
+ self.old_to_new[old_path] = (
235
+ new_path,
236
+ _get_tbp(new_item, new_paths),
237
+ _get_ctxpath(new_path, new_paths),
238
+ )
239
+
240
+ # Iterate through all NBC metadata and add entries
241
+ for new_item in new.iterfind(".//field[@change_nbc_description]"):
242
+ new_path = new_item.get("path")
243
+ assert new_path is not None
244
+ nbc_description = new_item.get("change_nbc_description")
245
+ # change_nbc_version may be a comma-separated list of versions
246
+ # the only supported case is multiple renames in succession
247
+ nbc_version = new_item.get("change_nbc_version")
248
+
249
+ try:
250
+ parsed_nbc_versions = [
251
+ Version(version) for version in nbc_version.split(",")
252
+ ]
253
+ except InvalidVersion:
254
+ log_args = (nbc_version, new_path)
255
+ logger.error("Ignoring invalid NBC version: %r for %r.", *log_args)
256
+ continue
257
+ assert sorted(parsed_nbc_versions) == parsed_nbc_versions
258
+
259
+ if parsed_nbc_versions[-1] <= self.version_old:
260
+ continue
261
+ if nbc_description in DDVersionMap.RENAMED_DESCRIPTIONS:
262
+ previous_names = new_item.get("change_nbc_previous_name").split(",")
263
+ assert len(previous_names) == len(parsed_nbc_versions)
264
+ # select the correct previous name:
265
+ for i, version in enumerate(parsed_nbc_versions):
266
+ if version > self.version_old:
267
+ previous_name = previous_names[i]
268
+ break
269
+ old_path = get_old_path(new_path, previous_name)
270
+ old_item = old_paths.get(old_path)
271
+ if old_item is None:
272
+ logger.debug(
273
+ "Skipped NBC change for %r: renamed path %r not found in %s.",
274
+ new_path,
275
+ old_path,
276
+ self.version_old,
277
+ )
278
+ elif self._check_data_type(old_item, new_item):
279
+ add_rename(old_path, new_path)
280
+ if old_item.get("data_type") in DDVersionMap.STRUCTURE_TYPES:
281
+ # Add entries for common sub-elements
282
+ for path in old_paths:
283
+ if path.startswith(old_path):
284
+ npath = path.replace(old_path, new_path, 1)
285
+ if npath in new_path_set:
286
+ add_rename(path, npath)
287
+ elif nbc_description == "type_changed":
288
+ pass # We will handle this (if possible) in self._check_data_type
289
+ elif nbc_description == "repeat_children_first_point":
290
+ old_path = process_parent_renames(new_path)
291
+ self.new_to_old.post_process[new_path] = _remove_last_point
292
+ self.old_to_new.post_process[old_path] = _repeat_first_point
293
+ elif nbc_description == "repeat_children_first_point_conditional":
294
+ # This conversion needs access to the old data structure to get the
295
+ # DDv3 `closed` node which is removed in DDv4, so we handle it as a type
296
+ # change:
297
+ old_path = process_parent_renames(new_path)
298
+ self.new_to_old.type_change[new_path] = _remove_last_point_conditional
299
+ self.old_to_new.type_change[old_path] = _repeat_first_point_conditional
300
+ self.old_to_new.ignore_missing_paths.add(f"{old_path}/closed")
301
+ elif nbc_description in (
302
+ "repeat_children_first_point_conditional_sibling",
303
+ "repeat_children_first_point_conditional_sibling_dynamic",
304
+ ):
305
+ old_path = process_parent_renames(new_path)
306
+ self.new_to_old.type_change[new_path] = _remove_last_point_conditional
307
+ self.old_to_new.type_change[old_path] = _repeat_first_point_conditional
308
+ closed_path = Path(old_path).parent / "closed"
309
+ self.old_to_new.ignore_missing_paths.add(str(closed_path))
310
+ elif nbc_description == "remove_last_point_if_open_annular_centreline":
311
+ old_path = process_parent_renames(new_path)
312
+ self.new_to_old.type_change[new_path] = _repeat_last_point_centreline
313
+ self.old_to_new.type_change[old_path] = _remove_last_point_centreline
314
+ closed_path = Path(old_path) / "closed"
315
+ self.old_to_new.ignore_missing_paths.add(str(closed_path))
316
+ else: # Ignore unknown NBC changes
317
+ log_args = (nbc_description, new_path)
318
+ logger.error("Ignoring unsupported NBC change: %r for %r.", *log_args)
319
+
320
+ # Check if all common elements are still valid
321
+ for common_path in old_path_set & new_path_set:
322
+ if common_path in self.new_to_old or common_path in self.old_to_new:
323
+ continue # This path is part of an NBC change, we can skip it
324
+ self._check_data_type(old_paths[common_path], new_paths[common_path])
325
+
326
+ # Record missing items
327
+ self._map_missing(True, new_path_set.difference(old_path_set, self.new_to_old))
328
+ self._map_missing(False, old_path_set.difference(new_path_set, self.old_to_new))
329
+
330
+ new_version = None
331
+ new_version_node = self.new_version.find("version")
332
+ if new_version_node is not None:
333
+ new_version = parse_dd_version(new_version_node.text)
334
+ # Additional conversion rules for DDv3 to DDv4
335
+ if self.version_old.major == 3 and new_version and new_version.major == 4:
336
+ # Postprocessing for COCOS definition change:
337
+ xpath_query = ".//field[@cocos_label_transformation='psi_like']"
338
+ for old_item in old.iterfind(xpath_query):
339
+ old_path = old_item.get("path")
340
+ new_path = self.old_to_new.path.get(old_path, old_path)
341
+ self.new_to_old.post_process[new_path] = _cocos_change
342
+ self.old_to_new.post_process[old_path] = _cocos_change
343
+ # Definition change for pf_active circuit/connections
344
+ if self.ids_name == "pf_active":
345
+ path = "circuit/connections"
346
+ self.new_to_old.post_process[path] = _circuit_connections_4to3
347
+ self.old_to_new.post_process[path] = _circuit_connections_3to4
348
+ # Migrate ids_properties/source to ids_properties/provenance
349
+ # Only implement forward conversion (DD3 -> 4):
350
+ # - Pretend that this is a rename from ids_properties/source -> provenance
351
+ # - And register type_change handler which will be called with the source
352
+ # element and the new provenance structure
353
+ path = "ids_properties/source"
354
+ self.old_to_new.path[path] = "ids_properties/provenance"
355
+ self.old_to_new.type_change[path] = _ids_properties_source
356
+
357
+ def _map_missing(self, is_new: bool, missing_paths: Set[str]):
358
+ rename_map = self.new_to_old if is_new else self.old_to_new
359
+ # Find all structures which have a renamed sub-item
360
+ structures_with_renames = set()
361
+ for path in rename_map:
362
+ for parent in iter_parents(path):
363
+ structures_with_renames.add(parent)
364
+
365
+ skipped_paths = set()
366
+ for path in sorted(missing_paths):
367
+ # Only mark a non-existing structure if there are no renames inside it, so a
368
+ # structure marked in the rename_map as None can be skipped completely.
369
+ if path not in structures_with_renames:
370
+ # Only mark if there is no parent structure already skipped
371
+ for parent in iter_parents(path):
372
+ if parent in skipped_paths:
373
+ break
374
+ else:
375
+ skipped_paths.add(path)
376
+ for path in skipped_paths:
377
+ rename_map.path[path] = None
378
+
379
+
380
+ def _get_ctxpath(path: str, paths: Dict[str, Element]) -> str:
381
+ """Get the path of the nearest parent AoS."""
382
+ for parent_path in reversed(list(iter_parents(path))):
383
+ if paths[parent_path].get("data_type") == "struct_array":
384
+ return path[len(parent_path) + 1 :]
385
+ return path # no nearest parent AoS
386
+
387
+
388
+ def _get_tbp(element: Element, paths: Dict[str, Element]):
389
+ """Calculate the timebasepath to use for the lowlevel."""
390
+ if element.get("data_type") == "struct_array":
391
+ # https://git.iter.org/projects/IMAS/repos/access-layer/browse/pythoninterface/py_ids.xsl?at=refs%2Ftags%2F4.11.4#367-384
392
+ if element.get("type") != "dynamic":
393
+ return ""
394
+ # Find path of first ancestor that is an AoS
395
+ path = element.get("path")
396
+ assert path is not None
397
+ return _get_ctxpath(path, paths) + "/time"
398
+ # https://git.iter.org/projects/IMAS/repos/access-layer/browse/pythoninterface/py_ids.xsl?at=refs%2Ftags%2F4.11.4#1524-1566
399
+ return element.get("timebasepath", "")
400
+
401
+
402
+ def dd_version_map_from_factories(
403
+ ids_name: str, factory1: IDSFactory, factory2: IDSFactory
404
+ ) -> Tuple[DDVersionMap, bool]:
405
+ """Build a DDVersionMap from two IDSFactories."""
406
+ assert factory1._version
407
+ assert factory2._version
408
+ factory1_version = parse_dd_version(factory1._version)
409
+ factory2_version = parse_dd_version(factory2._version)
410
+ old_version, old_factory, new_factory = min(
411
+ (factory1_version, factory1, factory2),
412
+ (factory2_version, factory2, factory1),
413
+ )
414
+ return (
415
+ _DDVersionMap(ids_name, old_factory._etree, new_factory._etree, old_version),
416
+ old_factory is factory1,
417
+ )
418
+
419
+
420
+ def convert_ids(
421
+ toplevel: IDSToplevel,
422
+ version: Optional[str],
423
+ *,
424
+ deepcopy: bool = False,
425
+ provenance_origin_uri: str = "",
426
+ xml_path: Optional[str] = None,
427
+ factory: Optional[IDSFactory] = None,
428
+ target: Optional[IDSToplevel] = None,
429
+ ) -> IDSToplevel:
430
+ """Convert an IDS to the specified data dictionary version.
431
+
432
+ Newer data dictionary versions may introduce non-backwards-compatible (NBC) changes.
433
+ For example, the ``global_quantities.power_from_plasma`` quantity in the ``wall``
434
+ IDS was renamed in DD version 3.31.0 to ``global_quantities.power_incident``. When
435
+ converting from a version older than 3.31.0 to a version that is newer than that,
436
+ this method will migrate the data.
437
+
438
+ By default, this method performs a `shallow copy` of numerical data. All
439
+ multi-dimensional numpy arrays from the returned IDS share their data with the
440
+ original IDS. When performing `in-place` operations on numpy arrays, the data will
441
+ be changed in both IDSs! If this is not desired, you may set the ``deepcopy``
442
+ keyword argument to True.
443
+
444
+ See also:
445
+ :ref:`Conversion of IDSs between DD versions`.
446
+
447
+ Args:
448
+ toplevel: The IDS element to convert.
449
+ version: The data dictionary version to convert to, for example "3.38.0". Must
450
+ be None when using ``xml_path`` or ``factory``.
451
+
452
+ Keyword Args:
453
+ deepcopy: When True, performs a deep copy of all data. When False (default),
454
+ numpy arrays are not copied and the converted IDS shares the same underlying
455
+ data buffers.
456
+ provenance_origin_uri: When nonempty, add an entry in the provenance data in
457
+ ``ids_properties`` to indicate that this IDS has been converted, and it was
458
+ originally stored at the given uri.
459
+ xml_path: Path to a data dictionary XML file that should be used instead of the
460
+ released data dictionary version specified by ``version``.
461
+ factory: Existing IDSFactory to use for as target version.
462
+ target: Use this IDSToplevel as target toplevel instead of creating one.
463
+ """
464
+ if toplevel._lazy:
465
+ raise NotImplementedError(
466
+ "IDS conversion is not implemented for lazy-loaded IDSs"
467
+ )
468
+
469
+ ids_name = toplevel.metadata.name
470
+ if target is None:
471
+ if factory is None:
472
+ factory = IDSFactory(version, xml_path)
473
+ if not factory.exists(ids_name):
474
+ raise RuntimeError(
475
+ f"There is no IDS with name {ids_name} in DD version {version}."
476
+ )
477
+ target_ids = factory.new(ids_name)
478
+ else:
479
+ target_ids = target
480
+
481
+ source_version = parse_dd_version(toplevel._version)
482
+ target_version = parse_dd_version(target_ids._version)
483
+ logger.info(
484
+ "Starting conversion of IDS %s from version %s to version %s.",
485
+ ids_name,
486
+ source_version,
487
+ target_version,
488
+ )
489
+
490
+ source_is_new = source_version > target_version
491
+ source_tree = toplevel._parent._etree
492
+ target_tree = target_ids._parent._etree
493
+ if source_is_new:
494
+ version_map = _DDVersionMap(ids_name, target_tree, source_tree, target_version)
495
+ else:
496
+ version_map = _DDVersionMap(ids_name, source_tree, target_tree, source_version)
497
+
498
+ _copy_structure(toplevel, target_ids, deepcopy, source_is_new, version_map)
499
+ logger.info("Conversion of IDS %s finished.", ids_name)
500
+ if provenance_origin_uri:
501
+ _add_provenance_entry(target_ids, toplevel._version, provenance_origin_uri)
502
+ return target_ids
503
+
504
+
505
+ def _add_provenance_entry(
506
+ target_ids: IDSToplevel, source_version: str, provenance_origin_uri: str
507
+ ) -> None:
508
+ # provenance node was added in DD 3.34.0
509
+ if not hasattr(target_ids.ids_properties, "provenance"):
510
+ logger.warning(
511
+ "Cannot add provenance entry for DD conversion: "
512
+ "target IDS does not have a provenance property."
513
+ )
514
+ return
515
+
516
+ # Find the node corresponding to the whole IDS, or create one if there is none
517
+ for node in target_ids.ids_properties.provenance.node:
518
+ if node.path == "":
519
+ break
520
+ else:
521
+ # No node found for the whole IDS, create a new one:
522
+ curlen = len(target_ids.ids_properties.provenance.node)
523
+ target_ids.ids_properties.provenance.node.resize(curlen + 1, keep=True)
524
+ node = target_ids.ids_properties.provenance.node[-1]
525
+
526
+ # Populate the node
527
+ source_txt = (
528
+ f"{provenance_origin_uri}; "
529
+ f"This IDS has been converted from DD {source_version} to "
530
+ f"DD {target_ids._dd_version} by imas-python {imas.__version__}."
531
+ )
532
+ if hasattr(node, "reference"):
533
+ # DD version after IMAS-5304
534
+ node.reference.resize(len(node.reference) + 1, keep=True)
535
+ node.reference[-1].name = source_txt
536
+ timestamp = datetime.datetime.now(datetime.UTC).isoformat(timespec="seconds")
537
+ node.reference[-1].timestamp = timestamp.replace("+00:00", "Z")
538
+ else:
539
+ # DD before IMAS-5304 (between 3.34.0 and 3.41.0)
540
+ node.sources.append(source_txt) # sources is a STR_1D (=list of strings)
541
+
542
+
543
+ def _copy_structure(
544
+ source: IDSStructure,
545
+ target: IDSStructure,
546
+ deepcopy: bool,
547
+ source_is_new: bool,
548
+ version_map: DDVersionMap,
549
+ ):
550
+ """Recursively copy data, following NBC renames.
551
+
552
+ Args:
553
+ source: Source structure.
554
+ target: Target structure.
555
+ deepcopy: See :func:`convert_ids`.
556
+ source_is_new: True iff the DD version of the source is newer than that of the
557
+ target.
558
+ version_map: Version map containing NBC renames.
559
+ """
560
+ rename_map = version_map.new_to_old if source_is_new else version_map.old_to_new
561
+ for item in source.iter_nonempty_():
562
+ path = item.metadata.path_string
563
+ if path in rename_map:
564
+ if rename_map.path[path] is None:
565
+ if path not in rename_map.ignore_missing_paths:
566
+ if path in rename_map.type_change:
567
+ msg = "Element %r changed type in the target IDS."
568
+ else:
569
+ msg = "Element %r does not exist in the target IDS."
570
+ logger.warning(msg + " Data is not copied.", path)
571
+ continue
572
+ else:
573
+ target_item = IDSPath(rename_map.path[path]).goto(target)
574
+ else:
575
+ try:
576
+ target_item = target[item.metadata.name]
577
+ except AttributeError:
578
+ # In exceptional cases the item does not exist in the target. Example:
579
+ # neutron_diagnostic IDS between DD 3.40.1 and 3.41.0. has renamed
580
+ # synthetic_signals/fusion_power -> fusion_power. The synthetic_signals
581
+ # structure no longer exists but we need to descend into it to get the
582
+ # total_neutron_flux.
583
+ target_item = target
584
+ if path in rename_map.type_change:
585
+ # Handle type change
586
+ new_items = rename_map.type_change[path](item, target_item)
587
+ if new_items is None:
588
+ continue # handled
589
+ else:
590
+ item, target_item = new_items
591
+
592
+ if isinstance(item, IDSStructArray):
593
+ size = len(item)
594
+ target_item.resize(size)
595
+ for i in range(size):
596
+ _copy_structure(
597
+ item[i], target_item[i], deepcopy, source_is_new, version_map
598
+ )
599
+ elif isinstance(item, IDSStructure):
600
+ _copy_structure(item, target_item, deepcopy, source_is_new, version_map)
601
+ else:
602
+ if deepcopy:
603
+ # No nested types are used as data, so a shallow copy is sufficient
604
+ target_item.value = copy.copy(item.value)
605
+ else:
606
+ target_item.value = item.value
607
+
608
+ # Post-process the node:
609
+ if path in rename_map.post_process:
610
+ rename_map.post_process[path](target_item)
611
+
612
+
613
+ ########################################################################################
614
+ # Type changed handlers and post-processing functions #
615
+ ########################################################################################
616
+
617
+
618
+ def _type_changed_structure_aos(
619
+ source_node: IDSBase, target_node: IDSBase
620
+ ) -> Optional[Tuple[IDSStructure, IDSStructure]]:
621
+ """Handle changed type, structure to array of structures (or vice versa): IMAS-5170.
622
+
623
+ Args:
624
+ source_node: data source (must be IDSStructure or IDSStructArray)
625
+ target_node: data target (must be IDSStructArray or IDSStructure)
626
+
627
+ Returns:
628
+ (source_node, target_node): new source and target node to continue copying, or
629
+ None if no copying needs to be done.
630
+ """
631
+ if isinstance(source_node, IDSStructure):
632
+ # Structure to AOS is always supported
633
+ assert isinstance(target_node, IDSStructArray)
634
+ target_node.resize(1)
635
+ return source_node, target_node[0]
636
+ assert isinstance(source_node, IDSStructArray)
637
+ assert len(source_node) > 0 # empty AoS should not be provided as source_node
638
+ assert isinstance(target_node, IDSStructure)
639
+ if len(source_node) == 1:
640
+ # AoS to Structure is supported when the AOS has size 1
641
+ return source_node[0], target_node
642
+ # source_node has more than 1 element, this is not supported:
643
+ logger.warning(
644
+ "Element %r changed type in the target IDS and has more than 1 item. "
645
+ "Data is not copied.",
646
+ source_node.metadata.path_string,
647
+ )
648
+ return None
649
+
650
+
651
+ def _type_changed_0d_1d_data(
652
+ source_node: IDSPrimitive, target_node: IDSPrimitive
653
+ ) -> None:
654
+ """Handle changed type, 0D to 1D (or vice versa): IMAS-5170.
655
+
656
+ Note: underlying data type (INT, FLT, CPX or STR) should not change.
657
+
658
+ Args:
659
+ source_node: Data source
660
+ target_node: Data target
661
+ """
662
+ if source_node.metadata.ndim == 0:
663
+ # 0D to 1D is always supported
664
+ target_node.value = [source_node.value]
665
+ elif len(source_node) == 1:
666
+ # 1D to 0D is only supported when 1 item is present
667
+ target_node.value = source_node[0]
668
+ else:
669
+ logger.warning(
670
+ "Element %r changed type in the target IDS and has more than 1 item. "
671
+ "Data is not copied.",
672
+ source_node.metadata.path_string,
673
+ )
674
+
675
+
676
+ def _type_changed_to_identifier(source_node: IDSBase, target_node: IDSBase) -> None:
677
+ """Handle a type change from list of indexes to a proper identifier."""
678
+ if len(source_node) == 0:
679
+ return
680
+
681
+ if source_node.metadata.data_type is IDSDataType.INT:
682
+ # target_node is an array of identifier structures
683
+ target_node.resize(len(source_node))
684
+ identifier_enum = target_node.metadata.identifier_enum
685
+ if identifier_enum is not None:
686
+ for i in range(len(source_node)):
687
+ try:
688
+ # Look up the index in the identifier and assign to set name, index
689
+ # and description
690
+ target_node[i] = identifier_enum(source_node[i])
691
+ except ValueError:
692
+ # That may fail for unknown values (e.g. negative values): fall back
693
+ # to only setting index and leaving name and description empty
694
+ target_node[i].index = source_node[i]
695
+ else:
696
+ # We couldn't get the identifier enum, so just copy into the index array
697
+ for i in range(len(source_node)):
698
+ target_node[i].index = source_node[i]
699
+
700
+ else:
701
+ # source_node is an array of identifier structures, target_node is an INT_1D
702
+ target_node.value = numpy.array(
703
+ [node.index for node in source_node], numpy.int32
704
+ )
705
+
706
+
707
+ def _type_changed_flt0d_int0d(source_node: IDSNumeric0D, target_node: IDSNumeric0D):
708
+ """Handle a type change from FLT_0D to INT_0D.
709
+
710
+ Support type change of ion/element/z_n from DD3 to DD4.
711
+
712
+ Note:
713
+ When the floating point data cannot be converted to an integer without loss of
714
+ data, a warning is issued and the data is not copied.
715
+ """
716
+ if source_node.metadata.data_type is IDSDataType.INT:
717
+ # INT to FLT can always be represented exactly
718
+ target_node.value = float(source_node.value)
719
+
720
+ else:
721
+ float_value: float = source_node.value
722
+ int_value = numpy.int32(float_value)
723
+ if int_value == float_value:
724
+ target_node.value = int_value # no data lost on conversion
725
+ else:
726
+ logger.warning(
727
+ "Element %r with value %f is cannot be represented as an integer. "
728
+ "Data is not copied.",
729
+ source_node.metadata.path_string,
730
+ float_value,
731
+ )
732
+
733
+
734
+ def _remove_last_point(node: IDSBase) -> None:
735
+ """Postprocess method for nbc_description=repeat_children_first_point.
736
+
737
+ This method handles postprocessing when converting from new (DDv4) to old (DDv3).
738
+ """
739
+ for child in node.iter_nonempty_():
740
+ child.value = child.value[:-1]
741
+
742
+
743
+ def _repeat_first_point(node: IDSBase) -> None:
744
+ """Postprocess method for nbc_description=repeat_children_first_point.
745
+
746
+ This method handles postprocessing when converting from old (DDv3) to new (DDv4).
747
+ """
748
+ for child in node.iter_nonempty_():
749
+ child.value = numpy.concatenate((child.value, [child.value[0]]))
750
+
751
+
752
+ def _remove_last_point_conditional(
753
+ source_node: IDSStructure, target_node: IDSStructure
754
+ ) -> None:
755
+ """Type change method for nbc_description=repeat_children_first_point_conditional*.
756
+
757
+ This method handles converting from new (DDv4) to old (DDv3).
758
+ """
759
+ closed_node = getattr(target_node, "closed", None)
760
+ if closed_node is None: # closed is a sibling node
761
+ closed_node = target_node._dd_parent.closed
762
+
763
+ # Figure out if the contour is closed:
764
+ closed = True
765
+ # source_node may be an AoS for the ...conditional_sibling_dynamic case
766
+ if source_node.metadata.data_type is IDSDataType.STRUCT_ARRAY:
767
+ iterator = source_node
768
+ else:
769
+ iterator = [source_node]
770
+ for source in iterator:
771
+ for component in source.iter_nonempty_():
772
+ if component.metadata.name != "time":
773
+ if not numpy.isclose(component[0], component[-1], rtol=1e-6, atol=0):
774
+ closed = False
775
+ break
776
+ if not closed:
777
+ break
778
+
779
+ # Copy data
780
+ closed_node.value = int(closed)
781
+ if source_node.metadata.data_type is IDSDataType.STRUCT_ARRAY:
782
+ target_node.resize(len(source_node))
783
+ iterator = zip(source_node, target_node)
784
+ else:
785
+ iterator = [(source_node, target_node)]
786
+ for source, target in iterator:
787
+ for child in source.iter_nonempty_():
788
+ value = child.value
789
+ if (
790
+ closed
791
+ and child.metadata.name != "time"
792
+ and isinstance(child, IDSNumericArray)
793
+ ):
794
+ # repeat first point:
795
+ value = value[:-1]
796
+ target[child.metadata.name] = value
797
+
798
+
799
+ def _repeat_first_point_conditional(
800
+ source_node: IDSStructure, target_node: IDSStructure
801
+ ) -> None:
802
+ """Type change method for nbc_description=repeat_children_first_point_conditional*.
803
+
804
+ This method handles converting from old (DDv3) to new (DDv4).
805
+ """
806
+ closed_node = getattr(source_node, "closed", None)
807
+ if closed_node is None: # closed is a sibling node
808
+ closed_node = source_node._dd_parent.closed
809
+ closed = bool(closed_node)
810
+
811
+ # source_node may be an AoS for the ...conditional_sibling_dynamic case
812
+ if source_node.metadata.data_type is IDSDataType.STRUCT_ARRAY:
813
+ target_node.resize(len(source_node))
814
+ iterator = zip(source_node, target_node)
815
+ else:
816
+ iterator = [(source_node, target_node)]
817
+ for source, target in iterator:
818
+ for child in source.iter_nonempty_():
819
+ if child.metadata.name != "closed" and not child.metadata.name.endswith(
820
+ "_error_index"
821
+ ):
822
+ value = child.value
823
+ if (
824
+ closed
825
+ and child.metadata.name != "time"
826
+ and isinstance(child, IDSNumericArray)
827
+ ):
828
+ # repeat first point:
829
+ value = numpy.concatenate((value, [value[0]]))
830
+ target[child.metadata.name] = value
831
+
832
+
833
+ def _remove_last_point_centreline(source_node: IDSBase, target_node: IDSBase) -> None:
834
+ """Type change method for
835
+ nbc_description=repeat_children_first_point_conditional_centreline.
836
+
837
+ This method handles converting from old (DDv3) to new (DDv4).
838
+
839
+ If a centreline is a closed contour, we should do nothing.
840
+ If it is an open contour the thickness variable had too many entries,
841
+ and we'll drop the last one.
842
+ """
843
+ closed = bool(source_node._parent.centreline.closed)
844
+
845
+ if closed:
846
+ target_node.value = source_node.value
847
+ else:
848
+ target_node.value = source_node.value[:-1]
849
+
850
+
851
+ def _repeat_last_point_centreline(source_node: IDSBase, target_node: IDSBase) -> None:
852
+ """Type change method for
853
+ nbc_description=repeat_children_first_point_conditional_centreline.
854
+
855
+ This method handles converting from new (DDv4) to old (DDv3).
856
+
857
+ If a centreline is a closed contour, we should do nothing.
858
+ If it is an open contour the thickness variable in the older
859
+ dd has one extra entry, so repeat the last one.
860
+ """
861
+ closed = bool(target_node._parent.centreline.closed)
862
+ if closed:
863
+ target_node.value = source_node.value
864
+ else:
865
+ target_node.value = numpy.concatenate(
866
+ (source_node.value, [source_node.value[-1]])
867
+ )
868
+
869
+
870
+ def _cocos_change(node: IDSBase) -> None:
871
+ """Handle COCOS definition change: multiply values by -1."""
872
+ if not isinstance(node, IDSPrimitive):
873
+ if node.metadata.path_string == "flux_loop/flux":
874
+ # Workaround for DD definition issue with flux_loop/flux in magnetics IDS
875
+ node.data.value = -node.data.value
876
+ else:
877
+ logger.error(
878
+ "Error while applying COCOS transformation (DD3->4): cannot multiply "
879
+ "element %r by -1.",
880
+ node.metadata.path_string,
881
+ )
882
+ else:
883
+ node.value = -node.value
884
+
885
+
886
+ def _circuit_connections_3to4(node: IDSPrimitive) -> None:
887
+ """Handle definition change for pf_active circuit/connections."""
888
+ shape = node.shape
889
+ if shape[1] % 2: # second dimension not divisible by 2:
890
+ logger.error(
891
+ f"Error while converting {node}. Size of the second dimension should "
892
+ "be divisible by 2. Data was not converted."
893
+ )
894
+ else:
895
+ node.value = node.value[:, ::2] - node.value[:, 1::2]
896
+
897
+
898
+ def _circuit_connections_4to3(node: IDSPrimitive) -> None:
899
+ """Handle definition change for pf_active circuit/connections."""
900
+ shape = node.shape
901
+ new_value = numpy.zeros((shape[0], 2 * shape[1]), dtype=numpy.int32)
902
+ new_value[:, ::2] = node.value == 1
903
+ new_value[:, 1::2] = node.value == -1
904
+ node.value = new_value
905
+
906
+
907
+ def _ids_properties_source(source: IDSString0D, provenance: IDSStructure) -> None:
908
+ """Handle DD3to4 migration of ids_properties/source to ids_properties/provenance."""
909
+ if len(provenance.node) > 0:
910
+ logger.warning(
911
+ "Element %r could not be migrated: %r is alreadys set.",
912
+ source.metadata.path_string,
913
+ provenance.node.metadata.path_string,
914
+ )
915
+ return
916
+
917
+ # Populate ids_properties/provenance/node[0]/reference[0].name with source.value
918
+ provenance.node.resize(1)
919
+ provenance.node[0].reference.resize(1)
920
+ provenance.node[0].reference[0].name = source.value