@@ -57,12 +57,42 @@ const CORE_MODEL_PSI_MAX = 0.03
5757const EDGE_MODEL_PSI_MIN = 0.9
5858# θ-lines subsample stride for the 2D geometry channels
5959const 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
142172end
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
147177Knot density ρ(ψ) (knots per unit ψ_N) at the pass-1 nodes `equil.profiles.xs`, combining
148178by 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
242261end
243262
@@ -321,45 +340,129 @@ function merge_mandatory_nodes(grid::Vector{Float64}, mandatory::Vector{Float64}
321340 return merged
322341end
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
328417Build 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"""
336427function 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)
351455end
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
356460Knot count the measured-curvature density model implies for a formed equilibrium. Used as
357461a post-refinement consistency diagnostic: if this differs substantially from the actual
358462grid 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)))
365468end
0 commit comments