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
Open
closure_channel=sawtooth_bootstrap: q0-pinned two-scale Ip closure (+ closure-health flag, ohmic deprecated)#51d-burg wants to merge 4 commits into
d-burg wants to merge 4 commits into
Conversation
…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>
There was a problem hiding this comment.
🟡 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 standardizedclosure_healthrecord and anohmic-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 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 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 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") |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
closure_channel="sawtooth_bootstrap": q0-pinned two-scale Ip closureStacked on
hybrid-ohmic-mode(#42). Adds a third closure channel for theohmic 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
bootstrapfrees onlys_bs, so nothing holds q0 in place: a largebootstrap down-scale strips the core share too, and on sawtoothing
discharges q0 drifts several times the 0.01 acceptance.
ohmicfrees onlys_ohmand 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.pypredictor/corrector)At frozen anchor geometry
q0 ∝ 1/j_φ(0), so matching q0 is matching theon-axis current density — a linear 2×2 in
(s_ohm, s_bs): anaxis-current row plus the exact-Ip row
close_ipalready 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 steptaken along the Ip-closed manifold — never a loop. Where the recomputed
bootstrap has no core content the axis row pins
s_ohm = 1and the channelreduces to
bootstrapexactly, 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'srequested 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_sourcesidentifier index 701carrying current — read once at load, present-but-idle ≠ active) or the
source's own
|q0_dd| ≤ q0_gate(1.1); otherwise it falls back tobootstrapwith a printed note. Gating on the estimator-mapped targetinstead 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 aclosure_limitedflag (raw mismatchEvidence (local campaigns on DIII-D FUSE postdictive + IDA-lite data; no data in this repo)
vs
bootstrap11/24; corrector fired 9/28, always +1 solve, always helped.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
bootstrapon 26/29 slices and removes its large negative outliers;ohmic0/15 with implausible Δ′.crossing; this channel reproduces the recorded crossing time where
bootstrapmoves it 250 ms earlier;ohmicnever crosses.channels on 4/6 slices — two scales cannot pin ρ(q=2), which is why the
code default stays
bootstrapand whyclosure_limitedexists.Tests
tests/test_ohmic_closure.py: the 2×2 hits both targets exactly and reducesto
bootstrapwithout 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