Skip to content

Commit 2225813

Browse files
logan-ncclaude
andcommitted
FFS - NEW FEATURE - Certified adaptive psi grid for the calculated kinetic matrices
The expensive bounce-average kernel ran on every equilibrium knot, which both starves kinetic decks of resolution (the shipped calculated example uses mpsi=16, carrying percent-level errors on the B/C totals) and wastes kernel evaluations on fine grids. Adds certified_kinetic_grid: seed with a coarse skeleton of the ideal coefficient- spline knots (endpoints and rational windows always retained; the seed must not scale with the equilibrium grid), then batched certify-or-refine rounds -- spline the kinetic increments on the current knots, propose all uncertified midpoints, evaluate the whole batch with the existing threaded kernel, and certify or insert. The certificate is the max-element residual against tolerance * max|total| for every consumed family including the adjoint combination kw3 - kt3, so tolerance is held on the ideal+kinetic totals the solver consumes, never on increments in isolation. Spacing floors: the Frobenius cap in the core, RATIONAL_RES_SPACING outside, and RATIONAL_RES_SPACING/4 inside rational windows so narrow layers cannot hide behind the floor where they are expected. Control: kinetic_grid_tol (default 0 = off; knob-off verified bit-identical). Measured on the Solovev calculated case at tol=1e-3: m64 deck 65 -> 93 knots (certification adds knots where the coarse grid under-resolves, matching the pre-registered expectation); m256 deck 150 kernel evaluations vs 255 (-41%) with et[1] within 9.4e-5 of the full grid; tol sweep 3e-3..1e-4 is plateau-flat. The kernel now accepts an arbitrary psi list (psis kwarg), threading pattern unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 0c17097 commit 2225813

5 files changed

Lines changed: 181 additions & 7 deletions

File tree

src/ForceFreeStates/ForceFreeStatesStructs.jl

Lines changed: 3 additions & 0 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_tol::Float64` - Relative tolerance for the certified adaptive ψ grid of the calculated kinetic matrices. When > 0, the expensive kinetic kernel is evaluated on a seed grid (the ideal coefficient-spline knots) and intervals are refined until the spline-predicted **total** (ideal + kinetic) matrices match fresh evaluations within `kinetic_grid_tol · max|T|` for every element of every consumed family, including the non-Hermitian adjoint combination. `0` (default) evaluates the kernel on every equilibrium knot, today's behaviour. Applies only to `kinetic_source = "calculated"`
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_tol::Float64 = 0.0
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
@@ -332,6 +334,7 @@ end
332334
# FastInterpolations natively supports complex values: CubicSeriesInterpolant{Tgrid, Tvalue}
333335
# NOTE: itp_opts must precede interpolant fields — @kwdef evaluates defaults in declaration order
334336
itp_opts::Opts = (; extrap=ExtendExtrap())
337+
matrix_xs::Vector{Float64} = Float64[] # psi knots the coefficient splines are built on (density-capped equilibrium-grid subset)
335338

336339
amats::S = _empty_series_interp_complex(numpert_total^2, itp_opts)
337340
bmats::S = _empty_series_interp_complex(numpert_total^2, itp_opts)

src/ForceFreeStates/Fourfit.jl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,7 @@ function make_matrix(equil::Equilibrium.PlasmaEquilibrium, intr::ForceFreeStates
520520
end
521521
push!(keep, length(metric.xs))
522522
mxs = metric.xs[keep]
523+
ffit.matrix_xs = mxs
523524
length(mxs) < length(metric.xs) &&
524525
@info "EL coefficient-spline grid: $(length(metric.xs)) -> $(length(mxs)) knots after core density cap"
525526

src/ForceFreeStates/Kinetic.jl

Lines changed: 171 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,135 @@
1+
"""
2+
certified_kinetic_grid(seed, evaluate, ideal_scales, tol, rationals; kwargs...)
3+
-> (xs, kw_flat, kt_flat)
4+
5+
Choose the ψ knots for the calculated kinetic matrices by batched certify-or-refine rounds, so the
6+
expensive kernel runs only where the **total** (ideal + kinetic) matrices demand it.
7+
8+
`evaluate(psis) -> (kw, kt)` runs the threaded kernel on a batch of ψ values. Starting from `seed`
9+
(the ideal coefficient-spline knots), each round splines the kinetic increments on the current
10+
knots, proposes the midpoints of all uncertified intervals, evaluates the whole batch, and
11+
classifies: a midpoint whose predicted increments match the fresh evaluation within
12+
`tol · scale` for **every element of every consumed family** certifies its interval and is
13+
discarded (a knot the spline already predicts only adds noise-scale curvature); otherwise it
14+
becomes a knot and its sub-intervals join the queue. The families are the six kinetic components
15+
plus the non-Hermitian adjoint combination `kw₃ − kt₃`, and each `scale` is the largest magnitude
16+
of the corresponding **total** matrix over the seed — so the tolerance is held on what the
17+
Euler-Lagrange solver actually consumes, never on the increments in isolation.
18+
19+
Intervals are never refined below a floor of `0.05·ψ` in the Frobenius core or
20+
`RATIONAL_RES_SPACING` outside it, and refinement stops after `max_rounds`. The returned knot set
21+
always contains the seed, so structure resolved by the ideal grid is retained.
22+
"""
23+
function certified_kinetic_grid(seed::Vector{Float64}, evaluate::Function,
24+
ideal_scales::NTuple{6,Float64}, tol::Float64, rationals::Vector{Float64};
25+
max_rounds::Int=6, verbose::Bool=true)
26+
27+
xs = copy(seed)
28+
kw, kt = evaluate(xs)
29+
nevals = length(xs)
30+
np2 = size(kw, 2)
31+
32+
# Tolerance scale per family: the total matrix the solver sees (ideal + increments over seed).
33+
scales = ntuple(ic -> max(ideal_scales[ic],
34+
maximum(abs, @view(kw[:, :, ic])) + maximum(abs, @view(kt[:, :, ic]))), 6)
35+
36+
uncertified = trues(length(xs) - 1)
37+
# Spacing floors: the Frobenius-region cap in the core, RATIONAL_RES_SPACING elsewhere — and a
38+
# finer floor inside rational windows, so a narrow resonance layer cannot hide behind the
39+
# coarse floor exactly where layers are expected (the anti-aliasing rule).
40+
near_rational(x) = any(abs(x - r) <= Equilibrium.RATIONAL_RES_RADIUS for r in rationals)
41+
floor_at(x) = near_rational(x) ? Equilibrium.RATIONAL_RES_SPACING / 4 :
42+
(x < 0.1 ? 0.05 * x : Equilibrium.RATIONAL_RES_SPACING)
43+
added = 0
44+
for round in 1:max_rounds
45+
props = Float64[]
46+
slots = Int[]
47+
for i in findall(uncertified)
48+
m = 0.5 * (xs[i] + xs[i+1])
49+
if xs[i+1] - xs[i] < 2 * floor_at(m)
50+
uncertified[i] = false # certified by the spacing floor
51+
continue
52+
end
53+
push!(props, m)
54+
push!(slots, i)
55+
end
56+
isempty(props) && break
57+
kwp, ktp = evaluate(props)
58+
nevals += length(props)
59+
60+
# Spline each increment family on the current knots to predict the midpoints.
61+
pred_kw = [cubic_interp(xs, Series(@view(kw[:, :, ic]))) for ic in 1:6]
62+
pred_kt = [cubic_interp(xs, Series(@view(kt[:, :, ic]))) for ic in 1:6]
63+
buf = Vector{ComplexF64}(undef, np2)
64+
65+
fails = Int[]
66+
for (k, m) in pairs(props)
67+
ok = true
68+
for ic in 1:6
69+
pred_kw[ic](buf, m)
70+
r = maximum(abs(buf[j] - kwp[k, j, ic]) for j in 1:np2)
71+
pred_kt[ic](buf, m)
72+
r = max(r, maximum(abs(buf[j] - ktp[k, j, ic]) for j in 1:np2))
73+
if r > tol * scales[ic]
74+
ok = false
75+
break
76+
end
77+
end
78+
if ok # adjoint combination kw3 - kt3, held against the C-total scale
79+
pred_kw[3](buf, m)
80+
a = copy(buf)
81+
pred_kt[3](buf, m)
82+
r = maximum(abs((a[j] - buf[j]) - (kwp[k, j, 3] - ktp[k, j, 3])) for j in 1:np2)
83+
ok = r <= tol * scales[3]
84+
end
85+
if ok
86+
uncertified[slots[k]] = false
87+
else
88+
push!(fails, k)
89+
end
90+
end
91+
isempty(fails) && (fill!(uncertified, false); break)
92+
93+
# Insert failed midpoints as knots (batch merge, preserving order).
94+
newxs = Float64[]
95+
newkw = zeros(ComplexF64, length(xs) + length(fails), np2, 6)
96+
newkt = zeros(ComplexF64, length(xs) + length(fails), np2, 6)
97+
newunc = Bool[]
98+
fi = 1
99+
row = 0
100+
for i in eachindex(xs)
101+
row += 1
102+
push!(newxs, xs[i])
103+
newkw[row, :, :] .= kw[i, :, :]
104+
newkt[row, :, :] .= kt[i, :, :]
105+
i == length(xs) && break
106+
inserted = false
107+
while fi <= length(fails) && slots[fails[fi]] == i
108+
k = fails[fi]
109+
row += 1
110+
push!(newxs, props[k])
111+
newkw[row, :, :] .= kwp[k, :, :]
112+
newkt[row, :, :] .= ktp[k, :, :]
113+
inserted = true
114+
fi += 1
115+
end
116+
if inserted
117+
push!(newunc, true, true) # both halves of the split interval re-enter the queue
118+
else
119+
push!(newunc, uncertified[i])
120+
end
121+
end
122+
added += length(fails)
123+
xs = newxs
124+
kw = newkw[1:row, :, :]
125+
kt = newkt[1:row, :, :]
126+
uncertified = BitVector(newunc)
127+
end
128+
@info "Certified kinetic grid: $(length(seed)) seed -> $(length(xs)) knots " *
129+
"($nevals kernel evaluations, $added refined, tol=$tol)"
130+
return xs, kw, kt
131+
end
132+
1133
"""
2134
make_kinetic_matrix(ctrl, equil, ffit, intr, metric;
3135
calculated_source=nothing)
@@ -41,7 +173,42 @@ function make_kinetic_matrix(
41173
"calling make_kinetic_matrix directly, or pass " *
42174
"`calculated_source=KineticForces.compute_calculated_kinetic_matrices` explicitly."
43175
)
44-
kw_flat, kt_flat = calculated_source(ctrl, equil, intr, metric, ffit)
176+
if ctrl.kinetic_grid_tol > 0
177+
# Certified adaptive grid: seed with the ideal coefficient-spline knots and refine
178+
# until the total matrices are spline-predictable to kinetic_grid_tol everywhere.
179+
# Seed with a coarse skeleton of the ideal coefficient-spline knots: the kernel is the
180+
# expensive part, so the seed must not scale with the equilibrium grid. Every ~seed
181+
# gap the certificate cannot vouch for is refined, so coarse seeding trades cheap
182+
# certificates for expensive blanket evaluation. Endpoints and rational-window knots
183+
# are always retained.
184+
base = isempty(ffit.matrix_xs) ? metric.xs : ffit.matrix_xs
185+
seed_max = 49
186+
if length(base) > seed_max
187+
rats = [sng.psifac for sng in intr.sing]
188+
keep_idx = falses(length(base))
189+
keep_idx[1] = keep_idx[end] = true
190+
stride = max(1, (length(base) - 1) ÷ (seed_max - 1))
191+
keep_idx[1:stride:end] .= true
192+
for (i, x) in pairs(base)
193+
any(abs(x - r) <= Equilibrium.RATIONAL_RES_RADIUS for r in rats) && (keep_idx[i] = true)
194+
end
195+
seed = base[keep_idx]
196+
else
197+
seed = copy(base)
198+
end
199+
hint = Ref(1)
200+
ideal_scales = ntuple(ic -> begin
201+
sp = (ffit.amats, ffit.bmats, ffit.cmats, ffit.dmats_prim, ffit.emats_prim, ffit.hmats)[ic]
202+
maximum(maximum(abs, sp(x; hint=hint)) for x in seed)
203+
end, 6)
204+
rationals = [sng.psifac for sng in intr.sing]
205+
xs, kw_flat, kt_flat = certified_kinetic_grid(seed,
206+
psis -> calculated_source(ctrl, equil, intr, metric, ffit; psis=psis),
207+
ideal_scales, ctrl.kinetic_grid_tol, rationals; verbose=ctrl.verbose)
208+
mpsi = length(xs)
209+
else
210+
kw_flat, kt_flat = calculated_source(ctrl, equil, intr, metric, ffit)
211+
end
45212
kw_flat .*= ctrl.kinetic_factor
46213
kt_flat .*= ctrl.kinetic_factor
47214
else
@@ -55,7 +222,7 @@ function make_kinetic_matrix(
55222
end
56223

57224
# Pre-compute FKG derived matrices (corresponds to Fortran method=0)
58-
_compute_fkg_matrices!(ffit, equil, intr, metric, kw_flat, kt_flat)
225+
_compute_fkg_matrices!(ffit, equil, intr, metric, kw_flat, kt_flat; xs=xs)
59226

60227
return nothing
61228
end
@@ -78,9 +245,9 @@ function _compute_fkg_matrices!(
78245
intr::ForceFreeStatesInternal,
79246
metric::MetricData,
80247
kw_flat::Array{ComplexF64,3},
81-
kt_flat::Array{ComplexF64,3}
248+
kt_flat::Array{ComplexF64,3};
249+
xs::Vector{Float64}=metric.xs
82250
)
83-
xs = metric.xs
84251
mpsi = length(xs)
85252
np = intr.numpert_total
86253
mpert = intr.mpert

src/GeneralizedPerturbedEquilibrium.jl

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -419,10 +419,10 @@ function main_from_inputs(
419419
# Inject the KineticForces callback so the "calculated" source can
420420
# invoke compute_calculated_kinetic_matrices without ForceFreeStates
421421
# importing KineticForces (which would invert the load order).
422-
calculated_cb = (c, e, i, m, f) ->
422+
calculated_cb = (c, e, i, m, f; psis=nothing) ->
423423
KineticForces.compute_calculated_kinetic_matrices(
424424
c, e, i, m, f;
425-
kf_ctrl=kf_ctrl, kinetic_profiles=kinetic_profiles)
425+
kf_ctrl=kf_ctrl, kinetic_profiles=kinetic_profiles, psis=psis)
426426
make_kinetic_matrix(ctrl, equil, ffit, intr, metric;
427427
calculated_source=calculated_cb)
428428

src/KineticForces/CalculatedKineticMatrices.jl

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,11 @@ function compute_calculated_kinetic_matrices(
5656
ffit;
5757
kf_ctrl::KineticForcesControl = KineticForcesControl(),
5858
kinetic_profiles::Equilibrium.KineticProfileSplines,
59+
psis::Union{Nothing,Vector{Float64}}=nothing,
5960
)
60-
xs = metric.xs
61+
# The kernel is a pure function of psi (it evaluates equilibrium splines), so it can be driven
62+
# over any knot list; default is the full equilibrium grid.
63+
xs = psis === nothing ? metric.xs : psis
6164
mpsi = length(xs)
6265
mpert = ffs_intr.mpert
6366
npert = ffs_intr.npert

0 commit comments

Comments
 (0)