Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions bin/pds-run-case.sbatch
Original file line number Diff line number Diff line change
Expand Up @@ -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[@]}"
Expand Down
7 changes: 7 additions & 0 deletions cases/overrides/metis_nice_evol_from_dina_105073.ymmsl
Original file line number Diff line number Diff line change
@@ -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
16 changes: 16 additions & 0 deletions cases/overrides/metis_nice_evol_from_dina_105084.ymmsl
Original file line number Diff line number Diff line change
@@ -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
26 changes: 26 additions & 0 deletions controllers/KCURR_RZIp/muscle_NICE_input.m
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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];
Expand Down
34 changes: 34 additions & 0 deletions controllers/KCURR_RZIp/muscle_controller_NICE_IMAS_iter_init.m
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
11 changes: 11 additions & 0 deletions workflows/lib/easybuild_programs.ymmsl
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,17 @@ programs:
executable: python
args: -u $PDS_REPO/workflows/utils/temporal_coupler.py

# 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/reference_rebase.py

metis:
script: |
#!/bin/bash
Expand Down
143 changes: 143 additions & 0 deletions workflows/metis_nice_evol_from_dina/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
# 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.

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
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_<shot>.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/`.

`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
```

## 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` 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

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.
Loading