Skip to content

Equilibrium - BUGFIX! - 🚨 Tie the equilibrium ODE absolute tolerance to etol - #491

Merged
d-burg merged 10 commits into
developfrom
bugfix/equilibrium-abstol-follows-etol
Oct 9, 2026
Merged

d-burg merged 10 commits into
developfrom
bugfix/equilibrium-abstol-follows-etol

Conversation

@d-burg

@d-burg d-burg commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator

Release note

  • Audience: users
  • Numerical impact: DIII-D-like results move; Solovev, GGJ and kinetic Solovev cases are unchanged. Riccati Δ′ real parts move 0.1–1.3 % on the auto grid and at most 0.02 % on fixed grids (0.2 % at the 6/1). et[1] moves 0.07 %, the 2/1 SLAYER growth rate 0.6 % (3/1 0.20 %, 4/1 0.28 %), the NTV torque 0.12 % and the Galerkin Δ_coil block 0.99 %. The harness's worst-element figure for the Riccati Δ′ diagonal is 3.5–15.6 %; that is the imaginary part of the 5/1 element, which is not converged on either side. (harness @ 6d6a493)
  • Migration: a non-positive or non-finite etol is now a construction-time error. Decks with a valid etol need no change; DIII-D-like results will differ from earlier runs. An equilibrium ODE solve that stops before the end of its range (step limit, step-size underflow, instability) is now an error instead of a silently truncated solution, including inside the TJ-analytic ν root-find.

The equilibrium field-line, LAR and TJ-analytic integrations used reltol = etol with a hardcoded abstol, so tightening etol past 1e-8 did nothing where the solution is small. The absolute tolerance is now min(etol, previous hardcode): tighter decks converge as requested, and looser decks keep exactly their previous value.

image

Equilibrium of the DIII-D-like example against etol, each relative to the tightest run. On develop nothing improves past 1e-8; with this PR β_N and the q profile keep converging. At the default etol = 1e-10 the equilibrium setup takes 3.5 s instead of 2.5 s.

image

Left: change in Re Δ′ at each rational surface, develop to this PR, on three grids. Right: other tracked quantities on the same case.

Regression report

regress --refs 6fc89d021,6d6a4932d --force (all cases) on feynman: current develop against this branch with develop merged.

cases result
Solovev, GGJ, kinetic Solovev (8 cases) unchanged
diiid_n1_riccati (auto grid) Re Δ′ moves 0.14–1.33 % per surface
fixed grids, 128 and 256 points Re Δ′ moves ≤ 0.02 %; 0.22 % at the 6/1 on 256 points
diiid_n1 et[1] 0.07 %, NTV torque 0.12 %, resonant field magnitudes ≤ 0.06 %, equilibrium scalars ≤ 2e-5
diiid_slayer_n1 γ: 2/1 0.61 %, 3/1 0.20 %, 4/1 0.28 %
gal_resistive_diiid Δ′ matrix norm 0.05 %, Δ_coil block 0.99 %
diiid_error_field correction overlap 0.01 %, NTV torques 0.02–0.12 %
diiid_multi_n, efit_fixedbdy_separatrix ≤ 0.03 %
diiid_n1_riccati_precompiled the same lines as diiid_n1_riccati
gal_resistive_pe extracts nothing on either ref (a fault of that case, not this PR)

The Riccati Δ′ real parts are bit-identical, on both refs, to the run before the latest develop merge and the review fixes. The 2/1 γ figure depends on develop's unseeded triangulation, which moves it by about 0.1 % from run to run.

An earlier run, regress --refs 79ab50427,e875c3cbb --force (all cases), isolates the first return-code commit: every case is unchanged apart from the 0.01 % SLAYER γ line that is the same triangulation noise. So the check moves nothing, and no equilibrium solve in any harness case stops early.

Notes for reviewers

  • Not covered by ForceFreeStates - BUGFIX! - Scale the ODE absolute tolerance to each solution column #480: that PR fixes the ForceFreeStates solves only; this is the equilibrium solve.
  • Fortran comparison: Fortran's field-line integration already scales its absolute tolerance with etol. On matched fixed ldp grids (mpsi 128 and 256), this branch moves the converged Δ′ elements by 0.003–0.2 %, each toward Fortran STRIDE.
  • Overlap: Equilibrium - BUGFIX! - Sample all flux surfaces on common straight-fieldline abscissae and cap the coefficient-spline core knots #398 edits the same two files and may conflict.
  • Return-code check added, and it found a bug in this branch. The LAR profile and TJ-analytic shaping solves now error unless the integrator finished. The arc-length field-line solve must end on its midplane callback; reaching the end of its range means the field line never closed. The direct field-line solve keeps develop's own guard, which is stricter.
    • With abstol following etol, the LAR profile solve needs about 23,500 Rosenbrock23 steps at the default etol = 1e-10 (2,463 with the old fixed 1e-8). It was stopping at its hard-coded 10,000-step limit at r = 0.42 a, and the equilibrium was built from the truncated table; the existing LAR unit test still passed.
    • The fixed 10,000-step limit is removed, so the integrator's default of 1e6 applies. No tolerance changed. No harness case uses eq_type = "lar", which is why the earlier report did not show it.
  • Changes since the review (commit 6d6a4932d). All seven review suggestions are in.
    • The ν root-find falls back to ν = qa/qc only when the root-find itself has no bracket or does not converge. Any other error, including a trial shaping solve that stopped early, propagates. A test forces a failed trial solve and checks that it does.
    • check_equil_solve accepts only Success and Terminated; a test covers a StalledSuccess solution.
    • The TJ shaping solve loses its 10,000-step limit, as the LAR solve did. The redundant abstol arguments and the single-use constant are gone, and the etol docstring is shortened.
    • The tolerance helper and the solve check moved from EquilibriumTypes.jl to a new SolveTolerances.jl; no existing Equilibrium file was a natural home.
  • The torque sign flip reported earlier is gone. It came from an order-dependent tie-break in the m = 0 axis start on develop, which ForceFreeStates - BUGFIX! - Default the axis start to the fixed initialization #514 has since removed. Against current develop the perturbed-equilibrium toroidal torque moves 0.03 %, the PE surface energy 0.04 % and Im(et[1]) 0.07 %. The 50 % and 65 % rotation-scan lines on diiid_error_field are gone as well; that line now moves 0.03 %.

NO MERGE WITHOUT HUMAN REVIEW. The approval on this PR predates the develop merge and the review fixes, so the reviewer needs to see the new head before it is merged.

🤖 Generated with Claude Code

d-burg and others added 5 commits September 23, 2026 15:01
… etol

The field-line, LAR and TJ-analytic integrations passed reltol=etol but a
hardcoded abstol, which dominates wherever the solution is small, so tightening
etol past 1e-8 left q0 wandering at ~1e-9. Use abstol = min(etol, previous
hardcode): tighter decks now converge as requested, and looser decks (the
Solovev examples at etol=1e-7) keep exactly their previous abstol.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pass it explicitly

Replace the repeated min(etol, 1e-8) literal with equil_abstol(etol) capped at
EQUIL_ABSTOL_MAX, name the dense TJ-analytic ceiling TJ_DENSE_ABSTOL_MAX, pass
abstol explicitly from tj_analytic_run and tj_analytic_run_direct into the nu
root-find, and document that etol now governs abstol. Same values at every site.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…building the equilibrium config

etol sets both the relative and (capped) absolute tolerance of the equilibrium ODE solves, so zero, negative, NaN or Inf is now a construction-time error rather than a failed or meaningless solve. Adds a test in runtests_equil.jl.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@d-burg d-burg self-assigned this Oct 2, 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 Oct 2, 2026
…y, and raise the LAR step limit

With abstol following etol, the large-aspect-ratio profile solve needs ~2.3e4 Rosenbrock23 steps at the
default etol = 1e-10 and stopped at its 1e4-step limit at r = 0.42 a, so the equilibrium was built from a
truncated profile table. Every equilibrium ODE solve now checks its return code, and the LAR limit is 1e6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@d-burg d-burg changed the title Equilibrium - BUGFIX! - Tie the equilibrium ODE absolute tolerance to etol Equilibrium - BUGFIX! - 🚨 Tie the equilibrium ODE absolute tolerance to etol Oct 5, 2026
…-abstol-follows-etol

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… drop two redundant solve guards

The arc-length solve passed the return-code check with Success, which there means the field line never returned
to the midplane; it now requires Terminated. The direct field-line check duplicated develop's stricter guard,
and the LAR step limit equalled the integrator's default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@d-burg
d-burg requested a review from jhalpern30 October 6, 2026 18:27
@d-burg
d-burg marked this pull request as ready for review October 6, 2026 18:28
Copilot AI balanced review requested due to automatic review settings October 6, 2026 18:28

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

TJ root finding currently catches and suppresses newly detected ODE integration failures.

Review effort: Balanced
Findings: 1 High severity

Open (1)
What changed in this PR

Ties equilibrium ODE absolute tolerances to etol, validates tolerance inputs, and rejects incomplete integrations.

Changes:

  • Adds shared tolerance and solve-status helpers.
  • Applies tolerance scaling across direct, arc-length, LAR, and TJ integrations.
  • Adds validation and failure-handling tests.

One unresolved issue remains: TJ root finding can swallow ODE failures and silently fall back.

NO MERGE WITHOUT THIRD-PARTY HUMAN REVIEW

File Description
src/​Equilibrium/​EquilibriumTypes.jl Adds validation and shared helpers.
src/​Equilibrium/​DirectEquilibrium.jl Scales direct integration tolerance.
src/​Equilibrium/​DirectEquilibriumArcLength.jl Scales positional tolerance and validates closure.
src/​Equilibrium/​AnalyticEquilibrium.jl Updates LAR/TJ tolerances and completion checks.
test/​runtests_equil.jl Tests validation and solve status.

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

function tj_analytic_find_nu(p::TJAnalyticShapeParams, qa_target::Float64; reltol::Float64=1e-7, abstol::Float64=equil_abstol(reltol))
function q2_edge(nu::Float64)
sol = tj_analytic_shape_solve(p, nu; reltol)
sol = tj_analytic_shape_solve(p, nu; reltol, abstol)

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.

Claude's addition to this - a bit in the weeds for me, probably just combine the recommendation:
This catch swallows the new solve check. check_equil_solve throws ErrorException on MaxIters, step-size underflow, or an unstable right-hand side, and find_zero lets that exception out. Roots only raises Roots.ConvergenceFailed (no convergence) or ArgumentError whose message contains "not a bracketing interval" (the bracket has no sign change). Every other exception should propagate.

As written, one trial ν that stops early is logged as a root-find miss and replaced with ν = qa/qc. The final tj_analytic_shape_solve at that fallback can then succeed, so the equilibrium is built at the wrong ν.

catch err
    fallback = err isa Roots.ConvergenceFailed ||
               (err isa ArgumentError && occursin("not a bracketing interval", err.msg))
    fallback || rethrow()
    @warn "ν root-find failed for TJ-analytic equilibrium; falling back to lowest-order ν = qa/qc" exception=(err, catch_backtrace())
    nu_guess
end

exception=(err, catch_backtrace()) is what prints the stack; error=err only stores the object.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done with the patch as written. The fallback is taken only for Roots.ConvergenceFailed or the no-bracket ArgumentError, everything else rethrows, and the warning now carries the backtrace. I added a test that forces a failed trial solve (NaN abstol) and checks the error comes out of tj_analytic_find_nu.

One caveat: the no-bracket case is matched on Roots' message text, which I checked against the pinned Roots 2.3.0. If that text ever changes, the case will rethrow, so it fails loudly and not silently.

@jhalpern30 jhalpern30 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.

Looks good, only minor suggestions. Interesting how this is now the second abstol that was dropped but then turned out to be important to results

Comment thread src/Equilibrium/AnalyticEquilibrium.jl Outdated
if saveat === nothing
return solve(prob, Vern9(); reltol, abstol, maxiters=10000, dense=false)
sol = if saveat === nothing
solve(prob, Vern9(); reltol, abstol, maxiters=10000, dense=false)

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.

Claude says to drop the maxiters here, since the default of 1e6 should be fine - just like you did for the main LAR integrator above this.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in 6d6a493. It's gone from both solve calls in tj_analytic_shape_solve, so the integrator's default applies, same as the LAR solve.

Comment thread src/Equilibrium/AnalyticEquilibrium.jl Outdated

nu = tj_analytic_find_nu(p, tj.qa; reltol=equil_input.etol)
sol = tj_analytic_shape_solve(p, nu; reltol=equil_input.etol)
nu = tj_analytic_find_nu(p, tj.qa; reltol=equil_input.etol, abstol=equil_abstol(equil_input.etol))

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 extra argument isn't actually doing anything, you should be able to just pass in the reltol and get the default keyword argument behavior. This applies to 461, 461, and 601, and more clearly differentiates the alternate behavior you have for 608

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done. The three call sites now pass only reltol. The dense solve keeps its explicit abstol because its ceiling is tighter.

Comment thread src/Equilibrium/AnalyticEquilibrium.jl Outdated
end

# Tighter abstol ceiling for the dense-saveat TJ-analytic solve that feeds the (R, Z) → (r, w) Newton inversion.
const TJ_DENSE_ABSTOL_MAX = 1e-10

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 only used once below. Why not just hardcode 1e-10 there with a comment for why its there? Or something more obvious

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done. The constant is removed and the 1e-10 is inline, with a comment saying why that solve needs a tighter ceiling than the others.

Comment thread src/Equilibrium/EquilibriumTypes.jl Outdated
Return the equilibrium ODE solution `sol`, or raise an error naming the solve `what` if the integrator
stopped early (step limit, step-size underflow, instability) instead of finishing or terminating on its callback.
"""
function check_equil_solve(sol, what::AbstractString)

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, seems to make sense:
successful_retcode is also true for FloatingPointLimit, StalledSuccess, ExactSolutionLeft, and ExactSolutionRight. Those are nonlinear-solver exits; an equilibrium integral that stops on one of them has not reached the end of its range. ReturnCode is already in scope in this module:

retcode = sol.retcode
retcode == ReturnCode.Success || retcode == ReturnCode.Terminated ||
    error("Equilibrium ODE solve ($what) stopped early at t = $(sol.t[end]) with retcode $(retcode).")

Leave the arc-length check (DirectEquilibriumArcLength.jl:119) as a separate == ReturnCode.Terminated test. There, Success means the midplane callback never fired.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done as suggested. check_equil_solve accepts only Success and Terminated, and the arc-length check is still its own == Terminated test. I added a test that a StalledSuccess solution is rejected.

Comment thread src/Equilibrium/EquilibriumTypes.jl Outdated
- `newq0::Float64` - Target on-axis safety factor q(0); the q and F profiles are rescaled to
meet it (0 = use input value, -1 = use the axis extrapolation with its sign flipped)
- `etol::Float64` - Error tolerance for equilibrium solver
- `etol::Float64` - Relative tolerance of the equilibrium ODE solves (positive and finite); their

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.

Sure, I get having the check below to ensure a reasonable input. But does this really need to be included in the docstring lol, I think its fairly straightforward that a tolerance is positive and finite

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed, removed. The constructor check stays.

Comment thread src/Equilibrium/EquilibriumTypes.jl Outdated
end
end

"""

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.

These additions seem a bit out of place in EquilibriumTypes... is there a better place for them?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Moved both to a new src/Equilibrium/SolveTolerances.jl, included right after EquilibriumTypes.jl. I didn't find an existing Equilibrium file they fit in. Happy to move them again if you have one in mind.

function tj_analytic_find_nu(p::TJAnalyticShapeParams, qa_target::Float64; reltol::Float64=1e-7, abstol::Float64=equil_abstol(reltol))
function q2_edge(nu::Float64)
sol = tj_analytic_shape_solve(p, nu; reltol)
sol = tj_analytic_shape_solve(p, nu; reltol, abstol)

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.

Claude's addition to this - a bit in the weeds for me, probably just combine the recommendation:
This catch swallows the new solve check. check_equil_solve throws ErrorException on MaxIters, step-size underflow, or an unstable right-hand side, and find_zero lets that exception out. Roots only raises Roots.ConvergenceFailed (no convergence) or ArgumentError whose message contains "not a bracketing interval" (the bracket has no sign change). Every other exception should propagate.

As written, one trial ν that stops early is logged as a root-find miss and replaced with ν = qa/qc. The final tj_analytic_shape_solve at that fallback can then succeed, so the equilibrium is built at the wrong ν.

catch err
    fallback = err isa Roots.ConvergenceFailed ||
               (err isa ArgumentError && occursin("not a bracketing interval", err.msg))
    fallback || rethrow()
    @warn "ν root-find failed for TJ-analytic equilibrium; falling back to lowest-order ν = qa/qc" exception=(err, catch_backtrace())
    nu_guess
end

exception=(err, catch_backtrace()) is what prints the stack; error=err only stores the object.

d-burg and others added 2 commits October 9, 2026 12:39
…ind and accept only finished return codes

Review fixes. The ν root-find falls back to ν = qa/qc only when the
root-find itself has no bracket or does not converge. check_equil_solve
accepts Success and Terminated only. The TJ shaping solve drops its step
cap, redundant abstol arguments and a single-use constant go, and the two
solve helpers move to SolveTolerances.jl.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@d-burg

d-burg commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

All seven suggestions are in 6d6a493, together with a merge of current develop. I reran the full harness against develop and updated the numbers in the description.

The Riccati Δ′ real parts are bit-identical to the run you reviewed. Several other numbers shrank (NTV torque 0.88 % → 0.12 %, and the torque sign flip is gone) because #514 removed the axis-start tie-break that was inflating them. Could you take another look at the new head?

@d-burg
d-burg merged commit bfc6061 into develop Oct 9, 2026
26 checks passed
@d-burg
d-burg deleted the bugfix/equilibrium-abstol-follows-etol branch October 9, 2026 23:00
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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants