Skip to content

PerturbedEquilibrium - FEATURE - Decompose the plasma energy of free-boundary eigenmodes on the (psi,theta) grid - #465

Open
jaesun57 wants to merge 27 commits into
developfrom
feature/energy-decomposition
Open

jaesun57 wants to merge 27 commits into
developfrom
feature/energy-decomposition

Conversation

@jaesun57

@jaesun57 jaesun57 commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator

Release note

  • Audience: users
  • Numerical impact: none (harness @ 4285245)
  • Migration: none

A new [EnergyDecomposition] section decomposes the plasma potential energy δW_p of selected free-boundary eigenmodes on the (ψ,θ) grid in two forms, the effective-field form ½∫[|b_eff|²/μ₀ − K|ξ_n|²] with its shear-current, parallel-current and pressure-curvature terms, and the standard form ½∫[|b|²/μ₀ − j·(b×ξ*) + (∇·ξ)*(ξ·∇p)]; it verifies (∇×b_eff)·∇ψ = 0, compares each total with eigenmode_plasma_energies, and writes everything to ForceFreeStates/EnergyDecomposition/ in gpec.h5.

Regression report

================================================================
Case: solovev_n1 — Solovev analytical equilibrium, n=1, ideal stability
================================================================

Regression Report: solovev_n1
===================================================================================================
Ref 1: origin/develop  @ 20c466994 (2026-09-24)
       env: julia 1.11.6, arm64-apple-darwin24.0.0, manifest 3928d757 (pinned), 4 threads/4 BLAS
Ref 2: d77d35936  @ d77d35936 (2026-09-24)
       env: julia 1.11.6, arm64-apple-darwin24.0.0, manifest 3928d757 (pinned), 4 threads/4 BLAS
---------------------------------------------------------------------------------------------------
Quantity                                        origin/develop   d77d35936        Diff       Status
---------------------------------------------------------------------------------------------------
total energy Re(et[1])                          5.472393e-01     5.472393e-01     0.0e+00    OK    
total energy Im(et[1])                          1.021845e-03     1.021845e-03     0.0e+00    OK    
plasma energy Re(ep[1])                         -9.840040e+00    -9.840040e+00    0.0e+00    OK    
vacuum energy Re(ev[1])                         1.038728e+01     1.038728e+01     0.0e+00    OK    
vacuum matrix min eigenvalue                    2.171572e+00     2.171572e+00     0.0e+00    OK    
plasma energy (all)                             [32 elem]        [32 elem]        0.0e+00    OK    
vacuum energy (all)                             [32 elem]        [32 elem]        0.0e+00    OK    
total energy (all)                              [32 elem]        [32 elem]        0.0e+00    OK    
ODE steps (saved)                               396              396              0.0e+00    OK    
ODE steps (total)                               630              630              0.0e+00    OK    
q0                                              1.900006e+00     1.900006e+00     0.0e+00    OK    
q95                                             3.147422e+00     3.147422e+00     0.0e+00    OK    
beta_t                                          4.620629e-02     4.620629e-02     0.0e+00    OK    
beta_n                                          3.215492e+00     3.215492e+00     0.0e+00    OK    
# singular surfaces                             2                2                0.0e+00    OK    
singular psi locations                          [2 elem]         [2 elem]         0.0e+00    OK    
singular q values                               [2 elem]         [2 elem]         0.0e+00    OK    
mpert                                           32               32               0.0e+00    OK    
npert                                           1                1                0.0e+00    OK    
q profile (checksum)                            51fe3fff9691...  51fe3fff9691...  identical  OK    
pressure profile (checksum)                     1de4ae32d2b5...  1de4ae32d2b5...  identical  OK    
ca_left (checksum)                              293eefbfb4a7...  293eefbfb4a7...  identical  OK    
energy decomposition dW_p (effective field)     N/A              [1 elem]         N/A        N/A   
energy decomposition dW_p (standard form)       N/A              [1 elem]         N/A        N/A   
energy decomposition rel. error vs ep           N/A              [1 elem]         N/A        N/A   
energy decomposition std-form rel. error vs ep  N/A              [1 elem]         N/A        N/A   
energy decomposition curl residual (relative)   N/A              [1 elem]         N/A        N/A   
Runtime (s)                                     182.1s           204.3s                      --    
===================================================================================================
Summary: 22 unchanged, 5 missing/N/A


================================================================
Case: diiid_n1 — DIII-D-like equilibrium, n=1, ideal + perturbed equilibrium
================================================================

Regression Report: diiid_n1
===================================================================================================
Ref 1: origin/develop  @ 20c466994 (2026-09-24)
       env: julia 1.11.6, arm64-apple-darwin24.0.0, manifest 3928d757 (pinned), 4 threads/4 BLAS
Ref 2: d77d35936  @ d77d35936 (2026-09-24)
       env: julia 1.11.6, arm64-apple-darwin24.0.0, manifest 3928d757 (pinned), 4 threads/4 BLAS
---------------------------------------------------------------------------------------------------
Quantity                                        origin/develop   d77d35936        Diff       Status
---------------------------------------------------------------------------------------------------
total energy Re(et[1])                          8.011977e-01     8.011977e-01     0.0e+00    OK    
total energy Im(et[1])                          4.145207e-05     4.145207e-05     0.0e+00    OK    
plasma energy Re(ep[1])                         -1.348528e+00    -1.348528e+00    0.0e+00    OK    
vacuum energy Re(ev[1])                         2.149726e+00     2.149726e+00     0.0e+00    OK    
vacuum matrix min eigenvalue                    1.873976e-01     1.873976e-01     0.0e+00    OK    
plasma energy (all)                             [35 elem]        [35 elem]        0.0e+00    OK    
vacuum energy (all)                             [35 elem]        [35 elem]        0.0e+00    OK    
total energy (all)                              [35 elem]        [35 elem]        0.0e+00    OK    
ODE steps (saved)                               2542             2542             0.0e+00    OK    
ODE steps (total)                               4532             4532             0.0e+00    OK    
q0                                              1.204212e+00     1.204212e+00     0.0e+00    OK    
q95                                             4.781723e+00     4.781723e+00     0.0e+00    OK    
beta_t                                          1.327025e-02     1.327025e-02     0.0e+00    OK    
beta_n                                          1.372511e+00     1.372511e+00     0.0e+00    OK    
internal inductance li1                         8.842322e-01     8.842322e-01     0.0e+00    OK    
internal inductance li2                         7.080794e-01     7.080794e-01     0.0e+00    OK    
internal inductance li3                         7.304378e-01     7.304378e-01     0.0e+00    OK    
poloidal beta betap1                            6.680742e-01     6.680742e-01     0.0e+00    OK    
poloidal beta betap2                            5.349834e-01     5.349834e-01     0.0e+00    OK    
poloidal beta betap3                            5.518761e-01     5.518761e-01     0.0e+00    OK    
# singular surfaces                             5                5                0.0e+00    OK    
singular psi locations                          [5 elem]         [5 elem]         0.0e+00    OK    
singular q values                               [5 elem]         [5 elem]         0.0e+00    OK    
current beta betaj                              4.236478e-01     4.236478e-01     0.0e+00    OK    
plasma volume                                   1.829471e+01     1.829471e+01     0.0e+00    OK    
plasma current                                  1.152130e+00     1.152130e+00     0.0e+00    OK    
mpert                                           35               35               0.0e+00    OK    
npert                                           1                1                0.0e+00    OK    
toroidal field bt0                              2.006573e+00     2.006573e+00     0.0e+00    OK    
wall field bwall                                3.880146e-01     3.880146e-01     0.0e+00    OK    
aspect ratio                                    2.845746e+00     2.845746e+00     0.0e+00    OK    
elongation kappa                                1.708323e+00     1.708323e+00     0.0e+00    OK    
q profile (checksum)                            2a0be4f020df...  2a0be4f020df...  identical  OK    
pressure profile (checksum)                     fb5a352b74ce...  fb5a352b74ce...  identical  OK    
Mercier D_I profile (checksum)                  99d4ef7cff0b...  99d4ef7cff0b...  identical  OK    
resistive interchange D_R profile (checksum)    60499f52f9b0...  60499f52f9b0...  identical  OK    
ballooning Delta' profile (checksum)            e4d0b31d8b0c...  e4d0b31d8b0c...  identical  OK    
energy decomposition dW_p (effective field)     N/A              [1 elem]         N/A        N/A   
energy decomposition dW_p (standard form)       N/A              [1 elem]         N/A        N/A   
island half-widths                              [5 elem]         [5 elem]         0.0e+00    OK    
Chirikov parameter                              [5 elem]         [5 elem]         0.0e+00    OK    
energy decomposition rel. error vs ep           N/A              [1 elem]         N/A        N/A   
||resonant area-weighted field||                5.189033e-04     5.189033e-04     0.0e+00    OK    
energy decomposition std-form rel. error vs ep  N/A              [1 elem]         N/A        N/A   
dominant-coupling singular values               N/A              N/A                         N/A   
energy decomposition curl residual (relative)   N/A              [1 elem]         N/A        N/A   
|forcing overlap with dominant mode|            N/A              N/A                         N/A   
PE plasma energy                                3.422706e+00     3.422706e+00     0.0e+00    OK    
PE vacuum energy                                3.174511e+00     3.174511e+00     0.0e+00    OK    
PE surface energy                               5.826847e+00     5.826847e+00     0.0e+00    OK    
PE toroidal torque                              5.086590e-02     5.086590e-02     0.0e+00    OK    
NTV torque FGAR [N·m]                           5.297001e-01     5.297001e-01     0.0e+00    OK    
NTV kinetic energy dW FGAR [J]                  7.924880e-02     7.924880e-02     0.0e+00    OK    
Runtime (s)                                     285.3s           285.2s                      --    
||forcing b~|| (root-area-weighted)             4.683329e-04     4.683329e-04     0.0e+00    OK    
resonant area-weighted field b^r                [5 elem]         [5 elem]         0.0e+00    OK    
===================================================================================================
Summary: 48 unchanged, 7 missing/N/A

Notes for reviewers

  • Physics: src/PerturbedEquilibrium/EnergyDecomposition.jl follows the Fortran GPEC gpeq_c, gpeq_shear, gpeq_curvature, gpeq_K, gpeq_epf, gpeq_dst, gpeq_firstform routine by routine (the Fortran recon_flag / recon_flag2 diagnostics; docs/tex/recon/main.tex in the Fortran repository has the derivations). recon_flag3 was deliberately not ported.
  • Three documented deviations from the Fortran, all measured on the Solovev example:
    • the decomposition is evaluated on the equilibrium ψ knots (plus the solution edge) with ξ_s recomputed from the exact Euler-Lagrange matrices at each node. Between knots the metric coefficients, matrices and geometry are interpolated separately and the curl identity only holds to O(h⁴); on the knots it holds to 1e-13.
    • Jξ^ψ, the covariant b_i and ξ_n are formed pointwise in θ with the exact local metric instead of the mode-truncated gpeq_contra/gpeq_cova/gpeq_normal arrays; with the truncated arrays the |V|² and K₂|ξ_n|² pieces do not cancel and the effective-field total sits 21 % below ep[1] independent of the ψ grid.
    • σ, K₂ and κ_ψ use the metric-consistent B² = (χ'/J)²(g_θθ + 2q g_θζ + q²g_ζζ) rather than the equilibrium's separate |B| spline, which differs from it by up to several percent near the edge.
      With these, each form reproduces ep[k] to better than 1 % on the Solovev decks (1e-5 to 1e-2 depending on the radial resolution). A Fortran run of the same case (reg_flag=f, matched psihigh) reproduces its own ep to 1.4e-4 in both forms; the effective form needs metric derivatives (shear, curvature) that the standard form does not, so its residual tracks the coordinate accuracy of the Julia equilibrium.
  • The reg_spot keyword refactor of compute_contra_displacements is numerically neutral (every pre-existing harness row unchanged).
  • Ideal, single-n, forward-integrator runs only; the stage warns and skips otherwise.

🤖 Generated with Claude Code

Notes

This is has the same feature with PrincetonUniversity/GPEC#213 this PR. But would be much more clear and neat version.

jaesun57 and others added 12 commits September 22, 2026 17:32
…mpute_contra_displacements as a keyword

The eigenmode energy decomposition runs the contravariant convolution unregularized and has no PerturbedEquilibriumControl, so the width becomes a keyword; reconstruct_physical_fields passes ctrl.reg_spot through unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…s, result, and surface geometry

First piece of the eigenmode energy decomposition: the [EnergyDecomposition] control struct, the result struct whose fields are the HDF5 leaf names, and the per-surface geometry (gradient and tangent bases, metric, B² derivatives) that every kernel shares instead of re-deriving it as the Fortran does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…gy decomposition

Shear S, curvature projection κ_ψ, parallel-current ratio σ, and the three destabilizing terms K1 = |∇ψ|²σS, K2 = μ₀B²σ², K3 = 2p'κ_ψ per θ point, following the Fortran gpeq_shear/gpeq_curvature/gpeq_K with the periodic spline derivative of the shear geometry term.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…nmode and verify its curl has no normal component

eigenmode_modes runs the unregularized gpeq chain for the edge eigenvector wt[:, k] on the equilibrium knots inside the solution domain, splining ξ and ξ' from the ODE grid and recomputing ξ_s from the ideal Euler-Lagrange matrices at each node as the Fortran gpeq_sol does; surface_fields! puts one surface on the θ grid with the band-limited ξ_n of gpeq_normal; effective_field! adds the ξ_n(μ₀ j × n̂) correction of gpeq_c; curl_residual! evaluates (∇×b_eff)·∇ψ in mode space.

The knots matter: between them the metric coefficients, the matrices and the bicubic geometry are interpolated separately and the identity only holds to O(h⁴) (1e-4 on the mpsi = 16 Solovev fixture, O(1) next to a rational surface); on the knots the Solovev test reaches 5e-13.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ndard-form energy densities on a surface

One θ pass per surface produces |b_eff|²/μ₀ with its component split and the three K_i|ξ_n|² terms (Fortran gpeq_epf/gpeq_dst) and, for the standard form, |b|²/μ₀, j·(b×ξ*) and (∇·ξ)*(ξ·∇p) (gpeq_firstform); surface_integral is the periodic Riemann sum the Fortran uses.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…boundary eigenmodes over psi

decompose_energy runs the per-surface kernels threaded over the equilibrium knots of the solution domain, integrates the effective-field and standard-form pieces in ψ with a cubic spline, scales them by 2μ₀/ψ₀² to the normalization of eigenmode_plasma_energies, and stores the two consistency checks (curl residual over the knots, mismatch against ep[k]) as data.

The per-surface fields now form Jξ^ψ, the covariant b_i and ξ_n pointwise from the exact local metric instead of the mode-truncated gpeq_contra/gpeq_cova/gpeq_normal arrays: with the truncated arrays the |V|² and K₂|ξ_n|² pieces no longer cancel and the effective-field total sat 21 % below ep[1] on the Solovev fixture independent of the ψ grid. Pointwise, both forms converge with the grid: relative mismatch against ep at mpsi = 16/48/128 is 1.4e-2/3.4e-3/2.3e-3 (effective) and 1.2e-2/1.4e-3/2.5e-4 (standard) for the least-stable mode; the knot curl residual stays at 1e-13.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…rceFreeStates/EnergyDecomposition

The eigenmode δW_p decomposition sits beside FreeBoundaryStability, with the profiles on the decomposition ψ scale, the optional Densities/ subgroup on a θ scale, zero-extent datasets for a disabled form, and a full long_name/units/dims table so the schema test holds.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…he [EnergyDecomposition] section

The stage runs right after the ForceFreeStates output is written, so it is available with force_termination = true and without any forcing definition; it warns and skips when the solve has no dense ξ solution or no free-boundary eigenmodes, and main returns it as energy. energy_decomposition is the scripting entry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ev and DIII-D-like ideal decks

Both decks integrate forward with vac_flag, so the eigenmode energy decomposition runs and its group is produced for the regression cases built on them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…in solovev_n1 and diiid_n1

The two δW_p route totals, their relative mismatch against eigenmode_plasma_energies, and the relative (∇×b_eff)·∇ψ residual of the least-stable eigenmode.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…nmodes

A short page with the two forms of δW_p, the K kernel, the normalization against eigenmode_plasma_energies, the TOML section, the output group and the API, plus the schema and architecture entries. Docstring cross-references of the unexported helpers become plain code spans since they are never rendered.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ngth in the energy-decomposition kernel

σ, K₂ and κ_ψ now take B² = (χ'/J)²(g_θθ + 2q g_θζ + q²g_ζζ) from a bicubic of the straight-field-line metric on the equilibrium grid instead of the equilibrium's separate |B| spline, which differs from it by up to several percent near the edge of the Solovev example; the difference broke the |V|² ↔ K₂|ξ_n|² cancellation of the effective-field form. Solovev mpsi = 128: effective-form mismatch against ep[1] 1.08e-2 → 7.9e-3, standard form unchanged at 1.7e-5; mpsi = 256: 9.9e-4 and 8.8e-3.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@github-actions github-actions Bot added the feature New capability label Sep 22, 2026
@jaesun57 jaesun57 closed this Sep 22, 2026
@jaesun57
jaesun57 deleted the feature/energy-decomposition branch September 22, 2026 23:40
@jaesun57
jaesun57 restored the feature/energy-decomposition branch September 22, 2026 23:47
…ed field as δB in the energy-decomposition page

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@jaesun57 jaesun57 reopened this Sep 23, 2026
@jaesun57 jaesun57 self-assigned this Sep 23, 2026
jaesun57 and others added 4 commits September 24, 2026 13:07
…and the energy decomposition over through gpec.h5

The [EnergyDecomposition] stage still runs inside main after the force-free-states solve, but its result no longer rides on the returned NamedTuple: it is written to ForceFreeStates/EnergyDecomposition/ in gpec.h5, and scripts get it from energy_decomposition(result.ffs), which is now exported next to solve and perturbed_equilibrium. This matches how the LocalStability stage hands its result over and keeps main's (; ffs, pe, slayer) shape that runtests_result_struct.jl pins.

Also restore develop's text where the earlier formatter run had reflowed unrelated lines in GeneralizedPerturbedEquilibrium.jl and FieldReconstruction.jl, and make the main-stage test read the decomposition back from gpec.h5.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…oolkit/GPEC into feature/energy-decomposition
…and exports clear of the resonant-coupling lines

develop's ResonantCoupling additions land on the same lines the branch used for its include and exports in PerturbedEquilibrium.jl and for its key-file bullet in architecture.md. Put the branch's lines where develop did not touch (after the FieldReconstruction include, in the type-export block, after the Response.jl bullet) so the branch merges cleanly without a merge commit. EnergyDecomposition.jl needs nothing from Response, SingularCoupling or Utils at parse time.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@jaesun57
jaesun57 marked this pull request as ready for review September 24, 2026 17:40
@jaesun57
jaesun57 requested review from logan-nc and a lite review from Copilot September 24, 2026 17:40

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The review found one critical integration issue and two moderate correctness/schema issues.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 High severity · 1 Medium severity

Open (2)
What changed in this PR

Adds an optional energy-decomposition diagnostic for free-boundary eigenmodes, with effective-field and standard-form calculations, HDF5 output, tests, regression tracking, and documentation.

Changes:

  • Adds decomposition APIs and pipeline/TOML integration.
  • Adds regression quantities and example configuration.
  • Adds tests, documentation, and HDF5 schema guidance.
  • Refactors displacement regularization to a keyword argument.

Unresolved findings: critical integration gating issue (4 votes), moderate disabled-field HDF5 shape issue (3 votes), and moderate truncated-domain integration issue (1 vote).

File Description
test/​runtests.jl Registers energy-decomposition tests.
test/​runtests_energy_decomposition.jl Tests calculations, toggles, metadata, and output.
src/​PerturbedEquilibrium/​PerturbedEquilibrium.jl Includes and exports the new APIs.
src/​PerturbedEquilibrium/​FieldReconstruction.jl Refactors regularization argument handling.
src/​PerturbedEquilibrium/​EnergyDecomposition.jl Implements decomposition, diagnostics, and HDF5 writing.
src/​GeneralizedPerturbedEquilibrium.jl Integrates the optional stage and scripting API.
regression-harness/​cases/​solovev_n1.toml Tracks decomposition diagnostics.
regression-harness/​cases/​diiid_n1.toml Tracks decomposition diagnostics.
examples/​Solovev_ideal_example/​gpec.toml Enables the feature in the example.
examples/​DIIID-like_ideal_example/​gpec.toml Enables the feature in the example.
docs/​src/​energy_decomposition.md Documents theory, usage, outputs, and API.
docs/​src/​citations.md Adds supporting citations.
docs/​src/​api.md Documents the scripting entry point.
docs/​make.jl Adds the documentation page to navigation.
docs/​development/​references.md Records implementation references.
docs/​development/​hdf5-conventions.md Documents the new output schema.
docs/​development/​architecture.md Updates the architecture overview.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +863 to +864
ForceFreeStates.require_solution(result, "energy decomposition") || return nothing
ForceFreeStates.require(result, :free_boundary, "energy decomposition") || return nothing
:effective_b_curl_residual_density, :b_squared_density, :current_coupling_density, :pressure_compression_density)

# Empty result arrays are written zero-extent (the file's not-computed sentinel).
_h5_value(x::AbstractArray) = isempty(x) ? similar(x, 0) : x

@logan-nc logan-nc left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consolidated review from three independent passes (structural, performance, code clarity), each claim checked against the source. Ordered by what I'd want settled before merge. The physics itself was not reviewed here — see the end.

1. Blocking: the stage aborts the run instead of skipping

run_energy_decomposition (GeneralizedPerturbedEquilibrium.jl, new hunk) gates only require_solution and require(:free_boundary), which warn and return. But decompose_energy throws ArgumentError on nlow != nhigh and mats.kinetic !== nothing (EnergyDecomposition.jl:698–699). A legal multi-n or kinetic deck that carries [EnergyDecomposition] therefore kills the whole run after FFS — before PE, KineticForces or SLAYER — contradicting the stage docstring ("warned and skipped, like every optional stage") and docs/src/energy_decomposition.md. Copilot flagged the same gap. ~10 lines: warn-and-return for both conditions in the driver, keep the throws for scripted use.

2. Decide before merge: where the output lives

  • The group ForceFreeStates/EnergyDecomposition/ bakes "free-boundary eigenmodes only" into the schema, while the producer is a PE-module stage. Free-boundary enters the code through just wt[:,k] (:342) and ep[k] (:814); a decompose_energy(ffs, edge::Vector; reference=nothing) entry would serve fixed-boundary modes and forced responses at ~half a day, and PerturbedEquilibrium/… would then be the natural home. HDF5 renames are clean breaks here with no shim, so the placement is worth deciding now even if the generalization waits.
  • eigenmode_index = copy(eigenmodes) (:719) echoes a control value into an output group (hdf5-conventions.md: never echo control-struct values outside Input/). It is defensible as the coordinate of the eigenmode dimension — in which case attach it as a dimension scale; only psi is attached today (:926–927).

3. Structural: the geometry block shadows Equilibrium

  • surface_geometry! (:160–166) reproduces Equilibrium.flux_surface_metric (FluxSurfaceMetrics.jl:16–23) line for line — the function whose docstring declares it "the single source of truth for the |∇ψ| computation reused across the FFS and PE modules" — then extends it with w21/w22/w31/w32 and the tangent basis. Extend flux_surface_metric (or add a sibling in Equilibrium) and call it, rather than carrying a second implementation in PE.
  • The same block re-interpolates geometry with 13 bicubic evaluations per point at the equilibrium knots, where rzphi_*.nodal_derivs.partials[1:3, i, j] already holds f, ∂f/∂ψ, ∂f/∂θ and make_metric (Fourfit.jl:193–197) and GeometryProfiles.jl:44 read them directly. Only the appended edge node needs the interpolant. The feature is cheap overall (order 0.1–1 s), so this is about consistency with the existing pattern more than speed.
  • metric_bsq_interpolant (:213–230) is a private, persistent B² that competes with equil.eqfun_B and is rebuilt on every call (:717). Whether metric-consistent B² is the right physics is for the physics review; either way its owner should be Equilibrium or make_metric, so a future consumer (LocalStability shear, KineticForces) does not grow a third B².
  • eigenmode_modes (:361–367) re-encodes −A⁻¹(Bξ′ + Cξ) by hand; compute_node_xi_s! (ForceFreeStates/EulerLagrange.jl:1397, used at Result.jl:174) already does exactly that.
  • surface_geometry! and curvature_kernel! depend only on (equil, psi) but sit inside the eigenmode loop (:755–770): all geometry is computed twice for eigenmodes = [1, 2]. Hoist per surface.

4. Robustness

  • surface_geometry!(…; bsq=nothing) leaves geom.bsq zero, and curvature_kernel! divides by it (:285). Make bsq required.
  • similar(x, 0) (:921–922) collapses disabled 2-D/3-D outputs to rank-1 while the metadata table declares (psi, eigenmode) / (psi, theta, eigenmode) — the writer cannot attach the declared dims (Copilot). The zero-extent sentinel has precedent, but only if the rank is kept.
  • Single-n is load-bearing, not just declared: N = ffs.numpert_total (:349) is written into xi_s[i, :] of width mpert (:367), a DimensionMismatch the guard at :698 is what prevents.
  • Constructors hard-code field counts (for _ in 1:16, 1:6, 1:13, 1:6, 1:8 at :131/248/400/451/539); fieldcount(T) stops them rotting when a field is added.

5. Cleanup

  • Code comments are clean — no run exceeds two lines. The narration is in docstrings: :208–211 and :409–412 carry session history (keep the one-sentence reason; drop the rest), and test:176, :208 record observed percentages that belong in the docs, not a comment.
  • docs/src/energy_decomposition.md:64 says "better than 1 %"; the tests record 1.4 %, 1.2 % and 3.3 % at the mpsi = 16 fixture (test:176, :208). "≲1–3 % at mpsi = 16, converging with resolution" matches the evidence.
  • test/runtests_energy_decomposition.jl (292 lines) would be ~200 with the same coverage: _ed_metadata_violations (:216–231) duplicates test/h5_metadata_check.jl — include it; :32–50 tests the reg_spot keyword, not this kernel; :70–73 asserts kwdef defaults; :86–90 re-derive K1/K2/K3 from :293–295 (tautological); :99–128 hand-plumbs the curl residual that :173 already pins; decompose_energy runs eight times — hoist the shared results. :287–291 writes into the shared fixture's gpec.h5 and only works because it is the last testset — write to a temp copy.
  • Five structs describe their fields in prose rather than a ## Fields block (:104, :232, :376, :435, :516); the unnamed 1e-10 at :315; μ₀j^θ and μ₀j^ζ computed three times (:286, :475, :572); the 13 hand-named accumulators plus three mirrored copy blocks (:737–749, :796–837) could be driven from one name tuple, as _allocate_result! already half-does.

6. Before merge

  • The branch is ~250 commits behind and develop has since rewritten FieldReconstruction.jl (225 lines) and the driver (+455); your 6-line reg_spot change and 47-line hook land on rewritten code. DIRTY here is semantic, not textual (zero conflict markers) — worth re-reading both hunks after the merge-forward.
  • The regression report was a laptop run (arm64, 4 threads) against a develop that has moved; please re-run on the shared machine after merging forward and refresh the stamp.
  • This review deliberately did not cover the physics (metric-B² vs eqfun_B, the pointwise covariant lowering your docstring at :408–412 justifies, the ξ_s recomputation at knots). Those deserve a physics pass and I'd ask for one before approval.

Credit where due: Copilot's two inline comments — the gating gap and the similar(x, 0) rank collapse — were both real and are folded in above.

🤖 Generated with Claude Code

https://claude.ai/code/session_0186CLfjeJeSusNTva5RMfGU

@logan-nc logan-nc left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Physics pass, to close the gap my earlier review left open. Checked term by term against the Fortran counterpart and the theory note on origin/recon of the Fortran repository (gpout_recon/gpout_recon2 in gpout.f, gpeq_c/gpeq_K/gpeq_shear/gpeq_curvature/gpeq_cveri/gpeq_firstform in gpeq.f, docs/tex/recon/main.tex) — not on Fortran develop, which is worth stating in the docs since "fidelity to Fortran" means fidelity to that branch. Every claim marked verified was read or derived; the Fortran line references were checked by me as well.

Verdict: correct with caveats. No missing terms, no placeholders; every kernel either matches origin/recon line for line or is a defensible, more accurate deviation. One thing should be settled before merge — see §6 — because it is the untested explanation for the 1.4–3.3 % mismatch.

1. Both δW forms — verified

  • Standard form (:565–585): derived from −½∫ξ*·F(ξ) with the full ξ (the ξ∥ pieces of the J×Q and ∇·ξ terms cancel, so using the full Jξ^θ, Jξ^ζ is right). det_term at :572–575 is J·det[j, b, ξ*] with j^ψ = 0; ∇·ξ at :577 is (1/J)[∂ψ(Jξ^ψ) + ∂θ(Jξ^θ) − 2πin Jξ^ζ] in the exp(2πi(mθ−nζ)) convention. Term for term identical to gpeq_firstform. One divergence: ∂θ(Jξ^θ) is spectral here (:415–418) and a periodic spline there — fine, but annotate it as a Fortran deviation.
  • Effective field b_eff = b + ξ_n μ₀ j×n̂ (:455–480): all six component formulas verified by hand and identical to gpeq_c (cwt/cwz/cvp/cvt/cvz). μ₀j^θ = −F′/J and μ₀j^ζ = −(μ₀p)′/χ′ − qF′/J follow from ∇×B and (j×B)·∇ψ = p′, given F_deriv = d(2πF)/dψ_N (inferred from the Fortran sq%f1(1) usage; please confirm).
  • K = K1 + K2 + K3 (:293–295): identical to gpeq_K and main.tex; units consistent (K|ξ_n|² is Pa); K3 = 2p′κ_ψ checks against the interchange term. Caveat: K1, K2 and the completing-the-square identity were verified against main.tex and gpeq_K only — Bernstein 1983, Chance 1992 and Boozer 2004 are not in docs/resources/, so the equation numbers this PR adds to references.md/citations.md are unverified. Please either add the PDFs or cite the theory note.
  • ½, Re, conjugation, normalization: Re[(Jc^i)* c_i]/(μ₀J) is Hermitian-correct; the ½ matches Fortran dw_raw = 0.5*total; the 2μ₀/ψ₀² scale (:602) with unit-power-normalized wt[:,k] as edge BC (:342) is the same convention as Fortran's dcon_fac after idcon.f undoes its rescaling; ep = diag(wt†·wp·wt) with wp = (u2/u1)/ψ₀² matches free.f. No vacuum or surface term belongs in the comparison — ep is the plasma boundary term Ξ†(FΞ′+KΞ), i.e. the volume δW_p; ev is separate. Verified in concept; the DCON ½/μ₀ scale was not re-derived from Glasser 2016, but the 1e-3 agreement rules out a missing O(1) factor.

2. Metric-B² vs eqfun_B — your choice is right; say so at the call site

Fortran gpeq_K and gpeq_curvature take B² from eqfun (bsq_val = eqfun%f(1)**2, with fx/fy for its derivatives). make_metric builds only g_ij/J, J, J_ψ — no eqfun — so the Euler–Lagrange operator and the b reconstruction carry the metric B², and the C∥ = K2|ξ_n|² cancellation holds only with that B². Using it is operator-consistent and an improvement over Fortran. The ~1 % is the mismatch between the equilibrium's direct |B| and the inverse-map metric at mpsi = 16 — coordinate-transformation accuracy converging with equilibrium resolution, not hidden physics. It needs the project's one-line deviation note at :285, :291, :294, not only in the docstring.

3. Pointwise covariant lowering — the claim holds, with two corrections

gpeq_c back-transforms band-limited modes before squaring, so ∮|C|² and ∮K2|ξ_n|² each lose out-of-band energy that does not cancel between them; pointwise products on the mtheta grid are Parseval-exact (aliasing aside). So the Julia effective-field numbers will differ from Fortran recon reference values at the few-% level, and Julia is the more accurate. Two corrections to the docstring at :408–412: "resolution-independent" should read "ψ-resolution-independent, mpert-dependent" (the deficit shrinks with the band); and this is a hybrid — Jξ^ψ is formed pointwise (:416, :426) but Jξ^θ, Jξ^ζ still come from the truncated jmat convolution in compute_contra_displacements — the docstring implies all-pointwise.

4. ξ_s at the knots — verified, no physical content in knot-vs-node

−A⁻¹(Bξ′ + Cξ) (:361–367) matches Fortran gpeq_sol and Julia's own compute_node_xi_s! (EulerLagrange.jl:1397, same raw A; Cholesky vs LDLᵀ immaterial). It is the algebraic Euler–Lagrange relation for Ξ_s, so evaluating at knots rather than ODE nodes changes only the interpolation accuracy of (ξ, ξ′), nothing physical. That argues for calling the existing helper rather than re-encoding it (as my earlier review noted).

5. The curl residual — right check, state its scope

(:495–515) is identical to gpeq_cveri and is the Ξ_s equation itself (∇ψ·∇×C = 0 ⇔ δj·∇ψ = 0 closure), so at the knots it holds by construction. It validates the ξ_s algebra, the g22/g23/g33 lowering and the b_θ, b_ζ assembly against A, B, C — not the ψ-ODE and not b_ψ or g11/g12/g13/|∇ψ|². A roundoff-level value says nothing about the eigenmode's correctness. Worth one sentence in the docs so it isn't read as a global check.

6. Convergence — the one thing to settle before merge

Expected: ~h⁴ for the cubic-spline ψ-quadrature on a smooth integrand, spectral in θ. Observed from the PR's own numbers (mpsi 16 → 128): effective form ~h^0.9, standard form ~h^1.8. That is not quadrature-order behaviour.

The leading explanation is a mis-documented Fortran deviation. gpout.f says verbatim, just above the energy check: "Perform the energy check on the DCON solution grid. Do not use rzphi%xs for this comparison; it is a coarse geometry grid." — and integrates on psifac. decomposition_grid (:309–316) integrates on the rzphi_xs equilibrium knots, and the docstring at :305 says gpout_recon "loops over the same knots" — true of its diagnostics loop, not of its energy integral. With the 1/(m − nq) structure in ξ_s and Jξ^θ, the coarse-knot integral is the natural suspect for both the size of the mismatch and the sub-quadrature rate; the slower effective-form rate is consistent with K depending on spline derivatives (bsq_psi, dratio) that lose an order. A confound: ep itself moves with mpsi.

Discriminating test, cheap to run: fix mpsi, integrate on ODE nodes ∪ knots instead of knots alone, and report how the relative error changes. If it collapses, fix the grid and the docstring; a separate mpert scan would then bound the band-truncation floor at mpsi = 128.

7. Fortran correspondence and the annotations owed

gpout_recon/gpeq_c/gpeq_K/gpeq_shear/gpeq_curvature/gpeq_cveri/gpeq_epf/gpeq_dst/gpeq_firstform ↔ decompose_energy/effective_field!/curvature_kernel!/curl_residual!/surface_densities!. Term-level divergences, each needing the project's one-line deviation comment at the call site: (a) metric B² vs eqfun B²; (b) pointwise vs band-truncated C_i, ξ_n; (c) spectral vs spline ∂θ; (d) radial grid knots vs psifac; (e) reg_spot = 0 vs Fortran's regularized xmp1/xmt path in gpeq_firstform (inferred from variable names, not traced). Also: the long_name on current_coupling and pressure_compression (:940–941) says "imaginary part is roundoff" — true of the volume totals, not of the per-surface profiles; reword.

Not checked: the Bernstein/Chance/Boozer equation numbers; the DCON ½/μ₀ convention from Glasser 2016 directly; the edge amplitude in sum_eigenmode_contributions; gpeq_firstform's regularization path; gpout_recon2's radial grid; aliasing error of the mtheta Riemann sum.

🤖 Generated with Claude Code

https://claude.ai/code/session_0186CLfjeJeSusNTva5RMfGU

jaesun57 and others added 9 commits October 15, 2026 14:28
…warning on multi-n, kinetic and non-forward runs

run_energy_decomposition now warns and returns nothing for a non-forward integrator, nn_low != nn_high or kinetic matrices instead of letting decompose_energy throw and abort main. Disabled forms are written as their zero-extent arrays directly, so the sentinels keep the rank their dims attribute declares.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…c B² and node ξ_s with Equilibrium and ForceFreeStates in the energy decomposition

Add Equilibrium.flux_surface_basis (interpolant and knot methods, sharing the ∇ψ kernel of flux_surface_metric) and move metric_bsq_interpolant next to it. surface_geometry! takes bsq as a required keyword and reads the stored knot values on equilibrium knots. eigenmode_modes calls ForceFreeStates.compute_node_xi_s!, which now also accepts a single displacement vector. Geometry, the curvature kernel and μ₀j are computed once per surface before the eigenmode loop, the per-surface integrals and density copies are driven by the profile name tuples, the scratch constructors use fieldcount, and the structs document their fields. Decomposition totals are unchanged to 1e-15 on the Solovev fixture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…iations from the Fortran recon branch

Mark at each call site where the decomposition departs from the Fortran recon branch (metric B², pointwise products, spectral θ-derivative, knot-grid integration, reg_spot = 0). Correct the decomposition_grid and surface_fields! docstrings and the current_coupling and pressure_compression long_names, and state in the docs what the curl residual validates and the measured convergence, including the open edge offset of the effective-field form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and drop redundant tests

Use the shared h5_metadata_check walker, compute the decompositions once, drop tests that re-derived definitions or duplicated the pinned curl residual, and add checks for the skip warnings, the zero-extent ranks, knot versus interpolant geometry and the required bsq keyword. The scripting-entry test now writes to a scratch copy of gpec.h5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…bedEquilibrium/EnergyDecomposition with an eigenmode_index coordinate

The decomposition of free-boundary DCON eigenmodes now writes to PerturbedEquilibrium/EnergyDecomposition/ instead of ForceFreeStates/EnergyDecomposition/, with no legacy path. eigenmode_index is marked as the dimension scale of the eigenmode axis and attached to every total, profile and density, so it reads as a coordinate rather than an echoed control value. The writer, tests, docs, schema table and the solovev_n1 and diiid_n1 harness cases move together, and the docs state that the stage covers free-boundary eigenmodes only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…omposition

# Conflicts:
#	docs/development/references.md
#	docs/make.jl
#	regression-harness/cases/diiid_n1.toml
#	src/GeneralizedPerturbedEquilibrium.jl
…uracy and attribute the form offset to the equilibrium tolerance

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s instead of splining the saved ODE steps

Each decomposition knot now gets its Euler-Lagrange state by integrating from the nearest saved step that no singular surface separates from it (ForceFreeStates.solution_state_at), so ξ and ξ' no longer depend on how densely the forward sweep saved. On the Solovev example the standard-form mismatch is -4.0e-4 at every eulerlagrange_tolerance from 1e-7 to 1e-11, where the splined value moved from -1.45e-3 to -4.0e-4. Pre-existing harness rows are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New capability

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants