Skip to content

Commit e8c9695

Browse files
logan-ncclaude
andcommitted
EQUIL - NEW FEATURE - Record the resistive-layer domain scan in gpec.h5
The scan now runs whenever kinetic profiles are reachable, with only the enforcement gated by psihigh_from_layer_overlap, so the file always records what the layer physics wanted for the domain. Equilibrium/psihigh_resolved domain actually reconstructed Equilibrium/psihigh_resistive_layers where adjacent layers start to overlap Equilibrium/ResistiveLayers/ per-surface widths for every scored surface rational_psi, rational_m, rational_n, r_s, delta_s_abs [m], delta_s_psi [1], extrapolated Comparing the two scalars answers "did the domain honour the layer physics", which previously required reading the run log. Both width forms are kept deliberately: delta_s_abs is the physical thickness and delta_s_psi is the same width in normalized flux, which is what the overlap criterion compares against surface spacing. delta_s_abs matches the leaf name already used in Tearing/LayerWidths per the one-name-per-quantity rule. psihigh_resolved matches the name PR #367 (immutable input structs) introduces. It is written explicitly here only because that PR has not landed; once it does, EquilibriumParameters carries the field and the EQUIL_H5_NAMES loop writes it, so the explicit line must be deleted or the dataset is created twice. The write site says so. Also threads resistivity_model and lnLambda_form from SLAYERControl into the scan; it had hardcoded SauterNeoModel/:nrl, so a deck selecting another closure would get a different eta -- and hence a different delta_s -- from the SLAYER stage. h5 schema test passes (the metadata contract covers the new datasets). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSrf6JCViFfVzqzkQ66o6b
1 parent 86071ec commit e8c9695

3 files changed

Lines changed: 131 additions & 37 deletions

File tree

src/GeneralizedPerturbedEquilibrium.jl

Lines changed: 81 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -109,10 +109,15 @@ function _layer_overlap_profiles(inputs, dir_path::AbstractString)
109109
"($(sprint(showerror, err))); the domain scan is skipped."
110110
nothing
111111
end
112+
# Every knob that feeds the layer width comes from the same control the SLAYER stage
113+
# uses, so delta_s at a shared surface is identical in Equilibrium/ResistiveLayers/
114+
# and Tearing/LayerWidths/ rather than silently differing by the resistivity closure.
112115
loaded !== nothing && return (profiles=loaded.profiles,
113116
chi_perp=loaded.chi_perp === nothing ? sl_ctrl.chi_perp : loaded.chi_perp,
114117
chi_tor=loaded.chi_tor === nothing ? sl_ctrl.chi_tor : loaded.chi_tor,
115-
mu_i=sl_ctrl.mu_i, zeff=sl_ctrl.zeff)
118+
mu_i=sl_ctrl.mu_i, zeff=sl_ctrl.zeff,
119+
resistivity_model=Runner._build_resistivity_model(sl_ctrl.resistivity_model),
120+
lnLambda_form=sl_ctrl.lnLambda_form)
116121
end
117122
end
118123
if haskey(inputs, "KineticForces")
@@ -126,7 +131,9 @@ function _layer_overlap_profiles(inputs, dir_path::AbstractString)
126131
T_e=data.T_e, T_i=data.T_i,
127132
omega=(data.omega_E === nothing ? zeros(npsi) : data.omega_E),
128133
omega_e=zeros(npsi), omega_i=zeros(npsi))
129-
return (profiles=profiles, chi_perp=1.0, chi_tor=1.0, mu_i=2.0, zeff=1.0)
134+
return (profiles=profiles, chi_perp=1.0, chi_tor=1.0, mu_i=2.0, zeff=1.0,
135+
resistivity_model=Tearing.InnerLayer.SLAYER.SauterNeoModel(),
136+
lnLambda_form=:nrl)
130137
catch err
131138
err isa InterruptException && rethrow()
132139
@warn "psihigh_from_layer_overlap: could not read [KineticForces] kinetic_file " *
@@ -162,29 +169,34 @@ end
162169
# superset, so its measured knot density is still usable), or the accepted trial for an
163170
# outward move. `eq_config.psihigh` is left untouched when the scan cannot run.
164171
function _apply_layer_overlap_psihigh!(eq_config, equil, rerun_input, inputs, intr, ctrl;
165-
max_trials::Int=4)
172+
enforce::Bool=true, max_trials::Int=4)
173+
_rec(; kw...) = merge((scan=nothing, recommended=NaN, psihigh_delta_s=NaN, psihigh_visco=NaN,
174+
floor=NaN, applied=eq_config.psihigh, enforced=enforce, reason="not_run"), kw)
166175
if intr.nlow != intr.nhigh
167-
@warn "psihigh_from_layer_overlap is ignored for multi-n runs (nlow=$(intr.nlow), nhigh=$(intr.nhigh)); " *
176+
enforce && @warn "psihigh_from_layer_overlap is ignored for multi-n runs (nlow=$(intr.nlow), nhigh=$(intr.nhigh)); " *
168177
"the overlap criterion depends on which n sets the rational spacing. Keeping psihigh=$(eq_config.psihigh)."
169-
return equil
178+
return equil, _rec(; reason="multi_n_unsupported")
170179
end
171180
kin = _layer_overlap_profiles(inputs, intr.dir_path)
172181
if kin === nothing
173-
@info "psihigh_from_layer_overlap: no kinetic profiles configured ([SLAYER] profile_file or " *
182+
enforce && @info "psihigh_from_layer_overlap: no kinetic profiles configured ([SLAYER] profile_file or " *
174183
"[KineticForces] kinetic_file); keeping psihigh = $(@sprintf("%.6f", eq_config.psihigh))."
175-
return equil
184+
return equil, _rec(; reason="no_kinetic_profiles")
176185
end
177186

178187
psihigh_safe = eq_config.psihigh
179188
scan = Tearing.resistive_layer_overlap(equil, kin.profiles;
180189
n_tor=intr.nlow, psihigh_safe=psihigh_safe,
181190
chi_perp=kin.chi_perp, chi_tor=kin.chi_tor,
182-
mu_i=kin.mu_i, zeff=kin.zeff)
191+
mu_i=kin.mu_i, zeff=kin.zeff,
192+
resistivity_model=kin.resistivity_model, lnLambda_form=kin.lnLambda_form)
193+
_rec2(; kw...) = _rec(; scan=scan, psihigh_delta_s=something(scan.psihigh_delta_s, NaN),
194+
psihigh_visco=something(scan.psihigh_visco, NaN), kw...)
183195
target = scan.psihigh
184196
if target === nothing
185-
@info "psihigh_from_layer_overlap: no resistive-layer overlap within the scanned range; " *
186-
"keeping psihigh = $(@sprintf("%.6f", psihigh_safe))."
187-
return equil
197+
@info "resistive-layer overlap: no overlap within the scanned range; " *
198+
"psihigh = $(@sprintf("%.6f", psihigh_safe)) is unconstrained by layer physics."
199+
return equil, _rec2(; reason="no_overlap")
188200
end
189201
k = scan.first_overlap
190202
why = "layers of the q=$(scan.m[k])/$(scan.n[k]) and q=$(scan.m[k-1])/$(scan.n[k-1]) surfaces overlap"
@@ -194,38 +206,49 @@ function _apply_layer_overlap_psihigh!(eq_config, equil, rerun_input, inputs, in
194206
# more likely wrong than the plasma is that pathological.
195207
n_kept = count(<=(target), scan.psi)
196208
if n_kept < 2
197-
@warn "psihigh_from_layer_overlap: the criterion asks for psihigh = $(@sprintf("%.6f", target)), " *
209+
@warn "resistive-layer overlap: the criterion asks for psihigh = $(@sprintf("%.6f", target)), " *
198210
"which would keep only $n_kept rational surface(s) ($why). Refusing to truncate — " *
199211
"check the layer widths. Keeping psihigh = $(@sprintf("%.6f", psihigh_safe))."
200-
return equil
212+
return equil, _rec2(; recommended=target, reason="too_few_surfaces")
201213
end
202214

203215
# Lower gate: never cut inside the rational window psiedge sits in.
204216
fl = _psiedge_floor(scan.psi, ctrl.psiedge, psihigh_safe)
217+
floor_val = fl === nothing ? NaN : fl.floor
205218
if fl !== nothing && target < fl.floor
206-
eq_config.psihigh = fl.floor
207-
@warn "psihigh_from_layer_overlap: the layer criterion asks for psihigh = " *
219+
enforce && (eq_config.psihigh = fl.floor)
220+
@warn "resistive-layer overlap: the layer criterion asks for psihigh = " *
208221
"$(@sprintf("%.6f", target)) ($why), but psiedge = $(@sprintf("%.6f", ctrl.psiedge)) " *
209222
"sits inside the q=$(scan.m[fl.index])/$(scan.n[fl.index]) window, so the domain is " *
210223
"held at $(@sprintf("%.6f", fl.floor)) to keep the dW edge scan band open. The layer " *
211224
"criterion is overridden here — the retained surfaces are set by psiedge, not by physics."
212-
return equil
225+
return equil, _rec2(; recommended=target, floor=floor_val,
226+
applied=enforce ? fl.floor : eq_config.psihigh, reason="psiedge_floor")
213227
end
214228

215229
# Inward: the pass-1 equilibrium already covers the domain, so nothing to verify.
216230
if target <= psihigh_safe
217-
eq_config.psihigh = target
231+
enforce && (eq_config.psihigh = target)
218232
a_top = Tearing.InnerLayer.SLAYER.surface_minor_radius(equil, psihigh_safe)
219233
a_cut = Tearing.InnerLayer.SLAYER.surface_minor_radius(equil, target)
220234
@info "psihigh_from_layer_overlap: $(@sprintf("%.6f", psihigh_safe)) -> $(@sprintf("%.6f", target)) " *
221235
"($why); removes $(@sprintf("%.2f", 1e3 * (a_top - a_cut))) mm of minor radius, " *
222-
"$(@sprintf("%.2f", 100 * (a_top - a_cut) / a_top))% of a, keeping $n_kept of $(length(scan.psi)) surfaces"
223-
return equil
236+
"$(@sprintf("%.2f", 100 * (a_top - a_cut) / a_top))% of a, keeping $n_kept of $(length(scan.psi)) surfaces" *
237+
(enforce ? "" : " [DIAGNOSTIC ONLY -- psihigh_from_layer_overlap is off, psihigh unchanged]")
238+
return equil, _rec2(; recommended=target, floor=floor_val,
239+
applied=enforce ? target : eq_config.psihigh,
240+
reason=enforce ? "applied" : "not_enforced")
224241
end
225242

226243
# Outward: the domain has to be formed and checked before it can be trusted. The raw psi(R,Z)
227244
# map lives on DirectRunInput, which plain `efit` uses too -- pass it so the round-trip
228245
# tracing residual runs on every direct/EFIT deck, not only on efit_by_inversion.
246+
if !enforce
247+
@info "resistive-layer overlap: layers stay separated beyond the first-pass bound; the " *
248+
"criterion would raise psihigh to $(@sprintf("%.6f", target)) [DIAGNOSTIC ONLY -- " *
249+
"psihigh_from_layer_overlap is off, psihigh unchanged]"
250+
return equil, _rec2(; recommended=target, floor=floor_val, reason="not_enforced")
251+
end
229252
psi_map = rerun_input isa Equilibrium.DirectRunInput ? rerun_input.psi_in : nothing
230253
psio_ref = rerun_input isa Equilibrium.DirectRunInput ? rerun_input.psio : nothing
231254
accepted, accepted_equil = psihigh_safe, equil
@@ -268,7 +291,8 @@ function _apply_layer_overlap_psihigh!(eq_config, equil, rerun_input, inputs, in
268291
@info "psihigh_from_layer_overlap: $(@sprintf("%.6f", psihigh_safe)) -> $(@sprintf("%.6f", accepted)) " *
269292
"(layers still separated beyond the first-pass bound; $why)"
270293
end
271-
return accepted_equil
294+
return accepted_equil, _rec2(; recommended=target, floor=floor_val, applied=accepted,
295+
reason=accepted <= psihigh_safe ? "formation_gate" : "applied")
272296
end
273297

274298
# Re-run the overlap scan on the final equilibrium and report disagreement with the
@@ -280,7 +304,8 @@ function _verify_layer_overlap_psihigh(eq_config, equil, inputs, intr; rtol::Rea
280304
scan = Tearing.resistive_layer_overlap(equil, kin.profiles;
281305
n_tor=intr.nlow, psihigh_safe=eq_config.psihigh,
282306
chi_perp=kin.chi_perp, chi_tor=kin.chi_tor,
283-
mu_i=kin.mu_i, zeff=kin.zeff)
307+
mu_i=kin.mu_i, zeff=kin.zeff,
308+
resistivity_model=kin.resistivity_model, lnLambda_form=kin.lnLambda_form)
284309
target = scan.psihigh
285310
target === nothing && return nothing
286311
rel = abs(target - eq_config.psihigh) / max(eq_config.psihigh, eps())
@@ -471,10 +496,13 @@ function main_from_inputs(
471496
# formed there. This runs BEFORE grid construction: setup_equilibrium re-clamps psihigh
472497
# to the closed-flux region internally, and _validate_psi_nodes hard-errors if psihigh
473498
# moves after the nodes are built.
499+
# The scan ALWAYS runs when kinetic profiles are reachable, so gpec.h5 always records what
500+
# the resistive-layer logic would have chosen. Only the enforcement is gated by the flag --
501+
# which is what makes a discrepancy between the psihigh used and the criterion (a psiedge
502+
# floor, a formation back-off, or simply the flag being off) visible after the fact.
474503
psihigh_pass1 = eq_config.psihigh
475-
if eq_config.psihigh_from_layer_overlap
476-
equil = _apply_layer_overlap_psihigh!(eq_config, equil, rerun_input, inputs, intr, ctrl)
477-
end
504+
equil, layer_overlap = _apply_layer_overlap_psihigh!(eq_config, equil, rerun_input, inputs,
505+
intr, ctrl; enforce=eq_config.psihigh_from_layer_overlap)
478506
psihigh_moved = eq_config.psihigh != psihigh_pass1
479507

480508
# Two-pass auto grid: measure the pass-1 equilibrium's curvature (profiles, geometry,
@@ -747,7 +775,8 @@ function main_from_inputs(
747775
forcing_modes_snapshot,
748776
gal_data;
749777
locstab=locstab,
750-
ballooning_boundary=ballooning_boundary
778+
ballooning_boundary=ballooning_boundary,
779+
layer_overlap=layer_overlap
751780
)
752781
@info "Results written to $(ctrl.HDF5_filename)"
753782
end
@@ -957,7 +986,8 @@ function write_outputs_to_HDF5(
957986
forcing_modes::Union{Nothing,Vector{ForcingTerms.ForcingMode}}=nothing,
958987
gal_data::Union{GalerkinResult,Nothing}=nothing;
959988
locstab::Union{FastInterpolations.CubicSeriesInterpolant,Nothing}=nothing,
960-
ballooning_boundary=(psi=Float64[], alpha=Float64[], alpha_critical=Float64[])
989+
ballooning_boundary=(psi=Float64[], alpha=Float64[], alpha_critical=Float64[]),
990+
layer_overlap=nothing
961991
)
962992

963993
# Idempotent: already done if a PerturbedEquilibrium stage ran. Leaves the stores empty
@@ -1018,6 +1048,31 @@ function write_outputs_to_HDF5(
10181048
val === nothing && continue
10191049
out_h5["Equilibrium/$(get(EQUIL_H5_NAMES, f, String(f)))"] = val
10201050
end
1051+
# Domain actually reconstructed, and what the resistive-layer physics asked for. The two
1052+
# differ whenever the psiedge floor, the formation gate, or the enforcement flag
1053+
# intervened, so comparing them is how a user checks the domain against the layer logic.
1054+
#
1055+
# NOTE: `psihigh_resolved` is written explicitly here only until PR #367 (immutable input
1056+
# structs) lands -- that PR puts `psihigh_resolved` on EquilibriumParameters, at which
1057+
# point the EQUIL_H5_NAMES loop above writes it and THIS LINE MUST BE DELETED or the
1058+
# dataset is created twice.
1059+
out_h5["Equilibrium/psihigh_resolved"] = equil.config.psihigh
1060+
out_h5["Equilibrium/psihigh_resistive_layers"] =
1061+
layer_overlap === nothing ? NaN : Float64(layer_overlap.recommended)
1062+
1063+
# Per-surface resistive layer widths from the domain scan: every rational surface it
1064+
# scored, including any located outside the grid. Written whenever the scan could run,
1065+
# independently of whether the SLAYER stage ran or the truncation was enforced.
1066+
if layer_overlap !== nothing && layer_overlap.scan !== nothing
1067+
sc = layer_overlap.scan
1068+
out_h5["Equilibrium/ResistiveLayers/rational_psi"] = sc.psi
1069+
out_h5["Equilibrium/ResistiveLayers/rational_m"] = sc.m
1070+
out_h5["Equilibrium/ResistiveLayers/rational_n"] = sc.n
1071+
out_h5["Equilibrium/ResistiveLayers/r_s"] = sc.rs
1072+
out_h5["Equilibrium/ResistiveLayers/delta_s_abs"] = sc.delta_s_m
1073+
out_h5["Equilibrium/ResistiveLayers/delta_s_psi"] = sc.width_delta_s
1074+
out_h5["Equilibrium/ResistiveLayers/extrapolated"] = Int.(sc.extrapolated)
1075+
end
10211076
out_h5["Equilibrium/psi_total"] = equil.psio
10221077
out_h5["Equilibrium/R_axis"] = equil.ro
10231078
out_h5["Equilibrium/Z_axis"] = equil.zo

src/HDF5Schema.jl

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,37 @@ const MAIN_H5_ANNOTATIONS = [
6464
# --- Equilibrium/ scalars (written per-field when set; superset listed) ---
6565
"Equilibrium/R_axis" => (; long_name="R-coordinate of the magnetic axis", units="m"),
6666
"Equilibrium/Z_axis" => (; long_name="Z-coordinate of the magnetic axis", units="m"),
67+
"Equilibrium/psihigh_resolved" =>
68+
(;
69+
long_name="normalized poloidal flux at the outer edge of the reconstructed equilibrium domain; may differ from the requested [Equilibrium] psihigh via the separatrix clamp or the resistive-layer truncation",
70+
units="1"
71+
),
72+
"Equilibrium/psihigh_resistive_layers" =>
73+
(;
74+
long_name="normalized poloidal flux at which adjacent rational surfaces' resistive layers begin to overlap; NaN when not computed (no kinetic profiles, multi-n run, or no overlap in range). Compare with psihigh_resolved to check the domain against the layer physics",
75+
units="1"
76+
),
77+
"Equilibrium/ResistiveLayers/rational_psi" =>
78+
(; long_name="normalized poloidal flux of each rational surface scored by the resistive-layer scan", units="1", dims=("rational",)),
79+
"Equilibrium/ResistiveLayers/rational_m" =>
80+
(; long_name="poloidal mode number m of each scored rational surface", dims=("rational",)),
81+
"Equilibrium/ResistiveLayers/rational_n" =>
82+
(; long_name="toroidal mode number n of each scored rational surface", dims=("rational",)),
83+
"Equilibrium/ResistiveLayers/r_s" =>
84+
(; long_name="minor radius of each scored rational surface", units="m", dims=("rational",)),
85+
"Equilibrium/ResistiveLayers/delta_s_abs" =>
86+
(; long_name="physical resistive layer thickness |δ_s| at each scored rational surface", units="m", dims=("rational",)),
87+
"Equilibrium/ResistiveLayers/delta_s_psi" =>
88+
(;
89+
long_name="the same layer thickness in normalized flux, |δ_s|/|da/dψ|, so it is directly comparable with the spacing between rational surfaces",
90+
units="1",
91+
dims=("rational",)
92+
),
93+
"Equilibrium/ResistiveLayers/extrapolated" =>
94+
(;
95+
long_name="flag: this surface was located outside the equilibrium grid (edge q-law for a diverted plasma, or the real q profile for an inverse equilibrium) rather than found within it",
96+
dims=("rational",)
97+
),
6798
"Equilibrium/psi_total" => (; long_name="total poloidal flux difference |ψ_axis − ψ_boundary|", units="Wb/rad"),
6899
"Equilibrium/R_midplane" => (; long_name="R of the boundary at the inboard and outboard midplane crossings", units="m"),
69100
"Equilibrium/R_extremum" => (; long_name="R of the boundary at its upper and lower Z extrema", units="m"),
@@ -238,7 +269,7 @@ const MAIN_H5_ANNOTATIONS = [
238269
"SurfaceGeometries/Plasma/z" => (; long_name="Cartesian z of plasma-surface point cloud", units="m"),
239270
"SurfaceGeometries/Wall/x" => (; long_name="Cartesian x of wall point cloud", units="m"),
240271
"SurfaceGeometries/Wall/y" => (; long_name="Cartesian y of wall point cloud", units="m"),
241-
"SurfaceGeometries/Wall/z" => (; long_name="Cartesian z of wall point cloud", units="m"),
272+
"SurfaceGeometries/Wall/z" => (; long_name="Cartesian z of wall point cloud", units="m")
242273
]
243274

244275
# Euler-Lagrange operator matrices: same wording per letter, Ideal/ and Kinetic/ variants.
@@ -251,7 +282,7 @@ const _ELM_IDEAL_LETTERS = [
251282
("H", "Euler-Lagrange primitive coefficient matrix H"),
252283
("F", "Euler-Lagrange derived coefficient matrix F"),
253284
("K", "Euler-Lagrange derived coefficient matrix K"),
254-
("G", "Euler-Lagrange derived coefficient matrix G"),
285+
("G", "Euler-Lagrange derived coefficient matrix G")
255286
]
256287
# The kinetic branch overwrites only A, B, C, K, G and adds f0; D, E, H, F are shared
257288
# unchanged from the ideal set and are not re-emitted.
@@ -261,14 +292,19 @@ const _ELM_KINETIC_LETTERS = [
261292
("C", "Euler-Lagrange primitive coefficient matrix C"),
262293
("K", "Euler-Lagrange derived coefficient matrix K"),
263294
("G", "Euler-Lagrange derived coefficient matrix G"),
264-
("f0", "raw kinetic component matrix f0"),
295+
("f0", "raw kinetic component matrix f0")
265296
]
266297
const ELM_H5_ANNOTATIONS = vcat(
267298
["ForceFreeStates/EulerLagrangeMatrices/psi" => (; long_name="normalized poloidal flux ψ_N grid of the operator matrices", scale="psi")],
268-
["ForceFreeStates/EulerLagrangeMatrices/Ideal/$l" =>
269-
(; long_name="ideal " * d, dims=("psi", "mode_row", "mode_col"), attach=(1 => "ForceFreeStates/EulerLagrangeMatrices/psi",)) for (l, d) in _ELM_IDEAL_LETTERS],
270-
["ForceFreeStates/EulerLagrangeMatrices/Kinetic/$l" =>
271-
(; long_name="kinetic-modified " * d, dims=("psi", "mode_row", "mode_col"), attach=(1 => "ForceFreeStates/EulerLagrangeMatrices/psi",)) for (l, d) in _ELM_KINETIC_LETTERS]
299+
[
300+
"ForceFreeStates/EulerLagrangeMatrices/Ideal/$l" =>
301+
(; long_name="ideal " * d, dims=("psi", "mode_row", "mode_col"), attach=(1 => "ForceFreeStates/EulerLagrangeMatrices/psi",)) for (l, d) in _ELM_IDEAL_LETTERS
302+
],
303+
[
304+
"ForceFreeStates/EulerLagrangeMatrices/Kinetic/$l" =>
305+
(; long_name="kinetic-modified " * d, dims=("psi", "mode_row", "mode_col"), attach=(1 => "ForceFreeStates/EulerLagrangeMatrices/psi",)) for
306+
(l, d) in _ELM_KINETIC_LETTERS
307+
]
272308
)
273309

274310
"""

0 commit comments

Comments
 (0)