From 2d4d18c6b9b8f099371e6a0199d80c6cde6faa3c Mon Sep 17 00:00:00 2001 From: d-burg Date: Thu, 3 Sep 2026 21:57:44 -0400 Subject: [PATCH 01/20] Tearing.Dispersion - FEATURE - Add a per-surface rotation shift on the inner-layer Q argument The coupled determinant evaluated every surface's inner layer at the same lab-frame frequency, so there was no way to impose differential rotation between rational surfaces and no way to test the coupled growth rate for rotation invariance. Add a real `q_shift` to `SurfaceCoupling`, applied to the layer's Q argument in the scalar residual, the reduced m x m determinant, and the 4m x 4m Pletzer-Dewar matching. Re(Q) is the lab-frame frequency in each surface's own normalization, so the offset is real and Dopplers that surface away from the common eigenvalue; zero reproduces the previous static behaviour exactly. The runner builds the shifts from a new `[SLAYER] omega_shift_kHz` vector (per-surface lab-frame offsets in kHz, core to edge), converting with each surface's tau_k, and records the applied dRe(Q) at `PerSurface/q_shift`. On the shipped DIII-D-like deck in coupled mode, a 3 kHz shift on the 3/1 alone moves the n=1 coupled growth rate from 205.6 to 233.4 1/s (+13.5%), with omega essentially unchanged. Co-Authored-By: Claude Opus 5 (cherry picked from commit bdafb63dbe9334232bcead6357e7ed9190cb5835) --- examples/DIIID-like_SLAYER_example/gpec.toml | 7 ++ src/Tearing/Dispersion/Coupled.jl | 18 ++-- src/Tearing/Dispersion/CoupledFullMatch.jl | 5 +- src/Tearing/Dispersion/SurfaceCoupling.jl | 49 ++++++--- src/Tearing/Runner/Control.jl | 20 ++++ src/Tearing/Runner/HDF5Output.jl | 6 ++ src/Tearing/Runner/run_slayer.jl | 41 ++++++-- test/runtests.jl | 1 + test/runtests_dispersion_rotation.jl | 105 +++++++++++++++++++ 9 files changed, 221 insertions(+), 31 deletions(-) create mode 100644 test/runtests_dispersion_rotation.jl diff --git a/examples/DIIID-like_SLAYER_example/gpec.toml b/examples/DIIID-like_SLAYER_example/gpec.toml index da07c4a9a..21d65974a 100644 --- a/examples/DIIID-like_SLAYER_example/gpec.toml +++ b/examples/DIIID-like_SLAYER_example/gpec.toml @@ -75,6 +75,13 @@ mu_i = 2.0 # Ion mass in proton-mass units (2.0 = deut zeff = 1.0 # Effective charge chi_perp = 1.0 # fallback only; the kinetic file supplies χ⊥(ψ) chi_tor = 1.0 # fallback only; the kinetic file supplies χ_φ(ψ) +# Per-surface lab-frame frequency shift [kHz], core→edge (2/1, 3/1, 4/1, 5/1, +# 6/1, 7/1 on this equilibrium). Entry k Dopplers surface k's inner layer by +# ΔRe(Q_k) = τ_k·2π·1e3·Δf_k, so the surfaces no longer all corotate with the +# eigenvalue. Empty (the default) is the static case; uncomment for a rotation +# scan. τ_k here is 1.04e-4/6.25e-5/4.17e-5 s at the 2/1, 3/1, 4/1, so 1 kHz is +# ΔRe(Q) = 0.65/0.39/0.26 respectively — check roots stay inside Q_re_range. +# omega_shift_kHz = [0.0, 3.0, 0.0, 0.0, 0.0, 0.0] # per-surface lab-frame frequency shift [kHz], core→edge pole_threshold_adaptive = true # SLAYER |Δ| spans many orders; adapt to 10·median(|Δ|) filter_above_poles = true # Discard roots above the highest pole γ filter_outside_re = true # Condition the above-pole filter on the +γ step exiting the Re(Δ)=0 contour loop diff --git a/src/Tearing/Dispersion/Coupled.jl b/src/Tearing/Dispersion/Coupled.jl index 4029943b9..8ae266fdc 100644 --- a/src/Tearing/Dispersion/Coupled.jl +++ b/src/Tearing/Dispersion/Coupled.jl @@ -15,10 +15,10 @@ # det = mc(Q::ComplexF64) # # At each evaluation, for k = 1 .. msing_max, the inner-layer Δ is computed -# at a Q rescaled by `tauk_ref / tauk_k`, then subtracted (with the dc -# offset) from the diagonal of an `msing_max × msing_max` upper-left -# submatrix of `dp_matrix`. The off-diagonal Δ' couplings are passed -# through unchanged. +# at a Q rescaled by `tauk_ref / tauk_k` and offset by that surface's real +# Doppler shift `q_shift`, then subtracted (with the dc offset) from the +# diagonal of an `msing_max × msing_max` upper-left submatrix of `dp_matrix`. +# The off-diagonal Δ' couplings are passed through unchanged. """ MultiSurfaceCoupling{V<:AbstractVector{<:SurfaceCoupling}} @@ -29,11 +29,15 @@ normalization), and the truncation `msing_max` (number of surfaces actually participating in the determinant). Calling `mc(Q)` returns `det(M(Q))` where ``` -M[k,k] = dp_matrix[k,k] - scale_k · Δ_inner_k(Q · tauk_ref / tauk_k) - dc_k +M[k,k] = dp_matrix[k,k] - scale_k · Δ_inner_k(Q · tauk_ref / tauk_k + q_shift_k) - dc_k M[i,j] = dp_matrix[i,j] for i ≠ j (off-diagonal Δ' couplings) ``` A root of `mc` in the complex `Q` plane is a coupled tearing eigenvalue. + +`q_shift_k` is the real, per-surface Doppler offset carried on each +`SurfaceCoupling` (zero by default, in which case every surface sees the same +lab-frame frequency and the determinant is unchanged from the static form). """ struct MultiSurfaceCoupling{V<:AbstractVector{<:SurfaceCoupling}} surfaces::V @@ -91,7 +95,9 @@ function (mc::MultiSurfaceCoupling)(Q::Number) M = mc.dp_matrix[1:n, 1:n] @inbounds for k in 1:n sc = mc.surfaces[k] - Q_k = Qc * (ref_tauk / sc.tauk) + # Real q_shift Dopplers this surface's layer away from the common + # lab-frame frequency carried by the scanned Q. + Q_k = Qc * (ref_tauk / sc.tauk) + sc.q_shift # m×m scalar coupling: use only the tearing channel. The # interchange (Glasser-stabilization) channel is carried in the # full 4m×4m dispersion in `CoupledFullMatch.jl`; this reduced diff --git a/src/Tearing/Dispersion/CoupledFullMatch.jl b/src/Tearing/Dispersion/CoupledFullMatch.jl index 9b0f136c9..e7b6a0d21 100644 --- a/src/Tearing/Dispersion/CoupledFullMatch.jl +++ b/src/Tearing/Dispersion/CoupledFullMatch.jl @@ -165,10 +165,11 @@ function (mc::MultiSurfaceCouplingFull)(Q::Number) idx3 = idx1 + s2 # d^k_+ idx4 = idx2 + s2 # d^k_- - # Per-surface Q shift: guess_modify = Q + i·n·rotation[k]. + # Per-surface Q shift: guess_modify = Q + i·n·rotation[k], plus the + # real Doppler offset sc.q_shift carried on the SurfaceCoupling. # Also apply ref_tauk / sc.tauk rescaling (we keep the SurfaceCoupling # tauk normalization that SLAYER needs; GGJ has tauk=1 so it's a no-op). - Q_k = Qc * (ref_tauk / sc.tauk) + 1im * mc.ntor * mc.rotation[k] + Q_k = Qc * (ref_tauk / sc.tauk) + sc.q_shift + 1im * mc.ntor * mc.rotation[k] resp = solve_inner(sc.model, sc.params, Q_k; mc.inner_kwargs...) # delta1 = interchange (parity −), delta2 = tearing (parity +); named diff --git a/src/Tearing/Dispersion/SurfaceCoupling.jl b/src/Tearing/Dispersion/SurfaceCoupling.jl index 0d57207ae..a32d6d22c 100644 --- a/src/Tearing/Dispersion/SurfaceCoupling.jl +++ b/src/Tearing/Dispersion/SurfaceCoupling.jl @@ -12,6 +12,12 @@ # multi-surface `MultiSurfaceCoupling` to rescale Q between each surface's # normalization. # +# `q_shift` is a real, additive offset on the layer's Q argument. Re(Q) is the +# lab-frame mode frequency in this surface's normalization (omega = Re(Q)/tauk), +# so `q_shift = tauk * n * Omega_k` imposes the Doppler shift of a surface +# rotating at Omega_k on an otherwise common lab-frame eigenvalue. Zero +# reproduces the static, all-surfaces-corotating case. +# # Constructor convenience: `surface_coupling(model, params, dp_diag; dc=0.0)` # auto-fills `scale` and `tauk` based on the model type — `scale = S^(1/3)` # and `tauk = params.tauk` for SLAYER (de-normalizes the inner-layer Δ to @@ -21,13 +27,16 @@ """ SurfaceCoupling{M<:InnerLayerModel, P} -Per-surface dispersion data: `(model, params, dp_diag, dc, scale, tauk)`. -Calling `sc(Q)` returns the complex residual +Per-surface dispersion data: `(model, params, dp_diag, dc, scale, tauk, +q_shift)`. Calling `sc(Q)` returns the complex residual ``` -r(Q) = dp_diag - scale * solve_inner(model, params, Q).tearing - dc +r(Q) = dp_diag - scale * solve_inner(model, params, Q + q_shift).tearing - dc ``` +`q_shift` is the real Doppler offset described in the file header; it defaults +to zero, which leaves the residual identical to the pre-rotation form. + A root of `sc` in the complex `Q` plane is a **tearing** eigenvalue at this surface in the *uncoupled* approximation (only the tearing channel of the inner-layer response appears — the interchange channel enters the @@ -42,16 +51,18 @@ struct SurfaceCoupling{M<:InnerLayerModel,P} dc::Float64 scale::Float64 tauk::Float64 + q_shift::Float64 end function (sc::SurfaceCoupling)(Q::Number) - Δ = solve_inner(sc.model, sc.params, ComplexF64(Q)).tearing + Δ = solve_inner(sc.model, sc.params, ComplexF64(Q) + sc.q_shift).tearing return sc.dp_diag - sc.scale * Δ - sc.dc end """ surface_coupling(model::SLAYERModel, params::SLAYERParameters, - dp_diag::Number; dc::Real=0.0) -> SurfaceCoupling + dp_diag::Number; dc::Real=0.0, q_shift::Real=0.0) + -> SurfaceCoupling 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 @@ -60,17 +71,18 @@ 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. +`MultiSurfaceCoupling` Q rescaling. `q_shift` is the real Doppler offset on the +layer's Q argument (`tauk * n * Omega`); it defaults to zero. """ function surface_coupling(model::SLAYERModel, params::SLAYERParameters, - dp_diag::Number; dc::Real=0.0) + dp_diag::Number; dc::Real=0.0, q_shift::Real=0.0) return SurfaceCoupling(model, params, ComplexF64(dp_diag), - Float64(dc), params.lu^(1 / 3), params.tauk) + Float64(dc), params.lu^(1 / 3), params.tauk, Float64(q_shift)) end """ surface_coupling(model::GGJModel, params::GGJParameters, - dp_diag::Number) -> SurfaceCoupling + dp_diag::Number; q_shift::Real=0.0) -> SurfaceCoupling GGJ convenience constructor. `scale` is `1.0` because GGJ's `solve_inner` applies its own `rescale_delta` (S^(2p₁/3)·v1^(2p₁)) internally, so the @@ -84,24 +96,27 @@ natively. A Δ_crit proxy (χ_parallel-matching offset on the diagonal) is meaningful only for tearing-only slab-layer approximations like SLAYER; for GGJ it would double-count the interchange physics. The `SurfaceCoupling` struct's `dc` field is hard-wired to 0 here. + +`q_shift` is the real Doppler offset on the layer's Q argument. GGJ carries +`tauk = 1`, so the caller must supply the shift already in GGJ's own Q units. """ function surface_coupling(model::GGJModel, params::GGJParameters, - dp_diag::Number) + dp_diag::Number; q_shift::Real=0.0) return SurfaceCoupling(model, params, ComplexF64(dp_diag), - 0.0, 1.0, 1.0) + 0.0, 1.0, 1.0, Float64(q_shift)) end """ surface_coupling(model::InnerLayerModel, params, dp_diag::Number; - dc::Real=0.0, scale::Real=1.0, tauk::Real=1.0) - -> SurfaceCoupling + dc::Real=0.0, scale::Real=1.0, tauk::Real=1.0, + q_shift::Real=0.0) -> SurfaceCoupling Generic fallback constructor. Use this when wiring a new inner-layer model -into the dispersion solver — pass the appropriate inner→outer-units `scale` -and per-surface `tauk` explicitly. +into the dispersion solver — pass the appropriate inner→outer-units `scale`, +per-surface `tauk`, and (for a rotating surface) `q_shift` explicitly. """ function surface_coupling(model::InnerLayerModel, params, dp_diag::Number; - dc::Real=0.0, scale::Real=1.0, tauk::Real=1.0) + dc::Real=0.0, scale::Real=1.0, tauk::Real=1.0, q_shift::Real=0.0) return SurfaceCoupling(model, params, ComplexF64(dp_diag), - Float64(dc), Float64(scale), Float64(tauk)) + Float64(dc), Float64(scale), Float64(tauk), Float64(q_shift)) end diff --git a/src/Tearing/Runner/Control.jl b/src/Tearing/Runner/Control.jl index 1a12ff7e6..98c97ed34 100644 --- a/src/Tearing/Runner/Control.jl +++ b/src/Tearing/Runner/Control.jl @@ -32,6 +32,12 @@ constructor. - `bt` -- toroidal field `[T]`. `nothing` (default) resolves the physical `B_T = F(ψ)/(2π·R₀)` per surface from the equilibrium's F-spline; a scalar or a callable of `psi` overrides it + - `omega_shift_kHz` -- per-surface lab-frame frequency shift in kHz, + ordered core→edge. Entry `k` Dopplers surface `k`'s inner layer by + `ΔRe(Q_k) = tauk_k · 2π · 1e3 · omega_shift_kHz[k]`, modelling a surface + rotating relative to the common lab-frame eigenvalue. Empty (default) + means no shift on any surface. Must be empty or have one entry per + rational surface actually analysed - `mu_i` -- ion mass in proton-mass units (default 2.0 for D) - `zeff` -- effective charge - `chi_perp`, `chi_tor` -- fallback perpendicular / toroidal heat @@ -151,6 +157,15 @@ there is one consistent interface for resistive and kinetic profiles. # / failed-Δ'-BVP surface, not a real root. Flagged `:spurious`. validity_rtol::Float64 = 1e-3 + # Per-surface lab-frame frequency shift [kHz], ordered core→edge to match + # the rational-surface list. Entry k Dopplers surface k's inner layer by + # ΔRe(Q_k) = tauk_k · 2π · 1e3 · omega_shift_kHz[k], so a common + # lab-frame eigenvalue is seen at a different frequency by each rotating + # surface. Empty (the default) means no shift anywhere — the static, + # all-surfaces-corotating case. A shorter-than-msing vector is an error; + # pad with zeros to shift only some surfaces. + omega_shift_kHz::Vector{Float64} = Float64[] + profile_file::String = "" profile_group::String = "/" @@ -185,6 +200,9 @@ function validate(ctrl::SLAYERControl) "not in $(_VALID_LNLAMBDA_FORMS)")) ctrl.msing_max >= 1 || throw(ArgumentError("SLAYERControl: msing_max=$(ctrl.msing_max) must be ≥ 1")) + all(isfinite, ctrl.omega_shift_kHz) || + throw(ArgumentError("SLAYERControl: omega_shift_kHz contains a " * + "non-finite entry: $(ctrl.omega_shift_kHz)")) ctrl.nre >= 2 && ctrl.nim >= 2 || throw(ArgumentError("SLAYERControl: nre and nim must both be ≥ 2")) ctrl.amr_passes >= 0 || @@ -249,6 +267,8 @@ function slayer_control_from_toml(section::AbstractDict) elseif sym in (:bt, :dr_val, :dgeo_val) # Allow explicit nothing (auto-derive) or a number (override) kwargs[sym] = v === nothing ? nothing : Float64(v) + elseif sym === :omega_shift_kHz + kwargs[sym] = Float64[Float64(x) for x in v] elseif sym === :boxes # `boxes` is a Vector{NTuple{4,Float64}}; from TOML this comes # in as a list of 4-element arrays. Coerce each. diff --git a/src/Tearing/Runner/HDF5Output.jl b/src/Tearing/Runner/HDF5Output.jl index b0fc8d2e4..5139f774e 100644 --- a/src/Tearing/Runner/HDF5Output.jl +++ b/src/Tearing/Runner/HDF5Output.jl @@ -44,6 +44,10 @@ function write_slayer_hdf5!(parent::Union{HDF5.File,HDF5.Group}, end _write_per_surface!(g, result.params, result.dp_matrix) + # Applied per-surface rotation Doppler in Q units (all-zero unless + # control.omega_shift_kHz was set); derived, so it belongs here rather than + # under Input/ where the raw kHz values are echoed. + g["PerSurface/q_shift"] = _q_shifts(result.control, result.params, length(result.params)) # Surface identity (absent when the analysis was built from bare parameters). isempty(result.rational_psi) || (g["PerSurface/rational_psi"] = result.rational_psi) isempty(result.rational_q) || (g["PerSurface/rational_q"] = result.rational_q) @@ -82,6 +86,8 @@ const TEARING_H5_ANNOTATIONS = [ "PerSurface/iota_e" => (; long_name="electron fraction ι_e = Q_e/(Q_e − Q_i) per surface", dims=("surface",)), "PerSurface/tau_k" => (; long_name="Q-normalization time S^(1/3)·τ_H per surface (Q = τ_k·ω; diamagnetic inputs Q_e, Q_i carry the opposite sign by convention)", units="s", dims=("surface",)), + "PerSurface/q_shift" => + (; long_name="real Doppler offset ΔRe(Q) = τ_k·2π·1e3·omega_shift_kHz on each surface's inner-layer Q (0 = corotates with the eigenvalue)", dims=("surface",)), "PerSurface/tau_R" => (; long_name="resistive diffusion time τ_R = μ₀r_s²/η per surface", units="s", dims=("surface",)), "PerSurface/Delta_prime_norm" => (; long_name="Δ'-normalization factor S^(1/3)/r_s per surface", units="1/m", dims=("surface",)), "PerSurface/rs" => (; long_name="minor radius of each rational surface", units="m", dims=("surface",)), diff --git a/src/Tearing/Runner/run_slayer.jl b/src/Tearing/Runner/run_slayer.jl index 2459087cf..c6bb3d12e 100644 --- a/src/Tearing/Runner/run_slayer.jl +++ b/src/Tearing/Runner/run_slayer.jl @@ -139,18 +139,39 @@ end # --------------------------------------------------------------------- # SLAYER: scale = lu^(1/3), tauk from the surface, dc from the χ‖ proxy. function _build_surface_coupling(model::SLAYERModel, params::SLAYERParameters, - dp_diag) - return surface_coupling(model, params, dp_diag; dc=params.dc_tmp) + dp_diag, q_shift::Real=0.0) + return surface_coupling(model, params, dp_diag; dc=params.dc_tmp, q_shift=q_shift) end # GGJ: scale = 1.0 (rescale_delta applied inside solve_inner), tauk = 1.0, # dc = 0 (the 4m×4m Pletzer-Dewar residual carries interchange stabilization # natively). See the GGJ `surface_coupling` method. function _build_surface_coupling(model::GGJModel, params::GGJParameters, - dp_diag) - return surface_coupling(model, params, dp_diag) + dp_diag, q_shift::Real=0.0) + return surface_coupling(model, params, dp_diag; q_shift=q_shift) end +# Convert `control.omega_shift_kHz` (per-surface lab-frame frequency offsets in +# kHz, core→edge) into the real Q-space offsets the inner layers consume. Re(Q) +# at surface k is that surface's frequency scaled by its own `tauk`, so a +# lab-frame Δf maps to ΔRe(Q_k) = tauk_k · 2π · 1e3 · Δf_k. Parameters without a +# `tauk` (GGJ) carry no time normalization, so their entry is taken as already +# dimensionless in GGJ's own Q units. +function _q_shifts(control::SLAYERControl, params, n::Integer) + shifts = control.omega_shift_kHz + isempty(shifts) && return zeros(Float64, n) + length(shifts) == n || + throw(ArgumentError("run_slayer: omega_shift_kHz has $(length(shifts)) " * + "entries but $n rational surfaces were analysed. " * + "Supply one entry per surface (pad with zeros to " * + "shift only some), or leave it empty for no shift.")) + return Float64[ + (hasproperty(params[k], :tauk) ? params[k].tauk : 1.0) * 2π * 1e3 * shifts[k] + for k in 1:n + ] +end + + # --------------------------------------------------------------------- # Reference-length conversion of the outer Δ' for the slab layer # --------------------------------------------------------------------- @@ -272,8 +293,16 @@ function run_slayer_from_inputs(params::AbstractVector{<:InnerLayerParameters}, ) end - # Per-surface SurfaceCoupling objects - scs = [_build_surface_coupling(model, params[k], dp[k, k]) for k in 1:n] + # Per-surface Doppler shift in Q units. `omega_shift_kHz[k]` is a + # lab-frame frequency offset in kHz; Re(Q) is that surface's frequency + # times its own tauk, so the Q-space offset is tauk_k · 2π · 1e3 · Δf_k. + # GGJ carries tauk = 1, so its shift is taken as already dimensionless. + q_shifts = _q_shifts(control, params, n) + scs = [_build_surface_coupling(model, params[k], dp[k, k], q_shifts[k]) for k in 1:n] + any(!iszero, q_shifts) && @info( + "SLAYER: applying per-surface rotation shift " * + "omega_shift_kHz=$(control.omega_shift_kHz) kHz " * + "→ ΔRe(Q)=$(round.(q_shifts; digits=4))") # Per-surface resistive layer thickness [m] via the del_s Riccati solve. # Independent of the dispersion scan / coupling mode — a pure diagnostic. diff --git a/test/runtests.jl b/test/runtests.jl index 7845eea60..c5451b585 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -43,6 +43,7 @@ else include("./runtests_dispersion_residual.jl") include("./runtests_dispersion_coupled.jl") include("./runtests_dispersion_coupled_full.jl") + include("./runtests_dispersion_rotation.jl") include("./runtests_dispersion_scan.jl") include("./runtests_dispersion_amr.jl") include("./runtests_dispersion_polish.jl") diff --git a/test/runtests_dispersion_rotation.jl b/test/runtests_dispersion_rotation.jl new file mode 100644 index 000000000..6651f257e --- /dev/null +++ b/test/runtests_dispersion_rotation.jl @@ -0,0 +1,105 @@ +@testset "Dispersion per-surface rotation shift" begin + using GeneralizedPerturbedEquilibrium.InnerLayer + using GeneralizedPerturbedEquilibrium.InnerLayer: InnerLayerModel, solve_inner + using GeneralizedPerturbedEquilibrium.Dispersion + using GeneralizedPerturbedEquilibrium.Tearing.Runner: SLAYERControl, + slayer_control_from_toml, validate, _q_shifts + using LinearAlgebra + + # Linear inner layer Δ(Q) = a + b·Q makes the applied Q offset readable + # straight off the residual. + struct RotTestModel <: InnerLayerModel + a::ComplexF64 + b::ComplexF64 + end + GeneralizedPerturbedEquilibrium.InnerLayer.solve_inner( + m::RotTestModel, params, Q::Number) = + InnerLayerResponse(m.a + m.b * ComplexF64(Q), zero(ComplexF64)) + + model = RotTestModel(0.0im, 1.0 + 0im) + + @testset "q_shift defaults to zero and is inert" begin + sc = surface_coupling(model, nothing, 1.0 + 0im; scale=1.0, tauk=1.0) + @test sc.q_shift == 0.0 + # Unshifted residual is dp_diag - Δ(Q) = 1 - Q + @test sc(2.0 + 0im) ≈ (1.0 - 2.0) + 0im + end + + @testset "q_shift offsets the layer Q argument on the scalar residual" begin + shift = 0.75 + sc0 = surface_coupling(model, nothing, 1.0 + 0im; scale=1.0, tauk=1.0) + scs = surface_coupling(model, nothing, 1.0 + 0im; scale=1.0, tauk=1.0, + q_shift=shift) + @test scs.q_shift == shift + for Q in (0.0 + 0im, 2.0 - 1.0im, -3.5 + 0.25im) + @test scs(Q) ≈ sc0(Q + shift) + end + end + + @testset "q_shift is real: it moves Re(Q), not Im(Q)" begin + # The shift models rotation, so it must not leak into the growth-rate + # axis. Δ(Q) = Q here, so the residual difference IS the applied offset. + sc0 = surface_coupling(model, nothing, 0.0 + 0im; scale=1.0, tauk=1.0) + scs = surface_coupling(model, nothing, 0.0 + 0im; scale=1.0, tauk=1.0, + q_shift=2.0) + applied = sc0(1.0 + 1.0im) - scs(1.0 + 1.0im) # = Δ(Q+s) - Δ(Q) = s + @test real(applied) ≈ 2.0 + @test imag(applied) ≈ 0.0 atol = 1e-14 + end + + @testset "Coupled determinant applies each surface's own shift" begin + # Diagonal Δ', so det = Π_k (dp_kk - Δ_k(Q·tauk_ref/tauk_k + shift_k)). + s1, s2 = 0.5, -1.25 + sc1 = surface_coupling(model, nothing, 1.0 + 0im; scale=1.0, tauk=1.0, + q_shift=s1) + sc2 = surface_coupling(model, nothing, 2.0 + 0im; scale=1.0, tauk=2.0, + q_shift=s2) + dp = ComplexF64[1.0 0.0; 0.0 2.0] + mc = multi_surface_coupling([sc1, sc2], dp) + + Q = 1.5 + 0.5im + expected = (1.0 - (Q * (1.0 / 1.0) + s1)) * (2.0 - (Q * (1.0 / 2.0) + s2)) + @test mc(Q) ≈ expected + + # Zero shifts reproduce the pre-rotation determinant exactly. + sc1z = surface_coupling(model, nothing, 1.0 + 0im; scale=1.0, tauk=1.0) + sc2z = surface_coupling(model, nothing, 2.0 + 0im; scale=1.0, tauk=2.0) + mcz = multi_surface_coupling([sc1z, sc2z], dp) + @test mcz(Q) ≈ (1.0 - Q) * (2.0 - Q / 2) + end + + @testset "_q_shifts converts kHz to Q units via tauk" begin + p1 = 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, + 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) + p2 = 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, + qval=3.0, sval_r=1.5, bt=2.0, rs=0.6, R0=1.7, + mu_i=2.0, zeff=1.0, chi_perp=1.0, chi_tor=1.0, m=3, n=1) + params = [p1, p2] + + # Empty (default) means no shift anywhere. + @test _q_shifts(SLAYERControl(), params, 2) == [0.0, 0.0] + + ctrl = SLAYERControl(; omega_shift_kHz=[1.0, -2.0]) + got = _q_shifts(ctrl, params, 2) + @test got[1] ≈ p1.tauk * 2π * 1e3 * 1.0 + @test got[2] ≈ p2.tauk * 2π * 1e3 * -2.0 + + # Length must match the number of surfaces analysed. + @test_throws ArgumentError _q_shifts(SLAYERControl(; omega_shift_kHz=[1.0]), + params, 2) + end + + @testset "omega_shift_kHz parses and validates from TOML" begin + ctrl = slayer_control_from_toml(Dict("omega_shift_kHz" => [0, 3.0, -1.5])) + @test ctrl.omega_shift_kHz == [0.0, 3.0, -1.5] + @test eltype(ctrl.omega_shift_kHz) === Float64 + # Default stays empty so existing decks are untouched. + @test isempty(slayer_control_from_toml(Dict{String,Any}()).omega_shift_kHz) + # A non-finite shift would silently poison every Q evaluation; the + # validator rejects it (TOML cannot express NaN, so go through validate). + @test_throws ArgumentError validate(SLAYERControl(; omega_shift_kHz=[NaN])) + end +end From 291701c7f5fac15b6945c4923b41f742ddbd58aa Mon Sep 17 00:00:00 2001 From: d-burg Date: Thu, 3 Sep 2026 22:11:33 -0400 Subject: [PATCH 02/20] Tearing - BUGFIX! - Align the rotation shift sign with TJ and offer the direct tauk rescale Verified the layer normalization against Fitzpatrick's TJ (Documentation/Layer.tex and Layer/Layer.cpp). TJ solves for the layer eigenvalue as ghat = i(Q_E - omega*tau_k) with Q_E = tau_k*omega_E, and reports omega = (Q_E - Im(ghat))/tau_k. Our Riccati bridge already documents ghat = -i*Q, so Q_k = tau_k*(omega - omega_E_k). Two consequences: Rename `omega_shift_kHz` to `omega_E_kHz` and negate the conversion, so the input is the surface's E*B rotation frequency and the layer responds to the mode frequency in its own fluid frame, as in TJ. The knob was new in the previous commit, so nothing downstream depended on the old spelling. Add `tauk_rescale`, selecting the inter-surface Q normalization used by the coupled determinant. The default `:legacy` keeps Q*tauk_ref/tauk_k, which every coupled growth rate produced by this code so far has used. `:direct` uses Q*tauk_k/tauk_ref, the direction implied both by our own extraction convention omega = Re(Q)/tauk and by TJ. The two differ by (tauk_k/tauk_ref)^2 and move results, so the correction is opt-in rather than applied silently. Co-Authored-By: Claude Opus 5 (cherry picked from commit d1c4e6766ffe958a548a61deb47627728a25588a) --- examples/DIIID-like_SLAYER_example/gpec.toml | 15 +++--- src/Tearing/Dispersion/Coupled.jl | 25 ++++++++-- src/Tearing/Dispersion/SurfaceCoupling.jl | 12 +++-- src/Tearing/Runner/Control.jl | 50 ++++++++++++-------- src/Tearing/Runner/HDF5Output.jl | 4 +- src/Tearing/Runner/run_slayer.jl | 33 +++++++------ test/runtests_dispersion_rotation.jl | 42 ++++++++++++---- 7 files changed, 116 insertions(+), 65 deletions(-) diff --git a/examples/DIIID-like_SLAYER_example/gpec.toml b/examples/DIIID-like_SLAYER_example/gpec.toml index 21d65974a..196ca2a3b 100644 --- a/examples/DIIID-like_SLAYER_example/gpec.toml +++ b/examples/DIIID-like_SLAYER_example/gpec.toml @@ -75,13 +75,14 @@ mu_i = 2.0 # Ion mass in proton-mass units (2.0 = deut zeff = 1.0 # Effective charge chi_perp = 1.0 # fallback only; the kinetic file supplies χ⊥(ψ) chi_tor = 1.0 # fallback only; the kinetic file supplies χ_φ(ψ) -# Per-surface lab-frame frequency shift [kHz], core→edge (2/1, 3/1, 4/1, 5/1, -# 6/1, 7/1 on this equilibrium). Entry k Dopplers surface k's inner layer by -# ΔRe(Q_k) = τ_k·2π·1e3·Δf_k, so the surfaces no longer all corotate with the -# eigenvalue. Empty (the default) is the static case; uncomment for a rotation -# scan. τ_k here is 1.04e-4/6.25e-5/4.17e-5 s at the 2/1, 3/1, 4/1, so 1 kHz is -# ΔRe(Q) = 0.65/0.39/0.26 respectively — check roots stay inside Q_re_range. -# omega_shift_kHz = [0.0, 3.0, 0.0, 0.0, 0.0, 0.0] # per-surface lab-frame frequency shift [kHz], core→edge +# Per-surface E×B rotation frequency [kHz], core→edge (2/1, 3/1, 4/1, 5/1, 6/1, +# 7/1 on this equilibrium). Entry k shifts surface k's inner layer by +# ΔRe(Q_k) = -τ_k·2π·1e3·f_E, so each layer responds in its own fluid frame. +# Empty (the default) is the static case; uncomment for a rotation scan. τ_k +# here is 6.63e-5/4.01e-5/2.68e-5 s at the 2/1, 3/1, 4/1, so 1 kHz is +# ΔRe(Q) = -0.42/-0.25/-0.17 — check roots stay inside Q_re_range. +# omega_E_kHz = [0.0, 3.0, 0.0, 0.0, 0.0, 0.0] # per-surface E×B rotation frequency [kHz], core→edge +# tauk_rescale = "legacy" # coupled inter-surface Q normalization: "legacy" or "direct" (TJ-consistent) pole_threshold_adaptive = true # SLAYER |Δ| spans many orders; adapt to 10·median(|Δ|) filter_above_poles = true # Discard roots above the highest pole γ filter_outside_re = true # Condition the above-pole filter on the +γ step exiting the Re(Δ)=0 contour loop diff --git a/src/Tearing/Dispersion/Coupled.jl b/src/Tearing/Dispersion/Coupled.jl index 8ae266fdc..5a3ca424a 100644 --- a/src/Tearing/Dispersion/Coupled.jl +++ b/src/Tearing/Dispersion/Coupled.jl @@ -29,13 +29,21 @@ normalization), and the truncation `msing_max` (number of surfaces actually participating in the determinant). Calling `mc(Q)` returns `det(M(Q))` where ``` -M[k,k] = dp_matrix[k,k] - scale_k · Δ_inner_k(Q · tauk_ref / tauk_k + q_shift_k) - dc_k +M[k,k] = dp_matrix[k,k] - scale_k · Δ_inner_k(Q · ratio_k + q_shift_k) - dc_k M[i,j] = dp_matrix[i,j] for i ≠ j (off-diagonal Δ' couplings) ``` A root of `mc` in the complex `Q` plane is a coupled tearing eigenvalue. -`q_shift_k` is the real, per-surface Doppler offset carried on each +`ratio_k` is `tauk_ref/tauk_k` under the default `tauk_rescale=:legacy` and +`tauk_k/tauk_ref` under `:direct`. `:direct` is the direction implied by the +extraction convention `ω = Re(Q)/tauk` (so `Q_k = tauk_k·ω` for one shared +physical ω) and by Fitzpatrick's TJ, where the layer eigenvalue enters as +`ĝ = i(Q_E − ω·tau_k)`. `:legacy` is retained as the default because every +published coupled growth rate from this code was produced with it; switching +moves results and is an explicit opt-in. + +`q_shift_k` is the real, per-surface E×B Doppler offset carried on each `SurfaceCoupling` (zero by default, in which case every surface sees the same lab-frame frequency and the determinant is unchanged from the static form). """ @@ -44,6 +52,7 @@ struct MultiSurfaceCoupling{V<:AbstractVector{<:SurfaceCoupling}} dp_matrix::Matrix{ComplexF64} ref_idx::Int msing_max::Int + tauk_rescale::Symbol end """ @@ -71,8 +80,12 @@ length `length(surfaces)` (it is the same matrix returned by function multi_surface_coupling(surfaces::AbstractVector{<:SurfaceCoupling}, dp_matrix::AbstractMatrix; ref_idx::Integer=1, - msing_max::Integer=min(3, length(surfaces))) + msing_max::Integer=min(3, length(surfaces)), + tauk_rescale::Symbol=:legacy) n = length(surfaces) + tauk_rescale in (:legacy, :direct) || + throw(ArgumentError("multi_surface_coupling: tauk_rescale=" * + "$tauk_rescale must be :legacy or :direct")) size(dp_matrix) == (n, n) || throw(ArgumentError("multi_surface_coupling: dp_matrix size " * "$(size(dp_matrix)) ≠ ($n, $n)")) @@ -84,7 +97,7 @@ function multi_surface_coupling(surfaces::AbstractVector{<:SurfaceCoupling}, "out of range 1:$n")) return MultiSurfaceCoupling(surfaces, Matrix{ComplexF64}(dp_matrix), - Int(ref_idx), Int(msing_max)) + Int(ref_idx), Int(msing_max), tauk_rescale) end function (mc::MultiSurfaceCoupling)(Q::Number) @@ -97,7 +110,9 @@ function (mc::MultiSurfaceCoupling)(Q::Number) sc = mc.surfaces[k] # Real q_shift Dopplers this surface's layer away from the common # lab-frame frequency carried by the scanned Q. - Q_k = Qc * (ref_tauk / sc.tauk) + sc.q_shift + ratio = mc.tauk_rescale === :direct ? (sc.tauk / ref_tauk) : + (ref_tauk / sc.tauk) + Q_k = Qc * ratio + sc.q_shift # m×m scalar coupling: use only the tearing channel. The # interchange (Glasser-stabilization) channel is carried in the # full 4m×4m dispersion in `CoupledFullMatch.jl`; this reduced diff --git a/src/Tearing/Dispersion/SurfaceCoupling.jl b/src/Tearing/Dispersion/SurfaceCoupling.jl index a32d6d22c..f626f59ca 100644 --- a/src/Tearing/Dispersion/SurfaceCoupling.jl +++ b/src/Tearing/Dispersion/SurfaceCoupling.jl @@ -12,11 +12,13 @@ # multi-surface `MultiSurfaceCoupling` to rescale Q between each surface's # normalization. # -# `q_shift` is a real, additive offset on the layer's Q argument. Re(Q) is the -# lab-frame mode frequency in this surface's normalization (omega = Re(Q)/tauk), -# so `q_shift = tauk * n * Omega_k` imposes the Doppler shift of a surface -# rotating at Omega_k on an otherwise common lab-frame eigenvalue. Zero -# reproduces the static, all-surfaces-corotating case. +# `q_shift` is a real, additive offset on the layer's Q argument, carrying the +# E×B rotation of this surface. Fitzpatrick's TJ writes the layer eigenvalue as +# `ĝ = i(Q_E − ω·tau_k)` with `Q_E = tau_k·omega_E` (TJ Documentation/Layer.tex, +# "Calculation of Growth-Rate and Real Frequency"); with `ĝ = −i·Q` this is +# `Q_k = tau_k·(omega − omega_E_k)`, i.e. the layer sees the mode frequency in +# its own fluid frame. So `q_shift = −tau_k·omega_E_k`, and zero reproduces the +# static, all-surfaces-corotating case. # # Constructor convenience: `surface_coupling(model, params, dp_diag; dc=0.0)` # auto-fills `scale` and `tauk` based on the model type — `scale = S^(1/3)` diff --git a/src/Tearing/Runner/Control.jl b/src/Tearing/Runner/Control.jl index 98c97ed34..7e0b37cbb 100644 --- a/src/Tearing/Runner/Control.jl +++ b/src/Tearing/Runner/Control.jl @@ -32,12 +32,16 @@ constructor. - `bt` -- toroidal field `[T]`. `nothing` (default) resolves the physical `B_T = F(ψ)/(2π·R₀)` per surface from the equilibrium's F-spline; a scalar or a callable of `psi` overrides it - - `omega_shift_kHz` -- per-surface lab-frame frequency shift in kHz, - ordered core→edge. Entry `k` Dopplers surface `k`'s inner layer by - `ΔRe(Q_k) = tauk_k · 2π · 1e3 · omega_shift_kHz[k]`, modelling a surface - rotating relative to the common lab-frame eigenvalue. Empty (default) - means no shift on any surface. Must be empty or have one entry per - rational surface actually analysed + - `omega_E_kHz` -- per-surface E×B rotation frequency in kHz, ordered + core→edge. Entry `k` shifts surface `k`'s inner-layer Q argument by + `ΔRe(Q_k) = −tauk_k · 2π · 1e3 · omega_E_kHz[k]`, so that layer responds to + the mode frequency in its own fluid frame (TJ's `ĝ = i(Q_E − ω·tau_k)`). + Empty (default) means no rotation on any surface. Must be empty or have + one entry per rational surface actually analysed + - `tauk_rescale` -- direction of the inter-surface Q normalization in the + coupled determinant: `:legacy` (default, `Q·tauk_ref/tauk_k`) or `:direct` + (`Q·tauk_k/tauk_ref`, consistent with `ω = Re(Q)/tauk` and with TJ). + `:direct` moves every coupled growth rate, so it is opt-in - `mu_i` -- ion mass in proton-mass units (default 2.0 for D) - `zeff` -- effective charge - `chi_perp`, `chi_tor` -- fallback perpendicular / toroidal heat @@ -157,14 +161,18 @@ there is one consistent interface for resistive and kinetic profiles. # / failed-Δ'-BVP surface, not a real root. Flagged `:spurious`. validity_rtol::Float64 = 1e-3 - # Per-surface lab-frame frequency shift [kHz], ordered core→edge to match - # the rational-surface list. Entry k Dopplers surface k's inner layer by - # ΔRe(Q_k) = tauk_k · 2π · 1e3 · omega_shift_kHz[k], so a common - # lab-frame eigenvalue is seen at a different frequency by each rotating - # surface. Empty (the default) means no shift anywhere — the static, - # all-surfaces-corotating case. A shorter-than-msing vector is an error; - # pad with zeros to shift only some surfaces. - omega_shift_kHz::Vector{Float64} = Float64[] + # Per-surface E×B rotation frequency [kHz], ordered core→edge to match the + # rational-surface list. Entry k shifts surface k's inner layer by + # ΔRe(Q_k) = −tauk_k · 2π · 1e3 · omega_E_kHz[k], so each rotating surface + # sees the common lab-frame eigenvalue in its own fluid frame. Empty (the + # default) means no rotation anywhere. A shorter-than-msing vector is an + # error; pad with zeros to leave some surfaces static. + omega_E_kHz::Vector{Float64} = Float64[] + + # Inter-surface Q normalization in the coupled determinant: :legacy keeps + # Q·tauk_ref/tauk_k (every published result to date); :direct uses + # Q·tauk_k/tauk_ref, the direction implied by ω = Re(Q)/tauk and by TJ. + tauk_rescale::Symbol = :legacy profile_file::String = "" profile_group::String = "/" @@ -178,6 +186,7 @@ const _VALID_COUPLING_MODES = (:uncoupled, :coupled) const _VALID_DC_TYPES = (:none, :lar, :rfitzp, :toroidal) const _VALID_RESISTIVITY_MODELS = (:sauter, :redl, :spitzer, :spitzer_harm) const _VALID_LNLAMBDA_FORMS = (:nrl, :sauter, :wesson) +const _VALID_TAUK_RESCALE = (:legacy, :direct) function validate(ctrl::SLAYERControl) ctrl.inner_model in _VALID_INNER_MODELS || @@ -200,9 +209,12 @@ function validate(ctrl::SLAYERControl) "not in $(_VALID_LNLAMBDA_FORMS)")) ctrl.msing_max >= 1 || throw(ArgumentError("SLAYERControl: msing_max=$(ctrl.msing_max) must be ≥ 1")) - all(isfinite, ctrl.omega_shift_kHz) || - throw(ArgumentError("SLAYERControl: omega_shift_kHz contains a " * - "non-finite entry: $(ctrl.omega_shift_kHz)")) + all(isfinite, ctrl.omega_E_kHz) || + throw(ArgumentError("SLAYERControl: omega_E_kHz contains a " * + "non-finite entry: $(ctrl.omega_E_kHz)")) + ctrl.tauk_rescale in _VALID_TAUK_RESCALE || + throw(ArgumentError("SLAYERControl: tauk_rescale=$(ctrl.tauk_rescale) " * + "must be one of $(_VALID_TAUK_RESCALE)")) ctrl.nre >= 2 && ctrl.nim >= 2 || throw(ArgumentError("SLAYERControl: nre and nim must both be ≥ 2")) ctrl.amr_passes >= 0 || @@ -260,14 +272,14 @@ function slayer_control_from_toml(section::AbstractDict) for (k, v) in flat sym = Symbol(k) if sym in (:inner_model, :scan_mode, :coupling_mode, :dc_type, - :resistivity_model, :lnLambda_form) + :resistivity_model, :lnLambda_form, :tauk_rescale) kwargs[sym] = v isa Symbol ? v : Symbol(String(v)) elseif sym in (:Q_re_range, :Q_im_range) kwargs[sym] = _as_range(v) elseif sym in (:bt, :dr_val, :dgeo_val) # Allow explicit nothing (auto-derive) or a number (override) kwargs[sym] = v === nothing ? nothing : Float64(v) - elseif sym === :omega_shift_kHz + elseif sym === :omega_E_kHz kwargs[sym] = Float64[Float64(x) for x in v] elseif sym === :boxes # `boxes` is a Vector{NTuple{4,Float64}}; from TOML this comes diff --git a/src/Tearing/Runner/HDF5Output.jl b/src/Tearing/Runner/HDF5Output.jl index 5139f774e..7c16b4b2e 100644 --- a/src/Tearing/Runner/HDF5Output.jl +++ b/src/Tearing/Runner/HDF5Output.jl @@ -45,7 +45,7 @@ function write_slayer_hdf5!(parent::Union{HDF5.File,HDF5.Group}, _write_per_surface!(g, result.params, result.dp_matrix) # Applied per-surface rotation Doppler in Q units (all-zero unless - # control.omega_shift_kHz was set); derived, so it belongs here rather than + # control.omega_E_kHz was set); derived, so it belongs here rather than # under Input/ where the raw kHz values are echoed. g["PerSurface/q_shift"] = _q_shifts(result.control, result.params, length(result.params)) # Surface identity (absent when the analysis was built from bare parameters). @@ -87,7 +87,7 @@ const TEARING_H5_ANNOTATIONS = [ "PerSurface/tau_k" => (; long_name="Q-normalization time S^(1/3)·τ_H per surface (Q = τ_k·ω; diamagnetic inputs Q_e, Q_i carry the opposite sign by convention)", units="s", dims=("surface",)), "PerSurface/q_shift" => - (; long_name="real Doppler offset ΔRe(Q) = τ_k·2π·1e3·omega_shift_kHz on each surface's inner-layer Q (0 = corotates with the eigenvalue)", dims=("surface",)), + (; long_name="real E×B Doppler offset ΔRe(Q) = −τ_k·2π·1e3·omega_E_kHz on each surface's inner-layer Q (0 = corotates with the eigenvalue)", dims=("surface",)), "PerSurface/tau_R" => (; long_name="resistive diffusion time τ_R = μ₀r_s²/η per surface", units="s", dims=("surface",)), "PerSurface/Delta_prime_norm" => (; long_name="Δ'-normalization factor S^(1/3)/r_s per surface", units="1/m", dims=("surface",)), "PerSurface/rs" => (; long_name="minor radius of each rational surface", units="m", dims=("surface",)), diff --git a/src/Tearing/Runner/run_slayer.jl b/src/Tearing/Runner/run_slayer.jl index c6bb3d12e..d057b409e 100644 --- a/src/Tearing/Runner/run_slayer.jl +++ b/src/Tearing/Runner/run_slayer.jl @@ -151,22 +151,23 @@ function _build_surface_coupling(model::GGJModel, params::GGJParameters, return surface_coupling(model, params, dp_diag; q_shift=q_shift) end -# Convert `control.omega_shift_kHz` (per-surface lab-frame frequency offsets in -# kHz, core→edge) into the real Q-space offsets the inner layers consume. Re(Q) -# at surface k is that surface's frequency scaled by its own `tauk`, so a -# lab-frame Δf maps to ΔRe(Q_k) = tauk_k · 2π · 1e3 · Δf_k. Parameters without a -# `tauk` (GGJ) carry no time normalization, so their entry is taken as already -# dimensionless in GGJ's own Q units. +# Convert `control.omega_E_kHz` (per-surface E×B rotation frequencies in kHz, +# core→edge) into the real Q-space offsets the inner layers consume. TJ writes +# the layer eigenvalue as ĝ = i(Q_E − ω·tau_k) with Q_E = tau_k·omega_E; with +# ĝ = −i·Q that is Q_k = tau_k·(omega − omega_E_k), so the offset is +# ΔRe(Q_k) = −tauk_k · 2π · 1e3 · omega_E_kHz[k] — the layer responds in its own +# fluid frame. Parameters without a `tauk` (GGJ) carry no time normalization, so +# their entry is taken as already dimensionless in GGJ's own Q units. function _q_shifts(control::SLAYERControl, params, n::Integer) - shifts = control.omega_shift_kHz + shifts = control.omega_E_kHz isempty(shifts) && return zeros(Float64, n) length(shifts) == n || - throw(ArgumentError("run_slayer: omega_shift_kHz has $(length(shifts)) " * + throw(ArgumentError("run_slayer: omega_E_kHz has $(length(shifts)) " * "entries but $n rational surfaces were analysed. " * "Supply one entry per surface (pad with zeros to " * - "shift only some), or leave it empty for no shift.")) + "leave some static), or leave it empty for no rotation.")) return Float64[ - (hasproperty(params[k], :tauk) ? params[k].tauk : 1.0) * 2π * 1e3 * shifts[k] + -(hasproperty(params[k], :tauk) ? params[k].tauk : 1.0) * 2π * 1e3 * shifts[k] for k in 1:n ] end @@ -293,15 +294,12 @@ function run_slayer_from_inputs(params::AbstractVector{<:InnerLayerParameters}, ) end - # Per-surface Doppler shift in Q units. `omega_shift_kHz[k]` is a - # lab-frame frequency offset in kHz; Re(Q) is that surface's frequency - # times its own tauk, so the Q-space offset is tauk_k · 2π · 1e3 · Δf_k. - # GGJ carries tauk = 1, so its shift is taken as already dimensionless. + # Per-surface E×B Doppler shift in Q units; see `_q_shifts`. q_shifts = _q_shifts(control, params, n) scs = [_build_surface_coupling(model, params[k], dp[k, k], q_shifts[k]) for k in 1:n] any(!iszero, q_shifts) && @info( - "SLAYER: applying per-surface rotation shift " * - "omega_shift_kHz=$(control.omega_shift_kHz) kHz " * + "SLAYER: applying per-surface E×B rotation " * + "omega_E_kHz=$(control.omega_E_kHz) kHz " * "→ ΔRe(Q)=$(round.(q_shifts; digits=4))") # Per-surface resistive layer thickness [m] via the del_s Riccati solve. @@ -364,7 +362,8 @@ function run_slayer_from_inputs(params::AbstractVector{<:InnerLayerParameters}, elseif control.coupling_mode === :coupled m_use = min(control.msing_max, n) - mc = multi_surface_coupling(scs, dp; ref_idx=1, msing_max=m_use) + mc = multi_surface_coupling(scs, dp; ref_idx=1, msing_max=m_use, + tauk_rescale=control.tauk_rescale) scan = _run_scan(mc, control) pthr = _pole_threshold_for(scan) ref_tauk = scs[1].tauk diff --git a/test/runtests_dispersion_rotation.jl b/test/runtests_dispersion_rotation.jl index 6651f257e..9f489a2d8 100644 --- a/test/runtests_dispersion_rotation.jl +++ b/test/runtests_dispersion_rotation.jl @@ -82,24 +82,46 @@ # Empty (default) means no shift anywhere. @test _q_shifts(SLAYERControl(), params, 2) == [0.0, 0.0] - ctrl = SLAYERControl(; omega_shift_kHz=[1.0, -2.0]) + ctrl = SLAYERControl(; omega_E_kHz=[1.0, -2.0]) got = _q_shifts(ctrl, params, 2) - @test got[1] ≈ p1.tauk * 2π * 1e3 * 1.0 - @test got[2] ≈ p2.tauk * 2π * 1e3 * -2.0 + # Sign is negative: TJ's ĝ = i(Q_E − ω·τ_k) maps to Q_k = τ_k(ω − ω_E). + @test got[1] ≈ -p1.tauk * 2π * 1e3 * 1.0 + @test got[2] ≈ -p2.tauk * 2π * 1e3 * -2.0 # Length must match the number of surfaces analysed. - @test_throws ArgumentError _q_shifts(SLAYERControl(; omega_shift_kHz=[1.0]), + @test_throws ArgumentError _q_shifts(SLAYERControl(; omega_E_kHz=[1.0]), params, 2) end - @testset "omega_shift_kHz parses and validates from TOML" begin - ctrl = slayer_control_from_toml(Dict("omega_shift_kHz" => [0, 3.0, -1.5])) - @test ctrl.omega_shift_kHz == [0.0, 3.0, -1.5] - @test eltype(ctrl.omega_shift_kHz) === Float64 + @testset "omega_E_kHz and tauk_rescale parse and validate from TOML" begin + ctrl = slayer_control_from_toml(Dict("omega_E_kHz" => [0, 3.0, -1.5])) + @test ctrl.omega_E_kHz == [0.0, 3.0, -1.5] + @test eltype(ctrl.omega_E_kHz) === Float64 # Default stays empty so existing decks are untouched. - @test isempty(slayer_control_from_toml(Dict{String,Any}()).omega_shift_kHz) + @test isempty(slayer_control_from_toml(Dict{String,Any}()).omega_E_kHz) # A non-finite shift would silently poison every Q evaluation; the # validator rejects it (TOML cannot express NaN, so go through validate). - @test_throws ArgumentError validate(SLAYERControl(; omega_shift_kHz=[NaN])) + @test_throws ArgumentError validate(SLAYERControl(; omega_E_kHz=[NaN])) + + # tauk_rescale round-trips as a Symbol and rejects unknown values. + @test slayer_control_from_toml(Dict("tauk_rescale" => "direct")).tauk_rescale === :direct + @test SLAYERControl().tauk_rescale === :legacy + @test_throws ArgumentError validate(SLAYERControl(; tauk_rescale=:sideways)) + end + + @testset "tauk_rescale flips the inter-surface Q normalization" begin + sc1 = surface_coupling(model, nothing, 1.0 + 0im; scale=1.0, tauk=1.0) + sc2 = surface_coupling(model, nothing, 2.0 + 0im; scale=1.0, tauk=2.0) + dp = ComplexF64[1.0 0.0; 0.0 2.0] + Q = 1.5 + 0.5im + + leg = multi_surface_coupling([sc1, sc2], dp) + dir = multi_surface_coupling([sc1, sc2], dp; tauk_rescale=:direct) + @test leg.tauk_rescale === :legacy + # :legacy divides by tauk_k, :direct multiplies by it. + @test leg(Q) ≈ (1.0 - Q) * (2.0 - Q / 2) + @test dir(Q) ≈ (1.0 - Q) * (2.0 - 2Q) + @test_throws ArgumentError multi_surface_coupling([sc1, sc2], dp; + tauk_rescale=:sideways) end end From daaa36744c8c76b8b70a4c27ea9c1b792079751c Mon Sep 17 00:00:00 2001 From: d-burg Date: Fri, 4 Sep 2026 01:50:32 -0400 Subject: [PATCH 03/20] Tearing.Dispersion - BUGFIX! - Correct the inter-surface Q rescaling in the coupled determinant The coupled determinant evaluated surface k's inner layer at Q_k = Q * (tauk_ref / tauk_k). That direction is inverted. Q is defined as tauk * omega -- Fortran GPEC slayer/params.f sets `Q = Qconv*omega` with `Qconv = lu**(1/3)*tau_h`, this code builds Q_e and Q_i the same way, and the growth-rate extraction inverts it as omega = Re(Q)/tauk. One shared physical eigenvalue therefore reaches surface k by MULTIPLYING by tauk_k, giving Q_k = Q * (tauk_k / tauk_ref). The inconsistency is visible inside a single layer solve: the scanned frequency was scaled as 1/tauk_k while that surface's Q_e/Q_i scale as tauk_k, yet the layer equations add them (ghat + i*Q_e). The two were mis-scaled by (tauk_k/tauk_ref)^2 -- a factor of 18 at the 3/1 on the TJ pc=0.1 equilibrium. Confirmed three ways: the definitional argument above; Fitzpatrick's TJ, where ghat_k = tauk_k * g_k (TJ Documentation/Layer.tex); and a rigid-rotation invariance test, which the corrected direction satisfies to four significant figures in omega while the old one misses it by 15x and swings gamma by 44%. `tauk_rescale` now defaults to `:direct`. `:legacy` restores the old behaviour for reproducing results generated before this correction. The existing rescale test only restated the implementation, so it is rewritten to pin the physics and to cover both directions. NOTE: Fortran GPEC has the same inversion on its `slayer_growthrate` branch, slayer/growthrates.f: `g_tmp = (g_in*sl_in%Qconv_arr(1))/tauk`. This Julia code was a faithful port; the upstream fix is still outstanding. Co-Authored-By: Claude Opus 5 (cherry picked from commit d352ae24373a0052f44277b7d63cb460ab7dbaa5) --- src/Tearing/Dispersion/Coupled.jl | 17 +++++----- src/Tearing/Dispersion/SurfaceCoupling.jl | 6 ++-- src/Tearing/Runner/Control.jl | 17 ++++++---- test/runtests_dispersion_coupled.jl | 39 ++++++++++++++--------- test/runtests_dispersion_rotation.jl | 13 ++++---- 5 files changed, 53 insertions(+), 39 deletions(-) diff --git a/src/Tearing/Dispersion/Coupled.jl b/src/Tearing/Dispersion/Coupled.jl index 5a3ca424a..c9ac9dec0 100644 --- a/src/Tearing/Dispersion/Coupled.jl +++ b/src/Tearing/Dispersion/Coupled.jl @@ -35,13 +35,14 @@ M[i,j] = dp_matrix[i,j] for i ≠ j (off-diagonal Δ' couplings) A root of `mc` in the complex `Q` plane is a coupled tearing eigenvalue. -`ratio_k` is `tauk_ref/tauk_k` under the default `tauk_rescale=:legacy` and -`tauk_k/tauk_ref` under `:direct`. `:direct` is the direction implied by the -extraction convention `ω = Re(Q)/tauk` (so `Q_k = tauk_k·ω` for one shared -physical ω) and by Fitzpatrick's TJ, where the layer eigenvalue enters as -`ĝ = i(Q_E − ω·tau_k)`. `:legacy` is retained as the default because every -published coupled growth rate from this code was produced with it; switching -moves results and is an explicit opt-in. +`ratio_k` is `tauk_k/tauk_ref` under the default `tauk_rescale=:direct` and +`tauk_ref/tauk_k` under `:legacy`. `:direct` is the direction implied by the +definition `Q = tauk·ω` (so one shared physical ω gives `Q_k = tauk_k·ω`) and +by Fitzpatrick's TJ, where the layer eigenvalue is `ĝ_k = tauk_k·g_k`. Under +`:legacy` the scanned frequency scales as `1/tauk_k` while that surface's +`Q_e`/`Q_i` scale as `tauk_k`, yet the layer equations add them (`ĝ + i·Q_e`), +so the two are mis-scaled by `(tauk_k/tauk_ref)²`. `:legacy` is retained only +to reproduce results generated before the correction. `q_shift_k` is the real, per-surface E×B Doppler offset carried on each `SurfaceCoupling` (zero by default, in which case every surface sees the same @@ -81,7 +82,7 @@ function multi_surface_coupling(surfaces::AbstractVector{<:SurfaceCoupling}, dp_matrix::AbstractMatrix; ref_idx::Integer=1, msing_max::Integer=min(3, length(surfaces)), - tauk_rescale::Symbol=:legacy) + tauk_rescale::Symbol=:direct) n = length(surfaces) tauk_rescale in (:legacy, :direct) || throw(ArgumentError("multi_surface_coupling: tauk_rescale=" * diff --git a/src/Tearing/Dispersion/SurfaceCoupling.jl b/src/Tearing/Dispersion/SurfaceCoupling.jl index f626f59ca..09a997eac 100644 --- a/src/Tearing/Dispersion/SurfaceCoupling.jl +++ b/src/Tearing/Dispersion/SurfaceCoupling.jl @@ -73,8 +73,8 @@ 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. `q_shift` is the real Doppler offset on the -layer's Q argument (`tauk * n * Omega`); it defaults to zero. +`MultiSurfaceCoupling` Q rescaling. `q_shift` is the real E×B Doppler offset on +the layer's Q argument (`-tauk * omega_E`); it defaults to zero. """ function surface_coupling(model::SLAYERModel, params::SLAYERParameters, dp_diag::Number; dc::Real=0.0, q_shift::Real=0.0) @@ -99,7 +99,7 @@ meaningful only for tearing-only slab-layer approximations like SLAYER; for GGJ it would double-count the interchange physics. The `SurfaceCoupling` struct's `dc` field is hard-wired to 0 here. -`q_shift` is the real Doppler offset on the layer's Q argument. GGJ carries +`q_shift` is the real E×B Doppler offset on the layer's Q argument. GGJ carries `tauk = 1`, so the caller must supply the shift already in GGJ's own Q units. """ function surface_coupling(model::GGJModel, params::GGJParameters, diff --git a/src/Tearing/Runner/Control.jl b/src/Tearing/Runner/Control.jl index 7e0b37cbb..b17d21d4f 100644 --- a/src/Tearing/Runner/Control.jl +++ b/src/Tearing/Runner/Control.jl @@ -39,9 +39,9 @@ constructor. Empty (default) means no rotation on any surface. Must be empty or have one entry per rational surface actually analysed - `tauk_rescale` -- direction of the inter-surface Q normalization in the - coupled determinant: `:legacy` (default, `Q·tauk_ref/tauk_k`) or `:direct` - (`Q·tauk_k/tauk_ref`, consistent with `ω = Re(Q)/tauk` and with TJ). - `:direct` moves every coupled growth rate, so it is opt-in + coupled determinant: `:direct` (default, `Q·tauk_k/tauk_ref`, consistent + with `Q = tauk·omega` and with TJ) or `:legacy` (`Q·tauk_ref/tauk_k`, the + pre-correction behaviour, for reproducing older results only) - `mu_i` -- ion mass in proton-mass units (default 2.0 for D) - `zeff` -- effective charge - `chi_perp`, `chi_tor` -- fallback perpendicular / toroidal heat @@ -169,10 +169,13 @@ there is one consistent interface for resistive and kinetic profiles. # error; pad with zeros to leave some surfaces static. omega_E_kHz::Vector{Float64} = Float64[] - # Inter-surface Q normalization in the coupled determinant: :legacy keeps - # Q·tauk_ref/tauk_k (every published result to date); :direct uses - # Q·tauk_k/tauk_ref, the direction implied by ω = Re(Q)/tauk and by TJ. - tauk_rescale::Symbol = :legacy + # Inter-surface Q normalization in the coupled determinant. `:direct` + # (default) uses Q·tauk_k/tauk_ref: Q is defined as tauk·omega, so one + # shared physical eigenvalue maps to surface k by MULTIPLYING by tauk_k. + # `:legacy` restores the previous Q·tauk_ref/tauk_k for reproducing results + # generated before the correction; it puts the scanned frequency and that + # surface's Q_e/Q_i on opposite tauk scalings inside the same layer solve. + tauk_rescale::Symbol = :direct profile_file::String = "" profile_group::String = "/" diff --git a/test/runtests_dispersion_coupled.jl b/test/runtests_dispersion_coupled.jl index 1b950016e..3d6638f70 100644 --- a/test/runtests_dispersion_coupled.jl +++ b/test/runtests_dispersion_coupled.jl @@ -167,31 +167,40 @@ end end - @testset "Per-surface Q rescaling via tauk_ref / tauk_k" begin - # Each surface evaluates its inner Δ at Q_k = Q · (tauk_ref/tauk_k). - # With Δ(Q) = Q (b=1, a=0), the diagonal modification is - # M[k,k] = dp_diag_k - scale·Q·(tauk_ref/tauk_k) - # Verify against an explicit closed form with mismatched tauks. + @testset "Per-surface Q rescaling direction" begin + # Q is defined as tauk·ω, so one shared physical eigenvalue reaches + # surface k by MULTIPLYING by tauk_k: Q_k = Q · (tauk_k/tauk_ref). + # That is `:direct`, the default. `:legacy` is the pre-correction + # reciprocal, kept only to reproduce older results. sc1 = surface_coupling(LinTestModel(0.0im, 1.0+0im), nothing, 0.0+0im; scale=1.0, tauk=2.0) # ref tauk sc2 = surface_coupling(LinTestModel(0.0im, 1.0+0im), nothing, - 0.0+0im; scale=1.0, tauk=4.0) # half rate + 0.0+0im; scale=1.0, tauk=4.0) dp = ComplexF64[0.0 0.0; 0.0 0.0] + mc = multi_surface_coupling([sc1, sc2], dp; ref_idx=1) + @test mc.tauk_rescale === :direct for Q in (1.0+0im, 0.5+0.3im) - # M[1,1] = 0 - Q · (2/2) = -Q - # M[2,2] = 0 - Q · (2/4) = -Q/2 - # det = M[1,1] · M[2,2] = Q·Q/2 = Q²/2 - @test mc(Q) ≈ Q^2 / 2 rtol = 1e-12 + # M[1,1] = -Q·(2/2) = -Q ; M[2,2] = -Q·(4/2) = -2Q ; det = 2Q² + @test mc(Q) ≈ 2 * Q^2 rtol = 1e-12 end - # Switch ref_idx to surface 2 + # Reference surface is a labelling choice, not physics: the ratio + # tauk_k/tauk_ref rescales the scanned Q, so switching ref_idx + # rescales the whole determinant by a known power of the tauk ratio. mc2 = multi_surface_coupling([sc1, sc2], dp; ref_idx=2) for Q in (1.0+0im, 0.5+0.3im) - # M[1,1] = -Q · (4/2) = -2Q - # M[2,2] = -Q · (4/4) = -Q - # det = 2Q · Q = 2Q² - @test mc2(Q) ≈ 2 * Q^2 rtol = 1e-12 + # M[1,1] = -Q·(2/4) = -Q/2 ; M[2,2] = -Q·(4/4) = -Q ; det = Q²/2 + @test mc2(Q) ≈ Q^2 / 2 rtol = 1e-12 + end + + # `:legacy` is the exact reciprocal of `:direct` on the diagonal, so + # for this 2-surface product it differs by (tauk_2/tauk_1)^2 = 4. + mcl = multi_surface_coupling([sc1, sc2], dp; ref_idx=1, + tauk_rescale=:legacy) + for Q in (1.0+0im, 0.5+0.3im) + @test mcl(Q) ≈ Q^2 / 2 rtol = 1e-12 + @test mc(Q) ≈ 4 * mcl(Q) rtol = 1e-12 end end diff --git a/test/runtests_dispersion_rotation.jl b/test/runtests_dispersion_rotation.jl index 9f489a2d8..8bb1464e1 100644 --- a/test/runtests_dispersion_rotation.jl +++ b/test/runtests_dispersion_rotation.jl @@ -58,14 +58,15 @@ mc = multi_surface_coupling([sc1, sc2], dp) Q = 1.5 + 0.5im - expected = (1.0 - (Q * (1.0 / 1.0) + s1)) * (2.0 - (Q * (1.0 / 2.0) + s2)) + # Default :direct rescale: surface k sees Q·tauk_k/tauk_ref + q_shift_k. + expected = (1.0 - (Q * (1.0 / 1.0) + s1)) * (2.0 - (Q * (2.0 / 1.0) + s2)) @test mc(Q) ≈ expected - # Zero shifts reproduce the pre-rotation determinant exactly. + # Zero shifts reproduce the un-rotated determinant exactly. sc1z = surface_coupling(model, nothing, 1.0 + 0im; scale=1.0, tauk=1.0) sc2z = surface_coupling(model, nothing, 2.0 + 0im; scale=1.0, tauk=2.0) mcz = multi_surface_coupling([sc1z, sc2z], dp) - @test mcz(Q) ≈ (1.0 - Q) * (2.0 - Q / 2) + @test mcz(Q) ≈ (1.0 - Q) * (2.0 - 2Q) end @testset "_q_shifts converts kHz to Q units via tauk" begin @@ -104,8 +105,8 @@ @test_throws ArgumentError validate(SLAYERControl(; omega_E_kHz=[NaN])) # tauk_rescale round-trips as a Symbol and rejects unknown values. - @test slayer_control_from_toml(Dict("tauk_rescale" => "direct")).tauk_rescale === :direct - @test SLAYERControl().tauk_rescale === :legacy + @test slayer_control_from_toml(Dict("tauk_rescale" => "legacy")).tauk_rescale === :legacy + @test SLAYERControl().tauk_rescale === :direct @test_throws ArgumentError validate(SLAYERControl(; tauk_rescale=:sideways)) end @@ -115,7 +116,7 @@ dp = ComplexF64[1.0 0.0; 0.0 2.0] Q = 1.5 + 0.5im - leg = multi_surface_coupling([sc1, sc2], dp) + leg = multi_surface_coupling([sc1, sc2], dp; tauk_rescale=:legacy) dir = multi_surface_coupling([sc1, sc2], dp; tauk_rescale=:direct) @test leg.tauk_rescale === :legacy # :legacy divides by tauk_k, :direct multiplies by it. From 1531a001594f5d3e2c2015935e8c8eb0b2944de7 Mon Sep 17 00:00:00 2001 From: d-burg Date: Wed, 16 Sep 2026 16:33:07 -0400 Subject: [PATCH 04/20] InnerLayer.SLAYER - REFACTOR! - Remove the unused rotation argument from slayer_parameters slayer_parameters required an `omega` rotation keyword but never read it, and build_slayer_inputs dutifully passed the kinetic file's rotation into it. A single layer is solved in its own plasma frame, so rotation has no place in the per-surface parameters; it belongs in the coupled determinant, where surfaces rotating at different rates must share one lab-frame eigenvalue. Drop the argument. Co-Authored-By: Claude Opus 5 --- src/InnerLayer/SLAYER/LayerInputs.jl | 2 +- src/InnerLayer/SLAYER/LayerParameters.jl | 5 ++--- test/runtests_dispersion_coupled.jl | 2 +- test/runtests_dispersion_residual.jl | 2 +- test/runtests_dispersion_rotation.jl | 4 ++-- test/runtests_slayer_params.jl | 2 +- test/runtests_slayer_riccati.jl | 2 +- 7 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/InnerLayer/SLAYER/LayerInputs.jl b/src/InnerLayer/SLAYER/LayerInputs.jl index 252e81ae9..24224cd23 100644 --- a/src/InnerLayer/SLAYER/LayerInputs.jl +++ b/src/InnerLayer/SLAYER/LayerInputs.jl @@ -375,7 +375,7 @@ function build_slayer_inputs(equil, sings, profiles::KineticProfiles; 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, + omega_e=ω_e_use, omega_i=ω_i_use, qval=q, sval_r=sval_r, bt=_bt_at(psi), rs=rs, R0=R0_use, mu_i=mu_i, zeff=zeff, chi_perp=_eval(chi_perp, psi), diff --git a/src/InnerLayer/SLAYER/LayerParameters.jl b/src/InnerLayer/SLAYER/LayerParameters.jl index ae875dea4..a18dbc81a 100644 --- a/src/InnerLayer/SLAYER/LayerParameters.jl +++ b/src/InnerLayer/SLAYER/LayerParameters.jl @@ -177,7 +177,7 @@ function _solve_dc_tmp(; dc_type::Symbol, dr_val::Real, dgeo_val::Real, end """ - slayer_parameters(; n_e, t_e, t_i, omega, omega_e, omega_i, + slayer_parameters(; n_e, t_e, t_i, omega_e, omega_i, qval, sval_r, bt, rs, R0, mu_i, zeff, chi_perp, chi_tor, m, n, @@ -199,7 +199,6 @@ parametrization (P_perp/P_tor/D_norm; the older magnetic/electron Prandtl - `n_e` -- electron density [m⁻³] - `t_e` -- electron temperature [eV] - `t_i` -- ion temperature [eV] - - `omega` -- toroidal rotation frequency at the surface [rad/s] - `omega_e` -- electron diamagnetic frequency [rad/s] - `omega_i` -- ion diamagnetic frequency [rad/s] - `qval` -- safety factor q at the surface @@ -260,7 +259,7 @@ dispersion relation. """ function slayer_parameters(; n_e::Real, t_e::Real, t_i::Real, - omega::Real, omega_e::Real, omega_i::Real, + omega_e::Real, omega_i::Real, qval::Real, sval_r::Real, bt::Real, rs::Real, R0::Real, mu_i::Real, zeff::Real, chi_perp::Real, chi_tor::Real, diff --git a/test/runtests_dispersion_coupled.jl b/test/runtests_dispersion_coupled.jl index 3d6638f70..431d4039e 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_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 e8af06da4..9f283b071 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_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_rotation.jl b/test/runtests_dispersion_rotation.jl index 8bb1464e1..d01d2dd3c 100644 --- a/test/runtests_dispersion_rotation.jl +++ b/test/runtests_dispersion_rotation.jl @@ -71,11 +71,11 @@ @testset "_q_shifts converts kHz to Q units via tauk" begin p1 = 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_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) p2 = 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_e=1.0e4, omega_i=5.0e3, qval=3.0, sval_r=1.5, bt=2.0, rs=0.6, R0=1.7, mu_i=2.0, zeff=1.0, chi_perp=1.0, chi_tor=1.0, m=3, n=1) params = [p1, p2] diff --git a/test/runtests_slayer_params.jl b/test/runtests_slayer_params.jl index 1f665fb1d..8822e954b 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_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, diff --git a/test/runtests_slayer_riccati.jl b/test/runtests_slayer_riccati.jl index 9d4a95462..48a6263b2 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_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 a97c79e6dbfd9fc9c8b5f0e1311962315953f760 Mon Sep 17 00:00:00 2001 From: d-burg Date: Wed, 16 Sep 2026 16:33:07 -0400 Subject: [PATCH 05/20] Tearing.Dispersion - BUGFIX! - Correct the Q rescaling and replace the imaginary rotation term in the full coupled determinant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reduced determinant was corrected to evaluate surface k at Q·tauk_k/tauk_ref, but the full 4m×4m Pletzer-Dewar form still used the inverted tauk_ref/tauk_k and still added i·ntor·rotation[k] alongside the new real q_shift. That imaginary term is Fortran's guess_modify carried over without the change of convention: with Q = tauk·(ω + iγ) it moves γ rather than ω, and it adds a frequency in s⁻¹ to a dimensionless Q. Give the full form the same tauk_rescale switch (default :direct) and take the Doppler offset solely from each SurfaceCoupling's real q_shift. The rotation and ntor fields are removed. Tests pin the per-surface Q argument under both rescalings and that the shift moves Re(Q) only. Co-Authored-By: Claude Opus 5 --- src/Tearing/Dispersion/CoupledFullMatch.jl | 44 +++++++++------------ test/runtests_dispersion_coupled_full.jl | 46 ++++++++++++---------- 2 files changed, 45 insertions(+), 45 deletions(-) diff --git a/src/Tearing/Dispersion/CoupledFullMatch.jl b/src/Tearing/Dispersion/CoupledFullMatch.jl index e7b6a0d21..fd63173f2 100644 --- a/src/Tearing/Dispersion/CoupledFullMatch.jl +++ b/src/Tearing/Dispersion/CoupledFullMatch.jl @@ -71,16 +71,15 @@ studies use the reduced m × m `MultiSurfaceCoupling` instead. - `dp_raw::Matrix{ComplexF64}` — 2m × 2m outer-region matrix (side-major). - `ref_idx::Int` — reference surface for Q rescaling (1-based). - `msing_max::Int` — number of surfaces to include (truncates). - - `rotation::Vector{Float64}` — per-surface rotation frequencies (s⁻¹). - - `ntor::Int` — toroidal mode number `n` (default 1). + - `tauk_rescale::Symbol` — inter-surface Q normalization, `:direct` or `:legacy` + (see `multi_surface_coupling`). """ struct MultiSurfaceCouplingFull{V<:AbstractVector{<:SurfaceCoupling},K<:NamedTuple} surfaces::V dp_raw::Matrix{ComplexF64} ref_idx::Int msing_max::Int - rotation::Vector{Float64} - ntor::Int + tauk_rescale::Symbol inner_kwargs::K # kwargs forwarded to solve_inner; e.g. (pfac=0.1, nx=128, nq=5) end @@ -88,16 +87,15 @@ end multi_surface_coupling_full(surfaces, dp_raw; ref_idx=1, msing_max=length(surfaces), - rotation=zeros(length(surfaces)), - ntor=1) -> MultiSurfaceCouplingFull + tauk_rescale=:direct, + inner_kwargs=NamedTuple()) -> MultiSurfaceCouplingFull Construct the 4m × 4m dispersion matrix driver. `dp_raw` must be the 2m × 2m matrix in side-major ordering (the `intr.delta_prime_raw` field populated by `ForceFreeStates.compute_delta_prime_matrix!` on the -Riccati path). `rotation[k]` is the per-surface rotation -frequency; it shifts the per-surface inner Q argument by -`i·ntor·rotation[k]`. Default zero rotation matches the static-equilibrium -case. +Riccati path). Surface k's inner layer is evaluated at `Q·ratio_k + q_shift_k`, with +`ratio_k` and the real E×B Doppler offset `q_shift_k` exactly as in the reduced +`multi_surface_coupling`. # Keyword arguments @@ -107,9 +105,8 @@ case. matching matrix becomes 4·msing_max × 4·msing_max, built from the corresponding 2·msing_max × 2·msing_max submatrix of `dp_raw`. Defaults to `length(surfaces)`. - - `rotation` — per-surface rotation frequencies in s⁻¹ (length m). - Defaults to all zero. - - `ntor` — toroidal mode number n. Defaults to 1. + - `tauk_rescale` — `:direct` (default, `Q·tauk_k/tauk_ref`) or `:legacy` + (`Q·tauk_ref/tauk_k`, only for reproducing pre-correction results). - `inner_kwargs` — NamedTuple of kwargs forwarded to `solve_inner` at every Q evaluation, e.g. `(pfac=0.1, xfac=10.0, nx=128, nq=5)` for Galerkin grid tuning. Defaults to `NamedTuple()`. @@ -118,8 +115,7 @@ function multi_surface_coupling_full(surfaces::AbstractVector{<:SurfaceCoupling} dp_raw::AbstractMatrix; ref_idx::Integer=1, msing_max::Integer=length(surfaces), - rotation::AbstractVector{<:Real}=zeros(length(surfaces)), - ntor::Integer=1, + tauk_rescale::Symbol=:direct, inner_kwargs::NamedTuple=NamedTuple()) m = length(surfaces) size(dp_raw) == (2m, 2m) || @@ -131,14 +127,13 @@ function multi_surface_coupling_full(surfaces::AbstractVector{<:SurfaceCoupling} 1 <= msing_max <= m || throw(ArgumentError("multi_surface_coupling_full: msing_max=$msing_max " * "out of range 1:$m")) - length(rotation) == m || - throw(ArgumentError("multi_surface_coupling_full: rotation length " * - "$(length(rotation)) ≠ $m")) + tauk_rescale in (:legacy, :direct) || + throw(ArgumentError("multi_surface_coupling_full: tauk_rescale=" * + "$tauk_rescale must be :legacy or :direct")) return MultiSurfaceCouplingFull(surfaces, Matrix{ComplexF64}(dp_raw), Int(ref_idx), Int(msing_max), - Float64.(collect(rotation)), - Int(ntor), + tauk_rescale, inner_kwargs) end @@ -165,11 +160,10 @@ function (mc::MultiSurfaceCouplingFull)(Q::Number) idx3 = idx1 + s2 # d^k_+ idx4 = idx2 + s2 # d^k_- - # Per-surface Q shift: guess_modify = Q + i·n·rotation[k], plus the - # real Doppler offset sc.q_shift carried on the SurfaceCoupling. - # Also apply ref_tauk / sc.tauk rescaling (we keep the SurfaceCoupling - # tauk normalization that SLAYER needs; GGJ has tauk=1 so it's a no-op). - Q_k = Qc * (ref_tauk / sc.tauk) + sc.q_shift + 1im * mc.ntor * mc.rotation[k] + # Map the shared scanned Q onto this surface's normalization, then Doppler it into + # the surface's E×B frame (GGJ carries tauk = 1, so the ratio is a no-op there). + ratio = mc.tauk_rescale === :direct ? (sc.tauk / ref_tauk) : (ref_tauk / sc.tauk) + Q_k = Qc * ratio + sc.q_shift resp = solve_inner(sc.model, sc.params, Q_k; mc.inner_kwargs...) # delta1 = interchange (parity −), delta2 = tearing (parity +); named diff --git a/test/runtests_dispersion_coupled_full.jl b/test/runtests_dispersion_coupled_full.jl index d471650f8..474f46872 100644 --- a/test/runtests_dispersion_coupled_full.jl +++ b/test/runtests_dispersion_coupled_full.jl @@ -31,8 +31,7 @@ @test size(mc.dp_raw) == (4, 4) @test mc.msing_max == 2 @test mc.ref_idx == 1 - @test mc.rotation == [0.0, 0.0] - @test mc.ntor == 1 + @test mc.tauk_rescale === :direct # Wrong outer dim @test_throws ArgumentError multi_surface_coupling_full([sc1, sc2], @@ -45,9 +44,9 @@ dp_raw; msing_max=0) @test_throws ArgumentError multi_surface_coupling_full([sc1, sc2], dp_raw; msing_max=3) - # Wrong rotation length + # Unknown inter-surface Q normalization @test_throws ArgumentError multi_surface_coupling_full([sc1, sc2], - dp_raw; rotation=[0.0]) + dp_raw; tauk_rescale=:bogus) end @testset "1-surface 4×4 det matches hand computation" begin @@ -120,20 +119,9 @@ @test det_jl ≈ det(M) atol = 1e-12 * abs(det(M)) end - @testset "Rotation shift applies i·ntor·rotation to inner Q argument" begin - # Ensure the per-surface rotation enters the inner-layer argument. - # Use a linear Δ_t model so Q-dependence is tractable. - dp_raw = ComplexF64[1.0 0; 0 1.0] - # Δ_t(Q) = Q (pure linear), Δ_i(Q) = 0 - sc = surface_coupling(_LinearInnerF(0 + 0im, 1 + 0im, 0 + 0im, 0 + 0im), - nothing, 0 + 0im; scale=1.0, tauk=1.0, dc=0.0) - # Case A: rotation=0, Q=2+0im → inner sees 2+0im → Δ_t=2, Δ_i=0 - mc0 = multi_surface_coupling_full([sc], dp_raw; rotation=[0.0], ntor=1) - # Case B: rotation=3, Q=2+0im → inner sees 2 + 1j*1*3 = 2+3i → Δ_t=2+3i - mcR = multi_surface_coupling_full([sc], dp_raw; rotation=[3.0], ntor=1) - @test mc0(2.0 + 0.0im) ≠ mcR(2.0 + 0.0im) - - # Check by hand. Both with the same outer matrix: + @testset "Each surface sees Q·tauk_k/tauk_ref plus its real q_shift" begin + # Linear Δ_t(Q) = Q, Δ_i = 0. With block-diagonal dp_raw the 8×8 matching matrix is a + # symmetric permutation of two per-surface 4×4 blocks, so det factorizes. function detAt(Δ_t, Δ_i) M = ComplexF64[ 1 0 -1 1; @@ -142,8 +130,26 @@ 0 1 -Δ_i -Δ_t] return det(M) end - @test mc0(2.0 + 0.0im) ≈ detAt(2.0 + 0.0im, 0.0 + 0.0im) - @test mcR(2.0 + 0.0im) ≈ detAt(2.0 + 3.0im, 0.0 + 0.0im) + lin = _LinearInnerF(0 + 0im, 1 + 0im, 0 + 0im, 0 + 0im) + s1, s2 = 0.25, -0.75 + sc1 = surface_coupling(lin, nothing, 0 + 0im; scale=1.0, tauk=2.0, dc=0.0, q_shift=s1) + sc2 = surface_coupling(lin, nothing, 0 + 0im; scale=1.0, tauk=4.0, dc=0.0, q_shift=s2) + dp_raw = Matrix{ComplexF64}(I, 4, 4) + Q = 1.5 + 0.5im + + mc = multi_surface_coupling_full([sc1, sc2], dp_raw) + @test mc.tauk_rescale === :direct + @test mc(Q) ≈ detAt(Q * (2.0 / 2.0) + s1, 0im) * detAt(Q * (4.0 / 2.0) + s2, 0im) + + mcl = multi_surface_coupling_full([sc1, sc2], dp_raw; tauk_rescale=:legacy) + @test mcl(Q) ≈ detAt(Q * (2.0 / 2.0) + s1, 0im) * detAt(Q * (2.0 / 4.0) + s2, 0im) + + # The shift is real: it moves the layer argument along Re(Q) only. + mc0 = multi_surface_coupling_full([surface_coupling(lin, nothing, 0 + 0im; scale=1.0, tauk=1.0, dc=0.0)], + Matrix{ComplexF64}(I, 2, 2)) + mcs = multi_surface_coupling_full([surface_coupling(lin, nothing, 0 + 0im; scale=1.0, tauk=1.0, dc=0.0, q_shift=3.0)], + Matrix{ComplexF64}(I, 2, 2)) + @test mcs(2.0 + 0.0im) ≈ mc0(5.0 + 0.0im) end @testset "SurfaceCoupling scale multiplies both inner channels" begin From 4964f53d70b3467a832f3b2087c31c57be69e792 Mon Sep 17 00:00:00 2001 From: d-burg Date: Wed, 16 Sep 2026 16:33:07 -0400 Subject: [PATCH 06/20] =?UTF-8?q?Tearing=20-=20FEATURE!=20-=20Take=20E?= =?UTF-8?q?=C3=97B=20rotation=20from=20the=20kinetic=20file=20and=20apply?= =?UTF-8?q?=20it=20only=20to=20the=20coupled=20determinant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run_slayer loaded the kinetic file's omega_E and then discarded it; the only way to rotate a surface was a hand-typed omega_E_kHz vector. That vector was also applied to uncoupled scans, where a layer is already solved in its own plasma frame and a shift only relabels the reported frequency, and it omitted the toroidal mode number: the file's omega_E is per unit n, while the layer sees n·Ω_E (TJ's ω_E = (m/r_s)·V_E). - Sample Ω_E from the kinetic file at each rational surface by default; a non-empty omega_E_kHz overrides it in the same per-unit-n convention. - Doppler-shift only the coupled determinant, by ΔRe(Q_k) = −τ_k·n·Ω_E,k. Uncoupled runs apply no shift. GGJ surfaces carry no time normalization and are not shifted. - Record the shifts actually applied in SLAYERResult.q_shift and write those to PerSurface/q_shift, instead of recomputing from the control block. - Warn when a surface's offset in the scanned Q falls outside Q_re_range, since the lab-frame root then silently becomes :no_root. The sign needs no mapping onto TJ's conventions. On the DIII-D-like deck the static uncoupled roots sit on ω_*e = −Q_e/τ_k (0.1%, 0.04%, 0.4% at the 2/1, 3/1, 4/1), and the file's omega_E is built from the same ion diamagnetic formula as SLAYER's ω_*i, so ω, ω_* and Ω_E share one convention and the Doppler term is a plain subtraction. In coupled mode the deck's root then lands at ω = 30993 rad/s against the predicted n·Ω_E + ω_*e = 30986 at the 2/1, with γ = 195.0 s⁻¹ against the uncoupled 194.8. Co-Authored-By: Claude Opus 5 --- examples/DIIID-like_SLAYER_example/gpec.toml | 14 ++-- src/Tearing/Runner/Control.jl | 22 +++-- src/Tearing/Runner/HDF5Output.jl | 8 +- src/Tearing/Runner/Result.jl | 2 + src/Tearing/Runner/run_slayer.jl | 88 +++++++++++++------- test/runtests_dispersion_rotation.jl | 74 ++++++++++++---- test/runtests_slayer_runner.jl | 27 ++++++ 7 files changed, 163 insertions(+), 72 deletions(-) diff --git a/examples/DIIID-like_SLAYER_example/gpec.toml b/examples/DIIID-like_SLAYER_example/gpec.toml index 196ca2a3b..e60d73357 100644 --- a/examples/DIIID-like_SLAYER_example/gpec.toml +++ b/examples/DIIID-like_SLAYER_example/gpec.toml @@ -75,14 +75,12 @@ mu_i = 2.0 # Ion mass in proton-mass units (2.0 = deut zeff = 1.0 # Effective charge chi_perp = 1.0 # fallback only; the kinetic file supplies χ⊥(ψ) chi_tor = 1.0 # fallback only; the kinetic file supplies χ_φ(ψ) -# Per-surface E×B rotation frequency [kHz], core→edge (2/1, 3/1, 4/1, 5/1, 6/1, -# 7/1 on this equilibrium). Entry k shifts surface k's inner layer by -# ΔRe(Q_k) = -τ_k·2π·1e3·f_E, so each layer responds in its own fluid frame. -# Empty (the default) is the static case; uncomment for a rotation scan. τ_k -# here is 6.63e-5/4.01e-5/2.68e-5 s at the 2/1, 3/1, 4/1, so 1 kHz is -# ΔRe(Q) = -0.42/-0.25/-0.17 — check roots stay inside Q_re_range. -# omega_E_kHz = [0.0, 3.0, 0.0, 0.0, 0.0, 0.0] # per-surface E×B rotation frequency [kHz], core→edge -# tauk_rescale = "legacy" # coupled inter-surface Q normalization: "legacy" or "direct" (TJ-consistent) +# E×B rotation only Doppler-shifts the coupled determinant, and by default comes from the kinetic +# file's omega_E. Uncomment omega_E_kHz to override it for a rotation scan (one entry per surface, +# 2/1…7/1 here). A shift of 1 kHz per unit n is ΔRe(Q) = -0.42/-0.25/-0.17 at the 2/1, 3/1, 4/1, so +# check roots stay inside Q_re_range. +# omega_E_kHz = [0.0, 3.0, 0.0, 0.0, 0.0, 0.0] # override of the per-surface E×B rotation Ω_E/2π per unit n [kHz], core→edge; empty uses the kinetic file +# tauk_rescale = "direct" # coupled inter-surface Q normalization: "direct" (Q·tauk_k/tauk_ref) or "legacy" (pre-correction, reproduction only) pole_threshold_adaptive = true # SLAYER |Δ| spans many orders; adapt to 10·median(|Δ|) filter_above_poles = true # Discard roots above the highest pole γ filter_outside_re = true # Condition the above-pole filter on the +γ step exiting the Re(Δ)=0 contour loop diff --git a/src/Tearing/Runner/Control.jl b/src/Tearing/Runner/Control.jl index b17d21d4f..60ee9afaf 100644 --- a/src/Tearing/Runner/Control.jl +++ b/src/Tearing/Runner/Control.jl @@ -32,12 +32,14 @@ constructor. - `bt` -- toroidal field `[T]`. `nothing` (default) resolves the physical `B_T = F(ψ)/(2π·R₀)` per surface from the equilibrium's F-spline; a scalar or a callable of `psi` overrides it - - `omega_E_kHz` -- per-surface E×B rotation frequency in kHz, ordered - core→edge. Entry `k` shifts surface `k`'s inner-layer Q argument by - `ΔRe(Q_k) = −tauk_k · 2π · 1e3 · omega_E_kHz[k]`, so that layer responds to - the mode frequency in its own fluid frame (TJ's `ĝ = i(Q_E − ω·tau_k)`). - Empty (default) means no rotation on any surface. Must be empty or have - one entry per rational surface actually analysed + - `omega_E_kHz` -- optional override of the per-surface E×B rotation, as the + frequency Ω_E/2π per unit toroidal mode number in kHz (the kinetic file's + `omega_E` convention), ordered core→edge. Empty (default) takes Ω_E from the + kinetic file at each rational surface. Rotation enters only the coupled + determinant: surface `k`'s inner-layer Q is Doppler-shifted by + `ΔRe(Q_k) = −tauk_k · n · Ω_E,k`, so each layer sees the mode in its own E×B + frame (TJ's `ĝ = i(Q_E − ω·tau_k)`). Must be empty or have one entry per + rational surface actually analysed - `tauk_rescale` -- direction of the inter-surface Q normalization in the coupled determinant: `:direct` (default, `Q·tauk_k/tauk_ref`, consistent with `Q = tauk·omega` and with TJ) or `:legacy` (`Q·tauk_ref/tauk_k`, the @@ -161,12 +163,8 @@ there is one consistent interface for resistive and kinetic profiles. # / failed-Δ'-BVP surface, not a real root. Flagged `:spurious`. validity_rtol::Float64 = 1e-3 - # Per-surface E×B rotation frequency [kHz], ordered core→edge to match the - # rational-surface list. Entry k shifts surface k's inner layer by - # ΔRe(Q_k) = −tauk_k · 2π · 1e3 · omega_E_kHz[k], so each rotating surface - # sees the common lab-frame eigenvalue in its own fluid frame. Empty (the - # default) means no rotation anywhere. A shorter-than-msing vector is an - # error; pad with zeros to leave some surfaces static. + # Override for the per-surface E×B rotation Ω_E/2π per unit n [kHz], core→edge. Empty takes + # Ω_E from the kinetic file; either way it only Doppler-shifts the coupled determinant. omega_E_kHz::Vector{Float64} = Float64[] # Inter-surface Q normalization in the coupled determinant. `:direct` diff --git a/src/Tearing/Runner/HDF5Output.jl b/src/Tearing/Runner/HDF5Output.jl index 7c16b4b2e..428be54c7 100644 --- a/src/Tearing/Runner/HDF5Output.jl +++ b/src/Tearing/Runner/HDF5Output.jl @@ -44,10 +44,8 @@ function write_slayer_hdf5!(parent::Union{HDF5.File,HDF5.Group}, end _write_per_surface!(g, result.params, result.dp_matrix) - # Applied per-surface rotation Doppler in Q units (all-zero unless - # control.omega_E_kHz was set); derived, so it belongs here rather than - # under Input/ where the raw kHz values are echoed. - g["PerSurface/q_shift"] = _q_shifts(result.control, result.params, length(result.params)) + # E×B Doppler offset actually applied to each surface's inner-layer Q (zero outside coupled mode). + g["PerSurface/q_shift"] = result.q_shift # Surface identity (absent when the analysis was built from bare parameters). isempty(result.rational_psi) || (g["PerSurface/rational_psi"] = result.rational_psi) isempty(result.rational_q) || (g["PerSurface/rational_q"] = result.rational_q) @@ -87,7 +85,7 @@ const TEARING_H5_ANNOTATIONS = [ "PerSurface/tau_k" => (; long_name="Q-normalization time S^(1/3)·τ_H per surface (Q = τ_k·ω; diamagnetic inputs Q_e, Q_i carry the opposite sign by convention)", units="s", dims=("surface",)), "PerSurface/q_shift" => - (; long_name="real E×B Doppler offset ΔRe(Q) = −τ_k·2π·1e3·omega_E_kHz on each surface's inner-layer Q (0 = corotates with the eigenvalue)", dims=("surface",)), + (; long_name="real E×B Doppler offset ΔRe(Q) = −τ_k·n·Ω_E applied to each surface's inner-layer Q in the coupled determinant (0 in uncoupled mode)", dims=("surface",)), "PerSurface/tau_R" => (; long_name="resistive diffusion time τ_R = μ₀r_s²/η per surface", units="s", dims=("surface",)), "PerSurface/Delta_prime_norm" => (; long_name="Δ'-normalization factor S^(1/3)/r_s per surface", units="1/m", dims=("surface",)), "PerSurface/rs" => (; long_name="minor radius of each rational surface", units="m", dims=("surface",)), diff --git a/src/Tearing/Runner/Result.jl b/src/Tearing/Runner/Result.jl index 60a728869..a41eb4b11 100644 --- a/src/Tearing/Runner/Result.jl +++ b/src/Tearing/Runner/Result.jl @@ -53,6 +53,7 @@ struct SLAYERResult per_surface_extraction::Vector{GrowthRateResult} coupled_extraction::Union{Nothing,GrowthRateResult} layer_widths::Vector{LayerWidths} + q_shift::Vector{Float64} scan_data::Vector{Union{ScanResult,AMRResult}} end @@ -65,5 +66,6 @@ function empty_slayer_result(control::SLAYERControl) ComplexF64[], Float64[], Float64[], GrowthRateResult[], nothing, LayerWidths[], + Float64[], Union{ScanResult,AMRResult}[]) end diff --git a/src/Tearing/Runner/run_slayer.jl b/src/Tearing/Runner/run_slayer.jl index d057b409e..7e3fff14c 100644 --- a/src/Tearing/Runner/run_slayer.jl +++ b/src/Tearing/Runner/run_slayer.jl @@ -36,7 +36,7 @@ function _load_profiles(control::SLAYERControl, dir_path::AbstractString) end # ω_*e/ω_*i are recomputed per-surface from equilibrium gradients # (compute_omega_star), so the diamagnetic-frequency inputs here are - # placeholders; `omega` carries the ExB rotation when present. + # placeholders; `omega` carries the E×B rotation Ω_E (per unit n) when present. npsi = length(data.psi) omega = data.omega_E === nothing ? zeros(npsi) : data.omega_E profiles = KineticProfiles(; psi=data.psi, n_e=data.n_e, T_e=data.T_e, @@ -151,25 +151,32 @@ function _build_surface_coupling(model::GGJModel, params::GGJParameters, return surface_coupling(model, params, dp_diag; q_shift=q_shift) end -# Convert `control.omega_E_kHz` (per-surface E×B rotation frequencies in kHz, -# core→edge) into the real Q-space offsets the inner layers consume. TJ writes -# the layer eigenvalue as ĝ = i(Q_E − ω·tau_k) with Q_E = tau_k·omega_E; with -# ĝ = −i·Q that is Q_k = tau_k·(omega − omega_E_k), so the offset is -# ΔRe(Q_k) = −tauk_k · 2π · 1e3 · omega_E_kHz[k] — the layer responds in its own -# fluid frame. Parameters without a `tauk` (GGJ) carry no time normalization, so -# their entry is taken as already dimensionless in GGJ's own Q units. -function _q_shifts(control::SLAYERControl, params, n::Integer) - shifts = control.omega_E_kHz - isempty(shifts) && return zeros(Float64, n) - length(shifts) == n || - throw(ArgumentError("run_slayer: omega_E_kHz has $(length(shifts)) " * - "entries but $n rational surfaces were analysed. " * - "Supply one entry per surface (pad with zeros to " * - "leave some static), or leave it empty for no rotation.")) - return Float64[ - -(hasproperty(params[k], :tauk) ? params[k].tauk : 1.0) * 2π * 1e3 * shifts[k] - for k in 1:n - ] +# Per-surface E×B angular frequency Ω_E per unit n [rad/s]: `control.omega_E_kHz` when set, +# otherwise the kinetic file's `omega_E` sampled at each rational surface. +function _omega_E_per_surface(control::SLAYERControl, n::Integer, omega_E_file::AbstractVector{<:Real}) + if !isempty(control.omega_E_kHz) + length(control.omega_E_kHz) == n || + throw(ArgumentError("run_slayer: omega_E_kHz has $(length(control.omega_E_kHz)) " * + "entries but $n rational surfaces were analysed. Supply one " * + "entry per surface, or leave it empty to use the kinetic file.")) + return 2π * 1e3 .* control.omega_E_kHz + end + isempty(omega_E_file) && return zeros(Float64, n) + length(omega_E_file) == n || + throw(ArgumentError("run_slayer: omega_E has $(length(omega_E_file)) entries but " * + "$n rational surfaces were analysed.")) + return Float64.(omega_E_file) +end + +# Real Doppler offset on a surface's inner-layer Q. TJ writes ĝ = i(Q_E − ω·τ_k) with +# Q_E = τ_k·ω_E and ω_E = (m/r_s)·V_E ∝ n·Ω_E; with ĝ = −i·Q this is Q_k = τ_k·(ω − n·Ω_E). +_q_shift(p::SLAYERParameters, Ω_E::Real) = -p.tauk * p.n * Ω_E +# GGJ carries no time normalization (tauk = 1), so a physical rotation has no Q-space image. +_q_shift(::InnerLayerParameters, ::Real) = 0.0 + +function _q_shifts(control::SLAYERControl, params, n::Integer; omega_E::AbstractVector{<:Real}=Float64[]) + Ω_E = _omega_E_per_surface(control, n, omega_E) + return Float64[_q_shift(params[k], Ω_E[k]) for k in 1:n] end @@ -221,19 +228,23 @@ end """ run_slayer_from_inputs(params::Vector{SLAYERParameters}, dp_matrix::AbstractMatrix, - control::SLAYERControl) -> SLAYERResult + control::SLAYERControl; + rational_psi, rational_q, omega_E) -> SLAYERResult Run the SLAYER tearing analysis given pre-built per-surface `SLAYERParameters` and the outer-region Δ' matrix. Bypasses the equilibrium-driven `build_slayer_inputs` step — use this when the parameters are already known (e.g. in unit tests or when rebuilding -from cached HDF5 output). +from cached HDF5 output). `omega_E` is the E×B angular frequency per unit n +at each surface [rad/s]; in coupled mode it Doppler-shifts each surface's +inner-layer Q unless `control.omega_E_kHz` overrides it. Empty means no rotation. """ function run_slayer_from_inputs(params::AbstractVector{<:InnerLayerParameters}, dp_matrix::AbstractMatrix, control::SLAYERControl; rational_psi::Vector{Float64}=Float64[], - rational_q::Vector{Float64}=Float64[]) + rational_q::Vector{Float64}=Float64[], + omega_E::Vector{Float64}=Float64[]) validate(control) control.enabled || return empty_slayer_result(control) isempty(params) && return empty_slayer_result(control) @@ -294,12 +305,29 @@ function run_slayer_from_inputs(params::AbstractVector{<:InnerLayerParameters}, ) end - # Per-surface E×B Doppler shift in Q units; see `_q_shifts`. - q_shifts = _q_shifts(control, params, n) + # E×B rotation only Doppler-shifts the coupled determinant: each uncoupled layer is already + # solved in its own plasma frame, so a shift there would only relabel the reported frequency. + coupled = control.coupling_mode === :coupled + !coupled && !isempty(control.omega_E_kHz) && @warn( + "SLAYER: omega_E_kHz is ignored with coupling_mode=:uncoupled; rotation only enters " * + "the coupled determinant.") + coupled && _is_ggj(model) && any(!iszero, _omega_E_per_surface(control, n, omega_E)) && @warn( + "SLAYER: E×B rotation is not applied to GGJ surfaces, which carry no time normalization.") + q_shifts = coupled ? _q_shifts(control, params, n; omega_E=omega_E) : zeros(Float64, n) scs = [_build_surface_coupling(model, params[k], dp[k, k], q_shifts[k]) for k in 1:n] + # In the scanned (reference-normalized) Q, surface k's layer response moves by τ_ref·n·Ω_E,k. + # A shift outside the scan box silently turns the coupled root into :no_root. + if any(!iszero, q_shifts) + m_use = min(control.msing_max, n) + doppler = [-q_shifts[k] * scs[1].tauk / scs[k].tauk for k in 1:m_use] + lo, hi = control.Q_re_range + any(d -> !(lo <= d <= hi), doppler) && @warn( + "SLAYER: E×B Doppler offsets Re(Q)=$(round.(doppler; digits=3)) fall outside " * + "Q_re_range=$(control.Q_re_range); widen it or the coupled root may be missed.") + end any(!iszero, q_shifts) && @info( - "SLAYER: applying per-surface E×B rotation " * - "omega_E_kHz=$(control.omega_E_kHz) kHz " * + "SLAYER: coupled determinant Doppler-shifted by per-surface E×B rotation " * + "(" * (isempty(control.omega_E_kHz) ? "kinetic file" : "omega_E_kHz override") * ") " * "→ ΔRe(Q)=$(round.(q_shifts; digits=4))") # Per-surface resistive layer thickness [m] via the del_s Riccati solve. @@ -392,7 +420,7 @@ function run_slayer_from_inputs(params::AbstractVector{<:InnerLayerParameters}, return SLAYERResult(true, control, params, rational_psi, rational_q, dp, Q_root, omega_Hz, gamma_Hz, per_surface_extraction, coupled_extraction, - layer_widths, scan_data_list) + layer_widths, q_shifts, scan_data_list) end # --------------------------------------------------------------------- @@ -531,5 +559,7 @@ function run_slayer(equil, surfaces::AbstractVector, delta_prime_matrix::Abstrac rational_psi = Float64[surfaces[p.ising].psifac for p in params] rational_q = Float64[surfaces[p.ising].q for p in params] - return run_slayer_from_inputs(params, dp, control; rational_psi=rational_psi, rational_q=rational_q) + # E×B rotation per unit n at each surface, for the coupled determinant's Doppler shifts. + omega_E = Float64[profiles.omega(ψ) for ψ in rational_psi] + return run_slayer_from_inputs(params, dp, control; rational_psi=rational_psi, rational_q=rational_q, omega_E=omega_E) end diff --git a/test/runtests_dispersion_rotation.jl b/test/runtests_dispersion_rotation.jl index d01d2dd3c..ec80b5f06 100644 --- a/test/runtests_dispersion_rotation.jl +++ b/test/runtests_dispersion_rotation.jl @@ -69,29 +69,67 @@ @test mcz(Q) ≈ (1.0 - Q) * (2.0 - 2Q) end - @testset "_q_shifts converts kHz to Q units via tauk" begin - p1 = slayer_parameters(n_e=5.0e19, t_e=1000.0, t_i=1000.0, + @testset "_q_shifts: kinetic-file Ω_E by default, omega_E_kHz override, n-scaled" begin + mk(; qval, rs, m, n) = slayer_parameters(n_e=5.0e19, t_e=1000.0, t_i=1000.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) - p2 = slayer_parameters(n_e=5.0e19, t_e=1000.0, t_i=1000.0, - omega_e=1.0e4, omega_i=5.0e3, - qval=3.0, sval_r=1.5, bt=2.0, rs=0.6, R0=1.7, - mu_i=2.0, zeff=1.0, chi_perp=1.0, chi_tor=1.0, m=3, n=1) - params = [p1, p2] + qval=qval, sval_r=1.0, bt=2.0, rs=rs, R0=1.7, + mu_i=2.0, zeff=1.0, chi_perp=1.0, chi_tor=1.0, m=m, n=n) + params = [mk(; qval=1.5, rs=0.5, m=3, n=2), mk(; qval=2.0, rs=0.6, m=4, n=2)] + Ω_file = [3.0e4, -1.0e4] # rad/s per unit n, as carried by the kinetic file - # Empty (default) means no shift anywhere. + # No file rotation and no override: no shift anywhere. @test _q_shifts(SLAYERControl(), params, 2) == [0.0, 0.0] - ctrl = SLAYERControl(; omega_E_kHz=[1.0, -2.0]) - got = _q_shifts(ctrl, params, 2) - # Sign is negative: TJ's ĝ = i(Q_E − ω·τ_k) maps to Q_k = τ_k(ω − ω_E). - @test got[1] ≈ -p1.tauk * 2π * 1e3 * 1.0 - @test got[2] ≈ -p2.tauk * 2π * 1e3 * -2.0 + # File rotation by default. The mode sees n·Ω_E, and ĝ = i(Q_E − ω·τ_k) gives + # Q_k = τ_k·(ω − n·Ω_E), so the offset is −τ_k·n·Ω_E. + got = _q_shifts(SLAYERControl(), params, 2; omega_E=Ω_file) + @test got ≈ [-params[1].tauk * 2 * Ω_file[1], -params[2].tauk * 2 * Ω_file[2]] + + # A non-empty omega_E_kHz replaces the file values, in the same per-unit-n convention. + ovr = _q_shifts(SLAYERControl(; omega_E_kHz=[1.0, -2.0]), params, 2; omega_E=Ω_file) + @test ovr ≈ [-params[1].tauk * 2 * 2π * 1e3 * 1.0, -params[2].tauk * 2 * 2π * 1e3 * -2.0] + + # Either source must supply exactly one value per analysed surface. + @test_throws ArgumentError _q_shifts(SLAYERControl(; omega_E_kHz=[1.0]), params, 2) + @test_throws ArgumentError _q_shifts(SLAYERControl(), params, 2; omega_E=[1.0]) + end + + @testset "Coupled roots do not depend on the reference surface" begin + # Diagonal Δ′ decouples the surfaces, so each coupled root must be that surface's own + # root in physical units whichever surface normalizes Q. + d = ComplexF64[1.0+1.0im, 3.0+2.0im] + tauk = [2.0e-4, 5.0e-4] + scs = [surface_coupling(model, nothing, d[k]; scale=1.0, tauk=tauk[k]) for k in 1:2] + # Δ(Q) = Q makes surface k's own root Q_k = d_k, i.e. ω + iγ = d_k/τ_k. + expected = sort(d ./ tauk; by=real) + for ref in 1:2 + mc = multi_surface_coupling(scs, diagm(d); ref_idx=ref, msing_max=2) + # det is quadratic in Q: recover it from three samples and solve. + xs = ComplexF64[0, 1, 2] + c = [x^j for x in xs, j in 0:2] \ [mc(x) for x in xs] + disc = sqrt(c[2]^2 - 4c[3] * c[1]) + roots = [(-c[2] + disc) / (2c[3]), (-c[2] - disc) / (2c[3])] + @test sort(roots ./ tauk[ref]; by=real) ≈ expected rtol = 1e-10 + end + end - # Length must match the number of surfaces analysed. - @test_throws ArgumentError _q_shifts(SLAYERControl(; omega_E_kHz=[1.0]), - params, 2) + @testset "Rigid E×B rotation shifts ω by n·Ω_E and leaves γ unchanged" begin + d = ComplexF64[1.0+1.0im, 3.0+2.0im] + tauk = [2.0e-4, 5.0e-4] + nΩ = 700.0 + static = [surface_coupling(model, nothing, d[k]; scale=1.0, tauk=tauk[k]) for k in 1:2] + rigid = [surface_coupling(model, nothing, d[k]; scale=1.0, tauk=tauk[k], q_shift=-tauk[k] * nΩ) + for k in 1:2] + function physical_roots(scs) + mc = multi_surface_coupling(scs, diagm(d); ref_idx=1, msing_max=2) + xs = ComplexF64[0, 1, 2] + c = [x^j for x in xs, j in 0:2] \ [mc(x) for x in xs] + disc = sqrt(c[2]^2 - 4c[3] * c[1]) + return sort([(-c[2] + disc) / (2c[3]), (-c[2] - disc) / (2c[3])] ./ tauk[1]; by=real) + end + r0, r1 = physical_roots(static), physical_roots(rigid) + @test real.(r1) ≈ real.(r0) .+ nΩ rtol = 1e-10 + @test imag.(r1) ≈ imag.(r0) rtol = 1e-10 end @testset "omega_E_kHz and tauk_rescale parse and validate from TOML" begin diff --git a/test/runtests_slayer_runner.jl b/test/runtests_slayer_runner.jl index ee114e37f..76f5283c7 100644 --- a/test/runtests_slayer_runner.jl +++ b/test/runtests_slayer_runner.jl @@ -136,6 +136,33 @@ end end + @testset "run_slayer_from_inputs: E×B rotation enters only the coupled determinant" begin + params = [_mk_params(; rs=0.5, lu=1.0e7, tauk=1.0e-4, m=2, n=1, ising=1), + _mk_params(; rs=0.6, lu=2.0e7, tauk=1.2e-4, m=3, n=1, ising=2)] + dpm = ComplexF64[-2.0 0.0; 0.0 -3.0] + Ω_E = [2.0e3, -1.0e3] + grid = (; enabled=true, scan_mode=:brute_force, Q_re_range=(-1.0, 1.0), Q_im_range=(-0.5, 0.8), + nre=8, nim=8, pole_threshold=1e5) + + # Uncoupled layers are solved in their own plasma frame: no shift, even when supplied. + r_unc = run_slayer_from_inputs(params, dpm, SLAYERControl(; coupling_mode=:uncoupled, grid...); omega_E=Ω_E) + @test r_unc.q_shift == [0.0, 0.0] + + # Coupled: the kinetic-file Ω_E Dopplers each surface by −τ_k·n·Ω_E. + r_cpl = run_slayer_from_inputs(params, dpm, SLAYERControl(; coupling_mode=:coupled, grid...); omega_E=Ω_E) + @test r_cpl.q_shift ≈ [-1.0e-4 * 1 * Ω_E[1], -1.2e-4 * 1 * Ω_E[2]] + + # omega_E_kHz takes precedence over the file. + r_ovr = run_slayer_from_inputs(params, dpm, + SLAYERControl(; coupling_mode=:coupled, omega_E_kHz=[1.0, 0.0], grid...); omega_E=Ω_E) + @test r_ovr.q_shift ≈ [-1.0e-4 * 2π * 1e3, 0.0] + + # A Doppler offset beyond the scan box is flagged rather than silently losing the root: + # τ_ref·n·Ω_E = 1e-4 · 3e4 = 3 lies outside Re(Q) ∈ [-1, 1]. + @test_logs (:warn, r"outside Q_re_range") match_mode=:any run_slayer_from_inputs(params, dpm, + SLAYERControl(; coupling_mode=:coupled, grid...); omega_E=[3.0e4, 0.0]) + end + @testset "run_slayer_from_inputs: disabled path is a no-op" begin c = SLAYERControl(; enabled=false) params = [_mk_params()] From 611be8c25a04961292c002d0f1e7789b3a802cf1 Mon Sep 17 00:00:00 2001 From: d-burg Date: Wed, 16 Sep 2026 16:33:07 -0400 Subject: [PATCH 07/20] Regression - TEST - Add a coupled-determinant SLAYER case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No harness case exercised the coupled determinant, so neither the inter-surface Q normalization nor the E×B Doppler shift was guarded. Reuse the n=1 SLAYER deck in coupled mode, widening Re(Q) to [-4, 6] because the kinetic-file rotation moves the root to Re(Q) ≈ 2.06, outside the deck's [-2, 2]. The run adds about 17 s of SLAYER time on top of the shared equilibrium and stability stages. Co-Authored-By: Claude Opus 5 --- .../cases/diiid_slayer_n1_coupled.toml | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 regression-harness/cases/diiid_slayer_n1_coupled.toml diff --git a/regression-harness/cases/diiid_slayer_n1_coupled.toml b/regression-harness/cases/diiid_slayer_n1_coupled.toml new file mode 100644 index 000000000..39ca44f07 --- /dev/null +++ b/regression-harness/cases/diiid_slayer_n1_coupled.toml @@ -0,0 +1,86 @@ +# Regression case: DIII-D-like H-mode n=1 SLAYER coupled-determinant growth rate. +# Reuses the n=1 SLAYER deck via [overrides] in coupled mode, the only harness coverage of the +# multi-surface determinant: its inter-surface Q normalization and the per-surface E×B Doppler +# shift taken from the kinetic file's omega_E. The Re(Q) window is widened because that shift +# moves the coupled root to the lab frame (Re(Q) ≈ 2.06 here, outside the deck's [-2, 2]). +# 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_n1_coupled" +description = "DIII-D-like H-mode equilibrium, n=1, SLAYER coupled multi-surface determinant with kinetic-file E×B rotation (reuses the n=1 deck via overrides)" +example_dir = "examples/DIIID-like_SLAYER_example" + +# Run the shared SLAYER deck through the coupled determinant. +[overrides] +"SLAYER.coupling_mode" = "coupled" +"SLAYER.Q_re_range" = [-4.0, 6.0] + +# Per-surface inputs to the determinant +[quantities.slayer_tauk] +h5path = "Tearing/PerSurface/tau_k" +type = "real_vector" +extract = "all_real" +label = "SLAYER tauk" +noise_threshold = 1e-12 +order = 10 + +# Applied E×B Doppler offset ΔRe(Q) = −τ_k·n·Ω_E per surface +[quantities.slayer_q_shift] +h5path = "Tearing/PerSurface/q_shift" +type = "real_vector" +extract = "all_real" +label = "SLAYER q_shift" +noise_threshold = 1e-10 +order = 11 + +# Coupled eigenvalue (one root for the whole determinant). The coupled path has no root +# polishing, so the thresholds match the uncoupled case's absolute, modestly loose floors. +[quantities.slayer_Q_coupled] +h5path = "Tearing/Roots/Q_root" +type = "complex_vector" +extract = "all_complex" +label = "SLAYER coupled Q_root" +noise_threshold = 1e-4 +order = 30 + +[quantities.slayer_omega_coupled] +h5path = "Tearing/Roots/omega" +type = "real_vector" +extract = "all_real" +label = "SLAYER coupled ω" +noise_threshold = 1.0 +order = 32 + +[quantities.slayer_gamma_coupled] +h5path = "Tearing/Roots/gamma" +type = "real_vector" +extract = "all_real" +label = "SLAYER coupled γ" +noise_threshold = 1e-1 +order = 33 + +# no_root flag (1 = extraction failed); guards that the coupled root stays inside the window. +[quantities.slayer_no_root_coupled] +h5path = "Tearing/Roots/no_root" +type = "real_vector" +extract = "all_real" +label = "SLAYER coupled no_root flag" +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 6602f2ee311087b7aecf57298b34a7ed93238096 Mon Sep 17 00:00:00 2001 From: d-burg Date: Thu, 17 Sep 2026 15:23:55 -0400 Subject: [PATCH 08/20] InnerLayer.SLAYER - REFACTOR! - Drop the always-zero diamagnetic stubs from KineticProfiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KineticProfiles carried omega_e and omega_i, but nothing could put a real value in them: run_slayer filled them with zeros, the kinetic file schema has no ω_* column, and compute_omega_star defaulted to true with no caller in src/ ever passing it, so build_slayer_inputs always derived ω_*e and ω_*i from the density and temperature splines instead. The fields, the zeros and the compute_omega_star branch are removed; the container keeps the profiles that are actually read, including omega, which now carries the E×B rotation into the coupled determinant. The two tests that used the pass-through to pin Q_e and Q_i now assert the derived values instead: the fixtures are linear in ψ at constant n_e, so ω_*e = -700/psio and ω_*i = +600/psio exactly. Co-Authored-By: Claude Opus 5 --- src/InnerLayer/SLAYER/LayerInputs.jl | 3 +-- src/Tearing/Runner/run_slayer.jl | 8 +++---- src/Utilities/KineticProfiles.jl | 35 +++++++++++----------------- test/runtests_kinetic_profiles.jl | 15 ++++-------- test/runtests_resist_eval.jl | 10 +++----- test/runtests_slayer_inputs.jl | 27 +++++++++++---------- 6 files changed, 37 insertions(+), 61 deletions(-) diff --git a/src/InnerLayer/SLAYER/LayerInputs.jl b/src/InnerLayer/SLAYER/LayerInputs.jl index 24224cd23..0f28f963d 100644 --- a/src/InnerLayer/SLAYER/LayerInputs.jl +++ b/src/InnerLayer/SLAYER/LayerInputs.jl @@ -238,7 +238,6 @@ function build_slayer_inputs(equil, sings, profiles::KineticProfiles; dgeo_val=nothing, dc_type::Symbol=:none, theta::Real=0.0, - compute_omega_star::Bool=true, resistivity_model::NeoResistivityModel=SauterNeoModel(), lnLambda_form::Symbol=:nrl) R0_use = R0 === nothing ? equil.ro : Float64(R0) @@ -293,7 +292,7 @@ function build_slayer_inputs(equil, sings, profiles::KineticProfiles; prof = profiles(psi) # 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) + ω_e_use, ω_i_use = _omega_star_at(psi, n_res) # Pull geometric trapped-fraction inputs from ResistGeometry when # available (populated by ForceFreeStates.resist_eval_all!); else diff --git a/src/Tearing/Runner/run_slayer.jl b/src/Tearing/Runner/run_slayer.jl index 7e3fff14c..3a50b9f47 100644 --- a/src/Tearing/Runner/run_slayer.jl +++ b/src/Tearing/Runner/run_slayer.jl @@ -34,14 +34,12 @@ function _load_profiles(control::SLAYERControl, dir_path::AbstractString) error("run_slayer: kinetic file '$path' is missing required " * "dataset '$name' for the SLAYER inner layer.") end - # ω_*e/ω_*i are recomputed per-surface from equilibrium gradients - # (compute_omega_star), so the diamagnetic-frequency inputs here are - # placeholders; `omega` carries the E×B rotation Ω_E (per unit n) when present. + # `omega` carries the E×B rotation Ω_E (per unit n) when the file has it; ω_*e/ω_*i are + # derived per surface from the density and temperature splines by `build_slayer_inputs`. npsi = length(data.psi) omega = data.omega_E === nothing ? zeros(npsi) : data.omega_E profiles = KineticProfiles(; psi=data.psi, n_e=data.n_e, T_e=data.T_e, - T_i=data.T_i, omega=omega, - omega_e=zeros(npsi), omega_i=zeros(npsi)) + T_i=data.T_i, omega=omega) # χ⊥(ψ)/χ_φ(ψ) splines from the file. A χ array that is absent OR all-zero # is treated as "not provided" — χ must be positive (χ=0 ⇒ τ_⊥→∞), and the diff --git a/src/Utilities/KineticProfiles.jl b/src/Utilities/KineticProfiles.jl index 70f1a2974..0272a0d2f 100644 --- a/src/Utilities/KineticProfiles.jl +++ b/src/Utilities/KineticProfiles.jl @@ -11,7 +11,7 @@ using FastInterpolations """ KineticProfiles -Radial kinetic-profile container. All six profiles are 1D cubic splines of +Radial kinetic-profile container. All four profiles are 1D cubic splines of the normalized poloidal flux ψ ∈ [0, 1]. | field | meaning | units | @@ -19,18 +19,18 @@ the normalized poloidal flux ψ ∈ [0, 1]. | `n_e` | electron density | m⁻³ | | `T_e` | electron temperature | eV | | `T_i` | ion temperature | eV | -| `omega` | toroidal rotation | rad/s | -| `omega_e` | electron diamagnetic frequency ω\\_\\*e | rad/s | -| `omega_i` | ion diamagnetic frequency ω\\_\\*i | rad/s | +| `omega` | E×B rotation Ω_E, per unit n | rad/s | -Construct via the keyword constructor `KineticProfiles(; psi, n_e, T_e, T_i, omega, omega_e, omega_i)` with matched-length vectors. The SLAYER -runner builds this object from a standardized kinetic-profile file via -`Equilibrium.read_kinetic_file`. +Construct via the keyword constructor `KineticProfiles(; psi, n_e, T_e, T_i, omega)` with +matched-length vectors. The SLAYER runner builds this object from a standardized +kinetic-profile file via `Equilibrium.read_kinetic_file`. Diamagnetic frequencies are not +carried here: `build_slayer_inputs` derives ω_*e and ω_*i per surface from the density and +temperature splines. Evaluate all profiles at a given ψ via the call operator: ```julia -vals = kp(0.5) # NamedTuple(n_e=..., T_e=..., ..., omega_i=...) +vals = kp(0.5) # NamedTuple(n_e=..., T_e=..., T_i=..., omega=...) ``` """ struct KineticProfiles{S} @@ -38,21 +38,16 @@ struct KineticProfiles{S} T_e::S T_i::S omega::S - omega_e::S - omega_i::S end function KineticProfiles(; psi::AbstractVector{<:Real}, n_e::AbstractVector{<:Real}, T_e::AbstractVector{<:Real}, T_i::AbstractVector{<:Real}, - omega::AbstractVector{<:Real}, - omega_e::AbstractVector{<:Real}, - omega_i::AbstractVector{<:Real}) + omega::AbstractVector{<:Real}) xs = collect(Float64.(psi)) for (name, v) in (("n_e", n_e), ("T_e", T_e), ("T_i", T_i), - ("omega", omega), ("omega_e", omega_e), - ("omega_i", omega_i)) + ("omega", omega)) length(v) == length(xs) || throw(ArgumentError("KineticProfiles: length($name) = $(length(v)) " * "≠ length(psi) = $(length(xs))")) @@ -60,22 +55,18 @@ function KineticProfiles(; psi::AbstractVector{<:Real}, return KineticProfiles(cubic_interp(xs, Float64.(n_e)), cubic_interp(xs, Float64.(T_e)), cubic_interp(xs, Float64.(T_i)), - cubic_interp(xs, Float64.(omega)), - cubic_interp(xs, Float64.(omega_e)), - cubic_interp(xs, Float64.(omega_i))) + cubic_interp(xs, Float64.(omega))) end """ (kp::KineticProfiles)(psi::Real) -> NamedTuple Evaluate all profiles at `psi` and return them as a NamedTuple with fields -`(n_e, T_e, T_i, omega, omega_e, omega_i)`. +`(n_e, T_e, T_i, omega)`. """ (kp::KineticProfiles)(psi::Real) = ( n_e=kp.n_e(psi), T_e=kp.T_e(psi), T_i=kp.T_i(psi), - omega=kp.omega(psi), - omega_e=kp.omega_e(psi), - omega_i=kp.omega_i(psi) + omega=kp.omega(psi) ) diff --git a/test/runtests_kinetic_profiles.jl b/test/runtests_kinetic_profiles.jl index d7308b2d1..583c345df 100644 --- a/test/runtests_kinetic_profiles.jl +++ b/test/runtests_kinetic_profiles.jl @@ -10,9 +10,7 @@ "n_e" => fill(5.0e19, length(psi)), "T_e" => 1000.0 .* (1.0 .- 0.7 .* psi), "T_i" => 1200.0 .* (1.0 .- 0.6 .* psi), - "omega" => 1.0e4 .* psi, - "omega_e" => fill(1.0e4, length(psi)), - "omega_i" => fill(5.0e3, length(psi)) + "omega" => 1.0e4 .* psi ) ) end @@ -20,23 +18,20 @@ @testset "kwarg constructor + evaluation" begin psi, d = _synthetic() kp = KineticProfiles(; psi=psi, n_e=d["n_e"], T_e=d["T_e"], - T_i=d["T_i"], omega=d["omega"], - omega_e=d["omega_e"], omega_i=d["omega_i"]) + T_i=d["T_i"], omega=d["omega"]) # Exact recovery at a node vals = kp(0.5) @test vals.n_e ≈ 5.0e19 @test vals.T_e ≈ 1000.0 * (1 - 0.7 * 0.5) @test vals.T_i ≈ 1200.0 * (1 - 0.6 * 0.5) @test vals.omega ≈ 1.0e4 * 0.5 - @test vals.omega_e ≈ 1.0e4 - @test vals.omega_i ≈ 5.0e3 # Smooth interpolation between nodes vals2 = kp(0.25) @test vals2.T_e ≈ 1000.0 * (1 - 0.7 * 0.25) rtol = 1e-6 # NamedTuple fields - @test keys(vals) == (:n_e, :T_e, :T_i, :omega, :omega_e, :omega_i) + @test keys(vals) == (:n_e, :T_e, :T_i, :omega) end @testset "length mismatch raises" begin @@ -46,8 +41,6 @@ n_e=fill(1.0, length(psi) - 1), # wrong length T_e=fill(1000.0, length(psi)), T_i=fill(1000.0, length(psi)), - omega=fill(0.0, length(psi)), - omega_e=fill(0.0, length(psi)), - omega_i=fill(0.0, length(psi))) + omega=fill(0.0, length(psi))) end end diff --git a/test/runtests_resist_eval.jl b/test/runtests_resist_eval.jl index 8e334a67a..f7cbd995e 100644 --- a/test/runtests_resist_eval.jl +++ b/test/runtests_resist_eval.jl @@ -105,9 +105,7 @@ n_e=fill(5.0e19, length(psi_pts)), T_e=1000.0 .* (1.0 .- 0.7 .* psi_pts), T_i=1000.0 .* (1.0 .- 0.6 .* psi_pts), - omega=fill(0.0, length(psi_pts)), - omega_e=fill(1.0e4, length(psi_pts)), - omega_i=fill(5.0e3, length(psi_pts))) + omega=fill(0.0, length(psi_pts))) dq = deriv_view(equil.profiles.q_spline, 1) s1 = SingType(; psifac=0.3, rho=sqrt(0.3), m=[2], n=[1], @@ -149,7 +147,7 @@ n = length(psi_pts) profiles = KineticProfiles(; psi=psi_pts, n_e=fill(5.0e19, n), T_e=fill(1000.0, n), T_i=fill(1000.0, n), - omega=fill(0.0, n), omega_e=fill(1.0e4, n), omega_i=fill(5.0e3, n)) + omega=fill(0.0, n)) s_unpop = SingType(; psifac=0.5, rho=sqrt(0.5), m=[2], n=[1], q=2.0, q1=1.0, @@ -168,9 +166,7 @@ n_e=fill(5.0e19, length(psi_pts)), T_e=1000.0 .* (1.0 .- 0.7 .* psi_pts), T_i=fill(1000.0, length(psi_pts)), - omega=fill(0.0, length(psi_pts)), - omega_e=fill(0.0, length(psi_pts)), - omega_i=fill(0.0, length(psi_pts))) + omega=fill(0.0, length(psi_pts))) dq = deriv_view(equil.profiles.q_spline, 1) s1 = SingType(; psifac=0.3, rho=sqrt(0.3), m=[2], n=[1], diff --git a/test/runtests_slayer_inputs.jl b/test/runtests_slayer_inputs.jl index fce001036..da7bef7c1 100644 --- a/test/runtests_slayer_inputs.jl +++ b/test/runtests_slayer_inputs.jl @@ -20,9 +20,7 @@ n_e=fill(5.0e19, length(psi_pts)), T_e=1000.0 .* (1.0 .- 0.7 .* psi_pts), T_i=1000.0 .* (1.0 .- 0.6 .* psi_pts), - omega=fill(0.0, length(psi_pts)), - omega_e=fill(1.0e4, length(psi_pts)), - omega_i=fill(5.0e3, length(psi_pts))) + omega=fill(0.0, length(psi_pts))) # Helper to build a minimal SingType without touching unused fields _mk_sing(; psi, q, q1, m, n, delta_prime=-10.0+0im) = SingType( @@ -77,11 +75,7 @@ # dr_val=0.0 bypasses the build_slayer_inputs requirement that sing.restype be # pre-populated by ForceFreeStates.resist_eval_all! — the test sings here are # minimal stubs without restype, so we supply dr_val explicitly. - # compute_omega_star=false makes Q_e/Q_i pass through directly from profiles.omega_e/i - # rather than being recomputed from n_e/T_e/T_i gradients — required for the Q_e == - # -tauk·omega_e(ψ) identity check below. - sl = build_slayer_inputs(equil, sings, profiles; bt=2.0, dr_val=0.0, - compute_omega_star=false) + sl = build_slayer_inputs(equil, sings, profiles; bt=2.0, dr_val=0.0) @test length(sl) == 2 @test sl[1] isa SLAYERParameters @@ -109,9 +103,14 @@ @test sl[1].lu != sl[2].lu @test sl[1].tauk != sl[2].tauk - # Q_e, Q_i follow the SLAYER layerinputs sign convention - @test sl[1].Q_e == -sl[1].tauk * profiles.omega_e(0.3) - @test sl[1].Q_i == -sl[1].tauk * profiles.omega_i(0.3) + # Q_e, Q_i follow the SLAYER layerinputs sign convention, built on the ω_* derived from + # the profile splines. These fixtures are linear in ψ with constant n_e, so + # ω_*e = (2π/χ₁)·dT_e/dψ = -700/psio and ω_*i = -(2π/χ₁)·dT_i/dψ = +600/psio exactly. + ω_star_e = -700.0 / equil.psio + ω_star_i = 600.0 / equil.psio + @test sl[1].Q_e ≈ -sl[1].tauk * ω_star_e rtol = 1e-10 + @test sl[1].Q_i ≈ -sl[1].tauk * ω_star_i rtol = 1e-10 + @test sl[1].Q_e > 0 > sl[1].Q_i end @testset "build_slayer_inputs: bt defaults to the physical B_T, not a normalization" begin @@ -129,9 +128,9 @@ sings = [_mk_sing(psi=0.3, q=2.0, q1=1.5, m=2, n=1)] bt_phys = Float64(equil.profiles.F_spline(0.3)) / (2π * equil.ro) - sl_default = build_slayer_inputs(equil, sings, profiles; dr_val=0.0, compute_omega_star=false) - sl_at_phys = build_slayer_inputs(equil, sings, profiles; bt=bt_phys, dr_val=0.0, compute_omega_star=false) - sl_double = build_slayer_inputs(equil, sings, profiles; bt=2 * bt_phys, dr_val=0.0, compute_omega_star=false) + sl_default = build_slayer_inputs(equil, sings, profiles; dr_val=0.0) + sl_at_phys = build_slayer_inputs(equil, sings, profiles; bt=bt_phys, dr_val=0.0) + sl_double = build_slayer_inputs(equil, sings, profiles; bt=2 * bt_phys, dr_val=0.0) # Leaving bt unset must be identical to passing the physical field explicitly. @test sl_default[1].lu ≈ sl_at_phys[1].lu rtol = 1e-12 From 4ec888831bd71c9541f4113afde2936389658da1 Mon Sep 17 00:00:00 2001 From: d-burg Date: Wed, 23 Sep 2026 16:31:16 -0400 Subject: [PATCH 09/20] Tearing - REFACTOR - Remove the legacy inter-surface Q normalization and tidy the rotation plumbing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop `tauk_rescale` entirely: `Q·tauk_k/tauk_ref` is the only normalization, so the `:legacy` reproduction shim (which could not reproduce pre-correction results once the kinetic file carries omega_E) goes, with its field, keyword, TOML key and validation. - The example deck's rotation note gives the shift in the scanned plane (+τ_ref·n·Ω_E, the same for every surface) rather than each surface's own q_shift. - `Roots/omega` documents its frame: E×B per surface when uncoupled, lab when coupled. - GGJ surfaces take no q_shift (they have no time normalization); Ω_E is resolved once per run. - Replace the documentation-file citations with the relation itself and trim the comments that narrated the old bug. Co-Authored-By: Claude Opus 5.5 --- examples/DIIID-like_SLAYER_example/gpec.toml | 5 +- src/InnerLayer/SLAYER/LayerInputs.jl | 3 +- src/Tearing/Dispersion/Coupled.jl | 25 +--- src/Tearing/Dispersion/CoupledFullMatch.jl | 14 +- src/Tearing/Dispersion/SurfaceCoupling.jl | 24 +-- src/Tearing/Runner/Control.jl | 20 +-- src/Tearing/Runner/HDF5Output.jl | 16 +- src/Tearing/Runner/run_slayer.jl | 43 +++--- test/runtests_dispersion_coupled.jl | 148 +++++++++---------- test/runtests_dispersion_coupled_full.jl | 8 - test/runtests_dispersion_rotation.jl | 44 ++---- 11 files changed, 133 insertions(+), 217 deletions(-) diff --git a/examples/DIIID-like_SLAYER_example/gpec.toml b/examples/DIIID-like_SLAYER_example/gpec.toml index e60d73357..b89195741 100644 --- a/examples/DIIID-like_SLAYER_example/gpec.toml +++ b/examples/DIIID-like_SLAYER_example/gpec.toml @@ -77,10 +77,9 @@ chi_perp = 1.0 # fallback only; the kinetic file supplies chi_tor = 1.0 # fallback only; the kinetic file supplies χ_φ(ψ) # E×B rotation only Doppler-shifts the coupled determinant, and by default comes from the kinetic # file's omega_E. Uncomment omega_E_kHz to override it for a rotation scan (one entry per surface, -# 2/1…7/1 here). A shift of 1 kHz per unit n is ΔRe(Q) = -0.42/-0.25/-0.17 at the 2/1, 3/1, 4/1, so -# check roots stay inside Q_re_range. +# 2/1…7/1 here). In the scanned Re(Q), normalized to the 2/1, every surface's layer response moves by +# +τ_2/1·n·Ω_E: +0.42 per kHz per unit n here, so check roots stay inside Q_re_range. # omega_E_kHz = [0.0, 3.0, 0.0, 0.0, 0.0, 0.0] # override of the per-surface E×B rotation Ω_E/2π per unit n [kHz], core→edge; empty uses the kinetic file -# tauk_rescale = "direct" # coupled inter-surface Q normalization: "direct" (Q·tauk_k/tauk_ref) or "legacy" (pre-correction, reproduction only) pole_threshold_adaptive = true # SLAYER |Δ| spans many orders; adapt to 10·median(|Δ|) filter_above_poles = true # Discard roots above the highest pole γ filter_outside_re = true # Condition the above-pole filter on the +γ step exiting the Re(Δ)=0 contour loop diff --git a/src/InnerLayer/SLAYER/LayerInputs.jl b/src/InnerLayer/SLAYER/LayerInputs.jl index 0f28f963d..ea3c89189 100644 --- a/src/InnerLayer/SLAYER/LayerInputs.jl +++ b/src/InnerLayer/SLAYER/LayerInputs.jl @@ -290,8 +290,7 @@ function build_slayer_inputs(equil, sings, profiles::KineticProfiles; n_res = sing.n[1] prof = profiles(psi) - # 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, ω_*i from the density and temperature spline derivatives, at this surface's n. ω_e_use, ω_i_use = _omega_star_at(psi, n_res) # Pull geometric trapped-fraction inputs from ResistGeometry when diff --git a/src/Tearing/Dispersion/Coupled.jl b/src/Tearing/Dispersion/Coupled.jl index c9ac9dec0..2848c4495 100644 --- a/src/Tearing/Dispersion/Coupled.jl +++ b/src/Tearing/Dispersion/Coupled.jl @@ -15,7 +15,7 @@ # det = mc(Q::ComplexF64) # # At each evaluation, for k = 1 .. msing_max, the inner-layer Δ is computed -# at a Q rescaled by `tauk_ref / tauk_k` and offset by that surface's real +# at a Q rescaled by `tauk_k / tauk_ref` and offset by that surface's real # Doppler shift `q_shift`, then subtracted (with the dc offset) from the # diagonal of an `msing_max × msing_max` upper-left submatrix of `dp_matrix`. # The off-diagonal Δ' couplings are passed through unchanged. @@ -35,14 +35,8 @@ M[i,j] = dp_matrix[i,j] for i ≠ j (off-diagonal Δ' couplings) A root of `mc` in the complex `Q` plane is a coupled tearing eigenvalue. -`ratio_k` is `tauk_k/tauk_ref` under the default `tauk_rescale=:direct` and -`tauk_ref/tauk_k` under `:legacy`. `:direct` is the direction implied by the -definition `Q = tauk·ω` (so one shared physical ω gives `Q_k = tauk_k·ω`) and -by Fitzpatrick's TJ, where the layer eigenvalue is `ĝ_k = tauk_k·g_k`. Under -`:legacy` the scanned frequency scales as `1/tauk_k` while that surface's -`Q_e`/`Q_i` scale as `tauk_k`, yet the layer equations add them (`ĝ + i·Q_e`), -so the two are mis-scaled by `(tauk_k/tauk_ref)²`. `:legacy` is retained only -to reproduce results generated before the correction. +`ratio_k = tauk_k/tauk_ref`: `Q` is defined as `tauk·ω`, so one shared physical +ω reaches surface `k` as `Q_k = tauk_k·ω`, on the same scaling as its `Q_e`/`Q_i`. `q_shift_k` is the real, per-surface E×B Doppler offset carried on each `SurfaceCoupling` (zero by default, in which case every surface sees the same @@ -53,7 +47,6 @@ struct MultiSurfaceCoupling{V<:AbstractVector{<:SurfaceCoupling}} dp_matrix::Matrix{ComplexF64} ref_idx::Int msing_max::Int - tauk_rescale::Symbol end """ @@ -81,12 +74,8 @@ length `length(surfaces)` (it is the same matrix returned by function multi_surface_coupling(surfaces::AbstractVector{<:SurfaceCoupling}, dp_matrix::AbstractMatrix; ref_idx::Integer=1, - msing_max::Integer=min(3, length(surfaces)), - tauk_rescale::Symbol=:direct) + msing_max::Integer=min(3, length(surfaces))) n = length(surfaces) - tauk_rescale in (:legacy, :direct) || - throw(ArgumentError("multi_surface_coupling: tauk_rescale=" * - "$tauk_rescale must be :legacy or :direct")) size(dp_matrix) == (n, n) || throw(ArgumentError("multi_surface_coupling: dp_matrix size " * "$(size(dp_matrix)) ≠ ($n, $n)")) @@ -98,7 +87,7 @@ function multi_surface_coupling(surfaces::AbstractVector{<:SurfaceCoupling}, "out of range 1:$n")) return MultiSurfaceCoupling(surfaces, Matrix{ComplexF64}(dp_matrix), - Int(ref_idx), Int(msing_max), tauk_rescale) + Int(ref_idx), Int(msing_max)) end function (mc::MultiSurfaceCoupling)(Q::Number) @@ -111,9 +100,7 @@ function (mc::MultiSurfaceCoupling)(Q::Number) sc = mc.surfaces[k] # Real q_shift Dopplers this surface's layer away from the common # lab-frame frequency carried by the scanned Q. - ratio = mc.tauk_rescale === :direct ? (sc.tauk / ref_tauk) : - (ref_tauk / sc.tauk) - Q_k = Qc * ratio + sc.q_shift + Q_k = Qc * (sc.tauk / ref_tauk) + sc.q_shift # m×m scalar coupling: use only the tearing channel. The # interchange (Glasser-stabilization) channel is carried in the # full 4m×4m dispersion in `CoupledFullMatch.jl`; this reduced diff --git a/src/Tearing/Dispersion/CoupledFullMatch.jl b/src/Tearing/Dispersion/CoupledFullMatch.jl index fd63173f2..8d309a9d7 100644 --- a/src/Tearing/Dispersion/CoupledFullMatch.jl +++ b/src/Tearing/Dispersion/CoupledFullMatch.jl @@ -71,15 +71,12 @@ studies use the reduced m × m `MultiSurfaceCoupling` instead. - `dp_raw::Matrix{ComplexF64}` — 2m × 2m outer-region matrix (side-major). - `ref_idx::Int` — reference surface for Q rescaling (1-based). - `msing_max::Int` — number of surfaces to include (truncates). - - `tauk_rescale::Symbol` — inter-surface Q normalization, `:direct` or `:legacy` - (see `multi_surface_coupling`). """ struct MultiSurfaceCouplingFull{V<:AbstractVector{<:SurfaceCoupling},K<:NamedTuple} surfaces::V dp_raw::Matrix{ComplexF64} ref_idx::Int msing_max::Int - tauk_rescale::Symbol inner_kwargs::K # kwargs forwarded to solve_inner; e.g. (pfac=0.1, nx=128, nq=5) end @@ -87,7 +84,6 @@ end multi_surface_coupling_full(surfaces, dp_raw; ref_idx=1, msing_max=length(surfaces), - tauk_rescale=:direct, inner_kwargs=NamedTuple()) -> MultiSurfaceCouplingFull Construct the 4m × 4m dispersion matrix driver. `dp_raw` must be the @@ -105,8 +101,6 @@ Riccati path). Surface k's inner layer is evaluated at `Q·ratio_k + q_shift_k`, matching matrix becomes 4·msing_max × 4·msing_max, built from the corresponding 2·msing_max × 2·msing_max submatrix of `dp_raw`. Defaults to `length(surfaces)`. - - `tauk_rescale` — `:direct` (default, `Q·tauk_k/tauk_ref`) or `:legacy` - (`Q·tauk_ref/tauk_k`, only for reproducing pre-correction results). - `inner_kwargs` — NamedTuple of kwargs forwarded to `solve_inner` at every Q evaluation, e.g. `(pfac=0.1, xfac=10.0, nx=128, nq=5)` for Galerkin grid tuning. Defaults to `NamedTuple()`. @@ -115,7 +109,6 @@ function multi_surface_coupling_full(surfaces::AbstractVector{<:SurfaceCoupling} dp_raw::AbstractMatrix; ref_idx::Integer=1, msing_max::Integer=length(surfaces), - tauk_rescale::Symbol=:direct, inner_kwargs::NamedTuple=NamedTuple()) m = length(surfaces) size(dp_raw) == (2m, 2m) || @@ -127,13 +120,9 @@ function multi_surface_coupling_full(surfaces::AbstractVector{<:SurfaceCoupling} 1 <= msing_max <= m || throw(ArgumentError("multi_surface_coupling_full: msing_max=$msing_max " * "out of range 1:$m")) - tauk_rescale in (:legacy, :direct) || - throw(ArgumentError("multi_surface_coupling_full: tauk_rescale=" * - "$tauk_rescale must be :legacy or :direct")) return MultiSurfaceCouplingFull(surfaces, Matrix{ComplexF64}(dp_raw), Int(ref_idx), Int(msing_max), - tauk_rescale, inner_kwargs) end @@ -162,8 +151,7 @@ function (mc::MultiSurfaceCouplingFull)(Q::Number) # Map the shared scanned Q onto this surface's normalization, then Doppler it into # the surface's E×B frame (GGJ carries tauk = 1, so the ratio is a no-op there). - ratio = mc.tauk_rescale === :direct ? (sc.tauk / ref_tauk) : (ref_tauk / sc.tauk) - Q_k = Qc * ratio + sc.q_shift + Q_k = Qc * (sc.tauk / ref_tauk) + sc.q_shift resp = solve_inner(sc.model, sc.params, Q_k; mc.inner_kwargs...) # delta1 = interchange (parity −), delta2 = tearing (parity +); named diff --git a/src/Tearing/Dispersion/SurfaceCoupling.jl b/src/Tearing/Dispersion/SurfaceCoupling.jl index 09a997eac..38be0cadf 100644 --- a/src/Tearing/Dispersion/SurfaceCoupling.jl +++ b/src/Tearing/Dispersion/SurfaceCoupling.jl @@ -12,13 +12,9 @@ # multi-surface `MultiSurfaceCoupling` to rescale Q between each surface's # normalization. # -# `q_shift` is a real, additive offset on the layer's Q argument, carrying the -# E×B rotation of this surface. Fitzpatrick's TJ writes the layer eigenvalue as -# `ĝ = i(Q_E − ω·tau_k)` with `Q_E = tau_k·omega_E` (TJ Documentation/Layer.tex, -# "Calculation of Growth-Rate and Real Frequency"); with `ĝ = −i·Q` this is -# `Q_k = tau_k·(omega − omega_E_k)`, i.e. the layer sees the mode frequency in -# its own fluid frame. So `q_shift = −tau_k·omega_E_k`, and zero reproduces the -# static, all-surfaces-corotating case. +# `q_shift = −tau_k·omega_E_k` is a real, additive offset on the layer's Q argument that puts +# the mode frequency in this surface's E×B frame, `Q_k = tau_k·(omega − omega_E_k)`; zero is +# the static, all-surfaces-corotating case. # # Constructor convenience: `surface_coupling(model, params, dp_diag; dc=0.0)` # auto-fills `scale` and `tauk` based on the model type — `scale = S^(1/3)` @@ -29,8 +25,7 @@ """ SurfaceCoupling{M<:InnerLayerModel, P} -Per-surface dispersion data: `(model, params, dp_diag, dc, scale, tauk, -q_shift)`. Calling `sc(Q)` returns the complex residual +Per-surface dispersion data: `(model, params, dp_diag, dc, scale, tauk, q_shift)`. Calling `sc(Q)` returns the complex residual ``` r(Q) = dp_diag - scale * solve_inner(model, params, Q + q_shift).tearing - dc @@ -97,15 +92,12 @@ interchange channel, which provides Glasser (Mercier) stabilization natively. A Δ_crit proxy (χ_parallel-matching offset on the diagonal) is meaningful only for tearing-only slab-layer approximations like SLAYER; for GGJ it would double-count the interchange physics. The `SurfaceCoupling` -struct's `dc` field is hard-wired to 0 here. - -`q_shift` is the real E×B Doppler offset on the layer's Q argument. GGJ carries -`tauk = 1`, so the caller must supply the shift already in GGJ's own Q units. +struct's `dc` field is hard-wired to 0 here, and so is `q_shift`: with no time +normalization, a physical E×B rotation has no image in GGJ's Q. """ -function surface_coupling(model::GGJModel, params::GGJParameters, - dp_diag::Number; q_shift::Real=0.0) +function surface_coupling(model::GGJModel, params::GGJParameters, dp_diag::Number) return SurfaceCoupling(model, params, ComplexF64(dp_diag), - 0.0, 1.0, 1.0, Float64(q_shift)) + 0.0, 1.0, 1.0, 0.0) end """ diff --git a/src/Tearing/Runner/Control.jl b/src/Tearing/Runner/Control.jl index 60ee9afaf..62d3acbdd 100644 --- a/src/Tearing/Runner/Control.jl +++ b/src/Tearing/Runner/Control.jl @@ -38,12 +38,8 @@ constructor. kinetic file at each rational surface. Rotation enters only the coupled determinant: surface `k`'s inner-layer Q is Doppler-shifted by `ΔRe(Q_k) = −tauk_k · n · Ω_E,k`, so each layer sees the mode in its own E×B - frame (TJ's `ĝ = i(Q_E − ω·tau_k)`). Must be empty or have one entry per + frame. Must be empty or have one entry per rational surface actually analysed - - `tauk_rescale` -- direction of the inter-surface Q normalization in the - coupled determinant: `:direct` (default, `Q·tauk_k/tauk_ref`, consistent - with `Q = tauk·omega` and with TJ) or `:legacy` (`Q·tauk_ref/tauk_k`, the - pre-correction behaviour, for reproducing older results only) - `mu_i` -- ion mass in proton-mass units (default 2.0 for D) - `zeff` -- effective charge - `chi_perp`, `chi_tor` -- fallback perpendicular / toroidal heat @@ -167,14 +163,6 @@ there is one consistent interface for resistive and kinetic profiles. # Ω_E from the kinetic file; either way it only Doppler-shifts the coupled determinant. omega_E_kHz::Vector{Float64} = Float64[] - # Inter-surface Q normalization in the coupled determinant. `:direct` - # (default) uses Q·tauk_k/tauk_ref: Q is defined as tauk·omega, so one - # shared physical eigenvalue maps to surface k by MULTIPLYING by tauk_k. - # `:legacy` restores the previous Q·tauk_ref/tauk_k for reproducing results - # generated before the correction; it puts the scanned frequency and that - # surface's Q_e/Q_i on opposite tauk scalings inside the same layer solve. - tauk_rescale::Symbol = :direct - profile_file::String = "" profile_group::String = "/" @@ -187,7 +175,6 @@ const _VALID_COUPLING_MODES = (:uncoupled, :coupled) const _VALID_DC_TYPES = (:none, :lar, :rfitzp, :toroidal) const _VALID_RESISTIVITY_MODELS = (:sauter, :redl, :spitzer, :spitzer_harm) const _VALID_LNLAMBDA_FORMS = (:nrl, :sauter, :wesson) -const _VALID_TAUK_RESCALE = (:legacy, :direct) function validate(ctrl::SLAYERControl) ctrl.inner_model in _VALID_INNER_MODELS || @@ -213,9 +200,6 @@ function validate(ctrl::SLAYERControl) all(isfinite, ctrl.omega_E_kHz) || throw(ArgumentError("SLAYERControl: omega_E_kHz contains a " * "non-finite entry: $(ctrl.omega_E_kHz)")) - ctrl.tauk_rescale in _VALID_TAUK_RESCALE || - throw(ArgumentError("SLAYERControl: tauk_rescale=$(ctrl.tauk_rescale) " * - "must be one of $(_VALID_TAUK_RESCALE)")) ctrl.nre >= 2 && ctrl.nim >= 2 || throw(ArgumentError("SLAYERControl: nre and nim must both be ≥ 2")) ctrl.amr_passes >= 0 || @@ -273,7 +257,7 @@ function slayer_control_from_toml(section::AbstractDict) for (k, v) in flat sym = Symbol(k) if sym in (:inner_model, :scan_mode, :coupling_mode, :dc_type, - :resistivity_model, :lnLambda_form, :tauk_rescale) + :resistivity_model, :lnLambda_form) kwargs[sym] = v isa Symbol ? v : Symbol(String(v)) elseif sym in (:Q_re_range, :Q_im_range) kwargs[sym] = _as_range(v) diff --git a/src/Tearing/Runner/HDF5Output.jl b/src/Tearing/Runner/HDF5Output.jl index 428be54c7..61d4cdb4e 100644 --- a/src/Tearing/Runner/HDF5Output.jl +++ b/src/Tearing/Runner/HDF5Output.jl @@ -110,11 +110,21 @@ 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, ψ_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")), + "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",)), + (; + long_name="mode rotation angular frequency ω = Re(Q)/τ_k of each root: each surface's E×B frame when uncoupled, the lab frame when coupled", + units="rad/s", + dims=("surface",) + ), "Roots/gamma" => (; long_name="growth rate γ = Im(Q)/τ_k of each root, positive = unstable (an e-folding rate, so no 2π distinction applies)", units="1/s", dims=("surface",)), "Roots/no_root" => (; long_name="flag: no usable dispersion root found (Q_root is NaN, ω/γ are placeholders)", dims=("surface",)), diff --git a/src/Tearing/Runner/run_slayer.jl b/src/Tearing/Runner/run_slayer.jl index 3a50b9f47..2b1469522 100644 --- a/src/Tearing/Runner/run_slayer.jl +++ b/src/Tearing/Runner/run_slayer.jl @@ -137,26 +137,28 @@ end # --------------------------------------------------------------------- # SLAYER: scale = lu^(1/3), tauk from the surface, dc from the χ‖ proxy. function _build_surface_coupling(model::SLAYERModel, params::SLAYERParameters, - dp_diag, q_shift::Real=0.0) + dp_diag, q_shift::Real) return surface_coupling(model, params, dp_diag; dc=params.dc_tmp, q_shift=q_shift) end # GGJ: scale = 1.0 (rescale_delta applied inside solve_inner), tauk = 1.0, # dc = 0 (the 4m×4m Pletzer-Dewar residual carries interchange stabilization -# natively). See the GGJ `surface_coupling` method. -function _build_surface_coupling(model::GGJModel, params::GGJParameters, - dp_diag, q_shift::Real=0.0) - return surface_coupling(model, params, dp_diag; q_shift=q_shift) -end +# natively), and no E×B shift. See the GGJ `surface_coupling` method. +_build_surface_coupling(model::GGJModel, params::GGJParameters, dp_diag, ::Real) = + surface_coupling(model, params, dp_diag) # Per-surface E×B angular frequency Ω_E per unit n [rad/s]: `control.omega_E_kHz` when set, # otherwise the kinetic file's `omega_E` sampled at each rational surface. function _omega_E_per_surface(control::SLAYERControl, n::Integer, omega_E_file::AbstractVector{<:Real}) if !isempty(control.omega_E_kHz) length(control.omega_E_kHz) == n || - throw(ArgumentError("run_slayer: omega_E_kHz has $(length(control.omega_E_kHz)) " * - "entries but $n rational surfaces were analysed. Supply one " * - "entry per surface, or leave it empty to use the kinetic file.")) + throw( + ArgumentError( + "run_slayer: omega_E_kHz has $(length(control.omega_E_kHz)) " * + "entries but $n rational surfaces were analysed. Supply one " * + "entry per surface, or leave it empty to use the kinetic file." + ) + ) return 2π * 1e3 .* control.omega_E_kHz end isempty(omega_E_file) && return zeros(Float64, n) @@ -166,17 +168,12 @@ function _omega_E_per_surface(control::SLAYERControl, n::Integer, omega_E_file:: return Float64.(omega_E_file) end -# Real Doppler offset on a surface's inner-layer Q. TJ writes ĝ = i(Q_E − ω·τ_k) with -# Q_E = τ_k·ω_E and ω_E = (m/r_s)·V_E ∝ n·Ω_E; with ĝ = −i·Q this is Q_k = τ_k·(ω − n·Ω_E). +# Real Doppler offset on a surface's inner-layer Q: Q_k = τ_k·(ω − n·Ω_E) in its E×B frame. _q_shift(p::SLAYERParameters, Ω_E::Real) = -p.tauk * p.n * Ω_E # GGJ carries no time normalization (tauk = 1), so a physical rotation has no Q-space image. _q_shift(::InnerLayerParameters, ::Real) = 0.0 -function _q_shifts(control::SLAYERControl, params, n::Integer; omega_E::AbstractVector{<:Real}=Float64[]) - Ω_E = _omega_E_per_surface(control, n, omega_E) - return Float64[_q_shift(params[k], Ω_E[k]) for k in 1:n] -end - +_q_shifts(params, Ω_E::AbstractVector{<:Real}) = Float64[_q_shift(params[k], Ω_E[k]) for k in eachindex(Ω_E)] # --------------------------------------------------------------------- # Reference-length conversion of the outer Δ' for the slab layer @@ -307,11 +304,12 @@ function run_slayer_from_inputs(params::AbstractVector{<:InnerLayerParameters}, # solved in its own plasma frame, so a shift there would only relabel the reported frequency. coupled = control.coupling_mode === :coupled !coupled && !isempty(control.omega_E_kHz) && @warn( - "SLAYER: omega_E_kHz is ignored with coupling_mode=:uncoupled; rotation only enters " * - "the coupled determinant.") - coupled && _is_ggj(model) && any(!iszero, _omega_E_per_surface(control, n, omega_E)) && @warn( - "SLAYER: E×B rotation is not applied to GGJ surfaces, which carry no time normalization.") - q_shifts = coupled ? _q_shifts(control, params, n; omega_E=omega_E) : zeros(Float64, n) + "SLAYER: omega_E_kHz is ignored with coupling_mode=:uncoupled; rotation only enters " * + "the coupled determinant.") + Ω_E = coupled ? _omega_E_per_surface(control, n, omega_E) : zeros(Float64, n) + coupled && _is_ggj(model) && any(!iszero, Ω_E) && @warn( + "SLAYER: E×B rotation is not applied to GGJ surfaces, which carry no time normalization.") + q_shifts = _q_shifts(params, Ω_E) scs = [_build_surface_coupling(model, params[k], dp[k, k], q_shifts[k]) for k in 1:n] # In the scanned (reference-normalized) Q, surface k's layer response moves by τ_ref·n·Ω_E,k. # A shift outside the scan box silently turns the coupled root into :no_root. @@ -388,8 +386,7 @@ function run_slayer_from_inputs(params::AbstractVector{<:InnerLayerParameters}, elseif control.coupling_mode === :coupled m_use = min(control.msing_max, n) - mc = multi_surface_coupling(scs, dp; ref_idx=1, msing_max=m_use, - tauk_rescale=control.tauk_rescale) + mc = multi_surface_coupling(scs, dp; ref_idx=1, msing_max=m_use) scan = _run_scan(mc, control) pthr = _pole_threshold_for(scan) ref_tauk = scs[1].tauk diff --git a/test/runtests_dispersion_coupled.jl b/test/runtests_dispersion_coupled.jl index 431d4039e..ed4991bca 100644 --- a/test/runtests_dispersion_coupled.jl +++ b/test/runtests_dispersion_coupled.jl @@ -19,7 +19,7 @@ InnerLayerResponse(m.a + m.b * ComplexF64(Q), zero(ComplexF64)) function _slayer_ref() - return slayer_parameters( + return slayer_parameters(; n_e=5.0e19, t_e=1000.0, t_i=1000.0, omega_e=-1.0e4, omega_i=5.0e3, qval=2.0, sval_r=1.0, bt=2.0, @@ -28,10 +28,10 @@ end @testset "Constructor validation" begin - sc1 = surface_coupling(LinTestModel(0.0im, 1.0+0im), nothing, - 1.0+0im; scale=1.0, tauk=1.0) - sc2 = surface_coupling(LinTestModel(0.0im, 1.0+0im), nothing, - 2.0+0im; scale=1.0, tauk=1.0) + sc1 = surface_coupling(LinTestModel(0.0im, 1.0 + 0im), nothing, + 1.0 + 0im; scale=1.0, tauk=1.0) + sc2 = surface_coupling(LinTestModel(0.0im, 1.0 + 0im), nothing, + 2.0 + 0im; scale=1.0, tauk=1.0) good_dp = ComplexF64[1.0 0.1; 0.1 2.0] mc = multi_surface_coupling([sc1, sc2], good_dp) @@ -40,25 +40,25 @@ @test size(mc.dp_matrix) == (2, 2) # 3-surface default also caps at 3 (min(3, 3) = 3) - sc3 = surface_coupling(LinTestModel(0.0im, 1.0+0im), nothing, - 3.0+0im; scale=1.0, tauk=1.0) + sc3 = surface_coupling(LinTestModel(0.0im, 1.0 + 0im), nothing, + 3.0 + 0im; scale=1.0, tauk=1.0) good_dp3 = ComplexF64[1.0 0.1 0.0; 0.1 2.0 0.0; 0.0 0.0 3.0] mc3 = multi_surface_coupling([sc1, sc2, sc3], good_dp3) @test mc3.msing_max == 3 # 4-surface case caps at 3 (the design default — Δ' beyond 3 surfaces # tends to be erratic in practice) - sc4 = surface_coupling(LinTestModel(0.0im, 1.0+0im), nothing, - 4.0+0im; scale=1.0, tauk=1.0) + sc4 = surface_coupling(LinTestModel(0.0im, 1.0 + 0im), nothing, + 4.0 + 0im; scale=1.0, tauk=1.0) good_dp4 = ComplexF64[1.0 0.0 0.0 0.0; - 0.0 2.0 0.0 0.0; - 0.0 0.0 3.0 0.0; - 0.0 0.0 0.0 4.0] + 0.0 2.0 0.0 0.0; + 0.0 0.0 3.0 0.0; + 0.0 0.0 0.0 4.0] mc4 = multi_surface_coupling([sc1, sc2, sc3, sc4], good_dp4) @test mc4.msing_max == 3 # default capped at 3 # Caller can opt in to all 4 mc4_full = multi_surface_coupling([sc1, sc2, sc3, sc4], good_dp4; - msing_max=4) + msing_max=4) @test mc4_full.msing_max == 4 # Mismatched dp size @@ -69,32 +69,32 @@ # Out-of-range ref_idx @test_throws ArgumentError multi_surface_coupling([sc1, sc2], good_dp; - ref_idx=3) + ref_idx=3) @test_throws ArgumentError multi_surface_coupling([sc1, sc2], good_dp; - ref_idx=0) + ref_idx=0) # Out-of-range msing_max @test_throws ArgumentError multi_surface_coupling([sc1, sc2], good_dp; - msing_max=3) + msing_max=3) @test_throws ArgumentError multi_surface_coupling([sc1, sc2], good_dp; - msing_max=0) + msing_max=0) end @testset "Diagonal Δ' factorizes (det = ∏ per-surface residuals)" begin # When dp_matrix is diagonal, no off-diagonal coupling exists and # the coupled determinant should reduce exactly to the product of # per-surface residuals. - sc1 = surface_coupling(LinTestModel(1.0+0im, 1.0+0im), nothing, - 5.0+0im; scale=1.0, tauk=1.0) - sc2 = surface_coupling(LinTestModel(2.0+0im, 1.0+0im), nothing, - 7.0+0im; scale=1.0, tauk=1.0) - sc3 = surface_coupling(LinTestModel(0.5+0im, 0.5+0im), nothing, - 3.0+0im; scale=1.0, tauk=1.0) + sc1 = surface_coupling(LinTestModel(1.0 + 0im, 1.0 + 0im), nothing, + 5.0 + 0im; scale=1.0, tauk=1.0) + sc2 = surface_coupling(LinTestModel(2.0 + 0im, 1.0 + 0im), nothing, + 7.0 + 0im; scale=1.0, tauk=1.0) + sc3 = surface_coupling(LinTestModel(0.5 + 0im, 0.5 + 0im), nothing, + 3.0 + 0im; scale=1.0, tauk=1.0) dp = ComplexF64[5.0 0.0 0.0; - 0.0 7.0 0.0; - 0.0 0.0 3.0] + 0.0 7.0 0.0; + 0.0 0.0 3.0] mc = multi_surface_coupling([sc1, sc2, sc3], dp) - for Q in (0.5+0im, 2.0+0.3im, -1.0-0.5im, 4.5+1.0im) + for Q in (0.5 + 0im, 2.0 + 0.3im, -1.0 - 0.5im, 4.5 + 1.0im) @test mc(Q) ≈ sc1(Q) * sc2(Q) * sc3(Q) rtol = 1e-12 end end @@ -103,23 +103,23 @@ # With Δ_inner(Q) = b·Q and dp_diag = b·Q_root for each surface, # the coupled determinant has its roots exactly at the union of # single-surface roots. - Q1, Q2 = 0.5+0.0im, 2.0+0.0im - sc1 = surface_coupling(LinTestModel(0.0im, 1.0+0im), nothing, - Q1; scale=1.0, tauk=1.0) - sc2 = surface_coupling(LinTestModel(0.0im, 1.0+0im), nothing, - Q2; scale=1.0, tauk=1.0) + Q1, Q2 = 0.5 + 0.0im, 2.0 + 0.0im + sc1 = surface_coupling(LinTestModel(0.0im, 1.0 + 0im), nothing, + Q1; scale=1.0, tauk=1.0) + sc2 = surface_coupling(LinTestModel(0.0im, 1.0 + 0im), nothing, + Q2; scale=1.0, tauk=1.0) dp = ComplexF64[real(Q1) 0.0; 0.0 real(Q2)] mc = multi_surface_coupling([sc1, sc2], dp) @test abs(mc(Q1)) < 1e-12 @test abs(mc(Q2)) < 1e-12 - @test abs(mc(0.0+0.0im)) > 0 + @test abs(mc(0.0 + 0.0im)) > 0 end @testset "Off-diagonal coupling shifts the roots away from the diagonal" begin - sc1 = surface_coupling(LinTestModel(0.0im, 1.0+0im), nothing, - 0.5+0im; scale=1.0, tauk=1.0) - sc2 = surface_coupling(LinTestModel(0.0im, 1.0+0im), nothing, - 2.0+0im; scale=1.0, tauk=1.0) + sc1 = surface_coupling(LinTestModel(0.0im, 1.0 + 0im), nothing, + 0.5 + 0im; scale=1.0, tauk=1.0) + sc2 = surface_coupling(LinTestModel(0.0im, 1.0 + 0im), nothing, + 2.0 + 0im; scale=1.0, tauk=1.0) # Coupling-free baseline dp_diag = ComplexF64[0.5 0.0; 0.0 2.0] mc_diag = multi_surface_coupling([sc1, sc2], dp_diag) @@ -137,50 +137,47 @@ end @testset "msing_max truncation uses upper-left submatrix" begin - sc1 = surface_coupling(LinTestModel(0.0im, 1.0+0im), nothing, - 1.0+0im; scale=1.0, tauk=1.0) - sc2 = surface_coupling(LinTestModel(0.0im, 1.0+0im), nothing, - 2.0+0im; scale=1.0, tauk=1.0) - sc3 = surface_coupling(LinTestModel(0.0im, 1.0+0im), nothing, - 3.0+0im; scale=1.0, tauk=1.0) + sc1 = surface_coupling(LinTestModel(0.0im, 1.0 + 0im), nothing, + 1.0 + 0im; scale=1.0, tauk=1.0) + sc2 = surface_coupling(LinTestModel(0.0im, 1.0 + 0im), nothing, + 2.0 + 0im; scale=1.0, tauk=1.0) + sc3 = surface_coupling(LinTestModel(0.0im, 1.0 + 0im), nothing, + 3.0 + 0im; scale=1.0, tauk=1.0) dp = ComplexF64[1.0 0.0 0.0; - 0.0 2.0 0.0; - 0.0 0.0 3.0] + 0.0 2.0 0.0; + 0.0 0.0 3.0] # msing_max = 1 reduces to sc1(Q) alone mc1 = multi_surface_coupling([sc1, sc2, sc3], dp; msing_max=1) - for Q in (0.0+0im, 1.0+0im, 2.0+0im) + for Q in (0.0 + 0im, 1.0 + 0im, 2.0 + 0im) @test mc1(Q) ≈ sc1(Q) end # msing_max = 2 uses the upper-left 2×2 → sc1·sc2 mc2 = multi_surface_coupling([sc1, sc2, sc3], dp; msing_max=2) - for Q in (0.0+0im, 0.5+0.5im) + for Q in (0.0 + 0im, 0.5 + 0.5im) @test mc2(Q) ≈ sc1(Q) * sc2(Q) end # msing_max = 3 (default for ≥3 surfaces) uses the full 3×3 → sc1·sc2·sc3 mc3 = multi_surface_coupling([sc1, sc2, sc3], dp) @test mc3.msing_max == 3 # min(3, 3) = 3 - for Q in (0.5+0.5im, 1.5-0.5im) + for Q in (0.5 + 0.5im, 1.5 - 0.5im) @test mc3(Q) ≈ sc1(Q) * sc2(Q) * sc3(Q) end end @testset "Per-surface Q rescaling direction" begin # Q is defined as tauk·ω, so one shared physical eigenvalue reaches - # surface k by MULTIPLYING by tauk_k: Q_k = Q · (tauk_k/tauk_ref). - # That is `:direct`, the default. `:legacy` is the pre-correction - # reciprocal, kept only to reproduce older results. - sc1 = surface_coupling(LinTestModel(0.0im, 1.0+0im), nothing, - 0.0+0im; scale=1.0, tauk=2.0) # ref tauk - sc2 = surface_coupling(LinTestModel(0.0im, 1.0+0im), nothing, - 0.0+0im; scale=1.0, tauk=4.0) + # surface k as Q_k = Q · (tauk_k/tauk_ref). + sc1 = surface_coupling(LinTestModel(0.0im, 1.0 + 0im), nothing, + 0.0 + 0im; scale=1.0, tauk=2.0) # ref tauk + sc2 = surface_coupling(LinTestModel(0.0im, 1.0 + 0im), nothing, + 0.0 + 0im; scale=1.0, tauk=4.0) dp = ComplexF64[0.0 0.0; 0.0 0.0] mc = multi_surface_coupling([sc1, sc2], dp; ref_idx=1) - @test mc.tauk_rescale === :direct - for Q in (1.0+0im, 0.5+0.3im) + for Q in (1.0 + 0im, 0.5 + 0.3im) # M[1,1] = -Q·(2/2) = -Q ; M[2,2] = -Q·(4/2) = -2Q ; det = 2Q² @test mc(Q) ≈ 2 * Q^2 rtol = 1e-12 end @@ -189,19 +186,10 @@ # tauk_k/tauk_ref rescales the scanned Q, so switching ref_idx # rescales the whole determinant by a known power of the tauk ratio. mc2 = multi_surface_coupling([sc1, sc2], dp; ref_idx=2) - for Q in (1.0+0im, 0.5+0.3im) + for Q in (1.0 + 0im, 0.5 + 0.3im) # M[1,1] = -Q·(2/4) = -Q/2 ; M[2,2] = -Q·(4/4) = -Q ; det = Q²/2 @test mc2(Q) ≈ Q^2 / 2 rtol = 1e-12 end - - # `:legacy` is the exact reciprocal of `:direct` on the diagonal, so - # for this 2-surface product it differs by (tauk_2/tauk_1)^2 = 4. - mcl = multi_surface_coupling([sc1, sc2], dp; ref_idx=1, - tauk_rescale=:legacy) - for Q in (1.0+0im, 0.5+0.3im) - @test mcl(Q) ≈ Q^2 / 2 rtol = 1e-12 - @test mc(Q) ≈ 4 * mcl(Q) rtol = 1e-12 - end end @testset "SLAYER self-consistency: known coupled root" begin @@ -211,25 +199,25 @@ p_a = _slayer_ref() p_b = _slayer_ref() m = SLAYERModel() - sc1 = surface_coupling(m, p_a, 0.0+0im) - sc2 = surface_coupling(m, p_b, 0.0+0im) + sc1 = surface_coupling(m, p_a, 0.0 + 0im) + sc2 = surface_coupling(m, p_b, 0.0 + 0im) Q_pin = 0.3 + 0.4im ref_tauk = sc1.tauk # Compute the diagonal modifications at Q_pin - Δ1 = solve_inner(m, p_a, Q_pin * (ref_tauk/sc1.tauk)).tearing * sc1.scale - Δ2 = solve_inner(m, p_b, Q_pin * (ref_tauk/sc2.tauk)).tearing * sc2.scale + Δ1 = solve_inner(m, p_a, Q_pin * (ref_tauk / sc1.tauk)).tearing * sc1.scale + Δ2 = solve_inner(m, p_b, Q_pin * (ref_tauk / sc2.tauk)).tearing * sc2.scale # Build dp such that M(Q_pin) is exactly singular. # Choose off-diagonal couplings, then set diagonals so M[k,k]=Δ_k # makes the matrix singular by setting M[1,1]·M[2,2] = M[1,2]·M[2,1]. - c12, c21 = 0.05+0im, 0.05+0im + c12, c21 = 0.05 + 0im, 0.05 + 0im # Pick M[1,1] arbitrarily, solve for M[2,2]: M11 = 0.7 + 0.0im M22 = (c12 * c21) / M11 - dp = ComplexF64[M11+Δ1 c12; - c21 M22+Δ2] + dp = ComplexF64[M11+Δ1 c12; + c21 M22+Δ2] mc = multi_surface_coupling([sc1, sc2], dp) # The constructed M(Q_pin) is exactly singular by construction @@ -241,8 +229,8 @@ @testset "GGJ surfaces flow through the coupled API" begin p = glasser_wang_2020_eq55() - sc1 = surface_coupling(GGJModel(solver=:shooting), p, -1.0+0im) - sc2 = surface_coupling(GGJModel(solver=:shooting), p, -2.0+0im) + sc1 = surface_coupling(GGJModel(; solver=:shooting), p, -1.0 + 0im) + sc2 = surface_coupling(GGJModel(; solver=:shooting), p, -2.0 + 0im) dp = ComplexF64[-1.0 0.1; 0.1 -2.0] mc = multi_surface_coupling([sc1, sc2], dp) @test mc isa MultiSurfaceCoupling @@ -252,14 +240,14 @@ @testset "Broadcast over a 2D Q grid" begin # Coupled residual must be broadcast-compatible for PR 5/6 scans. - sc1 = surface_coupling(LinTestModel(0.0im, 1.0+0im), nothing, - 0.0+0im; scale=1.0, tauk=1.0) - sc2 = surface_coupling(LinTestModel(0.0im, 1.0+0im), nothing, - 0.0+0im; scale=1.0, tauk=1.0) + sc1 = surface_coupling(LinTestModel(0.0im, 1.0 + 0im), nothing, + 0.0 + 0im; scale=1.0, tauk=1.0) + sc2 = surface_coupling(LinTestModel(0.0im, 1.0 + 0im), nothing, + 0.0 + 0im; scale=1.0, tauk=1.0) dp = ComplexF64[0.0 0.0; 0.0 0.0] mc = multi_surface_coupling([sc1, sc2], dp) - Q_grid = [(qr + qi*im) for qr in -1.0:0.5:1.0, qi in -1.0:0.5:1.0] + Q_grid = [(qr + qi * im) for qr in -1.0:0.5:1.0, qi in -1.0:0.5:1.0] det_grid = mc.(Q_grid) @test size(det_grid) == size(Q_grid) @test all(d -> d isa ComplexF64, det_grid) diff --git a/test/runtests_dispersion_coupled_full.jl b/test/runtests_dispersion_coupled_full.jl index 474f46872..d862374a3 100644 --- a/test/runtests_dispersion_coupled_full.jl +++ b/test/runtests_dispersion_coupled_full.jl @@ -31,7 +31,6 @@ @test size(mc.dp_raw) == (4, 4) @test mc.msing_max == 2 @test mc.ref_idx == 1 - @test mc.tauk_rescale === :direct # Wrong outer dim @test_throws ArgumentError multi_surface_coupling_full([sc1, sc2], @@ -44,9 +43,6 @@ dp_raw; msing_max=0) @test_throws ArgumentError multi_surface_coupling_full([sc1, sc2], dp_raw; msing_max=3) - # Unknown inter-surface Q normalization - @test_throws ArgumentError multi_surface_coupling_full([sc1, sc2], - dp_raw; tauk_rescale=:bogus) end @testset "1-surface 4×4 det matches hand computation" begin @@ -138,12 +134,8 @@ Q = 1.5 + 0.5im mc = multi_surface_coupling_full([sc1, sc2], dp_raw) - @test mc.tauk_rescale === :direct @test mc(Q) ≈ detAt(Q * (2.0 / 2.0) + s1, 0im) * detAt(Q * (4.0 / 2.0) + s2, 0im) - mcl = multi_surface_coupling_full([sc1, sc2], dp_raw; tauk_rescale=:legacy) - @test mcl(Q) ≈ detAt(Q * (2.0 / 2.0) + s1, 0im) * detAt(Q * (2.0 / 4.0) + s2, 0im) - # The shift is real: it moves the layer argument along Re(Q) only. mc0 = multi_surface_coupling_full([surface_coupling(lin, nothing, 0 + 0im; scale=1.0, tauk=1.0, dc=0.0)], Matrix{ComplexF64}(I, 2, 2)) diff --git a/test/runtests_dispersion_rotation.jl b/test/runtests_dispersion_rotation.jl index ec80b5f06..c1d2f53a2 100644 --- a/test/runtests_dispersion_rotation.jl +++ b/test/runtests_dispersion_rotation.jl @@ -3,7 +3,7 @@ using GeneralizedPerturbedEquilibrium.InnerLayer: InnerLayerModel, solve_inner using GeneralizedPerturbedEquilibrium.Dispersion using GeneralizedPerturbedEquilibrium.Tearing.Runner: SLAYERControl, - slayer_control_from_toml, validate, _q_shifts + slayer_control_from_toml, validate, _q_shifts, _omega_E_per_surface using LinearAlgebra # Linear inner layer Δ(Q) = a + b·Q makes the applied Q offset readable @@ -48,7 +48,7 @@ end @testset "Coupled determinant applies each surface's own shift" begin - # Diagonal Δ', so det = Π_k (dp_kk - Δ_k(Q·tauk_ref/tauk_k + shift_k)). + # Diagonal Δ', so det = Π_k (dp_kk - Δ_k(Q·tauk_k/tauk_ref + shift_k)). s1, s2 = 0.5, -1.25 sc1 = surface_coupling(model, nothing, 1.0 + 0im; scale=1.0, tauk=1.0, q_shift=s1) @@ -70,28 +70,29 @@ end @testset "_q_shifts: kinetic-file Ω_E by default, omega_E_kHz override, n-scaled" begin - mk(; qval, rs, m, n) = slayer_parameters(n_e=5.0e19, t_e=1000.0, t_i=1000.0, + mk(; qval, rs, m, n) = slayer_parameters(; n_e=5.0e19, t_e=1000.0, t_i=1000.0, omega_e=1.0e4, omega_i=5.0e3, qval=qval, sval_r=1.0, bt=2.0, rs=rs, R0=1.7, mu_i=2.0, zeff=1.0, chi_perp=1.0, chi_tor=1.0, m=m, n=n) params = [mk(; qval=1.5, rs=0.5, m=3, n=2), mk(; qval=2.0, rs=0.6, m=4, n=2)] Ω_file = [3.0e4, -1.0e4] # rad/s per unit n, as carried by the kinetic file + qs(ctrl; omega_E=Float64[]) = _q_shifts(params, _omega_E_per_surface(ctrl, 2, omega_E)) # No file rotation and no override: no shift anywhere. - @test _q_shifts(SLAYERControl(), params, 2) == [0.0, 0.0] + @test qs(SLAYERControl()) == [0.0, 0.0] - # File rotation by default. The mode sees n·Ω_E, and ĝ = i(Q_E − ω·τ_k) gives - # Q_k = τ_k·(ω − n·Ω_E), so the offset is −τ_k·n·Ω_E. - got = _q_shifts(SLAYERControl(), params, 2; omega_E=Ω_file) + # File rotation by default. The mode sees n·Ω_E, so Q_k = τ_k·(ω − n·Ω_E) and the offset + # is −τ_k·n·Ω_E. + got = qs(SLAYERControl(); omega_E=Ω_file) @test got ≈ [-params[1].tauk * 2 * Ω_file[1], -params[2].tauk * 2 * Ω_file[2]] # A non-empty omega_E_kHz replaces the file values, in the same per-unit-n convention. - ovr = _q_shifts(SLAYERControl(; omega_E_kHz=[1.0, -2.0]), params, 2; omega_E=Ω_file) + ovr = qs(SLAYERControl(; omega_E_kHz=[1.0, -2.0]); omega_E=Ω_file) @test ovr ≈ [-params[1].tauk * 2 * 2π * 1e3 * 1.0, -params[2].tauk * 2 * 2π * 1e3 * -2.0] # Either source must supply exactly one value per analysed surface. - @test_throws ArgumentError _q_shifts(SLAYERControl(; omega_E_kHz=[1.0]), params, 2) - @test_throws ArgumentError _q_shifts(SLAYERControl(), params, 2; omega_E=[1.0]) + @test_throws ArgumentError qs(SLAYERControl(; omega_E_kHz=[1.0])) + @test_throws ArgumentError qs(SLAYERControl(); omega_E=[1.0]) end @testset "Coupled roots do not depend on the reference surface" begin @@ -132,7 +133,7 @@ @test imag.(r1) ≈ imag.(r0) rtol = 1e-10 end - @testset "omega_E_kHz and tauk_rescale parse and validate from TOML" begin + @testset "omega_E_kHz parses and validates from TOML" begin ctrl = slayer_control_from_toml(Dict("omega_E_kHz" => [0, 3.0, -1.5])) @test ctrl.omega_E_kHz == [0.0, 3.0, -1.5] @test eltype(ctrl.omega_E_kHz) === Float64 @@ -141,26 +142,5 @@ # A non-finite shift would silently poison every Q evaluation; the # validator rejects it (TOML cannot express NaN, so go through validate). @test_throws ArgumentError validate(SLAYERControl(; omega_E_kHz=[NaN])) - - # tauk_rescale round-trips as a Symbol and rejects unknown values. - @test slayer_control_from_toml(Dict("tauk_rescale" => "legacy")).tauk_rescale === :legacy - @test SLAYERControl().tauk_rescale === :direct - @test_throws ArgumentError validate(SLAYERControl(; tauk_rescale=:sideways)) - end - - @testset "tauk_rescale flips the inter-surface Q normalization" begin - sc1 = surface_coupling(model, nothing, 1.0 + 0im; scale=1.0, tauk=1.0) - sc2 = surface_coupling(model, nothing, 2.0 + 0im; scale=1.0, tauk=2.0) - dp = ComplexF64[1.0 0.0; 0.0 2.0] - Q = 1.5 + 0.5im - - leg = multi_surface_coupling([sc1, sc2], dp; tauk_rescale=:legacy) - dir = multi_surface_coupling([sc1, sc2], dp; tauk_rescale=:direct) - @test leg.tauk_rescale === :legacy - # :legacy divides by tauk_k, :direct multiplies by it. - @test leg(Q) ≈ (1.0 - Q) * (2.0 - Q / 2) - @test dir(Q) ≈ (1.0 - Q) * (2.0 - 2Q) - @test_throws ArgumentError multi_surface_coupling([sc1, sc2], dp; - tauk_rescale=:sideways) end end From 0058655aa0f68b94825e8ed82392df6b7de5f6a7 Mon Sep 17 00:00:00 2001 From: d-burg Date: Wed, 23 Sep 2026 18:04:14 -0400 Subject: [PATCH 10/20] Tearing.Dispersion - BUGFIX! - Seed the AMR triangulation so extracted roots are reproducible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_extract_growth_rates_amr` triangulated the AMR samples with DelaunayTriangulation's default randomized insertion order, drawn from the global RNG, which Julia seeds from entropy at startup. An AMR grid is full of cocircular points, where the Delaunay triangulation is not unique, so each run could pick different diagonals and hence different contour crossings. Polished roots converge past that; the unpolished coupled root did not. On the DIII-D-like coupled deck, the identical scan samples gave γ = 190.646 or 191.888 s⁻¹ depending only on the seed (10 seeds, 5/5 split). This is the run-to-run scatter this PR reported. Threads were not the cause. The triangulation now draws its insertion order from a fixed-seed Xoshiro, and a test asserts that extraction leaves the global RNG untouched. The coupled root is now reproducible, but it still carries the triangulation's ~0.7% interpolation error until coupled root polishing exists. Co-Authored-By: Claude Opus 5.5 --- .../Dispersion/GrowthRateExtraction.jl | 5 +- src/Tearing/Runner/run_slayer.jl | 4 +- test/runtests_dispersion_amr.jl | 93 +++++++++++-------- 3 files changed, 59 insertions(+), 43 deletions(-) diff --git a/src/Tearing/Dispersion/GrowthRateExtraction.jl b/src/Tearing/Dispersion/GrowthRateExtraction.jl index 42c2c9681..45f5bc74d 100644 --- a/src/Tearing/Dispersion/GrowthRateExtraction.jl +++ b/src/Tearing/Dispersion/GrowthRateExtraction.jl @@ -22,6 +22,7 @@ using Contour using DelaunayTriangulation +using Random: Xoshiro # --------------------------------------------------------------------- # Public result struct + main entry point. @@ -955,7 +956,9 @@ function _extract_growth_rates_amr(Q::Vector{ComplexF64}, throw(ArgumentError("_extract_growth_rates_amr: need ≥ 3 points to triangulate")) pts = [(real(q), imag(q)) for q in Q] - tri = triangulate(pts) + # AMR grids are full of cocircular points, where the Delaunay triangulation is not unique and + # the randomized insertion order picks the diagonals; a fixed seed makes the contours reproducible. + tri = triangulate(pts; rng=Xoshiro(0)) # Segment types (carry complementary-field value at each endpoint) re_segs = NamedTuple{(:p1, :p2, :a1, :a2), diff --git a/src/Tearing/Runner/run_slayer.jl b/src/Tearing/Runner/run_slayer.jl index 2b1469522..07ab497b1 100644 --- a/src/Tearing/Runner/run_slayer.jl +++ b/src/Tearing/Runner/run_slayer.jl @@ -394,8 +394,8 @@ function run_slayer_from_inputs(params::AbstractVector{<:InnerLayerParameters}, # determinant det(D'−D(Q)) is ill-conditioned (its magnitude floors well # above zero), so |det|-based polishing is a no-op and the residual-scale # gate is unreliable. A σ_min-based coupled refinement is a dev follow-up; - # for now the coupled determinant uses the raw contour extraction (the - # BLAS pin in the scan still makes it thread-deterministic). + # for now the coupled root is the raw contour intersection, so it carries the + # triangulation's interpolation error (~0.7% in γ on the DIII-D-like deck). gr = find_growth_rates(scan, ref_tauk; pole_threshold=pthr, filter_above_poles=control.filter_above_poles, diff --git a/test/runtests_dispersion_amr.jl b/test/runtests_dispersion_amr.jl index 014f3d019..b2acd1065 100644 --- a/test/runtests_dispersion_amr.jl +++ b/test/runtests_dispersion_amr.jl @@ -13,7 +13,7 @@ # Small 2×2 initial grid → 9 unique corners amr = amr_scan(counting_f, (-1.0, 1.0), (-1.0, 1.0); - nre0=2, nim0=2, passes=0) + nre0=2, nim0=2, passes=0) @test amr isa AMRResult @test length(amr.cells) == 4 # 2×2 cells # Dedup: 9 unique corners (3×3) @@ -27,7 +27,7 @@ amr0 = amr_scan(f, (-1.0, 1.0), (-1.0, 1.0); nre0=4, nim0=4, passes=0) amr3 = amr_scan(f, (-1.0, 1.0), (-1.0, 1.0); nre0=4, nim0=4, passes=3) @test length(amr3.cells) > length(amr0.cells) - @test length(amr3.Q) > length(amr0.Q) + @test length(amr3.Q) > length(amr0.Q) # A 4×4 coarse grid is 16 cells; adding 3 refinement passes must # leave the total bounded by exponential growth of only the cells # bracketing the root (roughly linear in the path length). @@ -36,26 +36,26 @@ @testset "amr_scan: argument validation" begin @test_throws ArgumentError amr_scan(identity, (0.0, 1.0), (0.0, 1.0); - nre0=0, nim0=2, passes=1) + nre0=0, nim0=2, passes=1) @test_throws ArgumentError amr_scan(identity, (0.0, 1.0), (0.0, 1.0); - nre0=2, nim0=0, passes=1) + nre0=2, nim0=0, passes=1) @test_throws ArgumentError amr_scan(identity, (0.0, 1.0), (0.0, 1.0); - nre0=2, nim0=2, passes=-1) + nre0=2, nim0=2, passes=-1) end @testset "amr_scan: max_cells safety cap fires" begin # A pathological f that forces every cell to subdivide every pass f(Q) = 0.0 + 0.0im # identically zero → every cell crosses @test_throws ErrorException amr_scan(f, (-1.0, 1.0), (-1.0, 1.0); - nre0=4, nim0=4, passes=10, - max_cells=100) + nre0=4, nim0=4, passes=10, + max_cells=100) end @testset "find_growth_rates(AMR): single isolated root" begin Q_root = 0.42 + 0.27im f(Q) = ComplexF64(Q) - Q_root amr = amr_scan(f, (-1.0, 1.5), (-0.5, 1.0); - nre0=8, nim0=6, passes=4) + nre0=8, nim0=6, passes=4) result = find_growth_rates(amr, 1.0) @test result isa GrowthRateResult @test abs(result.Q_root - Q_root) < 1e-3 # AMR-resolution limited @@ -63,12 +63,25 @@ @test length(result.valid_roots) == 1 end + @testset "find_growth_rates(AMR): extraction does not draw from the global RNG" begin + # The AMR grid's cocircular points make the Delaunay triangulation non-unique, so the + # insertion order picks the diagonals and hence the contours; it must not come from the + # global RNG, which is seeded from entropy at startup. + using Random + amr = amr_scan(Q -> ComplexF64(Q) - (0.42 + 0.27im), (-1.0, 1.5), (-0.5, 1.0); nre0=8, nim0=6, passes=2) + Random.seed!(7) + find_growth_rates(amr, 1.0) + after = rand() + Random.seed!(7) + @test after == rand() + end + @testset "find_growth_rates(AMR): higher-γ root selected" begin Q1 = 0.3 + 0.5im # higher γ Q2 = -0.4 + 0.1im f(Q) = (ComplexF64(Q) - Q1) * (ComplexF64(Q) - Q2) amr = amr_scan(f, (-1.0, 1.0), (-0.3, 0.8); - nre0=10, nim0=8, passes=4) + nre0=10, nim0=8, passes=4) result = find_growth_rates(amr, 1.0) @test length(result.valid_roots) == 2 @test abs(result.Q_root - Q1) < 1e-2 @@ -79,7 +92,7 @@ Q_p = -0.5 + 0.6im f(Q) = (ComplexF64(Q) - Q_r) / (ComplexF64(Q) - Q_p) amr = amr_scan(f, (-1.5, 1.5), (-0.5, 1.5); - nre0=10, nim0=8, passes=5) + nre0=10, nim0=8, passes=5) result = find_growth_rates(amr, 1.0; pole_threshold=10.0) @test length(result.poles) >= 1 @test any(p -> abs(p - Q_p) < 0.05, result.poles) @@ -90,7 +103,7 @@ Q_root = 1.0 + 2.0im f(Q) = ComplexF64(Q) - Q_root amr = amr_scan(f, (-2.0, 3.0), (-1.0, 4.0); - nre0=8, nim0=8, passes=4) + nre0=8, nim0=8, passes=4) tauk = 5e-5 result = find_growth_rates(amr, tauk) @test result.omega_Hz ≈ real(result.Q_root) / tauk @@ -119,13 +132,13 @@ Q_root = 0.5 + 0.3im f(Q) = ComplexF64(Q) - Q_root scan = brute_force_scan(f, (-1.0, 1.0), (-0.5, 1.0); - nre=80, nim=60, threaded=false) - amr = amr_scan(f, (-1.0, 1.0), (-0.5, 1.0); - nre0=8, nim0=6, passes=4) + nre=80, nim=60, threaded=false) + amr = amr_scan(f, (-1.0, 1.0), (-0.5, 1.0); + nre0=8, nim0=6, passes=4) r_grid = find_growth_rates(scan, 1.0) - r_amr = find_growth_rates(amr, 1.0) + r_amr = find_growth_rates(amr, 1.0) @test abs(r_grid.Q_root - Q_root) < 1e-3 - @test abs(r_amr.Q_root - Q_root) < 1e-3 + @test abs(r_amr.Q_root - Q_root) < 1e-3 @test abs(r_grid.Q_root - r_amr.Q_root) < 5e-3 end @@ -139,23 +152,23 @@ InnerLayerResponse(m.a + m.b * ComplexF64(Q), zero(ComplexF64)) Q_pin = 0.7 - 0.3im - sc = surface_coupling(LinModel(0.0im, 1.0+0im), nothing, - Q_pin; scale=1.0, tauk=1.0) + sc = surface_coupling(LinModel(0.0im, 1.0 + 0im), nothing, + Q_pin; scale=1.0, tauk=1.0) amr = amr_scan(sc, (-0.5, 1.5), (-1.0, 0.5); - nre0=8, nim0=6, passes=4) + nre0=8, nim0=6, passes=4) r = find_growth_rates(amr, sc.tauk) @test abs(r.Q_root - Q_pin) < 1e-2 # Multi-surface coupled scan through AMR Q_a, Q_b = 0.7 - 0.3im, -0.4 + 0.5im - sc1 = surface_coupling(LinModel(0.0im, 1.0+0im), nothing, - ComplexF64(0); scale=1.0, tauk=1.0) - sc2 = surface_coupling(LinModel(0.0im, 1.0+0im), nothing, - ComplexF64(0); scale=1.0, tauk=1.0) + sc1 = surface_coupling(LinModel(0.0im, 1.0 + 0im), nothing, + ComplexF64(0); scale=1.0, tauk=1.0) + sc2 = surface_coupling(LinModel(0.0im, 1.0 + 0im), nothing, + ComplexF64(0); scale=1.0, tauk=1.0) dp = ComplexF64[Q_a 0.0; 0.0 Q_b] mc = multi_surface_coupling([sc1, sc2], dp) amr_c = amr_scan(mc, (-1.0, 1.5), (-1.0, 1.0); - nre0=10, nim0=8, passes=4) + nre0=10, nim0=8, passes=4) r_c = find_growth_rates(amr_c, mc.surfaces[mc.ref_idx].tauk) @test abs(r_c.Q_root - Q_b) < 1e-2 # higher-γ root end @@ -175,15 +188,15 @@ # otherwise crosses the entire real axis). f(Q) = (ComplexF64(Q) - 0.0) / (ComplexF64(Q) - (-50.0)) + (1.0 + 1.0im) boxes = [((-75.0, -25.0), (-25.0, 25.0)), - ((-25.0, 25.0), (-25.0, 25.0)), - (( 25.0, 75.0), (-25.0, 25.0))] + ((-25.0, 25.0), (-25.0, 25.0)), + ((25.0, 75.0), (-25.0, 25.0))] result = multi_box_amr_scan(f, boxes; - pole_magnitude_threshold=10.0, - prescreen_nre=25, prescreen_nim=25, - nre0=25, nim0=25, passes=2, - max_cells=100_000, - max_cells_action=:warn_truncate, - parallel=false) + pole_magnitude_threshold=10.0, + prescreen_nre=25, prescreen_nim=25, + nre0=25, nim0=25, passes=2, + max_cells=100_000, + max_cells_action=:warn_truncate, + parallel=false) @test result isa MultiBoxAMRResult @test length(result.box_results) == 3 @test length(result.box_activity) == 3 @@ -212,15 +225,15 @@ # independent of sign-change tests. g(Q) = 1000.0 / (ComplexF64(Q) - (-50.0))^2 + (5.0 + 5.0im) boxes = [((-75.0, -25.0), (-25.0, 25.0)), - ((-25.0, 25.0), (-25.0, 25.0)), - (( 25.0, 75.0), (-25.0, 25.0))] + ((-25.0, 25.0), (-25.0, 25.0)), + ((25.0, 75.0), (-25.0, 25.0))] result = multi_box_amr_scan(g, boxes; - pole_magnitude_threshold=50.0, - prescreen_nre=25, prescreen_nim=25, - nre0=25, nim0=25, passes=1, - max_cells=100_000, - max_cells_action=:warn_truncate, - parallel=false) + pole_magnitude_threshold=50.0, + prescreen_nre=25, prescreen_nim=25, + nre0=25, nim0=25, passes=1, + max_cells=100_000, + max_cells_action=:warn_truncate, + parallel=false) @test result.box_activity[1] != NoActivity @test result.box_activity[2] == NoActivity @test result.box_activity[3] == NoActivity From c03a4cb24a85b6145861d6a4060bfe5e312592b1 Mon Sep 17 00:00:00 2001 From: d-burg Date: Fri, 25 Sep 2026 11:04:39 -0400 Subject: [PATCH 11/20] Tearing - BUGFIX - Guard rotation at n=0 and fix coupled SLAYER docs and rescale test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Throw when a surface has E×B rotation but SLAYERParameters.n < 1, instead of silently zeroing the Doppler offset. - Put the toroidal mode number back into the documented q_shift = −τ_k·n·Ω_E. - State the omega_E_kHz units correctly (per unit n like the kinetic file's omega_E, but kHz rather than rad/s) and drop the duplicated inline comment. - Define the coupled reference surface once and word the Doppler-window check as a heuristic; drop the per-run Doppler @info (q_shift is in the output). - Drop deck-specific numbers from the coupled-root comment and example deck; note that coupled runs need a wider Q_re_range. - Fix the known-coupled-root test to use the tauk_k/tauk_ref direction and a second surface with a different tauk, so it exercises the rescale. Co-Authored-By: Claude Opus 5.5 --- examples/DIIID-like_SLAYER_example/gpec.toml | 2 +- src/Tearing/Dispersion/SurfaceCoupling.jl | 8 +++--- src/Tearing/Runner/Control.jl | 16 +++++------- src/Tearing/Runner/run_slayer.jl | 27 ++++++++++---------- test/runtests_dispersion_coupled.jl | 13 +++++++--- test/runtests_slayer_runner.jl | 5 ++++ 6 files changed, 40 insertions(+), 31 deletions(-) diff --git a/examples/DIIID-like_SLAYER_example/gpec.toml b/examples/DIIID-like_SLAYER_example/gpec.toml index b89195741..62f80b881 100644 --- a/examples/DIIID-like_SLAYER_example/gpec.toml +++ b/examples/DIIID-like_SLAYER_example/gpec.toml @@ -78,7 +78,7 @@ chi_tor = 1.0 # fallback only; the kinetic file supplies # E×B rotation only Doppler-shifts the coupled determinant, and by default comes from the kinetic # file's omega_E. Uncomment omega_E_kHz to override it for a rotation scan (one entry per surface, # 2/1…7/1 here). In the scanned Re(Q), normalized to the 2/1, every surface's layer response moves by -# +τ_2/1·n·Ω_E: +0.42 per kHz per unit n here, so check roots stay inside Q_re_range. +# +τ_2/1·n·Ω_E, so check roots stay inside Q_re_range; coupled runs usually need a wider Q_re_range. # omega_E_kHz = [0.0, 3.0, 0.0, 0.0, 0.0, 0.0] # override of the per-surface E×B rotation Ω_E/2π per unit n [kHz], core→edge; empty uses the kinetic file pole_threshold_adaptive = true # SLAYER |Δ| spans many orders; adapt to 10·median(|Δ|) filter_above_poles = true # Discard roots above the highest pole γ diff --git a/src/Tearing/Dispersion/SurfaceCoupling.jl b/src/Tearing/Dispersion/SurfaceCoupling.jl index 38be0cadf..554071281 100644 --- a/src/Tearing/Dispersion/SurfaceCoupling.jl +++ b/src/Tearing/Dispersion/SurfaceCoupling.jl @@ -12,9 +12,9 @@ # multi-surface `MultiSurfaceCoupling` to rescale Q between each surface's # normalization. # -# `q_shift = −tau_k·omega_E_k` is a real, additive offset on the layer's Q argument that puts -# the mode frequency in this surface's E×B frame, `Q_k = tau_k·(omega − omega_E_k)`; zero is -# the static, all-surfaces-corotating case. +# `q_shift = −tau_k·n·Omega_E_k` (Omega_E per unit toroidal mode number n) is a real, additive +# offset on the layer's Q argument that puts the mode frequency in this surface's E×B frame, +# `Q_k = tau_k·(omega − n·Omega_E_k)`; zero is the static, all-surfaces-corotating case. # # Constructor convenience: `surface_coupling(model, params, dp_diag; dc=0.0)` # auto-fills `scale` and `tauk` based on the model type — `scale = S^(1/3)` @@ -69,7 +69,7 @@ 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. `q_shift` is the real E×B Doppler offset on -the layer's Q argument (`-tauk * omega_E`); it defaults to zero. +the layer's Q argument (`-tauk * n * Omega_E`, Omega_E per unit n); it defaults to zero. """ function surface_coupling(model::SLAYERModel, params::SLAYERParameters, dp_diag::Number; dc::Real=0.0, q_shift::Real=0.0) diff --git a/src/Tearing/Runner/Control.jl b/src/Tearing/Runner/Control.jl index 62d3acbdd..6c283c222 100644 --- a/src/Tearing/Runner/Control.jl +++ b/src/Tearing/Runner/Control.jl @@ -32,14 +32,12 @@ constructor. - `bt` -- toroidal field `[T]`. `nothing` (default) resolves the physical `B_T = F(ψ)/(2π·R₀)` per surface from the equilibrium's F-spline; a scalar or a callable of `psi` overrides it - - `omega_E_kHz` -- optional override of the per-surface E×B rotation, as the - frequency Ω_E/2π per unit toroidal mode number in kHz (the kinetic file's - `omega_E` convention), ordered core→edge. Empty (default) takes Ω_E from the - kinetic file at each rational surface. Rotation enters only the coupled - determinant: surface `k`'s inner-layer Q is Doppler-shifted by - `ΔRe(Q_k) = −tauk_k · n · Ω_E,k`, so each layer sees the mode in its own E×B - frame. Must be empty or have one entry per - rational surface actually analysed + - `omega_E_kHz` -- optional override of the per-surface E×B rotation, ordered core→edge. + Per unit toroidal mode number like the kinetic file's `omega_E`, but given as Ω_E/2π in + kHz rather than the file's rad/s. Empty (default) takes Ω_E from the kinetic file at each + rational surface. Rotation enters only the coupled determinant: surface `k`'s inner-layer + Q is Doppler-shifted by `ΔRe(Q_k) = −tauk_k · n · Ω_E,k`, so each layer sees the mode in + its own E×B frame. Must be empty or have one entry per rational surface actually analysed - `mu_i` -- ion mass in proton-mass units (default 2.0 for D) - `zeff` -- effective charge - `chi_perp`, `chi_tor` -- fallback perpendicular / toroidal heat @@ -159,8 +157,6 @@ there is one consistent interface for resistive and kinetic profiles. # / failed-Δ'-BVP surface, not a real root. Flagged `:spurious`. validity_rtol::Float64 = 1e-3 - # Override for the per-surface E×B rotation Ω_E/2π per unit n [kHz], core→edge. Empty takes - # Ω_E from the kinetic file; either way it only Doppler-shifts the coupled determinant. omega_E_kHz::Vector{Float64} = Float64[] profile_file::String = "" diff --git a/src/Tearing/Runner/run_slayer.jl b/src/Tearing/Runner/run_slayer.jl index 07ab497b1..29acf3a82 100644 --- a/src/Tearing/Runner/run_slayer.jl +++ b/src/Tearing/Runner/run_slayer.jl @@ -169,7 +169,11 @@ function _omega_E_per_surface(control::SLAYERControl, n::Integer, omega_E_file:: end # Real Doppler offset on a surface's inner-layer Q: Q_k = τ_k·(ω − n·Ω_E) in its E×B frame. -_q_shift(p::SLAYERParameters, Ω_E::Real) = -p.tauk * p.n * Ω_E +function _q_shift(p::SLAYERParameters, Ω_E::Real) + iszero(Ω_E) || p.n >= 1 || + throw(ArgumentError("run_slayer: E×B rotation Ω_E=$(Ω_E) rad/s needs the toroidal mode number, but SLAYERParameters has n=$(p.n)")) + return -p.tauk * p.n * Ω_E +end # GGJ carries no time normalization (tauk = 1), so a physical rotation has no Q-space image. _q_shift(::InnerLayerParameters, ::Real) = 0.0 @@ -311,20 +315,18 @@ function run_slayer_from_inputs(params::AbstractVector{<:InnerLayerParameters}, "SLAYER: E×B rotation is not applied to GGJ surfaces, which carry no time normalization.") q_shifts = _q_shifts(params, Ω_E) scs = [_build_surface_coupling(model, params[k], dp[k, k], q_shifts[k]) for k in 1:n] - # In the scanned (reference-normalized) Q, surface k's layer response moves by τ_ref·n·Ω_E,k. - # A shift outside the scan box silently turns the coupled root into :no_root. + # Coupled Q is scanned in the reference surface's normalization. + ref_idx = 1 + # In the scanned Q, surface k's layer response moves by τ_ref·n·Ω_E,k. Heuristic check only: + # the root also carries the diamagnetic Re(Q), so a window can pass and still miss it. if any(!iszero, q_shifts) m_use = min(control.msing_max, n) - doppler = [-q_shifts[k] * scs[1].tauk / scs[k].tauk for k in 1:m_use] + doppler = [-q_shifts[k] * scs[ref_idx].tauk / scs[k].tauk for k in 1:m_use] lo, hi = control.Q_re_range any(d -> !(lo <= d <= hi), doppler) && @warn( "SLAYER: E×B Doppler offsets Re(Q)=$(round.(doppler; digits=3)) fall outside " * "Q_re_range=$(control.Q_re_range); widen it or the coupled root may be missed.") end - any(!iszero, q_shifts) && @info( - "SLAYER: coupled determinant Doppler-shifted by per-surface E×B rotation " * - "(" * (isempty(control.omega_E_kHz) ? "kinetic file" : "omega_E_kHz override") * ") " * - "→ ΔRe(Q)=$(round.(q_shifts; digits=4))") # Per-surface resistive layer thickness [m] via the del_s Riccati solve. # Independent of the dispersion scan / coupling mode — a pure diagnostic. @@ -386,16 +388,15 @@ function run_slayer_from_inputs(params::AbstractVector{<:InnerLayerParameters}, elseif control.coupling_mode === :coupled m_use = min(control.msing_max, n) - mc = multi_surface_coupling(scs, dp; ref_idx=1, msing_max=m_use) + mc = multi_surface_coupling(scs, dp; ref_idx=ref_idx, msing_max=m_use) scan = _run_scan(mc, control) pthr = _pole_threshold_for(scan) - ref_tauk = scs[1].tauk + ref_tauk = scs[ref_idx].tauk # Coupled path: no root polishing / validity gate. The m×m coupled # determinant det(D'−D(Q)) is ill-conditioned (its magnitude floors well # above zero), so |det|-based polishing is a no-op and the residual-scale - # gate is unreliable. A σ_min-based coupled refinement is a dev follow-up; - # for now the coupled root is the raw contour intersection, so it carries the - # triangulation's interpolation error (~0.7% in γ on the DIII-D-like deck). + # gate is unreliable. The coupled root is therefore the raw contour + # intersection and carries the triangulation's interpolation error. gr = find_growth_rates(scan, ref_tauk; pole_threshold=pthr, filter_above_poles=control.filter_above_poles, diff --git a/test/runtests_dispersion_coupled.jl b/test/runtests_dispersion_coupled.jl index ed4991bca..2c40e5c0c 100644 --- a/test/runtests_dispersion_coupled.jl +++ b/test/runtests_dispersion_coupled.jl @@ -196,18 +196,25 @@ # Build a 2-surface SLAYER MultiSurfaceCoupling, evaluate at # Q_pin, and back-fill dp_matrix so that det(M(Q_pin)) = 0 # exactly. + # A second surface with different n_e and T_e, so tauk differs and the rescale is exercised. p_a = _slayer_ref() - p_b = _slayer_ref() + p_b = slayer_parameters(; + n_e=2.0e19, t_e=500.0, t_i=500.0, + omega_e=-1.0e4, omega_i=5.0e3, + qval=3.0, sval_r=1.5, bt=2.0, + rs=0.6, R0=1.7, mu_i=2.0, zeff=1.0, + chi_perp=1.0, chi_tor=1.0, m=3, n=1) m = SLAYERModel() sc1 = surface_coupling(m, p_a, 0.0 + 0im) sc2 = surface_coupling(m, p_b, 0.0 + 0im) + @test !(sc2.tauk ≈ sc1.tauk) Q_pin = 0.3 + 0.4im ref_tauk = sc1.tauk # Compute the diagonal modifications at Q_pin - Δ1 = solve_inner(m, p_a, Q_pin * (ref_tauk / sc1.tauk)).tearing * sc1.scale - Δ2 = solve_inner(m, p_b, Q_pin * (ref_tauk / sc2.tauk)).tearing * sc2.scale + Δ1 = solve_inner(m, p_a, Q_pin * (sc1.tauk / ref_tauk)).tearing * sc1.scale + Δ2 = solve_inner(m, p_b, Q_pin * (sc2.tauk / ref_tauk)).tearing * sc2.scale # Build dp such that M(Q_pin) is exactly singular. # Choose off-diagonal couplings, then set diagonals so M[k,k]=Δ_k diff --git a/test/runtests_slayer_runner.jl b/test/runtests_slayer_runner.jl index 76f5283c7..6864bfcc6 100644 --- a/test/runtests_slayer_runner.jl +++ b/test/runtests_slayer_runner.jl @@ -161,6 +161,11 @@ # τ_ref·n·Ω_E = 1e-4 · 3e4 = 3 lies outside Re(Q) ∈ [-1, 1]. @test_logs (:warn, r"outside Q_re_range") match_mode=:any run_slayer_from_inputs(params, dpm, SLAYERControl(; coupling_mode=:coupled, grid...); omega_E=[3.0e4, 0.0]) + + # Rotation without a toroidal mode number would silently vanish, so it is an error. + params_n0 = [_mk_params(; rs=0.5, lu=1.0e7, tauk=1.0e-4, m=2, n=0, ising=1)] + @test_throws "needs the toroidal mode number" run_slayer_from_inputs(params_n0, ComplexF64[-2.0;;], + SLAYERControl(; coupling_mode=:coupled, grid...); omega_E=[2.0e3]) end @testset "run_slayer_from_inputs: disabled path is a no-op" begin From 6fe8b5af229ec31e0f98a684089098108c201384 Mon Sep 17 00:00:00 2001 From: d-burg Date: Fri, 25 Sep 2026 11:09:28 -0400 Subject: [PATCH 12/20] Repo - MINOR - Apply the formatter to the audit fixes Co-Authored-By: Claude Opus 5.5 --- test/runtests_slayer_runner.jl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/runtests_slayer_runner.jl b/test/runtests_slayer_runner.jl index 6864bfcc6..c4e0a62bf 100644 --- a/test/runtests_slayer_runner.jl +++ b/test/runtests_slayer_runner.jl @@ -159,7 +159,7 @@ # A Doppler offset beyond the scan box is flagged rather than silently losing the root: # τ_ref·n·Ω_E = 1e-4 · 3e4 = 3 lies outside Re(Q) ∈ [-1, 1]. - @test_logs (:warn, r"outside Q_re_range") match_mode=:any run_slayer_from_inputs(params, dpm, + @test_logs (:warn, r"outside Q_re_range") match_mode = :any run_slayer_from_inputs(params, dpm, SLAYERControl(; coupling_mode=:coupled, grid...); omega_E=[3.0e4, 0.0]) # Rotation without a toroidal mode number would silently vanish, so it is an error. @@ -214,7 +214,7 @@ 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, alpha_mercier=0.5) - dp1 = ComplexF64[5.0+0im;;] + 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 From 370671f54e56b6d96d4508652838d22aae116298 Mon Sep 17 00:00:00 2001 From: d-burg Date: Fri, 25 Sep 2026 15:37:44 -0400 Subject: [PATCH 13/20] Utilities - REFACTOR! - Rename the KineticProfiles rotation field omega to omega_E MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The field and keyword now carry the same name as the kinetic-file dataset they are read from, Ω_E per unit n in rad/s. Co-Authored-By: Claude Opus 5.5 --- src/Tearing/Runner/run_slayer.jl | 8 ++++---- src/Utilities/KineticProfiles.jl | 18 +++++++++--------- test/runtests_kinetic_profiles.jl | 10 +++++----- test/runtests_resist_eval.jl | 6 +++--- test/runtests_slayer_inputs.jl | 2 +- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/Tearing/Runner/run_slayer.jl b/src/Tearing/Runner/run_slayer.jl index 29acf3a82..fee0371ea 100644 --- a/src/Tearing/Runner/run_slayer.jl +++ b/src/Tearing/Runner/run_slayer.jl @@ -34,12 +34,12 @@ function _load_profiles(control::SLAYERControl, dir_path::AbstractString) error("run_slayer: kinetic file '$path' is missing required " * "dataset '$name' for the SLAYER inner layer.") end - # `omega` carries the E×B rotation Ω_E (per unit n) when the file has it; ω_*e/ω_*i are + # `omega_E` carries the E×B rotation Ω_E (per unit n) when the file has it; ω_*e/ω_*i are # derived per surface from the density and temperature splines by `build_slayer_inputs`. npsi = length(data.psi) - omega = data.omega_E === nothing ? zeros(npsi) : data.omega_E + omega_E = data.omega_E === nothing ? zeros(npsi) : data.omega_E profiles = KineticProfiles(; psi=data.psi, n_e=data.n_e, T_e=data.T_e, - T_i=data.T_i, omega=omega) + T_i=data.T_i, omega_E=omega_E) # χ⊥(ψ)/χ_φ(ψ) splines from the file. A χ array that is absent OR all-zero # is treated as "not provided" — χ must be positive (χ=0 ⇒ τ_⊥→∞), and the @@ -556,6 +556,6 @@ function run_slayer(equil, surfaces::AbstractVector, delta_prime_matrix::Abstrac rational_psi = Float64[surfaces[p.ising].psifac for p in params] rational_q = Float64[surfaces[p.ising].q for p in params] # E×B rotation per unit n at each surface, for the coupled determinant's Doppler shifts. - omega_E = Float64[profiles.omega(ψ) for ψ in rational_psi] + omega_E = Float64[profiles.omega_E(ψ) for ψ in rational_psi] return run_slayer_from_inputs(params, dp, control; rational_psi=rational_psi, rational_q=rational_q, omega_E=omega_E) end diff --git a/src/Utilities/KineticProfiles.jl b/src/Utilities/KineticProfiles.jl index 0272a0d2f..217a674d2 100644 --- a/src/Utilities/KineticProfiles.jl +++ b/src/Utilities/KineticProfiles.jl @@ -19,9 +19,9 @@ the normalized poloidal flux ψ ∈ [0, 1]. | `n_e` | electron density | m⁻³ | | `T_e` | electron temperature | eV | | `T_i` | ion temperature | eV | -| `omega` | E×B rotation Ω_E, per unit n | rad/s | +| `omega_E` | E×B rotation Ω_E, per unit n | rad/s | -Construct via the keyword constructor `KineticProfiles(; psi, n_e, T_e, T_i, omega)` with +Construct via the keyword constructor `KineticProfiles(; psi, n_e, T_e, T_i, omega_E)` with matched-length vectors. The SLAYER runner builds this object from a standardized kinetic-profile file via `Equilibrium.read_kinetic_file`. Diamagnetic frequencies are not carried here: `build_slayer_inputs` derives ω_*e and ω_*i per surface from the density and @@ -30,24 +30,24 @@ temperature splines. Evaluate all profiles at a given ψ via the call operator: ```julia -vals = kp(0.5) # NamedTuple(n_e=..., T_e=..., T_i=..., omega=...) +vals = kp(0.5) # NamedTuple(n_e=..., T_e=..., T_i=..., omega_E=...) ``` """ struct KineticProfiles{S} n_e::S T_e::S T_i::S - omega::S + omega_E::S end function KineticProfiles(; psi::AbstractVector{<:Real}, n_e::AbstractVector{<:Real}, T_e::AbstractVector{<:Real}, T_i::AbstractVector{<:Real}, - omega::AbstractVector{<:Real}) + omega_E::AbstractVector{<:Real}) xs = collect(Float64.(psi)) for (name, v) in (("n_e", n_e), ("T_e", T_e), ("T_i", T_i), - ("omega", omega)) + ("omega_E", omega_E)) length(v) == length(xs) || throw(ArgumentError("KineticProfiles: length($name) = $(length(v)) " * "≠ length(psi) = $(length(xs))")) @@ -55,18 +55,18 @@ function KineticProfiles(; psi::AbstractVector{<:Real}, return KineticProfiles(cubic_interp(xs, Float64.(n_e)), cubic_interp(xs, Float64.(T_e)), cubic_interp(xs, Float64.(T_i)), - cubic_interp(xs, Float64.(omega))) + cubic_interp(xs, Float64.(omega_E))) end """ (kp::KineticProfiles)(psi::Real) -> NamedTuple Evaluate all profiles at `psi` and return them as a NamedTuple with fields -`(n_e, T_e, T_i, omega)`. +`(n_e, T_e, T_i, omega_E)`. """ (kp::KineticProfiles)(psi::Real) = ( n_e=kp.n_e(psi), T_e=kp.T_e(psi), T_i=kp.T_i(psi), - omega=kp.omega(psi) + omega_E=kp.omega_E(psi) ) diff --git a/test/runtests_kinetic_profiles.jl b/test/runtests_kinetic_profiles.jl index 583c345df..89e364945 100644 --- a/test/runtests_kinetic_profiles.jl +++ b/test/runtests_kinetic_profiles.jl @@ -10,7 +10,7 @@ "n_e" => fill(5.0e19, length(psi)), "T_e" => 1000.0 .* (1.0 .- 0.7 .* psi), "T_i" => 1200.0 .* (1.0 .- 0.6 .* psi), - "omega" => 1.0e4 .* psi + "omega_E" => 1.0e4 .* psi ) ) end @@ -18,20 +18,20 @@ @testset "kwarg constructor + evaluation" begin psi, d = _synthetic() kp = KineticProfiles(; psi=psi, n_e=d["n_e"], T_e=d["T_e"], - T_i=d["T_i"], omega=d["omega"]) + T_i=d["T_i"], omega_E=d["omega_E"]) # Exact recovery at a node vals = kp(0.5) @test vals.n_e ≈ 5.0e19 @test vals.T_e ≈ 1000.0 * (1 - 0.7 * 0.5) @test vals.T_i ≈ 1200.0 * (1 - 0.6 * 0.5) - @test vals.omega ≈ 1.0e4 * 0.5 + @test vals.omega_E ≈ 1.0e4 * 0.5 # Smooth interpolation between nodes vals2 = kp(0.25) @test vals2.T_e ≈ 1000.0 * (1 - 0.7 * 0.25) rtol = 1e-6 # NamedTuple fields - @test keys(vals) == (:n_e, :T_e, :T_i, :omega) + @test keys(vals) == (:n_e, :T_e, :T_i, :omega_E) end @testset "length mismatch raises" begin @@ -41,6 +41,6 @@ n_e=fill(1.0, length(psi) - 1), # wrong length T_e=fill(1000.0, length(psi)), T_i=fill(1000.0, length(psi)), - omega=fill(0.0, length(psi))) + omega_E=fill(0.0, length(psi))) end end diff --git a/test/runtests_resist_eval.jl b/test/runtests_resist_eval.jl index f7cbd995e..8ce0c4898 100644 --- a/test/runtests_resist_eval.jl +++ b/test/runtests_resist_eval.jl @@ -105,7 +105,7 @@ n_e=fill(5.0e19, length(psi_pts)), T_e=1000.0 .* (1.0 .- 0.7 .* psi_pts), T_i=1000.0 .* (1.0 .- 0.6 .* psi_pts), - omega=fill(0.0, length(psi_pts))) + omega_E=fill(0.0, length(psi_pts))) dq = deriv_view(equil.profiles.q_spline, 1) s1 = SingType(; psifac=0.3, rho=sqrt(0.3), m=[2], n=[1], @@ -147,7 +147,7 @@ n = length(psi_pts) profiles = KineticProfiles(; psi=psi_pts, n_e=fill(5.0e19, n), T_e=fill(1000.0, n), T_i=fill(1000.0, n), - omega=fill(0.0, n)) + omega_E=fill(0.0, n)) s_unpop = SingType(; psifac=0.5, rho=sqrt(0.5), m=[2], n=[1], q=2.0, q1=1.0, @@ -166,7 +166,7 @@ n_e=fill(5.0e19, length(psi_pts)), T_e=1000.0 .* (1.0 .- 0.7 .* psi_pts), T_i=fill(1000.0, length(psi_pts)), - omega=fill(0.0, length(psi_pts))) + omega_E=fill(0.0, length(psi_pts))) dq = deriv_view(equil.profiles.q_spline, 1) s1 = SingType(; psifac=0.3, rho=sqrt(0.3), m=[2], n=[1], diff --git a/test/runtests_slayer_inputs.jl b/test/runtests_slayer_inputs.jl index da7bef7c1..b499bb13d 100644 --- a/test/runtests_slayer_inputs.jl +++ b/test/runtests_slayer_inputs.jl @@ -20,7 +20,7 @@ n_e=fill(5.0e19, length(psi_pts)), T_e=1000.0 .* (1.0 .- 0.7 .* psi_pts), T_i=1000.0 .* (1.0 .- 0.6 .* psi_pts), - omega=fill(0.0, length(psi_pts))) + omega_E=fill(0.0, length(psi_pts))) # Helper to build a minimal SingType without touching unused fields _mk_sing(; psi, q, q1, m, n, delta_prime=-10.0+0im) = SingType( From 105d2cfc795c826da20324df66b55058e35daaca Mon Sep 17 00:00:00 2001 From: d-burg Date: Fri, 25 Sep 2026 15:40:51 -0400 Subject: [PATCH 14/20] Tearing - FEATURE! - Key omega_E_kHz by m/n and record the resolved rotation per surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit omega_E_kHz is now a TOML table keyed by the surface's m/n, e.g. {"2/1" = 0.0, "3/1" = 3.0}; unlisted surfaces keep the kinetic file's Ω_E and an m/n matching no analysed surface is an error. The positional list form is removed. The Ω_E actually resolved on each surface is carried as SLAYERResult.omega_E and written to Tearing/PerSurface/omega_E [rad/s, per unit n] in both coupling modes; PerSurface/q_shift stays the applied Doppler offset (zero when uncoupled). Co-Authored-By: Claude Opus 5.5 --- examples/DIIID-like_SLAYER_example/gpec.toml | 8 +-- src/Tearing/Runner/Control.jl | 36 +++++++++---- src/Tearing/Runner/HDF5Output.jl | 5 +- src/Tearing/Runner/Result.jl | 8 ++- src/Tearing/Runner/run_slayer.jl | 54 +++++++++++--------- test/runtests_dispersion_rotation.jl | 27 ++++++---- test/runtests_slayer_runner.jl | 12 ++++- 7 files changed, 100 insertions(+), 50 deletions(-) diff --git a/examples/DIIID-like_SLAYER_example/gpec.toml b/examples/DIIID-like_SLAYER_example/gpec.toml index 62f80b881..9f726c479 100644 --- a/examples/DIIID-like_SLAYER_example/gpec.toml +++ b/examples/DIIID-like_SLAYER_example/gpec.toml @@ -76,10 +76,10 @@ zeff = 1.0 # Effective charge chi_perp = 1.0 # fallback only; the kinetic file supplies χ⊥(ψ) chi_tor = 1.0 # fallback only; the kinetic file supplies χ_φ(ψ) # E×B rotation only Doppler-shifts the coupled determinant, and by default comes from the kinetic -# file's omega_E. Uncomment omega_E_kHz to override it for a rotation scan (one entry per surface, -# 2/1…7/1 here). In the scanned Re(Q), normalized to the 2/1, every surface's layer response moves by -# +τ_2/1·n·Ω_E, so check roots stay inside Q_re_range; coupled runs usually need a wider Q_re_range. -# omega_E_kHz = [0.0, 3.0, 0.0, 0.0, 0.0, 0.0] # override of the per-surface E×B rotation Ω_E/2π per unit n [kHz], core→edge; empty uses the kinetic file +# file's omega_E. Uncomment omega_E_kHz to override it on chosen surfaces for a rotation scan; unlisted +# surfaces keep the file's value. In the scanned Re(Q), normalized to the 2/1, every surface's layer +# response moves by +τ_2/1·n·Ω_E, so check roots stay inside Q_re_range; coupled runs usually need a wider Q_re_range. +# omega_E_kHz = {"2/1" = 0.0, "3/1" = 3.0} # override of the E×B rotation Ω_E/2π per unit n [kHz], keyed by m/n; unlisted surfaces use the kinetic file pole_threshold_adaptive = true # SLAYER |Δ| spans many orders; adapt to 10·median(|Δ|) filter_above_poles = true # Discard roots above the highest pole γ filter_outside_re = true # Condition the above-pole filter on the +γ step exiting the Re(Δ)=0 contour loop diff --git a/src/Tearing/Runner/Control.jl b/src/Tearing/Runner/Control.jl index 6c283c222..6e9e0bebc 100644 --- a/src/Tearing/Runner/Control.jl +++ b/src/Tearing/Runner/Control.jl @@ -32,12 +32,13 @@ constructor. - `bt` -- toroidal field `[T]`. `nothing` (default) resolves the physical `B_T = F(ψ)/(2π·R₀)` per surface from the equilibrium's F-spline; a scalar or a callable of `psi` overrides it - - `omega_E_kHz` -- optional override of the per-surface E×B rotation, ordered core→edge. - Per unit toroidal mode number like the kinetic file's `omega_E`, but given as Ω_E/2π in - kHz rather than the file's rad/s. Empty (default) takes Ω_E from the kinetic file at each - rational surface. Rotation enters only the coupled determinant: surface `k`'s inner-layer - Q is Doppler-shifted by `ΔRe(Q_k) = −tauk_k · n · Ω_E,k`, so each layer sees the mode in - its own E×B frame. Must be empty or have one entry per rational surface actually analysed + - `omega_E_kHz` -- optional override of the per-surface E×B rotation, keyed by the surface's + `"m/n"` (TOML: `omega_E_kHz = {"2/1" = 0.0, "3/1" = 3.0}`). Per unit toroidal mode number + like the kinetic file's `omega_E`, but given as Ω_E/2π in kHz rather than the file's rad/s. + Surfaces not listed (all of them by default) take Ω_E from the kinetic file; a key matching + no analysed surface is an error. Rotation enters only the coupled determinant: surface `k`'s + inner-layer Q is Doppler-shifted by `ΔRe(Q_k) = −tauk_k · n · Ω_E,k`, so each layer sees the + mode in its own E×B frame - `mu_i` -- ion mass in proton-mass units (default 2.0 for D) - `zeff` -- effective charge - `chi_perp`, `chi_tor` -- fallback perpendicular / toroidal heat @@ -157,7 +158,7 @@ there is one consistent interface for resistive and kinetic profiles. # / failed-Δ'-BVP surface, not a real root. Flagged `:spurious`. validity_rtol::Float64 = 1e-3 - omega_E_kHz::Vector{Float64} = Float64[] + omega_E_kHz::Dict{String,Float64} = Dict{String,Float64}() profile_file::String = "" profile_group::String = "/" @@ -193,9 +194,13 @@ function validate(ctrl::SLAYERControl) "not in $(_VALID_LNLAMBDA_FORMS)")) ctrl.msing_max >= 1 || throw(ArgumentError("SLAYERControl: msing_max=$(ctrl.msing_max) must be ≥ 1")) - all(isfinite, ctrl.omega_E_kHz) || + all(isfinite, values(ctrl.omega_E_kHz)) || throw(ArgumentError("SLAYERControl: omega_E_kHz contains a " * "non-finite entry: $(ctrl.omega_E_kHz)")) + for key in keys(ctrl.omega_E_kHz) + _mn_key(key) == key || + throw(ArgumentError("SLAYERControl: omega_E_kHz key \"$key\" is not canonical; write it as \"$(_mn_key(key))\"")) + end ctrl.nre >= 2 && ctrl.nim >= 2 || throw(ArgumentError("SLAYERControl: nre and nim must both be ≥ 2")) ctrl.amr_passes >= 0 || @@ -203,6 +208,13 @@ function validate(ctrl::SLAYERControl) return ctrl end +# Canonical "m/n" label of an omega_E_kHz key; throws on anything not of that form. +function _mn_key(key::AbstractString) + mt = match(r"^\s*(-?\d+)\s*/\s*(\d+)\s*$", key) + mt === nothing && throw(ArgumentError("SLAYERControl: omega_E_kHz key \"$key\" is not of the form \"m/n\", e.g. \"2/1\"")) + return "$(parse(Int, mt[1]))/$(parse(Int, mt[2]))" +end + # Helper: coerce range-like values to a 2-tuple of Float64 _as_range(x::NTuple{2,<:Real}) = (Float64(x[1]), Float64(x[2])) _as_range(x::AbstractVector) = begin @@ -261,7 +273,13 @@ function slayer_control_from_toml(section::AbstractDict) # Allow explicit nothing (auto-derive) or a number (override) kwargs[sym] = v === nothing ? nothing : Float64(v) elseif sym === :omega_E_kHz - kwargs[sym] = Float64[Float64(x) for x in v] + v isa AbstractDict || + throw(ArgumentError("slayer_control_from_toml: omega_E_kHz must be a table keyed by m/n, " * + "e.g. omega_E_kHz = {\"2/1\" = 0.0, \"3/1\" = 3.0}; got $v")) + table = Dict{String,Float64}(_mn_key(String(key)) => Float64(x) for (key, x) in v) + length(table) == length(v) || + throw(ArgumentError("slayer_control_from_toml: omega_E_kHz names the same m/n twice: $(collect(keys(v)))")) + kwargs[sym] = table elseif sym === :boxes # `boxes` is a Vector{NTuple{4,Float64}}; from TOML this comes # in as a list of 4-element arrays. Coerce each. diff --git a/src/Tearing/Runner/HDF5Output.jl b/src/Tearing/Runner/HDF5Output.jl index 61d4cdb4e..1dc6d9ee7 100644 --- a/src/Tearing/Runner/HDF5Output.jl +++ b/src/Tearing/Runner/HDF5Output.jl @@ -44,7 +44,8 @@ function write_slayer_hdf5!(parent::Union{HDF5.File,HDF5.Group}, end _write_per_surface!(g, result.params, result.dp_matrix) - # E×B Doppler offset actually applied to each surface's inner-layer Q (zero outside coupled mode). + # E×B rotation resolved per surface, and the Doppler offset it applies to the inner-layer Q. + g["PerSurface/omega_E"] = result.omega_E g["PerSurface/q_shift"] = result.q_shift # Surface identity (absent when the analysis was built from bare parameters). isempty(result.rational_psi) || (g["PerSurface/rational_psi"] = result.rational_psi) @@ -84,6 +85,8 @@ const TEARING_H5_ANNOTATIONS = [ "PerSurface/iota_e" => (; long_name="electron fraction ι_e = Q_e/(Q_e − Q_i) per surface", dims=("surface",)), "PerSurface/tau_k" => (; long_name="Q-normalization time S^(1/3)·τ_H per surface (Q = τ_k·ω; diamagnetic inputs Q_e, Q_i carry the opposite sign by convention)", units="s", dims=("surface",)), + "PerSurface/omega_E" => + (; long_name="E×B angular frequency Ω_E per unit n on each surface (kinetic file or omega_E_kHz override)", units="rad/s", dims=("surface",)), "PerSurface/q_shift" => (; long_name="real E×B Doppler offset ΔRe(Q) = −τ_k·n·Ω_E applied to each surface's inner-layer Q in the coupled determinant (0 in uncoupled mode)", dims=("surface",)), "PerSurface/tau_R" => (; long_name="resistive diffusion time τ_R = μ₀r_s²/η per surface", units="s", dims=("surface",)), diff --git a/src/Tearing/Runner/Result.jl b/src/Tearing/Runner/Result.jl index a41eb4b11..c264f6f9d 100644 --- a/src/Tearing/Runner/Result.jl +++ b/src/Tearing/Runner/Result.jl @@ -37,6 +37,11 @@ downstream inspection and HDF5 output. - `layer_widths` -- `Vector{LayerWidths}`, one per surface: the resistive layer thickness (in meters) from the `del_s` Riccati solve plus FKR / visco-resistive sanity scales. Empty when disabled. + - `omega_E` -- E×B angular frequency Ω_E per unit n [rad/s] resolved on each + surface: the kinetic file's value, or the `control.omega_E_kHz` override for that m/n. + Recorded in both coupling modes + - `q_shift` -- real Doppler offset `−τ_k·n·Ω_E` applied to each surface's + inner-layer Q; zero outside `:coupled` mode, where rotation does not enter - `scan_data` -- scan results (per-surface in uncoupled, single entry in coupled). Empty unless `control.store_scan == true`. """ @@ -53,6 +58,7 @@ struct SLAYERResult per_surface_extraction::Vector{GrowthRateResult} coupled_extraction::Union{Nothing,GrowthRateResult} layer_widths::Vector{LayerWidths} + omega_E::Vector{Float64} q_shift::Vector{Float64} scan_data::Vector{Union{ScanResult,AMRResult}} end @@ -66,6 +72,6 @@ function empty_slayer_result(control::SLAYERControl) ComplexF64[], Float64[], Float64[], GrowthRateResult[], nothing, LayerWidths[], - Float64[], + Float64[], Float64[], Union{ScanResult,AMRResult}[]) end diff --git a/src/Tearing/Runner/run_slayer.jl b/src/Tearing/Runner/run_slayer.jl index fee0371ea..ab7eef705 100644 --- a/src/Tearing/Runner/run_slayer.jl +++ b/src/Tearing/Runner/run_slayer.jl @@ -147,25 +147,30 @@ end _build_surface_coupling(model::GGJModel, params::GGJParameters, dp_diag, ::Real) = surface_coupling(model, params, dp_diag) -# Per-surface E×B angular frequency Ω_E per unit n [rad/s]: `control.omega_E_kHz` when set, -# otherwise the kinetic file's `omega_E` sampled at each rational surface. -function _omega_E_per_surface(control::SLAYERControl, n::Integer, omega_E_file::AbstractVector{<:Real}) - if !isempty(control.omega_E_kHz) - length(control.omega_E_kHz) == n || - throw( - ArgumentError( - "run_slayer: omega_E_kHz has $(length(control.omega_E_kHz)) " * - "entries but $n rational surfaces were analysed. Supply one " * - "entry per surface, or leave it empty to use the kinetic file." - ) - ) - return 2π * 1e3 .* control.omega_E_kHz - end - isempty(omega_E_file) && return zeros(Float64, n) - length(omega_E_file) == n || +# "m/n" label keying `control.omega_E_kHz`; GGJ parameters carry no mode numbers. +_mn_label(p::SLAYERParameters) = "$(p.m)/$(p.n)" +_mn_label(::InnerLayerParameters) = nothing + +# Per-surface E×B angular frequency Ω_E per unit n [rad/s]: the kinetic file's `omega_E` at each +# rational surface, replaced on every surface whose m/n `control.omega_E_kHz` lists. +function _omega_E_per_surface(control::SLAYERControl, params::AbstractVector, omega_E_file::AbstractVector{<:Real}) + n = length(params) + isempty(omega_E_file) || length(omega_E_file) == n || throw(ArgumentError("run_slayer: omega_E has $(length(omega_E_file)) entries but " * "$n rational surfaces were analysed.")) - return Float64.(omega_E_file) + Ω_E = isempty(omega_E_file) ? zeros(Float64, n) : Float64.(omega_E_file) + isempty(control.omega_E_kHz) && return Ω_E + labels = [_mn_label(p) for p in params] + any(isnothing, labels) && + throw(ArgumentError("run_slayer: omega_E_kHz is keyed by m/n, which $(eltype(params)) surfaces do not carry.")) + unknown = setdiff(keys(control.omega_E_kHz), labels) + isempty(unknown) || + throw(ArgumentError("run_slayer: omega_E_kHz lists m/n $(sort(collect(unknown))) matching no analysed surface; " * + "the analysed surfaces are $(labels).")) + for (k, label) in enumerate(labels) + haskey(control.omega_E_kHz, label) && (Ω_E[k] = 2π * 1e3 * control.omega_E_kHz[label]) + end + return Ω_E end # Real Doppler offset on a surface's inner-layer Q: Q_k = τ_k·(ω − n·Ω_E) in its E×B frame. @@ -235,8 +240,9 @@ Run the SLAYER tearing analysis given pre-built per-surface equilibrium-driven `build_slayer_inputs` step — use this when the parameters are already known (e.g. in unit tests or when rebuilding from cached HDF5 output). `omega_E` is the E×B angular frequency per unit n -at each surface [rad/s]; in coupled mode it Doppler-shifts each surface's -inner-layer Q unless `control.omega_E_kHz` overrides it. Empty means no rotation. +at each surface [rad/s], replaced on any surface whose m/n `control.omega_E_kHz` +lists; empty means no rotation. In coupled mode it Doppler-shifts each surface's +inner-layer Q; in both modes the resolved value is reported as `result.omega_E`. """ function run_slayer_from_inputs(params::AbstractVector{<:InnerLayerParameters}, dp_matrix::AbstractMatrix, @@ -308,12 +314,12 @@ function run_slayer_from_inputs(params::AbstractVector{<:InnerLayerParameters}, # solved in its own plasma frame, so a shift there would only relabel the reported frequency. coupled = control.coupling_mode === :coupled !coupled && !isempty(control.omega_E_kHz) && @warn( - "SLAYER: omega_E_kHz is ignored with coupling_mode=:uncoupled; rotation only enters " * - "the coupled determinant.") - Ω_E = coupled ? _omega_E_per_surface(control, n, omega_E) : zeros(Float64, n) + "SLAYER: omega_E_kHz does not shift the layers with coupling_mode=:uncoupled; rotation only " * + "enters the coupled determinant, so it is just recorded in PerSurface/omega_E.") + Ω_E = _omega_E_per_surface(control, params, omega_E) coupled && _is_ggj(model) && any(!iszero, Ω_E) && @warn( "SLAYER: E×B rotation is not applied to GGJ surfaces, which carry no time normalization.") - q_shifts = _q_shifts(params, Ω_E) + q_shifts = coupled ? _q_shifts(params, Ω_E) : zeros(Float64, n) scs = [_build_surface_coupling(model, params[k], dp[k, k], q_shifts[k]) for k in 1:n] # Coupled Q is scanned in the reference surface's normalization. ref_idx = 1 @@ -416,7 +422,7 @@ function run_slayer_from_inputs(params::AbstractVector{<:InnerLayerParameters}, return SLAYERResult(true, control, params, rational_psi, rational_q, dp, Q_root, omega_Hz, gamma_Hz, per_surface_extraction, coupled_extraction, - layer_widths, q_shifts, scan_data_list) + layer_widths, Ω_E, q_shifts, scan_data_list) end # --------------------------------------------------------------------- diff --git a/test/runtests_dispersion_rotation.jl b/test/runtests_dispersion_rotation.jl index c1d2f53a2..440de1703 100644 --- a/test/runtests_dispersion_rotation.jl +++ b/test/runtests_dispersion_rotation.jl @@ -76,7 +76,7 @@ mu_i=2.0, zeff=1.0, chi_perp=1.0, chi_tor=1.0, m=m, n=n) params = [mk(; qval=1.5, rs=0.5, m=3, n=2), mk(; qval=2.0, rs=0.6, m=4, n=2)] Ω_file = [3.0e4, -1.0e4] # rad/s per unit n, as carried by the kinetic file - qs(ctrl; omega_E=Float64[]) = _q_shifts(params, _omega_E_per_surface(ctrl, 2, omega_E)) + qs(ctrl; omega_E=Float64[]) = _q_shifts(params, _omega_E_per_surface(ctrl, params, omega_E)) # No file rotation and no override: no shift anywhere. @test qs(SLAYERControl()) == [0.0, 0.0] @@ -86,12 +86,16 @@ got = qs(SLAYERControl(); omega_E=Ω_file) @test got ≈ [-params[1].tauk * 2 * Ω_file[1], -params[2].tauk * 2 * Ω_file[2]] - # A non-empty omega_E_kHz replaces the file values, in the same per-unit-n convention. - ovr = qs(SLAYERControl(; omega_E_kHz=[1.0, -2.0]); omega_E=Ω_file) + # omega_E_kHz replaces the file value on each listed m/n, in the same per-unit-n convention. + ovr = qs(SLAYERControl(; omega_E_kHz=Dict("3/2" => 1.0, "4/2" => -2.0)); omega_E=Ω_file) @test ovr ≈ [-params[1].tauk * 2 * 2π * 1e3 * 1.0, -params[2].tauk * 2 * 2π * 1e3 * -2.0] - # Either source must supply exactly one value per analysed surface. - @test_throws ArgumentError qs(SLAYERControl(; omega_E_kHz=[1.0])) + # Unlisted surfaces keep the kinetic-file value. + part = qs(SLAYERControl(; omega_E_kHz=Dict("4/2" => -2.0)); omega_E=Ω_file) + @test part ≈ [-params[1].tauk * 2 * Ω_file[1], -params[2].tauk * 2 * 2π * 1e3 * -2.0] + + # An m/n matching no analysed surface is an error, as is a file of the wrong length. + @test_throws "matching no analysed surface" qs(SLAYERControl(; omega_E_kHz=Dict("2/1" => 1.0))) @test_throws ArgumentError qs(SLAYERControl(); omega_E=[1.0]) end @@ -134,13 +138,18 @@ end @testset "omega_E_kHz parses and validates from TOML" begin - ctrl = slayer_control_from_toml(Dict("omega_E_kHz" => [0, 3.0, -1.5])) - @test ctrl.omega_E_kHz == [0.0, 3.0, -1.5] - @test eltype(ctrl.omega_E_kHz) === Float64 + ctrl = slayer_control_from_toml(Dict("omega_E_kHz" => Dict("2/1" => 0, " 3 / 1 " => 3.0))) + @test ctrl.omega_E_kHz == Dict("2/1" => 0.0, "3/1" => 3.0) + @test ctrl.omega_E_kHz isa Dict{String,Float64} # Default stays empty so existing decks are untouched. @test isempty(slayer_control_from_toml(Dict{String,Any}()).omega_E_kHz) + # The positional list form is gone, and keys must read m/n. + @test_throws "table keyed by m/n" slayer_control_from_toml(Dict("omega_E_kHz" => [0.0, 3.0])) + @test_throws "not of the form" slayer_control_from_toml(Dict("omega_E_kHz" => Dict("2:1" => 3.0))) + @test_throws "same m/n twice" slayer_control_from_toml(Dict("omega_E_kHz" => Dict("2/1" => 1.0, "02/1" => 2.0))) # A non-finite shift would silently poison every Q evaluation; the # validator rejects it (TOML cannot express NaN, so go through validate). - @test_throws ArgumentError validate(SLAYERControl(; omega_E_kHz=[NaN])) + @test_throws ArgumentError validate(SLAYERControl(; omega_E_kHz=Dict("2/1" => NaN))) + @test_throws "not canonical" validate(SLAYERControl(; omega_E_kHz=Dict("02/1" => 1.0))) end end diff --git a/test/runtests_slayer_runner.jl b/test/runtests_slayer_runner.jl index c4e0a62bf..e9a374906 100644 --- a/test/runtests_slayer_runner.jl +++ b/test/runtests_slayer_runner.jl @@ -147,15 +147,23 @@ # Uncoupled layers are solved in their own plasma frame: no shift, even when supplied. r_unc = run_slayer_from_inputs(params, dpm, SLAYERControl(; coupling_mode=:uncoupled, grid...); omega_E=Ω_E) @test r_unc.q_shift == [0.0, 0.0] + # The resolved rotation is still reported in both modes. + @test r_unc.omega_E == Ω_E # Coupled: the kinetic-file Ω_E Dopplers each surface by −τ_k·n·Ω_E. r_cpl = run_slayer_from_inputs(params, dpm, SLAYERControl(; coupling_mode=:coupled, grid...); omega_E=Ω_E) @test r_cpl.q_shift ≈ [-1.0e-4 * 1 * Ω_E[1], -1.2e-4 * 1 * Ω_E[2]] + @test r_cpl.omega_E == Ω_E - # omega_E_kHz takes precedence over the file. + # omega_E_kHz takes precedence over the file on the surfaces it lists. r_ovr = run_slayer_from_inputs(params, dpm, - SLAYERControl(; coupling_mode=:coupled, omega_E_kHz=[1.0, 0.0], grid...); omega_E=Ω_E) + SLAYERControl(; coupling_mode=:coupled, omega_E_kHz=Dict("2/1" => 1.0, "3/1" => 0.0), grid...); omega_E=Ω_E) @test r_ovr.q_shift ≈ [-1.0e-4 * 2π * 1e3, 0.0] + @test r_ovr.omega_E ≈ [2π * 1e3, 0.0] + r_part = run_slayer_from_inputs(params, dpm, + SLAYERControl(; coupling_mode=:coupled, omega_E_kHz=Dict("2/1" => 1.0), grid...); omega_E=Ω_E) + @test r_part.omega_E ≈ [2π * 1e3, Ω_E[2]] + @test r_part.q_shift ≈ [-1.0e-4 * 2π * 1e3, -1.2e-4 * Ω_E[2]] # A Doppler offset beyond the scan box is flagged rather than silently losing the root: # τ_ref·n·Ω_E = 1e-4 · 3e4 = 3 lies outside Re(Q) ∈ [-1, 1]. From a8935e6024d46c01c305f2b03dabeded3afbc5b1 Mon Sep 17 00:00:00 2001 From: d-burg Date: Fri, 25 Sep 2026 15:40:51 -0400 Subject: [PATCH 15/20] =?UTF-8?q?Tearing=20-=20TEST=20-=20Anchor=20the=20E?= =?UTF-8?q?=C3=97B=20Doppler=20sign=20on=20real=20SLAYER=20surfaces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit State the lab-to-E×B-frame relation behind q_shift = −τ_k·n·Ω_E in the _q_shift docstring, and test on two real SLAYER surfaces that the rotating coupled determinant at Q + τ_ref·n·Ω_E equals the static one at Q, so every root keeps its γ and its lab frequency moves by +n·Ω_E. Co-Authored-By: Claude Opus 5.5 --- src/Tearing/Runner/run_slayer.jl | 16 +++++++++++++++- test/runtests_dispersion_rotation.jl | 24 +++++++++++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/Tearing/Runner/run_slayer.jl b/src/Tearing/Runner/run_slayer.jl index ab7eef705..4347a23b8 100644 --- a/src/Tearing/Runner/run_slayer.jl +++ b/src/Tearing/Runner/run_slayer.jl @@ -173,7 +173,21 @@ function _omega_E_per_surface(control::SLAYERControl, params::AbstractVector, om return Ω_E end -# Real Doppler offset on a surface's inner-layer Q: Q_k = τ_k·(ω − n·Ω_E) in its E×B frame. +""" + _q_shift(p::SLAYERParameters, Ω_E) -> Float64 + +Real offset added to a surface's scanned inner-layer `Q` for E×B rotation `Ω_E` per unit n [rad/s]. +The layer's `Q` is the mode frequency in the local E×B frame (Burgess et al. 2026, Table 1), while +the coupled scan runs in the lab frame, so + +``` +Q_layer = τ_k·ω_lab − τ_k·n·Ω_E ⇒ q_shift = −τ_k·n·Ω_E +``` + +A layer root at rest in its E×B frame therefore sits at `ω_lab = n·Ω_E`: the mode rotates with the +plasma, at unchanged γ. The conjugated layer Δ and the opposite-sign diamagnetic inputs +(`Q_e = −τ_k·ω_*e`) flip together and leave this sign unchanged. +""" function _q_shift(p::SLAYERParameters, Ω_E::Real) iszero(Ω_E) || p.n >= 1 || throw(ArgumentError("run_slayer: E×B rotation Ω_E=$(Ω_E) rad/s needs the toroidal mode number, but SLAYERParameters has n=$(p.n)")) diff --git a/test/runtests_dispersion_rotation.jl b/test/runtests_dispersion_rotation.jl index 440de1703..caaf39041 100644 --- a/test/runtests_dispersion_rotation.jl +++ b/test/runtests_dispersion_rotation.jl @@ -3,7 +3,7 @@ using GeneralizedPerturbedEquilibrium.InnerLayer: InnerLayerModel, solve_inner using GeneralizedPerturbedEquilibrium.Dispersion using GeneralizedPerturbedEquilibrium.Tearing.Runner: SLAYERControl, - slayer_control_from_toml, validate, _q_shifts, _omega_E_per_surface + slayer_control_from_toml, validate, _q_shifts, _omega_E_per_surface, _build_surface_coupling using LinearAlgebra # Linear inner layer Δ(Q) = a + b·Q makes the applied Q offset readable @@ -99,6 +99,28 @@ @test_throws ArgumentError qs(SLAYERControl(); omega_E=[1.0]) end + @testset "Real SLAYER surfaces: the lab-frame root moves with the E×B rotation" begin + # Q_layer = τ_k·ω_lab − τ_k·n·Ω_E, so the rotating determinant at Q + τ_ref·n·Ω_E equals the + # static one at Q: every root keeps its γ and moves by ω_lab(Ω_E) − ω_lab(0) = +n·Ω_E. + mk(; qval, rs, m, n) = slayer_parameters(; n_e=5.0e19, t_e=1000.0, t_i=1000.0, + omega_e=1.0e4, omega_i=5.0e3, + qval=qval, sval_r=1.0, bt=2.0, rs=rs, R0=1.7, + mu_i=2.0, zeff=1.0, chi_perp=1.0, chi_tor=1.0, m=m, n=n) + params = [mk(; qval=1.5, rs=0.5, m=3, n=2), mk(; qval=2.0, rs=0.6, m=4, n=2)] + slayer = SLAYERModel(; variant=:fitzpatrick) + dp = ComplexF64[-2.0 0.5; 0.5 -3.0] + lab_shift = 0.5 # τ_ref·n·Ω_E in the reference Q + Ω_rigid = lab_shift / (params[1].tauk * params[1].n) # rad/s per unit n, on both surfaces + build(Ω) = [_build_surface_coupling(slayer, params[k], dp[k, k], q) for (k, q) in enumerate(_q_shifts(params, Ω))] + static = multi_surface_coupling(build(zeros(2)), dp; ref_idx=1, msing_max=2) + rotating = multi_surface_coupling(build(fill(Ω_rigid, 2)), dp; ref_idx=1, msing_max=2) + for Q in (0.3 + 0.2im, -0.4 + 0.6im, 0.1 + 1.1im) + @test rotating(Q + lab_shift) ≈ static(Q) rtol = 1e-6 + # The opposite Doppler sign (mode counter-rotating with the plasma) must not match. + @test !isapprox(rotating(Q - lab_shift), static(Q); rtol=1e-3) + end + end + @testset "Coupled roots do not depend on the reference surface" begin # Diagonal Δ′ decouples the surfaces, so each coupled root must be that surface's own # root in physical units whichever surface normalizes Q. From 07fd3063e357d37ece3c093dbfe6dd998412131f Mon Sep 17 00:00:00 2001 From: d-burg Date: Fri, 25 Sep 2026 15:43:14 -0400 Subject: [PATCH 16/20] Repo - MINOR - Apply the formatter to the audit fixes Co-Authored-By: Claude Opus 5.5 --- src/Tearing/Runner/Result.jl | 23 ++++++++++++----------- src/Tearing/Runner/run_slayer.jl | 3 ++- src/Utilities/KineticProfiles.jl | 12 ++++++------ test/runtests_slayer_inputs.jl | 22 +++++++++++----------- 4 files changed, 31 insertions(+), 29 deletions(-) diff --git a/src/Tearing/Runner/Result.jl b/src/Tearing/Runner/Result.jl index c264f6f9d..1d2ddfb0b 100644 --- a/src/Tearing/Runner/Result.jl +++ b/src/Tearing/Runner/Result.jl @@ -25,9 +25,10 @@ downstream inspection and HDF5 output. `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 - `params[1].tauk`) + + + length `nsurfaces` in `:uncoupled` mode + + length `1` in `:coupled` mode (global eigenvalue normalized by + `params[1].tauk`) - `omega_Hz`, `gamma_Hz` -- physical rotation frequency / growth rate - `per_surface_extraction` -- `Vector{GrowthRateResult}` of length `nsurfaces` in uncoupled mode (each includes polelines, pole list, @@ -66,12 +67,12 @@ end # Empty result (enabled=false path) function empty_slayer_result(control::SLAYERControl) return SLAYERResult(false, control, - SLAYERParameters[], - Float64[], Float64[], - zeros(ComplexF64, 0, 0), - ComplexF64[], Float64[], Float64[], - GrowthRateResult[], nothing, - LayerWidths[], - Float64[], Float64[], - Union{ScanResult,AMRResult}[]) + SLAYERParameters[], + Float64[], Float64[], + zeros(ComplexF64, 0, 0), + ComplexF64[], Float64[], Float64[], + GrowthRateResult[], nothing, + LayerWidths[], + Float64[], Float64[], + Union{ScanResult,AMRResult}[]) end diff --git a/src/Tearing/Runner/run_slayer.jl b/src/Tearing/Runner/run_slayer.jl index 4347a23b8..9cf87ffbd 100644 --- a/src/Tearing/Runner/run_slayer.jl +++ b/src/Tearing/Runner/run_slayer.jl @@ -327,7 +327,8 @@ function run_slayer_from_inputs(params::AbstractVector{<:InnerLayerParameters}, # E×B rotation only Doppler-shifts the coupled determinant: each uncoupled layer is already # solved in its own plasma frame, so a shift there would only relabel the reported frequency. coupled = control.coupling_mode === :coupled - !coupled && !isempty(control.omega_E_kHz) && @warn( + !coupled && !isempty(control.omega_E_kHz) && + @warn( "SLAYER: omega_E_kHz does not shift the layers with coupling_mode=:uncoupled; rotation only " * "enters the coupled determinant, so it is just recorded in PerSurface/omega_E.") Ω_E = _omega_E_per_surface(control, params, omega_E) diff --git a/src/Utilities/KineticProfiles.jl b/src/Utilities/KineticProfiles.jl index 217a674d2..5b900b859 100644 --- a/src/Utilities/KineticProfiles.jl +++ b/src/Utilities/KineticProfiles.jl @@ -14,12 +14,12 @@ using FastInterpolations Radial kinetic-profile container. All four profiles are 1D cubic splines of the normalized poloidal flux ψ ∈ [0, 1]. -| field | meaning | units | -|:--------- |:--------------------------------------- |:----- | -| `n_e` | electron density | m⁻³ | -| `T_e` | electron temperature | eV | -| `T_i` | ion temperature | eV | -| `omega_E` | E×B rotation Ω_E, per unit n | rad/s | +| field | meaning | units | +|:--------- |:---------------------------- |:----- | +| `n_e` | electron density | m⁻³ | +| `T_e` | electron temperature | eV | +| `T_i` | ion temperature | eV | +| `omega_E` | E×B rotation Ω_E, per unit n | rad/s | Construct via the keyword constructor `KineticProfiles(; psi, n_e, T_e, T_i, omega_E)` with matched-length vectors. The SLAYER runner builds this object from a standardized diff --git a/test/runtests_slayer_inputs.jl b/test/runtests_slayer_inputs.jl index b499bb13d..fd1c10827 100644 --- a/test/runtests_slayer_inputs.jl +++ b/test/runtests_slayer_inputs.jl @@ -23,7 +23,7 @@ omega_E=fill(0.0, length(psi_pts))) # Helper to build a minimal SingType without touching unused fields - _mk_sing(; psi, q, q1, m, n, delta_prime=-10.0+0im) = SingType( + _mk_sing(; psi, q, q1, m, n, delta_prime=-10.0 + 0im) = SingType(; psifac=psi, rho=sqrt(psi), m=[m], n=[n], q=q, q1=q1, delta_prime=ComplexF64[delta_prime], delta_prime_col=zeros(ComplexF64, 0, 0), @@ -70,8 +70,8 @@ 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)] + 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)] # dr_val=0.0 bypasses the build_slayer_inputs requirement that sing.restype be # pre-populated by ForceFreeStates.resist_eval_all! — the test sings here are # minimal stubs without restype, so we supply dr_val explicitly. @@ -125,7 +125,7 @@ # survived. It shows up on a deck whose normalization differs from its field -- the # DIII-D-like EFIT deck has b0exp = 1.0 against a physical ~1.95 T. What is pinned here is # therefore the resolution rule itself, which is deck-independent. - sings = [_mk_sing(psi=0.3, q=2.0, q1=1.5, m=2, n=1)] + sings = [_mk_sing(; psi=0.3, q=2.0, q1=1.5, m=2, n=1)] bt_phys = Float64(equil.profiles.F_spline(0.3)) / (2π * equil.ro) sl_default = build_slayer_inputs(equil, sings, profiles; dr_val=0.0) @@ -142,14 +142,14 @@ end @testset "build_slayer_inputs: chi_perp/chi_tor as scalars and callables" begin - sings = [_mk_sing(psi=0.5, q=2.4, q1=1.2, m=2, n=1)] + sings = [_mk_sing(; psi=0.5, q=2.4, q1=1.2, m=2, n=1)] # Scalar (dr_val=0.0 bypasses the sing.restype requirement; see comment above) sl_s = build_slayer_inputs(equil, sings, profiles; bt=2.0, chi_perp=2.0, chi_tor=1.5, dr_val=0.0) # Callable with matching value - chi_p(psi) = 2.0 + 0.0*psi - chi_t(psi) = 1.5 + 0.0*psi + chi_p(psi) = 2.0 + 0.0 * psi + chi_t(psi) = 1.5 + 0.0 * psi sl_c = build_slayer_inputs(equil, sings, profiles; bt=2.0, chi_perp=chi_p, chi_tor=chi_t, dr_val=0.0) @test sl_s[1].P_perp ≈ sl_c[1].P_perp @@ -166,7 +166,7 @@ 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)] + 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, :flux) sl = build_slayer_inputs(equil, sings, profiles; bt=2.0, dr_val=0.0, rs_method=rsm) @@ -185,7 +185,7 @@ 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)] + sings = [_mk_sing(; psi=0.5, q=2.4, q1=1.2, m=2, n=1)] # dc_type=:none and dr_val=0.0 → dc_tmp = 0 regardless of dr_val sl_none = build_slayer_inputs(equil, sings, profiles; @@ -215,8 +215,8 @@ # 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)] + 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) From ad39cc2efc1aeee50a987f65627d7ed9fe8dc5d9 Mon Sep 17 00:00:00 2001 From: d-burg Date: Fri, 25 Sep 2026 15:54:18 -0400 Subject: [PATCH 17/20] Tearing - TEST - Size the Doppler-sign test bound to the layer solve's rounding noise Co-Authored-By: Claude Opus 5.5 --- test/runtests_dispersion_rotation.jl | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/runtests_dispersion_rotation.jl b/test/runtests_dispersion_rotation.jl index caaf39041..a3f91f33a 100644 --- a/test/runtests_dispersion_rotation.jl +++ b/test/runtests_dispersion_rotation.jl @@ -115,7 +115,8 @@ static = multi_surface_coupling(build(zeros(2)), dp; ref_idx=1, msing_max=2) rotating = multi_surface_coupling(build(fill(Ω_rigid, 2)), dp; ref_idx=1, msing_max=2) for Q in (0.3 + 0.2im, -0.4 + 0.6im, 0.1 + 1.1im) - @test rotating(Q + lab_shift) ≈ static(Q) rtol = 1e-6 + # The layer arguments agree to rounding, which the adaptive layer ODE amplifies to ≲ 3e-6. + @test rotating(Q + lab_shift) ≈ static(Q) rtol = 1e-4 # The opposite Doppler sign (mode counter-rotating with the plasma) must not match. @test !isapprox(rotating(Q - lab_shift), static(Q); rtol=1e-3) end From f88753a97341af748ff40eab6d79865a5ef41625 Mon Sep 17 00:00:00 2001 From: d-burg Date: Tue, 6 Oct 2026 00:15:07 -0400 Subject: [PATCH 18/20] Regression - MINOR - Declare the tolerance class of each tracked quantity in the coupled SLAYER case Co-Authored-By: Claude Opus 5.5 --- regression-harness/cases/diiid_slayer_n1_coupled.toml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/regression-harness/cases/diiid_slayer_n1_coupled.toml b/regression-harness/cases/diiid_slayer_n1_coupled.toml index 39ca44f07..5ffd5e774 100644 --- a/regression-harness/cases/diiid_slayer_n1_coupled.toml +++ b/regression-harness/cases/diiid_slayer_n1_coupled.toml @@ -23,6 +23,7 @@ extract = "all_real" label = "SLAYER tauk" noise_threshold = 1e-12 order = 10 +class = "physics_converged" # Applied E×B Doppler offset ΔRe(Q) = −τ_k·n·Ω_E per surface [quantities.slayer_q_shift] @@ -32,9 +33,13 @@ extract = "all_real" label = "SLAYER q_shift" noise_threshold = 1e-10 order = 11 +class = "physics_converged" # Coupled eigenvalue (one root for the whole determinant). The coupled path has no root # polishing, so the thresholds match the uncoupled case's absolute, modestly loose floors. +# Pin Q, ω and γ with a measured tolerance, not the class default: re-extracting one set of scan +# samples under different triangulations gave γ = 190.646 and 191.888 s⁻¹ (0.65 %). ω and Q +# need the same measurement. [quantities.slayer_Q_coupled] h5path = "Tearing/Roots/Q_root" type = "complex_vector" @@ -42,6 +47,7 @@ extract = "all_complex" label = "SLAYER coupled Q_root" noise_threshold = 1e-4 order = 30 +class = "physics_converged" [quantities.slayer_omega_coupled] h5path = "Tearing/Roots/omega" @@ -50,6 +56,7 @@ extract = "all_real" label = "SLAYER coupled ω" noise_threshold = 1.0 order = 32 +class = "physics_converged" [quantities.slayer_gamma_coupled] h5path = "Tearing/Roots/gamma" @@ -58,6 +65,7 @@ extract = "all_real" label = "SLAYER coupled γ" noise_threshold = 1e-1 order = 33 +class = "physics_converged" # no_root flag (1 = extraction failed); guards that the coupled root stays inside the window. [quantities.slayer_no_root_coupled] @@ -67,6 +75,7 @@ extract = "all_real" label = "SLAYER coupled no_root flag" noise_threshold = 0 order = 34 +class = "physics_converged" # Settings (catches accidental config drift) [quantities.slayer_enabled] @@ -76,6 +85,7 @@ extract = "value" label = "SLAYER enabled flag" noise_threshold = 0 order = 90 +class = "topological" [quantities.runtime] h5path = "" @@ -84,3 +94,4 @@ extract = "value" label = "Runtime (s)" noise_threshold = 0.0 order = 999 +class = "diagnostic" From 993f519af5e155387540be7c1934b995d15e9b2b Mon Sep 17 00:00:00 2001 From: d-burg Date: Tue, 6 Oct 2026 16:25:29 -0400 Subject: [PATCH 19/20] Tearing - REFACTOR - Trim duplicated rotation tests, key validation and convention statements Rotation is tested once, through run_slayer_from_inputs, with the n = 2 and wrong-length cases moved there. omega_E_kHz keys are validated in one place, the run-time check against the analysed surfaces. The Doppler convention is derived once, in _q_shift. Co-Authored-By: Claude Opus 5.5 --- examples/DIIID-like_SLAYER_example/gpec.toml | 4 - .../cases/diiid_slayer_n1_coupled.toml | 2 +- src/Tearing/Dispersion/Coupled.jl | 6 +- src/Tearing/Dispersion/SurfaceCoupling.jl | 20 ++-- src/Tearing/Runner/Control.jl | 20 +--- src/Tearing/Runner/run_slayer.jl | 9 +- test/runtests_dispersion_coupled_full.jl | 7 -- test/runtests_dispersion_rotation.jl | 91 ++----------------- test/runtests_slayer_runner.jl | 18 ++-- 9 files changed, 31 insertions(+), 146 deletions(-) diff --git a/examples/DIIID-like_SLAYER_example/gpec.toml b/examples/DIIID-like_SLAYER_example/gpec.toml index b20c56fa9..29b91a47f 100644 --- a/examples/DIIID-like_SLAYER_example/gpec.toml +++ b/examples/DIIID-like_SLAYER_example/gpec.toml @@ -75,10 +75,6 @@ mu_i = 2.0 # Ion mass in proton-mass units (2.0 = deut zeff = 1.0 # Effective charge chi_perp = 1.0 # fallback only; the kinetic file supplies χ⊥(ψ) chi_tor = 1.0 # fallback only; the kinetic file supplies χ_φ(ψ) -# E×B rotation only Doppler-shifts the coupled determinant, and by default comes from the kinetic -# file's omega_E. Uncomment omega_E_kHz to override it on chosen surfaces for a rotation scan; unlisted -# surfaces keep the file's value. In the scanned Re(Q), normalized to the 2/1, every surface's layer -# response moves by +τ_2/1·n·Ω_E, so check roots stay inside Q_re_range; coupled runs usually need a wider Q_re_range. # omega_E_kHz = {"2/1" = 0.0, "3/1" = 3.0} # override of the E×B rotation Ω_E/2π per unit n [kHz], keyed by m/n; unlisted surfaces use the kinetic file pole_threshold_adaptive = true # SLAYER |Δ| spans many orders; adapt to 10·median(|Δ|) filter_above_poles = true # Discard roots above the highest pole γ diff --git a/regression-harness/cases/diiid_slayer_n1_coupled.toml b/regression-harness/cases/diiid_slayer_n1_coupled.toml index 5ffd5e774..f3f095698 100644 --- a/regression-harness/cases/diiid_slayer_n1_coupled.toml +++ b/regression-harness/cases/diiid_slayer_n1_coupled.toml @@ -2,7 +2,7 @@ # Reuses the n=1 SLAYER deck via [overrides] in coupled mode, the only harness coverage of the # multi-surface determinant: its inter-surface Q normalization and the per-surface E×B Doppler # shift taken from the kinetic file's omega_E. The Re(Q) window is widened because that shift -# moves the coupled root to the lab frame (Re(Q) ≈ 2.06 here, outside the deck's [-2, 2]). +# moves the coupled root to the lab frame, outside the deck's [-2, 2]. # 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] diff --git a/src/Tearing/Dispersion/Coupled.jl b/src/Tearing/Dispersion/Coupled.jl index 2848c4495..43a4b7b87 100644 --- a/src/Tearing/Dispersion/Coupled.jl +++ b/src/Tearing/Dispersion/Coupled.jl @@ -38,9 +38,7 @@ A root of `mc` in the complex `Q` plane is a coupled tearing eigenvalue. `ratio_k = tauk_k/tauk_ref`: `Q` is defined as `tauk·ω`, so one shared physical ω reaches surface `k` as `Q_k = tauk_k·ω`, on the same scaling as its `Q_e`/`Q_i`. -`q_shift_k` is the real, per-surface E×B Doppler offset carried on each -`SurfaceCoupling` (zero by default, in which case every surface sees the same -lab-frame frequency and the determinant is unchanged from the static form). +`q_shift_k` is the real offset carried on each `SurfaceCoupling` (zero by default). """ struct MultiSurfaceCoupling{V<:AbstractVector{<:SurfaceCoupling}} surfaces::V @@ -98,8 +96,6 @@ function (mc::MultiSurfaceCoupling)(Q::Number) M = mc.dp_matrix[1:n, 1:n] @inbounds for k in 1:n sc = mc.surfaces[k] - # Real q_shift Dopplers this surface's layer away from the common - # lab-frame frequency carried by the scanned Q. Q_k = Qc * (sc.tauk / ref_tauk) + sc.q_shift # m×m scalar coupling: use only the tearing channel. The # interchange (Glasser-stabilization) channel is carried in the diff --git a/src/Tearing/Dispersion/SurfaceCoupling.jl b/src/Tearing/Dispersion/SurfaceCoupling.jl index 554071281..f243febec 100644 --- a/src/Tearing/Dispersion/SurfaceCoupling.jl +++ b/src/Tearing/Dispersion/SurfaceCoupling.jl @@ -12,10 +12,6 @@ # multi-surface `MultiSurfaceCoupling` to rescale Q between each surface's # normalization. # -# `q_shift = −tau_k·n·Omega_E_k` (Omega_E per unit toroidal mode number n) is a real, additive -# offset on the layer's Q argument that puts the mode frequency in this surface's E×B frame, -# `Q_k = tau_k·(omega − n·Omega_E_k)`; zero is the static, all-surfaces-corotating case. -# # Constructor convenience: `surface_coupling(model, params, dp_diag; dc=0.0)` # auto-fills `scale` and `tauk` based on the model type — `scale = S^(1/3)` # and `tauk = params.tauk` for SLAYER (de-normalizes the inner-layer Δ to @@ -31,8 +27,8 @@ Per-surface dispersion data: `(model, params, dp_diag, dc, scale, tauk, q_shift) r(Q) = dp_diag - scale * solve_inner(model, params, Q + q_shift).tearing - dc ``` -`q_shift` is the real Doppler offset described in the file header; it defaults -to zero, which leaves the residual identical to the pre-rotation form. +`q_shift` is a real offset on the layer's Q argument, zero by default. The Tearing runner +sets it to the surface's E×B Doppler shift. A root of `sc` in the complex `Q` plane is a **tearing** eigenvalue at this surface in the *uncoupled* approximation (only the tearing channel @@ -68,8 +64,7 @@ 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. `q_shift` is the real E×B Doppler offset on -the layer's Q argument (`-tauk * n * Omega_E`, Omega_E per unit n); it defaults to zero. +`MultiSurfaceCoupling` Q rescaling. """ function surface_coupling(model::SLAYERModel, params::SLAYERParameters, dp_diag::Number; dc::Real=0.0, q_shift::Real=0.0) @@ -79,7 +74,7 @@ end """ surface_coupling(model::GGJModel, params::GGJParameters, - dp_diag::Number; q_shift::Real=0.0) -> SurfaceCoupling + dp_diag::Number) -> SurfaceCoupling GGJ convenience constructor. `scale` is `1.0` because GGJ's `solve_inner` applies its own `rescale_delta` (S^(2p₁/3)·v1^(2p₁)) internally, so the @@ -92,8 +87,7 @@ interchange channel, which provides Glasser (Mercier) stabilization natively. A Δ_crit proxy (χ_parallel-matching offset on the diagonal) is meaningful only for tearing-only slab-layer approximations like SLAYER; for GGJ it would double-count the interchange physics. The `SurfaceCoupling` -struct's `dc` field is hard-wired to 0 here, and so is `q_shift`: with no time -normalization, a physical E×B rotation has no image in GGJ's Q. +struct's `dc` field is hard-wired to 0 here, and so is `q_shift`. """ function surface_coupling(model::GGJModel, params::GGJParameters, dp_diag::Number) return SurfaceCoupling(model, params, ComplexF64(dp_diag), @@ -106,8 +100,8 @@ end q_shift::Real=0.0) -> SurfaceCoupling Generic fallback constructor. Use this when wiring a new inner-layer model -into the dispersion solver — pass the appropriate inner→outer-units `scale`, -per-surface `tauk`, and (for a rotating surface) `q_shift` explicitly. +into the dispersion solver — pass the appropriate inner→outer-units `scale` +and per-surface `tauk` explicitly. """ function surface_coupling(model::InnerLayerModel, params, dp_diag::Number; dc::Real=0.0, scale::Real=1.0, tauk::Real=1.0, q_shift::Real=0.0) diff --git a/src/Tearing/Runner/Control.jl b/src/Tearing/Runner/Control.jl index 6e9e0bebc..c42ec2903 100644 --- a/src/Tearing/Runner/Control.jl +++ b/src/Tearing/Runner/Control.jl @@ -36,9 +36,7 @@ constructor. `"m/n"` (TOML: `omega_E_kHz = {"2/1" = 0.0, "3/1" = 3.0}`). Per unit toroidal mode number like the kinetic file's `omega_E`, but given as Ω_E/2π in kHz rather than the file's rad/s. Surfaces not listed (all of them by default) take Ω_E from the kinetic file; a key matching - no analysed surface is an error. Rotation enters only the coupled determinant: surface `k`'s - inner-layer Q is Doppler-shifted by `ΔRe(Q_k) = −tauk_k · n · Ω_E,k`, so each layer sees the - mode in its own E×B frame + no analysed surface is an error. Rotation enters only the coupled determinant - `mu_i` -- ion mass in proton-mass units (default 2.0 for D) - `zeff` -- effective charge - `chi_perp`, `chi_tor` -- fallback perpendicular / toroidal heat @@ -197,10 +195,6 @@ function validate(ctrl::SLAYERControl) all(isfinite, values(ctrl.omega_E_kHz)) || throw(ArgumentError("SLAYERControl: omega_E_kHz contains a " * "non-finite entry: $(ctrl.omega_E_kHz)")) - for key in keys(ctrl.omega_E_kHz) - _mn_key(key) == key || - throw(ArgumentError("SLAYERControl: omega_E_kHz key \"$key\" is not canonical; write it as \"$(_mn_key(key))\"")) - end ctrl.nre >= 2 && ctrl.nim >= 2 || throw(ArgumentError("SLAYERControl: nre and nim must both be ≥ 2")) ctrl.amr_passes >= 0 || @@ -208,13 +202,6 @@ function validate(ctrl::SLAYERControl) return ctrl end -# Canonical "m/n" label of an omega_E_kHz key; throws on anything not of that form. -function _mn_key(key::AbstractString) - mt = match(r"^\s*(-?\d+)\s*/\s*(\d+)\s*$", key) - mt === nothing && throw(ArgumentError("SLAYERControl: omega_E_kHz key \"$key\" is not of the form \"m/n\", e.g. \"2/1\"")) - return "$(parse(Int, mt[1]))/$(parse(Int, mt[2]))" -end - # Helper: coerce range-like values to a 2-tuple of Float64 _as_range(x::NTuple{2,<:Real}) = (Float64(x[1]), Float64(x[2])) _as_range(x::AbstractVector) = begin @@ -276,10 +263,7 @@ function slayer_control_from_toml(section::AbstractDict) v isa AbstractDict || throw(ArgumentError("slayer_control_from_toml: omega_E_kHz must be a table keyed by m/n, " * "e.g. omega_E_kHz = {\"2/1\" = 0.0, \"3/1\" = 3.0}; got $v")) - table = Dict{String,Float64}(_mn_key(String(key)) => Float64(x) for (key, x) in v) - length(table) == length(v) || - throw(ArgumentError("slayer_control_from_toml: omega_E_kHz names the same m/n twice: $(collect(keys(v)))")) - kwargs[sym] = table + kwargs[sym] = Dict{String,Float64}(String(key) => Float64(x) for (key, x) in v) elseif sym === :boxes # `boxes` is a Vector{NTuple{4,Float64}}; from TOML this comes # in as a list of 4-element arrays. Coerce each. diff --git a/src/Tearing/Runner/run_slayer.jl b/src/Tearing/Runner/run_slayer.jl index 9cf87ffbd..ee4b89071 100644 --- a/src/Tearing/Runner/run_slayer.jl +++ b/src/Tearing/Runner/run_slayer.jl @@ -184,9 +184,8 @@ the coupled scan runs in the lab frame, so Q_layer = τ_k·ω_lab − τ_k·n·Ω_E ⇒ q_shift = −τ_k·n·Ω_E ``` -A layer root at rest in its E×B frame therefore sits at `ω_lab = n·Ω_E`: the mode rotates with the -plasma, at unchanged γ. The conjugated layer Δ and the opposite-sign diamagnetic inputs -(`Q_e = −τ_k·ω_*e`) flip together and leave this sign unchanged. +The conjugated layer Δ and the opposite-sign diamagnetic inputs (`Q_e = −τ_k·ω_*e`) flip together +and leave this sign unchanged. """ function _q_shift(p::SLAYERParameters, Ω_E::Real) iszero(Ω_E) || p.n >= 1 || @@ -196,8 +195,6 @@ end # GGJ carries no time normalization (tauk = 1), so a physical rotation has no Q-space image. _q_shift(::InnerLayerParameters, ::Real) = 0.0 -_q_shifts(params, Ω_E::AbstractVector{<:Real}) = Float64[_q_shift(params[k], Ω_E[k]) for k in eachindex(Ω_E)] - # --------------------------------------------------------------------- # Reference-length conversion of the outer Δ' for the slab layer # --------------------------------------------------------------------- @@ -334,7 +331,7 @@ function run_slayer_from_inputs(params::AbstractVector{<:InnerLayerParameters}, Ω_E = _omega_E_per_surface(control, params, omega_E) coupled && _is_ggj(model) && any(!iszero, Ω_E) && @warn( "SLAYER: E×B rotation is not applied to GGJ surfaces, which carry no time normalization.") - q_shifts = coupled ? _q_shifts(params, Ω_E) : zeros(Float64, n) + q_shifts = coupled ? Float64[_q_shift(params[k], Ω_E[k]) for k in 1:n] : zeros(Float64, n) scs = [_build_surface_coupling(model, params[k], dp[k, k], q_shifts[k]) for k in 1:n] # Coupled Q is scanned in the reference surface's normalization. ref_idx = 1 diff --git a/test/runtests_dispersion_coupled_full.jl b/test/runtests_dispersion_coupled_full.jl index d862374a3..3b4143f5b 100644 --- a/test/runtests_dispersion_coupled_full.jl +++ b/test/runtests_dispersion_coupled_full.jl @@ -135,13 +135,6 @@ mc = multi_surface_coupling_full([sc1, sc2], dp_raw) @test mc(Q) ≈ detAt(Q * (2.0 / 2.0) + s1, 0im) * detAt(Q * (4.0 / 2.0) + s2, 0im) - - # The shift is real: it moves the layer argument along Re(Q) only. - mc0 = multi_surface_coupling_full([surface_coupling(lin, nothing, 0 + 0im; scale=1.0, tauk=1.0, dc=0.0)], - Matrix{ComplexF64}(I, 2, 2)) - mcs = multi_surface_coupling_full([surface_coupling(lin, nothing, 0 + 0im; scale=1.0, tauk=1.0, dc=0.0, q_shift=3.0)], - Matrix{ComplexF64}(I, 2, 2)) - @test mcs(2.0 + 0.0im) ≈ mc0(5.0 + 0.0im) end @testset "SurfaceCoupling scale multiplies both inner channels" begin diff --git a/test/runtests_dispersion_rotation.jl b/test/runtests_dispersion_rotation.jl index a3f91f33a..61e713b15 100644 --- a/test/runtests_dispersion_rotation.jl +++ b/test/runtests_dispersion_rotation.jl @@ -3,7 +3,7 @@ using GeneralizedPerturbedEquilibrium.InnerLayer: InnerLayerModel, solve_inner using GeneralizedPerturbedEquilibrium.Dispersion using GeneralizedPerturbedEquilibrium.Tearing.Runner: SLAYERControl, - slayer_control_from_toml, validate, _q_shifts, _omega_E_per_surface, _build_surface_coupling + slayer_control_from_toml, validate, _q_shift, _build_surface_coupling using LinearAlgebra # Linear inner layer Δ(Q) = a + b·Q makes the applied Q offset readable @@ -18,13 +18,6 @@ model = RotTestModel(0.0im, 1.0 + 0im) - @testset "q_shift defaults to zero and is inert" begin - sc = surface_coupling(model, nothing, 1.0 + 0im; scale=1.0, tauk=1.0) - @test sc.q_shift == 0.0 - # Unshifted residual is dp_diag - Δ(Q) = 1 - Q - @test sc(2.0 + 0im) ≈ (1.0 - 2.0) + 0im - end - @testset "q_shift offsets the layer Q argument on the scalar residual" begin shift = 0.75 sc0 = surface_coupling(model, nothing, 1.0 + 0im; scale=1.0, tauk=1.0) @@ -36,17 +29,6 @@ end end - @testset "q_shift is real: it moves Re(Q), not Im(Q)" begin - # The shift models rotation, so it must not leak into the growth-rate - # axis. Δ(Q) = Q here, so the residual difference IS the applied offset. - sc0 = surface_coupling(model, nothing, 0.0 + 0im; scale=1.0, tauk=1.0) - scs = surface_coupling(model, nothing, 0.0 + 0im; scale=1.0, tauk=1.0, - q_shift=2.0) - applied = sc0(1.0 + 1.0im) - scs(1.0 + 1.0im) # = Δ(Q+s) - Δ(Q) = s - @test real(applied) ≈ 2.0 - @test imag(applied) ≈ 0.0 atol = 1e-14 - end - @testset "Coupled determinant applies each surface's own shift" begin # Diagonal Δ', so det = Π_k (dp_kk - Δ_k(Q·tauk_k/tauk_ref + shift_k)). s1, s2 = 0.5, -1.25 @@ -58,60 +40,23 @@ mc = multi_surface_coupling([sc1, sc2], dp) Q = 1.5 + 0.5im - # Default :direct rescale: surface k sees Q·tauk_k/tauk_ref + q_shift_k. expected = (1.0 - (Q * (1.0 / 1.0) + s1)) * (2.0 - (Q * (2.0 / 1.0) + s2)) @test mc(Q) ≈ expected - - # Zero shifts reproduce the un-rotated determinant exactly. - sc1z = surface_coupling(model, nothing, 1.0 + 0im; scale=1.0, tauk=1.0) - sc2z = surface_coupling(model, nothing, 2.0 + 0im; scale=1.0, tauk=2.0) - mcz = multi_surface_coupling([sc1z, sc2z], dp) - @test mcz(Q) ≈ (1.0 - Q) * (2.0 - 2Q) - end - - @testset "_q_shifts: kinetic-file Ω_E by default, omega_E_kHz override, n-scaled" begin - mk(; qval, rs, m, n) = slayer_parameters(; n_e=5.0e19, t_e=1000.0, t_i=1000.0, - omega_e=1.0e4, omega_i=5.0e3, - qval=qval, sval_r=1.0, bt=2.0, rs=rs, R0=1.7, - mu_i=2.0, zeff=1.0, chi_perp=1.0, chi_tor=1.0, m=m, n=n) - params = [mk(; qval=1.5, rs=0.5, m=3, n=2), mk(; qval=2.0, rs=0.6, m=4, n=2)] - Ω_file = [3.0e4, -1.0e4] # rad/s per unit n, as carried by the kinetic file - qs(ctrl; omega_E=Float64[]) = _q_shifts(params, _omega_E_per_surface(ctrl, params, omega_E)) - - # No file rotation and no override: no shift anywhere. - @test qs(SLAYERControl()) == [0.0, 0.0] - - # File rotation by default. The mode sees n·Ω_E, so Q_k = τ_k·(ω − n·Ω_E) and the offset - # is −τ_k·n·Ω_E. - got = qs(SLAYERControl(); omega_E=Ω_file) - @test got ≈ [-params[1].tauk * 2 * Ω_file[1], -params[2].tauk * 2 * Ω_file[2]] - - # omega_E_kHz replaces the file value on each listed m/n, in the same per-unit-n convention. - ovr = qs(SLAYERControl(; omega_E_kHz=Dict("3/2" => 1.0, "4/2" => -2.0)); omega_E=Ω_file) - @test ovr ≈ [-params[1].tauk * 2 * 2π * 1e3 * 1.0, -params[2].tauk * 2 * 2π * 1e3 * -2.0] - - # Unlisted surfaces keep the kinetic-file value. - part = qs(SLAYERControl(; omega_E_kHz=Dict("4/2" => -2.0)); omega_E=Ω_file) - @test part ≈ [-params[1].tauk * 2 * Ω_file[1], -params[2].tauk * 2 * 2π * 1e3 * -2.0] - - # An m/n matching no analysed surface is an error, as is a file of the wrong length. - @test_throws "matching no analysed surface" qs(SLAYERControl(; omega_E_kHz=Dict("2/1" => 1.0))) - @test_throws ArgumentError qs(SLAYERControl(); omega_E=[1.0]) end @testset "Real SLAYER surfaces: the lab-frame root moves with the E×B rotation" begin # Q_layer = τ_k·ω_lab − τ_k·n·Ω_E, so the rotating determinant at Q + τ_ref·n·Ω_E equals the # static one at Q: every root keeps its γ and moves by ω_lab(Ω_E) − ω_lab(0) = +n·Ω_E. - mk(; qval, rs, m, n) = slayer_parameters(; n_e=5.0e19, t_e=1000.0, t_i=1000.0, + mk(; qval, rs, m) = slayer_parameters(; n_e=5.0e19, t_e=1000.0, t_i=1000.0, omega_e=1.0e4, omega_i=5.0e3, qval=qval, sval_r=1.0, bt=2.0, rs=rs, R0=1.7, - mu_i=2.0, zeff=1.0, chi_perp=1.0, chi_tor=1.0, m=m, n=n) - params = [mk(; qval=1.5, rs=0.5, m=3, n=2), mk(; qval=2.0, rs=0.6, m=4, n=2)] + mu_i=2.0, zeff=1.0, chi_perp=1.0, chi_tor=1.0, m=m, n=2) + params = [mk(; qval=1.5, rs=0.5, m=3), mk(; qval=2.0, rs=0.6, m=4)] slayer = SLAYERModel(; variant=:fitzpatrick) dp = ComplexF64[-2.0 0.5; 0.5 -3.0] lab_shift = 0.5 # τ_ref·n·Ω_E in the reference Q Ω_rigid = lab_shift / (params[1].tauk * params[1].n) # rad/s per unit n, on both surfaces - build(Ω) = [_build_surface_coupling(slayer, params[k], dp[k, k], q) for (k, q) in enumerate(_q_shifts(params, Ω))] + build(Ω) = [_build_surface_coupling(slayer, params[k], dp[k, k], _q_shift(params[k], Ω[k])) for k in 1:2] static = multi_surface_coupling(build(zeros(2)), dp; ref_idx=1, msing_max=2) rotating = multi_surface_coupling(build(fill(Ω_rigid, 2)), dp; ref_idx=1, msing_max=2) for Q in (0.3 + 0.2im, -0.4 + 0.6im, 0.1 + 1.1im) @@ -141,38 +86,14 @@ end end - @testset "Rigid E×B rotation shifts ω by n·Ω_E and leaves γ unchanged" begin - d = ComplexF64[1.0+1.0im, 3.0+2.0im] - tauk = [2.0e-4, 5.0e-4] - nΩ = 700.0 - static = [surface_coupling(model, nothing, d[k]; scale=1.0, tauk=tauk[k]) for k in 1:2] - rigid = [surface_coupling(model, nothing, d[k]; scale=1.0, tauk=tauk[k], q_shift=-tauk[k] * nΩ) - for k in 1:2] - function physical_roots(scs) - mc = multi_surface_coupling(scs, diagm(d); ref_idx=1, msing_max=2) - xs = ComplexF64[0, 1, 2] - c = [x^j for x in xs, j in 0:2] \ [mc(x) for x in xs] - disc = sqrt(c[2]^2 - 4c[3] * c[1]) - return sort([(-c[2] + disc) / (2c[3]), (-c[2] - disc) / (2c[3])] ./ tauk[1]; by=real) - end - r0, r1 = physical_roots(static), physical_roots(rigid) - @test real.(r1) ≈ real.(r0) .+ nΩ rtol = 1e-10 - @test imag.(r1) ≈ imag.(r0) rtol = 1e-10 - end - @testset "omega_E_kHz parses and validates from TOML" begin - ctrl = slayer_control_from_toml(Dict("omega_E_kHz" => Dict("2/1" => 0, " 3 / 1 " => 3.0))) + ctrl = slayer_control_from_toml(Dict("omega_E_kHz" => Dict("2/1" => 0, "3/1" => 3.0))) @test ctrl.omega_E_kHz == Dict("2/1" => 0.0, "3/1" => 3.0) - @test ctrl.omega_E_kHz isa Dict{String,Float64} # Default stays empty so existing decks are untouched. @test isempty(slayer_control_from_toml(Dict{String,Any}()).omega_E_kHz) - # The positional list form is gone, and keys must read m/n. @test_throws "table keyed by m/n" slayer_control_from_toml(Dict("omega_E_kHz" => [0.0, 3.0])) - @test_throws "not of the form" slayer_control_from_toml(Dict("omega_E_kHz" => Dict("2:1" => 3.0))) - @test_throws "same m/n twice" slayer_control_from_toml(Dict("omega_E_kHz" => Dict("2/1" => 1.0, "02/1" => 2.0))) # A non-finite shift would silently poison every Q evaluation; the # validator rejects it (TOML cannot express NaN, so go through validate). @test_throws ArgumentError validate(SLAYERControl(; omega_E_kHz=Dict("2/1" => NaN))) - @test_throws "not canonical" validate(SLAYERControl(; omega_E_kHz=Dict("02/1" => 1.0))) end end diff --git a/test/runtests_slayer_runner.jl b/test/runtests_slayer_runner.jl index e9a374906..30a52c89d 100644 --- a/test/runtests_slayer_runner.jl +++ b/test/runtests_slayer_runner.jl @@ -154,16 +154,20 @@ r_cpl = run_slayer_from_inputs(params, dpm, SLAYERControl(; coupling_mode=:coupled, grid...); omega_E=Ω_E) @test r_cpl.q_shift ≈ [-1.0e-4 * 1 * Ω_E[1], -1.2e-4 * 1 * Ω_E[2]] @test r_cpl.omega_E == Ω_E + # The mode sees n·Ω_E: the same rotation shifts an n = 2 surface twice as far. + r_n2 = run_slayer_from_inputs([_mk_params(; tauk=1.0e-4, m=3, n=2)], ComplexF64[-2.0;;], + SLAYERControl(; coupling_mode=:coupled, grid...); omega_E=[2.0e3]) + @test r_n2.q_shift ≈ [-1.0e-4 * 2 * 2.0e3] + @test_throws "rational surfaces were analysed" run_slayer_from_inputs(params, dpm, + SLAYERControl(; coupling_mode=:coupled, grid...); omega_E=[1.0]) - # omega_E_kHz takes precedence over the file on the surfaces it lists. + # omega_E_kHz takes precedence over the file on the surfaces it lists; the rest keep the file value. r_ovr = run_slayer_from_inputs(params, dpm, - SLAYERControl(; coupling_mode=:coupled, omega_E_kHz=Dict("2/1" => 1.0, "3/1" => 0.0), grid...); omega_E=Ω_E) - @test r_ovr.q_shift ≈ [-1.0e-4 * 2π * 1e3, 0.0] - @test r_ovr.omega_E ≈ [2π * 1e3, 0.0] - r_part = run_slayer_from_inputs(params, dpm, SLAYERControl(; coupling_mode=:coupled, omega_E_kHz=Dict("2/1" => 1.0), grid...); omega_E=Ω_E) - @test r_part.omega_E ≈ [2π * 1e3, Ω_E[2]] - @test r_part.q_shift ≈ [-1.0e-4 * 2π * 1e3, -1.2e-4 * Ω_E[2]] + @test r_ovr.omega_E ≈ [2π * 1e3, Ω_E[2]] + @test r_ovr.q_shift ≈ [-1.0e-4 * 2π * 1e3, -1.2e-4 * Ω_E[2]] + @test_throws "matching no analysed surface" run_slayer_from_inputs(params, dpm, + SLAYERControl(; coupling_mode=:coupled, omega_E_kHz=Dict("5/1" => 1.0), grid...); omega_E=Ω_E) # A Doppler offset beyond the scan box is flagged rather than silently losing the root: # τ_ref·n·Ω_E = 1e-4 · 3e4 = 3 lies outside Re(Q) ∈ [-1, 1]. From cac33caa554d4f28c652b214baac6978d27a42e4 Mon Sep 17 00:00:00 2001 From: d-burg Date: Tue, 6 Oct 2026 16:38:33 -0400 Subject: [PATCH 20/20] Tearing - REFACTOR - Move the omega_E_kHz rotation override out of the coupled-determinant fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fix takes E×B rotation from the kinetic file only. The per-surface override is a rotation-scan feature and goes to its own pull request. Co-Authored-By: Claude Opus 5.5 --- examples/DIIID-like_SLAYER_example/gpec.toml | 1 - src/Tearing/Runner/Control.jl | 15 -------- src/Tearing/Runner/HDF5Output.jl | 2 +- src/Tearing/Runner/Result.jl | 5 +-- src/Tearing/Runner/run_slayer.jl | 39 +++----------------- test/runtests_dispersion_rotation.jl | 14 +------ test/runtests_slayer_runner.jl | 8 ---- 7 files changed, 9 insertions(+), 75 deletions(-) diff --git a/examples/DIIID-like_SLAYER_example/gpec.toml b/examples/DIIID-like_SLAYER_example/gpec.toml index 29b91a47f..a8a99f4c7 100644 --- a/examples/DIIID-like_SLAYER_example/gpec.toml +++ b/examples/DIIID-like_SLAYER_example/gpec.toml @@ -75,7 +75,6 @@ mu_i = 2.0 # Ion mass in proton-mass units (2.0 = deut zeff = 1.0 # Effective charge chi_perp = 1.0 # fallback only; the kinetic file supplies χ⊥(ψ) chi_tor = 1.0 # fallback only; the kinetic file supplies χ_φ(ψ) -# omega_E_kHz = {"2/1" = 0.0, "3/1" = 3.0} # override of the E×B rotation Ω_E/2π per unit n [kHz], keyed by m/n; unlisted surfaces use the kinetic file pole_threshold_adaptive = true # SLAYER |Δ| spans many orders; adapt to 10·median(|Δ|) filter_above_poles = true # Discard roots above the highest pole γ filter_outside_re = true # Condition the above-pole filter on the +γ step exiting the Re(Δ)=0 contour loop diff --git a/src/Tearing/Runner/Control.jl b/src/Tearing/Runner/Control.jl index c42ec2903..1a12ff7e6 100644 --- a/src/Tearing/Runner/Control.jl +++ b/src/Tearing/Runner/Control.jl @@ -32,11 +32,6 @@ constructor. - `bt` -- toroidal field `[T]`. `nothing` (default) resolves the physical `B_T = F(ψ)/(2π·R₀)` per surface from the equilibrium's F-spline; a scalar or a callable of `psi` overrides it - - `omega_E_kHz` -- optional override of the per-surface E×B rotation, keyed by the surface's - `"m/n"` (TOML: `omega_E_kHz = {"2/1" = 0.0, "3/1" = 3.0}`). Per unit toroidal mode number - like the kinetic file's `omega_E`, but given as Ω_E/2π in kHz rather than the file's rad/s. - Surfaces not listed (all of them by default) take Ω_E from the kinetic file; a key matching - no analysed surface is an error. Rotation enters only the coupled determinant - `mu_i` -- ion mass in proton-mass units (default 2.0 for D) - `zeff` -- effective charge - `chi_perp`, `chi_tor` -- fallback perpendicular / toroidal heat @@ -156,8 +151,6 @@ there is one consistent interface for resistive and kinetic profiles. # / failed-Δ'-BVP surface, not a real root. Flagged `:spurious`. validity_rtol::Float64 = 1e-3 - omega_E_kHz::Dict{String,Float64} = Dict{String,Float64}() - profile_file::String = "" profile_group::String = "/" @@ -192,9 +185,6 @@ function validate(ctrl::SLAYERControl) "not in $(_VALID_LNLAMBDA_FORMS)")) ctrl.msing_max >= 1 || throw(ArgumentError("SLAYERControl: msing_max=$(ctrl.msing_max) must be ≥ 1")) - all(isfinite, values(ctrl.omega_E_kHz)) || - throw(ArgumentError("SLAYERControl: omega_E_kHz contains a " * - "non-finite entry: $(ctrl.omega_E_kHz)")) ctrl.nre >= 2 && ctrl.nim >= 2 || throw(ArgumentError("SLAYERControl: nre and nim must both be ≥ 2")) ctrl.amr_passes >= 0 || @@ -259,11 +249,6 @@ function slayer_control_from_toml(section::AbstractDict) elseif sym in (:bt, :dr_val, :dgeo_val) # Allow explicit nothing (auto-derive) or a number (override) kwargs[sym] = v === nothing ? nothing : Float64(v) - elseif sym === :omega_E_kHz - v isa AbstractDict || - throw(ArgumentError("slayer_control_from_toml: omega_E_kHz must be a table keyed by m/n, " * - "e.g. omega_E_kHz = {\"2/1\" = 0.0, \"3/1\" = 3.0}; got $v")) - kwargs[sym] = Dict{String,Float64}(String(key) => Float64(x) for (key, x) in v) elseif sym === :boxes # `boxes` is a Vector{NTuple{4,Float64}}; from TOML this comes # in as a list of 4-element arrays. Coerce each. diff --git a/src/Tearing/Runner/HDF5Output.jl b/src/Tearing/Runner/HDF5Output.jl index 1dc6d9ee7..b5395bfde 100644 --- a/src/Tearing/Runner/HDF5Output.jl +++ b/src/Tearing/Runner/HDF5Output.jl @@ -86,7 +86,7 @@ const TEARING_H5_ANNOTATIONS = [ "PerSurface/tau_k" => (; long_name="Q-normalization time S^(1/3)·τ_H per surface (Q = τ_k·ω; diamagnetic inputs Q_e, Q_i carry the opposite sign by convention)", units="s", dims=("surface",)), "PerSurface/omega_E" => - (; long_name="E×B angular frequency Ω_E per unit n on each surface (kinetic file or omega_E_kHz override)", units="rad/s", dims=("surface",)), + (; long_name="E×B angular frequency Ω_E per unit n on each surface, from the kinetic file", units="rad/s", dims=("surface",)), "PerSurface/q_shift" => (; long_name="real E×B Doppler offset ΔRe(Q) = −τ_k·n·Ω_E applied to each surface's inner-layer Q in the coupled determinant (0 in uncoupled mode)", dims=("surface",)), "PerSurface/tau_R" => (; long_name="resistive diffusion time τ_R = μ₀r_s²/η per surface", units="s", dims=("surface",)), diff --git a/src/Tearing/Runner/Result.jl b/src/Tearing/Runner/Result.jl index 1d2ddfb0b..3a018a38d 100644 --- a/src/Tearing/Runner/Result.jl +++ b/src/Tearing/Runner/Result.jl @@ -38,9 +38,8 @@ downstream inspection and HDF5 output. - `layer_widths` -- `Vector{LayerWidths}`, one per surface: the resistive layer thickness (in meters) from the `del_s` Riccati solve plus FKR / visco-resistive sanity scales. Empty when disabled. - - `omega_E` -- E×B angular frequency Ω_E per unit n [rad/s] resolved on each - surface: the kinetic file's value, or the `control.omega_E_kHz` override for that m/n. - Recorded in both coupling modes + - `omega_E` -- E×B angular frequency Ω_E per unit n [rad/s] on each surface, + from the kinetic file. Recorded in both coupling modes - `q_shift` -- real Doppler offset `−τ_k·n·Ω_E` applied to each surface's inner-layer Q; zero outside `:coupled` mode, where rotation does not enter - `scan_data` -- scan results (per-surface in uncoupled, single diff --git a/src/Tearing/Runner/run_slayer.jl b/src/Tearing/Runner/run_slayer.jl index ee4b89071..0556feeb2 100644 --- a/src/Tearing/Runner/run_slayer.jl +++ b/src/Tearing/Runner/run_slayer.jl @@ -147,32 +147,6 @@ end _build_surface_coupling(model::GGJModel, params::GGJParameters, dp_diag, ::Real) = surface_coupling(model, params, dp_diag) -# "m/n" label keying `control.omega_E_kHz`; GGJ parameters carry no mode numbers. -_mn_label(p::SLAYERParameters) = "$(p.m)/$(p.n)" -_mn_label(::InnerLayerParameters) = nothing - -# Per-surface E×B angular frequency Ω_E per unit n [rad/s]: the kinetic file's `omega_E` at each -# rational surface, replaced on every surface whose m/n `control.omega_E_kHz` lists. -function _omega_E_per_surface(control::SLAYERControl, params::AbstractVector, omega_E_file::AbstractVector{<:Real}) - n = length(params) - isempty(omega_E_file) || length(omega_E_file) == n || - throw(ArgumentError("run_slayer: omega_E has $(length(omega_E_file)) entries but " * - "$n rational surfaces were analysed.")) - Ω_E = isempty(omega_E_file) ? zeros(Float64, n) : Float64.(omega_E_file) - isempty(control.omega_E_kHz) && return Ω_E - labels = [_mn_label(p) for p in params] - any(isnothing, labels) && - throw(ArgumentError("run_slayer: omega_E_kHz is keyed by m/n, which $(eltype(params)) surfaces do not carry.")) - unknown = setdiff(keys(control.omega_E_kHz), labels) - isempty(unknown) || - throw(ArgumentError("run_slayer: omega_E_kHz lists m/n $(sort(collect(unknown))) matching no analysed surface; " * - "the analysed surfaces are $(labels).")) - for (k, label) in enumerate(labels) - haskey(control.omega_E_kHz, label) && (Ω_E[k] = 2π * 1e3 * control.omega_E_kHz[label]) - end - return Ω_E -end - """ _q_shift(p::SLAYERParameters, Ω_E) -> Float64 @@ -251,9 +225,8 @@ Run the SLAYER tearing analysis given pre-built per-surface equilibrium-driven `build_slayer_inputs` step — use this when the parameters are already known (e.g. in unit tests or when rebuilding from cached HDF5 output). `omega_E` is the E×B angular frequency per unit n -at each surface [rad/s], replaced on any surface whose m/n `control.omega_E_kHz` -lists; empty means no rotation. In coupled mode it Doppler-shifts each surface's -inner-layer Q; in both modes the resolved value is reported as `result.omega_E`. +at each surface [rad/s]; empty means no rotation. In coupled mode it Doppler-shifts +each surface's inner-layer Q; in both modes it is reported as `result.omega_E`. """ function run_slayer_from_inputs(params::AbstractVector{<:InnerLayerParameters}, dp_matrix::AbstractMatrix, @@ -324,11 +297,9 @@ function run_slayer_from_inputs(params::AbstractVector{<:InnerLayerParameters}, # E×B rotation only Doppler-shifts the coupled determinant: each uncoupled layer is already # solved in its own plasma frame, so a shift there would only relabel the reported frequency. coupled = control.coupling_mode === :coupled - !coupled && !isempty(control.omega_E_kHz) && - @warn( - "SLAYER: omega_E_kHz does not shift the layers with coupling_mode=:uncoupled; rotation only " * - "enters the coupled determinant, so it is just recorded in PerSurface/omega_E.") - Ω_E = _omega_E_per_surface(control, params, omega_E) + isempty(omega_E) || length(omega_E) == n || + throw(ArgumentError("run_slayer: omega_E has $(length(omega_E)) entries but $n rational surfaces were analysed.")) + Ω_E = isempty(omega_E) ? zeros(Float64, n) : omega_E coupled && _is_ggj(model) && any(!iszero, Ω_E) && @warn( "SLAYER: E×B rotation is not applied to GGJ surfaces, which carry no time normalization.") q_shifts = coupled ? Float64[_q_shift(params[k], Ω_E[k]) for k in 1:n] : zeros(Float64, n) diff --git a/test/runtests_dispersion_rotation.jl b/test/runtests_dispersion_rotation.jl index 61e713b15..488fa95b8 100644 --- a/test/runtests_dispersion_rotation.jl +++ b/test/runtests_dispersion_rotation.jl @@ -2,8 +2,7 @@ using GeneralizedPerturbedEquilibrium.InnerLayer using GeneralizedPerturbedEquilibrium.InnerLayer: InnerLayerModel, solve_inner using GeneralizedPerturbedEquilibrium.Dispersion - using GeneralizedPerturbedEquilibrium.Tearing.Runner: SLAYERControl, - slayer_control_from_toml, validate, _q_shift, _build_surface_coupling + using GeneralizedPerturbedEquilibrium.Tearing.Runner: _q_shift, _build_surface_coupling using LinearAlgebra # Linear inner layer Δ(Q) = a + b·Q makes the applied Q offset readable @@ -85,15 +84,4 @@ @test sort(roots ./ tauk[ref]; by=real) ≈ expected rtol = 1e-10 end end - - @testset "omega_E_kHz parses and validates from TOML" begin - ctrl = slayer_control_from_toml(Dict("omega_E_kHz" => Dict("2/1" => 0, "3/1" => 3.0))) - @test ctrl.omega_E_kHz == Dict("2/1" => 0.0, "3/1" => 3.0) - # Default stays empty so existing decks are untouched. - @test isempty(slayer_control_from_toml(Dict{String,Any}()).omega_E_kHz) - @test_throws "table keyed by m/n" slayer_control_from_toml(Dict("omega_E_kHz" => [0.0, 3.0])) - # A non-finite shift would silently poison every Q evaluation; the - # validator rejects it (TOML cannot express NaN, so go through validate). - @test_throws ArgumentError validate(SLAYERControl(; omega_E_kHz=Dict("2/1" => NaN))) - end end diff --git a/test/runtests_slayer_runner.jl b/test/runtests_slayer_runner.jl index 30a52c89d..de18d5623 100644 --- a/test/runtests_slayer_runner.jl +++ b/test/runtests_slayer_runner.jl @@ -161,14 +161,6 @@ @test_throws "rational surfaces were analysed" run_slayer_from_inputs(params, dpm, SLAYERControl(; coupling_mode=:coupled, grid...); omega_E=[1.0]) - # omega_E_kHz takes precedence over the file on the surfaces it lists; the rest keep the file value. - r_ovr = run_slayer_from_inputs(params, dpm, - SLAYERControl(; coupling_mode=:coupled, omega_E_kHz=Dict("2/1" => 1.0), grid...); omega_E=Ω_E) - @test r_ovr.omega_E ≈ [2π * 1e3, Ω_E[2]] - @test r_ovr.q_shift ≈ [-1.0e-4 * 2π * 1e3, -1.2e-4 * Ω_E[2]] - @test_throws "matching no analysed surface" run_slayer_from_inputs(params, dpm, - SLAYERControl(; coupling_mode=:coupled, omega_E_kHz=Dict("5/1" => 1.0), grid...); omega_E=Ω_E) - # A Doppler offset beyond the scan box is flagged rather than silently losing the root: # τ_ref·n·Ω_E = 1e-4 · 3e4 = 3 lies outside Re(Q) ∈ [-1, 1]. @test_logs (:warn, r"outside Q_re_range") match_mode = :any run_slayer_from_inputs(params, dpm,