Skip to content

closure_channel=sawtooth_bootstrap: q0-pinned two-scale Ip closure (+ closure-health flag, ohmic deprecated) - #51

Open
d-burg wants to merge 4 commits into
hybrid-ohmic-modefrom
feat/q0-closure
Open

closure_channel=sawtooth_bootstrap: q0-pinned two-scale Ip closure (+ closure-health flag, ohmic deprecated)#51
d-burg wants to merge 4 commits into
hybrid-ohmic-modefrom
feat/q0-closure

Conversation

@d-burg

@d-burg d-burg commented Sep 7, 2026

Copy link
Copy Markdown
Owner

closure_channel="sawtooth_bootstrap": q0-pinned two-scale Ip closure

Stacked on hybrid-ohmic-mode (#42). Adds a third closure channel for the
ohmic baseline mode that determines both hybrid scales — Ip exactly (the
affine FSA measure, as the other channels) and q0 to first order — at
essentially no extra cost.

Why

bootstrap frees only s_bs, so nothing holds q0 in place: a large
bootstrap down-scale strips the core share too, and on sawtoothing
discharges q0 drifts several times the 0.01 acceptance. ohmic frees only
s_ohm and is far worse (core hollowing, q0 → 1.3–2.6, no q=1 surface).
On a sawtoothing flattop q0 ≈ 1 is a robust physical fact and the source's
sawtooth model enforces it; pinning to it is the physically justified second
constraint the design notes had already called for.

How (utils.close_ip_q0, run.py predictor/corrector)

At frozen anchor geometry q0 ∝ 1/j_φ(0), so matching q0 is matching the
on-axis current density — a linear 2×2 in (s_ohm, s_bs): an
axis-current row plus the exact-Ip row close_ip already uses. Closed form,
zero GS solves. The closed-hybrid solve that happens anyway reads back q0;
only if it misses q0_tol (0.01 absolute) is one analytic Newton step
taken along the Ip-closed manifold — never a loop. Where the recomputed
bootstrap has no core content the axis row pins s_ohm = 1 and the channel
reduces to bootstrap exactly, for free.

The q0 reference is the source's own q0 as TokaMaker estimates it
(utils.unrenormalise_q0: the anchor's q0 mapped back onto the source's
requested axis current — the anchor is a solve renormalised to Ip, so its raw
q0 belongs to a current the source never claimed). Estimator-consistent
(issue #20 lesson); the dd's own q[0] and the basis are recorded.

Gate

The pin applies only where sawteeth justify it: admitted when the source's
sawtooth model is active at the slice (core_sources identifier index 701
carrying current — read once at load, present-but-idle ≠ active) or the
source's own |q0_dd| ≤ q0_gate (1.1); otherwise it falls back to
bootstrap with a printed note. Gating on the estimator-mapped target
instead admitted idle-sawtooth ramp slices — fixed here.

Closure health (every channel) and deprecation

Every ohmic-mode run now records raw_components_ip_mismatch_pct,
f_BS_unscaled/f_BS_closed, and a closure_limited flag (raw mismatch

10 % of Ip, or bs_scale < 0.5) with reasons, surfaced in
li_metrics. Ip conservation only says the components' integral is off,
not where; a flagged slice's current split — and any Δ′ built on it — is
unvalidated regardless of channel. closure_channel="ohmic" is
deprecated (DeprecationWarning; diagnostic bracket only).

Evidence (local campaigns on DIII-D FUSE postdictive + IDA-lite data; no data in this repo)

  • Six sawtoothing flattop discharges, 24 slices: q0 within 0.01 on 24/24
    vs bootstrap 11/24; corrector fired 9/28, always +1 solve, always helped.
  • A full-discharge pair (ramp-up → ramp-down, 90 runs, 0 failures): flattop
    15/15 vs 2/15, ramp-down 6/6 vs 1/6, ramp-up identical (degenerate limit);
    q=1 surface present on 9/15 vs 2/15 flattop slices; Δ′(2/1) agrees with
    bootstrap on 26/29 slices and removes its large negative outliers;
    ohmic 0/15 with implausible Δ′.
  • A flagship Δ′-increase case: both bootstrap-family channels preserve the
    crossing; this channel reproduces the recorded crossing time where
    bootstrap moves it 250 ms earlier; ohmic never crosses.
  • A high-βp pair: 10/12 vs 0/12 on q0, but Δ′(2/1) flips sign between the
    channels on 4/6 slices — two scales cannot pin ρ(q=2), which is why the
    code default stays bootstrap and why closure_limited exists.

Tests

tests/test_ohmic_closure.py: the 2×2 hits both targets exactly and reduces
to bootstrap without core bootstrap; the un-renormalisation mapping (ratio,
identity, COCOS sign, refusals); singular-system refusal on a relative
floor; gate basis and sign handling; closure-health flags under both current
conventions; the deprecation warning. Fast suite: 434 passed.

🤖 Generated with Claude Code

d-burg and others added 4 commits September 4, 2026 17:40
…olves

The plain "bootstrap" channel closes Ip on j_BS alone and has nothing holding
q0 in place: a large s_bs down-scale removes the CORE share of the recomputed
bootstrap too, so q0 drifts even though FUSE's diffused j_ohmic is untouched.
On a sawtoothing flattop q0 ~ 1 is a robust physical fact and the GPEC Delta'
runs are sensitive to it (SAWTOOTH_BOOTSTRAP_PLAN section 1).

Two unknowns, two targets, and -- deliberately -- no root-find.  A bisection on
q0 would cost 6-10 GS solves per slice; instead, at frozen anchor geometry
q0 = 2 B0 (1+kappa0^2)/(2 kappa0 mu0 R0 j0) is just q0 ~ 1/j0, so matching q0
to first order is matching the on-axis current density.  That makes the whole
closure a LINEAR 2x2 (utils.close_ip_q0):

    s_ohm*j_ind(0) + s_bs*j_BS(0) = j_ref(0) - j_fix(0)     axis current -> q0
    s_ohm*lin(ohm) + s_bs*lin(bs) = sgn*Ip - c - lin(fix)   exact Ip (affine)

solved in closed form for zero extra solves.  Ip stays exact by construction
everywhere, as in every other channel; q0 is first-order and verified against
the closed-hybrid solve that the common tail already runs.  If that lands
outside q0_tol (0.01 absolute), ONE analytic Newton step along the Ip-closed
manifold (dq0/ds_ohm = -q0 (dj0/ds_ohm)/j0, ds_bs/ds_ohm = -lin(ohm)/lin(bs))
and the result is accepted whatever it gives.  No loop: the cost ceiling is
the point, and a residual that is reported beats one iterated away invisibly.

q0_ref costs nothing either.  The anchor snapshot IS the converged forward
solve of the source's own total (solve_jphi(bl.j_phi) runs before copy_eq()),
so the estimator-consistent reference the plan asks for -- TokaMaker's q0 for
FUSE's total on the anchor, never the dd's own q estimator (issue #20) -- is
the anchor's own get_q at the psi_pad-clipped axis.  The dd's q[0] is recorded
alongside for comparison only.

j_ref(0) is the anchor's ACHIEVED axis current (its GS-reconstructed own
profile), not the requested FUSE total: solve_jphi hands TokaMaker a shape and
TokaMaker renormalises it to Ip, and FUSE's total carries -3.7% of Ip on the
reference slice, so the anchor ran on ~1.04x what was requested and q0_ref
belongs to that state.  Targeting the requested value would bake the Ip deficit
into the axis match as a systematic q0 error several times q0_tol and fire the
corrector on every slice.  Both values are recorded.

Gated on the physics that justifies it (plan section 2.3): admitted when the
FUSE sawtooth source (core_sources identifier index 701) carries non-zero
j_parallel at the slice OR q0_ref <= q0_gate (1.1); otherwise it prints why and
falls back to close_ip("bootstrap") -- never silently.  A declared-but-idle
sawtooth source does not admit a ramp slice.  Where the recomputed bootstrap
has no core content the 2x2 degenerates to exactly close_ip("bootstrap"), as
the plan predicts, at zero cost -- pinned by a test.

  bouquet/utils.py     close_ip_q0: the 2x2, with a RELATIVE determinant floor
                       (A/m^2 and A share no absolute epsilon) and the same
                       [0.2, 5] refusal as close_ip
  bouquet/run.py       _close_ip_q0_predictor / _close_ip_q0_corrector, the
                       dispatch, and both channel whitelists
  bouquet/io/imas.py   SAWTOOTH_SOURCE_INDEX=701; the sawtooth amplitude and
                       the dd's own axis q, read at load time because the
                       reader does not retain the (100s of MB) dd
  bouquet/baseline.py  Baseline.sawtooth
  bouquet/config.py    q0_gate=1.1, q0_tol=0.01
  tests                7 solve-free tests: both targets hit exactly, the
                       bootstrap degeneracy, the singular/bounds/NaN refusals,
                       the gate's OR logic and the idle-source rule

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…dd's own q[0] is -0.99, which would bypass the threshold silently); read q off a copy_eq snapshot, never the live solver

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d anchor

User decision, replacing the achieved-axis-current reference.  The anchor is a
solve of the source total that solve_jphi renormalised to Ip_target, so on a
slice where FUSE's core_profiles total does not carry Ip (-3.89 % on the reference validation slice)
its q0 belongs to a current FUSE never claimed.  Pinning to it propagated that
known DATA artefact into the current split, which is precisely what every
other channel refuses to do -- they absorb the Ip deficit into ONE scale and
leave the shape alone.

The target is now that q0 mapped back onto the source's own current, to the
same first order the predictor already runs on (q0 ~ 1/j_phi(0) at frozen
geometry):

    q0_target = q0_anchor * (j_achieved(0) / j_requested(0))

and the axis row matches j_requested(0) -- the source total at the clipped
axis.  Applied consistently, not as a lone j_ref0 swap: the GATE now tests
|q0_target| (the q0 actually being claimed, which on a large-deficit slice can
sit the other side of the threshold from the anchor's), and the corrector's
acceptance, Newton step and recorded residual all compare against q0_target.

  utils.unrenormalise_q0   the ratio, extracted so the tests exercise the
                           SHIPPED formula rather than a re-derivation (the
                           TestClosureAlgebra rule); refuses a zero or
                           non-finite requested axis current, carries the
                           COCOS sign rather than stripping it
  ip_closure               q0_anchor, q0_target, q0_target_source, q0_dd,
                           j_ref0_achieved, j_ref0_requested, j_renorm_ratio,
                           j_ref0_used -- the un-renormalisation is auditable
                           from the recon JSON alone
  li_metrics["sawtooth"]   gate inputs (max|j_par| at the slice, active/
                           present, q0_dd, + q0_target/q0_anchor when
                           computed) now travel with EVERY IMAS baseline, not
                           just q0-channel runs: choosing fan-out slices is
                           exactly the moment you do not yet have a q0-channel
                           run to read, and re-reading a 179 MB dd per slice
                           to answer "would the gate admit this?" is absurd
  tests                    4 new for the mapping (ratio against the measured
                           the reference validation slice numbers, no-op identity when the source does
                           carry Ip, sign carried, zero/NaN refused); the
                           degeneracy test now asserts s_ohm == 1 exactly

Validated on the reference validation slice: s_ohm 0.9977, s_bs 0.9334 (bootstrap: 1.0000 /
0.9251) -- it does reduce to bootstrap, as expected.  Zero extra solves, 36 s
vs 35 s.  But the residual is +0.0077 against q0_tol 0.01: matching the axis
current EXACTLY still left q0 0.8 % off target, because the Ip-closed hybrid
reproduces its request as an integral and not pointwise on axis.  That model
error is comparable to the correction being made, and it is recorded in the
predictor docstring rather than tuned away -- q0_tol is the validity band of
the linearisation, not a knob.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…flag on every channel, deprecate the ohmic channel

Campaign follow-ups (six sawtoothing flattop discharges, a full-discharge
pair from ramp-up to ramp-down, a flagship Delta' case, and a high-beta_p
pair):

- Gate: compare the SOURCE's own |q0_dd| (the physically clamped value on
  a sawtoothing discharge) against q0_gate, OR admit on sawtooth activity.
  Gating on q0_target -- the TokaMaker-estimator mapping -- read
  systematically lower and admitted idle-sawtooth ramp slices whose own
  q0_dd sat above the threshold.  q0_target is used only when the source
  carries no axis q; the basis is recorded (utils.q0_gate_admits).
- Closure health, every channel (utils.closure_health -> ip_closure and
  li_metrics['closure_limited']): raw-components Ip mismatch, unscaled and
  closed bootstrap fractions, and a closure-limited flag when the raw
  mismatch exceeds 10 % of Ip or the bootstrap is scaled below 0.5.  Ip
  conservation says the integral is off, not where; a flagged slice's
  current split (and any Delta' built on it) is unvalidated regardless of
  channel.  Refusals stay refusals.
- closure_channel='ohmic' is deprecated (DeprecationWarning + printed
  warning; utils.warn_deprecated_channel), kept selectable for bracketing
  only: it hollows the core, lifts q0 far above the source's, loses the
  q=1 surface on most sawtoothing slices and yields implausible Delta'.
- config docs: sawtooth_bootstrap is the recommended flattop channel for
  sawtoothing discharges (never worse than bootstrap, degenerates to it
  where the recomputed bootstrap has no core content, <= 1 extra solve);
  the code default stays 'bootstrap' pending a mid-radius constraint for
  the high-beta_p regime.  Shot references removed from comments (public
  repo).

9 new solve-free tests (gate basis and COCOS sign, closure-health flags
under both current conventions, deprecation warning). Fast suite: 434.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 7, 2026 21:12

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The new q0 corrector can raise a ZeroDivisionError when ip_bs == 0.0, which is a runtime crash risk in the added control path.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds a new IMAS ohmic-baseline closure channel (closure_channel="sawtooth_bootstrap") that closes Ip and approximately pins q0 (with an at-most-one-solve corrector), plus slice-level gate inputs/diagnostics and a standardized “closure health” record; also deprecates closure_channel="ohmic".

Changes:

  • Introduces q0-target mapping + gate helpers and a 2×2 analytic predictor (unrenormalise_q0, q0_gate_admits, close_ip_q0) plus a standardized closure_health record and an ohmic-channel deprecation warning.
  • Integrates the new closure channel into the IMAS ohmic-mode baseline flow (predictor/corrector, gating, metrics recording).
  • Extends IMAS reader + baseline schema to persist sawtooth gate inputs and adds comprehensive unit tests.
File summaries
File Description
tests/test_ohmic_closure.py Adds tests for q0 predictor/target mapping, gate behavior, closure-health flags, and deprecation warning.
bouquet/utils.py Adds q0 un-renormalisation, gate helper, closure-health record, deprecation warning, and 2×2 Ip+q0 closure solver.
bouquet/run.py Implements sawtooth-bootstrap predictor/corrector flow, gating, closure-health recording, and channel validation updates.
bouquet/io/imas.py Reads sawtooth-model activity + q0_dd from IMAS dd for gate inputs; threads through Baseline.
bouquet/config.py Documents new channel semantics, deprecation, and adds q0 gate/tolerance config knobs.
bouquet/baseline.py Adds Baseline.sawtooth field for persisted slice-level gate inputs.
Review details
  • Files reviewed: 6/6 changed files
  • Comments generated: 3
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread bouquet/run.py
Comment on lines +811 to +815
dsbs = -state["ip_ind"] / state["ip_bs"]
dj0 = state["j_ind0"] + state["j_bs0"] * dsbs
j0 = (state["ohm_scale"] * state["j_ind0"]
+ state["bs_scale"] * state["j_bs0"] + state["j_fix0"])
dq0ds = -q0_tok * dj0 / j0 if j0 else 0.0
Comment thread bouquet/run.py
Comment on lines +624 to +627
and the axis row is matched against ``j_requested(0)`` = the source
total at the clipped axis. Both axis currents and their ratio are
recorded so the un-renormalisation is auditable, and the gate tests
``|q0_target|`` -- the physical q0 being claimed -- not the anchor's.
Comment thread bouquet/utils.py
Comment on lines +883 to +886
raise RuntimeError(
f"close_ip_q0: {name} {s:.3f} is outside [{lo:g}, {hi:g}] -- "
"no (Ip, q0)-consistent split exists within the scale bounds; "
"refusing to hide that behind a rescale")
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants