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 12 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 |
| 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)) |
| 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.
| 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?
Release note
gal_resistive_diiidthe PEST3 Δ diagonal moves by 0.40 % and the Δ′ matrix norm by 2e-5 (relative) (harness @ cd290c6)gal_solver = "LU".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!).Regression report
regress --cases gal_resistive_diiid,gal_resistive_pe --refs 6fc89d021,cd290c659(develop merge-base vs this head; 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