Repository navigation
Equilibrium - BUGFIX! - Sample all flux surfaces on common straight-fieldline abscissae and cap the coefficient-spline core knots - #398
Conversation
…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>
…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>
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>
…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>
|
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>
|
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. |
… vs #398; deltas inherited Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…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
…III-D step explosion is a #398 regression 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
…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
|
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 @ |
…tent-surface-theta-parametrization
…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
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:
#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:
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 ( 🤖 Generated with Claude Code |
…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
|
@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 ( 🤖 Generated with Claude Code |
|
@d-burg I'm gonna merge this weds latest. A read through before then would be 👍 🙏 |
d-burg
left a comment
There was a problem hiding this comment.
Some final claude notes and proposed to-dos:
Please fix before merge
- 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 forcedreg_spot = 0in kinetic runs. The title should mention it too. - 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.
- 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.
- Unit test for
core_capped_knots— see the inline comment. - Harness at head for
diiid_slayer_n1,gal_resistive_diiidandgal_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 |
There was a problem hiding this comment.
From claude: nothing is resampled any more; the fit is only needed for the θ-derivative.
| # 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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
The 2e-4 figure is for the n = 1 DIII-D spectrum. The cap constant does not scale with mpert.
| 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} |
There was a problem hiding this comment.
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| 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. |
There was a problem hiding this comment.
Do we want fortran source citations like this one?
| 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 |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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.
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. Neitherreltolnorabstoltouched it, because it is remap interpolation error, not integration error.The trace now returns its dense solution, and
equilibrium_solverroot-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 returnsnothingand 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_solverhas a single path. Before this round the fixcovered only
direct_fieldline_int:efit_arclengthstill resampled each surface from its ownadaptive steps and kept the noise, and the
sol === nothingbranch would have given any futuretracer 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_intreturned its ODE scratchDirectBField, which the right-hand side, therefinement 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 wasmutation-checked against the code it guards:
mpsi64 vs 128efit↔efit_arclength, same gridThe 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 atetol = 1e-7, the 9th-order solver crosses a wholesurface 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
etolmakes develop's steps denser and walksit toward this PR's answer, and this PR's answer does not depend on
etol:etolDevelop 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:
sol,tj_analytic_direct,efit) carried thisetol-dependent geometry error, at roughly the scaleabove. 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.
abstolon the two tracer solves this PR restructures. On develop, part of thenumerical 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
solvelines, 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:
et[1]developet[1]this PRdevelop'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 reportsection below; the mechanism discussion here is unaffected.diiid_n1 — physics moves only in the 3rd–4th digit, cost halves:
q0,q95,beta_t,beta_nand 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 andmpertare allexactly unchanged;
beta_t/beta_nmove 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: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/gmatsand 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)
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):
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
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 (largempsi), which is precisely the issue Performance: Why do large equilibrium splines slow down the code? #376 scenario.handoff/issue376/RESULTS.md§22–§23 (experiment branch).Verification
runtests_eulerlagrange,runtests_riccati,runtests_sing— all pass.handoff/issue376/RESULTS.md§22.Release note
efit_arclengthgets the same fix, and F and P are now evaluated on the traced surface. (harness @ 81006c3)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
mpsiwhere they previously drifted, andthe 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).
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.
Rebased onto develop (349a0c2)
Merged develop on 2026-09-04. The only conflict was
Fourfit.jl, where develop's MatrixSplinesrefactor 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. Capbehaviour is unchanged — only its plumbing follows develop.
Verification after the port:
runtests_equil279/279,runtests_grid_refinement59/59.Harness vs develop reproduces the headline effect —
diiid_n1ODE 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 abovetest/runtests_equil.jl— 279 pass (includes the arclength path)test/runtests_grid_refinement.jl— passtest/runtests_tj_analytic.jl— pass (exercisestj_analytic_direct, the same code path)Scope / follow-ups
eq_type ∈ {efit, efit_arclength, imas, sol, tj_analytic_direct}— everything reachingequilibrium_solverinDirectEquilibrium.jl.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.🤖 Generated with Claude Code
Follow-up verification: Δ′ and wall time (requested in review)
The original evidence used
diiid_n1/solovev_n1, which runintegrator = "forward"and thereforedo not emit the BVP Δ′ matrix at all. Re-ran the Δ′-tracking cases.
diiid_n1_riccatigal_resistive_diiid— the quantities the tearing/matching path consumesReading the 16.73%
It is one element. Δ′ BVP diagonal across an mpsi ladder on the riccati deck:
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_n1210.6 → 184.5 s,diiid_n1_riccati202.5 → 192.8 s,
gal_resistive_diiid216.8 → 213.6 s.