Ideal MHD Stability (ForceFreeStates)
The ForceFreeStates module implements ideal MHD stability analysis for axisymmetric toroidal plasmas following the direct Newcomb criterion described in [Glasser 2016]. It solves the Euler-Lagrange (EL) system derived from the potential energy functional, identifies singular (rational) surfaces where resonant coupling occurs, and returns eigenmode energies, the tearing stability parameters Δ', and the full inter-surface Δ' matrix.
Physical background
Ideal MHD stability is determined by the sign of the perturbed potential energy
\[\delta W[\xi] = \int_0^{\psi_\mathrm{lim}} \mathcal{F}(\xi, \xi') \, d\psi,\]
where $\xi(\psi)$ is the poloidal displacement vector. The extremum of $\delta W$ over all admissible $\xi$ satisfies the Euler-Lagrange system [Glasser 2016, Eq. 24]:
\[\frac{d}{d\psi} \begin{pmatrix} U_1 \\ U_2 \end{pmatrix} = \begin{pmatrix} A & B \\ C & D \end{pmatrix} \begin{pmatrix} U_1 \\ U_2 \end{pmatrix}, \quad A = -Q\bar{F}^{-1}\bar{K}, \; B = Q\bar{F}^{-1}Q, \; C = \bar{G} - \bar{K}^\dagger\bar{F}^{-1}\bar{K}, \; D = \bar{K}^\dagger\bar{F}^{-1}Q,\]
where $\bar{F}$, $\bar{K}$, $\bar{G}$ are the MHD metric matrices in Fourier-mode space and $Q = \mathrm{diag}(1/(m - nq))$ is the singular factor. The Newcomb criterion states that the plasma is stable if and only if this system admits a regular solution that remains finite across every rational surface.
Key references
| Paper | Content |
|---|---|
| [Glasser 2016] Phys. Plasmas 23, 112506 | Newcomb criterion, EL system, standard DCON integration |
| [Glasser 2018a] Phys. Plasmas 25, 032507 | Riccati reformulation, reduced stiffness near singular surfaces |
| [Glasser 2018b] Phys. Plasmas 25, 032501 | STRIDE code: parallel FM integration, inter-surface Δ' matrix |
Integration methods
Three formalisms are available, selected by integrator. Forward and Riccati solve the same EL system and differ in numerical strategy and in what they leave behind for the rest of the pipeline: the forward driver returns dense displacement profiles, the Riccati driver returns the inter-surface $\Delta'$ matrix. Galerkin solves the outer region variationally instead, and is documented in docs/src/galerkin.md.
Forward integration
forward_eulerlagrange_integration is the baseline driver — our implementation of the standard DCON radial integration [Glasser 2016]. It integrates the EL ODE directly in $(U_1, U_2)$ using the adaptive 9th-order Vern9 solver. Near each rational surface the columns of $U_2$ that correspond to resonant modes are zeroed via Gaussian reduction (GR), keeping the solution bounded; transform_u! undoes the reduction at the end, so u_store comes back dense in the axis (Euler-Lagrange) basis. This is the reference path for correctness comparisons, the only path whose solution the perturbed-equilibrium stage can consume, and the only path that supports kinetic_factor > 0.
Enable with:
[ForceFreeStates]
integrator = "forward"Riccati integration
riccati_eulerlagrange_integration (the default) is our implementation of the STRIDE approach [Glasser 2018b], built on the dual Riccati reformulation [Glasser 2018a]. It decomposes the radial domain into independent chunks, integrates each chunk's fundamental-matrix (FM) propagator in parallel using Threads.@threads, then multiplies the propagators in order and applies each singular-surface crossing serially. It is the only driver that produces the inter-surface $\Delta'$ matrix. Because chunk endpoints are all it stores, u_store is sparse and stays in the Riccati basis — dense $\xi$ profiles and the ForceFreeStates/Solutions/ForwardIntegration/xi_* datasets require the forward driver.
Crossings and the outer-plasma re-integration use the dual Riccati matrix $S = U_1 \cdot U_2^{-1}$ [Glasser 2018a, Eq. 19]:
\[\frac{dS}{d\psi} = w^\dagger \bar{F}^{-1} w - S\bar{G}S, \qquad w = Q - \bar{K}S.\]
$S$ remains bounded near rational surfaces (where $U_1, U_2$ grow exponentially). Rather than integrating the quadratic Riccati ODE directly (which blows up when $|S|$ is large), the code integrates the linear EL system with sing_der! as the RHS and recovers $S = U_1 U_2^{-1}$ via periodic renormalization — an approach that is mathematically equivalent to O(Δψ) but uses Vern9's full 9th-order accuracy. Renormalization is triggered whenever $\max(|U_1|)$ or $\max(|U_2|)$ exceeds the threshold ucrit, and is forced at the end of each chunk. At singular surface crossings, riccati_cross_ideal_singular_surf! applies the small-asymptotic matching directly in column ipert_res — without Gaussian reduction — and renormalizes to $(S, I)$.
Enable with:
[ForceFreeStates]
integrator = "riccati"
nchunks = 0 # 0 = auto: derived from the singular-surface count aloneChunking and thread independence
balance_integration_chunks splits the base chunks until the count reaches a target derived from the number of singular surfaces, max(2 m_s + 3, 8(m_s + 1) + m_s), where $m_s$ is msing. Setting nchunks overrides that target; a value below the $2 m_s + 3$ floor is clamped up with a warning. The target never consults Threads.nthreads(), and every chunk integrates independently from identity initial conditions, so the results do not depend on how many threads julia -t provides — threads change wall-clock only.
Bidirectional integration for large N
For large mode counts the FM propagator for a chunk ending near a rational surface is ill-conditioned: the EL solutions grow exponentially toward the rational surface, so the forward FM amplifies numerical errors. GPEC follows the STRIDE approach [Glasser 2018b, Sec. III.A]: the crossing chunk (the last sub-chunk before each rational surface) is integrated backward — from the rational surface toward the interior — producing a well-conditioned backward FM $\Phi_L$. The forward propagation is recovered as $\Phi_L^{-1}$ via an LU solve in serial assembly, which is accurate precisely because $\Phi_L$ is well-conditioned.
The implementation uses a direction field on IntegrationChunk:
direction = +1: standard forward integration,tspan = (ψ_start, ψ_end).direction = -1: backward integration,tspan = (ψ_end, ψ_start)(reversed).
chunk_el_integration_bounds(...; bidirectional=true) assigns direction = -1 to every crossing chunk. balance_integration_chunks preserves this: the sub-chunk closest to the rational surface inherits direction, while the earlier sub-chunk always gets direction=+1.
Accuracy (N=26, DIIID-like example): energy eigenvalue within 2% of the forward path. The residual ~2% gap comes from the different crossing convention (Riccati-style direct zeroing vs GR), not from ODE tolerance; it is present at every thread count.
Galerkin
integrator = "galerkin" solves the same EL system variationally instead of integrating it: the outer region is discretized on packed Hermite-cubic elements and solved as one global banded system, giving the RDCON resistive $\Delta'$ matrix and the PEST-3 matching blocks. It computes its own vacuum response and returns no free-boundary energies, no ODE trace, and no fixed-boundary crit scan. With gal_match_flag it also matches the inner layer, producing a driven $\xi$ solution that PerturbedEquilibrium consumes. Kinetic runs are not supported. See docs/src/galerkin.md for the solver and its gal_* knobs.
Enable with:
[ForceFreeStates]
integrator = "galerkin"Local stability: Mercier and ballooning (s–α)
Setting local_stability_flag = true in [ForceFreeStates] runs a local high-$n$ stability scan over every flux surface, in addition to the global ideal analysis above. For the derivation and implementation details behind these diagnostics, see Ballooning and Mercier Local Stability. Three diagnostics are produced and stored under the LocalStability/ HDF5 group, each a profile in normalized poloidal flux $\psi$:
- Mercier criterion $D_I$ (
LocalStability/D_I) — the ideal interchange criterion. A surface is Mercier-unstable where $D_I > 0$. It is evaluated from the $\det(\bar{d}_0)$ of the integrated local-mode matrix. - Resistive interchange $D_R$ (
LocalStability/D_R) — the Glasser–Greene–Johnson resistive interchange criterion $D_R = D_I + (H - 1/2)^2$. The $D_I$ term is the same $\det(\bar{d}_0)$ value reported inLocalStability/D_I; $H$ is computed from the legacy Mercier/GGJ flux-surface averages of the field and metric quantities. $D_R > 0$ indicates resistive interchange instability. - Ballooning $\Delta'$ (
LocalStability/ballooning_Delta_prime) — the high-$n$ ballooning stability index, obtained by integrating the ballooning equation along the field line and taking the jump in the logarithmic derivative of the solution between the two asymptotic ends.
LocalStability/ballooning_Delta_prime is the local high-$n$ ballooning index and is distinct from the resistive tearing $\Delta'$ described in the next section, which is written under SingularSurfaces/ and PerturbedEquilibrium/SingularCoupling/Delta_prime. They measure different instabilities; do not confuse them.
s–α diagram
The local criteria can be mapped over a two-parameter $(p', q')$ scan at a single flux surface — the classic s–α (shear–pressure) stability diagram. For a chosen surface the pressure gradient $p'$ and shear $q'$ are scaled away from their equilibrium values, and $\Delta'$ and $D_I$ are recomputed on the resulting grid. The $\Delta' = 0$ and $D_I = 0$ contours bound the ballooning- and Mercier-stable regions, with the equilibrium operating point marked inside.
The example script examples/DIIID-like_ideal_example/analyze_example.jl demonstrates the full workflow: it plots the $s$ and $\alpha$ profiles, the $D_I$ and ballooning $\Delta'$ profiles, and the 2-D s–α maps with their zero contours, using salpha_reference, compute_ballooning_stability!, and scan_delta_prime_map.
Δ' tearing stability parameter
Per-surface Δ' (delta_prime)
At each rational surface the asymptotic matching condition gives the tearing stability parameter [Glasser 2016]:
\[\Delta'_s = \frac{c_{a,r}[i_s,i_s,2] - c_{a,l}[i_s,i_s,2]}{4\pi^2 \psi_0},\]
where $c_{a,l}$ and $c_{a,r}$ are the left and right asymptotic coefficients at surface $s$, and $i_s$ is the column index of the resonant mode. Positive $\Delta' > 0$ indicates a tearing-unstable surface.
The Riccati and parallel FM paths populate intr.sing[s].delta_prime (a length-$n_\mathrm{res}$ vector) inline during each crossing. A companion vector delta_prime_col (length N) stores the coupling of all poloidal modes to the resonant mode at surface $s$:
\[(\Delta'_\mathrm{col})_{j,i} = \frac{c_{a,r}[j,i_s,2] - c_{a,l}[j,i_s,2]}{4\pi^2 \psi_0}.\]
The diagonal element $(\Delta'_\mathrm{col})_{i_s,i}$ equals delta_prime[i] exactly by construction.
Inter-surface Δ' matrix (delta_prime_matrix)
compute_delta_prime_matrix! assembles an $m_\mathrm{sing} \times m_\mathrm{sing}$ inter-surface tearing matrix following the STRIDE global BVP [Glasser 2018b, Sec. III.B]. Internally, the solver builds a raw $2 m_\mathrm{sing} \times 2 m_\mathrm{sing}$ matrix whose rows/columns index the left and right inner-layer boundaries of every rational surface; the stored PEST3-convention $\Delta'$ is the four-term combination $\text{dp\_raw}[2i, 2j] - \text{dp\_raw}[2i, 2j{-}1] - \text{dp\_raw}[2i{-}1, 2j] + \text{dp\_raw}[2i{-}1, 2j{-}1]$ that folds the raw block into a per-surface response. The BVP unknowns are the plasma state at the left and right inner-layer boundaries of every rational surface; the driving terms are unit-amplitude asymptotic solutions at each boundary. The resulting matrix encodes the full plasma response between all pairs of surfaces and is required for resistive stability analysis of multi-surface configurations.
The BVP is well-conditioned because it is formulated using the split $(\Phi_R, \Phi_L)$ propagator blocks from bidirectional integration rather than the monolithic forward product $\Phi_L^{-1} \Phi_R$ (which is ill-conditioned for large N):
\[\Phi_R[j] \cdot x_R[j-1] - \Phi_L[j] \cdot x_L[j] = 0 \quad \text{(junction at } \psi_m[j]\text{)},\]
where $\Phi_R[j]$ is the forward FM product from $\psi_{R,j-1}$ to the junction, and $\Phi_L[j]$ is the backward crossing FM from $\psi_{L,j}$ to the junction.
The matrix is written to the HDF5 output under SingularSurfaces/Delta_prime_matrix. The Galerkin integrator computes the same quantity in the same PEST-3 convention and publishes it on the same path, so downstream consumers (SLAYER among them) never branch on which formalism ran.
Configuration reference
All ForceFreeStates options are set in the [ForceFreeStates] section of gpec.toml.
[ForceFreeStates]
# Integration driver
integrator = "riccati" # "forward" for dense xi profiles and kinetic runs; "galerkin" for the RDCON outer-region solve
nchunks = 0 # Riccati chunk-count target (0 = auto, from msing alone)
# Mode space
nn_low = 1 # lowest toroidal mode number
nn_high = 1 # highest toroidal mode number
delta_mlow = 0 # extra low poloidal modes (m < mlow)
delta_mhigh = 0 # extra high poloidal modes (m > mhigh)
# ODE solver
numsteps_init = 200 # initial step budget per chunk
numunorms_init = 50 # renorm checkpoint budget
reltol = 1e-6 # ODE relative tolerance
# Local stability
local_stability_flag = false # scan Mercier D_I, resistive D_R, and ballooning Δ' over ψ
# Output
verbose = true
write_outputs_to_HDF5 = trueThe number of Julia threads is controlled at startup via -t N or the JULIA_NUM_THREADS environment variable; it is not a runtime parameter.
API Reference
The Galerkin Δ′ solver (src/ForceFreeStates/Galerkin/) is documented separately in docs/src/galerkin.md.
GeneralizedPerturbedEquilibrium.ForceFreeStates.ChunkPropagator — Type
ChunkPropagatorFundamental matrix for one integration chunk, stored as two N×N×2 solution blocks. Represents the propagator Φ(ψ₂,ψ₁) computed by integrating the EL ODE from two identity-block initial conditions:
block_upper_ic: result of integrating with IC = (IN, 0N) (U₁ = I, U₂ = 0)block_lower_ic: result of integrating with IC = (0N, IN) (U₁ = 0, U₂ = I)
Applying the propagator to the current state u_prev:
u₁new = blockupperic[:,:,1] · u₁prev + blockloweric[:,:,1] · u₂prev u₂new = blockupperic[:,:,2] · u₁prev + blockloweric[:,:,2] · u₂prev
Since each chunk starts from a bounded identity IC (rather than the accumulated state), exponential growth within a chunk does not affect the conditioning of the overall assembly. This enables Threads.@threads parallel integration across all chunks.
GeneralizedPerturbedEquilibrium.ForceFreeStates.DebugSettings — Type
DebugSettings
A mutable struct containing settings for debugging and benchmarking output.
Fields
output_benchmark_data::Bool- Flag to output benchmark data for comparison between codesgal_basis_output::Bool- Write the raw Galerkin outer-region basis functions (per-interval, unconstrained at the rationals) underGalerkinIntegration/Basis/. Solver internals for development verification, not physics output.
GeneralizedPerturbedEquilibrium.ForceFreeStates.DeltaPrimeData — Type
DeltaPrimeDataThe solve's Δ′/outer-region matching payload, in one formalism-independent layout. The Riccati/STRIDE boundary-value problem and the RDCON Galerkin solve compute the same quantities in the same PEST-3 convention — the four parity blocks are the identical ± combination of the raw side-major matrix in both (pest3_decompose, Riccati.jl, and gal_pest3_blocks, GalerkinSolve.jl, both porting Fortran gal_write_pest3_data) — so consumers never branch on which integrator ran.
Every matrix is indexed by the singular surfaces the producing formalism actually solved across, ordered core→edge: result.surfaces for Riccati, the in-domain in-band subset of it for Galerkin (gal_resonant_surfaces). Side-major orderings run [L_s1, R_s1, L_s2, R_s2, …].
Fields
matrix::Matrix{ComplexF64}- Inter-surface Δ′ of shape (msing × msing) in PEST3 convention, the tearing↔tearing parity projection ofraw. Same asDeltaof the PEST-3 block set. Both formalisms.raw::Matrix{ComplexF64}- Raw outer-region matching matrix D′ of shape (2msing × 2msing), side-major on both axes. Both formalisms.coil::Matrix{ComplexF64}- Edge coil-response matrix of shape (2msing × numperttotal); column k is the resonant small-solution response at each surface side to a unit source on edge poloidal mode k. Riccati fills it from the vacuum-edge BVP, Galerkin from the `galrpecflag` columns (transposed at pack time from the (numperttotal × 2msing) block the Galerkin solve produces). Empty when neither ran.A::Union{Nothing,Matrix{ComplexF64}}- PEST-3 interchange↔interchange block (msing × msing). Galerkin only;nothingfor Riccati, which persists onlyrawand recovers the blocks on demand viapest3_decompose.B::Union{Nothing,Matrix{ComplexF64}}- PEST-3 interchange↔tearing block; seeA.Gamma::Union{Nothing,Matrix{ComplexF64}}- PEST-3 tearing↔interchange block; seeA.
GeneralizedPerturbedEquilibrium.ForceFreeStates.EdgeScanState — Type
EdgeScanState
Holds the state and results for the edge dW stability scan over ψ ∈ [psiedge, psilim]. Initialized and populated by findmax_dW_edge!; results written to HDF5 under EdgeScan/. The energies are generalized (W, N) pencil values: power-normalized and invariant to the working (Jacobian) coordinate (see power_norm_matrix!).
Fields
wvmat- Precomputed wv matrix spline (raw, no singfac); singfac applied analytically infree_compute_total.wv_hint::Base.RefValue{Int}- Search hint for wvmat spline (different grid from equilibrium profiles).psi, q- ψ and q values at each edge scan step.total_eigenvalue, plasma_energy, vacuum_energy, vacuum_eigenvalue- Power-normalized energy components at each step (NaN for steps where the wp solve was singular). These drive the truncation choice and are written toEdgeScan/.
GeneralizedPerturbedEquilibrium.ForceFreeStates.ForceFreeStatesControl — Type
ForceFreeStatesControlAn immutable struct containing 'ForceFreeStates' parameters set by the user in gpec.toml.
Fields
verbose::Bool- Enable verbose outputlocal_stability_flag::Bool- Enable local stability analysis (D_Iand ballooning)vac_flag::Bool- Enable vacuum region calculationmthvac::Int- Number of vacuum poloidal grid points (corresponds tomthetain VacuumInput)nzvac::Int- Number of vacuum toroidal grid points (corresponds tonzetain VacuumInput3D)sing_start::Int- Start integration at thesing_start-th singular surfacenn_low::Int- Lower bound for toroidal modesnn_high::Int- Upper bound for toroidal modesdelta_mlow::Int- Expands lower bound of Fourier harmonics by delta_mlowdelta_mhigh::Int- Expands upper bound of Fourier harmonics by delta_mhighnstep::Int- Maximum number of integration steps (not yet implemented)ksing::Int- Singular surface handling parametereulerlagrange_tolerance::Float64- Relative tolerance for ODE integration of Euler-Lagrange equationsucrit::Float64- Critical value of unorm ratio to trigger solution normalization. In the standard path it triggers Gaussian reduction; in the Riccati path it triggersrenormalize_riccati_inplace!. Default1e4empirically keeps max(|U₁|, |U₂|) in O(1)–O(10⁴) over the integration domain on DIII-D / Solovev sweeps; lower triggers excess renorms without accuracy gain, higher risks overflow before the next renorm.numsteps_init::Int- Initial array size for ODE data storagenumunorms_init::Int- Initial array size for solution normalization datasingfac_min::Float64- Fractional distance from rational q at which ideal jump condition is enforcedset_psilim_via_dmlim::Bool- Truncate the integration domain at(last_rational_q + dmlim) / nrather than atqhigh/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 = trueis 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 setset_psilim_via_dmlim = falseand letqhigh/psihighcontrol the truncation. Multi-nruns are not supported by this truncation (the "outermost rational + dmlim / n" depends on whichn); whenset_psilim_via_dmlim = truewithnn_low != nn_high,sing_lim!warns and falls back toqhigh/psihigh. Defaulttrue.dmlim::Float64- Distance beyond last rational surface (normalised ∈ [0,1) in units of 1/n). Only used whenset_psilim_via_dmlimis true. Fortran STRIDE convention is 0.2 (truncate 20 % of one rational-surface spacing above the last surface), retained here.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).qhigh::Float64- Integration terminated at q limit determined by minimum of qhigh and qa from equilkinetic_source::String- Kinetic matrix source: "fixed" (X-shaped test matrices scaled by kinetic_factor relative to ideal matrix Frobenius norms; Ak, Dk, Hk Hermitian, Bk, Ck, Ek non-Hermitian), "calculated" (PENTRC — not yet implemented)kinetic_factor::Float64- Dimensionless scaling factor for kinetic matrices. Zero (the default) disables the kinetic path; any positive value enables it and scales the kinetic matrices: when kineticsource="fixed", scales X-shaped test matrices relative to ideal matrix norms; when kineticsource="calculated", applied as uniform post-hoc multiplier to W and T components.qlow::Float64- Integration terminated at q limit determined by minimum of qlow and q0 from equilpsiedge::Float64- If less than psilim, records a dW(ψ) diagnostic scan over [psiedge, psilim] on odet.edgescan. The integration domain (psilim) is always controlled by qhigh / psihigh and is not modified by this scan (unless `truncateatdWpeak=true`, see caveats below).truncate_at_dW_peak::Bool- Whentrueandpsiedge < psilim, the edge-dW scan's peak location is adopted as the new physical plasma edge —intr.psilim/intr.qlim/odet.uare pulled back to the peak, AND the FM Δ' chunks/propagators are made self-consistent with the new boundary (the chunk that straddles the peak is rebuilt + re-integrated; any chunks past the peak are dropped). This reproduces the spirit of the original oderecordedge heuristic from Fortran STRIDE while keeping Δ' and δW well-defined at the new boundary. The Δ' metric is still physically dependent on where the peak falls in the edge band, so use this flag deliberately when you mean to scan against the peak-defined edge (e.g. for studying edge-mode regimes); leave atfalse(default) for the full-domain Δ' atqhigh/psihigh/dmlim.diagnose::Bool- Enable diagnostic output (not yet implemented)diagnose_ca::Bool- Enable asymptotic coefficient diagnostics (not yet implemented)write_outputs_to_HDF5::Bool- Write results to HDF5 formatHDF5_filename::String- Name of HDF5 output filesave_interval::Int- Save every Nth ODE step (1=all, 10=every 10th). Always saves near rational surfaces. (Same aseuler_stepin the Fortran)force_termination::Bool- Terminate after force-free states (skip perturbed equilibrium calculations)integrator::String- Which formalism integrates the Euler-Lagrange system."forward"sweeps the plasma serially with Gaussian reduction and returnsu_store/du_store/xi_s_storedense in the axis (EL) basis — the only convention PerturbedEquilibrium and FieldReconstruction consume correctly, and the only path that supportskinetic_factor > 0."riccati"(default) runs the chunked fundamental-matrix propagator driver (Glasser 2018 Phys. Plasmas 25, 032507): chunks are integrated independently from identity initial conditions and assembled serially with Riccati-style crossings, which is the only way to obtain the singular-surface Δ' matrix for the tearing-mode solvers downstream, but leavesu_storeas sparse chunk-endpoint Riccati states, so dense ξ profiles are unavailable."galerkin"solves the same Euler-Lagrange system variationally instead of by radial ODE integration — the RDCON outer-region singular Galerkin method (Glasser, Wang & Park 2016 Phys. Plasmas 23, 112506), which discretizes the displacement on packed Hermite-cubic elements and solves one global banded system — producing the resistive Δ′ matrix and, whengal_match_flagis set, the RPEC inner-layer-matched ξ; it computes its own vacuum response and returns no free-boundary energies, and does not supportkinetic_factor > 0. Requiressingfac_min != 0for"riccati".nchunks::Int- Target number of Riccati integration chunks.0(the default) derives the count from problem structure alone:max(2·msing + 3, 8·(msing + 1) + msing), enough sub-chunks per segment to keep the accumulated propagator products well-conditioned. An explicit value below2·msing + 3is clamped up with a warning. Chunk sizing never consultsThreads.nthreads(), so Riccati outputs are identical whatever thread countjulia -tprovides; threads only change wall-clock.extended_precision_bvp::Bool- Whentrue(default), promote the Δ' BVP linear system toComplex{Double64}(~31 digits) for the LU solve and PEST3 combination. Guards against catastrophic cancellation in the PEST3 four-term combination (dp_raw entries can be 10⁴–10⁵× larger than the result; the imaginary part of off-diagonal Δ' is particularly sensitive). Disabling (false) saves ~1.5–2× the BVP solve time but on DIIID-class equilibria the imaginary Δ' components can drift by factors of 2–5×; only disable for performance experiments on cases where Float64 has been validated against Double64.
GeneralizedPerturbedEquilibrium.ForceFreeStates.ForceFreeStatesInternal — Type
ForceFreeStatesInternalA mutable struct holding internal state variables for stability calculations.
Fields
dir_path::String- Directory path for input/output filesmlow::Int- Lowest poloidal mode numbermhigh::Int- Highest poloidal mode numbermpert::Int- Number of poloidal modes (mhigh - mlow + 1)nlow::Int- Lowest toroidal mode number, resolved fromctrl.nn_low/nn_highnhigh::Int- Highest toroidal mode number, resolved fromctrl.nn_low/nn_highnpert::Int- Number of toroidal modes (nhigh - nlow + 1)numpert_total::Int- Total number of modes (mpert × npert)msing::Int- Number of ideal singular surfaceskmsing::Int- Number of kinetic singular surfaces (det(F̄) near-zeros)sing::Vector{SingType}- Vector of ideal singular surface datakinsing::Vector{SingType}- Vector of kinetic singular surface datakinsing_scan_psi::Vector{Float64}- ψ grid used byfind_kinetic_singular_surfaces!for the cond(F̄) scan (empty unless the finder has run)kinsing_scan_cond::Vector{Float64}- cond(F̄) values on that grid; the finder locates peaks that exceedkinsing_scan_thresholdkinsing_scan_threshold::Float64- Threshold on cond(F̄) used to accept a peak as a kinetic singular surfacepsilim::Float64- Flux limit for integrationqlim::Float64- Safety factor at psilimq1lim::Float64- Safety factor derivative at psilimwall_settings::Vacuum.WallShapeSettings- Wall shape settings for vacuum calculations
GeneralizedPerturbedEquilibrium.ForceFreeStates.FreeBoundaryResult — Type
FreeBoundaryResultResult of the free-boundary calculation, returned by free_run. All matrices are in the ξ Fourier basis and are numpert_total × numpert_total; the energies are generalized (W, N) pencil values, power-normalized and invariant to the working (Jacobian) coordinate.
Fields
wt::Matrix{ComplexF64}- Eigenvector matrix of W·v = λ·N·v. Columns are eigenmodes sorted most-unstable first, normalized to unit power norm v†·N·v = 1.wt0::Matrix{ComplexF64}- Total-energy matrix W = wp + wv before diagonalisationwp::Matrix{ComplexF64}- Plasma energy matrixwv::Matrix{ComplexF64}- Vacuum energy matrix, singfac-scaled atqlimep::Vector{ComplexF64}- Plasma energy per eigenmode (power quotient v†·wp·v with v†·N·v = 1)ev::Vector{ComplexF64}- Vacuum energy per eigenmode (power quotient v†·wv·v with v†·N·v = 1)et::Vector{ComplexF64}- Total energy eigenvalues of the pencil (W, N); et = ep + ev per moden_tor_idx::Vector{Int}- 0-based toroidal mode number index of each sorted eigenvaluevacuum_eigenvalue::Float64- Least stable (minimum) eigenvalue of the pencil (wv, N), clamped to zeroplasma_pts,wall_pts::Matrix{Float64}- Cartesian (x, y, z) surface coordinates,numpoints × 3, retained for HDF5 output
GeneralizedPerturbedEquilibrium.ForceFreeStates.IntegrationChunk — Type
IntegrationChunkA struct representing a region of integration in the Euler-Lagrange solver.
Fields
psi_start::Float64- Starting ψ coordinate for this integration regionpsi_end::Float64- Ending ψ coordinate for this integration regionneeds_crossing::Bool- Whether a rational surface crossing is needed after this chunkising::Int- Index of the singular surface associated with this chunk (0 if none)direction::Int- Integration direction: +1 forward (axis→edge), -1 backward (edge→axis). Fordirection=-1chunks,psi_start<psi_endbut integration proceeds frompsi_endtowardpsi_start. The resulting propagator maps state atpsi_end→ state atpsi_start. Used in bidirectional parallel FM to produce well-conditioned crossing-chunk propagators: solutions that grow exponentially forward (toward a singularity) decay when integrated backward, so the backward propagator is well-conditioned.
GeneralizedPerturbedEquilibrium.ForceFreeStates.ModeSpace — Type
ModeSpaceSupertype for objects that carry the resolved (m, n) mode space — mlow, mhigh, mpert, nlow, nhigh, npert, numpert_total. Both the solve-time scratch ForceFreeStatesInternal and the published ForceFreeStatesResult are ModeSpaces, so kernels that need nothing but the mode indexing (el_derivatives!, materialize_derivative_stores!, build_kinetic_metric_matrices) accept either.
GeneralizedPerturbedEquilibrium.ForceFreeStates.OdeState — Type
OdeState
A mutable struct to hold the state of the ODE solver used by the ForceFreeStates integration routines. This struct stores configuration parameters used to allocate arrays, the evolving stored solution during integration, diagnostic arrays used for normalization / Gaussian reduction, and a small set of temporary matrices and factors used to compute singular-layer corrections.
Fields
numpert_total::Int- Total number of Fourier mode combinations (m × n) used in the calculation.numunorms_init::Int- Initial allocation size for the number of normalization operations recorded.msing::Int- Number of singular surfaces in the equilibrium (used to size asymptotic coefficient arrays).numsteps_init::Int- Initial allocation size for the number of integration steps to store.step::Int- Current integration step index (1-based, likeistepin the original Fortran).psi_store::Vector{Float64}- Stored psi values at each saved integration step (lengthnumsteps_init).q_store::Vector{Float64}- Stored q values at each saved integration step (lengthnumsteps_init).u_store::Array{ComplexF64,4}- Stored solution arrays at each saved step with shape(numpert_total, numpert_total, 2, numsteps_init)(complex solution state used by the solver).du_store::Array{ComplexF64,3}- dΞψ/dψ (the u₁ block only) at each saved step, shape `(numperttotal, numperttotal, step). Empty untilmaterializederivative_stores!` fills it, except on the galerkin-matched path which supplies the analytic derivative at construction. du₂/dψ is never stored densely — its only consumer evaluates it on demand at bracket nodes.xi_s_store::Array{ComplexF64,3}- Clebsch displacement Ξs at each saved step, eq. 18 of Glasser 2016, shape `(numperttotal, numperttotal, step). Empty until materialized, same asdustore`.u_store_el_basis::Bool- True whenu_storeholds the Euler-Lagrange state(u₁, u₂), so the derivative kernel can be re-applied to it. False on the sparse parallel path, whose stored columns are chunk-endpoint Riccati matrices;materialize_derivative_stores!refuses to run there.du_store_populated::Bool- True oncedu_store/xi_s_storehold valid data in the final (post-transform, post-normalization) basis. Set bymaterialize_derivative_stores!or by the galerkin-matched constructor; stays false where the stores cannot be materialized, e.g. the sparse parallel path whose solution is in the Riccati basis.crit_store::Vector{Float64}- Stored crit parameter values (smallest eigenvalue of W⁻ꜝ) (lengthnumsteps_init).ca_r::Array{ComplexF64,4}- Asymptotic coefficients just to the right of each singular surface with shape(numpert_total, numpert_total, 2, msing).ca_l::Array{ComplexF64,4}- Asymptotic coefficients just to the left of each singular surface with shape(numpert_total, numpert_total, 2, msing).ca_populated::Bool- True once an ideal singular-surface crossing has filledca_l/ca_r; kinetic and galerkin-matched runs never populate them and leave this false, and the HDF5 writer then emits zero-extentca_left/ca_rightdatasets instead of unpopulated arrays.edge_scan::EdgeScanState- Edge dW scan state and results. Initialized as a disabled sentinel (Nedge=0) and replaced by `findmaxdW_edge!` when a scan runs.psifac::Float64- Current normalized flux coordinate for the integrator.q::Float64- Safety factor value atpsifac(current q during integration).u::Array{ComplexF64,3}- Current working solution arrays with shape(numpert_total, numpert_total, 2).ising_start::Int- Index of the starting singular surface to be crossed during integration.psimax::Float64- Maximum psi value for which the integrator is allowed to run in next integration region.needs_crossing::Bool- Flag indicating whether a rational surface needs to be crossed after the current integration region.nzero::Int- Count of detected zero crossings (used for diagnostics).new::Bool- Flag indicating whether a newunorm0should be computed after a fixup.Initialization parameters
unorm::Vector{Float64}- Current norms of the solution vectors (lengthnumpert_total).unorm0::Vector{Float64}- Reference/initial norms of the solution vectors (lengthnumpert_total).Saved data throughout integration
ifix::Int- Number of normalization operations performed (index into normalization arrays).
Total ODE solver steps taken (all steps, not just saved ones)
index::Array{Int,2}- Index matrix used for sorting solution norms with shape(numpert_total, numunorms_init).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) (lengthnumunorms_init).zeroed_idx::Vector{Vector{Int}}- For each ideal rational surface jump, a vector of indices of solutions that were zeroed. # Data for integratorfixfac::Array{ComplexF64,3}- Fix-up factors for Gaussian reduction with shape(numpert_total, numpert_total, numunorms_init).fixstep::Vector{Int64}- Step indices (psi step positions) at which normalization/fixups were performed (lengthnumunorms_init).
GeneralizedPerturbedEquilibrium.ForceFreeStates.SingAsymptotics — Type
SingAsymptoticsA struct containing asymptotic expansion data for ideal ForceFreeStates calculations at a singular surface. This data is computed on-demand during singular surface crossings in cross_ideal_singular_surf!.
Fields
alpha::Vector{ComplexF64}- Resonant matrix eigenvaluesr1::Vector{Int}- Resonant indices along first indexr2::Vector{Int}- Resonant indices along second indexn1::Vector{Int}- Nonresonant indices along first indexn2::Vector{Int}- Nonresonant indices along second indexpower::Vector{ComplexF64}- Power series coefficientsvmat::Array{ComplexF64,4}- Power series of V matrix for asymptotic analysismmat::Array{ComplexF64,4}- Power series of M matrix for asymptotic analysism0mat::Matrix{ComplexF64}- Zeroth order M matrix projected onto resonant subspace
GeneralizedPerturbedEquilibrium.ForceFreeStates.SingType — Type
SingTypeA mutable struct holding data related to the singular surfaces in the equilibrium.
Fields
psifac::Float64- Normalized flux coordinate at the singular surfacerho::Float64- Radial coordinate (√ψ)m::Vector{Int}- Poloidal mode number(s)n::Vector{Int}- Toroidal mode number(s)q::Float64- Safety factor (= m/n)q1::Float64- Derivative of safety factor with respect to ψdelta_prime::Vector{ComplexF64}- STUB (not physically valid). Per-surface ca-based Δ' estimate retained for future work / debugging only. The physically valid Δ' isForceFreeStatesInternal.delta_prime_matrix, computed via the STRIDE global BVP (Glasser 2018 PoP 25, 032501). Do not use this field for tearing-stability analysis; do not expect agreement withdelta_prime_matrix.delta_prime_col::Matrix{ComplexF64}- STUB (not physically valid). Per-surface ca-based Δ' column retained for future work / debugging only. Shape (numperttotal × nresmodes); `deltaprimecol[j, i] = (car[j,ipertresi,2] - cal[j,ipertresi,2]) / (4π²·psio). The diagonal element matches the (also stubbed)deltaprime[i]. Only populated for the Riccati/parallel FM paths. The physically valid Δ' isForceFreeStatesInternal.deltaprimematrix`; this field exists for future development on intra-surface coupling diagnostics, not for production use.
GeneralizedPerturbedEquilibrium.ForceFreeStates.ForceFreeStatesResult — Type
ForceFreeStatesResultThe published product of a force-free-states solve: everything downstream stages (PerturbedEquilibrium, KineticForces, SLAYER, the HDF5 writer) are allowed to read. ForceFreeStatesInternal remains solve-time scratch and does not cross a module boundary once this has been built.
Optional fields are Union{Nothing,T} and follow a presence-equals-capability rule: a consumer that needs one gates on require (or require_solution for the ξ profiles) and warns-and-skips rather than erroring, so a result from an integrator that cannot supply a given product still flows through the pipeline.
A jump condition at the rational surfaces is always applied before a final result is built, so bpen and closure are always present.
Fields
integrator::Symbol-:forward,:riccati, or:galerkin.control::ForceFreeStatesControl- Provenance snapshot of the controls the solve ran with.equil::Equilibrium.PlasmaEquilibrium- The equilibrium actually integrated against (the re-formed one on the two-pass path).mlow,mhigh,mpert,nlow,nhigh,npert,numpert_total- Resolved (m, n) mode space; seeModeSpace.psilow,psilim,qlim,q1lim- Integration bounds, and q with its ψ-derivative atpsilim.dir_path::String- Working directory of the run.wall_settings::Vacuum.WallShapeSettings- Wall shape used by the vacuum calculation.debug_settings::DebugSettings- Diagnostic dump settings (the[DEBUG]deck section /debug=API keyword).metric::MetricData,ffit::FourFitVars- Metric data and Euler-Lagrange matrix interpolants.surfaces::Vector{SingType}- Ideal singular surfaces in the integration domain, with asymptotic bases and GGJ coefficients.kinetic::NamedTuple- Kinetic singular-surface scan (kmsing,kinsing,scan_psi,scan_cond,scan_threshold); empty unless the finder ran.closure::Symbol- How the basis is closed at the rationals::ideal(the ideal jump condition was imposed) or:matched(an inner-layer solution was matched in).bpen::Matrix{ComplexF64}-(msing × numpert_total)penetrated resonant field from the inner layer, per surface and driving mode; all zeros under:idealclosure.solution::Union{Nothing,SolutionProfiles}- THE solve's ξ solution;nothingwhen the formalism produced none (Riccati, and Galerkin without a match).diagnostics::Union{Nothing,OdeState}- The integrator's raw ODE state (ψ trace,crit, edge scan, asymptotic coefficients). Written to HDF5; not a consumable solution.wp::Union{Nothing,Matrix{ComplexF64}}- Fixed-boundary plasma energy matrixW_p = (U₂·U₁⁻¹)/ψ₀²atpsilim. Present for any Euler-Lagrange sweep (forward or Riccati) regardless ofvac_flag— the fixed-boundary run's energy product — and identical tofree_boundary.wpwhen the free-boundary calculation ran.free_boundary::Union{Nothing,FreeBoundaryResult}- Free-boundary energies and eigenmodes;nothingwhen the vacuum step was skipped or the formalism computes none.delta_prime::Union{Nothing,DeltaPrimeData}- Δ′/outer-region matching payload from whichever formalism ran (Riccati BVP or Galerkin);nothingwhen neither produced one.galerkin::Union{Nothing,GalerkinResult}- RDCON Galerkin solver internals and FEM diagnostics, including the RPEC inner-layer match when requested. Its Δ′ payload lives indelta_prime, not here.
GeneralizedPerturbedEquilibrium.ForceFreeStates.SolutionProfiles — Type
SolutionProfilesThe solve's ξ solution on its radial grid, in the exact shape PerturbedEquilibrium consumes. Field names mirror the OdeState store subset so the consumers read the same names whichever formalism produced the solution.
Fields
basis::Symbol- Which formalism's basis the profiles are in::el_axis(forward integrator, axis Euler-Lagrange basis) or:gal_native(inner-layer-matched Galerkin solution, identity-at-edge basis).step::Int- Number of stored radial nodes.psi_store::Vector{Float64}- ψ at each node.q_store::Vector{Float64}- Safety factor at each node.u_store::Array{ComplexF64,4}-(N, N, 2, step)solution state: Ξ_ψ in the first component and its conjugate momentum in the second.du_store::Array{ComplexF64,3}-(N, N, step)dΞ_ψ/dψ.xi_s_store::Array{ComplexF64,3}-(N, N, step)Clebsch displacement Ξ_s.
GeneralizedPerturbedEquilibrium.ForceFreeStates.build_result — Method
build_result(integrator, ctrl, equil, intr, metric, ffit, odet, free_energies, gal_data, gal_dp)
-> ForceFreeStatesResultAssemble the published result once the solve is finished — the one place that decides what a formalism's raw output means downstream. Beyond materializing the forward path's derivative stores, packing the matched Galerkin solution, and forming the fixed-boundary W_p when the free-boundary stage did not run, every field is copied or aliased from what the stages already produced.
odet is the integrator's ODE state (nothing for Galerkin); gal_data/gal_dp the Galerkin solver internals and its Δ′ payload (nothing otherwise). The two formalisms are never both present: additive Galerkin does not exist.
GeneralizedPerturbedEquilibrium.ForceFreeStates.require — Method
require(result::ForceFreeStatesResult, field::Symbol, calc::AbstractString) -> BoolWarn-and-skip gate: true iff the optional field field was populated by the integrator that produced result. Warns naming the calculation being skipped otherwise.
GeneralizedPerturbedEquilibrium.ForceFreeStates.require_solution — Method
require_solution(result::ForceFreeStatesResult, calc::AbstractString) -> BoolWarn-and-skip gate for ξ-profile consumers: require specialized to solution, with a message naming what a solution takes to produce.
GeneralizedPerturbedEquilibrium.ForceFreeStates.resist_eval — Method
resist_eval(sing, equil, intr; eta, rho, gamma, ising=0) -> InnerLayer.GGJParametersCompute the GGJ resistive inner-layer parameters at the rational surface sing. Port of RDCON resist_eval (resist.f), but with η and ρ supplied by the caller (no built-in Spitzer/density model). Returns an InnerLayer.GGJParameters carrying the curvature/mass coefficients E, F, G, H, K, M, the local time scales τA (taua), τR (taur), v1 (V′ normalized by total volume), and the surface index ising.
Required keyword arguments
eta::Real— plasma resistivity η at this surface [Ω·m]rho::Real— mass density ρ at this surface [kg/m³]gamma::Real— ratio of specific heats (enters G; physical thermodynamic value, e.g. 5/3)
Physics
The six flux-surface averages (Fortran avg(1:6)) are ⟨B²/|∇ψ|²⟩, ⟨1/|∇ψ|²⟩, ⟨1/B²⟩, ⟨1/(B²|∇ψ|²)⟩, ⟨B²⟩, ⟨|∇ψ|²/B²⟩, each weighted by J/V′ and integrated over θ ∈ [0,1) — exactly the resist.f integrands. The timescales are τ_A = √(ρ·M·μ₀) / |2π n q₁ χ₁ / V′| and τ_R = (⟨B²/|∇ψ|²⟩/⟨B²⟩)·μ₀/η, with η and ρ entering once, explicitly.
GeneralizedPerturbedEquilibrium.ForceFreeStates.apply_gaussian_reduction! — Method
apply_gaussian_reduction!(u::Array{ComplexF64,3}, odet::OdeState, intr::ForceFreeStatesInternal, sing_flag::Bool)Applies Gaussian reduction to orthogonalize solution vectors in u. Formerly ode_fixup!. Performs the same function as ode_fixup in the Fortran code, except now relevant fixfac data are stored in memory instead of dumped to euler.bin. Used when the spread in norms exceeds a threshold or when a rational surface is reached. This will update both u and relevant fields in odet in-place. See the description of compute_solution_norms! for more details on the benefits of in-place u updates.
GeneralizedPerturbedEquilibrium.ForceFreeStates.balance_integration_chunks — Method
balance_integration_chunks(chunks, ctrl, intr) -> Vector{IntegrationChunk}Sub-divide integration chunks to produce a load-balanced set for the Riccati BVP. Starts from the output of chunk_el_integration_bounds and iteratively splits the highest-cost chunk (by ode_itime_cost) until the total chunk count reaches the target set by ctrl.nchunks (0 = auto). The target is derived from problem structure only — never from Threads.nthreads() — so the chunk list, and hence every Riccati output, is identical whatever thread count julia -t provides.
Each split finds the equal-cost midpoint ψmid via bisection: odeitimecost(psistart, psimid) ≈ odeitimecost(psistart, psi_end) / 2
Sub-chunks inherit needs_crossing=false and ising=0. Only the LAST sub-chunk of each original chunk retains needs_crossing=true and the original ising, so the rational surface crossing still fires at the correct ψ in the serial assembly phase.
GeneralizedPerturbedEquilibrium.ForceFreeStates.chunk_el_integration_bounds — Method
chunk_el_integration_bounds(odet::OdeState, ctrl::ForceFreeStatesControl, intr::ForceFreeStatesInternal)Pre-compute all integration chunks from the current position to the edge. Returns a vector of IntegrationChunk objects, each representing a region to integrate and whether it needs a rational surface crossing beforehand.
This function replaces the iterative while-loop logic with a single upfront computation, making the integration flow more predictable and easier to parallelize (e.g., for STRIDE).
Arguments
odet::OdeState- ODE state struct (starting position and singular surface index)ctrl::ForceFreeStatesControl- Control parametersintr::ForceFreeStatesInternal- Internal data (singular surfaces, limits)
Returns
Vector{IntegrationChunk}- Array of integration chunks to process
GeneralizedPerturbedEquilibrium.ForceFreeStates.compute_axis_init — Method
compute_axis_init(ffit, profiles, intr, psi_low) -> (U1_init, U2_init)Compute axis initial conditions for the Euler-Lagrange ODE via the Frobenius leading-coefficient eigenvalue problem [Glasser Phys. Plasmas 2016 112506 Eq. 51]:
lim_{ψ→0} [ψ M(ψ) − a I] v = 0For each mode j, solves the 2×2 Frobenius eigenvalue problem for the diagonal block of A₀ = ψlow · M(ψlow). The eigenvector with Re(a) ≥ 0 is the regular (non-singular) Frobenius solution. Returns U₁init and U₂init normalized so that U₂_init = I (consistent with the N independent solutions convention).
For m≠0 the regular eigenvector has a negligible U₁ component (~ψlow^(|m|/2)), recovering the Glasser [0, I] limit as ψlow → 0. For m=0 (degenerate a≈0), the regular eigenvector is identified by dominant |U₁| component, giving the physically correct constant-displacement Frobenius solution and avoiding the spurious logarithmic irregularity.
GeneralizedPerturbedEquilibrium.ForceFreeStates.compute_delta_prime_from_ca! — Method
compute_delta_prime_from_ca!(odet, intr, equil)STUB — not physically valid. Compute a per-surface Δ' estimate from the asymptotic coefficients ca_l/ca_r using Δ'[i] = (ca_r[i,i,2,s] - ca_l[i,i,2,s]) / (4π²·psio).
The physically valid tearing-stability Δ' is ForceFreeStatesInternal.delta_prime_matrix, computed via the STRIDE global BVP in compute_delta_prime_matrix!. The per-surface ca-based formula here ignores inter-surface coupling and the vacuum BC, and should not be expected to agree with delta_prime_matrix. Retained for reference / future work on intra-surface coupling diagnostics.
Not called from any integration driver. Used only by tests / benchmarks that exercise the stub formula directly.
GeneralizedPerturbedEquilibrium.ForceFreeStates.compute_solution_norms! — Method
compute_solution_norms!(u::Array{ComplexF64,3}, odet::OdeState, ctrl::ForceFreeStatesControl, intr::ForceFreeStatesInternal, sing_flag::Bool)Computes norms of the solution vectors of the array u and normalizes them if this is not the first call after a fixup. Formerly ode_unorm!. Throws an error if any vector norm is zero. It then compares the variation in norms relative to initial values after a fixup, and applies the Gaussian reduction via apply_gaussian_reduction! if the variation exceeds ctrl.ucrit or if sing_flag is true. Performs the same function as ode_unorm in the Fortran code, with minor differences in indexing and array handling.
Note that we pass u as an argument to reduce the number of copies that are necessary in the integration callback, since otherwise we'd need to copy from the integrator state to odet.u and then back again. This way, we can just operate on u directly without extra copies.
Arguments
- u: Current solution vector array, updated in-place if fixfac is called
- sing_flag: Indicates if normalization is occuring at a singular surface or not
TODOs
Add resizing logic for unorm arrays when ifix exceeds allocated size
GeneralizedPerturbedEquilibrium.ForceFreeStates.cross_ideal_singular_surf! — Method
cross_ideal_singular_surf!(odet::OdeState, ctrl::ForceFreeStatesControl, equil::Equilibrium.PlasmaEquilibrium, ffit::FourFitVars, intr::ForceFreeStatesInternal)Handle the crossing of a rational surface during integration if kinetic mode is disabled. Formerly ode_ideal_cross!. Performs the same function as ode_ideal_cross in the Fortran code. Differences mainly in integration data storage logic, but otherwise identical. It normalizes and reinitializes the solution vector at the singularity, and updates relevant state variables.
Asymptotics are now computed on-demand here instead of being pre-computed, making it clear that asymptotic calculations are specific to ideal ForceFreeStates and not inherent to the singular surface.
Arguments
ising::Int- Index of the singular surface being crossed
GeneralizedPerturbedEquilibrium.ForceFreeStates.cross_kinetic_singular_surf! — Method
cross_kinetic_singular_surf!(odet, ctrl, equil, ffit, intr, ising)Cross a kinetically-displaced singular surface using a simple trapezoidal step. Matches Fortran ode_kin_cross with con_flag=true (ode.f:615-619): evaluate the ODE RHS on both sides of the singularity and take a trapezoidal Euler step across. No asymptotic analysis, no Gaussian elimination — the kinetic FKG formulation absorbs the ideal singularity and the trapezoidal step handles the residual near-singularity.
Much simpler than cross_ideal_singular_surf! which requires asymptotic power series and solution vector surgery.
GeneralizedPerturbedEquilibrium.ForceFreeStates.eulerlagrange_integration — Method
eulerlagrange_integration(ctrl, equil, ffit, intr) -> (odet, propagators, chunks, S_left)Integrate the Euler-Lagrange equations from the axis to intr.psilim, crossing each singular surface on the way (Fortran ode_run). Dispatches on ctrl.integrator to riccati_eulerlagrange_integration (the chunked propagator BVP) or forward_eulerlagrange_integration.
Only the Riccati branch populates propagators / chunks / S_left, which compute_delta_prime_matrix! consumes for the Δ' BVP; the forward branch returns nothing for all three.
GeneralizedPerturbedEquilibrium.ForceFreeStates.findmax_dW_edge! — Method
findmax_dW_edge!(odet::OdeState, ctrl::ForceFreeStatesControl, equil::Equilibrium.PlasmaEquilibrium, ffit::FourFitVars, intr::ForceFreeStatesInternal)Records the total dW in the integration region between ctrl.psiedge and ctrl.psilim. This performs the same function as ode_record_edge in the Fortran, but everything is now done post-integration which cleans up the logic, i.e. no "_edge" arrays.
The dW is stored at that step index in odet.dW_edge; because we initialize dW_edge to -Inf, we can just take the max value after integration to get the total dW at the edge and avoid unphysical kink modes that might occur just inside rational surfaces.
We have also separated the computation of the wv matrix spline and the total dW calculation into free_compute_wv_spline and free_compute_total respectively for clarity. We create the wv matrix spline once prior to the loop.
GeneralizedPerturbedEquilibrium.ForceFreeStates.forward_eulerlagrange_integration — Method
forward_eulerlagrange_integration(ctrl, equil, ffit, intr; verbose=ctrl.verbose) -> (odet, nothing, nothing, nothing)Forward branch of eulerlagrange_integration: integrates chunk by chunk from the axis, applying Gaussian reduction whenever a solution norm ratio exceeds ctrl.ucrit and undoing it via transform_u! at the end, so odet.u_store comes back dense in the axis basis. Call directly to force this branch regardless of ctrl.integrator; verbose overrides ctrl.verbose for progress logging.
GeneralizedPerturbedEquilibrium.ForceFreeStates.initialize_el_at_axis! — Method
initialize_el_at_axis!(odet::OdeState, ctrl::ForceFreeStatesControl, ffit::FourFitVars, profiles::Equilibrium.ProfileSplines, intr::ForceFreeStatesInternal)Initialize the OdeState struct for the case of singstart = 0 (axis initialization). Formerly `odeaxisinit!. This now only initializespsifac,isingstart, andu`.
TODOs
Move isingstart logic to chunkelintegrationbounds?
GeneralizedPerturbedEquilibrium.ForceFreeStates.integrate_el_region! — Method
integrate_el_region!(odet::OdeState, ctrl::ForceFreeStatesControl, equil::Equilibrium.PlasmaEquilibrium, ffit::FourFitVars, intr::ForceFreeStatesInternal, chunk::IntegrationChunk)Integrate the Euler-Lagrange equations from psi_start to psi_end. Formerly ode_step!. Performs the same function as ode_step in the Fortran code, with the addition of a callback function to handle tolerances, normalization, and storage at each step of the integration. In Fortran, this was performed by running LSODE in one-step mode (so ode_step was called hundreds of times) and calling the relevant functions in a DO loop. Here, we use the DifferentialEquations.jl interface to achieve the same functionality in a more Julian way. The integration bounds are now explicit arguments, making it clear what region is being integrated.
Arguments
odet::OdeState- ODE state struct (modified in-place)ctrl::ForceFreeStatesControl- Control parametersequil::Equilibrium.PlasmaEquilibrium- Plasma equilibriumffit::FourFitVars- Fourier fit variablesintr::ForceFreeStatesInternal- Internal datachunk::IntegrationChunk- Integration chunk containing start and end ψ for integration
TODOs
Check sensitivity of results to tolerances, currently using same logic as Fortran Check absolute tolerances, currently only relative tolerances are updated
GeneralizedPerturbedEquilibrium.ForceFreeStates.ode_itime_cost — Method
ode_itime_cost(psi1, psi2, intr) -> Float64Estimate the relative ODE integration cost for the interval [ψ₁, ψ₂] using the empirical log-divergent cost model from STRIDE (Glasser 2018). Coefficients are the module constants ODE_COST_AXIS, ODE_COST_RAT, ODE_COST_EDGE. The cost is additive for sub-intervals not containing rational surfaces, which makes it suitable for equal-cost splitting via bisection in balance_integration_chunks.
GeneralizedPerturbedEquilibrium.ForceFreeStates.transform_u! — Method
transform_u!(odet::OdeState, intr::ForceFreeStatesInternal)Constructs the transformation matrices to form the true solution vectors. Effectively "undoes" the Gaussian reduction applied during fixups throughout the integration, such that we have the true solution vectors for use in GPEC. Modifies the store arrays in odet in-place. Performs a similar function as idcon_transform + idcon_build in the Fortran code, except we separate the building of the transformation matrices and determining the coefficients for a chosen force-free solution, which can be done in postprocessing.
GeneralizedPerturbedEquilibrium.ForceFreeStates._find_rational_surfaces — Method
_find_rational_surfaces(equil::Equilibrium.PlasmaEquilibrium, nlow::Int, nhigh::Int)Locate all rational q-surfaces q = m/n for n in nlow:nhigh by Brent bisection between consecutive extrema of the q-profile (reverse shear gives one root per monotone segment). Returns a vector of (m, n, psifac) named tuples in discovery order (n outer, ψ-interval inner). Requires equilibrium_qfind! to have populated equil.params.qextrema_*.
GeneralizedPerturbedEquilibrium.ForceFreeStates._golden_section_max — Method
Golden-section search to find the ψ that maximizes f(ψ) on [a, b].
GeneralizedPerturbedEquilibrium.ForceFreeStates.compute_node_xi_s! — Method
compute_node_xi_s!(xi_s, du1, u1, ffit, psieval; kinetic=false, hint=Ref(1))Evaluate Ξs = -A⁻¹(B·Ξ′ψ + C·Ξψ) [Glasser Phys. Plasmas 2016 112506 eq. 18] at psieval, writing into `xis.du1andu1are the Ξ′_ψ and Ξ_ψ blocks at the same ψ, i.e. slices of ael_derivatives!` result and its input state.
Split out of the derivative kernel because Ξ_s is needed only at saved nodes, not at every Runge-Kutta stage. Ideal runs factor the Hermitian A by Cholesky; with kinetic=true A picks up non-Hermitian contributions and needs an LU.
GeneralizedPerturbedEquilibrium.ForceFreeStates.compute_sing_asymptotics — Method
compute_sing_asymptotics(singp::SingType, ctrl::ForceFreeStatesControl, equil::Equilibrium.PlasmaEquilibrium, ffit::FourFitVars, intr::ForceFreeStatesInternal)Calculate asymptotic vmat and mmat matrices for a singular surface. Formerly sing_vmat!. Returns a SingAsymptotics struct with the computed data instead of mutating the SingType struct. This makes it clear that asymptotics are computed on-demand for ideal ForceFreeStates and are not inherent properties of the singular surface.
See equations 41-48 in the Glasser Phys. Plasmas 2016 112506 for the mathematical details.
Arguments
singp::SingType: Singular surface parameterssing_order: Expansion order, defaulting toctrl.sing_order. The Galerkin path overrides it per surface (gal_sing_order, raised for high-Mercier-index surfaces).
Returns
SingAsymptotics: Struct containing all asymptotic expansion data
GeneralizedPerturbedEquilibrium.ForceFreeStates.compute_sing_mmat! — Method
compute_sing_mmat!(mmat::Array{ComplexF64,4}, singp::SingType, ctrl::ForceFreeStatesControl, profiles::Equilibrium.ProfileSplines, ffit::FourFitVars, intr::ForceFreeStatesInternal)Calculate asymptotic mmat matrix for a singular surface. Formerly sing_mmat!. Performs the same function as sing_mmat in the Fortran code. Main differences are 1-indexing for the expansion orders and using dense matrices instead of banded. We keep the Fortran convention of only filling in the lower half of the Hermitian matrices, and wrap the subsequent multiplications in Hermitian() calls to take advantage of the symmetry. We have tried to be explicit in which matrices have only their lower triangle stored to avoid confusion.
More specifically, in this function we construct the Taylor series M_k of the matrix M (eq. 43-44 in Glasser 2016). This involves: evaluating the cubic splines and their derivatives at the singular surface, constructing the Taylor series coefficients for each composite matrix (simple for G, more complicated for F and K since they are stored as their nonsingular forms and need to be multiplied by Q, which is itself a Taylor series so the coefficients get complicated), solving x = L v for each order in the Taylor series, and then reconstructing mmat from x.
Arguments
mmat::Array{ComplexF64,4}: Output array to store the computed mmat valuessingp::SingType: Singular surface parameters
TODOs
Check third derivative accuracy in cubic splines or determine if it matters Better way to unpack the cubic splines Rename variables to be more intuitive? I don't like ff - maybe f and ffact instead of flower Add a spline for F directly instead of the lower triangular factorization to avoid complexity?
GeneralizedPerturbedEquilibrium.ForceFreeStates.el_derivatives! — Method
el_derivatives!(du, u, kinetic, equil, ffit, intr, psieval, spline_hint, ffit_hint) -> qEuler-Lagrange (or, when kinetic is true, FKG) derivative kernel: writes du₁/dψ and du₂/dψ at psieval into du and returns q there. Holds no state of its own — the two hints are the caller's interval-search accelerators, so concurrent callers just pass their own.
GeneralizedPerturbedEquilibrium.ForceFreeStates.evaluate_fbar_condition — Method
evaluate_fbar_condition(psi, ffit, equil, intr; hint=Ref(1))Evaluate the condition number of the kinetic F̄ matrix at a given ψ. Uses cond(F̄) as a scale-invariant measure of near-singularity. Mirrors the intent of Fortran sing_get_f_det (sing.f:1298-1481) which computes det(F̄).
F̄(i,j) = q₁·f0(i,j)·q₂ - q₁·P(i,j) - conj(P†(j,i))·q₂ + R1(i,j)
where q₁ = m₁ - n·q(ψ), q₂ = m₂ - n·q(ψ) are the direct singularity factors.
GeneralizedPerturbedEquilibrium.ForceFreeStates.find_kinetic_singular_surfaces! — Method
find_kinetic_singular_surfaces!(ffit, equil, intr; ngrid=2000, cond_threshold=1e8)Find kinetically-displaced singular surfaces — locations where cond(F̄) peaks, indicating near-singularity of the kinetic F̄ matrix in the ODE RHS. Populates intr.kinsing and intr.kmsing.
Mirrors the intent of Fortran ksing_find (sing.f:1486-1616) which finds zeros of det(F̄) via adaptive bisection. Here we use condition number peaks instead of determinant zeros for better numerical robustness and scale invariance.
Algorithm:
- Evaluate cond(F̄) on a dense ψ grid
- Find local maxima (peaks where gradient changes from + to -)
- Refine each peak with golden-section minimization of -cond
- Filter by threshold and resonance condition
GeneralizedPerturbedEquilibrium.ForceFreeStates.rational_psi_nodes — Method
rational_psi_nodes(equil::Equilibrium.PlasmaEquilibrium; nlow::Int, nhigh::Int=nlow)Unique ψ_N locations of all rational surfaces q = m/n for n in nlow:nhigh, sorted increasing. Used as mandatory knots for the two-pass equilibrium grid refinement (the same physical surface reached through several (m, n) pairs is deduplicated by q value).
GeneralizedPerturbedEquilibrium.ForceFreeStates.sing_der! — Method
sing_der!(du, u, ctrl, equil, ffit, intr, odet, psieval)Unpacked-argument form of the Euler-Lagrange derivative, using odet's spline hints and recording q at psieval in odet.q. Not safe to call concurrently on a shared odet; multi-threaded callers should use el_derivatives! with their own hints.
GeneralizedPerturbedEquilibrium.ForceFreeStates.sing_der! — Method
sing_der!(
du::Array{ComplexF64,3},
u::Array{ComplexF64,3},
params::Tuple{ForceFreeStatesControl, Equilibrium.PlasmaEquilibrium, FourFitVars, ForceFreeStatesInternal, OdeState, IntegrationChunk},
psieval::Float64
)Evaluate the derivative of the Euler-Lagrange equations [Glasser Phys. Plasmas 2016 112506 eq. 24]. This implements du/dψ for both the ideal and kinetic MHD eigenvalue problems.
This function performs the same role as sing_der in the Fortran code, with main differences coming from hiding LAPACK operations under the hood via Julia's LinearAlgebra package, so the code is much more straightforward.
This follows the Julia DifferentialEquations package format for in place updating.
ode_function!(du, u, p, t)From DifferentialEquations.jl docs: Defining your ODE function to be in-place updating can have performance benefits. What this means is that, instead of writing a function which outputs its solution, you write a function which updates a vector that is designated to hold the solution. By doing this, DifferentialEquations.jl's solver packages are able to reduce the amount of array allocations and achieve better performance.
Wherever possible, in-place operations on pre-allocated arrays are used to minimize memory allocations. All LAPACK operations are handled under the hood by Julia's LinearAlgebra package, so we can obtain a much more simplistic code with similar performance.
Arguments
du::Array{ComplexF64,3}: Pre-allocated array to hold the derivative result, shape (mpert, mpert, 2), updated in-placeu::Array{ComplexF64,3}: Current state array, shape (mpert, mpert, 2)params::Tuple{ForceFreeStatesControl, PlasmaEquilibrium, FourFitVars, ForceFreeStatesInternal, OdeState, IntegrationChunk}: Tuple of relevant structspsieval::Float64: Current psi value at which to evaluate the derivative
The unpacked-argument method carries the arithmetic; this tuple method is the thin adapter the integrator calls. Ξs is not computed here — it is a save-point quantity, obtained from [`computenodexis!`](@ref) only where it is actually consumed.
GeneralizedPerturbedEquilibrium.ForceFreeStates.sing_find! — Method
sing_find!(intr::ForceFreeStatesInternal, equil::Equilibrium.PlasmaEquilibrium)Locate singular rational q-surfaces (q = m/nn) using a bisection method between extrema of the q-profile, and store their properties in intr.sing. Performs the same function as sing_find in the Fortran code.
GeneralizedPerturbedEquilibrium.ForceFreeStates.sing_get_ca — Method
sing_get_ca(u::Array{ComplexF64,3}, ua::Array{ComplexF64,3}, intr::ForceFreeStatesInternal)Compute the asymptotic expansion coefficients according to equation 50 in Glasser 2016 DCON paper. Performs the same function as sing_get_ca in the Fortran code.
Arguments
u::Array{ComplexF64,3}: Current solution matrix, shape (numperttotal, numperttotal, 2)ua::Array{ComplexF64,3}: Asymptotic solution matrix, shape (numperttotal, numperttotal, 2)intr::ForceFreeStatesInternal: Internal ForceFreeStates data containing perturbation dimensions
GeneralizedPerturbedEquilibrium.ForceFreeStates.sing_get_dua — Method
sing_get_dua(sing_asymp::SingAsymptotics, dpsi::Float64) -> duaCompute the derivative of the asymptotic series solution with respect to the positive distance dpsi = |ψ − ψ_res|, consistent with sing_get_ua. Port of Fortran sing_get_dua (sing.f / sing1.f). Shape (numpert_total, 2*numpert_total, 2). Used by the outer-region Galerkin solver (gal_extension, sing_matvec).
Direction is carried by the SingAsymptotics (left vs right vmat built with sig=∓1), exactly as in sing_get_ua, so dpsi is always positive and the arithmetic is real (no analytic continuation). The term-by-term derivative is built via a power array tracking each term's total exponent (in half-powers of dpsi: the 2*singorder offset, the ∓1 shearing on resonant rows, and the ∓2α on resonant columns), then the same shearing/pfac restoration and the chain-rule factor 1/(2·dpsi) are applied. The physical d/dψ sign for the left side (ψ = ψres − dpsi) is applied by the caller.
Arguments
sing_asymp::SingAsymptotics: Pre-computed asymptotic data (must be left- or right-specific)dpsi::Float64: Positive distance from singular surface = |ψ − ψ_res|
GeneralizedPerturbedEquilibrium.ForceFreeStates.sing_get_dua_res! — Method
sing_get_dua_res!(out, sing_asymp, dpsi) -> outEvaluate only the big (col 1) and small (col 2) resonant columns of sing_get_dua into the caller-provided out of shape (numpert_total, 2, 2). Allocation-free; scalar per-term exponents replace the full power array. See the section comment.
GeneralizedPerturbedEquilibrium.ForceFreeStates.sing_get_dua_res — Method
Allocating wrapper for sing_get_dua_res!; returns the big/small columns as (N, 2, 2).
GeneralizedPerturbedEquilibrium.ForceFreeStates.sing_get_ua — Method
sing_get_ua(sing_asymp::SingAsymptotics, dpsi::Float64) -> uaCompute the asymptotic series solution for a given singular surface. Uses direction-specific asymptotics (left: sig=-1, right: sig=+1) with positive dpsi. Matches Fortran STRIDE's sing_get_ua.
Arguments
sing_asymp::SingAsymptotics: Pre-computed asymptotic data (must be left or right specific)dpsi::Float64: Positive distance from singular surface = |ψ - ψ_res|
GeneralizedPerturbedEquilibrium.ForceFreeStates.sing_get_ua_res! — Method
sing_get_ua_res!(out, sing_asymp, dpsi) -> outEvaluate only the big (col 1) and small (col 2) resonant columns of sing_get_ua into the caller-provided out of shape (numpert_total, 2, 2). Allocation-free. See the section comment.
GeneralizedPerturbedEquilibrium.ForceFreeStates.sing_get_ua_res — Method
Allocating wrapper for sing_get_ua_res!; returns the big/small columns as (N, 2, 2).
GeneralizedPerturbedEquilibrium.ForceFreeStates.sing_get_ua_res_cut! — Method
sing_get_ua_res_cut!(out, sing_asymp, dpsi) -> outLeading-order ("cut") form of sing_get_ua_res!: keeps only the t = 0 term of the Frobenius series instead of summing all 2·sing_order terms, then applies the same unshear.
This is the resonant basis used to build the cut outer solution, whose role is to supply the regular background that the resistive inner-layer solution is added to when forming the composite solution near a rational surface. Because the full and cut series share the same leading term, the difference between them is exactly the higher-order content that the layer solution replaces.
GeneralizedPerturbedEquilibrium.ForceFreeStates.sing_get_ua_res_cut — Method
Allocating wrapper for sing_get_ua_res_cut!; returns the big/small columns as (N, 2, 2).
GeneralizedPerturbedEquilibrium.ForceFreeStates.sing_lim! — Method
sing_lim!(intr::ForceFreeStatesInternal, ctrl::ForceFreeStatesControl, equil::Equilibrium.PlasmaEquilibrium)Compute and set integration ψ, q, and q' limits by handling cases where user truncates before the last singular surface. Performs a similar function to sing_lim in the Fortran code. Main differences include renaming of sasflag -> setpsilimviadmlim, removing dW edge storage variables since we now store all integration terms in memory, and simplification of the logic.
The target value qlim is first determined from user-specified control parameters (ctrl.qhigh or ctrl.dmlim), subject to the constraint that it does not exceed equil.params.qmax. If set_psilim_via_dmlim is true, qlim is adjusted to the largest rational surface such that nq + dmlim < qmax. If qlim < qmax, a Newton iteration is performed to find the corresponding psilim to integrate to.
Note that the Newton iteration will be triggered if either set_psilim_via_dmlim is true or ctrl.qhigh < equil.params.qmax. Otherwise, the equilibrium edge values are used.
GeneralizedPerturbedEquilibrium.ForceFreeStates.sing_matmul — Method
sing_matmul(a, b) -> cMatrix multiplication specific to singular matrices. Identical to the Fortran sing_matmul subroutine.
Arguments
a::AbstractArray{ComplexF64,3}: shape (mpert, 2 * mpert, 2)b::AbstractArray{ComplexF64,3}: shape (mmpert, 2 * mpert, 2)
Returns
c::Array{ComplexF64,3}: shape (mpert, 2 * mpert, 2)
GeneralizedPerturbedEquilibrium.ForceFreeStates.sing_matvec! — Method
sing_matvec!(matvec, kmat, gmat, d1, tmp, sfvec, ffit, intr, psi, q, ua, dua) -> matvecAllocation-free, in-place form of sing_matvec for the hot resonant-quadrature integrand. All scratch is caller-owned: kmat/gmat are N×N, d1/tmp/sfvec are length-N, matvec is N×size(ua,2). The reduced K̄/Ḡ splines write directly into kmat/gmat. The accumulation order (separate gmat*ua into tmp, then add) reproduces sing_matvec bit-for-bit — a fused 5-arg mul! would round differently, and the ill-conditioned near-singular resonant solve amplifies a 1e-16 change into ~1e-3 in Δ′.
GeneralizedPerturbedEquilibrium.ForceFreeStates.sing_matvec — Method
sing_matvec(ffit::FourFitVars, intr::ForceFreeStatesInternal, psi::Float64, q::Float64, ua, dua) -> matvecApply the Euler-Lagrange residual operator L u = -(F u' + K u)' + (K† u' + G u) to the asymptotic solutions. Port of Fortran sing_matvec (sing.f). Returns matvec, shape (numpert_total, size(ua,2)).
Uses the reduced (Schur-complemented) ffit.kmats (= K̄) and ffit.gmats (= Ḡ) directly, with the direct singular factor singfac = m - n q applied to u' = dua[:,:,1]. The second component ua[:,:,2] is the canonical momentum F u' + K u, so -dua[:,:,2] = -(F u' + K u)'.
Arguments
psi: flux coordinate;q: safety factor atpsi(passed in to avoid re-evaluating the spline)ua,dua: asymptotic solution and its ψ-derivative fromsing_get_ua/sing_get_dua
GeneralizedPerturbedEquilibrium.ForceFreeStates.sing_min! — Method
sing_min!(intr::ForceFreeStatesInternal, ctrl::ForceFreeStatesControl, equil::Equilibrium.PlasmaEquilibrium)Set the lower integration bound intr.psilow. Port of Fortran RDCON sing_min (sing.f): when qlow > qmin, the q < qlow core (including any q ≤ 1 sawtooth/internal-kink surfaces) must be excluded from the outer-region Galerkin domain — otherwise the Hermite FEM integrates through those ideal singularities without imposing the ideal constraint, contaminating Δ′ at the innermost kept surface. A Newton iteration locates ψ where q = qlow; scanning starts from the edge inward for robustness in reverse-shear cores. When qlow ≤ qmin the axis value is kept.
GeneralizedPerturbedEquilibrium.ForceFreeStates.solve_higher_order_vmat! — Method
solve_higher_order_vmat!(vmat::Array{ComplexF64,4}, mmat::Array{ComplexF64,4}, m0mat::Matrix{ComplexF64}, alpha::Vector{ComplexF64}, r1::Vector{Int}, r2::Vector{Int}, n1::Vector{Int}, n2::Vector{Int}, power::Vector{ComplexF64}, intr::ForceFreeStatesInternal, k::Int)Solves iteratively for the next order in the power series vmat. See equation 47 in the Glasser 2016 DCON paper. Identical to the Fortran sing_solve subroutine. Now takes individual arrays instead of singp struct.
Arguments
vmat::Array{ComplexF64,4}: V matrix power series (modified in-place)mmat::Array{ComplexF64,4}: M matrix power seriesm0mat::Matrix{ComplexF64}: Zeroth order M matrixalpha::Vector{ComplexF64}: Eigenvalues of M₀ for resonant modesr1, r2, n1, n2::Vector{Int}: Resonant and nonresonant indicespower::Vector{ComplexF64}: α values for all modes (0 for nonresonant)k::Int: The current order in the power series expansion
GeneralizedPerturbedEquilibrium.ForceFreeStates.MetricData — Type
MetricDataA structure to hold the computed metric tensor components and their Fourier representation. This is the Julia equivalent of the fspline_type named metric in the Fortran fourfit_make_metric subroutine.
Fields
mpsi::Int: Number of radial grid points minus one.mtheta::Int: Number of poloidal grid points minus one.xs::Vector{Float64}: Radial coordinates (normalized poloidal fluxψ_norm).ys::Vector{Float64}: Poloidal angle coordinatesθin radians (0 to 2π).fs::Array{Float64, 3}: The raw metric data on the grid, size(mpsi, mtheta, 8). The 8 quantities are:g¹¹,g²²,g³³,g²³,g³¹,g¹²,J,∂J/∂ψ.fourier_coeffs::Utilities.FourierCoefficients: The FFT coefficients (no spline interpolation needed).
GeneralizedPerturbedEquilibrium.ForceFreeStates.build_kinetic_metric_matrices — Method
build_kinetic_metric_matrices(equil, intr, metric) → NamedTupleCompute the 5 kinetic geometric matrices (s, t, x, y, z) needed by KineticForces.BounceAveraging for kinetic W vector outer products. These encode the coupling between magnetic perturbation modes and the equilibrium geometry.
Ports Fortran dcon_interface.f lines 895-1013 (idcon_action_matrices).
Steps
- Compute 8
fmodbquantities on the (ψ,θ) grid from equilibrium geometry - Fourier decompose via
Utilities.FourierCoefficients - Assemble mpert×mpert matrices from Fourier bands at each ψ
- Create
CubicSeriesInterpolants over ψ
Returns
NamedTuple (smats, tmats, xmats, ymats, zmats) of CubicSeriesInterpolants, each mapping ψ → flattened mpert² complex vector.
Reference: [Logan et al., Phys. Plasmas 20, 122507 (2013)]
GeneralizedPerturbedEquilibrium.ForceFreeStates.make_matrix — Method
make_matrix(metric::MetricData, equil::Equilibrium.PlasmaEquilibrium, intr::ForceFreeStatesInternal) -> FourFitVarsConstructs main ForceFreeStates matrices for a given toroidal mode number and returns them as a new FourFitVars object. See the appendix of the Glasser Phys. Plasmas 2016 112506 DCON paper for details on the matrix definitions. Performs the same function as fourfit_make_matrix in the Fortran code, except F, G, and K are stored as dense matrices. The matrix F is stored in factorized form with the lower triangle only, because F is Hermitian and can be written as F = L · Lᴴ, which speeds up calculations later (i.e. sing_der!).
Arguments
metric::MetricData: Metric coefficients on the (ψ, θ) grid, including Fourier representations of g^ij and J.
Returns
ffit::FourFitVars: A struct holding cubic spline fits of the assembled matrices
TODOs
Add kinetic metric tensor components for kinetic mode Set powers if necessary
GeneralizedPerturbedEquilibrium.ForceFreeStates.make_metric — Method
make_metric(equil::Equilibrium.PlasmaEquilibrium, mpert::Int) -> MetricDataConstructs the metric tensor data on a (ψ, θ) grid from an input plasma equilibrium. The metric coefficients stored in metric.fs include:
- g^ψψ · J
- g^θθ · J
- g^ζζ · J
- g^θζ · J
- g^ζψ · J
- g^ψθ · J
- J (Jacobian)
- ∂J/∂ψ
Arguments
mpert::Int: Number of poloidal modes
Returns
metric::MetricData: A structure containing the metric coefficients, coordinate grids, and Jacobians for the specified equilibrium.
GeneralizedPerturbedEquilibrium.ForceFreeStates._compute_fkg_matrices! — Method
_compute_fkg_matrices!(ffit, equil, intr, metric, kw_flat, kt_flat)Pre-compute the derived F, K, G kinetic matrices at each ψ grid point and store as splines. This corresponds to fourfit_kinetic_matrix method=0 in the Fortran code (Fortran fourfit.F lines 1170-1260).
The 9 matrices computed are the Schur complement reductions of ideal (A,B,C,D,E,H) and kinetic (W,T) primitives into the F, K, G sub-matrices of the non-Hermitian kinetic ODE system (Logan 2015 Appendix C, Eqs C.1-C.11). These sub-matrices absorb the singfac dependence so that the ODE RHS in sing_der! can be assembled with explicit (m-nq) factors rather than 1/(m-nq), avoiding numerical blow-up at rational surfaces.
GeneralizedPerturbedEquilibrium.ForceFreeStates.make_kinetic_matrix — Method
make_kinetic_matrix(ctrl, equil, ffit, intr, metric;
calculated_source=nothing)Construct kinetic energy (W) and torque (T) matrices, store as splines in ffit, and pre-compute the FKG derived matrices used by sing_der!.
Dispatches on ctrl.kinetic_source:
"fixed": X-shaped test matrices scaled byctrl.kinetic_factorrelative to ideal matrix Frobenius norms (Ak, Dk, Hk Hermitian; Bk, Ck, Ek non-Hermitian)"calculated": Compute via thecalculated_sourcecallback. This is expected to beKineticForces.compute_calculated_kinetic_matricesinjected byGeneralizedPerturbedEquilibrium.main. The callback receives(ctrl, equil, intr, metric, ffit)and returns(kw_flat, kt_flat)of shape(mpsi, np^2, 6). Callback injection is used because ForceFreeStates is loaded before KineticForces, so a direct import would invert the dependency order.
Both paths apply ctrl.kinetic_factor as a global scale before the FKG Schur reduction.
GeneralizedPerturbedEquilibrium.ForceFreeStates.check_for_zero_crossings! — Method
check_for_zero_crossings!(odet, profiles, istep) -> zero_cross, nonhermCheck if the critical eigenvalue (crit), i.e. the smallest eigenvalue of W⁻¹, changed signs between a given integration step istep and the previous step. We first compute crit at the current step, writing the result to the crit_store array, then compare with the previous step to see if it changed signs. If so, we perform additional checks to ensure the crossing is physical and not just numerical noise, and if so, we return that a zerocrossing occurred, indicating the presence of a fixed-boundary instability. This performs the same function as `odeoutput_monitor` in the Fortran code, except we can do it post-integration rather than during and don't directly handle file outputs here.
Arguments
profiles::ProfileSplines: Profile splines containing equilibrium profilesistep::Int: Current integration step index
Returns
zero_cross::Bool: True if a physical zero crossing was detectednonherm::Bool: True if W⁻¹ was non-Hermitian beyond tolerance
GeneralizedPerturbedEquilibrium.ForceFreeStates.compute_smallest_eigenvalue — Method
compute_smallest_eigenvalue(u) -> crit, nonhermForm the inverse plasma response matrix W⁻¹ using the solution matrix u and returns its minimum eigenvalue by magnitude. Performs the same function as ode_output_get_crit in the Fortran code, except we explicitly form W⁻¹ here from U₁ * U₂⁻¹ using Julia's right division operator / instead of adj(adj(U₂)⁻¹ * adj(U₁)) as done in Fortran. We have also added a check to ensure W is Hermitian within tolerance, as the matrix should be Hermitian by construction but may accumulate numerical noise during integration.
Arguments
u::AbstractArray{ComplexF64, 3}: Solution matrix atpsi
Returns
crit::Float64: the computed scaled critical eigenvaluenonherm::Bool: true if W⁻¹ was non-Hermitian beyond tolerance (> 1e-3)
GeneralizedPerturbedEquilibrium.ForceFreeStates.evaluate_stability_criterion! — Method
evaluate_stability_criterion!(odet, profiles) -> nzeroEvaluate the stability criterion over the entire integration, counting the number of zero crossings of the critical eigenvalue which indicate instability. This acts as an outer wrapper for check_for_zero_crossings! since our in-memory integration allow this to be done post-integration rather than during like the Fortran. We update the crit_store in odet in place, and return the total number of zero crossings found. If the W inverse matrix was non-Hermitian beyond tolerance at any integration steps, a warning is printed with the total count.
GeneralizedPerturbedEquilibrium.ForceFreeStates.materialize_derivative_stores! — Method
materialize_derivative_stores!(odet, equil, ffit, intr) -> BoolFill odet.du_store (dΞψ/dψ) and `odet.xisstorefrom the stored solution, returning whether they hold valid data afterwards. Idempotent: a no-op whendustore_populated` is already true, so the galerkin-matched path keeps its analytic derivatives.
Derivatives are recomputed rather than accumulated during integration because they are needed only at saved nodes, not at every Runge-Kutta stage. Recomputing after the Gaussian fixup transforms and the free-boundary normalization is exact rather than merely close: the Euler-Lagrange system is linear in u, and both operations right-multiply the solution by a mixing matrix T, so du(ψ, u·T) = du(ψ, u)·T.
Returns false without allocating when there is nothing to work from — no ffit, no stored steps, or a solution held in a basis the Euler-Lagrange kernel does not apply to (the sparse parallel path, which stores chunk-endpoint Riccati matrices).
GeneralizedPerturbedEquilibrium.ForceFreeStates.resize_storage! — Method
resize_storage!(odet::OdeState)Resize storage arrays in odet when the current step exceeds allocated size. Doubles the size of the storage arrays for u_store, psi_store, and q_store, and copies over existing data to the new arrays. The derivative stores are not grown here: they are filled at exact size by materialize_derivative_stores! after integration.
GeneralizedPerturbedEquilibrium.ForceFreeStates.store_ode_data! — Method
store_ode_data!(odet::OdeState, psi::Float64, u)Save the current integration state at psi: u plus odet.q, which callers set for the accepted point. Derivatives are not stored — materialize_derivative_stores! recomputes them from (psi_store, u_store) after integration, where they are actually consumed.
GeneralizedPerturbedEquilibrium.ForceFreeStates.trim_storage! — Method
trim_storage!(odet::OdeState)Trim storage arrays in odet to the actual number of steps taken. Resizes u_store, psi_store, and q_store to the current step count, removing any unused allocated space. The derivative stores are left alone — they are either empty (serial/Riccati, filled later by materialize_derivative_stores!) or already exact-size (galerkin-matched states).
GeneralizedPerturbedEquilibrium.ForceFreeStates.compute_scaled_wv — Method
compute_scaled_wv(ctrl::ForceFreeStatesControl, equil::Equilibrium.PlasmaEquilibrium, intr::ForceFreeStatesInternal) -> (wv, vac)Vacuum response matrix at the control surface intr.psilim, scaled by the singular factors (m - n·q)(m' - n'·q) [Chance Phys. Plasmas 1997 2161 eq. 126]. Handles 2D single-n, 2D multi-n block-diagonal and 3D vacuum problems through Vacuum.compute_vacuum_response.
Needs no ODE state, so both the free-boundary calculation and the standalone Galerkin solve share it. Returns the scaled wv alongside the full vacuum response vac, whose surface point clouds the free-boundary result carries to HDF5. wv aliases vac.wv, which is scaled in place.
GeneralizedPerturbedEquilibrium.ForceFreeStates.free_compute_total — Method
free_compute_total(equil::Equilibrium.PlasmaEquilibrium, ffit::FourFitVars, intr::ForceFreeStatesInternal, odet::OdeState) -> ComplexF64Compute total complex energy eigenvalue (total1). This is a trimmed down version of free_run that only computes the total energy eigenvalue for the mode unstable mode, used in findmax_dW_edge! which calls this function at each step in the psiedge -> psilim region of integration. This performs the same function as free_test in the Fortran code, except we have moved the creation of the wv matrix spline to free_compute_wv_spline and pass it in odet.edge_scan.wvmat (a complex-valued spline).
GeneralizedPerturbedEquilibrium.ForceFreeStates.free_compute_wv_spline — Method
free_compute_wv_spline(ctrl::ForceFreeStatesControl, equil::Equilibrium.PlasmaEquilibrium, intr::ForceFreeStatesInternal)Compute a spline of vacuum response matrices over the range of psi from 'ctrl.psiedge' to intr.qlim. This is used for fast evaluation of wt during `oderecordedge. Performs the same function asfreewvmats` in the Fortran code. Currently defaults to 4 spline points per q-window minimum.
GeneralizedPerturbedEquilibrium.ForceFreeStates.free_run — Method
free_run(odet::OdeState, ctrl::ForceFreeStatesControl, equil::Equilibrium.PlasmaEquilibrium, ffit::FourFitVars, intr::ForceFreeStatesInternal) -> FreeBoundaryResultCompute the free boundary energies using the Julia port of the VACUUM code. Performs the same function as free_run in the Fortran code.
Returns a FreeBoundaryResult struct containing the data needed for perturbed equilibrium calculations and data dumping.
GeneralizedPerturbedEquilibrium.ForceFreeStates.normalize_eigenfunctions! — Method
normalize_eigenfunctions!(odet::OdeState, wt::AbstractMatrix{ComplexF64}, psio::Float64) -> OdeStateRescale the stored EL solution vectors in odet.u_store (and du_store/xi_s_store when a path has already filled them) so the edge displacement matches the free-boundary eigenvectors wt (scaled by 2π·psio·1e-3). Modifies odet in place. Call after free_run when downstream code consumes the stored ξ profiles.
GeneralizedPerturbedEquilibrium.ForceFreeStates.power_norm_matrix! — Method
power_norm_matrix!(Nmat, jmat, mpert, npert, dV_dpsi) -> NmatAssemble the power-normalization (surface-norm) matrix N from the conjugate-symmetric Jacobian Fourier band jmat (length 2·mpert−1, evaluated from the ffit.jmats spline), such that
ξ†·N·ξ = ∮ J |ξ(θ)|² dθ / (dV/dψ) = ⟨|ξ|²⟩is the flux-surface average of the squared boundary displacement — the DCON power normalization as a quadratic form. N is Hermitian Toeplitz within each n-block (block-diagonal over n since the Jacobian is axisymmetric) and positive definite (J > 0). It is the metric of the generalized eigenproblem W·v = λ·N·v solved in free_run and free_compute_total: because W and N transform by the same congruence under a change of working (Jacobian) coordinate, the pencil eigenvalues are power-normalized mode energies that are invariant to that coordinate choice.
GeneralizedPerturbedEquilibrium.ForceFreeStates._two_sided_singular_asymptotics — Method
_two_sided_singular_asymptotics(singp, ctrl, equil, ffit, intr) -> (left, right)Compute left- (sig=-1) and right- (sig=+1) side singular asymptotics matching Fortran STRIDE's separate vmatl/vmatr (sing_vmat). Alpha is taken from the right side and shared with the left.
GeneralizedPerturbedEquilibrium.ForceFreeStates.apply_propagator! — Method
apply_propagator!(odet, prop)Apply the chunk propagator prop to the current state odet.u in-place.
The propagator acts as a linear map on the (U₁, U₂) pair:
U₁new = blockupperic[:,:,1] · U₁prev + blockloweric[:,:,1] · U₂prev U₂new = blockupperic[:,:,2] · U₁prev + blockloweric[:,:,2] · U₂prev
This correctly propagates any state (not just the identity), including the (S, I) form produced by Riccati-style crossings.
Implements the subpropagator composition Φ(ψ₂, ψ₀) = Φ(ψ₂, ψ₁) · Φ(ψ₁, ψ₀) of Glasser-Kolemen (2018) Phys. Plasmas 25, 032501 Eq. 29.
GeneralizedPerturbedEquilibrium.ForceFreeStates.apply_propagator_inverse! — Method
apply_propagator_inverse!(odet, prop)Apply the inverse of the chunk propagator prop to the current state odet.u in-place.
Used for backward chunks (direction=-1): the stored propagator Φbwd maps state at `psiend→ state atpsistart(well-conditioned because solutions that grow exponentially forward decay backward). To advance the Riccati state frompsistarttopsiend`, we solve Φbwd · x = uold, which gives x = Φbwd⁻¹ · uold = Φfwd · u_old.
Since Φbwd is well-conditioned, the LU solve is accurate, giving the same result as applying the (ill-conditioned) forward propagator Φfwd but with far better precision.
Implements the inverse subpropagator identity Φ(ψ₂, ψ₁) = Φ(ψ₁, ψ₂)⁻¹ of Glasser-Kolemen (2018) Phys. Plasmas 25, 032501 Eq. 33.
GeneralizedPerturbedEquilibrium.ForceFreeStates.assemble_fm_matrix — Method
assemble_fm_matrix(propagators, idx_range; condition=false) -> Matrix{ComplexF64}Assemble the 2N×2N fundamental matrix (propagator) by multiplying chunk propagators in order for indices idx_range. Returns Φend * ... * Φstart, so that the result maps the IC at the start of idx_range[1] to the state at the end of idx_range[end].
Each ChunkPropagator stores the 2N columns of Φ split into two N×N×2 blocks:
block_upper_ic[:,:,1:2] ↔ Φ[:,1:N] (result from IC=(I,0))
block_lower_ic[:,:,1:2] ↔ Φ[:,N+1:2N] (result from IC=(0,I))When condition=true, applies Gaussian reduction (condition_propagator!) after each multiplication step, following STRIDE's ode_fixup convention. This prevents exponential growth of the accumulated product: without conditioning, products of K chunk propagators can reach cond ~ (condperchunk)^K, causing catastrophic cancellation. With periodic conditioning, each step stays at O(condperchunk) and only the N well-conditioned U₂ columns (right half) survive.
Use condition=true for the axis→first-surface segment, where the axis BC (U₁=0) means only U₂ ICs are needed. Do NOT use for inter-surface segments where both U₁ and U₂ components carry physical information.
GeneralizedPerturbedEquilibrium.ForceFreeStates.compute_delta_prime_matrix! — Method
compute_delta_prime_matrix!(intr, propagators, chunks; wv, psio, debug, ctrl, equil, ffit)Compute the inter-surface tearing stability matrix (msing × msing) using the STRIDE global BVP formulation [Glasser 2018 Phys. Plasmas 25, 032501, Sec. III.B].
The BVP encodes the full plasma response with unknowns at each surface boundary:
x_axis (N): free IC parameters at the axis (U₁ = 0 regular solutions)
x_left[j] (2N): state at left inner-layer boundary of surface j
x_right[j] (2N): state at right inner-layer boundary of surface j
x_edge (N): free IC parameters at the edge
Total unknowns: nMat = (2 + 4·msing)·NEdge boundary condition
When wv is provided (the vacuum response matrix, singfac-scaled), the edge BC follows the Fortran STRIDE convention:
U₁ = c, U₂ = -wv·ψ₀²·cwhich is the free-boundary condition wp + wv = 0 at the edge. When wv is nothing, a conducting wall BC (U₁ = 0) is used.
Gaussian reduction (conditioning)
Forward-propagated segment propagators (axis→surface, surface→surface) can be extremely ill-conditioned (cond ~ 10²⁴) due to exponential growth of the big solution. Following STRIDE's ode_fixup, Gaussian reduction is applied to each assembled propagator's U₂ columns before inserting into the BVP matrix. This keeps the BVP matrix full-rank and well-conditioned.
Output: PEST3-convention Δ' (deltap)
The raw BVP solution is a 2·msing × 2·msing matrix dp with left/right sub-indices at each surface. The PEST3-convention Δ' matrix is the linear combination [Chance, PPPL-2527]:
deltap(i,j) = dp(2i,2j) - dp(2i,2j-1) - dp(2i-1,2j) + dp(2i-1,2j-1)stored in intr.delta_prime_matrix (msing × msing).
Limitations
This routine currently assumes exactly one resonant mode per singular surface (the standard single-n case). When any surface carries more than one resonant mode — i.e., a multi-n run where a single q value satisfies two distinct (m, n) tuples (e.g. q = 2 with (m=2, n=1) AND (m=4, n=2)) — the routine emits a warning and skips the inter-surface BVP rather than crashing. Generalizing the BVP to multi-resonance surfaces is tracked as a follow-up: the matrix shape becomes n_res_total × n_res_total with n_res_total = sum(length(intr.sing[j].m)) and a (surface, mode, side) ↔ BVP-row map; see PR discussion.
Note: intr.delta_prime_matrix is the only physically valid Δ' produced by this code. The per-surface ca-based stub intr.sing[*].delta_prime / delta_prime_col (populated by riccati_cross_ideal_singular_surf!) is a diagnostic placeholder for future intra-surface coupling work and is not expected to agree with delta_prime_matrix.
GeneralizedPerturbedEquilibrium.ForceFreeStates.condition_propagator! — Method
condition_propagator!(Phi, N)Apply Gaussian reduction to the U₂-columns (columns N+1:2N) of a 2N×2N propagator matrix in-place, following STRIDE's ode_fixup convention. Triangularizes the U₁ (upper N rows) subblock by pivoted elimination, improving the condition number so the propagator can be used in a BVP without losing numerical rank.
After conditioning, only the U₂ columns carry meaningful information; the U₁ columns (1:N) are zeroed. The BVP axis block uses Phi[:, N+1:2N] (the conditioned half).
GeneralizedPerturbedEquilibrium.ForceFreeStates.integrate_fm_with_ua_ic — Method
integrate_fm_with_ua_ic(chunks, chunk_range, ua, ctrl, equil, ffit, intr;
backward=false) -> Matrix{ComplexF64}Re-integrate a span of chunks using ua (asymptotic solution) as initial conditions, matching Fortran STRIDE's uFMsinginit behavior. Returns a 2N×2N fundamental matrix where column j is the ODE solution at the span endpoint with IC = column j of T = [ua[:,:,1]; ua[:,:,2]].
When backward=false (default): ua is the IC at psistart, integrate forward to psiend. When backward=true: ua is the IC at psiend, integrate backward to psistart. The result maps asymptotic coefficients at psiend → state at psistart.
This provides numerically accurate propagators near singular surfaces because the ODE integrator maintains per-column relative accuracy even when columns span a 10^8+ dynamic range (big/small solutions). In contrast, post-multiplying a pre-computed identity-IC propagator by T loses the small-solution information to roundoff.
GeneralizedPerturbedEquilibrium.ForceFreeStates.integrate_propagator_chunk! — Method
integrate_propagator_chunk!(prop, chunk, ctrl, equil, ffit, intr, odet_proxy)Compute the fundamental matrix (propagator) for one integration chunk by solving the EL ODE twice from identity-block initial conditions.
The first solve uses IC = (IN, 0N) (U₁=I, U₂=0) and stores the result in prop.block_upper_ic. The second uses IC = (0N, IN) (U₁=0, U₂=I) and stores the result in prop.block_lower_ic.
odet_proxy is a per-thread lightweight OdeState used to provide thread-local storage for sing_der! side effects (q, ud, spline_hint). Multiple threads may call this function concurrently using distinct odet_proxy objects.
No callback is used: the propagator integration proceeds without normalization or storage steps, since the identity ICs ensure bounded solutions within each chunk.
GeneralizedPerturbedEquilibrium.ForceFreeStates.pest3_decompose — Method
pest3_decompose(dp_raw::AbstractMatrix) -> (A', B', Γ', Δ')Rotate the raw 2m×2m outer-region matching matrix dp_raw (side-major ordering [L_s1, R_s1, L_s2, R_s2, …]) into the Pletzer–Dewar 1991 parity blocks. Given rows and columns paired by surface (odd index = left, even index = right), the Fortran RDCON parity combination is
A'(i,j) = RR + RL + LR + LL (even-i, even-j) — interchange↔interchange
B'(i,j) = RR − RL + LR − LL (even-i, odd-j) — interchange↔tearing
Γ'(i,j) = RR + RL − LR − LL (odd-i, even-j) — tearing↔interchange
Δ'(i,j) = RR − RL − LR + LL (odd-i, odd-j) — tearing↔tearingwhere RR = dp_raw[2i, 2j], RL = dp_raw[2i, 2j−1], LR = dp_raw[2i−1, 2j], LL = dp_raw[2i−1, 2j−1]. Each block is m×m.
Matches Fortran exactly — no ½ prefactor (Pletzer–Dewar multiply by ½, but the Fortran RDCON code leaves it commented out and our Julia port follows Fortran to keep the benchmark bit-identical; the prefactor cancels in det(D' − D(γ)) = 0).
The Δ' block returned here equals intr.delta_prime_matrix (the m×m PEST3 tearing projection computed inside compute_delta_prime_matrix!).
Arguments
dp_raw— 2m×2m complex matrix (typicallyintr.delta_prime_raw).
Returns
Named tuple (A=A', B=B', Γ=Gp, Δ=Dp) of four m×m complex matrices. In the full det(D' − D(γ)) = 0 eigenvalue problem, these fill the 2m×2m outer matrix as D' = [[A' B'] [Γ' Δ']] with the interchange channel (Glasser stabilization) in the upper-left block and the tearing channel in the lower-right.
GeneralizedPerturbedEquilibrium.ForceFreeStates.renormalize_riccati! — Method
renormalize_riccati!(odet, intr)After a singular surface crossing, restore the canonical Riccati storage convention: u[:,:,1] = Snew = U₁new · U₂_new⁻¹ u[:,:,2] = I
riccati_cross_ideal_singular_surf! leaves u[:,:,1] = U₁new and u[:,:,2] = U₂new (not I), so this step is required before continuing the Riccati integration.
The ustore entry from the crossing correctly has U₁new and U₂new (stored before this call), so `computesmallesteigenvalue` still computes U₁new/U₂new = Snew correctly.
GeneralizedPerturbedEquilibrium.ForceFreeStates.renormalize_riccati_inplace! — Method
renormalize_riccati_inplace!(u, N)In-place Riccati renormalization on an arbitrary N×N×2 array: u[:,:,1] = U₁ · U₂⁻¹ (new S) u[:,:,2] = I
Used in riccati_integrator_callback! to renormalize the integrator's live state when column norms grow beyond ctrl.ucrit, analogous to Gaussian reduction in the standard ODE. This keeps the inputs to sing_der! bounded, preventing the same exponential growth that occurs in the standard (non-Riccati) ODE without Gaussian reduction.
GeneralizedPerturbedEquilibrium.ForceFreeStates.riccati_cross_ideal_singular_surf! — Method
riccati_cross_ideal_singular_surf!(odet, ctrl, equil, ffit, intr, ising)Cross a singular surface for the Riccati formulation. Replaces cross_ideal_singular_surf! for the Riccati integration path with two key differences:
No Gaussian reduction:
cross_ideal_singular_surf!callscompute_solution_norms!which applies Gaussian reduction to (S, I). This divides by pivot elements of S, which can be near-zero (S = 0 at axis and grows slowly), producing NaN/Inf in U₂. For Riccati, S is bounded so Gaussian reduction is unnecessary.Direct column zeroing: Instead of using the GR-sorted
odet.indexto identify the column to zero, we useipert_resdirectly (the resonant mode index). This is valid since without GR there is no permutation applied to the columns of S.
Δ' normalization: This function expects odet.u in the bounded (U₁, U₂) form produced by riccati_integrate_chunk! with needs_crossing=true (final renorm skipped). cal is computed from (U₁, U₂) before the crossing, and car from (U₁new, U₂new) before renormalize_riccati!. Since column ipert_res of [U₁new; U₂new] equals the introduced asymptotic solution exactly, car[ipertres,ipertres,2] = 1 regardless of other column normalizations. This gives a physically meaningful Δ' = car - ca_l with consistent left/right normalization.
After the predictor step and asymptotic introduction, renormalize_riccati! is called to restore the canonical (S_new, I) form before continuing integration.
The ustore entry at the crossing step correctly stores (U₁new, U₂new) so that `evaluatestabilitycriterion!` can compute U₁new / U₂new = Snew correctly.
GeneralizedPerturbedEquilibrium.ForceFreeStates.riccati_der! — Method
riccati_der!(du, u, params, psieval)Evaluate the explicit dual Riccati ODE right-hand side: dS/dψ = w†·F̄⁻¹·w - S·Ḡ·S, w = Q - K̄·S
where Q = diag(1/(m - n·q)) is the diagonal singular factor matrix. The identity slice u[:,:,2] = I does not evolve (du[:,:,2] = 0).
REFERENCE IMPLEMENTATION — not called in production. The explicit Riccati ODE is numerically unstable for explicit solvers: the quadratic S·Ḡ·S term blows up when K̄·S ≫ Q. The production path integrates sing_der! with periodic renormalize_riccati_inplace! instead (see module docstring). Kept here for documentation of Eq. 19 in source form and for future use with implicit solvers; exercised only by unit tests that verify the formula.
See: Glasser (2018) Phys. Plasmas 25, 032507 — Eq. 19 (dual Riccati form)
GeneralizedPerturbedEquilibrium.ForceFreeStates.riccati_eulerlagrange_integration — Method
riccati_eulerlagrange_integration(ctrl, equil, ffit, intr) -> (odet, propagators, chunks, S_left)The Riccati/STRIDE integrator: a chunked fundamental matrix (propagator) driver for the EL integration. The trailing three return values feed the Δ' BVP in compute_delta_prime_matrix!; this is the only branch that produces them.
Solves the same system as forward_eulerlagrange_integration, but integrates all bulk chunks concurrently using Threads.@threads, then re-integrates the outer plasma serially:
- Chunk generation: calls
chunk_el_integration_bounds, thenbalance_integration_chunksto sub-divide chunks for load balancing. The chunk count depends only onintr.msingandctrl.nchunks, never on the thread count, so results are thread-independent. - Propagator phase:
integrate_propagator_chunk!integrates each chunk independently from identity initial conditions (no accumulated state, no normalization/callback). Each thread uses a privateOdeStateproxy forsing_der!side effects. - Serial assembly: propagators are applied sequentially with
apply_propagator!. Rational surface crossings usericcati_cross_ideal_singular_surf!(no Gaussian reduction). - Outer plasma re-integration: after the last rational surface crossing, the outer plasma (from last ψs to psilim) is re-integrated using `riccatiintegratechunk!
. FM propagation in this region is prone to precision loss for high N (exponential growth without renormalization); Riccati integration keeps matrices bounded and provides dense checkpoints forfindmaxdW_edge!`.
Select via integrator = "riccati" in [ForceFreeStates] of gpec.toml. Requires singfac_min != 0. Uses whatever threads julia -t provides; ctrl.nchunks is the only tunable.
Key differences from the forward integrator:
- No Gaussian reduction in the propagator BVP phase (crossings use the Riccati-style algorithm,
odet.ifixstays 0) transform_u!is called on the odet but is a no-op (ifix=0)- Outer plasma uses serial Riccati integration for numerical stability
odet.u_storeholds chunk-endpoint Riccati states, not dense Euler-Lagrange ξ, andodet.u_store_el_basisstaysfalse: this integrator never claims the EL basis, so PerturbedEquilibrium and the HDF5 forward-integration ξ datasets require the forward path.
Bidirectional integration for large-N accuracy: The crossing chunk (nearest to each rational surface singL[j]) is integrated backward (direction=-1, tspan reversed). Backward integration of a region where solutions grow exponentially forward causes them to decay, so the resulting backward FM Φbwd is well-conditioned. The accurate forward propagation is recovered as Φbwd⁻¹ via a stable LU solve in apply_propagator_inverse!. This follows the same principle as STRIDE (Glasser 2018 Phys. Plasmas 25, 032501). The all-forward path had ~10% energy error for the DIIID-like example (N=26, n=1); bidirectional reduces this to within 2%.
GeneralizedPerturbedEquilibrium.ForceFreeStates.riccati_integrate_chunk! — Method
riccati_integrate_chunk!(odet, ctrl, equil, ffit, intr, chunk)Integrate the dual Riccati ODE from chunk.psi_start to chunk.psi_end.
Uses sing_der! as the ODE RHS with riccati_integrator_callback!, which applies renormalize_riccati_inplace! (instead of Gaussian reduction) when norms exceed ucrit. Starting state: u[:,:,1] = S_prev, u[:,:,2] = I (set by initialization or previous renorm). Ending state: u[:,:,1] = U₁, u[:,:,2] = U₂ (ratio S = U₁·U₂⁻¹ is the updated Riccati matrix).
GeneralizedPerturbedEquilibrium.ForceFreeStates.riccati_integrator_callback! — Method
riccati_integrator_callback!(integrator)Callback function for the Riccati ODE integrator. Handles tolerance updates, renormalization, and storage at each step.
Uses sing_der! as the ODE RHS: u[:,:,1] = U₁ (starts as S), u[:,:,2] = U₂ (starts as I). When max(|U₁|) or max(|U₂|) exceeds ctrl.ucrit, applies renormalize_riccati_inplace! to compute S = U₁·U₂⁻¹ and reset U₂ = I. This is the Riccati analogue of Gaussian reduction in the standard integrator_callback!, and keeps the ODE inputs bounded.
Example usage
Run stability analysis from a TOML configuration
using GeneralizedPerturbedEquilibrium, TOML
const FFS = GeneralizedPerturbedEquilibrium.ForceFreeStates
ex = "examples/Solovev_ideal_example"
inputs = TOML.parsefile(joinpath(ex, "gpec.toml"))
ctrl = FFS.ForceFreeStatesControl(;
(Symbol(k) => v for (k, v) in inputs["ForceFreeStates"])...)
equil = GeneralizedPerturbedEquilibrium.Equilibrium.setup_equilibrium(
GeneralizedPerturbedEquilibrium.Equilibrium.EquilibriumConfig(inputs["Equilibrium"], ex))
intr = FFS.ForceFreeStatesInternal(; dir_path=ex)
intr.wall_settings = GeneralizedPerturbedEquilibrium.Vacuum.WallShapeSettings(;
(Symbol(k) => v for (k, v) in inputs["Wall"])...)
intr.nlow = ctrl.nn_low; intr.nhigh = ctrl.nn_high; intr.npert = 1
FFS.sing_lim!(intr, ctrl, equil)
FFS.sing_find!(intr, equil)
intr.mlow = min(intr.nlow * equil.params.qmin, 0) - 4 - ctrl.delta_mlow
intr.mhigh = trunc(Int, intr.nhigh * equil.params.qmax) + ctrl.delta_mhigh
intr.mpert = intr.mhigh - intr.mlow + 1
intr.numpert_total = intr.mpert * intr.npert
metric = FFS.make_metric(equil, intr.mpert)
ffit = FFS.make_matrix(equil, intr, metric)
# Choose integration driver. The top-level `eulerlagrange_integration` dispatches
# on ctrl.integrator and always returns a 4-tuple
# (odet, propagators, chunks, S_at_surface_left). The trailing three are `nothing`
# on the forward path.
odet, _, _, _ = FFS.eulerlagrange_integration(ctrl, equil, ffit, intr)
vac = FFS.free_run(odet, ctrl, equil, ffit, intr)
println("Energy eigenvalue et[1] = ", real(vac.et[1]))Inspect Δ' at singular surfaces
for s in 1:intr.msing
sing = intr.sing[s]
println("Surface $s: ψ = $(sing.psi_s), m/n = $(sing.m[1])/$(sing.n[1])")
println(" Δ' = $(real(sing.delta_prime[1]))")
endAccess inter-surface Δ' matrix (Riccati path)
# intr.delta_prime_matrix is msing × msing after riccati_eulerlagrange_integration.
# Internally the solver builds a 2·msing × 2·msing raw matrix; the stored Δ' is
# the PEST3 four-term combination that folds the raw block into a per-surface
# tearing parameter.
dpm = intr.delta_prime_matrix
println("Δ' matrix size: ", size(dpm))
println("Diagonal (self-response Δ'):")
for j in 1:intr.msing
println(" Surface $j: ", real(dpm[j, j]))
endNotes
- The standard path does not populate
delta_prime; the canonical Δ' is the STRIDE BVPSingularSurfaces/Delta_prime_matrixfrom the parallel FM path.ca_l/ca_rare filled only by ideal surface crossings (kinetic and galerkin-matched runs emit zero-extentca_left/ca_rightsentinels). - The Riccati and parallel FM paths compute Δ' inline at each crossing, using the direct diagonal formula (no GR permutation). The result in
delta_prime_col[ipert_res, i]equalsdelta_prime[i]to machine precision. delta_prime_matrixcontains raw BVP coefficients, not asymptotic-normalized values; its diagonal elements do not in general equaldelta_prime.- ODE step counts depend on the equilibrium profile and mode count; the
numsteps_initparameter sets the initial allocation but the solver adapts automatically.
See also
docs/src/galerkin.md— RDCON outer-region Galerkin Δ′ solver (part of this module)docs/src/equilibrium.md— build thePlasmaEquilibriumobject required by this moduledocs/src/vacuum.md— vacuum response computed from the EL solution infree_rundocs/src/perturbed_equilibrium.md— downstream singular coupling analysis using Δ'