Skip to content

Commit a0520f4

Browse files
jhalpern30claude
andcommitted
EQUIL - REFACTOR - Freeze EquilibriumConfig by resolving psihigh onto the run inputs
EquilibriumConfig is now an immutable struct: it holds the input request and is never written to after construction. The separatrix clamp previously wrote its result back into config.psihigh, which was load-bearing through aliasing (PlasmaEquilibrium.config is raw_profile.config). The resolved value now lives on the run-input structs as psihigh_resolved, defaulted to config.psihigh by an outer constructor so existing readers construct unchanged. resolve_psihigh! replaces the two duplicated clamp blocks in setup_equilibrium, the three solvers form their psi grid from psihigh_resolved, and the efit_by_inversion mid-solve InverseRunInput rebuild forwards it explicitly instead of relying on the config alias. EquilibriumParameters.psihigh_resolved carries it onto the finished equilibrium for sing_lim! and the driver. Two other config mutations had to go for the struct to freeze: the eq_filename path resolution now folds into the dict before construction, and the rerun path clears eq_filename on a dict copy so the re-serialized inputs keep the user's original. EquilibriumConfig(path::String) delegates to the dict constructor rather than duplicating it. setup_equilibrium's signature is unchanged, so no caller, harness or benchmark entry point moves. No intended numerical change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 73e85a9 commit a0520f4

11 files changed

Lines changed: 108 additions & 54 deletions

benchmarks/benchmark_q_vs_iota_edge.jl

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@ const EXAMPLE_DIR = joinpath(@__DIR__, "..", "examples", "DIIID-like_ideal_examp
2020

2121
# Dense ldp reference equilibrium: treat its q(ψ) as ground truth
2222
function reference_q()
23-
_, eq_config, additional_input = GPE.build_inputs_from_toml(EXAMPLE_DIR)
24-
eq_config.grid_type = "ldp"
25-
eq_config.mpsi = 1024
23+
inputs, _, additional_input = GPE.build_inputs_from_toml(EXAMPLE_DIR)
24+
equil_dict = merge(inputs["Equilibrium"], Dict{String,Any}("grid_type" => "ldp", "mpsi" => 1024))
25+
eq_config = GPE.Equilibrium.EquilibriumConfig(equil_dict, EXAMPLE_DIR)
2626
equil = GPE.Equilibrium.setup_equilibrium(eq_config, additional_input)
2727
return equil, eq_config
2828
end

src/Equilibrium/DirectEquilibrium.jl

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -474,7 +474,7 @@ robustness.
474474
psio = raw_profile.psio
475475
mtheta = equil_params.mtheta
476476
psilow = equil_params.psilow
477-
psihigh = equil_params.psihigh
477+
psihigh = raw_profile.psihigh_resolved
478478

479479
# Locate the magnetic axis and separatrix for the field-line integrations
480480
ro, zo, _, rs2 = direct_position!(raw_profile)
@@ -647,6 +647,7 @@ robustness.
647647

648648
params = EquilibriumParameters()
649649
params.bt_sign = raw_profile.bt_sign
650+
params.psihigh_resolved = psihigh
650651

651652
return PlasmaEquilibrium(raw_profile.config, params, profiles, geometry,
652653
rzphi_xs, rzphi_ys,

src/Equilibrium/DirectEquilibriumByInversion.jl

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,12 +73,12 @@ end
7373
"""
7474
clamp_psihigh_to_separatrix(raw_profile) -> (clamped_psihigh, was_adjusted)
7575
76-
Binary-searches for the highest psihigh ≤ raw_profile.config.psihigh at which the
76+
Binary-searches for the highest psihigh ≤ `raw_profile.psihigh_resolved` at which the
7777
ψ level set is still a closed curve in the EFIT grid. Returns the safe value and a
7878
Bool indicating whether any clamping occurred.
7979
"""
8080
function clamp_psihigh_to_separatrix(raw_profile::DirectRunInput)
81-
psihigh = raw_profile.config.psihigh
81+
psihigh = raw_profile.psihigh_resolved
8282
ψ_coarse = raw_profile.psi_in.nodal_derivs.partials[1, :, :]
8383

8484
has_closed_contour(ψ_high) = any(
@@ -99,6 +99,22 @@ function clamp_psihigh_to_separatrix(raw_profile::DirectRunInput)
9999
return (lo, true)
100100
end
101101

102+
"""
103+
resolve_psihigh!(raw_profile) -> raw_profile
104+
105+
Clamp `raw_profile.psihigh_resolved` to the outermost closed flux surface, warning if the
106+
requested `psihigh` had none. Mutates and returns the input; `config.psihigh` keeps the request.
107+
"""
108+
function resolve_psihigh!(raw_profile::DirectRunInput)
109+
psihigh_safe, adjusted = clamp_psihigh_to_separatrix(raw_profile)
110+
if adjusted
111+
@warn "psihigh=$(raw_profile.psihigh_resolved) has no closed flux surface in EFIT grid; " *
112+
"clamped to $(round(psihigh_safe; sigdigits=7))"
113+
raw_profile.psihigh_resolved = psihigh_safe
114+
end
115+
return raw_profile
116+
end
117+
102118
"""
103119
resample_contour_to_theta_grid!(R_out, Z_out, curve, ro, zo, theta_grid)
104120
@@ -408,7 +424,7 @@ function equilibrium_solver_by_inversion(
408424
psio = raw_profile.psio
409425
mtheta = equil_params.mtheta
410426
psilow = equil_params.psilow
411-
psihigh = equil_params.psihigh
427+
psihigh = raw_profile.psihigh_resolved
412428

413429
# Locate the magnetic axis and separatrix for the contour tracing
414430
ro, zo, _, rs2 = direct_position!(raw_profile)
@@ -672,7 +688,7 @@ function equilibrium_solver_by_inversion(
672688
# Intermediate inverse input; the captured DirectIngest rides on the original eq_input,
673689
# which setup_equilibrium forwards onto the equilibrium, so this one carries ingest=nothing.
674690
inv_input = InverseRunInput(raw_profile.config, raw_profile.sq_in,
675-
rz_in_xs, rz_in_ys, rz_in_R, rz_in_Z, ro, zo, psio, nothing)
691+
rz_in_xs, rz_in_ys, rz_in_R, rz_in_Z, ro, zo, psio, nothing, raw_profile.psihigh_resolved)
676692

677693
pe = equilibrium_solver(inv_input; override_psi_nodes)
678694

src/Equilibrium/Equilibrium.jl

Lines changed: 6 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -92,27 +92,16 @@ function setup_equilibrium(eq_config::EquilibriumConfig, additional_input=nothin
9292
if additional_input isa DirectRunInput
9393
eq_input = additional_input
9494
eq_input.config = eq_config
95-
# Re-run the separatrix clamp for efit-family replays so an overridden
96-
# psihigh from the rerun TOML is re-validated against the closed flux region.
97-
if eq_type in EFIT_KINDS
98-
psihigh_safe, adjusted = clamp_psihigh_to_separatrix(eq_input)
99-
if adjusted
100-
@warn "psihigh=$(eq_input.config.psihigh) has no closed flux surface in EFIT grid; " *
101-
"clamped to $(round(psihigh_safe; sigdigits=7))"
102-
eq_input.config.psihigh = psihigh_safe
103-
end
104-
end
95+
# Reset before re-resolving: a pass-1 clamped value must not shadow a psihigh
96+
# overridden in the rerun TOML.
97+
eq_input.psihigh_resolved = eq_config.psihigh
98+
eq_type in EFIT_KINDS && resolve_psihigh!(eq_input)
10599
elseif additional_input isa InverseRunInput
106100
eq_input = additional_input
107101
eq_input.config = eq_config
102+
eq_input.psihigh_resolved = eq_config.psihigh
108103
elseif eq_type in EFIT_KINDS
109-
eq_input = read_efit(eq_config)
110-
psihigh_safe, adjusted = clamp_psihigh_to_separatrix(eq_input)
111-
if adjusted
112-
@warn "psihigh=$(eq_input.config.psihigh) has no closed flux surface in EFIT grid; " *
113-
"clamped to $(round(psihigh_safe; sigdigits=7))"
114-
eq_input.config.psihigh = psihigh_safe
115-
end
104+
eq_input = resolve_psihigh!(read_efit(eq_config))
116105
elseif eq_type in ["chease2", "chease_ascii"]
117106
eq_input = read_chease_ascii(eq_config)
118107
elseif eq_type in ["chease", "chease_binary"]

src/Equilibrium/EquilibriumTypes.jl

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ end
55
"""
66
EquilibriumConfig(...)
77
8-
A mutable struct containing configuration parameters for equilibrium reconstruction.
9-
Bundles all necessary settings originally specified in the equil fortran namelists.
8+
An immutable struct containing configuration parameters for equilibrium reconstruction
9+
specified in the input.
1010
1111
## Fields
1212
@@ -27,7 +27,11 @@ Bundles all necessary settings originally specified in the equil fortran namelis
2727
refinement when mpsi=0, three-region log layout when mpsi>0; "ldp", "pow1", "uniform";
2828
"log_asymptotic" is a legacy alias for "auto")
2929
- `psilow::Float64` - Lower limit of normalized flux coordinate
30-
- `psihigh::Float64` - Upper limit of normalized flux coordinate
30+
- `psihigh::Float64` - Requested upper limit of normalized flux coordinate. For efit-family
31+
equilibria this is the user's request, which may lie outside the closed-flux region; the
32+
value the equilibrium is actually formed on is `DirectRunInput.psihigh_resolved` (carried
33+
onto the equilibrium as `EquilibriumParameters.psihigh_resolved`). Read that, not this,
34+
for anything downstream of `setup_equilibrium`.
3135
- `mpsi::Int` - Number of radial grid intervals; 0 with grid_type="auto" selects the
3236
two-pass auto grid: the main driver forms a coarse pass-1 equilibrium, measures its curvature,
3337
pins knots on rational surfaces, and re-forms on the refined grid. Standalone `setup_equilibrium`
@@ -40,7 +44,7 @@ Bundles all necessary settings originally specified in the equil fortran namelis
4044
- `force_termination::Bool` - Terminate after equilibrium setup (skip stability calculations)
4145
- `use_galgrid::Bool` - Use the same grid as galerkin method
4246
"""
43-
@kwdef mutable struct EquilibriumConfig
47+
@kwdef struct EquilibriumConfig
4448
eq_type::String = "efit"
4549
eq_filename::String = "mypath"
4650
r0exp::Float64 = 1.0
@@ -176,15 +180,14 @@ function EquilibriumConfig(equil_dict::Dict{String,Any}, base_path::String="./")
176180
end
177181
end
178182

179-
# Construct validated struct
180-
config = EquilibriumConfig(; symbolize_keys(config_data)...)
181-
# Only resolve `eq_filename` against `base_path` if the user actually
182-
# supplied one (otherwise leave the kwdef sentinel for the embedded path).
183-
if haskey(config_data, "eq_filename") && !isabspath(config.eq_filename)
184-
config.eq_filename = normpath(joinpath(base_path, config.eq_filename))
183+
# Only resolve `eq_filename` against `base_path` if the user actually supplied one
184+
# (otherwise leave the kwdef sentinel for the embedded path). The empty string is the
185+
# rerun path's "no input file" marker and must stay empty, not become `base_path`.
186+
if haskey(config_data, "eq_filename") && !isempty(config_data["eq_filename"]) && !isabspath(config_data["eq_filename"])
187+
config_data["eq_filename"] = normpath(joinpath(base_path, config_data["eq_filename"]))
185188
end
186189

187-
return config
190+
return EquilibriumConfig(; symbolize_keys(config_data)...)
188191
end
189192

190193
"""
@@ -208,12 +211,7 @@ function EquilibriumConfig(path::String)
208211
end
209212

210213
# Construct validated struct
211-
config = EquilibriumConfig(; symbolize_keys(config_data)...)
212-
if !isabspath(config.eq_filename)
213-
config.eq_filename = normpath(joinpath(dirname(path), config.eq_filename))
214-
end
215-
216-
return config
214+
return EquilibriumConfig(Dict{String,Any}(config_data), dirname(path))
217215
end
218216

219217
"""
@@ -444,6 +442,10 @@ raw equilibrium data and preparing the initial splines.
444442
- `bt_sign::Int` — Sign of the toroidal field (+1 or -1); read from fpol sign in EFIT g-files
445443
- `ingest::EquilibriumIngest` — captured raw arrays for the `gpec.h5` rerun snapshot
446444
(a [`DirectIngest`](@ref) for file-based reads, or `nothing` for analytic equilibria)
445+
- `psihigh_resolved::Float64` — outer flux limit the equilibrium is formed on: `config.psihigh`
446+
clamped to the outermost closed flux surface by [`resolve_psihigh!`](@ref). Defaults to
447+
`config.psihigh` and only differs for efit-family equilibria whose requested limit falls
448+
outside the closed-flux region. The solvers build their ψ grid from this field.
447449
"""
448450
mutable struct DirectRunInput{S<:FastInterpolations.CubicSeriesInterpolant,I2D<:FastInterpolations.CubicInterpolantND}
449451
config::EquilibriumConfig
@@ -458,8 +460,16 @@ mutable struct DirectRunInput{S<:FastInterpolations.CubicSeriesInterpolant,I2D<:
458460
psio::Float64 # The total flux difference |ψ_axis - ψ_boundary| [Weber / radian].
459461
bt_sign::Int # Sign of the toroidal field: +1 or -1 (from fpol sign in g-file)
460462
ingest::EquilibriumIngest
463+
psihigh_resolved::Float64
461464
end
462465

466+
# Readers construct without a resolved psihigh; it starts at the request and `resolve_psihigh!`
467+
# clamps it for efit-family equilibria.
468+
DirectRunInput(config::EquilibriumConfig, sq_in, psi_in, psi_in_xs, psi_in_ys,
469+
rmin, rmax, zmin, zmax, psio, bt_sign, ingest) =
470+
DirectRunInput(config, sq_in, psi_in, psi_in_xs, psi_in_ys,
471+
rmin, rmax, zmin, zmax, psio, bt_sign, ingest, config.psihigh)
472+
463473
"""
464474
InverseRunInput(...)
465475
@@ -478,6 +488,9 @@ A container struct for inputs to the `inverse_run` function.
478488
- `psio::Float64` - Total flux difference |ψ_axis - ψ_boundary| [Wb/rad]
479489
- `ingest::EquilibriumIngest` - captured raw arrays for the `gpec.h5` rerun snapshot
480490
(an [`InverseIngest`](@ref) for file-based reads, or `nothing` for analytic equilibria)
491+
- `psihigh_resolved::Float64` - outer flux limit the equilibrium is formed on; see
492+
[`DirectRunInput`](@ref). Equals `config.psihigh` for every inverse reader (CHEASE,
493+
analytic); `efit_by_inversion` forwards the clamped value from its `DirectRunInput`.
481494
"""
482495
mutable struct InverseRunInput{S<:FastInterpolations.CubicSeriesInterpolant,I2D<:FastInterpolations.CubicInterpolantND}
483496
config::EquilibriumConfig
@@ -490,8 +503,14 @@ mutable struct InverseRunInput{S<:FastInterpolations.CubicSeriesInterpolant,I2D<
490503
zo::Float64 # Z axis location
491504
psio::Float64 # Total flux difference |psi_axis - psi_boundary|
492505
ingest::EquilibriumIngest
506+
psihigh_resolved::Float64
493507
end
494508

509+
InverseRunInput(config::EquilibriumConfig, sq_in, rz_in_xs, rz_in_ys, rz_in_R, rz_in_Z,
510+
ro, zo, psio, ingest) =
511+
InverseRunInput(config, sq_in, rz_in_xs, rz_in_ys, rz_in_R, rz_in_Z,
512+
ro, zo, psio, ingest, config.psihigh)
513+
495514
"""
496515
EquilibriumParameters
497516
@@ -502,6 +521,9 @@ A mutable struct containing computed equilibrium parameters and diagnostic flags
502521
- `ro::Union{Nothing,Float64}` - R-coordinate of the magnetic axis [m]
503522
- `zo::Union{Nothing,Float64}` - Z-coordinate of the magnetic axis [m]
504523
- `psio::Union{Nothing,Float64}` - Total flux difference |ψ_axis - ψ_boundary| [Wb/rad]
524+
- `psihigh_resolved::Union{Nothing,Float64}` - Outer flux limit the equilibrium was formed on,
525+
equal to the outermost ψ node. This is `config.psihigh` clamped to the outermost closed flux
526+
surface; downstream code wanting the plasma edge must read this, not `config.psihigh`.
505527
- `rsep::Union{Nothing,Vector{Float64}}` - R-coordinates of the plasma boundary [m]
506528
- `zsep::Union{Nothing,Vector{Float64}}` - Z-coordinates of the plasma boundary [m]
507529
- `rext::Union{Nothing,Vector{Float64}}` - R-coordinates of the plasma edge [m]
@@ -554,6 +576,7 @@ A mutable struct containing computed equilibrium parameters and diagnostic flags
554576
ro::Union{Nothing,Float64} = nothing # R-coordinate of the magnetic axis [m]
555577
zo::Union{Nothing,Float64} = nothing # Z-coordinate of the magnetic axis [m]
556578
psio::Union{Nothing,Float64} = nothing # Total flux difference |ψ_axis - ψ_boundary| [Wb/rad]
579+
psihigh_resolved::Union{Nothing,Float64} = nothing # Outer flux limit actually formed on (clamped config.psihigh)
557580
rsep::Union{Nothing,Vector{Float64}} = nothing # R-coordinates of the plasma boundary [m]
558581
zsep::Union{Nothing,Vector{Float64}} = nothing # Z-coordinates of the plasma boundary [m]
559582
rext::Union{Nothing,Vector{Float64}} = nothing # R-coordinates of the plasma edge [m]

src/Equilibrium/InverseEquilibrium.jl

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ function equilibrium_solver(input::InverseRunInput; override_psi_nodes::Union{No
5959
mpsi = config.mpsi
6060
mtheta = config.mtheta
6161
psilow = config.psilow
62-
psihigh = config.psihigh
62+
psihigh = input.psihigh_resolved
6363
newq0 = config.newq0
6464

6565
# c-----------------------------------------------------------------------
@@ -386,7 +386,7 @@ function equilibrium_solver(input::InverseRunInput; override_psi_nodes::Union{No
386386

387387
return PlasmaEquilibrium(
388388
input.config,
389-
EquilibriumParameters(),
389+
EquilibriumParameters(; psihigh_resolved=psihigh),
390390
profiles,
391391
geometry,
392392
rzphi_xs, rzphi_ys,

src/ForceFreeStates/Sing.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ function sing_lim!(intr::ForceFreeStatesInternal, ctrl::ForceFreeStatesControl,
115115
# Initial guesses based on equilibrium
116116
intr.qlim = min(equil.params.qmax, ctrl.qhigh) # equilibrium solve only goes up to qmax, so we're capped there
117117
intr.q1lim = profiles.q_deriv(profiles.xs[end]; hint=Ref(profiles.npts_minus_1))
118-
intr.psilim = equil.config.psihigh
118+
intr.psilim = equil.params.psihigh_resolved
119119

120120
# Optionally override qlim based on dmlim (Fortran sas_flag=t equivalent).
121121
# Multi-n runs (nn_low != nn_high) are not supported — the "outermost rational + dmlim/n"

src/GeneralizedPerturbedEquilibrium.jl

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -318,12 +318,11 @@ function main_from_inputs(
318318
sing_lim!(intr, ctrl, equil)
319319

320320
# If truncating before psihigh, reform equilibrium if desired
321-
if intr.psilim != equil.config.psihigh && ctrl.reform_eq_with_psilim
322-
@warn "Reforming equilibrium splines from psihigh to psilim not implemented yet. Proceeding with psihigh = $(equil.config.psihigh)."
321+
if intr.psilim != equil.params.psihigh_resolved && ctrl.reform_eq_with_psilim
322+
@warn "Reforming equilibrium splines from psihigh to psilim not implemented yet. Proceeding with psihigh = $(equil.params.psihigh_resolved)."
323323
# JMH - Nik please put the logic we discussed here
324324
# something like ?
325-
# equil.config.psihigh = intr.psilim
326-
# equil = set_up_equilibrium(equil.config)
325+
# re-form the equilibrium with its outer limit set to intr.psilim
327326
end
328327

329328
# Compute local stability (if desired). `locstab` holds `D_I` from the ballooning

src/Rerun.jl

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -267,9 +267,10 @@ function build_inputs_from_h5(args::Vector{String})
267267
" output: $(abspath(joinpath(output_dir, output_name)))\n$_BANNER"
268268

269269
_drop_deprecated_keys!(inputs["Equilibrium"], _DEPRECATED_EQUIL_KEYS, "Equilibrium")
270-
eq_config = Equilibrium.EquilibriumConfig(inputs["Equilibrium"], output_dir)
271-
# Clear eq_filename: unused on replay, and a stale absolute path could mislead downstream code.
272-
eq_config.eq_filename = ""
270+
# Clear eq_filename on a copy: unused on replay, a stale absolute path could mislead
271+
# downstream code, and `inputs` itself is re-serialized into the rerun's gpec_toml_raw.
272+
equil_dict = merge(inputs["Equilibrium"], Dict{String,Any}("eq_filename" => ""))
273+
eq_config = Equilibrium.EquilibriumConfig(equil_dict, output_dir)
273274

274275
# Analytic kinds regenerate from their TOML section; file-based kinds rebuild splines from
275276
# the stored ingest. A file-based run with no ingest can only come from a pre-ingest gpec.h5.

test/runtests_equil.jl

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,31 @@
6868
@test all(>(0), B_nodes)
6969
end
7070

71+
@testset "Resolved psihigh" begin
72+
# The config holds the user's request and is never written to; the value the
73+
# equilibrium is actually formed on rides on params.psihigh_resolved.
74+
for eq in (plasma_eq_efit, plasma_eq_arclength, plasma_eq_inversion)
75+
@test eq.params.psihigh_resolved == eq.rzphi_xs[end]
76+
@test eq.params.psihigh_resolved <= eq.config.psihigh
77+
end
78+
# 0.994 sits inside the closed-flux region, so nothing is clamped
79+
@test plasma_eq_efit.params.psihigh_resolved == 0.994
80+
81+
# Requesting the separatrix itself must leave the request intact on the config
82+
edge_config = GeneralizedPerturbedEquilibrium.Equilibrium.EquilibriumConfig(;
83+
eq_filename=joinpath(data_dir, "EQDSK_COCOS_02"),
84+
eq_type="efit",
85+
jac_type="boozer",
86+
grid_type="ldp",
87+
psilow=0.01,
88+
psihigh=1.0
89+
)
90+
plasma_eq_edge = GeneralizedPerturbedEquilibrium.Equilibrium.setup_equilibrium(edge_config)
91+
@test edge_config.psihigh == 1.0
92+
@test plasma_eq_edge.params.psihigh_resolved <= 1.0
93+
@test plasma_eq_edge.params.psihigh_resolved == plasma_eq_edge.rzphi_xs[end]
94+
end
95+
7196
@testset "EFIT Method Consistency" begin
7297
# All three methods solve the same equilibrium — q-profiles should broadly agree.
7398
# Tolerance is 10% to allow for method-specific discretisation differences.

0 commit comments

Comments
 (0)