Skip to content

Equilibrium - BUGFIX! - Sample all flux surfaces on common straight-fieldline abscissae and cap the coefficient-spline core knots - #398

Open
logan-nc wants to merge 32 commits into
developfrom
performance/consistent-surface-theta-parametrization
Open

logan-nc wants to merge 32 commits into
developfrom
performance/consistent-surface-theta-parametrization

Conversation

@logan-nc

@logan-nc logan-nc commented Aug 16, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Each flux surface was traced independently and then splined on that surface's own solver-chosen abscissae before being resampled onto the common θ grid (DirectEquilibrium.jl). The resample error was therefore uncorrelated between neighbouring surfaces — white noise in ψ that grid refinement amplifies rather than reduces. Neither reltol nor abstol touched it, because it is remap interpolation error, not integration error.

The trace now returns its dense solution, and equilibrium_solver root-solves (Brent, bracketed by the monotone jac-weighted flux integral) for the angle at which the normalised straight-fieldline angle reaches each target node, evaluating there. Every surface is sampled at identical abscissae and the resample error at the output nodes is zero. The arclength tracer returns nothing and keeps the previous path.

Investigated under issue #376.

Review round (2026-10-03)

Sampling is now part of the tracer contract. Every field-line tracer takes the target
straight-fieldline angles and returns its surface already sampled there, through one shared helper
(sample_trace_at_sfl_angles). equilibrium_solver has a single path. Before this round the fix
covered only direct_fieldline_int: efit_arclength still resampled each surface from its own
adaptive steps
and kept the noise, and the sol === nothing branch would have given any future
tracer the same silent fallback.

Pre-existing bug fixed: F and P were read at the solver's last evaluation, not on the surface.
direct_fieldline_int returned its ODE scratch DirectBField, which the right-hand side, the
refinement callback and the dense interpolant overwrite on every call. The arclength tracer already
kept them separate. The correction is ≤1.5e-8 in P (Solovev, at the edge) and ~1e-10 in q and ν.

The restructure is otherwise a pure restructure. With the F/P fix applied to both the old
sampling and the refactored one, the harness is bit-identical on all six cases. Every number
below that moved relative to the pre-review head is therefore attributable to the arclength fix or
the F/P fix.

New test, runtests_sfl_sampling.jl, checks the property rather than the mechanism, and was
mutation-checked against the code it guards:

code Solovev rzphi, mpsi 64 vs 128 efit ↔ efit_arclength, same grid
develop (old resampling, both tracers) fail, 2.1e-4 to 1.1e-3 fail, 3.9e-4
this branch before the arclength fix pass fail, 2.4e-4
this branch pass, ≤ 2.2e-7 pass, 2.3e-6

The remaining ~2e-6 tracer disagreement is the arclength trace's ordinary integration error. It
follows the level set by construction, and at its configured position tolerance it sits within ~1e-6 m
of the flux surface. That is the floor of the agreement test; tightening it would mean tightening
that tolerance.

Merged in: #414 (near-axis kinetic validity suppression), which also cures the DIII-D kinetic
step regression listed below. Relation to #480: measured, and it does not make this PR
redundant: comment.

Why Solovev's energy moves 98%: an independent check (2026-10-04)

Solovev is analytic, but GPEC does not trace the formula. It samples ψ on an (R, Z) grid, splines it,
and traces the spline. On develop each traced surface was then resampled from its own solver
steps
. On Solovev_ideal_example, which runs at etol = 1e-7, the 9th-order solver crosses a whole
surface in 23–29 steps. A cubic spline through ~25 points, resampled at 257 angles, is off by
2–4e-3 in r² compared with a densely traced surface (measured). The error differs from surface to
surface, so the ψ-derivatives in the Euler-Lagrange coefficients amplify it. et[1] is a
near-cancellation (ep ≈ −10.4, ev ≈ +10.4), so a 6.5 % plasma-energy error became a 98 % et[1] error.

This check does not depend on this PR's code. Tightening etol makes develop's steps denser and walks
it toward this PR's answer, and this PR's answer does not depend on etol:

etol develop et[1] develop ep this PR et[1] this PR ep
1e-7 (the deck) 0.6773 −9.7361 0.010735 −10.41668
1e-10 0.011719 −10.41523 0.010735 −10.41668
1e-13 0.011520 −10.41590 0.010735 −10.41668

Develop is still 7 % high at 1e-13 because it still takes only 40–70 steps per surface. Correction to
earlier text in this description:
the removed error was called "~1e-6". On this deck it is ~1e-3. On
DIII-D the shift is only 0.1 % because that deck runs at etol = 1e-10, where the steps are denser.

@d-burg, this is likely relevant to your work:

  • Tearing and Δ′ studies on traced equilibria. On develop, any directly traced equilibrium (sol,
    tj_analytic_direct, efit) carried this etol-dependent geometry error, at roughly the scale
    above. That includes the Cerfon–Freidberg equilibrium in Equilibrium - FEATURE - 🌱 Add the Cerfon-Freidberg diverted analytic equilibrium #495, which returns a DirectRunInput.
    Δ′ depends on exactly the ψ-derivatives that amplify it. The inverse paths (lar, tj_analytic,
    CHEASE) do not have this mechanism.
  • Equilibrium - BUGFIX! - 🚨 Tie the equilibrium ODE absolute tolerance to etol #491 changes abstol on the two tracer solves this PR restructures. On develop, part of the
    numerical movement from a tolerance change comes through this resample sparsity rather than through
    integration accuracy. After this PR, step density no longer enters the sampled geometry. The two
    PRs also overlap textually on those solve lines, though the resolution is mechanical.

The headline: the answer stops depending on the grid

Solovev, same commit, only this change differing — the free-boundary energy was not converging before and is now converged to 6 significant figures:

mpsi et[1] develop et[1] this PR EL steps develop EL steps this PR
256 1.959e-02 1.462068e-02 1074 775
512 5.399e-02 1.462087e-02 1558 806
1024 8.890e-02 1.462084e-02 2689 891

develop's et[1] drifts 4.5× across the ladder and is still moving; the plasma energy drifts with it (−10.4049 → −10.3144). With this change both are grid-invariant, and the step count is nearly flat (1.04×, 1.11× per doubling versus 1.45×, 1.73×).

That is the property we want: once the equilibrium splines resolve the equilibrium, adding knots should change neither the answer nor the work.

Regression harness

regress --cases diiid_n1,solovev_n1 --refs develop,local — original pre-port measurement, baseline develop @ 9491f89. The current numbers, re-run against develop @ 349a0c262, are in the ## Regression report section below; the mechanism discussion here is unaffected.

diiid_n1 — physics moves only in the 3rd–4th digit, cost halves:

quantity develop local diff
total energy Re(et[1]) 8.012318e-01 8.004564e-01 0.10%
plasma energy Re(ep[1]) -1.348486e+00 -1.349677e+00 0.09%
vacuum energy Re(ev[1]) 2.149718e+00 2.150134e+00 0.02%
plasma volume 1.829472e+01 1.829471e+01 0.00%
ODE steps (total) 4572 1974 −56.8%

q0, q95, beta_t, beta_n and the singular-surface locations/count are unchanged to 0.00%.

solovev_n1 — et[1] moves 98%. This is the fix working, not a regression: as the table above shows, the develop value is grid-dependent and non-convergent. Solovev's ν node data sat at ~19% relative white noise under the old resample, so it is the case this change corrects most. et[1] is also a near-cancellation (ep ≈ −10.4, ev ≈ +10.4), so a 7% plasma-energy correction dominates it.

On solovev_n1, q0, q95, the singular-surface count/locations/q-values and mpert are all
exactly unchanged; beta_t/beta_n move 0.02%/0.08%.

Runtime 210.6s → 184.5s.

Kinetic cases — large moves, all of which enter at this PR

regress --cases solovev_kinetic_calculated,solovev_kinetic_ntv,solovev_kinetic_nuzero --refs develop,local. This PR moves the kinetic harness values, some of them dramatically — that is owned here, not hidden:

case quantity develop local diff
kinetic_calculated Re(et[1]) 1.845905 1.799588 2.5%
kinetic_calculated Im(et[1]) −1.421059 −1.309210 7.9%
kinetic_ntv NTV torque (fgar), magnitude ~1.56e-4 N·m ~3.85e-1 N·m ~2500×
kinetic_ntv ψ-quadrature integrand evaluations 840 60 −93%
kinetic_ntv root-area-weighted Re(et[1]) 6.773e-1 1.074e-2 −98%
kinetic_nuzero Im(et[1]) −2.034869 −1.559010 23%
kinetic_nuzero Re(et[1]) 2.245440 2.284431 1.7%

Attribution: the stacked follow-up branches (EL grid cap + certified kinetic grid, knob off) reproduce these local values bit-identically (34/34 tracked quantities unchanged vs this branch's head), so every kinetic delta vs develop enters at this commit — none at the follow-ups.

Interpretation: the old per-surface resample injected white-in-ψ geometry error (~1e-3 on the Solovev deck at etol = 1e-7; see "Why Solovev's energy moves 98%" above), and resonance-dominated kinetic quantities amplify exactly that kind of noise — the Solovev cases are the most sensitive in the suite. The quadrature-cost drop (840 → 60 evaluations for the same tolerance) is direct evidence the develop-side torque integrand carried noise structure the quadrature was chasing. The eigenvalue moves (2–8%, 23% for Im at ν→0) are consistent with the ideal-case finding that develop's Solovev values were grid-dependent while this branch's are grid-convergent.


Second change, folded in from #408: cap the core knot density of the EL coefficient splines

(#408 was folded here on 2026-09-04 — both are grid-side fixes aimed at the same symptom, the cap is a no-op on production auto grids so its measurements only mean anything alongside the step numbers above, and the two were benchmarked as a pair throughout. One file, src/ForceFreeStates/Fourfit.jl.)

Summary

The Euler-Lagrange coefficient splines (fmats/kmats/gmats and the primitives) inherited every knot of the equilibrium grid. A cubic spline's third-derivative jumps at knots scale as (node error)/Δψ³, so the equilibrium's near-axis packing (Δψ ~ 1e-6 at high mpsi) amplifies even tolerance-level node error (~1e-9, measured) into huge C² kinks — and the adaptive integrator's step size becomes slaved to the knot spacing. Measured directly: core jump magnitudes grow ~34× per mpsi doubling, and an (tol/J)^¼ step model reproduces the observed step-count ladder.

The coefficients are near-cylindrical in the core and do not need that packing. This PR builds their splines on a subset of the equilibrium grid with core density capped at Δψ ≥ 0.05·ψ below ψ = 0.1. Node values are unchanged — only interpolation density — which is why the physics moves at the 1e-7–1e-8 level.

Stacked on #398 (route (a), same investigation); diff shows only the cap once #398 lands.

Measured (DIII-D stripped decks, route-(a) base, mpsi 512/1024)

mψ=512 mψ=1024
accepted EL steps 2768 → 2188 4403 → 3034
step growth per doubling — 1.59× → 1.39×
warm run 12.8 → 10.4 s (−19%) —
et[1] relative change 3e-8 3e-8
Riccati Δ′ diagonal (all 5 surfaces) 4e-7 —

Not a tuned hack: the rule's basis and its generalization

Form: near the axis every component is a Frobenius power law in ψ; power laws are scale-free, so log-uniform sampling (Δψ ≥ c·ψ) resolves them at constant relative accuracy. Constant: cubic interpolation of ψ^p on a log-uniform grid errs by ~(p·c)⁴/384, so c = 0.05 resolves even the steepest spectrum component (p = m_max/2 = 11) to ~2e-4 — and the physics responds far below that because the steep components carry vanishing solution amplitude. Region: ends at min(0.1, innermost rational − RATIONAL_RES_RADIUS), and no knot inside a rational's resolution window is ever removed — guards for decks (e.g. higher n) whose rationals reach the core; verified no-ops on every current case.

Cross-equilibrium check (four equilibria, two construction paths, three grid families):

case knots EL steps et[1] change
DIII-D efit, traced, log-family m1024 1025→575 −31% 3e-8
tj_analytic_direct, traced, log-family m1024 1025→575 −35% 1.3e-9
LAR, inversion, packed auto m1024 1025→575 −8% exact to 8 digits
Solovev, traced, ldp m512/m1024 505→468 ~0 ~4.5e-7 abs (3e-5 rel via the ±10.4 → 0.0146 cancellation)

The LAR row is the anti-over-fit witness: clean geometry with no noise to exploit, and the cap is still harmless.

Two properties reviewers should know

  1. No-op on production decks. The two-pass auto grid's core spacing already satisfies the cap — verified: 287 → 287 knots on the DIII-D auto deck, and the full harness (diiid_n1, solovev_n1, diiid_n1_riccati, gal_resistive_diiid) reproduces Equilibrium - BUGFIX! - Sample all flux surfaces on common straight-fieldline abscissae and cap the coefficient-spline core knots #398's numbers exactly. The cap engages only on explicitly packed fine grids (large mpsi), which is precisely the issue Performance: Why do large equilibrium splines slow down the code? #376 scenario.
  2. A fixed cap beats general curvature selection — measured, not assumed. Removal-error knot selection (drop any knot whose interpolated value matches within δ for every element of all 12 matrices) was implemented and swept: it reaches the physics plateau only at δ=1e-7 where it keeps 504/513 knots and buys nothing, and breaks et[1]/Δ′ at any δ loose enough to matter. The core knots that are safe to remove fail the interpolation test (real coefficient curvature, but the solution components multiplying them vanish as ψ^|m|/2). Interpolation error is the wrong objective; the fixed mechanism-based cap is the right scope for the ideal path. Record: handoff/issue376/RESULTS.md §22–§23 (experiment branch).

Verification

Release note

  • Audience: users
  • Numerical impact: ideal and kinetic results move. The resample noise that grid refinement amplified is gone, so Solovev's free-boundary energy now converges in mpsi. efit_arclength gets the same fix, and F and P are now evaluated on the traced surface. (harness @ 81006c3)
  • Migration: re-pin local regression baselines — ideal and kinetic values move (Solovev's free-boundary energy most of all); no interface, deck-key or output-name changes

Every flux surface is now sampled at the same straight-fieldline abscissae instead of being
splined on its own solver-chosen points and remapped, so the surface-to-surface noise that
refinement used to amplify is gone: energies converge in mpsi where they previously drifted, and
the Euler-Lagrange integration stops chasing the noise (DIII-D ODE steps roughly halve). Folded in
from #408, the Euler-Lagrange coefficient splines no longer inherit the equilibrium grid's
near-axis packing, which removes the third-derivative kinks that slaved the integrator's step size
to the knot spacing on finely packed grids.

Regression report

regress --cases diiid_n1,solovev_n1,diiid_n1_riccati,efit_fixedbdy_separatrix,solovev_kinetic_calculated,solovev_kinetic_ntv,solovev_kinetic_nuzero,solovev_kinetic_multiion --refs 57ec7e16a,81006c3a6.
Baseline develop @ 57ec7e1; branch at 81006c3 (includes #414 and today's develop).

diiid_n1                        develop         this PR         diff
total energy Re(et[1])          8.012318e-01    8.004563e-01    0.10%
NTV torque FGAR [N.m]           5.296762e-01    5.325920e-01    OK (within tolerance)
beta_n / plasma volume          unchanged       unchanged       ≤ 3e-7
ODE steps (total)               4572            1953            -57.3%

solovev_n1                      develop         this PR         diff
total energy Re(et[1])          6.773398e-01    1.073537e-02    98.42%   <- the fix; see the mpsi ladder
ODE steps (total)               618             488             -21.0%

diiid_n1_riccati                develop         this PR         diff
Δ′ diagonal, real parts         see below       see below       ≤ 2.3%
Δ′[4,4] imaginary part          -379.5i         -5.3i           spurious Im removed
total energy Re(et[1])          8.037196e-01    8.029446e-01    0.10%
ODE steps (total)               1645            1350            -17.9%

solovev_kinetic_calculated      develop         this PR         diff
total energy Re(et[1])          1.873745e+00    1.827410e+00    2.47%
total energy Im(et[1])         -1.434288e+00   -1.328439e+00    7.38%

solovev_kinetic_nuzero          develop         this PR         diff
total energy Re(et[1])          2.243336e+00    2.290424e+00    2.10%
total energy Im(et[1])         -2.026025e+00   -1.568157e+00    22.6%

solovev_kinetic_ntv             develop         this PR         diff
NTV torque fgar                 ~1.6e-4         ~3.85e-1        dominated by this PR
NTV psi quadrature evaluations  840             45              -94.6%

The Riccati row's 16.7% headline is the imaginary part of Δ′[4,4], which should be zero for an ideal
Δ′. See "Reading the 16.73%" below. Real parts move ≤2.3%, inside the ~2% grid-to-grid spread
recorded for Δ′ in #379. Two quantities that are physically zero for an ideal run sit at the
numerical floor and change sign: DIII-D Im(et[1]) and the ideal PE toroidal torque (±5e-2). The
kinetic moves are this PR's sampling plus #414's suppression, as attributed in #414.

⚠️ Requires third-party human review before merging — do not merge without an approving review.

Rebased onto develop (349a0c2)

Merged develop on 2026-09-04. The only conflict was Fourfit.jl, where develop's MatrixSplines
refactor now builds an immutable IdealMatrices: the cap logic moved into a named helper,
core_capped_knots(xs, rationals), and the constructor builds on the capped subset. Cap
behaviour is unchanged — only its plumbing follows develop.

Verification after the port: runtests_equil 279/279, runtests_grid_refinement 59/59.
Harness vs develop reproduces the headline effect — diiid_n1 ODE steps 4572 → 1974 (−56.8%),
with energies moving 0.02–0.28% (Re(et[1]) 0.10%) and q0/q95/β/singular-surface locations at 0.00%.

Resolved: DIII-D kinetic-calculated EL stepping

This PR alone takes the DIII-D kinetic_source="calculated" forward integration to 223,271 steps,
against 7,972 on develop. #414, now merged into this branch, removes the near-axis region where the
kinetic model is invalid and cures it outright: 4,246 steps, below develop's baseline. No harness case
covers this configuration yet; a DIII-D kinetic-calculated FFS-only case remains a follow-up.

Caveat, stated plainly: a three-orders-of-magnitude move in the ntv-case torque means the develop baseline for that tracked value was noise-dominated, and the new value has no independent reference yet. This needs a physics reviewer's judgment, not just the attribution argument. If this PR is accepted, the kinetic harness baselines must be re-pinned on the merge commit.

Tests

  • test/runtests_sfl_sampling.jl (new) — 4 pass; mutation-checked as tabulated above
  • Full suite on the merged branch — 4793 / 4793; CI green on Julia 1.11 and 1.x
  • test/runtests_equil.jl — 279 pass (includes the arclength path)
  • test/runtests_grid_refinement.jl — pass
  • test/runtests_tj_analytic.jl — pass (exercises tj_analytic_direct, the same code path)

Scope / follow-ups

  • Covers eq_type ∈ {efit, efit_arclength, imas, sol, tj_analytic_direct} — everything reaching equilibrium_solver in DirectEquilibrium.jl.
  • Not affected: InverseEquilibrium.jl (chease / lar / tj_analytic / efit_by_inversion). It samples every surface on one common input θ grid through smooth 2-D interpolants, so its abscissae vary smoothly in ψ and the white-noise mechanism does not arise.
  • The arclength trace stays within ~1e-6 m of the flux surface, set by its position tolerance. That is the floor of the tracer-agreement test.
  • DIII-D retains a deep-core residual (steps still grow 1.59× per mpsi doubling, concentrated in ψ<0.05); under investigation separately, likely tied to the EFIT input's own resolution rather than the trace.

⚠️ Requires third-party human review before merging — do not merge without an approving review.

🤖 Generated with Claude Code


Follow-up verification: Δ′ and wall time (requested in review)

The original evidence used diiid_n1/solovev_n1, which run integrator = "forward" and therefore
do not emit the BVP Δ′ matrix at all. Re-ran the Δ′-tracking cases.

diiid_n1_riccati

quantity develop local diff
delta prime (BVP diagonal) [5 elem] [5 elem] 16.73%
delta prime (raw side-major) [10 elem] [10 elem] 2.35%
edge coil response delta_coil [10 elem] [10 elem] 1.26%
total energy Re(et[1]) 8.037196e-01 8.029430e-01 0.10%
# singular surfaces / psi / q 5 5 OK / 2e-09 / 0.0
ODE steps (total) 1649 1358 −17.7%
Runtime 202.5s 192.8s −4.8%

gal_resistive_diiid — the quantities the tearing/matching path consumes

quantity diff
gal PEST3 Δ diagonal 0.01%
‖gal Δ′ matrix‖ 0.03%
gal D_I per surface 0.00%
gal α per surface 0.00%
‖gal inner-layer Δ‖ 0.00%
‖gal Δ_coil block‖ 0.80%
‖gal match cout‖ 1.38%
gal match residual OK
Runtime 216.8s → 213.6s

Reading the 16.73%

It is one element. Δ′ BVP diagonal across an mpsi ladder on the riccati deck:

mpsi develop this PR
256 [9.7, −1.9, −13.1, 103024, 336.0] [9.7, −1.9, −13.1, 102768, 336.1]
512 [7.2, −5.2, −16.6, −1319.5, 61.4] [7.2, −5.2, −16.6, −1293.2, 61.5]
1024 [8.6, −5.5, −16.1, −2368.8, 55.1] [8.6, −5.5, −16.1, −2368.0, 55.6]
drift vs previous mpsi 174% → 79.5% 174.7% → 83.1%

The 4th entry (q = 5 surface) runs 1e5 → −1319 → −2369 and changes sign: it is not converged in
mpsi on either version
, so it cannot discriminate between them. The other four diagonal entries
agree between versions at every grid, and version-to-version agreement on the full diagonal is
0.25% / 2.0% / 0.03% at mpsi 256 / 512 / 1024.

Caveat worth stating plainly: this PR does not improve Δ′ convergence either (drift
174.7%/83.1% versus develop's 174.0%/79.5%). The unconverged q = 5 Δ′ element is a pre-existing
issue that this change neither causes nor fixes, and it deserves separate attention.

Wall time improves on every case measured: forward diiid_n1 210.6 → 184.5 s, diiid_n1_riccati
202.5 → 192.8 s, gal_resistive_diiid 216.8 → 213.6 s.

…fieldline angles

Each surface was traced independently and then splined on that surface's OWN
solver-chosen abscissae before being resampled onto the common theta grid, so the
resample error was uncorrelated between neighbouring surfaces -- white noise in psi
that grid refinement amplifies rather than reduces. Neither reltol nor abstol touched
it, because it is remap interpolation error rather than integration error.

The trace now returns its dense solution, and equilibrium_solver root-solves (Brent,
bracketed by the monotone jac-weighted flux integral) for the angle at which the
normalised straight-fieldline angle reaches each target node, evaluating there. Every
surface is sampled at identical abscissae and the resample error at the output nodes
is zero. The arclength tracer returns nothing for the solution and keeps the previous
path.

Measured on DIII-D stripped decks at eulerlagrange_tolerance 1e-10, accepted
Euler-Lagrange steps fall 2309/3977/7638 -> 1881/2768/4403 for mpsi 256/512/1024, a
42% reduction at mpsi=1024, and the per-doubling growth drops from 1.92x to 1.59x.
Surface geometry residuals now converge with refinement instead of sitting on a floor,
and every knot-to-knot correlation turns positive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
logan-nc added a commit that referenced this pull request Aug 17, 2026
…ed as PR #398

The A3 kill-switch fires: route (a) geometry already agrees with a near-exact trace
to 5e-10..2e-8, i.e. at the integration tolerance, so the planned SFL reparametrisation
would fix an error that is not there.

Also records that nstep is hypersensitive -- a 5.3e-11 geometry perturbation moves it
1.2% -- so the leftover 15-20% gaps are not reliable signal; that A1 places the residual
in the traced construction rather than the EFIT input (analytic input, traced 1.47x vs
inversion 1.18x); and that tightening the trace tolerance 1000x on that case is null.

Notes the harness baseline was refreshed from a stale local develop, and that the first
etol test was a no-op because the deck has no etol key.

No src changes on this branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
logan-nc added a commit that referenced this pull request Aug 17, 2026
The original PR evidence used forward-integrator cases, which emit no BVP Delta-prime
at all, so route (a) had never been checked against the observable that section 19
showed can break silently. Ran diiid_n1_riccati and gal_resistive_diiid.

Tearing-consumed quantities are unchanged: PEST3 Delta diagonal 0.01%, Delta-prime
matrix norm 0.03%, inner-layer Delta 0.00%. The headline 16.73% on the raw BVP
diagonal is a single element (q=5) that runs 1e5 -> -1319 -> -2369 and changes sign
across the mpsi ladder on BOTH versions, so it cannot discriminate between them.

Records that route (a) does not improve Delta-prime convergence either, flagging the
unconverged q=5 element as a pre-existing issue worth its own investigation. Wall time
improves on all three cases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
logan-nc and others added 2 commits August 18, 2026 08:44
…plines

The Euler-Lagrange coefficient splines inherited every knot of the equilibrium grid.
A cubic spline's third-derivative jumps at knots scale as (node error)/dpsi^3, so the
equilibrium's near-axis packing (dpsi ~ 1e-6) amplifies even tolerance-level node
error into huge C2 kinks, and the adaptive integrator's step size becomes slaved to
the knot spacing -- measured directly: core jump magnitudes grow ~34x per mpsi
doubling while a (tol/J)^(1/4) step model reproduces the observed step-count ladder.

The coefficients are near-cylindrical in the core and do not need that packing. Build
their splines on a subset of the equilibrium grid with core density capped at
dpsi >= 0.05*psi below psi = 0.1; node values are unchanged, only knot density.

Measured on DIII-D stripped decks (route-a base, mpsi 512/1024): accepted EL steps
2768 -> 2188 and 4403 -> 3034, per-doubling growth 1.59x -> 1.39x, warm run -19% at
mpsi=512, with et[1] unchanged to 3e-8 relative and the Riccati BVP Delta-prime
diagonal unchanged to 4e-7 on all five surfaces.

Interim fixed-cap form; a matrix-curvature knot selection is planned to replace the
fixed rule, with this commit as the fallback.

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

On the production two-pass auto grid the cap is a no-op (its core spacing already
satisfies the density rule), so the unconditional message was noise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@logan-nc

Copy link
Copy Markdown
Collaborator Author

Report with visuals for workflow and results can be found here: https://claude.ai/code/artifact/873c8376-3f75-4fe4-934b-4d39f23e2376

… protect rationals

Reframes the cap as log-uniform sampling of the Frobenius region: near the axis every
component is a power law in psi, and cubic interpolation of psi^p on a log-uniform
grid errs by ~(p*c)^4/384, so c = 0.05 resolves even the steepest spectrum component
(p = mmax/2) to ~2e-4 while the physics responds far below that.

Two generalization guards, motivated by cross-equilibrium testing: the capped region
now ends at the innermost rational surface when that sits inside psi = 0.1, and no
knot inside a rational's RATIONAL_RES_RADIUS window is ever removed -- preserving the
Delta'-stencil structure for decks (e.g. higher n) whose rationals reach the core.
Both guards are no-ops on every current case, verified: DIII-D m512 and Solovev m512
reproduce the previous cap's step counts and knot sets exactly.

Cross-equilibrium check of the rule itself: tj_analytic_direct m1024 (analytic,
traced) 1470 -> 959 steps at et[1] 1.3e-9; LAR m1024 (inversion path, clean geometry)
951 -> 875 at et[1] identical to 8 digits; Solovev ldp m512/m1024 ~unchanged steps at
~4.5e-7 absolute et[1] shift (a +-10.4 cancellation amplifies this to 3e-5 relative).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@logan-nc

Copy link
Copy Markdown
Collaborator Author

Review package (visual companion to this PR — mechanism diagram, the Solovev et[1] grid-convergence before/after, step ladders, geometry-residual evidence, and the Δ′ referee table):

📦 https://claude.ai/code/artifact/873c8376-3f75-4fe4-934b-4d39f23e2376

Self-contained page; complements rather than duplicates the description and diff. If the link does not resolve, ask @logan-nc to enable sharing on it.

logan-nc added a commit that referenced this pull request Aug 19, 2026
… vs #398; deltas inherited

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
logan-nc and others added 3 commits August 20, 2026 13:02
…ordering fails near the axis

Physics ruling (issue #376 DIII-D kinetic pathology): the drift-kinetic model
loses validity where thermal ion orbit widths reach the local minor radius.
psi_c = outermost crossing of <r> by max(potato width (q^2 rho^2 R0)^(1/3),
banana width q rho/sqrt(eps), poloidal gyroradius q rho/eps), computed from the
equilibrium and kinetic profiles at runtime -- no user tuning parameters (the
Fortran ktanh_flag precedent needed four). A C2 quintic envelope zeroes the
calculated kinetic increments below psi_c (kernel evaluation skipped) and rises
to 1 at 2 psi_c; the same boundary and envelope apply to the NTV torque psi
quadrature (one source of truth). One Bool (axis_validity_suppression, default
true) to disable for debugging.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LzbLFQKyuRE5DYZmLokKmk
…icForces/Validity

Whenever kinetic profiles are used (self-consistent matrices or NTV
post-processing), write the thermal orbit-width scales (rho_i, rho_banana,
rho_theta, w_potato), the local geometry (r_minor, d_separatrix), the profile
gradient lengths (L_p, L_q), the near-axis boundary psi_c with its applied
envelope, and an is_valid array (orbit width < r, rho_banana < L_p and L_q,
orbit width < distance to separatrix). Validity outside the near-axis envelope
is flagged, never suppressed -- the far edge can dominate the physical NTV.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LzbLFQKyuRE5DYZmLokKmk
…n-or-create the KineticForces group

The envelope has structure on the psi_c scale; coarse kinetic decks (m16) cannot
represent env*(increment) and the spline overshoot can land on a rational
surface inside the transition band, corrupting the eigenvalues. Augment the
kernel grid with knots across [psi_c, 2 psi_c] (band ends pinned -- the
smoothstep is only C2 there) on the full-grid path and seed them on the
certified path. Also open-or-create KineticForces in the NTV writer, which
collided with the Validity group.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LzbLFQKyuRE5DYZmLokKmk
…ata contract)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LzbLFQKyuRE5DYZmLokKmk
… auto psi grid

The two-pass auto grid's criterion is ideal-driven and knows nothing about kinetic
resonance locations. When a run builds calculated kinetic matrices, locate the
Omega_l = 0 surfaces (same locator as the NTV quadrature paneling) and insert them
as plain knots via merge_mandatory_nodes -- knot-at-node, no cleared zone, inserted
before rational bracketing so the Delta-prime clean-interval treatment wins locally.
Nodes inside the near-axis validity region are suppressed anyway and not pinned.
DIII-D: +2 net knots, et[1] unchanged to 2e-6, EL steps drop 14%.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LzbLFQKyuRE5DYZmLokKmk
logan-nc and others added 4 commits August 22, 2026 13:45
…nsistent kinetic runs

reg_spot smooths the ideal 1/(m-nq) divergence of the displacements before they
drive the NTV integrand. The self-consistent kinetic Euler-Lagrange operator has
no such divergence -- det(F-bar) is complex and nonzero at the rationals (Park &
Logan, Phys. Plasmas 24, 032505 (2017) Eq. 70) -- so regularizing there suppresses
a finite physical response, and inconsistently: xi^psi is never regularized, so
damping the other two breaks their near-resonance cancellation in dB/B.

Measured (DIII-D-like, n=1) against the EL solution's own dissipation: NTV torque
0.1655 vs 0.1322 N*m with reg_spot=0.05, and 0.1324 vs 0.1322 (0.15%) with it off.
Kinetic runs now force reg_spot=0 and log the override; ideal runs are unchanged,
where without it the displacement and torque diverge by four orders of magnitude.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LzbLFQKyuRE5DYZmLokKmk
…structure

The cond(F-bar) scan that locates kinetic singular surfaces already sweeps 2000
points and is written to SingularSurfaces/Kinetic/scan_cond, but only peaks above
the 1e8 singular threshold were surfaced. On a DIII-D-like case the strongest
peaks sit at 3e5-5e6 -- real shifted/split resonance structure (Park & Logan
Eq. 70) that stayed silent, and which tracks the NTV torque-density peaks at low
collisionality/rotation. Report the strongest few.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LzbLFQKyuRE5DYZmLokKmk
… F-bar structure

The cond(F-bar) scan already locates shifted/split kinetic resonances (Park &
Logan Eq. 70) but only reported them. Measure each sub-threshold peak's FWHM and,
where the grid puts fewer than three knots inside it, evaluate the kernel at a
few targeted psi and splice them in -- existing values are reused, so the cost is
one kernel call per added knot, not a re-formation. Respects MIN_KNOT_SPACING,
the near-axis validity band, and a 24-knot cap; a resolved grid inserts nothing.

Measured on DIII-D: no insertions on the nominal or low-collisionality cases; on
the collisionless slow-rotation case two peaks (psi=0.53, 0.51, FWHM 1.5e-3 and
2.5e-3) had zero knots inside them, and one of the two coincides with a top NTV
torque-density peak.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LzbLFQKyuRE5DYZmLokKmk
…ts the key

The override tested get(inputs,"reg_spot",0.0)!=0, so it only fired when a deck
set reg_spot explicitly; a deck relying on the struct default (0.05) silently kept
regularization on in a self-consistent kinetic run -- exactly the case the change
exists to prevent. Compare against the struct default and always force 0, logging
whenever the prior effective value was nonzero.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LzbLFQKyuRE5DYZmLokKmk
@logan-nc logan-nc changed the title EQUIL - IMPROVEMENT - Sample every flux surface at the same straight-fieldline angles EQUIL/FFS - IMPROVEMENT - Consistent SFL surface sampling and a core knot-density cap for the EL coefficient splines Sep 4, 2026
@logan-nc

logan-nc commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator Author

Review package (self-contained, private artifact): https://claude.ai/code/artifact/183e8712-e9fd-48c7-9ca0-1500f51ac263

Covers both folded changes — the aligned SFL surface sampling and the coefficient-spline core knot-density cap (#408, folded in here) — with the mechanism diagrams, the grid-convergence ladder, the C³-jump/knot-density figures and the cross-equilibrium generalization check. Numbers on the page were measured against develop @ 9491f893d; the post-port harness run reproduces them and the body below carries the current table.

@logan-nc logan-nc changed the title EQUIL/FFS - IMPROVEMENT - Consistent SFL surface sampling and a core knot-density cap for the EL coefficient splines Equilibrium - BUGFIX! - Sample all flux surfaces on common straight-fieldline abscissae and cap the coefficient-spline core knots Sep 5, 2026
@github-actions github-actions Bot added bugfix Something was wrong and now is not changed-results Results move or an interface breaks - read before upgrading labels Sep 5, 2026
@logan-nc
logan-nc marked this pull request as ready for review September 5, 2026 03:58
@logan-nc
logan-nc requested a review from d-burg September 5, 2026 03:58
@logan-nc logan-nc self-assigned this Sep 5, 2026
logan-nc and others added 7 commits September 19, 2026 09:06
…heta-parametrization' into feature/kinetic-axis-validity
…ional clearing

Independent review of #414 found the near-axis suppression boundary computed
three different ways, and two ways it can run away.

One boundary per run. The NTV psi torque quadrature recomputed psi_c from the
single species it was integrating and never called clear_rational_windows,
while the EL kinetic matrices and the Validity output used the widest-orbit
species' cleared boundary. Validity/psi_c in gpec.h5 therefore described a
different domain from the one the torque was integrated over, and the standalone
torque kept near-axis contributions the EL build had already zeroed - which is
exactly the cross-check the PE-vs-NTV agreement rests on. axis_validity_boundary
is now the only entry point: run_kinetic_forces computes it once and threads it
through compute_torque_all_methods! into integrate_psi_quadgk.

Contiguity. kinetic_axis_validity_psi took the outermost trigger anywhere on the
profile grid. q is not monotonic, so an edge q rise or a pedestal gradient could
re-trigger the criterion far from the axis and drag the suppressed band across a
large fraction of the plasma. The scan now stops at the first surface where the
ordering holds and warns if the criterion fires again further out.

Cascade. clear_rational_windows rescans [psi_c, 2 psi_c] after each move, so the
window widens as psi_c grows; with rationals at 0.15/0.25/0.45 a psi_c of 0.10
walked out to 0.455. The move is capped at VALIDITY_CLEAR_MAX_FACTOR times the
orbit-width boundary, past which the uncleared boundary is kept with a warning -
a band cutting a rational is resolved by the band knots, whereas suppressing half
the plasma is not recoverable.

Grid pinning. resonance_grid_nodes cleared its boundary against intr.sing, which
sing_find! does not populate until long after the grid is built, so the clearing
was silently a no-op. The nodes are now located inside maybe_reform_equilibrium
against that pass's own rational list.

Validity coverage. write_validity! was reachable only from run_kinetic_forces,
whose body is gated on a perturbed-equilibrium state, so a calculated-kinetic
FFS-only run wrote no Validity group at all. It is written from the ForceFreeStates
output stage instead, wherever kinetic profiles were loaded.

kinetic_regularization_kwargs is duck-typed on ffs so the override is testable,
and its message no longer says "self-consistent" - it covers both kinetic sources.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdRsGGg4YRG6E9fgLGbpdF
…larization override

The validity machinery shipped with no unit coverage. Adds runtests_kinetic_validity.jl
(registered in runtests.jl) over the pieces the review found defects in:

- kinetic_axis_validity_envelope: endpoint values, the midpoint, monotonicity, and a
  C2 check that the second difference straddling each band end vanishes linearly in h
- clear_rational_windows: empty and far-rational no-ops, a single in-band rational, and
  the cascade construction that must hit the cap and keep the orbit-width boundary
- kinetic_axis_validity_psi: a q spike far from the axis must not move the boundary
- kinetic_regularization_kwargs: ideal pass-through, kinetic forcing 0, and the case
  where the deck omits reg_spot entirely and must pick up the struct default

The equilibrium is a duck-typed stand-in - the boundary scan reads only q, <r>, <R>,
ro and b0 - so the tests run in seconds without building a PlasmaEquilibrium.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdRsGGg4YRG6E9fgLGbpdF
Review feedback on #414: the block comments are too long. Collapses the pinned-knot
block in refined_psi_grid, the BAND_KNOTS and validity-envelope blocks in Kinetic.jl,
and the sub-threshold reporting block in Finding.jl to one or two lines each, and
rewraps the refined_psi_grid docstring line that had grown past the 180-char margin.

Also folds find_kinetic_singular_surfaces!'s numbered "Algorithm:" list into prose,
per the repo's no-step-numbering rule.

docs/src/kinetic_forces.md keeps the regularization argument and its citations - it is
user-facing reference material, not a comment - but drops the single-case DIII-D
validation table, which belongs in the review package rather than in permanent docs
where it goes stale. Adds the Logan (2015) Ch. 7 Eq. 7.46 reference alongside Park &
Logan (2017), since it states the same result in GPEC's own notation.

The three example decks get a shorter inline description for axis_validity_suppression,
matching the surrounding comment column.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdRsGGg4YRG6E9fgLGbpdF
Review feedback on #414 flagged this block as too long. It is not actually this
branch's code - it arrived on develop with the nested energy/pitch tolerances and
shows in the pull request diff only because the merge base makes develop's commits
look like additions - but the comment rule applies either way, so it is trimmed here
rather than deferred: 27 comment lines down to 14, one line per field.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdRsGGg4YRG6E9fgLGbpdF
KineticForces/Validity carried no long_name/units at all. Its metadata table sat in
HDF5Schema.MAIN_H5_ANNOTATIONS, which apply_main_h5_metadata! applies at the end of
write_outputs_to_HDF5 - but write_validity! runs afterwards, in a separate h5open, so
the pass never saw those datasets and the entries were dead. HDF5Schema's own header
states the rule: sub-writers keep their tables next to their own writers. The table
moves to Output.jl as KF_VALIDITY_H5_ANNOTATIONS and write_validity! annotates the
group it just created, matching how write_to_hdf5! handles the per-method groups. The
entries also gain the `dims` attribute the contract requires of rank >= 1 profiles.

Corrects the rationale given in 2dcbbf1 for the cascade cap. That commit argued a
transition band cutting a rational is harmless because the band knots resolve it; the
history says otherwise - the band knots landed in 3f1dbfe and the NaN that motivated
clear_rational_windows was found after them, in 37c4eeb. The capped branch does
reinstate the hazard, so the warning now says so and names the two ways out
(axis_validity_suppression = false, or a finer grid there) instead of implying the
knots cover it. The behaviour is unchanged: swallowing half the plasma is the worse
failure.

The contiguity warning gains maxlog=1 - it is reachable from four call sites, once per
species - and its unit test now asserts the boundary rather than the log line, since
maxlog makes the log order-dependent across the suite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdRsGGg4YRG6E9fgLGbpdF
@logan-nc

logan-nc commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

Does #480 make this stack redundant? Measured: no

#480 (per-column ODE absolute tolerance) also cuts ODE steps, by fixing the cause of the step growth reported in #379. I checked whether that makes the surface sampling here, or the #408 knot cap folded into this PR, unnecessary.

Surface sampling: still needed. It fixes error in the equilibrium data the ODE integrates. #480 changes how tightly the ODE integrates it, and cannot remove noise already in the coefficients. Harness results on the standard decks:

develop #480 this stack stack + #480
Solovev et[1] 0.6773 0.6773 0.0107 0.0107
DIII-D et[1] 0.80123 0.80123 0.80046 0.80046
DIII-D steps 4572 1424 1974 799
Solovev steps 618 410 495 283
DIII-D Riccati steps 1645 4166 1358 2622

#480 leaves every energy unchanged to about 1e-6. The grid-dependent Solovev answer this PR corrects is therefore still present with #480 alone. The step savings compound.

Knot cap (#408): still needed on packed grids. On the auto grid it is inert: every harness case is bit-identical with it on or off, as stated above. On explicitly packed DIII-D grids it still saves steps on top of #480:

packed grid, with #480 cap off cap on
mpsi 512 879 794 (−10%)
mpsi 1024 1293 979 (−24%)
Riccati, mpsi 1024 3859 2976 (−23%)

et[1] moves by about 3e-8 and Δ′ by about 4e-7. The two fixes are complementary: #480 addresses loose tolerance, and the cap addresses spline kinks at densely packed knots.

Not yet checked: the Solovev grid-convergence ladder in this PR predates #480. I have not run develop + #480 alone at mpsi 256/512/1024. Since #480 leaves the standard-grid energy unchanged, it very likely still drifts.

The "stack" columns are #414's head (2abea51d2), which carries this branch. #414's own changes affect kinetic runs only, so the ideal rows reflect this PR. Baselines: develop 56eafc2a5 and #480 d73206dc7. The cap was disabled by a one-line ablation on local throwaway branches, none of them pushed.

🤖 Generated with Claude Code

logan-nc and others added 6 commits October 3, 2026 18:21
…t-fieldline angles through one tracer contract

The common-abscissae fix applied only to direct_fieldline_int. efit_arclength still
resampled each surface from its own adaptive solver steps, the same white-in-psi noise,
and equilibrium_solver chose between the two behaviours on `sol === nothing`, so any
tracer that returned no dense solution silently kept the noisy path.

Sampling is now part of the tracer contract. Every tracer takes theta_nodes and returns
its surface already sampled there, through one shared helper
(sample_trace_at_sfl_angles, which root-solves the monotone SFL integral on the dense
solution in the tracer's own integration variable). equilibrium_solver keeps a single
path with no duplicated formulas. The arclength tracer now solves with dense output,
brackets in arc length rather than eta, and unwraps eta at each sample against its
bracketing step.

The dense solution no longer leaves the tracer, so the concrete return annotation is
restored and the type instability introduced earlier on this branch is gone. The
root-solve brackets from the saved steps instead of re-evaluating the dense solution,
and the unreachable linear-interpolation fallback is now an error.

Direct-tracer results are unchanged; efit_arclength results move. Measured on the
EQDSK_COCOS_02 fixture, efit and efit_arclength rzphi now agree to 2-3e-6, down from
2.4-3.9e-4.

Also backports the length(xs) < 3 guard in core_capped_knots, which otherwise returns
a duplicate knot on a one-knot grid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdRsGGg4YRG6E9fgLGbpdF
…ot depend on solver steps

The common-SFL-abscissae fix had no test coverage. The existing equilibrium tests
compare at 1e-3 to 10 % and are blind to the ~1e-6 white-in-psi noise it removes.

Two checks of the property itself rather than the mechanism:
- Solovev rzphi at off-grid points is independent of psi resolution (64 vs 128).
- On the EQDSK_COCOS_02 fixture, the efit and efit_arclength tracers agree. They take
  different step sequences along the same surfaces, so any disagreement is step
  dependence.

Each threshold sits about 10x or more from both sides of the measured behaviour, and
was mutation-checked: all four checks fail on develop (old resampling in both tracers),
and the tracer check fails on this branch before the arclength tracer was converted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdRsGGg4YRG6E9fgLGbpdF
…s validity limit and report validity profiles (#414)
… than at the solver's last evaluation

direct_fieldline_int returned its ODE scratch DirectBField, which the right-hand side,
the surface-refinement callback and the dense interpolant overwrite on every evaluation.
F, P and everything built on them (q, nu, the F and P profiles, beta) were therefore read
wherever the solver last happened to evaluate, not at the surface start point the
docstring promises. The arclength tracer already kept a separate buffer for the ODE
(bfield_ode) and returned the start point.

The direct tracer now does the same. The change is about 1e-10 in q and nu on a fixed
grid, and the sampled geometry (r^2, angle offset, Jacobian) is bit-identical. It also
makes F and P independent of evaluation order: the old value moved whenever sampling
order changed, which is how this was found. Applying this one fix to both the old and
the refactored sampling makes their equilibria bitwise identical, so the sampling
restructure in the previous commit is otherwise a pure restructure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdRsGGg4YRG6E9fgLGbpdF
…heta-parametrization' into performance/consistent-surface-theta-parametrization
…tent-surface-theta-parametrization

# Conflicts:
#	src/GeneralizedPerturbedEquilibrium.jl
#	src/KineticForces/KineticForcesStructs.jl
#	src/PerturbedEquilibrium/FieldReconstruction.jl
#	src/PerturbedEquilibrium/PerturbedEquilibriumStructs.jl
@logan-nc

logan-nc commented Oct 4, 2026

Copy link
Copy Markdown
Collaborator Author

@d-burg, I've added a section to the description, "Why Solovev's energy moves 98%: an independent check", that may be relevant to your cylindrical tearing work. On develop, directly traced equilibria (sol, tj_analytic_direct, efit, and the Cerfon–Freidberg equilibrium in #495) carried an etol-dependent geometry error. On the Solovev deck it is about 1e-3, and it feeds straight into the ψ-derivatives that Δ′ depends on. The section also explains how this interacts with your #491, which changes the tolerance on the same tracer solves.

🤖 Generated with Claude Code

@logan-nc

logan-nc commented Oct 4, 2026

Copy link
Copy Markdown
Collaborator Author

@d-burg I'm gonna merge this weds latest. A read through before then would be 👍 🙏

@d-burg d-burg left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Some final claude notes and proposed to-dos:

Please fix before merge

  1. Release note. It says no deck-key or output changes, but #414 came in with this branch: axis_validity_suppression (default on), KineticForces/Validity, and a forced reg_spot = 0 in kinetic runs. The title should mention it too.
  2. Solovev mpsi ladder. It shows et[1] = 0.01462 "converged to 6 figures", but the harness deck (mpsi 128) gives 0.010735. Please re-run the ladder at this head, including 128.
  3. Two claims in the description.
    • The cap is not a no-op on auto grids: it removed 2–43 knots on five of my equilibria (harmlessly).
    • "Spurious Im removed" is not general: on fixed grids Im Δ′ dropped on one surface and rose on others.
  4. Unit test for core_capped_knots — see the inline comment.
  5. Harness at head for diiid_slayer_n1, gal_resistive_diiid and gal_resistive_pe; the current report covers 8 of 16 cases.

Follow-up issue is fine

  • After this PR the Solovev fixture is a ~1000:1 cancellation (et[1] = 0.0107 against ep = −10.42), so the Solovev cases will amplify every later change. The wall distance needs re-tuning, and the deck comment still says et[1] = +0.24.
  • I suspect the 2500× NTV torque move is resonant amplification from sitting that close to marginal: (0.677 / 0.0107)² ≈ 4000. Comparing the response amplitude between the two runs would settle it.
  • #414's physics still wants a look from someone with PENTRC experience, as @ebursch asked.

ff_interp = cubic_interp(ff_x_nodes, Series(ff_fs_nodes); bc=PeriodicBC())
ff_deriv = deriv1(ff_interp)

# Resample ff onto uniform theta grid

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

From claude: nothing is resampled any more; the fit is only needed for the θ-derivative.

Suggested change
# Evaluate the θ-fit and its derivative at the nodes (the Jacobian needs the derivative)

return hcat(sol.t::Vector{Float64}, sol_matrix), bfield
y_out = hcat(sol.t::Vector{Float64}, sol_matrix)
# State is [∫dl/Bp, rfac, ∫dl/(R²Bp), ∫jac·dl/Bp] and the integration variable is η itself.
return sample_trace_at_sfl_angles(sol, y_out, 4, theta_nodes, (u, eta, _) -> (eta, u[1], u[2], u[3], u[4])), bfield

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

From claude: the refinement callback saves pre-refinement states (save_positions=(true, false)), so the dense interpolant is offset by one step's refinement and these samples sit slightly off ψ = ψ₀ (tolerance-level). Not blocking. Calling direct_refine(u[2], eta, psi0, params) on each sample would put them on the surface exactly. Worth a follow-up?

Euler-Lagrange step size. Near the axis every component is a Frobenius power law in ψ, and power
laws are scale-free, so log-uniform sampling (Δψ ≥ 0.05·ψ) resolves them at constant relative
accuracy — cubic interpolation of ψ^p on that grid errs by ~(0.05p)⁴/384, resolving even the
steepest component (p = m_max/2) to ~2e-4, well below where the physics responds. The capped region

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The 2e-4 figure is for the n = 1 DIII-D spectrum. The cap constant does not scale with mpert.

Suggested change
steepest component (p = m_max/2) to ~2e-4, well below where the physics responds. The capped region
steepest component (p = m_max/2) to ~2e-4 at m_max = 22 (~1e-2 at m_max = 60). The capped region

rational's resolution window, preserving the Δ′-stencil structure the equilibrium grid encodes
(`Equilibrium.RATIONAL_RES_RADIUS`).
"""
function core_capped_knots(xs::Vector{Float64}, rationals::Vector{Float64})::Vector{Int}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

From claude: This has no test, and no harness case removes a knot, so CI never exercises it. Please add this as test/runtests_core_capped_knots.jl (passes 8/8 at this head) and include it in runtests.jl:

using Test
using GeneralizedPerturbedEquilibrium

@testset "core_capped_knots" begin
    FFS = GeneralizedPerturbedEquilibrium.ForceFreeStates
    R = GeneralizedPerturbedEquilibrium.Equilibrium.RATIONAL_RES_RADIUS

    # Densely packed grid, one rational far from the core.
    xs = collect(range(1e-3, 0.99; length=2000))
    keep = FFS.core_capped_knots(xs, [0.5])
    kept = xs[keep]
    @test keep[1] == 1 && keep[end] == length(xs)
    @test issorted(keep) && allunique(keep)
    @test length(keep) < length(xs)
    # Nothing at or above ψ = 0.1 is dropped.
    @test count(>=(0.1), kept) == count(>=(0.1), xs)
    # Below it, kept knots are at least 5% of ψ apart.
    core = kept[kept .< 0.1]
    @test all(diff(core) .>= 0.05 .* core[2:end])

    # A core rational ends the capped region before its resolution window.
    keep_rat = FFS.core_capped_knots(xs, [0.05])
    @test all(i in keep_rat for i in eachindex(xs) if abs(xs[i] - 0.05) <= R)

    # A grid already sparser than the cap is returned whole.
    sparse = collect(range(0.01, 0.99; length=20))
    @test FFS.core_capped_knots(sparse, [0.5]) == collect(1:20)
    @test FFS.core_capped_knots([0.1, 0.2], Float64[]) == [1, 2]
end

Comment on lines +122 to +124
its validity domain. Returns 0.0 when no criterion is met anywhere. The Fortran precedent
(`ktanh_flag`, dcon/fourfit.F) suppressed the same region with four hand-tuned knobs; here the
boundary is derived from the profiles with no user parameters.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Do we want fortran source citations like this one?

Suggested change
its validity domain. Returns 0.0 when no criterion is met anywhere. The Fortran precedent
(`ktanh_flag`, dcon/fourfit.F) suppressed the same region with four hand-tuned knobs; here the
boundary is derived from the profiles with no user parameters.
its validity domain. Returns 0.0 when no criterion is met anywhere. The boundary is derived from
the profiles with no user parameters.
``


nufac::Float64 = 1.0 # Collisionality scaling
divxfac::Float64 = 1.0 # div(xi_perp) scaling
axis_validity_suppression::Bool = true # documented in the `## Fields` docstring

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is a new key that is on by default and changes kinetic results. I think it needs to be in the release note, along with the forced reg_spot = 0 and the new KineticForces/Validity group

psilow=1e-4, psihigh=0.99999, mpsi=mpsi, mtheta=128),
Eq.SolovevConfig(64, 64, 64, 1.6, 0.33, 1.0, 1.9, 1.0, 1.0, 1.0)))
coarse, fine = sol_eq(64), sol_eq(128)
# ν is identically zero for this equilibrium, so it carries no signal.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

From claude: neither test covers ν: it is zero here, and the EFIT test checks only the Jacobian. Could the EFIT test also compare rzphi_nu? Solovev is analytic, so a direct comparison of r² against the exact surfaces would also be a stronger check than mpsi 64 vs 128.

@logan-nc logan-nc added this to the GPEC v2.0.0 milestone Oct 6, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bugfix Something was wrong and now is not changed-results Results move or an interface breaks - read before upgrading improvement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants