imas-python 2.2.0__py3-none-any.whl → 2.3.1__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.
imas/ids_convert.py CHANGED
@@ -274,16 +274,7 @@ class DDVersionMap:
274
274
  self.version_old,
275
275
  )
276
276
  elif self._check_data_type(old_item, new_item):
277
- # use class helper to register simple renames and
278
- # reciprocal mappings
279
277
  self._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
- self._add_rename(path, npath)
287
278
  elif nbc_description == "type_changed":
288
279
  pass # We will handle this (if possible) in self._check_data_type
289
280
  elif nbc_description == "repeat_children_first_point":
@@ -334,28 +325,40 @@ class DDVersionMap:
334
325
  # Additional conversion rules for DDv3 to DDv4
335
326
  if self.version_old.major == 3 and new_version and new_version.major == 4:
336
327
  self._apply_3to4_conversion(old, new)
328
+ # 3to4 rules may have introduced additional missing items in self.old_to_new
329
+ self._map_missing(
330
+ False, old_path_set.difference(new_path_set, self.old_to_new)
331
+ )
337
332
 
338
- def _add_rename(self, old_path: str, new_path: str) -> None:
333
+ def _add_rename(
334
+ self, old_path: str, new_path: str, reciprocal: bool = True
335
+ ) -> None:
339
336
  """Register a simple rename from old_path -> new_path using the
340
337
  path->Element maps stored on the instance (self.old_paths/self.new_paths).
341
338
  This will also add the reciprocal mapping when possible.
342
339
  """
343
340
  old_item = self.old_paths[old_path]
344
341
  new_item = self.new_paths[new_path]
345
-
346
- # forward mapping
342
+ # Forward mapping
347
343
  self.old_to_new[old_path] = (
348
344
  new_path,
349
345
  _get_tbp(new_item, self.new_paths),
350
346
  _get_ctxpath(new_path, self.new_paths),
351
347
  )
352
-
353
- # reciprocal mapping
354
- self.new_to_old[new_path] = (
355
- old_path,
356
- _get_tbp(old_item, self.old_paths),
357
- _get_ctxpath(old_path, self.old_paths),
358
- )
348
+ # Reciprocal mapping
349
+ if reciprocal:
350
+ self.new_to_old[new_path] = (
351
+ old_path,
352
+ _get_tbp(old_item, self.old_paths),
353
+ _get_ctxpath(old_path, self.old_paths),
354
+ )
355
+ # Apply to descendent nodes as well if the item is a struct or AoS
356
+ for item in old_item.findall("field"):
357
+ path = item.get("path")
358
+ assert path is not None and path.startswith(old_path)
359
+ npath = path.replace(old_path, new_path, 1)
360
+ if npath in self.new_paths:
361
+ self._add_rename(path, npath, reciprocal)
359
362
 
360
363
  def _apply_3to4_conversion(self, old: Element, new: Element) -> None:
361
364
  # Postprocessing for COCOS definition change:
@@ -421,6 +424,13 @@ class DDVersionMap:
421
424
  to_update[p] = v
422
425
  self.old_to_new.path.update(to_update)
423
426
 
427
+ # Migrate additional obsolescent nodes
428
+ # TODO: define migrations in a separate variable (as with the sign flips)?
429
+ if self.ids_name == "magnetics":
430
+ self._add_rename("bpol_probe", "b_field_pol_probe", reciprocal=False)
431
+ self._add_rename("method", "ip", reciprocal=False)
432
+ self.old_to_new.type_change["method"] = _magnetics_method_to_ip
433
+
424
434
  # GH#59: To improve further the conversion of DD3 to DD4, especially the
425
435
  # Machine Description part of the IDSs, we would like to add a 3to4 specific
426
436
  # rule to convert any siblings name + identifier (that are not part of an
@@ -431,19 +441,20 @@ class DDVersionMap:
431
441
  # Only perform the mapping if the corresponding target fields exist in the
432
442
  # new DD and if we don't already have a mapping for the involved paths.
433
443
  # use self.old_paths and self.new_paths set in _build_map
434
- for p in self.old_paths:
444
+ for name_path in self.old_paths:
435
445
  # look for name children
436
- if not p.endswith("/name"):
446
+ if not name_path.endswith("/name"):
437
447
  continue
438
- parent = p.rsplit("/", 1)[0]
439
- name_path = f"{parent}/name"
448
+ parent = name_path.rsplit("/", 1)[0]
440
449
  id_path = f"{parent}/identifier"
441
450
  index_path = f"{parent}/index"
442
- desc_path = f"{parent}/description"
443
- new_name_path = name_path
451
+ # Follow renames of parent structure
452
+ new_parent = self.old_to_new.path.get(parent) or parent
453
+ desc_path = f"{new_parent}/description"
454
+ new_name_path = f"{new_parent}/name"
444
455
 
445
- # If neither 'name' nor 'identifier' existed in the old DD, skip this parent
446
- if name_path not in self.old_paths or id_path not in self.old_paths:
456
+ # If 'identifier' doesn't exist in the old DD, skip this parent
457
+ if id_path not in self.old_paths:
447
458
  continue
448
459
  # exclude identifier-structure (has index sibling)
449
460
  if index_path in self.old_paths:
@@ -454,12 +465,11 @@ class DDVersionMap:
454
465
  continue
455
466
 
456
467
  # Map DD3 name -> DD4 description
457
- if name_path not in self.old_to_new.path:
458
- self._add_rename(name_path, desc_path)
459
-
468
+ self._add_rename(name_path, desc_path)
469
+ # GH#114: Also preserve name in DD4 name when identifier is empty
470
+ self.old_to_new.type_change[name_path] = _name_identifier_3to4
460
471
  # Map DD3 identifier -> DD4 name
461
- if id_path in self.old_to_new.path:
462
- self._add_rename(id_path, new_name_path)
472
+ self._add_rename(id_path, new_name_path)
463
473
 
464
474
  def _map_missing(self, is_new: bool, missing_paths: Set[str]):
465
475
  rename_map = self.new_to_old if is_new else self.old_to_new
@@ -1154,6 +1164,19 @@ def _circuit_connections_4to3(node: IDSPrimitive) -> None:
1154
1164
  node.value = new_value
1155
1165
 
1156
1166
 
1167
+ def _name_identifier_3to4(source_name: IDSBase, target_description: IDSBase) -> None:
1168
+ """Preserve name when identifier is empty, see GH#114."""
1169
+ # Always copy DD3 name -> DD4 description
1170
+ target_description.value = source_name.value
1171
+
1172
+ # When DD3 identifier is empty, also preserve name in DD4 name
1173
+ source_parent = source_name._parent
1174
+ source_identifier = getattr(source_parent, "identifier", None)
1175
+ if source_identifier is None or not source_identifier.value:
1176
+ target_parent = target_description._parent
1177
+ target_parent.name = source_name.value
1178
+
1179
+
1157
1180
  def _ids_properties_source(source: IDSString0D, provenance: IDSStructure) -> None:
1158
1181
  """Handle DD3to4 migration of ids_properties/source to ids_properties/provenance."""
1159
1182
  if len(provenance.node) > 0:
@@ -1314,3 +1337,14 @@ def _equilibrium_boundary_3to4(eq3: IDSToplevel, eq4: IDSToplevel, deepcopy: boo
1314
1337
  node[2].psi = -ts3.boundary_secondary_separatrix.psi # COCOS change
1315
1338
  node[2].levelset.r = copy(ts3.boundary_secondary_separatrix.outline.r)
1316
1339
  node[2].levelset.z = copy(ts3.boundary_secondary_separatrix.outline.z)
1340
+
1341
+
1342
+ def _magnetics_method_to_ip(method: IDSBase, ip: IDSBase) -> None:
1343
+ """Convert obsolescent method(:) to ip(:) in the magnetics IDS."""
1344
+ if not len(method):
1345
+ return
1346
+ ip.resize(len(method))
1347
+ for old_item, new_item in zip(method, ip, strict=True):
1348
+ new_item.method_name.value = old_item.name.value
1349
+ new_item.data.value = old_item.ip.data.value
1350
+ new_item.time.value = old_item.ip.time.value
imas/ids_coordinates.py CHANGED
@@ -81,7 +81,7 @@ class IDSCoordinate:
81
81
  logger.debug(
82
82
  f"Ignoring invalid coordinate specifier {spec}", exc_info=True
83
83
  )
84
- self.references: "tuple[IDSPath]" = tuple(refs)
84
+ self.references: "tuple[IDSPath, ...]" = tuple(refs)
85
85
  """A tuple paths that this coordinate refers to.
86
86
  """
87
87
 
@@ -357,7 +357,6 @@ class IDSCoordinates:
357
357
  IDSPath.goto().
358
358
  """
359
359
  did_capture = []
360
- path = self._node._path
361
360
  try:
362
361
  yield did_capture
363
362
  except CoordinateLookupError as exc:
@@ -365,6 +364,7 @@ class IDSCoordinates:
365
364
  except IndexError as exc:
366
365
  # Can happen in IDSPath.goto when an invalid index is encountered.
367
366
  coordinate_refs = coordinate.format_refs(self._node)
367
+ path = self._node._path
368
368
  raise ValidationError(
369
369
  f"Error while validating element `{path}`: dimension {dim + 1} has an "
370
370
  f"invalid index for coordinate(s) {coordinate_refs}."
@@ -372,13 +372,14 @@ class IDSCoordinates:
372
372
  except Exception as exc:
373
373
  # Ignore all other exceptions and log them
374
374
  if "Unexpected index" in str(exc):
375
- logger.debug(
376
- "Ignored AoS coordinate outside our tree (see IMAS-4675) of "
377
- "element `%s`, dimension %s, coordinate `%s`",
378
- path,
379
- dim,
380
- coordinate.references,
381
- )
375
+ if logger.isEnabledFor(logging.DEBUG):
376
+ logger.debug(
377
+ "Ignored AoS coordinate outside our tree (see IMAS-4675) "
378
+ "of element `%s`, dimension %s, coordinate `%s`",
379
+ self._node._path,
380
+ dim,
381
+ coordinate.references,
382
+ )
382
383
  else:
383
384
  if self._node._version <= "3.38.1":
384
385
  version_error = (
@@ -395,7 +396,7 @@ class IDSCoordinates:
395
396
  coordinate.references,
396
397
  dim,
397
398
  version_error,
398
- exc_info=1,
399
+ exc_info=True,
399
400
  )
400
401
  # Flag to the caller that an error was suppressed
401
402
  did_capture.append(1)
imas/ids_identifiers.py CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  import logging
6
6
  from enum import Enum
7
- from typing import Iterable, List, Type
7
+ from typing import Iterable, List, Type, Optional
8
8
  from xml.etree.ElementTree import fromstring
9
9
 
10
10
  from imas import dd_zip
@@ -15,19 +15,29 @@ logger = logging.getLogger(__name__)
15
15
  class IDSIdentifier(Enum):
16
16
  """Base class for all identifier enums."""
17
17
 
18
- def __new__(cls, value: int, description: str, aliases: list = []):
18
+ name: str
19
+ """Name of this identifier value."""
20
+ index: int
21
+ """Unique index for this identifier value."""
22
+ description: str
23
+ """Description for this identifier value."""
24
+ aliases: list[str]
25
+ """Alternative names for this identifier value."""
26
+ units: Optional[str]
27
+ """Units of the quantity/quantities altered by this identifier. May be ``None`` if
28
+ the Data Dictionary doesn't provide this metadata."""
29
+
30
+ def __new__(
31
+ cls, value: int, description: str, aliases: list[str], units: Optional[str]
32
+ ):
19
33
  obj = object.__new__(cls)
20
34
  obj._value_ = value
35
+ obj.index = value
36
+ obj.description = description
37
+ obj.aliases = aliases
38
+ obj.units = units
21
39
  return obj
22
40
 
23
- def __init__(self, value: int, description: str, aliases: list = []) -> None:
24
- self.index = value
25
- """Unique index for this identifier value."""
26
- self.description = description
27
- """Description for this identifier value."""
28
- self.aliases = aliases
29
- """Alternative names for this identifier value."""
30
-
31
41
  def __eq__(self, other):
32
42
  if self is other:
33
43
  return True
@@ -68,19 +78,22 @@ class IDSIdentifier(Enum):
68
78
  def _from_xml(cls, identifier_name, xml) -> Type["IDSIdentifier"]:
69
79
  element = fromstring(xml)
70
80
  enum_values = {}
71
- aliases = {}
72
81
  for int_element in element.iterfind("int"):
73
82
  name = int_element.get("name")
74
83
  value = int_element.text
84
+ assert value is not None
75
85
  description = int_element.get("description")
76
86
  # alias attribute may contain multiple comma-separated aliases
77
87
  alias_attr = int_element.get("alias", "")
78
88
  aliases = [a.strip() for a in alias_attr.split(",") if a.strip()]
79
- # Canonical entry: use the canonical 'name' as key
80
- enum_values[name] = (int(value), description, aliases)
81
- # Also add alias names as enum *aliases* (they become enum attributes)
89
+ units = int_element.get("units")
90
+
91
+ # Identifier can be looked up by its name or any of its aliases:
92
+ enumvalue = (int(value), description, aliases, units)
93
+ enum_values[name] = enumvalue
82
94
  for alias in aliases:
83
- enum_values[alias] = (int(value), description, aliases)
95
+ enum_values[alias] = enumvalue
96
+
84
97
  # Create the enumeration
85
98
  enum = cls(
86
99
  identifier_name,
imas/ids_primitive.py CHANGED
@@ -334,7 +334,7 @@ class IDSNumeric0D(IDSPrimitive):
334
334
  __doc__ = IDSDoc(__doc__)
335
335
  __slots__ = ()
336
336
 
337
- def __array__(self, dtype=None):
337
+ def __array__(self, dtype=None, copy=None):
338
338
  return np.array(self.value, dtype=dtype)
339
339
 
340
340
  def __str__(self):
@@ -437,8 +437,8 @@ class IDSNumericArray(IDSPrimitive, np.lib.mixins.NDArrayOperatorsMixin):
437
437
  # list, to support operations like np.add(array_like, list)
438
438
  _HANDLED_TYPES = (np.ndarray, Number)
439
439
 
440
- def __array__(self, dtype=None):
441
- return self.value.astype(dtype, copy=False)
440
+ def __array__(self, dtype=None, copy=None):
441
+ return self.value.astype(dtype, copy=bool(copy))
442
442
 
443
443
  def __array_ufunc__(self, ufunc, method, *inputs, **kwargs):
444
444
  out = kwargs.get("out", ())
imas/ids_structure.py CHANGED
@@ -57,7 +57,7 @@ class IDSStructure(IDSBase):
57
57
  def __getattr__(self, name):
58
58
  if name not in self._children:
59
59
  raise AttributeError(
60
- f"IDS structure '{self._path}' has no attribute '{name}'"
60
+ f"IDS structure '{self.metadata.path_string}' has no attribute '{name}'"
61
61
  )
62
62
  # Create child node
63
63
  child_meta = self._children[name]
@@ -12,7 +12,7 @@ def assert_equal(core_edge, plasma):
12
12
 
13
13
 
14
14
  def test_convert_training_core_profiles():
15
- with imas.training.get_training_db_entry() as entry:
15
+ with imas.training.get_training_db_entry(convert=True) as entry:
16
16
  cp = entry.get("core_profiles")
17
17
 
18
18
  pp = imas.convert_to_plasma_profiles(cp)
imas/test/test_hash.py CHANGED
@@ -27,7 +27,7 @@ def test_hash_str1d(minimal):
27
27
  "multiple entries to test!",
28
28
  ]
29
29
  minimal.str_1d = string_list
30
- hashes = list(map(xxh3_64_digest, string_list))
30
+ hashes = [xxh3_64_digest(s.encode("utf-8")) for s in string_list]
31
31
  expected = xxh3_64_digest(struct.pack("<Q", len(string_list)) + b"".join(hashes))
32
32
  assert expected == b"\x98\x011\x9dx+\x0e\xc0"
33
33
  assert imas.util.calc_hash(minimal.str_1d) == expected
@@ -6,11 +6,9 @@ from imas.dd_zip import dd_identifiers
6
6
  from imas.ids_factory import IDSFactory
7
7
  from imas.ids_identifiers import IDSIdentifier, identifiers
8
8
 
9
- has_aliases = Version(importlib.metadata.version("imas_data_dictionaries")) >= Version(
10
- "4.1.0"
11
- )
12
- requires_aliases = pytest.mark.skipif(
13
- not has_aliases, reason="Requires DD 4.1.0 for identifier aliases"
9
+ requires_dd4_1 = pytest.mark.skipif(
10
+ Version(importlib.metadata.version("imas_data_dictionaries")) < Version("4.1.0"),
11
+ reason="Test requires DD 4.1.0 for additional identifier metadata",
14
12
  )
15
13
 
16
14
 
@@ -112,7 +110,7 @@ Materials used in the device mechanical structures
112
110
  assert identifier.CxHy.aliases == ["alias1", "alias2", "3alias"]
113
111
 
114
112
 
115
- @requires_aliases
113
+ @requires_dd4_1
116
114
  def test_identifier_struct_assignment_with_aliases():
117
115
  """Test identifier struct assignment with aliases using materials_identifier."""
118
116
  mid = identifiers.materials_identifier
@@ -174,7 +172,7 @@ def test_invalid_identifier_assignment():
174
172
  cs.source[0].identifier = -1
175
173
 
176
174
 
177
- @requires_aliases
175
+ @requires_dd4_1
178
176
  def test_identifier_aliases():
179
177
  """Test identifier enum aliases functionality."""
180
178
  mid = identifiers.materials_identifier
@@ -197,7 +195,7 @@ def test_identifier_aliases():
197
195
  assert mid[alias] is mid.U_235
198
196
 
199
197
 
200
- @requires_aliases
198
+ @requires_dd4_1
201
199
  def test_identifier_alias_equality():
202
200
  """Test that identifiers with aliases are equal when comparing names and aliases."""
203
201
  mid = identifiers.materials_identifier
@@ -276,7 +274,7 @@ def test_identifier_alias_equality():
276
274
  assert mat5.descriptions[2] == mid["U_235"].description
277
275
 
278
276
 
279
- @requires_aliases
277
+ @requires_dd4_1
280
278
  def test_identifier_alias_equality_non_ggd():
281
279
  """Test identifier aliases functionality on non-ggd material"""
282
280
  mid = identifiers.materials_identifier
@@ -293,3 +291,14 @@ def test_identifier_alias_equality_non_ggd():
293
291
  summary_ids.wall.material.name = "235U" # Use canonical name
294
292
  assert summary_ids.wall.material == mid["235U"]
295
293
  assert summary_ids.wall.material == mid["U_235"]
294
+
295
+
296
+ @requires_dd4_1
297
+ def test_identifier_units():
298
+ ppcid = identifiers.poloidal_plane_coordinates_identifier
299
+ assert ppcid.rectangular.units == "m,m"
300
+ assert ppcid.inverse.units == "m,rad"
301
+
302
+ # materials identifier doesn't have units (and I don't expect they'll ever get any)
303
+ mid = identifiers.materials_identifier
304
+ assert mid.W.units is None
@@ -439,6 +439,55 @@ def test_3to4_cocos_magnetics_workaround(dd4factory):
439
439
  compare_children(mag, mag3)
440
440
 
441
441
 
442
+ def test_3to4_deprecated_magnetics(dd4factory):
443
+ # Test migrating deprecated bpol_probe
444
+ mag = IDSFactory("3.39.0").magnetics()
445
+ mag.bpol_probe.resize(2)
446
+ mag.bpol_probe[0].name = "name1"
447
+ mag.bpol_probe[0].identifier = "identifier1"
448
+ mag.bpol_probe[0].position.r = 1
449
+ mag.bpol_probe[0].field.data = [0.1, 0.2, 0.3]
450
+ mag.bpol_probe[1].name = "name2"
451
+ mag.bpol_probe[1].voltage.data = [0.1, 0.2, 0.3]
452
+
453
+ mag.method.resize(2)
454
+ for i, method in enumerate(mag.method):
455
+ method.name = f"name{i}"
456
+ method.ip.data = [i, 1.0, 2.0]
457
+ method.ip.time = [i + 1, 2.0, 3.0]
458
+
459
+ mag4 = convert_ids(mag, None, factory=dd4factory)
460
+ assert len(mag4.b_field_pol_probe) == 2
461
+ assert mag4.b_field_pol_probe[0].name == "identifier1"
462
+ assert mag4.b_field_pol_probe[0].description == "name1"
463
+ assert mag4.b_field_pol_probe[0].position.r == 1
464
+ assert array_equal(mag4.b_field_pol_probe[0].field.data, [0.1, 0.2, 0.3])
465
+ assert mag4.b_field_pol_probe[1].name == "name2"
466
+ assert mag4.b_field_pol_probe[1].description == "name2"
467
+ assert array_equal(mag4.b_field_pol_probe[1].voltage.data, [0.1, 0.2, 0.3])
468
+
469
+ assert len(mag4.ip) == 2
470
+ assert mag4.ip[0].method_name == "name0"
471
+ assert array_equal(mag4.ip[0].data, [0.0, 1.0, 2.0])
472
+ assert array_equal(mag4.ip[0].time, [1.0, 2.0, 3.0])
473
+ assert mag4.ip[1].method_name == "name1"
474
+ assert array_equal(mag4.ip[1].data, [1.0, 1.0, 2.0])
475
+ assert array_equal(mag4.ip[1].time, [2.0, 2.0, 3.0])
476
+
477
+ # If both the deprecated and the "correct" quantity exist, we expect only the
478
+ # correct one to be converted to DD4:
479
+ mag.b_field_pol_probe.resize(1)
480
+ mag.b_field_pol_probe[0].name = "test"
481
+ mag.ip.resize(1)
482
+ mag.ip[0].method_name = "ip"
483
+
484
+ mag4 = convert_ids(mag, None, factory=dd4factory)
485
+ assert len(mag4.b_field_pol_probe) == 1
486
+ assert mag4.b_field_pol_probe[0].name == "test"
487
+ assert len(mag4.ip) == 1
488
+ assert mag4.ip[0].method_name == "ip"
489
+
490
+
442
491
  def test_3to4_pulse_schedule():
443
492
  ps = IDSFactory("3.39.0").pulse_schedule()
444
493
  ps.ids_properties.homogeneous_time = IDS_TIME_MODE_HETEROGENEOUS
@@ -575,6 +624,29 @@ def test_4to3_name_identifier_mapping_magnetics():
575
624
  assert dst.b_field_pol_probe[0].identifier == "TEST_NAME"
576
625
 
577
626
 
627
+ def test_3to4_name_identifier_empty_identifier():
628
+ """GH#114: name must be preserved when identifier is empty."""
629
+ factory = IDSFactory("3.40.1")
630
+
631
+ src = factory.pf_active()
632
+ src.ids_properties.homogeneous_time = IDS_TIME_MODE_HOMOGENEOUS
633
+ src.coil.resize(2)
634
+ # Case 1: name populated, identifier empty
635
+ src.coil[0].name = "TEST_NAME"
636
+ src.coil[0].identifier = ""
637
+ # Case 2: name populated, identifier not set at all
638
+ src.coil[1].name = "TEST_NAME2"
639
+
640
+ dst = convert_ids(src, "4.0.0")
641
+
642
+ # name must be preserved in DD4 name (not overwritten by empty identifier)
643
+ assert dst.coil[0].name == "TEST_NAME"
644
+ assert dst.coil[0].description == "TEST_NAME"
645
+
646
+ assert dst.coil[1].name == "TEST_NAME2"
647
+ assert dst.coil[1].description == "TEST_NAME2"
648
+
649
+
578
650
  def test_3to4_cocos_hardcoded_paths():
579
651
  # Check for existence in 3.42.0
580
652
  factory = IDSFactory("3.42.0")
@@ -0,0 +1,101 @@
1
+ import pytest
2
+
3
+ import imas
4
+ from imas_core import _al_lowlevel
5
+ from imas.exception import DataEntryException
6
+ from imas.ids_defs import IDS_TIME_MODE_HOMOGENEOUS, IDS_TIME_MODE_INDEPENDENT
7
+
8
+
9
+ if not hasattr(_al_lowlevel, "al_list_filled_paths"):
10
+ marker = pytest.mark.xfail(reason="list_filled_paths not available in imas_core")
11
+ else:
12
+ marker = []
13
+
14
+
15
+ @pytest.fixture(params=["netcdf", pytest.param("hdf5", marks=marker)])
16
+ def testuri(request, tmp_path):
17
+ if request.param == "netcdf":
18
+ return str(tmp_path / "list_filled_paths.nc")
19
+ return f"imas:{request.param}?path={tmp_path}/list_filled_paths_{request.param}"
20
+
21
+
22
+ def test_list_filled_paths(testuri):
23
+ with imas.DBEntry(testuri, "w", dd_version="4.0.0") as dbentry:
24
+ # No IDSs in the DBEntry yet, expect an exception
25
+ with pytest.raises(DataEntryException):
26
+ dbentry.list_filled_paths("core_profiles")
27
+
28
+ cp = dbentry.factory.core_profiles()
29
+ cp.ids_properties.homogeneous_time = IDS_TIME_MODE_HOMOGENEOUS
30
+ cp.ids_properties.comment = "comment"
31
+ cp.time = [0.1, 0.2]
32
+ cp.profiles_1d.resize(2)
33
+ cp.profiles_1d[0].grid.rho_tor_norm = [1.0, 2.0]
34
+ cp.profiles_1d[0].ion.resize(2)
35
+ cp.profiles_1d[0].ion[1].temperature = [1.0, 2.0]
36
+ cp.profiles_1d[1].grid.psi = [1.0, 2.0]
37
+ cp.profiles_1d[1].q = [1.0, 2.0]
38
+ cp.profiles_1d[1].e_field.radial = [1.0, 2.0]
39
+ cp.profiles_1d[1].neutral.resize(2)
40
+ cp.global_quantities.ip = [1.0, 2.0]
41
+
42
+ dbentry.put(cp)
43
+
44
+ filled_paths = dbentry.list_filled_paths("core_profiles")
45
+ assert isinstance(filled_paths, list)
46
+ assert set(filled_paths) == {
47
+ "ids_properties/version_put/access_layer",
48
+ "ids_properties/version_put/access_layer_language",
49
+ "ids_properties/version_put/data_dictionary",
50
+ "ids_properties/homogeneous_time",
51
+ "ids_properties/comment",
52
+ "time",
53
+ "profiles_1d/grid/rho_tor_norm",
54
+ "profiles_1d/ion/temperature",
55
+ "profiles_1d/grid/psi",
56
+ "profiles_1d/q",
57
+ "profiles_1d/e_field/radial",
58
+ "profiles_1d/e_field/radial",
59
+ "global_quantities/ip",
60
+ }
61
+ # Other occurrence should still raise an error:
62
+ with pytest.raises(DataEntryException):
63
+ dbentry.list_filled_paths("core_profiles", 1)
64
+ # Until we write data to the occurrence:
65
+ dbentry.put(cp, 3)
66
+ assert set(filled_paths) == set(dbentry.list_filled_paths("core_profiles", 3))
67
+
68
+
69
+ def test_list_filled_paths_autoconvert(testuri):
70
+ with imas.DBEntry(testuri, "w", dd_version="3.25.0") as entry:
71
+ ps = entry.factory.pulse_schedule()
72
+ ps.ids_properties.homogeneous_time = IDS_TIME_MODE_INDEPENDENT
73
+ ps.ec.antenna.resize(1)
74
+ ps.ec.antenna[0].launching_angle_pol.reference_name = "test"
75
+ entry.put(ps)
76
+
77
+ filled_paths = entry.list_filled_paths("pulse_schedule")
78
+ assert set(filled_paths) == {
79
+ "ids_properties/version_put/access_layer",
80
+ "ids_properties/version_put/access_layer_language",
81
+ "ids_properties/version_put/data_dictionary",
82
+ "ids_properties/homogeneous_time",
83
+ "ec/antenna/launching_angle_pol/reference_name",
84
+ }
85
+
86
+ # Check autoconvert with DD 3.28.0
87
+ with imas.DBEntry(testuri, "r", dd_version="3.28.0") as entry:
88
+ assert set(entry.list_filled_paths("pulse_schedule", autoconvert=False)) == {
89
+ "ids_properties/version_put/access_layer",
90
+ "ids_properties/version_put/access_layer_language",
91
+ "ids_properties/version_put/data_dictionary",
92
+ "ids_properties/homogeneous_time",
93
+ "ec/antenna/launching_angle_pol/reference_name", # original name
94
+ }
95
+ assert set(entry.list_filled_paths("pulse_schedule")) == {
96
+ "ids_properties/version_put/access_layer",
97
+ "ids_properties/version_put/access_layer_language",
98
+ "ids_properties/version_put/data_dictionary",
99
+ "ids_properties/homogeneous_time",
100
+ "ec/launcher/steering_angle_pol/reference_name", # autoconverted name
101
+ }
@@ -1,11 +1,13 @@
1
1
  import numpy as np
2
+ import netCDF4
2
3
  import pytest
3
4
 
4
5
  import imas
5
6
  import imas.training
7
+ from imas.test.test_helpers import compare_children
6
8
  from imas.util import to_xarray
7
9
 
8
- pytest.importorskip("xarray")
10
+ xarray = pytest.importorskip("xarray")
9
11
 
10
12
 
11
13
  @pytest.fixture
@@ -87,8 +89,29 @@ def test_to_xarray():
87
89
  ids.profiles_1d[0].time = 0.0
88
90
 
89
91
  # These should all be identical:
90
- ds1 = to_xarray(ids)
92
+ ds1 = to_xarray(ids).drop_vars(
93
+ ["profiles_1d", "profiles_1d.electrons", "profiles_1d.grid"]
94
+ )
91
95
  ds2 = to_xarray(ids, "profiles_1d.electrons.temperature")
92
96
  ds3 = to_xarray(ids, "profiles_1d/electrons/temperature")
93
97
  assert ds1.equals(ds2)
94
98
  assert ds2.equals(ds3)
99
+
100
+
101
+ @pytest.mark.parametrize("idsname", ["core_profiles", "equilibrium"])
102
+ def test_roundtrip_xarray_netcdf(tmp_path, entry, idsname):
103
+ ids = entry.get(idsname)
104
+ xrds = to_xarray(ids)
105
+ fname = f"{tmp_path}/test-{idsname}-xarray.nc"
106
+ # First write mandatory file-level metadata
107
+ with netCDF4.Dataset(fname, "x") as ds:
108
+ ds.data_dictionary_version = imas.util.get_data_dictionary_version(ids)
109
+ # Then use xarray to write the IDS
110
+ xrds.to_netcdf(fname, "a", format="NETCDF4", group=f"{idsname}/0")
111
+ # And read it back with a DBEntry
112
+ with imas.DBEntry(fname, "r") as entry:
113
+ ids2 = entry.get(idsname)
114
+ compare_children(ids, ids2)
115
+ # Reading the netCDF file with xarray should produce an identical dataset
116
+ ncxrds = xarray.load_dataset(fname, group=f"{idsname}/0")
117
+ assert xrds.equals(ncxrds)