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
1616using .. Utilities: KineticProfiles
1717using ... 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)
3232end
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 )) / (2 a)
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) / (2 h)
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)
62152end
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 = 10 h
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)) / (2 h)
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
0 commit comments