From c8b1f6afa7c4738df659e17d9cbb0b2bfa33a700 Mon Sep 17 00:00:00 2001 From: satelite2517 Date: Wed, 1 Jul 2026 14:33:21 +0900 Subject: [PATCH 01/10] KineticForces - BUG FIX - #269 kinetic dW inflated: don't over-scale xi^alpha by chi1 set_perturbation_data! multiplied clebsch_alpha by chi1 to form the tangential displacement fed to the NTV operator (both xs_m[3] and the JBB deweighting xms). But clebsch_alpha from FieldReconstruction is already the physical xi^alpha = xms/chi1 -- exactly what Fortran gpout_xclebsch writes (xmsout = xmsout/chi1) and what PENTRC consumes directly. The extra *chi1 (~1.65x) over-scaled the tangential displacement, inflating the kinetic dW while leaving the torque comparatively OK. Verified on the DIII-D kinetic case by feeding a matched displacement through the operator (amplitude-independent dW/T; Fortran ref -0.1823): the real-pipeline dW/T moves from -0.117 (36% off) to -0.168 (7.8% off) after the fix. The ~8% residual is grid/accuracy (mpsi, xi differences) plus the separate loglam log10 issue. Also removes the now-obsolete compensating ./chi1 in benchmark_diiid_ideal_ntv_torque.jl, which wrongly assumed gpec_xclebsch stored raw xi^alpha (the file already stores xms/chi1), so its matched test stays correct. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01RLSHXUFe1LSNX3keRUMWnC --- benchmarks/benchmark_diiid_ideal_ntv_torque.jl | 7 +++---- src/KineticForces/KineticForcesStructs.jl | 9 ++++----- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/benchmarks/benchmark_diiid_ideal_ntv_torque.jl b/benchmarks/benchmark_diiid_ideal_ntv_torque.jl index 32aa4c51e..80465daa3 100644 --- a/benchmarks/benchmark_diiid_ideal_ntv_torque.jl +++ b/benchmarks/benchmark_diiid_ideal_ntv_torque.jl @@ -264,15 +264,14 @@ function run_benchmark(fortran_dir::String=DEFAULT_FORTRAN_DIR) _p("\n--- Build PE state (Fortran Clebsch → dbob_m/divx_m) ---") t1 = time() - # Construct PerturbedEquilibriumState with Fortran Clebsch data - # Fortran xclebsch stores ξ^α directly; PE convention is ξ^α/χ₁ - chi1 = 2π * equil.psio + # Construct PerturbedEquilibriumState with Fortran Clebsch data. + # Fortran gpec_xclebsch already writes ξ^α = xms/χ₁, exactly what the KF operator consumes — feed directly. pe_state = PE.PerturbedEquilibriumState(; psi_grid=psi_grid_f, xi_modes=( clebsch_psi=clebsch_psi, clebsch_psi1=clebsch_psi1, - clebsch_alpha=clebsch_alpha ./ chi1 # Store as ξ^α/χ₁ per PE convention + clebsch_alpha=clebsch_alpha ) ) diff --git a/src/KineticForces/KineticForcesStructs.jl b/src/KineticForces/KineticForcesStructs.jl index 5e2358121..070e602d1 100644 --- a/src/KineticForces/KineticForcesStructs.jl +++ b/src/KineticForces/KineticForcesStructs.jl @@ -309,16 +309,15 @@ function set_perturbation_data!(kf_intr::KineticForcesInternal, pe_state, ffs_in psi_grid = pe_state.psi_grid npsi = length(psi_grid) mpert = ffs_intr.mpert - chi1 = kf_intr.chi1 # Build xs_m: 3 CubicSeriesInterpolants from Clebsch displacement matrices # xs_m[1] = ξ^ψ (unregularized), xs_m[2] = ∂ξ^ψ/∂ψ (regularized), xs_m[3] = ξ^α - # Note: clebsch_alpha is stored as ξ^α/χ₁, multiply by chi1 to get ξ^α itp_opts = (; extrap=ExtendExtrap()) + # clebsch_alpha is already the physical ξ^α = xms/χ₁ (as Fortran gpout_xclebsch writes it); use directly. + clebsch_alpha_mat = xi_modes.clebsch_alpha xs_m_1 = cubic_interp(psi_grid, Series(xi_modes.clebsch_psi); itp_opts...) xs_m_2 = cubic_interp(psi_grid, Series(xi_modes.clebsch_psi1); itp_opts...) - clebsch_alpha_raw = xi_modes.clebsch_alpha .* chi1 - xs_m_3 = cubic_interp(psi_grid, Series(clebsch_alpha_raw); itp_opts...) + xs_m_3 = cubic_interp(psi_grid, Series(clebsch_alpha_mat); itp_opts...) kf_intr.xs_m = [xs_m_1, xs_m_2, xs_m_3] # Build geometric matrices (S,T,X,Y,Z) for JBB deweighting @@ -355,7 +354,7 @@ function set_perturbation_data!(kf_intr::KineticForcesInternal, pe_state, ffs_in # Get Clebsch displacement vectors at this ψ xsp = view(xi_modes.clebsch_psi, ipsi, :) # ξ^ψ [mpert] xmp1 = view(xi_modes.clebsch_psi1, ipsi, :) # ∂ξ^ψ/∂ψ [mpert] - xms = view(clebsch_alpha_raw, ipsi, :) # ξ^α [mpert] + xms = view(clebsch_alpha_mat, ipsi, :) # ξ^α [mpert] # Evaluate geometric matrices at ψ → mpert² flat vectors, reshape to mpert×mpert geom_mats.smats(smat_flat, psi; hint=hint_s) From 0858f63be9ed628cec0ac13323a93fe3cff4d0f2 Mon Sep 17 00:00:00 2001 From: satelite2517 Date: Thu, 2 Jul 2026 14:02:41 +0900 Subject: [PATCH 02/10] KineticForces - BUG FIX - #269 use axis toroidal field for bo, matching Fortran PENTRC intr.bo was equil.params.b0 = bt0, the edge-extrapolated vacuum field at rmean (equil_out.f:268). Fortran PENTRC instead uses the field at the magnetic axis: bo = |sq%f(1)|/(2pi*ro) at psi=0 (dcon_interface.f:1414). On DIII-D 147131 these differ by 2.2%, and since bo normalizes the pitch variable (lambda = mu*bo/E, v_par = 1 - lambda*B/bo) the trapped/passing boundary and every bounce-averaged quantity were shifted by that scale: wb(lambda) disagreed with Fortran by up to several percent mid-passing and ~3.5% trapped. With this fix wb/wd match Fortran to ~1e-4 across the lambda range at psi=0.3/0.5/0.7. DIIID ideal-NTV benchmark vs fresh Fortran reference (with the companion theta-quadrature fix): FGAR dW err 7.23% -> 2.94%, T err 2.66% -> 1.31%; TGAR dW 4.60% -> 4.54%, T 1.71% -> 1.65%. All within the 5% acceptance. Co-Authored-By: Claude Fable 5 --- src/KineticForces/KineticForcesStructs.jl | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/KineticForces/KineticForcesStructs.jl b/src/KineticForces/KineticForcesStructs.jl index 070e602d1..15371158c 100644 --- a/src/KineticForces/KineticForcesStructs.jl +++ b/src/KineticForces/KineticForcesStructs.jl @@ -254,9 +254,15 @@ the equilibrium geometry parameters needed for NTV calculations. function KineticForcesInternal(equil; verbose::Bool=false) mthsurf = length(equil.rzphi_ys) - 1 nth = mthsurf + 1 + # Fortran PENTRC (dcon_interface.f set_geom): bo = |sq%f(1)|/(2π·ro) at ψ=0 — + # the toroidal field AT THE MAGNETIC AXIS, from F extrapolated to the axis. + # NOT params.b0 (= bt0, the edge-extrapolated vacuum field at rmean, ~2% + # different on DIII-D). bo normalizes λ = μ·bo/E and v_par = 1−λB/bo, so this + # offset shifted the trapped/passing boundary and all bounce integrals (#269). + bo_axis = abs(equil.profiles.F_spline(0.0)) / (2π * equil.ro) KineticForcesInternal(; ro = equil.ro, - bo = equil.params.b0, + bo = bo_axis, chi1 = 2π * equil.psio, mthsurf, tpsi_xs = collect(range(0.0, 1.0, length=nth)), From 8c0721f154e4c00d918051e26eabdc34f8a69472 Mon Sep 17 00:00:00 2001 From: satelite2517 Date: Thu, 2 Jul 2026 14:03:05 +0900 Subject: [PATCH 03/10] KineticForces - BUG FIX - #269 port Fortran cubic-spline theta-quadrature into bounce integrals The bounce integrals (omega_b, omega_D, the pl phase cumulative, the action integral, and the matrix-path W_mu/W_E bounce averages) were trapezoid/Riemann sums. Fortran torque.F90 instead fits the samples on the uniform unit grid with spline_fit(...,"extrap") (C^2 cubic, 4-point Lagrange endpoint derivatives) and integrates the cubic exactly via spline_int. With the 1/sqrt(v_par) integrable singularities at the bounce points, the scheme difference was a leading-order systematic (dJdJ off by up to ~20% at intermediate trapped lambda). - Add _fortran_spline_derivs!/_fortran_spline_integral/_fortran_spline_cumint! porting spline_fac + spline_fit_ahg("extrap") + spline_int on a uniform grid (real and complex), verified against a dense solve to ~1e-15. - Restructure _bounce_integrate to sample arrays with Fortran's exact vpar<=0 zero-fill/hold-fill/backfill semantics (torque.F90:674-735), bug-for-bug including the wb->wd slot copy in the hold branch. - pl phase now uses the cumulative spline integral (bspl%fsi), not a running trapezoid; drop the spurious +eps in the 1/pl division (|pl|=1). - nlmda default 64 -> 128 on the torque path, matching the Fortran pentrc default and the matrix path. - fbnce lambda-interpolant BC ZeroCurvBC -> CubicFit, the analogue of Fortran cspline_fit(fbnce,'extrap'). With the companion bo axis-field fix, bounce-averaged wb/wd match Fortran to ~1e-4 and normalized dJdJ to <1% away from the trapped/ passing boundary; DIIID benchmark FGAR dW error 7.23% -> 2.94%. Co-Authored-By: Claude Fable 5 --- src/KineticForces/BounceAveraging.jl | 227 +++++++++++++++++++-------- src/KineticForces/Torque.jl | 11 +- 2 files changed, 167 insertions(+), 71 deletions(-) diff --git a/src/KineticForces/BounceAveraging.jl b/src/KineticForces/BounceAveraging.jl index 3a75b003b..439c83e6c 100644 --- a/src/KineticForces/BounceAveraging.jl +++ b/src/KineticForces/BounceAveraging.jl @@ -141,6 +141,94 @@ function _powspace_antideriv(x::Vector{Float64}, pow::Int) end +# ============================================================================ +# Fortran spline-quadrature helpers (uniform grid) +# ============================================================================ +# The Fortran bounce integrals are NOT Riemann/trapezoid sums: torque.F90 fits +# the θ-samples with equil/spline.f `spline_fit(...,"extrap")` (C² cubic with +# endpoint derivatives from a 4-point Lagrange fit) and integrates the fitted +# cubic exactly via `spline_int`: ∫ over interval = h/12·(6(f_i+f_{i+1}) + +# h·(d_i−d_{i+1})). The 1/√v_par integrand near bounce points makes the +# quadrature scheme a leading-order effect (trapezoid gave a systematic ~3.5% +# inflation of ∫J·B/√v_par in the trapped region → wb 3.5% low, dW off; see +# issue #269), so we reproduce the Fortran scheme exactly. + +""" + _fortran_spline_derivs!(d, f, h) + +Nodal first derivatives of the C² cubic spline through `f` on a uniform grid +of spacing `h`, with Fortran `"extrap"` endpoint conditions (derivative of the +cubic Lagrange polynomial through the first/last 4 points). Ports +`equil/spline.f` `spline_fit_ahg`/`spline_fac` specialized to a uniform grid. +Works for real and complex `f`. Requires `length(f) ≥ 4`. +""" +function _fortran_spline_derivs!(d::AbstractVector{T}, f::AbstractVector{T}, h::Float64) where {T} + n = length(f) + @assert length(d) == n && n >= 4 + # Endpoint derivatives: 4-point Lagrange (uniform grid): [-11/6, 3, -3/2, 1/3]/h + d[1] = (-11 * f[1] + 18 * f[2] - 9 * f[3] + 2 * f[4]) / (6h) + d[n] = (11 * f[n] - 18 * f[n-1] + 9 * f[n-2] - 2 * f[n-3]) / (6h) + # Interior: d[i-1] + 4·d[i] + d[i+1] = 3(f[i+1]-f[i-1])/h, i = 2..n-1, + # with known d[1], d[n] moved to the RHS. Thomas algorithm on (1,4,1). + # Sweep storage reuses d[2:n-1] for RHS. + cp = Vector{Float64}(undef, n - 2) # modified super-diagonal + @inbounds begin + rhs2 = 3 * (f[3] - f[1]) / h - d[1] + cp[1] = 1.0 / 4.0 + d[2] = rhs2 / 4.0 + for i in 3:n-1 + rhs = 3 * (f[i+1] - f[i-1]) / h + if i == n - 1 + rhs -= d[n] + end + denom = 4.0 - cp[i-2] + cp[i-1] = 1.0 / denom + d[i] = (rhs - d[i-1]) / denom + end + for i in n-2:-1:2 + d[i] -= cp[i-1] * d[i+1] + end + end + return d +end + +""" + _fortran_spline_integral(f, d, h) → total + +Exact integral of the cubic-Hermite spline with node values `f` and node +derivatives `d` on a uniform grid of spacing `h`. Ports `spline_int`: +per-interval `h/12·(6(f_i+f_{i+1}) + h·(d_i−d_{i+1}))`. +""" +function _fortran_spline_integral(f::AbstractVector{T}, d::AbstractVector{T}, h::Float64) where {T} + total = zero(T) + @inbounds for i in 1:length(f)-1 + total += (h / 12) * (6 * (f[i] + f[i+1]) + h * (d[i] - d[i+1])) + end + return total +end + +""" + _fortran_spline_cumint!(fsi, f, d, h) + +Cumulative nodal integrals (`fsi[i] = ∫₀^{x_i}` of the fitted spline), +matching Fortran `spline_int`'s `fsi` accumulation. `fsi[1] = 0`. +""" +function _fortran_spline_cumint!(fsi::AbstractVector{T}, f::AbstractVector{T}, d::AbstractVector{T}, h::Float64) where {T} + fsi[1] = zero(T) + @inbounds for i in 1:length(f)-1 + fsi[i+1] = fsi[i] + (h / 12) * (6 * (f[i] + f[i+1]) + h * (d[i] - d[i+1])) + end + return fsi +end + +"""Convenience: spline-fit ("extrap") + integrate samples `f` on a uniform grid.""" +function _fortran_spline_quad(f::AbstractVector{T}, h::Float64) where {T} + d = Vector{T}(undef, length(f)) + _fortran_spline_derivs!(d, f, h) + return _fortran_spline_integral(f, d, h) +end + + # ============================================================================ # Core bounce averaging # ============================================================================ @@ -149,7 +237,7 @@ end compute_bounce_data(psi, n, l, q, bo, bmax, bmin, ibmax, theta_bmax, tspl, mfac, chi1, ro, dbob_m_f, divx_m_f, divxfac, wdfac, mass, chrg, T_s, method; - nlmda=64, ntheta=128, + nlmda=128, ntheta=128, smat=nothing, tmat=nothing, xmat=nothing, ymat=nothing, zmat=nothing) → BounceData @@ -181,7 +269,7 @@ Ports Fortran torque.F90 lines 530-816 (GAR branch). - `method`: Method string (first char: f/t/p determines λ range) # Keyword Arguments -- `nlmda`: Number of pitch angle grid points (default 64) +- `nlmda`: Number of pitch angle grid points (default 128, matching Fortran pentrc nlmda) - `ntheta`: Number of poloidal grid points per bounce (default 128) - `smat, tmat, xmat, ymat, zmat`: Geometric matrices (mpert×mpert) for kinetic matrix path """ @@ -194,7 +282,7 @@ function compute_bounce_data( divxfac::Float64, wdfac::Float64, mass::Float64, chrg::Float64, T_s::Float64, method::String; - nlmda::Int=64, ntheta::Int=128, + nlmda::Int=128, ntheta::Int=128, smat::Union{Nothing,Matrix{ComplexF64}}=nothing, tmat::Union{Nothing,Matrix{ComplexF64}}=nothing, xmat::Union{Nothing,Matrix{ComplexF64}}=nothing, @@ -456,14 +544,12 @@ function _bounce_integrate( ntheta = length(tdt_pts) theta0 = tdt_pts[1] - # Cumulative bounce integrals - cum_wb = 0.0 - cum_wd = 0.0 - - # θ-scratch array allocated fresh each call. Pool-based reuse was tried - # (AdaptiveArrayPools) but showed no speedup and introduced severe slowdowns - # at 2+ threads; plain allocations match Fortran baseline behavior. - cum_wb_arr = zeros(Float64, ntheta) + # θ-sample arrays (Fortran bspl%fs/jvtheta; sample i ↔ Fortran node i-1). + # Allocated fresh each call. Pool-based reuse was tried (AdaptiveArrayPools) + # but showed no speedup and introduced severe slowdowns at 2+ threads; + # plain allocations match Fortran baseline behavior. + g_wb = zeros(Float64, ntheta) # bspl%fs(:,1): J·B/√v_par · dθ/dx + g_wd = zeros(Float64, ntheta) # bspl%fs(:,2): drift integrand · dθ/dx # Action integrand jvtheta = zeros(ComplexF64, ntheta) @@ -493,12 +579,23 @@ function _bounce_integrate( vpar = 1.0 - (lmda / bo) * B_val if vpar <= 0 - # Zero crossing near bounce points — handle like Fortran lines 678-697 + # Zero crossing near bounce points — Fortran torque.F90:678-697 fill + # semantics (sample i ↔ Fortran node i-1): if i < ntheta ÷ 2 - # Before midpoint: zero out previous entries + # Before midpoint: restart — zero everything up to and including + # this sample (Fortran bspl%fs(:i-1,:)=0; jvtheta(:i)=0). + fill!(view(g_wb, 1:i), 0.0) + fill!(view(g_wd, 1:i), 0.0) + fill!(view(jvtheta, 1:i), ComplexF64(0.0)) continue else - # After midpoint: clamp remaining entries + # After midpoint: hold the previous sample for the rest of the + # grid. Fortran fills BOTH quantities from fs(i-2,1) — the wb + # integrand — including the wd slot (bspl%fs(i-1:,2)=bspl%fs(i-2,1)); + # reproduced verbatim for parity. + fill!(view(g_wb, i:ntheta), g_wb[i-1]) + fill!(view(g_wd, i:ntheta), g_wb[i-1]) + fill!(view(jvtheta, i:ntheta), jvtheta[i-1]) break end end @@ -506,15 +603,9 @@ function _bounce_integrate( sqrt_vpar = sqrt(vpar) # Bounce integrands (Fortran lines 698-701) - wb_integrand = dt * jac * B_val / sqrt_vpar - wd_integrand = dt * jac * dBdpsi * (1.0 - 1.5 * lmda * B_val / bo) / sqrt_vpar + - dt * djdpsi * B_val * sqrt_vpar - - cum_wb += wb_integrand - cum_wd += wd_integrand - # Trapezoidal cumulative (matches Fortran spline_int semantics on linear fn): - # bspl%fsi(j)/Δx = g_1 + ... + g_{j-1} + g_j/2, so subtract half the current sample. - cum_wb_arr[i] = cum_wb - wb_integrand / 2 + g_wb[i] = dt * jac * B_val / sqrt_vpar + g_wd[i] = dt * jac * dBdpsi * (1.0 - 1.5 * lmda * B_val / bo) / sqrt_vpar + + dt * djdpsi * B_val * sqrt_vpar # Fourier modes at this θ (Fortran lines 702-708) — write into pre-allocated # expm buffer using the ORIGINAL expression order to preserve bit-level parity. @@ -548,15 +639,26 @@ function _bounce_integrate( wen_mt[mi, i] = wen_pre * expm[mi] / (B_val * sqrt_vpar) * phase / (2 * chi1) end end + + # Smooth backfill for points zeroed before a restart (Fortran lines 730-734, + # index ranges preserved verbatim: g fills samples 3..i-1, jv fills 2..i-1). + if i >= 3 && g_wb[i-1] == 0.0 + fill!(view(g_wb, 3:i-1), g_wb[i]) + fill!(view(g_wd, 3:i-1), g_wd[i]) + fill!(view(jvtheta, 2:i-1), jvtheta[i]) + end end - # Total bounce integrals — Fortran splines over bspl%xs = linspace(0,1,ntheta) - # and integrates via spline_int, which is ≈ (1/(ntheta-1)) × Σ f_i. The tdt(2,i) - # weights contain dθ/dx so Σ tdt·f is raw Riemann; divide by (ntheta-1) to get - # the integral over the unit linear space [0,1] that Fortran produces. - nrm = 1.0 / (ntheta - 1) - total_wb = cum_wb * nrm - total_wd = cum_wd * nrm + # Total bounce integrals — Fortran fits bspl%xs = linspace(0,1,ntheta) with + # spline_fit("extrap") and integrates the cubic exactly (spline_int). The + # tdt(2,i) weights contain dθ/dx so the samples live on the unit x-grid. + h = 1.0 / (ntheta - 1) + d_wb = Vector{Float64}(undef, ntheta) + _fortran_spline_derivs!(d_wb, g_wb, h) + fsi_wb = Vector{Float64}(undef, ntheta) + _fortran_spline_cumint!(fsi_wb, g_wb, d_wb, h) + total_wb = fsi_wb[ntheta] + total_wd = _fortran_spline_quad(g_wd, h) if total_wb ≈ 0.0 # Degenerate case — return zeros @@ -567,35 +669,22 @@ function _bounce_integrate( wbbar = ro * twopi / ((2 - sigma) * total_wb) wdbar = ro^2 * bo * wdfac * wbbar * 2 * (2 - sigma) * total_wd - # Phase factor (Fortran line 750). Ratio cum_wb_arr[i]/total_wb is dimensionless — - # (ntheta-1) cancels, no scaling. For do_matrices we keep `pl` as a Vector because - # it is referenced below; otherwise we fuse pl + bjspl → bj_integral in a single - # pass, avoiding two Vector{ComplexF64}(ntheta) allocations. - # Trapezoidal quadrature: boundary samples weighted by 0.5. jvtheta is zero at - # i=1 and i=ntheta (loop above runs 2:ntheta-1) so the boundary terms contribute - # nothing in practice, but writing the weights explicitly keeps the integration - # self-correct if the boundary handling ever changes. + # Phase factor (Fortran line 750): pl_i = exp(-2πi·lnq·h(θ_i)) with + # h = fsi_wb/((2-σ)·total_wb) the cumulative spline integral — NOT a + # running trapezoid — matching bspl%fsi exactly. pl_denom = (2 - sigma) * total_wb one_minus_sigma = 1 - sigma - bj_integral = ComplexF64(0.0) - if do_matrices - pl = Vector{ComplexF64}(undef, ntheta) - @inbounds for i in 1:ntheta - pl[i] = cis(-twopi * lnq * cum_wb_arr[i] * nrm / pl_denom) - end - @inbounds for i in 1:ntheta - w = (i == 1 || i == ntheta) ? 0.5 : 1.0 - bj_integral += w * conj(jvtheta[i]) * (pl[i] + one_minus_sigma / (pl[i] + SINGULAR_EPS)) - end - else - pl = nothing - @inbounds for i in 1:ntheta - pli = cis(-twopi * lnq * cum_wb_arr[i] * nrm / pl_denom) - w = (i == 1 || i == ntheta) ? 0.5 : 1.0 - bj_integral += w * conj(jvtheta[i]) * (pli + one_minus_sigma / (pli + SINGULAR_EPS)) - end + pl = Vector{ComplexF64}(undef, ntheta) + @inbounds for i in 1:ntheta + pl[i] = cis(-twopi * lnq * fsi_wb[i] / pl_denom) + end + + # Action bounce integral (Fortran bjspl: cspline_fit("extrap") + cspline_int). + bj_samples = Vector{ComplexF64}(undef, ntheta) + @inbounds for i in 1:ntheta + bj_samples[i] = conj(jvtheta[i]) * (pl[i] + one_minus_sigma / pl[i]) end - bj_integral *= nrm + bj_integral = _fortran_spline_quad(bj_samples, h) # |δJ|² (Fortran line 756) — division by 2 corrects quadratic form dJdJ_val = wbbar * abs(bj_integral)^2 / 2.0 / ro^2 @@ -603,21 +692,25 @@ function _bounce_integrate( # Matrix path: bounce-average W vectors and form outer products (Fortran lines 759-793) wmats_lmda = nothing if do_matrices - # Bounce-average W_μ and W_E vectors (Fortran lines 762-767). - # Trapezoidal quadrature: boundary samples weighted by 0.5 (wmu_mt and wen_mt - # are zero at i=1 and i=ntheta from the 2:ntheta-1 population loop above). + # Bounce-average W_μ and W_E vectors (Fortran lines 762-767): per mode, + # cspline_fit("extrap") + cspline_int of conj(W_m(θ))·(pl + (1-σ)/pl), + # matching the bjspl quadrature above. wmu_ba = zeros(ComplexF64, mpert) wen_ba = zeros(ComplexF64, mpert) - @inbounds for i in 1:ntheta - w = (i == 1 || i == ntheta) ? 0.5 : 1.0 - factor = w * (pl[i] + one_minus_sigma / (pl[i] + SINGULAR_EPS)) - for mi in 1:mpert - wmu_ba[mi] += conj(wmu_mt[mi, i]) * factor - wen_ba[mi] += conj(wen_mt[mi, i]) * factor + wsamp = Vector{ComplexF64}(undef, ntheta) + wder = Vector{ComplexF64}(undef, ntheta) + @inbounds for mi in 1:mpert + for i in 1:ntheta + wsamp[i] = conj(wmu_mt[mi, i]) * (pl[i] + one_minus_sigma / pl[i]) + end + _fortran_spline_derivs!(wder, wsamp, h) + wmu_ba[mi] = _fortran_spline_integral(wsamp, wder, h) + for i in 1:ntheta + wsamp[i] = conj(wen_mt[mi, i]) * (pl[i] + one_minus_sigma / pl[i]) end + _fortran_spline_derivs!(wder, wsamp, h) + wen_ba[mi] = _fortran_spline_integral(wsamp, wder, h) end - wmu_ba .*= nrm - wen_ba .*= nrm # Reshape as 1×mpert for matrix multiply (Fortran lines 771-772) wmmt = reshape(wmu_ba, 1, mpert) diff --git a/src/KineticForces/Torque.jl b/src/KineticForces/Torque.jl index 35d55a00f..06c57e6c4 100644 --- a/src/KineticForces/Torque.jl +++ b/src/KineticForces/Torque.jl @@ -422,7 +422,7 @@ function calculate_gar(psi, n, l, q, epsr, wdian, wdiat, welec, nuk, bo, ibmax::Int, theta_bmax::Float64, smat=nothing, tmat=nothing, xmat=nothing, ymat=nothing, zmat=nothing, - nlmda::Int=64, ntheta::Int=128, + nlmda::Int=128, ntheta::Int=128, nutype::String="harmonic", f0type::String="maxwellian", nufac::Float64=1.0, ximag::Float64=0.0, qt::Bool=false, energy_atol::Float64=1e-7, energy_rtol::Float64=1e-5, @@ -477,8 +477,10 @@ function calculate_gar(psi, n, l, q, epsr, wdian, wdiat, welec, nuk, bo, end end - # Build CubicSeriesInterpolant on normalized data (single build) - fbnce = cubic_interp(bounce.lambda, Series(fbnce_data); bc=ZeroCurvBC()) + # Build CubicSeriesInterpolant on normalized data (single build). + # CubicFit endpoint BC = 4-point polynomial fit, matching Fortran + # cspline_fit(fbnce,'extrap') endpoint-derivative extrapolation. + fbnce = cubic_interp(bounce.lambda, Series(fbnce_data); bc=CubicFit()) # 3. Set rex/imx multipliers (Fortran lines 839-847) method_suffix = length(method) >= 4 ? method[2:4] : "" @@ -808,7 +810,8 @@ function kinetic_energy_matrices_for_euler_lagrange!( end end - fbnce = cubic_interp(bounce.lambda, Series(fbnce_data); bc=ZeroCurvBC()) + # CubicFit endpoint BC matches Fortran cspline_fit(fbnce,'extrap'). + fbnce = cubic_interp(bounce.lambda, Series(fbnce_data); bc=CubicFit()) # Dual-output pitch integration: one energy sweep per (λ, E) produces both # halves. Packed return is [wmm | tmm], each length nqty. From 9eea561745839cf4b1c6b7fd6a755450f6f42d27 Mon Sep 17 00:00:00 2001 From: satelite2517 Date: Thu, 2 Jul 2026 14:03:23 +0900 Subject: [PATCH 04/10] KineticForces - MINOR - benchmark: drop stale thmax0 key, tolerate fgar-only references The generated gpec.toml carried thmax0, which ForceFreeStatesControl on this branch does not accept, so the benchmark failed at startup. Also read missing T/dW_total_* reference attributes as NaN so an fgar-only Fortran pentrc reference still benchmarks the fgar method. Co-Authored-By: Claude Fable 5 --- benchmarks/benchmark_diiid_ideal_ntv_torque.jl | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/benchmarks/benchmark_diiid_ideal_ntv_torque.jl b/benchmarks/benchmark_diiid_ideal_ntv_torque.jl index 80465daa3..979621fa9 100644 --- a/benchmarks/benchmark_diiid_ideal_ntv_torque.jl +++ b/benchmarks/benchmark_diiid_ideal_ntv_torque.jl @@ -77,11 +77,13 @@ Read `T_total_*` and `dW_total_*` scalar reference values from the Fortran function read_pentrc_reference(pentrc_nc::String) NCDatasets.Dataset(pentrc_nc, "r") do ds gatt = ds.attrib + # Reference runs may enable only a subset of methods (e.g. fgar only); missing attributes → NaN. + att(k) = haskey(gatt, k) ? Float64(gatt[k]) : NaN return ( - T_total_fgar=Float64(gatt["T_total_fgar"]), - T_total_tgar=Float64(gatt["T_total_tgar"]), - dW_total_fgar=Float64(gatt["dW_total_fgar"]), - dW_total_tgar=Float64(gatt["dW_total_tgar"]) + T_total_fgar=att("T_total_fgar"), + T_total_tgar=att("T_total_tgar"), + dW_total_fgar=att("dW_total_fgar"), + dW_total_tgar=att("dW_total_tgar") ) end end @@ -142,7 +144,6 @@ nn_high = 1 delta_mlow = 8 delta_mhigh = 8 mthvac = 512 -thmax0 = 1 kinetic_source = "fixed" kinetic_factor = 0.0 From 942ba2a6182d958d4b35a91ba1f217fc396f3c8f Mon Sep 17 00:00:00 2001 From: satelite2517 Date: Thu, 2 Jul 2026 23:14:29 +0900 Subject: [PATCH 05/10] KineticForces - BUG FIX - #269 match Fortran vspl: extrap v_par, knot transit start, complete descending roots Three residual bounce-averaging deviations from Fortran torque.F90, found by adversarial line-level verification after the bo/theta-quadrature fixes: 1. v_par source: Fortran builds a SEPARATE spline vspl = 1-(lambda/bo)*B fit "extrap" and takes both the 1/sqrt(v_par) factor and the bounce-point roots from it (torque.F90:599-602, :677 "more consistent w/ bnce pts than direct from tspl"). Julia computed v_par directly from the periodic tspl - exactly the path that comment avoids. The difference is purely the theta-interp end condition (extrap vs periodic): identical at nodes, divergent off-node near the theta=0/1 seam and at turning points where 1/sqrt(v_par) amplifies it. Fix: build B_extrap = cubic_interp(xs, B_vals; bc=CubicFit()) once per surface and evaluate v_par = 1-(lambda/bo)*B_extrap(theta) everywhere (valid because the extrap spline operator is affine in the nodal data, so the per-lambda vspl equals the affine image of a single lambda-independent B cubic). Numerator fields (B, J, dB/dpsi, dJ/dpsi) still come from the periodic tspl, matching Fortran. 2. Transit start: the passing and no-root branches used the refined dB/dtheta=0 extremum for t1/t2; Fortran uses the nodal-argmax knot tspl%xs(ibmax) (torque.F90:610-611, :648-649) - the refined extremum is diagnostic-only. The extrema loops keep refining the bmax/bmin VALUES (and theta_bmin, which Fortran does use), but no longer move theta_bmax. 3. Bounce-point roots: the fixed 256-point sign-change scan could miss a near-marginal root pair that Fortran's analytic per-interval spline_roots resolves, and returned roots ascending while spline_roots returns them descending (spline.f:1782-1785) - an ordering the deepest-well wrap logic is sensitive to for >=3 roots. Fix: Roots.find_zeros on the same B_extrap cubic, sorted descending; _bisect_vpar deleted. DIIID ideal-NTV benchmark vs fresh Fortran reference (with the companion chi1/bo/theta-quadrature fixes on this branch): FGAR dW err 7.23% -> 3.04%, T 2.66% -> 1.28%; TGAR dW 4.60% -> 4.54%, T 1.71% -> 1.65% - all four within the 5% acceptance (issue #269 reported 16.94%/18.70%). Kinetic-DCON eigenvalue unregressed: et1 = 1.046386-0.302321i vs Fortran 1.050683-0.301242i (0.41%/0.36%). Also tested and ruled out the issue's 083b0cc0 hypothesis: the theta-endpoint snap is a 3-ulp data correction whose measured effect on the interpolants is 3.1e-15 relative. Co-Authored-By: Claude Fable 5 --- src/KineticForces/BounceAveraging.jl | 108 ++++++++++----------------- src/KineticForces/Torque.jl | 23 ++++-- 2 files changed, 55 insertions(+), 76 deletions(-) diff --git a/src/KineticForces/BounceAveraging.jl b/src/KineticForces/BounceAveraging.jl index 439c83e6c..1803c0564 100644 --- a/src/KineticForces/BounceAveraging.jl +++ b/src/KineticForces/BounceAveraging.jl @@ -235,7 +235,7 @@ end """ compute_bounce_data(psi, n, l, q, bo, bmax, bmin, ibmax, theta_bmax, - tspl, mfac, chi1, ro, dbob_m_f, divx_m_f, + tspl, B_extrap, mfac, chi1, ro, dbob_m_f, divx_m_f, divxfac, wdfac, mass, chrg, T_s, method; nlmda=128, ntheta=128, smat=nothing, tmat=nothing, xmat=nothing, @@ -277,7 +277,7 @@ function compute_bounce_data( psi::Float64, n::Int, l::Int, q::Float64, bo::Float64, bmax::Float64, bmin::Float64, ibmax::Int, theta_bmax::Float64, - tspl, mfac::Vector{Int}, chi1::Float64, ro::Float64, + tspl, B_extrap, mfac::Vector{Int}, chi1::Float64, ro::Float64, dbob_m_f::Vector{ComplexF64}, divx_m_f::Vector{ComplexF64}, divxfac::Float64, wdfac::Float64, mass::Float64, chrg::Float64, @@ -325,13 +325,13 @@ function compute_bounce_data( # Find bounce points and build θ sub-grid _, _, tdt_pts, tdt_wts = _find_bounce_points_and_grid( - lmda, bo, sigma, tspl, ibmax, theta_bmax, + lmda, bo, sigma, B_extrap, ibmax, theta_bmax, lmdatpb, lmdamax, psi, ntheta) # Bounce integrals over θ (Fortran lines 674-735) wbbar, wdbar, dJdJ_val, wmats_lmda = _bounce_integrate( tdt_pts, tdt_wts, lmda, lnq, sigma, n, q, bo, - tspl, chi1, ro, mfac, dbob_m_f, divx_m_f, divxfac, wdfac, + tspl, B_extrap, chi1, ro, mfac, dbob_m_f, divx_m_f, divxfac, wdfac, do_matrices, mpert, smat, tmat, xmat, ymat, zmat) # Physical frequencies (Fortran lines 744-745) @@ -389,32 +389,42 @@ function _build_lambda_grid(method_char::Char, lmdatpb::Float64, lmdamax::Float6 end +""" + _vpar_from_extrap(B_extrap, lmda, bo, θ) → v_par + +Parallel-velocity factor `v_par = 1 − (λ/bo)·B(θ)` evaluated from the **extrap** +cubic of B (`B_extrap`), matching Fortran's separate `vspl` (torque.F90:599-600, +`vpar = vspl%f(1)` at :677 — "more consistent w/ bnce pts than direct from tspl"). +Because the "extrap" endpoint derivatives are linear in the nodal data, the extrap +cubic of `1−(λ/bo)B` equals `1−(λ/bo)·(extrap cubic of B)`, so we build `B_extrap` +once per surface (CubicFit ≡ Fortran extrap) and reuse it for the vpar factor, the +bounce-point roots, and the deepest-well test — NOT the periodic `tspl`. +""" +@inline _vpar_from_extrap(B_extrap, lmda::Float64, bo::Float64, θ::Float64) = + 1.0 - (lmda / bo) * B_extrap(mod(θ, 1.0)) + + """ Find bounce points for trapped/passing particles and build θ sub-grid. Returns (t1, t2, theta_points, theta_weights). """ function _find_bounce_points_and_grid( lmda::Float64, bo::Float64, sigma::Int, - tspl, ::Int, theta_bmax::Float64, + B_extrap, ::Int, theta_bmax::Float64, ::Float64, ::Float64, psi::Float64, ntheta::Int ) if sigma == 0 # trapped - # Build v_par(θ) = 1 - (λ/bo)*B(θ) and find roots - # Use a dense θ grid to find zero crossings - nfine = 256 - theta_fine = range(0.0, 1.0, length=nfine+1) - vpar_fine = [1.0 - (lmda / bo) * tspl(mod(θ, 1.0))[1] for θ in theta_fine] - - # Find zero crossings - bpts = Float64[] - for i in 1:nfine - if vpar_fine[i] * vpar_fine[i+1] < 0 - # Bisect for better accuracy - θ_root = _bisect_vpar(tspl, lmda, bo, theta_fine[i], theta_fine[i+1]) - push!(bpts, θ_root) - end - end + # Bounce points = roots of v_par(θ) = 1 − (λ/bo)·B_extrap(θ) in (0,1). + # Fortran fits vspl "extrap" and calls spline_roots (torque.F90:600-602), which + # solves each equilibrium interval's cubic analytically. Roots.find_zeros + # adaptively finds ALL roots of the SAME extrap cubic (B_extrap), so the root + # values match spline_roots (robust to the near-boundary interior dip a fixed + # sign-change scan could miss). Sorted DESCENDING to match spline_roots order + # (spline.f:1782-1785), which the marginally-trapped and deepest-well wrap + # logic below assume. + vpar_fn = θ -> _vpar_from_extrap(B_extrap, lmda, bo, θ) + bpts = sort!(Roots.find_zeros(vpar_fn, 0.0, 1.0); rev=true) nbpts = length(bpts) if nbpts < 1 @@ -427,7 +437,7 @@ function _find_bounce_points_and_grid( t2 = bpts[1] + 1.0 else # Find deepest potential well (Fortran lines 616-639) - t1, t2 = _find_deepest_well(bpts, tspl, lmda, bo) + t1, t2 = _find_deepest_well(bpts, B_extrap, lmda, bo) end # Power-law grid refined near bounce points @@ -443,56 +453,11 @@ function _find_bounce_points_and_grid( end -""" - _bisect_vpar(tspl, lmda, bo, θa, θb; tol=1e-12, maxiter=50) → θ - -Bisect on `θ ∈ [θa, θb]` to find a bounce point — the angle where the parallel -velocity vanishes, `v_par(θ) = 1 - (λ/bo) · B(θ) = 0`. The caller is responsible -for supplying a bracket where `v_par(θa)` and `v_par(θb)` have opposite signs; -this routine does not check, it just halves toward the sign change. - -# Arguments -- `tspl`: 1D θ-interpolant returning at least `[B(θ), …]` at first index; - `tspl(mod(θ, 1.0))[1]` extracts the local field magnitude. -- `lmda`: pitch-angle parameter λ = μ·bo / E. -- `bo`: on-axis toroidal field used to normalise λ. -- `θa, θb`: bracket endpoints (normalised θ ∈ [0,1)); typically straddling a B-peak. - -# Keyword arguments -- `tol`: termination tolerance — exits when either `|v_par(θ_mid)| < tol` or the - bracket width `θb − θa < tol`. Default `1e-12` is tight enough that the - residual `v_par` is dominated by the spline-evaluation roundoff of `tspl`. -- `maxiter`: hard iteration cap. Default `50` halves the bracket by ~10⁻¹⁵, well - past `tol` on a unit-scale bracket; the cap is a runaway guard rather than the - expected exit. On exhaustion the midpoint of the final bracket is returned. - -# Returns -- `θ::Float64`: the converged (or capped) bounce-point angle. -""" -function _bisect_vpar(tspl, lmda::Float64, bo::Float64, θa::Float64, θb::Float64; tol=1e-12, maxiter=50) - va = 1.0 - (lmda / bo) * tspl(mod(θa, 1.0))[1] - for _ in 1:maxiter - θm = 0.5 * (θa + θb) - vm = 1.0 - (lmda / bo) * tspl(mod(θm, 1.0))[1] - if abs(vm) < tol || (θb - θa) < tol - return θm - end - if va * vm < 0 - θb = θm - else - θa = θm - va = vm - end - end - return 0.5 * (θa + θb) -end - - """ Find deepest potential well among bounce point pairs. Ports Fortran lines 616-639. """ -function _find_deepest_well(bpts::Vector{Float64}, tspl, lmda::Float64, bo::Float64) +function _find_deepest_well(bpts::Vector{Float64}, B_extrap, lmda::Float64, bo::Float64) nbpts = length(bpts) best_vpar = 0.0 best_t1 = 0.0 @@ -506,7 +471,7 @@ function _find_deepest_well(bpts::Vector{Float64}, tspl, lmda::Float64, bo::Floa else θmid = 0.5 * (bpts[i] + bpts[j]) end - vpar_mid = 1.0 - (lmda / bo) * tspl(mod(θmid, 1.0))[1] + vpar_mid = _vpar_from_extrap(B_extrap, lmda, bo, θmid) if vpar_mid > best_vpar best_t1 = bpts[i] best_t2 = bpts[j] @@ -535,7 +500,7 @@ Ports Fortran torque.F90 lines 674-793. function _bounce_integrate( tdt_pts::Vector{Float64}, tdt_wts::Vector{Float64}, lmda::Float64, lnq::Float64, sigma::Int, n::Int, q::Float64, bo::Float64, - tspl, chi1::Float64, ro::Float64, + tspl, B_extrap, chi1::Float64, ro::Float64, mfac::Vector{Int}, dbob_m_f::Vector{ComplexF64}, divx_m_f::Vector{ComplexF64}, divxfac::Float64, wdfac::Float64, do_matrices::Bool, mpert::Int, @@ -576,7 +541,10 @@ function _bounce_integrate( jac = tspl_f[4] djdpsi = tspl_f[5] - vpar = 1.0 - (lmda / bo) * B_val + # vpar from the extrap cubic of B (Fortran vspl, torque.F90:677), NOT the + # periodic tspl B_val — which stays as the numerator field below (Fortran + # bspl uses tspl%f(1)/(4) for the numerator, vspl%f(1) only for 1/√vpar). + vpar = 1.0 - (lmda / bo) * B_extrap(θmod) if vpar <= 0 # Zero crossing near bounce points — Fortran torque.F90:678-697 fill diff --git a/src/KineticForces/Torque.jl b/src/KineticForces/Torque.jl index 06c57e6c4..671e70716 100644 --- a/src/KineticForces/Torque.jl +++ b/src/KineticForces/Torque.jl @@ -100,6 +100,10 @@ function tpsi!(tpsi_var::Ref{ComplexF64}, psi::Float64, n::Int, l::Int, # Create periodic interpolant for poloidal quantities tspl = cubic_interp(xs, Series(hcat(B_vals, dBdpsi_vals, dBdtheta_vals, jac_vals, djdpsi_vals)); bc=PeriodicBC()) + # Extrap cubic of B for the v_par factor + bounce points (Fortran vspl, fit + # "extrap"); CubicFit ≡ Fortran extrap. Kept separate from the periodic tspl + # so v_par matches Fortran torque.F90:599-600,677 exactly (see _vpar_from_extrap). + B_extrap = cubic_interp(xs, B_vals; bc=CubicFit()) bmax = maximum(B_vals) ibmax = argmax(B_vals) @@ -144,7 +148,9 @@ function tpsi!(tpsi_var::Ref{ComplexF64}, psi::Float64, n::Int, l::Int, end if Bθ > bmax bmax = Bθ - theta_bmax = θn + # theta_bmax intentionally NOT refined: Fortran uses the nodal-argmax + # knot xs[ibmax] as the transit start t1/t2 (torque.F90:610-611,648-649); + # the refined extremum (:301) is diagnostic-only. Keep theta_bmax = xs[ibmax]. end end @@ -241,6 +247,7 @@ function tpsi!(tpsi_var::Ref{ComplexF64}, psi::Float64, n::Int, l::Int, method, op_wmats; chi1=intr.chi1, ro=intr.ro, mfac=intr.mfac, mpert=intr.mpert, ibmax=ibmax, theta_bmax=theta_bmax, + B_extrap=B_extrap, smat=smat_f, tmat=tmat_f, xmat=xmat_f, ymat=ymat_f, zmat=zmat_f, energy_atol=atol_xlmda, energy_rtol=rtol_xlmda, @@ -419,7 +426,7 @@ function calculate_gar(psi, n, l, q, epsr, wdian, wdiat, welec, nuk, bo, bmax, bmin, n_s::Float64, T_s::Float64, mass, chrg, tspl, dbob_m_f, divx_m_f, divxfac, wdfac, method, op_wmats; chi1::Float64, ro::Float64, mfac::Vector{Int}, mpert::Int, - ibmax::Int, theta_bmax::Float64, + ibmax::Int, theta_bmax::Float64, B_extrap, smat=nothing, tmat=nothing, xmat=nothing, ymat=nothing, zmat=nothing, nlmda::Int=128, ntheta::Int=128, @@ -433,7 +440,7 @@ function calculate_gar(psi, n, l, q, epsr, wdian, wdiat, welec, nuk, bo, # 1. Compute bounce-averaged quantities bounce = compute_bounce_data( psi, n, l, q, bo, bmax, bmin, ibmax, theta_bmax, - tspl, mfac, chi1, ro, dbob_m_f, divx_m_f, divxfac, wdfac, + tspl, B_extrap, mfac, chi1, ro, dbob_m_f, divx_m_f, divxfac, wdfac, mass, chrg, T_s, method; nlmda, ntheta, smat, tmat, xmat, ymat, zmat) @@ -638,6 +645,8 @@ function _setup_surface_state( end tspl = cubic_interp(xs, Series(hcat(B_vals, dBdpsi_vals, dBdtheta_vals, jac_vals, djdpsi_vals)); bc=PeriodicBC()) + # Extrap cubic of B for v_par + bounce points (Fortran vspl "extrap"); see _vpar_from_extrap. + B_extrap = cubic_interp(xs, B_vals; bc=CubicFit()) bmax = maximum(B_vals) ibmax = argmax(B_vals) @@ -671,7 +680,9 @@ function _setup_surface_state( end if Bθ > bmax bmax = Bθ - theta_bmax = θn + # theta_bmax intentionally NOT refined: Fortran uses the nodal-argmax + # knot xs[ibmax] for t1/t2 (torque.F90:610-611,648-649); refined extremum + # is diagnostic-only. Keep theta_bmax = xs[ibmax]. end end end @@ -708,7 +719,7 @@ function _setup_surface_state( return (; chrg, mass, - tspl, bmax, bmin, ibmax, theta_bmax, + tspl, B_extrap, bmax, bmin, ibmax, theta_bmax, q, n_s, T_s, welec, wdian, wdiat, wtran, wgyro, nuk, epsr, @@ -773,7 +784,7 @@ function kinetic_energy_matrices_for_euler_lagrange!( bounce = compute_bounce_data( psi, n, l, state.q, bo, state.bmax, state.bmin, state.ibmax, state.theta_bmax, - state.tspl, mfac, chi1, ro, dbob_m_f, divx_m_f, 1.0, wdfac, + state.tspl, state.B_extrap, mfac, chi1, ro, dbob_m_f, divx_m_f, 1.0, wdfac, state.mass, state.chrg, state.T_s, "fwmm"; nlmda, ntheta, smat=smat_f, tmat=tmat_f, xmat=xmat_f, ymat=ymat_f, zmat=zmat_f) From fe346d4481c4271bbf158bedb70274eafe6ab27b Mon Sep 17 00:00:00 2001 From: logan-nc <6198372+logan-nc@users.noreply.github.com> Date: Thu, 9 Jul 2026 09:39:59 -0400 Subject: [PATCH 06/10] KineticForces - OPTIMIZATION - preallocate bounce quadrature buffers in BounceScratch All per-lambda spline-quadrature buffers (integrand samples, derivative and Thomas workspaces) now live in the per-surface BounceScratch, restoring the zero-allocation hot loop. Also drops dead placeholder args from _find_bounce_points_and_grid and renames a loop index that shadowed q. Co-Authored-By: Claude Fable 5 --- src/KineticForces/BounceAveraging.jl | 85 ++++++++++++++++------------ 1 file changed, 49 insertions(+), 36 deletions(-) diff --git a/src/KineticForces/BounceAveraging.jl b/src/KineticForces/BounceAveraging.jl index a35ce4fd1..e59f44fa6 100644 --- a/src/KineticForces/BounceAveraging.jl +++ b/src/KineticForces/BounceAveraging.jl @@ -154,24 +154,22 @@ end # issue #269), so we reproduce the Fortran scheme exactly. """ - _fortran_spline_derivs!(d, f, h) + _fortran_spline_derivs!(d, f, h, cp) Nodal first derivatives of the C² cubic spline through `f` on a uniform grid -of spacing `h`, with Fortran `"extrap"` endpoint conditions (derivative of the -cubic Lagrange polynomial through the first/last 4 points). Ports -`equil/spline.f` `spline_fit_ahg`/`spline_fac` specialized to a uniform grid. -Works for real and complex `f`. Requires `length(f) ≥ 4`. +of spacing `h`, with endpoint derivatives from a 4-point Lagrange fit (the +`CubicFit` boundary condition; same fit as the original Fortran spline package's +"extrap" option). Works for real and complex `f`. Requires `length(f) ≥ 4`. +`cp` is a Float64 workspace of length ≥ `length(f) - 2` (Thomas sweep storage). """ -function _fortran_spline_derivs!(d::AbstractVector{T}, f::AbstractVector{T}, h::Float64) where {T} +function _fortran_spline_derivs!(d::AbstractVector{T}, f::AbstractVector{T}, h::Float64, cp::Vector{Float64}) where {T} n = length(f) - @assert length(d) == n && n >= 4 + @assert length(d) == n && n >= 4 && length(cp) >= n - 2 # Endpoint derivatives: 4-point Lagrange (uniform grid): [-11/6, 3, -3/2, 1/3]/h d[1] = (-11 * f[1] + 18 * f[2] - 9 * f[3] + 2 * f[4]) / (6h) d[n] = (11 * f[n] - 18 * f[n-1] + 9 * f[n-2] - 2 * f[n-3]) / (6h) # Interior: d[i-1] + 4·d[i] + d[i+1] = 3(f[i+1]-f[i-1])/h, i = 2..n-1, # with known d[1], d[n] moved to the RHS. Thomas algorithm on (1,4,1). - # Sweep storage reuses d[2:n-1] for RHS. - cp = Vector{Float64}(undef, n - 2) # modified super-diagonal @inbounds begin rhs2 = 3 * (f[3] - f[1]) / h - d[1] cp[1] = 1.0 / 4.0 @@ -221,10 +219,9 @@ function _fortran_spline_cumint!(fsi::AbstractVector{T}, f::AbstractVector{T}, d return fsi end -"""Convenience: spline-fit ("extrap") + integrate samples `f` on a uniform grid.""" -function _fortran_spline_quad(f::AbstractVector{T}, h::Float64) where {T} - d = Vector{T}(undef, length(f)) - _fortran_spline_derivs!(d, f, h) +"""Convenience: spline-fit + exact integral of samples `f` on a uniform grid, using workspaces `d` and `cp`.""" +function _fortran_spline_quad(f::AbstractVector{T}, h::Float64, d::AbstractVector{T}, cp::Vector{Float64}) where {T} + _fortran_spline_derivs!(d, f, h, cp) return _fortran_spline_integral(f, d, h) end @@ -328,8 +325,7 @@ function compute_bounce_data( # Find bounce points and build θ sub-grid _, _, tdt_pts, tdt_wts = _find_bounce_points_and_grid( - lmda, bo, sigma, B_extrap, ibmax, theta_bmax, - lmdatpb, lmdamax, psi, ntheta) + lmda, bo, sigma, B_extrap, theta_bmax, psi, ntheta) # Bounce integrals over θ (Fortran lines 674-735) wbbar, wdbar, dJdJ_val, wmats_lmda = _bounce_integrate( @@ -343,8 +339,8 @@ function compute_bounce_data( dJdJ_arr[ilmda] = dJdJ_val if do_matrices && !isnothing(wmats_lmda) - @inbounds for q in 1:length(wmats_lmda) - wmats_arr[ilmda, q] = wmats_lmda[q] + @inbounds for iq in eachindex(wmats_lmda) + wmats_arr[ilmda, iq] = wmats_lmda[iq] end end end @@ -364,8 +360,15 @@ Per-surface scratch buffers for the bounce-averaging inner loops. Allocated once the callers zero-initialize are `fill!`-reset per λ, preserving bit-for-bit results. ## Fields +- `g_wb::Vector{Float64}`: length `ntheta` — bounce-action integrand samples +- `g_wd::Vector{Float64}`: length `ntheta` — drift integrand samples - `cum_wb_arr::Vector{Float64}`: length `ntheta` — cumulative bounce action +- `d_re::Vector{Float64}`: length `ntheta` — real spline-derivative workspace +- `cp::Vector{Float64}`: length `ntheta` — Thomas-sweep workspace - `jvtheta::Vector{ComplexF64}`: length `ntheta` — action integrand +- `bj_samples::Vector{ComplexF64}`: length `ntheta` — action bounce-integral samples +- `d_c::Vector{ComplexF64}`: length `ntheta` — complex spline-derivative workspace +- `wsamp::Vector{ComplexF64}`: length `ntheta` — per-mode W quadrature samples - `wmu_mt::Matrix{ComplexF64}`: `mpert × ntheta` — W_μ per θ - `wen_mt::Matrix{ComplexF64}`: `mpert × ntheta` — W_E per θ - `expm::Vector{ComplexF64}`: length `mpert` — Fourier basis at a θ @@ -376,8 +379,15 @@ the callers zero-initialize are `fill!`-reset per λ, preserving bit-for-bit res - `tspl_f::Vector{Float64}`: length 5 — in-place tspl(θ) evaluation """ struct BounceScratch + g_wb::Vector{Float64} + g_wd::Vector{Float64} cum_wb_arr::Vector{Float64} + d_re::Vector{Float64} + cp::Vector{Float64} jvtheta::Vector{ComplexF64} + bj_samples::Vector{ComplexF64} + d_c::Vector{ComplexF64} + wsamp::Vector{ComplexF64} wmu_mt::Matrix{ComplexF64} wen_mt::Matrix{ComplexF64} expm::Vector{ComplexF64} @@ -391,6 +401,13 @@ end function BounceScratch(ntheta::Int, mpert::Int) return BounceScratch( Vector{Float64}(undef, ntheta), + Vector{Float64}(undef, ntheta), + Vector{Float64}(undef, ntheta), + Vector{Float64}(undef, ntheta), + Vector{Float64}(undef, ntheta), + Vector{ComplexF64}(undef, ntheta), + Vector{ComplexF64}(undef, ntheta), + Vector{ComplexF64}(undef, ntheta), Vector{ComplexF64}(undef, ntheta), Matrix{ComplexF64}(undef, mpert, ntheta), Matrix{ComplexF64}(undef, mpert, ntheta), @@ -463,8 +480,7 @@ Returns (t1, t2, theta_points, theta_weights). """ function _find_bounce_points_and_grid( lmda::Float64, bo::Float64, sigma::Int, - B_extrap, ::Int, theta_bmax::Float64, - ::Float64, ::Float64, psi::Float64, + B_extrap, theta_bmax::Float64, psi::Float64, ntheta::Int ) if sigma == 0 # trapped @@ -562,12 +578,12 @@ function _bounce_integrate( ntheta = length(tdt_pts) theta0 = tdt_pts[1] - # θ-sample arrays (Fortran bspl%fs/jvtheta; sample i ↔ Fortran node i-1). - # Allocated fresh each call. Pool-based reuse was tried (AdaptiveArrayPools) - # but showed no speedup and introduced severe slowdowns at 2+ threads; - # plain allocations match Fortran baseline behavior. - g_wb = zeros(Float64, ntheta) # bspl%fs(:,1): J·B/√v_par · dθ/dx - g_wd = zeros(Float64, ntheta) # bspl%fs(:,2): drift integrand · dθ/dx + # θ-sample integrands (zero-reset per λ: the loop populates 2:ntheta-1 with + # continue/break paths that rely on unwritten entries staying 0) + g_wb = scr.g_wb # J·B/√v_par · dθ/dx + g_wd = scr.g_wd # drift integrand · dθ/dx + fill!(g_wb, 0.0) + fill!(g_wd, 0.0) # Action integrand jvtheta = scr.jvtheta @@ -677,12 +693,12 @@ function _bounce_integrate( # spline_fit("extrap") and integrates the cubic exactly (spline_int). The # tdt(2,i) weights contain dθ/dx so the samples live on the unit x-grid. h = 1.0 / (ntheta - 1) - d_wb = Vector{Float64}(undef, ntheta) - _fortran_spline_derivs!(d_wb, g_wb, h) + d_wb = scr.d_re + _fortran_spline_derivs!(d_wb, g_wb, h, scr.cp) fsi_wb = scr.cum_wb_arr # per-surface scratch; fully overwritten by cumint _fortran_spline_cumint!(fsi_wb, g_wb, d_wb, h) total_wb = fsi_wb[ntheta] - total_wd = _fortran_spline_quad(g_wd, h) + total_wd = _fortran_spline_quad(g_wd, h, scr.d_re, scr.cp) if total_wb ≈ 0.0 # Degenerate case — return zeros @@ -704,11 +720,11 @@ function _bounce_integrate( end # Action bounce integral (Fortran bjspl: cspline_fit("extrap") + cspline_int). - bj_samples = Vector{ComplexF64}(undef, ntheta) + bj_samples = scr.bj_samples @inbounds for i in 1:ntheta bj_samples[i] = conj(jvtheta[i]) * (pl[i] + one_minus_sigma / pl[i]) end - bj_integral = _fortran_spline_quad(bj_samples, h) + bj_integral = _fortran_spline_quad(bj_samples, h, scr.d_c, scr.cp) # |δJ|² (Fortran line 756) — division by 2 corrects quadratic form dJdJ_val = wbbar * abs(bj_integral)^2 / 2.0 / ro^2 @@ -722,19 +738,16 @@ function _bounce_integrate( # fully overwritten per mode. wmu_ba = scr.wmu_ba wen_ba = scr.wen_ba - wsamp = Vector{ComplexF64}(undef, ntheta) - wder = Vector{ComplexF64}(undef, ntheta) + wsamp = scr.wsamp @inbounds for mi in 1:mpert for i in 1:ntheta wsamp[i] = conj(wmu_mt[mi, i]) * (pl[i] + one_minus_sigma / pl[i]) end - _fortran_spline_derivs!(wder, wsamp, h) - wmu_ba[mi] = _fortran_spline_integral(wsamp, wder, h) + wmu_ba[mi] = _fortran_spline_quad(wsamp, h, scr.d_c, scr.cp) for i in 1:ntheta wsamp[i] = conj(wen_mt[mi, i]) * (pl[i] + one_minus_sigma / pl[i]) end - _fortran_spline_derivs!(wder, wsamp, h) - wen_ba[mi] = _fortran_spline_integral(wsamp, wder, h) + wen_ba[mi] = _fortran_spline_quad(wsamp, h, scr.d_c, scr.cp) end # Reshape as 1×mpert for matrix multiply (Fortran lines 771-772) From 1e4626515bfd5cbd0871b0fdece7e495ce248d15 Mon Sep 17 00:00:00 2001 From: logan-nc <6198372+logan-nc@users.noreply.github.com> Date: Thu, 9 Jul 2026 09:45:38 -0400 Subject: [PATCH 07/10] KineticForces - REFACTOR - bounce quadrature via FastInterpolations in-place refit Replaces the custom uniform-grid spline helpers with reusable CubicFit interpolants held in BounceScratch: per lambda the y data is overwritten and the z coefficients refit via FastInterpolations._solve_system!, then integrated with the full-domain integrate/cumulative_integrate! fast path (zero allocation). Also drops the now-unused ibmax argument from compute_bounce_data. Co-Authored-By: Claude Fable 5 --- src/KineticForces/BounceAveraging.jl | 147 +++++++-------------------- src/KineticForces/Torque.jl | 10 +- 2 files changed, 41 insertions(+), 116 deletions(-) diff --git a/src/KineticForces/BounceAveraging.jl b/src/KineticForces/BounceAveraging.jl index e59f44fa6..e88587e38 100644 --- a/src/KineticForces/BounceAveraging.jl +++ b/src/KineticForces/BounceAveraging.jl @@ -141,97 +141,12 @@ function _powspace_antideriv(x::Vector{Float64}, pow::Int) end -# ============================================================================ -# Fortran spline-quadrature helpers (uniform grid) -# ============================================================================ -# The Fortran bounce integrals are NOT Riemann/trapezoid sums: torque.F90 fits -# the θ-samples with equil/spline.f `spline_fit(...,"extrap")` (C² cubic with -# endpoint derivatives from a 4-point Lagrange fit) and integrates the fitted -# cubic exactly via `spline_int`: ∫ over interval = h/12·(6(f_i+f_{i+1}) + -# h·(d_i−d_{i+1})). The 1/√v_par integrand near bounce points makes the -# quadrature scheme a leading-order effect (trapezoid gave a systematic ~3.5% -# inflation of ∫J·B/√v_par in the trapped region → wb 3.5% low, dW off; see -# issue #269), so we reproduce the Fortran scheme exactly. - -""" - _fortran_spline_derivs!(d, f, h, cp) - -Nodal first derivatives of the C² cubic spline through `f` on a uniform grid -of spacing `h`, with endpoint derivatives from a 4-point Lagrange fit (the -`CubicFit` boundary condition; same fit as the original Fortran spline package's -"extrap" option). Works for real and complex `f`. Requires `length(f) ≥ 4`. -`cp` is a Float64 workspace of length ≥ `length(f) - 2` (Thomas sweep storage). -""" -function _fortran_spline_derivs!(d::AbstractVector{T}, f::AbstractVector{T}, h::Float64, cp::Vector{Float64}) where {T} - n = length(f) - @assert length(d) == n && n >= 4 && length(cp) >= n - 2 - # Endpoint derivatives: 4-point Lagrange (uniform grid): [-11/6, 3, -3/2, 1/3]/h - d[1] = (-11 * f[1] + 18 * f[2] - 9 * f[3] + 2 * f[4]) / (6h) - d[n] = (11 * f[n] - 18 * f[n-1] + 9 * f[n-2] - 2 * f[n-3]) / (6h) - # Interior: d[i-1] + 4·d[i] + d[i+1] = 3(f[i+1]-f[i-1])/h, i = 2..n-1, - # with known d[1], d[n] moved to the RHS. Thomas algorithm on (1,4,1). - @inbounds begin - rhs2 = 3 * (f[3] - f[1]) / h - d[1] - cp[1] = 1.0 / 4.0 - d[2] = rhs2 / 4.0 - for i in 3:n-1 - rhs = 3 * (f[i+1] - f[i-1]) / h - if i == n - 1 - rhs -= d[n] - end - denom = 4.0 - cp[i-2] - cp[i-1] = 1.0 / denom - d[i] = (rhs - d[i-1]) / denom - end - for i in n-2:-1:2 - d[i] -= cp[i-1] * d[i+1] - end - end - return d -end - -""" - _fortran_spline_integral(f, d, h) → total - -Exact integral of the cubic-Hermite spline with node values `f` and node -derivatives `d` on a uniform grid of spacing `h`. Ports `spline_int`: -per-interval `h/12·(6(f_i+f_{i+1}) + h·(d_i−d_{i+1}))`. -""" -function _fortran_spline_integral(f::AbstractVector{T}, d::AbstractVector{T}, h::Float64) where {T} - total = zero(T) - @inbounds for i in 1:length(f)-1 - total += (h / 12) * (6 * (f[i] + f[i+1]) + h * (d[i] - d[i+1])) - end - return total -end - -""" - _fortran_spline_cumint!(fsi, f, d, h) - -Cumulative nodal integrals (`fsi[i] = ∫₀^{x_i}` of the fitted spline), -matching Fortran `spline_int`'s `fsi` accumulation. `fsi[1] = 0`. -""" -function _fortran_spline_cumint!(fsi::AbstractVector{T}, f::AbstractVector{T}, d::AbstractVector{T}, h::Float64) where {T} - fsi[1] = zero(T) - @inbounds for i in 1:length(f)-1 - fsi[i+1] = fsi[i] + (h / 12) * (6 * (f[i] + f[i+1]) + h * (d[i] - d[i+1])) - end - return fsi -end - -"""Convenience: spline-fit + exact integral of samples `f` on a uniform grid, using workspaces `d` and `cp`.""" -function _fortran_spline_quad(f::AbstractVector{T}, h::Float64, d::AbstractVector{T}, cp::Vector{Float64}) where {T} - _fortran_spline_derivs!(d, f, h, cp) - return _fortran_spline_integral(f, d, h) -end - - # ============================================================================ # Core bounce averaging # ============================================================================ """ - compute_bounce_data(psi, n, l, q, bo, bmax, bmin, ibmax, theta_bmax, + compute_bounce_data(psi, n, l, q, bo, bmax, bmin, theta_bmax, tspl, B_extrap, mfac, chi1, ro, dbob_m_f, divx_m_f, divxfac, wdfac, mass, chrg, T_s, method; nlmda=128, ntheta=128, @@ -251,8 +166,7 @@ Ports Fortran torque.F90 lines 530-816 (GAR branch). - `q`: Safety factor at this ψ - `bo`: On-axis toroidal field [T] - `bmax, bmin`: Max/min of B(θ) at this ψ -- `ibmax`: Index of Bmax in poloidal grid -- `theta_bmax`: θ location of Bmax +- `theta_bmax`: θ location of Bmax (nodal knot; the passing-transit start) - `tspl`: Poloidal interpolant: tspl(θ) → [B, dB/dψ, dB/dθ, J, dJ/dψ] - `mfac`: Poloidal mode numbers [mlow:mhigh] - `chi1`: 2π·ψ₀ flux normalization @@ -273,7 +187,7 @@ Ports Fortran torque.F90 lines 530-816 (GAR branch). function compute_bounce_data( psi::Float64, n::Int, l::Int, q::Float64, bo::Float64, bmax::Float64, bmin::Float64, - ibmax::Int, theta_bmax::Float64, + theta_bmax::Float64, tspl, B_extrap, mfac::Vector{Int}, chi1::Float64, ro::Float64, dbob_m_f::Vector{ComplexF64}, divx_m_f::Vector{ComplexF64}, divxfac::Float64, wdfac::Float64, @@ -378,16 +292,11 @@ the callers zero-initialize are `fill!`-reset per λ, preserving bit-for-bit res - `wmats_lmda::Vector{ComplexF64}`: length `nqty_matrix(mpert)` — packed W outer products - `tspl_f::Vector{Float64}`: length 5 — in-place tspl(θ) evaluation """ -struct BounceScratch +struct BounceScratch{TR, TC} g_wb::Vector{Float64} g_wd::Vector{Float64} cum_wb_arr::Vector{Float64} - d_re::Vector{Float64} - cp::Vector{Float64} jvtheta::Vector{ComplexF64} - bj_samples::Vector{ComplexF64} - d_c::Vector{ComplexF64} - wsamp::Vector{ComplexF64} wmu_mt::Matrix{ComplexF64} wen_mt::Matrix{ComplexF64} expm::Vector{ComplexF64} @@ -396,18 +305,20 @@ struct BounceScratch wen_ba::Vector{ComplexF64} wmats_lmda::Vector{ComplexF64} tspl_f::Vector{Float64} + itp_r::TR + itp_c::TC end function BounceScratch(ntheta::Int, mpert::Int) + # Reusable fit-and-integrate interpolants on the unit θ-quadrature grid; + # per λ their y contents are overwritten and z refit in place. + xs = range(0.0, 1.0, length=ntheta) + itp_r = cubic_interp(xs, zeros(Float64, ntheta); bc=CubicFit()) + itp_c = cubic_interp(xs, zeros(ComplexF64, ntheta); bc=CubicFit()) return BounceScratch( Vector{Float64}(undef, ntheta), Vector{Float64}(undef, ntheta), Vector{Float64}(undef, ntheta), - Vector{Float64}(undef, ntheta), - Vector{Float64}(undef, ntheta), - Vector{ComplexF64}(undef, ntheta), - Vector{ComplexF64}(undef, ntheta), - Vector{ComplexF64}(undef, ntheta), Vector{ComplexF64}(undef, ntheta), Matrix{ComplexF64}(undef, mpert, ntheta), Matrix{ComplexF64}(undef, mpert, ntheta), @@ -417,9 +328,17 @@ function BounceScratch(ntheta::Int, mpert::Int) Vector{ComplexF64}(undef, mpert), Vector{ComplexF64}(undef, nqty_matrix(mpert)), Vector{Float64}(undef, 5), + itp_r, + itp_c, ) end +"""Refit `itp` in place to its current `y` contents (reuses the cached factorization).""" +@inline function _refit!(itp) + FastInterpolations._solve_system!(itp.z, itp.cache, itp.y, itp.bc) + return itp +end + # ============================================================================ # Internal helpers @@ -692,13 +611,15 @@ function _bounce_integrate( # Total bounce integrals — Fortran fits bspl%xs = linspace(0,1,ntheta) with # spline_fit("extrap") and integrates the cubic exactly (spline_int). The # tdt(2,i) weights contain dθ/dx so the samples live on the unit x-grid. - h = 1.0 / (ntheta - 1) - d_wb = scr.d_re - _fortran_spline_derivs!(d_wb, g_wb, h, scr.cp) - fsi_wb = scr.cum_wb_arr # per-surface scratch; fully overwritten by cumint - _fortran_spline_cumint!(fsi_wb, g_wb, d_wb, h) + itp_r = scr.itp_r + copyto!(itp_r.y, g_wb) + _refit!(itp_r) + fsi_wb = scr.cum_wb_arr # per-surface scratch; fully overwritten + FastInterpolations.cumulative_integrate!(fsi_wb, itp_r) total_wb = fsi_wb[ntheta] - total_wd = _fortran_spline_quad(g_wd, h, scr.d_re, scr.cp) + copyto!(itp_r.y, g_wd) + _refit!(itp_r) + total_wd = FastInterpolations.integrate(itp_r) if total_wb ≈ 0.0 # Degenerate case — return zeros @@ -720,11 +641,13 @@ function _bounce_integrate( end # Action bounce integral (Fortran bjspl: cspline_fit("extrap") + cspline_int). - bj_samples = scr.bj_samples + itp_c = scr.itp_c + bj_samples = itp_c.y @inbounds for i in 1:ntheta bj_samples[i] = conj(jvtheta[i]) * (pl[i] + one_minus_sigma / pl[i]) end - bj_integral = _fortran_spline_quad(bj_samples, h, scr.d_c, scr.cp) + _refit!(itp_c) + bj_integral = FastInterpolations.integrate(itp_c) # |δJ|² (Fortran line 756) — division by 2 corrects quadratic form dJdJ_val = wbbar * abs(bj_integral)^2 / 2.0 / ro^2 @@ -738,16 +661,18 @@ function _bounce_integrate( # fully overwritten per mode. wmu_ba = scr.wmu_ba wen_ba = scr.wen_ba - wsamp = scr.wsamp + wsamp = itp_c.y @inbounds for mi in 1:mpert for i in 1:ntheta wsamp[i] = conj(wmu_mt[mi, i]) * (pl[i] + one_minus_sigma / pl[i]) end - wmu_ba[mi] = _fortran_spline_quad(wsamp, h, scr.d_c, scr.cp) + _refit!(itp_c) + wmu_ba[mi] = FastInterpolations.integrate(itp_c) for i in 1:ntheta wsamp[i] = conj(wen_mt[mi, i]) * (pl[i] + one_minus_sigma / pl[i]) end - wen_ba[mi] = _fortran_spline_quad(wsamp, h, scr.d_c, scr.cp) + _refit!(itp_c) + wen_ba[mi] = FastInterpolations.integrate(itp_c) end # Reshape as 1×mpert for matrix multiply (Fortran lines 771-772) diff --git a/src/KineticForces/Torque.jl b/src/KineticForces/Torque.jl index 3e2d5cef3..67edb3542 100644 --- a/src/KineticForces/Torque.jl +++ b/src/KineticForces/Torque.jl @@ -231,7 +231,7 @@ function tpsi!(tpsi_var::Ref{ComplexF64}, psi::Float64, n::Int, l::Int, tspl, dbob_m_f, divx_m_f, divxfac, wdfac, method, op_wmats; chi1=intr.chi1, ro=intr.ro, mfac=intr.mfac, - mpert=intr.mpert, ibmax=ibmax, theta_bmax=theta_bmax, + mpert=intr.mpert, theta_bmax=theta_bmax, B_extrap=B_extrap, smat=smat_f, tmat=tmat_f, xmat=xmat_f, ymat=ymat_f, zmat=zmat_f, @@ -407,7 +407,7 @@ function calculate_gar(psi, n, l, q, epsr, wdian, wdiat, welec, nuk, bo, bmax, bmin, n_s::Float64, T_s::Float64, mass, chrg, tspl, dbob_m_f, divx_m_f, divxfac, wdfac, method, op_wmats; chi1::Float64, ro::Float64, mfac::Vector{Int}, mpert::Int, - ibmax::Int, theta_bmax::Float64, B_extrap, + theta_bmax::Float64, B_extrap, smat=nothing, tmat=nothing, xmat=nothing, ymat=nothing, zmat=nothing, nlmda::Int=128, ntheta::Int=128, @@ -420,7 +420,7 @@ function calculate_gar(psi, n, l, q, epsr, wdian, wdiat, welec, nuk, bo, # 1. Compute bounce-averaged quantities bounce = compute_bounce_data( - psi, n, l, q, bo, bmax, bmin, ibmax, theta_bmax, + psi, n, l, q, bo, bmax, bmin, theta_bmax, tspl, B_extrap, mfac, chi1, ro, dbob_m_f, divx_m_f, divxfac, wdfac, mass, chrg, T_s, method; nlmda, ntheta, smat, tmat, xmat, ymat, zmat) @@ -700,7 +700,7 @@ function _setup_surface_state( return (; chrg, mass, - tspl, B_extrap, bmax, bmin, ibmax, theta_bmax, + tspl, B_extrap, bmax, bmin, theta_bmax, q, n_s, T_s, welec, wdian, wdiat, wtran, wgyro, nuk, epsr, @@ -764,7 +764,7 @@ function kinetic_energy_matrices_for_euler_lagrange!( divx_m_f = zeros(ComplexF64, mpert) bounce = compute_bounce_data( - psi, n, l, state.q, bo, state.bmax, state.bmin, state.ibmax, state.theta_bmax, + psi, n, l, state.q, bo, state.bmax, state.bmin, state.theta_bmax, state.tspl, state.B_extrap, mfac, chi1, ro, dbob_m_f, divx_m_f, 1.0, wdfac, state.mass, state.chrg, state.T_s, "fwmm"; nlmda, ntheta, smat=smat_f, tmat=tmat_f, xmat=xmat_f, ymat=ymat_f, zmat=zmat_f) From 3ea624b937d37619feab66e3fb434d011ca4332b Mon Sep 17 00:00:00 2001 From: logan-nc <6198372+logan-nc@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:17:28 -0400 Subject: [PATCH 08/10] KineticForces - CLEANUP - streamline bounce-averaging annotations per review Comments now state what the code does in one or two lines, keep a single Fortran anchor per function instead of per-line citations, and drop issue references and stale error percentages. Documents B_extrap in the compute_bounce_data docstring and notes why the exact-equality backfill test is safe. Co-Authored-By: Claude Fable 5 --- src/KineticForces/BounceAveraging.jl | 117 +++++++++------------- src/KineticForces/KineticForcesStructs.jl | 9 +- src/KineticForces/Torque.jl | 23 ++--- 3 files changed, 62 insertions(+), 87 deletions(-) diff --git a/src/KineticForces/BounceAveraging.jl b/src/KineticForces/BounceAveraging.jl index e88587e38..4d1e7c946 100644 --- a/src/KineticForces/BounceAveraging.jl +++ b/src/KineticForces/BounceAveraging.jl @@ -167,7 +167,9 @@ Ports Fortran torque.F90 lines 530-816 (GAR branch). - `bo`: On-axis toroidal field [T] - `bmax, bmin`: Max/min of B(θ) at this ψ - `theta_bmax`: θ location of Bmax (nodal knot; the passing-transit start) -- `tspl`: Poloidal interpolant: tspl(θ) → [B, dB/dψ, dB/dθ, J, dJ/dψ] +- `tspl`: Periodic poloidal interpolant: tspl(θ) → [B, dB/dψ, dB/dθ, J, dJ/dψ] +- `B_extrap`: Endpoint-fit (non-periodic) cubic of B(θ) used for v_par and the + bounce-point roots (the Fortran `vspl` equivalent) - `mfac`: Poloidal mode numbers [mlow:mhigh] - `chi1`: 2π·ψ₀ flux normalization - `ro`: Major radius [m] @@ -267,22 +269,16 @@ end """ BounceScratch(ntheta, mpert) -Per-surface scratch buffers for the bounce-averaging inner loops. Allocated once in -`compute_bounce_data` and reused across every λ (each λ previously reallocated these -θ- and mode-sized arrays). Sizes are fixed for a flux surface (`ntheta` sub-grid points, -`mpert` Fourier modes), so a single set of buffers serves the whole λ sweep. Buffers that -the callers zero-initialize are `fill!`-reset per λ, preserving bit-for-bit results. +Per-surface scratch for the bounce-averaging inner loops, allocated once in +`compute_bounce_data` and reused across every λ. Sizes are fixed for a flux surface +(`ntheta` sub-grid points, `mpert` Fourier modes). Buffers the loops populate only +partially are `fill!`-reset per λ. ## Fields - `g_wb::Vector{Float64}`: length `ntheta` — bounce-action integrand samples - `g_wd::Vector{Float64}`: length `ntheta` — drift integrand samples -- `cum_wb_arr::Vector{Float64}`: length `ntheta` — cumulative bounce action -- `d_re::Vector{Float64}`: length `ntheta` — real spline-derivative workspace -- `cp::Vector{Float64}`: length `ntheta` — Thomas-sweep workspace +- `cum_wb_arr::Vector{Float64}`: length `ntheta` — cumulative bounce-action integral - `jvtheta::Vector{ComplexF64}`: length `ntheta` — action integrand -- `bj_samples::Vector{ComplexF64}`: length `ntheta` — action bounce-integral samples -- `d_c::Vector{ComplexF64}`: length `ntheta` — complex spline-derivative workspace -- `wsamp::Vector{ComplexF64}`: length `ntheta` — per-mode W quadrature samples - `wmu_mt::Matrix{ComplexF64}`: `mpert × ntheta` — W_μ per θ - `wen_mt::Matrix{ComplexF64}`: `mpert × ntheta` — W_E per θ - `expm::Vector{ComplexF64}`: length `mpert` — Fourier basis at a θ @@ -291,6 +287,8 @@ the callers zero-initialize are `fill!`-reset per λ, preserving bit-for-bit res - `wen_ba::Vector{ComplexF64}`: length `mpert` — bounce-averaged W_E - `wmats_lmda::Vector{ComplexF64}`: length `nqty_matrix(mpert)` — packed W outer products - `tspl_f::Vector{Float64}`: length 5 — in-place tspl(θ) evaluation +- `itp_r::TR`, `itp_c::TC`: real/complex fit-and-integrate interpolants on the unit + θ-quadrature grid (`CubicFit` endpoints); see `_refit!` """ struct BounceScratch{TR, TC} g_wb::Vector{Float64} @@ -333,7 +331,11 @@ function BounceScratch(ntheta::Int, mpert::Int) ) end -"""Refit `itp` in place to its current `y` contents (reuses the cached factorization).""" +""" +Refit `itp` in place to its current `y` contents, reusing the cached tridiagonal +factorization. Uses the package-internal `_solve_system!` because no exported API +refits an interpolant without allocating; this keeps the per-λ hot loop allocation-free. +""" @inline function _refit!(itp) FastInterpolations._solve_system!(itp.z, itp.cache, itp.y, itp.bc) return itp @@ -379,15 +381,9 @@ end """ - _vpar_from_extrap(B_extrap, lmda, bo, θ) → v_par - -Parallel-velocity factor `v_par = 1 − (λ/bo)·B(θ)` evaluated from the **extrap** -cubic of B (`B_extrap`), matching Fortran's separate `vspl` (torque.F90:599-600, -`vpar = vspl%f(1)` at :677 — "more consistent w/ bnce pts than direct from tspl"). -Because the "extrap" endpoint derivatives are linear in the nodal data, the extrap -cubic of `1−(λ/bo)B` equals `1−(λ/bo)·(extrap cubic of B)`, so we build `B_extrap` -once per surface (CubicFit ≡ Fortran extrap) and reuse it for the vpar factor, the -bounce-point roots, and the deepest-well test — NOT the periodic `tspl`. +Parallel-velocity factor `v_par = 1 − (λ/bo)·B(θ)` from the endpoint-fit cubic of B +(`B_extrap`, built where the surface interpolants are constructed), keeping v_par +consistent with the bounce-point roots as in Fortran's `vspl`. """ @inline _vpar_from_extrap(B_extrap, lmda::Float64, bo::Float64, θ::Float64) = 1.0 - (lmda / bo) * B_extrap(mod(θ, 1.0)) @@ -403,14 +399,9 @@ function _find_bounce_points_and_grid( ntheta::Int ) if sigma == 0 # trapped - # Bounce points = roots of v_par(θ) = 1 − (λ/bo)·B_extrap(θ) in (0,1). - # Fortran fits vspl "extrap" and calls spline_roots (torque.F90:600-602), which - # solves each equilibrium interval's cubic analytically. Roots.find_zeros - # adaptively finds ALL roots of the SAME extrap cubic (B_extrap), so the root - # values match spline_roots (robust to the near-boundary interior dip a fixed - # sign-change scan could miss). Sorted DESCENDING to match spline_roots order - # (spline.f:1782-1785), which the marginally-trapped and deepest-well wrap - # logic below assume. + # Bounce points: all roots of v_par(θ) = 1 − (λ/bo)·B_extrap(θ) in (0,1), + # sorted descending — the same order as Fortran spline_roots, which the + # marginally-trapped and deepest-well wrap logic below assume. vpar_fn = θ -> _vpar_from_extrap(B_extrap, lmda, bo, θ) bpts = sort!(Roots.find_zeros(vpar_fn, 0.0, 1.0); rev=true) @@ -424,7 +415,6 @@ function _find_bounce_points_and_grid( t1 = bpts[1] t2 = bpts[1] + 1.0 else - # Find deepest potential well (Fortran lines 616-639) t1, t2 = _find_deepest_well(bpts, B_extrap, lmda, bo) end @@ -442,8 +432,8 @@ end """ -Find deepest potential well among bounce point pairs. -Ports Fortran lines 616-639. +Find the deepest potential well (largest midpoint v_par) among bounce-point pairs, +handling pairs that wrap through θ = 0/1. """ function _find_deepest_well(bpts::Vector{Float64}, B_extrap, lmda::Float64, bo::Float64) nbpts = length(bpts) @@ -532,26 +522,22 @@ function _bounce_integrate( jac = tspl_f[4] djdpsi = tspl_f[5] - # vpar from the extrap cubic of B (Fortran vspl, torque.F90:677), NOT the - # periodic tspl B_val — which stays as the numerator field below (Fortran - # bspl uses tspl%f(1)/(4) for the numerator, vspl%f(1) only for 1/√vpar). + # v_par from the endpoint-fit cubic (consistent with the bounce points); + # the periodic tspl B_val remains the numerator field in the integrands. vpar = 1.0 - (lmda / bo) * B_extrap(θmod) if vpar <= 0 - # Zero crossing near bounce points — Fortran torque.F90:678-697 fill - # semantics (sample i ↔ Fortran node i-1): + # Negative v_par near a bounce point: same fill rules as the Fortran bounce loop. if i < ntheta ÷ 2 - # Before midpoint: restart — zero everything up to and including - # this sample (Fortran bspl%fs(:i-1,:)=0; jvtheta(:i)=0). + # Before midpoint: restart — zero everything up to this sample. fill!(view(g_wb, 1:i), 0.0) fill!(view(g_wd, 1:i), 0.0) fill!(view(jvtheta, 1:i), ComplexF64(0.0)) continue else - # After midpoint: hold the previous sample for the rest of the - # grid. Fortran fills BOTH quantities from fs(i-2,1) — the wb - # integrand — including the wd slot (bspl%fs(i-1:,2)=bspl%fs(i-2,1)); - # reproduced verbatim for parity. + # After midpoint: hold the previous sample to the end of the grid. + # The wd slot is deliberately held from the wb integrand (g_wb), + # reproducing the Fortran behavior for parity. fill!(view(g_wb, i:ntheta), g_wb[i-1]) fill!(view(g_wd, i:ntheta), g_wb[i-1]) fill!(view(jvtheta, i:ntheta), jvtheta[i-1]) @@ -561,18 +547,15 @@ function _bounce_integrate( sqrt_vpar = sqrt(vpar) - # Bounce integrands (Fortran lines 698-701) + # Bounce integrands g_wb[i] = dt * jac * B_val / sqrt_vpar g_wd[i] = dt * jac * dBdpsi * (1.0 - 1.5 * lmda * B_val / bo) / sqrt_vpar + dt * djdpsi * B_val * sqrt_vpar - # Fourier modes at this θ (Fortran lines 702-708) — write into pre-allocated - # expm buffer using the ORIGINAL expression order to preserve bit-level parity. + # Fourier modes at this θ @inbounds for mi in 1:mpert expm[mi] = cis(twopi * mfac[mi] * θ) end - # Replaces `sum(dbob_m_f .* expm)` / `sum(divx_m_f .* expm) * divxfac` - # with direct accumulators; same evaluation order as the broadcast + sum. dbob = ComplexF64(0.0) divx = ComplexF64(0.0) @inbounds for mi in 1:mpert @@ -581,15 +564,13 @@ function _bounce_integrate( end divx *= divxfac - # Action integrand (Fortran line 706-708) + # Action integrand phase = cis(-twopi * n * q * (θ - theta0)) jvtheta[i] = dt * jac * B_val * (divx * sqrt_vpar + dbob * (1.0 - 1.5 * lmda * B_val / bo) / sqrt_vpar) * phase - # W vectors for matrix path (Fortran lines 722-727). Element-by-element - # write preserving original broadcast evaluation order exactly to keep - # bit-level parity (matters because downstream quadrature is tolerance-sensitive). + # W vectors for matrix path if do_matrices wmu_pre = dt * (lmda / bo) wen_pre = dt @@ -599,8 +580,8 @@ function _bounce_integrate( end end - # Smooth backfill for points zeroed before a restart (Fortran lines 730-734, - # index ranges preserved verbatim: g fills samples 3..i-1, jv fills 2..i-1). + # Smooth backfill for points zeroed before a restart. Exact equality with + # 0.0 is safe: the restart branch set these entries with fill!(…, 0.0). if i >= 3 && g_wb[i-1] == 0.0 fill!(view(g_wb, 3:i-1), g_wb[i]) fill!(view(g_wd, 3:i-1), g_wd[i]) @@ -608,9 +589,10 @@ function _bounce_integrate( end end - # Total bounce integrals — Fortran fits bspl%xs = linspace(0,1,ntheta) with - # spline_fit("extrap") and integrates the cubic exactly (spline_int). The - # tdt(2,i) weights contain dθ/dx so the samples live on the unit x-grid. + # Total bounce integrals: fit an endpoint-fit cubic on the unit x-grid (the tdt + # weights contain dθ/dx) and integrate it exactly. With the 1/√v_par endpoint + # singularities the quadrature scheme is a leading-order effect, so this matches + # the exact-cubic integration of the Fortran spline package (not a trapezoid sum). itp_r = scr.itp_r copyto!(itp_r.y, g_wb) _refit!(itp_r) @@ -626,13 +608,12 @@ function _bounce_integrate( return 0.0, 0.0, 0.0, nothing end - # Bounce-averaged frequencies (Fortran lines 740-741) + # Bounce-averaged frequencies wbbar = ro * twopi / ((2 - sigma) * total_wb) wdbar = ro^2 * bo * wdfac * wbbar * 2 * (2 - sigma) * total_wd - # Phase factor (Fortran line 750): pl_i = exp(-2πi·lnq·h(θ_i)) with - # h = fsi_wb/((2-σ)·total_wb) the cumulative spline integral — NOT a - # running trapezoid — matching bspl%fsi exactly. + # Phase factor pl_i = exp(-2πi·lnq·fsi_wb(θ_i)/((2-σ)·total_wb)), using the + # cumulative spline integral of the bounce action. pl_denom = (2 - sigma) * total_wb one_minus_sigma = 1 - sigma pl = scr.pl # per-surface scratch; fully overwritten @@ -640,7 +621,7 @@ function _bounce_integrate( pl[i] = cis(-twopi * lnq * fsi_wb[i] / pl_denom) end - # Action bounce integral (Fortran bjspl: cspline_fit("extrap") + cspline_int). + # Action bounce integral (exact-cubic quadrature, as for the totals above). itp_c = scr.itp_c bj_samples = itp_c.y @inbounds for i in 1:ntheta @@ -649,16 +630,14 @@ function _bounce_integrate( _refit!(itp_c) bj_integral = FastInterpolations.integrate(itp_c) - # |δJ|² (Fortran line 756) — division by 2 corrects quadratic form + # |δJ|² — division by 2 corrects the quadratic form dJdJ_val = wbbar * abs(bj_integral)^2 / 2.0 / ro^2 - # Matrix path: bounce-average W vectors and form outer products (Fortran lines 759-793) + # Matrix path: bounce-average W vectors and form outer products wmats_lmda = nothing if do_matrices - # Bounce-average W_μ and W_E vectors (Fortran lines 762-767): per mode, - # cspline_fit("extrap") + cspline_int of conj(W_m(θ))·(pl + (1-σ)/pl), - # matching the bjspl quadrature above. Results land in per-surface scratch, - # fully overwritten per mode. + # Per mode, exact-cubic quadrature of conj(W_m(θ))·(pl + (1-σ)/pl), + # matching the action bounce integral above. wmu_ba = scr.wmu_ba wen_ba = scr.wen_ba wsamp = itp_c.y diff --git a/src/KineticForces/KineticForcesStructs.jl b/src/KineticForces/KineticForcesStructs.jl index 15371158c..7c3eb016f 100644 --- a/src/KineticForces/KineticForcesStructs.jl +++ b/src/KineticForces/KineticForcesStructs.jl @@ -254,11 +254,10 @@ the equilibrium geometry parameters needed for NTV calculations. function KineticForcesInternal(equil; verbose::Bool=false) mthsurf = length(equil.rzphi_ys) - 1 nth = mthsurf + 1 - # Fortran PENTRC (dcon_interface.f set_geom): bo = |sq%f(1)|/(2π·ro) at ψ=0 — - # the toroidal field AT THE MAGNETIC AXIS, from F extrapolated to the axis. - # NOT params.b0 (= bt0, the edge-extrapolated vacuum field at rmean, ~2% - # different on DIII-D). bo normalizes λ = μ·bo/E and v_par = 1−λB/bo, so this - # offset shifted the trapped/passing boundary and all bounce integrals (#269). + # Toroidal field at the magnetic axis, which normalizes λ = μ·bo/E: F_spline + # stores 2πF (F = R·B_t), so |F_spline(0)|/(2π·ro) = F(0)/ro, the same + # normalization as Fortran PENTRC. params.b0 is the edge-extrapolated vacuum + # field and differs by ~2%. bo_axis = abs(equil.profiles.F_spline(0.0)) / (2π * equil.ro) KineticForcesInternal(; ro = equil.ro, diff --git a/src/KineticForces/Torque.jl b/src/KineticForces/Torque.jl index 67edb3542..c5d85dcd4 100644 --- a/src/KineticForces/Torque.jl +++ b/src/KineticForces/Torque.jl @@ -94,9 +94,9 @@ function tpsi!(tpsi_var::Ref{ComplexF64}, psi::Float64, n::Int, l::Int, # Create periodic interpolant for poloidal quantities tspl = cubic_interp(xs, Series(hcat(B_vals, dBdpsi_vals, dBdtheta_vals, jac_vals, djdpsi_vals)); bc=PeriodicBC()) - # Extrap cubic of B for the v_par factor + bounce points (Fortran vspl, fit - # "extrap"); CubicFit ≡ Fortran extrap. Kept separate from the periodic tspl - # so v_par matches Fortran torque.F90:599-600,677 exactly (see _vpar_from_extrap). + # v_par and the bounce points use a separate endpoint-fit (non-periodic) cubic + # of B, like Fortran's vspl. The endpoint fit of 1−(λ/bo)B equals 1−(λ/bo) + # times the fit of B, so one B_extrap per surface serves every λ. B_extrap = cubic_interp(xs, B_vals; bc=CubicFit()) bmax = maximum(B_vals) @@ -142,9 +142,8 @@ function tpsi!(tpsi_var::Ref{ComplexF64}, psi::Float64, n::Int, l::Int, end if Bθ > bmax bmax = Bθ - # theta_bmax intentionally NOT refined: Fortran uses the nodal-argmax - # knot xs[ibmax] as the transit start t1/t2 (torque.F90:610-611,648-649); - # the refined extremum (:301) is diagnostic-only. Keep theta_bmax = xs[ibmax]. + # theta_bmax stays at the nodal knot xs[ibmax]: the transit starts at + # the knot (as in Fortran), not at the refined extremum. end end @@ -465,9 +464,8 @@ function calculate_gar(psi, n, l, q, epsr, wdian, wdiat, welec, nuk, bo, end end - # Build CubicSeriesInterpolant on normalized data (single build). - # CubicFit endpoint BC = 4-point polynomial fit, matching Fortran - # cspline_fit(fbnce,'extrap') endpoint-derivative extrapolation. + # Build CubicSeriesInterpolant on normalized data (single build); CubicFit + # endpoint BC matches the Fortran "extrap" fit. fbnce = cubic_interp(bounce.lambda, Series(fbnce_data); bc=CubicFit()) # 3. Set rex/imx multipliers (Fortran lines 839-847) @@ -626,7 +624,7 @@ function _setup_surface_state( end tspl = cubic_interp(xs, Series(hcat(B_vals, dBdpsi_vals, dBdtheta_vals, jac_vals, djdpsi_vals)); bc=PeriodicBC()) - # Extrap cubic of B for v_par + bounce points (Fortran vspl "extrap"); see _vpar_from_extrap. + # Endpoint-fit (non-periodic) cubic of B for v_par and bounce points (Fortran vspl equivalent). B_extrap = cubic_interp(xs, B_vals; bc=CubicFit()) bmax = maximum(B_vals) @@ -661,9 +659,8 @@ function _setup_surface_state( end if Bθ > bmax bmax = Bθ - # theta_bmax intentionally NOT refined: Fortran uses the nodal-argmax - # knot xs[ibmax] for t1/t2 (torque.F90:610-611,648-649); refined extremum - # is diagnostic-only. Keep theta_bmax = xs[ibmax]. + # theta_bmax stays at the nodal knot xs[ibmax]: the transit starts at + # the knot (as in Fortran), not at the refined extremum. end end end From bf13143d90ab03aebf7c9177382ee9fce0aadf6b Mon Sep 17 00:00:00 2001 From: logan-nc <6198372+logan-nc@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:17:28 -0400 Subject: [PATCH 09/10] REGRESSION - Track NTV kinetic energy (dW) in diiid_n1 case Adds kinetic_forces/fgar/total_energy to the tracked quantities. This is the post-PE NTV quantity most sensitive to the bounce-averaging chain and was previously untracked, letting the kinetic dW drift ship unnoticed. Co-Authored-By: Claude Fable 5 --- regression-harness/cases/diiid_n1.toml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/regression-harness/cases/diiid_n1.toml b/regression-harness/cases/diiid_n1.toml index 6693b5601..17f6ff584 100644 --- a/regression-harness/cases/diiid_n1.toml +++ b/regression-harness/cases/diiid_n1.toml @@ -360,6 +360,17 @@ label = "NTV torque FGAR (Re,Im) [N·m]" noise_threshold = 1e-2 order = 95 +# NTV kinetic energy (δW) through the same post-PE FGAR path. This is the +# quantity most sensitive to the bounce-averaging chain (quadrature scheme, +# v_par source, bo normalization), so it guards that whole path directly. +[quantities.ntv_energy_fgar] +h5path = "kinetic_forces/fgar/total_energy" +type = "real_vector" +extract = "all_real" +label = "NTV energy FGAR (Re,Im) [J]" +noise_threshold = 1e-3 +order = 96 + # Profiles (checksums) [quantities.q_profile] h5path = "splines/profiles/q" From 7d289385d0be76c1e1c6e0d192a110c58af6368d Mon Sep 17 00:00:00 2001 From: logan-nc <6198372+logan-nc@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:40:27 -0400 Subject: [PATCH 10/10] REGRESSION - Track NTV kinetic energy as a scalar, not a complex pair total_energy is complex(imag(total_torque)/(2n), 0.0): the imaginary slot is structurally zero. Extract only the first element and label it as the kinetic energy dW, so the tracked value is the physical quantity rather than a pair whose second component carries no information. Co-Authored-By: Claude Opus 4.8 (1M context) --- regression-harness/cases/diiid_n1.toml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/regression-harness/cases/diiid_n1.toml b/regression-harness/cases/diiid_n1.toml index 17f6ff584..89618f71c 100644 --- a/regression-harness/cases/diiid_n1.toml +++ b/regression-harness/cases/diiid_n1.toml @@ -360,14 +360,16 @@ label = "NTV torque FGAR (Re,Im) [N·m]" noise_threshold = 1e-2 order = 95 -# NTV kinetic energy (δW) through the same post-PE FGAR path. This is the -# quantity most sensitive to the bounce-averaging chain (quadrature scheme, -# v_par source, bo normalization), so it guards that whole path directly. +# NTV kinetic energy δW = Im(T)/(2n) through the same post-PE FGAR path. Stored +# on disk as [δW, 0] (the imaginary slot is always zero), so only the first +# element carries information. Derivable from the torque above, but tracked +# explicitly at a tighter threshold to guard Im(T), which is the component the +# bounce-averaging chain moves most. [quantities.ntv_energy_fgar] h5path = "kinetic_forces/fgar/total_energy" type = "real_vector" -extract = "all_real" -label = "NTV energy FGAR (Re,Im) [J]" +extract = "real_first" +label = "NTV kinetic energy dW FGAR [J]" noise_threshold = 1e-3 order = 96