From 0cb665e1a16ee688d0a99251d616451eda65a3ee Mon Sep 17 00:00:00 2001 From: Sebbe Blokhuizen Date: Wed, 22 Jul 2026 13:28:55 +0200 Subject: [PATCH] Revert "Merge pull request #170 from DaanVanVugt/feature/reference-tendency" This reverts commit 214cd7764b6efc138bf3a5116b08f7c0f11c1e88, reversing changes made to 9b13366a6029803f0fe1a93a171e84e0bf7f3385. --- docs/source/examples/imports.yaml | 49 -- docs/source/tendencies.rst | 44 -- docs/source/yaml_format.rst | 38 +- tests/muscle3_integration/overlay.ymmsl.in | 2 +- .../overlay_waveforms.yaml | 9 +- tests/test_configuration.py | 12 +- tests/test_exporter.py | 561 ++---------------- tests/test_muscle3.py | 152 +---- tests/test_waveform.py | 47 -- tests/test_yaml/example.yaml | 2 +- tests/test_yaml_parser.py | 26 +- waveform_editor/configuration.py | 26 - waveform_editor/export/exporter.py | 229 ++++--- waveform_editor/gui/dict_editor.py | 12 +- waveform_editor/gui/editor.py | 4 +- waveform_editor/gui/main.py | 16 +- waveform_editor/gui/plotter_edit.py | 5 +- waveform_editor/ids_fill.py | 131 ---- waveform_editor/import_resolver.py | 308 ---------- waveform_editor/import_waveform.py | 62 -- waveform_editor/muscle3.py | 95 ++- waveform_editor/static_waveform.py | 28 - waveform_editor/tendencies/import_tendency.py | 89 --- waveform_editor/waveform.py | 49 +- waveform_editor/yaml/yaml_globals.py | 11 +- waveform_editor/yaml/yaml_parser.py | 59 -- 26 files changed, 279 insertions(+), 1787 deletions(-) delete mode 100644 docs/source/examples/imports.yaml delete mode 100644 waveform_editor/ids_fill.py delete mode 100644 waveform_editor/import_resolver.py delete mode 100644 waveform_editor/import_waveform.py delete mode 100644 waveform_editor/static_waveform.py delete mode 100644 waveform_editor/tendencies/import_tendency.py diff --git a/docs/source/examples/imports.yaml b/docs/source/examples/imports.yaml deleted file mode 100644 index c1dc5b11..00000000 --- a/docs/source/examples/imports.yaml +++ /dev/null @@ -1,49 +0,0 @@ -# Reference example for the `imports` mechanism. The URIs are illustrative. -globals: - dd_version: 4.0.0 - imports: - machine: imas:hdf5?path=/path/to/machine_description # static (no /time) - scenario: imas:hdf5?path=/path/to/scenario_run # time-dependent - live_eq: {port: equilibrium_in} # received at run time - -ec_launchers: - # Whole-IDS import = machine-description overlay: copied in first (as a base), - # then the leaves below override individual nodes. - ec_launchers/*: - - {ref: machine} - ec_launchers/beam(1)/phase/angle: - - {type: linear, to: 1.65, duration: 100} - -Heating: - # Scalar (0D) import, resampled onto the export /time with linear interpolation. - core_sources/source(1)/global_quantities/total_ion_power: - - {ref: scenario, interp: linear} - - # Static value (not a time series) -- names the source. - core_sources/source(1)/identifier/name: - - {value: ec} - - # Trailing /* -- mirror every filled leaf of the subtree from the entry. - core_sources/source(1)/profiles_1d/*: - - {ref: scenario} - - # Index wildcard -- import the leaf for every source. - core_sources/source(*)/global_quantities/total_ion_power: - - {ref: scenario} - - # Multiple index wildcards -- every (source, ion) combination. - core_sources/source(*)/profiles_1d/ion(*)/z_ion: - - {ref: scenario} - -Plasma current: - # Composite (0D only): an analytic segment, then an imported segment. Each fills its - # [start, end] window. - equilibrium/time_slice/global_quantities/ip: - - {type: constant, value: -1.0e6, duration: 1} - - {ref: scenario, duration: 1, time_offset: 0.5} - - # Overlay onto a live IDS received over a MUSCLE3 port (the actor's equilibrium_in), - # then override a leaf. Same shape as the machine-description overlay above, but the - # source is runtime data rather than a file. - equilibrium/*: - - {ref: live_eq} diff --git a/docs/source/tendencies.rst b/docs/source/tendencies.rst index 72d610aa..88030e0e 100644 --- a/docs/source/tendencies.rst +++ b/docs/source/tendencies.rst @@ -8,8 +8,6 @@ This document describes the different types of tendencies available in the Wavef Each tendency defines the behavior of the signal over a specific time interval. You can chain multiple tendencies together to create complex waveforms. -If ``type`` is omitted it is inferred from the entry's keys: ``ref`` → :ref:`import `, ``to`` → linear, ``time`` → :ref:`piecewise `, ``value`` → constant; anything else defaults to ``linear`` (so a ``from``-only, ``rate``-only, or bare segment is still a linear ramp). Tendencies with no distinguishing key -- the periodic shapes, ``smooth``, and a value-less ``constant`` -- must name their ``type`` explicitly. - Common Time Parameters ====================== @@ -190,48 +188,6 @@ Parameters .. warning:: This tendency does **not** accept the common ``start``, ``duration``, or ``end`` parameters. These are derived directly from the required ``time`` list. -.. _import-tendency: - -Import -====== - -Takes its values from an external entry in :ref:`globals.imports ` instead of an analytic shape, resampled onto the export time base. By default it reads the waveform's own DD path. - -*Type:* ``import`` (inferred when ``ref`` is present) - -Parameters ----------- -* ``ref``: Entry in ``globals.imports`` to read from. -* ``path``: DD path to read. Defaults to the waveform's own path. -* ``time_offset``: Offset added to the export time when sampling. Defaults to ``0``. -* ``interp``: Resampling mode: ``closest`` (default), ``linear`` or ``previous``. - -.. code-block:: yaml - - core_sources/source(1)/profiles_1d/electrons/energy: - - {ref: scenario, interp: linear} - -Wildcards expand against the source: a trailing ``*`` imports a whole subtree (``/*`` copies a whole IDS), and a ``(*)`` index wildcard iterates every element of an array of structure -- several may be combined, e.g. every ion of every source. An overlay may list several sources, applied in order: - -.. code-block:: yaml - - core_sources/source(*)/profiles_1d/ion(*)/z_ion: - - {ref: scenario} - - ec_launchers/*: # whole-IDS overlay; sources stack - - {ref: machine} - - {ref: scenario} - -**Precedence.** Where imports or explicit waveforms write the same node, the most specific wins regardless of order: ``/*`` < subtree ``.../*`` < explicit leaf. Equal specificity falls back to listing order (last wins). - -Non-0D imports (a profile, a wildcard subtree) own the whole waveform. Only **0D (scalar)** imports combine with analytic segments, each filling its ``[start, end]`` window: - -.. code-block:: yaml - - equilibrium/time_slice/global_quantities/ip: - - {type: constant, value: -1.0, duration: 1} # analytic on [0, 1] s - - {ref: scenario, duration: 1} # imported on [1, 2] s - Periodic Tendencies =================== diff --git a/docs/source/yaml_format.rst b/docs/source/yaml_format.rst index 3f90bdbf..72fd8d7c 100644 --- a/docs/source/yaml_format.rst +++ b/docs/source/yaml_format.rst @@ -50,29 +50,21 @@ These parameters can be changed under the "Edit Global Properties" tab in the GU globals: dd_version: 3.42.0 -* **imports:** Named external data entries that waveforms read from (see - :ref:`Import `), keyed by names you choose and refer to with - ``{ref: }``. Each value is an IMAS URI, or ``{port: }`` for an IDS - received on a MUSCLE3 port at run time (used by the actor). +* **machine_description:** Provides URIs for IMAS machine description entries. + The machine descriptions are relevant when you :ref:`export a waveform configuration to an IDS`. + When exporting, any existing data from the given machine description will be copied + to the new IDS, before the waveforms from the configuration are added. + To specify machine descriptions for a target IDS, use a dictionary where keys are + the IDS names and values are their corresponding machine description URIs. .. code-block:: yaml globals: dd_version: 3.42.0 - imports: - machine: imas:hdf5?path=machine_description1 - scenario: imas:hdf5?path=scenario_run - live_eq: {port: equilibrium_in} - - Overlay a machine-description IDS with an ``/*`` wildcard import, then override - individual nodes. - - .. code-block:: yaml - - ec_launchers: - ec_launchers/*: - - {ref: machine} # overlay base - ec_launchers/beam(1)/phase/angle: -1.65898 # then override leaves + machine_description: + ec_launchers: imas:hdf5?path=machine_description1 + nbi: imas:hdf5?path=machine_description2 + # Add other IDSs as needed Grouping Waveforms ------------------ @@ -114,8 +106,6 @@ a list of waveforms, or a single number (float or integer). # Implicit linear ramp back to 0 over 25 seconds - { duration: 25, to: 0 } - If ``type`` is omitted it is inferred from the entry's keys: ``ref`` → ``import``, ``to`` → ``linear``, ``time`` → ``piecewise``, ``value`` → ``constant``; anything else defaults to ``linear``. Tendencies with no distinguishing key (the periodic shapes, ``smooth``, a value-less ``constant``) must name their ``type``. - Refer to the :ref:`Available Tendencies ` documentation for details on the different tendency types and their parameters. 2. **Constant Value:** A simple number (integer or float) defines a constant waveform over time. @@ -164,12 +154,4 @@ Slicing can be applied at multiple nested levels. For example, the following fil interferometer/channel(2:3)/wavelength(1:4)/phase_corrected/data: 15.0 -Complete Example ----------------- - -The following configuration exercises the full :ref:`imports ` mechanism: a machine-description overlay (``/*``), a scalar import with interpolation, a static value, trailing-subtree and index wildcards (``source(*)``, ``ion(*)``), a 0D composite, and a runtime port-import. - -.. literalinclude:: examples/imports.yaml - :language: yaml - diff --git a/tests/muscle3_integration/overlay.ymmsl.in b/tests/muscle3_integration/overlay.ymmsl.in index da113730..93f79f61 100644 --- a/tests/muscle3_integration/overlay.ymmsl.in +++ b/tests/muscle3_integration/overlay.ymmsl.in @@ -14,7 +14,7 @@ model: waveform_actor: implementation: waveform_actor ports: - # An IDS-named input port exposes the received IDS as a port-import: + # An IDS-named input port selects overlay mode: f_init: equilibrium_in # Name of the output port is "_out": o_f: diff --git a/tests/muscle3_integration/overlay_waveforms.yaml b/tests/muscle3_integration/overlay_waveforms.yaml index 3d988b95..ccb7c054 100644 --- a/tests/muscle3_integration/overlay_waveforms.yaml +++ b/tests/muscle3_integration/overlay_waveforms.yaml @@ -1,13 +1,8 @@ -# Waveform configuration for the overlay example: the equilibrium received on the -# 'equilibrium_in' port is imported whole (overlay base), then a single plasma-current -# ramp is written onto every time slice while its other data (the boundary) is kept. +# Waveform configuration for the overlay example: a single plasma-current ramp that the +# actor writes onto every time slice of the equilibrium it receives. globals: dd_version: 4.0.0 - imports: - eq_in: {port: equilibrium_in} Plasma current: - equilibrium/*: - - {ref: eq_in} equilibrium/time_slice/global_quantities/ip: - {type: linear, to: -15e6, duration: 100} diff --git a/tests/test_configuration.py b/tests/test_configuration.py index 2bf51be9..88ea1905 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -256,7 +256,7 @@ def test_dump_comments(): yaml_str = dedent(""" globals: dd_version: 3.42.0 - imports: + machine_description: ec_launchers: imas:hdf5?path=test_md ec_launchers: beams: @@ -280,12 +280,12 @@ def test_dump_globals(): config = WaveformConfiguration() config.load_yaml(yaml_str) config.globals.dd_version = "3.41.0" - config.globals.imports = {"ec_launchers": "imas:mdsplus?path=test"} + config.globals.machine_description = {"ec_launchers": "imas:mdsplus?path=test"} dumped_yaml = config.dump() expected_dump = dedent(""" globals: dd_version: 3.41.0 - imports: + machine_description: ec_launchers: imas:mdsplus?path=test ec_launchers: ec_launchers/beam(1)/phase/angle: @@ -326,7 +326,7 @@ def test_load_yaml_globals(): yaml_str = """ globals: dd_version: 3.42.0 - imports: + machine_description: ec_launchers: imas:hdf5?path=testdb ec_launchers: ec_launchers/beam(1)/phase/angle: 1e-3 @@ -334,7 +334,7 @@ def test_load_yaml_globals(): config = WaveformConfiguration() config.load_yaml(yaml_str) assert config.globals.dd_version == "3.42.0" - assert config.globals.imports["ec_launchers"] == "imas:hdf5?path=testdb" + assert config.globals.machine_description["ec_launchers"] == "imas:hdf5?path=testdb" yaml_str = """ ec_launchers: @@ -342,7 +342,7 @@ def test_load_yaml_globals(): """ config.load_yaml(yaml_str) assert config.globals.dd_version == LATEST_DD_VERSION - assert not config.globals.imports + assert not config.globals.machine_description def test_bounds(config): diff --git a/tests/test_exporter.py b/tests/test_exporter.py index 2e73596a..c82b6af7 100644 --- a/tests/test_exporter.py +++ b/tests/test_exporter.py @@ -175,16 +175,13 @@ def test_to_ids_aos(tmp_path): def test_export_with_md(tmp_path, ec_launchers_md_uri): - """A whole-IDS import (`ec_launchers/*`) acts as a machine-description base: it is - overlaid first, then explicit leaf waveforms override individual nodes.""" + """Test export if machine description is provided.""" yaml_str = f""" globals: dd_version: 4.0.0 - imports: - md: {ec_launchers_md_uri} + machine_description: + ec_launchers: {ec_launchers_md_uri} ec_launchers: - ec_launchers/*: - - {{ref: md}} ec_launchers/beam(2)/phase/angle: 1 """ uri = f"{tmp_path}/test_db.nc" @@ -209,40 +206,13 @@ def test_export_full_slice_flt_1d(tmp_path): assert np.array_equal(ids.beam[0].phase.angle, [111] * 3) -@pytest.mark.parametrize("slice_first", [True, False]) -def test_full_slice_independent_of_order(tmp_path, slice_first): - """A `:` slice expands to the final array size whether it is listed before or after - the explicitly-indexed sibling that sizes the array: beam(:) fills all four beams - sized by beam(4). Arrays are sized (once) before any value is filled.""" - slice_wf = "ec_launchers/beam(:)/phase/angle: 7" - index_wf = ( - "ec_launchers/beam(4)/power_launched/data:\n" - " - {type: constant, value: 1.0}" - ) - order = (slice_wf, index_wf) if slice_first else (index_wf, slice_wf) - yaml_str = f""" - ec_launchers: - {order[0]} - {order[1]} - """ - uri = f"{tmp_path}/test_db.nc" - _export_ids(uri, yaml_str, np.array([0, 0.5, 1.0])) - with imas.DBEntry(uri, "r", dd_version="4.0.0") as dbentry: - ids = dbentry.get("ec_launchers") - assert len(ids.beam) == 4 - for beam in range(4): - assert np.array_equal(ids.beam[beam].phase.angle, [7] * 3) - - def test_export_full_slice_md_flt_1d(tmp_path, ec_launchers_md_uri): yaml_str = f""" globals: dd_version: 4.0.0 - imports: - md: {ec_launchers_md_uri} + machine_description: + ec_launchers: {ec_launchers_md_uri} ec_launchers: - ec_launchers/*: - - {{ref: md}} ec_launchers/beam(:)/phase/angle: 123 """ uri = f"{tmp_path}/test_db.nc" @@ -274,11 +244,9 @@ def test_export_slice_md_flt_1d(tmp_path, ec_launchers_md_uri): yaml_str = f""" globals: dd_version: 4.0.0 - imports: - md: {ec_launchers_md_uri} + machine_description: + ec_launchers: {ec_launchers_md_uri} ec_launchers: - ec_launchers/*: - - {{ref: md}} ec_launchers/beam(2:3)/phase/angle: 123 """ uri = f"{tmp_path}/test_db.nc" @@ -327,11 +295,9 @@ def test_export_half_slice_md_forward_flt_1d(tmp_path, ec_launchers_md_uri): yaml_str = f""" globals: dd_version: 4.0.0 - imports: - md: {ec_launchers_md_uri} + machine_description: + ec_launchers: {ec_launchers_md_uri} ec_launchers: - ec_launchers/*: - - {{ref: md}} ec_launchers/beam(2:)/phase/angle: 123 """ uri = f"{tmp_path}/test_db.nc" @@ -349,11 +315,9 @@ def test_export_half_slice_md_backward_flt_1d(tmp_path, ec_launchers_md_uri): yaml_str = f""" globals: dd_version: 4.0.0 - imports: - md: {ec_launchers_md_uri} + machine_description: + ec_launchers: {ec_launchers_md_uri} ec_launchers: - ec_launchers/*: - - {{ref: md}} ec_launchers/beam(:2)/phase/angle: 123 """ uri = f"{tmp_path}/test_db.nc" @@ -413,11 +377,9 @@ def test_export_full_slice_md_flt_0d(tmp_path, core_sources_md_uri): yaml_str = f""" globals: dd_version: 4.0.0 - imports: - md: {core_sources_md_uri} + machine_description: + core_sources: {core_sources_md_uri} core_sources: - core_sources/*: - - {{ref: md}} core_sources/source(:)/global_quantities/power: - {{type: piecewise, time: [0, 0.5, 1], value: [1,2,3]}} """ @@ -457,11 +419,9 @@ def test_export_slice_md_flt_0d(tmp_path, core_sources_md_uri): yaml_str = f""" globals: dd_version: 4.0.0 - imports: - md: {core_sources_md_uri} + machine_description: + core_sources: {core_sources_md_uri} core_sources: - core_sources/*: - - {{ref: md}} core_sources/source(2:3)/global_quantities/power: - {{type: piecewise, time: [0, 0.5, 1], value: [1,2,3]}} """ @@ -523,11 +483,9 @@ def test_export_half_slice_md_forward_flt_0d(tmp_path, core_sources_md_uri): yaml_str = f""" globals: dd_version: 4.0.0 - imports: - md: {core_sources_md_uri} + machine_description: + core_sources: {core_sources_md_uri} core_sources: - core_sources/*: - - {{ref: md}} core_sources/source(2:)/global_quantities/power: - {{type: piecewise, time: [0, 0.5, 1], value: [1,2,3]}} """ @@ -549,11 +507,9 @@ def test_export_half_slice_md_backward_flt_0d(tmp_path, core_sources_md_uri): yaml_str = f""" globals: dd_version: 4.0.0 - imports: - md: {core_sources_md_uri} + machine_description: + core_sources: {core_sources_md_uri} core_sources: - core_sources/*: - - {{ref: md}} core_sources/source(:2)/global_quantities/power: - {{type: piecewise, time: [0, 0.5, 1], value: [1,2,3]}} """ @@ -753,470 +709,27 @@ def test_example_yaml(tmp_path): assert np.all(nbi.unit[0].energy.data == values) -@pytest.fixture -def core_sources_ext_uri(tmp_path): - """An external, time-dependent core_sources to import from. Its single source has - only identifier.index set (no identifier.name), so the config assigns the name.""" - uri = f"imas:hdf5?path={tmp_path}/ext" - with imas.DBEntry(uri, "w", dd_version="4.0.0") as dbentry: - cs = dbentry.factory.new("core_sources") - cs.ids_properties.homogeneous_time = imas.ids_defs.IDS_TIME_MODE_HOMOGENEOUS - cs.time = [0.0, 1.0, 2.0] - cs.source.resize(1) - cs.source[0].identifier.index = 1 - cs.source[0].profiles_1d.resize(3) - for t in range(3): - cs.source[0].profiles_1d[t].grid.rho_tor_norm = [0.0, 0.5, 1.0] - cs.source[0].profiles_1d[t].electrons.energy = [t * 10.0] * 3 - dbentry.put(cs) - return uri - - -def test_import_scalar(core_sources_ext_uri): - """An import reads its (resampled) value per variable from a named entry at the same - path; a {value: } constant assigns a static field (e.g. a name).""" - config = WaveformConfiguration() - config.load_yaml( - f""" -globals: - dd_version: 4.0.0 - imports: - ext: "{core_sources_ext_uri}" -Heating: - core_sources/source(1)/profiles_1d/electrons/energy: - - {{type: reference, ref: ext}} - core_sources/source(1)/identifier/name: - - {{value: ec}} -""" - ) - times = np.array([0.0, 1.0, 2.0]) - cs = ConfigurationExporter(config, times).to_ids_dict()["core_sources"] - - # a {value: } constant names the (1-based) first source: - assert str(cs.source[0].identifier.name) == "ec" - # the profile was read from the reference and resampled onto /time: - assert np.allclose( - [cs.source[0].profiles_1d[t].electrons.energy[0] for t in range(3)], - [0.0, 10.0, 20.0], - ) - # only the referenced node was imported, not its siblings: - assert len(cs.source[0].profiles_1d[0].grid.rho_tor_norm) == 0 - - -@pytest.fixture -def equilibrium_ext_uri(tmp_path): - """An external equilibrium with a scalar ip(t) = 0, 10, 20 at t = 0, 1, 2.""" - uri = f"imas:hdf5?path={tmp_path}/eq" - with imas.DBEntry(uri, "w", dd_version="4.0.0") as dbentry: - eq = dbentry.factory.new("equilibrium") - eq.ids_properties.homogeneous_time = imas.ids_defs.IDS_TIME_MODE_HOMOGENEOUS - eq.time = [0.0, 1.0, 2.0] - eq.time_slice.resize(3) - for t in range(3): - eq.time_slice[t].global_quantities.ip = t * 10.0 - dbentry.put(eq) - return uri - - -def test_import_composite(equilibrium_ext_uri): - """A scalar waveform mixing an analytic and an import segment: each fills its - [start, end] window. Imports may be combined with analytic segments only for 0D.""" - config = WaveformConfiguration() - config.load_yaml( - f""" -globals: - dd_version: 4.0.0 - imports: - ext: "{equilibrium_ext_uri}" -Plasma current: - equilibrium/time_slice/global_quantities/ip: - - {{type: constant, value: -1.0, duration: 1}} - - {{ref: ext, duration: 1}} -""" - ) - times = np.array([0.0, 2.0]) - eq = ConfigurationExporter(config, times).to_ids_dict()["equilibrium"] - - # [0, 1] s: analytic constant -1; [1, 2] s: reference ip (20 at t=2) - assert eq.time_slice[0].global_quantities.ip == -1.0 - assert eq.time_slice[1].global_quantities.ip == 20.0 - - -def test_import_wildcard(core_sources_ext_uri): - """A `*` after a prefix imports every filled leaf of that subtree from the entry.""" - config = WaveformConfiguration() - config.load_yaml( - f""" -globals: - dd_version: 4.0.0 - imports: - ext: "{core_sources_ext_uri}" -Heating: - core_sources/source(1)/profiles_1d/*: - - {{ref: ext}} -""" - ) - times = np.array([0.0, 1.0, 2.0]) - cs = ConfigurationExporter(config, times).to_ids_dict()["core_sources"] - - # both leaves under profiles_1d were imported across all three slices: - assert len(cs.source[0].profiles_1d) == 3 - assert np.allclose( - [cs.source[0].profiles_1d[t].electrons.energy[0] for t in range(3)], - [0.0, 10.0, 20.0], - ) - assert np.allclose(cs.source[0].profiles_1d[0].grid.rho_tor_norm, [0.0, 0.5, 1.0]) - - -@pytest.fixture -def core_sources_multi_ext_uri(tmp_path): - """An external core_sources with two sources; source(s) has electrons.energy - offset by 100*s so each source is distinguishable after import.""" - uri = f"imas:hdf5?path={tmp_path}/multi" - with imas.DBEntry(uri, "w", dd_version="4.0.0") as dbentry: - cs = dbentry.factory.new("core_sources") - cs.ids_properties.homogeneous_time = imas.ids_defs.IDS_TIME_MODE_HOMOGENEOUS - cs.time = [0.0, 1.0, 2.0] - cs.source.resize(2) - for s in range(2): - cs.source[s].profiles_1d.resize(3) - for t in range(3): - cs.source[s].profiles_1d[t].grid.rho_tor_norm = [0.0, 0.5, 1.0] - cs.source[s].profiles_1d[t].electrons.energy = [100 * s + t * 10.0] * 3 - dbentry.put(cs) - return uri - - -def test_import_index_wildcard(core_sources_multi_ext_uri): - """A `(*)` index wildcard imports the leaf for every element of that array of - structure in the source (here: the same leaf across all sources).""" - config = WaveformConfiguration() - config.load_yaml( - f""" -globals: - dd_version: 4.0.0 - imports: - ext: "{core_sources_multi_ext_uri}" -Heating: - core_sources/source(*)/profiles_1d/electrons/energy: - - {{ref: ext}} -""" - ) - times = np.array([0.0, 1.0, 2.0]) - cs = ConfigurationExporter(config, times).to_ids_dict()["core_sources"] - - # both sources were imported, each with its own offset across all slices: - assert len(cs.source) == 2 - for s in range(2): - assert np.allclose( - [cs.source[s].profiles_1d[t].electrons.energy[0] for t in range(3)], - [100 * s + t * 10.0 for t in range(3)], - ) - - -@pytest.fixture -def core_sources_ions_ext_uri(tmp_path): - """An external core_sources with two sources, each with two ions; ion z_ion encodes - its (source, ion) indices as 10*source + ion so every combination is distinguishable - after a two-dimensional wildcard import.""" - uri = f"imas:hdf5?path={tmp_path}/ions" - with imas.DBEntry(uri, "w", dd_version="4.0.0") as dbentry: - cs = dbentry.factory.new("core_sources") - cs.ids_properties.homogeneous_time = imas.ids_defs.IDS_TIME_MODE_HOMOGENEOUS - cs.time = [0.0, 1.0] - cs.source.resize(2) - for s in range(2): - cs.source[s].profiles_1d.resize(2) - for t in range(2): - cs.source[s].profiles_1d[t].ion.resize(2) - for i in range(2): - cs.source[s].profiles_1d[t].ion[i].z_ion = 10.0 * s + i - dbentry.put(cs) - return uri - - -def test_import_index_wildcard_multi(core_sources_ions_ext_uri): - """Multiple `(*)` index wildcards expand over every combination (here sources x - ions), supporting higher-dimensional imports.""" - config = WaveformConfiguration() - config.load_yaml( - f""" -globals: - dd_version: 4.0.0 - imports: - ext: "{core_sources_ions_ext_uri}" -Heating: - core_sources/source(*)/profiles_1d/ion(*)/z_ion: - - {{ref: ext}} -""" - ) - times = np.array([0.0, 1.0]) - cs = ConfigurationExporter(config, times).to_ids_dict()["core_sources"] - - assert len(cs.source) == 2 - for s in range(2): - assert len(cs.source[s].profiles_1d[0].ion) == 2 - for i in range(2): - for t in range(2): - assert cs.source[s].profiles_1d[t].ion[i].z_ion == 10.0 * s + i - - -@pytest.fixture -def core_sources_varying_ions_uri(tmp_path): - """A core_sources whose ion array of structure varies in size across time slices - (2 ions at t=0, 3 ions at t=1) -- a legal but wildcard-incompatible source.""" - uri = f"imas:hdf5?path={tmp_path}/varying" - with imas.DBEntry(uri, "w", dd_version="4.0.0") as dbentry: - cs = dbentry.factory.new("core_sources") - cs.ids_properties.homogeneous_time = imas.ids_defs.IDS_TIME_MODE_HOMOGENEOUS - cs.time = [0.0, 1.0] - cs.source.resize(1) - cs.source[0].profiles_1d.resize(2) - for t, n_ions in enumerate((2, 3)): - cs.source[0].profiles_1d[t].ion.resize(n_ions) - for i in range(n_ions): - cs.source[0].profiles_1d[t].ion[i].z_ion = float(i) - dbentry.put(cs) - return uri - - -def test_import_index_wildcard_varying_in_time_raises(core_sources_varying_ions_uri): - """A `(*)` over an array of structure whose size varies in time raises a clear - error instead of silently importing only some elements.""" - config = WaveformConfiguration() - config.load_yaml( - f""" -globals: - dd_version: 4.0.0 - imports: - ext: "{core_sources_varying_ions_uri}" -Heating: - core_sources/source(1)/profiles_1d/ion(*)/z_ion: - - {{ref: ext}} -""" - ) - with pytest.raises(RuntimeError, match="varies across a time-dependent parent"): - ConfigurationExporter(config, np.array([0.0, 1.0])).to_ids_dict() - - -def test_import_time_offset(core_sources_ext_uri): - """`time_offset` shifts the time at which the import is sampled.""" - config = WaveformConfiguration() - config.load_yaml( - f""" -globals: - dd_version: 4.0.0 - imports: - ext: "{core_sources_ext_uri}" -Heating: - core_sources/source(1)/profiles_1d/electrons/energy: - - {{type: reference, ref: ext, time_offset: 1.0}} -""" - ) - times = np.array([0.0, 1.0]) - cs = ConfigurationExporter(config, times).to_ids_dict()["core_sources"] - - # sampled at t+1 (CLOSEST): export t=0 -> ext t=1 (10), export t=1 -> ext t=2 (20) - assert np.allclose( - [cs.source[0].profiles_1d[t].electrons.energy[0] for t in range(2)], - [10.0, 20.0], - ) - - -def test_import_interp_linear(equilibrium_ext_uri): - """`interp: linear` interpolates between the source time slices instead of snapping - to the closest one.""" - config = WaveformConfiguration() - config.load_yaml( - f""" -globals: - dd_version: 4.0.0 - imports: - ext: "{equilibrium_ext_uri}" -Plasma current: - equilibrium/time_slice/global_quantities/ip: - - {{ref: ext, interp: linear}} -""" - ) - times = np.array([0.5, 1.5]) - eq = ConfigurationExporter(config, times).to_ids_dict()["equilibrium"] - - # ip is 0, 10, 20 at t = 0, 1, 2 -> linear interpolation gives 5 and 15: - assert eq.time_slice[0].global_quantities.ip == 5.0 - assert eq.time_slice[1].global_quantities.ip == 15.0 - - -def test_import_missing_entry_errors(core_sources_ext_uri): - """Referring to an import name that is not declared raises, rather than silently - producing an empty IDS.""" +def test_overlay_base_without_waveforms_warns(caplog): + """An overlay base whose IDS has no waveforms in the config is dropped, with a + warning, rather than silently passed through.""" + yaml_str = """ + equilibrium: + equilibrium/time_slice/global_quantities/ip: + - {from: 2, to: 3, duration: 1} + """ config = WaveformConfiguration() - config.load_yaml( - """ -globals: - dd_version: 4.0.0 -Heating: - core_sources/source(1)/profiles_1d/electrons/energy: - - {ref: does_not_exist} -""" - ) - with pytest.raises(KeyError): - ConfigurationExporter(config, np.array([0.0, 1.0])).to_ids_dict() - - -def test_import_port_overlay(): - """A `{port: }` import reads an IDS supplied at run time (as the MUSCLE3 actor - does); a whole-IDS import overlays it, then explicit leaves override.""" - received = imas.IDSFactory("4.0.0").new("equilibrium") - received.ids_properties.homogeneous_time = imas.ids_defs.IDS_TIME_MODE_HOMOGENEOUS - received.time = [0.0, 1.0] - received.time_slice.resize(2) - received.vacuum_toroidal_field.r0 = 6.2 - for t in range(2): - received.time_slice[t].global_quantities.ip = 100.0 + config.load_yaml(yaml_str) - config = WaveformConfiguration() - config.load_yaml( - """ -globals: - dd_version: 4.0.0 - imports: - live: {port: equilibrium_in} -Plasma: - equilibrium/*: - - {ref: live} - equilibrium/time_slice/global_quantities/ip: - - {type: constant, value: 5.0} -""" - ) - times = np.array([0.0, 1.0]) + # Provide a 'core_profiles' base that the configuration says nothing about. + base = imas.IDSFactory("4.0.0").new("core_profiles") exporter = ConfigurationExporter( - config, times, received_idss={"equilibrium_in": received} + config, np.array([0.0, 1.0]), base_idss={"core_profiles": base} ) - eq = exporter.to_ids_dict()["equilibrium"] - - # overlaid base field survived, while ip was overridden by the explicit waveform: - assert eq.vacuum_toroidal_field.r0 == 6.2 - assert eq.time_slice[0].global_quantities.ip == 5.0 - -def test_scalar_import_resolves_in_waveform(equilibrium_ext_uri): - """The scalar import value is produced by the waveform itself (get_value), not the - exporter: the editor/CSV path resolves it directly via the import resolver.""" - config = WaveformConfiguration() - config.load_yaml( - f""" -globals: - dd_version: 4.0.0 - imports: - ext: "{equilibrium_ext_uri}" -Plasma current: - equilibrium/time_slice/global_quantities/ip: - - {{ref: ext, interp: linear}} -""" - ) - waveform = config["equilibrium/time_slice/global_quantities/ip"] - times = np.array([0.5, 1.5]) - _, values = waveform.get_value(times) - # ip is 0, 10, 20 at t = 0, 1, 2 -> linear interpolation gives 5 and 15: - assert np.allclose(values, [5.0, 15.0]) - - -def test_scalar_import_raw_in_editor(equilibrium_ext_uri): - """With no time array (the editor plot), a lone import returns the raw source - samples on the source's own time base -- not resampled onto a foreign grid.""" - config = WaveformConfiguration() - config.load_yaml( - f""" -globals: - dd_version: 4.0.0 - imports: - ext: "{equilibrium_ext_uri}" -Plasma current: - equilibrium/time_slice/global_quantities/ip: - - {{ref: ext}} -""" - ) - waveform = config["equilibrium/time_slice/global_quantities/ip"] - times, values = waveform.get_value() - # the source's own samples: ip = 0, 10, 20 at t = 0, 1, 2 - assert np.allclose(times, [0.0, 1.0, 2.0]) - assert np.allclose(values, [0.0, 10.0, 20.0]) + with caplog.at_level("WARNING"): + idss = exporter.to_ids_dict() - -@pytest.fixture -def two_equilibria(tmp_path): - """Two external equilibria for overlay-precedence tests: 'a' has ip(t) = 0,10,20 and - r0 = 6.2; 'b' has a constant ip = 100 and r0 = 9.9.""" - a = f"imas:hdf5?path={tmp_path}/a" - b = f"imas:hdf5?path={tmp_path}/b" - for uri, const_ip, r0 in ((a, None, 6.2), (b, 100.0, 9.9)): - with imas.DBEntry(uri, "w", dd_version="4.0.0") as dbentry: - eq = dbentry.factory.new("equilibrium") - eq.ids_properties.homogeneous_time = imas.ids_defs.IDS_TIME_MODE_HOMOGENEOUS - eq.time = [0.0, 1.0, 2.0] - eq.vacuum_toroidal_field.r0 = r0 - eq.time_slice.resize(3) - for t in range(3): - eq.time_slice[t].global_quantities.ip = ( - const_ip if const_ip is not None else t * 10.0 - ) - dbentry.put(eq) - return a, b - - -def test_multiple_source_overlay(two_equilibria): - """An overlay may list several sources; they overlay in listed order, so the last - wins at a shared leaf (equal specificity).""" - a, b = two_equilibria - config = WaveformConfiguration() - config.load_yaml( - f""" -globals: - dd_version: 4.0.0 - imports: - a: "{a}" - b: "{b}" -Machine: - equilibrium/*: - - {{ref: a}} - - {{ref: b}} -""" - ) - times = np.array([0.0, 1.0, 2.0]) - eq = ConfigurationExporter(config, times).to_ids_dict()["equilibrium"] - - # 'b' is listed last, so its values win: - assert eq.vacuum_toroidal_field.r0 == 9.9 - assert np.allclose( - [eq.time_slice[t].global_quantities.ip for t in range(3)], [100.0, 100.0, 100.0] - ) - - -def test_specificity_beats_order(two_equilibria): - """A more specific overlay wins over a broader one regardless of listing order: the - broad `equilibrium/*` is listed last but applied first, so the narrower - `equilibrium/vacuum_toroidal_field/*` still wins for r0.""" - a, b = two_equilibria - config = WaveformConfiguration() - config.load_yaml( - f""" -globals: - dd_version: 4.0.0 - imports: - a: "{a}" - b: "{b}" -Machine: - equilibrium/vacuum_toroidal_field/*: - - {{ref: b}} - equilibrium/*: - - {{ref: a}} -""" - ) - times = np.array([0.0, 1.0, 2.0]) - eq = ConfigurationExporter(config, times).to_ids_dict()["equilibrium"] - - # r0 from the more specific 'b' subtree, ip from the broad 'a' whole-IDS import: - assert eq.vacuum_toroidal_field.r0 == 9.9 - assert np.allclose( - [eq.time_slice[t].global_quantities.ip for t in range(3)], [0.0, 10.0, 20.0] - ) + assert "core_profiles" not in idss # dropped, not passed through + assert "equilibrium" in idss + assert "core_profiles" in caplog.text + assert "no waveforms" in caplog.text diff --git a/tests/test_muscle3.py b/tests/test_muscle3.py index 7adcad46..0e5f5dae 100644 --- a/tests/test_muscle3.py +++ b/tests/test_muscle3.py @@ -8,7 +8,7 @@ # This cannot be imported if libmuscle is not available from waveform_editor.muscle3 import ( # noqa: E402 - _time_base_and_received_idss, + _time_base_and_base_ids, waveform_actor, ) @@ -106,17 +106,13 @@ def test_muscle3(tmp_path, monkeypatch): # --- whole-trace mode: an '_in' port carrying an IDS -> overlay on its /time ---- TRACE_YAML = """ -globals: - dd_version: 4.0.0 - imports: - eq_in: {port: equilibrium_in} equilibrium: - equilibrium/*: - - {ref: eq_in} equilibrium/time_slice/global_quantities/ip: - {to: 8.33e5, duration: 20} - {type: constant, duration: 20} - {duration: 25, to: 0} +globals: + dd_version: 4.0.0 """ # Same waveform as the per-slice test, but now interpolated onto a whole trace at once: TRACE_TIMES = [1.0, 21.0, 50.0] @@ -196,124 +192,7 @@ def test_muscle3_whole_trace(tmp_path, monkeypatch): libmuscle.runner.run_simulation(configuration, implementations) -# --- multiple F_INIT ports: the first declared carries the time base, the rest are ---- -# --- port-imports only, resampled onto it (even with a different number of slices) ---- - -MULTI_PORT_YAML = """ -globals: - dd_version: 4.0.0 - imports: - eq_in: {port: equilibrium_in} - cp_in: {port: core_profiles_in} -equilibrium: - equilibrium/*: - - {ref: eq_in} -core_profiles: - core_profiles/*: - - {ref: cp_in} -""" -EQ_TIMES = [1.0, 21.0, 50.0] -CP_TIMES = [0.0, 50.0] # fewer, different slices than EQ_TIMES -- must be resampled -CP_IP = [1e6, 2e6] - -MULTI_PORT_YMMSL = """ -ymmsl_version: v0.1 - -model: - name: test_waveform_actor_multi_port - - components: - eq_generator: - implementation: eq_generator - cp_generator: - implementation: cp_generator - waveform_actor: - implementation: waveform_actor - multi_port_validator: - implementation: multi_port_validator - - conduits: - eq_generator.output: waveform_actor.equilibrium_in - cp_generator.output: waveform_actor.core_profiles_in - waveform_actor.equilibrium_out: multi_port_validator.equilibrium_in - waveform_actor.core_profiles_out: multi_port_validator.core_profiles_in - -settings: - waveform_actor.waveforms: {waveform_yaml} -""" - - -def eq_generator(): - instance = libmuscle.Instance({ymmsl.Operator.O_I: ["output"]}) - - while instance.reuse_instance(): - eq = imas.IDSFactory("4.0.0").equilibrium() - eq.ids_properties.homogeneous_time = imas.ids_defs.IDS_TIME_MODE_HOMOGENEOUS - eq.time = EQ_TIMES - eq.time_slice.resize(len(EQ_TIMES)) - for ts in eq.time_slice: - ts.boundary.outline.r = BOUNDARY_R - instance.send("output", libmuscle.Message(EQ_TIMES[0], data=eq.serialize())) - - -def cp_generator(): - instance = libmuscle.Instance({ymmsl.Operator.O_I: ["output"]}) - - while instance.reuse_instance(): - cp = imas.IDSFactory("4.0.0").core_profiles() - cp.ids_properties.homogeneous_time = imas.ids_defs.IDS_TIME_MODE_HOMOGENEOUS - cp.time = CP_TIMES - cp.global_quantities.ip = CP_IP - instance.send("output", libmuscle.Message(CP_TIMES[0], data=cp.serialize())) - - -def multi_port_validator(): - instance = libmuscle.Instance( - { - ymmsl.Operator.F_INIT: ["equilibrium_in", "core_profiles_in"], - } - ) - - i = 0 - while instance.reuse_instance(): - eq_msg = instance.receive("equilibrium_in") - cp_msg = instance.receive("core_profiles_in") - - eq = imas.IDSFactory("4.0.0").equilibrium() - eq.deserialize(eq_msg.data) - # The first-declared port (equilibrium_in) drives the time base and its - # non-overlaid data (the boundary) survives untouched. - assert np.array_equal(eq.time, EQ_TIMES) - for ts in eq.time_slice: - assert np.array_equal(ts.boundary.outline.r, BOUNDARY_R) - - cp = imas.IDSFactory("4.0.0").core_profiles() - cp.deserialize(cp_msg.data) - # core_profiles is a secondary port-import: it is resampled onto the - # equilibrium time base rather than kept on its own (shorter) time array. - assert np.array_equal(cp.time, EQ_TIMES) - assert np.allclose(cp.global_quantities.ip, [CP_IP[0], CP_IP[0], CP_IP[1]]) - - i += 1 - assert i == 1 - - -@pytest.mark.filterwarnings("ignore:.*use of fork():DeprecationWarning") -def test_muscle3_multiple_finit_ports(tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - waveform_yaml = (tmp_path / "multi_port.yml").resolve() - waveform_yaml.write_text(MULTI_PORT_YAML) - configuration = ymmsl.load(MULTI_PORT_YMMSL.format(waveform_yaml=waveform_yaml)) - implementations = { - "eq_generator": eq_generator, - "cp_generator": cp_generator, - "waveform_actor": waveform_actor, - "multi_port_validator": multi_port_validator, - } - libmuscle.runner.run_simulation(configuration, implementations) - - -# --- the input port carrying an IDS becomes a port-import ----------------------------- +# --- overlay-mode validation of the incoming base IDS --------------------------------- class _Msg: @@ -332,34 +211,33 @@ def _eq_msg(homogeneous_time, time): return _Msg(eq.serialize()) -def test_received_non_homogeneous_warns(caplog): - """A non-homogeneous IDS is still exposed as a port-import but warns; the export - times are taken from its /time. The IDS is keyed by the input port name.""" +def test_overlay_non_homogeneous_warns(caplog): + """A non-homogeneous base is overlaid but warns; INFO names the selected mode.""" msg = _eq_msg(imas.ids_defs.IDS_TIME_MODE_HETEROGENEOUS, TRACE_TIMES) with caplog.at_level("INFO"): - times, received = _time_base_and_received_idss(msg, "equilibrium_in", "4.0.0") + times, base_idss = _time_base_and_base_ids(msg, "equilibrium_in", "4.0.0") assert np.array_equal(times, TRACE_TIMES) - assert set(received) == {"equilibrium_in"} + assert set(base_idss) == {"equilibrium"} + assert "overlay mode" in caplog.text assert "homogeneous time mode" in caplog.text -def test_received_homogeneous_does_not_warn(caplog): +def test_overlay_homogeneous_does_not_warn(caplog): msg = _eq_msg(imas.ids_defs.IDS_TIME_MODE_HOMOGENEOUS, TRACE_TIMES) with caplog.at_level("WARNING"): - _time_base_and_received_idss(msg, "equilibrium_in", "4.0.0") + _time_base_and_base_ids(msg, "equilibrium_in", "4.0.0") assert "homogeneous time mode" not in caplog.text -def test_received_missing_time_raises(): +def test_overlay_missing_time_raises(): msg = _eq_msg(imas.ids_defs.IDS_TIME_MODE_HOMOGENEOUS, None) with pytest.raises(RuntimeError, match="no root '/time'"): - _time_base_and_received_idss(msg, "equilibrium_in", "4.0.0") + _time_base_and_base_ids(msg, "equilibrium_in", "4.0.0") def test_fresh_export_mode(caplog): """A port whose name is not a valid IDS selects fresh-export mode.""" with caplog.at_level("INFO"): - msg = _Msg(None, 3.0) - times, received = _time_base_and_received_idss(msg, "input", "4.0.0") - assert np.array_equal(times, [3.0]) and received == {} + times, base_idss = _time_base_and_base_ids(_Msg(None, 3.0), "input", "4.0.0") + assert np.array_equal(times, [3.0]) and base_idss == {} assert "fresh-export mode" in caplog.text diff --git a/tests/test_waveform.py b/tests/test_waveform.py index 1d38ebad..6735d8a3 100644 --- a/tests/test_waveform.py +++ b/tests/test_waveform.py @@ -4,7 +4,6 @@ from waveform_editor.tendencies.constant import ConstantTendency from waveform_editor.tendencies.linear import LinearTendency from waveform_editor.tendencies.periodic.sine_wave import SineWaveTendency -from waveform_editor.tendencies.piecewise import PiecewiseLinearTendency from waveform_editor.tendencies.smooth import SmoothTendency from waveform_editor.waveform import Waveform @@ -72,52 +71,6 @@ def test_tendencies(waveform): assert isinstance(waveform.tendencies[3], SmoothTendency) -@pytest.mark.parametrize( - "entry, expected", - [ - ({"user_to": 8, "user_duration": 5}, LinearTendency), - ({"user_time": [0, 1, 2], "user_value": [1, 2, 3]}, PiecewiseLinearTendency), - ({"user_value": 4, "user_duration": 2}, ConstantTendency), - # Linear does not require `to`; its other forms fall back to linear: - ({"user_from": 3, "user_duration": 1}, LinearTendency), - ({"user_rate": 2, "user_duration": 1}, LinearTendency), - ({"user_duration": 1}, LinearTendency), # bare -> linear (inferred from peers) - ], -) -def test_infer_tendency_type(entry, expected): - """When `type` is omitted, the tendency type is inferred from the entry's keys: - `to` -> linear, `time` -> piecewise, `value` -> constant, else linear. Linear does - not require `to` -- a `from`/`rate`/bare segment falls back to linear and takes its - endpoints from its neighbours.""" - waveform = Waveform(waveform=[entry]) - assert isinstance(waveform.tendencies[0], expected) - assert not waveform.annotations # inference produced no errors - - -def test_infer_value_less_segment_is_linear(): - """A value-less segment between two constants is ambiguous from its keys alone - (a linear ramp vs. a constant holding the previous value); inference resolves it to - a linear ramp, so a value-less constant must set `type: constant` explicitly.""" - waveform = Waveform( - waveform=[ - {"user_value": 3, "user_duration": 1}, - {"user_duration": 1}, - {"user_value": 10, "user_duration": 1}, - ] - ) - assert isinstance(waveform.tendencies[1], LinearTendency) - assert not waveform.annotations - - -def test_explicit_type_overrides_inference(): - """An explicit `type` is honoured even when the keys would infer another type - (here `to` would otherwise infer linear, but `smooth` is requested).""" - waveform = Waveform( - waveform=[{"user_type": "smooth", "user_from": 0, "user_to": 5, "duration": 2}] - ) - assert isinstance(waveform.tendencies[0], SmoothTendency) - - def test_get_value(waveform): """Test if get_value returns the correct values.""" times = np.linspace(0, 14, 15) diff --git a/tests/test_yaml/example.yaml b/tests/test_yaml/example.yaml index 4980a1ab..39719ccd 100644 --- a/tests/test_yaml/example.yaml +++ b/tests/test_yaml/example.yaml @@ -1,6 +1,6 @@ globals: dd_version: 4.0.0 - imports: {} + machine_description: {} dummy_waveform: w/1: - {to: 1e5, duration: 100} diff --git a/tests/test_yaml_parser.py b/tests/test_yaml_parser.py index c682e8af..45d0da9a 100644 --- a/tests/test_yaml_parser.py +++ b/tests/test_yaml_parser.py @@ -190,7 +190,7 @@ def test_load_yaml_globals_full(yaml_parser, config): yaml_str = """ globals: dd_version: 3.42.0 - imports: + machine_description: ec_launchers: imas:hdf5?path=test_md equilibrium: imas:hdf5?path=test_md2 """ @@ -198,15 +198,19 @@ def test_load_yaml_globals_full(yaml_parser, config): assert not config.groups assert not config.waveform_map assert config.globals.dd_version == "3.42.0" - assert config.globals.imports["ec_launchers"] == "imas:hdf5?path=test_md" - assert config.globals.imports["equilibrium"] == "imas:hdf5?path=test_md2" + assert ( + config.globals.machine_description["ec_launchers"] == "imas:hdf5?path=test_md" + ) + assert ( + config.globals.machine_description["equilibrium"] == "imas:hdf5?path=test_md2" + ) assert not config.load_error def test_load_yaml_globals_missing_dd_version(yaml_parser, config): yaml_str = """ globals: - imports: + machine_description: ec_launchers: imas:hdf5?path=test_md equilibrium: imas:hdf5?path=test_md2 """ @@ -214,15 +218,19 @@ def test_load_yaml_globals_missing_dd_version(yaml_parser, config): assert not config.groups assert not config.waveform_map assert config.globals.dd_version == LATEST_DD_VERSION - assert config.globals.imports["ec_launchers"] == "imas:hdf5?path=test_md" - assert config.globals.imports["equilibrium"] == "imas:hdf5?path=test_md2" + assert ( + config.globals.machine_description["ec_launchers"] == "imas:hdf5?path=test_md" + ) + assert ( + config.globals.machine_description["equilibrium"] == "imas:hdf5?path=test_md2" + ) assert not config.load_error -def test_load_yaml_globals_invalid_imports(yaml_parser): +def test_load_yaml_globals_invalid_machine_description(yaml_parser): yaml_str = """ globals: - imports: imas:hdf5?path=test_md + machine_description: imas:hdf5?path=test_md """ with pytest.raises(ValueError): yaml_parser.load_yaml(yaml_str) @@ -237,5 +245,5 @@ def test_load_yaml_globals_dd_version_only(yaml_parser, config): assert not config.groups assert not config.waveform_map assert config.globals.dd_version == "4.0.0" - assert not config.globals.imports + assert not config.globals.machine_description assert not config.load_error diff --git a/waveform_editor/configuration.py b/waveform_editor/configuration.py index 276f8c5c..83c2d4a1 100644 --- a/waveform_editor/configuration.py +++ b/waveform_editor/configuration.py @@ -8,7 +8,6 @@ from waveform_editor.dependency_graph import DependencyGraph from waveform_editor.derived_waveform import DerivedWaveform from waveform_editor.group import WaveformGroup -from waveform_editor.import_resolver import ImportResolver from waveform_editor.yaml.yaml_globals import YamlGlobals from waveform_editor.yaml.yaml_parser import YamlParser @@ -33,9 +32,6 @@ def __init__(self): self.dependency_graph = DependencyGraph() self.start = self.DEFAULT_START self.end = self.DEFAULT_END - # Reads external data for import (``{ref: ...}``) waveforms. Rebuilt lazily; - # invalidated when globals (imports / dd_version) change. - self.import_resolver = None # Trigger has_changed boolean when a global param is changed for param_name in self.globals.param: @@ -43,28 +39,6 @@ def __init__(self): def _set_changed(self, event): self.has_changed = True - # Imports or dd_version may have changed: drop the cached resolver. - self.import_resolver = None - - @property - def imports(self): - """Named external data entries (name -> URI or {port: name}), stored under - globals and consumed by import (``{ref: ...}``) entries in waveforms.""" - return self.globals.imports - - def ensure_resolver(self, received_idss=None): - """Return the import resolver, building it if needed. - - ``received_idss`` (IDSs received on MUSCLE3 ports at run time) is passed by the - exporter for a fresh export; the editor builds a resolver with none, so only URI - imports resolve there. The resolver caches source reads, so it is shared across - all waveforms of one export. - """ - if self.import_resolver is None or received_idss is not None: - self.import_resolver = ImportResolver( - self.imports, self.globals.dd_version, received_idss - ) - return self.import_resolver def __getitem__(self, key): """Retrieves a waveform or group by name/path. diff --git a/waveform_editor/export/exporter.py b/waveform_editor/export/exporter.py index d9244be5..3201ed0e 100644 --- a/waveform_editor/export/exporter.py +++ b/waveform_editor/export/exporter.py @@ -8,21 +8,18 @@ from imas.ids_path import IDSPath from waveform_editor.export.pcssp_exporter import PCSSPExporter -from waveform_editor.ids_fill import fill_nodes, size_arrays -from waveform_editor.import_waveform import ImportWaveform -from waveform_editor.static_waveform import StaticWaveform logger = logging.getLogger(__name__) class ConfigurationExporter: - def __init__(self, config, times, progress=None, received_idss=None): + def __init__(self, config, times, progress=None, base_idss=None): self.config = config self.times = times self.progress = progress - # The resolver reads external data for {ref: ...} imports. received_idss are the - # IDSs received over MUSCLE3 ports at run time (empty for a plain file export). - self.resolver = config.ensure_resolver(received_idss or {}) + # {ids_name: IDS} bases to overlay the waveforms onto in place (preserving their + # other data), taking precedence over the machine description / an empty IDS. + self.base_idss = base_idss or {} self.total_progress = None self.current_progress = None # We assume that all DD times are in seconds @@ -72,102 +69,36 @@ def _generate_idss(self, factory): factory: IDSFactory to use for creating new IDSs """ ids_map = self._get_ids_map() + # An overlay base with no waveforms in the config is never filled nor yielded. + for ids_name in self.base_idss.keys() - ids_map.keys(): + logger.warning( + f"overlay base '{ids_name}' has no waveforms in the config, " + f"so it is not exported." + ) self.total_progress = sum(2 * len(waveforms) for waveforms in ids_map.values()) self.current_progress = 0 for ids_name, waveforms in ids_map.items(): logger.debug(f"Filling {ids_name}...") - ids = factory.new(ids_name) - self._fill_waveforms(ids, waveforms) - # Set the time mode after filling: a whole-IDS import may carry the time - # mode of its (e.g. time-independent) source, which we override. + ids = self._base_ids(ids_name, factory) # TODO: currently only IDSs with homogeneous time mode are supported ids.ids_properties.homogeneous_time = ( imas.ids_defs.IDS_TIME_MODE_HOMOGENEOUS ) ids.time = self.times + self._fill_waveforms(ids, waveforms) yield ids_name, ids - def _get_ids_map(self): - """Constructs a mapping of IDS names to their corresponding waveform objects. - - Returns: - A dictionary mapping IDS names to lists of waveform objects. - """ - ids_map = {} - for name, group in self.config.waveform_map.items(): - waveform = group[name] - # wildcard reference imports (e.g. .../profiles_1d/*) have no single DD node - if "*" not in name and not waveform.metadata: - logger.warning( - f"'{waveform.name}' does not exist in IDS, so it is not exported." - ) - continue - # Here we assume the first word of the waveform to contain the IDS name - ids = waveform.name.split("/")[0] - ids_map.setdefault(ids, []).append(waveform) - return ids_map - - def _fill_waveforms(self, ids, waveforms): - """Populates the given IDS object with waveform data. - - Args: - ids: The IDS to populate with waveform data. - waveforms: A list of waveform objects to be filled into the IDS. - """ - # Structural imports (ImportWaveform) are overlaid first, broadest-first (a - # stable sort keeps file order for equal specificity), so more specific imports - # -- and then the explicit leaf waveforms below -- override them. - imports = sorted( - (w for w in waveforms if isinstance(w, ImportWaveform)), - key=lambda w: w.specificity, - ) - waveforms = [w for w in waveforms if not isinstance(w, ImportWaveform)] - for waveform in imports: - logger.debug(f"Importing {waveform.name}...") - self._overlay_import(ids, waveform) - self._increment_progress() - - self._fill_explicit(ids, waveforms) - - def _overlay_import(self, ids, waveform): - """Overlay an ImportWaveform's source(s) onto ``ids``, in listed order. Each - spec's ``path`` overrides the source path; the destination is the waveform's - own path.""" - for spec in waveform.specs: - self.resolver.fill_import( - ids, - ref=spec.ref, - src_path=spec.path or waveform.name, - dst_path=waveform.name, - time=self.times, - time_offset=spec.time_offset, - interp=spec.interp, - ) - - def _fill_explicit(self, ids, waveforms): - """Fill the explicit (analytic / scalar-import / static) waveforms into ``ids``. - - Size every array of structure to the largest size any waveform needs first, then - fill, so a ``:`` slice expands against the final sizes regardless of the order - the waveforms appear in. - """ - targets = [] - for waveform in waveforms: - path = IDSPath("/".join(waveform.name.split("/")[1:])) - if isinstance(waveform, StaticWaveform): - values = waveform.value # a bare constant (e.g. an identifier name) - else: - # Scalar imports and analytic+import composites resolve themselves. - _, values = waveform.get_value(self.times) - targets.append((path, values)) - self._increment_progress() - - size_arrays(ids, [path for path, _ in targets], len(self.times)) - - # Fill in declaration order, so a later waveform wins at a shared leaf. - for path, values in targets: - fill_nodes(ids, path, values) - self._increment_progress() + def _base_ids(self, ids_name, factory): + """The IDS to fill the waveforms onto: a caller-provided base, else the machine + description, else a new empty IDS.""" + if ids_name in self.base_idss: + return self.base_idss[ids_name] + md = self.config.globals.machine_description.get(ids_name) + if md: + with imas.DBEntry(md, "r") as entry_md: + orig_ids = entry_md.get(ids_name, autoconvert=False) + return imas.convert_ids(orig_ids, self.config.globals.dd_version) + return factory.new(ids_name) def to_png(self, dir_path): """Export the waveforms to PNGs. @@ -225,9 +156,123 @@ def to_csv(self, file_path): df.to_csv(file_path, index=False) logger.info(f"Successfully exported waveform configuration to {file_path}.") + def _get_ids_map(self): + """Constructs a mapping of IDS names to their corresponding waveform objects. + + Returns: + A dictionary mapping IDS names to lists of waveform objects. + """ + ids_map = {} + for name, group in self.config.waveform_map.items(): + waveform = group[name] + if not waveform.metadata: + logger.warning( + f"'{waveform.name}' does not exist in IDS, so it is not exported." + ) + continue + split_path = waveform.name.split("/") + # Here we assume the first word of the waveform to contain the IDS name + ids = split_path[0] + ids_map.setdefault(ids, []).append(waveform) + return ids_map + + def _fill_waveforms(self, ids, waveforms): + """Populates the given IDS object with waveform data. + + Args: + ids: The IDS to populate with waveform data. + waveforms: A list of waveform objects to be filled into the IDS. + """ + # Ensure get_value is only called once per waveform + values_per_waveform = [] + + # We iterate through the waveforms in reverse order because they are typically + # ordered with increasing indices. By processing them in reverse, we avoid + # unnecessary repeated resizing. + for waveform in reversed(waveforms): + logger.debug(f"Filling {waveform.name}...") + path = IDSPath("/".join(waveform.name.split("/")[1:])) + _, values = waveform.get_value(self.times) + values_per_waveform.append((path, values)) + self._fill_nodes_recursively(ids, path, values, fill=False) + self._increment_progress() + + # NOTE: We perform two passes: + # - The first pass (above) resizes the necessary nodes without filling values. + # - The second pass (below) actually fills the nodes with their values. + # + # This two-pass process ensures correct handling of the following example, where + # 'beam(:)/phase/angle' is processed before 'beam(4)/power_launched/data'. + # Here, phase/angle should be filled for all 4 beams. + # However, certain niche cases involving multiple slices for different waveforms + # might still not be handled correctly. + for waveform, (path, values) in zip( + waveforms, values_per_waveform, strict=True + ): + logger.debug(f"Filling {waveform.name}...") + self._fill_nodes_recursively(ids, path, values) + self._increment_progress() + def _increment_progress(self): """Increment the progress bar""" if self.progress: self.current_progress += 1 # Maximum is is 90%, the last 10% must be set after exporting self.progress.value = int(90 * self.current_progress / self.total_progress) + + def _fill_nodes_recursively(self, node, path, values, path_index=0, fill=True): + """Recursively fills nodes in the IDS based on the provided path and values. + + Args: + node: The current IDS node. + path: The path to the node, as an IDSPath object. + values: The values to fill into the IDS node. + path_index: The current index of the path we are processing. + fill: Whether to fill the node with values. + """ + if path_index == len(path.parts): + if fill: + node.value = values + return + part = path.parts[path_index] + index = path.indices[path_index] + + node = node[part] + next_index = path_index + 1 + if index is None: + if node.metadata.type.is_dynamic and part != path.parts[-1]: + if len(node) != len(values): + node.resize(len(values), keep=True) + for item, value in zip(node, values, strict=True): + self._fill_nodes_recursively(item, path, value, next_index) + else: + self._fill_nodes_recursively(node, path, values, next_index) + elif isinstance(index, slice): + start, stop = self._resize_slice(node, index) + for i in range(start, stop): + self._fill_nodes_recursively(node[i], path, values, next_index) + else: + if len(node) <= index: + node.resize(index + 1, keep=True) + self._fill_nodes_recursively(node[index], path, values, next_index) + + def _resize_slice(self, ids_node, slice): + """Resizes slice and returns the start/stop values of the slice + + Args: + ids_node: The current IDS node to slice. + slice: The slice for the IDS node. + + Returns: + Tuple containing the start and stop values of the slice. + """ + if slice.start is None and slice.stop is None: + start = 0 + stop = len(ids_node) or 1 + else: + start = slice.start if slice.start is not None else 0 + stop = slice.stop if slice.stop is not None else len(ids_node) or start + 1 + max_index = max(start, stop - 1) + if len(ids_node) <= max_index: + ids_node.resize(max_index + 1, keep=True) + return start, stop diff --git a/waveform_editor/gui/dict_editor.py b/waveform_editor/gui/dict_editor.py index 6ee8ba57..d64f0b0f 100644 --- a/waveform_editor/gui/dict_editor.py +++ b/waveform_editor/gui/dict_editor.py @@ -15,21 +15,15 @@ class DictEditor(pn.widgets.CompositeWidget): _composite_type = pn.Column - def __init__(self, key_options=None, names=("Key", "Value"), **params): + def __init__(self, key_options, names, **params): """Initialize the DictEditor widget. Args: - key_options: List of allowed keys shown in a dropdown editor, or None to - allow free-text keys. + key_options: List of allowed keys shown in the dropdown editor. names: Tuple of column names to display for keys and values. """ - key_editor = ( - {"type": "list", "values": key_options} - if key_options is not None - else {"type": "input"} - ) self.tabulator = pn.widgets.Tabulator( - editors={"key": key_editor, "delete": None}, + editors={"key": {"type": "list", "values": key_options}, "delete": None}, titles={"delete": "🗑️", "key": names[0], "value": names[1]}, layout="fit_data_stretch", sizing_mode="stretch_width", diff --git a/waveform_editor/gui/editor.py b/waveform_editor/gui/editor.py index 97ab4624..75c7083d 100644 --- a/waveform_editor/gui/editor.py +++ b/waveform_editor/gui/editor.py @@ -5,8 +5,6 @@ from panel.viewable import Viewer from waveform_editor.derived_waveform import DerivedWaveform -from waveform_editor.import_waveform import ImportWaveform -from waveform_editor.static_waveform import StaticWaveform from waveform_editor.waveform import Waveform @@ -14,7 +12,7 @@ class WaveformEditor(Viewer): """A Panel interface for waveform editing.""" waveform = param.ClassSelector( - class_=(Waveform, DerivedWaveform, ImportWaveform, StaticWaveform), + class_=(Waveform, DerivedWaveform), doc="Waveform currently being edited. Use `set_waveform` to change.", ) stored_string = param.String( diff --git a/waveform_editor/gui/main.py b/waveform_editor/gui/main.py index 3672b674..e0052905 100644 --- a/waveform_editor/gui/main.py +++ b/waveform_editor/gui/main.py @@ -1,5 +1,6 @@ import logging +import imas import panel as pn import param @@ -19,7 +20,7 @@ WAVEFORM_EDITOR_PAGE, WaveformContent, ) -from waveform_editor.util import State +from waveform_editor.util import LATEST_DD_VERSION, State logger = logging.getLogger(__name__) @@ -74,17 +75,10 @@ def __init__(self): self.config.globals.param, show_name=False, widgets={ - "imports": { + "machine_description": { "widget_type": DictEditor, - "key_options": None, - "names": ("Name", "URI"), - "description": ( - "Named external data entries to import from. Each value is an " - "IMAS URI, or {port: <name>} for an IDS " - "received on a MUSCLE3 port at run time. Reference one from a " - "waveform with {ref: <name>}; a waveform " - "named * overlays the whole entry." - ), + "key_options": imas.IDSFactory(LATEST_DD_VERSION).ids_names(), + "names": ("IDS", "URI"), } }, ) diff --git a/waveform_editor/gui/plotter_edit.py b/waveform_editor/gui/plotter_edit.py index 77f9ada9..1b38bbf9 100644 --- a/waveform_editor/gui/plotter_edit.py +++ b/waveform_editor/gui/plotter_edit.py @@ -9,8 +9,6 @@ from ruamel.yaml import YAML from waveform_editor.derived_waveform import DerivedWaveform -from waveform_editor.import_waveform import ImportWaveform -from waveform_editor.static_waveform import StaticWaveform from waveform_editor.tendencies.piecewise import PiecewiseLinearTendency from waveform_editor.util import State from waveform_editor.waveform import Waveform @@ -20,8 +18,7 @@ class PlotterEdit(Viewer): """Class to plot a single waveform in edit mode.""" plotted_waveform: Waveform = param.ClassSelector( - class_=(Waveform, DerivedWaveform, ImportWaveform, StaticWaveform), - allow_refs=True, + class_=(Waveform, DerivedWaveform), allow_refs=True ) def __init__(self, editor, **params): diff --git a/waveform_editor/ids_fill.py b/waveform_editor/ids_fill.py deleted file mode 100644 index c9ccdeb0..00000000 --- a/waveform_editor/ids_fill.py +++ /dev/null @@ -1,131 +0,0 @@ -"""Helpers for writing values into an IDS at a given DD path. - -Filling is two steps: :func:`size_arrays` grows every array of structure to the largest -size any waveform requires (so a ``:`` slice later expands against the final size, and -each array is resized once regardless of declaration order), then :func:`fill_nodes` -writes each waveform's values. The resolver reuses :func:`fill_nodes` for structural -imports, which size themselves as they copy. -""" - -import numpy as np - - -def size_arrays(node, paths, time_len, path_index=0): - """Resize every array of structure crossed by ``paths`` to the size it needs. - - The required size of an array is the maximum over all ``paths`` of: an explicit - index + 1, a bounded slice's stop, or ``time_len`` for a dynamic (time-dependent) - array addressed without an index. This is computed before resizing, so the result - does not depend on the order of ``paths`` and each array is grown exactly once. A - bare ``:`` and arrays with no size source contribute nothing and are grown at fill - time instead. - """ - groups = {} - for path in paths: - if path_index < len(path.parts): - groups.setdefault(path.parts[path_index], []).append(path) - - for part, group in groups.items(): - child = node[part] - nxt = path_index + 1 - - # Decide whether `part` is an array of structure here, and its required size. - is_array = False - required = 0 - for path in group: - index = path.indices[path_index] - if isinstance(index, int): - is_array = True - required = max(required, index + 1) - elif isinstance(index, slice): - is_array = True - if index.stop is not None: - required = max(required, index.stop) - elif child.metadata.type.is_dynamic and nxt < len(path.parts): - is_array = True # dynamic AoS addressed without an index: one per slice - required = max(required, time_len) - - if not is_array: - # Plain structure (or terminal leaf): descend in place. - size_arrays(child, group, time_len, nxt) - continue - - if required > len(child): - child.resize(required, keep=True) - - # Recurse into the elements each path covers, now that the array is sized. - per_element = {} - for path in group: - if nxt >= len(path.parts): - continue - index = path.indices[path_index] - if isinstance(index, int): - covered = (index,) - elif isinstance(index, slice): - stop = index.stop if index.stop is not None else len(child) - covered = range(index.start or 0, stop) - else: # dynamic AoS addressed without an index: every element - covered = range(len(child)) - for i in covered: - if i < len(child): - per_element.setdefault(i, []).append(path) - for i, element_paths in per_element.items(): - size_arrays(child[i], element_paths, time_len, nxt) - - -def fill_nodes(node, path, values, path_index=0): - """Write ``values`` at ``path`` in ``node``, growing arrays of structure crossed on - the way to fit (so it is correct without a prior :func:`size_arrays` pass too).""" - if path_index == len(path.parts): - if ( - not node.metadata.type.is_dynamic - and isinstance(values, np.ndarray) - and values.ndim > 0 - ): - # A static (time-independent) destination leaf can still receive an - # explicit scalar import: ImportResolver.sample() broadcasts a static - # source across the full export time base so the analytic-waveform - # machinery can treat every import uniformly as a per-time array. All - # elements are identical for a genuinely static source, so collapse back - # to a single value rather than assigning a multi-element array to a 0D - # leaf (which imas-python's scalar cast rejects). Restricted to ndarray - # (as returned by ImportResolver.sample()/raw()) so a StaticWaveform's - # bare constant (e.g. a string name) is never indexed into. - values = values[0] - node.value = values - return - part = path.parts[path_index] - index = path.indices[path_index] - - node = node[part] - next_index = path_index + 1 - if index is None: - if node.metadata.type.is_dynamic and part != path.parts[-1]: - if len(node) != len(values): - node.resize(len(values), keep=True) - for item, value in zip(node, values, strict=True): - fill_nodes(item, path, value, next_index) - else: - fill_nodes(node, path, values, next_index) - elif isinstance(index, slice): - start, stop = resize_slice(node, index) - for i in range(start, stop): - fill_nodes(node[i], path, values, next_index) - else: - if len(node) <= index: - node.resize(index + 1, keep=True) - fill_nodes(node[index], path, values, next_index) - - -def resize_slice(ids_node, slice_): - """Resize ``ids_node`` to cover ``slice_`` and return its (start, stop).""" - if slice_.start is None and slice_.stop is None: - start = 0 - stop = len(ids_node) or 1 - else: - start = slice_.start if slice_.start is not None else 0 - stop = slice_.stop if slice_.stop is not None else len(ids_node) or start + 1 - max_index = max(start, stop - 1) - if len(ids_node) <= max_index: - ids_node.resize(max_index + 1, keep=True) - return start, stop diff --git a/waveform_editor/import_resolver.py b/waveform_editor/import_resolver.py deleted file mode 100644 index b4a5ea17..00000000 --- a/waveform_editor/import_resolver.py +++ /dev/null @@ -1,308 +0,0 @@ -import logging -import re -from contextlib import contextmanager - -import imas -import numpy as np -from imas.ids_path import IDSPath -from imas.util import get_full_path, tree_iter - -from waveform_editor.ids_fill import fill_nodes, resize_slice - -logger = logging.getLogger(__name__) - -# User-facing interpolation modes -> IMAS interpolation constants. -INTERP_MODES = ("closest", "linear", "previous") - - -def _interp_const(mode): - """Map a user interpolation mode name to an IMAS interpolation constant.""" - return { - "closest": imas.ids_defs.CLOSEST_INTERP, - "linear": imas.ids_defs.LINEAR_INTERP, - "previous": imas.ids_defs.PREVIOUS_INTERP, - }[mode] - - -class ImportResolver: - """Reads named external data entries (``globals.imports``) from IMAS. - - This is the single place that opens external entries / MUSCLE3 port IDSs and turns - them into values: the raw source samples (for editing/plotting) and values resampled - onto a requested export time base (for export). The waveforms ask the resolver for - their values; the exporter only writes the result into the target IDS. - - Each import name maps to an IMAS URI string, or a ``{port: }`` referring to an - IDS received on a MUSCLE3 port at run time (``received_idss``). Sources are read in - full once and cached, then resampled in memory so any backend can be sliced - (netCDF does not support get_slice directly). - """ - - def __init__(self, imports, dd_version, received_idss=None): - self.imports = imports or {} - self.dd_version = dd_version - # {port_name: IDS} received over MUSCLE3 ports at run time. - self.received_idss = received_idss or {} - # full source IDSs, keyed by (source_key, ids_name) - self._full_cache = {} - - # -- source resolution ---------------------------------------------------- - - def _source(self, ref): - """The import spec (URI string or ``{port: ...}``) for import name ``ref``.""" - if ref not in self.imports: - raise KeyError(f"unknown import '{ref}'") - return self.imports[ref] - - @staticmethod - def _port_of(source): - """The MUSCLE3 port name if ``source`` is a port-import, else None. A - port-import is ``{port: }`` or the ``port:`` string shorthand.""" - if isinstance(source, dict) and "port" in source: - return source["port"] - if isinstance(source, str) and source.startswith("port:"): - return source[len("port:") :] - return None - - def _source_key(self, source): - port = self._port_of(source) - return ("port", port) if port is not None else source - - @contextmanager - def _open(self, source): - """Yield a DBEntry to read an import source from. ``source`` is an IMAS URI - string, or a port-import referring to an IDS received over a MUSCLE3 port - (loaded into an in-memory entry so it can be sliced like any other).""" - dd = self.dd_version - port = self._port_of(source) - if port is not None: - ids = self.received_idss.get(port) - if ids is None: - raise KeyError(f"no IDS received on import port '{port}'") - with imas.DBEntry("imas:memory?path=/", "w", dd_version=dd) as mem: - mem.put(imas.convert_ids(ids, dd)) - yield mem - else: - with imas.DBEntry(source, "r", dd_version=dd) as ext: - yield ext - - def _full(self, ids_name, source): - """The full source IDS, read once and cached.""" - key = (self._source_key(source), ids_name) - if key not in self._full_cache: - with self._open(source) as ext: - self._full_cache[key] = ext.get(ids_name) - return self._full_cache[key] - - @staticmethod - def _is_homogeneous(ids): - return int(ids.ids_properties.homogeneous_time) == ( - imas.ids_defs.IDS_TIME_MODE_HOMOGENEOUS - ) - - def _resampled(self, ids_name, source, time, time_offset, interp): - """The full source IDS resampled onto ``time`` (shifted by ``time_offset``). - - A time-independent source (e.g. a machine description) has no time dimension and - is returned as-is. The source is hosted in an in-memory entry so it can be - resampled slice-by-slice via get_slice regardless of its backend. - """ - full = self._full(ids_name, source) - if not self._is_homogeneous(full): - return full - dd = self.dd_version - interp_const = _interp_const(interp) - with imas.DBEntry("imas:memory?path=/src", "w", dd_version=dd) as src: - src.put(full) - slices = [ - src.get_slice(ids_name, float(t) + time_offset, interp_const) - for t in time - ] - with imas.DBEntry("imas:memory?path=/dst", "w", dd_version=dd) as dst: - for sl in slices: - dst.put_slice(sl) - return dst.get(ids_name) - - # -- public read API ------------------------------------------------------ - - def raw(self, ref, ids_path, *, time_offset=0.0): - """The source's own (time, values) at ``ids_path``, without resampling. - - Used for editing/plotting: the raw samples are shown on the source's native time - base (mapped into waveform time by subtracting ``time_offset``). Returns empty - arrays for a time-independent source, which has no curve to draw. - """ - source = self._source(ref) - ids_name, sub = ids_path.split("/", 1) - full = self._full(ids_name, source) - times = np.asarray(full.time, dtype=float) - if times.size == 0: - return np.array([]), np.array([]) - values = np.asarray(self.extract_values(full, IDSPath(sub)), dtype=float) - if values.shape != times.shape: - return np.array([]), np.array([]) - return times - time_offset, values - - def sample(self, ref, ids_path, time, *, time_offset=0.0, interp="closest"): - """Values at ``ids_path`` from import ``ref``, resampled onto ``time``. - - Scalar quantities only; a static (time-independent) value is broadcast across - ``time``. Non-0D quantities are handled structurally via :meth:`fill_import`. - """ - source = self._source(ref) - ids_name, sub = ids_path.split("/", 1) - resampled = self._resampled(ids_name, source, time, time_offset, interp) - values = np.asarray(self.extract_values(resampled, IDSPath(sub)), dtype=float) - if values.ndim == 0: - values = np.full(len(time), float(values)) - return values - - def fill_import(self, ids, *, ref, src_path, dst_path, time, time_offset, interp): - """Copy a (resampled) non-0D / wildcard import from ``ref`` into ``ids``. - - Index wildcards (``source(*)/...``) are expanded against the source, iterating - over every element of that array of structure; a trailing ``/*`` mirror-copies a - whole subtree; everything else copies a single node. - """ - source = self._source(ref) - src_ids, src_sub = src_path.split("/", 1) - _, dst_sub = dst_path.split("/", 1) - resampled = self._resampled(src_ids, source, time, time_offset, interp) - for csrc, cdst, is_subtree in self.expand_index_wildcards( - resampled, src_sub, dst_sub - ): - if is_subtree: - prefix = csrc.split("*", 1)[0].rstrip("/") - subtree = self.navigate(resampled, IDSPath(prefix)) - for leaf in tree_iter(subtree, leaf_only=True, visit_empty=False): - self._mirror_leaf(resampled, ids, get_full_path(leaf)) - else: - values = self.extract_values(resampled, IDSPath(csrc)) - fill_nodes(ids, IDSPath(cdst), values) - - # -- path / value helpers (read side) ------------------------------------- - - def expand_index_wildcards(self, root, src_sub, dst_sub): - """Expand every ``(*)`` index wildcard against the resampled source. - - Yields ``(concrete_src_sub, concrete_dst_sub, is_subtree)`` tuples: each ``(*)`` - is replaced (one at a time, recursively) by the 1-based index of every element - of that array of structure in the source, a trailing ``/*`` is flagged as a - whole-subtree mirror, and other index specs (``2``, ``2:3``, ``:``) pass through - unchanged. The concrete paths are read/filled via IDSPath, which owns indexing. - """ - src_parts = src_sub.split("/") - dst_parts = dst_sub.split("/") - - star = next( - ( - i - for i, s in enumerate(src_parts) - if s != "*" and self._parse_segment(s)[1] == "*" - ), - None, - ) - if star is None: - yield src_sub, dst_sub, src_parts[-1] == "*" # trailing /* = subtree mirror - return - - name = self._parse_segment(src_parts[star])[0] - for k in range(1, self._count_aos(root, src_parts[:star], name) + 1): - new_src = src_parts.copy() - new_dst = dst_parts.copy() - new_src[star] = f"{name}({k})" - if star < len(new_dst): - new_dst[star] = f"{name}({k})" - yield from self.expand_index_wildcards( - root, "/".join(new_src), "/".join(new_dst) - ) - - def _count_aos(self, root, prefix_parts, name): - """The number of elements of the ``name`` array of structure in the source, - reached by walking ``prefix_parts``. Explicit 1-based indices are honoured; an - array of structure with no explicit index is descended into for every element. - - If ``name`` sits under a time-dependent array of structure, its element count - must be the same in every slice -- a ``(*)`` wildcard cannot expand to a count - that varies in time. A varying count raises rather than silently mis-populating. - """ - counts = set() - - def walk(node, parts): - if not parts: - counts.add(len(node[name])) - return - seg_name, idx = self._parse_segment(parts[0]) - node = node[seg_name] - if idx is not None and idx != ":" and ":" not in idx: - walk(node[int(idx) - 1], parts[1:]) # explicit 1-based index - elif hasattr(node, "resize"): # array of structure: visit every element - for element in node: - walk(element, parts[1:]) - else: # plain structure - walk(node, parts[1:]) - - walk(root, prefix_parts) - if len(counts) > 1: - raise RuntimeError( - f"cannot expand '{name}(*)': its element count varies across a " - f"time-dependent parent (found {sorted(counts)}). An index wildcard " - f"requires a uniform array-of-structure size." - ) - return counts.pop() if counts else 0 - - @staticmethod - def _parse_segment(seg): - """Split a path segment ``name`` or ``name(idx)`` into (name, idx-or-None).""" - match = re.match(r"^([^()]+)(?:\((.*)\))?$", seg) - return match.group(1), match.group(2) - - @staticmethod - def navigate(node, path): - """Walk an IDSPath, applying explicit indices; arrays of structure are kept.""" - for part, index in zip(path.parts, path.indices, strict=True): - node = node[part] - if index is not None: - node = node[index] - return node - - @staticmethod - def _mirror_leaf(src_root, dst_root, full_path): - """Copy one leaf, addressed by its concrete root path (e.g. - ``source[0]/profiles_1d[3]/electrons/energy``), from src_root into dst_root, - creating any intermediate arrays of structure on the way.""" - steps = [ - (m.group(1), int(m.group(2)) if m.group(2) else None) - for m in re.finditer(r"([^/\[\]]+)(?:\[(\d+)\])?", full_path) - ] - src, dst = src_root, dst_root - for name, index in steps[:-1]: - src, dst = src[name], dst[name] - if index is not None: - if len(dst) <= index: - dst.resize(index + 1, keep=True) - src, dst = src[index], dst[index] - leaf_name = steps[-1][0] - dst[leaf_name].value = src[leaf_name].value - - def extract_values(self, node, path, path_index=0): - """Read the values at ``path`` from ``node`` as a per-time list where the path - crosses a dynamic array of structures (mirror of the node-filling logic).""" - if path_index == len(path.parts): - return node.value - part = path.parts[path_index] - index = path.indices[path_index] - node = node[part] - next_index = path_index + 1 - if index is None: - if node.metadata.type.is_dynamic and part != path.parts[-1]: - return [self.extract_values(item, path, next_index) for item in node] - return self.extract_values(node, path, next_index) - elif isinstance(index, slice): - start, stop = resize_slice(node, index) - return [ - self.extract_values(node[i], path, next_index) - for i in range(start, stop) - ] - else: - return self.extract_values(node[index], path, next_index) diff --git a/waveform_editor/import_waveform.py b/waveform_editor/import_waveform.py deleted file mode 100644 index f07d60ed..00000000 --- a/waveform_editor/import_waveform.py +++ /dev/null @@ -1,62 +0,0 @@ -from collections import namedtuple - -import numpy as np - -from waveform_editor.base_waveform import BaseWaveform -from waveform_editor.import_resolver import INTERP_MODES - -# One import within an ImportWaveform: which entry to read (ref) and how. -ImportSpec = namedtuple("ImportSpec", ["ref", "path", "time_offset", "interp"]) - - -def _spec_from_entry(entry): - interp = entry.get("user_interp", "closest") - return ImportSpec( - ref=entry.get("user_ref", ""), - path=entry.get("user_path") or None, - time_offset=entry.get("user_time_offset", 0.0) or 0.0, - interp=interp if interp in INTERP_MODES else "closest", - ) - - -class ImportWaveform(BaseWaveform): - """A waveform whose entire content is imported from external entries. - - Used for imports that cannot be expressed as a single analytic segment: non-0D - quantities (a value per radial point, etc.) and wildcard paths (``.../*``) that - mirror a whole subtree. This class only carries the imports' configuration; the - exporter copies the (resampled) source into the target IDS via the ImportResolver. - - It may carry **several** imports (``[{ref: a}, {ref: b}]``), overlaid in listed - order. A whole-IDS import (``/*``) is an overlay base for that IDS. Overlays - are applied broadest-first (see :meth:`specificity`), so more specific imports win. - """ - - def __init__(self, entries, *, yaml_str="", name="waveform", dd_version=None): - super().__init__(yaml_str, name, dd_version) - self.yaml_str = yaml_str - if isinstance(entries, dict): - entries = [entries] - self.specs = [_spec_from_entry(e) for e in entries] - self.line_number = entries[0].get("line_number", 0) if entries else 0 - - @property - def specificity(self): - """Concrete (pre-wildcard) path length. Broader overlays have a lower value and - are applied first, so more specific imports win; ``*`` (whole entry) is 0.""" - segments = self.name.split("/") - for i, segment in enumerate(segments): - if segment == "*" or segment.endswith("(*)"): - return i - return len(segments) - - def get_value( - self, time: np.ndarray | None = None - ) -> tuple[np.ndarray, np.ndarray]: - # Imported, possibly non-scalar data is not plotted as a simple curve. - if time is None: - time = np.array([]) - return time, np.zeros_like(time, dtype=float) - - def get_yaml_string(self) -> str: - return self.yaml_str diff --git a/waveform_editor/muscle3.py b/waveform_editor/muscle3.py index 15518540..72e072fb 100644 --- a/waveform_editor/muscle3.py +++ b/waveform_editor/muscle3.py @@ -15,53 +15,50 @@ logger = logging.getLogger(__name__) -def _time_base_and_received_idss(msg, input_port, dd_version): - """Resolve one F_INIT port's candidate export time base and received IDS. - - Called once per connected F_INIT port; the caller picks which result (if any) - becomes the actor's actual time base. The port name selects the mode. A port named - ``_in`` (a valid IDS name) carrying that IDS exposes it as a *port-import*: the - IDS is keyed by the port name so a config import ``{port: }`` can read - it, and its ``time`` array is offered as a candidate export time base. Combined - with an ``/*`` import this overlays the waveforms onto the received IDS (e.g. - adding Ip to an equilibrium). Any other port name yields no candidate time base - (the caller falls back to *fresh export*, evaluating at ``msg.timestamp``). +def _time_base_and_base_ids(msg, input_port, dd_version): + """Resolve the export time base and the optional base IDS to overlay onto. + + The input port name selects the mode. A port named ``_in`` (a valid IDS name) + selects *overlay*: the message must carry that IDS, and the waveforms are evaluated + on its ``time`` array and overlaid onto it in place, preserving its other data so it + can be passed on (e.g. adding Ip to an equilibrium). Any other name selects *fresh + export*: the waveforms are evaluated at ``msg.timestamp`` into a single slice. """ name = input_port.removesuffix("_in") factory = imas.IDSFactory(dd_version) - if not factory.exists(name) or msg.data is None: - logger.info("fresh-export mode on '%s'", input_port) + if not factory.exists(name): + logger.info("fresh-export mode on '%s' (not an IDS name)", input_port) return np.array([msg.timestamp]), {} - logger.info("received '%s' on '%s' (available as a port-import)", name, input_port) - received = factory.new(name) - received.deserialize(msg.data) - - # The waveforms are evaluated on '/time' and written back homogeneous, so '/time' is - # not authoritative for a non-homogeneous IDS: warn rather than fail. - if int(received.ids_properties.homogeneous_time) != ( + logger.info("overlay mode on '%s': overlaying onto '%s'", input_port, name) + if msg.data is None: + raise RuntimeError( + f"input port '{input_port}' selects overlay mode, but the message carried " + f"no '{name}' IDS" + ) + base = factory.new(name) + base.deserialize(msg.data) + + # Overlay evaluates the waveforms on '/time' and writes the result back homogeneous, + # so '/time' is not authoritative for a non-homogeneous base: warn rather than fail. + if int(base.ids_properties.homogeneous_time) != ( imas.ids_defs.IDS_TIME_MODE_HOMOGENEOUS ): logger.warning("received '%s' IDS is not in homogeneous time mode", name) - times = np.asarray(received.time, dtype=float) + times = np.asarray(base.time, dtype=float) if times.size == 0: - raise RuntimeError(f"received '{name}' IDS has no root '/time'") - return times, {input_port: received} + raise RuntimeError(f"received '{name}' IDS has no root '/time' to overlay onto") + return times, {name: base} def waveform_actor(): logger.info("Starting waveform actor") # Ports are created by libmuscle from the yMMSL conduits, not named here. - # - One or more input ports, received in their yMMSL declaration order. The first - # one named '_in' whose message carries that IDS selects overlay mode: the - # waveforms are exported on its /time and the result overlaid onto it. Every - # other '_in' port carrying an IDS is a port-import only -- available as - # {ref: } via a `{port: }` globals.imports entry, resampled onto - # the primary time base -- without affecting which port drives the time base. If - # no port selects overlay mode, a single slice is exported at the first message's - # timestamp. + # - Exactly one input port. If named '_in' and the message carries that IDS, + # the waveforms are exported on its /time and overlaid onto it; otherwise a single + # slice at the message timestamp is exported. # - Output port names must be '_out' or ''. instance = Instance(flags=InstanceFlags.KEEPS_NO_STATE_FOR_NEXT_USE) @@ -80,28 +77,15 @@ def waveform_actor(): load_config(config, fname) ports = instance.list_ports() - input_ports = ports.get(Operator.F_INIT, []) - if not input_ports: - raise RuntimeError("At least one F_INIT port must be connected.") - - times = None - received_idss = {} - primary_msg = None - for input_port in input_ports: - msg = instance.receive(input_port) - primary_msg = primary_msg or msg - port_times, port_received = _time_base_and_received_idss( - msg, input_port, config.globals.dd_version - ) - received_idss.update(port_received) - if times is None and port_received: - times, primary_msg = port_times, msg - if times is None: - # No F_INIT port selected overlay mode: fresh export at the first - # message's timestamp. - times = np.array([primary_msg.timestamp]) - - exporter = ConfigurationExporter(config, times, received_idss=received_idss) + if len(ports.get(Operator.F_INIT, [])) != 1: + raise RuntimeError("Exactly one F_INIT port must be connected.") + input_port = ports[Operator.F_INIT][0] + msg = instance.receive(input_port) + + times, base_idss = _time_base_and_base_ids( + msg, input_port, config.globals.dd_version + ) + exporter = ConfigurationExporter(config, times, base_idss=base_idss) idss = exporter.to_ids_dict() for portname in ports[Operator.O_F]: @@ -116,10 +100,7 @@ def waveform_actor(): ) data = idss[idsname].serialize() - instance.send( - portname, - Message(primary_msg.timestamp, primary_msg.next_timestamp, data), - ) + instance.send(portname, Message(msg.timestamp, msg.next_timestamp, data)) if __name__ == "__main__": diff --git a/waveform_editor/static_waveform.py b/waveform_editor/static_waveform.py deleted file mode 100644 index 1626e0c8..00000000 --- a/waveform_editor/static_waveform.py +++ /dev/null @@ -1,28 +0,0 @@ -import numpy as np - -from waveform_editor.base_waveform import BaseWaveform - - -class StaticWaveform(BaseWaveform): - """A waveform that assigns a single static value to a DD node. - - Used for non-numeric or non-time-dependent fields, e.g. a ``{value: ec}`` constant - that names an identifier. The value is written verbatim by the exporter; it is not a - time series and has no analytic tendencies. - """ - - def __init__(self, value, *, yaml_str="", name="waveform", dd_version=None): - super().__init__(yaml_str, name, dd_version) - self.yaml_str = yaml_str - self.value = value - - def get_value( - self, time: np.ndarray | None = None - ) -> tuple[np.ndarray, np.ndarray]: - # A static value is not a time series; nothing to plot. - if time is None: - time = np.array([]) - return time, np.zeros_like(time, dtype=float) - - def get_yaml_string(self) -> str: - return self.yaml_str diff --git a/waveform_editor/tendencies/import_tendency.py b/waveform_editor/tendencies/import_tendency.py deleted file mode 100644 index 46349451..00000000 --- a/waveform_editor/tendencies/import_tendency.py +++ /dev/null @@ -1,89 +0,0 @@ -import numpy as np -import param - -from waveform_editor.import_resolver import INTERP_MODES -from waveform_editor.tendencies.base import BaseTendency - - -class ImportTendency(BaseTendency): - """A waveform segment whose values are imported from an external entry. - - Instead of an analytic shape, the values are read from a named entry in - ``globals.imports``. By default the same DD path the segment sits at is read - (``default_path``, set by the parent waveform); ``path`` overrides it and - ``time_offset`` shifts the sampling time. ``interp`` selects the resampling mode - (closest/linear/previous) used when sampling onto the export time base. - - For a **0D (scalar)** quantity an import may be one segment among analytic ones, - each filling its ``[start, end]`` window, so this is a real tendency. Non-0D - quantities and wildcard paths instead use an - :class:`~waveform_editor.import_waveform.ImportWaveform`. - - Values come from an :class:`~waveform_editor.import_resolver.ImportResolver` bound - by the parent waveform: ``get_value(time)`` returns values resampled onto ``time`` - (export); ``get_value()`` returns the raw source samples (editing/plotting), clipped - to this segment's window only when one was given. - """ - - # User keys are passed with a ``user_`` prefix by the YAML parser. - user_ref = param.String( - default="", doc="Name of the entry in globals.imports to read." - ) - user_time_offset = param.Number( - default=0.0, doc="Offset added to the export time when sampling the import." - ) - user_path = param.String( - default=None, - doc="DD path to read from the import (defaults to the waveform's path).", - ) - user_interp = param.Selector( - default="closest", - objects=list(INTERP_MODES), - doc="Interpolation mode used when resampling onto the export time base.", - ) - - # Bound by the parent waveform before evaluation (see Waveform._bind_imports). Class - # defaults so they exist while param watchers run during __init__ (before binding). - resolver = None - default_path = None - - @property - def _path(self): - return self.user_path or self.default_path - - @property - def _has_window(self): - """Whether the user gave an explicit time window (vs. spanning the source).""" - return ( - self.user_start is not None - or self.user_duration is not None - or self.user_end is not None - ) - - def get_value(self, time: np.ndarray | None = None): - if self.resolver is None or not self._path: - # Unresolved (no config/resolver, or path unknown): placeholder curve so the - # tendency interface stays satisfied for bounds/plotting. - if time is None: - time = np.array([self.start, self.end]) - return time, np.zeros(len(time)) - if time is None: - # Editing/plotting: raw source samples, not resampled. Clipped to this - # segment's [start, end] only when an explicit window was given. - times, values = self.resolver.raw( - self.user_ref, self._path, time_offset=self.user_time_offset - ) - if not self._has_window: - return times, values - window = (times >= self.start) & (times <= self.end) - return times[window], values[window] - return time, self.resolver.sample( - self.user_ref, - self._path, - time, - time_offset=self.user_time_offset, - interp=self.user_interp, - ) - - def get_derivative(self, time: np.ndarray) -> np.ndarray: - return np.zeros(len(time)) diff --git a/waveform_editor/waveform.py b/waveform_editor/waveform.py index fd1c7ed6..17011175 100644 --- a/waveform_editor/waveform.py +++ b/waveform_editor/waveform.py @@ -6,7 +6,6 @@ from waveform_editor.base_waveform import BaseWaveform from waveform_editor.tendencies.constant import ConstantTendency -from waveform_editor.tendencies.import_tendency import ImportTendency from waveform_editor.tendencies.linear import LinearTendency from waveform_editor.tendencies.periodic.sawtooth_wave import SawtoothWaveTendency from waveform_editor.tendencies.periodic.sine_wave import SineWaveTendency @@ -30,9 +29,6 @@ "smooth": SmoothTendency, "piecewise": PiecewiseLinearTendency, "repeat": RepeatTendency, - "import": ImportTendency, - # `reference` kept as an alias for the import tendency type. - "reference": ImportTendency, } @@ -46,29 +42,13 @@ def __init__( is_repeated=False, name="waveform", dd_version=None, - config=None, ): super().__init__(yaml_str, name, dd_version) self.line_number = line_number self.is_repeated = is_repeated - # Used to reach the import resolver for {ref: ...} tendencies (None when the - # waveform is built outside a configuration, e.g. a repeated sub-waveform). - self.config = config if waveform is not None: self._process_waveform(waveform) - def _bind_imports(self): - """Give import tendencies the resolver and default DD path so they can produce - their values. Read fresh each call: the resolver may be rebound for a run (e.g. - with IDSs received on MUSCLE3 ports).""" - if not any(isinstance(t, ImportTendency) for t in self.tendencies): - return - resolver = self.config.ensure_resolver() if self.config else None - for tendency in self.tendencies: - if isinstance(tendency, ImportTendency): - tendency.resolver = resolver - tendency.default_path = self.name - def get_value( self, time: np.ndarray | None = None ) -> tuple[np.ndarray, np.ndarray]: @@ -85,18 +65,10 @@ def get_value( if not self.tendencies: return np.array([]), np.array([]) - self._bind_imports() - if time is None: time, values = zip(*(t.get_value() for t in self.tendencies), strict=True) time = np.concatenate(time) values = np.concatenate(values) - elif len(self.tendencies) == 1 and isinstance( - self.tendencies[0], ImportTendency - ): - # A lone import spans the whole waveform, rather than a default [0, 1] - # segment window; sample it across the full requested time base. - return self.tendencies[0].get_value(time) else: values = self._evaluate_tendencies(time) @@ -214,23 +186,6 @@ def update_annotations(self, event=None): if tendency.annotations and tendency.annotations not in self.annotations: self.annotations.add_annotations(tendency.annotations) - @staticmethod - def _infer_tendency_type(entry): - """Infer the tendency type from an entry's keys when ``type`` is omitted. - - Unambiguous keys map directly; periodic shapes share keys (period/amplitude) so - they still need an explicit ``type``. Falls back to ``linear``. - """ - for key, tendency_type in ( - ("user_ref", "import"), - ("user_to", "linear"), - ("user_time", "piecewise"), - ("user_value", "constant"), - ): - if key in entry: - return tendency_type - return "linear" - def _has_type_error(self, entry): """Check if the YAML entry contains an error related to the tendency type. @@ -243,9 +198,9 @@ def _has_type_error(self, entry): line_number = entry.get("line_number", 0) ignore_msg = "This tendency will be ignored.\n" - # If no type is given, infer it from the entry's keys (linear by default) + # If no type is given, take linear as default if "user_type" not in entry: - entry["user_type"] = self._infer_tendency_type(entry) + entry["user_type"] = "linear" tendency_type = entry.get("user_type", None) if tendency_type is None: diff --git a/waveform_editor/yaml/yaml_globals.py b/waveform_editor/yaml/yaml_globals.py index e5c4e390..b2086dcc 100644 --- a/waveform_editor/yaml/yaml_globals.py +++ b/waveform_editor/yaml/yaml_globals.py @@ -14,15 +14,10 @@ class YamlGlobals(param.Parameterized): objects=AVAILABLE_DD_VERSIONS, doc="IMAS Data Dictionary version", ) - imports = param.Dict( - label="Imports", + machine_description = param.Dict( + label="Machine Description URIs", default={}, - doc=( - "Named external data entries to import values from. Each value is either " - "an IMAS URI string, or a mapping ``{port: }`` referring to an IDS " - "received on a MUSCLE3 port at run time. Consumed by ``{ref: }`` " - "import entries in waveforms." - ), + doc="Machine description URIs for each IDS.", ) def __init__(self, **params): diff --git a/waveform_editor/yaml/yaml_parser.py b/waveform_editor/yaml/yaml_parser.py index e4f5f752..60ad2f94 100644 --- a/waveform_editor/yaml/yaml_parser.py +++ b/waveform_editor/yaml/yaml_parser.py @@ -2,46 +2,15 @@ import re from io import StringIO -import imas import yaml -from imas.ids_path import IDSPath from ruamel.yaml import YAML from waveform_editor.derived_waveform import DerivedWaveform -from waveform_editor.import_waveform import ImportWaveform -from waveform_editor.static_waveform import StaticWaveform from waveform_editor.waveform import Waveform logger = logging.getLogger(__name__) -def _is_import_entry(entry): - """Whether a parsed waveform entry declares an import (``{ref: ...}``).""" - return isinstance(entry, dict) and ( - "user_ref" in entry or entry.get("user_type") in ("import", "reference") - ) - - -def _import_is_non_scalar(name, entry, dd_version): - """Whether an import must be an ImportWaveform rather than a 0D segment. - - True for wildcard paths (``.../*``) and for any path whose DD leaf is not a scalar - (a value per radial point, an array of structure, etc.); such imports cannot be - combined with analytic segments and own the whole waveform. - """ - source = entry.get("user_path") or name - if "*" in source: - return True - try: - ids_name, path = source.split("/", 1) - ids = imas.IDSFactory(version=dd_version).new(ids_name) - metadata = IDSPath(path).goto_metadata(ids.metadata) - except (imas.exception.IDSNameError, ValueError, KeyError): - # Unknown path: let the (sole-content) ImportWaveform path handle/report it. - return True - return metadata.ndim != 0 - - class LineNumberYamlLoader(yaml.SafeLoader): def _check_for_duplicates(self, node, deep): seen = set() @@ -189,40 +158,12 @@ def parse_waveform(self, yaml_str): line_number = waveform_yaml.get("line_number", 0) dd_version = self.config.globals.dd_version if isinstance(waveform, list): - # A single {value: } entry is a static constant (e.g. an - # identifier name) -- strings don't fit the numeric tendency value flow. - if ( - len(waveform) == 1 - and isinstance(waveform[0], dict) - and isinstance(waveform[0].get("user_value"), str) - ): - return StaticWaveform( - waveform[0]["user_value"], - yaml_str=yaml_str, - name=name, - dd_version=dd_version, - ) - # A non-0D or wildcard import owns the whole waveform (and may list - # several overlays); 0D imports stay as tendency segments, combinable - # with analytic ones. - if ( - waveform - and all(_is_import_entry(entry) for entry in waveform) - and _import_is_non_scalar(name, waveform[0], dd_version) - ): - return ImportWaveform( - waveform, - yaml_str=yaml_str, - name=name, - dd_version=dd_version, - ) waveform = Waveform( waveform=waveform, yaml_str=yaml_str, line_number=line_number, name=name, dd_version=dd_version, - config=self.config, ) else: waveform = DerivedWaveform(