From 1f01e9c730d4979a062ae58dddf6e33ff716bb7e Mon Sep 17 00:00:00 2001 From: Via Weber Date: Wed, 8 Jul 2026 10:47:27 -0400 Subject: [PATCH 01/38] FORCEFREESTATES - MINOR - added first iteration of looping BC in Riccati --- src/ForceFreeStates/Riccati.jl | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/ForceFreeStates/Riccati.jl b/src/ForceFreeStates/Riccati.jl index f134b686..d36df687 100644 --- a/src/ForceFreeStates/Riccati.jl +++ b/src/ForceFreeStates/Riccati.jl @@ -628,6 +628,33 @@ function _assemble_bvp_S_axis(uShootR::Vector{Matrix{ComplexF64}}, return M, nMat, col_edge end +# Loop the 2N boundary conditions of Eq. (37) [Glasser-Kolemen 2018 PoP 25, 032501]. +# M is the BVP matrix from _assemble_bvp_S_axis. The last 2*msing rows at the driving rows: +# setting one to 1 assigns that big-solution coefficients. Solve once per boundary condition +# and collect the small-solution coefficients into the raw D' matrix. +function loop_boundary_conditions(M::Matrix{ComplexF64}, msing::Int, N::Int, + ipert_all::Vector{Int}) + nMat = size(M, 1) + s2 = 2 * msing + M_lu = lu(M) + dp_raw = zeros(ComplexF64, s2, s2) + b = zeros(ComplexF64, nMat) + + for jsing in 1:msing, side in 1:2 + dRow = 2jsing - (2 - side) + fill!(b, 0) + b[nMat - s2 + dRow] = 1 + x = M_lu \ b + + for ksing in 1:msing + ipert_k = ipert_all[ksing] + dp_raw[dRow, 2ksing-1] = x[col_left(ksing, N)[ipert_k + N]] + dp_raw[dRow, 2ksing] = x[col_right(ksing, N)[ipert_k + N]] + end + return dp_raw + end + + # Fallback BVP assembly with FM-based axis BC (used when no Riccati S matrices are available). # Uses the conditioned axis propagator Phi_R[1][:,N+1:2N] in place of S-axis matching. function _assemble_bvp_FM_axis(Phi_L_mats::Vector{Matrix{ComplexF64}}, From 3a0837e3b360c8d973440bc11fd60c75a98ae454 Mon Sep 17 00:00:00 2001 From: Matthew Pharr Date: Wed, 8 Jul 2026 12:10:37 -0400 Subject: [PATCH 02/38] InnerLayer (GGJ) - NEW - complex rotated-ray entire-solution-based colocation method implementation of GGJ that avoids Delta poles on the imaginary Q axis and can solve for Delta_even adn Delta_odd up to very high (at least ~500) |Q| --- Project.toml | 6 +- src/InnerLayer/GGJ/GGJ.jl | 41 +- src/InnerLayer/GGJ/Ray.jl | 1183 ++++++++++++++++++++++++++ src/InnerLayer/GGJ/RayAsymptotics.jl | 244 ++++++ src/InnerLayer/InnerLayer.jl | 4 + test/runtests_innerlayer.jl | 36 + 6 files changed, 1499 insertions(+), 15 deletions(-) create mode 100644 src/InnerLayer/GGJ/Ray.jl create mode 100644 src/InnerLayer/GGJ/RayAsymptotics.jl diff --git a/Project.toml b/Project.toml index fd2c512d..478b05c1 100644 --- a/Project.toml +++ b/Project.toml @@ -25,6 +25,7 @@ PlotlyJS = "f0f68f2c-4968-5e81-91da-67840de0976a" Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80" Printf = "de0858da-6303-5e67-8744-51eddeeeb8d7" QuadGK = "1fd47b50-473d-5c70-9696-f719f8f3bcdc" +Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" Roots = "f2b01f46-fcfa-551c-844a-d8ac1e96c665" SparseArrays = "2f01184e-e22b-5df5-ae63-d93ebab69eaf" SpecialFunctions = "276daf66-3868-5448-9aa4-cd146d93841b" @@ -57,14 +58,11 @@ QuadGK = "2.11.3" Roots = "2.2.13" SparseArrays = "1" SpecialFunctions = "2.5.1" +Random = "1" StaticArrays = "1.9.15" Statistics = "1" TOML = "1" Test = "1" julia = "1.11" -[extras] -Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" -[targets] -test = ["Random"] diff --git a/src/InnerLayer/GGJ/GGJ.jl b/src/InnerLayer/GGJ/GGJ.jl index e7f0488a..289d3c38 100644 --- a/src/InnerLayer/GGJ/GGJ.jl +++ b/src/InnerLayer/GGJ/GGJ.jl @@ -1,15 +1,20 @@ # GGJ.jl # -# Glasser–Greene–Johnson resistive inner-layer model. Provides two +# Glasser–Greene–Johnson resistive inner-layer model. Provides three # interchangeable solvers selected via the `solver` type-parameter of # `GGJModel`: # -# - `:shooting` – stable backward shoot from X_max → 0 (Phase 3) -# - `:galerkin` – Hermite-cubic finite element method (Phase 4) +# - `:shooting` – stable backward shoot from X_max → 0; +# numerically stable only for |Q| ≪ 1, should not be used +# - `:galerkin` – Hermite-cubic finite element method; +# real-axis method, degrades for |Q| ≳ 1 off the real axis +# - `:ray` – rotated-contour spectral-element collocation (Ray.jl); +# robust to |Q| ~ 500 on/near the imaginary axis # -# Both solvers share the same `inps` Wasow asymptotic-basis kernel -# (`InnerAsymptotics.jl`) for the large-x boundary condition. They return -# the parity-projected matching data `(Δ_odd, Δ_even)` of GWP2016 Eqs. (34)–(35). +# All solvers share the same `inps` Wasow asymptotic-basis kernel +# (`InnerAsymptotics.jl`, complex-x extension in `RayAsymptotics.jl`) for the +# large-x boundary condition. They return the parity-projected matching data +# `(Δ_odd, Δ_even)` of GWP2016 Eqs. (34)–(35) in the same (deltac) convention. # # Equation references throughout this module use two source papers: # @@ -31,6 +36,10 @@ module GGJ using LinearAlgebra using StaticArrays +using SparseArrays +using Random +using Printf +using DoubleFloats: Double64 import ..InnerLayerModel, ..solve_inner @@ -38,25 +47,35 @@ import ..InnerLayerModel, ..solve_inner GGJModel{S} <: InnerLayerModel Glasser–Greene–Johnson resistive inner-layer model. The type parameter `S` -selects the solver: `:galerkin` (default) for the Hermite-cubic finite element -solver and `:shooting` for the backward stable-shoot solver. Both -implementations consume the same `inps` asymptotic-basis kernel and return -the parity-projected matching data. +selects the solver: `:ray` (default) for the rotated-contour collocation +solver (robust at large |Q| on/near the imaginary axis; agrees with +`:galerkin` within its error bar at moderate Q), `:galerkin` for the +Hermite-cubic finite element solver (real-axis method; degrades for +|Q| ≳ 1), and `:shooting` for the backward stable-shoot solver (|Q| ≪ 1 +only). All implementations consume the same `inps` asymptotic-basis kernel +and return the parity-projected matching data in the same convention. +Note the backends take different numerical-knob keywords: pass +`GGJModel(solver=:galerkin)` explicitly when using `nx`/`xfac`-style +Galerkin knobs. """ struct GGJModel{S} <: InnerLayerModel end -GGJModel(; solver::Symbol=:galerkin) = GGJModel{solver}() +GGJModel(; solver::Symbol=:ray) = GGJModel{solver}() include("GGJParameters.jl") include("InnerAsymptotics.jl") +include("RayAsymptotics.jl") include("Reference.jl") include("Shooting.jl") include("Galerkin.jl") +include("Ray.jl") export GGJModel, GGJParameters export mercier_di, mercier_dr, inner_Q, rescale_delta export build_asymptotics, evaluate_asymptotics, pick_xmax export InnerAsymptoticsCache export glasser_wang_2020_eq55 +export solve_ray, RaySolveResult, pick_smax, physical_ua_dua +export delta_convergence, solution_profile, asymptotic_profile, q4_surface_benchmark end # module GGJ diff --git a/src/InnerLayer/GGJ/Ray.jl b/src/InnerLayer/GGJ/Ray.jl new file mode 100644 index 00000000..174aebf8 --- /dev/null +++ b/src/InnerLayer/GGJ/Ray.jl @@ -0,0 +1,1183 @@ +# Ray.jl +# +# Rotated-contour spectral-element collocation backend (`GGJModel{:ray}`) +# for the GGJ inner-layer matching data — robust at large |Q| on and near +# the imaginary axis, where both `:shooting` (exponential dichotomy) and +# `:galerkin` (real-axis oscillation + on-axis pseudo-resonance) degrade. +# +# Ported 2026-07-08 from the standalone GGJRay package (~/Projects/GGJ_test), +# validated there against the Fortran rmatch pins, the `:galerkin` backend at +# moderate Q, and physical benchmarks to Q = 500i. Complete methods paper: +# GGJ_test/docs/METHOD.md. In one paragraph: +# +# The equations are continued analytically onto the ray x = e^{iθ}s with +# θ = arg(Q)/4 (parabolic-cylinder exponent exactly real; pseudo-resonance +# cleared). On [0, s_m] a global Chebyshev spectral-element collocation BVP +# in the plain variables v = (Ψ, Ξ, Υ, Ψ', Ξ', Υ') is solved with parity +# conditions at the (ordinary-point) origin and Δ, c₁, c₂ as bordered +# unknowns of one sparse LU per parity (rank-3 Woodbury for the second). +# The far-field condition uses the SAME inps Wasow basis as the other +# backends (RayAsymptotics.jl evaluates it at complex x; the rdcon +# normalization of Δ is retained), applied at the series radius S and +# transported S → s_m by an L-stable 2-stage Radau IIA march in the +# quotient modulo the decaying exponential pair. The damped-zone march +# arithmetic runs in Complex{Double64}: at large S the near-parallel +# power-pair geometry amplifies the structured backward error of the +# ill-conditioned implicit solves (Σ eps·z ≈ eps·ρS²/2) into Δ-mixing +# ~1e-4 at |Q| = 500 in Float64 (METHOD.md §8). +# +# Sections below mirror the GGJRay source layout: +# 1. system — plain-variable first-order ODE matrix, parity sets +# 2. mesh — Chebyshev cells, barycentric interpolation, WKB grading +# 3. boundary — decaying pair, Radau IIA quotient march +# 4. solve — assembly, bordered solve, refinement, driver, diagnostics + +""" + q4_surface_benchmark() -> GGJParameters + +Physical q = 4 rational-surface benchmark point (S = τ_R/τ_A ≈ 4.58×10⁶, +D_I ≈ −0.31166, α = √(−D_I) ≈ 0.5583). Primary validation point for the +rotated-ray backend on the imaginary-Q axis (pinned at Q = 100i, 500i in +the test suite). +""" +function q4_surface_benchmark() + return GGJParameters(; + E=-0.13733, F=0.022202, G=7.60633, H=0.053468, K=14.66987, + M=30.26883, taua=2.11226e-7, taur=0.968219, v1=1.55009 + ) +end + +# system.jl +# +# The GGJ inner-region equations (GWP2016 Eq. 11 ≡ GW2020 Eq. 1) as a plain +# first-order system in v = (Ψ, Ξ, Υ, Ψ', Ξ', Υ'), dv/dx = M(x)·v. +# All coefficients are POLYNOMIAL in x, so x = 0 is an ordinary point (the +# x⁻²/x⁻⁴ singularities of the GW2020 Eq. 2 form are artifacts of the +# (xΨ, Ψ'/x) scaling) and the system continues analytically to complex x. +# +# Ψ'' = H Υ' + Q(Ψ − xΞ) +# Ξ'' = [Q x² Ξ − Q x Ψ − (E+F) Υ − H Ψ'] / Q² +# Υ'' = [x² Υ − x Ψ − Q²(G(Ξ−Υ) − K(EΞ + FΥ + HΨ'))] / Q +# +# Row-by-row this matches the deltac `inpso_get_uv` matrices (I, V, U) after +# dividing each equation by its diagonal second-derivative coefficient +# (1, Q², Q). + +""" + ode_matrix([CT,] p::GGJParameters, Q, x) -> SMatrix{6,6,CT} + +Coefficient matrix `M(x)` of `dv/dx = M v`, `v = (Ψ, Ξ, Υ, Ψ', Ξ', Υ')`, +valid for real or complex `x`. The optional leading type argument selects +the element type (e.g. `Complex{Double64}` for the extended-precision +damped-zone march); default `ComplexF64`. +""" +ode_matrix(p::GGJParameters, Q::ComplexF64, x::Number) = + ode_matrix(ComplexF64, p, Q, x) + +function ode_matrix(::Type{CT}, p::GGJParameters, Q::Number, x::Number) where {CT<:Complex} + e = CT(p.E) + f = CT(p.F) + g = CT(p.G) + h = CT(p.H) + k = CT(p.K) + q = CT(Q) + q2 = q * q + xc = CT(x) + x2 = xc * xc + + m = zeros(MMatrix{6,6,CT}) + # rows 1–3: (Ψ, Ξ, Υ)' = (Ψ', Ξ', Υ') + m[1, 4] = 1 + m[2, 5] = 1 + m[3, 6] = 1 + # row 4: Ψ'' = Q Ψ − Q x Ξ + H Υ' + m[4, 1] = q + m[4, 2] = -q * xc + m[4, 6] = h + # row 5: Ξ'' = −(x/Q) Ψ + (x²/Q) Ξ − ((E+F)/Q²) Υ − (H/Q²) Ψ' + m[5, 1] = -xc / q + m[5, 2] = x2 / q + m[5, 3] = -(e + f) / q2 + m[5, 4] = -h / q2 + # row 6: Υ'' = −(x/Q) Ψ − Q(G−KE) Ξ + (x²/Q + Q(G+KF)) Υ + HKQ Ψ' + m[6, 1] = -xc / q + m[6, 2] = -q * (g - k * e) + m[6, 3] = x2 / q + q * (g + k * f) + m[6, 4] = h * k * q + + return SMatrix{6,6,CT}(m) +end + +""" + parity_rows(isol) -> SVector{3,Int} + +Component indices of `v = (Ψ, Ξ, Υ, Ψ', Ξ', Υ')` that vanish at s = 0 for +each parity solution, mirroring deltac_set_boundary: + + - `isol = 1` ("odd"): Ψ'(0) = 0, Ξ(0) = 0, Υ(0) = 0 → components (4, 2, 3) + - `isol = 2` ("even"): Ψ(0) = 0, Ξ'(0) = 0, Υ'(0) = 0 → components (1, 5, 6) +""" +parity_rows(isol::Int) = isol == 1 ? SVector(4, 2, 3) : SVector(1, 5, 6) +# mesh.jl +# +# Graded spectral-element mesh on the ray parameter s ∈ [0, S], plus the +# Chebyshev–Lobatto reference-cell machinery (nodes, differentiation matrix, +# barycentric interpolation). +# +# The initial grading tracks the local stiffness scales of the rotated +# system: +# - the fast Ohm's-law pair e^{±√Q x}: active near the origin, decaying +# at rate Re(√Q e^{iθ}) — resolved on a fine zone [0, s₁]; +# - the parabolic-cylinder pair e^{±ρ s²/2}, ρ = Re(e^{2iθ}/√Q): decaying +# content is above relative machine floor only for s ≲ s_d = √(2D/ρ) — +# resolved on a rate-adapted zone [s₁, s_d]; +# - beyond s_d the physical solution is a smooth power law: geometric +# (log-spaced) cells suffice out to S. +# Residual-driven bisection refinement (solve.jl) then repairs whatever the +# initial guess missed. + +""" + cheblobatto(p) -> (t, D) + +Chebyshev–Lobatto nodes on [-1, 1] in ASCENDING order and the spectral +differentiation matrix `D` for those nodes (Trefethen's `cheb`, reflected). +""" +function cheblobatto(p::Int) + p >= 1 || throw(ArgumentError("polynomial order p must be ≥ 1")) + # Standard descending Trefethen nodes x_j = cos(jπ/p), j = 0..p. + x = [cos(pi * j / p) for j in 0:p] + c = [j == 0 || j == p ? 2.0 : 1.0 for j in 0:p] .* [(-1.0)^j for j in 0:p] + D = zeros(p + 1, p + 1) + for i in 1:(p+1), j in 1:(p+1) + if i != j + D[i, j] = (c[i] / c[j]) / (x[i] - x[j]) + end + end + for i in 1:(p+1) + D[i, i] = -sum(D[i, j] for j in 1:(p+1) if j != i) + end + # Reflect to ascending order: y = -x is ascending with the SAME index + # order (x is descending), and for g(y) := f(-y) sampled on y the chain + # rule gives D_y = -D. No index permutation. + t = -x + Dt = -D + return t, Dt +end + +""" + barycentric_weights(t) -> w + +Barycentric interpolation weights for nodes `t` (any distribution). +""" +function barycentric_weights(t::AbstractVector{Float64}) + n = length(t) + w = ones(Float64, n) + for j in 1:n + for k in 1:n + k == j && continue + w[j] /= (t[j] - t[k]) + end + end + return w +end + +""" + barycentric_eval(t, w, vals, τ) -> value + +Evaluate the interpolant of `vals` (matrix: nodes × components) at `τ`. +Returns a vector over components. +""" +function barycentric_eval(t::AbstractVector{Float64}, w::AbstractVector{Float64}, + vals::AbstractMatrix{ComplexF64}, τ::Float64) + n = length(t) + for j in 1:n + if τ == t[j] + return vals[j, :] + end + end + num = zeros(ComplexF64, size(vals, 2)) + den = 0.0 + for j in 1:n + fac = w[j] / (τ - t[j]) + den += fac + @views num .+= fac .* vals[j, :] + end + return num ./ den +end + +""" + initial_breaks(params, Q, θ, S, p; cfl=0.4, drop=40.0, ratio=1.3, + hmax_frac=0.08) -> Vector{Float64} + +Build the initial cell-boundary vector `0 = s₀ < s₁ < … = S` using the +three-zone grading described in the header. `cfl` ≈ resolved-exponential +rate per node; `drop` = e-folds after which decaying exponential content is +considered machine-dead; `ratio` = geometric growth in the outer zone. +""" +function initial_breaks(params::GGJParameters, Q::ComplexF64, θ::Float64, + S::Float64, p::Int; cfl::Float64=0.4, drop::Float64=40.0, + ratio::Float64=1.3, hmax_frac::Float64=0.08) + sq = sqrt(Q) + ρ = real(cis(2θ) / sq) # parabolic pair: exponent ρ s²/2 + ρ > 0 || throw(ArgumentError("contour angle θ=$θ gives non-decaying parabolic pair (ρ=$ρ ≤ 0); use θ ≈ arg(Q)/4")) + # Fast local WKB scales: the Ohm pair |√Q| and the Υ-family + # √|Q(G+KF)| (dominant on extreme-coefficient surfaces, K ~ 10⁶ — + # dormant almost everywhere but must be resolved; see solve.jl header). + μ = abs(sq) + sqrt(abs(Q) * abs(params.G + params.K * params.F)) + μdec = max(real(sq * cis(θ)), 0.1 * μ) # its decay rate along the ray + + s1 = min(drop / μdec, 0.7 * S) # fast pair dead beyond s1 + sd = min(sqrt(2 * drop / ρ), 0.8 * S) # parabolic pair dead beyond sd + sd = max(sd, min(1.05 * s1, S)) + hmax = hmax_frac * S + + # Single grading rule: every cell resolves the full local WKB rate + # μ + ρs (fast pair + parabolic pair), whether the corresponding mode + # amplitudes are alive or dormant — unresolved dormant modes alias into + # spurious bounded discrete modes and poison the global solve. The + # outer power-law region is not discretized at all (handled by the + # boundary march), so this never gets expensive: the BVP domain ends at + # s_m ~ max(s_d, s₁) where μ dominates the rate budget. + _ = (s1, sd, ratio) # zone markers retained in signature for tuning + breaks = [0.0] + while breaks[end] < S + s = breaks[end] + h = cfl * p / (μ + ρ * s) + h = min(h, hmax, S / 8) + snew = s + h + if snew >= S - 0.25 * h + snew = S + end + push!(breaks, min(snew, S)) + end + # Guard against a degenerate final sliver. + if length(breaks) >= 3 && (breaks[end] - breaks[end-1]) < 0.2 * (breaks[end-1] - breaks[end-2]) + deleteat!(breaks, length(breaks) - 1) + end + return breaks +end +# boundary.jl +# +# Large-s boundary data at the matching point on the ray x = e^{iθ}s. +# +# The admissible (bounded-as-s→∞) solution space is spanned by +# { u_small, u_big } — the two power-like Mercier solutions, taken from +# the inps series in the standard normalization, and +# { E₁, E₂ } — the two forward-DECAYING exponential solutions. +# +# The inps series is only trusted at radius S (residual ≤ smax_tol), which +# for extreme-coefficient surfaces (K ~ 10⁶) can be S ~ 10⁴–10⁵, while the +# collocation BVP ends at s_m ~ 30–150. The gap is bridged by MARCHING the +# power-pair data W = [u_small, u_big] backward S → s_m. +# +# Marching design (the hard-won part): +# - Backward, the decaying pair GROWS at rate ρs (ρ = Re(e^{2iθ}/√Q)), up +# to ~10³/unit at large s. Any explicit integrator — including +# projected/continuous-QR variants, whose unprojected stage points +# reintroduce the transverse stiffness — is stability-limited to +# h ≲ 3/(ρs), i.e. O(ρS²) steps: hopeless at S ~ 10⁵. +# - The system is LINEAR, so we use a stiffly-accurate L-STABLE implicit +# method (2-stage Radau IIA, order 3; one 12×12 solve per step). For +# resolved slow content it is 3rd-order accurate; for the unresolvable +# backward-growing exponential directions |R(z)| < 1 at large |z| — +# the method DAMPS what it cannot resolve instead of amplifying it. +# That is normally an accuracy vice; here it is exactly the required +# behavior, because Δ is defined modulo the decaying pair. +# - In the partially-resolved band (|z| = ρs·h ≲ 10) genuine backward +# growth of rounding-injected decaying-pair content still occurs, so W +# is PURGED against a locally-extracted decaying frame every +# Φ = ∫ρs ds ≈ 8 e-folds. Purging only ever removes span{E} content — +# legitimate by the definition of Δ — so the transported coefficients +# of u_small/u_big are exact up to integrator tolerance. + +""" + decaying_pair(params, Q, θ, S; growth=30.0, seed=20260707) -> E::Matrix{ComplexF64} (6×2) + +Orthonormal basis of the forward-decaying pair at s = S on the ray, via a +SHORT backward RK4 integration over [S, S+ΔS], with ΔS chosen so the pair is +amplified by ≈ e^growth relative to everything else (backward-attracting +purification of random seeds). The integration is fully resolved +(h·rate ≈ 0.05), so plain RK4 is stable over this short stretch. +""" +function decaying_pair(params::GGJParameters, Q::ComplexF64, θ::Float64, S::Float64; + growth::Float64=30.0, seed::Int=20260707, dp_res::Float64=20.0) + sq = sqrt(Q) + ρ = real(cis(2θ) / sq) + ρ > 0 || throw(ArgumentError("θ=$θ gives ρ=$ρ ≤ 0; decaying pair undefined")) + ph = cis(θ) + # Purification window: BOTH backward-growing directions must separate + # from the power pair by ≥ `growth` e-folds over [S, S+ΔS], so the + # budget is set by the SLOWEST grower's rate margin over the algebraic + # power rates (from the frozen spectrum) — not by ρS. At moderate |Q| + # the fast-Ohm grower can be ~0.6/unit while ρS ~ 10²: budgeting on ρS + # leaves the second frame column O(1)-contaminated with power content, + # which then poisons every march purge that uses the frame. + g2 = Inf + gmax_loc = 0.0 + for λ in eigvals(Matrix(ph * ode_matrix(params, Q, ph * S))) + g = -real(λ) + g > 20.0 / S && (g2 = min(g2, g)) + gmax_loc = max(gmax_loc, abs(real(λ))) + end + gsep = isfinite(g2) ? max(g2 - 20.0 / S, 0.05 * g2) : ρ * S + ΔS = min(growth / gsep, 3.0 * S) + + # Step resolution must use the TRUE local spectrum, not the analytic + # ρs + |√Q| estimate: on extreme-coefficient surfaces the Υ-family rate + # √|Q(G+KF)| dominates at small s and the analytic estimate under-counts + # it 10×, leaving h·λ ~ 0.2 — stable but phase-inaccurate, which corrupts + # the extracted frame directions (observed as O(1) θ-drift downstream). + for λ in eigvals(Matrix(ph * ode_matrix(params, Q, ph * (S + ΔS)))) + gmax_loc = max(gmax_loc, abs(real(λ))) + end + rate = max(ρ * (S + ΔS) + abs(sq), gmax_loc) + nsteps = clamp(ceil(Int, dp_res * rate * ΔS), 200, 2_000_000) + h = -ΔS / nsteps + + rng = MersenneTwister(seed) + v = randn(rng, ComplexF64, 6, 2) + v = Matrix(qr(v).Q) + + f(s, y) = (ph * ode_matrix(params, Q, ph * s)) * y + + s = S + ΔS + for istep in 1:nsteps + k1 = f(s, v) + k2 = f(s + h / 2, v + (h / 2) * k1) + k3 = f(s + h / 2, v + (h / 2) * k2) + k4 = f(s + h, v + h * k3) + v = v + (h / 6) * (k1 + 2k2 + 2k3 + k4) + s += h + if istep % 50 == 0 + v = Matrix(qr(v).Q) + end + end + return Matrix(qr(v).Q) +end + +""" + march_boundary(params, Q, θ, S, s_m, Us, Ub; rtol=1e-9, growth=30.0, seed=20260707) + -> (Us_m, Ub_m, F) + +Transport the power-pair boundary data from the series radius `S` inward to +`s_m` along the ray, modulo the decaying exponential pair (the quotient in +which the matching data Δ is defined). Adaptive 2-stage Radau IIA (L-stable, +stiffly accurate) on the raw linear system, with Φ-budgeted purges against +locally-extracted decaying frames; see the file header for why explicit +(including projected-QR) marchers cannot do this job at large S. +""" +function march_boundary(params::GGJParameters, Q::ComplexF64, θ::Float64, + S::Float64, s_m::Float64, Us::Vector{ComplexF64}, Ub::Vector{ComplexF64}; + rtol::Float64=1e-9, growth::Float64=30.0, seed::Int=20260707, + ztrk::Float64=0.3, purge_budget::Float64=2.5, dp_res::Float64=20.0) + ph = cis(θ) + sq = sqrt(Q) + ρ = real(cis(2θ) / sq) + 𝔐(s) = ph * ode_matrix(params, Q, ph * s) + + W = hcat(copy(Us), copy(Ub)) + function purge!(W, s) + Floc = decaying_pair(params, Q, θ, s; growth=growth, seed=seed, dp_res=dp_res) + W .-= Floc * (Floc' * W) + return Floc + end + + I6 = Matrix{ComplexF64}(I, 6, 6) + # One Radau IIA step: A = [5/12 -1/12; 3/4 1/4], c = (1/3, 1), b = A[2,:]. + function radau_step(s, W, h) + M1 = 𝔐(s + h / 3) + M2 = 𝔐(s + h) + Ablk = [I6-(5h/12)*M1 (h/12)*M1; -(3h/4)*M2 I6-(h/4)*M2] + rhs = [M1 * W; M2 * W] + K = Ablk \ rhs + return W + h * ((3 / 4) * @view(K[1:6, :]) + (1 / 4) * @view(K[7:12, :])) + end + + # Extended-precision (Double64) Radau step for the damped-zone W march. + # At z = ρ·c·s² ≫ 1 the step matrices have cond ~ z and the LU backward + # error has a SYSTEMATIC direction set by the elimination structure — it + # does not dither away, accumulating a relative error Σ eps·z ≈ eps·ρS²/2 + # on the power columns that is INDEPENDENT of the step law (c cancels). + # The near-parallel power-pair geometry at large S turns that into + # u_small/u_big coefficient mixing β ~ eps·ρS²/2 · (‖u_big‖/‖u_small‖)(S) + # ~ 1e-4 at |Q| = 500 — saturating Δ of the near-pole parity at ~1/β and + # flooring the other at |Δ|·β (the ε_mix defect; docs/BUGHUNT_epsmix.md). + # Double64 arithmetic (eps ~ 5e-33) removes it outright; the resolved + # band stays Float64 (z ≤ ztrk there, solves well-conditioned). + CTd = Complex{Double64} + phd = CTd(cos(Double64(θ)), sin(Double64(θ))) + I6d = Matrix{CTd}(I, 6, 6) + 𝔐d(s) = phd * Matrix(ode_matrix(CTd, params, Q, phd * Double64(s))) + function radau_step_d(s, Wd, h) + hd = Double64(h) + M1 = 𝔐d(s + h / 3) + M2 = 𝔐d(s + h) + Ablk = [I6d-(5hd/12)*M1 (hd/12)*M1; -(3hd/4)*M2 I6d-(hd/4)*M2] + rhs = [M1 * Wd; M2 * Wd] + K = Ablk \ rhs + return Wd + hd * ((CTd(3) / 4) * K[1:6, :] + (CTd(1) / 4) * K[7:12, :]) + end + + # --- Schedule: geometric where damped, growth-capped where resolved. --- + # No adaptive controller (three generations died on estimator artifacts); + # the step law is derived from the local mode structure instead. At each + # step the eigenvalues of 𝔐(s) give the two backward-growing exponential + # rates g (Re λ < 0, excluding the algebraic power-pair rates ~ r/s): + # + # damped zone (g_min·c·s > 8 for all growers): h = -c·s. All growing + # content — including the O(smax_tol) fast-direction contamination of + # the series data — sits at z ≫ 1 where L-stable Radau DAMPS it + # (|R| ~ 6/z²). Slow-mode global error ≈ 10²c³ → c = (rtol/100)^{1/3}; + # step count ~ ln(S/s_m)/c independent of S. + # + # resolved band (some grower at z ≲ 8): genuine backward growth must be + # tracked and removed. h = -min(c·s, 0.3/g_max) keeps z ≤ 0.3 so the + # DISCRETE growing eigendirection matches the continuous one to + # O(z²) ~ 1% (an orthogonal purge against a frame misaligned by δ + # leaks δ·content — with e-fold budget Φ ≤ 2.5 the cycle gain + # e^Φ·δ < 0.2 and contamination stays at the rounding floor). The + # frame F is carried CONTINUOUSLY with the same Radau steps + QR + # (backward-attracting ⇒ self-correcting), so no re-extraction cost. + c = clamp(cbrt(rtol / 100.0), 2e-5, 2e-3) + + # --- Phase 1: damped zone, W in Double64 (see radau_step_d comment). --- + # First purge (at S) and all damped-zone steps run in extended precision; + # on resolved-band entry W drops back to Float64 for phase 2. + Wd = CTd.(W) + let Floc = decaying_pair(params, Q, θ, S; growth=growth, seed=seed, dp_res=dp_res) + Fd = CTd.(Floc) + Wd .-= Fd * (Fd' * Wd) + end + s = S + nstep = 0 + while s > s_m + 1e-12 * S + λs = eigvals(Matrix(𝔐(s))) + gmin = Inf + for λ in λs + g = -real(λ) + g > 10.0 / s && (gmin = min(gmin, g)) + end + (isfinite(gmin) && gmin * c * s < 8.0) && break # resolved-band entry + h = -min(c * s, s - s_m) + Wd = radau_step_d(s, Wd, h) + s += h + nstep += 1 + nstep > 5_000_000 && error("march_boundary: step limit exceeded (s=$s)") + end + W = ComplexF64.(Wd) + + # --- Phase 2: resolved band (Float64, carried frame + purges). --- + Φ = 0.0 + F = Matrix{ComplexF64}(undef, 6, 2) + have_F = false + while s > s_m + 1e-12 * S + # Backward-growing exponential rates from the frozen-coefficient + # spectrum (Re λ < 0 ⇒ grows as s decreases); rates ≲ 10/s are the + # algebraic power pair, not exponential growers. + λs = eigvals(Matrix(𝔐(s))) + gmin, gmax = Inf, 0.0 + for λ in λs + g = -real(λ) + if g > 10.0 / s + gmin = min(gmin, g) + gmax = max(gmax, g) + end + end + resolved = isfinite(gmin) && gmin * c * s < 8.0 + h = resolved ? -min(c * s, ztrk / gmax, s - s_m) : -min(c * s, s - s_m) + if resolved && !have_F + F = decaying_pair(params, Q, θ, s; growth=growth, seed=seed, dp_res=dp_res) + W .-= F * (F' * W) + have_F = true + Φ = 0.0 + end + W = radau_step(s, W, h) + if have_F + F = radau_step(s, F, h) + F = Matrix(qr(F).Q) + Φ += gmax * abs(h) + if Φ > purge_budget + W .-= F * (F' * W) + Φ = 0.0 + end + end + s += h + nstep += 1 + nstep > 5_000_000 && error("march_boundary: step limit exceeded (s=$s)") + end + # The boundary basis must span the traces AT s_m of the solutions that + # decay AT INFINITY — which, after the mode families morph around + # s ~ |Q|, is NOT the same subspace as the locally-decaying pair at s_m. + # The carried frame F is exactly the marched ∞-decaying pair (it enters + # the resolved band before the morphing region and tracks the subspace + # through it), so it IS the correct E-basis; re-extracting locally at + # s_m substitutes the wrong subspace and corrupts Δ at O(1) on + # extreme-coefficient surfaces. Local extraction remains only as the + # fallback when the march never entered the resolved band (no morphing + # crossed under resolution — the subspaces then coincide). + if have_F + W .-= F * (F' * W) + else + F = purge!(W, s_m) + end + return W[:, 1], W[:, 2], F +end + +""" + boundary_basis(params, Q, cache, θ, S; growth=30.0) + -> (Us, Ub, E, cond_est) + +Boundary data at s = S directly from the inps series (no march; used when +the series is already valid at the BVP edge): `Us`, `Ub` are the small/large +power-like solutions as 6-component states in the inps normalization, `E` +the numerically-generated decaying pair, `cond_est` the condition number of +the column-normalized basis. +""" +function boundary_basis(params::GGJParameters, Q::ComplexF64, + cache::InnerAsymptoticsCache, θ::Float64, S::Float64; + growth::Float64=30.0, seed::Int=20260707, dp_res::Float64=20.0) + xS = cis(θ) * S + ua, dua = physical_ua_dua(cache, xS) + Ub = ComplexF64[ua[1, 1], ua[2, 1], ua[3, 1], dua[1, 1], dua[2, 1], dua[3, 1]] + Us = ComplexF64[ua[1, 2], ua[2, 2], ua[3, 2], dua[1, 2], dua[2, 2], dua[3, 2]] + E = decaying_pair(params, Q, θ, S; growth=growth, seed=seed, dp_res=dp_res) + + Bmat = hcat(Us ./ norm(Us), Ub ./ norm(Ub), E) + cond_est = cond(Bmat) + return Us, Ub, E, cond_est +end +# solve.jl +# +# Global spectral-element collocation solve of the GGJ inner-layer BVP on +# the rotated ray, with the matching data Δ as a bordered unknown. +# +# For each parity (deltac isol = 1 "odd", 2 "even"): +# +# unknowns : v at all global nodes (6 components each), plus (Δ, c₁, c₂) +# equations: dv/ds = e^{iθ} M(e^{iθ}s) v collocated at the p non-left +# Lobatto nodes of every cell (right-biased collocation — +# Radau-IIA-like, stable for the dormant stiff modes); +# 3 parity conditions at s = 0; +# 6 matching conditions at s = S: +# v(S) − Δ·Ub − c₁E₁ − c₂E₂ = Us . +# +# One sparse LU per parity per mesh; residual-driven bisection refinement. +# The exponential dichotomy never enters: no quantity is ever propagated +# across the layer, and the boundary condition splits the dichotomy exactly. + +""" + RaySolveResult + +Output of [`solve_ray`](@ref). `Δ` is the rescaled matching data in the +deltac output ordering (index 1 ↔ even-BC solution, 2 ↔ odd-BC solution, +i.e. the same swap deltac_solve applies); `Δraw` is (isol=1, isol=2) in raw +inps normalization before `rescale_delta`. +""" +struct RaySolveResult + Δ::SVector{2,ComplexF64} + Δraw::SVector{2,ComplexF64} + cexp::SMatrix{2,2,ComplexF64,4} # decaying-pair coefficients (col = isol) + Q::ComplexF64 + θ::Float64 + S::Float64 + series_ok::Bool # pick_smax reached its tolerance + bc_cond::Float64 # boundary-basis condition number + breaks::Vector{Float64} + p::Int + sols::Matrix{ComplexF64} # ndof × 2 nodal solution (isol = 1, 2) + resid::Float64 # final max relative collocation residual + δΔ_refine::Float64 # |Δ change| in last refinement round (rel) + nrounds::Int +end + +# ----------------------------------------------------------------------- +# Assembly. +# ----------------------------------------------------------------------- + +function _assemble_base(params::GGJParameters, Q::ComplexF64, θ::Float64, + breaks::Vector{Float64}, p::Int, + t::Vector{Float64}, D::Matrix{Float64}, + Us::Vector{ComplexF64}, Ub::Vector{ComplexF64}, E::Matrix{ComplexF64}) + N = length(breaks) - 1 + Ng = N * p + 1 + ndof = 6 * Ng + 3 + ph = cis(θ) + βb = norm(Ub) + + nnz_est = N * p * 6 * (p + 7) + 24 + 3 + Ic = Vector{Int}() + Jc = Vector{Int}() + Vc = Vector{ComplexF64}() + sizehint!(Ic, nnz_est) + sizehint!(Jc, nnz_est) + sizehint!(Vc, nnz_est) + + @inbounds for c in 1:N + a, b = breaks[c], breaks[c+1] + Dscale = 2 / (b - a) + for j in 1:p + s_j = a + (b - a) * (t[j+1] + 1) / 2 + M = ph * ode_matrix(params, Q, ph * s_j) + row0 = 6 * ((c - 1) * p + (j - 1)) + gj = (c - 1) * p + j + 1 + for i in 1:6 + row = row0 + i + for k in 0:p + gk = (c - 1) * p + k + 1 + push!(Ic, row) + push!(Jc, 6 * (gk - 1) + i) + push!(Vc, Dscale * D[j+1, k+1]) + end + for i2 in 1:6 + Mv = M[i, i2] + if Mv != 0 + push!(Ic, row) + push!(Jc, 6 * (gj - 1) + i2) + push!(Vc, -Mv) + end + end + end + end + end + + # Matching rows at s = S. + rowm0 = 6 * N * p + @inbounds for i in 1:6 + row = rowm0 + i + push!(Ic, row) + push!(Jc, 6 * (Ng - 1) + i) + push!(Vc, one(ComplexF64)) + push!(Ic, row) + push!(Jc, 6 * Ng + 1) + push!(Vc, -Ub[i] / βb) + push!(Ic, row) + push!(Jc, 6 * Ng + 2) + push!(Vc, -E[i, 1]) + push!(Ic, row) + push!(Jc, 6 * Ng + 3) + push!(Vc, -E[i, 2]) + end + + rhs = zeros(ComplexF64, ndof) + rhs[rowm0.+(1:6)] .= Us + + return Ic, Jc, Vc, rhs, βb, ndof, Ng +end + +""" + _solve_parities(Ic, Jc, Vc, rhs, ndof, Ng, N, p) -> (sol_odd, sol_even) + +Solve BOTH parity systems from one factorization. The two matrices differ +only in the 3 parity rows at s = 0 (odd: unit entries at components (4,2,3); +even: at (1,5,6)), i.e. A_even = A_odd + Σₖ e_{rₖ}(e_{c2ₖ} − e_{c1ₖ})ᵀ — a +rank-3 update. One sparse LU (of the row-equilibrated odd system) plus a +Woodbury correction (3 extra triangular solves + a 3×3 solve) replaces the +second factorization, which dominates the linear-algebra cost. +""" +function _solve_parities(Ic::Vector{Int}, Jc::Vector{Int}, Vc::Vector{ComplexF64}, + rhs::Vector{ComplexF64}, ndof::Int, Ng::Int, N::Int, p::Int) + rowp0 = 6 * N * p + 6 + c1 = parity_rows(1) + c2 = parity_rows(2) + I2 = copy(Ic) + J2 = copy(Jc) + V2 = copy(Vc) + for k in 1:3 + push!(I2, rowp0 + k) + push!(J2, c1[k]) + push!(V2, one(ComplexF64)) + end + A = sparse(I2, J2, V2, ndof, ndof) + + # Row equilibration (as in _solve_parity; the parity rows have unit + # entries in both variants, so one scaling serves both systems). + rmax = zeros(Float64, ndof) + rows = rowvals(A) + vals = nonzeros(A) + for col in 1:ndof + for idx in nzrange(A, col) + r = rows[idx] + a = abs(vals[idx]) + a > rmax[r] && (rmax[r] = a) + end + end + @inbounds for i in 1:ndof + rmax[i] = rmax[i] > 0 ? 1 / rmax[i] : 1.0 + end + Dr = Diagonal(rmax) + F = lu(Dr * A) + x1 = F \ (Dr * rhs) + + # Woodbury: (DrA + U Vᵀ)⁻¹ b = x1 − A⁻¹U (I₃ + Vᵀ A⁻¹U)⁻¹ Vᵀ x1, + # with U[:,k] = Dr[rₖ,rₖ]·e_{rₖ} and Vᵀ[k,:] = (e_{c2ₖ} − e_{c1ₖ})ᵀ. + U = zeros(ComplexF64, ndof, 3) + for k in 1:3 + U[rowp0+k, k] = rmax[rowp0+k] + end + AinvU = F \ U + Vx1 = ComplexF64[x1[c2[k]] - x1[c1[k]] for k in 1:3] + S = Matrix{ComplexF64}(I, 3, 3) + for k in 1:3, j in 1:3 + S[k, j] += AinvU[c2[k], j] - AinvU[c1[k], j] + end + x2 = x1 - AinvU * (S \ Vx1) + # UMFPACK numeric factorizations live in C malloc, invisible to the GC's + # heap accounting — under the refinement loop, lazily-finalized multi-GB + # factorizations pile up (observed: tens of GB on 24k-cell meshes). Free + # eagerly now that both solutions are extracted. + finalize(F) + return x1, x2 +end + +function _solve_parity(Ic::Vector{Int}, Jc::Vector{Int}, Vc::Vector{ComplexF64}, + rhs::Vector{ComplexF64}, ndof::Int, Ng::Int, N::Int, p::Int, isol::Int; + leftcomps::AbstractVector{Int}=parity_rows(isol), + leftvals::AbstractVector{ComplexF64}=zeros(ComplexF64, 3)) + rowp0 = 6 * N * p + 6 + I2 = copy(Ic) + J2 = copy(Jc) + V2 = copy(Vc) + rhs = copy(rhs) + for k in eachindex(leftcomps) + push!(I2, rowp0 + k) + push!(J2, leftcomps[k]) # node 1 → columns 1..6 + push!(V2, one(ComplexF64)) + rhs[rowp0+k] = leftvals[k] + end + A = sparse(I2, J2, V2, ndof, ndof) + + # Row equilibration. + rmax = zeros(Float64, ndof) + rows = rowvals(A) # CSC: iterate columns + vals = nonzeros(A) + for col in 1:ndof + for idx in nzrange(A, col) + r = rows[idx] + a = abs(vals[idx]) + a > rmax[r] && (rmax[r] = a) + end + end + @inbounds for i in 1:ndof + rmax[i] = rmax[i] > 0 ? 1 / rmax[i] : 1.0 + end + Dr = Diagonal(rmax) + F = lu(Dr * A) + x = F \ (Dr * rhs) + finalize(F) # eager UMFPACK free — see _solve_parities + return x +end + +# ----------------------------------------------------------------------- +# Residual estimation for adaptive refinement. +# ----------------------------------------------------------------------- + +function _cell_errors(params::GGJParameters, Q::ComplexF64, θ::Float64, + breaks::Vector{Float64}, p::Int, + t::Vector{Float64}, D::Matrix{Float64}, w::Vector{Float64}, + sol::Vector{ComplexF64}) + N = length(breaks) - 1 + ph = cis(θ) + errs = zeros(Float64, N) + + # Reference-cell test points: left endpoint + inter-node midpoints. + τs = Float64[-1.0] + for j in 1:p + push!(τs, (t[j] + t[j+1]) / 2) + end + + vals = Matrix{ComplexF64}(undef, p + 1, 6) + for c in 1:N + a, b = breaks[c], breaks[c+1] + Dscale = 2 / (b - a) + for j in 0:p + g = (c - 1) * p + j + 1 + for i in 1:6 + vals[j+1, i] = sol[6*(g-1)+i] + end + end + dvals = (Dscale .* D) * vals + + emax = 0.0 + for τ in τs + vτ = barycentric_eval(t, w, vals, τ) + dvτ = barycentric_eval(t, w, dvals, τ) + s_τ = a + (b - a) * (τ + 1) / 2 + Mv = (ph * ode_matrix(params, Q, ph * s_τ)) * vτ + rnorm = 0.0 + scale = 1e-300 + for i in 1:6 + rnorm = max(rnorm, abs(dvτ[i] - Mv[i])) + scale = max(scale, abs(dvτ[i]), abs(Mv[i])) + end + emax = max(emax, rnorm / scale) + end + errs[c] = emax + end + return errs +end + +# ----------------------------------------------------------------------- +# Driver. +# ----------------------------------------------------------------------- + +""" + solve_ray(params::GGJParameters, Q; θ=angle(Q)/4, kmax=12, p=12, + S=nothing, smax_tol=1e-9, growth=30.0, + refine_tol=1e-8, max_rounds=8, max_cells=6000, + verbose=false) -> RaySolveResult + +Solve the GGJ inner-layer matching problem on the rotated ray x = e^{iθ}s +and return the parity matching data. `Δ` follows the deltac output +convention (swap + `rescale_delta`) so it is directly comparable to the +GPEC_julia Galerkin solver. + +Keyword highlights: + - `θ` : contour angle; default arg(Q)/4 makes the parabolic exponent + exactly real. θ = 0 reproduces a real-axis solve. + - `S` : matching radius; default from the inps series residual + criterion along the ray (`pick_smax`). + - `p` : polynomial order per spectral element. + - `refine_tol`/`max_rounds`: residual-driven bisection refinement control. +""" +function solve_ray(params::GGJParameters, Q::ComplexF64; + θ::Float64=angle(Q) / 4, kmax::Int=12, p::Int=12, + S::Union{Nothing,Float64}=nothing, smax_tol::Float64=1e-9, + growth::Float64=30.0, refine_tol::Float64=1e-8, + march_rtol::Float64=1e-9, sm_fac::Float64=1.0, cfl::Float64=0.4, + max_rounds::Int=8, max_cells::Int=6000, seed::Int=20260707, + ztrk::Float64=0.3, purge_budget::Float64=2.5, dp_res::Float64=20.0, + handoff::Union{Nothing,NTuple{2,Vector{ComplexF64}}}=nothing, + boundary::Union{Nothing,Tuple{Vector{ComplexF64},Vector{ComplexF64},Matrix{ComplexF64}}}=nothing, + verbose::Bool=false) + + cache = build_asymptotics(params, Q; kmax=kmax) + series_ok = true + if S === nothing + Sv, cache, series_ok = pick_smax(params, Q; θ=θ, eps=smax_tol, kmax=kmax, cache=cache) + series_ok || @warn "pick_smax: series residual target $smax_tol not reached; using best point S=$Sv" + else + Sv = S + end + + # Decide the BVP outer radius: the layer physics (exponential content + # above machine floor) ends at s_d; beyond that the solution is pure + # power law and is handled by the projected quotient MARCH, not by + # collocation. For small |Q| (s_d ≳ S) no march is needed. + sq = sqrt(Q) + ρ = real(cis(2θ) / sq) + # BVP outer radius s_m: where every exponential solution family has + # decayed ≥ 45 e-folds from the origin, computed from the actual + # frozen-coefficient spectrum (the asymptotic ρs rate is wrong near the + # origin for extreme-coefficient surfaces, where the Υ-family rate + # √|Q(G+KF)| dominates and kills exponential content within s ~ O(1)). + s_m = let acc = 0.0, sprev = 0.0, out = Sv + for sk in exp10.(range(-2, log10(Sv), length=400)) + gdec = Inf + for λ in eigvals(Matrix(cis(θ) * ode_matrix(params, Q, cis(θ) * sk))) + g = -real(λ) # backward-growth = forward-decay rate + g > 10.0 / sk && (gdec = min(gdec, g)) + end + isfinite(gdec) && (acc += gdec * (sk - sprev)) + sprev = sk + if acc >= 45.0 + out = sk + break + end + end + sm_fac * max(out, 2.0) + end + + local Us, Ub, E, bc_cond, Sbvp + if boundary !== nothing + # diagnostic: externally supplied boundary data (Us, Ub, E) at s_m, + # e.g. from an extended-precision march + Us, Ub, E = boundary + Sbvp = s_m + bc_cond = cond(hcat(Us ./ norm(Us), Ub ./ norm(Ub), E)) + elseif s_m < 0.98 * Sv + local UsS, UbS + if handoff === nothing + ua, dua = physical_ua_dua(cache, cis(θ) * Sv) + UbS = ComplexF64[ua[1, 1], ua[2, 1], ua[3, 1], dua[1, 1], dua[2, 1], dua[3, 1]] + UsS = ComplexF64[ua[1, 2], ua[2, 2], ua[3, 2], dua[1, 2], dua[2, 2], dua[3, 2]] + else + # diagnostic: externally supplied series hand-off (Us, Ub) at + # x = e^{iθ}S, e.g. evaluated in extended precision + UsS, UbS = handoff + end + Us, Ub, E = march_boundary(params, Q, θ, Sv, s_m, UsS, UbS; + rtol=march_rtol, growth=growth, seed=seed, + ztrk=ztrk, purge_budget=purge_budget, dp_res=dp_res) + Sbvp = s_m + bc_cond = cond(hcat(Us ./ norm(Us), Ub ./ norm(Ub), E)) + verbose && @printf(" march: S=%.4g → s_m=%.4g, bc_cond=%.1e\n", Sv, s_m, bc_cond) + else + Us, Ub, E, bc_cond = boundary_basis(params, Q, cache, θ, Sv; + growth=growth, seed=seed, dp_res=dp_res) + Sbvp = Sv + end + bc_cond < 1e12 || @warn "boundary basis poorly conditioned (cond ≈ $bc_cond)" + + t, D = cheblobatto(p) + w = barycentric_weights(t) + breaks = initial_breaks(params, Q, θ, Sbvp, p; cfl=cfl) + + Δraw = MVector{2,ComplexF64}(0, 0) + Δprev = MVector{2,ComplexF64}(NaN, NaN) + cexp = zeros(MMatrix{2,2,ComplexF64}) + local sols + resid = Inf + resid_prev = Inf + nstall = 0 + δΔ = Inf + round_used = 0 + + for round in 0:max_rounds + round_used = round + N = length(breaks) - 1 + Ic, Jc, Vc, rhs, βb, ndof, Ng = _assemble_base(params, Q, θ, breaks, p, t, D, Us, Ub, E) + sols = Matrix{ComplexF64}(undef, ndof, 2) + errs = zeros(Float64, N) + sol_odd, sol_even = _solve_parities(Ic, Jc, Vc, rhs, ndof, Ng, N, p) + for (isol, sol) in ((1, sol_odd), (2, sol_even)) + sols[:, isol] = sol + Δraw[isol] = sol[6*Ng+1] / βb + cexp[1, isol] = sol[6*Ng+2] + cexp[2, isol] = sol[6*Ng+3] + errs = max.(errs, _cell_errors(params, Q, θ, breaks, p, t, D, w, sol)) + end + resid = maximum(errs) + δΔ = maximum(abs.(Δraw .- Δprev) ./ (abs.(Δraw) .+ 1e-300)) + Δprev .= Δraw + verbose && @printf(" round %d: %d cells, resid %.2e, Δraw[1] = %.6e %+.6eim, δΔ = %.1e\n", + round, N, resid, real(Δraw[1]), imag(Δraw[1]), δΔ) + + (resid < refine_tol || round == max_rounds) && break + # Stagnation guard: on extreme-coefficient surfaces the residual + # bottoms out at the roundoff floor ε·‖M‖ (~10⁻⁸ ≫ refine_tol); + # refining past that plateau burns cells without informing Δ. Two + # consecutive rounds with < 2× improvement ⇒ converged-to-plateau. + nstall = resid > 0.5 * resid_prev ? nstall + 1 : 0 + resid_prev = resid + if nstall >= 2 + verbose && @printf(" refinement stagnated at resid %.2e (roundoff plateau) — stopping\n", resid) + break + end + marked = findall(>(refine_tol), errs) + isempty(marked) && break + if N + length(marked) > max_cells + @warn "solve_ray: cell budget ($max_cells) reached with resid=$resid" + break + end + newbreaks = Float64[breaks[1]] + for c in 1:N + if c in marked + push!(newbreaks, (breaks[c] + breaks[c+1]) / 2) + end + push!(newbreaks, breaks[c+1]) + end + breaks = newbreaks + end + + Δswapped = SVector{2,ComplexF64}(Δraw[2], Δraw[1]) # deltac_solve convention + Δ = rescale_delta(Δswapped, params) + + return RaySolveResult(Δ, SVector(Δraw), SMatrix(cexp), Q, θ, Sv, series_ok, + bc_cond, breaks, p, sols, resid, δΔ, round_used) +end + +solve_ray(params::GGJParameters, Q::Number; kwargs...) = + solve_ray(params, ComplexF64(Q); kwargs...) + +""" + solve_inner(::GGJModel{:ray}, params::GGJParameters, γ::Number; kwargs...) + -> SVector{2,ComplexF64} + +Solve the GGJ inner-layer matching problem with the rotated-ray collocation +backend and return the parity-projected matching data `(Δ₁, Δ₂)` in the same +convention as the `:shooting` and `:galerkin` backends (deltac output swap + +`X₀^{2√(−D_I)}` physical rescale applied), directly interchangeable with +them. Converts γ via `inner_Q` and forwards all keywords to +[`solve_ray`](@ref); use `solve_ray` directly when the full +[`RaySolveResult`](@ref) (raw Δ, contour, mesh, nodal solution, residuals) +is wanted. + +Preferred backend for |Q| ≳ 1 and for Q near the imaginary axis (rotation / +resistivity scans): validated to |Q| = 500 on the imaginary axis, where the +other backends fail. At |Q| ≪ 1 all three backends agree; `:ray` remains +accurate but `:galerkin` may be faster for very small |Q| real Q. +""" +function solve_inner(::GGJModel{:ray}, params::GGJParameters, γ::Number; kwargs...) + res = solve_ray(params, inner_Q(params, γ); kwargs...) + return res.Δ +end + +""" + evaluate_solution(res::RaySolveResult, s::Real; isol=1) -> SVector{6,ComplexF64} + +Evaluate the solved state v = (Ψ, Ξ, Υ, Ψ', Ξ', Υ') at ray parameter `s` +(diagnostic; barycentric interpolation on the containing cell). +""" +function evaluate_solution(res::RaySolveResult, s::Real; isol::Int=1) + breaks = res.breaks + p = res.p + (breaks[1] <= s <= breaks[end]) || throw(ArgumentError("s=$s outside [0, $(breaks[end])]")) + c = clamp(searchsortedlast(breaks, s), 1, length(breaks) - 1) + a, b = breaks[c], breaks[c+1] + t, _ = cheblobatto(p) + w = barycentric_weights(t) + vals = Matrix{ComplexF64}(undef, p + 1, 6) + for j in 0:p + g = (c - 1) * p + j + 1 + for i in 1:6 + vals[j+1, i] = res.sols[6*(g-1)+i, isol] + end + end + τ = 2 * (s - a) / (b - a) - 1 + return SVector{6,ComplexF64}(barycentric_eval(t, w, vals, τ)...) +end + +""" + solution_profile(res::RaySolveResult; npc=8) -> (; s, x, Ψ, Ξ, Υ) + +Reconstruct the physical inner-layer fields (Ψ, Ξ, Υ) on the solution +contour, `npc` points per cell over the collocation domain [0, s_m]. +Output layout mirrors the legacy `solve_inner_profile`: each field is an +`npts × 2` matrix with columns (isol=1 "odd", isol=2 "even"); `x = e^{iθ}s` +is the (complex) layer coordinate. For real Q (θ = 0) this is the real-axis +profile directly; for rotated solves the fields live on the ray — analytic +continuation back to real x is a separate (unstable) problem, by design. +""" +function solution_profile(res::RaySolveResult; npc::Int=8) + breaks = res.breaks + p = res.p + t, _ = cheblobatto(p) + w = barycentric_weights(t) + N = length(breaks) - 1 + npts = N * npc + 1 + s = Vector{Float64}(undef, npts) + Ψ = Matrix{ComplexF64}(undef, npts, 2) + Ξ = Matrix{ComplexF64}(undef, npts, 2) + Υ = Matrix{ComplexF64}(undef, npts, 2) + vals = Matrix{ComplexF64}(undef, p + 1, 6) + k = 0 + for c in 1:N + a, b = breaks[c], breaks[c+1] + for m in 0:(npc-1) + k += 1 + τ = -1 + 2 * m / npc + s[k] = a + (b - a) * (τ + 1) / 2 + for isol in 1:2 + for j in 0:p, i in 1:6 + vals[j+1, i] = res.sols[6*((c-1)*p+j)+i, isol] + end + v = barycentric_eval(t, w, vals, τ) + Ψ[k, isol] = v[1] + Ξ[k, isol] = v[2] + Υ[k, isol] = v[3] + end + end + end + # right endpoint + k += 1 + s[k] = breaks[end] + for isol in 1:2 + v = evaluate_solution(res, breaks[end]; isol=isol) + Ψ[k, isol] = v[1] + Ξ[k, isol] = v[2] + Υ[k, isol] = v[3] + end + return (; s=s, x=cis(res.θ) .* s, Ψ=Ψ, Ξ=Ξ, Υ=Υ) +end + +""" + asymptotic_profile(params, res::RaySolveResult, srange) -> (; s, x, Ψ, Ξ, Υ) + +Evaluate the ANALYTIC representation `u_small + Δ·u_big` on the ray for +`s ≥ res.S` (where the inps series is trusted; decaying-exponential content +is machine-dead there). Concatenating with [`solution_profile`](@ref) +demonstrates the seamless numeric ↔ asymptotic match — the same overlap the +outer-region matching relies on. +""" +function asymptotic_profile(params::GGJParameters, res::RaySolveResult, + srange::AbstractVector{<:Real}) + cache = build_asymptotics(params, res.Q; kmax=12) + n = length(srange) + Ψ = Matrix{ComplexF64}(undef, n, 2) + Ξ = Matrix{ComplexF64}(undef, n, 2) + Υ = Matrix{ComplexF64}(undef, n, 2) + for (k, s) in enumerate(srange) + ua, _ = physical_ua_dua(cache, cis(res.θ) * s) + for isol in 1:2 + Δ = res.Δraw[isol] + Ψ[k, isol] = ua[1, 2] + Δ * ua[1, 1] + Ξ[k, isol] = ua[2, 2] + Δ * ua[2, 1] + Υ[k, isol] = ua[3, 2] + Δ * ua[3, 1] + end + end + return (; s=collect(float.(srange)), x=cis(res.θ) .* srange, Ψ=Ψ, Ξ=Ξ, Υ=Υ) +end + +""" + delta_convergence(params, Q; verbose=true, refine_tol=1e-9, + max_rounds=16, max_cells=12000, kwargs...) + -> (; Δ, spread, base, table) + +Convergence battery for the matching data: solve at a trusted baseline, then +re-solve with every numerical knob perturbed on an INDEPENDENT axis, and +report the relative change of (Δ₁, Δ₂) per knob. The knobs probe orthogonal +error sources, so the worst-case `spread` is an honest error bar on Δ: + + - `θ → 1.2θ` : contour angle — Δ is an analytic invariant of the ray, + so any drift is pure numerical error (sharpest test). + OUTWARD: the natural angle maximizes clearance from the + x² ≈ −Q²(G+KF) pseudo-resonance, so inward checks + measure their own degraded solve, not the baseline; + - `p → p+4` : spectral-element order (interior discretization); + - `kmax+4, smax_tol/10` : series order and matching radius S (far-field BC); + - `refine_tol/10` : mesh refinement depth; + - `march_rtol/100` : outer-region quotient-march tolerance; + - `sm_fac 1→1.4` : BVP/march handoff radius; + - `growth 30→45` : decaying-pair purification e-folds. + +Returns the baseline `Δ`, the per-parity worst-case relative `spread`, the +baseline result struct, and the per-knob table `(name, δΔ₁, δΔ₂)`. +""" +function delta_convergence(params::GGJParameters, Q::ComplexF64; + verbose::Bool=true, refine_tol::Float64=1e-9, + max_rounds::Int=16, max_cells::Int=12000, kwargs...) + base = solve_ray(params, Q; refine_tol=refine_tol, max_rounds=max_rounds, + max_cells=max_cells, kwargs...) + variations = [ + ("θ → 1.2θ", (; θ=(base.θ == 0 ? 0.1 : 1.2 * base.θ))), + ("p → p+4", (; p=base.p + 4)), + ("kmax+4, S↑", (; kmax=16, smax_tol=1e-10)), + ("refine_tol/10", (; refine_tol=refine_tol / 10)), + ("march_rtol/100", (; march_rtol=1e-11)), + ("sm_fac 1→1.4", (; sm_fac=1.4)), + ("growth 30→45", (; growth=45.0)), + ] + table = NamedTuple[] + spread = MVector{2,Float64}(0.0, 0.0) + verbose && @printf("Δ convergence battery, Q = %s:\n", string(Q)) + verbose && @printf(" baseline: Δ₁ = %+.8e %+.8eim, Δ₂ = %+.8e %+.8eim\n", + real(base.Δ[1]), imag(base.Δ[1]), real(base.Δ[2]), imag(base.Δ[2])) + for (name, kw) in variations + r = solve_ray(params, Q; refine_tol=refine_tol, max_rounds=max_rounds, + max_cells=max_cells, kwargs..., kw...) + d1 = abs(r.Δ[1] - base.Δ[1]) / abs(base.Δ[1]) + d2 = abs(r.Δ[2] - base.Δ[2]) / abs(base.Δ[2]) + spread[1] = max(spread[1], d1) + spread[2] = max(spread[2], d2) + push!(table, (; name, d1, d2)) + verbose && @printf(" %-16s δΔ₁ = %.2e δΔ₂ = %.2e\n", name, d1, d2) + end + verbose && @printf(" %-16s δΔ₁ = %.2e δΔ₂ = %.2e\n", "WORST CASE", spread[1], spread[2]) + return (; Δ=base.Δ, spread=SVector(spread), base=base, table=table) +end + +delta_convergence(params::GGJParameters, Q::Number; kwargs...) = + delta_convergence(params, ComplexF64(Q); kwargs...) + +export solve_inner_ray, evaluate_solution, delta_convergence, + solution_profile, asymptotic_profile diff --git a/src/InnerLayer/GGJ/RayAsymptotics.jl b/src/InnerLayer/GGJ/RayAsymptotics.jl new file mode 100644 index 00000000..06deeaf1 --- /dev/null +++ b/src/InnerLayer/GGJ/RayAsymptotics.jl @@ -0,0 +1,244 @@ +# RayAsymptotics.jl +# +# Complex-x extensions of the inps Wasow asymptotic kernel +# (InnerAsymptotics.jl) for the rotated-ray solver (Ray.jl). +# +# The inps construction (GW2020 Eqs. 3–52) depends only on Q — the contour +# enters only through the EVALUATION point. These methods therefore reuse the +# existing, validated `InnerAsymptoticsCache` and coefficient packing verbatim +# and add evaluation at complex x = e^{iθ}s (principal branch of x^r, valid +# for |arg x| < π), the plain-variable conversion used by the ray boundary +# data, the GW2020 Eq. (54) residual along the ray, and the series-radius +# selector `pick_smax` (the ray analog of `pick_xmax`). +# +# Ported from GGJRay (~/Projects/GGJ_test, docs/METHOD.md §6); the real-x +# methods of InnerAsymptotics.jl are untouched. + +# Complex-argument Horner evaluator: same contract as _horner(x::Real, ...) +# in InnerAsymptotics.jl, with x^rvec on the principal branch. +function _horner(x::Complex, c::AbstractMatrix{ComplexF64}; + rvec::Union{Nothing,AbstractVector{<:Real}}=nothing, + derivative::Bool=false) + nrows, ncols = size(c) + n = ncols - 1 + + y = Vector{ComplexF64}(undef, nrows) + @inbounds for i in 1:nrows + y[i] = c[i, ncols] + end + @inbounds for k in (n-1):-1:0 + for i in 1:nrows + y[i] = y[i] * x + c[i, k+1] + end + end + + if rvec !== nothing + @inbounds for i in 1:nrows + y[i] *= ComplexF64(x)^rvec[i] + end + end + + if !derivative + return y, nothing + end + + dy = similar(y) + if rvec !== nothing + @inbounds for i in 1:nrows + dy[i] = c[i, ncols] * (rvec[i] + n) + end + @inbounds for k in (n-1):-1:0 + for i in 1:nrows + dy[i] = dy[i] * x + c[i, k+1] * (rvec[i] + k) + end + end + @inbounds for i in 1:nrows + dy[i] = dy[i] * ComplexF64(x)^rvec[i] / x + end + else + @inbounds for i in 1:nrows + dy[i] = c[i, ncols] * n + end + @inbounds for k in (n-1):-1:0 + for i in 1:nrows + dy[i] = dy[i] * x + c[i, k+1] * k + end + end + @inbounds for i in 1:nrows + dy[i] = dy[i] / x + end + end + return y, dy +end + +""" + evaluate_asymptotics(cache, x::Complex; derivative=true, apply_T=true) -> (U, dU) + +Evaluate the inps asymptotic basis at complex `x` (GW2020 Eq. 53; principal +branch, |arg x| < π). Returns the 6×2 matrix `U` whose columns are the two +power-like asymptotic solutions in the GW2020 Eq. (2) state convention +`u = (xΨ, Ξ, Υ, (xΨ)'/x, Ξ'/x, Υ'/x)`, and their x-derivatives `dU` if +requested. `apply_T=false` returns the pre-Jordan-basis representation used +by the residual measure. +""" +function evaluate_asymptotics(cache::InnerAsymptoticsCache, x::Complex; + derivative::Bool=true, apply_T::Bool=true) + xfac = 1 / (x * x) + R = cache.R + rvec = SVector(-R[1] / 2, -R[1] / 2, -R[2] / 2, -R[2] / 2) + + cc = _pack_y_coefs(cache) + dd = _pack_qp_coefs(cache) + + yy, dyy = _horner(xfac, cc; rvec=rvec, derivative=derivative) + zz, dzz = _horner(xfac, dd; derivative=derivative) + + y = SMatrix{2,2,ComplexF64}(yy[1], yy[2], yy[3], yy[4]) + q = SMatrix{2,2,ComplexF64}(zz[1], zz[2], zz[3], zz[4]) + p21 = SMatrix{4,2,ComplexF64}(zz[5], zz[6], zz[7], zz[8], + zz[9], zz[10], zz[11], zz[12]) + + pp_m = zeros(ComplexF64, 6, 2) + pp_m[1, 1] = 1 + pp_m[2, 2] = 1 + @inbounds for i in 1:4, j in 1:2 + pp_m[i+2, j] = p21[i, j] + end + pp = SMatrix{6,2,ComplexF64}(pp_m) + + smat = @SMatrix ComplexF64[1 0; 0 xfac] + + qsy = q * smat * y + U = pp * qsy + + if derivative + chain = -2 * xfac / x + dy_v = chain .* dyy + dz_v = chain .* dzz + + dy = SMatrix{2,2,ComplexF64}(dy_v[1], dy_v[2], dy_v[3], dy_v[4]) + dq = SMatrix{2,2,ComplexF64}(dz_v[1], dz_v[2], dz_v[3], dz_v[4]) + dp21 = SMatrix{4,2,ComplexF64}(dz_v[5], dz_v[6], dz_v[7], dz_v[8], + dz_v[9], dz_v[10], dz_v[11], dz_v[12]) + + dpp_m = zeros(ComplexF64, 6, 2) + @inbounds for i in 1:4, j in 1:2 + dpp_m[i+2, j] = dp21[i, j] + end + dpp = SMatrix{6,2,ComplexF64}(dpp_m) + + dsmat = @SMatrix ComplexF64[0 0; 0 (-2*xfac/x)] + dqsy = q * smat * dy + q * dsmat * y + dq * smat * y + dU = pp * dqsy + dpp * qsy + + if apply_T + U = cache.T * U + dU = cache.T * dU + end + return U, dU + else + if apply_T + U = cache.T * U + end + return U, nothing + end +end + +""" + physical_ua_dua(cache, x::Number) -> (ua, dua) + +Convert the inps 6×2 basis at (possibly complex) `x` to physical `(Ψ, Ξ, Υ)` +values and x-derivatives (each 3×2; column 1 = large power solution, +column 2 = small). Same map as the deltac `inpso_get_ua/dua` convention and +the Galerkin backend's `_physical_ua_dua`, generalized to complex x. +""" +function physical_ua_dua(cache::InnerAsymptoticsCache, x::Number) + xc = ComplexF64(x) + U, dU = evaluate_asymptotics(cache, xc; derivative=true, apply_T=true) + ua = zeros(ComplexF64, 3, 2) + dua = zeros(ComplexF64, 3, 2) + @inbounds for j in 1:2 + ua[1, j] = U[1, j] / xc + ua[2, j] = U[2, j] + ua[3, j] = U[3, j] + dua[1, j] = U[4, j] - U[1, j] / (xc * xc) + dua[2, j] = U[5, j] * xc + dua[3, j] = U[6, j] * xc + end + return ua, dua +end + +""" + asymptotic_residual(cache, x::Complex) -> SVector{2,Float64} + +GW2020 Eq. (54) convergence measure of the two power-like series columns at +complex `x`: ‖dU − x·J(x)·U‖∞ / max(‖dU‖∞, ‖x·J(x)·U‖∞), per column. +""" +function asymptotic_residual(cache::InnerAsymptoticsCache, x::Complex) + U, dU = evaluate_asymptotics(cache, x; derivative=true, apply_T=false) + + xfac = 1 / (x * x) + M = cache.J[1] + if cache.kmax > 0 + M = M + xfac * cache.J[2] + end + if cache.kmax > 1 + M = M + xfac * xfac * cache.J[3] + end + + matvec1 = dU + matvec2 = -x * (M * U) + matvec0 = matvec1 + matvec2 + + delta = MVector{2,Float64}(0.0, 0.0) + @inbounds for j in 1:2 + n0 = 0.0 + n1 = 0.0 + n2 = 0.0 + for i in 1:6 + n0 = max(n0, abs(matvec0[i, j])) + n1 = max(n1, abs(matvec1[i, j])) + n2 = max(n2, abs(matvec2[i, j])) + end + denom = max(n1, n2) + delta[j] = denom == 0 ? 0.0 : n0 / denom + end + return SVector{2,Float64}(delta) +end + +""" + pick_smax(params, Q; θ=angle(Q)/4, eps=1e-9, kmax=12, cache=nothing, + slogmin=-1.0, slogmax=6.5, dslog=0.01) -> (S, cache, achieved) + +Ray analog of `pick_xmax`: sweep the ray parameter `s` log-uniformly and +return the smallest `s` at which the series residual along `x = e^{iθ}s` +drops below `eps`. If the target is never reached, returns the residual- +minimizing `s` with `achieved = false` (caller should warn). Also returns +the asymptotics cache for reuse. +""" +function pick_smax(params::GGJParameters, Q::ComplexF64; + θ::Float64=angle(Q) / 4, eps::Float64=1e-9, kmax::Int=12, + cache::Union{Nothing,InnerAsymptoticsCache}=nothing, + slogmin::Float64=-1.0, slogmax::Float64=6.5, dslog::Float64=0.01) + c = cache === nothing ? build_asymptotics(params, Q; kmax=kmax) : cache + ph = cis(θ) + best_s = 10.0^slogmin + best_r = Inf + slog = slogmin + while slog <= slogmax + s = 10.0^slog + r = maximum(asymptotic_residual(c, ph * s)) + if r < eps + return s, c, true + end + if r < best_r + best_r = r + best_s = s + end + slog += dslog + end + return best_s, c, false +end + +pick_smax(params::GGJParameters, Q::Number; kwargs...) = + pick_smax(params, ComplexF64(Q); kwargs...) diff --git a/src/InnerLayer/InnerLayer.jl b/src/InnerLayer/InnerLayer.jl index 934e8f83..03b195ce 100644 --- a/src/InnerLayer/InnerLayer.jl +++ b/src/InnerLayer/InnerLayer.jl @@ -17,6 +17,8 @@ include("GGJ/GGJ.jl") import .GGJ: GGJModel, GGJParameters, build_asymptotics, evaluate_asymptotics, pick_xmax import .GGJ: InnerAsymptoticsCache, mercier_di, mercier_dr, inner_Q, rescale_delta import .GGJ: glasser_wang_2020_eq55, solve_inner_converged # solve_inner_converged: experimental, not exported (reachable as a qualified call only) +import .GGJ: solve_ray, RaySolveResult, pick_smax, physical_ua_dua +import .GGJ: delta_convergence, solution_profile, asymptotic_profile, q4_surface_benchmark # SLAYER imports go here export InnerLayerModel, solve_inner @@ -24,6 +26,8 @@ export GGJ, GGJModel, GGJParameters export build_asymptotics, evaluate_asymptotics, pick_xmax, InnerAsymptoticsCache export mercier_di, mercier_dr, inner_Q, rescale_delta export glasser_wang_2020_eq55 +export solve_ray, RaySolveResult, pick_smax, physical_ua_dua +export delta_convergence, solution_profile, asymptotic_profile, q4_surface_benchmark # SLAYER exports go here diff --git a/test/runtests_innerlayer.jl b/test/runtests_innerlayer.jl index 7ee36d06..188beddc 100644 --- a/test/runtests_innerlayer.jl +++ b/test/runtests_innerlayer.jl @@ -49,3 +49,39 @@ const GGJ = IL.GGJ @test abs(imag(Δ[2])) < 1e-3 * abs(Δ[2]) end end + +@testset "InnerLayer GGJ :ray backend (rotated-contour collocation)" begin + # Ported 2026-07-08 from the standalone GGJRay package (validated there: + # manufactured Δ* to 3e-14, Fortran rmatch pins, 96-equilibrium robustness + # scans to Q = 500i). These tests pin the port, not the method. + p = IL.glasser_wang_2020_eq55() + + @testset "agrees with :galerkin at the paper point Q = 0.1234" begin + γ = 0.1234 * GGJ.q0(p) + Δ = IL.solve_inner(IL.GGJModel(; solver=:ray), p, γ) + # Same Fortran-cross-checked pins as the Galerkin testset above. + @test real(Δ[1]) ≈ 3.698368e4 rtol = 1e-3 + @test real(Δ[2]) ≈ 14.759721 rtol = 1e-3 + @test abs(imag(Δ[1])) < 1e-3 * abs(Δ[1]) + @test abs(imag(Δ[2])) < 1e-3 * abs(Δ[2]) + # :ray is the default backend. + @test IL.GGJModel() === IL.GGJModel{:ray}() + end + + @testset "q4 physical benchmark at Q = 500i (regime beyond :galerkin)" begin + q4 = IL.q4_surface_benchmark() + γ = 500.0im * GGJ.q0(q4) + Δ = IL.solve_inner(IL.GGJModel(), q4, γ) + # Pins from the standalone GGJRay validation (post ε_mix fix; + # S-invariant to 3e-4 / 7e-9 and θ-stable there). + @test Δ[1] ≈ 2.4720608737 + 13.354123514im rtol = 1e-4 + @test Δ[2] ≈ 0.13749694953 + 0.74275468725im rtol = 1e-4 + + # Δ is an analytic invariant of the contour angle: the outward + # θ-check drift is a direct numerical error measurement. + Q = GGJ.inner_Q(q4, γ) + r2 = IL.solve_ray(q4, Q; θ=1.2 * angle(Q) / 4) + @test abs(r2.Δ[2] - Δ[2]) / abs(Δ[2]) < 1e-5 + @test abs(r2.Δ[1] - Δ[1]) / abs(Δ[1]) < 1e-3 + end +end From a4ec72d8898ca3ef1a1bfbcef91cde16e4f869c3 Mon Sep 17 00:00:00 2001 From: logan-nc <6198372+logan-nc@users.noreply.github.com> Date: Thu, 9 Jul 2026 09:36:03 -0400 Subject: [PATCH 03/38] DOCS - NEW FEATURE - JCP-style documentation standard and full InnerLayer :ray page with provenance-tracked figures Establishes a repeatable "Journal of Computational Physics"-style documentation standard and applies it to the new GGJ rotated-ray (:ray) inner-layer backend. Standard and figure infrastructure: - docs/DOC_STANDARD.md: module-page template (equations -> numerical method -> validation figures -> API), figure-organization principle, provenance policy, and the regenerate-only-when-`depends`-change rule. - docs/figure_tools.jl: save_doc_figure stamps each PNG with the git commit/date, writes a machine-readable manifest.toml with per-figure `depends`, and centralizes the step_series spectrum helper. - Content figures now live in docs/src/figures// (script + PNG together); docs/src/assets/ is reserved for Documenter chrome. .gitignore updated. InnerLayer page (docs/src/inner_layer.md, stub -> full page) with four computed, provenance-stamped figures: backend accuracy vs :ray along the imaginary axis, the rotated-ray/pseudo-resonance geometry, the seamless collocation<->asymptotic field match, and the Q=500i convergence error bar. Ballooning figures migrated to docs/src/figures/ballooning/ as the second exemplar and registered legacy (generators predate the provenance system). Hardening and review fixes: - regression-harness case ggj_ray_q500i (+ runner template) pinning the :ray matching data at Q=500i on the q=4 benchmark surface. - test/runtests_innerlayer.jl: internal-machinery unit tests (cheblobatto, ode_matrix, parity_rows, decaying_pair, profile diagnostics, delta_convergence). - GGJParameters.jl: corrected the rescale_delta docstring exponent (X0^(-2*sqrt(-D_I)), was mislabeled with the reciprocal sign). Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 4 + docs/DOC_STANDARD.md | 126 +++++++++ docs/figure_tools.jl | 166 +++++++++++ docs/src/ballooning.md | 4 +- docs/src/citations.md | 2 +- docs/src/developer_notes.md | 12 + .../ballooning/delta_prime_matching.png | Bin .../ballooning/mercier_comparison.png | Bin .../inner_layer/backend_regime_map.png | Bin 0 -> 59262 bytes .../inner_layer/convergence_Sinvariance.png | Bin 0 -> 52336 bytes .../inner_layer/make_backend_regime_map.jl | 65 +++++ .../make_convergence_Sinvariance.jl | 45 +++ .../inner_layer/make_rotated_ray_contour.jl | 65 +++++ .../inner_layer/make_solution_profiles.jl | 60 ++++ .../inner_layer/rotated_ray_contour.png | Bin 0 -> 46538 bytes .../figures/inner_layer/solution_profiles.png | Bin 0 -> 57570 bytes docs/src/figures/manifest.toml | 43 +++ docs/src/inner_layer.md | 267 +++++++++++++++++- regression-harness/cases/ggj_ray_q500i.toml | 53 ++++ regression-harness/src/runner.jl | 27 ++ src/InnerLayer/GGJ/GGJParameters.jl | 6 +- test/runtests_innerlayer.jl | 56 ++++ 22 files changed, 990 insertions(+), 11 deletions(-) create mode 100644 docs/DOC_STANDARD.md create mode 100644 docs/figure_tools.jl rename docs/src/{assets => figures}/ballooning/delta_prime_matching.png (100%) rename docs/src/{assets => figures}/ballooning/mercier_comparison.png (100%) create mode 100644 docs/src/figures/inner_layer/backend_regime_map.png create mode 100644 docs/src/figures/inner_layer/convergence_Sinvariance.png create mode 100644 docs/src/figures/inner_layer/make_backend_regime_map.jl create mode 100644 docs/src/figures/inner_layer/make_convergence_Sinvariance.jl create mode 100644 docs/src/figures/inner_layer/make_rotated_ray_contour.jl create mode 100644 docs/src/figures/inner_layer/make_solution_profiles.jl create mode 100644 docs/src/figures/inner_layer/rotated_ray_contour.png create mode 100644 docs/src/figures/inner_layer/solution_profiles.png create mode 100644 docs/src/figures/manifest.toml create mode 100644 regression-harness/cases/ggj_ray_q500i.toml diff --git a/.gitignore b/.gitignore index 7e1ff4f6..0729e730 100644 --- a/.gitignore +++ b/.gitignore @@ -16,9 +16,13 @@ !docs/resources/*.pdf *.png !docs/src/assets/**/*.png +# committed documentation figures (generated by docs/src/figures/**/make_*.jl) +!docs/src/figures/**/*.png *.jld2 .gitattributes Manifest.toml +# the docs figure-provenance manifest is not a Julia Manifest.toml (case-insensitive FS collides) +!docs/src/figures/manifest.toml *.log docs/build/ anaconda_projects/ diff --git a/docs/DOC_STANDARD.md b/docs/DOC_STANDARD.md new file mode 100644 index 00000000..a1af2043 --- /dev/null +++ b/docs/DOC_STANDARD.md @@ -0,0 +1,126 @@ +# GPEC documentation standard + +This is the authoring standard for the GPEC user documentation under +`docs/src/`. The goal is that each physics module page reads like a compact +*Journal of Computational Physics* methods paper: the governing equations, the +numerical method, and the validation that the method works — with figures whose +provenance is explicit. Those module pages **are** built by Documenter and +published on the public documentation site. + +This standard file (`docs/DOC_STANDARD.md`) is itself the one exception: it lives +outside `docs/src/` and is not listed in `make.jl`, so Documenter does not +render it to the website. It is a contributor-facing reference, read here in the +repository — which is why `developer_notes.md` links to it by GitHub URL rather +than as a site-internal page. + +The `Ballooning and Mercier Local Stability` and `Inner Layer Module` pages are +the reference exemplars; new or reworked module pages should follow their shape. + +## Module-page template + +A module reference page (`docs/src/.md`) has these sections, in order. +Not every section is large for every module, but the skeleton is the same. + +1. **Overview** — what the module computes and where it sits in the + equilibrium → stability → perturbed-equilibrium pipeline. +2. **Governing equations** — the physics the module discretizes, written as + numbered display equations. Each equation or block cites the paper it comes + from (the PDFs in `docs/resources/`), e.g. *(GWP2016 Eq. 11)*. Citing + equation numbers is required, not optional — it is what makes the + implementation auditable against theory. +3. **Numerical method** — the discretization and algorithm: what is expanded in + what basis, how the linear/eigenvalue/BVP problem is assembled and solved, + and the *design choices* that matter (why this contour, this ordering, this + precision). This is the part that distinguishes a methods page from an API + dump. +4. **Validation & benchmarks** — evidence the method is correct: a table of + pinned values cross-checked against an independent code or analytic result, + and figures (see below) showing convergence, invariance, or agreement. +5. **Practical usage** — the configuration keys and the public entry points a + user calls, with a short worked example. +6. **API Reference** — an `@autodocs` block for the module so every exported + docstring is rendered and `checkdocs=:exports` stays satisfied. + +## Figures + +### Where figures live + +- **Content figures** (anything shown on a page) live in + `docs/src/figures//`. Each committed `.png` sits next to the + script `make_.jl` that produced it — script and figure are always + committed together, so it is always clear how a figure was made. +- `docs/src/assets/` is reserved for **Documenter chrome only** (the site logo, + themes, custom CSS). Content figures never go there. +- The shared helper `docs/figure_tools.jl` (outside `src/`, so it is not + published) provides `save_doc_figure`, provenance stamping, the manifest, and + the `step_series` spectrum helper. Every `make_.jl` `include`s it. + +### Where a figure's data comes from + +Documentation figures get their **own** generator scripts under +`docs/src/figures/`. They are not forced to route through `benchmarks/`. When a +figure genuinely *is* a benchmark or Fortran cross-check result, its +`make_.jl` may call the relevant `benchmarks/` script, and its `depends` +list records that coupling — but the committed PNG and a runnable script still +live together under `docs/src/figures//`. + +`benchmarks/` remains the home of performance comparisons and Fortran +cross-checks (its outputs are git-ignored and may be heavy). `docs/src/figures/` +is the home of the small, purpose-built, committed figures that illustrate a +page. + +### Figures do not build with the docs + +Figure scripts run **manually**, in the root project: + +```bash +julia --project=. docs/src/figures//make_.jl +``` + +They are **never** run at Documenter build time. The build only embeds the +committed PNGs, so `docs/Project.toml` stays minimal (no `Plots`) and the build +stays fast. This is also why we do not regenerate every figure on every docs +change — see the regeneration policy below. + +### Provenance + +`save_doc_figure` stamps a small `GPEC · ` mark in the corner +of every figure (so a figure's age is visible when browsing the rendered site) +and writes a machine-readable entry to `docs/src/figures/manifest.toml`: + +```toml +[inner_layer.rotated_ray_contour] +script = "make_rotated_ray_contour.jl" +commit = "3a0837e3" +date = "2026-07-08" +depends = ["src/InnerLayer/GGJ/Ray.jl"] +``` + +`depends` lists the repo-relative source files whose *numbers* the figure +visualizes. Generate figures as the last step before committing a change; the +stamp then reflects the state they were made against (a `-dirty` suffix marks a +figure generated with an uncommitted working tree, which is honest, not an +error). + +### When to regenerate a figure + +Regenerate a figure (and its manifest entry) only when: + +1. a file in its `depends` list changed the numbers it shows — the regression + harness is the trigger: if a tracked quantity for that module moved, its + figures are suspect; +2. the physics or method the figure illustrates changed; or +3. the figure script itself changed. + +Do **not** regenerate for prose edits, formatting, or changes to unrelated +modules. Because each figure carries its generating commit and a `depends` +list, a reviewer (or a future `docs/check_figures.jl`) can tell when a figure +predates the last change to a file it depends on and is therefore stale. + +### Legacy figures + +A figure migrated from before this system, whose original generator was not +preserved and which cannot be faithfully reproduced from current code, is +registered with `register_legacy_figure` (`commit = "legacy"`, a `note` +explaining why). This is honest bookkeeping for genuinely irreproducible +figures — it is never a substitute for writing a generator for a new figure. diff --git a/docs/figure_tools.jl b/docs/figure_tools.jl new file mode 100644 index 00000000..ca8001be --- /dev/null +++ b/docs/figure_tools.jl @@ -0,0 +1,166 @@ +# figure_tools.jl +# +# Shared helpers for GPEC documentation figures. Every figure generator +# (`docs/src/figures//make_.jl`) `include`s this file and calls +# `save_doc_figure` to stamp, save, and register its output. See +# `docs/DOC_STANDARD.md` for the figure-organization and provenance policy. +# +# This file lives OUTSIDE `docs/src/` so Documenter does not publish it. The +# `make_.jl` scripts do live under `docs/src/figures/` (co-located with +# their PNGs) and are published alongside the figures for reproducibility. +# +# Figure scripts run MANUALLY in the root project, never at docs-build time: +# julia --project=. docs/src/figures//make_.jl +# so the Documenter build stays fast and Plots-free. + +using Plots +using Dates +using TOML + +const _DOCS_DIR = @__DIR__ # .../docs +const _REPO_ROOT = normpath(joinpath(_DOCS_DIR, "..")) # repo root +const FIGURES_ROOT = joinpath(_DOCS_DIR, "src", "figures") # docs/src/figures +const MANIFEST_PATH = joinpath(FIGURES_ROOT, "manifest.toml") + +""" + figure_provenance() -> (; commit, date, dirty) + +Short git commit and ISO date stamped onto every figure. `commit` is the +`git rev-parse --short HEAD` hash (reusing the idiom from +`benchmarks/benchmark_git_branches.jl`), with a `-dirty` suffix when the +working tree has uncommitted changes so a figure never claims a cleaner +provenance than it has. Falls back to `"unknown"` outside a git checkout. +""" +function figure_provenance() + date = string(Dates.today()) + try + h = strip(read(`git -C $(_REPO_ROOT) rev-parse --short HEAD`, String)) + dirty = !isempty(strip(read(`git -C $(_REPO_ROOT) status --porcelain`, String))) + return (; commit=(dirty ? "$h-dirty" : h), date=date, dirty=dirty) + catch + return (; commit="unknown", date=date, dirty=false) + end +end + +""" + stamp!(p; commit, date, subplot=length(p.subplots)) + +Annotate a small `GPEC · ` provenance mark in the bottom-right +corner of subplot `subplot` (default: the last panel), so the figure's age is +visible when browsing the rendered docs. Works on linear or log axes +(positions in data coordinates). Never throws: a plotting hiccup warns and +leaves the figure unstamped rather than losing the plot. +""" +function stamp!(p::Plots.Plot; commit::AbstractString, date::AbstractString, + subplot::Int=length(p.subplots)) + try + sp = p[subplot] + xl = Plots.xlims(sp) + yl = Plots.ylims(sp) + xpos = xl[2] # right edge (data coords) + ypos = yl[1] # bottom edge + txt = Plots.text("GPEC $(commit) · $(date)", 6, RGBA(0.5, 0.5, 0.5, 0.9), + :right, :bottom) + annotate!(sp, xpos, ypos, txt) + catch err + @warn "stamp! failed; saving figure without provenance mark" exception = err + end + return p +end + +""" + save_doc_figure(p, mod, name; script, depends, npx...) -> String + +Stamp `p` with the current git provenance, save it to +`docs/src/figures//.png`, and upsert its `manifest.toml` entry +(`script`, `commit`, `date`, `depends`). `depends` lists the repo-relative +source files whose numbers the figure visualizes — the regeneration policy in +`docs/DOC_STANDARD.md` keys off them. Returns the absolute PNG path (printed +so it can be opened directly). Extra keyword args are forwarded to `savefig`. +""" +function save_doc_figure(p::Plots.Plot, mod::AbstractString, name::AbstractString; + script::AbstractString, depends::AbstractVector{<:AbstractString}=String[]) + prov = figure_provenance() + stamp!(p; commit=prov.commit, date=prov.date) + + outdir = joinpath(FIGURES_ROOT, mod) + mkpath(outdir) + outpath = joinpath(outdir, name * ".png") + savefig(p, outpath) + + _update_manifest!(mod, name; script=String(script), commit=prov.commit, + date=prov.date, depends=String.(depends)) + + println("Saved doc figure: $(abspath(outpath))") + return abspath(outpath) +end + +# Load / merge-write the figures manifest, keyed [.]. Kept sorted and +# human-diffable; one entry per committed figure. +function _load_manifest() + return isfile(MANIFEST_PATH) ? TOML.parsefile(MANIFEST_PATH) : Dict{String,Any}() +end + +function _write_manifest(manifest) + open(MANIFEST_PATH, "w") do io + println(io, "# GPEC documentation-figure provenance manifest.") + println(io, "# One [.
] entry per committed PNG under docs/src/figures/.") + println(io, "# Regenerate a figure (and this entry) only when a file in its `depends`") + println(io, "# list changes the numbers it shows — see docs/DOC_STANDARD.md.") + println(io) + TOML.print(io, manifest; sorted=true) + end + return manifest +end + +function _update_manifest!(mod::AbstractString, name::AbstractString; + script::String, commit::String, date::String, depends::Vector{String}) + manifest = _load_manifest() + modtbl = get!(manifest, mod, Dict{String,Any}()) + modtbl[name] = Dict{String,Any}( + "script" => script, + "commit" => commit, + "date" => date, + "depends" => depends + ) + return _write_manifest(manifest) +end + +""" + register_legacy_figure(mod, name; note, depends=String[]) + +Record a manifest entry for a figure that predates this provenance system and +whose original generator script was not preserved (`commit = "legacy"`). Use +this only for migrated figures that cannot be faithfully reproduced from the +current code — never to skip writing a generator for a new figure. `note` +states why the figure is legacy (e.g. "compared the since-removed Mercier.jl"). +""" +function register_legacy_figure(mod::AbstractString, name::AbstractString; + note::AbstractString, depends::AbstractVector{<:AbstractString}=String[]) + manifest = _load_manifest() + modtbl = get!(manifest, mod, Dict{String,Any}()) + modtbl[name] = Dict{String,Any}( + "script" => "(not preserved)", + "commit" => "legacy", + "date" => "unknown", + "depends" => String.(depends), + "note" => String(note) + ) + _write_manifest(manifest) + println("Registered legacy figure: $(mod).$(name)") + return manifest +end + +""" + step_series(m_vals, amps) -> (m_ext, amp_ext) + +Canonical spectrum-plot helper (CLAUDE.md plotting convention): pad a zero on +each end of a discrete-mode series so a `seriestype=:steppre` plot draws clean +boxes that fall to zero at the edges. Centralized here so doc figures and +benchmarks share one definition. +""" +function step_series(m_vals, amps) + m_ext = [m_vals[1] - 1; m_vals; m_vals[end] + 1] + amp_ext = [0.0; amps; 0.0] + return m_ext, amp_ext +end diff --git a/docs/src/ballooning.md b/docs/src/ballooning.md index 6207b225..3313e535 100644 --- a/docs/src/ballooning.md +++ b/docs/src/ballooning.md @@ -854,7 +854,7 @@ numerically fragile. The comparison below shows that the `Baloo.f` style, which sets the displacement to vanish at ``\pm\theta_{\max}`` and computes ``\Delta'`` from the resulting solution, gives the most stable continuous profile. -![Comparison of numerical stability among methods for extracting ``\Delta'`` and ``c_{a1}``.](assets/ballooning/delta_prime_matching.png) +![Comparison of numerical stability among methods for extracting ``\Delta'`` and ``c_{a1}``.](figures/ballooning/delta_prime_matching.png) `Bal.jl` therefore adopts the `Baloo.f`-style boundary replacement for the ballooning ``\Delta'`` calculation. @@ -905,7 +905,7 @@ The previous standalone `Mercier.jl` path can therefore be removed from the loca stability calculation. The comparison below shows agreement between the previous Mercier calculation and the new `Bal.jl` calculation. -![Comparison of Mercier criterion calculations from `Mercier.jl` and `Bal.jl`.](assets/ballooning/mercier_comparison.png) +![Comparison of Mercier criterion calculations from `Mercier.jl` and `Bal.jl`.](figures/ballooning/mercier_comparison.png) The local-stability output now stores ballooning ``\Delta'`` in the fourth `locstab_fs` entry. In the HDF5 output this is written as diff --git a/docs/src/citations.md b/docs/src/citations.md index dfb65fb6..143b9a49 100644 --- a/docs/src/citations.md +++ b/docs/src/citations.md @@ -104,7 +104,7 @@ Provides an efficient algorithm for computing the full Δ' matrix (coupling betw > *Physics of Plasmas* **27**, 012506 (2020). > DOI: [10.1063/1.5134999](https://doi.org/10.1063/1.5134999) -Showcases a different basis that significantly aids convergence of the Galerkin solver used in the GGJ InnerLayer module. +Constructs the Wasow large-``x`` asymptotic basis (the `inps` kernel) that supplies the far-field boundary condition for every GGJ InnerLayer backend — the Galerkin solver and the rotated-contour collocation (`:ray`) solver alike. --- diff --git a/docs/src/developer_notes.md b/docs/src/developer_notes.md index 9a77b751..95f586da 100644 --- a/docs/src/developer_notes.md +++ b/docs/src/developer_notes.md @@ -16,6 +16,18 @@ CODE - TAG - Detailed message Where CODE is the module name (EQUIL, ForceFreeStates, VAC, PERTURBED EQUILIBRIUM, etc.) and TAG describes the type of change (WIP, MINOR, IMPROVEMENT, BUG FIX, NEW FEATURE, REFACTOR, CLEANUP, etc.). This format is used for compiling release notes — tags should be human-readable but are not enforced to a fixed set. +## Documentation standard + +Module reference pages follow a shared *Journal of Computational Physics*-style +structure (governing equations → numerical method → validation figures → API), +and every documentation figure is committed together with the script that made +it and a provenance stamp. The full policy — page template, figure +organization under `docs/src/figures//`, provenance, and the +regenerate-only-when-`depends`-change rule — is in +[`docs/DOC_STANDARD.md`](https://github.com/OpenFUSIONToolkit/GPEC/blob/develop/docs/DOC_STANDARD.md). +The `Ballooning and Mercier Local Stability` and `Inner Layer Module` pages are +the reference exemplars. + ## Regression Testing The regression harness **must be run on every pull request before merging into `develop`**. It is the project's primary safeguard for tracking how numerical results evolve across changes, so it is only useful if every PR exercises it. When you open a PR, paste the regression report into the PR thread so reviewers can see what moved (and what did not). If your change touches a quantity that is not yet tracked, add a new regression case — or extend an existing one — in the same PR. diff --git a/docs/src/assets/ballooning/delta_prime_matching.png b/docs/src/figures/ballooning/delta_prime_matching.png similarity index 100% rename from docs/src/assets/ballooning/delta_prime_matching.png rename to docs/src/figures/ballooning/delta_prime_matching.png diff --git a/docs/src/assets/ballooning/mercier_comparison.png b/docs/src/figures/ballooning/mercier_comparison.png similarity index 100% rename from docs/src/assets/ballooning/mercier_comparison.png rename to docs/src/figures/ballooning/mercier_comparison.png diff --git a/docs/src/figures/inner_layer/backend_regime_map.png b/docs/src/figures/inner_layer/backend_regime_map.png new file mode 100644 index 0000000000000000000000000000000000000000..d86221bcdfa989e5698539f3d2fabd3449059601 GIT binary patch literal 59262 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 C^I&HH literal 0 HcmV?d00001 diff --git a/docs/src/figures/inner_layer/convergence_Sinvariance.png b/docs/src/figures/inner_layer/convergence_Sinvariance.png new file mode 100644 index 0000000000000000000000000000000000000000..d8b550822530a547073755a57db8762df23bfa33 GIT binary patch literal 52336 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

cz{R+x| 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

] entry per committed PNG under docs/src/figures/. +# Regenerate a figure (and this entry) only when a file in its `depends` +# list changes the numbers it shows — see docs/DOC_STANDARD.md. + + +[ballooning.delta_prime_matching] +commit = "legacy" +date = "unknown" +depends = ["src/ForceFreeStates/Ballooning.jl"] +note = "Predates the provenance system; compared methods for extracting the ballooning Delta_prime and c_a1. Original generator script not preserved." +script = "(not preserved)" + +[ballooning.mercier_comparison] +commit = "legacy" +date = "unknown" +depends = ["src/ForceFreeStates/Ballooning.jl"] +note = "Predates the provenance system; compared the since-removed standalone Mercier.jl against Bal.jl. Not reproducible from current code (Mercier.jl was removed once Bal.jl subsumed it)." +script = "(not preserved)" + +[inner_layer.backend_regime_map] +commit = "3a0837e3-dirty" +date = "2026-07-08" +depends = ["src/InnerLayer/GGJ/Ray.jl", "src/InnerLayer/GGJ/Galerkin.jl", "src/InnerLayer/GGJ/Shooting.jl"] +script = "make_backend_regime_map.jl" + +[inner_layer.convergence_Sinvariance] +commit = "3a0837e3-dirty" +date = "2026-07-08" +depends = ["src/InnerLayer/GGJ/Ray.jl"] +script = "make_convergence_Sinvariance.jl" + +[inner_layer.rotated_ray_contour] +commit = "3a0837e3-dirty" +date = "2026-07-08" +depends = ["src/InnerLayer/GGJ/Ray.jl"] +script = "make_rotated_ray_contour.jl" + +[inner_layer.solution_profiles] +commit = "3a0837e3-dirty" +date = "2026-07-08" +depends = ["src/InnerLayer/GGJ/Ray.jl", "src/InnerLayer/GGJ/RayAsymptotics.jl", "src/InnerLayer/GGJ/InnerAsymptotics.jl"] +script = "make_solution_profiles.jl" diff --git a/docs/src/inner_layer.md b/docs/src/inner_layer.md index cc80f52e..2d9a2246 100644 --- a/docs/src/inner_layer.md +++ b/docs/src/inner_layer.md @@ -1,17 +1,272 @@ # Inner Layer Module -The InnerLayer module provides abstract scaffolding for resistive inner-layer -models used in matched asymptotic expansions for resistive MHD stability -analysis. It currently includes the GGJ (Glasser–Greene–Johnson) shooting -method for computing the inner-layer response. +The `InnerLayer` module solves the **resistive inner-region problem** of +matched-asymptotic resistive-MHD stability: the thin layer around a rational +surface where resistivity, inertia and the ideal singularity balance. Its +output is the pair of parity-projected matching data ``(\Delta_\mathrm{odd}, +\Delta_\mathrm{even})`` that the outer ideal region (DCON/GPEC) matches to in +order to obtain resistive growth rates, tearing ``\Delta'``, and resistive +interchange stability. -## InnerLayer +The module provides an abstract [`InnerLayerModel`](@ref InnerLayer.InnerLayerModel) interface and one +concrete model so far, the **GGJ (Glasser–Greene–Johnson)** layer, with three +interchangeable solver backends. Future inner-layer models (SLAYER, kinetic +layers) plug in through the same interface. + +Two source papers define the equations and the asymptotic construction, and are +cited by their equation numbers throughout the code and below: + +- **GWP2016** — A. H. Glasser, Z. R. Wang and J.-K. Park, *Computation of + resistive instabilities by matched asymptotic expansions*, Phys. Plasmas + **23**, 112506 (2016). +- **GW2020** — A. H. Glasser and Z. R. Wang, *Asymptotic solutions and + convergence studies of the resistive inner region equations*, Phys. Plasmas + **27**, 012506 (2020). + +## Governing equations + +At a rational surface the layer equations couple three fields — the perturbed +flux ``\Psi`` and two auxiliary layer variables ``\Xi`` and ``\Upsilon`` — as +functions of the scaled distance ``x`` from the surface. In the GWP2016 form +(Eq. 11 ≡ GW2020 Eq. 1) they are + +```math +\begin{aligned} +\Psi'' &= H\,\Upsilon' + Q\,(\Psi - x\,\Xi),\\[2pt] +\Xi'' &= \frac{1}{Q^{2}}\Big[\,Q x^{2}\,\Xi - Q x\,\Psi - (E+F)\,\Upsilon - H\,\Psi'\,\Big],\\[2pt] +\Upsilon''&= \frac{1}{Q}\Big[\,x^{2}\,\Upsilon - x\,\Psi - Q^{2}\big(G(\Xi-\Upsilon) - K(E\Xi + F\Upsilon + H\Psi')\big)\Big], +\end{aligned} +``` + +where ``E, F, G, H, K`` are the flux-surface-averaged equilibrium coefficients +of GWP2016 Eq. (A8) and ``Q`` is the dimensionless growth rate defined below. +These coefficients are collected in [`GGJParameters`](@ref InnerLayer.GGJParameters). + +### Dimensionless scales + +The layer is scaled by the local Lundquist number ``S = \tau_R/\tau_A`` (ratio +of resistive to Alfvén time). The displacement and growth-rate scales are + +```math +X_0 = S^{-1/3}, \qquad Q_0 = X_0/\tau_A \qquad \text{(GWP2016 Eqs. A14–A15)}, +``` + +so a physical growth rate ``\gamma`` maps to the scaled eigenvalue +``Q = \gamma/Q_0`` ([`inner_Q`](@ref InnerLayer.inner_Q)) that appears in the equations above, and +the scaled matching data are returned to physical units by the +``X_0^{-2\sqrt{-D_I}} = S^{\,2\sqrt{-D_I}/3}`` rescaling (together with the +``v_1^{\,2\sqrt{-D_I}}`` linear-scale factor; [`rescale_delta`](@ref InnerLayer.rescale_delta)). + +### Mercier index and matching exponents + +The premise of an inner-layer model is local Mercier stability. The Mercier +interchange index and its resistive counterpart are + +```math +D_I = E + F + H - \tfrac14, \qquad +D_R = E + F + H^2 = D_I + \big(H-\tfrac12\big)^2 \qquad \text{(GWP2016 A9–A10)}, +``` + +([`mercier_di`](@ref InnerLayer.mercier_di), [`mercier_dr`](@ref InnerLayer.mercier_dr)). For ``D_I < 0`` the large-``x`` +solutions are the two power laws ``x^{r_\pm}`` with the Frobenius exponents + +```math +r_\pm = \tfrac32 \pm \sqrt{-D_I}, \qquad p_1 \equiv \sqrt{-D_I} +\qquad \text{(GW2020 Eq. 49)}. +``` + +The matching datum ``\Delta`` is the amplitude of the large solution +``x^{r_+}`` when the small solution ``x^{r_-}`` is normalized to unity (i.e. the +ratio of large to small coefficient), in the physical (outer-matching) +normalization. Imposing the two parities at ``x=0`` (odd: +``\Psi'(0)=\Xi(0)=\Upsilon(0)=0``; even: ``\Psi(0)=\Xi'(0)=\Upsilon'(0)=0``) +gives the pair ``(\Delta_\mathrm{odd}, \Delta_\mathrm{even})`` of GWP2016 +Eqs. (34)–(35). + +## Numerical method + +!!! note "Benchmark surface used in the figures" + Every figure on this page is computed on the ``q = 4`` rational-surface + benchmark ([`q4_surface_benchmark`](@ref InnerLayer.q4_surface_benchmark)) — + a fixed set of GGJ layer coefficients (``S = \tau_R/\tau_A \approx 4.6\times10^{6}``, + ``D_I \approx -0.31``) representative of a physical ``q = 4`` surface. It is a + stand-alone inner-layer coefficient set, not extracted from a particular + equilibrium run such as the DIII-D-like example. The well-conditioned + real-``Q`` cross-check in the Validation section instead uses the + Glasser & Wang (2020) Eq. (55) surface. + +### Solver backends + +The `GGJ` model exposes three solvers through the `solver` type parameter of +[`GGJModel`](@ref InnerLayer.GGJModel); all consume the same large-``x`` asymptotic basis and return +``\Delta`` in the same convention: + +| backend | method | robust regime | +|:--------|:-------|:--------------| +| `:shooting` | backward stable shoot from ``X_\mathrm{max}\to 0`` | ``\lvert Q\rvert \ll 1`` | +| `:galerkin` | Hermite-cubic finite-element (real axis) | ``\lvert Q\rvert \lesssim 1`` | +| `:ray` (default) | rotated-contour spectral-element collocation | ``\lvert Q\rvert \sim 500`` on/near the imaginary axis | + +The difficulty is that ``\Delta(Q)`` has poles on the imaginary-``Q`` axis, and +both older backends lose accuracy off the real axis: `:shooting` through the +exponential dichotomy of the backward shoot, `:galerkin` through real-axis +oscillation and an on-axis pseudo-resonance. Measured against the `:ray` +reference along the imaginary axis, `:shooting` holds to ``|Q|\sim 1`` and +`:galerkin` to ``|Q|\sim 4`` before both lose all accuracy: + +![Relative error of the :shooting and :galerkin backends against the :ray reference along the imaginary-Q axis, on the q=4 benchmark surface. Each curve's crossing of the 1% line marks that method's practical reach.](figures/inner_layer/backend_regime_map.png) + +The `:ray` backend was written to reach the large-``|Q|`` imaginary-axis regime +(rotation and resistivity scans) where these fail. The remainder of this section +describes it. + +### Entire-solution formulation + +`:ray` works with the **plain** state ``v = (\Psi, \Xi, \Upsilon, \Psi', +\Xi', \Upsilon')`` and writes the layer equations as the first-order system + +```math +\frac{dv}{dx} = M(x)\,v . +``` + +The coefficient matrix (the `GGJ` internal `ode_matrix`) is **polynomial** in ``x``, so +``x = 0`` is an ordinary point — the ``x^{-2}/x^{-4}`` singularities of the +GW2020 Eq. (2) scaled form ``(x\Psi,\ \Psi'/x,\ \dots)`` are artifacts of that +scaling, not of the equations. Because the coefficients are entire, the system +continues analytically to complex ``x``, which is what makes the contour +rotation below legitimate. + +### The rotated ray + +The equations are continued onto the ray + +```math +x = e^{i\theta}\, s, \qquad s \in [0, S], \qquad \theta = \tfrac14\arg Q, +``` + +The angle ``\theta = \tfrac14\arg Q`` makes the parabolic-cylinder exponent of +the outer solutions exactly real and clears the pseudo-resonance at +``x^2 = -Q^2(G + K F)``, which on the imaginary-``Q`` axis is real and large and +therefore sits directly on the un-rotated (real-``x``) contour. Rotating the +contour lifts it off that point; ``\theta = 0`` recovers a real-axis solve. + +![The rotated integration ray in the complex layer-coordinate plane at Q = 500i. The real-axis contour runs through the pseudo-resonance x² = −Q²(G+KF); the ray at θ = arg(Q)/4 = 22.5° clears it.](figures/inner_layer/rotated_ray_contour.png) + +### Spectral-element collocation BVP + +On ``[0, s_m]`` the system is discretized by a **global Chebyshev +spectral-element collocation** boundary-value problem: right-biased (Radau-like) +collocation at the Chebyshev–Lobatto nodes of each cell, three parity conditions +at the ordinary-point origin ``s = 0``, and six matching conditions at the outer +edge, + +```math +v(S) - \Delta\, U_b - c_1 E_1 - c_2 E_2 = U_s , +``` + +with the matching datum ``\Delta`` and the two decaying-mode amplitudes +``c_1, c_2`` carried as **bordered unknowns**. Here ``U_s, U_b`` are the small +and large power-like solutions and ``E_{1,2}`` the forward-decaying exponential +pair. No quantity is ever propagated across the layer, so the exponential +dichotomy that limits the shooting backend never enters; the boundary condition +splits it exactly. + +The two parities differ only in their three ``s=0`` rows, i.e. by a rank-3 +update, so both are obtained from a **single sparse LU factorization** plus a +Woodbury correction (the `GGJ` internal `_solve_parities`) rather than two +factorizations. A residual-driven bisection refinement adds cells until the +collocation residual meets tolerance, with a roundoff-plateau guard. + +### Far-field boundary and the inward march + +The large-``x`` boundary data use the **same** `inps` Wasow asymptotic basis as +the other backends (GW2020 Eqs. 3–52), evaluated at complex ``x`` by +`RayAsymptotics.jl` and applied at the series radius ``S`` where the series is +trusted (residual below tolerance; [`pick_smax`](@ref InnerLayer.pick_smax)). For large ``|Q|`` the +trusted radius ``S`` can be far outside the collocation domain ``s_m``, so the +power-pair data are transported inward from ``S`` to ``s_m`` by an **L-stable +2-stage Radau IIA march** in the quotient modulo the decaying exponential pair — +the subspace in which ``\Delta`` is defined (the `GGJ` internal +`march_boundary`). An L-stable implicit method is essential: the decaying pair +grows under backward integration, so any explicit marcher is stability-limited +to ``O(\rho S^2)`` steps, while the Radau march *damps* the unresolvable +backward-growing directions instead of amplifying them. + +The damped-zone march runs in `Complex{Double64}` extended precision. At large +``S`` the near-parallel power-pair geometry amplifies the structured backward +error of the ill-conditioned implicit solves into ``\Delta``-mixing of order +``10^{-4}`` at ``|Q| = 500`` in `Float64`; extended precision removes this +floor, while the well-conditioned resolved band stays in `Float64`. + +The result is a seamless numeric↔asymptotic solution: the collocation solution +on ``[0, s_m]`` and the analytic ``u_\mathrm{small} + \Delta\,u_\mathrm{big}`` +representation for ``s \ge S`` share the same power-law tail — the overlap the +outer-region matching relies on. + +![Inner-layer fields Ψ, Ξ, Υ on the rotated ray for the q=4 surface at Q = 2i. The collocation solution (solid) joins the asymptotic representation (dashed) seamlessly at the match point S = s_m.](figures/inner_layer/solution_profiles.png) + +## Validation and benchmarks + +**Cross-check against the Fortran `rmatch` solver.** At the Glasser & Wang +(2020) Eq. (55) operating point ``Q = 0.1234`` (real, well-conditioned) the +Julia GGJ solvers and the Fortran `rmatch deltac` solver agree to ``\sim +10^{-8}``: + +| quantity | value (`:galerkin` and `:ray`) | +|:---------|:-------------------------------| +| ``\Delta_\mathrm{odd}`` | ``3.698368\times 10^{4}`` | +| ``\Delta_\mathrm{even}`` | ``14.759721`` | + +**Physical benchmark on the imaginary axis.** On the ``q=4`` rational-surface +benchmark ([`q4_surface_benchmark`](@ref InnerLayer.q4_surface_benchmark), ``S \approx 4.58\times10^6``, +``D_I \approx -0.312``) the `:ray` backend is pinned at ``Q = 500i``, a regime +entirely beyond `:galerkin`: + +| quantity | value at ``Q = 500i`` | +|:---------|:----------------------| +| ``\Delta_\mathrm{odd}`` | ``2.47206 + 13.35412\,i`` | +| ``\Delta_\mathrm{even}`` | ``0.137497 + 0.742755\,i`` | + +**Convergence and contour invariance.** Because ``\Delta`` is an analytic +invariant of the contour angle, re-solving with each numerical knob perturbed on +an independent axis ([`delta_convergence`](@ref InnerLayer.delta_convergence)) gives an honest error bar: at +``Q = 500i`` the worst-case spread across all knobs is ``\sim 5\times10^{-6}`` +for ``\Delta_\mathrm{odd}`` and ``\sim 6\times10^{-7}`` for +``\Delta_\mathrm{even}``. + +![Relative change of Δ at Q = 500i under independent perturbations of each numerical knob (contour angle, spectral order, series order/radius, refinement depth, march tolerance, handoff radius, purification depth). The worst-case spread is the reported error bar.](figures/inner_layer/convergence_Sinvariance.png) + +## Choosing a backend + +`:ray` is the **default** — `GGJModel()` constructs `GGJModel{:ray}()`. It is +the correct choice for ``|Q| \gtrsim 1`` and for any ``Q`` near the imaginary +axis. `:galerkin` remains available and may be faster for very small real +``|Q|``, but note the backends take different numerical-knob keywords: pass +`GGJModel(solver=:galerkin)` explicitly when supplying the Galerkin `nx`/`xfac` +knobs, since those are silently ignored by `:ray`. + +```julia +using GeneralizedPerturbedEquilibrium.InnerLayer + +p = q4_surface_benchmark() # GGJParameters for the q=4 benchmark surface +γ = 500im * GGJ.q0(p) # physical rate placing Q on the imaginary axis at 500i + +Δ = solve_inner(GGJModel(), p, γ) # (Δ_odd, Δ_even) with the default :ray backend + +# Full result (raw Δ, contour, mesh, nodal solution, residuals) via solve_ray: +res = solve_ray(p, GGJ.inner_Q(p, γ)) +res.Δ, res.resid, res.bc_cond +``` + +## API Reference + +### InnerLayer ```@autodocs Modules = [GeneralizedPerturbedEquilibrium.InnerLayer] ``` -## GGJ +### GGJ ```@autodocs Modules = [GeneralizedPerturbedEquilibrium.InnerLayer.GGJ] diff --git a/regression-harness/cases/ggj_ray_q500i.toml b/regression-harness/cases/ggj_ray_q500i.toml new file mode 100644 index 00000000..967fe379 --- /dev/null +++ b/regression-harness/cases/ggj_ray_q500i.toml @@ -0,0 +1,53 @@ +# Regression case: GGJ inner-layer rotated-ray collocation backend at Q = 500i. +# A "computed" case (kind = "computed", no example_dir) that calls solve_inner with the +# :ray backend on the physical q=4 benchmark surface at Q = 500i — a regime beyond the +# :galerkin backend — and tracks the Δ_odd / Δ_even matching coefficients. Guards the +# rotated-ray solver (and its extended-precision damped-zone march) against numerical +# drift across changes. Each [quantities.*] block names an HDF5 path in the computed +# output, how to extract it, and the noise floor for diffs. +[case] +name = "ggj_ray_q500i" +description = "GGJ inner-layer :ray backend, q=4 surface, Q = 500i (beyond :galerkin)" +kind = "computed" + +# Δ_odd / Δ_even from solve_inner(GGJModel(:ray), q4_surface_benchmark(), 500im·Q₀). +# Complex-valued; both real and imaginary parts are tracked separately. +[quantities.delta_odd_real] +h5path = "ggj/delta_odd_real" +type = "real_scalar" +extract = "value" +label = "Re(Δ_odd)" +noise_threshold = 1e-8 +order = 10 + +[quantities.delta_odd_imag] +h5path = "ggj/delta_odd_imag" +type = "real_scalar" +extract = "value" +label = "Im(Δ_odd)" +noise_threshold = 1e-8 +order = 11 + +[quantities.delta_even_real] +h5path = "ggj/delta_even_real" +type = "real_scalar" +extract = "value" +label = "Re(Δ_even)" +noise_threshold = 1e-8 +order = 12 + +[quantities.delta_even_imag] +h5path = "ggj/delta_even_imag" +type = "real_scalar" +extract = "value" +label = "Im(Δ_even)" +noise_threshold = 1e-8 +order = 13 + +[quantities.runtime] +h5path = "" +type = "runtime" +extract = "value" +label = "Runtime (s)" +noise_threshold = 0.0 +order = 90 diff --git a/regression-harness/src/runner.jl b/regression-harness/src/runner.jl index 8705cb4d..84e80838 100644 --- a/regression-harness/src/runner.jl +++ b/regression-harness/src/runner.jl @@ -79,6 +79,31 @@ open(ARGS[2], "w") do f end """ +# GGJ rotated-ray backend at Q = 500i on the q=4 benchmark surface — a regime beyond the +# :galerkin backend. Builds the physical rate γ = 500i·Q₀ so inner_Q lands exactly on the +# imaginary axis at 500i, then writes the parity matching data. Runtime to ARGS[2] as usual. +const COMPUTED_GGJ_RAY_SCRIPT_TEMPLATE = """ +using Pkg +%INSTANTIATE% +using GeneralizedPerturbedEquilibrium +using GeneralizedPerturbedEquilibrium.InnerLayer +using HDF5 +p = q4_surface_benchmark() +γ = 500.0im * InnerLayer.GGJ.q0(p) +t_start = time() +Δ = solve_inner(GGJModel(solver=:ray), p, γ) +elapsed = time() - t_start +h5open(ARGS[1], "w") do fid + fid["ggj/delta_odd_real"] = real(Δ[1]) + fid["ggj/delta_odd_imag"] = imag(Δ[1]) + fid["ggj/delta_even_real"] = real(Δ[2]) + fid["ggj/delta_even_imag"] = imag(Δ[2]) +end +open(ARGS[2], "w") do f + println(f, elapsed) +end +""" + # Self-contained separatrix-finder regression (PR #296). Loads a fixed-boundary EFIT whose # computational box hugs the LCFS (eps=0.05 TokaMaker aspect-scan g-file): outside the prescribed # LCFS the coil-vacuum flux turns back above the boundary value before the grid edge, so the old @@ -139,6 +164,8 @@ Pick the subprocess script template for a `kind="computed"` case. function _computed_script_template(case_spec::CaseSpec) if case_spec.name == "ggj_reference" return COMPUTED_GGJ_SCRIPT_TEMPLATE + elseif case_spec.name == "ggj_ray_q500i" + return COMPUTED_GGJ_RAY_SCRIPT_TEMPLATE elseif case_spec.name == "efit_fixedbdy_separatrix" return COMPUTED_SEPARATRIX_SCRIPT_TEMPLATE end diff --git a/src/InnerLayer/GGJ/GGJParameters.jl b/src/InnerLayer/GGJ/GGJParameters.jl index 732ab781..8ab243e8 100644 --- a/src/InnerLayer/GGJ/GGJParameters.jl +++ b/src/InnerLayer/GGJ/GGJParameters.jl @@ -112,8 +112,10 @@ inner_Q(p::GGJParameters, γ::Number) = ComplexF64(γ) / q0(p) rescale_delta(Δ, p::GGJParameters) -> SVector{2,ComplexF64} Map the scaled inner-layer matching data back to physical Δ at the rational -surface by the `X₀^(2√(−D_I))` rescaling implied by the power-like matching -(GWP2016 Sec. IV; the inner solution ~ X^{r±} converts to physical x = X₀ X). +surface by the `X₀^(−2√(−D_I)) = S^(2√(−D_I)/3)` rescaling, together with the +`v₁^(2√(−D_I))` linear-scale factor, implied by the power-like matching +(GWP2016 Sec. IV; the inner solution ~ X^{r±} converts to physical x = X₀ X, so +the large/small amplitude ratio Δ scales by X₀^{−(r₊−r₋)} = X₀^(−2√(−D_I))). Operates element-wise on a 2-vector of `(Δ_odd, Δ_even)`. """ function rescale_delta(Δ::AbstractVector, p::GGJParameters) diff --git a/test/runtests_innerlayer.jl b/test/runtests_innerlayer.jl index 188beddc..f5e3f7cc 100644 --- a/test/runtests_innerlayer.jl +++ b/test/runtests_innerlayer.jl @@ -85,3 +85,59 @@ end @test abs(r2.Δ[1] - Δ[1]) / abs(Δ[1]) < 1e-3 end end + +@testset "InnerLayer GGJ :ray internal machinery" begin + p = IL.q4_surface_benchmark() + + @testset "cheblobatto nodes and differentiation matrix" begin + t, D = GGJ.cheblobatto(8) + @test length(t) == 9 + @test issorted(t) # ascending, per the reflected convention + @test t[1] ≈ -1 && t[end] ≈ 1 + @test D * ones(9) ≈ zeros(9) atol = 1e-10 # d/dt of a constant is zero + @test D * t ≈ ones(9) atol = 1e-10 # d/dt of the linear function is one + end + + @testset "ode_matrix: ordinary point and type-generic build" begin + Q = 5.0im + # x = 0 is an ordinary point: the coefficient matrix is finite there. + M0 = GGJ.ode_matrix(p, Q, 0.0) + @test all(isfinite, M0) + @test M0[1, 4] == 1 && M0[2, 5] == 1 && M0[3, 6] == 1 # v' = (Ψ',Ξ',Υ') block + # The extended-precision build agrees with the Float64 build. + Md = GGJ.ode_matrix(Complex{GGJ.Double64}, p, Q, 0.3) + @test ComplexF64.(Md) ≈ GGJ.ode_matrix(p, Q, 0.3) rtol = 1e-12 + end + + @testset "parity_rows match the deltac boundary convention" begin + @test GGJ.parity_rows(1) == [4, 2, 3] # odd: Ψ'(0)=Ξ(0)=Υ(0)=0 + @test GGJ.parity_rows(2) == [1, 5, 6] # even: Ψ(0)=Ξ'(0)=Υ'(0)=0 + end + + @testset "decaying_pair is an orthonormal 6×2 frame" begin + Q = 5.0im + θ = angle(Q) / 4 + E = GGJ.decaying_pair(p, Q, θ, 60.0) + @test size(E) == (6, 2) + @test all(isfinite, E) + @test E' * E ≈ I(2) atol = 1e-10 # columns orthonormal + end + + @testset "profile diagnostics reconstruct finite fields" begin + res = IL.solve_ray(p, 5.0im) + prof = IL.solution_profile(res; npc=6) + @test size(prof.Ψ, 2) == 2 && length(prof.s) == size(prof.Ψ, 1) + @test all(isfinite, prof.Ψ) && all(isfinite, prof.Ξ) && all(isfinite, prof.Υ) + # Analytic tail evaluates on the trusted series radius. + asy = IL.asymptotic_profile(p, res, [res.S, 2 * res.S]) + @test all(isfinite, asy.Ψ) + end + + @testset "delta_convergence: small spread, consistent with solve_inner" begin + Q = 5.0im + conv = IL.delta_convergence(p, Q; verbose=false) + Δ = IL.solve_inner(IL.GGJModel(; solver=:ray), p, Q * GGJ.q0(p)) + @test conv.Δ ≈ Δ rtol = 1e-6 # baseline == the plain solve + @test maximum(conv.spread) < 1e-4 # honest error bar is small here + end +end From 3c8aeb7f3191dc5e88f3af151e4f7eabb6ed292a Mon Sep 17 00:00:00 2001 From: Via Weber Date: Thu, 9 Jul 2026 12:05:08 -0400 Subject: [PATCH 04/38] FORCEFREESTATES - Minor - first iterations for looping BC in Riccati --- .../TkMkr_D3Dlike_Hmode.geqdsk | 1 + .../gpec.toml | 103 ++++++++++++++++++ src/ForceFreeStates/ForceFreeStatesStructs.jl | 6 + src/ForceFreeStates/Riccati.jl | 45 +++++++- src/GeneralizedPerturbedEquilibrium.jl | 5 + 5 files changed, 155 insertions(+), 5 deletions(-) create mode 120000 examples/DIIID-like_gal_resistive_pe_example/TkMkr_D3Dlike_Hmode.geqdsk create mode 100644 examples/DIIID-like_gal_resistive_pe_example/gpec.toml diff --git a/examples/DIIID-like_gal_resistive_pe_example/TkMkr_D3Dlike_Hmode.geqdsk b/examples/DIIID-like_gal_resistive_pe_example/TkMkr_D3Dlike_Hmode.geqdsk new file mode 120000 index 00000000..4200141b --- /dev/null +++ b/examples/DIIID-like_gal_resistive_pe_example/TkMkr_D3Dlike_Hmode.geqdsk @@ -0,0 +1 @@ +../DIIID-like_ideal_example/TkMkr_D3Dlike_Hmode.geqdsk \ No newline at end of file diff --git a/examples/DIIID-like_gal_resistive_pe_example/gpec.toml b/examples/DIIID-like_gal_resistive_pe_example/gpec.toml new file mode 100644 index 00000000..1fa209f0 --- /dev/null +++ b/examples/DIIID-like_gal_resistive_pe_example/gpec.toml @@ -0,0 +1,103 @@ +[Equilibrium] +eq_filename = "TkMkr_D3Dlike_Hmode.geqdsk" # Path to equilibrium file +eq_type = "efit" # Type of the input 2D equilibrium file +jac_type = "hamada" # Coordinate system (hamada, pest, boozer, equal_arc) +power_bp = 0 # Poloidal field power exponent for Jacobian +power_b = 0 # Toroidal field power exponent for Jacobian +power_r = 0 # Major radius power exponent for Jacobian +grid_type = "ldp" # Radial grid packing type (ldp = linear-derivative packing toward rationals) +psilow = 1e-4 # Lower limit of normalized flux coordinate +psihigh = 0.993 # Upper limit of normalized flux coordinate (0.993 stays clear of the separatrix; truncating at ≳0.998 is numerically unreasonable here) +mpsi = 128 # Number of radial grid intervals (0 = two-pass auto grid from psi_accuracy) +psi_accuracy = 0.001 # Target relative accuracy of splined profile derivatives for the auto grid +mtheta = 256 # Number of poloidal grid points +newq0 = 0 # Override for on-axis safety factor (0 = use input value) +etol = 1e-10 # Error tolerance for equilibrium solver +force_termination = false # Terminate after equilibrium setup (skip stability calculations) + +[Wall] +shape = "nowall" # Wall shape (nowall, conformal, elliptical, dee, mod_dee, filepath) +a = 0.2415 # Distance from plasma (conformal) or shape parameter +aw = 0.05 # Half-thickness parameter for Dee-shaped walls +bw = 1.5 # Elongation parameter for wall shapes +cw = 0 # Offset of wall center from major radius +dw = 0.5 # Triangularity parameter for wall shapes +tw = 0.05 # Sharpness of wall corners (try 0.05 as initial value) +equal_arc_wall = true # Equal arc length distribution of nodes on wall + +[ForceFreeStates] +local_stability_flag = true # Perform local stability analysis (Mercier and ballooning) across the ψ profile +mat_flag = true # Construct coefficient matrices for diagnostic purposes (required by gal_flag) +ode_flag = true # Integrate ODE's for determining stability of internal long-wavelength mode (must be true for GPEC) +vac_flag = true # Compute plasma, vacuum, and total energies for free-boundary modes + +psiedge = 0.99 # Edge dW scan band: dW(ψ) computed for ψ ∈ [psiedge, psilim], integration truncated at peak +qlow = 1.02 # Integration initiated at q determined by min(q0, qlow)... +qhigh = 1e3 # Integration terminated at q limit determined by min(qa, qhigh)... +sing_start = 0 # Start integration at the sing_start'th rational from the axis (psilow) + +nn_low = 1 # Smallest toroidal mode number to include +nn_high = 1 # Largest toroidal mode number to include +delta_mlow = 8 # Expands lower bound of Fourier harmonics +delta_mhigh = 4 # Expands upper bound of Fourier harmonics +mthvac = 512 # Number of points used in splines over poloidal angle at plasma-vacuum interface. + +kinetic_source = "fixed" # Kinetic matrix source ("fixed" test matrices, or "calculated" kinetic response) +kinetic_factor = 0.0 # Scaling of kinetic matrices (0 = ideal path; >0 enables kinetic mode) +eulerlagrange_tolerance = 1e-10# Relative tolerance for ODE integration of Euler-Lagrange equations +save_interval = 3 # Save every Nth ODE step (1=all, 10=every 10th). Always saves near rational surfaces. +singfac_min = 1e-4 # Fractional distance from rational q at which ideal jump enforced +ucrit = 1e4 # Maximum fraction of solutions allowed before re-normalized + +# Δ' BVP + parallel integration (see ForceFreeStatesControl docstring for details) +use_parallel = true # Run parallel FM-propagator BVP path (unlocks singular/delta_prime_matrix) +parallel_threads = 2 # BVP thread cap (1 = serial/bit-deterministic; 2 ≈ +20% speedup; ≥3 saturates) +populate_dense_xi = false # Dense axis-basis ξ for the FFS HDF5 output. Not needed here: no [PerturbedEquilibrium] section, and the gal-matched path builds its own dense ξ. Set true only for a shooting-fed PE run. +set_psilim_via_dmlim = false # Keep psilim at psihigh (do not truncate at last_rational_q + dmlim) +dmlim = 0.2 # Truncate integration at (last_rational_q + dmlim) / n (only used when set_psilim_via_dmlim = true) + +# Outer-region singular Galerkin Δ′ solver with rpec coil columns. +# This case exercises the gal solve AND the rpec coil/edge path (delta_coil) for regression tracking. +gal_flag = true # Enable the outer-region singular Galerkin Δ′ solve (requires mat_flag = true) +gal_solver = "LU" # Banded solver: "LU" (zgbtrf/zgbtrs) or "cholesky" (zpbtrf/zpbtrs). rpec requires "LU". +gal_nx = 256 # Hermite-cubic elements per interval between singular surfaces +gal_nq = 6 # Gauss-Lobatto quadrature order per element +gal_pfac = 0.03 # Grid packing ratio toward singular surfaces (smaller = tighter packing) +gal_dx0 = 5e-4 # Resonant-element integration truncation distance from the rational (× 1/|n q'|) +gal_dx1 = 1e-3 # Resonant-element size (× 1/|n q'|) +gal_dx2 = 1e-3 # Extension-element size (× 1/|n q'|) +gal_cutoff = 10 # Number of elements carrying the large (driving) solution as a source term +gal_tol = 1e-10 # Resonant-cell adaptive-quadrature (QuadGK) tolerance +gal_gnstep = 5000 # Maximum resonant-integration steps +gal_dx1dx2_flag = true # Enable the special dx1/dx2 element sizing for resonant/extension cells +gal_sing_order = 6 # Base power-series order for the Galerkin singular asymptotics +gal_sing_order_ceiling = true # Auto-raise the series order by ceil(2·Re(α)) on high-Mercier-index surfaces +gal_rpec_flag = true # Append mpert coil-response columns (unit edge sources) to the solve → delta_coil block + +# DRIVEN (RPEC) outer↔inner matching. Per-surface resistive inputs (core→edge) for a DIII-D-like case: +# η=8e-8, ρ=3.3e-7 kg/m³ (ni≈1e20 m^-3), rotation=1 Hz, Γ=5/3. 4 surfaces. +gal_match_flag = true # Solve the coil-driven matched ξ(ψ) from the gal Δ′ + the inner-layer Δ(Q). Requires gal_rpec_flag = true. +gal_ideal_flag = false # true → build the IDEAL solution (skip the inner layer, bare coil columns). false → the resistive matching below. +gal_eta = [8e-8, 8e-8, 8e-8, 8e-8] # Per-surface resistivity η (core→edge) +gal_rho = [3.3e-7, 3.3e-7, 3.3e-7, 3.3e-7] # Per-surface mass density ρ [kg/m³] (core→edge) +gal_rotation = [1.0, 1.0, 1.0, 1.0] # Per-surface rotation frequency f [Hz] (core→edge); forced eigenvalue γ_s = 2πi·n·f +gal_gamma = 1.6666666666666667 # Ratio of specific heats Γ for the resistive-layer coefficients (5/3) + +[ForcingTerms] +forcing_data_format = "coil" # Format: "ascii", "hdf5", or "coil" (Biot-Savart from 3D wires) +machine = "d3d" # Geometry prefix; resolves to bundled coil_geometries/d3d_*.dat +mtheta_coil = 480 # Poloidal grid for boundary B·n̂ evaluation +nzeta_coil = 32 # Toroidal grid (0 = auto = 32·n) + +[[ForcingTerms.coil_set]] +name = "c" # DIII-D C-coil (6 coils); resolves to coil_geometries/d3d_c.dat +currents = [1000.0, 500.0, -500.0, -1000.0, -500.0, 500.0] # n=1 cosine phasing, 1 kA peak [A] + +[PerturbedEquilibrium] +fixed_boundary = false # Use fixed boundary conditions +output_eigenmodes = true # Output eigenmode fields as b-fields +compute_response = true # Compute plasma response to forcing +compute_singular_coupling = true # Compute singular layer coupling metrics +verbose = true # Enable verbose logging +write_outputs_to_HDF5 = true # Write perturbed equilibrium outputs to HDF5 +reg_spot = 0.05 # Regularization width for singular surfaces (0 = disabled) diff --git a/src/ForceFreeStates/ForceFreeStatesStructs.jl b/src/ForceFreeStates/ForceFreeStatesStructs.jl index 852d8893..df4b4374 100644 --- a/src/ForceFreeStates/ForceFreeStatesStructs.jl +++ b/src/ForceFreeStates/ForceFreeStatesStructs.jl @@ -195,6 +195,12 @@ A mutable struct holding internal state variables for stability calculations. raw 2msing×2msing BVP solution to produce the PEST3-compatible tearing parameter. """ delta_prime_matrix::Matrix{ComplexF64} = Matrix{ComplexF64}(undef, 0, 0) + """ + Edge coil-response matrix of shape (2msing × numpert_total). Column k is the resonant + small-solution response at each surface side to driving edge poloidal mode k, built by + looping the Eq. (37) edge boundary condition through the Riccati BVP (`loop_edge_boundary_conditions`). + """ + delta_coil_matrix::Matrix{ComplexF64} = Matrix{ComplexF64}(undef, 0, 0) end """ diff --git a/src/ForceFreeStates/Riccati.jl b/src/ForceFreeStates/Riccati.jl index d36df687..80664851 100644 --- a/src/ForceFreeStates/Riccati.jl +++ b/src/ForceFreeStates/Riccati.jl @@ -334,6 +334,12 @@ function compute_delta_prime_matrix!( @info "Δ' BVP: nMat=$nMat, rank(M)=$(rank(M)), cond(M)=$(@sprintf("%.2e", cond(M)))" end + # rpec coil-response loop needs the vacuum edge (col_edge coupled in junc_rows[1:N]) and the + # S-axis row layout that `loop_edge_boundary_conditions` assumes for its edge-BC rows. + if use_S_axis && wv !== nothing + intr.delta_coil_matrix = loop_edge_boundary_conditions(M, col_edge, msing, N, ipert_all) + end + intr.delta_prime_matrix = _solve_bvp_and_combine_pest3( M, msing, N, nMat, use_S_axis, ipert_all, col_edge, ctrl, debug) end @@ -633,7 +639,7 @@ end # setting one to 1 assigns that big-solution coefficients. Solve once per boundary condition # and collect the small-solution coefficients into the raw D' matrix. function loop_boundary_conditions(M::Matrix{ComplexF64}, msing::Int, N::Int, - ipert_all::Vector{Int}) + ipert_all::Vector{Int}) nMat = size(M, 1) s2 = 2 * msing M_lu = lu(M) @@ -643,17 +649,46 @@ function loop_boundary_conditions(M::Matrix{ComplexF64}, msing::Int, N::Int, for jsing in 1:msing, side in 1:2 dRow = 2jsing - (2 - side) fill!(b, 0) - b[nMat - s2 + dRow] = 1 + b[nMat-s2+dRow] = 1 x = M_lu \ b for ksing in 1:msing ipert_k = ipert_all[ksing] - dp_raw[dRow, 2ksing-1] = x[col_left(ksing, N)[ipert_k + N]] - dp_raw[dRow, 2ksing] = x[col_right(ksing, N)[ipert_k + N]] + dp_raw[dRow, 2ksing-1] = x[_col_left(ksing, N)[ipert_k+N]] + dp_raw[dRow, 2ksing] = x[_col_right(ksing, N)[ipert_k+N]] end - return dp_raw end + return dp_raw +end +# Coil-response loop (galerkin gal_rpec convention) for the edge block of Eq. (37) [Glasser-Kolemen +# 2018 PoP 25 082502]. For each edge poloidal mode k, pin the edge to a Dirichlet identity and drive +# α_edge = e_k as a unit source, solve the square system (block-6 driving rows held at 0), and read the +# resonant small-solution coefficient at each surface → delta_coil (2·msing × N). Pass the vacuum M. +function loop_edge_boundary_conditions(M::Matrix{ComplexF64}, col_edge, msing::Int, N::Int, + ipert_all::Vector{Int}) + nMat = size(M, 1) + edge_rows = (nMat-2msing-N+1):(nMat-2msing) # wall edge-BC rows of the last surface + Mc = copy(M) + Mc[edge_rows, :] .= 0 + Mc[edge_rows, col_edge] .= Matrix{ComplexF64}(I, N, N) # α_edge = RHS + Mc_lu = lu(Mc) + + delta_coil = zeros(ComplexF64, 2msing, N) + rhs = zeros(ComplexF64, nMat) + for k in 1:N + fill!(rhs, 0) + rhs[edge_rows[k]] = 1 # unit edge source: drive α_edge = e_k (galerkin rpec convention) + x = Mc_lu \ rhs + + for j in 1:msing # read resonant small solution at each surface + ipert_j = ipert_all[j] + delta_coil[2j-1, k] = x[_col_left(j, N)[ipert_j+N]] + delta_coil[2j, k] = x[_col_right(j, N)[ipert_j+N]] + end + end + return delta_coil +end # Fallback BVP assembly with FM-based axis BC (used when no Riccati S matrices are available). # Uses the conditioned axis propagator Phi_R[1][:,N+1:2N] in place of S-axis matching. diff --git a/src/GeneralizedPerturbedEquilibrium.jl b/src/GeneralizedPerturbedEquilibrium.jl index b6988f0c..781760a9 100755 --- a/src/GeneralizedPerturbedEquilibrium.jl +++ b/src/GeneralizedPerturbedEquilibrium.jl @@ -615,6 +615,11 @@ function write_outputs_to_HDF5( out_h5["singular/delta_prime_matrix"] = intr.delta_prime_matrix end + # Edge coil-response matrix (2msing × numpert_total) from the Eq. (37) edge-BC loop. + if intr.msing > 0 && !isempty(intr.delta_coil_matrix) + out_h5["singular/delta_coil_matrix"] = intr.delta_coil_matrix + end + # Write kinetic singular surface data (det(F̄) near-zeros) and the cond(F̄) scan # used to find them. Populated only when kinetic crossings were searched for. out_h5["singular/kinetic/kmsing"] = intr.kmsing From e5ffbd681babae5cbb641471c18974f6f1bda1af Mon Sep 17 00:00:00 2001 From: Matthew Pharr Date: Fri, 10 Jul 2026 02:07:16 -0400 Subject: [PATCH 05/38] DOCS - UPDATE - Fix some inaccuracies in docs additions --- docs/src/inner_layer.md | 58 +++++++++++++-------- regression-harness/cases/ggj_ray_q500i.toml | 11 ++-- 2 files changed, 43 insertions(+), 26 deletions(-) diff --git a/docs/src/inner_layer.md b/docs/src/inner_layer.md index 2d9a2246..25c4416d 100644 --- a/docs/src/inner_layer.md +++ b/docs/src/inner_layer.md @@ -89,9 +89,9 @@ Eqs. (34)–(35). Every figure on this page is computed on the ``q = 4`` rational-surface benchmark ([`q4_surface_benchmark`](@ref InnerLayer.q4_surface_benchmark)) — a fixed set of GGJ layer coefficients (``S = \tau_R/\tau_A \approx 4.6\times10^{6}``, - ``D_I \approx -0.31``) representative of a physical ``q = 4`` surface. It is a - stand-alone inner-layer coefficient set, not extracted from a particular - equilibrium run such as the DIII-D-like example. The well-conditioned + ``D_I \approx -0.31``) frozen into code from the ``q = 4`` rational surface of + the DIII-D-like (TkMkr H-mode) equilibrium of the `resistive_resmets` + benchmark, at that case's per-surface ``\eta`` and ``\rho``. The well-conditioned real-``Q`` cross-check in the Validation section instead uses the Glasser & Wang (2020) Eq. (55) surface. @@ -104,15 +104,20 @@ The `GGJ` model exposes three solvers through the `solver` type parameter of | backend | method | robust regime | |:--------|:-------|:--------------| | `:shooting` | backward stable shoot from ``X_\mathrm{max}\to 0`` | ``\lvert Q\rvert \ll 1`` | -| `:galerkin` | Hermite-cubic finite-element (real axis) | ``\lvert Q\rvert \lesssim 1`` | +| `:galerkin` | Hermite-cubic finite-element (real axis) | drift onset ``\lvert Q\rvert \sim 1``; 1% error by ``\lvert Q\rvert \sim 4`` | | `:ray` (default) | rotated-contour spectral-element collocation | ``\lvert Q\rvert \sim 500`` on/near the imaginary axis | -The difficulty is that ``\Delta(Q)`` has poles on the imaginary-``Q`` axis, and -both older backends lose accuracy off the real axis: `:shooting` through the -exponential dichotomy of the backward shoot, `:galerkin` through real-axis -oscillation and an on-axis pseudo-resonance. Measured against the `:ray` -reference along the imaginary axis, `:shooting` holds to ``|Q|\sim 1`` and -`:galerkin` to ``|Q|\sim 4`` before both lose all accuracy: +The difficulty is the large-``|Q|`` imaginary-``Q`` axis itself, where rotation +and resistivity scans live: there the layer's pseudo-resonance at +``x^2 = -Q^2(G+KF)`` sits directly on the real-``x`` integration path and the +exponential dichotomy weakens, so both older backends degrade — `:shooting` +through the dichotomy of the backward shoot, `:galerkin` through real-axis +oscillation and the on-axis pseudo-resonance. (The poles of ``\Delta(Q)`` +itself lie on and near the **real**-``Q`` axis — they are the layer +eigenvalues; ``\Delta`` is smooth along the imaginary axis.) Measured against +the `:ray` reference along the imaginary axis, `:shooting` holds to +``|Q|\sim 1`` and `:galerkin` to ``|Q|\sim 4`` (the 1% error crossings) +before both lose all accuracy: ![Relative error of the :shooting and :galerkin backends against the :ray reference along the imaginary-Q axis, on the q=4 benchmark surface. Each curve's crossing of the 1% line marks that method's practical reach.](figures/inner_layer/backend_regime_map.png) @@ -208,14 +213,20 @@ outer-region matching relies on. ## Validation and benchmarks **Cross-check against the Fortran `rmatch` solver.** At the Glasser & Wang -(2020) Eq. (55) operating point ``Q = 0.1234`` (real, well-conditioned) the -Julia GGJ solvers and the Fortran `rmatch deltac` solver agree to ``\sim -10^{-8}``: +(2020) Eq. (55) operating point ``Q = 0.1234`` (real) the `:galerkin` backend +reproduces the Fortran `rmatch deltac` solver to ``\sim 10^{-8}``: -| quantity | value (`:galerkin` and `:ray`) | -|:---------|:-------------------------------| -| ``\Delta_\mathrm{odd}`` | ``3.698368\times 10^{4}`` | -| ``\Delta_\mathrm{even}`` | ``14.759721`` | +| quantity | `:galerkin` (= Fortran to ``10^{-8}``) | `:ray` | +|:---------|:---------------------------------------|:-------| +| ``\Delta_\mathrm{odd}`` | ``3.698368\times 10^{4}`` | ``3.69789\times 10^{4}`` | +| ``\Delta_\mathrm{even}`` | ``14.759721`` | ``14.759715`` | + +The large ``\lvert\Delta_\mathrm{odd}\rvert \sim 4\times10^{4}`` means this +operating point sits near a pole of ``\Delta(Q)``, where every solver's error +is amplified by the pole geometry; the ``1.3\times10^{-4}`` +`:galerkin`↔`:ray` difference in ``\Delta_\mathrm{odd}`` (versus +``4\times10^{-7}`` in ``\Delta_\mathrm{even}``) is consistent with that +amplification, not a defect of either backend. **Physical benchmark on the imaginary axis.** On the ``q=4`` rational-surface benchmark ([`q4_surface_benchmark`](@ref InnerLayer.q4_surface_benchmark), ``S \approx 4.58\times10^6``, @@ -224,15 +235,18 @@ entirely beyond `:galerkin`: | quantity | value at ``Q = 500i`` | |:---------|:----------------------| -| ``\Delta_\mathrm{odd}`` | ``2.47206 + 13.35412\,i`` | -| ``\Delta_\mathrm{even}`` | ``0.137497 + 0.742755\,i`` | +| ``\Delta_\mathrm{odd}`` | ``2.4720 + 13.3540\,i`` | +| ``\Delta_\mathrm{even}`` | ``0.13750 + 0.74275\,i`` | **Convergence and contour invariance.** Because ``\Delta`` is an analytic invariant of the contour angle, re-solving with each numerical knob perturbed on an independent axis ([`delta_convergence`](@ref InnerLayer.delta_convergence)) gives an honest error bar: at ``Q = 500i`` the worst-case spread across all knobs is ``\sim 5\times10^{-6}`` for ``\Delta_\mathrm{odd}`` and ``\sim 6\times10^{-7}`` for -``\Delta_\mathrm{even}``. +``\Delta_\mathrm{even}``. That is a single-machine error bar: across +machines/BLAS builds the absolute values reproduce to ``\sim 10^{-5}`` +relative (the damped-zone implicit solves carry platform-dependent structured +roundoff), which is why the table above quotes five significant figures. ![Relative change of Δ at Q = 500i under independent perturbations of each numerical knob (contour angle, spectral order, series order/radius, refinement depth, march tolerance, handoff radius, purification depth). The worst-case spread is the reported error bar.](figures/inner_layer/convergence_Sinvariance.png) @@ -241,9 +255,9 @@ for ``\Delta_\mathrm{odd}`` and ``\sim 6\times10^{-7}`` for `:ray` is the **default** — `GGJModel()` constructs `GGJModel{:ray}()`. It is the correct choice for ``|Q| \gtrsim 1`` and for any ``Q`` near the imaginary axis. `:galerkin` remains available and may be faster for very small real -``|Q|``, but note the backends take different numerical-knob keywords: pass +``|Q|``, but note the backends take disjoint numerical-knob keywords: pass `GGJModel(solver=:galerkin)` explicitly when supplying the Galerkin `nx`/`xfac` -knobs, since those are silently ignored by `:ray`. +knobs — passing them to the default `:ray` backend throws a `MethodError`. ```julia using GeneralizedPerturbedEquilibrium.InnerLayer diff --git a/regression-harness/cases/ggj_ray_q500i.toml b/regression-harness/cases/ggj_ray_q500i.toml index 967fe379..f5722ee4 100644 --- a/regression-harness/cases/ggj_ray_q500i.toml +++ b/regression-harness/cases/ggj_ray_q500i.toml @@ -12,12 +12,15 @@ kind = "computed" # Δ_odd / Δ_even from solve_inner(GGJModel(:ray), q4_surface_benchmark(), 500im·Q₀). # Complex-valued; both real and imaginary parts are tracked separately. +# noise_threshold 1e-4 (absolute): the extended-precision damped-zone march carries +# BLAS/platform-dependent structured roundoff at the ~1e-4 absolute level on these +# O(10) components (~1e-5 relative); a tighter floor flags phantom cross-machine drift. [quantities.delta_odd_real] h5path = "ggj/delta_odd_real" type = "real_scalar" extract = "value" label = "Re(Δ_odd)" -noise_threshold = 1e-8 +noise_threshold = 1e-4 order = 10 [quantities.delta_odd_imag] @@ -25,7 +28,7 @@ h5path = "ggj/delta_odd_imag" type = "real_scalar" extract = "value" label = "Im(Δ_odd)" -noise_threshold = 1e-8 +noise_threshold = 1e-4 order = 11 [quantities.delta_even_real] @@ -33,7 +36,7 @@ h5path = "ggj/delta_even_real" type = "real_scalar" extract = "value" label = "Re(Δ_even)" -noise_threshold = 1e-8 +noise_threshold = 1e-4 order = 12 [quantities.delta_even_imag] @@ -41,7 +44,7 @@ h5path = "ggj/delta_even_imag" type = "real_scalar" extract = "value" label = "Im(Δ_even)" -noise_threshold = 1e-8 +noise_threshold = 1e-4 order = 13 [quantities.runtime] From f2322475d0f876db80cb999e66c6663e9795701b Mon Sep 17 00:00:00 2001 From: Matthew Pharr Date: Fri, 10 Jul 2026 02:32:06 -0400 Subject: [PATCH 06/38] ForceFreeStates - NEW - Add inner layer parameters to hdf5 for easy inner layer scans --- src/ForceFreeStates/Galerkin/GalerkinMatch.jl | 5 ++++- src/ForceFreeStates/Galerkin/GalerkinSolve.jl | 8 ++++++++ src/ForceFreeStates/Galerkin/GalerkinStructs.jl | 1 + 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/ForceFreeStates/Galerkin/GalerkinMatch.jl b/src/ForceFreeStates/Galerkin/GalerkinMatch.jl index 21fc87ff..21035b06 100644 --- a/src/ForceFreeStates/Galerkin/GalerkinMatch.jl +++ b/src/ForceFreeStates/Galerkin/GalerkinMatch.jl @@ -45,6 +45,7 @@ function gal_match_rpec(ctrl::ForceFreeStatesControl, equil, intr::ForceFreeStat bpen = zeros(ComplexF64, msing, mcoil) inner_psi = Vector{Float64}[] # no inner layer in the ideal limit inner_xi = Matrix{ComplexF64}[] + inner_params = InnerLayer.GGJParameters[] # inner layer skipped in the ideal limit rpec_eig = zeros(ComplexF64, msing) residual = 0.0 else @@ -71,9 +72,11 @@ function gal_match_rpec(ctrl::ForceFreeStatesControl, equil, intr::ForceFreeStat inner_psi = Vector{Vector{Float64}}(undef, msing) inner_odd = Vector{Vector{ComplexF64}}(undef, msing) # Ξ₁, antisymmetric across ψ_s inner_even = Vector{Vector{ComplexF64}}(undef, msing) # Ξ₂, symmetric across ψ_s + inner_params = Vector{InnerLayer.GGJParameters}(undef, msing) for i in 1:msing params = resist_eval(sings[i], equil, intr; eta=ctrl.gal_eta[i], rho=ctrl.gal_rho[i], gamma=ctrl.gal_gamma, ising=i) + inner_params[i] = params γ = 2π * im * nn * ctrl.gal_rotation[i] # forced eigenvalue; gal_rotation is f [Hz], γ = 2πi·n·f rpec_eig[i] = γ # Inner-solve knobs matched to the Fortran rmatch deltac/inps reference (DELTAC_LIST): inps basis, @@ -153,7 +156,7 @@ function gal_match_rpec(ctrl::ForceFreeStatesControl, equil, intr::ForceFreeStat end end - return GalMatchResult(cout, cin, xi, xi_deriv, deltar, bpen, inner_psi, inner_xi, rpec_eig, residual) + return GalMatchResult(cout, cin, xi, xi_deriv, deltar, bpen, inner_psi, inner_xi, inner_params, rpec_eig, residual) end """ diff --git a/src/ForceFreeStates/Galerkin/GalerkinSolve.jl b/src/ForceFreeStates/Galerkin/GalerkinSolve.jl index 0b7dd658..7614bdac 100644 --- a/src/ForceFreeStates/Galerkin/GalerkinSolve.jl +++ b/src/ForceFreeStates/Galerkin/GalerkinSolve.jl @@ -272,6 +272,14 @@ function write_galerkin!(out_h5, result::GalerkinResult) out_h5["galerkin/match/inner/xi_$i"] = m.inner_xi[i] end out_h5["galerkin/match/residual"] = m.residual + # Per-surface GGJ inner-layer coefficients (resist_eval), replacing the old + # GAL_DUMP_INNER temp-CSV hook: everything needed to reconstruct the layer + # problem (E,F,G,H,K,M) and its scales (taua, taur ⇒ S = taur/taua, v1). + if !isempty(m.inner_params) + for f in (:E, :F, :G, :H, :K, :M, :taua, :taur, :v1) + out_h5["galerkin/match/inner_params/$(f)"] = [getfield(pp, f) for pp in m.inner_params] + end + end end return nothing end diff --git a/src/ForceFreeStates/Galerkin/GalerkinStructs.jl b/src/ForceFreeStates/Galerkin/GalerkinStructs.jl index 2fcd9173..40343458 100644 --- a/src/ForceFreeStates/Galerkin/GalerkinStructs.jl +++ b/src/ForceFreeStates/Galerkin/GalerkinStructs.jl @@ -169,6 +169,7 @@ struct GalMatchResult bpen::Matrix{ComplexF64} inner_psi::Vector{Vector{Float64}} inner_xi::Vector{Matrix{ComplexF64}} + inner_params::Vector{InnerLayer.GGJParameters} # per-surface layer coefficients from resist_eval (empty in the ideal limit) rpec_eig::Vector{ComplexF64} residual::Float64 end From a7e5eba11ca3a1898b841be7b47be9c180262199 Mon Sep 17 00:00:00 2001 From: Matthew Pharr Date: Fri, 10 Jul 2026 02:49:15 -0400 Subject: [PATCH 07/38] PerturbedEquilibrium, ForceFreeStates - BUG FIX + NEW - penetrated field from the inner layer. Calculates penetrated field from a B_pen object that gets passed in from forcefreestates. If resistive, this comes from the inner layer model. If ideal, this is set to exactly 0. This is carried through in PerturbedEquilibrium and used for all B_pen outputs. --- src/ForceFreeStates/Galerkin/GalerkinMatch.jl | 23 ++++++++++++++++--- src/ForceFreeStates/Galerkin/GalerkinSolve.jl | 1 + .../Galerkin/GalerkinStructs.jl | 1 + src/GeneralizedPerturbedEquilibrium.jl | 9 ++++++++ .../PerturbedEquilibriumStructs.jl | 18 +++++++++++++++ src/PerturbedEquilibrium/SingularCoupling.jl | 23 +++++++++++++++++++ src/PerturbedEquilibrium/Utils.jl | 4 ++++ 7 files changed, 76 insertions(+), 3 deletions(-) diff --git a/src/ForceFreeStates/Galerkin/GalerkinMatch.jl b/src/ForceFreeStates/Galerkin/GalerkinMatch.jl index 21035b06..fa60b9f6 100644 --- a/src/ForceFreeStates/Galerkin/GalerkinMatch.jl +++ b/src/ForceFreeStates/Galerkin/GalerkinMatch.jl @@ -45,6 +45,7 @@ function gal_match_rpec(ctrl::ForceFreeStatesControl, equil, intr::ForceFreeStat bpen = zeros(ComplexF64, msing, mcoil) inner_psi = Vector{Float64}[] # no inner layer in the ideal limit inner_xi = Matrix{ComplexF64}[] + inner_b = Matrix{ComplexF64}[] inner_params = InnerLayer.GGJParameters[] # inner layer skipped in the ideal limit rpec_eig = zeros(ComplexF64, msing) residual = 0.0 @@ -72,6 +73,8 @@ function gal_match_rpec(ctrl::ForceFreeStatesControl, equil, intr::ForceFreeStat inner_psi = Vector{Vector{Float64}}(undef, msing) inner_odd = Vector{Vector{ComplexF64}}(undef, msing) # Ξ₁, antisymmetric across ψ_s inner_even = Vector{Vector{ComplexF64}}(undef, msing) # Ξ₂, symmetric across ψ_s + inner_bodd = Vector{Vector{ComplexF64}}(undef, msing) # b^ψ₁ = scale·resc·Ψ₁, symmetric (Ψ′₁(0)=0) + inner_beven = Vector{Vector{ComplexF64}}(undef, msing) # b^ψ₂ = scale·resc·Ψ₂, antisymmetric (Ψ₂(0)=0) inner_params = Vector{InnerLayer.GGJParameters}(undef, msing) for i in 1:msing params = resist_eval(sings[i], equil, intr; eta=ctrl.gal_eta[i], rho=ctrl.gal_rho[i], @@ -85,14 +88,25 @@ function gal_match_rpec(ctrl::ForceFreeStatesControl, equil, intr::ForceFreeStat deltar[i, 1] = Δ[1] deltar[i, 2] = Δ[2] x0i = InnerLayer.GGJ.x0(params) - resc = (params.v1 / x0i)^(0.5 + InnerLayer.GGJ.p1(params)) # deltac.f amplitude rescale - scale = chi1 * im * nn * sings[i].q1 * x0i # match.f b-field scaling + # Amplitude rescale: inner solutions are normalized in X = v1·δψ/x0 (inner_psi below); + # converting a big-branch (μ₋ = −1/2−p1) amplitude to the outer δψ-normalization gives + # resc = (v1/x0)^(−μ₋), the companion of rescale_delta's (v1/x0)^(2p1) = (v1/x0)^(μ₊−μ₋). + resc = (params.v1 / x0i)^(0.5 + InnerLayer.GGJ.p1(params)) + # b-field scaling, derived from the code's own outer convention (SingularCoupling): + # b_m = 2πi·χ₁·(m−nq)·ξ_m, m−nq = −n·q′·δψ, δψ = (x0/v1)·X, resc·Ξ(X) ↔ ξ_m, + # and the far-field identity Ψ = X·Ξ (GWP2016 Eq. 16; Ψ is the normal-field variable, Eq. A17): + # b_m = −2πi·χ₁·n·q′·(x0/v1)·resc·Ψ. + scale = -2π * chi1 * im * nn * sings[i].q1 * x0i / params.v1 pen[i, 1] = scale * prof.Ψ[1, 1] * resc # layer center X=0, parity 1 (Ψ(0)≠0) pen[i, 2] = scale * prof.Ψ[1, 2] * resc # parity 2 (Ψ(0)=0 ⇒ ~0) xvar = prof.x .* (x0i / params.v1) # inner X → ψ-distance (deltac.f:1822) inner_psi[i] = vcat(reverse(sings[i].psifac .- xvar), sings[i].psifac .+ xvar) inner_odd[i] = resc .* vcat(reverse(.-prof.Ξ[:, 1]), prof.Ξ[:, 1]) # comp 2, parity 1 (odd: −left,+right) inner_even[i] = resc .* vcat(reverse(prof.Ξ[:, 2]), prof.Ξ[:, 2]) # comp 2, parity 2 (even) + # b^ψ profiles on the same two-sided grid: Ψ is the normal-field variable (GWP2016 A17), + # b_m(δψ) = scale·resc·Ψ(X) throughout the layer (→ outer frozen-in relation via Ψ = XΞ). + inner_bodd[i] = (scale * resc) .* vcat(reverse(prof.Ψ[:, 1]), prof.Ψ[:, 1]) # parity 1: Ψ even + inner_beven[i] = (scale * resc) .* vcat(reverse(.-prof.Ψ[:, 2]), prof.Ψ[:, 2]) # parity 2: Ψ odd end # --- assemble the 4·msing matching system (match.f) --- @@ -138,6 +152,9 @@ function gal_match_rpec(ctrl::ForceFreeStatesControl, equil, intr::ForceFreeStat # Matched inner-layer ξ_ψ(ψ) per surface, per coil drive (match.f intotsol): odd parity weighted by # cin[2i] (cofin(2·ising)), even parity by cin[2i-1] (cofin(2·ising-1)). inner_xi = [inner_odd[i] * transpose(cin[2i, :]) .+ inner_even[i] * transpose(cin[2i-1, :]) for i in 1:msing] + # Matched inner-layer b^ψ(ψ) per surface, per coil drive — the overlap-validation companion of + # inner_xi: in the matching region it must lie on the outer b^ψ eigenfunction. + inner_b = [inner_bodd[i] * transpose(cin[2i, :]) .+ inner_beven[i] * transpose(cin[2i-1, :]) for i in 1:msing] end # --- matched outer ξ/ξ′ per coil drive (match.f); ideal: cout=0 ⇒ bare coil column --- @@ -156,7 +173,7 @@ function gal_match_rpec(ctrl::ForceFreeStatesControl, equil, intr::ForceFreeStat end end - return GalMatchResult(cout, cin, xi, xi_deriv, deltar, bpen, inner_psi, inner_xi, inner_params, rpec_eig, residual) + return GalMatchResult(cout, cin, xi, xi_deriv, deltar, bpen, inner_psi, inner_xi, inner_b, inner_params, rpec_eig, residual) end """ diff --git a/src/ForceFreeStates/Galerkin/GalerkinSolve.jl b/src/ForceFreeStates/Galerkin/GalerkinSolve.jl index 7614bdac..d3ef0379 100644 --- a/src/ForceFreeStates/Galerkin/GalerkinSolve.jl +++ b/src/ForceFreeStates/Galerkin/GalerkinSolve.jl @@ -270,6 +270,7 @@ function write_galerkin!(out_h5, result::GalerkinResult) for i in eachindex(m.inner_psi) out_h5["galerkin/match/inner/psi_$i"] = m.inner_psi[i] out_h5["galerkin/match/inner/xi_$i"] = m.inner_xi[i] + out_h5["galerkin/match/inner/b_$i"] = m.inner_b[i] end out_h5["galerkin/match/residual"] = m.residual # Per-surface GGJ inner-layer coefficients (resist_eval), replacing the old diff --git a/src/ForceFreeStates/Galerkin/GalerkinStructs.jl b/src/ForceFreeStates/Galerkin/GalerkinStructs.jl index 40343458..ae22d4c8 100644 --- a/src/ForceFreeStates/Galerkin/GalerkinStructs.jl +++ b/src/ForceFreeStates/Galerkin/GalerkinStructs.jl @@ -169,6 +169,7 @@ struct GalMatchResult bpen::Matrix{ComplexF64} inner_psi::Vector{Vector{Float64}} inner_xi::Vector{Matrix{ComplexF64}} + inner_b::Vector{Matrix{ComplexF64}} # per surface, matched inner-layer b^ψ(ψ) on inner_psi (overlap validation) inner_params::Vector{InnerLayer.GGJParameters} # per-surface layer coefficients from resist_eval (empty in the ideal limit) rpec_eig::Vector{ComplexF64} residual::Float64 diff --git a/src/GeneralizedPerturbedEquilibrium.jl b/src/GeneralizedPerturbedEquilibrium.jl index e7ecef8a..b66dc064 100755 --- a/src/GeneralizedPerturbedEquilibrium.jl +++ b/src/GeneralizedPerturbedEquilibrium.jl @@ -517,6 +517,15 @@ function main_from_inputs( if ctrl.gal_flag && ctrl.gal_match_flag && gal_data !== nothing && gal_data.match !== nothing @info "PerturbedEquilibrium: using the RPEC-matched gal solution" pe_odet = gal_matched_odestate(gal_data, ffit, intr) + # Inner-layer penetrated field per coil-drive column, same basis as the matched OdeState + # columns; SingularCoupling uses it as the official penetrated field (pointwise midpoint + # is only the fallback). Ideal mode: match.bpen is identically zero ⇒ B_pen ≡ 0. + pe_intr.inner_bpen = gal_data.match.bpen + elseif ctrl.kinetic_factor == 0 + # Ideal shooting run: perfect shielding — the penetrated field is exactly zero, same + # dispatch as the gal-ideal path. Only KINETIC shooting remains on the pointwise + # surface-interpolation fallback (outstanding). + pe_intr.inner_bpen = zeros(ComplexF64, intr.msing, intr.numpert_total) end # Reuse the forcing modes loaded at snapshot time (or injected by diff --git a/src/PerturbedEquilibrium/PerturbedEquilibriumStructs.jl b/src/PerturbedEquilibrium/PerturbedEquilibriumStructs.jl index 804c6ac8..ba05478c 100644 --- a/src/PerturbedEquilibrium/PerturbedEquilibriumStructs.jl +++ b/src/PerturbedEquilibrium/PerturbedEquilibriumStructs.jl @@ -74,6 +74,15 @@ Internal state variables for perturbed equilibrium calculations. singular_coupling_metrics::Dict{String,Float64} = Dict{String,Float64}() m_modes::Vector{Int} = Int[] n_modes::Vector{Int} = Int[] + # ForceFreeStates-provided B_pen per (match surface × coil-drive column), read off the GGJ inner + # solution at the layer center (GalMatchResult.bpen). Cusp-free: the outer large solution blows + # up at the rational, so the pointwise C_penetrated evaluation is bracket-offset dependent; the + # layer-center Ψ(0) is finite and grid-independent. Columns are the same identity-at-edge + # coil-drive basis as the matched OdeState solutions, so PE's C_coeffs contraction applies. + # DISPATCH: when non-empty, this is the OFFICIAL penetrated field (C_penetrated_area_weighted_field); + # the pointwise surface interpolation is only the fallback. Ideal mode (gal OR shooting) passes + # identically zero — perfect shielding. Empty → pointwise fallback (kinetic shooting: outstanding). + inner_bpen::Matrix{ComplexF64} = zeros(ComplexF64, 0, 0) end """ @@ -158,6 +167,10 @@ well-conditioned flux-space inductances L, Λ: C_island_width_sq::Matrix{ComplexF64} = zeros(ComplexF64, 0, 0) C_penetrated_area_weighted_field::Matrix{ComplexF64} = zeros(ComplexF64, 0, 0) C_delta_prime::Matrix{ComplexF64} = zeros(ComplexF64, 0, 0) + # Inner-layer (cusp-free) penetrated field coupling: layer-center Ψ(0) of the matched GGJ inner + # solution, contracted with the same C_coeffs as the outer rows. Populated only for RPEC-matched + # gal runs (intr.inner_bpen non-empty). + C_penetrated_field_inner::Matrix{ComplexF64} = zeros(ComplexF64, 0, 0) # Applied resonant vectors [n_rational] = C · amp_vec resonant_area_weighted_field::Vector{ComplexF64} = ComplexF64[] @@ -165,6 +178,11 @@ well-conditioned flux-space inductances L, Λ: island_width_sq::Vector{ComplexF64} = ComplexF64[] penetrated_area_weighted_field::Vector{ComplexF64} = ComplexF64[] delta_prime::Vector{ComplexF64} = ComplexF64[] + penetrated_field_inner::Vector{ComplexF64} = ComplexF64[] + # Applied-drive weights over the OdeState solution columns (C_coeffs·forcing_flux) and per-row + # surface area — exposed so inner-layer per-coil profiles can be contracted/normalized offline. + forcing_solution_weights::Vector{ComplexF64} = ComplexF64[] + rational_area::Vector{Float64} = Float64[] # Diagnostics [n_rational] island_half_width::Vector{Float64} = Float64[] diff --git a/src/PerturbedEquilibrium/SingularCoupling.jl b/src/PerturbedEquilibrium/SingularCoupling.jl index 2a2bec04..87ecf6f5 100644 --- a/src/PerturbedEquilibrium/SingularCoupling.jl +++ b/src/PerturbedEquilibrium/SingularCoupling.jl @@ -134,11 +134,17 @@ function compute_singular_coupling_metrics!( state.C_island_width_sq = zeros(ComplexF64, n_rational, numpert_total) state.C_penetrated_area_weighted_field = zeros(ComplexF64, n_rational, numpert_total) state.C_delta_prime = zeros(ComplexF64, n_rational, numpert_total) + # B_pen dispatch: a ForceFreeStates-provided layer-center penetrated field (identically zero in + # ideal mode) becomes the OFFICIAL C_penetrated_area_weighted_field; the pointwise midpoint + # evaluation below is only the fallback. + use_inner_bpen = !isempty(intr.inner_bpen) + use_inner_bpen && (state.C_penetrated_field_inner = zeros(ComplexF64, n_rational, numpert_total)) state.rational_psi = zeros(Float64, n_rational) state.rational_q = zeros(Float64, n_rational) state.rational_m_res = zeros(Int, n_rational) state.rational_n = zeros(Int, n_rational) state.rational_surface_idx = zeros(Int, n_rational) + state.rational_area = zeros(Float64, n_rational) # Precompute ODE coefficient matrix C_coeffs for all PE forcing modes. # For each forcing mode k: c_k = u_bnd⁻¹ × edge_mn_k @@ -253,6 +259,19 @@ function compute_singular_coupling_metrics!( state.C_penetrated_area_weighted_field[row, k] = (b_l + b_r) / 2 / area end + # Inner-layer (cusp-free) penetrated field: bpen[s, j] is linear in the same identity-at-edge + # coil-drive columns as the OdeState solutions, so it contracts with C_coeffs exactly like + # the outer solution values above (xsp = dot(u, ck)); /area matches the area-weighted + # convention of the pointwise row. No bracket points involved — the value is the matched GGJ + # inner solution's Ψ(0), finite at the rational where the outer large solution blows up. + # When present it IS the official penetrated field (the midpoint value above is overwritten); + # ideal mode passes zeros ⇒ penetrated field exactly 0 (perfect shielding). + if use_inner_bpen && s <= size(intr.inner_bpen, 1) + pen_row = (transpose(C_coeffs) * @view(intr.inner_bpen[s, :])) ./ area + state.C_penetrated_field_inner[row, :] = pen_row + state.C_penetrated_area_weighted_field[row, :] = pen_row + end + # LHS normalization audit (#233) — output scalar coordinate-invariance per row: # - Δ' (1/length): the resonant-surface jump in ∂b^ψ/∂ψ over 2π·χ₁; the tearing index is # coordinate-invariant (its sign/zero-crossing set the stability boundary) [Glasser 2016]. @@ -270,6 +289,7 @@ function compute_singular_coupling_metrics!( end state.rational_psi[row] = sing_surf.psifac + state.rational_area[row] = area state.rational_q[row] = sing_surf.q state.rational_m_res[row] = m_res state.rational_n[row] = nn @@ -295,6 +315,8 @@ function compute_singular_coupling_metrics!( state.island_width_sq = state.C_island_width_sq * forcing_flux state.penetrated_area_weighted_field = state.C_penetrated_area_weighted_field * forcing_flux state.delta_prime = state.C_delta_prime * forcing_flux + use_inner_bpen && (state.penetrated_field_inner = state.C_penetrated_field_inner * forcing_flux) + state.forcing_solution_weights = C_coeffs * forcing_flux # Conform the stored coupling-matrix input basis to the coordinate-invariant root-area-weighted # field (b̃) space (#233 / Pharr 2026): C̃ = C·R, so each stored row acts on the applied field @@ -309,6 +331,7 @@ function compute_singular_coupling_metrics!( state.C_island_width_sq = state.C_island_width_sq * flux_conform state.C_penetrated_area_weighted_field = state.C_penetrated_area_weighted_field * flux_conform state.C_delta_prime = state.C_delta_prime * flux_conform + use_inner_bpen && (state.C_penetrated_field_inner = state.C_penetrated_field_inner * flux_conform) # Phase 5: Island diagnostics from applied resonant vectors compute_island_diagnostics!(state, n_rational) diff --git a/src/PerturbedEquilibrium/Utils.jl b/src/PerturbedEquilibrium/Utils.jl index 9957fe7c..0cae9c23 100644 --- a/src/PerturbedEquilibrium/Utils.jl +++ b/src/PerturbedEquilibrium/Utils.jl @@ -198,6 +198,7 @@ function write_outputs_to_HDF5( !isempty(state.C_island_width_sq) && (coupling_group["C_island_width_sq"] = state.C_island_width_sq) !isempty(state.C_penetrated_area_weighted_field) && (coupling_group["C_penetrated_area_weighted_field"] = state.C_penetrated_area_weighted_field) !isempty(state.C_delta_prime) && (coupling_group["C_delta_prime"] = state.C_delta_prime) + !isempty(state.C_penetrated_field_inner) && (coupling_group["C_penetrated_field_inner"] = state.C_penetrated_field_inner) # Applied resonant vectors [n_rational] !isempty(state.resonant_area_weighted_field) && (coupling_group["resonant_area_weighted_field"] = state.resonant_area_weighted_field) @@ -205,6 +206,9 @@ function write_outputs_to_HDF5( !isempty(state.island_width_sq) && (coupling_group["island_width_sq"] = state.island_width_sq) !isempty(state.penetrated_area_weighted_field) && (coupling_group["penetrated_area_weighted_field"] = state.penetrated_area_weighted_field) !isempty(state.delta_prime) && (coupling_group["delta_prime"] = state.delta_prime) + !isempty(state.penetrated_field_inner) && (coupling_group["penetrated_field_inner"] = state.penetrated_field_inner) + !isempty(state.forcing_solution_weights) && (coupling_group["forcing_solution_weights"] = state.forcing_solution_weights) + !isempty(state.rational_area) && (coupling_group["rational_area"] = state.rational_area) !isempty(state.island_half_width) && (coupling_group["island_half_width"] = state.island_half_width) !isempty(state.chirikov_parameter) && (coupling_group["chirikov_parameter"] = state.chirikov_parameter) From cd884c4ead27de6dc2325feb8d7db380834df87a Mon Sep 17 00:00:00 2001 From: Matthew Pharr Date: Fri, 10 Jul 2026 02:58:59 -0400 Subject: [PATCH 08/38] PerturbedEquilibrium, ForceFreeStates - NEW - coefficient-based Delta_mn for gal -> PE --- src/ForceFreeStates/Galerkin/GalerkinMatch.jl | 63 ++++++++++++++++--- src/ForceFreeStates/Galerkin/GalerkinSolve.jl | 3 +- .../Galerkin/GalerkinStructs.jl | 1 + src/GeneralizedPerturbedEquilibrium.jl | 6 ++ .../PerturbedEquilibriumStructs.jl | 12 +++- src/PerturbedEquilibrium/SingularCoupling.jl | 49 ++++++++++++--- 6 files changed, 114 insertions(+), 20 deletions(-) diff --git a/src/ForceFreeStates/Galerkin/GalerkinMatch.jl b/src/ForceFreeStates/Galerkin/GalerkinMatch.jl index fa60b9f6..e9563e69 100644 --- a/src/ForceFreeStates/Galerkin/GalerkinMatch.jl +++ b/src/ForceFreeStates/Galerkin/GalerkinMatch.jl @@ -13,7 +13,40 @@ # identity-at-edge basis (coil column j has ξ_edge = e_j). """ - gal_match_rpec(ctrl, equil, intr, gal_result) -> GalMatchResult + gal_deltamn_kernels(asymps, sings, intr, nn) -> (KL, KR) + +Per-surface unit-small-solution kernels for the coefficient-based Δ_mn: the resonant-harmonic +`bwp1/(2πχ₁) = i·(singfac·ξ′ − n·q′·ξ)` of the UNIT small Frobenius solution, evaluated at the +conventional brackets ψ_s ∓ δ with δ = 5e-4/(|n|·|q′|) — the same offsets as SingularCoupling's +finite-jump fallback (spot_psi), so the two paths measure the same object. The matched solution's +Δ_mn is then `Δ_mn[i,j] = KR[i]·c_small_R[i,j] − KL[i]·c_small_L[i,j]`: the small-solution +(shielding) content only. The big/penetrated branch is excluded — its finite-offset contribution +diverges as δ^{−(1/2+p₁)} and is not part of the physical Δ_mn (in ideal runs it is zero anyway). +""" +function gal_deltamn_kernels(asymps::Vector{GalSingAsymp}, sings::Vector{SingType}, + intr::ForceFreeStatesInternal, nn::Int) + msing = length(sings) + KL = zeros(ComplexF64, msing) + KR = zeros(ComplexF64, msing) + for i in 1:msing + q1 = sings[i].q1 + imres = round(Int, sings[i].q * nn) - intr.mlow + 1 + δ = 5e-4 / (abs(nn) * abs(q1)) + for (K, sgn) in ((KR, 1.0), (KL, -1.0)) + z = sgn * δ + ua = sing_get_ua_gal(asymps[i], z) + dua = sing_get_dua_gal(asymps[i], z) + ξ = ua[imres, 2, 1] + ξ′ = dua[imres, 2, 1] + singfac = -nn * q1 * z # m_res − n·q(ψ_s+z) to leading order + K[i] = im * (singfac * ξ′ - nn * q1 * ξ) + end + end + return KL, KR +end + +""" + gal_match_rpec(ctrl, equil, intr, gal_result, asymps) -> GalMatchResult Solve the coil-driven RPEC matching from the outer Δ′ (`gal_result`) and the per-surface inner-layer Δ. Requires `gal_result.solution` (reconstructed outer ξ/ξ′) and the rpec coil block `gal_result.delta_coil`. @@ -23,7 +56,7 @@ The resistive path uses per-surface inputs `ctrl.gal_eta`/`gal_rho`/`gal_rotatio solution is the bare ideal coil column (Fortran rmatch `coil%ideal_flag`) — the DCON/EL reference. """ function gal_match_rpec(ctrl::ForceFreeStatesControl, equil, intr::ForceFreeStatesInternal, - gal_result::GalerkinResult) + gal_result::GalerkinResult, asymps::Vector{GalSingAsymp}) msing = gal_result.msing mpert = intr.numpert_total @@ -35,6 +68,12 @@ function gal_match_rpec(ctrl::ForceFreeStatesControl, equil, intr::ForceFreeStat isempty(gal_result.delta_coil) && error("gal_match_rpec: delta_coil is empty — gal_rpec_flag must be true for RPEC matching") + # Singular-surface set (same helper as galerkin_solve); needed by both branches (Δ_mn kernels) + # and by the resistive inner-layer loop. + sings, _, _ = gal_resonant_surfaces(intr, equil) + length(sings) == msing || + error("gal_match_rpec: re-derived $(length(sings)) surfaces, expected msing=$msing") + if ctrl.gal_ideal_flag # Ideal limit (Fortran rmatch coil%ideal_flag, match.f): skip the inner layer entirely # and set the resistive plasma combination to zero. The gal outer solve is already fully ideal, so @@ -54,12 +93,6 @@ function gal_match_rpec(ctrl::ForceFreeStatesControl, equil, intr::ForceFreeStat length(v) == msing || error("gal_match_rpec: $name has length $(length(v)), expected msing=$msing (one value per surface, core→edge)") end - # Re-derive the gal singular-surface set (same helper as galerkin_solve) for the SingType objects - # resist_eval needs. - sings, _, _ = gal_resonant_surfaces(intr, equil) - length(sings) == msing || - error("gal_match_rpec: re-derived $(length(sings)) surfaces, expected msing=$msing") - # --- inner-layer matching data Δ(Q) per surface (deltac_run; match.f) --- # solve_inner_profile returns the same Δ as solve_inner plus the reconstructed inner-layer field, # so the layer-center value (penetrated field) comes for free from the single matching solve. @@ -173,7 +206,19 @@ function gal_match_rpec(ctrl::ForceFreeStatesControl, equil, intr::ForceFreeStat end end - return GalMatchResult(cout, cin, xi, xi_deriv, deltar, bpen, inner_psi, inner_xi, inner_b, inner_params, rpec_eig, residual) + # Coefficient-based Δ_mn (both branches; ideal has cout = 0 ⇒ coil small content only). + # Small-solution content of the matched solution at each surface side: the resonant solutions' + # small coefficients (delta rows 1:2msing) weighted by cout, plus the coil column's own small + # content (delta rows 2msing+1:end). Slots: column 2i-1 = left, 2i = right of surface i. + KL, KR = gal_deltamn_kernels(asymps, sings, intr, nn) + Dm = gal_result.delta + S_small = transpose(cout) * Dm[1:2msing, :] .+ Dm[(2msing+1):(2msing+mcoil), :] # (mcoil × 2msing) + delta_mn = Matrix{ComplexF64}(undef, msing, mcoil) + for i in 1:msing, j in 1:mcoil + delta_mn[i, j] = KR[i] * S_small[j, 2i] - KL[i] * S_small[j, 2i-1] + end + + return GalMatchResult(cout, cin, xi, xi_deriv, deltar, bpen, inner_psi, inner_xi, inner_b, delta_mn, inner_params, rpec_eig, residual) end """ diff --git a/src/ForceFreeStates/Galerkin/GalerkinSolve.jl b/src/ForceFreeStates/Galerkin/GalerkinSolve.jl index d3ef0379..bde9776c 100644 --- a/src/ForceFreeStates/Galerkin/GalerkinSolve.jl +++ b/src/ForceFreeStates/Galerkin/GalerkinSolve.jl @@ -196,7 +196,7 @@ function galerkin_solve(ctrl::ForceFreeStatesControl, equil, ffit::FourFitVars, "RPEC matching: IDEAL solution (inner layer skipped, bare coil columns)" : "RPEC matching: inner-layer Δ(Q) + outer↔inner solve for the coil-driven ξ" ) - match = gal_match_rpec(ctrl, equil, intr, result) + match = gal_match_rpec(ctrl, equil, intr, result, asymps) ctrl.gal_ideal_flag || (ctrl.verbose && @info "RPEC matching: linear-solve residual = $(match.residual)") return GalerkinResult(delta, Ap, Bp, Gammap, Deltap, msing, sing_psi, sing_q, sing_m, sing_n, di, alpha, delta_coil, solution, match) @@ -265,6 +265,7 @@ function write_galerkin!(out_h5, result::GalerkinResult) out_h5["galerkin/match/xi_deriv"] = m.xi_deriv out_h5["galerkin/match/deltar"] = m.deltar out_h5["galerkin/match/bpen"] = m.bpen + out_h5["galerkin/match/delta_mn"] = m.delta_mn out_h5["galerkin/match/rpec_eig"] = m.rpec_eig # Per-surface inner-layer ξ_ψ(ψ) (match.f intotsol); ragged grids → one dataset pair per surface. for i in eachindex(m.inner_psi) diff --git a/src/ForceFreeStates/Galerkin/GalerkinStructs.jl b/src/ForceFreeStates/Galerkin/GalerkinStructs.jl index ae22d4c8..f251dc8c 100644 --- a/src/ForceFreeStates/Galerkin/GalerkinStructs.jl +++ b/src/ForceFreeStates/Galerkin/GalerkinStructs.jl @@ -170,6 +170,7 @@ struct GalMatchResult inner_psi::Vector{Vector{Float64}} inner_xi::Vector{Matrix{ComplexF64}} inner_b::Vector{Matrix{ComplexF64}} # per surface, matched inner-layer b^ψ(ψ) on inner_psi (overlap validation) + delta_mn::Matrix{ComplexF64} # (msing × mcoil) coefficient-based Δ_mn (small-solution content at the conventional brackets) inner_params::Vector{InnerLayer.GGJParameters} # per-surface layer coefficients from resist_eval (empty in the ideal limit) rpec_eig::Vector{ComplexF64} residual::Float64 diff --git a/src/GeneralizedPerturbedEquilibrium.jl b/src/GeneralizedPerturbedEquilibrium.jl index b66dc064..d7de97ee 100755 --- a/src/GeneralizedPerturbedEquilibrium.jl +++ b/src/GeneralizedPerturbedEquilibrium.jl @@ -517,10 +517,16 @@ function main_from_inputs( if ctrl.gal_flag && ctrl.gal_match_flag && gal_data !== nothing && gal_data.match !== nothing @info "PerturbedEquilibrium: using the RPEC-matched gal solution" pe_odet = gal_matched_odestate(gal_data, ffit, intr) + pe_intr.odet_from_gal = true # Inner-layer penetrated field per coil-drive column, same basis as the matched OdeState # columns; SingularCoupling uses it as the official penetrated field (pointwise midpoint # is only the fallback). Ideal mode: match.bpen is identically zero ⇒ B_pen ≡ 0. pe_intr.inner_bpen = gal_data.match.bpen + # Δ_mn coefficient object (gal paths, ideal + resistive): small-solution content at the + # conventional brackets — replaces the finite-jump evaluation in SingularCoupling. + # (The non-galerkin ideal pathway gets its own Δ object separately; until then shooting + # runs fall back to the finite jump.) + pe_intr.ffs_delta_mn = gal_data.match.delta_mn elseif ctrl.kinetic_factor == 0 # Ideal shooting run: perfect shielding — the penetrated field is exactly zero, same # dispatch as the gal-ideal path. Only KINETIC shooting remains on the pointwise diff --git a/src/PerturbedEquilibrium/PerturbedEquilibriumStructs.jl b/src/PerturbedEquilibrium/PerturbedEquilibriumStructs.jl index ba05478c..5cfe63a0 100644 --- a/src/PerturbedEquilibrium/PerturbedEquilibriumStructs.jl +++ b/src/PerturbedEquilibrium/PerturbedEquilibriumStructs.jl @@ -81,8 +81,18 @@ Internal state variables for perturbed equilibrium calculations. # coil-drive basis as the matched OdeState solutions, so PE's C_coeffs contraction applies. # DISPATCH: when non-empty, this is the OFFICIAL penetrated field (C_penetrated_area_weighted_field); # the pointwise surface interpolation is only the fallback. Ideal mode (gal OR shooting) passes - # identically zero — perfect shielding. Empty → pointwise fallback (kinetic shooting: outstanding). + # identically zero — perfect shielding. Empty → pointwise fallback, which after the main.jl wiring + # only happens for KINETIC shooting runs (outstanding). inner_bpen::Matrix{ComplexF64} = zeros(ComplexF64, 0, 0) + # ForceFreeStates-provided Δ_mn per (match surface × coil-drive column), same basis/contraction + # as inner_bpen, in the C_delta_prime output convention ([∂ψ(𝒥b·∇ψ)]/(2πχ₁) per unit drive). + # DISPATCH: when non-empty it replaces the finite-jump evaluation (which is offset-dependent for + # p₁ ≠ 1/2 and penetration-contaminated in resistive runs). To be populated by ForceFreeStates: + # ideal Δ_coil calculation (in progress) and the layer-consistent resistive Δ (Fortran parity, TBD). + ffs_delta_mn::Matrix{ComplexF64} = zeros(ComplexF64, 0, 0) + # True when the PE OdeState is the gal(-matched) solution: ud_store then carries the analytic ξ′ + # (incl. resonant series), enabling the exact-derivative path in SingularCoupling. + odet_from_gal::Bool = false end """ diff --git a/src/PerturbedEquilibrium/SingularCoupling.jl b/src/PerturbedEquilibrium/SingularCoupling.jl index 87ecf6f5..1d63aee8 100644 --- a/src/PerturbedEquilibrium/SingularCoupling.jl +++ b/src/PerturbedEquilibrium/SingularCoupling.jl @@ -40,6 +40,20 @@ function _hermite_cubic_val(u_a, u_b, du_a, du_b, psi_a, psi_b, psi) return @. h00 * u_a + h * h10 * du_a + h01 * u_b + h * h11 * du_b end +# Cubic Hermite interpolant DERIVATIVE at psi. Used when the endpoint derivatives are analytic +# (gal-matched OdeState: ud_store carries the exact ξ′ including the resonant Frobenius content), +# so the interpolated derivative is consistent with the stored analytic one — unlike a chord slope, +# which differences singular content across nodes. +function _hermite_cubic_deriv(u_a, u_b, du_a, du_b, psi_a, psi_b, psi) + h = psi_b - psi_a + t = (psi - psi_a) / h + d00 = (6t^2 - 6t) / h + d10 = 3t^2 - 4t + 1 + d01 = (-6t^2 + 6t) / h + d11 = 3t^2 - 2t + return @. d00 * u_a + d10 * du_a + d01 * u_b + d11 * du_b +end + # Reflect a periodic theta-space vector θ → -θ (the theta reversal in gpvacuum_flxsurf). _reverse_theta(v::AbstractVector) = circshift(reverse(v), 1) @@ -134,10 +148,12 @@ function compute_singular_coupling_metrics!( state.C_island_width_sq = zeros(ComplexF64, n_rational, numpert_total) state.C_penetrated_area_weighted_field = zeros(ComplexF64, n_rational, numpert_total) state.C_delta_prime = zeros(ComplexF64, n_rational, numpert_total) - # B_pen dispatch: a ForceFreeStates-provided layer-center penetrated field (identically zero in - # ideal mode) becomes the OFFICIAL C_penetrated_area_weighted_field; the pointwise midpoint - # evaluation below is only the fallback. + # Dispatch flags: ForceFreeStates-provided objects take precedence over the bracket evaluations. + # - inner_bpen: layer-center penetrated field (identically zero in ideal mode) → becomes the + # OFFICIAL C_penetrated_area_weighted_field; pointwise midpoint is only the fallback. + # - ffs_delta_mn: coefficient-based Δ_mn → replaces the finite-jump C_delta_prime when present. use_inner_bpen = !isempty(intr.inner_bpen) + use_ffs_delta = !isempty(intr.ffs_delta_mn) use_inner_bpen && (state.C_penetrated_field_inner = zeros(ComplexF64, n_rational, numpert_total)) state.rational_psi = zeros(Float64, n_rational) state.rational_q = zeros(Float64, n_rational) @@ -225,12 +241,21 @@ function compute_singular_coupling_metrics!( u_l = _hermite_cubic_val(ua_l, ub_l, dua_l, dub_l, psi_il_l, psi_ir_l, lpsi) u_r = _hermite_cubic_val(ua_r, ub_r, dua_r, dub_r, psi_il_r, psi_ir_r, rpsi) - # Derivative (ud): chord slope from u_store only — ud_store can be systematically off near - # outer surfaces (q=4/5) where the ODE solution varies rapidly; near-cancellation in bwp1 - # then amplifies even a ~10% ud_store error into a large Delta' error. Chord slope avoids - # this by using only u values, which are accurately stored by the ODE integrator. - ud_l = (ub_l .- ua_l) ./ (psi_ir_l - psi_il_l) - ud_r = (ub_r .- ua_r) ./ (psi_ir_r - psi_il_r) + # Derivative (ud): two paths. + # - gal-matched OdeState (intr.odet_from_gal): ud_store is the ANALYTIC ξ′ from the gal basis, + # including the resonant Frobenius series — use the Hermite-cubic derivative built from + # (u, ud), which is exact where the representation is. + # - shooting OdeState: chord slope from u_store only — ud_store can be systematically off near + # outer surfaces (q=4/5) where the ODE solution varies rapidly; near-cancellation in bwp1 + # then amplifies even a ~10% ud_store error into a large Delta' error. Chord slope avoids + # this by using only u values, which are accurately stored by the ODE integrator. + if intr.odet_from_gal + ud_l = _hermite_cubic_deriv(ua_l, ub_l, dua_l, dub_l, psi_il_l, psi_ir_l, lpsi) + ud_r = _hermite_cubic_deriv(ua_r, ub_r, dua_r, dub_r, psi_il_r, psi_ir_r, rpsi) + else + ud_l = (ub_l .- ua_l) ./ (psi_ir_l - psi_il_l) + ud_r = (ub_r .- ua_r) ./ (psi_ir_r - psi_il_r) + end q_l = equil.profiles.q_spline(lpsi) q1_l = equil.profiles.q_deriv(lpsi) @@ -272,6 +297,12 @@ function compute_singular_coupling_metrics!( state.C_penetrated_area_weighted_field[row, :] = pen_row end + # Δ_mn: prefer the ForceFreeStates coefficient-based object (same contraction); the + # finite-jump value assigned above remains only as the fallback. + if use_ffs_delta && s <= size(intr.ffs_delta_mn, 1) + state.C_delta_prime[row, :] = transpose(C_coeffs) * @view(intr.ffs_delta_mn[s, :]) + end + # LHS normalization audit (#233) — output scalar coordinate-invariance per row: # - Δ' (1/length): the resonant-surface jump in ∂b^ψ/∂ψ over 2π·χ₁; the tearing index is # coordinate-invariant (its sign/zero-crossing set the stability boundary) [Glasser 2016]. From 9dc37bb70352fad0d8bd46f152a627197e472195 Mon Sep 17 00:00:00 2001 From: Matthew Pharr Date: Fri, 10 Jul 2026 03:14:57 -0400 Subject: [PATCH 09/38] ForceFreeStates - NEW - rotated-ray inner-layer backend as the resistive matching default --- src/ForceFreeStates/ForceFreeStatesStructs.jl | 1 + src/ForceFreeStates/Galerkin/GalerkinMatch.jl | 46 ++++++++++++++----- 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/src/ForceFreeStates/ForceFreeStatesStructs.jl b/src/ForceFreeStates/ForceFreeStatesStructs.jl index c8702e00..1cbb1963 100644 --- a/src/ForceFreeStates/ForceFreeStatesStructs.jl +++ b/src/ForceFreeStates/ForceFreeStatesStructs.jl @@ -310,6 +310,7 @@ A mutable struct containing control parameters for stability analysis, set by th # --- DRIVEN (RPEC) outer↔inner asymptotic matching (rmatch match_rpec port) --- gal_match_flag::Bool = false # enable the RPEC inner-layer matching: solve the coil-driven matched ξ(ψ) from the gal Δ′ + the inner-layer Δ(Q). Requires gal_rpec_flag=true. gal_ideal_flag::Bool = false # within the match, build the IDEAL solution: skip the inner-layer Δ, use bare coil columns (cout=0). Mirrors Fortran rmatch coil%ideal_flag (the EL reference). eta/rho/rotation ignored. + gal_inner_solver::String = "ray" # inner-layer Δ backend for the match: "ray" (rotated-contour collocation, certified Δ at the optimal θ = arg(Q)/4; robust for |Q| ≳ 1) or "galerkin" (Hermite-cubic inps; drifts for |Q| ≳ 1) gal_eta::Vector{Float64} = Float64[] # per-surface resistivity η (length msing, core→edge); Fortran rmatch `eta` gal_rho::Vector{Float64} = Float64[] # per-surface mass density ρ [kg/m³] (length msing, core→edge); Fortran rmatch `massden` gal_rotation::Vector{Float64} = Float64[] # per-surface rotation frequency f [Hz] (length msing, core→edge); forced eigenvalue γ_s = 2πi·n·f. Fortran rmatch `rotation` diff --git a/src/ForceFreeStates/Galerkin/GalerkinMatch.jl b/src/ForceFreeStates/Galerkin/GalerkinMatch.jl index e9563e69..870267d9 100644 --- a/src/ForceFreeStates/Galerkin/GalerkinMatch.jl +++ b/src/ForceFreeStates/Galerkin/GalerkinMatch.jl @@ -92,6 +92,8 @@ function gal_match_rpec(ctrl::ForceFreeStatesControl, equil, intr::ForceFreeStat for (name, v) in (("gal_eta", ctrl.gal_eta), ("gal_rho", ctrl.gal_rho), ("gal_rotation", ctrl.gal_rotation)) length(v) == msing || error("gal_match_rpec: $name has length $(length(v)), expected msing=$msing (one value per surface, core→edge)") end + ctrl.gal_inner_solver in ("ray", "galerkin") || + error("gal_match_rpec: gal_inner_solver = \"$(ctrl.gal_inner_solver)\" (expected \"ray\" or \"galerkin\")") # --- inner-layer matching data Δ(Q) per surface (deltac_run; match.f) --- # solve_inner_profile returns the same Δ as solve_inner plus the reconstructed inner-layer field, @@ -115,11 +117,31 @@ function gal_match_rpec(ctrl::ForceFreeStatesControl, equil, intr::ForceFreeStat inner_params[i] = params γ = 2π * im * nn * ctrl.gal_rotation[i] # forced eigenvalue; gal_rotation is f [Hz], γ = 2πi·n·f rpec_eig[i] = γ - # Inner-solve knobs matched to the Fortran rmatch deltac/inps reference (DELTAC_LIST): inps basis, - # inps_xfac=10 (xmax×10, grid nx = 128·10 = 1280), nq=5, cutoff=5, order_pow=8 (↔ kmax). - Δ, _, prof, _ = InnerLayer.GGJ.solve_inner_profile(params, γ; xfac=10.0, nx=1280, nq=5, cutoff=5, kmax=8) # (Δ₁, Δ₂) in deltac.f convention - deltar[i, 1] = Δ[1] - deltar[i, 2] = Δ[2] + # gal_inner_solver = "ray" (default) sources the inner layer from the rotated-ray + # backend: the certified Δ comes from the optimal-contour solve at θ = arg(Q)/4 (robust + # for |Q| ≳ 1, where the galerkin inner solver drifts), and the profile quantities + # (pen, inner_xi, inner_b) from a θ=0 re-solve on the real axis — valid at physical + # (RPEC) |Q| since the on-axis pseudo-resonance is a regular point resolved by the BVP + # refinement. The θ=0-vs-optimal-θ Δ agreement doubles as a runtime certificate (two + # maximally different contours through the same entire solutions). + if ctrl.gal_inner_solver == "ray" + Q = InnerLayer.GGJ.inner_Q(params, γ) + rr = InnerLayer.GGJ.solve_ray(params, Q) # certified Δ (optimal θ) + deltar[i, 1] = rr.Δ[1] + deltar[i, 2] = rr.Δ[2] + r0 = InnerLayer.GGJ.solve_ray(params, Q; θ=0.0) # real-axis profile solve + relΔ = abs(r0.Δ[1] - rr.Δ[1]) / max(abs(rr.Δ[1]), 1e-300) + relΔ > 1e-3 && @warn "GGJ ray: θ=0 profile solve disagrees with certified Δ" surface = i relΔ + p0 = InnerLayer.GGJ.solution_profile(r0) + profΨ, profΞ, xg = p0.Ψ, p0.Ξ, p0.s + else + # Inner-solve knobs matched to the Fortran rmatch deltac/inps reference (DELTAC_LIST): inps + # basis, inps_xfac=10 (xmax×10, grid nx = 128·10 = 1280), nq=5, cutoff=5, order_pow=8 (↔ kmax). + Δ, _, prof, _ = InnerLayer.GGJ.solve_inner_profile(params, γ; xfac=10.0, nx=1280, nq=5, cutoff=5, kmax=8) # (Δ₁, Δ₂) in deltac.f convention + deltar[i, 1] = Δ[1] + deltar[i, 2] = Δ[2] + profΨ, profΞ, xg = prof.Ψ, prof.Ξ, prof.x + end x0i = InnerLayer.GGJ.x0(params) # Amplitude rescale: inner solutions are normalized in X = v1·δψ/x0 (inner_psi below); # converting a big-branch (μ₋ = −1/2−p1) amplitude to the outer δψ-normalization gives @@ -130,16 +152,16 @@ function gal_match_rpec(ctrl::ForceFreeStatesControl, equil, intr::ForceFreeStat # and the far-field identity Ψ = X·Ξ (GWP2016 Eq. 16; Ψ is the normal-field variable, Eq. A17): # b_m = −2πi·χ₁·n·q′·(x0/v1)·resc·Ψ. scale = -2π * chi1 * im * nn * sings[i].q1 * x0i / params.v1 - pen[i, 1] = scale * prof.Ψ[1, 1] * resc # layer center X=0, parity 1 (Ψ(0)≠0) - pen[i, 2] = scale * prof.Ψ[1, 2] * resc # parity 2 (Ψ(0)=0 ⇒ ~0) - xvar = prof.x .* (x0i / params.v1) # inner X → ψ-distance (deltac.f:1822) + pen[i, 1] = scale * profΨ[1, 1] * resc # layer center X=0, parity 1 (Ψ(0)≠0) + pen[i, 2] = scale * profΨ[1, 2] * resc # parity 2 (Ψ(0)=0 ⇒ ~0) + xvar = xg .* (x0i / params.v1) # inner X → ψ-distance (deltac.f:1822) inner_psi[i] = vcat(reverse(sings[i].psifac .- xvar), sings[i].psifac .+ xvar) - inner_odd[i] = resc .* vcat(reverse(.-prof.Ξ[:, 1]), prof.Ξ[:, 1]) # comp 2, parity 1 (odd: −left,+right) - inner_even[i] = resc .* vcat(reverse(prof.Ξ[:, 2]), prof.Ξ[:, 2]) # comp 2, parity 2 (even) + inner_odd[i] = resc .* vcat(reverse(.-profΞ[:, 1]), profΞ[:, 1]) # comp 2, parity 1 (odd: −left,+right) + inner_even[i] = resc .* vcat(reverse(profΞ[:, 2]), profΞ[:, 2]) # comp 2, parity 2 (even) # b^ψ profiles on the same two-sided grid: Ψ is the normal-field variable (GWP2016 A17), # b_m(δψ) = scale·resc·Ψ(X) throughout the layer (→ outer frozen-in relation via Ψ = XΞ). - inner_bodd[i] = (scale * resc) .* vcat(reverse(prof.Ψ[:, 1]), prof.Ψ[:, 1]) # parity 1: Ψ even - inner_beven[i] = (scale * resc) .* vcat(reverse(.-prof.Ψ[:, 2]), prof.Ψ[:, 2]) # parity 2: Ψ odd + inner_bodd[i] = (scale * resc) .* vcat(reverse(profΨ[:, 1]), profΨ[:, 1]) # parity 1: Ψ even + inner_beven[i] = (scale * resc) .* vcat(reverse(.-profΨ[:, 2]), profΨ[:, 2]) # parity 2: Ψ odd end # --- assemble the 4·msing matching system (match.f) --- From ec0f85ec07723c60f37854d55343d51e96c0cc8f Mon Sep 17 00:00:00 2001 From: Via Weber Date: Sun, 12 Jul 2026 16:34:35 -0400 Subject: [PATCH 10/38] FORCEFREESTATES - MINOR - Add delta_coil edge-BC test harness for galerkin matching --- .../gpec.toml | 1 + src/ForceFreeStates/Riccati.jl | 111 ++++++++++++------ src/GeneralizedPerturbedEquilibrium.jl | 3 +- 3 files changed, 76 insertions(+), 39 deletions(-) diff --git a/examples/DIIID-like_gal_resistive_pe_example/gpec.toml b/examples/DIIID-like_gal_resistive_pe_example/gpec.toml index 1fa209f0..fea71c80 100644 --- a/examples/DIIID-like_gal_resistive_pe_example/gpec.toml +++ b/examples/DIIID-like_gal_resistive_pe_example/gpec.toml @@ -26,6 +26,7 @@ tw = 0.05 # Sharpness of wall corners (try 0.05 as initial equal_arc_wall = true # Equal arc length distribution of nodes on wall [ForceFreeStates] +HDF5_filename = "gpec_stride.h5" # write our STRIDE output here, preserving the galerkin reference gpec.h5 local_stability_flag = true # Perform local stability analysis (Mercier and ballooning) across the ψ profile mat_flag = true # Construct coefficient matrices for diagnostic purposes (required by gal_flag) ode_flag = true # Integrate ODE's for determining stability of internal long-wavelength mode (must be true for GPEC) diff --git a/src/ForceFreeStates/Riccati.jl b/src/ForceFreeStates/Riccati.jl index 80664851..1cb301c1 100644 --- a/src/ForceFreeStates/Riccati.jl +++ b/src/ForceFreeStates/Riccati.jl @@ -336,8 +336,23 @@ function compute_delta_prime_matrix!( # rpec coil-response loop needs the vacuum edge (col_edge coupled in junc_rows[1:N]) and the # S-axis row layout that `loop_edge_boundary_conditions` assumes for its edge-BC rows. - if use_S_axis && wv !== nothing - intr.delta_coil_matrix = loop_edge_boundary_conditions(M, col_edge, msing, N, ipert_all) + # (The edge BC / RHS formulation itself is chosen inside that function via ENV["DELTACOIL_MODE"].) + if use_S_axis && wv !== nothing #only run the coil loop on the S-axis path with a vacuum edge (real W_V present) + #KNOB — Q1 per-surface normalization. snorm = |n·q'|^α (derived: sing_get_ua small basis ~ dpsi^α at + #dpsi ∝ 1/(n·q'), α = Mercier exponent). To test a different normalization, change `alpha`/the power below. + nq = [abs(Float64(minimum(sing[j].n)) * Float64(sing[j].q1)) for j in 1:msing] #per surface: |n·q'| (n = toroidal mode, q' = dq/dψ at the surface) + alpha = (ctrl !== nothing && equil !== nothing && ffit !== nothing) ? #if we have the inputs to rebuild the asymptotics... + [real(compute_sing_asymptotics(sing[j], ctrl, equil, ffit, intr; sig=1.0).alpha[1]) for j in 1:msing] : #...compute α = Mercier exponent per surface (√−D_I) + fill(0.55, msing) #...otherwise fall back to a typical α ≈ 0.55 + snorm = nq .^ alpha #the derived per-surface factor: snorm[j] = |n·q'|^α_j (EDIT exponent to retune) + # DELTACOIL_COMP tests the ξ-vs-ξ' basis convention: multiply the small coeff by dpsi^p per surface + # (dpsi = singfac_min/|n·q'|; reading ξ' vs ξ differs by one dpsi). "xi"(default)=p0, "xip"=+1, "xip_half"=+0.5, "xi_neg"=-1. + comp = get(ENV, "DELTACOIL_COMP", "xi") + pcomp = comp == "xip" ? 1.0 : comp == "xip_half" ? 0.5 : comp == "xi_neg" ? -1.0 : 0.0 + if pcomp != 0.0 && ctrl !== nothing + snorm = snorm .* (ctrl.singfac_min ./ nq) .^ pcomp #apply dpsi^p (ξ→ξ' relative scaling) + end + intr.delta_coil_matrix = loop_edge_boundary_conditions(M, col_edge, msing, N, ipert_all, snorm) #run the coil loop and store the result on the internal state end intr.delta_prime_matrix = _solve_bvp_and_combine_pest3( @@ -634,57 +649,77 @@ function _assemble_bvp_S_axis(uShootR::Vector{Matrix{ComplexF64}}, return M, nMat, col_edge end -# Loop the 2N boundary conditions of Eq. (37) [Glasser-Kolemen 2018 PoP 25, 032501]. -# M is the BVP matrix from _assemble_bvp_S_axis. The last 2*msing rows at the driving rows: -# setting one to 1 assigns that big-solution coefficients. Solve once per boundary condition -# and collect the small-solution coefficients into the raw D' matrix. -function loop_boundary_conditions(M::Matrix{ComplexF64}, msing::Int, N::Int, - ipert_all::Vector{Int}) - nMat = size(M, 1) - s2 = 2 * msing - M_lu = lu(M) - dp_raw = zeros(ComplexF64, s2, s2) +# Δ' loop: drive each of the 2·msing big-solution BCs of Eq. (37) in turn, collect the small-solution +# (+N slot) coeffs at every surface into the raw Δ' matrix [Glasser-Kolemen 2018 PoP 25, 032501]. +function loop_boundary_conditions(M::Matrix{ComplexF64}, msing::Int, N::Int, ipert_all::Vector{Int}) + nMat = size(M, 1) #BVP unknowns = (2 + 4·msing)·N + s2 = 2 * msing #number of BCs (left/right per surface) + M_lu = lu(M) #factorize the full Eq.(37) matrix once; only the RHS changes per BC + dp_raw = zeros(ComplexF64, s2, s2) #raw Δ': rows = driver BC, cols = surface side b = zeros(ComplexF64, nMat) - for jsing in 1:msing, side in 1:2 - dRow = 2jsing - (2 - side) + dRow = 2jsing - (2 - side) #BC index: surface jsing, side 1=left/2=right fill!(b, 0) - b[nMat-s2+dRow] = 1 + b[nMat-s2+dRow] = 1 #drive this big-solution coeff = 1 (bottom driving rows) x = M_lu \ b - for ksing in 1:msing ipert_k = ipert_all[ksing] - dp_raw[dRow, 2ksing-1] = x[_col_left(ksing, N)[ipert_k+N]] - dp_raw[dRow, 2ksing] = x[_col_right(ksing, N)[ipert_k+N]] + dp_raw[dRow, 2ksing-1] = x[_col_left(ksing, N)[ipert_k+N]] #left small-solution coeff + dp_raw[dRow, 2ksing] = x[_col_right(ksing, N)[ipert_k+N]] #right small-solution coeff end end return dp_raw end -# Coil-response loop (galerkin gal_rpec convention) for the edge block of Eq. (37) [Glasser-Kolemen -# 2018 PoP 25 082502]. For each edge poloidal mode k, pin the edge to a Dirichlet identity and drive -# α_edge = e_k as a unit source, solve the square system (block-6 driving rows held at 0), and read the -# resonant small-solution coefficient at each surface → delta_coil (2·msing × N). Pass the vacuum M. +# Coil-response loop for the Eq. (37) edge block [Glasser-Kolemen 2018 PoP 25 082502]: for each edge poloidal +# mode k, copy the full BVP matrix, impose the Eq. (38) edge BC via `bc!`, solve, and read the snorm-scaled +# small-solution coeff (+N slot) at every surface → delta_coil (2·msing × N). To change the boundary condition, +# edit `bc!` below — it mutates the copy Mc for edge mode k and returns the RHS to drive (return `nothing` for a +# homogeneous null-space solve). top/bot = the Eq. (38) 1_M-identity / -W_V edge row blocks; col_edge = edge cols. function loop_edge_boundary_conditions(M::Matrix{ComplexF64}, col_edge, msing::Int, N::Int, - ipert_all::Vector{Int}) + ipert_all::Vector{Int}, snorm::Vector{Float64}) nMat = size(M, 1) - edge_rows = (nMat-2msing-N+1):(nMat-2msing) # wall edge-BC rows of the last surface - Mc = copy(M) - Mc[edge_rows, :] .= 0 - Mc[edge_rows, col_edge] .= Matrix{ComplexF64}(I, N, N) # α_edge = RHS - Mc_lu = lu(Mc) - - delta_coil = zeros(ComplexF64, 2msing, N) - rhs = zeros(ComplexF64, nMat) + top = (nMat-2msing-2N+1):(nMat-2msing-N) #Eq.38 top rows (1_M identity) + bot = (nMat-2msing-N+1):(nMat-2msing) #Eq.38 bottom rows (-W_V) + # DELTACOIL_EDGE selects the edge BC to test against galerkin (default "driven"). bc! mutates the copy Mc for + # edge mode k and returns the RHS (or nothing for a homogeneous null-space solve): + # "driven" – replace the W_V bottom with a Dirichlet identity (α_edge = RHS), unit drive at bot[k]. Well-posed. + # "wv" – keep the assembled true W_V edge untouched, unit drive at bot[k] (galerkin's vacuum edge). + # "wv_top" – keep the true W_V edge, drive via the top (1_M) row top[k] instead. + # "wv_null" – keep the true W_V edge untouched, RHS = 0 → svd null space (DEGENERATE: Mc is k-independent so + # all 24 columns come out identical, and M is nonsingular so the "null" vector is just noise). + # "wv_null_pin" – keep the true W_V edge but replace the k-th driving row with a unit pin on edge mode k, RHS = 0. + # This makes Mc k-dependent and rank-deficient, so svd null space is a physical per-mode mode. + edge = get(ENV, "DELTACOIL_EDGE", "driven") + drive_rows = (nMat-2msing+1):nMat #Eq.37 driving rows (pin the resonant big-solution coeffs) + function bc!(Mc, k) + edge == "wv_null" && return nothing #true W_V edge untouched, homogeneous (all-zero RHS) → null space + if edge == "wv_null_pin" + Mc[drive_rows, :] .= 0 #drop the big-solution driving constraints + Mc[drive_rows[1], col_edge[k]] = 1 #pin edge mode k in their place → k-dependent, rank-deficient Mc + return nothing #RHS = 0 → svd null space (now physical, per edge mode k) + end + rhs = zeros(ComplexF64, nMat) + if edge == "driven" + Mc[bot, :] .= 0 + Mc[bot, col_edge] .= I(N) #Dirichlet identity edge: α_edge = RHS + rhs[bot[k]] = 1 #unit drive on edge mode k + elseif edge == "wv_top" + rhs[top[k]] = 1 #drive the true W_V edge via the top 1_M row + else #"wv": drive the true W_V edge via the bottom row + rhs[bot[k]] = 1 + end + return rhs + end + delta_coil = zeros(ComplexF64, 2msing, N) #rows = surface side, cols = edge mode k for k in 1:N - fill!(rhs, 0) - rhs[edge_rows[k]] = 1 # unit edge source: drive α_edge = e_k (galerkin rpec convention) - x = Mc_lu \ rhs - - for j in 1:msing # read resonant small solution at each surface + Mc = copy(M) #fresh full Eq.(37) matrix per mode k + rhs = bc!(Mc, k) + x = rhs === nothing ? svd(Mc).V[:, end] : lu(Mc) \ rhs + for j in 1:msing ipert_j = ipert_all[j] - delta_coil[2j-1, k] = x[_col_left(j, N)[ipert_j+N]] - delta_coil[2j, k] = x[_col_right(j, N)[ipert_j+N]] + delta_coil[2j-1, k] = snorm[j] * x[_col_left(j, N)[ipert_j+N]] + delta_coil[2j, k] = snorm[j] * x[_col_right(j, N)[ipert_j+N]] end end return delta_coil diff --git a/src/GeneralizedPerturbedEquilibrium.jl b/src/GeneralizedPerturbedEquilibrium.jl index 781760a9..46f09796 100755 --- a/src/GeneralizedPerturbedEquilibrium.jl +++ b/src/GeneralizedPerturbedEquilibrium.jl @@ -81,7 +81,7 @@ function main(args::Vector{String}=String[]; dd::Union{IMASdd.dd,Nothing}=nothin # Read input data and set up data structures intr = ForceFreeStatesInternal(; dir_path=path) inputs = TOML.parsefile(joinpath(intr.dir_path, "gpec.toml")) - ctrl = ForceFreeStatesControl(; (Symbol(k) => v for (k, v) in inputs["ForceFreeStates"])...) + ctrl = ForceFreeStatesControl(; (Symbol(k) => v for (k, v) in inputs["ForceFreeStates"] if Symbol(k) in fieldnames(ForceFreeStatesControl))...) # Set up equilibrium from gpec.toml or fallback to equil.toml if it exists. # Analytic equilibria ("tj_analytic", "tj_analytic_direct", "sol", "lar") can @@ -618,6 +618,7 @@ function write_outputs_to_HDF5( # Edge coil-response matrix (2msing × numpert_total) from the Eq. (37) edge-BC loop. if intr.msing > 0 && !isempty(intr.delta_coil_matrix) out_h5["singular/delta_coil_matrix"] = intr.delta_coil_matrix + out_h5["singular/delta_coil_abs"] = abs.(intr.delta_coil_matrix) # real |.| for H5Web heatmap view end # Write kinetic singular surface data (det(F̄) near-zeros) and the cond(F̄) scan From 91c03c7ca41324314b16dcbd783302fa5061f952 Mon Sep 17 00:00:00 2001 From: Via Weber Date: Sun, 12 Jul 2026 18:24:21 -0400 Subject: [PATCH 11/38] FORCEFREESTATES - MINOR - test runs for delta_coil --- src/ForceFreeStates/Riccati.jl | 63 +++++++--------------------------- 1 file changed, 13 insertions(+), 50 deletions(-) diff --git a/src/ForceFreeStates/Riccati.jl b/src/ForceFreeStates/Riccati.jl index 1cb301c1..3de5e848 100644 --- a/src/ForceFreeStates/Riccati.jl +++ b/src/ForceFreeStates/Riccati.jl @@ -336,22 +336,14 @@ function compute_delta_prime_matrix!( # rpec coil-response loop needs the vacuum edge (col_edge coupled in junc_rows[1:N]) and the # S-axis row layout that `loop_edge_boundary_conditions` assumes for its edge-BC rows. - # (The edge BC / RHS formulation itself is chosen inside that function via ENV["DELTACOIL_MODE"].) if use_S_axis && wv !== nothing #only run the coil loop on the S-axis path with a vacuum edge (real W_V present) - #KNOB — Q1 per-surface normalization. snorm = |n·q'|^α (derived: sing_get_ua small basis ~ dpsi^α at - #dpsi ∝ 1/(n·q'), α = Mercier exponent). To test a different normalization, change `alpha`/the power below. + #Q1 per-surface normalization: snorm = |n·q'|^α (derived: sing_get_ua small basis ~ dpsi^α at + #dpsi ∝ 1/(n·q'), α = Mercier exponent). nq = [abs(Float64(minimum(sing[j].n)) * Float64(sing[j].q1)) for j in 1:msing] #per surface: |n·q'| (n = toroidal mode, q' = dq/dψ at the surface) alpha = (ctrl !== nothing && equil !== nothing && ffit !== nothing) ? #if we have the inputs to rebuild the asymptotics... [real(compute_sing_asymptotics(sing[j], ctrl, equil, ffit, intr; sig=1.0).alpha[1]) for j in 1:msing] : #...compute α = Mercier exponent per surface (√−D_I) fill(0.55, msing) #...otherwise fall back to a typical α ≈ 0.55 - snorm = nq .^ alpha #the derived per-surface factor: snorm[j] = |n·q'|^α_j (EDIT exponent to retune) - # DELTACOIL_COMP tests the ξ-vs-ξ' basis convention: multiply the small coeff by dpsi^p per surface - # (dpsi = singfac_min/|n·q'|; reading ξ' vs ξ differs by one dpsi). "xi"(default)=p0, "xip"=+1, "xip_half"=+0.5, "xi_neg"=-1. - comp = get(ENV, "DELTACOIL_COMP", "xi") - pcomp = comp == "xip" ? 1.0 : comp == "xip_half" ? 0.5 : comp == "xi_neg" ? -1.0 : 0.0 - if pcomp != 0.0 && ctrl !== nothing - snorm = snorm .* (ctrl.singfac_min ./ nq) .^ pcomp #apply dpsi^p (ξ→ξ' relative scaling) - end + snorm = nq .^ alpha #the derived per-surface factor: snorm[j] = |n·q'|^α_j intr.delta_coil_matrix = loop_edge_boundary_conditions(M, col_edge, msing, N, ipert_all, snorm) #run the coil loop and store the result on the internal state end @@ -671,51 +663,22 @@ function loop_boundary_conditions(M::Matrix{ComplexF64}, msing::Int, N::Int, ipe return dp_raw end -# Coil-response loop for the Eq. (37) edge block [Glasser-Kolemen 2018 PoP 25 082502]: for each edge poloidal -# mode k, copy the full BVP matrix, impose the Eq. (38) edge BC via `bc!`, solve, and read the snorm-scaled -# small-solution coeff (+N slot) at every surface → delta_coil (2·msing × N). To change the boundary condition, -# edit `bc!` below — it mutates the copy Mc for edge mode k and returns the RHS to drive (return `nothing` for a -# homogeneous null-space solve). top/bot = the Eq. (38) 1_M-identity / -W_V edge row blocks; col_edge = edge cols. +# Coil-response loop for the Eq. (37) edge block Φ(a₂,1) [Glasser-Kolemen 2018 PoP 25 082502]: for each edge poloidal +# mode k, copy the full BVP matrix, impose the Eq. (38) edge BC (top 1_N identity pinned at mode k, bottom -W_V = 0), +# with the entire RHS = 0 → svd null-space solve, and read the snorm-scaled small-solution coeff (+N slot) at every +# surface → delta_coil (2·msing × N). function loop_edge_boundary_conditions(M::Matrix{ComplexF64}, col_edge, msing::Int, N::Int, ipert_all::Vector{Int}, snorm::Vector{Float64}) nMat = size(M, 1) - top = (nMat-2msing-2N+1):(nMat-2msing-N) #Eq.38 top rows (1_M identity) + top = (nMat-2msing-2N+1):(nMat-2msing-N) #Eq.38 top rows (1_N identity) bot = (nMat-2msing-N+1):(nMat-2msing) #Eq.38 bottom rows (-W_V) - # DELTACOIL_EDGE selects the edge BC to test against galerkin (default "driven"). bc! mutates the copy Mc for - # edge mode k and returns the RHS (or nothing for a homogeneous null-space solve): - # "driven" – replace the W_V bottom with a Dirichlet identity (α_edge = RHS), unit drive at bot[k]. Well-posed. - # "wv" – keep the assembled true W_V edge untouched, unit drive at bot[k] (galerkin's vacuum edge). - # "wv_top" – keep the true W_V edge, drive via the top (1_M) row top[k] instead. - # "wv_null" – keep the true W_V edge untouched, RHS = 0 → svd null space (DEGENERATE: Mc is k-independent so - # all 24 columns come out identical, and M is nonsingular so the "null" vector is just noise). - # "wv_null_pin" – keep the true W_V edge but replace the k-th driving row with a unit pin on edge mode k, RHS = 0. - # This makes Mc k-dependent and rank-deficient, so svd null space is a physical per-mode mode. - edge = get(ENV, "DELTACOIL_EDGE", "driven") - drive_rows = (nMat-2msing+1):nMat #Eq.37 driving rows (pin the resonant big-solution coeffs) - function bc!(Mc, k) - edge == "wv_null" && return nothing #true W_V edge untouched, homogeneous (all-zero RHS) → null space - if edge == "wv_null_pin" - Mc[drive_rows, :] .= 0 #drop the big-solution driving constraints - Mc[drive_rows[1], col_edge[k]] = 1 #pin edge mode k in their place → k-dependent, rank-deficient Mc - return nothing #RHS = 0 → svd null space (now physical, per edge mode k) - end - rhs = zeros(ComplexF64, nMat) - if edge == "driven" - Mc[bot, :] .= 0 - Mc[bot, col_edge] .= I(N) #Dirichlet identity edge: α_edge = RHS - rhs[bot[k]] = 1 #unit drive on edge mode k - elseif edge == "wv_top" - rhs[top[k]] = 1 #drive the true W_V edge via the top 1_M row - else #"wv": drive the true W_V edge via the bottom row - rhs[bot[k]] = 1 - end - return rhs - end delta_coil = zeros(ComplexF64, 2msing, N) #rows = surface side, cols = edge mode k - for k in 1:N + for k in 1:N #loop the edge block Φ(a₂,1), one column per edge mode k Mc = copy(M) #fresh full Eq.(37) matrix per mode k - rhs = bc!(Mc, k) - x = rhs === nothing ? svd(Mc).V[:, end] : lu(Mc) \ rhs + Mc[bot, col_edge] .= 0 #Eq.38 bottom: -W_V = 0 + Mc[top, col_edge] .= 0 + Mc[top[k], col_edge[k]] = 1 #Eq.38 top: 1_N = 1 for edge mode k + x = svd(Mc).V[:, end] #RHS = 0 → null-space (smallest singular vector) for j in 1:msing ipert_j = ipert_all[j] delta_coil[2j-1, k] = snorm[j] * x[_col_left(j, N)[ipert_j+N]] From 31814dc69f7bd93539a1a4285275bed0fe1f024c Mon Sep 17 00:00:00 2001 From: Via Weber Date: Mon, 13 Jul 2026 18:22:15 -0400 Subject: [PATCH 12/38] FORCEFREESTATES - MINOR - updates comparing plots with galerkin --- src/ForceFreeStates/Riccati.jl | 101 +++++++++++++++++++++++++++------ 1 file changed, 83 insertions(+), 18 deletions(-) diff --git a/src/ForceFreeStates/Riccati.jl b/src/ForceFreeStates/Riccati.jl index 3de5e848..119d7c90 100644 --- a/src/ForceFreeStates/Riccati.jl +++ b/src/ForceFreeStates/Riccati.jl @@ -336,15 +336,37 @@ function compute_delta_prime_matrix!( # rpec coil-response loop needs the vacuum edge (col_edge coupled in junc_rows[1:N]) and the # S-axis row layout that `loop_edge_boundary_conditions` assumes for its edge-BC rows. + # (The edge BC / RHS formulation itself is chosen inside that function via ENV["DELTACOIL_MODE"].) if use_S_axis && wv !== nothing #only run the coil loop on the S-axis path with a vacuum edge (real W_V present) - #Q1 per-surface normalization: snorm = |n·q'|^α (derived: sing_get_ua small basis ~ dpsi^α at - #dpsi ∝ 1/(n·q'), α = Mercier exponent). + #KNOB — Q1 per-surface normalization. snorm = |n·q'|^α (derived: sing_get_ua small basis ~ dpsi^α at + #dpsi ∝ 1/(n·q'), α = Mercier exponent). To test a different normalization, change `alpha`/the power below. nq = [abs(Float64(minimum(sing[j].n)) * Float64(sing[j].q1)) for j in 1:msing] #per surface: |n·q'| (n = toroidal mode, q' = dq/dψ at the surface) alpha = (ctrl !== nothing && equil !== nothing && ffit !== nothing) ? #if we have the inputs to rebuild the asymptotics... [real(compute_sing_asymptotics(sing[j], ctrl, equil, ffit, intr; sig=1.0).alpha[1]) for j in 1:msing] : #...compute α = Mercier exponent per surface (√−D_I) fill(0.55, msing) #...otherwise fall back to a typical α ≈ 0.55 - snorm = nq .^ alpha #the derived per-surface factor: snorm[j] = |n·q'|^α_j - intr.delta_coil_matrix = loop_edge_boundary_conditions(M, col_edge, msing, N, ipert_all, snorm) #run the coil loop and store the result on the internal state + snorm = nq .^ alpha #the derived per-surface factor: snorm[j] = |n·q'|^α_j (EDIT exponent to retune) + # DELTACOIL_COMP tests the ξ-vs-ξ' basis convention: multiply the small coeff by dpsi^p per surface + # (dpsi = singfac_min/|n·q'|; reading ξ' vs ξ differs by one dpsi). "xi"(default)=p0, "xip"=+1, "xip_half"=+0.5, "xi_neg"=-1. + comp = get(ENV, "DELTACOIL_COMP", "xi") + pcomp = comp == "xip" ? 1.0 : comp == "xip_half" ? 0.5 : comp == "xi_neg" ? -1.0 : 0.0 + if pcomp != 0.0 && ctrl !== nothing + snorm = snorm .* (ctrl.singfac_min ./ nq) .^ pcomp #apply dpsi^p (ξ→ξ' relative scaling) + end + #DELTACOIL_PROJECT (experimental): build weak-form projection weights so the readout is the + #cell-integrated projection Σ_m W_m·x_m/W[small] (mode-mixing) instead of a single coefficient. + Wl = Wr = nothing + if get(ENV, "DELTACOIL_PROJECT", "") != "" && ctrl !== nothing && equil !== nothing && ffit !== nothing + Wl = Vector{Vector{ComplexF64}}(undef, msing) + Wr = Vector{Vector{ComplexF64}}(undef, msing) + for j in 1:msing + dlo = ctrl.singfac_min / nq[j]; dhi = dlo * 100 #resonant cell [dpsi_lo, dpsi_hi] + aR = compute_sing_asymptotics(sing[j], ctrl, equil, ffit, intr; sig=1.0) + aL = compute_sing_asymptotics(sing[j], ctrl, equil, ffit, intr; sig=-1.0) + Wr[j] = _projection_weight(aR, ipert_all[j] + N, dlo, dhi) + Wl[j] = _projection_weight(aL, ipert_all[j] + N, dlo, dhi) + end + end + intr.delta_coil_matrix = loop_edge_boundary_conditions(M, col_edge, msing, N, ipert_all, snorm; Wl, Wr) #run the coil loop end intr.delta_prime_matrix = _solve_bvp_and_combine_pest3( @@ -642,7 +664,7 @@ function _assemble_bvp_S_axis(uShootR::Vector{Matrix{ComplexF64}}, end # Δ' loop: drive each of the 2·msing big-solution BCs of Eq. (37) in turn, collect the small-solution -# (+N slot) coeffs at every surface into the raw Δ' matrix [Glasser-Kolemen 2018 PoP 25, 032501]. +# (+N slot) coeffs at every surface into the raw Δ' matrix [Glasser-Kolemen 2018]. function loop_boundary_conditions(M::Matrix{ComplexF64}, msing::Int, N::Int, ipert_all::Vector{Int}) nMat = size(M, 1) #BVP unknowns = (2 + 4·msing)·N s2 = 2 * msing #number of BCs (left/right per surface) @@ -663,26 +685,69 @@ function loop_boundary_conditions(M::Matrix{ComplexF64}, msing::Int, N::Int, ipe return dp_raw end -# Coil-response loop for the Eq. (37) edge block Φ(a₂,1) [Glasser-Kolemen 2018 PoP 25 082502]: for each edge poloidal -# mode k, copy the full BVP matrix, impose the Eq. (38) edge BC (top 1_N identity pinned at mode k, bottom -W_V = 0), -# with the entire RHS = 0 → svd null-space solve, and read the snorm-scaled small-solution coeff (+N slot) at every -# surface → delta_coil (2·msing × N). +# Weak-form projection weight (EXPERIMENTAL, ENV DELTACOIL_PROJECT): W[m] = ∫ Σ_h conj(u^S_h)·u^basis_{m,h}(ξ) +# d(dpsi) over the resonant cell — mirrors RDCON's gal_resonant cell-integrated projection (without the energy +# operator STRIDE lacks). Reading Σ_m W_m·x_m / W[small] replaces the pointwise single-coefficient read and +# mixes modes via the cross-overlaps of u^S with the big/nonresonant basis functions. +function _projection_weight(asymp::SingAsymptotics, small_col::Int, dpsi_lo::Float64, dpsi_hi::Float64) + npts = 24 + ds = exp.(range(log(dpsi_lo), log(dpsi_hi); length=npts)) + nbasis = size(asymp.vmat, 2) + W = zeros(ComplexF64, nbasis) + for t in 1:npts + ua = sing_get_ua(asymp, ds[t]) + uS = conj.(ua[:, small_col, 1]) #conj small-solution ξ component, over harmonics + dstep = t < npts ? (ds[t+1] - ds[t]) : (ds[t] - ds[t-1]) + for m in 1:nbasis + W[m] += dstep * sum(uS .* @view ua[:, m, 1]) + end + end + return W +end + +# Coil-response loop for the Eq. (37) edge block [Glasser-Kolemen 2018 PoP 25 082502]: for each edge poloidal +# mode k, copy the full BVP matrix, impose the Eq. (38) edge BC via `bc!`, solve, and read the snorm-scaled +# small-solution coeff (+N slot) at every surface → delta_coil (2·msing × N). If Wl/Wr are given (weak-form +# projection weights), read the cell-integrated projection Σ_m W_m·x_m / W[small] instead of the single coeff. function loop_edge_boundary_conditions(M::Matrix{ComplexF64}, col_edge, msing::Int, N::Int, - ipert_all::Vector{Int}, snorm::Vector{Float64}) + ipert_all::Vector{Int}, snorm::Vector{Float64}; Wl=nothing, Wr=nothing) nMat = size(M, 1) - top = (nMat-2msing-2N+1):(nMat-2msing-N) #Eq.38 top rows (1_N identity) + top = (nMat-2msing-2N+1):(nMat-2msing-N) #Eq.38 top rows (1_M identity) bot = (nMat-2msing-N+1):(nMat-2msing) #Eq.38 bottom rows (-W_V) + # DELTACOIL_EDGE selects the edge BC to test against galerkin (default "driven"). bc! mutates the copy Mc for + # edge mode k and returns the RHS (or nothing for a homogeneous null-space solve): + # "driven" – replace the W_V bottom with a Dirichlet identity (α_edge = RHS), unit drive at bot[k]. Well-posed. + # "wv" – keep the assembled true W_V edge untouched, unit drive at bot[k] (galerkin's vacuum edge). + # "wv_top" – keep the true W_V edge, drive via the top (1_M) row top[k] instead. + edge = get(ENV, "DELTACOIL_EDGE", "driven") + function bc!(Mc, k) + rhs = zeros(ComplexF64, nMat) + if edge == "driven" + Mc[bot, :] .= 0 + Mc[bot, col_edge] .= I(N) + rhs[bot[k]] = 1 #unit drive on edge mode k + elseif edge == "wv_top" + rhs[top[k]] = 1 #drive the true W_V edge via the top 1_M row + else #"wv": drive the true W_V edge via the bottom row + rhs[bot[k]] = 1 + end + return rhs + end delta_coil = zeros(ComplexF64, 2msing, N) #rows = surface side, cols = edge mode k - for k in 1:N #loop the edge block Φ(a₂,1), one column per edge mode k + for k in 1:N Mc = copy(M) #fresh full Eq.(37) matrix per mode k - Mc[bot, col_edge] .= 0 #Eq.38 bottom: -W_V = 0 - Mc[top, col_edge] .= 0 - Mc[top[k], col_edge[k]] = 1 #Eq.38 top: 1_N = 1 for edge mode k - x = svd(Mc).V[:, end] #RHS = 0 → null-space (smallest singular vector) + rhs = bc!(Mc, k) + x = rhs === nothing ? svd(Mc).V[:, end] : lu(Mc) \ rhs for j in 1:msing ipert_j = ipert_all[j] - delta_coil[2j-1, k] = snorm[j] * x[_col_left(j, N)[ipert_j+N]] - delta_coil[2j, k] = snorm[j] * x[_col_right(j, N)[ipert_j+N]] + cl = _col_left(j, N); cr = _col_right(j, N) + if Wl === nothing + delta_coil[2j-1, k] = snorm[j] * x[cl[ipert_j+N]] + delta_coil[2j, k] = snorm[j] * x[cr[ipert_j+N]] + else #weak-form cell-integrated projection (experimental) + delta_coil[2j-1, k] = snorm[j] * (sum(Wl[j] .* @view x[cl]) / Wl[j][ipert_j+N]) + delta_coil[2j, k] = snorm[j] * (sum(Wr[j] .* @view x[cr]) / Wr[j][ipert_j+N]) + end end end return delta_coil From f8423297bef3377ddb3212380e552dbd2fa312af Mon Sep 17 00:00:00 2001 From: Via Weber Date: Tue, 14 Jul 2026 14:53:47 -0400 Subject: [PATCH 13/38] FORCEFREESTATES - MINOR - additional plots created for alignment of STRIDE to RDCON --- src/GeneralizedPerturbedEquilibrium.jl | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/GeneralizedPerturbedEquilibrium.jl b/src/GeneralizedPerturbedEquilibrium.jl index 46f09796..f8763a06 100755 --- a/src/GeneralizedPerturbedEquilibrium.jl +++ b/src/GeneralizedPerturbedEquilibrium.jl @@ -615,10 +615,13 @@ function write_outputs_to_HDF5( out_h5["singular/delta_prime_matrix"] = intr.delta_prime_matrix end - # Edge coil-response matrix (2msing × numpert_total) from the Eq. (37) edge-BC loop. + # Edge coil-response matrix, stored (numpert_total × 2msing) = (edge mode, surface-side) to match + # the galerkin/delta_coil layout so H5Web heatmaps share axes (x = edge mode, y = surface-side). + # Internal intr.delta_coil_matrix stays (2msing × numpert_total); transpose only at write. if intr.msing > 0 && !isempty(intr.delta_coil_matrix) - out_h5["singular/delta_coil_matrix"] = intr.delta_coil_matrix - out_h5["singular/delta_coil_abs"] = abs.(intr.delta_coil_matrix) # real |.| for H5Web heatmap view + dc = permutedims(intr.delta_coil_matrix) + out_h5["singular/delta_coil_matrix"] = dc + out_h5["singular/delta_coil_abs"] = abs.(dc) # real |.| for H5Web heatmap view end # Write kinetic singular surface data (det(F̄) near-zeros) and the cond(F̄) scan From 63cd88d4efb5edb963ce03e17202daa4dafb2714 Mon Sep 17 00:00:00 2001 From: Via Weber Date: Wed, 15 Jul 2026 13:09:41 -0400 Subject: [PATCH 14/38] FORCEFREESTATE - Minor - began adding resonant_match_rpec + resist_eval for resistive matching --- src/ForceFreeStates/ForceFreeStates.jl | 3 + src/ForceFreeStates/ForceFreeStatesStructs.jl | 15 +++ src/ForceFreeStates/Resist.jl | 122 ++++++++++++++++++ src/ForceFreeStates/ResistiveMatch.jl | 87 +++++++++++++ src/ForceFreeStates/Riccati.jl | 22 ++++ src/GeneralizedPerturbedEquilibrium.jl | 22 +++- 6 files changed, 267 insertions(+), 4 deletions(-) create mode 100644 src/ForceFreeStates/Resist.jl create mode 100644 src/ForceFreeStates/ResistiveMatch.jl diff --git a/src/ForceFreeStates/ForceFreeStates.jl b/src/ForceFreeStates/ForceFreeStates.jl index 4af40c71..dceaebf7 100644 --- a/src/ForceFreeStates/ForceFreeStates.jl +++ b/src/ForceFreeStates/ForceFreeStates.jl @@ -15,6 +15,7 @@ using Roots import ..Equilibrium import ..Utilities import ..Vacuum +import ..InnerLayer using Printf using DoubleFloats import StaticArrays: @MMatrix @@ -33,6 +34,8 @@ include("Utils.jl") include("PowerNorm.jl") include("Free.jl") include("Riccati.jl") +include("Resist.jl") +include("ResistiveMatch.jl") # These are used for various small tolerances and root finders throughout ForceFreeStates global eps = 1e-10 diff --git a/src/ForceFreeStates/ForceFreeStatesStructs.jl b/src/ForceFreeStates/ForceFreeStatesStructs.jl index df4b4374..7a7f9cb0 100644 --- a/src/ForceFreeStates/ForceFreeStatesStructs.jl +++ b/src/ForceFreeStates/ForceFreeStatesStructs.jl @@ -201,6 +201,13 @@ A mutable struct holding internal state variables for stability calculations. looping the Eq. (37) edge boundary condition through the Riccati BVP (`loop_edge_boundary_conditions`). """ delta_coil_matrix::Matrix{ComplexF64} = Matrix{ComplexF64}(undef, 0, 0) + # Resistive inner-layer-matched coil response (Wang 2020 Eq. 11): C = -(Δ_out - Δ_in)^{-1} Δ_coil. + resonant_match_cout::Matrix{ComplexF64} = Matrix{ComplexF64}(undef, 0, 0) # outer coeffs (2msing × ncoil) + resonant_match_cin::Matrix{ComplexF64} = Matrix{ComplexF64}(undef, 0, 0) # inner coeffs (2msing × ncoil) + resonant_match_deltar::Matrix{ComplexF64} = Matrix{ComplexF64}(undef, 0, 0) # per-surface inner-layer Δ (msing × 2) + resonant_match_rpec_eig::Vector{ComplexF64} = ComplexF64[] # forced eigenvalue γ_s = 2πi·n·f + resonant_match_flux::Matrix{ComplexF64} = Matrix{ComplexF64}(undef, 0, 0) # reconnected resonant flux (2msing × ncoil) + resonant_match_residual::Float64 = NaN end """ @@ -303,6 +310,14 @@ A mutable struct containing control parameters for stability analysis, set by th populate_dense_xi::Bool = false # When use_parallel=true, set to true ONLY if a PerturbedEquilibrium pipeline will consume dense ξ. Default false avoids the ~1× parallel-BVP serial-EL re-run for non-PE runs (Δ'/vacuum/ideal-stability only). See ForceFreeStatesControl docstring for the full trade-off (et[1] convention differs by ~0.12% on DIIID between populate=true vs false). extended_precision_bvp::Bool = true # Promote Δ' BVP to Complex{Double64}; default on (Float64 drifts the imaginary Δ' by 2–5× on DIIID-class cases). fixed_axis::Bool = false + # Resistive inner-layer matching (Wang 2020 asymptotic matching): C = -(Δ_out - Δ_in(i2πf))^{-1} Δ_coil. + # Per-surface resistive inputs (core→edge), consumed by resist_eval + InnerLayer.solve_inner. + gal_match_flag::Bool = false # Enable the coil-driven outer↔inner resistive match (needs use_parallel Δ' BVP) + gal_ideal_flag::Bool = false # true → skip the inner layer (ideal limit; cout=0, bare coil columns) + gal_eta::Vector{Float64} = Float64[] # per-surface resistivity η [Ω·m] + gal_rho::Vector{Float64} = Float64[] # per-surface mass density ρ [kg/m³] + gal_rotation::Vector{Float64} = Float64[] # per-surface rotation frequency f [Hz]; forced eigenvalue γ_s = 2πi·n·f + gal_gamma::Float64 = 5.0 / 3.0 # ratio of specific heats Γ for the resistive-layer coefficients end @kwdef mutable struct FourFitVars{S<:CubicSeriesInterpolant,Opts<:NamedTuple} diff --git a/src/ForceFreeStates/Resist.jl b/src/ForceFreeStates/Resist.jl new file mode 100644 index 00000000..693eb516 --- /dev/null +++ b/src/ForceFreeStates/Resist.jl @@ -0,0 +1,122 @@ +# Resist.jl +# +# Per-rational-surface resistive inner-layer parameters (Glasser–Greene–Johnson). +# Julia port of RDCON's `resist_eval` (resist.f): computes the flux-surface-averaged +# coefficients E, F, H, M, G, K and the local time scales τ_A, τ_R at a singular surface. +# +# Returns an `InnerLayer.GGJParameters`, so a resistive ForceFreeStates run can pass its +# per-surface coefficients straight to the GGJ inner solver. (InnerLayer is included first +# so this type is available here.) +# +# Resistivity η and mass density ρ are required per-surface inputs. The Fortran Spitzer block +# (hardcoded ne/Te → η, ρ) is intentionally not ported; the caller supplies η and ρ from its +# own transport/profile model. τ_A and τ_R are built from a purely geometric factor times the +# explicit η and ρ (no RMATCH-style divide-out / re-multiply). +# +# Deliberate differences from the Fortran reference: +# - Rational surfaces lie between grid nodes (q = m/n), so geometry is evaluated with the +# callable bicubic interpolants at the exact (ψ_s, θ) — using DerivOp(0,1) for θ-derivatives — +# rather than the nodal-grid access used by mercier_scan!. +# - η and ρ are caller-supplied (no Spitzer model, no hardcoded defaults). +# +# Reference: Glasser, Greene & Johnson, Phys. Fluids 18, 875 (1975); Glasser, PoP 23, 072505 (2016). + +""" + resist_eval(sing, equil, intr; eta, rho, gamma, ising=0) -> InnerLayer.GGJParameters + +Compute the GGJ resistive inner-layer parameters at the rational surface `sing`. +Port of RDCON `resist_eval` (resist.f), but with η and ρ supplied by the caller (no +built-in Spitzer/density model). Returns an `InnerLayer.GGJParameters` carrying the curvature/mass +coefficients E, F, G, H, K, M, the local time scales τ_A (`taua`), τ_R (`taur`), `v1` (V′ normalized +by total volume), and the surface index `ising`. + +# Required keyword arguments + - `eta::Real` — plasma resistivity η at this surface [Ω·m] + - `rho::Real` — mass density ρ at this surface [kg/m³] + - `gamma::Real` — ratio of specific heats (enters G; physical thermodynamic value, e.g. 5/3) + +# Physics +The six flux-surface averages (Fortran `avg(1:6)`) are +`⟨B²/|∇ψ|²⟩, ⟨1/|∇ψ|²⟩, ⟨1/B²⟩, ⟨1/(B²|∇ψ|²)⟩, ⟨B²⟩, ⟨|∇ψ|²/B²⟩`, each weighted by `J/V′` +and integrated over θ ∈ [0,1) — exactly the resist.f integrands. The timescales are +`τ_A = √(ρ·M·μ₀) / |2π n q₁ χ₁ / V′|` and `τ_R = (⟨B²/|∇ψ|²⟩/⟨B²⟩)·μ₀/η`, with η and ρ entering +once, explicitly. +""" +function resist_eval(sing::SingType, equil::Equilibrium.PlasmaEquilibrium, + intr::ForceFreeStatesInternal; eta::Real, rho::Real, gamma::Real, ising::Int=0) + + profiles = equil.profiles + psifac = sing.psifac + nn = sing.n[1] + + # --- 1D surface quantities at the (off-grid) rational ψ --- + hint = Ref(1) + twopif = profiles.F_spline(psifac; hint=hint) + p = profiles.P_spline(psifac; hint=hint) + p1 = profiles.P_deriv(psifac; hint=hint) + v1 = profiles.dVdpsi_spline(psifac; hint=hint) + v2 = profiles.dVdpsi_deriv(psifac; hint=hint) + q = sing.q + q1 = sing.q1 + chi1 = 2π * equil.psio + + # --- θ-integrate the six geometric averages (resist.f) --- + nth = length(equil.rzphi_ys) + ff = zeros(nth, 6) + hint2d = (Ref(1), Ref(1)) + for itheta in 1:nth + theta = equil.rzphi_ys[itheta] + f1 = equil.rzphi_rsquared((psifac, theta); hint=hint2d) + f2 = equil.rzphi_offset((psifac, theta); hint=hint2d) + jac = equil.rzphi_jac((psifac, theta); hint=hint2d) + fy1 = equil.rzphi_rsquared((psifac, theta); deriv=DerivOp(0, 1), hint=hint2d) + fy2 = equil.rzphi_offset((psifac, theta); deriv=DerivOp(0, 1), hint=hint2d) + fy3 = equil.rzphi_nu((psifac, theta); deriv=DerivOp(0, 1), hint=hint2d) + + rfac = sqrt(f1) + eta_geo = 2π * (theta + f2) # geometric poloidal angle (local; not the resistivity η) + r = equil.ro + rfac * cos(eta_geo) + + v21 = fy1 / (2.0 * rfac * jac) + v22 = (1.0 + fy2) * 2π * rfac / jac + v23 = fy3 * r / jac + v33 = 2π * r / jac + bsq = chi1^2 * (v21^2 + v22^2 + (v23 + q * v33)^2) + dpsisq = (2π * r)^2 * (v21^2 + v22^2) + + ff[itheta, 1] = bsq / dpsisq + ff[itheta, 2] = 1.0 / dpsisq + ff[itheta, 3] = 1.0 / bsq + ff[itheta, 4] = 1.0 / (bsq * dpsisq) + ff[itheta, 5] = bsq + ff[itheta, 6] = dpsisq / bsq + @views ff[itheta, :] .*= jac / v1 + end + itp = cubic_interp(equil.rzphi_ys, Series(ff); bc=PeriodicBC()) + avg = FastInterpolations.integrate(itp) + + # --- curvature terms E, F, H (resist.f) --- + E = p1 * v1 / (q1 * chi1^2)^2 * avg[1] * (twopif * q1 * chi1 / avg[5] - v2) + F = (p1 * v1 / (q1 * chi1^2))^2 * (avg[1] * avg[3] + (twopif / chi1)^2 * (avg[1] * avg[4] - avg[2]^2)) + H = twopif * p1 * v1 / (q1 * chi1^3) * (avg[2] - avg[1] / avg[5]) + + # --- mass factor M and derived G, K (resist.f) --- + M = avg[1] * (avg[6] + (twopif / chi1)^2 * (avg[3] - 1 / avg[5])) + G = avg[5] / (M * gamma * p) + K = (q1 * chi1^2 / (p1 * v1))^2 * avg[5] / (M * avg[1]) + + # --- Alfvén and resistive times from caller-supplied η, ρ (resist.f) --- + # τ_A = √(ρ·M·μ₀) / |2π n q₁ χ₁ / V′| — geometric factor × √ρ. + # τ_R = (⟨B²/|∇ψ|²⟩ / ⟨B²⟩) · μ₀ / η — geometric factor × 1/η. + # η, ρ enter once, explicitly (no RMATCH divide-out / re-multiply). + mu0 = 4π * 1e-7 + geom_taua = sqrt(M * mu0) / abs(2π * nn * q1 * chi1 / v1) + taua = sqrt(rho) * geom_taua + geom_taur = avg[1] / avg[5] * mu0 + taur = geom_taur / eta + + v1norm = equil.params.volume === nothing ? v1 : v1 / equil.params.volume + + return InnerLayer.GGJParameters(; E=E, F=F, G=G, H=H, K=K, M=M, + taua=taua, taur=taur, v1=v1norm, ising=ising) +end diff --git a/src/ForceFreeStates/ResistiveMatch.jl b/src/ForceFreeStates/ResistiveMatch.jl new file mode 100644 index 00000000..5cad58bc --- /dev/null +++ b/src/ForceFreeStates/ResistiveMatch.jl @@ -0,0 +1,87 @@ +# ResistiveMatch.jl +# +# Coil-driven outer↔inner resistive asymptotic matching for the STRIDE/Riccati path. +# Implements Wang et al., Phys. Plasmas 27, 122509 (2020), Eq. (11): +# +# (Δ_out − Δ_in(i2πf)) · C = −Δ_coil, C = −(Δ_out − Δ_in(i2πf))^{-1} · Δ_coil +# +# Δ_out is the ideal outer-region Δ′ small-solution block, Δ_in(i2πf) is the resistive inner-layer +# matching data (InnerLayer.solve_inner at the rpec forced eigenvalue γ = 2πi·n·f), and Δ_coil is +# the small-solution contribution induced by the external coil perturbations. +# +# Mirrors RDCON's `gal_match_rpec` (Fortran rmatch `match_rpec`, match.f). Key normalization point +# (verified against Wang 2020 + gal_match_rpec): Δ_out and Δ_coil must share the SAME normalization, +# so both use STRIDE's RAW small-solution coefficients — +# Δ_out = dp_raw (loop_boundary_conditions, big-solution-driven raw coeffs) +# Δ_coil = raw edge-driven coeffs (loop_edge_boundary_conditions with snorm = 1) +# The `snorm = |n·q'|^α` used for the delta_coil↔galerkin comparison is not part of the matching +# normalization and is deliberately omitted. + +"Result of the coil-driven outer↔inner resistive match (Wang 2020 Eq. 11)." +struct ResonantMatchResult + cout::Matrix{ComplexF64} # outer coefficients (2·msing × ncoil) + cin::Matrix{ComplexF64} # inner coefficients (2·msing × ncoil) + deltar::Matrix{ComplexF64} # per-surface inner-layer Δ (msing × 2), deltac.f convention + rpec_eig::Vector{ComplexF64} # forced eigenvalue γ_s = 2πi·n·f per surface + reconnected_flux::Matrix{ComplexF64} # matched small-solution (reconnected) amplitude (2·msing × ncoil) + residual::Float64 # relative solve residual ‖mat·cof − rmat‖ / ‖rmat‖ +end + +""" + resonant_match_rpec(delta_out_raw, delta_coil_raw, sings, equil, intr, ctrl) -> ResonantMatchResult + +Assemble and solve the 4·msing coil-driven matching system (Wang 2020 Eq. 11; match.f) from STRIDE's +raw outer Δ′ block and raw Δ_coil, plus the per-surface resistive inner-layer Δ (resist_eval → +InnerLayer.solve_inner). `delta_out_raw` is (2msing × 2msing) indexed [driver, surface]; `delta_coil_raw` +is (2msing × ncoil) indexed [surface, coil-mode]. +""" +function resonant_match_rpec(delta_out_raw::AbstractMatrix, delta_coil_raw::AbstractMatrix, + sings::Vector{SingType}, equil::Equilibrium.PlasmaEquilibrium, + intr::ForceFreeStatesInternal, ctrl::ForceFreeStatesControl) + + msing = length(sings) + ncoil = size(delta_coil_raw, 2) + nn = intr.nlow + + if ctrl.gal_ideal_flag # ideal limit: skip the inner layer, cout = 0 + # reconnected flux collapses to the bare coil small-solution amplitude (no reconnection) + return ResonantMatchResult(zeros(ComplexF64, 2msing, ncoil), zeros(ComplexF64, 2msing, ncoil), + zeros(ComplexF64, msing, 2), zeros(ComplexF64, msing), Matrix{ComplexF64}(delta_coil_raw), 0.0) + end + for (nm, v) in (("gal_eta", ctrl.gal_eta), ("gal_rho", ctrl.gal_rho), ("gal_rotation", ctrl.gal_rotation)) + length(v) == msing || error("resonant_match_rpec: $nm has length $(length(v)), expected msing=$msing (one per surface, core→edge)") + end + + # --- per-surface resistive inner-layer matching data Δ(Q) (Wang 2020; deltac.f) --- + inner = InnerLayer.GGJModel(; solver=:galerkin) + deltar = zeros(ComplexF64, msing, 2) + rpec_eig = zeros(ComplexF64, msing) + for i in 1:msing + params = resist_eval(sings[i], equil, intr; eta=ctrl.gal_eta[i], rho=ctrl.gal_rho[i], gamma=ctrl.gal_gamma, ising=i) + γ = 2π * im * nn * ctrl.gal_rotation[i] # rpec forced eigenvalue 2πi·n·f + rpec_eig[i] = γ + Δ = InnerLayer.solve_inner(inner, params, γ) + deltar[i, 1] = Δ[1] + deltar[i, 2] = Δ[2] + end + + # --- assemble the 4·msing matching system mat·[cout; cin] = rmat (match.f) --- + mat = zeros(ComplexF64, 4msing, 4msing) + rmat = zeros(ComplexF64, 4msing, ncoil) + @views mat[2msing+1:4msing, 1:2msing] .= transpose(delta_out_raw) # Δ_out (raw dp_raw block) + @views rmat[2msing+1:4msing, :] .= .-delta_coil_raw # −Δ_coil (STRIDE (2msing,ncoil)) + for ising in 1:msing + idx1 = 2ising - 1; idx2 = 2ising + idx3 = idx1 + 2msing; idx4 = idx2 + 2msing + d1 = deltar[ising, 1]; d2 = deltar[ising, 2] + mat[idx1, idx1] = 1; mat[idx2, idx2] = 1 + mat[idx1, idx3] = -1; mat[idx1, idx4] = 1 + mat[idx2, idx3] = -1; mat[idx2, idx4] = -1 + mat[idx3, idx3] = -d1; mat[idx3, idx4] = d2 # Δ_in blocks + mat[idx4, idx3] = -d1; mat[idx4, idx4] = -d2 + end + + cof = mat \ rmat + residual = norm(mat * cof - rmat) / max(norm(rmat), 1e-300) + return ResonantMatchResult(cof[1:2msing, :], cof[2msing+1:4msing, :], deltar, rpec_eig, residual) +end diff --git a/src/ForceFreeStates/Riccati.jl b/src/ForceFreeStates/Riccati.jl index 119d7c90..a743dff7 100644 --- a/src/ForceFreeStates/Riccati.jl +++ b/src/ForceFreeStates/Riccati.jl @@ -371,6 +371,28 @@ function compute_delta_prime_matrix!( intr.delta_prime_matrix = _solve_bvp_and_combine_pest3( M, msing, N, nMat, use_S_axis, ipert_all, col_edge, ctrl, debug) + + # Resistive inner-layer matching (Wang 2020 Eq. 11): feed the RAW outer Δ' block (dp_raw) and the + # RAW edge-driven Δ_coil (snorm = 1, so both share one normalization — Step-1 result) into the + # coil-driven outer↔inner match. Gated on ctrl.gal_match_flag; needs the S-axis vacuum-edge BVP. + if use_S_axis && wv !== nothing && ctrl !== nothing && ctrl.gal_match_flag && equil !== nothing + delta_out_raw = loop_boundary_conditions(M, msing, N, ipert_all) #raw Δ_out (2msing×2msing) + delta_coil_raw = loop_edge_boundary_conditions(M, col_edge, msing, N, ipert_all, ones(Float64, msing)) #raw Δ_coil (snorm=1) + mres = resonant_match_rpec(delta_out_raw, delta_coil_raw, sing, equil, intr, ctrl) + intr.resonant_match_cout = mres.cout + intr.resonant_match_cin = mres.cin + intr.resonant_match_deltar = mres.deltar + intr.resonant_match_rpec_eig = mres.rpec_eig + intr.resonant_match_flux = mres.reconnected_flux + intr.resonant_match_residual = mres.residual + @info @sprintf("Resistive inner-layer match: residual=%.2e, ‖cout‖=%.3e, ‖cin‖=%.3e (%d surfaces, %d coil modes)", + mres.residual, norm(mres.cout), norm(mres.cin), msing, size(mres.cout, 2)) + for i in 1:msing + @info @sprintf(" surf %d (q=%.3g): Δ_in=(%.3e%+.3ei, %.3e%+.3ei) γ=%.3e%+.3ei", + i, sing[i].q, real(mres.deltar[i,1]), imag(mres.deltar[i,1]), real(mres.deltar[i,2]), imag(mres.deltar[i,2]), + real(mres.rpec_eig[i]), imag(mres.rpec_eig[i])) + end + end end # Column index helpers for the BVP matrix. j is the 1-based singular-surface index, diff --git a/src/GeneralizedPerturbedEquilibrium.jl b/src/GeneralizedPerturbedEquilibrium.jl index f8763a06..84a95f68 100755 --- a/src/GeneralizedPerturbedEquilibrium.jl +++ b/src/GeneralizedPerturbedEquilibrium.jl @@ -13,14 +13,14 @@ include("Vacuum/Vacuum.jl") import .Vacuum as Vacuum export Vacuum -include("ForceFreeStates/ForceFreeStates.jl") -import .ForceFreeStates as ForceFreeStates -export ForceFreeStates - include("InnerLayer/InnerLayer.jl") import .InnerLayer as InnerLayer export InnerLayer +include("ForceFreeStates/ForceFreeStates.jl") +import .ForceFreeStates as ForceFreeStates +export ForceFreeStates + include("ForcingTerms/ForcingTerms.jl") import .ForcingTerms as ForcingTerms export ForcingTerms @@ -624,6 +624,20 @@ function write_outputs_to_HDF5( out_h5["singular/delta_coil_abs"] = abs.(dc) # real |.| for H5Web heatmap view end + # Resistive inner-layer-matched coil response (Wang et al. PoP 27, 122509 (2020), Eq. 11: + # C = -(Δ_out - Δ_in(i2πf))^{-1} Δ_coil). Computed in ResistiveMatch.jl via the STRIDE/Riccati + # outer Δ' + the GGJ inner layer (resist_eval → InnerLayer.solve_inner). + if intr.msing > 0 && !isempty(intr.resonant_match_deltar) + g = create_group(out_h5, "singular/resonant_match") + g["deltar"] = intr.resonant_match_deltar # (msing × 2) per-surface inner-layer Δ(Q) + g["cout"] = intr.resonant_match_cout # (2msing × ncoil) matched outer coefficients + g["cin"] = intr.resonant_match_cin # (2msing × ncoil) matched inner coefficients + g["rpec_eig"] = intr.resonant_match_rpec_eig # (msing) forced eigenvalue γ_s = 2πi·n·f + g["reconnected_flux"] = intr.resonant_match_flux # (2msing × ncoil) matched small-solution (reconnected) amplitude + g["reconnected_flux_abs"] = abs.(intr.resonant_match_flux) # |.| for H5Web heatmap view + g["residual"] = intr.resonant_match_residual + end + # Write kinetic singular surface data (det(F̄) near-zeros) and the cond(F̄) scan # used to find them. Populated only when kinetic crossings were searched for. out_h5["singular/kinetic/kmsing"] = intr.kmsing From bdae287ed5a70e3f100d91a97450102b87f599e1 Mon Sep 17 00:00:00 2001 From: Via Weber Date: Fri, 17 Jul 2026 10:11:41 -0400 Subject: [PATCH 15/38] FORCEFREESTATES - MMINOR - Testing for sensitivity of deltacoil --- benchmarks/deltacoil_sensitivity/README.md | 96 ++++++++++++++++ .../results/deltacoil_dmlim_fine_result.txt | 20 ++++ .../results/deltacoil_metrics_result.txt | 39 +++++++ .../results/deltacoil_sensitivity_result.txt | 32 ++++++ .../results/deltacoil_svd_tol_result.txt | 39 +++++++ .../scripts/deltacoil_metrics.jl | 104 +++++++++++++++++ .../scripts/deltacoil_sensitivity.jl | 96 ++++++++++++++++ .../scripts/deltacoil_svd_tol.jl | 108 ++++++++++++++++++ .../scripts/plot_deltacoil_dmlim_fine.py | 45 ++++++++ .../scripts/plot_deltacoil_metrics.py | 45 ++++++++ .../scripts/plot_deltacoil_sensitivity.py | 52 +++++++++ .../scripts/plot_deltacoil_svd.py | 46 ++++++++ examples/LAR_resistive_match_test/gpec.toml | 94 +++++++++++++++ .../LAR_resistive_match_test_ideal/gpec.toml | 94 +++++++++++++++ src/ForceFreeStates/ResistiveMatch.jl | 94 +++++++++------ 15 files changed, 968 insertions(+), 36 deletions(-) create mode 100644 benchmarks/deltacoil_sensitivity/README.md create mode 100644 benchmarks/deltacoil_sensitivity/results/deltacoil_dmlim_fine_result.txt create mode 100644 benchmarks/deltacoil_sensitivity/results/deltacoil_metrics_result.txt create mode 100644 benchmarks/deltacoil_sensitivity/results/deltacoil_sensitivity_result.txt create mode 100644 benchmarks/deltacoil_sensitivity/results/deltacoil_svd_tol_result.txt create mode 100644 benchmarks/deltacoil_sensitivity/scripts/deltacoil_metrics.jl create mode 100644 benchmarks/deltacoil_sensitivity/scripts/deltacoil_sensitivity.jl create mode 100644 benchmarks/deltacoil_sensitivity/scripts/deltacoil_svd_tol.jl create mode 100644 benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_dmlim_fine.py create mode 100644 benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_metrics.py create mode 100644 benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_sensitivity.py create mode 100644 benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_svd.py create mode 100644 examples/LAR_resistive_match_test/gpec.toml create mode 100644 examples/LAR_resistive_match_test_ideal/gpec.toml diff --git a/benchmarks/deltacoil_sensitivity/README.md b/benchmarks/deltacoil_sensitivity/README.md new file mode 100644 index 00000000..4e891eae --- /dev/null +++ b/benchmarks/deltacoil_sensitivity/README.md @@ -0,0 +1,96 @@ +# `delta_coil` truncation & near-rational sensitivity study + +Sensitivity of the STRIDE/Riccati **`delta_coil`** (outer small-solution coefficient at each rational +surface, driven by unit edge/coil modes; `singular/delta_coil_matrix`) to two numerical knobs: + +- **`dmlim`** — the outer-domain truncation point, active only when `set_psilim_via_dmlim = true` + (`psilim = psi(q_last + dmlim)`). +- **`singfac_min`** — the fractional distance from a rational surface at which the ideal jump is + enforced (how close the integration approaches the rational). + +Case: `examples/DIIID-like_gal_resistive_pe_example` (DIII-D-like, n=1, rationals q=2,3,4,5, `qmax=5.426`). + +## Two metrics, per singular surface + +`delta_coil_matrix` is `(2·msing, ncoil)`: each singular surface is **two rows** (its L/R small +solutions). A surface's response vector `v_s` is that `2×ncoil` block flattened. We report, against a +**nominal** run (the case as-shipped: `set_psilim_via_dmlim=false`, `dmlim=0.2`, `singfac_min=1e-4`): + +1. **Norm** `‖v_s‖` — magnitude sensitivity. +2. **Cosine similarity** `|⟨v_nom, v_s⟩| / (‖v_nom‖·‖v_s‖)` — phase-invariant normalized dot product. + `1.0` = identical pattern (only rescaled); `<1` = the coil-response **pattern rotated** — a change + the norm cannot see. + +All runs use `gal_match_flag = false`: `delta_coil` is produced by the rpec edge loop independently of +the resistive match, so there is no inner-layer `msing`/array guard and `dmlim` is free to change the +surface count. Results are aligned **by q-value**, so a surface appearing/disappearing is handled cleanly. + +## Key findings + +- **`singfac_min` is inert.** Norm and cosine are identical to 5–6 significant figures across + `1e-5 … 1e-3` at every surface including the edge — `delta_coil` is independent of the near-rational + approach distance in both magnitude and pattern. + +- **Integration tolerance is inert too.** Sweeping `eulerlagrange_tolerance` over `1e-8 … 1e-12` leaves + `delta_coil` bit-identical — norms, cosine (= 1.000000), and the full singular-value spectrum unchanged. + +- **SVD confirms good conditioning.** The `delta_coil` matrix is well-conditioned (cond ≈ 26, σ₁ ≈ 29, + σ_min ≈ 1.1). Below the `dmlim` threshold the singular-value spectrum and the leading left singular + vector are stable (`u₁·u₁_nom` = 0.998–1.000); at `dmlim = 0.5` the matrix drops to rank-6 (q5 gone), + so its norm and condition number fall (cond → 11) — the discrete surface loss shows cleanly in the SVD. + +- **`dmlim` is the dominant sensitivity — and it is a *pattern-level* effect the norm hides.** Under the + `dmlim` sweep the norms of q2/q3/q4 barely move (~5–16%), which looks robust; but the cosine with the + nominal drops sharply — e.g. at `dmlim=0.5` the q4 pattern is nearly orthogonal to nominal + (cosine ≈ 0.17, ~80° rotation) at ~16% norm change. **A norm-only scan would falsely call this robust.** + +- **The truncation rule drops the edge rational at a sharp threshold.** `dmlim` keeps the outermost + rational `q_r` only if `q_r + dmlim < qmax`. Here q=5 survives only for **`dmlim < qmax − 5 = 0.426`**. + Above that, q5 is dropped entirely (edge becomes q4), and removing it reorganizes the coil response at + every remaining surface — worst right at `dmlim ≈ 0.5`, partially realigning as `dmlim` grows further. + +- **The collapse is a STEP at the threshold, not a smooth rotation** (fine sweep, 0.30–0.50). Just below + (`dmlim=0.42`) every surface's cosine is `0.9999+` — the truncation sits at `qmax`, i.e. the full + nominal domain. The instant q5 drops (`dmlim=0.44`) the cosine collapses discontinuously: q4 → 0.104 + (near-orthogonal), q3 → 0.55, q2 → 0.78. Above the threshold it only slowly realigns (q4: 0.10 → 0.17 + by `dmlim=0.5`). So the pattern change is driven by the *discrete* loss of the surface, not a gradual + boundary shift. + +- Practical guidance: for this diverted-style case the shipped default `set_psilim_via_dmlim=true, + dmlim=0.2` sits comfortably below the 0.426 threshold (all four rationals retained). Choosing + `dmlim` near/above the threshold both changes which surfaces exist and abruptly rotates the response + pattern — avoid parking `dmlim` near `qmax − floor(qmax)`. + +## Layout + +``` +scripts/ deltacoil_metrics.jl norm + cosine-vs-nominal, per surface (dmlim & singfac sweeps) + deltacoil_svd_tol.jl SVD (singular values + vectors) + integration-tolerance scan + deltacoil_sensitivity.jl per-surface norms only (earlier, simpler version) + plot_deltacoil_metrics.py cosine-similarity plots + plot_deltacoil_dmlim_fine.py fine dmlim sweep plot + plot_deltacoil_svd.py SVD spectrum + tolerance-invariance plot + plot_deltacoil_sensitivity.py norm plots +results/ *_result.txt raw scan tables (incl. deltacoil_svd_tol_result.txt) +figures/ deltacoil_metrics.png cosine similarity vs dmlim / singfac + deltacoil_sensitivity.png |delta_coil| vs dmlim / singfac + deltacoil_dmlim_fine.png fine dmlim sweep (0.30–0.50) through the q5-drop threshold + deltacoil_svd.png singular-value spectrum vs dmlim + tolerance invariance +``` + +## Reproduce + +```bash +cd +# coarse dmlim + singfac sweeps: +julia --project=. benchmarks/deltacoil_sensitivity/scripts/deltacoil_metrics.jl \ + examples/DIIID-like_gal_resistive_pe_example +# fine dmlim sweep only (skip singfac), custom dmlim points: +SKIP_SINGFAC=1 julia --project=. benchmarks/deltacoil_sensitivity/scripts/deltacoil_metrics.jl \ + examples/DIIID-like_gal_resistive_pe_example 0.30 0.35 0.40 0.42 0.44 0.46 0.48 0.50 +# then plot (edit the embedded values to match a new run): +python3 benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_metrics.py +``` + +Each sweep point is one GPEC run (~1–2 min). Scans use temp dirs and never overwrite the example's +own HDF5 outputs; the galerkin reference `gpec.h5` is never touched. diff --git a/benchmarks/deltacoil_sensitivity/results/deltacoil_dmlim_fine_result.txt b/benchmarks/deltacoil_sensitivity/results/deltacoil_dmlim_fine_result.txt new file mode 100644 index 00000000..b58b7237 --- /dev/null +++ b/benchmarks/deltacoil_sensitivity/results/deltacoil_dmlim_fine_result.txt @@ -0,0 +1,20 @@ + Sweep A — delta_coil vs dmlim ∈ (0,1) [nominal ncoil=26] +================================================================================ + nominal ‖v‖ per surface: q2.0=11.94 q3.0=9.687 q4.0=12.59 q5.0=31.7 + + (1) ‖delta_coil‖ per surface (— = surface absent): + q\dmlim | 0.3 | 0.35 | 0.4 | 0.42 | 0.44 | 0.46 | 0.48 | 0.5 + q=2.0 | 1.172e+01 | 1.181e+01 | 1.189e+01 | 1.193e+01 | 1.108e+01 | 1.107e+01 | 1.107e+01 | 1.107e+01 + q=3.0 | 9.560e+00 | 9.609e+00 | 9.660e+00 | 9.681e+00 | 9.713e+00 | 9.681e+00 | 9.650e+00 | 9.619e+00 + q=4.0 | 1.251e+01 | 1.254e+01 | 1.257e+01 | 1.258e+01 | 1.521e+01 | 1.498e+01 | 1.477e+01 | 1.457e+01 + q=5.0 | 3.349e+01 | 3.273e+01 | 3.202e+01 | 3.177e+01 | — | — | — | — <- edge + + (2) cosine similarity with nominal ||/(‖v_nom‖‖v‖) (1.0 = same pattern): + q\dmlim | 0.3 | 0.35 | 0.4 | 0.42 | 0.44 | 0.46 | 0.48 | 0.5 + q=2.0 | 0.996348 | 0.998666 | 0.999843 | 0.999991 | 0.775613 | 0.784651 | 0.793553 | 0.802348 + q=3.0 | 0.992477 | 0.997249 | 0.999675 | 0.999982 | 0.551682 | 0.568847 | 0.585903 | 0.602900 + q=4.0 | 0.981228 | 0.993110 | 0.999184 | 0.999955 | 0.104129 | 0.125729 | 0.149245 | 0.174318 + q=5.0 | 0.942364 | 0.978476 | 0.997414 | 0.999857 | — | — | — | — + +read: ‖v‖ moves ⇒ magnitude sensitivity; cosine<1 ⇒ the response PATTERN over coil modes rotated. +cosine≈1 with ‖v‖ varying ⇒ pure rescaling; cosine falling ⇒ genuine change of the coil-response shape. diff --git a/benchmarks/deltacoil_sensitivity/results/deltacoil_metrics_result.txt b/benchmarks/deltacoil_sensitivity/results/deltacoil_metrics_result.txt new file mode 100644 index 00000000..820abf4b --- /dev/null +++ b/benchmarks/deltacoil_sensitivity/results/deltacoil_metrics_result.txt @@ -0,0 +1,39 @@ + Sweep A — delta_coil vs dmlim ∈ (0,1) [nominal ncoil=26] +================================================================================ + nominal ‖v‖ per surface: q2.0=11.94 q3.0=9.687 q4.0=12.59 q5.0=31.7 + + (1) ‖delta_coil‖ per surface (— = surface absent): + q\dmlim | 0.1 | 0.3 | 0.5 | 0.7 | 0.9 + q=2.0 | 1.140e+01 | 1.172e+01 | 1.107e+01 | 1.100e+01 | 1.114e+01 + q=3.0 | 9.389e+00 | 9.560e+00 | 9.619e+00 | 9.319e+00 | 9.279e+00 + q=4.0 | 1.242e+01 | 1.251e+01 | 1.457e+01 | 1.305e+01 | 1.251e+01 + q=5.0 | 3.857e+01 | 3.349e+01 | — | — | — <- edge + + (2) cosine similarity with nominal ||/(‖v_nom‖‖v‖) (1.0 = same pattern): + q\dmlim | 0.1 | 0.3 | 0.5 | 0.7 | 0.9 + q=2.0 | 0.975948 | 0.996348 | 0.802348 | 0.882046 | 0.938423 + q=3.0 | 0.950813 | 0.992477 | 0.602900 | 0.762331 | 0.875203 + q=4.0 | 0.879623 | 0.981228 | 0.174318 | 0.464867 | 0.705142 + q=5.0 | 0.593958 | 0.942364 | — | — | — + +================================================================================ + Sweep B — delta_coil vs singfac_min [dmlim=0.2] +================================================================================ + nominal ‖v‖ per surface: q2.0=11.94 q3.0=9.687 q4.0=12.59 q5.0=31.7 + + (1) ‖delta_coil‖ per surface (— = surface absent): + q\sfac | 1e-05 | 0.0001 | 0.001 + q=2.0 | 1.155e+01 | 1.155e+01 | 1.155e+01 + q=3.0 | 9.469e+00 | 9.469e+00 | 9.469e+00 + q=4.0 | 1.245e+01 | 1.245e+01 | 1.245e+01 + q=5.0 | 3.509e+01 | 3.510e+01 | 3.509e+01 <- edge + + (2) cosine similarity with nominal ||/(‖v_nom‖‖v‖) (1.0 = same pattern): + q\sfac | 1e-05 | 0.0001 | 0.001 + q=2.0 | 0.988340 | 0.988340 | 0.988340 + q=3.0 | 0.976060 | 0.976060 | 0.976060 + q=4.0 | 0.940802 | 0.940802 | 0.940802 + q=5.0 | 0.818224 | 0.818224 | 0.818224 + +read: ‖v‖ moves ⇒ magnitude sensitivity; cosine<1 ⇒ the response PATTERN over coil modes rotated. +cosine≈1 with ‖v‖ varying ⇒ pure rescaling; cosine falling ⇒ genuine change of the coil-response shape. diff --git a/benchmarks/deltacoil_sensitivity/results/deltacoil_sensitivity_result.txt b/benchmarks/deltacoil_sensitivity/results/deltacoil_sensitivity_result.txt new file mode 100644 index 00000000..e4003f63 --- /dev/null +++ b/benchmarks/deltacoil_sensitivity/results/deltacoil_sensitivity_result.txt @@ -0,0 +1,32 @@ + Sweep A — delta_coil vs outer truncation dmlim ∈ (0,1) [set_psilim_via_dmlim=true] + |delta_coil| per surface (blank = surface absent at that setting) +======================================================================== + q\dmlim | 0.1 | 0.3 | 0.5 | 0.7 | 0.9 + q=2.0 | 1.140e+01 | 1.172e+01 | 1.107e+01 | 1.100e+01 | 1.114e+01 + q=3.0 | 9.389e+00 | 9.560e+00 | 9.619e+00 | 9.319e+00 | 9.279e+00 + q=4.0 | 1.242e+01 | 1.251e+01 | 1.457e+01 | 1.305e+01 | 1.251e+01 + q=5.0 | 3.857e+01 | 3.349e+01 | — | — | — <- edge + ------------------------------------------------------------------ + relative change of |delta_coil| vs dmlim=0.9 (reference): + q=2.0 | 2.36e-02 | 5.24e-02 | 6.31e-03 | 1.19e-02 | 0.00e+00 + q=3.0 | 1.18e-02 | 3.02e-02 | 3.66e-02 | 4.33e-03 | 0.00e+00 + q=4.0 | 7.45e-03 | 5.73e-04 | 1.64e-01 | 4.32e-02 | 0.00e+00 + +======================================================================== + Sweep B — delta_coil vs rational-surface cutoff singfac_min [dmlim fixed at 0.2] + |delta_coil| per surface (blank = surface absent at that setting) +======================================================================== + q\sfac | 1e-05 | 0.0001 | 0.001 + q=2.0 | 1.155e+01 | 1.155e+01 | 1.155e+01 + q=3.0 | 9.469e+00 | 9.469e+00 | 9.469e+00 + q=4.0 | 1.245e+01 | 1.245e+01 | 1.245e+01 + q=5.0 | 3.509e+01 | 3.510e+01 | 3.509e+01 <- edge + ------------------------------------------------------------------ + relative change of |delta_coil| vs sfac=0.001 (reference): + q=2.0 | 8.85e-06 | 1.11e-05 | 0.00e+00 + q=3.0 | 1.99e-05 | 1.52e-05 | 0.00e+00 + q=4.0 | 8.78e-06 | 2.23e-05 | 0.00e+00 + q=5.0 | 5.56e-06 | 3.68e-05 | 0.00e+00 + +interpretation: flat rows ⇒ delta_coil robust to that knob at that surface; the edge-most +surface is where truncation (dmlim) and approach distance (singfac_min) bite hardest. diff --git a/benchmarks/deltacoil_sensitivity/results/deltacoil_svd_tol_result.txt b/benchmarks/deltacoil_sensitivity/results/deltacoil_svd_tol_result.txt new file mode 100644 index 00000000..e0836619 --- /dev/null +++ b/benchmarks/deltacoil_sensitivity/results/deltacoil_svd_tol_result.txt @@ -0,0 +1,39 @@ + (A) SVD of the delta_coil matrix M = (2·msing × ncoil) +==================================================================================== + run | 2msing | ‖M‖2 (σ1) | ‖M‖F | σ_min | cond | u1·u1_nom + -------------------------------------------------------------------------------- + nominal | 8 | 2.8991e+01 | 3.7409e+01 | 1.107e+00 | 2.62e+01 | 1.00000 + dmlim=0.3 | 8 | 3.0493e+01 | 3.8820e+01 | 1.121e+00 | 2.72e+01 | 0.99752 + dmlim=0.42 | 8 | 2.9043e+01 | 3.7463e+01 | 1.107e+00 | 2.62e+01 | 0.99999 + dmlim=0.5 | 6 | 1.5731e+01 | 2.0669e+01 | 1.395e+00 | 1.13e+01 | n/a + tol=1.0e-8 | 8 | 2.8991e+01 | 3.7409e+01 | 1.107e+00 | 2.62e+01 | 1.00000 + tol=1.0e-10 | 8 | 2.8991e+01 | 3.7409e+01 | 1.107e+00 | 2.62e+01 | 1.00000 + tol=1.0e-12 | 8 | 2.8991e+01 | 3.7409e+01 | 1.107e+00 | 2.62e+01 | 1.00000 + + singular-value spectrum σ_i (first 6): + run | σ1 | σ2 | σ3 | σ4 | σ5 | σ6 + nominal | 2.899e+01 | 1.557e+01 | 1.294e+01 | 9.612e+00 | 6.048e+00 | 3.607e+00 + dmlim=0.3 | 3.049e+01 | 1.555e+01 | 1.376e+01 | 9.490e+00 | 5.997e+00 | 3.594e+00 + dmlim=0.42 | 2.904e+01 | 1.557e+01 | 1.299e+01 | 9.607e+00 | 6.045e+00 | 3.607e+00 + dmlim=0.5 | 1.573e+01 | 1.062e+01 | 6.220e+00 | 4.523e+00 | 2.437e+00 | 1.395e+00 + tol=1.0e-8 | 2.899e+01 | 1.557e+01 | 1.294e+01 | 9.612e+00 | 6.048e+00 | 3.607e+00 + tol=1.0e-10 | 2.899e+01 | 1.557e+01 | 1.294e+01 | 9.612e+00 | 6.048e+00 | 3.607e+00 + tol=1.0e-12 | 2.899e+01 | 1.557e+01 | 1.294e+01 | 9.612e+00 | 6.048e+00 | 3.607e+00 + +==================================================================================== + (B) eulerlagrange_tolerance scan — per-surface ‖v‖ and cosine vs nominal +==================================================================================== + q\tol | 1e-08 | 1e-10 | 1e-12 (nominal = as-shipped) + (1) ‖delta_coil‖ per surface: + q=2.0 | 1.194e+01 | 1.194e+01 | 1.194e+01 + q=3.0 | 9.687e+00 | 9.687e+00 | 9.687e+00 + q=4.0 | 1.259e+01 | 1.259e+01 | 1.259e+01 + q=5.0 | 3.170e+01 | 3.170e+01 | 3.170e+01 + (2) cosine with nominal: + q=2.0 | 1.000000 | 1.000000 | 1.000000 + q=3.0 | 1.000000 | 1.000000 | 1.000000 + q=4.0 | 1.000000 | 1.000000 | 1.000000 + q=5.0 | 1.000000 | 1.000000 | 1.000000 + +read: (A) stable σ-spectrum + u1·u1_nom≈1 ⇒ matrix conditioning robust; (B) cosine≈1 across +tolerances ⇒ delta_coil independent of the ODE tolerance (as it is of singfac_min). diff --git a/benchmarks/deltacoil_sensitivity/scripts/deltacoil_metrics.jl b/benchmarks/deltacoil_sensitivity/scripts/deltacoil_metrics.jl new file mode 100644 index 00000000..e9b2f172 --- /dev/null +++ b/benchmarks/deltacoil_sensitivity/scripts/deltacoil_metrics.jl @@ -0,0 +1,104 @@ +#!/usr/bin/env julia +# deltacoil_metrics.jl — per-singular-surface sensitivity of delta_coil to dmlim and singfac_min, +# reported with TWO complementary metrics against a nominal (baseline) run: +# (1) ‖v_s‖ — magnitude of the surface's delta_coil vector (over all coil modes) +# (2) |⟨v_s^nom, v_s⟩| / (‖v_s^nom‖‖v_s‖) — normalized Hermitian dot product (cosine similarity) +# with the nominal: 1.0 = identical pattern (only rescaled), <1.0 = the response ROTATED. +# +# delta_coil_matrix is (2·msing, ncoil): each singular surface = 2 rows (L/R small solutions), so a +# surface's vector v_s = vec(dc[2s-1:2s, :]) has length 2·ncoil. The dot product needs matching ncoil, +# so when a dmlim run drops q5 (changing the resonant m-band → ncoil), cosine is marked n/a. +# Run with gal_match_flag=false (delta_coil is computed by the rpec loop, independent of the match). +# +# Nominal = the baseline DIII-D case AS-SHIPPED (set_psilim_via_dmlim=false, dmlim=0.2, singfac_min=1e-4). +# +# Usage: julia --project= scripts/deltacoil_metrics.jl examples/DIIID-like_gal_resistive_pe_example + +using GeneralizedPerturbedEquilibrium, HDF5, LinearAlgebra, Printf + +length(ARGS) >= 1 || error("usage: julia --project= scripts/deltacoil_metrics.jl ") +base = ARGS[1]; isdir(base) || error("no such dir: $base") +ENV["DELTACOIL_MODE"] = "driven"; delete!(ENV, "DELTACOIL_PROJECT") + +DMLIMS = length(ARGS) >= 2 ? parse.(Float64, ARGS[2:end]) : [0.1, 0.3, 0.5, 0.7, 0.9] +SINGFAC = [1e-5, 1e-4, 1e-3] +SKIP_B = get(ENV, "SKIP_SINGFAC", "") != "" # set SKIP_SINGFAC=1 to run only the dmlim sweep + +base_toml = read(joinpath(base, "gpec.toml"), String) +scratch = mktempdir() +eqm = match(r"eq_filename\s*=\s*\"([^\"]+)\"", base_toml); eqm === nothing && error("no eq_filename") +eqfile = eqm.captures[1]; eqsrc = isabspath(eqfile) ? eqfile : joinpath(base, eqfile) + +# returns (sorted q-values, Dict q=>complex surface-vector, ncoil) +function run_one(tag, patches) + dir = joinpath(scratch, tag); mkpath(dir) + dst = joinpath(dir, basename(eqfile)); islink(dst) || symlink(realpath(eqsrc), dst) + toml = base_toml + for (r, s) in patches; toml = replace(toml, r => s); end + toml = replace(toml, r"gal_match_flag\s*=\s*\w+" => "gal_match_flag = false") + toml = replace(toml, r"eq_filename\s*=\s*\"[^\"]+\"" => "eq_filename = \"$(basename(eqfile))\"") + toml = replace(toml, r"HDF5_filename\s*=\s*\"[^\"]+\"" => "HDF5_filename = \"$tag.h5\"") + write(joinpath(dir, "gpec.toml"), toml) + @info ">>> $tag" + GeneralizedPerturbedEquilibrium.main([dir]) + h5open(joinpath(dir, "$tag.h5"), "r") do fid + q = vec(read(fid, "singular/q")) + dc = read(fid, "singular/delta_coil_matrix") + dc = size(dc, 1) < size(dc, 2) ? dc : permutedims(dc) # -> (2msing, ncoil) + ncoil = size(dc, 2) + v = Dict{Float64,Vector{ComplexF64}}() + for s in 1:length(q); v[round(q[s]; digits=2)] = vec(dc[2s-1:2s, :]); end + (sort(round.(q; digits=2)), v, ncoil) + end +end + +cosine(a, b) = (length(a) == length(b) && norm(a) > 0 && norm(b) > 0) ? abs(dot(a, b)) / (norm(a) * norm(b)) : NaN + +# --- nominal (baseline as-shipped) --- +@info "=== nominal (baseline) ===" +qn, vnom, nc_nom = run_one("nominal", Pair{Regex,String}[]) + +# --- Sweep A: dmlim (truncation on) --- +runsA = [run_one("dmlim_$(d)", [r"set_psilim_via_dmlim\s*=\s*\w+" => "set_psilim_via_dmlim = true", + r"(?m)^([ \t]*)dmlim[ \t]*=[ \t]*[\d.]+" => SubstitutionString("\\1dmlim = $d")]) + for d in DMLIMS] +# --- Sweep B: singfac_min (truncation on, dmlim fixed 0.2) --- +runsB = SKIP_B ? Nothing[] : + [run_one("sfac_$(sf)", [r"set_psilim_via_dmlim\s*=\s*\w+" => "set_psilim_via_dmlim = true", + r"(?m)^([ \t]*)dmlim[ \t]*=[ \t]*[\d.]+" => SubstitutionString("\\1dmlim = 0.2"), + r"singfac_min\s*=\s*[\d.eE+-]+" => "singfac_min = $sf"]) + for sf in SINGFAC] + +function report(title, param, vals, runs) + qs = sort(collect(keys(vnom))) + println("\n" * "="^80); println(" $title"); println("="^80) + println(" nominal ‖v‖ per surface: " * join(["q$q=$(round(norm(vnom[q]);sigdigits=4))" for q in qs], " ")) + println("\n (1) ‖delta_coil‖ per surface (— = surface absent):") + @printf(" %-7s", "q\\$param") + for v in vals; @printf(" | %9.3g", v); end; println() + for q in qs + @printf(" q=%-5.1f", q) + for r in runs; @printf(" | %9s", haskey(r[2], q) ? @sprintf("%.3e", norm(r[2][q])) : "—"); end + println(q == maximum(qs) ? " <- edge" : "") + end + println("\n (2) cosine similarity with nominal ||/(‖v_nom‖‖v‖) (1.0 = same pattern):") + @printf(" %-7s", "q\\$param") + for v in vals; @printf(" | %9.3g", v); end; println() + for q in qs + @printf(" q=%-5.1f", q) + for r in runs + if haskey(r[2], q) + c = cosine(vnom[q], r[2][q]) + @printf(" | %9s", isnan(c) ? "n/a(ncoil)" : @sprintf("%.6f", c)) + else + @printf(" | %9s", "—") + end + end + println() + end +end + +report("Sweep A — delta_coil vs dmlim ∈ (0,1) [nominal ncoil=$nc_nom]", "dmlim", DMLIMS, runsA) +SKIP_B || report("Sweep B — delta_coil vs singfac_min [dmlim=0.2]", "sfac", SINGFAC, runsB) +println("\nread: ‖v‖ moves ⇒ magnitude sensitivity; cosine<1 ⇒ the response PATTERN over coil modes rotated.") +println("cosine≈1 with ‖v‖ varying ⇒ pure rescaling; cosine falling ⇒ genuine change of the coil-response shape.") diff --git a/benchmarks/deltacoil_sensitivity/scripts/deltacoil_sensitivity.jl b/benchmarks/deltacoil_sensitivity/scripts/deltacoil_sensitivity.jl new file mode 100644 index 00000000..d313e44a --- /dev/null +++ b/benchmarks/deltacoil_sensitivity/scripts/deltacoil_sensitivity.jl @@ -0,0 +1,96 @@ +#!/usr/bin/env julia +# deltacoil_sensitivity.jl — sensitivity of delta_coil to (a) the outer truncation point dmlim and +# (b) the rational-surface approach distance singfac_min. +# +# delta_coil (singular/delta_coil_matrix) is the outer small-solution response to unit edge sources. +# It is computed by the rpec edge loop INDEPENDENTLY of the resistive match, so this runs with +# gal_match_flag = false — no inner-layer match, hence no msing/array guard, and dmlim is free to +# change the surface count. Results are aligned BY q-VALUE (not row index), so a surface appearing or +# disappearing across the sweep is handled cleanly. +# +# Sweep A: dmlim ∈ (0,1) with set_psilim_via_dmlim = true (moves the outer truncation boundary) +# Sweep B: singfac_min (moves the rational-surface cutoff) +# +# Usage (repo root, project env): +# julia --project=/abs/path/to/GPEC scripts/deltacoil_sensitivity.jl examples/DIIID-like_gal_resistive_pe_example +# +# Runs GPEC once per sweep point (~2 min each). Edit the value lists below to change the sampling. + +using GeneralizedPerturbedEquilibrium, HDF5, LinearAlgebra, Printf + +length(ARGS) >= 1 || error("usage: julia --project= scripts/deltacoil_sensitivity.jl ") +base = ARGS[1]; isdir(base) || error("no such dir: $base") +ENV["DELTACOIL_MODE"] = "driven"; delete!(ENV, "DELTACOIL_PROJECT") + +DMLIMS = [0.1, 0.3, 0.5, 0.7, 0.9] # open interval (0,1) +SINGFAC = [1e-5, 1e-4, 1e-3] # rational-surface approach distance + +base_toml = read(joinpath(base, "gpec.toml"), String) +scratch = mktempdir() +eqm = match(r"eq_filename\s*=\s*\"([^\"]+)\"", base_toml); eqm === nothing && error("no eq_filename") +eqfile = eqm.captures[1]; eqsrc = isabspath(eqfile) ? eqfile : joinpath(base, eqfile) + +# run GPEC with the given toml patches; return (q-values, per-surface |delta_coil| dict keyed by q) +function run_one(tag, patches) + dir = joinpath(scratch, tag); mkpath(dir) + dst = joinpath(dir, basename(eqfile)); islink(dst) || symlink(realpath(eqsrc), dst) + toml = base_toml + for (r, s) in patches; toml = replace(toml, r => s); end + toml = replace(toml, r"gal_match_flag\s*=\s*\w+" => "gal_match_flag = false") # delta_coil only + toml = replace(toml, r"eq_filename\s*=\s*\"[^\"]+\"" => "eq_filename = \"$(basename(eqfile))\"") + toml = replace(toml, r"HDF5_filename\s*=\s*\"[^\"]+\"" => "HDF5_filename = \"$tag.h5\"") + write(joinpath(dir, "gpec.toml"), toml) + @info ">>> $tag" + GeneralizedPerturbedEquilibrium.main([dir]) + h5open(joinpath(dir, "$tag.h5"), "r") do fid + q = vec(read(fid, "singular/q")) + dc = read(fid, "singular/delta_coil_matrix") + dc = size(dc, 1) < size(dc, 2) ? dc : permutedims(dc) # -> (2msing, ncoil) + mag = Dict{Float64,Float64}() + for s in 1:length(q); mag[round(q[s]; digits=2)] = norm(dc[2s-1:2s, :]); end + (sort(round.(q; digits=2)), mag) + end +end + +function report(title, param, vals, runs) + qs = sort(collect(reduce(union, (Set(keys(r[2])) for r in runs)))) + println("\n" * "="^72); println(" $title"); println(" |delta_coil| per surface (blank = surface absent at that setting)"); println("="^72) + @printf(" %-6s", "q\\$param") + for v in vals; @printf(" | %9.3g", v); end; println() + for q in qs + @printf(" q=%-4.1f", q) + for (i, v) in enumerate(vals) + m = get(runs[i][2], q, nothing) + m === nothing ? @printf(" | %9s", "—") : @printf(" | %9.3e", m) + end + println(q == maximum(qs) ? " <- edge" : "") + end + # relative change vs the LAST column (reference) + refm = runs[end][2] + println(" " * "-"^66) + println(" relative change of |delta_coil| vs $param=$(vals[end]) (reference):") + for q in qs + haskey(refm, q) || continue + @printf(" q=%-4.1f", q) + for (i, v) in enumerate(vals) + m = get(runs[i][2], q, nothing) + (m === nothing) ? @printf(" | %9s", "—") : @printf(" | %9.2e", abs(m - refm[q]) / max(refm[q], 1e-300)) + end + println() + end +end + +# --- Sweep A: dmlim (truncation) --- +runsA = [run_one("dmlim_$(d)", [r"set_psilim_via_dmlim\s*=\s*\w+" => "set_psilim_via_dmlim = true", + r"(?m)^([ \t]*)dmlim[ \t]*=[ \t]*[\d.]+" => SubstitutionString("\\1dmlim = $d")]) + for d in DMLIMS] +# --- Sweep B: singfac_min (approach distance), truncation fixed at recommended dmlim=0.2 --- +runsB = [run_one("sfac_$(sf)", [r"set_psilim_via_dmlim\s*=\s*\w+" => "set_psilim_via_dmlim = true", + r"(?m)^([ \t]*)dmlim[ \t]*=[ \t]*[\d.]+" => SubstitutionString("\\1dmlim = 0.2"), + r"singfac_min\s*=\s*[\d.eE+-]+" => "singfac_min = $sf"]) + for sf in SINGFAC] + +report("Sweep A — delta_coil vs outer truncation dmlim ∈ (0,1) [set_psilim_via_dmlim=true]", "dmlim", DMLIMS, runsA) +report("Sweep B — delta_coil vs rational-surface cutoff singfac_min [dmlim fixed at 0.2]", "sfac", SINGFAC, runsB) +println("\ninterpretation: flat rows ⇒ delta_coil robust to that knob at that surface; the edge-most") +println("surface is where truncation (dmlim) and approach distance (singfac_min) bite hardest.") diff --git a/benchmarks/deltacoil_sensitivity/scripts/deltacoil_svd_tol.jl b/benchmarks/deltacoil_sensitivity/scripts/deltacoil_svd_tol.jl new file mode 100644 index 00000000..04cf99b2 --- /dev/null +++ b/benchmarks/deltacoil_sensitivity/scripts/deltacoil_svd_tol.jl @@ -0,0 +1,108 @@ +#!/usr/bin/env julia +# deltacoil_svd_tol.jl — completes the numerical-robustness metric set for delta_coil: +# (A) SVD of the delta_coil matrix per run — singular VALUES (spectrum, spectral & Frobenius norm, +# condition number) and singular VECTORS (leading left-vector alignment with the nominal). +# (B) integration-tolerance scan (eulerlagrange_tolerance) with the per-surface norm + dot-product +# metrics, to confirm delta_coil is insensitive to the ODE tolerance. +# +# delta_coil_matrix is (2·msing, ncoil). SVD works on any shape; the left singular vectors live in the +# (2·msing) surface-side space, so leading-vector alignment vs nominal is defined only when msing matches +# (marked n/a when a dmlim run has dropped q5). Runs use gal_match_flag=false (delta_coil is independent +# of the match). Nominal = baseline as-shipped. +# +# Usage: julia --project= scripts/deltacoil_svd_tol.jl + +using GeneralizedPerturbedEquilibrium, HDF5, LinearAlgebra, Printf + +length(ARGS) >= 1 || error("usage: julia --project= scripts/deltacoil_svd_tol.jl ") +base = ARGS[1]; isdir(base) || error("no such dir: $base") +ENV["DELTACOIL_MODE"] = "driven"; delete!(ENV, "DELTACOIL_PROJECT") + +DMLIMS = [0.30, 0.42, 0.50] # below / at / above the q5-drop threshold (0.426) +TOLS = [1e-8, 1e-10, 1e-12] # eulerlagrange_tolerance + +base_toml = read(joinpath(base, "gpec.toml"), String) +scratch = mktempdir() +eqm = match(r"eq_filename\s*=\s*\"([^\"]+)\"", base_toml); eqm === nothing && error("no eq_filename") +eqfile = eqm.captures[1]; eqsrc = isabspath(eqfile) ? eqfile : joinpath(base, eqfile) + +# returns (q-values, delta_coil matrix M as (2msing, ncoil)) +function run_one(tag, patches) + dir = joinpath(scratch, tag); mkpath(dir) + dst = joinpath(dir, basename(eqfile)); islink(dst) || symlink(realpath(eqsrc), dst) + toml = base_toml + for (r, s) in patches; toml = replace(toml, r => s); end + toml = replace(toml, r"gal_match_flag\s*=\s*\w+" => "gal_match_flag = false") + toml = replace(toml, r"eq_filename\s*=\s*\"[^\"]+\"" => "eq_filename = \"$(basename(eqfile))\"") + toml = replace(toml, r"HDF5_filename\s*=\s*\"[^\"]+\"" => "HDF5_filename = \"$tag.h5\"") + write(joinpath(dir, "gpec.toml"), toml) + @info ">>> $tag" + GeneralizedPerturbedEquilibrium.main([dir]) + h5open(joinpath(dir, "$tag.h5"), "r") do fid + q = sort(round.(vec(read(fid, "singular/q")); digits=2)) + M = read(fid, "singular/delta_coil_matrix") + M = size(M, 1) < size(M, 2) ? M : permutedims(M) # (2msing, ncoil) + (q, Matrix{ComplexF64}(M)) + end +end + +cosv(a, b) = (length(a) == length(b) && norm(a) > 0 && norm(b) > 0) ? abs(dot(a, b)) / (norm(a) * norm(b)) : NaN +persurf(q, M) = Dict(q[s] => vec(M[2s-1:2s, :]) for s in 1:length(q)) + +@info "=== nominal ==="; qn, Mn = run_one("nominal", Pair{Regex,String}[]) +Fn = svd(Mn); u1n = Fn.U[:, 1]; vsurf_n = persurf(qn, Mn) + +runsD = [(d, run_one("dmlim_$(d)", [r"set_psilim_via_dmlim\s*=\s*\w+" => "set_psilim_via_dmlim = true", + r"(?m)^([ \t]*)dmlim[ \t]*=[ \t]*[\d.]+" => SubstitutionString("\\1dmlim = $d")])...) + for d in DMLIMS] +runsT = [(t, run_one("tol_$(t)", [r"eulerlagrange_tolerance\s*=\s*[\d.eE+-]+" => "eulerlagrange_tolerance = $t"])...) + for t in TOLS] + +# ---------- (A) SVD metrics ---------- +println("\n" * "="^84) +println(" (A) SVD of the delta_coil matrix M = (2·msing × ncoil)") +println("="^84) +allruns = [("nominal", qn, Mn); [("dmlim=$d", q, M) for (d, q, M) in runsD]; [("tol=$t", q, M) for (t, q, M) in runsT]] +@printf(" %-11s | %-6s | %-10s | %-10s | %-9s | %-9s | %-10s\n", + "run", "2msing", "‖M‖2 (σ1)", "‖M‖F", "σ_min", "cond", "u1·u1_nom") +println(" " * "-"^80) +for (name, q, M) in allruns + F = svd(M) + u1 = F.U[:, 1] + align = cosv(u1n, u1) + @printf(" %-11s | %-6d | %10.4e | %10.4e | %9.3e | %9.2e | %s\n", + name, size(M, 1), F.S[1], norm(M), F.S[end], F.S[1] / F.S[end], + isnan(align) ? " n/a" : @sprintf("%8.5f", align)) +end + +println("\n singular-value spectrum σ_i (first 6):") +@printf(" %-11s", "run") +for i in 1:6; @printf(" | σ%-7d", i); end; println() +for (name, q, M) in allruns + S = svd(M).S + @printf(" %-11s", name) + for i in 1:6; @printf(" | %8.3e", i <= length(S) ? S[i] : NaN); end; println() +end + +# ---------- (B) integration-tolerance robustness ---------- +println("\n" * "="^84) +println(" (B) eulerlagrange_tolerance scan — per-surface ‖v‖ and cosine vs nominal") +println("="^84) +qs = sort(collect(keys(vsurf_n))) +@printf(" %-8s", "q\\tol"); for t in TOLS; @printf(" | %9.0e", t); end; println(" (nominal = as-shipped)") +println(" (1) ‖delta_coil‖ per surface:") +for q in qs + @printf(" q=%-5.1f", q) + for (t, qq, M) in runsT + v = persurf(qq, M); @printf(" | %9s", haskey(v, q) ? @sprintf("%.3e", norm(v[q])) : "—") + end; println() +end +println(" (2) cosine with nominal:") +for q in qs + @printf(" q=%-5.1f", q) + for (t, qq, M) in runsT + v = persurf(qq, M); @printf(" | %9s", haskey(v, q) ? @sprintf("%.6f", cosv(vsurf_n[q], v[q])) : "—") + end; println() +end +println("\nread: (A) stable σ-spectrum + u1·u1_nom≈1 ⇒ matrix conditioning robust; (B) cosine≈1 across") +println("tolerances ⇒ delta_coil independent of the ODE tolerance (as it is of singfac_min).") diff --git a/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_dmlim_fine.py b/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_dmlim_fine.py new file mode 100644 index 00000000..3c3d3da6 --- /dev/null +++ b/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_dmlim_fine.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""plot_deltacoil_dmlim_fine.py — fine dmlim sweep (0.30-0.50) through the q5-drop threshold (0.426). +Shows the delta_coil pattern collapse is a STEP at the threshold, not a smooth rotation. +Values from deltacoil_metrics.jl (SKIP_SINGFAC=1) on the DIII-D-like case, qmax=5.426.""" +import numpy as np, matplotlib +matplotlib.use("Agg"); import matplotlib.pyplot as plt + +QMAX = 5.426; thr = QMAX - 5.0 # 0.426 +lo = np.array([0.30, 0.35, 0.40, 0.42]) # q5 present +hi = np.array([0.44, 0.46, 0.48, 0.50]) # q5 dropped +cos_lo = { # cosine vs nominal, below threshold + "q=2": [0.996348, 0.998666, 0.999843, 0.999991], + "q=3": [0.992477, 0.997249, 0.999675, 0.999982], + "q=4": [0.981228, 0.993110, 0.999184, 0.999955], + "q=5 (edge)": [0.942364, 0.978476, 0.997414, 0.999857], +} +cos_hi = { # above threshold (q5 absent) + "q=2": [0.775613, 0.784651, 0.793553, 0.802348], + "q=3": [0.551682, 0.568847, 0.585903, 0.602900], + "q=4": [0.104129, 0.125729, 0.149245, 0.174318], +} +colors = {"q=2": "#2E86C1", "q=3": "#28B463", "q=4": "#E67E22", "q=5 (edge)": "#C0392B"} + +fig, ax = plt.subplots(figsize=(9.2, 6.0), constrained_layout=True) +for k in cos_lo: + ax.plot(lo, cos_lo[k], "o-", color=colors[k], lw=2, ms=7, label=k) +for k in cos_hi: + ax.plot(hi, cos_hi[k], "s--", color=colors[k], lw=2, ms=7) # dashed above threshold +ax.axvline(thr, color="grey", ls=":", lw=1.6) +ax.axvspan(thr, 0.51, color="grey", alpha=0.07) +ax.text(thr + 0.004, 0.05, f"q5 dropped\n(dmlim > {thr:.3f})", fontsize=9, color="dimgrey") +ax.text(0.315, 0.05, "q5 retained\n(4 surfaces)", fontsize=9, color="dimgrey") +ax.annotate("q4 pattern nearly orthogonal\nto nominal (cos ≈ 0.10)", + xy=(0.44, 0.104), xytext=(0.455, 0.30), fontsize=8.5, color=colors["q=4"], + arrowprops=dict(arrowstyle="->", color=colors["q=4"], lw=1.2)) +ax.set_xlabel("dmlim (outer truncation, set_psilim_via_dmlim=true)") +ax.set_ylabel("cosine similarity of delta_coil with nominal") +ax.set_title("Fine dmlim sweep through the q5-drop threshold\n" + "solid = q5 present; dashed = q5 dropped — the pattern collapses as a STEP at 0.426", + fontsize=11.5, fontweight="bold") +ax.set_ylim(0, 1.03); ax.set_xlim(0.29, 0.51) +ax.legend(fontsize=9, loc="center right"); ax.grid(alpha=0.25) +out = "benchmarks/deltacoil_sensitivity/figures/deltacoil_dmlim_fine.png" +import os; os.makedirs(os.path.dirname(out), exist_ok=True) +fig.savefig(out, dpi=140, bbox_inches="tight"); print(f"wrote {out}") diff --git a/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_metrics.py b/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_metrics.py new file mode 100644 index 00000000..240f9f30 --- /dev/null +++ b/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_metrics.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""plot_deltacoil_metrics.py — cosine similarity of delta_coil with the nominal case, per surface. +The dot-product metric that the norm hides: shows the response PATTERN rotating under truncation.""" +import numpy as np, matplotlib +matplotlib.use("Agg"); import matplotlib.pyplot as plt + +dmlim = np.array([0.1, 0.3, 0.5, 0.7, 0.9]) +cosA = { # cosine similarity with nominal (set_psilim_via_dmlim=false baseline) + "q=2": [0.975948, 0.996348, 0.802348, 0.882046, 0.938423], + "q=3": [0.950813, 0.992477, 0.602900, 0.762331, 0.875203], + "q=4": [0.879623, 0.981228, 0.174318, 0.464867, 0.705142], + "q=5 (edge)": [0.593958, 0.942364, np.nan, np.nan, np.nan], +} +sfac = np.array([1e-5, 1e-4, 1e-3]) +cosB = { # identical across singfac (delta_coil independent of singfac_min) + "q=2": [0.988340]*3, "q=3": [0.976060]*3, "q=4": [0.940802]*3, "q=5 (edge)": [0.818224]*3, +} +colors = {"q=2": "#2E86C1", "q=3": "#28B463", "q=4": "#E67E22", "q=5 (edge)": "#C0392B"} +QMAX = 5.426; thresh = QMAX - 5.0 + +fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(13, 5.6), constrained_layout=True) +fig.suptitle("delta_coil: normalized dot product with nominal (cosine similarity), per surface\n" + "1.0 = same response pattern; <1 = pattern ROTATED (invisible to the norm)", + fontsize=12.5, fontweight="bold") + +for k, v in cosA.items(): + ax1.plot(dmlim, v, "o-", color=colors[k], lw=2, ms=7, label=k) +ax1.axvline(thresh, color="grey", ls="--", lw=1.3) +ax1.text(thresh + 0.012, 0.30, f"q5 dropped\n(dmlim > {thresh:.3f})", fontsize=8.5, color="grey") +ax1.set_xlabel("dmlim (outer truncation)"); ax1.set_ylabel("cosine similarity with nominal") +ax1.set_title("Sweep A — truncation: pattern rotates hard once q5 drops", fontsize=10.5, fontweight="bold") +ax1.set_ylim(0, 1.03); ax1.legend(fontsize=9, loc="lower right"); ax1.grid(alpha=0.25) + +for k, v in cosB.items(): + ax2.plot(sfac, v, "o-", color=colors[k], lw=2, ms=7, label=k) +ax2.set_xscale("log"); ax2.set_ylim(0, 1.03) +ax2.set_xlabel("singfac_min (rational-surface approach distance)") +ax2.set_ylabel("cosine similarity with nominal") +ax2.set_title("Sweep B — approach distance: perfectly flat (no effect)", fontsize=10.5, fontweight="bold") +ax2.legend(fontsize=9, loc="lower left"); ax2.grid(alpha=0.25) +ax2.text(0.5, 0.55, "cosine identical to 6 decimals across 2 decades of singfac_min\n" + "(offset from 1.0 is the truncation-flag on/off, not singfac)", + transform=ax2.transAxes, ha="center", fontsize=8.5, style="italic") + +out = "deltacoil_metrics.png"; fig.savefig(out, dpi=140, bbox_inches="tight"); print(f"wrote {out}") diff --git a/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_sensitivity.py b/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_sensitivity.py new file mode 100644 index 00000000..3fb68732 --- /dev/null +++ b/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_sensitivity.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""plot_deltacoil_sensitivity.py — visualize delta_coil sensitivity to dmlim and singfac_min. +Values measured by deltacoil_sensitivity.jl on the DIII-D-like case (qmax=5.426).""" +import numpy as np, matplotlib +matplotlib.use("Agg"); import matplotlib.pyplot as plt + +# --- Sweep A: dmlim (truncation); NaN = surface absent at that dmlim --- +dmlim = np.array([0.1, 0.3, 0.5, 0.7, 0.9]) +A = { # |delta_coil| per surface + "q=2": [11.40, 11.72, 11.07, 11.00, 11.14], + "q=3": [9.389, 9.560, 9.619, 9.319, 9.279], + "q=4": [12.42, 12.51, 14.57, 13.05, 12.51], + "q=5 (edge)": [38.57, 33.49, np.nan, np.nan, np.nan], +} +# --- Sweep B: singfac_min (approach distance) --- +sfac = np.array([1e-5, 1e-4, 1e-3]) +B = { + "q=2": [11.55, 11.55, 11.55], + "q=3": [9.469, 9.469, 9.469], + "q=4": [12.45, 12.45, 12.45], + "q=5 (edge)": [35.09, 35.10, 35.09], +} +colors = {"q=2": "#2E86C1", "q=3": "#28B463", "q=4": "#E67E22", "q=5 (edge)": "#C0392B"} +QMAX = 5.426; thresh = QMAX - 5.0 # dmlim above which q5 is dropped + +fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(13, 5.4), constrained_layout=True) +fig.suptitle("delta_coil sensitivity — DIII-D-like, n=1 (qmax = 5.426)", fontsize=13, fontweight="bold") + +for k, v in A.items(): + ax1.plot(dmlim, v, "o-", color=colors[k], lw=2, ms=7, label=k) +ax1.axvline(thresh, color="grey", ls="--", lw=1.4) +ax1.text(thresh + 0.01, ax1.get_ylim()[1]*0.96, f" q5 dropped for\n dmlim > {thresh:.3f}\n (q₅+dmlim > qmax)", + va="top", fontsize=8.5, color="grey") +ax1.set_xlabel("dmlim (outer truncation, set_psilim_via_dmlim=true)") +ax1.set_ylabel("|delta_coil|") +ax1.set_title("Sweep A — truncation point", fontsize=11, fontweight="bold") +ax1.legend(fontsize=9); ax1.grid(alpha=0.25) + +for k, v in B.items(): + ax2.plot(sfac, v, "o-", color=colors[k], lw=2, ms=7, label=k) +ax2.set_xscale("log") +ax2.set_xlabel("singfac_min (rational-surface approach distance)") +ax2.set_ylabel("|delta_coil|") +ax2.set_title("Sweep B — approach distance (dmlim=0.2, all 4 surfaces)", fontsize=11, fontweight="bold") +ax2.legend(fontsize=9); ax2.grid(alpha=0.25) +ax2.text(0.5, 0.02, "flat to 5–6 significant figures across 2 decades — delta_coil is\n" + "essentially independent of singfac_min at every surface incl. edge", + transform=ax2.transAxes, ha="center", va="bottom", fontsize=8.5, style="italic") + +out = "deltacoil_sensitivity.png" +fig.savefig(out, dpi=140, bbox_inches="tight") +print(f"wrote {out}") diff --git a/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_svd.py b/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_svd.py new file mode 100644 index 00000000..5bef6cdf --- /dev/null +++ b/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_svd.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""plot_deltacoil_svd.py — SVD robustness of the delta_coil matrix vs the truncation dmlim. +LEFT: singular-value spectrum. RIGHT: condition number per run (this is what varies — the flat +tolerance-bar panel was removed because it carried no information: the tolerance scan is bit-identical +and is stated in the caption instead). Values from deltacoil_svd_tol.jl on the DIII-D-like case.""" +import numpy as np, matplotlib +matplotlib.use("Agg"); import matplotlib.pyplot as plt + +sigma = { + "nominal": [28.99, 15.57, 12.94, 9.612, 6.048, 3.607], + "dmlim=0.30": [30.49, 15.55, 13.76, 9.490, 5.997, 3.594], + "dmlim=0.42": [29.04, 15.57, 12.99, 9.607, 6.045, 3.607], + "dmlim=0.50 (q5 dropped)": [15.73, 10.62, 6.220, 4.523, 2.437, 1.395], +} +cols = {"nominal":"#111111","dmlim=0.30":"#2E86C1","dmlim=0.42":"#28B463","dmlim=0.50 (q5 dropped)":"#C0392B"} +# condition number (σ1/σ_min) per run — this genuinely varies +cond_runs = ["nominal","dmlim=0.30","dmlim=0.42","dmlim=0.50"] +cond_vals = [26.2, 27.2, 26.2, 11.3] +cond_cols = ["#111111","#2E86C1","#28B463","#C0392B"] + +fig,(ax1,ax2)=plt.subplots(1,2,figsize=(13,5.3),constrained_layout=True) +fig.suptitle("delta_coil matrix — SVD robustness (DIII-D-like)",fontsize=13,fontweight="bold") + +x=np.arange(1,7) +for k,v in sigma.items(): + ax1.plot(x,v,"o-",color=cols[k],lw=2,ms=6,label=k) +ax1.set_yscale("log"); ax1.set_xlabel("singular value index i"); ax1.set_ylabel("σᵢ") +ax1.set_title("Singular-value spectrum vs truncation\n(stable below threshold; whole matrix shrinks when q5 leaves)", + fontsize=10.5,fontweight="bold") +ax1.legend(fontsize=8.5); ax1.grid(alpha=0.25,which="both") + +bars=ax2.bar(cond_runs,cond_vals,color=cond_cols) +for b,v in zip(bars,cond_vals): ax2.text(b.get_x()+b.get_width()/2,v+0.5,f"{v:.1f}",ha="center",fontsize=9) +ax2.set_ylabel("condition number σ₁ / σ_min"); ax2.set_ylim(0,40) +ax2.set_title("Conditioning per run\n(well-conditioned ≈ 26; drops to 11 when q5 is dropped)", + fontsize=10.5,fontweight="bold") +ax2.grid(alpha=0.2,axis="y") +ax2.text(0.5,0.93,"Integration-tolerance scan (1e-8…1e-12): the σ-spectrum,\n" + "norms and cosine are all BIT-IDENTICAL — omitted from the plot\n" + "because there is nothing to show (delta_coil is tolerance-independent).", + transform=ax2.transAxes,ha="center",fontsize=8,style="italic", + bbox=dict(boxstyle="round",fc="#F4F6FA",ec="#B4BED2")) + +out="benchmarks/deltacoil_sensitivity/figures/deltacoil_svd.png" +import os; os.makedirs(os.path.dirname(out),exist_ok=True) +fig.savefig(out,dpi=140,bbox_inches="tight"); print("wrote",out) diff --git a/examples/LAR_resistive_match_test/gpec.toml b/examples/LAR_resistive_match_test/gpec.toml new file mode 100644 index 00000000..9e884c2a --- /dev/null +++ b/examples/LAR_resistive_match_test/gpec.toml @@ -0,0 +1,94 @@ +# LAR (TJ-analytic) resistive-match test case for the STRIDE/Riccati path. +# +# A genuinely different equilibrium from the DIII-D-like validation: large-aspect-ratio, +# analytic TJ model (Fitzpatrick), ε = a/R₀ = 0.2, n = 1. Purpose: exercise resonant_match_rpec +# on a new equilibrium and check it is internally self-consistent (residual ~1e-16, ideal-limit +# cout = 0, self-convergence in gal_sing_order). NOTE: the current branch has no galerkin match +# path, so a galerkin head-to-head requires the STRIDE-deltacoil branch (commit this work first). +# +# For n = 1 with q ∈ [1.5, 3.6] the rational surfaces are q = 2 and q = 3 → msing = 2, so the +# per-surface resistive arrays below have length 2 (core→edge). + +[Equilibrium] +eq_type = "tj_analytic" +jac_type = "hamada" +grid_type = "ldp" +psilow = 0.01 +psihigh = 0.995 +mpsi = 128 +mtheta = 512 + +[TJ_ANALYTIC_INPUT] +lar_r0 = 2.0 +lar_a = 0.4 +qc = 1.5 +qa = 3.6 +pc = 0.01 +mu = 2.0 +B0 = 12.0 +ma = 128 +mtau = 128 + +[Wall] +shape = "conformal" +a = 20 + +[ForceFreeStates] +HDF5_filename = "gpec_stride_lar.h5" +local_stability_flag = true +mat_flag = true +ode_flag = true +vac_flag = true + +qlow = 1.02 +qhigh = 3.6 +sing_start = 0 +nn_low = 1 +nn_high = 1 +delta_mlow = 8 +delta_mhigh = 8 +delta_mband = 0 +mthvac = 960 +thmax0 = 1 + +eulerlagrange_tolerance = 1e-12 +singfac_min = 1e-4 +ucrit = 1e4 +sing_order = 6 +save_interval = 3 + +use_parallel = true +parallel_threads = 2 +populate_dense_xi = false +truncate_at_dW_peak = false +set_psilim_via_dmlim = false +dmlim = 0.2 +force_termination = true +write_outputs_to_HDF5 = true + +# Outer-region singular Galerkin Δ′ solve + rpec unit-edge columns (delta_coil), then the +# STRIDE outer↔inner resistive match (Wang 2020 Eq. 11). +gal_flag = true +gal_solver = "LU" +gal_nx = 256 +gal_nq = 6 +gal_pfac = 0.03 +gal_dx0 = 5e-4 +gal_dx1 = 1e-3 +gal_dx2 = 1e-3 +gal_cutoff = 10 +gal_tol = 1e-10 +gal_gnstep = 5000 +gal_dx1dx2_flag = true +gal_sing_order = 6 +gal_sing_order_ceiling = true +gal_rpec_flag = true + +# DRIVEN (RPEC) resistive match. 2 rational surfaces (q=2,3). Same nominal layer inputs as the +# DIII-D case: η=8e-8, ρ=3.3e-7 kg/m³, rotation=1 Hz, Γ=5/3. +gal_match_flag = true +gal_ideal_flag = false +gal_eta = [8e-8, 8e-8] +gal_rho = [3.3e-7, 3.3e-7] +gal_rotation = [1.0, 1.0] +gal_gamma = 1.6666666666666667 diff --git a/examples/LAR_resistive_match_test_ideal/gpec.toml b/examples/LAR_resistive_match_test_ideal/gpec.toml new file mode 100644 index 00000000..e52aa77f --- /dev/null +++ b/examples/LAR_resistive_match_test_ideal/gpec.toml @@ -0,0 +1,94 @@ +# LAR (TJ-analytic) resistive-match test case for the STRIDE/Riccati path. +# +# A genuinely different equilibrium from the DIII-D-like validation: large-aspect-ratio, +# analytic TJ model (Fitzpatrick), ε = a/R₀ = 0.2, n = 1. Purpose: exercise resonant_match_rpec +# on a new equilibrium and check it is internally self-consistent (residual ~1e-16, ideal-limit +# cout = 0, self-convergence in gal_sing_order). NOTE: the current branch has no galerkin match +# path, so a galerkin head-to-head requires the STRIDE-deltacoil branch (commit this work first). +# +# For n = 1 with q ∈ [1.5, 3.6] the rational surfaces are q = 2 and q = 3 → msing = 2, so the +# per-surface resistive arrays below have length 2 (core→edge). + +[Equilibrium] +eq_type = "tj_analytic" +jac_type = "hamada" +grid_type = "ldp" +psilow = 0.01 +psihigh = 0.995 +mpsi = 128 +mtheta = 512 + +[TJ_ANALYTIC_INPUT] +lar_r0 = 2.0 +lar_a = 0.4 +qc = 1.5 +qa = 3.6 +pc = 0.01 +mu = 2.0 +B0 = 12.0 +ma = 128 +mtau = 128 + +[Wall] +shape = "conformal" +a = 20 + +[ForceFreeStates] +HDF5_filename = "gpec_ideal_lar.h5" +local_stability_flag = true +mat_flag = true +ode_flag = true +vac_flag = true + +qlow = 1.02 +qhigh = 3.6 +sing_start = 0 +nn_low = 1 +nn_high = 1 +delta_mlow = 8 +delta_mhigh = 8 +delta_mband = 0 +mthvac = 960 +thmax0 = 1 + +eulerlagrange_tolerance = 1e-12 +singfac_min = 1e-4 +ucrit = 1e4 +sing_order = 6 +save_interval = 3 + +use_parallel = true +parallel_threads = 2 +populate_dense_xi = false +truncate_at_dW_peak = false +set_psilim_via_dmlim = false +dmlim = 0.2 +force_termination = true +write_outputs_to_HDF5 = true + +# Outer-region singular Galerkin Δ′ solve + rpec unit-edge columns (delta_coil), then the +# STRIDE outer↔inner resistive match (Wang 2020 Eq. 11). +gal_flag = true +gal_solver = "LU" +gal_nx = 256 +gal_nq = 6 +gal_pfac = 0.03 +gal_dx0 = 5e-4 +gal_dx1 = 1e-3 +gal_dx2 = 1e-3 +gal_cutoff = 10 +gal_tol = 1e-10 +gal_gnstep = 5000 +gal_dx1dx2_flag = true +gal_sing_order = 6 +gal_sing_order_ceiling = true +gal_rpec_flag = true + +# DRIVEN (RPEC) resistive match. 2 rational surfaces (q=2,3). Same nominal layer inputs as the +# DIII-D case: η=8e-8, ρ=3.3e-7 kg/m³, rotation=1 Hz, Γ=5/3. +gal_match_flag = true +gal_ideal_flag = true +gal_eta = [8e-8, 8e-8] +gal_rho = [3.3e-7, 3.3e-7] +gal_rotation = [1.0, 1.0] +gal_gamma = 1.6666666666666667 diff --git a/src/ForceFreeStates/ResistiveMatch.jl b/src/ForceFreeStates/ResistiveMatch.jl index 5cad58bc..1247c914 100644 --- a/src/ForceFreeStates/ResistiveMatch.jl +++ b/src/ForceFreeStates/ResistiveMatch.jl @@ -1,50 +1,66 @@ # ResistiveMatch.jl # -# Coil-driven outer↔inner resistive asymptotic matching for the STRIDE/Riccati path. -# Implements Wang et al., Phys. Plasmas 27, 122509 (2020), Eq. (11): +# This file does the "matching" step for the resistive (tearing) plasma response. # -# (Δ_out − Δ_in(i2πf)) · C = −Δ_coil, C = −(Δ_out − Δ_in(i2πf))^{-1} · Δ_coil +# The plasma has an outer region (ideal MHD, which STRIDE already solves) and a very +# thin inner layer right at each rational surface, where resistivity actually matters. Neither piece +# is complete by itself; they have to agree where they meet. Computing that agreement is what this +# file does, following Wang et al. 2020 (Phys. Plasmas 27, 122509), Eq. (11): # -# Δ_out is the ideal outer-region Δ′ small-solution block, Δ_in(i2πf) is the resistive inner-layer -# matching data (InnerLayer.solve_inner at the rpec forced eigenvalue γ = 2πi·n·f), and Δ_coil is -# the small-solution contribution induced by the external coil perturbations. +# C = -(Δ_out - Δ_in(i2πf))^{-1} · Δ_coil # -# Mirrors RDCON's `gal_match_rpec` (Fortran rmatch `match_rpec`, match.f). Key normalization point -# (verified against Wang 2020 + gal_match_rpec): Δ_out and Δ_coil must share the SAME normalization, -# so both use STRIDE's RAW small-solution coefficients — -# Δ_out = dp_raw (loop_boundary_conditions, big-solution-driven raw coeffs) -# Δ_coil = raw edge-driven coeffs (loop_edge_boundary_conditions with snorm = 1) -# The `snorm = |n·q'|^α` used for the delta_coil↔galerkin comparison is not part of the matching -# normalization and is deliberately omitted. +# In layman's terms: +# Δ_out = how the ideal outer plasma responds (comes from STRIDE's Δ' solve) +# Δ_in = how the resistive inner layer responds; the "i2πf" is where plasma rotation enters +# Δ_coil = the push from the external coil (this is what drives everything) +# C = the matched coefficients we solve for +# +# This is the same calculation the previous Fortran code does (RDCON's gal_match_rpec in match.f). Here, I'm +# just doing it in Julia on the STRIDE path. +# +# Δ_out and Δ_coil have to be in the SAME units before you can subtract them, so I use STRIDE's RAW coefficients +# for both (no extra scaling). There is a factor called snorm (= |n·q'|^α) that appears elsewhere for +# comparing to the Galerkin code, but that's only a unit conversion for plotting; +# it's not part of the real physics, so I leave it out here. -"Result of the coil-driven outer↔inner resistive match (Wang 2020 Eq. 11)." +"What comes out of the match (Wang 2020 Eq. 11)." struct ResonantMatchResult - cout::Matrix{ComplexF64} # outer coefficients (2·msing × ncoil) - cin::Matrix{ComplexF64} # inner coefficients (2·msing × ncoil) - deltar::Matrix{ComplexF64} # per-surface inner-layer Δ (msing × 2), deltac.f convention - rpec_eig::Vector{ComplexF64} # forced eigenvalue γ_s = 2πi·n·f per surface - reconnected_flux::Matrix{ComplexF64} # matched small-solution (reconnected) amplitude (2·msing × ncoil) - residual::Float64 # relative solve residual ‖mat·cof − rmat‖ / ‖rmat‖ + cout::Matrix{ComplexF64} # the outer coefficients we solved for (2·msing × ncoil) + cin::Matrix{ComplexF64} # the inner-layer coefficients (2·msing × ncoil) + deltar::Matrix{ComplexF64} # the inner-layer Δ at each surface (msing × 2) + rpec_eig::Vector{ComplexF64} # the frequency each layer "sees": γ = 2πi·n·f, one per surface + reconnected_flux::Matrix{ComplexF64} # the reconnected (resonant) flux, the physical answer we want + residual::Float64 # how well the linear solve worked (should be tiny, ~1e-16) end """ resonant_match_rpec(delta_out_raw, delta_coil_raw, sings, equil, intr, ctrl) -> ResonantMatchResult -Assemble and solve the 4·msing coil-driven matching system (Wang 2020 Eq. 11; match.f) from STRIDE's -raw outer Δ′ block and raw Δ_coil, plus the per-surface resistive inner-layer Δ (resist_eval → -InnerLayer.solve_inner). `delta_out_raw` is (2msing × 2msing) indexed [driver, surface]; `delta_coil_raw` -is (2msing × ncoil) indexed [surface, coil-mode]. +Build and solve the matching system (Wang 2020 Eq. 11). It takes STRIDE's raw outer response +`delta_out_raw` and raw coil drive `delta_coil_raw`, gets the resistive inner-layer Δ at each surface +(resist_eval → InnerLayer.solve_inner), then solves for the matched coefficients. +Shapes: `delta_out_raw` is (2msing × 2msing); `delta_coil_raw` is (2msing × ncoil). """ function resonant_match_rpec(delta_out_raw::AbstractMatrix, delta_coil_raw::AbstractMatrix, sings::Vector{SingType}, equil::Equilibrium.PlasmaEquilibrium, intr::ForceFreeStatesInternal, ctrl::ForceFreeStatesControl) - msing = length(sings) + msing = size(delta_out_raw, 1) ÷ 2 ncoil = size(delta_coil_raw, 2) nn = intr.nlow - if ctrl.gal_ideal_flag # ideal limit: skip the inner layer, cout = 0 - # reconnected flux collapses to the bare coil small-solution amplitude (no reconnection) + # Safety checks: make sure the surfaces line up with the outer blocks before I trust anything. If + # they don't match, the wrong inner-layer Δ would quietly get attached to the wrong surface, so I + # error out loudly instead of getting a silently-wrong answer. + size(delta_out_raw) == (2msing, 2msing) || + error("resonant_match_rpec: delta_out_raw is $(size(delta_out_raw)), expected (2msing, 2msing)") + length(sings) == msing || + error("resonant_match_rpec: sings length $(length(sings)) != msing=$msing (surface-set mismatch)") + size(delta_coil_raw, 1) == 2msing || + error("resonant_match_rpec: delta_coil_raw has $(size(delta_coil_raw, 1)) rows, expected 2msing=$(2msing)") + + if ctrl.gal_ideal_flag # "ideal" case: no resistivity, so no reconnection → cout is 0 + # with no inner layer there's nothing to reconnect, so the flux is just the bare coil drive return ResonantMatchResult(zeros(ComplexF64, 2msing, ncoil), zeros(ComplexF64, 2msing, ncoil), zeros(ComplexF64, msing, 2), zeros(ComplexF64, msing), Matrix{ComplexF64}(delta_coil_raw), 0.0) end @@ -52,36 +68,42 @@ function resonant_match_rpec(delta_out_raw::AbstractMatrix, delta_coil_raw::Abst length(v) == msing || error("resonant_match_rpec: $nm has length $(length(v)), expected msing=$msing (one per surface, core→edge)") end - # --- per-surface resistive inner-layer matching data Δ(Q) (Wang 2020; deltac.f) --- + # --- get the resistive inner-layer Δ at each rational surface --- inner = InnerLayer.GGJModel(; solver=:galerkin) deltar = zeros(ComplexF64, msing, 2) rpec_eig = zeros(ComplexF64, msing) for i in 1:msing params = resist_eval(sings[i], equil, intr; eta=ctrl.gal_eta[i], rho=ctrl.gal_rho[i], gamma=ctrl.gal_gamma, ising=i) - γ = 2π * im * nn * ctrl.gal_rotation[i] # rpec forced eigenvalue 2πi·n·f + γ = 2π * im * nn * ctrl.gal_rotation[i] # the frequency this layer sees (plasma rotation enters here) rpec_eig[i] = γ Δ = InnerLayer.solve_inner(inner, params, γ) deltar[i, 1] = Δ[1] deltar[i, 2] = Δ[2] end - # --- assemble the 4·msing matching system mat·[cout; cin] = rmat (match.f) --- + # --- build the big linear system mat · [cout; cin] = rmat and solve it (this IS Eq. 11 written out) --- mat = zeros(ComplexF64, 4msing, 4msing) rmat = zeros(ComplexF64, 4msing, ncoil) - @views mat[2msing+1:4msing, 1:2msing] .= transpose(delta_out_raw) # Δ_out (raw dp_raw block) - @views rmat[2msing+1:4msing, :] .= .-delta_coil_raw # −Δ_coil (STRIDE (2msing,ncoil)) + @views mat[2msing+1:4msing, 1:2msing] .= transpose(delta_out_raw) # the Δ_out part of the system + @views rmat[2msing+1:4msing, :] .= .-delta_coil_raw # the −Δ_coil drive goes on the right-hand side for ising in 1:msing + # fill in one surface at a time: top rows tie cout to cin, bottom rows carry the inner-layer Δ idx1 = 2ising - 1; idx2 = 2ising idx3 = idx1 + 2msing; idx4 = idx2 + 2msing d1 = deltar[ising, 1]; d2 = deltar[ising, 2] mat[idx1, idx1] = 1; mat[idx2, idx2] = 1 mat[idx1, idx3] = -1; mat[idx1, idx4] = 1 mat[idx2, idx3] = -1; mat[idx2, idx4] = -1 - mat[idx3, idx3] = -d1; mat[idx3, idx4] = d2 # Δ_in blocks + mat[idx3, idx3] = -d1; mat[idx3, idx4] = d2 # the Δ_in entries for this surface mat[idx4, idx3] = -d1; mat[idx4, idx4] = -d2 end - cof = mat \ rmat - residual = norm(mat * cof - rmat) / max(norm(rmat), 1e-300) - return ResonantMatchResult(cof[1:2msing, :], cof[2msing+1:4msing, :], deltar, rpec_eig, residual) + cof = mat \ rmat # solve the system + residual = norm(mat * cof - rmat) / max(norm(rmat), 1e-300) # double-check the solve is good + cout = cof[1:2msing, :] + cin = cof[2msing+1:4msing, :] + # The reconnected flux is the coil drive plus the plasma's own outer response to the matched cout. + # (A reviewer pointed out this also equals -Δ_in·cin, i.e. the inner-layer side agrees, good sanity check.) + reconnected_flux = delta_coil_raw .+ transpose(delta_out_raw) * cout + return ResonantMatchResult(cout, cin, deltar, rpec_eig, reconnected_flux, residual) end From 05e673965626428b09ff59a1b35dc8cca6eb3207 Mon Sep 17 00:00:00 2001 From: Via Weber Date: Fri, 17 Jul 2026 15:54:02 -0400 Subject: [PATCH 16/38] FORCEFREESTATES - MINOR - deltacoil vs rdcon convergence tests --- .../deltacoil_rdcon_convergence_result.txt | 11 ++++ .../results/deltacoil_vs_rdcon_result.txt | 5 ++ .../scripts/deltacoil_rdcon_convergence.jl | 63 +++++++++++++++++++ .../scripts/plot_deltacoil_vs_rdcon.py | 49 +++++++++++++++ 4 files changed, 128 insertions(+) create mode 100644 benchmarks/deltacoil_sensitivity/results/deltacoil_rdcon_convergence_result.txt create mode 100644 benchmarks/deltacoil_sensitivity/results/deltacoil_vs_rdcon_result.txt create mode 100644 benchmarks/deltacoil_sensitivity/scripts/deltacoil_rdcon_convergence.jl create mode 100644 benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_vs_rdcon.py diff --git a/benchmarks/deltacoil_sensitivity/results/deltacoil_rdcon_convergence_result.txt b/benchmarks/deltacoil_sensitivity/results/deltacoil_rdcon_convergence_result.txt new file mode 100644 index 00000000..277aa699 --- /dev/null +++ b/benchmarks/deltacoil_sensitivity/results/deltacoil_rdcon_convergence_result.txt @@ -0,0 +1,11 @@ + STRIDE delta_coil: self-convergence vs correlation with RDCON (inner surfaces) +====================================================================== + order | (a) self-conv vs ord 8 | (b) corr with RDCON + | q2 q3 | q2 q3 + -------------------------------------------------------------- + 4 | 1.71e-08 3.35e-08 | 0.920 0.574 + 6 | 0.00e+00 0.00e+00 | 0.920 0.574 + 8 | 0.00e+00 0.00e+00 | 0.920 0.574 + +interpretation: if (a) → 0 while (b) stays flat below 1, the STRIDE↔RDCON gap is representational +(weak-form vs strong-form), NOT STRIDE resolution — so refining STRIDE will not close it. diff --git a/benchmarks/deltacoil_sensitivity/results/deltacoil_vs_rdcon_result.txt b/benchmarks/deltacoil_sensitivity/results/deltacoil_vs_rdcon_result.txt new file mode 100644 index 00000000..03635d14 --- /dev/null +++ b/benchmarks/deltacoil_sensitivity/results/deltacoil_vs_rdcon_result.txt @@ -0,0 +1,5 @@ +STRIDE delta_coil vs RDCON (galerkin/delta_coil), per-surface correlation of |delta_coil| +variant | q2L q2R q3L q3R q4L q4R q5L q5R | inner outer +strong-form (driven) | 0.90 0.94 0.48 0.67 0.12 0.04 -.38 -.22 | 0.75 -0.11 +weak-form projection | 0.49 0.51 0.24 0.38 -.07 0.01 -.36 -.37 | 0.41 -0.20 +energy projection | 0.91 0.94 0.48 0.56 0.12 0.70 -.38 -.22 | 0.72 0.06 diff --git a/benchmarks/deltacoil_sensitivity/scripts/deltacoil_rdcon_convergence.jl b/benchmarks/deltacoil_sensitivity/scripts/deltacoil_rdcon_convergence.jl new file mode 100644 index 00000000..863f5e18 --- /dev/null +++ b/benchmarks/deltacoil_sensitivity/scripts/deltacoil_rdcon_convergence.jl @@ -0,0 +1,63 @@ +#!/usr/bin/env julia +# deltacoil_rdcon_convergence.jl — does STRIDE's delta_coil converge to RDCON as STRIDE refines? +# +# Sweeps STRIDE's singular-expansion order (sing_order) and, per inner surface (q2, q3), reports: +# (a) STRIDE self-convergence — |dc(order) − dc(order_max)| / |dc(order_max)| (should → 0) +# (b) correlation of STRIDE |delta_coil| with the FIXED RDCON reference (galerkin/delta_coil). +# If STRIDE self-converges (a→0) but the RDCON correlation (b) stays FLAT below 1, the residual is +# the weak-form-vs-strong-form representation, not STRIDE under-resolution. If (b) climbs toward 1, +# it was resolution. Runs with the match off (delta_coil only). RDCON ref read from the example gpec.h5. +# +# Usage: julia --project= scripts/deltacoil_rdcon_convergence.jl [ord1 ord2 ...] + +using GeneralizedPerturbedEquilibrium, HDF5, LinearAlgebra, Statistics, Printf + +length(ARGS) >= 1 || error("usage: julia --project= scripts/deltacoil_rdcon_convergence.jl [orders...]") +base = ARGS[1]; isdir(base) || error("no such dir: $base") +ORDERS = length(ARGS) >= 2 ? parse.(Int, ARGS[2:end]) : [4, 6, 8] +ENV["DELTACOIL_MODE"] = "driven"; delete!(ENV, "DELTACOIL_PROJECT") + +base_toml = read(joinpath(base, "gpec.toml"), String) +scratch = mktempdir() +eqm = match(r"eq_filename\s*=\s*\"([^\"]+)\"", base_toml); eqm === nothing && error("no eq_filename") +eqfile = eqm.captures[1]; eqsrc = isabspath(eqfile) ? eqfile : joinpath(base, eqfile) + +align8(a) = size(a, 1) == 8 ? a : permutedims(a) # -> (8 surface-side, ncoil) +R = align8(h5read(joinpath(base, "gpec.h5"), "galerkin/delta_coil")) # fixed RDCON reference +corr(a, b) = cor(abs.(vec(a)), abs.(vec(b))) + +function run_order(ord) + dir = joinpath(scratch, "ord_$ord"); mkpath(dir) + dst = joinpath(dir, basename(eqfile)); islink(dst) || symlink(realpath(eqsrc), dst) + toml = replace(base_toml, r"gal_match_flag\s*=\s*\w+" => "gal_match_flag = false") + toml = replace(toml, r"gal_sing_order_ceiling\s*=\s*\w+" => "gal_sing_order_ceiling = false") + if occursin(r"(?m)^[ \t]*sing_order[ \t]*=[ \t]*\d+", toml) + toml = replace(toml, r"(?m)^([ \t]*)sing_order[ \t]*=[ \t]*\d+" => SubstitutionString("\\1sing_order = $ord")) + else + toml = replace(toml, "[ForceFreeStates]" => "[ForceFreeStates]\nsing_order = $ord"; count=1) + end + toml = replace(toml, r"eq_filename\s*=\s*\"[^\"]+\"" => "eq_filename = \"$(basename(eqfile))\"") + toml = replace(toml, r"HDF5_filename\s*=\s*\"[^\"]+\"" => "HDF5_filename = \"ord_$ord.h5\"") + write(joinpath(dir, "gpec.toml"), toml) + @info ">>> sing_order = $ord" + GeneralizedPerturbedEquilibrium.main([dir]) + align8(h5read(joinpath(dir, "ord_$ord.h5"), "singular/delta_coil_matrix")) +end + +res = Dict(o => run_order(o) for o in ORDERS) +omax = maximum(ORDERS); ref = res[omax] +inner_rows = [1, 2, 3, 4] # q2L,q2R,q3L,q3R + +println("\n" * "="^70) +println(" STRIDE delta_coil: self-convergence vs correlation with RDCON (inner surfaces)") +println("="^70) +@printf(" %-6s | %-22s | %-22s\n", "order", "(a) self-conv vs ord $omax", "(b) corr with RDCON") +@printf(" %-6s | %-10s %-10s | %-10s %-10s\n", "", "q2", "q3", "q2", "q3") +println(" " * "-"^62) +for o in sort(ORDERS) + sc(rows) = norm(abs.(res[o][rows,:]) .- abs.(ref[rows,:])) / max(norm(abs.(ref[rows,:])), 1e-300) + cr(rows) = mean(corr(res[o][r,:], R[r,:]) for r in rows) + @printf(" %-6d | %-10.2e %-10.2e | %-10.3f %-10.3f\n", o, sc(1:2), sc(3:4), cr(1:2), cr(3:4)) +end +println("\ninterpretation: if (a) → 0 while (b) stays flat below 1, the STRIDE↔RDCON gap is representational") +println("(weak-form vs strong-form), NOT STRIDE resolution — so refining STRIDE will not close it.") diff --git a/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_vs_rdcon.py b/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_vs_rdcon.py new file mode 100644 index 00000000..5f27e760 --- /dev/null +++ b/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_vs_rdcon.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""plot_deltacoil_vs_rdcon.py — per-surface correlation of STRIDE delta_coil with the RDCON reference, +for the strong-form (driven) and two projected (weak-form, energy-weighted) readouts. +Shows the match is good on the inner surfaces and fails at the edge, and that projecting the readout +does not close the gap — the residual is weak-form-vs-strong-form representation plus RDCON edge +degradation, not a fixable reweighting. Reads the example HDF5 files directly.""" +import os, numpy as np, h5py, matplotlib +matplotlib.use("Agg"); import matplotlib.pyplot as plt + +EX = "examples/DIIID-like_gal_resistive_pe_example" +def load(fn, key): + with h5py.File(os.path.join(EX, fn), "r") as f: + a = np.asarray(f[key][()]) + return a if a.shape[0] == 8 else a.T # -> (8 surface-side, 26 coil) + +R = load("gpec.h5", "galerkin/delta_coil") # RDCON weak-form reference +variants = {"strong-form (driven)":"gpec_driven.h5", + "weak-form projection":"gpec_project.h5", + "energy projection":"gpec_project_energy.h5"} +cols = {"strong-form (driven)":"#2E86C1","weak-form projection":"#B9770E","energy projection":"#28B463"} +sides = ["q2L","q2R","q3L","q3R","q4L","q4R","q5L","q5R"] +def corr(a,b): a,b=np.abs(a),np.abs(b); return np.corrcoef(a,b)[0,1] + +C = {name:[corr(load(fn,"singular/delta_coil_matrix")[r], R[r]) for r in range(8)] + for name,fn in variants.items()} + +fig, ax = plt.subplots(figsize=(10.5,5.4), constrained_layout=True) +ax.axvspan(-0.5,3.5,color="#2E86C1",alpha=0.05); ax.axvspan(3.5,7.5,color="#C0392B",alpha=0.06) +ax.text(1.5,1.06,"inner surfaces (RDCON reliable)",ha="center",fontsize=9,color="#20507f") +ax.text(5.5,1.06,"outer / edge (RDCON degrades)",ha="center",fontsize=9,color="#7a2420") +x=np.arange(8); w=0.26 +for i,(name,cs) in enumerate(C.items()): + ax.bar(x+(i-1)*w, cs, w, color=cols[name], label=name) +ax.axhline(0,color="k",lw=0.7); ax.set_xticks(x); ax.set_xticklabels(sides) +ax.set_ylabel("correlation of |delta_coil| with RDCON"); ax.set_ylim(-0.6,1.15) +ax.set_title("STRIDE delta_coil vs RDCON, per surface-side\n" + "good on inner surfaces; edge disagreement is RDCON degradation; projection does not close the gap", + fontsize=11.5,fontweight="bold") +ax.legend(fontsize=9,loc="lower left"); ax.grid(alpha=0.2,axis="y") +ax.text(0.99,0.02,"residual = weak-form (RDCON) vs strong-form (STRIDE) representation\n" + "+ RDCON edge degradation. Invariant quantities (Δ′, matched deltar/cout) match cleanly.", + transform=ax.transAxes,ha="right",va="bottom",fontsize=8,style="italic", + bbox=dict(boxstyle="round",fc="#F4F6FA",ec="#B4BED2")) +out="benchmarks/deltacoil_sensitivity/figures/deltacoil_vs_rdcon.png" +os.makedirs(os.path.dirname(out),exist_ok=True) +fig.savefig(out,dpi=140,bbox_inches="tight"); print("wrote",out) +# print the table too +print(f'{"variant":22s} | inner outer') +for name,cs in C.items(): print(f'{name:22s} | {np.mean(cs[:4]):5.2f} {np.mean(cs[4:]):5.2f}') From d75f694bfbb7943e26b90456ac2ce9b165e4e1fa Mon Sep 17 00:00:00 2001 From: Via Weber Date: Thu, 23 Jul 2026 13:07:49 -0400 Subject: [PATCH 17/38] FORCEFREESTATES - Minor - truncation scans begin --- .../scripts/deltacoil_psihigh_metric.jl | 136 ++++++++++++++++++ .../scripts/deltacoil_psihigh_scan.jl | 121 ++++++++++++++++ .../scripts/deltacoil_sensitivity.jl | 8 +- .../scripts/penetrated_field_psihigh.jl | 122 ++++++++++++++++ .../scripts/plot_deltacoil_psihigh.py | 109 ++++++++++++++ .../scripts/plot_deltacoil_psihigh_metric.py | 76 ++++++++++ .../scripts/plot_deltacoil_sensitivity.py | 84 ++++++----- .../scripts/plot_deltacoil_vs_rdcon.py | 9 +- .../scripts/plot_match_easycase.py | 134 +++++++++++++++++ .../scripts/plot_penetrated_field_psihigh.py | 72 ++++++++++ .../scripts/plot_resistivity_scan.py | 64 +++++++++ .../scripts/plot_resonant_coupling_psihigh.py | 72 ++++++++++ .../scripts/resistivity_scan.jl | 123 ++++++++++++++++ .../scripts/resonant_coupling_psihigh.jl | 126 ++++++++++++++++ examples/LAR_resistive_match_test/gpec.toml | 2 - 15 files changed, 1218 insertions(+), 40 deletions(-) create mode 100644 benchmarks/deltacoil_sensitivity/scripts/deltacoil_psihigh_metric.jl create mode 100644 benchmarks/deltacoil_sensitivity/scripts/deltacoil_psihigh_scan.jl create mode 100644 benchmarks/deltacoil_sensitivity/scripts/penetrated_field_psihigh.jl create mode 100644 benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_psihigh.py create mode 100644 benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_psihigh_metric.py create mode 100644 benchmarks/deltacoil_sensitivity/scripts/plot_match_easycase.py create mode 100644 benchmarks/deltacoil_sensitivity/scripts/plot_penetrated_field_psihigh.py create mode 100644 benchmarks/deltacoil_sensitivity/scripts/plot_resistivity_scan.py create mode 100644 benchmarks/deltacoil_sensitivity/scripts/plot_resonant_coupling_psihigh.py create mode 100644 benchmarks/deltacoil_sensitivity/scripts/resistivity_scan.jl create mode 100644 benchmarks/deltacoil_sensitivity/scripts/resonant_coupling_psihigh.jl diff --git a/benchmarks/deltacoil_sensitivity/scripts/deltacoil_psihigh_metric.jl b/benchmarks/deltacoil_sensitivity/scripts/deltacoil_psihigh_metric.jl new file mode 100644 index 00000000..f1abb0ed --- /dev/null +++ b/benchmarks/deltacoil_sensitivity/scripts/deltacoil_psihigh_metric.jl @@ -0,0 +1,136 @@ +#!/usr/bin/env julia +# deltacoil_psihigh_metric.jl — the dot-product (cosine) shape metric of plot 2, but with the truncation +# scanned by psihigh (absolute outer flux boundary) instead of dmlim. +# +# For each rational surface s and each psihigh, we form v_s = vec(delta_coil rows for that surface) and +# report the cosine similarity with the NOMINAL (full-domain) run: +# cos = || / (‖v_s^nom‖ ‖v_s‖) +# 1.0 means the response pattern over coil modes is identical (only rescaled); < 1.0 means it ROTATED. +# This catches shape changes that the norm ‖v_s‖ hides. +# +# Wrinkle handled: as psihigh grows, surfaces enter, and the coil poloidal-mode grid (mlow..mhigh) changes, +# so the raw column count differs between runs. We therefore ALIGN by poloidal mode m: the cosine for a +# surface uses only the m-columns common to that run and the nominal. Nominal = the largest psihigh (full +# domain). Runs with gal_match_flag=false (delta_coil only). psihigh is log-packed toward the edge. +# +# Usage: julia --project= scripts/deltacoil_psihigh_metric.jl [N] [psihigh_max] + +using GeneralizedPerturbedEquilibrium, HDF5, LinearAlgebra, Printf + +length(ARGS) >= 1 || error("usage: julia --project= scripts/deltacoil_psihigh_metric.jl [N] [psihigh_max]") +base = ARGS[1]; isdir(base) || error("no such dir: $base") +N = length(ARGS) >= 2 ? parse(Int, ARGS[2]) : 20 +PSIH_MAX = length(ARGS) >= 3 ? parse(Float64, ARGS[3]) : 0.993 +PSIH_MIN = 0.1 +ENV["DELTACOIL_MODE"] = "driven"; delete!(ENV, "DELTACOIL_PROJECT") + +PSIHIGHS = round.(1 .- 10 .^ range(log10(1 - PSIH_MIN), log10(1 - PSIH_MAX); length=N); digits=5) + +base_toml = read(joinpath(base, "gpec.toml"), String) +scratch = mktempdir() +eqm = match(r"eq_filename\s*=\s*\"([^\"]+)\"", base_toml) +eqfile = eqm === nothing ? nothing : eqm.captures[1] +eqsrc = eqfile === nothing ? nothing : (isabspath(eqfile) ? eqfile : joinpath(base, eqfile)) + +# run GPEC at one psihigh; return Dict q => (block::Matrix (2×ncoil), mlow::Int), or nothing on failure. +function run_one(ph) + tag = "ph_$(ph)" + dir = joinpath(scratch, tag); mkpath(dir) + if eqfile !== nothing + dst = joinpath(dir, basename(eqfile)); islink(dst) || symlink(realpath(eqsrc), dst) + end + toml = base_toml + for key in ("force_termination", "HDF5_filename", "psiedge", "set_psilim_via_dmlim", "gal_match_flag", + "truncate_at_dW_peak", "thmax0", "delta_mband") + toml = replace(toml, Regex("(?m)^[ \\t]*$key[ \\t]*=.*\\n") => "") + end + toml = replace(toml, "[ForceFreeStates]" => + "[ForceFreeStates]\nforce_termination = true\nHDF5_filename = \"$tag.h5\"\npsiedge = 1.0\n" * + "set_psilim_via_dmlim = false\ntruncate_at_dW_peak = false\ngal_match_flag = false"; count=1) + toml = replace(toml, r"(?m)^([ \t]*)psihigh[ \t]*=[ \t]*[\d.eE+-]+" => SubstitutionString("\\1psihigh = $ph")) + eqfile !== nothing && (toml = replace(toml, r"eq_filename\s*=\s*\"[^\"]+\"" => "eq_filename = \"$(basename(eqfile))\"")) + write(joinpath(dir, "gpec.toml"), toml) + @info ">>> psihigh = $ph" + try + GeneralizedPerturbedEquilibrium.main([dir]) + catch err + @warn "run failed at psihigh=$ph" exception = (err, catch_backtrace()) + return nothing + end + h5path = joinpath(dir, "$tag.h5") + isfile(h5path) || return Dict{Float64,Tuple{Matrix{ComplexF64},Int}}() + h5open(h5path, "r") do fid + haskey(fid, "singular/q") || return Dict{Float64,Tuple{Matrix{ComplexF64},Int}}() + q = vec(read(fid, "singular/q")) + isempty(q) && return Dict{Float64,Tuple{Matrix{ComplexF64},Int}}() + dc = read(fid, "singular/delta_coil_matrix") + dc = size(dc, 1) < size(dc, 2) ? dc : permutedims(dc) # -> (2msing, ncoil), columns m=mlow..mhigh + mlow = Int(read(fid, "info/mlow")) + out = Dict{Float64,Tuple{Matrix{ComplexF64},Int}}() + for s in 1:length(q); out[round(q[s]; digits=2)] = (Matrix{ComplexF64}(dc[2s-1:2s, :]), mlow); end + out + end +end + +# cosine of surface blocks aligned by poloidal mode m (use only the m-columns common to both) +function cosine_aligned(run, nom) + (br, mlr) = run; (bn, mln) = nom + ncr = size(br, 2); ncn = size(bn, 2) + mhr = mlr + ncr - 1; mhn = mln + ncn - 1 + mlo = max(mlr, mln); mhi = min(mhr, mhn) + mhi >= mlo || return NaN + cr = (mlo - mlr + 1):(mhi - mlr + 1) + cn = (mlo - mln + 1):(mhi - mln + 1) + a = vec(br[:, cr]); b = vec(bn[:, cn]) + (norm(a) > 0 && norm(b) > 0) ? abs(dot(b, a)) / (norm(a) * norm(b)) : NaN +end + +results = [(ph, run_one(ph)) for ph in PSIHIGHS] +# nominal = the largest psihigh with data (full domain) +nom_idx = findlast(r -> r[2] !== nothing && !isempty(r[2]), results) +nom_idx === nothing && error("no successful runs") +nominal = results[nom_idx][2] +@info "nominal (full-domain) psihigh = $(results[nom_idx][1]), surfaces present: $(sort(collect(keys(nominal))))" + +allq = sort(collect(reduce(union, (Set(keys(r[2])) for r in results if r[2] !== nothing); init=Set{Float64}()))) + +println("\n" * "="^78) +println(" Plot-2 dot-product (cosine) metric vs psihigh truncation (nominal = full domain)") +println(" cos = || / (‖v_nom‖‖v‖) per surface, aligned by poloidal mode m") +println("="^78) +@printf(" %-9s", "psihigh") +for q in allq; @printf(" | q=%-6.1f", q); end; println() +println(" " * "-"^(11 + 11 * length(allq))) +for (ph, r) in results + @printf(" %-9.4f", ph) + for q in allq + if r === nothing || !haskey(r, q) || !haskey(nominal, q) + @printf(" | %8s", "—") + else + c = cosine_aligned(r[q], nominal[q]) + @printf(" | %8s", isnan(c) ? "n/a" : @sprintf("%.4f", c)) + end + end + println() +end + +outdir = joinpath(@__DIR__, "..", "results"); mkpath(outdir) +csv = joinpath(outdir, "deltacoil_psihigh_metric.csv") +open(csv, "w") do io + println(io, "psihigh," * join(["q$(q)" for q in allq], ",")) + for (ph, r) in results + vals = String[] + for q in allq + if r === nothing || !haskey(r, q) || !haskey(nominal, q) + push!(vals, "") + else + c = cosine_aligned(r[q], nominal[q]) + push!(vals, isnan(c) ? "" : string(c)) + end + end + println(io, "$ph," * join(vals, ",")) + end +end +println("\nwrote: $(abspath(csv))") +println("read: cos ≈ 1 ⇒ shape unchanged (only rescaled by truncation); cos < 1 ⇒ the coil-response") +println("pattern ROTATED as the truncation boundary moved. The norm alone would hide this.") diff --git a/benchmarks/deltacoil_sensitivity/scripts/deltacoil_psihigh_scan.jl b/benchmarks/deltacoil_sensitivity/scripts/deltacoil_psihigh_scan.jl new file mode 100644 index 00000000..251df5e4 --- /dev/null +++ b/benchmarks/deltacoil_sensitivity/scripts/deltacoil_psihigh_scan.jl @@ -0,0 +1,121 @@ +#!/usr/bin/env julia +# deltacoil_psihigh_scan.jl — sensitivity of delta_coil to the outer truncation, scanned via the +# ABSOLUTE flux boundary psihigh (instead of the relative dmlim). +# +# Setup per the requested convention: +# set_psilim_via_dmlim = false → psilim follows psihigh (dmlim truncation OFF) +# psiedge = 1 → disable the edge-dW scan band +# truncate_at_dW_peak = false → psihigh is the SOLE truncation control +# gal_match_flag = false → delta_coil only (no inner match; no msing/array guard; surface +# count free to change as psihigh moves past each rational ψ) +# +# psihigh is swept from PSIH_MIN(0.1) to PSIH_MAX(~edge) with LOGARITHMIC packing toward the edge +# (points cluster near ψ→1, where truncation bites). Results aligned BY q-VALUE, so surfaces +# appearing/disappearing across the sweep are handled cleanly. +# +# Usage (repo root, project env): +# julia --project=/abs/path/to/GPEC scripts/deltacoil_psihigh_scan.jl [N] [psihigh_max] +# +# Runs GPEC once per point (~2 min each). Each point re-solves the equilibrium out to psihigh. + +using GeneralizedPerturbedEquilibrium, HDF5, LinearAlgebra, Printf + +length(ARGS) >= 1 || error("usage: julia --project= scripts/deltacoil_psihigh_scan.jl [N] [psihigh_max]") +base = ARGS[1]; isdir(base) || error("no such dir: $base") +N = length(ARGS) >= 2 ? parse(Int, ARGS[2]) : 10 +PSIH_MAX = length(ARGS) >= 3 ? parse(Float64, ARGS[3]) : 0.993 # numerically-safe edge (separatrix ~1 is unreasonable) +PSIH_MIN = 0.1 +ENV["DELTACOIL_MODE"] = "driven"; delete!(ENV, "DELTACOIL_PROJECT") + +# logarithmic packing toward the edge: dense near ψ→1, first point = PSIH_MIN, last = PSIH_MAX +PSIHIGHS = round.(1 .- 10 .^ range(log10(1 - PSIH_MIN), log10(1 - PSIH_MAX); length=N); digits=5) + +base_toml = read(joinpath(base, "gpec.toml"), String) +scratch = mktempdir() +eqm = match(r"eq_filename\s*=\s*\"([^\"]+)\"", base_toml) +eqfile = eqm === nothing ? nothing : eqm.captures[1] +eqsrc = eqfile === nothing ? nothing : (isabspath(eqfile) ? eqfile : joinpath(base, eqfile)) + +# run GPEC at one psihigh; return (sorted q-values, |delta_coil| per surface keyed by q). nothing on failure. +function run_one(ph) + tag = "ph_$(ph)" + dir = joinpath(scratch, tag); mkpath(dir) + if eqfile !== nothing + dst = joinpath(dir, basename(eqfile)); islink(dst) || symlink(realpath(eqsrc), dst) + end + toml = base_toml + # Strip every FFS knob we override (examples differ: some set force_termination/HDF5_filename/psiedge, + # some don't — stripping first avoids TOML duplicate-key errors), then inject one canonical block after + # the section header. delta_coil is computed inside ForceFreeStates, so force_termination=true stops + # before PerturbedEquilibrium and the explicit HDF5_filename is the file we read back. + # also drop keys removed from ForceFreeStatesControl on this branch (thmax0, delta_mband) — some + # example tomls (e.g. LAR) still carry them and would otherwise fail the control constructor. + for key in ("force_termination", "HDF5_filename", "psiedge", "set_psilim_via_dmlim", "gal_match_flag", + "truncate_at_dW_peak", "thmax0", "delta_mband") + toml = replace(toml, Regex("(?m)^[ \\t]*$key[ \\t]*=.*\\n") => "") + end + toml = replace(toml, "[ForceFreeStates]" => + "[ForceFreeStates]\nforce_termination = true\nHDF5_filename = \"$tag.h5\"\npsiedge = 1.0\n" * + "set_psilim_via_dmlim = false\ntruncate_at_dW_peak = false\ngal_match_flag = false"; count=1) + toml = replace(toml, r"(?m)^([ \t]*)psihigh[ \t]*=[ \t]*[\d.eE+-]+" => SubstitutionString("\\1psihigh = $ph")) + eqfile !== nothing && (toml = replace(toml, r"eq_filename\s*=\s*\"[^\"]+\"" => "eq_filename = \"$(basename(eqfile))\"")) + write(joinpath(dir, "gpec.toml"), toml) + @info ">>> psihigh = $ph" + try + GeneralizedPerturbedEquilibrium.main([dir]) + catch err + @warn "run failed at psihigh=$ph" exception = (err, catch_backtrace()) + return nothing + end + h5path = joinpath(dir, "$tag.h5") + isfile(h5path) || (@warn "no output h5 at psihigh=$ph (likely no singular surfaces inside psihigh)"; return (Float64[], Dict{Float64,Float64}())) + h5open(h5path, "r") do fid + haskey(fid, "singular/q") || return (Float64[], Dict{Float64,Float64}()) + q = vec(read(fid, "singular/q")) + isempty(q) && return (Float64[], Dict{Float64,Float64}()) + dc = read(fid, "singular/delta_coil_matrix") + dc = size(dc, 1) < size(dc, 2) ? dc : permutedims(dc) # -> (2msing, ncoil) + mag = Dict{Float64,Float64}() + for s in 1:length(q); mag[round(q[s]; digits=2)] = norm(dc[2s-1:2s, :]); end + (sort(round.(q; digits=2)), mag) + end +end + +runs = [(ph, run_one(ph)) for ph in PSIHIGHS] + +# ---- report ---- +allq = sort(collect(reduce(union, (Set(keys(r[2][2])) for r in runs if r[2] !== nothing); init=Set{Float64}()))) +println("\n" * "="^78) +println(" delta_coil vs OUTER TRUNCATION scanned by psihigh (log-packed toward edge)") +println(" set_psilim_via_dmlim=false, psiedge=1, truncate_at_dW_peak=false, gal_match_flag=false") +println(" |delta_coil| per surface (blank = surface not present at that psihigh)") +println("="^78) +@printf(" %-9s", "psihigh") +for q in allq; @printf(" | q=%-6.1f", q); end; println() +println(" " * "-"^(11 + 11 * length(allq))) +for (ph, r) in runs + @printf(" %-9.4f", ph) + if r === nothing + for _ in allq; @printf(" | %8s", "FAIL"); end + else + for q in allq + m = get(r[2], q, nothing) + m === nothing ? @printf(" | %8s", "—") : @printf(" | %8.2e", m) + end + end + println() +end + +# CSV for plotting +outdir = joinpath(@__DIR__, "..", "results"); mkpath(outdir) +csv = joinpath(outdir, "deltacoil_psihigh_result.csv") +open(csv, "w") do io + println(io, "psihigh," * join(["q$(q)" for q in allq], ",")) + for (ph, r) in runs + vals = [r === nothing ? "" : (haskey(r[2], q) ? string(r[2][q]) : "") for q in allq] + println(io, "$ph," * join(vals, ",")) + end +end +println("\nwrote: $(abspath(csv))") +println("interpretation: as psihigh → edge, more surfaces enter and each surface's |delta_coil|") +println("settles; the edge-most surface is where the truncation boundary bites hardest.") diff --git a/benchmarks/deltacoil_sensitivity/scripts/deltacoil_sensitivity.jl b/benchmarks/deltacoil_sensitivity/scripts/deltacoil_sensitivity.jl index d313e44a..57353de9 100644 --- a/benchmarks/deltacoil_sensitivity/scripts/deltacoil_sensitivity.jl +++ b/benchmarks/deltacoil_sensitivity/scripts/deltacoil_sensitivity.jl @@ -38,7 +38,13 @@ function run_one(tag, patches) for (r, s) in patches; toml = replace(toml, r => s); end toml = replace(toml, r"gal_match_flag\s*=\s*\w+" => "gal_match_flag = false") # delta_coil only toml = replace(toml, r"eq_filename\s*=\s*\"[^\"]+\"" => "eq_filename = \"$(basename(eqfile))\"") - toml = replace(toml, r"HDF5_filename\s*=\s*\"[^\"]+\"" => "HDF5_filename = \"$tag.h5\"") + # delta_coil is written by ForceFreeStates; terminate there (skip PE, which would otherwise send the + # consolidated output to the default gpec.h5) and pin the FFS output file we read back. Strip any + # pre-existing copies first to avoid TOML duplicate-key errors across example variants. + for key in ("force_termination", "HDF5_filename") + toml = replace(toml, Regex("(?m)^[ \\t]*$key[ \\t]*=.*\\n") => "") + end + toml = replace(toml, "[ForceFreeStates]" => "[ForceFreeStates]\nforce_termination = true\nHDF5_filename = \"$tag.h5\""; count=1) write(joinpath(dir, "gpec.toml"), toml) @info ">>> $tag" GeneralizedPerturbedEquilibrium.main([dir]) diff --git a/benchmarks/deltacoil_sensitivity/scripts/penetrated_field_psihigh.jl b/benchmarks/deltacoil_sensitivity/scripts/penetrated_field_psihigh.jl new file mode 100644 index 00000000..f6680e18 --- /dev/null +++ b/benchmarks/deltacoil_sensitivity/scripts/penetrated_field_psihigh.jl @@ -0,0 +1,122 @@ +#!/usr/bin/env julia +# penetrated_field_psihigh.jl -- truncation scan of the PENETRATED (reconnected) resonant field versus +# the outer boundary psihigh, from a single match run, for both paths: +# STRIDE path : singular/resonant_match/reconnected_flux (Ψ ~ delta_coil + delta_out * cout) +# galerkin path : galerkin/match/bpen (area-weighted penetrated b, -2pi/v1 fix) +# reconnected_flux is (ncoil, 2msing) (two sides per surface); bpen is (ncoil, msing) (one per surface). +# We report the per-surface norm of each. This is the field that actually penetrates the resistive layer, +# one step past the matched coupling cout. +# +# Resistive arrays (gal_eta/gal_rho/gal_rotation) must have length msing, which changes as psihigh passes +# surfaces: run the full domain first (input arrays already correct) to get surface locations, then size +# the arrays to the surface count at each psihigh. +# +# Usage: julia --project= scripts/penetrated_field_psihigh.jl [N] [psihigh_max] + +using GeneralizedPerturbedEquilibrium, HDF5, LinearAlgebra, Printf + +length(ARGS) >= 1 || error("usage: julia --project= scripts/penetrated_field_psihigh.jl [N] [psihigh_max]") +base = ARGS[1]; isdir(base) || error("no such dir: $base") +N = length(ARGS) >= 2 ? parse(Int, ARGS[2]) : 12 +PSIH_MAX = length(ARGS) >= 3 ? parse(Float64, ARGS[3]) : 0.993 +ENV["DELTACOIL_MODE"] = "driven"; delete!(ENV, "DELTACOIL_PROJECT") + +base_toml = read(joinpath(base, "gpec.toml"), String) +scratch = mktempdir() +eqm = match(r"eq_filename\s*=\s*\"([^\"]+)\"", base_toml) +eqfile = eqm === nothing ? nothing : eqm.captures[1] +eqsrc = eqfile === nothing ? nothing : (isabspath(eqfile) ? eqfile : joinpath(base, eqfile)) + +firstval(key, default) = (m = match(Regex("$key\\s*=\\s*\\[\\s*([\\d.eE+-]+)"), base_toml); m === nothing ? default : parse(Float64, m.captures[1])) +ETA = firstval("gal_eta", 8e-8); RHO = firstval("gal_rho", 3.3e-7); ROT = firstval("gal_rotation", 1.0) + +function run_match(ph, ms) + tag = "ph_$(ph)" + dir = joinpath(scratch, tag); mkpath(dir) + if eqfile !== nothing + dst = joinpath(dir, basename(eqfile)); islink(dst) || symlink(realpath(eqsrc), dst) + end + toml = base_toml + strip_keys = ["force_termination", "HDF5_filename", "psiedge", "set_psilim_via_dmlim", + "truncate_at_dW_peak", "thmax0", "delta_mband", "gal_match_flag"] + ms === nothing || append!(strip_keys, ["gal_eta", "gal_rho", "gal_rotation"]) + for key in strip_keys + toml = replace(toml, Regex("(?m)^[ \\t]*$key[ \\t]*=.*\\n") => "") + end + arrblk = ms === nothing ? "" : + "gal_eta = [" * join(fill(string(ETA), ms), ", ") * "]\n" * + "gal_rho = [" * join(fill(string(RHO), ms), ", ") * "]\n" * + "gal_rotation = [" * join(fill(string(ROT), ms), ", ") * "]\n" + toml = replace(toml, "[ForceFreeStates]" => + "[ForceFreeStates]\nforce_termination = true\nHDF5_filename = \"$tag.h5\"\npsiedge = 1.0\n" * + "set_psilim_via_dmlim = false\ntruncate_at_dW_peak = false\ngal_match_flag = true\n" * arrblk; count=1) + toml = replace(toml, r"(?m)^([ \t]*)psihigh[ \t]*=[ \t]*[\d.eE+-]+" => SubstitutionString("\\1psihigh = $ph")) + eqfile !== nothing && (toml = replace(toml, r"eq_filename\s*=\s*\"[^\"]+\"" => "eq_filename = \"$(basename(eqfile))\"")) + write(joinpath(dir, "gpec.toml"), toml) + @info ">>> psihigh = $ph (msing arrays = $(ms === nothing ? "toml" : ms))" + try + GeneralizedPerturbedEquilibrium.main([dir]) + catch err + @warn "run failed at psihigh=$ph" exception = (err, catch_backtrace()) + return nothing + end + h5path = joinpath(dir, "$tag.h5") + isfile(h5path) || return nothing + h5open(h5path, "r") do fid + haskey(fid, "singular/q") || return nothing + q = vec(read(fid, "singular/q")); isempty(q) && return nothing + psi = vec(read(fid, "singular/psi")) + # STRIDE reconnected_flux: (ncoil, 2msing), 2 columns per surface + pk2(dc) = (dc = size(dc, 1) >= size(dc, 2) ? dc : permutedims(dc); + Dict(round(q[s]; digits=2) => norm(dc[:, 2s-1:2s]) for s in 1:length(q))) + # galerkin bpen: (ncoil, msing), 1 column per surface + pk1(dc) = (dc = size(dc, 1) >= size(dc, 2) ? dc : permutedims(dc); + Dict(round(q[s]; digits=2) => norm(dc[:, s]) for s in 1:length(q))) + stride_p = haskey(fid, "singular/resonant_match/reconnected_flux") ? pk2(read(fid, "singular/resonant_match/reconnected_flux")) : Dict{Float64,Float64}() + gal_p = haskey(fid, "galerkin/match/bpen") ? pk1(read(fid, "galerkin/match/bpen")) : Dict{Float64,Float64}() + (q=sort(round.(q; digits=2)), psi=psi, stride=stride_p, gal=gal_p) + end +end + +@info "=== nominal (full domain) ===" +nom = run_match(PSIH_MAX, nothing) +nom === nothing && error("nominal full-domain match failed") +psi_s = sort(nom.psi) +@info "surfaces (psi_s): $psi_s full msing=$(length(psi_s))" + +PSIHIGHS = round.(1 .- 10 .^ range(log10(1 - (psi_s[1] + 1e-3)), log10(1 - PSIH_MAX); length=N); digits=5) + +results = Tuple{Float64,Any}[] +for ph in PSIHIGHS + ms = count(<(ph), psi_s) + push!(results, (ph, ms == 0 ? nothing : (ph == PSIH_MAX ? nom : run_match(ph, ms)))) +end +any(r -> r[1] == PSIH_MAX, results) || push!(results, (PSIH_MAX, nom)) + +allq = sort(collect(nom.q)) +outdir = joinpath(@__DIR__, "..", "results"); mkpath(outdir) +tag = replace(basename(base), r"[^A-Za-z0-9]" => "") +csv = joinpath(outdir, "penetrated_field_psihigh_$tag.csv") +open(csv, "w") do io + println(io, "psihigh," * join(["stride_q$(q)" for q in allq], ",") * "," * join(["gal_q$(q)" for q in allq], ",")) + for (ph, r) in sort(results, by=first) + sv = [r === nothing ? "" : string(get(r.stride, q, "")) for q in allq] + gv = [r === nothing ? "" : string(get(r.gal, q, "")) for q in allq] + println(io, "$ph," * join(sv, ",") * "," * join(gv, ",")) + end +end + +println("\n" * "="^78) +println(" PENETRATED field per surface vs psihigh ($(basename(base)))") +println(" STRIDE = singular/resonant_match/reconnected_flux ; galerkin = galerkin/match/bpen") +println("="^78) +@printf(" %-9s", "psihigh") +for q in allq; @printf(" | S:q%-4.1f", q); end +for q in allq; @printf(" | G:q%-4.1f", q); end; println() +for (ph, r) in sort(results, by=first) + @printf(" %-9.4f", ph) + for q in allq; @printf(" | %7s", r === nothing ? "-" : (haskey(r.stride, q) ? @sprintf("%.2e", r.stride[q]) : "-")); end + for q in allq; @printf(" | %7s", r === nothing ? "-" : (haskey(r.gal, q) ? @sprintf("%.2e", r.gal[q]) : "-")); end + println() +end +println("\nwrote: $(abspath(csv))") diff --git a/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_psihigh.py b/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_psihigh.py new file mode 100644 index 00000000..3868b043 --- /dev/null +++ b/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_psihigh.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +# plot_deltacoil_psihigh.py - plot |delta_coil| per rational surface vs the outer truncation psihigh +# (log-packed toward the edge). Reads results/deltacoil_psihigh_result.csv written by +# deltacoil_psihigh_scan.jl. Each surface's curve begins where psihigh first exceeds its psi_s. +# +# Usage: python3 plot_deltacoil_psihigh.py [results/deltacoil_psihigh_result.csv] + +import sys, os, csv +import numpy as np +import matplotlib +matplotlib.use('Agg') +import matplotlib.pyplot as plt + +here = os.path.dirname(os.path.abspath(__file__)) +csv_path = sys.argv[1] if len(sys.argv) > 1 else os.path.join(here, '..', 'results', 'deltacoil_psihigh_result.csv') +csv_path = os.path.abspath(csv_path) + +# --- read CSV --- +with open(csv_path) as f: + rows = list(csv.reader(f)) +header = rows[0] +qcols = header[1:] # e.g. ['q2.0','q3.0','q4.0','q5.0'] +qvals = [float(c[1:]) for c in qcols] +psihigh, data = [], {q: [] for q in qvals} +for r in rows[1:]: + if not r or r[0] == '': + continue + psihigh.append(float(r[0])) + for q, cell in zip(qvals, r[1:]): + data[q].append(float(cell) if cell not in ('', 'FAIL') else np.nan) +psihigh = np.array(psihigh) + +# case label derived from the CSV name, so titles are correct for DIII-D / LAR / etc. +_case = os.path.splitext(os.path.basename(csv_path))[0].replace('_result', '').replace('deltacoil_psihigh', '').strip('_').upper() +CASE = {'': 'DIII-D-like', 'DIIID': 'DIII-D-like', 'LAR': 'LAR (large-aspect-ratio)'}.get(_case, _case) + +# entry psihigh per surface (first psihigh at which it appears) - a case-agnostic proxy for psi_s, +# so the same plotter works for DIII-D, LAR, or any case without hardcoded surface locations. +def entry_psihigh(q): + y = np.array(data[q], float) + idx = np.where(~np.isnan(y))[0] + return psihigh[idx[0]] if len(idx) else np.nan +PSI_S = {q: entry_psihigh(q) for q in qvals} +colors = plt.cm.viridis(np.linspace(0.08, 0.82, len(qvals))) + +# x-axis = distance from the edge (1 - psihigh) on a log scale (edge at right), so the log-packed +# near-edge points spread out and curves approach convergence horizontally, not vertically. Anchor to +# the leftmost psihigh that has data so empty low-psihigh points do not push labels off the range. +_has = np.array([any(not np.isnan(data[q][i]) for q in qvals) for i in range(len(psihigh))]) +x0 = psihigh[_has].min() if _has.any() else psihigh.min() +def edge_log_x(ax): + fwd = lambda p: -np.log10(np.clip(1.0 - np.asarray(p, float), 1e-9, None)) + inv = lambda t: 1.0 - 10.0 ** (-np.asarray(t, float)) + ax.set_xscale('function', functions=(fwd, inv)) + cand = np.array([0.6, 0.75, 0.85, 0.9, 0.95, 0.98, 0.99]) + tk = cand[(cand >= x0 - 1e-9) & (cand <= psihigh.max() + 1e-9)] + ax.set_xticks(tk); ax.set_xticklabels([f'{t:g}' for t in tk]); ax.minorticks_off() + ax.set_xlim(x0 - (1 - x0) * 0.06, psihigh.max() + (1 - psihigh.max()) * 0.4) + +fig, ax = plt.subplots(figsize=(8.2, 5.0)) +ax.set_yscale('log') # log-y so the edge surface (q5) does not flatten q2/q3/q4 +for q, c in zip(qvals, colors): + y = np.array(data[q], float) + m = ~np.isnan(y) + ax.plot(psihigh[m], y[m], 'o-', color=c, lw=1.8, ms=6, label=f'q = {q:g}') +for q, c in zip(qvals, colors): # vlines after data so ylim is settled on the log scale + if q in PSI_S and np.isfinite(PSI_S[q]): + ax.axvline(PSI_S[q], ls=':', color=c, lw=1.0, alpha=0.7) + ax.text(PSI_S[q], ax.get_ylim()[1], f' q={q:g} enters ≈{PSI_S[q]:.3f}', color=c, + fontsize=7.5, rotation=90, va='top', ha='left') + +edge_log_x(ax) +ax.set_xlabel('outer truncation ψ$_{high}$ (log distance from edge, 1 - ψ$_{high}$; edge at right)', fontsize=11) +ax.set_ylabel('|delta_coil| per surface (log)', fontsize=11) +ax.set_title('delta_coil vs outer truncation ψ$_{high}$ (log-packed toward edge)\n' + f'{CASE}, n=1 · each curve begins where ψ$_{{high}}$ passes that surface', fontsize=11) +ax.grid(alpha=0.25) +ax.legend(title='rational surface', fontsize=9, title_fontsize=9) +fig.subplots_adjust(left=0.12, bottom=0.13, right=0.97, top=0.88) + +# derive output name from the CSV stem so different cases (diiid/lar) get distinct figures +stem = os.path.splitext(os.path.basename(csv_path))[0].replace('_result', '') +out = os.path.join(here, '..', 'figures', stem + '.png') +out = os.path.abspath(out); os.makedirs(os.path.dirname(out), exist_ok=True) +fig.savefig(out, dpi=150) +print('wrote:', out) + +# second panel: relative change vs the edge-most (last) psihigh - how much truncation still moves each surface +fig2, ax2 = plt.subplots(figsize=(8.2, 5.0)) +for q, c in zip(qvals, colors): + y = np.array(data[q], float) + m = ~np.isnan(y) + if m.sum() < 2: + continue + ref = y[m][-1] # reference = value at largest psihigh where present + ax2.plot(psihigh[m], np.abs(y[m] - ref) / max(abs(ref), 1e-300), 'o-', color=c, lw=1.8, ms=6, label=f'q = {q:g}') +ax2.set_yscale('log') +edge_log_x(ax2) +ax2.set_xlabel('outer truncation ψ$_{high}$ (log distance from edge, 1 - ψ$_{high}$; edge at right)', fontsize=11) +ax2.set_ylabel('|Δ delta_coil| / |delta_coil(ψ$_{high}$=max)|', fontsize=11) +ax2.set_title(f'Relative sensitivity of delta_coil to the truncation boundary, {CASE}\n' + '(vs the outermost ψ$_{high}$ reference; lower = more converged)', fontsize=11) +ax2.grid(alpha=0.25, which='both') +ax2.legend(title='rational surface', fontsize=9, title_fontsize=9) +fig2.subplots_adjust(left=0.13, bottom=0.13, right=0.97, top=0.88) +out2 = os.path.join(here, '..', 'figures', stem + '_relchange.png') +out2 = os.path.abspath(out2) +fig2.savefig(out2, dpi=150) +print('wrote:', out2) diff --git a/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_psihigh_metric.py b/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_psihigh_metric.py new file mode 100644 index 00000000..91e112a2 --- /dev/null +++ b/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_psihigh_metric.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +# plot_deltacoil_psihigh_metric.py - plot the plot-2 dot-product (cosine) shape metric vs the psihigh +# truncation. Reads results/deltacoil_psihigh_metric.csv from deltacoil_psihigh_metric.jl. +# Each curve is one rational surface: cosine similarity of its delta_coil with the full-domain nominal. +# cos = 1 means shape unchanged (only rescaled); cos < 1 means the coil-response pattern rotated. +# +# Usage: python3 plot_deltacoil_psihigh_metric.py [results/deltacoil_psihigh_metric.csv] + +import sys, os, csv +import numpy as np +import matplotlib +matplotlib.use('Agg') +import matplotlib.pyplot as plt + +here = os.path.dirname(os.path.abspath(__file__)) +csv_path = sys.argv[1] if len(sys.argv) > 1 else os.path.join(here, '..', 'results', 'deltacoil_psihigh_metric.csv') +csv_path = os.path.abspath(csv_path) + +with open(csv_path) as f: + rows = list(csv.reader(f)) +header = rows[0] +qvals = [float(c[1:]) for c in header[1:]] +psihigh, data = [], {q: [] for q in qvals} +for r in rows[1:]: + if not r or r[0] == '': + continue + psihigh.append(float(r[0])) + for q, cell in zip(qvals, r[1:]): + data[q].append(float(cell) if cell not in ('', 'FAIL') else np.nan) +psihigh = np.array(psihigh) + +_case = os.path.splitext(os.path.basename(csv_path))[0].replace('_metric', '').replace('deltacoil_psihigh', '').strip('_').upper() +CASE = {'': 'DIII-D-like', 'DIIID': 'DIII-D-like', 'LAR': 'LAR (large-aspect-ratio)'}.get(_case, _case) + +colors = plt.cm.viridis(np.linspace(0.08, 0.82, len(qvals))) + +# leftmost psihigh that actually has data (first surface entry); ticks/annotations anchor here so the +# empty low-psihigh points do not push labels off the plotted range. +_has = np.array([any(not np.isnan(data[q][i]) for q in qvals) for i in range(len(psihigh))]) +x0 = psihigh[_has].min() if _has.any() else psihigh.min() + +fig, ax = plt.subplots(figsize=(8.4, 5.1)) +for q, c in zip(qvals, colors): + y = np.array(data[q], float) + m = ~np.isnan(y) + ax.plot(psihigh[m], y[m], 'o-', color=c, lw=1.9, ms=6, label=f'q = {q:g}') + +ax.axhline(1.0, ls=':', color='#888', lw=1.0) +ax.text(x0, 1.03, '1.0 = shape unchanged (only rescaled)', fontsize=8, color='#555', va='bottom', ha='left') +ax.text(x0, 0.06, 'low = pattern rotated\n(shape change the norm hides)', + fontsize=8, color='#a33', va='bottom', ha='left') +# x-axis = distance from the edge (1 - psihigh) on a log scale (edge at right), so the log-packed +# near-edge points spread out and the curves approach convergence horizontally, not vertically. +_fwd = lambda p: -np.log10(np.clip(1.0 - np.asarray(p, float), 1e-9, None)) +_inv = lambda t: 1.0 - 10.0 ** (-np.asarray(t, float)) +ax.set_xscale('function', functions=(_fwd, _inv)) +_cand = np.array([0.6, 0.75, 0.85, 0.9, 0.95, 0.98, 0.99]) +_ticks = _cand[(_cand >= x0 - 1e-9) & (_cand <= psihigh.max() + 1e-9)] +ax.set_xticks(_ticks); ax.set_xticklabels([f'{t:g}' for t in _ticks]) +ax.minorticks_off() +ax.set_xlim(x0 - (1 - x0) * 0.06, psihigh.max() + (1 - psihigh.max()) * 0.4) +ax.set_xlabel('outer truncation ψ$_{high}$ (log distance from edge, 1 - ψ$_{high}$; edge at right)', fontsize=11) +ax.set_ylabel('cosine similarity of delta_coil with full-domain nominal', fontsize=11) +ax.set_title('Dot-product (shape) metric vs psihigh truncation\n' + f'{CASE}, n=1 · per surface, aligned by poloidal mode m · nominal = largest ψ$_{{high}}$', + fontsize=11) +ax.set_ylim(-0.05, 1.06) +ax.grid(alpha=0.25) +ax.legend(title='rational surface', fontsize=9, title_fontsize=9, loc='lower right') +fig.subplots_adjust(left=0.12, bottom=0.13, right=0.97, top=0.86) + +stem = os.path.splitext(os.path.basename(csv_path))[0] +out = os.path.abspath(os.path.join(here, '..', 'figures', stem + '.png')) +os.makedirs(os.path.dirname(out), exist_ok=True) +fig.savefig(out, dpi=150) +print('wrote:', out) diff --git a/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_sensitivity.py b/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_sensitivity.py index 3fb68732..04134514 100644 --- a/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_sensitivity.py +++ b/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_sensitivity.py @@ -1,52 +1,72 @@ #!/usr/bin/env python3 -"""plot_deltacoil_sensitivity.py — visualize delta_coil sensitivity to dmlim and singfac_min. -Values measured by deltacoil_sensitivity.jl on the DIII-D-like case (qmax=5.426).""" +"""plot_deltacoil_sensitivity.py - delta_coil sensitivity to dmlim (outer truncation) and singfac_min +(rational-surface approach distance), DIII-D-like n=1. Overlays the OLD DIII-D equilibrium (dashed, +open markers) against the RETARGETED Ip=1.15 MA equilibrium (solid, filled; commit 8f0a7cce). +NOTE: the delta_coil code is bit-identical across branches - this-branch code on the old geqdsk +reproduces the old values exactly (q5=38.57). The visible difference is ENTIRELY the equilibrium +retarget (q95 4.505->4.782, qmax 5.426->5.972), amplified at the edge surface q5 which is hyper- +sensitive to the edge q-profile/truncation. Values measured by deltacoil_sensitivity.jl (gal_match_flag=false).""" import numpy as np, matplotlib matplotlib.use("Agg"); import matplotlib.pyplot as plt +import os -# --- Sweep A: dmlim (truncation); NaN = surface absent at that dmlim --- +# ---- Sweep A: dmlim (truncation), set_psilim_via_dmlim=true ; NaN = surface absent ---- dmlim = np.array([0.1, 0.3, 0.5, 0.7, 0.9]) -A = { # |delta_coil| per surface +A_prev = { # previous week (loop-boundary-cond-riccati); q5 dropped out for dmlim > qmax-5 = 0.426 "q=2": [11.40, 11.72, 11.07, 11.00, 11.14], "q=3": [9.389, 9.560, 9.619, 9.319, 9.279], "q=4": [12.42, 12.51, 14.57, 13.05, 12.51], "q=5 (edge)": [38.57, 33.49, np.nan, np.nan, np.nan], } -# --- Sweep B: singfac_min (approach distance) --- +A_new = { # this branch (GGJ_colocation_backend): q5 now present at all dmlim and ~4x larger + "q=2": [10.36, 10.56, 10.83, 11.12, 11.42], + "q=3": [8.279, 8.334, 8.460, 8.607, 8.762], + "q=4": [11.34, 11.13, 11.12, 11.20, 11.28], + "q=5 (edge)": [200.6, 152.7, 139.4, 134.1, 130.9], +} +# ---- Sweep B: singfac_min (approach distance), dmlim fixed at 0.2 ---- sfac = np.array([1e-5, 1e-4, 1e-3]) -B = { - "q=2": [11.55, 11.55, 11.55], - "q=3": [9.469, 9.469, 9.469], - "q=4": [12.45, 12.45, 12.45], - "q=5 (edge)": [35.09, 35.10, 35.09], +B_prev = { + "q=2": [11.55, 11.55, 11.55], "q=3": [9.469, 9.469, 9.469], + "q=4": [12.45, 12.45, 12.45], "q=5 (edge)": [35.09, 35.10, 35.09], +} +B_new = { + "q=2": [10.45, 10.45, 10.45], "q=3": [8.292, 8.292, 8.292], + "q=4": [11.20, 11.20, 11.20], "q=5 (edge)": [167.8, 167.8, 167.8], } colors = {"q=2": "#2E86C1", "q=3": "#28B463", "q=4": "#E67E22", "q=5 (edge)": "#C0392B"} -QMAX = 5.426; thresh = QMAX - 5.0 # dmlim above which q5 is dropped -fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(13, 5.4), constrained_layout=True) -fig.suptitle("delta_coil sensitivity — DIII-D-like, n=1 (qmax = 5.426)", fontsize=13, fontweight="bold") +fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(13, 5.6), constrained_layout=True) +fig.suptitle("delta_coil sensitivity, DIII-D-like, n=1 · old equilibrium (dashed) vs retargeted Ip=1.15 MA (solid)", + fontsize=13, fontweight="bold") -for k, v in A.items(): - ax1.plot(dmlim, v, "o-", color=colors[k], lw=2, ms=7, label=k) -ax1.axvline(thresh, color="grey", ls="--", lw=1.4) -ax1.text(thresh + 0.01, ax1.get_ylim()[1]*0.96, f" q5 dropped for\n dmlim > {thresh:.3f}\n (q₅+dmlim > qmax)", - va="top", fontsize=8.5, color="grey") -ax1.set_xlabel("dmlim (outer truncation, set_psilim_via_dmlim=true)") -ax1.set_ylabel("|delta_coil|") -ax1.set_title("Sweep A — truncation point", fontsize=11, fontweight="bold") -ax1.legend(fontsize=9); ax1.grid(alpha=0.25) +for k in A_new: + ax1.plot(dmlim, A_prev[k], "o--", color=colors[k], lw=1.6, ms=6, mfc="white", alpha=0.8) + ax1.plot(dmlim, A_new[k], "o-", color=colors[k], lw=2.2, ms=7, label=k) +ax1.set_yscale("log") # log-y: q5 (~130-200) dwarfs q2/q3/q4 (~10) +ax1.set_xlabel("dmlim (outer truncation, set_psilim_via_dmlim = true)") +ax1.set_ylabel("|delta_coil| (log)") +ax1.set_title("Sweep A: truncation point", fontsize=11, fontweight="bold") +ax1.legend(fontsize=9, title="solid = Ip=1.15 MA (new geqdsk)\ndashed = old geqdsk", title_fontsize=8) +ax1.grid(alpha=0.25, which="both") +ax1.annotate("q5 shifts ~5x with the equilibrium retarget\n(edge q-profile); delta_coil code is\nbit-identical across branches", + xy=(0.5, 139.4), xytext=(0.34, 58), fontsize=8.2, color=colors["q=5 (edge)"], + arrowprops=dict(arrowstyle="->", color=colors["q=5 (edge)"], lw=1.1)) -for k, v in B.items(): - ax2.plot(sfac, v, "o-", color=colors[k], lw=2, ms=7, label=k) -ax2.set_xscale("log") +for k in B_new: + ax2.plot(sfac, B_prev[k], "o--", color=colors[k], lw=1.6, ms=6, mfc="white", alpha=0.8) + ax2.plot(sfac, B_new[k], "o-", color=colors[k], lw=2.2, ms=7, label=k) +ax2.set_xscale("log"); ax2.set_yscale("log") ax2.set_xlabel("singfac_min (rational-surface approach distance)") -ax2.set_ylabel("|delta_coil|") -ax2.set_title("Sweep B — approach distance (dmlim=0.2, all 4 surfaces)", fontsize=11, fontweight="bold") -ax2.legend(fontsize=9); ax2.grid(alpha=0.25) -ax2.text(0.5, 0.02, "flat to 5–6 significant figures across 2 decades — delta_coil is\n" - "essentially independent of singfac_min at every surface incl. edge", +ax2.set_ylabel("|delta_coil| (log)") +ax2.set_title("Sweep B: approach distance (dmlim = 0.2, all 4 surfaces)", fontsize=11, fontweight="bold") +ax2.legend(fontsize=9); ax2.grid(alpha=0.25, which="both") +ax2.text(0.5, 0.02, "flat to 5-6 sig figs across 2 decades for both equilibria,\n" + "delta_coil is essentially independent of singfac_min at every surface", transform=ax2.transAxes, ha="center", va="bottom", fontsize=8.5, style="italic") -out = "deltacoil_sensitivity.png" +here = os.path.dirname(os.path.abspath(__file__)) +out = os.path.abspath(os.path.join(here, "..", "figures", "deltacoil_sensitivity.png")) +os.makedirs(os.path.dirname(out), exist_ok=True) fig.savefig(out, dpi=140, bbox_inches="tight") -print(f"wrote {out}") +print("wrote:", out) diff --git a/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_vs_rdcon.py b/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_vs_rdcon.py index 5f27e760..32539694 100644 --- a/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_vs_rdcon.py +++ b/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_vs_rdcon.py @@ -15,9 +15,8 @@ def load(fn, key): R = load("gpec.h5", "galerkin/delta_coil") # RDCON weak-form reference variants = {"strong-form (driven)":"gpec_driven.h5", - "weak-form projection":"gpec_project.h5", - "energy projection":"gpec_project_energy.h5"} -cols = {"strong-form (driven)":"#2E86C1","weak-form projection":"#B9770E","energy projection":"#28B463"} + "weak-form projection":"gpec_project.h5"} +cols = {"strong-form (driven)":"#2E86C1","weak-form projection":"#B9770E"} sides = ["q2L","q2R","q3L","q3R","q4L","q4R","q5L","q5R"] def corr(a,b): a,b=np.abs(a),np.abs(b); return np.corrcoef(a,b)[0,1] @@ -28,9 +27,9 @@ def corr(a,b): a,b=np.abs(a),np.abs(b); return np.corrcoef(a,b)[0,1] ax.axvspan(-0.5,3.5,color="#2E86C1",alpha=0.05); ax.axvspan(3.5,7.5,color="#C0392B",alpha=0.06) ax.text(1.5,1.06,"inner surfaces (RDCON reliable)",ha="center",fontsize=9,color="#20507f") ax.text(5.5,1.06,"outer / edge (RDCON degrades)",ha="center",fontsize=9,color="#7a2420") -x=np.arange(8); w=0.26 +x=np.arange(8); w=0.38; n=len(C) for i,(name,cs) in enumerate(C.items()): - ax.bar(x+(i-1)*w, cs, w, color=cols[name], label=name) + ax.bar(x+(i-(n-1)/2)*w, cs, w, color=cols[name], label=name) ax.axhline(0,color="k",lw=0.7); ax.set_xticks(x); ax.set_xticklabels(sides) ax.set_ylabel("correlation of |delta_coil| with RDCON"); ax.set_ylim(-0.6,1.15) ax.set_title("STRIDE delta_coil vs RDCON, per surface-side\n" diff --git a/benchmarks/deltacoil_sensitivity/scripts/plot_match_easycase.py b/benchmarks/deltacoil_sensitivity/scripts/plot_match_easycase.py new file mode 100644 index 00000000..e414e60e --- /dev/null +++ b/benchmarks/deltacoil_sensitivity/scripts/plot_match_easycase.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +# Task 4 (matching easy case) validation figures. Reads the three LAR resistive-match runs and +# produces: +# match_easycase_cout.png - matched |cout| per surface (STRIDE vs galerkin) + ideal=0, and the +# STRIDE-vs-galerkin cout correlation per surface. +# match_easycase_prec.png - solve residual and reconnected-flux self-consistency (log scale). +# Usage: plot_match_easycase.py +import sys, numpy as np, h5py +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt + +DRIVEN, IDEAL, SINGLE, FIGDIR = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4] + +def cread(fid, path): + """Read a Julia HDF5 complex/real dataset as a numpy array, restored to Julia (column-major) + orientation: h5py exposes 2D datasets transposed, so we transpose back.""" + if path not in fid: + return None + a = fid[path][()] + if a.dtype.names and set(a.dtype.names) >= {"r", "i"}: + a = a["r"] + 1j * a["i"] + if a.ndim == 2: + a = a.T + return a + +def surf_metrics(h5): + """Per-surface matched-cout norms + STRIDE-vs-galerkin correlation, plus residuals/self-consistency.""" + with h5py.File(h5, "r") as fid: + scout = cread(fid, "singular/resonant_match/cout") + cin = cread(fid, "singular/resonant_match/cin") + dr = cread(fid, "singular/resonant_match/deltar") + flux = cread(fid, "singular/resonant_match/reconnected_flux") + sres = float(np.array(fid["singular/resonant_match/residual"][()])) + gcout = cread(fid, "galerkin/match/cout") + gres = float(np.array(fid["galerkin/match/residual"][()])) + msing = dr.shape[0] + # per-surface (two rows per surface) norms and complex correlation + s_norm, g_norm, corr = [], [], [] + for i in range(msing): + rows = slice(2 * i, 2 * i + 2) + s = np.asarray(scout[rows]).ravel().astype(complex) + g = np.asarray(gcout[rows]).ravel().astype(complex) + s_norm.append(np.linalg.norm(s)) + g_norm.append(np.linalg.norm(g)) + denom = np.linalg.norm(s) * np.linalg.norm(g) + 1e-300 + corr.append(abs(np.vdot(s, g)) / denom) + # reconnected-flux self-consistency: reconnected_flux ?= -InnerBlock*cin + inner = np.zeros_like(flux, dtype=complex) + for i in range(msing): + d1, d2 = dr[i, 0], dr[i, 1] + r1, r2 = 2 * i, 2 * i + 1 + inner[r1] = -(-d1 * cin[r1] + d2 * cin[r2]) + inner[r2] = -(-d1 * cin[r1] - d2 * cin[r2]) + fden = np.linalg.norm(flux) + 1e-300 + selfc = np.linalg.norm(inner - flux) / fden + return dict(msing=msing, s_norm=s_norm, g_norm=g_norm, corr=corr, + sres=sres, gres=gres, selfc=selfc, + scout_tot=float(np.linalg.norm(scout.astype(complex))), + gcout_tot=float(np.linalg.norm(gcout.astype(complex)))) + +d = surf_metrics(DRIVEN) +si = surf_metrics(SINGLE) +with h5py.File(IDEAL, "r") as fid: + ideal_scout = float(np.linalg.norm(cread(fid, "singular/resonant_match/cout").astype(complex))) + ideal_gcout = float(np.linalg.norm(cread(fid, "galerkin/match/cout").astype(complex))) + ideal_sres = float(np.array(fid["singular/resonant_match/residual"][()])) + +BLUE, ORANGE, GREY = "#1f5fbf", "#e07b1a", "#666666" + +# ---------- Figure 1: matched cout (per surface) + correlation ---------- +fig, (axA, axB) = plt.subplots(1, 2, figsize=(11.0, 4.2)) + +# Panel A: per-surface matched |cout|, STRIDE vs galerkin, + ideal=0 +labels = ["driven q=2", "driven q=3", "single q=2", "ideal q=2", "ideal q=3"] +stride_vals = d["s_norm"] + si["s_norm"] + [ideal_scout, ideal_scout] +galerk_vals = d["g_norm"] + si["g_norm"] + [ideal_gcout, ideal_gcout] +x = np.arange(len(labels)); w = 0.38 +axA.bar(x - w/2, stride_vals, w, label="STRIDE (Riccati outer)", color=BLUE) +axA.bar(x + w/2, galerk_vals, w, label="galerkin (RDCON outer)", color=ORANGE) +axA.set_xticks(x); axA.set_xticklabels(labels, rotation=20, ha="right", fontsize=8.5) +axA.set_ylabel("matched |cout| per surface") +axA.set_title("(a) Matched outer coefficient: driven vs ideal", fontsize=10) +axA.legend(fontsize=8, loc="upper right") +axA.annotate("ideal limit:\ncout = 0 exactly\n(perfect shielding)", xy=(3.5, 0.0), + xytext=(2.7, max(stride_vals)*0.55), fontsize=8, color=GREY, + ha="left", arrowprops=dict(arrowstyle="->", color=GREY)) + +# Panel B: STRIDE-vs-galerkin cout correlation per surface +clab = ["driven q=2\n(inner, coupled)", "driven q=3\n(edge, coupled)", "single q=2\n(isolated pair)"] +cvals = [d["corr"][0], d["corr"][1], si["corr"][0]] +cx = np.arange(len(clab)) +bars = axB.bar(cx, cvals, 0.5, color=[GREY, GREY, BLUE]) +axB.axhline(1.0, color="k", lw=0.7, ls=":") +axB.set_ylim(0, 1.08); axB.set_xticks(cx); axB.set_xticklabels(clab, fontsize=8.3) +axB.set_ylabel("|| / (norms)") +axB.set_title("(b) STRIDE vs galerkin matched-cout correlation", fontsize=10) +for b, v in zip(bars, cvals): + axB.text(b.get_x()+b.get_width()/2, v+0.015, f"{v:.4f}", ha="center", fontsize=8.5) + +fig.tight_layout() +f1 = f"{FIGDIR}/match_easycase_cout.png" +fig.savefig(f1, dpi=150); print("WROTE:", f1) + +# ---------- Figure 2: residual + self-consistency (log) ---------- +fig2, ax = plt.subplots(figsize=(8.6, 4.0)) +groups = ["driven", "single-surface", "ideal"] +sres = [d["sres"], si["sres"], ideal_sres] +gres = [d["gres"], si["gres"], 0.0] +selfc = [d["selfc"], si["selfc"], np.nan] # ideal self-consistency is N/A (no inner layer) +floor = 1e-18 +def clamp(v): return [max(x, floor) if np.isfinite(x) else np.nan for x in v] +gx = np.arange(len(groups)); w = 0.26 +ax.bar(gx - w, clamp(sres), w, label="STRIDE solve residual", color=BLUE) +ax.bar(gx, clamp(gres), w, label="galerkin solve residual", color=ORANGE) +ax.bar(gx + w, clamp(selfc), w, label="reconnected-flux self-consistency", color="#2a9d4a") +ax.axhline(1e-16, color="k", lw=0.7, ls=":"); ax.text(2.35, 1.3e-16, "1e-16", fontsize=8, color=GREY) +ax.set_yscale("log"); ax.set_ylim(1e-18, 1e-10) +ax.set_xticks(gx); ax.set_xticklabels(groups) +ax.set_ylabel("relative error (log scale)") +ax.set_title("Match solve residual and reconnected-flux self-consistency", fontsize=10.5) +ax.legend(fontsize=8, loc="upper left") +ax.text(1.62, 3e-18, "ideal: cout=0, no inner layer\n(self-consistency N/A)", fontsize=7.6, color=GREY) +fig2.tight_layout() +f2 = f"{FIGDIR}/match_easycase_prec.png" +fig2.savefig(f2, dpi=150); print("WROTE:", f2) + +# ---------- console summary for the PDF text ---------- +print("\n=== SUMMARY ===") +print(f"driven : msing={d['msing']} sres={d['sres']:.3e} gres={d['gres']:.3e} selfc={d['selfc']:.3e} " + f"corr={['%.4f'%c for c in d['corr']]}") +print(f"single : msing={si['msing']} sres={si['sres']:.3e} gres={si['gres']:.3e} selfc={si['selfc']:.3e} " + f"corr={['%.4f'%c for c in si['corr']]}") +print(f"ideal : ||cout|| STRIDE={ideal_scout:.3e} galerkin={ideal_gcout:.3e} sres={ideal_sres:.3e}") diff --git a/benchmarks/deltacoil_sensitivity/scripts/plot_penetrated_field_psihigh.py b/benchmarks/deltacoil_sensitivity/scripts/plot_penetrated_field_psihigh.py new file mode 100644 index 00000000..bc83a439 --- /dev/null +++ b/benchmarks/deltacoil_sensitivity/scripts/plot_penetrated_field_psihigh.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +# plot_penetrated_field_psihigh.py -- matched resonant coupling ||cout|| per rational surface vs the +# outer truncation psihigh, comparing the two match paths: STRIDE (singular/resonant_match/cout, solid) +# and galerkin (galerkin/match/cout, dashed). Reads resonant_coupling_psihigh_.csv. +# x-axis = log distance from edge (1 - psihigh), edge at right, matching the other psihigh figures. +# +# Usage: python3 plot_penetrated_field_psihigh.py results/resonant_coupling_psihigh_.csv + +import sys, os, csv +import numpy as np +import matplotlib +matplotlib.use('Agg') +import matplotlib.pyplot as plt + +here = os.path.dirname(os.path.abspath(__file__)) +csv_path = os.path.abspath(sys.argv[1]) if len(sys.argv) > 1 else \ + os.path.join(here, '..', 'results', 'penetrated_field_psihigh.csv') + +with open(csv_path) as f: + rows = list(csv.reader(f)) +header = rows[0] +scols = [c for c in header[1:] if c.startswith('stride_q')] +gcols = [c for c in header[1:] if c.startswith('gal_q')] +qvals = [float(c.split('q')[1]) for c in scols] +idx = {c: i + 1 for i, c in enumerate(header[1:])} +psihigh, S, G = [], {q: [] for q in qvals}, {q: [] for q in qvals} +for r in rows[1:]: + if not r or r[0] == '': + continue + psihigh.append(float(r[0])) + for q in qvals: + sv = r[idx[f'stride_q{q}']]; gv = r[idx[f'gal_q{q}']] + S[q].append(float(sv) if sv not in ('', 'FAIL') else np.nan) + G[q].append(float(gv) if gv not in ('', 'FAIL') else np.nan) +psihigh = np.array(psihigh) + +_case = os.path.splitext(os.path.basename(csv_path))[0].replace('penetrated_field_psihigh', '').strip('_').upper() +CASE = 'LAR (large-aspect-ratio)' if 'LAR' in _case else ('DIII-D-like' if ('DIIID' in _case or _case == '') else _case) + +_hasany = np.array([any(not np.isnan(S[q][i]) for q in qvals) for i in range(len(psihigh))]) +x0 = psihigh[_hasany].min() if _hasany.any() else psihigh.min() +def edge_log_x(ax): + fwd = lambda p: -np.log10(np.clip(1.0 - np.asarray(p, float), 1e-9, None)) + inv = lambda t: 1.0 - 10.0 ** (-np.asarray(t, float)) + ax.set_xscale('function', functions=(fwd, inv)) + cand = np.array([0.5, 0.6, 0.75, 0.85, 0.9, 0.95, 0.98, 0.99]) + tk = cand[(cand >= x0 - 1e-9) & (cand <= psihigh.max() + 1e-9)] + ax.set_xticks(tk); ax.set_xticklabels([f'{t:g}' for t in tk]); ax.minorticks_off() + ax.set_xlim(x0 - (1 - x0) * 0.06, psihigh.max() + (1 - psihigh.max()) * 0.4) + +colors = plt.cm.viridis(np.linspace(0.08, 0.82, len(qvals))) +fig, ax = plt.subplots(figsize=(8.6, 5.2)) +ax.set_yscale('log') +for q, c in zip(qvals, colors): + ys = np.array(S[q], float); m = ~np.isnan(ys) + ax.plot(psihigh[m], ys[m], 'o-', color=c, lw=2.0, ms=6, label=f'q = {q:g}, STRIDE') + yg = np.array(G[q], float); mg = ~np.isnan(yg) + ax.plot(psihigh[mg], yg[mg], 's--', color=c, lw=1.5, ms=5, mfc='white', label=f'q = {q:g}, galerkin') +edge_log_x(ax) +ax.set_xlabel('outer truncation ψ$_{high}$ (log distance from edge, 1 - ψ$_{high}$; edge at right)', fontsize=11) +ax.set_ylabel('penetrated field per surface (log)', fontsize=11) +ax.set_title('Penetrated field vs psihigh truncation: STRIDE reconnected_flux (solid) vs galerkin bpen (dashed)\n' + f'{CASE}, n=1 · field that penetrates the resistive layer', fontsize=11) +ax.grid(alpha=0.25, which='both') +ax.legend(fontsize=7.6, ncol=2, loc='best') +fig.subplots_adjust(left=0.12, bottom=0.13, right=0.97, top=0.88) + +stem = os.path.splitext(os.path.basename(csv_path))[0].replace('penetrated_field', 'pen') +out = os.path.abspath(os.path.join(here, '..', 'figures', stem + '.png')) +os.makedirs(os.path.dirname(out), exist_ok=True) +fig.savefig(out, dpi=150) +print('wrote:', out) diff --git a/benchmarks/deltacoil_sensitivity/scripts/plot_resistivity_scan.py b/benchmarks/deltacoil_sensitivity/scripts/plot_resistivity_scan.py new file mode 100644 index 00000000..343e4de7 --- /dev/null +++ b/benchmarks/deltacoil_sensitivity/scripts/plot_resistivity_scan.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +# plot_resistivity_scan.py -- resistivity (eta) scan comparing the two inner-layer backends (ray vs +# galerkin) in the galerkin match. Two panels: left = inner-layer |deltar| (Delta(Q), where the backends +# differ), right = matched penetrated field ||bpen|| (the shielding family). ray solid, galerkin dashed, +# one color per rational surface. |Q| grows as eta falls (roughly |Q| ~ eta^-1/3), toward the left. +# Reads resistivity_scan_.csv. +# +# Usage: python3 plot_resistivity_scan.py results/resistivity_scan_.csv + +import sys, os, csv +import numpy as np +import matplotlib +matplotlib.use('Agg') +import matplotlib.pyplot as plt + +csv_path = os.path.abspath(sys.argv[1]) +here = os.path.dirname(os.path.abspath(__file__)) +with open(csv_path) as f: + rows = list(csv.reader(f)) +header = rows[0] +qvals = sorted({float(c.split('_q')[1]) for c in header[1:]}) +idx = {c: i for i, c in enumerate(header)} +eta = [] +data = {(m, q): [] for m in ('ray_deltar', 'gal_deltar', 'ray_bpen', 'gal_bpen') for q in qvals} +for r in rows[1:]: + if not r or r[0] == '': + continue + eta.append(float(r[0])) + for (m, q) in data: + col = f'{m}_q{q}' + v = r[idx[col]] if col in idx else '' + data[(m, q)].append(float(v) if v not in ('', 'FAIL') else np.nan) +eta = np.array(eta) + +_case = os.path.splitext(os.path.basename(csv_path))[0].replace('resistivity_scan', '').strip('_').upper() +CASE = 'LAR (large-aspect-ratio)' if 'LAR' in _case else 'DIII-D-like' +colors = plt.cm.viridis(np.linspace(0.08, 0.82, len(qvals))) + +fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(13.5, 5.4)) +def panel(ax, ray_key, gal_key, ylab, ttl): + for q, c in zip(qvals, colors): + yr = np.array(data[(ray_key, q)], float); mr = ~np.isnan(yr) + ax.plot(eta[mr], yr[mr], 'o-', color=c, lw=2.0, ms=6, label=f'q = {q:g}, ray') + yg = np.array(data[(gal_key, q)], float); mg = ~np.isnan(yg) + ax.plot(eta[mg], yg[mg], 's--', color=c, lw=1.5, ms=5, mfc='white', label=f'q = {q:g}, galerkin') + ax.set_xscale('log'); ax.set_yscale('log') + ax.invert_xaxis() # low eta (high |Q|, where galerkin drifts) on the RIGHT + ax.set_xlabel('resistivity eta (low eta = high |Q|, to the right)', fontsize=10.5) + ax.set_ylabel(ylab, fontsize=10.5) + ax.set_title(ttl, fontsize=10.5, fontweight='bold') + ax.grid(alpha=0.25, which='both') + +panel(ax1, 'ray_deltar', 'gal_deltar', 'inner-layer |deltar| (log)', 'Inner-layer Delta(Q): ray vs galerkin backend') +panel(ax2, 'ray_bpen', 'gal_bpen', 'matched penetrated field ||bpen|| (log)', 'Penetrated (shielding) field: ray vs galerkin backend') +ax1.legend(fontsize=7.4, ncol=2, loc='best') +fig.suptitle(f'Resistivity scan: ray (solid) vs galerkin (dashed) inner-layer backend · {CASE}, n=1', + fontsize=12, fontweight='bold') +fig.subplots_adjust(left=0.07, right=0.98, bottom=0.12, top=0.88, wspace=0.22) + +stem = os.path.splitext(os.path.basename(csv_path))[0].replace('resistivity_scan', 'resist') +out = os.path.abspath(os.path.join(here, '..', 'figures', stem + '.png')) +os.makedirs(os.path.dirname(out), exist_ok=True) +fig.savefig(out, dpi=150) +print('wrote:', out) diff --git a/benchmarks/deltacoil_sensitivity/scripts/plot_resonant_coupling_psihigh.py b/benchmarks/deltacoil_sensitivity/scripts/plot_resonant_coupling_psihigh.py new file mode 100644 index 00000000..3d4ff1c9 --- /dev/null +++ b/benchmarks/deltacoil_sensitivity/scripts/plot_resonant_coupling_psihigh.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +# plot_resonant_coupling_psihigh.py -- matched resonant coupling ||cout|| per rational surface vs the +# outer truncation psihigh, comparing the two match paths: STRIDE (singular/resonant_match/cout, solid) +# and galerkin (galerkin/match/cout, dashed). Reads resonant_coupling_psihigh_.csv. +# x-axis = log distance from edge (1 - psihigh), edge at right, matching the other psihigh figures. +# +# Usage: python3 plot_resonant_coupling_psihigh.py results/resonant_coupling_psihigh_.csv + +import sys, os, csv +import numpy as np +import matplotlib +matplotlib.use('Agg') +import matplotlib.pyplot as plt + +here = os.path.dirname(os.path.abspath(__file__)) +csv_path = os.path.abspath(sys.argv[1]) if len(sys.argv) > 1 else \ + os.path.join(here, '..', 'results', 'resonant_coupling_psihigh.csv') + +with open(csv_path) as f: + rows = list(csv.reader(f)) +header = rows[0] +scols = [c for c in header[1:] if c.startswith('stride_q')] +gcols = [c for c in header[1:] if c.startswith('gal_q')] +qvals = [float(c.split('q')[1]) for c in scols] +idx = {c: i + 1 for i, c in enumerate(header[1:])} +psihigh, S, G = [], {q: [] for q in qvals}, {q: [] for q in qvals} +for r in rows[1:]: + if not r or r[0] == '': + continue + psihigh.append(float(r[0])) + for q in qvals: + sv = r[idx[f'stride_q{q}']]; gv = r[idx[f'gal_q{q}']] + S[q].append(float(sv) if sv not in ('', 'FAIL') else np.nan) + G[q].append(float(gv) if gv not in ('', 'FAIL') else np.nan) +psihigh = np.array(psihigh) + +_case = os.path.splitext(os.path.basename(csv_path))[0].replace('resonant_coupling_psihigh', '').strip('_').upper() +CASE = 'LAR (large-aspect-ratio)' if 'LAR' in _case else ('DIII-D-like' if ('DIIID' in _case or _case == '') else _case) + +_hasany = np.array([any(not np.isnan(S[q][i]) for q in qvals) for i in range(len(psihigh))]) +x0 = psihigh[_hasany].min() if _hasany.any() else psihigh.min() +def edge_log_x(ax): + fwd = lambda p: -np.log10(np.clip(1.0 - np.asarray(p, float), 1e-9, None)) + inv = lambda t: 1.0 - 10.0 ** (-np.asarray(t, float)) + ax.set_xscale('function', functions=(fwd, inv)) + cand = np.array([0.5, 0.6, 0.75, 0.85, 0.9, 0.95, 0.98, 0.99]) + tk = cand[(cand >= x0 - 1e-9) & (cand <= psihigh.max() + 1e-9)] + ax.set_xticks(tk); ax.set_xticklabels([f'{t:g}' for t in tk]); ax.minorticks_off() + ax.set_xlim(x0 - (1 - x0) * 0.06, psihigh.max() + (1 - psihigh.max()) * 0.4) + +colors = plt.cm.viridis(np.linspace(0.08, 0.82, len(qvals))) +fig, ax = plt.subplots(figsize=(8.6, 5.2)) +ax.set_yscale('log') +for q, c in zip(qvals, colors): + ys = np.array(S[q], float); m = ~np.isnan(ys) + ax.plot(psihigh[m], ys[m], 'o-', color=c, lw=2.0, ms=6, label=f'q = {q:g}, STRIDE') + yg = np.array(G[q], float); mg = ~np.isnan(yg) + ax.plot(psihigh[mg], yg[mg], 's--', color=c, lw=1.5, ms=5, mfc='white', label=f'q = {q:g}, galerkin') +edge_log_x(ax) +ax.set_xlabel('outer truncation ψ$_{high}$ (log distance from edge, 1 - ψ$_{high}$; edge at right)', fontsize=11) +ax.set_ylabel('matched resonant coupling ||cout|| per surface (log)', fontsize=11) +ax.set_title('Matched resonant coupling vs psihigh truncation: STRIDE (solid) vs galerkin (dashed)\n' + f'{CASE}, n=1 · cout from Wang 2020 Eq. 11 match', fontsize=11) +ax.grid(alpha=0.25, which='both') +ax.legend(fontsize=7.6, ncol=2, loc='best') +fig.subplots_adjust(left=0.12, bottom=0.13, right=0.97, top=0.88) + +stem = os.path.splitext(os.path.basename(csv_path))[0].replace('resonant_coupling', 'rescoup') +out = os.path.abspath(os.path.join(here, '..', 'figures', stem + '.png')) +os.makedirs(os.path.dirname(out), exist_ok=True) +fig.savefig(out, dpi=150) +print('wrote:', out) diff --git a/benchmarks/deltacoil_sensitivity/scripts/resistivity_scan.jl b/benchmarks/deltacoil_sensitivity/scripts/resistivity_scan.jl new file mode 100644 index 00000000..59cc09ce --- /dev/null +++ b/benchmarks/deltacoil_sensitivity/scripts/resistivity_scan.jl @@ -0,0 +1,123 @@ +#!/usr/bin/env julia +# resistivity_scan.jl -- resistivity (eta) scan of the matched inner-layer response, comparing the two +# inner-layer backends in the galerkin match: "ray" (rotated-contour collocation, robust for |Q| >~ 1) +# and "galerkin" (Hermite-cubic inps, drifts for |Q| >~ 1). psihigh is held at the full domain; only eta +# is varied (|Q| grows as eta falls, roughly |Q| ~ eta^(-1/3)). Per surface we report: +# |deltar| -- the inner-layer Delta(Q) (galerkin/match/deltar), where the two backends differ, and +# ||bpen|| -- the matched penetrated field (galerkin/match/bpen), the shielding family. +# The branch result to reproduce: ray gives a smooth, monotonic shielding family; galerkin jumps at low eta. +# +# Usage: julia --project= scripts/resistivity_scan.jl [N_eta] [eta_hi] [eta_lo] + +using GeneralizedPerturbedEquilibrium, HDF5, LinearAlgebra, Printf + +length(ARGS) >= 1 || error("usage: julia --project= scripts/resistivity_scan.jl [N_eta] [eta_hi] [eta_lo]") +base = ARGS[1]; isdir(base) || error("no such dir: $base") +NETA = length(ARGS) >= 2 ? parse(Int, ARGS[2]) : 8 +ETA_HI = length(ARGS) >= 3 ? parse(Float64, ARGS[3]) : 1e-6 +ETA_LO = length(ARGS) >= 4 ? parse(Float64, ARGS[4]) : 1e-13 +ENV["DELTACOIL_MODE"] = "driven"; delete!(ENV, "DELTACOIL_PROJECT") + +base_toml = read(joinpath(base, "gpec.toml"), String) +scratch = mktempdir() +eqm = match(r"eq_filename\s*=\s*\"([^\"]+)\"", base_toml) +eqfile = eqm === nothing ? nothing : eqm.captures[1] +eqsrc = eqfile === nothing ? nothing : (isabspath(eqfile) ? eqfile : joinpath(base, eqfile)) + +# how many surfaces (length of the toml's gal_eta array = full-domain msing) +nsurf = (m = match(r"gal_eta\s*=\s*\[([^\]]*)\]", base_toml); m === nothing ? 0 : count(==(','), m.captures[1]) + 1) +nsurf > 0 || error("could not parse gal_eta length from toml") +RHO = (m = match(r"gal_rho\s*=\s*\[\s*([\d.eE+-]+)", base_toml); m === nothing ? 3.3e-7 : parse(Float64, m.captures[1])) +# rotation f [Hz] sets gamma=2pi n f, hence |Q| = 2pi n f / Q0 (Q0 ∝ eta^1/3). Override via ENV to reach +# the |Q| >~ 1 regime where the galerkin inner layer drifts (example default f=1 Hz keeps |Q| < 1). +ROT = (rv = get(ENV, "GAL_ROTATION", ""); rv != "" ? parse(Float64, rv) : + (m = match(r"gal_rotation\s*=\s*\[\s*([\d.eE+-]+)", base_toml); m === nothing ? 1.0 : parse(Float64, m.captures[1]))) + +ETAS = 10 .^ range(log10(ETA_HI), log10(ETA_LO); length=NETA) + +function run_eta(eta, solver) + tag = "eta_$(eta)_$(solver)" + dir = joinpath(scratch, tag); mkpath(dir) + if eqfile !== nothing + dst = joinpath(dir, basename(eqfile)); islink(dst) || symlink(realpath(eqsrc), dst) + end + toml = base_toml + for key in ("force_termination", "HDF5_filename", "gal_match_flag", "gal_inner_solver", + "thmax0", "delta_mband", "gal_eta", "gal_rho", "gal_rotation") + toml = replace(toml, Regex("(?m)^[ \\t]*$key[ \\t]*=.*\\n") => "") + end + arrs = "gal_eta = [" * join(fill(string(eta), nsurf), ", ") * "]\n" * + "gal_rho = [" * join(fill(string(RHO), nsurf), ", ") * "]\n" * + "gal_rotation = [" * join(fill(string(ROT), nsurf), ", ") * "]\n" + toml = replace(toml, "[ForceFreeStates]" => + "[ForceFreeStates]\nforce_termination = true\nHDF5_filename = \"$tag.h5\"\ngal_match_flag = true\n" * + "gal_inner_solver = \"$solver\"\n" * arrs; count=1) + eqfile !== nothing && (toml = replace(toml, r"eq_filename\s*=\s*\"[^\"]+\"" => "eq_filename = \"$(basename(eqfile))\"")) + write(joinpath(dir, "gpec.toml"), toml) + @info ">>> eta = $eta solver = $solver" + try + GeneralizedPerturbedEquilibrium.main([dir]) + catch err + @warn "run failed at eta=$eta solver=$solver" exception = (err, catch_backtrace()) + return nothing + end + h5path = joinpath(dir, "$tag.h5") + isfile(h5path) || return nothing + h5open(h5path, "r") do fid + haskey(fid, "singular/q") || return nothing + q = vec(read(fid, "singular/q")); isempty(q) && return nothing + dr = haskey(fid, "galerkin/match/deltar") ? read(fid, "galerkin/match/deltar") : nothing # (msing,2) + bp = haskey(fid, "galerkin/match/bpen") ? read(fid, "galerkin/match/bpen") : nothing # (ncoil,msing) + deltar = Dict{Float64,Float64}(); bpen = Dict{Float64,Float64}() + for s in 1:length(q) + qk = round(q[s]; digits=2) + dr === nothing || (deltar[qk] = norm(dr[s, :])) + if bp !== nothing + b = size(bp, 1) >= size(bp, 2) ? bp : permutedims(bp) + bpen[qk] = norm(b[:, s]) + end + end + (q=sort(round.(q; digits=2)), deltar=deltar, bpen=bpen) + end +end + +rows = Tuple{Float64,Any,Any}[] +for eta in ETAS + push!(rows, (eta, run_eta(eta, "ray"), run_eta(eta, "galerkin"))) +end +allq = sort(collect(reduce(union, (Set(keys(r[2].deltar)) for r in rows if r[2] !== nothing); init=Set{Float64}()))) + +outdir = joinpath(@__DIR__, "..", "results"); mkpath(outdir) +tag = replace(basename(base), r"[^A-Za-z0-9]" => "") * "_f" * replace(string(ROT), "." => "p") +csv = joinpath(outdir, "resistivity_scan_$tag.csv") +open(csv, "w") do io + cols = String["eta"] + for q in allq, m in ("ray_deltar","gal_deltar","ray_bpen","gal_bpen"); push!(cols, "$(m)_q$(q)"); end + println(io, join(cols, ",")) + for (eta, rr, rg) in rows + vals = String[string(eta)] + for q in allq + push!(vals, rr === nothing ? "" : string(get(rr.deltar, q, ""))) + push!(vals, rg === nothing ? "" : string(get(rg.deltar, q, ""))) + push!(vals, rr === nothing ? "" : string(get(rr.bpen, q, ""))) + push!(vals, rg === nothing ? "" : string(get(rg.bpen, q, ""))) + end + println(io, join(vals, ",")) + end +end + +println("\n" * "="^78) +println(" RESISTIVITY scan ($(basename(base))): inner-layer |deltar| vs eta, ray vs galerkin backend") +println("="^78) +@printf(" %-10s", "eta") +for q in allq; @printf(" | ray:q%-4.1f | gal:q%-4.1f", q, q); end; println() +for (eta, rr, rg) in rows + @printf(" %-10.2e", eta) + for q in allq + @printf(" | %8s | %8s", + rr === nothing ? "-" : (haskey(rr.deltar, q) ? @sprintf("%.2e", rr.deltar[q]) : "-"), + rg === nothing ? "-" : (haskey(rg.deltar, q) ? @sprintf("%.2e", rg.deltar[q]) : "-")) + end + println() +end +println("\nwrote: $(abspath(csv))") diff --git a/benchmarks/deltacoil_sensitivity/scripts/resonant_coupling_psihigh.jl b/benchmarks/deltacoil_sensitivity/scripts/resonant_coupling_psihigh.jl new file mode 100644 index 00000000..ccad8469 --- /dev/null +++ b/benchmarks/deltacoil_sensitivity/scripts/resonant_coupling_psihigh.jl @@ -0,0 +1,126 @@ +#!/usr/bin/env julia +# resonant_coupling_psihigh.jl -- truncation scan of the MATCHED resonant coupling (Wang 2020 Eq. 11 +# output) versus the outer boundary psihigh, for BOTH match paths from a single run: +# STRIDE path : singular/resonant_match/cout (Riccati outer delta_coil + inner layer) +# galerkin path : galerkin/match/cout (RDCON weak-form outer + inner layer) +# Per rational surface we report ||cout_surface|| (norm over coil modes of that surface's two sides). +# This is the physical matched coupling, downstream of the delta_coil we scanned before. +# +# The match needs per-surface resistive arrays (gal_eta/gal_rho/gal_rotation) of length msing, and msing +# changes as psihigh passes surfaces. So we first run the full domain (arrays already correct in the toml) +# to get the surface locations, then size the arrays to the surface count at each psihigh. +# +# Usage: julia --project= scripts/resonant_coupling_psihigh.jl [N] [psihigh_max] + +using GeneralizedPerturbedEquilibrium, HDF5, LinearAlgebra, Printf + +length(ARGS) >= 1 || error("usage: julia --project= scripts/resonant_coupling_psihigh.jl [N] [psihigh_max]") +base = ARGS[1]; isdir(base) || error("no such dir: $base") +N = length(ARGS) >= 2 ? parse(Int, ARGS[2]) : 12 +PSIH_MAX = length(ARGS) >= 3 ? parse(Float64, ARGS[3]) : 0.993 +ENV["DELTACOIL_MODE"] = "driven"; delete!(ENV, "DELTACOIL_PROJECT") + +base_toml = read(joinpath(base, "gpec.toml"), String) +scratch = mktempdir() +eqm = match(r"eq_filename\s*=\s*\"([^\"]+)\"", base_toml) +eqfile = eqm === nothing ? nothing : eqm.captures[1] +eqsrc = eqfile === nothing ? nothing : (isabspath(eqfile) ? eqfile : joinpath(base, eqfile)) + +# base resistive values (uniform across surfaces in the examples): parse first entry of each array +firstval(key, default) = (m = match(Regex("$key\\s*=\\s*\\[\\s*([\\d.eE+-]+)"), base_toml); m === nothing ? default : parse(Float64, m.captures[1])) +ETA = firstval("gal_eta", 8e-8); RHO = firstval("gal_rho", 3.3e-7); ROT = firstval("gal_rotation", 1.0) + +# run the match at one psihigh with resistive arrays of length `ms` (nothing => keep toml arrays = full domain) +function run_match(ph, ms) + tag = "ph_$(ph)" + dir = joinpath(scratch, tag); mkpath(dir) + if eqfile !== nothing + dst = joinpath(dir, basename(eqfile)); islink(dst) || symlink(realpath(eqsrc), dst) + end + toml = base_toml + strip_keys = ["force_termination", "HDF5_filename", "psiedge", "set_psilim_via_dmlim", + "truncate_at_dW_peak", "thmax0", "delta_mband", "gal_match_flag"] + ms === nothing || append!(strip_keys, ["gal_eta", "gal_rho", "gal_rotation"]) # only resize when ms given; keep toml arrays for full domain + for key in strip_keys + toml = replace(toml, Regex("(?m)^[ \\t]*$key[ \\t]*=.*\\n") => "") + end + arrblk = ms === nothing ? "" : + "gal_eta = [" * join(fill(string(ETA), ms), ", ") * "]\n" * + "gal_rho = [" * join(fill(string(RHO), ms), ", ") * "]\n" * + "gal_rotation = [" * join(fill(string(ROT), ms), ", ") * "]\n" + toml = replace(toml, "[ForceFreeStates]" => + "[ForceFreeStates]\nforce_termination = true\nHDF5_filename = \"$tag.h5\"\npsiedge = 1.0\n" * + "set_psilim_via_dmlim = false\ntruncate_at_dW_peak = false\ngal_match_flag = true\n" * arrblk; count=1) + toml = replace(toml, r"(?m)^([ \t]*)psihigh[ \t]*=[ \t]*[\d.eE+-]+" => SubstitutionString("\\1psihigh = $ph")) + eqfile !== nothing && (toml = replace(toml, r"eq_filename\s*=\s*\"[^\"]+\"" => "eq_filename = \"$(basename(eqfile))\"")) + write(joinpath(dir, "gpec.toml"), toml) + @info ">>> psihigh = $ph (msing arrays = $(ms === nothing ? "toml" : ms))" + try + GeneralizedPerturbedEquilibrium.main([dir]) + catch err + @warn "run failed at psihigh=$ph" exception = (err, catch_backtrace()) + return nothing + end + h5path = joinpath(dir, "$tag.h5") + isfile(h5path) || return nothing + h5open(h5path, "r") do fid + haskey(fid, "singular/q") || return nothing + q = vec(read(fid, "singular/q")); isempty(q) && return nothing + psi = vec(read(fid, "singular/psi")) + # per-surface norm of cout: cout is (ncoil, 2msing), columns are surface-sides + percol(dc) = begin + dc = size(dc, 1) >= size(dc, 2) ? dc : permutedims(dc) # -> (ncoil, 2msing) + Dict(round(q[s]; digits=2) => norm(dc[:, 2s-1:2s]) for s in 1:length(q)) + end + stride_c = haskey(fid, "singular/resonant_match/cout") ? percol(read(fid, "singular/resonant_match/cout")) : Dict{Float64,Float64}() + gal_c = haskey(fid, "galerkin/match/cout") ? percol(read(fid, "galerkin/match/cout")) : Dict{Float64,Float64}() + (q=sort(round.(q; digits=2)), psi=psi, stride=stride_c, gal=gal_c) + end +end + +# --- nominal at full domain first: gives surface locations and full msing (toml arrays already correct) --- +@info "=== nominal (full domain) ===" +nom = run_match(PSIH_MAX, nothing) +nom === nothing && error("nominal full-domain match failed") +psi_s = sort(nom.psi) +@info "surfaces (psi_s): $psi_s full msing=$(length(psi_s))" + +# grid: log-packed toward edge, from just past the innermost surface to the edge +PSIHIGHS = round.(1 .- 10 .^ range(log10(1 - (psi_s[1] + 1e-3)), log10(1 - PSIH_MAX); length=N); digits=5) + +results = Tuple{Float64,Any}[] +for ph in PSIHIGHS + ms = count(<(ph), psi_s) + push!(results, (ph, ms == 0 ? nothing : (ph == PSIH_MAX ? nom : run_match(ph, ms)))) +end +# make sure the nominal (max) point is present +any(r -> r[1] == PSIH_MAX, results) || push!(results, (PSIH_MAX, nom)) + +allq = sort(collect(nom.q)) +outdir = joinpath(@__DIR__, "..", "results"); mkpath(outdir) +tag = replace(basename(base), r"[^A-Za-z0-9]" => "") +csv = joinpath(outdir, "resonant_coupling_psihigh_$tag.csv") +open(csv, "w") do io + hdr = "psihigh," * join(["stride_q$(q)" for q in allq], ",") * "," * join(["gal_q$(q)" for q in allq], ",") + println(io, hdr) + for (ph, r) in sort(results, by=first) + sv = [r === nothing ? "" : string(get(r.stride, q, "")) for q in allq] + gv = [r === nothing ? "" : string(get(r.gal, q, "")) for q in allq] + println(io, "$ph," * join(sv, ",") * "," * join(gv, ",")) + end +end + +println("\n" * "="^78) +println(" MATCHED resonant coupling ||cout|| per surface vs psihigh ($(basename(base)))") +println(" STRIDE = singular/resonant_match/cout ; galerkin = galerkin/match/cout") +println("="^78) +@printf(" %-9s", "psihigh") +for q in allq; @printf(" | S:q%-4.1f", q); end +for q in allq; @printf(" | G:q%-4.1f", q); end; println() +for (ph, r) in sort(results, by=first) + @printf(" %-9.4f", ph) + for q in allq; @printf(" | %7s", r === nothing ? "-" : (haskey(r.stride, q) ? @sprintf("%.2e", r.stride[q]) : "-")); end + for q in allq; @printf(" | %7s", r === nothing ? "-" : (haskey(r.gal, q) ? @sprintf("%.2e", r.gal[q]) : "-")); end + println() +end +println("\nwrote: $(abspath(csv))") diff --git a/examples/LAR_resistive_match_test/gpec.toml b/examples/LAR_resistive_match_test/gpec.toml index 9e884c2a..321c4c11 100644 --- a/examples/LAR_resistive_match_test/gpec.toml +++ b/examples/LAR_resistive_match_test/gpec.toml @@ -47,9 +47,7 @@ nn_low = 1 nn_high = 1 delta_mlow = 8 delta_mhigh = 8 -delta_mband = 0 mthvac = 960 -thmax0 = 1 eulerlagrange_tolerance = 1e-12 singfac_min = 1e-4 From 885e2aa8abd4888fa40579586442bd5e109764a8 Mon Sep 17 00:00:00 2001 From: Via Weber Date: Tue, 28 Jul 2026 10:41:33 -0400 Subject: [PATCH 18/38] FORCEFREESTATE - minor - tests ran for truncation and resistivity --- ...ndrical_newcomb_deltaprime.cpython-311.pyc | Bin 0 -> 8388 bytes .../scripts/cylindrical_newcomb_deltaprime.py | 125 +++++++++++++ .../deltacoil_psihigh_riccati_galerkin.jl | 152 ++++++++++++++++ .../scripts/deltamn_resistivity_merge.jl | 167 ++++++++++++++++++ .../scripts/plot_deltacoil_psihigh.py | 26 ++- .../scripts/plot_deltacoil_psihigh_panels.py | 140 +++++++++++++++ .../scripts/plot_deltacoil_psihigh_rg.py | 165 +++++++++++++++++ .../scripts/plot_deltamn_merge.py | 73 ++++++++ .../scripts/plot_deltaprime_epsilon.py | 74 ++++++++ .../scripts/plot_marginal_q2.py | 65 +++++++ .../scripts/plot_psihigh_fig1style_rg.py | 103 +++++++++++ 11 files changed, 1086 insertions(+), 4 deletions(-) create mode 100644 benchmarks/deltacoil_sensitivity/scripts/__pycache__/cylindrical_newcomb_deltaprime.cpython-311.pyc create mode 100644 benchmarks/deltacoil_sensitivity/scripts/cylindrical_newcomb_deltaprime.py create mode 100644 benchmarks/deltacoil_sensitivity/scripts/deltacoil_psihigh_riccati_galerkin.jl create mode 100644 benchmarks/deltacoil_sensitivity/scripts/deltamn_resistivity_merge.jl create mode 100644 benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_psihigh_panels.py create mode 100644 benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_psihigh_rg.py create mode 100644 benchmarks/deltacoil_sensitivity/scripts/plot_deltamn_merge.py create mode 100644 benchmarks/deltacoil_sensitivity/scripts/plot_deltaprime_epsilon.py create mode 100644 benchmarks/deltacoil_sensitivity/scripts/plot_marginal_q2.py create mode 100644 benchmarks/deltacoil_sensitivity/scripts/plot_psihigh_fig1style_rg.py diff --git a/benchmarks/deltacoil_sensitivity/scripts/__pycache__/cylindrical_newcomb_deltaprime.cpython-311.pyc b/benchmarks/deltacoil_sensitivity/scripts/__pycache__/cylindrical_newcomb_deltaprime.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ac36d5cffcc71d50056b69ca253981b9a20ff3a9 GIT binary patch literal 8388 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 +# We report the dimensionless r_s*Delta' (standard tearing index). +# +# Self-test: with the current term OFF the solution is vacuum x^{+/-m}, giving r_s*Delta' = -2m exactly. +# Boundary conditions: regular at axis (psi ~ x^m); NO WALL at the edge (vacuum psi ~ x^{-m}, i.e. psi'/psi = -m at x=1). +# +# Pure numpy (no scipy). Usage: python3 cylindrical_newcomb_deltaprime.py (runs self-test + the 5 TJ cases) + +import numpy as np + +def _qparts(x, qc, nu): + # analytic q(x) and q'(x) for q(x)=x^2/f1, f1=[1-(1-x^2)^nu]/(nu*qc); cheap (2 power ops) + if x < 1e-8: + return qc, 0.0 + u = 1.0 - x*x + un = u**nu + g = 1.0 - un + q = nu*qc*x*x/g + gp = 2.0*nu*x*u**(nu - 1.0) # dg/dx + qp = nu*qc*(2.0*x/g - x*x*gp/(g*g)) # dq/dx + return q, qp + +def q_of_x(x, qc, nu): + return _qparts(float(x), qc, nu)[0] + +def Hfun(x, qc, nu): + q, qp = _qparts(x, qc, nu) + return 2.0/q - x*qp/(q*q) + +def Hprime(x, qc, nu, h=1e-6): + xm = max(x - h, 1e-9) + return (Hfun(x + h, qc, nu) - Hfun(xm, qc, nu)) / (x + h - xm) + +def rhs(x, y, m, n, qc, nu, current=True): + y1, y2 = y + dy1 = y2 / x + cur = 0.0 + if current: + q, _ = _qparts(x, qc, nu) + cur = m*q*Hprime(x, qc, nu) / (m - n*q) + dy2 = (m*m/x + cur) * y1 + return np.array([dy1, dy2]) + +def rk4(f, x0, x1, y0, N): + h = (x1 - x0) / N + y = np.array(y0, float); x = x0 + for _ in range(N): + k1 = f(x, y); k2 = f(x + 0.5*h, y + 0.5*h*k1) + k3 = f(x + 0.5*h, y + 0.5*h*k2); k4 = f(x + h, y + h*k3) + y = y + (h/6.0)*(k1 + 2*k2 + 2*k3 + k4); x += h + return y + +def find_rs(m, n, qc, nu): + tgt = m / n + a, b = 1e-4, 1.0 - 1e-9 + fa = q_of_x(a, qc, nu) - tgt; fb = q_of_x(b, qc, nu) - tgt + if fa*fb > 0: + return None + for _ in range(200): + mid = 0.5*(a + b); fm = q_of_x(mid, qc, nu) - tgt + if abs(fm) < 1e-12 or (b - a) < 1e-13: + return mid + if fa*fm <= 0: b, fb = mid, fm + else: a, fa = mid, fm + return 0.5*(a + b) + +def deltaprime(m, n, qc, nu, delta=1e-3, N=400000, current=True): + rs = find_rs(m, n, qc, nu) + if rs is None: + return None, None + f = lambda x, y: rhs(x, y, m, n, qc, nu, current) + x0 = 1e-4 + yin = rk4(f, x0, rs - delta, [x0**m, m*x0**m], N) # axis -> just inside surface + ld_in = (yin[1]/(rs - delta)) / yin[0] + yout = rk4(f, 1.0, rs + delta, [1.0, -float(m)], N) # no-wall edge -> just outside surface + ld_out = (yout[1]/(rs + delta)) / yout[0] + aDp = ld_out - ld_in # a*Delta' (a=1) + return rs*aDp, rs # return r_s*Delta' (dimensionless) + +def deltaprime_extrap(m, n, qc, nu, deltas=(1e-2, 3e-3, 1e-3, 3e-4), N=60000): + # Delta'(delta) carries a residual ~ G*ln(delta) because the log-singular part of psi'/psi has a + # side-dependent coefficient. Fit Delta'(delta) = Delta'_true + G*ln(delta) and return the intercept. + xs, ys = [], [] + for d in deltas: + v, rs = deltaprime(m, n, qc, nu, delta=d, N=N, current=True) + if v is None: + return None, None, None + xs.append(np.log(d)); ys.append(float(np.real(v))) + A = np.vstack([np.ones_like(xs), xs]).T + (b0, G), *_ = np.linalg.lstsq(A, np.array(ys), rcond=None) + resid = float(np.sqrt(np.mean((A @ np.array([b0, G]) - np.array(ys))**2))) + return b0, G, resid # b0 = r_s*Delta'_true (intercept), G = log slope, resid = fit residual + +CASES = [("q1",0.85,1.5,1),("q2",1.5,2.8,2),("q3",2.2,3.8,3),("q4",3.2,4.8,4),("q5",4.2,5.8,5)] + +if __name__ == "__main__": + print("="*74) + print(" Cylindrical Newcomb Delta-prime (analytic TJ q-profile, no wall)") + print("="*74) + print("\n[self-test] current term OFF -> r_s*Delta' should equal -2m exactly:") + for lab, qc, qa, m in CASES: + nu = qa/qc + val, rs = deltaprime(m, 1, qc, nu, delta=1e-3, N=200000, current=False) + print(f" {lab} (m={m}): r_s*Delta'={val:+.4f} expected -2m={-2*m:+d} r_s={rs:.4f} " + f"{'OK' if abs(val+2*m)<0.05 else 'CHECK'}") + print("\n[physical] current term ON, delta-convergence (r_s*Delta'):") + hdr = " case r_s " + "".join(f"d={d:<9}" for d in ("1e-2","3e-3","1e-3")) + print(hdr) + for lab, qc, qa, m in CASES: + nu = qa/qc + vals = [] + for d in (1e-2, 3e-3, 1e-3): + v, rs = deltaprime(m, 1, qc, nu, delta=d, N=300000, current=True) + vals.append(v) + print(f" {lab:4s} {rs:.4f} " + "".join(f"{v:<+11.4f}" for v in vals)) diff --git a/benchmarks/deltacoil_sensitivity/scripts/deltacoil_psihigh_riccati_galerkin.jl b/benchmarks/deltacoil_sensitivity/scripts/deltacoil_psihigh_riccati_galerkin.jl new file mode 100644 index 00000000..7b0f4319 --- /dev/null +++ b/benchmarks/deltacoil_sensitivity/scripts/deltacoil_psihigh_riccati_galerkin.jl @@ -0,0 +1,152 @@ +#!/usr/bin/env julia +# deltacoil_psihigh_riccati_galerkin.jl -- unified psihigh truncation scan that records BOTH the +# Riccati/STRIDE delta_coil (singular/delta_coil_matrix) and the Galerkin/RDCON delta_coil +# (galerkin/delta_coil) at every psihigh, so both methods can be compared on one plot. +# +# One GPEC run per psihigh serves both the norm view and the shape (cosine) view, so this replaces +# running deltacoil_psihigh_scan.jl and deltacoil_psihigh_metric.jl separately. Per surface and per +# method it writes: +# norm = ||delta_coil block for that surface|| +# cos = || / (||v_nominal|| ||v||), aligned by poloidal mode m, nominal = largest psihigh +# cos = 1 means the coil-response pattern is unchanged (only rescaled); cos < 1 means it rotated. +# +# Config matches the earlier psihigh scans: set_psilim_via_dmlim=false, psiedge=1, truncate_at_dW_peak=false, +# gal_match_flag=false (delta_coil only). psihigh is log-packed toward the edge. +# +# Usage: julia --project= scripts/deltacoil_psihigh_riccati_galerkin.jl [N] [psihigh_max] [psihigh_min] + +using GeneralizedPerturbedEquilibrium, HDF5, LinearAlgebra, Printf + +length(ARGS) >= 1 || error("usage: julia --project= scripts/deltacoil_psihigh_riccati_galerkin.jl [N] [psihigh_max] [psihigh_min]") +base = ARGS[1]; isdir(base) || error("no such dir: $base") +N = length(ARGS) >= 2 ? parse(Int, ARGS[2]) : 100 +PSIH_MAX = length(ARGS) >= 3 ? parse(Float64, ARGS[3]) : 0.9999 +PSIH_MIN = length(ARGS) >= 4 ? parse(Float64, ARGS[4]) : 0.9 +ENV["DELTACOIL_MODE"] = "driven"; delete!(ENV, "DELTACOIL_PROJECT") + +# logarithmic packing toward the edge: dense near psi -> 1, first point = PSIH_MIN, last = PSIH_MAX +PSIHIGHS = round.(1 .- 10 .^ range(log10(1 - PSIH_MIN), log10(1 - PSIH_MAX); length=N); digits=6) + +base_toml = read(joinpath(base, "gpec.toml"), String) +scratch = mktempdir() +eqm = match(r"eq_filename\s*=\s*\"([^\"]+)\"", base_toml) +eqfile = eqm === nothing ? nothing : eqm.captures[1] +eqsrc = eqfile === nothing ? nothing : (isabspath(eqfile) ? eqfile : joinpath(base, eqfile)) + +# per-surface delta_coil block, keyed by q, for one method's HDF5 dataset +function blocks(fid, path, q) + haskey(fid, path) || return nothing + dc = read(fid, path) + dc = size(dc, 1) < size(dc, 2) ? dc : permutedims(dc) # -> (2msing, ncoil), columns m=mlow..mhigh + out = Dict{Float64,Matrix{ComplexF64}}() + for s in 1:length(q); out[round(q[s]; digits=2)] = Matrix{ComplexF64}(dc[2s-1:2s, :]); end + out +end + +# run GPEC at one psihigh; return (q-values, ric blocks, gal blocks, mlow), or nothing on failure. +function run_one(ph) + tag = "ph_$(ph)" + dir = joinpath(scratch, tag); mkpath(dir) + if eqfile !== nothing + dst = joinpath(dir, basename(eqfile)); islink(dst) || symlink(realpath(eqsrc), dst) + end + toml = base_toml + for key in ("force_termination", "HDF5_filename", "psiedge", "set_psilim_via_dmlim", "gal_match_flag", + "truncate_at_dW_peak", "thmax0", "delta_mband") + toml = replace(toml, Regex("(?m)^[ \\t]*$key[ \\t]*=.*\\n") => "") + end + toml = replace(toml, "[ForceFreeStates]" => + "[ForceFreeStates]\nforce_termination = true\nHDF5_filename = \"$tag.h5\"\npsiedge = 1.0\n" * + "set_psilim_via_dmlim = false\ntruncate_at_dW_peak = false\ngal_match_flag = false"; count=1) + toml = replace(toml, r"(?m)^([ \t]*)psihigh[ \t]*=[ \t]*[\d.eE+-]+" => SubstitutionString("\\1psihigh = $ph")) + eqfile !== nothing && (toml = replace(toml, r"eq_filename\s*=\s*\"[^\"]+\"" => "eq_filename = \"$(basename(eqfile))\"")) + write(joinpath(dir, "gpec.toml"), toml) + @info ">>> psihigh = $ph" + try + GeneralizedPerturbedEquilibrium.main([dir]) + catch err + @warn "run failed at psihigh=$ph" exception = (err, catch_backtrace()) + return nothing + end + h5path = joinpath(dir, "$tag.h5") + isfile(h5path) || return nothing + h5open(h5path, "r") do fid + haskey(fid, "singular/q") || return nothing + q = vec(read(fid, "singular/q")); isempty(q) && return nothing + mlow = Int(read(fid, "info/mlow")) + ric = blocks(fid, "singular/delta_coil_matrix", q) + gal = blocks(fid, "galerkin/delta_coil", q) + (q=sort(round.(q; digits=2)), ric=ric, gal=gal, mlow=mlow) + end +end + +# cosine between a run block and the nominal block, aligned by common poloidal modes m +function cosine_aligned(br, mlr, bn, mln) + ncr = size(br, 2); ncn = size(bn, 2) + mlo = max(mlr, mln); mhi = min(mlr + ncr - 1, mln + ncn - 1) + mhi >= mlo || return NaN + a = vec(br[:, (mlo - mlr + 1):(mhi - mlr + 1)]) + b = vec(bn[:, (mlo - mln + 1):(mhi - mln + 1)]) + (norm(a) > 0 && norm(b) > 0) ? abs(dot(b, a)) / (norm(a) * norm(b)) : NaN +end + +results = [(ph, run_one(ph)) for ph in PSIHIGHS] + +# nominal = the largest psihigh that produced data (full-domain reference for the cosine metric) +nom_idx = findlast(r -> r[2] !== nothing, results) +nom_idx === nothing && error("no successful runs") +nom = results[nom_idx][2] +@info "nominal (full-domain) psihigh = $(results[nom_idx][1]); surfaces present: $(nom.q)" + +allq = sort(collect(reduce(union, (Set(r[2].q) for r in results if r[2] !== nothing); init=Set{Float64}()))) + +# helper: norm / cosine for a method (:ric or :gal) at a given surface for a run r +getblk(r, meth, q) = (d = getfield(r, meth); (d === nothing || !haskey(d, q)) ? nothing : d[q]) +nrm(r, meth, q) = (b = getblk(r, meth, q); b === nothing ? NaN : norm(b)) +function cosm(r, meth, q) + b = getblk(r, meth, q); bn = getblk(nom, meth, q) + (b === nothing || bn === nothing) ? NaN : cosine_aligned(b, r.mlow, bn, nom.mlow) +end + +outdir = joinpath(@__DIR__, "..", "results"); mkpath(outdir) +tag = replace(basename(base), r"[^A-Za-z0-9]" => "") +csv = joinpath(outdir, "deltacoil_psihigh_rg_$tag.csv") +open(csv, "w") do io + cols = String["psihigh"] + for q in allq, meth in ("ric", "gal"), metric in ("norm", "cos"); push!(cols, "$(metric)_$(meth)_q$(q)"); end + println(io, join(cols, ",")) + for (ph, r) in results + vals = String[string(ph)] + for q in allq + if r === nothing + append!(vals, ["", "", "", ""]) + else + nr = nrm(r, :ric, q); cr = cosm(r, :ric, q) + ng = nrm(r, :gal, q); cg = cosm(r, :gal, q) + push!(vals, isnan(nr) ? "" : string(nr)); push!(vals, isnan(cr) ? "" : string(cr)) + push!(vals, isnan(ng) ? "" : string(ng)); push!(vals, isnan(cg) ? "" : string(cg)) + end + end + println(io, join(vals, ",")) + end +end + +# console summary +nfail = count(r -> r[2] === nothing, results) +println("\n" * "="^80) +println(" delta_coil psihigh truncation ($(basename(base))): Riccati vs Galerkin, $N points, " + * "psihigh $PSIH_MIN -> $PSIH_MAX") +println(" points that produced data: $(N - nfail)/$N (failures recorded as blanks)") +println("="^80) +@printf(" %-10s", "psihigh") +for q in allq; @printf(" | q%-4.1f ric | q%-4.1f gal", q, q); end; println() +for (ph, r) in results + @printf(" %-10.5f", ph) + for q in allq + @printf(" | %8s | %8s", + r === nothing || isnan(nrm(r, :ric, q)) ? "-" : @sprintf("%.2e", nrm(r, :ric, q)), + r === nothing || isnan(nrm(r, :gal, q)) ? "-" : @sprintf("%.2e", nrm(r, :gal, q))) + end + println() +end +println("\nwrote: $(abspath(csv))") diff --git a/benchmarks/deltacoil_sensitivity/scripts/deltamn_resistivity_merge.jl b/benchmarks/deltacoil_sensitivity/scripts/deltamn_resistivity_merge.jl new file mode 100644 index 00000000..ad8c82a1 --- /dev/null +++ b/benchmarks/deltacoil_sensitivity/scripts/deltamn_resistivity_merge.jl @@ -0,0 +1,167 @@ +#!/usr/bin/env julia +# deltamn_resistivity_merge.jl -- Task 5 capstone: merge the outer coupling matrix with the +# eta-dependent inner layer to get the full resonant response vs resistivity. +# +# The coefficient-based Delta_mn is assembled (GalerkinMatch.jl) as +# S_small = transpose(cout) * Dm[1:2msing, :] + Dm[2msing+1 : 2msing+mcoil, :] +# delta_mn[i,j] = KR[i]*S_small[j,2i] - KL[i]*S_small[j,2i-1] +# with Dm = galerkin/delta the OUTER Delta-prime matrix. Only `cout` carries the resistivity +# dependence (through the matched inner-layer Delta), so Delta_mn splits into +# delta_mn = delta_mn_coil (eta-independent, from the Dm coil block) +# + delta_mn_plasma (eta-dependent, the cout-weighted plasma term). +# The per-surface kernels KL,KR are not written to HDF5, so we recover them per surface by least +# squares from the stored delta_mn and S_small (2 complex unknowns, mcoil equations) and report the +# fit residual so the split is verifiable rather than assumed. +# +# Scans eta with the "ray" inner backend (Task 10 established ray is the trustworthy one at |Q| >~ 1). +# Rotation override via ENV["GAL_ROTATION"] (|Q| ~ f * eta^(-1/3); f=1 Hz keeps |Q| < 1). +# +# Usage: julia --project= scripts/deltamn_resistivity_merge.jl [N_eta] [eta_hi] [eta_lo] + +using GeneralizedPerturbedEquilibrium, HDF5, LinearAlgebra, Printf + +length(ARGS) >= 1 || error("usage: julia --project= scripts/deltamn_resistivity_merge.jl [N_eta] [eta_hi] [eta_lo]") +base = ARGS[1]; isdir(base) || error("no such dir: $base") +NETA = length(ARGS) >= 2 ? parse(Int, ARGS[2]) : 8 +ETA_HI = length(ARGS) >= 3 ? parse(Float64, ARGS[3]) : 1e-6 +ETA_LO = length(ARGS) >= 4 ? parse(Float64, ARGS[4]) : 1e-13 +ENV["DELTACOIL_MODE"] = "driven"; delete!(ENV, "DELTACOIL_PROJECT") + +base_toml = read(joinpath(base, "gpec.toml"), String) +scratch = mktempdir() +eqm = match(r"eq_filename\s*=\s*\"([^\"]+)\"", base_toml) +eqfile = eqm === nothing ? nothing : eqm.captures[1] +eqsrc = eqfile === nothing ? nothing : (isabspath(eqfile) ? eqfile : joinpath(base, eqfile)) + +nsurf = (m = match(r"gal_eta\s*=\s*\[([^\]]*)\]", base_toml); m === nothing ? 0 : count(==(','), m.captures[1]) + 1) +nsurf > 0 || error("could not parse gal_eta length from toml") +RHO = (m = match(r"gal_rho\s*=\s*\[\s*([\d.eE+-]+)", base_toml); m === nothing ? 3.3e-7 : parse(Float64, m.captures[1])) +ROT = (rv = get(ENV, "GAL_ROTATION", ""); rv != "" ? parse(Float64, rv) : + (m = match(r"gal_rotation\s*=\s*\[\s*([\d.eE+-]+)", base_toml); m === nothing ? 1.0 : parse(Float64, m.captures[1]))) + +ETAS = 10 .^ range(log10(ETA_HI), log10(ETA_LO); length=NETA) + +"Recover KL,KR per surface by least squares, then split delta_mn into its coil and plasma parts." +function decompose(dmn, cout, Dm) + msing, mcoil = size(dmn) + S_plasma = transpose(cout) * Dm[1:2msing, :] # (mcoil × 2msing) eta-dependent + S_coil = Dm[(2msing+1):(2msing+mcoil), :] # (mcoil × 2msing) eta-independent + S_small = S_plasma .+ S_coil + n_tot = zeros(msing); n_coil = zeros(msing); n_plas = zeros(msing); fitres = zeros(msing) + for i in 1:msing + A = hcat(S_small[:, 2i], -S_small[:, 2i-1]) # columns multiply [KR; KL] + b = vec(dmn[i, :]) + x = A \ b + fitres[i] = norm(A * x - b) / max(norm(b), 1e-300) + KR, KL = x[1], x[2] + n_tot[i] = norm(b) + n_coil[i] = norm(KR .* S_coil[:, 2i] .- KL .* S_coil[:, 2i-1]) + n_plas[i] = norm(KR .* S_plasma[:, 2i] .- KL .* S_plasma[:, 2i-1]) + end + return (tot=n_tot, coil=n_coil, plasma=n_plas, fitres=fitres) +end + +function run_eta(eta) + tag = "eta_$(eta)_ray" + dir = joinpath(scratch, tag); mkpath(dir) + if eqfile !== nothing + dst = joinpath(dir, basename(eqfile)); islink(dst) || symlink(realpath(eqsrc), dst) + end + toml = base_toml + for key in ("force_termination", "HDF5_filename", "gal_match_flag", "gal_inner_solver", + "thmax0", "delta_mband", "gal_eta", "gal_rho", "gal_rotation") + toml = replace(toml, Regex("(?m)^[ \\t]*$key[ \\t]*=.*\\n") => "") + end + arrs = "gal_eta = [" * join(fill(string(eta), nsurf), ", ") * "]\n" * + "gal_rho = [" * join(fill(string(RHO), nsurf), ", ") * "]\n" * + "gal_rotation = [" * join(fill(string(ROT), nsurf), ", ") * "]\n" + toml = replace(toml, "[ForceFreeStates]" => + "[ForceFreeStates]\nforce_termination = true\nHDF5_filename = \"$tag.h5\"\ngal_match_flag = true\n" * + "gal_inner_solver = \"ray\"\n" * arrs; count=1) + eqfile !== nothing && (toml = replace(toml, r"eq_filename\s*=\s*\"[^\"]+\"" => "eq_filename = \"$(basename(eqfile))\"")) + write(joinpath(dir, "gpec.toml"), toml) + @info ">>> eta = $eta (ray, f = $ROT Hz)" + try + GeneralizedPerturbedEquilibrium.main([dir]) + catch err + @warn "run failed at eta=$eta" exception = (err, catch_backtrace()) + return nothing + end + h5path = joinpath(dir, "$tag.h5") + isfile(h5path) || return nothing + h5open(h5path, "r") do fid + haskey(fid, "singular/q") || return nothing + q = vec(read(fid, "singular/q")); isempty(q) && return nothing + (haskey(fid, "galerkin/match/delta_mn") && haskey(fid, "galerkin/match/cout") && + haskey(fid, "galerkin/delta")) || return nothing + dmn = read(fid, "galerkin/match/delta_mn") # (msing × mcoil) + cout = read(fid, "galerkin/match/cout") # (2msing × mcoil) + Dm = read(fid, "galerkin/delta") # (2msing+mcoil × 2msing) + dr = haskey(fid, "galerkin/match/deltar") ? read(fid, "galerkin/match/deltar") : nothing + bp = haskey(fid, "galerkin/match/bpen") ? read(fid, "galerkin/match/bpen") : nothing + dec = decompose(dmn, cout, Dm) + qk = round.(q; digits=2) + deltar = Dict{Float64,Float64}(); bpen = Dict{Float64,Float64}() + dmn_t = Dict{Float64,Float64}(); dmn_c = Dict{Float64,Float64}() + dmn_p = Dict{Float64,Float64}(); fitr = Dict{Float64,Float64}() + for s in 1:length(q) + k = qk[s] + dr === nothing || (deltar[k] = norm(dr[s, :])) + if bp !== nothing + b = size(bp, 1) >= size(bp, 2) ? bp : permutedims(bp) + bpen[k] = norm(b[:, s]) + end + dmn_t[k] = dec.tot[s]; dmn_c[k] = dec.coil[s] + dmn_p[k] = dec.plasma[s]; fitr[k] = dec.fitres[s] + end + (q=sort(qk), deltar=deltar, bpen=bpen, dmn=dmn_t, dmn_coil=dmn_c, dmn_plasma=dmn_p, fitres=fitr) + end +end + +rows = Tuple{Float64,Any}[] +for eta in ETAS + push!(rows, (eta, run_eta(eta))) +end +allq = sort(collect(reduce(union, (Set(keys(r[2].dmn)) for r in rows if r[2] !== nothing); init=Set{Float64}()))) + +outdir = joinpath(@__DIR__, "..", "results"); mkpath(outdir) +tag = replace(basename(base), r"[^A-Za-z0-9]" => "") * "_f" * replace(string(ROT), "." => "p") +csv = joinpath(outdir, "deltamn_merge_$tag.csv") +open(csv, "w") do io + cols = String["eta"] + for q in allq, m in ("dmn", "dmn_coil", "dmn_plasma", "deltar", "bpen", "fitres") + push!(cols, "$(m)_q$(q)") + end + println(io, join(cols, ",")) + for (eta, r) in rows + vals = String[string(eta)] + for q in allq, d in (r === nothing ? [nothing for _ in 1:6] : + [r.dmn, r.dmn_coil, r.dmn_plasma, r.deltar, r.bpen, r.fitres]) + push!(vals, d === nothing ? "" : string(get(d, q, ""))) + end + println(io, join(vals, ",")) + end +end + +println("\n" * "="^92) +println(" Delta_mn MERGE scan ($(basename(base)), ray, f = $ROT Hz): full resonant response vs eta") +println("="^92) +@printf(" %-10s", "eta") +for q in allq; @printf(" | q%-4.1f dmn | q%-4.1f coil | q%-4.1f plas", q, q, q); end; println() +for (eta, r) in rows + @printf(" %-10.2e", eta) + for q in allq + if r === nothing + @printf(" | %9s | %10s | %10s", "-", "-", "-") + else + @printf(" | %9s | %10s | %10s", + haskey(r.dmn, q) ? @sprintf("%.3e", r.dmn[q]) : "-", + haskey(r.dmn_coil, q) ? @sprintf("%.3e", r.dmn_coil[q]) : "-", + haskey(r.dmn_plasma, q) ? @sprintf("%.3e", r.dmn_plasma[q]) : "-") + end + end + println() +end +mx = maximum((maximum(values(r.fitres)) for (_, r) in rows if r !== nothing); init=0.0) +@printf("\n max KL/KR least-squares fit residual over all eta and surfaces: %.3e (should be ~1e-12 or below)\n", mx) +println("wrote: $(abspath(csv))") diff --git a/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_psihigh.py b/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_psihigh.py index 3868b043..d3148b69 100644 --- a/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_psihigh.py +++ b/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_psihigh.py @@ -12,7 +12,18 @@ import matplotlib.pyplot as plt here = os.path.dirname(os.path.abspath(__file__)) -csv_path = sys.argv[1] if len(sys.argv) > 1 else os.path.join(here, '..', 'results', 'deltacoil_psihigh_result.csv') +# optional --psi_s="2:0.518,3:0.770,..." gives the TRUE rational-surface psi locations (from the +# equilibrium's singular/psi), annotated alongside the grid-entry proxy. Non-flag arg = csv path. +TRUE_PSIS = {} +_pos = [] +for a in sys.argv[1:]: + if a.startswith('--psi_s='): + for kv in a.split('=', 1)[1].split(','): + if ':' in kv: + k, v = kv.split(':'); TRUE_PSIS[float(k)] = float(v) + else: + _pos.append(a) +csv_path = _pos[0] if _pos else os.path.join(here, '..', 'results', 'deltacoil_psihigh_result.csv') csv_path = os.path.abspath(csv_path) # --- read CSV --- @@ -65,15 +76,22 @@ def edge_log_x(ax): ax.plot(psihigh[m], y[m], 'o-', color=c, lw=1.8, ms=6, label=f'q = {q:g}') for q, c in zip(qvals, colors): # vlines after data so ylim is settled on the log scale if q in PSI_S and np.isfinite(PSI_S[q]): - ax.axvline(PSI_S[q], ls=':', color=c, lw=1.0, alpha=0.7) - ax.text(PSI_S[q], ax.get_ylim()[1], f' q={q:g} enters ≈{PSI_S[q]:.3f}', color=c, + ax.axvline(PSI_S[q], ls=':', color=c, lw=1.0, alpha=0.7) # grid-entry (first psihigh with data) + lbl = f' q={q:g} enters ≈{PSI_S[q]:.3f}' + if q in TRUE_PSIS: # true rational-surface location + ax.axvline(TRUE_PSIS[q], ls='--', color=c, lw=1.0, alpha=0.55) + lbl += f' (ψ$_s$≈{TRUE_PSIS[q]:.3f})' + ax.text(PSI_S[q], ax.get_ylim()[1], lbl, color=c, fontsize=7.5, rotation=90, va='top', ha='left') edge_log_x(ax) ax.set_xlabel('outer truncation ψ$_{high}$ (log distance from edge, 1 - ψ$_{high}$; edge at right)', fontsize=11) ax.set_ylabel('|delta_coil| per surface (log)', fontsize=11) +_sub = 'each curve begins where ψ$_{high}$ passes that surface' +if TRUE_PSIS: + _sub += ' · dotted = grid entry, dashed = true rational surface ψ$_s$' ax.set_title('delta_coil vs outer truncation ψ$_{high}$ (log-packed toward edge)\n' - f'{CASE}, n=1 · each curve begins where ψ$_{{high}}$ passes that surface', fontsize=11) + f'{CASE}, n=1 · {_sub}', fontsize=10.5) ax.grid(alpha=0.25) ax.legend(title='rational surface', fontsize=9, title_fontsize=9) fig.subplots_adjust(left=0.12, bottom=0.13, right=0.97, top=0.88) diff --git a/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_psihigh_panels.py b/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_psihigh_panels.py new file mode 100644 index 00000000..bb82f1ed --- /dev/null +++ b/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_psihigh_panels.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +# plot_deltacoil_psihigh_panels.py -- per-surface paneled view of the psihigh truncation scan, in the +# "Slide 7" edge-truncation style: ONE subplot per rational surface (its own y-scale), with +# Riccati/STRIDE (orange, solid, circles) vs Galerkin/RDCON (blue, dashed, squares) overlaid, gray +# dotted vertical lines at each rational-surface location, and all panels combined into a single PNG. +# Reads results/deltacoil_psihigh_rg_.csv from deltacoil_psihigh_riccati_galerkin.jl. +# +# Usage: plot_deltacoil_psihigh_panels.py [CASE label] [--qmax=Q] [--quantity=norm|cos] + +import sys, os, csv, math +import numpy as np +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +from matplotlib.lines import Line2D + +argv = [a for a in sys.argv[1:]] +QMAX = None; SUFFIX = ""; QUANT = "norm"; PDFDIR = None +_kept = [] +for a in argv: + if a.startswith("--qmax="): + QMAX = float(a.split("=", 1)[1]); SUFFIX = "_core" + elif a.startswith("--quantity="): + QUANT = a.split("=", 1)[1] + elif a.startswith("--pdfdir="): + PDFDIR = os.path.expanduser(a.split("=", 1)[1]) + else: + _kept.append(a) +argv = _kept +CSVP = os.path.abspath(argv[0]) +here = os.path.dirname(os.path.abspath(__file__)) +_tag = os.path.splitext(os.path.basename(CSVP))[0].replace("deltacoil_psihigh_rg_", "") +CASE = argv[1] if len(argv) > 1 else { + "LARresistivematchtest": "LAR (large-aspect-ratio)", + "DIIIDlikegalresistivepeexample": "DIII-D-like", +}.get(_tag, _tag) + +with open(CSVP) as f: + rows = list(csv.reader(f)) +header = rows[0] +qs = sorted({c.split("_q")[1] for c in header if c.startswith("norm_ric_q")}, key=float) +if QMAX is not None: + qs = [q for q in qs if float(q) <= QMAX + 1e-9] +idx = {c: i for i, c in enumerate(header)} +psihigh = np.array([float(r[0]) for r in rows[1:] if r and r[0] != ""]) + +def col(name): + j = idx.get(name); out = [] + for r in rows[1:]: + if not r or r[0] == "": + continue + v = r[j] if (j is not None and j < len(r)) else "" + try: + out.append(float(v) if v not in ("", "FAIL", "NaN") else np.nan) + except ValueError: + out.append(np.nan) + return np.array(out) + +# entry psihigh per surface (approx psi_s) = smallest psihigh at which the surface has data +def entry_ph(q): + y = col(f"norm_ric_q{q}"); m = np.isfinite(y) + return psihigh[m].min() if m.any() else np.nan +PSI_S = {q: entry_ph(q) for q in qs} + +ORANGE = "#ff7f0e"; BLUE = "#1f77b4" +prefix, ylabel, ylog = ("norm", "|delta_coil|", True) if QUANT == "norm" \ + else ("cos", "cosine sim. with nominal", False) + +# common x-range (0.9 -> 0.9999) for every panel so surfaces are compared on the same axis +GX0 = psihigh.min(); GXMAX = psihigh.max() +def edge_log_x(ax): + fwd = lambda p: -np.log10(np.clip(1.0 - np.asarray(p, float), 1e-9, None)) + inv = lambda t: 1.0 - 10.0 ** (-np.asarray(t, float)) + ax.set_xscale("function", functions=(fwd, inv)) + cand = np.array([0.9, 0.99, 0.999, 0.9999]) + tk = cand[(cand >= GX0 - 1e-9) & (cand <= GXMAX + 1e-9)] + ax.set_xticks(tk); ax.set_xticklabels([f"{t:g}" for t in tk]); ax.minorticks_off() + ax.set_xlim(GX0 - (1 - GX0) * 0.06, GXMAX + (1 - GXMAX) * 0.4) + +N = len(qs) +# balanced grid: a single row for <=3 surfaces, otherwise two rows (2x2 for 4, 2x4 for 7-8), so +# panels stay roughly square and legible rather than a long thin strip +if N <= 3: + ncols = N; nrows = 1 +else: + ncols = math.ceil(N / 2); nrows = 2 +fig, axes = plt.subplots(nrows, ncols, figsize=(4.7 * ncols, 4.1 * nrows), squeeze=False) + +for i, q in enumerate(qs): + ax = axes[i // ncols][i % ncols] + yr = col(f"{prefix}_ric_q{q}"); yg = col(f"{prefix}_gal_q{q}") + mr = np.isfinite(yr); mg = np.isfinite(yg) + ax.plot(psihigh[mr], yr[mr], "o-", color=ORANGE, lw=1.6, ms=4) + ax.plot(psihigh[mg], yg[mg], "s--", color=BLUE, lw=1.5, ms=4) + if ylog: + ax.set_yscale("log") + else: + ax.set_ylim(-0.05, 1.06) + edge_log_x(ax) + # gray dotted vertical lines only for rational surfaces that ENTER within the plotted psihigh window. + # A surface already present at the leftmost psihigh (entry == psihigh.min()) lies interior to the + # window (psi_s < psihigh_min), i.e. off the left edge, so drawing it at the boundary would falsely + # imply a surface at psihigh_min. Those are skipped (e.g. LAR q=2,3 both sit at psi<0.9). + ytop = ax.get_ylim()[1] + for qq in qs: + ps = PSI_S[qq] + if np.isfinite(ps) and ps > GX0 + 1e-9: + ax.axvline(ps, ls=":", color="#999", lw=0.9) + ax.text(ps, ytop, f" q={float(qq):g}", color="#666", fontsize=7, rotation=90, va="top", ha="left") + ax.set_title(f"q = {float(q):g}/1", fontsize=11) + ax.set_xlabel(r"$\psi_{high}$", fontsize=10) + if i % ncols == 0: + ax.set_ylabel(ylabel + (" (log)" if ylog else ""), fontsize=10) + ax.grid(alpha=0.25, which="both") + if i == 0: + handles = [Line2D([0], [0], color=ORANGE, ls="-", marker="o", ms=4, label="Riccati (STRIDE)"), + Line2D([0], [0], color=BLUE, ls="--", marker="s", ms=4, label="Galerkin (RDCON)"), + Line2D([0], [0], color="#999", ls=":", label="rational surface")] + ax.legend(handles=handles, fontsize=8, loc="best", framealpha=0.9) + +for j in range(N, nrows * ncols): + axes[j // ncols][j % ncols].axis("off") + +qtxt = "|delta_coil|" if QUANT == "norm" else "delta_coil shape cosine" +fig.suptitle(f"Edge truncation: {qtxt} vs psihigh (log to psi=1, no wall) | {CASE}, n=1\n" + "per rational surface: Riccati (STRIDE, orange) vs Galerkin (RDCON, blue)", fontsize=12) +fig.tight_layout(rect=[0, 0, 1, 0.94]) +stem = os.path.splitext(os.path.basename(CSVP))[0] + SUFFIX + f"_panels_{prefix}" +out = os.path.abspath(os.path.join(here, "..", "figures", stem + ".png")) +fig.savefig(out, dpi=150); print("wrote:", out) + +# optional vector PDF with a human-readable, title-based filename for easy navigation +if PDFDIR is not None: + os.makedirs(PDFDIR, exist_ok=True) + case_short = "DIII-D" if "DIII" in CASE else ("LAR" if "LAR" in CASE else CASE) + qrange = f"q{int(float(qs[0]))}-{int(float(qs[-1]))}" + qtxt_file = "norm (delta_coil magnitude)" if QUANT == "norm" else "shape cosine" + fname = f"{case_short} - delta_coil edge truncation - {qrange} - Riccati vs Galerkin - {qtxt_file}.pdf" + pdfout = os.path.join(PDFDIR, fname) + fig.savefig(pdfout); print("wrote PDF:", pdfout) diff --git a/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_psihigh_rg.py b/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_psihigh_rg.py new file mode 100644 index 00000000..775416d8 --- /dev/null +++ b/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_psihigh_rg.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +# plot_deltacoil_psihigh_rg.py -- plot the psihigh truncation scan with BOTH methods overlaid: +# Riccati/STRIDE (solid circles) and Galerkin/RDCON (dashed squares), one color per rational surface. +# Reads results/deltacoil_psihigh_rg_.csv from deltacoil_psihigh_riccati_galerkin.jl and writes +# three figures per case: +# _norm.png |delta_coil| per surface vs psihigh (log-y) +# _relchange.png relative change vs the outermost psihigh (convergence view) +# _metric.png cosine shape metric vs the full-domain nominal (shape rotation view) +# x-axis is log distance from the edge (1 - psihigh); edge at right. +# +# Usage: python3 plot_deltacoil_psihigh_rg.py .csv> [CASE label] + +import sys, os, csv +import numpy as np +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +from matplotlib.lines import Line2D +from matplotlib.legend_handler import HandlerTuple + +# positional: CSV [CASE label]; optional --qmax= keeps only surfaces with q <= qmax and appends +# "_core" to the output names (so the full-range figure is not overwritten). Used to keep the DIII-D +# comparison legible when near-edge surfaces (q>=6) diverge by many decades. +argv = [a for a in sys.argv[1:]] +QMAX = None; SUFFIX = "" +_kept = [] +for a in argv: + if a.startswith("--qmax="): + QMAX = float(a.split("=", 1)[1]); SUFFIX = "_core" + else: + _kept.append(a) +argv = _kept +CSVP = os.path.abspath(argv[0]) +here = os.path.dirname(os.path.abspath(__file__)) +_tag = os.path.splitext(os.path.basename(CSVP))[0].replace("deltacoil_psihigh_rg_", "") +CASE = argv[1] if len(argv) > 1 else { + "LARresistivematchtest": "LAR (large-aspect-ratio)", + "DIIIDlikegalresistivepeexample": "DIII-D-like", +}.get(_tag, _tag) + +with open(CSVP) as f: + rows = list(csv.reader(f)) +header = rows[0] +# surfaces present, from the norm_ric_q columns +qs = sorted({c.split("_q")[1] for c in header if c.startswith("norm_ric_q")}, key=float) +if QMAX is not None: + qs = [q for q in qs if float(q) <= QMAX + 1e-9] +idx = {c: i for i, c in enumerate(header)} + +def col(name): + j = idx.get(name) + out = [] + for r in rows[1:]: + if not r or r[0] == "": + continue + v = r[j] if (j is not None and j < len(r)) else "" + try: + out.append(float(v)) if v not in ("", "FAIL", "NaN") else out.append(np.nan) + except ValueError: + out.append(np.nan) + return np.array(out) + +psihigh = np.array([float(r[0]) for r in rows[1:] if r and r[0] != ""]) +# Riccati/STRIDE keeps the cool viridis palette; Galerkin/RDCON uses warm tones (orange -> dark red), +# so the two methods are distinguished by COLOR FAMILY (cool vs warm) and surface by shade within it. +colors_ric = plt.cm.viridis(np.linspace(0.08, 0.82, len(qs))) +colors_gal = plt.cm.YlOrRd(np.linspace(0.45, 0.95, len(qs))) + +# x-axis: distance from edge (1 - psihigh) on a log scale, edge at right. Anchor to leftmost psihigh +# that has any data so empty near-axis points do not push the range. +def edge_log_x(ax, x0, xmax): + fwd = lambda p: -np.log10(np.clip(1.0 - np.asarray(p, float), 1e-9, None)) + inv = lambda t: 1.0 - 10.0 ** (-np.asarray(t, float)) + ax.set_xscale("function", functions=(fwd, inv)) + cand = np.array([0.9, 0.95, 0.98, 0.99, 0.995, 0.999, 0.9999]) + tk = cand[(cand >= x0 - 1e-9) & (cand <= xmax + 1e-9)] + ax.set_xticks(tk); ax.set_xticklabels([f"{t:g}" for t in tk]); ax.minorticks_off() + ax.set_xlim(x0 - (1 - x0) * 0.06, xmax + (1 - xmax) * 0.4) + +def method_legend(ax): + # representative mid-palette swatches so the cool=Riccati / warm=Galerkin mapping is explicit; + # placed OUTSIDE the axes (lower right) so it never covers data points + handles = [Line2D([0], [0], color=plt.cm.viridis(0.45), ls="-", marker="o", ms=5, lw=2, label="Riccati (STRIDE), cool"), + Line2D([0], [0], color=plt.cm.YlOrRd(0.75), ls="--", marker="s", ms=5, lw=2, label="Galerkin (RDCON), warm")] + leg = ax.legend(handles=handles, fontsize=8.5, loc="lower left", bbox_to_anchor=(1.015, 0.0), + framealpha=0.95, title="method (color family)", title_fontsize=8.5) + ax.add_artist(leg) + +def surface_legend(ax): + # each surface shows BOTH its Riccati (cool) and Galerkin (warm) swatch as a paired handle; + # placed OUTSIDE the axes (upper right) so all points stay visible + handles = [(Line2D([0], [0], color=colors_ric[k], ls="-", marker="o", ms=5, lw=2), + Line2D([0], [0], color=colors_gal[k], ls="--", marker="s", ms=5, lw=2)) for k in range(len(qs))] + labels = [f"q = {q}" for q in qs] + ax.legend(handles=handles, labels=labels, fontsize=8.5, loc="upper left", bbox_to_anchor=(1.015, 1.0), + title="rational surface (Riccati | Galerkin)", title_fontsize=8.5, framealpha=0.95, + handler_map={tuple: HandlerTuple(ndivide=None)}, handlelength=3.0) + +def x0_of(mask_cols): + has = np.zeros(len(psihigh), bool) + for c in mask_cols: + y = col(c); has |= np.isfinite(y) + return psihigh[has].min() if has.any() else psihigh.min() + +stem = os.path.splitext(os.path.basename(CSVP))[0] + SUFFIX +figdir = os.path.abspath(os.path.join(here, "..", "figures")); os.makedirs(figdir, exist_ok=True) + +# ---------- (1) norm ---------- +_panel = "norm" +fig, ax = plt.subplots(figsize=(11.4, 5.4)); ax.set_yscale("log") +for k, q in enumerate(qs): + yr = col(f"norm_ric_q{q}"); yg = col(f"norm_gal_q{q}") + mr = np.isfinite(yr); mg = np.isfinite(yg) + ax.plot(psihigh[mr], yr[mr], "o-", color=colors_ric[k], lw=1.8, ms=5) + ax.plot(psihigh[mg], yg[mg], "s--", color=colors_gal[k], lw=1.5, ms=4.5, alpha=0.95) +x0 = x0_of([f"norm_ric_q{q}" for q in qs] + [f"norm_gal_q{q}" for q in qs]) +edge_log_x(ax, x0, psihigh.max()) +ax.set_xlabel(r"outer truncation $\psi_{high}$ (log distance from edge, 1 - $\psi_{high}$; edge at right)", fontsize=11) +ax.set_ylabel(r"|delta_coil| per surface (log)", fontsize=11) +ax.set_title(f"delta_coil vs outer truncation: Riccati (STRIDE) vs Galerkin (RDCON)\n{CASE}, n=1", fontsize=11) +ax.grid(alpha=0.25, which="both") +method_legend(ax); surface_legend(ax) +fig.subplots_adjust(left=0.08, bottom=0.13, right=0.76, top=0.9) +p1 = os.path.join(figdir, stem + "_norm.png"); fig.savefig(p1, dpi=150); print("wrote:", p1) + +# ---------- (2) relchange vs outermost psihigh ---------- +_panel = "rel" +fig, ax = plt.subplots(figsize=(11.4, 5.4)); ax.set_yscale("log") +for k, q in enumerate(qs): + for meth, cmap, style in (("ric", colors_ric, ("o-", 1.8, 5, 1.0)), ("gal", colors_gal, ("s--", 1.5, 4.5, 0.95))): + y = col(f"norm_{meth}_q{q}"); m = np.isfinite(y) + if m.sum() < 2: + continue + ref = y[m][-1] + ax.plot(psihigh[m], np.abs(y[m] - ref) / max(abs(ref), 1e-300), + style[0], color=cmap[k], lw=style[1], ms=style[2], alpha=style[3]) +x0 = x0_of([f"norm_ric_q{q}" for q in qs] + [f"norm_gal_q{q}" for q in qs]) +edge_log_x(ax, x0, psihigh.max()) +ax.set_xlabel(r"outer truncation $\psi_{high}$ (log distance from edge, 1 - $\psi_{high}$; edge at right)", fontsize=11) +ax.set_ylabel(r"|$\Delta$ delta_coil| / |delta_coil($\psi_{high}$=max)|", fontsize=11) +ax.set_title(f"Relative sensitivity of delta_coil to the truncation boundary\n{CASE}: Riccati vs Galerkin (lower = more converged)", fontsize=11) +ax.grid(alpha=0.25, which="both") +method_legend(ax); surface_legend(ax) +fig.subplots_adjust(left=0.09, bottom=0.13, right=0.76, top=0.9) +p2 = os.path.join(figdir, stem + "_relchange.png"); fig.savefig(p2, dpi=150); print("wrote:", p2) + +# ---------- (3) cosine shape metric ---------- +_panel = "metric" +fig, ax = plt.subplots(figsize=(11.4, 5.4)) +for k, q in enumerate(qs): + yr = col(f"cos_ric_q{q}"); yg = col(f"cos_gal_q{q}") + mr = np.isfinite(yr); mg = np.isfinite(yg) + ax.plot(psihigh[mr], yr[mr], "o-", color=colors_ric[k], lw=1.9, ms=5) + ax.plot(psihigh[mg], yg[mg], "s--", color=colors_gal[k], lw=1.5, ms=4.5, alpha=0.95) +ax.axhline(1.0, ls=":", color="#888", lw=1.0) +x0 = x0_of([f"cos_ric_q{q}" for q in qs] + [f"cos_gal_q{q}" for q in qs]) +edge_log_x(ax, x0, psihigh.max()) +ax.set_ylim(-0.05, 1.06) +ax.set_xlabel(r"outer truncation $\psi_{high}$ (log distance from edge, 1 - $\psi_{high}$; edge at right)", fontsize=11) +ax.set_ylabel("cosine similarity of delta_coil with full-domain nominal", fontsize=11) +ax.set_title(f"Shape (cosine) metric vs psihigh truncation: Riccati vs Galerkin\n{CASE}, n=1 (1.0 = shape unchanged; lower = pattern rotated)", fontsize=11) +ax.grid(alpha=0.25) +method_legend(ax); surface_legend(ax) +fig.subplots_adjust(left=0.08, bottom=0.13, right=0.76, top=0.88) +p3 = os.path.join(figdir, stem + "_metric.png"); fig.savefig(p3, dpi=150); print("wrote:", p3) diff --git a/benchmarks/deltacoil_sensitivity/scripts/plot_deltamn_merge.py b/benchmarks/deltacoil_sensitivity/scripts/plot_deltamn_merge.py new file mode 100644 index 00000000..80b480ac --- /dev/null +++ b/benchmarks/deltacoil_sensitivity/scripts/plot_deltamn_merge.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +# Task 5: plot the merged resonant response vs resistivity from deltamn_resistivity_merge.jl. +# (a) full |Delta_mn| vs eta, one curve per rational surface (the capstone result) +# (b) decomposition per surface: the eta-independent coil term vs the eta-dependent plasma term. +# The coil term must be FLAT in eta; its relative spread is printed as an internal consistency check. +# Usage: plot_deltamn_merge.py [title] +import sys, csv, math +import numpy as np +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt + +CSV, OUT = sys.argv[1], sys.argv[2] +TITLE = sys.argv[3] if len(sys.argv) > 3 else "" + +rows = list(csv.DictReader(open(CSV))) +if not rows: + raise SystemExit(f"no rows in {CSV}") +cols = rows[0].keys() +qs = sorted({c.split("_q")[1] for c in cols if c.startswith("dmn_q")}, key=float) + +def col(name): + out = [] + for r in rows: + v = r.get(name, "") + try: + out.append(float(v)) if v not in ("", None) else out.append(np.nan) + except ValueError: + out.append(np.nan) + return np.array(out) + +eta = col("eta") +order = np.argsort(-eta) # high eta (weakly driven) -> low eta (strongly driven) +eta = eta[order] + +COLORS = ["#1f5fbf", "#e07b1a", "#2a9d4a", "#b3306b", "#6a3fb5"] +fig, (axA, axB) = plt.subplots(1, 2, figsize=(11.6, 4.4)) + +print(f"\n=== {CSV} ===") +for k, q in enumerate(qs): + c = COLORS[k % len(COLORS)] + tot = col(f"dmn_q{q}")[order] + coil = col(f"dmn_coil_q{q}")[order] + plas = col(f"dmn_plasma_q{q}")[order] + fitr = col(f"fitres_q{q}")[order] + + axA.plot(eta, tot, "o-", color=c, lw=1.8, ms=4, label=f"q = {q}") + axB.plot(eta, plas, "o-", color=c, lw=1.8, ms=4, label=f"q = {q} plasma (eta-dep)") + axB.plot(eta, coil, "s--", color=c, lw=1.4, ms=3.5, alpha=0.75, label=f"q = {q} coil (eta-indep)") + + good = np.isfinite(coil) & (coil > 0) + spread = (np.nanmax(coil[good]) / np.nanmin(coil[good]) - 1.0) if good.sum() > 1 else float("nan") + swing = (np.nanmax(tot[np.isfinite(tot)]) / np.nanmin(tot[np.isfinite(tot)])) if np.isfinite(tot).sum() > 1 else float("nan") + print(f" q={q}: |Delta_mn| swings {swing:.3g}x over eta | coil-term relative spread {spread:.2e} " + f"(should be ~0) | max fit residual {np.nanmax(fitr):.2e}") + +for ax in (axA, axB): + ax.set_xscale("log"); ax.set_yscale("log") + ax.invert_xaxis() # left = high eta (weak drive), right = low eta (strong drive) + ax.set_xlabel("resistivity eta (decreasing to the right = more strongly driven layer)") + ax.grid(True, which="major", alpha=0.25) +axA.set_ylabel("|Delta_mn| (merged resonant response)") +axA.set_title("(a) Full merged resonant response vs resistivity", fontsize=10.5) +axA.legend(fontsize=8.5) +axB.set_ylabel("contribution to |Delta_mn|") +axB.set_title("(b) Decomposition: eta-independent coil vs eta-dependent plasma", fontsize=10.5) +axB.legend(fontsize=7.4, ncol=1) + +if TITLE: + fig.suptitle(TITLE, fontsize=11.5, y=1.00) +fig.tight_layout() +fig.savefig(OUT, dpi=150) +print("WROTE:", OUT) diff --git a/benchmarks/deltacoil_sensitivity/scripts/plot_deltaprime_epsilon.py b/benchmarks/deltacoil_sensitivity/scripts/plot_deltaprime_epsilon.py new file mode 100644 index 00000000..a33a4580 --- /dev/null +++ b/benchmarks/deltacoil_sensitivity/scripts/plot_deltaprime_epsilon.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +# plot_deltaprime_epsilon.py -- Phase C benchmark for the single-surface TJ study: GPEC's tearing +# Delta-prime versus inverse aspect ratio epsilon = a/R0, at fixed cylindrical q-profile, for each +# single-rational-surface case. As epsilon -> 0 the toroidal Delta-prime must converge to the cylindrical +# (large-aspect) value; the dotted lines mark the analytic vacuum limit r_s*Delta' = -2m from the +# independent Newcomb solver (exact when the current gradient at the surface is negligible). +# +# Reads scratch eps__/gpec_eps__.h5 written by the eps-scan runner. +# Usage: python3 plot_deltaprime_epsilon.py + +import sys, os +import numpy as np +import h5py +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt + +SC, FIGDIR = sys.argv[1], sys.argv[2] +os.makedirs(FIGDIR, exist_ok=True) +LABS = ["q1", "q2", "q3", "q4", "q5"] +MOF = {"q1":1,"q2":2,"q3":3,"q4":4,"q5":5} +EPS = [0.2, 0.1, 0.05] + +def read_dp(h5): + with h5py.File(h5, "r") as f: + if "galerkin/pest3_Delta" not in f: + return None, None + dp = np.atleast_2d(f["galerkin/pest3_Delta"][()]).ravel() + q = np.atleast_1d(f["singular/q"][()]) + return float(np.real(dp[0])), float(q[0]) if len(q) else None + +data = {} # lab -> list of (eps, Dprime) +print("=== GPEC tearing Delta-prime vs aspect ratio epsilon (fixed q-profile) ===") +print(f" {'case':5s} {'m':>2s} " + "".join(f"eps={e:<7}" for e in EPS) + " vacuum(-2m) converged?") +for lab in LABS: + m = MOF[lab]; row = [] + for e in EPS: + h5 = os.path.join(SC, f"eps_{lab}_{e}", f"gpec_eps_{lab}_{e}.h5") + if os.path.exists(h5): + dp, q = read_dp(h5) + row.append((e, dp)) + else: + row.append((e, None)) + data[lab] = row + vals = [d for _, d in row if d is not None] + conv = (abs(vals[-1] - vals[-2]) if len(vals) >= 2 else float("nan")) + cells = "".join(f"{(d if d is not None else float('nan')):<+11.3f}" for _, d in row) + print(f" {lab:5s} {m:>2d} {cells} {-2*m:<+11d} d(last2)={conv:.3f}") + +# ---- plot ---- +colors = plt.cm.viridis(np.linspace(0.08, 0.82, len(LABS))) +fig, ax = plt.subplots(figsize=(9.0, 5.6)) +for k, lab in enumerate(LABS): + m = MOF[lab] + es = [e for e, d in data[lab] if d is not None] + ds = [d for e, d in data[lab] if d is not None] + if not ds: + continue + ax.plot(es, ds, "o-", color=colors[k], lw=1.9, ms=6, label=f"{lab}: q={m}/1") + ax.axhline(-2*m, ls=":", color=colors[k], lw=1.1, alpha=0.7) + ax.text(0.205, -2*m, f" -2m={-2*m}", color=colors[k], fontsize=7.5, va="center", ha="left") +ax.set_xscale("log") +ax.invert_xaxis() # epsilon decreasing to the right = more cylindrical +ax.set_xlabel(r"inverse aspect ratio $\epsilon = a/R_0$ (decreasing to the right = more cylindrical)", fontsize=11) +ax.set_ylabel(r"tearing $\Delta'$ (PEST3, $= r_s\Delta'$)", fontsize=11) +ax.set_title("Single-surface TJ study: GPEC tearing $\\Delta'$ vs aspect ratio\n" + "dotted = analytic Newcomb vacuum limit $r_s\\Delta'=-2m$ (exact); " + "convergence as $\\epsilon\\to0$ validates the cylindrical reduction", fontsize=10.5) +ax.grid(alpha=0.25, which="both") +ax.legend(fontsize=9, title="rational surface", title_fontsize=9, loc="best") +fig.tight_layout() +out = os.path.join(FIGDIR, "single_surface_deltaprime_epsilon.png") +fig.savefig(out, dpi=150) +print("wrote:", out) diff --git a/benchmarks/deltacoil_sensitivity/scripts/plot_marginal_q2.py b/benchmarks/deltacoil_sensitivity/scripts/plot_marginal_q2.py new file mode 100644 index 00000000..c8e0ac87 --- /dev/null +++ b/benchmarks/deltacoil_sensitivity/scripts/plot_marginal_q2.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +# plot_marginal_q2.py -- Phase D marginal-stability scan for the single q=2 TJ surface. Varies the +# edge safety factor qa (fixed qc=1.5, so the current-peaking nu=qa/qc changes) while keeping exactly +# one rational surface (q=2). Tracks the tearing Delta-prime; the classic single-surface tearing test +# is Delta-prime passing through zero (marginal stability) as the current gradient at the surface changes. +# Reads scratch marg_q2_qa<...>/gpec_marg_q2_qa<...>.h5. +# Usage: python3 plot_marginal_q2.py + +import sys, os +import numpy as np +import h5py +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt + +SC, FIGDIR = sys.argv[1], sys.argv[2] +os.makedirs(FIGDIR, exist_ok=True) +QC = 1.5 +QAS = [2.1, 2.2, 2.3, 2.4, 2.5, 2.7, 2.9] + +rows = [] +for qa in QAS: + tag = f"qa{qa}".replace(".", "p") + h5 = os.path.join(SC, f"marg_q2_{tag}", f"gpec_marg_q2_{tag}.h5") + if not os.path.exists(h5): + continue + with h5py.File(h5, "r") as f: + if "galerkin/pest3_Delta" not in f: + continue + dp = float(np.real(np.atleast_2d(f["galerkin/pest3_Delta"][()]).ravel()[0])) + q = np.atleast_1d(f["singular/q"][()]) + rows.append((qa, qa / QC, dp, len(q))) + +print("=== q2 marginal-stability scan (qc=1.5 fixed) ===") +print(f" {'qa':>5s} {'nu=qa/qc':>9s} {'msing':>5s} {'Delta_prime':>12s}") +for qa, nu, dp, ms in rows: + print(f" {qa:>5.2f} {nu:>9.3f} {ms:>5d} {dp:>+12.3f}") + +qas = np.array([r[0] for r in rows]); dps = np.array([r[2] for r in rows]) +# locate zero crossing by linear interpolation between sign changes +qa_marg = None +for i in range(len(qas) - 1): + if dps[i] == 0 or (dps[i] < 0) != (dps[i+1] < 0): + qa_marg = qas[i] + (0 - dps[i]) * (qas[i+1] - qas[i]) / (dps[i+1] - dps[i]) + break +print(f" marginal (Delta_prime=0) at qa ~ {qa_marg:.3f} (nu ~ {qa_marg/QC:.3f})" if qa_marg else " no sign change in scanned range") + +fig, ax = plt.subplots(figsize=(8.2, 5.2)) +ax.plot(qas, dps, "o-", color="#1f5fbf", lw=2, ms=7) +ax.axhline(0, color="k", lw=0.9, ls="--") +if qa_marg: + ax.axvline(qa_marg, color="#c0392b", lw=1.2, ls=":") + ax.plot([qa_marg], [0], "s", color="#c0392b", ms=9, + label=f"marginal: qa~{qa_marg:.3f} (nu~{qa_marg/QC:.3f})") + ax.legend(fontsize=9, loc="best") +ax.text(0.02, 0.96, "unstable (Delta' > 0)", transform=ax.transAxes, fontsize=9, color="#a33", va="top") +ax.text(0.02, 0.05, "stable (Delta' < 0)", transform=ax.transAxes, fontsize=9, color="#264", va="bottom") +ax.set_xlabel("edge safety factor qa (current peaking nu = qa/qc, qc=1.5 fixed)", fontsize=11) +ax.set_ylabel("tearing $\\Delta'$ (PEST3)", fontsize=11) +ax.set_title("Single q=2 TJ surface: marginal-stability scan\n$\\Delta'$ vs current peaking; zero crossing = tearing marginal point", fontsize=11) +ax.grid(alpha=0.25) +fig.tight_layout() +out = os.path.join(FIGDIR, "single_surface_q2_marginal.png") +fig.savefig(out, dpi=150) +print("wrote:", out) diff --git a/benchmarks/deltacoil_sensitivity/scripts/plot_psihigh_fig1style_rg.py b/benchmarks/deltacoil_sensitivity/scripts/plot_psihigh_fig1style_rg.py new file mode 100644 index 00000000..9cdbcf0c --- /dev/null +++ b/benchmarks/deltacoil_sensitivity/scripts/plot_psihigh_fig1style_rg.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +# plot_psihigh_fig1style_rg.py -- Task-1 / Fig-1a-b style view of the Riccati-vs-Galerkin edge scan: +# all surfaces overlaid on ONE magnitude axis (left) plus the relative-change convergence view (right), +# from the 100-point deltacoil_psihigh_rg_.csv (psihigh 0.9 -> 0.9999). Color = surface; +# STRIDE (Riccati) solid+circles, Galerkin (RDCON) dashed+squares. Surfaces that ENTER within the +# window get dotted grid-entry + dashed true-psi_s markers; surfaces already present at the left edge +# are interior (psi_s < 0.9, off-screen left) and their psi_s is noted in the legend. +# Usage: plot_psihigh_fig1style_rg.py [--qmax=Q] [--psi_s="2:0.49,3:0.886,..."] + +import sys, os, csv +import numpy as np +import matplotlib +matplotlib.use('Agg') +import matplotlib.pyplot as plt +from matplotlib.lines import Line2D + +QMAX = None; TRUE = {}; pos = [] +for a in sys.argv[1:]: + if a.startswith('--qmax='): + QMAX = float(a.split('=', 1)[1]) + elif a.startswith('--psi_s='): + for kv in a.split('=', 1)[1].split(','): + if ':' in kv: + k, v = kv.split(':'); TRUE[float(k)] = float(v) + else: + pos.append(a) +CSVP = os.path.abspath(pos[0]); here = os.path.dirname(os.path.abspath(__file__)) +_tag = os.path.splitext(os.path.basename(CSVP))[0].replace('deltacoil_psihigh_rg_', '') +CASE = {'LARresistivematchtest': 'LAR (large-aspect-ratio)', + 'DIIIDlikegalresistivepeexample': 'DIII-D-like'}.get(_tag, _tag) + +rows = list(csv.reader(open(CSVP))); h = rows[0]; idx = {c: i for i, c in enumerate(h)} +ph = np.array([float(r[0]) for r in rows[1:] if r and r[0]]) +qs = sorted({c.split('_q')[1] for c in h if c.startswith('norm_ric_q')}, key=float) +if QMAX is not None: + qs = [q for q in qs if float(q) <= QMAX + 1e-9] + +def col(n): + j = idx.get(n); out = [] + for r in rows[1:]: + if not r or not r[0]: + continue + v = r[j] if (j is not None and j < len(r)) else '' + out.append(float(v) if v not in ('', 'FAIL', 'NaN') else np.nan) + return np.array(out) + +GX0, GXM = ph.min(), ph.max() +def edge_log_x(ax): + fwd = lambda p: -np.log10(np.clip(1.0 - np.asarray(p, float), 1e-9, None)) + inv = lambda t: 1.0 - 10.0 ** (-np.asarray(t, float)) + ax.set_xscale('function', functions=(fwd, inv)) + cand = np.array([0.9, 0.99, 0.999, 0.9999]) + tk = cand[(cand >= GX0 - 1e-9) & (cand <= GXM + 1e-9)] + ax.set_xticks(tk); ax.set_xticklabels([f'{t:g}' for t in tk]); ax.minorticks_off() + ax.set_xlim(GX0 - (1 - GX0) * 0.06, GXM + (1 - GXM) * 0.4) + +colors = plt.cm.viridis(np.linspace(0.08, 0.82, len(qs))) +fig, (axM, axR) = plt.subplots(1, 2, figsize=(13.5, 5.4)) + +for q, c in zip(qs, colors): + qf = float(q) + yr = col(f'norm_ric_q{q}'); yg = col(f'norm_gal_q{q}') + interior = np.isfinite(yr[np.argmin(ph)]) # present at the leftmost psihigh? + lab = f'q = {qf:g}' + if interior and qf in TRUE: + lab += f' (ψ$_s$≈{TRUE[qf]:.2f}, interior)' + mR = np.isfinite(yr); mG = np.isfinite(yg) + axM.plot(ph[mR], yr[mR], '-', color=c, lw=1.8, label=lab) + axM.plot(ph[mG], yg[mG], '--', color=c, lw=1.2, alpha=0.85) + # markers only for surfaces that enter within the window (not interior/off-left) + if not interior: + ent = ph[mR].min() + axM.axvline(ent, ls=':', color=c, lw=1.0, alpha=0.7) + t = f' q={qf:g} enters ≈{ent:.4f}' + if qf in TRUE: + axM.axvline(TRUE[qf], ls='--', color=c, lw=1.0, alpha=0.5) + t += f' (ψ$_s$≈{TRUE[qf]:.4f})' + axM.text(ent, axM.get_ylim()[1], t, color=c, fontsize=7, rotation=90, va='top', ha='left') + # relative change vs the outermost (edge-most) value, STRIDE + if mR.any(): + ref = yr[mR][-1] + axR.plot(ph[mR], np.abs(yr[mR] - ref) / max(abs(ref), 1e-300), '-', color=c, lw=1.8, label=f'q = {qf:g}') + +for ax in (axM, axR): + ax.set_yscale('log'); edge_log_x(ax); ax.grid(alpha=0.25, which='both') + ax.set_xlabel('outer truncation ψ$_{high}$ (log distance from edge; edge at right)', fontsize=10) +axM.set_ylabel('|delta_coil| per surface (log)', fontsize=11) +axR.set_ylabel('relative change vs ψ$_{high}$=max (log; lower = converged)', fontsize=10) +axM.set_title('|delta_coil| vs ψ$_{high}$ (all surfaces, one axis)', fontsize=11) +axR.set_title('Convergence: relative sensitivity to the boundary (Riccati)', fontsize=11) +# legends +h1 = axM.get_legend_handles_labels() +axM.legend(*h1, fontsize=8, title='rational surface', title_fontsize=8, loc='lower right') +style = [Line2D([0], [0], color='#444', ls='-', label='Riccati (STRIDE)'), + Line2D([0], [0], color='#444', ls='--', label='Galerkin (RDCON)')] +axR.legend(handles=style + [Line2D([0], [0], color=c, ls='-', label=f'q = {float(q):g}') for q, c in zip(qs, colors)], + fontsize=8, loc='lower left', ncol=2) +fig.suptitle(f'Edge truncation, Fig-1a/1b style: {CASE}, n=1 · 100 points, ψ$_{{high}}$ = 0.9 to 0.9999\n' + 'color = surface; Riccati (STRIDE) solid, Galerkin (RDCON) dashed', fontsize=12) +fig.tight_layout(rect=[0, 0, 1, 0.93]) +stem = 'deltacoil_psihigh_fig1style_' + _tag +out = os.path.abspath(os.path.join(here, '..', 'figures', stem + '.png')) +fig.savefig(out, dpi=150); print('wrote:', out) From 6b37f9573176d1cd56b533edad04cba53257d174 Mon Sep 17 00:00:00 2001 From: Matthew Pharr Date: Wed, 29 Jul 2026 18:36:51 -0400 Subject: [PATCH 19/38] PerturbedEquilibrium - MINOR - Add psi_n to response output structure in HDF5 --- src/PerturbedEquilibrium/Utils.jl | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/PerturbedEquilibrium/Utils.jl b/src/PerturbedEquilibrium/Utils.jl index 0cae9c23..1a239185 100644 --- a/src/PerturbedEquilibrium/Utils.jl +++ b/src/PerturbedEquilibrium/Utils.jl @@ -61,6 +61,7 @@ perturbed_equilibrium/ ├── forcing_b / forcing_b_root_area / forcing_b_area # control-surface forcing spectrum (b, b̃, b̄) [numpert_total], tesla ├── response_b / response_b_root_area / response_b_area # control-surface response spectrum (b, b̃, b̄) [numpert_total], tesla ├── response/ +│ ├── psi_n # Radial abscissa ψ_N [npsi] shared by every response profile below │ ├── xi_psi # Radial displacement ξ^ψ = ξ·∇ψ (ComplexF64 [npsi, mpert]) │ ├── xi_psi_J # J·ξ^ψ Jacobian-weighted (from gpeq_contra) │ ├── b_psi_area_weighted # b^ψ / ⟨J·|∇ψ|⟩_θ area-normalized (ComplexF64 [npsi, mpert]) @@ -136,6 +137,7 @@ function write_outputs_to_HDF5( # Response fields (ComplexF64 directly) response_group = haskey(pe_group, "response") ? pe_group["response"] : create_group(pe_group, "response") + !isempty(state.psi_grid) && (response_group["psi_n"] = state.psi_grid) have_xi = !isnothing(state.xi_modes) have_b = have_xi && !isnothing(state.b_modes) response_group["xi_psi"] = have_xi ? state.xi_modes.psi : ComplexF64[] From 5c823598be2c4f420354f5b3134bf82acce19913 Mon Sep 17 00:00:00 2001 From: Matthew Pharr Date: Wed, 29 Jul 2026 21:05:51 -0400 Subject: [PATCH 20/38] DOCS - MINOR - - Updated paths in the Fortran-Julia correspondence map to be relative to the repository. - Clarified descriptions in the kinetic NTV and resistive layer maps regarding Fortran source locations. - Modified benchmark scripts to accept Fortran run directories from environment variables or command line, removing hardcoded paths. - Improved comments and documentation across various files for clarity and consistency. - Removed unnecessary code and comments in the InnerLayer GGJ tests to enhance readability. --- .../fortran_correspondence_map.md | 37 ++++---- .../galerkin_assembly_map.md | 2 +- .../kinetic_ntv_map.md | 2 +- .../resistive_layer_map.md | 2 +- .claude/agents/fortran-physics-reviewer.md | 4 +- CLAUDE.md | 2 +- ...hmark_coil_ForcingTerms_against_fortran.jl | 6 +- .../benchmark_diiid_ideal_ntv_torque.jl | 12 +-- .../benchmark_diiid_kinetic_stability.jl | 13 +-- .../benchmark_solovev_kinetic_stability.jl | 12 ++- src/ForceFreeStates/ResistiveMatch.jl | 13 ++- src/InnerLayer/GGJ/GGJ.jl | 11 +-- src/InnerLayer/GGJ/GGJParameters.jl | 4 +- src/InnerLayer/GGJ/Ray.jl | 86 +++---------------- src/InnerLayer/GGJ/RayAsymptotics.jl | 4 +- src/InnerLayer/GGJ/Reference.jl | 15 ++++ src/KineticForces/PitchIntegration.jl | 2 +- src/KineticForces/Torque.jl | 2 +- test/runtests_eulerlagrange.jl | 5 +- test/runtests_innerlayer.jl | 10 +-- 20 files changed, 99 insertions(+), 145 deletions(-) diff --git a/.claude/agent-memory/fortran-physics-reviewer/fortran_correspondence_map.md b/.claude/agent-memory/fortran-physics-reviewer/fortran_correspondence_map.md index 95852271..4ba8494e 100644 --- a/.claude/agent-memory/fortran-physics-reviewer/fortran_correspondence_map.md +++ b/.claude/agent-memory/fortran-physics-reviewer/fortran_correspondence_map.md @@ -1,33 +1,36 @@ --- name: Fortran-Julia File Correspondence Map -description: Mapping between Fortran GPEC source files at ~/Code/gpec and Julia JPEC modules for physics review +description: Mapping between Fortran GPEC source files and Julia JPEC modules for physics review type: reference --- +Fortran paths are relative to the root of the Fortran GPEC clone; Julia paths are +relative to this repository. + ## Coil/ForcingTerms -- `~/Code/gpec/coil/coil.F` (coil_read) -> `src/ForcingTerms/CoilGeometry.jl` (apply_transforms) -- `~/Code/gpec/coil/field.F` (field_bs_psi) -> `src/ForcingTerms/BiotSavart.jl` + `src/ForcingTerms/CoilFourier.jl` +- `coil/coil.F` (coil_read) -> `src/ForcingTerms/CoilGeometry.jl` (apply_transforms) +- `coil/field.F` (field_bs_psi) -> `src/ForcingTerms/BiotSavart.jl` + `src/ForcingTerms/CoilFourier.jl` ## PerturbedEquilibrium -- `~/Code/gpec/gpec/gpeq.f` (gpeq_sol, gpeq_contra, gpeq_surface, gpeq_normal) -> `src/PerturbedEquilibrium/FieldReconstruction.jl` + `src/PerturbedEquilibrium/ResponseMatrices.jl` -- `~/Code/gpec/gpec/gpresp.f` (gpresp_pinduct, gpresp_sinduct, gpresp_permeab) -> `src/PerturbedEquilibrium/ResponseMatrices.jl` -- `~/Code/gpec/gpec/gpout.f` (gpout_singcoup, gpout_xbnormal) -> `src/PerturbedEquilibrium/SingularCoupling.jl` + `src/PerturbedEquilibrium/FieldReconstruction.jl` -- `~/Code/gpec/gpec/gpvacuum.f` (gpvacuum_flxsurf) -> `src/PerturbedEquilibrium/SingularCoupling.jl` (compute_surface_inductance_from_greens) +- `gpec/gpeq.f` (gpeq_sol, gpeq_contra, gpeq_surface, gpeq_normal) -> `src/PerturbedEquilibrium/FieldReconstruction.jl` + `src/PerturbedEquilibrium/ResponseMatrices.jl` +- `gpec/gpresp.f` (gpresp_pinduct, gpresp_sinduct, gpresp_permeab) -> `src/PerturbedEquilibrium/ResponseMatrices.jl` +- `gpec/gpout.f` (gpout_singcoup, gpout_xbnormal) -> `src/PerturbedEquilibrium/SingularCoupling.jl` + `src/PerturbedEquilibrium/FieldReconstruction.jl` +- `gpec/gpvacuum.f` (gpvacuum_flxsurf) -> `src/PerturbedEquilibrium/SingularCoupling.jl` (compute_surface_inductance_from_greens) ## KineticForces (NTV, Fortran `pentrc/`) — see kinetic_ntv_map.md for the audit checklist -- `~/Code/gpec/pentrc/torque.F90` -> `src/KineticForces/Torque.jl` (tpsi! single-surface torque) -- `~/Code/gpec/pentrc/pitch.f90` -> `src/KineticForces/PitchIntegration.jl` (pitch-angle lambda integration, bounce-averaged operator assembly) -- `~/Code/gpec/pentrc/energy.f90` -> `src/KineticForces/EnergyIntegration.jl` (energy-space quadrature integrand) -- `~/Code/gpec/pentrc/pentrc.F90` (orchestration) -> `src/KineticForces/{KineticForces.jl,Compute.jl}` -- `~/Code/gpec/pentrc/inputs.f90` + `params.f90` -> `src/KineticForces/KineticForcesStructs.jl` -- `~/Code/gpec/pentrc/dcon_interface.f` -> `src/KineticForces/CalculatedKineticMatrices.jl` (bridge to ForceFreeStates kinetic stability) + `src/ForceFreeStates/{Kinetic.jl,FixedKineticMatrices.jl}` +- `pentrc/torque.F90` -> `src/KineticForces/Torque.jl` (tpsi! single-surface torque) +- `pentrc/pitch.f90` -> `src/KineticForces/PitchIntegration.jl` (pitch-angle lambda integration, bounce-averaged operator assembly) +- `pentrc/energy.f90` -> `src/KineticForces/EnergyIntegration.jl` (energy-space quadrature integrand) +- `pentrc/pentrc.F90` (orchestration) -> `src/KineticForces/{KineticForces.jl,Compute.jl}` +- `pentrc/inputs.f90` + `params.f90` -> `src/KineticForces/KineticForcesStructs.jl` +- `pentrc/dcon_interface.f` -> `src/KineticForces/CalculatedKineticMatrices.jl` (bridge to ForceFreeStates kinetic stability) + `src/ForceFreeStates/{Kinetic.jl,FixedKineticMatrices.jl}` - bounce averaging of the 6 perturbed-action matrices -> `src/KineticForces/BounceAveraging.jl` ## InnerLayer (resistive matched-asymptotics, Fortran `rmatch/`) — see resistive_layer_map.md for the audit checklist -- `~/Code/gpec/rmatch/deltac.f` (Galerkin solver) -> `src/InnerLayer/GGJ/Galerkin.jl` -- `~/Code/gpec/rmatch/deltar.f` (shooting solver) -> `src/InnerLayer/GGJ/Shooting.jl` -- `~/Code/gpec/rmatch/inps.f` + `inpso.f` (Wasow asymptotic basis: T,J,P,B,Q,C,D,Y,Z,U matrices) -> `src/InnerLayer/GGJ/InnerAsymptotics.jl` -- `~/Code/gpec/rmatch/{inner.f,match.f,msing.f,gamma.f}` -> `src/InnerLayer/GGJ/` (parameters, matching data, special functions) +- `rmatch/deltac.f` (Galerkin solver) -> `src/InnerLayer/GGJ/Galerkin.jl` +- `rmatch/deltar.f` (shooting solver) -> `src/InnerLayer/GGJ/Shooting.jl` +- `rmatch/inps.f` + `inpso.f` (Wasow asymptotic basis: T,J,P,B,Q,C,D,Y,Z,U matrices) -> `src/InnerLayer/GGJ/InnerAsymptotics.jl` +- `rmatch/{inner.f,match.f,msing.f,gamma.f}` -> `src/InnerLayer/GGJ/` (parameters, matching data, special functions) - SLAYER drift-MHD two-fluid solver: `src/InnerLayer/SLAYER/` is a placeholder pending implementation ## Key Fortran Conventions diff --git a/.claude/agent-memory/fortran-physics-reviewer/galerkin_assembly_map.md b/.claude/agent-memory/fortran-physics-reviewer/galerkin_assembly_map.md index 3dd7bec8..a24db8f4 100644 --- a/.claude/agent-memory/fortran-physics-reviewer/galerkin_assembly_map.md +++ b/.claude/agent-memory/fortran-physics-reviewer/galerkin_assembly_map.md @@ -49,7 +49,7 @@ lives only in match_delta (Newton eigenvalue search), NOT the RPEC path. Two iss Confirm intended convention vs Fortran before trusting resistive Δ(Q); ideal_flag path (γ unused) is unaffected. RESOLUTION 2026-06-20: author confirms the Hz convention is DELIBERATE (gal_rotation is f[Hz], so γ=2πi·n·f); the overstated comments were corrected. Δ(Q) now guarded by test/runtests_innerlayer.jl (Julia self-pin on -glasser_wang_2020_eq55). Remaining follow-up (mpharr): upgrade that guard to a Fortran deltac_run / paper +glasser_wang_2020_eq55). Remaining follow-up: upgrade that guard to a Fortran deltac_run / paper cross-check to fully close the convention question. ## Verdict: PASS WITH REQUIRED ANNOTATIONS for outer solve; RPEC γ convention is SHOULD-FIX (verify vs match.f). diff --git a/.claude/agent-memory/fortran-physics-reviewer/kinetic_ntv_map.md b/.claude/agent-memory/fortran-physics-reviewer/kinetic_ntv_map.md index 414be7a9..5f5dfb56 100644 --- a/.claude/agent-memory/fortran-physics-reviewer/kinetic_ntv_map.md +++ b/.claude/agent-memory/fortran-physics-reviewer/kinetic_ntv_map.md @@ -6,7 +6,7 @@ type: reference The KineticForces module computes neoclassical toroidal viscosity (NTV) torque and the kinetic energy/matrices from trapped-particle nonambipolar transport. Fortran counterpart is -`~/Code/gpec/pentrc/` (file mapping in fortran_correspondence_map.md). +the Fortran `pentrc/` sources (file mapping in fortran_correspondence_map.md). ## Governing theory - Logan & Park (2013) PoP 20, 122507: diamagnetic frequency (Eq. 7), energy integrand (Eq. 8), torque normalization Im(T) = 2n·δW_k (Eq. 19). diff --git a/.claude/agent-memory/fortran-physics-reviewer/resistive_layer_map.md b/.claude/agent-memory/fortran-physics-reviewer/resistive_layer_map.md index 1a794e7f..57e3b1d7 100644 --- a/.claude/agent-memory/fortran-physics-reviewer/resistive_layer_map.md +++ b/.claude/agent-memory/fortran-physics-reviewer/resistive_layer_map.md @@ -7,7 +7,7 @@ type: reference The InnerLayer module performs matched-asymptotic analysis of resistive MHD stability at rational surfaces — solving the resistive inner-layer equations and computing the tearing stability parameter Δ via parity-projected splitting (Δ_odd, Δ_even). Fortran counterpart is -`~/Code/gpec/rmatch/` (file mapping in fortran_correspondence_map.md). +the Fortran `rmatch/` sources (file mapping in fortran_correspondence_map.md). ## Governing theory - Glasser (2016) PoP 23, 072505: computation of resistive instabilities by matched asymptotic expansions; shooting method, Frobenius exponent splitting. diff --git a/.claude/agents/fortran-physics-reviewer.md b/.claude/agents/fortran-physics-reviewer.md index 986f8eb3..8def0bc2 100644 --- a/.claude/agents/fortran-physics-reviewer.md +++ b/.claude/agents/fortran-physics-reviewer.md @@ -35,7 +35,7 @@ You operate under a hard budget to protect the user's token quota: - **KineticForces** (NTV, formerly PENTRC): Logan & Park (2013) PoP 20, 122507; Logan (2015) PhD Thesis (Ch. 7 Eqs 7.30–7.35, App. C/D); Park et al. (2009) PRL 102, 065002 - **InnerLayer** (resistive matched-asymptotics, GGJ/SLAYER): Glasser (2016) PoP 23, 072505; Glasser (2018) PoP 25, 032501; Glasser (2020) "Asymptotic solutions and convergence studies of the resistive inner region equations"; Wang et al. (2020) PoP 27, 122509 - **Analysis**: visualization/post-processing only — no physics kernels; verify it reads the correct HDF5 groups and applies correct conventions rather than auditing equations. -- Locate the corresponding Fortran GPEC source at `~/Code/gpec` (ask the user for the path if not found there). KineticForces ↔ `~/Code/gpec/pentrc/`, InnerLayer ↔ `~/Code/gpec/rmatch/`. Read the relevant Fortran routines carefully. Your project memory holds a maintained correspondence map and per-domain audit checklists — consult it first. +- Locate the corresponding Fortran GPEC source in the user's local Fortran GPEC clone (ask the user for its path). KineticForces ↔ `pentrc/`, InnerLayer ↔ `rmatch/`. Read the relevant Fortran routines carefully. Your project memory holds a maintained correspondence map and per-domain audit checklists — consult it first. ### Step 2: Read the Fortran implementation - Open and carefully read the relevant Fortran source files. @@ -132,7 +132,7 @@ Examples of what to record: # Persistent Agent Memory -You have a persistent, file-based memory system at `~/.claude/agent-memory/fortran-physics-reviewer/` (shared across all clones of this repo on this machine). When reading or writing files, expand `~` to the absolute home directory path (e.g. use `/Users/yourname/.claude/agent-memory/fortran-physics-reviewer/`). Create the directory if it does not exist before writing. +You have a persistent, file-based memory system in this repository at `.claude/agent-memory/fortran-physics-reviewer/` (versioned with the repo, so findings are shared with everyone working on it). Resolve it relative to the repository root, and create the directory if it does not exist before writing. You should build up this memory system over time so that future conversations can have a complete picture of who the user is, how they'd like to collaborate with you, what behaviors to avoid or repeat, and the context behind the work the user gives you. diff --git a/CLAUDE.md b/CLAUDE.md index da32ed04..2ae24726 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ GPEC (Generalized Perturbed Equilibrium Code, Julia implementation) is a compreh **Relationship to Fortran GPEC**: This Julia GPEC is an evolution of the Fortran GPEC code suite, available at https://github.com/PrincetonUniversity/GPEC. When users reference "the Fortran code", "the original GPEC", or "Fortran GPEC", they are referring to that Fortran codebase. This Julia implementation reimplements and extends GPEC's functionality with improved performance and maintainability. -**Local GPEC Repository**: For code conversion or comparison with the original Fortran implementation, check for a local GPEC repository at `~/Code/gpec`. If not found at this location, ask the user for the correct path. +**Local GPEC Repository**: For code conversion or comparison with the original Fortran implementation, a local clone of the Fortran GPEC repository is needed; ask the user for its path. This codebase is implemented in Julia. References to “Fortran GPEC” or “legacy VACUUM” refer to the original upstream Fortran codebase, not a runtime dependency of this Julia implementation. diff --git a/benchmarks/benchmark_coil_ForcingTerms_against_fortran.jl b/benchmarks/benchmark_coil_ForcingTerms_against_fortran.jl index d50fe72f..22e2b193 100644 --- a/benchmarks/benchmark_coil_ForcingTerms_against_fortran.jl +++ b/benchmarks/benchmark_coil_ForcingTerms_against_fortran.jl @@ -13,7 +13,8 @@ runs the Julia Biot-Savart pipeline, and produces a 4-panel diagnostic figure: Usage: julia --project=. benchmarks/check_coil_pipeline.jl [/path/to/fortran/run] -Default run directory: ~/Code/gpec/docs/examples/DIIID_ideal_example +The Fortran run directory (a DIII-D ideal example run) is taken from the command +line, or from the GPEC_FORTRAN_DIR environment variable if no argument is given. """ using GeneralizedPerturbedEquilibrium @@ -149,7 +150,8 @@ end # Main # --------------------------------------------------------------------------- -run_dir = length(ARGS) > 0 ? ARGS[1] : expanduser("~/Code/gpec/docs/examples/DIIID_ideal_example") +run_dir = length(ARGS) > 0 ? ARGS[1] : get(ENV, "GPEC_FORTRAN_DIR", "") +isempty(run_dir) && error("No Fortran run directory given: pass it as the first argument or set GPEC_FORTRAN_DIR") isdir(run_dir) || error("Run directory not found: $run_dir") mkpath(OUTPUT_DIR) diff --git a/benchmarks/benchmark_diiid_ideal_ntv_torque.jl b/benchmarks/benchmark_diiid_ideal_ntv_torque.jl index 32aa4c51..750e70a8 100644 --- a/benchmarks/benchmark_diiid_ideal_ntv_torque.jl +++ b/benchmarks/benchmark_diiid_ideal_ntv_torque.jl @@ -20,8 +20,8 @@ the Fortran example directory. Usage: julia --project=. benchmarks/benchmark_diiid_ideal_ntv_torque.jl [fortran_dir] - fortran_dir defaults to \$GPEC_FORTRAN_DIIID or - ~/Code/gpec/docs/examples/DIIID_ideal_example. + fortran_dir is a Fortran GPEC DIII-D ideal example run directory, taken from + the command line or from \$GPEC_FORTRAN_DIIID. One of the two is required. """ using Printf @@ -36,7 +36,9 @@ const KF = GPE.KineticForces const Eq = GPE.Equilibrium const PE = GPE.PerturbedEquilibrium -const DEFAULT_FORTRAN_DIR = expanduser("~/Code/gpec/docs/examples/DIIID_ideal_example") +"Fortran GPEC DIII-D ideal example run directory, from the environment (no on-disk default)." +default_fortran_dir() = get(() -> error("Set GPEC_FORTRAN_DIIID, or pass the Fortran run directory as the first argument"), + ENV, "GPEC_FORTRAN_DIIID") """ discover_inputs(fortran_dir) → (; eq_file, kin_file, xclebsch, pentrc_nc) @@ -219,7 +221,7 @@ end _p(args...) = (println(stderr, args...); flush(stderr)) _pf(fmt, args...) = (print(stderr, Printf.format(Printf.Format(fmt), args...)); flush(stderr)) -function run_benchmark(fortran_dir::String=DEFAULT_FORTRAN_DIR) +function run_benchmark(fortran_dir::String=default_fortran_dir()) _p("=" ^ 70) _p(" DIIID Kinetic Benchmark: Julia KineticForces vs Fortran PENTRC") _p(" Fortran example: $fortran_dir") @@ -481,6 +483,6 @@ end # Run only when invoked as a script (so other benchmarks can `include()` this # file to reuse `load_fortran_xclebsch` without triggering the full run). if abspath(PROGRAM_FILE) == @__FILE__ - fortran_dir = length(ARGS) >= 1 ? ARGS[1] : get(ENV, "GPEC_FORTRAN_DIIID", DEFAULT_FORTRAN_DIR) + fortran_dir = length(ARGS) >= 1 ? ARGS[1] : default_fortran_dir() results = run_benchmark(fortran_dir) end diff --git a/benchmarks/benchmark_diiid_kinetic_stability.jl b/benchmarks/benchmark_diiid_kinetic_stability.jl index 0b48d0d2..1cf32b36 100644 --- a/benchmarks/benchmark_diiid_kinetic_stability.jl +++ b/benchmarks/benchmark_diiid_kinetic_stability.jl @@ -24,8 +24,8 @@ the non-Hermitian FKG reduction and the inner kinetic-matrix tolerances. Usage: julia --project=. benchmarks/benchmark_diiid_kinetic_stability.jl [fortran_dir] - fortran_dir defaults to \$GPEC_FORTRAN_DIIID_DCON or - ~/Code/gpec/docs/examples/DIIID_kinetic_example. + fortran_dir is a Fortran GPEC DIII-D kinetic example run directory, taken from + the command line or from \$GPEC_FORTRAN_DIIID_DCON. One of the two is required. """ using Printf @@ -36,7 +36,9 @@ using GeneralizedPerturbedEquilibrium const GPE = GeneralizedPerturbedEquilibrium const AnalysisFFS = GPE.Analysis.ForceFreeStates -const DEFAULT_FORTRAN_DIR = expanduser("~/Code/gpec/docs/examples/DIIID_kinetic_example") +"Fortran GPEC DIII-D kinetic example run directory, from the environment (no on-disk default)." +default_fortran_dir() = get(() -> error("Set GPEC_FORTRAN_DIIID_DCON, or pass the Fortran run directory as the first argument"), + ENV, "GPEC_FORTRAN_DIIID_DCON") """ discover_inputs(fortran_dir) → (; eq_file, kin_file, dcon_nc) @@ -176,7 +178,7 @@ end _p(args...) = (println(stderr, args...); flush(stderr)) _pf(fmt, args...) = (print(stderr, Printf.format(Printf.Format(fmt), args...)); flush(stderr)) -function run_benchmark(fortran_dir::String=DEFAULT_FORTRAN_DIR) +function run_benchmark(fortran_dir::String=default_fortran_dir()) _p("=" ^ 70) _p(" DIIID Kinetic-DCON Benchmark: Julia KF→FFS vs Fortran DCON") _p(" Fortran example: $fortran_dir") @@ -255,7 +257,6 @@ end # Run only when invoked as a script. if abspath(PROGRAM_FILE) == @__FILE__ - fortran_dir = length(ARGS) >= 1 ? ARGS[1] : - get(ENV, "GPEC_FORTRAN_DIIID_DCON", DEFAULT_FORTRAN_DIR) + fortran_dir = length(ARGS) >= 1 ? ARGS[1] : default_fortran_dir() run_benchmark(fortran_dir) end diff --git a/benchmarks/benchmark_solovev_kinetic_stability.jl b/benchmarks/benchmark_solovev_kinetic_stability.jl index ae98fe02..6926834b 100644 --- a/benchmarks/benchmark_solovev_kinetic_stability.jl +++ b/benchmarks/benchmark_solovev_kinetic_stability.jl @@ -31,10 +31,9 @@ the pre-rewrite (`ximag`) value of 34.176 was not. Usage: julia --project=. benchmarks/benchmark_solovev_kinetic_stability.jl -Environment: - GPEC_FORTRAN_DCON path to Fortran `dcon` (default ~/Code/gpec/bin/dcon) +Environment (both required): + GPEC_FORTRAN_DCON path to the Fortran `dcon` executable GPEC_FORTRAN_SOLDIR Fortran solovev_kinetic_example dir used as deck template - (default ~/Code/gpec/docs/examples/solovev_kinetic_example) """ using Printf @@ -46,9 +45,8 @@ const GPE = GeneralizedPerturbedEquilibrium const REPO = normpath(joinpath(@__DIR__, "..")) const JULIA_FIXTURE = joinpath(REPO, "test", "test_data", "regression_solovev_kinetic_calculated") -const FORTRAN_DCON = get(ENV, "GPEC_FORTRAN_DCON", expanduser("~/Code/gpec/bin/dcon")) -const FORTRAN_SOLDIR = get(ENV, "GPEC_FORTRAN_SOLDIR", - expanduser("~/Code/gpec/docs/examples/solovev_kinetic_example")) +const FORTRAN_DCON = get(ENV, "GPEC_FORTRAN_DCON", "") +const FORTRAN_SOLDIR = get(ENV, "GPEC_FORTRAN_SOLDIR", "") _p(args...) = (println(stderr, args...); flush(stderr)) @@ -86,7 +84,7 @@ fields that must match the Julia regression fixture (read from its `gpec.toml` and `sol.toml`). Converts the fixture's `kinetic.dat` into Fortran `kinetic.txt`. """ function build_matched_fortran_deck(workdir::String) - isdir(FORTRAN_SOLDIR) || error("Fortran deck template not found: $FORTRAN_SOLDIR") + isdir(FORTRAN_SOLDIR) || error("Fortran deck template not found: '$FORTRAN_SOLDIR' (set GPEC_FORTRAN_SOLDIR)") for f in ("sol.in", "equil.in", "dcon.in", "pentrc.in", "vac.in") cp(joinpath(FORTRAN_SOLDIR, f), joinpath(workdir, f); force=true) end diff --git a/src/ForceFreeStates/ResistiveMatch.jl b/src/ForceFreeStates/ResistiveMatch.jl index 1247c914..d42d224c 100644 --- a/src/ForceFreeStates/ResistiveMatch.jl +++ b/src/ForceFreeStates/ResistiveMatch.jl @@ -15,13 +15,12 @@ # Δ_coil = the push from the external coil (this is what drives everything) # C = the matched coefficients we solve for # -# This is the same calculation the previous Fortran code does (RDCON's gal_match_rpec in match.f). Here, I'm -# just doing it in Julia on the STRIDE path. +# This is the same calculation the Fortran code performs in RDCON's gal_match_rpec (match.f), done here in +# Julia on the STRIDE path. # -# Δ_out and Δ_coil have to be in the SAME units before you can subtract them, so I use STRIDE's RAW coefficients -# for both (no extra scaling). There is a factor called snorm (= |n·q'|^α) that appears elsewhere for -# comparing to the Galerkin code, but that's only a unit conversion for plotting; -# it's not part of the real physics, so I leave it out here. +# Δ_out and Δ_coil have to be in the SAME units before they can be subtracted, so both use STRIDE's RAW +# coefficients (no extra scaling). A factor called snorm (= |n·q'|^α) appears elsewhere when comparing to the +# Galerkin code, but that is only a unit conversion for plotting, not part of the physics, so it is omitted here. "What comes out of the match (Wang 2020 Eq. 11)." struct ResonantMatchResult @@ -103,7 +102,7 @@ function resonant_match_rpec(delta_out_raw::AbstractMatrix, delta_coil_raw::Abst cout = cof[1:2msing, :] cin = cof[2msing+1:4msing, :] # The reconnected flux is the coil drive plus the plasma's own outer response to the matched cout. - # (A reviewer pointed out this also equals -Δ_in·cin, i.e. the inner-layer side agrees, good sanity check.) + # It also equals -Δ_in·cin, so the inner-layer side agrees — a useful sanity check. reconnected_flux = delta_coil_raw .+ transpose(delta_out_raw) * cout return ResonantMatchResult(cout, cin, deltar, rpec_eig, reconnected_flux, residual) end diff --git a/src/InnerLayer/GGJ/GGJ.jl b/src/InnerLayer/GGJ/GGJ.jl index 289d3c38..5e95cf83 100644 --- a/src/InnerLayer/GGJ/GGJ.jl +++ b/src/InnerLayer/GGJ/GGJ.jl @@ -11,9 +11,7 @@ # - `:ray` – rotated-contour spectral-element collocation (Ray.jl); # robust to |Q| ~ 500 on/near the imaginary axis # -# All solvers share the same `inps` Wasow asymptotic-basis kernel -# (`InnerAsymptotics.jl`, complex-x extension in `RayAsymptotics.jl`) for the -# large-x boundary condition. They return the parity-projected matching data +# They return the parity-projected matching data # `(Δ_odd, Δ_even)` of GWP2016 Eqs. (34)–(35) in the same (deltac) convention. # # Equation references throughout this module use two source papers: @@ -52,11 +50,8 @@ solver (robust at large |Q| on/near the imaginary axis; agrees with `:galerkin` within its error bar at moderate Q), `:galerkin` for the Hermite-cubic finite element solver (real-axis method; degrades for |Q| ≳ 1), and `:shooting` for the backward stable-shoot solver (|Q| ≪ 1 -only). All implementations consume the same `inps` asymptotic-basis kernel -and return the parity-projected matching data in the same convention. -Note the backends take different numerical-knob keywords: pass -`GGJModel(solver=:galerkin)` explicitly when using `nx`/`xfac`-style -Galerkin knobs. +only). +Note the backends take different numerical-knob keywords. """ struct GGJModel{S} <: InnerLayerModel end diff --git a/src/InnerLayer/GGJ/GGJParameters.jl b/src/InnerLayer/GGJ/GGJParameters.jl index 8ab243e8..208e663e 100644 --- a/src/InnerLayer/GGJ/GGJParameters.jl +++ b/src/InnerLayer/GGJ/GGJParameters.jl @@ -113,9 +113,7 @@ inner_Q(p::GGJParameters, γ::Number) = ComplexF64(γ) / q0(p) Map the scaled inner-layer matching data back to physical Δ at the rational surface by the `X₀^(−2√(−D_I)) = S^(2√(−D_I)/3)` rescaling, together with the -`v₁^(2√(−D_I))` linear-scale factor, implied by the power-like matching -(GWP2016 Sec. IV; the inner solution ~ X^{r±} converts to physical x = X₀ X, so -the large/small amplitude ratio Δ scales by X₀^{−(r₊−r₋)} = X₀^(−2√(−D_I))). +`v₁^(2√(−D_I))` linear-scale factor, (GWP2016 Sec. IV). Operates element-wise on a 2-vector of `(Δ_odd, Δ_even)`. """ function rescale_delta(Δ::AbstractVector, p::GGJParameters) diff --git a/src/InnerLayer/GGJ/Ray.jl b/src/InnerLayer/GGJ/Ray.jl index 174aebf8..b60a481f 100644 --- a/src/InnerLayer/GGJ/Ray.jl +++ b/src/InnerLayer/GGJ/Ray.jl @@ -5,10 +5,9 @@ # the imaginary axis, where both `:shooting` (exponential dichotomy) and # `:galerkin` (real-axis oscillation + on-axis pseudo-resonance) degrade. # -# Ported 2026-07-08 from the standalone GGJRay package (~/Projects/GGJ_test), -# validated there against the Fortran rmatch pins, the `:galerkin` backend at -# moderate Q, and physical benchmarks to Q = 500i. Complete methods paper: -# GGJ_test/docs/METHOD.md. In one paragraph: +# Validated against the Fortran rmatch pins, the `:galerkin` backend at +# moderate Q, and physical benchmarks to Q = 500i. Full method write-up: +# docs/src/inner_layer.md, "Numerical method". In one paragraph: # # The equations are continued analytically onto the ray x = e^{iθ}s with # θ = arg(Q)/4 (parabolic-cylinder exponent exactly real; pseudo-resonance @@ -24,29 +23,15 @@ # arithmetic runs in Complex{Double64}: at large S the near-parallel # power-pair geometry amplifies the structured backward error of the # ill-conditioned implicit solves (Σ eps·z ≈ eps·ρS²/2) into Δ-mixing -# ~1e-4 at |Q| = 500 in Float64 (METHOD.md §8). +# ~1e-4 at |Q| = 500 in Float64 (docs/src/inner_layer.md, "Far-field +# boundary and the inward march"). # -# Sections below mirror the GGJRay source layout: +# File layout: # 1. system — plain-variable first-order ODE matrix, parity sets # 2. mesh — Chebyshev cells, barycentric interpolation, WKB grading # 3. boundary — decaying pair, Radau IIA quotient march # 4. solve — assembly, bordered solve, refinement, driver, diagnostics -""" - q4_surface_benchmark() -> GGJParameters - -Physical q = 4 rational-surface benchmark point (S = τ_R/τ_A ≈ 4.58×10⁶, -D_I ≈ −0.31166, α = √(−D_I) ≈ 0.5583). Primary validation point for the -rotated-ray backend on the imaginary-Q axis (pinned at Q = 100i, 500i in -the test suite). -""" -function q4_surface_benchmark() - return GGJParameters(; - E=-0.13733, F=0.022202, G=7.60633, H=0.053468, K=14.66987, - M=30.26883, taua=2.11226e-7, taur=0.968219, v1=1.55009 - ) -end - # system.jl # # The GGJ inner-region equations (GWP2016 Eq. 11 ≡ GW2020 Eq. 1) as a plain @@ -371,8 +356,6 @@ function march_boundary(params::GGJParameters, Q::ComplexF64, θ::Float64, rtol::Float64=1e-9, growth::Float64=30.0, seed::Int=20260707, ztrk::Float64=0.3, purge_budget::Float64=2.5, dp_res::Float64=20.0) ph = cis(θ) - sq = sqrt(Q) - ρ = real(cis(2θ) / sq) 𝔐(s) = ph * ode_matrix(params, Q, ph * s) W = hcat(copy(Us), copy(Ub)) @@ -401,7 +384,7 @@ function march_boundary(params::GGJParameters, Q::ComplexF64, θ::Float64, # The near-parallel power-pair geometry at large S turns that into # u_small/u_big coefficient mixing β ~ eps·ρS²/2 · (‖u_big‖/‖u_small‖)(S) # ~ 1e-4 at |Q| = 500 — saturating Δ of the near-pole parity at ~1/β and - # flooring the other at |Δ|·β (the ε_mix defect; docs/BUGHUNT_epsmix.md). + # flooring the other at |Δ|·β — the Δ-mixing defect extended precision removes. # Double64 arithmetic (eps ~ 5e-33) removes it outright; the resolved # band stays Float64 (z ≤ ztrk there, solves well-conditioned). CTd = Complex{Double64} @@ -665,7 +648,7 @@ function _assemble_base(params::GGJParameters, Q::ComplexF64, θ::Float64, end """ - _solve_parities(Ic, Jc, Vc, rhs, ndof, Ng, N, p) -> (sol_odd, sol_even) + _solve_parities(Ic, Jc, Vc, rhs, ndof, N, p) -> (sol_odd, sol_even) Solve BOTH parity systems from one factorization. The two matrices differ only in the 3 parity rows at s = 0 (odd: unit entries at components (4,2,3); @@ -675,7 +658,7 @@ Woodbury correction (3 extra triangular solves + a 3×3 solve) replaces the second factorization, which dominates the linear-algebra cost. """ function _solve_parities(Ic::Vector{Int}, Jc::Vector{Int}, Vc::Vector{ComplexF64}, - rhs::Vector{ComplexF64}, ndof::Int, Ng::Int, N::Int, p::Int) + rhs::Vector{ComplexF64}, ndof::Int, N::Int, p::Int) rowp0 = 6 * N * p + 6 c1 = parity_rows(1) c2 = parity_rows(2) @@ -689,8 +672,7 @@ function _solve_parities(Ic::Vector{Int}, Jc::Vector{Int}, Vc::Vector{ComplexF64 end A = sparse(I2, J2, V2, ndof, ndof) - # Row equilibration (as in _solve_parity; the parity rows have unit - # entries in both variants, so one scaling serves both systems). + # Row equilibration. rmax = zeros(Float64, ndof) rows = rowvals(A) vals = nonzeros(A) @@ -716,11 +698,11 @@ function _solve_parities(Ic::Vector{Int}, Jc::Vector{Int}, Vc::Vector{ComplexF64 end AinvU = F \ U Vx1 = ComplexF64[x1[c2[k]] - x1[c1[k]] for k in 1:3] - S = Matrix{ComplexF64}(I, 3, 3) + Smat = Matrix{ComplexF64}(I, 3, 3) for k in 1:3, j in 1:3 - S[k, j] += AinvU[c2[k], j] - AinvU[c1[k], j] + Smat[k, j] += AinvU[c2[k], j] - AinvU[c1[k], j] end - x2 = x1 - AinvU * (S \ Vx1) + x2 = x1 - AinvU * (Smat \ Vx1) # UMFPACK numeric factorizations live in C malloc, invisible to the GC's # heap accounting — under the refinement loop, lazily-finalized multi-GB # factorizations pile up (observed: tens of GB on 24k-cell meshes). Free @@ -729,44 +711,6 @@ function _solve_parities(Ic::Vector{Int}, Jc::Vector{Int}, Vc::Vector{ComplexF64 return x1, x2 end -function _solve_parity(Ic::Vector{Int}, Jc::Vector{Int}, Vc::Vector{ComplexF64}, - rhs::Vector{ComplexF64}, ndof::Int, Ng::Int, N::Int, p::Int, isol::Int; - leftcomps::AbstractVector{Int}=parity_rows(isol), - leftvals::AbstractVector{ComplexF64}=zeros(ComplexF64, 3)) - rowp0 = 6 * N * p + 6 - I2 = copy(Ic) - J2 = copy(Jc) - V2 = copy(Vc) - rhs = copy(rhs) - for k in eachindex(leftcomps) - push!(I2, rowp0 + k) - push!(J2, leftcomps[k]) # node 1 → columns 1..6 - push!(V2, one(ComplexF64)) - rhs[rowp0+k] = leftvals[k] - end - A = sparse(I2, J2, V2, ndof, ndof) - - # Row equilibration. - rmax = zeros(Float64, ndof) - rows = rowvals(A) # CSC: iterate columns - vals = nonzeros(A) - for col in 1:ndof - for idx in nzrange(A, col) - r = rows[idx] - a = abs(vals[idx]) - a > rmax[r] && (rmax[r] = a) - end - end - @inbounds for i in 1:ndof - rmax[i] = rmax[i] > 0 ? 1 / rmax[i] : 1.0 - end - Dr = Diagonal(rmax) - F = lu(Dr * A) - x = F \ (Dr * rhs) - finalize(F) # eager UMFPACK free — see _solve_parities - return x -end - # ----------------------------------------------------------------------- # Residual estimation for adaptive refinement. # ----------------------------------------------------------------------- @@ -938,7 +882,7 @@ function solve_ray(params::GGJParameters, Q::ComplexF64; Ic, Jc, Vc, rhs, βb, ndof, Ng = _assemble_base(params, Q, θ, breaks, p, t, D, Us, Ub, E) sols = Matrix{ComplexF64}(undef, ndof, 2) errs = zeros(Float64, N) - sol_odd, sol_even = _solve_parities(Ic, Jc, Vc, rhs, ndof, Ng, N, p) + sol_odd, sol_even = _solve_parities(Ic, Jc, Vc, rhs, ndof, N, p) for (isol, sol) in ((1, sol_odd), (2, sol_even)) sols[:, isol] = sol Δraw[isol] = sol[6*Ng+1] / βb @@ -1179,5 +1123,3 @@ end delta_convergence(params::GGJParameters, Q::Number; kwargs...) = delta_convergence(params, ComplexF64(Q); kwargs...) -export solve_inner_ray, evaluate_solution, delta_convergence, - solution_profile, asymptotic_profile diff --git a/src/InnerLayer/GGJ/RayAsymptotics.jl b/src/InnerLayer/GGJ/RayAsymptotics.jl index 06deeaf1..f20c9a3b 100644 --- a/src/InnerLayer/GGJ/RayAsymptotics.jl +++ b/src/InnerLayer/GGJ/RayAsymptotics.jl @@ -11,8 +11,8 @@ # data, the GW2020 Eq. (54) residual along the ray, and the series-radius # selector `pick_smax` (the ray analog of `pick_xmax`). # -# Ported from GGJRay (~/Projects/GGJ_test, docs/METHOD.md §6); the real-x -# methods of InnerAsymptotics.jl are untouched. +# "Far-field boundary and the inward +# march"; the real-x methods of InnerAsymptotics.jl are untouched. # Complex-argument Horner evaluator: same contract as _horner(x::Real, ...) # in InnerAsymptotics.jl, with x^rvec on the principal branch. diff --git a/src/InnerLayer/GGJ/Reference.jl b/src/InnerLayer/GGJ/Reference.jl index 6127cbfd..aedb4add 100644 --- a/src/InnerLayer/GGJ/Reference.jl +++ b/src/InnerLayer/GGJ/Reference.jl @@ -27,3 +27,18 @@ function glasser_wang_2020_eq55(; taua::Float64=1.0, taur::Float64=1e6, v1::Floa taua=taua, taur=taur, v1=v1 ) end + +""" + q4_surface_benchmark() -> GGJParameters + +Physical q = 4 rational-surface benchmark point (S = τ_R/τ_A ≈ 4.58×10⁶, +D_I ≈ −0.31166, α = √(−D_I) ≈ 0.5583). Primary validation point for the +rotated-ray backend on the imaginary-Q axis (pinned at Q = 100i, 500i in +the test suite). +""" +function q4_surface_benchmark() + return GGJParameters(; + E=-0.13733, F=0.022202, G=7.60633, H=0.053468, K=14.66987, + M=30.26883, taua=2.11226e-7, taur=0.968219, v1=1.55009 + ) +end diff --git a/src/KineticForces/PitchIntegration.jl b/src/KineticForces/PitchIntegration.jl index fa61c595..845cfabc 100644 --- a/src/KineticForces/PitchIntegration.jl +++ b/src/KineticForces/PitchIntegration.jl @@ -166,7 +166,7 @@ in a single pitch integration, sharing one energy integration per (λ, E). Returns a length-`2*nqty` packed buffer: `[wmm | tmm]`. The two halves each reproduce Fortran's independent-pass result at Fortran's element-by-element -convention (verified via matrix-dump comparison vs `~/Code/gpec/dcon/fourfit.F` +convention (verified via matrix-dump comparison vs Fortran `dcon/fourfit.F` `kwmat_l`/`ktmat_l`). Downstream `kwmat ± ktmat` combinations in `ForceFreeStates/Kinetic.jl` then reproduce `sing.f:967-1075` exactly for the non-Hermitian B_k, C_k, E_k diagonals. diff --git a/src/KineticForces/Torque.jl b/src/KineticForces/Torque.jl index 240358b1..38297c11 100644 --- a/src/KineticForces/Torque.jl +++ b/src/KineticForces/Torque.jl @@ -877,7 +877,7 @@ independent integration passes at `torque.F90:842-847`. Per-surface matrix dumps confirm element-by-element agreement with Fortran `fourfit.F:1080-1082` (`kwmat_l`, `ktmat_l`). This is the Fortran convention required by the adjoint combinations `kwmat ± ktmat` in `ForceFreeStates/Kinetic.jl` / -`~/Code/gpec/dcon/sing.f:967-1075` for non-Hermitian B_k, C_k, E_k. +Fortran `dcon/sing.f:967-1075` for non-Hermitian B_k, C_k, E_k. # Arguments - `kwmat::Array{ComplexF64,3}`: Output (mpert×mpert×6), fwmm half, zeroed on entry diff --git a/test/runtests_eulerlagrange.jl b/test/runtests_eulerlagrange.jl index 6f3ebc54..beb11049 100644 --- a/test/runtests_eulerlagrange.jl +++ b/test/runtests_eulerlagrange.jl @@ -1,6 +1,5 @@ -# TODO: perhaps this isn't the best place for this function? -# Should I do include("../ForceFreeStates/utils.jl") instead? or maybe save these functions in a separate file? -# associated TODO: come up with Gaussian reduction test that doesn't rely on external data +# TODO: this helper may belong in a shared test-utilities file rather than here. +# TODO: come up with a Gaussian reduction test that doesn't rely on external data. function load_u_matrix(filename) lines = readlines(filename) diff --git a/test/runtests_innerlayer.jl b/test/runtests_innerlayer.jl index f5e3f7cc..0b146104 100644 --- a/test/runtests_innerlayer.jl +++ b/test/runtests_innerlayer.jl @@ -51,9 +51,9 @@ const GGJ = IL.GGJ end @testset "InnerLayer GGJ :ray backend (rotated-contour collocation)" begin - # Ported 2026-07-08 from the standalone GGJRay package (validated there: - # manufactured Δ* to 3e-14, Fortran rmatch pins, 96-equilibrium robustness - # scans to Q = 500i). These tests pin the port, not the method. + # The method was validated before landing here: manufactured Δ* to 3e-14, + # Fortran rmatch pins, 96-equilibrium robustness scans to Q = 500i. These + # tests pin the implementation, not the method. p = IL.glasser_wang_2020_eq55() @testset "agrees with :galerkin at the paper point Q = 0.1234" begin @@ -72,8 +72,8 @@ end q4 = IL.q4_surface_benchmark() γ = 500.0im * GGJ.q0(q4) Δ = IL.solve_inner(IL.GGJModel(), q4, γ) - # Pins from the standalone GGJRay validation (post ε_mix fix; - # S-invariant to 3e-4 / 7e-9 and θ-stable there). + # Pins from the pre-port validation suite (post extended-precision + # march fix; S-invariant to 3e-4 / 7e-9 and θ-stable there). @test Δ[1] ≈ 2.4720608737 + 13.354123514im rtol = 1e-4 @test Δ[2] ≈ 0.13749694953 + 0.74275468725im rtol = 1e-4 From 4823341b65a9a3a25b3a34594aec9bf99467babe Mon Sep 17 00:00:00 2001 From: Matthew Pharr Date: Wed, 29 Jul 2026 21:21:34 -0400 Subject: [PATCH 21/38] GGJ - DOCS - Trim down excessive docstrings throughout GGJ. --- src/InnerLayer/GGJ/GGJ.jl | 12 +-- src/InnerLayer/GGJ/GGJParameters.jl | 45 +++------ src/InnerLayer/GGJ/Galerkin.jl | 50 +++------- src/InnerLayer/GGJ/InnerAsymptotics.jl | 64 ++++--------- src/InnerLayer/GGJ/Ray.jl | 124 +++++++++---------------- src/InnerLayer/GGJ/RayAsymptotics.jl | 29 +++--- src/InnerLayer/GGJ/Reference.jl | 20 ++-- src/InnerLayer/GGJ/Shooting.jl | 15 +-- 8 files changed, 117 insertions(+), 242 deletions(-) diff --git a/src/InnerLayer/GGJ/GGJ.jl b/src/InnerLayer/GGJ/GGJ.jl index 5e95cf83..870eb551 100644 --- a/src/InnerLayer/GGJ/GGJ.jl +++ b/src/InnerLayer/GGJ/GGJ.jl @@ -44,14 +44,10 @@ import ..InnerLayerModel, ..solve_inner """ GGJModel{S} <: InnerLayerModel -Glasser–Greene–Johnson resistive inner-layer model. The type parameter `S` -selects the solver: `:ray` (default) for the rotated-contour collocation -solver (robust at large |Q| on/near the imaginary axis; agrees with -`:galerkin` within its error bar at moderate Q), `:galerkin` for the -Hermite-cubic finite element solver (real-axis method; degrades for -|Q| ≳ 1), and `:shooting` for the backward stable-shoot solver (|Q| ≪ 1 -only). -Note the backends take different numerical-knob keywords. +Glasser–Greene–Johnson resistive inner-layer model. `S` selects the solver +backend: `:ray` (default; robust at large |Q| on/near the imaginary axis), +`:galerkin` (real-axis Hermite FEM; degrades for |Q| ≳ 1), or `:shooting` +(|Q| ≪ 1 only). The backends take different numerical-knob keywords. """ struct GGJModel{S} <: InnerLayerModel end diff --git a/src/InnerLayer/GGJ/GGJParameters.jl b/src/InnerLayer/GGJ/GGJParameters.jl index 208e663e..237a3c61 100644 --- a/src/InnerLayer/GGJ/GGJParameters.jl +++ b/src/InnerLayer/GGJ/GGJParameters.jl @@ -8,13 +8,10 @@ """ GGJParameters -Dimensionless parameters of the Glasser–Greene–Johnson inner-layer model -at a single rational surface, plus the local Alfvén/resistive timescales -needed to scale the matching data back to physical Δ. The equilibrium -coefficients `E, F, G, H, K, M` are the flux-surface averages defined in -GWP2016 Eq. (A8); they enter the inner-region equations (Eq. 11). - -Fields are the same as the Fortran `resist_type`: +Glasser–Greene–Johnson inner-layer parameters at one rational surface: the +flux-surface-averaged equilibrium coefficients of GWP2016 Eq. (A8) plus the +local timescales that scale the matching data back to physical Δ. Same +fields as the Fortran `resist_type`: | field | meaning | |:------- |:-------------------------------------------------------------- | @@ -29,8 +26,7 @@ Fields are the same as the Fortran `resist_type`: | `v1` | Linear scale factor used in the V₁ rescaling | | `ising` | Index of the singular surface (traceability only) | -The complex growth rate `γ` is **not** stored here; it is passed as a -separate argument to `solve_inner`. +The growth rate `γ` is not stored here; it is a separate argument to `solve_inner`. """ Base.@kwdef struct GGJParameters E::Float64 @@ -48,26 +44,22 @@ end """ mercier_di(p::GGJParameters) -> Float64 -Mercier interchange index `D_I = E + F + H − 1/4` (GWP2016 Eq. A9; also the -quantity under the root in the GW2020 Eq. 49 power exponents). `D_I > 0` -indicates local ideal interchange instability. +Mercier interchange index `D_I = E + F + H − 1/4` (GWP2016 Eq. A9); `D_I > 0` means local ideal interchange instability. """ mercier_di(p::GGJParameters) = p.E + p.F + p.H - 0.25 """ mercier_dr(p::GGJParameters) -> Float64 -Resistive interchange index `D_R = E + F + H² = D_I + (H − 1/2)²` -(GWP2016 Eq. A10). `D_R > 0` indicates local resistive interchange instability. +Resistive interchange index `D_R = E + F + H²` (GWP2016 Eq. A10); `D_R > 0` means local resistive interchange instability. """ mercier_dr(p::GGJParameters) = p.E + p.F + p.H * p.H """ p1(p::GGJParameters) -> Float64 -`p₁ = √(−D_I)`, the Mercier power that sets the large-x Frobenius exponents -`r± = 3/2 ± √(−D_I)` (GW2020 Eq. 49; μ = −1/2 ± √(−D_I) in GWP2016 Eq. 26). -The Mercier-stable branch requires `D_I < 0`; this function asserts it. +`p₁ = √(−D_I)`, setting the large-x Frobenius exponents `r± = 3/2 ± p₁` +(GW2020 Eq. 49). Throws unless `D_I < 0` (Mercier-stable required). """ function p1(p::GGJParameters) di = mercier_di(p) @@ -78,43 +70,36 @@ end """ sfac(p::GGJParameters) -> Float64 -Lundquist number `S = τ_R / τ_A` (GWP2016 Sec. I) that sets the inner-layer -length and time scales `X₀ ∝ S^(−1/3)`, `Q₀ ∝ S^(−1/3)/τ_A`. +Lundquist number `S = τ_R / τ_A`. """ sfac(p::GGJParameters) = p.taur / p.taua """ x0(p::GGJParameters) -> Float64 -Inner-layer displacement scale factor `X₀ = S^(−1/3)` (GWP2016 Eq. A14), -relating scaled `X` to physical `x = ψ − ψ₀` via `x = X₀ X`. +Inner-layer length scale `X₀ = S^(−1/3)` (GWP2016 Eq. A14); physical `x = X₀ X`. """ x0(p::GGJParameters) = sfac(p)^(-1.0 / 3.0) """ q0(p::GGJParameters) -> Float64 -Inner-layer growth-rate scale factor `Q₀ = X₀ / τ_A` (GWP2016 Eq. A15), -relating the scaled growth rate `Q` to the physical rate `s` via `s = Q₀ Q`. +Growth-rate scale `Q₀ = X₀ / τ_A` (GWP2016 Eq. A15); physical rate `= Q₀ Q`. """ q0(p::GGJParameters) = x0(p) / p.taua """ inner_Q(p::GGJParameters, γ::Number) -> ComplexF64 -Dimensionless scaled inner-layer growth rate `Q = γ / Q₀` (GWP2016 Eq. A15), -the eigenvalue appearing in the inner-region equations (Eq. 11) and the inps -Wasow basis. The argument `γ` may be real or complex; the result is always complex. +Scaled inner-layer growth rate `Q = γ / Q₀` (GWP2016 Eq. A15). """ inner_Q(p::GGJParameters, γ::Number) = ComplexF64(γ) / q0(p) """ rescale_delta(Δ, p::GGJParameters) -> SVector{2,ComplexF64} -Map the scaled inner-layer matching data back to physical Δ at the rational -surface by the `X₀^(−2√(−D_I)) = S^(2√(−D_I)/3)` rescaling, together with the -`v₁^(2√(−D_I))` linear-scale factor, (GWP2016 Sec. IV). -Operates element-wise on a 2-vector of `(Δ_odd, Δ_even)`. +Rescale the matching data to physical Δ by `S^(2√(−D_I)/3) · v₁^(2√(−D_I))` +(GWP2016 Sec. IV), element-wise on `(Δ_odd, Δ_even)`. """ function rescale_delta(Δ::AbstractVector, p::GGJParameters) s = sfac(p) diff --git a/src/InnerLayer/GGJ/Galerkin.jl b/src/InnerLayer/GGJ/Galerkin.jl index 76b7578f..637c4e28 100644 --- a/src/InnerLayer/GGJ/Galerkin.jl +++ b/src/InnerLayer/GGJ/Galerkin.jl @@ -38,12 +38,9 @@ using QuadGK: quadgk # ----------------------------------------------------------------------- """ -Convert the inps 6×2 output U_inps (the Wasow asymptotic basis U = TPQSY of -GW2020 Eq. 53) at coordinate `x` to the physical (Ψ, Ξ, Υ) and (Ψ', Ξ', Υ') -representation used by deltac/inpso. The 6-component first-order state packs -(Ψ, Ξ, Υ, Ψ', Ξ', Υ') with the GW2020 Eq. (2) scaling 𝚿 ≡ (xΨ, Ξ, Υ), hence -the /x and ·x factors below. Returns `(ua, dua)` each 3×2 complex, where columns -are the two power-like (Mercier) solutions and rows are the components (Ψ, Ξ, Υ). +Convert the inps 6×2 basis (GW2020 Eq. 53) at `x` to physical `(Ψ, Ξ, Υ)` +values and derivatives, each 3×2 (columns = the two power-like solutions). +The /x and ·x factors undo the GW2020 Eq. (2) scaling 𝚿 ≡ (xΨ, Ξ, Υ). """ function _physical_ua_dua(cache::InnerAsymptoticsCache, x::Real) U, dU = evaluate_asymptotics(cache, x; derivative=true, apply_T=true) @@ -61,23 +58,11 @@ function _physical_ua_dua(cache::InnerAsymptoticsCache, x::Real) end """ -Build the (I, U, V) coefficient matrices of the second-order system -`I·u'' − V·u' − U·u = 0` for u = (Ψ, Ξ, Υ) at coordinate `x`. Port of -inpso_get_uv. All matrices are 3×3 complex. - -These are the matrices A, B, C of GWP2016 Eq. (12), `A Ψ'' + B Ψ' + C Ψ = 0`, -with A given in Eq. (14), B in Eq. (14), and C in Eq. (15). The code's weak-form -layout flips the off-diagonal signs: (I, V, U) = (A, −B, −C). - -Each matrix row is one GGJ inner-region equation (GWP2016 Eq. 11 ≡ GW2020 Eq. 1); -reading `I u'' − V u' − U u` across a row reproduces the corresponding equation: - -row 1 (Ψ): Ψ_xx − H Υ_x − Q(Ψ − x Ξ) = 0 -⇒ I=(1,0,0) V=(0,0,H) U=(Q, −Qx, 0) -row 2 (Ξ): Q² Ξ_xx − Q x² Ξ + Q x Ψ + (E+F) Υ + H Ψ_x = 0 -⇒ I=(0,Q²,0) V=(−H,0,0) U=(−Qx, Q x², −(E+F)) -row 3 (Υ): Q Υ_xx − x² Υ + x Ψ + Q²[G(Ξ−Υ) − K(E Ξ + F Υ + H Ψ_x)] = 0 -⇒ I=(0,0,Q) V=(K Q² H,0,0) U=(−x, −Q²(G−KE), x²+Q²(G+KF)) +Coefficient matrices of the second-order system `I·u'' − V·u' − U·u = 0` for +u = (Ψ, Ξ, Υ) at `x`; port of inpso_get_uv, 3×3 complex. These are A, B, C of +GWP2016 Eqs. (12)–(15) with `(I, V, U) = (A, −B, −C)` and rows scaled by +(1, Q², Q). The inline comments below give the row-by-row map to the inner +equations. """ function _physical_uv(params::GGJParameters, Q::ComplexF64, x::Real) e = ComplexF64(params.E); @@ -893,20 +878,15 @@ end # ISSUES AND IS NOT RELIABLE IN BROAD SCANS. IT IS LEFT HERE FOR REFERENCE AND MAY BE REWORKED LATER. """ solve_inner_converged(::GGJModel{:galerkin}, params::GGJParameters, γ::Number; - rtol=1e-2, kmax0=12, xfac0=1.5, cells_per_unit=3.0, - nx_min=1024, nx_max=8192, kmax_step=2, kmax_max=28, - xfac_growth=1.5, max_levels=6, nq=6, pfac=1.0, cutoff=8, tol_res=1e-4) + rtol=1e-2, max_levels=6, kwargs...) -> (; delta, converged, err, kmax, xfac, nx, nlevels) -Convergence-guarded GGJ inner-layer solve: only returns a Δ once it is stable under joint refinement of -the three coupled accuracy knobs — series order `kmax`, asymptotic reach `xfac`, and grid resolution `nx`. -Successively refines all three (raising `kmax` clears the high-|Q| series floor; raising `xfac` clears the -reach floor; `nx` is scaled with `xmax` to hold cells-per-unit-x ≈ `cells_per_unit`, which prevents the -grid-starvation breakup that otherwise corrupts Δ at large `xfac`/high |Q|) until the per-component -relative change of (Δ₁, Δ₂) drops below `rtol`, or `max_levels` is hit (then `converged=false`). - -The metric is per real/imag component with a significance floor (5% of |Δᵢ|), so a converged norm cannot -mask a wrong reactive part Re(Δ₁) — the failure mode under-reach produces (Im dominates |Δ₁|). +Convergence-guarded Galerkin solve: jointly refines the three coupled accuracy +knobs (series order `kmax`, asymptotic reach `xfac`, grid `nx` scaled to hold +cells-per-unit-x ≈ `cells_per_unit`) until the per-component relative change +of (Δ₁, Δ₂) drops below `rtol`, or `max_levels` is hit (`converged=false`). +The metric is per real/imag component with a 5% significance floor, so a +converged norm cannot mask a wrong small component. """ function solve_inner_converged(model::GGJModel{:galerkin}, params::GGJParameters, γ::Number; rtol::Float64=1e-2, kmax0::Int=12, xfac0::Float64=1.5, diff --git a/src/InnerLayer/GGJ/InnerAsymptotics.jl b/src/InnerLayer/GGJ/InnerAsymptotics.jl index 7b6b838c..2411f896 100644 --- a/src/InnerLayer/GGJ/InnerAsymptotics.jl +++ b/src/InnerLayer/GGJ/InnerAsymptotics.jl @@ -33,13 +33,9 @@ const _R2 = SVector(3, 4, 5, 6) """ InnerAsymptoticsCache -Frozen state of the `inps` Wasow asymptotic-basis construction for a single -`(GGJParameters, Q)` pair. All matrices are stored as `SMatrix`/`SVector` -so the evaluator can run allocation-free on a hot path. - -Index convention: `P[k+1]` holds the k-th-order matrix `P_k`, `B[k+1]` -holds `B_k`, etc., for k = 0, 1, …, the upper bound documented in each -field. +Frozen `inps` Wasow asymptotic-basis construction for one `(GGJParameters, Q)` +pair, stored as `SMatrix`/`SVector` for allocation-free evaluation. Index +convention: `P[k+1]` holds the k-th-order matrix `P_k`, etc. Fields: @@ -357,13 +353,9 @@ end """ build_asymptotics(params::GGJParameters, Q::ComplexF64; kmax::Int=8) -> InnerAsymptoticsCache -Construct the `inps` Wasow asymptotic basis for the given GGJ parameters -and dimensionless growth rate `Q`. Truncates each power series at order -`kmax` (default `8`). The returned cache can be evaluated at any `x > 0` -via [`evaluate_asymptotics`](@ref) and queried for an adaptive `X_max` -via [`pick_xmax`](@ref). - -Reference: Glasser & Wang, Phys. Plasmas **27**, 012506 (2020), Eqs. 7–53. +Construct the `inps` Wasow asymptotic basis (GW2020 Eqs. 7–53), truncating +each power series at order `kmax`. Evaluate with [`evaluate_asymptotics`](@ref); +pick a cutoff with [`pick_xmax`](@ref). """ function build_asymptotics(params::GGJParameters, Q::ComplexF64; kmax::Int=8) p1v = p1(params) @@ -498,19 +490,12 @@ end # ----------------------------------------------------------------------- """ - evaluate_asymptotics(cache::InnerAsymptoticsCache, x::Real; - derivative::Bool=true, apply_T::Bool=true) - -> (U, dU) - -Evaluate the inps asymptotic basis at `x > 0`. Returns the 6×2 complex -matrix `U` whose two columns are the algebraically-decaying ("small") -asymptotic solutions of the GGJ system, and (if `derivative=true`) the -6×2 matrix `dU` of their derivatives `dU/dx`. - -If `apply_T=false`, the result is left in the J-rotated coordinate basis -(used by `inps_delta` for residual checks). The default `apply_T=true` -returns the solutions in the original 6-component first-order-system -basis used by `inpso_get_uv` and the shooting / Galerkin solvers. + evaluate_asymptotics(cache::InnerAsymptoticsCache, x::Real; derivative=true, apply_T=true) -> (U, dU) + +Evaluate the inps basis at `x > 0`: `U` is 6×2 with columns the two power-like +solutions (GW2020 Eq. 53), `dU` their x-derivatives (`nothing` unless +`derivative=true`). `apply_T=false` leaves the result in the J-rotated basis +used by the residual check. """ function evaluate_asymptotics(cache::InnerAsymptoticsCache, x::Real; derivative::Bool=true, apply_T::Bool=true) @@ -590,11 +575,8 @@ end """ asymptotic_residual(cache::InnerAsymptoticsCache, x::Real) -> SVector{2,Float64} -Compute the convergence measure `Δ±` of the asymptotic basis at `x` for each -of the two algebraic columns (GW2020 Eq. 54). Mirrors `inps_delta`: returns -`‖dU − x·matrix·U‖∞ / max(‖dU‖∞, ‖x·matrix·U‖∞)` per column, where -`matrix = J₀ + xfac·J₁ + xfac²·J₂` is the J-rotated coefficient matrix (the -residual of `v' = xJv`, GW2020 Eq. 6). +Convergence measure Δ± of the two series columns at `x` (GW2020 Eq. 54): +`‖dU − x·J(x)·U‖∞ / max(‖dU‖∞, ‖x·J(x)·U‖∞)` per column. Mirrors `inps_delta`. """ function asymptotic_residual(cache::InnerAsymptoticsCache, x::Real) U, dU = evaluate_asymptotics(cache, x; derivative=true, apply_T=false) @@ -631,19 +613,11 @@ function asymptotic_residual(cache::InnerAsymptoticsCache, x::Real) end """ - pick_xmax(params::GGJParameters, Q::ComplexF64; - eps::Float64=1e-7, kmax::Int=8, - xlogmin::Float64=-1.0, xlogmax::Float64=4.0, - dxlog::Float64=0.01) -> (Float64, InnerAsymptoticsCache) - -Sweep `x` log-uniformly upward from `10^xlogmin` and return the smallest -`x` at which `max(asymptotic_residual(cache, x)) < eps` — the cutoff `x_max` -where the GW2020 Eq. (54) convergence measure drops below tolerance -(GW2020 Sec. III, Fig. 3). Also returns the `InnerAsymptoticsCache` it built -so callers can reuse it. Mirrors `inps_xmax`. - -Throws an `ErrorException` if no `x` in the sweep range achieves the -target tolerance. + pick_xmax(params, Q; eps=1e-7, kmax=8, xlogmin=-1.0, xlogmax=4.0, dxlog=0.01) -> (x_max, cache) + +Sweep `x` log-uniformly and return the smallest `x` where the GW2020 Eq. (54) +residual drops below `eps`, plus the cache built along the way. Throws if the +tolerance is never reached in the sweep range. Mirrors `inps_xmax`. """ function pick_xmax(params::GGJParameters, Q::ComplexF64; eps::Float64=1e-7, kmax::Int=8, diff --git a/src/InnerLayer/GGJ/Ray.jl b/src/InnerLayer/GGJ/Ray.jl index b60a481f..b398933b 100644 --- a/src/InnerLayer/GGJ/Ray.jl +++ b/src/InnerLayer/GGJ/Ray.jl @@ -51,10 +51,9 @@ """ ode_matrix([CT,] p::GGJParameters, Q, x) -> SMatrix{6,6,CT} -Coefficient matrix `M(x)` of `dv/dx = M v`, `v = (Ψ, Ξ, Υ, Ψ', Ξ', Υ')`, -valid for real or complex `x`. The optional leading type argument selects -the element type (e.g. `Complex{Double64}` for the extended-precision -damped-zone march); default `ComplexF64`. +Coefficient matrix `M(x)` of `dv/dx = M v`, `v = (Ψ, Ξ, Υ, Ψ', Ξ', Υ')`, for +real or complex `x`. `CT` selects the element type (default `ComplexF64`; +`Complex{Double64}` for the extended-precision march). """ ode_matrix(p::GGJParameters, Q::ComplexF64, x::Number) = ode_matrix(ComplexF64, p, Q, x) @@ -276,13 +275,11 @@ end # of u_small/u_big are exact up to integrator tolerance. """ - decaying_pair(params, Q, θ, S; growth=30.0, seed=20260707) -> E::Matrix{ComplexF64} (6×2) + decaying_pair(params, Q, θ, S; growth=30.0, seed=20260707, dp_res=20.0) -> E (6×2) -Orthonormal basis of the forward-decaying pair at s = S on the ray, via a -SHORT backward RK4 integration over [S, S+ΔS], with ΔS chosen so the pair is -amplified by ≈ e^growth relative to everything else (backward-attracting -purification of random seeds). The integration is fully resolved -(h·rate ≈ 0.05), so plain RK4 is stable over this short stretch. +Orthonormal basis of the forward-decaying pair at s = S on the ray: random +seeds integrated backward over [S, S+ΔS] with fully-resolved RK4, ΔS sized so +the pair separates from everything else by ≈ `growth` e-folds. """ function decaying_pair(params::GGJParameters, Q::ComplexF64, θ::Float64, S::Float64; growth::Float64=30.0, seed::Int=20260707, dp_res::Float64=20.0) @@ -346,10 +343,9 @@ end Transport the power-pair boundary data from the series radius `S` inward to `s_m` along the ray, modulo the decaying exponential pair (the quotient in -which the matching data Δ is defined). Adaptive 2-stage Radau IIA (L-stable, -stiffly accurate) on the raw linear system, with Φ-budgeted purges against -locally-extracted decaying frames; see the file header for why explicit -(including projected-QR) marchers cannot do this job at large S. +which Δ is defined). 2-stage Radau IIA (L-stable) with Φ-budgeted purges +against a carried decaying frame; explicit marchers cannot do this at large S +(see file header). """ function march_boundary(params::GGJParameters, Q::ComplexF64, θ::Float64, S::Float64, s_m::Float64, Us::Vector{ComplexF64}, Ub::Vector{ComplexF64}; @@ -650,12 +646,10 @@ end """ _solve_parities(Ic, Jc, Vc, rhs, ndof, N, p) -> (sol_odd, sol_even) -Solve BOTH parity systems from one factorization. The two matrices differ -only in the 3 parity rows at s = 0 (odd: unit entries at components (4,2,3); -even: at (1,5,6)), i.e. A_even = A_odd + Σₖ e_{rₖ}(e_{c2ₖ} − e_{c1ₖ})ᵀ — a -rank-3 update. One sparse LU (of the row-equilibrated odd system) plus a -Woodbury correction (3 extra triangular solves + a 3×3 solve) replaces the -second factorization, which dominates the linear-algebra cost. +Solve both parity systems from one factorization: the two matrices differ only +in the 3 parity rows at s = 0 (a rank-3 update), so one sparse LU of the +row-equilibrated odd system plus a Woodbury correction replaces the second +factorization, which dominates the linear-algebra cost. """ function _solve_parities(Ic::Vector{Int}, Jc::Vector{Int}, Vc::Vector{ComplexF64}, rhs::Vector{ComplexF64}, ndof::Int, N::Int, p::Int) @@ -765,23 +759,16 @@ end # ----------------------------------------------------------------------- """ - solve_ray(params::GGJParameters, Q; θ=angle(Q)/4, kmax=12, p=12, - S=nothing, smax_tol=1e-9, growth=30.0, - refine_tol=1e-8, max_rounds=8, max_cells=6000, - verbose=false) -> RaySolveResult - -Solve the GGJ inner-layer matching problem on the rotated ray x = e^{iθ}s -and return the parity matching data. `Δ` follows the deltac output -convention (swap + `rescale_delta`) so it is directly comparable to the -GPEC_julia Galerkin solver. - -Keyword highlights: - - `θ` : contour angle; default arg(Q)/4 makes the parabolic exponent - exactly real. θ = 0 reproduces a real-axis solve. - - `S` : matching radius; default from the inps series residual - criterion along the ray (`pick_smax`). - - `p` : polynomial order per spectral element. - - `refine_tol`/`max_rounds`: residual-driven bisection refinement control. + solve_ray(params::GGJParameters, Q; θ=angle(Q)/4, kmax=12, p=12, S=nothing, + smax_tol=1e-9, refine_tol=1e-8, max_rounds=8, max_cells=6000, + verbose=false, kwargs...) -> RaySolveResult + +Solve the GGJ inner-layer matching problem on the rotated ray x = e^{iθ}s. +`Δ` follows the deltac output convention (swap + `rescale_delta`), directly +comparable to the `:galerkin` backend. θ = 0 reproduces a real-axis solve; +`S` defaults to the inps series-residual radius (`pick_smax`); `p` is the +polynomial order per spectral element; remaining keywords are march/mesh +knobs (see `march_boundary`, `initial_breaks`, `decaying_pair`). """ function solve_ray(params::GGJParameters, Q::ComplexF64; θ::Float64=angle(Q) / 4, kmax::Int=12, p::Int=12, @@ -937,19 +924,11 @@ solve_ray(params::GGJParameters, Q::Number; kwargs...) = solve_inner(::GGJModel{:ray}, params::GGJParameters, γ::Number; kwargs...) -> SVector{2,ComplexF64} -Solve the GGJ inner-layer matching problem with the rotated-ray collocation -backend and return the parity-projected matching data `(Δ₁, Δ₂)` in the same -convention as the `:shooting` and `:galerkin` backends (deltac output swap + -`X₀^{2√(−D_I)}` physical rescale applied), directly interchangeable with -them. Converts γ via `inner_Q` and forwards all keywords to -[`solve_ray`](@ref); use `solve_ray` directly when the full -[`RaySolveResult`](@ref) (raw Δ, contour, mesh, nodal solution, residuals) -is wanted. - -Preferred backend for |Q| ≳ 1 and for Q near the imaginary axis (rotation / -resistivity scans): validated to |Q| = 500 on the imaginary axis, where the -other backends fail. At |Q| ≪ 1 all three backends agree; `:ray` remains -accurate but `:galerkin` may be faster for very small |Q| real Q. +Rotated-ray backend: converts γ via `inner_Q`, forwards keywords to +[`solve_ray`](@ref), and returns `(Δ₁, Δ₂)` in the shared backend convention +(deltac swap + physical rescale). Preferred for |Q| ≳ 1 and near the +imaginary axis; use `solve_ray` directly when the full +[`RaySolveResult`](@ref) is wanted. """ function solve_inner(::GGJModel{:ray}, params::GGJParameters, γ::Number; kwargs...) res = solve_ray(params, inner_Q(params, γ); kwargs...) @@ -984,13 +963,10 @@ end """ solution_profile(res::RaySolveResult; npc=8) -> (; s, x, Ψ, Ξ, Υ) -Reconstruct the physical inner-layer fields (Ψ, Ξ, Υ) on the solution -contour, `npc` points per cell over the collocation domain [0, s_m]. -Output layout mirrors the legacy `solve_inner_profile`: each field is an -`npts × 2` matrix with columns (isol=1 "odd", isol=2 "even"); `x = e^{iθ}s` -is the (complex) layer coordinate. For real Q (θ = 0) this is the real-axis -profile directly; for rotated solves the fields live on the ray — analytic -continuation back to real x is a separate (unstable) problem, by design. +Reconstruct (Ψ, Ξ, Υ) on the solution contour, `npc` points per cell; each +field is `npts × 2` with columns (odd, even) and `x = e^{iθ}s`. For rotated +solves the fields live on the ray — continuation back to real x is a +separate (unstable) problem, by design. """ function solution_profile(res::RaySolveResult; npc::Int=8) breaks = res.breaks @@ -1037,11 +1013,9 @@ end """ asymptotic_profile(params, res::RaySolveResult, srange) -> (; s, x, Ψ, Ξ, Υ) -Evaluate the ANALYTIC representation `u_small + Δ·u_big` on the ray for -`s ≥ res.S` (where the inps series is trusted; decaying-exponential content -is machine-dead there). Concatenating with [`solution_profile`](@ref) -demonstrates the seamless numeric ↔ asymptotic match — the same overlap the -outer-region matching relies on. +Evaluate the analytic representation `u_small + Δ·u_big` on the ray for +`s ≥ res.S`; concatenated with [`solution_profile`](@ref) it should join +the numeric solution seamlessly across the march zone. """ function asymptotic_profile(params::GGJParameters, res::RaySolveResult, srange::AbstractVector{<:Real}) @@ -1067,25 +1041,13 @@ end max_rounds=16, max_cells=12000, kwargs...) -> (; Δ, spread, base, table) -Convergence battery for the matching data: solve at a trusted baseline, then -re-solve with every numerical knob perturbed on an INDEPENDENT axis, and -report the relative change of (Δ₁, Δ₂) per knob. The knobs probe orthogonal -error sources, so the worst-case `spread` is an honest error bar on Δ: - - - `θ → 1.2θ` : contour angle — Δ is an analytic invariant of the ray, - so any drift is pure numerical error (sharpest test). - OUTWARD: the natural angle maximizes clearance from the - x² ≈ −Q²(G+KF) pseudo-resonance, so inward checks - measure their own degraded solve, not the baseline; - - `p → p+4` : spectral-element order (interior discretization); - - `kmax+4, smax_tol/10` : series order and matching radius S (far-field BC); - - `refine_tol/10` : mesh refinement depth; - - `march_rtol/100` : outer-region quotient-march tolerance; - - `sm_fac 1→1.4` : BVP/march handoff radius; - - `growth 30→45` : decaying-pair purification e-folds. - -Returns the baseline `Δ`, the per-parity worst-case relative `spread`, the -baseline result struct, and the per-knob table `(name, δΔ₁, δΔ₂)`. +Error bar for the matching data: solve at a strict baseline, then re-solve +with each numerical knob perturbed on an independent axis (contour angle θ, +element order p, series order/radius, refinement depth, march tolerance, +handoff radius, purification e-folds — see `variations` below) and report the +relative change of (Δ₁, Δ₂) per knob. θ is perturbed outward only: inward +moves toward the x² ≈ −Q²(G+KF) pseudo-resonance and measures the perturbed +solve, not the baseline. `spread` is the per-parity worst case over all knobs. """ function delta_convergence(params::GGJParameters, Q::ComplexF64; verbose::Bool=true, refine_tol::Float64=1e-9, diff --git a/src/InnerLayer/GGJ/RayAsymptotics.jl b/src/InnerLayer/GGJ/RayAsymptotics.jl index f20c9a3b..630d5c39 100644 --- a/src/InnerLayer/GGJ/RayAsymptotics.jl +++ b/src/InnerLayer/GGJ/RayAsymptotics.jl @@ -11,8 +11,7 @@ # data, the GW2020 Eq. (54) residual along the ray, and the series-radius # selector `pick_smax` (the ray analog of `pick_xmax`). # -# "Far-field boundary and the inward -# march"; the real-x methods of InnerAsymptotics.jl are untouched. +# The real-x methods of InnerAsymptotics.jl are untouched. # Complex-argument Horner evaluator: same contract as _horner(x::Real, ...) # in InnerAsymptotics.jl, with x^rvec on the principal branch. @@ -74,12 +73,10 @@ end """ evaluate_asymptotics(cache, x::Complex; derivative=true, apply_T=true) -> (U, dU) -Evaluate the inps asymptotic basis at complex `x` (GW2020 Eq. 53; principal -branch, |arg x| < π). Returns the 6×2 matrix `U` whose columns are the two -power-like asymptotic solutions in the GW2020 Eq. (2) state convention -`u = (xΨ, Ξ, Υ, (xΨ)'/x, Ξ'/x, Υ'/x)`, and their x-derivatives `dU` if -requested. `apply_T=false` returns the pre-Jordan-basis representation used -by the residual measure. +Complex-x version of [`evaluate_asymptotics`](@ref) (GW2020 Eq. 53; principal +branch of `x^r`, valid for |arg x| < π). Columns of the 6×2 `U` are the two +power-like solutions in the GW2020 Eq. (2) state convention +`u = (xΨ, Ξ, Υ, (xΨ)'/x, Ξ'/x, Υ'/x)`. """ function evaluate_asymptotics(cache::InnerAsymptoticsCache, x::Complex; derivative::Bool=true, apply_T::Bool=true) @@ -148,9 +145,8 @@ end physical_ua_dua(cache, x::Number) -> (ua, dua) Convert the inps 6×2 basis at (possibly complex) `x` to physical `(Ψ, Ξ, Υ)` -values and x-derivatives (each 3×2; column 1 = large power solution, -column 2 = small). Same map as the deltac `inpso_get_ua/dua` convention and -the Galerkin backend's `_physical_ua_dua`, generalized to complex x. +values and x-derivatives, each 3×2 (column 1 = large power solution, 2 = small). +Same map as the deltac `inpso_get_ua/dua` convention. """ function physical_ua_dua(cache::InnerAsymptoticsCache, x::Number) xc = ComplexF64(x) @@ -171,8 +167,7 @@ end """ asymptotic_residual(cache, x::Complex) -> SVector{2,Float64} -GW2020 Eq. (54) convergence measure of the two power-like series columns at -complex `x`: ‖dU − x·J(x)·U‖∞ / max(‖dU‖∞, ‖x·J(x)·U‖∞), per column. +GW2020 Eq. (54) residual of the two series columns at complex `x`. """ function asymptotic_residual(cache::InnerAsymptoticsCache, x::Complex) U, dU = evaluate_asymptotics(cache, x; derivative=true, apply_T=false) @@ -210,11 +205,9 @@ end pick_smax(params, Q; θ=angle(Q)/4, eps=1e-9, kmax=12, cache=nothing, slogmin=-1.0, slogmax=6.5, dslog=0.01) -> (S, cache, achieved) -Ray analog of `pick_xmax`: sweep the ray parameter `s` log-uniformly and -return the smallest `s` at which the series residual along `x = e^{iθ}s` -drops below `eps`. If the target is never reached, returns the residual- -minimizing `s` with `achieved = false` (caller should warn). Also returns -the asymptotics cache for reuse. +Ray analog of [`pick_xmax`](@ref): the smallest `s` at which the series +residual along `x = e^{iθ}s` drops below `eps`. If never reached, returns the +residual-minimizing `s` with `achieved = false`. """ function pick_smax(params::GGJParameters, Q::ComplexF64; θ::Float64=angle(Q) / 4, eps::Float64=1e-9, kmax::Int=12, diff --git a/src/InnerLayer/GGJ/Reference.jl b/src/InnerLayer/GGJ/Reference.jl index aedb4add..16098a28 100644 --- a/src/InnerLayer/GGJ/Reference.jl +++ b/src/InnerLayer/GGJ/Reference.jl @@ -5,21 +5,13 @@ # corresponding Fortran RMATCH test cases. """ - glasser_wang_2020_eq55() -> GGJParameters + glasser_wang_2020_eq55(; taua=1.0, taur=1e6, v1=1.0) -> GGJParameters -D-shaped aspect-ratio-2, q = 2 surface from Glasser & Wang, Phys. Plasmas -**27**, 012506 (2020), Eq. 55. This is the primary benchmark case for -validating the inps Wasow basis convergence (their Figs. 1–4). This is useful -only for benchmarking the galerkin solver and comparing to published results. - -The five coefficients below are transcribed verbatim from Eq. 55; the paper's -companion operating point is the scaled growth rate `Q = 1.234e-1` (their Fig. 1). -Note Eq. 55 does not tabulate an inner-region matching `Δ(Q)` — its `Δ_±` (Eq. 54) -is a convergence-error norm — so a quantitative `Δ(Q)` cross-check needs a Fortran -rmatch/INPS run, not this paper alone. - -Timescale parameters (taua, taur, v1) are set to canonical normalization; -callers should override them for physical cases. +D-shaped aspect-ratio-2, q = 2 surface from GW2020 Eq. 55 (companion operating +point `Q = 1.234e-1`, their Fig. 1); benchmark for the inps basis convergence +(their Figs. 1–4). Eq. 55 does not tabulate `Δ(Q)` — its `Δ_±` (Eq. 54) is a +convergence norm — so quantitative Δ cross-checks need a Fortran rmatch/inps +run. Timescales default to canonical normalization; override for physical cases. """ function glasser_wang_2020_eq55(; taua::Float64=1.0, taur::Float64=1e6, v1::Float64=1.0) return GGJParameters(; diff --git a/src/InnerLayer/GGJ/Shooting.jl b/src/InnerLayer/GGJ/Shooting.jl index 7a3162d2..5dc8219a 100644 --- a/src/InnerLayer/GGJ/Shooting.jl +++ b/src/InnerLayer/GGJ/Shooting.jl @@ -343,17 +343,10 @@ end rtol_origin::Float64=1e-6, nps::Int=8, fmax::Float64=1.0, solver=Tsit5()) -> SVector{2,ComplexF64} -Solve the GGJ inner-layer matching problem by stable backward shooting in -the origin-diagonalized 4×4 basis. Direct port of the rmatch `deltar.f` -algorithm. - -Returns the parity-projected matching data `(Δ₁, Δ₂)` (already rescaled -back to physical units via `rescale_delta`). Index ordering matches the -Fortran `deltar` output. - -Tolerances `reltol`/`abstol` are the integrator tolerances; `rtol_origin` -controls the truncation error of the origin Frobenius series and the -choice of `tmin`. +Backward shoot in the origin-diagonalized 4×4 basis; direct port of rmatch +`deltar.f`. Returns `(Δ₁, Δ₂)` rescaled to physical units, index ordering as +the Fortran `deltar` output. `rtol_origin` sets the origin-series truncation +and `tmin`. Not for production use — see the file header. """ function solve_inner(::GGJModel{:shooting}, params::GGJParameters, γ::Number; reltol::Float64=1e-6, abstol::Float64=1e-6, From 54486f5032d27ac74713ef4d079f4b06eb081b72 Mon Sep 17 00:00:00 2001 From: Matthew Pharr Date: Thu, 30 Jul 2026 00:38:47 -0400 Subject: [PATCH 22/38] ForceFreeStates - CLEANUP - remove artifacts of delta_mn integration --- src/ForceFreeStates/Galerkin/GalerkinMatch.jl | 63 +++---------------- src/ForceFreeStates/Galerkin/GalerkinSolve.jl | 3 +- .../Galerkin/GalerkinStructs.jl | 1 - src/GeneralizedPerturbedEquilibrium.jl | 5 -- .../PerturbedEquilibriumStructs.jl | 6 -- src/PerturbedEquilibrium/SingularCoupling.jl | 14 +---- 6 files changed, 13 insertions(+), 79 deletions(-) diff --git a/src/ForceFreeStates/Galerkin/GalerkinMatch.jl b/src/ForceFreeStates/Galerkin/GalerkinMatch.jl index 870267d9..576f96de 100644 --- a/src/ForceFreeStates/Galerkin/GalerkinMatch.jl +++ b/src/ForceFreeStates/Galerkin/GalerkinMatch.jl @@ -13,40 +13,7 @@ # identity-at-edge basis (coil column j has ξ_edge = e_j). """ - gal_deltamn_kernels(asymps, sings, intr, nn) -> (KL, KR) - -Per-surface unit-small-solution kernels for the coefficient-based Δ_mn: the resonant-harmonic -`bwp1/(2πχ₁) = i·(singfac·ξ′ − n·q′·ξ)` of the UNIT small Frobenius solution, evaluated at the -conventional brackets ψ_s ∓ δ with δ = 5e-4/(|n|·|q′|) — the same offsets as SingularCoupling's -finite-jump fallback (spot_psi), so the two paths measure the same object. The matched solution's -Δ_mn is then `Δ_mn[i,j] = KR[i]·c_small_R[i,j] − KL[i]·c_small_L[i,j]`: the small-solution -(shielding) content only. The big/penetrated branch is excluded — its finite-offset contribution -diverges as δ^{−(1/2+p₁)} and is not part of the physical Δ_mn (in ideal runs it is zero anyway). -""" -function gal_deltamn_kernels(asymps::Vector{GalSingAsymp}, sings::Vector{SingType}, - intr::ForceFreeStatesInternal, nn::Int) - msing = length(sings) - KL = zeros(ComplexF64, msing) - KR = zeros(ComplexF64, msing) - for i in 1:msing - q1 = sings[i].q1 - imres = round(Int, sings[i].q * nn) - intr.mlow + 1 - δ = 5e-4 / (abs(nn) * abs(q1)) - for (K, sgn) in ((KR, 1.0), (KL, -1.0)) - z = sgn * δ - ua = sing_get_ua_gal(asymps[i], z) - dua = sing_get_dua_gal(asymps[i], z) - ξ = ua[imres, 2, 1] - ξ′ = dua[imres, 2, 1] - singfac = -nn * q1 * z # m_res − n·q(ψ_s+z) to leading order - K[i] = im * (singfac * ξ′ - nn * q1 * ξ) - end - end - return KL, KR -end - -""" - gal_match_rpec(ctrl, equil, intr, gal_result, asymps) -> GalMatchResult + gal_match_rpec(ctrl, equil, intr, gal_result) -> GalMatchResult Solve the coil-driven RPEC matching from the outer Δ′ (`gal_result`) and the per-surface inner-layer Δ. Requires `gal_result.solution` (reconstructed outer ξ/ξ′) and the rpec coil block `gal_result.delta_coil`. @@ -56,7 +23,7 @@ The resistive path uses per-surface inputs `ctrl.gal_eta`/`gal_rho`/`gal_rotatio solution is the bare ideal coil column (Fortran rmatch `coil%ideal_flag`) — the DCON/EL reference. """ function gal_match_rpec(ctrl::ForceFreeStatesControl, equil, intr::ForceFreeStatesInternal, - gal_result::GalerkinResult, asymps::Vector{GalSingAsymp}) + gal_result::GalerkinResult) msing = gal_result.msing mpert = intr.numpert_total @@ -68,12 +35,6 @@ function gal_match_rpec(ctrl::ForceFreeStatesControl, equil, intr::ForceFreeStat isempty(gal_result.delta_coil) && error("gal_match_rpec: delta_coil is empty — gal_rpec_flag must be true for RPEC matching") - # Singular-surface set (same helper as galerkin_solve); needed by both branches (Δ_mn kernels) - # and by the resistive inner-layer loop. - sings, _, _ = gal_resonant_surfaces(intr, equil) - length(sings) == msing || - error("gal_match_rpec: re-derived $(length(sings)) surfaces, expected msing=$msing") - if ctrl.gal_ideal_flag # Ideal limit (Fortran rmatch coil%ideal_flag, match.f): skip the inner layer entirely # and set the resistive plasma combination to zero. The gal outer solve is already fully ideal, so @@ -92,6 +53,12 @@ function gal_match_rpec(ctrl::ForceFreeStatesControl, equil, intr::ForceFreeStat for (name, v) in (("gal_eta", ctrl.gal_eta), ("gal_rho", ctrl.gal_rho), ("gal_rotation", ctrl.gal_rotation)) length(v) == msing || error("gal_match_rpec: $name has length $(length(v)), expected msing=$msing (one value per surface, core→edge)") end + + # Re-derive the gal singular-surface set (same helper as galerkin_solve) for the SingType objects + # resist_eval needs. + sings, _, _ = gal_resonant_surfaces(intr, equil) + length(sings) == msing || + error("gal_match_rpec: re-derived $(length(sings)) surfaces, expected msing=$msing") ctrl.gal_inner_solver in ("ray", "galerkin") || error("gal_match_rpec: gal_inner_solver = \"$(ctrl.gal_inner_solver)\" (expected \"ray\" or \"galerkin\")") @@ -228,19 +195,7 @@ function gal_match_rpec(ctrl::ForceFreeStatesControl, equil, intr::ForceFreeStat end end - # Coefficient-based Δ_mn (both branches; ideal has cout = 0 ⇒ coil small content only). - # Small-solution content of the matched solution at each surface side: the resonant solutions' - # small coefficients (delta rows 1:2msing) weighted by cout, plus the coil column's own small - # content (delta rows 2msing+1:end). Slots: column 2i-1 = left, 2i = right of surface i. - KL, KR = gal_deltamn_kernels(asymps, sings, intr, nn) - Dm = gal_result.delta - S_small = transpose(cout) * Dm[1:2msing, :] .+ Dm[(2msing+1):(2msing+mcoil), :] # (mcoil × 2msing) - delta_mn = Matrix{ComplexF64}(undef, msing, mcoil) - for i in 1:msing, j in 1:mcoil - delta_mn[i, j] = KR[i] * S_small[j, 2i] - KL[i] * S_small[j, 2i-1] - end - - return GalMatchResult(cout, cin, xi, xi_deriv, deltar, bpen, inner_psi, inner_xi, inner_b, delta_mn, inner_params, rpec_eig, residual) + return GalMatchResult(cout, cin, xi, xi_deriv, deltar, bpen, inner_psi, inner_xi, inner_b, inner_params, rpec_eig, residual) end """ diff --git a/src/ForceFreeStates/Galerkin/GalerkinSolve.jl b/src/ForceFreeStates/Galerkin/GalerkinSolve.jl index bde9776c..d3ef0379 100644 --- a/src/ForceFreeStates/Galerkin/GalerkinSolve.jl +++ b/src/ForceFreeStates/Galerkin/GalerkinSolve.jl @@ -196,7 +196,7 @@ function galerkin_solve(ctrl::ForceFreeStatesControl, equil, ffit::FourFitVars, "RPEC matching: IDEAL solution (inner layer skipped, bare coil columns)" : "RPEC matching: inner-layer Δ(Q) + outer↔inner solve for the coil-driven ξ" ) - match = gal_match_rpec(ctrl, equil, intr, result, asymps) + match = gal_match_rpec(ctrl, equil, intr, result) ctrl.gal_ideal_flag || (ctrl.verbose && @info "RPEC matching: linear-solve residual = $(match.residual)") return GalerkinResult(delta, Ap, Bp, Gammap, Deltap, msing, sing_psi, sing_q, sing_m, sing_n, di, alpha, delta_coil, solution, match) @@ -265,7 +265,6 @@ function write_galerkin!(out_h5, result::GalerkinResult) out_h5["galerkin/match/xi_deriv"] = m.xi_deriv out_h5["galerkin/match/deltar"] = m.deltar out_h5["galerkin/match/bpen"] = m.bpen - out_h5["galerkin/match/delta_mn"] = m.delta_mn out_h5["galerkin/match/rpec_eig"] = m.rpec_eig # Per-surface inner-layer ξ_ψ(ψ) (match.f intotsol); ragged grids → one dataset pair per surface. for i in eachindex(m.inner_psi) diff --git a/src/ForceFreeStates/Galerkin/GalerkinStructs.jl b/src/ForceFreeStates/Galerkin/GalerkinStructs.jl index f251dc8c..ae22d4c8 100644 --- a/src/ForceFreeStates/Galerkin/GalerkinStructs.jl +++ b/src/ForceFreeStates/Galerkin/GalerkinStructs.jl @@ -170,7 +170,6 @@ struct GalMatchResult inner_psi::Vector{Vector{Float64}} inner_xi::Vector{Matrix{ComplexF64}} inner_b::Vector{Matrix{ComplexF64}} # per surface, matched inner-layer b^ψ(ψ) on inner_psi (overlap validation) - delta_mn::Matrix{ComplexF64} # (msing × mcoil) coefficient-based Δ_mn (small-solution content at the conventional brackets) inner_params::Vector{InnerLayer.GGJParameters} # per-surface layer coefficients from resist_eval (empty in the ideal limit) rpec_eig::Vector{ComplexF64} residual::Float64 diff --git a/src/GeneralizedPerturbedEquilibrium.jl b/src/GeneralizedPerturbedEquilibrium.jl index 8685747d..2dec9831 100755 --- a/src/GeneralizedPerturbedEquilibrium.jl +++ b/src/GeneralizedPerturbedEquilibrium.jl @@ -521,11 +521,6 @@ function main_from_inputs( # columns; SingularCoupling uses it as the official penetrated field (pointwise midpoint # is only the fallback). Ideal mode: match.bpen is identically zero ⇒ B_pen ≡ 0. pe_intr.inner_bpen = gal_data.match.bpen - # Δ_mn coefficient object (gal paths, ideal + resistive): small-solution content at the - # conventional brackets — replaces the finite-jump evaluation in SingularCoupling. - # (The non-galerkin ideal pathway gets its own Δ object separately; until then shooting - # runs fall back to the finite jump.) - pe_intr.ffs_delta_mn = gal_data.match.delta_mn elseif ctrl.kinetic_factor == 0 # Ideal shooting run: perfect shielding — the penetrated field is exactly zero, same # dispatch as the gal-ideal path. Only KINETIC shooting remains on the pointwise diff --git a/src/PerturbedEquilibrium/PerturbedEquilibriumStructs.jl b/src/PerturbedEquilibrium/PerturbedEquilibriumStructs.jl index 5cfe63a0..a2d7502d 100644 --- a/src/PerturbedEquilibrium/PerturbedEquilibriumStructs.jl +++ b/src/PerturbedEquilibrium/PerturbedEquilibriumStructs.jl @@ -84,12 +84,6 @@ Internal state variables for perturbed equilibrium calculations. # identically zero — perfect shielding. Empty → pointwise fallback, which after the main.jl wiring # only happens for KINETIC shooting runs (outstanding). inner_bpen::Matrix{ComplexF64} = zeros(ComplexF64, 0, 0) - # ForceFreeStates-provided Δ_mn per (match surface × coil-drive column), same basis/contraction - # as inner_bpen, in the C_delta_prime output convention ([∂ψ(𝒥b·∇ψ)]/(2πχ₁) per unit drive). - # DISPATCH: when non-empty it replaces the finite-jump evaluation (which is offset-dependent for - # p₁ ≠ 1/2 and penetration-contaminated in resistive runs). To be populated by ForceFreeStates: - # ideal Δ_coil calculation (in progress) and the layer-consistent resistive Δ (Fortran parity, TBD). - ffs_delta_mn::Matrix{ComplexF64} = zeros(ComplexF64, 0, 0) # True when the PE OdeState is the gal(-matched) solution: ud_store then carries the analytic ξ′ # (incl. resonant series), enabling the exact-derivative path in SingularCoupling. odet_from_gal::Bool = false diff --git a/src/PerturbedEquilibrium/SingularCoupling.jl b/src/PerturbedEquilibrium/SingularCoupling.jl index 1d63aee8..57524fbe 100644 --- a/src/PerturbedEquilibrium/SingularCoupling.jl +++ b/src/PerturbedEquilibrium/SingularCoupling.jl @@ -148,12 +148,10 @@ function compute_singular_coupling_metrics!( state.C_island_width_sq = zeros(ComplexF64, n_rational, numpert_total) state.C_penetrated_area_weighted_field = zeros(ComplexF64, n_rational, numpert_total) state.C_delta_prime = zeros(ComplexF64, n_rational, numpert_total) - # Dispatch flags: ForceFreeStates-provided objects take precedence over the bracket evaluations. - # - inner_bpen: layer-center penetrated field (identically zero in ideal mode) → becomes the - # OFFICIAL C_penetrated_area_weighted_field; pointwise midpoint is only the fallback. - # - ffs_delta_mn: coefficient-based Δ_mn → replaces the finite-jump C_delta_prime when present. + # B_pen dispatch: a ForceFreeStates-provided layer-center penetrated field (identically zero in + # ideal mode) becomes the OFFICIAL C_penetrated_area_weighted_field; the pointwise midpoint + # evaluation below is only the fallback. use_inner_bpen = !isempty(intr.inner_bpen) - use_ffs_delta = !isempty(intr.ffs_delta_mn) use_inner_bpen && (state.C_penetrated_field_inner = zeros(ComplexF64, n_rational, numpert_total)) state.rational_psi = zeros(Float64, n_rational) state.rational_q = zeros(Float64, n_rational) @@ -297,12 +295,6 @@ function compute_singular_coupling_metrics!( state.C_penetrated_area_weighted_field[row, :] = pen_row end - # Δ_mn: prefer the ForceFreeStates coefficient-based object (same contraction); the - # finite-jump value assigned above remains only as the fallback. - if use_ffs_delta && s <= size(intr.ffs_delta_mn, 1) - state.C_delta_prime[row, :] = transpose(C_coeffs) * @view(intr.ffs_delta_mn[s, :]) - end - # LHS normalization audit (#233) — output scalar coordinate-invariance per row: # - Δ' (1/length): the resonant-surface jump in ∂b^ψ/∂ψ over 2π·χ₁; the tearing index is # coordinate-invariant (its sign/zero-crossing set the stability boundary) [Glasser 2016]. From e0468206e71594f1402927461f31249d63b43859 Mon Sep 17 00:00:00 2001 From: Matthew Pharr Date: Thu, 30 Jul 2026 10:08:20 -0400 Subject: [PATCH 23/38] InnerLayer - NEW - Implement cut solution for composite inner-region solution and update related functions --- .../Galerkin/GalerkinAssembly.jl | 5 ++ src/ForceFreeStates/Galerkin/GalerkinMatch.jl | 53 +++++++++++++- .../Galerkin/GalerkinSolution.jl | 70 ++++++++++++++++--- src/ForceFreeStates/Galerkin/GalerkinSolve.jl | 5 +- .../Galerkin/GalerkinStructs.jl | 10 +++ src/ForceFreeStates/Sing.jl | 42 +++++++++++ 6 files changed, 173 insertions(+), 12 deletions(-) diff --git a/src/ForceFreeStates/Galerkin/GalerkinAssembly.jl b/src/ForceFreeStates/Galerkin/GalerkinAssembly.jl index 6d28ed53..3032e6ef 100644 --- a/src/ForceFreeStates/Galerkin/GalerkinAssembly.jl +++ b/src/ForceFreeStates/Galerkin/GalerkinAssembly.jl @@ -22,6 +22,11 @@ @inline sing_get_dua_gal(g::GalSingAsymp, z::Float64) = z >= 0 ? sing_get_dua_res(g.right, z) : (-1) .* sing_get_dua_res(g.left, -z) +# Leading-order ("cut") resonant basis, used to build the cut outer solution that the inner-layer +# solution is added to. Same two-sided convention as `sing_get_ua_gal`. +@inline sing_get_ua_gal_cut(g::GalSingAsymp, z::Float64) = + z >= 0 ? sing_get_ua_res_cut(g.right, z) : sing_get_ua_res_cut(g.left, -z) + @inline sing_get_ua_gal!(out, g::GalSingAsymp, z::Float64) = z >= 0 ? sing_get_ua_res!(out, g.right, z) : sing_get_ua_res!(out, g.left, -z) @inline function sing_get_dua_gal!(out, g::GalSingAsymp, z::Float64) diff --git a/src/ForceFreeStates/Galerkin/GalerkinMatch.jl b/src/ForceFreeStates/Galerkin/GalerkinMatch.jl index 576f96de..435fd17a 100644 --- a/src/ForceFreeStates/Galerkin/GalerkinMatch.jl +++ b/src/ForceFreeStates/Galerkin/GalerkinMatch.jl @@ -174,8 +174,7 @@ function gal_match_rpec(ctrl::ForceFreeStatesControl, equil, intr::ForceFreeStat # Matched inner-layer ξ_ψ(ψ) per surface, per coil drive (match.f intotsol): odd parity weighted by # cin[2i] (cofin(2·ising)), even parity by cin[2i-1] (cofin(2·ising-1)). inner_xi = [inner_odd[i] * transpose(cin[2i, :]) .+ inner_even[i] * transpose(cin[2i-1, :]) for i in 1:msing] - # Matched inner-layer b^ψ(ψ) per surface, per coil drive — the overlap-validation companion of - # inner_xi: in the matching region it must lie on the outer b^ψ eigenfunction. + # Matched inner-layer b^ψ(ψ) per surface, per coil drive. inner_b = [inner_bodd[i] * transpose(cin[2i, :]) .+ inner_beven[i] * transpose(cin[2i-1, :]) for i in 1:msing] end @@ -195,6 +194,56 @@ function gal_match_rpec(ctrl::ForceFreeStatesControl, equil, intr::ForceFreeStat end end + # --- composite inner-region solution: cut outer background + layer (match.f intotsol/intotsol_b) --- + # The layer solution alone carries only the resonant content it resolves; the smooth background + # removed by the cut has to be added back for the inner and outer solutions to overlap in the + # matching region. Without this the inner profile does not graft onto the outer eigenfunction. + if !isempty(inner_psi) + sols_cut = gal_result.solution.xi_cut + isempty(sols_cut) && error("gal_match_rpec: solution.xi_cut is empty — the cut solution is " * + "required to build the composite inner-region solution") + cut_range = gal_result.solution.cut_range + keep = .!gal_result.solution.issing + psi_keep = gal_result.solution.psi[keep] + chi1_c = 2π * equil.psio + for i in 1:msing + m_res = round(Int, nn * sings[i].q) + ires = m_res - intr.mlow + 1 + # Clip the layer to the window where the cut is active; outside it the cut removes + # nothing and the composite is undefined (Fortran writes no points there). + lo, hi = cut_range[i, 1], cut_range[i, 2] + inside = findall(p -> lo <= p <= hi, inner_psi[i]) + if isempty(inside) + @warn "gal_match_rpec: inner layer at ψ=$(round(sings[i].psifac, digits=5)) lies outside the " * + "resonant/extension cells (ψ ∈ [$lo, $hi]); raise gal_dx1/gal_dx2 to overlap the layer" surface = i + continue + end + length(inside) < length(inner_psi[i]) && @info "gal_match_rpec: surface $i inner region clipped to the " * + "cut window ($(length(inside)) of $(length(inner_psi[i])) points)" + inner_psi[i] = inner_psi[i][inside] + inner_xi[i] = inner_xi[i][inside, :] + inner_b[i] = inner_b[i][inside, :] + + # cut outer background for this surface's resonant harmonic, per coil drive + cutmn = Matrix{ComplexF64}(undef, length(psi_keep), mcoil) + for j in 1:mcoil + @views cutmn[:, j] .= sols_cut[ires, keep, 2msing+j] + for isol in 1:2msing + @views cutmn[:, j] .+= cout[isol, j] .* sols_cut[ires, keep, isol] + end + end + itp = cubic_interp(psi_keep, Series(cutmn); extrap=ExtendExtrap()) + buf = Vector{ComplexF64}(undef, mcoil) + hint = Ref(1) + for (ip, psi_p) in enumerate(inner_psi[i]) + itp(buf, psi_p; hint=hint) + singfac = m_res - nn * equil.profiles.q_spline(psi_p) + @views inner_xi[i][ip, :] .+= buf + @views inner_b[i][ip, :] .+= (2π * im * chi1_c * singfac) .* buf + end + end + end + return GalMatchResult(cout, cin, xi, xi_deriv, deltar, bpen, inner_psi, inner_xi, inner_b, inner_params, rpec_eig, residual) end diff --git a/src/ForceFreeStates/Galerkin/GalerkinSolution.jl b/src/ForceFreeStates/Galerkin/GalerkinSolution.jl index 596df080..6ad3a5a1 100644 --- a/src/ForceFreeStates/Galerkin/GalerkinSolution.jl +++ b/src/ForceFreeStates/Galerkin/GalerkinSolution.jl @@ -9,7 +9,10 @@ # derivative (sing_get_dua_gal), never by spline-differentiating ξ at the packed edge. # # Fortran module flags restore_uh/us/ul default .TRUE. (gal.f); the full reconstruction is always -# performed. The cut path (gal.f, used only for the matching diagnostic) is NOT ported here. +# performed. The `cut=true` path swaps the resonant Frobenius series for its leading-order term +# (`sing_get_ua_gal_cut`); the resulting cut solution supplies the regular background that the +# resistive inner-layer solution is added to when forming the composite solution at a rational +# surface (see `gal_match_rpec`). # Sampling points per cell, matching Fortran interp_np_res / interp_np (gal.f): coarse in regular cells, # dense in resonant/extension cells to resolve the near-singular asymptotic series. @@ -37,16 +40,24 @@ const GAL_INTERP_NP_RES = 60 end """ - gal_get_solution(ws, asymps, sings, intr, x, iintvl, icell, isol; want_d=true) + gal_get_solution(ws, asymps, sings, intr, x, iintvl, icell, isol; want_d=true, cut_delta=nothing) -> (sol, dsol, iintvl, icell) Reconstruct the displacement `sol` (length `mpert`) — and, when `want_d`, its analytic radial derivative `dsol = d(sol)/dψ` — at flux `x` for solution column `isol`, from the solved Galerkin -coefficients `ws.sol`. Port of `gal_get_solution` (gal.f, non-cut path). `iintvl`/`icell` +coefficients `ws.sol`. Port of `gal_get_solution` (gal.f). `iintvl`/`icell` are a monotone cell cursor advanced and returned for the next call. `dsol` is `nothing` if `!want_d`. + +Passing `cut_delta` (the `(nsol, 2·msing)` small-solution coefficient matrix, i.e. `GalerkinResult.delta`) +selects the *cut* solution: the leading-order resonant content is subtracted from the reconstruction, +leaving the smooth background that the resistive inner-layer solution is added to when forming the +composite solution at a rational surface. `want_d` is forced off in that case. """ function gal_get_solution(ws::GalWorkspace, asymps::Vector{GalSingAsymp}, sings::Vector{SingType}, - intr::ForceFreeStatesInternal, x::Float64, iintvl::Int, icell::Int, isol::Int; want_d::Bool=true) + intr::ForceFreeStatesInternal, x::Float64, iintvl::Int, icell::Int, isol::Int; want_d::Bool=true, + cut_delta::Union{Nothing,AbstractMatrix{ComplexF64}}=nothing) + + cut_delta === nothing || (want_d = false) msing = length(ws.intvl) - 1 mpert = intr.numpert_total @@ -107,8 +118,9 @@ function gal_get_solution(ws::GalWorkspace, asymps::Vector{GalSingAsymp}, sings: end # large solution (coeff 1) only for the column driving this surface/side (gal.f) - if (isol == 2jsing - 1 && cell.extra == GAL_SIDE_LEFT) || - (isol == 2jsing && cell.extra == GAL_SIDE_RIGHT) + driving = (isol == 2jsing - 1 && cell.extra == GAL_SIDE_LEFT) || + (isol == 2jsing && cell.extra == GAL_SIDE_RIGHT) + if driving if cell.etype == GCT_RES || cell.etype == GCT_EXT || cell.etype == GCT_EXT1 @views sol .+= ua[:, 1, 1] want_d && (@views dsol .+= dua[:, 1, 1]) @@ -118,19 +130,34 @@ function gal_get_solution(ws::GalWorkspace, asymps::Vector{GalSingAsymp}, sings: end end + # Cut solution (gal.f cut_flag block): remove the leading-order resonant content, evaluated at x + # for every resonant cell type. The small coefficient comes from the Δ′ matrix rather than + # `cell.emap` because ext1/ext2 cells carry no emap. + if cut_delta !== nothing + jsol = cell.extra == GAL_SIDE_LEFT ? 2jsing - 1 : 2jsing + ua_cut = sing_get_ua_gal_cut(asymp, x - psi_s) + @views sol .-= cut_delta[isol, jsol] .* ua_cut[:, 2, 1] + driving && (@views sol .-= ua_cut[:, 1, 1]) + end + return sol, dsol, iintvl, icell end """ - gal_output_solution(ws, asymps, sings, intr, profiles, psihigh) -> GalerkinSolution + gal_output_solution(ws, asymps, sings, intr, profiles, psihigh; delta=nothing) -> GalerkinSolution Build the gal-native packed radial grid (inner→edge; `GAL_INTERP_NP_RES` points per resonant/extension cell, `GAL_INTERP_NP` elsewhere, plus the edge point ψ=psihigh) and evaluate ξ AND the analytic ξ′ over it for every solution column. Port of `gal_output_solution` (gal.f); the binary/ASCII writes and the `b_flag` ξ→b^ψ conversion (off by default) are not ported — we keep ξ itself. + +When `delta` (the `(nsol, 2·msing)` small-solution coefficient matrix) is supplied, the cut solution +`xi_cut` is evaluated on the same grid; it is the background of the composite inner-region solution +built by `gal_match_rpec`. """ function gal_output_solution(ws::GalWorkspace, asymps::Vector{GalSingAsymp}, sings::Vector{SingType}, - intr::ForceFreeStatesInternal, profiles, psihigh::Float64) + intr::ForceFreeStatesInternal, profiles, psihigh::Float64; + delta::Union{Nothing,AbstractMatrix{ComplexF64}}=nothing) mpert = intr.numpert_total msing = length(ws.intvl) - 1 @@ -160,6 +187,7 @@ function gal_output_solution(ws::GalWorkspace, asymps::Vector{GalSingAsymp}, sin ngrid = length(psi) xi = zeros(ComplexF64, mpert, ngrid, ws.nsol) xi_deriv = zeros(ComplexF64, mpert, ngrid, ws.nsol) + xi_cut = delta === nothing ? zeros(ComplexF64, 0, 0, 0) : zeros(ComplexF64, mpert, ngrid, ws.nsol) for isol in 1:ws.nsol iintvl = 0 icell = 1 @@ -169,7 +197,31 @@ function gal_output_solution(ws::GalWorkspace, asymps::Vector{GalSingAsymp}, sin @views xi[:, ip, isol] .= sol @views xi_deriv[:, ip, isol] .= dsol end + delta === nothing && continue + iintvl = 0 + icell = 1 + for ip in 1:ngrid + issing[ip] && continue + solc, _, iintvl, icell = gal_get_solution(ws, asymps, sings, intr, psi[ip], iintvl, icell, isol; + cut_delta=delta) + @views xi_cut[:, ip, isol] .= solc + end + end + + # ψ span of the resonant + extension cells flanking each surface: outside it the cut subtracts + # nothing, so the composite inner-region solution is only defined within these bounds + # (Fortran `outs%xext`). + cut_range = zeros(Float64, 0, 0) + if delta !== nothing + cut_range = hcat(fill(Inf, msing), fill(-Inf, msing)) + for iintvl in 0:msing, icell in 1:ws.nx + cell = ws.intvl[iintvl+1].cells[icell] + cell.etype == GCT_NONE && continue + js = _cell_jsing(cell, iintvl) + cut_range[js, 1] = min(cut_range[js, 1], cell.x[1]) + cut_range[js, 2] = max(cut_range[js, 2], cell.x[2]) + end end - return GalerkinSolution(psi, q, issing, xi, xi_deriv) + return GalerkinSolution(psi, q, issing, xi, xi_deriv, xi_cut, cut_range) end diff --git a/src/ForceFreeStates/Galerkin/GalerkinSolve.jl b/src/ForceFreeStates/Galerkin/GalerkinSolve.jl index d3ef0379..ad92efd5 100644 --- a/src/ForceFreeStates/Galerkin/GalerkinSolve.jl +++ b/src/ForceFreeStates/Galerkin/GalerkinSolve.jl @@ -179,7 +179,8 @@ function galerkin_solve(ctrl::ForceFreeStatesControl, equil, ffit::FourFitVars, # Reconstruct ξ(ψ) AND analytic ξ′(ψ) on the gal-native grid (gal_output_solution). ctrl.verbose && @info "Reconstructing outer-region ξ and analytic ξ′ on the gal grid" - solution = gal_output_solution(ws, asymps, sings, intr, equil.profiles, psihigh) + solution = gal_output_solution(ws, asymps, sings, intr, equil.profiles, psihigh; + delta=(ctrl.gal_match_flag ? delta : nothing)) sing_psi = [s.psifac for s in sings] sing_q = [s.q for s in sings] @@ -256,6 +257,8 @@ function write_galerkin!(out_h5, result::GalerkinResult) out_h5["galerkin/solution/issing"] = collect(sol.issing) out_h5["galerkin/solution/xi"] = sol.xi out_h5["galerkin/solution/xi_deriv"] = sol.xi_deriv + isempty(sol.xi_cut) || (out_h5["galerkin/solution/xi_cut"] = sol.xi_cut) + isempty(sol.cut_range) || (out_h5["galerkin/solution/cut_range"] = sol.cut_range) end if result.match !== nothing m = result.match diff --git a/src/ForceFreeStates/Galerkin/GalerkinStructs.jl b/src/ForceFreeStates/Galerkin/GalerkinStructs.jl index ae22d4c8..4d1877af 100644 --- a/src/ForceFreeStates/Galerkin/GalerkinStructs.jl +++ b/src/ForceFreeStates/Galerkin/GalerkinStructs.jl @@ -126,6 +126,14 @@ PerturbedEquilibrium). Populated by `gal_output_solution` (GalerkinSolution.jl). - `psi::Vector{Float64}`, `q::Vector{Float64}` — radial grid (inner→edge) and its safety factor. - `issing::Vector{Bool}` — grid points sitting on a singular surface (skipped; left zero). - `xi::Array{ComplexF64,3}`, `xi_deriv::Array{ComplexF64,3}` — `(mpert, ngrid, nsol)` ξ and dξ/dψ. + - `xi_cut::Array{ComplexF64,3}` — `(mpert, ngrid, nsol)` cut solution: ξ with the leading-order + resonant content removed. This is the smooth background the resistive inner-layer solution is + added to when forming the composite solution at a rational surface, so the inner and outer + solutions overlap in the matching region. Sized `(0,0,0)` when the cut path was not requested. + - `cut_range::Matrix{Float64}` — `(msing, 2)` ψ bounds of the resonant + extension cells flanking + each rational surface. Outside these bounds no resonant content is subtracted, so `xi_cut` + equals `xi` there and the composite solution is undefined; the inner-region solution is only + meaningful inside this window. Sized `(0,0)` when the cut path was not requested. """ struct GalerkinSolution psi::Vector{Float64} @@ -133,6 +141,8 @@ struct GalerkinSolution issing::Vector{Bool} xi::Array{ComplexF64,3} xi_deriv::Array{ComplexF64,3} + xi_cut::Array{ComplexF64,3} + cut_range::Matrix{Float64} end """ diff --git a/src/ForceFreeStates/Sing.jl b/src/ForceFreeStates/Sing.jl index a45b6664..592a50c4 100644 --- a/src/ForceFreeStates/Sing.jl +++ b/src/ForceFreeStates/Sing.jl @@ -907,6 +907,48 @@ Allocating wrapper for [`sing_get_ua_res!`](@ref); returns the big/small columns sing_get_ua_res(sing_asymp::SingAsymptotics, dpsi::Float64) = sing_get_ua_res!(Array{ComplexF64,3}(undef, size(sing_asymp.vmat, 1), 2, 2), sing_asymp, dpsi) +""" + sing_get_ua_res_cut!(out, sing_asymp, dpsi) -> out + +Leading-order ("cut") form of [`sing_get_ua_res!`](@ref): keeps only the `t = 0` term of the +Frobenius series instead of summing all `2·sing_order` terms, then applies the same unshear. + +This is the resonant basis used to build the *cut* outer solution, whose role is to supply the +regular background that the resistive inner-layer solution is added to when forming the composite +solution near a rational surface. Because the full and cut series share the same leading term, the +difference between them is exactly the higher-order content that the layer solution replaces. +""" +function sing_get_ua_res_cut!(out::AbstractArray{ComplexF64,3}, sing_asymp::SingAsymptotics, dpsi::Float64) + vmat = sing_asymp.vmat + N = size(vmat, 1) + sqrtfac = sqrt(dpsi) + ρ = sing_asymp.r1[1] + cbig = sing_asymp.r2[1] + csml = sing_asymp.r2[2] + + @inbounds for k in 1:2, ii in 1:N + out[ii, 1, k] = vmat[ii, cbig, k, 1] + out[ii, 2, k] = vmat[ii, csml, k, 1] + end + + pfac = dpsi^sing_asymp.alpha[1] + @inbounds for k in 1:2, ii in 1:N + out[ii, 1, k] /= pfac + out[ii, 2, k] *= pfac + end + @inbounds out[ρ, 1, 1] /= sqrtfac + @inbounds out[ρ, 2, 1] /= sqrtfac + @inbounds out[ρ, 1, 2] *= sqrtfac + @inbounds out[ρ, 2, 2] *= sqrtfac + return out +end + +""" +Allocating wrapper for [`sing_get_ua_res_cut!`](@ref); returns the big/small columns as `(N, 2, 2)`. +""" +sing_get_ua_res_cut(sing_asymp::SingAsymptotics, dpsi::Float64) = + sing_get_ua_res_cut!(Array{ComplexF64,3}(undef, size(sing_asymp.vmat, 1), 2, 2), sing_asymp, dpsi) + """ Allocating wrapper for [`sing_get_dua_res!`](@ref); returns the big/small columns as `(N, 2, 2)`. """ From fe544d7a1c07890a6e03d576b0cefd109679ac95 Mon Sep 17 00:00:00 2001 From: Matthew Pharr Date: Thu, 30 Jul 2026 12:18:26 -0400 Subject: [PATCH 24/38] InnerLayer - REFACTOR - Move inner-layer profile solve behind the solve_inner_profile interface --- src/ForceFreeStates/ForceFreeStatesStructs.jl | 7 +++ src/ForceFreeStates/Galerkin/GalerkinMatch.jl | 51 +++++++------------ src/InnerLayer/GGJ/GGJ.jl | 2 +- src/InnerLayer/GGJ/GGJParameters.jl | 6 +++ src/InnerLayer/GGJ/Galerkin.jl | 13 +++++ src/InnerLayer/GGJ/Ray.jl | 27 ++++++++++ src/InnerLayer/InnerLayer.jl | 2 +- src/InnerLayer/InnerLayerInterface.jl | 24 +++++++++ test/runtests_innerlayer.jl | 23 +++++++++ 9 files changed, 121 insertions(+), 34 deletions(-) diff --git a/src/ForceFreeStates/ForceFreeStatesStructs.jl b/src/ForceFreeStates/ForceFreeStatesStructs.jl index 759a4d62..62698148 100644 --- a/src/ForceFreeStates/ForceFreeStatesStructs.jl +++ b/src/ForceFreeStates/ForceFreeStatesStructs.jl @@ -322,6 +322,13 @@ A mutable struct containing control parameters for stability analysis, set by th gal_match_flag::Bool = false # enable the RPEC inner-layer matching: solve the coil-driven matched ξ(ψ) from the gal Δ′ + the inner-layer Δ(Q). Requires gal_rpec_flag=true. gal_ideal_flag::Bool = false # within the match, build the IDEAL solution: skip the inner-layer Δ, use bare coil columns (cout=0). Mirrors Fortran rmatch coil%ideal_flag (the EL reference). eta/rho/rotation ignored. gal_inner_solver::String = "ray" # inner-layer Δ backend for the match: "ray" (rotated-contour collocation, certified Δ at the optimal θ = arg(Q)/4; robust for |Q| ≳ 1) or "galerkin" (Hermite-cubic inps; drifts for |Q| ≳ 1) + # Inner-layer "galerkin" backend knobs (used only when gal_inner_solver = "galerkin"; the "ray" + # backend is self-tuning). Defaults match the Fortran rmatch deltac/inps reference (DELTAC_LIST). + gal_inner_xfac::Float64 = 10.0 # asymptotic-matching radius multiplier (inps_xfac: xmax × 10) + gal_inner_nx::Int = 1280 # inner-layer grid cells (128 · xfac in the reference) + gal_inner_nq::Int = 5 # quadrature order per cell + gal_inner_cutoff::Int = 5 # cells carrying the large solution as driving term + gal_inner_kmax::Int = 8 # large-x asymptotic series order (↔ order_pow) gal_eta::Vector{Float64} = Float64[] # per-surface resistivity η (length msing, core→edge); Fortran rmatch `eta` gal_rho::Vector{Float64} = Float64[] # per-surface mass density ρ [kg/m³] (length msing, core→edge); Fortran rmatch `massden` gal_rotation::Vector{Float64} = Float64[] # per-surface rotation frequency f [Hz] (length msing, core→edge); forced eigenvalue γ_s = 2πi·n·f. Fortran rmatch `rotation` diff --git a/src/ForceFreeStates/Galerkin/GalerkinMatch.jl b/src/ForceFreeStates/Galerkin/GalerkinMatch.jl index 435fd17a..e9d91d85 100644 --- a/src/ForceFreeStates/Galerkin/GalerkinMatch.jl +++ b/src/ForceFreeStates/Galerkin/GalerkinMatch.jl @@ -84,44 +84,31 @@ function gal_match_rpec(ctrl::ForceFreeStatesControl, equil, intr::ForceFreeStat inner_params[i] = params γ = 2π * im * nn * ctrl.gal_rotation[i] # forced eigenvalue; gal_rotation is f [Hz], γ = 2πi·n·f rpec_eig[i] = γ - # gal_inner_solver = "ray" (default) sources the inner layer from the rotated-ray - # backend: the certified Δ comes from the optimal-contour solve at θ = arg(Q)/4 (robust - # for |Q| ≳ 1, where the galerkin inner solver drifts), and the profile quantities - # (pen, inner_xi, inner_b) from a θ=0 re-solve on the real axis — valid at physical - # (RPEC) |Q| since the on-axis pseudo-resonance is a regular point resolved by the BVP - # refinement. The θ=0-vs-optimal-θ Δ agreement doubles as a runtime certificate (two - # maximally different contours through the same entire solutions). - if ctrl.gal_inner_solver == "ray" - Q = InnerLayer.GGJ.inner_Q(params, γ) - rr = InnerLayer.GGJ.solve_ray(params, Q) # certified Δ (optimal θ) - deltar[i, 1] = rr.Δ[1] - deltar[i, 2] = rr.Δ[2] - r0 = InnerLayer.GGJ.solve_ray(params, Q; θ=0.0) # real-axis profile solve - relΔ = abs(r0.Δ[1] - rr.Δ[1]) / max(abs(rr.Δ[1]), 1e-300) - relΔ > 1e-3 && @warn "GGJ ray: θ=0 profile solve disagrees with certified Δ" surface = i relΔ - p0 = InnerLayer.GGJ.solution_profile(r0) - profΨ, profΞ, xg = p0.Ψ, p0.Ξ, p0.s + # gal_inner_solver = "ray" (default) uses the rotated-ray backend (certified optimal-θ Δ + # + θ=0 real-axis profile re-solve with runtime certificate — see the InnerLayer + # solve_inner_profile(::GGJModel{:ray}, ...) docstring); "galerkin" uses the Hermite-FEM + # real-axis solve with the ctrl.gal_inner_* knobs (defaults match the Fortran rmatch + # deltac/inps reference). + inner = if ctrl.gal_inner_solver == "ray" + InnerLayer.solve_inner_profile(InnerLayer.GGJModel(; solver=:ray), params, γ) else - # Inner-solve knobs matched to the Fortran rmatch deltac/inps reference (DELTAC_LIST): inps - # basis, inps_xfac=10 (xmax×10, grid nx = 128·10 = 1280), nq=5, cutoff=5, order_pow=8 (↔ kmax). - Δ, _, prof, _ = InnerLayer.GGJ.solve_inner_profile(params, γ; xfac=10.0, nx=1280, nq=5, cutoff=5, kmax=8) # (Δ₁, Δ₂) in deltac.f convention - deltar[i, 1] = Δ[1] - deltar[i, 2] = Δ[2] - profΨ, profΞ, xg = prof.Ψ, prof.Ξ, prof.x + InnerLayer.solve_inner_profile(InnerLayer.GGJModel(; solver=:galerkin), params, γ; + xfac=ctrl.gal_inner_xfac, nx=ctrl.gal_inner_nx, nq=ctrl.gal_inner_nq, cutoff=ctrl.gal_inner_cutoff, kmax=ctrl.gal_inner_kmax) end - x0i = InnerLayer.GGJ.x0(params) - # Amplitude rescale: inner solutions are normalized in X = v1·δψ/x0 (inner_psi below); - # converting a big-branch (μ₋ = −1/2−p1) amplitude to the outer δψ-normalization gives - # resc = (v1/x0)^(−μ₋), the companion of rescale_delta's (v1/x0)^(2p1) = (v1/x0)^(μ₊−μ₋). - resc = (params.v1 / x0i)^(0.5 + InnerLayer.GGJ.p1(params)) + deltar[i, 1] = inner.Δ[1] + deltar[i, 2] = inner.Δ[2] + profΨ, profΞ, xg = inner.Ψ, inner.Ξ, inner.x + # Amplitude rescale: inner profiles are normalized in X = v1·δψ/x0 (inner_psi below); + # inner.rescale converts a big-branch amplitude to the outer δψ-normalization. + resc = inner.rescale # b-field scaling, derived from the code's own outer convention (SingularCoupling): - # b_m = 2πi·χ₁·(m−nq)·ξ_m, m−nq = −n·q′·δψ, δψ = (x0/v1)·X, resc·Ξ(X) ↔ ξ_m, + # b_m = 2πi·χ₁·(m−nq)·ξ_m, m−nq = −n·q′·δψ, δψ = dψdx·X, resc·Ξ(X) ↔ ξ_m, # and the far-field identity Ψ = X·Ξ (GWP2016 Eq. 16; Ψ is the normal-field variable, Eq. A17): - # b_m = −2πi·χ₁·n·q′·(x0/v1)·resc·Ψ. - scale = -2π * chi1 * im * nn * sings[i].q1 * x0i / params.v1 + # b_m = −2πi·χ₁·n·q′·dψdx·resc·Ψ. + scale = -2π * chi1 * im * nn * sings[i].q1 * inner.dψdx pen[i, 1] = scale * profΨ[1, 1] * resc # layer center X=0, parity 1 (Ψ(0)≠0) pen[i, 2] = scale * profΨ[1, 2] * resc # parity 2 (Ψ(0)=0 ⇒ ~0) - xvar = xg .* (x0i / params.v1) # inner X → ψ-distance (deltac.f:1822) + xvar = xg .* inner.dψdx # inner X → ψ-distance (deltac.f:1822) inner_psi[i] = vcat(reverse(sings[i].psifac .- xvar), sings[i].psifac .+ xvar) inner_odd[i] = resc .* vcat(reverse(.-profΞ[:, 1]), profΞ[:, 1]) # comp 2, parity 1 (odd: −left,+right) inner_even[i] = resc .* vcat(reverse(profΞ[:, 2]), profΞ[:, 2]) # comp 2, parity 2 (even) diff --git a/src/InnerLayer/GGJ/GGJ.jl b/src/InnerLayer/GGJ/GGJ.jl index 870eb551..3a69ce79 100644 --- a/src/InnerLayer/GGJ/GGJ.jl +++ b/src/InnerLayer/GGJ/GGJ.jl @@ -39,7 +39,7 @@ using Random using Printf using DoubleFloats: Double64 -import ..InnerLayerModel, ..solve_inner +import ..InnerLayerModel, ..solve_inner, ..solve_inner_profile """ GGJModel{S} <: InnerLayerModel diff --git a/src/InnerLayer/GGJ/GGJParameters.jl b/src/InnerLayer/GGJ/GGJParameters.jl index 237a3c61..579d2e04 100644 --- a/src/InnerLayer/GGJ/GGJParameters.jl +++ b/src/InnerLayer/GGJ/GGJParameters.jl @@ -107,3 +107,9 @@ function rescale_delta(Δ::AbstractVector, p::GGJParameters) fac = s^(2.0 * pp / 3.0) * p.v1^(2.0 * pp) return SVector{2,ComplexF64}(Δ[1] * fac, Δ[2] * fac) end + +# Profile conversions shared by the solve_inner_profile backends: δψ per unit inner +# coordinate X = v₁·δψ/X₀, and the big-branch (μ₋ = −1/2−p₁) amplitude rescale to the +# outer δψ-normalization, resc = (v₁/X₀)^(−μ₋) — the companion of rescale_delta's +# (v₁/X₀)^(2p₁) = (v₁/X₀)^(μ₊−μ₋). +_profile_conversions(p::GGJParameters) = (; dψdx=x0(p) / p.v1, rescale=(p.v1 / x0(p))^(0.5 + p1(p))) diff --git a/src/InnerLayer/GGJ/Galerkin.jl b/src/InnerLayer/GGJ/Galerkin.jl index 637c4e28..167bbe6d 100644 --- a/src/InnerLayer/GGJ/Galerkin.jl +++ b/src/InnerLayer/GGJ/Galerkin.jl @@ -830,6 +830,19 @@ function solve_inner_profile(params::GGJParameters, γ::Number; return Δ, Q, _solution_profile(ws), (; xmax=xmax_info.xmax, x0=x0(params), sfac=sfac(params)) end +""" + solve_inner_profile(::GGJModel{:galerkin}, params::GGJParameters, γ::Number; kwargs...) + -> (; Δ, x, Ψ, Ξ, dψdx, rescale) + +Hermite-FEM implementation of the [`solve_inner_profile`](@ref) interface: +real-axis solve, so `Δ` and the profiles come from the same solution. Same +numerics/kwargs as `solve_inner(GGJModel(; solver=:galerkin), ...)`. +""" +function solve_inner_profile(::GGJModel{:galerkin}, params::GGJParameters, γ::Number; kwargs...) + Δ, _, prof, _ = solve_inner_profile(params, γ; kwargs...) + return (; Δ=Δ, x=prof.x, Ψ=prof.Ψ, Ξ=prof.Ξ, _profile_conversions(params)...) +end + """ solve_inner(::GGJModel{:galerkin}, params::GGJParameters, γ::Number; kmax::Int=8, nx::Int=512, nq::Int=4, pfac::Float64=1.0, diff --git a/src/InnerLayer/GGJ/Ray.jl b/src/InnerLayer/GGJ/Ray.jl index b398933b..7d985f4e 100644 --- a/src/InnerLayer/GGJ/Ray.jl +++ b/src/InnerLayer/GGJ/Ray.jl @@ -935,6 +935,33 @@ function solve_inner(::GGJModel{:ray}, params::GGJParameters, γ::Number; kwargs return res.Δ end +""" + solve_inner_profile(::GGJModel{:ray}, params::GGJParameters, γ::Number; + npc=8, certify_rtol=1e-3, kwargs...) + -> (; Δ, x, Ψ, Ξ, dψdx, rescale, certΔ) + +Rotated-ray implementation of the [`solve_inner_profile`](@ref) interface. The +certified `Δ` comes from the optimal-contour solve at θ = arg(Q)/4 (robust for +|Q| ≳ 1, where real-axis methods drift); the profiles come from a θ = 0 +re-solve on the real axis, valid at physical (RPEC) |Q| since the on-axis +pseudo-resonance is a regular point resolved by the BVP refinement. The +θ=0-vs-optimal-θ Δ agreement doubles as a runtime certificate (two maximally +different contours through the same entire solutions): the relative mismatch +is returned as `certΔ` and warns above `certify_rtol`. `npc` sets the profile +points per mesh cell; extra keywords forward to both [`solve_ray`](@ref) calls +(θ is fixed by the method — do not pass it). +""" +function solve_inner_profile(::GGJModel{:ray}, params::GGJParameters, γ::Number; + npc::Int=8, certify_rtol::Float64=1e-3, kwargs...) + Q = inner_Q(params, γ) + rr = solve_ray(params, Q; kwargs...) # certified Δ (optimal θ) + r0 = solve_ray(params, Q; θ=0.0, kwargs...) # real-axis profile solve + certΔ = abs(r0.Δ[1] - rr.Δ[1]) / max(abs(rr.Δ[1]), 1e-300) + certΔ > certify_rtol && @warn "GGJ ray: θ=0 profile solve disagrees with certified Δ" ising = params.ising Q certΔ + prof = solution_profile(r0; npc=npc) + return (; Δ=rr.Δ, x=prof.s, Ψ=prof.Ψ, Ξ=prof.Ξ, _profile_conversions(params)..., certΔ=certΔ) +end + """ evaluate_solution(res::RaySolveResult, s::Real; isol=1) -> SVector{6,ComplexF64} diff --git a/src/InnerLayer/InnerLayer.jl b/src/InnerLayer/InnerLayer.jl index 03b195ce..112843b0 100644 --- a/src/InnerLayer/InnerLayer.jl +++ b/src/InnerLayer/InnerLayer.jl @@ -21,7 +21,7 @@ import .GGJ: solve_ray, RaySolveResult, pick_smax, physical_ua_dua import .GGJ: delta_convergence, solution_profile, asymptotic_profile, q4_surface_benchmark # SLAYER imports go here -export InnerLayerModel, solve_inner +export InnerLayerModel, solve_inner, solve_inner_profile export GGJ, GGJModel, GGJParameters export build_asymptotics, evaluate_asymptotics, pick_xmax, InnerAsymptoticsCache export mercier_di, mercier_dr, inner_Q, rescale_delta diff --git a/src/InnerLayer/InnerLayerInterface.jl b/src/InnerLayer/InnerLayerInterface.jl index 3c6e9010..a4d6f6d1 100644 --- a/src/InnerLayer/InnerLayerInterface.jl +++ b/src/InnerLayer/InnerLayerInterface.jl @@ -27,3 +27,27 @@ imposed at the rational surface, X = 0). They are the Δ_{j,±}(γ) of Glasser, Wang & Park, Phys. Plasmas **23**, 112506 (2016), Eqs. (34)–(35). """ function solve_inner end + +""" + solve_inner_profile(model::InnerLayerModel, params, γ::Number; kwargs...) + -> (; Δ, x, Ψ, Ξ, dψdx, rescale, ...) + +Compute the inner-layer matching data **and** the reconstructed layer field +profiles for the given `model` — everything an outer↔inner matching driver +needs from the layer, so drivers never touch model internals. Returns a named +tuple with at least: + + - `Δ` — the same `(Δ_odd, Δ_even)` matching data as [`solve_inner`](@ref) + - `x` — real ascending grid in the model's stretched inner coordinate, + `x ≥ 0` with the rational surface at `x = 0` + - `Ψ`, `Ξ` — `length(x) × 2` profiles, columns (odd, even) parity, in the + model's inner normalization: `Ψ` the normal-field + (reconnected-flux) variable, `Ξ` the displacement + - `dψdx` — conversion to poloidal-flux distance, `δψ = dψdx · x` + - `rescale` — amplitude factor converting the inner-normalized profiles to + the outer δψ-normalized convention (companion of the Δ rescale) + +Concrete models may return additional diagnostic fields (e.g. a solve-quality +certificate). Solver-knob keywords are model-specific. +""" +function solve_inner_profile end diff --git a/test/runtests_innerlayer.jl b/test/runtests_innerlayer.jl index 0b146104..7b087216 100644 --- a/test/runtests_innerlayer.jl +++ b/test/runtests_innerlayer.jl @@ -141,3 +141,26 @@ end @test maximum(conv.spread) < 1e-4 # honest error bar is small here end end + +@testset "solve_inner_profile interface (matching-driver contract)" begin + p = IL.glasser_wang_2020_eq55() + γ = 0.1234 * GGJ.q0(p) + for model in (IL.GGJModel(; solver=:ray), IL.GGJModel(; solver=:galerkin)) + prof = IL.solve_inner_profile(model, p, γ) + # Δ agrees with the plain matching solve of the same backend (identical solve path). + @test prof.Δ ≈ IL.solve_inner(model, p, γ) rtol = 1e-12 + # Real ascending inner-coordinate grid from the rational surface, profiles npts × 2. + @test issorted(prof.x) + @test prof.x[1] ≈ 0 atol = 1e-12 + @test size(prof.Ψ) == (length(prof.x), 2) && size(prof.Ξ) == (length(prof.x), 2) + @test all(isfinite, prof.Ψ) && all(isfinite, prof.Ξ) + # Parity at the layer center: Ψ(0) ≠ 0 odd-parity column, Ψ(0) = 0 even-parity column. + @test abs(prof.Ψ[1, 2]) < 1e-6 * abs(prof.Ψ[1, 1]) + # Conversion factors match their GGJ definitions. + @test prof.dψdx ≈ GGJ.x0(p) / p.v1 + @test prof.rescale ≈ (p.v1 / GGJ.x0(p))^(0.5 + GGJ.p1(p)) + end + # Ray backend certificate: at real Q the optimal contour is θ = 0, so the two solves coincide. + ray = IL.solve_inner_profile(IL.GGJModel(; solver=:ray), p, γ) + @test ray.certΔ < 1e-12 +end From 3e93ef2e5a6074960567546317e40efb131910ce Mon Sep 17 00:00:00 2001 From: Matthew Pharr Date: Thu, 30 Jul 2026 12:28:48 -0400 Subject: [PATCH 25/38] ForceFreeStates - CLEANUP - Remove unused ResistiveMatch.jl file --- src/ForceFreeStates/ResistiveMatch.jl | 108 -------------------------- 1 file changed, 108 deletions(-) delete mode 100644 src/ForceFreeStates/ResistiveMatch.jl diff --git a/src/ForceFreeStates/ResistiveMatch.jl b/src/ForceFreeStates/ResistiveMatch.jl deleted file mode 100644 index d42d224c..00000000 --- a/src/ForceFreeStates/ResistiveMatch.jl +++ /dev/null @@ -1,108 +0,0 @@ -# ResistiveMatch.jl -# -# This file does the "matching" step for the resistive (tearing) plasma response. -# -# The plasma has an outer region (ideal MHD, which STRIDE already solves) and a very -# thin inner layer right at each rational surface, where resistivity actually matters. Neither piece -# is complete by itself; they have to agree where they meet. Computing that agreement is what this -# file does, following Wang et al. 2020 (Phys. Plasmas 27, 122509), Eq. (11): -# -# C = -(Δ_out - Δ_in(i2πf))^{-1} · Δ_coil -# -# In layman's terms: -# Δ_out = how the ideal outer plasma responds (comes from STRIDE's Δ' solve) -# Δ_in = how the resistive inner layer responds; the "i2πf" is where plasma rotation enters -# Δ_coil = the push from the external coil (this is what drives everything) -# C = the matched coefficients we solve for -# -# This is the same calculation the Fortran code performs in RDCON's gal_match_rpec (match.f), done here in -# Julia on the STRIDE path. -# -# Δ_out and Δ_coil have to be in the SAME units before they can be subtracted, so both use STRIDE's RAW -# coefficients (no extra scaling). A factor called snorm (= |n·q'|^α) appears elsewhere when comparing to the -# Galerkin code, but that is only a unit conversion for plotting, not part of the physics, so it is omitted here. - -"What comes out of the match (Wang 2020 Eq. 11)." -struct ResonantMatchResult - cout::Matrix{ComplexF64} # the outer coefficients we solved for (2·msing × ncoil) - cin::Matrix{ComplexF64} # the inner-layer coefficients (2·msing × ncoil) - deltar::Matrix{ComplexF64} # the inner-layer Δ at each surface (msing × 2) - rpec_eig::Vector{ComplexF64} # the frequency each layer "sees": γ = 2πi·n·f, one per surface - reconnected_flux::Matrix{ComplexF64} # the reconnected (resonant) flux, the physical answer we want - residual::Float64 # how well the linear solve worked (should be tiny, ~1e-16) -end - -""" - resonant_match_rpec(delta_out_raw, delta_coil_raw, sings, equil, intr, ctrl) -> ResonantMatchResult - -Build and solve the matching system (Wang 2020 Eq. 11). It takes STRIDE's raw outer response -`delta_out_raw` and raw coil drive `delta_coil_raw`, gets the resistive inner-layer Δ at each surface -(resist_eval → InnerLayer.solve_inner), then solves for the matched coefficients. -Shapes: `delta_out_raw` is (2msing × 2msing); `delta_coil_raw` is (2msing × ncoil). -""" -function resonant_match_rpec(delta_out_raw::AbstractMatrix, delta_coil_raw::AbstractMatrix, - sings::Vector{SingType}, equil::Equilibrium.PlasmaEquilibrium, - intr::ForceFreeStatesInternal, ctrl::ForceFreeStatesControl) - - msing = size(delta_out_raw, 1) ÷ 2 - ncoil = size(delta_coil_raw, 2) - nn = intr.nlow - - # Safety checks: make sure the surfaces line up with the outer blocks before I trust anything. If - # they don't match, the wrong inner-layer Δ would quietly get attached to the wrong surface, so I - # error out loudly instead of getting a silently-wrong answer. - size(delta_out_raw) == (2msing, 2msing) || - error("resonant_match_rpec: delta_out_raw is $(size(delta_out_raw)), expected (2msing, 2msing)") - length(sings) == msing || - error("resonant_match_rpec: sings length $(length(sings)) != msing=$msing (surface-set mismatch)") - size(delta_coil_raw, 1) == 2msing || - error("resonant_match_rpec: delta_coil_raw has $(size(delta_coil_raw, 1)) rows, expected 2msing=$(2msing)") - - if ctrl.gal_ideal_flag # "ideal" case: no resistivity, so no reconnection → cout is 0 - # with no inner layer there's nothing to reconnect, so the flux is just the bare coil drive - return ResonantMatchResult(zeros(ComplexF64, 2msing, ncoil), zeros(ComplexF64, 2msing, ncoil), - zeros(ComplexF64, msing, 2), zeros(ComplexF64, msing), Matrix{ComplexF64}(delta_coil_raw), 0.0) - end - for (nm, v) in (("gal_eta", ctrl.gal_eta), ("gal_rho", ctrl.gal_rho), ("gal_rotation", ctrl.gal_rotation)) - length(v) == msing || error("resonant_match_rpec: $nm has length $(length(v)), expected msing=$msing (one per surface, core→edge)") - end - - # --- get the resistive inner-layer Δ at each rational surface --- - inner = InnerLayer.GGJModel(; solver=:galerkin) - deltar = zeros(ComplexF64, msing, 2) - rpec_eig = zeros(ComplexF64, msing) - for i in 1:msing - params = resist_eval(sings[i], equil, intr; eta=ctrl.gal_eta[i], rho=ctrl.gal_rho[i], gamma=ctrl.gal_gamma, ising=i) - γ = 2π * im * nn * ctrl.gal_rotation[i] # the frequency this layer sees (plasma rotation enters here) - rpec_eig[i] = γ - Δ = InnerLayer.solve_inner(inner, params, γ) - deltar[i, 1] = Δ[1] - deltar[i, 2] = Δ[2] - end - - # --- build the big linear system mat · [cout; cin] = rmat and solve it (this IS Eq. 11 written out) --- - mat = zeros(ComplexF64, 4msing, 4msing) - rmat = zeros(ComplexF64, 4msing, ncoil) - @views mat[2msing+1:4msing, 1:2msing] .= transpose(delta_out_raw) # the Δ_out part of the system - @views rmat[2msing+1:4msing, :] .= .-delta_coil_raw # the −Δ_coil drive goes on the right-hand side - for ising in 1:msing - # fill in one surface at a time: top rows tie cout to cin, bottom rows carry the inner-layer Δ - idx1 = 2ising - 1; idx2 = 2ising - idx3 = idx1 + 2msing; idx4 = idx2 + 2msing - d1 = deltar[ising, 1]; d2 = deltar[ising, 2] - mat[idx1, idx1] = 1; mat[idx2, idx2] = 1 - mat[idx1, idx3] = -1; mat[idx1, idx4] = 1 - mat[idx2, idx3] = -1; mat[idx2, idx4] = -1 - mat[idx3, idx3] = -d1; mat[idx3, idx4] = d2 # the Δ_in entries for this surface - mat[idx4, idx3] = -d1; mat[idx4, idx4] = -d2 - end - - cof = mat \ rmat # solve the system - residual = norm(mat * cof - rmat) / max(norm(rmat), 1e-300) # double-check the solve is good - cout = cof[1:2msing, :] - cin = cof[2msing+1:4msing, :] - # The reconnected flux is the coil drive plus the plasma's own outer response to the matched cout. - # It also equals -Δ_in·cin, so the inner-layer side agrees — a useful sanity check. - reconnected_flux = delta_coil_raw .+ transpose(delta_out_raw) * cout - return ResonantMatchResult(cout, cin, deltar, rpec_eig, reconnected_flux, residual) -end From 7393bb1393f1aeaabb1b6237855670c061612a3c Mon Sep 17 00:00:00 2001 From: Matthew Pharr Date: Thu, 30 Jul 2026 13:31:54 -0400 Subject: [PATCH 26/38] Galerkin/InnerLayer - CLEANUP - Remove outdated comments and clarify inner-layer matching function documentation --- src/ForceFreeStates/Galerkin/GalerkinSolve.jl | 3 --- src/ForceFreeStates/Galerkin/GalerkinStructs.jl | 4 ++-- src/InnerLayer/GGJ/Ray.jl | 2 +- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/ForceFreeStates/Galerkin/GalerkinSolve.jl b/src/ForceFreeStates/Galerkin/GalerkinSolve.jl index ad92efd5..aa033a36 100644 --- a/src/ForceFreeStates/Galerkin/GalerkinSolve.jl +++ b/src/ForceFreeStates/Galerkin/GalerkinSolve.jl @@ -276,9 +276,6 @@ function write_galerkin!(out_h5, result::GalerkinResult) out_h5["galerkin/match/inner/b_$i"] = m.inner_b[i] end out_h5["galerkin/match/residual"] = m.residual - # Per-surface GGJ inner-layer coefficients (resist_eval), replacing the old - # GAL_DUMP_INNER temp-CSV hook: everything needed to reconstruct the layer - # problem (E,F,G,H,K,M) and its scales (taua, taur ⇒ S = taur/taua, v1). if !isempty(m.inner_params) for f in (:E, :F, :G, :H, :K, :M, :taua, :taur, :v1) out_h5["galerkin/match/inner_params/$(f)"] = [getfield(pp, f) for pp in m.inner_params] diff --git a/src/ForceFreeStates/Galerkin/GalerkinStructs.jl b/src/ForceFreeStates/Galerkin/GalerkinStructs.jl index 4d1877af..cbfd59d1 100644 --- a/src/ForceFreeStates/Galerkin/GalerkinStructs.jl +++ b/src/ForceFreeStates/Galerkin/GalerkinStructs.jl @@ -179,8 +179,8 @@ struct GalMatchResult bpen::Matrix{ComplexF64} inner_psi::Vector{Vector{Float64}} inner_xi::Vector{Matrix{ComplexF64}} - inner_b::Vector{Matrix{ComplexF64}} # per surface, matched inner-layer b^ψ(ψ) on inner_psi (overlap validation) - inner_params::Vector{InnerLayer.GGJParameters} # per-surface layer coefficients from resist_eval (empty in the ideal limit) + inner_b::Vector{Matrix{ComplexF64}} # per surface, matched inner-layer b^ψ(ψ) on inner_psi + inner_params::Vector{InnerLayer.GGJParameters} # per-surface layer coefficients from resist_eval rpec_eig::Vector{ComplexF64} residual::Float64 end diff --git a/src/InnerLayer/GGJ/Ray.jl b/src/InnerLayer/GGJ/Ray.jl index 7d985f4e..47097c68 100644 --- a/src/InnerLayer/GGJ/Ray.jl +++ b/src/InnerLayer/GGJ/Ray.jl @@ -763,7 +763,7 @@ end smax_tol=1e-9, refine_tol=1e-8, max_rounds=8, max_cells=6000, verbose=false, kwargs...) -> RaySolveResult -Solve the GGJ inner-layer matching problem on the rotated ray x = e^{iθ}s. +Solve the GGJ inner-layer problem on the rotated ray x = e^{iθ}s. `Δ` follows the deltac output convention (swap + `rescale_delta`), directly comparable to the `:galerkin` backend. θ = 0 reproduces a real-axis solve; `S` defaults to the inps series-residual radius (`pick_smax`); `p` is the From 092dfa01a27005f6f1518d794660f2b02020aca3 Mon Sep 17 00:00:00 2001 From: Via Weber Date: Thu, 30 Jul 2026 10:40:06 -0400 Subject: [PATCH 27/38] FORCEFREESTATES - MINOR - finished benchmarking truncation and resistivity scans for delta_coil with corrected edge harmonic --- .../scripts/deltacoil_metrics.jl | 10 ++++- .../scripts/deltacoil_svd_tol.jl | 9 ++++- .../scripts/plot_deltacoil_dmlim_fine.py | 4 +- .../scripts/plot_deltacoil_metrics.py | 6 +-- .../scripts/plot_deltacoil_svd.py | 10 ++--- .../scripts/plot_deltacoil_vs_rdcon.py | 4 +- src/ForceFreeStates/ForceFreeStatesStructs.jl | 1 + src/ForceFreeStates/Riccati.jl | 39 +++++++------------ src/GeneralizedPerturbedEquilibrium.jl | 4 ++ 9 files changed, 46 insertions(+), 41 deletions(-) diff --git a/benchmarks/deltacoil_sensitivity/scripts/deltacoil_metrics.jl b/benchmarks/deltacoil_sensitivity/scripts/deltacoil_metrics.jl index e9b2f172..1d1c8a72 100644 --- a/benchmarks/deltacoil_sensitivity/scripts/deltacoil_metrics.jl +++ b/benchmarks/deltacoil_sensitivity/scripts/deltacoil_metrics.jl @@ -35,9 +35,15 @@ function run_one(tag, patches) dst = joinpath(dir, basename(eqfile)); islink(dst) || symlink(realpath(eqsrc), dst) toml = base_toml for (r, s) in patches; toml = replace(toml, r => s); end - toml = replace(toml, r"gal_match_flag\s*=\s*\w+" => "gal_match_flag = false") toml = replace(toml, r"eq_filename\s*=\s*\"[^\"]+\"" => "eq_filename = \"$(basename(eqfile))\"") - toml = replace(toml, r"HDF5_filename\s*=\s*\"[^\"]+\"" => "HDF5_filename = \"$tag.h5\"") + # Inject FFS controls after the section header: examples may omit these keys, so a plain regex + # replace is not enough — strip any existing, then inject (force_termination stops after stability; + # per-tag HDF5_filename is the file we read back). set_psilim_via_dmlim/dmlim/singfac stay in `patches`. + for key in ("force_termination", "HDF5_filename", "gal_match_flag") + toml = replace(toml, Regex("(?m)^[ \\t]*$key[ \\t]*=.*\\n") => "") + end + toml = replace(toml, "[ForceFreeStates]" => + "[ForceFreeStates]\nforce_termination = true\nHDF5_filename = \"$tag.h5\"\ngal_match_flag = false"; count=1) write(joinpath(dir, "gpec.toml"), toml) @info ">>> $tag" GeneralizedPerturbedEquilibrium.main([dir]) diff --git a/benchmarks/deltacoil_sensitivity/scripts/deltacoil_svd_tol.jl b/benchmarks/deltacoil_sensitivity/scripts/deltacoil_svd_tol.jl index 04cf99b2..3e5bc058 100644 --- a/benchmarks/deltacoil_sensitivity/scripts/deltacoil_svd_tol.jl +++ b/benchmarks/deltacoil_sensitivity/scripts/deltacoil_svd_tol.jl @@ -32,9 +32,14 @@ function run_one(tag, patches) dst = joinpath(dir, basename(eqfile)); islink(dst) || symlink(realpath(eqsrc), dst) toml = base_toml for (r, s) in patches; toml = replace(toml, r => s); end - toml = replace(toml, r"gal_match_flag\s*=\s*\w+" => "gal_match_flag = false") toml = replace(toml, r"eq_filename\s*=\s*\"[^\"]+\"" => "eq_filename = \"$(basename(eqfile))\"") - toml = replace(toml, r"HDF5_filename\s*=\s*\"[^\"]+\"" => "HDF5_filename = \"$tag.h5\"") + # Inject FFS controls after the section header: examples may omit these keys, so a plain regex + # replace is not enough - strip any existing, then inject. set_psilim_via_dmlim/dmlim stay in `patches`. + for key in ("force_termination", "HDF5_filename", "gal_match_flag") + toml = replace(toml, Regex("(?m)^[ \\t]*$key[ \\t]*=.*\\n") => "") + end + toml = replace(toml, "[ForceFreeStates]" => + "[ForceFreeStates]\nforce_termination = true\nHDF5_filename = \"$tag.h5\"\ngal_match_flag = false"; count=1) write(joinpath(dir, "gpec.toml"), toml) @info ">>> $tag" GeneralizedPerturbedEquilibrium.main([dir]) diff --git a/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_dmlim_fine.py b/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_dmlim_fine.py index 3c3d3da6..e37468ed 100644 --- a/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_dmlim_fine.py +++ b/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_dmlim_fine.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""plot_deltacoil_dmlim_fine.py — fine dmlim sweep (0.30-0.50) through the q5-drop threshold (0.426). +"""plot_deltacoil_dmlim_fine.py - fine dmlim sweep (0.30-0.50) through the q5-drop threshold (0.426). Shows the delta_coil pattern collapse is a STEP at the threshold, not a smooth rotation. Values from deltacoil_metrics.jl (SKIP_SINGFAC=1) on the DIII-D-like case, qmax=5.426.""" import numpy as np, matplotlib @@ -36,7 +36,7 @@ ax.set_xlabel("dmlim (outer truncation, set_psilim_via_dmlim=true)") ax.set_ylabel("cosine similarity of delta_coil with nominal") ax.set_title("Fine dmlim sweep through the q5-drop threshold\n" - "solid = q5 present; dashed = q5 dropped — the pattern collapses as a STEP at 0.426", + "solid = q5 present; dashed = q5 dropped - the pattern collapses as a STEP at 0.426", fontsize=11.5, fontweight="bold") ax.set_ylim(0, 1.03); ax.set_xlim(0.29, 0.51) ax.legend(fontsize=9, loc="center right"); ax.grid(alpha=0.25) diff --git a/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_metrics.py b/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_metrics.py index 240f9f30..0e738899 100644 --- a/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_metrics.py +++ b/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_metrics.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""plot_deltacoil_metrics.py — cosine similarity of delta_coil with the nominal case, per surface. +"""plot_deltacoil_metrics.py - cosine similarity of delta_coil with the nominal case, per surface. The dot-product metric that the norm hides: shows the response PATTERN rotating under truncation.""" import numpy as np, matplotlib matplotlib.use("Agg"); import matplotlib.pyplot as plt @@ -28,7 +28,7 @@ ax1.axvline(thresh, color="grey", ls="--", lw=1.3) ax1.text(thresh + 0.012, 0.30, f"q5 dropped\n(dmlim > {thresh:.3f})", fontsize=8.5, color="grey") ax1.set_xlabel("dmlim (outer truncation)"); ax1.set_ylabel("cosine similarity with nominal") -ax1.set_title("Sweep A — truncation: pattern rotates hard once q5 drops", fontsize=10.5, fontweight="bold") +ax1.set_title("Sweep A - truncation: pattern rotates hard once q5 drops", fontsize=10.5, fontweight="bold") ax1.set_ylim(0, 1.03); ax1.legend(fontsize=9, loc="lower right"); ax1.grid(alpha=0.25) for k, v in cosB.items(): @@ -36,7 +36,7 @@ ax2.set_xscale("log"); ax2.set_ylim(0, 1.03) ax2.set_xlabel("singfac_min (rational-surface approach distance)") ax2.set_ylabel("cosine similarity with nominal") -ax2.set_title("Sweep B — approach distance: perfectly flat (no effect)", fontsize=10.5, fontweight="bold") +ax2.set_title("Sweep B - approach distance: perfectly flat (no effect)", fontsize=10.5, fontweight="bold") ax2.legend(fontsize=9, loc="lower left"); ax2.grid(alpha=0.25) ax2.text(0.5, 0.55, "cosine identical to 6 decimals across 2 decades of singfac_min\n" "(offset from 1.0 is the truncation-flag on/off, not singfac)", diff --git a/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_svd.py b/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_svd.py index 5bef6cdf..40ce7968 100644 --- a/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_svd.py +++ b/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_svd.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -"""plot_deltacoil_svd.py — SVD robustness of the delta_coil matrix vs the truncation dmlim. -LEFT: singular-value spectrum. RIGHT: condition number per run (this is what varies — the flat +"""plot_deltacoil_svd.py - SVD robustness of the delta_coil matrix vs the truncation dmlim. +LEFT: singular-value spectrum. RIGHT: condition number per run (this is what varies - the flat tolerance-bar panel was removed because it carried no information: the tolerance scan is bit-identical and is stated in the caption instead). Values from deltacoil_svd_tol.jl on the DIII-D-like case.""" import numpy as np, matplotlib @@ -13,13 +13,13 @@ "dmlim=0.50 (q5 dropped)": [15.73, 10.62, 6.220, 4.523, 2.437, 1.395], } cols = {"nominal":"#111111","dmlim=0.30":"#2E86C1","dmlim=0.42":"#28B463","dmlim=0.50 (q5 dropped)":"#C0392B"} -# condition number (σ1/σ_min) per run — this genuinely varies +# condition number (σ1/σ_min) per run - this genuinely varies cond_runs = ["nominal","dmlim=0.30","dmlim=0.42","dmlim=0.50"] cond_vals = [26.2, 27.2, 26.2, 11.3] cond_cols = ["#111111","#2E86C1","#28B463","#C0392B"] fig,(ax1,ax2)=plt.subplots(1,2,figsize=(13,5.3),constrained_layout=True) -fig.suptitle("delta_coil matrix — SVD robustness (DIII-D-like)",fontsize=13,fontweight="bold") +fig.suptitle("delta_coil matrix - SVD robustness (DIII-D-like)",fontsize=13,fontweight="bold") x=np.arange(1,7) for k,v in sigma.items(): @@ -36,7 +36,7 @@ fontsize=10.5,fontweight="bold") ax2.grid(alpha=0.2,axis="y") ax2.text(0.5,0.93,"Integration-tolerance scan (1e-8…1e-12): the σ-spectrum,\n" - "norms and cosine are all BIT-IDENTICAL — omitted from the plot\n" + "norms and cosine are all BIT-IDENTICAL - omitted from the plot\n" "because there is nothing to show (delta_coil is tolerance-independent).", transform=ax2.transAxes,ha="center",fontsize=8,style="italic", bbox=dict(boxstyle="round",fc="#F4F6FA",ec="#B4BED2")) diff --git a/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_vs_rdcon.py b/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_vs_rdcon.py index 32539694..83fd3161 100644 --- a/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_vs_rdcon.py +++ b/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_vs_rdcon.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 -"""plot_deltacoil_vs_rdcon.py — per-surface correlation of STRIDE delta_coil with the RDCON reference, +"""plot_deltacoil_vs_rdcon.py - per-surface correlation of STRIDE delta_coil with the RDCON reference, for the strong-form (driven) and two projected (weak-form, energy-weighted) readouts. Shows the match is good on the inner surfaces and fails at the edge, and that projecting the readout -does not close the gap — the residual is weak-form-vs-strong-form representation plus RDCON edge +does not close the gap - the residual is weak-form-vs-strong-form representation plus RDCON edge degradation, not a fixable reweighting. Reads the example HDF5 files directly.""" import os, numpy as np, h5py, matplotlib matplotlib.use("Agg"); import matplotlib.pyplot as plt diff --git a/src/ForceFreeStates/ForceFreeStatesStructs.jl b/src/ForceFreeStates/ForceFreeStatesStructs.jl index 62698148..1e43efb9 100644 --- a/src/ForceFreeStates/ForceFreeStatesStructs.jl +++ b/src/ForceFreeStates/ForceFreeStatesStructs.jl @@ -206,6 +206,7 @@ A mutable struct holding internal state variables for stability calculations. resonant_match_deltar::Matrix{ComplexF64} = Matrix{ComplexF64}(undef, 0, 0) # per-surface inner-layer Δ (msing × 2) resonant_match_rpec_eig::Vector{ComplexF64} = ComplexF64[] # forced eigenvalue γ_s = 2πi·n·f resonant_match_flux::Matrix{ComplexF64} = Matrix{ComplexF64}(undef, 0, 0) # reconnected resonant flux (2msing × ncoil) + resonant_match_bpen::Matrix{ComplexF64} = Matrix{ComplexF64}(undef, 0, 0) # area-weighted penetrated b-field (msing × ncoil), like-for-like with galerkin/match/bpen resonant_match_residual::Float64 = NaN end diff --git a/src/ForceFreeStates/Riccati.jl b/src/ForceFreeStates/Riccati.jl index a61a2710..d7a65fed 100644 --- a/src/ForceFreeStates/Riccati.jl +++ b/src/ForceFreeStates/Riccati.jl @@ -338,20 +338,8 @@ function compute_delta_prime_matrix!( # S-axis row layout that `loop_edge_boundary_conditions` assumes for its edge-BC rows. # (The edge BC / RHS formulation itself is chosen inside that function via ENV["DELTACOIL_MODE"].) if use_S_axis && wv !== nothing #only run the coil loop on the S-axis path with a vacuum edge (real W_V present) - #KNOB — Q1 per-surface normalization. snorm = |n·q'|^α (derived: sing_get_ua small basis ~ dpsi^α at - #dpsi ∝ 1/(n·q'), α = Mercier exponent). To test a different normalization, change `alpha`/the power below. - nq = [abs(Float64(minimum(sing[j].n)) * Float64(sing[j].q1)) for j in 1:msing] #per surface: |n·q'| (n = toroidal mode, q' = dq/dψ at the surface) - alpha = (ctrl !== nothing && equil !== nothing && ffit !== nothing) ? #if we have the inputs to rebuild the asymptotics... - [real(compute_sing_asymptotics(sing[j], ctrl, equil, ffit, intr; sig=1.0).alpha[1]) for j in 1:msing] : #...compute α = Mercier exponent per surface (√−D_I) - fill(0.55, msing) #...otherwise fall back to a typical α ≈ 0.55 - snorm = nq .^ alpha #the derived per-surface factor: snorm[j] = |n·q'|^α_j (EDIT exponent to retune) - # DELTACOIL_COMP tests the ξ-vs-ξ' basis convention: multiply the small coeff by dpsi^p per surface - # (dpsi = singfac_min/|n·q'|; reading ξ' vs ξ differs by one dpsi). "xi"(default)=p0, "xip"=+1, "xip_half"=+0.5, "xi_neg"=-1. - comp = get(ENV, "DELTACOIL_COMP", "xi") - pcomp = comp == "xip" ? 1.0 : comp == "xip_half" ? 0.5 : comp == "xi_neg" ? -1.0 : 0.0 - if pcomp != 0.0 && ctrl !== nothing - snorm = snorm .* (ctrl.singfac_min ./ nq) .^ pcomp #apply dpsi^p (ξ→ξ' relative scaling) - end + # Q1 per-surface normalization REMOVED: delta_coil normalization is hardcoded to 1 (raw, unnormalized). + nq = [abs(Float64(minimum(sing[j].n)) * Float64(sing[j].q1)) for j in 1:msing] #per surface: |n·q'| (still needed by DELTACOIL_PROJECT below) #DELTACOIL_PROJECT (experimental): build weak-form projection weights so the readout is the #cell-integrated projection Σ_m W_m·x_m/W[small] (mode-mixing) instead of a single coefficient. Wl = Wr = nothing @@ -366,24 +354,25 @@ function compute_delta_prime_matrix!( Wl[j] = _projection_weight(aL, ipert_all[j] + N, dlo, dhi) end end - intr.delta_coil_matrix = loop_edge_boundary_conditions(M, col_edge, msing, N, ipert_all, snorm; Wl, Wr) #run the coil loop + intr.delta_coil_matrix = loop_edge_boundary_conditions(M, col_edge, msing, N, ipert_all; Wl, Wr) #run the coil loop (edge harmonic read raw, normalization = 1) end intr.delta_prime_matrix = _solve_bvp_and_combine_pest3( M, msing, N, nMat, use_S_axis, ipert_all, col_edge, ctrl, debug) # Resistive inner-layer matching (Wang 2020 Eq. 11): feed the RAW outer Δ' block (dp_raw) and the - # RAW edge-driven Δ_coil (snorm = 1, so both share one normalization — Step-1 result) into the + # RAW edge-driven Δ_coil (normalization = 1, so both share one normalization — Step-1 result) into the # coil-driven outer↔inner match. Gated on ctrl.gal_match_flag; needs the S-axis vacuum-edge BVP. if use_S_axis && wv !== nothing && ctrl !== nothing && ctrl.gal_match_flag && equil !== nothing delta_out_raw = loop_boundary_conditions(M, msing, N, ipert_all) #raw Δ_out (2msing×2msing) - delta_coil_raw = loop_edge_boundary_conditions(M, col_edge, msing, N, ipert_all, ones(Float64, msing)) #raw Δ_coil (snorm=1) + delta_coil_raw = loop_edge_boundary_conditions(M, col_edge, msing, N, ipert_all) #raw Δ_coil (normalization = 1) mres = resonant_match_rpec(delta_out_raw, delta_coil_raw, sing, equil, intr, ctrl) intr.resonant_match_cout = mres.cout intr.resonant_match_cin = mres.cin intr.resonant_match_deltar = mres.deltar intr.resonant_match_rpec_eig = mres.rpec_eig intr.resonant_match_flux = mres.reconnected_flux + intr.resonant_match_bpen = mres.bpen intr.resonant_match_residual = mres.residual @info @sprintf("Resistive inner-layer match: residual=%.2e, ‖cout‖=%.3e, ‖cin‖=%.3e (%d surfaces, %d coil modes)", mres.residual, norm(mres.cout), norm(mres.cin), msing, size(mres.cout, 2)) @@ -728,11 +717,11 @@ function _projection_weight(asymp::SingAsymptotics, small_col::Int, dpsi_lo::Flo end # Coil-response loop for the Eq. (37) edge block [Glasser-Kolemen 2018 PoP 25 082502]: for each edge poloidal -# mode k, copy the full BVP matrix, impose the Eq. (38) edge BC via `bc!`, solve, and read the snorm-scaled -# small-solution coeff (+N slot) at every surface → delta_coil (2·msing × N). If Wl/Wr are given (weak-form -# projection weights), read the cell-integrated projection Σ_m W_m·x_m / W[small] instead of the single coeff. +# mode k, copy the full BVP matrix, impose the Eq. (38) edge BC via `bc!`, solve, and read the raw +# small-solution coeff (+N slot, normalization = 1) at every surface → delta_coil (2·msing × N). If Wl/Wr are +# given (weak-form projection weights), read the cell-integrated projection Σ_m W_m·x_m / W[small] instead. function loop_edge_boundary_conditions(M::Matrix{ComplexF64}, col_edge, msing::Int, N::Int, - ipert_all::Vector{Int}, snorm::Vector{Float64}; Wl=nothing, Wr=nothing) + ipert_all::Vector{Int}; Wl=nothing, Wr=nothing) nMat = size(M, 1) top = (nMat-2msing-2N+1):(nMat-2msing-N) #Eq.38 top rows (1_M identity) bot = (nMat-2msing-N+1):(nMat-2msing) #Eq.38 bottom rows (-W_V) @@ -764,11 +753,11 @@ function loop_edge_boundary_conditions(M::Matrix{ComplexF64}, col_edge, msing::I ipert_j = ipert_all[j] cl = _col_left(j, N); cr = _col_right(j, N) if Wl === nothing - delta_coil[2j-1, k] = snorm[j] * x[cl[ipert_j+N]] - delta_coil[2j, k] = snorm[j] * x[cr[ipert_j+N]] + delta_coil[2j-1, k] = x[cl[ipert_j+N]] + delta_coil[2j, k] = x[cr[ipert_j+N]] else #weak-form cell-integrated projection (experimental) - delta_coil[2j-1, k] = snorm[j] * (sum(Wl[j] .* @view x[cl]) / Wl[j][ipert_j+N]) - delta_coil[2j, k] = snorm[j] * (sum(Wr[j] .* @view x[cr]) / Wr[j][ipert_j+N]) + delta_coil[2j-1, k] = sum(Wl[j] .* @view x[cl]) / Wl[j][ipert_j+N] + delta_coil[2j, k] = sum(Wr[j] .* @view x[cr]) / Wr[j][ipert_j+N] end end end diff --git a/src/GeneralizedPerturbedEquilibrium.jl b/src/GeneralizedPerturbedEquilibrium.jl index 2dec9831..0d3ebaef 100755 --- a/src/GeneralizedPerturbedEquilibrium.jl +++ b/src/GeneralizedPerturbedEquilibrium.jl @@ -803,6 +803,10 @@ function write_outputs_to_HDF5( g["rpec_eig"] = intr.resonant_match_rpec_eig # (msing) forced eigenvalue γ_s = 2πi·n·f g["reconnected_flux"] = intr.resonant_match_flux # (2msing × ncoil) matched small-solution (reconnected) amplitude g["reconnected_flux_abs"] = abs.(intr.resonant_match_flux) # |.| for H5Web heatmap view + if !isempty(intr.resonant_match_bpen) + g["bpen"] = intr.resonant_match_bpen # (msing × ncoil) area-weighted penetrated b-field (like galerkin/match/bpen) + g["bpen_abs"] = abs.(intr.resonant_match_bpen) + end g["residual"] = intr.resonant_match_residual end From cfa51478e69e779e1af7af3585b983cd32b4c8ad Mon Sep 17 00:00:00 2001 From: Via Weber Date: Thu, 30 Jul 2026 10:40:06 -0400 Subject: [PATCH 28/38] FORCEFREESTATES - MINOR - finished benchmarking truncation and resistivity scans for delta_coil with corrected edge harmonic --- .../scripts/plot_deltacoil_psihigh_rg.py | 24 +++++++++++-------- .../scripts/plot_resonant_coupling_psihigh.py | 4 ++-- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_psihigh_rg.py b/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_psihigh_rg.py index 775416d8..a93e92ad 100644 --- a/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_psihigh_rg.py +++ b/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_psihigh_rg.py @@ -77,22 +77,22 @@ def edge_log_x(ax, x0, xmax): ax.set_xticks(tk); ax.set_xticklabels([f"{t:g}" for t in tk]); ax.minorticks_off() ax.set_xlim(x0 - (1 - x0) * 0.06, xmax + (1 - xmax) * 0.4) -def method_legend(ax): +def method_legend(ax, loc="lower left", bbox=(1.015, 0.0)): # representative mid-palette swatches so the cool=Riccati / warm=Galerkin mapping is explicit; - # placed OUTSIDE the axes (lower right) so it never covers data points + # default placement is OUTSIDE the axes (lower right) so it never covers data points handles = [Line2D([0], [0], color=plt.cm.viridis(0.45), ls="-", marker="o", ms=5, lw=2, label="Riccati (STRIDE), cool"), Line2D([0], [0], color=plt.cm.YlOrRd(0.75), ls="--", marker="s", ms=5, lw=2, label="Galerkin (RDCON), warm")] - leg = ax.legend(handles=handles, fontsize=8.5, loc="lower left", bbox_to_anchor=(1.015, 0.0), + leg = ax.legend(handles=handles, fontsize=8.5, loc=loc, bbox_to_anchor=bbox, framealpha=0.95, title="method (color family)", title_fontsize=8.5) ax.add_artist(leg) -def surface_legend(ax): +def surface_legend(ax, loc="upper left", bbox=(1.015, 1.0)): # each surface shows BOTH its Riccati (cool) and Galerkin (warm) swatch as a paired handle; - # placed OUTSIDE the axes (upper right) so all points stay visible + # default placement is OUTSIDE the axes (upper right) so all points stay visible handles = [(Line2D([0], [0], color=colors_ric[k], ls="-", marker="o", ms=5, lw=2), Line2D([0], [0], color=colors_gal[k], ls="--", marker="s", ms=5, lw=2)) for k in range(len(qs))] labels = [f"q = {q}" for q in qs] - ax.legend(handles=handles, labels=labels, fontsize=8.5, loc="upper left", bbox_to_anchor=(1.015, 1.0), + ax.legend(handles=handles, labels=labels, fontsize=8.5, loc=loc, bbox_to_anchor=bbox, title="rational surface (Riccati | Galerkin)", title_fontsize=8.5, framealpha=0.95, handler_map={tuple: HandlerTuple(ndivide=None)}, handlelength=3.0) @@ -119,8 +119,10 @@ def x0_of(mask_cols): ax.set_ylabel(r"|delta_coil| per surface (log)", fontsize=11) ax.set_title(f"delta_coil vs outer truncation: Riccati (STRIDE) vs Galerkin (RDCON)\n{CASE}, n=1", fontsize=11) ax.grid(alpha=0.25, which="both") -method_legend(ax); surface_legend(ax) -fig.subplots_adjust(left=0.08, bottom=0.13, right=0.76, top=0.9) +# legends INSIDE the upper-left empty region (curves stay in a flat band until Galerkin blows up on the right) +method_legend(ax, loc="upper left", bbox=(0.012, 0.52)) +surface_legend(ax, loc="upper left", bbox=(0.012, 0.985)) +fig.subplots_adjust(left=0.08, bottom=0.13, right=0.97, top=0.9) p1 = os.path.join(figdir, stem + "_norm.png"); fig.savefig(p1, dpi=150); print("wrote:", p1) # ---------- (2) relchange vs outermost psihigh ---------- @@ -140,8 +142,10 @@ def x0_of(mask_cols): ax.set_ylabel(r"|$\Delta$ delta_coil| / |delta_coil($\psi_{high}$=max)|", fontsize=11) ax.set_title(f"Relative sensitivity of delta_coil to the truncation boundary\n{CASE}: Riccati vs Galerkin (lower = more converged)", fontsize=11) ax.grid(alpha=0.25, which="both") -method_legend(ax); surface_legend(ax) -fig.subplots_adjust(left=0.09, bottom=0.13, right=0.76, top=0.9) +# legends INSIDE the lower-left empty region (curves live in the upper-left / lower-right) +method_legend(ax, loc="lower left", bbox=(0.012, 0.30)) +surface_legend(ax, loc="lower left", bbox=(0.012, 0.015)) +fig.subplots_adjust(left=0.09, bottom=0.13, right=0.97, top=0.9) p2 = os.path.join(figdir, stem + "_relchange.png"); fig.savefig(p2, dpi=150); print("wrote:", p2) # ---------- (3) cosine shape metric ---------- diff --git a/benchmarks/deltacoil_sensitivity/scripts/plot_resonant_coupling_psihigh.py b/benchmarks/deltacoil_sensitivity/scripts/plot_resonant_coupling_psihigh.py index 3d4ff1c9..f55d1182 100644 --- a/benchmarks/deltacoil_sensitivity/scripts/plot_resonant_coupling_psihigh.py +++ b/benchmarks/deltacoil_sensitivity/scripts/plot_resonant_coupling_psihigh.py @@ -58,12 +58,12 @@ def edge_log_x(ax): ax.plot(psihigh[mg], yg[mg], 's--', color=c, lw=1.5, ms=5, mfc='white', label=f'q = {q:g}, galerkin') edge_log_x(ax) ax.set_xlabel('outer truncation ψ$_{high}$ (log distance from edge, 1 - ψ$_{high}$; edge at right)', fontsize=11) -ax.set_ylabel('matched resonant coupling ||cout|| per surface (log)', fontsize=11) +ax.set_ylabel('matched resonant coupling\n||cout|| per surface (log)', fontsize=11) ax.set_title('Matched resonant coupling vs psihigh truncation: STRIDE (solid) vs galerkin (dashed)\n' f'{CASE}, n=1 · cout from Wang 2020 Eq. 11 match', fontsize=11) ax.grid(alpha=0.25, which='both') ax.legend(fontsize=7.6, ncol=2, loc='best') -fig.subplots_adjust(left=0.12, bottom=0.13, right=0.97, top=0.88) +fig.subplots_adjust(left=0.13, bottom=0.13, right=0.97, top=0.88) stem = os.path.splitext(os.path.basename(csv_path))[0].replace('resonant_coupling', 'rescoup') out = os.path.abspath(os.path.join(here, '..', 'figures', stem + '.png')) From 62ccc242ccb7fa0991b134c26acf5d497836208c Mon Sep 17 00:00:00 2001 From: Matthew Pharr Date: Thu, 30 Jul 2026 15:33:04 -0400 Subject: [PATCH 29/38] DOCS - MINOR - Remove Delta_coil benchmarks from tracking --- benchmarks/deltacoil_sensitivity/README.md | 96 ---------- .../results/deltacoil_dmlim_fine_result.txt | 20 --- .../results/deltacoil_metrics_result.txt | 39 ---- .../deltacoil_rdcon_convergence_result.txt | 11 -- .../results/deltacoil_sensitivity_result.txt | 32 ---- .../results/deltacoil_svd_tol_result.txt | 39 ---- .../results/deltacoil_vs_rdcon_result.txt | 5 - ...ndrical_newcomb_deltaprime.cpython-311.pyc | Bin 8388 -> 0 bytes .../scripts/cylindrical_newcomb_deltaprime.py | 125 ------------- .../scripts/deltacoil_metrics.jl | 110 ------------ .../scripts/deltacoil_psihigh_metric.jl | 136 -------------- .../deltacoil_psihigh_riccati_galerkin.jl | 152 ---------------- .../scripts/deltacoil_psihigh_scan.jl | 121 ------------- .../scripts/deltacoil_rdcon_convergence.jl | 63 ------- .../scripts/deltacoil_sensitivity.jl | 102 ----------- .../scripts/deltacoil_svd_tol.jl | 113 ------------ .../scripts/deltamn_resistivity_merge.jl | 167 ----------------- .../scripts/penetrated_field_psihigh.jl | 122 ------------- .../scripts/plot_deltacoil_dmlim_fine.py | 45 ----- .../scripts/plot_deltacoil_metrics.py | 45 ----- .../scripts/plot_deltacoil_psihigh.py | 127 ------------- .../scripts/plot_deltacoil_psihigh_metric.py | 76 -------- .../scripts/plot_deltacoil_psihigh_panels.py | 140 --------------- .../scripts/plot_deltacoil_psihigh_rg.py | 169 ------------------ .../scripts/plot_deltacoil_sensitivity.py | 72 -------- .../scripts/plot_deltacoil_svd.py | 46 ----- .../scripts/plot_deltacoil_vs_rdcon.py | 48 ----- .../scripts/plot_deltamn_merge.py | 73 -------- .../scripts/plot_deltaprime_epsilon.py | 74 -------- .../scripts/plot_marginal_q2.py | 65 ------- .../scripts/plot_match_easycase.py | 134 -------------- .../scripts/plot_penetrated_field_psihigh.py | 72 -------- .../scripts/plot_psihigh_fig1style_rg.py | 103 ----------- .../scripts/plot_resistivity_scan.py | 64 ------- .../scripts/plot_resonant_coupling_psihigh.py | 72 -------- .../scripts/resistivity_scan.jl | 123 ------------- .../scripts/resonant_coupling_psihigh.jl | 126 ------------- 37 files changed, 3127 deletions(-) delete mode 100644 benchmarks/deltacoil_sensitivity/README.md delete mode 100644 benchmarks/deltacoil_sensitivity/results/deltacoil_dmlim_fine_result.txt delete mode 100644 benchmarks/deltacoil_sensitivity/results/deltacoil_metrics_result.txt delete mode 100644 benchmarks/deltacoil_sensitivity/results/deltacoil_rdcon_convergence_result.txt delete mode 100644 benchmarks/deltacoil_sensitivity/results/deltacoil_sensitivity_result.txt delete mode 100644 benchmarks/deltacoil_sensitivity/results/deltacoil_svd_tol_result.txt delete mode 100644 benchmarks/deltacoil_sensitivity/results/deltacoil_vs_rdcon_result.txt delete mode 100644 benchmarks/deltacoil_sensitivity/scripts/__pycache__/cylindrical_newcomb_deltaprime.cpython-311.pyc delete mode 100644 benchmarks/deltacoil_sensitivity/scripts/cylindrical_newcomb_deltaprime.py delete mode 100644 benchmarks/deltacoil_sensitivity/scripts/deltacoil_metrics.jl delete mode 100644 benchmarks/deltacoil_sensitivity/scripts/deltacoil_psihigh_metric.jl delete mode 100644 benchmarks/deltacoil_sensitivity/scripts/deltacoil_psihigh_riccati_galerkin.jl delete mode 100644 benchmarks/deltacoil_sensitivity/scripts/deltacoil_psihigh_scan.jl delete mode 100644 benchmarks/deltacoil_sensitivity/scripts/deltacoil_rdcon_convergence.jl delete mode 100644 benchmarks/deltacoil_sensitivity/scripts/deltacoil_sensitivity.jl delete mode 100644 benchmarks/deltacoil_sensitivity/scripts/deltacoil_svd_tol.jl delete mode 100644 benchmarks/deltacoil_sensitivity/scripts/deltamn_resistivity_merge.jl delete mode 100644 benchmarks/deltacoil_sensitivity/scripts/penetrated_field_psihigh.jl delete mode 100644 benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_dmlim_fine.py delete mode 100644 benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_metrics.py delete mode 100644 benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_psihigh.py delete mode 100644 benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_psihigh_metric.py delete mode 100644 benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_psihigh_panels.py delete mode 100644 benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_psihigh_rg.py delete mode 100644 benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_sensitivity.py delete mode 100644 benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_svd.py delete mode 100644 benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_vs_rdcon.py delete mode 100644 benchmarks/deltacoil_sensitivity/scripts/plot_deltamn_merge.py delete mode 100644 benchmarks/deltacoil_sensitivity/scripts/plot_deltaprime_epsilon.py delete mode 100644 benchmarks/deltacoil_sensitivity/scripts/plot_marginal_q2.py delete mode 100644 benchmarks/deltacoil_sensitivity/scripts/plot_match_easycase.py delete mode 100644 benchmarks/deltacoil_sensitivity/scripts/plot_penetrated_field_psihigh.py delete mode 100644 benchmarks/deltacoil_sensitivity/scripts/plot_psihigh_fig1style_rg.py delete mode 100644 benchmarks/deltacoil_sensitivity/scripts/plot_resistivity_scan.py delete mode 100644 benchmarks/deltacoil_sensitivity/scripts/plot_resonant_coupling_psihigh.py delete mode 100644 benchmarks/deltacoil_sensitivity/scripts/resistivity_scan.jl delete mode 100644 benchmarks/deltacoil_sensitivity/scripts/resonant_coupling_psihigh.jl diff --git a/benchmarks/deltacoil_sensitivity/README.md b/benchmarks/deltacoil_sensitivity/README.md deleted file mode 100644 index 4e891eae..00000000 --- a/benchmarks/deltacoil_sensitivity/README.md +++ /dev/null @@ -1,96 +0,0 @@ -# `delta_coil` truncation & near-rational sensitivity study - -Sensitivity of the STRIDE/Riccati **`delta_coil`** (outer small-solution coefficient at each rational -surface, driven by unit edge/coil modes; `singular/delta_coil_matrix`) to two numerical knobs: - -- **`dmlim`** — the outer-domain truncation point, active only when `set_psilim_via_dmlim = true` - (`psilim = psi(q_last + dmlim)`). -- **`singfac_min`** — the fractional distance from a rational surface at which the ideal jump is - enforced (how close the integration approaches the rational). - -Case: `examples/DIIID-like_gal_resistive_pe_example` (DIII-D-like, n=1, rationals q=2,3,4,5, `qmax=5.426`). - -## Two metrics, per singular surface - -`delta_coil_matrix` is `(2·msing, ncoil)`: each singular surface is **two rows** (its L/R small -solutions). A surface's response vector `v_s` is that `2×ncoil` block flattened. We report, against a -**nominal** run (the case as-shipped: `set_psilim_via_dmlim=false`, `dmlim=0.2`, `singfac_min=1e-4`): - -1. **Norm** `‖v_s‖` — magnitude sensitivity. -2. **Cosine similarity** `|⟨v_nom, v_s⟩| / (‖v_nom‖·‖v_s‖)` — phase-invariant normalized dot product. - `1.0` = identical pattern (only rescaled); `<1` = the coil-response **pattern rotated** — a change - the norm cannot see. - -All runs use `gal_match_flag = false`: `delta_coil` is produced by the rpec edge loop independently of -the resistive match, so there is no inner-layer `msing`/array guard and `dmlim` is free to change the -surface count. Results are aligned **by q-value**, so a surface appearing/disappearing is handled cleanly. - -## Key findings - -- **`singfac_min` is inert.** Norm and cosine are identical to 5–6 significant figures across - `1e-5 … 1e-3` at every surface including the edge — `delta_coil` is independent of the near-rational - approach distance in both magnitude and pattern. - -- **Integration tolerance is inert too.** Sweeping `eulerlagrange_tolerance` over `1e-8 … 1e-12` leaves - `delta_coil` bit-identical — norms, cosine (= 1.000000), and the full singular-value spectrum unchanged. - -- **SVD confirms good conditioning.** The `delta_coil` matrix is well-conditioned (cond ≈ 26, σ₁ ≈ 29, - σ_min ≈ 1.1). Below the `dmlim` threshold the singular-value spectrum and the leading left singular - vector are stable (`u₁·u₁_nom` = 0.998–1.000); at `dmlim = 0.5` the matrix drops to rank-6 (q5 gone), - so its norm and condition number fall (cond → 11) — the discrete surface loss shows cleanly in the SVD. - -- **`dmlim` is the dominant sensitivity — and it is a *pattern-level* effect the norm hides.** Under the - `dmlim` sweep the norms of q2/q3/q4 barely move (~5–16%), which looks robust; but the cosine with the - nominal drops sharply — e.g. at `dmlim=0.5` the q4 pattern is nearly orthogonal to nominal - (cosine ≈ 0.17, ~80° rotation) at ~16% norm change. **A norm-only scan would falsely call this robust.** - -- **The truncation rule drops the edge rational at a sharp threshold.** `dmlim` keeps the outermost - rational `q_r` only if `q_r + dmlim < qmax`. Here q=5 survives only for **`dmlim < qmax − 5 = 0.426`**. - Above that, q5 is dropped entirely (edge becomes q4), and removing it reorganizes the coil response at - every remaining surface — worst right at `dmlim ≈ 0.5`, partially realigning as `dmlim` grows further. - -- **The collapse is a STEP at the threshold, not a smooth rotation** (fine sweep, 0.30–0.50). Just below - (`dmlim=0.42`) every surface's cosine is `0.9999+` — the truncation sits at `qmax`, i.e. the full - nominal domain. The instant q5 drops (`dmlim=0.44`) the cosine collapses discontinuously: q4 → 0.104 - (near-orthogonal), q3 → 0.55, q2 → 0.78. Above the threshold it only slowly realigns (q4: 0.10 → 0.17 - by `dmlim=0.5`). So the pattern change is driven by the *discrete* loss of the surface, not a gradual - boundary shift. - -- Practical guidance: for this diverted-style case the shipped default `set_psilim_via_dmlim=true, - dmlim=0.2` sits comfortably below the 0.426 threshold (all four rationals retained). Choosing - `dmlim` near/above the threshold both changes which surfaces exist and abruptly rotates the response - pattern — avoid parking `dmlim` near `qmax − floor(qmax)`. - -## Layout - -``` -scripts/ deltacoil_metrics.jl norm + cosine-vs-nominal, per surface (dmlim & singfac sweeps) - deltacoil_svd_tol.jl SVD (singular values + vectors) + integration-tolerance scan - deltacoil_sensitivity.jl per-surface norms only (earlier, simpler version) - plot_deltacoil_metrics.py cosine-similarity plots - plot_deltacoil_dmlim_fine.py fine dmlim sweep plot - plot_deltacoil_svd.py SVD spectrum + tolerance-invariance plot - plot_deltacoil_sensitivity.py norm plots -results/ *_result.txt raw scan tables (incl. deltacoil_svd_tol_result.txt) -figures/ deltacoil_metrics.png cosine similarity vs dmlim / singfac - deltacoil_sensitivity.png |delta_coil| vs dmlim / singfac - deltacoil_dmlim_fine.png fine dmlim sweep (0.30–0.50) through the q5-drop threshold - deltacoil_svd.png singular-value spectrum vs dmlim + tolerance invariance -``` - -## Reproduce - -```bash -cd -# coarse dmlim + singfac sweeps: -julia --project=. benchmarks/deltacoil_sensitivity/scripts/deltacoil_metrics.jl \ - examples/DIIID-like_gal_resistive_pe_example -# fine dmlim sweep only (skip singfac), custom dmlim points: -SKIP_SINGFAC=1 julia --project=. benchmarks/deltacoil_sensitivity/scripts/deltacoil_metrics.jl \ - examples/DIIID-like_gal_resistive_pe_example 0.30 0.35 0.40 0.42 0.44 0.46 0.48 0.50 -# then plot (edit the embedded values to match a new run): -python3 benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_metrics.py -``` - -Each sweep point is one GPEC run (~1–2 min). Scans use temp dirs and never overwrite the example's -own HDF5 outputs; the galerkin reference `gpec.h5` is never touched. diff --git a/benchmarks/deltacoil_sensitivity/results/deltacoil_dmlim_fine_result.txt b/benchmarks/deltacoil_sensitivity/results/deltacoil_dmlim_fine_result.txt deleted file mode 100644 index b58b7237..00000000 --- a/benchmarks/deltacoil_sensitivity/results/deltacoil_dmlim_fine_result.txt +++ /dev/null @@ -1,20 +0,0 @@ - Sweep A — delta_coil vs dmlim ∈ (0,1) [nominal ncoil=26] -================================================================================ - nominal ‖v‖ per surface: q2.0=11.94 q3.0=9.687 q4.0=12.59 q5.0=31.7 - - (1) ‖delta_coil‖ per surface (— = surface absent): - q\dmlim | 0.3 | 0.35 | 0.4 | 0.42 | 0.44 | 0.46 | 0.48 | 0.5 - q=2.0 | 1.172e+01 | 1.181e+01 | 1.189e+01 | 1.193e+01 | 1.108e+01 | 1.107e+01 | 1.107e+01 | 1.107e+01 - q=3.0 | 9.560e+00 | 9.609e+00 | 9.660e+00 | 9.681e+00 | 9.713e+00 | 9.681e+00 | 9.650e+00 | 9.619e+00 - q=4.0 | 1.251e+01 | 1.254e+01 | 1.257e+01 | 1.258e+01 | 1.521e+01 | 1.498e+01 | 1.477e+01 | 1.457e+01 - q=5.0 | 3.349e+01 | 3.273e+01 | 3.202e+01 | 3.177e+01 | — | — | — | — <- edge - - (2) cosine similarity with nominal ||/(‖v_nom‖‖v‖) (1.0 = same pattern): - q\dmlim | 0.3 | 0.35 | 0.4 | 0.42 | 0.44 | 0.46 | 0.48 | 0.5 - q=2.0 | 0.996348 | 0.998666 | 0.999843 | 0.999991 | 0.775613 | 0.784651 | 0.793553 | 0.802348 - q=3.0 | 0.992477 | 0.997249 | 0.999675 | 0.999982 | 0.551682 | 0.568847 | 0.585903 | 0.602900 - q=4.0 | 0.981228 | 0.993110 | 0.999184 | 0.999955 | 0.104129 | 0.125729 | 0.149245 | 0.174318 - q=5.0 | 0.942364 | 0.978476 | 0.997414 | 0.999857 | — | — | — | — - -read: ‖v‖ moves ⇒ magnitude sensitivity; cosine<1 ⇒ the response PATTERN over coil modes rotated. -cosine≈1 with ‖v‖ varying ⇒ pure rescaling; cosine falling ⇒ genuine change of the coil-response shape. diff --git a/benchmarks/deltacoil_sensitivity/results/deltacoil_metrics_result.txt b/benchmarks/deltacoil_sensitivity/results/deltacoil_metrics_result.txt deleted file mode 100644 index 820abf4b..00000000 --- a/benchmarks/deltacoil_sensitivity/results/deltacoil_metrics_result.txt +++ /dev/null @@ -1,39 +0,0 @@ - Sweep A — delta_coil vs dmlim ∈ (0,1) [nominal ncoil=26] -================================================================================ - nominal ‖v‖ per surface: q2.0=11.94 q3.0=9.687 q4.0=12.59 q5.0=31.7 - - (1) ‖delta_coil‖ per surface (— = surface absent): - q\dmlim | 0.1 | 0.3 | 0.5 | 0.7 | 0.9 - q=2.0 | 1.140e+01 | 1.172e+01 | 1.107e+01 | 1.100e+01 | 1.114e+01 - q=3.0 | 9.389e+00 | 9.560e+00 | 9.619e+00 | 9.319e+00 | 9.279e+00 - q=4.0 | 1.242e+01 | 1.251e+01 | 1.457e+01 | 1.305e+01 | 1.251e+01 - q=5.0 | 3.857e+01 | 3.349e+01 | — | — | — <- edge - - (2) cosine similarity with nominal ||/(‖v_nom‖‖v‖) (1.0 = same pattern): - q\dmlim | 0.1 | 0.3 | 0.5 | 0.7 | 0.9 - q=2.0 | 0.975948 | 0.996348 | 0.802348 | 0.882046 | 0.938423 - q=3.0 | 0.950813 | 0.992477 | 0.602900 | 0.762331 | 0.875203 - q=4.0 | 0.879623 | 0.981228 | 0.174318 | 0.464867 | 0.705142 - q=5.0 | 0.593958 | 0.942364 | — | — | — - -================================================================================ - Sweep B — delta_coil vs singfac_min [dmlim=0.2] -================================================================================ - nominal ‖v‖ per surface: q2.0=11.94 q3.0=9.687 q4.0=12.59 q5.0=31.7 - - (1) ‖delta_coil‖ per surface (— = surface absent): - q\sfac | 1e-05 | 0.0001 | 0.001 - q=2.0 | 1.155e+01 | 1.155e+01 | 1.155e+01 - q=3.0 | 9.469e+00 | 9.469e+00 | 9.469e+00 - q=4.0 | 1.245e+01 | 1.245e+01 | 1.245e+01 - q=5.0 | 3.509e+01 | 3.510e+01 | 3.509e+01 <- edge - - (2) cosine similarity with nominal ||/(‖v_nom‖‖v‖) (1.0 = same pattern): - q\sfac | 1e-05 | 0.0001 | 0.001 - q=2.0 | 0.988340 | 0.988340 | 0.988340 - q=3.0 | 0.976060 | 0.976060 | 0.976060 - q=4.0 | 0.940802 | 0.940802 | 0.940802 - q=5.0 | 0.818224 | 0.818224 | 0.818224 - -read: ‖v‖ moves ⇒ magnitude sensitivity; cosine<1 ⇒ the response PATTERN over coil modes rotated. -cosine≈1 with ‖v‖ varying ⇒ pure rescaling; cosine falling ⇒ genuine change of the coil-response shape. diff --git a/benchmarks/deltacoil_sensitivity/results/deltacoil_rdcon_convergence_result.txt b/benchmarks/deltacoil_sensitivity/results/deltacoil_rdcon_convergence_result.txt deleted file mode 100644 index 277aa699..00000000 --- a/benchmarks/deltacoil_sensitivity/results/deltacoil_rdcon_convergence_result.txt +++ /dev/null @@ -1,11 +0,0 @@ - STRIDE delta_coil: self-convergence vs correlation with RDCON (inner surfaces) -====================================================================== - order | (a) self-conv vs ord 8 | (b) corr with RDCON - | q2 q3 | q2 q3 - -------------------------------------------------------------- - 4 | 1.71e-08 3.35e-08 | 0.920 0.574 - 6 | 0.00e+00 0.00e+00 | 0.920 0.574 - 8 | 0.00e+00 0.00e+00 | 0.920 0.574 - -interpretation: if (a) → 0 while (b) stays flat below 1, the STRIDE↔RDCON gap is representational -(weak-form vs strong-form), NOT STRIDE resolution — so refining STRIDE will not close it. diff --git a/benchmarks/deltacoil_sensitivity/results/deltacoil_sensitivity_result.txt b/benchmarks/deltacoil_sensitivity/results/deltacoil_sensitivity_result.txt deleted file mode 100644 index e4003f63..00000000 --- a/benchmarks/deltacoil_sensitivity/results/deltacoil_sensitivity_result.txt +++ /dev/null @@ -1,32 +0,0 @@ - Sweep A — delta_coil vs outer truncation dmlim ∈ (0,1) [set_psilim_via_dmlim=true] - |delta_coil| per surface (blank = surface absent at that setting) -======================================================================== - q\dmlim | 0.1 | 0.3 | 0.5 | 0.7 | 0.9 - q=2.0 | 1.140e+01 | 1.172e+01 | 1.107e+01 | 1.100e+01 | 1.114e+01 - q=3.0 | 9.389e+00 | 9.560e+00 | 9.619e+00 | 9.319e+00 | 9.279e+00 - q=4.0 | 1.242e+01 | 1.251e+01 | 1.457e+01 | 1.305e+01 | 1.251e+01 - q=5.0 | 3.857e+01 | 3.349e+01 | — | — | — <- edge - ------------------------------------------------------------------ - relative change of |delta_coil| vs dmlim=0.9 (reference): - q=2.0 | 2.36e-02 | 5.24e-02 | 6.31e-03 | 1.19e-02 | 0.00e+00 - q=3.0 | 1.18e-02 | 3.02e-02 | 3.66e-02 | 4.33e-03 | 0.00e+00 - q=4.0 | 7.45e-03 | 5.73e-04 | 1.64e-01 | 4.32e-02 | 0.00e+00 - -======================================================================== - Sweep B — delta_coil vs rational-surface cutoff singfac_min [dmlim fixed at 0.2] - |delta_coil| per surface (blank = surface absent at that setting) -======================================================================== - q\sfac | 1e-05 | 0.0001 | 0.001 - q=2.0 | 1.155e+01 | 1.155e+01 | 1.155e+01 - q=3.0 | 9.469e+00 | 9.469e+00 | 9.469e+00 - q=4.0 | 1.245e+01 | 1.245e+01 | 1.245e+01 - q=5.0 | 3.509e+01 | 3.510e+01 | 3.509e+01 <- edge - ------------------------------------------------------------------ - relative change of |delta_coil| vs sfac=0.001 (reference): - q=2.0 | 8.85e-06 | 1.11e-05 | 0.00e+00 - q=3.0 | 1.99e-05 | 1.52e-05 | 0.00e+00 - q=4.0 | 8.78e-06 | 2.23e-05 | 0.00e+00 - q=5.0 | 5.56e-06 | 3.68e-05 | 0.00e+00 - -interpretation: flat rows ⇒ delta_coil robust to that knob at that surface; the edge-most -surface is where truncation (dmlim) and approach distance (singfac_min) bite hardest. diff --git a/benchmarks/deltacoil_sensitivity/results/deltacoil_svd_tol_result.txt b/benchmarks/deltacoil_sensitivity/results/deltacoil_svd_tol_result.txt deleted file mode 100644 index e0836619..00000000 --- a/benchmarks/deltacoil_sensitivity/results/deltacoil_svd_tol_result.txt +++ /dev/null @@ -1,39 +0,0 @@ - (A) SVD of the delta_coil matrix M = (2·msing × ncoil) -==================================================================================== - run | 2msing | ‖M‖2 (σ1) | ‖M‖F | σ_min | cond | u1·u1_nom - -------------------------------------------------------------------------------- - nominal | 8 | 2.8991e+01 | 3.7409e+01 | 1.107e+00 | 2.62e+01 | 1.00000 - dmlim=0.3 | 8 | 3.0493e+01 | 3.8820e+01 | 1.121e+00 | 2.72e+01 | 0.99752 - dmlim=0.42 | 8 | 2.9043e+01 | 3.7463e+01 | 1.107e+00 | 2.62e+01 | 0.99999 - dmlim=0.5 | 6 | 1.5731e+01 | 2.0669e+01 | 1.395e+00 | 1.13e+01 | n/a - tol=1.0e-8 | 8 | 2.8991e+01 | 3.7409e+01 | 1.107e+00 | 2.62e+01 | 1.00000 - tol=1.0e-10 | 8 | 2.8991e+01 | 3.7409e+01 | 1.107e+00 | 2.62e+01 | 1.00000 - tol=1.0e-12 | 8 | 2.8991e+01 | 3.7409e+01 | 1.107e+00 | 2.62e+01 | 1.00000 - - singular-value spectrum σ_i (first 6): - run | σ1 | σ2 | σ3 | σ4 | σ5 | σ6 - nominal | 2.899e+01 | 1.557e+01 | 1.294e+01 | 9.612e+00 | 6.048e+00 | 3.607e+00 - dmlim=0.3 | 3.049e+01 | 1.555e+01 | 1.376e+01 | 9.490e+00 | 5.997e+00 | 3.594e+00 - dmlim=0.42 | 2.904e+01 | 1.557e+01 | 1.299e+01 | 9.607e+00 | 6.045e+00 | 3.607e+00 - dmlim=0.5 | 1.573e+01 | 1.062e+01 | 6.220e+00 | 4.523e+00 | 2.437e+00 | 1.395e+00 - tol=1.0e-8 | 2.899e+01 | 1.557e+01 | 1.294e+01 | 9.612e+00 | 6.048e+00 | 3.607e+00 - tol=1.0e-10 | 2.899e+01 | 1.557e+01 | 1.294e+01 | 9.612e+00 | 6.048e+00 | 3.607e+00 - tol=1.0e-12 | 2.899e+01 | 1.557e+01 | 1.294e+01 | 9.612e+00 | 6.048e+00 | 3.607e+00 - -==================================================================================== - (B) eulerlagrange_tolerance scan — per-surface ‖v‖ and cosine vs nominal -==================================================================================== - q\tol | 1e-08 | 1e-10 | 1e-12 (nominal = as-shipped) - (1) ‖delta_coil‖ per surface: - q=2.0 | 1.194e+01 | 1.194e+01 | 1.194e+01 - q=3.0 | 9.687e+00 | 9.687e+00 | 9.687e+00 - q=4.0 | 1.259e+01 | 1.259e+01 | 1.259e+01 - q=5.0 | 3.170e+01 | 3.170e+01 | 3.170e+01 - (2) cosine with nominal: - q=2.0 | 1.000000 | 1.000000 | 1.000000 - q=3.0 | 1.000000 | 1.000000 | 1.000000 - q=4.0 | 1.000000 | 1.000000 | 1.000000 - q=5.0 | 1.000000 | 1.000000 | 1.000000 - -read: (A) stable σ-spectrum + u1·u1_nom≈1 ⇒ matrix conditioning robust; (B) cosine≈1 across -tolerances ⇒ delta_coil independent of the ODE tolerance (as it is of singfac_min). diff --git a/benchmarks/deltacoil_sensitivity/results/deltacoil_vs_rdcon_result.txt b/benchmarks/deltacoil_sensitivity/results/deltacoil_vs_rdcon_result.txt deleted file mode 100644 index 03635d14..00000000 --- a/benchmarks/deltacoil_sensitivity/results/deltacoil_vs_rdcon_result.txt +++ /dev/null @@ -1,5 +0,0 @@ -STRIDE delta_coil vs RDCON (galerkin/delta_coil), per-surface correlation of |delta_coil| -variant | q2L q2R q3L q3R q4L q4R q5L q5R | inner outer -strong-form (driven) | 0.90 0.94 0.48 0.67 0.12 0.04 -.38 -.22 | 0.75 -0.11 -weak-form projection | 0.49 0.51 0.24 0.38 -.07 0.01 -.36 -.37 | 0.41 -0.20 -energy projection | 0.91 0.94 0.48 0.56 0.12 0.70 -.38 -.22 | 0.72 0.06 diff --git a/benchmarks/deltacoil_sensitivity/scripts/__pycache__/cylindrical_newcomb_deltaprime.cpython-311.pyc b/benchmarks/deltacoil_sensitivity/scripts/__pycache__/cylindrical_newcomb_deltaprime.cpython-311.pyc deleted file mode 100644 index ac36d5cffcc71d50056b69ca253981b9a20ff3a9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8388 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 -# We report the dimensionless r_s*Delta' (standard tearing index). -# -# Self-test: with the current term OFF the solution is vacuum x^{+/-m}, giving r_s*Delta' = -2m exactly. -# Boundary conditions: regular at axis (psi ~ x^m); NO WALL at the edge (vacuum psi ~ x^{-m}, i.e. psi'/psi = -m at x=1). -# -# Pure numpy (no scipy). Usage: python3 cylindrical_newcomb_deltaprime.py (runs self-test + the 5 TJ cases) - -import numpy as np - -def _qparts(x, qc, nu): - # analytic q(x) and q'(x) for q(x)=x^2/f1, f1=[1-(1-x^2)^nu]/(nu*qc); cheap (2 power ops) - if x < 1e-8: - return qc, 0.0 - u = 1.0 - x*x - un = u**nu - g = 1.0 - un - q = nu*qc*x*x/g - gp = 2.0*nu*x*u**(nu - 1.0) # dg/dx - qp = nu*qc*(2.0*x/g - x*x*gp/(g*g)) # dq/dx - return q, qp - -def q_of_x(x, qc, nu): - return _qparts(float(x), qc, nu)[0] - -def Hfun(x, qc, nu): - q, qp = _qparts(x, qc, nu) - return 2.0/q - x*qp/(q*q) - -def Hprime(x, qc, nu, h=1e-6): - xm = max(x - h, 1e-9) - return (Hfun(x + h, qc, nu) - Hfun(xm, qc, nu)) / (x + h - xm) - -def rhs(x, y, m, n, qc, nu, current=True): - y1, y2 = y - dy1 = y2 / x - cur = 0.0 - if current: - q, _ = _qparts(x, qc, nu) - cur = m*q*Hprime(x, qc, nu) / (m - n*q) - dy2 = (m*m/x + cur) * y1 - return np.array([dy1, dy2]) - -def rk4(f, x0, x1, y0, N): - h = (x1 - x0) / N - y = np.array(y0, float); x = x0 - for _ in range(N): - k1 = f(x, y); k2 = f(x + 0.5*h, y + 0.5*h*k1) - k3 = f(x + 0.5*h, y + 0.5*h*k2); k4 = f(x + h, y + h*k3) - y = y + (h/6.0)*(k1 + 2*k2 + 2*k3 + k4); x += h - return y - -def find_rs(m, n, qc, nu): - tgt = m / n - a, b = 1e-4, 1.0 - 1e-9 - fa = q_of_x(a, qc, nu) - tgt; fb = q_of_x(b, qc, nu) - tgt - if fa*fb > 0: - return None - for _ in range(200): - mid = 0.5*(a + b); fm = q_of_x(mid, qc, nu) - tgt - if abs(fm) < 1e-12 or (b - a) < 1e-13: - return mid - if fa*fm <= 0: b, fb = mid, fm - else: a, fa = mid, fm - return 0.5*(a + b) - -def deltaprime(m, n, qc, nu, delta=1e-3, N=400000, current=True): - rs = find_rs(m, n, qc, nu) - if rs is None: - return None, None - f = lambda x, y: rhs(x, y, m, n, qc, nu, current) - x0 = 1e-4 - yin = rk4(f, x0, rs - delta, [x0**m, m*x0**m], N) # axis -> just inside surface - ld_in = (yin[1]/(rs - delta)) / yin[0] - yout = rk4(f, 1.0, rs + delta, [1.0, -float(m)], N) # no-wall edge -> just outside surface - ld_out = (yout[1]/(rs + delta)) / yout[0] - aDp = ld_out - ld_in # a*Delta' (a=1) - return rs*aDp, rs # return r_s*Delta' (dimensionless) - -def deltaprime_extrap(m, n, qc, nu, deltas=(1e-2, 3e-3, 1e-3, 3e-4), N=60000): - # Delta'(delta) carries a residual ~ G*ln(delta) because the log-singular part of psi'/psi has a - # side-dependent coefficient. Fit Delta'(delta) = Delta'_true + G*ln(delta) and return the intercept. - xs, ys = [], [] - for d in deltas: - v, rs = deltaprime(m, n, qc, nu, delta=d, N=N, current=True) - if v is None: - return None, None, None - xs.append(np.log(d)); ys.append(float(np.real(v))) - A = np.vstack([np.ones_like(xs), xs]).T - (b0, G), *_ = np.linalg.lstsq(A, np.array(ys), rcond=None) - resid = float(np.sqrt(np.mean((A @ np.array([b0, G]) - np.array(ys))**2))) - return b0, G, resid # b0 = r_s*Delta'_true (intercept), G = log slope, resid = fit residual - -CASES = [("q1",0.85,1.5,1),("q2",1.5,2.8,2),("q3",2.2,3.8,3),("q4",3.2,4.8,4),("q5",4.2,5.8,5)] - -if __name__ == "__main__": - print("="*74) - print(" Cylindrical Newcomb Delta-prime (analytic TJ q-profile, no wall)") - print("="*74) - print("\n[self-test] current term OFF -> r_s*Delta' should equal -2m exactly:") - for lab, qc, qa, m in CASES: - nu = qa/qc - val, rs = deltaprime(m, 1, qc, nu, delta=1e-3, N=200000, current=False) - print(f" {lab} (m={m}): r_s*Delta'={val:+.4f} expected -2m={-2*m:+d} r_s={rs:.4f} " - f"{'OK' if abs(val+2*m)<0.05 else 'CHECK'}") - print("\n[physical] current term ON, delta-convergence (r_s*Delta'):") - hdr = " case r_s " + "".join(f"d={d:<9}" for d in ("1e-2","3e-3","1e-3")) - print(hdr) - for lab, qc, qa, m in CASES: - nu = qa/qc - vals = [] - for d in (1e-2, 3e-3, 1e-3): - v, rs = deltaprime(m, 1, qc, nu, delta=d, N=300000, current=True) - vals.append(v) - print(f" {lab:4s} {rs:.4f} " + "".join(f"{v:<+11.4f}" for v in vals)) diff --git a/benchmarks/deltacoil_sensitivity/scripts/deltacoil_metrics.jl b/benchmarks/deltacoil_sensitivity/scripts/deltacoil_metrics.jl deleted file mode 100644 index 1d1c8a72..00000000 --- a/benchmarks/deltacoil_sensitivity/scripts/deltacoil_metrics.jl +++ /dev/null @@ -1,110 +0,0 @@ -#!/usr/bin/env julia -# deltacoil_metrics.jl — per-singular-surface sensitivity of delta_coil to dmlim and singfac_min, -# reported with TWO complementary metrics against a nominal (baseline) run: -# (1) ‖v_s‖ — magnitude of the surface's delta_coil vector (over all coil modes) -# (2) |⟨v_s^nom, v_s⟩| / (‖v_s^nom‖‖v_s‖) — normalized Hermitian dot product (cosine similarity) -# with the nominal: 1.0 = identical pattern (only rescaled), <1.0 = the response ROTATED. -# -# delta_coil_matrix is (2·msing, ncoil): each singular surface = 2 rows (L/R small solutions), so a -# surface's vector v_s = vec(dc[2s-1:2s, :]) has length 2·ncoil. The dot product needs matching ncoil, -# so when a dmlim run drops q5 (changing the resonant m-band → ncoil), cosine is marked n/a. -# Run with gal_match_flag=false (delta_coil is computed by the rpec loop, independent of the match). -# -# Nominal = the baseline DIII-D case AS-SHIPPED (set_psilim_via_dmlim=false, dmlim=0.2, singfac_min=1e-4). -# -# Usage: julia --project= scripts/deltacoil_metrics.jl examples/DIIID-like_gal_resistive_pe_example - -using GeneralizedPerturbedEquilibrium, HDF5, LinearAlgebra, Printf - -length(ARGS) >= 1 || error("usage: julia --project= scripts/deltacoil_metrics.jl ") -base = ARGS[1]; isdir(base) || error("no such dir: $base") -ENV["DELTACOIL_MODE"] = "driven"; delete!(ENV, "DELTACOIL_PROJECT") - -DMLIMS = length(ARGS) >= 2 ? parse.(Float64, ARGS[2:end]) : [0.1, 0.3, 0.5, 0.7, 0.9] -SINGFAC = [1e-5, 1e-4, 1e-3] -SKIP_B = get(ENV, "SKIP_SINGFAC", "") != "" # set SKIP_SINGFAC=1 to run only the dmlim sweep - -base_toml = read(joinpath(base, "gpec.toml"), String) -scratch = mktempdir() -eqm = match(r"eq_filename\s*=\s*\"([^\"]+)\"", base_toml); eqm === nothing && error("no eq_filename") -eqfile = eqm.captures[1]; eqsrc = isabspath(eqfile) ? eqfile : joinpath(base, eqfile) - -# returns (sorted q-values, Dict q=>complex surface-vector, ncoil) -function run_one(tag, patches) - dir = joinpath(scratch, tag); mkpath(dir) - dst = joinpath(dir, basename(eqfile)); islink(dst) || symlink(realpath(eqsrc), dst) - toml = base_toml - for (r, s) in patches; toml = replace(toml, r => s); end - toml = replace(toml, r"eq_filename\s*=\s*\"[^\"]+\"" => "eq_filename = \"$(basename(eqfile))\"") - # Inject FFS controls after the section header: examples may omit these keys, so a plain regex - # replace is not enough — strip any existing, then inject (force_termination stops after stability; - # per-tag HDF5_filename is the file we read back). set_psilim_via_dmlim/dmlim/singfac stay in `patches`. - for key in ("force_termination", "HDF5_filename", "gal_match_flag") - toml = replace(toml, Regex("(?m)^[ \\t]*$key[ \\t]*=.*\\n") => "") - end - toml = replace(toml, "[ForceFreeStates]" => - "[ForceFreeStates]\nforce_termination = true\nHDF5_filename = \"$tag.h5\"\ngal_match_flag = false"; count=1) - write(joinpath(dir, "gpec.toml"), toml) - @info ">>> $tag" - GeneralizedPerturbedEquilibrium.main([dir]) - h5open(joinpath(dir, "$tag.h5"), "r") do fid - q = vec(read(fid, "singular/q")) - dc = read(fid, "singular/delta_coil_matrix") - dc = size(dc, 1) < size(dc, 2) ? dc : permutedims(dc) # -> (2msing, ncoil) - ncoil = size(dc, 2) - v = Dict{Float64,Vector{ComplexF64}}() - for s in 1:length(q); v[round(q[s]; digits=2)] = vec(dc[2s-1:2s, :]); end - (sort(round.(q; digits=2)), v, ncoil) - end -end - -cosine(a, b) = (length(a) == length(b) && norm(a) > 0 && norm(b) > 0) ? abs(dot(a, b)) / (norm(a) * norm(b)) : NaN - -# --- nominal (baseline as-shipped) --- -@info "=== nominal (baseline) ===" -qn, vnom, nc_nom = run_one("nominal", Pair{Regex,String}[]) - -# --- Sweep A: dmlim (truncation on) --- -runsA = [run_one("dmlim_$(d)", [r"set_psilim_via_dmlim\s*=\s*\w+" => "set_psilim_via_dmlim = true", - r"(?m)^([ \t]*)dmlim[ \t]*=[ \t]*[\d.]+" => SubstitutionString("\\1dmlim = $d")]) - for d in DMLIMS] -# --- Sweep B: singfac_min (truncation on, dmlim fixed 0.2) --- -runsB = SKIP_B ? Nothing[] : - [run_one("sfac_$(sf)", [r"set_psilim_via_dmlim\s*=\s*\w+" => "set_psilim_via_dmlim = true", - r"(?m)^([ \t]*)dmlim[ \t]*=[ \t]*[\d.]+" => SubstitutionString("\\1dmlim = 0.2"), - r"singfac_min\s*=\s*[\d.eE+-]+" => "singfac_min = $sf"]) - for sf in SINGFAC] - -function report(title, param, vals, runs) - qs = sort(collect(keys(vnom))) - println("\n" * "="^80); println(" $title"); println("="^80) - println(" nominal ‖v‖ per surface: " * join(["q$q=$(round(norm(vnom[q]);sigdigits=4))" for q in qs], " ")) - println("\n (1) ‖delta_coil‖ per surface (— = surface absent):") - @printf(" %-7s", "q\\$param") - for v in vals; @printf(" | %9.3g", v); end; println() - for q in qs - @printf(" q=%-5.1f", q) - for r in runs; @printf(" | %9s", haskey(r[2], q) ? @sprintf("%.3e", norm(r[2][q])) : "—"); end - println(q == maximum(qs) ? " <- edge" : "") - end - println("\n (2) cosine similarity with nominal ||/(‖v_nom‖‖v‖) (1.0 = same pattern):") - @printf(" %-7s", "q\\$param") - for v in vals; @printf(" | %9.3g", v); end; println() - for q in qs - @printf(" q=%-5.1f", q) - for r in runs - if haskey(r[2], q) - c = cosine(vnom[q], r[2][q]) - @printf(" | %9s", isnan(c) ? "n/a(ncoil)" : @sprintf("%.6f", c)) - else - @printf(" | %9s", "—") - end - end - println() - end -end - -report("Sweep A — delta_coil vs dmlim ∈ (0,1) [nominal ncoil=$nc_nom]", "dmlim", DMLIMS, runsA) -SKIP_B || report("Sweep B — delta_coil vs singfac_min [dmlim=0.2]", "sfac", SINGFAC, runsB) -println("\nread: ‖v‖ moves ⇒ magnitude sensitivity; cosine<1 ⇒ the response PATTERN over coil modes rotated.") -println("cosine≈1 with ‖v‖ varying ⇒ pure rescaling; cosine falling ⇒ genuine change of the coil-response shape.") diff --git a/benchmarks/deltacoil_sensitivity/scripts/deltacoil_psihigh_metric.jl b/benchmarks/deltacoil_sensitivity/scripts/deltacoil_psihigh_metric.jl deleted file mode 100644 index f1abb0ed..00000000 --- a/benchmarks/deltacoil_sensitivity/scripts/deltacoil_psihigh_metric.jl +++ /dev/null @@ -1,136 +0,0 @@ -#!/usr/bin/env julia -# deltacoil_psihigh_metric.jl — the dot-product (cosine) shape metric of plot 2, but with the truncation -# scanned by psihigh (absolute outer flux boundary) instead of dmlim. -# -# For each rational surface s and each psihigh, we form v_s = vec(delta_coil rows for that surface) and -# report the cosine similarity with the NOMINAL (full-domain) run: -# cos = || / (‖v_s^nom‖ ‖v_s‖) -# 1.0 means the response pattern over coil modes is identical (only rescaled); < 1.0 means it ROTATED. -# This catches shape changes that the norm ‖v_s‖ hides. -# -# Wrinkle handled: as psihigh grows, surfaces enter, and the coil poloidal-mode grid (mlow..mhigh) changes, -# so the raw column count differs between runs. We therefore ALIGN by poloidal mode m: the cosine for a -# surface uses only the m-columns common to that run and the nominal. Nominal = the largest psihigh (full -# domain). Runs with gal_match_flag=false (delta_coil only). psihigh is log-packed toward the edge. -# -# Usage: julia --project= scripts/deltacoil_psihigh_metric.jl [N] [psihigh_max] - -using GeneralizedPerturbedEquilibrium, HDF5, LinearAlgebra, Printf - -length(ARGS) >= 1 || error("usage: julia --project= scripts/deltacoil_psihigh_metric.jl [N] [psihigh_max]") -base = ARGS[1]; isdir(base) || error("no such dir: $base") -N = length(ARGS) >= 2 ? parse(Int, ARGS[2]) : 20 -PSIH_MAX = length(ARGS) >= 3 ? parse(Float64, ARGS[3]) : 0.993 -PSIH_MIN = 0.1 -ENV["DELTACOIL_MODE"] = "driven"; delete!(ENV, "DELTACOIL_PROJECT") - -PSIHIGHS = round.(1 .- 10 .^ range(log10(1 - PSIH_MIN), log10(1 - PSIH_MAX); length=N); digits=5) - -base_toml = read(joinpath(base, "gpec.toml"), String) -scratch = mktempdir() -eqm = match(r"eq_filename\s*=\s*\"([^\"]+)\"", base_toml) -eqfile = eqm === nothing ? nothing : eqm.captures[1] -eqsrc = eqfile === nothing ? nothing : (isabspath(eqfile) ? eqfile : joinpath(base, eqfile)) - -# run GPEC at one psihigh; return Dict q => (block::Matrix (2×ncoil), mlow::Int), or nothing on failure. -function run_one(ph) - tag = "ph_$(ph)" - dir = joinpath(scratch, tag); mkpath(dir) - if eqfile !== nothing - dst = joinpath(dir, basename(eqfile)); islink(dst) || symlink(realpath(eqsrc), dst) - end - toml = base_toml - for key in ("force_termination", "HDF5_filename", "psiedge", "set_psilim_via_dmlim", "gal_match_flag", - "truncate_at_dW_peak", "thmax0", "delta_mband") - toml = replace(toml, Regex("(?m)^[ \\t]*$key[ \\t]*=.*\\n") => "") - end - toml = replace(toml, "[ForceFreeStates]" => - "[ForceFreeStates]\nforce_termination = true\nHDF5_filename = \"$tag.h5\"\npsiedge = 1.0\n" * - "set_psilim_via_dmlim = false\ntruncate_at_dW_peak = false\ngal_match_flag = false"; count=1) - toml = replace(toml, r"(?m)^([ \t]*)psihigh[ \t]*=[ \t]*[\d.eE+-]+" => SubstitutionString("\\1psihigh = $ph")) - eqfile !== nothing && (toml = replace(toml, r"eq_filename\s*=\s*\"[^\"]+\"" => "eq_filename = \"$(basename(eqfile))\"")) - write(joinpath(dir, "gpec.toml"), toml) - @info ">>> psihigh = $ph" - try - GeneralizedPerturbedEquilibrium.main([dir]) - catch err - @warn "run failed at psihigh=$ph" exception = (err, catch_backtrace()) - return nothing - end - h5path = joinpath(dir, "$tag.h5") - isfile(h5path) || return Dict{Float64,Tuple{Matrix{ComplexF64},Int}}() - h5open(h5path, "r") do fid - haskey(fid, "singular/q") || return Dict{Float64,Tuple{Matrix{ComplexF64},Int}}() - q = vec(read(fid, "singular/q")) - isempty(q) && return Dict{Float64,Tuple{Matrix{ComplexF64},Int}}() - dc = read(fid, "singular/delta_coil_matrix") - dc = size(dc, 1) < size(dc, 2) ? dc : permutedims(dc) # -> (2msing, ncoil), columns m=mlow..mhigh - mlow = Int(read(fid, "info/mlow")) - out = Dict{Float64,Tuple{Matrix{ComplexF64},Int}}() - for s in 1:length(q); out[round(q[s]; digits=2)] = (Matrix{ComplexF64}(dc[2s-1:2s, :]), mlow); end - out - end -end - -# cosine of surface blocks aligned by poloidal mode m (use only the m-columns common to both) -function cosine_aligned(run, nom) - (br, mlr) = run; (bn, mln) = nom - ncr = size(br, 2); ncn = size(bn, 2) - mhr = mlr + ncr - 1; mhn = mln + ncn - 1 - mlo = max(mlr, mln); mhi = min(mhr, mhn) - mhi >= mlo || return NaN - cr = (mlo - mlr + 1):(mhi - mlr + 1) - cn = (mlo - mln + 1):(mhi - mln + 1) - a = vec(br[:, cr]); b = vec(bn[:, cn]) - (norm(a) > 0 && norm(b) > 0) ? abs(dot(b, a)) / (norm(a) * norm(b)) : NaN -end - -results = [(ph, run_one(ph)) for ph in PSIHIGHS] -# nominal = the largest psihigh with data (full domain) -nom_idx = findlast(r -> r[2] !== nothing && !isempty(r[2]), results) -nom_idx === nothing && error("no successful runs") -nominal = results[nom_idx][2] -@info "nominal (full-domain) psihigh = $(results[nom_idx][1]), surfaces present: $(sort(collect(keys(nominal))))" - -allq = sort(collect(reduce(union, (Set(keys(r[2])) for r in results if r[2] !== nothing); init=Set{Float64}()))) - -println("\n" * "="^78) -println(" Plot-2 dot-product (cosine) metric vs psihigh truncation (nominal = full domain)") -println(" cos = || / (‖v_nom‖‖v‖) per surface, aligned by poloidal mode m") -println("="^78) -@printf(" %-9s", "psihigh") -for q in allq; @printf(" | q=%-6.1f", q); end; println() -println(" " * "-"^(11 + 11 * length(allq))) -for (ph, r) in results - @printf(" %-9.4f", ph) - for q in allq - if r === nothing || !haskey(r, q) || !haskey(nominal, q) - @printf(" | %8s", "—") - else - c = cosine_aligned(r[q], nominal[q]) - @printf(" | %8s", isnan(c) ? "n/a" : @sprintf("%.4f", c)) - end - end - println() -end - -outdir = joinpath(@__DIR__, "..", "results"); mkpath(outdir) -csv = joinpath(outdir, "deltacoil_psihigh_metric.csv") -open(csv, "w") do io - println(io, "psihigh," * join(["q$(q)" for q in allq], ",")) - for (ph, r) in results - vals = String[] - for q in allq - if r === nothing || !haskey(r, q) || !haskey(nominal, q) - push!(vals, "") - else - c = cosine_aligned(r[q], nominal[q]) - push!(vals, isnan(c) ? "" : string(c)) - end - end - println(io, "$ph," * join(vals, ",")) - end -end -println("\nwrote: $(abspath(csv))") -println("read: cos ≈ 1 ⇒ shape unchanged (only rescaled by truncation); cos < 1 ⇒ the coil-response") -println("pattern ROTATED as the truncation boundary moved. The norm alone would hide this.") diff --git a/benchmarks/deltacoil_sensitivity/scripts/deltacoil_psihigh_riccati_galerkin.jl b/benchmarks/deltacoil_sensitivity/scripts/deltacoil_psihigh_riccati_galerkin.jl deleted file mode 100644 index 7b0f4319..00000000 --- a/benchmarks/deltacoil_sensitivity/scripts/deltacoil_psihigh_riccati_galerkin.jl +++ /dev/null @@ -1,152 +0,0 @@ -#!/usr/bin/env julia -# deltacoil_psihigh_riccati_galerkin.jl -- unified psihigh truncation scan that records BOTH the -# Riccati/STRIDE delta_coil (singular/delta_coil_matrix) and the Galerkin/RDCON delta_coil -# (galerkin/delta_coil) at every psihigh, so both methods can be compared on one plot. -# -# One GPEC run per psihigh serves both the norm view and the shape (cosine) view, so this replaces -# running deltacoil_psihigh_scan.jl and deltacoil_psihigh_metric.jl separately. Per surface and per -# method it writes: -# norm = ||delta_coil block for that surface|| -# cos = || / (||v_nominal|| ||v||), aligned by poloidal mode m, nominal = largest psihigh -# cos = 1 means the coil-response pattern is unchanged (only rescaled); cos < 1 means it rotated. -# -# Config matches the earlier psihigh scans: set_psilim_via_dmlim=false, psiedge=1, truncate_at_dW_peak=false, -# gal_match_flag=false (delta_coil only). psihigh is log-packed toward the edge. -# -# Usage: julia --project= scripts/deltacoil_psihigh_riccati_galerkin.jl [N] [psihigh_max] [psihigh_min] - -using GeneralizedPerturbedEquilibrium, HDF5, LinearAlgebra, Printf - -length(ARGS) >= 1 || error("usage: julia --project= scripts/deltacoil_psihigh_riccati_galerkin.jl [N] [psihigh_max] [psihigh_min]") -base = ARGS[1]; isdir(base) || error("no such dir: $base") -N = length(ARGS) >= 2 ? parse(Int, ARGS[2]) : 100 -PSIH_MAX = length(ARGS) >= 3 ? parse(Float64, ARGS[3]) : 0.9999 -PSIH_MIN = length(ARGS) >= 4 ? parse(Float64, ARGS[4]) : 0.9 -ENV["DELTACOIL_MODE"] = "driven"; delete!(ENV, "DELTACOIL_PROJECT") - -# logarithmic packing toward the edge: dense near psi -> 1, first point = PSIH_MIN, last = PSIH_MAX -PSIHIGHS = round.(1 .- 10 .^ range(log10(1 - PSIH_MIN), log10(1 - PSIH_MAX); length=N); digits=6) - -base_toml = read(joinpath(base, "gpec.toml"), String) -scratch = mktempdir() -eqm = match(r"eq_filename\s*=\s*\"([^\"]+)\"", base_toml) -eqfile = eqm === nothing ? nothing : eqm.captures[1] -eqsrc = eqfile === nothing ? nothing : (isabspath(eqfile) ? eqfile : joinpath(base, eqfile)) - -# per-surface delta_coil block, keyed by q, for one method's HDF5 dataset -function blocks(fid, path, q) - haskey(fid, path) || return nothing - dc = read(fid, path) - dc = size(dc, 1) < size(dc, 2) ? dc : permutedims(dc) # -> (2msing, ncoil), columns m=mlow..mhigh - out = Dict{Float64,Matrix{ComplexF64}}() - for s in 1:length(q); out[round(q[s]; digits=2)] = Matrix{ComplexF64}(dc[2s-1:2s, :]); end - out -end - -# run GPEC at one psihigh; return (q-values, ric blocks, gal blocks, mlow), or nothing on failure. -function run_one(ph) - tag = "ph_$(ph)" - dir = joinpath(scratch, tag); mkpath(dir) - if eqfile !== nothing - dst = joinpath(dir, basename(eqfile)); islink(dst) || symlink(realpath(eqsrc), dst) - end - toml = base_toml - for key in ("force_termination", "HDF5_filename", "psiedge", "set_psilim_via_dmlim", "gal_match_flag", - "truncate_at_dW_peak", "thmax0", "delta_mband") - toml = replace(toml, Regex("(?m)^[ \\t]*$key[ \\t]*=.*\\n") => "") - end - toml = replace(toml, "[ForceFreeStates]" => - "[ForceFreeStates]\nforce_termination = true\nHDF5_filename = \"$tag.h5\"\npsiedge = 1.0\n" * - "set_psilim_via_dmlim = false\ntruncate_at_dW_peak = false\ngal_match_flag = false"; count=1) - toml = replace(toml, r"(?m)^([ \t]*)psihigh[ \t]*=[ \t]*[\d.eE+-]+" => SubstitutionString("\\1psihigh = $ph")) - eqfile !== nothing && (toml = replace(toml, r"eq_filename\s*=\s*\"[^\"]+\"" => "eq_filename = \"$(basename(eqfile))\"")) - write(joinpath(dir, "gpec.toml"), toml) - @info ">>> psihigh = $ph" - try - GeneralizedPerturbedEquilibrium.main([dir]) - catch err - @warn "run failed at psihigh=$ph" exception = (err, catch_backtrace()) - return nothing - end - h5path = joinpath(dir, "$tag.h5") - isfile(h5path) || return nothing - h5open(h5path, "r") do fid - haskey(fid, "singular/q") || return nothing - q = vec(read(fid, "singular/q")); isempty(q) && return nothing - mlow = Int(read(fid, "info/mlow")) - ric = blocks(fid, "singular/delta_coil_matrix", q) - gal = blocks(fid, "galerkin/delta_coil", q) - (q=sort(round.(q; digits=2)), ric=ric, gal=gal, mlow=mlow) - end -end - -# cosine between a run block and the nominal block, aligned by common poloidal modes m -function cosine_aligned(br, mlr, bn, mln) - ncr = size(br, 2); ncn = size(bn, 2) - mlo = max(mlr, mln); mhi = min(mlr + ncr - 1, mln + ncn - 1) - mhi >= mlo || return NaN - a = vec(br[:, (mlo - mlr + 1):(mhi - mlr + 1)]) - b = vec(bn[:, (mlo - mln + 1):(mhi - mln + 1)]) - (norm(a) > 0 && norm(b) > 0) ? abs(dot(b, a)) / (norm(a) * norm(b)) : NaN -end - -results = [(ph, run_one(ph)) for ph in PSIHIGHS] - -# nominal = the largest psihigh that produced data (full-domain reference for the cosine metric) -nom_idx = findlast(r -> r[2] !== nothing, results) -nom_idx === nothing && error("no successful runs") -nom = results[nom_idx][2] -@info "nominal (full-domain) psihigh = $(results[nom_idx][1]); surfaces present: $(nom.q)" - -allq = sort(collect(reduce(union, (Set(r[2].q) for r in results if r[2] !== nothing); init=Set{Float64}()))) - -# helper: norm / cosine for a method (:ric or :gal) at a given surface for a run r -getblk(r, meth, q) = (d = getfield(r, meth); (d === nothing || !haskey(d, q)) ? nothing : d[q]) -nrm(r, meth, q) = (b = getblk(r, meth, q); b === nothing ? NaN : norm(b)) -function cosm(r, meth, q) - b = getblk(r, meth, q); bn = getblk(nom, meth, q) - (b === nothing || bn === nothing) ? NaN : cosine_aligned(b, r.mlow, bn, nom.mlow) -end - -outdir = joinpath(@__DIR__, "..", "results"); mkpath(outdir) -tag = replace(basename(base), r"[^A-Za-z0-9]" => "") -csv = joinpath(outdir, "deltacoil_psihigh_rg_$tag.csv") -open(csv, "w") do io - cols = String["psihigh"] - for q in allq, meth in ("ric", "gal"), metric in ("norm", "cos"); push!(cols, "$(metric)_$(meth)_q$(q)"); end - println(io, join(cols, ",")) - for (ph, r) in results - vals = String[string(ph)] - for q in allq - if r === nothing - append!(vals, ["", "", "", ""]) - else - nr = nrm(r, :ric, q); cr = cosm(r, :ric, q) - ng = nrm(r, :gal, q); cg = cosm(r, :gal, q) - push!(vals, isnan(nr) ? "" : string(nr)); push!(vals, isnan(cr) ? "" : string(cr)) - push!(vals, isnan(ng) ? "" : string(ng)); push!(vals, isnan(cg) ? "" : string(cg)) - end - end - println(io, join(vals, ",")) - end -end - -# console summary -nfail = count(r -> r[2] === nothing, results) -println("\n" * "="^80) -println(" delta_coil psihigh truncation ($(basename(base))): Riccati vs Galerkin, $N points, " - * "psihigh $PSIH_MIN -> $PSIH_MAX") -println(" points that produced data: $(N - nfail)/$N (failures recorded as blanks)") -println("="^80) -@printf(" %-10s", "psihigh") -for q in allq; @printf(" | q%-4.1f ric | q%-4.1f gal", q, q); end; println() -for (ph, r) in results - @printf(" %-10.5f", ph) - for q in allq - @printf(" | %8s | %8s", - r === nothing || isnan(nrm(r, :ric, q)) ? "-" : @sprintf("%.2e", nrm(r, :ric, q)), - r === nothing || isnan(nrm(r, :gal, q)) ? "-" : @sprintf("%.2e", nrm(r, :gal, q))) - end - println() -end -println("\nwrote: $(abspath(csv))") diff --git a/benchmarks/deltacoil_sensitivity/scripts/deltacoil_psihigh_scan.jl b/benchmarks/deltacoil_sensitivity/scripts/deltacoil_psihigh_scan.jl deleted file mode 100644 index 251df5e4..00000000 --- a/benchmarks/deltacoil_sensitivity/scripts/deltacoil_psihigh_scan.jl +++ /dev/null @@ -1,121 +0,0 @@ -#!/usr/bin/env julia -# deltacoil_psihigh_scan.jl — sensitivity of delta_coil to the outer truncation, scanned via the -# ABSOLUTE flux boundary psihigh (instead of the relative dmlim). -# -# Setup per the requested convention: -# set_psilim_via_dmlim = false → psilim follows psihigh (dmlim truncation OFF) -# psiedge = 1 → disable the edge-dW scan band -# truncate_at_dW_peak = false → psihigh is the SOLE truncation control -# gal_match_flag = false → delta_coil only (no inner match; no msing/array guard; surface -# count free to change as psihigh moves past each rational ψ) -# -# psihigh is swept from PSIH_MIN(0.1) to PSIH_MAX(~edge) with LOGARITHMIC packing toward the edge -# (points cluster near ψ→1, where truncation bites). Results aligned BY q-VALUE, so surfaces -# appearing/disappearing across the sweep are handled cleanly. -# -# Usage (repo root, project env): -# julia --project=/abs/path/to/GPEC scripts/deltacoil_psihigh_scan.jl [N] [psihigh_max] -# -# Runs GPEC once per point (~2 min each). Each point re-solves the equilibrium out to psihigh. - -using GeneralizedPerturbedEquilibrium, HDF5, LinearAlgebra, Printf - -length(ARGS) >= 1 || error("usage: julia --project= scripts/deltacoil_psihigh_scan.jl [N] [psihigh_max]") -base = ARGS[1]; isdir(base) || error("no such dir: $base") -N = length(ARGS) >= 2 ? parse(Int, ARGS[2]) : 10 -PSIH_MAX = length(ARGS) >= 3 ? parse(Float64, ARGS[3]) : 0.993 # numerically-safe edge (separatrix ~1 is unreasonable) -PSIH_MIN = 0.1 -ENV["DELTACOIL_MODE"] = "driven"; delete!(ENV, "DELTACOIL_PROJECT") - -# logarithmic packing toward the edge: dense near ψ→1, first point = PSIH_MIN, last = PSIH_MAX -PSIHIGHS = round.(1 .- 10 .^ range(log10(1 - PSIH_MIN), log10(1 - PSIH_MAX); length=N); digits=5) - -base_toml = read(joinpath(base, "gpec.toml"), String) -scratch = mktempdir() -eqm = match(r"eq_filename\s*=\s*\"([^\"]+)\"", base_toml) -eqfile = eqm === nothing ? nothing : eqm.captures[1] -eqsrc = eqfile === nothing ? nothing : (isabspath(eqfile) ? eqfile : joinpath(base, eqfile)) - -# run GPEC at one psihigh; return (sorted q-values, |delta_coil| per surface keyed by q). nothing on failure. -function run_one(ph) - tag = "ph_$(ph)" - dir = joinpath(scratch, tag); mkpath(dir) - if eqfile !== nothing - dst = joinpath(dir, basename(eqfile)); islink(dst) || symlink(realpath(eqsrc), dst) - end - toml = base_toml - # Strip every FFS knob we override (examples differ: some set force_termination/HDF5_filename/psiedge, - # some don't — stripping first avoids TOML duplicate-key errors), then inject one canonical block after - # the section header. delta_coil is computed inside ForceFreeStates, so force_termination=true stops - # before PerturbedEquilibrium and the explicit HDF5_filename is the file we read back. - # also drop keys removed from ForceFreeStatesControl on this branch (thmax0, delta_mband) — some - # example tomls (e.g. LAR) still carry them and would otherwise fail the control constructor. - for key in ("force_termination", "HDF5_filename", "psiedge", "set_psilim_via_dmlim", "gal_match_flag", - "truncate_at_dW_peak", "thmax0", "delta_mband") - toml = replace(toml, Regex("(?m)^[ \\t]*$key[ \\t]*=.*\\n") => "") - end - toml = replace(toml, "[ForceFreeStates]" => - "[ForceFreeStates]\nforce_termination = true\nHDF5_filename = \"$tag.h5\"\npsiedge = 1.0\n" * - "set_psilim_via_dmlim = false\ntruncate_at_dW_peak = false\ngal_match_flag = false"; count=1) - toml = replace(toml, r"(?m)^([ \t]*)psihigh[ \t]*=[ \t]*[\d.eE+-]+" => SubstitutionString("\\1psihigh = $ph")) - eqfile !== nothing && (toml = replace(toml, r"eq_filename\s*=\s*\"[^\"]+\"" => "eq_filename = \"$(basename(eqfile))\"")) - write(joinpath(dir, "gpec.toml"), toml) - @info ">>> psihigh = $ph" - try - GeneralizedPerturbedEquilibrium.main([dir]) - catch err - @warn "run failed at psihigh=$ph" exception = (err, catch_backtrace()) - return nothing - end - h5path = joinpath(dir, "$tag.h5") - isfile(h5path) || (@warn "no output h5 at psihigh=$ph (likely no singular surfaces inside psihigh)"; return (Float64[], Dict{Float64,Float64}())) - h5open(h5path, "r") do fid - haskey(fid, "singular/q") || return (Float64[], Dict{Float64,Float64}()) - q = vec(read(fid, "singular/q")) - isempty(q) && return (Float64[], Dict{Float64,Float64}()) - dc = read(fid, "singular/delta_coil_matrix") - dc = size(dc, 1) < size(dc, 2) ? dc : permutedims(dc) # -> (2msing, ncoil) - mag = Dict{Float64,Float64}() - for s in 1:length(q); mag[round(q[s]; digits=2)] = norm(dc[2s-1:2s, :]); end - (sort(round.(q; digits=2)), mag) - end -end - -runs = [(ph, run_one(ph)) for ph in PSIHIGHS] - -# ---- report ---- -allq = sort(collect(reduce(union, (Set(keys(r[2][2])) for r in runs if r[2] !== nothing); init=Set{Float64}()))) -println("\n" * "="^78) -println(" delta_coil vs OUTER TRUNCATION scanned by psihigh (log-packed toward edge)") -println(" set_psilim_via_dmlim=false, psiedge=1, truncate_at_dW_peak=false, gal_match_flag=false") -println(" |delta_coil| per surface (blank = surface not present at that psihigh)") -println("="^78) -@printf(" %-9s", "psihigh") -for q in allq; @printf(" | q=%-6.1f", q); end; println() -println(" " * "-"^(11 + 11 * length(allq))) -for (ph, r) in runs - @printf(" %-9.4f", ph) - if r === nothing - for _ in allq; @printf(" | %8s", "FAIL"); end - else - for q in allq - m = get(r[2], q, nothing) - m === nothing ? @printf(" | %8s", "—") : @printf(" | %8.2e", m) - end - end - println() -end - -# CSV for plotting -outdir = joinpath(@__DIR__, "..", "results"); mkpath(outdir) -csv = joinpath(outdir, "deltacoil_psihigh_result.csv") -open(csv, "w") do io - println(io, "psihigh," * join(["q$(q)" for q in allq], ",")) - for (ph, r) in runs - vals = [r === nothing ? "" : (haskey(r[2], q) ? string(r[2][q]) : "") for q in allq] - println(io, "$ph," * join(vals, ",")) - end -end -println("\nwrote: $(abspath(csv))") -println("interpretation: as psihigh → edge, more surfaces enter and each surface's |delta_coil|") -println("settles; the edge-most surface is where the truncation boundary bites hardest.") diff --git a/benchmarks/deltacoil_sensitivity/scripts/deltacoil_rdcon_convergence.jl b/benchmarks/deltacoil_sensitivity/scripts/deltacoil_rdcon_convergence.jl deleted file mode 100644 index 863f5e18..00000000 --- a/benchmarks/deltacoil_sensitivity/scripts/deltacoil_rdcon_convergence.jl +++ /dev/null @@ -1,63 +0,0 @@ -#!/usr/bin/env julia -# deltacoil_rdcon_convergence.jl — does STRIDE's delta_coil converge to RDCON as STRIDE refines? -# -# Sweeps STRIDE's singular-expansion order (sing_order) and, per inner surface (q2, q3), reports: -# (a) STRIDE self-convergence — |dc(order) − dc(order_max)| / |dc(order_max)| (should → 0) -# (b) correlation of STRIDE |delta_coil| with the FIXED RDCON reference (galerkin/delta_coil). -# If STRIDE self-converges (a→0) but the RDCON correlation (b) stays FLAT below 1, the residual is -# the weak-form-vs-strong-form representation, not STRIDE under-resolution. If (b) climbs toward 1, -# it was resolution. Runs with the match off (delta_coil only). RDCON ref read from the example gpec.h5. -# -# Usage: julia --project= scripts/deltacoil_rdcon_convergence.jl [ord1 ord2 ...] - -using GeneralizedPerturbedEquilibrium, HDF5, LinearAlgebra, Statistics, Printf - -length(ARGS) >= 1 || error("usage: julia --project= scripts/deltacoil_rdcon_convergence.jl [orders...]") -base = ARGS[1]; isdir(base) || error("no such dir: $base") -ORDERS = length(ARGS) >= 2 ? parse.(Int, ARGS[2:end]) : [4, 6, 8] -ENV["DELTACOIL_MODE"] = "driven"; delete!(ENV, "DELTACOIL_PROJECT") - -base_toml = read(joinpath(base, "gpec.toml"), String) -scratch = mktempdir() -eqm = match(r"eq_filename\s*=\s*\"([^\"]+)\"", base_toml); eqm === nothing && error("no eq_filename") -eqfile = eqm.captures[1]; eqsrc = isabspath(eqfile) ? eqfile : joinpath(base, eqfile) - -align8(a) = size(a, 1) == 8 ? a : permutedims(a) # -> (8 surface-side, ncoil) -R = align8(h5read(joinpath(base, "gpec.h5"), "galerkin/delta_coil")) # fixed RDCON reference -corr(a, b) = cor(abs.(vec(a)), abs.(vec(b))) - -function run_order(ord) - dir = joinpath(scratch, "ord_$ord"); mkpath(dir) - dst = joinpath(dir, basename(eqfile)); islink(dst) || symlink(realpath(eqsrc), dst) - toml = replace(base_toml, r"gal_match_flag\s*=\s*\w+" => "gal_match_flag = false") - toml = replace(toml, r"gal_sing_order_ceiling\s*=\s*\w+" => "gal_sing_order_ceiling = false") - if occursin(r"(?m)^[ \t]*sing_order[ \t]*=[ \t]*\d+", toml) - toml = replace(toml, r"(?m)^([ \t]*)sing_order[ \t]*=[ \t]*\d+" => SubstitutionString("\\1sing_order = $ord")) - else - toml = replace(toml, "[ForceFreeStates]" => "[ForceFreeStates]\nsing_order = $ord"; count=1) - end - toml = replace(toml, r"eq_filename\s*=\s*\"[^\"]+\"" => "eq_filename = \"$(basename(eqfile))\"") - toml = replace(toml, r"HDF5_filename\s*=\s*\"[^\"]+\"" => "HDF5_filename = \"ord_$ord.h5\"") - write(joinpath(dir, "gpec.toml"), toml) - @info ">>> sing_order = $ord" - GeneralizedPerturbedEquilibrium.main([dir]) - align8(h5read(joinpath(dir, "ord_$ord.h5"), "singular/delta_coil_matrix")) -end - -res = Dict(o => run_order(o) for o in ORDERS) -omax = maximum(ORDERS); ref = res[omax] -inner_rows = [1, 2, 3, 4] # q2L,q2R,q3L,q3R - -println("\n" * "="^70) -println(" STRIDE delta_coil: self-convergence vs correlation with RDCON (inner surfaces)") -println("="^70) -@printf(" %-6s | %-22s | %-22s\n", "order", "(a) self-conv vs ord $omax", "(b) corr with RDCON") -@printf(" %-6s | %-10s %-10s | %-10s %-10s\n", "", "q2", "q3", "q2", "q3") -println(" " * "-"^62) -for o in sort(ORDERS) - sc(rows) = norm(abs.(res[o][rows,:]) .- abs.(ref[rows,:])) / max(norm(abs.(ref[rows,:])), 1e-300) - cr(rows) = mean(corr(res[o][r,:], R[r,:]) for r in rows) - @printf(" %-6d | %-10.2e %-10.2e | %-10.3f %-10.3f\n", o, sc(1:2), sc(3:4), cr(1:2), cr(3:4)) -end -println("\ninterpretation: if (a) → 0 while (b) stays flat below 1, the STRIDE↔RDCON gap is representational") -println("(weak-form vs strong-form), NOT STRIDE resolution — so refining STRIDE will not close it.") diff --git a/benchmarks/deltacoil_sensitivity/scripts/deltacoil_sensitivity.jl b/benchmarks/deltacoil_sensitivity/scripts/deltacoil_sensitivity.jl deleted file mode 100644 index 57353de9..00000000 --- a/benchmarks/deltacoil_sensitivity/scripts/deltacoil_sensitivity.jl +++ /dev/null @@ -1,102 +0,0 @@ -#!/usr/bin/env julia -# deltacoil_sensitivity.jl — sensitivity of delta_coil to (a) the outer truncation point dmlim and -# (b) the rational-surface approach distance singfac_min. -# -# delta_coil (singular/delta_coil_matrix) is the outer small-solution response to unit edge sources. -# It is computed by the rpec edge loop INDEPENDENTLY of the resistive match, so this runs with -# gal_match_flag = false — no inner-layer match, hence no msing/array guard, and dmlim is free to -# change the surface count. Results are aligned BY q-VALUE (not row index), so a surface appearing or -# disappearing across the sweep is handled cleanly. -# -# Sweep A: dmlim ∈ (0,1) with set_psilim_via_dmlim = true (moves the outer truncation boundary) -# Sweep B: singfac_min (moves the rational-surface cutoff) -# -# Usage (repo root, project env): -# julia --project=/abs/path/to/GPEC scripts/deltacoil_sensitivity.jl examples/DIIID-like_gal_resistive_pe_example -# -# Runs GPEC once per sweep point (~2 min each). Edit the value lists below to change the sampling. - -using GeneralizedPerturbedEquilibrium, HDF5, LinearAlgebra, Printf - -length(ARGS) >= 1 || error("usage: julia --project= scripts/deltacoil_sensitivity.jl ") -base = ARGS[1]; isdir(base) || error("no such dir: $base") -ENV["DELTACOIL_MODE"] = "driven"; delete!(ENV, "DELTACOIL_PROJECT") - -DMLIMS = [0.1, 0.3, 0.5, 0.7, 0.9] # open interval (0,1) -SINGFAC = [1e-5, 1e-4, 1e-3] # rational-surface approach distance - -base_toml = read(joinpath(base, "gpec.toml"), String) -scratch = mktempdir() -eqm = match(r"eq_filename\s*=\s*\"([^\"]+)\"", base_toml); eqm === nothing && error("no eq_filename") -eqfile = eqm.captures[1]; eqsrc = isabspath(eqfile) ? eqfile : joinpath(base, eqfile) - -# run GPEC with the given toml patches; return (q-values, per-surface |delta_coil| dict keyed by q) -function run_one(tag, patches) - dir = joinpath(scratch, tag); mkpath(dir) - dst = joinpath(dir, basename(eqfile)); islink(dst) || symlink(realpath(eqsrc), dst) - toml = base_toml - for (r, s) in patches; toml = replace(toml, r => s); end - toml = replace(toml, r"gal_match_flag\s*=\s*\w+" => "gal_match_flag = false") # delta_coil only - toml = replace(toml, r"eq_filename\s*=\s*\"[^\"]+\"" => "eq_filename = \"$(basename(eqfile))\"") - # delta_coil is written by ForceFreeStates; terminate there (skip PE, which would otherwise send the - # consolidated output to the default gpec.h5) and pin the FFS output file we read back. Strip any - # pre-existing copies first to avoid TOML duplicate-key errors across example variants. - for key in ("force_termination", "HDF5_filename") - toml = replace(toml, Regex("(?m)^[ \\t]*$key[ \\t]*=.*\\n") => "") - end - toml = replace(toml, "[ForceFreeStates]" => "[ForceFreeStates]\nforce_termination = true\nHDF5_filename = \"$tag.h5\""; count=1) - write(joinpath(dir, "gpec.toml"), toml) - @info ">>> $tag" - GeneralizedPerturbedEquilibrium.main([dir]) - h5open(joinpath(dir, "$tag.h5"), "r") do fid - q = vec(read(fid, "singular/q")) - dc = read(fid, "singular/delta_coil_matrix") - dc = size(dc, 1) < size(dc, 2) ? dc : permutedims(dc) # -> (2msing, ncoil) - mag = Dict{Float64,Float64}() - for s in 1:length(q); mag[round(q[s]; digits=2)] = norm(dc[2s-1:2s, :]); end - (sort(round.(q; digits=2)), mag) - end -end - -function report(title, param, vals, runs) - qs = sort(collect(reduce(union, (Set(keys(r[2])) for r in runs)))) - println("\n" * "="^72); println(" $title"); println(" |delta_coil| per surface (blank = surface absent at that setting)"); println("="^72) - @printf(" %-6s", "q\\$param") - for v in vals; @printf(" | %9.3g", v); end; println() - for q in qs - @printf(" q=%-4.1f", q) - for (i, v) in enumerate(vals) - m = get(runs[i][2], q, nothing) - m === nothing ? @printf(" | %9s", "—") : @printf(" | %9.3e", m) - end - println(q == maximum(qs) ? " <- edge" : "") - end - # relative change vs the LAST column (reference) - refm = runs[end][2] - println(" " * "-"^66) - println(" relative change of |delta_coil| vs $param=$(vals[end]) (reference):") - for q in qs - haskey(refm, q) || continue - @printf(" q=%-4.1f", q) - for (i, v) in enumerate(vals) - m = get(runs[i][2], q, nothing) - (m === nothing) ? @printf(" | %9s", "—") : @printf(" | %9.2e", abs(m - refm[q]) / max(refm[q], 1e-300)) - end - println() - end -end - -# --- Sweep A: dmlim (truncation) --- -runsA = [run_one("dmlim_$(d)", [r"set_psilim_via_dmlim\s*=\s*\w+" => "set_psilim_via_dmlim = true", - r"(?m)^([ \t]*)dmlim[ \t]*=[ \t]*[\d.]+" => SubstitutionString("\\1dmlim = $d")]) - for d in DMLIMS] -# --- Sweep B: singfac_min (approach distance), truncation fixed at recommended dmlim=0.2 --- -runsB = [run_one("sfac_$(sf)", [r"set_psilim_via_dmlim\s*=\s*\w+" => "set_psilim_via_dmlim = true", - r"(?m)^([ \t]*)dmlim[ \t]*=[ \t]*[\d.]+" => SubstitutionString("\\1dmlim = 0.2"), - r"singfac_min\s*=\s*[\d.eE+-]+" => "singfac_min = $sf"]) - for sf in SINGFAC] - -report("Sweep A — delta_coil vs outer truncation dmlim ∈ (0,1) [set_psilim_via_dmlim=true]", "dmlim", DMLIMS, runsA) -report("Sweep B — delta_coil vs rational-surface cutoff singfac_min [dmlim fixed at 0.2]", "sfac", SINGFAC, runsB) -println("\ninterpretation: flat rows ⇒ delta_coil robust to that knob at that surface; the edge-most") -println("surface is where truncation (dmlim) and approach distance (singfac_min) bite hardest.") diff --git a/benchmarks/deltacoil_sensitivity/scripts/deltacoil_svd_tol.jl b/benchmarks/deltacoil_sensitivity/scripts/deltacoil_svd_tol.jl deleted file mode 100644 index 3e5bc058..00000000 --- a/benchmarks/deltacoil_sensitivity/scripts/deltacoil_svd_tol.jl +++ /dev/null @@ -1,113 +0,0 @@ -#!/usr/bin/env julia -# deltacoil_svd_tol.jl — completes the numerical-robustness metric set for delta_coil: -# (A) SVD of the delta_coil matrix per run — singular VALUES (spectrum, spectral & Frobenius norm, -# condition number) and singular VECTORS (leading left-vector alignment with the nominal). -# (B) integration-tolerance scan (eulerlagrange_tolerance) with the per-surface norm + dot-product -# metrics, to confirm delta_coil is insensitive to the ODE tolerance. -# -# delta_coil_matrix is (2·msing, ncoil). SVD works on any shape; the left singular vectors live in the -# (2·msing) surface-side space, so leading-vector alignment vs nominal is defined only when msing matches -# (marked n/a when a dmlim run has dropped q5). Runs use gal_match_flag=false (delta_coil is independent -# of the match). Nominal = baseline as-shipped. -# -# Usage: julia --project= scripts/deltacoil_svd_tol.jl - -using GeneralizedPerturbedEquilibrium, HDF5, LinearAlgebra, Printf - -length(ARGS) >= 1 || error("usage: julia --project= scripts/deltacoil_svd_tol.jl ") -base = ARGS[1]; isdir(base) || error("no such dir: $base") -ENV["DELTACOIL_MODE"] = "driven"; delete!(ENV, "DELTACOIL_PROJECT") - -DMLIMS = [0.30, 0.42, 0.50] # below / at / above the q5-drop threshold (0.426) -TOLS = [1e-8, 1e-10, 1e-12] # eulerlagrange_tolerance - -base_toml = read(joinpath(base, "gpec.toml"), String) -scratch = mktempdir() -eqm = match(r"eq_filename\s*=\s*\"([^\"]+)\"", base_toml); eqm === nothing && error("no eq_filename") -eqfile = eqm.captures[1]; eqsrc = isabspath(eqfile) ? eqfile : joinpath(base, eqfile) - -# returns (q-values, delta_coil matrix M as (2msing, ncoil)) -function run_one(tag, patches) - dir = joinpath(scratch, tag); mkpath(dir) - dst = joinpath(dir, basename(eqfile)); islink(dst) || symlink(realpath(eqsrc), dst) - toml = base_toml - for (r, s) in patches; toml = replace(toml, r => s); end - toml = replace(toml, r"eq_filename\s*=\s*\"[^\"]+\"" => "eq_filename = \"$(basename(eqfile))\"") - # Inject FFS controls after the section header: examples may omit these keys, so a plain regex - # replace is not enough - strip any existing, then inject. set_psilim_via_dmlim/dmlim stay in `patches`. - for key in ("force_termination", "HDF5_filename", "gal_match_flag") - toml = replace(toml, Regex("(?m)^[ \\t]*$key[ \\t]*=.*\\n") => "") - end - toml = replace(toml, "[ForceFreeStates]" => - "[ForceFreeStates]\nforce_termination = true\nHDF5_filename = \"$tag.h5\"\ngal_match_flag = false"; count=1) - write(joinpath(dir, "gpec.toml"), toml) - @info ">>> $tag" - GeneralizedPerturbedEquilibrium.main([dir]) - h5open(joinpath(dir, "$tag.h5"), "r") do fid - q = sort(round.(vec(read(fid, "singular/q")); digits=2)) - M = read(fid, "singular/delta_coil_matrix") - M = size(M, 1) < size(M, 2) ? M : permutedims(M) # (2msing, ncoil) - (q, Matrix{ComplexF64}(M)) - end -end - -cosv(a, b) = (length(a) == length(b) && norm(a) > 0 && norm(b) > 0) ? abs(dot(a, b)) / (norm(a) * norm(b)) : NaN -persurf(q, M) = Dict(q[s] => vec(M[2s-1:2s, :]) for s in 1:length(q)) - -@info "=== nominal ==="; qn, Mn = run_one("nominal", Pair{Regex,String}[]) -Fn = svd(Mn); u1n = Fn.U[:, 1]; vsurf_n = persurf(qn, Mn) - -runsD = [(d, run_one("dmlim_$(d)", [r"set_psilim_via_dmlim\s*=\s*\w+" => "set_psilim_via_dmlim = true", - r"(?m)^([ \t]*)dmlim[ \t]*=[ \t]*[\d.]+" => SubstitutionString("\\1dmlim = $d")])...) - for d in DMLIMS] -runsT = [(t, run_one("tol_$(t)", [r"eulerlagrange_tolerance\s*=\s*[\d.eE+-]+" => "eulerlagrange_tolerance = $t"])...) - for t in TOLS] - -# ---------- (A) SVD metrics ---------- -println("\n" * "="^84) -println(" (A) SVD of the delta_coil matrix M = (2·msing × ncoil)") -println("="^84) -allruns = [("nominal", qn, Mn); [("dmlim=$d", q, M) for (d, q, M) in runsD]; [("tol=$t", q, M) for (t, q, M) in runsT]] -@printf(" %-11s | %-6s | %-10s | %-10s | %-9s | %-9s | %-10s\n", - "run", "2msing", "‖M‖2 (σ1)", "‖M‖F", "σ_min", "cond", "u1·u1_nom") -println(" " * "-"^80) -for (name, q, M) in allruns - F = svd(M) - u1 = F.U[:, 1] - align = cosv(u1n, u1) - @printf(" %-11s | %-6d | %10.4e | %10.4e | %9.3e | %9.2e | %s\n", - name, size(M, 1), F.S[1], norm(M), F.S[end], F.S[1] / F.S[end], - isnan(align) ? " n/a" : @sprintf("%8.5f", align)) -end - -println("\n singular-value spectrum σ_i (first 6):") -@printf(" %-11s", "run") -for i in 1:6; @printf(" | σ%-7d", i); end; println() -for (name, q, M) in allruns - S = svd(M).S - @printf(" %-11s", name) - for i in 1:6; @printf(" | %8.3e", i <= length(S) ? S[i] : NaN); end; println() -end - -# ---------- (B) integration-tolerance robustness ---------- -println("\n" * "="^84) -println(" (B) eulerlagrange_tolerance scan — per-surface ‖v‖ and cosine vs nominal") -println("="^84) -qs = sort(collect(keys(vsurf_n))) -@printf(" %-8s", "q\\tol"); for t in TOLS; @printf(" | %9.0e", t); end; println(" (nominal = as-shipped)") -println(" (1) ‖delta_coil‖ per surface:") -for q in qs - @printf(" q=%-5.1f", q) - for (t, qq, M) in runsT - v = persurf(qq, M); @printf(" | %9s", haskey(v, q) ? @sprintf("%.3e", norm(v[q])) : "—") - end; println() -end -println(" (2) cosine with nominal:") -for q in qs - @printf(" q=%-5.1f", q) - for (t, qq, M) in runsT - v = persurf(qq, M); @printf(" | %9s", haskey(v, q) ? @sprintf("%.6f", cosv(vsurf_n[q], v[q])) : "—") - end; println() -end -println("\nread: (A) stable σ-spectrum + u1·u1_nom≈1 ⇒ matrix conditioning robust; (B) cosine≈1 across") -println("tolerances ⇒ delta_coil independent of the ODE tolerance (as it is of singfac_min).") diff --git a/benchmarks/deltacoil_sensitivity/scripts/deltamn_resistivity_merge.jl b/benchmarks/deltacoil_sensitivity/scripts/deltamn_resistivity_merge.jl deleted file mode 100644 index ad8c82a1..00000000 --- a/benchmarks/deltacoil_sensitivity/scripts/deltamn_resistivity_merge.jl +++ /dev/null @@ -1,167 +0,0 @@ -#!/usr/bin/env julia -# deltamn_resistivity_merge.jl -- Task 5 capstone: merge the outer coupling matrix with the -# eta-dependent inner layer to get the full resonant response vs resistivity. -# -# The coefficient-based Delta_mn is assembled (GalerkinMatch.jl) as -# S_small = transpose(cout) * Dm[1:2msing, :] + Dm[2msing+1 : 2msing+mcoil, :] -# delta_mn[i,j] = KR[i]*S_small[j,2i] - KL[i]*S_small[j,2i-1] -# with Dm = galerkin/delta the OUTER Delta-prime matrix. Only `cout` carries the resistivity -# dependence (through the matched inner-layer Delta), so Delta_mn splits into -# delta_mn = delta_mn_coil (eta-independent, from the Dm coil block) -# + delta_mn_plasma (eta-dependent, the cout-weighted plasma term). -# The per-surface kernels KL,KR are not written to HDF5, so we recover them per surface by least -# squares from the stored delta_mn and S_small (2 complex unknowns, mcoil equations) and report the -# fit residual so the split is verifiable rather than assumed. -# -# Scans eta with the "ray" inner backend (Task 10 established ray is the trustworthy one at |Q| >~ 1). -# Rotation override via ENV["GAL_ROTATION"] (|Q| ~ f * eta^(-1/3); f=1 Hz keeps |Q| < 1). -# -# Usage: julia --project= scripts/deltamn_resistivity_merge.jl [N_eta] [eta_hi] [eta_lo] - -using GeneralizedPerturbedEquilibrium, HDF5, LinearAlgebra, Printf - -length(ARGS) >= 1 || error("usage: julia --project= scripts/deltamn_resistivity_merge.jl [N_eta] [eta_hi] [eta_lo]") -base = ARGS[1]; isdir(base) || error("no such dir: $base") -NETA = length(ARGS) >= 2 ? parse(Int, ARGS[2]) : 8 -ETA_HI = length(ARGS) >= 3 ? parse(Float64, ARGS[3]) : 1e-6 -ETA_LO = length(ARGS) >= 4 ? parse(Float64, ARGS[4]) : 1e-13 -ENV["DELTACOIL_MODE"] = "driven"; delete!(ENV, "DELTACOIL_PROJECT") - -base_toml = read(joinpath(base, "gpec.toml"), String) -scratch = mktempdir() -eqm = match(r"eq_filename\s*=\s*\"([^\"]+)\"", base_toml) -eqfile = eqm === nothing ? nothing : eqm.captures[1] -eqsrc = eqfile === nothing ? nothing : (isabspath(eqfile) ? eqfile : joinpath(base, eqfile)) - -nsurf = (m = match(r"gal_eta\s*=\s*\[([^\]]*)\]", base_toml); m === nothing ? 0 : count(==(','), m.captures[1]) + 1) -nsurf > 0 || error("could not parse gal_eta length from toml") -RHO = (m = match(r"gal_rho\s*=\s*\[\s*([\d.eE+-]+)", base_toml); m === nothing ? 3.3e-7 : parse(Float64, m.captures[1])) -ROT = (rv = get(ENV, "GAL_ROTATION", ""); rv != "" ? parse(Float64, rv) : - (m = match(r"gal_rotation\s*=\s*\[\s*([\d.eE+-]+)", base_toml); m === nothing ? 1.0 : parse(Float64, m.captures[1]))) - -ETAS = 10 .^ range(log10(ETA_HI), log10(ETA_LO); length=NETA) - -"Recover KL,KR per surface by least squares, then split delta_mn into its coil and plasma parts." -function decompose(dmn, cout, Dm) - msing, mcoil = size(dmn) - S_plasma = transpose(cout) * Dm[1:2msing, :] # (mcoil × 2msing) eta-dependent - S_coil = Dm[(2msing+1):(2msing+mcoil), :] # (mcoil × 2msing) eta-independent - S_small = S_plasma .+ S_coil - n_tot = zeros(msing); n_coil = zeros(msing); n_plas = zeros(msing); fitres = zeros(msing) - for i in 1:msing - A = hcat(S_small[:, 2i], -S_small[:, 2i-1]) # columns multiply [KR; KL] - b = vec(dmn[i, :]) - x = A \ b - fitres[i] = norm(A * x - b) / max(norm(b), 1e-300) - KR, KL = x[1], x[2] - n_tot[i] = norm(b) - n_coil[i] = norm(KR .* S_coil[:, 2i] .- KL .* S_coil[:, 2i-1]) - n_plas[i] = norm(KR .* S_plasma[:, 2i] .- KL .* S_plasma[:, 2i-1]) - end - return (tot=n_tot, coil=n_coil, plasma=n_plas, fitres=fitres) -end - -function run_eta(eta) - tag = "eta_$(eta)_ray" - dir = joinpath(scratch, tag); mkpath(dir) - if eqfile !== nothing - dst = joinpath(dir, basename(eqfile)); islink(dst) || symlink(realpath(eqsrc), dst) - end - toml = base_toml - for key in ("force_termination", "HDF5_filename", "gal_match_flag", "gal_inner_solver", - "thmax0", "delta_mband", "gal_eta", "gal_rho", "gal_rotation") - toml = replace(toml, Regex("(?m)^[ \\t]*$key[ \\t]*=.*\\n") => "") - end - arrs = "gal_eta = [" * join(fill(string(eta), nsurf), ", ") * "]\n" * - "gal_rho = [" * join(fill(string(RHO), nsurf), ", ") * "]\n" * - "gal_rotation = [" * join(fill(string(ROT), nsurf), ", ") * "]\n" - toml = replace(toml, "[ForceFreeStates]" => - "[ForceFreeStates]\nforce_termination = true\nHDF5_filename = \"$tag.h5\"\ngal_match_flag = true\n" * - "gal_inner_solver = \"ray\"\n" * arrs; count=1) - eqfile !== nothing && (toml = replace(toml, r"eq_filename\s*=\s*\"[^\"]+\"" => "eq_filename = \"$(basename(eqfile))\"")) - write(joinpath(dir, "gpec.toml"), toml) - @info ">>> eta = $eta (ray, f = $ROT Hz)" - try - GeneralizedPerturbedEquilibrium.main([dir]) - catch err - @warn "run failed at eta=$eta" exception = (err, catch_backtrace()) - return nothing - end - h5path = joinpath(dir, "$tag.h5") - isfile(h5path) || return nothing - h5open(h5path, "r") do fid - haskey(fid, "singular/q") || return nothing - q = vec(read(fid, "singular/q")); isempty(q) && return nothing - (haskey(fid, "galerkin/match/delta_mn") && haskey(fid, "galerkin/match/cout") && - haskey(fid, "galerkin/delta")) || return nothing - dmn = read(fid, "galerkin/match/delta_mn") # (msing × mcoil) - cout = read(fid, "galerkin/match/cout") # (2msing × mcoil) - Dm = read(fid, "galerkin/delta") # (2msing+mcoil × 2msing) - dr = haskey(fid, "galerkin/match/deltar") ? read(fid, "galerkin/match/deltar") : nothing - bp = haskey(fid, "galerkin/match/bpen") ? read(fid, "galerkin/match/bpen") : nothing - dec = decompose(dmn, cout, Dm) - qk = round.(q; digits=2) - deltar = Dict{Float64,Float64}(); bpen = Dict{Float64,Float64}() - dmn_t = Dict{Float64,Float64}(); dmn_c = Dict{Float64,Float64}() - dmn_p = Dict{Float64,Float64}(); fitr = Dict{Float64,Float64}() - for s in 1:length(q) - k = qk[s] - dr === nothing || (deltar[k] = norm(dr[s, :])) - if bp !== nothing - b = size(bp, 1) >= size(bp, 2) ? bp : permutedims(bp) - bpen[k] = norm(b[:, s]) - end - dmn_t[k] = dec.tot[s]; dmn_c[k] = dec.coil[s] - dmn_p[k] = dec.plasma[s]; fitr[k] = dec.fitres[s] - end - (q=sort(qk), deltar=deltar, bpen=bpen, dmn=dmn_t, dmn_coil=dmn_c, dmn_plasma=dmn_p, fitres=fitr) - end -end - -rows = Tuple{Float64,Any}[] -for eta in ETAS - push!(rows, (eta, run_eta(eta))) -end -allq = sort(collect(reduce(union, (Set(keys(r[2].dmn)) for r in rows if r[2] !== nothing); init=Set{Float64}()))) - -outdir = joinpath(@__DIR__, "..", "results"); mkpath(outdir) -tag = replace(basename(base), r"[^A-Za-z0-9]" => "") * "_f" * replace(string(ROT), "." => "p") -csv = joinpath(outdir, "deltamn_merge_$tag.csv") -open(csv, "w") do io - cols = String["eta"] - for q in allq, m in ("dmn", "dmn_coil", "dmn_plasma", "deltar", "bpen", "fitres") - push!(cols, "$(m)_q$(q)") - end - println(io, join(cols, ",")) - for (eta, r) in rows - vals = String[string(eta)] - for q in allq, d in (r === nothing ? [nothing for _ in 1:6] : - [r.dmn, r.dmn_coil, r.dmn_plasma, r.deltar, r.bpen, r.fitres]) - push!(vals, d === nothing ? "" : string(get(d, q, ""))) - end - println(io, join(vals, ",")) - end -end - -println("\n" * "="^92) -println(" Delta_mn MERGE scan ($(basename(base)), ray, f = $ROT Hz): full resonant response vs eta") -println("="^92) -@printf(" %-10s", "eta") -for q in allq; @printf(" | q%-4.1f dmn | q%-4.1f coil | q%-4.1f plas", q, q, q); end; println() -for (eta, r) in rows - @printf(" %-10.2e", eta) - for q in allq - if r === nothing - @printf(" | %9s | %10s | %10s", "-", "-", "-") - else - @printf(" | %9s | %10s | %10s", - haskey(r.dmn, q) ? @sprintf("%.3e", r.dmn[q]) : "-", - haskey(r.dmn_coil, q) ? @sprintf("%.3e", r.dmn_coil[q]) : "-", - haskey(r.dmn_plasma, q) ? @sprintf("%.3e", r.dmn_plasma[q]) : "-") - end - end - println() -end -mx = maximum((maximum(values(r.fitres)) for (_, r) in rows if r !== nothing); init=0.0) -@printf("\n max KL/KR least-squares fit residual over all eta and surfaces: %.3e (should be ~1e-12 or below)\n", mx) -println("wrote: $(abspath(csv))") diff --git a/benchmarks/deltacoil_sensitivity/scripts/penetrated_field_psihigh.jl b/benchmarks/deltacoil_sensitivity/scripts/penetrated_field_psihigh.jl deleted file mode 100644 index f6680e18..00000000 --- a/benchmarks/deltacoil_sensitivity/scripts/penetrated_field_psihigh.jl +++ /dev/null @@ -1,122 +0,0 @@ -#!/usr/bin/env julia -# penetrated_field_psihigh.jl -- truncation scan of the PENETRATED (reconnected) resonant field versus -# the outer boundary psihigh, from a single match run, for both paths: -# STRIDE path : singular/resonant_match/reconnected_flux (Ψ ~ delta_coil + delta_out * cout) -# galerkin path : galerkin/match/bpen (area-weighted penetrated b, -2pi/v1 fix) -# reconnected_flux is (ncoil, 2msing) (two sides per surface); bpen is (ncoil, msing) (one per surface). -# We report the per-surface norm of each. This is the field that actually penetrates the resistive layer, -# one step past the matched coupling cout. -# -# Resistive arrays (gal_eta/gal_rho/gal_rotation) must have length msing, which changes as psihigh passes -# surfaces: run the full domain first (input arrays already correct) to get surface locations, then size -# the arrays to the surface count at each psihigh. -# -# Usage: julia --project= scripts/penetrated_field_psihigh.jl [N] [psihigh_max] - -using GeneralizedPerturbedEquilibrium, HDF5, LinearAlgebra, Printf - -length(ARGS) >= 1 || error("usage: julia --project= scripts/penetrated_field_psihigh.jl [N] [psihigh_max]") -base = ARGS[1]; isdir(base) || error("no such dir: $base") -N = length(ARGS) >= 2 ? parse(Int, ARGS[2]) : 12 -PSIH_MAX = length(ARGS) >= 3 ? parse(Float64, ARGS[3]) : 0.993 -ENV["DELTACOIL_MODE"] = "driven"; delete!(ENV, "DELTACOIL_PROJECT") - -base_toml = read(joinpath(base, "gpec.toml"), String) -scratch = mktempdir() -eqm = match(r"eq_filename\s*=\s*\"([^\"]+)\"", base_toml) -eqfile = eqm === nothing ? nothing : eqm.captures[1] -eqsrc = eqfile === nothing ? nothing : (isabspath(eqfile) ? eqfile : joinpath(base, eqfile)) - -firstval(key, default) = (m = match(Regex("$key\\s*=\\s*\\[\\s*([\\d.eE+-]+)"), base_toml); m === nothing ? default : parse(Float64, m.captures[1])) -ETA = firstval("gal_eta", 8e-8); RHO = firstval("gal_rho", 3.3e-7); ROT = firstval("gal_rotation", 1.0) - -function run_match(ph, ms) - tag = "ph_$(ph)" - dir = joinpath(scratch, tag); mkpath(dir) - if eqfile !== nothing - dst = joinpath(dir, basename(eqfile)); islink(dst) || symlink(realpath(eqsrc), dst) - end - toml = base_toml - strip_keys = ["force_termination", "HDF5_filename", "psiedge", "set_psilim_via_dmlim", - "truncate_at_dW_peak", "thmax0", "delta_mband", "gal_match_flag"] - ms === nothing || append!(strip_keys, ["gal_eta", "gal_rho", "gal_rotation"]) - for key in strip_keys - toml = replace(toml, Regex("(?m)^[ \\t]*$key[ \\t]*=.*\\n") => "") - end - arrblk = ms === nothing ? "" : - "gal_eta = [" * join(fill(string(ETA), ms), ", ") * "]\n" * - "gal_rho = [" * join(fill(string(RHO), ms), ", ") * "]\n" * - "gal_rotation = [" * join(fill(string(ROT), ms), ", ") * "]\n" - toml = replace(toml, "[ForceFreeStates]" => - "[ForceFreeStates]\nforce_termination = true\nHDF5_filename = \"$tag.h5\"\npsiedge = 1.0\n" * - "set_psilim_via_dmlim = false\ntruncate_at_dW_peak = false\ngal_match_flag = true\n" * arrblk; count=1) - toml = replace(toml, r"(?m)^([ \t]*)psihigh[ \t]*=[ \t]*[\d.eE+-]+" => SubstitutionString("\\1psihigh = $ph")) - eqfile !== nothing && (toml = replace(toml, r"eq_filename\s*=\s*\"[^\"]+\"" => "eq_filename = \"$(basename(eqfile))\"")) - write(joinpath(dir, "gpec.toml"), toml) - @info ">>> psihigh = $ph (msing arrays = $(ms === nothing ? "toml" : ms))" - try - GeneralizedPerturbedEquilibrium.main([dir]) - catch err - @warn "run failed at psihigh=$ph" exception = (err, catch_backtrace()) - return nothing - end - h5path = joinpath(dir, "$tag.h5") - isfile(h5path) || return nothing - h5open(h5path, "r") do fid - haskey(fid, "singular/q") || return nothing - q = vec(read(fid, "singular/q")); isempty(q) && return nothing - psi = vec(read(fid, "singular/psi")) - # STRIDE reconnected_flux: (ncoil, 2msing), 2 columns per surface - pk2(dc) = (dc = size(dc, 1) >= size(dc, 2) ? dc : permutedims(dc); - Dict(round(q[s]; digits=2) => norm(dc[:, 2s-1:2s]) for s in 1:length(q))) - # galerkin bpen: (ncoil, msing), 1 column per surface - pk1(dc) = (dc = size(dc, 1) >= size(dc, 2) ? dc : permutedims(dc); - Dict(round(q[s]; digits=2) => norm(dc[:, s]) for s in 1:length(q))) - stride_p = haskey(fid, "singular/resonant_match/reconnected_flux") ? pk2(read(fid, "singular/resonant_match/reconnected_flux")) : Dict{Float64,Float64}() - gal_p = haskey(fid, "galerkin/match/bpen") ? pk1(read(fid, "galerkin/match/bpen")) : Dict{Float64,Float64}() - (q=sort(round.(q; digits=2)), psi=psi, stride=stride_p, gal=gal_p) - end -end - -@info "=== nominal (full domain) ===" -nom = run_match(PSIH_MAX, nothing) -nom === nothing && error("nominal full-domain match failed") -psi_s = sort(nom.psi) -@info "surfaces (psi_s): $psi_s full msing=$(length(psi_s))" - -PSIHIGHS = round.(1 .- 10 .^ range(log10(1 - (psi_s[1] + 1e-3)), log10(1 - PSIH_MAX); length=N); digits=5) - -results = Tuple{Float64,Any}[] -for ph in PSIHIGHS - ms = count(<(ph), psi_s) - push!(results, (ph, ms == 0 ? nothing : (ph == PSIH_MAX ? nom : run_match(ph, ms)))) -end -any(r -> r[1] == PSIH_MAX, results) || push!(results, (PSIH_MAX, nom)) - -allq = sort(collect(nom.q)) -outdir = joinpath(@__DIR__, "..", "results"); mkpath(outdir) -tag = replace(basename(base), r"[^A-Za-z0-9]" => "") -csv = joinpath(outdir, "penetrated_field_psihigh_$tag.csv") -open(csv, "w") do io - println(io, "psihigh," * join(["stride_q$(q)" for q in allq], ",") * "," * join(["gal_q$(q)" for q in allq], ",")) - for (ph, r) in sort(results, by=first) - sv = [r === nothing ? "" : string(get(r.stride, q, "")) for q in allq] - gv = [r === nothing ? "" : string(get(r.gal, q, "")) for q in allq] - println(io, "$ph," * join(sv, ",") * "," * join(gv, ",")) - end -end - -println("\n" * "="^78) -println(" PENETRATED field per surface vs psihigh ($(basename(base)))") -println(" STRIDE = singular/resonant_match/reconnected_flux ; galerkin = galerkin/match/bpen") -println("="^78) -@printf(" %-9s", "psihigh") -for q in allq; @printf(" | S:q%-4.1f", q); end -for q in allq; @printf(" | G:q%-4.1f", q); end; println() -for (ph, r) in sort(results, by=first) - @printf(" %-9.4f", ph) - for q in allq; @printf(" | %7s", r === nothing ? "-" : (haskey(r.stride, q) ? @sprintf("%.2e", r.stride[q]) : "-")); end - for q in allq; @printf(" | %7s", r === nothing ? "-" : (haskey(r.gal, q) ? @sprintf("%.2e", r.gal[q]) : "-")); end - println() -end -println("\nwrote: $(abspath(csv))") diff --git a/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_dmlim_fine.py b/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_dmlim_fine.py deleted file mode 100644 index e37468ed..00000000 --- a/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_dmlim_fine.py +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env python3 -"""plot_deltacoil_dmlim_fine.py - fine dmlim sweep (0.30-0.50) through the q5-drop threshold (0.426). -Shows the delta_coil pattern collapse is a STEP at the threshold, not a smooth rotation. -Values from deltacoil_metrics.jl (SKIP_SINGFAC=1) on the DIII-D-like case, qmax=5.426.""" -import numpy as np, matplotlib -matplotlib.use("Agg"); import matplotlib.pyplot as plt - -QMAX = 5.426; thr = QMAX - 5.0 # 0.426 -lo = np.array([0.30, 0.35, 0.40, 0.42]) # q5 present -hi = np.array([0.44, 0.46, 0.48, 0.50]) # q5 dropped -cos_lo = { # cosine vs nominal, below threshold - "q=2": [0.996348, 0.998666, 0.999843, 0.999991], - "q=3": [0.992477, 0.997249, 0.999675, 0.999982], - "q=4": [0.981228, 0.993110, 0.999184, 0.999955], - "q=5 (edge)": [0.942364, 0.978476, 0.997414, 0.999857], -} -cos_hi = { # above threshold (q5 absent) - "q=2": [0.775613, 0.784651, 0.793553, 0.802348], - "q=3": [0.551682, 0.568847, 0.585903, 0.602900], - "q=4": [0.104129, 0.125729, 0.149245, 0.174318], -} -colors = {"q=2": "#2E86C1", "q=3": "#28B463", "q=4": "#E67E22", "q=5 (edge)": "#C0392B"} - -fig, ax = plt.subplots(figsize=(9.2, 6.0), constrained_layout=True) -for k in cos_lo: - ax.plot(lo, cos_lo[k], "o-", color=colors[k], lw=2, ms=7, label=k) -for k in cos_hi: - ax.plot(hi, cos_hi[k], "s--", color=colors[k], lw=2, ms=7) # dashed above threshold -ax.axvline(thr, color="grey", ls=":", lw=1.6) -ax.axvspan(thr, 0.51, color="grey", alpha=0.07) -ax.text(thr + 0.004, 0.05, f"q5 dropped\n(dmlim > {thr:.3f})", fontsize=9, color="dimgrey") -ax.text(0.315, 0.05, "q5 retained\n(4 surfaces)", fontsize=9, color="dimgrey") -ax.annotate("q4 pattern nearly orthogonal\nto nominal (cos ≈ 0.10)", - xy=(0.44, 0.104), xytext=(0.455, 0.30), fontsize=8.5, color=colors["q=4"], - arrowprops=dict(arrowstyle="->", color=colors["q=4"], lw=1.2)) -ax.set_xlabel("dmlim (outer truncation, set_psilim_via_dmlim=true)") -ax.set_ylabel("cosine similarity of delta_coil with nominal") -ax.set_title("Fine dmlim sweep through the q5-drop threshold\n" - "solid = q5 present; dashed = q5 dropped - the pattern collapses as a STEP at 0.426", - fontsize=11.5, fontweight="bold") -ax.set_ylim(0, 1.03); ax.set_xlim(0.29, 0.51) -ax.legend(fontsize=9, loc="center right"); ax.grid(alpha=0.25) -out = "benchmarks/deltacoil_sensitivity/figures/deltacoil_dmlim_fine.png" -import os; os.makedirs(os.path.dirname(out), exist_ok=True) -fig.savefig(out, dpi=140, bbox_inches="tight"); print(f"wrote {out}") diff --git a/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_metrics.py b/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_metrics.py deleted file mode 100644 index 0e738899..00000000 --- a/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_metrics.py +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env python3 -"""plot_deltacoil_metrics.py - cosine similarity of delta_coil with the nominal case, per surface. -The dot-product metric that the norm hides: shows the response PATTERN rotating under truncation.""" -import numpy as np, matplotlib -matplotlib.use("Agg"); import matplotlib.pyplot as plt - -dmlim = np.array([0.1, 0.3, 0.5, 0.7, 0.9]) -cosA = { # cosine similarity with nominal (set_psilim_via_dmlim=false baseline) - "q=2": [0.975948, 0.996348, 0.802348, 0.882046, 0.938423], - "q=3": [0.950813, 0.992477, 0.602900, 0.762331, 0.875203], - "q=4": [0.879623, 0.981228, 0.174318, 0.464867, 0.705142], - "q=5 (edge)": [0.593958, 0.942364, np.nan, np.nan, np.nan], -} -sfac = np.array([1e-5, 1e-4, 1e-3]) -cosB = { # identical across singfac (delta_coil independent of singfac_min) - "q=2": [0.988340]*3, "q=3": [0.976060]*3, "q=4": [0.940802]*3, "q=5 (edge)": [0.818224]*3, -} -colors = {"q=2": "#2E86C1", "q=3": "#28B463", "q=4": "#E67E22", "q=5 (edge)": "#C0392B"} -QMAX = 5.426; thresh = QMAX - 5.0 - -fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(13, 5.6), constrained_layout=True) -fig.suptitle("delta_coil: normalized dot product with nominal (cosine similarity), per surface\n" - "1.0 = same response pattern; <1 = pattern ROTATED (invisible to the norm)", - fontsize=12.5, fontweight="bold") - -for k, v in cosA.items(): - ax1.plot(dmlim, v, "o-", color=colors[k], lw=2, ms=7, label=k) -ax1.axvline(thresh, color="grey", ls="--", lw=1.3) -ax1.text(thresh + 0.012, 0.30, f"q5 dropped\n(dmlim > {thresh:.3f})", fontsize=8.5, color="grey") -ax1.set_xlabel("dmlim (outer truncation)"); ax1.set_ylabel("cosine similarity with nominal") -ax1.set_title("Sweep A - truncation: pattern rotates hard once q5 drops", fontsize=10.5, fontweight="bold") -ax1.set_ylim(0, 1.03); ax1.legend(fontsize=9, loc="lower right"); ax1.grid(alpha=0.25) - -for k, v in cosB.items(): - ax2.plot(sfac, v, "o-", color=colors[k], lw=2, ms=7, label=k) -ax2.set_xscale("log"); ax2.set_ylim(0, 1.03) -ax2.set_xlabel("singfac_min (rational-surface approach distance)") -ax2.set_ylabel("cosine similarity with nominal") -ax2.set_title("Sweep B - approach distance: perfectly flat (no effect)", fontsize=10.5, fontweight="bold") -ax2.legend(fontsize=9, loc="lower left"); ax2.grid(alpha=0.25) -ax2.text(0.5, 0.55, "cosine identical to 6 decimals across 2 decades of singfac_min\n" - "(offset from 1.0 is the truncation-flag on/off, not singfac)", - transform=ax2.transAxes, ha="center", fontsize=8.5, style="italic") - -out = "deltacoil_metrics.png"; fig.savefig(out, dpi=140, bbox_inches="tight"); print(f"wrote {out}") diff --git a/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_psihigh.py b/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_psihigh.py deleted file mode 100644 index d3148b69..00000000 --- a/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_psihigh.py +++ /dev/null @@ -1,127 +0,0 @@ -#!/usr/bin/env python3 -# plot_deltacoil_psihigh.py - plot |delta_coil| per rational surface vs the outer truncation psihigh -# (log-packed toward the edge). Reads results/deltacoil_psihigh_result.csv written by -# deltacoil_psihigh_scan.jl. Each surface's curve begins where psihigh first exceeds its psi_s. -# -# Usage: python3 plot_deltacoil_psihigh.py [results/deltacoil_psihigh_result.csv] - -import sys, os, csv -import numpy as np -import matplotlib -matplotlib.use('Agg') -import matplotlib.pyplot as plt - -here = os.path.dirname(os.path.abspath(__file__)) -# optional --psi_s="2:0.518,3:0.770,..." gives the TRUE rational-surface psi locations (from the -# equilibrium's singular/psi), annotated alongside the grid-entry proxy. Non-flag arg = csv path. -TRUE_PSIS = {} -_pos = [] -for a in sys.argv[1:]: - if a.startswith('--psi_s='): - for kv in a.split('=', 1)[1].split(','): - if ':' in kv: - k, v = kv.split(':'); TRUE_PSIS[float(k)] = float(v) - else: - _pos.append(a) -csv_path = _pos[0] if _pos else os.path.join(here, '..', 'results', 'deltacoil_psihigh_result.csv') -csv_path = os.path.abspath(csv_path) - -# --- read CSV --- -with open(csv_path) as f: - rows = list(csv.reader(f)) -header = rows[0] -qcols = header[1:] # e.g. ['q2.0','q3.0','q4.0','q5.0'] -qvals = [float(c[1:]) for c in qcols] -psihigh, data = [], {q: [] for q in qvals} -for r in rows[1:]: - if not r or r[0] == '': - continue - psihigh.append(float(r[0])) - for q, cell in zip(qvals, r[1:]): - data[q].append(float(cell) if cell not in ('', 'FAIL') else np.nan) -psihigh = np.array(psihigh) - -# case label derived from the CSV name, so titles are correct for DIII-D / LAR / etc. -_case = os.path.splitext(os.path.basename(csv_path))[0].replace('_result', '').replace('deltacoil_psihigh', '').strip('_').upper() -CASE = {'': 'DIII-D-like', 'DIIID': 'DIII-D-like', 'LAR': 'LAR (large-aspect-ratio)'}.get(_case, _case) - -# entry psihigh per surface (first psihigh at which it appears) - a case-agnostic proxy for psi_s, -# so the same plotter works for DIII-D, LAR, or any case without hardcoded surface locations. -def entry_psihigh(q): - y = np.array(data[q], float) - idx = np.where(~np.isnan(y))[0] - return psihigh[idx[0]] if len(idx) else np.nan -PSI_S = {q: entry_psihigh(q) for q in qvals} -colors = plt.cm.viridis(np.linspace(0.08, 0.82, len(qvals))) - -# x-axis = distance from the edge (1 - psihigh) on a log scale (edge at right), so the log-packed -# near-edge points spread out and curves approach convergence horizontally, not vertically. Anchor to -# the leftmost psihigh that has data so empty low-psihigh points do not push labels off the range. -_has = np.array([any(not np.isnan(data[q][i]) for q in qvals) for i in range(len(psihigh))]) -x0 = psihigh[_has].min() if _has.any() else psihigh.min() -def edge_log_x(ax): - fwd = lambda p: -np.log10(np.clip(1.0 - np.asarray(p, float), 1e-9, None)) - inv = lambda t: 1.0 - 10.0 ** (-np.asarray(t, float)) - ax.set_xscale('function', functions=(fwd, inv)) - cand = np.array([0.6, 0.75, 0.85, 0.9, 0.95, 0.98, 0.99]) - tk = cand[(cand >= x0 - 1e-9) & (cand <= psihigh.max() + 1e-9)] - ax.set_xticks(tk); ax.set_xticklabels([f'{t:g}' for t in tk]); ax.minorticks_off() - ax.set_xlim(x0 - (1 - x0) * 0.06, psihigh.max() + (1 - psihigh.max()) * 0.4) - -fig, ax = plt.subplots(figsize=(8.2, 5.0)) -ax.set_yscale('log') # log-y so the edge surface (q5) does not flatten q2/q3/q4 -for q, c in zip(qvals, colors): - y = np.array(data[q], float) - m = ~np.isnan(y) - ax.plot(psihigh[m], y[m], 'o-', color=c, lw=1.8, ms=6, label=f'q = {q:g}') -for q, c in zip(qvals, colors): # vlines after data so ylim is settled on the log scale - if q in PSI_S and np.isfinite(PSI_S[q]): - ax.axvline(PSI_S[q], ls=':', color=c, lw=1.0, alpha=0.7) # grid-entry (first psihigh with data) - lbl = f' q={q:g} enters ≈{PSI_S[q]:.3f}' - if q in TRUE_PSIS: # true rational-surface location - ax.axvline(TRUE_PSIS[q], ls='--', color=c, lw=1.0, alpha=0.55) - lbl += f' (ψ$_s$≈{TRUE_PSIS[q]:.3f})' - ax.text(PSI_S[q], ax.get_ylim()[1], lbl, color=c, - fontsize=7.5, rotation=90, va='top', ha='left') - -edge_log_x(ax) -ax.set_xlabel('outer truncation ψ$_{high}$ (log distance from edge, 1 - ψ$_{high}$; edge at right)', fontsize=11) -ax.set_ylabel('|delta_coil| per surface (log)', fontsize=11) -_sub = 'each curve begins where ψ$_{high}$ passes that surface' -if TRUE_PSIS: - _sub += ' · dotted = grid entry, dashed = true rational surface ψ$_s$' -ax.set_title('delta_coil vs outer truncation ψ$_{high}$ (log-packed toward edge)\n' - f'{CASE}, n=1 · {_sub}', fontsize=10.5) -ax.grid(alpha=0.25) -ax.legend(title='rational surface', fontsize=9, title_fontsize=9) -fig.subplots_adjust(left=0.12, bottom=0.13, right=0.97, top=0.88) - -# derive output name from the CSV stem so different cases (diiid/lar) get distinct figures -stem = os.path.splitext(os.path.basename(csv_path))[0].replace('_result', '') -out = os.path.join(here, '..', 'figures', stem + '.png') -out = os.path.abspath(out); os.makedirs(os.path.dirname(out), exist_ok=True) -fig.savefig(out, dpi=150) -print('wrote:', out) - -# second panel: relative change vs the edge-most (last) psihigh - how much truncation still moves each surface -fig2, ax2 = plt.subplots(figsize=(8.2, 5.0)) -for q, c in zip(qvals, colors): - y = np.array(data[q], float) - m = ~np.isnan(y) - if m.sum() < 2: - continue - ref = y[m][-1] # reference = value at largest psihigh where present - ax2.plot(psihigh[m], np.abs(y[m] - ref) / max(abs(ref), 1e-300), 'o-', color=c, lw=1.8, ms=6, label=f'q = {q:g}') -ax2.set_yscale('log') -edge_log_x(ax2) -ax2.set_xlabel('outer truncation ψ$_{high}$ (log distance from edge, 1 - ψ$_{high}$; edge at right)', fontsize=11) -ax2.set_ylabel('|Δ delta_coil| / |delta_coil(ψ$_{high}$=max)|', fontsize=11) -ax2.set_title(f'Relative sensitivity of delta_coil to the truncation boundary, {CASE}\n' - '(vs the outermost ψ$_{high}$ reference; lower = more converged)', fontsize=11) -ax2.grid(alpha=0.25, which='both') -ax2.legend(title='rational surface', fontsize=9, title_fontsize=9) -fig2.subplots_adjust(left=0.13, bottom=0.13, right=0.97, top=0.88) -out2 = os.path.join(here, '..', 'figures', stem + '_relchange.png') -out2 = os.path.abspath(out2) -fig2.savefig(out2, dpi=150) -print('wrote:', out2) diff --git a/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_psihigh_metric.py b/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_psihigh_metric.py deleted file mode 100644 index 91e112a2..00000000 --- a/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_psihigh_metric.py +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/env python3 -# plot_deltacoil_psihigh_metric.py - plot the plot-2 dot-product (cosine) shape metric vs the psihigh -# truncation. Reads results/deltacoil_psihigh_metric.csv from deltacoil_psihigh_metric.jl. -# Each curve is one rational surface: cosine similarity of its delta_coil with the full-domain nominal. -# cos = 1 means shape unchanged (only rescaled); cos < 1 means the coil-response pattern rotated. -# -# Usage: python3 plot_deltacoil_psihigh_metric.py [results/deltacoil_psihigh_metric.csv] - -import sys, os, csv -import numpy as np -import matplotlib -matplotlib.use('Agg') -import matplotlib.pyplot as plt - -here = os.path.dirname(os.path.abspath(__file__)) -csv_path = sys.argv[1] if len(sys.argv) > 1 else os.path.join(here, '..', 'results', 'deltacoil_psihigh_metric.csv') -csv_path = os.path.abspath(csv_path) - -with open(csv_path) as f: - rows = list(csv.reader(f)) -header = rows[0] -qvals = [float(c[1:]) for c in header[1:]] -psihigh, data = [], {q: [] for q in qvals} -for r in rows[1:]: - if not r or r[0] == '': - continue - psihigh.append(float(r[0])) - for q, cell in zip(qvals, r[1:]): - data[q].append(float(cell) if cell not in ('', 'FAIL') else np.nan) -psihigh = np.array(psihigh) - -_case = os.path.splitext(os.path.basename(csv_path))[0].replace('_metric', '').replace('deltacoil_psihigh', '').strip('_').upper() -CASE = {'': 'DIII-D-like', 'DIIID': 'DIII-D-like', 'LAR': 'LAR (large-aspect-ratio)'}.get(_case, _case) - -colors = plt.cm.viridis(np.linspace(0.08, 0.82, len(qvals))) - -# leftmost psihigh that actually has data (first surface entry); ticks/annotations anchor here so the -# empty low-psihigh points do not push labels off the plotted range. -_has = np.array([any(not np.isnan(data[q][i]) for q in qvals) for i in range(len(psihigh))]) -x0 = psihigh[_has].min() if _has.any() else psihigh.min() - -fig, ax = plt.subplots(figsize=(8.4, 5.1)) -for q, c in zip(qvals, colors): - y = np.array(data[q], float) - m = ~np.isnan(y) - ax.plot(psihigh[m], y[m], 'o-', color=c, lw=1.9, ms=6, label=f'q = {q:g}') - -ax.axhline(1.0, ls=':', color='#888', lw=1.0) -ax.text(x0, 1.03, '1.0 = shape unchanged (only rescaled)', fontsize=8, color='#555', va='bottom', ha='left') -ax.text(x0, 0.06, 'low = pattern rotated\n(shape change the norm hides)', - fontsize=8, color='#a33', va='bottom', ha='left') -# x-axis = distance from the edge (1 - psihigh) on a log scale (edge at right), so the log-packed -# near-edge points spread out and the curves approach convergence horizontally, not vertically. -_fwd = lambda p: -np.log10(np.clip(1.0 - np.asarray(p, float), 1e-9, None)) -_inv = lambda t: 1.0 - 10.0 ** (-np.asarray(t, float)) -ax.set_xscale('function', functions=(_fwd, _inv)) -_cand = np.array([0.6, 0.75, 0.85, 0.9, 0.95, 0.98, 0.99]) -_ticks = _cand[(_cand >= x0 - 1e-9) & (_cand <= psihigh.max() + 1e-9)] -ax.set_xticks(_ticks); ax.set_xticklabels([f'{t:g}' for t in _ticks]) -ax.minorticks_off() -ax.set_xlim(x0 - (1 - x0) * 0.06, psihigh.max() + (1 - psihigh.max()) * 0.4) -ax.set_xlabel('outer truncation ψ$_{high}$ (log distance from edge, 1 - ψ$_{high}$; edge at right)', fontsize=11) -ax.set_ylabel('cosine similarity of delta_coil with full-domain nominal', fontsize=11) -ax.set_title('Dot-product (shape) metric vs psihigh truncation\n' - f'{CASE}, n=1 · per surface, aligned by poloidal mode m · nominal = largest ψ$_{{high}}$', - fontsize=11) -ax.set_ylim(-0.05, 1.06) -ax.grid(alpha=0.25) -ax.legend(title='rational surface', fontsize=9, title_fontsize=9, loc='lower right') -fig.subplots_adjust(left=0.12, bottom=0.13, right=0.97, top=0.86) - -stem = os.path.splitext(os.path.basename(csv_path))[0] -out = os.path.abspath(os.path.join(here, '..', 'figures', stem + '.png')) -os.makedirs(os.path.dirname(out), exist_ok=True) -fig.savefig(out, dpi=150) -print('wrote:', out) diff --git a/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_psihigh_panels.py b/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_psihigh_panels.py deleted file mode 100644 index bb82f1ed..00000000 --- a/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_psihigh_panels.py +++ /dev/null @@ -1,140 +0,0 @@ -#!/usr/bin/env python3 -# plot_deltacoil_psihigh_panels.py -- per-surface paneled view of the psihigh truncation scan, in the -# "Slide 7" edge-truncation style: ONE subplot per rational surface (its own y-scale), with -# Riccati/STRIDE (orange, solid, circles) vs Galerkin/RDCON (blue, dashed, squares) overlaid, gray -# dotted vertical lines at each rational-surface location, and all panels combined into a single PNG. -# Reads results/deltacoil_psihigh_rg_.csv from deltacoil_psihigh_riccati_galerkin.jl. -# -# Usage: plot_deltacoil_psihigh_panels.py [CASE label] [--qmax=Q] [--quantity=norm|cos] - -import sys, os, csv, math -import numpy as np -import matplotlib -matplotlib.use("Agg") -import matplotlib.pyplot as plt -from matplotlib.lines import Line2D - -argv = [a for a in sys.argv[1:]] -QMAX = None; SUFFIX = ""; QUANT = "norm"; PDFDIR = None -_kept = [] -for a in argv: - if a.startswith("--qmax="): - QMAX = float(a.split("=", 1)[1]); SUFFIX = "_core" - elif a.startswith("--quantity="): - QUANT = a.split("=", 1)[1] - elif a.startswith("--pdfdir="): - PDFDIR = os.path.expanduser(a.split("=", 1)[1]) - else: - _kept.append(a) -argv = _kept -CSVP = os.path.abspath(argv[0]) -here = os.path.dirname(os.path.abspath(__file__)) -_tag = os.path.splitext(os.path.basename(CSVP))[0].replace("deltacoil_psihigh_rg_", "") -CASE = argv[1] if len(argv) > 1 else { - "LARresistivematchtest": "LAR (large-aspect-ratio)", - "DIIIDlikegalresistivepeexample": "DIII-D-like", -}.get(_tag, _tag) - -with open(CSVP) as f: - rows = list(csv.reader(f)) -header = rows[0] -qs = sorted({c.split("_q")[1] for c in header if c.startswith("norm_ric_q")}, key=float) -if QMAX is not None: - qs = [q for q in qs if float(q) <= QMAX + 1e-9] -idx = {c: i for i, c in enumerate(header)} -psihigh = np.array([float(r[0]) for r in rows[1:] if r and r[0] != ""]) - -def col(name): - j = idx.get(name); out = [] - for r in rows[1:]: - if not r or r[0] == "": - continue - v = r[j] if (j is not None and j < len(r)) else "" - try: - out.append(float(v) if v not in ("", "FAIL", "NaN") else np.nan) - except ValueError: - out.append(np.nan) - return np.array(out) - -# entry psihigh per surface (approx psi_s) = smallest psihigh at which the surface has data -def entry_ph(q): - y = col(f"norm_ric_q{q}"); m = np.isfinite(y) - return psihigh[m].min() if m.any() else np.nan -PSI_S = {q: entry_ph(q) for q in qs} - -ORANGE = "#ff7f0e"; BLUE = "#1f77b4" -prefix, ylabel, ylog = ("norm", "|delta_coil|", True) if QUANT == "norm" \ - else ("cos", "cosine sim. with nominal", False) - -# common x-range (0.9 -> 0.9999) for every panel so surfaces are compared on the same axis -GX0 = psihigh.min(); GXMAX = psihigh.max() -def edge_log_x(ax): - fwd = lambda p: -np.log10(np.clip(1.0 - np.asarray(p, float), 1e-9, None)) - inv = lambda t: 1.0 - 10.0 ** (-np.asarray(t, float)) - ax.set_xscale("function", functions=(fwd, inv)) - cand = np.array([0.9, 0.99, 0.999, 0.9999]) - tk = cand[(cand >= GX0 - 1e-9) & (cand <= GXMAX + 1e-9)] - ax.set_xticks(tk); ax.set_xticklabels([f"{t:g}" for t in tk]); ax.minorticks_off() - ax.set_xlim(GX0 - (1 - GX0) * 0.06, GXMAX + (1 - GXMAX) * 0.4) - -N = len(qs) -# balanced grid: a single row for <=3 surfaces, otherwise two rows (2x2 for 4, 2x4 for 7-8), so -# panels stay roughly square and legible rather than a long thin strip -if N <= 3: - ncols = N; nrows = 1 -else: - ncols = math.ceil(N / 2); nrows = 2 -fig, axes = plt.subplots(nrows, ncols, figsize=(4.7 * ncols, 4.1 * nrows), squeeze=False) - -for i, q in enumerate(qs): - ax = axes[i // ncols][i % ncols] - yr = col(f"{prefix}_ric_q{q}"); yg = col(f"{prefix}_gal_q{q}") - mr = np.isfinite(yr); mg = np.isfinite(yg) - ax.plot(psihigh[mr], yr[mr], "o-", color=ORANGE, lw=1.6, ms=4) - ax.plot(psihigh[mg], yg[mg], "s--", color=BLUE, lw=1.5, ms=4) - if ylog: - ax.set_yscale("log") - else: - ax.set_ylim(-0.05, 1.06) - edge_log_x(ax) - # gray dotted vertical lines only for rational surfaces that ENTER within the plotted psihigh window. - # A surface already present at the leftmost psihigh (entry == psihigh.min()) lies interior to the - # window (psi_s < psihigh_min), i.e. off the left edge, so drawing it at the boundary would falsely - # imply a surface at psihigh_min. Those are skipped (e.g. LAR q=2,3 both sit at psi<0.9). - ytop = ax.get_ylim()[1] - for qq in qs: - ps = PSI_S[qq] - if np.isfinite(ps) and ps > GX0 + 1e-9: - ax.axvline(ps, ls=":", color="#999", lw=0.9) - ax.text(ps, ytop, f" q={float(qq):g}", color="#666", fontsize=7, rotation=90, va="top", ha="left") - ax.set_title(f"q = {float(q):g}/1", fontsize=11) - ax.set_xlabel(r"$\psi_{high}$", fontsize=10) - if i % ncols == 0: - ax.set_ylabel(ylabel + (" (log)" if ylog else ""), fontsize=10) - ax.grid(alpha=0.25, which="both") - if i == 0: - handles = [Line2D([0], [0], color=ORANGE, ls="-", marker="o", ms=4, label="Riccati (STRIDE)"), - Line2D([0], [0], color=BLUE, ls="--", marker="s", ms=4, label="Galerkin (RDCON)"), - Line2D([0], [0], color="#999", ls=":", label="rational surface")] - ax.legend(handles=handles, fontsize=8, loc="best", framealpha=0.9) - -for j in range(N, nrows * ncols): - axes[j // ncols][j % ncols].axis("off") - -qtxt = "|delta_coil|" if QUANT == "norm" else "delta_coil shape cosine" -fig.suptitle(f"Edge truncation: {qtxt} vs psihigh (log to psi=1, no wall) | {CASE}, n=1\n" - "per rational surface: Riccati (STRIDE, orange) vs Galerkin (RDCON, blue)", fontsize=12) -fig.tight_layout(rect=[0, 0, 1, 0.94]) -stem = os.path.splitext(os.path.basename(CSVP))[0] + SUFFIX + f"_panels_{prefix}" -out = os.path.abspath(os.path.join(here, "..", "figures", stem + ".png")) -fig.savefig(out, dpi=150); print("wrote:", out) - -# optional vector PDF with a human-readable, title-based filename for easy navigation -if PDFDIR is not None: - os.makedirs(PDFDIR, exist_ok=True) - case_short = "DIII-D" if "DIII" in CASE else ("LAR" if "LAR" in CASE else CASE) - qrange = f"q{int(float(qs[0]))}-{int(float(qs[-1]))}" - qtxt_file = "norm (delta_coil magnitude)" if QUANT == "norm" else "shape cosine" - fname = f"{case_short} - delta_coil edge truncation - {qrange} - Riccati vs Galerkin - {qtxt_file}.pdf" - pdfout = os.path.join(PDFDIR, fname) - fig.savefig(pdfout); print("wrote PDF:", pdfout) diff --git a/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_psihigh_rg.py b/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_psihigh_rg.py deleted file mode 100644 index a93e92ad..00000000 --- a/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_psihigh_rg.py +++ /dev/null @@ -1,169 +0,0 @@ -#!/usr/bin/env python3 -# plot_deltacoil_psihigh_rg.py -- plot the psihigh truncation scan with BOTH methods overlaid: -# Riccati/STRIDE (solid circles) and Galerkin/RDCON (dashed squares), one color per rational surface. -# Reads results/deltacoil_psihigh_rg_.csv from deltacoil_psihigh_riccati_galerkin.jl and writes -# three figures per case: -# _norm.png |delta_coil| per surface vs psihigh (log-y) -# _relchange.png relative change vs the outermost psihigh (convergence view) -# _metric.png cosine shape metric vs the full-domain nominal (shape rotation view) -# x-axis is log distance from the edge (1 - psihigh); edge at right. -# -# Usage: python3 plot_deltacoil_psihigh_rg.py .csv> [CASE label] - -import sys, os, csv -import numpy as np -import matplotlib -matplotlib.use("Agg") -import matplotlib.pyplot as plt -from matplotlib.lines import Line2D -from matplotlib.legend_handler import HandlerTuple - -# positional: CSV [CASE label]; optional --qmax= keeps only surfaces with q <= qmax and appends -# "_core" to the output names (so the full-range figure is not overwritten). Used to keep the DIII-D -# comparison legible when near-edge surfaces (q>=6) diverge by many decades. -argv = [a for a in sys.argv[1:]] -QMAX = None; SUFFIX = "" -_kept = [] -for a in argv: - if a.startswith("--qmax="): - QMAX = float(a.split("=", 1)[1]); SUFFIX = "_core" - else: - _kept.append(a) -argv = _kept -CSVP = os.path.abspath(argv[0]) -here = os.path.dirname(os.path.abspath(__file__)) -_tag = os.path.splitext(os.path.basename(CSVP))[0].replace("deltacoil_psihigh_rg_", "") -CASE = argv[1] if len(argv) > 1 else { - "LARresistivematchtest": "LAR (large-aspect-ratio)", - "DIIIDlikegalresistivepeexample": "DIII-D-like", -}.get(_tag, _tag) - -with open(CSVP) as f: - rows = list(csv.reader(f)) -header = rows[0] -# surfaces present, from the norm_ric_q columns -qs = sorted({c.split("_q")[1] for c in header if c.startswith("norm_ric_q")}, key=float) -if QMAX is not None: - qs = [q for q in qs if float(q) <= QMAX + 1e-9] -idx = {c: i for i, c in enumerate(header)} - -def col(name): - j = idx.get(name) - out = [] - for r in rows[1:]: - if not r or r[0] == "": - continue - v = r[j] if (j is not None and j < len(r)) else "" - try: - out.append(float(v)) if v not in ("", "FAIL", "NaN") else out.append(np.nan) - except ValueError: - out.append(np.nan) - return np.array(out) - -psihigh = np.array([float(r[0]) for r in rows[1:] if r and r[0] != ""]) -# Riccati/STRIDE keeps the cool viridis palette; Galerkin/RDCON uses warm tones (orange -> dark red), -# so the two methods are distinguished by COLOR FAMILY (cool vs warm) and surface by shade within it. -colors_ric = plt.cm.viridis(np.linspace(0.08, 0.82, len(qs))) -colors_gal = plt.cm.YlOrRd(np.linspace(0.45, 0.95, len(qs))) - -# x-axis: distance from edge (1 - psihigh) on a log scale, edge at right. Anchor to leftmost psihigh -# that has any data so empty near-axis points do not push the range. -def edge_log_x(ax, x0, xmax): - fwd = lambda p: -np.log10(np.clip(1.0 - np.asarray(p, float), 1e-9, None)) - inv = lambda t: 1.0 - 10.0 ** (-np.asarray(t, float)) - ax.set_xscale("function", functions=(fwd, inv)) - cand = np.array([0.9, 0.95, 0.98, 0.99, 0.995, 0.999, 0.9999]) - tk = cand[(cand >= x0 - 1e-9) & (cand <= xmax + 1e-9)] - ax.set_xticks(tk); ax.set_xticklabels([f"{t:g}" for t in tk]); ax.minorticks_off() - ax.set_xlim(x0 - (1 - x0) * 0.06, xmax + (1 - xmax) * 0.4) - -def method_legend(ax, loc="lower left", bbox=(1.015, 0.0)): - # representative mid-palette swatches so the cool=Riccati / warm=Galerkin mapping is explicit; - # default placement is OUTSIDE the axes (lower right) so it never covers data points - handles = [Line2D([0], [0], color=plt.cm.viridis(0.45), ls="-", marker="o", ms=5, lw=2, label="Riccati (STRIDE), cool"), - Line2D([0], [0], color=plt.cm.YlOrRd(0.75), ls="--", marker="s", ms=5, lw=2, label="Galerkin (RDCON), warm")] - leg = ax.legend(handles=handles, fontsize=8.5, loc=loc, bbox_to_anchor=bbox, - framealpha=0.95, title="method (color family)", title_fontsize=8.5) - ax.add_artist(leg) - -def surface_legend(ax, loc="upper left", bbox=(1.015, 1.0)): - # each surface shows BOTH its Riccati (cool) and Galerkin (warm) swatch as a paired handle; - # default placement is OUTSIDE the axes (upper right) so all points stay visible - handles = [(Line2D([0], [0], color=colors_ric[k], ls="-", marker="o", ms=5, lw=2), - Line2D([0], [0], color=colors_gal[k], ls="--", marker="s", ms=5, lw=2)) for k in range(len(qs))] - labels = [f"q = {q}" for q in qs] - ax.legend(handles=handles, labels=labels, fontsize=8.5, loc=loc, bbox_to_anchor=bbox, - title="rational surface (Riccati | Galerkin)", title_fontsize=8.5, framealpha=0.95, - handler_map={tuple: HandlerTuple(ndivide=None)}, handlelength=3.0) - -def x0_of(mask_cols): - has = np.zeros(len(psihigh), bool) - for c in mask_cols: - y = col(c); has |= np.isfinite(y) - return psihigh[has].min() if has.any() else psihigh.min() - -stem = os.path.splitext(os.path.basename(CSVP))[0] + SUFFIX -figdir = os.path.abspath(os.path.join(here, "..", "figures")); os.makedirs(figdir, exist_ok=True) - -# ---------- (1) norm ---------- -_panel = "norm" -fig, ax = plt.subplots(figsize=(11.4, 5.4)); ax.set_yscale("log") -for k, q in enumerate(qs): - yr = col(f"norm_ric_q{q}"); yg = col(f"norm_gal_q{q}") - mr = np.isfinite(yr); mg = np.isfinite(yg) - ax.plot(psihigh[mr], yr[mr], "o-", color=colors_ric[k], lw=1.8, ms=5) - ax.plot(psihigh[mg], yg[mg], "s--", color=colors_gal[k], lw=1.5, ms=4.5, alpha=0.95) -x0 = x0_of([f"norm_ric_q{q}" for q in qs] + [f"norm_gal_q{q}" for q in qs]) -edge_log_x(ax, x0, psihigh.max()) -ax.set_xlabel(r"outer truncation $\psi_{high}$ (log distance from edge, 1 - $\psi_{high}$; edge at right)", fontsize=11) -ax.set_ylabel(r"|delta_coil| per surface (log)", fontsize=11) -ax.set_title(f"delta_coil vs outer truncation: Riccati (STRIDE) vs Galerkin (RDCON)\n{CASE}, n=1", fontsize=11) -ax.grid(alpha=0.25, which="both") -# legends INSIDE the upper-left empty region (curves stay in a flat band until Galerkin blows up on the right) -method_legend(ax, loc="upper left", bbox=(0.012, 0.52)) -surface_legend(ax, loc="upper left", bbox=(0.012, 0.985)) -fig.subplots_adjust(left=0.08, bottom=0.13, right=0.97, top=0.9) -p1 = os.path.join(figdir, stem + "_norm.png"); fig.savefig(p1, dpi=150); print("wrote:", p1) - -# ---------- (2) relchange vs outermost psihigh ---------- -_panel = "rel" -fig, ax = plt.subplots(figsize=(11.4, 5.4)); ax.set_yscale("log") -for k, q in enumerate(qs): - for meth, cmap, style in (("ric", colors_ric, ("o-", 1.8, 5, 1.0)), ("gal", colors_gal, ("s--", 1.5, 4.5, 0.95))): - y = col(f"norm_{meth}_q{q}"); m = np.isfinite(y) - if m.sum() < 2: - continue - ref = y[m][-1] - ax.plot(psihigh[m], np.abs(y[m] - ref) / max(abs(ref), 1e-300), - style[0], color=cmap[k], lw=style[1], ms=style[2], alpha=style[3]) -x0 = x0_of([f"norm_ric_q{q}" for q in qs] + [f"norm_gal_q{q}" for q in qs]) -edge_log_x(ax, x0, psihigh.max()) -ax.set_xlabel(r"outer truncation $\psi_{high}$ (log distance from edge, 1 - $\psi_{high}$; edge at right)", fontsize=11) -ax.set_ylabel(r"|$\Delta$ delta_coil| / |delta_coil($\psi_{high}$=max)|", fontsize=11) -ax.set_title(f"Relative sensitivity of delta_coil to the truncation boundary\n{CASE}: Riccati vs Galerkin (lower = more converged)", fontsize=11) -ax.grid(alpha=0.25, which="both") -# legends INSIDE the lower-left empty region (curves live in the upper-left / lower-right) -method_legend(ax, loc="lower left", bbox=(0.012, 0.30)) -surface_legend(ax, loc="lower left", bbox=(0.012, 0.015)) -fig.subplots_adjust(left=0.09, bottom=0.13, right=0.97, top=0.9) -p2 = os.path.join(figdir, stem + "_relchange.png"); fig.savefig(p2, dpi=150); print("wrote:", p2) - -# ---------- (3) cosine shape metric ---------- -_panel = "metric" -fig, ax = plt.subplots(figsize=(11.4, 5.4)) -for k, q in enumerate(qs): - yr = col(f"cos_ric_q{q}"); yg = col(f"cos_gal_q{q}") - mr = np.isfinite(yr); mg = np.isfinite(yg) - ax.plot(psihigh[mr], yr[mr], "o-", color=colors_ric[k], lw=1.9, ms=5) - ax.plot(psihigh[mg], yg[mg], "s--", color=colors_gal[k], lw=1.5, ms=4.5, alpha=0.95) -ax.axhline(1.0, ls=":", color="#888", lw=1.0) -x0 = x0_of([f"cos_ric_q{q}" for q in qs] + [f"cos_gal_q{q}" for q in qs]) -edge_log_x(ax, x0, psihigh.max()) -ax.set_ylim(-0.05, 1.06) -ax.set_xlabel(r"outer truncation $\psi_{high}$ (log distance from edge, 1 - $\psi_{high}$; edge at right)", fontsize=11) -ax.set_ylabel("cosine similarity of delta_coil with full-domain nominal", fontsize=11) -ax.set_title(f"Shape (cosine) metric vs psihigh truncation: Riccati vs Galerkin\n{CASE}, n=1 (1.0 = shape unchanged; lower = pattern rotated)", fontsize=11) -ax.grid(alpha=0.25) -method_legend(ax); surface_legend(ax) -fig.subplots_adjust(left=0.08, bottom=0.13, right=0.76, top=0.88) -p3 = os.path.join(figdir, stem + "_metric.png"); fig.savefig(p3, dpi=150); print("wrote:", p3) diff --git a/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_sensitivity.py b/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_sensitivity.py deleted file mode 100644 index 04134514..00000000 --- a/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_sensitivity.py +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env python3 -"""plot_deltacoil_sensitivity.py - delta_coil sensitivity to dmlim (outer truncation) and singfac_min -(rational-surface approach distance), DIII-D-like n=1. Overlays the OLD DIII-D equilibrium (dashed, -open markers) against the RETARGETED Ip=1.15 MA equilibrium (solid, filled; commit 8f0a7cce). -NOTE: the delta_coil code is bit-identical across branches - this-branch code on the old geqdsk -reproduces the old values exactly (q5=38.57). The visible difference is ENTIRELY the equilibrium -retarget (q95 4.505->4.782, qmax 5.426->5.972), amplified at the edge surface q5 which is hyper- -sensitive to the edge q-profile/truncation. Values measured by deltacoil_sensitivity.jl (gal_match_flag=false).""" -import numpy as np, matplotlib -matplotlib.use("Agg"); import matplotlib.pyplot as plt -import os - -# ---- Sweep A: dmlim (truncation), set_psilim_via_dmlim=true ; NaN = surface absent ---- -dmlim = np.array([0.1, 0.3, 0.5, 0.7, 0.9]) -A_prev = { # previous week (loop-boundary-cond-riccati); q5 dropped out for dmlim > qmax-5 = 0.426 - "q=2": [11.40, 11.72, 11.07, 11.00, 11.14], - "q=3": [9.389, 9.560, 9.619, 9.319, 9.279], - "q=4": [12.42, 12.51, 14.57, 13.05, 12.51], - "q=5 (edge)": [38.57, 33.49, np.nan, np.nan, np.nan], -} -A_new = { # this branch (GGJ_colocation_backend): q5 now present at all dmlim and ~4x larger - "q=2": [10.36, 10.56, 10.83, 11.12, 11.42], - "q=3": [8.279, 8.334, 8.460, 8.607, 8.762], - "q=4": [11.34, 11.13, 11.12, 11.20, 11.28], - "q=5 (edge)": [200.6, 152.7, 139.4, 134.1, 130.9], -} -# ---- Sweep B: singfac_min (approach distance), dmlim fixed at 0.2 ---- -sfac = np.array([1e-5, 1e-4, 1e-3]) -B_prev = { - "q=2": [11.55, 11.55, 11.55], "q=3": [9.469, 9.469, 9.469], - "q=4": [12.45, 12.45, 12.45], "q=5 (edge)": [35.09, 35.10, 35.09], -} -B_new = { - "q=2": [10.45, 10.45, 10.45], "q=3": [8.292, 8.292, 8.292], - "q=4": [11.20, 11.20, 11.20], "q=5 (edge)": [167.8, 167.8, 167.8], -} -colors = {"q=2": "#2E86C1", "q=3": "#28B463", "q=4": "#E67E22", "q=5 (edge)": "#C0392B"} - -fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(13, 5.6), constrained_layout=True) -fig.suptitle("delta_coil sensitivity, DIII-D-like, n=1 · old equilibrium (dashed) vs retargeted Ip=1.15 MA (solid)", - fontsize=13, fontweight="bold") - -for k in A_new: - ax1.plot(dmlim, A_prev[k], "o--", color=colors[k], lw=1.6, ms=6, mfc="white", alpha=0.8) - ax1.plot(dmlim, A_new[k], "o-", color=colors[k], lw=2.2, ms=7, label=k) -ax1.set_yscale("log") # log-y: q5 (~130-200) dwarfs q2/q3/q4 (~10) -ax1.set_xlabel("dmlim (outer truncation, set_psilim_via_dmlim = true)") -ax1.set_ylabel("|delta_coil| (log)") -ax1.set_title("Sweep A: truncation point", fontsize=11, fontweight="bold") -ax1.legend(fontsize=9, title="solid = Ip=1.15 MA (new geqdsk)\ndashed = old geqdsk", title_fontsize=8) -ax1.grid(alpha=0.25, which="both") -ax1.annotate("q5 shifts ~5x with the equilibrium retarget\n(edge q-profile); delta_coil code is\nbit-identical across branches", - xy=(0.5, 139.4), xytext=(0.34, 58), fontsize=8.2, color=colors["q=5 (edge)"], - arrowprops=dict(arrowstyle="->", color=colors["q=5 (edge)"], lw=1.1)) - -for k in B_new: - ax2.plot(sfac, B_prev[k], "o--", color=colors[k], lw=1.6, ms=6, mfc="white", alpha=0.8) - ax2.plot(sfac, B_new[k], "o-", color=colors[k], lw=2.2, ms=7, label=k) -ax2.set_xscale("log"); ax2.set_yscale("log") -ax2.set_xlabel("singfac_min (rational-surface approach distance)") -ax2.set_ylabel("|delta_coil| (log)") -ax2.set_title("Sweep B: approach distance (dmlim = 0.2, all 4 surfaces)", fontsize=11, fontweight="bold") -ax2.legend(fontsize=9); ax2.grid(alpha=0.25, which="both") -ax2.text(0.5, 0.02, "flat to 5-6 sig figs across 2 decades for both equilibria,\n" - "delta_coil is essentially independent of singfac_min at every surface", - transform=ax2.transAxes, ha="center", va="bottom", fontsize=8.5, style="italic") - -here = os.path.dirname(os.path.abspath(__file__)) -out = os.path.abspath(os.path.join(here, "..", "figures", "deltacoil_sensitivity.png")) -os.makedirs(os.path.dirname(out), exist_ok=True) -fig.savefig(out, dpi=140, bbox_inches="tight") -print("wrote:", out) diff --git a/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_svd.py b/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_svd.py deleted file mode 100644 index 40ce7968..00000000 --- a/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_svd.py +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env python3 -"""plot_deltacoil_svd.py - SVD robustness of the delta_coil matrix vs the truncation dmlim. -LEFT: singular-value spectrum. RIGHT: condition number per run (this is what varies - the flat -tolerance-bar panel was removed because it carried no information: the tolerance scan is bit-identical -and is stated in the caption instead). Values from deltacoil_svd_tol.jl on the DIII-D-like case.""" -import numpy as np, matplotlib -matplotlib.use("Agg"); import matplotlib.pyplot as plt - -sigma = { - "nominal": [28.99, 15.57, 12.94, 9.612, 6.048, 3.607], - "dmlim=0.30": [30.49, 15.55, 13.76, 9.490, 5.997, 3.594], - "dmlim=0.42": [29.04, 15.57, 12.99, 9.607, 6.045, 3.607], - "dmlim=0.50 (q5 dropped)": [15.73, 10.62, 6.220, 4.523, 2.437, 1.395], -} -cols = {"nominal":"#111111","dmlim=0.30":"#2E86C1","dmlim=0.42":"#28B463","dmlim=0.50 (q5 dropped)":"#C0392B"} -# condition number (σ1/σ_min) per run - this genuinely varies -cond_runs = ["nominal","dmlim=0.30","dmlim=0.42","dmlim=0.50"] -cond_vals = [26.2, 27.2, 26.2, 11.3] -cond_cols = ["#111111","#2E86C1","#28B463","#C0392B"] - -fig,(ax1,ax2)=plt.subplots(1,2,figsize=(13,5.3),constrained_layout=True) -fig.suptitle("delta_coil matrix - SVD robustness (DIII-D-like)",fontsize=13,fontweight="bold") - -x=np.arange(1,7) -for k,v in sigma.items(): - ax1.plot(x,v,"o-",color=cols[k],lw=2,ms=6,label=k) -ax1.set_yscale("log"); ax1.set_xlabel("singular value index i"); ax1.set_ylabel("σᵢ") -ax1.set_title("Singular-value spectrum vs truncation\n(stable below threshold; whole matrix shrinks when q5 leaves)", - fontsize=10.5,fontweight="bold") -ax1.legend(fontsize=8.5); ax1.grid(alpha=0.25,which="both") - -bars=ax2.bar(cond_runs,cond_vals,color=cond_cols) -for b,v in zip(bars,cond_vals): ax2.text(b.get_x()+b.get_width()/2,v+0.5,f"{v:.1f}",ha="center",fontsize=9) -ax2.set_ylabel("condition number σ₁ / σ_min"); ax2.set_ylim(0,40) -ax2.set_title("Conditioning per run\n(well-conditioned ≈ 26; drops to 11 when q5 is dropped)", - fontsize=10.5,fontweight="bold") -ax2.grid(alpha=0.2,axis="y") -ax2.text(0.5,0.93,"Integration-tolerance scan (1e-8…1e-12): the σ-spectrum,\n" - "norms and cosine are all BIT-IDENTICAL - omitted from the plot\n" - "because there is nothing to show (delta_coil is tolerance-independent).", - transform=ax2.transAxes,ha="center",fontsize=8,style="italic", - bbox=dict(boxstyle="round",fc="#F4F6FA",ec="#B4BED2")) - -out="benchmarks/deltacoil_sensitivity/figures/deltacoil_svd.png" -import os; os.makedirs(os.path.dirname(out),exist_ok=True) -fig.savefig(out,dpi=140,bbox_inches="tight"); print("wrote",out) diff --git a/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_vs_rdcon.py b/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_vs_rdcon.py deleted file mode 100644 index 83fd3161..00000000 --- a/benchmarks/deltacoil_sensitivity/scripts/plot_deltacoil_vs_rdcon.py +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env python3 -"""plot_deltacoil_vs_rdcon.py - per-surface correlation of STRIDE delta_coil with the RDCON reference, -for the strong-form (driven) and two projected (weak-form, energy-weighted) readouts. -Shows the match is good on the inner surfaces and fails at the edge, and that projecting the readout -does not close the gap - the residual is weak-form-vs-strong-form representation plus RDCON edge -degradation, not a fixable reweighting. Reads the example HDF5 files directly.""" -import os, numpy as np, h5py, matplotlib -matplotlib.use("Agg"); import matplotlib.pyplot as plt - -EX = "examples/DIIID-like_gal_resistive_pe_example" -def load(fn, key): - with h5py.File(os.path.join(EX, fn), "r") as f: - a = np.asarray(f[key][()]) - return a if a.shape[0] == 8 else a.T # -> (8 surface-side, 26 coil) - -R = load("gpec.h5", "galerkin/delta_coil") # RDCON weak-form reference -variants = {"strong-form (driven)":"gpec_driven.h5", - "weak-form projection":"gpec_project.h5"} -cols = {"strong-form (driven)":"#2E86C1","weak-form projection":"#B9770E"} -sides = ["q2L","q2R","q3L","q3R","q4L","q4R","q5L","q5R"] -def corr(a,b): a,b=np.abs(a),np.abs(b); return np.corrcoef(a,b)[0,1] - -C = {name:[corr(load(fn,"singular/delta_coil_matrix")[r], R[r]) for r in range(8)] - for name,fn in variants.items()} - -fig, ax = plt.subplots(figsize=(10.5,5.4), constrained_layout=True) -ax.axvspan(-0.5,3.5,color="#2E86C1",alpha=0.05); ax.axvspan(3.5,7.5,color="#C0392B",alpha=0.06) -ax.text(1.5,1.06,"inner surfaces (RDCON reliable)",ha="center",fontsize=9,color="#20507f") -ax.text(5.5,1.06,"outer / edge (RDCON degrades)",ha="center",fontsize=9,color="#7a2420") -x=np.arange(8); w=0.38; n=len(C) -for i,(name,cs) in enumerate(C.items()): - ax.bar(x+(i-(n-1)/2)*w, cs, w, color=cols[name], label=name) -ax.axhline(0,color="k",lw=0.7); ax.set_xticks(x); ax.set_xticklabels(sides) -ax.set_ylabel("correlation of |delta_coil| with RDCON"); ax.set_ylim(-0.6,1.15) -ax.set_title("STRIDE delta_coil vs RDCON, per surface-side\n" - "good on inner surfaces; edge disagreement is RDCON degradation; projection does not close the gap", - fontsize=11.5,fontweight="bold") -ax.legend(fontsize=9,loc="lower left"); ax.grid(alpha=0.2,axis="y") -ax.text(0.99,0.02,"residual = weak-form (RDCON) vs strong-form (STRIDE) representation\n" - "+ RDCON edge degradation. Invariant quantities (Δ′, matched deltar/cout) match cleanly.", - transform=ax.transAxes,ha="right",va="bottom",fontsize=8,style="italic", - bbox=dict(boxstyle="round",fc="#F4F6FA",ec="#B4BED2")) -out="benchmarks/deltacoil_sensitivity/figures/deltacoil_vs_rdcon.png" -os.makedirs(os.path.dirname(out),exist_ok=True) -fig.savefig(out,dpi=140,bbox_inches="tight"); print("wrote",out) -# print the table too -print(f'{"variant":22s} | inner outer') -for name,cs in C.items(): print(f'{name:22s} | {np.mean(cs[:4]):5.2f} {np.mean(cs[4:]):5.2f}') diff --git a/benchmarks/deltacoil_sensitivity/scripts/plot_deltamn_merge.py b/benchmarks/deltacoil_sensitivity/scripts/plot_deltamn_merge.py deleted file mode 100644 index 80b480ac..00000000 --- a/benchmarks/deltacoil_sensitivity/scripts/plot_deltamn_merge.py +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env python3 -# Task 5: plot the merged resonant response vs resistivity from deltamn_resistivity_merge.jl. -# (a) full |Delta_mn| vs eta, one curve per rational surface (the capstone result) -# (b) decomposition per surface: the eta-independent coil term vs the eta-dependent plasma term. -# The coil term must be FLAT in eta; its relative spread is printed as an internal consistency check. -# Usage: plot_deltamn_merge.py [title] -import sys, csv, math -import numpy as np -import matplotlib -matplotlib.use("Agg") -import matplotlib.pyplot as plt - -CSV, OUT = sys.argv[1], sys.argv[2] -TITLE = sys.argv[3] if len(sys.argv) > 3 else "" - -rows = list(csv.DictReader(open(CSV))) -if not rows: - raise SystemExit(f"no rows in {CSV}") -cols = rows[0].keys() -qs = sorted({c.split("_q")[1] for c in cols if c.startswith("dmn_q")}, key=float) - -def col(name): - out = [] - for r in rows: - v = r.get(name, "") - try: - out.append(float(v)) if v not in ("", None) else out.append(np.nan) - except ValueError: - out.append(np.nan) - return np.array(out) - -eta = col("eta") -order = np.argsort(-eta) # high eta (weakly driven) -> low eta (strongly driven) -eta = eta[order] - -COLORS = ["#1f5fbf", "#e07b1a", "#2a9d4a", "#b3306b", "#6a3fb5"] -fig, (axA, axB) = plt.subplots(1, 2, figsize=(11.6, 4.4)) - -print(f"\n=== {CSV} ===") -for k, q in enumerate(qs): - c = COLORS[k % len(COLORS)] - tot = col(f"dmn_q{q}")[order] - coil = col(f"dmn_coil_q{q}")[order] - plas = col(f"dmn_plasma_q{q}")[order] - fitr = col(f"fitres_q{q}")[order] - - axA.plot(eta, tot, "o-", color=c, lw=1.8, ms=4, label=f"q = {q}") - axB.plot(eta, plas, "o-", color=c, lw=1.8, ms=4, label=f"q = {q} plasma (eta-dep)") - axB.plot(eta, coil, "s--", color=c, lw=1.4, ms=3.5, alpha=0.75, label=f"q = {q} coil (eta-indep)") - - good = np.isfinite(coil) & (coil > 0) - spread = (np.nanmax(coil[good]) / np.nanmin(coil[good]) - 1.0) if good.sum() > 1 else float("nan") - swing = (np.nanmax(tot[np.isfinite(tot)]) / np.nanmin(tot[np.isfinite(tot)])) if np.isfinite(tot).sum() > 1 else float("nan") - print(f" q={q}: |Delta_mn| swings {swing:.3g}x over eta | coil-term relative spread {spread:.2e} " - f"(should be ~0) | max fit residual {np.nanmax(fitr):.2e}") - -for ax in (axA, axB): - ax.set_xscale("log"); ax.set_yscale("log") - ax.invert_xaxis() # left = high eta (weak drive), right = low eta (strong drive) - ax.set_xlabel("resistivity eta (decreasing to the right = more strongly driven layer)") - ax.grid(True, which="major", alpha=0.25) -axA.set_ylabel("|Delta_mn| (merged resonant response)") -axA.set_title("(a) Full merged resonant response vs resistivity", fontsize=10.5) -axA.legend(fontsize=8.5) -axB.set_ylabel("contribution to |Delta_mn|") -axB.set_title("(b) Decomposition: eta-independent coil vs eta-dependent plasma", fontsize=10.5) -axB.legend(fontsize=7.4, ncol=1) - -if TITLE: - fig.suptitle(TITLE, fontsize=11.5, y=1.00) -fig.tight_layout() -fig.savefig(OUT, dpi=150) -print("WROTE:", OUT) diff --git a/benchmarks/deltacoil_sensitivity/scripts/plot_deltaprime_epsilon.py b/benchmarks/deltacoil_sensitivity/scripts/plot_deltaprime_epsilon.py deleted file mode 100644 index a33a4580..00000000 --- a/benchmarks/deltacoil_sensitivity/scripts/plot_deltaprime_epsilon.py +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env python3 -# plot_deltaprime_epsilon.py -- Phase C benchmark for the single-surface TJ study: GPEC's tearing -# Delta-prime versus inverse aspect ratio epsilon = a/R0, at fixed cylindrical q-profile, for each -# single-rational-surface case. As epsilon -> 0 the toroidal Delta-prime must converge to the cylindrical -# (large-aspect) value; the dotted lines mark the analytic vacuum limit r_s*Delta' = -2m from the -# independent Newcomb solver (exact when the current gradient at the surface is negligible). -# -# Reads scratch eps__/gpec_eps__.h5 written by the eps-scan runner. -# Usage: python3 plot_deltaprime_epsilon.py - -import sys, os -import numpy as np -import h5py -import matplotlib -matplotlib.use("Agg") -import matplotlib.pyplot as plt - -SC, FIGDIR = sys.argv[1], sys.argv[2] -os.makedirs(FIGDIR, exist_ok=True) -LABS = ["q1", "q2", "q3", "q4", "q5"] -MOF = {"q1":1,"q2":2,"q3":3,"q4":4,"q5":5} -EPS = [0.2, 0.1, 0.05] - -def read_dp(h5): - with h5py.File(h5, "r") as f: - if "galerkin/pest3_Delta" not in f: - return None, None - dp = np.atleast_2d(f["galerkin/pest3_Delta"][()]).ravel() - q = np.atleast_1d(f["singular/q"][()]) - return float(np.real(dp[0])), float(q[0]) if len(q) else None - -data = {} # lab -> list of (eps, Dprime) -print("=== GPEC tearing Delta-prime vs aspect ratio epsilon (fixed q-profile) ===") -print(f" {'case':5s} {'m':>2s} " + "".join(f"eps={e:<7}" for e in EPS) + " vacuum(-2m) converged?") -for lab in LABS: - m = MOF[lab]; row = [] - for e in EPS: - h5 = os.path.join(SC, f"eps_{lab}_{e}", f"gpec_eps_{lab}_{e}.h5") - if os.path.exists(h5): - dp, q = read_dp(h5) - row.append((e, dp)) - else: - row.append((e, None)) - data[lab] = row - vals = [d for _, d in row if d is not None] - conv = (abs(vals[-1] - vals[-2]) if len(vals) >= 2 else float("nan")) - cells = "".join(f"{(d if d is not None else float('nan')):<+11.3f}" for _, d in row) - print(f" {lab:5s} {m:>2d} {cells} {-2*m:<+11d} d(last2)={conv:.3f}") - -# ---- plot ---- -colors = plt.cm.viridis(np.linspace(0.08, 0.82, len(LABS))) -fig, ax = plt.subplots(figsize=(9.0, 5.6)) -for k, lab in enumerate(LABS): - m = MOF[lab] - es = [e for e, d in data[lab] if d is not None] - ds = [d for e, d in data[lab] if d is not None] - if not ds: - continue - ax.plot(es, ds, "o-", color=colors[k], lw=1.9, ms=6, label=f"{lab}: q={m}/1") - ax.axhline(-2*m, ls=":", color=colors[k], lw=1.1, alpha=0.7) - ax.text(0.205, -2*m, f" -2m={-2*m}", color=colors[k], fontsize=7.5, va="center", ha="left") -ax.set_xscale("log") -ax.invert_xaxis() # epsilon decreasing to the right = more cylindrical -ax.set_xlabel(r"inverse aspect ratio $\epsilon = a/R_0$ (decreasing to the right = more cylindrical)", fontsize=11) -ax.set_ylabel(r"tearing $\Delta'$ (PEST3, $= r_s\Delta'$)", fontsize=11) -ax.set_title("Single-surface TJ study: GPEC tearing $\\Delta'$ vs aspect ratio\n" - "dotted = analytic Newcomb vacuum limit $r_s\\Delta'=-2m$ (exact); " - "convergence as $\\epsilon\\to0$ validates the cylindrical reduction", fontsize=10.5) -ax.grid(alpha=0.25, which="both") -ax.legend(fontsize=9, title="rational surface", title_fontsize=9, loc="best") -fig.tight_layout() -out = os.path.join(FIGDIR, "single_surface_deltaprime_epsilon.png") -fig.savefig(out, dpi=150) -print("wrote:", out) diff --git a/benchmarks/deltacoil_sensitivity/scripts/plot_marginal_q2.py b/benchmarks/deltacoil_sensitivity/scripts/plot_marginal_q2.py deleted file mode 100644 index c8e0ac87..00000000 --- a/benchmarks/deltacoil_sensitivity/scripts/plot_marginal_q2.py +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env python3 -# plot_marginal_q2.py -- Phase D marginal-stability scan for the single q=2 TJ surface. Varies the -# edge safety factor qa (fixed qc=1.5, so the current-peaking nu=qa/qc changes) while keeping exactly -# one rational surface (q=2). Tracks the tearing Delta-prime; the classic single-surface tearing test -# is Delta-prime passing through zero (marginal stability) as the current gradient at the surface changes. -# Reads scratch marg_q2_qa<...>/gpec_marg_q2_qa<...>.h5. -# Usage: python3 plot_marginal_q2.py - -import sys, os -import numpy as np -import h5py -import matplotlib -matplotlib.use("Agg") -import matplotlib.pyplot as plt - -SC, FIGDIR = sys.argv[1], sys.argv[2] -os.makedirs(FIGDIR, exist_ok=True) -QC = 1.5 -QAS = [2.1, 2.2, 2.3, 2.4, 2.5, 2.7, 2.9] - -rows = [] -for qa in QAS: - tag = f"qa{qa}".replace(".", "p") - h5 = os.path.join(SC, f"marg_q2_{tag}", f"gpec_marg_q2_{tag}.h5") - if not os.path.exists(h5): - continue - with h5py.File(h5, "r") as f: - if "galerkin/pest3_Delta" not in f: - continue - dp = float(np.real(np.atleast_2d(f["galerkin/pest3_Delta"][()]).ravel()[0])) - q = np.atleast_1d(f["singular/q"][()]) - rows.append((qa, qa / QC, dp, len(q))) - -print("=== q2 marginal-stability scan (qc=1.5 fixed) ===") -print(f" {'qa':>5s} {'nu=qa/qc':>9s} {'msing':>5s} {'Delta_prime':>12s}") -for qa, nu, dp, ms in rows: - print(f" {qa:>5.2f} {nu:>9.3f} {ms:>5d} {dp:>+12.3f}") - -qas = np.array([r[0] for r in rows]); dps = np.array([r[2] for r in rows]) -# locate zero crossing by linear interpolation between sign changes -qa_marg = None -for i in range(len(qas) - 1): - if dps[i] == 0 or (dps[i] < 0) != (dps[i+1] < 0): - qa_marg = qas[i] + (0 - dps[i]) * (qas[i+1] - qas[i]) / (dps[i+1] - dps[i]) - break -print(f" marginal (Delta_prime=0) at qa ~ {qa_marg:.3f} (nu ~ {qa_marg/QC:.3f})" if qa_marg else " no sign change in scanned range") - -fig, ax = plt.subplots(figsize=(8.2, 5.2)) -ax.plot(qas, dps, "o-", color="#1f5fbf", lw=2, ms=7) -ax.axhline(0, color="k", lw=0.9, ls="--") -if qa_marg: - ax.axvline(qa_marg, color="#c0392b", lw=1.2, ls=":") - ax.plot([qa_marg], [0], "s", color="#c0392b", ms=9, - label=f"marginal: qa~{qa_marg:.3f} (nu~{qa_marg/QC:.3f})") - ax.legend(fontsize=9, loc="best") -ax.text(0.02, 0.96, "unstable (Delta' > 0)", transform=ax.transAxes, fontsize=9, color="#a33", va="top") -ax.text(0.02, 0.05, "stable (Delta' < 0)", transform=ax.transAxes, fontsize=9, color="#264", va="bottom") -ax.set_xlabel("edge safety factor qa (current peaking nu = qa/qc, qc=1.5 fixed)", fontsize=11) -ax.set_ylabel("tearing $\\Delta'$ (PEST3)", fontsize=11) -ax.set_title("Single q=2 TJ surface: marginal-stability scan\n$\\Delta'$ vs current peaking; zero crossing = tearing marginal point", fontsize=11) -ax.grid(alpha=0.25) -fig.tight_layout() -out = os.path.join(FIGDIR, "single_surface_q2_marginal.png") -fig.savefig(out, dpi=150) -print("wrote:", out) diff --git a/benchmarks/deltacoil_sensitivity/scripts/plot_match_easycase.py b/benchmarks/deltacoil_sensitivity/scripts/plot_match_easycase.py deleted file mode 100644 index e414e60e..00000000 --- a/benchmarks/deltacoil_sensitivity/scripts/plot_match_easycase.py +++ /dev/null @@ -1,134 +0,0 @@ -#!/usr/bin/env python3 -# Task 4 (matching easy case) validation figures. Reads the three LAR resistive-match runs and -# produces: -# match_easycase_cout.png - matched |cout| per surface (STRIDE vs galerkin) + ideal=0, and the -# STRIDE-vs-galerkin cout correlation per surface. -# match_easycase_prec.png - solve residual and reconnected-flux self-consistency (log scale). -# Usage: plot_match_easycase.py -import sys, numpy as np, h5py -import matplotlib -matplotlib.use("Agg") -import matplotlib.pyplot as plt - -DRIVEN, IDEAL, SINGLE, FIGDIR = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4] - -def cread(fid, path): - """Read a Julia HDF5 complex/real dataset as a numpy array, restored to Julia (column-major) - orientation: h5py exposes 2D datasets transposed, so we transpose back.""" - if path not in fid: - return None - a = fid[path][()] - if a.dtype.names and set(a.dtype.names) >= {"r", "i"}: - a = a["r"] + 1j * a["i"] - if a.ndim == 2: - a = a.T - return a - -def surf_metrics(h5): - """Per-surface matched-cout norms + STRIDE-vs-galerkin correlation, plus residuals/self-consistency.""" - with h5py.File(h5, "r") as fid: - scout = cread(fid, "singular/resonant_match/cout") - cin = cread(fid, "singular/resonant_match/cin") - dr = cread(fid, "singular/resonant_match/deltar") - flux = cread(fid, "singular/resonant_match/reconnected_flux") - sres = float(np.array(fid["singular/resonant_match/residual"][()])) - gcout = cread(fid, "galerkin/match/cout") - gres = float(np.array(fid["galerkin/match/residual"][()])) - msing = dr.shape[0] - # per-surface (two rows per surface) norms and complex correlation - s_norm, g_norm, corr = [], [], [] - for i in range(msing): - rows = slice(2 * i, 2 * i + 2) - s = np.asarray(scout[rows]).ravel().astype(complex) - g = np.asarray(gcout[rows]).ravel().astype(complex) - s_norm.append(np.linalg.norm(s)) - g_norm.append(np.linalg.norm(g)) - denom = np.linalg.norm(s) * np.linalg.norm(g) + 1e-300 - corr.append(abs(np.vdot(s, g)) / denom) - # reconnected-flux self-consistency: reconnected_flux ?= -InnerBlock*cin - inner = np.zeros_like(flux, dtype=complex) - for i in range(msing): - d1, d2 = dr[i, 0], dr[i, 1] - r1, r2 = 2 * i, 2 * i + 1 - inner[r1] = -(-d1 * cin[r1] + d2 * cin[r2]) - inner[r2] = -(-d1 * cin[r1] - d2 * cin[r2]) - fden = np.linalg.norm(flux) + 1e-300 - selfc = np.linalg.norm(inner - flux) / fden - return dict(msing=msing, s_norm=s_norm, g_norm=g_norm, corr=corr, - sres=sres, gres=gres, selfc=selfc, - scout_tot=float(np.linalg.norm(scout.astype(complex))), - gcout_tot=float(np.linalg.norm(gcout.astype(complex)))) - -d = surf_metrics(DRIVEN) -si = surf_metrics(SINGLE) -with h5py.File(IDEAL, "r") as fid: - ideal_scout = float(np.linalg.norm(cread(fid, "singular/resonant_match/cout").astype(complex))) - ideal_gcout = float(np.linalg.norm(cread(fid, "galerkin/match/cout").astype(complex))) - ideal_sres = float(np.array(fid["singular/resonant_match/residual"][()])) - -BLUE, ORANGE, GREY = "#1f5fbf", "#e07b1a", "#666666" - -# ---------- Figure 1: matched cout (per surface) + correlation ---------- -fig, (axA, axB) = plt.subplots(1, 2, figsize=(11.0, 4.2)) - -# Panel A: per-surface matched |cout|, STRIDE vs galerkin, + ideal=0 -labels = ["driven q=2", "driven q=3", "single q=2", "ideal q=2", "ideal q=3"] -stride_vals = d["s_norm"] + si["s_norm"] + [ideal_scout, ideal_scout] -galerk_vals = d["g_norm"] + si["g_norm"] + [ideal_gcout, ideal_gcout] -x = np.arange(len(labels)); w = 0.38 -axA.bar(x - w/2, stride_vals, w, label="STRIDE (Riccati outer)", color=BLUE) -axA.bar(x + w/2, galerk_vals, w, label="galerkin (RDCON outer)", color=ORANGE) -axA.set_xticks(x); axA.set_xticklabels(labels, rotation=20, ha="right", fontsize=8.5) -axA.set_ylabel("matched |cout| per surface") -axA.set_title("(a) Matched outer coefficient: driven vs ideal", fontsize=10) -axA.legend(fontsize=8, loc="upper right") -axA.annotate("ideal limit:\ncout = 0 exactly\n(perfect shielding)", xy=(3.5, 0.0), - xytext=(2.7, max(stride_vals)*0.55), fontsize=8, color=GREY, - ha="left", arrowprops=dict(arrowstyle="->", color=GREY)) - -# Panel B: STRIDE-vs-galerkin cout correlation per surface -clab = ["driven q=2\n(inner, coupled)", "driven q=3\n(edge, coupled)", "single q=2\n(isolated pair)"] -cvals = [d["corr"][0], d["corr"][1], si["corr"][0]] -cx = np.arange(len(clab)) -bars = axB.bar(cx, cvals, 0.5, color=[GREY, GREY, BLUE]) -axB.axhline(1.0, color="k", lw=0.7, ls=":") -axB.set_ylim(0, 1.08); axB.set_xticks(cx); axB.set_xticklabels(clab, fontsize=8.3) -axB.set_ylabel("|| / (norms)") -axB.set_title("(b) STRIDE vs galerkin matched-cout correlation", fontsize=10) -for b, v in zip(bars, cvals): - axB.text(b.get_x()+b.get_width()/2, v+0.015, f"{v:.4f}", ha="center", fontsize=8.5) - -fig.tight_layout() -f1 = f"{FIGDIR}/match_easycase_cout.png" -fig.savefig(f1, dpi=150); print("WROTE:", f1) - -# ---------- Figure 2: residual + self-consistency (log) ---------- -fig2, ax = plt.subplots(figsize=(8.6, 4.0)) -groups = ["driven", "single-surface", "ideal"] -sres = [d["sres"], si["sres"], ideal_sres] -gres = [d["gres"], si["gres"], 0.0] -selfc = [d["selfc"], si["selfc"], np.nan] # ideal self-consistency is N/A (no inner layer) -floor = 1e-18 -def clamp(v): return [max(x, floor) if np.isfinite(x) else np.nan for x in v] -gx = np.arange(len(groups)); w = 0.26 -ax.bar(gx - w, clamp(sres), w, label="STRIDE solve residual", color=BLUE) -ax.bar(gx, clamp(gres), w, label="galerkin solve residual", color=ORANGE) -ax.bar(gx + w, clamp(selfc), w, label="reconnected-flux self-consistency", color="#2a9d4a") -ax.axhline(1e-16, color="k", lw=0.7, ls=":"); ax.text(2.35, 1.3e-16, "1e-16", fontsize=8, color=GREY) -ax.set_yscale("log"); ax.set_ylim(1e-18, 1e-10) -ax.set_xticks(gx); ax.set_xticklabels(groups) -ax.set_ylabel("relative error (log scale)") -ax.set_title("Match solve residual and reconnected-flux self-consistency", fontsize=10.5) -ax.legend(fontsize=8, loc="upper left") -ax.text(1.62, 3e-18, "ideal: cout=0, no inner layer\n(self-consistency N/A)", fontsize=7.6, color=GREY) -fig2.tight_layout() -f2 = f"{FIGDIR}/match_easycase_prec.png" -fig2.savefig(f2, dpi=150); print("WROTE:", f2) - -# ---------- console summary for the PDF text ---------- -print("\n=== SUMMARY ===") -print(f"driven : msing={d['msing']} sres={d['sres']:.3e} gres={d['gres']:.3e} selfc={d['selfc']:.3e} " - f"corr={['%.4f'%c for c in d['corr']]}") -print(f"single : msing={si['msing']} sres={si['sres']:.3e} gres={si['gres']:.3e} selfc={si['selfc']:.3e} " - f"corr={['%.4f'%c for c in si['corr']]}") -print(f"ideal : ||cout|| STRIDE={ideal_scout:.3e} galerkin={ideal_gcout:.3e} sres={ideal_sres:.3e}") diff --git a/benchmarks/deltacoil_sensitivity/scripts/plot_penetrated_field_psihigh.py b/benchmarks/deltacoil_sensitivity/scripts/plot_penetrated_field_psihigh.py deleted file mode 100644 index bc83a439..00000000 --- a/benchmarks/deltacoil_sensitivity/scripts/plot_penetrated_field_psihigh.py +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env python3 -# plot_penetrated_field_psihigh.py -- matched resonant coupling ||cout|| per rational surface vs the -# outer truncation psihigh, comparing the two match paths: STRIDE (singular/resonant_match/cout, solid) -# and galerkin (galerkin/match/cout, dashed). Reads resonant_coupling_psihigh_.csv. -# x-axis = log distance from edge (1 - psihigh), edge at right, matching the other psihigh figures. -# -# Usage: python3 plot_penetrated_field_psihigh.py results/resonant_coupling_psihigh_.csv - -import sys, os, csv -import numpy as np -import matplotlib -matplotlib.use('Agg') -import matplotlib.pyplot as plt - -here = os.path.dirname(os.path.abspath(__file__)) -csv_path = os.path.abspath(sys.argv[1]) if len(sys.argv) > 1 else \ - os.path.join(here, '..', 'results', 'penetrated_field_psihigh.csv') - -with open(csv_path) as f: - rows = list(csv.reader(f)) -header = rows[0] -scols = [c for c in header[1:] if c.startswith('stride_q')] -gcols = [c for c in header[1:] if c.startswith('gal_q')] -qvals = [float(c.split('q')[1]) for c in scols] -idx = {c: i + 1 for i, c in enumerate(header[1:])} -psihigh, S, G = [], {q: [] for q in qvals}, {q: [] for q in qvals} -for r in rows[1:]: - if not r or r[0] == '': - continue - psihigh.append(float(r[0])) - for q in qvals: - sv = r[idx[f'stride_q{q}']]; gv = r[idx[f'gal_q{q}']] - S[q].append(float(sv) if sv not in ('', 'FAIL') else np.nan) - G[q].append(float(gv) if gv not in ('', 'FAIL') else np.nan) -psihigh = np.array(psihigh) - -_case = os.path.splitext(os.path.basename(csv_path))[0].replace('penetrated_field_psihigh', '').strip('_').upper() -CASE = 'LAR (large-aspect-ratio)' if 'LAR' in _case else ('DIII-D-like' if ('DIIID' in _case or _case == '') else _case) - -_hasany = np.array([any(not np.isnan(S[q][i]) for q in qvals) for i in range(len(psihigh))]) -x0 = psihigh[_hasany].min() if _hasany.any() else psihigh.min() -def edge_log_x(ax): - fwd = lambda p: -np.log10(np.clip(1.0 - np.asarray(p, float), 1e-9, None)) - inv = lambda t: 1.0 - 10.0 ** (-np.asarray(t, float)) - ax.set_xscale('function', functions=(fwd, inv)) - cand = np.array([0.5, 0.6, 0.75, 0.85, 0.9, 0.95, 0.98, 0.99]) - tk = cand[(cand >= x0 - 1e-9) & (cand <= psihigh.max() + 1e-9)] - ax.set_xticks(tk); ax.set_xticklabels([f'{t:g}' for t in tk]); ax.minorticks_off() - ax.set_xlim(x0 - (1 - x0) * 0.06, psihigh.max() + (1 - psihigh.max()) * 0.4) - -colors = plt.cm.viridis(np.linspace(0.08, 0.82, len(qvals))) -fig, ax = plt.subplots(figsize=(8.6, 5.2)) -ax.set_yscale('log') -for q, c in zip(qvals, colors): - ys = np.array(S[q], float); m = ~np.isnan(ys) - ax.plot(psihigh[m], ys[m], 'o-', color=c, lw=2.0, ms=6, label=f'q = {q:g}, STRIDE') - yg = np.array(G[q], float); mg = ~np.isnan(yg) - ax.plot(psihigh[mg], yg[mg], 's--', color=c, lw=1.5, ms=5, mfc='white', label=f'q = {q:g}, galerkin') -edge_log_x(ax) -ax.set_xlabel('outer truncation ψ$_{high}$ (log distance from edge, 1 - ψ$_{high}$; edge at right)', fontsize=11) -ax.set_ylabel('penetrated field per surface (log)', fontsize=11) -ax.set_title('Penetrated field vs psihigh truncation: STRIDE reconnected_flux (solid) vs galerkin bpen (dashed)\n' - f'{CASE}, n=1 · field that penetrates the resistive layer', fontsize=11) -ax.grid(alpha=0.25, which='both') -ax.legend(fontsize=7.6, ncol=2, loc='best') -fig.subplots_adjust(left=0.12, bottom=0.13, right=0.97, top=0.88) - -stem = os.path.splitext(os.path.basename(csv_path))[0].replace('penetrated_field', 'pen') -out = os.path.abspath(os.path.join(here, '..', 'figures', stem + '.png')) -os.makedirs(os.path.dirname(out), exist_ok=True) -fig.savefig(out, dpi=150) -print('wrote:', out) diff --git a/benchmarks/deltacoil_sensitivity/scripts/plot_psihigh_fig1style_rg.py b/benchmarks/deltacoil_sensitivity/scripts/plot_psihigh_fig1style_rg.py deleted file mode 100644 index 9cdbcf0c..00000000 --- a/benchmarks/deltacoil_sensitivity/scripts/plot_psihigh_fig1style_rg.py +++ /dev/null @@ -1,103 +0,0 @@ -#!/usr/bin/env python3 -# plot_psihigh_fig1style_rg.py -- Task-1 / Fig-1a-b style view of the Riccati-vs-Galerkin edge scan: -# all surfaces overlaid on ONE magnitude axis (left) plus the relative-change convergence view (right), -# from the 100-point deltacoil_psihigh_rg_.csv (psihigh 0.9 -> 0.9999). Color = surface; -# STRIDE (Riccati) solid+circles, Galerkin (RDCON) dashed+squares. Surfaces that ENTER within the -# window get dotted grid-entry + dashed true-psi_s markers; surfaces already present at the left edge -# are interior (psi_s < 0.9, off-screen left) and their psi_s is noted in the legend. -# Usage: plot_psihigh_fig1style_rg.py [--qmax=Q] [--psi_s="2:0.49,3:0.886,..."] - -import sys, os, csv -import numpy as np -import matplotlib -matplotlib.use('Agg') -import matplotlib.pyplot as plt -from matplotlib.lines import Line2D - -QMAX = None; TRUE = {}; pos = [] -for a in sys.argv[1:]: - if a.startswith('--qmax='): - QMAX = float(a.split('=', 1)[1]) - elif a.startswith('--psi_s='): - for kv in a.split('=', 1)[1].split(','): - if ':' in kv: - k, v = kv.split(':'); TRUE[float(k)] = float(v) - else: - pos.append(a) -CSVP = os.path.abspath(pos[0]); here = os.path.dirname(os.path.abspath(__file__)) -_tag = os.path.splitext(os.path.basename(CSVP))[0].replace('deltacoil_psihigh_rg_', '') -CASE = {'LARresistivematchtest': 'LAR (large-aspect-ratio)', - 'DIIIDlikegalresistivepeexample': 'DIII-D-like'}.get(_tag, _tag) - -rows = list(csv.reader(open(CSVP))); h = rows[0]; idx = {c: i for i, c in enumerate(h)} -ph = np.array([float(r[0]) for r in rows[1:] if r and r[0]]) -qs = sorted({c.split('_q')[1] for c in h if c.startswith('norm_ric_q')}, key=float) -if QMAX is not None: - qs = [q for q in qs if float(q) <= QMAX + 1e-9] - -def col(n): - j = idx.get(n); out = [] - for r in rows[1:]: - if not r or not r[0]: - continue - v = r[j] if (j is not None and j < len(r)) else '' - out.append(float(v) if v not in ('', 'FAIL', 'NaN') else np.nan) - return np.array(out) - -GX0, GXM = ph.min(), ph.max() -def edge_log_x(ax): - fwd = lambda p: -np.log10(np.clip(1.0 - np.asarray(p, float), 1e-9, None)) - inv = lambda t: 1.0 - 10.0 ** (-np.asarray(t, float)) - ax.set_xscale('function', functions=(fwd, inv)) - cand = np.array([0.9, 0.99, 0.999, 0.9999]) - tk = cand[(cand >= GX0 - 1e-9) & (cand <= GXM + 1e-9)] - ax.set_xticks(tk); ax.set_xticklabels([f'{t:g}' for t in tk]); ax.minorticks_off() - ax.set_xlim(GX0 - (1 - GX0) * 0.06, GXM + (1 - GXM) * 0.4) - -colors = plt.cm.viridis(np.linspace(0.08, 0.82, len(qs))) -fig, (axM, axR) = plt.subplots(1, 2, figsize=(13.5, 5.4)) - -for q, c in zip(qs, colors): - qf = float(q) - yr = col(f'norm_ric_q{q}'); yg = col(f'norm_gal_q{q}') - interior = np.isfinite(yr[np.argmin(ph)]) # present at the leftmost psihigh? - lab = f'q = {qf:g}' - if interior and qf in TRUE: - lab += f' (ψ$_s$≈{TRUE[qf]:.2f}, interior)' - mR = np.isfinite(yr); mG = np.isfinite(yg) - axM.plot(ph[mR], yr[mR], '-', color=c, lw=1.8, label=lab) - axM.plot(ph[mG], yg[mG], '--', color=c, lw=1.2, alpha=0.85) - # markers only for surfaces that enter within the window (not interior/off-left) - if not interior: - ent = ph[mR].min() - axM.axvline(ent, ls=':', color=c, lw=1.0, alpha=0.7) - t = f' q={qf:g} enters ≈{ent:.4f}' - if qf in TRUE: - axM.axvline(TRUE[qf], ls='--', color=c, lw=1.0, alpha=0.5) - t += f' (ψ$_s$≈{TRUE[qf]:.4f})' - axM.text(ent, axM.get_ylim()[1], t, color=c, fontsize=7, rotation=90, va='top', ha='left') - # relative change vs the outermost (edge-most) value, STRIDE - if mR.any(): - ref = yr[mR][-1] - axR.plot(ph[mR], np.abs(yr[mR] - ref) / max(abs(ref), 1e-300), '-', color=c, lw=1.8, label=f'q = {qf:g}') - -for ax in (axM, axR): - ax.set_yscale('log'); edge_log_x(ax); ax.grid(alpha=0.25, which='both') - ax.set_xlabel('outer truncation ψ$_{high}$ (log distance from edge; edge at right)', fontsize=10) -axM.set_ylabel('|delta_coil| per surface (log)', fontsize=11) -axR.set_ylabel('relative change vs ψ$_{high}$=max (log; lower = converged)', fontsize=10) -axM.set_title('|delta_coil| vs ψ$_{high}$ (all surfaces, one axis)', fontsize=11) -axR.set_title('Convergence: relative sensitivity to the boundary (Riccati)', fontsize=11) -# legends -h1 = axM.get_legend_handles_labels() -axM.legend(*h1, fontsize=8, title='rational surface', title_fontsize=8, loc='lower right') -style = [Line2D([0], [0], color='#444', ls='-', label='Riccati (STRIDE)'), - Line2D([0], [0], color='#444', ls='--', label='Galerkin (RDCON)')] -axR.legend(handles=style + [Line2D([0], [0], color=c, ls='-', label=f'q = {float(q):g}') for q, c in zip(qs, colors)], - fontsize=8, loc='lower left', ncol=2) -fig.suptitle(f'Edge truncation, Fig-1a/1b style: {CASE}, n=1 · 100 points, ψ$_{{high}}$ = 0.9 to 0.9999\n' - 'color = surface; Riccati (STRIDE) solid, Galerkin (RDCON) dashed', fontsize=12) -fig.tight_layout(rect=[0, 0, 1, 0.93]) -stem = 'deltacoil_psihigh_fig1style_' + _tag -out = os.path.abspath(os.path.join(here, '..', 'figures', stem + '.png')) -fig.savefig(out, dpi=150); print('wrote:', out) diff --git a/benchmarks/deltacoil_sensitivity/scripts/plot_resistivity_scan.py b/benchmarks/deltacoil_sensitivity/scripts/plot_resistivity_scan.py deleted file mode 100644 index 343e4de7..00000000 --- a/benchmarks/deltacoil_sensitivity/scripts/plot_resistivity_scan.py +++ /dev/null @@ -1,64 +0,0 @@ -#!/usr/bin/env python3 -# plot_resistivity_scan.py -- resistivity (eta) scan comparing the two inner-layer backends (ray vs -# galerkin) in the galerkin match. Two panels: left = inner-layer |deltar| (Delta(Q), where the backends -# differ), right = matched penetrated field ||bpen|| (the shielding family). ray solid, galerkin dashed, -# one color per rational surface. |Q| grows as eta falls (roughly |Q| ~ eta^-1/3), toward the left. -# Reads resistivity_scan_.csv. -# -# Usage: python3 plot_resistivity_scan.py results/resistivity_scan_.csv - -import sys, os, csv -import numpy as np -import matplotlib -matplotlib.use('Agg') -import matplotlib.pyplot as plt - -csv_path = os.path.abspath(sys.argv[1]) -here = os.path.dirname(os.path.abspath(__file__)) -with open(csv_path) as f: - rows = list(csv.reader(f)) -header = rows[0] -qvals = sorted({float(c.split('_q')[1]) for c in header[1:]}) -idx = {c: i for i, c in enumerate(header)} -eta = [] -data = {(m, q): [] for m in ('ray_deltar', 'gal_deltar', 'ray_bpen', 'gal_bpen') for q in qvals} -for r in rows[1:]: - if not r or r[0] == '': - continue - eta.append(float(r[0])) - for (m, q) in data: - col = f'{m}_q{q}' - v = r[idx[col]] if col in idx else '' - data[(m, q)].append(float(v) if v not in ('', 'FAIL') else np.nan) -eta = np.array(eta) - -_case = os.path.splitext(os.path.basename(csv_path))[0].replace('resistivity_scan', '').strip('_').upper() -CASE = 'LAR (large-aspect-ratio)' if 'LAR' in _case else 'DIII-D-like' -colors = plt.cm.viridis(np.linspace(0.08, 0.82, len(qvals))) - -fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(13.5, 5.4)) -def panel(ax, ray_key, gal_key, ylab, ttl): - for q, c in zip(qvals, colors): - yr = np.array(data[(ray_key, q)], float); mr = ~np.isnan(yr) - ax.plot(eta[mr], yr[mr], 'o-', color=c, lw=2.0, ms=6, label=f'q = {q:g}, ray') - yg = np.array(data[(gal_key, q)], float); mg = ~np.isnan(yg) - ax.plot(eta[mg], yg[mg], 's--', color=c, lw=1.5, ms=5, mfc='white', label=f'q = {q:g}, galerkin') - ax.set_xscale('log'); ax.set_yscale('log') - ax.invert_xaxis() # low eta (high |Q|, where galerkin drifts) on the RIGHT - ax.set_xlabel('resistivity eta (low eta = high |Q|, to the right)', fontsize=10.5) - ax.set_ylabel(ylab, fontsize=10.5) - ax.set_title(ttl, fontsize=10.5, fontweight='bold') - ax.grid(alpha=0.25, which='both') - -panel(ax1, 'ray_deltar', 'gal_deltar', 'inner-layer |deltar| (log)', 'Inner-layer Delta(Q): ray vs galerkin backend') -panel(ax2, 'ray_bpen', 'gal_bpen', 'matched penetrated field ||bpen|| (log)', 'Penetrated (shielding) field: ray vs galerkin backend') -ax1.legend(fontsize=7.4, ncol=2, loc='best') -fig.suptitle(f'Resistivity scan: ray (solid) vs galerkin (dashed) inner-layer backend · {CASE}, n=1', - fontsize=12, fontweight='bold') -fig.subplots_adjust(left=0.07, right=0.98, bottom=0.12, top=0.88, wspace=0.22) - -stem = os.path.splitext(os.path.basename(csv_path))[0].replace('resistivity_scan', 'resist') -out = os.path.abspath(os.path.join(here, '..', 'figures', stem + '.png')) -os.makedirs(os.path.dirname(out), exist_ok=True) -fig.savefig(out, dpi=150) -print('wrote:', out) diff --git a/benchmarks/deltacoil_sensitivity/scripts/plot_resonant_coupling_psihigh.py b/benchmarks/deltacoil_sensitivity/scripts/plot_resonant_coupling_psihigh.py deleted file mode 100644 index f55d1182..00000000 --- a/benchmarks/deltacoil_sensitivity/scripts/plot_resonant_coupling_psihigh.py +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env python3 -# plot_resonant_coupling_psihigh.py -- matched resonant coupling ||cout|| per rational surface vs the -# outer truncation psihigh, comparing the two match paths: STRIDE (singular/resonant_match/cout, solid) -# and galerkin (galerkin/match/cout, dashed). Reads resonant_coupling_psihigh_.csv. -# x-axis = log distance from edge (1 - psihigh), edge at right, matching the other psihigh figures. -# -# Usage: python3 plot_resonant_coupling_psihigh.py results/resonant_coupling_psihigh_.csv - -import sys, os, csv -import numpy as np -import matplotlib -matplotlib.use('Agg') -import matplotlib.pyplot as plt - -here = os.path.dirname(os.path.abspath(__file__)) -csv_path = os.path.abspath(sys.argv[1]) if len(sys.argv) > 1 else \ - os.path.join(here, '..', 'results', 'resonant_coupling_psihigh.csv') - -with open(csv_path) as f: - rows = list(csv.reader(f)) -header = rows[0] -scols = [c for c in header[1:] if c.startswith('stride_q')] -gcols = [c for c in header[1:] if c.startswith('gal_q')] -qvals = [float(c.split('q')[1]) for c in scols] -idx = {c: i + 1 for i, c in enumerate(header[1:])} -psihigh, S, G = [], {q: [] for q in qvals}, {q: [] for q in qvals} -for r in rows[1:]: - if not r or r[0] == '': - continue - psihigh.append(float(r[0])) - for q in qvals: - sv = r[idx[f'stride_q{q}']]; gv = r[idx[f'gal_q{q}']] - S[q].append(float(sv) if sv not in ('', 'FAIL') else np.nan) - G[q].append(float(gv) if gv not in ('', 'FAIL') else np.nan) -psihigh = np.array(psihigh) - -_case = os.path.splitext(os.path.basename(csv_path))[0].replace('resonant_coupling_psihigh', '').strip('_').upper() -CASE = 'LAR (large-aspect-ratio)' if 'LAR' in _case else ('DIII-D-like' if ('DIIID' in _case or _case == '') else _case) - -_hasany = np.array([any(not np.isnan(S[q][i]) for q in qvals) for i in range(len(psihigh))]) -x0 = psihigh[_hasany].min() if _hasany.any() else psihigh.min() -def edge_log_x(ax): - fwd = lambda p: -np.log10(np.clip(1.0 - np.asarray(p, float), 1e-9, None)) - inv = lambda t: 1.0 - 10.0 ** (-np.asarray(t, float)) - ax.set_xscale('function', functions=(fwd, inv)) - cand = np.array([0.5, 0.6, 0.75, 0.85, 0.9, 0.95, 0.98, 0.99]) - tk = cand[(cand >= x0 - 1e-9) & (cand <= psihigh.max() + 1e-9)] - ax.set_xticks(tk); ax.set_xticklabels([f'{t:g}' for t in tk]); ax.minorticks_off() - ax.set_xlim(x0 - (1 - x0) * 0.06, psihigh.max() + (1 - psihigh.max()) * 0.4) - -colors = plt.cm.viridis(np.linspace(0.08, 0.82, len(qvals))) -fig, ax = plt.subplots(figsize=(8.6, 5.2)) -ax.set_yscale('log') -for q, c in zip(qvals, colors): - ys = np.array(S[q], float); m = ~np.isnan(ys) - ax.plot(psihigh[m], ys[m], 'o-', color=c, lw=2.0, ms=6, label=f'q = {q:g}, STRIDE') - yg = np.array(G[q], float); mg = ~np.isnan(yg) - ax.plot(psihigh[mg], yg[mg], 's--', color=c, lw=1.5, ms=5, mfc='white', label=f'q = {q:g}, galerkin') -edge_log_x(ax) -ax.set_xlabel('outer truncation ψ$_{high}$ (log distance from edge, 1 - ψ$_{high}$; edge at right)', fontsize=11) -ax.set_ylabel('matched resonant coupling\n||cout|| per surface (log)', fontsize=11) -ax.set_title('Matched resonant coupling vs psihigh truncation: STRIDE (solid) vs galerkin (dashed)\n' - f'{CASE}, n=1 · cout from Wang 2020 Eq. 11 match', fontsize=11) -ax.grid(alpha=0.25, which='both') -ax.legend(fontsize=7.6, ncol=2, loc='best') -fig.subplots_adjust(left=0.13, bottom=0.13, right=0.97, top=0.88) - -stem = os.path.splitext(os.path.basename(csv_path))[0].replace('resonant_coupling', 'rescoup') -out = os.path.abspath(os.path.join(here, '..', 'figures', stem + '.png')) -os.makedirs(os.path.dirname(out), exist_ok=True) -fig.savefig(out, dpi=150) -print('wrote:', out) diff --git a/benchmarks/deltacoil_sensitivity/scripts/resistivity_scan.jl b/benchmarks/deltacoil_sensitivity/scripts/resistivity_scan.jl deleted file mode 100644 index 59cc09ce..00000000 --- a/benchmarks/deltacoil_sensitivity/scripts/resistivity_scan.jl +++ /dev/null @@ -1,123 +0,0 @@ -#!/usr/bin/env julia -# resistivity_scan.jl -- resistivity (eta) scan of the matched inner-layer response, comparing the two -# inner-layer backends in the galerkin match: "ray" (rotated-contour collocation, robust for |Q| >~ 1) -# and "galerkin" (Hermite-cubic inps, drifts for |Q| >~ 1). psihigh is held at the full domain; only eta -# is varied (|Q| grows as eta falls, roughly |Q| ~ eta^(-1/3)). Per surface we report: -# |deltar| -- the inner-layer Delta(Q) (galerkin/match/deltar), where the two backends differ, and -# ||bpen|| -- the matched penetrated field (galerkin/match/bpen), the shielding family. -# The branch result to reproduce: ray gives a smooth, monotonic shielding family; galerkin jumps at low eta. -# -# Usage: julia --project= scripts/resistivity_scan.jl [N_eta] [eta_hi] [eta_lo] - -using GeneralizedPerturbedEquilibrium, HDF5, LinearAlgebra, Printf - -length(ARGS) >= 1 || error("usage: julia --project= scripts/resistivity_scan.jl [N_eta] [eta_hi] [eta_lo]") -base = ARGS[1]; isdir(base) || error("no such dir: $base") -NETA = length(ARGS) >= 2 ? parse(Int, ARGS[2]) : 8 -ETA_HI = length(ARGS) >= 3 ? parse(Float64, ARGS[3]) : 1e-6 -ETA_LO = length(ARGS) >= 4 ? parse(Float64, ARGS[4]) : 1e-13 -ENV["DELTACOIL_MODE"] = "driven"; delete!(ENV, "DELTACOIL_PROJECT") - -base_toml = read(joinpath(base, "gpec.toml"), String) -scratch = mktempdir() -eqm = match(r"eq_filename\s*=\s*\"([^\"]+)\"", base_toml) -eqfile = eqm === nothing ? nothing : eqm.captures[1] -eqsrc = eqfile === nothing ? nothing : (isabspath(eqfile) ? eqfile : joinpath(base, eqfile)) - -# how many surfaces (length of the toml's gal_eta array = full-domain msing) -nsurf = (m = match(r"gal_eta\s*=\s*\[([^\]]*)\]", base_toml); m === nothing ? 0 : count(==(','), m.captures[1]) + 1) -nsurf > 0 || error("could not parse gal_eta length from toml") -RHO = (m = match(r"gal_rho\s*=\s*\[\s*([\d.eE+-]+)", base_toml); m === nothing ? 3.3e-7 : parse(Float64, m.captures[1])) -# rotation f [Hz] sets gamma=2pi n f, hence |Q| = 2pi n f / Q0 (Q0 ∝ eta^1/3). Override via ENV to reach -# the |Q| >~ 1 regime where the galerkin inner layer drifts (example default f=1 Hz keeps |Q| < 1). -ROT = (rv = get(ENV, "GAL_ROTATION", ""); rv != "" ? parse(Float64, rv) : - (m = match(r"gal_rotation\s*=\s*\[\s*([\d.eE+-]+)", base_toml); m === nothing ? 1.0 : parse(Float64, m.captures[1]))) - -ETAS = 10 .^ range(log10(ETA_HI), log10(ETA_LO); length=NETA) - -function run_eta(eta, solver) - tag = "eta_$(eta)_$(solver)" - dir = joinpath(scratch, tag); mkpath(dir) - if eqfile !== nothing - dst = joinpath(dir, basename(eqfile)); islink(dst) || symlink(realpath(eqsrc), dst) - end - toml = base_toml - for key in ("force_termination", "HDF5_filename", "gal_match_flag", "gal_inner_solver", - "thmax0", "delta_mband", "gal_eta", "gal_rho", "gal_rotation") - toml = replace(toml, Regex("(?m)^[ \\t]*$key[ \\t]*=.*\\n") => "") - end - arrs = "gal_eta = [" * join(fill(string(eta), nsurf), ", ") * "]\n" * - "gal_rho = [" * join(fill(string(RHO), nsurf), ", ") * "]\n" * - "gal_rotation = [" * join(fill(string(ROT), nsurf), ", ") * "]\n" - toml = replace(toml, "[ForceFreeStates]" => - "[ForceFreeStates]\nforce_termination = true\nHDF5_filename = \"$tag.h5\"\ngal_match_flag = true\n" * - "gal_inner_solver = \"$solver\"\n" * arrs; count=1) - eqfile !== nothing && (toml = replace(toml, r"eq_filename\s*=\s*\"[^\"]+\"" => "eq_filename = \"$(basename(eqfile))\"")) - write(joinpath(dir, "gpec.toml"), toml) - @info ">>> eta = $eta solver = $solver" - try - GeneralizedPerturbedEquilibrium.main([dir]) - catch err - @warn "run failed at eta=$eta solver=$solver" exception = (err, catch_backtrace()) - return nothing - end - h5path = joinpath(dir, "$tag.h5") - isfile(h5path) || return nothing - h5open(h5path, "r") do fid - haskey(fid, "singular/q") || return nothing - q = vec(read(fid, "singular/q")); isempty(q) && return nothing - dr = haskey(fid, "galerkin/match/deltar") ? read(fid, "galerkin/match/deltar") : nothing # (msing,2) - bp = haskey(fid, "galerkin/match/bpen") ? read(fid, "galerkin/match/bpen") : nothing # (ncoil,msing) - deltar = Dict{Float64,Float64}(); bpen = Dict{Float64,Float64}() - for s in 1:length(q) - qk = round(q[s]; digits=2) - dr === nothing || (deltar[qk] = norm(dr[s, :])) - if bp !== nothing - b = size(bp, 1) >= size(bp, 2) ? bp : permutedims(bp) - bpen[qk] = norm(b[:, s]) - end - end - (q=sort(round.(q; digits=2)), deltar=deltar, bpen=bpen) - end -end - -rows = Tuple{Float64,Any,Any}[] -for eta in ETAS - push!(rows, (eta, run_eta(eta, "ray"), run_eta(eta, "galerkin"))) -end -allq = sort(collect(reduce(union, (Set(keys(r[2].deltar)) for r in rows if r[2] !== nothing); init=Set{Float64}()))) - -outdir = joinpath(@__DIR__, "..", "results"); mkpath(outdir) -tag = replace(basename(base), r"[^A-Za-z0-9]" => "") * "_f" * replace(string(ROT), "." => "p") -csv = joinpath(outdir, "resistivity_scan_$tag.csv") -open(csv, "w") do io - cols = String["eta"] - for q in allq, m in ("ray_deltar","gal_deltar","ray_bpen","gal_bpen"); push!(cols, "$(m)_q$(q)"); end - println(io, join(cols, ",")) - for (eta, rr, rg) in rows - vals = String[string(eta)] - for q in allq - push!(vals, rr === nothing ? "" : string(get(rr.deltar, q, ""))) - push!(vals, rg === nothing ? "" : string(get(rg.deltar, q, ""))) - push!(vals, rr === nothing ? "" : string(get(rr.bpen, q, ""))) - push!(vals, rg === nothing ? "" : string(get(rg.bpen, q, ""))) - end - println(io, join(vals, ",")) - end -end - -println("\n" * "="^78) -println(" RESISTIVITY scan ($(basename(base))): inner-layer |deltar| vs eta, ray vs galerkin backend") -println("="^78) -@printf(" %-10s", "eta") -for q in allq; @printf(" | ray:q%-4.1f | gal:q%-4.1f", q, q); end; println() -for (eta, rr, rg) in rows - @printf(" %-10.2e", eta) - for q in allq - @printf(" | %8s | %8s", - rr === nothing ? "-" : (haskey(rr.deltar, q) ? @sprintf("%.2e", rr.deltar[q]) : "-"), - rg === nothing ? "-" : (haskey(rg.deltar, q) ? @sprintf("%.2e", rg.deltar[q]) : "-")) - end - println() -end -println("\nwrote: $(abspath(csv))") diff --git a/benchmarks/deltacoil_sensitivity/scripts/resonant_coupling_psihigh.jl b/benchmarks/deltacoil_sensitivity/scripts/resonant_coupling_psihigh.jl deleted file mode 100644 index ccad8469..00000000 --- a/benchmarks/deltacoil_sensitivity/scripts/resonant_coupling_psihigh.jl +++ /dev/null @@ -1,126 +0,0 @@ -#!/usr/bin/env julia -# resonant_coupling_psihigh.jl -- truncation scan of the MATCHED resonant coupling (Wang 2020 Eq. 11 -# output) versus the outer boundary psihigh, for BOTH match paths from a single run: -# STRIDE path : singular/resonant_match/cout (Riccati outer delta_coil + inner layer) -# galerkin path : galerkin/match/cout (RDCON weak-form outer + inner layer) -# Per rational surface we report ||cout_surface|| (norm over coil modes of that surface's two sides). -# This is the physical matched coupling, downstream of the delta_coil we scanned before. -# -# The match needs per-surface resistive arrays (gal_eta/gal_rho/gal_rotation) of length msing, and msing -# changes as psihigh passes surfaces. So we first run the full domain (arrays already correct in the toml) -# to get the surface locations, then size the arrays to the surface count at each psihigh. -# -# Usage: julia --project= scripts/resonant_coupling_psihigh.jl [N] [psihigh_max] - -using GeneralizedPerturbedEquilibrium, HDF5, LinearAlgebra, Printf - -length(ARGS) >= 1 || error("usage: julia --project= scripts/resonant_coupling_psihigh.jl [N] [psihigh_max]") -base = ARGS[1]; isdir(base) || error("no such dir: $base") -N = length(ARGS) >= 2 ? parse(Int, ARGS[2]) : 12 -PSIH_MAX = length(ARGS) >= 3 ? parse(Float64, ARGS[3]) : 0.993 -ENV["DELTACOIL_MODE"] = "driven"; delete!(ENV, "DELTACOIL_PROJECT") - -base_toml = read(joinpath(base, "gpec.toml"), String) -scratch = mktempdir() -eqm = match(r"eq_filename\s*=\s*\"([^\"]+)\"", base_toml) -eqfile = eqm === nothing ? nothing : eqm.captures[1] -eqsrc = eqfile === nothing ? nothing : (isabspath(eqfile) ? eqfile : joinpath(base, eqfile)) - -# base resistive values (uniform across surfaces in the examples): parse first entry of each array -firstval(key, default) = (m = match(Regex("$key\\s*=\\s*\\[\\s*([\\d.eE+-]+)"), base_toml); m === nothing ? default : parse(Float64, m.captures[1])) -ETA = firstval("gal_eta", 8e-8); RHO = firstval("gal_rho", 3.3e-7); ROT = firstval("gal_rotation", 1.0) - -# run the match at one psihigh with resistive arrays of length `ms` (nothing => keep toml arrays = full domain) -function run_match(ph, ms) - tag = "ph_$(ph)" - dir = joinpath(scratch, tag); mkpath(dir) - if eqfile !== nothing - dst = joinpath(dir, basename(eqfile)); islink(dst) || symlink(realpath(eqsrc), dst) - end - toml = base_toml - strip_keys = ["force_termination", "HDF5_filename", "psiedge", "set_psilim_via_dmlim", - "truncate_at_dW_peak", "thmax0", "delta_mband", "gal_match_flag"] - ms === nothing || append!(strip_keys, ["gal_eta", "gal_rho", "gal_rotation"]) # only resize when ms given; keep toml arrays for full domain - for key in strip_keys - toml = replace(toml, Regex("(?m)^[ \\t]*$key[ \\t]*=.*\\n") => "") - end - arrblk = ms === nothing ? "" : - "gal_eta = [" * join(fill(string(ETA), ms), ", ") * "]\n" * - "gal_rho = [" * join(fill(string(RHO), ms), ", ") * "]\n" * - "gal_rotation = [" * join(fill(string(ROT), ms), ", ") * "]\n" - toml = replace(toml, "[ForceFreeStates]" => - "[ForceFreeStates]\nforce_termination = true\nHDF5_filename = \"$tag.h5\"\npsiedge = 1.0\n" * - "set_psilim_via_dmlim = false\ntruncate_at_dW_peak = false\ngal_match_flag = true\n" * arrblk; count=1) - toml = replace(toml, r"(?m)^([ \t]*)psihigh[ \t]*=[ \t]*[\d.eE+-]+" => SubstitutionString("\\1psihigh = $ph")) - eqfile !== nothing && (toml = replace(toml, r"eq_filename\s*=\s*\"[^\"]+\"" => "eq_filename = \"$(basename(eqfile))\"")) - write(joinpath(dir, "gpec.toml"), toml) - @info ">>> psihigh = $ph (msing arrays = $(ms === nothing ? "toml" : ms))" - try - GeneralizedPerturbedEquilibrium.main([dir]) - catch err - @warn "run failed at psihigh=$ph" exception = (err, catch_backtrace()) - return nothing - end - h5path = joinpath(dir, "$tag.h5") - isfile(h5path) || return nothing - h5open(h5path, "r") do fid - haskey(fid, "singular/q") || return nothing - q = vec(read(fid, "singular/q")); isempty(q) && return nothing - psi = vec(read(fid, "singular/psi")) - # per-surface norm of cout: cout is (ncoil, 2msing), columns are surface-sides - percol(dc) = begin - dc = size(dc, 1) >= size(dc, 2) ? dc : permutedims(dc) # -> (ncoil, 2msing) - Dict(round(q[s]; digits=2) => norm(dc[:, 2s-1:2s]) for s in 1:length(q)) - end - stride_c = haskey(fid, "singular/resonant_match/cout") ? percol(read(fid, "singular/resonant_match/cout")) : Dict{Float64,Float64}() - gal_c = haskey(fid, "galerkin/match/cout") ? percol(read(fid, "galerkin/match/cout")) : Dict{Float64,Float64}() - (q=sort(round.(q; digits=2)), psi=psi, stride=stride_c, gal=gal_c) - end -end - -# --- nominal at full domain first: gives surface locations and full msing (toml arrays already correct) --- -@info "=== nominal (full domain) ===" -nom = run_match(PSIH_MAX, nothing) -nom === nothing && error("nominal full-domain match failed") -psi_s = sort(nom.psi) -@info "surfaces (psi_s): $psi_s full msing=$(length(psi_s))" - -# grid: log-packed toward edge, from just past the innermost surface to the edge -PSIHIGHS = round.(1 .- 10 .^ range(log10(1 - (psi_s[1] + 1e-3)), log10(1 - PSIH_MAX); length=N); digits=5) - -results = Tuple{Float64,Any}[] -for ph in PSIHIGHS - ms = count(<(ph), psi_s) - push!(results, (ph, ms == 0 ? nothing : (ph == PSIH_MAX ? nom : run_match(ph, ms)))) -end -# make sure the nominal (max) point is present -any(r -> r[1] == PSIH_MAX, results) || push!(results, (PSIH_MAX, nom)) - -allq = sort(collect(nom.q)) -outdir = joinpath(@__DIR__, "..", "results"); mkpath(outdir) -tag = replace(basename(base), r"[^A-Za-z0-9]" => "") -csv = joinpath(outdir, "resonant_coupling_psihigh_$tag.csv") -open(csv, "w") do io - hdr = "psihigh," * join(["stride_q$(q)" for q in allq], ",") * "," * join(["gal_q$(q)" for q in allq], ",") - println(io, hdr) - for (ph, r) in sort(results, by=first) - sv = [r === nothing ? "" : string(get(r.stride, q, "")) for q in allq] - gv = [r === nothing ? "" : string(get(r.gal, q, "")) for q in allq] - println(io, "$ph," * join(sv, ",") * "," * join(gv, ",")) - end -end - -println("\n" * "="^78) -println(" MATCHED resonant coupling ||cout|| per surface vs psihigh ($(basename(base)))") -println(" STRIDE = singular/resonant_match/cout ; galerkin = galerkin/match/cout") -println("="^78) -@printf(" %-9s", "psihigh") -for q in allq; @printf(" | S:q%-4.1f", q); end -for q in allq; @printf(" | G:q%-4.1f", q); end; println() -for (ph, r) in sort(results, by=first) - @printf(" %-9.4f", ph) - for q in allq; @printf(" | %7s", r === nothing ? "-" : (haskey(r.stride, q) ? @sprintf("%.2e", r.stride[q]) : "-")); end - for q in allq; @printf(" | %7s", r === nothing ? "-" : (haskey(r.gal, q) ? @sprintf("%.2e", r.gal[q]) : "-")); end - println() -end -println("\nwrote: $(abspath(csv))") From fb0d3349a8fb61da573fc72408a4121a822e2a9b Mon Sep 17 00:00:00 2001 From: Matthew Pharr Date: Thu, 30 Jul 2026 16:32:48 -0400 Subject: [PATCH 30/38] GGJ - CLEANUP - Adjust formatting in _build_tjmat function for clarity --- src/InnerLayer/GGJ/InnerAsymptotics.jl | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/InnerLayer/GGJ/InnerAsymptotics.jl b/src/InnerLayer/GGJ/InnerAsymptotics.jl index 2411f896..acdeeb69 100644 --- a/src/InnerLayer/GGJ/InnerAsymptotics.jl +++ b/src/InnerLayer/GGJ/InnerAsymptotics.jl @@ -93,6 +93,7 @@ function _build_tjmat(p::GGJParameters, Q::ComplexF64) # T (Eq. 7) — Fortran constructs this with column-major RESHAPE; the # listing below is row-major Julia order matching that layout. + #! format: off T = @SMatrix ComplexF64[ 1 0 h*q q2/λ h*q -q2/λ 0 0 0 -1/λ 0 1/λ @@ -110,6 +111,7 @@ function _build_tjmat(p::GGJParameters, Q::ComplexF64) 0 0 λ/2 0 0 1/2 0 λ/2 0 0 1/2 0 ] + #! format: on # A_0, A_1, A_2 — GW2020 Eqs. (4), (5), and the A₂ matrix of Eq. (3). Build mutable then freeze. A0 = zeros(ComplexF64, 6, 6) From 70dcb21bf98a9c346ac45ddb61b42bea1cdaef7a Mon Sep 17 00:00:00 2001 From: Matthew Pharr Date: Thu, 30 Jul 2026 16:34:46 -0400 Subject: [PATCH 31/38] GGJ - CLEANUP - Apply formatter to GGJ code --- src/InnerLayer/GGJ/Galerkin.jl | 65 ++++++++++++++------------ src/InnerLayer/GGJ/InnerAsymptotics.jl | 12 ++--- src/InnerLayer/GGJ/Ray.jl | 9 ++-- src/InnerLayer/GGJ/Shooting.jl | 16 +++---- 4 files changed, 51 insertions(+), 51 deletions(-) diff --git a/src/InnerLayer/GGJ/Galerkin.jl b/src/InnerLayer/GGJ/Galerkin.jl index 167bbe6d..6ec87d5e 100644 --- a/src/InnerLayer/GGJ/Galerkin.jl +++ b/src/InnerLayer/GGJ/Galerkin.jl @@ -65,11 +65,11 @@ GWP2016 Eqs. (12)–(15) with `(I, V, U) = (A, −B, −C)` and rows scaled by equations. """ function _physical_uv(params::GGJParameters, Q::ComplexF64, x::Real) - e = ComplexF64(params.E); + e = ComplexF64(params.E) f = ComplexF64(params.F) - h = ComplexF64(params.H); + h = ComplexF64(params.H) g = ComplexF64(params.G) - k = ComplexF64(params.K); + k = ComplexF64(params.K) q = Q q2 = q * q x2 = x * x @@ -112,7 +112,7 @@ function _hermite(x::Real, x0::Real, x1::Real) dx = x1 - x0 t0 = (x - x0) / dx t1 = 1 - t0 - t02 = t0 * t0; + t02 = t0 * t0 t12 = t1 * t1 pb = SVector{4,Float64}( t12 * (1 + 2t0), @@ -211,7 +211,7 @@ function _xmax_3level(params::GGJParameters, Q::ComplexF64; if !any(set) break end - x_prev = x; + x_prev = x delta_prev = dmax x *= dxfac end @@ -295,9 +295,9 @@ function _build_grid_and_workspace(nx::Int, xmax::Float64, dx1::Float64, dx2::Fl x_nodes[nx-1] = xmax - (dx1 + dx2) ixmax = nx - 2 # packed region is 0..ixmax - x0 = x_nodes[1]; + x0 = x_nodes[1] x1_packed = x_nodes[ixmax+1] - xm = (x1_packed + x0) / 2; + xm = (x1_packed + x0) / 2 dxp = (x1_packed - x0) / 2 mx = ixmax ÷ 2 packed = xm .+ dxp .* _pack(mx, pfac, side) @@ -312,7 +312,7 @@ function _build_grid_and_workspace(nx::Int, xmax::Float64, dx1::Float64, dx2::Fl imap = 1 for ix in 1:nx et = etypes[ix] - xl = x_nodes[ix]; + xl = x_nodes[ix] xr = x_nodes[ix+1] cell_np = if et == CT_NONE || et == CT_EXT1 || et == CT_EXT2 @@ -537,7 +537,7 @@ function _resonant_integral(cell::GalerkinCell, params::GGJParameters, function integrand_11(x) ua, dua = _physical_ua_dua(cache, x) Imat, Umat, Vmat = _physical_uv(params, Q, x) - ua1 = ua[:, 1]; + ua1 = ua[:, 1] dua1 = dua[:, 1] return transpose(dua1) * Imat * dua1 + transpose(ua1) * Vmat * dua1 + transpose(ua1) * Umat * ua1 end @@ -546,8 +546,8 @@ function _resonant_integral(cell::GalerkinCell, params::GGJParameters, ua, dua = _physical_ua_dua(cache, x) Imat, Umat, Vmat = _physical_uv(params, Q, x) dua1 = dua[:, 1] - ua1 = ua[:, 1]; - ua2 = ua[:, 2]; + ua1 = ua[:, 1] + ua2 = ua[:, 2] dua2 = dua[:, 2] return transpose(dua1) * Imat * dua2 + transpose(ua1) * Vmat * dua2 + transpose(ua1) * Umat * ua2 end @@ -566,7 +566,7 @@ function _assemble_and_solve!(ws::GalerkinWorkspace, params::GGJParameters, Q::ComplexF64, cache::InnerAsymptoticsCache; nq::Int=4, tol_res::Float64=1e-5) - mpert = 3; + mpert = 3 np = 3 quad_nodes, quad_weights = gausslobatto(nq + 1) offset = ws.kl + ws.kl + 1 # kl + ku + 1 since ku = kl @@ -588,14 +588,14 @@ function _assemble_and_solve!(ws::GalerkinWorkspace, for ip in 0:np_eff, ipert in 1:mpert i = cell.map[ipert, ip+1] if i > ws.ndim - ; - continue; + + continue end for jp in 0:np_eff, jpert in 1:mpert j = cell.map[jpert, jp+1] if j > ws.ndim - ; - continue; + + continue end ws.mat[offset+i-j, j, 1] += cell_mat[ipert, jpert, ip+1, jp+1] end @@ -626,8 +626,8 @@ function _assemble_and_solve!(ws::GalerkinWorkspace, continue end if i > ws.ndim - ; - continue; + + continue end for jp in 0:npp, jpert in 1:mpert j = jp < size(cell.map, 2) ? cell.map[jpert, jp+1] : cell.emap[1] @@ -635,8 +635,8 @@ function _assemble_and_solve!(ws::GalerkinWorkspace, continue end if j > ws.ndim - ; - continue; + + continue end ws.mat[offset+i-j, j, 1] += cell_mat_ext[ipert, jpert, ip+1, jp+1] end @@ -703,8 +703,8 @@ function _assemble_and_solve!(ws::GalerkinWorkspace, for ipert in 1:mpert i = cell1.map[ipert, 1] # ip=0 DOFs if i > ws.ndim - ; - continue; + + continue end for jj in max(1, i-ws.kl):min(ws.ndim, i+ws.kl) ws.mat[offset+i-jj, jj, isol] = 0 @@ -719,20 +719,20 @@ function _assemble_and_solve!(ws::GalerkinWorkspace, # → row=Ξ(ip=0), col=Ξ(ip=1): A[map[2,1], map[2,2]] = 1 # → row=Υ(ip=0), col=Υ(ip=1): A[map[3,1], map[3,2]] = 1 if isol == 1 - i = cell1.map[1, 1]; + i = cell1.map[1, 1] j = cell1.map[1, 2] ws.mat[offset+i-j, j, isol] = 1 for ipert in 2:3 - i = cell1.map[ipert, 1]; + i = cell1.map[ipert, 1] j = cell1.map[ipert, 1] ws.mat[offset+i-j, j, isol] = 1 end else - i = cell1.map[1, 1]; + i = cell1.map[1, 1] j = cell1.map[1, 1] ws.mat[offset+i-j, j, isol] = 1 for ipert in 2:3 - i = cell1.map[ipert, 1]; + i = cell1.map[ipert, 1] j = cell1.map[ipert, 2] ws.mat[offset+i-j, j, isol] = 1 end @@ -746,8 +746,8 @@ function _assemble_and_solve!(ws::GalerkinWorkspace, end # Solve for each parity using LAPACK banded LU (gbtrf! + gbtrs!) - n = ws.ndim; - kl = ws.kl; + n = ws.ndim + kl = ws.kl ku = kl for isol in 1:2 ab = copy(ws.mat[:, :, isol]) @@ -800,9 +800,12 @@ function _solution_profile(ws::GalerkinWorkspace; npc::Int=10) end k += 1 xs[k] = x - Ψ[k, 1] = vals[1, 1]; Ψ[k, 2] = vals[1, 2] - Ξ[k, 1] = vals[2, 1]; Ξ[k, 2] = vals[2, 2] - Υ[k, 1] = vals[3, 1]; Υ[k, 2] = vals[3, 2] + Ψ[k, 1] = vals[1, 1] + Ψ[k, 2] = vals[1, 2] + Ξ[k, 1] = vals[2, 1] + Ξ[k, 2] = vals[2, 2] + Υ[k, 1] = vals[3, 1] + Υ[k, 2] = vals[3, 2] end end p = sortperm(xs) diff --git a/src/InnerLayer/GGJ/InnerAsymptotics.jl b/src/InnerLayer/GGJ/InnerAsymptotics.jl index acdeeb69..4dfd641a 100644 --- a/src/InnerLayer/GGJ/InnerAsymptotics.jl +++ b/src/InnerLayer/GGJ/InnerAsymptotics.jl @@ -174,9 +174,9 @@ function _lyap_solve(K::SMatrix{6,6,ComplexF64}, λ::ComplexF64) Pm = zeros(ComplexF64, 6, 6) # B is block-diagonal in the (r1, r2) split. - Bm[1, 1] = K[1, 1]; + Bm[1, 1] = K[1, 1] Bm[1, 2] = K[1, 2] - Bm[2, 1] = K[2, 1]; + Bm[2, 1] = K[2, 1] Bm[2, 2] = K[2, 2] for i in 3:6, j in 3:6 Bm[i, j] = K[i, j] @@ -312,7 +312,7 @@ function _coefs(B::Vector{SMatrix{6,6,ComplexF64,36}}, end # Lowest-order Y solution and inverse — GW2020 Eq. (48), with exponents r± from Eq. (49). - r1 = ComplexF64(R[1]); + r1 = ComplexF64(R[1]) r2 = ComplexF64(R[2]) Y0 = @SMatrix ComplexF64[ 1 1 @@ -519,7 +519,7 @@ function evaluate_asymptotics(cache::InnerAsymptoticsCache, x::Real; # Splitting matrix pp (6×2): top 2×2 = I, bottom 4×2 = p21. pp_m = zeros(ComplexF64, 6, 2) - pp_m[1, 1] = 1; + pp_m[1, 1] = 1 pp_m[2, 2] = 1 @inbounds for i in 1:4, j in 1:2 pp_m[i+2, j] = p21[i, j] @@ -600,8 +600,8 @@ function asymptotic_residual(cache::InnerAsymptoticsCache, x::Real) delta = MVector{2,Float64}(0.0, 0.0) @inbounds for j in 1:2 - n0 = 0.0; - n1 = 0.0; + n0 = 0.0 + n1 = 0.0 n2 = 0.0 for i in 1:6 n0 = max(n0, abs(matvec0[i, j])) diff --git a/src/InnerLayer/GGJ/Ray.jl b/src/InnerLayer/GGJ/Ray.jl index 47097c68..7556a589 100644 --- a/src/InnerLayer/GGJ/Ray.jl +++ b/src/InnerLayer/GGJ/Ray.jl @@ -638,7 +638,7 @@ function _assemble_base(params::GGJParameters, Q::ComplexF64, θ::Float64, end rhs = zeros(ComplexF64, ndof) - rhs[rowm0.+(1:6)] .= Us + rhs[rowm0 .+ (1:6)] .= Us return Ic, Jc, Vc, rhs, βb, ndof, Ng end @@ -802,7 +802,7 @@ function solve_ray(params::GGJParameters, Q::ComplexF64; # origin for extreme-coefficient surfaces, where the Υ-family rate # √|Q(G+KF)| dominates and kills exponential content within s ~ O(1)). s_m = let acc = 0.0, sprev = 0.0, out = Sv - for sk in exp10.(range(-2, log10(Sv), length=400)) + for sk in exp10.(range(-2, log10(Sv); length=400)) gdec = Inf for λ in eigvals(Matrix(cis(θ) * ode_matrix(params, Q, cis(θ) * sk))) g = -real(λ) # backward-growth = forward-decay rate @@ -940,8 +940,6 @@ end npc=8, certify_rtol=1e-3, kwargs...) -> (; Δ, x, Ψ, Ξ, dψdx, rescale, certΔ) -Rotated-ray implementation of the [`solve_inner_profile`](@ref) interface. The -certified `Δ` comes from the optimal-contour solve at θ = arg(Q)/4 (robust for |Q| ≳ 1, where real-axis methods drift); the profiles come from a θ = 0 re-solve on the real axis, valid at physical (RPEC) |Q| since the on-axis pseudo-resonance is a regular point resolved by the BVP refinement. The @@ -1088,7 +1086,7 @@ function delta_convergence(params::GGJParameters, Q::ComplexF64; ("refine_tol/10", (; refine_tol=refine_tol / 10)), ("march_rtol/100", (; march_rtol=1e-11)), ("sm_fac 1→1.4", (; sm_fac=1.4)), - ("growth 30→45", (; growth=45.0)), + ("growth 30→45", (; growth=45.0)) ] table = NamedTuple[] spread = MVector{2,Float64}(0.0, 0.0) @@ -1111,4 +1109,3 @@ end delta_convergence(params::GGJParameters, Q::Number; kwargs...) = delta_convergence(params, ComplexF64(Q); kwargs...) - diff --git a/src/InnerLayer/GGJ/Shooting.jl b/src/InnerLayer/GGJ/Shooting.jl index 5dc8219a..e0f8ce45 100644 --- a/src/InnerLayer/GGJ/Shooting.jl +++ b/src/InnerLayer/GGJ/Shooting.jl @@ -62,11 +62,11 @@ function _build_origin_arrays(p::GGJParameters, Q::ComplexF64; nps::Int=8, rtol: p1v = p1(p) pplus = -0.5 + p1v - e = ComplexF64(p.E); - f = ComplexF64(p.F); + e = ComplexF64(p.E) + f = ComplexF64(p.F) h = ComplexF64(p.H) - g = ComplexF64(p.G); - k = ComplexF64(p.K); + g = ComplexF64(p.G) + k = ComplexF64(p.K) q = Q q3 = q^3 @@ -158,11 +158,11 @@ end function _build_infinity_arrays(p::GGJParameters, Q::ComplexF64, d0inv::SMatrix{4,4,ComplexF64}; rtol::Float64=1e-6, fmax::Float64=1.0) - e = ComplexF64(p.E); - f = ComplexF64(p.F); + e = ComplexF64(p.E) + f = ComplexF64(p.F) h = ComplexF64(p.H) - g = ComplexF64(p.G); - k = ComplexF64(p.K); + g = ComplexF64(p.G) + k = ComplexF64(p.K) q = Q dr = mercier_dr(p) From 2d63e1b0e4579bd6c0f323cb5ae17183932d9f40 Mon Sep 17 00:00:00 2001 From: Matthew Pharr Date: Thu, 30 Jul 2026 16:40:08 -0400 Subject: [PATCH 32/38] GGJ - CLEANUP - Adjust formatting in _physical_uv function for consistency --- src/InnerLayer/GGJ/Galerkin.jl | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/InnerLayer/GGJ/Galerkin.jl b/src/InnerLayer/GGJ/Galerkin.jl index 6ec87d5e..dc4ec5a8 100644 --- a/src/InnerLayer/GGJ/Galerkin.jl +++ b/src/InnerLayer/GGJ/Galerkin.jl @@ -83,10 +83,11 @@ function _physical_uv(params::GGJParameters, Q::ComplexF64, x::Real) # row 1 (Ψ): (Q, −Q x, 0) # row 2 (Ξ): (−Q x, Q x², −(E+F)) # row 3 (Υ): (−x, −Q²(G−KE), x²+Q²(G+KF)) + #! format: off U = @SMatrix ComplexF64[ - q (-q * x) 0 - (-x / q) * q2 (x2 / q) * q2 (-(e + f) / q2) * q2 - (-x / q) * q (-(g - k * e) * q) * q (x2 / q + (g + k * f) * q) * q + q (-q * x) 0 + (-x / q) * q2 (x2 / q) * q2 (-(e + f) / q2) * q2 + (-x / q) * q (-(g - k * e) * q) * q (x2 / q + (g + k * f) * q) * q ] # V = −B of GWP2016 Eq. (14): coefficients of −u' in each equation (same row scaling as U): @@ -94,10 +95,11 @@ function _physical_uv(params::GGJParameters, Q::ComplexF64, x::Real) # row 2 (Ξ): (−H, 0, 0) ⇒ +H Ψ_x # row 3 (Υ): (K Q² H, 0, 0) ⇒ −K Q² H Ψ_x V = @SMatrix ComplexF64[ - 0 0 h - (-h / q2) * q2 0 0 - (h * k * q) * q 0 0 + 0 0 h + (-h / q2) * q2 0 0 + (h * k * q) * q 0 0 ] + #! format: on return Imat, U, V end From 72aca99d146469bcaae2defa033e0711987a5187 Mon Sep 17 00:00:00 2001 From: Matthew Pharr Date: Thu, 30 Jul 2026 17:08:58 -0400 Subject: [PATCH 33/38] ForceFreeStates - CLEANUP - Adjust formatting in EulerLagrange and Riccati files for consistency --- src/ForceFreeStates/EulerLagrange.jl | 2 ++ src/ForceFreeStates/Riccati.jl | 8 ++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/ForceFreeStates/EulerLagrange.jl b/src/ForceFreeStates/EulerLagrange.jl index daa37337..0115b47c 100644 --- a/src/ForceFreeStates/EulerLagrange.jl +++ b/src/ForceFreeStates/EulerLagrange.jl @@ -310,8 +310,10 @@ function compute_axis_init(ffit::FourFitVars, profiles::Equilibrium.ProfileSplin m22 = sf * kd * fi # Frobenius matrix A₀_j = ψ_low · M_j [Glasser 2016 Eq. 51] + #! format: off F_eig = eigen([psi_low*m11 psi_low*m12; psi_low*m21 psi_low*m22]) + #! format: on eig_vals = F_eig.values eig_vecs = F_eig.vectors diff --git a/src/ForceFreeStates/Riccati.jl b/src/ForceFreeStates/Riccati.jl index d7a65fed..59d6b166 100644 --- a/src/ForceFreeStates/Riccati.jl +++ b/src/ForceFreeStates/Riccati.jl @@ -142,8 +142,10 @@ function assemble_fm_matrix(propagators::Vector{ChunkPropagator}, idx_range; isempty(idx_range) && return Phi for i in idx_range p = propagators[i] + #! format: off Phi_i = [p.block_upper_ic[:,:,1] p.block_lower_ic[:,:,1]; p.block_upper_ic[:,:,2] p.block_lower_ic[:,:,2]] + #! format: on Phi = Phi_i * Phi if condition condition_propagator!(Phi, N) @@ -1633,8 +1635,10 @@ Glasser-Kolemen (2018) Phys. Plasmas 25, 032501 Eq. 33. function apply_propagator_inverse!(odet::OdeState, prop::ChunkPropagator) N = size(odet.u, 1) # Assemble 2N×2N backward FM Φ_bwd - Φ = [prop.block_upper_ic[:,:,1] prop.block_lower_ic[:,:,1]; - prop.block_upper_ic[:,:,2] prop.block_lower_ic[:,:,2]] + #! format: off + Φ = [prop.block_upper_ic[:,:,1] prop.block_lower_ic[:,:,1]; + prop.block_upper_ic[:,:,2] prop.block_lower_ic[:,:,2]] + #! format: on # Φ_bwd maps state at psi_end → psi_start (well-conditioned). # We want Φ_fwd = Φ_bwd⁻¹ to advance state from psi_start → psi_end. # Solving Φ_bwd · x = [U₁_old; U₂_old] gives x = Φ_bwd⁻¹ · [U₁_old; U₂_old]. From a5e3c24fcaeecac682e4ec3241408c7396ece43c Mon Sep 17 00:00:00 2001 From: Matthew Pharr Date: Thu, 30 Jul 2026 17:29:31 -0400 Subject: [PATCH 34/38] PerturbedEquilibrium - CLEANUP - Retire penetrated_area_weighted_field in favor of penetrated_area_weighted_field_inner. fallback when running kinetic to be implemented later --- .../PerturbedEquilibriumStructs.jl | 18 +------- src/PerturbedEquilibrium/SingularCoupling.jl | 46 ++++++++----------- src/PerturbedEquilibrium/Utils.jl | 6 +-- 3 files changed, 23 insertions(+), 47 deletions(-) diff --git a/src/PerturbedEquilibrium/PerturbedEquilibriumStructs.jl b/src/PerturbedEquilibrium/PerturbedEquilibriumStructs.jl index a2d7502d..903a93c5 100644 --- a/src/PerturbedEquilibrium/PerturbedEquilibriumStructs.jl +++ b/src/PerturbedEquilibrium/PerturbedEquilibriumStructs.jl @@ -74,15 +74,8 @@ Internal state variables for perturbed equilibrium calculations. singular_coupling_metrics::Dict{String,Float64} = Dict{String,Float64}() m_modes::Vector{Int} = Int[] n_modes::Vector{Int} = Int[] - # ForceFreeStates-provided B_pen per (match surface × coil-drive column), read off the GGJ inner - # solution at the layer center (GalMatchResult.bpen). Cusp-free: the outer large solution blows - # up at the rational, so the pointwise C_penetrated evaluation is bracket-offset dependent; the - # layer-center Ψ(0) is finite and grid-independent. Columns are the same identity-at-edge - # coil-drive basis as the matched OdeState solutions, so PE's C_coeffs contraction applies. - # DISPATCH: when non-empty, this is the OFFICIAL penetrated field (C_penetrated_area_weighted_field); - # the pointwise surface interpolation is only the fallback. Ideal mode (gal OR shooting) passes - # identically zero — perfect shielding. Empty → pointwise fallback, which after the main.jl wiring - # only happens for KINETIC shooting runs (outstanding). + # ForceFreeStates-provided B_pen per (match surface × coil-drive column) from inner layer. Needs + # fallback for kinetic mode, TODO. Zero if not provided (ideal mode). inner_bpen::Matrix{ComplexF64} = zeros(ComplexF64, 0, 0) # True when the PE OdeState is the gal(-matched) solution: ud_store then carries the analytic ξ′ # (incl. resonant series), enabling the exact-derivative path in SingularCoupling. @@ -171,10 +164,6 @@ well-conditioned flux-space inductances L, Λ: C_island_width_sq::Matrix{ComplexF64} = zeros(ComplexF64, 0, 0) C_penetrated_area_weighted_field::Matrix{ComplexF64} = zeros(ComplexF64, 0, 0) C_delta_prime::Matrix{ComplexF64} = zeros(ComplexF64, 0, 0) - # Inner-layer (cusp-free) penetrated field coupling: layer-center Ψ(0) of the matched GGJ inner - # solution, contracted with the same C_coeffs as the outer rows. Populated only for RPEC-matched - # gal runs (intr.inner_bpen non-empty). - C_penetrated_field_inner::Matrix{ComplexF64} = zeros(ComplexF64, 0, 0) # Applied resonant vectors [n_rational] = C · amp_vec resonant_area_weighted_field::Vector{ComplexF64} = ComplexF64[] @@ -182,9 +171,6 @@ well-conditioned flux-space inductances L, Λ: island_width_sq::Vector{ComplexF64} = ComplexF64[] penetrated_area_weighted_field::Vector{ComplexF64} = ComplexF64[] delta_prime::Vector{ComplexF64} = ComplexF64[] - penetrated_field_inner::Vector{ComplexF64} = ComplexF64[] - # Applied-drive weights over the OdeState solution columns (C_coeffs·forcing_flux) and per-row - # surface area — exposed so inner-layer per-coil profiles can be contracted/normalized offline. forcing_solution_weights::Vector{ComplexF64} = ComplexF64[] rational_area::Vector{Float64} = Float64[] diff --git a/src/PerturbedEquilibrium/SingularCoupling.jl b/src/PerturbedEquilibrium/SingularCoupling.jl index 57524fbe..ca148722 100644 --- a/src/PerturbedEquilibrium/SingularCoupling.jl +++ b/src/PerturbedEquilibrium/SingularCoupling.jl @@ -146,13 +146,14 @@ function compute_singular_coupling_metrics!( state.C_resonant_area_weighted_field = zeros(ComplexF64, n_rational, numpert_total) state.C_resonant_current = zeros(ComplexF64, n_rational, numpert_total) state.C_island_width_sq = zeros(ComplexF64, n_rational, numpert_total) - state.C_penetrated_area_weighted_field = zeros(ComplexF64, n_rational, numpert_total) + have_inner_bpen = !isempty(intr.inner_bpen) + if have_inner_bpen + state.C_penetrated_area_weighted_field = zeros(ComplexF64, n_rational, numpert_total) + else + state.C_penetrated_area_weighted_field = zeros(ComplexF64, 0, 0) + @warn "No inner-layer B_pen supplied; penetrated field not computed." maxlog=1 + end state.C_delta_prime = zeros(ComplexF64, n_rational, numpert_total) - # B_pen dispatch: a ForceFreeStates-provided layer-center penetrated field (identically zero in - # ideal mode) becomes the OFFICIAL C_penetrated_area_weighted_field; the pointwise midpoint - # evaluation below is only the fallback. - use_inner_bpen = !isempty(intr.inner_bpen) - use_inner_bpen && (state.C_penetrated_field_inner = zeros(ComplexF64, n_rational, numpert_total)) state.rational_psi = zeros(Float64, n_rational) state.rational_q = zeros(Float64, n_rational) state.rational_m_res = zeros(Int, n_rational) @@ -240,9 +241,8 @@ function compute_singular_coupling_metrics!( u_l = _hermite_cubic_val(ua_l, ub_l, dua_l, dub_l, psi_il_l, psi_ir_l, lpsi) u_r = _hermite_cubic_val(ua_r, ub_r, dua_r, dub_r, psi_il_r, psi_ir_r, rpsi) # Derivative (ud): two paths. - # - gal-matched OdeState (intr.odet_from_gal): ud_store is the ANALYTIC ξ′ from the gal basis, - # including the resonant Frobenius series — use the Hermite-cubic derivative built from - # (u, ud), which is exact where the representation is. + # - gal-matched OdeState (intr.odet_from_gal): ud_store is the analytic ξ′ from the gal basis, + # use the analytic Hermite-cubic derivative built from (u, ud). # - shooting OdeState: chord slope from u_store only — ud_store can be systematically off near # outer surfaces (q=4/5) where the ODE solution varies rapidly; near-cancellation in bwp1 # then amplifies even a ~10% ud_store error into a large Delta' error. Chord slope avoids @@ -272,29 +272,23 @@ function compute_singular_coupling_metrics!( bwp1_l = 2π * im * chi1 * (singfac_l * xsp1_l - nn * q1_l * xsp_l) bwp1_r = 2π * im * chi1 * (singfac_r * xsp1_r - nn * q1_r * xsp_r) jump_vec[k] = bwp1_r - bwp1_l - # C_penetrated_area_weighted_field: midpoint of b^ψ at lpsi/rpsi divided by the scalar surface area. - # Matches Fortran gpout_resp: gpeq_interp_singsurf evaluates bwp_mn at respsi. - # LHS normalization audit (#233): the resonant flux Φ^r divided by the scalar area A^r - # is a genuine field amplitude in tesla and is coordinate-invariant [Pharr 2026; cf. - # the resonant-field definition in the Conventions Reference]. - b_l = chi1 * singfac_l * 2π * im * xsp_l - b_r = chi1 * singfac_r * 2π * im * xsp_r - state.C_penetrated_area_weighted_field[row, k] = (b_l + b_r) / 2 / area + # Kinetic fallback for penetrated field: + # if kinetic (where to get this variable???) + # b_l = chi1 * singfac_l * 2π * im * xsp_l + # b_r = chi1 * singfac_r * 2π * im * xsp_r + # state.C_penetrated_area_weighted_field[row, k] = (b_l + b_r) / 2 / area end # Inner-layer (cusp-free) penetrated field: bpen[s, j] is linear in the same identity-at-edge # coil-drive columns as the OdeState solutions, so it contracts with C_coeffs exactly like # the outer solution values above (xsp = dot(u, ck)); /area matches the area-weighted - # convention of the pointwise row. No bracket points involved — the value is the matched GGJ - # inner solution's Ψ(0), finite at the rational where the outer large solution blows up. - # When present it IS the official penetrated field (the midpoint value above is overwritten); - # ideal mode passes zeros ⇒ penetrated field exactly 0 (perfect shielding). - if use_inner_bpen && s <= size(intr.inner_bpen, 1) + # convention of the pointwise row. + if have_inner_bpen && s <= size(intr.inner_bpen, 1) pen_row = (transpose(C_coeffs) * @view(intr.inner_bpen[s, :])) ./ area - state.C_penetrated_field_inner[row, :] = pen_row state.C_penetrated_area_weighted_field[row, :] = pen_row end + # LHS normalization audit (#233) — output scalar coordinate-invariance per row: # - Δ' (1/length): the resonant-surface jump in ∂b^ψ/∂ψ over 2π·χ₁; the tearing index is # coordinate-invariant (its sign/zero-crossing set the stability boundary) [Glasser 2016]. @@ -336,9 +330,8 @@ function compute_singular_coupling_metrics!( state.resonant_area_weighted_field = state.C_resonant_area_weighted_field * forcing_flux state.resonant_current = state.C_resonant_current * forcing_flux state.island_width_sq = state.C_island_width_sq * forcing_flux - state.penetrated_area_weighted_field = state.C_penetrated_area_weighted_field * forcing_flux state.delta_prime = state.C_delta_prime * forcing_flux - use_inner_bpen && (state.penetrated_field_inner = state.C_penetrated_field_inner * forcing_flux) + have_inner_bpen && (state.penetrated_area_weighted_field = state.C_penetrated_area_weighted_field * forcing_flux) state.forcing_solution_weights = C_coeffs * forcing_flux # Conform the stored coupling-matrix input basis to the coordinate-invariant root-area-weighted @@ -352,9 +345,8 @@ function compute_singular_coupling_metrics!( state.C_resonant_area_weighted_field = state.C_resonant_area_weighted_field * flux_conform state.C_resonant_current = state.C_resonant_current * flux_conform state.C_island_width_sq = state.C_island_width_sq * flux_conform - state.C_penetrated_area_weighted_field = state.C_penetrated_area_weighted_field * flux_conform state.C_delta_prime = state.C_delta_prime * flux_conform - use_inner_bpen && (state.C_penetrated_field_inner = state.C_penetrated_field_inner * flux_conform) + have_inner_bpen && (state.C_penetrated_area_weighted_field = state.C_penetrated_area_weighted_field * flux_conform) # Phase 5: Island diagnostics from applied resonant vectors compute_island_diagnostics!(state, n_rational) diff --git a/src/PerturbedEquilibrium/Utils.jl b/src/PerturbedEquilibrium/Utils.jl index 1a239185..a7f06db8 100644 --- a/src/PerturbedEquilibrium/Utils.jl +++ b/src/PerturbedEquilibrium/Utils.jl @@ -200,16 +200,14 @@ function write_outputs_to_HDF5( !isempty(state.C_island_width_sq) && (coupling_group["C_island_width_sq"] = state.C_island_width_sq) !isempty(state.C_penetrated_area_weighted_field) && (coupling_group["C_penetrated_area_weighted_field"] = state.C_penetrated_area_weighted_field) !isempty(state.C_delta_prime) && (coupling_group["C_delta_prime"] = state.C_delta_prime) - !isempty(state.C_penetrated_field_inner) && (coupling_group["C_penetrated_field_inner"] = state.C_penetrated_field_inner) # Applied resonant vectors [n_rational] !isempty(state.resonant_area_weighted_field) && (coupling_group["resonant_area_weighted_field"] = state.resonant_area_weighted_field) !isempty(state.resonant_current) && (coupling_group["resonant_current"] = state.resonant_current) !isempty(state.island_width_sq) && (coupling_group["island_width_sq"] = state.island_width_sq) - !isempty(state.penetrated_area_weighted_field) && (coupling_group["penetrated_area_weighted_field"] = state.penetrated_area_weighted_field) + !isempty(state.penetrated_area_weighted_field) && (coupling_group["penetrated_area_weighted_field"] = state.penetrated_area_weighted_field) !isempty(state.delta_prime) && (coupling_group["delta_prime"] = state.delta_prime) - !isempty(state.penetrated_field_inner) && (coupling_group["penetrated_field_inner"] = state.penetrated_field_inner) - !isempty(state.forcing_solution_weights) && (coupling_group["forcing_solution_weights"] = state.forcing_solution_weights) + !isempty(state.forcing_solution_weights) && (coupling_group["forcing_solution_weights"] = state.forcing_solution_weights) !isempty(state.rational_area) && (coupling_group["rational_area"] = state.rational_area) !isempty(state.island_half_width) && (coupling_group["island_half_width"] = state.island_half_width) !isempty(state.chirikov_parameter) && (coupling_group["chirikov_parameter"] = state.chirikov_parameter) From aeea7d8461e89b4734062ededa416e56aa2f3087 Mon Sep 17 00:00:00 2001 From: Matthew Pharr Date: Thu, 30 Jul 2026 17:48:48 -0400 Subject: [PATCH 35/38] MULTIPLE - CLEANUP - Remove unused includes and shortened comments in ForceFreeStates and PerturbedEquilibrium structs for clarity --- src/ForceFreeStates/ForceFreeStates.jl | 1 - src/ForceFreeStates/ForceFreeStatesStructs.jl | 17 +--------- src/GeneralizedPerturbedEquilibrium.jl | 34 +++---------------- .../PerturbedEquilibriumStructs.jl | 5 +-- 4 files changed, 7 insertions(+), 50 deletions(-) diff --git a/src/ForceFreeStates/ForceFreeStates.jl b/src/ForceFreeStates/ForceFreeStates.jl index 334eaa10..5705c1a4 100644 --- a/src/ForceFreeStates/ForceFreeStates.jl +++ b/src/ForceFreeStates/ForceFreeStates.jl @@ -36,7 +36,6 @@ include("Utils.jl") include("RootAreaWeighted.jl") include("Free.jl") include("Riccati.jl") -include("ResistiveMatch.jl") # RDCON outer-region singular Galerkin Δ′ solver (gal_solve port) include("Galerkin/GalerkinStructs.jl") diff --git a/src/ForceFreeStates/ForceFreeStatesStructs.jl b/src/ForceFreeStates/ForceFreeStatesStructs.jl index 1e43efb9..21352fb3 100644 --- a/src/ForceFreeStates/ForceFreeStatesStructs.jl +++ b/src/ForceFreeStates/ForceFreeStatesStructs.jl @@ -194,20 +194,6 @@ A mutable struct holding internal state variables for stability calculations. raw 2msing×2msing BVP solution to produce the PEST3-compatible tearing parameter. """ delta_prime_matrix::Matrix{ComplexF64} = Matrix{ComplexF64}(undef, 0, 0) - """ - Edge coil-response matrix of shape (2msing × numpert_total). Column k is the resonant - small-solution response at each surface side to driving edge poloidal mode k, built by - looping the Eq. (37) edge boundary condition through the Riccati BVP (`loop_edge_boundary_conditions`). - """ - delta_coil_matrix::Matrix{ComplexF64} = Matrix{ComplexF64}(undef, 0, 0) - # Resistive inner-layer-matched coil response (Wang 2020 Eq. 11): C = -(Δ_out - Δ_in)^{-1} Δ_coil. - resonant_match_cout::Matrix{ComplexF64} = Matrix{ComplexF64}(undef, 0, 0) # outer coeffs (2msing × ncoil) - resonant_match_cin::Matrix{ComplexF64} = Matrix{ComplexF64}(undef, 0, 0) # inner coeffs (2msing × ncoil) - resonant_match_deltar::Matrix{ComplexF64} = Matrix{ComplexF64}(undef, 0, 0) # per-surface inner-layer Δ (msing × 2) - resonant_match_rpec_eig::Vector{ComplexF64} = ComplexF64[] # forced eigenvalue γ_s = 2πi·n·f - resonant_match_flux::Matrix{ComplexF64} = Matrix{ComplexF64}(undef, 0, 0) # reconnected resonant flux (2msing × ncoil) - resonant_match_bpen::Matrix{ComplexF64} = Matrix{ComplexF64}(undef, 0, 0) # area-weighted penetrated b-field (msing × ncoil), like-for-like with galerkin/match/bpen - resonant_match_residual::Float64 = NaN end """ @@ -323,8 +309,7 @@ A mutable struct containing control parameters for stability analysis, set by th gal_match_flag::Bool = false # enable the RPEC inner-layer matching: solve the coil-driven matched ξ(ψ) from the gal Δ′ + the inner-layer Δ(Q). Requires gal_rpec_flag=true. gal_ideal_flag::Bool = false # within the match, build the IDEAL solution: skip the inner-layer Δ, use bare coil columns (cout=0). Mirrors Fortran rmatch coil%ideal_flag (the EL reference). eta/rho/rotation ignored. gal_inner_solver::String = "ray" # inner-layer Δ backend for the match: "ray" (rotated-contour collocation, certified Δ at the optimal θ = arg(Q)/4; robust for |Q| ≳ 1) or "galerkin" (Hermite-cubic inps; drifts for |Q| ≳ 1) - # Inner-layer "galerkin" backend knobs (used only when gal_inner_solver = "galerkin"; the "ray" - # backend is self-tuning). Defaults match the Fortran rmatch deltac/inps reference (DELTAC_LIST). + # --- Inner-layer "galerkin" backend knobs (used only when gal_inner_solver = "galerkin") --- gal_inner_xfac::Float64 = 10.0 # asymptotic-matching radius multiplier (inps_xfac: xmax × 10) gal_inner_nx::Int = 1280 # inner-layer grid cells (128 · xfac in the reference) gal_inner_nq::Int = 5 # quadrature order per cell diff --git a/src/GeneralizedPerturbedEquilibrium.jl b/src/GeneralizedPerturbedEquilibrium.jl index 0d3ebaef..2a7c9ae6 100755 --- a/src/GeneralizedPerturbedEquilibrium.jl +++ b/src/GeneralizedPerturbedEquilibrium.jl @@ -167,6 +167,7 @@ function main_from_inputs( @info "\n Equilibrium\n$_SECTION" equil_start = time() + # Build data structures from inputs intr = ForceFreeStatesInternal(; dir_path=path) ffs_table = inputs["ForceFreeStates"] @@ -517,14 +518,8 @@ function main_from_inputs( @info "PerturbedEquilibrium: using the RPEC-matched gal solution" pe_odet = gal_matched_odestate(gal_data, ffit, intr) pe_intr.odet_from_gal = true - # Inner-layer penetrated field per coil-drive column, same basis as the matched OdeState - # columns; SingularCoupling uses it as the official penetrated field (pointwise midpoint - # is only the fallback). Ideal mode: match.bpen is identically zero ⇒ B_pen ≡ 0. pe_intr.inner_bpen = gal_data.match.bpen - elseif ctrl.kinetic_factor == 0 - # Ideal shooting run: perfect shielding — the penetrated field is exactly zero, same - # dispatch as the gal-ideal path. Only KINETIC shooting remains on the pointwise - # surface-interpolation fallback (outstanding). + else pe_intr.inner_bpen = zeros(ComplexF64, intr.msing, intr.numpert_total) end @@ -786,28 +781,9 @@ function write_outputs_to_HDF5( # Edge coil-response matrix, stored (numpert_total × 2msing) = (edge mode, surface-side) to match # the galerkin/delta_coil layout so H5Web heatmaps share axes (x = edge mode, y = surface-side). # Internal intr.delta_coil_matrix stays (2msing × numpert_total); transpose only at write. - if intr.msing > 0 && !isempty(intr.delta_coil_matrix) - dc = permutedims(intr.delta_coil_matrix) - out_h5["singular/delta_coil_matrix"] = dc - out_h5["singular/delta_coil_abs"] = abs.(dc) # real |.| for H5Web heatmap view - end - - # Resistive inner-layer-matched coil response (Wang et al. PoP 27, 122509 (2020), Eq. 11: - # C = -(Δ_out - Δ_in(i2πf))^{-1} Δ_coil). Computed in ResistiveMatch.jl via the STRIDE/Riccati - # outer Δ' + the GGJ inner layer (resist_eval → InnerLayer.solve_inner). - if intr.msing > 0 && !isempty(intr.resonant_match_deltar) - g = create_group(out_h5, "singular/resonant_match") - g["deltar"] = intr.resonant_match_deltar # (msing × 2) per-surface inner-layer Δ(Q) - g["cout"] = intr.resonant_match_cout # (2msing × ncoil) matched outer coefficients - g["cin"] = intr.resonant_match_cin # (2msing × ncoil) matched inner coefficients - g["rpec_eig"] = intr.resonant_match_rpec_eig # (msing) forced eigenvalue γ_s = 2πi·n·f - g["reconnected_flux"] = intr.resonant_match_flux # (2msing × ncoil) matched small-solution (reconnected) amplitude - g["reconnected_flux_abs"] = abs.(intr.resonant_match_flux) # |.| for H5Web heatmap view - if !isempty(intr.resonant_match_bpen) - g["bpen"] = intr.resonant_match_bpen # (msing × ncoil) area-weighted penetrated b-field (like galerkin/match/bpen) - g["bpen_abs"] = abs.(intr.resonant_match_bpen) - end - g["residual"] = intr.resonant_match_residual + if intr.msing > 0 && !isempty(intr.delta_coil) + dc = permutedims(intr.delta_coil) + out_h5["singular/delta_coil"] = dc end # Write kinetic singular surface data (det(F̄) near-zeros) and the cond(F̄) scan diff --git a/src/PerturbedEquilibrium/PerturbedEquilibriumStructs.jl b/src/PerturbedEquilibrium/PerturbedEquilibriumStructs.jl index 903a93c5..3a114270 100644 --- a/src/PerturbedEquilibrium/PerturbedEquilibriumStructs.jl +++ b/src/PerturbedEquilibrium/PerturbedEquilibriumStructs.jl @@ -74,11 +74,8 @@ Internal state variables for perturbed equilibrium calculations. singular_coupling_metrics::Dict{String,Float64} = Dict{String,Float64}() m_modes::Vector{Int} = Int[] n_modes::Vector{Int} = Int[] - # ForceFreeStates-provided B_pen per (match surface × coil-drive column) from inner layer. Needs - # fallback for kinetic mode, TODO. Zero if not provided (ideal mode). + # ForceFreeStates-provided B_pen per (match surface × coil-drive column) from inner layer. inner_bpen::Matrix{ComplexF64} = zeros(ComplexF64, 0, 0) - # True when the PE OdeState is the gal(-matched) solution: ud_store then carries the analytic ξ′ - # (incl. resonant series), enabling the exact-derivative path in SingularCoupling. odet_from_gal::Bool = false end From 81d8164699b9e6b4887f47b17c9ef75f9ea0eed5 Mon Sep 17 00:00:00 2001 From: Matthew Pharr Date: Thu, 30 Jul 2026 18:50:12 -0400 Subject: [PATCH 36/38] ForceFreeStates - CLEANUP - cleaned up riccati delta_coil , formatting and examples --- examples/LAR_ideal_match_test/gpec.toml | 82 ++++++++++ examples/LAR_resistive_match_test/gpec.toml | 153 +++++++++--------- .../LAR_resistive_match_test_ideal/gpec.toml | 94 ----------- src/ForceFreeStates/ForceFreeStatesStructs.jl | 7 + src/ForceFreeStates/Riccati.jl | 152 +++-------------- src/GeneralizedPerturbedEquilibrium.jl | 3 +- src/InnerLayer/GGJ/Ray.jl | 10 +- 7 files changed, 194 insertions(+), 307 deletions(-) create mode 100644 examples/LAR_ideal_match_test/gpec.toml delete mode 100644 examples/LAR_resistive_match_test_ideal/gpec.toml diff --git a/examples/LAR_ideal_match_test/gpec.toml b/examples/LAR_ideal_match_test/gpec.toml new file mode 100644 index 00000000..33a71012 --- /dev/null +++ b/examples/LAR_ideal_match_test/gpec.toml @@ -0,0 +1,82 @@ +# LAR ideal-limit reference: large-aspect-ratio analytic TJ equilibrium, epsilon = a/R0 = 0.2, n = 1. +# +# The ideal-limit companion to LAR_resistive_match_test, identical except that gal_ideal_flag skips +# the resistive inner layer entirely and builds the bare (perfectly shielded) coil columns. Running +# the two side by side isolates what the inner layer contributes on this geometry. The per-surface +# resistivity, density and rotation inputs are omitted here because the ideal limit never reads them. +# This run terminates after the stability stage. + +[Equilibrium] +eq_type = "tj_analytic" # Type of the input 2D equilibrium (analytic large-aspect-ratio model) +jac_type = "hamada" # Coordinate system (hamada, pest, boozer, equal_arc, park, custom) +grid_type = "ldp" # Radial grid packing type (ldp = linear-derivative packing toward rationals) +psilow = 0.01 # Lower limit of normalized flux coordinate +psihigh = 0.995 # Upper limit of normalized flux coordinate +mpsi = 128 # Number of radial grid points (0 = auto-compute from psi_accuracy) +mtheta = 512 # Number of poloidal grid points + +[TJ_ANALYTIC_INPUT] +lar_r0 = 2.0 # Major radius R0 [m] +lar_a = 0.4 # Minor radius a [m] (epsilon = a/R0 = 0.2) +qc = 1.5 # On-axis safety factor +qa = 3.6 # Edge safety factor +pc = 0.01 # Normalized on-axis pressure +mu = 2.0 # Pressure peaking exponent: p2 = pc*(1-r^2)^mu +B0 = 12.0 # On-axis toroidal field [T] +ma = 128 # Radial grid points +mtau = 128 # Poloidal grid points + +[Wall] +shape = "conformal" # Wall shape (nowall, conformal, elliptical, dee, mod_dee, filepath) +a = 20 # Distance from plasma (conformal) or shape parameter + +[ForceFreeStates] +HDF5_filename = "gpec_lar_ideal.h5" # Output filename for the stability-stage results +local_stability_flag = true # Perform local stability analysis (Mercier and ballooning) across the psi profile +mat_flag = true # Construct coefficient matrices for diagnostic purposes (required by gal_flag) +ode_flag = true # Integrate ODE's for determining stability of internal long-wavelength mode +vac_flag = true # Compute plasma, vacuum, and total energies for free-boundary modes + +qlow = 1.02 # Integration initiated at q determined by min(q0, qlow) +qhigh = 3.6 # Integration terminated at q limit determined by min(qa, qhigh) +sing_start = 0 # Start integration at the sing_start'th rational from the axis (psilow) +nn_low = 1 # Smallest toroidal mode number to include +nn_high = 1 # Largest toroidal mode number to include +delta_mlow = 8 # Expands lower bound of Fourier harmonics +delta_mhigh = 8 # Expands upper bound of Fourier harmonics +mthvac = 960 # Number of points used in splines over poloidal angle at plasma-vacuum interface + +eulerlagrange_tolerance = 1e-12 # Relative tolerance for ODE integration of Euler-Lagrange equations +singfac_min = 1e-4 # Fractional distance from rational q at which ideal jump enforced +ucrit = 1e4 # Maximum fraction of solutions allowed before re-normalized +sing_order = 6 # Power-series order for the singular-surface asymptotics +save_interval = 3 # Save every Nth ODE step (1=all, 10=every 10th). Always saves near rational surfaces. + +use_parallel = true # Run parallel FM-propagator BVP path (unlocks singular/delta_prime_matrix) +parallel_threads = 2 # BVP thread cap (1 = serial/bit-deterministic; 2 is about +20% speedup) +populate_dense_xi = false # Dense axis-basis xi for the FFS HDF5 output. Not needed here: the gal-matched path builds its own dense xi. +set_psilim_via_dmlim = false # Keep psilim at psihigh (do not truncate at last_rational_q + dmlim) +dmlim = 0.2 # Truncate integration at (last_rational_q + dmlim) / n (only used when set_psilim_via_dmlim = true) +force_termination = true # Terminate after the stability stage (no perturbed-equilibrium section here) +write_outputs_to_HDF5 = true # Write stability outputs to HDF5 + +# Outer-region singular Galerkin Delta-prime solver with rpec coil columns. +gal_flag = true # Enable the outer-region singular Galerkin Delta-prime solve (requires mat_flag = true) +gal_solver = "LU" # Banded solver: "LU" (zgbtrf/zgbtrs) or "cholesky" (zpbtrf/zpbtrs). rpec requires "LU". +gal_nx = 256 # Hermite-cubic elements per interval between singular surfaces +gal_nq = 6 # Gauss-Lobatto quadrature order per element +gal_pfac = 0.03 # Grid packing ratio toward singular surfaces (smaller = tighter packing) +gal_dx0 = 5e-4 # Resonant-element integration truncation distance from the rational (times 1/|n q'|) +gal_dx1 = 1e-3 # Resonant-element size (times 1/|n q'|) +gal_dx2 = 1e-3 # Extension-element size (times 1/|n q'|) +gal_cutoff = 10 # Number of elements carrying the large (driving) solution as a source term +gal_tol = 1e-10 # Resonant-cell adaptive-quadrature (QuadGK) tolerance +gal_gnstep = 5000 # Maximum resonant-integration steps +gal_dx1dx2_flag = true # Enable the special dx1/dx2 element sizing for resonant/extension cells +gal_sing_order = 6 # Base power-series order for the Galerkin singular asymptotics +gal_sing_order_ceiling = true # Auto-raise the series order by ceil(2*Re(alpha)) on high-Mercier-index surfaces +gal_rpec_flag = true # Append mpert coil-response columns (unit edge sources) to the solve, giving the delta_coil block + +# DRIVEN (RPEC) outer-inner matching in the ideal limit: the inner layer is skipped entirely. +gal_match_flag = true # Solve the coil-driven matched xi(psi) from the gal Delta-prime. Requires gal_rpec_flag = true. +gal_ideal_flag = true # true = build the IDEAL solution (skip the inner layer, bare coil columns) diff --git a/examples/LAR_resistive_match_test/gpec.toml b/examples/LAR_resistive_match_test/gpec.toml index 321c4c11..625918ae 100644 --- a/examples/LAR_resistive_match_test/gpec.toml +++ b/examples/LAR_resistive_match_test/gpec.toml @@ -1,92 +1,89 @@ -# LAR (TJ-analytic) resistive-match test case for the STRIDE/Riccati path. +# LAR resistive-match test: large-aspect-ratio analytic TJ equilibrium, epsilon = a/R0 = 0.2, n = 1. # -# A genuinely different equilibrium from the DIII-D-like validation: large-aspect-ratio, -# analytic TJ model (Fitzpatrick), ε = a/R₀ = 0.2, n = 1. Purpose: exercise resonant_match_rpec -# on a new equilibrium and check it is internally self-consistent (residual ~1e-16, ideal-limit -# cout = 0, self-convergence in gal_sing_order). NOTE: the current branch has no galerkin match -# path, so a galerkin head-to-head requires the STRIDE-deltacoil branch (commit this work first). -# -# For n = 1 with q ∈ [1.5, 3.6] the rational surfaces are q = 2 and q = 3 → msing = 2, so the -# per-surface resistive arrays below have length 2 (core→edge). +# A different equilibrium from the DIII-D-like validation case, used to check that the +# outer-region Galerkin Delta-prime solve and the driven (RPEC) outer-inner match stay self-consistent +# on a second geometry: matching residual near machine precision and convergence under gal_sing_order +# refinement. For n = 1 with q in [1.5, 3.6] the rational surfaces are q = 2 and q = 3, so msing = 2 +# and the per-surface resistive arrays below have length 2 (core to edge). This run terminates after +# the stability stage. The companion LAR_ideal_match_test case is the ideal-limit reference. [Equilibrium] -eq_type = "tj_analytic" -jac_type = "hamada" -grid_type = "ldp" -psilow = 0.01 -psihigh = 0.995 -mpsi = 128 -mtheta = 512 +eq_type = "tj_analytic" # Type of the input 2D equilibrium (analytic large-aspect-ratio model) +jac_type = "hamada" # Coordinate system (hamada, pest, boozer, equal_arc, park, custom) +grid_type = "ldp" # Radial grid packing type (ldp = linear-derivative packing toward rationals) +psilow = 0.01 # Lower limit of normalized flux coordinate +psihigh = 0.995 # Upper limit of normalized flux coordinate +mpsi = 128 # Number of radial grid points (0 = auto-compute from psi_accuracy) +mtheta = 512 # Number of poloidal grid points [TJ_ANALYTIC_INPUT] -lar_r0 = 2.0 -lar_a = 0.4 -qc = 1.5 -qa = 3.6 -pc = 0.01 -mu = 2.0 -B0 = 12.0 -ma = 128 -mtau = 128 +lar_r0 = 2.0 # Major radius R0 [m] +lar_a = 0.4 # Minor radius a [m] (epsilon = a/R0 = 0.2) +qc = 1.5 # On-axis safety factor +qa = 3.6 # Edge safety factor +pc = 0.01 # Normalized on-axis pressure +mu = 2.0 # Pressure peaking exponent: p2 = pc*(1-r^2)^mu +B0 = 12.0 # On-axis toroidal field [T] +ma = 128 # Radial grid points +mtau = 128 # Poloidal grid points [Wall] -shape = "conformal" -a = 20 +shape = "conformal" # Wall shape (nowall, conformal, elliptical, dee, mod_dee, filepath) +a = 20 # Distance from plasma (conformal) or shape parameter [ForceFreeStates] -HDF5_filename = "gpec_stride_lar.h5" -local_stability_flag = true -mat_flag = true -ode_flag = true -vac_flag = true +HDF5_filename = "gpec_lar_resistive.h5" # Output filename for the stability-stage results +local_stability_flag = true # Perform local stability analysis (Mercier and ballooning) across the psi profile +mat_flag = true # Construct coefficient matrices for diagnostic purposes (required by gal_flag) +ode_flag = true # Integrate ODE's for determining stability of internal long-wavelength mode +vac_flag = true # Compute plasma, vacuum, and total energies for free-boundary modes -qlow = 1.02 -qhigh = 3.6 -sing_start = 0 -nn_low = 1 -nn_high = 1 -delta_mlow = 8 -delta_mhigh = 8 -mthvac = 960 +qlow = 1.02 # Integration initiated at q determined by min(q0, qlow) +qhigh = 3.6 # Integration terminated at q limit determined by min(qa, qhigh) +sing_start = 0 # Start integration at the sing_start'th rational from the axis (psilow) +nn_low = 1 # Smallest toroidal mode number to include +nn_high = 1 # Largest toroidal mode number to include +delta_mlow = 8 # Expands lower bound of Fourier harmonics +delta_mhigh = 8 # Expands upper bound of Fourier harmonics +mthvac = 960 # Number of points used in splines over poloidal angle at plasma-vacuum interface -eulerlagrange_tolerance = 1e-12 -singfac_min = 1e-4 -ucrit = 1e4 -sing_order = 6 -save_interval = 3 +eulerlagrange_tolerance = 1e-12 # Relative tolerance for ODE integration of Euler-Lagrange equations +singfac_min = 1e-4 # Fractional distance from rational q at which ideal jump enforced +ucrit = 1e4 # Maximum fraction of solutions allowed before re-normalized +sing_order = 6 # Power-series order for the singular-surface asymptotics +save_interval = 3 # Save every Nth ODE step (1=all, 10=every 10th). Always saves near rational surfaces. -use_parallel = true -parallel_threads = 2 -populate_dense_xi = false -truncate_at_dW_peak = false -set_psilim_via_dmlim = false -dmlim = 0.2 -force_termination = true -write_outputs_to_HDF5 = true +use_parallel = true # Run parallel FM-propagator BVP path (unlocks singular/delta_prime_matrix) +parallel_threads = 2 # BVP thread cap (1 = serial/bit-deterministic; 2 is about +20% speedup) +populate_dense_xi = false # Dense axis-basis xi for the FFS HDF5 output. Not needed here: the gal-matched path builds its own dense xi. +set_psilim_via_dmlim = false # Keep psilim at psihigh (do not truncate at last_rational_q + dmlim) +dmlim = 0.2 # Truncate integration at (last_rational_q + dmlim) / n (only used when set_psilim_via_dmlim = true) +force_termination = true # Terminate after the stability stage (no perturbed-equilibrium section here) +write_outputs_to_HDF5 = true # Write stability outputs to HDF5 -# Outer-region singular Galerkin Δ′ solve + rpec unit-edge columns (delta_coil), then the -# STRIDE outer↔inner resistive match (Wang 2020 Eq. 11). -gal_flag = true -gal_solver = "LU" -gal_nx = 256 -gal_nq = 6 -gal_pfac = 0.03 -gal_dx0 = 5e-4 -gal_dx1 = 1e-3 -gal_dx2 = 1e-3 -gal_cutoff = 10 -gal_tol = 1e-10 -gal_gnstep = 5000 -gal_dx1dx2_flag = true -gal_sing_order = 6 -gal_sing_order_ceiling = true -gal_rpec_flag = true +# Outer-region singular Galerkin Delta-prime solver with rpec coil columns. +gal_flag = true # Enable the outer-region singular Galerkin Delta-prime solve (requires mat_flag = true) +gal_solver = "LU" # Banded solver: "LU" (zgbtrf/zgbtrs) or "cholesky" (zpbtrf/zpbtrs). rpec requires "LU". +gal_nx = 256 # Hermite-cubic elements per interval between singular surfaces +gal_nq = 6 # Gauss-Lobatto quadrature order per element +gal_pfac = 0.03 # Grid packing ratio toward singular surfaces (smaller = tighter packing) +gal_dx0 = 5e-4 # Resonant-element integration truncation distance from the rational (times 1/|n q'|) +gal_dx1 = 1e-3 # Resonant-element size (times 1/|n q'|) +gal_dx2 = 1e-3 # Extension-element size (times 1/|n q'|) +gal_cutoff = 10 # Number of elements carrying the large (driving) solution as a source term +gal_tol = 1e-10 # Resonant-cell adaptive-quadrature (QuadGK) tolerance +gal_gnstep = 5000 # Maximum resonant-integration steps +gal_dx1dx2_flag = true # Enable the special dx1/dx2 element sizing for resonant/extension cells +gal_sing_order = 6 # Base power-series order for the Galerkin singular asymptotics +gal_sing_order_ceiling = true # Auto-raise the series order by ceil(2*Re(alpha)) on high-Mercier-index surfaces +gal_rpec_flag = true # Append mpert coil-response columns (unit edge sources) to the solve, giving the delta_coil block -# DRIVEN (RPEC) resistive match. 2 rational surfaces (q=2,3). Same nominal layer inputs as the -# DIII-D case: η=8e-8, ρ=3.3e-7 kg/m³, rotation=1 Hz, Γ=5/3. -gal_match_flag = true -gal_ideal_flag = false -gal_eta = [8e-8, 8e-8] -gal_rho = [3.3e-7, 3.3e-7] -gal_rotation = [1.0, 1.0] -gal_gamma = 1.6666666666666667 +# DRIVEN (RPEC) outer-inner matching. Per-surface resistive inputs (core to edge) for the two +# rational surfaces q = 2 and q = 3: eta=8e-8, rho=3.3e-7 kg/m^3, rotation=1 Hz, Gamma=5/3. +gal_match_flag = true # Solve the coil-driven matched xi(psi) from the gal Delta-prime + the inner-layer Delta(Q). Requires gal_rpec_flag = true. +gal_ideal_flag = false # true = build the IDEAL solution (skip the inner layer, bare coil columns). false = the resistive matching below. +gal_inner_solver = "ray" # Inner-layer Delta backend: "ray" (rotated-contour collocation, robust at large |Q|) or "galerkin" +gal_eta = [8e-8, 8e-8] # Per-surface resistivity eta (core to edge) +gal_rho = [3.3e-7, 3.3e-7] # Per-surface mass density rho [kg/m^3] (core to edge) +gal_rotation = [1.0, 1.0] # Per-surface rotation frequency f [Hz] (core to edge); forced eigenvalue gamma_s = 2*pi*i*n*f +gal_gamma = 1.6666666666666667 # Ratio of specific heats Gamma for the resistive-layer coefficients (5/3) diff --git a/examples/LAR_resistive_match_test_ideal/gpec.toml b/examples/LAR_resistive_match_test_ideal/gpec.toml deleted file mode 100644 index e52aa77f..00000000 --- a/examples/LAR_resistive_match_test_ideal/gpec.toml +++ /dev/null @@ -1,94 +0,0 @@ -# LAR (TJ-analytic) resistive-match test case for the STRIDE/Riccati path. -# -# A genuinely different equilibrium from the DIII-D-like validation: large-aspect-ratio, -# analytic TJ model (Fitzpatrick), ε = a/R₀ = 0.2, n = 1. Purpose: exercise resonant_match_rpec -# on a new equilibrium and check it is internally self-consistent (residual ~1e-16, ideal-limit -# cout = 0, self-convergence in gal_sing_order). NOTE: the current branch has no galerkin match -# path, so a galerkin head-to-head requires the STRIDE-deltacoil branch (commit this work first). -# -# For n = 1 with q ∈ [1.5, 3.6] the rational surfaces are q = 2 and q = 3 → msing = 2, so the -# per-surface resistive arrays below have length 2 (core→edge). - -[Equilibrium] -eq_type = "tj_analytic" -jac_type = "hamada" -grid_type = "ldp" -psilow = 0.01 -psihigh = 0.995 -mpsi = 128 -mtheta = 512 - -[TJ_ANALYTIC_INPUT] -lar_r0 = 2.0 -lar_a = 0.4 -qc = 1.5 -qa = 3.6 -pc = 0.01 -mu = 2.0 -B0 = 12.0 -ma = 128 -mtau = 128 - -[Wall] -shape = "conformal" -a = 20 - -[ForceFreeStates] -HDF5_filename = "gpec_ideal_lar.h5" -local_stability_flag = true -mat_flag = true -ode_flag = true -vac_flag = true - -qlow = 1.02 -qhigh = 3.6 -sing_start = 0 -nn_low = 1 -nn_high = 1 -delta_mlow = 8 -delta_mhigh = 8 -delta_mband = 0 -mthvac = 960 -thmax0 = 1 - -eulerlagrange_tolerance = 1e-12 -singfac_min = 1e-4 -ucrit = 1e4 -sing_order = 6 -save_interval = 3 - -use_parallel = true -parallel_threads = 2 -populate_dense_xi = false -truncate_at_dW_peak = false -set_psilim_via_dmlim = false -dmlim = 0.2 -force_termination = true -write_outputs_to_HDF5 = true - -# Outer-region singular Galerkin Δ′ solve + rpec unit-edge columns (delta_coil), then the -# STRIDE outer↔inner resistive match (Wang 2020 Eq. 11). -gal_flag = true -gal_solver = "LU" -gal_nx = 256 -gal_nq = 6 -gal_pfac = 0.03 -gal_dx0 = 5e-4 -gal_dx1 = 1e-3 -gal_dx2 = 1e-3 -gal_cutoff = 10 -gal_tol = 1e-10 -gal_gnstep = 5000 -gal_dx1dx2_flag = true -gal_sing_order = 6 -gal_sing_order_ceiling = true -gal_rpec_flag = true - -# DRIVEN (RPEC) resistive match. 2 rational surfaces (q=2,3). Same nominal layer inputs as the -# DIII-D case: η=8e-8, ρ=3.3e-7 kg/m³, rotation=1 Hz, Γ=5/3. -gal_match_flag = true -gal_ideal_flag = true -gal_eta = [8e-8, 8e-8] -gal_rho = [3.3e-7, 3.3e-7] -gal_rotation = [1.0, 1.0] -gal_gamma = 1.6666666666666667 diff --git a/src/ForceFreeStates/ForceFreeStatesStructs.jl b/src/ForceFreeStates/ForceFreeStatesStructs.jl index 21352fb3..c415d53f 100644 --- a/src/ForceFreeStates/ForceFreeStatesStructs.jl +++ b/src/ForceFreeStates/ForceFreeStatesStructs.jl @@ -194,6 +194,13 @@ A mutable struct holding internal state variables for stability calculations. raw 2msing×2msing BVP solution to produce the PEST3-compatible tearing parameter. """ delta_prime_matrix::Matrix{ComplexF64} = Matrix{ComplexF64}(undef, 0, 0) + """ + Edge coil-response matrix of shape (2msing × numpert_total). Column k is the resonant + small-solution response at each surface side to a unit source on edge poloidal mode k, + built by imposing the Eq. (37) rpec edge boundary condition on the Riccati BVP + (`_solve_bvp_edge_coil`). Empty unless the S-axis vacuum-edge BVP was assembled. + """ + delta_coil::Matrix{ComplexF64} = Matrix{ComplexF64}(undef, 0, 0) end """ diff --git a/src/ForceFreeStates/Riccati.jl b/src/ForceFreeStates/Riccati.jl index 59d6b166..9b06ef07 100644 --- a/src/ForceFreeStates/Riccati.jl +++ b/src/ForceFreeStates/Riccati.jl @@ -336,54 +336,14 @@ function compute_delta_prime_matrix!( @info "Δ' BVP: nMat=$nMat, rank(M)=$(rank(M)), cond(M)=$(@sprintf("%.2e", cond(M)))" end - # rpec coil-response loop needs the vacuum edge (col_edge coupled in junc_rows[1:N]) and the - # S-axis row layout that `loop_edge_boundary_conditions` assumes for its edge-BC rows. - # (The edge BC / RHS formulation itself is chosen inside that function via ENV["DELTACOIL_MODE"].) - if use_S_axis && wv !== nothing #only run the coil loop on the S-axis path with a vacuum edge (real W_V present) - # Q1 per-surface normalization REMOVED: delta_coil normalization is hardcoded to 1 (raw, unnormalized). - nq = [abs(Float64(minimum(sing[j].n)) * Float64(sing[j].q1)) for j in 1:msing] #per surface: |n·q'| (still needed by DELTACOIL_PROJECT below) - #DELTACOIL_PROJECT (experimental): build weak-form projection weights so the readout is the - #cell-integrated projection Σ_m W_m·x_m/W[small] (mode-mixing) instead of a single coefficient. - Wl = Wr = nothing - if get(ENV, "DELTACOIL_PROJECT", "") != "" && ctrl !== nothing && equil !== nothing && ffit !== nothing - Wl = Vector{Vector{ComplexF64}}(undef, msing) - Wr = Vector{Vector{ComplexF64}}(undef, msing) - for j in 1:msing - dlo = ctrl.singfac_min / nq[j]; dhi = dlo * 100 #resonant cell [dpsi_lo, dpsi_hi] - aR = compute_sing_asymptotics(sing[j], ctrl, equil, ffit, intr; sig=1.0) - aL = compute_sing_asymptotics(sing[j], ctrl, equil, ffit, intr; sig=-1.0) - Wr[j] = _projection_weight(aR, ipert_all[j] + N, dlo, dhi) - Wl[j] = _projection_weight(aL, ipert_all[j] + N, dlo, dhi) - end - end - intr.delta_coil_matrix = loop_edge_boundary_conditions(M, col_edge, msing, N, ipert_all; Wl, Wr) #run the coil loop (edge harmonic read raw, normalization = 1) + # rpec coil-response block: needs the S-axis row layout that `_solve_bvp_edge_coil` assumes + # for its edge rows, and a vacuum edge in the assembled matrix (col_edge in junc_rows). + if use_S_axis && wv !== nothing + intr.delta_coil = _solve_bvp_edge_coil(M, col_edge, msing, N, ipert_all) end intr.delta_prime_matrix = _solve_bvp_and_combine_pest3( M, msing, N, nMat, use_S_axis, ipert_all, col_edge, ctrl, debug) - - # Resistive inner-layer matching (Wang 2020 Eq. 11): feed the RAW outer Δ' block (dp_raw) and the - # RAW edge-driven Δ_coil (normalization = 1, so both share one normalization — Step-1 result) into the - # coil-driven outer↔inner match. Gated on ctrl.gal_match_flag; needs the S-axis vacuum-edge BVP. - if use_S_axis && wv !== nothing && ctrl !== nothing && ctrl.gal_match_flag && equil !== nothing - delta_out_raw = loop_boundary_conditions(M, msing, N, ipert_all) #raw Δ_out (2msing×2msing) - delta_coil_raw = loop_edge_boundary_conditions(M, col_edge, msing, N, ipert_all) #raw Δ_coil (normalization = 1) - mres = resonant_match_rpec(delta_out_raw, delta_coil_raw, sing, equil, intr, ctrl) - intr.resonant_match_cout = mres.cout - intr.resonant_match_cin = mres.cin - intr.resonant_match_deltar = mres.deltar - intr.resonant_match_rpec_eig = mres.rpec_eig - intr.resonant_match_flux = mres.reconnected_flux - intr.resonant_match_bpen = mres.bpen - intr.resonant_match_residual = mres.residual - @info @sprintf("Resistive inner-layer match: residual=%.2e, ‖cout‖=%.3e, ‖cin‖=%.3e (%d surfaces, %d coil modes)", - mres.residual, norm(mres.cout), norm(mres.cin), msing, size(mres.cout, 2)) - for i in 1:msing - @info @sprintf(" surf %d (q=%.3g): Δ_in=(%.3e%+.3ei, %.3e%+.3ei) γ=%.3e%+.3ei", - i, sing[i].q, real(mres.deltar[i,1]), imag(mres.deltar[i,1]), real(mres.deltar[i,2]), imag(mres.deltar[i,2]), - real(mres.rpec_eig[i]), imag(mres.rpec_eig[i])) - end - end end # Column index helpers for the BVP matrix. j is the 1-based singular-surface index, @@ -676,92 +636,26 @@ function _assemble_bvp_S_axis(uShootR::Vector{Matrix{ComplexF64}}, return M, nMat, col_edge end -# Δ' loop: drive each of the 2·msing big-solution BCs of Eq. (37) in turn, collect the small-solution -# (+N slot) coeffs at every surface into the raw Δ' matrix [Glasser-Kolemen 2018]. -function loop_boundary_conditions(M::Matrix{ComplexF64}, msing::Int, N::Int, ipert_all::Vector{Int}) - nMat = size(M, 1) #BVP unknowns = (2 + 4·msing)·N - s2 = 2 * msing #number of BCs (left/right per surface) - M_lu = lu(M) #factorize the full Eq.(37) matrix once; only the RHS changes per BC - dp_raw = zeros(ComplexF64, s2, s2) #raw Δ': rows = driver BC, cols = surface side - b = zeros(ComplexF64, nMat) - for jsing in 1:msing, side in 1:2 - dRow = 2jsing - (2 - side) #BC index: surface jsing, side 1=left/2=right - fill!(b, 0) - b[nMat-s2+dRow] = 1 #drive this big-solution coeff = 1 (bottom driving rows) - x = M_lu \ b - for ksing in 1:msing - ipert_k = ipert_all[ksing] - dp_raw[dRow, 2ksing-1] = x[_col_left(ksing, N)[ipert_k+N]] #left small-solution coeff - dp_raw[dRow, 2ksing] = x[_col_right(ksing, N)[ipert_k+N]] #right small-solution coeff - end - end - return dp_raw -end - -# Weak-form projection weight (EXPERIMENTAL, ENV DELTACOIL_PROJECT): W[m] = ∫ Σ_h conj(u^S_h)·u^basis_{m,h}(ξ) -# d(dpsi) over the resonant cell — mirrors RDCON's gal_resonant cell-integrated projection (without the energy -# operator STRIDE lacks). Reading Σ_m W_m·x_m / W[small] replaces the pointwise single-coefficient read and -# mixes modes via the cross-overlaps of u^S with the big/nonresonant basis functions. -function _projection_weight(asymp::SingAsymptotics, small_col::Int, dpsi_lo::Float64, dpsi_hi::Float64) - npts = 24 - ds = exp.(range(log(dpsi_lo), log(dpsi_hi); length=npts)) - nbasis = size(asymp.vmat, 2) - W = zeros(ComplexF64, nbasis) - for t in 1:npts - ua = sing_get_ua(asymp, ds[t]) - uS = conj.(ua[:, small_col, 1]) #conj small-solution ξ component, over harmonics - dstep = t < npts ? (ds[t+1] - ds[t]) : (ds[t] - ds[t-1]) - for m in 1:nbasis - W[m] += dstep * sum(uS .* @view ua[:, m, 1]) - end - end - return W -end - -# Coil-response loop for the Eq. (37) edge block [Glasser-Kolemen 2018 PoP 25 082502]: for each edge poloidal -# mode k, copy the full BVP matrix, impose the Eq. (38) edge BC via `bc!`, solve, and read the raw -# small-solution coeff (+N slot, normalization = 1) at every surface → delta_coil (2·msing × N). If Wl/Wr are -# given (weak-form projection weights), read the cell-integrated projection Σ_m W_m·x_m / W[small] instead. -function loop_edge_boundary_conditions(M::Matrix{ComplexF64}, col_edge, msing::Int, N::Int, - ipert_all::Vector{Int}; Wl=nothing, Wr=nothing) +# Coil-response block for the Eq. (37) edge [Glasser-Kolemen 2018 PoP 25, 032501]: impose the rpec edge +# boundary condition — identity edge plus a unit source per poloidal mode, matching RDCON's +# `gal_set_boundary` rpec branch — then read the small-solution (+N slot) coefficient at every surface. +# The BC is the same for every edge mode, so the matrix is factorized once and all N modes are solved +# together as columns of one right-hand side. Returns delta_coil (2·msing × N), rows = surface side. +function _solve_bvp_edge_coil(M::Matrix{ComplexF64}, col_edge, msing::Int, N::Int, ipert_all::Vector{Int}) nMat = size(M, 1) - top = (nMat-2msing-2N+1):(nMat-2msing-N) #Eq.38 top rows (1_M identity) - bot = (nMat-2msing-N+1):(nMat-2msing) #Eq.38 bottom rows (-W_V) - # DELTACOIL_EDGE selects the edge BC to test against galerkin (default "driven"). bc! mutates the copy Mc for - # edge mode k and returns the RHS (or nothing for a homogeneous null-space solve): - # "driven" – replace the W_V bottom with a Dirichlet identity (α_edge = RHS), unit drive at bot[k]. Well-posed. - # "wv" – keep the assembled true W_V edge untouched, unit drive at bot[k] (galerkin's vacuum edge). - # "wv_top" – keep the true W_V edge, drive via the top (1_M) row top[k] instead. - edge = get(ENV, "DELTACOIL_EDGE", "driven") - function bc!(Mc, k) - rhs = zeros(ComplexF64, nMat) - if edge == "driven" - Mc[bot, :] .= 0 - Mc[bot, col_edge] .= I(N) - rhs[bot[k]] = 1 #unit drive on edge mode k - elseif edge == "wv_top" - rhs[top[k]] = 1 #drive the true W_V edge via the top 1_M row - else #"wv": drive the true W_V edge via the bottom row - rhs[bot[k]] = 1 - end - return rhs - end - delta_coil = zeros(ComplexF64, 2msing, N) #rows = surface side, cols = edge mode k - for k in 1:N - Mc = copy(M) #fresh full Eq.(37) matrix per mode k - rhs = bc!(Mc, k) - x = rhs === nothing ? svd(Mc).V[:, end] : lu(Mc) \ rhs - for j in 1:msing - ipert_j = ipert_all[j] - cl = _col_left(j, N); cr = _col_right(j, N) - if Wl === nothing - delta_coil[2j-1, k] = x[cl[ipert_j+N]] - delta_coil[2j, k] = x[cr[ipert_j+N]] - else #weak-form cell-integrated projection (experimental) - delta_coil[2j-1, k] = sum(Wl[j] .* @view x[cl]) / Wl[j][ipert_j+N] - delta_coil[2j, k] = sum(Wr[j] .* @view x[cr]) / Wr[j][ipert_j+N] - end - end + bot = (nMat-2msing-N+1):(nMat-2msing) # Eq. (38) bottom rows, carrying the W_V block + Mc = copy(M) + Mc[bot, :] .= 0 + Mc[bot, col_edge] .= I(N) # Dirichlet edge: the edge coefficients equal the source + B = zeros(ComplexF64, nMat, N) + B[bot, :] .= I(N) # unit drive, one column per edge poloidal mode + X = lu(Mc) \ B + delta_coil = zeros(ComplexF64, 2msing, N) + for j in 1:msing + row_left = _col_left(j, N)[ipert_all[j]+N] + row_right = _col_right(j, N)[ipert_all[j]+N] + @views delta_coil[2j-1, :] .= X[row_left, :] + @views delta_coil[2j, :] .= X[row_right, :] end return delta_coil end diff --git a/src/GeneralizedPerturbedEquilibrium.jl b/src/GeneralizedPerturbedEquilibrium.jl index 2a7c9ae6..0ba46b00 100755 --- a/src/GeneralizedPerturbedEquilibrium.jl +++ b/src/GeneralizedPerturbedEquilibrium.jl @@ -167,7 +167,6 @@ function main_from_inputs( @info "\n Equilibrium\n$_SECTION" equil_start = time() - # Build data structures from inputs intr = ForceFreeStatesInternal(; dir_path=path) ffs_table = inputs["ForceFreeStates"] @@ -780,7 +779,7 @@ function write_outputs_to_HDF5( # Edge coil-response matrix, stored (numpert_total × 2msing) = (edge mode, surface-side) to match # the galerkin/delta_coil layout so H5Web heatmaps share axes (x = edge mode, y = surface-side). - # Internal intr.delta_coil_matrix stays (2msing × numpert_total); transpose only at write. + # Internal intr.delta_coil stays (2msing × numpert_total); transpose only at write. if intr.msing > 0 && !isempty(intr.delta_coil) dc = permutedims(intr.delta_coil) out_h5["singular/delta_coil"] = dc diff --git a/src/InnerLayer/GGJ/Ray.jl b/src/InnerLayer/GGJ/Ray.jl index 7556a589..aa7348cd 100644 --- a/src/InnerLayer/GGJ/Ray.jl +++ b/src/InnerLayer/GGJ/Ray.jl @@ -6,7 +6,7 @@ # `:galerkin` (real-axis oscillation + on-axis pseudo-resonance) degrade. # # Validated against the Fortran rmatch pins, the `:galerkin` backend at -# moderate Q, and physical benchmarks to Q = 500i. Full method write-up: +# moderate Q, and physical benchmarks to Q = 500i. Summarized method write-up: # docs/src/inner_layer.md, "Numerical method". In one paragraph: # # The equations are continued analytically onto the ray x = e^{iθ}s with @@ -525,6 +525,8 @@ function boundary_basis(params::GGJParameters, Q::ComplexF64, cond_est = cond(Bmat) return Us, Ub, E, cond_est end + + # solve.jl # # Global spectral-element collocation solve of the GGJ inner-layer BVP on @@ -535,7 +537,7 @@ end # unknowns : v at all global nodes (6 components each), plus (Δ, c₁, c₂) # equations: dv/ds = e^{iθ} M(e^{iθ}s) v collocated at the p non-left # Lobatto nodes of every cell (right-biased collocation — -# Radau-IIA-like, stable for the dormant stiff modes); +# Radau-IIA-like, stable for the stiff modes); # 3 parity conditions at s = 0; # 6 matching conditions at s = S: # v(S) − Δ·Ub − c₁E₁ − c₂E₂ = Us . @@ -792,7 +794,7 @@ function solve_ray(params::GGJParameters, Q::ComplexF64; # Decide the BVP outer radius: the layer physics (exponential content # above machine floor) ends at s_d; beyond that the solution is pure - # power law and is handled by the projected quotient MARCH, not by + # power law and is handled by the projected quotient march, not by # collocation. For small |Q| (s_d ≳ S) no march is needed. sq = sqrt(Q) ρ = real(cis(2θ) / sq) @@ -1090,7 +1092,7 @@ function delta_convergence(params::GGJParameters, Q::ComplexF64; ] table = NamedTuple[] spread = MVector{2,Float64}(0.0, 0.0) - verbose && @printf("Δ convergence battery, Q = %s:\n", string(Q)) + verbose && @printf("Δ convergence check, Q = %s:\n", string(Q)) verbose && @printf(" baseline: Δ₁ = %+.8e %+.8eim, Δ₂ = %+.8e %+.8eim\n", real(base.Δ[1]), imag(base.Δ[1]), real(base.Δ[2]), imag(base.Δ[2])) for (name, kw) in variations From 7fba8a4c8b1abad8662f92e6b650c26459e33d8c Mon Sep 17 00:00:00 2001 From: Matthew Pharr Date: Thu, 30 Jul 2026 19:22:57 -0400 Subject: [PATCH 37/38] SingularCoupling, GGJ - CLEANUP - Remove commented-out kinetic fallback code for penetrated field and fix some docstrings --- src/InnerLayer/GGJ/Ray.jl | 2 ++ src/PerturbedEquilibrium/SingularCoupling.jl | 5 ----- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/InnerLayer/GGJ/Ray.jl b/src/InnerLayer/GGJ/Ray.jl index aa7348cd..ea0e62ee 100644 --- a/src/InnerLayer/GGJ/Ray.jl +++ b/src/InnerLayer/GGJ/Ray.jl @@ -942,6 +942,8 @@ end npc=8, certify_rtol=1e-3, kwargs...) -> (; Δ, x, Ψ, Ξ, dψdx, rescale, certΔ) +Rotated-ray implementation of the [`solve_inner_profile`](@ref) interface. The +certified `Δ` comes from the optimal-contour solve at θ = arg(Q)/4 (robust for |Q| ≳ 1, where real-axis methods drift); the profiles come from a θ = 0 re-solve on the real axis, valid at physical (RPEC) |Q| since the on-axis pseudo-resonance is a regular point resolved by the BVP refinement. The diff --git a/src/PerturbedEquilibrium/SingularCoupling.jl b/src/PerturbedEquilibrium/SingularCoupling.jl index ca148722..ff375ffe 100644 --- a/src/PerturbedEquilibrium/SingularCoupling.jl +++ b/src/PerturbedEquilibrium/SingularCoupling.jl @@ -272,11 +272,6 @@ function compute_singular_coupling_metrics!( bwp1_l = 2π * im * chi1 * (singfac_l * xsp1_l - nn * q1_l * xsp_l) bwp1_r = 2π * im * chi1 * (singfac_r * xsp1_r - nn * q1_r * xsp_r) jump_vec[k] = bwp1_r - bwp1_l - # Kinetic fallback for penetrated field: - # if kinetic (where to get this variable???) - # b_l = chi1 * singfac_l * 2π * im * xsp_l - # b_r = chi1 * singfac_r * 2π * im * xsp_r - # state.C_penetrated_area_weighted_field[row, k] = (b_l + b_r) / 2 / area end # Inner-layer (cusp-free) penetrated field: bpen[s, j] is linear in the same identity-at-edge From ed7fd4f9818d66a117c334f2c42cda857ff13388 Mon Sep 17 00:00:00 2001 From: Matthew Pharr Date: Thu, 30 Jul 2026 19:33:31 -0400 Subject: [PATCH 38/38] ForceFreeStates - CLEANUP - Remove obsolete toml flags --- examples/LAR_ideal_match_test/gpec.toml | 4 +--- examples/LAR_resistive_match_test/gpec.toml | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/examples/LAR_ideal_match_test/gpec.toml b/examples/LAR_ideal_match_test/gpec.toml index 33a71012..07f67030 100644 --- a/examples/LAR_ideal_match_test/gpec.toml +++ b/examples/LAR_ideal_match_test/gpec.toml @@ -33,8 +33,6 @@ a = 20 # Distance from plasma (conformal) or shape param [ForceFreeStates] HDF5_filename = "gpec_lar_ideal.h5" # Output filename for the stability-stage results local_stability_flag = true # Perform local stability analysis (Mercier and ballooning) across the psi profile -mat_flag = true # Construct coefficient matrices for diagnostic purposes (required by gal_flag) -ode_flag = true # Integrate ODE's for determining stability of internal long-wavelength mode vac_flag = true # Compute plasma, vacuum, and total energies for free-boundary modes qlow = 1.02 # Integration initiated at q determined by min(q0, qlow) @@ -61,7 +59,7 @@ force_termination = true # Terminate after the stability stage (no perturb write_outputs_to_HDF5 = true # Write stability outputs to HDF5 # Outer-region singular Galerkin Delta-prime solver with rpec coil columns. -gal_flag = true # Enable the outer-region singular Galerkin Delta-prime solve (requires mat_flag = true) +gal_flag = true # Enable the outer-region singular Galerkin Delta-prime solve gal_solver = "LU" # Banded solver: "LU" (zgbtrf/zgbtrs) or "cholesky" (zpbtrf/zpbtrs). rpec requires "LU". gal_nx = 256 # Hermite-cubic elements per interval between singular surfaces gal_nq = 6 # Gauss-Lobatto quadrature order per element diff --git a/examples/LAR_resistive_match_test/gpec.toml b/examples/LAR_resistive_match_test/gpec.toml index 625918ae..7fb9f565 100644 --- a/examples/LAR_resistive_match_test/gpec.toml +++ b/examples/LAR_resistive_match_test/gpec.toml @@ -34,8 +34,6 @@ a = 20 # Distance from plasma (conformal) or shape param [ForceFreeStates] HDF5_filename = "gpec_lar_resistive.h5" # Output filename for the stability-stage results local_stability_flag = true # Perform local stability analysis (Mercier and ballooning) across the psi profile -mat_flag = true # Construct coefficient matrices for diagnostic purposes (required by gal_flag) -ode_flag = true # Integrate ODE's for determining stability of internal long-wavelength mode vac_flag = true # Compute plasma, vacuum, and total energies for free-boundary modes qlow = 1.02 # Integration initiated at q determined by min(q0, qlow) @@ -62,7 +60,7 @@ force_termination = true # Terminate after the stability stage (no perturb write_outputs_to_HDF5 = true # Write stability outputs to HDF5 # Outer-region singular Galerkin Delta-prime solver with rpec coil columns. -gal_flag = true # Enable the outer-region singular Galerkin Delta-prime solve (requires mat_flag = true) +gal_flag = true # Enable the outer-region singular Galerkin Delta-prime solve gal_solver = "LU" # Banded solver: "LU" (zgbtrf/zgbtrs) or "cholesky" (zpbtrf/zpbtrs). rpec requires "LU". gal_nx = 256 # Hermite-cubic elements per interval between singular surfaces gal_nq = 6 # Gauss-Lobatto quadrature order per element

aF=T6zyLf78Z|tjr04KA=W|-Mn8CX=S(bdMj%_ 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 M?>hUmop 10 && continue # :shooting is meaningless past its regime + try + Δ = IL.solve_inner(IL.GGJModel(; solver=:shooting), p, γ(aq)) + all(isfinite, Δ) && (push!(shoot_x, aq); push!(shoot_e, max(relerr(Δ, ref[k]), 1e-16))) + catch + end +end + +@printf("computed %d :ray refs, %d :galerkin, %d :shooting points\n", + length(absQ), length(gal_x), length(shoot_x)) + +plt = plot(; size=(820, 560), legend=:bottomright, xscale=:log10, yscale=:log10, + xlabel="|Q| (imaginary axis, Q = i|Q|)", + ylabel="relative error vs :ray reference", + title="Backend accuracy along the imaginary-Q axis (q=4 surface)", + ylims=(1e-7, 3e0), left_margin=9Plots.mm, bottom_margin=5Plots.mm) + +plot!(plt, shoot_x, shoot_e; lw=2, marker=:diamond, ms=5, color=:seagreen, label=":shooting") +plot!(plt, gal_x, gal_e; lw=2, marker=:circle, ms=5, color=:orange, label=":galerkin") +hline!(plt, [1e-2]; color=:red, ls=:dash, lw=1.5, label="1% accuracy") +annotate!(plt, 8.0, 3e-6, + Plots.text(":ray reference\n(error < 1e-5 to |Q| = 500)", 8, :center, RGB(0.2, 0.3, 0.6))) + +save_doc_figure(plt, "inner_layer", "backend_regime_map"; + script=basename(@__FILE__), + depends=["src/InnerLayer/GGJ/Ray.jl", "src/InnerLayer/GGJ/Galerkin.jl", + "src/InnerLayer/GGJ/Shooting.jl"]) diff --git a/docs/src/figures/inner_layer/make_convergence_Sinvariance.jl b/docs/src/figures/inner_layer/make_convergence_Sinvariance.jl new file mode 100644 index 00000000..3e2a71b2 --- /dev/null +++ b/docs/src/figures/inner_layer/make_convergence_Sinvariance.jl @@ -0,0 +1,45 @@ +# make_convergence_Sinvariance.jl +# +# Figure: honest numerical error bar on the matching data Δ at Q = 500i. The +# `delta_convergence` battery re-solves with each numerical knob perturbed on +# an independent axis (contour angle θ, spectral order p, series order/radius, +# refinement depth, march tolerance, handoff radius, purification e-folds). Δ +# is an analytic invariant of the contour, so the worst-case spread across +# these orthogonal perturbations bounds the true error. Eight solves; ~30 s. +# +# Run manually: julia --project=. docs/src/figures/inner_layer/make_convergence_Sinvariance.jl + +include(joinpath(@__DIR__, "..", "..", "..", "figure_tools.jl")) + +using GeneralizedPerturbedEquilibrium +using Printf + +const IL = GeneralizedPerturbedEquilibrium.InnerLayer + +p = IL.q4_surface_benchmark() +Q = 500.0im + +conv = IL.delta_convergence(p, Q; verbose=false) +names = [r.name for r in conv.table] +d1 = [max(r.d1, 1e-16) for r in conv.table] # relative change of Δ_odd +d2 = [max(r.d2, 1e-16) for r in conv.table] # relative change of Δ_even +n = length(names) + +plt = plot(; size=(820, 540), legend=:topright, yscale=:log10, + xticks=(1:n, names), xrotation=30, ylims=(1e-12, 1e-2), + ylabel="relative change of Δ per knob", xlabel="perturbed numerical knob", + title=@sprintf("Convergence & contour-invariance of Δ, Q = %gi (q=4)", imag(Q)), + left_margin=10Plots.mm, bottom_margin=14Plots.mm) + +scatter!(plt, 1:n, d1; marker=:circle, ms=7, color=1, label="δΔ_odd") +scatter!(plt, 1:n, d2; marker=:diamond, ms=7, color=2, label="δΔ_even") + +# Worst-case spread — the reported error bar on each parity. +hline!(plt, [conv.spread[1]]; color=1, ls=:dash, lw=1.5, + label=@sprintf("worst-case Δ_odd %.1e", conv.spread[1])) +hline!(plt, [conv.spread[2]]; color=2, ls=:dash, lw=1.5, + label=@sprintf("worst-case Δ_even %.1e", conv.spread[2])) + +save_doc_figure(plt, "inner_layer", "convergence_Sinvariance"; + script=basename(@__FILE__), + depends=["src/InnerLayer/GGJ/Ray.jl"]) diff --git a/docs/src/figures/inner_layer/make_rotated_ray_contour.jl b/docs/src/figures/inner_layer/make_rotated_ray_contour.jl new file mode 100644 index 00000000..15abe167 --- /dev/null +++ b/docs/src/figures/inner_layer/make_rotated_ray_contour.jl @@ -0,0 +1,65 @@ +# make_rotated_ray_contour.jl +# +# Figure: the rotated integration ray in the complex layer-coordinate (x) plane. +# Shows why the :ray backend rotates the contour by θ = arg(Q)/4: the real-axis +# contour (θ = 0) runs straight through the pseudo-resonance at +# x² = −Q²(G + K F) (real and large on the imaginary-Q axis), while the rotated +# ray clears it. Geometry only — no BVP solve — so it is cheap to regenerate. +# +# Run manually: julia --project=. docs/src/figures/inner_layer/make_rotated_ray_contour.jl + +include(joinpath(@__DIR__, "..", "..", "..", "figure_tools.jl")) + +using GeneralizedPerturbedEquilibrium +using Printf + +const IL = GeneralizedPerturbedEquilibrium.InnerLayer +const GGJ = IL.GGJ + +# q = 4 rational-surface benchmark on the imaginary-Q axis (the regime that +# defeats :galerkin). Q is the scaled growth rate; θ makes the parabolic +# exponent real. +p = IL.q4_surface_benchmark() +Q = 500.0im +θ = angle(Q) / 4 # = π/8 = 22.5° + +# Pseudo-resonance location: x² = −Q²(G + K F). On the imaginary-Q axis this is +# real and large, so it sits ON the real-x contour. +x_pr = sqrt(-Q^2 * (p.G + p.K * p.F)) +xpr = real(x_pr) # imaginary part ≈ 0 here + +# Matching radius from the series-residual criterion along the ray (annotation). +S, _, ok = IL.pick_smax(p, Q; θ=θ) + +# Draw both contours out to ~1.8× the pseudo-resonance radius so it is in frame. +smax = 1.8 * xpr +s = range(0, smax; length=400) +ray = cis(θ) .* s # x = e^{iθ} s + +plt = plot(; size=(720, 560), legend=:topleft, framestyle=:zerolines, + xlabel="Re x", ylabel="Im x", aspect_ratio=:equal, + title="Rotated integration ray, Q = 500i (q=4 surface)", + left_margin=6Plots.mm, bottom_margin=4Plots.mm) + +# Real-axis contour (θ = 0) and the rotated ray. +plot!(plt, [0, smax], [0, 0]; lw=2, ls=:dash, color=:gray, + label="real-axis contour (θ = 0)") +plot!(plt, real.(ray), imag.(ray); lw=3, color=1, + label=@sprintf("rotated ray x = e^{iθ}s, θ = %.1f°", rad2deg(θ))) + +# Pseudo-resonance on the real axis — the point the rotation steps around. +scatter!(plt, [xpr], [0.0]; marker=:xcross, ms=9, color=:red, msw=3, + label="pseudo-resonance x² = −Q²(G+KF)") + +# Clearance note above the ray near the pseudo-resonance radius, plus the true +# matching radius. Positions are tied to xpr so they stay inside the frame. +annotate!(plt, 1.02 * xpr, 0.56 * xpr, + Plots.text("ray stays clear of the\nreal-axis pseudo-resonance", 8, :left, + RGB(0.15, 0.15, 0.15))) +annotate!(plt, 0.45 * xpr, -0.14 * xpr, + Plots.text(@sprintf("matching radius S ≈ %.2g%s", S, ok ? "" : " (series tol not met)"), + 8, :gray, :left)) + +save_doc_figure(plt, "inner_layer", "rotated_ray_contour"; + script=basename(@__FILE__), + depends=["src/InnerLayer/GGJ/Ray.jl"]) diff --git a/docs/src/figures/inner_layer/make_solution_profiles.jl b/docs/src/figures/inner_layer/make_solution_profiles.jl new file mode 100644 index 00000000..2165f358 --- /dev/null +++ b/docs/src/figures/inner_layer/make_solution_profiles.jl @@ -0,0 +1,60 @@ +# make_solution_profiles.jl +# +# Figure: the inner-layer fields (Ψ, Ξ, Υ) reconstructed on the rotated ray for +# the q=4 benchmark surface. The collocation solution on [0, s_m] (solid) and +# the analytic u_small + Δ·u_big asymptotic representation for s ≥ S (dashed) +# share the same power-law tail — the numeric↔asymptotic overlap the outer- +# region matching relies on. One BVP solve; a few minutes at large imaginary Q. +# +# Run manually: julia --project=. docs/src/figures/inner_layer/make_solution_profiles.jl + +include(joinpath(@__DIR__, "..", "..", "..", "figure_tools.jl")) + +using GeneralizedPerturbedEquilibrium +using Printf + +const IL = GeneralizedPerturbedEquilibrium.InnerLayer +const GGJ = IL.GGJ + +p = IL.q4_surface_benchmark() +Q = 2.0im # imaginary axis, beyond the |Q|≪1 shooting regime, +# small enough that the collocation domain reaches +# the series radius directly (no march) — so the +# numeric and asymptotic segments join seamlessly. +isol = 1 # "odd" parity solution (Ψ'(0)=Ξ(0)=Υ(0)=0) + +res = IL.solve_ray(p, Q) +s_m = res.breaks[end] +@printf("solve_ray: Q=%s θ=%.3f S=%.4g s_m=%.4g resid=%.1e\n", + string(Q), res.θ, res.S, s_m, res.resid) + +# Collocation solution over the BVP domain [0, s_m]. +prof = IL.solution_profile(res; npc=8) + +# Analytic tail on [S, few·S] where the inps series is trusted. +srange = 10 .^ range(log10(res.S), log10(4 * res.S); length=80) +asy = IL.asymptotic_profile(p, res, srange) + +fields = ((:Ψ, prof.Ψ, asy.Ψ, 1), (:Ξ, prof.Ξ, asy.Ξ, 2), (:Υ, prof.Υ, asy.Υ, 3)) + +plt = plot(; size=(760, 560), legend=:bottomleft, xscale=:log10, yscale=:log10, + ylims=(1e-3, 1e2), + xlabel="ray parameter s (x = e^{iθ}s)", ylabel="|field| (odd parity)", + title=@sprintf("Inner-layer fields on the rotated ray, Q = %gi (q=4)", imag(Q)), + left_margin=8Plots.mm, bottom_margin=5Plots.mm) + +for (nm, col, acol, ci) in fields + plot!(plt, prof.s[2:end], abs.(col[2:end, isol]); lw=2.5, color=ci, + label="|$(nm)| collocation") + plot!(plt, asy.s, abs.(acol[:, isol]); lw=2, ls=:dash, color=ci, label="|$(nm)| asymptotic") +end + +# Matching radius: with no march the BVP edge s_m coincides with the series +# radius S, so numeric and asymptotic meet at a single point. +vline!(plt, [res.S]; color=:black, ls=:dot, lw=1, + label=@sprintf("S = s_m ≈ %.1f (match point)", res.S)) + +save_doc_figure(plt, "inner_layer", "solution_profiles"; + script=basename(@__FILE__), + depends=["src/InnerLayer/GGJ/Ray.jl", "src/InnerLayer/GGJ/RayAsymptotics.jl", + "src/InnerLayer/GGJ/InnerAsymptotics.jl"]) diff --git a/docs/src/figures/inner_layer/rotated_ray_contour.png b/docs/src/figures/inner_layer/rotated_ray_contour.png new file mode 100644 index 0000000000000000000000000000000000000000..27884cf1f6be7c7aefa552eb942d022cc83b79f7 GIT binary patch literal 46538 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 C5{_8_ literal 0 HcmV?d00001 diff --git a/docs/src/figures/manifest.toml b/docs/src/figures/manifest.toml new file mode 100644 index 00000000..2ad9ef5d --- /dev/null +++ b/docs/src/figures/manifest.toml @@ -0,0 +1,43 @@ +# GPEC documentation-figure provenance manifest. +# One [.