Skip to content

TokaMaker: Native jphi-linterp Ip hold (via #267) + bootstrap scaler simplification#1

Open
d-burg wants to merge 18 commits into
mainfrom
feat/jphi-linterp-Ip-cutcell-fix
Open

TokaMaker: Native jphi-linterp Ip hold (via #267) + bootstrap scaler simplification#1
d-burg wants to merge 18 commits into
mainfrom
feat/jphi-linterp-Ip-cutcell-fix

Conversation

@d-burg

@d-burg d-burg commented Jun 4, 2026

Copy link
Copy Markdown
Owner

TokaMaker: Native jphi-linterp Ip hold (via OpenFUSIONToolkit#267) + bootstrap scaler simplification

Summary

When TokaMaker solves with a jphi-linterp profile (caller specifies ⟨j_φ⟩,
solver back-solves FF'), the integrated plasma current drifts from Ip_target
by ~0.5–1 % due to a cut-cell quadrature mismatch near the separatrix. This branch
makes the GS solve hold Ip to target natively (~0.0004 %) and, now that the
native hold is reliable, removes the redundant Python Ip-rescaling secant from
solve_with_bootstrap and simplifies find_optimal_scale to a core-j_0-only
scaler.

This branch now contains two pieces:

Part Source
jphi-linterp native Ip hold (Fortran) @hansec's updated OpenFUSIONToolkit#267, merged in (gs_itor_nl damped relative-norm update + pressure-update ordering)
Bootstrap scaler simplification (Python) this branch (bootstrap.py)

We previously carried our own Fortran outer-loop for the native hold. After
head-to-head testing (below), OpenFUSIONToolkit#267's in-loop approach proved tighter and faster,
so we dropped our outer-loop and merged OpenFUSIONToolkit#267 instead. Our standalone Fortran PR
(OpenFUSIONToolkit#290) is closed in favor of OpenFUSIONToolkit#267. Once OpenFUSIONToolkit#267
lands in upstream main and this fork's main syncs, the Fortran here dedupes
and this PR reduces to just the bootstrap.py diff.

What's included

jphi-linterp Ip handling (Fortran) — from OpenFUSIONToolkit#267:

  • jphi_update normalizes against the physical current (gs_itor_nl) under a
    damped multiplicative update relative to the previous norm
    (jphi_norm = (1 + Ip_target/itor_nl)·norm_last/2), which converges where the
    earlier absolute-estimate relaxation oscillated.
  • P%update is moved ahead of the current integral (and p_scale set) so the
    current is computed against an up-to-date pressure profile.
  • Itor_targetIp_target rename and Ip_target_skip flag.

Bootstrap scaler simplification (bootstrap.py) — this branch:

  • solve_with_bootstrap: drop the find_optimal_scale(find_j0=False) Ip-scale
    secant → final_scale_Ip = 1.0 (Ip is now held by the solve). The core-j_0
    scale (find_j0=True) is unchanged.
  • find_optimal_scale: remove the now-unused find_j0=False branch,
    get_Ip_error, and the find_j0/scale_j0 parameters — now a clean core-j_0
    matcher.
  • analyze_bootstrap_edge_spike: guard an empty edge slice so numpy.max no
    longer raises.

Why

Downstream (the bouquet perturbed-equilibrium workflow) carried several Python
Ip-alignment secants purely to compensate for the jphi-linterp drift. With the
native hold those are redundant; this removes the OFT-side one and unblocks
removing the rest downstream.

Validation

jphi-linterp Ip hold across operating points (DIII-D-like H-mode, same mesh /
coils / isoflux; realized Ip via get_stats):

Ip target (MA) Ip realized error nl_its
0.90 0.9000 −0.0000 % 15
1.08 1.0800 +0.0004 % 13
1.20 1.2000 +0.0004 % 12
1.32 1.3200 +0.0004 % 12
1.50 1.5001 +0.0005 % 12

Holds Ip to ~0.0004 % of target in 12–15 nonlinear iterations across the range.
(For reference, our earlier outer-loop held ~0.01–0.05 % in 14–39 iterations on
the same cases; OpenFUSIONToolkit#267 is ~100× tighter and faster.)

  • find_optimal_scale core-j_0 behavior unchanged; the Python package imports
    and the merged tree builds cleanly.

Notes

hansec and others added 13 commits April 15, 2026 11:57
Consolidates post-PR#195 improvements to bootstrap.py:
- Add prominence filter to edge spike detection
- Fix degenerate curve_fit bounds in analyze_bootstrap_edge_spike
- Tighten Ip scale factor bracket from ±10% to ±1%
- Add verbose flag to solve_with_bootstrap and find_optimal_scale
- Move log Lambda calc inside relevant logic
- Various bug fixes and diagnostics

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
 - Replace `Itor_target` with `Ip_target` throughout code
Address the well-known ~0.5-1% Ip discretization mismatch between
jphi_update's flux-averaged normalization (gs_flux_int) and
gs_comp_globals' physical-Ip integration (R*P' + 0.5*FFP/R).
Disagreement is structural -- the two formulas integrate
different quantities, agreeing only when <R>*<1/R> = 1 pointwise
(circular plasma).  For D-shaped DIII-D plasma, ~0.76% mismatch.

Fix: outer iteration in tokamaker_solve wrapper, gated on
jphi_flux_func profile type detection.  After each inner Picard
solve, compute Ip_phys via gs_comp_globals, compare to original
Itor_target, scale Itor_target by a damped multiplicative
correction, re-Picard.  Continues until rel_err < 5e-4 or
max_outer (5) iterations.

Key design choices:
  * jphi_update remains stateless from caller perspective --
    avoids breaking caller-side iterative wrappers (eg. bouquet's
    find_optimal_scale, _corrective_jphi_iteration) that depend
    on solve being a deterministic FFP-from-jphi mapping for a
    given equilibrium
  * Damped correction (^0.7) + hard clamp [0.7, 1.3] for safety
  * Only activates for jphi_flux_func profiles -- PP'/FF' users
    unaffected
  * Skipped for vacuum solves (no Ip target)

Validation (DIII-D 204441 @ 4.4s recon):
  * Ip error: -0.7570%   -> -0.0396%  (19x improvement)
  * PIN_JPHI draw 1 bnd-diag post-Ip-align: 3.68 mm -> 0.00 mm
  * Recon's [Ip match] secant converges identically (deterministic
    callable behaviour preserved)
  * No build regressions

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…loop

User-reported regression: with the outer loop in place, recon flow
showed ~2% Ip drift after many internal solves.  Cause: outer loop
scales gseq%Itor_target multiplicatively but never restores it.
Each subsequent mygs.solve() call starts from the previous call's
final (inflated) Itor_target -- cumulative inflation across the
~dozens of solves a typical recon flow performs.

Fix: save Itor_target magnitude at entry, restore at exit (preserving
whatever sign jphi_update last set).  The FFP is calibrated for the
inflated target during the final inner Picard, so the equilibrium
correctly has Ip ~ user_target; only the bookkeeping target is reset.
Future mygs.solve() calls start from the clean user-set target and
re-run the outer loop if needed.

Validation (PIN_JPHI=1, sigma=0, N=3 draws):
  * Ip-align all 3 draws: actual_Ip == target (no-op corrections)
  * bnd-diag post-Ip-align: 0.00 mm (all draws)
  * bnd-diag post-homotopy: 0.39 mm (just iso-update + homotopy noise)
  * F-drift 0.00%, VSC drift 0.02% on every draw
  * 3/3 IN_SPEC
  * No cumulative drift across draws

Before fix (original pre-outer-loop OFT):
  PIN_JPHI sigma=0: 3.69 mm RMS, Ip err -0.76%
After fix (outer loop + restore):
  PIN_JPHI sigma=0: 0.39 mm RMS, Ip err +0.005%   -- ~10x and 150x

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…d ip_phys

Guard the multiplicative Itor_target correction against a corrupted ip_phys
returned by gs_comp_globals after a nominally-successful (ierr=0) inner solve.
Three failure modes, all observed under tight coil-bound homotopy at low
j_phi pinning: ip_phys <= 0 (degenerate), ip_phys NaN/Inf (singular flux
state), or |ip_phys| outside [0.3, 3.0]x target (QP went singular, plasma
collapsed to device center, integral is bogus). Applying a correction from
that state inflates Itor_target to nonsense and corrupts downstream solves;
instead bail without correction and let the caller's rollback recover.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…j0 only

The GS solve now holds Ip to target natively (jphi-linterp cut-cell fix +
Ip-correction outer loop), so the find_optimal_scale(find_j0=False) Ip-scale
secant in solve_with_bootstrap is redundant -> final_scale_Ip = 1.0.

Strip the now-unused find_j0=False branch, get_Ip_error, and the
find_j0/scale_j0 parameters from find_optimal_scale; it is now a clean
core-j0 matcher. Callers updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 4, 2026 20:34

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.

Pull request overview

This PR updates TokaMaker’s jphi-linterp workflow to (1) hold plasma current Ip to the requested target during the Grad–Shafranov solve via a Fortran-side outer correction loop, and (2) simplify the Python bootstrap workflow now that Ip alignment no longer requires a downstream secant rescaling.

Changes:

  • Add a jphi-linterp-specific outer iteration in tokamaker_solve to iteratively correct Itor_target so that physical Ip (from gs_comp_globals) matches the original target.
  • Simplify find_optimal_scale to a single “core-j0 matching” secant path and remove the Python-side Ip rescaling secant in solve_with_bootstrap.
  • Improve edge-spike peak selection in analyze_bootstrap_edge_spike by adding a prominence filter and preferring the tallest peak in the far edge region.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
src/python/wrappers/tokamaker_f.F90 Adds a jphi-linterp-only outer correction loop in tokamaker_solve to converge realized Ip to the user’s target.
src/python/OpenFUSIONToolkit/TokaMaker/bootstrap.py Removes redundant Python Ip rescaling in bootstrap solve flow and simplifies scale optimization to core-j0 matching.

Comment on lines +210 to +213
# Find peak in the edge region (prominence filter suppresses noise
# oscillations that can be misidentified for small bootstrap spikes)
min_prominence = 0.05 * numpy.max(j_edge) if numpy.max(j_edge) > 0 else 0.0
peaks, properties = find_peaks(j_edge, height=0., prominence=min_prominence)
Comment thread src/python/wrappers/tokamaker_f.F90 Outdated
Comment on lines +724 to +730
IF(ip_phys_bad)THEN
IF(oft_debug_print(1))WRITE(*,'(A,I0,A,2ES16.8)') &
' [JPHI_IP] iter ', outer_it, &
' SAFETY BAIL -- ip_phys out of sane range, ip_phys/target =', &
ABS(ip_phys), ip_phys_target
EXIT ! caller will see equilibrium as solver produced it; restore handled below
END IF
- Fortran (identical to OpenFUSIONToolkit#290 update c68d383): signal failure on the
  jphi-linterp Ip safety-bail via error_str so a corrupted equilibrium no
  longer returns as a nominal success; fix the bail debug label.
- bootstrap.py `analyze_bootstrap_edge_spike`: guard an empty edge slice
  (psi_N may not reach psi_N>=0.7) so numpy.max no longer raises; compute the
  edge max once.
@d-burg

d-burg commented Jun 4, 2026

Copy link
Copy Markdown
Owner Author

Addressed in 21fcd91:

hansec and others added 4 commits June 4, 2026 20:19
# Conflicts:
#	src/physics/grad_shaf.F90
#	src/python/wrappers/tokamaker_f.F90
…rop our outer-loop, keep bootstrap.py simplification
@d-burg d-burg changed the title TokaMaker: Hold Ip natively for jphi-linterp profiles + simplify the bootstrap scaler TokaMaker: Native jphi-linterp Ip hold (via #267) + bootstrap scaler simplification Jun 5, 2026
@d-burg

d-burg commented Jun 5, 2026

Copy link
Copy Markdown
Owner Author

@hansec I have some small cleanups in the wake of your Ip fix, which this builds on. The jphi-linterp Ip fix (hansec:tMaker_jphi_Ip, OpenFUSIONToolkit#267) successfully holds Ip to target natively (~0.0004 %) during the GS solve. That branch is merged in here so this can be tested/used now; once OpenFUSIONToolkit#267 lands in upstream main its Fortran will dedupe and this PR reduces to the bootstrap.py diff below.

Actual changes in this PR (bootstrap.py)

Now that the solve holds Ip natively, the Python-side Ip rescaling is redundant:

  • solve_with_bootstrap: drop the find_optimal_scale(find_j0=False) Ip-scale secant → final_scale_Ip = 1.0.
  • find_optimal_scale: remove the now-unused find_j0=False branch, get_Ip_error, and the find_j0/scale_j0 params — now a clean core-j_0 matcher.
  • analyze_bootstrap_edge_spike: guard an empty edge slice so numpy.max doesn't raise.

Everything else in the diff (grad_shaf*.F90, tokamaker_f.F90, .gitignore) is OpenFUSIONToolkit#267, included only until it merges upstream.

Validation

jphi-linterp solve holds Ip to ~0.0004 % of target in 12–15 nonlinear iterations across 0.9–1.5 MA (DIII-D-like H-mode); find_optimal_scale core-j_0 behavior unchanged; package imports and the tree builds cleanly.

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.

3 participants