Skip to content

Commit 0440553

Browse files
authored
Merge branch 'develop' into feature/thread-invariance
2 parents 23d40cb + 3cf96df commit 0440553

9 files changed

Lines changed: 294 additions & 79 deletions

src/Equilibrium/DirectEquilibrium.jl

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -415,10 +415,11 @@ solvers.
415415
function _build_psi_grid(equil_params, psilow, psihigh)
416416
mpsi = equil_params.mpsi
417417
if equil_params.grid_type in ("auto", "log_asymptotic") && mpsi == 0 && equil_params.psi_accuracy > 0
418-
# Two-pass auto grid: this is the coarse pass-1 layout; the driver measures the
419-
# formed equilibrium's curvature and re-forms on a refined grid (GridRefinement.jl).
420-
mpsi = 128
421-
@info "Auto psi grid: forming pass-1 equilibrium on coarse $(mpsi)-interval log_asymptotic grid pending curvature-based refinement"
418+
# Two-pass auto grid: this is the pass-1 layout; the driver measures the formed
419+
# equilibrium's curvature and re-forms on a refined grid (GridRefinement.jl). Sized to
420+
# resolve the gradient structure accurately so pass 2 is well-provisioned (PASS1_INTERVALS).
421+
mpsi = PASS1_INTERVALS
422+
@info "Auto psi grid: forming pass-1 equilibrium on $(mpsi)-interval log_asymptotic grid pending curvature-based refinement"
422423
elseif mpsi == 0
423424
mpsi = 128
424425
end
@@ -444,7 +445,10 @@ function _build_psi_grid(equil_params, psilow, psihigh)
444445
else
445446
error("Unsupported grid_type: $(equil_params.grid_type)")
446447
end
447-
return psi_nodes
448+
# Floor node spacing on the fixed grids: ldp/pow1 pack the edge as ~(π/2·mpsi)⁻² and at high
449+
# mpsi drive it below the integration-noise scale (garbage curvature). The auto grid floors its
450+
# refined pass-2 grid in refined_psi_grid, so it is left untouched here.
451+
return equil_params.grid_type in ("auto", "log_asymptotic") ? psi_nodes : enforce_min_spacing(psi_nodes, MIN_KNOT_SPACING)
448452
end
449453

450454
"""

src/Equilibrium/Equilibrium.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export setup_equilibrium, EquilibriumConfig, PlasmaEquilibrium, EquilibriumParam
3030
KineticProfileSplines, load_kinetic_profiles,
3131
KineticProfileData, read_kinetic_file, write_kinetic_h5
3232
export flux_surface_metric, flux_surface_area
33-
export wants_two_pass, refined_psi_grid, merge_mandatory_nodes, implied_knot_count
33+
export wants_two_pass, refined_psi_grid, merge_mandatory_nodes, bracket_mandatory_nodes, enforce_min_spacing, implied_knot_count
3434
export compute_sqrt_jac_delpsi, compute_sqrtamat, rootarea_to_area_weight, area_to_rootarea_weight
3535

3636
# --- Constants ---

src/Equilibrium/GridRefinement.jl

Lines changed: 142 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,42 @@ const CORE_MODEL_PSI_MAX = 0.03
5757
const EDGE_MODEL_PSI_MIN = 0.9
5858
# θ-lines subsample stride for the 2D geometry channels
5959
const THETA_STRIDE = 8
60-
# Local density elevation around mandatory (rational) surfaces: knot spacing h_s = coef·τ^(1/3)
61-
# at the surface, geometric growth away from it, applied within the given radius. This keeps the
62-
# equidistributed base grid from starving the approach to each rational, where the ideal-MHD
63-
# Δ′ extraction is most sensitive to the local equilibrium-spline resolution.
64-
const SING_PACK_COEF = 0.06
65-
const SING_PACK_RADIUS = 0.05
60+
# Rational-surface bracketing (Δ′ robustness). The ideal-MHD Δ′ asymptotic matching samples the
61+
# cubic equilibrium splines' 2nd/3rd derivatives across each rational ψ_s over the matching stencil
62+
# [ψ_s − dpsi, ψ_s + dpsi], dpsi = singfac_min/|n·q′|. A cubic 3rd derivative is piecewise constant
63+
# and jumps at every knot, so a knot inside that stencil makes q‴ inconsistent across the crossing
64+
# and Δ′ non-convergent. We keep the stencil inside ONE clean spline interval by bracketing each
65+
# surface with knots at ψ_s ± w (w = BRACKET_COEF·dpsi) and clearing the zone between them — knots
66+
# stay near the surface (kinetic-layer resolution) but never on it. BRACKET_COEF > 1 leaves margin
67+
# for the pass-1→pass-2 shift of ψ_s.
68+
const BRACKET_COEF = 4.0
69+
# Global minimum knot spacing. No grid — auto refinement, the near-surface bracket, or a fixed
70+
# ldp/pow1 grid packing its edge at high mpsi — may place nodes closer than this. Below it, cubic
71+
# high derivatives are dominated by field-line integration noise rather than curvature (e.g. an
72+
# ldp grid at mpsi=2048 packs the edge to ~6e-7, deep in noise). Chosen well under the spacing of a
73+
# converged uniform reference (~5e-4 at mpsi=2048) so it clips only pathological clustering.
74+
const MIN_KNOT_SPACING = 1.0e-4
75+
# Near-rational resolution patch. The Δ′ extraction samples the equilibrium splines' 3rd
76+
# derivatives at each rational surface, which the smooth-q measured-curvature density starves at
77+
# mid-radius (q looks smooth there, so few knots are placed, yet Δ′ still needs fine local
78+
# resolution). Within RATIONAL_RES_RADIUS of each rational we floor the density at 1/RATIONAL_RES_SPACING
79+
# so equidistribution lays a locally-uniform fine patch there — the resolution Δ′ needs — which
80+
# `bracket_mandatory_nodes` then centers the surface within. The spacing is **fixed** (τ-independent):
81+
# Δ′ is a property of the rational surface, so its accuracy must not depend on the global accuracy
82+
# target τ (that only sizes the pedestal/edge grid) — a τ-dependent patch would leave Δ′ far from
83+
# converged at the default τ. RATIONAL_RES_SPACING is set where the q‴ estimate converges (the ldp
84+
# mpsi ladder converges the q=2 Δ′ by h ≈ 5e-4).
85+
const RATIONAL_RES_SPACING = 5.0e-4
86+
const RATIONAL_RES_RADIUS = 5.0e-3
87+
# Cap on the refined pass-2 interval count (density integral is clamped to this).
88+
const REFINED_N_CAP = 1024
89+
# Pass-1 interval count for the two-pass auto grid: the layout on which the formed equilibrium's
90+
# curvature is measured to provision the refined pass-2 grid. Pass 1 is equilibrium-only (cheap;
91+
# the ODE integration that dominates runtime is on pass 2). Sized to measure the gradient structure
92+
# (pedestal, edge) accurately. NOTE: because the curvature estimate is currently grid-dependent
93+
# (it grows with the measurement grid — see the dynamic-grid issue), a larger pass 1 measures more
94+
# curvature and enlarges pass 2; revisit once the density is made grid-invariant.
95+
const PASS1_INTERVALS = 1024
6696

6797
"""
6898
wants_two_pass(config::EquilibriumConfig) -> Bool
@@ -142,7 +172,7 @@ function _density_from_curvature!(rho::Vector{Float64}, d4::Vector{Float64}, f_s
142172
end
143173

144174
"""
145-
_knot_density(equil::PlasmaEquilibrium; tau, kin=nothing, mandatory=Float64[], sing_pack_coef=SING_PACK_COEF) -> Vector{Float64}
175+
_knot_density(equil::PlasmaEquilibrium; tau, kin=nothing) -> Vector{Float64}
146176
147177
Knot density ρ(ψ) (knots per unit ψ_N) at the pass-1 nodes `equil.profiles.xs`, combining
148178
by max:
@@ -159,14 +189,12 @@ by max:
159189
from uniform relative q′ error on q ≈ −A·ln(1−ψ), independent of A and normally
160190
inactive; and the core for ψ ≤ 0.03, geometric-in-log(ψ) from the power-law axis
161191
form, which replaces the (noise-dominated) measurement there. A global minimum
162-
density applies everywhere;
163-
- local packing around each `mandatory` (rational) surface: spacing `sing_pack_coef`·τ^(1/3)
164-
at the surface with geometric growth away from it, within `SING_PACK_RADIUS`.
192+
density applies everywhere.
165193
166-
A running max over ±1 node smooths single-stencil dropouts.
194+
Rational surfaces are handled by `bracket_mandatory_nodes` after equidistribution, not by
195+
elevating the density here. A running max over ±1 node smooths single-stencil dropouts.
167196
"""
168-
function _knot_density(equil::PlasmaEquilibrium; tau::Float64, kin::Union{Nothing,KineticProfileSplines}=nothing,
169-
mandatory::Vector{Float64}=Float64[], sing_pack_coef::Float64=SING_PACK_COEF)
197+
function _knot_density(equil::PlasmaEquilibrium; tau::Float64, kin::Union{Nothing,KineticProfileSplines}=nothing)
170198
xs = equil.profiles.xs
171199
n = length(xs)
172200
# Curvature is measured against ρ = √ψ (regular at the axis); the ρ-space density
@@ -229,15 +257,6 @@ function _knot_density(equil::PlasmaEquilibrium; tau::Float64, kin::Union{Nothin
229257
rho_s[i] = max(rho_s[i], 1.0 / H_TARGET_MAX)
230258
end
231259

232-
# Local packing around mandatory (rational) surfaces
233-
h_s = max(sing_pack_coef * tau^(1 / 3), H_TARGET_MIN)
234-
for psi_m in mandatory
235-
@inbounds for i in 1:n
236-
d = abs(xs[i] - psi_m)
237-
d > SING_PACK_RADIUS && continue
238-
rho_s[i] = max(rho_s[i], 1.0 / max(dlog * d, h_s))
239-
end
240-
end
241260
return rho_s
242261
end
243262

@@ -321,45 +340,129 @@ function merge_mandatory_nodes(grid::Vector{Float64}, mandatory::Vector{Float64}
321340
return merged
322341
end
323342

343+
"""
344+
enforce_min_spacing(grid, hmin) -> Vector{Float64}
345+
346+
Drop interior nodes so no two kept nodes are closer than `hmin`, preserving both endpoints.
347+
Guards against noise-level packing from any generator — the auto-grid near-surface bracket, or a
348+
fixed `ldp`/`pow1` grid whose edge spacing collapses (`~(π/2·mpsi)⁻²` for `ldp`) at high mpsi,
349+
below which cubic high derivatives are integration noise, not curvature. A no-op when the grid is
350+
already coarser than `hmin` everywhere (e.g. a converged uniform reference).
351+
"""
352+
function enforce_min_spacing(grid::Vector{Float64}, hmin::Float64)
353+
n = length(grid)
354+
(n < 3 || hmin <= 0) && return copy(grid)
355+
kept = Float64[grid[1]]
356+
for i in 2:(n-1)
357+
grid[i] - kept[end] >= hmin && push!(kept, grid[i])
358+
end
359+
# Keep the far endpoint; if the last retained interior node crowds it, drop that node instead.
360+
length(kept) > 1 && grid[n] - kept[end] < hmin && pop!(kept)
361+
push!(kept, grid[n])
362+
return kept
363+
end
364+
365+
"""
366+
bracket_mandatory_nodes(grid, centers, min_half_widths, min_spacing; collapse_atol=1e-7) -> Vector{Float64}
367+
368+
Center each rational `centers[i]` inside a single clean spline interval by replacing the knots
369+
straddling it with a symmetric pair at `centers[i] ± w`. The half-width `w` is the larger of
370+
`min_half_widths[i]` (the Δ′-stencil floor `BRACKET_COEF·dpsi`) and **half the local grid
371+
spacing**, so the bracket blends into the surrounding grid rather than punching a narrow interval
372+
into it: the region stays locally uniform with the center at its midpoint. That local uniformity
373+
is what keeps the cubic 3rd derivative the Δ′ extraction samples both *consistent across* the
374+
surface (no knot-on-surface jump) and *stable across* grid refinement — a narrow bracket amid
375+
wide neighbors would be consistent but noisy. Non-bracket knots within `w` of the center, or
376+
within `min_spacing` of either new bracket knot, are dropped; endpoints always win; bracket knots
377+
outside the open domain are dropped, and knots closer than `collapse_atol` are collapsed to keep
378+
the grid strictly increasing.
379+
"""
380+
function bracket_mandatory_nodes(grid::Vector{Float64}, centers::Vector{Float64}, min_half_widths::Vector{Float64}, min_spacing::Float64; collapse_atol::Float64=1e-7)
381+
isempty(centers) && return copy(grid)
382+
length(centers) == length(min_half_widths) || error("bracket_mandatory_nodes: centers and min_half_widths length mismatch")
383+
lo, hi = grid[1], grid[end]
384+
order = sortperm(centers)
385+
kept = Tuple{Float64,Bool}[(g, false) for g in grid] # (value, is_bracket_knot)
386+
last_c = -Inf
387+
for idx in order
388+
c, hw = centers[idx], min_half_widths[idx]
389+
(lo < c < hi) || continue
390+
c - last_c < collapse_atol && continue # duplicate rational (same q via several (m,n))
391+
k = clamp(searchsortedlast(grid, c), 1, length(grid) - 1)
392+
w = max(hw, 0.5 * (grid[k+1] - grid[k])) # blend to half the local spacing
393+
left, right = c - w, c + w
394+
# Drop non-bracket, non-endpoint knots inside the zone or crowding a new bracket knot.
395+
filter!(kept) do t
396+
t[2] || t[1] == lo || t[1] == hi ||
397+
!(left < t[1] < right || abs(t[1] - left) < min_spacing || abs(t[1] - right) < min_spacing)
398+
end
399+
left > lo && push!(kept, (left, true))
400+
right < hi && push!(kept, (right, true))
401+
last_c = c
402+
end
403+
sort!(kept; by=first)
404+
merged = Float64[]
405+
for (v, _) in kept
406+
(isempty(merged) || v - merged[end] > collapse_atol) && push!(merged, v)
407+
end
408+
all(diff(merged) .> 0) || error("bracket_mandatory_nodes produced a non-increasing grid")
409+
return merged
410+
end
411+
324412
"""
325413
refined_psi_grid(equil::PlasmaEquilibrium; tau, kin=nothing, mandatory=Float64[],
326-
delta_frac=0.25, N_cap=1024, sing_pack_coef=SING_PACK_COEF) -> Vector{Float64}
414+
singfac_min=1e-4, n_min=1, bracket_coef=BRACKET_COEF,
415+
min_spacing=MIN_KNOT_SPACING, N_cap=1024) -> Vector{Float64}
327416
328417
Build the refined pass-2 ψ grid from a formed pass-1 equilibrium: measured-curvature knot
329-
density (`_knot_density`), equidistribution, and mandatory-knot insertion
330-
(`merge_mandatory_nodes`). `tau` is the target interpolation accuracy (`psi_accuracy`);
331-
`kin` optionally supplies kinetic profiles whose pedestal gradients attract knots;
332-
`mandatory` lists ψ values that must appear as knots (e.g. rational surfaces);
333-
`sing_pack_coef` scales the knot spacing at those surfaces (convergence studies only —
334-
the default is scan-calibrated).
418+
density (`_knot_density`), equidistribution, a global minimum-spacing floor
419+
(`enforce_min_spacing`), and rational-surface bracketing (`bracket_mandatory_nodes`). `tau` is
420+
the target interpolation accuracy (`psi_accuracy`); `kin` optionally supplies kinetic profiles
421+
whose pedestal gradients attract knots; `mandatory` lists rational-surface ψ values to bracket;
422+
`singfac_min` and `n_min` (smallest |n| in the run) set each surface's matching half-stencil
423+
`dpsi = singfac_min/(n_min·|q′|)`, and the bracket half-width is `bracket_coef·dpsi` (floored at
424+
`min_spacing`). Rational surfaces are bracketed, not pinned: a knot on the surface would make the
425+
Δ′ extraction's cubic 3rd derivative jump mid-stencil (see `BRACKET_COEF`).
335426
"""
336427
function refined_psi_grid(equil::PlasmaEquilibrium;
337428
tau::Float64,
338429
kin::Union{Nothing,KineticProfileSplines}=nothing,
339430
mandatory::Vector{Float64}=Float64[],
340-
delta_frac::Float64=0.25,
341-
N_cap::Int=1024,
342-
sing_pack_coef::Float64=SING_PACK_COEF)
431+
singfac_min::Float64=1e-4,
432+
n_min::Int=1,
433+
bracket_coef::Float64=BRACKET_COEF,
434+
min_spacing::Float64=MIN_KNOT_SPACING,
435+
N_cap::Int=REFINED_N_CAP)
343436
xs = equil.profiles.xs
344-
rho = _knot_density(equil; tau, kin, mandatory, sing_pack_coef)
437+
rho = _knot_density(equil; tau, kin)
438+
# Floor the density to a fixed (τ-independent) locally-uniform fine patch around each rational
439+
# so Δ′ has the resolution to sample 3rd derivatives there at any accuracy target (see
440+
# RATIONAL_RES_SPACING).
441+
if !isempty(mandatory)
442+
h_rat = max(RATIONAL_RES_SPACING, min_spacing)
443+
@inbounds for m in mandatory, i in eachindex(xs)
444+
abs(xs[i] - m) <= RATIONAL_RES_RADIUS && (rho[i] = max(rho[i], 1.0 / h_rat))
445+
end
446+
end
345447
M_total = sum(0.5 * (rho[i] + rho[i-1]) * (xs[i] - xs[i-1]) for i in 2:length(xs))
346448
N = clamp(ceil(Int, M_total), 32, N_cap)
347449
N == N_cap && M_total > N_cap &&
348450
@warn "refined_psi_grid: knot count capped at $N_cap (density integral wants $(ceil(Int, M_total))); psi_accuracy=$tau may not be attainable"
349-
grid = _equidistribute(xs, rho, N)
350-
return merge_mandatory_nodes(grid, mandatory; delta_frac)
451+
grid = enforce_min_spacing(_equidistribute(xs, rho, N), min_spacing)
452+
isempty(mandatory) && return grid
453+
min_half_widths = [max(bracket_coef * singfac_min / (n_min * abs(equil.profiles.q_deriv(m))), min_spacing) for m in mandatory]
454+
return bracket_mandatory_nodes(grid, mandatory, min_half_widths, min_spacing)
351455
end
352456

353457
"""
354-
implied_knot_count(equil::PlasmaEquilibrium; tau, kin=nothing, mandatory=Float64[]) -> Int
458+
implied_knot_count(equil::PlasmaEquilibrium; tau, kin=nothing) -> Int
355459
356460
Knot count the measured-curvature density model implies for a formed equilibrium. Used as
357461
a post-refinement consistency diagnostic: if this differs substantially from the actual
358462
grid size, the pass-1 grid under- or over-sampled a feature.
359463
"""
360-
function implied_knot_count(equil::PlasmaEquilibrium; tau::Float64, kin::Union{Nothing,KineticProfileSplines}=nothing,
361-
mandatory::Vector{Float64}=Float64[])
464+
function implied_knot_count(equil::PlasmaEquilibrium; tau::Float64, kin::Union{Nothing,KineticProfileSplines}=nothing)
362465
xs = equil.profiles.xs
363-
rho = _knot_density(equil; tau, kin, mandatory)
466+
rho = _knot_density(equil; tau, kin)
364467
return ceil(Int, sum(0.5 * (rho[i] + rho[i-1]) * (xs[i] - xs[i-1]) for i in 2:length(xs)))
365468
end

src/Equilibrium/InverseEquilibrium.jl

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -140,10 +140,10 @@ function equilibrium_solver(input::InverseRunInput; override_psi_nodes::Union{No
140140
mpsi = length(sq_xs) - 1
141141
elseif grid_type in ("auto", "log_asymptotic")
142142
if mpsi == 0 && config.psi_accuracy > 0
143-
# Two-pass auto grid: coarse pass-1 layout; the driver refines and re-forms
144-
# on the measured-curvature grid (GridRefinement.jl).
145-
mpsi = 128
146-
@info "Auto psi grid: forming pass-1 equilibrium on coarse $(mpsi)-interval log_asymptotic grid pending curvature-based refinement"
143+
# Two-pass auto grid: pass-1 layout sized to resolve the gradient structure so pass 2
144+
# is well-provisioned; the driver refines and re-forms (GridRefinement.jl, PASS1_INTERVALS).
145+
mpsi = PASS1_INTERVALS
146+
@info "Auto psi grid: forming pass-1 equilibrium on $(mpsi)-interval log_asymptotic grid pending curvature-based refinement"
147147
elseif mpsi == 0
148148
mpsi = 128
149149
end
@@ -173,6 +173,13 @@ function equilibrium_solver(input::InverseRunInput; override_psi_nodes::Union{No
173173
else
174174
error("Unsupported grid_type: $grid_type")
175175
end
176+
# Floor node spacing on the fixed grids (ldp/pow1 pack the edge below the integration-noise
177+
# scale at high mpsi); the auto grid floors its refined grid in refined_psi_grid, and an
178+
# override grid arrives already floored.
179+
if override_psi_nodes === nothing && !(grid_type in ("auto", "log_asymptotic"))
180+
sq_xs = enforce_min_spacing(sq_xs, MIN_KNOT_SPACING)
181+
mpsi = length(sq_xs) - 1
182+
end
176183
sq_fs = zeros(Float64, mpsi+1, 4)
177184
sq = cubic_interp(sq_xs, Series(sq_fs); extrap=ExtendExtrap())
178185

0 commit comments

Comments
 (0)