Repository navigation
ForceFreeStates.Galerkin - FEATURE! - Solve the Galerkin system by banded Cholesky, with LU fallback - #513
ForceFreeStates.Galerkin - FEATURE! - Solve the Galerkin system by banded Cholesky, with LU fallback#513StuartBenjamin wants to merge 15 commits into
Conversation
…r gal_solver = "cholesky"
gal_solver = "cholesky" called LinearAlgebra.LAPACK.pbtrf!/pbtrs!, which do not exist, so the
option died with an UndefVarError. Call LAPACK zpbtrf('L')/zpbtrs('L') directly through
libblastrampoline, unscaled, on the lower band the assembly already builds for Cholesky. Unlike
the reference implementation, a non-positive-definite matrix (zpbtrf info > 0) raises an error
naming gal_solver = "LU" instead of continuing with a partial factor.
Add examples/LAR_gal_cholesky_example (LAR analytic equilibrium, positive-definite Galerkin
matrix), regression case lar_gal_cholesky, test/runtests_galerkin_cholesky.jl, and a solver note
in docs/src/galerkin.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
matt-pharr
left a comment
There was a problem hiding this comment.
Couple cleanup items and one main question, being whether we should do away with the flag altogether and just make every galerkin solve try to use cholesky first and then use LU as a fallback. See the relevant comment.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…to enable Cholesky solver for ncoil > 0
…rst and fall back to LU gal_solver defaults to "auto", following @matt-pharr's review recommendation on #513: the full-band Galerkin matrix is solved by banded Hermitian Cholesky when it is Hermitian positive definite (Dirichlet rows' columns moved to the right-hand side), else by the Jacobi-scaled LU. "LU" skips the attempt; "cholesky" errors instead of falling back. The matrix is always assembled full band, so the lower-band assembly branches are gone. The test now checks the solver choice and the solution against a dense solve. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…try by entry, diagonal included The Hermiticity check before the banded Cholesky skipped the diagonal and used one tolerance, 1e-12 of the largest entry. zpbtrf reads only the real part of the diagonal, so a complex diagonal (the resonant elements' conj(u)·Lu) was dropped silently; and since the diagonal spans many decades, the small rows passed any asymmetry. Each entry is now compared with 1e-10·√|aᵢᵢaⱼⱼ|, diagonal included (the quadrature round-off is ~1e-11 of that scale). gal_cholesky_solve! requires kl == ku and solves in place of sol once zpbtrf succeeds. Galerkin() now defaults to solver = "auto" like gal_solver, and its docstring lists "auto". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…holesky falls back to LU only when not positive definite Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sonant surfaces Resonant powers are the eigenvalues of each side's own M₀ block. √(−det) matches those eigenvalues only when the trace vanishes, and that path is unchanged. alpha_override and extended_precision_bvp are removed. Co-authored-by: Cursor <cursoragent@cursor.com>
… bitwise-regression for simplicity and correctness
…sonant surfaces (#517)
d-burg
left a comment
There was a problem hiding this comment.
#517 was merged into this branch, so I'm reviewing its exponent change here, where it will reach develop.
The exponent change is correct, and it is the paper's definition rather than a departure from it, so that's good. Glasser (2016) defines the powers by det[M₀ − αI] = 0 (Eq. 46). Of α = ±√(−D_I) (Eqs. 48–49) the paper says it could not be proved analytically, only that the roots converge to it as Fourier components are added, and it proposes that agreement as a Fourier-convergence check. So sqrt(−det) was Eq. 46 under an unstated tr = 0 assumption.
From claude: regarding @matt-pharr's question (more right, or only more self-consistent): as far as I can derive it, the resonant block has the form [−κ+σ/2 φ; γ conj(κ)−σ/2] with φ, γ real, so tr = −2i·Im κ. That is a genuine term of the truncated operator, not an assembly slip. Matching to a basis with any other powers makes Δ′ depend on the matching distance, so this is more right for the truncated system. It does hide one thing: the old imaginary Δ′ was the only visible symptom of a non-converged resonant block, so I'd like the trace itself reported. Log Im tr / 2 and α per surface (ideally also next to alpha in the HDF5 output), and warn when |tr| is not small against α. The debug print in Asymptotics.jl (lines 125–126, outside this diff) still prints di next to an α that is no longer √(−di); something like:
block_det = m0mat[1, 1]*m0mat[2, 2] - m0mat[2, 1]*m0mat[1, 2]
block_tr = m0mat[1, 1] + m0mat[2, 2]
msg *= @sprintf(" det= %+.12e, tr= %+.12e %+.12ei\n", real(block_det), real(block_tr), imag(block_tr))
msg *= @sprintf(" alpha= %+.12e %+.12ei\n", real(alpha[1]), imag(alpha[1]))Requests before this goes to develop:
- Release note. This PR now carries #517's results-moving change and the removal of
extended_precision_bvp, but the release note covers onlygal_solver. Suggested Migration: "To reproduce the previous Galerkin results, setgal_solver = "LU". Removeextended_precision_bvpfrom[ForceFreeStates](the TOML key is ignored with a warning; passing it as a keyword tosolveis now an error); the Δ′ BVP always solves in Float64. Anything pinned to imaginary Δ′ or to Galerkinalphamust be re-baselined." The Numerical-impact line needs the exponent change too. - CI is red at
test/runtests_eulerlagrange.jl:706: the opposed-powers testset still checks bitwise a branch that was removed (suggestion inline). - Harness at the current head. The stamp here (
cd290c659) predates the #517 merge, and #517's own report was against a merge-base older than the axis-start change (#514). The near-axis surface is the one most exposed to that. - Wording: "Hermitian with a real diagonal" rather than "Δ′ must be real", as Matt suggested on #517.
- Solovev. Solovev is up-down symmetric, so its blocks should be traceless and the exponent change should not move it, yet #517 reported Im Δ′ going from −1.8e9 to −27 there. What is Im tr at its q = 2 surface? If it is round-off level, that change needs another explanation; if not, the matrices have an imaginary part on a symmetric equilibrium.
- Near-axis surface at n = 4. A residual Im Δ′ of −25 (about 1 % of the diagonal) means the series and the integrated solution disagree by about 1 % there; I'd treat that as the error bar. A cheap diagnostic: run with
JULIA_DEBUG=GeneralizedPerturbedEquilibrium, compare Im tr, α and dpsi^(−2α) at q = 1.25 with the other surfaces, then a three-pointsingfac_minscan (3e-4, 1e-4, 3e-5) to see whether Δ′₁₁ plateaus. - Small:
using DoubleFloatsinsrc/ForceFreeStates/ForceFreeStates.jl(line 22) is now dead; GGJ has its ownusing.
| @testset "column scaling stays /dpsi^α when the powers are opposed" begin | ||
| α = ComplexF64(1.5) | ||
| dpsi = 1.3e-4 | ||
| big = ones(ComplexF64, 2, 2) | ||
| small = ones(ComplexF64, 2, 2) | ||
| FFS._apply_column_powers!(big, small, -α, α, dpsi) | ||
| pfac = dpsi^α | ||
| big_ref = ones(ComplexF64, 2, 2) | ||
| small_ref = ones(ComplexF64, 2, 2) | ||
| big_ref ./= pfac | ||
| small_ref .*= pfac | ||
| @test big == big_ref | ||
| @test small == small_ref | ||
|
|
There was a problem hiding this comment.
The opposed-powers branch this testset pinned is gone, so its bitwise check now fails CI (line 706). The second half of the testset already checks the general path exactly, so I'd drop the first half rather than relax it to ≈.
| @testset "column scaling stays /dpsi^α when the powers are opposed" begin | |
| α = ComplexF64(1.5) | |
| dpsi = 1.3e-4 | |
| big = ones(ComplexF64, 2, 2) | |
| small = ones(ComplexF64, 2, 2) | |
| FFS._apply_column_powers!(big, small, -α, α, dpsi) | |
| pfac = dpsi^α | |
| big_ref = ones(ComplexF64, 2, 2) | |
| small_ref = ones(ComplexF64, 2, 2) | |
| big_ref ./= pfac | |
| small_ref .*= pfac | |
| @test big == big_ref | |
| @test small == small_ref | |
| @testset "column scaling multiplies each column by dpsi^power" begin | |
| dpsi = 1.3e-4 |
There was a problem hiding this comment.
Done in a968945: the opposed-powers half is dropped, and the general half stays.
| function resonant_block_exponents(m0::AbstractMatrix, sig::Real) | ||
| tr = m0[1, 1] + m0[2, 2] | ||
| det = m0[1, 1] * m0[2, 2] - m0[2, 1] * m0[1, 2] | ||
| α = sqrt(tr * tr / 4 - det) |
There was a problem hiding this comment.
With the traceless branch removed this is the only path, so a real block with complex roots throws DomainError and one with real roots returns Float64. Converting keeps it total and type-stable without changing any complex result.
| α = sqrt(tr * tr / 4 - det) | |
| α = sqrt(ComplexF64(tr * tr / 4 - det)) |
There was a problem hiding this comment.
Done: α = sqrt(ComplexF64(tr * tr / 4 - det)).
| function resonant_block_exponents(m0::AbstractMatrix, sig::Real) | ||
| tr = m0[1, 1] + m0[2, 2] | ||
| det = m0[1, 1] * m0[2, 2] - m0[2, 1] * m0[1, 2] | ||
| α = sqrt(tr * tr / 4 - det) |
There was a problem hiding this comment.
When tr²/4 − det is negative (a Mercier-unstable surface), it is real by construction, so the sign of the root comes from the sign of a zero or round-off imaginary part. Left and right can then label opposite solutions "big"; a standalone copy of this helper does that for a real block and its mirror. The old shared α made the pairing consistent by construction, and it is now gone entirely. Could α be canonicalised the same way on both sides (Re α > 0, or Im α > 0 when Re α is zero to round-off), or the pair computed once per surface and passed to both sides?
| end | ||
| end | ||
|
|
||
| @testset "resonant block Frobenius exponents" begin |
There was a problem hiding this comment.
These tests pin the 2×2 algebra but not the claim. The assembled block always has a purely imaginary trace, which none of the inputs here has, and the unit-test fixtures are up-down symmetric, so CI never exercises the new path. I'd add a case with the real block structure (before the column-scaling testset):
@testset "Hermitian-structured block: shared imaginary shift, real eigenvectors, same powers on both sides" begin
# Shape the assembly produces: [−κ+σ/2 φ; γ conj(κ)−σ/2] with φ, γ real and κ → −κ on the left side.
κ, φ, γ = 0.3 + 0.02im, 0.7, 2.5
m_right = ComplexF64[-κ+0.5 φ; γ conj(κ)-0.5]
m_left = ComplexF64[κ-0.5 φ; γ -conj(κ)+0.5]
α_right, big_right, small_right = FFS.resonant_block_exponents(m_right, 1.0)
α_left, big_left, small_left = FFS.resonant_block_exponents(m_left, -1.0)
@test α_right ≈ sqrt((real(κ) - 0.5)^2 + φ * γ)
@test α_left ≈ α_right
@test imag(big_right) ≈ -imag(κ)
@test imag(small_right) ≈ -imag(κ)
@test big_left ≈ big_right
@test small_left ≈ small_right
# ξ′ entries of both leading eigenvectors are real, so the big/small cross-Wronskian 2α/φ is real.
@test abs(imag(-(m_right[1, 1] - big_right) / φ)) < 1e-14
@test abs(imag(-(m_right[1, 1] - small_right) / φ)) < 1e-14
@test -(m_right[1, 1] - small_right) / φ + (m_right[1, 1] - big_right) / φ ≈ 2 * α_right / φ
endIdeally also one integration-level assertion on an asymmetric fixture: left and right powers agree, and the Riccati Δ′ diagonal is real to a stated tolerance.
There was a problem hiding this comment.
Added as you wrote it, before the column-scaling testset (a968945).
| M_solve = Tc.(M) | ||
|
|
||
| M_lu = lu(M_solve; check=false) | ||
| M_lu = lu(M; check=false) |
There was a problem hiding this comment.
I don't think the evidence supports deleting extended_precision_bvp before this reaches develop, only changing its default. It covers only singfac_min = 1e-4; #517's own 2 % miss at singfac_min = 1e-6 (Solovev q₀ = 1.85) is a case where Float64 is measurably wrong, and the code can no longer detect or repair it. A singfac_min scan is also exactly how one would verify the new exponents. The exponent fix and the precision of this LU solve are separate error sources. Could we keep the option as opt-in (default false, which keeps the whole speedup), or replace it with one step of iterative refinement (Float64 LU, residual in Double64) plus a warning when the correction to the read-off entries is large? What do you think?
There was a problem hiding this comment.
Agreed. It is back as an opt-in, default false (2c02244), so the speedup stays and a singfac_min scan can still check Float64 (point 6 is that case). A test checks that the Double64 solve matches the Float64 Δ′ diagonal on the DIII-D BVP case.
…surface; trace in the debug print; review tests - resonant_block_exponents: radicand in ComplexF64; a round-off imaginary part is dropped, so a negative tr²/4 − det gives the same α (and big root) on both sides. - Debug print: det, tr and α instead of di. - Tests: column-scaling testset keeps only its general half (the opposed-powers branch is gone); Hermitian-structured block; Mercier-unstable block with ±1e-17i in the radicand. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…impler α canonicalisation
- extended_precision_bvp (default false) solves the Δ' BVP and its PEST3 combination in
Complex{Double64}, to check Float64 at small singfac_min; it is no longer a dropped key.
- resonant_block_exponents: when Re α is round-off, Im α > 0 (same α on both sides).
- DIII-D BVP test: the Double64 solve matches the Float64 Δ' diagonal.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t small against α Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks for the careful review. Most of it concerns #517, which is merged into this branch, so I've answered those points here too. Pushed c827d4d (three commits on top of 4b44dcd). Point by point:
My three commits on top of 4b44dcd change none of these numbers. With
You also asked for the trace to be reported. The debug print now shows det, tr and α. A warning fires when a resonant block's |tr|/2 exceeds 1e-3·|α|. On the n = 4 and Solov'ev decks above the ratio is at most 3e-5, so it stays quiet there. |
|
Just human stuart adding here: @jhalpern30 most of these comments refer to ur original PR, I tried my best to respond to d-burg's review, but feel free to check out the responses if you want |
…v; conflicts take OpenFUSIONToolkit#513's newer OpenFUSIONToolkit#517 state
|
@d-burg @StuartBenjamin Following up on point 5 and on what τ = Im tr(M₀)/2 actually is. I ran a few more tests on feyn. In short: τ is numerical, it has two identifiable sources, and both go away with resolution. The eigenvalue exponents are still the right choice. What τ is. In every run, τ equals Im[(F̄⁻¹K̄)ᵣᵣ]/(n q′) at the rational surface (r = the resonant mode) to within 1e-9. That combination is large away from the surface and should pass exactly through zero at ψₛ, because two terms cancel there: the resonant term (F̄⁻¹)ᵣᵣ·Im K̄ᵣᵣ and the sum over the other modes. τ is whatever is left over from that cancellation. It isn't geometry. Mirroring the DIII-D-like g-file in Z does not flip the sign of τ (the mirror/original ratios are +0.2 to +3.2), and the up-down symmetric Solovev has the largest τ of the cases I ran. The off-diagonal Δ′ phases, on the other hand, conjugate exactly under the mirror (e.g. Δ′₁₂ −16.57° → +16.57°). So the complex off-diagonals are genuine geometry: Δ′ is Hermitian, and it is real only for up-down symmetric equilibria. Where the leftover comes from (DIII-D-like deck):
Neither mtheta (256 to 1024) nor etol (1e-10 to 1e-14) changes either part. Solovev (point 5). Its τ (1e-6 to 1e-4) wanders and flips sign as mpsi and mtheta change, and the Mercier D_I itself drifts with the Solovev grid. So the floor comes from the analytic Solovev equilibrium rather than from the ψ/θ grids. Amplified near the pole, that gives the old −1.8e9, which fits Stuart's numbers. What it means for this PR. At any finite resolution the discretized operator really does have this small trace. The eigenvalue exponents keep the asymptotic series consistent with it, so Δ′ no longer depends on the matching distance. Forcing tr = 0 would undo that. Nothing suggests Glasser's ±√(−D_I) is a symmetric-only result; it is the converged limit. On the new warning. τ is only a loose proxy for an under-resolved surface. At mpsi = 128 the q=4 surface has det(M₀) 34 % off the Mercier D_I, yet τ/α ≈ 4e-5, below the 1e-3 threshold. Comparing det(M₀) directly with the Mercier D_I would catch that case. I'd suggest two follow-ups for an issue rather than this PR: that check, and inserting each rational surface into the radial grid as a knot, which removes the interpolation part of τ by construction. |
Release note
gal_resistive_diiid: PEST3 Δ diagonal 0.40 %, Δ′ matrix norm 2e-5, at cd290c6).gal_solver = "LU".extended_precision_bvpnow defaults to false (it was true); set it true for the previous Double64 Δ′ BVP solve. Anything pinned to imaginary Δ′ or to Galerkinalphamust be re-baselined.The Galerkin Δ′ solve now factors the matrix by banded Cholesky and falls back to LU when it is not positive definite (
gal_solver = "auto", the new default, as @matt-pharr suggested). Before,gal_solver = "cholesky"crashed (UndefVarError: pbtrf!).This branch also carries #517 (resonant Frobenius powers from each side's M₀ block), merged into it, and the review follow-ups: the same α on both sides of a Mercier-unstable surface,
extended_precision_bvpback as an opt-in (default false), and a warning when a resonant M₀ block's trace is not small against α.Regression report
regress --refs 278057d96,c827d4d98(develop vs this head, all 18 cases; julia 1.11.7, pinned manifest, 2 threads):gal_resistive_diiiddiiid_n1_riccati(and_precompiled)diiid_n1,diiid_multi_n,diiid_truncation_n1,diiid_error_fieldsolovev_n1,solovev_multi_n,solovev_kinetic_*diiid_slayer_n1gal_resistive_peis N/A on both, as on developWith
regress --refs 278057d96,4b44dcd02,c827d4d98, the three review commits (a968945, 2c02244, c827d4d) change none of these numbers: every case reports c827d4d unchanged against 4b44dcd.Galerkin Cholesky alone, before #517 was merged (6fc89d0 vs cd290c6)
regress --cases gal_resistive_diiid,gal_resistive_pe --refs 6fc89d021,cd290c659(develop merge-base vs the Cholesky commit; julia 1.11.7, pinned manifest, 2 threads).gal_resistive_peis N/A on both refs, as on develop.Notes for reviewers
gal_solver:"auto"tries Cholesky, then LU;"LU"skips Cholesky;"cholesky"errors instead of falling back. The key stays because the TPS wrapper passes it.gal_resistive_diiid, 1.6e-7 with ForceFreeStates - BUGFIX! - Use trace-aware Frobenius exponents at resonant surfaces #517), which LU keeps and Cholesky drops: that is the 0.40 % change above.zpbtrf'sinfois checked. Fortran RDCON'sgal.fignores it and solves with a partial factor (fixed in RDCON - BUGFIX - Galerkin factorization failures: Cholesky falls back to LU, a failed LU stops PrincetonUniversity/GPEC#301).runtests_galerkin_cholesky.jl: a banded system with Dirichlet rows and a nonzero prescribed value is solved by Cholesky and matches a dense solve; made indefinite, it falls back to LU with the same answer, and"cholesky"errors.🤖 Generated with Claude Code