From 7847c08b52a649f3314d908a400d8f9ec560bc25 Mon Sep 17 00:00:00 2001 From: adrianaghiozzi <67669644+adrianaghiozzi@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:32:22 -0400 Subject: [PATCH 01/51] Explicit type equil --- src/KineticForces/Torque.jl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/KineticForces/Torque.jl b/src/KineticForces/Torque.jl index af22d165c..f96935c57 100644 --- a/src/KineticForces/Torque.jl +++ b/src/KineticForces/Torque.jl @@ -30,7 +30,7 @@ Imaginary component is proportional to the kinetic energy Im(T) = 2*n*dW_k. """ function tpsi!(tpsi_var::Ref{ComplexF64}, psi::Float64, n::Int, l::Int, zi::Int, mi::Int, wdfac::Float64, divxfac::Float64, - electron::Bool, method::String, equil, intr::KineticForcesInternal, + electron::Bool, method::String, equil::Equilibrium.PlasmaEquilibrium, intr::KineticForcesInternal, kinetic_profiles::Equilibrium.KineticProfileSplines; op_wmats::Union{Nothing,Array{ComplexF64,3}}=nothing, rex_override::Union{Nothing,Float64}=nothing, @@ -889,7 +889,7 @@ function compute_kinetic_matrices_at_psi!( ktmat::Array{ComplexF64,3}, psi::Float64, n::Int, l::Int, zi::Int, mi::Int, wdfac::Float64, _divxfac::Float64, - electron::Bool, equil, intr::KineticForcesInternal, + electron::Bool, equil::Equilibrium.PlasmaEquilibrium, intr::KineticForcesInternal, kinetic_profiles::Equilibrium.KineticProfileSplines; nutype::String="harmonic", f0type::String="maxwellian", nufac::Float64=1.0, atol_xlmda::Float64=1e-9, rtol_xlmda::Float64=1e-6) From 98af781fab5883d498d151b4692ad4e92d6ec662 Mon Sep 17 00:00:00 2001 From: adrianaghiozzi <67669644+adrianaghiozzi@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:34:20 -0400 Subject: [PATCH 02/51] Faster green function --- src/Vacuum/Kernel2D.jl | 48 ++++++++++++++---------------------------- 1 file changed, 16 insertions(+), 32 deletions(-) diff --git a/src/Vacuum/Kernel2D.jl b/src/Vacuum/Kernel2D.jl index ea93f224b..fc7155f3e 100644 --- a/src/Vacuum/Kernel2D.jl +++ b/src/Vacuum/Kernel2D.jl @@ -639,27 +639,29 @@ according to equations (36)-(42) of Chance 1997. Replaces `green` from Fortran c gamma_prefactor::Float64=2 * sqrt(π) * gamma(0.5 - n), uselegacygreenfunction::Bool=false ) - x_obs2 = x_obs^2 x_source2 = x_source^2 x_minus2 = (x_obs - x_source)^2 x_multiple = x_obs * x_source - ζ = (z_obs - z_source) + ζ = z_obs - z_source ζ2 = ζ^2 ρ2 = x_minus2 + ζ2 - - # Distance parameter ℛ [Chance Phys. Plasmas 1997 2161 eq. 41] R4 = ρ2 * (ρ2 + 4 * x_multiple) R2 = sqrt(R4) R = sqrt(R2) R5 = R4 * R - # Argument of Legendre function 𝘴 [Chance Phys. Plasmas 1997 2161 eq. 42] - s = (x_obs2 + x_source2 + ζ2) / R2 + S = x_obs2 + x_source2 + ζ2 + a = x_obs2 - x_source2 + D = a + ζ2 # x_obs2 - x_source2 + ζ2 + E = ζ2 - a # x_source2 - x_obs2 + ζ2 + fourXmult = 4 * x_multiple + twoXobsD = 2 * x_obs * D + xSourceE = x_source * E + + s = S / R2 - # Legendre functions for - # P⁰ = p0, P¹ = p1, Pⁿ = pn, Pⁿ⁺¹ = pnp1 legendre = acquire!(pool, Float64, n + 2) if uselegacygreenfunction Pn_minus_half_1997!(legendre, s, n) @@ -667,37 +669,19 @@ according to equations (36)-(42) of Chance 1997. Replaces `green` from Fortran c Pn_minus_half_2007!(legendre, s, n) end - p0 = legendre[1] - p1 = legendre[2] - pnp1 = legendre[end] - pn = legendre[end-1] + p0, p1, pnp1, pn = @inbounds legendre[1], legendre[2], legendre[end], legendre[end-1] - # Green's function 2π𝒢ⁿ = G_n [Chance Phys. Plasmas 1997 2161 eq. 40] gg = gamma_prefactor / R G_n = gg * pn + grad_gg = gg / (2π * R4) - # Gradient factor [Chance Phys. Plasmas 1997 2161 eq. 44] - # NOTE: Paper has erroneous extra factor of 2π - grad_gg = gg / R4 / 2π - - # Derivatives of Green's function [Chance Phys. Plasmas 1997 2161 eq. 36-38] - # ∂Gⁿ/∂X' using chain rule: ∂Gⁿ/∂X' = (∂Gⁿ/∂R)(∂R/∂X') + (∂Gⁿ/∂s)(∂s/∂X') - xterm1 = (n * (x_obs2 + x_source2 + ζ2) * (x_obs2 - x_source2 + ζ2) - x_source2*(x_source2-x_obs2+ζ2)) * pn - xterm2 = (2.0 * x_source * x_obs * (x_obs2-x_source2+ζ2)) * pnp1 - dG_dX = grad_gg * (xterm1 + xterm2) / x_source - - # ∂Gⁿ/∂Z' using chain rule - zterm1 = (2.0 * n + 1.0) * (x_obs2 + x_source2 + ζ2) * pn - zterm2 = 4.0 * x_multiple * pnp1 - dG_dZ = grad_gg * (zterm1 + zterm2) * ζ + dG_dX = grad_gg * ( (n * S * D - x_source * xSourceE) * pn / x_source + twoXobsD * pnp1 ) + dG_dZ = grad_gg * ((2n + 1) * S * pn + fourXmult * pnp1) * ζ - # Coupling term 𝒥 ∇'𝒢ⁿ∇'ℒ [Chance Phys. Plasmas 1997 2161 eq. 51] - # Jacobian factor from coordinate transformation coupling_n = -x_source * (dz_dtheta * dG_dX - dx_dtheta * dG_dZ) - # Special case for n=0: coupling_0 = 1/(2π) 𝒥 ∇'𝒢⁰∇'ℒ - dG_dX0_R5 = ((2.0 * x_obs * (x_obs2-x_source2+ζ2)) * p1 - x_source * (x_source2-x_obs2+ζ2) * p0) - dG_dZ0_R5 = ζ * ((x_obs2 + x_source2 + ζ2) * p0 + 4.0 * x_multiple * p1) + dG_dX0_R5 = twoXobsD * p1 - xSourceE * p0 + dG_dZ0_R5 = ζ * (S * p0 + fourXmult * p1) coupling_0 = -x_source * (dz_dtheta * dG_dX0_R5 - dx_dtheta * dG_dZ0_R5) / R5 return G_n, coupling_n, coupling_0 end From 8adb33b724bd9eebd8edda2cf78f27fc34983cbf Mon Sep 17 00:00:00 2001 From: adrianaghiozzi <67669644+adrianaghiozzi@users.noreply.github.com> Date: Fri, 14 Aug 2026 10:36:02 -0400 Subject: [PATCH 03/51] Tuple -> Vector in integrate_pitch_gar_quadgk to avoid type instability --- src/KineticForces/PitchIntegration.jl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/KineticForces/PitchIntegration.jl b/src/KineticForces/PitchIntegration.jl index fa61c5952..afa646b17 100644 --- a/src/KineticForces/PitchIntegration.jl +++ b/src/KineticForces/PitchIntegration.jl @@ -97,9 +97,9 @@ function integrate_pitch_gar_quadgk( # the kink in leff = ell + n*q (circulating) → ell (trapped). bobmax_clip = clamp(bobmax, lambda_min, lambda_max) segments = if lambda_min < bobmax_clip < lambda_max - (lambda_min, bobmax_clip, lambda_max) + [lambda_min, bobmax_clip, lambda_max] else - (lambda_min, lambda_max) + [lambda_min, lambda_max] end # In-place quadgk! buffer; copy the result out so the returned vector is From 152e7e50d8c859b9d0082c0d1987e4df044d79b4 Mon Sep 17 00:00:00 2001 From: adrianaghiozzi <67669644+adrianaghiozzi@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:18:09 -0400 Subject: [PATCH 04/51] Better type instability fix for integrate_pitch_gar_quadgk --- src/KineticForces/PitchIntegration.jl | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/src/KineticForces/PitchIntegration.jl b/src/KineticForces/PitchIntegration.jl index afa646b17..20c7a5703 100644 --- a/src/KineticForces/PitchIntegration.jl +++ b/src/KineticForces/PitchIntegration.jl @@ -96,17 +96,13 @@ function integrate_pitch_gar_quadgk( # Split domain at trapped/passing boundary so Gauss-Kronrod resolves # the kink in leff = ell + n*q (circulating) → ell (trapped). bobmax_clip = clamp(bobmax, lambda_min, lambda_max) - segments = if lambda_min < bobmax_clip < lambda_max - [lambda_min, bobmax_clip, lambda_max] - else - [lambda_min, lambda_max] - end - - # In-place quadgk! buffer; copy the result out so the returned vector is - # owned by the caller. buf = zeros(ComplexF64, nqty) kernel! = (out, λ) -> _pitch_gar_kernel_quadgk!(out, λ, params) - I, _ = quadgk!(kernel!, buf, segments...; atol=pitch_atol, rtol=pitch_rtol) + I, _ = if lambda_min < bobmax_clip < lambda_max + quadgk!(kernel!, buf, lambda_min, bobmax_clip, lambda_max; atol=pitch_atol, rtol=pitch_rtol) + else + quadgk!(kernel!, buf, lambda_min, lambda_max; atol=pitch_atol, rtol=pitch_rtol) + end return copy(I) end @@ -195,15 +191,13 @@ function integrate_pitch_gar_quadgk_wt( lambda_max = last(fbnce.cache.x) bobmax_clip = clamp(bobmax, lambda_min, lambda_max) - segments = if lambda_min < bobmax_clip < lambda_max - (lambda_min, bobmax_clip, lambda_max) - else - (lambda_min, lambda_max) - end - buf = zeros(ComplexF64, 2 * nqty) kernel! = (out, λ) -> _pitch_gar_kernel_quadgk_wt!(out, λ, params) - I, _ = quadgk!(kernel!, buf, segments...; atol=pitch_atol, rtol=pitch_rtol) + I, _ = if lambda_min < bobmax_clip < lambda_max + quadgk!(kernel!, buf, lambda_min, bobmax_clip, lambda_max; atol=pitch_atol, rtol=pitch_rtol) + else + quadgk!(kernel!, buf, lambda_min, lambda_max; atol=pitch_atol, rtol=pitch_rtol) + end return copy(I) end From 69ad76e4f7677c54ac91a69be3bca74d6e57a92f Mon Sep 17 00:00:00 2001 From: adrianaghiozzi <67669644+adrianaghiozzi@users.noreply.github.com> Date: Fri, 14 Aug 2026 17:15:09 -0400 Subject: [PATCH 05/51] FORCEFREESTATES - OPTIMIZATION - Loosen ballooning ODE abstol to match reltol integrate_ballooning_ode set abstol=reltol^2 (1e-16, at machine epsilon) against a non-stiff DP5 solver, forcing needlessly tight step control. Profiling (warmed, no JIT noise) showed ballooning boundary search as the dominant real cost on gal-resistive-PE-style cases -- ~55% of pipeline runtime, driven by many repeated ODE shoots per flux surface (linear pre-scan + bisection to locate the marginal-stability crossing). Setting abstol=reltol=1e-8 (the standard pairing) gives a measured ~8% speedup on Force-Free States for DIIID-like_gal_resistive_pe_example, confirming reltol was already the binding constraint for most of the integration. Validated bit-for-bit identical locstab/alpha_critical (the value that actually drives stability classification) before/after; the raw ballooning_Delta_prime diagnostic drifts ~1e-5 relative, which flips a few profile checksums in the regression harness but is far below any threshold that matters physically. Full 11-case regression suite against merge-base a0cad260 is otherwise clean. --- src/ForceFreeStates/Ballooning.jl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ForceFreeStates/Ballooning.jl b/src/ForceFreeStates/Ballooning.jl index d24f9ff81..78408b5a2 100644 --- a/src/ForceFreeStates/Ballooning.jl +++ b/src/ForceFreeStates/Ballooning.jl @@ -1169,7 +1169,7 @@ function integrate_ballooning_ode(ode_coefficient_spline; theta_k::Float64=0.0) problem_left, DP5(); reltol=TOLERANCE, - abstol=TOLERANCE^2, + abstol=TOLERANCE, dtmin=MINIMUM_STEP, adaptive=true, save_everystep=false, @@ -1190,7 +1190,7 @@ function integrate_ballooning_ode(ode_coefficient_spline; theta_k::Float64=0.0) problem_right, DP5(); reltol=TOLERANCE, - abstol=TOLERANCE^2, + abstol=TOLERANCE, dtmin=MINIMUM_STEP, adaptive=true, save_everystep=false, From e952629dba932e192b697fc4cbec0c182361b23e Mon Sep 17 00:00:00 2001 From: adrianaghiozzi <67669644+adrianaghiozzi@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:51:05 -0400 Subject: [PATCH 06/51] Add back Distance parameter comment --- src/Vacuum/Kernel2D.jl | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Vacuum/Kernel2D.jl b/src/Vacuum/Kernel2D.jl index fc7155f3e..fd6599209 100644 --- a/src/Vacuum/Kernel2D.jl +++ b/src/Vacuum/Kernel2D.jl @@ -647,6 +647,8 @@ according to equations (36)-(42) of Chance 1997. Replaces `green` from Fortran c ζ2 = ζ^2 ρ2 = x_minus2 + ζ2 + + # Distance parameter ℛ [Chance Phys. Plasmas 1997 2161 eq. 41] R4 = ρ2 * (ρ2 + 4 * x_multiple) R2 = sqrt(R4) R = sqrt(R2) From 09cb856d3eb07464e01f24b505ef4297bc85738b Mon Sep 17 00:00:00 2001 From: logan-nc Date: Thu, 20 Aug 2026 11:47:01 -0400 Subject: [PATCH 07/51] KF - NEW FEATURE - Suppress kinetic terms where the zero-orbit-width ordering fails near the axis Physics ruling (issue #376 DIII-D kinetic pathology): the drift-kinetic model loses validity where thermal ion orbit widths reach the local minor radius. psi_c = outermost crossing of by max(potato width (q^2 rho^2 R0)^(1/3), banana width q rho/sqrt(eps), poloidal gyroradius q rho/eps), computed from the equilibrium and kinetic profiles at runtime -- no user tuning parameters (the Fortran ktanh_flag precedent needed four). A C2 quintic envelope zeroes the calculated kinetic increments below psi_c (kernel evaluation skipped) and rises to 1 at 2 psi_c; the same boundary and envelope apply to the NTV torque psi quadrature (one source of truth). One Bool (axis_validity_suppression, default true) to disable for debugging. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LzbLFQKyuRE5DYZmLokKmk --- examples/DIIID-like_ideal_example/gpec.toml | 1 + .../Solovev_kinetic_NTV_example/gpec.toml | 1 + examples/a10_kinetic_example/gpec.toml | 1 + .../CalculatedKineticMatrices.jl | 68 +++++++++----- src/KineticForces/Compute.jl | 61 ++++++++----- src/KineticForces/KineticForcesStructs.jl | 88 ++++++++++--------- src/KineticForces/Utils.jl | 56 +++++++++++- 7 files changed, 188 insertions(+), 88 deletions(-) diff --git a/examples/DIIID-like_ideal_example/gpec.toml b/examples/DIIID-like_ideal_example/gpec.toml index b84e12872..1edc25231 100644 --- a/examples/DIIID-like_ideal_example/gpec.toml +++ b/examples/DIIID-like_ideal_example/gpec.toml @@ -87,5 +87,6 @@ f0type = "maxwellian" # Equilibrium distribution moment = "pressure" # Pressure-moment NTV torque atol_xlmda = 1e-9 # Absolute tolerance for inner pitch + energy integrations rtol_xlmda = 1e-5 # Relative tolerance for inner pitch + energy integrations +axis_validity_suppression = true # Suppress kinetic terms where the zero-orbit-width ordering fails near the axis (profile-derived boundary, no tuning parameters) write_outputs_to_HDF5 = true # Write outputs to the HDF5 file verbose = true # Enable verbose logging diff --git a/examples/Solovev_kinetic_NTV_example/gpec.toml b/examples/Solovev_kinetic_NTV_example/gpec.toml index e52b0d537..7a54a0397 100644 --- a/examples/Solovev_kinetic_NTV_example/gpec.toml +++ b/examples/Solovev_kinetic_NTV_example/gpec.toml @@ -87,3 +87,4 @@ f0fac = 1 # Scale toroidal field at constant pressure (β, q change; Φ, p, [KineticForces] kinetic_file = "kinetic.dat" # Kinetic profile file: psi_n, n_i, n_e, T_i, T_e, omega_E columns +axis_validity_suppression = true # Suppress kinetic terms where the zero-orbit-width ordering fails near the axis (profile-derived boundary, no tuning parameters) diff --git a/examples/a10_kinetic_example/gpec.toml b/examples/a10_kinetic_example/gpec.toml index fd8881122..ae602799f 100644 --- a/examples/a10_kinetic_example/gpec.toml +++ b/examples/a10_kinetic_example/gpec.toml @@ -59,3 +59,4 @@ nutype = "harmonic" # Collision operator (zero, small, krook, harmoni f0type = "maxwellian" # Distribution function (maxwellian, jkp, cgl) atol_xlmda = 1e-9 # Absolute tolerance for inner pitch + energy integrations rtol_xlmda = 1e-5 # Relative tolerance for inner pitch + energy integrations +axis_validity_suppression = true # Suppress kinetic terms where the zero-orbit-width ordering fails near the axis (profile-derived boundary, no tuning parameters) diff --git a/src/KineticForces/CalculatedKineticMatrices.jl b/src/KineticForces/CalculatedKineticMatrices.jl index d36fead99..9d8a19def 100644 --- a/src/KineticForces/CalculatedKineticMatrices.jl +++ b/src/KineticForces/CalculatedKineticMatrices.jl @@ -31,22 +31,25 @@ tracked as follow-up work blocked on PR #196 — see the plan's "Out of scope" section. # Arguments -- `ffs_ctrl`: ForceFreeStatesControl (carries `kinetic_factor`, `kinetic_source`) -- `equil`: PlasmaEquilibrium with 2D interpolants and named profile/geometry splines -- `ffs_intr`: ForceFreeStatesInternal (mode indexing) -- `metric`: MetricData (provides ψ grid via `metric.xs`) -- `ffit`: FourFitVars (used only for `numpert_total` cross-check) + + - `ffs_ctrl`: ForceFreeStatesControl (carries `kinetic_factor`, `kinetic_source`) + - `equil`: PlasmaEquilibrium with 2D interpolants and named profile/geometry splines + - `ffs_intr`: ForceFreeStatesInternal (mode indexing) + - `metric`: MetricData (provides ψ grid via `metric.xs`) + - `ffit`: FourFitVars (used only for `numpert_total` cross-check) # Keyword arguments -- `kf_ctrl`: KineticForcesControl, defaults to `KineticForcesControl()`. Used to - carry NTV-specific knobs (nl, zi, mi, wdfac, divxfac, electron) that the - KineticForces kernel needs but ForceFreeStatesControl does not expose. -- `kinetic_profiles::Equilibrium.KineticProfileSplines`: Required. Named kinetic- - profile splines loaded via `Equilibrium.load_kinetic_profiles`. + + - `kf_ctrl`: KineticForcesControl, defaults to `KineticForcesControl()`. Used to + carry NTV-specific knobs (nl, zi, mi, wdfac, divxfac, electron) that the + KineticForces kernel needs but ForceFreeStatesControl does not expose. + - `kinetic_profiles::Equilibrium.KineticProfileSplines`: Required. Named kinetic- + profile splines loaded via `Equilibrium.load_kinetic_profiles`. # Returns -- `kw_flat::Array{ComplexF64,3}`: Energy matrices, shape `(mpsi, np^2, 6)` -- `kt_flat::Array{ComplexF64,3}`: Torque matrices, shape `(mpsi, np^2, 6)` + + - `kw_flat::Array{ComplexF64,3}`: Energy matrices, shape `(mpsi, np^2, 6)` + - `kt_flat::Array{ComplexF64,3}`: Torque matrices, shape `(mpsi, np^2, 6)` """ function compute_calculated_kinetic_matrices( _ffs_ctrl, @@ -54,10 +57,13 @@ function compute_calculated_kinetic_matrices( ffs_intr, metric, ffit; - kf_ctrl::KineticForcesControl = KineticForcesControl(), + kf_ctrl::KineticForcesControl=KineticForcesControl(), kinetic_profiles::Equilibrium.KineticProfileSplines, + psis::Union{Nothing,Vector{Float64}}=nothing ) - xs = metric.xs + # The kernel is a pure function of psi (it evaluates equilibrium splines), so it can be + # driven over any knot list; default is the full equilibrium grid. + xs = psis === nothing ? metric.xs : psis mpsi = length(xs) mpert = ffs_intr.mpert npert = ffs_intr.npert @@ -93,22 +99,36 @@ function compute_calculated_kinetic_matrices( # ipsi row of kw_flat/kt_flat. Per-thread copies of kf_intr provide isolated # tpsi_* θ-grid buffers and interpolant hint refs; geometric/profile splines # are read-only and safely shared through deepcopy semantics. + # Near-axis validity envelope: suppress the drift-kinetic increments where the + # zero-orbit-width ordering fails; kernel evaluation is skipped where it is identically 0. + env = ones(Float64, mpsi) + if kf_ctrl.axis_validity_suppression + psi_c = kinetic_axis_validity_psi(kinetic_profiles, equil; + zi=kf_ctrl.zi, mi=kf_ctrl.mi, electron=kf_ctrl.electron) + if psi_c > 0 + env .= kinetic_axis_validity_envelope.(xs, psi_c) + @info "Kinetic axis-validity suppression: psi_c=$(round(psi_c; sigdigits=3)), envelope reaches 1 at " * + "psi=$(round(2 * psi_c; sigdigits=3)); $(count(iszero, env)) of $mpsi surfaces skipped" + end + end + nl = kf_ctrl.nl nthreads = Threads.maxthreadid() - thread_intrs = [deepcopy(kf_intr) for _ in 1:nthreads] - thread_full_w = [zeros(ComplexF64, mpert, mpert, 6) for _ in 1:nthreads] - thread_full_t = [zeros(ComplexF64, mpert, mpert, 6) for _ in 1:nthreads] + thread_intrs = [deepcopy(kf_intr) for _ in 1:nthreads] + thread_full_w = [zeros(ComplexF64, mpert, mpert, 6) for _ in 1:nthreads] + thread_full_t = [zeros(ComplexF64, mpert, mpert, 6) for _ in 1:nthreads] thread_block_w = [zeros(ComplexF64, mpert, mpert, 6) for _ in 1:nthreads] thread_block_t = [zeros(ComplexF64, mpert, mpert, 6) for _ in 1:nthreads] Threads.@threads for ipsi in 1:mpsi - tid = Threads.threadid() - intr_t = thread_intrs[tid] - full_w = thread_full_w[tid] - full_t = thread_full_t[tid] + tid = Threads.threadid() + intr_t = thread_intrs[tid] + full_w = thread_full_w[tid] + full_t = thread_full_t[tid] block_w = thread_block_w[tid] block_t = thread_block_t[tid] - psi = xs[ipsi] + psi = xs[ipsi] + env[ipsi] == 0.0 && continue for in_idx in 1:npert n = ffs_intr.nlow + in_idx - 1 fill!(full_w, 0) @@ -131,8 +151,8 @@ function compute_calculated_kinetic_matrices( row_offset = (in_idx - 1) * mpert for k in 1:6, j in 1:mpert, i in 1:mpert idx = (row_offset + j - 1) * np + (row_offset + i) - kw_flat[ipsi, idx, k] = full_w[i, j, k] - kt_flat[ipsi, idx, k] = full_t[i, j, k] + kw_flat[ipsi, idx, k] = env[ipsi] * full_w[i, j, k] + kt_flat[ipsi, idx, k] = env[ipsi] * full_t[i, j, k] end end end diff --git a/src/KineticForces/Compute.jl b/src/KineticForces/Compute.jl index 4175a62b4..3d9c3d8cf 100644 --- a/src/KineticForces/Compute.jl +++ b/src/KineticForces/Compute.jl @@ -41,7 +41,7 @@ Warn when the ψ torque quadrature terminated without satisfying the requested t silent-garbage scenario for weak applied fields, since NTV scales as δB². """ function check_psi_quadrature_convergence(total::ComplexF64, quad_err::Float64, - ctrl::KineticForcesControl, method::String) + ctrl::KineticForcesControl, method::String) if quad_err > max(ctrl.atol_psi, ctrl.rtol_psi * abs(total)) @warn "ψ torque quadrature ($method) did not converge within maxevals_psi=$(ctrl.maxevals_psi): " * "error estimate $quad_err vs |T|=$(abs(total)) N·m. Raise maxevals_psi or loosen rtol_psi." @@ -63,14 +63,16 @@ diagnostic T(ψ) profile at no extra cost (the values are computed anyway — we just keep them). # Returns + NamedTuple with: -- `total::ComplexF64`: Total integrated torque -- `torque_profile`: NamedTuple of (psi, dtdpsi, t_cumulative) from evaluation points -- `matrix_integrated`: Trapezoidal-integrated mpert×mpert×6 matrix (if matrix method) -- `psi_nsteps::Int`: Number of integrand evaluations -- `psi_quad_error::Float64`: Quadrature error estimate for the total torque -- `panel_psis::Vector{Float64}`: Quadrature panel boundaries actually used (bounds + interior resonant surfaces) -- `resonance_psis::Vector{Float64}`: Located kinetic-resonance ψ surfaces (Ω_ℓ(x=1)=0), for diagnostics/plotting + + - `total::ComplexF64`: Total integrated torque + - `torque_profile`: NamedTuple of (psi, dtdpsi, t_cumulative) from evaluation points + - `matrix_integrated`: Trapezoidal-integrated mpert×mpert×6 matrix (if matrix method) + - `psi_nsteps::Int`: Number of integrand evaluations + - `psi_quad_error::Float64`: Quadrature error estimate for the total torque + - `panel_psis::Vector{Float64}`: Quadrature panel boundaries actually used (bounds + interior resonant surfaces) + - `resonance_psis::Vector{Float64}`: Located kinetic-resonance ψ surfaces (Ω_ℓ(x=1)=0), for diagnostics/plotting The integral is paneled at the rational-surface ψ locations (`intr.sing_psis`) and capped at `ctrl.maxevals_psi` evaluations; a warning is emitted if the quadrature fails to reach @@ -95,9 +97,21 @@ function integrate_psi_quadgk( if x0 >= xout return (total=ComplexF64(0.0), torque_profile=nothing, matrix_integrated=nothing, psi_nsteps=0, psi_quad_error=0.0, - panel_psis=Float64[], resonance_psis=Float64[]) + panel_psis=Float64[], resonance_psis=Float64[]) end + # Near-axis validity suppression: same boundary and envelope as the EL kinetic + # matrices (one source of truth), applied to the torque density; the quadrature + # domain starts at the boundary since the integrand is identically zero below it. + psi_c = ctrl.axis_validity_suppression ? + kinetic_axis_validity_psi(kinetic_profiles, equil; zi=zi, mi=mi, electron=electron) : 0.0 + x0 = max(x0, psi_c) + if x0 >= xout + return (total=ComplexF64(0.0), torque_profile=nothing, matrix_integrated=nothing, psi_nsteps=0, psi_quad_error=0.0, + panel_psis=Float64[], resonance_psis=Float64[]) + end + psi_c > 0 && @info "Kinetic axis-validity suppression in ψ torque quadrature: domain starts at psi_c=$(round(psi_c; sigdigits=3))" + # The outer ψ-integral (the QuadGK batch / ψ-node loop) stays serial: QuadGK's refine # loop invokes the callback with small batches (~15 nodes), so threading it is # fork-join-bound. Instead thread the inner bounce-harmonic loop (2·nl+1 harmonics), @@ -133,9 +147,9 @@ function integrate_psi_quadgk( w = is_matrix_method ? thread_wtw[tid] : nothing is_matrix_method && fill!(w, 0) tpsi!(thread_tpsi[tid], psi, n, l, zi, mi, wdfac, divxfac, - electron, method, equil, thread_intrs[tid], kinetic_profiles; - op_wmats=w, - atol_xlmda=ctrl.atol_xlmda, rtol_xlmda=ctrl.rtol_xlmda) + electron, method, equil, thread_intrs[tid], kinetic_profiles; + op_wmats=w, + atol_xlmda=ctrl.atol_xlmda, rtol_xlmda=ctrl.rtol_xlmda) harm_vals[ell_idx] = thread_tpsi[tid][] is_matrix_method && (harm_elems[ell_idx] .= w) end @@ -145,6 +159,7 @@ function integrate_psi_quadgk( for ell_idx in 1:nharm total += harm_vals[ell_idx] end + total *= kinetic_axis_validity_envelope(psi, psi_c) y[k] = total push!(logged_psi, psi) @@ -154,6 +169,7 @@ function integrate_psi_quadgk( for ell_idx in 1:nharm elems_accum .+= harm_elems[ell_idx] end + elems_accum .*= kinetic_axis_validity_envelope(psi, psi_c) push!(logged_elems, elems_accum) end end @@ -162,7 +178,7 @@ function integrate_psi_quadgk( # Panels at the rational surfaces the run resolved plus the kinetic-resonance # surfaces (thermal-energy Ω_ℓ = 0 for ℓ ∈ -nl:nl) — both are torque-density peaks. resonance_psis = kinetic_resonance_psi_nodes(kinetic_profiles, equil; n, nl, zi, mi, electron, wdfac) - pts = psi_panel_points(vcat(intr.sing_psis, resonance_psis), x0, xout) + pts = psi_panel_points(vcat(intr.sing_psis, resonance_psis, [2 * psi_c]), x0, xout) bi = QuadGK.BatchIntegrand(psi_batch!, ComplexF64[], Float64[]) total, quad_err = quadgk(bi, pts...; atol=ctrl.atol_psi, rtol=ctrl.rtol_psi, maxevals=ctrl.maxevals_psi) @@ -205,7 +221,7 @@ function integrate_psi_quadgk( "$(length(pts) - 1) panels ($n_rational rational + $n_resonance kinetic resonance surfaces)" return (total=total, torque_profile=torque_profile, matrix_integrated=matrix_integrated, psi_nsteps=npts, psi_quad_error=quad_err, - panel_psis=pts, resonance_psis=sort(resonance_psis)) + panel_psis=pts, resonance_psis=sort(resonance_psis)) end @@ -224,15 +240,16 @@ For multi-n calculations, loops over toroidal mode numbers and assembles block-diagonal kinetic matrices. # Arguments -- `state::KineticForcesState`: Accumulates results for all methods -- `intr::KineticForcesInternal`: Internal state with equilibrium data -- `ctrl::KineticForcesControl`: Control parameters specifying which methods to run -- `equil`: PlasmaEquilibrium with 2D interpolants -- `kinetic_profiles::Equilibrium.KineticProfileSplines`: Named kinetic-profile splines + + - `state::KineticForcesState`: Accumulates results for all methods + - `intr::KineticForcesInternal`: Internal state with equilibrium data + - `ctrl::KineticForcesControl`: Control parameters specifying which methods to run + - `equil`: PlasmaEquilibrium with 2D interpolants + - `kinetic_profiles::Equilibrium.KineticProfileSplines`: Named kinetic-profile splines """ function compute_torque_all_methods!(state::KineticForcesState, intr::KineticForcesInternal, - ctrl::KineticForcesControl, equil, - kinetic_profiles::Equilibrium.KineticProfileSplines) + ctrl::KineticForcesControl, equil, + kinetic_profiles::Equilibrium.KineticProfileSplines) for entry in METHOD_REGISTRY getfield(ctrl, entry.flag) || continue @@ -311,7 +328,7 @@ function compute_torque_all_methods!(state::KineticForcesState, intr::KineticFor t_cumulative=t_cum_out, psi_nsteps=psi_nsteps_total, panel_psis=panel_psis_out, - resonance_psis=resonance_psis_out, + resonance_psis=resonance_psis_out ) state.method_results[method] = result_entry diff --git a/src/KineticForces/KineticForcesStructs.jl b/src/KineticForces/KineticForcesStructs.jl index 0d1b46464..f3e1fbf9b 100644 --- a/src/KineticForces/KineticForcesStructs.jl +++ b/src/KineticForces/KineticForcesStructs.jl @@ -3,15 +3,16 @@ Single source of truth for the NTV calculation methods. Each entry is a NamedTuple `(name, flag, kind, doc)`: -- `name` — short method identifier used as the HDF5 group key and in `intr.method` -- `flag` — the `KineticForcesControl` field symbol that enables the method -- `kind` — dispatch routing tag consumed by `method_kind` / `Torque.jl` - (`:gar` for the GAR/matrix family, `:fcgl`/`:rlar`/`:clar` for the - three special-cased methods) -- `doc` — one-line description printed in verbose output + + - `name` — short method identifier used as the HDF5 group key and in `intr.method` + - `flag` — the `KineticForcesControl` field symbol that enables the method + - `kind` — dispatch routing tag consumed by `method_kind` / `Torque.jl` + (`:gar` for the GAR/matrix family, `:fcgl`/`:rlar`/`:clar` for the + three special-cased methods) + - `doc` — one-line description printed in verbose output The method names/docs and the `Compute.jl` enable list are all derived from this -tuple, and `Torque.jl` routes on `kind`, so the methods are enumerated in one place. +tuple, and `Torque.jl` routes on `kind`, so the methods are enumerated in one place. To add a method: append an entry here and add the matching `*_flag` field to `KineticForcesControl`. """ @@ -56,6 +57,7 @@ User-facing control parameters from the TOML `[KineticForces]` section. Configures which NTV methods to run, species parameters, tolerances, and output options. Constructed via keyword arguments or from a TOML dict: + ```julia ctrl = KineticForcesControl(; (Symbol(k) => v for (k, v) in inputs["KineticForces"])...) ``` @@ -123,6 +125,7 @@ ctrl = KineticForcesControl(; (Symbol(k) => v for (k, v) in inputs["KineticForce nufac::Float64 = 1.0 # Collisionality scaling divxfac::Float64 = 1.0 # div(xi_perp) scaling + axis_validity_suppression::Bool = true # Suppress kinetic terms where the zero-orbit-width ordering fails near the axis (see kinetic_axis_validity_psi); envelope and boundary are profile-derived, no tuning parameters # Energy integration parameters nutype::String = "harmonic" # Collision operator: "zero", "small", "krook", "harmonic" @@ -176,12 +179,13 @@ Internal working state for KineticForces calculations. Holds equilibrium-derived quantities, profile interpolants, and integration results. Fields replacing former module-level globals: -- `ro`, `bo`, `chi1`: Equilibrium geometry parameters -- `mthsurf`, `mfac`: Poloidal grid info -- `dbob_m`, `divx_m`: Perturbation mode interpolants -- `sing_psis`: Rational-surface ψ locations (sorted, from the stability analysis), used as - panel boundaries for the outer ψ torque quadrature so the resonant peaks fall on - Gauss-Kronrod interval endpoints instead of driving deep adaptive bisection + + - `ro`, `bo`, `chi1`: Equilibrium geometry parameters + - `mthsurf`, `mfac`: Poloidal grid info + - `dbob_m`, `divx_m`: Perturbation mode interpolants + - `sing_psis`: Rational-surface ψ locations (sorted, from the stability analysis), used as + panel boundaries for the outer ψ torque quadrature so the resonant peaks fall on + Gauss-Kronrod interval endpoints instead of driving deep adaptive bisection Equilibrium and kinetic profile data are read directly from the `PlasmaEquilibrium` (`equil.profiles`, `equil.geometry`) and the @@ -271,17 +275,17 @@ function KineticForcesInternal(equil; verbose::Bool=false) # Axis toroidal field F(0)/ro that normalizes λ = μ·bo/E; F_spline stores 2πF. bo_axis = abs(equil.profiles.F_spline(0.0)) / (2π * equil.ro) KineticForcesInternal(; - ro = equil.ro, - bo = bo_axis, - chi1 = 2π * equil.psio, + ro=equil.ro, + bo=bo_axis, + chi1=2π * equil.psio, mthsurf, - tpsi_xs = collect(range(0.0, 1.0, length=nth)), - tpsi_B = Vector{Float64}(undef, nth), - tpsi_dBdpsi = Vector{Float64}(undef, nth), - tpsi_dBdtheta = Vector{Float64}(undef, nth), - tpsi_jac = Vector{Float64}(undef, nth), - tpsi_djdpsi = Vector{Float64}(undef, nth), - verbose, + tpsi_xs=collect(range(0.0, 1.0; length=nth)), + tpsi_B=Vector{Float64}(undef, nth), + tpsi_dBdpsi=Vector{Float64}(undef, nth), + tpsi_dBdtheta=Vector{Float64}(undef, nth), + tpsi_jac=Vector{Float64}(undef, nth), + tpsi_djdpsi=Vector{Float64}(undef, nth), + verbose ) end @@ -291,19 +295,21 @@ end Populate perturbation data from PerturbedEquilibriumState into KineticForcesInternal. Builds three interpolant sets from PE Clebsch displacements: -1. `xs_m` — [ξ^ψ, ∂ξ^ψ/∂ψ, ξ^α] CubicSeriesInterpolants over ψ -2. `dbob_m` — δB/B Fourier modes via JBB deweighting (Fortran set_peq) -3. `divx_m` — ∇·ξ⊥ Fourier modes via JBB deweighting + + 1. `xs_m` — [ξ^ψ, ∂ξ^ψ/∂ψ, ξ^α] CubicSeriesInterpolants over ψ + 2. `dbob_m` — δB/B Fourier modes via JBB deweighting (Fortran set_peq) + 3. `divx_m` — ∇·ξ⊥ Fourier modes via JBB deweighting The JBB deweighting algorithm (Fortran pentrc/inputs.f90:828-868): -1. Apply geometric matrices S,T,X,Y,Z in m-space -2. Inverse DFT to θ-space -3. Divide by J·B² at each θ -4. Forward DFT back to m-space + + 1. Apply geometric matrices S,T,X,Y,Z in m-space + 2. Inverse DFT to θ-space + 3. Divide by J·B² at each θ + 4. Forward DFT back to m-space """ function set_perturbation_data!(kf_intr::KineticForcesInternal, pe_state, ffs_intr, - equil::Equilibrium.PlasmaEquilibrium, - metric::ForceFreeStates.MetricData) + equil::Equilibrium.PlasmaEquilibrium, + metric::ForceFreeStates.MetricData) # Copy mode numbers from FFS kf_intr.mlow = ffs_intr.mlow kf_intr.mhigh = ffs_intr.mhigh @@ -372,9 +378,9 @@ function set_perturbation_data!(kf_intr::KineticForcesInternal, pe_state, ffs_in psi = psi_grid[ipsi] # Get Clebsch displacement vectors at this ψ - xsp = view(xi_modes.clebsch_psi, ipsi, :) # ξ^ψ [mpert] + xsp = view(xi_modes.clebsch_psi, ipsi, :) # ξ^ψ [mpert] xmp1 = view(xi_modes.clebsch_psi1, ipsi, :) # ∂ξ^ψ/∂ψ [mpert] - xms = view(clebsch_alpha_mat, ipsi, :) # ξ^α [mpert] + xms = view(clebsch_alpha_mat, ipsi, :) # ξ^α [mpert] # Evaluate geometric matrices at ψ → mpert² flat vectors, reshape to mpert×mpert geom_mats.smats(smat_flat, psi; hint=hint_s) @@ -394,8 +400,8 @@ function set_perturbation_data!(kf_intr::KineticForcesInternal, pe_state, ffs_in mul!(jbb_kapx, smat, xsp) mul!(jbb_kapx, tmat, xms, 1.0 + 0.0im, 1.0 + 0.0im) # += tmat * xms mul!(jbb_divx, xmat, xmp1) - mul!(jbb_divx, ymat, xsp, 1.0 + 0.0im, 1.0 + 0.0im) # += ymat * xsp - mul!(jbb_divx, zmat, xms, 1.0 + 0.0im, 1.0 + 0.0im) # += zmat * xms + mul!(jbb_divx, ymat, xsp, 1.0 + 0.0im, 1.0 + 0.0im) # += ymat * xsp + mul!(jbb_divx, zmat, xms, 1.0 + 0.0im, 1.0 + 0.0im) # += zmat * xms @. jbb_dbob = -(jbb_divx + jbb_kapx) # Inverse DFT to θ-space, divide by J·B², forward DFT back @@ -422,9 +428,9 @@ Matches Fortran set_peq lines 859-868: transforms JBB-weighted m-space data to θ-space, removes the J·B² weighting at each poloidal angle, and transforms back. """ function _jbb_deweight!(out::AbstractVector{ComplexF64}, jbb_modes::Vector{ComplexF64}, - ft::Utilities.FourierTransforms.FourierTransform, - psi::Float64, equil::Equilibrium.PlasmaEquilibrium, - mthsurf::Int, theta_buf::Vector{ComplexF64}) + ft::Utilities.FourierTransforms.FourierTransform, + psi::Float64, equil::Equilibrium.PlasmaEquilibrium, + mthsurf::Int, theta_buf::Vector{ComplexF64}) # Inverse DFT: m-space → θ-space theta_buf .= Utilities.FourierTransforms.inverse(ft, jbb_modes) @@ -494,8 +500,8 @@ Accumulated results from all KineticForces computations. Written to gpec.h5 under the "KineticForces" group. """ @kwdef mutable struct KineticForcesState - method_results::Dict{String, MethodResult} = Dict{String, MethodResult}() + method_results::Dict{String,MethodResult} = Dict{String,MethodResult}() # Block-diagonal kinetic matrices: key=method, value=(numpert_total, numpert_total, 6) - kinetic_matrices::Dict{String, Array{ComplexF64,3}} = Dict{String, Array{ComplexF64,3}}() + kinetic_matrices::Dict{String,Array{ComplexF64,3}} = Dict{String,Array{ComplexF64,3}}() completed::Bool = false end diff --git a/src/KineticForces/Utils.jl b/src/KineticForces/Utils.jl index 51c0cd3a8..97cfa4665 100644 --- a/src/KineticForces/Utils.jl +++ b/src/KineticForces/Utils.jl @@ -71,7 +71,7 @@ the estimate degenerates. Panel placement only needs ~peak-width accuracy, so th estimates (single spline evaluations) are sufficient and no bounce averaging is performed. """ function kinetic_resonance_psi_nodes(kinetic_profiles::Equilibrium.KineticProfileSplines, equil; - n::Int, nl::Int, zi::Int=1, mi::Int=2, electron::Bool=false, wdfac::Float64=1.0, xeval::Float64=2.5) + n::Int, nl::Int, zi::Int=1, mi::Int=2, electron::Bool=false, wdfac::Float64=1.0, xeval::Float64=2.5) chrg = electron ? -e : zi * e mass = electron ? me : mi * mp T_spline = electron ? kinetic_profiles.Te_spline : kinetic_profiles.Ti_spline @@ -89,3 +89,57 @@ function kinetic_resonance_psi_nodes(kinetic_profiles::Equilibrium.KineticProfil grid = filter(x -> x > 0, kinetic_profiles.xs) return _resonance_nodes_from_frequencies(wbhat_f, kinetic_profiles.omegaE_spline, wdhat_f, grid; n, nl, xeval) end + +""" + kinetic_axis_validity_psi(kinetic_profiles, equil; zi=1, mi=2, electron=false) → Float64 + +ψ_N below which the zero-orbit-width drift-kinetic ordering fails: the outermost ψ on the +kinetic-profile grid where any of the three thermal orbit-width scales reaches the local minor +radius ⟨r⟩ — potato width `(q²ρ²R₀)^(1/3)`, banana width `q·ρ/√ε`, and poloidal gyroradius +`q·ρ/ε` (ε = ⟨r⟩/⟨R⟩, clamped as in `kinetic_resonance_psi_nodes`; thermal gyroradius +ρ = √(2·m·T)·/(Z·e·B₀) of the computed species). Inside this boundary trapped bananas become +potato orbits with width comparable to r itself, so the bounce-averaged kinetic response is +evaluated outside its validity domain (measured consequence: diverging kinetic increments and a +pathological EL step count). Returns 0.0 when no criterion is met anywhere. The Fortran precedent +(`ktanh_flag`, dcon/fourfit.F) suppressed the same region with four hand-tuned knobs; here the +boundary is derived from the profiles with no user parameters. +""" +function kinetic_axis_validity_psi(kinetic_profiles::Equilibrium.KineticProfileSplines, equil; + zi::Int=1, mi::Int=2, electron::Bool=false) + chrg = electron ? e : zi * e + mass = electron ? me : mi * mp + T_spline = electron ? kinetic_profiles.Te_spline : kinetic_profiles.Ti_spline + q_spline = equil.profiles.q_spline + avg_r = equil.geometry.avg_r_spline + avg_R = equil.geometry.avg_R_spline + ro = abs(equil.ro) + bo = abs(equil.params.b0) + psi_c = 0.0 + for psi in kinetic_profiles.xs + psi <= 0 && continue + r = avg_r(psi) + r <= 0 && continue + eps = max(r / avg_R(psi), 1e-6) + rho = mass * sqrt(2 * T_spline(psi) / mass) / (abs(chrg) * bo) + q = abs(q_spline(psi)) + w_orbit = max(cbrt(q^2 * rho^2 * ro), q * rho / sqrt(eps), q * rho / eps) + w_orbit >= r && (psi_c = max(psi_c, psi)) + end + return psi_c +end + +""" + kinetic_axis_validity_envelope(psi, psi_c) → Float64 + +C² quintic smoothstep for the near-axis kinetic suppression: 0 for ψ ≤ ψ_c (drift-kinetic model +invalid; kernel evaluation may be skipped), rising over `[ψ_c, 2ψ_c]`, 1 above. The transition +width is tied to ψ_c itself, so there is no independent width parameter. `psi_c ≤ 0` returns 1 +(no suppression). +""" +function kinetic_axis_validity_envelope(psi::Float64, psi_c::Float64) + psi_c <= 0 && return 1.0 + t = (psi - psi_c) / psi_c + t <= 0 && return 0.0 + t >= 1 && return 1.0 + return t^3 * (10 + t * (6 * t - 15)) +end From f95230d38c1e5e0e7b8d61712f49fcbc3ae5b047 Mon Sep 17 00:00:00 2001 From: logan-nc Date: Thu, 20 Aug 2026 11:56:29 -0400 Subject: [PATCH 08/51] KF - NEW FEATURE - Output kinetic-model validity profiles under KineticForces/Validity Whenever kinetic profiles are used (self-consistent matrices or NTV post-processing), write the thermal orbit-width scales (rho_i, rho_banana, rho_theta, w_potato), the local geometry (r_minor, d_separatrix), the profile gradient lengths (L_p, L_q), the near-axis boundary psi_c with its applied envelope, and an is_valid array (orbit width < r, rho_banana < L_p and L_q, orbit width < distance to separatrix). Validity outside the near-axis envelope is flagged, never suppressed -- the far edge can dominate the physical NTV. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LzbLFQKyuRE5DYZmLokKmk --- src/GeneralizedPerturbedEquilibrium.jl | 39 +++++++++-- src/HDF5Schema.jl | 38 +++++++++-- .../CalculatedKineticMatrices.jl | 2 +- src/KineticForces/Utils.jl | 67 +++++++++++++++++++ 4 files changed, 132 insertions(+), 14 deletions(-) diff --git a/src/GeneralizedPerturbedEquilibrium.jl b/src/GeneralizedPerturbedEquilibrium.jl index 7b17683cf..d1d85d22c 100755 --- a/src/GeneralizedPerturbedEquilibrium.jl +++ b/src/GeneralizedPerturbedEquilibrium.jl @@ -76,7 +76,7 @@ using .ForceFreeStates: eulerlagrange_integration, free_run, normalize_eigenfunc using .ForceFreeStates: galerkin_solve, write_galerkin!, GalerkinResult, gal_matched_odestate const _DEPRECATED_FFS_KEYS = ("mer_flag", "force_wv_symmetry", "ode_flag", "cyl_flag", "mat_flag", "reform_eq_with_psilim", - "use_riccati", "use_parallel", "parallel_threads", "populate_dense_xi") + "use_riccati", "use_parallel", "parallel_threads", "populate_dense_xi") const _DEPRECATED_EQUIL_KEYS = ("power_bp", "power_b", "power_r", "power_rc") # Drop deprecated keys from a parsed gpec.toml section so legacy files keep parsing @@ -419,10 +419,11 @@ function main_from_inputs( # Inject the KineticForces callback so the "calculated" source can # invoke compute_calculated_kinetic_matrices without ForceFreeStates # importing KineticForces (which would invert the load order). - calculated_cb = (c, e, i, m, f) -> - KineticForces.compute_calculated_kinetic_matrices( - c, e, i, m, f; - kf_ctrl=kf_ctrl, kinetic_profiles=kinetic_profiles) + calculated_cb = + (c, e, i, m, f; psis=nothing) -> + KineticForces.compute_calculated_kinetic_matrices( + c, e, i, m, f; + kf_ctrl=kf_ctrl, kinetic_profiles=kinetic_profiles, psis=psis) make_kinetic_matrix(ctrl, equil, ffit, intr, metric; calculated_source=calculated_cb) @@ -495,6 +496,8 @@ function main_from_inputs( inputs, forcing_modes_snapshot, gal_data; + kinetic_profiles=kinetic_profiles, + kf_ctrl=kf_ctrl, locstab=locstab, ballooning_boundary=ballooning_boundary ) @@ -705,7 +708,9 @@ function write_outputs_to_HDF5( forcing_modes::Union{Nothing,Vector{ForcingTerms.ForcingMode}}=nothing, gal_data::Union{GalerkinResult,Nothing}=nothing; locstab::Union{FastInterpolations.CubicSeriesInterpolant,Nothing}=nothing, - ballooning_boundary=(psi=Float64[], alpha=Float64[], alpha_critical=Float64[]) + ballooning_boundary=(psi=Float64[], alpha=Float64[], alpha_critical=Float64[]), + kinetic_profiles=nothing, + kf_ctrl=nothing ) # Idempotent: already done if a PerturbedEquilibrium stage ran. Leaves the stores empty @@ -821,6 +826,28 @@ function write_outputs_to_HDF5( out_h5["$fwd/xi_s"] = odet.xi_s_store out_h5["$fwd/crit"] = odet.crit_store + # Kinetic-model validity diagnostics: orbit-width scales vs local geometry and profile + # gradient lengths, whenever kinetic profiles were used (self-consistent matrices or NTV + # post-processing). Diagnostic only — nothing outside the near-axis envelope is suppressed. + if kinetic_profiles !== nothing + kfc = kf_ctrl === nothing ? KineticForces.KineticForcesControl() : kf_ctrl + vp = KineticForces.kinetic_validity_profiles(kinetic_profiles, equil; + zi=kfc.zi, mi=kfc.mi, electron=kfc.electron) + vg = "KineticForces/Validity" + out_h5["$vg/psi"] = vp.psi + out_h5["$vg/rho_i"] = vp.rho_i + out_h5["$vg/rho_banana"] = vp.rho_banana + out_h5["$vg/rho_theta"] = vp.rho_theta + out_h5["$vg/w_potato"] = vp.w_potato + out_h5["$vg/r_minor"] = vp.r_minor + out_h5["$vg/L_p"] = vp.L_p + out_h5["$vg/L_q"] = vp.L_q + out_h5["$vg/d_separatrix"] = vp.d_separatrix + out_h5["$vg/psi_c"] = vp.psi_c + out_h5["$vg/envelope"] = kfc.axis_validity_suppression ? vp.envelope : ones(length(vp.psi)) + out_h5["$vg/is_valid"] = Int8.(vp.is_valid) + end + # Write edge stability scan data (only present when psiedge < psilim). # Generalized (W, N) pencil energies — power-normalized, Jacobian-invariant; these are # the values findmax_dW_edge! uses to choose the truncation point. diff --git a/src/HDF5Schema.jl b/src/HDF5Schema.jl index a1a47ef44..06487b7ea 100644 --- a/src/HDF5Schema.jl +++ b/src/HDF5Schema.jl @@ -141,6 +141,25 @@ const MAIN_H5_ANNOTATIONS = [ "LocalStability/alpha_critical" => (; long_name="critical normalized pressure gradient α for first ballooning stability", dims=("psi_ballooning",), attach=(1 => "LocalStability/ballooning_psi",)), + # --- KineticForces/Validity/ --- + "KineticForces/Validity/psi" => (; long_name="normalized poloidal flux ψ_N of the kinetic validity profiles", scale="psi"), + "KineticForces/Validity/rho_i" => (; long_name="thermal ion gyroradius √(2mT)/(Z·e·B₀)", units="m", attach=(1 => "KineticForces/Validity/psi",)), + "KineticForces/Validity/rho_banana" => (; long_name="thermal banana orbit width q·ρ_i/√ε", units="m", attach=(1 => "KineticForces/Validity/psi",)), + "KineticForces/Validity/rho_theta" => (; long_name="thermal poloidal gyroradius q·ρ_i/ε", units="m", attach=(1 => "KineticForces/Validity/psi",)), + "KineticForces/Validity/w_potato" => (; long_name="potato orbit width (q²ρ_i²R₀)^(1/3)", units="m", attach=(1 => "KineticForces/Validity/psi",)), + "KineticForces/Validity/r_minor" => (; long_name="surface-average minor radius ⟨r⟩", units="m", attach=(1 => "KineticForces/Validity/psi",)), + "KineticForces/Validity/L_p" => (; long_name="pressure gradient scale length |p|/|dp/dr|", units="m", attach=(1 => "KineticForces/Validity/psi",)), + "KineticForces/Validity/L_q" => (; long_name="safety-factor gradient scale length |q|/|dq/dr|", units="m", attach=(1 => "KineticForces/Validity/psi",)), + "KineticForces/Validity/d_separatrix" => (; long_name="distance to the separatrix ⟨r⟩(1) − ⟨r⟩(ψ)", units="m", attach=(1 => "KineticForces/Validity/psi",)), + "KineticForces/Validity/psi_c" => (; long_name="near-axis kinetic validity boundary: outermost ψ_N where a thermal orbit width reaches ⟨r⟩"), + "KineticForces/Validity/envelope" => + (; long_name="near-axis suppression envelope applied to the calculated kinetic terms (1 = unsuppressed)", units="1", attach=(1 => "KineticForces/Validity/psi",)), + "KineticForces/Validity/is_valid" => (; + long_name="1 where every zero-orbit-width ordering holds: max orbit width < ⟨r⟩, ρ_banana < L_p and L_q, max orbit width < d_separatrix", + units="1", + attach=(1 => "KineticForces/Validity/psi",) + ), + # --- ForceFreeStates/Solutions/ForwardIntegration/ --- "ForceFreeStates/Solutions/ForwardIntegration/nstep" => (; long_name="number of saved solution snapshots"), "ForceFreeStates/Solutions/ForwardIntegration/nstep_total" => (; long_name="total ODE solver steps taken"), @@ -239,7 +258,7 @@ const MAIN_H5_ANNOTATIONS = [ "SurfaceGeometries/Plasma/z" => (; long_name="Cartesian z of plasma-surface point cloud", units="m"), "SurfaceGeometries/Wall/x" => (; long_name="Cartesian x of wall point cloud", units="m"), "SurfaceGeometries/Wall/y" => (; long_name="Cartesian y of wall point cloud", units="m"), - "SurfaceGeometries/Wall/z" => (; long_name="Cartesian z of wall point cloud", units="m"), + "SurfaceGeometries/Wall/z" => (; long_name="Cartesian z of wall point cloud", units="m") ] # Euler-Lagrange operator matrices: same wording per letter, Ideal/ and Kinetic/ variants. @@ -252,7 +271,7 @@ const _ELM_IDEAL_LETTERS = [ ("H", "Euler-Lagrange primitive coefficient matrix H"), ("F", "Euler-Lagrange derived coefficient matrix F"), ("K", "Euler-Lagrange derived coefficient matrix K"), - ("G", "Euler-Lagrange derived coefficient matrix G"), + ("G", "Euler-Lagrange derived coefficient matrix G") ] # The kinetic branch overwrites only A, B, C, K, G and adds f0; D, E, H, F are shared # unchanged from the ideal set and are not re-emitted. @@ -262,14 +281,19 @@ const _ELM_KINETIC_LETTERS = [ ("C", "Euler-Lagrange primitive coefficient matrix C"), ("K", "Euler-Lagrange derived coefficient matrix K"), ("G", "Euler-Lagrange derived coefficient matrix G"), - ("f0", "raw kinetic component matrix f0"), + ("f0", "raw kinetic component matrix f0") ] const ELM_H5_ANNOTATIONS = vcat( ["ForceFreeStates/EulerLagrangeMatrices/psi" => (; long_name="normalized poloidal flux ψ_N grid of the operator matrices", scale="psi")], - ["ForceFreeStates/EulerLagrangeMatrices/Ideal/$l" => - (; long_name="ideal " * d, dims=("psi", "mode_row", "mode_col"), attach=(1 => "ForceFreeStates/EulerLagrangeMatrices/psi",)) for (l, d) in _ELM_IDEAL_LETTERS], - ["ForceFreeStates/EulerLagrangeMatrices/Kinetic/$l" => - (; long_name="kinetic-modified " * d, dims=("psi", "mode_row", "mode_col"), attach=(1 => "ForceFreeStates/EulerLagrangeMatrices/psi",)) for (l, d) in _ELM_KINETIC_LETTERS] + [ + "ForceFreeStates/EulerLagrangeMatrices/Ideal/$l" => + (; long_name="ideal " * d, dims=("psi", "mode_row", "mode_col"), attach=(1 => "ForceFreeStates/EulerLagrangeMatrices/psi",)) for (l, d) in _ELM_IDEAL_LETTERS + ], + [ + "ForceFreeStates/EulerLagrangeMatrices/Kinetic/$l" => + (; long_name="kinetic-modified " * d, dims=("psi", "mode_row", "mode_col"), attach=(1 => "ForceFreeStates/EulerLagrangeMatrices/psi",)) for + (l, d) in _ELM_KINETIC_LETTERS + ] ) """ diff --git a/src/KineticForces/CalculatedKineticMatrices.jl b/src/KineticForces/CalculatedKineticMatrices.jl index 9d8a19def..492a62403 100644 --- a/src/KineticForces/CalculatedKineticMatrices.jl +++ b/src/KineticForces/CalculatedKineticMatrices.jl @@ -108,7 +108,7 @@ function compute_calculated_kinetic_matrices( if psi_c > 0 env .= kinetic_axis_validity_envelope.(xs, psi_c) @info "Kinetic axis-validity suppression: psi_c=$(round(psi_c; sigdigits=3)), envelope reaches 1 at " * - "psi=$(round(2 * psi_c; sigdigits=3)); $(count(iszero, env)) of $mpsi surfaces skipped" + "psi=$(round(2 * psi_c; sigdigits=3)) (kernel evaluation skipped below psi_c)" maxlog = 1 end end diff --git a/src/KineticForces/Utils.jl b/src/KineticForces/Utils.jl index 97cfa4665..e2ab60c43 100644 --- a/src/KineticForces/Utils.jl +++ b/src/KineticForces/Utils.jl @@ -143,3 +143,70 @@ function kinetic_axis_validity_envelope(psi::Float64, psi_c::Float64) t >= 1 && return 1.0 return t^3 * (10 + t * (6 * t - 15)) end + +""" + kinetic_validity_profiles(kinetic_profiles, equil; zi=1, mi=2, electron=false) → NamedTuple + +Radial profiles of the drift-kinetic validity diagnostics, on the kinetic-profile ψ grid: +`psi`; the thermal orbit-width scales `rho_i` (gyroradius √(2mT)/(Z·e·B₀)), `rho_banana` +(q·ρ/√ε), `rho_theta` (poloidal gyroradius q·ρ/ε), `w_potato` ((q²ρ²R₀)^(1/3)); the local +geometry `r_minor` (⟨r⟩) and `d_separatrix` (⟨r⟩(1) − ⟨r⟩(ψ)); the profile gradient lengths +`L_p` and `L_q` (|X|·|dr/dψ|/|dX/dψ|, Inf where the profile is flat); the near-axis suppression +boundary `psi_c` (`kinetic_axis_validity_psi`) with its `envelope`; and `is_valid` — true where +every zero-orbit-width ordering holds at coefficient 1: max orbit width < r, ρ_banana < L_p and +L_q, and max(ρ_i, ρ_banana, ρ_θ) < d_separatrix. Validity is diagnostic only — nothing outside +the near-axis envelope is suppressed (the far edge and steep-gradient regions are flagged, not +zeroed, since they can dominate the physical NTV). +""" +function kinetic_validity_profiles(kinetic_profiles::Equilibrium.KineticProfileSplines, equil; + zi::Int=1, mi::Int=2, electron::Bool=false) + chrg = electron ? e : zi * e + mass = electron ? me : mi * mp + T_spline = electron ? kinetic_profiles.Te_spline : kinetic_profiles.Ti_spline + q_spline, q_deriv = equil.profiles.q_spline, equil.profiles.q_deriv + P_spline, P_deriv = equil.profiles.P_spline, equil.profiles.P_deriv + avg_r = equil.geometry.avg_r_spline + avg_R = equil.geometry.avg_R_spline + r_deriv = deriv1(avg_r) + ro = abs(equil.ro) + bo = abs(equil.params.b0) + r_sep = avg_r(1.0) + psi_c = kinetic_axis_validity_psi(kinetic_profiles, equil; zi=zi, mi=mi, electron=electron) + + psi = [x for x in kinetic_profiles.xs if 0 < x <= 1] + n = length(psi) + rho_i = zeros(n) + rho_banana = zeros(n) + rho_theta = zeros(n) + w_potato = zeros(n) + r_minor = zeros(n) + L_p = zeros(n) + L_q = zeros(n) + d_separatrix = zeros(n) + envelope = zeros(n) + is_valid = falses(n) + for (i, x) in pairs(psi) + r = avg_r(x) + eps = max(r / avg_R(x), 1e-6) + rho = mass * sqrt(2 * T_spline(x) / mass) / (abs(chrg) * bo) + q = abs(q_spline(x)) + drdpsi = abs(r_deriv(x)) + rho_i[i] = rho + rho_banana[i] = q * rho / sqrt(eps) + rho_theta[i] = q * rho / eps + w_potato[i] = cbrt(q^2 * rho^2 * ro) + r_minor[i] = r + d_separatrix[i] = max(r_sep - r, 0.0) + dP = abs(P_deriv(x)) + L_p[i] = dP > 0 ? abs(P_spline(x)) * drdpsi / dP : Inf + dq = abs(q_deriv(x)) + L_q[i] = dq > 0 ? q * drdpsi / dq : Inf + envelope[i] = kinetic_axis_validity_envelope(x, psi_c) + w_orbit = max(w_potato[i], rho_banana[i], rho_theta[i]) + is_valid[i] = w_orbit < r && rho_banana[i] < L_p[i] && rho_banana[i] < L_q[i] && + max(rho_i[i], rho_banana[i], rho_theta[i]) < d_separatrix[i] + end + return (psi=psi, rho_i=rho_i, rho_banana=rho_banana, rho_theta=rho_theta, w_potato=w_potato, + r_minor=r_minor, L_p=L_p, L_q=L_q, d_separatrix=d_separatrix, + psi_c=psi_c, envelope=envelope, is_valid=is_valid) +end From 3f1dbfef948d2b07511f9195e412bf0710767d95 Mon Sep 17 00:00:00 2001 From: logan-nc Date: Thu, 20 Aug 2026 12:45:05 -0400 Subject: [PATCH 09/51] KF - BUGFIX - Resolve the validity-envelope band on coarse grids; open-or-create the KineticForces group The envelope has structure on the psi_c scale; coarse kinetic decks (m16) cannot represent env*(increment) and the spline overshoot can land on a rational surface inside the transition band, corrupting the eigenvalues. Augment the kernel grid with knots across [psi_c, 2 psi_c] (band ends pinned -- the smoothstep is only C2 there) on the full-grid path and seed them on the certified path. Also open-or-create KineticForces in the NTV writer, which collided with the Validity group. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LzbLFQKyuRE5DYZmLokKmk --- src/ForceFreeStates/Kinetic.jl | 27 ++++++++++++++++++++------ src/GeneralizedPerturbedEquilibrium.jl | 10 +++++++++- src/KineticForces/Output.jl | 25 +++++++++++++----------- 3 files changed, 44 insertions(+), 18 deletions(-) diff --git a/src/ForceFreeStates/Kinetic.jl b/src/ForceFreeStates/Kinetic.jl index da1202bae..fa9f954bb 100644 --- a/src/ForceFreeStates/Kinetic.jl +++ b/src/ForceFreeStates/Kinetic.jl @@ -26,11 +26,19 @@ function make_kinetic_matrix( ffit::FourFitVars, intr::ForceFreeStatesInternal, metric::MetricData; - calculated_source::Union{Nothing,Function}=nothing + calculated_source::Union{Nothing,Function}=nothing, + axis_validity_psi_c::Float64=0.0 ) xs = metric.xs mpsi = length(xs) + # The near-axis validity envelope (KineticForces) has structure on the scale of the + # suppression boundary; coarse equilibrium grids cannot represent env·(increment), and the + # spline overshoot can land on a rational surface. Pin the band ends (the smoothstep is + # only C² there) and resolve the transition with a fixed set of knots. + band_knots(lo, hi) = axis_validity_psi_c > 0 ? + [x for x in range(axis_validity_psi_c, 2 * axis_validity_psi_c; length=9) if lo < x < hi] : Float64[] + # Get raw kinetic matrices (scaling is baked into each source) if ctrl.kinetic_source == "fixed" kw_flat, kt_flat = fixed_kinetic_matrices(intr.mpert, mpsi, ctrl.kinetic_factor, intr.mlow, ffit, xs) @@ -41,7 +49,14 @@ function make_kinetic_matrix( "calling make_kinetic_matrix directly, or pass " * "`calculated_source=KineticForces.compute_calculated_kinetic_matrices` explicitly." ) - kw_flat, kt_flat = calculated_source(ctrl, equil, intr, metric, ffit) + band = band_knots(xs[1], xs[end]) + if isempty(band) + kw_flat, kt_flat = calculated_source(ctrl, equil, intr, metric, ffit) + else + xs = sort!(unique!(vcat(collect(xs), band))) + mpsi = length(xs) + kw_flat, kt_flat = calculated_source(ctrl, equil, intr, metric, ffit; psis=xs) + end kw_flat .*= ctrl.kinetic_factor kt_flat .*= ctrl.kinetic_factor else @@ -55,13 +70,13 @@ function make_kinetic_matrix( end # Pre-compute FKG derived matrices (corresponds to Fortran method=0) - _compute_fkg_matrices!(ffit, equil, intr, metric, kw_flat, kt_flat) + _compute_fkg_matrices!(ffit, equil, intr, metric, kw_flat, kt_flat; xs=xs) return nothing end """ - _compute_fkg_matrices!(ffit, equil, intr, metric, kw_flat, kt_flat) + _compute_fkg_matrices!(ffit, equil, intr, metric, kw_flat, kt_flat; xs=xs) Pre-compute the derived F, K, G kinetic matrices at each ψ grid point and store as splines. This corresponds to `fourfit_kinetic_matrix` method=0 in the Fortran code (Fortran `fourfit.F` lines 1170-1260). @@ -78,9 +93,9 @@ function _compute_fkg_matrices!( intr::ForceFreeStatesInternal, metric::MetricData, kw_flat::Array{ComplexF64,3}, - kt_flat::Array{ComplexF64,3} + kt_flat::Array{ComplexF64,3}; + xs::Vector{Float64}=metric.xs ) - xs = metric.xs mpsi = length(xs) np = intr.numpert_total mpert = intr.mpert diff --git a/src/GeneralizedPerturbedEquilibrium.jl b/src/GeneralizedPerturbedEquilibrium.jl index d1d85d22c..52d255a07 100755 --- a/src/GeneralizedPerturbedEquilibrium.jl +++ b/src/GeneralizedPerturbedEquilibrium.jl @@ -424,8 +424,16 @@ function main_from_inputs( KineticForces.compute_calculated_kinetic_matrices( c, e, i, m, f; kf_ctrl=kf_ctrl, kinetic_profiles=kinetic_profiles, psis=psis) + # Near-axis validity boundary for the calculated kinetic matrices: the envelope band + # must be resolved by the kernel grid, so make_kinetic_matrix needs its location. + axis_psi_c = 0.0 + if ctrl.kinetic_source == "calculated" && kf_ctrl.axis_validity_suppression && kinetic_profiles !== nothing + axis_psi_c = KineticForces.kinetic_axis_validity_psi( + kinetic_profiles, equil; + zi=kf_ctrl.zi, mi=kf_ctrl.mi, electron=kf_ctrl.electron) + end make_kinetic_matrix(ctrl, equil, ffit, intr, metric; - calculated_source=calculated_cb) + calculated_source=calculated_cb, axis_validity_psi_c=axis_psi_c) # Find kinetically-displaced singular surfaces (zeros of det(F̄)) for ODE crossings. # Matches Fortran ksing_find (sing.f:1486-1616). singfac_min > 0 gates crossings; diff --git a/src/KineticForces/Output.jl b/src/KineticForces/Output.jl index dc613c04f..975ed6f03 100644 --- a/src/KineticForces/Output.jl +++ b/src/KineticForces/Output.jl @@ -12,14 +12,15 @@ then write to gpec.h5 in a single pass. Write all KineticForces results to the "KineticForces" group in gpec.h5. # Arguments -- `h5file::HDF5.File`: Open HDF5 file handle -- `state::KineticForcesState`: Accumulated computation results -- `dVdpsi_spline`: Optional dV/dψ_N profile interpolant; when given, dV/dψ_N is - written at the quadrature points so the torque density dT/dV = (dT/dψ)/(dV/dψ) - is directly available + + - `h5file::HDF5.File`: Open HDF5 file handle + - `state::KineticForcesState`: Accumulated computation results + - `dVdpsi_spline`: Optional dV/dψ_N profile interpolant; when given, dV/dψ_N is + written at the quadrature points so the torque density dT/dV = (dT/dψ)/(dV/dψ) + is directly available """ function write_to_hdf5!(h5file::HDF5.File, state::KineticForcesState; dVdpsi_spline=nothing) - g = create_group(h5file, "KineticForces") + g = haskey(h5file, "KineticForces") ? h5file["KineticForces"] : create_group(h5file, "KineticForces") for (method_name, result) in state.method_results mg = create_group(g, method_name) @@ -113,8 +114,9 @@ Write variable-length integration trajectory records using offset-indexed concat This is the standard HDF5 ragged array pattern for storing variable-length data. # Arguments -- `mg::HDF5.Group`: HDF5 group for this method -- `records::Vector{EnergyIntegrationResult}`: Integration records to write + + - `mg::HDF5.Group`: HDF5 group for this method + - `records::Vector{EnergyIntegrationResult}`: Integration records to write """ function write_integration_records!(mg::HDF5.Group, records::Vector{EnergyIntegrationResult}) rg = create_group(mg, "EnergyIntegrals") @@ -146,13 +148,14 @@ end Print a summary of KineticForces results to stdout. # Arguments -- `state::KineticForcesState`: Accumulated computation results -- `verbose::Bool`: Print detailed per-surface results + + - `state::KineticForcesState`: Accumulated computation results + - `verbose::Bool`: Print detailed per-surface results """ function print_summary(state::KineticForcesState; verbose::Bool=false) for (method_name, result) in state.method_results @printf("%-8s T_phi = %11.3e 2n*dW_k = %11.3e\n", - method_name, real(result.total_torque), imag(result.total_torque)) + method_name, real(result.total_torque), imag(result.total_torque)) end if verbose for (method_name, _) in state.kinetic_matrices From 12fca467cc7c9e0aeb4022c42a7caedc90fda655 Mon Sep 17 00:00:00 2001 From: logan-nc Date: Thu, 20 Aug 2026 13:41:47 -0400 Subject: [PATCH 10/51] KF - MINOR - Add units to the Validity psi_c annotation (schema metadata contract) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LzbLFQKyuRE5DYZmLokKmk --- src/HDF5Schema.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/HDF5Schema.jl b/src/HDF5Schema.jl index 06487b7ea..e1a73bb4e 100644 --- a/src/HDF5Schema.jl +++ b/src/HDF5Schema.jl @@ -151,7 +151,7 @@ const MAIN_H5_ANNOTATIONS = [ "KineticForces/Validity/L_p" => (; long_name="pressure gradient scale length |p|/|dp/dr|", units="m", attach=(1 => "KineticForces/Validity/psi",)), "KineticForces/Validity/L_q" => (; long_name="safety-factor gradient scale length |q|/|dq/dr|", units="m", attach=(1 => "KineticForces/Validity/psi",)), "KineticForces/Validity/d_separatrix" => (; long_name="distance to the separatrix ⟨r⟩(1) − ⟨r⟩(ψ)", units="m", attach=(1 => "KineticForces/Validity/psi",)), - "KineticForces/Validity/psi_c" => (; long_name="near-axis kinetic validity boundary: outermost ψ_N where a thermal orbit width reaches ⟨r⟩"), + "KineticForces/Validity/psi_c" => (; long_name="near-axis kinetic validity boundary: outermost ψ_N where a thermal orbit width reaches ⟨r⟩", units="1"), "KineticForces/Validity/envelope" => (; long_name="near-axis suppression envelope applied to the calculated kinetic terms (1 = unsuppressed)", units="1", attach=(1 => "KineticForces/Validity/psi",)), "KineticForces/Validity/is_valid" => (; From 0def3533b66d90310fcb2eede0dca669cd3a045d Mon Sep 17 00:00:00 2001 From: logan-nc Date: Fri, 21 Aug 2026 17:52:15 -0400 Subject: [PATCH 11/51] EQUIL - NEW FEATURE - Pin located kinetic-resonance surfaces into the auto psi grid The two-pass auto grid's criterion is ideal-driven and knows nothing about kinetic resonance locations. When a run builds calculated kinetic matrices, locate the Omega_l = 0 surfaces (same locator as the NTV quadrature paneling) and insert them as plain knots via merge_mandatory_nodes -- knot-at-node, no cleared zone, inserted before rational bracketing so the Delta-prime clean-interval treatment wins locally. Nodes inside the near-axis validity region are suppressed anyway and not pinned. DIII-D: +2 net knots, et[1] unchanged to 2e-6, EL steps drop 14%. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LzbLFQKyuRE5DYZmLokKmk --- src/Equilibrium/GridRefinement.jl | 8 +++++++- src/GeneralizedPerturbedEquilibrium.jl | 26 +++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/Equilibrium/GridRefinement.jl b/src/Equilibrium/GridRefinement.jl index e58eb81dc..d9441bbe8 100644 --- a/src/Equilibrium/GridRefinement.jl +++ b/src/Equilibrium/GridRefinement.jl @@ -418,7 +418,7 @@ Build the refined pass-2 ψ grid from a formed pass-1 equilibrium: measured-curv density (`_knot_density`), equidistribution, a global minimum-spacing floor (`enforce_min_spacing`), and rational-surface bracketing (`bracket_mandatory_nodes`). `tau` is the target interpolation accuracy (`psi_accuracy`); `kin` optionally supplies kinetic profiles -whose pedestal gradients attract knots; `mandatory` lists rational-surface ψ values to bracket; +whose pedestal gradients attract knots; `mandatory` lists rational-surface ψ values to bracket; `pinned` lists ψ values inserted as plain knots without a cleared zone (kinetic-resonance surfaces); `singfac_min` and `n_min` (smallest |n| in the run) set each surface's matching half-stencil `dpsi = singfac_min/(n_min·|q′|)`, and the bracket half-width is `bracket_coef·dpsi` (floored at `min_spacing`). Rational surfaces are bracketed, not pinned: a knot on the surface would make the @@ -428,6 +428,7 @@ function refined_psi_grid(equil::PlasmaEquilibrium; tau::Float64, kin::Union{Nothing,KineticProfileSplines}=nothing, mandatory::Vector{Float64}=Float64[], + pinned::Vector{Float64}=Float64[], singfac_min::Float64=1e-4, n_min::Int=1, bracket_coef::Float64=BRACKET_COEF, @@ -449,6 +450,11 @@ function refined_psi_grid(equil::PlasmaEquilibrium; N == N_cap && M_total > N_cap && @warn "refined_psi_grid: knot count capped at $N_cap (density integral wants $(ceil(Int, M_total))); psi_accuracy=$tau may not be attainable" grid = enforce_min_spacing(_equidistribute(xs, rho, N), min_spacing) + # Pinned knots (e.g. kinetic-resonance surfaces): knot-at-node semantics via + # merge_mandatory_nodes — no cleared zone. Inserted before rational bracketing, so a + # pinned node inside a rational's bracket zone is cleared by it (the Δ′ clean-interval + # requirement wins locally; the rational's own dense floor resolves that neighbourhood). + grid = isempty(pinned) ? grid : merge_mandatory_nodes(grid, pinned) isempty(mandatory) && return grid min_half_widths = [max(bracket_coef * singfac_min / (n_min * abs(equil.profiles.q_deriv(m))), min_spacing) for m in mandatory] return bracket_mandatory_nodes(grid, mandatory, min_half_widths, min_spacing) diff --git a/src/GeneralizedPerturbedEquilibrium.jl b/src/GeneralizedPerturbedEquilibrium.jl index 52d255a07..b95d2f263 100755 --- a/src/GeneralizedPerturbedEquilibrium.jl +++ b/src/GeneralizedPerturbedEquilibrium.jl @@ -249,8 +249,32 @@ function main_from_inputs( # Smallest |n| in the run sets the widest matching half-stencil dpsi = singfac_min/(n_min·|q′|), # so the rational-surface brackets clear a zone large enough for every mode. n_min = minimum(abs(n) for n in intr.nlow:intr.nhigh if n != 0) + # Pin the located kinetic-resonance surfaces (Ω_ℓ = 0) as plain knots when the run + # builds calculated kinetic matrices — the ideal-driven grid criterion knows nothing + # about kinetic resonance locations. Nodes inside the near-axis validity region are + # suppressed anyway and not pinned. + pinned = Float64[] + if ctrl.kinetic_factor > 0 && ctrl.kinetic_source == "calculated" && kinetic_profiles !== nothing + for n_res in intr.nlow:intr.nhigh + n_res == 0 && continue + append!( + pinned, + KineticForces.kinetic_resonance_psi_nodes( + kinetic_profiles, equil; + n=n_res, nl=kf_ctrl.nl, zi=kf_ctrl.zi, mi=kf_ctrl.mi, + electron=kf_ctrl.electron, wdfac=kf_ctrl.wdfac) + ) + end + if kf_ctrl.axis_validity_suppression + psi_c_grid = KineticForces.kinetic_axis_validity_psi(kinetic_profiles, equil; + zi=kf_ctrl.zi, mi=kf_ctrl.mi, electron=kf_ctrl.electron) + filter!(p -> p > psi_c_grid, pinned) + end + isempty(pinned) || + @info "Pinning $(length(pinned)) kinetic-resonance surfaces into the ψ grid: $(round.(sort(pinned); digits=3))" + end psi_nodes = Equilibrium.refined_psi_grid(equil; - tau=eq_config.psi_accuracy, kin=kinetic_profiles, mandatory=mandatory, + tau=eq_config.psi_accuracy, kin=kinetic_profiles, mandatory=mandatory, pinned=pinned, singfac_min=ctrl.singfac_min, n_min=n_min) rerun_input = if additional_input !== nothing # Analytic *Config, IMAS dd, or prebuilt RunInput — all re-formable. The IMAS From 168857b8bd7f102bb8056534e553476a6ba30889 Mon Sep 17 00:00:00 2001 From: logan-nc Date: Sat, 22 Aug 2026 13:45:59 -0400 Subject: [PATCH 12/51] PE/KF - BUGFIX! - Disable ideal-singularity regularization in self-consistent kinetic runs reg_spot smooths the ideal 1/(m-nq) divergence of the displacements before they drive the NTV integrand. The self-consistent kinetic Euler-Lagrange operator has no such divergence -- det(F-bar) is complex and nonzero at the rationals (Park & Logan, Phys. Plasmas 24, 032505 (2017) Eq. 70) -- so regularizing there suppresses a finite physical response, and inconsistently: xi^psi is never regularized, so damping the other two breaks their near-resonance cancellation in dB/B. Measured (DIII-D-like, n=1) against the EL solution's own dissipation: NTV torque 0.1655 vs 0.1322 N*m with reg_spot=0.05, and 0.1324 vs 0.1322 (0.15%) with it off. Kinetic runs now force reg_spot=0 and log the override; ideal runs are unchanged, where without it the displacement and torque diverge by four orders of magnitude. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LzbLFQKyuRE5DYZmLokKmk --- docs/src/kinetic_forces.md | 36 +++++++++++++++++++ src/GeneralizedPerturbedEquilibrium.jl | 13 ++++++- .../FieldReconstruction.jl | 13 ++++--- .../PerturbedEquilibriumStructs.jl | 22 ++++++------ 4 files changed, 68 insertions(+), 16 deletions(-) diff --git a/docs/src/kinetic_forces.md b/docs/src/kinetic_forces.md index bfdf825ca..e81d59aff 100644 --- a/docs/src/kinetic_forces.md +++ b/docs/src/kinetic_forces.md @@ -89,6 +89,42 @@ terms respectively, and do not modify the stored kinetic profile splines. when computing the `toroidal_rotation_factor` back-solve. Julia uses a clean reimplementation with consistent pre-scaling derivatives throughout. +## Regularization: why kinetic runs set `reg_spot = 0` + +`[PerturbedEquilibrium] reg_spot` smooths the displacements before they drive the NTV +integrand, multiplying ``\xi^{\psi\prime}`` and ``\xi^\alpha`` by +``Q^2/(Q^2 + \mathrm{reg\_spot}^2)`` with ``Q = m - nq``. It exists because **ideal** MHD is +singular at the rationals: ``\bar F_\mathrm{ideal} = Q F Q`` has ``\det \bar F = 0`` there, so +those two components diverge as ``1/Q`` and the torque integral does not converge. + +The **self-consistent kinetic** workflow has no such singularity. Park & Logan +([Phys. Plasmas 24, 032505 (2017)](https://doi.org/10.1063/1.4978562), §III D) decompose the +kinetic composite matrix as ``F_k = Q \bar F_k Q - P_l^\dagger Q - Q P_u + R_1`` where +``R_1 \neq 0`` at ``Q = 0``; with finite torque ``\det \bar F`` is complex, its zeros leave the +real ``\psi`` axis, and the singularity is removed from both the solution and the torque +integral. (Torque-free kinetic energy principles instead *shift and split* the zeros to +``\psi_r \mp r_{L,R}``, where the singularity is logarithmic and integrable — still not a case +for smoothing.) + +GPEC therefore **forces `reg_spot = 0` whenever `kinetic_factor > 0`**, logging the override. +Leaving it on suppresses a finite physical response and does so inconsistently — ``\xi^\psi`` +is never regularized, so damping the other two breaks their near-resonance cancellation in +``\delta B/B`` and leaves a spurious residue driving the NTV integrand. + +Measured on the DIII-D-like H-mode case (n = 1, C-coil drive), comparing the NTV torque against +the Euler–Lagrange solution's own dissipation ``-2n\,\mathrm{Im}\langle \xi, u_2\rangle/4\mu_0`` +— two independent calculations of the same quantity: + +| configuration | max ``|\xi^\alpha|`` | NTV torque [N·m] | EL dissipation [N·m] | +|---|---|---|---| +| ideal, `reg_spot = 0` | 643.8 | 6074.3 | — | +| ideal, `reg_spot = 0.05` | 0.058 | 0.554 | — | +| kinetic, `reg_spot = 0.05` | 0.055 | 0.1655 | 0.1322 | +| kinetic, `reg_spot = 0` | 0.059 | **0.1324** | **0.1322** | + +The ideal rows show why the knob exists; the kinetic rows show why it must be off there — the +two independent torques agree to 0.15 % with no regularization, and to 20 % with it. + ## HDF5 outputs: complex torque convention and the EnergyIntegrals layout The method level of `KineticForces//` reports the two physical scalars a user diff --git a/src/GeneralizedPerturbedEquilibrium.jl b/src/GeneralizedPerturbedEquilibrium.jl index b95d2f263..9dbd50752 100755 --- a/src/GeneralizedPerturbedEquilibrium.jl +++ b/src/GeneralizedPerturbedEquilibrium.jl @@ -603,8 +603,19 @@ function main_from_inputs( ft_ctrl = ForcingTerms.ForcingTermsControl() # Use defaults end + pe_raw_in = inputs["PerturbedEquilibrium"] + # reg_spot smooths the ideal 1/(m−nq) divergence of ξ^ψ′ and ξ^α before they reach the NTV + # integrand. The self-consistent kinetic Euler-Lagrange operator has no such divergence — + # det(F̄) is complex and nonzero at the rationals — so regularizing there suppresses a finite + # physical response, and does so inconsistently (ξ^ψ is left unregularized, breaking the + # near-resonance cancellation in δB/B). Park & Logan, Phys. Plasmas 24, 032505 (2017) §III D. + if ctrl.kinetic_factor > 0 && get(pe_raw_in, "reg_spot", 0.0) != 0 + @info "Self-consistent kinetic run: overriding reg_spot=$(pe_raw_in["reg_spot"]) with 0 " * + "(the kinetic terms remove the ideal resonant singularity; see docs/src/kinetic_forces.md)" + pe_raw_in = merge(pe_raw_in, Dict("reg_spot" => 0.0)) + end pe_ctrl = PerturbedEquilibrium.PerturbedEquilibriumControl(; - (Symbol(k) => v for (k, v) in inputs["PerturbedEquilibrium"])... + (Symbol(k) => v for (k, v) in pe_raw_in)... ) pe_intr = PerturbedEquilibrium.PerturbedEquilibriumInternal(; dir_path=intr.dir_path) diff --git a/src/PerturbedEquilibrium/FieldReconstruction.jl b/src/PerturbedEquilibrium/FieldReconstruction.jl index e491477c4..1b0e5f8e3 100644 --- a/src/PerturbedEquilibrium/FieldReconstruction.jl +++ b/src/PerturbedEquilibrium/FieldReconstruction.jl @@ -19,6 +19,7 @@ where χ₁ = 2π·Ψ₀ [Park Phys. Plasmas 14, 052110 (2007) eq. 8-10]. Clebsch displacement components for PENTRC (matches Fortran gpout_xclebsch): ξ^ψ = xsp_mn (unregularized) ∂ξ^ψ/∂ψ = xmp1_mn (regularized: xsp1 * singfac²/(singfac² + reg_spot²)) + reg_spot = 0 in kinetic runs — no ideal singularity to smooth ξ^α = xms_mn (regularized: -A⁻¹(B·xmp1 + C·xsp), divided by χ₁ in output) Contravariant displacement from Jacobian convolution (matches Fortran gpeq_contra): @@ -404,7 +405,7 @@ function compute_clebsch_displacements( # xms = -(A\B)*xmp1 - (A\C)*xsp xsp_vec = view(xi_psi_modes, ipsi, :) mul!(xms_vec, bmat, xmp1_vec) # xms = B*xmp1 - mul!(xms_vec, cmat_buf, xsp_vec, 1.0+0.0im, 1.0+0.0im) # xms += C*xsp + mul!(xms_vec, cmat_buf, xsp_vec, 1.0 + 0.0im, 1.0 + 0.0im) # xms += C*xsp # amat is positive-definite by construction (Newcomb kinetic-energy form), so cholesky is # safe. cholesky! factorizes in place (amat is a per-thread scratch buffer, refilled by # ffit.amats each surface), avoiding a fresh factorization allocation per surface. @@ -980,10 +981,12 @@ function _apply_rzphi_transform( # Per-thread scratch (the immutable `ft` functor and `geom` are shared read-only): θ-space # transform inputs/outputs (length mtheta) and mode-space forward-DFT outputs (length mpert), # so the DFTs run in place with no per-surface allocation. - bufs = [(R=zeros(ComplexF64, mtheta), Z=zeros(ComplexF64, mtheta), P=zeros(ComplexF64, mtheta), - psi=zeros(ComplexF64, mtheta), th=zeros(ComplexF64, mtheta), ze=zeros(ComplexF64, mtheta), - Ro=zeros(ComplexF64, mpert), Zo=zeros(ComplexF64, mpert), Po=zeros(ComplexF64, mpert)) - for _ in 1:Threads.maxthreadid()] + bufs = [ + (R=zeros(ComplexF64, mtheta), Z=zeros(ComplexF64, mtheta), P=zeros(ComplexF64, mtheta), + psi=zeros(ComplexF64, mtheta), th=zeros(ComplexF64, mtheta), ze=zeros(ComplexF64, mtheta), + Ro=zeros(ComplexF64, mpert), Zo=zeros(ComplexF64, mpert), Po=zeros(ComplexF64, mpert)) + for _ in 1:Threads.maxthreadid() + ] Threads.@threads :static for ipsi in 1:npsi buf = bufs[Threads.threadid()] diff --git a/src/PerturbedEquilibrium/PerturbedEquilibriumStructs.jl b/src/PerturbedEquilibrium/PerturbedEquilibriumStructs.jl index 1ce0895d6..e44edb522 100644 --- a/src/PerturbedEquilibrium/PerturbedEquilibriumStructs.jl +++ b/src/PerturbedEquilibrium/PerturbedEquilibriumStructs.jl @@ -26,8 +26,9 @@ Medium Priority (defer for MWE): - `singular_point_method::String` - Method for singular point treatment (default: "standard") Regularization: - # High Priority (MWE) - - `reg_spot::Float64` - Regularization width for singular surface smoothing (default: 0.05). Set to 0 to disable. Must be ≥ 0. +# High Priority (MWE) + + - `reg_spot::Float64` - Regularization width for singular surface smoothing (default: 0.05). Set to 0 to disable. Must be ≥ 0. Forced to 0 in self-consistent kinetic runs, whose Euler-Lagrange operator has no resonant singularity to smooth. """ @kwdef struct PerturbedEquilibriumControl # High Priority (MWE) @@ -121,6 +122,7 @@ Metadata [n_rational] — identifies each (surface, n) row: Control-surface forcing/response spectra [numpert_total], in the three Pharr (2026) field representations (all tesla; no flux/weber is stored): + - `forcing_b`/`response_b` - bare normal field b (Σ⁻¹·b̃) - `forcing_b_rootarea`/`response_b_rootarea` - root-area-weighted field b̃ (coordinate-invariant) - `forcing_b_area`/`response_b_area` - area-weighted field b̄ (= S·b̃; flux is Φ = A·b̄) @@ -185,18 +187,18 @@ well-conditioned flux-space inductances L, Λ: rational_surface_idx::Vector{Int} = Int[] # Control-surface forcing/response spectra in the three weightings of field representations [numpert_total], tesla - forcing_b::Vector{ComplexF64} = ComplexF64[] # bare normal field b (forcing Φ_x) - forcing_b_rootarea::Vector{ComplexF64} = ComplexF64[] # root-area-weighted field b̃ (coordinate-invariant) - forcing_b_area::Vector{ComplexF64} = ComplexF64[] # area-weighted field b̄ - response_b::Vector{ComplexF64} = ComplexF64[] # bare normal field b (response Φ_tot = P·Φ_x) + forcing_b::Vector{ComplexF64} = ComplexF64[] # bare normal field b (forcing Φ_x) + forcing_b_rootarea::Vector{ComplexF64} = ComplexF64[] # root-area-weighted field b̃ (coordinate-invariant) + forcing_b_area::Vector{ComplexF64} = ComplexF64[] # area-weighted field b̄ + response_b::Vector{ComplexF64} = ComplexF64[] # bare normal field b (response Φ_tot = P·Φ_x) response_b_rootarea::Vector{ComplexF64} = ComplexF64[] # root-area-weighted field b̃ - response_b_area::Vector{ComplexF64} = ComplexF64[] # area-weighted field b̄ + response_b_area::Vector{ComplexF64} = ComplexF64[] # area-weighted field b̄ # Control surface matrices [numpert_total × numpert_total], root-area-weighted field (b̃) space - plasma_inductance::Matrix{ComplexF64} = zeros(ComplexF64, 0, 0) # Λ̃ (field space) + plasma_inductance::Matrix{ComplexF64} = zeros(ComplexF64, 0, 0) # Λ̃ (field space) surface_inductance::Matrix{ComplexF64} = zeros(ComplexF64, 0, 0) # L̃ (field space) - permeability::Matrix{ComplexF64} = zeros(ComplexF64, 0, 0) # P̃ = R⁻¹·Λ·L⁻¹·R - reluctance::Matrix{ComplexF64} = zeros(ComplexF64, 0, 0) # ϱ̃ = R†·L⁻¹·(Λ−L)·L⁻¹·R + permeability::Matrix{ComplexF64} = zeros(ComplexF64, 0, 0) # P̃ = R⁻¹·Λ·L⁻¹·R + reluctance::Matrix{ComplexF64} = zeros(ComplexF64, 0, 0) # ϱ̃ = R†·L⁻¹·(Λ−L)·L⁻¹·R rootarea_to_area_weight::Matrix{ComplexF64} = zeros(ComplexF64, 0, 0) # S = Σ/√A at psilim: b̃→b̄ recovery operator surface_area::Float64 = 0.0 # scalar control-surface area A = ∫J|∇ψ|dθ (flux: Φ = A·b̄; conform R = S·A) From 5aa11223c7123820f07aa73fe800d12987eb7967 Mon Sep 17 00:00:00 2001 From: logan-nc Date: Sat, 22 Aug 2026 16:35:33 -0400 Subject: [PATCH 13/51] FFS - IMPROVEMENT - Report sub-threshold near-singular kinetic F-bar structure The cond(F-bar) scan that locates kinetic singular surfaces already sweeps 2000 points and is written to SingularSurfaces/Kinetic/scan_cond, but only peaks above the 1e8 singular threshold were surfaced. On a DIII-D-like case the strongest peaks sit at 3e5-5e6 -- real shifted/split resonance structure (Park & Logan Eq. 70) that stayed silent, and which tracks the NTV torque-density peaks at low collisionality/rotation. Report the strongest few. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LzbLFQKyuRE5DYZmLokKmk --- src/ForceFreeStates/Sing.jl | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/src/ForceFreeStates/Sing.jl b/src/ForceFreeStates/Sing.jl index f41e6dd2f..363c094d1 100644 --- a/src/ForceFreeStates/Sing.jl +++ b/src/ForceFreeStates/Sing.jl @@ -123,9 +123,11 @@ function sing_lim!(intr::ForceFreeStatesInternal, ctrl::ForceFreeStatesControl, # strategy. Multi-n runs are not supported — the "outermost rational + dmlim/n" cutoff depends # on which n is used — and fall back to qhigh / psihigh truncation with a warning. if ctrl.set_psilim_via_dmlim && intr.nlow <= 0 - error("sing_lim!: set_psilim_via_dmlim = true requires a resolved toroidal range, but got intr.nlow=$(intr.nlow). " * - "Assign intr.nlow / intr.nhigh (from ctrl.nn_low / ctrl.nn_high) before calling sing_lim!, " * - "or set set_psilim_via_dmlim = false to truncate via qhigh / psihigh instead.") + error( + "sing_lim!: set_psilim_via_dmlim = true requires a resolved toroidal range, but got intr.nlow=$(intr.nlow). " * + "Assign intr.nlow / intr.nhigh (from ctrl.nn_low / ctrl.nn_high) before calling sing_lim!, " * + "or set set_psilim_via_dmlim = false to truncate via qhigh / psihigh instead." + ) elseif ctrl.set_psilim_via_dmlim && intr.nlow != intr.nhigh @warn "set_psilim_via_dmlim = true is ignored for multi-n runs (nn_low=$(intr.nlow), nn_high=$(intr.nhigh)); falling back to qhigh / psihigh truncation." elseif ctrl.set_psilim_via_dmlim @@ -266,7 +268,7 @@ function compute_sing_asymptotics( # This is the parameter α but for all modes - α = 0 for non-resonant modes power[ipert_res] .= -alpha - power[ipert_res .+ intr.numpert_total] .= alpha + power[ipert_res.+intr.numpert_total] .= alpha # Zeroth-order non-resonant solutions for ipert in 1:intr.numpert_total @@ -303,7 +305,7 @@ function compute_sing_asymptotics( msg *= @sprintf(" m0mat(1,2)= %+.12e %+.12ei\n", real(m0mat[1, 2]), imag(m0mat[1, 2])) msg *= @sprintf(" m0mat(2,1)= %+.12e %+.12ei\n", real(m0mat[2, 1]), imag(m0mat[2, 1])) msg *= @sprintf(" m0mat(2,2)= %+.12e %+.12ei\n", real(m0mat[2, 2]), imag(m0mat[2, 2])) - di = m0mat[1, 1]*m0mat[2, 2] - m0mat[2, 1]*m0mat[1, 2] + di = m0mat[1, 1] * m0mat[2, 2] - m0mat[2, 1] * m0mat[1, 2] msg *= @sprintf(" di= %+.12e, alpha= %+.12e %+.12ei\n", real(di), real(alpha[1]), imag(alpha[1])) msg *= @sprintf(" psifac= %+.12e, r1=%d, ipert0=%d\n", singp.psifac, r1[1], ipert0) msg *= @sprintf(" vmat(ip,ip,2,0)= %+.8e %+.8ei\n", real(vmat[ipert0, ipert0, 2, 1]), imag(vmat[ipert0, ipert0, 2, 1])) @@ -771,7 +773,7 @@ function sing_get_ua(sing_asymp::SingAsymptotics, dpsi::Float64) # Restore powers (unshear v→u) — matches Fortran STRIDE sing_get_ua for i in eachindex(r1) - pfac = pfac_base ^ sing_asymp.alpha[i] # dpsi^α + pfac = pfac_base^sing_asymp.alpha[i] # dpsi^α ua[:, r2[2*i-1], :] ./= pfac # big solution column: /dpsi^α ua[:, r2[2*i], :] .*= pfac # small solution column: *dpsi^α ua[r1[i], :, 1] ./= sqrtfac # resonant row ξ: /√dpsi @@ -1432,6 +1434,19 @@ function find_kinetic_singular_surfaces!(ffit::FourFitVars, equil::Equilibrium.P end end + # Peaks below the threshold are not singular surfaces, but they mark where the kinetic F̄ comes + # closest to singular — the shifted/split resonances of Park & Logan Eq. (70). Report the + # strongest few so sharp kinetic structure is visible rather than silent (on a DIII-D-like case + # these track the NTV torque-density peaks at low collisionality/rotation). + subthreshold = [i for i in 2:(ngrid-1) if cond_vals[i] > cond_vals[i-1] && cond_vals[i] > cond_vals[i+1] && + cond_threshold / 100 < cond_vals[i] <= cond_threshold] + if !isempty(subthreshold) + top = sort(subthreshold; by=i -> -cond_vals[i])[1:min(3, length(subthreshold))] + @info "Kinetic F̄ near-singular structure below the singular threshold at " * + join(["ψ=$(round(psi_grid[i]; digits=4)) (cond=$(round(cond_vals[i]; sigdigits=3)))" for i in top], ", ") * + " — full scan in SingularSurfaces/Kinetic/scan_cond; check the ψ grid resolves these if results look grid-sensitive" + end + # Refine each peak to find the precise ψ location kinsing_surfaces = SingType[] for idx in peak_indices From b3d7ab9a83760861b9db2f649f89b95a6aef247a Mon Sep 17 00:00:00 2001 From: logan-nc Date: Sat, 22 Aug 2026 17:30:39 -0400 Subject: [PATCH 14/51] FFS - NEW FEATURE - Add kinetic knots across unresolved near-singular F-bar structure The cond(F-bar) scan already locates shifted/split kinetic resonances (Park & Logan Eq. 70) but only reported them. Measure each sub-threshold peak's FWHM and, where the grid puts fewer than three knots inside it, evaluate the kernel at a few targeted psi and splice them in -- existing values are reused, so the cost is one kernel call per added knot, not a re-formation. Respects MIN_KNOT_SPACING, the near-axis validity band, and a 24-knot cap; a resolved grid inserts nothing. Measured on DIII-D: no insertions on the nominal or low-collisionality cases; on the collisionless slow-rotation case two peaks (psi=0.53, 0.51, FWHM 1.5e-3 and 2.5e-3) had zero knots inside them, and one of the two coincides with a top NTV torque-density peak. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LzbLFQKyuRE5DYZmLokKmk --- src/ForceFreeStates/Kinetic.jl | 91 ++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/src/ForceFreeStates/Kinetic.jl b/src/ForceFreeStates/Kinetic.jl index fa9f954bb..a1c070baa 100644 --- a/src/ForceFreeStates/Kinetic.jl +++ b/src/ForceFreeStates/Kinetic.jl @@ -1,3 +1,69 @@ + +""" + refine_grid_at_fbar_peaks(xs, kw, kt, evaluate, ffit, equil, intr, psi_c; + ngrid=1000, relaxed_frac=0.01, target=3, max_add=24) → (xs, kw, kt) + +Insert kinetic evaluation knots across near-singular structure of F̄ that the grid does not +resolve. Scans cond(F̄) (the same operator `find_kinetic_singular_surfaces!` searches — Park & +Logan Eq. 70, so shifted and split resonances are included), takes peaks between +`relaxed_frac`·threshold and the singular threshold, measures each peak's FWHM, and adds knots +only where fewer than `target` knots lie inside it. New points respect `MIN_KNOT_SPACING`, stay +above the near-axis validity band, and are capped at `max_add`; each costs one kernel evaluation +and the existing values are reused. A well-resolved grid inserts nothing. +""" +function refine_grid_at_fbar_peaks(xs::Vector{Float64}, kw::Array{ComplexF64,3}, kt::Array{ComplexF64,3}, + evaluate::Function, ffit::FourFitVars, equil::Equilibrium.PlasmaEquilibrium, + intr::ForceFreeStatesInternal, psi_c::Float64; + ngrid::Int=1000, relaxed_frac::Float64=0.01, target::Int=3, max_add::Int=24, + cond_threshold::Float64=1e8) + + lo, hi = xs[1], xs[end] + scan = collect(range(lo, hi; length=ngrid)) + hint = Ref(1) + cond_vals = [ + try + evaluate_fbar_condition(x, ffit, equil, intr; hint=hint) + catch + Inf + end for x in scan + ] + + add = Float64[] + for i in 2:(ngrid-1) + c = cond_vals[i] + (c > cond_vals[i-1] && c > cond_vals[i+1] && relaxed_frac * cond_threshold < c <= cond_threshold) || continue + l = i + while l > 1 && cond_vals[l] > c / 2 + l -= 1 + end + r = i + while r < ngrid && cond_vals[r] > c / 2 + r += 1 + end + inside = count(x -> scan[l] <= x <= scan[r], xs) + inside >= target && continue + w = (scan[r] - scan[l]) / 3 + for x in (scan[i], scan[i] - w, scan[i] + w) + (lo < x < hi && x > 2 * psi_c) || continue + minimum(abs.(xs .- x)) < Equilibrium.MIN_KNOT_SPACING && continue + isempty(add) || minimum(abs.(add .- x)) >= Equilibrium.MIN_KNOT_SPACING || continue + push!(add, x) + end + end + isempty(add) && return xs, kw, kt + length(add) > max_add && (add = sort(add)[1:max_add]) + + sort!(add) + @info "Kinetic grid: $(length(add)) knot(s) added across unresolved near-singular F̄ structure at " * + "ψ=$(round.(add; digits=4)) (cond peaks below the singular threshold)" + kw_new, kt_new = evaluate(add) + allxs = vcat(xs, add) + perm = sortperm(allxs) + kw_all = cat(kw, kw_new; dims=1)[perm, :, :] + kt_all = cat(kt, kt_new; dims=1)[perm, :, :] + return allxs[perm], kw_all, kt_all +end + """ make_kinetic_matrix(ctrl, equil, ffit, intr, metric; calculated_source=nothing) @@ -72,6 +138,31 @@ function make_kinetic_matrix( # Pre-compute FKG derived matrices (corresponds to Fortran method=0) _compute_fkg_matrices!(ffit, equil, intr, metric, kw_flat, kt_flat; xs=xs) + # The FKG splines now exist, so F̄ can be scanned: add knots only where near-singular + # structure (shifted/split kinetic resonances) falls in an interval that does not resolve it. + if ctrl.kinetic_source == "calculated" && calculated_source !== nothing + xs2, kw_flat, kt_flat = refine_grid_at_fbar_peaks( + collect(xs), kw_flat, kt_flat, + psis -> begin + kwn, ktn = calculated_source(ctrl, equil, intr, metric, ffit; psis=psis) + (kwn .* ctrl.kinetic_factor, ktn .* ctrl.kinetic_factor) + end, + ffit, equil, intr, axis_validity_psi_c) + if length(xs2) != length(xs) + xs = xs2 + # _compute_fkg_matrices! folds the kinetic increments into amats/bmats/cmats (saving the + # ideal copies), so restore those before recomputing or the increments are added twice. + ffit.amats = ffit.amats_ideal + ffit.bmats = ffit.bmats_ideal + ffit.cmats = ffit.cmats_ideal + for ic in 1:6 + ffit.kwmats[ic] = cubic_interp(xs, Series(@view(kw_flat[:, :, ic])); ffit.itp_opts...) + ffit.ktmats[ic] = cubic_interp(xs, Series(@view(kt_flat[:, :, ic])); ffit.itp_opts...) + end + _compute_fkg_matrices!(ffit, equil, intr, metric, kw_flat, kt_flat; xs=xs) + end + end + return nothing end From f587e9becea1da59e5785866dd5f986c59b5c830 Mon Sep 17 00:00:00 2001 From: logan-nc Date: Sat, 22 Aug 2026 18:31:50 -0400 Subject: [PATCH 15/51] PE - BUGFIX - Force reg_spot=0 in kinetic runs even when the deck omits the key The override tested get(inputs,"reg_spot",0.0)!=0, so it only fired when a deck set reg_spot explicitly; a deck relying on the struct default (0.05) silently kept regularization on in a self-consistent kinetic run -- exactly the case the change exists to prevent. Compare against the struct default and always force 0, logging whenever the prior effective value was nonzero. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LzbLFQKyuRE5DYZmLokKmk --- src/GeneralizedPerturbedEquilibrium.jl | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/GeneralizedPerturbedEquilibrium.jl b/src/GeneralizedPerturbedEquilibrium.jl index 9dbd50752..debbed227 100755 --- a/src/GeneralizedPerturbedEquilibrium.jl +++ b/src/GeneralizedPerturbedEquilibrium.jl @@ -609,8 +609,9 @@ function main_from_inputs( # det(F̄) is complex and nonzero at the rationals — so regularizing there suppresses a finite # physical response, and does so inconsistently (ξ^ψ is left unregularized, breaking the # near-resonance cancellation in δB/B). Park & Logan, Phys. Plasmas 24, 032505 (2017) §III D. - if ctrl.kinetic_factor > 0 && get(pe_raw_in, "reg_spot", 0.0) != 0 - @info "Self-consistent kinetic run: overriding reg_spot=$(pe_raw_in["reg_spot"]) with 0 " * + if ctrl.kinetic_factor > 0 + prev = get(pe_raw_in, "reg_spot", PerturbedEquilibrium.PerturbedEquilibriumControl().reg_spot) + prev == 0 || @info "Self-consistent kinetic run: overriding reg_spot=$prev with 0 " * "(the kinetic terms remove the ideal resonant singularity; see docs/src/kinetic_forces.md)" pe_raw_in = merge(pe_raw_in, Dict("reg_spot" => 0.0)) end From e4329fa58d579ad5e8b19a0c62ef13ede61d24c0 Mon Sep 17 00:00:00 2001 From: d-burg Date: Mon, 17 Aug 2026 16:42:57 -0400 Subject: [PATCH 16/51] =?UTF-8?q?TEARING=20-=20BUG=20FIX=20-=20Convert=20?= =?UTF-8?q?=CE=94'=20to=20the=20r=5Fs=20reference=20length=20before=20slab?= =?UTF-8?q?-layer=20matching?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SLAYER dispersion relation paired a ψ_N-referenced BVP Δ' with an r_s-referenced layer side (Δ(Q), S=τ_R/τ_H on r_s, and the critical-Δ, whose Ŵ_d is W/r_s). Apply the Frobenius reference-length transform Δ̂_ij = K_i^(1/2+μ_i)·Δ'_ij·K_j^(μ_j−1/2), K = r_s·(dψ_N/dr)|_s, μ = √(−D_I), at the matching point. Verified parameter-free against the TJ circular benchmarks (median residual +1% over 23 points; absolute 2/1 agreement 17.5%→2.5% (β) and 17.6%→1.1% (ε)). BVP Δ' outputs are unchanged; only the SLAYER matching (γ, Δ_eff) moves. GGJ untouched. Co-Authored-By: Claude Opus 5 --- src/InnerLayer/SLAYER/LayerInputs.jl | 18 +++++++++- src/InnerLayer/SLAYER/LayerParameters.jl | 26 ++++++++++++-- src/Tearing/Dispersion/SurfaceCoupling.jl | 12 ++++--- src/Tearing/Runner/HDF5Output.jl | 10 +++++- src/Tearing/Runner/Result.jl | 4 ++- src/Tearing/Runner/run_slayer.jl | 41 +++++++++++++++++++++++ test/runtests_slayer_runner.jl | 35 +++++++++++++++++++ 7 files changed, 137 insertions(+), 9 deletions(-) diff --git a/src/InnerLayer/SLAYER/LayerInputs.jl b/src/InnerLayer/SLAYER/LayerInputs.jl index 96177903d..8a408dd29 100644 --- a/src/InnerLayer/SLAYER/LayerInputs.jl +++ b/src/InnerLayer/SLAYER/LayerInputs.jl @@ -289,6 +289,20 @@ 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) with D_I = E + F + H − 1/4 (Glasser-Greene-Johnson 1975). + # A Mercier-unstable surface (D_I ≥ 0) has no real exponent; μ → 0 there, + # which makes the conversion factor K^(2μ) → 1 continuously (Δ' left raw). + k_ref_k = da_dpsi == 0.0 ? 1.0 : rs / da_dpsi + mu_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, @@ -304,7 +318,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, + mu_mercier=mu_k ) end return out diff --git a/src/InnerLayer/SLAYER/LayerParameters.jl b/src/InnerLayer/SLAYER/LayerParameters.jl index 5dbf9163f..28976e6d3 100644 --- a/src/InnerLayer/SLAYER/LayerParameters.jl +++ b/src/InnerLayer/SLAYER/LayerParameters.jl @@ -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) | +| `mu_mercier` | Mercier exponent μ = √(−D_I) governing the Δ' reference-length conversion (1/2 = slab/cylindrical value) | + +`k_ref` and `mu_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`. @@ -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 + mu_mercier::Float64 = 0.5 end # Allowed dc_type values for the critical-Δ offset. `:none` is the default @@ -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) + - `mu_mercier` -- Mercier exponent μ = √(−D_I) for the same conversion + (default `0.5`, the slab/cylindrical value at D_I = −1/4) # Resistivity kwargs @@ -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, + mu_mercier::Real=0.5) # Coulomb logarithm shared by the resistivity closure and τ_ee. lnLamb = coulomb_log_e(n_e, t_e; form=lnLambda_form) @@ -358,6 +379,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, mu_mercier=mu_mercier ) end diff --git a/src/Tearing/Dispersion/SurfaceCoupling.jl b/src/Tearing/Dispersion/SurfaceCoupling.jl index 271162c1a..0d57207ae 100644 --- a/src/Tearing/Dispersion/SurfaceCoupling.jl +++ b/src/Tearing/Dispersion/SurfaceCoupling.jl @@ -53,10 +53,14 @@ end surface_coupling(model::SLAYERModel, params::SLAYERParameters, dp_diag::Number; dc::Real=0.0) -> SurfaceCoupling -SLAYER convenience constructor. `scale` is set to `params.lu^(1/3)` so that -the dimensionless Δ from `riccati_f` is mapped to outer ψ-units before -subtraction from the Δ' diagonal. `tauk` is taken from `params.tauk` for use -by `MultiSurfaceCoupling` Q rescaling. +SLAYER convenience constructor. `scale` is set to `params.lu^(1/3)`, which +maps the dimensionless inner-layer Δ from `riccati_f` to the r_s-referenced +outer convention (S^(1/3) with S = τ_R/τ_H built on r_s; the Lundquist +number, `dc`, and the layer all share the `x̂ = (r−r_s)/r_s` reference +length). `dp_diag` must already be in that same r_s reference — the Tearing +runner converts the ψ_N-referenced BVP Δ' via `delta_prime_to_rs_reference` +before building couplings. `tauk` is taken from `params.tauk` for use by +`MultiSurfaceCoupling` Q rescaling. """ function surface_coupling(model::SLAYERModel, params::SLAYERParameters, dp_diag::Number; dc::Real=0.0) diff --git a/src/Tearing/Runner/HDF5Output.jl b/src/Tearing/Runner/HDF5Output.jl index ca4287147..b0f9110f3 100644 --- a/src/Tearing/Runner/HDF5Output.jl +++ b/src/Tearing/Runner/HDF5Output.jl @@ -95,6 +95,9 @@ const TEARING_H5_ANNOTATIONS = [ "PerSurface/d_beta" => (; long_name="β-weighted ion drift scale d_β", units="m", dims=("surface",)), "PerSurface/D_c_offset" => (; long_name="critical-Δ offset from χ_∥/χ_⊥ matching (Connor-Hastie-Helander 2015 Eq. 59)", dims=("surface",)), "PerSurface/D_c_type" => (; long_name="per-surface D_c prescription label", dims=("surface",)), + "PerSurface/k_ref" => (; long_name="reference-length ratio K = r_s·(dψ_N/dr) at each surface", dims=("surface",)), + "PerSurface/mu_mercier" => (; long_name="Mercier exponent μ = √(−D_I) at each surface", dims=("surface",)), + "PerSurface/delta_prime_conversion" => (; long_name="ψ_N → r_s reference-length factor K^(2μ) applied to each Δ' diagonal", dims=("surface",)), "PerSurface/E" => (; long_name="Glasser-Greene-Johnson coefficient E per surface", dims=("surface",)), "PerSurface/F" => (; long_name="Glasser-Greene-Johnson coefficient F per surface", dims=("surface",)), "PerSurface/G" => (; long_name="Glasser-Greene-Johnson coefficient G per surface", dims=("surface",)), @@ -103,7 +106,7 @@ const TEARING_H5_ANNOTATIONS = [ "PerSurface/M" => (; long_name="Glasser-Greene-Johnson coefficient M per surface", dims=("surface",)), "PerSurface/tau_A" => (; long_name="Alfvén time τ_A per surface (GGJ layer parameters)", units="s", dims=("surface",)), "PerSurface/dVdpsi" => (; long_name="dV/dψ_N at each surface", units="m^3", dims=("surface",)), - "PerSurface/Delta_prime_matrix" => (; long_name="full complex Δ' matrix coupling the rational surfaces", dims=("surface_row", "surface_col")), + "PerSurface/Delta_prime_matrix" => (; long_name="full complex Δ' matrix coupling the rational surfaces, as used in the matching (SLAYER path: r_s-referenced via K^(2μ); the ψ_N-referenced BVP matrix is SingularSurfaces/Delta_prime_matrix)", dims=("surface_row", "surface_col")), "Roots/Q_root" => (; long_name="complex dispersion-root normalized frequency Q (NaN = no root)", dims=("surface",)), "Roots/omega" => (; long_name="mode rotation angular frequency ω = Re(Q)/τ_k of each root", units="rad/s", dims=("surface",)), @@ -184,6 +187,11 @@ function _write_per_surface!(g, params::AbstractVector{SLAYERParameters}, ps["D_geo"] = Float64[p.dgeo_val for p in params] # Store dc_type per-surface as string array ps["D_c_type"] = String[String(p.dc_type) for p in params] + # Reference-length conversion applied to Δ' (ψ_N → r_s): K, μ, and the + # diagonal factor K^(2μ) actually multiplying each Δ'_kk. + ps["k_ref"] = Float64[p.k_ref for p in params] + ps["mu_mercier"] = Float64[p.mu_mercier for p in params] + ps["delta_prime_conversion"] = Float64[p.k_ref^(2 * p.mu_mercier) for p in params] ps["Delta_prime_matrix"] = dp_matrix return nothing diff --git a/src/Tearing/Runner/Result.jl b/src/Tearing/Runner/Result.jl index 0cffc21c9..d81508989 100644 --- a/src/Tearing/Runner/Result.jl +++ b/src/Tearing/Runner/Result.jl @@ -20,7 +20,9 @@ downstream inspection and HDF5 output. factor q of each analyzed surface, aligned with `params`. Empty when the analysis was built from bare parameters (`run_slayer_from_inputs` without the surface list), in which case the HDF5 writer skips them. - - `dp_matrix` -- outer-region Δ' matrix used in the analysis + - `dp_matrix` -- outer-region Δ' matrix used in the analysis. + SLAYER path: r_s-referenced (the ψ_N BVP matrix transformed by + `delta_prime_to_rs_reference`); GGJ path: the ψ_N matrix unchanged - `Q_root` -- tearing eigenvalue(s) in normalized Q * length `nsurfaces` in `:uncoupled` mode * length `1` in `:coupled` mode (global eigenvalue normalized by diff --git a/src/Tearing/Runner/run_slayer.jl b/src/Tearing/Runner/run_slayer.jl index 2a29c63a8..650761b3c 100644 --- a/src/Tearing/Runner/run_slayer.jl +++ b/src/Tearing/Runner/run_slayer.jl @@ -151,6 +151,39 @@ function _build_surface_coupling(model::GGJModel, params::GGJParameters, return surface_coupling(model, params, dp_diag) end +# --------------------------------------------------------------------- +# Reference-length conversion of the outer Δ' for the slab layer +# --------------------------------------------------------------------- +""" + delta_prime_to_rs_reference(dp_matrix, params) -> Matrix{ComplexF64} + +Convert the outer-region Δ' matrix from its ψ_N reference length (the +STRIDE/BVP convention: Frobenius coefficients normalized per unit Δψ_N) to +the r_s-based `x̂ = (r − r_s)/r_s` reference the slab layer works in +(Fitzpatrick 2023 convention — the same reference used by `dc_tmp` and by +the `S^(1/3)` Δ(Q) scale, both built on r_s). + +Near surface `k` the tearing-parity outer solution is +`A_L·|x|^(1/2−μ) + A_S·|x|^(1/2+μ)` with `μ = √(−D_I)` (Glasser, Wang & +Park 2016, Eq. 26). Rescaling the radial variable `x_ψ = K·x̂` with +`K = r_s·(dψ_N/dr)|_s` maps the coefficients as `Â_L = A_L·K^(1/2−μ)` and +`Â_S = A_S·K^(1/2+μ)`, so the response matrix (small coefficient at surface +`i` per unit large coefficient at surface `j`) transforms as + + Δ̂_ij = K_i^(1/2+μ_i) · Δ'_ij · K_j^(μ_j−1/2) + +whose diagonal is `K^(2μ)·Δ'_kk`; at D_I = −1/4 (μ = 1/2) this reduces to +the textbook `Δ̂ = r_s·Δ'_phys`. `K` and `μ` are carried per surface in +`SLAYERParameters.k_ref` / `.mu_mercier`; hand-built parameters default to +`k_ref = 1`, making the conversion the identity. +""" +function delta_prime_to_rs_reference(dp_matrix::AbstractMatrix, + params::AbstractVector) + dl = [p.k_ref^(0.5 + p.mu_mercier) for p in params] + dr = [p.k_ref^(p.mu_mercier - 0.5) for p in params] + return Diagonal(dl) * Matrix{ComplexF64}(dp_matrix) * Diagonal(dr) +end + # --------------------------------------------------------------------- # Core analysis entry point that takes pre-built parameters. # --------------------------------------------------------------------- @@ -194,6 +227,14 @@ function run_slayer_from_inputs(params::AbstractVector{<:InnerLayerParameters}, "(build_slayer_inputs for SLAYER, build_ggj_inputs for GGJ).") ) + # Slab-layer path: convert Δ' from its ψ_N reference length to the + # r_s-based convention shared by the layer Δ(Q) and the critical-Δ (see + # `delta_prime_to_rs_reference`). GGJ is genuinely toroidal/ψ-based + # (its `rescale_delta` handles inner→outer units natively) — no conversion. + if !_is_ggj(model) + dp = delta_prime_to_rs_reference(dp, params) + end + # The coupled determinant uses the reduced m×m (tearing-only) form, which # drops the interchange channel. For GGJ that channel carries the Glasser # interchange stabilization, so coupled-GGJ results omit real physics. diff --git a/test/runtests_slayer_runner.jl b/test/runtests_slayer_runner.jl index 0eacc18ed..fea52dc46 100644 --- a/test/runtests_slayer_runner.jl +++ b/test/runtests_slayer_runner.jl @@ -153,6 +153,41 @@ @test_throws ArgumentError run_slayer_from_inputs(params, bad_dp, c) end + @testset "delta_prime_to_rs_reference: ψ_N → r_s conversion" begin + # Two surfaces with distinct K and μ; the transform is + # Δ̂_ij = K_i^(1/2+μ_i) · Δ'_ij · K_j^(μ_j−1/2). + K1, mu1 = 0.9, 0.54 + K2, mu2 = 1.2, 0.60 + p1 = SLAYERParameters(; tau=1.0, lu=1e7, c_beta=0.1, D_norm=2.0, + P_perp=20.0, P_tor=10.0, Q_e=-1.0, Q_i=0.5, iota_e=2 / 3, + tauk=1e-4, tau_r=1.0, delta_n=1.0, rs=0.4, R0=1.7, bt=2.0, + sval_r=1.0, eta=2.5e-8, d_beta=4e-3, m=2, n=1, ising=1, + k_ref=K1, mu_mercier=mu1) + p2 = SLAYERParameters(; tau=1.0, lu=1e7, c_beta=0.1, D_norm=2.0, + P_perp=20.0, P_tor=10.0, Q_e=-1.0, Q_i=0.5, iota_e=2 / 3, + tauk=1e-4, tau_r=1.0, delta_n=1.0, rs=0.5, R0=1.7, bt=2.0, + sval_r=1.0, eta=2.5e-8, d_beta=4e-3, m=3, n=1, ising=2, + k_ref=K2, mu_mercier=mu2) + dp = ComplexF64[10.0+1im 2.0-0.5im; 3.0+0im 1.5+2im] + out = Runner.delta_prime_to_rs_reference(dp, [p1, p2]) + # Diagonal carries the scalar K^(2μ) + @test out[1, 1] ≈ K1^(2mu1) * dp[1, 1] + @test out[2, 2] ≈ K2^(2mu2) * dp[2, 2] + # Off-diagonals carry the split row/column factors + @test out[1, 2] ≈ K1^(0.5 + mu1) * K2^(mu2 - 0.5) * dp[1, 2] + @test out[2, 1] ≈ K2^(0.5 + mu2) * K1^(mu1 - 0.5) * dp[2, 1] + # At the slab point μ = 1/2 the diagonal factor is exactly K + pslab = SLAYERParameters(; tau=1.0, lu=1e7, c_beta=0.1, D_norm=2.0, + P_perp=20.0, P_tor=10.0, Q_e=-1.0, Q_i=0.5, iota_e=2 / 3, + tauk=1e-4, tau_r=1.0, delta_n=1.0, rs=0.4, R0=1.7, bt=2.0, + sval_r=1.0, eta=2.5e-8, d_beta=4e-3, m=2, n=1, ising=1, + k_ref=0.8, mu_mercier=0.5) + dp1 = ComplexF64[5.0+0im;;] + @test Runner.delta_prime_to_rs_reference(dp1, [pslab])[1, 1] ≈ 0.8 * 5.0 + # Default parameters (k_ref = 1) give the identity regardless of μ + @test Runner.delta_prime_to_rs_reference(dp, [_mk_params(), _mk_params()]) ≈ dp + end + @testset "run_slayer_from_inputs: coupled mode finds known root" begin # Build a 2-surface problem with a known coupled root by construction. p1 = _mk_params(; rs=0.5, lu=1.0e7, tauk=1.0e-4, Q_e=-1.0, Q_i=0.5, From d2494c6942664ab131eb25c5841a53ebbf39d1f9 Mon Sep 17 00:00:00 2001 From: d-burg Date: Thu, 20 Aug 2026 13:44:24 -0400 Subject: [PATCH 17/51] InnerLayer.SLAYER - NEW FEATURE - Add halfwidth and volume radial-label options to build_slayer_inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cylindrical layer model needs a single minor radius; on a shaped torus that label is ambiguous. Add rs_method options :halfwidth (midplane half-chord, the shift-free stand-in for the circular-theory flux label; reproduces it to ~1% on circular benchmarks) and :volume (cylinder-equivalent √(V/2π²R₀), the Rutherford-literature convention) alongside the existing :midplane default and :fsa. Every label feeds S, the r-based shear, W_d, and k_ref together, so each choice is self-consistent by construction. Programmatic API only — not exposed via TOML; the default and all TOML-driven results are unchanged. Co-Authored-By: Claude Opus 5 --- src/InnerLayer/SLAYER/LayerInputs.jl | 29 +++++++++++++++++++++++++--- test/runtests_slayer_inputs.jl | 18 +++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/InnerLayer/SLAYER/LayerInputs.jl b/src/InnerLayer/SLAYER/LayerInputs.jl index 8a408dd29..091dcb49a 100644 --- a/src/InnerLayer/SLAYER/LayerInputs.jl +++ b/src/InnerLayer/SLAYER/LayerInputs.jl @@ -16,7 +16,7 @@ using ..Utilities: KineticProfiles using ...Utilities.NeoclassicalResistivity: NeoResistivityModel, SpitzerModel, coulomb_log_e, nu_star_e -using FastInterpolations: DerivOp +using FastInterpolations: DerivOp, integrate """ surface_minor_radius(equil, psi; theta=0.0) -> Float64 @@ -114,6 +114,20 @@ 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 + (S, r-based shear, W_d, and the Δ' reference-length factor `k_ref` all + follow it together, so every choice is self-consistent). `:midplane` + (default): outboard-midplane chord from the magnetic axis — natural for + comparison with midplane diagnostics. `:halfwidth`: midplane half-chord + `(R_out − R_in)/2` — shift-free, the closest stand-in for the circular + flux label the Fitzpatrick layer formulas are derived in (reproduces the + cylindrical-theory label to ~1% on circular benchmark equilibria). + `:fsa`: θ-mean surface radius. `:volume`: cylinder-equivalent label + `√(V(ψ)/(2π²R₀))`, the Rutherford-literature convention. On shaped + equilibria the labels agree at low-q surfaces (~±3% in growth rate at + q=2) and diverge strongly near the edge, where the slab-layer matching + is label-ambiguous regardless of choice. 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()`, @@ -158,7 +172,9 @@ function build_slayer_inputs(equil, sings, profiles::KineticProfiles; # Minor-radius extractor: `:midplane` = outboard-midplane chord # (original behavior); `:fsa` = θ-mean of √rzphi_rsquared, the - # flux-surface-averaged minor radius. + # flux-surface-averaged minor radius; `:halfwidth` = midplane half-chord + # (R_out − R_in)/2, the shift-free circular flux label; `:volume` = the + # cylinder-equivalent label r_V = √(V(ψ)/(2π²R₀)). _rs_at(ψ) = if rs_method === :fsa integrand(θ) = sqrt(equil.rzphi_rsquared((Float64(ψ), Float64(θ)))) @@ -168,11 +184,18 @@ function build_slayer_inputs(equil, sings, profiles::KineticProfiles; s += integrand((k - 0.5) / N) end s / N + elseif rs_method === :halfwidth + 0.5 * (surface_minor_radius(equil, ψ; theta=0.0) + + surface_minor_radius(equil, ψ; theta=0.5)) + elseif rs_method === :volume + lo = 1e-4 + V = integrate(equil.profiles.dVdpsi_spline, lo, Float64(ψ)) + sqrt(max(V, 0.0) / (2π^2 * equil.ro)) else surface_minor_radius(equil, ψ; theta=theta) end _da_dpsi_at(ψ) = - if rs_method === :fsa + if rs_method !== :midplane # central finite difference on _rs_at h = 1e-5 lo = ψ - h diff --git a/test/runtests_slayer_inputs.jl b/test/runtests_slayer_inputs.jl index 55d6124e9..159406380 100644 --- a/test/runtests_slayer_inputs.jl +++ b/test/runtests_slayer_inputs.jl @@ -158,6 +158,24 @@ @test sl_var[1].P_perp ≈ sl_s[1].P_perp * 6.0 / 2.0 rtol = 1e-10 end + @testset "build_slayer_inputs: rs_method radial labels are self-consistent" begin + sings = [_mk_sing(psi=0.5, q=2.4, q1=1.2, m=2, n=1)] + got = Dict{Symbol,Any}() + for rsm in (:midplane, :halfwidth, :fsa, :volume) + sl = build_slayer_inputs(equil, sings, profiles; bt=2.0, dr_val=0.0, rs_method=rsm) + got[rsm] = sl[1] + @test isfinite(sl[1].rs) && sl[1].rs > 0 + @test isfinite(sl[1].k_ref) && sl[1].k_ref > 0 + @test isfinite(sl[1].sval_r) + end + # The outboard-shifted axis compresses the outboard chord, so the shift-free + # half-chord is at least the outboard axis-to-edge distance. + @test got[:halfwidth].rs >= got[:midplane].rs + # Labels genuinely differ (each self-consistent set has its own rs, S, k_ref) + @test got[:fsa].rs != got[:midplane].rs + @test got[:volume].lu != got[:midplane].lu + end + @testset "build_slayer_inputs: dc_type propagates and dr_val activates offset" begin sings = [_mk_sing(psi=0.5, q=2.4, q1=1.2, m=2, n=1)] From b68f0310c57c20e87a08b88375488b519fe8d458 Mon Sep 17 00:00:00 2001 From: d-burg Date: Thu, 20 Aug 2026 19:11:35 -0400 Subject: [PATCH 18/51] InnerLayer.SLAYER - FEATURE - Add the Fitzpatrick flux radial label and unify the label plumbing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/InnerLayer/InnerLayer.jl | 4 +- src/InnerLayer/SLAYER/LayerInputs.jl | 200 ++++++++++++++++----------- src/InnerLayer/SLAYER/SLAYER.jl | 2 +- test/runtests_slayer_inputs.jl | 23 ++- 4 files changed, 140 insertions(+), 89 deletions(-) diff --git a/src/InnerLayer/InnerLayer.jl b/src/InnerLayer/InnerLayer.jl index 43d1fc214..ddbde66c1 100644 --- a/src/InnerLayer/InnerLayer.jl +++ b/src/InnerLayer/InnerLayer.jl @@ -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 @@ -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 diff --git a/src/InnerLayer/SLAYER/LayerInputs.jl b/src/InnerLayer/SLAYER/LayerInputs.jl index 091dcb49a..c649aeb01 100644 --- a/src/InnerLayer/SLAYER/LayerInputs.jl +++ b/src/InnerLayer/SLAYER/LayerInputs.jl @@ -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, integrate +using FastInterpolations: DerivOp, integrate, cubic_interp, cumulative_integrate, ExtendExtrap """ surface_minor_radius(equil, psi; theta=0.0) -> Float64 @@ -32,33 +32,123 @@ 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₀)`. + 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) + # g = F/(B0 R0) departs from 1 by ~3% on a DIII-D-like deck and ~0.8% on a circular + # one, so carry it 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 + 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 """ @@ -114,19 +204,11 @@ 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 - (S, r-based shear, W_d, and the Δ' reference-length factor `k_ref` all - follow it together, so every choice is self-consistent). `:midplane` - (default): outboard-midplane chord from the magnetic axis — natural for - comparison with midplane diagnostics. `:halfwidth`: midplane half-chord - `(R_out − R_in)/2` — shift-free, the closest stand-in for the circular - flux label the Fitzpatrick layer formulas are derived in (reproduces the - cylindrical-theory label to ~1% on circular benchmark equilibria). - `:fsa`: θ-mean surface radius. `:volume`: cylinder-equivalent label - `√(V(ψ)/(2π²R₀))`, the Rutherford-literature convention. On shaped - equilibria the labels agree at low-q surfaces (~±3% in growth rate at - q=2) and diverge strongly near the edge, where the slab-layer matching - is label-ambiguous regardless of choice. Not exposed via TOML — + - `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). @@ -170,47 +252,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; `:halfwidth` = midplane half-chord - # (R_out − R_in)/2, the shift-free circular flux label; `:volume` = the - # cylinder-equivalent label r_V = √(V(ψ)/(2π²R₀)). - _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 - elseif rs_method === :halfwidth - 0.5 * (surface_minor_radius(equil, ψ; theta=0.0) + - surface_minor_radius(equil, ψ; theta=0.5)) - elseif rs_method === :volume - lo = 1e-4 - V = integrate(equil.profiles.dVdpsi_spline, lo, Float64(ψ)) - sqrt(max(V, 0.0) / (2π^2 * equil.ro)) - else - surface_minor_radius(equil, ψ; theta=theta) - end - _da_dpsi_at(ψ) = - if rs_method !== :midplane - # 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 diff --git a/src/InnerLayer/SLAYER/SLAYER.jl b/src/InnerLayer/SLAYER/SLAYER.jl index ece897c15..033f71eca 100644 --- a/src/InnerLayer/SLAYER/SLAYER.jl +++ b/src/InnerLayer/SLAYER/SLAYER.jl @@ -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 diff --git a/test/runtests_slayer_inputs.jl b/test/runtests_slayer_inputs.jl index 159406380..a5a6da414 100644 --- a/test/runtests_slayer_inputs.jl +++ b/test/runtests_slayer_inputs.jl @@ -42,8 +42,8 @@ @test r1 > 0 end - @testset "surface_da_dpsi: FD agrees with numerical derivative" begin - # Reference via a tighter FD + @testset "surface_da_dpsi: agrees with FD reference" begin + # The analytic ψ-derivative must reproduce a tight FD of the minor radius. for psi in (0.1, 0.4, 0.7) h_ref = 1e-4 r_p = surface_minor_radius(equil, psi + h_ref) @@ -53,16 +53,24 @@ end end - @testset "surface_da_dpsi: one-sided near boundaries" begin - # Near ψ=0 and ψ=1, the function falls back to one-sided FD and - # should still produce a finite positive number (minor radius is - # still increasing). + @testset "surface_da_dpsi: finite near the boundaries" begin + # The analytic form needs no clamping: near ψ=0 and ψ=1 it still returns a + # finite positive number (large near the axis, where a ~ √ψ). d_near_axis = surface_da_dpsi(equil, 1e-6) d_near_edge = surface_da_dpsi(equil, 1.0 - 1e-6) @test isfinite(d_near_axis) && d_near_axis > 0 @test isfinite(d_near_edge) && d_near_edge > 0 end + @testset "radial_label: analytic derivatives match FD" begin + for rsm in (:midplane, :halfwidth, :fsa, :volume, :flux) + r_at, dr_at = radial_label(equil; rs_method=rsm) + h = 1e-5 + ref = (r_at(0.5 + h) - r_at(0.5 - h)) / (2h) + @test dr_at(0.5) ≈ ref rtol = 1e-3 + end + end + @testset "build_slayer_inputs: returns correct per-surface data" begin sings = [_mk_sing(psi=0.3, q=2.0, q1=1.5, m=2, n=1), _mk_sing(psi=0.6, q=3.0, q1=2.5, m=3, n=1)] @@ -161,7 +169,7 @@ @testset "build_slayer_inputs: rs_method radial labels are self-consistent" begin sings = [_mk_sing(psi=0.5, q=2.4, q1=1.2, m=2, n=1)] got = Dict{Symbol,Any}() - for rsm in (:midplane, :halfwidth, :fsa, :volume) + for rsm in (:midplane, :halfwidth, :fsa, :volume, :flux) sl = build_slayer_inputs(equil, sings, profiles; bt=2.0, dr_val=0.0, rs_method=rsm) got[rsm] = sl[1] @test isfinite(sl[1].rs) && sl[1].rs > 0 @@ -174,6 +182,7 @@ # Labels genuinely differ (each self-consistent set has its own rs, S, k_ref) @test got[:fsa].rs != got[:midplane].rs @test got[:volume].lu != got[:midplane].lu + @test got[:flux].rs != got[:midplane].rs end @testset "build_slayer_inputs: dc_type propagates and dr_val activates offset" begin From 29df8700e72670cdc92f32a21eae608d320939af Mon Sep 17 00:00:00 2001 From: d-burg Date: Wed, 26 Aug 2026 20:09:06 -0400 Subject: [PATCH 19/51] InnerLayer.SLAYER - BUGFIX - Guard the Delta-prime reference-length factor against unusable da/dpsi The k_ref guard tested only for an exact zero, so a non-finite da/dpsi (reachable where the interpolant's r^2 is clamped non-positive on extrapolated surfaces) silently zeroed the converted Delta' diagonal, and a negative one raised DomainError from the non-integer exponent. Fall back to the identity conversion with a warning instead. Also corrects the Mercier-unstable comment: clamping mu to 0 leaves only the diagonal raw and discards a genuine phase, since the exponents are 1/2 +/- i|mu| there. Co-Authored-By: Claude Opus 5 --- src/InnerLayer/SLAYER/LayerInputs.jl | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/InnerLayer/SLAYER/LayerInputs.jl b/src/InnerLayer/SLAYER/LayerInputs.jl index c649aeb01..93580bf22 100644 --- a/src/InnerLayer/SLAYER/LayerInputs.jl +++ b/src/InnerLayer/SLAYER/LayerInputs.jl @@ -356,9 +356,19 @@ function build_slayer_inputs(equil, sings, profiles::KineticProfiles; # Reference-length conversion inputs for the outer Δ': K = r_s·(dψ_N/dr)|_s # and μ = √(−D_I) with D_I = E + F + H − 1/4 (Glasser-Greene-Johnson 1975). - # A Mercier-unstable surface (D_I ≥ 0) has no real exponent; μ → 0 there, - # which makes the conversion factor K^(2μ) → 1 continuously (Δ' left raw). - k_ref_k = da_dpsi == 0.0 ? 1.0 : rs / da_dpsi + # A Mercier-unstable surface (D_I ≥ 0) has exponents 1/2 ± i|μ|, so the factor is + # genuinely complex there; μ is clamped to 0, which leaves the Δ' diagonal raw and + # discards that phase. The off-diagonals still carry K_i^(1/2)·K_j^(−1/2). + # K = 1 (identity conversion) whenever da/dψ is not a usable positive number: zero + # or non-finite would zero or NaN the Δ' diagonal, and a negative base raises + # DomainError under the non-integer exponent. + 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 mu_k = if rg === nothing @warn("build_slayer_inputs: sing.restype not populated; using the " * "slab Mercier exponent μ = 1/2 for the Δ' reference-length " * From 53fffc0d0dfb7f66cc9a91230413abc6dba0daf6 Mon Sep 17 00:00:00 2001 From: d-burg Date: Fri, 28 Aug 2026 02:04:54 -0400 Subject: [PATCH 20/51] InnerLayer.SLAYER - BUGFIX - Restore the toroidal mode number in the diamagnetic frequencies _omega_star_at returns (dp/dpsi)/(e n_e), the n = 1 diamagnetic frequency. The physical omega_* = k_theta v_* carries the poloidal mode number; written in flux coordinates dpsi/dr = r B_phi / q absorbs the q in m = n q and leaves an explicit factor n. Nothing downstream restored it, so Q_e and Q_i were low by n at every rational surface and drift stabilization was underestimated for n >= 2. Confirmed against Fitzpatrick's TJ, which carries the mode number explicitly in both the layer-parameter notes and Rational.cpp; the ratio to the GPEC form is m/(q g) = n. The neighbouring quantities check out unchanged: TJ's tau_H reduces to R0/(n s V_A), matching tau_h exactly. iota_e = Q_e/(Q_e - Q_i) is a ratio, so the factor cancels there and it was already correct. Every shipped deck is n = 1, where the restored factor is exactly 1.0 and the arithmetic is bit-identical. Co-Authored-By: Claude Opus 5 --- src/InnerLayer/SLAYER/LayerInputs.jl | 19 ++++++++++++------- test/runtests_slayer_inputs.jl | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/InnerLayer/SLAYER/LayerInputs.jl b/src/InnerLayer/SLAYER/LayerInputs.jl index 96177903d..70a93ff1b 100644 --- a/src/InnerLayer/SLAYER/LayerInputs.jl +++ b/src/InnerLayer/SLAYER/LayerInputs.jl @@ -217,20 +217,25 @@ function build_slayer_inputs(equil, sings, profiles::KineticProfiles; da_dpsi = _da_dpsi_at(psi) sval_r = r_based_shear(rs, q, q1, da_dpsi) + # Resonant (m, n): take the first element of the mode-number vectors. + # Parallel-FM `sing.m`/`sing.n` hold exactly one entry each; ideal + # DCON may hold multiple — we pick the first and document the choice. + m_res = sing.m[1] + n_res = sing.n[1] + prof = profiles(psi) # Override ω_*e, ω_*i with spline-derivative values when requested. + # ω_* = k_θ·v_* carries the poloidal mode number; writing it in flux + # coordinates absorbs q (dψ/dr = r·B_φ/q), leaving ω_* = n·(dp/dψ)/(e·n_e). + # `_omega_star_at` returns the n = 1 value, so restore the factor n here. + # Values supplied through `profiles` are taken to be physical already. ω_e_use, ω_i_use = if compute_omega_star - _omega_star_at(psi) + ωe1, ωi1 = _omega_star_at(psi) + (n_res * ωe1, n_res * ωi1) else (prof.omega_e, prof.omega_i) end - # Resonant (m, n): take the first element of the mode-number vectors. - # Parallel-FM `sing.m`/`sing.n` hold exactly one entry each; ideal - # DCON may hold multiple — we pick the first and document the choice. - m_res = sing.m[1] - n_res = sing.n[1] - # Pull geometric trapped-fraction inputs from ResistGeometry when # available (populated by ForceFreeStates.resist_eval_all!); else # fall back to nothing and let slayer_parameters compute them from diff --git a/test/runtests_slayer_inputs.jl b/test/runtests_slayer_inputs.jl index 55d6124e9..f0b73fa77 100644 --- a/test/runtests_slayer_inputs.jl +++ b/test/runtests_slayer_inputs.jl @@ -183,4 +183,22 @@ @test sl isa Vector{SLAYERParameters} @test isempty(sl) end + + @testset "build_slayer_inputs: omega_star carries the toroidal mode number" begin + # ω_* = k_θ·v_* scales with the poloidal mode number; written in flux + # coordinates the q is absorbed by dψ/dr = r·B_φ/q, leaving + # ω_* = n·(dp/dψ)/(e·n_e). Resolve the same surface at n = 1 and n = 2: + # Q/tauk = -ω_* must double, while iota_e = Q_e/(Q_e - Q_i) is a ratio + # and must not move. Asserted through the returned parameters so the + # check survives a refactor of where the factor is applied. + s1 = [_mk_sing(psi=0.3, q=2.0, q1=1.5, m=2, n=1)] + s2 = [_mk_sing(psi=0.3, q=2.0, q1=1.5, m=4, n=2)] + sl1 = build_slayer_inputs(equil, s1, profiles; bt=2.0, dr_val=0.0) + sl2 = build_slayer_inputs(equil, s2, profiles; bt=2.0, dr_val=0.0) + + @test sl2[1].Q_e / sl2[1].tauk ≈ 2 * (sl1[1].Q_e / sl1[1].tauk) rtol = 1e-12 + @test sl2[1].Q_i / sl2[1].tauk ≈ 2 * (sl1[1].Q_i / sl1[1].tauk) rtol = 1e-12 + @test sl2[1].iota_e ≈ sl1[1].iota_e rtol = 1e-12 + end + end From a5dea9fedebece5d2902ed54088f0932f01e5165 Mon Sep 17 00:00:00 2001 From: d-burg Date: Fri, 28 Aug 2026 02:07:42 -0400 Subject: [PATCH 21/51] InnerLayer.SLAYER - BUGFIX! - Normalize D by the electron diamagnetic share, not the temperature ratio D_norm used sqrt(tau/(1+tau)) with tau = T_i/T_e. Fitzpatrick defines the ion-sound-radius normalization as D = S^(1/3) iota_e^(1/2) (d_beta/r_s), and iota_e reduces to 1/(1+tau), not tau/(1+tau) -- the same expression with the electron and ion temperatures transposed. The two agree only at T_i = T_e. Use the iota_e that slayer_parameters already computes from omega_*e and omega_*i. That is Fitzpatrick's own definition, iota_e = Q_e/(Q_e - Q_i), and it is strictly more general than either closed form: it carries the ratio of the electron and ion pressure-gradient scale lengths rather than assuming they match. Guard against a non-positive iota_e so a same-sign diamagnetic input raises a clear error instead of a DomainError under the square root. Results move on any deck with T_i != T_e, including the shipped SLAYER deck. Co-Authored-By: Claude Opus 5 --- src/InnerLayer/SLAYER/LayerParameters.jl | 20 ++++++++++++++++++-- test/runtests_slayer_params.jl | 5 +++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/InnerLayer/SLAYER/LayerParameters.jl b/src/InnerLayer/SLAYER/LayerParameters.jl index 9718ad666..f8edd7306 100644 --- a/src/InnerLayer/SLAYER/LayerParameters.jl +++ b/src/InnerLayer/SLAYER/LayerParameters.jl @@ -27,7 +27,7 @@ de-normalization. The parametrization uses `P_perp`, `P_tor`, and | `tau` | T_i / T_e | | `lu` | Lundquist number S = τ_R / τ_H | | `c_beta` | Compressibility √(β_local / (1 + β_local)) | -| `D_norm` | (d_β/r_s) · S^(1/3) · √(τ/(1+τ)) (Fitzpatrick normalized scale) | +| `D_norm` | (d_β/r_s) · S^(1/3) · √ι_e (Fitzpatrick normalized scale) | | `P_perp` | Perpendicular Prandtl number τ_R / τ_⊥ | | `P_tor` | Toroidal-direction Prandtl number τ_R / τ_‖tor | | `Q_e` | Normalized electron diamagnetic: −tauk · ω_*e | @@ -329,6 +329,19 @@ function slayer_parameters(; ) ) iota_e = Q_e / Q_e_minus_Q_i + # D below takes sqrt(iota_e). iota_e lies in (0,1) whenever ω_*e and ω_*i have + # opposite signs — the physical case and the convention stated above. Surface a + # negative value as a clear error rather than a bare DomainError. + iota_e > 0 || + throw( + ArgumentError( + "slayer_parameters: iota_e = Q_e/(Q_e - Q_i) = $iota_e is " * + "not positive, so the ion-sound-radius normalization " * + "D_norm = (d_beta/r_s)·S^(1/3)·sqrt(iota_e) is undefined. " * + "Check the diamagnetic-frequency inputs ω_*e, ω_*i — they " * + "are expected to carry opposite signs." + ) + ) # Plasma beta and compressibility lbeta = (5.0 / 3.0) * MU_0 * n_e * E_CHG * (t_e + t_i) / bt^2 @@ -342,7 +355,10 @@ function slayer_parameters(; # Normalized beta-related width and Δ-normalization d_beta = c_beta * d_i - D_norm = (d_beta / rs) * lu^(1.0 / 3.0) * sqrt(tau / (1.0 + tau)) + # D = S^(1/3)·iota_e^(1/2)·(d_beta/r_s). iota_e is the electron share of the + # total diamagnetic frequency; it reduces to 1/(1+tau) only when the electron + # and ion pressure-gradient scale lengths match, so use the computed value. + D_norm = (d_beta / rs) * lu^(1.0 / 3.0) * sqrt(iota_e) delta_n = lu^(1.0 / 3.0) / rs # Critical-Δ offset from chi_parallel matching diff --git a/test/runtests_slayer_params.jl b/test/runtests_slayer_params.jl index 330ba7297..c6f42a531 100644 --- a/test/runtests_slayer_params.jl +++ b/test/runtests_slayer_params.jl @@ -83,8 +83,9 @@ @test p.P_perp ≈ p.P_tor @test p.P_perp > 0 - # D_norm = (d_β/r_s)·S^(1/3)·√(τ/(1+τ)) - D_norm_expected = (p.d_beta / p.rs) * p.lu^(1 / 3) * sqrt(p.tau / (1 + p.tau)) + # D_norm = (d_β/r_s)·S^(1/3)·√ι_e — the electron share of the total + # diamagnetic frequency, not the temperature ratio. + D_norm_expected = (p.d_beta / p.rs) * p.lu^(1 / 3) * sqrt(p.iota_e) @test p.D_norm ≈ D_norm_expected rtol = 1e-12 # delta_n = S^(1/3)/r_s From 28a04256c0170fe1b972292c5aeb9ec85ba4cad7 Mon Sep 17 00:00:00 2001 From: d-burg Date: Fri, 28 Aug 2026 13:23:33 -0400 Subject: [PATCH 22/51] InnerLayer.SLAYER - MINOR - Tighten the omega-star commentary Co-Authored-By: Claude Fable 5 --- src/InnerLayer/SLAYER/LayerInputs.jl | 8 +++----- test/runtests_slayer_inputs.jl | 10 ++++------ 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/InnerLayer/SLAYER/LayerInputs.jl b/src/InnerLayer/SLAYER/LayerInputs.jl index 70a93ff1b..294090295 100644 --- a/src/InnerLayer/SLAYER/LayerInputs.jl +++ b/src/InnerLayer/SLAYER/LayerInputs.jl @@ -224,11 +224,9 @@ function build_slayer_inputs(equil, sings, profiles::KineticProfiles; n_res = sing.n[1] prof = profiles(psi) - # Override ω_*e, ω_*i with spline-derivative values when requested. - # ω_* = k_θ·v_* carries the poloidal mode number; writing it in flux - # coordinates absorbs q (dψ/dr = r·B_φ/q), leaving ω_* = n·(dp/dψ)/(e·n_e). - # `_omega_star_at` returns the n = 1 value, so restore the factor n here. - # Values supplied through `profiles` are taken to be physical already. + # Override ω_*e, ω_*i with spline-derivative values when requested. In flux coordinates + # ω_* = n·(dp/dψ)/(e·n_e); `_omega_star_at` returns the n = 1 value, so restore the + # factor n here. Values supplied through `profiles` are taken to be physical already. ω_e_use, ω_i_use = if compute_omega_star ωe1, ωi1 = _omega_star_at(psi) (n_res * ωe1, n_res * ωi1) diff --git a/test/runtests_slayer_inputs.jl b/test/runtests_slayer_inputs.jl index f0b73fa77..d4d198e20 100644 --- a/test/runtests_slayer_inputs.jl +++ b/test/runtests_slayer_inputs.jl @@ -185,12 +185,10 @@ end @testset "build_slayer_inputs: omega_star carries the toroidal mode number" begin - # ω_* = k_θ·v_* scales with the poloidal mode number; written in flux - # coordinates the q is absorbed by dψ/dr = r·B_φ/q, leaving - # ω_* = n·(dp/dψ)/(e·n_e). Resolve the same surface at n = 1 and n = 2: - # Q/tauk = -ω_* must double, while iota_e = Q_e/(Q_e - Q_i) is a ratio - # and must not move. Asserted through the returned parameters so the - # check survives a refactor of where the factor is applied. + # In flux coordinates ω_* = n·(dp/dψ)/(e·n_e): resolving the same surface at n = 1 + # and n = 2 must double Q/tauk = -ω_*, while the ratio iota_e must not move. + # Asserted through the returned parameters so the check survives a refactor of + # where the factor is applied. s1 = [_mk_sing(psi=0.3, q=2.0, q1=1.5, m=2, n=1)] s2 = [_mk_sing(psi=0.3, q=2.0, q1=1.5, m=4, n=2)] sl1 = build_slayer_inputs(equil, s1, profiles; bt=2.0, dr_val=0.0) From 8df7535e3c8b6d487f223167ef838ca881602c1b Mon Sep 17 00:00:00 2001 From: d-burg Date: Fri, 28 Aug 2026 13:23:33 -0400 Subject: [PATCH 23/51] InnerLayer.SLAYER - MINOR - Deduplicate the iota_e normalization commentary Co-Authored-By: Claude Fable 5 --- src/InnerLayer/SLAYER/LayerParameters.jl | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/InnerLayer/SLAYER/LayerParameters.jl b/src/InnerLayer/SLAYER/LayerParameters.jl index f8edd7306..15bc6aac1 100644 --- a/src/InnerLayer/SLAYER/LayerParameters.jl +++ b/src/InnerLayer/SLAYER/LayerParameters.jl @@ -355,9 +355,8 @@ function slayer_parameters(; # Normalized beta-related width and Δ-normalization d_beta = c_beta * d_i - # D = S^(1/3)·iota_e^(1/2)·(d_beta/r_s). iota_e is the electron share of the - # total diamagnetic frequency; it reduces to 1/(1+tau) only when the electron - # and ion pressure-gradient scale lengths match, so use the computed value. + # iota_e is the electron share of the total diamagnetic frequency; it reduces to + # 1/(1+tau) only when the electron and ion pressure-gradient scale lengths match. D_norm = (d_beta / rs) * lu^(1.0 / 3.0) * sqrt(iota_e) delta_n = lu^(1.0 / 3.0) / rs From 63dcc81132a5b9213165a22ecd8463b9cee2deee Mon Sep 17 00:00:00 2001 From: d-burg Date: Fri, 28 Aug 2026 13:23:57 -0400 Subject: [PATCH 24/51] InnerLayer.SLAYER - MINOR - Compress the radial-label commentary Co-Authored-By: Claude Fable 5 --- src/InnerLayer/SLAYER/LayerInputs.jl | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/src/InnerLayer/SLAYER/LayerInputs.jl b/src/InnerLayer/SLAYER/LayerInputs.jl index 93580bf22..b8806aee9 100644 --- a/src/InnerLayer/SLAYER/LayerInputs.jl +++ b/src/InnerLayer/SLAYER/LayerInputs.jl @@ -95,8 +95,7 @@ function radial_label(equil; rs_method::Symbol=:midplane, theta::Real=0.0) R0f = Float64(equil.ro) psiof = Float64(equil.psio) xs_f = collect(Float64, equil.profiles.xs) - # g = F/(B0 R0) departs from 1 by ~3% on a DIII-D-like deck and ~0.8% on a circular - # one, so carry it rather than assuming g = 1: r² = 2∫(q/g)dψ_p/B0. + # 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))) @@ -122,6 +121,8 @@ function radial_label(equil; rs_method::Symbol=:midplane, theta::Real=0.0) 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 @@ -354,14 +355,10 @@ 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) with D_I = E + F + H − 1/4 (Glasser-Greene-Johnson 1975). - # A Mercier-unstable surface (D_I ≥ 0) has exponents 1/2 ± i|μ|, so the factor is - # genuinely complex there; μ is clamped to 0, which leaves the Δ' diagonal raw and - # discards that phase. The off-diagonals still carry K_i^(1/2)·K_j^(−1/2). - # K = 1 (identity conversion) whenever da/dψ is not a usable positive number: zero - # or non-finite would zero or NaN the Δ' diagonal, and a negative base raises - # DomainError under the non-integer exponent. + # Reference-length conversion inputs for the outer Δ': K = r_s·(dψ_N/dr)|_s and + # μ = √(−D_I) (Glasser-Greene-Johnson 1975), 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 From 8ae06cd23568e3a774bb986fd5e12e600c6c2e7a Mon Sep 17 00:00:00 2001 From: d-burg Date: Fri, 28 Aug 2026 14:40:09 -0400 Subject: [PATCH 25/51] Regression - BUGFIX - Materialize override decks beside the example so relative paths resolve An [overrides] case copied its deck to a system temp dir, so a deck whose gpec.toml references files by relative path (the SLAYER deck's profile_file points into a sibling example) lost those references and the run failed. Copy the deck to a throwaway sibling of the example dir instead, and remove that dir itself on cleanup rather than its parent. Co-Authored-By: Claude Opus 5 --- regression-harness/src/runner.jl | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/regression-harness/src/runner.jl b/regression-harness/src/runner.jl index ed7e5f4d5..5ced56890 100644 --- a/regression-harness/src/runner.jl +++ b/regression-harness/src/runner.jl @@ -27,15 +27,17 @@ end Materialize the directory GPEC will actually run in. With no `overrides`, the example deck runs in place (returns it untouched). With overrides, -the deck is copied to a throwaway temp dir and the named gpec.toml keys are patched there, -so one shared example can serve several cases (e.g. a collisionless variant via -`"KineticForces.nutype" => "zero"`). Override keys are dotted paths into the TOML; missing -intermediate tables are created. Returns `(rundir, is_temp)`; the caller removes the temp -tree when `is_temp`. +the deck is copied to a throwaway sibling of the example dir and the named gpec.toml keys are +patched there, so one shared example can serve several cases (e.g. a collisionless variant via +`"KineticForces.nutype" => "zero"`). The copy sits beside the original so relative file +references in the deck (e.g. `SLAYER.profile_file = "..//..."`) still resolve. +Override keys are dotted paths into the TOML; missing intermediate tables are created. +Returns `(rundir, is_temp)`; the caller removes `rundir` when `is_temp`. """ function _materialize_rundir(example_path::String, overrides::Dict{String,Any}) isempty(overrides) && return (example_path, false) - rundir = joinpath(mktempdir(), "deck") + rundir = joinpath(dirname(example_path), ".regress-override-" * basename(example_path)) + rm(rundir; recursive=true, force=true) # stale leftover from a crashed run cp(example_path, rundir) rm(joinpath(rundir, "gpec.h5"); force=true) # drop any stale output copied along cfg = TOML.parsefile(joinpath(rundir, "gpec.toml")) @@ -523,7 +525,7 @@ function run_local(db::SQLite.DB, case_spec::CaseSpec, repo_root::String; rm(runinfo_file; force=true) end if rundir_is_temp && rundir !== nothing - rm(dirname(rundir); recursive=true, force=true) + rm(rundir; recursive=true, force=true) end end end @@ -647,7 +649,7 @@ function run_at_commit(db::SQLite.DB, commit_hash::String, ref_name::String, rm(runinfo_file; force=true) end if rundir_is_temp && rundir !== nothing - rm(dirname(rundir); recursive=true, force=true) + rm(rundir; recursive=true, force=true) end if own_worktree && worktree_path !== nothing remove_worktree(worktree_path, repo_root) From 21fd7240ee8db27abf9e7c01e6bb932e7e9d9fca Mon Sep 17 00:00:00 2001 From: d-burg Date: Fri, 28 Aug 2026 14:40:09 -0400 Subject: [PATCH 26/51] Regression - TEST - Add the n=2 SLAYER case guarding the 3/2 surface and the Q_e n-scaling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No SLAYER case ran above n = 1, so the n-dependence of the layer build — the diamagnetic frequencies omega_* proportional to n feeding Q_e/Q_i — was invisible to the harness; the omega-star factor restored on this branch could be silently reverted without any case moving. Reuse the n=1 deck via [overrides] at nn = 2, where qmin = 1.204 puts the 3/2 surface in the plasma, and track Q_e/Q_i directly alongside the n=1 case's quantity set with roots pinned on 3/2, 4/2, 5/2. Co-Authored-By: Claude Opus 5 --- regression-harness/cases/diiid_slayer_n2.toml | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 regression-harness/cases/diiid_slayer_n2.toml diff --git a/regression-harness/cases/diiid_slayer_n2.toml b/regression-harness/cases/diiid_slayer_n2.toml new file mode 100644 index 000000000..3444a51db --- /dev/null +++ b/regression-harness/cases/diiid_slayer_n2.toml @@ -0,0 +1,173 @@ +# Regression case: DIII-D-like H-mode n=2 SLAYER tearing-mode growth rates. +# Reuses the n=1 SLAYER deck via [overrides] at toroidal mode number 2, where the deck's +# qmin = 1.204 puts the 3/2 surface in the plasma. Exists to guard the n-dependence of the +# layer build — in particular the diamagnetic frequencies ω_* ∝ n feeding Q_e/Q_i, which +# no n=1 case can constrain — with the inner rationals 3/2, 4/2, 5/2 as the tracked roots. +# Each [quantities.*] block names an HDF5 path in the run output, how to extract it, +# and the noise floor below which a difference is treated as zero. +[case] +name = "diiid_slayer_n2" +description = "DIII-D-like H-mode equilibrium, n=2, SLAYER tearing-mode analysis guarding the 3/2 surface and the n-scaling of Q_e/Q_i (reuses the n=1 deck via overrides)" +example_dir = "examples/DIIID-like_SLAYER_example" + +# Run the shared SLAYER deck at n = 2 (no separate n=2 example directory). +[overrides] +"ForceFreeStates.nn_low" = 2 +"ForceFreeStates.nn_high" = 2 + +# Per-surface SLAYER layer parameters (geometry + dimensionless) +[quantities.slayer_ising] +h5path = "Tearing/PerSurface/rational_index" +type = "real_vector" +extract = "all_real" +label = "SLAYER surface indices" +noise_threshold = 0 +order = 10 + +[quantities.slayer_m] +h5path = "Tearing/PerSurface/m" +type = "real_vector" +extract = "all_real" +label = "SLAYER poloidal m" +noise_threshold = 0 +order = 11 + +[quantities.slayer_n] +h5path = "Tearing/PerSurface/n" +type = "real_vector" +extract = "all_real" +label = "SLAYER toroidal n" +noise_threshold = 0 +order = 12 + +[quantities.slayer_rs] +h5path = "Tearing/PerSurface/rs" +type = "real_vector" +extract = "all_real" +label = "SLAYER minor radius rs" +noise_threshold = 1e-10 +order = 13 + +[quantities.slayer_sval_r] +h5path = "Tearing/PerSurface/sval_r" +type = "real_vector" +extract = "all_real" +label = "SLAYER r-based shear" +noise_threshold = 1e-10 +order = 14 + +[quantities.slayer_lu] +h5path = "Tearing/PerSurface/lu" +type = "real_vector" +extract = "all_real" +label = "SLAYER Lundquist S" +noise_threshold = 1e-8 +order = 15 + +[quantities.slayer_D_norm] +h5path = "Tearing/PerSurface/D_norm" +type = "real_vector" +extract = "all_real" +label = "SLAYER D_norm" +noise_threshold = 1e-10 +order = 16 + +[quantities.slayer_P_perp] +h5path = "Tearing/PerSurface/P_perp" +type = "real_vector" +extract = "all_real" +label = "SLAYER P_perp" +noise_threshold = 1e-8 +order = 17 + +[quantities.slayer_tauk] +h5path = "Tearing/PerSurface/tau_k" +type = "real_vector" +extract = "all_real" +label = "SLAYER tauk" +noise_threshold = 1e-12 +order = 18 + +[quantities.slayer_Q_e] +h5path = "Tearing/PerSurface/Q_e" +type = "real_vector" +extract = "all_real" +label = "SLAYER Q_e" +noise_threshold = 1e-12 +order = 20 + +[quantities.slayer_Q_i] +h5path = "Tearing/PerSurface/Q_i" +type = "real_vector" +extract = "all_real" +label = "SLAYER Q_i" +noise_threshold = 1e-12 +order = 21 + +[quantities.slayer_iota_e] +h5path = "Tearing/PerSurface/iota_e" +type = "real_vector" +extract = "all_real" +label = "SLAYER iota_e" +noise_threshold = 1e-12 +order = 19 + +# Tearing eigenvalue (coupled mode → length 1). The headline deliverable: +# a real, nonzero growth rate on a realistic equilibrium. Root-extraction +# is sensitive to the AMR cell topology and ODE solver, so the Q/ω/γ +# thresholds are absolute and modestly loose; re-pin intentionally if a +# solver/AMR change shifts the root inventory. +# Growth-rate / frequency / root outputs. Pinned for the inner three rational +# surfaces only (3/2, 4/2, 5/2) via `first_3`: the Δ'/γ contour search is +# numerically unreliable on the outermost surfaces near the edge, so those are +# deliberately not golden-tracked. +[quantities.slayer_Q] +h5path = "Tearing/Roots/Q_root" +type = "complex_vector" +extract = "first_3_complex" +label = "SLAYER Q_root [3/2,4/2,5/2]" +noise_threshold = 1e-4 +order = 30 + +[quantities.slayer_omega_Hz] +h5path = "Tearing/Roots/omega" +type = "real_vector" +extract = "first_3" +label = "SLAYER ω_Hz [3/2,4/2,5/2]" +noise_threshold = 1.0 +order = 32 + +[quantities.slayer_gamma_Hz] +h5path = "Tearing/Roots/gamma" +type = "real_vector" +extract = "first_3" +label = "SLAYER γ_Hz [3/2,4/2,5/2]" +noise_threshold = 1e-1 +order = 33 + +# no_root flag (1 = extraction failed). Pinned for the inner three surfaces +# so the 3/2, 4/2, 5/2 root inventory cannot drift silently. +[quantities.slayer_no_root] +h5path = "Tearing/Roots/no_root" +type = "real_vector" +extract = "first_3" +label = "SLAYER no_root flags [3/2,4/2,5/2]" +noise_threshold = 0 +order = 34 + +# Settings (catches accidental config drift) +[quantities.slayer_enabled] +h5path = "Tearing/enabled" +type = "int_scalar" +extract = "value" +label = "SLAYER enabled flag" +noise_threshold = 0 +order = 90 + +[quantities.runtime] +h5path = "" +type = "runtime" +extract = "value" +label = "Runtime (s)" +noise_threshold = 0.0 +order = 999 From 9b44f8cfc6b8e34002a4c952ccce2148c47efcc5 Mon Sep 17 00:00:00 2001 From: d-burg Date: Fri, 28 Aug 2026 14:40:20 -0400 Subject: [PATCH 27/51] InnerLayer.SLAYER - MINOR - Restore the D-normalization formula citation Put the explicit D = S^(1/3)*iota_e^(1/2)*(d_beta/r_s) statement back above the code so the formula-to-theory traceability survives next to what it computes. Co-Authored-By: Claude Opus 5 --- src/InnerLayer/SLAYER/LayerParameters.jl | 1 + 1 file changed, 1 insertion(+) diff --git a/src/InnerLayer/SLAYER/LayerParameters.jl b/src/InnerLayer/SLAYER/LayerParameters.jl index 15bc6aac1..ea707e17c 100644 --- a/src/InnerLayer/SLAYER/LayerParameters.jl +++ b/src/InnerLayer/SLAYER/LayerParameters.jl @@ -355,6 +355,7 @@ function slayer_parameters(; # Normalized beta-related width and Δ-normalization d_beta = c_beta * d_i + # D = S^(1/3)·iota_e^(1/2)·(d_beta/r_s), Fitzpatrick's ion-sound-radius normalization. # iota_e is the electron share of the total diamagnetic frequency; it reduces to # 1/(1+tau) only when the electron and ion pressure-gradient scale lengths match. D_norm = (d_beta / rs) * lu^(1.0 / 3.0) * sqrt(iota_e) From 4c70d4232960bb9d0202406775c5d24ea43beb6a Mon Sep 17 00:00:00 2001 From: d-burg Date: Fri, 28 Aug 2026 14:47:17 -0400 Subject: [PATCH 28/51] Regression - MINOR - Gitignore the override rundirs and note the load-bearing cleanup path The sibling rundirs live inside the checkout, so without an ignore entry they show as untracked in git status --porcelain and can spuriously mark golden provenance dirty. Also state at both cleanup sites why rm targets rundir and not its parent. Co-Authored-By: Claude Opus 5 --- .gitignore | 3 +++ regression-harness/src/runner.jl | 2 ++ 2 files changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index fcb3cc96c..1d1bf86a2 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ scratch/ # Local profiling scratch (one-off study scripts, not part of the package) profiling/ + +# Override-case rundirs materialized beside their example by the regression harness +.regress-override-* diff --git a/regression-harness/src/runner.jl b/regression-harness/src/runner.jl index 5ced56890..048276b2a 100644 --- a/regression-harness/src/runner.jl +++ b/regression-harness/src/runner.jl @@ -525,6 +525,7 @@ function run_local(db::SQLite.DB, case_spec::CaseSpec, repo_root::String; rm(runinfo_file; force=true) end if rundir_is_temp && rundir !== nothing + # rundir sits beside the example; removing its parent would delete examples/ itself rm(rundir; recursive=true, force=true) end end @@ -649,6 +650,7 @@ function run_at_commit(db::SQLite.DB, commit_hash::String, ref_name::String, rm(runinfo_file; force=true) end if rundir_is_temp && rundir !== nothing + # rundir sits beside the example; removing its parent would delete examples/ itself rm(rundir; recursive=true, force=true) end if own_worktree && worktree_path !== nothing From e32740a80895f6ba13e3835e92fbaef5501260dc Mon Sep 17 00:00:00 2001 From: d-burg Date: Fri, 28 Aug 2026 16:14:45 -0400 Subject: [PATCH 29/51] InnerLayer.SLAYER - TEST - Give the shared fixtures the physical opposite-drift diamagnetic signs The shared test inputs set omega_e and omega_i both positive, which no plasma produces: electrons and ions drift oppositely, the pipeline computes omega_*e < 0 < omega_*i (all six shipped-deck surfaces have Q_e > 0 > Q_i, matching TJ), and same-sign inputs put iota_e = 2 outside its physical (0, 1) range. Flip omega_e to -1e4 so iota_e = 2/3, matching the hand-built small-D fixture, and update the two assertions that encoded the old values. Branch selection cannot move: iota_e appears on both sides of the regime test and cancels. Co-Authored-By: Claude Opus 5 --- test/runtests_dispersion_coupled.jl | 2 +- test/runtests_dispersion_residual.jl | 2 +- test/runtests_slayer_params.jl | 14 +++++++------- test/runtests_slayer_riccati.jl | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/test/runtests_dispersion_coupled.jl b/test/runtests_dispersion_coupled.jl index 5a65539ff..1b950016e 100644 --- a/test/runtests_dispersion_coupled.jl +++ b/test/runtests_dispersion_coupled.jl @@ -21,7 +21,7 @@ function _slayer_ref() return slayer_parameters( n_e=5.0e19, t_e=1000.0, t_i=1000.0, - omega=0.0, omega_e=1.0e4, omega_i=5.0e3, + omega=0.0, omega_e=-1.0e4, omega_i=5.0e3, qval=2.0, sval_r=1.0, bt=2.0, rs=0.5, R0=1.7, mu_i=2.0, zeff=1.0, chi_perp=1.0, chi_tor=1.0, m=2, n=1) diff --git a/test/runtests_dispersion_residual.jl b/test/runtests_dispersion_residual.jl index d0235a0da..e8af06da4 100644 --- a/test/runtests_dispersion_residual.jl +++ b/test/runtests_dispersion_residual.jl @@ -21,7 +21,7 @@ function _slayer_ref() return slayer_parameters( n_e=5.0e19, t_e=1000.0, t_i=1000.0, - omega=0.0, omega_e=1.0e4, omega_i=5.0e3, + omega=0.0, omega_e=-1.0e4, omega_i=5.0e3, qval=2.0, sval_r=1.0, bt=2.0, rs=0.5, R0=1.7, mu_i=2.0, zeff=1.0, chi_perp=1.0, chi_tor=1.0, m=2, n=1) diff --git a/test/runtests_slayer_params.jl b/test/runtests_slayer_params.jl index c6f42a531..397bee29e 100644 --- a/test/runtests_slayer_params.jl +++ b/test/runtests_slayer_params.jl @@ -9,7 +9,7 @@ function _ref_kwargs(; dr_val=0.0, dc_type=:none) return ( n_e=5.0e19, t_e=1000.0, t_i=1000.0, - omega=0.0, omega_e=1.0e4, omega_i=5.0e3, + omega=0.0, omega_e=-1.0e4, omega_i=5.0e3, qval=2.0, sval_r=1.0, bt=2.0, rs=0.5, R0=1.7, mu_i=2.0, zeff=1.0, chi_perp=1.0, chi_tor=1.0, @@ -36,13 +36,13 @@ # Trivially exact ratios @test p.tau ≈ 1.0 - # Q_e = −tauk·1e4 = negative; Q_i = −tauk·5e3 = negative - # Q_e − Q_i = −tauk·5e3 = Q_i (since Q_e = 2·Q_i) ⇒ iota_e = Q_e/Q_i = 2 - @test p.iota_e ≈ 2.0 + # Physical opposite-drift inputs: ω_*e < 0 < ω_*i, so Q = −tauk·ω gives + # Q_e > 0 > Q_i and iota_e = Q_e/(Q_e − Q_i) = 1e4/(1e4 + 5e3) = 2/3 + @test p.iota_e ≈ 2 / 3 - # Sign convention check (SLAYER layerinputs) - @test p.Q_e == -p.tauk * 1.0e4 - @test p.Q_i == -p.tauk * 5.0e3 # SLAYER params convention: Q_i = −tauk·ω*i + # Sign convention check (SLAYER layerinputs): Q = −tauk·ω + @test p.Q_e == p.tauk * 1.0e4 + @test p.Q_i == -p.tauk * 5.0e3 # Default resistivity closure is neoclassical (Sauter F_33). The # trapped-particle correction raises η above plain Spitzer at the diff --git a/test/runtests_slayer_riccati.jl b/test/runtests_slayer_riccati.jl index 22f7fc549..9d4a95462 100644 --- a/test/runtests_slayer_riccati.jl +++ b/test/runtests_slayer_riccati.jl @@ -15,7 +15,7 @@ function _ref_params_large_D() return slayer_parameters(; n_e=5.0e19, t_e=3000.0, t_i=3000.0, - omega=0.0, omega_e=1.0e4, omega_i=5.0e3, + omega=0.0, omega_e=-1.0e4, omega_i=5.0e3, qval=2.0, sval_r=1.0, bt=2.0, rs=0.5, R0=1.7, mu_i=2.0, zeff=1.0, chi_perp=1.0, chi_tor=1.0, From 2024a8bede5bda1ae17d9c40ece2453316b95e0b Mon Sep 17 00:00:00 2001 From: Jake Halpern Date: Mon, 31 Aug 2026 12:00:30 -0400 Subject: [PATCH 30/51] VACUUM - IMPROVEMENT - adding 3D conformal walls --- src/Vacuum/DataTypes.jl | 107 +++++++++++++++++++++++++++++++------ src/Vacuum/Vacuum.jl | 2 +- test/runtests_vacuum.jl | 113 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 203 insertions(+), 19 deletions(-) diff --git a/src/Vacuum/DataTypes.jl b/src/Vacuum/DataTypes.jl index da0b8167f..de1acfe02 100644 --- a/src/Vacuum/DataTypes.jl +++ b/src/Vacuum/DataTypes.jl @@ -198,6 +198,9 @@ Struct containing input settings for vacuum wall geometry. + `"mod_dee"`: Modified Dee-shaped wall + `"filepath"`: Custom wall shape from the file you specify + A non-axisymmetric boundary (`nzeta_in > 1`) supports `"nowall"` and `"conformal"`; the others + are poloidal contours that get revolved and so need `nzeta_in == 1`. + - `a::Float64`: Distance of wall from plasma in units of major radius (conformal), or shape parameter (others) - `aw::Float64`: Half-thickness parameter for Dee-shaped walls @@ -213,7 +216,9 @@ Struct containing input settings for vacuum wall geometry. # Core shape selection - `equal_arc_wall::Bool`: Flag to enforce equal arc length distribution of nodes on the wall - (recommended unless wall is very close to plasma) + (recommended unless wall is very close to plasma). Re-parameterizing the contour breaks the + plasma/wall grid index correspondence that the singular quadrature relies on; it is ignored + outright for `nzeta_in > 1` walls. """ @kwdef struct WallShapeSettings @@ -600,8 +605,8 @@ end """ WallGeometry3D -Struct holding wall geometry data for vacuum calculations. Arrays are of length -`mtheta`, where `mtheta` is the number of poloidal grid points and θ ∈ [0, 1). +Struct holding wall geometry data for vacuum calculations. Arrays have `mtheta * nzeta` rows, +ordered with the poloidal index fastest. # Fields @@ -627,21 +632,36 @@ struct WallGeometry3D end """ - WallGeometry3D(inputs::VacuumInput, wall_settings::WallShapeSettings) + WallGeometry3D(inputs::VacuumInput, plasma_surf::PlasmaGeometry3D, wall_settings::WallShapeSettings) -> WallGeometry3D -Constructor to initialize the 3D wall geometry based on the provided vacuum inputs and wall shape settings. -Currently only works for axisymmetric walls generated by toroidal extrusion of 2D poloidal contours. +Constructor to initialize the 3D wall geometry based on the provided vacuum inputs, plasma surface and +wall shape settings. This is the 3D counterpart of [`WallGeometry`](@ref) and selects the shape the same +way: an axisymmetric boundary builds the 2D poloidal contour and revolves it, so every shape +[`WallGeometry`](@ref) offers is available; a non-axisymmetric boundary offsets the plasma surface. + +Expects a full-torus boundary — call [`expand_field_periods`](@ref) first when `nfp > 1`. # Arguments - `inputs::VacuumInput`: Struct containing vacuum calculation parameters + - `plasma_surf::PlasmaGeometry3D`: Plasma surface the wall is built around (used by the conformal wall) - `wall_settings::WallShapeSettings`: Struct specifying wall shape and parameters # Returns - `WallGeometry3D`: Struct containing wall surface coordinates and derivatives + +# Notes + + - Axisymmetric boundaries (`nzeta_in == 1`) support nowall, conformal, elliptical, dee, mod_dee and + from_file; non-axisymmetric boundaries support nowall and conformal + - The conformal wall for a non-axisymmetric boundary displaces each plasma point along its own normal, + which keeps wall grid index `(i, j)` the closest wall point to plasma index `(i, j)` — the + correspondence the near-field patch of `compute_3D_kernel_matrices!` assumes + - Rejects a conformal offset that folds the surface, and warns when the plasma–wall gap is smaller + than one cell of the coarser grid (the double-layer near-field quadrature cannot resolve it) """ -function WallGeometry3D(inputs::VacuumInput, wall_settings::WallShapeSettings) +function WallGeometry3D(inputs::VacuumInput, plasma_surf::PlasmaGeometry3D, wall_settings::WallShapeSettings) # Basic wall flags nowall = wall_settings.shape == "nowall" @@ -676,16 +696,35 @@ function WallGeometry3D(inputs::VacuumInput, wall_settings::WallShapeSettings) ) end - inputs.nzeta_in > 1 && error("3D wall geometry not yet implemented for non-axisymmetric inputs") - inputs.nfp > 1 && error("3D wall geometry not yet implemented for nfp > 1") - - # Plasma surface coordinates (2D) - surf_2D = PlasmaGeometry(inputs) - wall_2D = WallGeometry(inputs, surf_2D, wall_settings) + # expand_field_periods leaves an axisymmetric boundary alone, so nfp > 1 can only arrive here with + # per-period counts that the rest of the 3D solve reads as full-torus ones. + inputs.nfp > 1 && error("3D wall geometry requires a full-torus boundary. Call expand_field_periods(inputs) first, or supply the whole torus with nfp = 1.") - # Build 3D surface point-by-point from 2D contour - for i in 1:mtheta, (j, ϕ) in enumerate(ϕ_grid) - r[i+mtheta*(j-1), :] .= [wall_2D.x[i] * cos(ϕ), wall_2D.x[i] * sin(ϕ), wall_2D.z[i]] + if inputs.nzeta_in == 1 + # Axisymmetric boundary: build the 2D poloidal contour and revolve it toroidally + surf_2D = PlasmaGeometry(inputs) + wall_2D = WallGeometry(inputs, surf_2D, wall_settings) + for i in 1:mtheta, (j, ϕ) in enumerate(ϕ_grid) + r[i+mtheta*(j-1), :] .= [wall_2D.x[i] * cos(ϕ), wall_2D.x[i] * sin(ϕ), wall_2D.z[i]] + end + elseif wall_settings.shape == "conformal" + # Displace every plasma point outward along its own normal + wall_settings.equal_arc_wall && @warn "equal_arc_wall is ignored for non-axisymmetric (nzeta_in > 1) walls: it re-parameterizes a 2D contour and would break the plasma/wall index alignment the near-field patch relies on." + + # Same logic as 2D + R_plasma = [hypot(plasma_surf.r[idx, 1], plasma_surf.r[idx, 2]) for idx in axes(plasma_surf.r, 1)] + offset_gap = wall_settings.a * 0.5 * (maximum(R_plasma) - minimum(R_plasma)) + @info "Calculating conformal wall shape $((@sprintf "%.2e" offset_gap)) m from plasma surface." + + # Plasma normal points into the plasma, so the displacement is along -normal + @inbounds for idx in axes(plasma_surf.r, 1) + scale = -offset_gap / sqrt(plasma_surf.normal[idx, 1]^2 + plasma_surf.normal[idx, 2]^2 + plasma_surf.normal[idx, 3]^2) + for k in 1:3 + r[idx, k] = plasma_surf.r[idx, k] + scale * plasma_surf.normal[idx, k] + end + end + else + error("Wall shape $(wall_settings.shape) is not available for a non-axisymmetric boundary (nzeta_in > 1).") end # Compute tangent vectors and normal vectors via periodic bicubic splines @@ -705,6 +744,42 @@ function WallGeometry3D(inputs::VacuumInput, wall_settings::WallShapeSettings) normal_orient = normal[idx, 1] > 0 ? 1 : -1 @views normal .*= normal_orient + # Same-index plasma–wall separation: the near-field patch is centred on this pair. + gap = minimum(hypot(r[idx, 1] - plasma_surf.r[idx, 1], r[idx, 2] - plasma_surf.r[idx, 2], r[idx, 3] - plasma_surf.r[idx, 3]) for idx in axes(r, 1)) + + # Fold check needs a pointwise normal offset (same-index pair) - equal_arc_wall re-parameterizes and breaks that pairing + if wall_settings.shape == "conformal" && (inputs.nzeta_in > 1 || !wall_settings.equal_arc_wall) + # Offset stays regular while wall and plasma normals stay aligned and the area element has not collapsed + # Both fail when the gap exceeds the local concave radius of curvature. + min_align = Inf # min n̂_wall · n̂_plasma_outward; +1 healthy, ≤0 folded + min_area_ratio = Inf # min ||n_wall||/||n_plasma||; →0 at a caustic + @inbounds for idx in axes(normal, 1) + norm_wall = sqrt(normal[idx, 1]^2 + normal[idx, 2]^2 + normal[idx, 3]^2) + norm_plasma = sqrt(plasma_surf.normal[idx, 1]^2 + plasma_surf.normal[idx, 2]^2 + plasma_surf.normal[idx, 3]^2) + # Plasma n in, wall n out of vacuum → healthy offset dots to −1; store −cosine so +1 is aligned. + dot_wp = normal[idx, 1] * plasma_surf.normal[idx, 1] + normal[idx, 2] * plasma_surf.normal[idx, 2] + normal[idx, 3] * plasma_surf.normal[idx, 3] + min_align = min(min_align, -dot_wp / (norm_wall * norm_plasma)) + min_area_ratio = min(min_area_ratio, norm_wall / norm_plasma) + end + + min_align <= 0.0 && error( + "Conformal wall offset of $((@sprintf "%.2e" gap)) m self-intersects: it exceeds the local concave radius " * + "of curvature of the plasma surface somewhere, folding the offset surface. Reduce the wall distance a." + ) + (min_align < 0.5 || min_area_ratio < 0.5) && + @warn "Conformal wall is close to folding: min(n̂_wall·n̂_plasma)=$((@sprintf "%.3f" min_align)), min area-element ratio=$((@sprintf "%.3f" min_area_ratio)). Reduce the wall distance a." + end + + # The 3D singular quadrature resolves the double-layer kernel well only while the plasma-wall gap spans a grid cell or more + h_θ = max(sqrt(sum(abs2, dr_dθ) / num_points), sqrt(sum(abs2, plasma_surf.dr_dθ) / num_points)) * dθ + h_ζ = max(sqrt(sum(abs2, dr_dζ) / num_points), sqrt(sum(abs2, plasma_surf.dr_dζ) / num_points)) * dζ + cells = gap / max(h_θ, h_ζ) + if cells < 1.0 + @warn "Plasma–wall gap $((@sprintf "%.2e" gap)) m spans only $((@sprintf "%.2f" cells)) cells of the coarser grid " * + "(h_θ=$((@sprintf "%.2e" h_θ)) m, h_ζ=$((@sprintf "%.2e" h_ζ)) m). The double-layer near-field quadrature is inaccurate " * + "below one cell. Refine the coarser of mtheta/nzeta, or move the wall out." + end + return WallGeometry3D( nowall, is_closed_toroidal, diff --git a/src/Vacuum/Vacuum.jl b/src/Vacuum/Vacuum.jl index 3fd13eb22..9c4921618 100644 --- a/src/Vacuum/Vacuum.jl +++ b/src/Vacuum/Vacuum.jl @@ -192,7 +192,7 @@ interior variant `-D + 2I` for the interior columns, then scatter back into the # Full-torus geometry for source surface; observers are restricted to one field period full = expand_field_periods(inputs) plasma_surf = PlasmaGeometry3D(full) - wall = WallGeometry3D(full, wall_settings) + wall = WallGeometry3D(full, plasma_surf, wall_settings) num_points_per_fp = mtheta * nzeta # points per field period num_points = num_points_per_fp * nfp # full-torus point count diff --git a/test/runtests_vacuum.jl b/test/runtests_vacuum.jl index 19f80b94d..2db363573 100644 --- a/test/runtests_vacuum.jl +++ b/test/runtests_vacuum.jl @@ -594,7 +594,7 @@ @testset "WallGeometry3D nowall" begin inputs = _make_3d_inputs(mtheta=32, nzeta=32, mtheta_eq=17) - wall = GeneralizedPerturbedEquilibrium.Vacuum.WallGeometry3D(inputs, WallShapeSettings(shape="nowall")) + wall = GeneralizedPerturbedEquilibrium.Vacuum.WallGeometry3D(inputs, GeneralizedPerturbedEquilibrium.Vacuum.PlasmaGeometry3D(inputs), WallShapeSettings(shape="nowall")) @test wall.nowall == true @test wall.mtheta == 32 @test wall.nzeta == 32 @@ -603,7 +603,11 @@ @testset "WallGeometry3D conformal" begin inputs = _make_3d_inputs(mtheta=32, nzeta=32, mtheta_eq=17) - wall = GeneralizedPerturbedEquilibrium.Vacuum.WallGeometry3D(inputs, WallShapeSettings(shape="conformal", a=0.2)) + wall = GeneralizedPerturbedEquilibrium.Vacuum.WallGeometry3D( + inputs, + GeneralizedPerturbedEquilibrium.Vacuum.PlasmaGeometry3D(inputs), + WallShapeSettings(shape="conformal", a=0.2) + ) @test wall.nowall == false @test wall.mtheta == 32 @test wall.nzeta == 32 @@ -614,6 +618,111 @@ @test all(isfinite, wall.normal) end + # Corrugated torus on a genuinely periodic (endpoint-excluded) grid. The shared + # _make_3d_nonaxis_inputs helper samples range(0, 2π, length=n), which repeats the seam + # point and leaves the surface non-smooth there — harmless for a nowall response, but it + # makes the offset surface fold, so the wall tests build their own boundary. + _make_3d_periodic_inputs(; mtheta=24, nzeta=24, mtheta_in=16, nzeta_in=16) = begin + θ_in = range(; start=0, length=mtheta_in, step=2π/mtheta_in) + ζ_in = range(; start=0, length=nzeta_in, step=2π/nzeta_in) + X = zeros(mtheta_in, nzeta_in) + Y = similar(X) + Z = similar(X) + for (i, θ) in enumerate(θ_in), (j, ζ) in enumerate(ζ_in) + R = 1.7 + 0.3 * cos(θ) + 0.05 * cos(2ζ) * cos(θ) + X[i, j] = R * cos(ζ) + Y[i, j] = R * sin(ζ) + Z[i, j] = 0.3 * sin(θ) + 0.05 * sin(2ζ) * sin(θ) + end + VacuumInput(x=vec(X), y=vec(Y), z=vec(Z), mtheta_in=mtheta_in, nzeta_in=nzeta_in, + m_modes=[1, 2], n_modes=[0, 1], mtheta=mtheta, nzeta=nzeta) + end + + @testset "WallGeometry3D conformal, non-axisymmetric input" begin + inputs = _make_3d_periodic_inputs(mtheta=24, nzeta=24) + settings = WallShapeSettings(shape="conformal", a=0.2, equal_arc_wall=false) + plasma = GeneralizedPerturbedEquilibrium.Vacuum.PlasmaGeometry3D(inputs) + wall = GeneralizedPerturbedEquilibrium.Vacuum.WallGeometry3D(inputs, plasma, settings) + + num_points = 24 * 24 + @test wall.nowall == false + @test size(wall.r) == (num_points, 3) + @test all(isfinite, wall.r) && all(isfinite, wall.normal) + + # Uniform offset along the plasma normal: every point moves the same distance, outward. + offsets = [norm(wall.r[i, :] - plasma.r[i, :]) for i in 1:num_points] + @test maximum(offsets) - minimum(offsets) < 1e-12 + @test all(hypot.(wall.r[:, 1], wall.r[:, 2]) .> 0) + R_wall = [hypot(wall.r[i, 1], wall.r[i, 2]) for i in 1:num_points] + R_plasma = [hypot(plasma.r[i, 1], plasma.r[i, 2]) for i in 1:num_points] + @test maximum(R_wall) > maximum(R_plasma) + + # The offset point is the closest wall point to its own plasma point, which is the + # index alignment the near-field patch assumes. + for i in (1, 300, num_points) + @test offsets[i] ≈ minimum(norm(wall.r[j, :] - plasma.r[i, :]) for j in 1:num_points) + end + + # Wall normals face out of the vacuum region, opposite the inward plasma normals. + aligns = [dot(wall.normal[i, :], plasma.normal[i, :]) for i in 1:num_points] + @test all(aligns .< 0) + end + + @testset "WallGeometry3D non-axisymmetric error paths" begin + inputs = _make_3d_periodic_inputs(mtheta=24, nzeta=24) + plasma = GeneralizedPerturbedEquilibrium.Vacuum.PlasmaGeometry3D(inputs) + for shape in ("elliptical", "dee", "mod_dee", "some_wall_file.dat") + @test_throws ErrorException GeneralizedPerturbedEquilibrium.Vacuum.WallGeometry3D(inputs, plasma, WallShapeSettings(shape=shape)) + end + # equal_arc_wall has no meaning without a 2D contour and is ignored with a warning + @test_logs (:warn, r"equal_arc_wall is ignored") match_mode=:any GeneralizedPerturbedEquilibrium.Vacuum.WallGeometry3D( + inputs, + plasma, + WallShapeSettings(shape="conformal", a=0.2, equal_arc_wall=true) + ) + # a full-torus boundary is required; expand_field_periods must run first + per_period = VacuumInput(x=inputs.x, y=inputs.y, z=inputs.z, mtheta_in=16, nzeta_in=16, + m_modes=inputs.m_modes, n_modes=inputs.n_modes, mtheta=24, nzeta=24, nfp=3) + @test_throws ErrorException GeneralizedPerturbedEquilibrium.Vacuum.WallGeometry3D(per_period, plasma, WallShapeSettings(shape="conformal", a=0.2)) + # an offset that folds the surface is rejected rather than silently returned + folded = _make_3d_nonaxis_inputs(mtheta=24, nzeta=24, mtheta_in=12, nzeta_in=12) + @test_throws ErrorException GeneralizedPerturbedEquilibrium.Vacuum.WallGeometry3D( + folded, + GeneralizedPerturbedEquilibrium.Vacuum.PlasmaGeometry3D(folded), + WallShapeSettings(shape="conformal", a=0.2, equal_arc_wall=false) + ) + end + + @testset "WallGeometry3D shape selection" begin + # Every shape reaches the right builder through the same chain WallGeometry uses in 2D. + # elongated so the elliptical branch has a well-defined focal distance + θ_eq = range(0, 2π, length=33)[1:32] + axi = VacuumInput(mtheta_in=32, nzeta_in=1, x=collect(1.7 .+ 0.3 .* cos.(θ_eq)), + z=collect(0.45 .* sin.(θ_eq)), ν=zeros(32), m_modes=[1, 2], n_modes=[0, 1], + mtheta=32, nzeta=32) + for (shape, settings) in (("elliptical", WallShapeSettings(shape="elliptical", a=0.5)), + ("dee", WallShapeSettings(shape="dee", a=0.3)), + ("mod_dee", WallShapeSettings(shape="mod_dee", a=0.5, cw=1.7))) + wall = GeneralizedPerturbedEquilibrium.Vacuum.WallGeometry3D(axi, GeneralizedPerturbedEquilibrium.Vacuum.PlasmaGeometry3D(axi), settings) + @test size(wall.r) == (32 * 32, 3) + @test all(isfinite, wall.normal) + # revolved shapes are axisymmetric: R and Z repeat from one toroidal plane to the next + @test hypot(wall.r[1, 1], wall.r[1, 2]) ≈ hypot(wall.r[1+32, 1], wall.r[1+32, 2]) + @test wall.r[1, 3] ≈ wall.r[1+32, 3] + end + # Gap warning uses the measured same-index separation, not a conformal-only offset that + # is 0 for these shapes. a = 1 m sits well outside one coarser cell, so no sub-cell warning. + @test_logs min_level=Base.CoreLogging.Warn GeneralizedPerturbedEquilibrium.Vacuum.WallGeometry3D( + axi, GeneralizedPerturbedEquilibrium.Vacuum.PlasmaGeometry3D(axi), WallShapeSettings(shape="elliptical", a=1.0) + ) + # an unreadable wall file is still reported by the 2D reader + @test_throws ErrorException GeneralizedPerturbedEquilibrium.Vacuum.WallGeometry3D( + axi, + GeneralizedPerturbedEquilibrium.Vacuum.PlasmaGeometry3D(axi), + WallShapeSettings(shape="no_such_wall.dat") + ) + end + @testset "compute_vacuum_response 3D nowall" begin inputs = _make_3d_inputs(mtheta=32, nzeta=32, mtheta_eq=17) wall_settings = WallShapeSettings(shape="nowall") From e11e16819925ff6e32eeeb4bb829b0d9e88c5414 Mon Sep 17 00:00:00 2001 From: d-burg Date: Thu, 3 Sep 2026 12:35:48 -0400 Subject: [PATCH 31/51] InnerLayer.SLAYER - MINOR - Name the Mercier exponent alpha and split the r_s-referenced Delta-prime dataset Rename mu_mercier to alpha_mercier: the quantity is sqrt(-D_I), the alpha of Glasser-Greene-Johnson 1975 Eq. 48 (STRIDE's alpha), not the full Frobenius exponent that Glasser, Wang & Park 2016 Eq. 26 calls mu. Rewrite the delta_prime_to_rs_reference docstring with the correct citations and state that the off-diagonal split is a diagonal similarity transform, so the choice of dependent variable leaves every dispersion root unchanged. Write the r_s-referenced matrix the slab layer matches as Tearing/PerSurface/Delta_prime_matrix_rs; Delta_prime_matrix under Tearing/PerSurface/ now appears only on the GGJ path, where it is the same psi_N-referenced quantity as SingularSurfaces/Delta_prime_matrix. Explain in the SLAYER example why the near-edge 5/1 and 6/1 report no_root. No numerical change. Co-Authored-By: Claude Fable 5.1 --- docs/development/hdf5-conventions.md | 2 +- examples/DIIID-like_SLAYER_example/gpec.toml | 7 +++- src/InnerLayer/SLAYER/LayerInputs.jl | 8 ++-- src/InnerLayer/SLAYER/LayerParameters.jl | 12 +++--- src/Tearing/Runner/HDF5Output.jl | 19 ++++++---- src/Tearing/Runner/Result.jl | 3 +- src/Tearing/Runner/run_slayer.jl | 39 ++++++++++++-------- test/runtests_slayer_runner.jl | 28 +++++++------- 8 files changed, 67 insertions(+), 51 deletions(-) diff --git a/docs/development/hdf5-conventions.md b/docs/development/hdf5-conventions.md index c6f568931..550702798 100644 --- a/docs/development/hdf5-conventions.md +++ b/docs/development/hdf5-conventions.md @@ -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][_]_[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//`) — 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//`) — the group supplies the context, the leaf supplies the identity. ## Inputs live only under `Input/` diff --git a/examples/DIIID-like_SLAYER_example/gpec.toml b/examples/DIIID-like_SLAYER_example/gpec.toml index 487d41f9b..da07c4a9a 100644 --- a/examples/DIIID-like_SLAYER_example/gpec.toml +++ b/examples/DIIID-like_SLAYER_example/gpec.toml @@ -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" diff --git a/src/InnerLayer/SLAYER/LayerInputs.jl b/src/InnerLayer/SLAYER/LayerInputs.jl index b8806aee9..05192d5a1 100644 --- a/src/InnerLayer/SLAYER/LayerInputs.jl +++ b/src/InnerLayer/SLAYER/LayerInputs.jl @@ -356,7 +356,7 @@ function build_slayer_inputs(equil, sings, profiles::KineticProfiles; end # Reference-length conversion inputs for the outer Δ': K = r_s·(dψ_N/dr)|_s and - # μ = √(−D_I) (Glasser-Greene-Johnson 1975), with μ clamped to 0 on Mercier-unstable + # α = √(−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 @@ -366,9 +366,9 @@ function build_slayer_inputs(equil, sings, profiles::KineticProfiles; "Δ' unconverted (k_ref = 1) at this surface.", maxlog=3) 1.0 end - mu_k = if rg === nothing + alpha_k = if rg === nothing @warn("build_slayer_inputs: sing.restype not populated; using the " * - "slab Mercier exponent μ = 1/2 for the Δ' reference-length " * + "slab Mercier exponent α = 1/2 for the Δ' reference-length " * "conversion at all such surfaces.", maxlog=1) 0.5 else @@ -392,7 +392,7 @@ function build_slayer_inputs(equil, sings, profiles::KineticProfiles; R_major_eff=R_major_eff, lnLambda_form=lnLambda_form, k_ref=k_ref_k, - mu_mercier=mu_k + alpha_mercier=alpha_k ) end return out diff --git a/src/InnerLayer/SLAYER/LayerParameters.jl b/src/InnerLayer/SLAYER/LayerParameters.jl index 638f456f9..1b1041fa7 100644 --- a/src/InnerLayer/SLAYER/LayerParameters.jl +++ b/src/InnerLayer/SLAYER/LayerParameters.jl @@ -47,9 +47,9 @@ de-normalization. The parametrization uses `P_perp`, `P_tor`, and | `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) | -| `mu_mercier` | Mercier exponent μ = √(−D_I) governing the Δ' reference-length conversion (1/2 = slab/cylindrical value) | +| `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 `mu_mercier` feed the ψ_N → r_s reference-length conversion of +`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 Δ' @@ -97,7 +97,7 @@ Base.@kwdef struct SLAYERParameters <: InnerLayerParameters # Reference-length conversion inputs for the outer Δ' (ψ_N → r_s-based x̂) k_ref::Float64 = 1.0 - mu_mercier::Float64 = 0.5 + alpha_mercier::Float64 = 0.5 end # Allowed dc_type values for the critical-Δ offset. `:none` is the default @@ -219,7 +219,7 @@ parametrization (P_perp/P_tor/D_norm; the older magnetic/electron Prandtl 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) - - `mu_mercier` -- Mercier exponent μ = √(−D_I) for the same conversion + - `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 @@ -273,7 +273,7 @@ function slayer_parameters(; R_major_eff::Union{Real,Nothing}=nothing, lnLambda_form::Symbol=:nrl, k_ref::Real=1.0, - mu_mercier::Real=0.5) + 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) @@ -382,6 +382,6 @@ function slayer_parameters(; dr_val=dr_val, dgeo_val=dgeo_val, eta=eta, d_beta=d_beta, dc_tmp=dc_tmp, dc_type=dc_type, - k_ref=k_ref, mu_mercier=mu_mercier + k_ref=k_ref, alpha_mercier=alpha_mercier ) end diff --git a/src/Tearing/Runner/HDF5Output.jl b/src/Tearing/Runner/HDF5Output.jl index b0f9110f3..e3052d6b4 100644 --- a/src/Tearing/Runner/HDF5Output.jl +++ b/src/Tearing/Runner/HDF5Output.jl @@ -96,8 +96,8 @@ const TEARING_H5_ANNOTATIONS = [ "PerSurface/D_c_offset" => (; long_name="critical-Δ offset from χ_∥/χ_⊥ matching (Connor-Hastie-Helander 2015 Eq. 59)", dims=("surface",)), "PerSurface/D_c_type" => (; long_name="per-surface D_c prescription label", dims=("surface",)), "PerSurface/k_ref" => (; long_name="reference-length ratio K = r_s·(dψ_N/dr) at each surface", dims=("surface",)), - "PerSurface/mu_mercier" => (; long_name="Mercier exponent μ = √(−D_I) at each surface", dims=("surface",)), - "PerSurface/delta_prime_conversion" => (; long_name="ψ_N → r_s reference-length factor K^(2μ) applied to each Δ' diagonal", dims=("surface",)), + "PerSurface/alpha_mercier" => (; long_name="Mercier Frobenius exponent α = √(−D_I) at each surface (Glasser-Greene-Johnson 1975 Eq. 48)", dims=("surface",)), + "PerSurface/delta_prime_conversion" => (; long_name="ψ_N → r_s reference-length factor K^(2α) applied to each Δ' diagonal", dims=("surface",)), "PerSurface/E" => (; long_name="Glasser-Greene-Johnson coefficient E per surface", dims=("surface",)), "PerSurface/F" => (; long_name="Glasser-Greene-Johnson coefficient F per surface", dims=("surface",)), "PerSurface/G" => (; long_name="Glasser-Greene-Johnson coefficient G per surface", dims=("surface",)), @@ -106,7 +106,8 @@ const TEARING_H5_ANNOTATIONS = [ "PerSurface/M" => (; long_name="Glasser-Greene-Johnson coefficient M per surface", dims=("surface",)), "PerSurface/tau_A" => (; long_name="Alfvén time τ_A per surface (GGJ layer parameters)", units="s", dims=("surface",)), "PerSurface/dVdpsi" => (; long_name="dV/dψ_N at each surface", units="m^3", dims=("surface",)), - "PerSurface/Delta_prime_matrix" => (; long_name="full complex Δ' matrix coupling the rational surfaces, as used in the matching (SLAYER path: r_s-referenced via K^(2μ); the ψ_N-referenced BVP matrix is SingularSurfaces/Delta_prime_matrix)", dims=("surface_row", "surface_col")), + "PerSurface/Delta_prime_matrix" => (; long_name="full complex Δ' matrix coupling the rational surfaces, ψ_N-referenced (GGJ path; identical to SingularSurfaces/Delta_prime_matrix)", dims=("surface_row", "surface_col")), + "PerSurface/Delta_prime_matrix_rs" => (; long_name="full complex Δ' matrix as used in the slab-layer matching, converted to the r_s reference length (K^(2α) on the diagonal; the ψ_N-referenced BVP matrix is SingularSurfaces/Delta_prime_matrix)", dims=("surface_row", "surface_col")), "Roots/Q_root" => (; long_name="complex dispersion-root normalized frequency Q (NaN = no root)", dims=("surface",)), "Roots/omega" => (; long_name="mode rotation angular frequency ω = Re(Q)/τ_k of each root", units="rad/s", dims=("surface",)), @@ -187,13 +188,15 @@ function _write_per_surface!(g, params::AbstractVector{SLAYERParameters}, ps["D_geo"] = Float64[p.dgeo_val for p in params] # Store dc_type per-surface as string array ps["D_c_type"] = String[String(p.dc_type) for p in params] - # Reference-length conversion applied to Δ' (ψ_N → r_s): K, μ, and the - # diagonal factor K^(2μ) actually multiplying each Δ'_kk. + # Reference-length conversion applied to Δ' (ψ_N → r_s): K, α, and the + # diagonal factor K^(2α) actually multiplying each Δ'_kk. ps["k_ref"] = Float64[p.k_ref for p in params] - ps["mu_mercier"] = Float64[p.mu_mercier for p in params] - ps["delta_prime_conversion"] = Float64[p.k_ref^(2 * p.mu_mercier) for p in params] + ps["alpha_mercier"] = Float64[p.alpha_mercier for p in params] + ps["delta_prime_conversion"] = Float64[p.k_ref^(2 * p.alpha_mercier) for p in params] - ps["Delta_prime_matrix"] = dp_matrix + # The converted matrix is a different quantity from the ψ_N-referenced BVP + # matrix, so it carries its own name. + ps["Delta_prime_matrix_rs"] = dp_matrix return nothing end diff --git a/src/Tearing/Runner/Result.jl b/src/Tearing/Runner/Result.jl index d81508989..60a728869 100644 --- a/src/Tearing/Runner/Result.jl +++ b/src/Tearing/Runner/Result.jl @@ -22,7 +22,8 @@ downstream inspection and HDF5 output. the surface list), in which case the HDF5 writer skips them. - `dp_matrix` -- outer-region Δ' matrix used in the analysis. SLAYER path: r_s-referenced (the ψ_N BVP matrix transformed by - `delta_prime_to_rs_reference`); GGJ path: the ψ_N matrix unchanged + `delta_prime_to_rs_reference`, written as `PerSurface/Delta_prime_matrix_rs`); + GGJ path: the ψ_N matrix unchanged (written as `PerSurface/Delta_prime_matrix`) - `Q_root` -- tearing eigenvalue(s) in normalized Q * length `nsurfaces` in `:uncoupled` mode * length `1` in `:coupled` mode (global eigenvalue normalized by diff --git a/src/Tearing/Runner/run_slayer.jl b/src/Tearing/Runner/run_slayer.jl index 650761b3c..1d07480ea 100644 --- a/src/Tearing/Runner/run_slayer.jl +++ b/src/Tearing/Runner/run_slayer.jl @@ -163,24 +163,33 @@ the r_s-based `x̂ = (r − r_s)/r_s` reference the slab layer works in (Fitzpatrick 2023 convention — the same reference used by `dc_tmp` and by the `S^(1/3)` Δ(Q) scale, both built on r_s). -Near surface `k` the tearing-parity outer solution is -`A_L·|x|^(1/2−μ) + A_S·|x|^(1/2+μ)` with `μ = √(−D_I)` (Glasser, Wang & -Park 2016, Eq. 26). Rescaling the radial variable `x_ψ = K·x̂` with -`K = r_s·(dψ_N/dr)|_s` maps the coefficients as `Â_L = A_L·K^(1/2−μ)` and -`Â_S = A_S·K^(1/2+μ)`, so the response matrix (small coefficient at surface -`i` per unit large coefficient at surface `j`) transforms as - - Δ̂_ij = K_i^(1/2+μ_i) · Δ'_ij · K_j^(μ_j−1/2) - -whose diagonal is `K^(2μ)·Δ'_kk`; at D_I = −1/4 (μ = 1/2) this reduces to -the textbook `Δ̂ = r_s·Δ'_phys`. `K` and `μ` are carried per surface in -`SLAYERParameters.k_ref` / `.mu_mercier`; hand-built parameters default to -`k_ref = 1`, making the conversion the identity. +Near surface `k` the outer solution combines the large and small Frobenius +solutions with the Mercier exponent `α = √(−D_I)`, `D_I = E + F + H − 1/4` +(Glasser, Greene & Johnson 1975, Eq. 48; STRIDE's `alpha`). The displacement +scales as `|x|^(−1/2 ± α)` (Glasser, Wang & Park 2016, Eq. 26), so the +flux-like variable `ψ ∝ x·ξ` of the slab Δ' definition scales as +`A_L·|x|^(1/2−α) + A_S·|x|^(1/2+α)`. Rescaling the radial variable +`x_ψ = K·x̂` with `K = r_s·(dψ_N/dr)|_s` maps the flux coefficients as +`Â_L = A_L·K^(1/2−α)` and `Â_S = A_S·K^(1/2+α)`, so the response matrix +(small coefficient at surface `i` per unit large coefficient at surface `j`) +transforms as + + Δ̂_ij = K_i^(1/2+α_i) · Δ'_ij · K_j^(α_j−1/2) + +whose diagonal is `K^(2α)·Δ'_kk`; at D_I = −1/4 (α = 1/2) this reduces to +the textbook `Δ̂ = r_s·Δ'_phys`. The diagonal factor is the exponent gap and +is the same whichever variable carries the coefficients; using the +displacement instead shifts both exponents by one and changes the +off-diagonal split by `K_i/K_j`, a diagonal similarity transform `D·Δ'·D⁻¹` +that leaves every uncoupled and coupled dispersion root unchanged. `K` and +`α` are carried per surface in `SLAYERParameters.k_ref` / `.alpha_mercier`; +hand-built parameters default to `k_ref = 1`, making the conversion the +identity. """ function delta_prime_to_rs_reference(dp_matrix::AbstractMatrix, params::AbstractVector) - dl = [p.k_ref^(0.5 + p.mu_mercier) for p in params] - dr = [p.k_ref^(p.mu_mercier - 0.5) for p in params] + dl = [p.k_ref^(0.5 + p.alpha_mercier) for p in params] + dr = [p.k_ref^(p.alpha_mercier - 0.5) for p in params] return Diagonal(dl) * Matrix{ComplexF64}(dp_matrix) * Diagonal(dr) end diff --git a/test/runtests_slayer_runner.jl b/test/runtests_slayer_runner.jl index fea52dc46..ee114e37f 100644 --- a/test/runtests_slayer_runner.jl +++ b/test/runtests_slayer_runner.jl @@ -154,37 +154,37 @@ end @testset "delta_prime_to_rs_reference: ψ_N → r_s conversion" begin - # Two surfaces with distinct K and μ; the transform is - # Δ̂_ij = K_i^(1/2+μ_i) · Δ'_ij · K_j^(μ_j−1/2). - K1, mu1 = 0.9, 0.54 - K2, mu2 = 1.2, 0.60 + # Two surfaces with distinct K and α; the transform is + # Δ̂_ij = K_i^(1/2+α_i) · Δ'_ij · K_j^(α_j−1/2). + K1, alpha1 = 0.9, 0.54 + K2, alpha2 = 1.2, 0.60 p1 = SLAYERParameters(; tau=1.0, lu=1e7, c_beta=0.1, D_norm=2.0, P_perp=20.0, P_tor=10.0, Q_e=-1.0, Q_i=0.5, iota_e=2 / 3, tauk=1e-4, tau_r=1.0, delta_n=1.0, rs=0.4, R0=1.7, bt=2.0, sval_r=1.0, eta=2.5e-8, d_beta=4e-3, m=2, n=1, ising=1, - k_ref=K1, mu_mercier=mu1) + k_ref=K1, alpha_mercier=alpha1) p2 = SLAYERParameters(; tau=1.0, lu=1e7, c_beta=0.1, D_norm=2.0, P_perp=20.0, P_tor=10.0, Q_e=-1.0, Q_i=0.5, iota_e=2 / 3, tauk=1e-4, tau_r=1.0, delta_n=1.0, rs=0.5, R0=1.7, bt=2.0, sval_r=1.0, eta=2.5e-8, d_beta=4e-3, m=3, n=1, ising=2, - k_ref=K2, mu_mercier=mu2) + k_ref=K2, alpha_mercier=alpha2) dp = ComplexF64[10.0+1im 2.0-0.5im; 3.0+0im 1.5+2im] out = Runner.delta_prime_to_rs_reference(dp, [p1, p2]) - # Diagonal carries the scalar K^(2μ) - @test out[1, 1] ≈ K1^(2mu1) * dp[1, 1] - @test out[2, 2] ≈ K2^(2mu2) * dp[2, 2] + # Diagonal carries the scalar K^(2α) + @test out[1, 1] ≈ K1^(2alpha1) * dp[1, 1] + @test out[2, 2] ≈ K2^(2alpha2) * dp[2, 2] # Off-diagonals carry the split row/column factors - @test out[1, 2] ≈ K1^(0.5 + mu1) * K2^(mu2 - 0.5) * dp[1, 2] - @test out[2, 1] ≈ K2^(0.5 + mu2) * K1^(mu1 - 0.5) * dp[2, 1] - # At the slab point μ = 1/2 the diagonal factor is exactly K + @test out[1, 2] ≈ K1^(0.5 + alpha1) * K2^(alpha2 - 0.5) * dp[1, 2] + @test out[2, 1] ≈ K2^(0.5 + alpha2) * K1^(alpha1 - 0.5) * dp[2, 1] + # At the slab point α = 1/2 the diagonal factor is exactly K pslab = SLAYERParameters(; tau=1.0, lu=1e7, c_beta=0.1, D_norm=2.0, P_perp=20.0, P_tor=10.0, Q_e=-1.0, Q_i=0.5, iota_e=2 / 3, tauk=1e-4, tau_r=1.0, delta_n=1.0, rs=0.4, R0=1.7, bt=2.0, sval_r=1.0, eta=2.5e-8, d_beta=4e-3, m=2, n=1, ising=1, - k_ref=0.8, mu_mercier=0.5) + k_ref=0.8, alpha_mercier=0.5) dp1 = ComplexF64[5.0+0im;;] @test Runner.delta_prime_to_rs_reference(dp1, [pslab])[1, 1] ≈ 0.8 * 5.0 - # Default parameters (k_ref = 1) give the identity regardless of μ + # Default parameters (k_ref = 1) give the identity regardless of α @test Runner.delta_prime_to_rs_reference(dp, [_mk_params(), _mk_params()]) ≈ dp end From 12f64da8c87c48abff6eecae527362d871a5d6ac Mon Sep 17 00:00:00 2001 From: d-burg Date: Thu, 3 Sep 2026 12:59:11 -0400 Subject: [PATCH 32/51] InnerLayer.SLAYER - MINOR - Narrow the Delta-prime conversion signature and note the F 2pi convention Addresses two review comments. delta_prime_to_rs_reference reads .k_ref and .alpha_mercier off its parameter vector, so accept only AbstractVector{<:SLAYERParameters}: a wrong parameter vector now fails as a MethodError at the call site instead of a field error inside the comprehension. The radial_label :flux docstring defines g = F/(B0 R0) while the code divides profiles.F_spline by 2pi, because that spline stores 2*pi*F. Say so, so the factor is not removed or duplicated later. Co-Authored-By: Claude Opus 5 --- src/InnerLayer/SLAYER/LayerInputs.jl | 4 +++- src/Tearing/Runner/run_slayer.jl | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/InnerLayer/SLAYER/LayerInputs.jl b/src/InnerLayer/SLAYER/LayerInputs.jl index 05192d5a1..cb1d14b85 100644 --- a/src/InnerLayer/SLAYER/LayerInputs.jl +++ b/src/InnerLayer/SLAYER/LayerInputs.jl @@ -78,7 +78,9 @@ derivatives are analytic — no label carries a finite-difference stencil. 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₀)`. + `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. diff --git a/src/Tearing/Runner/run_slayer.jl b/src/Tearing/Runner/run_slayer.jl index 1d07480ea..2459087cf 100644 --- a/src/Tearing/Runner/run_slayer.jl +++ b/src/Tearing/Runner/run_slayer.jl @@ -187,7 +187,7 @@ hand-built parameters default to `k_ref = 1`, making the conversion the identity. """ function delta_prime_to_rs_reference(dp_matrix::AbstractMatrix, - params::AbstractVector) + params::AbstractVector{<:SLAYERParameters}) dl = [p.k_ref^(0.5 + p.alpha_mercier) for p in params] dr = [p.k_ref^(p.alpha_mercier - 0.5) for p in params] return Diagonal(dl) * Matrix{ComplexF64}(dp_matrix) * Diagonal(dr) From 37c4eebb4eefd362e140773323bd192bc108864a Mon Sep 17 00:00:00 2001 From: logan-nc Date: Fri, 4 Sep 2026 15:03:54 -0400 Subject: [PATCH 33/51] KF - BUGFIX! - Keep the validity envelope clear of rational surfaces A rational surface inside the envelope's transition band gets its near-singular kinetic increments multiplied by a rapidly varying, near-zero envelope, which the matrix splines cannot represent; the overshoot propagates NaNs into the stability solve. Caught by the Solovev D-T deck, where the widest-orbit species (tritium) puts psi_c at 0.12 and the rational sits at 0.1221. Push the boundary past any rational whose window the band would cut through -- where orbit widths already reach the resonance is not trustworthy anyway -- and compute that boundary once per run, threading it to the kernel, the band knots and the Validity output so all three agree. Also from the clean-code review: single orbit_widths helper feeding both the boundary and the diagnostic profiles, and Validity's psi_c/envelope/is_valid now describe one species (the widest-orbit one) instead of contradicting each other. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LzbLFQKyuRE5DYZmLokKmk --- src/GeneralizedPerturbedEquilibrium.jl | 16 +++- .../CalculatedKineticMatrices.jl | 5 +- src/KineticForces/Output.jl | 12 ++- src/KineticForces/Utils.jl | 93 +++++++++++++++---- 4 files changed, 97 insertions(+), 29 deletions(-) diff --git a/src/GeneralizedPerturbedEquilibrium.jl b/src/GeneralizedPerturbedEquilibrium.jl index 021ae81d0..574fa4d9f 100755 --- a/src/GeneralizedPerturbedEquilibrium.jl +++ b/src/GeneralizedPerturbedEquilibrium.jl @@ -583,14 +583,18 @@ function prepare_force_free_states!( # Inject the KineticForces callback so the "calculated" source can # invoke compute_calculated_kinetic_matrices without ForceFreeStates # importing KineticForces (which would invert the load order). + # One boundary for the whole run: the widest-orbit species, moved clear of any rational + # surface whose window the transition band would otherwise cut through. + axis_psi_c = KineticForces.axis_validity_boundary(kf_ctrl, species, kinetic_profiles, equil, + Float64[sng.psifac for sng in intr.sing]) calculated_cb = (c, e, i, m, f; psis::Vector{Float64}=Float64[]) -> KineticForces.compute_calculated_kinetic_matrices( c, e, i, m, f; - kf_ctrl=kf_ctrl, kinetic_profiles=kinetic_profiles, species=species, psis=psis) + kf_ctrl=kf_ctrl, kinetic_profiles=kinetic_profiles, species=species, psis=psis, + axis_psi_c=axis_psi_c) mats = build_kinetic_matrix_splines(ctrl, equil, mats, intr, metric; - calculated_source=calculated_cb, - axis_validity_psi_c=KineticForces.axis_validity_boundary(kf_ctrl, species, kinetic_profiles, equil)) + calculated_source=calculated_cb, axis_validity_psi_c=axis_psi_c) # Find kinetically-displaced singular surfaces (zeros of det(F̄)) for ODE crossings. # Matches Fortran ksing_find (sing.f:1486-1616). singfac_min > 0 gates crossings; @@ -974,7 +978,8 @@ function run_kinetic_forces( h5open(joinpath(result.dir_path, kf_ctrl.HDF5_filename), "cw") do h5file KineticForces.write_to_hdf5!(h5file, kf_state; dVdpsi_spline=result.equil.profiles.dVdpsi_spline) - KineticForces.write_validity!(h5file, kf_ctrl, species, kinetic_profiles, result.equil) + KineticForces.write_validity!(h5file, kf_ctrl, species, kinetic_profiles, result.equil, + Float64[sng.psifac for sng in result.surfaces]) end end else @@ -1000,7 +1005,8 @@ function run_kinetic_forces( end KineticForces.write_to_hdf5!(h5file, kf_state; dVdpsi_spline=result.equil.profiles.dVdpsi_spline) - KineticForces.write_validity!(h5file, kf_ctrl, species, kinetic_profiles, result.equil) + KineticForces.write_validity!(h5file, kf_ctrl, species, kinetic_profiles, result.equil, + Float64[sng.psifac for sng in result.surfaces]) end end end diff --git a/src/KineticForces/CalculatedKineticMatrices.jl b/src/KineticForces/CalculatedKineticMatrices.jl index 5c1a1bc90..a698c848d 100644 --- a/src/KineticForces/CalculatedKineticMatrices.jl +++ b/src/KineticForces/CalculatedKineticMatrices.jl @@ -61,7 +61,8 @@ function compute_calculated_kinetic_matrices( kf_ctrl::KineticForcesControl=KineticForcesControl(), kinetic_profiles::Equilibrium.KineticProfileSplines, species::Union{Nothing,AbstractVector{<:Equilibrium.ResolvedNTVSpecies}}=nothing, - psis::Vector{Float64}=Float64[] + psis::Vector{Float64}=Float64[], + axis_psi_c::Float64=0.0 ) # The kernel is a pure function of psi (it evaluates equilibrium splines), so it can be # driven over any knot list; default is the full equilibrium grid. @@ -119,7 +120,7 @@ function compute_calculated_kinetic_matrices( # ordering fails, taking the widest-orbit species. Kernel evaluation is skipped where it is 0. env = ones(Float64, mpsi) if kf_ctrl.axis_validity_suppression - psi_c = kinetic_axis_validity_psi(splist, equil) + psi_c = axis_psi_c if psi_c > 0 env .= kinetic_axis_validity_envelope.(xs, psi_c) @info "Kinetic axis-validity suppression: psi_c=$(round(psi_c; sigdigits=3)), envelope reaches 1 at " * diff --git a/src/KineticForces/Output.jl b/src/KineticForces/Output.jl index 91f16959b..771873251 100644 --- a/src/KineticForces/Output.jl +++ b/src/KineticForces/Output.jl @@ -200,11 +200,15 @@ geometry, profile gradient lengths, the near-axis boundary and its applied envel far edge and steep-gradient regions are flagged rather than zeroed. """ function write_validity!(h5file::HDF5.File, kf_ctrl::KineticForcesControl, species, - kinetic_profiles, equil) + kinetic_profiles, equil, rationals::Vector{Float64}=Float64[]) kinetic_profiles === nothing && return nothing - vp = kinetic_validity_profiles(kinetic_profiles, equil; - zi=kf_ctrl.zi, mi=kf_ctrl.mi, electron=kf_ctrl.electron) - psi_c = axis_validity_boundary(kf_ctrl, species, kinetic_profiles, equil) + # One species sets all three of psi_c, envelope and is_valid: the widest-orbit species when a + # resolved set is available, else the control's single species. + vp = + species === nothing ? + kinetic_validity_profiles(kinetic_profiles, equil; zi=kf_ctrl.zi, mi=kf_ctrl.mi, electron=kf_ctrl.electron) : + kinetic_validity_profiles(species, equil) + psi_c = axis_validity_boundary(kf_ctrl, species, kinetic_profiles, equil, rationals) root = haskey(h5file, "KineticForces") ? h5file["KineticForces"] : create_group(h5file, "KineticForces") haskey(root, "Validity") && return nothing g = create_group(root, "Validity") diff --git a/src/KineticForces/Utils.jl b/src/KineticForces/Utils.jl index 2c922e1f2..80a04f1bf 100644 --- a/src/KineticForces/Utils.jl +++ b/src/KineticForces/Utils.jl @@ -90,6 +90,26 @@ function kinetic_resonance_psi_nodes(kinetic_profiles::Equilibrium.KineticProfil return _resonance_nodes_from_frequencies(wbhat_f, kinetic_profiles.omegaE_spline, wdhat_f, grid; n, nl, xeval) end +""" + orbit_widths(psi, T_spline, q_spline, avg_r, avg_R, mass, chrg, ro, bo) + +Thermal orbit-width scales of one species at ψ: the minor radius ⟨r⟩ and inverse aspect ratio ε, +the gyroradius ρ = √(2mT)/(Z·e·B₀), and the three widths that bound the zero-orbit-width ordering — +potato (q²ρ²R₀)^(1/3), banana qρ/√ε, and poloidal gyroradius qρ/ε. Single source of truth for both +the validity boundary and the diagnostic profiles. +""" +@inline function orbit_widths(psi::Float64, T_spline, q_spline, avg_r, avg_R, + mass::Float64, chrg::Float64, ro::Float64, bo::Float64) + r = avg_r(psi) + eps = max(r / avg_R(psi), 1e-6) + rho = sqrt(2 * mass * T_spline(psi)) / (abs(chrg) * bo) + q = abs(q_spline(psi)) + return (; r, eps, rho, + w_potato=cbrt(q^2 * rho^2 * ro), + rho_banana=q * rho / sqrt(eps), + rho_theta=q * rho / eps) +end + """ kinetic_axis_validity_psi(kinetic_profiles, equil; zi=1, mi=2, electron=false) → Float64 @@ -117,13 +137,9 @@ function kinetic_axis_validity_psi(kinetic_profiles::Equilibrium.KineticProfileS psi_c = 0.0 for psi in kinetic_profiles.xs psi <= 0 && continue - r = avg_r(psi) - r <= 0 && continue - eps = max(r / avg_R(psi), 1e-6) - rho = mass * sqrt(2 * T_spline(psi) / mass) / (abs(chrg) * bo) - q = abs(q_spline(psi)) - w_orbit = max(cbrt(q^2 * rho^2 * ro), q * rho / sqrt(eps), q * rho / eps) - w_orbit >= r && (psi_c = max(psi_c, psi)) + w = orbit_widths(psi, T_spline, q_spline, avg_r, avg_R, mass, chrg, ro, bo) + w.r <= 0 && continue + max(w.w_potato, w.rho_banana, w.rho_theta) >= w.r && (psi_c = max(psi_c, psi)) end return psi_c end @@ -203,14 +219,13 @@ function kinetic_validity_profiles(kinetic_profiles::Equilibrium.KineticProfileS is_valid = falses(n) for (i, x) in pairs(psi) r = avg_r(x) - eps = max(r / avg_R(x), 1e-6) - rho = mass * sqrt(2 * T_spline(x) / mass) / (abs(chrg) * bo) + w = orbit_widths(x, T_spline, q_spline, avg_r, avg_R, mass, chrg, ro, bo) + rho_i[i] = w.rho + rho_banana[i] = w.rho_banana + rho_theta[i] = w.rho_theta + w_potato[i] = w.w_potato q = abs(q_spline(x)) drdpsi = abs(r_deriv(x)) - rho_i[i] = rho - rho_banana[i] = q * rho / sqrt(eps) - rho_theta[i] = q * rho / eps - w_potato[i] = cbrt(q^2 * rho^2 * ro) r_minor[i] = r d_separatrix[i] = max(r_sep - r, 0.0) dP = abs(P_deriv(x)) @@ -227,6 +242,26 @@ function kinetic_validity_profiles(kinetic_profiles::Equilibrium.KineticProfileS psi_c=psi_c, envelope=envelope, is_valid=is_valid) end +""" + clear_rational_windows(psi_c, rationals) → Float64 + +Move the near-axis validity boundary outward until no rational surface lies inside the envelope's +transition band `[ψ_c, 2ψ_c]`. A rational sitting in the band gets its (near-singular) kinetic +increments multiplied by a rapidly varying, near-zero envelope, which the matrix splines cannot +represent — the resulting overshoot propagates NaNs into the stability solve. Where the orbit width +already reaches ⟨r⟩ the resonance is not trustworthy anyway, so the boundary is pushed past the +surface (suppressing it wholly) rather than cutting through it. +""" +function clear_rational_windows(psi_c::Float64, rationals::Vector{Float64})::Float64 + psi_c <= 0 && return psi_c + for _ in 1:length(rationals) + inside = filter(r -> psi_c - Equilibrium.RATIONAL_RES_RADIUS <= r <= 2 * psi_c, rationals) + isempty(inside) && break + psi_c = maximum(inside) + Equilibrium.RATIONAL_RES_RADIUS + end + return psi_c +end + """ axis_validity_boundary(kf_ctrl, species, kinetic_profiles, equil) → Float64 @@ -234,11 +269,14 @@ end disabled or no kinetic profiles were loaded. Falls back to `kf_ctrl.zi`/`mi` when the resolved species set is unavailable. """ -function axis_validity_boundary(kf_ctrl::KineticForcesControl, species, kinetic_profiles, equil)::Float64 +function axis_validity_boundary(kf_ctrl::KineticForcesControl, species, kinetic_profiles, equil, + rationals::Vector{Float64}=Float64[])::Float64 (kf_ctrl.axis_validity_suppression && kinetic_profiles !== nothing) || return 0.0 - species === nothing && return kinetic_axis_validity_psi(kinetic_profiles, equil; - zi=kf_ctrl.zi, mi=kf_ctrl.mi, electron=kf_ctrl.electron) - return kinetic_axis_validity_psi(species, equil) + psi_c = + species === nothing ? + kinetic_axis_validity_psi(kinetic_profiles, equil; zi=kf_ctrl.zi, mi=kf_ctrl.mi, electron=kf_ctrl.electron) : + kinetic_axis_validity_psi(species, equil) + return clear_rational_windows(psi_c, rationals) end """ @@ -260,7 +298,26 @@ function resonance_grid_nodes(ctrl, kf_ctrl::KineticForcesControl, kinetic_profi n=n_res, nl=kf_ctrl.nl, zi=kf_ctrl.zi, mi=kf_ctrl.mi, electron=kf_ctrl.electron, wdfac=kf_ctrl.wdfac)) end - psi_c = axis_validity_boundary(kf_ctrl, species, kinetic_profiles, equil) + psi_c = axis_validity_boundary(kf_ctrl, species, kinetic_profiles, equil, + Float64[sng.psifac for sng in intr.sing]) psi_c > 0 && filter!(p -> p > psi_c, nodes) return nodes end + +""" + kinetic_validity_profiles(species, equil) → NamedTuple + +Validity diagnostics for a resolved multi-species set, computed for the species that sets the +boundary (largest ψ_c). Reporting one species' profiles alongside another's ψ_c would make +`is_valid` contradict `envelope` in the same output group. +""" +function kinetic_validity_profiles(species::AbstractVector{<:Equilibrium.ResolvedNTVSpecies}, equil) + isempty(species) && error("kinetic_validity_profiles: empty species set") + widest = species[1] + psi_c = -1.0 + for sp in species + c = kinetic_axis_validity_psi(sp.profiles, equil; zi=sp.z, mi=sp.m, electron=sp.electron) + c > psi_c && (psi_c = c; widest = sp) + end + return kinetic_validity_profiles(widest.profiles, equil; zi=widest.z, mi=widest.m, electron=widest.electron) +end From 4dd32b280a86659d66d2ef871cd0131bb849b79c Mon Sep 17 00:00:00 2001 From: logan-nc Date: Fri, 4 Sep 2026 15:25:23 -0400 Subject: [PATCH 34/51] FFS/KF/PE - MINOR - Clean-code review follow-ups Single named constants for the kinetic singular threshold and its relaxed reporting fraction (they were duplicated literals in two files that could drift), one local-maxima pass instead of two, the regularization policy moved into PerturbedEquilibrium which owns reg_spot, the band-knot count named and justified, the struct field documented in the docstring per convention, and two allocating minimum(abs.(...)) checks replaced with any(). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LzbLFQKyuRE5DYZmLokKmk --- src/ForceFreeStates/Fourfit.jl | 1 + src/ForceFreeStates/Kinetic.jl | 15 +++++++---- src/ForceFreeStates/Surfaces/Finding.jl | 26 ++++++++++++------- src/GeneralizedPerturbedEquilibrium.jl | 22 +--------------- src/KineticForces/KineticForcesStructs.jl | 15 ++++++++--- .../PerturbedEquilibriumStructs.jl | 21 +++++++++++++++ 6 files changed, 60 insertions(+), 40 deletions(-) diff --git a/src/ForceFreeStates/Fourfit.jl b/src/ForceFreeStates/Fourfit.jl index 0f8095e3c..57cc28128 100644 --- a/src/ForceFreeStates/Fourfit.jl +++ b/src/ForceFreeStates/Fourfit.jl @@ -443,6 +443,7 @@ rational's resolution window, preserving the Δ′-stencil structure the equilib (`Equilibrium.RATIONAL_RES_RADIUS`). """ function core_capped_knots(xs::Vector{Float64}, rationals::Vector{Float64})::Vector{Int} + length(xs) < 3 && return collect(eachindex(xs)) cap_edge = 0.1 isempty(rationals) || (cap_edge = min(cap_edge, minimum(rationals) - Equilibrium.RATIONAL_RES_RADIUS)) in_rational_window(x) = any(abs(x - r) <= Equilibrium.RATIONAL_RES_RADIUS for r in rationals) diff --git a/src/ForceFreeStates/Kinetic.jl b/src/ForceFreeStates/Kinetic.jl index 9cbb8001f..e057276e1 100644 --- a/src/ForceFreeStates/Kinetic.jl +++ b/src/ForceFreeStates/Kinetic.jl @@ -1,4 +1,9 @@ +# Knots laid across the envelope's [ψ_c, 2ψ_c] transition. The envelope is a quintic smoothstep, +# so a cubic spline needs several interior knots to follow it without overshoot; nine keeps the +# residual below the kernel's own tolerance even on coarse decks. +const BAND_KNOTS = 9 + """ refine_grid_at_fbar_peaks(xs, kw, kt, evaluate, kin, equil, intr, psi_c; ngrid=1000, relaxed_frac=0.01, target=3, max_add=24) → (xs, kw, kt) @@ -14,8 +19,8 @@ and the existing values are reused. A well-resolved grid inserts nothing. function refine_grid_at_fbar_peaks(xs::Vector{Float64}, kw::Array{ComplexF64,3}, kt::Array{ComplexF64,3}, evaluate::Function, kin::KineticMatrices, equil::Equilibrium.PlasmaEquilibrium, intr::ForceFreeStatesInternal, psi_c::Float64; - ngrid::Int=1000, relaxed_frac::Float64=0.01, target::Int=3, max_add::Int=24, - cond_threshold::Float64=1e8) + ngrid::Int=1000, relaxed_frac::Float64=KINETIC_RELAXED_FRAC, target::Int=3, max_add::Int=24, + cond_threshold::Float64=KINETIC_SINGULAR_COND) lo, hi = xs[1], xs[end] scan = collect(range(lo, hi; length=ngrid)) @@ -45,8 +50,8 @@ function refine_grid_at_fbar_peaks(xs::Vector{Float64}, kw::Array{ComplexF64,3}, w = (scan[r] - scan[l]) / 3 for x in (scan[i], scan[i] - w, scan[i] + w) (lo < x < hi && x > 2 * psi_c) || continue - minimum(abs.(xs .- x)) < Equilibrium.MIN_KNOT_SPACING && continue - isempty(add) || minimum(abs.(add .- x)) >= Equilibrium.MIN_KNOT_SPACING || continue + any(y -> abs(y - x) < Equilibrium.MIN_KNOT_SPACING, xs) && continue + any(y -> abs(y - x) < Equilibrium.MIN_KNOT_SPACING, add) && continue push!(add, x) end end @@ -104,7 +109,7 @@ function build_kinetic_matrix_splines( # spline overshoot can land on a rational surface. Pin the band ends (the smoothstep is # only C² there) and resolve the transition with a fixed set of knots. band_knots(lo, hi) = axis_validity_psi_c > 0 ? - [x for x in range(axis_validity_psi_c, 2 * axis_validity_psi_c; length=9) if lo < x < hi] : Float64[] + [x for x in range(axis_validity_psi_c, 2 * axis_validity_psi_c; length=BAND_KNOTS) if lo < x < hi] : Float64[] # Get raw kinetic matrices (scaling is baked into each source) if ctrl.kinetic_source == "fixed" diff --git a/src/ForceFreeStates/Surfaces/Finding.jl b/src/ForceFreeStates/Surfaces/Finding.jl index 7c17bd5dc..9cc7b6112 100644 --- a/src/ForceFreeStates/Surfaces/Finding.jl +++ b/src/ForceFreeStates/Surfaces/Finding.jl @@ -241,6 +241,11 @@ function evaluate_fbar_condition(psi::Float64, kin::KineticMatrices, equil::Equi return cond(fbar) end +# Kinetic F̄ counts as singular above this condition number; structure within +# KINETIC_RELAXED_FRAC of it is reported as near-singular (the shifted/split resonances). +const KINETIC_SINGULAR_COND = 1.0e8 +const KINETIC_RELAXED_FRAC = 0.01 + """ find_kinetic_singular_surfaces!(mats, equil, intr; ngrid=2000, cond_threshold=1e8) @@ -259,7 +264,13 @@ Algorithm: 3. Refine each peak with golden-section minimization of -cond 4. Filter by threshold and resonance condition """ -function find_kinetic_singular_surfaces!(mats::MatrixSplines, equil::Equilibrium.PlasmaEquilibrium, intr::ForceFreeStatesInternal; ngrid::Int=2000, cond_threshold::Float64=1e8) +function find_kinetic_singular_surfaces!( + mats::MatrixSplines, + equil::Equilibrium.PlasmaEquilibrium, + intr::ForceFreeStatesInternal; + ngrid::Int=2000, + cond_threshold::Float64=KINETIC_SINGULAR_COND +) kin = mats.kinetic kin === nothing && error("find_kinetic_singular_surfaces! requires a kinetic fit; call build_kinetic_matrix_splines first") psilow = equil.profiles.xs[1] @@ -284,21 +295,16 @@ function find_kinetic_singular_surfaces!(mats::MatrixSplines, equil::Equilibrium intr.kinsing_scan_threshold = cond_threshold # Find local maxima of cond(F̄): points where cond increases then decreases - peak_indices = Int[] - for i in 2:(ngrid-1) - if cond_vals[i] > cond_vals[i-1] && cond_vals[i] > cond_vals[i+1] && cond_vals[i] > cond_threshold - push!(peak_indices, i) - end - end + local_maxima = [i for i in 2:(ngrid-1) if cond_vals[i] > cond_vals[i-1] && cond_vals[i] > cond_vals[i+1]] + peak_indices = filter(i -> cond_vals[i] > cond_threshold, local_maxima) # Peaks below the threshold are not singular surfaces, but they mark where the kinetic F̄ comes # closest to singular — the shifted/split resonances of Park & Logan Eq. (70). Report the # strongest few so sharp kinetic structure is visible rather than silent (on a DIII-D-like case # these track the NTV torque-density peaks at low collisionality/rotation). - subthreshold = [i for i in 2:(ngrid-1) if cond_vals[i] > cond_vals[i-1] && cond_vals[i] > cond_vals[i+1] && - cond_threshold / 100 < cond_vals[i] <= cond_threshold] + subthreshold = filter(i -> KINETIC_RELAXED_FRAC * cond_threshold < cond_vals[i] <= cond_threshold, local_maxima) if !isempty(subthreshold) - top = sort(subthreshold; by=i -> -cond_vals[i])[1:min(3, length(subthreshold))] + top = sort(subthreshold; by=i -> cond_vals[i], rev=true)[1:min(3, length(subthreshold))] @info "Kinetic F̄ near-singular structure below the singular threshold at " * join(["ψ=$(round(psi_grid[i]; digits=4)) (cond=$(round(cond_vals[i]; sigdigits=3)))" for i in top], ", ") * " — full scan in SingularSurfaces/Kinetic/scan_cond; check the ψ grid resolves these if results look grid-sensitive" diff --git a/src/GeneralizedPerturbedEquilibrium.jl b/src/GeneralizedPerturbedEquilibrium.jl index 574fa4d9f..aea43f817 100755 --- a/src/GeneralizedPerturbedEquilibrium.jl +++ b/src/GeneralizedPerturbedEquilibrium.jl @@ -855,26 +855,6 @@ function run_perturbed_equilibrium( return pe_state end -""" - kinetic_regularization_kwargs(ffs, kwargs) -> NamedTuple - -`reg_spot` smooths the **ideal** 1/(m−nq) divergence of ξ^ψ′ and ξ^α before they drive the NTV -integrand. A self-consistent kinetic solve has no such divergence — det(F̄) is complex and nonzero -at the rationals (Park & Logan, Phys. Plasmas 24, 032505 (2017) §III D) — so regularizing there -suppresses a finite physical response, and does so inconsistently, since ξ^ψ is never regularized. -Force `reg_spot = 0` for kinetic solves and log the override; ideal solves keep their setting. -""" -function kinetic_regularization_kwargs(ffs::ForceFreeStatesResult, kwargs) - base = values(kwargs) - default_reg = PerturbedEquilibrium.PerturbedEquilibriumControl().reg_spot - prev = get(base, :reg_spot, default_reg) - kinetic = ffs.mats.kinetic !== nothing - kinetic && prev != 0 && - @info "Self-consistent kinetic run: overriding reg_spot=$prev with 0 " * - "(the kinetic terms remove the ideal resonant singularity; see docs/src/kinetic_forces.md)" - return merge(base, (; reg_spot=kinetic ? 0.0 : prev)) -end - """ perturbed_equilibrium(ffs, rmp; forcing_modes=nothing, coil_sets=nothing, kwargs...) -> PerturbedEquilibriumState @@ -901,7 +881,7 @@ function perturbed_equilibrium( kwargs... ) ctrl = ffs.control - pe_ctrl = PerturbedEquilibrium.PerturbedEquilibriumControl(; kinetic_regularization_kwargs(ffs, kwargs)...) + pe_ctrl = PerturbedEquilibrium.PerturbedEquilibriumControl(; PerturbedEquilibrium.kinetic_regularization_kwargs(ffs, kwargs)...) pe_intr = PerturbedEquilibrium.PerturbedEquilibriumInternal(; dir_path=ffs.dir_path) # Inner-layer penetrated resonant field; zeros under ideal closure. diff --git a/src/KineticForces/KineticForcesStructs.jl b/src/KineticForces/KineticForcesStructs.jl index 3966d7410..a1a7f0a90 100644 --- a/src/KineticForces/KineticForcesStructs.jl +++ b/src/KineticForces/KineticForcesStructs.jl @@ -86,6 +86,13 @@ ctrl = KineticForcesControl(; (Symbol(k) => v for (k, v) in inputs["KineticForce Immutable: vary a field by building a new control rather than assigning to one (the multi-species loop does this per species, and `check_psi_quadrature_convergence`'s test builds a second control for its differing tolerance). + + - `axis_validity_suppression::Bool` - Suppress the calculated kinetic terms where the + zero-orbit-width ordering fails near the axis: below ψ_c — the outermost ψ at which a thermal + orbit width of the widest-orbit species reaches ⟨r⟩, moved clear of any rational surface the + transition would otherwise cut through — the increments are zeroed and the kernel is skipped, + rising to full strength at 2ψ_c through a C² envelope. Boundary and envelope come from the + equilibrium and kinetic profiles; there are no tuning parameters. Default `true`. """ @kwdef struct KineticForcesControl # Moment type @@ -151,7 +158,7 @@ builds a second control for its differing tolerance). nufac::Float64 = 1.0 # Collisionality scaling divxfac::Float64 = 1.0 # div(xi_perp) scaling - axis_validity_suppression::Bool = true # Suppress kinetic terms where the zero-orbit-width ordering fails near the axis (see kinetic_axis_validity_psi); envelope and boundary are profile-derived, no tuning parameters + axis_validity_suppression::Bool = true # documented in the `## Fields` docstring # Energy integration parameters nutype::String = "harmonic" # Collision operator: "zero", "small", "krook", "harmonic" @@ -334,9 +341,9 @@ The JBB deweighting algorithm (Fortran pentrc/inputs.f90:828-868): 4. Forward DFT back to m-space """ function set_perturbation_data!(kf_intr::KineticForcesInternal, pe_state::PerturbedEquilibrium.PerturbedEquilibriumState, - ffs::ForceFreeStates.ForceFreeStatesResult, - equil::Equilibrium.PlasmaEquilibrium, - metric::ForceFreeStates.MetricData) + ffs::ForceFreeStates.ForceFreeStatesResult, + equil::Equilibrium.PlasmaEquilibrium, + metric::ForceFreeStates.MetricData) # Copy mode numbers from FFS kf_intr.mlow = ffs.mlow kf_intr.mhigh = ffs.mhigh diff --git a/src/PerturbedEquilibrium/PerturbedEquilibriumStructs.jl b/src/PerturbedEquilibrium/PerturbedEquilibriumStructs.jl index ee0d23a85..52a97be33 100644 --- a/src/PerturbedEquilibrium/PerturbedEquilibriumStructs.jl +++ b/src/PerturbedEquilibrium/PerturbedEquilibriumStructs.jl @@ -26,6 +26,7 @@ Medium Priority (defer for MWE): - `singular_point_method::String` - Method for singular point treatment (default: "standard") Regularization: + # High Priority (MWE) - `reg_spot::Float64` - Regularization width for singular surface smoothing (default: 0.05). Set to 0 to disable. Must be ≥ 0. Forced to 0 in self-consistent kinetic runs, whose Euler-Lagrange operator has no resonant singularity to smooth. @@ -210,3 +211,23 @@ well-conditioned flux-space inductances L, Λ: plasma_energy::Float64 = 0.0 toroidal_torque::Float64 = 0.0 end + +""" + kinetic_regularization_kwargs(ffs, kwargs) -> NamedTuple + +`reg_spot` smooths the **ideal** 1/(m−nq) divergence of ξ^ψ′ and ξ^α before they drive the NTV +integrand. A self-consistent kinetic solve has no such divergence — det(F̄) is complex and nonzero +at the rationals (Park & Logan, Phys. Plasmas 24, 032505 (2017) §III D) — so regularizing there +suppresses a finite physical response, and does so inconsistently, since ξ^ψ is never regularized. +Force `reg_spot = 0` for kinetic solves and log the override; ideal solves keep their setting. +""" +function kinetic_regularization_kwargs(ffs::ForceFreeStatesResult, kwargs) + base = values(kwargs) + default_reg = PerturbedEquilibrium.PerturbedEquilibriumControl().reg_spot + prev = get(base, :reg_spot, default_reg) + kinetic = ffs.mats.kinetic !== nothing + kinetic && prev != 0 && + @info "Self-consistent kinetic run: overriding reg_spot=$prev with 0 " * + "(the kinetic terms remove the ideal resonant singularity; see docs/src/kinetic_forces.md)" + return merge(base, (; reg_spot=kinetic ? 0.0 : prev)) +end From 25d285eb8fa4e98676c18c7dc903726a7432686d Mon Sep 17 00:00:00 2001 From: d-burg Date: Fri, 4 Sep 2026 15:26:49 -0400 Subject: [PATCH 35/51] Regression - TEST - Track Q_e/Q_i on the n=1 SLAYER case and drop the n=2 case The n=2 case existed only to guard the n-dependence of the diamagnetic frequencies, which the unit test in runtests_slayer_inputs.jl already covers at a fraction of the cost (273 s/ref for the case). Its quantity set was a strict superset of the n=1 case, so fold the two additions it contributed -- Q_e and Q_i -- into the n=1 case and drop it. iota_e is their ratio and is blind to a common factor, so Q_e/Q_i are what pin the drive itself. The [overrides] rundir fix that this case needed stays: it is a general harness capability, not specific to the removed deck. Co-Authored-By: Claude Opus 5 --- regression-harness/cases/diiid_slayer_n1.toml | 22 ++- regression-harness/cases/diiid_slayer_n2.toml | 173 ------------------ 2 files changed, 21 insertions(+), 174 deletions(-) delete mode 100644 regression-harness/cases/diiid_slayer_n2.toml diff --git a/regression-harness/cases/diiid_slayer_n1.toml b/regression-harness/cases/diiid_slayer_n1.toml index 540ca7e50..90edf2e09 100644 --- a/regression-harness/cases/diiid_slayer_n1.toml +++ b/regression-harness/cases/diiid_slayer_n1.toml @@ -1,6 +1,8 @@ # Regression case: DIII-D-like H-mode n=1 SLAYER tearing-mode growth rates. # Uncoupled per-surface analysis with the AMR Q-plane scan; tracks the per-surface -# layer parameters and the extracted roots (Q, ω, γ) for the inner three rationals. +# layer parameters, the diamagnetic drive Q_e/Q_i, and the extracted roots (Q, ω, γ) +# for the inner three rationals. The n-scaling of Q_e/Q_i, which no single-n case can +# constrain, is covered by a unit test in test/runtests_slayer_inputs.jl instead. # Each [quantities.*] block names an HDF5 path in the run output, how to extract it, # and the noise floor below which a difference is treated as zero. [case] @@ -89,6 +91,24 @@ label = "SLAYER iota_e" noise_threshold = 1e-12 order = 19 +# Normalized diamagnetic frequencies. iota_e above is their ratio, so it is blind +# to a common factor on both; these pin the drive itself. +[quantities.slayer_Q_e] +h5path = "Tearing/PerSurface/Q_e" +type = "real_vector" +extract = "all_real" +label = "SLAYER Q_e" +noise_threshold = 1e-12 +order = 20 + +[quantities.slayer_Q_i] +h5path = "Tearing/PerSurface/Q_i" +type = "real_vector" +extract = "all_real" +label = "SLAYER Q_i" +noise_threshold = 1e-12 +order = 21 + # Tearing eigenvalue (coupled mode → length 1). The headline deliverable: # a real, nonzero growth rate on a realistic equilibrium. Root-extraction # is sensitive to the AMR cell topology and ODE solver, so the Q/ω/γ diff --git a/regression-harness/cases/diiid_slayer_n2.toml b/regression-harness/cases/diiid_slayer_n2.toml deleted file mode 100644 index 3444a51db..000000000 --- a/regression-harness/cases/diiid_slayer_n2.toml +++ /dev/null @@ -1,173 +0,0 @@ -# Regression case: DIII-D-like H-mode n=2 SLAYER tearing-mode growth rates. -# Reuses the n=1 SLAYER deck via [overrides] at toroidal mode number 2, where the deck's -# qmin = 1.204 puts the 3/2 surface in the plasma. Exists to guard the n-dependence of the -# layer build — in particular the diamagnetic frequencies ω_* ∝ n feeding Q_e/Q_i, which -# no n=1 case can constrain — with the inner rationals 3/2, 4/2, 5/2 as the tracked roots. -# Each [quantities.*] block names an HDF5 path in the run output, how to extract it, -# and the noise floor below which a difference is treated as zero. -[case] -name = "diiid_slayer_n2" -description = "DIII-D-like H-mode equilibrium, n=2, SLAYER tearing-mode analysis guarding the 3/2 surface and the n-scaling of Q_e/Q_i (reuses the n=1 deck via overrides)" -example_dir = "examples/DIIID-like_SLAYER_example" - -# Run the shared SLAYER deck at n = 2 (no separate n=2 example directory). -[overrides] -"ForceFreeStates.nn_low" = 2 -"ForceFreeStates.nn_high" = 2 - -# Per-surface SLAYER layer parameters (geometry + dimensionless) -[quantities.slayer_ising] -h5path = "Tearing/PerSurface/rational_index" -type = "real_vector" -extract = "all_real" -label = "SLAYER surface indices" -noise_threshold = 0 -order = 10 - -[quantities.slayer_m] -h5path = "Tearing/PerSurface/m" -type = "real_vector" -extract = "all_real" -label = "SLAYER poloidal m" -noise_threshold = 0 -order = 11 - -[quantities.slayer_n] -h5path = "Tearing/PerSurface/n" -type = "real_vector" -extract = "all_real" -label = "SLAYER toroidal n" -noise_threshold = 0 -order = 12 - -[quantities.slayer_rs] -h5path = "Tearing/PerSurface/rs" -type = "real_vector" -extract = "all_real" -label = "SLAYER minor radius rs" -noise_threshold = 1e-10 -order = 13 - -[quantities.slayer_sval_r] -h5path = "Tearing/PerSurface/sval_r" -type = "real_vector" -extract = "all_real" -label = "SLAYER r-based shear" -noise_threshold = 1e-10 -order = 14 - -[quantities.slayer_lu] -h5path = "Tearing/PerSurface/lu" -type = "real_vector" -extract = "all_real" -label = "SLAYER Lundquist S" -noise_threshold = 1e-8 -order = 15 - -[quantities.slayer_D_norm] -h5path = "Tearing/PerSurface/D_norm" -type = "real_vector" -extract = "all_real" -label = "SLAYER D_norm" -noise_threshold = 1e-10 -order = 16 - -[quantities.slayer_P_perp] -h5path = "Tearing/PerSurface/P_perp" -type = "real_vector" -extract = "all_real" -label = "SLAYER P_perp" -noise_threshold = 1e-8 -order = 17 - -[quantities.slayer_tauk] -h5path = "Tearing/PerSurface/tau_k" -type = "real_vector" -extract = "all_real" -label = "SLAYER tauk" -noise_threshold = 1e-12 -order = 18 - -[quantities.slayer_Q_e] -h5path = "Tearing/PerSurface/Q_e" -type = "real_vector" -extract = "all_real" -label = "SLAYER Q_e" -noise_threshold = 1e-12 -order = 20 - -[quantities.slayer_Q_i] -h5path = "Tearing/PerSurface/Q_i" -type = "real_vector" -extract = "all_real" -label = "SLAYER Q_i" -noise_threshold = 1e-12 -order = 21 - -[quantities.slayer_iota_e] -h5path = "Tearing/PerSurface/iota_e" -type = "real_vector" -extract = "all_real" -label = "SLAYER iota_e" -noise_threshold = 1e-12 -order = 19 - -# Tearing eigenvalue (coupled mode → length 1). The headline deliverable: -# a real, nonzero growth rate on a realistic equilibrium. Root-extraction -# is sensitive to the AMR cell topology and ODE solver, so the Q/ω/γ -# thresholds are absolute and modestly loose; re-pin intentionally if a -# solver/AMR change shifts the root inventory. -# Growth-rate / frequency / root outputs. Pinned for the inner three rational -# surfaces only (3/2, 4/2, 5/2) via `first_3`: the Δ'/γ contour search is -# numerically unreliable on the outermost surfaces near the edge, so those are -# deliberately not golden-tracked. -[quantities.slayer_Q] -h5path = "Tearing/Roots/Q_root" -type = "complex_vector" -extract = "first_3_complex" -label = "SLAYER Q_root [3/2,4/2,5/2]" -noise_threshold = 1e-4 -order = 30 - -[quantities.slayer_omega_Hz] -h5path = "Tearing/Roots/omega" -type = "real_vector" -extract = "first_3" -label = "SLAYER ω_Hz [3/2,4/2,5/2]" -noise_threshold = 1.0 -order = 32 - -[quantities.slayer_gamma_Hz] -h5path = "Tearing/Roots/gamma" -type = "real_vector" -extract = "first_3" -label = "SLAYER γ_Hz [3/2,4/2,5/2]" -noise_threshold = 1e-1 -order = 33 - -# no_root flag (1 = extraction failed). Pinned for the inner three surfaces -# so the 3/2, 4/2, 5/2 root inventory cannot drift silently. -[quantities.slayer_no_root] -h5path = "Tearing/Roots/no_root" -type = "real_vector" -extract = "first_3" -label = "SLAYER no_root flags [3/2,4/2,5/2]" -noise_threshold = 0 -order = 34 - -# Settings (catches accidental config drift) -[quantities.slayer_enabled] -h5path = "Tearing/enabled" -type = "int_scalar" -extract = "value" -label = "SLAYER enabled flag" -noise_threshold = 0 -order = 90 - -[quantities.runtime] -h5path = "" -type = "runtime" -extract = "value" -label = "Runtime (s)" -noise_threshold = 0.0 -order = 999 From 47feada50f492d08e1cb580a92efb0a477d522ac Mon Sep 17 00:00:00 2001 From: d-burg Date: Fri, 4 Sep 2026 17:21:19 -0400 Subject: [PATCH 36/51] InnerLayer.SLAYER - REFACTOR - Fold the toroidal mode number into the omega-star helper `_omega_star_at` returned the n = 1 diamagnetic frequency and left the call site to restore the factor n, so a helper named for omega-star did not return omega-star. Take the mode number as an argument instead and return the physical value, which drops the three-line fix-up comment at the call site and collapses the branch to a single expression. The parameter is `n_tor` rather than `n` because `n_e` and `n_i` are densities in the same scope. Numerically identical; the existing test asserts through the returned SLAYERParameters, so it covers the new arrangement unchanged. Co-Authored-By: Claude Opus 5 --- src/InnerLayer/SLAYER/LayerInputs.jl | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/src/InnerLayer/SLAYER/LayerInputs.jl b/src/InnerLayer/SLAYER/LayerInputs.jl index 294090295..1c772a3a6 100644 --- a/src/InnerLayer/SLAYER/LayerInputs.jl +++ b/src/InnerLayer/SLAYER/LayerInputs.jl @@ -189,21 +189,20 @@ function build_slayer_inputs(equil, sings, profiles::KineticProfiles; surface_da_dpsi(equil, ψ; theta=theta) end - # Per-surface ω_*e, ω_*i (diamagnetic frequencies) from spline - # derivatives. When `compute_omega_star=true` we override any ω_*e/ω_*i - # carried in `profiles`. Main-ion density is - # taken equal to the electron density (quasi-neutrality, matching the - # staging step). + # Per-surface ω_*e, ω_*i (diamagnetic frequencies) from spline derivatives. + # In flux coordinates ω_* = n·(dp/dψ)/(e·n_e), so the surface's toroidal mode + # number enters here. Main-ion density is taken equal to the electron density + # (quasi-neutrality, matching the staging step). chi1 = 2π * equil.psio - _omega_star_at(ψ) = begin + _omega_star_at(ψ, n_tor) = begin n_e = Float64(profiles.n_e(ψ)) dn_e = Float64(profiles.n_e(ψ; deriv=DerivOp(1))) T_e = Float64(profiles.T_e(ψ)) dT_e = Float64(profiles.T_e(ψ; deriv=DerivOp(1))) T_i = Float64(profiles.T_i(ψ)) dT_i = Float64(profiles.T_i(ψ; deriv=DerivOp(1))) - ω_star_e = (2π / chi1) * (T_e * dn_e / n_e + dT_e) - ω_star_i = -(2π / (Float64(z_i) * chi1)) * (T_i * dn_e / n_e + dT_i) + ω_star_e = n_tor * (2π / chi1) * (T_e * dn_e / n_e + dT_e) + ω_star_i = -n_tor * (2π / (Float64(z_i) * chi1)) * (T_i * dn_e / n_e + dT_i) return (ω_star_e, ω_star_i) end @@ -224,15 +223,9 @@ function build_slayer_inputs(equil, sings, profiles::KineticProfiles; n_res = sing.n[1] prof = profiles(psi) - # Override ω_*e, ω_*i with spline-derivative values when requested. In flux coordinates - # ω_* = n·(dp/dψ)/(e·n_e); `_omega_star_at` returns the n = 1 value, so restore the - # factor n here. Values supplied through `profiles` are taken to be physical already. - ω_e_use, ω_i_use = if compute_omega_star - ωe1, ωi1 = _omega_star_at(psi) - (n_res * ωe1, n_res * ωi1) - else - (prof.omega_e, prof.omega_i) - end + # Take ω_*e, ω_*i from the spline derivatives, or from `profiles` when the caller + # supplies them directly. `run_slayer` supplies zeros, so the latter is a library path. + ω_e_use, ω_i_use = compute_omega_star ? _omega_star_at(psi, n_res) : (prof.omega_e, prof.omega_i) # Pull geometric trapped-fraction inputs from ResistGeometry when # available (populated by ForceFreeStates.resist_eval_all!); else From bddd428b91a6004fbdeef186d8d3f1e6f703f9d2 Mon Sep 17 00:00:00 2001 From: d-burg Date: Fri, 4 Sep 2026 17:23:10 -0400 Subject: [PATCH 37/51] InnerLayer.SLAYER - MINOR - Drop the redundant D-normalization commentary Both comments restated what the code around them already says. The iota_e guard comment paraphrased the ArgumentError immediately below it, and the D_norm comment repeated a formula the struct docstring table already carries, next to a row that already defines iota_e = Q_e/(Q_e - Q_i). Co-Authored-By: Claude Opus 5 --- src/InnerLayer/SLAYER/LayerParameters.jl | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/InnerLayer/SLAYER/LayerParameters.jl b/src/InnerLayer/SLAYER/LayerParameters.jl index ea707e17c..65ca547ad 100644 --- a/src/InnerLayer/SLAYER/LayerParameters.jl +++ b/src/InnerLayer/SLAYER/LayerParameters.jl @@ -329,9 +329,6 @@ function slayer_parameters(; ) ) iota_e = Q_e / Q_e_minus_Q_i - # D below takes sqrt(iota_e). iota_e lies in (0,1) whenever ω_*e and ω_*i have - # opposite signs — the physical case and the convention stated above. Surface a - # negative value as a clear error rather than a bare DomainError. iota_e > 0 || throw( ArgumentError( @@ -355,9 +352,6 @@ function slayer_parameters(; # Normalized beta-related width and Δ-normalization d_beta = c_beta * d_i - # D = S^(1/3)·iota_e^(1/2)·(d_beta/r_s), Fitzpatrick's ion-sound-radius normalization. - # iota_e is the electron share of the total diamagnetic frequency; it reduces to - # 1/(1+tau) only when the electron and ion pressure-gradient scale lengths match. D_norm = (d_beta / rs) * lu^(1.0 / 3.0) * sqrt(iota_e) delta_n = lu^(1.0 / 3.0) / rs From e01d105bacb24789a951434e1d384eeffe3235f9 Mon Sep 17 00:00:00 2001 From: logan-nc Date: Sat, 12 Sep 2026 09:11:56 -0400 Subject: [PATCH 38/51] Equilibrium - BUGFIX! - Build the root-area weight with the inverse Fourier transform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit compute_sqrtamat took each unit mode to θ-space with transpose(basis)/N instead of the inverse transform adjoint(basis), so the √(J|∇ψ|) convolution matrix came out with its rows reflected in m and scaled by 1/mtheta: a matrix with zero diagonal and norm ~300 times too small in place of the near-identity weight Fortran GPEC writes as J_surf_2. Everything built on it was wrong — the b̃→b̄ operator, the flux conform R = S·A, the field-space response matrices, the root-area-weighted forcing and response fields, and the conformed singular-coupling matrices. On the benchmark case the corrected operator matches the Fortran J_surf_2 to the grid difference (correlation 1.0000, diagonal within 0.05 %). The new test checks the diagonal, Hermiticity, the weighted Parseval identity on the complete basis, and the inverse operator on the Solovev surface. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BzKDmtKYDotJWrcxWf1oJu --- src/Equilibrium/CoordinateInvariant.jl | 19 ++++---- test/runtests_coordinate_invariant.jl | 67 ++++++++++++++++++++------ 2 files changed, 62 insertions(+), 24 deletions(-) diff --git a/src/Equilibrium/CoordinateInvariant.jl b/src/Equilibrium/CoordinateInvariant.jl index 497c365fd..bbeea356c 100644 --- a/src/Equilibrium/CoordinateInvariant.jl +++ b/src/Equilibrium/CoordinateInvariant.jl @@ -54,13 +54,14 @@ w(θ) = √(J·|∇ψ|). Operationally, sqrtamat is the mode-space √weight operator: for a field b with Fourier coefficients b_fft, it satisfies the identity -`‖sqrtamat·b_fft‖² = N² · ∫ |b|² · J|∇ψ| dθ` -which is Jacobian-invariant on a given flux surface (see -`scripts/test_power_norm_invariance.jl`). - -The backward step uses exp(−imθ)/(1/N) normalization paired with the Julia -forward FT exp(+imθ) so that round-trip = identity and the convolution -structure is correct. +`‖sqrtamat·b_fft‖² = ∫ |b|² · J|∇ψ| dθ` (θ normalized to [0, 1)) +which is Jacobian-invariant on a given flux surface. Its diagonal is the θ-average of +√(J|∇ψ|), so `sqrtamat/√jarea` has a diagonal close to (and never above) one; this is the +matrix Fortran GPEC writes as `J_surf_2` after that division. + +Each column is the unit mode e^{+i m_k θ} taken to θ-space with the inverse transform +(`adjoint(basis)`, no 1/N), weighted pointwise, and brought back with the forward transform +(`basis/N`), so the round trip is the identity and the convolution structure is correct. """ function compute_sqrtamat( equil::PlasmaEquilibrium, @@ -78,8 +79,8 @@ function compute_sqrtamat( e_k .= 0.0 e_k[k] = 1.0 + 0.0im - # Standard backward FT: f(θ_j) = (1/N) Σ_m c_m exp(-imθ_j) = (transpose(basis) * c) / N - theta_vec = (transpose(ft.basis) * e_k) ./ mtheta + # Inverse FT of the unit mode: f(θ_j) = exp(+i m_k θ_j) (adjoint(basis) * c, see FourierTransforms) + theta_vec = adjoint(ft.basis) * e_k # Multiply pointwise by √(J·|∇ψ|) in theta-space theta_vec .*= sqrt_jdp diff --git a/test/runtests_coordinate_invariant.jl b/test/runtests_coordinate_invariant.jl index 39b20330a..157c76c54 100644 --- a/test/runtests_coordinate_invariant.jl +++ b/test/runtests_coordinate_invariant.jl @@ -43,31 +43,31 @@ end fm = PE.field_space_response_matrices(Λ, L, P, ϱ, S, jarea) @testset "Flux recovery contract (round-trip via R = S·A)" begin - @test R * fm.permeability / R ≈ P rtol = 1e-10 - @test R * fm.surface_inductance * R' ≈ L rtol = 1e-10 - @test R * fm.plasma_inductance * R' ≈ Λ rtol = 1e-10 - @test (R') \ fm.reluctance / R ≈ ϱ rtol = 1e-10 + @test R * fm.permeability / R ≈ P rtol = 1e-10 + @test R * fm.surface_inductance * R' ≈ L rtol = 1e-10 + @test R * fm.plasma_inductance * R' ≈ Λ rtol = 1e-10 + @test (R') \ fm.reluctance / R ≈ ϱ rtol = 1e-10 end @testset "Area-weighted (b̄) recovery via S (= flux/A²)" begin # b̄-space inductance L_b̄ = S·L̃·S† = L/A² since S·R⁻¹ = A⁻¹·I. - @test S * fm.surface_inductance * S' ≈ L ./ jarea^2 rtol = 1e-10 - @test S * fm.plasma_inductance * S' ≈ Λ ./ jarea^2 rtol = 1e-10 - @test S * fm.permeability / S ≈ P rtol = 1e-10 # similarity: A cancels + @test S * fm.surface_inductance * S' ≈ L ./ jarea^2 rtol = 1e-10 + @test S * fm.plasma_inductance * S' ≈ Λ ./ jarea^2 rtol = 1e-10 + @test S * fm.permeability / S ≈ P rtol = 1e-10 # similarity: A cancels end @testset "Internal consistency of the b̃ transform rules" begin - @test fm.permeability ≈ fm.plasma_inductance / fm.surface_inductance rtol = 1e-10 + @test fm.permeability ≈ fm.plasma_inductance / fm.surface_inductance rtol = 1e-10 L̃inv = inv(fm.surface_inductance) - @test fm.reluctance ≈ L̃inv * (fm.plasma_inductance - fm.surface_inductance) * L̃inv rtol = 1e-10 + @test fm.reluctance ≈ L̃inv * (fm.plasma_inductance - fm.surface_inductance) * L̃inv rtol = 1e-10 end @testset "Energy-scalar invariance (flux ↔ b̃)" begin Φ = ComplexF64[cis(0.3k) / k for k in 1:n] b̃ = R \ Φ # root-area-weighted field - @test dot(Φ, L \ Φ) ≈ dot(b̃, fm.surface_inductance \ b̃) rtol = 1e-10 - @test dot(Φ, Λ \ Φ) ≈ dot(b̃, fm.plasma_inductance \ b̃) rtol = 1e-10 - @test dot(Φ, ϱ * Φ) ≈ dot(b̃, fm.reluctance * b̃) rtol = 1e-10 + @test dot(Φ, L \ Φ) ≈ dot(b̃, fm.surface_inductance \ b̃) rtol = 1e-10 + @test dot(Φ, Λ \ Φ) ≈ dot(b̃, fm.plasma_inductance \ b̃) rtol = 1e-10 + @test dot(Φ, ϱ * Φ) ≈ dot(b̃, fm.reluctance * b̃) rtol = 1e-10 end @testset "Three-field vector relations (b, b̃, b̄; flux = A·b̄)" begin @@ -75,9 +75,9 @@ end b̄ = S * b̃ # area-weighted field b = (S .* sqrt(jarea)) \ b̃ # bare field b = Σ⁻¹·b̃ Φ = R * b̃ # poloidal flux - @test Φ ≈ jarea .* b̄ rtol = 1e-12 # Φ = A·b̄ - @test b̄ ≈ Φ ./ jarea rtol = 1e-12 - @test (S .* sqrt(jarea)) * b ≈ b̃ rtol = 1e-12 # Σ·b = b̃ + @test Φ ≈ jarea .* b̄ rtol = 1e-12 # Φ = A·b̄ + @test b̄ ≈ Φ ./ jarea rtol = 1e-12 + @test (S .* sqrt(jarea)) * b ≈ b̃ rtol = 1e-12 # Σ·b = b̃ end end @@ -97,3 +97,40 @@ end e_old = sort(real.(eigvals(Mold' * W * Mold))) @test e_new ≈ e_old rtol = 1e-12 end + +# The √weight operator on a real flux surface: its diagonal is the θ-average of √(J|∇ψ|), it is +# Hermitian, it carries the area-weighted field energy exactly (Parseval with the weight), and +# it inverts against area_to_rootarea_weight. A wrong transform convention (sign of the +# exponent or a stray 1/N) fails every one of these. +@testset "sqrtamat on the Solovev surface" begin + using TOML + EQ = GeneralizedPerturbedEquilibrium.Equilibrium + equil_dir = joinpath(@__DIR__, "..", "examples", "Solovev_ideal_example") + inputs = TOML.parsefile(joinpath(equil_dir, "gpec.toml")) + eq_config = EQ.EquilibriumConfig(inputs["Equilibrium"], equil_dir) + equil = EQ.setup_equilibrium(eq_config, EQ.SolovevConfig(inputs["SOL_INPUT"])) + psi = equil.rzphi_xs[end] + mtheta = length(equil.rzphi_ys) + mpert, mlow = 21, -8 + ft = GeneralizedPerturbedEquilibrium.Utilities.FourierTransforms.FourierTransform(mtheta, mpert, mlow) + w = EQ.compute_sqrt_jac_delpsi(equil, psi, mtheta) + jarea = EQ.flux_surface_area(equil, psi, mtheta) + Σ = EQ.compute_sqrtamat(equil, psi, ft) + S = EQ.rootarea_to_area_weight(equil, psi, ft) + @test size(Σ) == (mpert, mpert) + @test all(isapprox.(diag(Σ), sum(w) / mtheta; rtol=1e-12)) + @test norm(Σ - Σ') / norm(Σ) < 1e-12 + @test all(0 .< real.(diag(S)) .<= 1) # Cauchy–Schwarz: ⟨√(J|∇ψ|)⟩ ≤ √⟨J|∇ψ|⟩ + @test S ≈ Σ ./ sqrt(jarea) + # Each column is the forward transform of the weighted unit mode (the definition). + b = ComplexF64[cis(0.7k) * (1 + 0.1k) for k in 1:mpert] + @test Σ * b ≈ ft(w .* (adjoint(ft.basis) * b)) rtol = 1e-12 + # Weighted Parseval on the complete basis (mpert = mtheta, no truncation): + # ‖Σ·b‖² = (1/N) Σ_j J|∇ψ|_j |f_j|², f the θ-space field of b (θ ∈ [0,1)). + ft_full = GeneralizedPerturbedEquilibrium.Utilities.FourierTransforms.FourierTransform(mtheta, mtheta, -(mtheta ÷ 2)) + Σ_full = EQ.compute_sqrtamat(equil, psi, ft_full) + b_full = ComplexF64[cis(0.3k) / (1 + abs(k - mtheta ÷ 2)) for k in 1:mtheta] + f_full = adjoint(ft_full.basis) * b_full + @test sum(abs2, Σ_full * b_full) ≈ sum(w .^ 2 .* abs2.(f_full)) / mtheta rtol = 1e-10 + @test S * EQ.area_to_rootarea_weight(equil, psi, ft) ≈ I atol = 1e-10 +end From 5cb7adc6aca203b3ee6da1b6ccbd4da9a09dba20 Mon Sep 17 00:00:00 2001 From: logan-nc Date: Sat, 12 Sep 2026 09:20:07 -0400 Subject: [PATCH 39/51] Repo - TEST - Track the root-area-weighted forcing field in the diiid_n1 harness case Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BzKDmtKYDotJWrcxWf1oJu --- regression-harness/cases/diiid_n1.toml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/regression-harness/cases/diiid_n1.toml b/regression-harness/cases/diiid_n1.toml index feebc0c7f..dcba09788 100644 --- a/regression-harness/cases/diiid_n1.toml +++ b/regression-harness/cases/diiid_n1.toml @@ -411,3 +411,14 @@ extract = "value" label = "Runtime (s)" noise_threshold = 0.0 order = 999 + +# Root-area-weighted (coordinate-invariant) forcing field b̃ on the control surface: the +# applied coil spectrum pushed through the √(J|∇ψ|) weight operator. Tracks the field-space +# conversion that every ResponseMatrices/ field-space output depends on. +[quantities.forcing_b_rootarea_norm] +h5path = "PerturbedEquilibrium/forcing_b_root_area" +type = "complex_vector" +extract = "norm" +label = "||forcing b~|| (root-area-weighted)" +noise_threshold = 1e-10 +order = 1000 From 9674e1ef48f9b9ec9e47aa9fd37bf41c4e553fb2 Mon Sep 17 00:00:00 2001 From: logan-nc Date: Sat, 12 Sep 2026 09:31:51 -0400 Subject: [PATCH 40/51] ForcingTerms - BUGFIX! - Take the coil-grid helicity from the equilibrium file's current sign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The toroidal handedness of the coil boundary grid, helicity = sign(B_T) × sign(I_p), took the current sign from the computed plasma current, which is always positive because the reader makes ψ positive at the axis. Every negative-current equilibrium file therefore got the mirror image of its coil field; positive-current files (the DIII-D examples) hid it. The sign is now read from the g-file's current header value and from IMAS global ip, carried on DirectIngest/DirectRunInput/EquilibriumParameters as ip_sign, written as Equilibrium/I_p_sign, and used by sample_boundary_grid and Analysis.CoilForcing; rerun snapshots written before the field default to +1. On the benchmark case every coil set's spectrum now matches Fortran's Phi_coil to four digits. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BzKDmtKYDotJWrcxWf1oJu --- src/Analysis/CoilForcing.jl | 6 +- src/Equilibrium/AnalyticEquilibrium.jl | 129 +++++++++++++------------ src/Equilibrium/DirectEquilibrium.jl | 1 + src/Equilibrium/EquilibriumTypes.jl | 28 ++++-- src/Equilibrium/ReadEquilibrium.jl | 32 +++--- src/ForcingTerms/CoilFourier.jl | 11 +-- src/HDF5Schema.jl | 21 ++-- src/Rerun.jl | 17 ++-- test/runtests_coils.jl | 32 ++++++ 9 files changed, 172 insertions(+), 105 deletions(-) diff --git a/src/Analysis/CoilForcing.jl b/src/Analysis/CoilForcing.jl index 050b8cf45..660d0505b 100644 --- a/src/Analysis/CoilForcing.jl +++ b/src/Analysis/CoilForcing.jl @@ -238,11 +238,7 @@ function plot_mode_spectrum(forcing_modes; mlow=nothing, mhigh=nothing, save_pat end # Helicity = sign(Bt)·sign(Ip); the SFL→machine toroidal-angle handedness. -function _equil_helicity(equil) - bt = isnothing(equil.params.bt_sign) ? 1 : Int(sign(equil.params.bt_sign)) - ip = isnothing(equil.params.crnt) ? 1 : Int(sign(equil.params.crnt)) - return bt * ip -end +_equil_helicity(equil) = equil.params.bt_sign * equil.params.ip_sign # Unpack `modes` (a Vector of ForcingMode-like objects filtered to toroidal mode `n`, # or a `(m_vals, amplitudes)` tuple) into parallel m and complex-amplitude vectors. diff --git a/src/Equilibrium/AnalyticEquilibrium.jl b/src/Equilibrium/AnalyticEquilibrium.jl index 72a16a05c..767f2b718 100644 --- a/src/Equilibrium/AnalyticEquilibrium.jl +++ b/src/Equilibrium/AnalyticEquilibrium.jl @@ -242,10 +242,10 @@ Reference: R. Fitzpatrick, TJ code, https://github.com/rfitzp/TJ function tj_analytic_f1(x::Float64, nu::Float64, qc::Float64) if x < 0.1 x2 = x * x - return x2 * (1 - (nu-1)*x2/2 + (nu-1)*(nu-2)*x2*x2/6 - - (nu-1)*(nu-2)*(nu-3)*x2*x2*x2/24) / qc + return x2 * (1 - (nu - 1) * x2 / 2 + (nu - 1) * (nu - 2) * x2 * x2 / 6 - + (nu - 1) * (nu - 2) * (nu - 3) * x2 * x2 * x2 / 24) / qc else - return (1 - (1 - x*x)^nu) / (nu * qc) + return (1 - (1 - x * x)^nu) / (nu * qc) end end @@ -259,10 +259,10 @@ parameterization. function tj_analytic_f1p(x::Float64, nu::Float64, qc::Float64) if x < 0.1 x2 = x * x - return 2*x * (1 - (nu-1)*x2 + (nu-1)*(nu-2)*x2*x2/2 - - (nu-1)*(nu-2)*(nu-3)*x2*x2*x2/6) / qc + return 2 * x * (1 - (nu - 1) * x2 + (nu - 1) * (nu - 2) * x2 * x2 / 2 - + (nu - 1) * (nu - 2) * (nu - 3) * x2 * x2 * x2 / 6) / qc else - return 2*x * (1 - x*x)^(nu-1) / qc + return 2 * x * (1 - x * x)^(nu - 1) / qc end end @@ -273,10 +273,11 @@ GPEC adaptation of the analytic shape ODE used in R. Fitzpatrick's TJ code `tj_analytic_run_direct` call so both pipelines share identical numerics. Fields: + - physical: a, R0, qc, mu, pc, B0 - derived: epsa2 = (a/R0)² - near-axis BC constants: rmin, x0 = rmin, r0 = rmin·a, f1c = 1/qc, - p2ppc = d²p₂/dx²|_0 = −2·μ·pc + p2ppc = d²p₂/dx²|_0 = −2·μ·pc """ struct TJAnalyticShapeParams a::Float64 @@ -293,15 +294,15 @@ struct TJAnalyticShapeParams p2ppc::Float64 end -function TJAnalyticShapeParams(tj::TJAnalyticConfig; rmin::Float64 = 1e-4) +function TJAnalyticShapeParams(tj::TJAnalyticConfig; rmin::Float64=1e-4) a, R0 = tj.lar_a, tj.lar_r0 - mu = max(tj.mu, 1.001) + mu = max(tj.mu, 1.001) return TJAnalyticShapeParams( a, R0, tj.qc, mu, tj.pc, tj.B0, (a / R0)^2, rmin, rmin, rmin * a, 1.0 / tj.qc, - -2.0 * mu * tj.pc, + -2.0 * mu * tj.pc ) end @@ -315,9 +316,9 @@ The params argument carries TJAnalyticShapeParams fields plus the current `nu`. """ function tj_analytic_shape_rhs!(dy, y, params, r) (; a, B0, qc, mu, pc, epsa2, nu) = params - x = r / a + x = r / a xfac = max(1 - x^2, 0.0) - f1 = tj_analytic_f1(x, nu, qc) + f1 = tj_analytic_f1(x, nu, qc) f1px = tj_analytic_f1p(x, nu, qc) p2px = -2 * mu * pc * x * xfac^(mu - 1) @@ -340,16 +341,19 @@ function tj_analytic_shape_rhs!(dy, y, params, r) # f₃'(x) for Hₙ = Vₙ = 0 (n ≥ 2 harmonics rescaled to zero, as in the # TJ-analytic benchmark configuration of Fitzpatrick's TJ code). g2, f3 = y[2], y[5] - f3p_x = -f3 * f1px / f1 - - f1 * (3 * x^2 / 2 - 2 * x * H1p + H1p^2) / x + - f1px * (g2 - 3 * x^2 / 4 + H1 + 3 * H1p^2 / 2) + - x^2 * p2px * (g2 + x^2 / 2 - 3 * x * H1p - 2 * H1) / f1 + f3p_x = + -f3 * f1px / f1 - + f1 * (3 * x^2 / 2 - 2 * x * H1p + H1p^2) / x + + f1px * (g2 - 3 * x^2 / 4 + H1 + 3 * H1p^2 / 2) + + x^2 * p2px * (g2 + x^2 / 2 - 3 * x * H1p - 2 * H1) / f1 dy[5] = f3p_x / a return nothing end -"""Initial conditions at x = x0, matching the TJ-analytic model's near-axis -expansion (cf. R. Fitzpatrick's TJ code, https://github.com/rfitzp/TJ).""" +""" +Initial conditions at x = x0, matching the TJ-analytic model's near-axis +expansion (cf. R. Fitzpatrick's TJ code, https://github.com/rfitzp/TJ). +""" function tj_analytic_shape_initial(p::TJAnalyticShapeParams, nu::Float64) f1_0 = tj_analytic_f1(p.x0, nu, p.qc) y0 = zeros(5) @@ -368,14 +372,14 @@ downstream Hₙ / ψ splines sit on uniform nodes); leave it `nothing` for the default adaptive save pattern used by `tj_analytic_run`. """ function tj_analytic_shape_solve(p::TJAnalyticShapeParams, nu::Float64; - reltol::Float64 = 1e-7, abstol::Float64 = 1e-8, - saveat = nothing) - rhs_params = (; p.a, p.B0, p.qc, p.mu, p.pc, p.epsa2, nu = nu) + reltol::Float64=1e-7, abstol::Float64=1e-8, + saveat=nothing) + rhs_params = (; p.a, p.B0, p.qc, p.mu, p.pc, p.epsa2, nu=nu) prob = ODEProblem(tj_analytic_shape_rhs!, tj_analytic_shape_initial(p, nu), (p.r0, p.a), rhs_params) if saveat === nothing - return solve(prob, Vern9(); reltol, abstol, maxiters = 10000, dense = false) + return solve(prob, Vern9(); reltol, abstol, maxiters=10000, dense=false) else - return solve(prob, Vern9(); reltol, abstol, maxiters = 10000, saveat = saveat) + return solve(prob, Vern9(); reltol, abstol, maxiters=10000, saveat=saveat) end end @@ -389,9 +393,9 @@ O(εa²) correction relative to the lowest-order guess ν = qa/qc, which matters for the TJ-analytic benchmark at large ε. Falls back to the lowest-order ν if the bracket search diverges. """ -function tj_analytic_find_nu(p::TJAnalyticShapeParams, qa_target::Float64; reltol::Float64 = 1e-7) +function tj_analytic_find_nu(p::TJAnalyticShapeParams, qa_target::Float64; reltol::Float64=1e-7) function q2_edge(nu::Float64) - sol = tj_analytic_shape_solve(p, nu; reltol) + sol = tj_analytic_shape_solve(p, nu; reltol) g2end = sol.u[end][2] f3end = sol.u[end][5] f1end = tj_analytic_f1(1.0, nu, p.qc) @@ -400,7 +404,7 @@ function tj_analytic_find_nu(p::TJAnalyticShapeParams, qa_target::Float64; relto nu_guess = qa_target / p.qc return try find_zero(nu -> q2_edge(nu) - qa_target, (0.5 * nu_guess, 2 * nu_guess); - atol = 1e-8, rtol = 1e-10) + atol=1e-8, rtol=1e-10) catch err @warn "ν root-find failed for TJ-analytic equilibrium; falling back to lowest-order ν = qa/qc" error = err nu_guess @@ -443,16 +447,16 @@ included; they are zero in the TJ-analytic benchmark scans. Reference: R. Fitzpatrick, TJ code, https://github.com/rfitzp/TJ """ function tj_analytic_run(equil_input::EquilibriumConfig, tj::TJAnalyticConfig) - a, R0 = tj.lar_a, tj.lar_r0 + a, R0 = tj.lar_a, tj.lar_r0 qc, mu = tj.qc, max(tj.mu, 1.001) pc, B0 = tj.pc, tj.B0 ma, mtau = tj.ma, tj.mtau p = TJAnalyticShapeParams(tj) - epsa2 = p.epsa2 - p00_phys = B0^2 * epsa2 * pc # μ₀P = B₀²·εa²·p₂ at axis + epsa2 = p.epsa2 + p00_phys = B0^2 * epsa2 * pc # μ₀P = B₀²·εa²·p₂ at axis - nu = tj_analytic_find_nu(p, tj.qa; reltol = equil_input.etol) - sol = tj_analytic_shape_solve(p, nu; reltol = equil_input.etol) + nu = tj_analytic_find_nu(p, tj.qa; reltol=equil_input.etol) + sol = tj_analytic_shape_solve(p, nu; reltol=equil_input.etol) r_arr = sol.t y_mat = reduce(hcat, sol.u)' @@ -468,7 +472,7 @@ function tj_analytic_run(equil_input::EquilibriumConfig, tj::TJAnalyticConfig) xfac = max(1 - x^2, 0.0) f1 = tj_analytic_f1(x, nu, qc) - ψ = y_mat[i, 1] + ψ = y_mat[i, 1] g2 = y_mat[i, 2] H1 = y_mat[i, 3] f3 = y_mat[i, 5] @@ -507,7 +511,7 @@ function tj_analytic_run(equil_input::EquilibriumConfig, tj::TJAnalyticConfig) f = spl(r; hint=hint) # f[1]=F, f[2]=P, f[3]=q, f[4]=ψ, f[5]=g₂, f[6]=H₁ - sq_xs[ia] = f[4] / psio + sq_xs[ia] = f[4] / psio sq_fs[ia, 1] = f[1] # F sq_fs[ia, 2] = f[2] # P sq_fs[ia, 3] = f[3] # q @@ -525,7 +529,7 @@ function tj_analytic_run(equil_input::EquilibriumConfig, tj::TJAnalyticConfig) for itau in 1:(mtau+1) θ = 2π * (itau - 1) / mtau rzphi_fs_nodes[ia, itau, 1] = R0 + Δ + α * r * cos(θ) - rzphi_fs_nodes[ia, itau, 2] = α * r * sin(θ) + rzphi_fs_nodes[ia, itau, 2] = α * r * sin(θ) end end @@ -579,38 +583,38 @@ ODE (g₂, H₁, H₁', f₃), the `GetPSIvac` / `GetHHvac` vacuum extension, an EFIT-writer (R, Z) → (r, w) Newton inversion that this routine adapts. """ function tj_analytic_run_direct(equil_input::EquilibriumConfig, tj::TJAnalyticConfig; - nrbox::Int = 257, nzbox::Int = 257, rc::Float64 = 1.2) - a, R0 = tj.lar_a, tj.lar_r0 + nrbox::Int=257, nzbox::Int=257, rc::Float64=1.2) + a, R0 = tj.lar_a, tj.lar_r0 qc, mu = tj.qc, max(tj.mu, 1.001) pc, B0 = tj.pc, tj.B0 p = TJAnalyticShapeParams(tj) epsa, epsa2 = p.a / p.R0, p.epsa2 - p00_phys = B0^2 * epsa2 * pc + p00_phys = B0^2 * epsa2 * pc # ν root-find (cf. Fitzpatrick TJ's Setnu): q₂(1) = qa_target. - nu = tj_analytic_find_nu(p, tj.qa; reltol = equil_input.etol) + nu = tj_analytic_find_nu(p, tj.qa; reltol=equil_input.etol) # Dense saveat so the downstream splines (H₁, g₂, f₃, ψ) are evaluated on # a fine uniform r grid rather than the ~30 adaptive Vern9 steps — otherwise # the (R, Z) → (r, w) Newton iteration hits spline interpolation artifacts. - dense_r = collect(range(p.r0, p.a; length = 1024)) - sol = tj_analytic_shape_solve(p, nu; reltol = equil_input.etol, - abstol = 1e-10, saveat = dense_r) - r_arr = sol.t - y_mat = reduce(hcat, sol.u)' + dense_r = collect(range(p.r0, p.a; length=1024)) + sol = tj_analytic_shape_solve(p, nu; reltol=equil_input.etol, + abstol=1e-10, saveat=dense_r) + r_arr = sol.t + y_mat = reduce(hcat, sol.u)' # Radial splines in the TJ-analytic dimensionless x = r/a on a clean grid for H₁ etc. x_nodes = r_arr ./ a - ψ_of_r = cubic_interp(r_arr, y_mat[:, 1]; extrap=ExtendExtrap()) - H1_of_x = cubic_interp(x_nodes, y_mat[:, 3]; extrap=ExtendExtrap()) + ψ_of_r = cubic_interp(r_arr, y_mat[:, 1]; extrap=ExtendExtrap()) + H1_of_x = cubic_interp(x_nodes, y_mat[:, 3]; extrap=ExtendExtrap()) H1p_of_x = cubic_interp(x_nodes, y_mat[:, 4]; extrap=ExtendExtrap()) - g2_of_x = cubic_interp(x_nodes, y_mat[:, 2]; extrap=ExtendExtrap()) - f3_of_x = cubic_interp(x_nodes, y_mat[:, 5]; extrap=ExtendExtrap()) + g2_of_x = cubic_interp(x_nodes, y_mat[:, 2]; extrap=ExtendExtrap()) + f3_of_x = cubic_interp(x_nodes, y_mat[:, 5]; extrap=ExtendExtrap()) # Edge values needed by GetPSIvac - f1a = tj_analytic_f1(1.0, nu, qc) - f3a = f3_of_x(1.0) - H1a = H1_of_x(1.0) + f1a = tj_analytic_f1(1.0, nu, qc) + f3a = f3_of_x(1.0) + H1a = H1_of_x(1.0) H1ap = H1p_of_x(1.0) psio = ψ_of_r(a) # ψ at r = a (boundary) @@ -644,7 +648,7 @@ function tj_analytic_run_direct(equil_input::EquilibriumConfig, tj::TJAnalyticCo return f_R_shift(rc - 1e-8, w) * r^2 / rc^2 end H1 = (r < 1.0) ? H1_of_x(r) : H1_vac(r) - L = r^3 / 8 - r * H1 / 2 + L = r^3 / 8 - r * H1 / 2 return epsa2 * H1 + epsa2 * epsa * L * cos(w) end function f_Z_shift(r::Float64, w::Float64) @@ -652,7 +656,7 @@ function tj_analytic_run_direct(equil_input::EquilibriumConfig, tj::TJAnalyticCo return f_Z_shift(rc - 1e-8, w) * r^2 / rc^2 end H1 = (r < 1.0) ? H1_of_x(r) : H1_vac(r) - L = r^3 / 8 - r * H1 / 2 + L = r^3 / 8 - r * H1 / 2 return -epsa2 * epsa * L * sin(w) end @@ -699,9 +703,9 @@ function tj_analytic_run_direct(equil_input::EquilibriumConfig, tj::TJAnalyticCo # Grid spans R₀ ± rc·a × ±rc·a (where rc is the vacuum-shell radius in # units of a), giving a comfortable margin for the separatrix finder. r_span = rc * a - psi_in_xs = collect(range(R0 - r_span, R0 + r_span; length = nrbox)) - psi_in_ys = collect(range(-r_span, r_span; length = nzbox)) - psi_rz = zeros(Float64, nrbox, nzbox) + psi_in_xs = collect(range(R0 - r_span, R0 + r_span; length=nrbox)) + psi_in_ys = collect(range(-r_span, r_span; length=nzbox)) + psi_rz = zeros(Float64, nrbox, nzbox) for i in 1:nrbox, j in 1:nzbox R_norm = psi_in_xs[i] / R0 @@ -724,8 +728,10 @@ function tj_analytic_run_direct(equil_input::EquilibriumConfig, tj::TJAnalyticCo # 1D profile spline, same layout as read_efit (4 columns). Use the # TJ-analytic q₂ on the radial grid so that the prescribed q is # consistent with the ψ(R,Z) we just constructed. - psi_norm_grid = range(0.0, 1.0; length = nrbox) - F_nodes = zeros(nrbox); P_nodes = zeros(nrbox); q_nodes = zeros(nrbox) + psi_norm_grid = range(0.0, 1.0; length=nrbox) + F_nodes = zeros(nrbox) + P_nodes = zeros(nrbox) + q_nodes = zeros(nrbox) for i in 1:nrbox ψN = psi_norm_grid[i] # Invert ψN = (ψ_plasma(r) - 0) / psio ⇒ find r such that ψ_plasma(r) = ψN·psio. @@ -746,7 +752,7 @@ function tj_analytic_run_direct(equil_input::EquilibriumConfig, tj::TJAnalyticCo F_nodes[i] = R0 * B0 * (1 + epsa2 * g2_val) P_nodes[i] = p00_phys * xfac^mu q_nodes[i] = (x > 1e-10) ? x^2 * (1 + epsa2 * g2_val) * - exp(-epsa2 * f3_val / f1) / f1 : qc + exp(-epsa2 * f3_val / f1) / f1 : qc end sq_fs_nodes = hcat(F_nodes, P_nodes, q_nodes, sqrt.(collect(psi_norm_grid))) sq_in = cubic_interp(collect(psi_norm_grid), Series(sq_fs_nodes); extrap=ExtendExtrap()) @@ -756,7 +762,7 @@ function tj_analytic_run_direct(equil_input::EquilibriumConfig, tj::TJAnalyticCo # Analytic: ingest=nothing — replay regenerates from the [TJ_ANALYTIC_INPUT] TOML section. return DirectRunInput(equil_input, sq_in, psi_in, psi_in_xs, psi_in_ys, - rmin_grid, rmax_grid, zmin_grid, zmax_grid, psio, 1, nothing) + rmin_grid, rmax_grid, zmin_grid, zmax_grid, psio, 1, 1, nothing) end """ @@ -836,7 +842,6 @@ function sol_run(equil_inputs::EquilibriumConfig, sol_inputs::SolovevConfig) @info "Generating Solovev equilibrium: mr=$mr, mz=$mz, ma=$ma, e=$(@sprintf("%.3f", e)), a=$(@sprintf("%.3f", a)), r0=$(@sprintf("%.3f", r0)), q0=$(@sprintf("%.3f", q0))" # 1 is bt_sign=+1: Solovev has positive Bt by construction (f0 = r0 * b0fac > 0). - # No ip_sign field is needed; sign(crnt) is recovered from params.crnt downstream. - # Analytic: ingest=nothing — replay regenerates from the [SOL_INPUT] TOML section. - return DirectRunInput(equil_inputs, sq_in, psi_in, psi_in_xs, psi_in_ys, rmin, rmax, zmin, zmax, psio, 1, nothing) + # Analytic equilibria carry positive field and current; ingest=nothing — replay regenerates from the [SOL_INPUT] TOML section. + return DirectRunInput(equil_inputs, sq_in, psi_in, psi_in_xs, psi_in_ys, rmin, rmax, zmin, zmax, psio, 1, 1, nothing) end diff --git a/src/Equilibrium/DirectEquilibrium.jl b/src/Equilibrium/DirectEquilibrium.jl index 961a0f7f5..0bc9d26fa 100644 --- a/src/Equilibrium/DirectEquilibrium.jl +++ b/src/Equilibrium/DirectEquilibrium.jl @@ -654,6 +654,7 @@ robustness. params = EquilibriumParameters() params.bt_sign = raw_profile.bt_sign + params.ip_sign = raw_profile.ip_sign params.psihigh_resolved = psihigh return PlasmaEquilibrium(raw_profile.config, params, profiles, geometry, diff --git a/src/Equilibrium/EquilibriumTypes.jl b/src/Equilibrium/EquilibriumTypes.jl index 344f5a60f..ac5a212fc 100644 --- a/src/Equilibrium/EquilibriumTypes.jl +++ b/src/Equilibrium/EquilibriumTypes.jl @@ -245,7 +245,9 @@ A mutable struct holding parameters for the Large Aspect Ratio (LAR) plasma equi zeroth::Bool = false end -"Build a `LargeAspectRatioConfig` from a parsed `[LAR_INPUT]` TOML table." +""" +Build a `LargeAspectRatioConfig` from a parsed `[LAR_INPUT]` TOML table. +""" function LargeAspectRatioConfig(input_dict::Dict{String,Any}) return LargeAspectRatioConfig(; symbolize_keys(input_dict)...) end @@ -285,7 +287,9 @@ Reference: R. Fitzpatrick, TJ code, https://github.com/rfitzp/TJ zeroth::Bool = false # If true, suppress Shafranov shift end -"Build a `TJAnalyticConfig` from a parsed `[TJ_ANALYTIC_INPUT]` TOML table." +""" +Build a `TJAnalyticConfig` from a parsed `[TJ_ANALYTIC_INPUT]` TOML table. +""" function TJAnalyticConfig(input_dict::Dict{String,Any}) return TJAnalyticConfig(; symbolize_keys(input_dict)...) end @@ -321,7 +325,9 @@ A mutable struct holding parameters for the Solev'ev (SOL) plasma equilibrium mo f0fac::Float64 = 1 # scale toroidal field at constant pressure (s*f. beta,q changes. Phi,p,bp constant) end -"Build a `SolovevConfig` from a parsed `[SOL_INPUT]` TOML table." +""" +Build a `SolovevConfig` from a parsed `[SOL_INPUT]` TOML table. +""" function SolovevConfig(input_dict::Dict{String,Any}) return SolovevConfig(; symbolize_keys(input_dict)...) end @@ -346,6 +352,7 @@ not serializable; they are reconstructed from these nodes by `build_direct_from_ - `rmin/rmax/zmin/zmax::Float64` — computational-grid bounds [m] - `psio::Float64` — total flux difference |ψ_axis - ψ_boundary| [Wb/rad] - `bt_sign::Int` — sign of the toroidal field (+1 or -1) + - `ip_sign::Int` — sign of the plasma current (+1 or -1) as the source file states it """ struct DirectIngest sq_xs::Vector{Float64} @@ -359,6 +366,7 @@ struct DirectIngest zmax::Float64 psio::Float64 bt_sign::Int + ip_sign::Int end """ @@ -438,6 +446,10 @@ raw equilibrium data and preparing the initial splines. - `zmax::Float64` — Maximum Z-coordinate of the computational grid [m] - `psio::Float64` — Total flux difference `|ψ_axis - ψ_boundary|` [Wb/rad] - `bt_sign::Int` — Sign of the toroidal field (+1 or -1); read from fpol sign in EFIT g-files + - `ip_sign::Int` — Sign of the plasma current (+1 or -1); read from the `current` header value + in EFIT g-files and `global_quantities.ip` in IMAS. The internal flux is always made + positive at the axis, so the sign is kept here (never recovered from the computed current) + and fixes the SFL→machine toroidal-angle handedness `helicity = bt_sign × ip_sign`. - `ingest::EquilibriumIngest` — captured raw arrays for the `gpec.h5` rerun snapshot (a [`DirectIngest`](@ref) for file-based reads, or `nothing` for analytic equilibria) - `psihigh_resolved::Float64` — outer flux limit the equilibrium is formed on: `config.psihigh` @@ -459,6 +471,7 @@ mutable struct DirectRunInput{S<:FastInterpolations.CubicSeriesInterpolant,I2D<: zmax::Float64 # Maximum Z-coordinate of the computational grid [m]. psio::Float64 # The total flux difference |ψ_axis - ψ_boundary| [Weber / radian]. bt_sign::Int # Sign of the toroidal field: +1 or -1 (from fpol sign in g-file) + ip_sign::Int # Sign of the plasma current: +1 or -1 (from the g-file current / IMAS ip) ingest::EquilibriumIngest psihigh_resolved::Float64 end @@ -466,9 +479,9 @@ end # Readers construct without a resolved psihigh; it starts at the request and `resolve_psihigh!` # clamps it for efit-family equilibria. DirectRunInput(config::EquilibriumConfig, sq_in, psi_in, psi_in_xs, psi_in_ys, - rmin, rmax, zmin, zmax, psio, bt_sign, ingest) = + rmin, rmax, zmin, zmax, psio, bt_sign, ip_sign, ingest) = DirectRunInput(config, sq_in, psi_in, psi_in_xs, psi_in_ys, - rmin, rmax, zmin, zmax, psio, bt_sign, ingest, config.psihigh) + rmin, rmax, zmin, zmax, psio, bt_sign, ip_sign, ingest, config.psihigh) """ InverseRunInput(...) @@ -610,6 +623,7 @@ A mutable struct containing computed equilibrium parameters and diagnostic flags bt0::Union{Nothing,Float64} = nothing # Toroidal magnetic field at the axis [T] (always positive; sign in bt_sign) crnt::Union{Nothing,Float64} = nothing # Plasma current at the axis [A] bt_sign::Int = 1 # Sign of the toroidal field: +1 (positive Bt) or -1 (negative Bt, e.g. DIII-D standard) + ip_sign::Int = 1 # Sign of the plasma current as the source file states it: +1 or -1 (crnt is always positive) bwall::Union{Nothing,Float64} = nothing # Toroidal magnetic field at the wall [T] verbose::Bool = false # Whether to print verbose output diagnose_src::Bool = false # Whether to diagnose source data @@ -749,7 +763,7 @@ function GeometryProfileSplines(xs::Vector{Float64}, GeometryProfileSplines{typeof(area_spline)}( xs, npts, npts - 1, - area_spline, avg_r_spline, avg_R_spline, + area_spline, avg_r_spline, avg_R_spline ) end @@ -828,7 +842,7 @@ function KineticProfileSplines(xs::Vector{Float64}, xs, npts, npts - 1, ni_spline, ne_spline, Ti_spline, Te_spline, omegaE_spline, loglam_spline, nui_spline, nue_spline, zeff_spline, - ni_deriv, ne_deriv, Ti_deriv, Te_deriv, + ni_deriv, ne_deriv, Ti_deriv, Te_deriv ) end diff --git a/src/Equilibrium/ReadEquilibrium.jl b/src/Equilibrium/ReadEquilibrium.jl index 8cc221b7a..56ea891dc 100644 --- a/src/Equilibrium/ReadEquilibrium.jl +++ b/src/Equilibrium/ReadEquilibrium.jl @@ -67,6 +67,8 @@ function read_efit(config::EquilibriumConfig) header_vals = _read_1d_gfile_format(lines[2:5], 20) rdim, zdim, rcentr, rleft, zmid = header_vals[1:5] rmaxis, zmaxis, simag, sibry = header_vals[6:9] + ip_sign = Int(sign(header_vals[11])) # the g-file's plasma current; its sign sets the helicity + ip_sign == 0 && (ip_sign = 1) # --- Parse Data Blocks --- current_line_idx = 6 @@ -120,9 +122,9 @@ function read_efit(config::EquilibriumConfig) # Capture the raw arrays that reconstruct sq_in and psi_in, so the rerun path # (gpec.h5 → setup_equilibrium) can skip the g-file parse entirely. - ingest = DirectIngest(sq_xs, sq_fs_nodes, psi_in_xs, psi_in_ys, psi_proc, rmin, rmax, zmin, zmax, psio, fpol_sign) + ingest = DirectIngest(sq_xs, sq_fs_nodes, psi_in_xs, psi_in_ys, psi_proc, rmin, rmax, zmin, zmax, psio, fpol_sign, ip_sign) - return DirectRunInput(config, sq_in, psi_in, psi_in_xs, psi_in_ys, rmin, rmax, zmin, zmax, psio, fpol_sign, ingest) + return DirectRunInput(config, sq_in, psi_in, psi_in_xs, psi_in_ys, rmin, rmax, zmin, zmax, psio, fpol_sign, ip_sign, ingest) end @@ -152,7 +154,7 @@ function read_chease_binary(config::EquilibriumConfig) # 1D array allocation zcpr, zcppr, zq, zdq, ztmf, ztp, zfb, zfbp, zpsi, zpsim = - [zeros(i == 1 || i == 10 ? npsi1-1 : npsi1) for i in 1:10] + [zeros(i == 1 || i == 10 ? npsi1 - 1 : npsi1) for i in 1:10] for arr in (zcpr, zcppr, zq, zdq, ztmf, ztp, zfb, zfbp, zpsi, zpsim) read(io, UInt32) @@ -275,7 +277,7 @@ function read_chease_ascii(config::EquilibriumConfig) data = Float64[] for line in lines[lines_range] for i in 0:4 - s = strip(line[(22*i+1):min(end, 22*(i+1))]) + s = strip(line[(22*i+1):min(end, 22 * (i + 1))]) if !isempty(s) push!(data, parse(Float64, s)) end @@ -405,7 +407,7 @@ function build_direct_from_ingest(config::EquilibriumConfig, ingest::DirectInges sq_in = cubic_interp(ingest.sq_xs, Series(ingest.sq_fs); extrap=ExtendExtrap()) psi_in = cubic_interp((ingest.psi_xs, ingest.psi_ys), ingest.psi_rz; extrap=ExtendExtrap()) return DirectRunInput(config, sq_in, psi_in, ingest.psi_xs, ingest.psi_ys, - ingest.rmin, ingest.rmax, ingest.zmin, ingest.zmax, ingest.psio, ingest.bt_sign, ingest) + ingest.rmin, ingest.rmax, ingest.zmin, ingest.zmax, ingest.psio, ingest.bt_sign, ingest.ip_sign, ingest) end """ @@ -432,15 +434,18 @@ Load an equilibrium from an IMAS data dictionary and return a `DirectRunInput`. The `dd.equilibrium.time_slice[]` is used (active time slice). Poloidal flux is converted from the IMAS COCOS convention (set by `config.imas_cocos`) to the internal COCOS 2 convention: + - `imas_cocos = 11` (default, IMAS standard): divide ψ by 2π - `imas_cocos = 2` (GPEC internal): no conversion ## Arguments -- `config`: `EquilibriumConfig` with `eq_type = "imas"` and `imas_cocos` set. -- `dd`: populated `IMASdd.dd` with `dd.equilibrium.time_slice[]` containing: - - `global_quantities.psi_axis`, `global_quantities.psi_boundary` - - `profiles_1d.psi`, `profiles_1d.f`, `profiles_1d.pressure`, `profiles_1d.q` - - `profiles_2d[1].grid.dim1` (R), `profiles_2d[1].grid.dim2` (Z), `profiles_2d[1].psi` + + - `config`: `EquilibriumConfig` with `eq_type = "imas"` and `imas_cocos` set. + - `dd`: populated `IMASdd.dd` with `dd.equilibrium.time_slice[]` containing: + + + `global_quantities.psi_axis`, `global_quantities.psi_boundary` + + `profiles_1d.psi`, `profiles_1d.f`, `profiles_1d.pressure`, `profiles_1d.q` + + `profiles_2d[1].grid.dim1` (R), `profiles_2d[1].grid.dim2` (Z), `profiles_2d[1].psi` """ function read_imas(config::EquilibriumConfig, dd) @info "Processing IMAS equilibrium at global_time = $(dd.global_time) s" @@ -480,6 +485,9 @@ function read_imas(config::EquilibriumConfig, dd) # Capture toroidal-field sign from the boundary F value before abs() below. bt_sign = isempty(f_1d) ? 1 : Int(sign(f_1d[end])) bt_sign == 0 && (bt_sign = 1) + # Plasma-current sign from the IMAS global quantity (missing or zero → +1). + ip_imas = hasproperty(eqt.global_quantities, :ip) ? eqt.global_quantities.ip : 0.0 + ip_sign = ismissing(ip_imas) || ip_imas == 0 ? 1 : Int(sign(ip_imas)) nw = length(psi_1d) psi_norm_grid = range(0.0, 1.0; length=nw) @@ -528,7 +536,7 @@ function read_imas(config::EquilibriumConfig, dd) "\n Z ∈ [$(round(zmin; sigdigits=4)), $(round(zmax; sigdigits=4))] m" # Capture the raw arrays so an IMAS run can be replayed from gpec.h5 without the dd source. - ingest = DirectIngest(sq_xs, sq_fs_nodes, psi_in_xs, psi_in_ys, Matrix(psi_proc), rmin, rmax, zmin, zmax, psio, bt_sign) + ingest = DirectIngest(sq_xs, sq_fs_nodes, psi_in_xs, psi_in_ys, Matrix(psi_proc), rmin, rmax, zmin, zmax, psio, bt_sign, ip_sign) - return DirectRunInput(config, sq_in, psi_in, psi_in_xs, psi_in_ys, rmin, rmax, zmin, zmax, psio, bt_sign, ingest) + return DirectRunInput(config, sq_in, psi_in, psi_in_xs, psi_in_ys, rmin, rmax, zmin, zmax, psio, bt_sign, ip_sign, ingest) end diff --git a/src/ForcingTerms/CoilFourier.jl b/src/ForcingTerms/CoilFourier.jl index 62996bf59..9b2c5b22c 100644 --- a/src/ForcingTerms/CoilFourier.jl +++ b/src/ForcingTerms/CoilFourier.jl @@ -100,14 +100,15 @@ via periodic cubic splines on the resulting R(θ_norm), Z(θ_norm) data. The toroidal grid direction follows the Fortran GPEC convention: `phi_j = -helicity × 2π × j/nzeta` where `helicity = sign(Bt) × sign(Ip)`. -This is derived from `equil.params.bt_sign` and `equil.params.crnt`. +This is derived from `equil.params.bt_sign` and `equil.params.ip_sign` (the signs the equilibrium +file states; the computed current `crnt` is always positive and carries no direction). For DIII-D (Bt < 0, Ip > 0 → helicity = -1): phi increases with j (standard direction). For positive-helicity machines (Bt > 0, Ip > 0 → helicity = +1): phi decreases with j. """ function sample_boundary_grid(equil::Equilibrium.PlasmaEquilibrium, mtheta::Int, nzeta::Int; psi::Float64=equil.rzphi_xs[end]) # Build uniform theta grid (same convention as equil.rzphi_ys, but potentially finer) - theta_grid = range(0; length=mtheta, step=1.0/mtheta) + theta_grid = range(0; length=mtheta, step=1.0 / mtheta) R_arr = zeros(mtheta) Z_arr = zeros(mtheta) @@ -136,10 +137,8 @@ function sample_boundary_grid(equil::Equilibrium.PlasmaEquilibrium, mtheta::Int, # Helicity sets the direction of the toroidal angle grid to match Fortran convention: # phi_j = -helicity × 2π × j/nzeta, helicity = sign(Bt) × sign(Ip) - bt_sign = !isnothing(equil.params.bt_sign) ? equil.params.bt_sign : 1 - ip_sign = !isnothing(equil.params.crnt) ? Int(sign(equil.params.crnt)) : 1 - helicity = bt_sign * ip_sign - phi_grid = collect(range(0; length=nzeta, step=(-helicity * 2π/nzeta))) + helicity = equil.params.bt_sign * equil.params.ip_sign + phi_grid = collect(range(0; length=nzeta, step=(-helicity * 2π / nzeta))) # Toroidal angle offset ν(ψ, θ_SFL): in SFL coordinates the physical toroidal angle at # grid point (θ_SFL, ζ_SFL) is φ_phys = -helicity*(2π*ζ_SFL + ν(ψ,θ_SFL)). diff --git a/src/HDF5Schema.jl b/src/HDF5Schema.jl index 274f54f14..2f2f6f175 100644 --- a/src/HDF5Schema.jl +++ b/src/HDF5Schema.jl @@ -44,6 +44,7 @@ const EQUIL_H5_NAMES = Dict( :li2 => "l_i_2", :li3 => "l_i_3", :bt_sign => "B_T_sign", + :ip_sign => "I_p_sign", :psio => "psi_total" ) const EQUIL_H5_SKIP = Set([:psi0, :psi_axis, :psi_axis_norm, :zsep, :verbose, :diagnose_src, :diagnose_maxima]) @@ -98,6 +99,7 @@ const MAIN_H5_ANNOTATIONS = [ "Equilibrium/l_i_3" => (; long_name="internal inductance (definition 3)"), "Equilibrium/volume" => (; long_name="plasma volume", units="m^3"), "Equilibrium/B_T_sign" => (; long_name="sign of the toroidal field"), + "Equilibrium/I_p_sign" => (; long_name="sign of the plasma current as stated by the equilibrium file"), "Equilibrium/psi_norm" => (; long_name="normalized poloidal flux at the magnetic axis (0 by definition of ψ_N)"), "Equilibrium/psi_boundary" => (; long_name="poloidal flux at the plasma boundary in the internal normalized convention (1 by construction, not a Wb/rad datum)"), "Equilibrium/psi_boundary_norm" => (; long_name="normalized poloidal flux at the plasma boundary (1 by definition of ψ_N)"), @@ -245,7 +247,7 @@ const MAIN_H5_ANNOTATIONS = [ "SurfaceGeometries/Plasma/z" => (; long_name="Cartesian z of plasma-surface point cloud", units="m"), "SurfaceGeometries/Wall/x" => (; long_name="Cartesian x of wall point cloud", units="m"), "SurfaceGeometries/Wall/y" => (; long_name="Cartesian y of wall point cloud", units="m"), - "SurfaceGeometries/Wall/z" => (; long_name="Cartesian z of wall point cloud", units="m"), + "SurfaceGeometries/Wall/z" => (; long_name="Cartesian z of wall point cloud", units="m") ] # Euler-Lagrange operator matrices: same wording per letter, Ideal/ and Kinetic/ variants. @@ -258,7 +260,7 @@ const _ELM_IDEAL_LETTERS = [ ("H", "Euler-Lagrange primitive coefficient matrix H"), ("F", "Euler-Lagrange derived coefficient matrix F"), ("K", "Euler-Lagrange derived coefficient matrix K"), - ("G", "Euler-Lagrange derived coefficient matrix G"), + ("G", "Euler-Lagrange derived coefficient matrix G") ] # The kinetic branch overwrites only A, B, C, K, G and adds f0; D, E, H, F are shared # unchanged from the ideal set and are not re-emitted. @@ -268,14 +270,19 @@ const _ELM_KINETIC_LETTERS = [ ("C", "Euler-Lagrange primitive coefficient matrix C"), ("K", "Euler-Lagrange derived coefficient matrix K"), ("G", "Euler-Lagrange derived coefficient matrix G"), - ("f0", "raw kinetic component matrix f0"), + ("f0", "raw kinetic component matrix f0") ] const ELM_H5_ANNOTATIONS = vcat( ["ForceFreeStates/EulerLagrangeMatrices/psi" => (; long_name="normalized poloidal flux ψ_N grid of the operator matrices", scale="psi")], - ["ForceFreeStates/EulerLagrangeMatrices/Ideal/$l" => - (; long_name="ideal " * d, dims=("psi", "mode_row", "mode_col"), attach=(1 => "ForceFreeStates/EulerLagrangeMatrices/psi",)) for (l, d) in _ELM_IDEAL_LETTERS], - ["ForceFreeStates/EulerLagrangeMatrices/Kinetic/$l" => - (; long_name="kinetic-modified " * d, dims=("psi", "mode_row", "mode_col"), attach=(1 => "ForceFreeStates/EulerLagrangeMatrices/psi",)) for (l, d) in _ELM_KINETIC_LETTERS] + [ + "ForceFreeStates/EulerLagrangeMatrices/Ideal/$l" => + (; long_name="ideal " * d, dims=("psi", "mode_row", "mode_col"), attach=(1 => "ForceFreeStates/EulerLagrangeMatrices/psi",)) for (l, d) in _ELM_IDEAL_LETTERS + ], + [ + "ForceFreeStates/EulerLagrangeMatrices/Kinetic/$l" => + (; long_name="kinetic-modified " * d, dims=("psi", "mode_row", "mode_col"), attach=(1 => "ForceFreeStates/EulerLagrangeMatrices/psi",)) for + (l, d) in _ELM_KINETIC_LETTERS + ] ) """ diff --git a/src/Rerun.jl b/src/Rerun.jl index 03c19bea6..05e92b712 100644 --- a/src/Rerun.jl +++ b/src/Rerun.jl @@ -37,12 +37,15 @@ function read_equilibrium_ingest(in_h5) haskey(in_h5, group_path) || return nothing group = in_h5[group_path] kind = read(group, "ingest_kind") - T = kind == "direct" ? Equilibrium.DirectIngest : + T = + kind == "direct" ? Equilibrium.DirectIngest : kind == "inverse" ? Equilibrium.InverseIngest : error("Unknown equilibrium ingest_kind in gpec.h5: $kind (expected \"direct\" or \"inverse\")") # Positional reconstruction: relies on the default constructor, so `fieldnames(T)` order # must match the struct definition and the field-by-field write in write_outputs_to_HDF5. - return T((read(group, String(f)) for f in fieldnames(T))...) + # Files written before the plasma-current sign was stored carry no ip_sign; they were all + # positive-current runs in effect, so that field defaults to +1. + return T((haskey(group, String(f)) ? read(group, String(f)) : (f == :ip_sign ? 1 : error("missing equilibrium ingest field $f in gpec.h5")) for f in fieldnames(T))...) end """ @@ -90,7 +93,7 @@ function parse_override_flag(expr::AbstractString) # Warn instead of silently stringifying a bare word, which would otherwise only fail # much later where the field expects a number/bool. @warn "Could not parse --override value as a TOML literal; storing it as a string. " * - "Quote it explicitly if a string was intended." key=lhs value=rhs error=e + "Quote it explicitly if a string was intended." key = lhs value = rhs error = e rhs end @@ -281,9 +284,11 @@ function build_inputs_from_h5(args::Vector{String}) elseif ingest isa Equilibrium.InverseIngest Equilibrium.build_inverse_from_ingest(eq_config, ingest) else - error("gpec.h5 has no equilibrium ingest and eq_type=$(eq_config.eq_type) is not analytic — cannot replay. " * - "A file-based eq_type needs a stored ingest (pre-ingest snapshots lack one); a new analytic kind must be " * - "registered in Equilibrium.ANALYTIC_EQ.") + error( + "gpec.h5 has no equilibrium ingest and eq_type=$(eq_config.eq_type) is not analytic — cannot replay. " * + "A file-based eq_type needs a stored ingest (pre-ingest snapshots lack one); a new analytic kind must be " * + "registered in Equilibrium.ANALYTIC_EQ." + ) end return inputs, eq_config, additional_input, output_dir, current_git, preloaded_forcing, preloaded_coils diff --git a/test/runtests_coils.jl b/test/runtests_coils.jl index 8525ce70a..eb2df60c4 100644 --- a/test/runtests_coils.jl +++ b/test/runtests_coils.jl @@ -1,6 +1,7 @@ using Test using TOML using Statistics +using Printf using HDF5 using GeneralizedPerturbedEquilibrium using GeneralizedPerturbedEquilibrium.ForcingTerms @@ -554,3 +555,34 @@ end @test byname["wp"].ncoil == 3 end end + +# --------------------------------------------------------------------------- +@testset "CoilFourier: helicity follows the g-file current sign" begin + # The internal flux is made positive at the axis, so the computed current is always + # positive; the handedness of the toroidal grid must come from the file's stated signs. + gfile = joinpath(@__DIR__, "..", "examples", "DIIID-like_ideal_example", "TkMkr_D3Dlike_Hmode.geqdsk") + lines = readlines(gfile) + flipped = tempname() * ".geqdsk" + open(flipped, "w") do io + for (i, l) in enumerate(lines) + if i == 4 # header line 4 starts with the plasma current + vals = [l[j:min(j + 15, end)] for j in 1:16:length(l)] + cur = -parse(Float64, strip(vals[1])) + l = @sprintf("%16.9E", cur) * l[17:end] + end + println(io, l) + end + end + cfg(path) = Equilibrium.EquilibriumConfig(; eq_filename=path, eq_type="efit", jac_type="hamada", grid_type="ldp", psilow=0.01, psihigh=0.99, mpsi=32, mtheta=64) + eq_pos = Equilibrium.setup_equilibrium(cfg(gfile)) + eq_neg = Equilibrium.setup_equilibrium(cfg(flipped)) + @test eq_pos.params.ip_sign == 1 && eq_neg.params.ip_sign == -1 + @test eq_pos.params.bt_sign == eq_neg.params.bt_sign == -1 + @test eq_pos.params.crnt > 0 && eq_neg.params.crnt > 0 # the computed current carries no sign + g_pos = ForcingTerms.sample_boundary_grid(eq_pos, 16, 8; psi=0.9) + g_neg = ForcingTerms.sample_boundary_grid(eq_neg, 16, 8; psi=0.9) + @test g_pos.phi_grid[2] ≈ 2π / 8 # helicity −1: φ increases with j + @test g_neg.phi_grid[2] ≈ -2π / 8 # helicity +1: φ decreases with j + @test g_neg.phi_offset ≈ -g_pos.phi_offset + rm(flipped) +end From 76808dfb40ab686327676ae323ed30e15680a3b6 Mon Sep 17 00:00:00 2001 From: logan-nc Date: Sat, 12 Sep 2026 22:51:42 -0400 Subject: [PATCH 41/51] =?UTF-8?q?Equilibrium=20-=20MINOR=20-=20Take=20the?= =?UTF-8?q?=20unit=20mode=20to=20=CE=B8-space=20with=20the=20library=20inv?= =?UTF-8?q?erse=20transform?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit compute_sqrtamat now calls FourierTransforms.inverse for its backward step instead of spelling out adjoint(basis), so the pair it uses is exactly the forward/inverse pair whose round trip the transform tests check. Bit-identical to the previous commit. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BzKDmtKYDotJWrcxWf1oJu --- src/Equilibrium/CoordinateInvariant.jl | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Equilibrium/CoordinateInvariant.jl b/src/Equilibrium/CoordinateInvariant.jl index bbeea356c..ae001b5d6 100644 --- a/src/Equilibrium/CoordinateInvariant.jl +++ b/src/Equilibrium/CoordinateInvariant.jl @@ -59,9 +59,9 @@ which is Jacobian-invariant on a given flux surface. Its diagonal is the θ-aver √(J|∇ψ|), so `sqrtamat/√jarea` has a diagonal close to (and never above) one; this is the matrix Fortran GPEC writes as `J_surf_2` after that division. -Each column is the unit mode e^{+i m_k θ} taken to θ-space with the inverse transform -(`adjoint(basis)`, no 1/N), weighted pointwise, and brought back with the forward transform -(`basis/N`), so the round trip is the identity and the convolution structure is correct. +Each column is the unit mode e^{+i m_k θ} taken to θ-space with `FourierTransforms.inverse`, +weighted pointwise, and brought back with the forward transform `ft(...)`, the same pair whose +round trip is the identity, so the convolution structure is correct. """ function compute_sqrtamat( equil::PlasmaEquilibrium, @@ -79,8 +79,8 @@ function compute_sqrtamat( e_k .= 0.0 e_k[k] = 1.0 + 0.0im - # Inverse FT of the unit mode: f(θ_j) = exp(+i m_k θ_j) (adjoint(basis) * c, see FourierTransforms) - theta_vec = adjoint(ft.basis) * e_k + # Inverse FT of the unit mode, f(θ_j) = exp(+i m_k θ_j), through the library's own inverse + theta_vec = Utilities.FourierTransforms.inverse(ft, e_k) # Multiply pointwise by √(J·|∇ψ|) in theta-space theta_vec .*= sqrt_jdp From 384bb92027842c7e60c87a220a995ee69e7e38a8 Mon Sep 17 00:00:00 2001 From: Jake Halpern Date: Tue, 15 Sep 2026 15:50:34 -0400 Subject: [PATCH 42/51] VACUUM - MINOR -addressing Daniel's PR review --- src/Vacuum/DataTypes.jl | 87 +++++++++++++++++++++++++++++------------ test/runtests_vacuum.jl | 65 ++++++++++++++++++++++++++---- 2 files changed, 121 insertions(+), 31 deletions(-) diff --git a/src/Vacuum/DataTypes.jl b/src/Vacuum/DataTypes.jl index de1acfe02..2bf8d5e87 100644 --- a/src/Vacuum/DataTypes.jl +++ b/src/Vacuum/DataTypes.jl @@ -201,7 +201,10 @@ Struct containing input settings for vacuum wall geometry. A non-axisymmetric boundary (`nzeta_in > 1`) supports `"nowall"` and `"conformal"`; the others are poloidal contours that get revolved and so need `nzeta_in == 1`. - - `a::Float64`: Distance of wall from plasma in units of major radius (conformal), or shape parameter (others) + - `a::Float64`: Distance of wall from plasma in units of the minor radius `0.5(max R - min R)` + (conformal), or shape parameter (others). On a non-axisymmetric boundary the extrema are taken + over the whole torus, so `a` scales with half the global R-extent rather than a cross-section + minor radius; it reduces to the axisymmetric definition when the boundary is axisymmetric. - `aw::Float64`: Half-thickness parameter for Dee-shaped walls @@ -639,7 +642,8 @@ wall shape settings. This is the 3D counterpart of [`WallGeometry`](@ref) and se way: an axisymmetric boundary builds the 2D poloidal contour and revolves it, so every shape [`WallGeometry`](@ref) offers is available; a non-axisymmetric boundary offsets the plasma surface. -Expects a full-torus boundary — call [`expand_field_periods`](@ref) first when `nfp > 1`. +Expects a full-torus boundary on the same `mtheta`/`nzeta` grid as `plasma_surf`, and `nfp == 1`; +[`expand_field_periods`](@ref) produces both from a per-period non-axisymmetric boundary. # Arguments @@ -655,11 +659,15 @@ Expects a full-torus boundary — call [`expand_field_periods`](@ref) first when - Axisymmetric boundaries (`nzeta_in == 1`) support nowall, conformal, elliptical, dee, mod_dee and from_file; non-axisymmetric boundaries support nowall and conformal - - The conformal wall for a non-axisymmetric boundary displaces each plasma point along its own normal, - which keeps wall grid index `(i, j)` the closest wall point to plasma index `(i, j)` — the - correspondence the near-field patch of `compute_3D_kernel_matrices!` assumes + - The non-axisymmetric conformal wall displaces each plasma point along its own normal, so wall grid + index `(i, j)` is the closest wall point to plasma index `(i, j)` — the correspondence the near-field + patch of `compute_3D_kernel_matrices!` assumes. The revolved branch does not share it: the wall sits + on the geometric angle ϕ while `PlasmaGeometry3D` places the plasma on ζ = ϕ − ν(θ), and + `equal_arc_wall` re-parameterizes poloidally on top of that - Rejects a conformal offset that folds the surface, and warns when the plasma–wall gap is smaller - than one cell of the coarser grid (the double-layer near-field quadrature cannot resolve it) + than one cell of the coarser grid (the double-layer near-field quadrature cannot resolve it). + Both fold tests are local, so a global self-intersection between distant lobes — where the offset + surface meets itself with aligned normals and a positive area element — passes them """ function WallGeometry3D(inputs::VacuumInput, plasma_surf::PlasmaGeometry3D, wall_settings::WallShapeSettings) @@ -668,6 +676,21 @@ function WallGeometry3D(inputs::VacuumInput, plasma_surf::PlasmaGeometry3D, wall is_closed_toroidal = true (; mtheta, nzeta) = inputs + + # Both surfaces are indexed together throughout, and a mismatch would run off the end of one of them + (plasma_surf.mtheta, plasma_surf.nzeta) == (mtheta, nzeta) || error( + "Plasma and wall must be built on the same grid: plasma_surf is $(plasma_surf.mtheta)×$(plasma_surf.nzeta) " * + "but this wall is $(mtheta)×$(nzeta). Build both from the same VacuumInput." + ) + + # expand_field_periods tiles a non-axisymmetric boundary to the full torus and returns nfp = 1, so + # nfp > 1 reaches here only from an axisymmetric boundary, which has no field periodicity to declare. + inputs.nfp > 1 && error( + "3D wall geometry requires nfp = 1 on an axisymmetric (nzeta_in = 1) boundary: an axisymmetric " * + "surface has no field periodicity, and nfp > 1 leaves the solve reading $(mtheta)×$(nzeta) points " * + "as a full torus of $(mtheta)×$(nzeta * inputs.nfp)." + ) + dθ = 2π / mtheta dζ = 2π / nzeta θ_grid = range(; start=0, length=mtheta, step=dθ) @@ -696,9 +719,8 @@ function WallGeometry3D(inputs::VacuumInput, plasma_surf::PlasmaGeometry3D, wall ) end - # expand_field_periods leaves an axisymmetric boundary alone, so nfp > 1 can only arrive here with - # per-period counts that the rest of the 3D solve reads as full-torus ones. - inputs.nfp > 1 && error("3D wall geometry requires a full-torus boundary. Call expand_field_periods(inputs) first, or supply the whole torus with nfp = 1.") + # Plasma-wall separation at each grid point, measured the way each branch makes exact + gap = zeros(num_points) if inputs.nzeta_in == 1 # Axisymmetric boundary: build the 2D poloidal contour and revolve it toroidally @@ -707,22 +729,34 @@ function WallGeometry3D(inputs::VacuumInput, plasma_surf::PlasmaGeometry3D, wall for i in 1:mtheta, (j, ϕ) in enumerate(ϕ_grid) r[i+mtheta*(j-1), :] .= [wall_2D.x[i] * cos(ϕ), wall_2D.x[i] * sin(ϕ), wall_2D.z[i]] end + + # The revolved wall is axisymmetric while the plasma sits on ζ = ϕ − ν(θ), so the same-index pair + # is not the closest one. The separation is a 2D minimum over the wall contour in the plasma's own plane. + for idx in axes(plasma_surf.r, 1) + R_p = hypot(plasma_surf.r[idx, 1], plasma_surf.r[idx, 2]) + Z_p = plasma_surf.r[idx, 3] + gap[idx] = minimum(hypot(R_p - wall_2D.x[i], Z_p - wall_2D.z[i]) for i in 1:mtheta) + end elseif wall_settings.shape == "conformal" # Displace every plasma point outward along its own normal - wall_settings.equal_arc_wall && @warn "equal_arc_wall is ignored for non-axisymmetric (nzeta_in > 1) walls: it re-parameterizes a 2D contour and would break the plasma/wall index alignment the near-field patch relies on." + wall_settings.equal_arc_wall && + @info "equal_arc_wall is ignored for non-axisymmetric (nzeta_in > 1) walls: it re-parameterizes a 2D contour and would break the plasma/wall index alignment the near-field patch relies on." - # Same logic as 2D + # Same logic as 2D, with the extrema taken over the whole torus (see WallShapeSettings.a) R_plasma = [hypot(plasma_surf.r[idx, 1], plasma_surf.r[idx, 2]) for idx in axes(plasma_surf.r, 1)] offset_gap = wall_settings.a * 0.5 * (maximum(R_plasma) - minimum(R_plasma)) @info "Calculating conformal wall shape $((@sprintf "%.2e" offset_gap)) m from plasma surface." # Plasma normal points into the plasma, so the displacement is along -normal - @inbounds for idx in axes(plasma_surf.r, 1) + for idx in axes(plasma_surf.r, 1) scale = -offset_gap / sqrt(plasma_surf.normal[idx, 1]^2 + plasma_surf.normal[idx, 2]^2 + plasma_surf.normal[idx, 3]^2) for k in 1:3 r[idx, k] = plasma_surf.r[idx, k] + scale * plasma_surf.normal[idx, k] end end + + # Every point moves exactly offset_gap along its own normal, so the separation needs no search + fill!(gap, offset_gap) else error("Wall shape $(wall_settings.shape) is not available for a non-axisymmetric boundary (nzeta_in > 1).") end @@ -744,16 +778,13 @@ function WallGeometry3D(inputs::VacuumInput, plasma_surf::PlasmaGeometry3D, wall normal_orient = normal[idx, 1] > 0 ? 1 : -1 @views normal .*= normal_orient - # Same-index plasma–wall separation: the near-field patch is centred on this pair. - gap = minimum(hypot(r[idx, 1] - plasma_surf.r[idx, 1], r[idx, 2] - plasma_surf.r[idx, 2], r[idx, 3] - plasma_surf.r[idx, 3]) for idx in axes(r, 1)) - # Fold check needs a pointwise normal offset (same-index pair) - equal_arc_wall re-parameterizes and breaks that pairing if wall_settings.shape == "conformal" && (inputs.nzeta_in > 1 || !wall_settings.equal_arc_wall) # Offset stays regular while wall and plasma normals stay aligned and the area element has not collapsed # Both fail when the gap exceeds the local concave radius of curvature. min_align = Inf # min n̂_wall · n̂_plasma_outward; +1 healthy, ≤0 folded min_area_ratio = Inf # min ||n_wall||/||n_plasma||; →0 at a caustic - @inbounds for idx in axes(normal, 1) + for idx in axes(normal, 1) norm_wall = sqrt(normal[idx, 1]^2 + normal[idx, 2]^2 + normal[idx, 3]^2) norm_plasma = sqrt(plasma_surf.normal[idx, 1]^2 + plasma_surf.normal[idx, 2]^2 + plasma_surf.normal[idx, 3]^2) # Plasma n in, wall n out of vacuum → healthy offset dots to −1; store −cosine so +1 is aligned. @@ -763,21 +794,29 @@ function WallGeometry3D(inputs::VacuumInput, plasma_surf::PlasmaGeometry3D, wall end min_align <= 0.0 && error( - "Conformal wall offset of $((@sprintf "%.2e" gap)) m self-intersects: it exceeds the local concave radius " * + "Conformal wall offset of $((@sprintf "%.2e" minimum(gap))) m self-intersects: it exceeds the local concave radius " * "of curvature of the plasma surface somewhere, folding the offset surface. Reduce the wall distance a." ) (min_align < 0.5 || min_area_ratio < 0.5) && @warn "Conformal wall is close to folding: min(n̂_wall·n̂_plasma)=$((@sprintf "%.3f" min_align)), min area-element ratio=$((@sprintf "%.3f" min_area_ratio)). Reduce the wall distance a." end - # The 3D singular quadrature resolves the double-layer kernel well only while the plasma-wall gap spans a grid cell or more - h_θ = max(sqrt(sum(abs2, dr_dθ) / num_points), sqrt(sum(abs2, plasma_surf.dr_dθ) / num_points)) * dθ - h_ζ = max(sqrt(sum(abs2, dr_dζ) / num_points), sqrt(sum(abs2, plasma_surf.dr_dζ) / num_points)) * dζ - cells = gap / max(h_θ, h_ζ) + # The 3D singular quadrature resolves the double-layer kernel well only while the plasma-wall gap spans a + # grid cell or more. Taken pointwise, so an under-resolved patch is not averaged away by the rest of the surface. + node_norm(A, idx) = hypot(A[idx, 1], A[idx, 2], A[idx, 3]) + cells_per_point = [ + gap[idx] / max( + max(node_norm(dr_dθ, idx), node_norm(plasma_surf.dr_dθ, idx)) * dθ, + max(node_norm(dr_dζ, idx), node_norm(plasma_surf.dr_dζ, idx)) * dζ + ) for idx in axes(r, 1) + ] + cells, worst = findmin(cells_per_point) if cells < 1.0 - @warn "Plasma–wall gap $((@sprintf "%.2e" gap)) m spans only $((@sprintf "%.2f" cells)) cells of the coarser grid " * - "(h_θ=$((@sprintf "%.2e" h_θ)) m, h_ζ=$((@sprintf "%.2e" h_ζ)) m). The double-layer near-field quadrature is inaccurate " * - "below one cell. Refine the coarser of mtheta/nzeta, or move the wall out." + h_θ = max(node_norm(dr_dθ, worst), node_norm(plasma_surf.dr_dθ, worst)) * dθ + h_ζ = max(node_norm(dr_dζ, worst), node_norm(plasma_surf.dr_dζ, worst)) * dζ + @warn "Plasma–wall gap $((@sprintf "%.2e" gap[worst])) m spans only $((@sprintf "%.2f" cells)) cells of the coarser grid " * + "at its worst-resolved point (h_θ=$((@sprintf "%.2e" h_θ)) m, h_ζ=$((@sprintf "%.2e" h_ζ)) m). The double-layer " * + "near-field quadrature is inaccurate below one cell. Refine the coarser of mtheta/nzeta, or move the wall out." end return WallGeometry3D( diff --git a/test/runtests_vacuum.jl b/test/runtests_vacuum.jl index 2db363573..4ec0bccdc 100644 --- a/test/runtests_vacuum.jl +++ b/test/runtests_vacuum.jl @@ -622,17 +622,17 @@ # _make_3d_nonaxis_inputs helper samples range(0, 2π, length=n), which repeats the seam # point and leaves the surface non-smooth there — harmless for a nowall response, but it # makes the offset surface fold, so the wall tests build their own boundary. - _make_3d_periodic_inputs(; mtheta=24, nzeta=24, mtheta_in=16, nzeta_in=16) = begin + _make_3d_periodic_inputs(; mtheta=24, nzeta=24, mtheta_in=16, nzeta_in=16, eps=0.05) = begin θ_in = range(; start=0, length=mtheta_in, step=2π/mtheta_in) ζ_in = range(; start=0, length=nzeta_in, step=2π/nzeta_in) X = zeros(mtheta_in, nzeta_in) Y = similar(X) Z = similar(X) for (i, θ) in enumerate(θ_in), (j, ζ) in enumerate(ζ_in) - R = 1.7 + 0.3 * cos(θ) + 0.05 * cos(2ζ) * cos(θ) + R = 1.7 + 0.3 * cos(θ) + eps * cos(2ζ) * cos(θ) X[i, j] = R * cos(ζ) Y[i, j] = R * sin(ζ) - Z[i, j] = 0.3 * sin(θ) + 0.05 * sin(2ζ) * sin(θ) + Z[i, j] = 0.3 * sin(θ) + eps * sin(2ζ) * sin(θ) end VacuumInput(x=vec(X), y=vec(Y), z=vec(Z), mtheta_in=mtheta_in, nzeta_in=nzeta_in, m_modes=[1, 2], n_modes=[0, 1], mtheta=mtheta, nzeta=nzeta) @@ -652,7 +652,8 @@ # Uniform offset along the plasma normal: every point moves the same distance, outward. offsets = [norm(wall.r[i, :] - plasma.r[i, :]) for i in 1:num_points] @test maximum(offsets) - minimum(offsets) < 1e-12 - @test all(hypot.(wall.r[:, 1], wall.r[:, 2]) .> 0) + # Each wall point stays on its own plasma point's side of the machine axis (hypot > 0 cannot fail) + @test all(wall.r[i, 1] * plasma.r[i, 1] + wall.r[i, 2] * plasma.r[i, 2] > 0 for i in 1:num_points) R_wall = [hypot(wall.r[i, 1], wall.r[i, 2]) for i in 1:num_points] R_plasma = [hypot(plasma.r[i, 1], plasma.r[i, 2]) for i in 1:num_points] @test maximum(R_wall) > maximum(R_plasma) @@ -668,22 +669,47 @@ @test all(aligns .< 0) end + @testset "WallGeometry3D conformal matches the analytic offset of a circular cross-section" begin + # A circular cross-section has an exact conformal wall: 0.5(max R - min R) is the minor radius + # exactly, so every plane offsets to a circle of radius r_minor(1 + a) about the same centre. + # Pins the offset direction, magnitude and orientation at once, which a uniform-offset check cannot. + r_minor, R0 = 0.3, 1.7 + inputs = _make_3d_periodic_inputs(mtheta=24, nzeta=24, mtheta_in=32, nzeta_in=16, eps=0.0) + plasma = GeneralizedPerturbedEquilibrium.Vacuum.PlasmaGeometry3D(inputs) + for a in (0.2, 0.5) + wall = GeneralizedPerturbedEquilibrium.Vacuum.WallGeometry3D(inputs, plasma, WallShapeSettings(shape="conformal", a=a, equal_arc_wall=false)) + radii = [hypot(hypot(wall.r[i, 1], wall.r[i, 2]) - R0, wall.r[i, 3]) for i in axes(wall.r, 1)] + # Residual is the 32 -> 24 poloidal resampling of the boundary spline, not the offset + @test all(isapprox(rad, r_minor * (1 + a); atol=5e-4) for rad in radii) + end + end + @testset "WallGeometry3D non-axisymmetric error paths" begin inputs = _make_3d_periodic_inputs(mtheta=24, nzeta=24) plasma = GeneralizedPerturbedEquilibrium.Vacuum.PlasmaGeometry3D(inputs) for shape in ("elliptical", "dee", "mod_dee", "some_wall_file.dat") @test_throws ErrorException GeneralizedPerturbedEquilibrium.Vacuum.WallGeometry3D(inputs, plasma, WallShapeSettings(shape=shape)) end - # equal_arc_wall has no meaning without a 2D contour and is ignored with a warning - @test_logs (:warn, r"equal_arc_wall is ignored") match_mode=:any GeneralizedPerturbedEquilibrium.Vacuum.WallGeometry3D( + # equal_arc_wall has no meaning without a 2D contour and is ignored with a notice. It defaults to + # true, so this fires on runs that never set it: @info, not @warn. + @test_logs (:info, r"equal_arc_wall is ignored") match_mode=:any GeneralizedPerturbedEquilibrium.Vacuum.WallGeometry3D( inputs, plasma, WallShapeSettings(shape="conformal", a=0.2, equal_arc_wall=true) ) - # a full-torus boundary is required; expand_field_periods must run first + # nfp > 1 can only reach the constructor from an axisymmetric boundary, and is rejected there per_period = VacuumInput(x=inputs.x, y=inputs.y, z=inputs.z, mtheta_in=16, nzeta_in=16, m_modes=inputs.m_modes, n_modes=inputs.n_modes, mtheta=24, nzeta=24, nfp=3) @test_throws ErrorException GeneralizedPerturbedEquilibrium.Vacuum.WallGeometry3D(per_period, plasma, WallShapeSettings(shape="conformal", a=0.2)) + # nowall does not skip that guard + @test_throws ErrorException GeneralizedPerturbedEquilibrium.Vacuum.WallGeometry3D(per_period, plasma, WallShapeSettings(shape="nowall")) + # a plasma surface from a different grid is rejected rather than indexed off the end + other_grid = _make_3d_periodic_inputs(mtheta=16, nzeta=16) + @test_throws ErrorException GeneralizedPerturbedEquilibrium.Vacuum.WallGeometry3D( + inputs, + GeneralizedPerturbedEquilibrium.Vacuum.PlasmaGeometry3D(other_grid), + WallShapeSettings(shape="conformal", a=0.2) + ) # an offset that folds the surface is rejected rather than silently returned folded = _make_3d_nonaxis_inputs(mtheta=24, nzeta=24, mtheta_in=12, nzeta_in=12) @test_throws ErrorException GeneralizedPerturbedEquilibrium.Vacuum.WallGeometry3D( @@ -778,6 +804,31 @@ @test isapprox(wv, wv', rtol=1e-12) end + @testset "compute_vacuum_response 3D conformal wall on a non-axisymmetric boundary" begin + # The "3D conformal wall" testset above uses _make_3d_inputs (nzeta_in = 1), so it exercises the + # revolved branch. This is the only response-level coverage of the offset branch. + # a = 2.5 sits above the one-cell resolution floor (1.11 cells) while keeping mtheta, nzeta > 23 so + # the near-field patch keeps its full PATCH_RAD. On a 24 x 24 grid those two demands cannot be met + # by a close wall: h_ζ ~ 2π R0 / nzeta is 0.45 m here. + inputs = _make_3d_periodic_inputs(mtheta=24, nzeta=24) + settings = WallShapeSettings(shape="conformal", a=2.5, equal_arc_wall=false) + + wv = compute_vacuum_response(inputs, settings).wv + @test all(isfinite, wv) + @test isapprox(wv, wv', rtol=1e-12) + + # A conducting wall is stabilizing, so Wᵛ must exceed the nowall value. Catches a flipped normal + # or an offset built on the wrong side, which finiteness and Hermiticity both pass. + @test real(wv[1, 1]) > real(compute_vacuum_response(inputs, WallShapeSettings(shape="nowall")).wv[1, 1]) + + # This configuration clears the one-cell floor, so it draws no sub-cell or near-fold warning + @test_logs min_level=Base.CoreLogging.Warn GeneralizedPerturbedEquilibrium.Vacuum.WallGeometry3D( + inputs, + GeneralizedPerturbedEquilibrium.Vacuum.PlasmaGeometry3D(inputs), + settings + ) + end + # Field-periodic (layer-2) reduction: an nfp-periodic boundary makes the boundary-integral # operators block-circulant, so the reduced per-residue-class solve must reproduce the full # torus result (the n_stride=1 bridge case spans several residue classes mod nfp). From 2da8edcddc10978df3bb9906a718ca7452a6c023 Mon Sep 17 00:00:00 2001 From: d-burg Date: Wed, 16 Sep 2026 12:07:02 -0400 Subject: [PATCH 43/51] Tearing - BUGFIX - Describe D_norm with the iota_e normalization in the HDF5 annotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PerSurface/D_norm long_name still gave the retired √(τ/(1+τ)) factor, so every new gpec.h5 labelled the corrected values with the old formula. Match the struct docstring: D = (d_β/r_s)·S^(1/3)·√ι_e. Co-Authored-By: Claude Opus 5 --- src/Tearing/Runner/HDF5Output.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Tearing/Runner/HDF5Output.jl b/src/Tearing/Runner/HDF5Output.jl index e3052d6b4..b0fc8d2e4 100644 --- a/src/Tearing/Runner/HDF5Output.jl +++ b/src/Tearing/Runner/HDF5Output.jl @@ -74,7 +74,7 @@ const TEARING_H5_ANNOTATIONS = [ "PerSurface/tau" => (; long_name="temperature ratio τ = T_i/T_e per surface", dims=("surface",)), "PerSurface/lu" => (; long_name="Lundquist number S per surface", dims=("surface",)), "PerSurface/c_beta" => (; long_name="compressibility factor c_β = √(β_local/(1+β_local)) per surface", dims=("surface",)), - "PerSurface/D_norm" => (; long_name="Fitzpatrick normalized ion-sound/drift scale D = (d_β/r_s)·S^(1/3)·√(τ/(1+τ)) per surface", dims=("surface",)), + "PerSurface/D_norm" => (; long_name="Fitzpatrick normalized ion-sound/drift scale D = (d_β/r_s)·S^(1/3)·√ι_e per surface", dims=("surface",)), "PerSurface/P_perp" => (; long_name="perpendicular magnetic Prandtl number per surface", dims=("surface",)), "PerSurface/P_tor" => (; long_name="toroidal (momentum) magnetic Prandtl number per surface", dims=("surface",)), "PerSurface/Q_e" => (; long_name="normalized electron diamagnetic frequency Q_e per surface", dims=("surface",)), From 74b6c94f5f8b7d91b945dcada15021b2cc4dac9c Mon Sep 17 00:00:00 2001 From: d-burg Date: Wed, 16 Sep 2026 12:07:02 -0400 Subject: [PATCH 44/51] InnerLayer.SLAYER - TEST - Cover the diamagnetic-frequency guards in slayer_parameters Neither ArgumentError guard on the diamagnetic frequencies was exercised. Assert the degenerate case (iota_e singular), same-sign drifts with the ion drift dominant (iota_e < 0), and zero electron drift (iota_e = 0). Co-Authored-By: Claude Opus 5 --- test/runtests_slayer_params.jl | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/runtests_slayer_params.jl b/test/runtests_slayer_params.jl index 63d74ba83..1f665fb1d 100644 --- a/test/runtests_slayer_params.jl +++ b/test/runtests_slayer_params.jl @@ -140,6 +140,14 @@ @test p.ising == 0 end + @testset "Test 1d: reject diamagnetic frequencies that leave D undefined" begin + # iota_e = ω_*e/(ω_*e − ω_*i) must be finite and positive for D ∝ √iota_e. + _drifts(ωe, ωi) = merge(_ref_kwargs(), (omega_e=ωe, omega_i=ωi)) + @test_throws ArgumentError slayer_parameters(; _drifts(-1.0e4, -1.0e4)...) # degenerate: iota_e singular + @test_throws ArgumentError slayer_parameters(; _drifts(-1.0e4, -2.0e4)...) # same sign, |ω_*i| > |ω_*e|: iota_e = -1 + @test_throws ArgumentError slayer_parameters(; _drifts(0.0, 5.0e3)...) # no electron drift: iota_e = 0 + end + @testset "Test 2: r-based shear conversion" begin # Direct application of r_s · (dq/dψ) / (q · da/dψ). @test r_based_shear(0.5, 2.0, 4.0, 0.5) ≈ 2.0 From ea5d4658d05a3fbe4051a1aa8ba65191eea68102 Mon Sep 17 00:00:00 2001 From: d-burg Date: Thu, 17 Sep 2026 12:40:07 -0400 Subject: [PATCH 45/51] Regression - BUGFIX - Give each override run its own uniquely named rundir Placing the override rundir beside its example fixed relative file references, but the name became fixed per example, so two harness runs from the same checkout on an override case would delete and rewrite each other's deck and output. Create it with mktempdir under the example's parent instead: still a sibling, so relative references resolve, but unique per invocation, as the override rundir, worktrees and subprocess files already were. Only concurrent local-ref runs could collide; git-ref comparisons each run in their own temporary worktree. Co-Authored-By: Claude Opus 5 --- regression-harness/src/runner.jl | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/regression-harness/src/runner.jl b/regression-harness/src/runner.jl index 048276b2a..1a192a140 100644 --- a/regression-harness/src/runner.jl +++ b/regression-harness/src/runner.jl @@ -27,7 +27,7 @@ end Materialize the directory GPEC will actually run in. With no `overrides`, the example deck runs in place (returns it untouched). With overrides, -the deck is copied to a throwaway sibling of the example dir and the named gpec.toml keys are +the deck is copied to a uniquely named throwaway sibling of the example dir and the named gpec.toml keys are patched there, so one shared example can serve several cases (e.g. a collisionless variant via `"KineticForces.nutype" => "zero"`). The copy sits beside the original so relative file references in the deck (e.g. `SLAYER.profile_file = "..//..."`) still resolve. @@ -36,9 +36,9 @@ Returns `(rundir, is_temp)`; the caller removes `rundir` when `is_temp`. """ function _materialize_rundir(example_path::String, overrides::Dict{String,Any}) isempty(overrides) && return (example_path, false) - rundir = joinpath(dirname(example_path), ".regress-override-" * basename(example_path)) - rm(rundir; recursive=true, force=true) # stale leftover from a crashed run - cp(example_path, rundir) + # Unique per invocation, so concurrent runs from one checkout cannot share (and race on) a rundir. + rundir = mktempdir(dirname(example_path); prefix=".regress-override-" * basename(example_path) * "-", cleanup=false) + cp(example_path, rundir; force=true) rm(joinpath(rundir, "gpec.h5"); force=true) # drop any stale output copied along cfg = TOML.parsefile(joinpath(rundir, "gpec.toml")) for (dotted, val) in overrides From f574611c7c6aaf0ac152f5beb4e37214bf78f304 Mon Sep 17 00:00:00 2001 From: logan-nc Date: Fri, 18 Sep 2026 23:05:48 -0400 Subject: [PATCH 46/51] Equilibrium - BUGFIX - Read the IMAS plasma current only when the file states one The current is optional in IMAS and reading an absent one throws rather than returning missing, so checking that the field exists was not enough: every equilibrium without a stated current failed to load. It now reads through the default-returning accessor, leaving the helicity positive when the file is silent, as the g-file path already does. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BzKDmtKYDotJWrcxWf1oJu --- src/Equilibrium/ReadEquilibrium.jl | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Equilibrium/ReadEquilibrium.jl b/src/Equilibrium/ReadEquilibrium.jl index 56ea891dc..755a91aca 100644 --- a/src/Equilibrium/ReadEquilibrium.jl +++ b/src/Equilibrium/ReadEquilibrium.jl @@ -441,6 +441,7 @@ internal COCOS 2 convention: ## Arguments - `config`: `EquilibriumConfig` with `eq_type = "imas"` and `imas_cocos` set. + - `dd`: populated `IMASdd.dd` with `dd.equilibrium.time_slice[]` containing: + `global_quantities.psi_axis`, `global_quantities.psi_boundary` @@ -486,8 +487,9 @@ function read_imas(config::EquilibriumConfig, dd) bt_sign = isempty(f_1d) ? 1 : Int(sign(f_1d[end])) bt_sign == 0 && (bt_sign = 1) # Plasma-current sign from the IMAS global quantity (missing or zero → +1). - ip_imas = hasproperty(eqt.global_quantities, :ip) ? eqt.global_quantities.ip : 0.0 - ip_sign = ismissing(ip_imas) || ip_imas == 0 ? 1 : Int(sign(ip_imas)) + # The current is optional in IMAS, and reading an absent one throws rather than returning missing. + ip_imas = getproperty(eqt.global_quantities, :ip, 0.0) + ip_sign = ip_imas == 0 ? 1 : Int(sign(ip_imas)) nw = length(psi_1d) psi_norm_grid = range(0.0, 1.0; length=nw) From 2dcbbf17079547eea6ed1cc1ebb955dd00d894f1 Mon Sep 17 00:00:00 2001 From: logan-nc Date: Sat, 19 Sep 2026 09:11:29 -0400 Subject: [PATCH 47/51] KineticForces - BUGFIX! - Use one validity boundary and bound its rational clearing Independent review of #414 found the near-axis suppression boundary computed three different ways, and two ways it can run away. One boundary per run. The NTV psi torque quadrature recomputed psi_c from the single species it was integrating and never called clear_rational_windows, while the EL kinetic matrices and the Validity output used the widest-orbit species' cleared boundary. Validity/psi_c in gpec.h5 therefore described a different domain from the one the torque was integrated over, and the standalone torque kept near-axis contributions the EL build had already zeroed - which is exactly the cross-check the PE-vs-NTV agreement rests on. axis_validity_boundary is now the only entry point: run_kinetic_forces computes it once and threads it through compute_torque_all_methods! into integrate_psi_quadgk. Contiguity. kinetic_axis_validity_psi took the outermost trigger anywhere on the profile grid. q is not monotonic, so an edge q rise or a pedestal gradient could re-trigger the criterion far from the axis and drag the suppressed band across a large fraction of the plasma. The scan now stops at the first surface where the ordering holds and warns if the criterion fires again further out. Cascade. clear_rational_windows rescans [psi_c, 2 psi_c] after each move, so the window widens as psi_c grows; with rationals at 0.15/0.25/0.45 a psi_c of 0.10 walked out to 0.455. The move is capped at VALIDITY_CLEAR_MAX_FACTOR times the orbit-width boundary, past which the uncleared boundary is kept with a warning - a band cutting a rational is resolved by the band knots, whereas suppressing half the plasma is not recoverable. Grid pinning. resonance_grid_nodes cleared its boundary against intr.sing, which sing_find! does not populate until long after the grid is built, so the clearing was silently a no-op. The nodes are now located inside maybe_reform_equilibrium against that pass's own rational list. Validity coverage. write_validity! was reachable only from run_kinetic_forces, whose body is gated on a perturbed-equilibrium state, so a calculated-kinetic FFS-only run wrote no Validity group at all. It is written from the ForceFreeStates output stage instead, wherever kinetic profiles were loaded. kinetic_regularization_kwargs is duck-typed on ffs so the override is testable, and its message no longer says "self-consistent" - it covers both kinetic sources. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VdRsGGg4YRG6E9fgLGbpdF --- src/GeneralizedPerturbedEquilibrium.jl | 35 ++++++++---- .../CalculatedKineticMatrices.jl | 5 +- src/KineticForces/Compute.jl | 18 ++++--- src/KineticForces/Utils.jl | 54 ++++++++++++++----- .../PerturbedEquilibriumStructs.jl | 20 +++---- 5 files changed, 90 insertions(+), 42 deletions(-) diff --git a/src/GeneralizedPerturbedEquilibrium.jl b/src/GeneralizedPerturbedEquilibrium.jl index aea43f817..df8a0678e 100755 --- a/src/GeneralizedPerturbedEquilibrium.jl +++ b/src/GeneralizedPerturbedEquilibrium.jl @@ -211,7 +211,7 @@ function main_from_inputs( kf_ctrl, kinetic_profiles, kf_species = load_kinetic_context(inputs, intr, ctrl, equil) equil = maybe_reform_equilibrium(equil, eq_config, additional_input, intr, ctrl, kinetic_profiles; - pinned=KineticForces.resonance_grid_nodes(ctrl, kf_ctrl, kinetic_profiles, kf_species, equil, intr)) + kf_ctrl=kf_ctrl, kf_species=kf_species) @info "Equilibrium construction completed in $(@sprintf("%.3f", time() - equil_start)) s" @@ -257,6 +257,15 @@ function main_from_inputs( @info "Results written to $(ctrl.HDF5_filename)" end + # Validity describes the kinetic model, not the torque contraction, so it is written wherever + # kinetic profiles were loaded — including FFS-only runs that never reach run_kinetic_forces. + if ctrl.write_outputs_to_HDF5 && kinetic_profiles !== nothing + h5open(joinpath(ffs_result.dir_path, ctrl.HDF5_filename), "cw") do h5file + KineticForces.write_validity!(h5file, kf_ctrl, kf_species, kinetic_profiles, equil, + Float64[sng.psifac for sng in intr.sing]) + end + end + @info "Force-Free States completed in $(@sprintf("%.3f", time() - ffs_start)) s" # Early exit if user only requested force-free states (SLAYER still runs). @@ -380,7 +389,8 @@ function load_kinetic_context( end """ - maybe_reform_equilibrium(equil, eq_config, additional_input, intr, ctrl, kinetic_profiles) -> equil + maybe_reform_equilibrium(equil, eq_config, additional_input, intr, ctrl, kinetic_profiles; + kf_ctrl=nothing, kf_species=nothing) -> equil Two-pass auto grid: measure the pass-1 equilibrium's curvature (profiles, geometry, kinetic profiles), pin knots on rational surfaces, and re-form on the refined grid from the in-memory @@ -393,11 +403,16 @@ function maybe_reform_equilibrium( intr::ForceFreeStatesInternal, ctrl::ForceFreeStatesControl, kinetic_profiles; - pinned::Vector{Float64}=Float64[] + kf_ctrl::Union{Nothing,KineticForces.KineticForcesControl}=nothing, + kf_species=nothing ) Equilibrium.wants_two_pass(eq_config) || return equil mandatory = ForceFreeStates.rational_psi_nodes(equil; nlow=intr.nlow, nhigh=intr.nhigh) + # Kinetic-resonance nodes are located here rather than at the call site so the validity + # boundary that filters them is cleared against this pass's rationals (intr.sing is empty yet). + pinned = kf_ctrl === nothing ? Float64[] : + KineticForces.resonance_grid_nodes(ctrl, kf_ctrl, kinetic_profiles, kf_species, equil, intr; rationals=mandatory) # Smallest |n| in the run sets the widest matching half-stencil dpsi = singfac_min/(n_min·|q′|), # so the rational-surface brackets clear a zone large enough for every mode. n_min = minimum(abs(n) for n in intr.nlow:intr.nhigh if n != 0) @@ -942,6 +957,10 @@ function run_kinetic_forces( @info "\n KineticForces\n$_SECTION" kf_start = time() + # One boundary for the whole run, shared with the EL kinetic matrices and the Validity output. + axis_psi_c = KineticForces.axis_validity_boundary(kf_ctrl, species, kinetic_profiles, result.equil, + Float64[sng.psifac for sng in result.surfaces]) + # Standalone NTV torque diagnostics need a PE state (they contract kinetic operators # against ξ). The self-consistent kinetic_source="calculated" path produces none — skip. if pe_state === nothing @@ -953,13 +972,12 @@ function run_kinetic_forces( if species === nothing kf_state = KineticForces.KineticForcesState() - KineticForces.compute_torque_all_methods!(kf_state, kf_intr, kf_ctrl, result.equil, kinetic_profiles) + KineticForces.compute_torque_all_methods!(kf_state, kf_intr, kf_ctrl, result.equil, kinetic_profiles; + axis_psi_c=axis_psi_c) if kf_ctrl.write_outputs_to_HDF5 h5open(joinpath(result.dir_path, kf_ctrl.HDF5_filename), "cw") do h5file KineticForces.write_to_hdf5!(h5file, kf_state; dVdpsi_spline=result.equil.profiles.dVdpsi_spline) - KineticForces.write_validity!(h5file, kf_ctrl, species, kinetic_profiles, result.equil, - Float64[sng.psifac for sng in result.surfaces]) end end else @@ -972,7 +990,8 @@ function run_kinetic_forces( (f => getfield(kf_ctrl, f) for f in fieldnames(KineticForces.KineticForcesControl))..., zi=sp.z, mi=sp.m, electron=sp.electron, ion_species=KineticForces.IonSpecies[]) st = KineticForces.KineticForcesState() - KineticForces.compute_torque_all_methods!(st, kf_intr, sctrl, result.equil, sp.profiles) + KineticForces.compute_torque_all_methods!(st, kf_intr, sctrl, result.equil, sp.profiles; + axis_psi_c=axis_psi_c) push!(states, st) push!(labels, sp.label) end @@ -985,8 +1004,6 @@ function run_kinetic_forces( end KineticForces.write_to_hdf5!(h5file, kf_state; dVdpsi_spline=result.equil.profiles.dVdpsi_spline) - KineticForces.write_validity!(h5file, kf_ctrl, species, kinetic_profiles, result.equil, - Float64[sng.psifac for sng in result.surfaces]) end end end diff --git a/src/KineticForces/CalculatedKineticMatrices.jl b/src/KineticForces/CalculatedKineticMatrices.jl index de916b0ac..d6436b710 100644 --- a/src/KineticForces/CalculatedKineticMatrices.jl +++ b/src/KineticForces/CalculatedKineticMatrices.jl @@ -116,8 +116,9 @@ function compute_calculated_kinetic_matrices( [Equilibrium.ResolvedNTVSpecies(kf_ctrl.zi, kf_ctrl.mi, kf_ctrl.electron, "single", kinetic_profiles)] : species - # Near-axis validity envelope: suppress the drift-kinetic increments where the zero-orbit-width - # ordering fails, taking the widest-orbit species. Kernel evaluation is skipped where it is 0. + # Near-axis validity envelope: suppresses the drift-kinetic increments where the zero-orbit-width + # ordering fails (kernel evaluation skipped where it is 0). The same real scalar must scale kw and + # kt at the same ψ — that is what keeps the blocks Hermitian and the energy/torque relation intact. env = ones(Float64, mpsi) if kf_ctrl.axis_validity_suppression psi_c = axis_psi_c diff --git a/src/KineticForces/Compute.jl b/src/KineticForces/Compute.jl index 1ad347561..3ed4fdd9d 100644 --- a/src/KineticForces/Compute.jl +++ b/src/KineticForces/Compute.jl @@ -83,7 +83,7 @@ function integrate_psi_quadgk( wdfac::Float64, divxfac::Float64, electron::Bool, method::String, equil, intr::KineticForcesInternal, ctrl::KineticForcesControl, kinetic_profiles::Equilibrium.KineticProfileSplines; - psi_min::Float64=0.0, psi_max::Float64=1.0 + psi_min::Float64=0.0, psi_max::Float64=1.0, axis_psi_c::Float64=0.0 ) is_matrix_method = occursin("mm", method) mpert = intr.mpert @@ -100,11 +100,10 @@ function integrate_psi_quadgk( panel_psis=Float64[], resonance_psis=Float64[]) end - # Near-axis validity suppression: same boundary and envelope as the EL kinetic - # matrices (one source of truth), applied to the torque density; the quadrature - # domain starts at the boundary since the integrand is identically zero below it. - psi_c = ctrl.axis_validity_suppression ? - kinetic_axis_validity_psi(kinetic_profiles, equil; zi=zi, mi=mi, electron=electron) : 0.0 + # Near-axis validity suppression on the torque density. `axis_psi_c` is the run's single + # boundary (widest-orbit species, cleared of rationals), so the quadrature domain matches the + # EL kinetic matrices and the Validity output; below it the integrand is identically zero. + psi_c = ctrl.axis_validity_suppression ? axis_psi_c : 0.0 x0 = max(x0, psi_c) if x0 >= xout return (total=ComplexF64(0.0), torque_profile=nothing, matrix_integrated=nothing, psi_nsteps=0, psi_quad_error=0.0, @@ -305,10 +304,13 @@ block-diagonal kinetic matrices. - `ctrl::KineticForcesControl`: Control parameters specifying which methods to run - `equil`: PlasmaEquilibrium with 2D interpolants - `kinetic_profiles::Equilibrium.KineticProfileSplines`: Named kinetic-profile splines + - `axis_psi_c`: the run's near-axis validity boundary, suppressing the torque density below it + (`KineticForces.axis_validity_boundary`); 0 disables the suppression """ function compute_torque_all_methods!(state::KineticForcesState, intr::KineticForcesInternal, ctrl::KineticForcesControl, equil, - kinetic_profiles::Equilibrium.KineticProfileSplines) + kinetic_profiles::Equilibrium.KineticProfileSplines; + axis_psi_c::Float64=0.0) for entry in METHOD_REGISTRY getfield(ctrl, entry.flag) || continue @@ -351,7 +353,7 @@ function compute_torque_all_methods!(state::KineticForcesState, intr::KineticFor n, ctrl.nl, ctrl.zi, ctrl.mi, ctrl.wdfac, ctrl.divxfac, ctrl.electron, method, equil, intr, ctrl, kinetic_profiles; - psi_min=ctrl.psilims[1], psi_max=ctrl.psilims[2]) + psi_min=ctrl.psilims[1], psi_max=ctrl.psilims[2], axis_psi_c=axis_psi_c) total_torque += result.total psi_nsteps_total += result.psi_nsteps diff --git a/src/KineticForces/Utils.jl b/src/KineticForces/Utils.jl index 80a04f1bf..d2281de21 100644 --- a/src/KineticForces/Utils.jl +++ b/src/KineticForces/Utils.jl @@ -117,10 +117,9 @@ end kinetic-profile grid where any of the three thermal orbit-width scales reaches the local minor radius ⟨r⟩ — potato width `(q²ρ²R₀)^(1/3)`, banana width `q·ρ/√ε`, and poloidal gyroradius `q·ρ/ε` (ε = ⟨r⟩/⟨R⟩, clamped as in `kinetic_resonance_psi_nodes`; thermal gyroradius -ρ = √(2·m·T)·/(Z·e·B₀) of the computed species). Inside this boundary trapped bananas become -potato orbits with width comparable to r itself, so the bounce-averaged kinetic response is -evaluated outside its validity domain (measured consequence: diverging kinetic increments and a -pathological EL step count). Returns 0.0 when no criterion is met anywhere. The Fortran precedent +ρ = √(2·m·T)·/(Z·e·B₀) of the computed species). Inside this boundary trapped bananas become potato +orbits of width comparable to r itself, so the bounce-averaged kinetic response is evaluated outside +its validity domain. Returns 0.0 when no criterion is met anywhere. The Fortran precedent (`ktanh_flag`, dcon/fourfit.F) suppressed the same region with four hand-tuned knobs; here the boundary is derived from the profiles with no user parameters. """ @@ -134,13 +133,23 @@ function kinetic_axis_validity_psi(kinetic_profiles::Equilibrium.KineticProfileS avg_R = equil.geometry.avg_R_spline ro = abs(equil.ro) bo = abs(equil.params.b0) + # Only the region contiguous with the axis counts: q is not monotonic, so an edge q rise or a + # pedestal gradient can re-trigger the criterion far out, where the model has not lost validity. psi_c = 0.0 + psi_outer = 0.0 + contiguous = true for psi in kinetic_profiles.xs psi <= 0 && continue w = orbit_widths(psi, T_spline, q_spline, avg_r, avg_R, mass, chrg, ro, bo) w.r <= 0 && continue - max(w.w_potato, w.rho_banana, w.rho_theta) >= w.r && (psi_c = max(psi_c, psi)) + if max(w.w_potato, w.rho_banana, w.rho_theta) >= w.r + contiguous ? (psi_c = psi) : (psi_outer = max(psi_outer, psi)) + else + contiguous = false + end end + psi_outer > 0 && @warn "kinetic_axis_validity_psi: orbit widths also reach ⟨r⟩ out to ψ=$(round(psi_outer; sigdigits=3)), " * + "outside the axis-contiguous region ending at ψ_c=$(round(psi_c; sigdigits=3)); only the latter is suppressed" return psi_c end @@ -242,22 +251,39 @@ function kinetic_validity_profiles(kinetic_profiles::Equilibrium.KineticProfileS psi_c=psi_c, envelope=envelope, is_valid=is_valid) end +# Bound on how far clearing rationals out of the envelope band may push ψ_c, as a multiple of the +# orbit-width boundary; beyond it the uncleared boundary is kept (see `clear_rational_windows`). +const VALIDITY_CLEAR_MAX_FACTOR = 1.5 + """ clear_rational_windows(psi_c, rationals) → Float64 Move the near-axis validity boundary outward until no rational surface lies inside the envelope's -transition band `[ψ_c, 2ψ_c]`. A rational sitting in the band gets its (near-singular) kinetic -increments multiplied by a rapidly varying, near-zero envelope, which the matrix splines cannot -represent — the resulting overshoot propagates NaNs into the stability solve. Where the orbit width -already reaches ⟨r⟩ the resonance is not trustworthy anyway, so the boundary is pushed past the -surface (suppressing it wholly) rather than cutting through it. +transition band, tested over `[ψ_c - RATIONAL_RES_RADIUS, 2ψ_c]` so a surface just inside the band +edge is cleared too. A rational sitting in the band gets its (near-singular) kinetic increments +multiplied by a rapidly varying, near-zero envelope, which the matrix splines cannot represent — +the resulting overshoot propagates NaNs into the stability solve. Where the orbit width already +reaches ⟨r⟩ the resonance is not trustworthy anyway, so the boundary is pushed past the surface +(suppressing it wholly) rather than cutting through it. + +The band widens as ψ_c moves, so clearing can cascade through a rational-dense core. The move is +capped at `VALIDITY_CLEAR_MAX_FACTOR`× the orbit-width boundary; past that the uncleared boundary +is kept with a warning, since suppressing a large fraction of the plasma is worse than a band that +cuts a rational (which the band knots resolve). """ function clear_rational_windows(psi_c::Float64, rationals::Vector{Float64})::Float64 psi_c <= 0 && return psi_c + psi_c0 = psi_c + limit = VALIDITY_CLEAR_MAX_FACTOR * psi_c0 for _ in 1:length(rationals) inside = filter(r -> psi_c - Equilibrium.RATIONAL_RES_RADIUS <= r <= 2 * psi_c, rationals) isempty(inside) && break psi_c = maximum(inside) + Equilibrium.RATIONAL_RES_RADIUS + if psi_c > limit + @warn "clear_rational_windows: clearing the envelope band of rationals would push ψ_c from " * + "$(round(psi_c0; sigdigits=3)) past $(round(limit; sigdigits=3)); keeping the orbit-width boundary" + return psi_c0 + end end return psi_c end @@ -286,10 +312,11 @@ end located Ω_ℓ = 0 resonance surfaces for every toroidal mode in the run, outside the near-axis validity region (nodes there are suppressed anyway). Empty for ideal runs — the ideal grid criterion knows nothing about kinetic resonances, so this is the only thing that puts knots on -them. +them. `rationals` is the pass-1 rational-surface list the boundary is cleared against; the caller +supplies it because `intr.sing` is not populated until after the grid is built. """ function resonance_grid_nodes(ctrl, kf_ctrl::KineticForcesControl, kinetic_profiles, species, - equil, intr)::Vector{Float64} + equil, intr; rationals::Vector{Float64}=Float64[])::Vector{Float64} nodes = Float64[] (ctrl.kinetic_factor > 0 && ctrl.kinetic_source == "calculated" && kinetic_profiles !== nothing) || return nodes for n_res in intr.nlow:intr.nhigh @@ -298,8 +325,7 @@ function resonance_grid_nodes(ctrl, kf_ctrl::KineticForcesControl, kinetic_profi n=n_res, nl=kf_ctrl.nl, zi=kf_ctrl.zi, mi=kf_ctrl.mi, electron=kf_ctrl.electron, wdfac=kf_ctrl.wdfac)) end - psi_c = axis_validity_boundary(kf_ctrl, species, kinetic_profiles, equil, - Float64[sng.psifac for sng in intr.sing]) + psi_c = axis_validity_boundary(kf_ctrl, species, kinetic_profiles, equil, rationals) psi_c > 0 && filter!(p -> p > psi_c, nodes) return nodes end diff --git a/src/PerturbedEquilibrium/PerturbedEquilibriumStructs.jl b/src/PerturbedEquilibrium/PerturbedEquilibriumStructs.jl index 52a97be33..2a53285e3 100644 --- a/src/PerturbedEquilibrium/PerturbedEquilibriumStructs.jl +++ b/src/PerturbedEquilibrium/PerturbedEquilibriumStructs.jl @@ -27,9 +27,9 @@ Medium Priority (defer for MWE): Regularization: -# High Priority (MWE) - - - `reg_spot::Float64` - Regularization width for singular surface smoothing (default: 0.05). Set to 0 to disable. Must be ≥ 0. Forced to 0 in self-consistent kinetic runs, whose Euler-Lagrange operator has no resonant singularity to smooth. + - `reg_spot::Float64` - Regularization width for singular surface smoothing (default: 0.05). Set to 0 + to disable. Must be ≥ 0. Forced to 0 in kinetic runs, whose Euler-Lagrange operator has no + resonant singularity to smooth. """ @kwdef struct PerturbedEquilibriumControl # High Priority (MWE) @@ -216,18 +216,20 @@ end kinetic_regularization_kwargs(ffs, kwargs) -> NamedTuple `reg_spot` smooths the **ideal** 1/(m−nq) divergence of ξ^ψ′ and ξ^α before they drive the NTV -integrand. A self-consistent kinetic solve has no such divergence — det(F̄) is complex and nonzero -at the rationals (Park & Logan, Phys. Plasmas 24, 032505 (2017) §III D) — so regularizing there -suppresses a finite physical response, and does so inconsistently, since ξ^ψ is never regularized. -Force `reg_spot = 0` for kinetic solves and log the override; ideal solves keep their setting. +integrand. A kinetic solve has no such divergence — det(F̄) is complex and nonzero at the rationals +(Park & Logan, Phys. Plasmas 24, 032505 (2017) §III D) — so regularizing there suppresses a finite +physical response, and does so inconsistently, since ξ^ψ is never regularized. Force +`reg_spot = 0` whenever a kinetic fit is present (either `kinetic_source`, both of which remove the +resonant singularity) and log the override; ideal solves keep their setting. `ffs` is duck-typed: +only `ffs.mats.kinetic` is read, so the override is unit-testable without a full run. """ -function kinetic_regularization_kwargs(ffs::ForceFreeStatesResult, kwargs) +function kinetic_regularization_kwargs(ffs, kwargs) base = values(kwargs) default_reg = PerturbedEquilibrium.PerturbedEquilibriumControl().reg_spot prev = get(base, :reg_spot, default_reg) kinetic = ffs.mats.kinetic !== nothing kinetic && prev != 0 && - @info "Self-consistent kinetic run: overriding reg_spot=$prev with 0 " * + @info "Kinetic run: overriding reg_spot=$prev with 0 " * "(the kinetic terms remove the ideal resonant singularity; see docs/src/kinetic_forces.md)" return merge(base, (; reg_spot=kinetic ? 0.0 : prev)) end From 292c50b24bb50133701278ca305f8cdb5a90ceeb Mon Sep 17 00:00:00 2001 From: logan-nc Date: Sat, 19 Sep 2026 09:11:37 -0400 Subject: [PATCH 48/51] KineticForces - TEST - Cover the near-axis validity boundary and regularization override The validity machinery shipped with no unit coverage. Adds runtests_kinetic_validity.jl (registered in runtests.jl) over the pieces the review found defects in: - kinetic_axis_validity_envelope: endpoint values, the midpoint, monotonicity, and a C2 check that the second difference straddling each band end vanishes linearly in h - clear_rational_windows: empty and far-rational no-ops, a single in-band rational, and the cascade construction that must hit the cap and keep the orbit-width boundary - kinetic_axis_validity_psi: a q spike far from the axis must not move the boundary - kinetic_regularization_kwargs: ideal pass-through, kinetic forcing 0, and the case where the deck omits reg_spot entirely and must pick up the struct default The equilibrium is a duck-typed stand-in - the boundary scan reads only q, , , ro and b0 - so the tests run in seconds without building a PlasmaEquilibrium. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VdRsGGg4YRG6E9fgLGbpdF --- test/runtests.jl | 1 + test/runtests_kinetic_validity.jl | 114 ++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 test/runtests_kinetic_validity.jl diff --git a/test/runtests.jl b/test/runtests.jl index 7845eea60..5fa49ba86 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -48,6 +48,7 @@ else include("./runtests_dispersion_polish.jl") include("./runtests_slayer_runner.jl") include("./runtests_kinetic.jl") + include("./runtests_kinetic_validity.jl") include("./runtests_multiion.jl") include("./runtests_fullruns.jl") include("./runtests_coils.jl") diff --git a/test/runtests_kinetic_validity.jl b/test/runtests_kinetic_validity.jl new file mode 100644 index 000000000..c84b1d217 --- /dev/null +++ b/test/runtests_kinetic_validity.jl @@ -0,0 +1,114 @@ +@testset "Kinetic near-axis validity boundary" begin + + GPE = GeneralizedPerturbedEquilibrium + KF = GPE.KineticForces + EQ = GPE.Equilibrium + PE = GPE.PerturbedEquilibrium + + # Duck-typed stand-in for PlasmaEquilibrium: the boundary scan reads only these fields. + mock_equil(q, r, R; ro=1.7, b0=2.0) = + (; profiles=(; q_spline=q), geometry=(; avg_r_spline=r, avg_R_spline=R), ro=ro, params=(; b0=b0)) + + # Kinetic profiles carrying a prescribed Ti(ψ) in Joules; only xs and Ti_spline are read. + function mock_profiles(xs, Ti_eV) + ones_ = ones(length(xs)) + Ti = Ti_eV .* 1.602176634e-19 + return EQ.KineticProfileSplines(xs, 1e19 .* ones_, 1e19 .* ones_, Ti, Ti, + zero(ones_), 17.0 .* ones_, ones_, ones_, ones_) + end + + @testset "kinetic_axis_validity_envelope" begin + psi_c = 0.04 + @test KF.kinetic_axis_validity_envelope(0.0, psi_c) == 0.0 + @test KF.kinetic_axis_validity_envelope(psi_c, psi_c) == 0.0 + @test KF.kinetic_axis_validity_envelope(2 * psi_c, psi_c) == 1.0 + @test KF.kinetic_axis_validity_envelope(1.0, psi_c) == 1.0 + @test KF.kinetic_axis_validity_envelope(1.5 * psi_c, psi_c) ≈ 0.5 atol = 1e-12 + + # Disabled boundary leaves everything unsuppressed. + @test KF.kinetic_axis_validity_envelope(0.0, 0.0) == 1.0 + @test KF.kinetic_axis_validity_envelope(0.5, -1.0) == 1.0 + + # Monotone across the transition. + band = range(psi_c, 2 * psi_c; length=101) + vals = [KF.kinetic_axis_validity_envelope(x, psi_c) for x in band] + @test all(diff(vals) .>= -1e-15) + + # C² at the band ends: the second difference straddling each end vanishes linearly in h. + # A C¹-but-not-C² join would leave it constant, and a slope kink would blow it up as 1/h. + d2(x, h) = (KF.kinetic_axis_validity_envelope(x + h, psi_c) - 2 * KF.kinetic_axis_validity_envelope(x, psi_c) + + KF.kinetic_axis_validity_envelope(x - h, psi_c)) / h^2 + for edge in (psi_c, 2 * psi_c) + h = psi_c * 1e-2 + @test abs(d2(edge, h) / d2(edge, h / 2)) ≈ 2 atol = 0.2 + end + end + + @testset "clear_rational_windows" begin + R = EQ.RATIONAL_RES_RADIUS + + @test KF.clear_rational_windows(0.04, Float64[]) == 0.04 + @test KF.clear_rational_windows(0.0, [0.05]) == 0.0 + + # A rational well outside the band is left alone. + @test KF.clear_rational_windows(0.04, [0.5]) == 0.04 + + # A rational inside [ψ_c - R, 2ψ_c] pushes the boundary just past it. + @test KF.clear_rational_windows(0.10, [0.1221]) ≈ 0.1221 + R + + # Cascade guard: rationals spaced so each move widens the band into the next one. The move + # is capped at VALIDITY_CLEAR_MAX_FACTOR × ψ_c, and the orbit-width boundary is kept. + cascade = [0.15, 0.25, 0.45] + local psi_capped + @test_logs (:warn,) match_mode = :any begin + psi_capped = KF.clear_rational_windows(0.10, cascade) + end + @test psi_capped == 0.10 + @test KF.VALIDITY_CLEAR_MAX_FACTOR > 1 + end + + @testset "kinetic_axis_validity_psi is contiguous with the axis" begin + xs = collect(0.005:0.005:1.0) + # r = a√ψ, R constant: ε → 0 on axis, so ρ_θ = qρ/ε diverges there and the criterion fires. + a, R0 = 0.6, 1.7 + r_of(psi) = a * sqrt(psi) + R_of(_) = R0 + Ti = fill(2000.0, length(xs)) # 2 keV, flat + + # Smooth q: the invalid region is a single band touching the axis. + q_smooth(psi) = 1.0 + 2.0 * psi^2 + psi_c = KF.kinetic_axis_validity_psi(mock_profiles(xs, Ti), mock_equil(q_smooth, r_of, R_of)) + @test psi_c > 0 + @test psi_c < 0.5 + + # Same profile with a narrow q spike far from the axis. The spike re-triggers the criterion, + # but the model has not lost validity there, so the boundary must not jump out to it. + q_spiked(psi) = q_smooth(psi) + 400.0 * exp(-((psi - 0.9) / 0.01)^2) + local psi_c_spiked + @test_logs (:warn,) match_mode = :any begin + psi_c_spiked = KF.kinetic_axis_validity_psi(mock_profiles(xs, Ti), mock_equil(q_spiked, r_of, R_of)) + end + @test psi_c_spiked ≈ psi_c + @test psi_c_spiked < 0.5 + end + + @testset "kinetic_regularization_kwargs" begin + default_reg = PE.PerturbedEquilibriumControl().reg_spot + ideal = (; mats=(; kinetic=nothing)) + kinetic = (; mats=(; kinetic=(;))) + + # Ideal runs keep whatever the deck asked for, explicit or defaulted. + @test PE.kinetic_regularization_kwargs(ideal, pairs((; reg_spot=0.05))).reg_spot == 0.05 + @test PE.kinetic_regularization_kwargs(ideal, pairs((;))).reg_spot == default_reg + + # Kinetic runs force 0 — including when the deck omits the key entirely, which must pick up + # the struct default rather than leaving reg_spot unset. + @test PE.kinetic_regularization_kwargs(kinetic, pairs((; reg_spot=0.05))).reg_spot == 0.0 + @test PE.kinetic_regularization_kwargs(kinetic, pairs((;))).reg_spot == 0.0 + @test default_reg != 0.0 # otherwise the override above proves nothing + + # Unrelated kwargs pass through untouched. + out = PE.kinetic_regularization_kwargs(kinetic, pairs((; reg_spot=0.05, filter_modes=true))) + @test out.filter_modes == true + end +end From fd3411a55203efaa77c493bda4693218bc033deb Mon Sep 17 00:00:00 2001 From: logan-nc Date: Sat, 19 Sep 2026 09:11:37 -0400 Subject: [PATCH 49/51] ForceFreeStates/KineticForces - MINOR - Trim the review-flagged comments Review feedback on #414: the block comments are too long. Collapses the pinned-knot block in refined_psi_grid, the BAND_KNOTS and validity-envelope blocks in Kinetic.jl, and the sub-threshold reporting block in Finding.jl to one or two lines each, and rewraps the refined_psi_grid docstring line that had grown past the 180-char margin. Also folds find_kinetic_singular_surfaces!'s numbered "Algorithm:" list into prose, per the repo's no-step-numbering rule. docs/src/kinetic_forces.md keeps the regularization argument and its citations - it is user-facing reference material, not a comment - but drops the single-case DIII-D validation table, which belongs in the review package rather than in permanent docs where it goes stale. Adds the Logan (2015) Ch. 7 Eq. 7.46 reference alongside Park & Logan (2017), since it states the same result in GPEC's own notation. The three example decks get a shorter inline description for axis_validity_suppression, matching the surrounding comment column. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VdRsGGg4YRG6E9fgLGbpdF --- docs/src/kinetic_forces.md | 21 +++++++------------ examples/DIIID-like_ideal_example/gpec.toml | 2 +- .../Solovev_kinetic_NTV_example/gpec.toml | 2 +- examples/a10_kinetic_example/gpec.toml | 2 +- src/Equilibrium/GridRefinement.jl | 9 ++++---- src/ForceFreeStates/Kinetic.jl | 11 ++++------ src/ForceFreeStates/Surfaces/Finding.jl | 14 ++++--------- 7 files changed, 22 insertions(+), 39 deletions(-) diff --git a/docs/src/kinetic_forces.md b/docs/src/kinetic_forces.md index 6cbde2a58..95cf10315 100644 --- a/docs/src/kinetic_forces.md +++ b/docs/src/kinetic_forces.md @@ -153,26 +153,19 @@ kinetic composite matrix as ``F_k = Q \bar F_k Q - P_l^\dagger Q - Q P_u + R_1`` real ``\psi`` axis, and the singularity is removed from both the solution and the torque integral. (Torque-free kinetic energy principles instead *shift and split* the zeros to ``\psi_r \mp r_{L,R}``, where the singularity is logarithmic and integrable — still not a case -for smoothing.) +for smoothing.) Logan, *Electromagnetic Torque in Tokamaks with Toroidal Asymmetries* (PhD thesis, +Princeton, 2015), Ch. 7 Eq. 7.46 states the same resonance shifting/splitting result in GPEC's own +notation. GPEC therefore **forces `reg_spot = 0` whenever `kinetic_factor > 0`**, logging the override. Leaving it on suppresses a finite physical response and does so inconsistently — ``\xi^\psi`` is never regularized, so damping the other two breaks their near-resonance cancellation in ``\delta B/B`` and leaves a spurious residue driving the NTV integrand. -Measured on the DIII-D-like H-mode case (n = 1, C-coil drive), comparing the NTV torque against -the Euler–Lagrange solution's own dissipation ``-2n\,\mathrm{Im}\langle \xi, u_2\rangle/4\mu_0`` -— two independent calculations of the same quantity: - -| configuration | max ``|\xi^\alpha|`` | NTV torque [N·m] | EL dissipation [N·m] | -|---|---|---|---| -| ideal, `reg_spot = 0` | 643.8 | 6074.3 | — | -| ideal, `reg_spot = 0.05` | 0.058 | 0.554 | — | -| kinetic, `reg_spot = 0.05` | 0.055 | 0.1655 | 0.1322 | -| kinetic, `reg_spot = 0` | 0.059 | **0.1324** | **0.1322** | - -The ideal rows show why the knob exists; the kinetic rows show why it must be off there — the -two independent torques agree to 0.15 % with no regularization, and to 20 % with it. +The check that this is right is that the NTV torque and the Euler–Lagrange solution's own +dissipation ``-2n\,\mathrm{Im}\langle \xi, u_2\rangle/4\mu_0`` — two independent calculations +of the same quantity — agree to a fraction of a percent with the regularization off, and disagree +by tens of percent with it on. ## HDF5 outputs: complex torque convention and the EnergyIntegrals layout diff --git a/examples/DIIID-like_ideal_example/gpec.toml b/examples/DIIID-like_ideal_example/gpec.toml index 148d8ebf8..9896f4c89 100644 --- a/examples/DIIID-like_ideal_example/gpec.toml +++ b/examples/DIIID-like_ideal_example/gpec.toml @@ -87,6 +87,6 @@ f0type = "maxwellian" # Equilibrium distribution moment = "pressure" # Pressure-moment NTV torque atol_xlmda = 1e-9 # Absolute tolerance for inner pitch + energy integrations rtol_xlmda = 1e-5 # Relative tolerance for inner pitch + energy integrations -axis_validity_suppression = true # Suppress kinetic terms where the zero-orbit-width ordering fails near the axis (profile-derived boundary, no tuning parameters) +axis_validity_suppression = true # Suppress kinetic terms inside the near-axis orbit-width validity limit write_outputs_to_HDF5 = true # Write outputs to the HDF5 file verbose = true # Enable verbose logging diff --git a/examples/Solovev_kinetic_NTV_example/gpec.toml b/examples/Solovev_kinetic_NTV_example/gpec.toml index e174c5ab9..0e45a65fd 100644 --- a/examples/Solovev_kinetic_NTV_example/gpec.toml +++ b/examples/Solovev_kinetic_NTV_example/gpec.toml @@ -87,4 +87,4 @@ f0fac = 1 # Scale toroidal field at constant pressure (β, q change; Φ, p, [KineticForces] kinetic_file = "kinetic.dat" # Kinetic profile file: psi_n, n_i, n_e, T_i, T_e, omega_E columns -axis_validity_suppression = true # Suppress kinetic terms where the zero-orbit-width ordering fails near the axis (profile-derived boundary, no tuning parameters) +axis_validity_suppression = true # Suppress kinetic terms inside the near-axis orbit-width validity limit diff --git a/examples/a10_kinetic_example/gpec.toml b/examples/a10_kinetic_example/gpec.toml index 7854db0e1..7eabffae0 100644 --- a/examples/a10_kinetic_example/gpec.toml +++ b/examples/a10_kinetic_example/gpec.toml @@ -59,4 +59,4 @@ nutype = "harmonic" # Collision operator (zero, small, krook, harmoni f0type = "maxwellian" # Distribution function (maxwellian, jkp, cgl) atol_xlmda = 1e-9 # Absolute tolerance for inner pitch + energy integrations rtol_xlmda = 1e-5 # Relative tolerance for inner pitch + energy integrations -axis_validity_suppression = true # Suppress kinetic terms where the zero-orbit-width ordering fails near the axis (profile-derived boundary, no tuning parameters) +axis_validity_suppression = true # Suppress kinetic terms inside the near-axis orbit-width validity limit diff --git a/src/Equilibrium/GridRefinement.jl b/src/Equilibrium/GridRefinement.jl index d9441bbe8..0b64337a7 100644 --- a/src/Equilibrium/GridRefinement.jl +++ b/src/Equilibrium/GridRefinement.jl @@ -418,7 +418,8 @@ Build the refined pass-2 ψ grid from a formed pass-1 equilibrium: measured-curv density (`_knot_density`), equidistribution, a global minimum-spacing floor (`enforce_min_spacing`), and rational-surface bracketing (`bracket_mandatory_nodes`). `tau` is the target interpolation accuracy (`psi_accuracy`); `kin` optionally supplies kinetic profiles -whose pedestal gradients attract knots; `mandatory` lists rational-surface ψ values to bracket; `pinned` lists ψ values inserted as plain knots without a cleared zone (kinetic-resonance surfaces); +whose pedestal gradients attract knots; `mandatory` lists rational-surface ψ values to bracket; +`pinned` lists ψ values inserted as plain knots without a cleared zone (kinetic-resonance surfaces); `singfac_min` and `n_min` (smallest |n| in the run) set each surface's matching half-stencil `dpsi = singfac_min/(n_min·|q′|)`, and the bracket half-width is `bracket_coef·dpsi` (floored at `min_spacing`). Rational surfaces are bracketed, not pinned: a knot on the surface would make the @@ -450,10 +451,8 @@ function refined_psi_grid(equil::PlasmaEquilibrium; N == N_cap && M_total > N_cap && @warn "refined_psi_grid: knot count capped at $N_cap (density integral wants $(ceil(Int, M_total))); psi_accuracy=$tau may not be attainable" grid = enforce_min_spacing(_equidistribute(xs, rho, N), min_spacing) - # Pinned knots (e.g. kinetic-resonance surfaces): knot-at-node semantics via - # merge_mandatory_nodes — no cleared zone. Inserted before rational bracketing, so a - # pinned node inside a rational's bracket zone is cleared by it (the Δ′ clean-interval - # requirement wins locally; the rational's own dense floor resolves that neighbourhood). + # Pinned knots use knot-at-node semantics (no cleared zone); inserted before rational bracketing + # so a rational's bracket still wins locally where the two overlap. grid = isempty(pinned) ? grid : merge_mandatory_nodes(grid, pinned) isempty(mandatory) && return grid min_half_widths = [max(bracket_coef * singfac_min / (n_min * abs(equil.profiles.q_deriv(m))), min_spacing) for m in mandatory] diff --git a/src/ForceFreeStates/Kinetic.jl b/src/ForceFreeStates/Kinetic.jl index e057276e1..d148b12e9 100644 --- a/src/ForceFreeStates/Kinetic.jl +++ b/src/ForceFreeStates/Kinetic.jl @@ -1,7 +1,6 @@ -# Knots laid across the envelope's [ψ_c, 2ψ_c] transition. The envelope is a quintic smoothstep, -# so a cubic spline needs several interior knots to follow it without overshoot; nine keeps the -# residual below the kernel's own tolerance even on coarse decks. +# Knots across the envelope's [ψ_c, 2ψ_c] quintic-smoothstep transition, enough for a cubic spline +# to follow it without overshoot even on coarse decks. const BAND_KNOTS = 9 """ @@ -104,10 +103,8 @@ function build_kinetic_matrix_splines( xs = metric.xs mpsi = length(xs) - # The near-axis validity envelope (KineticForces) has structure on the scale of the - # suppression boundary; coarse equilibrium grids cannot represent env·(increment), and the - # spline overshoot can land on a rational surface. Pin the band ends (the smoothstep is - # only C² there) and resolve the transition with a fixed set of knots. + # Coarse equilibrium grids cannot represent env·(increment) across the validity band, and the + # spline overshoot can land on a rational; pin the band ends (C² there) and resolve the middle. band_knots(lo, hi) = axis_validity_psi_c > 0 ? [x for x in range(axis_validity_psi_c, 2 * axis_validity_psi_c; length=BAND_KNOTS) if lo < x < hi] : Float64[] diff --git a/src/ForceFreeStates/Surfaces/Finding.jl b/src/ForceFreeStates/Surfaces/Finding.jl index 9cc7b6112..9edfe6e0d 100644 --- a/src/ForceFreeStates/Surfaces/Finding.jl +++ b/src/ForceFreeStates/Surfaces/Finding.jl @@ -257,12 +257,8 @@ Mirrors the intent of Fortran `ksing_find` (`sing.f:1486-1616`) which finds zero det(F̄) via adaptive bisection. Here we use condition number peaks instead of determinant zeros for better numerical robustness and scale invariance. -Algorithm: - - 1. Evaluate cond(F̄) on a dense ψ grid - 2. Find local maxima (peaks where gradient changes from + to -) - 3. Refine each peak with golden-section minimization of -cond - 4. Filter by threshold and resonance condition +Evaluates cond(F̄) on a dense ψ grid, finds its local maxima, refines each with golden-section +minimization of -cond, then filters by threshold and resonance condition. """ function find_kinetic_singular_surfaces!( mats::MatrixSplines, @@ -298,10 +294,8 @@ function find_kinetic_singular_surfaces!( local_maxima = [i for i in 2:(ngrid-1) if cond_vals[i] > cond_vals[i-1] && cond_vals[i] > cond_vals[i+1]] peak_indices = filter(i -> cond_vals[i] > cond_threshold, local_maxima) - # Peaks below the threshold are not singular surfaces, but they mark where the kinetic F̄ comes - # closest to singular — the shifted/split resonances of Park & Logan Eq. (70). Report the - # strongest few so sharp kinetic structure is visible rather than silent (on a DIII-D-like case - # these track the NTV torque-density peaks at low collisionality/rotation). + # Peaks below threshold mark near-singular structure (the shifted/split resonances of Park & + # Logan Eq. 70); report the strongest few so sharp kinetic structure is visible rather than silent. subthreshold = filter(i -> KINETIC_RELAXED_FRAC * cond_threshold < cond_vals[i] <= cond_threshold, local_maxima) if !isempty(subthreshold) top = sort(subthreshold; by=i -> cond_vals[i], rev=true)[1:min(3, length(subthreshold))] From 0f8dd766fb0a359d5cb18721e12df6b8b5c7491d Mon Sep 17 00:00:00 2001 From: logan-nc Date: Sat, 19 Sep 2026 09:11:37 -0400 Subject: [PATCH 50/51] KineticForces - MINOR - Compress the nested-tolerance commentary Review feedback on #414 flagged this block as too long. It is not actually this branch's code - it arrived on develop with the nested energy/pitch tolerances and shows in the pull request diff only because the merge base makes develop's commits look like additions - but the comment rule applies either way, so it is trimmed here rather than deferred: 27 comment lines down to 14, one line per field. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VdRsGGg4YRG6E9fgLGbpdF --- src/KineticForces/KineticForcesStructs.jl | 25 ++++++----------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/src/KineticForces/KineticForcesStructs.jl b/src/KineticForces/KineticForcesStructs.jl index 8834a689f..16d895ada 100644 --- a/src/KineticForces/KineticForcesStructs.jl +++ b/src/KineticForces/KineticForcesStructs.jl @@ -130,32 +130,19 @@ builds a second control for its differing tolerance). nn::Int = 1 # Toroidal mode number nl::Int = 1 # Bounce harmonic number - # Tolerances, outermost to innermost: ψ quadrature ⊃ λ (pitch) ⊃ x (energy). - # Each level must be resolved more tightly than the one enclosing it, or the outer - # integrator chases its integrand's own quadrature noise instead of converging. - # *_xlmda: tolerances for the λ (pitch) integration - # *_x: tolerances for the x (energy) integration nested inside it; NaN ⇒ derive as - # nested_tolerance_margin × the pitch tolerances - # *_psi: tolerances for the outer ψ quadrature + # Tolerances, outermost to innermost: ψ quadrature ⊃ λ (pitch) ⊃ x (energy); each level must + # resolve tighter than the one enclosing it, or the outer integrator chases quadrature noise. atol_xlmda::Float64 = 1e-8 # Absolute tolerance for the inner pitch integration rtol_xlmda::Float64 = 1e-5 # Relative tolerance for the inner pitch integration atol_x::Float64 = NaN # Absolute tolerance for the energy integration (NaN ⇒ derived) rtol_x::Float64 = NaN # Relative tolerance for the energy integration (NaN ⇒ derived) - # The pitch integrand IS the energy integral, so the energy level is resolved this much - # tighter than the pitch level by default. + # Energy tolerances default to this fraction of the pitch ones (the pitch integrand is itself an energy integral). nested_tolerance_margin::Float64 = 1e-2 # Factor relating derived energy tolerances to the pitch ones - # rtol_psi is the primary convergence knob: ~2 significant figures matches the validity - # of the NTV model approximations. Do not set it tighter than the noise floor of the - # inner integrals (keep rtol_psi ≳ 10 × rtol_xlmda). + # Primary convergence knob (~2 sig figs matches NTV model validity); keep rtol_psi ≳ 10 × rtol_xlmda or it chases inner-integral noise. rtol_psi::Float64 = 1e-2 # Relative tolerance for outer ψ quadrature - # atol_psi is in N·m and therefore amplitude-sensitive: NTV scales as δB², so a 10× - # weaker applied field gives a 100× smaller torque and any fixed absolute tolerance can - # silently dominate termination with O(1) relative error. Default 0 (rtol-only); a - # nonzero value is an expert opt-out for near-net-zero-torque cases and triggers a - # warning when it dominates. + # Amplitude-sensitive (NTV ∝ δB²); default 0 (rtol-only). Nonzero is an expert opt-out for near-zero-torque cases and warns when it dominates. atol_psi::Float64 = 0.0 # Absolute tolerance for outer ψ quadrature [N·m] - # Runaway guard for the outer quadrature (e.g. sign-cancelling torque density with tiny - # net torque under rtol-only control); a convergence warning fires when hit. + # Runaway guard for rtol-only control (e.g. sign-cancelling torque density); warns when hit. maxevals_psi::Int = 2000 # Max integrand evaluations for outer ψ quadrature # Scaling factors From 2abea51d292928328fcb55190c3f3bcfc4b29c95 Mon Sep 17 00:00:00 2001 From: logan-nc Date: Sat, 19 Sep 2026 09:16:10 -0400 Subject: [PATCH 51/51] KineticForces - BUGFIX - Annotate the Validity group where it is written KineticForces/Validity carried no long_name/units at all. Its metadata table sat in HDF5Schema.MAIN_H5_ANNOTATIONS, which apply_main_h5_metadata! applies at the end of write_outputs_to_HDF5 - but write_validity! runs afterwards, in a separate h5open, so the pass never saw those datasets and the entries were dead. HDF5Schema's own header states the rule: sub-writers keep their tables next to their own writers. The table moves to Output.jl as KF_VALIDITY_H5_ANNOTATIONS and write_validity! annotates the group it just created, matching how write_to_hdf5! handles the per-method groups. The entries also gain the `dims` attribute the contract requires of rank >= 1 profiles. Corrects the rationale given in 2dcbbf170 for the cascade cap. That commit argued a transition band cutting a rational is harmless because the band knots resolve it; the history says otherwise - the band knots landed in 3f1dbfef9 and the NaN that motivated clear_rational_windows was found after them, in 37c4eebb4. The capped branch does reinstate the hazard, so the warning now says so and names the two ways out (axis_validity_suppression = false, or a finer grid there) instead of implying the knots cover it. The behaviour is unchanged: swallowing half the plasma is the worse failure. The contiguity warning gains maxlog=1 - it is reachable from four call sites, once per species - and its unit test now asserts the boundary rather than the log line, since maxlog makes the log order-dependent across the suite. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VdRsGGg4YRG6E9fgLGbpdF --- src/HDF5Schema.jl | 19 ------------------- src/KineticForces/Output.jl | 24 ++++++++++++++++++++++++ src/KineticForces/Utils.jl | 14 +++++++++----- test/runtests_kinetic_validity.jl | 7 +++---- 4 files changed, 36 insertions(+), 28 deletions(-) diff --git a/src/HDF5Schema.jl b/src/HDF5Schema.jl index 512264af2..2f2f6f175 100644 --- a/src/HDF5Schema.jl +++ b/src/HDF5Schema.jl @@ -143,25 +143,6 @@ const MAIN_H5_ANNOTATIONS = [ "LocalStability/alpha_critical" => (; long_name="critical normalized pressure gradient α for first ballooning stability", dims=("psi_ballooning",), attach=(1 => "LocalStability/ballooning_psi",)), - # --- KineticForces/Validity/ --- - "KineticForces/Validity/psi" => (; long_name="normalized poloidal flux ψ_N of the kinetic validity profiles", scale="psi"), - "KineticForces/Validity/rho_i" => (; long_name="thermal ion gyroradius √(2mT)/(Z·e·B₀)", units="m", attach=(1 => "KineticForces/Validity/psi",)), - "KineticForces/Validity/rho_banana" => (; long_name="thermal banana orbit width q·ρ_i/√ε", units="m", attach=(1 => "KineticForces/Validity/psi",)), - "KineticForces/Validity/rho_theta" => (; long_name="thermal poloidal gyroradius q·ρ_i/ε", units="m", attach=(1 => "KineticForces/Validity/psi",)), - "KineticForces/Validity/w_potato" => (; long_name="potato orbit width (q²ρ_i²R₀)^(1/3)", units="m", attach=(1 => "KineticForces/Validity/psi",)), - "KineticForces/Validity/r_minor" => (; long_name="surface-average minor radius ⟨r⟩", units="m", attach=(1 => "KineticForces/Validity/psi",)), - "KineticForces/Validity/L_p" => (; long_name="pressure gradient scale length |p|/|dp/dr|", units="m", attach=(1 => "KineticForces/Validity/psi",)), - "KineticForces/Validity/L_q" => (; long_name="safety-factor gradient scale length |q|/|dq/dr|", units="m", attach=(1 => "KineticForces/Validity/psi",)), - "KineticForces/Validity/d_separatrix" => (; long_name="distance to the separatrix ⟨r⟩(1) − ⟨r⟩(ψ)", units="m", attach=(1 => "KineticForces/Validity/psi",)), - "KineticForces/Validity/psi_c" => (; long_name="near-axis kinetic validity boundary: outermost ψ_N where a thermal orbit width reaches ⟨r⟩", units="1"), - "KineticForces/Validity/envelope" => - (; long_name="near-axis suppression envelope applied to the calculated kinetic terms (1 = unsuppressed)", units="1", attach=(1 => "KineticForces/Validity/psi",)), - "KineticForces/Validity/is_valid" => (; - long_name="1 where every zero-orbit-width ordering holds: max orbit width < ⟨r⟩, ρ_banana < L_p and L_q, max orbit width < d_separatrix", - units="1", - attach=(1 => "KineticForces/Validity/psi",) - ), - # --- ForceFreeStates/Solutions/ForwardIntegration/ --- "ForceFreeStates/Solutions/ForwardIntegration/nstep" => (; long_name="number of saved solution snapshots"), "ForceFreeStates/Solutions/ForwardIntegration/nstep_total" => (; long_name="total ODE solver steps taken"), diff --git a/src/KineticForces/Output.jl b/src/KineticForces/Output.jl index 771873251..867fafff4 100644 --- a/src/KineticForces/Output.jl +++ b/src/KineticForces/Output.jl @@ -224,5 +224,29 @@ function write_validity!(h5file::HDF5.File, kf_ctrl::KineticForcesControl, speci g["psi_c"] = psi_c g["envelope"] = psi_c > 0 ? kinetic_axis_validity_envelope.(vp.psi, psi_c) : ones(length(vp.psi)) g["is_valid"] = Int8.(vp.is_valid) + Utilities.HDF5Annotations.annotate!(g, KF_VALIDITY_H5_ANNOTATIONS) return nothing end + +# Metadata table for KineticForces/Validity/ (paths relative to the group). It lives here, not in +# HDF5Schema, because this group is written after the main writer's annotation pass has run. +const KF_VALIDITY_H5_ANNOTATIONS = [ + "psi" => (; long_name="normalized poloidal flux ψ_N of the kinetic validity profiles", scale="psi"), + "rho_i" => (; long_name="thermal ion gyroradius √(2mT)/(Z·e·B₀)", units="m", dims=("psi",), attach=(1 => "psi",)), + "rho_banana" => (; long_name="thermal banana orbit width q·ρ_i/√ε", units="m", dims=("psi",), attach=(1 => "psi",)), + "rho_theta" => (; long_name="thermal poloidal gyroradius q·ρ_i/ε", units="m", dims=("psi",), attach=(1 => "psi",)), + "w_potato" => (; long_name="potato orbit width (q²ρ_i²R₀)^(1/3)", units="m", dims=("psi",), attach=(1 => "psi",)), + "r_minor" => (; long_name="surface-average minor radius ⟨r⟩", units="m", dims=("psi",), attach=(1 => "psi",)), + "L_p" => (; long_name="pressure gradient scale length |p|/|dp/dr|", units="m", dims=("psi",), attach=(1 => "psi",)), + "L_q" => (; long_name="safety-factor gradient scale length |q|/|dq/dr|", units="m", dims=("psi",), attach=(1 => "psi",)), + "d_separatrix" => (; long_name="distance to the separatrix ⟨r⟩(1) − ⟨r⟩(ψ)", units="m", dims=("psi",), attach=(1 => "psi",)), + "psi_c" => (; long_name="near-axis kinetic validity boundary: outermost ψ_N where a thermal orbit width reaches ⟨r⟩", units="1"), + "envelope" => + (; long_name="near-axis suppression envelope applied to the calculated kinetic terms (1 = unsuppressed)", units="1", dims=("psi",), attach=(1 => "psi",)), + "is_valid" => (; + long_name="1 where every zero-orbit-width ordering holds: max orbit width < ⟨r⟩, ρ_banana < L_p and L_q, max orbit width < d_separatrix", + units="1", + dims=("psi",), + attach=(1 => "psi",) + ) +] diff --git a/src/KineticForces/Utils.jl b/src/KineticForces/Utils.jl index d2281de21..2cf25c2f5 100644 --- a/src/KineticForces/Utils.jl +++ b/src/KineticForces/Utils.jl @@ -149,7 +149,7 @@ function kinetic_axis_validity_psi(kinetic_profiles::Equilibrium.KineticProfileS end end psi_outer > 0 && @warn "kinetic_axis_validity_psi: orbit widths also reach ⟨r⟩ out to ψ=$(round(psi_outer; sigdigits=3)), " * - "outside the axis-contiguous region ending at ψ_c=$(round(psi_c; sigdigits=3)); only the latter is suppressed" + "outside the axis-contiguous region ending at ψ_c=$(round(psi_c; sigdigits=3)); only the latter is suppressed" maxlog = 1 return psi_c end @@ -267,9 +267,11 @@ reaches ⟨r⟩ the resonance is not trustworthy anyway, so the boundary is push (suppressing it wholly) rather than cutting through it. The band widens as ψ_c moves, so clearing can cascade through a rational-dense core. The move is -capped at `VALIDITY_CLEAR_MAX_FACTOR`× the orbit-width boundary; past that the uncleared boundary -is kept with a warning, since suppressing a large fraction of the plasma is worse than a band that -cuts a rational (which the band knots resolve). +capped at `VALIDITY_CLEAR_MAX_FACTOR`× the orbit-width boundary; past that the uncleared boundary is +kept with a warning. That reinstates the hazard this function exists to remove — the band knots +alone did not prevent it — so the warning says so: suppressing a large fraction of the plasma is the +worse failure, but the capped run needs `axis_validity_suppression = false` or a finer ψ grid if the +solve then produces NaNs. """ function clear_rational_windows(psi_c::Float64, rationals::Vector{Float64})::Float64 psi_c <= 0 && return psi_c @@ -281,7 +283,9 @@ function clear_rational_windows(psi_c::Float64, rationals::Vector{Float64})::Flo psi_c = maximum(inside) + Equilibrium.RATIONAL_RES_RADIUS if psi_c > limit @warn "clear_rational_windows: clearing the envelope band of rationals would push ψ_c from " * - "$(round(psi_c0; sigdigits=3)) past $(round(limit; sigdigits=3)); keeping the orbit-width boundary" + "$(round(psi_c0; sigdigits=3)) past $(round(limit; sigdigits=3)); keeping the orbit-width boundary. " * + "A rational then sits in the transition band — if the stability solve produces NaNs, set " * + "axis_validity_suppression = false or refine the ψ grid there." return psi_c0 end end diff --git a/test/runtests_kinetic_validity.jl b/test/runtests_kinetic_validity.jl index c84b1d217..5a5eac8c2 100644 --- a/test/runtests_kinetic_validity.jl +++ b/test/runtests_kinetic_validity.jl @@ -83,11 +83,10 @@ # Same profile with a narrow q spike far from the axis. The spike re-triggers the criterion, # but the model has not lost validity there, so the boundary must not jump out to it. + # The accompanying @warn carries maxlog=1, so whether it fires here depends on what ran + # earlier in the suite; the boundary itself is the contract worth asserting. q_spiked(psi) = q_smooth(psi) + 400.0 * exp(-((psi - 0.9) / 0.01)^2) - local psi_c_spiked - @test_logs (:warn,) match_mode = :any begin - psi_c_spiked = KF.kinetic_axis_validity_psi(mock_profiles(xs, Ti), mock_equil(q_spiked, r_of, R_of)) - end + psi_c_spiked = KF.kinetic_axis_validity_psi(mock_profiles(xs, Ti), mock_equil(q_spiked, r_of, R_of)) @test psi_c_spiked ≈ psi_c @test psi_c_spiked < 0.5 end