From 0da29c8d2803711bf59fd9ef730edf0c53bd9770 Mon Sep 17 00:00:00 2001 From: Maarten Sebregts Date: Wed, 10 Jun 2026 15:54:53 +0200 Subject: [PATCH] Add `units` metadata to Identifiers N.B. This commit also includes a refactor to remove IDSIdentifier.__init__ and use __new__ only. See https://docs.python.org/3/howto/enum.html#when-to-use-new-vs-init --- docs/source/identifiers.rst | 33 ++++++++++++++++++++++----- imas/ids_identifiers.py | 43 +++++++++++++++++++++++------------ imas/test/test_identifiers.py | 27 ++++++++++++++-------- 3 files changed, 73 insertions(+), 30 deletions(-) diff --git a/docs/source/identifiers.rst b/docs/source/identifiers.rst index 408c7abe..8a05a4a5 100644 --- a/docs/source/identifiers.rst +++ b/docs/source/identifiers.rst @@ -14,13 +14,16 @@ enumerated list of options for defining, for example: - These may have alternative naming conventions supported through aliases (e.g., "235U" and "U_235" for Uranium 235). -Identifiers are a list of possible valid labels. Each label has up to four -representations: +Identifiers are a list of possible valid options. Each option has three representations +that are stored in an IDS: 1. An index (integer) 2. A name (short string) 3. A description (long string) -4. List of aliases (list of short strings) + +.. seealso:: + `Data Dictionary documentation for identifiers + `__ Identifiers in IMAS-Python @@ -32,6 +35,20 @@ constructed on-demand from the loaded Data Dictionary definitions. All identifier enums can be accessed through ``imas.identifiers``. A list of the available identifiers is stored as ``imas.identifiers.identifiers``. +Each identifier option provides the following attributes: + +- ``name``: the name of the option. +- ``index``: the integer index value of the option. +- ``description``: a longer string describing the option. +- ``aliases``: a list of aliases that can be used instead of the name. +- ``units``: optional information about the units of the quantities that are affected by + the identifier option. Take, for example, the `poloidal plan coordinate identifier + `__ + which affects the units of ``grid/dim1`` and ``grid/dim2``. + +.. versionadded:: 2.1.0 ``aliases`` for identifiers. +.. versionadded:: 2.3.0 ``units`` metadata. + .. code-block:: python :caption: Accessing identifiers @@ -47,6 +64,9 @@ the available identifiers is stored as ``imas.identifiers.identifiers``. print(csid.total.index) print(csid.total.description) + # Search identifier options by their index value + print(csid(1)) + # Access identifiers with aliases (when available) mid = imas.identifiers.materials_identifier print(mid["235U"].name) # Access by canonical name @@ -57,12 +77,12 @@ the available identifiers is stored as ``imas.identifiers.identifiers``. assert mid["235U"].name is mid.U_235.name # Item access is also possible - print(identifiers["edge_source_identifier"]) + print(imas.identifiers["edge_source_identifier"]) # You can use imas.util.inspect to list all options - imas.util.inspect(identifiers.ggd_identifier) + imas.util.inspect(imas.identifiers.ggd_identifier) # And also to get more details of a specific option - imas.util.inspect(identifiers.ggd_identifier.SN) + imas.util.inspect(imas.identifiers.ggd_identifier.SN) # When an IDS node is an identifier, you can use # metadata.identifier_enum to get the identifier @@ -186,6 +206,7 @@ material_identifier["235U"]. mat.names[0] = mid.U_235.name # enum value via alias mat.names[0] = mid["U_235"].name # enum value via alias + Compare identifiers ------------------- diff --git a/imas/ids_identifiers.py b/imas/ids_identifiers.py index 1525a070..8bb522ef 100644 --- a/imas/ids_identifiers.py +++ b/imas/ids_identifiers.py @@ -4,7 +4,7 @@ import logging from enum import Enum -from typing import Iterable, List, Type +from typing import Iterable, List, Type, Optional from xml.etree.ElementTree import fromstring from imas import dd_zip @@ -15,19 +15,29 @@ class IDSIdentifier(Enum): """Base class for all identifier enums.""" - def __new__(cls, value: int, description: str, aliases: list = []): + name: str + """Name of this identifier value.""" + index: int + """Unique index for this identifier value.""" + description: str + """Description for this identifier value.""" + aliases: list[str] + """Alternative names for this identifier value.""" + units: Optional[str] + """Units of the quantity/quantities altered by this identifier. May be ``None`` if + the Data Dictionary doesn't provide this metadata.""" + + def __new__( + cls, value: int, description: str, aliases: list[str], units: Optional[str] + ): obj = object.__new__(cls) obj._value_ = value + obj.index = value + obj.description = description + obj.aliases = aliases + obj.units = units return obj - def __init__(self, value: int, description: str, aliases: list = []) -> None: - self.index = value - """Unique index for this identifier value.""" - self.description = description - """Description for this identifier value.""" - self.aliases = aliases - """Alternative names for this identifier value.""" - def __eq__(self, other): if self is other: return True @@ -68,19 +78,22 @@ def __eq__(self, other): def _from_xml(cls, identifier_name, xml) -> Type["IDSIdentifier"]: element = fromstring(xml) enum_values = {} - aliases = {} for int_element in element.iterfind("int"): name = int_element.get("name") value = int_element.text + assert value is not None description = int_element.get("description") # alias attribute may contain multiple comma-separated aliases alias_attr = int_element.get("alias", "") aliases = [a.strip() for a in alias_attr.split(",") if a.strip()] - # Canonical entry: use the canonical 'name' as key - enum_values[name] = (int(value), description, aliases) - # Also add alias names as enum *aliases* (they become enum attributes) + units = int_element.get("units") + + # Identifier can be looked up by its name or any of its aliases: + enumvalue = (int(value), description, aliases, units) + enum_values[name] = enumvalue for alias in aliases: - enum_values[alias] = (int(value), description, aliases) + enum_values[alias] = enumvalue + # Create the enumeration enum = cls( identifier_name, diff --git a/imas/test/test_identifiers.py b/imas/test/test_identifiers.py index 119e0e88..f7e7b5fc 100644 --- a/imas/test/test_identifiers.py +++ b/imas/test/test_identifiers.py @@ -6,11 +6,9 @@ from imas.ids_factory import IDSFactory from imas.ids_identifiers import IDSIdentifier, identifiers -has_aliases = Version(importlib.metadata.version("imas_data_dictionaries")) >= Version( - "4.1.0" -) -requires_aliases = pytest.mark.skipif( - not has_aliases, reason="Requires DD 4.1.0 for identifier aliases" +requires_dd4_1 = pytest.mark.skipif( + Version(importlib.metadata.version("imas_data_dictionaries")) < Version("4.1.0"), + reason="Test requires DD 4.1.0 for additional identifier metadata", ) @@ -112,7 +110,7 @@ def test_identifiers_with_aliases(): assert identifier.CxHy.aliases == ["alias1", "alias2", "3alias"] -@requires_aliases +@requires_dd4_1 def test_identifier_struct_assignment_with_aliases(): """Test identifier struct assignment with aliases using materials_identifier.""" mid = identifiers.materials_identifier @@ -174,7 +172,7 @@ def test_invalid_identifier_assignment(): cs.source[0].identifier = -1 -@requires_aliases +@requires_dd4_1 def test_identifier_aliases(): """Test identifier enum aliases functionality.""" mid = identifiers.materials_identifier @@ -197,7 +195,7 @@ def test_identifier_aliases(): assert mid[alias] is mid.U_235 -@requires_aliases +@requires_dd4_1 def test_identifier_alias_equality(): """Test that identifiers with aliases are equal when comparing names and aliases.""" mid = identifiers.materials_identifier @@ -276,7 +274,7 @@ def test_identifier_alias_equality(): assert mat5.descriptions[2] == mid["U_235"].description -@requires_aliases +@requires_dd4_1 def test_identifier_alias_equality_non_ggd(): """Test identifier aliases functionality on non-ggd material""" mid = identifiers.materials_identifier @@ -293,3 +291,14 @@ def test_identifier_alias_equality_non_ggd(): summary_ids.wall.material.name = "235U" # Use canonical name assert summary_ids.wall.material == mid["235U"] assert summary_ids.wall.material == mid["U_235"] + + +@requires_dd4_1 +def test_identifier_units(): + ppcid = identifiers.poloidal_plane_coordinates_identifier + assert ppcid.rectangular.units == "m,m" + assert ppcid.inverse.units == "m,rad" + + # materials identifier doesn't have units (and I don't expect they'll ever get any) + mid = identifiers.materials_identifier + assert mid.W.units is None