Skip to content

Commit 288fe03

Browse files
logan-ncclaude
andcommitted
EQUIL - BUGFIX - Detect field-line ODE failure; add psiedge floor and guard the trial loop
Section 0 of the plan first: triple-checked the metres->flux conversion before building on it. All three checks pass on the DIII-D-like deck. - d_i = 0.0509 m vs the NRL c/omega_pi form 0.0510 m (0.2%) - |dels_db| = 0.67..1.45, i.e. genuinely O(1); delta_s = 1.8..4.4 mm; delta_s/r_s = 0.3..1.1% - da/dpsi agrees to <=3.6% across surface_da_dpsi, a different-h difference, and the secant between adjacent surfaces - the set of overlapping pairs is IDENTICAL computed all-in-flux and all-in-metres, so the conversion cannot invent an overlap Decisive on its own, needing no conversion: q=6 and q=7 sit 1.821 mm apart with half-widths summing to 2.338 mm. Every other adjacent pair is 8.7 mm or more apart. The truncation removes 3.59 mm, 0.66% of the minor radius -- far out, as expected; the psi number reads deep only because psi is compressed at the edge. The original mixed-units comparison finds NO overlap at all on the in-grid surfaces, so it would not have truncated. DirectEquilibrium.jl: check sol.retcode and the eta=2*pi endpoint after the field-line solve. A failed integration does not throw -- it returns a solution truncated wherever it gave up, and the caller read the last point as if the surface closed. That was a silently wrong equilibrium; it is now a named error that says which psifac failed. Lower gate: the resolved psihigh may not fall below the top of the rational-q window psiedge sits in -- the first rational surface OUTBOARD of psiedge -- because the dW edge scan runs over [psiedge, psilim] and needs room for its maximum. psiedge >= psihigh (the documented scan-disabled sentinel) means no floor, else decks that switch the scan off could never truncate. Independently, refuse to truncate if fewer than two rational surfaces would survive. On the SLAYER deck the floor binds: the criterion asks 0.98919, psiedge=0.99 sits in the q=6 window, so the domain is held at 0.99328 and the warning says the retained surfaces are set by psiedge rather than by physics. Trial loop: catch any exception from setup_equilibrium (axis finder, separatrix finder, field-line ODE, Roots convergence) as a failed trial and back off, and restore eq_config.psihigh, which was mutated before the attempt. Also thread DirectRunInput.psi_in through so the round-trip tracing residual runs on plain efit decks, not only efit_by_inversion -- the capability existed, it was unwired. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSrf6JCViFfVzqzkQ66o6b
1 parent 9e026a5 commit 288fe03

3 files changed

Lines changed: 121 additions & 6 deletions

File tree

src/Equilibrium/DirectEquilibrium.jl

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,26 @@ function direct_fieldline_int(psifac::Float64, raw_profile::DirectRunInput, ro::
293293
prob = ODEProblem{true}(direct_fieldline_der!, u0, (0.0, 2π), params)
294294
sol = solve(prob, Vern9(); callback=callback, reltol=equil_config.etol, abstol=1e-8, dt=2π / 200, adaptive=true, dense=false)
295295

296+
# A failed integration does not throw: it returns a solution truncated wherever it gave up,
297+
# and the caller reads the last point as if it closed the field line at η = 2π. That is a
298+
# silently wrong surface, so check the retcode and the endpoint. Surfaces very close to the
299+
# separatrix are where this fires; callers trialling a large psihigh catch it and back off.
300+
if sol.retcode != ReturnCode.Success
301+
error(
302+
"direct_fieldline_int: field-line integration failed at psifac = " *
303+
"$(@sprintf("%.6f", psifac)) (retcode $(sol.retcode)); the flux surface did not " *
304+
"close. This usually means psihigh is too close to the separatrix for the " *
305+
"equilibrium grid to resolve."
306+
)
307+
end
308+
if !isapprox(sol.t[end], 2π; atol=1e-8)
309+
error(
310+
"direct_fieldline_int: field-line integration at psifac = " *
311+
"$(@sprintf("%.6f", psifac)) stopped at eta = $(@sprintf("%.6f", sol.t[end])) " *
312+
"instead of 2*pi; the flux surface did not close."
313+
)
314+
end
315+
296316
sol_matrix = reduce(hcat, sol.u::Vector{Vector{Float64}})'
297317
return hcat(sol.t::Vector{Float64}, sol_matrix), bfield
298318
end

src/GeneralizedPerturbedEquilibrium.jl

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -120,12 +120,25 @@ function _layer_overlap_profiles(inputs, dir_path::AbstractString)
120120
return nothing
121121
end
122122

123+
# Lower gate. The resolved psihigh may not fall below the top of the rational-q window that
124+
# `psiedge` sits in -- i.e. the first rational surface outboard of psiedge -- because the dW edge
125+
# scan runs over [psiedge, psilim] and needs room for its maximum to be findable. `min` with the
126+
# deck value keeps the floor from ever raising psihigh above what was asked for. Returns nothing
127+
# when psiedge >= psihigh (the documented "scan disabled" sentinel: no dW band to protect) or
128+
# when no rational surface lies outboard of psiedge.
129+
function _psiedge_floor(psis, psiedge::Real, psihigh_deck::Real)
130+
psiedge >= psihigh_deck && return nothing
131+
idx = findfirst(>(psiedge), psis)
132+
idx === nothing && return nothing
133+
return (floor=min(psihigh_deck, psis[idx]), index=idx)
134+
end
135+
123136
# Run the overlap scan on the pass-1 equilibrium, gate the result against what the
124137
# equilibrium reconstruction can actually deliver, and set `eq_config.psihigh`.
125138
# Returns the equilibrium to carry forward: the pass-1 one for an inward move (it spans a
126139
# superset, so its measured knot density is still usable), or the accepted trial for an
127140
# outward move. `eq_config.psihigh` is left untouched when the scan cannot run.
128-
function _apply_layer_overlap_psihigh!(eq_config, equil, rerun_input, inputs, intr;
141+
function _apply_layer_overlap_psihigh!(eq_config, equil, rerun_input, inputs, intr, ctrl;
129142
max_trials::Int=4)
130143
if intr.nlow != intr.nhigh
131144
@warn "psihigh_from_layer_overlap is ignored for multi-n runs (nlow=$(intr.nlow), nhigh=$(intr.nhigh)); " *
@@ -153,21 +166,66 @@ function _apply_layer_overlap_psihigh!(eq_config, equil, rerun_input, inputs, in
153166
k = scan.first_overlap
154167
why = "layers of the q=$(scan.m[k])/$(scan.n[k]) and q=$(scan.m[k-1])/$(scan.n[k-1]) surfaces overlap"
155168

169+
# Refuse outright if the domain would keep fewer than two rational surfaces: a one-surface
170+
# domain cannot support the analysis this feature serves, and widths asking for it are far
171+
# more likely wrong than the plasma is that pathological.
172+
n_kept = count(<=(target), scan.psi)
173+
if n_kept < 2
174+
@warn "psihigh_from_layer_overlap: the criterion asks for psihigh = $(@sprintf("%.6f", target)), " *
175+
"which would keep only $n_kept rational surface(s) ($why). Refusing to truncate — " *
176+
"check the layer widths. Keeping psihigh = $(@sprintf("%.6f", psihigh_safe))."
177+
return equil
178+
end
179+
180+
# Lower gate: never cut inside the rational window psiedge sits in.
181+
fl = _psiedge_floor(scan.psi, ctrl.psiedge, psihigh_safe)
182+
if fl !== nothing && target < fl.floor
183+
eq_config.psihigh = fl.floor
184+
@warn "psihigh_from_layer_overlap: the layer criterion asks for psihigh = " *
185+
"$(@sprintf("%.6f", target)) ($why), but psiedge = $(@sprintf("%.6f", ctrl.psiedge)) " *
186+
"sits inside the q=$(scan.m[fl.index])/$(scan.n[fl.index]) window, so the domain is " *
187+
"held at $(@sprintf("%.6f", fl.floor)) to keep the dW edge scan band open. The layer " *
188+
"criterion is overridden here — the retained surfaces are set by psiedge, not by physics."
189+
return equil
190+
end
191+
156192
# Inward: the pass-1 equilibrium already covers the domain, so nothing to verify.
157193
if target <= psihigh_safe
158194
eq_config.psihigh = target
159-
@info "psihigh_from_layer_overlap: $(@sprintf("%.6f", psihigh_safe)) -> $(@sprintf("%.6f", target)) ($why)"
195+
a_top = Tearing.InnerLayer.SLAYER.surface_minor_radius(equil, psihigh_safe)
196+
a_cut = Tearing.InnerLayer.SLAYER.surface_minor_radius(equil, target)
197+
@info "psihigh_from_layer_overlap: $(@sprintf("%.6f", psihigh_safe)) -> $(@sprintf("%.6f", target)) " *
198+
"($why); removes $(@sprintf("%.2f", 1e3 * (a_top - a_cut))) mm of minor radius, " *
199+
"$(@sprintf("%.2f", 100 * (a_top - a_cut) / a_top))% of a, keeping $n_kept of $(length(scan.psi)) surfaces"
160200
return equil
161201
end
162202

163-
# Outward: the domain has to be formed and checked before it can be trusted.
203+
# Outward: the domain has to be formed and checked before it can be trusted. The raw psi(R,Z)
204+
# map lives on DirectRunInput, which plain `efit` uses too -- pass it so the round-trip
205+
# tracing residual runs on every direct/EFIT deck, not only on efit_by_inversion.
206+
psi_map = rerun_input isa Equilibrium.DirectRunInput ? rerun_input.psi_in : nothing
207+
psio_ref = rerun_input isa Equilibrium.DirectRunInput ? rerun_input.psio : nothing
164208
accepted, accepted_equil = psihigh_safe, equil
165209
lo, hi = psihigh_safe, target
166210
for trial in 1:max_trials
167211
eq_config.psihigh = hi
168-
trial_equil = Equilibrium.setup_equilibrium(eq_config, rerun_input)
212+
# Forming an equilibrium this far out can fail hard: the axis and separatrix finders,
213+
# the field-line ODE, and the Roots calls inside it all throw near the separatrix. Any
214+
# of those is a failed trial, not a failed run -- and psihigh must be restored, since
215+
# it was mutated before the attempt.
216+
trial_equil = try
217+
Equilibrium.setup_equilibrium(eq_config, rerun_input)
218+
catch err
219+
err isa InterruptException && rethrow()
220+
eq_config.psihigh = accepted
221+
@warn "psihigh_from_layer_overlap: equilibrium formation threw at psihigh = " *
222+
"$(@sprintf("%.6f", hi)) (trial $trial/$max_trials): $(sprint(showerror, err)); " *
223+
"backing off toward $(@sprintf("%.6f", lo))."
224+
hi = (lo + hi) / 2
225+
continue
226+
end
169227
hi = eq_config.psihigh # setup_equilibrium may clamp back to the closed-flux region
170-
q = Equilibrium.equilibrium_edge_quality(trial_equil)
228+
q = Equilibrium.equilibrium_edge_quality(trial_equil; psi_map=psi_map, psio=psio_ref)
171229
if q.ok
172230
accepted, accepted_equil = hi, trial_equil
173231
@info "psihigh_from_layer_overlap: trial at psihigh = $(@sprintf("%.6f", hi)) accepted " *
@@ -392,7 +450,7 @@ function main_from_inputs(
392450
# moves after the nodes are built.
393451
psihigh_pass1 = eq_config.psihigh
394452
if eq_config.psihigh_from_layer_overlap
395-
equil = _apply_layer_overlap_psihigh!(eq_config, equil, rerun_input, inputs, intr)
453+
equil = _apply_layer_overlap_psihigh!(eq_config, equil, rerun_input, inputs, intr, ctrl)
396454
end
397455
psihigh_moved = eq_config.psihigh != psihigh_pass1
398456

test/runtests_layer_overlap.jl

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,43 @@
118118
@test q.ringing_ok
119119
end
120120

121+
@testset "psiedge floor: window top, not bottom" begin
122+
# Surfaces bracketing psiedge=0.99: the floor must be the first surface OUTBOARD of it,
123+
# so the dW scan band [psiedge, psilim] stays open.
124+
psis = [0.52, 0.77, 0.89, 0.968, 0.9933, 0.9985]
125+
fl = GeneralizedPerturbedEquilibrium._psiedge_floor(psis, 0.99, 0.9995)
126+
@test fl !== nothing
127+
@test fl.floor 0.9933 # q=6, the window top -- NOT 0.968
128+
@test fl.index == 5
129+
# min() with the deck value: the floor can never raise psihigh above what was asked for.
130+
fl2 = GeneralizedPerturbedEquilibrium._psiedge_floor(psis, 0.99, 0.992)
131+
@test fl2.floor 0.992
132+
end
133+
134+
@testset "psiedge floor: disabled sentinel and no-surface cases" begin
135+
psis = [0.52, 0.77, 0.89, 0.968, 0.9933, 0.9985]
136+
# psiedge >= psihigh is the documented "scan disabled" sentinel -> no floor at all,
137+
# otherwise decks that switch the dW scan off could never truncate.
138+
@test GeneralizedPerturbedEquilibrium._psiedge_floor(psis, 1.0, 0.9995) === nothing
139+
@test GeneralizedPerturbedEquilibrium._psiedge_floor(psis, 0.9995, 0.9995) === nothing
140+
# No rational surface outboard of psiedge -> nothing to floor against.
141+
@test GeneralizedPerturbedEquilibrium._psiedge_floor(psis, 0.999, 0.9995) === nothing
142+
@test GeneralizedPerturbedEquilibrium._psiedge_floor(Float64[], 0.99, 0.9995) === nothing
143+
end
144+
145+
@testset "field-line ODE failure is an error, not silent truncation" begin
146+
# A failed solve returns a solution truncated short of eta = 2*pi, which used to be
147+
# consumed as a closed flux surface. Both guards must be present in direct_fieldline_int:
148+
# the retcode check and the endpoint check (a solve can stop early and still report
149+
# Success via a callback-driven terminate).
150+
src = read(joinpath(dirname(@__DIR__), "src", "Equilibrium", "DirectEquilibrium.jl"), String)
151+
@test occursin("sol.retcode != ReturnCode.Success", src)
152+
@test occursin("isapprox(sol.t[end], 2π", src)
153+
# Both must name the surface so a failure is attributable to a psihigh, not just a stack trace.
154+
@test count("direct_fieldline_int: field-line integration", src) == 2
155+
@test occursin("psihigh is too close to the separatrix", src)
156+
end
157+
121158
@testset "psihigh_from_layer_overlap defaults off" begin
122159
@test Equilibrium.EquilibriumConfig().psihigh_from_layer_overlap == false
123160
@test eq_cfg.psihigh_from_layer_overlap == false

0 commit comments

Comments
 (0)