diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8a1c84b06..2b9bd5ebf 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -69,5 +69,5 @@ repos: - id: toml-no-deprecated-keys name: 'TOML conventions: no deprecated config keys' language: pygrep - entry: '^(mer_flag|force_wv_symmetry|ode_flag|cyl_flag|mat_flag|reform_eq_with_psilim|use_riccati|use_parallel|parallel_threads|populate_dense_xi|power_bp|power_b|power_r|power_rc)\s*=' + entry: '^(mer_flag|force_wv_symmetry|ode_flag|cyl_flag|mat_flag|reform_eq_with_psilim|use_riccati|use_parallel|parallel_threads|populate_dense_xi|gal_flag|power_bp|power_b|power_r|power_rc)\s*=' files: ^(examples/.*\.toml|test/test_data/.*\.toml)$ diff --git a/Project.toml b/Project.toml index bb4d32cc5..fe2f324d5 100644 --- a/Project.toml +++ b/Project.toml @@ -6,6 +6,7 @@ version = "0.1.0" [deps] AdaptiveArrayPools = "4f381ef7-9af0-4cbe-99d4-cf36d7b0f233" +CommonSolve = "38540f10-b2f7-11e9-35d8-d573e4eb0ff2" Contour = "d38c429a-6771-53c6-b99e-75d170b6e991" Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" DelaunayTriangulation = "927a84f5-c5f4-47a5-9785-b46e178433df" @@ -38,6 +39,7 @@ Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [compat] AdaptiveArrayPools = "0.3.5" +CommonSolve = "0.2" Contour = "0.6.3" DelaunayTriangulation = "1.6.6" DelimitedFiles = "1.9.1" diff --git a/REFACTOR_PLAN.md b/REFACTOR_PLAN.md index de545c427..cb6fd921f 100644 --- a/REFACTOR_PLAN.md +++ b/REFACTOR_PLAN.md @@ -2,8 +2,11 @@ ## Complete multi-PR implementation plan > **NOTE FOR ALL DEVELOPERS (read this first).** -> This document is the agreed, in-progress plan for a five-PR refactor of the -> ForceFreeStates ↔ PerturbedEquilibrium interface and the top-level driver. It is +> This document is the agreed, in-progress plan for a refactor of the +> ForceFreeStates ↔ PerturbedEquilibrium interface and the top-level driver, delivered +> as THREE pull requests: #381 (integrator unification), #387 (LocalStability), and one +> combined "interface PR" whose three commits carry what were originally planned as +> PRs 3-5 (the stack was collapsed once it became clear reviews would batch at the end). It is > committed directly to `develop` (deliberately, as documentation only — no code > changes ride with it) so everyone with open PRs can see what is coming and where it > will touch their work. Key coordination points: @@ -52,7 +55,7 @@ pe = perturbed_equilibrium(ffs, rmp) |---|---| | D1 | Three integrators = three formalisms: **Forward** (serial EL; rename all misuses of "shooting"), **Riccati** (the STRIDE FM-chunk driver currently behind `use_parallel`), **Galerkin** (RDCON; becomes fully standalone). | | D2 | Riccati uses whatever threads `julia -t` provides. Its ONLY tunable is **number of chunks** (`nchunks`). `parallel_threads` is deleted. Outputs must be independent of thread count ⇒ the auto chunk count derives from problem structure only, never `Threads.nthreads()`. | -| D3 | **No merging of two integration results.** `populate_dense_xi` + `_populate_dense_xi_via_serial_el!` + the standalone serial-Riccati path (`riccati_eulerlagrange_integration`) are deleted FIRST (PR 1). Riccati-fed PE warn-and-skips profile-based outputs until the separate Frobenius-reconstruction work (not in this plan) restores them from `delta_coil` + surface asymptotics. | +| D3 | **No merging of two integration results.** `populate_dense_xi` + `_populate_dense_xi_via_serial_el!` + the standalone serial-Riccati path (`riccati_eulerlagrange_integration`) are deleted FIRST (PR 1). Riccati-fed PE warn-and-skips profile-based outputs PERMANENTLY (D14: riccati never produces full profiles); the separate `delta_mn` work (not in this plan) restores the resonant-coupling outputs — not the profile-based ones — from `delta_coil` + surface asymptotics. | | D4 | Kinetic (`kinetic_factor > 0`) is Forward-only. `solve`/driver raises a clear error for Riccati+kinetic and Galerkin+kinetic. | | D5 | One result struct **`ForceFreeStatesResult`**; optional fields are `Union{Nothing,T}`; consumers use a `require(...)` helper → `@warn` + skip. | | D6 | Local stability (Ballooning.jl) → new top-level module **`LocalStability`**, depending only on Equilibrium (+ math deps). Only ctrl dependency is `verbose` → kwarg. | @@ -62,6 +65,8 @@ pe = perturbed_equilibrium(ffs, rmp) | D10 | No back-compat burden; examples/fixtures updated freely; regression re-baselining accepted. Final validation = fresh Fortran comparison after the sequence. Nothing else merges mid-sequence without coordination (#363/#345 landed before PR 1 and are absorbed — see header amendment). | | D11 | New structs immutable from day one (eases the later #367 merge). HDF5 writers become functions on result structs, keeping the merged #363 schema paths unchanged; #364 (metadata) re-targets them later. | | D12 | Analysis module reads HDF5 files, not live structs — untouched except where dataset names would change (they don't in this plan). | +| D13 | Inner-layer matching runs INSIDE `solve` (a `ForceFreeStatesResult` is always a closed basis). `result.solution` holds THE solve's ξ solution product — a thin `SolutionProfiles` interchange type — whenever one exists: forward always; galerkin when matched (built directly from the match — the `gal_matched_odestate` OdeState shim is DELETED); riccati permanently `nothing` — STRIDE matching yields rational-surface data (`bpen`, `delta_mn`), never profiles (D14). Closure is explicit and universal: `result.closure ∈ (:ideal, :matched)` and `result.bpen` (msing × numpert_total; zeros under ideal closure) are always present — the landing pad for any matching implementation. No transitional arbitration API: additive gal is removed in the SAME PR that introduces the result (PR 3), so one run has at most one solution and nothing like `pe_solution` is ever needed. Matching config is integrator-agnostic: a `ResistiveMatch` object (swappable `InnerLayer` model + per-surface `eta/rho/rotation`, `gamma`, `ideal`) passed as a `match=` kwarg to `solve` (PR 5). STRIDE-side matching is a future PR; until then `match` with `Riccati()` errors "not yet implemented". The `gal_*` matching TOML keys are renamed/re-homed by that future PR, not by this stack. | +| D14 | Same physics ⇒ same field, same type, across integrators, organized by the three-class taxonomy in §9 (control surface / full profiles / rational-surface resonant data). Riccati NEVER produces full ξ/ξ′ profiles — `result.solution` is permanently `nothing` for it. The next-cycle work adds `delta_mn` to riccati AND galerkin: a bpen-like matrix encoding the jump in the pitch-resonant derivative of the solution at each rational surface, from outer-solution asymptotics (for riccati: recoverable from `delta_coil`); it yields the perturbed current and shielded resonant flux, and is what PE resonant coupling consumes from a Riccati run (class 2, not class 1). Forward `delta_mn` is NOT planned — no concrete route has been identified and there may be none. There is ONE Δ′/matching data type, unified IN THIS PR: `delta_prime` carries Δ′ matrix, raw D′, `delta_coil`, and the PEST-3 blocks, produced by riccati and galerkin alike — galerkin already computes the same physics content, today under `galerkin.*` fields and different HDF5 names; its Δ′ payload merges into `delta_prime` (fields a formalism doesn't produce stay empty/`nothing`). Control-surface energies (`wp`, `free_boundary`) target all three integrators (galerkin pending its δW implementation). SLAYER consumes the unified `delta_prime`, so riccati- and galerkin-fed SLAYER both work (this PR). SLAYER + GGJ behind one abstract inner-layer interface is a later pass. | ### Verified code facts the workers must not re-derive @@ -152,11 +157,14 @@ All code must be JuliaFormatter-clean per `.JuliaFormatter.toml` before commit. | PR | Branch | Content | |----|--------|---------| -| 1 | `refactor/riccati-unification` | Delete serial-Riccati + `populate_dense_xi` + `parallel_threads`; `integrator=` ctrl key; `nchunks` knob; thread-independent chunking; shooting→forward rename | -| 2 | `refactor/local-stability-module` | Extract Ballooning.jl → `LocalStability` module; drop ctrl dependency | -| 3 | `refactor/forcefreestates-result` | `ForceFreeStatesResult` + warn-and-skip consumers (PE, FFS writer, SLAYER, write_imas) | -| 4 | `refactor/staged-main` | Decompose `main_from_inputs` into stage functions; standalone Galerkin; deprecate `gal_flag` | -| 5 | `feature/solve-api` | CommonSolve `solve`, integrator structs public, `PlasmaEquilibrium(path;…)`, `RMPField`, `perturbed_equilibrium` | +| #381 | `refactor/riccati-unification` | Delete serial-Riccati + `populate_dense_xi` + `parallel_threads`; `integrator=` ctrl key; `nchunks` knob; thread-independent chunking; shooting→forward rename | +| #387 | `refactor/local-stability-module` | Extract Ballooning.jl → `LocalStability` module; drop ctrl dependency (stacked on #381) | +| interface PR | `refactor/forcefreestates-result` | ONE PR, three slice-pure commits: **(a)** §5 `ForceFreeStatesResult` + warn-and-skip consumers + standalone Galerkin; **(b)** §6 staged `main`; **(c)** §7 `solve` API (stacked on #387) | + +Commit discipline for the interface PR: commit boundaries now do the job PR boundaries +did — keep each commit slice-pure (fixes amend into the right slice before review +starts; ordinary follow-up commits after). Per-slice numerical isolation stays +verifiable via the harness with commit SHAs as refs. --- @@ -233,7 +241,7 @@ All code must be JuliaFormatter-clean per `.JuliaFormatter.toml` before commit. `integrator="riccati"`) so the canonical Δ′-matrix fixture survives the DIIID-like_ideal switch to forward. Add a matching regression case `regression-harness/cases/diiid_n1_riccati.toml` tracking - `SingularSurfaces/delta_prime_matrix`-derived quantities (mirror the Δ′ entries of the + `SingularSurfaces/Delta_prime_matrix`-derived quantities (mirror the Δ′ entries of the existing `diiid_n1` case; ξ/PE quantities stay on `diiid_n1`). - `benchmarks/benchmark_threads.jl`, `benchmarks/benchmark_delta_prime_methods.jl`: update flag names (`use_riccati`/`parallel_threads` → `integrator`/`nchunks`); @@ -333,13 +341,13 @@ harness `--cases diiid_n1 --refs develop,local` (`LocalStability/*` datasets mus --- -## 5. PR 3 — `refactor/forcefreestates-result` +## 5. Interface PR, commit (a) — result struct, consumers, standalone Galerkin ### 5.1 New file `src/ForceFreeStates/Result.jl` (included from ForceFreeStates.jl) Reuse existing types wholesale (`SingType`, `OdeState`, `FreeBoundaryResult`, -`GalerkinResult`, `FourFitVars`, `MetricData`, `EdgeScanState`) — minimal-change -discipline; only two new types + helpers: +`GalerkinResult`, `FourFitVars`, `MetricData`, `EdgeScanState`); new types are +`DeltaPrimeData`, `SolutionProfiles`, and the result itself: ```julia "Δ′ outputs of the Riccati STRIDE BVP (moved off ForceFreeStatesInternal at result-build time)." @@ -349,6 +357,18 @@ struct DeltaPrimeData coil::Matrix{ComplexF64} # 2msing×numpert_total edge coil response (was intr.delta_coil) end +"The solve's ξ solution, in the exact shape PerturbedEquilibrium consumes. Field names + mirror the OdeState store subset so PE internals change minimally." +struct SolutionProfiles + basis::Symbol # :el_axis (forward) | :gal_native (matched galerkin) + step::Int # number of stored radial nodes + psi_store::Vector{Float64} + q_store::Vector{Float64} + u_store::Array{ComplexF64,4} # (N, N, 2, step) — Ξ_ψ and conjugate momentum + du_store::Array{ComplexF64,3} # (N, N, step) dΞ_ψ/dψ, ALWAYS populated + xi_s_store::Array{ComplexF64,3} # (N, N, step) Ξ_s, ALWAYS populated +end + struct ForceFreeStatesResult integrator::Symbol # :forward | :riccati | :galerkin control::ForceFreeStatesControl # provenance snapshot (carries mthvac, verbose, …) @@ -364,52 +384,65 @@ struct ForceFreeStatesResult ffit::FourFitVars surfaces::Vector{SingType} # alias of intr.sing (ua/restype/α live here) kinetic::@NamedTuple{kmsing::Int, kinsing::Vector{SingType}, scan_psi::Vector{Float64}, scan_cond::Vector{Float64}, scan_threshold::Float64} + # closure of the basis at the rationals (D13) — ALWAYS present + closure::Symbol # :ideal (jump condition imposed) | :matched (inner layer) + bpen::Matrix{ComplexF64} # (msing × numpert_total) penetrated resonant field; zeros under :ideal # per-integrator products (presence == capability) - solution::Union{Nothing,OdeState} # dense stores; see solution_basis - solution_basis::Symbol # :el_axis | :riccati | :gal_native | :none + solution::Union{Nothing,SolutionProfiles} # THE solve's ξ solution; nothing when none exists (riccati; unmatched gal) + diagnostics::Union{Nothing,OdeState} # the integrator's raw odet (crit, edge scan, ψ trace, ca); writer-only + wp::Union{Nothing,Matrix{ComplexF64}} # fixed-boundary plasma energy W_p at psilim; present for any EL sweep even with vac_flag=false (aliases free_boundary.wp when free_run ran) free_boundary::Union{Nothing,FreeBoundaryResult} delta_prime::Union{Nothing,DeltaPrimeData} galerkin::Union{Nothing,GalerkinResult} end ``` -Contract of `solution`/`solution_basis`: -- Forward → dense EL-basis odet, `:el_axis`. PE-usable. -- Riccati → its odet IS carried (psi/q/crit/edge_scan are valid, u_store is chunk - snapshots), `:riccati`. NOT PE-usable; HDF5 `ForceFreeStates/Solutions/ForwardIntegration/psi|crit` and `EdgeScan/*` still - written from it, `.../ForwardIntegration/xi_*` written empty. -- Galerkin with `gal_match_flag` → `gal_matched_odestate(...)`, `:gal_native` - (PE-usable; `du_store_populated=true` analytic derivatives). Without match → - `nothing`, `:none`. +Contract (D13 — final, no transitional states): +- Forward → `solution` = `SolutionProfiles(:el_axis, …)` aliasing the odet's stores (zero + copy), `diagnostics` = the same odet, `closure = :ideal`, `bpen` = zeros. +- Riccati → `solution = nothing` PERMANENTLY (chunk-endpoint states are not a ξ solution, + and no reconstruction is planned; the future STRIDE matching populates + `closure = :matched`, `bpen`, and `delta_mn` — rational-surface data, never profiles), + `diagnostics` = its odet (ψ/q/crit/edge scan/ca are valid), `closure = :ideal`. +- Galerkin, matched → `solution` = `SolutionProfiles(:gal_native, …)` built DIRECTLY from + `GalerkinResult.match`/`solution` (drop `issing` points, analytic Ξ′, `compute_node_xi_s!` + for Ξ_s — the useful guts of the deleted `gal_matched_odestate`, minus the OdeState + costume), `diagnostics = nothing`, `closure = :matched` (`:ideal` under `gal_ideal_flag`), + `bpen = galerkin.match.bpen`. +- Galerkin, unmatched → `solution = nothing` (raw homogeneous gal columns are not a driven + response basis), `closure = :ideal`. + +There is NO `pe_solution` and NO stored-basis arbitration: additive gal is removed in this +PR (§5.3), so a run has at most one solution and PE reads `result.solution` directly. Helpers (same file): ```julia -"Warn-and-skip gate: true iff `field` is populated." +"Warn-and-skip gate: true iff the optional `field` is populated." function require(result::ForceFreeStatesResult, field::Symbol, calc::AbstractString) getfield(result, field) === nothing || return true @warn "Skipping $calc: `$field` was not produced by the $(result.integrator) integrator" return false end -"Warn-and-skip gate for ξ-profile consumers: solution present AND in a usable basis." -function require_solution(result, calc; bases=(:el_axis, :gal_native)) - result.solution !== nothing && result.solution_basis in bases && return true - @warn "Skipping $calc: dense ξ profiles require a Forward (or gal-matched Galerkin) run; " * - "this result came from the $(result.integrator) integrator (basis=$(result.solution_basis))" - return false -end +"Specialized message for the ξ-solution gate." +require_solution(result, calc) = result.solution !== nothing ? true : + (@warn "Skipping $calc: no ξ solution — dense profiles require a Forward (or matched Galerkin) run; " * + "this result came from the $(result.integrator) integrator"; false) -"Assemble the result after integration. Pure data movement — no computation." +"Assemble the published result once the solve is finished." build_result(integrator, ctrl, equil, intr, metric, ffit, odet, free_energies, gal_data) -> ForceFreeStatesResult ``` -`build_result` sets `delta_prime = isempty(intr.delta_prime_matrix) ? nothing : -DeltaPrimeData(intr.delta_prime_matrix, intr.delta_prime_raw, intr.delta_coil)`, -`free_boundary = free_energies`, `galerkin = gal_data`, and the gal-vs-odet solution -selection currently done inline at `GeneralizedPerturbedEquilibrium.jl:584-592` -(`gal_matched_odestate` + `pe_intr.odet_from_gal`/`inner_bpen` handling moves behind -the result: `inner_bpen` is fetched from `result.galerkin.match.bpen` by the driver). +`build_result` responsibilities (the ONLY place with assembly logic): +- `delta_prime` from the intr Δ′ fields when non-empty; `free_boundary = free_energies`; + `galerkin = gal_data`; `diagnostics = odet`. +- Forward: call `materialize_derivative_stores!(odet, …)` HERE (moving the call out of the + writer and PE — one site, always-populated `du_store`/`xi_s_store`), then wrap the stores + in `SolutionProfiles(:el_axis, …)`. +- Matched gal: build `SolutionProfiles(:gal_native, …)` from the match (see contract above). +- `closure = (gal_data !== nothing && gal_data.match !== nothing && !ctrl.gal_ideal_flag) ? :matched : :ideal`; + `bpen` = match bpen or zeros(msing, numpert_total). `ForceFreeStatesInternal` stays as internal scratch during the solve; it no longer crosses module boundaries after `build_result`. @@ -420,20 +453,29 @@ crosses module boundaries after `build_result`. (drop `equil/odet/wt0/mthvac/ffs_intr/metric/ffit` — all read off `result`). Internals: - `initialize_mode_arrays!` reads mode fields from `result`. + - PE's working solution IS `result.solution::SolutionProfiles` (never an OdeState; PE + internals re-type from `OdeState` to `SolutionProfiles` — field names match, so the + change is annotations, not logic). No materialize call in PE: `du_store`/`xi_s_store` + arrive populated. - Response step: `require(result, :free_boundary, "plasma response") && - require_solution(result, "plasma response")` else skip (replaces the wt0-warn at - :128-130 and the discarded materialize Bool at :88 — `materialize_derivative_stores!` - is still called, on `result.solution`, only when the gates pass). + require_solution(result, "plasma response")` else skip. - Coupling step: same two gates + existing internal `plasma_response` gate. - All `ffs_intr.X` reads → `result.X`; `wt0` → `result.free_boundary.wt0`; - `mthvac` → `result.control.mthvac`; odet → `result.solution`. - - `pe_intr.odet_from_gal` ↔ `result.solution_basis == :gal_native`. + `mthvac` → `result.control.mthvac`. + - `pe_intr.odet_from_gal` ↔ `result.solution.basis == :gal_native`; + `pe_intr.inner_bpen = result.bpen` (driver; the gal special-case `if` is deleted). - **FFS HDF5 writer**: re-signature to `write_outputs_to_HDF5(result; git_version, inputs, forcing_modes, locstab, ballooning_boundary)` — body is today's `:700-991` with `ctrl/equil/intr/odet/free_energies/ffit/gal_data` spelled `result.*`; every group that came from an optional field gets the existing empty-array fallback (already the pattern for FreeBoundaryStability). Δ′ datasets - read from `result.delta_prime`. **Dataset names/paths unchanged** (D11). + read from `result.delta_prime`. Solution-adjacent datasets split by source: + `ForwardIntegration/xi_psi|u2|dxi_psi|xi_s` from `result.solution` when + `basis == :el_axis` (empty otherwise — the gal-native solution is already persisted + under the Galerkin group); `psi|q|nstep|nstep_total|crit`, `SingularSurfaces/ca_*`, + and `EdgeScan/*` from `result.diagnostics` when present (empty otherwise). Output is + byte-identical for every forward/riccati deck; the four gal decks become gal-only + files (§5.3). **Dataset names/paths unchanged** (D11). - **SLAYER**: `Runner.run_slayer(result, control; dir_path)` — reads `result.surfaces`, `result.delta_prime === nothing ? empty : result.delta_prime.matrix`, `result.equil`. Keep a thin internal method for the old `(equil, sing, dpm)` shape if @@ -445,28 +487,67 @@ crosses module boundaries after `build_result`. - Kinetic-forces stage keeps consuming `pe_state` + `result` fields analogously (`set_perturbation_data!(kf_intr, pe_state, result, …)` — mode/metric reads only). -### 5.3 Tests +### 5.3 Standalone Galerkin + additive-gal removal (pulled forward from PR 4) + +Additive gal is what would force a two-solutions-per-run transitional state; it dies in +this PR so the result contract above is final from day one. + +- Factor the wv computation out of `free_run` into a shared helper in + `src/ForceFreeStates/Free.jl`: + `compute_scaled_wv(ctrl, equil, intr) -> (wv, vac)` — the `VacuumInput` + + `compute_vacuum_response` + Chance singfac scaling block (no OdeState involved). + `free_run` calls it; identical numerics by construction. +- `integrator = "galerkin"` becomes legal: the driver's gal branch skips EL integration + and `free_run` entirely; runs `sing_min!` + (when `vac_flag`) `compute_scaled_wv` + + `galerkin_solve` (+ `gal_match_rpec` via the existing flags); `build_result` fills the + gal fields per the §5.1 contract. Errors if `kinetic_factor > 0`. `npert == 1` enforced + by `galerkin_solve` already. +- DELETE: `gal_matched_odestate` (GalerkinMatch.jl) and the driver's additive-gal PE + block (`pe_odet` selection). The additive path (`gal_flag=true` alongside another + integrator) is REMOVED; `gal_flag` joins `_DEPRECATED_FFS_KEYS` + the pre-commit hook. +- RETAIN `_chord_solution_at` (SingularCoupling.jl) as an uncalled helper: re-typed to + `SolutionProfiles`, hard-error branch dropped, stub-style docstring. Kept pending the + `delta_mn` resonant-coupling design (chord-slope derivatives may be useful when PE + consumes rational-surface data instead of profiles) — do NOT re-delete as dead code. +- Gal → PE this cycle: PerturbedEquilibrium's response step requires the free-boundary + δW (`wt0`), which the Galerkin formalism does not produce — so PE warn-skips entirely + on gal results (both gates: `free_boundary` missing kills response, and coupling needs + the response). The gal-native `solution` consumer path in PE therefore stays dormant + until the gal-side δW work lands (next cycle, with the STRIDE matching); the contract + and tests are already in place for it. +- Retoml the four gal decks to `integrator = "galerkin"` (drop `gal_flag`): + `DIIID-like_gal_resistive_example`, `DIIID-like_gal_resistive_pe_example`, + `LAR_ideal_match_test`, `LAR_resistive_match_test`. Their HDF5 outputs become gal-only + (FFS-side integration/energy datasets empty) — accepted per D10; gal datasets identical + because `galerkin_solve` inputs are unchanged. `gal_*` sub-knobs stay (they become + `Galerkin(...)` / `ResistiveMatch` fields in PR 5). + +### 5.4 Tests - New `test/runtests_result_struct.jl` (add to `test/runtests.jl` include list): - build a Solovev case; assert Forward result has `solution_basis == :el_axis`, + build a Solovev case; assert Forward result has `solution.basis == :el_axis`, + populated `du_store`/`xi_s_store`, `closure == :ideal`, `iszero(bpen)`, `delta_prime === nothing`; Riccati result has `delta_prime !== nothing`, - `solution_basis == :riccati`; `require_solution` warns exactly once - (`@test_logs (:warn,)`) and PE skips without throwing on a Riccati result with a - `[PerturbedEquilibrium]` deck. + `solution === nothing`, `diagnostics !== nothing`; `require_solution` warns exactly + once (`@test_logs (:warn,)`) and PE skips without throwing on a Riccati result with a + `[PerturbedEquilibrium]` deck; a matched gal deck (LAR_ideal_match_test-class) yields + `solution.basis == :gal_native` and `bpen == galerkin.match.bpen` (zeros under + `gal_ideal_flag`, with `closure == :ideal` there). - Update every test that consumed `main`'s old named-tuple return (`runtests_fullruns.jl`, `runtests_imas.jl`, `runtests_rerun_from_h5.jl`, `runtests_parallel_integration.jl` capture helpers). -### 5.4 Verification +### 5.5 Verification -Full suite; `runtests_fullruns.jl` (all decks — forward decks produce identical -HDF5 vs pre-PR, riccati decks now emit empty `ForwardIntegration/xi_*` + PE-skip warnings); -harness `--cases diiid_n1,solovev_n1 --refs develop,local` (forward-deck tracked -quantities unchanged); docs build. +Full suite; `runtests_fullruns.jl` (forward decks produce byte-identical HDF5 vs the +stack base, riccati decks emit empty `ForwardIntegration/xi_*` + PE-skip warnings, gal +decks become gal-only files); harness vs the stack base +(`--cases diiid_n1,diiid_n1_riccati,solovev_n1 --refs refactor/local-stability-module,local` +— tracked quantities unchanged; gal-flavored cases re-baselined); docs build. --- -## 6. PR 4 — `refactor/staged-main` +## 6. Interface PR, commit (b) — staged `main` (staging ONLY — gal work is in commit (a)) ### 6.1 Stage functions (all in `src/GeneralizedPerturbedEquilibrium.jl`; `main_from_inputs` becomes ~40 lines of orchestration) @@ -495,44 +576,44 @@ logic stays a pre-FFS stage but is owned by the FFS-facing function (`maybe_reform_equilibrium` calls `ForceFreeStates.rational_psi_nodes` + `Equilibrium.refined_psi_grid`/`setup_equilibrium` exactly as today). -### 6.2 Standalone Galerkin (`integrator = "galerkin"` becomes legal) +### 6.2 Tests / verification -- Factor the wv computation out of `free_run` into a shared helper in - `src/ForceFreeStates/Free.jl`: - ```julia - "Raw vacuum response at psilim with the Chance singfac scaling applied (Free.jl:82-88)." - compute_scaled_wv(ctrl, equil, intr) -> (wv, vac) # no OdeState involved - ``` - `free_run` calls it (identical numerics — pure extraction); the galerkin stage calls - it when `ctrl.vac_flag` to supply `wv` to `galerkin_solve`. -- `run_force_free_states` with `"galerkin"`: skip EL integration and `free_run` - entirely; `sing_min!` + `galerkin_solve` (+ `gal_match_rpec` via flags as today); - result: `free_boundary=nothing`, `delta_prime=nothing`, `galerkin=GalerkinResult`, - `solution` from `gal_matched_odestate` when matched (`:gal_native`) else - `nothing`/`:none`. Error if `kinetic_factor > 0`. `npert == 1` enforced by - `galerkin_solve` already. -- Deprecate `gal_flag` (add to `_DEPRECATED_FFS_KEYS` + hook): additive gal is - REMOVED — `gal_flag=true` decks become `integrator = "galerkin"`. Retoml: - `DIIID-like_gal_resistive_example`, `DIIID-like_gal_resistive_pe_example`, - `LAR_ideal_match_test`, `LAR_resistive_match_test` (their FFS-side datasets - disappear from the HDF5 — accepted per D10; gal datasets identical because - `galerkin_solve` inputs are unchanged). `gal_*` sub-knobs stay (they become - `Galerkin(...)` fields in PR 5). -- FFS writer: tolerate `solution === nothing` (write empty `ForwardIntegration/*` datasets — - extend the existing empty-fallback pattern). - -### 6.3 Tests / verification - -- Update `runtests_fullruns.jl` gal decks' expectations (gal-only HDF5). -- New testset (in `runtests_fullruns.jl` or the gal tests): `integrator="galerkin"` - on `LAR_ideal_match_test` produces the Galerkin `delta` dataset identical to the PR-3 additive - run (same `wv` by construction — assert against a stored reference or a paired - riccati+gal_flag run on the pre-PR commit during development). -- Full suite; harness (gal cases if present, plus diiid/solovev); docs. +Pure code motion: full suite unchanged; harness vs commit (a) must be identical for ALL +cases (no re-baselining in this slice); docs build. Standalone Galerkin and the +additive-gal removal live in commit (a) (§5.3). --- -## 7. PR 5 — `feature/solve-api` +## 6A. Interface PR, commit (b2) — unified Δ′/matching payload (D14) + +Galerkin computes the same Δ′ physics riccati does (Δ′ matrix, raw D′, `delta_coil`, +PEST-3 blocks), today under separate `galerkin.*` fields and different HDF5 names. This +commit merges the two payloads into the ONE `delta_prime` field so consumers never care +which formalism produced it. + +- **Inventory first (mandatory)**: enumerate every Δ′-flavored field in `GalerkinResult` + and every field in `DeltaPrimeData`, and produce the exact mapping (name, shape, + normalization, sign/side conventions) BEFORE moving anything. Do not assume the two + formalisms' arrays are layout-identical — verify shapes/conventions and document any + genuine mismatch in the type's docstring rather than silently coercing. +- **Type**: extend `DeltaPrimeData` to the union of both payloads (PEST-3 blocks join it). + Fields a formalism doesn't produce stay empty/`nothing`. `build_result` fills it from + whichever formalism ran; the Δ′ payload LEAVES the `galerkin` field, which keeps only + solver internals / FEM diagnostics / RPEC match data (post-inventory list goes in the + struct docstrings). +- **HDF5**: one set of dataset paths for Δ′ outputs regardless of formalism — the + riccati/shared paths are canonical; gal's Δ′ datasets move there (clean break per + `docs/development/hdf5-conventions.md`: update writer, readers, and harness case TOMLs + together; no legacy-path shim). Coordinate with the pending #364 reconciliation so the + paths are renamed once, not twice. +- **SLAYER**: `run_slayer` routes through the unified `delta_prime` — gal-fed SLAYER now + works. Update `runtests_slayer_runner.jl` accordingly. +- **Verification**: gal Δ′ values byte-identical to the pre-unification `galerkin.*` + datasets (only paths/fields move); riccati decks byte-identical throughout; result-struct + testsets extended for the unified field on both formalisms; gal harness cases re-baseline + (h5paths updated). + +## 7. Interface PR, commit (c) — `solve` API ### 7.1 Dependencies @@ -555,14 +636,25 @@ Base.@kwdef struct Galerkin <: AbstractIntegrator tol::Float64 = 1e-10; gnstep::Int = 20000; dx1dx2_flag::Bool = true sing_order::Int = 6; sing_order_ceiling::Bool = true rpec_flag::Bool = false; edge_onesided::Bool = false - match_flag::Bool = false; ideal_flag::Bool = false; inner_solver::String = "ray" - inner_xfac::Float64 = 10.0; inner_nx::Int = 1280; inner_nq::Int = 5 - inner_cutoff::Int = 5; inner_kmax::Int = 8 - eta::Vector{Float64} = Float64[]; rho::Vector{Float64} = Float64[] - rotation::Vector{Float64} = Float64[]; gamma::Float64 = 5/3 +end + +# D13: inner-layer matching config, integrator-agnostic (NOT part of any integrator struct) +Base.@kwdef struct ResistiveMatch + model = InnerLayer.GGJModel(solver=:ray) # swappable inner layer; backend knobs + # (xfac/nx/nq/cutoff/kmax ← gal_inner_*) live on the model + eta::Vector{Float64} = Float64[] # per-surface, core→edge (← gal_eta) + rho::Vector{Float64} = Float64[] # (← gal_rho) + rotation::Vector{Float64} = Float64[] # Hz; γ_s = 2πi·n·f_s (← gal_rotation) + gamma::Float64 = 5 / 3 # (← gal_gamma) + ideal::Bool = false # (← gal_ideal_flag) end ``` +`match !== nothing` replaces `gal_match_flag`. Inside `solve`, matching dispatches per +integrator: Galerkin → `gal_match_rpec`; Riccati → errors "not yet implemented" until +the STRIDE resonant-matching PR lands (that PR also renames/deprecates the `gal_*` +matching TOML keys — until then the TOML keys map onto `ResistiveMatch` internally). + Mapping helpers `_integrator_symbol(alg)` and `_apply_alg!(ctrl_kwargs, alg)` translate an alg struct into the `ForceFreeStatesControl` keyword set (pure translation — `ForceFreeStatesControl` remains the single source of truth for the @@ -575,6 +667,7 @@ solve; the TOML `integrator=` + flat `gal_*`/`nchunks` keys keep working unchang ```julia function solve(equil::Equilibrium.PlasmaEquilibrium, alg::AbstractIntegrator; nn::Union{Int,UnitRange{Int}}, wall::Vacuum.WallShapeSettings=Vacuum.WallShapeSettings(), + match::Union{Nothing,ResistiveMatch}=nothing, dir_path::String=".", kwargs...) # kwargs = any ForceFreeStatesControl field -> ForceFreeStatesResult ``` @@ -635,6 +728,176 @@ manual smoke: run the 4-line UX from the Context section in a REPL against --- +## 7A. Interface PR, commit (d) — ξ unification + tearing surface identity (IMPLEMENTED 2026-08-15) + +Final scope (converged with the user; supersedes the earlier "minimal transpose" reading): + +- **ξ unification (the real one)**: closed axis-to-edge ξ profiles are written from + `result.solution` into the producing formalism's Solutions group with IDENTICAL names and + (mode, solution, psi) axis order: `Solutions/ForwardIntegration/*` (unchanged) and new + `Solutions/GalerkinIntegration/{psi, q, xi_psi, dxi_psidpsi, xi_s}` (the gal grid, issing + nodes dropped — the same arrays as `result.solution`, which IS `Match/xi` repacked). The + gal closure (ideal jump or inner-layer Δ) always yields these profiles; a no-closure gal + run is Δ′-only and writes none. `Match/xi`/`Match/dxidpsi` datasets are REMOVED (they were + the profiles, mislabeled as matching diagnostics); `Match/` keeps cout/cin/Delta_r/bpen/ + rpec_eig/Inner/ only. +- **Raw outer basis demoted to debug output** (user call: solver internals, like dumping an + ODE work array): the old `GalerkinIntegration/Solution/` group is now `Basis/`, written + ONLY under the new `DebugSettings.gal_basis_output` flag ([DEBUG] deck section / `debug=` + API kwarg), transposed to the shared axis order. `ForceFreeStatesResult` now carries + `debug_settings` so the writer sees the flag. verify_gal_{solution,ideal}.jl need the flag. +- **Tearing surface identity (#388 item 2)**: `SLAYERResult` gained `rational_psi`/ + `rational_q` (aligned with `params`; empty when built from bare parameters); + `run_slayer_from_inputs` takes them as kwargs; the loose `run_slayer` fills them from + `surfaces[p.ising]`; writer emits `Tearing/PerSurface/rational_psi|rational_q` when + present + annotations. Gal-fed SLAYER output now identifies its surface subset. +- Benchmarks repointed (verify_gal_match/ideal/solution, compare_gal_vs_el, + scan_{rotation,resistivity}_m2, compare_jbgradpsi_m2 — the filtered psi grid is now + first-class so several scripts simplified); annotation tables updated (axis-order + warning dropped); hdf5-conventions.md updated; result-struct testsets assert + file == result.solution + Basis gating; slayer round-trip asserts surface identity. + +NOT done (stays on #388): item 3 (PE empty-placeholder pattern — align with #368), items +4–5 (schema-owner calls), items 6–8 (comment-audit pass). Full shared-Solutions schema for +closed profiles across formalisms (one group, grid-semantics contract) is future work with +the two-stage PE. + +## 7B. Settled design (2026-08-15): source algebra, two-stage PE, deck-as-serialization + +Discussion CLOSED with the user; decisions D15/D16 below are binding. Commit (c) is +implemented but UNCOMMITTED, so its concrete `RMPField` is REPLACED in place (no shim). + +### D15 — `RMPField` is abstract, with lazy linear algebra (lands in the (c) revision) + +- `RMPField` = the user-facing ABSTRACT supertype of every forcing source. File modes, + coil set + currents, or (future, #377) fields given on ψ=1 / an arbitrary surface via + equivalent surface currents — "they are all just external fields." Constructors on the + abstract type return concrete internal subtypes (today: one leaf wrapping + `ForcingTermsControl`; a surface-field leaf arrives with #377). +- Lazy `+`, `-`, scalar `*`: return a formal linear combination WITHOUT materializing. + Valid because PE is linear in the forcing — materialization commutes with summation. + Both current leaf kinds materialize to the same normalized `Vector{ForcingMode}` basis, + so summation = match (m,n), add amplitudes. Prefer ComplexF64 scale (coil phase + rotation is physical); scale must apply to the MATERIALIZED modes, format-independent. + +### D16 — the deck is the API, serialized (one path) + +Every TOML section corresponds 1:1 to an API object/call; the keys ARE the kwargs +(the `@kwdef` splat is the mapping). Consequences, in delivery order: + +1. **#393 (this PR)**: (c) revision per D15 + commit (d). Nothing else grows scope. + ctrl→TOML serialization explicitly deferred to step 2. +2. **Next PR: "main = 20 lines" (REORDERED ahead of the PE split, user call 2026-08-15: + close FFS completely before touching PE)** — kinetic profiles become an OPTIONAL + ATTRIBUTE OF PlasmaEquilibrium (`kinetic::Union{Nothing,KineticProfiles}`, loaded + data not file path; species/factor knobs are loader kwargs; rationale: the two-pass + grid refinement needs the profiles at equilibrium FORMATION, before any solve exists; + `solve` with kinetic_factor>0 then gates on `eq.kinetic`). COORDINATE with #367 + (struct freeze) — the field addition lands after Jake's PR. SLAYER gets an API entry + point; kinetic + SLAYER get API homes; + `main()` becomes a deck INTERPRETER (parse file → same constructors and calls a + script would make); `main_from_inputs` and the stage functions dissolve. The writer + serializes the RESOLVED ctrl structs (defaults included) into every output — same + blob for TOML and API runs — so every gpec.h5 is replayable and h5→toml regeneration + is just extracting it. Scripting users get the SAME per-section loaders main uses + (e.g. `PlasmaEquilibrium("case_dir/")` reads the `[Equilibrium]` section); no second + config system, ever. Deck completeness is automatic: the deck schema IS the struct + schema, and TOML array-of-tables (`[[ForcingTerms.source]]` with per-block scale) + serializes even the source algebra. +3. **Then: two-stage PE (stacked, AFTER FFS is closed)** — `GeneralPE = + perturbed_equilibrium(ffs)` builds the source-independent response/coupling + operators; `force(GeneralPE, fields)` (or callable `GeneralPE(fields)`) materializes + sources, applies P, computes derived quantities. Pairs with the delta_mn + resonant-coupling work (same territory, same cycle). Payoff: coil scans and + optimization reuse one GeneralPE across many cheap force() calls; a TOML deck maps + onto "GeneralPE + one force()" with no deck-format change. + +Defaults contract (established, keep): both paths splat over the same `@kwdef` struct +defaults — one defaults table. API is deliberately more explicit in two spots (no +default alg; `nn` required, `nn_low/nn_high` kwargs rejected). Deprecated deck keys +warn-and-ignore; unknown API kwargs hard-error (decks are archival, scripts fail fast). + +### Reviewer constraints from Nik (Slack, 2026-08-15 — binding on the follow-on PRs) + +- **No source-type zoo.** The common currency is the control-surface spectrum per source; + keep the concrete RMPField kinds minimal. Endpoint: at most ONE more leaf kind, ever — a + spectrum-literal ("here are control-surface modes, computed elsewhere") — and the #377 + equivalent-surface-currents solve becomes a UTILITY converting fields-on-a-surface into + that spectrum, NOT a type. External couplings (thincurr/surfmn/ferritic tools) cost GPEC + zero adapters: they produce spectra, directly or via the utility. +- **`scale` is a linear-combination weight, never a physical amplitude** (amplitudes are + ambiguous for magnetic materials, coil sets with dropouts, etc.). A degraded coil set is + `nominal - failed_coil`, not `0.9 * nominal`; material fields are computed at the + operating point by the code owning their physics, weight meaningful only for small linear + excursions. Docstrings reworded accordingly (2026-08-15, in the (c) revision). +- Nik explicitly likes the multi-shift/tilt-in-one-run capability (his bookkeeping win) — + keep it central in the two-stage-PE PR spec. + +### Plasma-response methods (Fortran resp_index — binding requirement, 2026-08-15) + +Fortran GPEC computes the plasma inductance / permeability P by FIVE selectable methods +(`plas_indmats(0:4)`, `resp_index`): j=0 = ENERGY method (wt0-based when +resp_induct_flag, else eigenmode energies et) — the Fortran default and the ONLY method +ported to Julia (`compute_plasma_response!`, Response.jl); j=1..4 = SURFACE-CURRENT +methods built from the four `kapmats`/`chpmats` variants (surface current κ and scalar +potential χ per identity-at-edge drive, gpresp_eigen → gpeq_surface at psilim) — these +need only the solutions' EDGE VALUES + vacuum Green's functions, NOT δW. Under gal_flag +Fortran computes only j=1 and forces resp_index=1: gal PE worked via surface currents +from the gal eigenfunctions. + +Consequences (correcting the earlier "PE requires δW" premise): +- The Julia gal→PE skip is a PORTING GAP artifact, not physics: the one ported method is + the one method gal cannot feed. Gal's matched solution already provides the + identity-at-edge columns the surface-current methods consume. +- REQUIREMENT for the two-stage-PE PR: preserve method multiplicity — a ResponseMethod + selection (energy | surface-current variants, the resp_index analog, as a typed + argument not a magic integer), with the surface-current port unlocking gal-fed PE + independently of the gal-δW work. The gal δW work remains scheduled for free-boundary + stability of gal runs and method-0 parity. +- gal_resistive_pe harness expectations change when either route lands. + +### North-star usage sketch (user's, verbatim intent; syntax deliberately sloppy — +### requirements catalog for the two-stage-PE PR, NOT #393 scope) + +```julia +Source_A = RMPField(coil1) +Source_B = RMPField(ferritic_material_fields_at_psi1) # needs #377 +Total_fields = Source_A + Source_B # fast: just records both sources + +GeneralPE = perturbed_equilibrium(ffs_result) +SpecificPE = force(GeneralPE, Total_fields) # Biot-Savart for A, Laplace/current-potential + # solve for B, sum on the control surface, + # apply P, derived quantities per output flags + +# Error-field sensitivity workflow: per-unit sources built by coil manipulation + algebra +PF1U_nominal = RMPField(pf1u_dat, 1) # 1 A +PF1U_shifted = shift_coil(PF1U_nominal, 1e-3) - PF1U_nominal # field per mm of shift + +# Named source SETS: force() runs per key, results in per-key (xarray-like) datasets +rmp_set = ("PF1U_shift"=PF1U_shifted, "PF1U_tilt"=PF1U_tilted, + "ferritic_welds"=surfmn_fields, "REMC"=thincurr_fields) +iter_pe = force(GeneralPE, rmp_set) + +# Keyed, labeled linear algebra on operators and results ("@" = xarray-like matmul): +overlaps_per_amp_per_mm = GeneralPE.C_xe @ iter_pe.Phi_sources_root_area_normalized + +# Collapse per-unit sources to a physical case: keyed scalar sets with wildcards, +# elementwise multiply, then sum to a single total field +tilts_shifts = ("PF1U_shift"=1.1e-3, "PF1U_tilt"=0.9e-3, "ferritic_welds"=1) +currents = ("PF1U_*"=14e3,) +total = sum(tilts_shifts * currents * rmp_set) +real_pe = force(GeneralPE, total; profile_output=true) +jbgradpsi = real_pe.Jbgradpsi +``` + +Requirements this implies for the two-stage-PE PR (catalogue, to be specced there): +named source sets with per-key PE results; coil-geometry manipulation (`shift_coil`, +tilts) composing with source algebra to build per-unit error-field bases; keyed scalar +sets with wildcard matching, elementwise `*` against source sets, `sum` collapsing to +one field; labeled (xarray-style) operator/result access so couplings contract naturally +per key; a `profile_output`-style flag family for derived profile quantities. + + ## 8. Cross-cutting execution rules (for every PR) 1. **Never merge without third-party human review. State this in every PR body.** @@ -652,20 +915,146 @@ manual smoke: run the 4-line UX from the Context section in a REPL against 9. Keep this `REFACTOR_PLAN.md` updated (check off completed PRs); delete it in a final cleanup commit after PR 5 is merged and the Fortran re-comparison is done. -## 9. Sanity map: which capability comes from where (post-refactor) +## 9. Sanity map: capability targets by integrator + +This is the TARGET matrix (D14): outputs representing the same physics are unified across +integrators — one field, one data type, regardless of which formalism produced it. Outputs +fall into three physics classes: + +- **Control surface**: quantities on the plasma boundary (`wp`, `free_boundary` energies). + Every integrator can supply these (gal pending its δW implementation). +- **In-plasma class 1 — full profiles**: ξ/ξ′ (or equivalent) across the volume + (`solution`), used to construct spectral, full-volume perturbed equilibria. + Forward and matched-Galerkin only; Riccati will NEVER produce these. +- **In-plasma class 2 — rational-surface resonant data**: quantities AT the rational + surfaces that quantify island-opening drive: `bpen`, and (future) `delta_mn` — the + matrix encoding the jump in the pitch-resonant derivative of the solution at each + rational surface, from outer-solution asymptotics (for Riccati: recoverable from + `delta_coil`). `delta_mn` yields the perturbed current and the shielded resonant flux, + and is what PE's resonant coupling will consume — no full profiles required. + +Legend: ✅ implemented · 🔜 target pending the named follow-on work · ❌ never · — N/A. | Output | Forward | Riccati | Galerkin | |---|---|---|---| -| dense ξ/Ξ′/Ξ_s profiles (`solution`, PE-usable) | ✅ `:el_axis` | ❌ (until Frobenius work) | matched only (`:gal_native`) | -| STRIDE Δ′ matrix / raw / `delta_coil` (`delta_prime`) | ❌ | ✅ | ❌ (has own `galerkin.delta`/`delta_coil`) | -| free-boundary energies (`free_boundary`) | ✅ | ✅ | ❌ (`nothing`) | -| fixed-boundary crit / nzero / edge scan (on odet) | ✅ | ✅ | ❌ | -| RDCON Δ′ + PEST3 blocks + RPEC match (`galerkin`) | ❌ | ❌ | ✅ | +| `wp` (control surface) | ✅ | ✅ | 🔜 gal δW work | +| `free_boundary` energies (control surface) | ✅ | ✅ | 🔜 gal δW work | +| `solution` — full ξ/ξ′ profiles (class 1) | ✅ `:el_axis` | ❌ (class 2 covers resonant coupling) | ✅ `:gal_native` | +| `closure` / `bpen` (class 2; always present, zeros under `:ideal`) | ✅ `:ideal` | ✅ `:ideal` (🔜 `:matched` with STRIDE matching) | ✅ `:ideal` or `:matched` | +| `delta_mn` (class 2; resonant-derivative jump) | ❌ not planned (no concrete route identified; may not exist) | 🔜 next-week work, from `delta_coil` | 🔜 next-week work | +| `delta_prime` — ONE unified type: Δ′ matrix, raw D′, `delta_coil`, PEST-3 blocks | — | ✅ | ✅ (PEST-3 blocks persisted; riccati recovers them via `pest3_decompose`) | +| raw integrator odet (`diagnostics`: crit, nzero, edge scan, ca) | ✅ | ✅ | — (no radial ODE sweep) | | kinetic (`kinetic_factor>0`) | ✅ | error | error | -| SLAYER inputs (surfaces + Δ′ matrix) | surfaces only (diag fallback) | ✅ | surfaces only | +| SLAYER inputs (surfaces + Δ′ matrix) | surfaces only (diag fallback) | ✅ | ✅ via unified `delta_prime` | + +SLAYER is an inner-layer consumer: SLAYER + GGJ should eventually sit behind one abstract +inner-layer interface (same family as the `ResistiveMatch` models, D13). Later pass, not this one. ## 10. Progress +### Live status (updated 2026-08-15 — read this first when resuming) + +- **#381 and #387 MERGED into develop** (a0c270f8, 2026-08-15): riccati unification + + LocalStability module are in. Branches deleted; #393 auto-retargeted to develop and + shows MERGEABLE. +- **Interface PR = #393** (`refactor/forcefreestates-result`, worktree `../result-pr3`, + DRAFT, base = develop): + - Commit (a) = 8f8e1645, done: result struct + SolutionProfiles + closure/bpen/wp + + standalone Galerkin + additive-gal removal. Verified: 82/82 result-struct tests, + 357/357 across six files, forward byte-identity (145 datasets), gal-group equivalence + (LAR_ideal_match_test, 12+16 datasets) — all vs f8996d4f, i.e. PRE-#364 base. + - Commit (b) committed: staged-main decomposition + per §6. Verified pure motion — normalized diffs of every stage body vs its old inline + block are character-identical (only function-boundary lines differ); both + force_termination early-exits preserved; one inert reorder (local stability hoisted + ahead of sing_lim!/sing_find!; it reads only equil). Gates: 82/82 result-struct + tests; fresh byte-identity of the coarsened Solovev fixture vs the commit (a) + artifact, 143/143 compared datasets identical (145 total incl. git_version + toml + blob). Review protocol for motion commits: read resulting functions top-down + + behavioral gates, NOT the raw diff; locally use `git diff --color-moved=dimmed-zebra + --color-moved-ws=allow-indentation-change --histogram`. + - Commit (c) implemented, reviewed, and REVISED per D15 (not yet committed): solve API + per §7, then RMPField reworked in place — now an ABSTRACT type with RMPSource leaf + (ComplexF64 scale) and RMPFieldSum lazy linear combinations (+, -, scalar *; flattened + term list); sum materialization evaluates each leaf via a scratch + PerturbedEquilibriumInternal and merges amplitudes per (n,m), sorted; + compute_perturbed_equilibrium accepts Union{ForcingTermsControl,RMPField}; algebra + tests added (type-level testset + one PE call asserting 3A-A == 2A); api.md gained a + Combining-forcing-sources section. THEN materialization made PURE (user request, fewer + !-functions for multithreading): materialize_forcing_modes(ffs, forcing; dir_path, + preloaded_coil_sets, verbose) -> (modes, coil_sets), three dispatch methods, no + mutation; the preload guard + state writes live ONLY in compute_perturbed_equilibrium + (double-apply bugs structurally impossible); driver pre-materialize call deleted. + scale reworded everywhere per Nik: linear-combination WEIGHT, never physical amplitude + (dropout example: nominal - failed_coil, not 0.9*nominal). Final gates: 70/70 solve + API + 17/17 fullruns after the refactor; docs build clean. + THEN problem-type form added (user design call): EulerLagrangeProblem(equil; nn, wall, + match, dir_path, debug, ctrl kwargs) names WHAT is solved (SciML problem/alg split — + PlasmaEquilibrium hosts many future problems, so solve(eq, alg) alone was namespace- + greedy); solve(prob, alg) is canonical, solve(eq, alg; kwargs...) retained as sugar + forwarding to it; nn_low/nn_high rejection lives in the problem constructor. Name + chosen over StabilityProblem because kinetic runs make stability an imprecise label. + Deviations recorded: `solve` lives in the TOP module (prepare_force_free_states! + needs the KineticForces callback; FFS cannot import KineticForces — same CommonSolve + generic, so ForceFreeStates.solve still resolves); ResistiveMatch is a plain config + mapping 1:1 onto gal_* keys (forces gal_rpec_flag=true); solve mirrors TOML side + effects (HDF5 write, local stability); forcing materialization unified in + PerturbedEquilibrium.materialize_forcing_modes! and the TOML driver rewired through + perturbed_equilibrium (ONE forcing path). Verified: 59/59 solve-api + 114/114 + result-struct + 17/17 fullruns (agent + independent rerun), TOML byte-identity + 207/207 datasets after the rewiring, docs build exit 0. + FOUND pre-existing bug (filed as #396, cross-linked from #377): TOML file-forcing + never applies convert_forcing_normalization! (snapshot preloads raw modes; the + isempty guard skips the convert branch) — factor 16.85 on Solovev amplitude-linear + PE outputs; present since the forcing-snapshot PR; NOT fixed here (needs a design + decision re: replay double-conversion; fixing moves TOML outputs). + - Commit (d) IMPLEMENTED by the coordinator directly (not yet committed; §7A has the + full final scope): ξ unification (closed gal profiles in the shared Solutions layout + from result.solution; raw basis debug-gated as Basis/), Tearing/PerSurface + rational_psi/rational_q. Gates GREEN: 133/133 result-struct (file == result.solution, + Basis gating, Match/xi absent), 73/73 slayer (surface identity), 17/17 fullruns, + 6/6 + 14/14 h5-schema (metadata contract on all new/moved datasets), forward fixture + byte-identical 137/137 vs pre-(d) tree. + - Commit (b2) implemented and reviewed (not yet committed; §6A, D14): `DeltaPrimeData` + (now in ForceFreeStatesStructs.jl for include order) carries matrix/raw/coil + gal-only + A/B/Gamma; `galerkin_solve` returns `(GalerkinResult, DeltaPrimeData)`; canonical HDF5 + paths `SingularSurfaces/{Delta_prime_matrix,Delta_prime_raw,Delta_coil,pest3_*}` written + once from `result.delta_prime`; `GalerkinDeltaPrime/` group deleted (per-surface + identifiers moved to `GalerkinIntegration/`); gal-fed SLAYER works. Convention gate + verified (PEST-3 combinations term-identical). Found+fixed pre-existing bug: old gal + `Delta_prime_raw` dataset was (2msing+mpert)×2msing with coil rows duplicated inside. + Verified: 114/114 result-struct, 71/71 slayer (independently rerun), gal Δ′ values + byte-identical under new paths (147/147 common), forward deck untouched (138/138), + benchmarks/ readers repointed. Harness gal_resistive_diiid triage CLOSED: the "3 + changed" rows were the invoking repo's renamed case TOML reading develop's RICCATI + datasets (the additive deck writes both formalisms, and riccati's datasets sit at + exactly the new canonical names) against local's GAL datasets — cross-formalism + apples-to-oranges, not numerical movement. Fresh dual-run proved gal==gal bit-for-bit + (leading raw block isequal, pest3 diag ratio 1.0, coil isequal). Action: re-baseline + the case once; harness cross-ref comparisons spanning the rename boundary are + confounded for this case and should not be repeated. + Also per D14: riccati will NEVER produce full ξ profiles — next-cycle work is the + `delta_mn` rational-surface matrix (from `delta_coil` asymptotics) for PE resonant + coupling, not profile reconstruction. +- **#364 reconciliation DONE** (merge commit b803788e in result-pr3): develop merged + bottom-up (#381 ← develop, #387 ← #381, result-pr3 ← #381-combined). The FFS-writer + conflict resolved as our-structure + #364's literature dataset names; two scope bugs + in auto-merged #364 machinery fixed (`write_root_attrs!` and `apply_main_h5_metadata!` + referenced the deleted `intr` local); `dVdpsi_spline` kwarg threaded through + `run_kinetic_forces`; `diiid_n1_riccati.toml` h5paths renamed (10 paths); stale + `LocalStability/di|dr` docstring in Ballooning.jl fixed (stale on develop too). + Post-merge smoke: 82/82 result-struct + 66/66 slayer. + STILL OWED: fresh byte-identity + gal-equivalence re-runs vs the post-merge base, full + suite, docs build, and one harness re-baseline. +- Standing decisions in force: `_chord_solution_at` retained as uncalled helper (§5.3 — + do not re-delete); gal→PE warn-skips this cycle (no gal δW yet); matching work lands + in a new `Matching/` directory (§ follow-on); directory reorg is a separate post-#367 + post-formatter-PR pure-move PR — never folded into feature commits; comment-audit PRs + follow the #354 pattern, separate from moves. +- Process rules (unchanged): ask before EVERY commit and EVERY push; no formatter ever; + slice-pure commits; third-party human review before ANY merge — non-negotiable. + + - [ ] PR 1 — `refactor/riccati-unification` — **implemented, in review.** Two deltas from the §3 spec, both improvements: the new Δ′ example references the DIIID geqdsk by relative path instead of copying it, and the TOML sweep covered six regression @@ -675,8 +1064,53 @@ manual smoke: run the 4-line UX from the Context section in a REPL against section did not list — `examples/DIIID-like_ideal_example/analyze_example.jl` (five ballooning entry points) and two docstring cross-references in `src/Analysis/ForceFreeStates.jl`. -- [ ] PR 3 — `refactor/forcefreestates-result` -- [ ] PR 4 — `refactor/staged-main` -- [ ] PR 5 — `feature/solve-api` +- [ ] Interface PR (`refactor/forcefreestates-result`) — three commits: (a) §5, (b) §6, (c) §7. + Commit (a) — **implemented (re-sliced §5), reviewed.** + Carries the pivot: no transitional API. `SolutionProfiles` is the one solution slot, + `closure`/`bpen` are unconditional on the result, standalone Galerkin and additive-gal + removal are pulled forward from PR 4, and `pe_solution` / `gal_matched_odestate` are + deleted rather than deferred. Deltas from the §5 spec: + 1. §5 did not say how the ForceFreeStates kernels PE calls keep working once + `ForceFreeStatesInternal` stops crossing the module boundary. Added an abstract + `ModeSpace` supertype (`ForceFreeStatesStructs.jl`) that both + `ForceFreeStatesInternal` and `ForceFreeStatesResult` subtype, and relaxed the + mode-space-only kernels to it: `el_derivatives!`, `materialize_derivative_stores!`, + `build_kinetic_metric_matrices`. + 2. `ForceFreeStatesResult` is parameterized on the equilibrium and `FourFitVars` types + (both are themselves parametric), so `result.equil` / `result.ffit` stay concretely + typed instead of becoming inference barriers on the PE hot paths. + 3. Two call sites outside `src/` consumed `main`'s old named tuple and are updated: + `benchmarks/benchmark_diiid_ideal_ntv_torque.jl` and + `examples/DIIID-like_ideal_example_IMAS/run_imas_example.jl`. + 4. Of the tests §5.4 lists for update, only `runtests_imas.jl` needed it — + `runtests_fullruns.jl`, `runtests_rerun_from_h5.jl` and + `runtests_parallel_integration.jl` never read `main`'s return value (the last drives + the low-level API directly and is unaffected). Coverage was added instead to + `runtests_slayer_runner.jl` (result-facing `run_slayer` dispatch) and + `runtests_imas.jl` (the `free_boundary === nothing` warn-and-skip). + 5. `_chord_solution_at` (PerturbedEquilibrium/SingularCoupling.jl) is deleted: with + `SolutionProfiles.du_store` populated by contract, its `!du_store_populated` branch is + unreachable. The gal-native / ideal-EL / kinetic branches are unchanged. + 6. The `integrator` TOML description changed in all 21 decks that carry the key (the + three-way value list), per the identical-descriptions rule in + `docs/development/toml-conventions.md`. + + Accepted output changes (D10), all spec'd in §5.1/§5.3/§5.4: + - Riccati decks write `ForceFreeStates/Solutions/ForwardIntegration/xi_psi` and `u2` + empty instead of the sparse chunk-endpoint snapshots (`dxi_psi`/`xi_s` were already + empty there). No harness case tracks those datasets. + - The four gal decks become gal-only files: their Galerkin datasets are unchanged, and + the FFS-side integration/energy datasets that the removed additive Riccati run used to + produce are now empty or absent. Verified dataset by dataset (§5.5 gate c). + + Observation for a later PR, not changed here: `result.bpen` has `msing` rows counted + from `intr.sing` under `:ideal` closure but from the Galerkin surface set under + `:matched`. The two can differ when `sing_min!` raises `psilow`. This reproduces the + pre-pivot behavior exactly (the driver previously assigned `gal_data.match.bpen` + directly, and `SingularCoupling` guards with `s <= size(inner_bpen, 1)`), so it is a + pre-existing row-alignment wart, not a regression. + + - [ ] Commit (b) — staged `main` (§6) + - [ ] Commit (c) — `solve` API (§7) - [ ] Fortran re-comparison of all important quantities - [ ] Delete this file diff --git a/benchmarks/benchmark_diiid_ideal_ntv_torque.jl b/benchmarks/benchmark_diiid_ideal_ntv_torque.jl index 2263e78ce..7da279b91 100644 --- a/benchmarks/benchmark_diiid_ideal_ntv_torque.jl +++ b/benchmarks/benchmark_diiid_ideal_ntv_torque.jl @@ -31,7 +31,6 @@ using Plots # Load GPEC using GeneralizedPerturbedEquilibrium const GPE = GeneralizedPerturbedEquilibrium -const FFS = GPE.ForceFreeStates const KF = GPE.KineticForces const Eq = GPE.Equilibrium const PE = GPE.PerturbedEquilibrium @@ -237,15 +236,12 @@ function run_benchmark(fortran_dir::String=default_fortran_dir()) _p("\n--- Equilibrium + ForceFreeStates (via main()) ---") t0 = time() result = GPE.main([tomldir]) - equil = result.equil - intr = result.intr - ctrl = result.ctrl + ffs = result.ffs + equil = ffs.equil + metric = ffs.metric _pf(" FFS completed in %.1f s\n", time() - t0) - _pf(" mpert=%d, mlow=%d, mhigh=%d\n", intr.mpert, intr.mlow, intr.mhigh) - - # Build metric (needed for JBB deweighting) - metric = FFS.make_metric(equil, intr.mpert) + _pf(" mpert=%d, mlow=%d, mhigh=%d\n", ffs.mpert, ffs.mlow, ffs.mhigh) # Load Fortran xclebsch data _p("\n--- Load Fortran xclebsch ---") @@ -257,8 +253,8 @@ function run_benchmark(fortran_dir::String=default_fortran_dir()) npsi_f, mpert_f, mlow_f) _pf(" ψ range: [%.6f, %.6f]\n", psi_grid_f[1], psi_grid_f[end]) - if mpert_f != intr.mpert || mlow_f != intr.mlow - @warn "Mode ranges differ: Fortran mpert=$mpert_f,mlow=$mlow_f vs Julia mpert=$(intr.mpert),mlow=$(intr.mlow)" + if mpert_f != ffs.mpert || mlow_f != ffs.mlow + @warn "Mode ranges differ: Fortran mpert=$mpert_f,mlow=$mlow_f vs Julia mpert=$(ffs.mpert),mlow=$(ffs.mlow)" end # Build PE state from Fortran data and run JBB deweighting @@ -291,7 +287,7 @@ function run_benchmark(fortran_dir::String=default_fortran_dir()) kf_intr = KF.KineticForcesInternal(equil; verbose=false) # Run set_perturbation_data! — builds dbob_m, divx_m, xs_m via JBB deweighting - KF.set_perturbation_data!(kf_intr, pe_state, intr, equil, metric) + KF.set_perturbation_data!(kf_intr, pe_state, ffs, equil, metric) _pf(" JBB deweighting completed in %.1f s\n", time() - t1) diff --git a/benchmarks/compare_gal_vs_el.jl b/benchmarks/compare_gal_vs_el.jl index 4c4f4f9dd..98c8e82ef 100644 --- a/benchmarks/compare_gal_vs_el.jl +++ b/benchmarks/compare_gal_vs_el.jl @@ -18,13 +18,13 @@ ksel = length(ARGS) >= 3 ? ARGS[3] : "highest" to_c(a) = eltype(a) <: Complex ? ComplexF64.(a) : map(x -> ComplexF64(x.re, x.im), a) -et, wt, u1, psiE, gxi, psiG, issing, mlow, sing_psi = h5open(h5path) do f +et, wt, u1, psiE, gxi, psiG, mlow, sing_psi = h5open(h5path) do f (to_c(read(f["ForceFreeStates/FreeBoundaryStability/eigenmode_energies"])), to_c(read(f["ForceFreeStates/FreeBoundaryStability/W_freeboundary_eigenmodes"])), to_c(read(f["ForceFreeStates/Solutions/ForwardIntegration/xi_psi"])), read(f["ForceFreeStates/Solutions/ForwardIntegration/psi"]), - to_c(read(f["ForceFreeStates/Solutions/GalerkinIntegration/Match/xi"])), read(f["ForceFreeStates/Solutions/GalerkinIntegration/Solution/psi"]), - Bool.(read(f["ForceFreeStates/Solutions/GalerkinIntegration/Solution/is_rational"])), read(f["Info/mlow"]), - read(f["SingularSurfaces/GalerkinDeltaPrime/rational_psi"])) + to_c(read(f["ForceFreeStates/Solutions/GalerkinIntegration/xi_psi"])), read(f["ForceFreeStates/Solutions/GalerkinIntegration/psi"]), + read(f["Info/mlow"]), + read(f["ForceFreeStates/Solutions/GalerkinIntegration/rational_psi"])) end mpert = size(u1, 1) @@ -38,10 +38,10 @@ w = wt[:, k] cEL = u1[:, :, end] \ w xiE = reduce(hcat, (u1[:, :, ip] * cEL for ip in 1:size(u1, 3))) # (mpert, nE) -# gal ideal profile (identity-at-edge ⇒ coefficient = w); drop on-surface points -keep = .!issing -psiGk = psiG[keep] -xiG = reduce(hcat, (gxi[:, ip, :] * w for ip in findall(keep))) # (mpert, nGk) +# gal ideal profile (identity-at-edge ⇒ coefficient = w); the closed-profile grid +# already excludes on-surface points +psiGk = psiG +xiG = reduce(hcat, (gxi[:, :, ip] * w for ip in eachindex(psiG))) # (mpert, nGk) ms = mlow .+ (0:mpert-1) peak = [maximum(abs, @view xiE[i, :]) for i in 1:mpert] diff --git a/benchmarks/compare_jbgradpsi_m2.jl b/benchmarks/compare_jbgradpsi_m2.jl index 913b48354..35816d666 100644 --- a/benchmarks/compare_jbgradpsi_m2.jl +++ b/benchmarks/compare_jbgradpsi_m2.jl @@ -3,7 +3,7 @@ # (1) IDEAL galerkin matched ξ (gal_match_flag=true, gal_ideal_flag=true) # (2) FORWARD ξ (gal_match_flag=false) # -# PE writes no ψ grid, so it's reconstructed: gal-ideal → ForceFreeStates/Solutions/GalerkinIntegration/Solution/psi minus issing points; +# PE writes no ψ grid, so it's reconstructed: gal-ideal → ForceFreeStates/Solutions/GalerkinIntegration/psi (already excludes on-surface points); # forward → ForceFreeStates/Solutions/ForwardIntegration/psi. # Usage: julia --project=. benchmarks/compare_jbgradpsi_m2.jl [gal_h5] [shoot_h5] [out.png] [m] @@ -19,9 +19,8 @@ to_c(a) = eltype(a) <: Complex ? ComplexF64.(a) : map(x -> ComplexF64(x.re, x.im # gal-ideal run: PE grid = gal solution grid with the on-surface (issing) points dropped pa_g, psi_g, mlow, sing_psi, sing_m = h5open(gal_h5) do f pa = to_c(read(f["PerturbedEquilibrium/Response/psi_area"])) # [npsi, mpert] - iss = Bool.(read(f["ForceFreeStates/Solutions/GalerkinIntegration/Solution/is_rational"])) - (pa, read(f["ForceFreeStates/Solutions/GalerkinIntegration/Solution/psi"])[.!iss], read(f["Info/mlow"]), - read(f["SingularSurfaces/GalerkinDeltaPrime/rational_psi"]), read(f["SingularSurfaces/GalerkinDeltaPrime/rational_m"])) + (pa, read(f["ForceFreeStates/Solutions/GalerkinIntegration/psi"]), read(f["Info/mlow"]), + read(f["ForceFreeStates/Solutions/GalerkinIntegration/rational_psi"]), read(f["ForceFreeStates/Solutions/GalerkinIntegration/rational_m"])) end # forward run: PE grid = ForceFreeStates/Solutions/ForwardIntegration/psi pa_s, psi_s = h5open(sh_h5) do f diff --git a/benchmarks/plot_xi_eigenmode.jl b/benchmarks/plot_xi_eigenmode.jl index 54e8ce105..78e26ee82 100644 --- a/benchmarks/plot_xi_eigenmode.jl +++ b/benchmarks/plot_xi_eigenmode.jl @@ -22,7 +22,7 @@ et, wt, u1, psi, mlow, sing_psi = h5open(h5path) do f to_c(read(f["ForceFreeStates/Solutions/ForwardIntegration/xi_psi"])), read(f["ForceFreeStates/Solutions/ForwardIntegration/psi"]), read(f["Info/mlow"]), - haskey(f, "SingularSurfaces/GalerkinDeltaPrime/rational_psi") ? read(f["SingularSurfaces/GalerkinDeltaPrime/rational_psi"]) : Float64[]) + haskey(f, "ForceFreeStates/Solutions/GalerkinIntegration/rational_psi") ? read(f["ForceFreeStates/Solutions/GalerkinIntegration/rational_psi"]) : Float64[]) end mpert, _, nstep = size(u1) diff --git a/benchmarks/scan_resistivity_m2.jl b/benchmarks/scan_resistivity_m2.jl index 1854fce57..00b7c0949 100644 --- a/benchmarks/scan_resistivity_m2.jl +++ b/benchmarks/scan_resistivity_m2.jl @@ -16,7 +16,7 @@ function read_m2(h5; gal::Bool) h5open(h5) do f pa = to_c(read(f["PerturbedEquilibrium/Response/psi_area"])) # [npsi, mpert] col = mtarget - read(f["Info/mlow"]) + 1 - psi = gal ? read(f["ForceFreeStates/Solutions/GalerkinIntegration/Solution/psi"])[.!Bool.(read(f["ForceFreeStates/Solutions/GalerkinIntegration/Solution/is_rational"]))] : + psi = gal ? read(f["ForceFreeStates/Solutions/GalerkinIntegration/psi"]) : read(f["ForceFreeStates/Solutions/ForwardIntegration/psi"]) (psi, pa[:, col]) end @@ -34,7 +34,7 @@ eta_ref = 8e-8 # rational surface for m=target sing_psi, sing_m = h5open(joinpath(scandirs[1], "gpec.h5")) do f - (read(f["SingularSurfaces/GalerkinDeltaPrime/rational_psi"]), read(f["SingularSurfaces/GalerkinDeltaPrime/rational_m"])) + (read(f["ForceFreeStates/Solutions/GalerkinIntegration/rational_psi"]), read(f["ForceFreeStates/Solutions/GalerkinIntegration/rational_m"])) end psi_res = mtarget in sing_m ? sing_psi[findfirst(==(mtarget), sing_m)] : NaN diff --git a/benchmarks/scan_rotation_m2.jl b/benchmarks/scan_rotation_m2.jl index 66cca17a5..2664a45c2 100644 --- a/benchmarks/scan_rotation_m2.jl +++ b/benchmarks/scan_rotation_m2.jl @@ -15,7 +15,7 @@ function read_m2(h5; gal::Bool) h5open(h5) do f pa = to_c(read(f["PerturbedEquilibrium/Response/psi_area"])) col = mtarget - read(f["Info/mlow"]) + 1 - psi = gal ? read(f["ForceFreeStates/Solutions/GalerkinIntegration/Solution/psi"])[.!Bool.(read(f["ForceFreeStates/Solutions/GalerkinIntegration/Solution/is_rational"]))] : + psi = gal ? read(f["ForceFreeStates/Solutions/GalerkinIntegration/psi"]) : read(f["ForceFreeStates/Solutions/ForwardIntegration/psi"]) (psi, pa[:, col]) end @@ -29,7 +29,7 @@ scandirs, rots = scandirs[ord], rots[ord] @printf("%d scan runs: rotation f = %s Hz (η fixed = 8e-8)\n", length(rots), join((@sprintf("%g", r) for r in rots), ", ")) sing_psi, sing_m = h5open(joinpath(scandirs[1], "gpec.h5")) do f - (read(f["SingularSurfaces/GalerkinDeltaPrime/rational_psi"]), read(f["SingularSurfaces/GalerkinDeltaPrime/rational_m"])) + (read(f["ForceFreeStates/Solutions/GalerkinIntegration/rational_psi"]), read(f["ForceFreeStates/Solutions/GalerkinIntegration/rational_m"])) end psi_res = mtarget in sing_m ? sing_psi[findfirst(==(mtarget), sing_m)] : NaN diff --git a/benchmarks/verify_gal_ideal.jl b/benchmarks/verify_gal_ideal.jl index 9614807f0..b4711f3a4 100644 --- a/benchmarks/verify_gal_ideal.jl +++ b/benchmarks/verify_gal_ideal.jl @@ -2,19 +2,22 @@ # 1. cout / cin / deltar all zero (no resistive plasma combination, no inner layer) # 2. matched ξ_j == the gal coil column sols(:,:,2·msing+j) (and ξ′ likewise) # Usage: julia --project=. benchmarks/verify_gal_ideal.jl [path/to/gpec.h5] +# Requires [DEBUG] gal_basis_output = true in the deck (compares against the raw-basis dump). using HDF5, Printf, LinearAlgebra h5path = length(ARGS) >= 1 ? ARGS[1] : "/tmp/gal_ideal_test/gpec.h5" to_c(a) = eltype(a) <: Complex ? ComplexF64.(a) : map(x -> ComplexF64(x.re, x.im), a) -cout, deltar, mxi, mdxi, sols, sols_d, sing_psi = h5open(h5path) do f +cout, deltar, mxi, mdxi, sols, sols_d, iss, sing_psi = h5open(h5path) do f (to_c(read(f["ForceFreeStates/Solutions/GalerkinIntegration/Match/cout"])), to_c(read(f["ForceFreeStates/Solutions/GalerkinIntegration/Match/Delta_r"])), - to_c(read(f["ForceFreeStates/Solutions/GalerkinIntegration/Match/xi"])), to_c(read(f["ForceFreeStates/Solutions/GalerkinIntegration/Match/dxidpsi"])), - to_c(read(f["ForceFreeStates/Solutions/GalerkinIntegration/Solution/xi_psi"])), to_c(read(f["ForceFreeStates/Solutions/GalerkinIntegration/Solution/dxi_psidpsi"])), - read(f["SingularSurfaces/GalerkinDeltaPrime/rational_psi"])) + to_c(read(f["ForceFreeStates/Solutions/GalerkinIntegration/xi_psi"])), to_c(read(f["ForceFreeStates/Solutions/GalerkinIntegration/dxi_psidpsi"])), + to_c(read(f["ForceFreeStates/Solutions/GalerkinIntegration/Basis/xi_psi"])), to_c(read(f["ForceFreeStates/Solutions/GalerkinIntegration/Basis/dxi_psidpsi"])), + Bool.(read(f["ForceFreeStates/Solutions/GalerkinIntegration/Basis/is_rational"])), + read(f["ForceFreeStates/Solutions/GalerkinIntegration/rational_psi"])) end msing = length(sing_psi) -mpert, ngrid, mcoil = size(mxi) +mpert, mcoil, ngrid = size(mxi) +keep = .!iss @printf("[1] ‖cout‖ = %.2e, ‖deltar‖ = %.2e %s\n", norm(cout), norm(deltar), (norm(cout) == 0 && norm(deltar) == 0) ? "✓ no resistive combination (ideal)" : "✗") @@ -22,7 +25,7 @@ mpert, ngrid, mcoil = size(mxi) # matched ξ_j should equal the coil column sols(:,:,2msing+j) err = maximum(1:mcoil) do j csol = 2msing + j - max(norm(mxi[:, :, j] - sols[:, :, csol]), norm(mdxi[:, :, j] - sols_d[:, :, csol])) + max(norm(mxi[:, j, :] - sols[:, csol, keep]), norm(mdxi[:, j, :] - sols_d[:, csol, keep])) end @printf("[2] max‖ξ_matched − coil column‖ = %.2e %s\n", err, err < 1e-12 ? "✓ matched ξ == bare ideal coil column" : "✗") diff --git a/benchmarks/verify_gal_match.jl b/benchmarks/verify_gal_match.jl index 084a6552c..51dcdd868 100644 --- a/benchmarks/verify_gal_match.jl +++ b/benchmarks/verify_gal_match.jl @@ -1,4 +1,5 @@ -# Piece 2 verification: RPEC outer↔inner matched solution (ForceFreeStates/Solutions/GalerkinIntegration/Match/*). +# Piece 2 verification: RPEC outer↔inner matched solution (closed profiles under +# GalerkinIntegration/xi_psi + matching diagnostics under GalerkinIntegration/Match/*). # 1. linear-solve residual ‖mat·cof − rmat‖/‖rmat‖ # 2. matched ξ / ξ′ finiteness # 3. edge column == identity basis: each coil drive j must give ξ_edge = e_j (the j-th harmonic), @@ -11,16 +12,16 @@ h5path = length(ARGS) >= 1 ? ARGS[1] : "examples/DIIID-like_gal_resistive_exampl @info "Reading $h5path" xi, dxi, cout, cin, deltar, eig, resid, sing_psi = h5open(h5path) do f - (read(f["ForceFreeStates/Solutions/GalerkinIntegration/Match/xi"]), read(f["ForceFreeStates/Solutions/GalerkinIntegration/Match/dxidpsi"]), + (read(f["ForceFreeStates/Solutions/GalerkinIntegration/xi_psi"]), read(f["ForceFreeStates/Solutions/GalerkinIntegration/dxi_psidpsi"]), read(f["ForceFreeStates/Solutions/GalerkinIntegration/Match/cout"]), read(f["ForceFreeStates/Solutions/GalerkinIntegration/Match/cin"]), read(f["ForceFreeStates/Solutions/GalerkinIntegration/Match/Delta_r"]), read(f["ForceFreeStates/Solutions/GalerkinIntegration/Match/rpec_eig"]), - read(f["ForceFreeStates/Solutions/GalerkinIntegration/Match/residual"]), read(f["SingularSurfaces/GalerkinDeltaPrime/rational_psi"])) + read(f["ForceFreeStates/Solutions/GalerkinIntegration/Match/residual"]), read(f["ForceFreeStates/Solutions/GalerkinIntegration/rational_psi"])) end # HDF5 stores ComplexF64 as a compound (re,im); convert if needed to_c(a) = eltype(a) <: Complex ? a : map(x -> ComplexF64(x.re, x.im), a) xi = to_c(xi); dxi = to_c(dxi); cout = to_c(cout); cin = to_c(cin); deltar = to_c(deltar); eig = to_c(eig) -mpert, ngrid, mcoil = size(xi) +mpert, mcoil, ngrid = size(xi) msing = size(deltar, 1) @printf("matched solution: mpert=%d ngrid=%d mcoil=%d msing=%d\n", mpert, ngrid, mcoil, msing) @@ -32,7 +33,7 @@ nbad = count(!isfinite, xi) + count(!isfinite, dxi) nbad, maximum(abs, xi), maximum(abs, dxi), nbad == 0 ? "✓" : "✗") # [3] edge column == identity (last grid point = psihigh edge) -edge = xi[:, ngrid, :] # (mpert mode, mcoil drive) +edge = xi[:, :, ngrid] # (mpert mode, mcoil drive) id_err = norm(edge - Matrix{ComplexF64}(I, mpert, mcoil)) / sqrt(mpert) @printf("[3] edge basis: ‖ξ(edge) − I‖/√mpert = %.3e %s\n", id_err, id_err < 1e-8 ? "✓ identity-at-edge" : "✗") diff --git a/benchmarks/verify_gal_solution.jl b/benchmarks/verify_gal_solution.jl index d2890df39..6404b6595 100644 --- a/benchmarks/verify_gal_solution.jl +++ b/benchmarks/verify_gal_solution.jl @@ -1,5 +1,6 @@ # Piece 1 verification: reconstructed gal ξ(ψ) and analytic ξ′(ψ). -# 1. shapes / finiteness sanity of ForceFreeStates/Solutions/GalerkinIntegration/Solution arrays +# 1. shapes / finiteness sanity of the raw-basis dump (GalerkinIntegration/Basis; requires +# [DEBUG] gal_basis_output = true in the deck) # 2. analytic ξ′ vs centered finite-difference of ξ — agree in the smooth interior, diverge at the # packed edge (the spline-endpoint-derivative artifact we deliberately avoid) # Usage: julia --project=. verify_gal_solution.jl [path/to/gpec.h5] @@ -9,20 +10,20 @@ h5path = length(ARGS) >= 1 ? ARGS[1] : "examples/DIIID-like_gal_resistive_exampl @info "Reading $h5path" psi, issing, xi, dxi, sing_psi = h5open(h5path) do f - (read(f["ForceFreeStates/Solutions/GalerkinIntegration/Solution/psi"]), - read(f["ForceFreeStates/Solutions/GalerkinIntegration/Solution/is_rational"]), read(f["ForceFreeStates/Solutions/GalerkinIntegration/Solution/xi_psi"]), - read(f["ForceFreeStates/Solutions/GalerkinIntegration/Solution/dxi_psidpsi"]), read(f["SingularSurfaces/GalerkinDeltaPrime/rational_psi"])) + (read(f["ForceFreeStates/Solutions/GalerkinIntegration/Basis/psi"]), + read(f["ForceFreeStates/Solutions/GalerkinIntegration/Basis/is_rational"]), read(f["ForceFreeStates/Solutions/GalerkinIntegration/Basis/xi_psi"]), + read(f["ForceFreeStates/Solutions/GalerkinIntegration/Basis/dxi_psidpsi"]), read(f["ForceFreeStates/Solutions/GalerkinIntegration/rational_psi"])) end issing = Bool.(issing) -mpert, ngrid, nsol = size(xi) +mpert, nsol, ngrid = size(xi) @printf("grid: mpert=%d ngrid=%d nsol=%d psi∈[%.4f, %.4f]\n", mpert, ngrid, nsol, psi[1], psi[end]) @printf("singular surfaces at psi = %s\n", join((@sprintf("%.4f", p) for p in sing_psi), ", ")) # --- finiteness (skip on-surface points, which are intentionally left zero) --- good = .!issing -nbad = count(!isfinite, xi[:, good, :]) + count(!isfinite, dxi[:, good, :]) +nbad = count(!isfinite, xi[:, :, good]) + count(!isfinite, dxi[:, :, good]) @printf("non-finite entries (off-surface): %d |xi|max=%.3e |dxi|max=%.3e\n", - nbad, maximum(abs, xi[:, good, :]), maximum(abs, dxi[:, good, :])) + nbad, maximum(abs, xi[:, :, good]), maximum(abs, dxi[:, :, good])) # --- analytic ξ′ vs centered finite difference of ξ --- # For each column, centered diff at interior grid points (using off-surface neighbours), compared to the @@ -40,8 +41,8 @@ for isol in 1:nsol (h1 <= 0 || h2 <= 0) && continue for m in 1:mpert # nonuniform centered difference - fd = (xi[m, ip+1, isol] - xi[m, ip-1, isol]) / (h2 + h1) - an = dxi[m, ip, isol] + fd = (xi[m, isol, ip+1] - xi[m, isol, ip-1]) / (h2 + h1) + an = dxi[m, isol, ip] scale = max(abs(an), abs(fd), 1e-30) push!(relerr, abs(fd - an) / scale) push!(dsurf, dist_sing(psi[ip])) diff --git a/docs/development/architecture.md b/docs/development/architecture.md index a6ddef8d9..3d1aed08c 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -62,6 +62,7 @@ GPEC consists of **eight main modules** organized in `src/`: - Identifies singular surfaces where ξ·∇ψ = 0 - Key files: - `ForceFreeStatesStructs.jl` - Core data structures + - `Result.jl` - `ForceFreeStatesResult`, the published solve product every downstream stage reads - `Ode.jl` - ODE solver for Euler-Lagrange equations - `Sing.jl` - Singular point handling and layer analysis - `Fourfit.jl` - Fourier fitting routines @@ -146,7 +147,7 @@ The complete GPEC analysis pipeline: - Compute Δ' at each singular surface - Calculate potential and kinetic energies - Check Mercier and ballooning stability criteria - - Outputs: Eigenmode structure ξ(ψ,θ) + - Outputs: `ForceFreeStatesResult` carrying the eigenmode structure ξ(ψ,θ) and the per-integrator products 4. **Perturbed Equilibrium** (GPEC-style): - Load external forcing data (coil fields, RMP configuration) @@ -176,8 +177,14 @@ The complete GPEC analysis pipeline: ### Stability - `SingType` - Singular surface data including: - Rational surface location (ψ, ρ, q = m/n, dq/dψ) - - Δ' (tearing stability parameter) — **stub**; the valid Δ' is `ForceFreeStatesInternal.delta_prime_matrix` + - Δ' (tearing stability parameter) — **stub**; the valid Δ' is `ForceFreeStatesResult.delta_prime.matrix` - Asymptotic solution bases at the inner-layer boundaries +- `ForceFreeStatesResult` - Published product of a solve: mode space, metric/matrix fits, singular + surfaces, and the per-integrator products (ξ solution and its basis, free-boundary energies, + STRIDE Δ', Galerkin solve). Optional products are `nothing` when the integrator that ran cannot + supply them, and consumers warn-and-skip via `require` / `require_solution`. +- `ForceFreeStatesInternal` - Solve-time scratch; does not cross a module boundary once the result + is built ### Perturbed Equilibrium - `PerturbedEquilibriumControl` - User-facing TOML configuration parameters diff --git a/docs/development/hdf5-conventions.md b/docs/development/hdf5-conventions.md index a28dcdc39..a3a098b1c 100644 --- a/docs/development/hdf5-conventions.md +++ b/docs/development/hdf5-conventions.md @@ -6,7 +6,7 @@ Conventions for the structure and naming of the `gpec.h5` output file. Every wri **The schema must be intuitive to a plasma physicist who is not a developer of this code.** The top level largely mirrors the TOML sections / major `src/` modules — which are themselves organized along physics lines — but **physics intuition wins whenever the two diverge**. Worked examples of that rule: -- All per-rational-surface stability results consolidate under `SingularSurfaces/`, regardless of which algorithm produced them: the ideal BVP `delta_prime_matrix`, the GGJ coefficients, and the Galerkin outer-region Δ′/PEST-3 results (`GalerkinDeltaPrime/`) live side by side. Provenance is recorded in the subgroup name, not by scattering results across producer-owned groups. +- All per-rational-surface stability results consolidate under `SingularSurfaces/`, regardless of which algorithm produced them: the GGJ coefficients sit beside the Δ′/PEST-3 matching results, and the Riccati BVP and the Galerkin outer-region solve write the *same* `Delta_prime_matrix`/`Delta_prime_raw`/`Delta_coil`/`pest3_*` paths rather than each owning a producer-named subgroup. - `EulerLagrangeMatrices` over a bare `Matrices` — group names must say what the data *is*, not which array it came from. Same reasoning renamed `records/` → `EnergyIntegrals/` and `matrices_/` → `KineticMatrices/`. Physics-topic groups elevated to top level (rather than nested under their producer): `Info/`, `Input/`, `SingularSurfaces/`, `LocalStability/`, `SurfaceGeometries/`. @@ -15,7 +15,7 @@ Physics-topic groups elevated to top level (rather than nested under their produ These rules govern `gpec.h5` (and any future GPEC-produced HDF5 output); harness-internal synthetic fixtures (e.g. the `ggj/*` reference files written by `regression-harness/src/runner.jl`) are out of scope. -- **Groups are CamelCase at every level** (`ForceFreeStates/`, `PerSurface/`, `GalerkinDeltaPrime/`). +- **Groups are CamelCase at every level** (`ForceFreeStates/`, `PerSurface/`, `GalerkinIntegration/`). - **Datasets (leaves) are snake_case** (`eigenmode_energies`, `delta_prime_matrix`). Established physics symbols keep their natural case (`E`, `F`, `Q_root`, `pest3_Delta`, `2piF`). - **Data-driven tokens are stored verbatim**: coil-set names under `Input/RawInputs/Coils/`, KineticForces method tokens (`fgar`, …), scan indices (`Surface_`, `psi_`). - **Word-valued names and boolean flags**: multi-word dataset names are snake_case English (`resonance_psi`, `trajectory_offsets`, `layer_widths`), never CamelCase — CamelCase is reserved for groups. A boolean flag is named for the state it asserts when true, with an `is_` prefix only where the bare word would read as a noun or collide with a data family: `is_rational` (bare `rational` would clash with the `rational_*` coordinate family) versus `enabled`, `truncated`, `no_root`, which already read as predicates. @@ -25,7 +25,7 @@ These rules govern `gpec.h5` (and any future GPEC-produced HDF5 output); harness - **"rational" over "singular"** in dataset names (`rational_psi`, `rational_q`, `rational_m`, `rational_n`, `rational_index`, `rational_count`) — kinetic/resistive runs are not singular at the rationals. Specifier order is standardized specifier-first (`rational_psi`, never `psi_rational`). - **Vector components** follow `[d][_]_[dpsi]` — the variable always comes first and the coordinate is always the trailing subscript. A bare coordinate suffix is the **contravariant** component (`xi_psi` = ξ^ψ), `_cov_` marks the **covariant** one (`b_cov_theta` = b_θ), a leading `J` marks a **Jacobian-weighted** component (`Jxi_theta` = J·ξ^θ), and other representations sit in the same slot (`xi_clebsch_psi`, `dxi_clebsch_psidpsi`). Never drop the variable: `clebsch_psi` is wrong because it does not say *what* is being represented. There is no HDF5/netCDF standard for super- vs subscripts — flat `_` names are universal — so the typeset form always appears in the dataset's `long_name`. - **Coordinates**: the radial abscissa is `psi` (normalized poloidal flux ψ_N) and the poloidal one is `theta` in every group; never `psi_n`, `xs`, or `ys`. -- **One name per physical quantity**: a quantity written in several groups carries the identical leaf name everywhere (`rational_psi` in `SingularSurfaces/`, `GalerkinDeltaPrime/`, and `SingularCoupling/`; `Delta_prime_matrix` in `SingularSurfaces/` and `Tearing/PerSurface/`; `dVdpsi` in `Profiles/`, `SingularSurfaces/`, and `KineticForces//`) — the group supplies the context, the leaf supplies the identity. +- **One name per physical quantity**: a quantity written in several groups carries the identical leaf name everywhere (`rational_psi` in `SingularSurfaces/`, `Solutions/GalerkinIntegration/`, and `SingularCoupling/`; `Delta_prime_matrix` in `SingularSurfaces/` and `Tearing/PerSurface/`; `dVdpsi` in `Profiles/`, `SingularSurfaces/`, and `KineticForces//`) — the group supplies the context, the leaf supplies the identity. ## Inputs live only under `Input/` @@ -40,9 +40,9 @@ Top level (10 groups): | `Info/` | Run metadata: `git_version`, mode-number ranges (`mpert`, `mlow`, …, `mn_index`), `psilim`, `qlim` | | `Input/` | Rerun snapshot: `gpec_toml_raw`, `RawInputs/{Equilibrium, ForcingTerms, Coils/}` | | `Equilibrium/` | Scalars (`beta_N`, `q_axis`, `q_95`, `I_p`, …) plus `Profiles/` (1-D on `psi`: 2piF, mu0p, dVdpsi, q) and `Geometry/` (2-D on `psi`×`theta`: rcoords, offset, nu, jac) | -| `ForceFreeStates/` | `Solutions/ForwardIntegration/` (u-solutions), `Solutions/GalerkinIntegration/` (`Solution/`, `Match/`, `msing`), `EulerLagrangeMatrices/{Ideal,Kinetic}`, `FreeBoundaryStability/`, `EdgeScan/` | +| `ForceFreeStates/` | `Solutions/ForwardIntegration/` (u-solutions), `Solutions/GalerkinIntegration/` (closed ξ profiles in the shared layout, `Match/` diagnostics, the gal surface list, debug-gated `Basis/`), `EulerLagrangeMatrices/{Ideal,Kinetic}`, `FreeBoundaryStability/`, `EdgeScan/` | | `LocalStability/` | Mercier `D_I`, resistive interchange `D_R`, `ballooning_Delta_prime` on `psi`; the ballooning α boundary on `ballooning_psi` | -| `SingularSurfaces/` | Per-rational-surface data: `rational_psi`/`rational_q`/`rational_m`/`rational_n`, GGJ coefficients, `Delta_prime_matrix`/`Delta_prime_raw`/`Delta_coil`, `GalerkinDeltaPrime/`, `Kinetic/` | +| `SingularSurfaces/` | Per-rational-surface data: `rational_psi`/`rational_q`/`rational_m`/`rational_n`, GGJ coefficients, `Delta_prime_matrix`/`Delta_prime_raw`/`Delta_coil`/`pest3_A`/`pest3_B`/`pest3_Gamma` (Riccati or Galerkin alike), `Kinetic/` | | `PerturbedEquilibrium/` | `ForcingModes/`, `Response/`, `ResponseMatrices/`, `SingularCoupling/`, `Energies/`, control-surface spectra | | `KineticForces/` | `/` (torque/energy profiles, `EnergyIntegrals/`, `KineticMatrices/`) | | `Tearing/` | `PerSurface/` (+ `DpMatrix/`), `Roots/`, `LayerWidths/`, `Diagnostics/{ValidRoots,Poles,FilteredRoots}`, `Scan/Surface_/` | diff --git a/docs/make.jl b/docs/make.jl index 35cc09837..92b605efc 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -26,6 +26,7 @@ makedocs(; "Home" => "index.md", "Setup" => "set_up.md", "Workflow" => "workflow.md", + "Scripting API" => "api.md", "Conventions Reference" => "conventions.md", "API Reference" => [ "Vacuum" => "vacuum.md", diff --git a/docs/src/api.md b/docs/src/api.md new file mode 100644 index 000000000..b2406c53d --- /dev/null +++ b/docs/src/api.md @@ -0,0 +1,107 @@ +# Scripting API + +GPEC's pipeline is normally driven from a `gpec.toml` deck through +`GeneralizedPerturbedEquilibrium.main`. The scripting API exposes the same stages as +ordinary Julia functions, so a run can be built, parameterized and looped over in a script +without writing a deck. + +```julia +using GeneralizedPerturbedEquilibrium + +eq = PlasmaEquilibrium("input.geqdsk"; jac_type="hamada") +ffs = solve(eq, Riccati(); nn=1, delta_mlow=8, delta_mhigh=8, vac_flag=true) +rmp = RMPField("coils.dat") +pe = perturbed_equilibrium(ffs, rmp) +``` + +`solve(eq, alg; kwargs...)` is sugar for the canonical problem form: a +[`EulerLagrangeProblem`](@ref) names WHAT is being solved (the perturbed-plasma +Euler-Lagrange system posed on this equilibrium, with this mode range, wall and closure) +and the integrator names HOW. A `PlasmaEquilibrium` hosts many possible problems; the +problem type keeps `solve` unambiguous as other problem classes appear. + +```julia +prob = EulerLagrangeProblem(eq; nn=1, delta_mlow=8, delta_mhigh=8, vac_flag=true) +ffs = solve(prob, Riccati()) +``` + +The four objects map one-to-one onto the pipeline stages: + + - [`PlasmaEquilibrium`](@ref) reads and processes the equilibrium — the `[Equilibrium]` + section. Analytic equilibria (`sol`, `lar`, `tj_analytic`) take their parameters from a + separate config object and are built with + `setup_equilibrium(eq_config, analytic_config)` instead. + - `solve` runs the force-free-states stage — the `[ForceFreeStates]` section — and returns + a `ForceFreeStatesResult`, exactly the object the TOML driver publishes. + - [`RMPField`](@ref) describes the external field — the `[ForcingTerms]` section — without + reading anything from disk until it is applied. + - `perturbed_equilibrium` runs the plasma-response stage — the `[PerturbedEquilibrium]` + section. + +## Combining forcing sources + +`RMPField`s form a vector space: `+`, `-` and scalar `*` build lazy linear combinations that +record their terms and compute nothing until the perturbed-equilibrium stage materializes +them. Because the plasma response is linear in the forcing, driving with a combination is +exactly equivalent to combining the individually materialized fields — each term is +evaluated on the control surface and the mode amplitudes are summed. A complex scalar +phase-rotates a source. + +```julia +nominal = RMPField("nominal_efc.dat") +weld_field = RMPField("weld_fields.h5") +pe = perturbed_equilibrium(ffs, 2.0 * nominal + 0.5 * weld_field) +``` + +This is the substrate for error-field workflows: build per-unit sources independently +(a coil set per ampere, a displacement field per millimeter), then assemble physical cases +by weighting and summing — without recomputing anything per combination until the final +`perturbed_equilibrium` call. + +The weights are linear-combination coefficients, not physical amplitudes: sources that are +not scalar multiples of each other get their own description and the algebra. A coil set +with a failed conductor is `nominal - failed_coil`, not `0.9 * nominal`. + +## Choosing an integrator + +The second argument of `solve` picks the formalism, and its fields are that formalism's +tunables. Everything else is a `ForceFreeStatesControl` keyword, so the TOML keys and the +`solve` keywords are the same knobs: + +```julia +ffs = solve(eq, Forward(); nn=1, vac_flag=true) # dense ξ profiles for PerturbedEquilibrium +ffs = solve(eq, Riccati(; nchunks=40); nn=1, vac_flag=true) # chunked propagators, Δ′ matrix +ffs = solve(eq, Galerkin(; nx=512); nn=1) # RDCON outer-region Galerkin Δ′ +``` + +Which products each formalism can supply differs; a result carries `nothing` in the fields +its integrator does not produce and consumers warn and skip rather than erroring. See the +[Stability Analysis](stability.md) page for the result struct and its capability gates. + +Inner-layer matching is requested with the integrator-agnostic `match` keyword, which closes +the basis with a resistive layer solution instead of the ideal jump condition: + +```julia +ffs = solve(eq, Galerkin(); nn=1, + match=ResistiveMatch(; eta=[1e-6, 2e-6], rho=[1e-7, 1e-7], rotation=[0.0, 0.0])) +@assert ffs.closure === :matched +``` + +Only the Galerkin formalism implements the match today; requesting one from `Forward` or +`Riccati` errors. Kinetic runs (`kinetic_factor > 0`) need the `[KineticForces]` profiles and +remain TOML-driven. + +## Entry points + +```@docs +GeneralizedPerturbedEquilibrium.EulerLagrangeProblem +GeneralizedPerturbedEquilibrium.solve +GeneralizedPerturbedEquilibrium.perturbed_equilibrium +``` + +## Integrator selectors + +```@autodocs +Modules = [GeneralizedPerturbedEquilibrium.ForceFreeStates] +Pages = ["Integrators.jl"] +``` diff --git a/docs/src/galerkin.md b/docs/src/galerkin.md index e55906e2b..04c4aba22 100644 --- a/docs/src/galerkin.md +++ b/docs/src/galerkin.md @@ -8,10 +8,18 @@ a single global banded system. Cells adjacent to each rational surface ("resona singular behavior is built into the basis rather than resolved numerically. The solve produces the inter-surface Δ′ matrix and the PEST-3 matching blocks -(``A'``, ``B'``, ``\Gamma'``, ``\Delta'``), written to the HDF5 output under the `SingularSurfaces/GalerkinDeltaPrime/` -group. These are the outer-region inputs to resistive matched-asymptotic stability analysis +(``A'``, ``B'``, ``\Gamma'``, ``\Delta'``), written to the HDF5 output under the same +`SingularSurfaces/` paths the Riccati Δ′ uses (`Delta_prime_matrix`, `Delta_prime_raw`, +`Delta_coil`, `pest3_A`, `pest3_B`, `pest3_Gamma`) — one set of names per quantity, +whichever formalism produced it. These are the outer-region inputs to resistive matched-asymptotic stability analysis [Glasser 2016, Phys. Plasmas **23**, 072505]. +Select it with `integrator = "galerkin"` in `[ForceFreeStates]`. It replaces the radial ODE +integration rather than supplementing it: the run computes its own vacuum response at the +control surface (when `vac_flag`) and produces no free-boundary energies or ODE trace. +Setting `gal_match_flag` additionally matches the inner layer, giving a driven ξ solution that +`PerturbedEquilibrium` consumes in place of a forward solution. + The implementation lives in `src/ForceFreeStates/Galerkin/`: | File | Content | @@ -20,7 +28,7 @@ The implementation lives in `src/ForceFreeStates/Galerkin/`: | `GalerkinGrid.jl` | Packed grid construction and local→global DOF mapping | | `GalerkinAssembly.jl` | Element-level assembly: Hermite basis, Gauss-Lobatto stiffness, resonant and extension cells, boundary conditions | | `GalerkinSolution.jl` | Reconstruct ξ(ψ) and analytic ξ′(ψ) on the gal-native grid | -| `GalerkinMatch.jl` | DRIVEN/RPEC outer↔inner asymptotic matching and the matched `OdeState` for PerturbedEquilibrium | +| `GalerkinMatch.jl` | DRIVEN/RPEC outer↔inner asymptotic matching, whose matched solution PerturbedEquilibrium consumes | | `GalerkinSolve.jl` | Top-level driver `galerkin_solve`, banded solve, Δ′ extraction, PEST-3 blocks, HDF5 output | ## API Reference diff --git a/docs/src/stability.md b/docs/src/stability.md index 9fb042e7d..529304a90 100644 --- a/docs/src/stability.md +++ b/docs/src/stability.md @@ -45,10 +45,11 @@ finite across every rational surface. ## Integration methods -Two integration drivers are available. Both solve the same EL system, but they 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. +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 @@ -137,6 +138,22 @@ rational surface inherits `direction`, while the earlier sub-chunk always gets ` 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: +```toml +[ForceFreeStates] +integrator = "galerkin" +``` + ## Local stability: Mercier and ballooning (s–α) Setting `local_stability_flag = true` in `[ForceFreeStates]` runs a local high-``n`` @@ -231,8 +248,10 @@ propagator blocks from bidirectional integration rather than the monolithic forw 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 only populated by the Riccati path and is written to the HDF5 output -under `SingularSurfaces/Delta_prime_matrix`. +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 @@ -241,7 +260,7 @@ All `ForceFreeStates` options are set in the `[ForceFreeStates]` section of `gpe ```toml [ForceFreeStates] # Integration driver -integrator = "riccati" # "forward" for dense xi profiles and kinetic runs +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 @@ -273,7 +292,7 @@ The Galerkin Δ′ solver (`src/ForceFreeStates/Galerkin/`) is documented separa ```@autodocs Modules = [GeneralizedPerturbedEquilibrium.ForceFreeStates] -Pages = ["ForceFreeStates.jl", "ForceFreeStatesStructs.jl", "Resist.jl", "EulerLagrange.jl", "Sing.jl", "Fourfit.jl", "Kinetic.jl", "FixedBoundaryStability.jl", "Utils.jl", "Free.jl", "Riccati.jl"] +Pages = ["ForceFreeStates.jl", "ForceFreeStatesStructs.jl", "Result.jl", "Resist.jl", "EulerLagrange.jl", "Sing.jl", "Fourfit.jl", "Kinetic.jl", "FixedBoundaryStability.jl", "Utils.jl", "Free.jl", "Riccati.jl"] ``` ## Example usage diff --git a/docs/src/workflow.md b/docs/src/workflow.md index 228459ed3..05b037451 100644 --- a/docs/src/workflow.md +++ b/docs/src/workflow.md @@ -179,7 +179,7 @@ All results are written to a single HDF5 file (default: `gpec.h5`). The top-leve | `Equilibrium/` | Equilibrium scalars (`beta_N`, `q_axis`, `q_95`, …), 1-D profiles (`Profiles/`), 2-D geometry (`Geometry/`) | | `ForceFreeStates/` | Stability solve: `Solutions/{ForwardIntegration,GalerkinIntegration}`, `EulerLagrangeMatrices/`, `FreeBoundaryStability/`, `EdgeScan/` | | `LocalStability/` | Mercier D_I, resistive interchange D_R, ballooning Δ' profiles | -| `SingularSurfaces/` | Per-rational-surface data: ψ_s, q, m/n, GGJ coefficients, Δ' matrices (`GalerkinDeltaPrime/`), kinetic surfaces (`Kinetic/`) | +| `SingularSurfaces/` | Per-rational-surface data: ψ_s, q, m/n, GGJ coefficients, Δ'/PEST-3 matching matrices, kinetic surfaces (`Kinetic/`) | | `PerturbedEquilibrium/` | Plasma response: `ForcingModes/`, `Response/`, `ResponseMatrices/`, `SingularCoupling/`, `Energies/` | | `KineticForces/` | NTV torque per method: energy integrals, kinetic matrices | | `Tearing/` | SLAYER/GGJ inner-layer growth rates: `PerSurface/`, `Roots/`, `LayerWidths/`, `Diagnostics/`, `Scan/` | diff --git a/examples/DIIID-like_SLAYER_example/gpec.toml b/examples/DIIID-like_SLAYER_example/gpec.toml index f5d5be92b..487d41f9b 100644 --- a/examples/DIIID-like_SLAYER_example/gpec.toml +++ b/examples/DIIID-like_SLAYER_example/gpec.toml @@ -52,7 +52,7 @@ singfac_min = 1e-4 # Fractional distance from rational q at which id ucrit = 1e4 # Maximum fraction of solutions allowed before re-normalized # Integrator selection and domain truncation (see ForceFreeStatesControl docstring for details) -integrator = "riccati" # Integration formalism: "forward" (dense ξ, needed for [PerturbedEquilibrium]/kinetic) or "riccati" (chunked propagator BVP, unlocks the singular-surface Δ′ matrix) +integrator = "riccati" # Integration formalism: "forward" (dense ξ, needed for [PerturbedEquilibrium]/kinetic), "riccati" (chunked propagator BVP, unlocks the singular-surface Δ′ matrix), or "galerkin" (RDCON outer-region singular Galerkin Δ′, with the optional RPEC inner-layer match) set_psilim_via_dmlim = true # TRUE for diverted geqdsks — q → ∞ at separatrix, so dmlim truncation avoids the δW kink instability at negligible domain cost dmlim = 0.2 # Truncate integration at (last_rational_q + dmlim) / n diff --git a/examples/DIIID-like_gal_resistive_example/gpec.toml b/examples/DIIID-like_gal_resistive_example/gpec.toml index efa77bb14..524a5ac26 100644 --- a/examples/DIIID-like_gal_resistive_example/gpec.toml +++ b/examples/DIIID-like_gal_resistive_example/gpec.toml @@ -51,13 +51,12 @@ singfac_min = 1e-4 # Fractional distance from rational q at which id ucrit = 1e4 # Maximum fraction of solutions allowed before re-normalized # Integrator selection and domain truncation (see ForceFreeStatesControl docstring for details) -integrator = "riccati" # Integration formalism: "forward" (dense ξ, needed for [PerturbedEquilibrium]/kinetic) or "riccati" (chunked propagator BVP, unlocks the singular-surface Δ′ matrix) +integrator = "galerkin" # Integration formalism: "forward" (dense ξ, needed for [PerturbedEquilibrium]/kinetic), "riccati" (chunked propagator BVP, unlocks the singular-surface Δ′ matrix), or "galerkin" (RDCON outer-region singular Galerkin Δ′, with the optional RPEC inner-layer match) set_psilim_via_dmlim = false # Keep psilim at psihigh (do not truncate at last_rational_q + dmlim) dmlim = 0.2 # Truncate integration at (last_rational_q + dmlim) / n (only used when set_psilim_via_dmlim = true) # Outer-region singular Galerkin Δ′ solver with rpec coil columns. # This case exercises the gal solve AND the rpec coil/edge path (delta_coil) for regression tracking. -gal_flag = true # Enable the outer-region singular Galerkin Δ′ solve gal_solver = "LU" # Banded solver: "LU" (zgbtrf/zgbtrs) or "cholesky" (zpbtrf/zpbtrs). rpec requires "LU". gal_nx = 256 # Hermite-cubic elements per interval between singular surfaces gal_nq = 6 # Gauss-Lobatto quadrature order per element diff --git a/examples/DIIID-like_gal_resistive_pe_example/gpec.toml b/examples/DIIID-like_gal_resistive_pe_example/gpec.toml index cff5472ff..475df829a 100644 --- a/examples/DIIID-like_gal_resistive_pe_example/gpec.toml +++ b/examples/DIIID-like_gal_resistive_pe_example/gpec.toml @@ -1,8 +1,9 @@ -# DIII-D-like H-mode DRIVEN resistive example: gal Δ′ + inner-layer matching → PerturbedEquilibrium. -# Extends the sibling DIIID-like_gal_resistive_example with DIII-D C-coil forcing -# (n=1 cosine phasing): the gal-matched resistive ξ is fed into PerturbedEquilibrium -# for coil-driven singular coupling — island half-widths, Chirikov overlap, resonant flux. -# Same equilibrium and Galerkin settings as the sibling; differences start at [ForcingTerms]. +# DIII-D-like H-mode DRIVEN resistive example: gal Δ′ + inner-layer matching, with DIII-D +# C-coil forcing (n=1 cosine phasing) staged for PerturbedEquilibrium. The PE stage currently +# warns and skips: it requires the free-boundary δW, which the Galerkin formalism does not +# yet produce — the gal-matched resistive ξ and penetrated field are carried on the result +# for when gal-side δW lands. Same equilibrium and Galerkin settings as the sibling +# DIIID-like_gal_resistive_example; differences start at [ForcingTerms]. [Equilibrium] eq_filename = "TkMkr_D3Dlike_Hmode.geqdsk" # Path to equilibrium file eq_type = "efit" # Type of the input 2D equilibrium file @@ -50,13 +51,12 @@ singfac_min = 1e-4 # Fractional distance from rational q at which id ucrit = 1e4 # Maximum fraction of solutions allowed before re-normalized # Integrator selection and domain truncation (see ForceFreeStatesControl docstring for details) -integrator = "riccati" # Integration formalism: "forward" (dense ξ, needed for [PerturbedEquilibrium]/kinetic) or "riccati" (chunked propagator BVP, unlocks the singular-surface Δ′ matrix) +integrator = "galerkin" # Integration formalism: "forward" (dense ξ, needed for [PerturbedEquilibrium]/kinetic), "riccati" (chunked propagator BVP, unlocks the singular-surface Δ′ matrix), or "galerkin" (RDCON outer-region singular Galerkin Δ′, with the optional RPEC inner-layer match) set_psilim_via_dmlim = false # Keep psilim at psihigh (do not truncate at last_rational_q + dmlim) dmlim = 0.2 # Truncate integration at (last_rational_q + dmlim) / n (only used when set_psilim_via_dmlim = true) # Outer-region singular Galerkin Δ′ solver with rpec coil columns. # This case exercises the gal solve AND the rpec coil/edge path (delta_coil) for regression tracking. -gal_flag = true # Enable the outer-region singular Galerkin Δ′ solve gal_solver = "LU" # Banded solver: "LU" (zgbtrf/zgbtrs) or "cholesky" (zpbtrf/zpbtrs). rpec requires "LU". gal_nx = 256 # Hermite-cubic elements per interval between singular surfaces gal_nq = 6 # Gauss-Lobatto quadrature order per element diff --git a/examples/DIIID-like_ideal_example/gpec.toml b/examples/DIIID-like_ideal_example/gpec.toml index b84e12872..24b2b7f24 100644 --- a/examples/DIIID-like_ideal_example/gpec.toml +++ b/examples/DIIID-like_ideal_example/gpec.toml @@ -50,7 +50,7 @@ singfac_min = 1e-4 # Fractional distance from rational q at which id ucrit = 1e4 # Column-norm threshold that triggers solution renormalization # Integrator selection and domain truncation (see ForceFreeStatesControl docstring for details) -integrator = "forward" # Integration formalism: "forward" (dense ξ, needed for [PerturbedEquilibrium]/kinetic) or "riccati" (chunked propagator BVP, unlocks the singular-surface Δ′ matrix) +integrator = "forward" # Integration formalism: "forward" (dense ξ, needed for [PerturbedEquilibrium]/kinetic), "riccati" (chunked propagator BVP, unlocks the singular-surface Δ′ matrix), or "galerkin" (RDCON outer-region singular Galerkin Δ′, with the optional RPEC inner-layer match) set_psilim_via_dmlim = true # Truncate at (last_rational_q + dmlim)/n — TRUE for diverted equilibria (q → ∞ at separatrix) dmlim = 0.2 # Truncate integration at (last_rational_q + dmlim)/n (used when set_psilim_via_dmlim = true) diff --git a/examples/DIIID-like_ideal_example_IMAS/run_imas_example.jl b/examples/DIIID-like_ideal_example_IMAS/run_imas_example.jl index f7f6cfb9e..c4fe1637f 100644 --- a/examples/DIIID-like_ideal_example_IMAS/run_imas_example.jl +++ b/examples/DIIID-like_ideal_example_IMAS/run_imas_example.jl @@ -61,8 +61,8 @@ try TOML.print(io, config_imas); end result_imas = GPEC.main([tmpdir_imas]; dd=dd) - global et_imas = real(result_imas.free_energies.et[1]) - global mpert_imas = result_imas.intr.mpert + global et_imas = real(result_imas.ffs.free_boundary.et[1]) + global mpert_imas = result_imas.ffs.mpert GPEC.write_imas(dd, result_imas) @assert dd.mhd_linear.time_slice[1].toroidal_mode[1].energy_perturbed ≈ et_imas finally @@ -80,8 +80,8 @@ try TOML.print(io, config_gfile); end result_gfile = GPEC.main([tmpdir_gfile]) - global et_gfile = real(result_gfile.free_energies.et[1]) - global mpert_gfile = result_gfile.intr.mpert + global et_gfile = real(result_gfile.ffs.free_boundary.et[1]) + global mpert_gfile = result_gfile.ffs.mpert finally rm(tmpdir_gfile; recursive=true) end diff --git a/examples/DIIID-like_riccati_deltaprime_example/gpec.toml b/examples/DIIID-like_riccati_deltaprime_example/gpec.toml index 18df054b9..f046f380b 100644 --- a/examples/DIIID-like_riccati_deltaprime_example/gpec.toml +++ b/examples/DIIID-like_riccati_deltaprime_example/gpec.toml @@ -52,6 +52,6 @@ singfac_min = 1e-4 # Fractional distance from rational q at which id ucrit = 1e4 # Column-norm threshold that triggers solution renormalization # Integrator selection and domain truncation (see ForceFreeStatesControl docstring for details) -integrator = "riccati" # Integration formalism: "forward" (dense ξ, needed for [PerturbedEquilibrium]/kinetic) or "riccati" (chunked propagator BVP, unlocks the singular-surface Δ′ matrix) +integrator = "riccati" # Integration formalism: "forward" (dense ξ, needed for [PerturbedEquilibrium]/kinetic), "riccati" (chunked propagator BVP, unlocks the singular-surface Δ′ matrix), or "galerkin" (RDCON outer-region singular Galerkin Δ′, with the optional RPEC inner-layer match) set_psilim_via_dmlim = true # Truncate at (last_rational_q + dmlim)/n — TRUE for diverted equilibria (q → ∞ at separatrix) dmlim = 0.2 # Truncate integration at (last_rational_q + dmlim)/n (used when set_psilim_via_dmlim = true) diff --git a/examples/LAR_beta_scan/gpec.toml b/examples/LAR_beta_scan/gpec.toml index 4ab001876..cf26ed99c 100644 --- a/examples/LAR_beta_scan/gpec.toml +++ b/examples/LAR_beta_scan/gpec.toml @@ -52,7 +52,7 @@ singfac_min = 1e-4 # Fractional distance from rational q at which id ucrit = 1e4 # Column-norm threshold that triggers solution renormalization sing_order = 6 # Order of the singular-surface (Frobenius) series expansion -integrator = "riccati" # Integration formalism: "forward" (dense ξ, needed for [PerturbedEquilibrium]/kinetic) or "riccati" (chunked propagator BVP, unlocks the singular-surface Δ′ matrix) +integrator = "riccati" # Integration formalism: "forward" (dense ξ, needed for [PerturbedEquilibrium]/kinetic), "riccati" (chunked propagator BVP, unlocks the singular-surface Δ′ matrix), or "galerkin" (RDCON outer-region singular Galerkin Δ′, with the optional RPEC inner-layer match) set_psilim_via_dmlim = false # FALSE for limited/analytical equilibria — rationals sparse, dmlim would chop too much edge dmlim = 0.2 # Truncate integration at (last_rational_q + dmlim)/n (used when set_psilim_via_dmlim = true) force_termination = true # Stop after force-free states (skip perturbed equilibrium) diff --git a/examples/LAR_epsilon_scan/gpec.toml b/examples/LAR_epsilon_scan/gpec.toml index 2dc5f069b..b1d006b46 100644 --- a/examples/LAR_epsilon_scan/gpec.toml +++ b/examples/LAR_epsilon_scan/gpec.toml @@ -53,7 +53,7 @@ singfac_min = 1e-4 # Fractional distance from rational q at which id ucrit = 1e4 # Column-norm threshold that triggers solution renormalization sing_order = 6 # Order of the singular-surface (Frobenius) series expansion -integrator = "riccati" # Integration formalism: "forward" (dense ξ, needed for [PerturbedEquilibrium]/kinetic) or "riccati" (chunked propagator BVP, unlocks the singular-surface Δ′ matrix) +integrator = "riccati" # Integration formalism: "forward" (dense ξ, needed for [PerturbedEquilibrium]/kinetic), "riccati" (chunked propagator BVP, unlocks the singular-surface Δ′ matrix), or "galerkin" (RDCON outer-region singular Galerkin Δ′, with the optional RPEC inner-layer match) set_psilim_via_dmlim = false # FALSE for limited/analytical equilibria — rationals sparse, dmlim would chop too much edge dmlim = 0.2 # Truncate integration at (last_rational_q + dmlim)/n (used when set_psilim_via_dmlim = true) force_termination = true # Stop after force-free states (skip perturbed equilibrium) diff --git a/examples/LAR_ideal_match_test/gpec.toml b/examples/LAR_ideal_match_test/gpec.toml index c53d472eb..2bcc0a211 100644 --- a/examples/LAR_ideal_match_test/gpec.toml +++ b/examples/LAR_ideal_match_test/gpec.toml @@ -50,14 +50,13 @@ ucrit = 1e4 # Maximum fraction of solutions allowed before r sing_order = 6 # Power-series order for the singular-surface asymptotics save_interval = 3 # Save every Nth ODE step (1=all, 10=every 10th). Always saves near rational surfaces. -integrator = "riccati" # Integration formalism: "forward" (dense ξ, needed for [PerturbedEquilibrium]/kinetic) or "riccati" (chunked propagator BVP, unlocks the singular-surface Δ′ matrix) +integrator = "galerkin" # Integration formalism: "forward" (dense ξ, needed for [PerturbedEquilibrium]/kinetic), "riccati" (chunked propagator BVP, unlocks the singular-surface Δ′ matrix), or "galerkin" (RDCON outer-region singular Galerkin Δ′, with the optional RPEC inner-layer match) set_psilim_via_dmlim = false # Keep psilim at psihigh (do not truncate at last_rational_q + dmlim) dmlim = 0.2 # Truncate integration at (last_rational_q + dmlim) / n (only used when set_psilim_via_dmlim = true) force_termination = true # Terminate after the stability stage (no perturbed-equilibrium section here) write_outputs_to_HDF5 = true # Write stability outputs to HDF5 # Outer-region singular Galerkin Delta-prime solver with rpec coil columns. -gal_flag = true # Enable the outer-region singular Galerkin Delta-prime solve gal_solver = "LU" # Banded solver: "LU" (zgbtrf/zgbtrs) or "cholesky" (zpbtrf/zpbtrs). rpec requires "LU". gal_nx = 256 # Hermite-cubic elements per interval between singular surfaces gal_nq = 6 # Gauss-Lobatto quadrature order per element diff --git a/examples/LAR_resistive_match_test/gpec.toml b/examples/LAR_resistive_match_test/gpec.toml index de8d0e5d1..635dae335 100644 --- a/examples/LAR_resistive_match_test/gpec.toml +++ b/examples/LAR_resistive_match_test/gpec.toml @@ -51,14 +51,13 @@ ucrit = 1e4 # Maximum fraction of solutions allowed before r sing_order = 6 # Power-series order for the singular-surface asymptotics save_interval = 3 # Save every Nth ODE step (1=all, 10=every 10th). Always saves near rational surfaces. -integrator = "riccati" # Integration formalism: "forward" (dense ξ, needed for [PerturbedEquilibrium]/kinetic) or "riccati" (chunked propagator BVP, unlocks the singular-surface Δ′ matrix) +integrator = "galerkin" # Integration formalism: "forward" (dense ξ, needed for [PerturbedEquilibrium]/kinetic), "riccati" (chunked propagator BVP, unlocks the singular-surface Δ′ matrix), or "galerkin" (RDCON outer-region singular Galerkin Δ′, with the optional RPEC inner-layer match) set_psilim_via_dmlim = false # Keep psilim at psihigh (do not truncate at last_rational_q + dmlim) dmlim = 0.2 # Truncate integration at (last_rational_q + dmlim) / n (only used when set_psilim_via_dmlim = true) force_termination = true # Terminate after the stability stage (no perturbed-equilibrium section here) write_outputs_to_HDF5 = true # Write stability outputs to HDF5 # Outer-region singular Galerkin Delta-prime solver with rpec coil columns. -gal_flag = true # Enable the outer-region singular Galerkin Delta-prime solve gal_solver = "LU" # Banded solver: "LU" (zgbtrf/zgbtrs) or "cholesky" (zpbtrf/zpbtrs). rpec requires "LU". gal_nx = 256 # Hermite-cubic elements per interval between singular surfaces gal_nq = 6 # Gauss-Lobatto quadrature order per element diff --git a/examples/Solovev_ideal_example/gpec.toml b/examples/Solovev_ideal_example/gpec.toml index a54725908..4060b3f74 100644 --- a/examples/Solovev_ideal_example/gpec.toml +++ b/examples/Solovev_ideal_example/gpec.toml @@ -72,7 +72,7 @@ ucrit = 1e3 # Column-norm threshold that triggers solution ren save_interval = 3 # Save every Nth ODE step (1=all). Always saves near rational surfaces. # Integrator selection and domain truncation (see ForceFreeStatesControl docstring for details) -integrator = "forward" # Integration formalism: "forward" (dense ξ, needed for [PerturbedEquilibrium]/kinetic) or "riccati" (chunked propagator BVP, unlocks the singular-surface Δ′ matrix) +integrator = "forward" # Integration formalism: "forward" (dense ξ, needed for [PerturbedEquilibrium]/kinetic), "riccati" (chunked propagator BVP, unlocks the singular-surface Δ′ matrix), or "galerkin" (RDCON outer-region singular Galerkin Δ′, with the optional RPEC inner-layer match) set_psilim_via_dmlim = false # FALSE for limited/analytical equilibria — rationals sparse, dmlim would chop too much edge dmlim = 0.2 # Truncate integration at (last_rational_q + dmlim)/n (used when set_psilim_via_dmlim = true) # Solovev analytic equilibrium parameters (eq_type = "sol"); see SolovevConfig in src/Equilibrium. diff --git a/examples/Solovev_ideal_example_3D/gpec.toml b/examples/Solovev_ideal_example_3D/gpec.toml index 7a0e40200..313b4528f 100644 --- a/examples/Solovev_ideal_example_3D/gpec.toml +++ b/examples/Solovev_ideal_example_3D/gpec.toml @@ -38,7 +38,7 @@ ucrit = 1e3 # Column-norm threshold that triggers solution ren save_interval = 3 # Save every Nth ODE step (1=all). Always saves near rational surfaces. # Integrator selection and domain truncation (see ForceFreeStatesControl docstring for details) -integrator = "riccati" # Integration formalism: "forward" (dense ξ, needed for [PerturbedEquilibrium]/kinetic) or "riccati" (chunked propagator BVP, unlocks the singular-surface Δ′ matrix) +integrator = "riccati" # Integration formalism: "forward" (dense ξ, needed for [PerturbedEquilibrium]/kinetic), "riccati" (chunked propagator BVP, unlocks the singular-surface Δ′ matrix), or "galerkin" (RDCON outer-region singular Galerkin Δ′, with the optional RPEC inner-layer match) set_psilim_via_dmlim = false # FALSE for limited/analytical equilibria — rationals sparse, dmlim would chop too much edge dmlim = 0.2 # Truncate integration at (last_rational_q + dmlim)/n (used when set_psilim_via_dmlim = true) diff --git a/examples/Solovev_ideal_example_multi_n/gpec.toml b/examples/Solovev_ideal_example_multi_n/gpec.toml index a82c3bcc0..7670bd5cf 100644 --- a/examples/Solovev_ideal_example_multi_n/gpec.toml +++ b/examples/Solovev_ideal_example_multi_n/gpec.toml @@ -49,7 +49,7 @@ ucrit = 1e3 # Column-norm threshold that triggers solution ren # Integrator selection and domain truncation (see ForceFreeStatesControl docstring for details). # The multi-n Δ' matrix has open issues and is skipped with a warning; the energies stay valid. -integrator = "riccati" # Integration formalism: "forward" (dense ξ, needed for [PerturbedEquilibrium]/kinetic) or "riccati" (chunked propagator BVP, unlocks the singular-surface Δ′ matrix) +integrator = "riccati" # Integration formalism: "forward" (dense ξ, needed for [PerturbedEquilibrium]/kinetic), "riccati" (chunked propagator BVP, unlocks the singular-surface Δ′ matrix), or "galerkin" (RDCON outer-region singular Galerkin Δ′, with the optional RPEC inner-layer match) set_psilim_via_dmlim = false # FALSE for multi-n — dmlim truncation is ambiguous when n varies (sing_lim! skips anyway) dmlim = 0.2 # Truncate integration at (last_rational_q + dmlim)/n (used when set_psilim_via_dmlim = true) # Solovev analytic equilibrium parameters (eq_type = "sol"); see SolovevConfig in src/Equilibrium. diff --git a/examples/Solovev_kinetic_NTV_example/gpec.toml b/examples/Solovev_kinetic_NTV_example/gpec.toml index e52b0d537..39483c808 100644 --- a/examples/Solovev_kinetic_NTV_example/gpec.toml +++ b/examples/Solovev_kinetic_NTV_example/gpec.toml @@ -69,7 +69,7 @@ ucrit = 1e3 # Column-norm threshold that triggers solution ren save_interval = 3 # Save every Nth ODE step (1=all). Always saves near rational surfaces. # Integrator selection and domain truncation (see ForceFreeStatesControl docstring for details) -integrator = "forward" # Integration formalism: "forward" (dense ξ, needed for [PerturbedEquilibrium]/kinetic) or "riccati" (chunked propagator BVP, unlocks the singular-surface Δ′ matrix) +integrator = "forward" # Integration formalism: "forward" (dense ξ, needed for [PerturbedEquilibrium]/kinetic), "riccati" (chunked propagator BVP, unlocks the singular-surface Δ′ matrix), or "galerkin" (RDCON outer-region singular Galerkin Δ′, with the optional RPEC inner-layer match) set_psilim_via_dmlim = false # FALSE for limited/analytical equilibria — rationals sparse, dmlim would chop too much edge dmlim = 0.2 # Truncate integration at (last_rational_q + dmlim)/n (used when set_psilim_via_dmlim = true) # Solovev analytic equilibrium parameters (eq_type = "sol"); see SolovevConfig in src/Equilibrium. diff --git a/examples/Solovev_kinetic_calculated_example/gpec.toml b/examples/Solovev_kinetic_calculated_example/gpec.toml index 7d3a13059..827582a7c 100644 --- a/examples/Solovev_kinetic_calculated_example/gpec.toml +++ b/examples/Solovev_kinetic_calculated_example/gpec.toml @@ -42,7 +42,7 @@ mthvac = 64 # Number of points used in splines over poloidal a kinetic_source = "calculated" # Kinetic matrix source — exercises KineticForces.compute_calculated_kinetic_matrices callback with real physics kinetic_factor = 1.0 # Full-strength kinetic matrices (the "calculated" path is the real physics; no perturbation scaling) -integrator = "forward" # Integration formalism: "forward" (dense ξ, needed for [PerturbedEquilibrium]/kinetic) or "riccati" (chunked propagator BVP, unlocks the singular-surface Δ′ matrix) +integrator = "forward" # Integration formalism: "forward" (dense ξ, needed for [PerturbedEquilibrium]/kinetic), "riccati" (chunked propagator BVP, unlocks the singular-surface Δ′ matrix), or "galerkin" (RDCON outer-region singular Galerkin Δ′, with the optional RPEC inner-layer match) eulerlagrange_tolerance = 1e-7 # Relative tolerance for ODE integration of Euler-Lagrange equations singfac_min = 1e-4 # Fractional distance from rational q at which ideal jump enforced ucrit = 1e3 # Maximum fraction of solutions allowed before re-normalized diff --git a/examples/a10_kinetic_example/gpec.toml b/examples/a10_kinetic_example/gpec.toml index fd8881122..47e2e603b 100644 --- a/examples/a10_kinetic_example/gpec.toml +++ b/examples/a10_kinetic_example/gpec.toml @@ -39,7 +39,7 @@ mthvac = 512 # Number of points used in splines over poloidal kinetic_source = "calculated" # Kinetic matrix source: "fixed" test matrices, or "calculated" from the kinetic NTV model kinetic_factor = 1.0 # Scaling of kinetic matrices (0 = ideal path; >0 enables kinetic mode; 1.0 = full strength) -integrator = "forward" # Integration formalism: "forward" (dense ξ, needed for [PerturbedEquilibrium]/kinetic) or "riccati" (chunked propagator BVP, unlocks the singular-surface Δ′ matrix) +integrator = "forward" # Integration formalism: "forward" (dense ξ, needed for [PerturbedEquilibrium]/kinetic), "riccati" (chunked propagator BVP, unlocks the singular-surface Δ′ matrix), or "galerkin" (RDCON outer-region singular Galerkin Δ′, with the optional RPEC inner-layer match) eulerlagrange_tolerance = 1e-7 # Relative tolerance for ODE integration of Euler-Lagrange equations singfac_min = 1e-4 # Fractional distance from rational q at which ideal jump enforced ucrit = 1e4 # Column-norm threshold that triggers solution renormalization diff --git a/regression-harness/cases/gal_resistive_diiid.toml b/regression-harness/cases/gal_resistive_diiid.toml index e9f1b23a6..b13388d8a 100644 --- a/regression-harness/cases/gal_resistive_diiid.toml +++ b/regression-harness/cases/gal_resistive_diiid.toml @@ -19,7 +19,7 @@ noise_threshold = 0 order = 10 [quantities.gal_sing_q] -h5path = "SingularSurfaces/GalerkinDeltaPrime/rational_q" +h5path = "ForceFreeStates/Solutions/GalerkinIntegration/rational_q" type = "real_vector" extract = "all_real" label = "gal singular q values" @@ -28,25 +28,25 @@ order = 11 # PEST-3 Δ matching matrix — per-surface diagonal (the physics-meaningful tearing Δ′) [quantities.gal_pest3_delta_diag] -h5path = "SingularSurfaces/GalerkinDeltaPrime/pest3_Delta" +h5path = "SingularSurfaces/Delta_prime_matrix" type = "complex_matrix" extract = "diagonal_complex" label = "gal PEST3 Δ diagonal" noise_threshold = 1e-6 order = 20 -# Full outer Δ′ matrix (nsol × 2·msing) — Frobenius norm catches any element drift +# Raw outer Δ′ matrix (2·msing × 2·msing) — Frobenius norm catches any element drift [quantities.gal_delta_norm] -h5path = "SingularSurfaces/GalerkinDeltaPrime/Delta_prime_raw" +h5path = "SingularSurfaces/Delta_prime_raw" type = "complex_matrix" extract = "norm" label = "||gal Δ′ matrix||" noise_threshold = 1e-6 order = 21 -# rpec coil-response block (mpert × 2·msing) — Frobenius norm +# rpec coil-response block (edge mode × surface-side) — Frobenius norm [quantities.gal_delta_coil_norm] -h5path = "SingularSurfaces/GalerkinDeltaPrime/Delta_coil" +h5path = "SingularSurfaces/Delta_coil" type = "complex_matrix" extract = "norm" label = "||gal Δ_coil block||" @@ -55,7 +55,7 @@ order = 22 # Mercier index per surface [quantities.gal_di] -h5path = "SingularSurfaces/GalerkinDeltaPrime/D_I" +h5path = "ForceFreeStates/Solutions/GalerkinIntegration/D_I" type = "real_vector" extract = "all_real" label = "gal D_I per surface" @@ -64,7 +64,7 @@ order = 30 # Resonant exponents α per surface [quantities.gal_alpha] -h5path = "SingularSurfaces/GalerkinDeltaPrime/alpha" +h5path = "ForceFreeStates/Solutions/GalerkinIntegration/alpha" type = "complex_vector" extract = "all_complex" label = "gal α per surface" diff --git a/src/Equilibrium/Equilibrium.jl b/src/Equilibrium/Equilibrium.jl index 6070e3359..eed157812 100644 --- a/src/Equilibrium/Equilibrium.jl +++ b/src/Equilibrium/Equilibrium.jl @@ -144,6 +144,27 @@ function setup_equilibrium(eq_config::EquilibriumConfig, additional_input=nothin return plasma_equilibrium end +""" + PlasmaEquilibrium(path::AbstractString; eq_type="efit", kwargs...) -> PlasmaEquilibrium + +Read the equilibrium file at `path` and return the processed equilibrium. Convenience entry +point of the scripting API: `kwargs` are [`EquilibriumConfig`](@ref) fields, so +`PlasmaEquilibrium("g000001.00001"; jac_type="hamada", mpsi=128)` is the whole setup. + +Only file-based equilibria go through this constructor. Analytic kinds (`sol`, `lar`, +`tj_analytic`) take their parameters from a separate config object and are built with +`setup_equilibrium(config, analytic_config)` instead. + +```julia +eq = PlasmaEquilibrium("input.geqdsk"; jac_type="hamada") +``` +""" +function PlasmaEquilibrium(path::AbstractString; eq_type::String="efit", kwargs...) + haskey(ANALYTIC_EQ, eq_type) && + error("$eq_type is an analytic equilibrium: build it with setup_equilibrium(config, $(ANALYTIC_EQ[eq_type].config_type)(...)) instead") + return setup_equilibrium(EquilibriumConfig(; eq_type, eq_filename=abspath(path), kwargs...)) +end + """ equilibrium_separatrix_find!(pe::PlasmaEquilibrium) diff --git a/src/ForceFreeStates/EulerLagrange.jl b/src/ForceFreeStates/EulerLagrange.jl index 8d0d024f6..88c1d9e4a 100644 --- a/src/ForceFreeStates/EulerLagrange.jl +++ b/src/ForceFreeStates/EulerLagrange.jl @@ -157,12 +157,12 @@ function eulerlagrange_integration(ctrl::ForceFreeStatesControl, equil::Equilibr if ctrl.integrator == "riccati" ctrl.kinetic_factor > 0 && error("kinetic runs require integrator=\"forward\"; the Riccati integrator has no kinetic crossing.") - # Riccati path returns (odet, propagators, chunks, S_at_surface_left) for the deferred Δ' BVP. return riccati_eulerlagrange_integration(ctrl, equil, ffit, intr) elseif ctrl.integrator == "forward" return forward_eulerlagrange_integration(ctrl, equil, ffit, intr) elseif ctrl.integrator == "galerkin" - error("integrator = \"galerkin\" is not yet a standalone integrator — use gal_flag = true alongside integrator = \"forward\" or \"riccati\".") + error("integrator = \"galerkin\" solves the Euler-Lagrange system variationally, not by ODE integration; " * + "it is dispatched to galerkin_solve.") end error("Unknown integrator: $(ctrl.integrator). Expected \"forward\", \"riccati\", or \"galerkin\".") end diff --git a/src/ForceFreeStates/ForceFreeStates.jl b/src/ForceFreeStates/ForceFreeStates.jl index a88f9e9b8..8981a141a 100644 --- a/src/ForceFreeStates/ForceFreeStates.jl +++ b/src/ForceFreeStates/ForceFreeStates.jl @@ -44,6 +44,12 @@ include("Galerkin/GalerkinSolution.jl") include("Galerkin/GalerkinMatch.jl") include("Galerkin/GalerkinSolve.jl") +# Scripting-API integrator selectors: pure configuration translated onto ForceFreeStatesControl. +include("Integrators.jl") + +# The published solve product; last, so it can name every type the stages above define. +include("Result.jl") + # These are used for various small tolerances and root finders throughout ForceFreeStates global eps = 1e-10 global itmax = 50 diff --git a/src/ForceFreeStates/ForceFreeStatesStructs.jl b/src/ForceFreeStates/ForceFreeStatesStructs.jl index 69fdd5f98..79735b895 100644 --- a/src/ForceFreeStates/ForceFreeStatesStructs.jl +++ b/src/ForceFreeStates/ForceFreeStatesStructs.jl @@ -1,3 +1,14 @@ +""" + ModeSpace + +Supertype for objects that carry the resolved (m, n) mode space — `mlow`, `mhigh`, `mpert`, +`nlow`, `nhigh`, `npert`, `numpert_total`. Both the solve-time scratch +[`ForceFreeStatesInternal`](@ref) and the published [`ForceFreeStatesResult`](@ref) are +`ModeSpace`s, so kernels that need nothing but the mode indexing (`el_derivatives!`, +`materialize_derivative_stores!`, `build_kinetic_metric_matrices`) accept either. +""" +abstract type ModeSpace end + """ SingType @@ -120,9 +131,11 @@ A mutable struct containing settings for debugging and benchmarking output. ## Fields - `output_benchmark_data::Bool` - Flag to output benchmark data for comparison between codes + - `gal_basis_output::Bool` - Write the raw Galerkin outer-region basis functions (per-interval, unconstrained at the rationals) under `GalerkinIntegration/Basis/`. Solver internals for development verification, not physics output. """ @kwdef mutable struct DebugSettings output_benchmark_data::Bool = false + gal_basis_output::Bool = false end """ @@ -152,7 +165,7 @@ A mutable struct holding internal state variables for stability calculations. - `q1lim::Float64` - Safety factor derivative at psilim - `wall_settings::Vacuum.WallShapeSettings` - Wall shape settings for vacuum calculations """ -@kwdef mutable struct ForceFreeStatesInternal +@kwdef mutable struct ForceFreeStatesInternal <: ModeSpace dir_path::String = "" mlow::Int = 0 mhigh::Int = 0 @@ -248,7 +261,7 @@ gpec.toml. - `HDF5_filename::String` - Name of HDF5 output file - `save_interval::Int` - Save every Nth ODE step (1=all, 10=every 10th). Always saves near rational surfaces. (Same as `euler_step` in 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 returns `u_store` / `du_store` / `xi_s_store` dense in the axis (EL) basis — the only convention PerturbedEquilibrium and FieldReconstruction consume correctly, and the only path that supports `kinetic_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 leaves `u_store` as sparse chunk-endpoint Riccati states, so dense ξ profiles are unavailable. `"galerkin"` is not yet a standalone integrator and currently errors — use `gal_flag = true` alongside another integrator. Requires `singfac_min != 0` for `"riccati"`. + - `integrator::String` - Which formalism integrates the Euler-Lagrange system. `"forward"` sweeps the plasma serially with Gaussian reduction and returns `u_store` / `du_store` / `xi_s_store` dense in the axis (EL) basis — the only convention PerturbedEquilibrium and FieldReconstruction consume correctly, and the only path that supports `kinetic_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 leaves `u_store` as 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, when `gal_match_flag` is set, the RPEC inner-layer-matched ξ; it computes its own vacuum response and returns no free-boundary energies, and does not support `kinetic_factor > 0`. Requires `singfac_min != 0` for `"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 below `2·msing + 3` is clamped up with a warning. Chunk sizing never consults `Threads.nthreads()`, so Riccati outputs are identical whatever thread count `julia -t` provides; threads only change wall-clock. - `extended_precision_bvp::Bool` - When `true` (default), promote the Δ' BVP linear system to `Complex{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. """ @@ -289,8 +302,7 @@ gpec.toml. nchunks::Int = 0 # Riccati chunk-count target; 0 = auto (derived from msing alone, never from Threads.nthreads()). extended_precision_bvp::Bool = true # Promote Δ' BVP to Complex{Double64}; default on (Float64 drifts the imaginary Δ' by 2–5× on DIIID-class cases). - # --- RDCON outer-region Galerkin Δ′ solver (gal_solve port) --- - gal_flag::Bool = false # enable outer-region Galerkin Δ′ solve + # --- RDCON outer-region Galerkin Δ′ solver (gal_solve port); selected by integrator = "galerkin" --- gal_solver::String = "LU" # "LU" (zgbtrf/zgbtrs) or "cholesky" (zpbtrf/zpbtrs) gal_nx::Int = 256 # elements per interval between singular surfaces gal_nq::Int = 6 # Gauss-Lobatto quadrature order per element @@ -623,3 +635,46 @@ end OdeState(numpert_total::Int, numsteps_init::Int, numunorms_init::Int, msing::Int) = OdeState(; numpert_total, numsteps_init, numunorms_init, msing) + +""" + DeltaPrimeData + +The 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 of `raw`. Same as `Delta` of 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 × numpert_total); 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 `gal_rpec_flag` columns (transposed at pack time + from the (numpert_total × 2msing) block the Galerkin solve produces). Empty when + neither ran. + - `A::Union{Nothing,Matrix{ComplexF64}}` - PEST-3 interchange↔interchange block + (msing × msing). Galerkin only; `nothing` for Riccati, which persists only `raw` and + recovers the blocks on demand via `pest3_decompose`. + - `B::Union{Nothing,Matrix{ComplexF64}}` - PEST-3 interchange↔tearing block; see `A`. + - `Gamma::Union{Nothing,Matrix{ComplexF64}}` - PEST-3 tearing↔interchange block; see `A`. +""" +struct DeltaPrimeData + matrix::Matrix{ComplexF64} + raw::Matrix{ComplexF64} + coil::Matrix{ComplexF64} + A::Union{Nothing,Matrix{ComplexF64}} + B::Union{Nothing,Matrix{ComplexF64}} + Gamma::Union{Nothing,Matrix{ComplexF64}} +end diff --git a/src/ForceFreeStates/Fourfit.jl b/src/ForceFreeStates/Fourfit.jl index 1ce7fde50..dc807f9df 100644 --- a/src/ForceFreeStates/Fourfit.jl +++ b/src/ForceFreeStates/Fourfit.jl @@ -143,7 +143,7 @@ each mapping ψ → flattened mpert² complex vector. Reference: [Logan et al., Phys. Plasmas 20, 122507 (2013)] """ function build_kinetic_metric_matrices(equil::Equilibrium.PlasmaEquilibrium, - intr::ForceFreeStatesInternal, + intr::ModeSpace, metric::MetricData) (; mpert, nlow, nhigh, mlow, mhigh) = intr mpsi = metric.mpsi diff --git a/src/ForceFreeStates/Free.jl b/src/ForceFreeStates/Free.jl index adf545b1c..ce3ac4ca7 100644 --- a/src/ForceFreeStates/Free.jl +++ b/src/ForceFreeStates/Free.jl @@ -54,6 +54,28 @@ downstream code consumes the stored ξ profiles. return odet end +""" + 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. +""" +function compute_scaled_wv(ctrl::ForceFreeStatesControl, equil::Equilibrium.PlasmaEquilibrium, intr::ForceFreeStatesInternal) + (; mlow, mhigh, nlow, nhigh, psilim, qlim, wall_settings) = intr + vac_inputs = Vacuum.VacuumInput(equil, psilim, ctrl.mthvac, ctrl.nzvac, mlow:mhigh, nlow:nhigh) + vac = Vacuum.compute_vacuum_response(vac_inputs, wall_settings) + wv = vac.wv + singfac = vec((mlow:mhigh) .- qlim .* (nlow:nhigh)') + wv .*= singfac .* singfac' + return wv, vac +end + """ free_run(odet::OdeState, ctrl::ForceFreeStatesControl, equil::Equilibrium.PlasmaEquilibrium, ffit::FourFitVars, intr::ForceFreeStatesInternal) -> FreeBoundaryResult @@ -66,7 +88,7 @@ calculations and data dumping. @with_pool pool function free_run(odet::OdeState, ctrl::ForceFreeStatesControl, equil::Equilibrium.PlasmaEquilibrium, ffit::FourFitVars, intr::ForceFreeStatesInternal) # Initializations and allocations - (; mpert, mlow, mhigh, numpert_total, psilim, qlim, npert, nlow, nhigh, wall_settings) = intr + (; mpert, numpert_total, psilim, npert) = intr wpt = zeros!(pool, ComplexF64, numpert_total, numpert_total) wvt = zeros!(pool, ComplexF64, numpert_total, numpert_total) tmp_mat = zeros!(pool, ComplexF64, numpert_total, numpert_total) @@ -78,14 +100,7 @@ calculations and data dumping. wp = zeros(ComplexF64, numpert_total, numpert_total) @views wp .= (odet.u[:, :, 2] / odet.u[:, :, 1]) ./ equil.psio^2 - # Compute vacuum response (handles 2D single-n, 2D multi-n block-diagonal, and 3D) - vac_inputs = Vacuum.VacuumInput(equil, psilim, ctrl.mthvac, ctrl.nzvac, mlow:mhigh, nlow:nhigh) - vac = Vacuum.compute_vacuum_response(vac_inputs, wall_settings) - wv = vac.wv - - # Scale by (m - n*q)(m' - n'*q) [Chance Phys. Plasmas 1997 2161 eq. 126] - singfac = vec((mlow:mhigh) .- qlim .* (nlow:nhigh)') - wv .*= singfac .* singfac' + wv, vac = compute_scaled_wv(ctrl, equil, intr) # Power-normalization matrix N at the plasma edge: ξ†·N·ξ = ⟨|ξ|²⟩ (see power_norm_matrix!). # The Jacobian band is evaluated at psilim (same surface as W), not at the last grid surface. diff --git a/src/ForceFreeStates/Galerkin/GalerkinMatch.jl b/src/ForceFreeStates/Galerkin/GalerkinMatch.jl index d9c7a4a81..7a6f2d978 100644 --- a/src/ForceFreeStates/Galerkin/GalerkinMatch.jl +++ b/src/ForceFreeStates/Galerkin/GalerkinMatch.jl @@ -13,17 +13,17 @@ # identity-at-edge basis (coil column j has ξ_edge = e_j). """ - gal_match_rpec(ctrl, equil, intr, gal_result) -> GalMatchResult + gal_match_rpec(ctrl, equil, intr, gal_result, dp) -> GalMatchResult -Solve the coil-driven RPEC matching from the outer Δ′ (`gal_result`) and the per-surface inner-layer Δ. -Requires `gal_result.solution` (reconstructed outer ξ/ξ′) and the rpec coil block `gal_result.delta_coil`. +Solve the coil-driven RPEC matching from the outer Δ′ payload `dp` and the per-surface inner-layer Δ. +Requires `gal_result.solution` (reconstructed outer ξ/ξ′) and the rpec coil block `dp.coil`. The resistive path uses per-surface inputs `ctrl.gal_eta`/`gal_rho`/`gal_rotation` (length `msing`) + `ctrl.gal_gamma`. When `ctrl.gal_ideal_flag`, the inner layer is skipped and `cout=0`, so the matched solution is the bare ideal coil column (Fortran rmatch `coil%ideal_flag`) — the DCON/EL reference. """ function gal_match_rpec(ctrl::ForceFreeStatesControl, equil, intr::ForceFreeStatesInternal, - gal_result::GalerkinResult) + gal_result::GalerkinResult, dp::DeltaPrimeData) msing = gal_result.msing mpert = intr.numpert_total @@ -32,8 +32,8 @@ function gal_match_rpec(ctrl::ForceFreeStatesControl, equil, intr::ForceFreeStat gal_result.solution !== nothing || error("gal_match_rpec: gal_result.solution is missing (need the gal-reconstructed ξ/ξ′)") - isempty(gal_result.delta_coil) && - error("gal_match_rpec: delta_coil is empty — gal_rpec_flag must be true for RPEC matching") + isempty(dp.coil) && + error("gal_match_rpec: the coil-response block is empty — gal_rpec_flag must be true for RPEC matching") if ctrl.gal_ideal_flag # Ideal limit (Fortran rmatch coil%ideal_flag, match.f): skip the inner layer entirely @@ -119,11 +119,10 @@ function gal_match_rpec(ctrl::ForceFreeStatesControl, equil, intr::ForceFreeStat end # --- assemble the 4·msing matching system (match.f) --- - delta_out = gal_result.delta[1:2msing, 1:2msing] # outer Δ′ plasma block mat = zeros(ComplexF64, 4msing, 4msing) rmat = zeros(ComplexF64, 4msing, mcoil) - @views mat[(2msing+1):4msing, 1:2msing] .= transpose(delta_out) # Δ_out - @views rmat[(2msing+1):4msing, :] .= .-transpose(gal_result.delta_coil) # −Δ_coil source + @views mat[(2msing+1):4msing, 1:2msing] .= transpose(dp.raw) # Δ_out + @views rmat[(2msing+1):4msing, :] .= .-dp.coil # −Δ_coil source (already surface-side × edge mode) for ising in 1:msing idx1 = 2ising - 1 idx2 = 2ising @@ -233,52 +232,3 @@ function gal_match_rpec(ctrl::ForceFreeStatesControl, equil, intr::ForceFreeStat return GalMatchResult(cout, cin, xi, xi_deriv, deltar, bpen, inner_psi, inner_xi, inner_b, inner_params, rpec_eig, residual) end - -""" - gal_matched_odestate(gal_result, ffit, intr) -> OdeState - -Pack the RPEC-matched outer solution into an `OdeState` shaped exactly like the forward integrator's, -so `PerturbedEquilibrium` consumes it unchanged. Mirrors Fortran `idcon_build`'s gal branch -(idcon.f) and `globalsol.bin` (the on-surface `issing` points are dropped, match.f): - - - `u_store[:,:,1,ip] = ξ_ψ` (matched fundamental matrix, mode×coil-drive, identity-at-edge basis) - - `du_store[:,:,1,ip] = dξ_ψ/dψ` (analytic) - - `xi_s_store[:,:,ip] = ξ_s = −A⁻¹(B·ξ′ + C·ξ)` via `ffit` (same outer ideal-MHD relation as `sing_der!`) - - `u_store[:,:,2] = 0` (PE never reads it; matches Fortran's unused u2 in the gal path) - -The grid is the gal-native grid (inner→edge); `step` indexes the edge so `build_flux_matrix` derives the -edge BC from `u_store[:,:,1,step]`. -""" -function gal_matched_odestate(gal_result::GalerkinResult, ffit::FourFitVars, intr::ForceFreeStatesInternal) - gal_result.match !== nothing || error("gal_matched_odestate: no match result (run with gal_match_flag=true)") - sol = gal_result.solution - m = gal_result.match - mpert = intr.numpert_total - - # Drop the on-surface (issing) grid points — zero placeholders where the resonant series diverges. - keep = .!sol.issing - psi_f = sol.psi[keep] - q_f = sol.q[keep] - xi_f = m.xi[:, keep, :] # (mpert, ngrid_f, mcoil) - dxi_f = m.xi_deriv[:, keep, :] - ngrid_f = length(psi_f) - - u_store = zeros(ComplexF64, mpert, mpert, 2, ngrid_f) - du_store = zeros(ComplexF64, mpert, mpert, ngrid_f) - xi_s_store = zeros(ComplexF64, mpert, mpert, ngrid_f) - - hint = Ref(1) - for ip in 1:ngrid_f - ξ = @view xi_f[:, ip, :] - ξ′ = @view dxi_f[:, ip, :] - @views u_store[:, :, 1, ip] .= ξ - @views du_store[:, :, ip] .= ξ′ - # ξ_s = −A⁻¹(B·ξ′ + C·ξ), the same node quantity the Euler-Lagrange path computes. - @views compute_node_xi_s!(xi_s_store[:, :, ip], ξ′, ξ, ffit, psi_f[ip]; hint=hint) - end - - # Derivatives here are the analytic galerkin ξ′, not recomputable from the ODE kernel. - return OdeState(; numpert_total=mpert, numunorms_init=1, msing=gal_result.msing, numsteps_init=ngrid_f, - step=ngrid_f, total_steps=ngrid_f, psi_store=psi_f, q_store=q_f, u_store=u_store, du_store=du_store, - xi_s_store=xi_s_store, du_store_populated=true) -end diff --git a/src/ForceFreeStates/Galerkin/GalerkinSolve.jl b/src/ForceFreeStates/Galerkin/GalerkinSolve.jl index a67f1c797..66dddd893 100644 --- a/src/ForceFreeStates/Galerkin/GalerkinSolve.jl +++ b/src/ForceFreeStates/Galerkin/GalerkinSolve.jl @@ -41,18 +41,17 @@ end """Empty `GalerkinResult` for a domain with no resonant surfaces.""" function empty_galerkin_result() - empty2 = Matrix{ComplexF64}(undef, 0, 0) - return GalerkinResult(empty2, empty2, empty2, empty2, empty2, 0, - Float64[], Float64[], Int[], Int[], Float64[], ComplexF64[], empty2, nothing, nothing) + return GalerkinResult(0, Float64[], Float64[], Int[], Int[], Float64[], ComplexF64[], nothing, nothing) end """ galerkin_solve(ctrl::ForceFreeStatesControl, equil, ffit::FourFitVars, - intr::ForceFreeStatesInternal; wv=nothing) -> GalerkinResult + intr::ForceFreeStatesInternal; wv=nothing) -> (GalerkinResult, Union{Nothing,DeltaPrimeData}) Compute the outer-region Δ′ matching matrix by the singular Galerkin method. Port of `gal_solve` -(gal.f). Single toroidal mode only (`intr.npert == 1`). Returns a `GalerkinResult`; if there -are no resonant surfaces in the domain it returns an empty result. +(gal.f). Single toroidal mode only (`intr.npert == 1`). Returns the solver internals as a +`GalerkinResult` alongside the Δ′ payload as the shared [`DeltaPrimeData`](@ref); if there are no +resonant surfaces in the domain it returns an empty result and `nothing`. `wv` is the vacuum energy matrix from `free_run`, which supplies the free-boundary edge term `wv_edge = wv · psio²`; pass `nothing` for a fixed-boundary edge. @@ -70,7 +69,7 @@ function galerkin_solve(ctrl::ForceFreeStatesControl, equil, ffit::FourFitVars, msing = length(sings) if msing == 0 ctrl.verbose && @info "galerkin_solve: no resonant surfaces in domain; skipping Δ′ solve" - return empty_galerkin_result() + return empty_galerkin_result(), nothing end ctrl.verbose && @info "Starting outer-region Galerkin Δ′ solve (msing=$msing, solver=$(ctrl.gal_solver))" @@ -173,8 +172,13 @@ function galerkin_solve(ctrl::ForceFreeStatesControl, equil, ffit::FourFitVars, # from the right series — the left series reuses the same α via alpha_override above). di = [real(-asymps[i].right.alpha[1]^2) for i in 1:msing] alpha = [asymps[i].right.alpha[1] for i in 1:msing] - # Coil-response block (rpec_flag): rows 2*msing+1 : 2*msing+mpert of delta (empty otherwise). - delta_coil = ncoil > 0 ? delta[(2*msing+1):(2*msing+ncoil), :] : Matrix{ComplexF64}(undef, 0, 0) + + # Pack the Δ′ payload in the shared layout: the raw D′ is the leading 2msing×2msing side-major + # block, and the coil-response rows 2*msing+1 : 2*msing+mpert (rpec_flag) transpose into the + # (surface-side × edge mode) orientation the Riccati BVP also produces. + dp_raw = delta[1:(2*msing), 1:(2*msing)] + dp_coil = ncoil > 0 ? permutedims(delta[(2*msing+1):(2*msing+ncoil), :]) : Matrix{ComplexF64}(undef, 0, 0) + dp = DeltaPrimeData(Deltap, dp_raw, dp_coil, Ap, Bp, Gammap) # Reconstruct ξ(ψ) AND analytic ξ′(ψ) on the gal-native grid (gal_output_solution). ctrl.verbose && @info "Reconstructing outer-region ξ and analytic ξ′ on the gal grid" @@ -185,21 +189,19 @@ function galerkin_solve(ctrl::ForceFreeStatesControl, equil, ffit::FourFitVars, sing_q = [s.q for s in sings] sing_m = [s.m[1] for s in sings] sing_n = [s.n[1] for s in sings] - result = GalerkinResult(delta, Ap, Bp, Gammap, Deltap, msing, - sing_psi, sing_q, sing_m, sing_n, di, alpha, delta_coil, solution, nothing) + result = GalerkinResult(msing, sing_psi, sing_q, sing_m, sing_n, di, alpha, solution, nothing) # DRIVEN (RPEC) outer↔inner matching: build the coil-driven matched ξ/ξ′ (gal_match_rpec). - ctrl.gal_match_flag || return result + ctrl.gal_match_flag || return result, dp ctrl.gal_rpec_flag || error("galerkin_solve: gal_match_flag=true requires gal_rpec_flag=true") ctrl.verbose && @info( ctrl.gal_ideal_flag ? "RPEC matching: IDEAL solution (inner layer skipped, bare coil columns)" : "RPEC matching: inner-layer Δ(Q) + outer↔inner solve for the coil-driven ξ" ) - match = gal_match_rpec(ctrl, equil, intr, result) + match = gal_match_rpec(ctrl, equil, intr, result, dp) ctrl.gal_ideal_flag || (ctrl.verbose && @info "RPEC matching: linear-solve residual = $(match.residual)") - return GalerkinResult(delta, Ap, Bp, Gammap, Deltap, msing, - sing_psi, sing_q, sing_m, sing_n, di, alpha, delta_coil, solution, match) + return GalerkinResult(msing, sing_psi, sing_q, sing_m, sing_n, di, alpha, solution, match), dp end """ @@ -227,51 +229,44 @@ function gal_pest3_blocks(delta::Matrix{ComplexF64}, msing::Int) end """ - write_galerkin!(out_h5, result::GalerkinResult) - -Write the Galerkin outputs into the open HDF5 file. The integrator's solution functions and -RPEC matching data go under `ForceFreeStates/Solutions/GalerkinIntegration/`; the per-surface -Δ′/PEST-3 matching results consolidate with the other rational-surface stability results under -`SingularSurfaces/GalerkinDeltaPrime/`. Replaces the Fortran `delta_gw`/`pest3_data` -ASCII/binary outputs. + write_galerkin!(out_h5, result::GalerkinResult; basis_output=false) + +Write the Galerkin solver outputs into the open HDF5 file, under +`ForceFreeStates/Solutions/GalerkinIntegration/`: the matching diagnostics and the surface list +the solve ran over (a subset of `SingularSurfaces/` when the domain or the m-band excludes +rationals). The Δ′/PEST-3 matrices and the closed ξ profiles are NOT written here — both go to +formalism-independent homes from the driver writer (`SingularSurfaces/` off `result.delta_prime`; +the shared `Solutions/` profile layout off `result.solution`). With `basis_output` the raw +outer-region basis functions (per-interval, unconstrained at the rationals — solver internals) +are dumped under `Basis/` in the shared axis order. Replaces the Fortran +`delta_gw`/`pest3_data` ASCII/binary outputs. """ -function write_galerkin!(out_h5, result::GalerkinResult) +function write_galerkin!(out_h5, result::GalerkinResult; basis_output::Bool=false) gal = "ForceFreeStates/Solutions/GalerkinIntegration" - gdp = "SingularSurfaces/GalerkinDeltaPrime" out_h5["$gal/rational_count"] = result.msing if result.msing == 0 annotate_galerkin!(out_h5) return nothing end - out_h5["$gdp/Delta_prime_raw"] = result.delta - out_h5["$gdp/pest3_A"] = result.Ap - out_h5["$gdp/pest3_B"] = result.Bp - out_h5["$gdp/pest3_Gamma"] = result.Gammap - out_h5["$gdp/pest3_Delta"] = result.Deltap - out_h5["$gdp/rational_psi"] = result.sing_psi - out_h5["$gdp/rational_q"] = result.sing_q - out_h5["$gdp/rational_m"] = result.sing_m - out_h5["$gdp/rational_n"] = result.sing_n - out_h5["$gdp/D_I"] = result.di - out_h5["$gdp/alpha"] = result.alpha - if !isempty(result.delta_coil) - out_h5["$gdp/Delta_coil"] = result.delta_coil - end - if result.solution !== nothing + out_h5["$gal/rational_psi"] = result.sing_psi + out_h5["$gal/rational_q"] = result.sing_q + out_h5["$gal/rational_m"] = result.sing_m + out_h5["$gal/rational_n"] = result.sing_n + out_h5["$gal/D_I"] = result.di + out_h5["$gal/alpha"] = result.alpha + if basis_output && result.solution !== nothing sol = result.solution - out_h5["$gal/Solution/psi"] = sol.psi - out_h5["$gal/Solution/is_rational"] = collect(sol.issing) - out_h5["$gal/Solution/xi_psi"] = sol.xi - out_h5["$gal/Solution/dxi_psidpsi"] = sol.xi_deriv - isempty(sol.xi_cut) || (out_h5["$gal/Solution/xi_psi_cut"] = sol.xi_cut) - isempty(sol.cut_range) || (out_h5["$gal/Solution/cut_range"] = sol.cut_range) + out_h5["$gal/Basis/psi"] = sol.psi + out_h5["$gal/Basis/is_rational"] = collect(sol.issing) + out_h5["$gal/Basis/xi_psi"] = permutedims(sol.xi, (1, 3, 2)) + out_h5["$gal/Basis/dxi_psidpsi"] = permutedims(sol.xi_deriv, (1, 3, 2)) + isempty(sol.xi_cut) || (out_h5["$gal/Basis/xi_psi_cut"] = permutedims(sol.xi_cut, (1, 3, 2))) + isempty(sol.cut_range) || (out_h5["$gal/Basis/cut_range"] = sol.cut_range) end if result.match !== nothing m = result.match out_h5["$gal/Match/cout"] = m.cout out_h5["$gal/Match/cin"] = m.cin - out_h5["$gal/Match/xi"] = m.xi - out_h5["$gal/Match/dxidpsi"] = m.xi_deriv out_h5["$gal/Match/Delta_r"] = m.deltar out_h5["$gal/Match/bpen"] = m.bpen out_h5["$gal/Match/rpec_eig"] = m.rpec_eig @@ -300,35 +295,43 @@ end # metadata contract; see docs/development/hdf5-conventions.md). const GALERKIN_H5_ANNOTATIONS = [ "ForceFreeStates/Solutions/GalerkinIntegration/rational_count" => (; long_name="number of rational (singular) surfaces in the Galerkin solve"), - "ForceFreeStates/Solutions/GalerkinIntegration/Solution/psi" => (; long_name="normalized poloidal flux ψ_N grid of the Galerkin solution", scale="psi"), - "ForceFreeStates/Solutions/GalerkinIntegration/Solution/is_rational" => - (; long_name="flag: grid node lies on a rational surface", dims=("psi",), attach=(1 => "ForceFreeStates/Solutions/GalerkinIntegration/Solution/psi",)), - "ForceFreeStates/Solutions/GalerkinIntegration/Solution/xi_psi" => - (; long_name="Galerkin solution functions ξ^ψ (arbitrary amplitude; note the psi/solution axis order differs from ForwardIntegration)", dims=("mode", "psi", "solution")), - "ForceFreeStates/Solutions/GalerkinIntegration/Solution/dxi_psidpsi" => - (; long_name="ψ_N derivative of the Galerkin solution functions ξ^ψ (arbitrary amplitude)", dims=("mode", "psi", "solution")), - "ForceFreeStates/Solutions/GalerkinIntegration/Solution/xi_psi_cut" => - (; long_name="Galerkin solution functions ξ^ψ with the leading-order resonant response excised", dims=("mode", "psi", "solution")), - "ForceFreeStates/Solutions/GalerkinIntegration/Solution/cut_range" => + "ForceFreeStates/Solutions/GalerkinIntegration/psi" => (; long_name="normalized poloidal flux ψ_N grid of the closed Galerkin solution", scale="psi_gal"), + "ForceFreeStates/Solutions/GalerkinIntegration/q" => + (; long_name="safety factor q on the Galerkin solution grid", dims=("psi",), attach=(1 => "ForceFreeStates/Solutions/GalerkinIntegration/psi",)), + "ForceFreeStates/Solutions/GalerkinIntegration/xi_psi" => + (; long_name="closed axis-to-edge ξ^ψ profiles (ideal or inner-layer closure; identity-at-edge basis)", dims=("mode", "solution", "psi"), attach=(3 => "ForceFreeStates/Solutions/GalerkinIntegration/psi",)), + "ForceFreeStates/Solutions/GalerkinIntegration/dxi_psidpsi" => + (; long_name="analytic ψ_N derivative of the closed ξ^ψ profiles", dims=("mode", "solution", "psi"), attach=(3 => "ForceFreeStates/Solutions/GalerkinIntegration/psi",)), + "ForceFreeStates/Solutions/GalerkinIntegration/xi_s" => + (; long_name="surface displacement ξ_s from the outer ideal-MHD relation", dims=("mode", "solution", "psi"), attach=(3 => "ForceFreeStates/Solutions/GalerkinIntegration/psi",)), + "ForceFreeStates/Solutions/GalerkinIntegration/Basis/psi" => (; long_name="normalized poloidal flux ψ_N grid of the raw Galerkin basis", scale="psi_gal_basis"), + "ForceFreeStates/Solutions/GalerkinIntegration/Basis/is_rational" => + (; long_name="flag: grid node lies on a rational surface", dims=("psi",), attach=(1 => "ForceFreeStates/Solutions/GalerkinIntegration/Basis/psi",)), + "ForceFreeStates/Solutions/GalerkinIntegration/Basis/xi_psi" => + (; long_name="raw Galerkin outer-region basis functions ξ^ψ (per-interval, unconstrained at the rationals; debug output)", dims=("mode", "solution", "psi")), + "ForceFreeStates/Solutions/GalerkinIntegration/Basis/dxi_psidpsi" => + (; long_name="ψ_N derivative of the raw Galerkin basis functions (debug output)", dims=("mode", "solution", "psi")), + "ForceFreeStates/Solutions/GalerkinIntegration/Basis/xi_psi_cut" => + (; long_name="raw Galerkin basis functions with the leading-order resonant response excised (debug output)", dims=("mode", "solution", "psi")), + "ForceFreeStates/Solutions/GalerkinIntegration/Basis/cut_range" => (; long_name="ψ_N bounds of the excised resonant + extension cells per surface", dims=("surface", "bound")), - "SingularSurfaces/GalerkinDeltaPrime/Delta_prime_raw" => - (; long_name="outer-region Δ' matrix (2msing×2msing, side-major [L_s1, R_s1, ...])", dims=("surface_side_row", "surface_side_col")), - "SingularSurfaces/GalerkinDeltaPrime/pest3_A" => (; long_name="PEST-3 matching block A' (Galerkin outer region)", dims=("surface_row", "surface_col")), - "SingularSurfaces/GalerkinDeltaPrime/pest3_B" => (; long_name="PEST-3 matching block B' (Galerkin outer region)", dims=("surface_row", "surface_col")), - "SingularSurfaces/GalerkinDeltaPrime/pest3_Gamma" => (; long_name="PEST-3 matching block Γ' (Galerkin outer region)", dims=("surface_row", "surface_col")), - "SingularSurfaces/GalerkinDeltaPrime/pest3_Delta" => (; long_name="PEST-3 matching block Δ' (Galerkin outer region)", dims=("surface_row", "surface_col")), - "SingularSurfaces/GalerkinDeltaPrime/rational_psi" => (; long_name="normalized poloidal flux ψ_N of each rational surface", scale="psi_rational"), - "SingularSurfaces/GalerkinDeltaPrime/rational_q" => - (; long_name="safety factor q = m/n at each rational surface", dims=("surface",), attach=(1 => "SingularSurfaces/GalerkinDeltaPrime/rational_psi",)), - "SingularSurfaces/GalerkinDeltaPrime/rational_m" => - (; long_name="resonant poloidal mode number m at each rational surface", dims=("surface",), attach=(1 => "SingularSurfaces/GalerkinDeltaPrime/rational_psi",)), - "SingularSurfaces/GalerkinDeltaPrime/rational_n" => - (; long_name="resonant toroidal mode number n at each rational surface", dims=("surface",), attach=(1 => "SingularSurfaces/GalerkinDeltaPrime/rational_psi",)), - "SingularSurfaces/GalerkinDeltaPrime/D_I" => - (; long_name="Mercier D_I at each rational surface", dims=("surface",), attach=(1 => "SingularSurfaces/GalerkinDeltaPrime/rational_psi",)), - "SingularSurfaces/GalerkinDeltaPrime/alpha" => - (; long_name="Frobenius small-solution exponent α at each rational surface", dims=("surface",), attach=(1 => "SingularSurfaces/GalerkinDeltaPrime/rational_psi",)), - "SingularSurfaces/GalerkinDeltaPrime/Delta_coil" => (; long_name="edge coil-response matrix (edge mode × surface-side; RPEC columns)", dims=("mode", "surface_side")) + "ForceFreeStates/Solutions/GalerkinIntegration/rational_psi" => + (; long_name="normalized poloidal flux ψ_N of each rational surface in the Galerkin solve", scale="psi_gal_rational"), + "ForceFreeStates/Solutions/GalerkinIntegration/rational_q" => + (; long_name="safety factor q = m/n at each rational surface in the Galerkin solve", dims=("surface",), + attach=(1 => "ForceFreeStates/Solutions/GalerkinIntegration/rational_psi",)), + "ForceFreeStates/Solutions/GalerkinIntegration/rational_m" => + (; long_name="resonant poloidal mode number m at each rational surface in the Galerkin solve", dims=("surface",), + attach=(1 => "ForceFreeStates/Solutions/GalerkinIntegration/rational_psi",)), + "ForceFreeStates/Solutions/GalerkinIntegration/rational_n" => + (; long_name="resonant toroidal mode number n at each rational surface in the Galerkin solve", dims=("surface",), + attach=(1 => "ForceFreeStates/Solutions/GalerkinIntegration/rational_psi",)), + "ForceFreeStates/Solutions/GalerkinIntegration/D_I" => + (; long_name="Mercier D_I at each rational surface in the Galerkin solve", dims=("surface",), + attach=(1 => "ForceFreeStates/Solutions/GalerkinIntegration/rational_psi",)), + "ForceFreeStates/Solutions/GalerkinIntegration/alpha" => + (; long_name="Frobenius small-solution exponent α at each rational surface in the Galerkin solve", dims=("surface",), + attach=(1 => "ForceFreeStates/Solutions/GalerkinIntegration/rational_psi",)) ] # Attach long_name/units/dims + dimension scales (declared in-table) to everything diff --git a/src/ForceFreeStates/Galerkin/GalerkinStructs.jl b/src/ForceFreeStates/Galerkin/GalerkinStructs.jl index cbfd59d17..f4e79deb0 100644 --- a/src/ForceFreeStates/Galerkin/GalerkinStructs.jl +++ b/src/ForceFreeStates/Galerkin/GalerkinStructs.jl @@ -188,26 +188,24 @@ end """ GalerkinResult -Outputs of the outer-region Galerkin solve. +Solver internals and FEM diagnostics of the outer-region Galerkin solve. Its Δ′ payload (Δ′ +matrix, raw D′, PEST-3 blocks, coil-response block) is NOT carried here: `galerkin_solve` +returns it separately as a [`DeltaPrimeData`](@ref), the one formalism-independent Δ′ type, +published as `ForceFreeStatesResult.delta_prime`. ## Fields - - `delta::Matrix{ComplexF64}` — Δ′ matrix, `(nsol, 2*msing)` with `nsol = 2*msing`; the small - resonant coefficients (Fortran `delta`, gal.f). - - `Ap, Bp, Gammap, Deltap::Matrix{ComplexF64}` — PEST-3 matching blocks, each `(msing, msing)` - (Fortran `gal_write_pest3_data`, gal.f). - - `msing::Int` — number of resonant singular surfaces included. - - `sing_psi, sing_q::Vector{Float64}`, `sing_m, sing_n::Vector{Int}` — per-surface identifiers. + - `msing::Int` — number of resonant singular surfaces in the Galerkin solve. May be fewer than + `result.surfaces`: only surfaces inside the integration domain and the resolved m-band are + kept (`gal_resonant_surfaces`). + - `sing_psi, sing_q::Vector{Float64}`, `sing_m, sing_n::Vector{Int}` — per-surface identifiers, + core→edge, in the order the Δ′ matrices are indexed. - `di::Vector{Float64}`, `alpha::Vector{ComplexF64}` — Mercier index and exponent per surface. - `solution::Union{Nothing,GalerkinSolution}` — reconstructed radial ξ(ψ) and analytic ξ′(ψ) on the gal-native grid; `nothing` if no resonant surfaces. + - `match::Union{Nothing,GalMatchResult}` — RPEC matched solution (`gal_match_flag`); `nothing` otherwise. """ struct GalerkinResult - delta::Matrix{ComplexF64} - Ap::Matrix{ComplexF64} - Bp::Matrix{ComplexF64} - Gammap::Matrix{ComplexF64} - Deltap::Matrix{ComplexF64} msing::Int sing_psi::Vector{Float64} sing_q::Vector{Float64} @@ -215,7 +213,6 @@ struct GalerkinResult sing_n::Vector{Int} di::Vector{Float64} alpha::Vector{ComplexF64} - delta_coil::Matrix{ComplexF64} # (mpert, 2*msing) coil-response block (rpec_flag); empty if not computed solution::Union{Nothing,GalerkinSolution} - match::Union{Nothing,GalMatchResult} # RPEC matched solution (gal_match_flag); nothing otherwise + match::Union{Nothing,GalMatchResult} end diff --git a/src/ForceFreeStates/Integrators.jl b/src/ForceFreeStates/Integrators.jl new file mode 100644 index 000000000..8b123b2d0 --- /dev/null +++ b/src/ForceFreeStates/Integrators.jl @@ -0,0 +1,190 @@ +""" + AbstractIntegrator + +Supertype of the three force-free-states formalisms selected by the scripting API +`solve(equil, alg; ...)`: [`Forward`](@ref), [`Riccati`](@ref) and [`Galerkin`](@ref). + +An integrator object is pure configuration. `solve` translates it into the matching +`ForceFreeStatesControl` keywords, so the struct fields and the TOML `[ForceFreeStates]` +keys always describe the same solve — `ForceFreeStatesControl` stays the single source of +truth and the TOML path is unaffected. +""" +abstract type AbstractIntegrator end + +""" + Forward() + +Serial Euler-Lagrange integrator: sweeps the full radial domain and stores the dense ξ +solution. The only formalism that supports kinetic runs and the only one whose solution +feeds the profile-based PerturbedEquilibrium outputs. Maps onto `integrator = "forward"`. +""" +struct Forward <: AbstractIntegrator end + +""" + Riccati(; nchunks=0) + +STRIDE-style chunked Riccati integrator: solves the Euler-Lagrange system on independent +radial chunks and couples them through a boundary-value problem, which is what unlocks the +inter-surface Δ′ matrix. Threads come from `julia -t`; the chunk count is the only tunable +and never depends on the thread count. Maps onto `integrator = "riccati"`. + +## Fields + + - `nchunks::Int` - Chunk-count target; `0` derives it from the number of singular surfaces. +""" +@kwdef struct Riccati <: AbstractIntegrator + nchunks::Int = 0 +end + +""" + Galerkin(; solver="LU", nx=256, ...) + +RDCON outer-region singular Galerkin solver: solves the same Euler-Lagrange system +variationally on a finite-element grid packed around the rational surfaces, producing Δ′ +without a radial ODE sweep. Maps onto `integrator = "galerkin"`; every field is the +matching `gal_*` control key without the prefix. + +## Fields + + - `solver::String` - Banded linear solver, `"LU"` (zgbtrf/zgbtrs) or `"cholesky"` (zpbtrf/zpbtrs). Matching requires `"LU"`. + - `nx::Int` - Elements per interval between singular surfaces. + - `nq::Int` - Gauss-Lobatto quadrature order per element. + - `pfac::Float64` - Grid packing ratio near singular surfaces. + - `dx0::Float64` - Resonant-element integration truncation distance, in units of 1/|n q′|. + - `dx1::Float64` - Resonant-element size, in units of 1/|n q′|. + - `dx2::Float64` - Extension-element size, in units of 1/|n q′|. + - `cutoff::Int` - Number of elements carrying the large solution as driving term. + - `tol::Float64` - Resonant-quadrature tolerance. + - `gnstep::Int` - Maximum resonant-quadrature evaluations. + - `dx1dx2_flag::Bool` - Enable the special dx1/dx2 treatment of resonant and extension elements. + - `sing_order::Int` - Base power-series order for the singular asymptotics. + - `sing_order_ceiling::Bool` - Auto-raise the order per surface for a high Mercier index. + - `rpec_flag::Bool` - Append the mpert coil-response columns to the Δ′ solve. Forced on when a [`ResistiveMatch`](@ref) is requested. + - `edge_onesided::Bool` - Pack the two end intervals one-sided toward their single rational end instead of the Fortran symmetric pack. +""" +@kwdef struct Galerkin <: AbstractIntegrator + solver::String = "LU" + nx::Int = 256 + nq::Int = 6 + pfac::Float64 = 0.001 + dx0::Float64 = 5e-4 + dx1::Float64 = 1e-3 + dx2::Float64 = 1e-3 + cutoff::Int = 10 + tol::Float64 = 1e-10 + gnstep::Int = 20000 + dx1dx2_flag::Bool = true + sing_order::Int = 6 + sing_order_ceiling::Bool = true + rpec_flag::Bool = false + edge_onesided::Bool = false +end + +""" + ResistiveMatch(; eta=[], rho=[], rotation=[], gamma=5/3, ideal=false, inner_solver="ray", ...) + +Inner-layer matching configuration, passed to `solve` as `match=` and independent of the +integrator that produced the outer solution. Requesting a match closes the basis with a +resistive inner-layer solution instead of the ideal jump condition, so the result carries +`closure = :matched` and a non-zero `bpen`. + +Only [`Galerkin`](@ref) implements the match today; a `Riccati` or `Forward` solve with +`match` set errors. The per-surface vectors are ordered core to edge and must have one +entry per matched rational surface. + +## Fields + + - `eta::Vector{Float64}` - Per-surface resistivity η. + - `rho::Vector{Float64}` - Per-surface mass density ρ in kg/m³. + - `rotation::Vector{Float64}` - Per-surface rotation frequency f in Hz; the forced eigenvalue is γ_s = 2πi·n·f. + - `gamma::Float64` - Ratio of specific heats Γ in the resistive-layer coefficients. + - `ideal::Bool` - Build the ideal (perfectly shielded) matched solution: skip the inner layer and use the bare coil columns. `eta`, `rho` and `rotation` are then unread. + - `inner_solver::String` - Inner-layer Δ backend, `"ray"` (rotated-contour collocation) or `"galerkin"` (Hermite-cubic elements). + - `inner_xfac::Float64` - Asymptotic-matching radius multiplier of the `"galerkin"` backend. + - `inner_nx::Int` - Grid cells of the `"galerkin"` backend. + - `inner_nq::Int` - Quadrature order per cell of the `"galerkin"` backend. + - `inner_cutoff::Int` - Cells carrying the large solution as driving term in the `"galerkin"` backend. + - `inner_kmax::Int` - Large-x asymptotic series order of the `"galerkin"` backend. +""" +@kwdef struct ResistiveMatch + eta::Vector{Float64} = Float64[] + rho::Vector{Float64} = Float64[] + rotation::Vector{Float64} = Float64[] + gamma::Float64 = 5 / 3 + ideal::Bool = false + inner_solver::String = "ray" + inner_xfac::Float64 = 10.0 + inner_nx::Int = 1280 + inner_nq::Int = 5 + inner_cutoff::Int = 5 + inner_kmax::Int = 8 +end + +""" + _integrator_symbol(alg) -> Symbol + +The `ForceFreeStatesControl.integrator` token an [`AbstractIntegrator`](@ref) selects. +""" +_integrator_symbol(::Forward) = :forward +_integrator_symbol(::Riccati) = :riccati +_integrator_symbol(::Galerkin) = :galerkin + +""" + _set_ctrl!(kwargs, key, value, source) -> kwargs + +Write one `ForceFreeStatesControl` keyword derived from `source`, rejecting a duplicate the +caller also passed to `solve` — the same knob would otherwise be set in two places. +""" +function _set_ctrl!(kwargs::Dict{Symbol,Any}, key::Symbol, value, source) + haskey(kwargs, key) && + error("`$key` is controlled by the $(nameof(typeof(source))) object; set it there instead of as a `solve` keyword") + kwargs[key] = value + return kwargs +end + +""" + _apply_alg!(kwargs, alg) -> kwargs + +Translate an [`AbstractIntegrator`](@ref) into `ForceFreeStatesControl` keywords on +`kwargs`. Pure translation: every field maps onto the control key of the same meaning. +""" +function _apply_alg!(kwargs::Dict{Symbol,Any}, alg::AbstractIntegrator) + return _set_ctrl!(kwargs, :integrator, String(_integrator_symbol(alg)), alg) +end + +function _apply_alg!(kwargs::Dict{Symbol,Any}, alg::Riccati) + _set_ctrl!(kwargs, :integrator, String(_integrator_symbol(alg)), alg) + return _set_ctrl!(kwargs, :nchunks, alg.nchunks, alg) +end + +function _apply_alg!(kwargs::Dict{Symbol,Any}, alg::Galerkin) + _set_ctrl!(kwargs, :integrator, String(_integrator_symbol(alg)), alg) + for name in fieldnames(Galerkin) + _set_ctrl!(kwargs, Symbol(:gal_, name), getfield(alg, name), alg) + end + return kwargs +end + +""" + _apply_match!(kwargs, match, alg) -> kwargs + +Translate a [`ResistiveMatch`](@ref) into the `gal_*` matching keywords, or error for an +integrator whose resonant matching is not implemented yet. `nothing` leaves `kwargs` alone, +which is the ideal-closure default. +""" +_apply_match!(kwargs::Dict{Symbol,Any}, ::Nothing, ::AbstractIntegrator) = kwargs + +function _apply_match!(kwargs::Dict{Symbol,Any}, ::ResistiveMatch, alg::AbstractIntegrator) + return error("resonant matching for this integrator is not yet implemented (requested with $(nameof(typeof(alg))))") +end + +function _apply_match!(kwargs::Dict{Symbol,Any}, match::ResistiveMatch, ::Galerkin) + kwargs[:gal_match_flag] = true + # The match consumes the coil-response columns, so it implies the rpec solve. + kwargs[:gal_rpec_flag] = true + for name in fieldnames(ResistiveMatch) + key = name === :ideal ? :gal_ideal_flag : Symbol(:gal_, name) + _set_ctrl!(kwargs, key, getfield(match, name), match) + end + return kwargs +end diff --git a/src/ForceFreeStates/Result.jl b/src/ForceFreeStates/Result.jl new file mode 100644 index 000000000..10fbf2302 --- /dev/null +++ b/src/ForceFreeStates/Result.jl @@ -0,0 +1,256 @@ +""" + SolutionProfiles + +The 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. +""" +struct SolutionProfiles + basis::Symbol + step::Int + psi_store::Vector{Float64} + q_store::Vector{Float64} + u_store::Array{ComplexF64,4} + du_store::Array{ComplexF64,3} + xi_s_store::Array{ComplexF64,3} +end + +""" + ForceFreeStatesResult + +The 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`](@ref) (or [`require_solution`](@ref) 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; see `ModeSpace`. + - `psilow`, `psilim`, `qlim`, `q1lim` - Integration bounds, and q with its ψ-derivative at `psilim`. + - `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 `:ideal` closure. + - `solution::Union{Nothing,SolutionProfiles}` - THE solve's ξ solution; `nothing` when 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 matrix + `W_p = (U₂·U₁⁻¹)/ψ₀²` at `psilim`. Present for any Euler-Lagrange sweep (forward or + Riccati) regardless of `vac_flag` — the fixed-boundary run's energy product — and + identical to `free_boundary.wp` when the free-boundary calculation ran. + - `free_boundary::Union{Nothing,FreeBoundaryResult}` - Free-boundary energies and eigenmodes; `nothing` when 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); `nothing` when 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 + in `delta_prime`, not here. +""" +struct ForceFreeStatesResult{E<:Equilibrium.PlasmaEquilibrium,F<:FourFitVars} <: ModeSpace + integrator::Symbol + control::ForceFreeStatesControl + equil::E + + # Mode space and integration domain, copied out of the solve-time scratch. + mlow::Int + mhigh::Int + mpert::Int + nlow::Int + nhigh::Int + npert::Int + numpert_total::Int + psilow::Float64 + psilim::Float64 + qlim::Float64 + q1lim::Float64 + dir_path::String + wall_settings::Vacuum.WallShapeSettings + debug_settings::DebugSettings + + # Assembly products, always present. + metric::MetricData + ffit::F + surfaces::Vector{SingType} + kinetic::@NamedTuple{kmsing::Int, kinsing::Vector{SingType}, scan_psi::Vector{Float64}, scan_cond::Vector{Float64}, scan_threshold::Float64} + + # Closure of the basis at the rationals, always present. + closure::Symbol + bpen::Matrix{ComplexF64} + + # Per-formalism products; presence is the capability signal. + solution::Union{Nothing,SolutionProfiles} + diagnostics::Union{Nothing,OdeState} + wp::Union{Nothing,Matrix{ComplexF64}} + free_boundary::Union{Nothing,FreeBoundaryResult} + delta_prime::Union{Nothing,DeltaPrimeData} + galerkin::Union{Nothing,GalerkinResult} +end + +""" + require(result::ForceFreeStatesResult, field::Symbol, calc::AbstractString) -> Bool + +Warn-and-skip gate: `true` iff the optional field `field` was populated by the integrator +that produced `result`. Warns naming the calculation being skipped otherwise. +""" +function require(result::ForceFreeStatesResult, field::Symbol, calc::AbstractString) + getfield(result, field) === nothing || return true + @warn "Skipping $calc: `$field` was not produced by the $(result.integrator) integrator" + return false +end + +""" + require_solution(result::ForceFreeStatesResult, calc::AbstractString) -> Bool + +Warn-and-skip gate for ξ-profile consumers: [`require`](@ref) specialized to `solution`, +with a message naming what a solution takes to produce. +""" +function require_solution(result::ForceFreeStatesResult, calc::AbstractString) + result.solution === nothing || return true + @warn "Skipping $calc: no ξ solution — dense profiles require a Forward (or matched Galerkin) run; " * + "this result came from the $(result.integrator) integrator" + return false +end + +# Pack the RPEC-matched Galerkin solution into `SolutionProfiles`. Mirrors Fortran +# `idcon_build`'s gal branch (idcon.f) and `globalsol.bin` (match.f): the on-surface `issing` +# grid points are dropped as zero placeholders where the resonant series diverges, Ξ′ is the +# analytic Galerkin derivative rather than a differenced value spline, and +# Ξ_s = −A⁻¹(B·Ξ′ + C·Ξ) is the same outer ideal-MHD relation `sing_der!` uses. The grid runs +# inner→edge, so the last node is the control surface and carries the edge boundary condition. +function _matched_gal_profiles(gal_result::GalerkinResult, ffit::FourFitVars, intr::ModeSpace) + sol = gal_result.solution + m = gal_result.match + npert = intr.numpert_total + + keep = .!sol.issing + psi_f = sol.psi[keep] + q_f = sol.q[keep] + xi_f = m.xi[:, keep, :] # (npert, ngrid_f, mcoil) + dxi_f = m.xi_deriv[:, keep, :] + ngrid_f = length(psi_f) + + # u_store[:, :, 2] stays zero: the conjugate momentum has no consumer on this path and no + # Galerkin counterpart (matches Fortran's unused u2 in the gal branch). + u_store = zeros(ComplexF64, npert, npert, 2, ngrid_f) + du_store = zeros(ComplexF64, npert, npert, ngrid_f) + xi_s_store = zeros(ComplexF64, npert, npert, ngrid_f) + + hint = Ref(1) + for ip in 1:ngrid_f + ξ = @view xi_f[:, ip, :] + ξ′ = @view dxi_f[:, ip, :] + @views u_store[:, :, 1, ip] .= ξ + @views du_store[:, :, ip] .= ξ′ + @views compute_node_xi_s!(xi_s_store[:, :, ip], ξ′, ξ, ffit, psi_f[ip]; hint=hint) + end + + return SolutionProfiles(:gal_native, ngrid_f, psi_f, q_f, u_store, du_store, xi_s_store) +end + +""" + build_result(integrator, ctrl, equil, intr, metric, ffit, odet, free_energies, gal_data, gal_dp) + -> ForceFreeStatesResult + +Assemble 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. +""" +function build_result( + integrator::Symbol, + ctrl::ForceFreeStatesControl, + equil::Equilibrium.PlasmaEquilibrium, + intr::ForceFreeStatesInternal, + metric::MetricData, + ffit::FourFitVars, + odet::Union{Nothing,OdeState}, + free_energies::Union{Nothing,FreeBoundaryResult}, + gal_data::Union{Nothing,GalerkinResult}, + gal_dp::Union{Nothing,DeltaPrimeData} +) + matched = gal_data !== nothing && gal_data.match !== nothing + + # The forward sweep is the only formalism whose stores need materializing; doing it here + # keeps `SolutionProfiles.du_store`/`xi_s_store` populated by construction. + solution = if integrator === :forward && odet !== nothing + materialize_derivative_stores!(odet, equil, ffit, intr) + SolutionProfiles(:el_axis, odet.step, odet.psi_store, odet.q_store, + odet.u_store, odet.du_store, odet.xi_s_store) + elseif matched + _matched_gal_profiles(gal_data, ffit, intr) + else + nothing + end + + # One Δ′ payload whichever formalism produced it; Riccati leaves the PEST-3 blocks unfilled + # because it persists only the raw D′ and recovers them on demand via `pest3_decompose`. + delta_prime = if gal_dp !== nothing + gal_dp + elseif !isempty(intr.delta_prime_matrix) + DeltaPrimeData(intr.delta_prime_matrix, intr.delta_prime_raw, intr.delta_coil, nothing, nothing, nothing) + else + nothing + end + kinetic = (kmsing=intr.kmsing, kinsing=intr.kinsing, scan_psi=intr.kinsing_scan_psi, + scan_cond=intr.kinsing_scan_cond, scan_threshold=intr.kinsing_scan_threshold) + + # The ideal-flag match deliberately skips the inner-layer Δ, so its basis is ideal-closed + # and carries no penetrated field. + closure = (matched && !ctrl.gal_ideal_flag) ? :matched : :ideal + bpen = closure === :matched ? gal_data.match.bpen : + zeros(ComplexF64, intr.msing, intr.numpert_total) + + # Fixed-boundary plasma energy matrix at the edge; free of any vacuum dependence, so a + # vac_flag=false run still publishes its energy product. Aliases free_run's when it ran. + wp = if free_energies !== nothing + free_energies.wp + elseif odet !== nothing + (odet.u[:, :, 2] / odet.u[:, :, 1]) ./ equil.psio^2 + else + nothing + end + + return ForceFreeStatesResult( + integrator, ctrl, equil, + intr.mlow, intr.mhigh, intr.mpert, intr.nlow, intr.nhigh, intr.npert, intr.numpert_total, + intr.psilow, intr.psilim, intr.qlim, intr.q1lim, intr.dir_path, intr.wall_settings, intr.debug_settings, + metric, ffit, intr.sing, kinetic, + closure, bpen, + solution, odet, wp, free_energies, delta_prime, gal_data + ) +end diff --git a/src/ForceFreeStates/Sing.jl b/src/ForceFreeStates/Sing.jl index f41e6dd2f..960530f6a 100644 --- a/src/ForceFreeStates/Sing.jl +++ b/src/ForceFreeStates/Sing.jl @@ -1185,7 +1185,7 @@ caller's interval-search accelerators, so concurrent callers just pass their own """ @with_pool pool function el_derivatives!(du::Array{ComplexF64,3}, u::Array{ComplexF64,3}, kinetic::Bool, equil::Equilibrium.PlasmaEquilibrium, ffit::FourFitVars, - intr::ForceFreeStatesInternal, psieval::Float64, spline_hint::Base.RefValue{Int}, ffit_hint::Base.RefValue{Int}) + intr::ModeSpace, psieval::Float64, spline_hint::Base.RefValue{Int}, ffit_hint::Base.RefValue{Int}) # Allocate temporary arrays from the pool Npert = intr.numpert_total diff --git a/src/ForceFreeStates/Utils.jl b/src/ForceFreeStates/Utils.jl index 71830e027..a1c07d632 100644 --- a/src/ForceFreeStates/Utils.jl +++ b/src/ForceFreeStates/Utils.jl @@ -79,7 +79,7 @@ function materialize_derivative_stores!( odet::OdeState, equil::Equilibrium.PlasmaEquilibrium, ffit::Union{FourFitVars,Nothing}, - intr::ForceFreeStatesInternal + intr::ModeSpace ) odet.du_store_populated && return true (isnothing(ffit) || odet.step == 0 || isempty(odet.u_store) || !odet.u_store_el_basis) && return false diff --git a/src/ForcingTerms/ForcingTerms.jl b/src/ForcingTerms/ForcingTerms.jl index 3bd938b43..3125b6382 100644 --- a/src/ForcingTerms/ForcingTerms.jl +++ b/src/ForcingTerms/ForcingTerms.jl @@ -277,6 +277,121 @@ function load_forcing_from_h5_group!(forcing_modes::Vector{ForcingMode}, group) return forcing_modes end -export ForcingTermsControl, ForcingMode, load_forcing_data!, save_forcing_to_h5, load_forcing_from_h5_group! +""" + RMPField + +Abstract supertype of every external-forcing description the scripting API accepts: a +resonant-magnetic-perturbation field, described by where it comes from and how strongly it +drives. Whether the source is a forcing-mode file, a coil set with currents, or (future) a +field given on a control surface, they are all just external fields — one type drives the +perturbed-equilibrium stage. + +Nothing is read from disk or computed at construction — the modes are materialized against +an equilibrium when the perturbed-equilibrium stage runs, so one `RMPField` can drive +several solves. + +## Construction + + RMPField(path; format=..., scale=1.0, kwargs...) + RMPField(coil_sets::Vector{Dict{String,Any}}; scale=1.0, kwargs...) + RMPField(ctrl::ForcingTermsControl; scale=1.0) + +The first form points at a forcing-mode file, `format` defaulting to `"hdf5"` for an `.h5` +or `.hdf5` extension and `"ascii"` otherwise. The second form takes TOML-shaped coil blocks +(the `[[ForcingTerms.coil_set]]` layout) and selects the coil format. Remaining `kwargs` are +[`ForcingTermsControl`](@ref) fields. All three return a single-source leaf +([`RMPSource`](@ref)). + +## Algebra + +`RMPField`s form a vector space: `+`, `-` and multiplication by a scalar (real or complex — +a complex factor phase-rotates the perturbation) build lazy linear combinations without +materializing anything. The perturbed-equilibrium response is linear in the forcing, so +materializing a combination equals combining the materialized sources: each term is +evaluated on the control surface and the mode amplitudes are summed. + +```julia +nominal = RMPField("nominal_efc.dat") +weld_field = RMPField("weld_fields.h5"; scale=0.5) +total = 2.0 * nominal + weld_field # lazy: records terms and weights, computes nothing +pe = perturbed_equilibrium(ffs, total) # materializes each term, sums, drives PE +``` + +Amplitude lives with the forcing description itself: per-conductor currents for the coil +format, per-mode `ForcingMode.amplitude` for the file formats. `scale` is NOT a physical +amplitude — it is the source's weight in a linear combination, applied to the materialized +control-surface spectrum uniformly across every toroidal mode (format-independent). Sources +that are not scalar multiples of each other get their own description and the algebra: a +coil set with a failed conductor is not `0.9 * nominal`, it is `nominal - failed_coil` (or +its own source); a magnetic-material field should be computed at the operating point by the +code that owns its physics, with the weight meaningful only for small linear excursions. +A per-n weight dictionary is not supported yet; it needs an n-keyed concept ForcingTerms +does not have. +""" +abstract type RMPField end + +""" + RMPSource + +A single-source [`RMPField`](@ref) leaf: one forcing description plus a complex weight. +Built by the `RMPField` constructors; scalar multiplication rescales the weight. + +## Fields + + - `ctrl::ForcingTermsControl` - The forcing source: format, file path or machine, and the raw coil-set blocks. + - `scale::ComplexF64` - The source's weight in a linear combination, applied to the materialized control-surface spectrum (complex = phase rotation). Not a physical amplitude. +""" +struct RMPSource <: RMPField + ctrl::ForcingTermsControl + scale::ComplexF64 +end + +""" + RMPFieldSum + +A lazy linear combination of [`RMPSource`](@ref) leaves, built by `+`/`-` on +[`RMPField`](@ref)s. Holds the flattened term list; scalar multiplication distributes onto +the leaves. Materialization evaluates each term against the equilibrium and sums the mode +amplitudes — valid because the perturbed-equilibrium response is linear in the forcing. + +## Fields + + - `terms::Vector{RMPSource}` - The flattened weighted sources. +""" +struct RMPFieldSum <: RMPField + terms::Vector{RMPSource} +end + +""" + _infer_format(path) -> String + +Forcing-data format implied by a file extension: `"hdf5"` for `.h5`/`.hdf5`, else `"ascii"`. +""" +_infer_format(path::AbstractString) = lowercase(splitext(path)[2]) in (".h5", ".hdf5") ? "hdf5" : "ascii" + +RMPField(ctrl::ForcingTermsControl; scale::Number=1.0) = RMPSource(ctrl, ComplexF64(scale)) + +function RMPField(path::AbstractString; format::String=_infer_format(path), scale::Number=1.0, kwargs...) + ctrl = ForcingTermsControl(; forcing_data_file=abspath(path), forcing_data_format=format, kwargs...) + return RMPSource(ctrl, ComplexF64(scale)) +end + +function RMPField(coil_sets::Vector{Dict{String,Any}}; scale::Number=1.0, kwargs...) + ctrl = ForcingTermsControl(; forcing_data_format="coil", coil_sets_raw=coil_sets, kwargs...) + return RMPSource(ctrl, ComplexF64(scale)) +end + +"Flattened weighted-leaf list of any [`RMPField`](@ref)." +_rmp_terms(f::RMPSource) = [f] +_rmp_terms(f::RMPFieldSum) = f.terms + +Base.:+(a::RMPField, b::RMPField) = RMPFieldSum(vcat(_rmp_terms(a), _rmp_terms(b))) +Base.:-(a::RMPField) = -1 * a +Base.:-(a::RMPField, b::RMPField) = a + (-1 * b) +Base.:*(c::Number, f::RMPSource) = RMPSource(f.ctrl, ComplexF64(c) * f.scale) +Base.:*(c::Number, f::RMPFieldSum) = RMPFieldSum([c * t for t in f.terms]) +Base.:*(f::RMPField, c::Number) = c * f + +export ForcingTermsControl, ForcingMode, RMPField, load_forcing_data!, save_forcing_to_h5, load_forcing_from_h5_group! end # module ForcingTerms diff --git a/src/GeneralizedPerturbedEquilibrium.jl b/src/GeneralizedPerturbedEquilibrium.jl index 7b17683cf..700b7a599 100755 --- a/src/GeneralizedPerturbedEquilibrium.jl +++ b/src/GeneralizedPerturbedEquilibrium.jl @@ -9,6 +9,8 @@ using FastInterpolations import IMASdd import AdaptiveArrayPools: @with_pool +import CommonSolve: solve + const _BANNER = "="^60 const _SECTION = "-"^40 @@ -68,15 +70,23 @@ include("HDF5Schema.jl") include("Rerun.jl") # Import ForceFreeStates types and functions needed for main -using .ForceFreeStates: ForceFreeStatesInternal, ForceFreeStatesControl, DebugSettings, FreeBoundaryResult, OdeState, FourFitVars +using .ForceFreeStates: ForceFreeStatesInternal, ForceFreeStatesControl, DebugSettings +using .ForceFreeStates: ForceFreeStatesResult, build_result using .ForceFreeStates: sing_lim!, sing_min!, sing_find!, resist_eval_all!, resist_geometry, ResistGeometry using .ForceFreeStates: make_metric, make_matrix, make_kinetic_matrix using .ForceFreeStates: find_kinetic_singular_surfaces! using .ForceFreeStates: eulerlagrange_integration, free_run, normalize_eigenfunctions! -using .ForceFreeStates: galerkin_solve, write_galerkin!, GalerkinResult, gal_matched_odestate +using .ForceFreeStates: galerkin_solve, write_galerkin! + +# Scripting-API surface: the integrator selectors, the published result, the equilibrium +# constructor and the forcing description, re-exported so a user needs one `using`. +using .ForceFreeStates: AbstractIntegrator, Forward, Riccati, Galerkin, ResistiveMatch +using .Equilibrium: PlasmaEquilibrium +using .ForcingTerms: RMPField const _DEPRECATED_FFS_KEYS = ("mer_flag", "force_wv_symmetry", "ode_flag", "cyl_flag", "mat_flag", "reform_eq_with_psilim", - "use_riccati", "use_parallel", "parallel_threads", "populate_dense_xi") + "use_riccati", "use_parallel", "parallel_threads", "populate_dense_xi", + "gal_flag") const _DEPRECATED_EQUIL_KEYS = ("power_bp", "power_b", "power_r", "power_rc") # Drop deprecated keys from a parsed gpec.toml section so legacy files keep parsing @@ -166,6 +176,11 @@ is enabled) so it still ends up in `Input/RawInputs/ForcingTerms/`. `Input/RawInputs/Coils/` so a coil run can be replayed (recomputing the field against the current equilibrium) without the original `.dat`/`.h5` files. The coil geometry actually used by the run is always written back into `Input/RawInputs/Coils/`. + +Returns `(; ffs, pe, slayer)`: the `ForceFreeStates.ForceFreeStatesResult`, the +`PerturbedEquilibriumState` (`nothing` when that stage did not run) and the SLAYER result +(`nothing` when that stage did not run or failed). An equilibrium-only run +(`force_termination` in `[Equilibrium]`) returns `nothing` — it never reaches the solve. """ function main_from_inputs( inputs::Dict{String,Any}, @@ -190,6 +205,105 @@ function main_from_inputs( _drop_deprecated_keys!(ffs_table, _DEPRECATED_FFS_KEYS, "ForceFreeStates") ctrl = ForceFreeStatesControl(; (Symbol(k) => v for (k, v) in ffs_table)...) + resolve_mode_space!(intr, ctrl) + + equil = Equilibrium.setup_equilibrium(eq_config, additional_input) + + kf_ctrl, kinetic_profiles = load_kinetic_context(inputs, intr, ctrl, equil) + equil = maybe_reform_equilibrium(equil, eq_config, additional_input, intr, ctrl, kinetic_profiles) + + @info "Equilibrium construction completed in $(@sprintf("%.3f", time() - equil_start)) s" + + # Early exit if user only requested equilibrium setup + if equil.config.force_termination + @info "\n$_BANNER\n GPEC completed successfully in $(@sprintf("%.3f", time() - total_start)) s\n$_BANNER" + return + end + + if "Wall" in keys(inputs) + intr.wall_settings = Vacuum.WallShapeSettings(; (Symbol(k) => v for (k, v) in inputs["Wall"])...) + else + intr.wall_settings = Vacuum.WallShapeSettings() + end + + if "DEBUG" in keys(inputs) + intr.debug_settings = DebugSettings(; (Symbol(k) => v for (k, v) in inputs["DEBUG"])...) + else + intr.debug_settings = DebugSettings() + end + + forcing_modes_snapshot = snapshot_forcing_modes(inputs, path, ctrl, preloaded_forcing_modes) + + # ---------------------------------------------------------------- + # Force-Free States + # ---------------------------------------------------------------- + @info "\n Force-Free States\n$_SECTION" + ffs_start = time() + + locstab, ballooning_boundary = run_local_stability(ctrl, equil) + metric, ffit = prepare_force_free_states!(intr, ctrl, equil, kf_ctrl, kinetic_profiles) + ffs_result = run_force_free_states(ctrl, equil, ffit, intr, metric) + + if ctrl.write_outputs_to_HDF5 + write_outputs_to_HDF5( + ffs_result; + git_version=git_version, + inputs=inputs, + forcing_modes=forcing_modes_snapshot, + locstab=locstab, + ballooning_boundary=ballooning_boundary + ) + @info "Results written to $(ctrl.HDF5_filename)" + end + + @info "Force-Free States completed in $(@sprintf("%.3f", time() - ffs_start)) s" + + # Early exit if user only requested force-free states (SLAYER still runs). + if ctrl.force_termination + slayer_result = run_slayer_stage(ffs_result, inputs, nothing) + @info "\n$_BANNER\n GPEC completed successfully in $(@sprintf("%.3f", time() - total_start)) s\n$_BANNER" + return (; ffs=ffs_result, pe=nothing, slayer=slayer_result) + end + + pe_state = run_perturbed_equilibrium(ffs_result, inputs, forcing_modes_snapshot, preloaded_coil_sets) + + run_kinetic_forces(inputs, ffs_result, pe_state, kf_ctrl, kinetic_profiles) + + # SLAYER runs after PE so it appends to the PE output file; it falls back to the + # ForceFreeStates file when PE did not run. + pe_file = if "PerturbedEquilibrium" in keys(inputs) + pe_out = get(inputs["PerturbedEquilibrium"], "output_filename", "") + isempty(pe_out) ? ctrl.HDF5_filename : pe_out + else + ctrl.HDF5_filename + end + slayer_result = run_slayer_stage(ffs_result, inputs, pe_file) + + # ---------------------------------------------------------------- + # Done + # ---------------------------------------------------------------- + @info "\n$_BANNER\n GPEC completed successfully in $(@sprintf("%.3f", time() - total_start)) s\n$_BANNER" + + # TODO: Do not allow perturbed equilibrium calculations if zero crossings are found + + return (; ffs=ffs_result, pe=pe_state, slayer=slayer_result) + +end + +""" + _mode_range_label(intr) -> String + +Compact label for the resolved toroidal mode range, `"1"` for a single `n` and `"1:3"` for a range. +""" +_mode_range_label(intr::ForceFreeStatesInternal) = intr.npert == 1 ? "$(intr.nlow)" : "$(intr.nlow):$(intr.nhigh)" + +""" + resolve_mode_space!(intr, ctrl) -> intr + +Resolve the requested toroidal mode range onto `intr`, filling an unspecified bound from the +other one and rejecting ranges that contain no supported `n >= 1` mode. +""" +function resolve_mode_space!(intr::ForceFreeStatesInternal, ctrl::ForceFreeStatesControl) # Determine toroidal mode numbers (n >= 1 required; 0 means "not specified") intr.nlow, intr.nhigh = ctrl.nn_low, ctrl.nn_high if intr.nlow == 0 && intr.nhigh == 0 @@ -211,15 +325,25 @@ function main_from_inputs( intr.nlow = 1 end intr.npert = intr.nhigh - intr.nlow + 1 - nstring = intr.npert == 1 ? "$(intr.nlow)" : "$(intr.nlow):$(intr.nhigh)" + return intr +end - equil = Equilibrium.setup_equilibrium(eq_config, additional_input) +""" + load_kinetic_context(inputs, intr, ctrl, equil) -> (kf_ctrl, kinetic_profiles) - # Build KineticForces control and load kinetic profiles once — reused by the grid - # refinement below, the stability kinetic callback (via `calculated_cb`), and the - # post-PE torque diagnostics block. The `"fixed"` kinetic source path in stability - # does not need kinetic_profiles, but the post-PE block always does, so we load - # whenever a [KineticForces] section is present or the stability path requests the +Build the KineticForces control and load the kinetic profiles once for the whole run. +`kinetic_profiles` is `nothing` when no stage asks for them. +""" +function load_kinetic_context( + inputs::Dict{String,Any}, + intr::ForceFreeStatesInternal, + ctrl::ForceFreeStatesControl, + equil::Equilibrium.PlasmaEquilibrium +) + # The profiles are reused by the grid refinement, the stability kinetic callback (via + # `calculated_cb`), and the post-PE torque diagnostics block. The `"fixed"` kinetic source + # path in stability does not need kinetic_profiles, but the post-PE block always does, so we + # load whenever a [KineticForces] section is present or the stability path requests the # calculated source. psio is invariant across grid re-formation. kf_ctrl = haskey(inputs, "KineticForces") ? @@ -241,65 +365,72 @@ function main_from_inputs( chi1=2π * equil.psio) end - # Two-pass auto grid: measure the pass-1 equilibrium's curvature (profiles, geometry, - # kinetic profiles), pin knots on rational surfaces, and re-form on the refined grid - # from the in-memory input — no file re-read. - if Equilibrium.wants_two_pass(eq_config) - mandatory = ForceFreeStates.rational_psi_nodes(equil; nlow=intr.nlow, nhigh=intr.nhigh) - # Smallest |n| in the run sets the widest matching half-stencil dpsi = singfac_min/(n_min·|q′|), - # so the rational-surface brackets clear a zone large enough for every mode. - n_min = minimum(abs(n) for n in intr.nlow:intr.nhigh if n != 0) - psi_nodes = Equilibrium.refined_psi_grid(equil; - tau=eq_config.psi_accuracy, kin=kinetic_profiles, mandatory=mandatory, - singfac_min=ctrl.singfac_min, n_min=n_min) - rerun_input = if additional_input !== nothing - # Analytic *Config, IMAS dd, or prebuilt RunInput — all re-formable. The IMAS - # path re-runs read_imas, which must resolve the same psihigh both passes; - # _validate_psi_nodes errors loudly if it does not. - additional_input - elseif equil.ingest isa Equilibrium.DirectIngest - Equilibrium.build_direct_from_ingest(eq_config, equil.ingest) - elseif equil.ingest isa Equilibrium.InverseIngest - Equilibrium.build_inverse_from_ingest(eq_config, equil.ingest) - else - nothing # fall back to re-reading the input file - end - equil = Equilibrium.setup_equilibrium(eq_config, rerun_input; override_psi_nodes=psi_nodes) - implied = Equilibrium.implied_knot_count(equil; tau=eq_config.psi_accuracy, kin=kinetic_profiles) - if implied > 1.5 * (length(psi_nodes) - 1) - @warn "Two-pass psi grid: refined equilibrium implies $implied knots vs $(length(psi_nodes) - 1) used — " * - "pass 1 may have under-sampled a feature; consider tightening psi_accuracy" - end - @info "Two-pass psi grid: $(length(psi_nodes)) knots, $(length(mandatory)) rational surfaces pinned (n=$nstring)" - end - - @info "Equilibrium construction completed in $(@sprintf("%.3f", time() - equil_start)) s" + return kf_ctrl, kinetic_profiles +end - # Early exit if user only requested equilibrium setup - if equil.config.force_termination - @info "\n$_BANNER\n GPEC completed successfully in $(@sprintf("%.3f", time() - total_start)) s\n$_BANNER" - return - end +""" + maybe_reform_equilibrium(equil, eq_config, additional_input, intr, ctrl, kinetic_profiles) -> equil - if "Wall" in keys(inputs) - intr.wall_settings = Vacuum.WallShapeSettings(; (Symbol(k) => v for (k, v) in inputs["Wall"])...) +Two-pass auto grid: measure the pass-1 equilibrium's curvature (profiles, geometry, kinetic +profiles), pin knots on rational surfaces, and re-form on the refined grid from the in-memory +input — no file re-read. Returns `equil` untouched when the configuration wants a single pass. +""" +function maybe_reform_equilibrium( + equil::Equilibrium.PlasmaEquilibrium, + eq_config::Equilibrium.EquilibriumConfig, + additional_input, + intr::ForceFreeStatesInternal, + ctrl::ForceFreeStatesControl, + kinetic_profiles +) + Equilibrium.wants_two_pass(eq_config) || return equil + + mandatory = ForceFreeStates.rational_psi_nodes(equil; nlow=intr.nlow, nhigh=intr.nhigh) + # Smallest |n| in the run sets the widest matching half-stencil dpsi = singfac_min/(n_min·|q′|), + # so the rational-surface brackets clear a zone large enough for every mode. + n_min = minimum(abs(n) for n in intr.nlow:intr.nhigh if n != 0) + psi_nodes = Equilibrium.refined_psi_grid(equil; + tau=eq_config.psi_accuracy, kin=kinetic_profiles, mandatory=mandatory, + singfac_min=ctrl.singfac_min, n_min=n_min) + rerun_input = if additional_input !== nothing + # Analytic *Config, IMAS dd, or prebuilt RunInput — all re-formable. The IMAS + # path re-runs read_imas, which must resolve the same psihigh both passes; + # _validate_psi_nodes errors loudly if it does not. + additional_input + elseif equil.ingest isa Equilibrium.DirectIngest + Equilibrium.build_direct_from_ingest(eq_config, equil.ingest) + elseif equil.ingest isa Equilibrium.InverseIngest + Equilibrium.build_inverse_from_ingest(eq_config, equil.ingest) else - intr.wall_settings = Vacuum.WallShapeSettings() + nothing # fall back to re-reading the input file end - - if "DEBUG" in keys(inputs) - intr.debug_settings = DebugSettings(; (Symbol(k) => v for (k, v) in inputs["DEBUG"])...) - else - intr.debug_settings = DebugSettings() + equil = Equilibrium.setup_equilibrium(eq_config, rerun_input; override_psi_nodes=psi_nodes) + implied = Equilibrium.implied_knot_count(equil; tau=eq_config.psi_accuracy, kin=kinetic_profiles) + if implied > 1.5 * (length(psi_nodes) - 1) + @warn "Two-pass psi grid: refined equilibrium implies $implied knots vs $(length(psi_nodes) - 1) used — " * + "pass 1 may have under-sampled a feature; consider tightening psi_accuracy" end + @info "Two-pass psi grid: $(length(psi_nodes)) knots, $(length(mandatory)) rational surfaces pinned (n=$(_mode_range_label(intr)))" + + return equil +end + +""" + snapshot_forcing_modes(inputs, path, ctrl, preloaded) -> Union{Nothing,Vector{ForcingMode}} - # Forcing-data snapshot: when PerturbedEquilibrium is enabled, load forcing - # modes early so they can be written into `Input/RawInputs/ForcingTerms/` - # alongside the TOML blob. On the rerun path the caller passes the modes in - # directly via `preloaded_forcing_modes`, bypassing the original file. Coil - # forcing is recomputed from the `[[ForcingTerms.coil_set]]` TOML blob on - # replay, so only the file-based formats need their modes captured here. - forcing_modes_snapshot = preloaded_forcing_modes +Capture the file-based forcing modes before the solve, when PerturbedEquilibrium is enabled, so +they land in `Input/RawInputs/ForcingTerms/` alongside the TOML blob. Returns `preloaded` +unchanged when the caller (the rerun path) already supplied the modes. +""" +function snapshot_forcing_modes( + inputs::Dict{String,Any}, + path::String, + ctrl::ForceFreeStatesControl, + preloaded::Union{Nothing,Vector{ForcingTerms.ForcingMode}} +) + # Coil forcing is recomputed from the `[[ForcingTerms.coil_set]]` TOML blob on replay, + # so only the file-based formats need their modes captured here. + forcing_modes_snapshot = preloaded if forcing_modes_snapshot === nothing && "PerturbedEquilibrium" in keys(inputs) ft_raw = get(inputs, "ForcingTerms", Dict{String,Any}()) scalar_forcing = filter(p -> p.first != "coil_set", ft_raw) @@ -317,18 +448,17 @@ function main_from_inputs( ) end end + return forcing_modes_snapshot +end - # ---------------------------------------------------------------- - # Force-Free States - # ---------------------------------------------------------------- - @info "\n Force-Free States\n$_SECTION" - ffs_start = time() - - # Determine psilim and qlim (where we will integrate to) - sing_lim!(intr, ctrl, equil) +""" + run_local_stability(ctrl, equil) -> (locstab, ballooning_boundary) - # Compute local stability (if desired). `locstab` holds `D_I` from the ballooning - # coefficient system and the local ballooning result; `nothing` when not computed. +Run the LocalStability stage when `local_stability_flag` is set. `locstab` holds `D_I` from the +ballooning coefficient system and the local ballooning result, `ballooning_boundary` the first +α-vs-ψ_N stability boundary; both are empty placeholders when the stage is off. +""" +function run_local_stability(ctrl::ForceFreeStatesControl, equil::Equilibrium.PlasmaEquilibrium) locstab = nothing ballooning_boundary = (psi=Float64[], alpha=Float64[], alpha_critical=Float64[]) if ctrl.local_stability_flag @@ -336,6 +466,25 @@ function main_from_inputs( # First ballooning stability boundary (α vs ψ_N) for BALOO-style diagnostics. ballooning_boundary = LocalStability.ballooning_alpha_boundary(equil; verbose=ctrl.verbose) end + return locstab, ballooning_boundary +end + +""" + prepare_force_free_states!(intr, ctrl, equil, kf_ctrl, kinetic_profiles) -> (metric, ffit) + +Set up the force-free-states solve on `intr`: integration limits, the surviving singular +surfaces and their GGJ coefficients, the poloidal mode range, and the metric plus +Euler-Lagrange (and, when requested, kinetic) matrices. +""" +function prepare_force_free_states!( + intr::ForceFreeStatesInternal, + ctrl::ForceFreeStatesControl, + equil::Equilibrium.PlasmaEquilibrium, + kf_ctrl::KineticForces.KineticForcesControl, + kinetic_profiles +) + # Determine psilim and qlim (where we will integrate to) + sing_lim!(intr, ctrl, equil) # Find all singular surfaces in the equilibrium sing_find!(intr, equil) @@ -360,7 +509,7 @@ function main_from_inputs( # surfaces) by raising psilow to where q = qlow (RDCON sing_min). Without this, the gal FEM # integrates through the unhandled q≤1 ideal singularity and contaminates Δ′ at the innermost # kept surface when q0 < qlow. No-op (keeps the axis bound) when qlow ≤ qmin. - if ctrl.gal_flag + if ctrl.integrator == "galerkin" sing_min!(intr, ctrl, equil) end @@ -434,118 +583,222 @@ function main_from_inputs( end end - # Integrate Euler-Lagrange Equation - if ctrl.verbose - @info "Integrating Euler-Lagrange equation" - end - odet, fm_propagators, fm_chunks, fm_S_left = eulerlagrange_integration(ctrl, equil, ffit, intr) - if odet.nzero > 0 && ctrl.verbose - @warn "Fixed-boundary mode unstable for n = $nstring" - end + return metric, ffit +end + +""" + run_force_free_states(ctrl, equil, ffit, intr, metric) -> ForceFreeStatesResult + +Run the formalism selected by `ctrl.integrator` — the standalone Galerkin solve, or the +Euler-Lagrange sweep with its free-boundary energies and Δ′ BVP — and publish its products as a +`ForceFreeStatesResult`. +""" +function run_force_free_states( + ctrl::ForceFreeStatesControl, + equil::Equilibrium.PlasmaEquilibrium, + ffit, + intr::ForceFreeStatesInternal, + metric +) + nstring = _mode_range_label(intr) - # Compute free boundary energies. + # The three formalisms are exclusive. Galerkin solves the same Euler-Lagrange system + # variationally rather than by radial ODE integration, so it replaces both the integration + # and the free-boundary energies, and supplies its own vacuum response at psilim. + odet = nothing free_energies = nothing - if ctrl.vac_flag && !(ctrl.ksing > 0 && ctrl.ksing <= intr.msing + 1) + gal_data = nothing + gal_dp = nothing + if ctrl.integrator == "galerkin" + ctrl.kinetic_factor == 0 || + error("integrator = \"galerkin\" does not support kinetic runs (kinetic_factor > 0); use integrator = \"forward\".") + gal_start = time() + wv = ctrl.vac_flag ? first(ForceFreeStates.compute_scaled_wv(ctrl, equil, intr)) : nothing + gal_data, gal_dp = galerkin_solve(ctrl, equil, ffit, intr; wv=wv) + @info "Galerkin solve completed in $(@sprintf("%.3f", time() - gal_start)) s" + else + # Integrate Euler-Lagrange Equation if ctrl.verbose - wall_desc = intr.wall_settings.shape == "nowall" ? "no wall" : intr.wall_settings.shape - @info "Computing free boundary energies ($wall_desc)" + @info "Integrating Euler-Lagrange equation" + end + odet, fm_propagators, fm_chunks, fm_S_left = eulerlagrange_integration(ctrl, equil, ffit, intr) + if odet.nzero > 0 && ctrl.verbose + @warn "Fixed-boundary mode unstable for n = $nstring" end - free_energies = free_run(odet, ctrl, equil, ffit, intr) - normalize_eigenfunctions!(odet, free_energies.wt, equil.psio) - if real(free_energies.et[1]) < 0 + + # Compute free boundary energies. + if ctrl.vac_flag && !(ctrl.ksing > 0 && ctrl.ksing <= intr.msing + 1) if ctrl.verbose - @warn "Free-boundary mode unstable for n = $nstring" + wall_desc = intr.wall_settings.shape == "nowall" ? "no wall" : intr.wall_settings.shape + @info "Computing free boundary energies ($wall_desc)" end - else - if ctrl.verbose - @info "All free-boundary modes stable for n = $nstring" + free_energies = free_run(odet, ctrl, equil, ffit, intr) + normalize_eigenfunctions!(odet, free_energies.wt, equil.psio) + if real(free_energies.et[1]) < 0 + if ctrl.verbose + @warn "Free-boundary mode unstable for n = $nstring" + end + else + if ctrl.verbose + @info "All free-boundary modes stable for n = $nstring" + end end - end - # Compute inter-surface Δ' matrix (STRIDE BVP) using vacuum edge BC. - # Requires propagators from parallel FM path and wv from free_run. - if ctrl.kinetic_factor == 0 && intr.msing > 0 && fm_propagators !== nothing - if ctrl.verbose - @info "Computing Δ' matrix (STRIDE BVP with vacuum coupling)" + # Compute inter-surface Δ' matrix (STRIDE BVP) using vacuum edge BC. + # Requires propagators from parallel FM path and wv from free_run. + if ctrl.kinetic_factor == 0 && intr.msing > 0 && fm_propagators !== nothing + if ctrl.verbose + @info "Computing Δ' matrix (STRIDE BVP with vacuum coupling)" + end + ForceFreeStates.compute_delta_prime_matrix!(intr, fm_propagators, fm_chunks; + wv=free_energies.wv, psio=equil.psio, debug=ctrl.verbose, + S_at_surface_left=fm_S_left, + ctrl=ctrl, equil=equil, ffit=ffit) end - ForceFreeStates.compute_delta_prime_matrix!(intr, fm_propagators, fm_chunks; - wv=free_energies.wv, psio=equil.psio, debug=ctrl.verbose, - S_at_surface_left=fm_S_left, - ctrl=ctrl, equil=equil, ffit=ffit) end end - # Outer-region resistive Δ′ matrix via the singular Galerkin method (RDCON gal_solve) - gal_data = nothing - if ctrl.gal_flag - gal_start = time() - gal_data = galerkin_solve(ctrl, equil, ffit, intr; wv=free_energies !== nothing ? free_energies.wv : nothing) - @info "Galerkin solve completed in $(@sprintf("%.3f", time() - gal_start)) s" + # Publish the solve: from here on the downstream stages read the result, never `intr`. + return build_result(Symbol(ctrl.integrator), ctrl, equil, intr, metric, ffit, odet, free_energies, gal_data, gal_dp) +end + +""" + EulerLagrangeProblem(equil; nn, wall=Vacuum.WallShapeSettings(), match=nothing, + dir_path=".", debug=DebugSettings(), kwargs...) + +The perturbed-plasma Euler-Lagrange problem posed on an equilibrium: the extremization of +the perturbed potential energy whose solutions are the force-free (and, via the TOML path, +kinetic) perturbed states. This is the WHAT of a stability solve; the integrator passed to +[`solve`](@ref) is the HOW. A `PlasmaEquilibrium` hosts many possible problems — this type +names this one, so `solve` stays unambiguous as other problem classes appear. + +`nn` is the toroidal mode number or range. `wall` is the vacuum wall shape, `match` an +optional [`ResistiveMatch`](@ref) closing the basis with an inner-layer solution instead of +the ideal jump, `dir_path` the working directory outputs are written to, and `debug` the +diagnostic dump settings of the DEBUG deck section. Any remaining keyword is a +`ForceFreeStatesControl` field, so the TOML keys and the problem keywords are the same +knobs. `nn_low`/`nn_high` are rejected — they come from `nn`. + +## Fields + + - `equil::Equilibrium.PlasmaEquilibrium` - The equilibrium the problem is posed on. + - `wall::Vacuum.WallShapeSettings` - Vacuum wall shape for the free-boundary energies. + - `match::Union{Nothing,ForceFreeStates.ResistiveMatch}` - Optional inner-layer closure. + - `dir_path::String` - Working directory for outputs. + - `debug::DebugSettings` - Diagnostic dump settings. + - `ctrl_kwargs::Dict{Symbol,Any}` - `ForceFreeStatesControl` keywords, `nn` already folded in. +""" +struct EulerLagrangeProblem + equil::Equilibrium.PlasmaEquilibrium + wall::Vacuum.WallShapeSettings + match::Union{Nothing,ForceFreeStates.ResistiveMatch} + dir_path::String + debug::DebugSettings + ctrl_kwargs::Dict{Symbol,Any} +end + +function EulerLagrangeProblem( + equil::Equilibrium.PlasmaEquilibrium; + nn::Union{Int,AbstractUnitRange{Int}}, + wall::Vacuum.WallShapeSettings=Vacuum.WallShapeSettings(), + match::Union{Nothing,ForceFreeStates.ResistiveMatch}=nothing, + dir_path::AbstractString=".", + debug::DebugSettings=DebugSettings(), + kwargs... +) + ctrl_kwargs = Dict{Symbol,Any}(kwargs) + (haskey(ctrl_kwargs, :nn_low) || haskey(ctrl_kwargs, :nn_high)) && + error("the toroidal mode range comes from the `nn` keyword; drop nn_low/nn_high") + ctrl_kwargs[:nn_low] = first(nn) + ctrl_kwargs[:nn_high] = last(nn) + return EulerLagrangeProblem(equil, wall, match, String(dir_path), debug, ctrl_kwargs) +end + +""" + solve(prob::EulerLagrangeProblem, alg) -> ForceFreeStatesResult + solve(equil, alg; nn, kwargs...) -> ForceFreeStatesResult + +Solve the perturbed-plasma [`EulerLagrangeProblem`](@ref) with the formalism `alg` — +[`Forward`](@ref), [`Riccati`](@ref) or [`Galerkin`](@ref) — and return the published +[`ForceFreeStatesResult`](@ref). This is the scripting entry point; it runs the same stages +a `gpec.toml` run of `main` does and produces the same result object. The second form is +sugar building the problem from an equilibrium and the problem keywords in one call. + +Knobs owned by `alg` or `match` are rejected as `ForceFreeStatesControl` keywords. Kinetic +runs are TOML-driven this cycle: `kinetic_factor > 0` needs the `[KineticForces]` profiles +and errors here. + +```julia +eq = PlasmaEquilibrium("input.geqdsk"; jac_type="hamada") +prob = EulerLagrangeProblem(eq; nn=1, delta_mlow=8, delta_mhigh=8, vac_flag=true) +ffs = solve(prob, Riccati()) +ffs = solve(eq, Riccati(); nn=1, vac_flag=true) # equivalent one-line form +``` +""" +function solve(prob::EulerLagrangeProblem, alg::ForceFreeStates.AbstractIntegrator) + total_start = time() + + equil = prob.equil + ctrl_kwargs = copy(prob.ctrl_kwargs) + ForceFreeStates._apply_alg!(ctrl_kwargs, alg) + ForceFreeStates._apply_match!(ctrl_kwargs, prob.match, alg) + ctrl = ForceFreeStatesControl(; ctrl_kwargs...) + + ctrl.kinetic_factor > 0 && + error("kinetic runs (kinetic_factor > 0) need the [KineticForces] profiles and are TOML-driven; run them through `main`") + + intr = ForceFreeStatesInternal(; dir_path=prob.dir_path) + intr.wall_settings = prob.wall + intr.debug_settings = prob.debug + + resolve_mode_space!(intr, ctrl) + + # The API path never reads kinetic profiles, so the KineticForces control is only the + # placeholder `prepare_force_free_states!` threads into its (unused) callback. + kf_ctrl = KineticForces.KineticForcesControl() + + if Equilibrium.wants_two_pass(equil.config) && equil.ingest === nothing + @warn "Two-pass auto grid needs the equilibrium's raw ingest, which analytic and IMAS equilibria do not carry; " * + "solving on the single-pass grid. Set mpsi explicitly to choose the grid." + else + equil = maybe_reform_equilibrium(equil, equil.config, nothing, intr, ctrl, nothing) end + locstab, ballooning_boundary = run_local_stability(ctrl, equil) + metric, ffit = prepare_force_free_states!(intr, ctrl, equil, kf_ctrl, nothing) + result = run_force_free_states(ctrl, equil, ffit, intr, metric) + if ctrl.write_outputs_to_HDF5 - write_outputs_to_HDF5( - ctrl, - equil, - intr, - odet, - free_energies, - ffit, - git_version, - inputs, - forcing_modes_snapshot, - gal_data; - locstab=locstab, - ballooning_boundary=ballooning_boundary - ) + write_outputs_to_HDF5(result; locstab=locstab, ballooning_boundary=ballooning_boundary) @info "Results written to $(ctrl.HDF5_filename)" end - @info "Force-Free States completed in $(@sprintf("%.3f", time() - ffs_start)) s" + @info "Force-Free States completed in $(@sprintf("%.3f", time() - total_start)) s" - # SLAYER tearing-mode analysis stage. Needs only equil + intr, so it runs in - # both the force_termination=true path and the full pipeline. `pe_file` is the - # HDF5 file PE wrote (to append into), or `nothing` if PE did not run. - function _run_slayer_stage(pe_file::Union{String,Nothing}) - ("SLAYER" in keys(inputs)) || return nothing - # SLAYER is a post-processing diagnostic. A failure here must not - # discard the equilibrium / stability / PE results already computed, - # so the whole stage is guarded: on error we log loudly and return - # `nothing` for the `slayer` field rather than propagating. - try - slayer_ctrl = Runner.slayer_control_from_toml(inputs["SLAYER"]) - slayer_ctrl.enabled || return nothing - @info "\n SLAYER\n$_SECTION" - slayer_start = time() - result = Runner.run_slayer(equil, intr, slayer_ctrl; - dir_path=intr.dir_path) - @info "SLAYER completed in $(@sprintf("%.3f", time() - slayer_start)) s" - h5_filename = pe_file === nothing ? ctrl.HDF5_filename : pe_file - h5_path = joinpath(intr.dir_path, h5_filename) - # Append the Tearing/ group; create the file if no prior stage wrote - # it (e.g. write_outputs_to_HDF5 disabled) rather than failing on "r+". - HDF5.h5open(h5_path, isfile(h5_path) ? "r+" : "w") do f - Runner.write_slayer_hdf5!(f, result) - end - @info "SLAYER results written to $h5_filename" - return result - catch err - @error "SLAYER stage failed; continuing without tearing results. " * - "Equilibrium / stability / PE outputs are unaffected." exception = - (err, catch_backtrace()) - return nothing - end - end + return result +end - # Early exit if user only requested force-free states (SLAYER still runs). - if ctrl.force_termination - slayer_result = _run_slayer_stage(nothing) - @info "\n$_BANNER\n GPEC completed successfully in $(@sprintf("%.3f", time() - total_start)) s\n$_BANNER" - return (ctrl=ctrl, equil=equil, intr=intr, ffit=ffit, odet=odet, - free_energies=free_energies, - slayer=slayer_result) - end +""" + solve(equil::PlasmaEquilibrium, alg; nn, kwargs...) -> ForceFreeStatesResult +Convenience form of [`solve`](@ref): builds the [`EulerLagrangeProblem`](@ref) from the +equilibrium and the problem keywords, then solves it with `alg`. +""" +solve(equil::Equilibrium.PlasmaEquilibrium, alg::ForceFreeStates.AbstractIntegrator; kwargs...) = + solve(EulerLagrangeProblem(equil; kwargs...), alg) + +""" + run_perturbed_equilibrium(result, inputs, forcing_modes_snapshot, preloaded_coil_sets) -> pe_state + +Run the perturbed-equilibrium stage against a published force-free-states `result` and write its +outputs. Returns `nothing` when the deck carries no `[PerturbedEquilibrium]` section. +""" +function run_perturbed_equilibrium( + result::ForceFreeStatesResult, + inputs::Dict{String,Any}, + forcing_modes_snapshot::Union{Nothing,Vector{ForcingTerms.ForcingMode}}, + preloaded_coil_sets::Union{Nothing,Vector{ForcingTerms.CoilSet}} +) # ---------------------------------------------------------------- # Perturbed Equilibrium # ---------------------------------------------------------------- @@ -553,6 +806,7 @@ function main_from_inputs( pe_start = time() # Check for PerturbedEquilibrium section and run if present + pe_state = nothing if "PerturbedEquilibrium" in keys(inputs) # Read ForcingTerms control parameters if "ForcingTerms" in keys(inputs) @@ -568,161 +822,232 @@ function main_from_inputs( ft_ctrl = ForcingTerms.ForcingTermsControl() # Use defaults end - pe_ctrl = PerturbedEquilibrium.PerturbedEquilibriumControl(; - (Symbol(k) => v for (k, v) in inputs["PerturbedEquilibrium"])... - ) - pe_intr = PerturbedEquilibrium.PerturbedEquilibriumInternal(; dir_path=intr.dir_path) - - # DRIVEN (RPEC): feed the coil-matched gal solution to PE instead of the forward solution. - # The matched OdeState is in the identity-at-edge basis; build_flux_matrix rederives the edge BC - # from u_store[:,:,1,step], so PE consumes it unchanged. The forward odet is left untouched for - # the Force-Free States HDF5 output. - pe_odet = odet - if ctrl.gal_flag && ctrl.gal_match_flag && gal_data !== nothing && gal_data.match !== nothing - @info "PerturbedEquilibrium: using the RPEC-matched gal solution" - pe_odet = gal_matched_odestate(gal_data, ffit, intr) - pe_intr.odet_from_gal = true - pe_intr.inner_bpen = gal_data.match.bpen - else - pe_intr.inner_bpen = zeros(ComplexF64, intr.msing, intr.numpert_total) - end + # The deck's forcing block is an unscaled RMPField, so the TOML path and the + # scripting API share one stage. + pe_state = perturbed_equilibrium(result, ForcingTerms.RMPField(ft_ctrl); + forcing_modes=forcing_modes_snapshot, coil_sets=preloaded_coil_sets, + (Symbol(k) => v for (k, v) in inputs["PerturbedEquilibrium"])...) + end - # Reuse the forcing modes loaded at snapshot time (or injected by - # `build_inputs_from_h5`) so the PE compute step never re-reads the original - # forcing file. `compute_perturbed_equilibrium` short-circuits - # `load_forcing_data!` when `pe_intr.forcing_modes` is non-empty. - if forcing_modes_snapshot !== nothing - pe_intr.forcing_modes = copy(forcing_modes_snapshot) - end + @info "Perturbed Equilibrium completed in $(@sprintf("%.3f", time() - pe_start)) s" - # Inject preloaded coil geometry (gpec.h5 replay with `--coil-source coils`) - # so the coil field is recomputed from stored geometry without the .dat/.h5 file. - if preloaded_coil_sets !== nothing - pe_intr.coil_sets = copy(preloaded_coil_sets) - end + return pe_state +end - # Run perturbed equilibrium calculations - # Free-boundary wt0 drives the plasma inductance; mthvac sizes the Green's-function solves - pe_state = PerturbedEquilibrium.compute_perturbed_equilibrium( - equil, pe_odet, free_energies !== nothing ? free_energies.wt0 : nothing, ctrl.mthvac, intr, ft_ctrl, pe_ctrl, pe_intr, - metric, ffit - ) +""" + perturbed_equilibrium(ffs, rmp; forcing_modes=nothing, coil_sets=nothing, kwargs...) -> PerturbedEquilibriumState - # Write perturbed equilibrium outputs to same HDF5 file - if pe_ctrl.write_outputs_to_HDF5 - output_file = isempty(pe_ctrl.output_filename) ? ctrl.HDF5_filename : pe_ctrl.output_filename - PerturbedEquilibrium.write_outputs_to_HDF5( - pe_state, pe_intr, joinpath(intr.dir_path, output_file) - ) - @info "Results written to $output_file" - end +Compute the plasma response to the external field `rmp` on top of a force-free-states solve +`ffs`, and write the perturbed-equilibrium outputs. `rmp` is an [`RMPField`](@ref); keyword +arguments are `PerturbedEquilibrium.PerturbedEquilibriumControl` fields. - # Snapshot the coil geometry actually used into the gpec.h5 output so the run - # is replayable from the output file alone (see `main_from_h5 --coil-source coils`). - if ctrl.write_outputs_to_HDF5 && !isempty(pe_intr.coil_sets) - _write_coil_snapshot!(joinpath(intr.dir_path, ctrl.HDF5_filename), pe_intr.coil_sets) - end - end +Products the producing integrator could not supply gate the corresponding calculation: the +step warns and is skipped rather than erroring, so a Riccati- or Galerkin-fed result still +flows through. - @info "Perturbed Equilibrium completed in $(@sprintf("%.3f", time() - pe_start)) s" +`forcing_modes` injects already-loaded modes (the gpec.h5 replay path) and `coil_sets` +already-built coil geometry, both bypassing the corresponding read. - # ---------------------------------------------------------------- - # KineticForces (Neoclassical Toroidal Viscosity) - # ---------------------------------------------------------------- - if "KineticForces" in keys(inputs) - @info "\n KineticForces\n$_SECTION" - kf_start = time() - - # Standalone NTV torque diagnostics need a PE state (they contract kinetic operators - # against ξ). The self-consistent kinetic_source="calculated" path produces none — skip. - if !@isdefined(pe_state) - @info "Skipping NTV torque diagnostics: no perturbed-equilibrium data (e.g. kinetic_source=\"calculated\")." - else - # kf_ctrl and kinetic_profiles were loaded once above the stability block. - kf_intr = KineticForces.KineticForcesInternal(equil; verbose=kf_ctrl.verbose) - KineticForces.set_perturbation_data!(kf_intr, pe_state, intr, equil, metric) +```julia +pe = perturbed_equilibrium(ffs, RMPField("forcing.dat")) +``` +""" +function perturbed_equilibrium( + ffs::ForceFreeStatesResult, + rmp::ForcingTerms.RMPField; + forcing_modes::Union{Nothing,Vector{ForcingTerms.ForcingMode}}=nothing, + coil_sets::Union{Nothing,Vector{ForcingTerms.CoilSet}}=nothing, + kwargs... +) + ctrl = ffs.control + pe_ctrl = PerturbedEquilibrium.PerturbedEquilibriumControl(; kwargs...) + pe_intr = PerturbedEquilibrium.PerturbedEquilibriumInternal(; dir_path=ffs.dir_path) - kf_state = KineticForces.KineticForcesState() - KineticForces.compute_torque_all_methods!(kf_state, kf_intr, kf_ctrl, equil, kinetic_profiles) + # Inner-layer penetrated resonant field; zeros under ideal closure. + pe_intr.inner_bpen = ffs.bpen - if kf_ctrl.write_outputs_to_HDF5 - h5open(joinpath(intr.dir_path, kf_ctrl.HDF5_filename), "cw") do h5file - KineticForces.write_to_hdf5!(h5file, kf_state; dVdpsi_spline=equil.profiles.dVdpsi_spline) - end - end - end + # Reuse forcing modes loaded at snapshot time (or injected by `build_inputs_from_h5`) so + # the materialization step never re-reads the original forcing file. + if forcing_modes !== nothing + pe_intr.forcing_modes = copy(forcing_modes) + end - @info "KineticForces completed in $(@sprintf("%.3f", time() - kf_start)) s" + # Injected coil geometry (gpec.h5 replay with `--coil-source coils`) lets the coil field + # be recomputed from stored geometry without the .dat/.h5 files. + if coil_sets !== nothing + pe_intr.coil_sets = copy(coil_sets) end + pe_state = PerturbedEquilibrium.compute_perturbed_equilibrium(ffs, rmp, pe_ctrl, pe_intr) + + # Write perturbed equilibrium outputs to same HDF5 file + if pe_ctrl.write_outputs_to_HDF5 + output_file = isempty(pe_ctrl.output_filename) ? ctrl.HDF5_filename : pe_ctrl.output_filename + PerturbedEquilibrium.write_outputs_to_HDF5( + pe_state, pe_intr, joinpath(ffs.dir_path, output_file) + ) + @info "Results written to $output_file" + end + + # Snapshot the coil geometry actually used into the gpec.h5 output so the run + # is replayable from the output file alone (see `main_from_h5 --coil-source coils`). + if ctrl.write_outputs_to_HDF5 && !isempty(pe_intr.coil_sets) + _write_coil_snapshot!(joinpath(ffs.dir_path, ctrl.HDF5_filename), pe_intr.coil_sets) + end + + return pe_state +end + +""" + run_kinetic_forces(inputs, result, pe_state, kf_ctrl, kinetic_profiles) + +Compute and write the neoclassical toroidal viscosity torque diagnostics when the deck carries a +`[KineticForces]` section. No-op when the perturbed-equilibrium state the operators contract +against is missing. +""" +function run_kinetic_forces( + inputs::Dict{String,Any}, + result::ForceFreeStatesResult, + pe_state, + kf_ctrl::KineticForces.KineticForcesControl, + kinetic_profiles +) # ---------------------------------------------------------------- - # SLAYER tearing-mode analysis (after PE so it appends to the PE output - # file; falls back to the ForceFreeStates file when PE did not run). + # KineticForces (Neoclassical Toroidal Viscosity) # ---------------------------------------------------------------- - pe_file = if "PerturbedEquilibrium" in keys(inputs) - pe_out = get(inputs["PerturbedEquilibrium"], "output_filename", "") - isempty(pe_out) ? ctrl.HDF5_filename : pe_out + ("KineticForces" in keys(inputs)) || return nothing + + @info "\n KineticForces\n$_SECTION" + kf_start = time() + + # Standalone NTV torque diagnostics need a PE state (they contract kinetic operators + # against ξ). The self-consistent kinetic_source="calculated" path produces none — skip. + if pe_state === nothing + @info "Skipping NTV torque diagnostics: no perturbed-equilibrium data (e.g. kinetic_source=\"calculated\")." else - ctrl.HDF5_filename + # kf_ctrl and kinetic_profiles were loaded once before the equilibrium was re-formed. + kf_intr = KineticForces.KineticForcesInternal(result.equil; verbose=kf_ctrl.verbose) + KineticForces.set_perturbation_data!(kf_intr, pe_state, result, result.equil, result.metric) + + kf_state = KineticForces.KineticForcesState() + KineticForces.compute_torque_all_methods!(kf_state, kf_intr, kf_ctrl, result.equil, kinetic_profiles) + + if kf_ctrl.write_outputs_to_HDF5 + h5open(joinpath(result.dir_path, kf_ctrl.HDF5_filename), "cw") do h5file + KineticForces.write_to_hdf5!(h5file, kf_state; dVdpsi_spline=result.equil.profiles.dVdpsi_spline) + end + end end - slayer_result = _run_slayer_stage(pe_file) - # ---------------------------------------------------------------- - # Done - # ---------------------------------------------------------------- - @info "\n$_BANNER\n GPEC completed successfully in $(@sprintf("%.3f", time() - total_start)) s\n$_BANNER" + @info "KineticForces completed in $(@sprintf("%.3f", time() - kf_start)) s" - # TODO: Do not allow perturbed equilibrium calculations if zero crossings are found + return nothing +end - return (ctrl=ctrl, equil=equil, intr=intr, ffit=ffit, odet=odet, - free_energies=free_energies, - slayer=slayer_result) +""" + run_slayer_stage(result, inputs, pe_file) -> slayer_result +Run the SLAYER tearing-mode analysis off the force-free-states `result`, appending its group to +`pe_file` (or the force-free-states output when PE did not run). Needs only the result, so it +runs in both the `force_termination = true` path and the full pipeline. +""" +function run_slayer_stage(result::ForceFreeStatesResult, inputs::Dict{String,Any}, pe_file::Union{String,Nothing}) + ("SLAYER" in keys(inputs)) || return nothing + # SLAYER is a post-processing diagnostic. A failure here must not + # discard the equilibrium / stability / PE results already computed, + # so the whole stage is guarded: on error we log loudly and return + # `nothing` for the `slayer` field rather than propagating. + try + slayer_ctrl = Runner.slayer_control_from_toml(inputs["SLAYER"]) + slayer_ctrl.enabled || return nothing + @info "\n SLAYER\n$_SECTION" + slayer_start = time() + slayer_result = Runner.run_slayer(result, slayer_ctrl; + dir_path=result.dir_path) + @info "SLAYER completed in $(@sprintf("%.3f", time() - slayer_start)) s" + h5_filename = pe_file === nothing ? result.control.HDF5_filename : pe_file + h5_path = joinpath(result.dir_path, h5_filename) + # Append the Tearing/ group; create the file if no prior stage wrote + # it (e.g. write_outputs_to_HDF5 disabled) rather than failing on "r+". + HDF5.h5open(h5_path, isfile(h5_path) ? "r+" : "w") do f + Runner.write_slayer_hdf5!(f, slayer_result) + end + @info "SLAYER results written to $h5_filename" + return slayer_result + catch err + @error "SLAYER stage failed; continuing without tearing results. " * + "Equilibrium / stability / PE outputs are unaffected." exception = + (err, catch_backtrace()) + return nothing + end end """ - write_outputs_to_HDF5(ctrl::ForceFreeStatesControl, equil::Equilibrium.PlasmaEquilibrium, intr::ForceFreeStatesInternal, odet::OdeState) + write_outputs_to_HDF5(result::ForceFreeStatesResult; git_version, inputs, forcing_modes, + locstab, ballooning_boundary) -Helper function to write the HDF5 output file with relevant run and equilibrium parameters. -This combines the functionality of several pieces of the Fortran code in `ode_output.f`, -primarily `ode_output_open` and the various `bin_euler` writes that occur throughout the -integration. Some parameters are only dumped in their respective flags are true, e.g. -vacuum data if `vac_flag` is true. +Write the HDF5 output file with the run, equilibrium and stability products carried by a +force-free-states `result`. This combines the functionality of several pieces of the Fortran +code in `ode_output.f`, primarily `ode_output_open` and the various `bin_euler` writes that +occur throughout the integration. Groups fed by an optional result field fall back to empty +datasets when the producing integrator did not supply it, so the schema is the same shape +whatever ran. + +The solution-adjacent datasets come from two independent sources: the dense +`Solutions/ForwardIntegration/xi_*` arrays from `result.solution` when it is in the forward +`:el_axis` basis (a matched Galerkin solution is persisted in full under the Galerkin group +instead), and ψ, q, the step counts, `crit`, the asymptotic coefficients and the edge scan +from `result.diagnostics`. Either may be absent, and is then written empty. + +`locstab` and `ballooning_boundary` come from the LocalStability stage, which is not part of +the force-free-states solve. ### TODOs Combine spline unpacking if possible, too many extra lines """ function write_outputs_to_HDF5( - ctrl::ForceFreeStatesControl, - equil::Equilibrium.PlasmaEquilibrium, - intr::ForceFreeStatesInternal, - odet::OdeState, - free_energies::Union{FreeBoundaryResult,Nothing}, - ffit::Union{FourFitVars,Nothing}=nothing, + result::ForceFreeStatesResult; git_version::String="unknown", inputs::Union{Nothing,Dict{String,Any}}=nothing, forcing_modes::Union{Nothing,Vector{ForcingTerms.ForcingMode}}=nothing, - gal_data::Union{GalerkinResult,Nothing}=nothing; locstab::Union{FastInterpolations.CubicSeriesInterpolant,Nothing}=nothing, ballooning_boundary=(psi=Float64[], alpha=Float64[], alpha_critical=Float64[]) ) - # Idempotent: already done if a PerturbedEquilibrium stage ran. Leaves the stores empty - # (and the datasets below empty) on paths whose solution basis cannot supply them. - ForceFreeStates.materialize_derivative_stores!(odet, equil, ffit, intr) - - h5open(joinpath(intr.dir_path, ctrl.HDF5_filename), "w") do out_h5 + ctrl = result.control + equil = result.equil + ffit = result.ffit + free_energies = result.free_boundary + gal_data = result.galerkin + diag = result.diagnostics + msing = length(result.surfaces) + # Closed ξ profiles are written into the producing formalism's Solutions group with the + # same dataset names and (mode, solution, psi) axis order: ForwardIntegration for the + # axis-basis sweep, GalerkinIntegration for the matched gal solution (its own grid). + xi_solution = (result.solution !== nothing && result.solution.basis === :el_axis) ? result.solution : nothing + gal_solution = (result.solution !== nothing && result.solution.basis === :gal_native) ? result.solution : nothing + + h5open(joinpath(result.dir_path, ctrl.HDF5_filename), "w") do out_h5 # File-level metadata contract (schema_version, Conventions, title, date). - Utilities.HDF5Annotations.write_root_attrs!(out_h5; title="GPEC output: $(basename(abspath(intr.dir_path)))") + Utilities.HDF5Annotations.write_root_attrs!(out_h5; title="GPEC output: $(basename(abspath(result.dir_path)))") # Store git version for reproducibility out_h5["Info/git_version"] = git_version - # Outer-region Galerkin Δ′ matrix (RDCON), if computed + # Outer-region Galerkin solver outputs (RDCON), if it ran if gal_data !== nothing - write_galerkin!(out_h5, gal_data) + write_galerkin!(out_h5, gal_data; basis_output=result.debug_settings.gal_basis_output) + end + + if gal_solution !== nothing + gal = "ForceFreeStates/Solutions/GalerkinIntegration" + out_h5["$gal/psi"] = gal_solution.psi_store + out_h5["$gal/q"] = gal_solution.q_store + out_h5["$gal/xi_psi"] = gal_solution.u_store[:, :, 1, :] + out_h5["$gal/dxi_psidpsi"] = gal_solution.du_store + out_h5["$gal/xi_s"] = gal_solution.xi_s_store end # Self-contained run snapshot: the full merged TOML (so a rerun can reconstruct every @@ -744,18 +1069,18 @@ function write_outputs_to_HDF5( end # Write derived run parameters - out_h5["Info/mpert"] = intr.mpert - out_h5["Info/mlow"] = intr.mlow - out_h5["Info/mhigh"] = intr.mhigh - out_h5["Info/npert"] = intr.npert - out_h5["Info/nlow"] = intr.nlow - out_h5["Info/nhigh"] = intr.nhigh - m = [(i - 1) % intr.mpert + intr.mlow for i in 1:(intr.numpert_total)] - n = [(i - 1) ÷ intr.mpert + intr.nlow for i in 1:(intr.numpert_total)] + out_h5["Info/mpert"] = result.mpert + out_h5["Info/mlow"] = result.mlow + out_h5["Info/mhigh"] = result.mhigh + out_h5["Info/npert"] = result.npert + out_h5["Info/nlow"] = result.nlow + out_h5["Info/nhigh"] = result.nhigh + m = [(i - 1) % result.mpert + result.mlow for i in 1:(result.numpert_total)] + n = [(i - 1) ÷ result.mpert + result.nlow for i in 1:(result.numpert_total)] out_h5["Info/mn_index"] = hcat(m, n) # (N, 2) matrix - out_h5["Info/psilim"] = intr.psilim - out_h5["Info/qlim"] = intr.qlim - out_h5["Info/dqdpsi_lim"] = intr.q1lim + out_h5["Info/psilim"] = result.psilim + out_h5["Info/qlim"] = result.qlim + out_h5["Info/dqdpsi_lim"] = result.q1lim # Write derived equilibrium parameters. The struct keeps its legacy field spellings; # EQUIL_H5_NAMES maps them to literature dataset names and EQUIL_H5_SKIP drops @@ -799,8 +1124,8 @@ function write_outputs_to_HDF5( out_h5["LocalStability/D_I"] = Float64[] out_h5["LocalStability/D_R"] = Float64[] end - out_h5["SingularSurfaces/D_I"] = (locstab !== nothing && !isempty(intr.sing)) ? - [locstab(sing.psifac)[1] / sing.psifac for sing in intr.sing] : Float64[] + out_h5["SingularSurfaces/D_I"] = (locstab !== nothing && !isempty(result.surfaces)) ? + [locstab(sing.psifac)[1] / sing.psifac for sing in result.surfaces] : Float64[] out_h5["LocalStability/ballooning_Delta_prime"] = locstab !== nothing ? locstab.y[:, 4] : Float64[] # First ballooning stability boundary: experimental α vs critical α (BALOO-style). @@ -809,23 +1134,25 @@ function write_outputs_to_HDF5( out_h5["LocalStability/alpha"] = ballooning_boundary.alpha out_h5["LocalStability/alpha_critical"] = ballooning_boundary.alpha_critical - # Write integration data + # Write integration data: the ψ trace and integrator diagnostics from the raw ODE state, + # the ξ profiles from the solution. Either may be absent (Galerkin has no ODE state; + # Riccati has no ξ solution), in which case the datasets are written empty. fwd = "ForceFreeStates/Solutions/ForwardIntegration" - out_h5["$fwd/nstep"] = odet.step # Number of saved solution snapshots - out_h5["$fwd/nstep_total"] = odet.total_steps # Total ODE solver steps taken - out_h5["$fwd/psi"] = odet.psi_store - out_h5["$fwd/q"] = odet.q_store - out_h5["$fwd/xi_psi"] = odet.u_store[:, :, 1, :] - out_h5["$fwd/u2"] = odet.u_store[:, :, 2, :] # TODO: what to name this? These are the "conjugate momenta" of u1 - out_h5["$fwd/dxi_psidpsi"] = odet.du_store - out_h5["$fwd/xi_s"] = odet.xi_s_store - out_h5["$fwd/crit"] = odet.crit_store + out_h5["$fwd/nstep"] = diag !== nothing ? diag.step : 0 # Number of saved solution snapshots + out_h5["$fwd/nstep_total"] = diag !== nothing ? diag.total_steps : 0 # Total ODE solver steps taken + out_h5["$fwd/psi"] = diag !== nothing ? diag.psi_store : Float64[] + out_h5["$fwd/q"] = diag !== nothing ? diag.q_store : Float64[] + out_h5["$fwd/xi_psi"] = xi_solution !== nothing ? xi_solution.u_store[:, :, 1, :] : ComplexF64[] + out_h5["$fwd/u2"] = xi_solution !== nothing ? xi_solution.u_store[:, :, 2, :] : ComplexF64[] # TODO: what to name this? These are the "conjugate momenta" of u1 + out_h5["$fwd/dxi_psidpsi"] = xi_solution !== nothing ? xi_solution.du_store : ComplexF64[] + out_h5["$fwd/xi_s"] = xi_solution !== nothing ? xi_solution.xi_s_store : ComplexF64[] + out_h5["$fwd/crit"] = diag !== nothing ? diag.crit_store : Float64[] # Write edge stability scan data (only present when psiedge < psilim). # Generalized (W, N) pencil energies — power-normalized, Jacobian-invariant; these are # the values findmax_dW_edge! uses to choose the truncation point. - if !isempty(odet.edge_scan.psi) - es = odet.edge_scan + if diag !== nothing && !isempty(diag.edge_scan.psi) + es = diag.edge_scan out_h5["ForceFreeStates/EdgeScan/psi"] = es.psi out_h5["ForceFreeStates/EdgeScan/q"] = es.q out_h5["ForceFreeStates/EdgeScan/total_energy"] = es.total_eigenvalue @@ -835,19 +1162,19 @@ function write_outputs_to_HDF5( end # Write singular surface data - out_h5["SingularSurfaces/rational_count"] = intr.msing - out_h5["SingularSurfaces/rational_psi"] = [sing.psifac for sing in intr.sing] - out_h5["SingularSurfaces/rational_q"] = [sing.q for sing in intr.sing] - out_h5["SingularSurfaces/dqdpsi"] = [sing.q1 for sing in intr.sing] - out_h5["SingularSurfaces/ca_left"] = odet.ca_l - out_h5["SingularSurfaces/ca_right"] = odet.ca_r - - if intr.msing > 0 + out_h5["SingularSurfaces/rational_count"] = msing + out_h5["SingularSurfaces/rational_psi"] = [sing.psifac for sing in result.surfaces] + out_h5["SingularSurfaces/rational_q"] = [sing.q for sing in result.surfaces] + out_h5["SingularSurfaces/dqdpsi"] = [sing.q1 for sing in result.surfaces] + out_h5["SingularSurfaces/ca_left"] = diag !== nothing ? diag.ca_l : ComplexF64[] + out_h5["SingularSurfaces/ca_right"] = diag !== nothing ? diag.ca_r : ComplexF64[] + + if msing > 0 # Mode numbers at each surface (jagged — pad with 0 to max_modes width) - max_modes = maximum(s -> length(s.m), intr.sing) - m_matrix = zeros(Int, intr.msing, max_modes) - n_matrix = zeros(Int, intr.msing, max_modes) - for (s, sing) in enumerate(intr.sing) + max_modes = maximum(s -> length(s.m), result.surfaces) + m_matrix = zeros(Int, msing, max_modes) + n_matrix = zeros(Int, msing, max_modes) + for (s, sing) in enumerate(result.surfaces) for i in 1:length(sing.m) m_matrix[s, i] = sing.m[i] n_matrix[s, i] = sing.n[i] @@ -862,56 +1189,60 @@ function write_outputs_to_HDF5( # (avg_bsq_over_dpsisq, avg_bsq) quantities are written so # downstream consumers (Tearing.InnerLayer.GGJ.build_ggj_inputs) # can reconstruct τ_A / τ_R from any kinetic-profile source. - if all(s -> s.restype !== nothing, intr.sing) - out_h5["SingularSurfaces/E"] = [s.restype.E for s in intr.sing] - out_h5["SingularSurfaces/F"] = [s.restype.F for s in intr.sing] - out_h5["SingularSurfaces/G"] = [s.restype.G for s in intr.sing] - out_h5["SingularSurfaces/H"] = [s.restype.H for s in intr.sing] - out_h5["SingularSurfaces/K"] = [s.restype.K for s in intr.sing] - out_h5["SingularSurfaces/M"] = [s.restype.M for s in intr.sing] - out_h5["SingularSurfaces/avg_bsq_over_dpsisq"] = [s.restype.avg_bsq_over_dpsisq for s in intr.sing] - out_h5["SingularSurfaces/avg_bsq"] = [s.restype.avg_bsq for s in intr.sing] - out_h5["SingularSurfaces/mu0p"] = [s.restype.p_local for s in intr.sing] - out_h5["SingularSurfaces/dmu0pdpsi"] = [s.restype.p1_local for s in intr.sing] - out_h5["SingularSurfaces/dVdpsi"] = [s.restype.v1_local for s in intr.sing] + if all(s -> s.restype !== nothing, result.surfaces) + out_h5["SingularSurfaces/E"] = [s.restype.E for s in result.surfaces] + out_h5["SingularSurfaces/F"] = [s.restype.F for s in result.surfaces] + out_h5["SingularSurfaces/G"] = [s.restype.G for s in result.surfaces] + out_h5["SingularSurfaces/H"] = [s.restype.H for s in result.surfaces] + out_h5["SingularSurfaces/K"] = [s.restype.K for s in result.surfaces] + out_h5["SingularSurfaces/M"] = [s.restype.M for s in result.surfaces] + out_h5["SingularSurfaces/avg_bsq_over_dpsisq"] = [s.restype.avg_bsq_over_dpsisq for s in result.surfaces] + out_h5["SingularSurfaces/avg_bsq"] = [s.restype.avg_bsq for s in result.surfaces] + out_h5["SingularSurfaces/mu0p"] = [s.restype.p_local for s in result.surfaces] + out_h5["SingularSurfaces/dmu0pdpsi"] = [s.restype.p1_local for s in result.surfaces] + out_h5["SingularSurfaces/dVdpsi"] = [s.restype.v1_local for s in result.surfaces] end end # Per-surface ca-based Δ' (`sing.delta_prime`) is a stub; only the BVP matrix is emitted (see SingType.delta_prime docstring). - # Write inter-surface Δ' matrix if computed (parallel FM path only). - # Shape: [msing × msing] — PEST3-convention deltap (STRIDE BVP with vacuum coupling). - if intr.msing > 0 && !isempty(intr.delta_prime_matrix) - out_h5["SingularSurfaces/Delta_prime_matrix"] = intr.delta_prime_matrix - end - - # Edge coil-response matrix, stored (numpert_total × 2msing) = (edge mode, surface-side) to match - # the SingularSurfaces/GalerkinDeltaPrime/Delta_coil layout so H5Web heatmaps share axes - # (x = edge mode, y = surface-side). - # Internal intr.delta_coil stays (2msing × numpert_total); transpose only at write. - if intr.msing > 0 && !isempty(intr.delta_coil) - dc = permutedims(intr.delta_coil) - out_h5["SingularSurfaces/Delta_coil"] = dc - end - - # Write raw 2msing×2msing outer-region D' matrix in side-major ordering - # [L_s1, R_s1, L_s2, R_s2, …]. Byte-compatible with Fortran - # rdcon/gal.f::gal_write_delta top 2msing×2msing block of delta_gw.dat. - # Needed for the full det(D' − D(γ)) = 0 eigenvalue problem via - # pest3_decompose to recover (A', B', Γ', Δ'). - if intr.msing > 0 && !isempty(intr.delta_prime_raw) - out_h5["SingularSurfaces/Delta_prime_raw"] = intr.delta_prime_raw + # Write the Δ' payload on one set of canonical paths, whichever formalism produced it + # (Riccati BVP or Galerkin): both compute the same quantities in the same PEST-3 + # convention. Surface indexing follows the producing formalism's surface list, which for + # Galerkin is the in-domain in-band subset of `SingularSurfaces/`. + dp = result.delta_prime + if msing > 0 && dp !== nothing + # Inter-surface Δ' matrix, shape [msing × msing] — PEST3-convention deltap. + out_h5["SingularSurfaces/Delta_prime_matrix"] = dp.matrix + + # Edge coil-response matrix, stored (numpert_total × 2msing) = (edge mode, surface-side) + # so H5Web heatmaps read x = edge mode, y = surface-side. The carried matrix stays + # (2msing × numpert_total); transpose only at write. + isempty(dp.coil) || (out_h5["SingularSurfaces/Delta_coil"] = permutedims(dp.coil)) + + # Raw 2msing×2msing outer-region D' matrix in side-major ordering + # [L_s1, R_s1, L_s2, R_s2, …]. Byte-compatible with Fortran + # rdcon/gal.f::gal_write_delta top 2msing×2msing block of delta_gw.dat. + # Needed for the full det(D' − D(γ)) = 0 eigenvalue problem via + # pest3_decompose to recover (A', B', Γ', Δ'). + isempty(dp.raw) || (out_h5["SingularSurfaces/Delta_prime_raw"] = dp.raw) + + # Remaining PEST-3 parity blocks, when the formalism persisted them (Galerkin); + # Riccati recovers them from Delta_prime_raw via pest3_decompose. + dp.A === nothing || (out_h5["SingularSurfaces/pest3_A"] = dp.A) + dp.B === nothing || (out_h5["SingularSurfaces/pest3_B"] = dp.B) + dp.Gamma === nothing || (out_h5["SingularSurfaces/pest3_Gamma"] = dp.Gamma) end # Write kinetic singular surface data (det(F̄) near-zeros) and the cond(F̄) scan # used to find them. Populated only when kinetic crossings were searched for. - out_h5["SingularSurfaces/Kinetic/rational_count"] = intr.kmsing - out_h5["SingularSurfaces/Kinetic/rational_psi"] = [s.psifac for s in intr.kinsing] - out_h5["SingularSurfaces/Kinetic/rational_q"] = [s.q for s in intr.kinsing] - out_h5["SingularSurfaces/Kinetic/dqdpsi"] = [s.q1 for s in intr.kinsing] - out_h5["SingularSurfaces/Kinetic/scan_psi"] = intr.kinsing_scan_psi - out_h5["SingularSurfaces/Kinetic/scan_cond"] = intr.kinsing_scan_cond - out_h5["SingularSurfaces/Kinetic/scan_threshold"] = intr.kinsing_scan_threshold + out_h5["SingularSurfaces/Kinetic/rational_count"] = result.kinetic.kmsing + out_h5["SingularSurfaces/Kinetic/rational_psi"] = [s.psifac for s in result.kinetic.kinsing] + out_h5["SingularSurfaces/Kinetic/rational_q"] = [s.q for s in result.kinetic.kinsing] + out_h5["SingularSurfaces/Kinetic/dqdpsi"] = [s.q1 for s in result.kinetic.kinsing] + out_h5["SingularSurfaces/Kinetic/scan_psi"] = result.kinetic.scan_psi + out_h5["SingularSurfaces/Kinetic/scan_cond"] = result.kinetic.scan_cond + out_h5["SingularSurfaces/Kinetic/scan_threshold"] = result.kinetic.scan_threshold # Write free-boundary stability data. The eigenmode energies are the generalized # eigenvalues of the pencil (W, N) with N the power-normalization (surface-norm) matrix: @@ -939,59 +1270,57 @@ function write_outputs_to_HDF5( out_h5["SurfaceGeometries/Wall/z"] = free_energies !== nothing ? free_energies.wall_pts[:, 3] : Float64[] # Write fundamental matrices on the ψ grid - if ffit !== nothing - xs = equil.rzphi_xs - npsi = length(xs) - np = intr.numpert_total - - # Helper: evaluate a matrix spline on the psi grid → (npsi, np, np) array - function _eval_mat_spline(spline) - arr = zeros(ComplexF64, npsi, np, np) - hint = Ref(1) - for i in 1:npsi - arr[i, :, :] .= reshape(spline(xs[i]; hint=hint), np, np) - end - return arr + xs = equil.rzphi_xs + npsi = length(xs) + np = result.numpert_total + + # Helper: evaluate a matrix spline on the psi grid → (npsi, np, np) array + function _eval_mat_spline(spline) + arr = zeros(ComplexF64, npsi, np, np) + hint = Ref(1) + for i in 1:npsi + arr[i, :, :] .= reshape(spline(xs[i]; hint=hint), np, np) end + return arr + end - elm = "ForceFreeStates/EulerLagrangeMatrices" - out_h5["$elm/psi"] = xs - # Ideal primitive matrices (A, B, C, D, E, H) - # When kinetic mode is on, amats/bmats/cmats hold kinetic-modified values, - # so we write those as the "effective" matrices and save raw kinetic - # components separately below. - if ctrl.kinetic_factor > 0 - # Use preserved ideal copies (before kinetic overwrite) - out_h5["$elm/Ideal/A"] = _eval_mat_spline(ffit.amats_ideal) - out_h5["$elm/Ideal/B"] = _eval_mat_spline(ffit.bmats_ideal) - out_h5["$elm/Ideal/C"] = _eval_mat_spline(ffit.cmats_ideal) - else - out_h5["$elm/Ideal/A"] = _eval_mat_spline(ffit.amats) - out_h5["$elm/Ideal/B"] = _eval_mat_spline(ffit.bmats) - out_h5["$elm/Ideal/C"] = _eval_mat_spline(ffit.cmats) - end - out_h5["$elm/Ideal/D"] = _eval_mat_spline(ffit.dmats_prim) - out_h5["$elm/Ideal/E"] = _eval_mat_spline(ffit.emats_prim) - out_h5["$elm/Ideal/H"] = _eval_mat_spline(ffit.hmats) - - # Ideal derived matrices (F, K, G) - out_h5["$elm/Ideal/F"] = _eval_mat_spline(ffit.fmats_lower) - out_h5["$elm/Ideal/K"] = _eval_mat_spline(ffit.kmats) - out_h5["$elm/Ideal/G"] = _eval_mat_spline(ffit.gmats) - - # Kinetic-modified matrices - if ctrl.kinetic_factor > 0 - out_h5["$elm/Kinetic/A"] = _eval_mat_spline(ffit.amats) - out_h5["$elm/Kinetic/B"] = _eval_mat_spline(ffit.bmats) - out_h5["$elm/Kinetic/C"] = _eval_mat_spline(ffit.cmats) - out_h5["$elm/Kinetic/f0"] = _eval_mat_spline(ffit.f0mats) - out_h5["$elm/Kinetic/K"] = _eval_mat_spline(ffit.kkmats) - out_h5["$elm/Kinetic/G"] = _eval_mat_spline(ffit.gaats) - end + elm = "ForceFreeStates/EulerLagrangeMatrices" + out_h5["$elm/psi"] = xs + # Ideal primitive matrices (A, B, C, D, E, H) + # When kinetic mode is on, amats/bmats/cmats hold kinetic-modified values, + # so we write those as the "effective" matrices and save raw kinetic + # components separately below. + if ctrl.kinetic_factor > 0 + # Use preserved ideal copies (before kinetic overwrite) + out_h5["$elm/Ideal/A"] = _eval_mat_spline(ffit.amats_ideal) + out_h5["$elm/Ideal/B"] = _eval_mat_spline(ffit.bmats_ideal) + out_h5["$elm/Ideal/C"] = _eval_mat_spline(ffit.cmats_ideal) + else + out_h5["$elm/Ideal/A"] = _eval_mat_spline(ffit.amats) + out_h5["$elm/Ideal/B"] = _eval_mat_spline(ffit.bmats) + out_h5["$elm/Ideal/C"] = _eval_mat_spline(ffit.cmats) + end + out_h5["$elm/Ideal/D"] = _eval_mat_spline(ffit.dmats_prim) + out_h5["$elm/Ideal/E"] = _eval_mat_spline(ffit.emats_prim) + out_h5["$elm/Ideal/H"] = _eval_mat_spline(ffit.hmats) + + # Ideal derived matrices (F, K, G) + out_h5["$elm/Ideal/F"] = _eval_mat_spline(ffit.fmats_lower) + out_h5["$elm/Ideal/K"] = _eval_mat_spline(ffit.kmats) + out_h5["$elm/Ideal/G"] = _eval_mat_spline(ffit.gmats) + + # Kinetic-modified matrices + if ctrl.kinetic_factor > 0 + out_h5["$elm/Kinetic/A"] = _eval_mat_spline(ffit.amats) + out_h5["$elm/Kinetic/B"] = _eval_mat_spline(ffit.bmats) + out_h5["$elm/Kinetic/C"] = _eval_mat_spline(ffit.cmats) + out_h5["$elm/Kinetic/f0"] = _eval_mat_spline(ffit.f0mats) + out_h5["$elm/Kinetic/K"] = _eval_mat_spline(ffit.kkmats) + out_h5["$elm/Kinetic/G"] = _eval_mat_spline(ffit.gaats) end # Self-describing metadata pass (long_name/units/dims + dimension scales). - apply_main_h5_metadata!(out_h5, intr) + apply_main_h5_metadata!(out_h5, result) end end @@ -1020,13 +1349,17 @@ for that `n_tor`. For multi-n runs the eigenvalue array `et` is sorted by stabil n-blocks; `n_tor_idx[i]` identifies which n-block eigenvalue `i` belongs to, so each n_tor receives the correct least-stable δW regardless of how modes are interleaved in `et`. -The `result` argument is the named tuple returned by `main`. +The `result` argument is the named tuple returned by `main`; its `ffs` field carries the +force-free-states result the energies are read from. """ function write_imas(dd, result) - result.free_energies === nothing && return - - free_energies = result.free_energies - intr = result.intr + ffs = result.ffs + if ffs.free_boundary === nothing + @warn "Skipping IMAS mhd_linear write: the $(ffs.integrator) run produced no free-boundary energies. " * + "Set vac_flag=true in [ForceFreeStates]." + return + end + free_energies = ffs.free_boundary # Top-level metadata dd.mhd_linear.code.name = "GPEC" @@ -1038,11 +1371,11 @@ function write_imas(dd, result) # Write the least-stable energy for each toroidal mode number # n_tor_idx[i] (0-based) identifies which n-block eigenvalue i belongs to. - resize!(ts.toroidal_mode, intr.npert) - for j in 0:(intr.npert-1) + resize!(ts.toroidal_mode, ffs.npert) + for j in 0:(ffs.npert-1) n_indices = findall(==(j), free_energies.n_tor_idx) # indices of eigenvalues in the j-th n-block mode = ts.toroidal_mode[j+1] - mode.n_tor = intr.nlow + j + mode.n_tor = ffs.nlow + j mode.energy_perturbed = minimum(real.(free_energies.et[n_indices])) # least-stable energy for this n-toroidal mode end @@ -1050,5 +1383,7 @@ function write_imas(dd, result) end export main, write_imas +export solve, perturbed_equilibrium +export PlasmaEquilibrium, EulerLagrangeProblem, Forward, Riccati, Galerkin, ResistiveMatch, ForceFreeStatesResult, RMPField end # module GeneralizedPerturbedEquilibrium diff --git a/src/HDF5Schema.jl b/src/HDF5Schema.jl index a1a47ef44..b8b0333c7 100644 --- a/src/HDF5Schema.jl +++ b/src/HDF5Schema.jl @@ -207,10 +207,14 @@ const MAIN_H5_ANNOTATIONS = [ (; long_name="μ0 × dp/dψ_N at each surface", units="T^2", dims=("surface",), attach=(1 => "SingularSurfaces/rational_psi", 1 => "SingularSurfaces/rational_q")), "SingularSurfaces/dVdpsi" => (; long_name="dV/dψ_N at each surface", units="m^3", dims=("surface",), attach=(1 => "SingularSurfaces/rational_psi", 1 => "SingularSurfaces/rational_q")), - "SingularSurfaces/Delta_prime_matrix" => (; long_name="inter-surface Δ' matrix (PEST3 convention, STRIDE BVP with vacuum coupling)", dims=("surface_row", "surface_col")), + "SingularSurfaces/Delta_prime_matrix" => + (; long_name="inter-surface Δ' matrix (PEST-3 tearing↔tearing block; STRIDE BVP or Galerkin outer region)", dims=("surface_row", "surface_col")), "SingularSurfaces/Delta_prime_raw" => (; long_name="raw 2msing×2msing outer-region D' matrix, side-major ordering [L_s1, R_s1, ...]", dims=("surface_side_row", "surface_side_col")), "SingularSurfaces/Delta_coil" => (; long_name="edge coil-response matrix (edge mode × surface-side)", dims=("mode", "surface_side")), + "SingularSurfaces/pest3_A" => (; long_name="PEST-3 matching block A' (interchange↔interchange)", dims=("surface_row", "surface_col")), + "SingularSurfaces/pest3_B" => (; long_name="PEST-3 matching block B' (interchange↔tearing)", dims=("surface_row", "surface_col")), + "SingularSurfaces/pest3_Gamma" => (; long_name="PEST-3 matching block Γ' (tearing↔interchange)", dims=("surface_row", "surface_col")), # --- SingularSurfaces/Kinetic/ --- "SingularSurfaces/Kinetic/rational_count" => (; long_name="number of kinetic singular surfaces (det(F̄) near-zeros)"), "SingularSurfaces/Kinetic/rational_psi" => (; long_name="normalized poloidal flux ψ_N of kinetic singular surfaces", scale="psi_kinetic_rational"), diff --git a/src/KineticForces/KineticForces.jl b/src/KineticForces/KineticForces.jl index 8f83780c7..1ac79ad22 100644 --- a/src/KineticForces/KineticForces.jl +++ b/src/KineticForces/KineticForces.jl @@ -38,6 +38,7 @@ using Roots import ..ForceFreeStates import ..Equilibrium import ..Utilities +import ..PerturbedEquilibrium # Supporting data structures and utilities include("KineticForcesStructs.jl") diff --git a/src/KineticForces/KineticForcesStructs.jl b/src/KineticForces/KineticForcesStructs.jl index 0d1b46464..4de379330 100644 --- a/src/KineticForces/KineticForcesStructs.jl +++ b/src/KineticForces/KineticForcesStructs.jl @@ -286,7 +286,7 @@ function KineticForcesInternal(equil; verbose::Bool=false) end """ - set_perturbation_data!(kf_intr, pe_state, ffs_intr, equil, metric) + set_perturbation_data!(kf_intr, pe_state, ffs, equil, metric) Populate perturbation data from PerturbedEquilibriumState into KineticForcesInternal. @@ -301,23 +301,24 @@ The JBB deweighting algorithm (Fortran pentrc/inputs.f90:828-868): 3. Divide by J·B² at each θ 4. Forward DFT back to m-space """ -function set_perturbation_data!(kf_intr::KineticForcesInternal, pe_state, ffs_intr, +function set_perturbation_data!(kf_intr::KineticForcesInternal, pe_state::PerturbedEquilibrium.PerturbedEquilibriumState, + ffs::ForceFreeStates.ForceFreeStatesResult, equil::Equilibrium.PlasmaEquilibrium, metric::ForceFreeStates.MetricData) # Copy mode numbers from FFS - kf_intr.mlow = ffs_intr.mlow - kf_intr.mhigh = ffs_intr.mhigh - kf_intr.mpert = ffs_intr.mpert - kf_intr.nlow = ffs_intr.nlow - kf_intr.nhigh = ffs_intr.nhigh - kf_intr.npert = ffs_intr.npert - kf_intr.numpert_total = ffs_intr.numpert_total - kf_intr.mfac = collect(ffs_intr.mlow:ffs_intr.mhigh) - kf_intr.psilim = ffs_intr.psilim + kf_intr.mlow = ffs.mlow + kf_intr.mhigh = ffs.mhigh + kf_intr.mpert = ffs.mpert + kf_intr.nlow = ffs.nlow + kf_intr.nhigh = ffs.nhigh + kf_intr.npert = ffs.npert + kf_intr.numpert_total = ffs.numpert_total + kf_intr.mfac = collect(ffs.mlow:ffs.mhigh) + kf_intr.psilim = ffs.psilim # Rational-surface ψ locations (ideal + kinetic EL) become panel boundaries for the # outer ψ torque quadrature; dedupe against coincident points happens in psi_panel_points. - kf_intr.sing_psis = sort!(vcat([s.psifac for s in ffs_intr.sing], [s.psifac for s in ffs_intr.kinsing])) + kf_intr.sing_psis = sort!(vcat([s.psifac for s in ffs.surfaces], [s.psifac for s in ffs.kinetic.kinsing])) # Bail if no xi_modes available (PE didn't run or failed) if pe_state.xi_modes === nothing || isempty(pe_state.psi_grid) @@ -328,7 +329,7 @@ function set_perturbation_data!(kf_intr::KineticForcesInternal, pe_state, ffs_in xi_modes = pe_state.xi_modes psi_grid = pe_state.psi_grid npsi = length(psi_grid) - mpert = ffs_intr.mpert + mpert = ffs.mpert # Build xs_m: 3 CubicSeriesInterpolants from Clebsch displacement matrices # xs_m[1] = ξ^ψ (unregularized), xs_m[2] = ∂ξ^ψ/∂ψ (regularized), xs_m[3] = ξ^α @@ -341,11 +342,11 @@ function set_perturbation_data!(kf_intr::KineticForcesInternal, pe_state, ffs_in kf_intr.xs_m = [xs_m_1, xs_m_2, xs_m_3] # Build geometric matrices (S,T,X,Y,Z) for JBB deweighting - geom_mats = ForceFreeStates.build_kinetic_metric_matrices(equil, ffs_intr, metric) + geom_mats = ForceFreeStates.build_kinetic_metric_matrices(equil, ffs, metric) # Build FourierTransform for the JBB deweighting DFT round-trip mthsurf = kf_intr.mthsurf - ft = Utilities.FourierTransforms.FourierTransform(mthsurf, mpert, ffs_intr.mlow) + ft = Utilities.FourierTransforms.FourierTransform(mthsurf, mpert, ffs.mlow) # JBB deweighting: convert Clebsch modes → physical δB/B and ∇·ξ⊥ modes dbob_m_data = zeros(ComplexF64, npsi, mpert) diff --git a/src/LocalStability/Ballooning.jl b/src/LocalStability/Ballooning.jl index 95e7c0a08..2add47a62 100644 --- a/src/LocalStability/Ballooning.jl +++ b/src/LocalStability/Ballooning.jl @@ -98,8 +98,8 @@ flux surface [Glasser-Greene-Johnson; Glasser Phys. Plasmas 23, 112506 field and metric quantities. The main local-stability scan takes `D_I` from the `det(d0bar)` calculation -reported as `LocalStability/di`, then combines it with this surface-average `H` to -form `LocalStability/dr`. This avoids recomputing a separate surface-average `D_I` +reported as `LocalStability/D_I`, then combines it with this surface-average `H` to +form `LocalStability/D_R`. This avoids recomputing a separate surface-average `D_I` inside the `D_R` path. """ function resistive_interchange_h(flux_surface_index::Int, plasma_eq::Equilibrium.PlasmaEquilibrium) diff --git a/src/PerturbedEquilibrium/FieldReconstruction.jl b/src/PerturbedEquilibrium/FieldReconstruction.jl index e491477c4..65f36ee51 100644 --- a/src/PerturbedEquilibrium/FieldReconstruction.jl +++ b/src/PerturbedEquilibrium/FieldReconstruction.jl @@ -36,8 +36,8 @@ Covariant components from metric tensor contraction (matches Fortran gpeq_cova): """ reconstruct_physical_fields( - response_vector, flux_matrix, ForceFreeStates_results, - equil, ffs_intr, intr, metric, ffit, ctrl + response_vector, flux_matrix, solution, + equil, ffs, intr, metric, ffit, ctrl ) -> (xi_modes, b_modes) Reconstruct displacement and perturbed magnetic field from eigenmode response. @@ -69,16 +69,16 @@ Tuple of (xi_modes, b_modes) NamedTuples: function reconstruct_physical_fields( response_vector::Vector{ComplexF64}, flux_matrix::Matrix{ComplexF64}, - ForceFreeStates_results::OdeState, + solution::SolutionProfiles, equil::Equilibrium.PlasmaEquilibrium, - ffs_intr::ForceFreeStatesInternal, + ffs::ForceFreeStatesResult, intr::PerturbedEquilibriumInternal, metric::MetricData, ffit::FourFitVars, ctrl::PerturbedEquilibriumControl ) - npsi = size(ForceFreeStates_results.u_store, 4) - psi_grid = ForceFreeStates_results.psi_store[1:npsi] + npsi = size(solution.u_store, 4) + psi_grid = solution.psi_store[1:npsi] # Pin BLAS to a single thread for the per-surface reconstruction below. Each threaded # loop over ψ calls only small BLAS kernels (per-surface mpert×mpert solves and mode @@ -92,33 +92,33 @@ function reconstruct_physical_fields( xi_psi_modes, xi_psi1_modes, xi_s_modes = sum_eigenmode_contributions( response_vector, flux_matrix, - ForceFreeStates_results, - ffs_intr + solution, + ffs ) # Compute perturbed field in mode space using ideal MHD relations # [Park Phys. Plasmas 14, 052110 (2007) eq. 8-10] b_psi_modes, b_theta_modes, b_zeta_modes = compute_perturbed_field_modes( xi_psi_modes, xi_psi1_modes, xi_s_modes, - psi_grid, equil, ffs_intr + psi_grid, equil, ffs ) # Compute Clebsch displacements with regularization (matches Fortran gpeq_sol + gpout_xclebsch) clebsch_psi, clebsch_psi1, clebsch_alpha = compute_clebsch_displacements( xi_psi_modes, xi_psi1_modes, xi_s_modes, - psi_grid, equil, ffs_intr, ffit, ctrl + psi_grid, equil, ffs, ffit, ctrl ) # Compute regularized (modified) b-field components (matches Fortran gpeq_sol bmt/bmz) b_theta_reg, b_zeta_reg = compute_modified_field_modes( xi_psi_modes, clebsch_psi1, clebsch_alpha, - psi_grid, equil, ffs_intr + psi_grid, equil, ffs ) # Compute contravariant displacement via Jacobian convolution (matches Fortran gpeq_contra) xwp_modes, xwt_modes, xwz_modes, xmt_modes, xmz_modes = compute_contra_displacements( xi_psi_modes, clebsch_psi1, clebsch_alpha, - psi_grid, equil, ffs_intr, metric, ctrl + psi_grid, equil, ffs, metric, ctrl ) # Compute covariant components via metric tensor contraction (matches Fortran gpeq_cova) @@ -126,7 +126,7 @@ function reconstruct_physical_fields( bvp_modes, bvt_modes, bvz_modes = compute_cova_components( xwp_modes, xmt_modes, xmz_modes, b_psi_modes, b_theta_reg, b_zeta_reg, - psi_grid, ffs_intr, metric + psi_grid, ffs, metric ) # Compute b^ψ / area for HDF5 output — matches Fortran gpout_xbnormal fast path @@ -164,7 +164,7 @@ function reconstruct_physical_fields( # b: uses raw psi (bwp from gpeq_sol, no Jacobian convolution) and regularized theta (bmt) # Build the (ψ,θ) flux→cylindrical geometry once and reuse it for both ξ and b: the # transform matrices depend only on equilibrium geometry, not on the perturbed field. - mlow = ffs_intr.mlow + mlow = ffs.mlow mpert_rz = size(xi_psi_modes, 2) mtheta_rz = max(2 * (abs(mlow) + mpert_rz), 512) ft_rz = Utilities.FourierTransforms.FourierTransform(mtheta_rz, mpert_rz, mlow) @@ -208,7 +208,7 @@ end """ sum_eigenmode_contributions( - response_vector, flux_matrix, ForceFreeStates_results, ffs_intr + response_vector, flux_matrix, solution, ffs ) -> (xi_psi_modes, xi_psi1_modes, xi_s_modes) Sum eigenmode contributions weighted by response coefficients. @@ -231,11 +231,11 @@ xi_s[ipsi, :] = xi_s_store[:, :, ipsi] * alpha # Ξ_s (toroidal, Glasser 2 function sum_eigenmode_contributions( response_vector::Vector{ComplexF64}, flux_matrix::Matrix{ComplexF64}, - ForceFreeStates_results::OdeState, - ffs_intr::ForceFreeStatesInternal + solution::SolutionProfiles, + ffs::ForceFreeStatesResult ) - mpert = ffs_intr.mpert - npsi = size(ForceFreeStates_results.u_store, 4) + mpert = ffs.mpert + npsi = size(solution.u_store, 4) # Convert mode-basis response (Phi_tot) to eigenmode amplitudes alpha # flux_matrix[mode, eigenmode], so: flux_matrix * alpha = response_vector @@ -249,15 +249,15 @@ function sum_eigenmode_contributions( # u_store[:,:,1] = Ξ_ψ (radial displacement). @view avoids copying the mpert×mpert # eigenmode-matrix slice on every surface (mul! takes the view directly). mul!(view(xi_psi_modes, ipsi, :), - @view(ForceFreeStates_results.u_store[:, :, 1, ipsi]), + @view(solution.u_store[:, :, 1, ipsi]), alpha) # du_store = dΞ_ψ/dψ (radial derivative) mul!(view(xi_psi1_modes, ipsi, :), - @view(ForceFreeStates_results.du_store[:, :, ipsi]), + @view(solution.du_store[:, :, ipsi]), alpha) # xi_s_store = Ξ_s = -A⁻¹(B·Ξ'_ψ + C·Ξ_ψ) (toroidal displacement, Glasser 2016 eq. 18) mul!(view(xi_s_modes, ipsi, :), - @view(ForceFreeStates_results.xi_s_store[:, :, ipsi]), + @view(solution.xi_s_store[:, :, ipsi]), alpha) end @@ -266,7 +266,7 @@ end """ compute_perturbed_field_modes( - xi_psi_modes, xi_psi1_modes, xi_s_modes, psi_grid, equil, ffs_intr + xi_psi_modes, xi_psi1_modes, xi_s_modes, psi_grid, equil, ffs ) -> (b_psi_modes, b_theta_modes, b_zeta_modes) Compute contravariant perturbed B-field from displacement using ideal MHD relations. @@ -283,7 +283,7 @@ function compute_perturbed_field_modes( xi_s_modes::Matrix{ComplexF64}, psi_grid::Vector{Float64}, equil::Equilibrium.PlasmaEquilibrium, - ffs_intr::ForceFreeStatesInternal + ffs::ForceFreeStatesResult ) npsi, mpert = size(xi_psi_modes) @@ -291,8 +291,8 @@ function compute_perturbed_field_modes( b_theta_modes = zeros(ComplexF64, npsi, mpert) b_zeta_modes = zeros(ComplexF64, npsi, mpert) - mlow = ffs_intr.mlow - nn = ffs_intr.nlow + mlow = ffs.mlow + nn = ffs.nlow chi1 = 2π * equil.psio Threads.@threads :static for ipsi in 1:npsi @@ -321,7 +321,7 @@ end """ compute_clebsch_displacements( xi_psi_modes, xi_psi1_modes, xi_s_modes, - psi_grid, equil, ffs_intr, ffit, ctrl + psi_grid, equil, ffs, ffit, ctrl ) -> (clebsch_psi, clebsch_psi1, clebsch_alpha) Compute Clebsch displacement components for PENTRC output. @@ -343,15 +343,15 @@ function compute_clebsch_displacements( xi_s_modes::Matrix{ComplexF64}, psi_grid::Vector{Float64}, equil::Equilibrium.PlasmaEquilibrium, - ffs_intr::ForceFreeStatesInternal, + ffs::ForceFreeStatesResult, ffit::FourFitVars, ctrl::PerturbedEquilibriumControl ) npsi, mpert = size(xi_psi_modes) - nn = ffs_intr.nlow - mlow = ffs_intr.mlow + nn = ffs.nlow + mlow = ffs.mlow chi1 = 2π * equil.psio - numpert_total = ffs_intr.numpert_total + numpert_total = ffs.numpert_total clebsch_psi = copy(xi_psi_modes) # ξ^ψ (unregularized) clebsch_psi1 = copy(xi_psi1_modes) # will be regularized below @@ -420,7 +420,7 @@ end """ compute_modified_field_modes( - xi_psi_modes, clebsch_psi1, clebsch_alpha, psi_grid, equil, ffs_intr + xi_psi_modes, clebsch_psi1, clebsch_alpha, psi_grid, equil, ffs ) -> (b_theta_reg, b_zeta_reg) Compute regularized (modified) contravariant B-field components. @@ -437,11 +437,11 @@ function compute_modified_field_modes( clebsch_alpha::Matrix{ComplexF64}, psi_grid::Vector{Float64}, equil::Equilibrium.PlasmaEquilibrium, - ffs_intr::ForceFreeStatesInternal + ffs::ForceFreeStatesResult ) npsi, mpert = size(xi_psi_modes) - mlow = ffs_intr.mlow - nn = ffs_intr.nlow + mlow = ffs.mlow + nn = ffs.nlow chi1 = 2π * equil.psio b_theta_reg = zeros(ComplexF64, npsi, mpert) @@ -469,7 +469,7 @@ end """ compute_contra_displacements( xi_psi_modes, clebsch_psi1, clebsch_alpha, - psi_grid, equil, ffs_intr, metric, ctrl + psi_grid, equil, ffs, metric, ctrl ) -> (xwp_modes, xwt_modes, xwz_modes, xmt_modes, xmz_modes) Compute contravariant displacement via Jacobian mode coupling convolution. @@ -490,13 +490,13 @@ function compute_contra_displacements( clebsch_alpha::Matrix{ComplexF64}, psi_grid::Vector{Float64}, equil::Equilibrium.PlasmaEquilibrium, - ffs_intr::ForceFreeStatesInternal, + ffs::ForceFreeStatesResult, metric::MetricData, ctrl::PerturbedEquilibriumControl ) npsi, mpert = size(xi_psi_modes) - mlow = ffs_intr.mlow - nn = ffs_intr.nlow + mlow = ffs.mlow + nn = ffs.nlow chi1 = 2π * equil.psio reg_spot = ctrl.reg_spot fc = metric.fourier_coeffs @@ -602,7 +602,7 @@ end compute_cova_components( xwp_modes, xmt_modes, xmz_modes, bwp_modes, bmt_modes, bmz_modes, - psi_grid, ffs_intr, metric + psi_grid, ffs, metric ) -> (xvp, xvt, xvz, bvp, bvt, bvz) Compute covariant displacement and B-field via metric tensor contraction. @@ -621,11 +621,11 @@ function compute_cova_components( bmt_modes::Matrix{ComplexF64}, bmz_modes::Matrix{ComplexF64}, psi_grid::Vector{Float64}, - ffs_intr::ForceFreeStatesInternal, + ffs::ForceFreeStatesResult, metric::MetricData ) npsi, mpert = size(xwp_modes) - mlow = ffs_intr.mlow + mlow = ffs.mlow fc = metric.fourier_coeffs xvp_modes = zeros(ComplexF64, npsi, mpert) @@ -742,7 +742,7 @@ end """ compute_b_n_xi_n_modes( - xwp_modes, b_psi_modes, ForceFreeStates_results, equil, ffs_intr + xwp_modes, b_psi_modes, solution, equil, ffs ) -> (b_n_modes, xi_n_modes) Compute physical normal field b_n and displacement xi_n in mode space. @@ -769,12 +769,12 @@ Tuple (b_n_modes, xi_n_modes), each [npsi, mpert] ComplexF64. function compute_b_n_xi_n_modes( xwp_modes::Matrix{ComplexF64}, b_psi_modes::Matrix{ComplexF64}, - ForceFreeStates_results::OdeState, + solution::SolutionProfiles, equil::Equilibrium.PlasmaEquilibrium, - ffs_intr::ForceFreeStatesInternal + ffs::ForceFreeStatesResult ) npsi, mpert = size(b_psi_modes) - mlow = ffs_intr.mlow + mlow = ffs.mlow mthsurf = length(equil.rzphi_ys) - 1 ro = equil.ro twopi = 2π @@ -790,7 +790,7 @@ function compute_b_n_xi_n_modes( phase_back = [exp(-twopi * im * m_vals[ipert] * thetas[k]) for k in 1:mthsurf, ipert in 1:mpert] Threads.@threads :static for ipsi in 1:npsi - psi = ForceFreeStates_results.psi_store[ipsi] + psi = solution.psi_store[ipsi] hint2d_psi = (Ref(1), Ref(1)) # IDFT: mode space → theta space diff --git a/src/PerturbedEquilibrium/PerturbedEquilibrium.jl b/src/PerturbedEquilibrium/PerturbedEquilibrium.jl index 5e9429d5b..245fa2949 100644 --- a/src/PerturbedEquilibrium/PerturbedEquilibrium.jl +++ b/src/PerturbedEquilibrium/PerturbedEquilibrium.jl @@ -11,7 +11,7 @@ using FastInterpolations # Import parent modules import ..Equilibrium import ..ForceFreeStates -import ..ForceFreeStates: OdeState, ForceFreeStatesInternal, FourFitVars, MetricData +import ..ForceFreeStates: SolutionProfiles, ForceFreeStatesResult, FourFitVars, MetricData import ..Vacuum import ..ForcingTerms import ..ForcingTerms: ForcingMode, CoilSet, load_forcing_data!, convert_forcing_normalization! @@ -38,107 +38,68 @@ export compute_perturbed_equilibrium export write_outputs_to_HDF5 """ - compute_perturbed_equilibrium( - equil, ForceFreeStates_results, wt0, mthvac, ffs_intr, - ft_ctrl, ctrl, intr, metric, ffit - )::PerturbedEquilibriumState + compute_perturbed_equilibrium(ffs, forcing, ctrl, intr)::PerturbedEquilibriumState Main entry point for perturbed equilibrium calculations. Computes plasma response to external forcing and calculates singular layer -coupling metrics. +coupling metrics. Every ForceFreeStates input — the equilibrium, the mode space, the +metric and matrix fits, the free-boundary energies and the ξ solution — is read off `ffs`. +Products the producing integrator could not supply gate the corresponding calculation: +the step warns and is skipped instead of erroring. ## Arguments - - `equil`: Equilibrium solution from Equilibrium module - - `ForceFreeStates_results`: Stability calculation results from ForceFreeStates module - - `wt0`: Free-boundary total-energy matrix W = wp + wv from `free_run`, or `nothing` when the free-boundary calculation was not run (response and singular coupling are then skipped) - - `mthvac`: Vacuum poloidal grid resolution to reuse for the Green's-function solves - - `ffs_intr`: ForceFreeStates internal state with mode information - - `ft_ctrl`: Forcing terms control parameters from [ForcingTerms] section + - `ffs`: `ForceFreeStates.ForceFreeStatesResult` from the stability solve + - `forcing`: the external-field description — a `ForcingTermsControl` (TOML path) or any [`ForcingTerms.RMPField`](@ref) - `ctrl`: Control parameters from [PerturbedEquilibrium] section - `intr`: Internal state variables - - `metric`: Metric tensor data with Fourier coefficients for Jacobian convolution - - `ffit`: FourFitVars with stability matrix interpolants (A, B, C) for regularization ## Returns - `PerturbedEquilibriumState`: Calculation results """ function compute_perturbed_equilibrium( - equil::Equilibrium.PlasmaEquilibrium, - ForceFreeStates_results::OdeState, - wt0::Union{Matrix{ComplexF64},Nothing}, - mthvac::Int, - ffs_intr::ForceFreeStates.ForceFreeStatesInternal, - ft_ctrl::ForcingTerms.ForcingTermsControl, + ffs::ForceFreeStatesResult, + forcing::Union{ForcingTerms.ForcingTermsControl,ForcingTerms.RMPField}, ctrl::PerturbedEquilibriumControl, - intr::PerturbedEquilibriumInternal, - metric::MetricData, - ffit::FourFitVars + intr::PerturbedEquilibriumInternal )::PerturbedEquilibriumState state = PerturbedEquilibriumState() + equil = ffs.equil + ffit = ffs.ffit + mthvac = ffs.control.mthvac # Step 0: Initialize mode arrays for convenient indexing - initialize_mode_arrays!(intr, ffs_intr) + initialize_mode_arrays!(intr, ffs) - # Ξ′ and Ξ_s are recomputed from the stored solution here rather than carried through - # integration; downstream response and coupling code reads them from the stores. - ForceFreeStates.materialize_derivative_stores!(ForceFreeStates_results, equil, ffit, ffs_intr) + # A run has at most one ξ solution, already closed at the rationals and carrying populated + # Ξ′ / Ξ_s stores. The gal-native basis takes the analytic Galerkin Ξ′ downstream. + solution = ffs.solution + intr.odet_from_gal = solution !== nothing && solution.basis === :gal_native - # Load forcing data. On the gpec.h5 replay path the caller preloads - # `intr.forcing_modes` from the snapshot, so skip re-reading the original file. + # The one place forcing state lands on `intr`: injected (replay) modes short-circuit the + # materialization entirely, so they are never re-converted or re-weighted. if isempty(intr.forcing_modes) - if ft_ctrl.forcing_data_format == "coil" - cfg = ForcingTerms.CoilConfig(ft_ctrl) - # Reuse preloaded coil geometry (gpec.h5 rerun with `--coil-source coils`) - # when present; otherwise build it from the TOML coil-set config. Either - # way, retain it on `intr.coil_sets` for the rerun snapshot writer. - coil_sets = isempty(intr.coil_sets) ? - ForcingTerms.load_coil_sets(cfg, ffs_intr.nlow; equil=equil) : intr.coil_sets - intr.coil_sets = coil_sets - for n in ffs_intr.nlow:ffs_intr.nhigh - modes_n = ForcingMode[] - ForcingTerms.compute_coil_forcing_modes!( - modes_n, coil_sets, equil, cfg, n, ffs_intr.mlow, ffs_intr.mhigh; - psi=ffs_intr.psilim, verbose=ctrl.verbose - ) - append!(intr.forcing_modes, modes_n) - end - else - norm_tag = load_forcing_data!(intr.forcing_modes, intr.dir_path, ft_ctrl.forcing_data_file, ft_ctrl.forcing_data_format, ctrl.verbose) - for n in ffs_intr.nlow:ffs_intr.nhigh - # filter returns a new Vector but still holds references to same ForcingMode objects - modes_n = filter(m -> m.n == n, intr.forcing_modes) - isempty(modes_n) && continue - m_vals = [m.m for m in modes_n] - # Same control surface as the coil branch above: psilim, the integration - # limit (Fortran: gpec/gpec.f:431 `field_bs_psi(psilim, ...)`). Without it - # the normalization was taken on the equilibrium-spline limit, which differs - # whenever dmlim/qhigh/psiedge truncation moves psilim inward. - convert_forcing_normalization!(modes_n, norm_tag, equil, n, - minimum(m_vals), maximum(m_vals); psi=ffs_intr.psilim) - end - end + modes, coil_sets = materialize_forcing_modes(ffs, forcing; + dir_path=intr.dir_path, preloaded_coil_sets=intr.coil_sets, verbose=ctrl.verbose) + intr.forcing_modes = modes + isempty(coil_sets) || (intr.coil_sets = coil_sets) end # Step 2: Compute plasma response - if ctrl.compute_response - if wt0 === nothing - @warn "Vacuum data not available. Skipping plasma response calculation. Set vac_flag=true in [ForceFreeStates] section." - else - compute_plasma_response!(state, equil, ForceFreeStates_results, wt0, mthvac, ffs_intr, intr, ctrl, metric, ffit) - end + if ctrl.compute_response && + ForceFreeStates.require(ffs, :free_boundary, "plasma response calculation") && + ForceFreeStates.require_solution(ffs, "plasma response calculation") + compute_plasma_response!(state, equil, solution, ffs.free_boundary.wt0, mthvac, ffs, intr, ctrl, ffs.metric, ffit) end # Step 3: Compute singular coupling metrics - if ctrl.compute_singular_coupling - if wt0 === nothing - @warn "Vacuum data not available. Skipping singular coupling calculation. Set vac_flag=true in [ForceFreeStates] section." - else - compute_singular_coupling_metrics!(state, equil, ForceFreeStates_results, mthvac, ffs_intr, intr, ctrl, ffit) - end + if ctrl.compute_singular_coupling && + ForceFreeStates.require(ffs, :free_boundary, "singular coupling calculation") && + ForceFreeStates.require_solution(ffs, "singular coupling calculation") + compute_singular_coupling_metrics!(state, equil, solution, mthvac, ffs, intr, ctrl, ffit) end # Step 4: Output eigenmode fields (integrated into HDF5 output) @@ -147,4 +108,102 @@ function compute_perturbed_equilibrium( return state end +""" + materialize_forcing_modes(ffs, forcing; dir_path, preloaded_coil_sets=CoilSet[], verbose=false) + -> (modes::Vector{ForcingMode}, coil_sets::Vector{CoilSet}) + +Pure computation of the control-surface forcing spectrum: turn a forcing description into +fresh `ForcingMode`s, for every toroidal mode of the solve and in the unit-norm convention +the response step consumes. Nothing is mutated — the caller owns where the result lands. + +Coil formats build the geometry (or reuse `preloaded_coil_sets` from the gpec.h5 replay +path) and integrate the Biot-Savart field on the control surface; file formats read the +modes from `dir_path` and convert their normalization. Both branches evaluate on +`ffs.psilim`, the integration limit. The second return value is the coil geometry actually +used (empty for file formats), which the writer snapshots for replay. +""" +function materialize_forcing_modes( + ffs::ForceFreeStatesResult, + forcing::ForcingTerms.ForcingTermsControl; + dir_path::AbstractString, + preloaded_coil_sets::Vector{ForcingTerms.CoilSet}=ForcingTerms.CoilSet[], + verbose::Bool=false +) + equil = ffs.equil + modes = ForcingMode[] + coil_sets = ForcingTerms.CoilSet[] + + if forcing.forcing_data_format == "coil" + cfg = ForcingTerms.CoilConfig(forcing) + coil_sets = isempty(preloaded_coil_sets) ? + ForcingTerms.load_coil_sets(cfg, ffs.nlow; equil=equil) : preloaded_coil_sets + for n in ffs.nlow:ffs.nhigh + modes_n = ForcingMode[] + ForcingTerms.compute_coil_forcing_modes!( + modes_n, coil_sets, equil, cfg, n, ffs.mlow, ffs.mhigh; + psi=ffs.psilim, verbose=verbose + ) + append!(modes, modes_n) + end + else + norm_tag = load_forcing_data!(modes, dir_path, forcing.forcing_data_file, forcing.forcing_data_format, verbose) + for n in ffs.nlow:ffs.nhigh + # filter returns a new Vector but still holds references to same ForcingMode objects + modes_n = filter(m -> m.n == n, modes) + isempty(modes_n) && continue + m_vals = [m.m for m in modes_n] + # Same control surface as the coil branch above: psilim, the integration + # limit (Fortran: gpec/gpec.f:431 `field_bs_psi(psilim, ...)`). Without it + # the normalization was taken on the equilibrium-spline limit, which differs + # whenever dmlim/qhigh/psiedge truncation moves psilim inward. + convert_forcing_normalization!(modes_n, norm_tag, equil, n, + minimum(m_vals), maximum(m_vals); psi=ffs.psilim) + end + end + + return modes, coil_sets +end + +""" + materialize_forcing_modes(ffs, rmp::ForcingTerms.RMPSource; kwargs...) -> (modes, coil_sets) + +Materialize a single-source [`ForcingTerms.RMPField`](@ref) leaf and apply its weight. The +returned modes are fresh objects, so weighting never reaches back into any caller state. +""" +function materialize_forcing_modes(ffs::ForceFreeStatesResult, rmp::ForcingTerms.RMPSource; kwargs...) + modes, coil_sets = materialize_forcing_modes(ffs, rmp.ctrl; kwargs...) + if rmp.scale != 1 + modes = [ForcingMode(; n=mode.n, m=mode.m, amplitude=rmp.scale * mode.amplitude) for mode in modes] + end + return modes, coil_sets +end + +""" + materialize_forcing_modes(ffs, rmp::ForcingTerms.RMPFieldSum; kwargs...) -> (modes, coil_sets) + +Materialize a lazy linear combination of forcing sources: each weighted leaf is evaluated +against the equilibrium independently, then the mode amplitudes are summed per (n, m) — the +perturbed-equilibrium response is linear in the forcing, so this equals forcing with the +combined field. The merged modes are sorted by (n, m) for a deterministic order. Coil +geometry from every coil-format leaf is concatenated in the second return value; the +preloaded-geometry shortcut does not apply to sums (the replay path injects materialized +modes upstream and never materializes a sum). +""" +function materialize_forcing_modes(ffs::ForceFreeStatesResult, rmp::ForcingTerms.RMPFieldSum; + dir_path::AbstractString, preloaded_coil_sets::Vector{ForcingTerms.CoilSet}=ForcingTerms.CoilSet[], + verbose::Bool=false) + amplitudes = Dict{Tuple{Int,Int},ComplexF64}() + coil_sets = ForcingTerms.CoilSet[] + for term in rmp.terms + term_modes, term_coils = materialize_forcing_modes(ffs, term; dir_path=dir_path, verbose=verbose) + for mode in term_modes + key = (mode.n, mode.m) + amplitudes[key] = get(amplitudes, key, 0.0 + 0.0im) + mode.amplitude + end + append!(coil_sets, term_coils) + end + modes = [ForcingMode(; n=n, m=m, amplitude=amplitudes[(n, m)]) for (n, m) in sort!(collect(keys(amplitudes)))] + return modes, coil_sets +end + end # module PerturbedEquilibrium diff --git a/src/PerturbedEquilibrium/PerturbedEquilibriumStructs.jl b/src/PerturbedEquilibrium/PerturbedEquilibriumStructs.jl index 1ce0895d6..d8251de38 100644 --- a/src/PerturbedEquilibrium/PerturbedEquilibriumStructs.jl +++ b/src/PerturbedEquilibrium/PerturbedEquilibriumStructs.jl @@ -76,7 +76,7 @@ Internal state variables for perturbed equilibrium calculations. n_modes::Vector{Int} = Int[] # ForceFreeStates-provided B_pen per (match surface × coil-drive column) from inner layer. inner_bpen::Matrix{ComplexF64} = zeros(ComplexF64, 0, 0) - # True when the consumed OdeState came from gal matching, whose du_store carries the + # True when the consumed solution came from gal matching, whose du_store carries the # analytic galerkin Ξ′; selects the gal branch of the singular-coupling Ξ′ evaluation. odet_from_gal::Bool = false end diff --git a/src/PerturbedEquilibrium/Response.jl b/src/PerturbedEquilibrium/Response.jl index d9a985204..d305a079b 100644 --- a/src/PerturbedEquilibrium/Response.jl +++ b/src/PerturbedEquilibrium/Response.jl @@ -1,6 +1,6 @@ """ compute_plasma_response!( - state, equil, ForceFreeStates_results, wt0, mthvac, ffs_intr, + state, equil, solution, wt0, mthvac, ffs, intr, ctrl, metric, ffit ) @@ -17,10 +17,10 @@ Implements resp_index=0 calculation from Fortran gpresp: function compute_plasma_response!( state::PerturbedEquilibriumState, equil::Equilibrium.PlasmaEquilibrium, - ForceFreeStates_results::OdeState, + solution::SolutionProfiles, wt0::Matrix{ComplexF64}, mthvac::Int, - ffs_intr::ForceFreeStatesInternal, + ffs::ForceFreeStatesResult, intr::PerturbedEquilibriumInternal, ctrl::PerturbedEquilibriumControl, metric::MetricData, @@ -31,14 +31,14 @@ function compute_plasma_response!( end # Build flux matrix from ForceFreeStates eigenmodes [mode × eigenmode] - flux_matrix = build_flux_matrix(equil, ForceFreeStates_results, ffs_intr) + flux_matrix = build_flux_matrix(equil, solution, ffs) # Plasma inductance Lambda (wt0 formula, Fortran resp_induct_flag=TRUE default) - plasma_inductance = calc_plasma_inductance(wt0, ffs_intr, equil.psio) + plasma_inductance = calc_plasma_inductance(wt0, ffs, equil.psio) # Surface inductance L from vacuum surface-current matrix at psilim. - nn = ffs_intr.nlow - surface_inductance = calc_surface_inductance(equil, ffs_intr.psilim, mthvac, ffs_intr.mlow:ffs_intr.mhigh, nn) + nn = ffs.nlow + surface_inductance = calc_surface_inductance(equil, ffs.psilim, mthvac, ffs.mlow:ffs.mhigh, nn) permeability = calc_permeability(plasma_inductance, surface_inductance) # Reluctance ϱ = L⁻¹·(Λ† − L)·L⁻¹ (Fortran gpresp_reluct: diff_indmats = CONJG(TRANSPOSE(plas_indmats)) − surf_indmats). @@ -55,7 +55,7 @@ function compute_plasma_response!( # field (b̃) space for output (issue #233 / Pharr 2026). Store the b̃→b̄ operator S = Σ/√A # and the scalar surface area A so users can recover the area-weighted field (b̄ = S·b̃) or # flux (Φ = A·b̄) — see Utils.jl output docs. - rootarea_to_area_weight, surface_area = build_control_surface_rootarea_to_area_weight(equil, ffs_intr) + rootarea_to_area_weight, surface_area = build_control_surface_rootarea_to_area_weight(equil, ffs) field_mats = field_space_response_matrices(plasma_inductance, surface_inductance, permeability, reluctance, rootarea_to_area_weight, surface_area) state.plasma_inductance = field_mats.plasma_inductance state.surface_inductance = field_mats.surface_inductance @@ -67,7 +67,7 @@ function compute_plasma_response!( # Forcing and response on the control surface. Flux Φ appears only as a brief internal bridge: # forcing arrives as Φ_x, the field reconstruction below consumes Φ_tot, and the b̃ spectra are # formed via the conform operator R = S·A (Φ = R·b̃). - forcing_flux = map_forcing_to_eigenmodes(intr.forcing_modes, ffs_intr) + forcing_flux = map_forcing_to_eigenmodes(intr.forcing_modes, ffs) response_flux = compute_plasma_response_vector(permeability, forcing_flux) # Output forcing/response in the three Pharr field representations (all tesla): @@ -98,17 +98,17 @@ function compute_plasma_response!( state.toroidal_torque = -2 * nn * imag(py) xi_modes, b_modes = reconstruct_physical_fields( - response_flux, flux_matrix, ForceFreeStates_results, equil, ffs_intr, intr, + response_flux, flux_matrix, solution, equil, ffs, intr, metric, ffit, ctrl ) - npsi = size(ForceFreeStates_results.u_store, 4) - state.psi_grid = ForceFreeStates_results.psi_store[1:npsi] + npsi = size(solution.u_store, 4) + state.psi_grid = solution.psi_store[1:npsi] state.xi_modes = xi_modes state.b_modes = b_modes b_n_modes, xi_n_modes = compute_b_n_xi_n_modes( - xi_modes.psi_J, b_modes.psi, ForceFreeStates_results, equil, ffs_intr + xi_modes.psi_J, b_modes.psi, solution, equil, ffs ) state.b_n_modes = b_n_modes state.xi_n_modes = xi_n_modes diff --git a/src/PerturbedEquilibrium/ResponseMatrices.jl b/src/PerturbedEquilibrium/ResponseMatrices.jl index 4a8ec46b8..d304d6db3 100644 --- a/src/PerturbedEquilibrium/ResponseMatrices.jl +++ b/src/PerturbedEquilibrium/ResponseMatrices.jl @@ -13,8 +13,8 @@ using ..Utilities.FourierTransforms """ extract_boundary_displacements( equil::Equilibrium.PlasmaEquilibrium, - ForceFreeStates_results::OdeState, - intr::ForceFreeStatesInternal + solution::SolutionProfiles, + ffs::ForceFreeStatesResult )::NamedTuple Extract eigenmode displacements and equilibrium quantities at the plasma boundary. @@ -43,8 +43,8 @@ plasma surface from ForceFreeStates eigenmode solutions. ## Arguments - `equil`: Equilibrium solution containing flux surfaces and q-profile - - `ForceFreeStates_results`: ODE integration results containing u_store with eigenmodes - - `intr`: ForceFreeStates internal state with boundary location (psilim) + - `solution`: ODE integration results containing u_store with eigenmodes + - `ffs`: ForceFreeStates result with the boundary location (psilim) ## Returns @@ -57,21 +57,21 @@ Named tuple with: """ function extract_boundary_displacements( equil::Equilibrium.PlasmaEquilibrium, - ForceFreeStates_results::OdeState, - intr::ForceFreeStatesInternal + solution::SolutionProfiles, + ffs::ForceFreeStatesResult ) # Extract boundary displacement (normal component) # u_store dimensions: [numpert_total, numpert_total, 2, numsteps] # Index 1 in 3rd dimension is ξ_ψ (radial displacement) # Last index in 4th dimension is the boundary - ξ_psi_boundary = ForceFreeStates_results.u_store[:, :, 1, ForceFreeStates_results.step] + ξ_psi_boundary = solution.u_store[:, :, 1, solution.step] # Get boundary location in normalized flux coordinates - psi_boundary = ForceFreeStates_results.psi_store[ForceFreeStates_results.step] + psi_boundary = solution.psi_store[solution.step] # Evaluate equilibrium quantities at boundary # Safety factor at boundary - q_boundary = ForceFreeStates_results.q_store[ForceFreeStates_results.step] + q_boundary = solution.q_store[solution.step] # FFS ODE integrates in ψ (normalized flux), so bwp_mn = chi1·singfac·2πi·ξ_ψ # where chi1 = 2π·psio (Fortran idcon.f: chi1 = twopi*psio) @@ -89,7 +89,7 @@ end """ compute_normal_magnetic_field( boundary_data::NamedTuple, - intr::ForceFreeStatesInternal + ffs::ForceFreeStatesResult )::Matrix{ComplexF64} Compute normal magnetic field at plasma boundary from eigenmode displacements. @@ -117,7 +117,7 @@ where ξ_ψ is the radial displacement eigenfunction. + q_boundary: Safety factor at boundary (scalar) + psi_boundary: Normalized flux at boundary (scalar) - - `intr`: ForceFreeStates internal state with mode arrays (mlow, mhigh, nlow, etc.) + - `ffs`: ForceFreeStates result with the mode space (mlow, mhigh, nlow, etc.) ## Returns @@ -126,10 +126,10 @@ where ξ_ψ is the radial displacement eigenfunction. """ function compute_normal_magnetic_field( boundary_data::NamedTuple, - intr::ForceFreeStatesInternal + ffs::ForceFreeStatesResult )::Matrix{ComplexF64} - numpert_total = intr.numpert_total + numpert_total = ffs.numpert_total bwp_mn = zeros(ComplexF64, numpert_total, numpert_total) # Extract boundary data @@ -143,8 +143,8 @@ function compute_normal_magnetic_field( # Linear index i corresponds to: m = (i-1) % mpert + mlow, n = (i-1) ÷ mpert + nlow singfac = zeros(Float64, numpert_total) for i in 1:numpert_total - m_mode = (i - 1) % intr.mpert + intr.mlow - n_mode = (i - 1) ÷ intr.mpert + intr.nlow + m_mode = (i - 1) % ffs.mpert + ffs.mlow + n_mode = (i - 1) ÷ ffs.mpert + ffs.nlow singfac[i] = m_mode - n_mode * q_boundary end @@ -162,8 +162,8 @@ end """ build_flux_matrix( equil::Equilibrium.PlasmaEquilibrium, - ForceFreeStates_results::OdeState, - intr::ForceFreeStatesInternal + solution::SolutionProfiles, + ffs::ForceFreeStatesResult )::Matrix{ComplexF64} Build vacuum poloidal flux matrix from ForceFreeStates eigenmode solutions. @@ -181,8 +181,8 @@ The flux matrix relates eigenmode displacements to vacuum poloidal flux: ## Arguments - `equil`: Equilibrium solution containing flux surfaces and q-profile - - `ForceFreeStates_results`: ForceFreeStates ODE integration results containing eigenmodes - - `intr`: ForceFreeStates internal state with mode information + - `solution`: ForceFreeStates ODE integration results containing eigenmodes + - `ffs`: ForceFreeStates result with the mode space ## Returns @@ -191,17 +191,17 @@ The flux matrix relates eigenmode displacements to vacuum poloidal flux: """ function build_flux_matrix( equil::Equilibrium.PlasmaEquilibrium, - ForceFreeStates_results::OdeState, - intr::ForceFreeStatesInternal + solution::SolutionProfiles, + ffs::ForceFreeStatesResult )::Matrix{ComplexF64} # Step 1: Extract boundary displacements and equilibrium quantities - boundary_data = extract_boundary_displacements(equil, ForceFreeStates_results, intr) + boundary_data = extract_boundary_displacements(equil, solution, ffs) # Step 2: Compute normal magnetic field at plasma boundary # This is the actual implementation of GPEC's bwp_mn calculation # bwp_mn[i,j] = i * (dΨ/dρ) * (m[i] - n*q_boundary) * ξ_ψ[i,j] - flxmats = compute_normal_magnetic_field(boundary_data, intr) + flxmats = compute_normal_magnetic_field(boundary_data, ffs) return flxmats end @@ -209,7 +209,7 @@ end """ calc_plasma_inductance( wt0::Matrix{ComplexF64}, - ffs_intr::ForceFreeStatesInternal, + ffs::ForceFreeStatesResult, psio::Float64 )::Matrix{ComplexF64} @@ -227,7 +227,7 @@ correctly recovering the properly-normalized inductance. ## Arguments - `wt0`: Total energy matrix W = wp + wv, before eigenvector sorting - - `ffs_intr`: ForceFreeStates internal state with mode info (mlow, mpert, nlow, qlim) + - `ffs`: ForceFreeStates result with the mode space and edge q (mlow, mpert, nlow, qlim) - `psio`: Total toroidal flux [Wb/rad] from equilibrium (equil.psio) ## Returns @@ -236,17 +236,17 @@ correctly recovering the properly-normalized inductance. """ function calc_plasma_inductance( wt0::Matrix{ComplexF64}, - ffs_intr::ForceFreeStatesInternal, + ffs::ForceFreeStatesResult, psio::Float64 )::Matrix{ComplexF64} - mpert = ffs_intr.numpert_total + mpert = ffs.numpert_total chi1 = 2π * psio # = Fortran's chi1 = twopi*psio - n = ffs_intr.nlow - qlim = ffs_intr.qlim # q at psilim + n = ffs.nlow + qlim = ffs.qlim # q at psilim # Singular factors s_i = m_i - n*qlim (same as Fortran: mfac(i) - nn*qlim) - s = [((i-1) % ffs_intr.mpert + ffs_intr.mlow) - n * qlim for i in 1:mpert] + s = [((i-1) % ffs.mpert + ffs.mlow) - n * qlim for i in 1:mpert] # Fortran idcon_norm: wt0 = wt0/(mu0*2)*psio^2 # Julia's wt0 is raw wp+wv; Fortran additionally scales by psio^2/(mu0*2) @@ -334,7 +334,7 @@ end """ build_control_surface_rootarea_to_area_weight( equil::Equilibrium.PlasmaEquilibrium, - ffs_intr::ForceFreeStatesInternal + ffs::ForceFreeStatesResult )::Tuple{Matrix{ComplexF64}, Float64} Build the numpert_total × numpert_total root-area-weighted → area-weighted field operator @@ -351,16 +351,16 @@ coordinate-invariant (b̃) matrices in the area-weighted field or recover flux """ function build_control_surface_rootarea_to_area_weight( equil::Equilibrium.PlasmaEquilibrium, - ffs_intr::ForceFreeStatesInternal + ffs::ForceFreeStatesResult )::Tuple{Matrix{ComplexF64},Float64} - mpert = ffs_intr.mpert - npert = ffs_intr.npert - Npert = ffs_intr.numpert_total + mpert = ffs.mpert + npert = ffs.npert + Npert = ffs.numpert_total mtheta_eq = length(equil.rzphi_ys) - ft = Utilities.FourierTransforms.FourierTransform(mtheta_eq, mpert, ffs_intr.mlow) - S_block = Equilibrium.rootarea_to_area_weight(equil, ffs_intr.psilim, ft) - jarea = Equilibrium.flux_surface_area(equil, ffs_intr.psilim, mtheta_eq) + ft = Utilities.FourierTransforms.FourierTransform(mtheta_eq, mpert, ffs.mlow) + S_block = Equilibrium.rootarea_to_area_weight(equil, ffs.psilim, ft) + jarea = Equilibrium.flux_surface_area(equil, ffs.psilim, mtheta_eq) npert == 1 && return (Matrix{ComplexF64}(S_block), jarea) @@ -416,7 +416,7 @@ end """ map_forcing_to_eigenmodes( forcing_modes::Vector{ForcingMode}, - intr::ForceFreeStatesInternal + ffs::ForceFreeStatesResult )::Vector{ComplexF64} Map external forcing modes to eigenmode basis. @@ -431,7 +431,7 @@ are automatically converted to unit-norm on load (see `ForcingMode` docstring). ## Arguments - `forcing_modes`: External forcing modes (amplitudes in unit-norm / Phi_x convention) - - `intr`: ForceFreeStates internal state with mode arrays + - `ffs`: ForceFreeStates result with the mode space ## Returns @@ -439,10 +439,10 @@ are automatically converted to unit-norm on load (see `ForcingMode` docstring). """ function map_forcing_to_eigenmodes( forcing_modes::Vector{ForcingMode}, - intr::ForceFreeStatesInternal + ffs::ForceFreeStatesResult )::Vector{ComplexF64} - numpert_total = intr.mpert * intr.npert + numpert_total = ffs.mpert * ffs.npert forcing_vector = zeros(ComplexF64, numpert_total) # Create mode index map: (m,n) -> linear index @@ -453,8 +453,8 @@ function map_forcing_to_eigenmodes( # Using 0-based indexing converted to 1-based: # m = (i-1) % mpert + mlow # n = (i-1) ÷ mpert + nlow - m_mode = (i - 1) % intr.mpert + intr.mlow - n_mode = (i - 1) ÷ intr.mpert + intr.nlow + m_mode = (i - 1) % ffs.mpert + ffs.mlow + n_mode = (i - 1) ÷ ffs.mpert + ffs.nlow if m_mode == forcing_mode.m && n_mode == forcing_mode.n forcing_vector[i] = forcing_mode.amplitude diff --git a/src/PerturbedEquilibrium/SingularCoupling.jl b/src/PerturbedEquilibrium/SingularCoupling.jl index dd24dbfb1..1d35f661d 100644 --- a/src/PerturbedEquilibrium/SingularCoupling.jl +++ b/src/PerturbedEquilibrium/SingularCoupling.jl @@ -54,16 +54,16 @@ end """ _chord_solution_at(psi, resnum, odet, nstep) -> (u, du) -Evaluate the `resnum` row of Ξ_ψ and Ξ′_ψ at `psi` from the stored ODE solution: +Evaluate the `resnum` row of Ξ_ψ and Ξ′_ψ at `psi` from the stored solution: cubic Hermite for the value, chord slope across the bracketing nodes for the derivative. -Least accurate method, kept for solution paths outside the forward EL integrator -(gal-matched, Riccati) whose stored derivatives cover only Ξ′. +The least accurate of the Ξ′ evaluators, kept as the fallback for a solution whose stored +Ξ′ cannot be trusted. Currently unused: every basis that reaches the singular-coupling +loop carries a populated `du_store`, so the gal-native, ideal-EL and kinetic evaluators +cover all of them. """ -function _chord_solution_at(psi::Float64, resnum::Int, odet::OdeState, nstep::Int) +function _chord_solution_at(psi::Float64, resnum::Int, odet::SolutionProfiles, nstep::Int) isempty(odet.du_store) && error( - "_chord_solution_at: no derivative store. The solution is in a basis " * - "the Euler-Lagrange kernel cannot be re-applied to (sparse Riccati path); " * - "dense Ξ′ requires the Forward integrator." + "_chord_solution_at: no derivative store — the solution carries no usable Ξ′." ) il, ir, _ = _psi_bracket(odet.psi_store, psi, nstep) psi_a, psi_b = odet.psi_store[il], odet.psi_store[ir] @@ -81,7 +81,7 @@ end """ _gal_solution_at(psi, resnum, odet, nstep) -> (u, du) -Evaluate the `resnum` row of Ξ_ψ and Ξ′_ψ at `psi` for a gal-matched `OdeState`: +Evaluate the `resnum` row of Ξ_ψ and Ξ′_ψ at `psi` for a gal-matched solution: cubic Hermite for the value, and the analytic Ξ′ carried in `du_store` for the derivative. Mirrors the `galsol%gal_flag` branch of Fortran `gpeq_sol`, which takes Ξ′ from the analytic galerkin derivative rather than differentiating the value @@ -89,7 +89,7 @@ spline. Differencing `u_store` here would discard that analytic content, and near-cancellation in `bwp1` (singfac·Ξ′ against n q′·Ξ, with singfac → 0 at the surface) amplifies the resulting error into Δ′ at the outer surfaces. """ -function _gal_solution_at(psi::Float64, resnum::Int, odet::OdeState, nstep::Int) +function _gal_solution_at(psi::Float64, resnum::Int, odet::SolutionProfiles, nstep::Int) il, ir, _ = _psi_bracket(odet.psi_store, psi, nstep) psi_a, psi_b = odet.psi_store[il], odet.psi_store[ir] @@ -118,7 +118,7 @@ function _solution_at( resnum::Int, m_res::Int, nn::Int, - odet::OdeState, + odet::SolutionProfiles, equil::Equilibrium.PlasmaEquilibrium, nstep::Int ) @@ -156,7 +156,7 @@ function _solution_at( end """ - _el_solution_at(psi, resnum, odet, ffit, equil, ffs_intr, nstep) -> (u, du) + _el_solution_at(psi, resnum, odet, ffit, equil, ffs, nstep) -> (u, du) Evaluate the `resnum` row of Ξ_ψ and Ξ′_ψ at `psi` from the stored ODE solution via the ideal Euler-Lagrange relation Ξ′ = Q⁻¹·F̄⁻¹·(Q⁻¹·u₂ − K̄·u₁) [Glasser 2016 eqs. 22-24], @@ -170,13 +170,13 @@ resonant evaluation points actually need them. function _el_solution_at( psi::Float64, resnum::Int, - odet::OdeState, + odet::SolutionProfiles, ffit::FourFitVars, equil::Equilibrium.PlasmaEquilibrium, - ffs_intr::ForceFreeStatesInternal, + ffs::ForceFreeStatesResult, nstep::Int ) - npert = ffs_intr.numpert_total + npert = ffs.numpert_total il, ir, _ = _psi_bracket(odet.psi_store, psi, nstep) psi_a, psi_b = odet.psi_store[il], odet.psi_store[ir] @@ -190,8 +190,8 @@ function _el_solution_at( q_hint = Ref(1) du_a = zeros(ComplexF64, npert, npert, 2) du_b = zeros(ComplexF64, npert, npert, 2) - ForceFreeStates.el_derivatives!(du_a, odet.u_store[:, :, :, il], false, equil, ffit, ffs_intr, psi_a, q_hint, hint) - ForceFreeStates.el_derivatives!(du_b, odet.u_store[:, :, :, ir], false, equil, ffit, ffs_intr, psi_b, q_hint, hint) + ForceFreeStates.el_derivatives!(du_a, odet.u_store[:, :, :, il], false, equil, ffit, ffs, psi_a, q_hint, hint) + ForceFreeStates.el_derivatives!(du_b, odet.u_store[:, :, :, ir], false, equil, ffit, ffs, psi_b, q_hint, hint) du1_a = @view du_a[:, :, 1] du1_b = @view du_b[:, :, 1] du2_a = @view du_a[:, :, 2] @@ -204,7 +204,7 @@ function _el_solution_at( # Ξ′ = Q⁻¹·F̄⁻¹·(Q⁻¹·u₂ − K̄·u₁) with Q⁻¹ = diag(1/(m − n·q)) q_e = equil.profiles.q_spline(psi) - singfac_inv = vec([1.0 / (m - q_e * n) for m in ffs_intr.mlow:ffs_intr.mhigh, n in ffs_intr.nlow:ffs_intr.nhigh]) + singfac_inv = vec([1.0 / (m - q_e * n) for m in ffs.mlow:ffs.mhigh, n in ffs.nlow:ffs.nhigh]) fmat_lower = Matrix{ComplexF64}(undef, npert, npert) ffit.fmats_lower(vec(fmat_lower), psi; hint=hint) ffit.kmats(vec(kmat), psi; hint=hint) @@ -221,9 +221,9 @@ end compute_singular_coupling_metrics!( state::PerturbedEquilibriumState, equil::Equilibrium.PlasmaEquilibrium, - ForceFreeStates_results::OdeState, + solution::SolutionProfiles, mthvac::Int, - ffs_intr::ForceFreeStatesInternal, + ffs::ForceFreeStatesResult, intr::PerturbedEquilibriumInternal, ctrl::PerturbedEquilibriumControl, ffit::FourFitVars @@ -253,16 +253,17 @@ Metadata `[n_rational]`: `rational_psi`, `rational_q`, `rational_m_res`, `ration function compute_singular_coupling_metrics!( state::PerturbedEquilibriumState, equil::Equilibrium.PlasmaEquilibrium, - ForceFreeStates_results::OdeState, + solution::SolutionProfiles, mthvac::Int, - ffs_intr::ForceFreeStatesInternal, + ffs::ForceFreeStatesResult, intr::PerturbedEquilibriumInternal, ctrl::PerturbedEquilibriumControl, ffit::FourFitVars ) ctrl.verbose && @info "Computing singular coupling metrics (GPEC method)" - (; msing, numpert_total, mlow, mhigh, nlow, nhigh) = ffs_intr + (; numpert_total, mlow, mhigh, nlow, nhigh) = ffs + msing = length(ffs.surfaces) if msing == 0 ctrl.verbose && @info "No singular surfaces found. Skipping singular coupling calculation." @@ -282,7 +283,7 @@ function compute_singular_coupling_metrics!( resonant_pairs = Tuple{Int,Int}[] for nn in nlow:nhigh for s in 1:msing - m_res_float = ffs_intr.sing[s].q * nn + m_res_float = ffs.surfaces[s].q * nn m_res = round(Int, m_res_float) abs(m_res_float - m_res) > 1e-6 && continue (m_res < mlow || m_res > mhigh) && continue @@ -321,10 +322,10 @@ function compute_singular_coupling_metrics!( # For each forcing mode k: c_k = u_bnd⁻¹ × edge_mn_k # where edge_mn_k[j] = plasma_response[j,k] / (chi1·singfac_lim[j]·2πi) # Matches Fortran gpout_resp: edge_mn = foutmn/(chi1·singfac·twopi·ifac) - psi_lim = ForceFreeStates_results.psi_store[ForceFreeStates_results.step] + psi_lim = solution.psi_store[solution.step] q_lim = equil.profiles.q_spline(psi_lim) singfac_lim = [intr.m_modes[j] - intr.n_modes[j] * q_lim for j in 1:numpert_total] - u_bnd = ForceFreeStates_results.u_store[:, :, 1, ForceFreeStates_results.step] + u_bnd = solution.u_store[:, :, 1, solution.step] # Divide each row j by singfac_lim[j] — reshape to column vector so Julia broadcasts row-wise, not column-wise. edge_mn = intr.plasma_response ./ (chi1 * 2π * im .* reshape(singfac_lim, :, 1)) C_coeffs = u_bnd \ edge_mn # mpert × numpert_total @@ -339,17 +340,15 @@ function compute_singular_coupling_metrics!( # a `finally` so an exception in the loop cannot leak the pinned count into the session. The # loop is top-level: its threadid()-indexed state must never be nested inside another # @threads region. - nstep = ForceFreeStates_results.step - # ξ′ evaluation preference: ideal EL relation, then interpolated stored RHS for kinetic, - # then chord slope for solution paths outside the serial EL integrator. - use_du_store = ForceFreeStates_results.du_store_populated - use_el = use_du_store && !ffit.kinetic_populated + nstep = solution.step + # ξ′ evaluation preference: the ideal EL relation, or the interpolated stored RHS for kinetic runs. + use_el = !ffit.kinetic_populated _blas_nthreads = BLAS.get_num_threads() BLAS.set_num_threads(1) try Threads.@threads :static for row in 1:length(resonant_pairs) (s, nn) = resonant_pairs[row] - sing_surf = ffs_intr.sing[s] + sing_surf = ffs.surfaces[s] m_res = round(Int, sing_surf.q * nn) resnum = findfirst(j -> intr.m_modes[j] == m_res && intr.n_modes[j] == nn, 1:numpert_total) @@ -384,20 +383,16 @@ function compute_singular_coupling_metrics!( # galerkin Ξ′, ideal runs the EL relation, kinetic runs the stored Ξ′. if intr.odet_from_gal # interpolate u and the analytic galerkin dξ/dψ carried in du_store - u_l, ud_l = _gal_solution_at(lpsi, resnum, ForceFreeStates_results, nstep) - u_r, ud_r = _gal_solution_at(rpsi, resnum, ForceFreeStates_results, nstep) - elseif !use_du_store - # interpolate u and finite-difference dξ/dψ across the bracketing nodes - u_l, ud_l = _chord_solution_at(lpsi, resnum, ForceFreeStates_results, nstep) - u_r, ud_r = _chord_solution_at(rpsi, resnum, ForceFreeStates_results, nstep) + u_l, ud_l = _gal_solution_at(lpsi, resnum, solution, nstep) + u_r, ud_r = _gal_solution_at(rpsi, resnum, solution, nstep) elseif use_el # interpolate u and evaluate dξ/dψ from the ideal EL relation - u_l, ud_l = _el_solution_at(lpsi, resnum, ForceFreeStates_results, ffit, equil, ffs_intr, nstep) - u_r, ud_r = _el_solution_at(rpsi, resnum, ForceFreeStates_results, ffit, equil, ffs_intr, nstep) + u_l, ud_l = _el_solution_at(lpsi, resnum, solution, ffit, equil, ffs, nstep) + u_r, ud_r = _el_solution_at(rpsi, resnum, solution, ffit, equil, ffs, nstep) else # interpolate u and the stored dξ/dψ, weighted to remove the resonant pole - u_l, ud_l = _solution_at(lpsi, sing_surf.psifac, resnum, m_res, nn, ForceFreeStates_results, equil, nstep) - u_r, ud_r = _solution_at(rpsi, sing_surf.psifac, resnum, m_res, nn, ForceFreeStates_results, equil, nstep) + u_l, ud_l = _solution_at(lpsi, sing_surf.psifac, resnum, m_res, nn, solution, equil, nstep) + u_r, ud_r = _solution_at(rpsi, sing_surf.psifac, resnum, m_res, nn, solution, equil, nstep) end q_l = equil.profiles.q_spline(lpsi) @@ -420,7 +415,7 @@ function compute_singular_coupling_metrics!( end # Inner-layer (cusp-free) penetrated field: bpen[s, j] is linear in the same identity-at-edge - # coil-drive columns as the OdeState solutions, so it contracts with C_coeffs exactly like + # coil-drive columns as the outer solution, so it contracts with C_coeffs exactly like # the outer solution values above (xsp = transpose(u) * ck); /area matches the area-weighted # convention of the pointwise row. if have_inner_bpen && s <= size(intr.inner_bpen, 1) @@ -482,7 +477,7 @@ function compute_singular_coupling_metrics!( # R = S·A (Σ·√A) is the only place flux briefly appears; it is built from the b̃→b̄ operator S and # the scalar surface area A. Done after the applied-vector evaluation above so those physical # scalars carry no round-trip noise. - rootarea_to_area_weight, surface_area = build_control_surface_rootarea_to_area_weight(equil, ffs_intr) + rootarea_to_area_weight, surface_area = build_control_surface_rootarea_to_area_weight(equil, ffs) flux_conform = rootarea_to_area_weight .* surface_area state.C_resonant_area_weighted_field = state.C_resonant_area_weighted_field * flux_conform state.C_resonant_current = state.C_resonant_current * flux_conform diff --git a/src/PerturbedEquilibrium/Utils.jl b/src/PerturbedEquilibrium/Utils.jl index 562b0b0f6..fc1eb9c0a 100644 --- a/src/PerturbedEquilibrium/Utils.jl +++ b/src/PerturbedEquilibrium/Utils.jl @@ -1,7 +1,7 @@ """ initialize_mode_arrays!( intr::PerturbedEquilibriumInternal, - ffs_intr::ForceFreeStatesInternal + ffs::ForceFreeStatesResult ) Initialize mode number arrays for convenient indexing. @@ -20,12 +20,12 @@ This matches the convention used in ForceFreeStates where modes are ordered as: """ function initialize_mode_arrays!( intr::PerturbedEquilibriumInternal, - ffs_intr::ForceFreeStatesInternal + ffs::ForceFreeStatesResult ) - numpert_total = ffs_intr.numpert_total - mpert = ffs_intr.mpert - mlow = ffs_intr.mlow - nlow = ffs_intr.nlow + numpert_total = ffs.numpert_total + mpert = ffs.mpert + mlow = ffs.mlow + nlow = ffs.nlow # Allocate arrays intr.m_modes = zeros(Int, numpert_total) diff --git a/src/Tearing/Runner/HDF5Output.jl b/src/Tearing/Runner/HDF5Output.jl index 9bbbf3292..ca4287147 100644 --- a/src/Tearing/Runner/HDF5Output.jl +++ b/src/Tearing/Runner/HDF5Output.jl @@ -44,6 +44,9 @@ function write_slayer_hdf5!(parent::Union{HDF5.File,HDF5.Group}, end _write_per_surface!(g, result.params, result.dp_matrix) + # Surface identity (absent when the analysis was built from bare parameters). + isempty(result.rational_psi) || (g["PerSurface/rational_psi"] = result.rational_psi) + isempty(result.rational_q) || (g["PerSurface/rational_q"] = result.rational_q) _write_roots!(g, result) _write_layer_widths!(g, result.layer_widths) _write_diagnostics!(g, result) @@ -64,6 +67,8 @@ _layer_model_token(::Type{GGJParameters}) = "ggj" const TEARING_H5_ANNOTATIONS = [ "enabled" => (; long_name="flag: SLAYER/tearing stage ran (1) or was disabled (0)"), "PerSurface/rational_index" => (; long_name="rational-surface index of each row", dims=("surface",)), + "PerSurface/rational_psi" => (; long_name="normalized poloidal flux ψ_N of each analyzed surface", dims=("surface",)), + "PerSurface/rational_q" => (; long_name="safety factor q = m/n of each analyzed surface", dims=("surface",)), "PerSurface/m" => (; long_name="resonant poloidal mode number m per surface", dims=("surface",)), "PerSurface/n" => (; long_name="resonant toroidal mode number n per surface", dims=("surface",)), "PerSurface/tau" => (; long_name="temperature ratio τ = T_i/T_e per surface", dims=("surface",)), diff --git a/src/Tearing/Runner/Result.jl b/src/Tearing/Runner/Result.jl index f5a57e88c..0cffc21c9 100644 --- a/src/Tearing/Runner/Result.jl +++ b/src/Tearing/Runner/Result.jl @@ -16,6 +16,10 @@ downstream inspection and HDF5 output. - `enabled` -- `true` only when the analysis actually ran - `control` -- the `SLAYERControl` used (frozen snapshot) - `params` -- `Vector{SLAYERParameters}`, one per surface + - `rational_psi`, `rational_q` -- normalized poloidal flux ψ_N and safety + factor q of each analyzed surface, aligned with `params`. Empty when the + analysis was built from bare parameters (`run_slayer_from_inputs` without + the surface list), in which case the HDF5 writer skips them. - `dp_matrix` -- outer-region Δ' matrix used in the analysis - `Q_root` -- tearing eigenvalue(s) in normalized Q * length `nsurfaces` in `:uncoupled` mode @@ -37,6 +41,8 @@ struct SLAYERResult enabled::Bool control::SLAYERControl params::AbstractVector{<:InnerLayerParameters} + rational_psi::Vector{Float64} + rational_q::Vector{Float64} dp_matrix::Matrix{ComplexF64} Q_root::Vector{ComplexF64} omega_Hz::Vector{Float64} @@ -51,6 +57,7 @@ end function empty_slayer_result(control::SLAYERControl) return SLAYERResult(false, control, SLAYERParameters[], + Float64[], Float64[], zeros(ComplexF64, 0, 0), ComplexF64[], Float64[], Float64[], GrowthRateResult[], nothing, diff --git a/src/Tearing/Runner/run_slayer.jl b/src/Tearing/Runner/run_slayer.jl index 0e254e625..b42980465 100644 --- a/src/Tearing/Runner/run_slayer.jl +++ b/src/Tearing/Runner/run_slayer.jl @@ -1,8 +1,8 @@ # Runner.jl # # Top-level orchestration for the SLAYER tearing-mode analysis. Given a -# fully-solved `PlasmaEquilibrium` + `ForceFreeStatesInternal` (which -# supplies the rational-surface list and the outer-region Δ' matrix) + a +# `ForceFreeStatesResult` (which supplies the equilibrium, the rational-surface +# list and the outer-region Δ' matrix) + a # populated `SLAYERControl`, `run_slayer` loads kinetic profiles, builds # per-surface SLAYER parameters, runs the requested scan mode, extracts # growth rates by contour intersection, and returns a `SLAYERResult`. @@ -167,7 +167,9 @@ from cached HDF5 output). """ function run_slayer_from_inputs(params::AbstractVector{<:InnerLayerParameters}, dp_matrix::AbstractMatrix, - control::SLAYERControl) + control::SLAYERControl; + rational_psi::Vector{Float64}=Float64[], + rational_q::Vector{Float64}=Float64[]) validate(control) control.enabled || return empty_slayer_result(control) isempty(params) && return empty_slayer_result(control) @@ -309,7 +311,7 @@ function run_slayer_from_inputs(params::AbstractVector{<:InnerLayerParameters}, control.store_scan && push!(scan_data_list, scan) end - return SLAYERResult(true, control, params, dp, + return SLAYERResult(true, control, params, rational_psi, rational_q, dp, Q_root, omega_Hz, gamma_Hz, per_surface_extraction, coupled_extraction, layer_widths, scan_data_list) @@ -349,26 +351,36 @@ end # Full pipeline: equilibrium + ForceFreeStates → parameters → analysis # --------------------------------------------------------------------- """ - run_slayer(equil, ffs_intr, control; dir_path="./") -> SLAYERResult + run_slayer(result, control; dir_path="./") -> SLAYERResult -Orchestrate the full SLAYER analysis against a solved -`PlasmaEquilibrium` and `ForceFreeStatesInternal`. Kinetic profiles are +Orchestrate the full SLAYER analysis against a `ForceFreeStates.ForceFreeStatesResult`, +reading its equilibrium, singular surfaces and Δ' matrix. Kinetic profiles are read from `control.profile_file` (relative to `dir_path`) through the shared `Equilibrium.read_kinetic_file` reader; when the file carries `chi_e`/`chi_phi` profiles they set χ⊥(ψ)/χ_φ(ψ), otherwise the scalar `control.chi_perp`/ -`chi_tor` fallbacks are used. Per-surface parameters are built via -`build_slayer_inputs`; the outer-region Δ' matrix is pulled from -`ffs_intr.delta_prime_matrix` (or, if empty, from the diagonal -`sing.delta_prime` entries). +`chi_tor` fallbacks are used. Returns an `enabled=false` `SLAYERResult` when `control.enabled` is false. """ -function run_slayer(equil, ffs_intr, control::SLAYERControl; - dir_path::AbstractString="./") +function run_slayer(result, control::SLAYERControl; dir_path::AbstractString="./") + dpm = result.delta_prime === nothing ? Matrix{ComplexF64}(undef, 0, 0) : result.delta_prime.matrix + return run_slayer(result.equil, result.surfaces, dpm, control; dir_path=dir_path) +end + +""" + run_slayer(equil, surfaces, delta_prime_matrix, control; dir_path="./") -> SLAYERResult + +Loose-argument form of [`run_slayer`](@ref), taking the equilibrium, the singular-surface +vector and the outer-region Δ' matrix directly. Per-surface parameters are built via +`build_slayer_inputs`; an empty or wrong-sized `delta_prime_matrix` falls back to a diagonal +built from the `sing.delta_prime` stubs. +""" +function run_slayer(equil, surfaces::AbstractVector, delta_prime_matrix::AbstractMatrix, + control::SLAYERControl; dir_path::AbstractString="./") validate(control) control.enabled || return empty_slayer_result(control) - isempty(ffs_intr.sing) && return empty_slayer_result(control) + isempty(surfaces) && return empty_slayer_result(control) loaded = _load_profiles(control, dir_path) profiles = loaded.profiles @@ -376,7 +388,7 @@ function run_slayer(equil, ffs_intr, control::SLAYERControl; if control.inner_model in (:ggj_shooting, :ggj_galerkin) # GGJ γ-extraction is future work; `run_slayer_from_inputs` emits the # warning once the model is built (so direct callers see it too). - params = build_ggj_inputs(equil, ffs_intr.sing, profiles; + params = build_ggj_inputs(equil, surfaces, profiles; mu_i=control.mu_i, zeff=control.zeff, resistivity_model=_build_resistivity_model(control.resistivity_model), @@ -390,7 +402,7 @@ function run_slayer(equil, ffs_intr, control::SLAYERControl; "SLAYER: kinetic file has no usable chi_e/chi_phi profile(s) " * "(dataset absent or all-zero); using the scalar " * "control.chi_perp/chi_tor fallback for the missing one(s).") - params = build_slayer_inputs(equil, ffs_intr.sing, profiles; + params = build_slayer_inputs(equil, surfaces, profiles; bt=bt, mu_i=control.mu_i, zeff=control.zeff, @@ -406,18 +418,18 @@ function run_slayer(equil, ffs_intr, control::SLAYERControl; # Δ' matrix: prefer the full parallel-FM matrix; fall back to a # diagonal built from each SingType's scalar delta_prime. - dp = if !isempty(ffs_intr.delta_prime_matrix) && - size(ffs_intr.delta_prime_matrix) == (length(params), length(params)) - Matrix{ComplexF64}(ffs_intr.delta_prime_matrix) + dp = if !isempty(delta_prime_matrix) && + size(delta_prime_matrix) == (length(params), length(params)) + Matrix{ComplexF64}(delta_prime_matrix) else # The full Δ' matrix is unavailable (e.g. the parallel-FM stage that # populates it was not run). The scalar-diagonal fallback uses # `sing.delta_prime`, which is a coarse per-surface stub; surfaces # with no entry default to Δ'=0, giving γ computed from zero drive. - n_missing = count(s -> isempty(s.delta_prime), ffs_intr.sing) + n_missing = count(s -> isempty(s.delta_prime), surfaces) @warn( - "SLAYER: ffs_intr.delta_prime_matrix is empty or wrong-sized " * - "($(size(ffs_intr.delta_prime_matrix)) vs " * + "SLAYER: delta_prime_matrix is empty or wrong-sized " * + "($(size(delta_prime_matrix)) vs " * "($(length(params)),$(length(params)))); falling back to the " * "diagonal `sing.delta_prime` stub. Growth rates use a coarse " * "per-surface Δ' and may be unreliable" * @@ -425,11 +437,13 @@ function run_slayer(equil, ffs_intr, control::SLAYERControl; "default to Δ'=0 (zero tearing drive)." : ".") ) M = zeros(ComplexF64, length(params), length(params)) - for (k, s) in enumerate(ffs_intr.sing) + for (k, s) in enumerate(surfaces) M[k, k] = isempty(s.delta_prime) ? 0.0 + 0im : s.delta_prime[1] end M end - return run_slayer_from_inputs(params, dp, control) + rational_psi = Float64[surfaces[p.ising].psifac for p in params] + rational_q = Float64[surfaces[p.ising].q for p in params] + return run_slayer_from_inputs(params, dp, control; rational_psi=rational_psi, rational_q=rational_q) end diff --git a/test/runtests.jl b/test/runtests.jl index 7e0e46d5d..48a53e2d6 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -30,6 +30,8 @@ else include("./runtests_eulerlagrange.jl") include("./runtests_riccati.jl") include("./runtests_parallel_integration.jl") + include("./runtests_result_struct.jl") + include("./runtests_solve_api.jl") include("./runtests_sing.jl") include("./runtests_innerlayer.jl") include("./runtests_tj_analytic.jl") diff --git a/test/runtests_imas.jl b/test/runtests_imas.jl index 774fdbfe8..2d82d5b0e 100644 --- a/test/runtests_imas.jl +++ b/test/runtests_imas.jl @@ -115,8 +115,8 @@ using GeneralizedPerturbedEquilibrium.Equilibrium mock_et = [0.4+0im, 0.7+0im, 1.1+0im] mock_n_idx = [0, 0, 0] # all belong to n=1 (j=0) mock_result = ( - free_energies = (et=mock_et, n_tor_idx=mock_n_idx), - intr = (numpert_total=3, npert=1, nlow=1), + ffs = (integrator=:forward, free_boundary=(et=mock_et, n_tor_idx=mock_n_idx), + numpert_total=3, npert=1, nlow=1), ) GeneralizedPerturbedEquilibrium.write_imas(dd, mock_result) @@ -145,8 +145,8 @@ using GeneralizedPerturbedEquilibrium.Equilibrium mock_et = [0.3+0im, 0.5+0im, 0.6+0im, 0.7+0im] mock_n_idx = [0, 1, 0, 1] mock_result = ( - free_energies = (et=mock_et, n_tor_idx=mock_n_idx), - intr = (numpert_total=4, npert=2, nlow=1), + ffs = (integrator=:forward, free_boundary=(et=mock_et, n_tor_idx=mock_n_idx), + numpert_total=4, npert=2, nlow=1), ) GeneralizedPerturbedEquilibrium.write_imas(dd, mock_result) @@ -169,24 +169,24 @@ using GeneralizedPerturbedEquilibrium.Equilibrium # Single n=1 run dd_single = IMASdd.dd() result_single = ( - free_energies = (et=[0.3+0im, 0.6+0im], n_tor_idx=[0, 0]), - intr = (numpert_total=2, npert=1, nlow=1), + ffs = (integrator=:forward, free_boundary=(et=[0.3+0im, 0.6+0im], n_tor_idx=[0, 0]), + numpert_total=2, npert=1, nlow=1), ) GeneralizedPerturbedEquilibrium.write_imas(dd_single, result_single) # Single n=2 run dd_single2 = IMASdd.dd() result_single2 = ( - free_energies = (et=[0.5+0im, 0.7+0im], n_tor_idx=[0, 0]), - intr = (numpert_total=2, npert=1, nlow=2), + ffs = (integrator=:forward, free_boundary=(et=[0.5+0im, 0.7+0im], n_tor_idx=[0, 0]), + numpert_total=2, npert=1, nlow=2), ) GeneralizedPerturbedEquilibrium.write_imas(dd_single2, result_single2) # Combined n=1,2 run (same eigenvalues, now interleaved) dd_multi = IMASdd.dd() result_multi = ( - free_energies = (et=[0.3+0im, 0.5+0im, 0.6+0im, 0.7+0im], n_tor_idx=[0, 1, 0, 1]), - intr = (numpert_total=4, npert=2, nlow=1), + ffs = (integrator=:forward, free_boundary=(et=[0.3+0im, 0.5+0im, 0.6+0im, 0.7+0im], n_tor_idx=[0, 1, 0, 1]), + numpert_total=4, npert=2, nlow=1), ) GeneralizedPerturbedEquilibrium.write_imas(dd_multi, result_multi) @@ -200,4 +200,12 @@ using GeneralizedPerturbedEquilibrium.Equilibrium @test ts_multi.toroidal_mode[2].energy_perturbed == ts_single2.toroidal_mode[1].energy_perturbed end + # Test 7: write_imas warns and skips when the run produced no free-boundary energies + @testset "write_imas: warn-and-skip without free-boundary energies" begin + dd = IMASdd.dd() + no_fb = (ffs=(integrator=:riccati, free_boundary=nothing, numpert_total=2, npert=1, nlow=1),) + @test_logs (:warn,) match_mode = :any GeneralizedPerturbedEquilibrium.write_imas(dd, no_fb) + @test isempty(dd.mhd_linear.time_slice) + end + end diff --git a/test/runtests_result_struct.jl b/test/runtests_result_struct.jl new file mode 100644 index 000000000..4c3902453 --- /dev/null +++ b/test/runtests_result_struct.jl @@ -0,0 +1,248 @@ +using TOML +using HDF5 + +# ForceFreeStatesResult: what each formalism publishes, and how consumers gate on it. +# The Solovev fixture deck (mpsi=16, delta_m=0) is coarse but has two rational surfaces, +# which is all these interface assertions need. +@testset "ForceFreeStatesResult" begin + FFS = GeneralizedPerturbedEquilibrium.ForceFreeStates + template = joinpath(@__DIR__, "test_data", "regression_solovev_ideal_example") + + # Copy a deck into `dir` and apply `[ForceFreeStates]` overrides / extra sections. + function _stage_deck(dir, source; ffs_overrides=Dict{String,Any}(), extra_sections=Dict{String,Any}()) + for name in readdir(source) + cp(joinpath(source, name), joinpath(dir, name)) + end + toml_path = joinpath(dir, "gpec.toml") + inputs = TOML.parsefile(toml_path) + merge!(inputs["ForceFreeStates"], ffs_overrides) + merge!(inputs, extra_sections) + open(io -> TOML.print(io, inputs), toml_path, "w") + return dir + end + + @testset "forward run publishes a dense axis-basis solution" begin + mktempdir() do dir + _stage_deck(dir, template) + ret = GeneralizedPerturbedEquilibrium.main([dir]) + + @test keys(ret) == (:ffs, :pe, :slayer) + ffs = ret.ffs + @test ffs isa FFS.ForceFreeStatesResult + @test ffs.integrator === :forward + + # The solve's ξ solution, with its derivative stores populated by construction. + sol = ffs.solution + @test sol isa FFS.SolutionProfiles + @test sol.basis === :el_axis + @test sol.step == size(sol.u_store, 4) + @test size(sol.du_store) == (ffs.numpert_total, ffs.numpert_total, sol.step) + @test size(sol.xi_s_store) == (ffs.numpert_total, ffs.numpert_total, sol.step) + @test !iszero(sol.du_store) + @test !iszero(sol.xi_s_store) + @test length(sol.psi_store) == sol.step + @test length(sol.q_store) == sol.step + + # The raw ODE state rides along for the writer, distinct from the solution. + @test ffs.diagnostics !== nothing + @test ffs.diagnostics.crit_store !== nothing + + # Ideal closure: no inner layer was matched in. + @test ffs.closure === :ideal + @test size(ffs.bpen) == (length(ffs.surfaces), ffs.numpert_total) + @test iszero(ffs.bpen) + + # Forward integration produces no STRIDE propagators, hence no Δ′ matrix. + @test ffs.delta_prime === nothing + @test ffs.galerkin === nothing + @test ffs.free_boundary !== nothing # vac_flag = true in the fixture + + # Mode space and domain are copied verbatim out of the solve-time scratch. + @test ffs.mpert == ffs.mhigh - ffs.mlow + 1 + @test ffs.npert == ffs.nhigh - ffs.nlow + 1 + @test ffs.numpert_total == ffs.mpert * ffs.npert + @test 0 < ffs.psilim <= 1 + @test !isempty(ffs.surfaces) + @test ffs.control.integrator == "forward" + @test ffs.dir_path == dir + end + end + + @testset "riccati run publishes Δ′ and diagnostics but no ξ solution" begin + mktempdir() do dir + _stage_deck(dir, template; ffs_overrides=Dict{String,Any}("integrator" => "riccati")) + ret = GeneralizedPerturbedEquilibrium.main([dir]) + + ffs = ret.ffs + @test ffs.integrator === :riccati + # Chunk-endpoint Riccati states are not a ξ solution; only the raw state is carried. + @test ffs.solution === nothing + @test ffs.diagnostics !== nothing + @test !ffs.diagnostics.u_store_el_basis + @test ffs.closure === :ideal + @test iszero(ffs.bpen) + + @test ffs.delta_prime !== nothing + @test size(ffs.delta_prime.matrix) == (length(ffs.surfaces), length(ffs.surfaces)) + @test size(ffs.delta_prime.raw) == (2 * length(ffs.surfaces), 2 * length(ffs.surfaces)) + # Riccati persists only the raw D′ and recovers the parity blocks on demand. + @test ffs.delta_prime.A === nothing + @test ffs.delta_prime.B === nothing + @test ffs.delta_prime.Gamma === nothing + @test ffs.delta_prime.matrix ≈ FFS.pest3_decompose(ffs.delta_prime.raw).Δ + @test ffs.free_boundary !== nothing + end + end + + @testset "require / require_solution gates" begin + mktempdir() do dir + _stage_deck(dir, template; ffs_overrides=Dict{String,Any}("integrator" => "riccati")) + ffs = GeneralizedPerturbedEquilibrium.main([dir]).ffs + + # `require` passes silently on a populated field and warns once on an absent one. + @test @test_logs FFS.require(ffs, :free_boundary, "a calculation") + @test !(@test_logs (:warn,) FFS.require(ffs, :galerkin, "a calculation")) + + # `require_solution` is the ξ-specific presence gate. + @test !(@test_logs (:warn,) FFS.require_solution(ffs, "a calculation")) + end + end + + @testset "PerturbedEquilibrium warn-skips on a riccati result" begin + mktempdir() do dir + _stage_deck(dir, template; + ffs_overrides=Dict{String,Any}("integrator" => "riccati"), + extra_sections=Dict{String,Any}( + "ForcingTerms" => Dict{String,Any}( + "forcing_data_file" => "forcing.dat", + "forcing_data_format" => "ascii"), + "PerturbedEquilibrium" => Dict{String,Any}( + "compute_response" => true, + "compute_singular_coupling" => true, + "verbose" => false, + "write_outputs_to_HDF5" => false))) + cp(joinpath(@__DIR__, "..", "examples", "Solovev_ideal_example", "forcing.dat"), + joinpath(dir, "forcing.dat")) + + local ret + @test_logs (:warn,) match_mode = :any (ret = GeneralizedPerturbedEquilibrium.main([dir])) + # The stage runs to completion; both sub-calculations are simply not populated. + @test ret.pe !== nothing + @test isempty(ret.pe.permeability) + @test isempty(ret.pe.C_delta_prime) + end + end + + # Standalone Galerkin with the RPEC inner-layer match: the matched outer solution becomes + # the result's ξ solution, and the closure records whether an inner layer was matched in. + @testset "matched Galerkin publishes a gal-native solution" begin + for (deck, ideal) in (("LAR_ideal_match_test", true), ("LAR_resistive_match_test", false)) + @testset "$deck" begin + mktempdir() do dir + _stage_deck(dir, joinpath(@__DIR__, "..", "examples", deck)) + ffs = GeneralizedPerturbedEquilibrium.main([dir]).ffs + + @test ffs.integrator === :galerkin + @test ffs.galerkin !== nothing + @test ffs.galerkin.match !== nothing + + sol = ffs.solution + @test sol isa FFS.SolutionProfiles + @test sol.basis === :gal_native + @test sol.step == length(sol.psi_store) == size(sol.u_store, 4) + @test !iszero(sol.du_store) # analytic galerkin Ξ′ + @test !iszero(sol.xi_s_store) + + # Galerkin runs no Euler-Lagrange sweep and computes no free-boundary energies. + @test ffs.diagnostics === nothing + @test ffs.free_boundary === nothing + @test ffs.wp === nothing + + # Galerkin publishes its Δ′ through the same unified field the Riccati BVP + # uses, in the same PEST-3 convention and the same shared layout. + dp = ffs.delta_prime + @test dp !== nothing + msing = ffs.galerkin.msing + @test size(dp.matrix) == (msing, msing) + @test size(dp.raw) == (2msing, 2msing) + @test dp.matrix ≈ FFS.pest3_decompose(dp.raw).Δ + + # Unlike Riccati, the Galerkin solve persists the remaining parity blocks. + for (blk, ref) in ((dp.A, :A), (dp.B, :B), (dp.Gamma, :Γ)) + @test blk !== nothing + @test size(blk) == (msing, msing) + @test blk ≈ getfield(FFS.pest3_decompose(dp.raw), ref) + end + + # The rpec coil block is normalized to the Riccati orientation at pack time. + @test size(dp.coil) == (2msing, ffs.numpert_total) + @test !iszero(dp.coil) + + # The ideal-flag match skips the inner layer, so its basis is ideal-closed. + if ideal + @test ffs.closure === :ideal + @test iszero(ffs.bpen) + else + @test ffs.closure === :matched + @test ffs.bpen == ffs.galerkin.match.bpen + @test !iszero(ffs.bpen) + end + + # The closed profiles land in the shared Solutions layout: same names and + # (mode, solution, psi) axis order as ForwardIntegration, on the gal grid. + gal = "ForceFreeStates/Solutions/GalerkinIntegration" + HDF5.h5open(joinpath(dir, ffs.control.HDF5_filename), "r") do f + @test read(f["$gal/psi"]) == sol.psi_store + @test read(f["$gal/xi_psi"]) == sol.u_store[:, :, 1, :] + @test read(f["$gal/dxi_psidpsi"]) == sol.du_store + @test read(f["$gal/xi_s"]) == sol.xi_s_store + # Matching diagnostics keep their group; the profile datasets left it, + # and the raw outer basis is debug-gated off by default. + @test haskey(f, "$gal/Match/cout") + @test !haskey(f, "$gal/Match/xi") + @test !haskey(f, "$gal/Basis") + @test !haskey(f, "$gal/Solution") + end + end + end + end + end + @testset "gal_basis_output dumps the raw outer basis" begin + mktempdir() do dir + _stage_deck(dir, joinpath(@__DIR__, "..", "examples", "LAR_ideal_match_test"); + extra_sections=Dict{String,Any}("DEBUG" => Dict{String,Any}("gal_basis_output" => true))) + ffs = GeneralizedPerturbedEquilibrium.main([dir]).ffs + gal = "ForceFreeStates/Solutions/GalerkinIntegration" + HDF5.h5open(joinpath(dir, ffs.control.HDF5_filename), "r") do f + @test haskey(f, "$gal/Basis/xi_psi") + basis = read(f["$gal/Basis/xi_psi"]) + # (mode, solution, psi) on the FULL gal grid, on-surface nodes included; the + # columns are the 2·msing resonant basis solutions plus the coil-drive columns. + @test size(basis, 2) == 2 * ffs.galerkin.msing + ffs.numpert_total + @test size(basis, 3) == length(read(f["$gal/Basis/psi"])) + end + end + end + + @testset "fixed-boundary run still publishes the plasma energy matrix" begin + mktempdir() do dir + _stage_deck(dir, template; ffs_overrides=Dict{String,Any}("vac_flag" => false)) + ffs = GeneralizedPerturbedEquilibrium.main([dir]).ffs + + # No vacuum stage, so no free-boundary product — but W_p needs only the edge state. + @test ffs.free_boundary === nothing + @test ffs.wp !== nothing + @test size(ffs.wp) == (ffs.numpert_total, ffs.numpert_total) + @test all(isfinite, ffs.wp) + end + end + + @testset "free-boundary run aliases free_run's W_p" begin + mktempdir() do dir + _stage_deck(dir, template) + ffs = GeneralizedPerturbedEquilibrium.main([dir]).ffs + @test ffs.free_boundary !== nothing + @test ffs.wp === ffs.free_boundary.wp + end + end +end diff --git a/test/runtests_slayer_runner.jl b/test/runtests_slayer_runner.jl index 25a28f4fc..fb4b923dd 100644 --- a/test/runtests_slayer_runner.jl +++ b/test/runtests_slayer_runner.jl @@ -94,6 +94,48 @@ @test_throws ArgumentError slayer_control_from_toml(bad) end + @testset "run_slayer: result-facing form forwards surfaces and Δ'" begin + # A result with no singular surfaces short-circuits before any equilibrium access, + # so a stand-in result is enough to pin the forwarding of surfaces / delta_prime. + c = SLAYERControl(; enabled=true, profile_file="unused.h5") + no_surfaces = (equil=nothing, surfaces=GeneralizedPerturbedEquilibrium.ForceFreeStates.SingType[], + delta_prime=nothing) + r = run_slayer(no_surfaces, c) + @test isempty(r.params) + + # A disabled control never looks at the result at all. + r_off = run_slayer(no_surfaces, SLAYERControl(; enabled=false, profile_file="unused.h5")) + @test r_off.enabled == false + end + + # Δ′ is unified across formalisms, so a Galerkin run feeds SLAYER exactly as a Riccati one + # does: `result.delta_prime.matrix` is populated and already sized to the surface list, which + # is the predicate `run_slayer` uses to accept it over the per-surface diagonal stub. + @testset "Galerkin-fed SLAYER: gal Δ' drives the coupled solve" begin + mktempdir() do dir + deck = joinpath(@__DIR__, "..", "examples", "LAR_ideal_match_test") + for name in readdir(deck) + cp(joinpath(deck, name), joinpath(dir, name)) + end + ffs = GeneralizedPerturbedEquilibrium.main([dir]).ffs + @test ffs.integrator === :galerkin + + dpm = ffs.delta_prime.matrix + @test size(dpm) == (length(ffs.surfaces), length(ffs.surfaces)) + @test size(dpm, 1) == 2 + + # The gal Δ′ goes through SLAYER's coupled dispersion solve unmodified; the surface + # parameters are synthetic because the LAR deck carries no kinetic profiles. + params = [_mk_params(; rs=0.5, lu=1.0e7, tauk=1.0e-4, m=2, ising=1), + _mk_params(; rs=0.6, lu=2.0e7, tauk=1.2e-4, m=3, ising=2)] + c = SLAYERControl(; enabled=true, coupling_mode=:coupled, scan_mode=:brute_force, + Q_re_range=(-1.0, 1.0), Q_im_range=(-0.5, 0.8), nre=20, nim=20, pole_threshold=1e5) + r = run_slayer_from_inputs(params, dpm, c) + @test r.enabled + @test r.coupled_extraction isa GrowthRateResult + end + end + @testset "run_slayer_from_inputs: disabled path is a no-op" begin c = SLAYERControl(; enabled=false) params = [_mk_params()] @@ -168,7 +210,8 @@ nre=40, nim=40, pole_threshold=1e5, store_scan=true) - r = run_slayer_from_inputs(params, dp, c) + r = run_slayer_from_inputs(params, dp, c; + rational_psi=[0.45, 0.72], rational_q=[2.0, 3.0]) mktemp() do path, io close(io) @@ -191,6 +234,10 @@ # Settings are not echoed — inputs live only under Input/ (the merged TOML). @test !haskey(g, "Settings") @test haskey(g, "PerSurface") + # Surface identity: present when the caller supplied it, so Tearing + # results plot against psi/q even when SLAYER analyzed a surface subset. + @test read(g["PerSurface/rational_psi"]) == [0.45, 0.72] + @test read(g["PerSurface/rational_q"]) == [2.0, 3.0] @test haskey(g, "Roots") @test haskey(g, "Diagnostics") @test haskey(g, "Scan") diff --git a/test/runtests_solve_api.jl b/test/runtests_solve_api.jl new file mode 100644 index 000000000..d04eb9065 --- /dev/null +++ b/test/runtests_solve_api.jl @@ -0,0 +1,206 @@ +using TOML + +# The scripting API: `PlasmaEquilibrium` / `solve(eq, alg)` / `perturbed_equilibrium` must run the +# same pipeline the TOML driver does. Every assertion is anchored on the coarse Solovev fixture +# deck (mpsi=16, mthvac=64, delta_m=0), so an API run is compared against `main` on the same deck. +@testset "solve API" begin + GPEC = GeneralizedPerturbedEquilibrium + FFS = GPEC.ForceFreeStates + template = joinpath(@__DIR__, "test_data", "regression_solovev_ideal_example") + deck = TOML.parsefile(joinpath(template, "gpec.toml")) + + # The fixture equilibrium and wall, built exactly as `build_inputs_from_toml` builds them. + equil = GPEC.Equilibrium.setup_equilibrium( + GPEC.Equilibrium.EquilibriumConfig(deck["Equilibrium"], template), + GPEC.Equilibrium.SolovevConfig(deck["SOL_INPUT"]) + ) + wall = GPEC.Vacuum.WallShapeSettings(; (Symbol(k) => v for (k, v) in deck["Wall"])...) + + # The deck's `[ForceFreeStates]` block as `solve` keywords: everything except the knobs the + # integrator object and the `nn` keyword own. + ffs_kwargs = Dict(Symbol(k) => v for (k, v) in deck["ForceFreeStates"] + if !(k in ("integrator", "nn_low", "nn_high"))) + + # Copy the fixture deck into `dir` and apply `[ForceFreeStates]` overrides / extra sections. + function _stage_deck(dir; ffs_overrides=Dict{String,Any}(), extra_sections=Dict{String,Any}()) + for name in readdir(template) + cp(joinpath(template, name), joinpath(dir, name)) + end + toml_path = joinpath(dir, "gpec.toml") + inputs = TOML.parsefile(toml_path) + merge!(inputs["ForceFreeStates"], ffs_overrides) + merge!(inputs, extra_sections) + open(io -> TOML.print(io, inputs), toml_path, "w") + return dir + end + + @testset "forward solve matches the TOML-driven run" begin + mktempdir() do dir + reference = GPEC.main([_stage_deck(dir)]).ffs + api = solve(equil, Forward(); nn=1, wall=wall, dir_path=dir, ffs_kwargs...) + + @test api isa FFS.ForceFreeStatesResult + @test api.integrator === :forward + @test api.free_boundary !== nothing + @test api.free_boundary.et[1] ≈ reference.free_boundary.et[1] rtol = 1e-12 + @test api.diagnostics.nzero == reference.diagnostics.nzero + @test api.mlow == reference.mlow + @test api.mhigh == reference.mhigh + @test api.psilim ≈ reference.psilim rtol = 1e-12 + @test length(api.surfaces) == length(reference.surfaces) + + # The solution is the forward integrator's dense axis-basis profile set. + @test api.solution isa FFS.SolutionProfiles + @test api.solution.basis === :el_axis + @test api.closure === :ideal + end + end + + @testset "riccati solve produces the unified delta_prime" begin + mktempdir() do dir + reference = GPEC.main([_stage_deck(dir; ffs_overrides=Dict{String,Any}("integrator" => "riccati"))]).ffs + prob = EulerLagrangeProblem(equil; nn=1, wall=wall, dir_path=dir, ffs_kwargs...) + api = solve(prob, Riccati(; nchunks=40)) + + @test api.integrator === :riccati + @test api.control.nchunks == 40 + @test api.solution === nothing + @test api.delta_prime !== nothing + msing = length(api.surfaces) + @test size(api.delta_prime.matrix) == (msing, msing) + @test api.delta_prime.matrix ≈ FFS.pest3_decompose(api.delta_prime.raw).Δ + + # Chunking is a decomposition of the same problem, so Δ′ tracks the TOML run. + @test size(reference.delta_prime.matrix) == size(api.delta_prime.matrix) + @test api.delta_prime.matrix ≈ reference.delta_prime.matrix rtol = 1e-6 + end + end + + @testset "galerkin solve returns a gal result with a unified delta_prime" begin + mktempdir() do dir + api = solve(equil, Galerkin(; nx=32); nn=1, wall=wall, dir_path=dir, ffs_kwargs...) + + @test api.integrator === :galerkin + @test api.control.gal_nx == 32 + @test api.galerkin !== nothing + @test api.free_boundary === nothing # galerkin computes no free-boundary energies + dp = api.delta_prime + @test dp !== nothing + msing = api.galerkin.msing + @test size(dp.matrix) == (msing, msing) + @test dp.matrix ≈ FFS.pest3_decompose(dp.raw).Δ + @test dp.A !== nothing # the parity blocks the galerkin solve persists + end + end + + @testset "perturbed_equilibrium round-trips a forward result" begin + forcing = joinpath(@__DIR__, "..", "examples", "Solovev_ideal_example", "forcing.dat") + pe_section = Dict{String,Any}( + "compute_response" => true, + "compute_singular_coupling" => true, + "verbose" => false, + "write_outputs_to_HDF5" => false) + + mktempdir() do dir + _stage_deck(dir; + extra_sections=Dict{String,Any}( + "ForcingTerms" => Dict{String,Any}( + "forcing_data_file" => "forcing.dat", + "forcing_data_format" => "ascii"), + "PerturbedEquilibrium" => pe_section)) + cp(forcing, joinpath(dir, "forcing.dat")) + reference = GPEC.main([dir]).pe + + ffs = solve(equil, Forward(); nn=1, wall=wall, dir_path=dir, ffs_kwargs...) + pe = perturbed_equilibrium(ffs, RMPField(forcing); + (Symbol(k) => v for (k, v) in pe_section)...) + + # The response matrices depend on the equilibrium and the solve, not on the drive. + @test !isempty(pe.permeability) + @test pe.permeability ≈ reference.permeability rtol = 1e-10 + @test size(pe.C_delta_prime) == size(reference.C_delta_prime) + @test !isempty(pe.resonant_area_weighted_field) + + # The amplitude-linear outputs are compared through the driver's own input path: + # the driver hands the stage the modes it snapshotted before the solve, whereas a + # fresh RMPField re-reads the file and re-runs the normalization conversion. + snapshot = GPEC.ForcingTerms.ForcingMode[] + GPEC.ForcingTerms.load_forcing_data!(snapshot, dir, "forcing.dat", "ascii", false) + injected = perturbed_equilibrium(ffs, RMPField(forcing); forcing_modes=snapshot, + (Symbol(k) => v for (k, v) in pe_section)...) + @test injected.resonant_area_weighted_field ≈ reference.resonant_area_weighted_field rtol = 1e-10 + @test injected.forcing_b ≈ reference.forcing_b rtol = 1e-10 + + # `scale` is a uniform multiplier on the materialized forcing, and the response is + # linear in it. + scaled = perturbed_equilibrium(ffs, RMPField(forcing; scale=2.0); + (Symbol(k) => v for (k, v) in pe_section)...) + @test scaled.forcing_b ≈ 2 .* pe.forcing_b rtol = 1e-10 + @test scaled.resonant_area_weighted_field ≈ 2 .* pe.resonant_area_weighted_field rtol = 1e-10 + + # Lazy source algebra materializes to the combined field: 3A - A == 2A drives + # the same perturbed equilibrium as scale=2 (exercises +, -, * and the merge). + combo = perturbed_equilibrium(ffs, 3 * RMPField(forcing) - RMPField(forcing); + (Symbol(k) => v for (k, v) in pe_section)...) + @test combo.forcing_b ≈ scaled.forcing_b rtol = 1e-10 + @test combo.resonant_area_weighted_field ≈ scaled.resonant_area_weighted_field rtol = 1e-10 + end + end + + @testset "RMPField algebra is lazy and flattens" begin + a = RMPField("a.dat") + b = RMPField("b.dat"; scale=0.5) + c = RMPField("c.dat") + s = a + b + @test s isa GPEC.ForcingTerms.RMPFieldSum + @test length(s.terms) == 2 + @test length((a + b + c).terms) == 3 + d = 2.0 * s + @test d.terms[1].scale == 2.0 + 0.0im + @test d.terms[2].scale == 1.0 + 0.0im + @test (im * a).scale == im + @test (a * 3).scale == 3.0 + 0.0im + @test (a - b).terms[2].scale == -0.5 + 0.0im + @test (-a).scale == -1.0 + 0.0im + end + + @testset "RMPField infers its format and carries its scale" begin + @test RMPField("forcing.dat").ctrl.forcing_data_format == "ascii" + @test RMPField("forcing.h5").ctrl.forcing_data_format == "hdf5" + @test RMPField("forcing.dat"; format="hdf5").ctrl.forcing_data_format == "hdf5" + @test isabspath(RMPField("forcing.dat").ctrl.forcing_data_file) + @test RMPField("forcing.dat"; scale=3.0).scale == 3.0 + coil_field = RMPField(Dict{String,Any}[Dict{String,Any}("name" => "iu")]; machine="d3d") + @test coil_field.ctrl.forcing_data_format == "coil" + @test coil_field.ctrl.machine == "d3d" + @test length(coil_field.ctrl.coil_sets_raw) == 1 + end + + @testset "integrator objects translate onto the control keys" begin + kwargs = Dict{Symbol,Any}() + FFS._apply_alg!(kwargs, Galerkin(; nx=64, rpec_flag=true)) + @test kwargs[:integrator] == "galerkin" + @test kwargs[:gal_nx] == 64 + @test kwargs[:gal_rpec_flag] + + # A match implies the coil-response columns and fills the inner-layer knobs. + FFS._apply_match!(kwargs, ResistiveMatch(; eta=[1e-6], inner_solver="ray"), Galerkin()) + @test kwargs[:gal_match_flag] + @test kwargs[:gal_rpec_flag] + @test kwargs[:gal_eta] == [1e-6] + @test kwargs[:gal_inner_solver] == "ray" + @test !kwargs[:gal_ideal_flag] + + # Every key the objects own is a `ForceFreeStatesControl` field. + @test all(in(fieldnames(FFS.ForceFreeStatesControl)), keys(kwargs)) + end + + @testset "rejected keyword combinations" begin + @test_throws ErrorException solve(equil, Forward(); nn=1, dir_path=".", ffs_kwargs..., kinetic_factor=0.5) + @test_throws ErrorException solve(equil, Riccati(); nn=1, dir_path=".", ffs_kwargs..., match=ResistiveMatch()) + @test_throws ErrorException solve(equil, Forward(); nn=1, dir_path=".", ffs_kwargs..., match=ResistiveMatch()) + @test_throws ErrorException solve(equil, Forward(); nn=1, dir_path=".", ffs_kwargs..., integrator="riccati") + @test_throws ErrorException solve(equil, Riccati(); nn=1, dir_path=".", ffs_kwargs..., nchunks=8) + @test_throws ErrorException solve(equil, Forward(); nn=1, dir_path=".", ffs_kwargs..., nn_low=2) + end +end diff --git a/test/test_data/regression_solovev_ideal_example/gpec.toml b/test/test_data/regression_solovev_ideal_example/gpec.toml index bf3ceb3df..9b3d346ac 100644 --- a/test/test_data/regression_solovev_ideal_example/gpec.toml +++ b/test/test_data/regression_solovev_ideal_example/gpec.toml @@ -46,7 +46,7 @@ singfac_min = 1e-4 # Fractional distance from rational q at which ide ucrit = 1e3 # Column-norm threshold that triggers solution renormalization # Integrator selection and domain truncation (see ForceFreeStatesControl docstring for details) -integrator = "forward" # Integration formalism: "forward" (dense ξ, needed for [PerturbedEquilibrium]/kinetic) or "riccati" (chunked propagator BVP, unlocks the singular-surface Δ′ matrix) +integrator = "forward" # Integration formalism: "forward" (dense ξ, needed for [PerturbedEquilibrium]/kinetic), "riccati" (chunked propagator BVP, unlocks the singular-surface Δ′ matrix), or "galerkin" (RDCON outer-region singular Galerkin Δ′, with the optional RPEC inner-layer match) set_psilim_via_dmlim = false # FALSE for limited/analytical equilibria — rationals sparse, dmlim would chop too much edge dmlim = 0.2 # Truncate integration at (last_rational_q + dmlim)/n (used when set_psilim_via_dmlim = true) diff --git a/test/test_data/regression_solovev_ideal_example_multi_n/gpec.toml b/test/test_data/regression_solovev_ideal_example_multi_n/gpec.toml index d74b2884d..58c4a6cd1 100644 --- a/test/test_data/regression_solovev_ideal_example_multi_n/gpec.toml +++ b/test/test_data/regression_solovev_ideal_example_multi_n/gpec.toml @@ -46,7 +46,7 @@ singfac_min = 1e-4 # Fractional distance from rational q at which ide ucrit = 1e3 # Column-norm threshold that triggers solution renormalization # Integrator selection and domain truncation (see ForceFreeStatesControl docstring for details) -integrator = "forward" # Integration formalism: "forward" (dense ξ, needed for [PerturbedEquilibrium]/kinetic) or "riccati" (chunked propagator BVP, unlocks the singular-surface Δ′ matrix) +integrator = "forward" # Integration formalism: "forward" (dense ξ, needed for [PerturbedEquilibrium]/kinetic), "riccati" (chunked propagator BVP, unlocks the singular-surface Δ′ matrix), or "galerkin" (RDCON outer-region singular Galerkin Δ′, with the optional RPEC inner-layer match) set_psilim_via_dmlim = false # FALSE for multi-n — dmlim truncation is ambiguous when n varies (sing_lim! skips anyway) dmlim = 0.2 # Truncate integration at (last_rational_q + dmlim)/n (used when set_psilim_via_dmlim = true) diff --git a/test/test_data/regression_solovev_kinetic_calculated/gpec.toml b/test/test_data/regression_solovev_kinetic_calculated/gpec.toml index 38b1a2394..1a6bb6f13 100644 --- a/test/test_data/regression_solovev_kinetic_calculated/gpec.toml +++ b/test/test_data/regression_solovev_kinetic_calculated/gpec.toml @@ -42,7 +42,7 @@ mthvac = 64 # Number of points used in splines over poloidal a kinetic_source = "calculated" # Kinetic matrix source: "fixed" test matrices, or "calculated" from the kinetic NTV model kinetic_factor = 1.0 # Scaling of kinetic matrices (0 = ideal path; >0 enables kinetic mode; 1.0 = full strength) -integrator = "forward" # Integration formalism: "forward" (dense ξ, needed for [PerturbedEquilibrium]/kinetic) or "riccati" (chunked propagator BVP, unlocks the singular-surface Δ′ matrix) +integrator = "forward" # Integration formalism: "forward" (dense ξ, needed for [PerturbedEquilibrium]/kinetic), "riccati" (chunked propagator BVP, unlocks the singular-surface Δ′ matrix), or "galerkin" (RDCON outer-region singular Galerkin Δ′, with the optional RPEC inner-layer match) eulerlagrange_tolerance = 1e-7 # Relative tolerance for ODE integration of Euler-Lagrange equations singfac_min = 1e-4 # Fractional distance from rational q at which ideal jump enforced ucrit = 1e3 # Column-norm threshold that triggers solution renormalization diff --git a/test/test_data/regression_solovev_kinetic_example/gpec.toml b/test/test_data/regression_solovev_kinetic_example/gpec.toml index 60b8f67fa..67692a315 100644 --- a/test/test_data/regression_solovev_kinetic_example/gpec.toml +++ b/test/test_data/regression_solovev_kinetic_example/gpec.toml @@ -46,7 +46,7 @@ singfac_min = 1e-4 # Fractional distance from rational q at which ide ucrit = 1e3 # Column-norm threshold that triggers solution renormalization # Integrator selection and domain truncation (see ForceFreeStatesControl docstring for details) -integrator = "forward" # Integration formalism: "forward" (dense ξ, needed for [PerturbedEquilibrium]/kinetic) or "riccati" (chunked propagator BVP, unlocks the singular-surface Δ′ matrix) +integrator = "forward" # Integration formalism: "forward" (dense ξ, needed for [PerturbedEquilibrium]/kinetic), "riccati" (chunked propagator BVP, unlocks the singular-surface Δ′ matrix), or "galerkin" (RDCON outer-region singular Galerkin Δ′, with the optional RPEC inner-layer match) set_psilim_via_dmlim = false # FALSE for limited/analytical equilibria — rationals sparse, dmlim would chop too much edge dmlim = 0.2 # Truncate integration at (last_rational_q + dmlim)/n (used when set_psilim_via_dmlim = true) diff --git a/test/test_data/regression_solovev_kinetic_multi_n/gpec.toml b/test/test_data/regression_solovev_kinetic_multi_n/gpec.toml index 6e82349dc..b2bdfb2b1 100644 --- a/test/test_data/regression_solovev_kinetic_multi_n/gpec.toml +++ b/test/test_data/regression_solovev_kinetic_multi_n/gpec.toml @@ -46,7 +46,7 @@ singfac_min = 1e-4 # Fractional distance from rational q at which ide ucrit = 1e3 # Column-norm threshold that triggers solution renormalization # Integrator selection and domain truncation (see ForceFreeStatesControl docstring for details) -integrator = "forward" # Integration formalism: "forward" (dense ξ, needed for [PerturbedEquilibrium]/kinetic) or "riccati" (chunked propagator BVP, unlocks the singular-surface Δ′ matrix) +integrator = "forward" # Integration formalism: "forward" (dense ξ, needed for [PerturbedEquilibrium]/kinetic), "riccati" (chunked propagator BVP, unlocks the singular-surface Δ′ matrix), or "galerkin" (RDCON outer-region singular Galerkin Δ′, with the optional RPEC inner-layer match) set_psilim_via_dmlim = false # FALSE for multi-n — dmlim truncation is ambiguous when n varies (sing_lim! skips anyway) dmlim = 0.2 # Truncate integration at (last_rational_q + dmlim)/n (used when set_psilim_via_dmlim = true) diff --git a/test/test_data/regression_solovev_kinetic_nuzero/gpec.toml b/test/test_data/regression_solovev_kinetic_nuzero/gpec.toml index a3cd0efe6..b41311748 100644 --- a/test/test_data/regression_solovev_kinetic_nuzero/gpec.toml +++ b/test/test_data/regression_solovev_kinetic_nuzero/gpec.toml @@ -42,7 +42,7 @@ mthvac = 64 # Number of points used in splines over poloidal a kinetic_source = "calculated" # Kinetic matrix source — exercises KineticForces.compute_calculated_kinetic_matrices callback with real physics kinetic_factor = 1.0 # Full-strength kinetic matrices (the "calculated" path is the real physics; no perturbation scaling) -integrator = "forward" # Integration formalism: "forward" (dense ξ, needed for [PerturbedEquilibrium]/kinetic) or "riccati" (chunked propagator BVP, unlocks the singular-surface Δ′ matrix) +integrator = "forward" # Integration formalism: "forward" (dense ξ, needed for [PerturbedEquilibrium]/kinetic), "riccati" (chunked propagator BVP, unlocks the singular-surface Δ′ matrix), or "galerkin" (RDCON outer-region singular Galerkin Δ′, with the optional RPEC inner-layer match) eulerlagrange_tolerance = 1e-7 # Relative tolerance for ODE integration of Euler-Lagrange equations singfac_min = 1e-4 # Fractional distance from rational q at which ideal jump enforced ucrit = 1e3 # Maximum fraction of solutions allowed before re-normalized