From 3bed4cc9072ddaccdb35ca2be93359c1e4b6c4c0 Mon Sep 17 00:00:00 2001 From: Mike Sanders Date: Tue, 1 Sep 2026 18:51:38 +0200 Subject: [PATCH 01/12] update metis evolutive controller workflow to current standards --- .../metis_nice_evol_from_dina_105073.ymmsl | 7 + workflows/metis_nice_evol_from_dina/README.md | 97 ++++++ .../metis_nice_evol_from_dina/config_nice.xml | 325 ++++++++++++++++++ workflows/metis_nice_evol_from_dina/env.sh | 3 + .../metis_nice_evol_from_dina/postprocess.sh | 21 ++ .../metis_nice_evol_from_dina/preprocess.sh | 64 ++++ .../metis_nice_evol_from_dina/settings.ymmsl | 78 +++++ .../metis_nice_evol_from_dina/workflow.ymmsl | 146 ++++++++ 8 files changed, 741 insertions(+) create mode 100644 cases/overrides/metis_nice_evol_from_dina_105073.ymmsl create mode 100644 workflows/metis_nice_evol_from_dina/README.md create mode 100644 workflows/metis_nice_evol_from_dina/config_nice.xml create mode 100644 workflows/metis_nice_evol_from_dina/env.sh create mode 100755 workflows/metis_nice_evol_from_dina/postprocess.sh create mode 100755 workflows/metis_nice_evol_from_dina/preprocess.sh create mode 100644 workflows/metis_nice_evol_from_dina/settings.ymmsl create mode 100644 workflows/metis_nice_evol_from_dina/workflow.ymmsl diff --git a/cases/overrides/metis_nice_evol_from_dina_105073.ymmsl b/cases/overrides/metis_nice_evol_from_dina_105073.ymmsl new file mode 100644 index 00000000..12d26f58 --- /dev/null +++ b/cases/overrides/metis_nice_evol_from_dina_105073.ymmsl @@ -0,0 +1,7 @@ +ymmsl_version: v0.2 +# DINA shot 105073. Converted from +# workflows/metis_predictive_nice_evol_from_dina/scenarios/105073/scenario_config.env, whose +# TSTART=75 is where that scenario bootstrapped NICE/METIS/the controller from. +settings: + source_nice.t_min: 75.0 + source_metis.t_min: 75.0 diff --git a/workflows/metis_nice_evol_from_dina/README.md b/workflows/metis_nice_evol_from_dina/README.md new file mode 100644 index 00000000..b15ce205 --- /dev/null +++ b/workflows/metis_nice_evol_from_dina/README.md @@ -0,0 +1,97 @@ +# metis_nice_evol_from_dina + +## What it does + +A MUSCLE3 workflow that runs METIS and NICE in genuine evolutive (forward, lockstep) +co-simulation, self-consistently, with a PCSSP magnetic controller closing the coil-current +feedback loop. + +This is `evolutive_controller` with METIS in place of TORAX as the transport solver, and +`metis_nice_inverse_from_dina`'s METIS-from-DINA bootstrap in place of that workflow's NICE +inverse solve: each internal step, `metis` evolves the plasma transport and hands +`equilibrium` + `core_profiles` to `nice_evo_rd` (NICE's resistive-diffusion evolutive +free-boundary solver), which returns the updated `equilibrium` for METIS's next geometry, same +as `evolutive_controller`'s `torax`/`nice_evo_rd` pair. Unlike TORAX, METIS also needs a fresh +`pulse_schedule`/`core_profiles`/`core_sources` boundary condition every internal step (its own +actuator/current-drive input), supplied by `synchro_nice_metis` re-slicing METIS's own +DINA-derived input trace at NICE's current equilibrium timestamp. + +Ported from `itergit/feature/metis_nice_evol`'s +`workflows/metis_predictive_nice_evol_from_dina/workflow.ymmsl.template` ("first version of the +workflow with METIS + NICE evol + Controller" and its follow-up port/conduit fixes) into this +repo's current `workflow.ymmsl` + `settings.ymmsl` + `cases/overrides/` structure. That branch +also has a `workflow_alternative.ymmsl.template` (two METIS instances -- a one-shot +`metis_init` bootstrap plus the in-loop `metis`) which was not ported; the single-metis +arrangement here is the branch's own latest committed direction. + +Structure lives in `workflow.ymmsl`; shared knobs in `settings.ymmsl`; per-shot bootstrap +timing (which DINA slice to start from has no sane generic default) in +`cases/overrides/metis_nice_evol_from_dina_.ymmsl`. + +## Running it + +Requires the scenario's raw DINA data in `pds-scenarios`. Build a case folder and hand it to +SLURM: + +```bash +bin/pds-create-case metis_nice_evol_from_dina 105073 # -> cases/metis_nice_evol_from_dina_105073 +sbatch bin/pds-run-case.sbatch cases/metis_nice_evol_from_dina_105073 +``` + +`pds-create-case`'s `preprocess.sh` builds both `source_metis`'s METIS-formatted dataset and +`source_nice`'s DINA-derived machine description (same dual build as +`metis_nice_inverse_from_dina`), once, into `$CASE_DIR/preprocess/`. + +## Known issue -- not yet run end to end + +**`synchro_nice_metis` uses `f_init`/`o_f`, not the branch's own latest (`s`/`o_i`) commit.** +The branch's final "change of port mode" commit (made the same day as the rest of this +addition) wired it as `s`/`o_i`, but `imas_muscle3.data_sink_source.muscled_sink_source` (see +that module's own docstring and `muscled_sink_source()`) only implements the `F_INIT`/`O_F` +operator pair for the hybrid sink/source actor -- there is no `S`/`O_I` support, and wiring +`s`/`o_i` as the branch did would very likely reproduce the "dead lock" its own commit history +mentions fixing once already (a `reuse_instance()` loop that only ever calls +`instance.receive`/`instance.send` against `Operator.F_INIT`/`Operator.O_F` port lists never +sees a message declared under `s`/`o_i`). + +`f_init`/`o_f` (matching the branch's earlier `workflow_alternative.ymmsl.template`) is not +just a workaround for that, it's the correct MMSF macro-micro pairing for this actor: +`nice_evo_rd.equilibrium_o_i` (O_I, fires every internal step) drives `synchro_nice_metis`'s +`F_INIT`, so its `reuse_instance()` loop runs once per NICE step, each cycle sending one `O_F` +message -- not "sent once, ever". `synchro_nice_metis`'s `O_F` output then feeds `metis`'s `S` +ports (`pulse_schedule_in_s`/`core_profiles_in_s`/`core_sources_in_s`); MUSCLE3 does not +enforce a matching operator label across a conduit (confirmed against `libmuscle`'s +`mmsf_validator.py`/`port_manager.py`/`topology_store.py` -- operator is purely local +bookkeeping per instance), only that message cadence lines up, which it does here since +`nice_evo_rd.dt` and `metis`'s own internal `dt` are required to match by design. Verified +structurally with `ci/check_ymmsl.py`'s resolve/check_consistent/flatten machinery, which +passes either way -- this reasoning about message cadence is what actually decides it, since +the static checker doesn't validate operator pairing across conduits. + +## Assumptions + +- `nice_evo_rd`'s port names (`equilibrium_f_init`, `pf_active_s`, `equilibrium_o_i`, ...) and + `magnetic_controller`'s (`equilibrium_in_f`, `equilibrium_in_s`, `pf_active_out_i`, ...) match + `evolutive_controller`'s -- the same NICE/controller binaries, so the same real port names, + which differ slightly from the branch's own (older) naming for the same ports. +- `metis`'s ports/settings (`metis_computation: 1`, the `metis_external_data_*` predictive + defaults) match `metis_from_dina`/`metis_nice_inverse_from_dina`'s conventions. +- `nice_evo_rd.t_interval`/`nice_evo_rd.dt` default to `0.002`, matching the branch's own + template (comment: "Need to match controller dt") -- not validated for solver stability here, + same caveat as `evolutive_controller`'s `0.01` default. +- `config_nice.xml` is the branch's own `param.xml.template` (verbatim, no placeholders to + resolve) -- it differs from `evolutive_controller/config_nice.xml` in `algoMode` (21 vs 31) + and several mesh-refinement parameters; not reconciled against that file. + +## Input requirements + +Same as `metis_nice_inverse_from_dina`: this shot's raw DINA source and standard machine +description in `pds-scenarios` (`source.env`), plus the ssh-gated PCS checkout and +`nice_imas_evo_rd_muscle3` binary (source-built, see `evolutive_controller`'s Input +requirements). + +## Output + +`sink_transport`, `sink_equilibrium`, and `sink_control` write METIS's evolved profiles, NICE's +evolutive equilibrium/pf_active, and the controller's corrected pf_active, respectively. Not +yet exercised end to end -- see Known issue above. diff --git a/workflows/metis_nice_evol_from_dina/config_nice.xml b/workflows/metis_nice_evol_from_dina/config_nice.xml new file mode 100644 index 00000000..3642364c --- /dev/null +++ b/workflows/metis_nice_evol_from_dina/config_nice.xml @@ -0,0 +1,325 @@ + + + + 1 + 0 + 0 + + 21 + + 2 + 0 + 0 + 1 + 0 + 2 + 1 + 0 + 0 + 0 + 0 + 1 + 1 + 0 + 1 + + 20 + 1.0e-10 + 2 + 2 + 20 + 1.0e-10 + 40 + 1.0e-10 + 25 + 1.0e-6 + + 1 + 2.25 + 6.2 + 0.0 + + 0 + + 0 + 4 + 4.43 5.45 5.88 4.64 + -3.44 -3.76 -3.00 -2.65 + 4.5 + -3.2 + 0.0005 + + 0 + 4 + 2.44 1.91 1.94 2.44 + 0.79 0.573 0.49 0.66 + 2.18 + 0.6 + 0.00003 + + 0 + 10 + 5.8 6.0 6.0 6.2 6.4 6.4 6.2 6.2 6.0 5.8 + 0.2 0.2 0.4 0.2 0.2 1.0 1.0 0.8 1.0 1.0 + 5.9 + 0.3 + 0.0005 + + 0.005 + 0.04 + 0.01 + 0.005 + 0.005 + 0.005 + 0.005 + + 1 + 17 + + 0 + 0 + + 0 + 1 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 17 + + 50 + 0 + + 6.2 + + + 0 + 11 + 0 0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9 1 + 2 0.6 1.4 + + 0 + 11 + 0 0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9 1 + 2 0.4 1.4 + + 1 + + + 0 + 11 + 0 0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9 1 + 1e20 + 4 + 3 1 0 1 + + + 0 + 11 + 0 0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9 1 + 25 + 1 1.5 0 1 + + + + 1.0e-1 + 1 1 1 1 1 1 1 1 1 1 1 + 1.0e12 + 0.0e10 + + 1.0e-1 + 1 1 1 1 1 1 1 1 1 1 1 + 1.0e12 + + 1.0e-1 + 1 1 1 1 1 1 1 1 1 1 1 + 1.0e12 + 1.0e3 1.0e3 1.0e0 1.0e12 1.0e0 + + 1.0e-1 + 1 1 1 1 1 1 1 1 1 1 1 1 + 0.0e20 + 0.0e0 + + + 0.1 + + 1e-3 + 0.01 + 0 + 0 + 0 + 0 + + 0.01 + 0.01 + 0 + 0 + 0 + 0 + 0 + + + + 0.01 + 0 + + 1e18 + 0.1 + 0 + 0 + 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 + + 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 + + + + 0.195 + 2.42e-20 + 5.23e-19 + + 0.001 + 0.1 + 0 + 0 + 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 + + 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 + + 0.001 + 0.1 + 0 + 0 + 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 + + + 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 + + + 1 1 1 + + + 1.0e3 + 0.1 + 0 + 0 + 0 + 0 + + + 0.001 + 0.1 + 0 + 25 + 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 + 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 0 0 1 1 1 0 0 + + + + + 13 + + + 3.88 4.00 5.55 7.36 8.0 8.45 8.66 8.44 7.62 5.80 4.87 4.18 3.88 + -2.53 -3.92 -4.65 -2.45 -1.4 -0.47 0.66 1.65 3.21 4.65 4.85 4.39 3.58 + + 0.05 + 0 + + 0.15 + 0.15 + 0.05 + + 1 + + 0 + 8 + 0 1 2 3 4 5 6 7 + 7 + 1 2 3 4 5 6 7 + 8 + 0 1 2 3 4 5 6 7 + 7 + 1 2 3 4 5 6 7 + 1 + 0.05 + + 3 + 0.1 + 1.0e-6 + 1.0e-3 + + 1 + 60 + 1 + + + 0 + 1.9 + 6.2 + 0.545 + 1.8 + 0.43 + 5.089 + -3.346 + 96 + + 0 + + + 3e7 + 0 + 14 + 0 1 2 2 3 4 5 6 7 8 9 10 11 12 + 13 + 0 1 2 4 5 6 7 8 9 10 11 12 13 + 2 + 11 12 + 0 + 0 1 2 3 4 5 6 7 8 9 10 + + + + + 2 + 6 + 0 0.2 0.4 0.6 0.8 1 + + 1.0 + 1.0e-3 + 0.0e12 + 0.0e12 + 1.0e-3 + + + 0 + 5 + + + + 1 + 10 + + 1 + 20 + + + + 0 + 10.1e19 + 3.0 + 0.15 + 1.0 + 1e-5 + 0.0 + + + + 0 + +
1.0e-3
+ +
diff --git a/workflows/metis_nice_evol_from_dina/env.sh b/workflows/metis_nice_evol_from_dina/env.sh new file mode 100644 index 00000000..2112fd6e --- /dev/null +++ b/workflows/metis_nice_evol_from_dina/env.sh @@ -0,0 +1,3 @@ +#!/bin/bash +# See workflows/metis_from_dina/env.sh -- same fix, same reason. +export IMAS_AL_DISABLE_VALIDATE=1 diff --git a/workflows/metis_nice_evol_from_dina/postprocess.sh b/workflows/metis_nice_evol_from_dina/postprocess.sh new file mode 100755 index 00000000..63fa3c0f --- /dev/null +++ b/workflows/metis_nice_evol_from_dina/postprocess.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Validation plots against DINA. Ported from itergit/feature/metis_nice_evol's +# workflows/metis_predictive_nice_evol_from_dina/postprocess_data.sh -- run by +# bin/pds-run-case.sbatch after muscle_manager finishes. See that script's header for the +# PDS_REPO/SHOT/CASE_DIR/RUN_DIR contract. +# +# t_list is the same "20 35 60" every metis_nice_utils validation plot uses -- not a real +# per-shot calibration, just where the old default happened to land. +set -euo pipefail + +T_LIST="20 35 60" + +mkdir -p "$RUN_DIR/plots" + +"$PYTHON" "$PDS_REPO/workflows/metis_nice_utils/plot_validation_metis_nice.py" \ + --shot_nr "$SHOT" \ + --dina_uri "$CASE_DIR/preprocess/dina_update_in" \ + --nice_uri "$RUN_DIR/nice_out" \ + --metis_uri "$RUN_DIR/metis_out" \ + --output_dir "$RUN_DIR/plots" \ + --t_list $T_LIST diff --git a/workflows/metis_nice_evol_from_dina/preprocess.sh b/workflows/metis_nice_evol_from_dina/preprocess.sh new file mode 100755 index 00000000..622d2ad5 --- /dev/null +++ b/workflows/metis_nice_evol_from_dina/preprocess.sh @@ -0,0 +1,64 @@ +#!/bin/bash +# Builds both METIS's own input dataset and NICE's DINA-derived machine-description input +# from this shot's raw DINA source. Identical dual build to +# metis_nice_inverse_from_dina/preprocess.sh (same two DINA-derived datasets are needed here); +# ported from itergit/feature/metis_nice_evol's +# workflows/metis_predictive_nice_evol_from_dina/preprocess_data.sh. Run once by +# bin/pds-create-case, output frozen into $CASE_DIR/preprocess/. See that script's header for +# the PDS_REPO/SCENARIOS_REPO/SHOT/CASE_DIR contract. +# +# source_metis.source_uri / synchro_nice_metis.source_uri -> ${CASE_DIR}/preprocess/metis_in +# source_nice.source_uri -> ${CASE_DIR}/preprocess/dina_in +# metis.metis_psioffset in settings.ymmsl defaults to a fixed 9.0; if this run's own DINA +# equilibrium yields a real value (init_metis_from_dina_ids.m computes and writes +# $OUT/PSI_OFFSET as a side effect of the MATLAB build below), that overrides it via +# $CASE_DIR/preprocess_settings.ymmsl, stacked in by bin/pds-run-case.sbatch -- matters here +# because NICE's evolutive free-boundary solve carries psi_LCFS forward from METIS. +set -euo pipefail + +# SOURCE_URI/SUMMARY_URI/MD_*/N_TIMESLICES (raw DINA + standard machine description). +# source.env is written to be sourced by pds-scenarios' own tools/prepare, which sets $TOOLS +# first (its own tools/ dir, for MD_IRON_CORE) -- replicate that here rather than pulling in +# tools/prepare itself. IMAS_VERSION comes from the PDS module already loaded. +export TOOLS="$SCENARIOS_REPO/tools" +source "$SCENARIOS_REPO/$SHOT/source.env" + +OUT="$CASE_DIR/preprocess" +mkdir -p "$OUT" + +export IMAS_AL_DISABLE_VALIDATE=1 + +python "$PDS_REPO/workflows/utils/convert_dina_data_to_input.py" \ + --source_uri "$SOURCE_URI" \ + --summary_uri "${SUMMARY_URI:-$SOURCE_URI}" \ + --md_pf_active_uri "$MD_PF_ACTIVE" \ + --md_pf_passive_uri "$MD_PF_PASSIVE" \ + --md_wall_uri "$MD_WALL" \ + --md_iron_core_uri "$MD_IRON_CORE" \ + --sink_uri "imas:hdf5?path=$OUT/dina_in" \ + --n_timeslices "${N_TIMESLICES:-51}" + +imas convert "$SOURCE_URI" "$IMAS_VERSION" "imas:hdf5?path=$OUT/dina_update_in" + +# IMAS-AL-Matlab has no intel-2025b build, so it can't coexist with the intel-2025b +# IMAS-Python stack `module load PDS` already loaded -- purge and reload fresh for this step. +# If PDS got loaded as an actual module (bin/pds-create-case's own auto-load, or the caller's +# shell), Lmod adopted PDS_REPO via its setenv and purge unwinds that -- reassert it after. +# +# --ignore_cache: the CI user's Lmod spider cache can predate a recent METIS-IRFM/MATLAB +# rebuild, in which case depends_on() can't find a MATLAB family member to resolve +# METIS-IRFM's dependency on and the load hard-fails (set -e kills the script here). +_PDS_REPO="$PDS_REPO" +module purge +module --ignore_cache load METIS-IRFM/2026.08-pds IMAS-AL-Matlab/5.4.0-intel-2023b-DD-4.0.0 +export PDS_REPO="$_PDS_REPO" + +export metis_dina_source="imas:hdf5?path=$OUT/dina_update_in" +export metis_imas_dataset="imas:hdf5?path=$OUT/metis_in" +matlab -batch "[s,t] = unix('which python');pyenv('Version',strtrim(t),'ExecutionMode','InProcess'); addpath('$PDS_REPO/workflows/metis_nice_utils');cd('$OUT');make_metis_from_dina_interpretative;" + +if [[ -f "$OUT/PSI_OFFSET" ]]; then + source "$OUT/PSI_OFFSET" + printf 'ymmsl_version: v0.2\nsettings:\n metis.metis_psioffset: %s\n' "$PSI_OFFSET" \ + > "$CASE_DIR/preprocess_settings.ymmsl" +fi diff --git a/workflows/metis_nice_evol_from_dina/settings.ymmsl b/workflows/metis_nice_evol_from_dina/settings.ymmsl new file mode 100644 index 00000000..258e145f --- /dev/null +++ b/workflows/metis_nice_evol_from_dina/settings.ymmsl @@ -0,0 +1,78 @@ +ymmsl_version: v0.2 + +# Generic settings for metis_nice_evol_from_dina, stacked after workflow.ymmsl by +# bin/pds-create-case / bin/pds-run-case.sbatch. ${SHOT} is filled in by pds-create-case. +# +# This workflow's preprocess.sh builds both source_metis's and source_nice's inputs, once, at +# bin/pds-create-case time, from this shot's raw DINA source (same dual build as +# metis_nice_inverse_from_dina's preprocess.sh: METIS's own dataset into +# ${CASE_DIR}/preprocess/metis_in, NICE's DINA-derived machine description into +# ${CASE_DIR}/preprocess/dina_in). +# +# Defaults below are PREDICTIVE mode (METIS computes its own profiles/sources; none are read +# from DINA). A shot needing interpretative mode overrides the five metis_external_data_* +# settings via cases/overrides/metis_nice_evol_from_dina_.ymmsl. +# +# source_nice.t_min has no sane generic default (which DINA slice to bootstrap NICE/METIS/the +# controller from is genuinely per-shot); every shot needs +# cases/overrides/metis_nice_evol_from_dina_.ymmsl for it, same as evolutive_controller's +# source.t_min. + +resources: + metis_nice_evol_from_dina.source_nice: {threads: 1} + metis_nice_evol_from_dina.source_metis: {threads: 1} + metis_nice_evol_from_dina.synchro_nice_metis: {threads: 1} + metis_nice_evol_from_dina.metis: {threads: 10} + metis_nice_evol_from_dina.nice_evo_rd: {threads: 2} + metis_nice_evol_from_dina.magnetic_controller: {threads: 1} + metis_nice_evol_from_dina.sink_transport: {threads: 1} + metis_nice_evol_from_dina.sink_equilibrium: {threads: 1} + metis_nice_evol_from_dina.sink_control: {threads: 1} + +settings: + source_nice.source_uri: "imas:hdf5?path=${CASE_DIR}/preprocess/dina_in" + source_nice.iterative: false + source_nice.t_min: 0.0 + + source_metis.source_uri: "imas:hdf5?path=${CASE_DIR}/preprocess/metis_in" + source_metis.iterative: false + source_metis.t_min: 0.0 + + synchro_nice_metis.source_uri: "imas:hdf5?path=${CASE_DIR}/preprocess/metis_in" + + sink_transport.sink_uri: "imas:hdf5?path=../../../metis_out" + sink_transport.sink_mode: "w" + sink_equilibrium.sink_uri: "imas:hdf5?path=../../../nice_out" + sink_equilibrium.sink_mode: "w" + sink_control.sink_uri: "imas:hdf5?path=../../../controller_out" + sink_control.sink_mode: "w" + + metis.metis_computation: 1 + # Fallback only -- preprocess.sh's own MATLAB build computes this shot's real value from its + # DINA equilibrium and overrides it via preprocess_settings.ymmsl (see that script). Matters + # here because NICE's evolutive free-boundary solve carries psi_LCFS forward from METIS. + metis.metis_psioffset: 9.0 + metis.metis_alwaysreadinitport: 0 + + nice_evo_rd.xml_path: ${PDS_REPO}/workflows/metis_nice_evol_from_dina/config_nice.xml + nice_evo_rd.t_interval: 0.002 + nice_evo_rd.dt: 0.002 + + # METIS switches for reading external data from the received IDSs -- predictive defaults, + # all off. See cases/overrides/metis_nice_evol_from_dina_.ymmsl for an interpretative + # shot. + metis.metis_external_data_equilibrium: 0 + metis.metis_external_data_current_diffusion: 0 + metis.metis_external_data_electron_temperature: 0 + metis.metis_external_data_electron_density: 0 + metis.metis_external_data_ion_temperature: 0 + metis.metis_external_data_charge_effective: 0 + metis.metis_external_data_momentum: 0 + metis.metis_external_data_LHCD: 0 + metis.metis_external_data_ECRH: 0 + metis.metis_external_data_ICRH: 0 + metis.metis_external_data_NBI: 0 + metis.metis_external_data_PLINE: 0 + metis.metis_external_data_runaway: 0 + + muscle_remote_log_level: 'INFO' diff --git a/workflows/metis_nice_evol_from_dina/workflow.ymmsl b/workflows/metis_nice_evol_from_dina/workflow.ymmsl new file mode 100644 index 00000000..3e8ab721 --- /dev/null +++ b/workflows/metis_nice_evol_from_dina/workflow.ymmsl @@ -0,0 +1,146 @@ +ymmsl_version: v0.2 + +# metis_nice_evol_from_dina -- METIS transport running in lockstep with NICE's evolutive +# free-boundary + resistive-diffusion solve, with a PCSSP magnetic controller closing the +# coil-current feedback loop. +# +# Ported from itergit/feature/metis_nice_evol's +# workflows/metis_predictive_nice_evol_from_dina/workflow.ymmsl.template ("first version of +# the workflow with METIS + NICE evol + Controller" and its follow-up port/conduit fixes). +# That branch also has a workflow_alternative.ymmsl.template (two METIS instances -- a +# one-shot "metis_init" bootstrap plus the in-loop "metis") which was not ported: the +# single-metis arrangement below is the branch's own latest committed direction. +# +# Structurally this is metis_nice_inverse_from_dina's METIS half spliced onto +# evolutive_controller's NICE-evo/magnetic_controller loop (see those two workflows' +# workflow.ymmsl for the individually-validated pieces): magnetic_controller and nice_evo_rd +# keep evolutive_controller's port names (the real controller/NICE binaries, not the +# branch's older names for the same ports); source_metis/source_nice/sink_transport/ +# sink_equilibrium keep metis_nice_inverse_from_dina's names. +# +# synchro_nice_metis uses f_init/o_f rather than the branch's own latest (s/o_i) commit -- +# see README.md for why: imas_muscle3.actors.sink_source_component only implements +# F_INIT/O_F, and f_init/o_f is also the correct MMSF macro-micro pairing here (nice_evo_rd's +# O_I drives synchro_nice_metis's F_INIT once per internal step; its O_F then feeds metis's S +# ports, which just need one message per METIS step -- MUSCLE3 does not require the operator +# label to match across a conduit, only the message cadence, and that's already pinned by +# nice_evo_rd.dt == metis's own internal dt). + +imports: +- from imas_muscle3 import implementation source_component +- from imas_muscle3 import implementation sink_component +- from imas_muscle3 import implementation sink_source_component +- from lib.easybuild_programs import implementation metis +- from lib.easybuild_programs import implementation nice_evo_rd +- from lib.easybuild_programs import implementation magnetic_controller + +models: + metis_nice_evol_from_dina: + description: METIS transport in lockstep with NICE evolutive + magnetic controller. + components: + source_nice: + description: NICE machine description (DINA-derived), read once + implementation: source_component + ports: {o_i: [pf_active_out, pf_passive_out, wall_out, iron_core_out, equilibrium_out]} + + source_metis: + description: source -- pulse schedule, profiles and sources for METIS's bootstrap + implementation: source_component + ports: {o_i: [pulse_schedule_out, core_profiles_out, core_sources_out]} + + synchro_nice_metis: + description: re-slices METIS's input trace at NICE's current equilibrium timestamp + implementation: sink_source_component + ports: + f_init: [equilibrium_in] + o_f: [pulse_schedule_out, core_profiles_out, core_sources_out] + + metis: + description: METIS transport (coupling mode 2), stepping in lockstep with nice_evo_rd + implementation: metis + ports: + f_init: [pulse_schedule_f_init, core_profiles_f_init, core_sources_f_init] + s: [equilibrium_in_s, pulse_schedule_in_s, core_profiles_in_s, core_sources_in_s] + o_i: [equilibrium_out_i, pulse_schedule_out_i, core_profiles_out_i, plasma_profiles_out_i, plasma_sources_out_i, summary_out_i] + + nice_evo_rd: + description: nice evolutive free-boundary + resistive-diffusion solve + implementation: nice_evo_rd + ports: + f_init: [equilibrium_f_init, wall_f_init, pf_active_f_init, pf_passive_f_init, iron_core_f_init, core_profiles_f_init] + s: [equilibrium_s, pf_active_s, core_profiles_s] + o_i: [equilibrium_o_i, pf_active_o_i] + + magnetic_controller: + description: PCSSP magnetic (SCDDS) controller + implementation: magnetic_controller + ports: + f_init: [equilibrium_in_f, pf_active_in_f] + s: [equilibrium_in_s, pf_active_in_s] + o_i: [pf_active_out_i] + + sink_transport: + description: sink -- METIS outputs + implementation: sink_component + ports: {f_init: [equilibrium_in, pulse_schedule_in, plasma_profiles_in, plasma_sources_in, summary_in]} + + sink_equilibrium: + description: sink -- NICE evolutive outputs + implementation: sink_component + ports: {f_init: [equilibrium_in, pf_active_in]} + + sink_control: + description: sink -- controller outputs + implementation: sink_component + ports: {f_init: [pf_active_in]} + + conduits: + # METIS bootstrap from source + source_metis.pulse_schedule_out: metis.pulse_schedule_f_init + source_metis.core_profiles_out: metis.core_profiles_f_init + source_metis.core_sources_out: metis.core_sources_f_init + + # METIS in-loop inputs from the NICE-equilibrium-driven resync + synchro_nice_metis.pulse_schedule_out: metis.pulse_schedule_in_s + synchro_nice_metis.core_profiles_out: metis.core_profiles_in_s + synchro_nice_metis.core_sources_out: metis.core_sources_in_s + + # METIS outputs to sink + metis.pulse_schedule_out_i: sink_transport.pulse_schedule_in + metis.plasma_profiles_out_i: sink_transport.plasma_profiles_in + metis.plasma_sources_out_i: sink_transport.plasma_sources_in + metis.summary_out_i: sink_transport.summary_in + + # NICE bootstrap from machine description + source_nice.pf_active_out: nice_evo_rd.pf_active_f_init + source_nice.pf_passive_out: nice_evo_rd.pf_passive_f_init + source_nice.iron_core_out: nice_evo_rd.iron_core_f_init + source_nice.wall_out: nice_evo_rd.wall_f_init + + # NICE bootstrap from METIS + metis.equilibrium_out_i: nice_evo_rd.equilibrium_f_init + metis.core_profiles_out_i: nice_evo_rd.core_profiles_f_init + # NICE in-loop input from METIS + metis.equilibrium_out_i: nice_evo_rd.equilibrium_s + metis.core_profiles_out_i: nice_evo_rd.core_profiles_s + # NICE in-loop input from the controller + magnetic_controller.pf_active_out_i: nice_evo_rd.pf_active_s + + # NICE outputs to METIS (closes the loop) and to the resync actor + nice_evo_rd.equilibrium_o_i: metis.equilibrium_in_s + nice_evo_rd.equilibrium_o_i: synchro_nice_metis.equilibrium_in + + # NICE outputs to the controller + nice_evo_rd.equilibrium_o_i: magnetic_controller.equilibrium_in_s + nice_evo_rd.pf_active_o_i: magnetic_controller.pf_active_in_s + + # NICE outputs to sink + nice_evo_rd.equilibrium_o_i: sink_equilibrium.equilibrium_in + nice_evo_rd.pf_active_o_i: sink_equilibrium.pf_active_in + + # Controller bootstrap from machine description + source_nice.equilibrium_out: magnetic_controller.equilibrium_in_f + source_nice.pf_active_out: magnetic_controller.pf_active_in_f + + # Controller output to sink + magnetic_controller.pf_active_out_i: sink_control.pf_active_in From b95aa215a2de60b8fa3e4b06ddc9698ca7a413c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artaud=20Jean-Fran=C3=A7ois?= Date: Fri, 4 Sep 2026 12:04:01 +0200 Subject: [PATCH 02/12] updated version of the workflow: to be used with METIS read from branch develop. Still problem with the controler --- .../metis_nice_evol_from_dina/preprocess.sh | 2 + .../metis_nice_evol_from_dina/settings.ymmsl | 13 +++++- .../metis_nice_evol_from_dina/workflow.ymmsl | 44 ++++++++++++++++--- 3 files changed, 50 insertions(+), 9 deletions(-) diff --git a/workflows/metis_nice_evol_from_dina/preprocess.sh b/workflows/metis_nice_evol_from_dina/preprocess.sh index 622d2ad5..6cf72603 100755 --- a/workflows/metis_nice_evol_from_dina/preprocess.sh +++ b/workflows/metis_nice_evol_from_dina/preprocess.sh @@ -61,4 +61,6 @@ if [[ -f "$OUT/PSI_OFFSET" ]]; then source "$OUT/PSI_OFFSET" printf 'ymmsl_version: v0.2\nsettings:\n metis.metis_psioffset: %s\n' "$PSI_OFFSET" \ > "$CASE_DIR/preprocess_settings.ymmsl" + printf ' metis_init.metis_psioffset: %s\n' "$PSI_OFFSET" \ + >> "$CASE_DIR/preprocess_settings.ymmsl" fi diff --git a/workflows/metis_nice_evol_from_dina/settings.ymmsl b/workflows/metis_nice_evol_from_dina/settings.ymmsl index 258e145f..e24c6a26 100644 --- a/workflows/metis_nice_evol_from_dina/settings.ymmsl +++ b/workflows/metis_nice_evol_from_dina/settings.ymmsl @@ -42,6 +42,8 @@ settings: sink_transport.sink_uri: "imas:hdf5?path=../../../metis_out" sink_transport.sink_mode: "w" + sink_metis_init.sink_uri: "imas:hdf5?path=../../../metis_init_out" + sink_metis_init.sink_mode: "w" sink_equilibrium.sink_uri: "imas:hdf5?path=../../../nice_out" sink_equilibrium.sink_mode: "w" sink_control.sink_uri: "imas:hdf5?path=../../../controller_out" @@ -54,6 +56,13 @@ settings: metis.metis_psioffset: 9.0 metis.metis_alwaysreadinitport: 0 + metis_init.metis_computation: 1 + # Fallback only -- preprocess.sh's own MATLAB build computes this shot's real value from its + # DINA equilibrium and overrides it via preprocess_settings.ymmsl (see that script). Matters + # here because NICE's evolutive free-boundary solve carries psi_LCFS forward from METIS. + metis_init.metis_psioffset: 9.0 + metis_init.metis_alwaysreadinitport: 0 + nice_evo_rd.xml_path: ${PDS_REPO}/workflows/metis_nice_evol_from_dina/config_nice.xml nice_evo_rd.t_interval: 0.002 nice_evo_rd.dt: 0.002 @@ -61,8 +70,8 @@ settings: # METIS switches for reading external data from the received IDSs -- predictive defaults, # all off. See cases/overrides/metis_nice_evol_from_dina_.ymmsl for an interpretative # shot. - metis.metis_external_data_equilibrium: 0 - metis.metis_external_data_current_diffusion: 0 + metis.metis_external_data_equilibrium: 1 + metis.metis_external_data_current_diffusion: 1 metis.metis_external_data_electron_temperature: 0 metis.metis_external_data_electron_density: 0 metis.metis_external_data_ion_temperature: 0 diff --git a/workflows/metis_nice_evol_from_dina/workflow.ymmsl b/workflows/metis_nice_evol_from_dina/workflow.ymmsl index 3e8ab721..acba388e 100644 --- a/workflows/metis_nice_evol_from_dina/workflow.ymmsl +++ b/workflows/metis_nice_evol_from_dina/workflow.ymmsl @@ -30,7 +30,7 @@ imports: - from imas_muscle3 import implementation source_component - from imas_muscle3 import implementation sink_component - from imas_muscle3 import implementation sink_source_component -- from lib.easybuild_programs import implementation metis +- from lib.local_programs import implementation metis - from lib.easybuild_programs import implementation nice_evo_rd - from lib.easybuild_programs import implementation magnetic_controller @@ -41,12 +41,15 @@ models: source_nice: description: NICE machine description (DINA-derived), read once implementation: source_component - ports: {o_i: [pf_active_out, pf_passive_out, wall_out, iron_core_out, equilibrium_out]} + ports: + o_f: [pf_active_out, pf_passive_out, wall_out, iron_core_out, equilibrium_out] source_metis: description: source -- pulse schedule, profiles and sources for METIS's bootstrap - implementation: source_component - ports: {o_i: [pulse_schedule_out, core_profiles_out, core_sources_out]} + implementation: sink_source_component + ports: + f_init: [pf_active_in] + o_f: [pulse_schedule_out, core_profiles_out, core_sources_out, equilibrium_out] synchro_nice_metis: description: re-slices METIS's input trace at NICE's current equilibrium timestamp @@ -63,6 +66,13 @@ models: s: [equilibrium_in_s, pulse_schedule_in_s, core_profiles_in_s, core_sources_in_s] o_i: [equilibrium_out_i, pulse_schedule_out_i, core_profiles_out_i, plasma_profiles_out_i, plasma_sources_out_i, summary_out_i] + metis_init: + description: METIS transport (coupling mode 2), stepping in lockstep with nice_evo_rd + implementation: metis + ports: + f_init: [pulse_schedule_f_init, core_profiles_f_init, core_sources_f_init] + o_f: [equilibrium_out_f, pulse_schedule_out_f, core_profiles_out_f, plasma_profiles_out_f, plasma_sources_out_f, summary_out_f] + nice_evo_rd: description: nice evolutive free-boundary + resistive-diffusion solve implementation: nice_evo_rd @@ -84,6 +94,11 @@ models: implementation: sink_component ports: {f_init: [equilibrium_in, pulse_schedule_in, plasma_profiles_in, plasma_sources_in, summary_in]} + sink_metis_init: + description: sink -- METIS init outputs + implementation: sink_component + ports: {f_init: [equilibrium_in, pulse_schedule_in, plasma_profiles_in, plasma_sources_in, summary_in]} + sink_equilibrium: description: sink -- NICE evolutive outputs implementation: sink_component @@ -100,6 +115,11 @@ models: source_metis.core_profiles_out: metis.core_profiles_f_init source_metis.core_sources_out: metis.core_sources_f_init + # METIS init bootstrap from source + source_metis.pulse_schedule_out: metis_init.pulse_schedule_f_init + source_metis.core_profiles_out: metis_init.core_profiles_f_init + source_metis.core_sources_out: metis_init.core_sources_f_init + # METIS in-loop inputs from the NICE-equilibrium-driven resync synchro_nice_metis.pulse_schedule_out: metis.pulse_schedule_in_s synchro_nice_metis.core_profiles_out: metis.core_profiles_in_s @@ -110,16 +130,26 @@ models: metis.plasma_profiles_out_i: sink_transport.plasma_profiles_in metis.plasma_sources_out_i: sink_transport.plasma_sources_in metis.summary_out_i: sink_transport.summary_in + metis.equilibrium_out_i: sink_transport.equilibrium_in + + # METIS init outputs to sink + metis_init.pulse_schedule_out_f: sink_metis_init.pulse_schedule_in + metis_init.plasma_profiles_out_f: sink_metis_init.plasma_profiles_in + metis_init.plasma_sources_out_f: sink_metis_init.plasma_sources_in + metis_init.summary_out_f: sink_metis_init.summary_in + metis_init.equilibrium_out_f: sink_metis_init.equilibrium_in + + # synchronisation between METIS source and NICE one + source_nice.pf_active_out: source_metis.pf_active_in # NICE bootstrap from machine description source_nice.pf_active_out: nice_evo_rd.pf_active_f_init source_nice.pf_passive_out: nice_evo_rd.pf_passive_f_init source_nice.iron_core_out: nice_evo_rd.iron_core_f_init source_nice.wall_out: nice_evo_rd.wall_f_init + metis_init.equilibrium_out_f: nice_evo_rd.equilibrium_f_init + metis_init.core_profiles_out_f: nice_evo_rd.core_profiles_f_init - # NICE bootstrap from METIS - metis.equilibrium_out_i: nice_evo_rd.equilibrium_f_init - metis.core_profiles_out_i: nice_evo_rd.core_profiles_f_init # NICE in-loop input from METIS metis.equilibrium_out_i: nice_evo_rd.equilibrium_s metis.core_profiles_out_i: nice_evo_rd.core_profiles_s From 50fe7d6df2251792c2ff636bf925f31dcb340311 Mon Sep 17 00:00:00 2001 From: Mireille Schneider Date: Fri, 4 Sep 2026 19:03:59 +0200 Subject: [PATCH 03/12] Fix metis_nice_evol_from_dina: split METIS initial state to NICE F_INIT; drop VirtualGL LD_PRELOAD in sbatch --- bin/pds-run-case.sbatch | 5 ++ workflows/lib/easybuild_programs.ymmsl | 5 ++ workflows/metis_nice_evol_from_dina/README.md | 26 ++++-- .../metis_nice_evol_from_dina/preprocess.sh | 2 - .../metis_nice_evol_from_dina/settings.ymmsl | 14 ++- .../metis_nice_evol_from_dina/workflow.ymmsl | 52 +++++------ workflows/utils/initial_state_splitter.py | 88 +++++++++++++++++++ 7 files changed, 143 insertions(+), 49 deletions(-) create mode 100644 workflows/utils/initial_state_splitter.py diff --git a/bin/pds-run-case.sbatch b/bin/pds-run-case.sbatch index 8b46ddaf..60f45276 100755 --- a/bin/pds-run-case.sbatch +++ b/bin/pds-run-case.sbatch @@ -84,6 +84,11 @@ for f in workflow.ymmsl workflow_settings.ymmsl scenario_settings.ymmsl preproce [[ -f "$CASE_DIR/$f" ]] && stack+=("$CASE_DIR/$f") done +# sbatch forwards the submitter's environment. A NoMachine desktop session preloads +# VirtualGL (LD_PRELOAD=libdlfaker.so:libvglfaker.so) into every process; on the compute +# node that leaves MATLAB's Simulink engine start hung until the 10 min peer timeout. +unset LD_PRELOAD VGL_ISACTIVE VGL_DISPLAY DISPLAY + [[ -f "$CASE_DIR/env.sh" ]] && source "$CASE_DIR/env.sh" "$MANAGER" --start-all --run-dir "$RUN_DIR" "${stack[@]}" "${OVERLAYS[@]}" diff --git a/workflows/lib/easybuild_programs.ymmsl b/workflows/lib/easybuild_programs.ymmsl index 9512350c..f08224bc 100644 --- a/workflows/lib/easybuild_programs.ymmsl +++ b/workflows/lib/easybuild_programs.ymmsl @@ -23,6 +23,11 @@ programs: executable: python args: -u $PDS_REPO/workflows/utils/temporal_coupler.py + initial_state_splitter: + modules: IMAS-MUSCLE3/1.0.0-intel-2025b-pds + executable: python + args: -u $PDS_REPO/workflows/utils/initial_state_splitter.py + metis: script: | #!/bin/bash diff --git a/workflows/metis_nice_evol_from_dina/README.md b/workflows/metis_nice_evol_from_dina/README.md index b15ce205..257c1677 100644 --- a/workflows/metis_nice_evol_from_dina/README.md +++ b/workflows/metis_nice_evol_from_dina/README.md @@ -14,15 +14,19 @@ free-boundary solver), which returns the updated `equilibrium` for METIS's next as `evolutive_controller`'s `torax`/`nice_evo_rd` pair. Unlike TORAX, METIS also needs a fresh `pulse_schedule`/`core_profiles`/`core_sources` boundary condition every internal step (its own actuator/current-drive input), supplied by `synchro_nice_metis` re-slicing METIS's own -DINA-derived input trace at NICE's current equilibrium timestamp. +DINA-derived input trace at NICE's current equilibrium timestamp. `metis_to_nice` +(`initial_state_splitter`) sits between `metis` and `nice_evo_rd`: METIS's actor emits N+1 +messages on its O_I ports (its initial state right after F_INIT, then one per step), while +`nice_evo_rd` wants the initial state on its own F_INIT and only the N per-step messages on +S, so `metis_to_nice` splits the stream accordingly. Ported from `itergit/feature/metis_nice_evol`'s `workflows/metis_predictive_nice_evol_from_dina/workflow.ymmsl.template` ("first version of the workflow with METIS + NICE evol + Controller" and its follow-up port/conduit fixes) into this -repo's current `workflow.ymmsl` + `settings.ymmsl` + `cases/overrides/` structure. That branch -also has a `workflow_alternative.ymmsl.template` (two METIS instances -- a one-shot -`metis_init` bootstrap plus the in-loop `metis`) which was not ported; the single-metis -arrangement here is the branch's own latest committed direction. +repo's current `workflow.ymmsl` + `settings.ymmsl` + `cases/overrides/` structure. The +single-metis arrangement is used, with `metis_to_nice` (`initial_state_splitter`) routing +METIS's initial O_I state to `nice_evo_rd`'s F_INIT and its later states to its S ports (see +above). Structure lives in `workflow.ymmsl`; shared knobs in `settings.ymmsl`; per-shot bootstrap timing (which DINA slice to start from has no sane generic default) in @@ -42,7 +46,14 @@ sbatch bin/pds-run-case.sbatch cases/metis_nice_evol_from_dina_105073 `source_nice`'s DINA-derived machine description (same dual build as `metis_nice_inverse_from_dina`), once, into `$CASE_DIR/preprocess/`. -## Known issue -- not yet run end to end +## Notes + +First end-to-end run on 2026-09-04 (shot 105084) found two blockers, both fixed: (1) a +NoMachine desktop session forwards `LD_PRELOAD=libdlfaker.so:libvglfaker.so` (VirtualGL) +through sbatch into every actor; on a compute node that hangs the Simulink engine start of +`magnetic_controller` until MUSCLE3's 10-minute peer timeout -- `bin/pds-run-case.sbatch` now +unsets it (same as upstream commit 855808f); (2) the METIS initial O_I message ending NICE's +time loop after one step -- fixed by `metis_to_nice` (see above). **`synchro_nice_metis` uses `f_init`/`o_f`, not the branch's own latest (`s`/`o_i`) commit.** The branch's final "change of port mode" commit (made the same day as the rest of this @@ -93,5 +104,4 @@ requirements). ## Output `sink_transport`, `sink_equilibrium`, and `sink_control` write METIS's evolved profiles, NICE's -evolutive equilibrium/pf_active, and the controller's corrected pf_active, respectively. Not -yet exercised end to end -- see Known issue above. +evolutive equilibrium/pf_active, and the controller's corrected pf_active, respectively. diff --git a/workflows/metis_nice_evol_from_dina/preprocess.sh b/workflows/metis_nice_evol_from_dina/preprocess.sh index 6cf72603..622d2ad5 100755 --- a/workflows/metis_nice_evol_from_dina/preprocess.sh +++ b/workflows/metis_nice_evol_from_dina/preprocess.sh @@ -61,6 +61,4 @@ if [[ -f "$OUT/PSI_OFFSET" ]]; then source "$OUT/PSI_OFFSET" printf 'ymmsl_version: v0.2\nsettings:\n metis.metis_psioffset: %s\n' "$PSI_OFFSET" \ > "$CASE_DIR/preprocess_settings.ymmsl" - printf ' metis_init.metis_psioffset: %s\n' "$PSI_OFFSET" \ - >> "$CASE_DIR/preprocess_settings.ymmsl" fi diff --git a/workflows/metis_nice_evol_from_dina/settings.ymmsl b/workflows/metis_nice_evol_from_dina/settings.ymmsl index e24c6a26..0a4fc316 100644 --- a/workflows/metis_nice_evol_from_dina/settings.ymmsl +++ b/workflows/metis_nice_evol_from_dina/settings.ymmsl @@ -23,6 +23,7 @@ resources: metis_nice_evol_from_dina.source_metis: {threads: 1} metis_nice_evol_from_dina.synchro_nice_metis: {threads: 1} metis_nice_evol_from_dina.metis: {threads: 10} + metis_nice_evol_from_dina.metis_to_nice: {threads: 1} metis_nice_evol_from_dina.nice_evo_rd: {threads: 2} metis_nice_evol_from_dina.magnetic_controller: {threads: 1} metis_nice_evol_from_dina.sink_transport: {threads: 1} @@ -42,8 +43,6 @@ settings: sink_transport.sink_uri: "imas:hdf5?path=../../../metis_out" sink_transport.sink_mode: "w" - sink_metis_init.sink_uri: "imas:hdf5?path=../../../metis_init_out" - sink_metis_init.sink_mode: "w" sink_equilibrium.sink_uri: "imas:hdf5?path=../../../nice_out" sink_equilibrium.sink_mode: "w" sink_control.sink_uri: "imas:hdf5?path=../../../controller_out" @@ -56,16 +55,13 @@ settings: metis.metis_psioffset: 9.0 metis.metis_alwaysreadinitport: 0 - metis_init.metis_computation: 1 - # Fallback only -- preprocess.sh's own MATLAB build computes this shot's real value from its - # DINA equilibrium and overrides it via preprocess_settings.ymmsl (see that script). Matters - # here because NICE's evolutive free-boundary solve carries psi_LCFS forward from METIS. - metis_init.metis_psioffset: 9.0 - metis_init.metis_alwaysreadinitport: 0 - nice_evo_rd.xml_path: ${PDS_REPO}/workflows/metis_nice_evol_from_dina/config_nice.xml nice_evo_rd.t_interval: 0.002 nice_evo_rd.dt: 0.002 + # Optional end of NICE's own time loop. Without it nothing ends the run: NICE's + # next_timestamp chain is circular (nice_evo_rd -> synchro_nice_metis -> metis -> + # metis_to_nice -> nice_evo_rd), so the run only stops at the SLURM time limit. + # nice_evo_rd.t_end: 2.5 # METIS switches for reading external data from the received IDSs -- predictive defaults, # all off. See cases/overrides/metis_nice_evol_from_dina_.ymmsl for an interpretative diff --git a/workflows/metis_nice_evol_from_dina/workflow.ymmsl b/workflows/metis_nice_evol_from_dina/workflow.ymmsl index acba388e..0b7da468 100644 --- a/workflows/metis_nice_evol_from_dina/workflow.ymmsl +++ b/workflows/metis_nice_evol_from_dina/workflow.ymmsl @@ -7,9 +7,11 @@ ymmsl_version: v0.2 # Ported from itergit/feature/metis_nice_evol's # workflows/metis_predictive_nice_evol_from_dina/workflow.ymmsl.template ("first version of # the workflow with METIS + NICE evol + Controller" and its follow-up port/conduit fixes). -# That branch also has a workflow_alternative.ymmsl.template (two METIS instances -- a -# one-shot "metis_init" bootstrap plus the in-loop "metis") which was not ported: the -# single-metis arrangement below is the branch's own latest committed direction. +# The single-metis arrangement below is used, with metis_to_nice (initial_state_splitter) +# routing METIS's initial O_I state to nice_evo_rd's F_INIT and its later states to +# nice_evo_rd's S -- because METIS's actor emits N+1 O_I messages (its initial state first, +# with next_timestamp None) while nice_evo_rd's evolutive actor expects N messages on S and +# stops its time loop on the first one without a next_timestamp. # # Structurally this is metis_nice_inverse_from_dina's METIS half spliced onto # evolutive_controller's NICE-evo/magnetic_controller loop (see those two workflows' @@ -33,6 +35,7 @@ imports: - from lib.local_programs import implementation metis - from lib.easybuild_programs import implementation nice_evo_rd - from lib.easybuild_programs import implementation magnetic_controller +- from lib.easybuild_programs import implementation initial_state_splitter models: metis_nice_evol_from_dina: @@ -66,12 +69,12 @@ models: s: [equilibrium_in_s, pulse_schedule_in_s, core_profiles_in_s, core_sources_in_s] o_i: [equilibrium_out_i, pulse_schedule_out_i, core_profiles_out_i, plasma_profiles_out_i, plasma_sources_out_i, summary_out_i] - metis_init: - description: METIS transport (coupling mode 2), stepping in lockstep with nice_evo_rd - implementation: metis + metis_to_nice: + description: splits METIS's O_I stream -- its initial state to nice_evo_rd's F_INIT, its per-step states to nice_evo_rd's S ports + implementation: initial_state_splitter ports: - f_init: [pulse_schedule_f_init, core_profiles_f_init, core_sources_f_init] - o_f: [equilibrium_out_f, pulse_schedule_out_f, core_profiles_out_f, plasma_profiles_out_f, plasma_sources_out_f, summary_out_f] + s: [equilibrium_in, core_profiles_in] + o_i: [equilibrium_init_out, equilibrium_out, core_profiles_init_out, core_profiles_out] nice_evo_rd: description: nice evolutive free-boundary + resistive-diffusion solve @@ -94,11 +97,6 @@ models: implementation: sink_component ports: {f_init: [equilibrium_in, pulse_schedule_in, plasma_profiles_in, plasma_sources_in, summary_in]} - sink_metis_init: - description: sink -- METIS init outputs - implementation: sink_component - ports: {f_init: [equilibrium_in, pulse_schedule_in, plasma_profiles_in, plasma_sources_in, summary_in]} - sink_equilibrium: description: sink -- NICE evolutive outputs implementation: sink_component @@ -115,11 +113,6 @@ models: source_metis.core_profiles_out: metis.core_profiles_f_init source_metis.core_sources_out: metis.core_sources_f_init - # METIS init bootstrap from source - source_metis.pulse_schedule_out: metis_init.pulse_schedule_f_init - source_metis.core_profiles_out: metis_init.core_profiles_f_init - source_metis.core_sources_out: metis_init.core_sources_f_init - # METIS in-loop inputs from the NICE-equilibrium-driven resync synchro_nice_metis.pulse_schedule_out: metis.pulse_schedule_in_s synchro_nice_metis.core_profiles_out: metis.core_profiles_in_s @@ -132,13 +125,6 @@ models: metis.summary_out_i: sink_transport.summary_in metis.equilibrium_out_i: sink_transport.equilibrium_in - # METIS init outputs to sink - metis_init.pulse_schedule_out_f: sink_metis_init.pulse_schedule_in - metis_init.plasma_profiles_out_f: sink_metis_init.plasma_profiles_in - metis_init.plasma_sources_out_f: sink_metis_init.plasma_sources_in - metis_init.summary_out_f: sink_metis_init.summary_in - metis_init.equilibrium_out_f: sink_metis_init.equilibrium_in - # synchronisation between METIS source and NICE one source_nice.pf_active_out: source_metis.pf_active_in @@ -147,12 +133,18 @@ models: source_nice.pf_passive_out: nice_evo_rd.pf_passive_f_init source_nice.iron_core_out: nice_evo_rd.iron_core_f_init source_nice.wall_out: nice_evo_rd.wall_f_init - metis_init.equilibrium_out_f: nice_evo_rd.equilibrium_f_init - metis_init.core_profiles_out_f: nice_evo_rd.core_profiles_f_init - # NICE in-loop input from METIS - metis.equilibrium_out_i: nice_evo_rd.equilibrium_s - metis.core_profiles_out_i: nice_evo_rd.core_profiles_s + # METIS -> NICE through the initial-state splitter: METIS's actor emits its initial + # state on its O_I ports right after F_INIT (next_timestamp None), then one state per + # step; NICE's evolutive actor wants the former on F_INIT and only the latter on S + # (it stops its time loop on the first S message without a next_timestamp). + metis.equilibrium_out_i: metis_to_nice.equilibrium_in + metis.core_profiles_out_i: metis_to_nice.core_profiles_in + metis_to_nice.equilibrium_init_out: nice_evo_rd.equilibrium_f_init + metis_to_nice.core_profiles_init_out: nice_evo_rd.core_profiles_f_init + metis_to_nice.equilibrium_out: nice_evo_rd.equilibrium_s + metis_to_nice.core_profiles_out: nice_evo_rd.core_profiles_s + # NICE in-loop input from the controller magnetic_controller.pf_active_out_i: nice_evo_rd.pf_active_s diff --git a/workflows/utils/initial_state_splitter.py b/workflows/utils/initial_state_splitter.py new file mode 100644 index 00000000..8ca82ce6 --- /dev/null +++ b/workflows/utils/initial_state_splitter.py @@ -0,0 +1,88 @@ +""" +MUSCLE3 actor splitting a per-step IDS stream into an initial message and a +per-step tail. + +Some MUSCLE3 actors (e.g. metis4muscle3, driven by imas_muscle3's +source_component conventions) emit their initial state on their O_I ports +right after F_INIT -- before reading any S input -- and then one further +state per step, so their O_I stream carries N+1 messages, the first with +next_timestamp=None. A peer actor with a genuine time loop (e.g. +nice_imas_evo_rd_muscle3's `while(has_t_next)`) instead expects the initial +state on its own F_INIT ports and exactly one message per step on its S +ports, and stops its time loop as soon as an S message arrives without a +next_timestamp -- so wiring the two directly makes the peer consume the +initial message as its first step and exit early. + +This actor sits in between: for each connected IDS channel, it forwards the +first message received on `_in` to `_init_out` (wire to +the peer's F_INIT), and every following message to `_out` (wire to +the peer's S), unchanged. +""" + +import logging + +from libmuscle import Instance, InstanceFlags +from ymmsl import Operator + +logger = logging.getLogger() + +CHANNELS = [ + "equilibrium", + "core_profiles", + "pf_active", + "core_sources", + "wall", + "pf_passive", + "iron_core", + "plasma_profiles", + "plasma_sources", + "pulse_schedule", + "summary", +] + + +def main() -> None: + ports = { + Operator.S: [f"{channel}_in" for channel in CHANNELS], + Operator.O_I: ( + [f"{channel}_init_out" for channel in CHANNELS] + + [f"{channel}_out" for channel in CHANNELS] + ), + } + instance = Instance(ports, InstanceFlags.SKIP_MMSF_SEQUENCE_CHECKS) + + active = [c for c in CHANNELS if instance.is_connected(f"{c}_in")] + for channel in CHANNELS: + for out_suffix in ("_init_out", "_out"): + port = f"{channel}{out_suffix}" + if instance.is_connected(port) and channel not in active: + raise RuntimeError( + f"'{port}' is connected but '{channel}_in' is not -- " + "an output channel needs its matching input wired." + ) + logger.info("active channels: %s", active) + + while instance.reuse_instance(): + logger.info("receiving initial state on: %s", active) + for channel in active: + msg = instance.receive(f"{channel}_in") + if instance.is_connected(f"{channel}_init_out"): + instance.send(f"{channel}_init_out", msg) + + logger.info("streaming per-step state on: %s", active) + running = list(active) + while running: + for channel in list(running): + msg = instance.receive(f"{channel}_in") + if instance.is_connected(f"{channel}_out"): + instance.send(f"{channel}_out", msg) + if msg.next_timestamp is None: + running.remove(channel) + + +if __name__ == "__main__": + logging.basicConfig( + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + level=logging.INFO, + ) + main() From b97cc3e255081e955694212a4bfd64a4b32c5d51 Mon Sep 17 00:00:00 2001 From: Mireille Schneider Date: Sat, 5 Sep 2026 19:50:36 +0200 Subject: [PATCH 04/12] metis_nice_evol: fix controller references (DINA flux, geometric axis, NICE voltage sign) and METIS inputs to NICE --- .../metis_nice_evol_from_dina_105084.ymmsl | 16 +++++++++ controllers/KCURR_RZIp/muscle_NICE_input.m | 26 ++++++++++++++ .../muscle_controller_NICE_IMAS_iter_init.m | 34 +++++++++++++++++++ workflows/lib/easybuild_programs.ymmsl | 6 ++++ workflows/metis_nice_evol_from_dina/README.md | 4 +-- .../metis_nice_evol_from_dina/config_nice.xml | 14 ++++++-- .../metis_nice_evol_from_dina/settings.ymmsl | 25 +++++++++++++- .../make_metis_from_dina_interpretative.m | 6 +++- workflows/utils/preprocess_dina.py | 17 ++++++++++ 9 files changed, 140 insertions(+), 8 deletions(-) create mode 100644 cases/overrides/metis_nice_evol_from_dina_105084.ymmsl diff --git a/cases/overrides/metis_nice_evol_from_dina_105084.ymmsl b/cases/overrides/metis_nice_evol_from_dina_105084.ymmsl new file mode 100644 index 00000000..dd1449a7 --- /dev/null +++ b/cases/overrides/metis_nice_evol_from_dina_105084.ymmsl @@ -0,0 +1,16 @@ +ymmsl_version: v0.2 + +# Per-shot override for metis_nice_evol_from_dina, shot 105084 (ramp-up, t_min 2.4176 s). +# +# Interpretative METIS: Te, Ti, ne and Zeff are read from the DINA-derived dataset instead +# of being predicted. In predictive mode METIS's 0D energy balance is fed by the ohmic power +# it derives from the external current diffusion (NICE's psi evolution, every 2 ms), and the +# small step-to-step wobble of NICE's psi turns into ohmic powers of -50..+65 MW; Te then +# swings between 0.7 and 9 keV from one exchange to the next, the conductivity NICE +# receives changes by a factor 50 per step and NICE's resistive diffusion diverges after +# ~20 steps (2026-09-04). DINA's Te at this time is ~0.4 keV. +settings: + metis.metis_external_data_electron_temperature: 1 + metis.metis_external_data_ion_temperature: 1 + metis.metis_external_data_electron_density: 1 + metis.metis_external_data_charge_effective: 1 diff --git a/controllers/KCURR_RZIp/muscle_NICE_input.m b/controllers/KCURR_RZIp/muscle_NICE_input.m index 5cb4f191..163782ee 100644 --- a/controllers/KCURR_RZIp/muscle_NICE_input.m +++ b/controllers/KCURR_RZIp/muscle_NICE_input.m @@ -11,6 +11,18 @@ function muscle_NICE_input(t_cur,voltage,coil_current) Vmax=[45000.0 45000.0 45000.0 45000.0 45000.0 45000.0 48000.0 55000.0 55000.0 55000.0 55000.0 22500.0 48000.0 60000.0]; Vmin=[-45000.0 -45000.0 -45000.0 -45000.0 -45000.0 -45000.0 -48000.0 -55000.0 -55000.0 -55000.0 -55000.0 -22500.0 -48000.0 -60000.0]; +% Optional tighter symmetric clamp on the commanded coil voltages (V), e.g. the ITER +% main-converter rating (~1.35 kV) instead of the coil terminal limits above. Set the +% environment variable NICE_VOLTAGE_LIMIT to enable; unset means the limits above. +% Motivation: at start-up the current loop reacts to a ~2% mismatch between NICE's +% inverse currents and the DINA references with commands at the +-45 kV limits for +% two 2 ms steps, a flux kick that threw NICE's evolutive solve into NaN (2026-09-04). +nice_voltage_limit = str2double(getenv('NICE_VOLTAGE_LIMIT')); +if ~isnan(nice_voltage_limit) && nice_voltage_limit > 0 + Vmax = min(Vmax, nice_voltage_limit); + Vmin = max(Vmin, -nice_voltage_limit); +end + resistances= [0.0005 0.0005 0.0005 0.0005 0.0005 0.0005 0.0005 0.0005 0.0005 0.0005 0.0005 0.0005 0.0057 0.00791]; % KCURR_PFPO1's CSPF vector is 11-wide, sorted CS3U CS2U CS1 CS2L CS3L @@ -39,6 +51,20 @@ function muscle_NICE_input(t_cur,voltage,coil_current) voltage=max(voltage,Vmin); voltage=min(voltage,Vmax); +% Sign convention adapter towards NICE. NICE/3.0.0.dev258's evolutive actor flips +% coil currents to its internal (Ip>0) convention but applies the received IDS +% voltages unflipped (ReadDataEvolutiveProblemWithRD: `signIc = 1`, the +% change_coil_sign branch is commented out), so the IDS current it returns moves +% opposite to the IDS voltage we send -- an inverted plant for this controller +% (confirmed on all 11 coils, 2026-09-04, shot 105084). NICE_VOLTAGE_SIGN=-1 in the +% magnetic_controller launch script compensates; set it to 1 (or unset) once NICE +% converts voltages with the same COCOS sign as currents. +nice_voltage_sign = str2double(getenv('NICE_VOLTAGE_SIGN')); +if isnan(nice_voltage_sign) + nice_voltage_sign = 1; +end +voltage = nice_voltage_sign * voltage; + pfa = ids_init('pf_active'); pfa.ids_properties.homogeneous_time = 1; pfa.time = [t_cur]; diff --git a/controllers/KCURR_RZIp/muscle_controller_NICE_IMAS_iter_init.m b/controllers/KCURR_RZIp/muscle_controller_NICE_IMAS_iter_init.m index b2837697..249f082b 100644 --- a/controllers/KCURR_RZIp/muscle_controller_NICE_IMAS_iter_init.m +++ b/controllers/KCURR_RZIp/muscle_controller_NICE_IMAS_iter_init.m @@ -96,10 +96,16 @@ ip_ref = zeros(1,n_slices); rgeo_ref = zeros(1,n_slices); zgeo_ref = zeros(1,n_slices); + % DINA never fills boundary.geometric_axis.z (only .r); this loop uses + % zgeo_ref as the RZIp controller's vertical-position reference, and + % receiving IMAS's empty-float sentinel (-9e40) here previously saturated + % all coil voltages to about -45 kV (found 2026-09-04, shot 105084). for i=1:n_slices ip_ref(i) = abs(equilibrium.time_slice{i}.global_quantities.ip); rgeo_ref(i) = equilibrium.time_slice{i}.boundary.geometric_axis.r; zgeo_ref(i) = equilibrium.time_slice{i}.boundary.geometric_axis.z; + rgeo_ref(i) = geo_ref_with_fallback(rgeo_ref(i), equilibrium.time_slice{i}, 'r', logger); + zgeo_ref(i) = geo_ref_with_fallback(zgeo_ref(i), equilibrium.time_slice{i}, 'z', logger); end enable_KCURR=1; @@ -143,3 +149,31 @@ fprintf(['\nControl over! ' num2str(simulation_time) 's of simulation for ' num2str(plasma_duration) 's of plasma.\n']); end + +function value = geo_ref_with_fallback(value, ts, field, logger) + % Recompute boundary.geometric_axis.(field) from the boundary outline + % midpoint when the primary value is empty or carries IMAS's + % empty-float sentinel (abs > 1e30). If the outline is itself empty, + % fall back further to global_quantities.magnetic_axis.(field). Warns + % only the first time any fallback is used across the whole loop. + persistent warned + if isempty(warned) + warned = false; + end + if isempty(value) || abs(value) > 1e30 + outline_field = ts.boundary.outline.(field); + if ~isempty(outline_field) + value = (max(outline_field) + min(outline_field)) / 2; + fallback = 'outline midpoint'; + else + value = ts.global_quantities.magnetic_axis.(field); + fallback = 'magnetic_axis'; + end + if ~warned + logger.warning(sprintf( ... + ['muscle_controller_NICE_IMAS_iter_init: boundary.geometric_axis.%s ' ... + 'was empty/sentinel, falling back to %s'], field, fallback)); + warned = true; + end + end +end diff --git a/workflows/lib/easybuild_programs.ymmsl b/workflows/lib/easybuild_programs.ymmsl index f08224bc..5cdf29df 100644 --- a/workflows/lib/easybuild_programs.ymmsl +++ b/workflows/lib/easybuild_programs.ymmsl @@ -107,6 +107,12 @@ programs: # by hand, pointing at a per-user run/pcs checkout. export PCS_PATH="$EBROOTPCS" export CONTROLLER_PATH="$controller_path" + # NICE/3.0.0.dev258's evolutive actor applies received IDS coil voltages without + # the COCOS sign flip it applies to coil currents (nice_imas.cc, + # ReadDataEvolutiveProblemWithRD: `signIc = 1`, flip commented out), so the plant the + # controller sees is inverted. muscle_NICE_input.m negates the voltages it sends when + # this is -1. Set to 1 / remove once NICE converts voltages like currents. + export NICE_VOLTAGE_SIGN=-1 # NICE's own runmatlab/muscle3/matlab_iter/ (added to the path by # initNice4matlab.m below) ships its own, much older, # different-port-signature muscle_controller_NICE_IMAS_iter_init.m, which diff --git a/workflows/metis_nice_evol_from_dina/README.md b/workflows/metis_nice_evol_from_dina/README.md index 257c1677..27cfc4b0 100644 --- a/workflows/metis_nice_evol_from_dina/README.md +++ b/workflows/metis_nice_evol_from_dina/README.md @@ -90,9 +90,7 @@ the static checker doesn't validate operator pairing across conduits. - `nice_evo_rd.t_interval`/`nice_evo_rd.dt` default to `0.002`, matching the branch's own template (comment: "Need to match controller dt") -- not validated for solver stability here, same caveat as `evolutive_controller`'s `0.01` default. -- `config_nice.xml` is the branch's own `param.xml.template` (verbatim, no placeholders to - resolve) -- it differs from `evolutive_controller/config_nice.xml` in `algoMode` (21 vs 31) - and several mesh-refinement parameters; not reconciled against that file. +- `config_nice.xml` is the branch's own `param.xml.template` with `use_desired_psib` 1 and `abserrIg` 3e5 (see the comment there); it differs from `evolutive_controller/config_nice.xml` in `algoMode` (21 vs 31), mesh refinement and profile degrees of freedom -- the latter was tried on 2026-09-04 and NICE's evolutive solve diverged at its third step with it, so the branch's config is kept. ## Input requirements diff --git a/workflows/metis_nice_evol_from_dina/config_nice.xml b/workflows/metis_nice_evol_from_dina/config_nice.xml index 3642364c..0b6d1e07 100644 --- a/workflows/metis_nice_evol_from_dina/config_nice.xml +++ b/workflows/metis_nice_evol_from_dina/config_nice.xml @@ -267,10 +267,18 @@ -3.346 96 - 0 - + + 1 + - 3e7 + 3e5 0 14 0 1 2 2 3 4 5 6 7 8 9 10 11 12 diff --git a/workflows/metis_nice_evol_from_dina/settings.ymmsl b/workflows/metis_nice_evol_from_dina/settings.ymmsl index 0a4fc316..765fbe2f 100644 --- a/workflows/metis_nice_evol_from_dina/settings.ymmsl +++ b/workflows/metis_nice_evol_from_dina/settings.ymmsl @@ -40,6 +40,11 @@ settings: source_metis.t_min: 0.0 synchro_nice_metis.source_uri: "imas:hdf5?path=${CASE_DIR}/preprocess/metis_in" + # The METIS input dataset has slices ~0.11 s apart while the loop runs every 2 ms; with the + # default 'closest' pick METIS's boundary conditions (and, in interpretative mode, Te/ne) + # jump by ~15% every ~55 steps, and NICE's evolutive Newton solve diverged exactly at the + # first such jump (2026-09-04). Interpolate linearly in time instead. + synchro_nice_metis.interpolation_method: linear sink_transport.sink_uri: "imas:hdf5?path=../../../metis_out" sink_transport.sink_mode: "w" @@ -65,7 +70,7 @@ settings: # METIS switches for reading external data from the received IDSs -- predictive defaults, # all off. See cases/overrides/metis_nice_evol_from_dina_.ymmsl for an interpretative - # shot. + # shot. The METIS runaway model is switched off via metis_option_runaway, see below. metis.metis_external_data_equilibrium: 1 metis.metis_external_data_current_diffusion: 1 metis.metis_external_data_electron_temperature: 0 @@ -74,10 +79,28 @@ settings: metis.metis_external_data_charge_effective: 0 metis.metis_external_data_momentum: 0 metis.metis_external_data_LHCD: 0 + # METIS's ECCD deposition is a near-delta on axis (width_ecrh=0 -> internal formula) that + # its 21-point radial grid cannot resolve: 60-75 kA of driven current show up as a + # j_non_inductive spike of 1.6-2.6 MA/m2 (several times j_total), forcing a locally + # reversed ohmic current, an ohmic power of -29 MW at one step and a Te collapse + # 6 keV -> 0.8 keV within 6 ms; NICE's resistive diffusion then diverges (2026-09-04). + # The METIS-from-DINA dataset carries the same spike, so reading it back does not help; + # EC is heating-only here (DINA drives <10 kA with it): metis_option_sens 0 = normal + # injection, no driven current. metis.metis_external_data_ECRH: 0 metis.metis_external_data_ICRH: 0 metis.metis_external_data_NBI: 0 metis.metis_external_data_PLINE: 0 + # METIS's own runaway-electron model (runaway=1 in the METIS dataset options) predicts + # >50% of Ip as runaway current at this low-density ramp-up point; NICE's resistive + # diffusion takes j_non_inductive as a fixed driven current and diverged to NaN in 5 + # steps (2026-09-04). Reading DINA's runaway current instead (metis_external_data_runaway + # 1) only applies from the first S exchange, not at METIS's initialisation, so METIS's + # initial state (q0 ~ 0.5, j_ni ~ 2 MA/m2) and its first evolved state (q0 ~ 2) were + # inconsistent and NICE failed at its second step. DINA's runaway current here is ~37 kA + # (3% of Ip): switch the model off in both phases instead. metis.metis_external_data_runaway: 0 + metis.metis_option_runaway: 0 + metis.metis_option_sens: 0 muscle_remote_log_level: 'INFO' diff --git a/workflows/metis_nice_utils/make_metis_from_dina_interpretative.m b/workflows/metis_nice_utils/make_metis_from_dina_interpretative.m index 093368ba..d60dd03b 100644 --- a/workflows/metis_nice_utils/make_metis_from_dina_interpretative.m +++ b/workflows/metis_nice_utils/make_metis_from_dina_interpretative.m @@ -6,5 +6,9 @@ dina_file = getenv('metis_dina_source'); metis_file = getenv('metis_imas_dataset'); metis_ref = fullfile(p,'certification','metis','reference_NTM_ITER.mat'); -prepare_IDS4METIS_from_dina(dina_file, metis_file,metis_ref,'interpretative',51); +% 301 time slices (~0.1 s apart early in the pulse) instead of 51 (~0.7 s): the +% workflow's source_metis/synchro_nice_metis pick the CLOSEST slice to NICE's time, and +% with 51 slices the METIS bootstrap at 2.42 s used the 2.61 s slice (Ip 1.44 MA vs +% DINA's 1.27 MA), a 12% Ip error the magnetic controller then fought from step one. +prepare_IDS4METIS_from_dina(dina_file, metis_file,metis_ref,'interpretative',301); \ No newline at end of file diff --git a/workflows/utils/preprocess_dina.py b/workflows/utils/preprocess_dina.py index cbe3f800..b4f180b9 100644 --- a/workflows/utils/preprocess_dina.py +++ b/workflows/utils/preprocess_dina.py @@ -62,6 +62,23 @@ def write_dina_data(db_out, db_in, db_sum, db_md_pf_active, n_timeslices): # profiles_1d.psi: 0..0.995 - 0..1 # boundary: 0.995 - 1 # boundary_separatrix: 1 - na + # DINA fills boundary.geometric_axis.r but never .z; the PCSSP magnetic + # controller uses geometric_axis.z as its vertical-position reference and + # otherwise reads IMAS's empty-float sentinel (-9e40). Take the midpoints of + # the ORIGINAL closed boundary outline (psi_norm 0.995 surface; its r midpoint + # matches DINA's own geometric_axis.r to 1e-3) before it is replaced below by + # the separatrix outline, whose open divertor legs (z down to -6 m) would put + # the midpoint 1.5 m too low on diverted slices. + r_out = np.asarray(eq_orig_ts.boundary.outline.r) + z_out = np.asarray(eq_orig_ts.boundary.outline.z) + if r_out.size == 0: + r_out = np.asarray(eq_orig_ts.boundary_separatrix.outline.r) + z_out = np.asarray(eq_orig_ts.boundary_separatrix.outline.z) + if r_out.size: + if not eq_orig_ts.boundary.geometric_axis.r.has_value: + eq_orig_ts.boundary.geometric_axis.r = (r_out.min() + r_out.max()) / 2 + if not eq_orig_ts.boundary.geometric_axis.z.has_value: + eq_orig_ts.boundary.geometric_axis.z = (z_out.min() + z_out.max()) / 2 eq_orig_ts.boundary.psi = eq_orig_ts.boundary_separatrix.psi eq_orig_ts.boundary.outline.r = eq_orig_ts.boundary_separatrix.outline.r eq_orig_ts.boundary.outline.z = eq_orig_ts.boundary_separatrix.outline.z From f5d5043ca81868fb0fd0ce6cece7a45f1f1c6ea8 Mon Sep 17 00:00:00 2001 From: Mireille Schneider Date: Tue, 8 Sep 2026 10:30:06 +0200 Subject: [PATCH 05/12] metis_nice_evol: restore two-METIS structure; metis_to_nice only filters METIS's init message; controller 4 threads --- workflows/metis_nice_evol_from_dina/README.md | 40 +++++++++------- .../metis_nice_evol_from_dina/preprocess.sh | 2 + .../metis_nice_evol_from_dina/settings.ymmsl | 20 ++++++-- .../metis_nice_evol_from_dina/workflow.ymmsl | 48 +++++++++++++------ workflows/utils/initial_state_splitter.py | 6 +++ 5 files changed, 80 insertions(+), 36 deletions(-) diff --git a/workflows/metis_nice_evol_from_dina/README.md b/workflows/metis_nice_evol_from_dina/README.md index 27cfc4b0..935f9c42 100644 --- a/workflows/metis_nice_evol_from_dina/README.md +++ b/workflows/metis_nice_evol_from_dina/README.md @@ -14,19 +14,23 @@ free-boundary solver), which returns the updated `equilibrium` for METIS's next as `evolutive_controller`'s `torax`/`nice_evo_rd` pair. Unlike TORAX, METIS also needs a fresh `pulse_schedule`/`core_profiles`/`core_sources` boundary condition every internal step (its own actuator/current-drive input), supplied by `synchro_nice_metis` re-slicing METIS's own -DINA-derived input trace at NICE's current equilibrium timestamp. `metis_to_nice` -(`initial_state_splitter`) sits between `metis` and `nice_evo_rd`: METIS's actor emits N+1 -messages on its O_I ports (its initial state right after F_INIT, then one per step), while -`nice_evo_rd` wants the initial state on its own F_INIT and only the N per-step messages on -S, so `metis_to_nice` splits the stream accordingly. +DINA-derived input trace at NICE's current equilibrium timestamp. + +`metis_to_nice` (`initial_state_splitter`) sits on the `metis` -> `nice_evo_rd` S path as a +drop-first filter. METIS's actor emits, during its initialisation turn, one O_I message +carrying its initial state (`next_timestamp` None, the same state `metis_init` writes on its +O_F ports) before the one-per-step evolved states; `nice_evo_rd`'s evolutive actor takes its +clock and its termination from every S message, so that first one would stop its time loop +after a single step. `metis_to_nice` forwards only the evolved states; `nice_evo_rd`'s F_INIT +is still bootstrapped by `metis_init`. Ported from `itergit/feature/metis_nice_evol`'s `workflows/metis_predictive_nice_evol_from_dina/workflow.ymmsl.template` ("first version of the workflow with METIS + NICE evol + Controller" and its follow-up port/conduit fixes) into this -repo's current `workflow.ymmsl` + `settings.ymmsl` + `cases/overrides/` structure. The -single-metis arrangement is used, with `metis_to_nice` (`initial_state_splitter`) routing -METIS's initial O_I state to `nice_evo_rd`'s F_INIT and its later states to its S ports (see -above). +repo's current `workflow.ymmsl` + `settings.ymmsl` + `cases/overrides/` structure. That branch +also has a `workflow_alternative.ymmsl.template` (two METIS instances -- a one-shot +`metis_init` bootstrap plus the in-loop `metis`) which was not ported; the single-metis +arrangement here is the branch's own latest committed direction. Structure lives in `workflow.ymmsl`; shared knobs in `settings.ymmsl`; per-shot bootstrap timing (which DINA slice to start from has no sane generic default) in @@ -46,14 +50,15 @@ sbatch bin/pds-run-case.sbatch cases/metis_nice_evol_from_dina_105073 `source_nice`'s DINA-derived machine description (same dual build as `metis_nice_inverse_from_dina`), once, into `$CASE_DIR/preprocess/`. -## Notes +`magnetic_controller` reserves 4 threads (MATLAB/Simulink), so the components no longer fit in +`bin/pds-run-case.sbatch`'s default 16 CPUs -- ask for more (`muscle3 resources -c 24` on the +case's flattened configuration confirms one node is enough): + +```bash +sbatch --cpus-per-task=24 bin/pds-run-case.sbatch cases/metis_nice_evol_from_dina_105073 +``` -First end-to-end run on 2026-09-04 (shot 105084) found two blockers, both fixed: (1) a -NoMachine desktop session forwards `LD_PRELOAD=libdlfaker.so:libvglfaker.so` (VirtualGL) -through sbatch into every actor; on a compute node that hangs the Simulink engine start of -`magnetic_controller` until MUSCLE3's 10-minute peer timeout -- `bin/pds-run-case.sbatch` now -unsets it (same as upstream commit 855808f); (2) the METIS initial O_I message ending NICE's -time loop after one step -- fixed by `metis_to_nice` (see above). +## Known issue -- not yet run end to end **`synchro_nice_metis` uses `f_init`/`o_f`, not the branch's own latest (`s`/`o_i`) commit.** The branch's final "change of port mode" commit (made the same day as the rest of this @@ -102,4 +107,5 @@ requirements). ## Output `sink_transport`, `sink_equilibrium`, and `sink_control` write METIS's evolved profiles, NICE's -evolutive equilibrium/pf_active, and the controller's corrected pf_active, respectively. +evolutive equilibrium/pf_active, and the controller's corrected pf_active, respectively. Not +yet exercised end to end -- see Known issue above. diff --git a/workflows/metis_nice_evol_from_dina/preprocess.sh b/workflows/metis_nice_evol_from_dina/preprocess.sh index 622d2ad5..6cf72603 100755 --- a/workflows/metis_nice_evol_from_dina/preprocess.sh +++ b/workflows/metis_nice_evol_from_dina/preprocess.sh @@ -61,4 +61,6 @@ if [[ -f "$OUT/PSI_OFFSET" ]]; then source "$OUT/PSI_OFFSET" printf 'ymmsl_version: v0.2\nsettings:\n metis.metis_psioffset: %s\n' "$PSI_OFFSET" \ > "$CASE_DIR/preprocess_settings.ymmsl" + printf ' metis_init.metis_psioffset: %s\n' "$PSI_OFFSET" \ + >> "$CASE_DIR/preprocess_settings.ymmsl" fi diff --git a/workflows/metis_nice_evol_from_dina/settings.ymmsl b/workflows/metis_nice_evol_from_dina/settings.ymmsl index 765fbe2f..2ca18425 100644 --- a/workflows/metis_nice_evol_from_dina/settings.ymmsl +++ b/workflows/metis_nice_evol_from_dina/settings.ymmsl @@ -23,10 +23,13 @@ resources: metis_nice_evol_from_dina.source_metis: {threads: 1} metis_nice_evol_from_dina.synchro_nice_metis: {threads: 1} metis_nice_evol_from_dina.metis: {threads: 10} + metis_nice_evol_from_dina.metis_init: {threads: 1} metis_nice_evol_from_dina.metis_to_nice: {threads: 1} metis_nice_evol_from_dina.nice_evo_rd: {threads: 2} - metis_nice_evol_from_dina.magnetic_controller: {threads: 1} + # Simulink controller: 4 threads + metis_nice_evol_from_dina.magnetic_controller: {threads: 4} metis_nice_evol_from_dina.sink_transport: {threads: 1} + metis_nice_evol_from_dina.sink_metis_init: {threads: 1} metis_nice_evol_from_dina.sink_equilibrium: {threads: 1} metis_nice_evol_from_dina.sink_control: {threads: 1} @@ -48,6 +51,8 @@ settings: sink_transport.sink_uri: "imas:hdf5?path=../../../metis_out" sink_transport.sink_mode: "w" + sink_metis_init.sink_uri: "imas:hdf5?path=../../../metis_init_out" + sink_metis_init.sink_mode: "w" sink_equilibrium.sink_uri: "imas:hdf5?path=../../../nice_out" sink_equilibrium.sink_mode: "w" sink_control.sink_uri: "imas:hdf5?path=../../../controller_out" @@ -60,13 +65,18 @@ settings: metis.metis_psioffset: 9.0 metis.metis_alwaysreadinitport: 0 + metis_init.metis_computation: 1 + # Fallback only -- preprocess.sh's own MATLAB build computes this shot's real value from its + # DINA equilibrium and overrides it via preprocess_settings.ymmsl (see that script). Matters + # here because NICE's evolutive free-boundary solve carries psi_LCFS forward from METIS. + metis_init.metis_psioffset: 9.0 + metis_init.metis_alwaysreadinitport: 0 + nice_evo_rd.xml_path: ${PDS_REPO}/workflows/metis_nice_evol_from_dina/config_nice.xml nice_evo_rd.t_interval: 0.002 nice_evo_rd.dt: 0.002 - # Optional end of NICE's own time loop. Without it nothing ends the run: NICE's - # next_timestamp chain is circular (nice_evo_rd -> synchro_nice_metis -> metis -> - # metis_to_nice -> nice_evo_rd), so the run only stops at the SLURM time limit. - # nice_evo_rd.t_end: 2.5 + # NICE/3.0.0.dev258's evolutive actor has no end-time setting of its own; the run ends + # through METIS's metis_clock_time_stop (default Inf, i.e. unset) or the SLURM time limit. # METIS switches for reading external data from the received IDSs -- predictive defaults, # all off. See cases/overrides/metis_nice_evol_from_dina_.ymmsl for an interpretative diff --git a/workflows/metis_nice_evol_from_dina/workflow.ymmsl b/workflows/metis_nice_evol_from_dina/workflow.ymmsl index 0b7da468..8280d590 100644 --- a/workflows/metis_nice_evol_from_dina/workflow.ymmsl +++ b/workflows/metis_nice_evol_from_dina/workflow.ymmsl @@ -7,11 +7,9 @@ ymmsl_version: v0.2 # Ported from itergit/feature/metis_nice_evol's # workflows/metis_predictive_nice_evol_from_dina/workflow.ymmsl.template ("first version of # the workflow with METIS + NICE evol + Controller" and its follow-up port/conduit fixes). -# The single-metis arrangement below is used, with metis_to_nice (initial_state_splitter) -# routing METIS's initial O_I state to nice_evo_rd's F_INIT and its later states to -# nice_evo_rd's S -- because METIS's actor emits N+1 O_I messages (its initial state first, -# with next_timestamp None) while nice_evo_rd's evolutive actor expects N messages on S and -# stops its time loop on the first one without a next_timestamp. +# That branch also has a workflow_alternative.ymmsl.template (two METIS instances -- a +# one-shot "metis_init" bootstrap plus the in-loop "metis") which was not ported: the +# single-metis arrangement below is the branch's own latest committed direction. # # Structurally this is metis_nice_inverse_from_dina's METIS half spliced onto # evolutive_controller's NICE-evo/magnetic_controller loop (see those two workflows' @@ -69,12 +67,19 @@ models: s: [equilibrium_in_s, pulse_schedule_in_s, core_profiles_in_s, core_sources_in_s] o_i: [equilibrium_out_i, pulse_schedule_out_i, core_profiles_out_i, plasma_profiles_out_i, plasma_sources_out_i, summary_out_i] + metis_init: + description: METIS transport (coupling mode 2), stepping in lockstep with nice_evo_rd + implementation: metis + ports: + f_init: [pulse_schedule_f_init, core_profiles_f_init, core_sources_f_init] + o_f: [equilibrium_out_f, pulse_schedule_out_f, core_profiles_out_f, plasma_profiles_out_f, plasma_sources_out_f, summary_out_f] + metis_to_nice: - description: splits METIS's O_I stream -- its initial state to nice_evo_rd's F_INIT, its per-step states to nice_evo_rd's S ports + description: "drops METIS's initialisation-turn message: METIS emits its initial state on its O_I ports before its first evolved state, and nice_evo_rd's S ports must only see evolved states" implementation: initial_state_splitter ports: s: [equilibrium_in, core_profiles_in] - o_i: [equilibrium_init_out, equilibrium_out, core_profiles_init_out, core_profiles_out] + o_i: [equilibrium_out, core_profiles_out] nice_evo_rd: description: nice evolutive free-boundary + resistive-diffusion solve @@ -97,6 +102,11 @@ models: implementation: sink_component ports: {f_init: [equilibrium_in, pulse_schedule_in, plasma_profiles_in, plasma_sources_in, summary_in]} + sink_metis_init: + description: sink -- METIS init outputs + implementation: sink_component + ports: {f_init: [equilibrium_in, pulse_schedule_in, plasma_profiles_in, plasma_sources_in, summary_in]} + sink_equilibrium: description: sink -- NICE evolutive outputs implementation: sink_component @@ -113,6 +123,11 @@ models: source_metis.core_profiles_out: metis.core_profiles_f_init source_metis.core_sources_out: metis.core_sources_f_init + # METIS init bootstrap from source + source_metis.pulse_schedule_out: metis_init.pulse_schedule_f_init + source_metis.core_profiles_out: metis_init.core_profiles_f_init + source_metis.core_sources_out: metis_init.core_sources_f_init + # METIS in-loop inputs from the NICE-equilibrium-driven resync synchro_nice_metis.pulse_schedule_out: metis.pulse_schedule_in_s synchro_nice_metis.core_profiles_out: metis.core_profiles_in_s @@ -125,6 +140,13 @@ models: metis.summary_out_i: sink_transport.summary_in metis.equilibrium_out_i: sink_transport.equilibrium_in + # METIS init outputs to sink + metis_init.pulse_schedule_out_f: sink_metis_init.pulse_schedule_in + metis_init.plasma_profiles_out_f: sink_metis_init.plasma_profiles_in + metis_init.plasma_sources_out_f: sink_metis_init.plasma_sources_in + metis_init.summary_out_f: sink_metis_init.summary_in + metis_init.equilibrium_out_f: sink_metis_init.equilibrium_in + # synchronisation between METIS source and NICE one source_nice.pf_active_out: source_metis.pf_active_in @@ -133,18 +155,16 @@ models: source_nice.pf_passive_out: nice_evo_rd.pf_passive_f_init source_nice.iron_core_out: nice_evo_rd.iron_core_f_init source_nice.wall_out: nice_evo_rd.wall_f_init + metis_init.equilibrium_out_f: nice_evo_rd.equilibrium_f_init + metis_init.core_profiles_out_f: nice_evo_rd.core_profiles_f_init - # METIS -> NICE through the initial-state splitter: METIS's actor emits its initial - # state on its O_I ports right after F_INIT (next_timestamp None), then one state per - # step; NICE's evolutive actor wants the former on F_INIT and only the latter on S - # (it stops its time loop on the first S message without a next_timestamp). + # NICE in-loop input from METIS, through metis_to_nice: METIS's initialisation turn + # emits its initial state on its O_I ports (next_timestamp None) before its first + # evolved state, and nice_evo_rd's evolutive time loop stops on such an S message. metis.equilibrium_out_i: metis_to_nice.equilibrium_in metis.core_profiles_out_i: metis_to_nice.core_profiles_in - metis_to_nice.equilibrium_init_out: nice_evo_rd.equilibrium_f_init - metis_to_nice.core_profiles_init_out: nice_evo_rd.core_profiles_f_init metis_to_nice.equilibrium_out: nice_evo_rd.equilibrium_s metis_to_nice.core_profiles_out: nice_evo_rd.core_profiles_s - # NICE in-loop input from the controller magnetic_controller.pf_active_out_i: nice_evo_rd.pf_active_s diff --git a/workflows/utils/initial_state_splitter.py b/workflows/utils/initial_state_splitter.py index 8ca82ce6..07e91dbd 100644 --- a/workflows/utils/initial_state_splitter.py +++ b/workflows/utils/initial_state_splitter.py @@ -17,6 +17,12 @@ first message received on `_in` to `_init_out` (wire to the peer's F_INIT), and every following message to `_out` (wire to the peer's S), unchanged. + +The `_init_out` ports are optional: a channel whose `_init_out` is +left unconnected simply has its first message dropped, so with none of them +wired the actor is a plain drop-first filter -- which is how +metis_nice_evol_from_dina uses it, its peer's F_INIT being bootstrapped by a +separate one-shot instance instead. """ import logging From e2d14bec523b2d7a9507c163bd7e17e583129554 Mon Sep 17 00:00:00 2001 From: Mireille Schneider Date: Tue, 8 Sep 2026 10:52:58 +0200 Subject: [PATCH 06/12] Format preprocess_dina.py to fix CI tests --- workflows/utils/preprocess_dina.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/workflows/utils/preprocess_dina.py b/workflows/utils/preprocess_dina.py index b4f180b9..151827ed 100644 --- a/workflows/utils/preprocess_dina.py +++ b/workflows/utils/preprocess_dina.py @@ -76,9 +76,13 @@ def write_dina_data(db_out, db_in, db_sum, db_md_pf_active, n_timeslices): z_out = np.asarray(eq_orig_ts.boundary_separatrix.outline.z) if r_out.size: if not eq_orig_ts.boundary.geometric_axis.r.has_value: - eq_orig_ts.boundary.geometric_axis.r = (r_out.min() + r_out.max()) / 2 + eq_orig_ts.boundary.geometric_axis.r = ( + r_out.min() + r_out.max() + ) / 2 if not eq_orig_ts.boundary.geometric_axis.z.has_value: - eq_orig_ts.boundary.geometric_axis.z = (z_out.min() + z_out.max()) / 2 + eq_orig_ts.boundary.geometric_axis.z = ( + z_out.min() + z_out.max() + ) / 2 eq_orig_ts.boundary.psi = eq_orig_ts.boundary_separatrix.psi eq_orig_ts.boundary.outline.r = eq_orig_ts.boundary_separatrix.outline.r eq_orig_ts.boundary.outline.z = eq_orig_ts.boundary_separatrix.outline.z From bebbdf3e88597fc8ac0f6b4569aa02c7aa44c4f9 Mon Sep 17 00:00:00 2001 From: Mireille Schneider Date: Tue, 8 Sep 2026 13:49:53 +0200 Subject: [PATCH 07/12] Scope NICE_VOLTAGE_SIGN=-1 to metis_nice_evol_from_dina env.sh; fixes evolutive_controller CI case --- workflows/lib/easybuild_programs.ymmsl | 6 ------ workflows/metis_nice_evol_from_dina/env.sh | 8 ++++++++ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/workflows/lib/easybuild_programs.ymmsl b/workflows/lib/easybuild_programs.ymmsl index 5cdf29df..f08224bc 100644 --- a/workflows/lib/easybuild_programs.ymmsl +++ b/workflows/lib/easybuild_programs.ymmsl @@ -107,12 +107,6 @@ programs: # by hand, pointing at a per-user run/pcs checkout. export PCS_PATH="$EBROOTPCS" export CONTROLLER_PATH="$controller_path" - # NICE/3.0.0.dev258's evolutive actor applies received IDS coil voltages without - # the COCOS sign flip it applies to coil currents (nice_imas.cc, - # ReadDataEvolutiveProblemWithRD: `signIc = 1`, flip commented out), so the plant the - # controller sees is inverted. muscle_NICE_input.m negates the voltages it sends when - # this is -1. Set to 1 / remove once NICE converts voltages like currents. - export NICE_VOLTAGE_SIGN=-1 # NICE's own runmatlab/muscle3/matlab_iter/ (added to the path by # initNice4matlab.m below) ships its own, much older, # different-port-signature muscle_controller_NICE_IMAS_iter_init.m, which diff --git a/workflows/metis_nice_evol_from_dina/env.sh b/workflows/metis_nice_evol_from_dina/env.sh index 2112fd6e..872fb989 100644 --- a/workflows/metis_nice_evol_from_dina/env.sh +++ b/workflows/metis_nice_evol_from_dina/env.sh @@ -1,3 +1,11 @@ #!/bin/bash # See workflows/metis_from_dina/env.sh -- same fix, same reason. export IMAS_AL_DISABLE_VALIDATE=1 +# NICE/3.0.0.dev258's evolutive actor applies received IDS coil voltages without +# the COCOS sign flip it applies to coil currents (nice_imas.cc, +# ReadDataEvolutiveProblemWithRD: signIc = 1, flip commented out), so the plant the +# controller sees is inverted. controllers/KCURR_RZIp/muscle_NICE_input.m negates +# the voltages it sends when this is -1. Kept in this workflow's env.sh so that +# other controller workflows are not affected. Set to 1 / remove once NICE +# converts voltages like currents. +export NICE_VOLTAGE_SIGN=-1 From 41dc003200ebdc0ed913c8c8891e8908aef2378d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artaud=20Jean-Fran=C3=A7ois?= Date: Wed, 9 Sep 2026 09:50:44 +0200 Subject: [PATCH 08/12] added nice inv to improved starting point + start simulation on flattop + set 4 core/thread at minima for Matlab as prescribe in MathWorks documentation --- .../metis_nice_evol_from_dina/settings.ymmsl | 12 ++++++----- .../metis_nice_evol_from_dina/workflow.ymmsl | 21 +++++++++++++++++-- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/workflows/metis_nice_evol_from_dina/settings.ymmsl b/workflows/metis_nice_evol_from_dina/settings.ymmsl index 2ca18425..bded1afd 100644 --- a/workflows/metis_nice_evol_from_dina/settings.ymmsl +++ b/workflows/metis_nice_evol_from_dina/settings.ymmsl @@ -23,10 +23,11 @@ resources: metis_nice_evol_from_dina.source_metis: {threads: 1} metis_nice_evol_from_dina.synchro_nice_metis: {threads: 1} metis_nice_evol_from_dina.metis: {threads: 10} - metis_nice_evol_from_dina.metis_init: {threads: 1} + metis_nice_evol_from_dina.metis_init: {threads: 4} metis_nice_evol_from_dina.metis_to_nice: {threads: 1} metis_nice_evol_from_dina.nice_evo_rd: {threads: 2} - # Simulink controller: 4 threads + metis_nice_evol_from_dina.nice_inv: {threads: 2} + # Simulink controller: 4 threads metis_nice_evol_from_dina.magnetic_controller: {threads: 4} metis_nice_evol_from_dina.sink_transport: {threads: 1} metis_nice_evol_from_dina.sink_metis_init: {threads: 1} @@ -36,11 +37,11 @@ resources: settings: source_nice.source_uri: "imas:hdf5?path=${CASE_DIR}/preprocess/dina_in" source_nice.iterative: false - source_nice.t_min: 0.0 + source_nice.t_min: 72.0 source_metis.source_uri: "imas:hdf5?path=${CASE_DIR}/preprocess/metis_in" - source_metis.iterative: false - source_metis.t_min: 0.0 + #source_metis.iterative: false + #source_metis.t_min: 72.0 synchro_nice_metis.source_uri: "imas:hdf5?path=${CASE_DIR}/preprocess/metis_in" # The METIS input dataset has slices ~0.11 s apart while the loop runs every 2 ms; with the @@ -77,6 +78,7 @@ settings: nice_evo_rd.dt: 0.002 # NICE/3.0.0.dev258's evolutive actor has no end-time setting of its own; the run ends # through METIS's metis_clock_time_stop (default Inf, i.e. unset) or the SLURM time limit. + nice_inv.xml_path: ${PDS_REPO}/workflows/metis_nice_evol_from_dina/config_nice.xml # METIS switches for reading external data from the received IDSs -- predictive defaults, # all off. See cases/overrides/metis_nice_evol_from_dina_.ymmsl for an interpretative diff --git a/workflows/metis_nice_evol_from_dina/workflow.ymmsl b/workflows/metis_nice_evol_from_dina/workflow.ymmsl index 8280d590..a2319718 100644 --- a/workflows/metis_nice_evol_from_dina/workflow.ymmsl +++ b/workflows/metis_nice_evol_from_dina/workflow.ymmsl @@ -32,6 +32,7 @@ imports: - from imas_muscle3 import implementation sink_source_component - from lib.local_programs import implementation metis - from lib.easybuild_programs import implementation nice_evo_rd +- from lib.easybuild_programs import implementation nice_inv - from lib.easybuild_programs import implementation magnetic_controller - from lib.easybuild_programs import implementation initial_state_splitter @@ -74,6 +75,14 @@ models: f_init: [pulse_schedule_f_init, core_profiles_f_init, core_sources_f_init] o_f: [equilibrium_out_f, pulse_schedule_out_f, core_profiles_out_f, plasma_profiles_out_f, plasma_sources_out_f, summary_out_f] + nice_inv: + description: NICE inverse solve on METIS's equilibrium (bare, no load balancer) + implementation: nice_inv + ports: + f_init: [equilibrium_in, wall_in, pf_active_in, pf_passive_in, iron_core_in] + o_f: [equilibrium_out, pf_active_out] + + metis_to_nice: description: "drops METIS's initialisation-turn message: METIS emits its initial state on its O_I ports before its first evolved state, and nice_evo_rd's S ports must only see evolved states" implementation: initial_state_splitter @@ -150,7 +159,15 @@ models: # synchronisation between METIS source and NICE one source_nice.pf_active_out: source_metis.pf_active_in - # NICE bootstrap from machine description + # NICE inv bootstrap from machine description and init + source_nice.pf_active_out: nice_inv.pf_active_in + source_nice.pf_passive_out: nice_inv.pf_passive_in + source_nice.iron_core_out: nice_inv.iron_core_in + source_nice.wall_out: nice_inv.wall_in + metis_init.equilibrium_out_f: nice_inv.equilibrium_in + + + # NICE evol bootstrap from machine description source_nice.pf_active_out: nice_evo_rd.pf_active_f_init source_nice.pf_passive_out: nice_evo_rd.pf_passive_f_init source_nice.iron_core_out: nice_evo_rd.iron_core_f_init @@ -181,7 +198,7 @@ models: nice_evo_rd.pf_active_o_i: sink_equilibrium.pf_active_in # Controller bootstrap from machine description - source_nice.equilibrium_out: magnetic_controller.equilibrium_in_f + nice_inv.equilibrium_out: magnetic_controller.equilibrium_in_f source_nice.pf_active_out: magnetic_controller.pf_active_in_f # Controller output to sink From 53f76505b65faa390f06611b088841b697215f05 Mon Sep 17 00:00:00 2001 From: Mireille Schneider Date: Wed, 9 Sep 2026 11:15:00 +0200 Subject: [PATCH 09/12] metis_nice_evol: add reference_rebase (controller feed-forward rebased on NICE inverse at t0); drop metis_to_nice, METIS actor now mutes its init-turn O_I send --- workflows/lib/easybuild_programs.ymmsl | 10 +- workflows/metis_nice_evol_from_dina/README.md | 46 +- .../metis_nice_evol_from_dina/settings.ymmsl | 4 +- .../metis_nice_evol_from_dina/workflow.ymmsl | 40 +- workflows/utils/initial_state_splitter.py | 94 ---- workflows/utils/reference_rebase.py | 465 ++++++++++++++++++ 6 files changed, 537 insertions(+), 122 deletions(-) delete mode 100644 workflows/utils/initial_state_splitter.py create mode 100644 workflows/utils/reference_rebase.py diff --git a/workflows/lib/easybuild_programs.ymmsl b/workflows/lib/easybuild_programs.ymmsl index f08224bc..337611be 100644 --- a/workflows/lib/easybuild_programs.ymmsl +++ b/workflows/lib/easybuild_programs.ymmsl @@ -23,10 +23,16 @@ programs: executable: python args: -u $PDS_REPO/workflows/utils/temporal_coupler.py - initial_state_splitter: + # IMAS_AL_DISABLE_VALIDATE: this actor deserialises a NICE-inverse equilibrium (whose + # grids_ggd is not itself time-varying and so stays at shape (1,) while the rest of the + # IDS has one entry per slice -- a coordinate mismatch IMAS's strict validator rejects, + # same reason as the waveform_editor and nice_* entries). + reference_rebase: modules: IMAS-MUSCLE3/1.0.0-intel-2025b-pds + env: + IMAS_AL_DISABLE_VALIDATE: 1 executable: python - args: -u $PDS_REPO/workflows/utils/initial_state_splitter.py + args: -u $PDS_REPO/workflows/utils/reference_rebase.py metis: script: | diff --git a/workflows/metis_nice_evol_from_dina/README.md b/workflows/metis_nice_evol_from_dina/README.md index 935f9c42..6dd77337 100644 --- a/workflows/metis_nice_evol_from_dina/README.md +++ b/workflows/metis_nice_evol_from_dina/README.md @@ -16,13 +16,45 @@ as `evolutive_controller`'s `torax`/`nice_evo_rd` pair. Unlike TORAX, METIS also actuator/current-drive input), supplied by `synchro_nice_metis` re-slicing METIS's own DINA-derived input trace at NICE's current equilibrium timestamp. -`metis_to_nice` (`initial_state_splitter`) sits on the `metis` -> `nice_evo_rd` S path as a -drop-first filter. METIS's actor emits, during its initialisation turn, one O_I message -carrying its initial state (`next_timestamp` None, the same state `metis_init` writes on its -O_F ports) before the one-per-step evolved states; `nice_evo_rd`'s evolutive actor takes its -clock and its termination from every S message, so that first one would stop its time loop -after a single step. `metis_to_nice` forwards only the evolved states; `nice_evo_rd`'s F_INIT -is still bootstrapped by `metis_init`. +The fix for METIS's initialisation-turn message on its O_I ports lives in the METIS actor +itself (`metis4muscle3.m`: no O_I send on the initialisation turn when not in init-only mode; +patch in the METIS repository, branch `develop`). METIS versions without that fix need a +drop-first filter between `metis` and `nice_evo_rd`, since `nice_evo_rd`'s evolutive actor +takes its clock and termination from every S message and would otherwise stop its time loop +after a single step. + +`reference_rebase` (`workflows/utils/reference_rebase.py`) sits between the scenario source +and the controller's F_INIT ports. The magnetic controller reads its whole reference +trajectory once, from `equilibrium_in_f` and `pf_active_in_f` +(`controllers/KCURR_RZIp/muscle_controller_NICE_IMAS_iter_init.m:67-126`): `equilibrium.time` +becomes Simulink's start/stop time, and `global_quantities.ip`, +`boundary.geometric_axis.r/.z` and `pf_active.coil{i}.current.data` become the `Ipl_ref`, +`Rpl_ref`, `Zpl_ref` and `CSPF_curr_ref` timeseries (with `CSPF_volt_cmd_FF` the +feed-forward voltage built from the first current sample and the coil resistances). Those +references come from the DINA scenario, while the plant is started from `nice_inv`'s own +inverse solve at the run's start time -- and the few-percent disagreement at t0 is answered +by saturated coil voltages on the first steps. Wiring `nice_inv` straight into the +controller's F_INIT is not an option either: it delivers a single time slice where the +controller needs the whole trace. `reference_rebase` therefore keeps the scenario trace -- +its shape, its time base, its length -- and shifts it so that at t0 it agrees exactly with +the NICE inverse solution, the same rebase the JT-60SA simulator applies: + + X_ff(t) = X_src(t) - X_src(t0) + X_nice(t0) + +with `t0` the time of the NICE-inverse slice and `X_src(t0)` interpolated linearly +(`numpy.interp`, clamped at the ends, so a t0 on or just outside the first scenario sample +is fine). It is applied per coil to `pf_active.coil[].current.data` (coils matched by name, +falling back to index with a warning), and to `equilibrium.time_slice[].global_quantities.ip` +and `.boundary.geometric_axis.r/.z` -- exactly the quantities the controller reads. +Everything else is forwarded untouched, including message timestamps: no flux quantity is +shifted, because the controller reads no `psi` from either its F_INIT or its S ports. For +the position it evaluates the controller's own fallback chain (geometric axis -> boundary +outline midpoint -> magnetic axis) on both sides and writes the shifted result back into +`boundary.geometric_axis`, so the controller reads a valid axis directly. The shifts are +logged per coil and for ip/r/z at INFO level. Optional settings `rebase_currents`, +`rebase_ip`, `rebase_position` (all default true) and `shift_mode` (`additive` default, +`none` for a pure passthrough) allow switching parts of it off for A/B debugging without +rewiring the workflow. Ported from `itergit/feature/metis_nice_evol`'s `workflows/metis_predictive_nice_evol_from_dina/workflow.ymmsl.template` ("first version of the diff --git a/workflows/metis_nice_evol_from_dina/settings.ymmsl b/workflows/metis_nice_evol_from_dina/settings.ymmsl index bded1afd..15836f3e 100644 --- a/workflows/metis_nice_evol_from_dina/settings.ymmsl +++ b/workflows/metis_nice_evol_from_dina/settings.ymmsl @@ -24,10 +24,10 @@ resources: metis_nice_evol_from_dina.synchro_nice_metis: {threads: 1} metis_nice_evol_from_dina.metis: {threads: 10} metis_nice_evol_from_dina.metis_init: {threads: 4} - metis_nice_evol_from_dina.metis_to_nice: {threads: 1} metis_nice_evol_from_dina.nice_evo_rd: {threads: 2} metis_nice_evol_from_dina.nice_inv: {threads: 2} - # Simulink controller: 4 threads + metis_nice_evol_from_dina.reference_rebase: {threads: 1} + # Simulink controller: 4 threads metis_nice_evol_from_dina.magnetic_controller: {threads: 4} metis_nice_evol_from_dina.sink_transport: {threads: 1} metis_nice_evol_from_dina.sink_metis_init: {threads: 1} diff --git a/workflows/metis_nice_evol_from_dina/workflow.ymmsl b/workflows/metis_nice_evol_from_dina/workflow.ymmsl index a2319718..2f1bd7c7 100644 --- a/workflows/metis_nice_evol_from_dina/workflow.ymmsl +++ b/workflows/metis_nice_evol_from_dina/workflow.ymmsl @@ -34,7 +34,7 @@ imports: - from lib.easybuild_programs import implementation nice_evo_rd - from lib.easybuild_programs import implementation nice_inv - from lib.easybuild_programs import implementation magnetic_controller -- from lib.easybuild_programs import implementation initial_state_splitter +- from lib.easybuild_programs import implementation reference_rebase models: metis_nice_evol_from_dina: @@ -81,14 +81,13 @@ models: ports: f_init: [equilibrium_in, wall_in, pf_active_in, pf_passive_in, iron_core_in] o_f: [equilibrium_out, pf_active_out] - - metis_to_nice: - description: "drops METIS's initialisation-turn message: METIS emits its initial state on its O_I ports before its first evolved state, and nice_evo_rd's S ports must only see evolved states" - implementation: initial_state_splitter + reference_rebase: + description: rebases the controller's feed-forward references -- coil currents, Ip, plasma position -- onto the NICE inverse solution at the start time + implementation: reference_rebase ports: - s: [equilibrium_in, core_profiles_in] - o_i: [equilibrium_out, core_profiles_out] + f_init: [equilibrium_in, pf_active_in, equilibrium_ref_in, pf_active_ref_in] + o_f: [equilibrium_out, pf_active_out] nice_evo_rd: description: nice evolutive free-boundary + resistive-diffusion solve @@ -175,13 +174,11 @@ models: metis_init.equilibrium_out_f: nice_evo_rd.equilibrium_f_init metis_init.core_profiles_out_f: nice_evo_rd.core_profiles_f_init - # NICE in-loop input from METIS, through metis_to_nice: METIS's initialisation turn - # emits its initial state on its O_I ports (next_timestamp None) before its first - # evolved state, and nice_evo_rd's evolutive time loop stops on such an S message. - metis.equilibrium_out_i: metis_to_nice.equilibrium_in - metis.core_profiles_out_i: metis_to_nice.core_profiles_in - metis_to_nice.equilibrium_out: nice_evo_rd.equilibrium_s - metis_to_nice.core_profiles_out: nice_evo_rd.core_profiles_s + # the in-loop METIS instance must not send its initialisation-turn state on its O_I ports + # (fixed in metis4muscle3.m: no O_I send on the initialisation turn when not in init-only + # mode); nice_evo_rd's evolutive time loop would otherwise stop on that first message + metis.equilibrium_out_i: nice_evo_rd.equilibrium_s + metis.core_profiles_out_i: nice_evo_rd.core_profiles_s # NICE in-loop input from the controller magnetic_controller.pf_active_out_i: nice_evo_rd.pf_active_s @@ -197,9 +194,18 @@ models: nice_evo_rd.equilibrium_o_i: sink_equilibrium.equilibrium_in nice_evo_rd.pf_active_o_i: sink_equilibrium.pf_active_in - # Controller bootstrap from machine description - nice_inv.equilibrium_out: magnetic_controller.equilibrium_in_f - source_nice.pf_active_out: magnetic_controller.pf_active_in_f + # Controller bootstrap: the DINA scenario trace (whole trace, the shape and time + # base the controller needs) rebased onto nice_inv's solution at the start time. + # Wiring nice_inv straight into the controller's F_INIT instead does not work: the + # controller reads its whole reference trajectory once from these two ports + # (equilibrium.time -> Simulink StartTime/StopTime, then one timeseries per + # reference), while nice_inv emits a single time slice. + source_nice.equilibrium_out: reference_rebase.equilibrium_in + source_nice.pf_active_out: reference_rebase.pf_active_in + nice_inv.equilibrium_out: reference_rebase.equilibrium_ref_in + nice_inv.pf_active_out: reference_rebase.pf_active_ref_in + reference_rebase.equilibrium_out: magnetic_controller.equilibrium_in_f + reference_rebase.pf_active_out: magnetic_controller.pf_active_in_f # Controller output to sink magnetic_controller.pf_active_out_i: sink_control.pf_active_in diff --git a/workflows/utils/initial_state_splitter.py b/workflows/utils/initial_state_splitter.py deleted file mode 100644 index 07e91dbd..00000000 --- a/workflows/utils/initial_state_splitter.py +++ /dev/null @@ -1,94 +0,0 @@ -""" -MUSCLE3 actor splitting a per-step IDS stream into an initial message and a -per-step tail. - -Some MUSCLE3 actors (e.g. metis4muscle3, driven by imas_muscle3's -source_component conventions) emit their initial state on their O_I ports -right after F_INIT -- before reading any S input -- and then one further -state per step, so their O_I stream carries N+1 messages, the first with -next_timestamp=None. A peer actor with a genuine time loop (e.g. -nice_imas_evo_rd_muscle3's `while(has_t_next)`) instead expects the initial -state on its own F_INIT ports and exactly one message per step on its S -ports, and stops its time loop as soon as an S message arrives without a -next_timestamp -- so wiring the two directly makes the peer consume the -initial message as its first step and exit early. - -This actor sits in between: for each connected IDS channel, it forwards the -first message received on `_in` to `_init_out` (wire to -the peer's F_INIT), and every following message to `_out` (wire to -the peer's S), unchanged. - -The `_init_out` ports are optional: a channel whose `_init_out` is -left unconnected simply has its first message dropped, so with none of them -wired the actor is a plain drop-first filter -- which is how -metis_nice_evol_from_dina uses it, its peer's F_INIT being bootstrapped by a -separate one-shot instance instead. -""" - -import logging - -from libmuscle import Instance, InstanceFlags -from ymmsl import Operator - -logger = logging.getLogger() - -CHANNELS = [ - "equilibrium", - "core_profiles", - "pf_active", - "core_sources", - "wall", - "pf_passive", - "iron_core", - "plasma_profiles", - "plasma_sources", - "pulse_schedule", - "summary", -] - - -def main() -> None: - ports = { - Operator.S: [f"{channel}_in" for channel in CHANNELS], - Operator.O_I: ( - [f"{channel}_init_out" for channel in CHANNELS] - + [f"{channel}_out" for channel in CHANNELS] - ), - } - instance = Instance(ports, InstanceFlags.SKIP_MMSF_SEQUENCE_CHECKS) - - active = [c for c in CHANNELS if instance.is_connected(f"{c}_in")] - for channel in CHANNELS: - for out_suffix in ("_init_out", "_out"): - port = f"{channel}{out_suffix}" - if instance.is_connected(port) and channel not in active: - raise RuntimeError( - f"'{port}' is connected but '{channel}_in' is not -- " - "an output channel needs its matching input wired." - ) - logger.info("active channels: %s", active) - - while instance.reuse_instance(): - logger.info("receiving initial state on: %s", active) - for channel in active: - msg = instance.receive(f"{channel}_in") - if instance.is_connected(f"{channel}_init_out"): - instance.send(f"{channel}_init_out", msg) - - logger.info("streaming per-step state on: %s", active) - running = list(active) - while running: - for channel in list(running): - msg = instance.receive(f"{channel}_in") - if instance.is_connected(f"{channel}_out"): - instance.send(f"{channel}_out", msg) - if msg.next_timestamp is None: - running.remove(channel) - - -if __name__ == "__main__": - logging.basicConfig( - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", - level=logging.INFO, - ) - main() diff --git a/workflows/utils/reference_rebase.py b/workflows/utils/reference_rebase.py new file mode 100644 index 00000000..f96cc3a3 --- /dev/null +++ b/workflows/utils/reference_rebase.py @@ -0,0 +1,465 @@ +""" +MUSCLE3 actor rebasing a controller's reference trace onto a NICE-inverse +solution at the workflow start time. + +Why this exists +--------------- +``metis_nice_evol_from_dina``'s magnetic controller +(``controllers/KCURR_RZIp/muscle_controller_NICE_IMAS_iter_init.m``) reads its +whole reference trajectory once, from its two F_INIT ports, and never re-reads +it (its S ports carry measurements only):: + + equilibrium.time -> Simulink StartTime/StopTime + time_slice{i}.global_quantities.ip (abs) -> Ipl_ref timeseries (:104,:115) + time_slice{i}.boundary.geometric_axis.r -> Rpl_ref timeseries (:105,:125) + time_slice{i}.boundary.geometric_axis.z -> Zpl_ref timeseries (:106,:126) + pf_active.coil{i}.current.data -> CSPF_curr_ref (:82,:116) + pf_active.coil{i}.resistance -> CSPF_volt_cmd_FF (:83,:118) + +Those references come from the DINA scenario (``source_nice``), while the +plant the controller drives is started from NICE's own inverse solve at the +first time of the run. The two disagree by a few percent at t0, and the +current loop answers that offset with saturated coil voltages on the very +first steps -- the flux kick that threw NICE's evolutive solve into NaN +(2026-09-04). + +Wiring the bare ``nice_inv`` output into the controller's F_INIT instead does +not work either: ``nice_inv`` produces a *single* time slice, whereas the +controller needs the whole trace (it takes ``time(1)``/``time(end)`` as the +Simulink start/stop times and builds one timeseries per reference). + +This actor keeps the scenario trace -- its shape, its time base, its length -- +and merely *rebases* it so that at t0 it agrees exactly with the NICE inverse +solution, exactly as the JT-60SA simulator does:: + + X_ff(t) = X_src(t) - X_src(t0) + X_nice(t0) + +applied to every quantity the controller actually reads: per-coil +feed-forward currents, the plasma-current reference and the plasma-position +references. ``X_src(t0)`` is interpolated linearly (``numpy.interp``, which +clamps outside the source range) in each IDS's own time base, so the actor is +safe when t0 falls on, before or after the first scenario sample. + +What is *not* rebased +--------------------- +Nothing else is touched. In particular no flux quantity is shifted: the +controller reads no ``psi`` (neither ``psi_boundary`` nor ``profiles_1d.psi``) +from its F_INIT ports -- verified in +``muscle_controller_NICE_IMAS_iter_init.m`` (the F_INIT block, lines 67-126) +and in ``muscle_IDS_NICE_output.m`` (the S block, lines 89-96). Coil +resistances are references only in the sense that the controller multiplies +them by the *rebased* current, so they need no shift of their own. + +Position references and the controller's fallback chain +------------------------------------------------------- +``geo_ref_with_fallback`` (init script, lines 153-179) falls back from +``boundary.geometric_axis.`` to the boundary-outline midpoint and then to +``global_quantities.magnetic_axis.`` when the primary value is empty or +carries IMAS's empty-float sentinel. This actor evaluates *the same* chain on +both the scenario slice and the NICE slice, shifts the resulting effective +values, and writes the result back into ``boundary.geometric_axis`` -- so the +controller reads a valid geometric axis directly and its fallback never fires. +The outline itself is left untouched. + +Ports +----- +F_INIT + ``equilibrium_in`` -- scenario equilibrium trace (from ``source_nice``) + ``pf_active_in`` -- scenario pf_active trace (from ``source_nice``) + ``equilibrium_ref_in`` -- NICE inverse equilibrium at t0 (from ``nice_inv``) + ``pf_active_ref_in`` -- NICE inverse pf_active at t0 (from ``nice_inv``) +O_F + ``equilibrium_out`` -- rebased equilibrium trace + ``pf_active_out`` -- rebased pf_active trace + +A ``*_ref_in`` port left unconnected turns its channel into a plain +passthrough (logged as a warning); a ``*_out`` port whose matching ``*_in`` is +unconnected is a configuration error. + +Settings (all optional) +----------------------- +``rebase_currents`` (bool, default ``true``) + Shift ``pf_active.coil[].current.data``. +``rebase_ip`` (bool, default ``true``) + Shift ``equilibrium.time_slice[].global_quantities.ip``. +``rebase_position`` (bool, default ``true``) + Shift ``equilibrium.time_slice[].boundary.geometric_axis.r/.z``. +``shift_mode`` (str, default ``additive``) + ``additive`` applies the formula above; ``none`` makes the actor a pure + passthrough (for A/B debugging without rewiring the workflow). + +Notes on the NICE-inverse payload +--------------------------------- +``nice_imas_inv_muscle3`` copies its *input* ``pf_active`` through and replaces +only ``coil(i).current.data(0)`` with the solved current, so the reference +currents are read at index 0 and the coil list (names and order) is whatever +``source_nice`` sent -- name matching therefore succeeds, and the index +fallback below is only a safety net. Its ``equilibrium`` output carries one +solved time slice; slice 0 is used, and t0 is that slice's time (falling back +to the message timestamp). +""" + +import logging + +import numpy as np +from imas_muscle3.utils import ids_from_message +from libmuscle import Instance, Message +from ymmsl import Operator + +logger = logging.getLogger() + +# IMAS's empty-float sentinel is -9e40; anything beyond this magnitude (or not +# finite) counts as "not filled", same test as the controller's +# geo_ref_with_fallback. +EMPTY_FLOAT_THRESHOLD = 1e30 + +CHANNELS = ("equilibrium", "pf_active") + +# t0 falling just outside a source range is normal (the run's start time need not +# coincide with a scenario sample) and identical for every coil, so the clamping +# notice is emitted once per process, like the controller's own fallback warning. +_clamp_warned = False + + +def _is_unset(value: float | None) -> bool: + """True when a scalar is absent or carries IMAS's empty-float sentinel.""" + if value is None: + return True + val = float(value) + return not np.isfinite(val) or abs(val) > EMPTY_FLOAT_THRESHOLD + + +def _slice_times(equilibrium) -> np.ndarray: + """Time base of an equilibrium's slices, homogeneous or not.""" + times = np.asarray(equilibrium.time, dtype=float) + if times.size == len(equilibrium.time_slice): + return times + return np.array( + [float(time_slice.time) for time_slice in equilibrium.time_slice], + dtype=float, + ) + + +def _coil_times(pf_active, coil) -> np.ndarray: + """Time base of one coil's current trace, homogeneous or not.""" + times = np.asarray(coil.current.time, dtype=float) + if times.size: + return times + return np.asarray(pf_active.time, dtype=float) + + +def _interp(t0: float, times: np.ndarray, values: np.ndarray) -> float: + """Linear interpolation clamped to the ends of `times`. + + numpy.interp already clamps, which is exactly the behaviour wanted when t0 + sits on or just outside the first scenario sample (``source_nice.t_min`` + trims the trace, and METIS's start time need not coincide with a DINA + sample). + """ + if values.size == 0: + raise ValueError("empty value array") + if values.size == 1 or times.size != values.size: + return float(values[0]) + global _clamp_warned + if (t0 < times[0] or t0 > times[-1]) and not _clamp_warned: + logger.warning( + "t0 = %g s is outside the source range [%g, %g] s -- clamping " + "to the nearest end point (reported once)", + t0, + times[0], + times[-1], + ) + _clamp_warned = True + return float(np.interp(t0, times, values)) + + +def _effective_axis(time_slice, field: str) -> tuple[float, str]: + """Plasma-position reference the controller would read, and its origin. + + Mirrors geo_ref_with_fallback in + muscle_controller_NICE_IMAS_iter_init.m: geometric_axis, then the boundary + outline midpoint, then the magnetic axis. + """ + value = getattr(time_slice.boundary.geometric_axis, field) + if not _is_unset(value): + return float(value), "geometric_axis" + outline = np.asarray(getattr(time_slice.boundary.outline, field), dtype=float) + if outline.size: + return float((outline.max() + outline.min()) / 2.0), "outline midpoint" + return ( + float(getattr(time_slice.global_quantities.magnetic_axis, field)), + "magnetic_axis", + ) + + +def _t0_from(equilibrium, fallback: float) -> float: + """Time of the NICE-inverse slice: its own timestamp, else the message's.""" + times = np.asarray(equilibrium.time, dtype=float) + if times.size and not _is_unset(times[0]): + return float(times[0]) + if len(equilibrium.time_slice): + candidate = equilibrium.time_slice[0].time + if not _is_unset(candidate): + return float(candidate) + return float(fallback) + + +def rebase_pf_active(source, reference, t0: float) -> None: + """Shift every coil's current trace onto the NICE solution at t0. + + ``source`` is modified in place: + ``I(t) <- I(t) + I_nice(t0) - I_src(t0)``, per coil, matched by name and + falling back to position in the coil list when the names differ. + """ + ref_coils: dict[str, object] = {} + for coil in reference.coil: + name = str(coil.name).strip() + if name: + ref_coils[name] = coil + + by_name = len(ref_coils) == len(reference.coil) and all( + str(coil.name).strip() in ref_coils for coil in source.coil + ) + if not by_name: + logger.warning( + "pf_active coil names do not match one-to-one between the " + "scenario trace and the NICE-inverse solution -- falling back to " + "matching by index (scenario: %s; NICE: %s)", + [str(c.name) for c in source.coil], + [str(c.name) for c in reference.coil], + ) + + for index, coil in enumerate(source.coil): + name = str(coil.name).strip() or f"coil[{index}]" + if by_name: + ref_coil = ref_coils[str(coil.name).strip()] + elif index < len(reference.coil): + ref_coil = reference.coil[index] + else: + logger.warning( + "no NICE-inverse counterpart for coil %s -- left unshifted", name + ) + continue + + data = np.asarray(coil.current.data, dtype=float) + ref_data = np.asarray(ref_coil.current.data, dtype=float) + if data.size == 0 or ref_data.size == 0: + logger.warning( + "coil %s has no current data on one side -- left unshifted", name + ) + continue + # NICE writes the solved current at index 0 of the pf_active it copies + # through, whatever the length of that copied trace. + i_nice = float(ref_data[0]) + i_src = _interp(t0, _coil_times(source, coil), data) + shift = i_nice - i_src + coil.current.data = data + shift + logger.info( + "pf_active %-42s I_src(t0)=%+12.6g A I_nice(t0)=%+12.6g A " + "shift=%+12.6g A", + name, + i_src, + i_nice, + shift, + ) + + +def rebase_equilibrium( + source, reference, t0: float, rebase_ip: bool, rebase_position: bool +) -> None: + """Shift the plasma-current and plasma-position references onto NICE's. + + ``source`` is modified in place; every other field (time base, number of + slices, profiles, boundary outline, ...) is left exactly as received. + """ + if not len(reference.time_slice): + logger.warning( + "the NICE-inverse equilibrium carries no time slice -- " + "equilibrium left unshifted" + ) + return + ref_slice = reference.time_slice[0] + if len(reference.time_slice) > 1: + logger.info( + "the NICE-inverse equilibrium carries %d slices; using slice 0 " + "(t = %g s) as the reference", + len(reference.time_slice), + t0, + ) + + times = _slice_times(source) + + if rebase_ip: + ip_values = np.array( + [ + float(time_slice.global_quantities.ip) + for time_slice in source.time_slice + ], + dtype=float, + ) + ip_src = _interp(t0, times, ip_values) + ip_nice = float(ref_slice.global_quantities.ip) + if ip_src * ip_nice < 0.0: + logger.warning( + "the scenario and NICE plasma currents have opposite signs at " + "t0 (%+.6g A vs %+.6g A) -- the additive shift is applied on " + "the signed values, check the COCOS convention", + ip_src, + ip_nice, + ) + shift = ip_nice - ip_src + for time_slice, value in zip(source.time_slice, ip_values, strict=False): + time_slice.global_quantities.ip = value + shift + logger.info( + "equilibrium %-42s ip_src(t0)=%+12.6g A ip_nice(t0)=%+12.6g A " + "shift=%+12.6g A", + "global_quantities.ip", + ip_src, + ip_nice, + shift, + ) + + if rebase_position: + for field in ("r", "z"): + effective = np.array( + [_effective_axis(ts, field)[0] for ts in source.time_slice], + dtype=float, + ) + src_value = _interp(t0, times, effective) + nice_value, nice_origin = _effective_axis(ref_slice, field) + shift = nice_value - src_value + for time_slice, value in zip(source.time_slice, effective, strict=False): + setattr(time_slice.boundary.geometric_axis, field, value + shift) + logger.info( + "equilibrium %-42s src(t0)=%+12.6g m nice(t0)=%+12.6g m " + "shift=%+12.6g m (NICE value from %s)", + f"boundary.geometric_axis.{field}", + src_value, + nice_value, + shift, + nice_origin, + ) + + +def _connected(instance: Instance, port: str) -> bool: + return instance.is_connected(port) + + +def main() -> None: + ports = { + Operator.F_INIT: ( + [f"{channel}_in" for channel in CHANNELS] + + [f"{channel}_ref_in" for channel in CHANNELS] + ), + Operator.O_F: [f"{channel}_out" for channel in CHANNELS], + } + instance = Instance(ports) + + while instance.reuse_instance(): + active: list[str] = [ + channel for channel in CHANNELS if _connected(instance, f"{channel}_in") + ] + for channel in CHANNELS: + if _connected(instance, f"{channel}_out") and channel not in active: + raise RuntimeError( + f"'{channel}_out' is connected but '{channel}_in' is not -- " + "an output channel needs its matching input wired." + ) + if not active: + raise RuntimeError( + "none of " + + ", ".join(f"{channel}_in" for channel in CHANNELS) + + " is connected -- nothing to rebase." + ) + + shift_mode = instance.get_setting("shift_mode", "str", default="additive") + if shift_mode not in ("additive", "none"): + raise ValueError( + f"shift_mode must be 'additive' or 'none', got {shift_mode!r}" + ) + rebase_currents = instance.get_setting("rebase_currents", "bool", default=True) + rebase_ip = instance.get_setting("rebase_ip", "bool", default=True) + rebase_position = instance.get_setting("rebase_position", "bool", default=True) + logger.info( + "active channels: %s; shift_mode=%s, rebase_currents=%s, " + "rebase_ip=%s, rebase_position=%s", + active, + shift_mode, + rebase_currents, + rebase_ip, + rebase_position, + ) + + messages = {} + sources = {} + references = {} + ref_messages = {} + for channel in active: + message = instance.receive(f"{channel}_in") + messages[channel] = message + sources[channel] = ids_from_message(channel, message.data) + ref_port = f"{channel}_ref_in" + if _connected(instance, ref_port): + ref_message = instance.receive(ref_port) + ref_messages[channel] = ref_message + references[channel] = ids_from_message(channel, ref_message.data) + else: + logger.warning( + "'%s' is not connected -- forwarding the %s trace unchanged", + ref_port, + channel, + ) + + # t0 is the time of the NICE-inverse slice: the reference equilibrium + # carries it in its own time array, the reference pf_active only + # echoes the scenario time base, so fall back to the timestamp of the + # message NICE sent rather than to any scenario time. + t0: float | None = None + if "equilibrium" in references: + t0 = _t0_from( + references["equilibrium"], ref_messages["equilibrium"].timestamp + ) + elif ref_messages: + t0 = float(ref_messages[next(iter(ref_messages))].timestamp) + if t0 is not None: + logger.info("rebasing the controller references onto t0 = %g s", t0) + + if shift_mode == "none": + logger.warning("shift_mode='none' -- forwarding both traces unchanged") + else: + if rebase_currents and "pf_active" in sources and "pf_active" in references: + rebase_pf_active(sources["pf_active"], references["pf_active"], t0) + if ( + (rebase_ip or rebase_position) + and "equilibrium" in sources + and "equilibrium" in references + ): + rebase_equilibrium( + sources["equilibrium"], + references["equilibrium"], + t0, + rebase_ip, + rebase_position, + ) + + for channel in active: + port = f"{channel}_out" + if not _connected(instance, port): + continue + message = messages[channel] + instance.send( + port, + Message( + message.timestamp, + next_timestamp=message.next_timestamp, + data=sources[channel].serialize(), + ), + ) + + +if __name__ == "__main__": + logging.basicConfig( + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + level=logging.INFO, + ) + main() From ef51f52453069ea85ddd8790eed3bc22767a336b Mon Sep 17 00:00:00 2001 From: Mireille Schneider Date: Wed, 9 Sep 2026 11:42:54 +0200 Subject: [PATCH 10/12] reference_rebase: satisfy ty (typed coil map, explicit t0 guard) --- workflows/utils/reference_rebase.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/workflows/utils/reference_rebase.py b/workflows/utils/reference_rebase.py index f96cc3a3..927e07f3 100644 --- a/workflows/utils/reference_rebase.py +++ b/workflows/utils/reference_rebase.py @@ -100,6 +100,7 @@ """ import logging +from typing import Any import numpy as np from imas_muscle3.utils import ids_from_message @@ -211,7 +212,7 @@ def rebase_pf_active(source, reference, t0: float) -> None: ``I(t) <- I(t) + I_nice(t0) - I_src(t0)``, per coil, matched by name and falling back to position in the coil list when the names differ. """ - ref_coils: dict[str, object] = {} + ref_coils: dict[str, Any] = {} for coil in reference.coil: name = str(coil.name).strip() if name: @@ -428,12 +429,22 @@ def main() -> None: logger.warning("shift_mode='none' -- forwarding both traces unchanged") else: if rebase_currents and "pf_active" in sources and "pf_active" in references: + if t0 is None: + raise RuntimeError( + "t0 could not be determined even though a NICE-inverse " + "pf_active reference was received -- this should not happen" + ) rebase_pf_active(sources["pf_active"], references["pf_active"], t0) if ( (rebase_ip or rebase_position) and "equilibrium" in sources and "equilibrium" in references ): + if t0 is None: + raise RuntimeError( + "t0 could not be determined even though a NICE-inverse " + "equilibrium reference was received -- this should not happen" + ) rebase_equilibrium( sources["equilibrium"], references["equilibrium"], From d8923bf9a886288626e920c443407d2746216349 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artaud=20Jean-Fran=C3=A7ois?= Date: Wed, 9 Sep 2026 16:03:48 +0200 Subject: [PATCH 11/12] added setting to define time step in METIS used if next_time_stamp is NaN from f-init input --- workflows/metis_nice_evol_from_dina/settings.ymmsl | 2 ++ 1 file changed, 2 insertions(+) diff --git a/workflows/metis_nice_evol_from_dina/settings.ymmsl b/workflows/metis_nice_evol_from_dina/settings.ymmsl index 15836f3e..dabd5e80 100644 --- a/workflows/metis_nice_evol_from_dina/settings.ymmsl +++ b/workflows/metis_nice_evol_from_dina/settings.ymmsl @@ -65,6 +65,7 @@ settings: # here because NICE's evolutive free-boundary solve carries psi_LCFS forward from METIS. metis.metis_psioffset: 9.0 metis.metis_alwaysreadinitport: 0 + metis.metis_clock_time_dt: 0.002 metis_init.metis_computation: 1 # Fallback only -- preprocess.sh's own MATLAB build computes this shot's real value from its @@ -72,6 +73,7 @@ settings: # here because NICE's evolutive free-boundary solve carries psi_LCFS forward from METIS. metis_init.metis_psioffset: 9.0 metis_init.metis_alwaysreadinitport: 0 + metis_init.metis_clock_time_dt: 0.002 nice_evo_rd.xml_path: ${PDS_REPO}/workflows/metis_nice_evol_from_dina/config_nice.xml nice_evo_rd.t_interval: 0.002 From dfbf7d610071647e1410c196a3679121b22b878b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artaud=20Jean-Fran=C3=A7ois?= Date: Thu, 10 Sep 2026 13:43:30 +0200 Subject: [PATCH 12/12] put to data sources for METIS to be able to get linear interpolate pulse_schedule and last nearest data for other IDSs --- .../metis_nice_evol_from_dina/settings.ymmsl | 10 +++++--- .../metis_nice_evol_from_dina/workflow.ymmsl | 23 +++++++++++++------ 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/workflows/metis_nice_evol_from_dina/settings.ymmsl b/workflows/metis_nice_evol_from_dina/settings.ymmsl index dabd5e80..fba35ab0 100644 --- a/workflows/metis_nice_evol_from_dina/settings.ymmsl +++ b/workflows/metis_nice_evol_from_dina/settings.ymmsl @@ -21,7 +21,8 @@ ymmsl_version: v0.2 resources: metis_nice_evol_from_dina.source_nice: {threads: 1} metis_nice_evol_from_dina.source_metis: {threads: 1} - metis_nice_evol_from_dina.synchro_nice_metis: {threads: 1} + metis_nice_evol_from_dina.synchro_nice_metis_nearest: {threads: 1} + metis_nice_evol_from_dina.synchro_nice_metis_linear: {threads: 1} metis_nice_evol_from_dina.metis: {threads: 10} metis_nice_evol_from_dina.metis_init: {threads: 4} metis_nice_evol_from_dina.nice_evo_rd: {threads: 2} @@ -43,12 +44,15 @@ settings: #source_metis.iterative: false #source_metis.t_min: 72.0 - synchro_nice_metis.source_uri: "imas:hdf5?path=${CASE_DIR}/preprocess/metis_in" + synchro_nice_metis_linear.source_uri: "imas:hdf5?path=${CASE_DIR}/preprocess/metis_in" # The METIS input dataset has slices ~0.11 s apart while the loop runs every 2 ms; with the # default 'closest' pick METIS's boundary conditions (and, in interpretative mode, Te/ne) # jump by ~15% every ~55 steps, and NICE's evolutive Newton solve diverged exactly at the # first such jump (2026-09-04). Interpolate linearly in time instead. - synchro_nice_metis.interpolation_method: linear + synchro_nice_metis_linear.interpolation_method: "linear" + + synchro_nice_metis_nearest.source_uri: "imas:hdf5?path=${CASE_DIR}/preprocess/metis_in" + synchro_nice_metis_nearest.interpolation_method: "closest" sink_transport.sink_uri: "imas:hdf5?path=../../../metis_out" sink_transport.sink_mode: "w" diff --git a/workflows/metis_nice_evol_from_dina/workflow.ymmsl b/workflows/metis_nice_evol_from_dina/workflow.ymmsl index 2f1bd7c7..50423dc6 100644 --- a/workflows/metis_nice_evol_from_dina/workflow.ymmsl +++ b/workflows/metis_nice_evol_from_dina/workflow.ymmsl @@ -53,12 +53,20 @@ models: f_init: [pf_active_in] o_f: [pulse_schedule_out, core_profiles_out, core_sources_out, equilibrium_out] - synchro_nice_metis: - description: re-slices METIS's input trace at NICE's current equilibrium timestamp + synchro_nice_metis_nearest: + description: re-slices METIS's input trace at NICE's current equilibrium timestamp (causal nearest resampling) implementation: sink_source_component ports: f_init: [equilibrium_in] - o_f: [pulse_schedule_out, core_profiles_out, core_sources_out] + o_f: [core_profiles_out, core_sources_out] + + synchro_nice_metis_linear: + description: re-slices METIS's input trace at NICE's current equilibrium timestamp (linear resampling) + implementation: sink_source_component + ports: + f_init: [equilibrium_in] + o_f: [pulse_schedule_out] + metis: description: METIS transport (coupling mode 2), stepping in lockstep with nice_evo_rd @@ -137,9 +145,9 @@ models: source_metis.core_sources_out: metis_init.core_sources_f_init # METIS in-loop inputs from the NICE-equilibrium-driven resync - synchro_nice_metis.pulse_schedule_out: metis.pulse_schedule_in_s - synchro_nice_metis.core_profiles_out: metis.core_profiles_in_s - synchro_nice_metis.core_sources_out: metis.core_sources_in_s + synchro_nice_metis_linear.pulse_schedule_out: metis.pulse_schedule_in_s + synchro_nice_metis_nearest.core_profiles_out: metis.core_profiles_in_s + synchro_nice_metis_nearest.core_sources_out: metis.core_sources_in_s # METIS outputs to sink metis.pulse_schedule_out_i: sink_transport.pulse_schedule_in @@ -184,7 +192,8 @@ models: # NICE outputs to METIS (closes the loop) and to the resync actor nice_evo_rd.equilibrium_o_i: metis.equilibrium_in_s - nice_evo_rd.equilibrium_o_i: synchro_nice_metis.equilibrium_in + nice_evo_rd.equilibrium_o_i: synchro_nice_metis_linear.equilibrium_in + nice_evo_rd.equilibrium_o_i: synchro_nice_metis_nearest.equilibrium_in # NICE outputs to the controller nice_evo_rd.equilibrium_o_i: magnetic_controller.equilibrium_in_s