Repository navigation
Equilibrium - BUGFIX! - 🚨 Tie the equilibrium ODE absolute tolerance to etol - #491
Conversation
… 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>
…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>
…-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>
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
TJ root finding currently catches and suppresses newly detected ODE integration failures.
Review effort: Balanced
Findings: 1
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) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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
| 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) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
|
|
||
| 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)) |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Done. The three call sites now pass only reltol. The dense solve keeps its explicit abstol because its ceiling is tighter.
| 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 |
There was a problem hiding this comment.
This is only used once below. Why not just hardcode 1e-10 there with a comment for why its there? Or something more obvious
There was a problem hiding this comment.
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.
| 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) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| - `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 |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Agreed, removed. The constructor check stays.
| end | ||
| end | ||
|
|
||
| """ |
There was a problem hiding this comment.
These additions seem a bit out of place in EquilibriumTypes... is there a better place for them?
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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.
…-abstol-follows-etol
…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>
|
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? |

Release note
etolis now a construction-time error. Decks with a validetolneed 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 = etolwith a hardcodedabstol, so tighteningetolpast 1e-8 did nothing where the solution is small. The absolute tolerance is nowmin(etol, previous hardcode): tighter decks converge as requested, and looser decks keep exactly their previous value.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 defaultetol = 1e-10the equilibrium setup takes 3.5 s instead of 2.5 s.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.diiid_n1_riccati(auto grid)diiid_n1diiid_slayer_n1gal_resistive_diiiddiiid_error_fielddiiid_multi_n,efit_fixedbdy_separatrixdiiid_n1_riccati_precompileddiiid_n1_riccatigal_resistive_peThe 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
etol. On matched fixedldpgrids (mpsi 128 and 256), this branch moves the converged Δ′ elements by 0.003–0.2 %, each toward Fortran STRIDE.etol, the LAR profile solve needs about 23,500 Rosenbrock23 steps at the defaultetol = 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.eq_type = "lar", which is why the earlier report did not show it.6d6a4932d). All seven review suggestions are in.check_equil_solveaccepts onlySuccessandTerminated; a test covers aStalledSuccesssolution.abstolarguments and the single-use constant are gone, and theetoldocstring is shortened.EquilibriumTypes.jlto a newSolveTolerances.jl; no existing Equilibrium file was a natural home.diiid_error_fieldare 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