Skip to content

Commit baf6746

Browse files
d-burgclaude
andcommitted
InnerLayer.SLAYER - FEATURE - Add the Fitzpatrick flux radial label and unify the label plumbing
Factor the rs_method radial-label options out of build_slayer_inputs into one exported radial_label(equil; rs_method, theta) returning (r, dr/dpsi) closures, and add a fifth label :flux — Fitzpatrick's toroidal-flux surface label (Nucl. Fusion 2025, Eq. 30), r = sqrt(2 psi_t / B0) with the g = F/(B0 R0) correction carried. All five labels now use analytic psi-derivatives from the interpolants themselves; the finite-difference stencils (which silently clamped within 1e-4 of the flux boundaries) are removed. The :fsa branch gains the max(r^2, 0) guard needed on extrapolated surfaces. k_ref and the K^(2mu) Delta-prime conversion consume the same closures, so the label choice drives S, the r-based shear, W_d and the Delta-prime reference length together for every option. Default :midplane behavior is unchanged up to the analytic-derivative refinement. Shared infrastructure for the resistive layer-overlap psihigh cap branch, which evaluates its criterion in :flux. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent f8e454c commit baf6746

4 files changed

Lines changed: 140 additions & 89 deletions

File tree

src/InnerLayer/InnerLayer.jl

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import .GGJ: delta_convergence, solution_profile, asymptotic_profile, q4_surface
2525

2626
import .SLAYER: SLAYERModel, SLAYERParameters, slayer_parameters, r_based_shear
2727
import .SLAYER: riccati_del_s, slayer_layer_thickness, LayerWidths
28-
import .SLAYER: surface_minor_radius, surface_da_dpsi, build_slayer_inputs
28+
import .SLAYER: surface_minor_radius, surface_da_dpsi, radial_label, build_slayer_inputs
2929

3030
export InnerLayerModel, InnerLayerParameters, InnerLayerResponse, solve_inner, solve_inner_profile
3131
export GGJ, GGJModel, GGJParameters
@@ -37,6 +37,6 @@ export delta_convergence, solution_profile, asymptotic_profile, q4_surface_bench
3737

3838
export SLAYER, SLAYERModel, SLAYERParameters, slayer_parameters, r_based_shear
3939
export riccati_del_s, slayer_layer_thickness, LayerWidths
40-
export surface_minor_radius, surface_da_dpsi, build_slayer_inputs
40+
export surface_minor_radius, surface_da_dpsi, radial_label, build_slayer_inputs
4141

4242
end # module InnerLayer

src/InnerLayer/SLAYER/LayerInputs.jl

Lines changed: 121 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,14 @@
99
# Geometry extraction:
1010
# - Minor radius at the outboard midplane (θ = 0) via
1111
# `equil.rzphi_rsquared((ψ, 0.0))`.
12-
# - `da/dψ` via central finite difference on the same bicubic.
12+
# - `da/dψ` from the interpolant's own analytic ψ-derivative.
1313
# - r-based magnetic shear via `r_based_shear(rs, q, q1, da/dψ)` (defined
1414
# in LayerParameters.jl).
1515

1616
using ..Utilities: KineticProfiles
1717
using ...Utilities.NeoclassicalResistivity: NeoResistivityModel, SpitzerModel,
1818
coulomb_log_e, nu_star_e
19-
using FastInterpolations: DerivOp, integrate
19+
using FastInterpolations: DerivOp, integrate, cubic_interp, cumulative_integrate, ExtendExtrap
2020

2121
"""
2222
surface_minor_radius(equil, psi; theta=0.0) -> Float64
@@ -32,33 +32,123 @@ function surface_minor_radius(equil, psi::Real; theta::Real=0.0)
3232
end
3333

3434
"""
35-
surface_da_dpsi(equil, psi; theta=0.0, h=1e-5) -> Float64
35+
surface_da_dpsi(equil, psi; theta=0.0) -> Float64
3636
37-
Central finite-difference approximation of `d(minor radius)/dψ` at `psi`.
38-
Falls back to one-sided differences near the flux-coordinate boundaries
39-
(0 or 1).
37+
Analytic ψ-derivative of the minor radius at `psi` and poloidal angle
38+
`theta`, taken from the `rzphi_rsquared` interpolant's own ψ-derivative as
39+
`da/dψ = (∂r²/∂ψ) / (2a)`. Valid wherever the interpolant is, including
40+
under extrapolation past the ψ grid. Diverges at the magnetic axis, where
41+
`a ~ √ψ`; callers evaluating near `ψ = 0` must check `isfinite`.
4042
"""
41-
function surface_da_dpsi(equil, psi::Real; theta::Real=0.0, h::Real=1e-5)
42-
psi_f = Float64(psi)
43-
# Clamp to safe sampling range within (0, 1)
44-
eps_edge = 10 * h
45-
lo = psi_f - h
46-
hi = psi_f + h
47-
if lo < eps_edge
48-
# one-sided forward
49-
a0 = surface_minor_radius(equil, max(psi_f, eps_edge); theta=theta)
50-
a1 = surface_minor_radius(equil, max(psi_f, eps_edge) + h; theta=theta)
51-
return (a1 - a0) / h
52-
elseif hi > 1.0 - eps_edge
53-
# one-sided backward
54-
a0 = surface_minor_radius(equil, min(psi_f, 1.0 - eps_edge) - h; theta=theta)
55-
a1 = surface_minor_radius(equil, min(psi_f, 1.0 - eps_edge); theta=theta)
56-
return (a1 - a0) / h
43+
function surface_da_dpsi(equil, psi::Real; theta::Real=0.0)
44+
return _da_dpsi_at_theta(equil, Float64(psi), Float64(theta))
45+
end
46+
47+
# d(√r²)/dψ at one (ψ, θ) from the interpolant's own ψ-derivative. Shared by all radial-label
48+
# conventions so none carries its own stencil.
49+
@inline function _da_dpsi_at_theta(equil, psi::Float64, theta::Float64)
50+
r_sq = equil.rzphi_rsquared((psi, theta))
51+
a = sqrt(max(r_sq, 0.0))
52+
a > 0 || return Inf # magnetic axis: a ~ √ψ, so da/dψ genuinely diverges
53+
return equil.rzphi_rsquared((psi, theta); deriv=DerivOp(1, 0)) / (2a)
54+
end
55+
56+
"""
57+
radial_label(equil; rs_method=:midplane, theta=0.0) -> (r_at, dr_dpsi_at)
58+
59+
Build the pair of closures `r_at(ψ)` and `dr_dpsi_at(ψ)` defining one radial
60+
label for the layer stack. Both closures must come from the same label,
61+
because the r-based shear `(r/q)dq/dr`, `τ_R = μ₀r²/η`, `τ_E = r²/χ`, the
62+
`d_β/r` normalization, the Δ' reference-length factor `k_ref = r_s/(da/dψ)`,
63+
and any metre-to-ψ width conversion all have to live in one coordinate; a
64+
mismatched `r` and `dr/dψ` silently corrupts every one of them. All
65+
derivatives are analytic — no label carries a finite-difference stencil.
66+
67+
# Labels
68+
69+
- `:midplane` -- outboard-midplane chord from the magnetic axis at `theta`
70+
(historical default), natural for comparison with midplane diagnostics.
71+
- `:halfwidth` -- midplane half-chord, the mean of the outboard and inboard
72+
chords at `θ = 0` and `θ = 0.5`; shift-free. Coincides with the flux label
73+
on circular equilibria but is its own convention on shaped ones.
74+
- `:fsa` -- θ-mean surface radius, a 128-point midpoint mean of the local
75+
minor radius; the closest geometric approximation to `:flux` at interior
76+
surfaces of shaped equilibria.
77+
- `:volume` -- cylinder-equivalent label `√(V(ψ)/(2π²R₀))`, the
78+
Rutherford-literature convention.
79+
- `:flux` -- toroidal-flux label. Fitzpatrick, Nucl. Fusion (2025),
80+
Eq. 30: `dψ_p/dr = B₀ r g/q` integrates to `ψ_t = B₀r²/2`, so
81+
`r = √(2ψ_t/B₀)` with `ψ_t = psio·∫₀^ψ (q/g) dψ′` and `g = F/(B₀R₀)`.
82+
Defined from flux alone, it carries no circular-cross-section assumption,
83+
and its derivative `dr/dψ ∝ q` grows toward a separatrix where the
84+
geometric labels' `da/dψ` collapses.
85+
86+
On shaped equilibria the labels agree at low-q surfaces and diverge strongly
87+
near the edge, where the slab-layer matching is label-ambiguous regardless of
88+
choice. The label is selected programmatically; it is not exposed via TOML.
89+
"""
90+
function radial_label(equil; rs_method::Symbol=:midplane, theta::Real=0.0)
91+
theta_f = Float64(theta)
92+
93+
_flux_r, _flux_dr = if rs_method === :flux
94+
b0f = Float64(equil.params.b0)
95+
R0f = Float64(equil.ro)
96+
psiof = Float64(equil.psio)
97+
xs_f = collect(Float64, equil.profiles.xs)
98+
# g = F/(B0 R0) departs from 1 by ~3% on a DIII-D-like deck and ~0.8% on a circular
99+
# one, so carry it rather than assuming g = 1: r² = 2∫(q/g)dψ_p/B0.
100+
_g_at(x) = Float64(equil.profiles.F_spline(x)) / (2π * b0f * R0f)
101+
qg = [Float64(equil.profiles.q_spline(x)) / _g_at(x) for x in xs_f]
102+
Phi = collect(Float64, cumulative_integrate(cubic_interp(xs_f, qg)))
103+
r_knots = sqrt.(max.(2 .* psiof .* Phi ./ b0f, 0.0))
104+
rspl = cubic_interp(xs_f, r_knots; extrap=ExtendExtrap())
105+
-> Float64(rspl(Float64(ψ))),
106+
ψ -> psiof * Float64(equil.profiles.q_spline(Float64(ψ))) /
107+
(b0f * _g_at(Float64(ψ)) * max(Float64(rspl(Float64(ψ))), eps())))
57108
else
58-
a_plus = surface_minor_radius(equil, psi_f + h; theta=theta)
59-
a_minus = surface_minor_radius(equil, psi_f - h; theta=theta)
60-
return (a_plus - a_minus) / (2h)
109+
(nothing, nothing)
61110
end
111+
112+
_a_at(ψ, θ) = sqrt(max(equil.rzphi_rsquared((Float64(ψ), Float64(θ))), 0.0))
113+
114+
_rs_at(ψ) =
115+
if rs_method === :fsa
116+
N = 128
117+
s = 0.0
118+
@inbounds for k in 1:N
119+
s += _a_at(ψ, (k - 0.5) / N)
120+
end
121+
s / N
122+
elseif rs_method === :halfwidth
123+
0.5 * (_a_at(ψ, 0.0) + _a_at(ψ, 0.5))
124+
elseif rs_method === :volume
125+
V = integrate(equil.profiles.dVdpsi_spline, 1e-4, Float64(ψ))
126+
sqrt(max(V, 0.0) / (2π^2 * equil.ro))
127+
elseif rs_method === :flux
128+
_flux_r(ψ)
129+
else
130+
surface_minor_radius(equil, ψ; theta=theta_f)
131+
end
132+
133+
_da_dpsi_at(ψ) =
134+
if rs_method === :fsa
135+
N = 128
136+
s = 0.0
137+
@inbounds for k in 1:N
138+
s += _da_dpsi_at_theta(equil, Float64(ψ), (k - 0.5) / N)
139+
end
140+
s / N
141+
elseif rs_method === :halfwidth
142+
0.5 * (_da_dpsi_at_theta(equil, Float64(ψ), 0.0) + _da_dpsi_at_theta(equil, Float64(ψ), 0.5))
143+
elseif rs_method === :volume
144+
Float64(equil.profiles.dVdpsi_spline(ψ)) / (4π^2 * equil.ro * max(_rs_at(ψ), eps()))
145+
elseif rs_method === :flux
146+
_flux_dr(ψ)
147+
else
148+
_da_dpsi_at_theta(equil, Float64(ψ), theta_f)
149+
end
150+
151+
return (_rs_at, _da_dpsi_at)
62152
end
63153

64154
"""
@@ -114,19 +204,11 @@ profiles, without an intermediate file round-trip.
114204
a prescribed value. (For `dc_type=:rfitzp` and `:lar`, dgeo_val is
115205
not consulted.)
116206
- `dc_type` -- `:none` (default), `:lar`, `:rfitzp`, or `:toroidal`.
117-
- `rs_method` -- radial label defining `r_s` for the whole layer stack
118-
(S, r-based shear, W_d, and the Δ' reference-length factor `k_ref` all
119-
follow it together, so every choice is self-consistent). `:midplane`
120-
(default): outboard-midplane chord from the magnetic axis — natural for
121-
comparison with midplane diagnostics. `:halfwidth`: midplane half-chord
122-
`(R_out − R_in)/2` — shift-free, the closest stand-in for the circular
123-
flux label the Fitzpatrick layer formulas are derived in (reproduces the
124-
cylindrical-theory label to ~1% on circular benchmark equilibria).
125-
`:fsa`: θ-mean surface radius. `:volume`: cylinder-equivalent label
126-
`√(V(ψ)/(2π²R₀))`, the Rutherford-literature convention. On shaped
127-
equilibria the labels agree at low-q surfaces (~±3% in growth rate at
128-
q=2) and diverge strongly near the edge, where the slab-layer matching
129-
is label-ambiguous regardless of choice. Not exposed via TOML —
207+
- `rs_method` -- radial label defining `r_s` for the whole layer stack:
208+
`:midplane` (default), `:halfwidth`, `:fsa`, `:volume`, or `:flux`. See
209+
[`radial_label`](@ref) for the definitions. S, the r-based shear, W_d,
210+
and the Δ' reference-length factor `k_ref` all follow the choice
211+
together, so every option is self-consistent. Not exposed via TOML —
130212
programmatic use only.
131213
- `theta` -- poloidal angle at which to measure minor radius (default
132214
`0.0`, outboard midplane).
@@ -170,47 +252,7 @@ function build_slayer_inputs(equil, sings, profiles::KineticProfiles;
170252
Float64(bt(ψ))
171253
end
172254

173-
# Minor-radius extractor: `:midplane` = outboard-midplane chord
174-
# (original behavior); `:fsa` = θ-mean of √rzphi_rsquared, the
175-
# flux-surface-averaged minor radius; `:halfwidth` = midplane half-chord
176-
# (R_out − R_in)/2, the shift-free circular flux label; `:volume` = the
177-
# cylinder-equivalent label r_V = √(V(ψ)/(2π²R₀)).
178-
_rs_at(ψ) =
179-
if rs_method === :fsa
180-
integrand(θ) = sqrt(equil.rzphi_rsquared((Float64(ψ), Float64(θ))))
181-
N = 128
182-
s = 0.0
183-
@inbounds for k in 1:N
184-
s += integrand((k - 0.5) / N)
185-
end
186-
s / N
187-
elseif rs_method === :halfwidth
188-
0.5 * (surface_minor_radius(equil, ψ; theta=0.0) +
189-
surface_minor_radius(equil, ψ; theta=0.5))
190-
elseif rs_method === :volume
191-
lo = 1e-4
192-
V = integrate(equil.profiles.dVdpsi_spline, lo, Float64(ψ))
193-
sqrt(max(V, 0.0) / (2π^2 * equil.ro))
194-
else
195-
surface_minor_radius(equil, ψ; theta=theta)
196-
end
197-
_da_dpsi_at(ψ) =
198-
if rs_method !== :midplane
199-
# central finite difference on _rs_at
200-
h = 1e-5
201-
lo = ψ - h
202-
hi = ψ + h
203-
eps_edge = 10h
204-
if lo < eps_edge
205-
(_rs_at(max(ψ, eps_edge) + h) - _rs_at(max(ψ, eps_edge))) / h
206-
elseif hi > 1.0 - eps_edge
207-
(_rs_at(min(ψ, 1.0 - eps_edge)) - _rs_at(min(ψ, 1.0 - eps_edge) - h)) / h
208-
else
209-
(_rs_at+ h) - _rs_at- h)) / (2h)
210-
end
211-
else
212-
surface_da_dpsi(equil, ψ; theta=theta)
213-
end
255+
_rs_at, _da_dpsi_at = radial_label(equil; rs_method=rs_method, theta=theta)
214256

215257
# Per-surface ω_*e, ω_*i (diamagnetic frequencies) from spline
216258
# derivatives. When `compute_omega_star=true` we override any ω_*e/ω_*i

src/InnerLayer/SLAYER/SLAYER.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ include("LayerInputs.jl")
5656
export SLAYERModel, SLAYERParameters, slayer_parameters
5757
export r_based_shear
5858
export riccati_del_s, slayer_layer_thickness, LayerWidths
59-
export surface_minor_radius, surface_da_dpsi, build_slayer_inputs
59+
export surface_minor_radius, surface_da_dpsi, radial_label, build_slayer_inputs
6060
export NeoResistivityModel, SpitzerModel, SpitzerHarmModel, SauterNeoModel, RedlNeoModel
6161

6262
end # module SLAYER

test/runtests_slayer_inputs.jl

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,8 @@
4242
@test r1 > 0
4343
end
4444

45-
@testset "surface_da_dpsi: FD agrees with numerical derivative" begin
46-
# Reference via a tighter FD
45+
@testset "surface_da_dpsi: agrees with FD reference" begin
46+
# The analytic ψ-derivative must reproduce a tight FD of the minor radius.
4747
for psi in (0.1, 0.4, 0.7)
4848
h_ref = 1e-4
4949
r_p = surface_minor_radius(equil, psi + h_ref)
@@ -53,16 +53,24 @@
5353
end
5454
end
5555

56-
@testset "surface_da_dpsi: one-sided near boundaries" begin
57-
# Near ψ=0 and ψ=1, the function falls back to one-sided FD and
58-
# should still produce a finite positive number (minor radius is
59-
# still increasing).
56+
@testset "surface_da_dpsi: finite near the boundaries" begin
57+
# The analytic form needs no clamping: near ψ=0 and ψ=1 it still returns a
58+
# finite positive number (large near the axis, where a ~ √ψ).
6059
d_near_axis = surface_da_dpsi(equil, 1e-6)
6160
d_near_edge = surface_da_dpsi(equil, 1.0 - 1e-6)
6261
@test isfinite(d_near_axis) && d_near_axis > 0
6362
@test isfinite(d_near_edge) && d_near_edge > 0
6463
end
6564

65+
@testset "radial_label: analytic derivatives match FD" begin
66+
for rsm in (:midplane, :halfwidth, :fsa, :volume, :flux)
67+
r_at, dr_at = radial_label(equil; rs_method=rsm)
68+
h = 1e-5
69+
ref = (r_at(0.5 + h) - r_at(0.5 - h)) / (2h)
70+
@test dr_at(0.5) ref rtol = 1e-3
71+
end
72+
end
73+
6674
@testset "build_slayer_inputs: returns correct per-surface data" begin
6775
sings = [_mk_sing(psi=0.3, q=2.0, q1=1.5, m=2, n=1),
6876
_mk_sing(psi=0.6, q=3.0, q1=2.5, m=3, n=1)]
@@ -161,7 +169,7 @@
161169
@testset "build_slayer_inputs: rs_method radial labels are self-consistent" begin
162170
sings = [_mk_sing(psi=0.5, q=2.4, q1=1.2, m=2, n=1)]
163171
got = Dict{Symbol,Any}()
164-
for rsm in (:midplane, :halfwidth, :fsa, :volume)
172+
for rsm in (:midplane, :halfwidth, :fsa, :volume, :flux)
165173
sl = build_slayer_inputs(equil, sings, profiles; bt=2.0, dr_val=0.0, rs_method=rsm)
166174
got[rsm] = sl[1]
167175
@test isfinite(sl[1].rs) && sl[1].rs > 0
@@ -174,6 +182,7 @@
174182
# Labels genuinely differ (each self-consistent set has its own rs, S, k_ref)
175183
@test got[:fsa].rs != got[:midplane].rs
176184
@test got[:volume].lu != got[:midplane].lu
185+
@test got[:flux].rs != got[:midplane].rs
177186
end
178187

179188
@testset "build_slayer_inputs: dc_type propagates and dr_val activates offset" begin

0 commit comments

Comments
 (0)