Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/development/hdf5-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ These rules govern `gpec.h5` (and any future GPEC-produced HDF5 output); harness
- **"rational" over "singular"** in dataset names (`rational_psi`, `rational_q`, `rational_m`, `rational_n`, `rational_index`, `rational_count`) — kinetic/resistive runs are not singular at the rationals. Specifier order is standardized specifier-first (`rational_psi`, never `psi_rational`).
- **Vector components** follow `[d]<variable>[_<representation>]_<coordinate>[dpsi]` — the variable always comes first and the coordinate is always the trailing subscript. A bare coordinate suffix is the **contravariant** component (`xi_psi` = ξ^ψ), `_cov_` marks the **covariant** one (`b_cov_theta` = b_θ), a leading `J` marks a **Jacobian-weighted** component (`Jxi_theta` = J·ξ^θ), and other representations sit in the same slot (`xi_clebsch_psi`, `dxi_clebsch_psidpsi`). Never drop the variable: `clebsch_psi` is wrong because it does not say *what* is being represented. There is no HDF5/netCDF standard for super- vs subscripts — flat `_` names are universal — so the typeset form always appears in the dataset's `long_name`.
- **Coordinates**: the radial abscissa is `psi` (normalized poloidal flux ψ_N) and the poloidal one is `theta` in every group; never `psi_n`, `xs`, or `ys`.
- **One name per physical quantity**: a quantity written in several groups carries the identical leaf name everywhere (`rational_psi` in `SingularSurfaces/`, `Solutions/GalerkinIntegration/`, and `SingularCoupling/`; `Delta_prime_matrix` in `SingularSurfaces/` and `Tearing/PerSurface/`; `dVdpsi` in `Profiles/`, `SingularSurfaces/`, and `KineticForces/<method>/`) — the group supplies the context, the leaf supplies the identity.
- **One name per physical quantity**: a quantity written in several groups carries the identical leaf name everywhere (`rational_psi` in `SingularSurfaces/`, `Solutions/GalerkinIntegration/`, and `SingularCoupling/`; `Delta_prime_matrix` in `SingularSurfaces/` and, on the GGJ path, `Tearing/PerSurface/` — the r_s-referenced matrix the slab layer matches is a different quantity and so carries its own name, `Tearing/PerSurface/Delta_prime_matrix_rs`; `dVdpsi` in `Profiles/`, `SingularSurfaces/`, and `KineticForces/<method>/`) — the group supplies the context, the leaf supplies the identity.

## Inputs live only under `Input/`

Expand Down
7 changes: 5 additions & 2 deletions examples/DIIID-like_SLAYER_example/gpec.toml
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,11 @@ dmlim = 0.2 # Truncate integration at (last_rational_q + dmli
# Runs in the force_termination path (PE skipped). Kinetic profiles (incl. the
# χ⊥/χ_φ heat/momentum diffusivities) are read from the standardized HDF5
# kinetic file shared with the kinetic/NTV physics. Per-surface (uncoupled)
# analysis: the headline is the unstable 2/1; the validity gate drops surfaces
# with no real root (e.g. the 5/1, whose Δ' BVP yields a huge uncancellable Δ').
# analysis: track the 2/1, 3/1, and 4/1 (the headline is the unstable 2/1).
# The 5/1 and 6/1 sit near the edge (ψ_N ≥ 0.97), far from the core where the
# slab-layer approximation holds; their Δ' are excessively large and likely
# invalid, no Re/Im contour crossing polishes to a true zero, and the validity
# gate correctly reports them as no_root rather than a spurious growth rate.
enabled = true # Run the SLAYER tearing-mode analysis
inner_model = "slayer_fitzpatrick" # Inner-layer Δ(Q) model: "slayer_fitzpatrick", "ggj_shooting", or "ggj_galerkin"
scan_mode = "amr" # Q-plane scan strategy: "amr" (adaptive refinement) or "brute_force"
Expand Down
4 changes: 2 additions & 2 deletions src/InnerLayer/InnerLayer.jl
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import .GGJ: delta_convergence, solution_profile, asymptotic_profile, q4_surface

import .SLAYER: SLAYERModel, SLAYERParameters, slayer_parameters, r_based_shear
import .SLAYER: riccati_del_s, slayer_layer_thickness, LayerWidths
import .SLAYER: surface_minor_radius, surface_da_dpsi, build_slayer_inputs
import .SLAYER: surface_minor_radius, surface_da_dpsi, radial_label, build_slayer_inputs

export InnerLayerModel, InnerLayerParameters, InnerLayerResponse, solve_inner, solve_inner_profile
export GGJ, GGJModel, GGJParameters
Expand All @@ -37,6 +37,6 @@ export delta_convergence, solution_profile, asymptotic_profile, q4_surface_bench

export SLAYER, SLAYERModel, SLAYERParameters, slayer_parameters, r_based_shear
export riccati_del_s, slayer_layer_thickness, LayerWidths
export surface_minor_radius, surface_da_dpsi, build_slayer_inputs
export surface_minor_radius, surface_da_dpsi, radial_label, build_slayer_inputs

end # module InnerLayer
206 changes: 148 additions & 58 deletions src/InnerLayer/SLAYER/LayerInputs.jl
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@
# Geometry extraction:
# - Minor radius at the outboard midplane (θ = 0) via
# `equil.rzphi_rsquared((ψ, 0.0))`.
# - `da/dψ` via central finite difference on the same bicubic.
# - `da/dψ` from the interpolant's own analytic ψ-derivative.
# - r-based magnetic shear via `r_based_shear(rs, q, q1, da/dψ)` (defined
# in LayerParameters.jl).

using ..Utilities: KineticProfiles
using ...Utilities.NeoclassicalResistivity: NeoResistivityModel, SpitzerModel,
coulomb_log_e, nu_star_e
using FastInterpolations: DerivOp
using FastInterpolations: DerivOp, integrate, cubic_interp, cumulative_integrate, ExtendExtrap

"""
surface_minor_radius(equil, psi; theta=0.0) -> Float64
Expand All @@ -32,33 +32,126 @@ function surface_minor_radius(equil, psi::Real; theta::Real=0.0)
end

"""
surface_da_dpsi(equil, psi; theta=0.0, h=1e-5) -> Float64
surface_da_dpsi(equil, psi; theta=0.0) -> Float64

Central finite-difference approximation of `d(minor radius)/dψ` at `psi`.
Falls back to one-sided differences near the flux-coordinate boundaries
(0 or 1).
Analytic ψ-derivative of the minor radius at `psi` and poloidal angle
`theta`, taken from the `rzphi_rsquared` interpolant's own ψ-derivative as
`da/dψ = (∂r²/∂ψ) / (2a)`. Valid wherever the interpolant is, including
under extrapolation past the ψ grid. Diverges at the magnetic axis, where
`a ~ √ψ`; callers evaluating near `ψ = 0` must check `isfinite`.
"""
function surface_da_dpsi(equil, psi::Real; theta::Real=0.0, h::Real=1e-5)
psi_f = Float64(psi)
# Clamp to safe sampling range within (0, 1)
eps_edge = 10 * h
lo = psi_f - h
hi = psi_f + h
if lo < eps_edge
# one-sided forward
a0 = surface_minor_radius(equil, max(psi_f, eps_edge); theta=theta)
a1 = surface_minor_radius(equil, max(psi_f, eps_edge) + h; theta=theta)
return (a1 - a0) / h
elseif hi > 1.0 - eps_edge
# one-sided backward
a0 = surface_minor_radius(equil, min(psi_f, 1.0 - eps_edge) - h; theta=theta)
a1 = surface_minor_radius(equil, min(psi_f, 1.0 - eps_edge); theta=theta)
return (a1 - a0) / h
function surface_da_dpsi(equil, psi::Real; theta::Real=0.0)
return _da_dpsi_at_theta(equil, Float64(psi), Float64(theta))
end

# d(√r²)/dψ at one (ψ, θ) from the interpolant's own ψ-derivative. Shared by all radial-label
# conventions so none carries its own stencil.
@inline function _da_dpsi_at_theta(equil, psi::Float64, theta::Float64)
r_sq = equil.rzphi_rsquared((psi, theta))
a = sqrt(max(r_sq, 0.0))
a > 0 || return Inf # magnetic axis: a ~ √ψ, so da/dψ genuinely diverges
return equil.rzphi_rsquared((psi, theta); deriv=DerivOp(1, 0)) / (2a)
end

"""
radial_label(equil; rs_method=:midplane, theta=0.0) -> (r_at, dr_dpsi_at)

Build the pair of closures `r_at(ψ)` and `dr_dpsi_at(ψ)` defining one radial
label for the layer stack. Both closures must come from the same label,
because the r-based shear `(r/q)dq/dr`, `τ_R = μ₀r²/η`, `τ_E = r²/χ`, the
`d_β/r` normalization, the Δ' reference-length factor `k_ref = r_s/(da/dψ)`,
and any metre-to-ψ width conversion all have to live in one coordinate; a
mismatched `r` and `dr/dψ` silently corrupts every one of them. All
derivatives are analytic — no label carries a finite-difference stencil.

# Labels

- `:midplane` -- outboard-midplane chord from the magnetic axis at `theta`
(historical default), natural for comparison with midplane diagnostics.
- `:halfwidth` -- midplane half-chord, the mean of the outboard and inboard
chords at `θ = 0` and `θ = 0.5`; shift-free. Coincides with the flux label
on circular equilibria but is its own convention on shaped ones.
- `:fsa` -- θ-mean surface radius, a 128-point midpoint mean of the local
minor radius; the closest geometric approximation to `:flux` at interior
surfaces of shaped equilibria.
- `:volume` -- cylinder-equivalent label `√(V(ψ)/(2π²R₀))`, the
Rutherford-literature convention.
- `:flux` -- toroidal-flux label. Fitzpatrick, Nucl. Fusion (2025),
Eq. 30: `dψ_p/dr = B₀ r g/q` integrates to `ψ_t = B₀r²/2`, so
`r = √(2ψ_t/B₀)` with `ψ_t = psio·∫₀^ψ (q/g) dψ′` and `g = F/(B₀R₀)`,
`F = R·B_tor`. Note `profiles.F_spline` stores `2πF`, so the
implementation divides it by 2π to form `g`.
Defined from flux alone, it carries no circular-cross-section assumption,
and its derivative `dr/dψ ∝ q` grows toward a separatrix where the
geometric labels' `da/dψ` collapses.

On shaped equilibria the labels agree at low-q surfaces and diverge strongly
near the edge, where the slab-layer matching is label-ambiguous regardless of
choice. The label is selected programmatically; it is not exposed via TOML.
"""
function radial_label(equil; rs_method::Symbol=:midplane, theta::Real=0.0)
theta_f = Float64(theta)

_flux_r, _flux_dr = if rs_method === :flux
b0f = Float64(equil.params.b0)
R0f = Float64(equil.ro)
psiof = Float64(equil.psio)
xs_f = collect(Float64, equil.profiles.xs)
# Carry g = F/(B0 R0) rather than assuming g = 1: r² = 2∫(q/g)dψ_p/B0.
_g_at(x) = Float64(equil.profiles.F_spline(x)) / (2π * b0f * R0f)
qg = [Float64(equil.profiles.q_spline(x)) / _g_at(x) for x in xs_f]
Phi = collect(Float64, cumulative_integrate(cubic_interp(xs_f, qg)))
r_knots = sqrt.(max.(2 .* psiof .* Phi ./ b0f, 0.0))
rspl = cubic_interp(xs_f, r_knots; extrap=ExtendExtrap())
(ψ -> Float64(rspl(Float64(ψ))),
ψ -> psiof * Float64(equil.profiles.q_spline(Float64(ψ))) /
(b0f * _g_at(Float64(ψ)) * max(Float64(rspl(Float64(ψ))), eps())))
else
a_plus = surface_minor_radius(equil, psi_f + h; theta=theta)
a_minus = surface_minor_radius(equil, psi_f - h; theta=theta)
return (a_plus - a_minus) / (2h)
(nothing, nothing)
end

_a_at(ψ, θ) = sqrt(max(equil.rzphi_rsquared((Float64(ψ), Float64(θ))), 0.0))

_rs_at(ψ) =
if rs_method === :fsa
N = 128
s = 0.0
@inbounds for k in 1:N
s += _a_at(ψ, (k - 0.5) / N)
end
s / N
elseif rs_method === :halfwidth
0.5 * (_a_at(ψ, 0.0) + _a_at(ψ, 0.5))
elseif rs_method === :volume
# Lower bound just off the axis, where dV/dψ is extrapolated; the omitted sliver
# of core volume is negligible for an edge-surface label.
V = integrate(equil.profiles.dVdpsi_spline, 1e-4, Float64(ψ))
sqrt(max(V, 0.0) / (2π^2 * equil.ro))
elseif rs_method === :flux
_flux_r(ψ)
else
surface_minor_radius(equil, ψ; theta=theta_f)
end

_da_dpsi_at(ψ) =
if rs_method === :fsa
N = 128
s = 0.0
@inbounds for k in 1:N
s += _da_dpsi_at_theta(equil, Float64(ψ), (k - 0.5) / N)
end
s / N
elseif rs_method === :halfwidth
0.5 * (_da_dpsi_at_theta(equil, Float64(ψ), 0.0) + _da_dpsi_at_theta(equil, Float64(ψ), 0.5))
elseif rs_method === :volume
Float64(equil.profiles.dVdpsi_spline(ψ)) / (4π^2 * equil.ro * max(_rs_at(ψ), eps()))
elseif rs_method === :flux
_flux_dr(ψ)
else
_da_dpsi_at_theta(equil, Float64(ψ), theta_f)
end

return (_rs_at, _da_dpsi_at)
end

"""
Expand Down Expand Up @@ -114,6 +207,12 @@ profiles, without an intermediate file round-trip.
a prescribed value. (For `dc_type=:rfitzp` and `:lar`, dgeo_val is
not consulted.)
- `dc_type` -- `:none` (default), `:lar`, `:rfitzp`, or `:toroidal`.
- `rs_method` -- radial label defining `r_s` for the whole layer stack:
`:midplane` (default), `:halfwidth`, `:fsa`, `:volume`, or `:flux`. See
[`radial_label`](@ref) for the definitions. S, the r-based shear, W_d,
and the Δ' reference-length factor `k_ref` all follow the choice
together, so every option is self-consistent. Not exposed via TOML —
programmatic use only.
- `theta` -- poloidal angle at which to measure minor radius (default
`0.0`, outboard midplane).
- `resistivity_model` -- `SauterNeoModel()` (default), `RedlNeoModel()`,
Expand Down Expand Up @@ -156,38 +255,7 @@ function build_slayer_inputs(equil, sings, profiles::KineticProfiles;
Float64(bt(ψ))
end

# Minor-radius extractor: `:midplane` = outboard-midplane chord
# (original behavior); `:fsa` = θ-mean of √rzphi_rsquared, the
# flux-surface-averaged minor radius.
_rs_at(ψ) =
if rs_method === :fsa
integrand(θ) = sqrt(equil.rzphi_rsquared((Float64(ψ), Float64(θ))))
N = 128
s = 0.0
@inbounds for k in 1:N
s += integrand((k - 0.5) / N)
end
s / N
else
surface_minor_radius(equil, ψ; theta=theta)
end
_da_dpsi_at(ψ) =
if rs_method === :fsa
# central finite difference on _rs_at
h = 1e-5
lo = ψ - h
hi = ψ + h
eps_edge = 10h
if lo < eps_edge
(_rs_at(max(ψ, eps_edge) + h) - _rs_at(max(ψ, eps_edge))) / h
elseif hi > 1.0 - eps_edge
(_rs_at(min(ψ, 1.0 - eps_edge)) - _rs_at(min(ψ, 1.0 - eps_edge) - h)) / h
else
(_rs_at(ψ + h) - _rs_at(ψ - h)) / (2h)
end
else
surface_da_dpsi(equil, ψ; theta=theta)
end
_rs_at, _da_dpsi_at = radial_label(equil; rs_method=rs_method, theta=theta)

# Per-surface ω_*e, ω_*i (diamagnetic frequencies) from spline
# derivatives. When `compute_omega_star=true` we override any ω_*e/ω_*i
Expand Down Expand Up @@ -289,6 +357,26 @@ function build_slayer_inputs(equil, sings, profiles::KineticProfiles;
_eval(dgeo_val, psi)
end

# Reference-length conversion inputs for the outer Δ': K = r_s·(dψ_N/dr)|_s and
# α = √(−D_I) (Glasser-Greene-Johnson 1975 Eq. 48), with α clamped to 0 on Mercier-unstable
# surfaces (the factor turns complex there) and K = 1 whenever da/dψ is not a usable
# positive number (zero/non-finite/negative would corrupt the Δ' diagonal).
k_ref_k = if isfinite(da_dpsi) && da_dpsi > 0.0
rs / da_dpsi
else
@warn("build_slayer_inputs: da/dψ = $da_dpsi at ψ = $psi is not usable; leaving " *
"Δ' unconverted (k_ref = 1) at this surface.", maxlog=3)
1.0
end
alpha_k = if rg === nothing
@warn("build_slayer_inputs: sing.restype not populated; using the " *
"slab Mercier exponent α = 1/2 for the Δ' reference-length " *
"conversion at all such surfaces.", maxlog=1)
0.5
else
sqrt(max(-(rg.E + rg.F + rg.H - 0.25), 0.0))
end

out[k] = slayer_parameters(;
n_e=prof.n_e, t_e=prof.T_e, t_i=prof.T_i,
omega=prof.omega, omega_e=ω_e_use, omega_i=ω_i_use,
Expand All @@ -304,7 +392,9 @@ function build_slayer_inputs(equil, sings, profiles::KineticProfiles;
f_trap=f_trap_kw,
nu_e_star=nu_e_star_kw,
R_major_eff=R_major_eff,
lnLambda_form=lnLambda_form
lnLambda_form=lnLambda_form,
k_ref=k_ref_k,
alpha_mercier=alpha_k
)
end
return out
Expand Down
26 changes: 24 additions & 2 deletions src/InnerLayer/SLAYER/LayerParameters.jl
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@ de-normalization. The parametrization uses `P_perp`, `P_tor`, and
| `d_beta` | Beta-weighted ion length scale c_β · d_i [m] |
| `dc_tmp` | Critical-Δ offset from chi_parallel matching |
| `dc_type` | Selector for `dc_tmp` formula |
| `k_ref` | Reference-length ratio K = r_s · (dψ_N/dr) at this surface (1 = no Δ' conversion) |
| `alpha_mercier` | Mercier Frobenius exponent α = √(−D_I) (Glasser-Greene-Johnson 1975 Eq. 48) governing the Δ' reference-length conversion (1/2 = slab/cylindrical value) |

`k_ref` and `alpha_mercier` feed the ψ_N → r_s reference-length conversion of
the outer Δ' matrix (see `delta_prime_to_rs_reference` in the Tearing
runner): the slab layer, its `dc_tmp` critical-Δ, and the `S^(1/3)` Δ(Q)
scale are all referenced to unit `x̂ = (r−r_s)/r_s`, while the outer BVP Δ'
is referenced to unit Δψ_N. Hand-built parameters default to `k_ref = 1`,
which makes the conversion the identity.

The complex normalized growth rate `Q = ω + iγ` is **not** stored here;
it is passed as a separate argument to `solve_inner`.
Expand Down Expand Up @@ -85,6 +94,10 @@ Base.@kwdef struct SLAYERParameters <: InnerLayerParameters
# Critical-Δ offset
dc_tmp::Float64 = 0.0
dc_type::Symbol = :none

# Reference-length conversion inputs for the outer Δ' (ψ_N → r_s-based x̂)
k_ref::Float64 = 1.0
alpha_mercier::Float64 = 0.5
end

# Allowed dc_type values for the critical-Δ offset. `:none` is the default
Expand Down Expand Up @@ -202,6 +215,12 @@ parametrization (P_perp/P_tor/D_norm; the older magnetic/electron Prandtl
- `dr_val`, `dgeo_val` -- inputs for the critical-Δ formula
- `dc_type` -- one of `:none`, `:lar`, `:rfitzp`, `:toroidal`
- `ising` -- singular-surface index for traceability
- `k_ref` -- reference-length ratio K = r_s·(dψ_N/dr) at the surface,
used by the Tearing runner to convert the ψ_N-referenced outer Δ' to
the r_s-referenced convention this layer works in (default `1.0`,
i.e. no conversion; `build_slayer_inputs` fills it from the equilibrium)
- `alpha_mercier` -- Mercier Frobenius exponent α = √(−D_I) for the same conversion
(default `0.5`, the slab/cylindrical value at D_I = −1/4)

# Resistivity kwargs

Expand Down Expand Up @@ -252,7 +271,9 @@ function slayer_parameters(;
f_trap::Union{Real,Nothing}=nothing,
nu_e_star::Union{Real,Nothing}=nothing,
R_major_eff::Union{Real,Nothing}=nothing,
lnLambda_form::Symbol=:nrl)
lnLambda_form::Symbol=:nrl,
k_ref::Real=1.0,
alpha_mercier::Real=0.5)

# Coulomb logarithm shared by the resistivity closure and τ_ee.
lnLamb = coulomb_log_e(n_e, t_e; form=lnLambda_form)
Expand Down Expand Up @@ -360,6 +381,7 @@ function slayer_parameters(;
rs=rs, R0=R0, bt=bt, sval_r=sval_r,
dr_val=dr_val, dgeo_val=dgeo_val,
eta=eta, d_beta=d_beta,
dc_tmp=dc_tmp, dc_type=dc_type
dc_tmp=dc_tmp, dc_type=dc_type,
k_ref=k_ref, alpha_mercier=alpha_mercier
)
end
2 changes: 1 addition & 1 deletion src/InnerLayer/SLAYER/SLAYER.jl
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ include("LayerInputs.jl")
export SLAYERModel, SLAYERParameters, slayer_parameters
export r_based_shear
export riccati_del_s, slayer_layer_thickness, LayerWidths
export surface_minor_radius, surface_da_dpsi, build_slayer_inputs
export surface_minor_radius, surface_da_dpsi, radial_label, build_slayer_inputs
export NeoResistivityModel, SpitzerModel, SpitzerHarmModel, SauterNeoModel, RedlNeoModel

end # module SLAYER
Loading
Loading