bouquet

Coil constraint handling

Forward-mode perturbed equilibria must keep coil currents close to the reconstructed baseline to be engineering-realistic. Bouquet enforces this with per-coil hard bounds on TokaMaker’s QP, applied through a progressive homotopy that warm-starts each tighter pass from the prior pass’s converged ψ. Each draw is then tagged in_spec against class-specific tolerances; out-of- spec draws are still archived.

The full source-cited tolerance budget, bound construction, and homotopy failure/rollback semantics are in architecture.md §15. This page is the user-facing summary plus the VSC drift metric, which is specific enough to warrant its own writeup.

Three coil classes (DIII-D reference)

Class Members Baseline range Spec interpretation
Non-VSC F-coils F1A–F8A, F1B–F8B 35–180 kA Tight relative drift (FilterConfig.inspec_F_max, default 0.02)
VSC pair F9A, F9B 35–50 kA Channel-decomposed drift (FilterConfig.inspec_VSC_max, default 0.02) — see below
E-coils ECOILA, ECOILB 0.5–1 kA Bounded by an absolute floor (coil_drift_floor_A, default 50 A) — excluded from in_spec because their small baselines make relative bounds engineering-meaningless

The 50 A floor is calibrated to a realistic current-control tolerance budget: ~30–50 A power-supply ripple + ~30–50 A Rogowski/integrator noise + ~10–100 A un-modelled vessel coupling. coil_drift_floor_A is a generate_bouquet keyword rather than a BouquetConfig field.

VSC drift metric (anti-series pair)

F9A/F9B are an anti-series VSC pair: they move together as a common-mode current ±ΔI that controls the plasma’s vertical position. On vertically sensitive equilibria one of the pair routinely sits near a current zero-crossing (e.g. F9A ≈ −16 kA while F9B ≈ +93 kA). The naive per-coil metric |ΔI| / |I_baseline| then divides a benign common-mode current by F9A’s near-zero baseline and reports a spurious large drift — a 400 A common-mode reads as 2.4% on F9A but only 0.4% on F9B, falsely rejecting the draw. This is the same small-baseline pathology that excludes the E-coils above, and it silently tanks the in-spec yield on any peaked or vertically-unstable baseline.

Bouquet therefore scores the VSC selection metric by decomposing the pair into its two physical degrees of freedom and gating their changes against the error-propagated measurement uncertainty (_vsc_channel_drift_pct in bouquet/TokaMaker_interface.py):

I_cm = (F9A − F9B)/2   # common-mode = vertical control
I_df = (F9A + F9B)/2   # differential = shaping residual

# both channels are linear combinations of two independently measured coils,
# so their uncertainty propagates IN QUADRATURE from the per-coil uncertainties:
sigma_VSC = sqrt(|F9A|² + |F9B|²) / 2  +  denom_floor      # (= offset/gain)
drift_VSC = 100 · max(|ΔI_cm|, |ΔI_df|) / sigma_VSC

This is the measurement-uncertainty reading: “states consistent with the measured VSC currents”. The non-VSC F-coils keep the relative |ΔI|/|I_base| metric, since their uncertainty is gain-dominated. If the binding constraint is instead the VSC power-supply control authority rather than measurement uncertainty, gate |ΔI_cm| against that amp budget directly — same structure, different number.

Note: architecture.md §15.6 still describes the older naive per-coil formula for the VSC pair. This page is the current behaviour.

Progressive homotopy

GenerationConfig.homotopy_passes is a list of (drift_F, drift_VSC) tuples tried in order, each warm-starting from the prior pass’s converged ψ. The default schedule is:

homotopy_passes = [
    (0.05, 0.10),   # Pass 1: loose start
    (0.02, 0.05),   # Pass 2: intermediate
    (0.01, 0.01),   # Pass 3: strict — total F9 drift ≤ 2% (bare 1% + VSC 1%)
]

Total F9A/F9B drift is drift_F + drift_VSC, so pick passes such that their sum is the desired total F9 tolerance. A single direct solve at the tight target is usually QP-infeasible from a cold start — the schedule is what makes ±1% coils reachable.

On infeasibility the homotopy rolls back to the last successful pass and re-solves to restore the solver’s internal flux-surface state (without that re-solve get_stats() returns l_i = inf). If pass 1 itself fails, the draw is rejected.

Related knobs: GenerationConfig.coil_drift (soft target, default 0.01), coil_drift_hard_factor (optional hard bounds at ± factor·coil_drift in every solve, default None = soft only), and SolverConfig.coil_vsc (which coils form the antisymmetric channel).

Per-draw attributes

Every stored draw carries these HDF5 attributes, so you can filter downstream without re-running anything:

Attribute Meaning
homotopy_pass Index of the last successful pass (0-based; −1 if no hard bounds ran)
homotopy_F_lim, homotopy_VSC_lim The (drift_F, drift_VSC) of that pass
max_F_drift_pct Max non-VSC F-coil drift, percent
max_VSC_drift_pct Max VSC channel drift, percent (the metric above)
in_spec max_F_drift_pct ≤ inspec_F_max and max_VSC_drift_pct ≤ inspec_VSC_max
inspec_F_max, inspec_VSC_max The thresholds that were applied

Bouquet.filter() re-applies these thresholds (from FilterConfig) as non-destructive passes_coil_filter / passes_boundary_filter / selected flags; filter_coil_currents and filter_boundaries are the standalone forms.