Skip to content

Commit 831e708

Browse files
logan-ncclaude
andcommitted
FFS - EXPERIMENT - One-round bisection refinement of the calculated kinetic matrix grid
User-proposed safety net, trial implementation: kernel-evaluate every interval midpoint above the validity band (one extra threaded kernel pass), compare against the increment splines with a per-interval LOCAL scale (the lesson from the parked global-scale certificate), insert missed midpoints as knots reusing their kernel values, and log the residual map unconditionally. Knob kinetic_grid_bisect (default 0 = off). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LzbLFQKyuRE5DYZmLokKmk
1 parent a38c41d commit 831e708

2 files changed

Lines changed: 81 additions & 28 deletions

File tree

src/ForceFreeStates/ForceFreeStatesStructs.jl

Lines changed: 2 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ gpec.toml.
233233
- `numsteps_init::Int` - Initial array size for ODE data storage
234234
- `numunorms_init::Int` - Initial array size for solution normalization data
235235
- `singfac_min::Float64` - Fractional distance from rational q at which ideal jump condition is enforced
236+
- `kinetic_grid_bisect::Float64` - EXPERIMENTAL one-round bisection refinement of the calculated kinetic matrix grid. Every interval midpoint above the near-axis validity band is kernel-evaluated; the measured values are compared against the increment splines with a per-interval local scale, and midpoints missed by more than this relative tolerance become knots (their kernel values are reused, so refinement costs exactly one extra kernel pass). A residual map is logged regardless of insertions. `0` (default) disables
236237
- `set_psilim_via_dmlim::Bool` - Truncate the integration domain at `(last_rational_q + dmlim) / n` rather than at `qhigh` / `psihigh`. Fortran STRIDE found that truncating ~20 % above the outermost rational (`dmlim = 0.2`) avoids a numerical kink instability in δW that appears when the integration ends too close to or just below a rational surface. **For diverted equilibria where q → ∞ at the separatrix** (e.g. DIII-D geqdsks, the bulk of production use) this costs negligible physical domain because rationals get arbitrarily dense near the LCFS — `set_psilim_via_dmlim = true` is the safe and recommended default. **For limited circular / analytical equilibria with finite q at the edge** (Solovev, LAR scans), rationals are sparse and 20 % above the last rational chops off too much edge, so set `set_psilim_via_dmlim = false` and let `qhigh` / `psihigh` control the truncation. Multi-`n` runs are not supported by this truncation (the "outermost rational + dmlim / n" depends on which `n`); when `set_psilim_via_dmlim = true` with `nn_low != nn_high`, `sing_lim!` warns and falls back to `qhigh` / `psihigh`. Default `true`.
237238
- `dmlim::Float64` - Distance beyond last rational surface (normalised ∈ [0,1) in units of 1/n). Only used when `set_psilim_via_dmlim` is true. Fortran STRIDE convention is 0.2 (truncate 20 % of one rational-surface spacing above the last surface), retained here.
238239
- `sing_order::Int` - Order of singular layer (Frobenius) expansion at rational surfaces. Default 6 (Fortran STRIDE convention for Δ' calculations; lower values trade accuracy for speed).
@@ -270,6 +271,7 @@ gpec.toml.
270271
numsteps_init::Int = 4000
271272
numunorms_init::Int = 100
272273
singfac_min::Float64 = 1e-4 # Matches Fortran STRIDE; required nonzero for the Riccati path.
274+
kinetic_grid_bisect::Float64 = 0.0 # EXPERIMENTAL: one bisection-refinement round of the calculated kinetic matrix grid; midpoints of every interval above the validity band are kernel-evaluated and inserted where the spline misses them by more than this relative tolerance (local scale). 0 = off
273275
set_psilim_via_dmlim::Bool = true # Safe default for diverted equilibria (most production use); set false for limited/analytical (LAR, Solovev). Auto-skipped for multi-n. See docstring.
274276
dmlim::Float64 = 0.2
275277
sing_order::Int = 6
@@ -481,71 +483,46 @@ and a small set of temporary matrices and factors used to compute singular-layer
481483
- `numpert_total::Int` - Total number of Fourier mode combinations (m × n) used in the calculation.
482484
483485
- `numunorms_init::Int` - Initial allocation size for the number of normalization operations recorded.
484-
485486
- `msing::Int` - Number of singular surfaces in the equilibrium (used to size asymptotic coefficient arrays).
486-
487487
- `numsteps_init::Int` - Initial allocation size for the number of integration steps to store.
488-
489488
- `step::Int` - Current integration step index (1-based, like `istep` in the original Fortran).
490-
491489
- `psi_store::Vector{Float64}` - Stored psi values at each saved integration step (length `numsteps_init`).
492-
493490
- `q_store::Vector{Float64}` - Stored q values at each saved integration step (length `numsteps_init`).
494-
495491
- `u_store::Array{ComplexF64,4}` - Stored solution arrays at each saved step with shape
496492
`(numpert_total, numpert_total, 2, numsteps_init)` (complex solution state used by the solver).
497-
498493
- `du_store::Array{ComplexF64,3}` - dΞ_ψ/dψ (the u₁ block only) at each saved step, shape
499494
`(numpert_total, numpert_total, step)`. Empty until `materialize_derivative_stores!` fills it,
500495
except on the galerkin-matched path which supplies the analytic derivative at construction.
501496
du₂/dψ is never stored densely — its only consumer evaluates it on demand at bracket nodes.
502-
503497
- `xi_s_store::Array{ComplexF64,3}` - Clebsch displacement Ξ_s at each saved step, eq. 18 of Glasser 2016,
504498
shape `(numpert_total, numpert_total, step)`. Empty until materialized, same as `du_store`.
505-
506499
- `u_store_el_basis::Bool` - True when `u_store` holds the Euler-Lagrange state `(u₁, u₂)`, so the
507500
derivative kernel can be re-applied to it. False on the sparse parallel path, whose stored columns
508501
are chunk-endpoint Riccati matrices; `materialize_derivative_stores!` refuses to run there.
509-
510502
- `du_store_populated::Bool` - True once `du_store`/`xi_s_store` hold valid data in the final
511503
(post-transform, post-normalization) basis. Set by `materialize_derivative_stores!` or by the
512504
galerkin-matched constructor; stays false where the stores cannot be materialized, e.g. the
513505
sparse parallel path whose solution is in the Riccati basis.
514-
515506
- `crit_store::Vector{Float64}` - Stored crit parameter values (smallest eigenvalue of W⁻ꜝ) (length `numsteps_init`).
516-
517507
- `ca_r::Array{ComplexF64,4}` - Asymptotic coefficients just to the right of each singular surface
518508
with shape `(numpert_total, numpert_total, 2, msing)`.
519-
520509
- `ca_l::Array{ComplexF64,4}` - Asymptotic coefficients just to the left of each singular surface
521510
with shape `(numpert_total, numpert_total, 2, msing)`.
522-
523511
- `edge_scan::EdgeScanState` - Edge dW scan state and results. Initialized as a disabled sentinel (N_edge=0) and replaced by `findmax_dW_edge!` when a scan runs.
524-
525512
- `psifac::Float64` - Current normalized flux coordinate for the integrator.
526-
527513
- `q::Float64` - Safety factor value at `psifac` (current q during integration).
528-
529514
- `u::Array{ComplexF64,3}` - Current working solution arrays with shape `(numpert_total, numpert_total, 2)`.
530-
531515
- `ising_start::Int` - Index of the starting singular surface to be crossed during integration.
532-
533516
- `psimax::Float64` - Maximum psi value for which the integrator is allowed to run in next integration region.
534-
535517
- `needs_crossing::Bool` - Flag indicating whether a rational surface needs to be crossed after the current integration region.
536-
537518
- `nzero::Int` - Count of detected zero crossings (used for diagnostics).
538-
539519
- `new::Bool` - Flag indicating whether a new `unorm0` should be computed after a fixup.
540520
541521
# Initialization parameters
542-
543522
- `unorm::Vector{Float64}` - Current norms of the solution vectors (length `numpert_total`).
544-
545523
- `unorm0::Vector{Float64}` - Reference/initial norms of the solution vectors (length `numpert_total`).
546524
547525
# Saved data throughout integration
548-
549526
- `ifix::Int` - Number of normalization operations performed (index into normalization arrays).
550527
551528
# Total ODE solver steps taken (all steps, not just saved ones)
@@ -554,11 +531,8 @@ and a small set of temporary matrices and factors used to compute singular-layer
554531
555532
- `sing_flag::Vector{Bool}` - Boolean flags indicating which stored normalizations correspond to singular solutions # Edge dW scan state and results (disabled sentinel when psiedge >= psilim, i.e. no edge scan)
556533
(length `numunorms_init`).
557-
558534
- `zeroed_idx::Vector{Vector{Int}}` - For each ideal rational surface jump, a vector of indices of solutions that were zeroed. # Data for integrator
559-
560535
- `fixfac::Array{ComplexF64,3}` - Fix-up factors for Gaussian reduction with shape `(numpert_total, numpert_total, numunorms_init)`.
561-
562536
- `fixstep::Vector{Int64}` - Step indices (psi step positions) at which normalization/fixups were performed (length `numunorms_init`).
563537
"""
564538
@kwdef mutable struct OdeState

src/ForceFreeStates/Kinetic.jl

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,75 @@
1+
2+
"""
3+
bisect_refine_kinetic_grid(xs, kw, kt, evaluate, tol, psi_c) → (xs, kw, kt)
4+
5+
One bounded bisection-refinement round for the calculated kinetic matrix grid (experimental).
6+
Every interval midpoint above `2·psi_c` (outside the near-axis validity band) is kernel-evaluated
7+
in one threaded batch; for each family the increment spline on the current knots predicts the
8+
midpoint, and the max-element residual is normalized by a **per-interval local scale** (the max
9+
increment magnitude over the interval's endpoints and midpoint, floored at 1% of the family's
10+
global maximum — the local scale is the lesson from the parked global-scale certificate, the
11+
floor keeps near-zero crossings from flagging on noise). Midpoints whose worst-family residual
12+
exceeds `tol` become knots; their kernel values are reused, so the round costs exactly one extra
13+
kernel pass regardless of how many knots it inserts. The residual distribution is logged
14+
unconditionally — the map of what the ideal-driven grid misses is the diagnostic even when
15+
nothing is inserted.
16+
"""
17+
function bisect_refine_kinetic_grid(xs::Vector{Float64}, kw::Array{ComplexF64,3}, kt::Array{ComplexF64,3},
18+
evaluate::Function, tol::Float64, psi_c::Float64)
19+
lo = 2 * psi_c
20+
cand = [i for i in 1:(length(xs)-1) if xs[i] >= lo]
21+
isempty(cand) && return xs, kw, kt
22+
mids = [0.5 * (xs[i] + xs[i+1]) for i in cand]
23+
kw_m, kt_m = evaluate(mids)
24+
np2 = size(kw, 2)
25+
worst = zeros(length(mids))
26+
buf = Vector{ComplexF64}(undef, np2)
27+
for ic in 1:6
28+
gscale_w = maximum(abs, @view(kw[:, :, ic]))
29+
gscale_t = maximum(abs, @view(kt[:, :, ic]))
30+
sp_w = cubic_interp(xs, Series(@view(kw[:, :, ic])))
31+
sp_t = cubic_interp(xs, Series(@view(kt[:, :, ic])))
32+
for (k, m) in pairs(mids)
33+
i = cand[k]
34+
for (sp, arr, arrm, gs) in ((sp_w, kw, kw_m, gscale_w), (sp_t, kt, kt_m, gscale_t))
35+
gs == 0 && continue
36+
sp(buf, m)
37+
scale = max(maximum(abs, @view(arr[i, :, ic])), maximum(abs, @view(arr[i+1, :, ic])),
38+
maximum(abs, @view(arrm[k, :, ic])), 0.01 * gs)
39+
r = maximum(abs(buf[j] - arrm[k, j, ic]) for j in 1:np2) / scale
40+
worst[k] = max(worst[k], r)
41+
end
42+
end
43+
end
44+
q = [round(sort(worst)[max(1, ceil(Int, f * length(worst)))]; sigdigits=2) for f in (0.5, 0.9, 0.99, 1.0)]
45+
ins = findall(>(tol), worst)
46+
@info "Kinetic bisection map: $(length(mids)) midpoints probed, residual quantiles " *
47+
"(50/90/99/100%) = $q; $(length(ins)) exceed tol=$tol" *
48+
(isempty(ins) ? "" : " at ψ=$(round.(mids[ins]; digits=4))")
49+
isempty(ins) && return xs, kw, kt
50+
keep = sort(ins)
51+
nnew = length(xs) + length(keep)
52+
xs2 = Vector{Float64}(undef, nnew)
53+
kw2 = Array{ComplexF64,3}(undef, nnew, np2, 6)
54+
kt2 = Array{ComplexF64,3}(undef, nnew, np2, 6)
55+
row = 0
56+
ki = 1
57+
for i in eachindex(xs)
58+
row += 1
59+
xs2[row] = xs[i]
60+
kw2[row, :, :] .= kw[i, :, :]
61+
kt2[row, :, :] .= kt[i, :, :]
62+
while ki <= length(keep) && cand[keep[ki]] == i
63+
row += 1
64+
xs2[row] = mids[keep[ki]]
65+
kw2[row, :, :] .= kw_m[keep[ki], :, :]
66+
kt2[row, :, :] .= kt_m[keep[ki], :, :]
67+
ki += 1
68+
end
69+
end
70+
return xs2, kw2, kt2
71+
end
72+
173
"""
274
make_kinetic_matrix(ctrl, equil, ffit, intr, metric;
375
calculated_source=nothing)
@@ -57,6 +129,13 @@ function make_kinetic_matrix(
57129
mpsi = length(xs)
58130
kw_flat, kt_flat = calculated_source(ctrl, equil, intr, metric, ffit; psis=xs)
59131
end
132+
if ctrl.kinetic_grid_bisect > 0
133+
xs, kw_flat, kt_flat = bisect_refine_kinetic_grid(
134+
collect(xs), kw_flat, kt_flat,
135+
psis -> calculated_source(ctrl, equil, intr, metric, ffit; psis=psis),
136+
ctrl.kinetic_grid_bisect, axis_validity_psi_c)
137+
mpsi = length(xs)
138+
end
60139
kw_flat .*= ctrl.kinetic_factor
61140
kt_flat .*= ctrl.kinetic_factor
62141
else

0 commit comments

Comments
 (0)