Skip to content
78 changes: 65 additions & 13 deletions src/Equilibrium/DirectEquilibrium.jl
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,32 @@ function direct_position!(raw_profile::DirectRunInput)
return ro, zo, rs1, rs2
end

"""
eta_at_sfl_angle(sol, y_out, x, total_x) -> Float64

Integration angle η at which the normalised straight-fieldline angle ∫jac·dl/Bp reaches `x`.

`y_out[:, 5]` is monotone in η, so it brackets the root to one solver step and Brent converges in
a handful of dense-output evaluations. Used to sample every flux surface at the *same* SFL angles
instead of resampling each surface's own solver steps (issue #376).
"""
function eta_at_sfl_angle(sol, y_out::Matrix{Float64}, x::Float64, total_x::Float64)
x <= 0 && return y_out[1, 1]
x >= 1 && return y_out[end, 1]
target = x * total_x
hi = searchsortedfirst(view(y_out, :, 5), target)
hi = clamp(hi, 2, size(y_out, 1))
lo = hi - 1
eta_lo, eta_hi = y_out[lo, 1], y_out[hi, 1]
f(eta) = sol(eta)[4] - target
flo, fhi = f(eta_lo), f(eta_hi)
# Degenerate bracket (repeated η, or the root sitting exactly on a step) needs no solve.
flo == 0 && return eta_lo
fhi == 0 && return eta_hi
(flo * fhi > 0 || eta_hi <= eta_lo) && return eta_lo + (eta_hi - eta_lo) * (target - y_out[lo, 5]) / max(y_out[hi, 5] - y_out[lo, 5], eps())
return find_zero(f, (eta_lo, eta_hi), Roots.Brent())
end

"""
direct_fieldline_int(psifac, raw_profile, ro, zo, rs2)

Expand All @@ -250,9 +276,12 @@ from 1:5 rather than 0:4 as in Fortran.
- `y_out[:, 4]`: ∫(dl/(R²Bp))
- `y_out[:, 5]`: ∫(jac*dl/Bp)

- `sol`: the dense ODE solution, so callers can evaluate the trace at prescribed SFL angles
rather than resampling this surface's own solver steps (`nothing` for tracers without it).

- `bfield`: A `DirectBField` object with values at the integration start point.
"""
function direct_fieldline_int(psifac::Float64, raw_profile::DirectRunInput, ro::Float64, zo::Float64, rs2::Float64)::Tuple{Matrix{Float64},DirectBField}
function direct_fieldline_int(psifac::Float64, raw_profile::DirectRunInput, ro::Float64, zo::Float64, rs2::Float64)

# Find the starting point on the flux surface (outboard midplane)
psi0_guess = raw_profile.psio * (1.0 - psifac)
Expand Down Expand Up @@ -291,10 +320,12 @@ function direct_fieldline_int(psifac::Float64, raw_profile::DirectRunInput, ro::
callback = DiscreteCallback((u, t, i) -> true, refine_affect!; save_positions=(true, false))

prob = ODEProblem{true}(direct_fieldline_der!, u0, (0.0, 2π), params)
sol = solve(prob, Vern9(); callback=callback, reltol=equil_config.etol, abstol=1e-8, dt=2π / 200, adaptive=true, dense=false)
# Dense output lets the caller evaluate the trace at the SFL angles it actually wants, instead
# of splining this surface's solver-chosen steps and resampling (issue #376).
sol = solve(prob, Vern9(); callback=callback, reltol=equil_config.etol, abstol=1e-8, dt=2π / 200, adaptive=true, dense=true)

sol_matrix = reduce(hcat, sol.u::Vector{Vector{Float64}})'
return hcat(sol.t::Vector{Float64}, sol_matrix), bfield
return hcat(sol.t::Vector{Float64}, sol_matrix), bfield, sol
end

"""
Expand Down Expand Up @@ -495,18 +526,39 @@ robustness.
ff_deriv_val = zeros!(pool, Float64, 4)

for ipsi in (mpsi+1):-1:1 # outermost to innermost
y_out, bfield = fieldline_int(psi_nodes[ipsi], raw_profile, ro, zo, rs2)
y_out, bfield, sol = fieldline_int(psi_nodes[ipsi], raw_profile, ro, zo, rs2)
checkpoint!(pool, Float64)

# Fit data into temporary straight fieldline poloidal angle splines
ff_x_nodes = acquire!(pool, Float64, size(y_out, 1))
@. ff_x_nodes = @view(y_out[:, 5]) / y_out[end, 5]

ff_fs_nodes = acquire!(pool, Float64, size(y_out, 1), 4)
@. ff_fs_nodes[:, 1] = @view(y_out[:, 3])^2
@. ff_fs_nodes[:, 2] = @view(y_out[:, 1]) / (2π) - ff_x_nodes
@. ff_fs_nodes[:, 3] = bfield.f * (@view(y_out[:, 4]) - ff_x_nodes * y_out[end, 4])
@. ff_fs_nodes[:, 4] = @view(y_out[:, 2]) / y_out[end, 2] - ff_x_nodes
# Straight-fieldline angle x = normalised ∫jac·dl/Bp, monotone in the integration angle η.
#
# Sampling x at this surface's own solver steps and resampling onto theta_nodes leaves a
# resample error that is uncorrelated between neighbouring surfaces, i.e. white noise in ψ
# that grid refinement then amplifies (issue #376). With dense output we instead solve for
# the η where x hits each target node and evaluate there, so every surface is sampled at
# the same abscissae and the resample error at the output nodes is zero.
nff = sol === nothing ? size(y_out, 1) : mtheta + 1
ff_x_nodes = acquire!(pool, Float64, nff)
ff_fs_nodes = acquire!(pool, Float64, nff, 4)

if sol === nothing
@. ff_x_nodes = @view(y_out[:, 5]) / y_out[end, 5]
@. ff_fs_nodes[:, 1] = @view(y_out[:, 3])^2
@. ff_fs_nodes[:, 2] = @view(y_out[:, 1]) / (2π) - ff_x_nodes
@. ff_fs_nodes[:, 3] = bfield.f * (@view(y_out[:, 4]) - ff_x_nodes * y_out[end, 4])
@. ff_fs_nodes[:, 4] = @view(y_out[:, 2]) / y_out[end, 2] - ff_x_nodes
else
total_x = y_out[end, 5]
for itheta in 1:(mtheta+1)
x = theta_nodes[itheta]
eta = eta_at_sfl_angle(sol, y_out, x, total_x)
u = sol(eta)
ff_x_nodes[itheta] = x
ff_fs_nodes[itheta, 1] = u[2]^2
ff_fs_nodes[itheta, 2] = eta / (2π) - x
ff_fs_nodes[itheta, 3] = bfield.f * (u[3] - x * y_out[end, 4])
ff_fs_nodes[itheta, 4] = u[1] / y_out[end, 2] - x
end
end

ff_fs_nodes[end, :] .= ff_fs_nodes[1, :] # enforce periodic endpoint

Expand Down
4 changes: 2 additions & 2 deletions src/Equilibrium/DirectEquilibriumArcLength.jl
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ outboard midplane (Z = zo, R > ro) after a minimum arc-length guard.
"""
@with_pool pool function arclength_fieldline_int(
psifac::Float64, raw_profile::DirectRunInput, ro::Float64, zo::Float64, rs2::Float64
)::Tuple{Matrix{Float64},DirectBField}
)::Tuple{Matrix{Float64},DirectBField,Nothing}

psi0_guess = raw_profile.psio * (1.0 - psifac)
r = ro + sqrt(psifac) * (rs2 - ro)
Expand Down Expand Up @@ -142,6 +142,6 @@ outboard midplane (Z = zo, R > ro) after a minimum arc-length guard.
end

# bfield at the starting point carries F and P for the surface-averaged quantities
return y_out, bfield
return y_out, bfield, nothing
end

57 changes: 44 additions & 13 deletions src/ForceFreeStates/Fourfit.jl
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,7 @@ function build_kinetic_metric_matrices(equil::Equilibrium.PlasmaEquilibrium,
v[3, 3] = 2π * rs

# Raw g^ij (Fortran dcon_interface.f:933-937)
g12 = v[1, 1]*v[2, 1] + v[1, 2]*v[2, 2] + v[1, 3]*v[2, 3]
g12 = v[1, 1] * v[2, 1] + v[1, 2] * v[2, 2] + v[1, 3] * v[2, 3]
g13 = v[3, 3] * v[1, 3]
g22 = v[2, 1]^2 + v[2, 2]^2 + v[2, 3]^2
g23 = v[2, 3] * v[3, 3]
Expand Down Expand Up @@ -428,6 +428,33 @@ function build_kinetic_metric_matrices(equil::Equilibrium.PlasmaEquilibrium,
end


"""
core_capped_knots(xs, rationals) -> Vector{Int}

Indices of `xs` the EL coefficient splines keep, decoupling their knots from the equilibrium grid
in the packed core. A cubic spline's third-derivative jumps scale as (node error)/Δψ³, so
equilibrium-grade core packing amplifies tolerance-level node error into jumps that slave the
Euler-Lagrange step size. Near the axis every component is a Frobenius power law in ψ, and power
laws are scale-free, so log-uniform sampling (Δψ ≥ 0.05·ψ) resolves them at constant relative
accuracy — cubic interpolation of ψ^p on that grid errs by ~(0.05p)⁴/384, resolving even the
steepest component (p = m_max/2) to ~2e-4, well below where the physics responds. The capped region
ends at the innermost rational (or ψ = 0.1, whichever is smaller) and never drops a knot inside a
rational's resolution window, preserving the Δ′-stencil structure the equilibrium grid encodes
(`Equilibrium.RATIONAL_RES_RADIUS`).
"""
function core_capped_knots(xs::Vector{Float64}, rationals::Vector{Float64})::Vector{Int}
cap_edge = 0.1
isempty(rationals) || (cap_edge = min(cap_edge, minimum(rationals) - Equilibrium.RATIONAL_RES_RADIUS))
in_rational_window(x) = any(abs(x - r) <= Equilibrium.RATIONAL_RES_RADIUS for r in rationals)
keep = Int[1]
for i in 2:(length(xs)-1)
x = xs[i]
(x >= cap_edge || in_rational_window(x) || (x - xs[keep[end]]) >= 0.05 * x) && push!(keep, i)
end
push!(keep, length(xs))
return keep
end

"""
build_matrix_splines(equil::Equilibrium.PlasmaEquilibrium, intr::ForceFreeStatesInternal, metric::MetricData) -> MatrixSplines

Expand Down Expand Up @@ -607,19 +634,23 @@ function build_matrix_splines(equil::Equilibrium.PlasmaEquilibrium, intr::ForceF
# Create complex series interpolants with per-column extrap BC
# TODO: set powers. Do we need this yet? Only called if power_flag = true
itp_opts = (; extrap=ExtendExtrap())
keep = core_capped_knots(metric.xs, [s.psifac for s in intr.sing])
mxs = metric.xs[keep]
length(mxs) < length(metric.xs) &&
@info "EL coefficient-spline grid: $(length(metric.xs)) -> $(length(mxs)) knots after core density cap"
ideal = IdealMatrices(;
A_spline=cubic_interp(metric.xs, Series(amats_flat); itp_opts...),
B_spline=cubic_interp(metric.xs, Series(bmats_flat); itp_opts...),
C_spline=cubic_interp(metric.xs, Series(cmats_flat); itp_opts...),
D_spline_prim=cubic_interp(metric.xs, Series(dmats_flat); itp_opts...),
E_spline_prim=cubic_interp(metric.xs, Series(emats_flat); itp_opts...),
H_spline=cubic_interp(metric.xs, Series(hmats_flat); itp_opts...),
F_spline_lower=cubic_interp(metric.xs, Series(fmats_lower_flat); itp_opts...),
F_spline_prim=cubic_interp(metric.xs, Series(fmats_prim_flat); itp_opts...),
F_spline_gal=cubic_interp(metric.xs, Series(fmats_gal_flat); itp_opts...),
G_spline=cubic_interp(metric.xs, Series(gmats_flat); itp_opts...),
K_spline=cubic_interp(metric.xs, Series(kmats_flat); itp_opts...),
A_spline=cubic_interp(mxs, Series(amats_flat[keep, :]); itp_opts...),
B_spline=cubic_interp(mxs, Series(bmats_flat[keep, :]); itp_opts...),
C_spline=cubic_interp(mxs, Series(cmats_flat[keep, :]); itp_opts...),
D_spline_prim=cubic_interp(mxs, Series(dmats_flat[keep, :]); itp_opts...),
E_spline_prim=cubic_interp(mxs, Series(emats_flat[keep, :]); itp_opts...),
H_spline=cubic_interp(mxs, Series(hmats_flat[keep, :]); itp_opts...),
F_spline_lower=cubic_interp(mxs, Series(fmats_lower_flat[keep, :]); itp_opts...),
F_spline_prim=cubic_interp(mxs, Series(fmats_prim_flat[keep, :]); itp_opts...),
F_spline_gal=cubic_interp(mxs, Series(fmats_gal_flat[keep, :]); itp_opts...),
G_spline=cubic_interp(mxs, Series(gmats_flat[keep, :]); itp_opts...),
K_spline=cubic_interp(mxs, Series(kmats_flat[keep, :]); itp_opts...),
# Jacobian Fourier band ψ-spline, used for the power normalization in Free.jl
J_spline=cubic_interp(metric.xs, Series(jmats_flat); itp_opts...))
J_spline=cubic_interp(mxs, Series(jmats_flat[keep, :]); itp_opts...))
return MatrixSplines(; ideal)
end
Loading