diff --git a/.gitignore b/.gitignore index 7e1ff4f6..c7f7d6ab 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,9 @@ pestotv regression-harness/*.sqlite regression-harness/*.sqlite-shm regression-harness/*.sqlite-wal +scratch/ +.claude/worktrees/ +.claude/agent-memory/ + +# Local profiling scratch (one-off study scripts, not part of the package) +profiling/ diff --git a/Project.toml b/Project.toml index fd2c512d..f1067858 100644 --- a/Project.toml +++ b/Project.toml @@ -7,6 +7,7 @@ version = "0.1.0" [deps] AdaptiveArrayPools = "4f381ef7-9af0-4cbe-99d4-cf36d7b0f233" Contour = "d38c429a-6771-53c6-b99e-75d170b6e991" +DelaunayTriangulation = "927a84f5-c5f4-47a5-9785-b46e178433df" DelimitedFiles = "8bb1440f-4735-579b-a4ab-409b98df4dab" DiffEqCallbacks = "459566f4-90b8-5000-8ac3-15dfb0a30def" Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" @@ -36,6 +37,7 @@ Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [compat] AdaptiveArrayPools = "0.3.5" Contour = "0.6.3" +DelaunayTriangulation = "1.6.6" DelimitedFiles = "1.9.1" DiffEqCallbacks = "4.9.0" Documenter = "1.14.1" diff --git a/docs/make.jl b/docs/make.jl index b62bf121..35cc0983 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -36,7 +36,7 @@ makedocs(; "KineticForces" => "kinetic_forces.md", "Forcing Terms" => "forcing_terms.md", "Perturbed Equilibrium" => "perturbed_equilibrium.md", - "Inner Layer" => "inner_layer.md", + "Tearing" => "inner_layer.md", "Analysis" => "analysis.md", "Utilities" => "utilities.md" ], diff --git a/docs/src/developer_notes.md b/docs/src/developer_notes.md index 9a77b751..cbb33740 100644 --- a/docs/src/developer_notes.md +++ b/docs/src/developer_notes.md @@ -20,6 +20,98 @@ Where CODE is the module name (EQUIL, ForceFreeStates, VAC, PERTURBED EQUILIBRIU The regression harness **must be run on every pull request before merging into `develop`**. It is the project's primary safeguard for tracking how numerical results evolve across changes, so it is only useful if every PR exercises it. When you open a PR, paste the regression report into the PR thread so reviewers can see what moved (and what did not). If your change touches a quantity that is not yet tracked, add a new regression case — or extend an existing one — in the same PR. +### Open problem: pinning grid-sensitive Δ′ robustly + +The ideal-MHD Δ′ values pinned in `test/runtests_parallel_integration.jl` +(`delta_prime_matrix` diagonal) and tracked by the harness are **single-point +snapshots**: one value at one `psi_accuracy` on one grid. They exist to catch +unintended changes, and are explicitly *not* converged Δ′. The extraction is +intrinsically grid-sensitive — a `psi_accuracy` scan from 2e-3 to 2.5e-4 swings +the DIII-D-like `dpm[1,1]` by roughly 50% (about 6.2 to 9.9), and switching the +grid generator moves it again. Any such pin therefore encodes an arbitrary point +on a varying curve, and re-pinning is required whenever the grid changes, which +weakens it as a regression signal. + +A more defensible criterion is to pin the **plateau**: scan Δ′ across +`psi_accuracy` and the integration-truncation controls, and take the value where +the result is stationary — the mode of the resulting distribution rather than any +single sample. Where a plateau exists it is a property of the physics rather than +of the discretization, so it would survive grid changes and would be a genuine +convergence statement. + +A `psi_accuracy` scan on the DIII-D-like SLAYER deck (2e-3 down to 3.125e-5, a +64x range) shows what a plateau search is up against: + +| psi_accuracy | knots | implied | dpm[1,1] | dpm[2,2] | dpm[3,3] | gamma 2/1 (Hz) | +|---|---|---|---|---|---|---| +| 2.0e-3 | 172 | – | 6.850 | -5.783 | -16.308 | 165.3 | +| 1.0e-3 | 205 | – | 6.387 | -5.873 | -16.867 | 154.3 | +| 5.0e-4 | 252 | – | 8.003 | -6.105 | -16.413 | 192.9 | +| 2.5e-4 | 307 | 522 | 8.567 | -6.235 | -16.529 | 206.2 | +| 1.25e-4 | 372 | 666 | 8.146 | -6.285 | -16.649 | 196.2 | +| 6.25e-5 | 457 | 916 | 8.911 | -6.477 | -16.510 | 214.4 | +| 3.125e-5 | 559 | 1226 | 9.514 | -6.260 | -16.485 | 228.7 | + +Three things follow. First, convergence is per-surface: the outer surface +`dpm[3,3]` (q=4) *does* plateau under the auto grid — the last four points agree +to about 1% — while `dpm[2,2]` is marginal and `dpm[1,1]` (q=2) never settles, +still moving ~7% between the two tightest grids. A plateau detector must +therefore report per-surface rather than pass/fail for the whole diagonal. +(As the `ldp` scan below shows, q=2 is not inherently unconvergeable — it is the +auto grid that prevents it from settling.) + +Second, the growth rate is linear in Δ′: `gamma/dpm[1,1]` is 24.1 to within 0.5% +across the whole scan, so a converged Δ′ is both necessary and sufficient for a +converged SLAYER growth rate on this surface. + +Third — and this is the blocker — the `implied` column (what +`implied_knot_count` requests after the refined solve) grows monotonically +relative to the grid actually used, from 1.7x to 2.2x. The two-pass scheme stops +after pass 2, so tightening `psi_accuracy` moves it *further* from +self-consistency rather than closer, and the warning's advice to "consider +tightening psi_accuracy" is counterproductive in this regime. + +The same deck on a deterministic `ldp` grid, which skips the measure-and-re-form +step entirely, converges: + +| mpsi | dpm[1,1] | dpm[2,2] | dpm[3,3] | gamma 2/1 (Hz) | +|---|---|---|---|---| +| 128 | 5.208 | -8.666 | -19.157 | 126.2 | +| 256 | 7.097 | -5.187 | -15.729 | 171.2 | +| 512 | 8.489 | -6.217 | -16.517 | 204.4 | +| 1024 | 8.932 | -6.298 | -16.529 | 214.9 | +| 2048 | 8.914 | -6.385 | -16.468 | 214.5 | + +Successive `dpm[1,1]` increments are +1.889, +1.392, +0.443, -0.018: the last +doubling moves both Δ′ and the growth rate by 0.2%. So the q=2 Δ′ is **not** +intrinsically ill-conditioned — it converges to ≈8.91, with gamma ≈214.5 Hz, and +`dpm[3,3]` converges to ≈-16.5 on *both* grid families, which is what a physical +value should do. The non-convergence under the auto grid is an artifact of the +generator, not of the Δ′ extraction. + +Two consequences. A plateau criterion is implementable today against a fixed +`ldp` grid, without waiting on the auto-grid work. And the auto grid's answers +are biased in both directions relative to the converged value: at its default +`psi_accuracy` it gave 6.39 (28% low), at its tightest 9.51 (7% high). Anything +pinned on the auto grid should be read with that in mind. + +This is not implemented. Doing it properly needs: + + - either a fixed `ldp` grid (which already converges, see above) or knot + refinement iterated to a fixed point (repeat the measure-and-re-form + step until `implied_knot_count` stops exceeding the grid in use), since + without it the scan target keeps moving; + - a scan driver over `psi_accuracy` × truncation (`psihigh`, `dmlim`, `qhigh`) + that records the Δ′ diagonal per configuration; + - a plateau/mode detector with an explicit stationarity tolerance, plus a + defined failure mode for surfaces where no plateau exists (the + near-separatrix surfaces are expected to fall in this class); + - a harness case that pins plateau values and their scan width, replacing the + single-point pins. + +Until then, treat the pinned diagonal as an order-of-magnitude and sign +diagnostic only, and expect to re-pin it whenever the equilibrium grid changes. + ### Regression Harness: Quick usage guide Set up an alias for convenience (optional): diff --git a/docs/src/inner_layer.md b/docs/src/inner_layer.md index cc80f52e..387c0655 100644 --- a/docs/src/inner_layer.md +++ b/docs/src/inner_layer.md @@ -1,9 +1,10 @@ -# Inner Layer Module +# Tearing Module -The InnerLayer module provides abstract scaffolding for resistive inner-layer -models used in matched asymptotic expansions for resistive MHD stability -analysis. It currently includes the GGJ (Glasser–Greene–Johnson) shooting -method for computing the inner-layer response. +The `Tearing` module groups the resistive tearing-mode analysis stack: +`InnerLayer` (per-surface inner-layer matching data Δ(Q) for the GGJ and +SLAYER models), `Dispersion` (physics-agnostic complex-plane scan and +contour-intersection root extraction), and `Runner` (user-facing TOML +configuration, profile loading, and HDF5 output). ## InnerLayer @@ -16,3 +17,21 @@ Modules = [GeneralizedPerturbedEquilibrium.InnerLayer] ```@autodocs Modules = [GeneralizedPerturbedEquilibrium.InnerLayer.GGJ] ``` + +## SLAYER + +```@autodocs +Modules = [GeneralizedPerturbedEquilibrium.InnerLayer.SLAYER] +``` + +## Dispersion + +```@autodocs +Modules = [GeneralizedPerturbedEquilibrium.Dispersion] +``` + +## Runner + +```@autodocs +Modules = [GeneralizedPerturbedEquilibrium.Runner] +``` diff --git a/examples/DIIID-like_SLAYER_example/gpec.toml b/examples/DIIID-like_SLAYER_example/gpec.toml new file mode 100644 index 00000000..2374ea51 --- /dev/null +++ b/examples/DIIID-like_SLAYER_example/gpec.toml @@ -0,0 +1,96 @@ +# DIII-D-like SLAYER tearing-mode growth-rate example. +# Reuses the equilibrium and H-mode kinetic profiles of the sibling +# DIIID-like_ideal_example (geqdsk referenced by relative path; not +# duplicated). Runs equilibrium + ForceFreeStates + SLAYER and skips +# PerturbedEquilibrium (ForceFreeStates.force_termination = true). + +[Equilibrium] +eq_filename = "../DIIID-like_ideal_example/TkMkr_D3Dlike_Hmode.geqdsk" # Path to equilibrium file +eq_type = "efit" # Type of the input 2D equilibrium file +jac_type = "hamada" # Coordinate system (hamada, pest, boozer, equal_arc) +power_bp = 0 # Poloidal field power exponent for Jacobian +power_b = 0 # Toroidal field power exponent for Jacobian +power_r = 0 # Major radius power exponent for Jacobian +grid_type = "log_asymptotic" # Radial grid packing type +psilow = 1e-4 # Lower limit of normalized flux coordinate +psihigh = 0.9995 # Upper limit of normalized flux coordinate +mpsi = 0 # Number of radial grid points (0 = auto-compute from psi_accuracy) +psi_accuracy = 0.001 # Target absolute error in q for auto-mpsi +mtheta = 256 # Number of poloidal grid points +newq0 = 0 # Override for on-axis safety factor (0 = use input value) +etol = 1e-10 # Error tolerance for equilibrium solver +force_termination = false # Terminate after equilibrium setup (skip stability calculations) + +[Wall] +shape = "nowall" # Wall shape (nowall, conformal, elliptical, dee, mod_dee, filepath) +a = 0.2415 # Distance from plasma (conformal) or shape parameter +aw = 0.05 # Half-thickness parameter for Dee-shaped walls +bw = 1.5 # Elongation parameter for wall shapes +cw = 0 # Offset of wall center from major radius +dw = 0.5 # Triangularity parameter for wall shapes +tw = 0.05 # Sharpness of wall corners (try 0.05 as initial value) +equal_arc_wall = true # Equal arc length distribution of nodes on wall + +[ForceFreeStates] +force_termination = true # Run FFS + SLAYER, skip PerturbedEquilibrium +local_stability_flag = true # Perform local stability analysis (Mercier and ballooning) across the ψ profile +mat_flag = true # Construct coefficient matrices for diagnostic purposes +ode_flag = true # Integrate ODEs for stability of the internal long-wavelength mode (must be true for GPEC) +vac_flag = true # Compute plasma, vacuum, and total energies for free-boundary modes + +psiedge = 0.99 # Edge dW scan band: dW(ψ) computed for ψ ∈ [psiedge, psilim], integration truncated at peak +qlow = 1.02 # Integration initiated at q determined by min(q0, qlow)... +qhigh = 1e3 # Integration terminated at q limit determined by min(qa, qhigh)... +sing_start = 0 # Start integration at the sing_start'th rational from the axis (psilow) + +nn_low = 1 # Smallest toroidal mode number to include +nn_high = 1 # Largest toroidal mode number to include +delta_mlow = 8 # Expands lower bound of Fourier harmonics +delta_mhigh = 8 # Expands upper bound of Fourier harmonics +mthvac = 512 # Number of points used in splines over poloidal angle at the plasma-vacuum interface + +kinetic_source = "fixed" # Kinetic matrix source: "fixed" test matrices, or "calculated" from the kinetic NTV model +kinetic_factor = 0.0 # Scaling of kinetic matrices (0 = ideal path; >0 enables kinetic mode) +eulerlagrange_tolerance = 1e-10# Relative tolerance for ODE integration of Euler-Lagrange equations +save_interval = 3 # Save every Nth ODE step (1=all, 10=every 10th). Always saves near rational surfaces. +singfac_min = 1e-4 # Fractional distance from rational q at which ideal jump enforced +ucrit = 1e4 # Maximum fraction of solutions allowed before re-normalized + +# Δ' BVP + parallel integration (see ForceFreeStatesControl docstring for details) +use_parallel = true # Run parallel FM-propagator BVP path (unlocks singular/delta_prime_matrix) +parallel_threads = 1 # serial/bit-deterministic BVP — keeps the regression Δ' (and hence γ) reproducible +populate_dense_xi = false # No PerturbedEquilibrium here; the dense EL pass has no consumer (auto-disabled under force_termination anyway). SLAYER needs only delta_prime_matrix from the parallel BVP. +truncate_at_dW_peak = false # Edge-dW scan stays diagnostic; integration domain set by qhigh / psihigh / dmlim +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 + +[SLAYER] +# SLAYER tearing-mode growth-rate analysis on the DIII-D-like H-mode equilibrium. +# Runs in the force_termination path (PE skipped). Kinetic profiles (incl. the +# χ⊥/χ_φ heat/momentum diffusivities) are read from the standardized HDF5 +# kinetic file shared with the kinetic/NTV physics. Per-surface (uncoupled) +# analysis: the headline is the unstable 2/1; the validity gate drops surfaces +# with no real root (e.g. the 5/1, whose Δ' BVP yields a huge uncancellable Δ'). +enabled = true +inner_model = "slayer_fitzpatrick" +scan_mode = "amr" +coupling_mode = "uncoupled" +dc_type = "none" +mu_i = 2.0 +zeff = 1.0 +chi_perp = 1.0 # fallback only; the kinetic file supplies χ⊥(ψ) +chi_tor = 1.0 # fallback only; the kinetic file supplies χ_φ(ψ) +pole_threshold_adaptive = true # SLAYER |Δ| spans many orders; adapt to 10·median(|Δ|) +filter_above_poles = true +filter_outside_re = true +polish_roots = true # refine each root to the true zero; enables the validity gate +store_scan = false # per-surface scans omitted from HDF5 (uncoupled has one per surface) +# Standardized kinetic-profile file (GPEC HDF5 kinetic schema), shared with the +# sibling ideal example; carries n_e, T_e, T_i, omega_E, chi_e (χ⊥), chi_phi (χ_φ). +profile_file = "../DIIID-like_ideal_example/TkMkr_D3Dlike_Hmode_kinetic.h5" + +[SLAYER.scan_grid] +Q_re_range = [-2.0, 2.0] +Q_im_range = [-0.5, 3.0] +nre = 41 +nim = 31 diff --git a/examples/Solovev_ideal_example/gpec.toml b/examples/Solovev_ideal_example/gpec.toml index 82367adc..2ed0654c 100644 --- a/examples/Solovev_ideal_example/gpec.toml +++ b/examples/Solovev_ideal_example/gpec.toml @@ -44,6 +44,11 @@ verbose = true # Enable verbose logging write_outputs_to_HDF5 = true # Write perturbed equilibrium outputs to HDF5 reg_spot = 0.05 # Regularization width for singular surfaces (0 = disabled) +# Note: SLAYER tearing analysis is not run on the Solovev analytic equilibrium +# — its Δ' / inner-layer dispersion does not yield meaningful tearing roots. +# The SLAYER example and regression case use the DIII-D-like geqdsk instead +# (see examples/DIIID-like_SLAYER_example). + [ForceFreeStates] local_stability_flag = true # Perform local stability analysis (Mercier and ballooning) across the ψ profile vac_flag = true # Compute plasma, vacuum, and total energies for free-boundary modes diff --git a/regression-harness/cases/diiid_slayer_n1.toml b/regression-harness/cases/diiid_slayer_n1.toml new file mode 100644 index 00000000..13167efe --- /dev/null +++ b/regression-harness/cases/diiid_slayer_n1.toml @@ -0,0 +1,162 @@ +[case] +name = "diiid_slayer_n1" +description = "DIII-D-like H-mode equilibrium, n=1, SLAYER tearing-mode analysis (uncoupled per-surface, AMR, validity-gated)" +example_dir = "examples/DIIID-like_SLAYER_example" + +# --------------------------------------------------------------------- +# Per-surface SLAYER layer parameters (geometry + dimensionless) +# --------------------------------------------------------------------- +[quantities.slayer_ising] +h5path = "slayer/per_surface/ising" +type = "real_vector" +extract = "all_real" +label = "SLAYER surface indices" +noise_threshold = 0 +order = 10 + +[quantities.slayer_m] +h5path = "slayer/per_surface/m" +type = "real_vector" +extract = "all_real" +label = "SLAYER poloidal m" +noise_threshold = 0 +order = 11 + +[quantities.slayer_n] +h5path = "slayer/per_surface/n" +type = "real_vector" +extract = "all_real" +label = "SLAYER toroidal n" +noise_threshold = 0 +order = 12 + +[quantities.slayer_rs] +h5path = "slayer/per_surface/rs" +type = "real_vector" +extract = "all_real" +label = "SLAYER minor radius rs" +noise_threshold = 1e-10 +order = 13 + +[quantities.slayer_sval_r] +h5path = "slayer/per_surface/sval_r" +type = "real_vector" +extract = "all_real" +label = "SLAYER r-based shear" +noise_threshold = 1e-10 +order = 14 + +[quantities.slayer_lu] +h5path = "slayer/per_surface/lu" +type = "real_vector" +extract = "all_real" +label = "SLAYER Lundquist S" +noise_threshold = 1e-8 +order = 15 + +[quantities.slayer_D_norm] +h5path = "slayer/per_surface/D_norm" +type = "real_vector" +extract = "all_real" +label = "SLAYER D_norm" +noise_threshold = 1e-10 +order = 16 + +[quantities.slayer_P_perp] +h5path = "slayer/per_surface/P_perp" +type = "real_vector" +extract = "all_real" +label = "SLAYER P_perp" +noise_threshold = 1e-8 +order = 17 + +[quantities.slayer_tauk] +h5path = "slayer/per_surface/tauk" +type = "real_vector" +extract = "all_real" +label = "SLAYER tauk" +noise_threshold = 1e-12 +order = 18 + +[quantities.slayer_iota_e] +h5path = "slayer/per_surface/iota_e" +type = "real_vector" +extract = "all_real" +label = "SLAYER iota_e" +noise_threshold = 1e-12 +order = 19 + +# --------------------------------------------------------------------- +# Tearing eigenvalue (coupled mode → length 1). The headline deliverable: +# a real, nonzero growth rate on a realistic equilibrium. Root-extraction +# is sensitive to the AMR cell topology and ODE solver, so the Q/ω/γ +# thresholds are absolute and modestly loose; re-pin intentionally if a +# solver/AMR change shifts the root inventory. +# Growth-rate / frequency / root outputs. Pinned for the inner three rational +# surfaces only (2/1, 3/1, 4/1) via `first_3`: the Δ'/γ contour search is +# numerically unreliable on the outermost surfaces (e.g. 5/1, 6/1, 7/1 near the +# edge), so those are deliberately not golden-tracked. +# --------------------------------------------------------------------- +[quantities.slayer_Q_re] +h5path = "slayer/roots/Q_root_real" +type = "real_vector" +extract = "first_3" +label = "SLAYER Re(Q_root) [2/1,3/1,4/1]" +noise_threshold = 1e-4 +order = 30 + +[quantities.slayer_Q_im] +h5path = "slayer/roots/Q_root_imag" +type = "real_vector" +extract = "first_3" +label = "SLAYER Im(Q_root) [2/1,3/1,4/1]" +noise_threshold = 1e-4 +order = 31 + +[quantities.slayer_omega_Hz] +h5path = "slayer/roots/omega_Hz" +type = "real_vector" +extract = "first_3" +label = "SLAYER ω_Hz [2/1,3/1,4/1]" +noise_threshold = 1.0 +order = 32 + +[quantities.slayer_gamma_Hz] +h5path = "slayer/roots/gamma_Hz" +type = "real_vector" +extract = "first_3" +label = "SLAYER γ_Hz [2/1,3/1,4/1]" +noise_threshold = 1e-1 +order = 33 + +# no_root flag (1 = extraction failed). Pinned for the inner three surfaces; +# this case guards that the 2/1, 3/1, 4/1 keep finding a real root. +[quantities.slayer_no_root] +h5path = "slayer/roots/no_root" +type = "real_vector" +extract = "first_3" +label = "SLAYER no_root flags [2/1,3/1,4/1]" +noise_threshold = 0 +order = 34 + +# --------------------------------------------------------------------- +# Settings (catches accidental config drift) +# --------------------------------------------------------------------- +[quantities.slayer_enabled] +h5path = "slayer/enabled" +type = "int_scalar" +extract = "value" +label = "SLAYER enabled flag" +noise_threshold = 0 +order = 90 + +# --------------------------------------------------------------------- +# Runtime +# --------------------------------------------------------------------- +[quantities.runtime] +h5path = "" +type = "runtime" +extract = "value" +label = "Runtime (s)" +noise_threshold = 0.0 +order = 999 diff --git a/regression-harness/src/extractor.jl b/regression-harness/src/extractor.jl index e24fdbb4..f82ca8fe 100644 --- a/regression-harness/src/extractor.jl +++ b/regression-harness/src/extractor.jl @@ -70,12 +70,23 @@ function apply_extraction(spec::QuantitySpec, raw)::ExtractedQuantity elseif spec.extract == "all_real" arr = Float64.(real.(raw)) - json_str = JSON.json(arr) + # allownan: SLAYER γ/Q_root are NaN when no dispersion root is found — + # a legitimate state to record so a future root appearing flags a diff. + json_str = JSON.json(arr; allownan=true) + return ExtractedQuantity(name, label, nothing, nothing, json_str, "json_array", threshold) + + elseif startswith(spec.extract, "first_") + # "first_N": real values of the leading N vector elements only. Used to + # golden-pin the inner (trustworthy) rational surfaces while ignoring + # edge surfaces where the Δ'/γ contour search is numerically unreliable. + nkeep = parse(Int, spec.extract[(length("first_")+1):end]) + arr = Float64.(real.(raw))[1:min(nkeep, length(raw))] + json_str = JSON.json(arr; allownan=true) return ExtractedQuantity(name, label, nothing, nothing, json_str, "json_array", threshold) elseif spec.extract == "all_complex" pairs = [[real(x), imag(x)] for x in raw] - json_str = JSON.json(pairs) + json_str = JSON.json(pairs; allownan=true) return ExtractedQuantity(name, label, nothing, nothing, json_str, "json_array", threshold) elseif spec.extract == "diagonal_complex" @@ -85,7 +96,7 @@ function apply_extraction(spec::QuantitySpec, raw)::ExtractedQuantity error("diagonal_complex requires a square 2-D matrix; got size $(size(raw))") diag_vec = [raw[i, i] for i in 1:size(raw, 1)] pairs = [[real(x), imag(x)] for x in diag_vec] - json_str = JSON.json(pairs) + json_str = JSON.json(pairs; allownan=true) return ExtractedQuantity(name, label, nothing, nothing, json_str, "json_array", threshold) elseif spec.extract == "checksum" @@ -98,9 +109,14 @@ function apply_extraction(spec::QuantitySpec, raw)::ExtractedQuantity end end -"""Compute absolute difference between two JSON-parsed elements (scalars, pairs, or nested arrays).""" +""" +Compute absolute difference between two JSON-parsed elements (scalars, pairs, or nested arrays). +""" function _json_element_diff(a, b)::Float64 if a isa Number && b isa Number + # Two NaN values denote the same "no result" state (e.g. SLAYER + # Q_root when no dispersion root is found) — treat as identical. + (isnan(Float64(a)) && isnan(Float64(b))) && return 0.0 return abs(Float64(a) - Float64(b)) elseif a isa Vector && b isa Vector return sqrt(sum(_json_element_diff(ai, bi)^2 for (ai, bi) in zip(a, b))) @@ -109,9 +125,14 @@ function _json_element_diff(a, b)::Float64 end end -"""Compute absolute value/magnitude of a JSON-parsed element.""" +""" +Compute absolute value/magnitude of a JSON-parsed element. +""" function _json_element_abs(x)::Float64 if x isa Number + # NaN denotes a "no result" state (e.g. SLAYER Q_root with no root); + # treat as 0 magnitude so it doesn't poison a vector's reference norm. + isnan(Float64(x)) && return 0.0 return abs(Float64(x)) elseif x isa Vector return sqrt(sum(_json_element_abs(xi)^2 for xi in x)) @@ -168,8 +189,8 @@ function compare_values(q1::NamedTuple, q2::NamedTuple) if t1 === nothing || t2 === nothing return (NaN, NaN, "N/A") end - arr1 = JSON.parse(t1) - arr2 = JSON.parse(t2) + arr1 = JSON.parse(t1; allownan=true) + arr2 = JSON.parse(t2; allownan=true) if length(arr1) != length(arr2) return (NaN, NaN, "CHANGED (length)") end diff --git a/regression-harness/src/reporter.jl b/regression-harness/src/reporter.jl index e86fc498..c0c57758 100644 --- a/regression-harness/src/reporter.jl +++ b/regression-harness/src/reporter.jl @@ -14,7 +14,7 @@ function _short_err(msg)::String lines = filter(!isempty, strip.(split(s, '\n'))) isempty(lines) && return "(empty)" pick = something(findfirst(l -> startswith(l, "ERROR:") || occursin("Error", l), lines), - length(lines)) + length(lines)) line = lines[pick] return length(line) > 200 ? line[1:200] * "..." : line end @@ -36,7 +36,7 @@ function format_value(q::NamedTuple)::String elseif q.value_type == "json_array" t = q.value_text t === nothing && return "N/A" - arr = JSON.parse(t) + arr = JSON.parse(t; allownan=true) return "[$(length(arr)) elem]" elseif q.value_type == "checksum" t = q.value_text @@ -66,7 +66,9 @@ function format_diff(abs_diff::Float64, rel_diff::Float64, status::String, value return @sprintf("%.3e (%.2f%%)", abs_diff, rel_diff * 100) end -"""Helper: print a row of padded columns.""" +""" +Helper: print a row of padded columns. +""" function _print_row(cols::Vector{String}, widths::Vector{Int}) parts = [rpad(cols[i], widths[i]) for i in eachindex(cols)] println(join(parts, " ")) @@ -76,7 +78,7 @@ end Print a two-ref comparison report for a single case. """ function report_two_ref_comparison(db::SQLite.DB, case_spec::CaseSpec, - ref1::ResolvedRef, ref2::ResolvedRef) + ref1::ResolvedRef, ref2::ResolvedRef) info1 = get_run_info(db, ref1.commit_hash, case_spec.name) info2 = get_run_info(db, ref2.commit_hash, case_spec.name) @@ -103,7 +105,9 @@ function report_two_ref_comparison(db::SQLite.DB, case_spec::CaseSpec, # Pre-compute all rows: (label, v1, v2, diff, status) header = ["Quantity", ref1.name, ref2.name, "Diff", "Status"] rows = Vector{Vector{String}}() - n_ok = 0; n_changed = 0; n_missing = 0 + n_ok = 0 + n_changed = 0 + n_missing = 0 # Helper: pick the value-cell text for one ref/quantity, accounting for # whole-ref failure (which should render as "FAILED" rather than "N/A"). @@ -114,7 +118,7 @@ function report_two_ref_comparison(db::SQLite.DB, case_spec::CaseSpec, runtime_cell = (failed::Bool, qs::Dict, qname::String) -> begin failed && return "FAILED" (haskey(qs, qname) && qs[qname].value_real !== nothing) ? - @sprintf("%.1fs", qs[qname].value_real) : "N/A" + @sprintf("%.1fs", qs[qname].value_real) : "N/A" end for spec in case_spec.quantities @@ -145,15 +149,20 @@ function report_two_ref_comparison(db::SQLite.DB, case_spec::CaseSpec, continue end - q1 = q1_all[qname]; q2 = q2_all[qname] + q1 = q1_all[qname] + q2 = q2_all[qname] abs_diff, rel_diff, status = compare_values(q1, q2) st = status == "CHANGED" ? "** CHANGED **" : status push!(rows, [spec.label, format_value(q1), format_value(q2), - format_diff(abs_diff, rel_diff, status, q1.value_type), st]) + format_diff(abs_diff, rel_diff, status, q1.value_type), st]) - if status == "OK"; n_ok += 1 - elseif contains(status, "CHANGED"); n_changed += 1 - else; n_missing += 1; end + if status == "OK" + n_ok += 1 + elseif contains(status, "CHANGED") + n_changed += 1 + else + n_missing += 1 + end end # Derive column widths from header + all row content @@ -202,13 +211,15 @@ Print a multi-ref tracking report for a single case. One row per quantity, one column per ref. """ function report_multi_ref(db::SQLite.DB, case_spec::CaseSpec, - refs::Vector{ResolvedRef}) + refs::Vector{ResolvedRef}) run_infos = [get_run_info(db, ref.commit_hash, case_spec.name) for ref in refs] failed_mask = [info === nothing || !info.success for info in run_infos] - all_qs = [begin - info = run_infos[i] - (info !== nothing && info.success) ? get_quantities(db, refs[i].commit_hash, case_spec.name) : Dict{String,NamedTuple}() - end for i in eachindex(refs)] + all_qs = [ + begin + info = run_infos[i] + (info !== nothing && info.success) ? get_quantities(db, refs[i].commit_hash, case_spec.name) : Dict{String,NamedTuple}() + end for i in eachindex(refs) + ] show_diff = length(refs) >= 2 ref_names = [ref.name for ref in refs] @@ -223,7 +234,9 @@ function report_multi_ref(db::SQLite.DB, case_spec::CaseSpec, # Pre-compute rows rows = Vector{Vector{String}}() - n_ok = 0; n_changed = 0; n_missing = 0 + n_ok = 0 + n_changed = 0 + n_missing = 0 for spec in case_spec.quantities qname = spec.name @@ -274,9 +287,13 @@ function report_multi_ref(db::SQLite.DB, case_spec::CaseSpec, st = status == "CHANGED" ? "** CHANGED **" : status push!(row, format_diff(abs_diff, rel_diff, status, q_prev.value_type)) push!(row, st) - if status == "OK"; n_ok += 1 - elseif contains(status, "CHANGED"); n_changed += 1 - else; n_missing += 1; end + if status == "OK" + n_ok += 1 + elseif contains(status, "CHANGED") + n_changed += 1 + else + n_missing += 1 + end elseif last_failed || prev_failed push!(row, "") push!(row, "FAILED") diff --git a/regression-harness/src/runner.jl b/regression-harness/src/runner.jl index 8705cb4d..45aabce5 100644 --- a/regression-harness/src/runner.jl +++ b/regression-harness/src/runner.jl @@ -57,6 +57,9 @@ end # Runs the Galerkin solver on the Glasser & Wang 2020 Eq. 55 parameter set # at γ = 1 + i and writes (Δ_odd, Δ_even) into a small HDF5 file at ARGS[1]. # Runtime is written to ARGS[2] for parity with the GPEC runner. +# `solve_inner` returns the named-field `InnerLayerResponse`; the historical +# delta_odd / delta_even slots map to interchange / tearing respectively, which +# preserves the numeric identity of each tracked quantity. const COMPUTED_GGJ_SCRIPT_TEMPLATE = """ using Pkg %INSTANTIATE% @@ -69,10 +72,10 @@ t_start = time() Δ = solve_inner(GGJModel(solver=:galerkin), p, γ) elapsed = time() - t_start h5open(ARGS[1], "w") do fid - fid["ggj/delta_odd_real"] = real(Δ[1]) - fid["ggj/delta_odd_imag"] = imag(Δ[1]) - fid["ggj/delta_even_real"] = real(Δ[2]) - fid["ggj/delta_even_imag"] = imag(Δ[2]) + fid["ggj/delta_odd_real"] = real(Δ.interchange) + fid["ggj/delta_odd_imag"] = imag(Δ.interchange) + fid["ggj/delta_even_real"] = real(Δ.tearing) + fid["ggj/delta_even_imag"] = imag(Δ.tearing) end open(ARGS[2], "w") do f println(f, elapsed) diff --git a/src/Equilibrium/DirectEquilibrium.jl b/src/Equilibrium/DirectEquilibrium.jl index 02f18410..b98511ab 100644 --- a/src/Equilibrium/DirectEquilibrium.jl +++ b/src/Equilibrium/DirectEquilibrium.jl @@ -380,7 +380,7 @@ function direct_refine(rfac::Float64, eta::Float64, psi0::Float64, params::Field end return find_zero((f, fp), rfac, Roots.Newton(); - atol=1e-12*abs(psi0), rtol=1e-12, maxevals=50) + atol=1e-12 * abs(psi0), rtol=1e-12, maxevals=50) end """ @@ -499,7 +499,7 @@ robustness. @. ff_x_nodes = @view(y_out[:, 5]) / y_out[end, 5] ff_fs_nodes = acquire!(pool, Float64, size(y_out, 1), 4) - @. ff_fs_nodes[:, 1] = @view(y_out[:, 3]) ^ 2 + @. ff_fs_nodes[:, 1] = @view(y_out[:, 3])^2 @. ff_fs_nodes[:, 2] = @view(y_out[:, 1]) / (2π) - ff_x_nodes @. ff_fs_nodes[:, 3] = bfield.f * (@view(y_out[:, 4]) - ff_x_nodes * y_out[end, 4]) @. ff_fs_nodes[:, 4] = @view(y_out[:, 2]) / y_out[end, 2] - ff_x_nodes diff --git a/src/ForceFreeStates/ForceFreeStates.jl b/src/ForceFreeStates/ForceFreeStates.jl index 65d31ab6..4e6d3c0f 100644 --- a/src/ForceFreeStates/ForceFreeStates.jl +++ b/src/ForceFreeStates/ForceFreeStates.jl @@ -28,6 +28,7 @@ include("Ballooning.jl") include("Resist.jl") include("EulerLagrange.jl") include("Sing.jl") +include("ResistEval.jl") include("Fourfit.jl") include("FixedKineticMatrices.jl") include("Kinetic.jl") diff --git a/src/ForceFreeStates/ForceFreeStatesStructs.jl b/src/ForceFreeStates/ForceFreeStatesStructs.jl index 7f727ea5..d7569a43 100644 --- a/src/ForceFreeStates/ForceFreeStatesStructs.jl +++ b/src/ForceFreeStates/ForceFreeStatesStructs.jl @@ -31,6 +31,7 @@ A mutable struct holding data related to the singular surfaces in the equilibriu ua_right::Array{ComplexF64,3} = Array{ComplexF64}(undef, 0, 0, 0) # asymptotic basis at right inner-layer boundary psi_ua_left::Float64 = 0.0 # ψ where ua_left was evaluated (left inner-layer boundary) psi_ua_right::Float64 = 0.0 # ψ where ua_right was evaluated (right inner-layer boundary) + restype::Any = nothing # ResistGeometry from ResistEval.jl (populated by resist_eval_all!); typed `Any` to avoid a cross-file type reference end """ @@ -194,6 +195,23 @@ A mutable struct holding internal state variables for stability calculations. raw 2msing×2msing BVP solution to produce the PEST3-compatible tearing parameter. """ delta_prime_matrix::Matrix{ComplexF64} = Matrix{ComplexF64}(undef, 0, 0) + + """ + Raw 2msing × 2msing outer-region matching matrix `D'` from the STRIDE global + BVP, in the side-major ordering `[L_s1, R_s1, L_s2, R_s2, …, L_sm, R_sm]` + (left vs right of each singular surface, interleaved surface-by-surface). + This is the Pletzer–Dewar 1991 outer-region matrix before parity rotation, + and is stored byte-compatibly with the Fortran `rdcon/gal.f::gal_write_delta` + convention (top 2msing×2msing block of `delta_gw.dat`). The PEST3 Δ' matrix + stored in `delta_prime_matrix` is the odd-parity tearing projection of this + raw matrix; the even-parity A' and off-parity B', Γ' blocks are recovered + via `pest3_decompose(dp_raw)` — needed for the full det(D' − D(γ)) = 0 + eigenvalue problem with Glasser stabilization. + + Empty unless `ctrl.use_parallel` is true. No ½ prefactor is applied (matches + Fortran rdcon; Pletzer–Dewar paper multiplies by ½). + """ + delta_prime_raw::Matrix{ComplexF64} = Matrix{ComplexF64}(undef, 0, 0) end """ @@ -238,7 +256,7 @@ A mutable struct containing control parameters for stability analysis, set by th - `force_termination::Bool` - Terminate after force-free states (skip perturbed equilibrium calculations) - `use_riccati::Bool` - Use the dual Riccati reformulation S = U₁·U₂⁻¹ instead of the standard U₁/U₂ ODE. Reduces stiffness for faster integration. See Glasser (2018) Phys. Plasmas 25, 032507. - `use_parallel::Bool` - Parallel fundamental matrix (propagator) integration using `Threads.@threads`. Each chunk is integrated independently from identity IC and assembled serially. Requires `singfac_min != 0`. Uses the same chunk bounds as the standard path but sub-divides chunks for load balancing. Crossings use the Riccati-style algorithm (no Gaussian reduction). - - `parallel_threads::Int` - Cap on the number of threads the parallel BVP uses. **Default `2`** parallelises the FM chunks across two threads (the BVP has ~10 chunks; 2 threads is enough to amortize them — speedup saturates here, raising to 4 adds scheduling overhead). Set `parallel_threads = 1` to run the FM chunks SERIALLY (no `Threads.@threads`), which is bit-deterministic and immune to the thread-schedule sensitivity that historically caused intermittent BVP divergences on numerically delicate equilibria like DIII-D 147131 (see CONVENTIONS.md §7). Empirical reliability sweep (5 trials × {1,2,4} on DIII-D 147131 βₚ≈0.07): 15/15 bit-identical Δ′ at every setting; pt=2 ≈ pt=4 ≈ 20 % faster than serial. If a parallel run diverges, drop to `parallel_threads = 1` rather than switching `use_parallel = false` — the latter is silently wrong. Capped at `Threads.nthreads()`. + - `parallel_threads::Int` - Cap on the number of threads the parallel BVP uses. **Default `2`** parallelises the FM chunks across two threads (the BVP has ~10 chunks; 2 threads is enough to amortize them — speedup saturates here, raising to 4 adds scheduling overhead). Set `parallel_threads = 1` to run the FM chunks SERIALLY (no `Threads.@threads`), which is bit-deterministic and immune to the thread-schedule sensitivity that can cause intermittent BVP divergence on numerically delicate equilibria. The parallel path produces bit-identical Δ′ across thread counts; `parallel_threads = 2` is about 20% faster than serial and saturates the speedup. If a parallel run diverges, drop to `parallel_threads = 1` rather than switching `use_parallel = false` — the latter is silently wrong. Capped at `Threads.nthreads()`. - `populate_dense_xi::Bool` - When `use_parallel = true`, append a serial Euler-Lagrange pass at the end of the propagator BVP and let it replace the `odet` returned to the main pipeline. This populates `u_store` / `ud_store` densely in the axis (EL) basis — the only convention the PerturbedEquilibrium / FieldReconstruction downstream code consumes correctly. Without it the parallel path stores only chunk-endpoint Riccati S matrices and zeros for `ud_store` (see Riccati.jl docstring caveats), and HDF5 `integration/xi_psi`/`dxi_psi`/`xi_s` are unusable. Δ' (`singular/delta_prime_matrix`) is computed from the parallel BVP and is bit-identical between `populate_dense_xi=true` and `false`. Energies (`vacuum/ep`/`ev`/`et`) are computed by `free_run!` from `odet`, so with `populate_dense_xi=true` they match what a pure serial run (`use_parallel=false`) would produce; with `populate_dense_xi=false` they use the parallel-pass Riccati `odet.u` instead (differs by the ~0.12 % Riccati-vs-axis algorithmic gap on DIIID-class cases). **Default `false`** to avoid paying the dense-pass cost on Δ'/vacuum/ideal-stability-only runs; **PerturbedEquilibrium-using configs must set `populate_dense_xi = true` explicitly** when `use_parallel = true` (otherwise PE silently reads Riccati-basis garbage). Auto-disabled when `force_termination = true` regardless of the user setting, since the dense pass has no downstream consumer in that case. Approximate cost when enabled: one extra serial EL integration (~1× the parallel BVP wall-clock for typical N). - `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. """ diff --git a/src/ForceFreeStates/ResistEval.jl b/src/ForceFreeStates/ResistEval.jl new file mode 100644 index 00000000..8bfc5a03 --- /dev/null +++ b/src/ForceFreeStates/ResistEval.jl @@ -0,0 +1,214 @@ +# ResistEval.jl +# +# Per-singular-surface Glasser-Greene-Johnson geometric coefficients (E, F, +# G, H, K, M) and the two flux-surface averages (⟨B²/|∇ψ|²⟩, ⟨B²⟩) that +# downstream callers need to turn geometry into τ_A / τ_R with kinetic +# profiles. +# +# Port of Fortran RDCON `resist_eval` (geometric part only). +# Unlike the Fortran, this routine produces *only* the pure-equilibrium +# quantities; kinetic timescales (τ_A, τ_R) are built on top in the +# downstream `build_ggj_inputs` helper using the same KineticProfiles that +# feed SLAYER, rather than Fortran's hardcoded `ne=1e14, te=3e3` +# parameter defaults. +# +# The 6 theta-integrands match the Fortran layout: +# 1: B² / |∇ψ|² +# 2: 1 / |∇ψ|² +# 3: 1 / B² +# 4: 1 / (B² · |∇ψ|²) +# 5: B² +# 6: |∇ψ|² / B² +# All weighted by `jac / v1` (jacobian / dV/dψ) before integration. +# +# A seventh integrand, B, is added (beyond the Fortran set) so that ⟨B⟩ is +# available for the Lin-Liu & Miller 1995 trapped-fraction formula used by +# the shared NeoclassicalResistivity closure. B_max, B_min, and the flux- +# surface-averaged major radius R_major are accumulated alongside by +# running extrema over the θ-loop. + +""" + ResistGeometry + +Per-singular-surface Glasser-Greene-Johnson geometric coefficients and +supporting flux-surface averages. + +| field | meaning | +|-------------|------------------------------------------------------| +| `E`, `F` | Glasser interchange parameters (enter `D_I = E+F+H-¼`) | +| `G` | Coupling coefficient (curvature × pressure gradient) | +| `H` | Pfirsch-Schlüter coefficient | +| `K` | Glasser parameter | +| `M` | Mass factor | +| `avg_bsq_over_dpsisq` | ⟨B²/|∇ψ|²⟩ — needed for τ_R | +| `avg_bsq` | ⟨B²⟩ — needed for τ_R | +| `avg_B` | ⟨B⟩ — needed for Lin-Liu-Miller f_t | +| `B_max`, `B_min` | θ-extrema of B on the surface [T] | +| `f_trap` | Lin-Liu & Miller 1995 trapped-particle fraction | +| `R_major` | flux-surface-averaged major radius ⟨R⟩ [m] | +| `eps_local` | (R_max − R_min)/2 / R_major — local inverse aspect ratio | +| `p_local` | Plasma pressure at this surface [Pa] | +| `p1_local` | dp/dψ at this surface | +| `v1_local` | dV/dψ at this surface | + +`H` here is identical to the `H` reported by `mercier_scan!` and stored +in `locstab/h` — the GGJ routine recomputes it for convenience. + +`avg_B`, `B_max`, `B_min`, `f_trap`, `R_major`, and `eps_local` are used +by `NeoclassicalResistivity.eta_neoclassical` to form the Sauter/Redl +F_33 correction to Spitzer resistivity. See Sauter, Angioni & Lin-Liu +1999, Phys. Plasmas 6, 2834 and Lin-Liu & Miller 1995, Phys. Plasmas 2, +1666. +""" +struct ResistGeometry + E::Float64 + F::Float64 + G::Float64 + H::Float64 + K::Float64 + M::Float64 + avg_bsq_over_dpsisq::Float64 + avg_bsq::Float64 + avg_B::Float64 + B_max::Float64 + B_min::Float64 + f_trap::Float64 + R_major::Float64 + eps_local::Float64 + p_local::Float64 + p1_local::Float64 + v1_local::Float64 +end + +""" + resist_geometry(equil, psifac, q1; gamma=5/3) -> ResistGeometry + +Port of Fortran RDCON `resist_eval` restricted to the +pure-equilibrium geometric coefficients. Integrates the 6 theta integrands +at the given flux surface and combines them into E, F, G, H, K, M via the +standard GGJ formulas. + +# Arguments + + - `equil::PlasmaEquilibrium` — the fully-solved equilibrium + - `psifac` — normalized flux coordinate of the singular surface + - `q1` — dq/dψ at this surface (from `SingType.q1`) + +# Keyword arguments + + - `gamma` — adiabatic index (default 5/3) + +!!! note "Contract" + `psifac` must be a genuine interior rational surface (`0 < ψ < 1`) with + nonzero `q1`, `p1 = dp/dψ`, and `p`. The GGJ combination divides by these + and by `|∇ψ|²` (which → 0 at the axis), so calling on the magnetic axis, + a flat-pressure surface, or a zero-shear surface yields `Inf`/`NaN`. This + matches the Fortran `resist_eval`, which is only ever invoked on interior + rationals. +""" +function resist_geometry(equil::Equilibrium.PlasmaEquilibrium, + psifac::Real, q1::Real; gamma::Real=5/3) + profiles = equil.profiles + twopi = 2π + chi1 = twopi * equil.psio + psi_f = Float64(psifac) + + # Surface-profile quantities (evaluate via the existing splines) + twopif = profiles.F_spline(psi_f) + p = profiles.P_spline(psi_f) + p1 = profiles.P_deriv(psi_f) + v1 = profiles.dVdpsi_spline(psi_f) + v2 = profiles.dVdpsi_deriv(psi_f) + q = profiles.q_spline(psi_f) + + # Build the 6 GGJ θ-integrands plus a 7th (B) for the neoclassical + # resistivity f_t calculation, and accumulate running extrema of + # (B, R) for Lin-Liu-Miller f_t and the local ε. + ntheta = length(equil.rzphi_ys) + ff = zeros(Float64, ntheta, 7) + B_max = -Inf + B_min = Inf + R_max = -Inf + R_min = Inf + for itheta in 1:ntheta + theta = equil.rzphi_ys[itheta] + f1 = equil.rzphi_rsquared((psi_f, theta)) + f2 = equil.rzphi_offset((psi_f, theta)) + jac = equil.rzphi_jac((psi_f, theta)) + fy1 = FastInterpolations.deriv_view(equil.rzphi_rsquared, (0, 1))((psi_f, theta)) + fy2 = FastInterpolations.deriv_view(equil.rzphi_offset, (0, 1))((psi_f, theta)) + fy3 = FastInterpolations.deriv_view(equil.rzphi_nu, (0, 1))((psi_f, theta)) + + rfac = sqrt(f1) + eta = twopi * (theta + f2) + r = equil.ro + rfac * cos(eta) + + v21 = fy1 / (2 * rfac * jac) + v22 = (1 + fy2) * twopi * rfac / jac + v23 = fy3 * r / jac + v33 = twopi * r / jac + bsq = chi1^2 * (v21^2 + v22^2 + (v23 + q*v33)^2) + dpsisq = (twopi * r)^2 * (v21^2 + v22^2) + + B_here = sqrt(bsq) + B_max = max(B_max, B_here) + B_min = min(B_min, B_here) + R_max = max(R_max, r) + R_min = min(R_min, r) + + ff[itheta, 1] = bsq / dpsisq + ff[itheta, 2] = 1.0 / dpsisq + ff[itheta, 3] = 1.0 / bsq + ff[itheta, 4] = 1.0 / (bsq * dpsisq) + ff[itheta, 5] = bsq + ff[itheta, 6] = dpsisq / bsq + ff[itheta, 7] = B_here + @views ff[itheta, :] .*= jac / v1 + end + + # Integrate each column around θ using the same periodic cubic-spline + # integrator Mercier.jl uses + itp = cubic_interp(equil.rzphi_ys, Series(ff); bc=PeriodicBC()) + avg = FastInterpolations.integrate(itp) + avg_B = avg[7] + R_major = 0.5 * (R_max + R_min) + eps_local = R_major > 0 ? 0.5 * (R_max - R_min) / R_major : 0.0 + f_trap = Utilities.NeoclassicalResistivity.trapped_fraction(avg_B, avg[5], B_min, B_max) + + # GGJ coefficients (Fortran RDCON resist_eval) + E_coef = p1 * v1 / (q1 * chi1^2)^2 * avg[1] * + (twopif * q1 * chi1 / avg[5] - v2) + F_coef = (p1 * v1 / (q1 * chi1^2))^2 * + (avg[1] * avg[3] + (twopif / chi1)^2 * + (avg[1] * avg[4] - avg[2]^2)) + H_coef = twopif * p1 * v1 / (q1 * chi1^3) * (avg[2] - avg[1] / avg[5]) + M_coef = avg[1] * + (avg[6] + (twopif / chi1)^2 * (avg[3] - 1.0 / avg[5])) + G_coef = avg[5] / (M_coef * gamma * p) + K_coef = (q1 * chi1^2 / (p1 * v1))^2 * + avg[5] / (M_coef * avg[1]) + + return ResistGeometry( + E_coef, F_coef, G_coef, H_coef, K_coef, M_coef, + avg[1], avg[5], + avg_B, B_max, B_min, f_trap, R_major, eps_local, + p, p1, v1, + ) +end + +""" + resist_eval_all!(intr::ForceFreeStatesInternal, equil; gamma=5/3) + +Populate `sing.restype` for every `SingType` in `intr.sing` using +`resist_geometry`. No-op for surfaces whose `restype` has already been +filled. +""" +function resist_eval_all!(intr::ForceFreeStatesInternal, + equil::Equilibrium.PlasmaEquilibrium; + gamma::Real=5/3) + for sing in intr.sing + sing.restype === nothing || continue + sing.restype = resist_geometry(equil, sing.psifac, sing.q1; gamma=gamma) + end + return intr +end diff --git a/src/ForceFreeStates/Riccati.jl b/src/ForceFreeStates/Riccati.jl index 715c6421..1be78f7f 100644 --- a/src/ForceFreeStates/Riccati.jl +++ b/src/ForceFreeStates/Riccati.jl @@ -334,8 +334,15 @@ function compute_delta_prime_matrix!( @info "Δ' BVP: nMat=$nMat, rank(M)=$(rank(M)), cond(M)=$(@sprintf("%.2e", cond(M)))" end - intr.delta_prime_matrix = _solve_bvp_and_combine_pest3( + deltap, dp_raw_persisted = _solve_bvp_and_combine_pest3( M, msing, N, nMat, use_S_axis, ipert_all, col_edge, ctrl, debug) + + # Persist both the PEST3 tearing projection (msing × msing) and the raw 2msing × 2msing + # D' matrix (side-major ordering, byte-compatible with Fortran rdcon/gal.f::gal_write_delta). + # The raw matrix is consumed by `pest3_decompose` to recover (A', B', Γ', Δ') for the full + # det(D' − D(γ)) = 0 eigenvalue problem; see ForceFreeStatesStructs.jl docstring. + intr.delta_prime_matrix = deltap + intr.delta_prime_raw = dp_raw_persisted end # Column index helpers for the BVP matrix. j is the 1-based singular-surface index, @@ -727,7 +734,9 @@ function _solve_bvp_and_combine_pest3(M::Matrix{ComplexF64}, msing::Int, N::Int, deltap = ComplexF64.(deltap_ext) debug && _log_bvp_pest3(dp_raw, deltap, s2, msing, Tc) - return deltap + # Return the PEST3-combined matrix AND the raw 2msing×2msing D' matrix (ComplexF64 + # for compatibility with downstream pest3_decompose / HDF5 writer). + return deltap, ComplexF64.(dp_raw) end # Logging helpers for `compute_delta_prime_matrix!`. Called only when debug=true. @@ -836,6 +845,69 @@ function _log_bvp_pest3(dp_raw, deltap, s2, msing, Tc) @info "Δ' BVP: deltap diagonal = $([@sprintf("%.4f%+.4fi", real(deltap[i,i]), imag(deltap[i,i])) for i in 1:msing])" end +""" + pest3_decompose(dp_raw::AbstractMatrix) -> (A', B', Γ', Δ') + +Rotate the raw 2m×2m outer-region matching matrix `dp_raw` (side-major +ordering `[L_s1, R_s1, L_s2, R_s2, …]`) into the Pletzer–Dewar 1991 parity +blocks. Given rows and columns paired by surface (odd index = left, even +index = right), the Fortran RDCON parity combination is + +``` +A'(i,j) = RR + RL + LR + LL (even-i, even-j) — interchange↔interchange +B'(i,j) = RR − RL + LR − LL (even-i, odd-j) — interchange↔tearing +Γ'(i,j) = RR + RL − LR − LL (odd-i, even-j) — tearing↔interchange +Δ'(i,j) = RR − RL − LR + LL (odd-i, odd-j) — tearing↔tearing +``` + +where `RR = dp_raw[2i, 2j]`, `RL = dp_raw[2i, 2j−1]`, +`LR = dp_raw[2i−1, 2j]`, `LL = dp_raw[2i−1, 2j−1]`. Each block is m×m. + +Matches Fortran exactly — no ½ prefactor (Pletzer–Dewar multiply by ½, but +the Fortran RDCON code leaves it commented out and our Julia port follows +Fortran to keep the benchmark bit-identical; the prefactor cancels in +`det(D' − D(γ)) = 0`). + +The Δ' block returned here equals `intr.delta_prime_matrix` (the m×m PEST3 +tearing projection computed inside `compute_delta_prime_matrix!`). + +# Arguments + + - `dp_raw` — 2m×2m complex matrix (typically `intr.delta_prime_raw`). + +# Returns + +Named tuple `(A=A', B=B', Γ=Gp, Δ=Dp)` of four m×m complex matrices. In the +full `det(D' − D(γ)) = 0` eigenvalue problem, these fill the 2m×2m outer +matrix as `D' = [[A' B'] [Γ' Δ']]` with the interchange channel (Glasser +stabilization) in the upper-left block and the tearing channel in the +lower-right. +""" +function pest3_decompose(dp_raw::AbstractMatrix) + s2 = size(dp_raw, 1) + size(dp_raw, 2) == s2 || + throw(ArgumentError("pest3_decompose: dp_raw must be square, got $(size(dp_raw))")) + iseven(s2) || + throw(ArgumentError("pest3_decompose: dp_raw side must be 2m for integer m, got $s2")) + m = s2 ÷ 2 + Tc = eltype(dp_raw) + Ap = zeros(Tc, m, m) + Bp = zeros(Tc, m, m) + Gp = zeros(Tc, m, m) + Dp = zeros(Tc, m, m) + for i in 1:m, j in 1:m + LL = dp_raw[2i-1, 2j-1] + LR = dp_raw[2i-1, 2j] + RL = dp_raw[2i, 2j-1] + RR = dp_raw[2i, 2j] + Ap[i, j] = RR + RL + LR + LL + Bp[i, j] = RR - RL + LR - LL + Gp[i, j] = RR + RL - LR - LL + Dp[i, j] = RR - RL - LR + LL + end + return (A=Ap, B=Bp, Γ=Gp, Δ=Dp) +end + """ riccati_der!(du, u, params, psieval) @@ -1641,7 +1713,7 @@ function _log_parallel_start(ctrl::ForceFreeStatesControl, odet::OdeState, end # Integrate each chunk's FM propagator from identity IC. Serial when bvp_threads == 1 -# (bit-deterministic; ~20% slower than 2-thread on DIII-D 147131 but immune to thread- +# (bit-deterministic; ~20% slower than 2-thread but immune to thread- # schedule sensitivity). Parallel uses :static scheduler so Threads.threadid() returns a # stable index into odet_proxies. If a parallel run ever diverges on a delicate equilibrium, # drop to parallel_threads = 1 rather than use_parallel = false — the latter is silently wrong. diff --git a/src/GeneralizedPerturbedEquilibrium.jl b/src/GeneralizedPerturbedEquilibrium.jl index 2e10a1bd..f6b1c7b4 100755 --- a/src/GeneralizedPerturbedEquilibrium.jl +++ b/src/GeneralizedPerturbedEquilibrium.jl @@ -24,6 +24,8 @@ include("Vacuum/Vacuum.jl") import .Vacuum as Vacuum export Vacuum +# InnerLayer holds the pure inner-region solvers and must load before +# ForceFreeStates, which calls them for the matched-Δ′ Galerkin solve. include("InnerLayer/InnerLayer.jl") import .InnerLayer as InnerLayer export InnerLayer @@ -32,6 +34,15 @@ include("ForceFreeStates/ForceFreeStates.jl") import .ForceFreeStates as ForceFreeStates export ForceFreeStates +include("Tearing/Tearing.jl") +import .Tearing as Tearing +export Tearing +# Backward-compat top-level aliases so callers can still reach these +# directly; the canonical nested path is `Tearing.{Dispersion,Runner}`. +import .Tearing.Dispersion as Dispersion +import .Tearing.Runner as Runner +export Dispersion, Runner + include("ForcingTerms/ForcingTerms.jl") import .ForcingTerms as ForcingTerms export ForcingTerms @@ -52,7 +63,7 @@ include("Rerun.jl") # Import ForceFreeStates types and functions needed for main using .ForceFreeStates: ForceFreeStatesInternal, ForceFreeStatesControl, DebugSettings, VacuumData, OdeState, FourFitVars -using .ForceFreeStates: sing_lim!, sing_min!, sing_find! +using .ForceFreeStates: sing_lim!, sing_min!, sing_find!, resist_eval_all!, resist_geometry, ResistGeometry using .ForceFreeStates: compute_ballooning_stability!, ballooning_alpha_boundary, ballooning_alpha_boundaries using .ForceFreeStates: make_metric, make_matrix, make_kinetic_matrix using .ForceFreeStates: find_kinetic_singular_surfaces! @@ -366,6 +377,14 @@ function main_from_inputs( sing_min!(intr, ctrl, equil) end + # Populate Glasser-Greene-Johnson geometric coefficients (E, F, G, H, + # K, M) for each surviving singular surface. Needed by the Julia GGJ + # inner-layer analysis; kinetic timescales (τ_A, τ_R) are layered on + # top by `build_ggj_inputs` using the same kinetic profiles as SLAYER. + if intr.msing > 0 + ForceFreeStates.resist_eval_all!(intr, equil) + end + # Determine poloidal mode numbers if ctrl.delta_mlow < 0 || ctrl.delta_mhigh < 0 error("Negative delta_mlow or delta_mhigh not allowed") @@ -492,10 +511,47 @@ function main_from_inputs( @info "Force-Free States completed in $(@sprintf("%.3f", time() - ffs_start)) s" - # Early exit if user only requested force-free states + # 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 slayer/ 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 + + # 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 + return (ctrl=ctrl, equil=equil, intr=intr, ffit=ffit, odet=odet, + vac_data=ctrl.vac_flag ? vac_data : nothing, + slayer=slayer_result) end # ---------------------------------------------------------------- @@ -604,6 +660,18 @@ function main_from_inputs( @info "KineticForces completed in $(@sprintf("%.3f", time() - kf_start)) s" end + # ---------------------------------------------------------------- + # 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). + # ---------------------------------------------------------------- + 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(pe_file) + # ---------------------------------------------------------------- # Done # ---------------------------------------------------------------- @@ -611,7 +679,9 @@ function main_from_inputs( # TODO: Do not allow perturbed equilibrium calculations if zero crossings are found - return (ctrl=ctrl, equil=equil, intr=intr, ffit=ffit, odet=odet, vac_data=ctrl.vac_flag ? vac_data : nothing) + return (ctrl=ctrl, equil=equil, intr=intr, ffit=ffit, odet=odet, + vac_data=ctrl.vac_flag ? vac_data : nothing, + slayer=slayer_result) end @@ -775,6 +845,26 @@ function write_outputs_to_HDF5( end out_h5["singular/m"] = m_matrix out_h5["singular/n"] = n_matrix + + # Glasser-Greene-Johnson geometric coefficients + surface averages + # (populated by ForceFreeStates.resist_eval_all! after sing_find!). + # Both kinetic-free (E, F, G, H, K, M) and geometry-only + # (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["singular/E"] = [s.restype.E for s in intr.sing] + out_h5["singular/F"] = [s.restype.F for s in intr.sing] + out_h5["singular/G"] = [s.restype.G for s in intr.sing] + out_h5["singular/H"] = [s.restype.H for s in intr.sing] + out_h5["singular/K"] = [s.restype.K for s in intr.sing] + out_h5["singular/M"] = [s.restype.M for s in intr.sing] + out_h5["singular/avg_bsq_over_dpsisq"] = [s.restype.avg_bsq_over_dpsisq for s in intr.sing] + out_h5["singular/avg_bsq"] = [s.restype.avg_bsq for s in intr.sing] + out_h5["singular/p_local"] = [s.restype.p_local for s in intr.sing] + out_h5["singular/p1_local"] = [s.restype.p1_local for s in intr.sing] + out_h5["singular/v1_local"] = [s.restype.v1_local for s in intr.sing] + end end # Per-surface ca-based Δ' (`sing.delta_prime`) is a stub; only the BVP matrix is emitted (see SingType.delta_prime docstring). @@ -785,6 +875,15 @@ function write_outputs_to_HDF5( out_h5["singular/delta_prime_matrix"] = intr.delta_prime_matrix 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["singular/delta_prime_raw"] = intr.delta_prime_raw + 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["singular/kinetic/kmsing"] = intr.kmsing diff --git a/src/InnerLayer/GGJ/GGJ.jl b/src/InnerLayer/GGJ/GGJ.jl index e7f0488a..222e1170 100644 --- a/src/InnerLayer/GGJ/GGJ.jl +++ b/src/InnerLayer/GGJ/GGJ.jl @@ -32,7 +32,7 @@ module GGJ using LinearAlgebra using StaticArrays -import ..InnerLayerModel, ..solve_inner +import ..InnerLayerModel, ..InnerLayerResponse, ..solve_inner, ..InnerLayerParameters """ GGJModel{S} <: InnerLayerModel diff --git a/src/InnerLayer/GGJ/GGJParameters.jl b/src/InnerLayer/GGJ/GGJParameters.jl index 732ab781..8209e28b 100644 --- a/src/InnerLayer/GGJ/GGJParameters.jl +++ b/src/InnerLayer/GGJ/GGJParameters.jl @@ -2,8 +2,7 @@ # # Physical parameters for the Glasser–Greene–Johnson inner-layer model and # the derived scale factors that map between physical and inner-layer -# (Wasow-normalized) variables. Mirrors the Fortran `resist_type` defined -# in rmatch/deltar_mod and rmatch/deltac_mod. +# (Wasow-normalized) variables. """ GGJParameters @@ -14,7 +13,7 @@ needed to scale the matching data back to physical Δ. The equilibrium coefficients `E, F, G, H, K, M` are the flux-surface averages defined in GWP2016 Eq. (A8); they enter the inner-region equations (Eq. 11). -Fields are the same as the Fortran `resist_type`: +Fields: | field | meaning | |:------- |:-------------------------------------------------------------- | @@ -32,7 +31,7 @@ Fields are the same as the Fortran `resist_type`: The complex growth rate `γ` is **not** stored here; it is passed as a separate argument to `solve_inner`. """ -Base.@kwdef struct GGJParameters +Base.@kwdef struct GGJParameters <: InnerLayerParameters E::Float64 F::Float64 G::Float64 diff --git a/src/InnerLayer/GGJ/Galerkin.jl b/src/InnerLayer/GGJ/Galerkin.jl index 76b7578f..0013de3d 100644 --- a/src/InnerLayer/GGJ/Galerkin.jl +++ b/src/InnerLayer/GGJ/Galerkin.jl @@ -1,12 +1,12 @@ # Galerkin.jl # # Hermite-cubic finite element (Galerkin) solver for the GGJ inner-layer -# model. Direct port of rmatch/deltac.f in the "resonant + noexp + inps" -# configuration. The half-domain problem (x ∈ [0, xmax]) is solved with -# parity boundary conditions at x = 0 and asymptotic matching at x = xmax. +# model, in the "resonant + noexp + inps" configuration. The half-domain +# problem (x ∈ [0, xmax]) is solved with parity boundary conditions at +# x = 0 and asymptotic matching at x = xmax. # # The solved field is the 3-component inner-layer solution u = (Ψ, Ξ, Υ) -# (Fortran psi/si/ups), obeying the GGJ inner-region equations +# obeying the GGJ inner-region equations # GWP2016 Eq. (11) ≡ GW2020 Eq. (1): # # Ψ_xx − H Υ_x − Q(Ψ − x Ξ) = 0, @@ -21,7 +21,7 @@ # the half-domain weak form (Eq. 32) on a packed Hermite-cubic grid (Eq. 33), # with resonant/extension cells (Fig. 1) supplying the matching data (Eqs. 34–35). # -# Configuration assumed (matches deltac.f defaults when called with inps): +# Configuration assumed: # gal_method = "resonant" method = true noexp = true # basis_type = 0 (Hermite) fulldomain = 0 inps_type = "inps" # mpert = 3 (Ψ, Ξ, Υ) np = 3 (Hermite cubic → 4 DOFs/node) @@ -34,13 +34,13 @@ using FastGaussQuadrature: gausslobatto using QuadGK: quadgk # ----------------------------------------------------------------------- -# Physical-variable helpers (replacements for inpso_get_ua/dua/uv). +# Physical-variable helpers. # ----------------------------------------------------------------------- """ Convert the inps 6×2 output U_inps (the Wasow asymptotic basis U = TPQSY of GW2020 Eq. 53) at coordinate `x` to the physical (Ψ, Ξ, Υ) and (Ψ', Ξ', Υ') -representation used by deltac/inpso. The 6-component first-order state packs +representation. The 6-component first-order state packs (Ψ, Ξ, Υ, Ψ', Ξ', Υ') with the GW2020 Eq. (2) scaling 𝚿 ≡ (xΨ, Ξ, Υ), hence the /x and ·x factors below. Returns `(ua, dua)` each 3×2 complex, where columns are the two power-like (Mercier) solutions and rows are the components (Ψ, Ξ, Υ). @@ -62,8 +62,8 @@ end """ Build the (I, U, V) coefficient matrices of the second-order system -`I·u'' − V·u' − U·u = 0` for u = (Ψ, Ξ, Υ) at coordinate `x`. Port of -inpso_get_uv. All matrices are 3×3 complex. +`I·u'' − V·u' − U·u = 0` for u = (Ψ, Ξ, Υ) at coordinate `x`. +All matrices are 3×3 complex. These are the matrices A, B, C of GWP2016 Eq. (12), `A Ψ'' + B Ψ' + C Ψ = 0`, with A given in Eq. (14), B in Eq. (14), and C in Eq. (15). The code's weak-form @@ -93,7 +93,7 @@ function _physical_uv(params::GGJParameters, Q::ComplexF64, x::Real) Imat = @SMatrix ComplexF64[1 0 0; 0 q2 0; 0 0 q] # U = −C of GWP2016 Eq. (15): coefficients of −u in each equation - # (inpso_get_uv stores them pre-scaled: row 2 ×Q², row 3 ×Q, matching + # (stored pre-scaled: row 2 ×Q², row 3 ×Q, matching # the I-row normalization). # row 1 (Ψ): (Q, −Q x, 0) # row 2 (Ξ): (−Q x, Q x², −(E+F)) @@ -118,16 +118,16 @@ function _physical_uv(params::GGJParameters, Q::ComplexF64, x::Real) end # ----------------------------------------------------------------------- -# Hermite cubic basis functions — port of deltac_hermite. +# Hermite cubic basis functions. # Returns (pb[1:4], qb[1:4]) where pb = values, qb = derivatives, -# indexed 1:4 → Fortran 0:3. +# indexed 1:4 → physical 0:3. # ----------------------------------------------------------------------- function _hermite(x::Real, x0::Real, x1::Real) dx = x1 - x0 t0 = (x - x0) / dx t1 = 1 - t0 - t02 = t0 * t0; + t02 = t0 * t0 t12 = t1 * t1 pb = SVector{4,Float64}( t12 * (1 + 2t0), @@ -145,7 +145,7 @@ function _hermite(x::Real, x0::Real, x1::Real) end # ----------------------------------------------------------------------- -# Grid packing — port of deltac_pack. The pfac > 1 branch is the GWP2016 +# Grid packing. The pfac > 1 branch is the GWP2016 # inner-region packing X(ξ,λ) = ln[(1+λξ)/(1−λξ)] / ln[(1+λ)/(1−λ)] # (Eq. 33), which concentrates nodes near X = 0; pfac = 1 gives a uniform # grid, and pfac in (0,1) the complementary edge-packed map. The grid-density @@ -191,7 +191,7 @@ function _pack(nx::Int, pfac::Float64, side::String) end # ----------------------------------------------------------------------- -# Three-level xmax sweep — replaces inpso_xmax for inps mode. The cutoff +# Three-level xmax sweep. The cutoff # x_max is the position where the asymptotic-basis residual Δ (GW2020 Eq. 54, # computed by `asymptotic_residual`) drops below a target tolerance — the # choice studied in GW2020 Sec. III and Fig. 3, where x_max is taken where the @@ -226,7 +226,7 @@ function _xmax_3level(params::GGJParameters, Q::ComplexF64; if !any(set) break end - x_prev = x; + x_prev = x delta_prev = dmax x *= dxfac end @@ -282,9 +282,17 @@ struct GalerkinWorkspace ndim::Int nx::Int kl::Int - mat::Array{ComplexF64,3} # (ldab, ndim, 2) banded storage - rhs::Matrix{ComplexF64} # (ndim, 2) - sol::Matrix{ComplexF64} # (ndim, 2) + mat::Array{ComplexF64,3} # (ldab, ndim, 2) banded storage + rhs::Matrix{ComplexF64} # (ndim, 2) + sol::Matrix{ComplexF64} # (ndim, 2) + # Reusable scratch buffers, zeroed per-cell via `fill!`. Eliminates the + # per-cell `zeros(...)` that otherwise allocates thousands of MiB over a + # full dispersion scan. + cell_mat_buf::Array{ComplexF64,4} # (mpert=3, mpert, np+1=4, np+1=4) + cell_mat_ext_buf::Array{ComplexF64,4} # (3, 3, 4, 4) max over CT_EXT/EXT1/EXT2 + cell_rhs_ext_buf::Matrix{ComplexF64} # (3, 4) + ab_buf::Matrix{ComplexF64} # (ldab, ndim) scratch for banded LU + rhs_buf::Vector{ComplexF64} # (ndim,) scratch for banded solve end function _build_grid_and_workspace(nx::Int, xmax::Float64, dx1::Float64, dx2::Float64, @@ -310,9 +318,9 @@ function _build_grid_and_workspace(nx::Int, xmax::Float64, dx1::Float64, dx2::Fl x_nodes[nx-1] = xmax - (dx1 + dx2) ixmax = nx - 2 # packed region is 0..ixmax - x0 = x_nodes[1]; + x0 = x_nodes[1] x1_packed = x_nodes[ixmax+1] - xm = (x1_packed + x0) / 2; + xm = (x1_packed + x0) / 2 dxp = (x1_packed - x0) / 2 mx = ixmax ÷ 2 packed = xm .+ dxp .* _pack(mx, pfac, side) @@ -327,7 +335,7 @@ function _build_grid_and_workspace(nx::Int, xmax::Float64, dx1::Float64, dx2::Fl imap = 1 for ix in 1:nx et = etypes[ix] - xl = x_nodes[ix]; + xl = x_nodes[ix] xr = x_nodes[ix+1] cell_np = if et == CT_NONE || et == CT_EXT1 || et == CT_EXT2 @@ -340,7 +348,7 @@ function _build_grid_and_workspace(nx::Int, xmax::Float64, dx1::Float64, dx2::Fl x_lsode = (et == CT_RES) ? xr : 0.0 - # Build map (matching deltac_make_map_hermite logic) + # Build local-to-global Hermite DOF map. if et == CT_RES # Will be set after the loop map_local = zeros(Int, mpert, 1) # map(:, 0:0) @@ -365,7 +373,7 @@ function _build_grid_and_workspace(nx::Int, xmax::Float64, dx1::Float64, dx2::Fl if et == CT_EXT # Extra asymptotic DOFs: emap = (imap, imap+1, imap+2). # With noexp, ndim = imap so only emap[1] is active; emap[2,3] > ndim - # and are skipped during assembly. This matches Fortran convention. + # and are skipped during assembly. emap_local = [imap, imap + 1, imap + 2] map_extra = [imap; imap + 1; imap + 2] # 3-element column map_local = hcat(map_local, map_extra) @@ -384,19 +392,29 @@ function _build_grid_and_workspace(nx::Int, xmax::Float64, dx1::Float64, dx2::Fl ndim = imap # noexp → ndim = imap (only emap[1] ≤ ndim) # Bandwidth - kl = mpert * (np + 1) + 1 - 1 # resonant method with noexp; +1-1 kept for agreement with Fortran indexing + kl = mpert * (np + 1) + 1 - 1 # resonant method with noexp; +1-1 kept for indexing agreement ku = kl ldab = 2kl + ku + 1 mat = zeros(ComplexF64, ldab, ndim, 2) rhs = zeros(ComplexF64, ndim, 2) sol = zeros(ComplexF64, ndim, 2) - - return GalerkinWorkspace(cells, ndim, nx, kl, mat, rhs, sol) + # Preallocate per-cell scratch buffers sized to the max case (np+1=4). + # Smaller cells (e.g. CT_EXT with cell.np=1) use a (2×2) sub-slice and + # rely on fill!(buf, 0) to keep the remainder zero. + cell_mat_buf = zeros(ComplexF64, mpert, mpert, np + 1, np + 1) + cell_mat_ext_buf = zeros(ComplexF64, mpert, mpert, np + 1, np + 1) + cell_rhs_ext_buf = zeros(ComplexF64, mpert, np + 1) + ab_buf = zeros(ComplexF64, ldab, ndim) + rhs_buf = zeros(ComplexF64, ndim) + + return GalerkinWorkspace(cells, ndim, nx, kl, mat, rhs, sol, + cell_mat_buf, cell_mat_ext_buf, cell_rhs_ext_buf, + ab_buf, rhs_buf) end # ----------------------------------------------------------------------- -# Local assembly: Gauss quadrature for "none" cells. Port of deltac_gauss_quad. +# Local assembly: Gauss quadrature for "none" cells. # Evaluates the GWP2016 Eq. (32) bilinear form on one cell. After integrating # the A-term by parts, the symmetric weak form is # Σⱼ [−(αᵢ', A αⱼ') + (αᵢ, B αⱼ') + (αᵢ, C αⱼ)] uⱼ , @@ -435,7 +453,7 @@ function _gauss_quad!(cell_mat::Array{ComplexF64,4}, cell::GalerkinCell, end # ----------------------------------------------------------------------- -# Extension assembly — port of deltac_extension. Handles ext, ext1, ext2 cell +# Extension assembly. Handles ext, ext1, ext2 cell # types: the GWP2016 extension cells (Fig. 1) where the small resonant power # series (column 2 of the asymptotic basis) drives the response and the large # resonant solution (column 1) is blended into the Hermite cubics with @@ -530,7 +548,7 @@ function _extension!(cell_mat::Array{ComplexF64,4}, cell_rhs::Matrix{ComplexF64} end # ----------------------------------------------------------------------- -# Resonant integral — replaces deltac_lsode_int with QuadGK. Evaluates the +# Resonant integral, evaluated with QuadGK. Evaluates the # GWP2016 Eq. (32) bilinear form over the resonant cell using the asymptotic # (power-series) solutions as both test and trial functions — the scalar # products that GWP2016 notes "are non-analytic and therefore require an @@ -552,7 +570,7 @@ function _resonant_integral(cell::GalerkinCell, params::GGJParameters, function integrand_11(x) ua, dua = _physical_ua_dua(cache, x) Imat, Umat, Vmat = _physical_uv(params, Q, x) - ua1 = ua[:, 1]; + ua1 = ua[:, 1] dua1 = dua[:, 1] return transpose(dua1) * Imat * dua1 + transpose(ua1) * Vmat * dua1 + transpose(ua1) * Umat * ua1 end @@ -561,8 +579,8 @@ function _resonant_integral(cell::GalerkinCell, params::GGJParameters, ua, dua = _physical_ua_dua(cache, x) Imat, Umat, Vmat = _physical_uv(params, Q, x) dua1 = dua[:, 1] - ua1 = ua[:, 1]; - ua2 = ua[:, 2]; + ua1 = ua[:, 1] + ua2 = ua[:, 2] dua2 = dua[:, 2] return transpose(dua1) * Imat * dua2 + transpose(ua1) * Vmat * dua2 + transpose(ua1) * Umat * ua2 end @@ -581,7 +599,7 @@ function _assemble_and_solve!(ws::GalerkinWorkspace, params::GGJParameters, Q::ComplexF64, cache::InnerAsymptoticsCache; nq::Int=4, tol_res::Float64=1e-5) - mpert = 3; + mpert = 3 np = 3 quad_nodes, quad_weights = gausslobatto(nq + 1) offset = ws.kl + ws.kl + 1 # kl + ku + 1 since ku = kl @@ -589,28 +607,30 @@ function _assemble_and_solve!(ws::GalerkinWorkspace, fill!(ws.mat, 0) fill!(ws.rhs, 0) - # Per-cell assembly + # Per-cell assembly — reuse the preallocated scratch buffers, zeroing + # only the sub-slice actually used by this cell's np_eff. + cell_mat = ws.cell_mat_buf + cell_mat_ext = ws.cell_mat_ext_buf + cell_rhs_ext = ws.cell_rhs_ext_buf for ix in 1:ws.nx cell = ws.cells[ix] # Gauss quadrature for Hermite contribution (all cell types) if cell.np >= 0 np_eff = cell.np - cell_mat = zeros(ComplexF64, mpert, mpert, np_eff + 1, np_eff + 1) + fill!(cell_mat, 0) _gauss_quad!(cell_mat, cell, quad_nodes, quad_weights, params, Q) # Assemble into global banded matrix (both parities use same base matrix) for ip in 0:np_eff, ipert in 1:mpert i = cell.map[ipert, ip+1] if i > ws.ndim - ; - continue; + continue end for jp in 0:np_eff, jpert in 1:mpert j = cell.map[jpert, jp+1] if j > ws.ndim - ; - continue; + continue end ws.mat[offset+i-j, j, 1] += cell_mat[ipert, jpert, ip+1, jp+1] end @@ -619,21 +639,18 @@ function _assemble_and_solve!(ws::GalerkinWorkspace, # Extension terms if cell.etype in (CT_EXT, CT_EXT1, CT_EXT2) + # np_eff matches the semantic size: CT_EXT has cell.np=1 → ext slot + # at index cell.np+1=2 (using 0-based; +1 in Julia), so the array + # used by the current code is (3,3,cell.np+2,cell.np+2)=(3,3,3,3). + # For CT_EXT1/EXT2 it's (3,3,cell.np+1,cell.np+1)=(3,3,4,4). + # Either way npp = cell.etype == CT_EXT ? cell.np + 1 : cell.np. np_eff = cell.etype == CT_EXT ? cell.np + 1 : cell.np - cell_mat_ext = zeros(ComplexF64, mpert, mpert, np_eff + 1, np_eff + 1) - cell_rhs_ext = zeros(ComplexF64, mpert, np_eff + 1) - # For ext, we need to create a temporary cell_mat that includes the extra DOF - if cell.etype == CT_EXT - cell_mat_ext = zeros(ComplexF64, mpert, mpert, cell.np + 2, cell.np + 2) - cell_rhs_ext = zeros(ComplexF64, mpert, cell.np + 2) - else - cell_mat_ext = zeros(ComplexF64, mpert, mpert, cell.np + 1, cell.np + 1) - cell_rhs_ext = zeros(ComplexF64, mpert, cell.np + 1) - end + fill!(cell_mat_ext, 0) + fill!(cell_rhs_ext, 0) _extension!(cell_mat_ext, cell_rhs_ext, cell, quad_nodes, quad_weights, params, Q, cache) # Assemble ext contributions - npp = size(cell_mat_ext, 3) - 1 + npp = np_eff for ip in 0:npp, ipert in 1:mpert i = ip < size(cell.map, 2) ? cell.map[ipert, ip+1] : cell.emap[1] # For the extra DOF, only ipert=1 is meaningful (noexp) @@ -641,8 +658,7 @@ function _assemble_and_solve!(ws::GalerkinWorkspace, continue end if i > ws.ndim - ; - continue; + continue end for jp in 0:npp, jpert in 1:mpert j = jp < size(cell.map, 2) ? cell.map[jpert, jp+1] : cell.emap[1] @@ -650,8 +666,7 @@ function _assemble_and_solve!(ws::GalerkinWorkspace, continue end if j > ws.ndim - ; - continue; + continue end ws.mat[offset+i-j, j, 1] += cell_mat_ext[ipert, jpert, ip+1, jp+1] end @@ -711,7 +726,7 @@ function _assemble_and_solve!(ws::GalerkinWorkspace, # one solution has even Ξ, Υ and odd Ψ, the other odd Ξ, Υ and even Ψ. # Imposing both at X=0 gives the two parities that each contribute a Δ for # outer-region matching (the Δ_odd, Δ_even of GWP2016 Eqs. 34–35). - # Mirrors deltac_set_boundary: for each isol, build a modified local + # For each isol, build a modified local # matrix for ip=0..1 of cell 1, then write it into the global matrix. for isol in 1:2 # Zero out ip=0 rows in the global matrix @@ -743,11 +758,11 @@ function _assemble_and_solve!(ws::GalerkinWorkspace, ws.mat[offset+i-j, j, isol] = 1 end else - i = cell1.map[1, 1]; + i = cell1.map[1, 1] j = cell1.map[1, 1] ws.mat[offset+i-j, j, isol] = 1 for ipert in 2:3 - i = cell1.map[ipert, 1]; + i = cell1.map[ipert, 1] j = cell1.map[ipert, 2] ws.mat[offset+i-j, j, isol] = 1 end @@ -765,11 +780,11 @@ function _assemble_and_solve!(ws::GalerkinWorkspace, kl = ws.kl; ku = kl for isol in 1:2 - ab = copy(ws.mat[:, :, isol]) - rhs_col = copy(ws.rhs[:, isol]) - ab, ipiv = LinearAlgebra.LAPACK.gbtrf!(kl, ku, n, ab) - LinearAlgebra.LAPACK.gbtrs!('N', kl, ku, n, ab, ipiv, rhs_col) - ws.sol[:, isol] .= rhs_col + copyto!(ws.ab_buf, @view(ws.mat[:, :, isol])) + copyto!(ws.rhs_buf, @view(ws.rhs[:, isol])) + _, ipiv = LinearAlgebra.LAPACK.gbtrf!(kl, ku, n, ws.ab_buf) + LinearAlgebra.LAPACK.gbtrs!('N', kl, ku, n, ws.ab_buf, ipiv, ws.rhs_buf) + ws.sol[:, isol] .= ws.rhs_buf end end @@ -849,15 +864,17 @@ end solve_inner(::GGJModel{:galerkin}, params::GGJParameters, γ::Number; kmax::Int=8, nx::Int=512, nq::Int=4, pfac::Float64=1.0, cutoff::Int=5, xfac::Float64=1.0, tol_res::Float64=1e-5) - -> SVector{2,ComplexF64} + -> InnerLayerResponse Solve the GGJ inner-layer matching problem using the Hermite-cubic finite -element (Galerkin) method (GWP2016 Sec. III). Direct port of rmatch/deltac.f in -the "resonant + noexp + inps" configuration. - -Returns the parity-projected matching data `(Δ₁, Δ₂)` (GWP2016 Eqs. 34–35) with -the `X₀^{2√(−D_I)}` physical rescaling applied. The ordering matches deltac.f's -output convention (swapped relative to deltar.f). +element (Galerkin) method (GWP2016 Sec. III), in the "resonant + noexp + inps" +configuration. + +Returns the parity-projected matching data (GWP2016 Eqs. 34–35) with the +`X₀^{2√(−D_I)}` physical rescaling applied, as an `InnerLayerResponse` whose +`tearing`/`interchange` fields are the isol=1/isol=2 element solutions +respectively — no parity swap (see the boundary-condition block above for the +parity derivation). """ function solve_inner(::GGJModel{:galerkin}, params::GGJParameters, γ::Number; kmax::Int=8, nx::Int=512, nq::Int=4, pfac::Float64=1.0, @@ -882,10 +899,11 @@ function solve_inner(::GGJModel{:galerkin}, params::GGJParameters, γ::Number; emap1 = res_cell.emap[1] Δ_raw = SVector{2,ComplexF64}(ws.sol[emap1, 1], ws.sol[emap1, 2]) - # Apply deltac.f's swap convention (deltac_solve) - Δ_swapped = SVector{2,ComplexF64}(Δ_raw[2], Δ_raw[1]) + # Rescaling is linear & diagonal; apply to the (tearing, interchange) + # pair directly, no parity swap. + Δ_rescaled = rescale_delta(Δ_raw, params) - return rescale_delta(Δ_swapped, params) + return InnerLayerResponse(Δ_rescaled[1], Δ_rescaled[2]) end diff --git a/src/InnerLayer/GGJ/InnerAsymptotics.jl b/src/InnerLayer/GGJ/InnerAsymptotics.jl index 7b6b838c..0f1d1b92 100644 --- a/src/InnerLayer/GGJ/InnerAsymptotics.jl +++ b/src/InnerLayer/GGJ/InnerAsymptotics.jl @@ -1,7 +1,6 @@ # InnerAsymptotics.jl # # Wasow asymptotic basis ("inps" basis) for the GGJ inner-layer system. -# Direct port of rmatch/inps.f. # # Reference: A. H. Glasser & Z. R. Wang, Phys. Plasmas 27, 012506 (2020) # ("GW2020"), Sections II.A–II.G (Eqs. 3–55). Notation map (all equation @@ -17,9 +16,6 @@ # Y_k, Z_k -> Eqs. 50–52 (Y-series Y = Y₀Z in shifted-exponent form) # U(x) -> Eq. 53 (final asymptotic solution U = TPQSY at large x) # residual -> Eq. 54 (convergence measure Δ± used to pick x_max) -# -# This file ports `inps_tjmat`, `inps_lyap_solve`, `inps_split`, `inps_coefs`, -# `inps_horner`, `inps_ua`, `inps_delta`, and `inps_xmax` from the Fortran. using LinearAlgebra using StaticArrays @@ -82,7 +78,7 @@ end # ----------------------------------------------------------------------- # Build T, Tinv, A_0..A_2, J_0..J_2. The coefficient matrices A₀,A₁,A₂ are # GW2020 Eqs. (3)–(5); the Jordan basis T, T⁻¹ are Eqs. (7)–(8); λ = Q^{-1/2} -# (Eq. 6); and J_i = T⁻¹A_iT are Eqs. (9)–(10). Mirrors inps_tjmat. +# (Eq. 6); and J_i = T⁻¹A_iT are Eqs. (9)–(10). # ----------------------------------------------------------------------- function _build_tjmat(p::GGJParameters, Q::ComplexF64) @@ -95,24 +91,24 @@ function _build_tjmat(p::GGJParameters, Q::ComplexF64) q2 = q * q λ = 1 / sqrt(q) - # T (Eq. 7) — Fortran constructs this with column-major RESHAPE; the - # listing below is row-major Julia order matching that layout. + # T (Eq. 7) — built by column-major reshape; the listing below is + # row-major Julia order matching that layout. T = @SMatrix ComplexF64[ - 1 0 h*q q2/λ h*q -q2/λ - 0 0 0 -1/λ 0 1/λ - 0 0 -1/λ 0 1/λ 0 - 0 1 -h/λ -q2 h/λ -q2 - 0 0 0 1 0 1 - 0 0 1 0 1 0 + 1 0 h*q q2/λ h*q -q2/λ + 0 0 0 -1/λ 0 1/λ + 0 0 -1/λ 0 1/λ 0 + 0 1 -h/λ -q2 h/λ -q2 + 0 0 0 1 0 1 + 0 0 1 0 1 0 ] Tinv = @SMatrix ComplexF64[ - 1 q2 0 0 0 -h*q - 0 0 -h 1 q2 0 - 0 0 -λ/2 0 0 1/2 - 0 -λ/2 0 0 1/2 0 - 0 0 λ/2 0 0 1/2 - 0 λ/2 0 0 1/2 0 + 1 q2 0 0 0 -h*q + 0 0 -h 1 q2 0 + 0 0 -λ/2 0 0 1/2 + 0 -λ/2 0 0 1/2 0 + 0 0 λ/2 0 0 1/2 + 0 λ/2 0 0 1/2 0 ] # A_0, A_1, A_2 — GW2020 Eqs. (4), (5), and the A₂ matrix of Eq. (3). Build mutable then freeze. @@ -162,7 +158,7 @@ end # ----------------------------------------------------------------------- # Closed-form Lyapunov solve for the splitting transformation — GW2020 Eq. (22), # J₀²² P_k²¹ − P_k²¹ J₀¹¹ = −K_k²¹ and J₀¹¹ P_k¹² − P_k¹² J₀²² = −K_k¹², with the -# block-diagonal part B_k = K_k from Eq. (21). Mirrors inps_lyap_solve. +# block-diagonal part B_k = K_k from Eq. (21). # # Given a 6×6 K, returns (B, P) such that: # - B is block-diagonal with B[r1,r1] = K[r1,r1] and B[r2,r2] = K[r2,r2] @@ -176,9 +172,9 @@ function _lyap_solve(K::SMatrix{6,6,ComplexF64}, λ::ComplexF64) Pm = zeros(ComplexF64, 6, 6) # B is block-diagonal in the (r1, r2) split. - Bm[1, 1] = K[1, 1]; + Bm[1, 1] = K[1, 1] Bm[1, 2] = K[1, 2] - Bm[2, 1] = K[2, 1]; + Bm[2, 1] = K[2, 1] Bm[2, 2] = K[2, 2] for i in 3:6, j in 3:6 Bm[i, j] = K[i, j] @@ -287,7 +283,7 @@ function _coefs(B::Vector{SMatrix{6,6,ComplexF64,36}}, end K2[k+1] = K2acc - # Q_k = [[0, 0], [-K2[1,1], -K2[1,2]]] (Fortran reshape gives this layout) + # Q_k = [[0, 0], [-K2[1,1], -K2[1,2]]] (column-major reshape gives this layout) Qm[k+1] = @SMatrix ComplexF64[ 0 0 -K2acc[1, 1] -K2acc[1, 2] @@ -314,7 +310,7 @@ function _coefs(B::Vector{SMatrix{6,6,ComplexF64,36}}, end # Lowest-order Y solution and inverse — GW2020 Eq. (48), with exponents r± from Eq. (49). - r1 = ComplexF64(R[1]); + r1 = ComplexF64(R[1]) r2 = ComplexF64(R[2]) Y0 = @SMatrix ComplexF64[ 1 1 @@ -386,7 +382,7 @@ build_asymptotics(params::GGJParameters, Q::Number; kmax::Int=8) = # ----------------------------------------------------------------------- # Horner evaluator with optional fractional-power prefactor. Sums the # descending power series in x^{-2k} (GW2020 Eq. 14 form) for the Y, Q, and -# P[r2,r1] coefficient blocks, with the x^{R/2} Mercier prefactor. Mirrors inps_horner. +# P[r2,r1] coefficient blocks, with the x^{R/2} Mercier prefactor. # # Computes y[i] = (Σ_{k=0..n} c[i,k] * x^k) * x^rvec[i] # and dy[i] = d/dx of the above. @@ -450,14 +446,14 @@ function _horner(x::Real, c::AbstractMatrix{ComplexF64}; end # Pack the cache's Y, Qmat, and the [r2, r1] block of P into the row-major -# Fortran-reshape order used by inps_horner. Returned matrices are +# reshape order used by `_horner`. Returned matrices are # n_components × (kmax+1). function _pack_y_coefs(cache::InnerAsymptoticsCache) kmax = cache.kmax cc = Matrix{ComplexF64}(undef, 4, kmax + 1) @inbounds for k in 0:kmax Yk = cache.Y[k+1] - # Fortran column-major reshape of (2,2) → 4 entries: + # Column-major reshape of (2,2) → 4 entries: # (Y[1,1], Y[2,1], Y[1,2], Y[2,2]) cc[1, k+1] = Yk[1, 1] cc[2, k+1] = Yk[2, 1] @@ -494,7 +490,7 @@ end # ----------------------------------------------------------------------- # Evaluator — back-substitution U = TPQSY of GW2020 Eq. (53), assembled from # the cached Y-series (Eq. 50), Q and the P[r2,r1] block, the shearing matrix -# S of Eq. (41), and the Jordan basis T of Eq. (7). Mirrors inps_ua. +# S of Eq. (41), and the Jordan basis T of Eq. (7). # ----------------------------------------------------------------------- """ @@ -508,9 +504,9 @@ asymptotic solutions of the GGJ system, and (if `derivative=true`) the 6×2 matrix `dU` of their derivatives `dU/dx`. If `apply_T=false`, the result is left in the J-rotated coordinate basis -(used by `inps_delta` for residual checks). The default `apply_T=true` +(used by `asymptotic_residual` for residual checks). The default `apply_T=true` returns the solutions in the original 6-component first-order-system -basis used by `inpso_get_uv` and the shooting / Galerkin solvers. +basis used by `_physical_uv` and the shooting / Galerkin solvers. """ function evaluate_asymptotics(cache::InnerAsymptoticsCache, x::Real; derivative::Bool=true, apply_T::Bool=true) @@ -532,7 +528,7 @@ function evaluate_asymptotics(cache::InnerAsymptoticsCache, x::Real; # Splitting matrix pp (6×2): top 2×2 = I, bottom 4×2 = p21. pp_m = zeros(ComplexF64, 6, 2) - pp_m[1, 1] = 1; + pp_m[1, 1] = 1 pp_m[2, 2] = 1 @inbounds for i in 1:4, j in 1:2 pp_m[i+2, j] = p21[i, j] @@ -564,7 +560,7 @@ function evaluate_asymptotics(cache::InnerAsymptoticsCache, x::Real; end dpp = SMatrix{6,2,ComplexF64}(dpp_m) - dsmat = @SMatrix ComplexF64[0 0; 0 (-2 * xfac / x)] + dsmat = @SMatrix ComplexF64[0 0; 0 (-2*xfac/x)] dqsy = q * smat * dy + q * dsmat * y + dq * smat * y dU = pp * dqsy + dpp * qsy @@ -584,14 +580,14 @@ end # ----------------------------------------------------------------------- # Residual `delta` and adaptive xmax — the convergence measure Δ± of GW2020 # Eq. (54), ‖u' − xJu‖/max(‖u'‖,‖xJu‖) in the J-rotated basis, used to choose -# x_max (GW2020 Sec. III, Fig. 3). Mirrors inps_delta + inps_xmax. +# x_max (GW2020 Sec. III, Fig. 3). # ----------------------------------------------------------------------- """ asymptotic_residual(cache::InnerAsymptoticsCache, x::Real) -> SVector{2,Float64} Compute the convergence measure `Δ±` of the asymptotic basis at `x` for each -of the two algebraic columns (GW2020 Eq. 54). Mirrors `inps_delta`: returns +of the two algebraic columns (GW2020 Eq. 54). Returns `‖dU − x·matrix·U‖∞ / max(‖dU‖∞, ‖x·matrix·U‖∞)` per column, where `matrix = J₀ + xfac·J₁ + xfac²·J₂` is the J-rotated coefficient matrix (the residual of `v' = xJv`, GW2020 Eq. 6). @@ -608,16 +604,16 @@ function asymptotic_residual(cache::InnerAsymptoticsCache, x::Real) M = M + xfac * xfac * cache.J[3] end - # Match the Fortran convention: matvec(:,:,1) = dU; matvec(:,:,2) = -x*M*U; - # matvec(:,:,0) = sum. delta(j) = ||matvec(:,j,0)||∞ / max(||matvec(:,j,1)||∞, ||matvec(:,j,2)||∞). + # matvec(:,:,1) = dU; matvec(:,:,2) = -x*M*U; matvec(:,:,0) = sum. + # delta(j) = ||matvec(:,j,0)||∞ / max(||matvec(:,j,1)||∞, ||matvec(:,j,2)||∞). matvec1 = dU matvec2 = -x * (M * U) matvec0 = matvec1 + matvec2 delta = MVector{2,Float64}(0.0, 0.0) @inbounds for j in 1:2 - n0 = 0.0; - n1 = 0.0; + n0 = 0.0 + n1 = 0.0 n2 = 0.0 for i in 1:6 n0 = max(n0, abs(matvec0[i, j])) @@ -640,7 +636,7 @@ Sweep `x` log-uniformly upward from `10^xlogmin` and return the smallest `x` at which `max(asymptotic_residual(cache, x)) < eps` — the cutoff `x_max` where the GW2020 Eq. (54) convergence measure drops below tolerance (GW2020 Sec. III, Fig. 3). Also returns the `InnerAsymptoticsCache` it built -so callers can reuse it. Mirrors `inps_xmax`. +so callers can reuse it. Throws an `ErrorException` if no `x` in the sweep range achieves the target tolerance. diff --git a/src/InnerLayer/GGJ/Reference.jl b/src/InnerLayer/GGJ/Reference.jl index 6127cbfd..ecdbf41d 100644 --- a/src/InnerLayer/GGJ/Reference.jl +++ b/src/InnerLayer/GGJ/Reference.jl @@ -1,8 +1,7 @@ # Reference.jl # # Hard-coded benchmark parameter sets for cross-validation of the GGJ -# inner-layer solvers. Values are taken from published papers and the -# corresponding Fortran RMATCH test cases. +# inner-layer solvers. Values are taken from published papers. """ glasser_wang_2020_eq55() -> GGJParameters @@ -15,8 +14,8 @@ only for benchmarking the galerkin solver and comparing to published results. The five coefficients below are transcribed verbatim from Eq. 55; the paper's companion operating point is the scaled growth rate `Q = 1.234e-1` (their Fig. 1). Note Eq. 55 does not tabulate an inner-region matching `Δ(Q)` — its `Δ_±` (Eq. 54) -is a convergence-error norm — so a quantitative `Δ(Q)` cross-check needs a Fortran -rmatch/INPS run, not this paper alone. +is a convergence-error norm — so a quantitative `Δ(Q)` cross-check needs an +independent inner-layer reference, not this paper alone. Timescale parameters (taua, taur, v1) are set to canonical normalization; callers should override them for physical cases. diff --git a/src/InnerLayer/GGJ/Shooting.jl b/src/InnerLayer/GGJ/Shooting.jl index 7a3162d2..50e7c403 100644 --- a/src/InnerLayer/GGJ/Shooting.jl +++ b/src/InnerLayer/GGJ/Shooting.jl @@ -1,16 +1,16 @@ # Shooting.jl # # NOTE: This solver is NOT used in production and is not to be used. It was a -# porting exercise (deltar.f) retained for reference only. The Galerkin solver +# porting exercise retained for reference only. The Galerkin solver # (Galerkin.jl) is the sole supported GGJ inner-layer solver; shooting underflows # at large |γ| and shares none of the production matching path. Do not call it. # -# Stable backward shooting solver for the GGJ inner-layer model. Direct -# Julia port of rmatch/deltar.f. Integrates the 4×4 origin-diagonalized +# Stable backward shooting solver for the GGJ inner-layer model. +# Integrates the 4×4 origin-diagonalized # resistive-layer ODE from `tmax` (large-x asymptotic regime) backward to # a small `tmin` (origin Frobenius regime), then projects onto the local # Frobenius basis at the origin and reads off the parity-projected -# matching data via `deltar_ratio`. +# matching data. # # This solves the same inner-region equations as the production Galerkin path # (GWP2016 Eq. 11 ≡ GW2020 Eq. 1) but by a different numerical method: a @@ -21,8 +21,7 @@ # columns 3 and 4 (`x^{−p1}` and `x^{−1/2}`); the two "large" (smooth) modes # are columns 1 and 2 (`x^{p1}` and `x^{1/2}`). # -# Reference: A. H. Glasser, Phys. Plasmas **23**, 072505 (2016) and the -# Fortran rmatch/deltar.f. +# Reference: A. H. Glasser, Phys. Plasmas **23**, 072505 (2016). using LinearAlgebra using SpecialFunctions: gamma @@ -31,8 +30,8 @@ using OrdinaryDiffEq """ GGJShootingSystem -Precomputed origin- and infinity-side arrays for the deltar.f-style -backward shoot. Construct via [`_build_shooting_system`](@ref). +Precomputed origin- and infinity-side arrays for the backward shoot. +Construct via [`_build_shooting_system`](@ref). """ struct GGJShootingSystem params::GGJParameters @@ -55,18 +54,18 @@ struct GGJShootingSystem end # ----------------------------------------------------------------------- -# Origin arrays — port of deltar_origin (deltar.f lines 333–482). +# Origin arrays for the origin-diagonalized Frobenius basis. # ----------------------------------------------------------------------- function _build_origin_arrays(p::GGJParameters, Q::ComplexF64; nps::Int=8, rtol::Float64=1e-6) p1v = p1(p) pplus = -0.5 + p1v - e = ComplexF64(p.E); - f = ComplexF64(p.F); + e = ComplexF64(p.E) + f = ComplexF64(p.F) h = ComplexF64(p.H) - g = ComplexF64(p.G); - k = ComplexF64(p.K); + g = ComplexF64(p.G) + k = ComplexF64(p.K) q = Q q3 = q^3 @@ -76,7 +75,7 @@ function _build_origin_arrays(p::GGJParameters, Q::ComplexF64; nps::Int=8, rtol: aplus = h - 0.5 + p1v aminus = h - 0.5 - p1v - # d0 — see deltar_origin lines 361–376. + # d0 — diagonalizing matrix at the origin. d0_m = zeros(ComplexF64, 4, 4) d0_m[1, 1] = q * a2 d0_m[1, 2] = 1 @@ -90,7 +89,7 @@ function _build_origin_arrays(p::GGJParameters, Q::ComplexF64; nps::Int=8, rtol: d0_m[4, 3] = aminus * a2 d0_m[4, 4] = 1 - # d0inv — same explicit antisymmetry pattern as deltar_origin lines 380–395. + # d0inv — explicit antisymmetry pattern of the origin diagonalizer inverse. d0inv_m = zeros(ComplexF64, 4, 4) d0inv_m[1, 1] = d0_m[3, 3] d0inv_m[2, 2] = d0_m[4, 4] @@ -123,7 +122,7 @@ function _build_origin_arrays(p::GGJParameters, Q::ComplexF64; nps::Int=8, rtol: d0inv_s = SMatrix{4,4,ComplexF64}(d0inv_m) al1_s = d0inv_s * SMatrix{4,4,ComplexF64}(al1_m) * d0_s - # ups[i, j, k] — origin power-series coefficients (deltar_origin lines 440–460). + # ups[i, j, k] — origin power-series coefficients. # ups(:,:,1) = I; higher orders from the al1 recurrence. ups = zeros(ComplexF64, 4, 4, nps) @inbounds for i in 1:4 @@ -139,7 +138,7 @@ function _build_origin_arrays(p::GGJParameters, Q::ComplexF64; nps::Int=8, rtol: end end - # tmin from the truncation-error bound on the origin series (line 470). + # tmin from the truncation-error bound on the origin series. unmax = 0.0 @inbounds for j in 1:4, i in 1:4 unmax = max(unmax, abs(ups[i, j, nps])) @@ -151,18 +150,18 @@ function _build_origin_arrays(p::GGJParameters, Q::ComplexF64; nps::Int=8, rtol: end # ----------------------------------------------------------------------- -# Infinity arrays — port of deltar_infinity (deltar.f lines 490–666). +# Infinity arrays — large-t asymptotic basis. # With nxps = 1 the higher-order vps recurrence is dead code; vps[:,:,1] = I. # ----------------------------------------------------------------------- function _build_infinity_arrays(p::GGJParameters, Q::ComplexF64, d0inv::SMatrix{4,4,ComplexF64}; rtol::Float64=1e-6, fmax::Float64=1.0) - e = ComplexF64(p.E); - f = ComplexF64(p.F); + e = ComplexF64(p.E) + f = ComplexF64(p.F) h = ComplexF64(p.H) - g = ComplexF64(p.G); - k = ComplexF64(p.K); + g = ComplexF64(p.G) + k = ComplexF64(p.K) q = Q dr = mercier_dr(p) @@ -176,7 +175,7 @@ function _build_infinity_arrays(p::GGJParameters, Q::ComplexF64, ddsq = bb * bb - cc dd = sqrt(ddsq) - # m matrix (2×2) — deltar_infinity lines 521–524. + # m matrix (2×2) — large-t asymptotic coupling. m11 = -lamdaq + dr / lamdaq m12 = h - dr / lamdaq m21 = kk1 * (h + dr / lamdaq) @@ -218,7 +217,7 @@ function _build_infinity_arrays(p::GGJParameters, Q::ComplexF64, d1 = SMatrix{4,4,ComplexF64}(d1_m) bl1 = SVector{4,ComplexF64}(-lamda, -lamda, lamda, lamda) - # tmax (deltar_infinity lines 644–650, ntmax = 1 default). + # tmax (ntmax = 1 default). p1v = sqrt(-mercier_di(p)) tmax_inner = sqrt((max(0.5, p1v)^2 - log(rtol)) / abs(lamda)) tmax_outer = 6.0 / abs(q) @@ -246,7 +245,7 @@ function _build_shooting_system(p::GGJParameters, Q::ComplexF64; end # ----------------------------------------------------------------------- -# Origin Frobenius basis evaluator — port of deltar_upsfit (lines 233–267). +# Origin Frobenius basis evaluator. # Returns the 4×4 matrix `u(t)` whose columns are the 4 fundamental # origin solutions evaluated at `t`. Solving `u * c0 = y` then projects a # 4-component integrated state onto the origin basis. @@ -274,8 +273,8 @@ function _origin_basis(sys::GGJShootingSystem, t::Real) end # ----------------------------------------------------------------------- -# Initial condition at tmax — see deltar_run lines 121–126. -# With nxps = 1 in deltar.f, vps[:,:,1] = I, so the asymptotic-basis +# Initial condition at tmax. +# With nxps = 1, vps[:,:,1] = I, so the asymptotic-basis # evaluator returns a diagonal v. The IC is then # y(tmax) = d1 * v(:, 1:2) # which extracts the first two columns of d1 scaled by the algebraic-mode @@ -296,7 +295,7 @@ function _initial_condition(sys::GGJShootingSystem) end # ----------------------------------------------------------------------- -# ODE right-hand side — port of deltar_der (lines 185–224). +# ODE right-hand side. # dy/dt = al1 * y * t + diag(al0) * y / t # ----------------------------------------------------------------------- @@ -310,7 +309,7 @@ function _ggj_der!(dy::AbstractMatrix{ComplexF64}, y::AbstractMatrix{ComplexF64} end # ----------------------------------------------------------------------- -# Parity-projected matching ratio — port of deltar_ratio (lines 674–716). +# Parity-projected matching ratio. # c0 is a 4×2 complex matrix whose rows are the four origin-mode # coefficients of the two integrated solutions (modes 1: x^{p1}, # 2: x^{1/2}, 3: x^{−p1}, 4: x^{−1/2}). @@ -341,15 +340,18 @@ end solve_inner(::GGJModel{:shooting}, params::GGJParameters, γ::Number; reltol::Float64=1e-6, abstol::Float64=1e-6, rtol_origin::Float64=1e-6, nps::Int=8, - fmax::Float64=1.0, solver=Tsit5()) -> SVector{2,ComplexF64} + fmax::Float64=1.0, solver=Tsit5()) -> InnerLayerResponse Solve the GGJ inner-layer matching problem by stable backward shooting in -the origin-diagonalized 4×4 basis. Direct port of the rmatch `deltar.f` -algorithm. +the origin-diagonalized 4×4 basis. -Returns the parity-projected matching data `(Δ₁, Δ₂)` (already rescaled -back to physical units via `rescale_delta`). Index ordering matches the -Fortran `deltar` output. +Returns an `InnerLayerResponse(tearing, interchange)` with rescaling +applied. `_delta_from_c0` returns a `(Δ₁, Δ₂)` pair where `Δ₁` is the +**interchange** (anti-symmetric / W-odd) channel and `Δ₂` is the +**tearing** (symmetric / W-even) channel. We therefore map +`Δ₂ → tearing` and `Δ₁ → interchange` into the named fields, matching the +physics channel labels used by the Galerkin solver and by the +`InnerLayerResponse` docstring. Tolerances `reltol`/`abstol` are the integrator tolerances; `rtol_origin` controls the truncation error of the origin Frobenius series and the @@ -374,7 +376,9 @@ function solve_inner(::GGJModel{:shooting}, params::GGJParameters, γ::Number; c0 = Matrix(u) \ Matrix(y_end) Δ_raw = _delta_from_c0(c0, sys) - return rescale_delta(Δ_raw, params) + Δ_rescaled = rescale_delta(Δ_raw, params) + # Δ_rescaled ≡ (Δ₁, Δ₂) = (interchange, tearing). + return InnerLayerResponse(Δ_rescaled[2], Δ_rescaled[1]) end solve_inner(::GGJModel{:shooting}, params::GGJParameters, γ::Real; kwargs...) = diff --git a/src/InnerLayer/InnerLayer.jl b/src/InnerLayer/InnerLayer.jl index 934e8f83..bee2a8a5 100644 --- a/src/InnerLayer/InnerLayer.jl +++ b/src/InnerLayer/InnerLayer.jl @@ -10,22 +10,29 @@ module InnerLayer using LinearAlgebra using StaticArrays +using ..Utilities + include("InnerLayerInterface.jl") include("GGJ/GGJ.jl") -# include("SLAYER/Slayer.jl") --- SLAYER code goes here +include("SLAYER/SLAYER.jl") import .GGJ: GGJModel, GGJParameters, build_asymptotics, evaluate_asymptotics, pick_xmax import .GGJ: InnerAsymptoticsCache, mercier_di, mercier_dr, inner_Q, rescale_delta -import .GGJ: glasser_wang_2020_eq55, solve_inner_converged # solve_inner_converged: experimental, not exported (reachable as a qualified call only) -# SLAYER imports go here +import .GGJ: glasser_wang_2020_eq55 +import .GGJ: solve_inner_converged # experimental, not exported (reachable as a qualified call only) + +import .SLAYER: SLAYERModel, SLAYERParameters, slayer_parameters, r_based_shear +import .SLAYER: riccati_del_s, slayer_layer_thickness, LayerWidths +import .SLAYER: surface_minor_radius, surface_da_dpsi, build_slayer_inputs -export InnerLayerModel, solve_inner +export InnerLayerModel, InnerLayerParameters, InnerLayerResponse, solve_inner export GGJ, GGJModel, GGJParameters export build_asymptotics, evaluate_asymptotics, pick_xmax, InnerAsymptoticsCache export mercier_di, mercier_dr, inner_Q, rescale_delta export glasser_wang_2020_eq55 -# SLAYER exports go here - +export SLAYER, SLAYERModel, SLAYERParameters, slayer_parameters, r_based_shear +export riccati_del_s, slayer_layer_thickness, LayerWidths +export surface_minor_radius, surface_da_dpsi, build_slayer_inputs end # module InnerLayer diff --git a/src/InnerLayer/InnerLayerInterface.jl b/src/InnerLayer/InnerLayerInterface.jl index 3c6e9010..e67cd141 100644 --- a/src/InnerLayer/InnerLayerInterface.jl +++ b/src/InnerLayer/InnerLayerInterface.jl @@ -15,15 +15,63 @@ Implementations live in submodules of `InnerLayer`, e.g. `InnerLayer.GGJ`. abstract type InnerLayerModel end """ - solve_inner(model::InnerLayerModel, params, γ::ComplexF64; kwargs...) -> SVector{2,ComplexF64} + InnerLayerParameters -Compute the parity-projected matching data `(Δ_odd, Δ_even)` for the given -inner-layer `model`, physical parameters `params`, and complex growth rate -`γ`. Concrete models specialize this function. +Abstract supertype for the per-surface physical-parameter structs consumed by +the inner-layer models (`SLAYERParameters`, `GGJParameters`). Lets the +dispersion runner and HDF5 output dispatch generically over a vector of +single-model parameters. +""" +abstract type InnerLayerParameters end + +""" + InnerLayerResponse + +Parity-projected inner-layer matching data at one rational surface. The two +components correspond to the homogeneous parity solutions of the half-domain +inner-layer problem (parity boundary conditions imposed at X = 0). They are +the `Δ_{j,±}(γ)` of Glasser, Wang & Park, Phys. Plasmas **23**, 112506 +(2016), Eqs. (34)–(35). + +# Fields + + - `tearing` — the **odd-parity** matching coefficient (GWP Δ_+, the + "odd mode"). Corresponds to a flux perturbation W that is EVEN in x and + a velocity/temperature perturbation that is ODD — i.e., the + reconnecting mode with a current sheet at the rational surface. This is + the tearing drive that appears as Δ' in the classical constant-ψ + tearing equation. Must be populated by every resistive inner-layer model. + + - `interchange` — the **even-parity** matching coefficient (GWP Δ_−, the + "even mode"). Corresponds to W odd, N and Θ even — i.e., the + non-reconnecting interchange/ballooning channel. Its dissipative piece + in toroidal geometry is the Glasser, Greene & Johnson stabilization + term that opposes tearing growth (Glasser, Greene & Johnson 1975; + Lütjens-Bondeson-Roy 1993). Pressureless inner-layer models (e.g. + SLAYER's Fitzpatrick Riccati) set this identically zero. + +The naming follows the physics channel rather than a mathematical parity +label because `odd/even` carries different meanings across the literature +depending on whether you label by the parity of W (GWP paper convention) +or the parity of (N, Θ). Using `tearing` and `interchange` avoids ambiguity. +""" +struct InnerLayerResponse + tearing::ComplexF64 + interchange::ComplexF64 +end + +InnerLayerResponse(; tearing::Number=0, interchange::Number=0) = + InnerLayerResponse(ComplexF64(tearing), ComplexF64(interchange)) + +""" + solve_inner(model::InnerLayerModel, params, γ::Number; kwargs...) -> InnerLayerResponse + +Compute the parity-projected matching data `(Δ_tearing, Δ_interchange)` for +the given inner-layer `model`, physical parameters `params`, and complex +growth rate `γ`. Concrete models specialize this function. -The two returned components correspond to the homogeneous odd / even parity -solutions of the half-domain inner-layer problem (parity boundary conditions -imposed at the rational surface, X = 0). They are the Δ_{j,±}(γ) of -Glasser, Wang & Park, Phys. Plasmas **23**, 112506 (2016), Eqs. (34)–(35). +See `InnerLayerResponse` for the physics-oriented field definitions. +Pressureless models (SLAYER) populate only `tearing` and leave +`interchange` at zero; two-fluid / finite-β models (GGJ) populate both. """ function solve_inner end diff --git a/src/InnerLayer/SLAYER/LayerInputs.jl b/src/InnerLayer/SLAYER/LayerInputs.jl new file mode 100644 index 00000000..96177903 --- /dev/null +++ b/src/InnerLayer/SLAYER/LayerInputs.jl @@ -0,0 +1,311 @@ +# LayerInputs.jl +# +# Build per-surface `SLAYERParameters` from an in-memory `PlasmaEquilibrium`, +# the `SingType` rational-surface data produced by `ForceFreeStates`, and a +# `KineticProfiles` object. Everything needed is already held in memory, so +# the layer inputs are assembled directly without an intermediate file +# round-trip. +# +# Geometry extraction: +# - Minor radius at the outboard midplane (θ = 0) via +# `equil.rzphi_rsquared((ψ, 0.0))`. +# - `da/dψ` via central finite difference on the same bicubic. +# - r-based magnetic shear via `r_based_shear(rs, q, q1, da/dψ)` (defined +# in LayerParameters.jl). + +using ..Utilities: KineticProfiles +using ...Utilities.NeoclassicalResistivity: NeoResistivityModel, SpitzerModel, + coulomb_log_e, nu_star_e +using FastInterpolations: DerivOp + +""" + surface_minor_radius(equil, psi; theta=0.0) -> Float64 + +Minor radius at normalized flux `psi` and poloidal angle `theta`, +computed from `equil.rzphi_rsquared` as `√((R − R₀)² + (Z − Z₀)²)`. +`theta = 0.0` (outboard midplane) is the default; pass `θ = π` to measure +the inboard side if you want an average. +""" +function surface_minor_radius(equil, psi::Real; theta::Real=0.0) + r_sq = equil.rzphi_rsquared((Float64(psi), Float64(theta))) + return sqrt(r_sq) +end + +""" + surface_da_dpsi(equil, psi; theta=0.0, h=1e-5) -> Float64 + +Central finite-difference approximation of `d(minor radius)/dψ` at `psi`. +Falls back to one-sided differences near the flux-coordinate boundaries +(0 or 1). +""" +function surface_da_dpsi(equil, psi::Real; theta::Real=0.0, h::Real=1e-5) + psi_f = Float64(psi) + # Clamp to safe sampling range within (0, 1) + eps_edge = 10 * h + lo = psi_f - h + hi = psi_f + h + if lo < eps_edge + # one-sided forward + a0 = surface_minor_radius(equil, max(psi_f, eps_edge); theta=theta) + a1 = surface_minor_radius(equil, max(psi_f, eps_edge) + h; theta=theta) + return (a1 - a0) / h + elseif hi > 1.0 - eps_edge + # one-sided backward + a0 = surface_minor_radius(equil, min(psi_f, 1.0 - eps_edge) - h; theta=theta) + a1 = surface_minor_radius(equil, min(psi_f, 1.0 - eps_edge); theta=theta) + return (a1 - a0) / h + else + a_plus = surface_minor_radius(equil, psi_f + h; theta=theta) + a_minus = surface_minor_radius(equil, psi_f - h; theta=theta) + return (a_plus - a_minus) / (2h) + end +end + +""" + build_slayer_inputs(equil, sings, profiles; …) -> Vector{SLAYERParameters} + +Build a `SLAYERParameters` for each rational surface in `sings`, pulling +geometry (minor radius, r-based shear, q, dq/dψ, R₀) from the in-memory +`equil::PlasmaEquilibrium` and kinetic data (n_e, T_e, T_i, ω, ω\\_\\*e, +ω\\_\\*i) from `profiles::KineticProfiles`. + +Layer inputs are assembled directly from the in-memory equilibrium and +profiles, without an intermediate file round-trip. + +# Arguments + + - `equil` -- `PlasmaEquilibrium` + - `sings` -- `Vector{SingType}` (one per resonant surface) + - `profiles` -- `KineticProfiles` valid across all `sings` ψ values + +# Keyword arguments + + - `bt` -- toroidal field [T]. Scalar, callable of `psi`, or + `nothing` (default). When `nothing`, the physical `B_T = F(ψ) / (2π·R₀)` + is computed per surface from the equilibrium's F-spline. Note: + `equil.config.b0exp` is a *normalization* (often just `1.0`), not the + physical field, so passing it as a scalar is almost always wrong. + + - `mu_i` -- ion mass in proton-mass units (default `2.0` for D). + - `zeff` -- effective charge (default `1.0`). + - `chi_perp` -- perpendicular heat diffusivity [m²/s]. Scalar or a + callable of `psi` (default `1.0`). + - `chi_tor` -- toroidal heat diffusivity [m²/s]. Scalar or a callable + of `psi` (default `1.0`). + - `dr_val` -- resistive interchange index `D_R = E + F + H²` + (Glasser-Greene-Johnson 1975) feeding the critical-Δ formulas + (`:lar`, `:rfitzp`, `:toroidal`). When `nothing` (default), Julia + derives it per-surface from the equilibrium as + `dr_val_k = D_R(ψ_k) = E_k + F_k + H_k²`, + consistent with Connor-Hastie-Helander 2015 (PPCF 57 065001) Eq. 59 + which uses `(−D_R)` in the χ_‖-matching critical-Δ. Pass a scalar / + vector / callable to override. + + **NOTE**: the χ_‖-matching critical-Δ requires the resistive + interchange index `D_R = E + F + H²` (Glasser-Greene-Johnson 1975), + NOT the Mercier index `D_I = E + F + H − 1/4`. The two differ by + `(H − 1/2)²`, which is non-trivial on shaped equilibria (~factor 3 on + DIII-D); this code uses the physically correct `D_R`. + - `dgeo_val` -- Connor 2015 (PPCF 57 065001) Eq. 59 geometric factor + used by `dc_type=:toroidal`. When `nothing` (default), an error is + raised if `dc_type=:toroidal` is also requested — the auto-derived + formula additionally needs ⟨|∇ψ|²⟩ FSA which `ResistGeometry` + doesn't currently expose. Pass a scalar / vector / callable to use + a prescribed value. (For `dc_type=:rfitzp` and `:lar`, dgeo_val is + not consulted.) + - `dc_type` -- `:none` (default), `:lar`, `:rfitzp`, or `:toroidal`. + - `theta` -- poloidal angle at which to measure minor radius (default + `0.0`, outboard midplane). + - `resistivity_model` -- `SauterNeoModel()` (default), `RedlNeoModel()`, + `SpitzerModel()`, or `SpitzerHarmModel()` (legacy Fitzpatrick σ_∥). + Sets the η entering τ_R = μ₀r_s²/η. With a neoclassical model, `f_trap` + and ν*_e are taken from the surface's `ResistGeometry` if populated + (via `ForceFreeStates.resist_eval_all!`), otherwise fall back to the + ε-only Lin-Liu-Miller form and `rs/R_0` aspect ratio. + - `lnLambda_form` -- Coulomb-log form passed through to `slayer_parameters` + (default `:nrl`; `:wesson` + `SpitzerHarmModel()` reproduces legacy + SLAYER exactly). +""" +function build_slayer_inputs(equil, sings, profiles::KineticProfiles; + bt=nothing, + R0=nothing, + rs_method::Symbol=:midplane, + mu_i::Real=2.0, + zeff::Real=1.0, + z_i::Real=1.0, + chi_perp=1.0, + chi_tor=1.0, + dr_val=nothing, + dgeo_val=nothing, + dc_type::Symbol=:none, + theta::Real=0.0, + compute_omega_star::Bool=true, + resistivity_model::NeoResistivityModel=SauterNeoModel(), + lnLambda_form::Symbol=:nrl) + R0_use = R0 === nothing ? equil.ro : Float64(R0) + _eval(x, ψ) = x isa Real ? Float64(x) : Float64(x(ψ)) + + # Compute physical B_T = F(ψ) / (2π·R₀) per surface from the F spline + # when `bt` is not explicitly supplied. + _bt_at(ψ) = + if bt === nothing + Float64(equil.profiles.F_spline(ψ)) / (2π * R0_use) + elseif bt isa Real + Float64(bt) + else + Float64(bt(ψ)) + end + + # Minor-radius extractor: `:midplane` = outboard-midplane chord + # (original behavior); `:fsa` = θ-mean of √rzphi_rsquared, the + # flux-surface-averaged minor radius. + _rs_at(ψ) = + if rs_method === :fsa + integrand(θ) = sqrt(equil.rzphi_rsquared((Float64(ψ), Float64(θ)))) + N = 128 + s = 0.0 + @inbounds for k in 1:N + s += integrand((k - 0.5) / N) + end + s / N + else + surface_minor_radius(equil, ψ; theta=theta) + end + _da_dpsi_at(ψ) = + if rs_method === :fsa + # central finite difference on _rs_at + h = 1e-5 + lo = ψ - h + hi = ψ + h + eps_edge = 10h + if lo < eps_edge + (_rs_at(max(ψ, eps_edge) + h) - _rs_at(max(ψ, eps_edge))) / h + elseif hi > 1.0 - eps_edge + (_rs_at(min(ψ, 1.0 - eps_edge)) - _rs_at(min(ψ, 1.0 - eps_edge) - h)) / h + else + (_rs_at(ψ + h) - _rs_at(ψ - h)) / (2h) + end + else + surface_da_dpsi(equil, ψ; theta=theta) + end + + # Per-surface ω_*e, ω_*i (diamagnetic frequencies) from spline + # derivatives. When `compute_omega_star=true` we override any ω_*e/ω_*i + # carried in `profiles`. Main-ion density is + # taken equal to the electron density (quasi-neutrality, matching the + # staging step). + chi1 = 2π * equil.psio + _omega_star_at(ψ) = begin + n_e = Float64(profiles.n_e(ψ)) + dn_e = Float64(profiles.n_e(ψ; deriv=DerivOp(1))) + T_e = Float64(profiles.T_e(ψ)) + dT_e = Float64(profiles.T_e(ψ; deriv=DerivOp(1))) + T_i = Float64(profiles.T_i(ψ)) + dT_i = Float64(profiles.T_i(ψ; deriv=DerivOp(1))) + ω_star_e = (2π / chi1) * (T_e * dn_e / n_e + dT_e) + ω_star_i = -(2π / (Float64(z_i) * chi1)) * (T_i * dn_e / n_e + dT_i) + return (ω_star_e, ω_star_i) + end + + out = Vector{SLAYERParameters}(undef, length(sings)) + for (k, sing) in enumerate(sings) + psi = sing.psifac + q = sing.q + q1 = sing.q1 + + rs = _rs_at(psi) + da_dpsi = _da_dpsi_at(psi) + sval_r = r_based_shear(rs, q, q1, da_dpsi) + + prof = profiles(psi) + # Override ω_*e, ω_*i with spline-derivative values when requested. + ω_e_use, ω_i_use = if compute_omega_star + _omega_star_at(psi) + else + (prof.omega_e, prof.omega_i) + end + + # Resonant (m, n): take the first element of the mode-number vectors. + # Parallel-FM `sing.m`/`sing.n` hold exactly one entry each; ideal + # DCON may hold multiple — we pick the first and document the choice. + m_res = sing.m[1] + n_res = sing.n[1] + + # Pull geometric trapped-fraction inputs from ResistGeometry when + # available (populated by ForceFreeStates.resist_eval_all!); else + # fall back to nothing and let slayer_parameters compute them from + # aspect ratio + Lin-Liu-Miller ε-only form. + rg = sing.restype + f_trap_kw = rg === nothing ? nothing : rg.f_trap + R_major_eff = rg === nothing ? nothing : rg.R_major + nu_e_star_kw = if rg === nothing || + resistivity_model isa Union{SpitzerModel,SpitzerHarmModel} + nothing + else + lnL = coulomb_log_e(prof.n_e, prof.T_e; form=lnLambda_form) + nu_star_e(prof.n_e, prof.T_e, rg.R_major, rg.eps_local, + q, zeff; lnLamb=lnL) + end + + # dr_val: per-surface resistive interchange index D_R = E + F + H² + # (Glasser-Greene-Johnson 1975). Used by `_solve_dc_tmp` to compute + # the χ_‖-matching critical-Δ via Connor-Hastie-Helander 2015 Eq. 59, + # which has `(−D_R)` as a multiplier. NOT the Mercier index + # D_I = E + F + H − 1/4 (see this function's docstring); we use the + # physically correct D_R here. + dr_val_k = if dr_val === nothing + rg === nothing && + throw( + ArgumentError( + "build_slayer_inputs: dr_val=nothing " * + "requires `sing.restype` populated by " * + "ForceFreeStates.resist_eval_all!. " * + "Surface k=$k has restype=nothing." + ) + ) + rg.E + rg.F + rg.H^2 + else + _eval(dr_val, psi) + end + + # dgeo_val: only used by dc_type=:toroidal (the Connor-Hastie- + # Helander 2015 formula). Auto-derivation requires ⟨|∇ψ|²⟩ FSA + # which the current `ResistGeometry` doesn't expose; for now we + # require an explicit value if the toroidal dc_type is selected. + dgeo_val_k = if dgeo_val === nothing + dc_type === :toroidal && + throw( + ArgumentError( + "build_slayer_inputs: dc_type=:toroidal " * + "needs `dgeo_val` (Connor 2015 PPCF 57 " * + "065001 Eq. 59 geometric factor). " * + "Auto-derivation from equilibrium not " * + "yet implemented; pass a scalar / vector " * + "/ callable explicitly." + ) + ) + 0.0 + else + _eval(dgeo_val, psi) + end + + out[k] = slayer_parameters(; + n_e=prof.n_e, t_e=prof.T_e, t_i=prof.T_i, + omega=prof.omega, omega_e=ω_e_use, omega_i=ω_i_use, + qval=q, sval_r=sval_r, bt=_bt_at(psi), + rs=rs, R0=R0_use, mu_i=mu_i, zeff=zeff, + chi_perp=_eval(chi_perp, psi), + chi_tor=_eval(chi_tor, psi), + m=m_res, n=n_res, + dr_val=dr_val_k, + dgeo_val=dgeo_val_k, + dc_type=dc_type, ising=k, + resistivity_model=resistivity_model, + f_trap=f_trap_kw, + nu_e_star=nu_e_star_kw, + R_major_eff=R_major_eff, + lnLambda_form=lnLambda_form + ) + end + return out +end diff --git a/src/InnerLayer/SLAYER/LayerParameters.jl b/src/InnerLayer/SLAYER/LayerParameters.jl new file mode 100644 index 00000000..57ee3076 --- /dev/null +++ b/src/InnerLayer/SLAYER/LayerParameters.jl @@ -0,0 +1,363 @@ +# LayerParameters.jl +# +# `SLAYERParameters` carries the dimensionless layer-physics parameters +# that the Fitzpatrick layer Riccati ODE consumes for one rational surface, +# plus the dimensional conversion factors needed to translate normalized +# frequencies and Δ values back to physical units. +# +# The constructor builds the per-surface state from dimensional equilibrium +# and kinetic-profile inputs. The Fitzpatrick two-fluid layer uses +# P_perp/P_tor/D_norm rather than the older magnetic/electron Prandtl +# (pr/pe) and ρ_s-based (ds) parametrization. Q is not stored — it is +# passed directly to `solve_inner`. + +""" + SLAYERParameters + +Dimensionless layer-physics parameters at one rational surface for the +Fitzpatrick two-fluid drift-MHD SLAYER inner-layer model (Fitzpatrick +2023; Park et al. 2022), plus dimensional auxiliaries required for +de-normalization. The parametrization uses `P_perp`, `P_tor`, and +`D_norm` (not the older `pr`/`pe`/`ds` set). + +| field | meaning | +|:---------- |:----------------------------------------------------------------- | +| `ising` | Singular-surface index (traceability only) | +| `m`, `n` | Poloidal / toroidal mode numbers at this surface | +| `tau` | T_i / T_e | +| `lu` | Lundquist number S = τ_R / τ_H | +| `c_beta` | Compressibility √(β_local / (1 + β_local)) | +| `D_norm` | (d_β/r_s) · S^(1/3) · √(τ/(1+τ)) (Fitzpatrick normalized scale) | +| `P_perp` | Perpendicular Prandtl number τ_R / τ_⊥ | +| `P_tor` | Toroidal-direction Prandtl number τ_R / τ_‖tor | +| `Q_e` | Normalized electron diamagnetic: −tauk · ω_*e | +| `Q_i` | Normalized ion diamagnetic: −tauk · ω_*i | +| `iota_e` | Q_e / (Q_e − Q_i) | +| `tauk` | Q-conversion factor S^(1/3) · τ_H [s] — multiplies ω to get Q | +| `tau_r` | Resistive diffusion time [s] | +| `delta_n` | Δ-normalization factor S^(1/3) / r_s [m⁻¹] | +| `rs` | Minor radius at this surface [m] | +| `R0` | Major radius [m] | +| `bt` | Toroidal field [T] | +| `sval_r` | r-based magnetic shear r_s · (dq/dr) / q (Fitzpatrick convention) | +| `dr_val` | Radial width parameter at surface (input to dc_tmp) | +| `dgeo_val` | Geometric Δ (Shafranov shift factor) | +| `eta` | Parallel resistivity entering τ_R = μ₀r_s²/η [Ω·m] | +| `d_beta` | Beta-weighted ion length scale c_β · d_i [m] | +| `dc_tmp` | Critical-Δ offset from chi_parallel matching | +| `dc_type` | Selector for `dc_tmp` formula | + +The complex normalized growth rate `Q = ω + iγ` is **not** stored here; +it is passed as a separate argument to `solve_inner`. +""" +Base.@kwdef struct SLAYERParameters <: InnerLayerParameters + # Surface identity + ising::Int = 0 + m::Int = 0 + n::Int = 0 + + # Normalized layer parameters consumed by riccati_f + tau::Float64 + lu::Float64 + c_beta::Float64 + D_norm::Float64 + P_perp::Float64 + P_tor::Float64 + Q_e::Float64 + Q_i::Float64 + iota_e::Float64 + + # Conversion factors (Q ↔ ω in rad/s) + tauk::Float64 + tau_r::Float64 + delta_n::Float64 + + # Geometric / fluid auxiliaries + rs::Float64 + R0::Float64 + bt::Float64 + sval_r::Float64 + dr_val::Float64 = 0.0 + dgeo_val::Float64 = 0.0 + eta::Float64 + d_beta::Float64 + + # Critical-Δ offset + dc_tmp::Float64 = 0.0 + dc_type::Symbol = :none +end + +# Allowed dc_type values for the critical-Δ offset. `:none` is the default +# `dc_tmp = 0` branch. +const ALLOWED_DC_TYPES = (:none, :lar, :rfitzp, :toroidal) + +""" + r_based_shear(rs, q, dq_dpsi, da_dpsi) -> Float64 + +Convert a ψ-based shear to the r-based (Fitzpatrick) convention used +throughout SLAYER: + +``` +s_r = r_s · (dq/dr) / q = r_s · (dq/dψ) / (q · da/dψ) +``` + +`rs` is the minor radius at the surface, `q` the safety factor, +`dq_dpsi` the radial derivative of q with respect to ψ, and `da_dpsi` +the derivative of the surface minor radius with respect to ψ. The two +ψ derivatives must use the **same** ψ convention (i.e., both with +respect to ψ_norm or both with respect to physical ψ — the conversion +factor cancels in the ratio). + +Equivalent to the conversion `s_Fitz = s_psiN · r_s / (psi_N · da_dpsiN)`. +""" +function r_based_shear(rs::Real, q::Real, dq_dpsi::Real, da_dpsi::Real) + da_dpsi != 0 || throw(ArgumentError("r_based_shear: da/dψ must be non-zero")) + q != 0 || throw(ArgumentError("r_based_shear: q must be non-zero")) + return rs * dq_dpsi / (q * da_dpsi) +end + +# Internal: solve the Wd self-consistency loop for the chi_parallel-based +# critical Δ (Connor-Hastie-Helander 2015). Returns dc_tmp as a Float64. +function _solve_dc_tmp(; dc_type::Symbol, dr_val::Real, dgeo_val::Real, + chi_perp::Real, t_e::Real, zeff::Real, tau_ee::Real, + rs::Real, R0::Real, sval_r::Real, n_tor::Integer, + max_iter::Integer=100, tol::Real=1e-10) + dc_type in ALLOWED_DC_TYPES || + throw(ArgumentError("SLAYERParameters: unknown dc_type=$dc_type. " * + "Allowed: $(ALLOWED_DC_TYPES)")) + (dc_type === :none || dr_val == 0.0) && return 0.0 + + vte = sqrt(2.0 * t_e * E_CHG / M_E) + chi_par_smfp = (1.581 * tau_ee * vte^2) / (1.0 + 0.2535 * zeff) + + Wd = 0.1 + converged = false + for _ in 1:max_iter + chi_par_lmfp = (2.0 * R0 * vte) / (sqrt(π) * n_tor * sval_r * Wd) + chi_par = (chi_par_smfp * chi_par_lmfp) / + (chi_par_smfp + chi_par_lmfp) + Wd_new = sqrt(8.0) * (chi_perp / chi_par)^0.25 * + (1.0 / sqrt((rs / R0) * sval_r * n_tor)) + if abs(Wd_new - Wd) / max(abs(Wd), 1e-30) < tol + Wd = Wd_new + converged = true + break + end + Wd = Wd_new + end + converged || error("SLAYERParameters: Wd iteration failed to converge") + + chi_par_lmfp = (2.0 * R0 * vte) / (sqrt(π) * n_tor * sval_r * Wd) + chi_par = (chi_par_smfp * chi_par_lmfp) / (chi_par_smfp + chi_par_lmfp) + + if dc_type === :lar + return 0.5 * (-dr_val) * π^1.5 * + (chi_par / chi_perp)^0.25 * + sqrt((n_tor * sval_r) / (R0 * rs)) + elseif dc_type === :rfitzp + return -(sqrt(2.0) * π^1.5 * dr_val) / Wd + elseif dc_type === :toroidal + return 0.5 * (-dr_val) * π^1.5 * + (chi_par / chi_perp)^0.25 * dgeo_val + end + return 0.0 +end + +""" + slayer_parameters(; n_e, t_e, t_i, omega, omega_e, omega_i, + qval, sval_r, bt, rs, R0, mu_i, zeff, + chi_perp, chi_tor, + m, n, + dr_val=0.0, dgeo_val=0.0, + dc_type=:none, ising=0, + resistivity_model=SauterNeoModel(), + f_trap=nothing, nu_e_star=nothing, + R_major_eff=nothing, + lnLambda_form=:nrl) + -> SLAYERParameters + +Build a `SLAYERParameters` for one rational surface from dimensional +equilibrium and kinetic-profile inputs, in the Fitzpatrick two-fluid layer +parametrization (P_perp/P_tor/D_norm; the older magnetic/electron Prandtl +`pr`/`pe` and ρ_s-based `ds` parameters are not used). + +# Arguments + + - `n_e` -- electron density [m⁻³] + - `t_e` -- electron temperature [eV] + - `t_i` -- ion temperature [eV] + - `omega` -- toroidal rotation frequency at the surface [rad/s] + - `omega_e` -- electron diamagnetic frequency [rad/s] + - `omega_i` -- ion diamagnetic frequency [rad/s] + - `qval` -- safety factor q at the surface + - `sval_r` -- **r-based** magnetic shear r·(dq/dr)/q (Fitzpatrick). + Use `r_based_shear` to convert from ψ-based shear. + - `bt` -- toroidal field [T] + - `rs` -- minor radius at the surface [m] + - `R0` -- major radius [m] + - `mu_i` -- ion mass in proton-mass units (e.g. 2.0 for D) + - `zeff` -- effective charge + - `chi_perp`, `chi_tor` -- perpendicular / toroidal heat diffusivity [m²/s] + - `m`, `n` -- poloidal / toroidal mode numbers at the surface + - `dr_val`, `dgeo_val` -- inputs for the critical-Δ formula + - `dc_type` -- one of `:none`, `:lar`, `:rfitzp`, `:toroidal` + - `ising` -- singular-surface index for traceability + +# Resistivity kwargs + +The selected resistivity sets the resistive diffusion time +`τ_R = μ₀ r_s² / η` and hence the Lundquist number and every normalized +layer parameter derived from it. + + - `resistivity_model` -- `SauterNeoModel()` (default: Sauter 1999 F_33 + trapped-particle correction, the physically-appropriate choice for + H-mode tearing stability), `RedlNeoModel()` (improved high-ν* fit), + `SpitzerModel()` (Sauter 18a fit, no trapped-particle correction), or + `SpitzerHarmModel()` (Fitzpatrick Spitzer-Härm σ_∥ — the legacy SLAYER + closure; pair with `lnLambda_form=:wesson` to reproduce legacy τ_R + exactly). + - `f_trap` -- trapped-particle fraction at this surface. If not provided + with a neoclassical model, falls back to Lin-Liu-Miller ε-only form + with `ε = rs / (R_major_eff or R0)`. + - `nu_e_star` -- electron collisionality. If `nothing` with a neoclassical + model, computed from Sauter 1999 Eq. 18b using the same ε. + - `R_major_eff` -- ⟨R⟩ at the surface for the ν*_e formula (default `R0`). + - `lnLambda_form` -- `:nrl` (default), `:sauter`, or `:wesson` (legacy + form). + +# Sign convention for diamagnetic frequencies + +The diamagnetic normalization uses + +``` +Q_e = -tauk · ω_*e +Q_i = -tauk · ω_*i +``` + +For the standard plasma-physics input where ω_*e is tabulated negative and +ω_*i positive (electrons and ions drifting in opposite directions), this +produces `Q_e > 0, Q_i < 0`, matching the opposite-drift expectation of the +dispersion relation. +""" +function slayer_parameters(; + n_e::Real, t_e::Real, t_i::Real, + omega::Real, omega_e::Real, omega_i::Real, + qval::Real, sval_r::Real, bt::Real, + rs::Real, R0::Real, mu_i::Real, zeff::Real, + chi_perp::Real, chi_tor::Real, + m::Integer, n::Integer, + dr_val::Real=0.0, dgeo_val::Real=0.0, + dc_type::Symbol=:none, ising::Integer=0, + resistivity_model::NeoResistivityModel=SauterNeoModel(), + f_trap::Union{Real,Nothing}=nothing, + nu_e_star::Union{Real,Nothing}=nothing, + R_major_eff::Union{Real,Nothing}=nothing, + lnLambda_form::Symbol=:nrl) + + # Coulomb logarithm shared by the resistivity closure and τ_ee. + lnLamb = coulomb_log_e(n_e, t_e; form=lnLambda_form) + + # Parallel resistivity entering τ_R. The model selects the closure: + # SpitzerHarmModel — Fitzpatrick Spitzer-Härm σ_∥; with + # lnLambda_form=:wesson this is bit-identical to the legacy SLAYER η. + # SpitzerModel — Sauter 18a fit (legacy 1.65e-9 form under :wesson). + # SauterNeoModel / RedlNeoModel — F_33 trapped-particle correction + # using f_trap and ν*_e (from ResistGeometry or ε-only fallback). + if resistivity_model isa SpitzerModel && lnLambda_form === :wesson + # Preserve bit-identical legacy η diagnostic behaviour. + eta = 1.65e-9 * lnLamb / (t_e / 1e3)^1.5 + elseif resistivity_model isa Union{SpitzerModel,SpitzerHarmModel} + eta = eta_neoclassical(resistivity_model, n_e, t_e, zeff, + 0.0, 0.0; lnLamb=lnLamb) + else + R_eff = R_major_eff === nothing ? R0 : Float64(R_major_eff) + eps_here = clamp(rs / R_eff, 1e-6, 1.0 - 1e-6) + ft_here = f_trap === nothing ? trapped_fraction_eps(eps_here) : + Float64(f_trap) + nue_here = nu_e_star === nothing ? + nu_star_e(n_e, t_e, R_eff, eps_here, qval, zeff; + lnLamb=lnLamb) : + Float64(nu_e_star) + eta = eta_neoclassical(resistivity_model, n_e, t_e, zeff, + ft_here, nue_here; lnLamb=lnLamb) + end + + # Basic plasma quantities + tau = t_i / t_e + rho = mu_i * M_P * n_e + + # Electron-electron collision time (still needed by the χ_∥-matching + # critical-Δ regardless of the resistivity model). + tau_ee = tau_ee_spitzer_harm(n_e, t_e; lnLamb=lnLamb) + + # Characteristic field, Alfven speed, length scales, fundamental + # timescales. + rho_s = 1.02e-4 * sqrt(mu_i * t_e) / bt # ion Larmor [m] + d_i = sqrt((mu_i * M_P) / (n_e * E_CHG^2 * MU_0)) # ion skin depth [m] + + # Alfven time uses minor-radius shear directly (sval enters the + # b_l = (n/m) r_s sval bt / R0 expression and cancels through to + # tau_h = R0 sqrt(mu0 rho) / (n sval bt)). + tau_h = R0 * sqrt(MU_0 * rho) / (n * sval_r * bt) + # Resistive diffusion time τ_R = μ₀ r_s² / η (Fitzpatrick 2023), with + # the selected η closure — neoclassical by default — setting the + # Lundquist number. + tau_r = MU_0 * rs^2 / eta + + # Lundquist number and Q-conversion factor + lu = tau_r / tau_h + tauk = lu^(1.0 / 3.0) * tau_h # = Qconv + + # Normalized diamagnetic frequencies, Q = -tauk·ω; see docstring sign + # convention. + Q_e = -tauk * omega_e + Q_i = -tauk * omega_i + Q_e_minus_Q_i = Q_e - Q_i + # iota_e = Q_e/(Q_e - Q_i) is singular when the electron and ion + # diamagnetic frequencies are degenerate (ω_*e == ω_*i). The downstream + # Riccati coefficients divide by iota_e, so silently setting it to 0 (or + # Inf) produces NaN/Inf; surface the degeneracy as a clear error instead. + Q_e_minus_Q_i != 0 || + throw( + ArgumentError( + "slayer_parameters: Q_e == Q_i (degenerate " * + "electron/ion diamagnetic frequencies) makes " * + "iota_e = Q_e/(Q_e - Q_i) singular. Check the " * + "diamagnetic-frequency inputs ω_*e, ω_*i." + ) + ) + iota_e = Q_e / Q_e_minus_Q_i + + # Plasma beta and compressibility + lbeta = (5.0 / 3.0) * MU_0 * n_e * E_CHG * (t_e + t_i) / bt^2 + c_beta = sqrt(lbeta / (1.0 + lbeta)) + + # Effective Prandtl-like transport ratios + tau_perp = rs^2 / chi_perp + P_perp = tau_r / tau_perp + tau_tor = rs^2 / chi_tor + P_tor = tau_r / tau_tor + + # Normalized beta-related width and Δ-normalization + d_beta = c_beta * d_i + D_norm = (d_beta / rs) * lu^(1.0 / 3.0) * sqrt(tau / (1.0 + tau)) + delta_n = lu^(1.0 / 3.0) / rs + + # Critical-Δ offset from chi_parallel matching + dc_tmp = _solve_dc_tmp(; dc_type=dc_type, dr_val=dr_val, dgeo_val=dgeo_val, + chi_perp=chi_perp, t_e=t_e, zeff=zeff, + tau_ee=tau_ee, rs=rs, R0=R0, sval_r=sval_r, + n_tor=n) + + return SLAYERParameters(; + ising=ising, m=m, n=n, + tau=tau, lu=lu, c_beta=c_beta, D_norm=D_norm, + P_perp=P_perp, P_tor=P_tor, + Q_e=Q_e, Q_i=Q_i, iota_e=iota_e, + tauk=tauk, tau_r=tau_r, delta_n=delta_n, + rs=rs, R0=R0, bt=bt, sval_r=sval_r, + dr_val=dr_val, dgeo_val=dgeo_val, + eta=eta, d_beta=d_beta, + dc_tmp=dc_tmp, dc_type=dc_type + ) +end diff --git a/src/InnerLayer/SLAYER/LayerThickness.jl b/src/InnerLayer/SLAYER/LayerThickness.jl new file mode 100644 index 00000000..eb16a763 --- /dev/null +++ b/src/InnerLayer/SLAYER/LayerThickness.jl @@ -0,0 +1,171 @@ +# LayerThickness.jl +# +# Resistive inner-layer *width* (del_s) diagnostic for the SLAYER two-fluid +# drift-MHD slab layer (Fitzpatrick, Tearing Mode Dynamics in Tokamak +# Plasmas, IOP 2023; Park et al. 2022, Phys. Plasmas 29, 122505; Burgess +# et al. 2026). This is a distinct quantity from the matching index +# Δ = π/W' returned by the dispersion solver (Riccati.jl): it integrates a +# separate reduced Riccati ODE evaluated at the electron diamagnetic +# frequency Q_e and extracts a dimensionless width ratio delta_s/d_beta, +# which scales to a layer thickness in metres via the beta-weighted ion +# length d_beta. +# +# Q_i, c_beta, and the scanned Q are NOT referenced by this width +# formulation; the E/F coefficients depend only on Q_e, P_perp, P_tor, tau, +# and D_norm. + +using OrdinaryDiffEq + +# --------------------------------------------------------------------- +# Pre-computed q-independent constants for the del_s Riccati ODE. +# Normalisation block: +# Q_hat = (Q_e (1+tau)/tau) / D_norm^4 +# P_perp_hat = P_perp / D_norm^6 +# P_tor_hat = P_tor / D_norm^6 +# `one_plus = 1 + 1/tau`, `inv_c = 1/(1+1/tau)` recur in E, F and the +# boundary/extraction prefactor, so they are cached here too. +# --------------------------------------------------------------------- +struct _DelSConsts + Q_hat::Float64 # normalised electron diamagnetic frequency + Pperp_hat::Float64 # normalised perpendicular Prandtl number + Ptor_hat::Float64 # normalised toroidal Prandtl number + one_plus::Float64 # 1 + 1/tau + inv_c::Float64 # 1 / (1 + 1/tau) +end + +@inline function _build_dels_consts(p::SLAYERParameters) + one_plus = 1.0 + 1.0 / p.tau # (1+tau)/tau + D2 = p.D_norm * p.D_norm + D4 = D2 * D2 + D6 = D4 * D2 + return _DelSConsts( + (p.Q_e * one_plus) / D4, + p.P_perp / D6, + p.P_tor / D6, + one_plus, + 1.0 / one_plus + ) +end + +# Scalar ODE right-hand side dW/dq for the del_s Riccati. The E, F +# dispersion coefficients are q-dependent and complex (via the `im·Q_hat` +# terms); +# everything else is cached in `_DelSConsts`. +@inline function _dels_rhs(W::Number, c::_DelSConsts, q::Real) + q2 = q * q + q4 = q2 * q2 + E = -(c.Q_hat^2) * c.inv_c - + im * c.Q_hat * (c.Pperp_hat + c.Ptor_hat) * q2 + + c.Pperp_hat * c.Ptor_hat * q4 + F = c.Pperp_hat - im * c.Q_hat + c.one_plus * c.Ptor_hat * q2 + return W / q - (W * W) / q + (q * E) / F +end + +# Analytic Jacobian dF/dW: the q·E/F term is W-independent, leaving +# d/dW(W/q - W²/q) = 1/q - 2W/q. +@inline _dels_jac(W::Number, c::_DelSConsts, q::Real) = 1.0 / q - 2.0 * W / q + +""" + riccati_del_s(p::SLAYERParameters; + q_start=5*p.D_norm, q_min=1e-5, + reltol=1e-10, abstol=1e-10, maxiters=50_000, + solver=Rodas5P(autodiff=false)) -> ComplexF64 + +Solve the SLAYER `del_s` inner-layer Riccati ODE and return the +**dimensionless** layer-thickness ratio `δ_s / d_β` at one rational +surface (see file header for references). + +Integrates `dW/dq = W/q − W²/q + q·E/F` inward from `q_start = 5·D_norm` +to `q_min`, with asymptotic boundary value `W = −α·q_start² − 0.5`, +`α = √(P̂_⊥ / (1 + 1/τ))`. The result is +`−(π / √(1 + 1/τ)) · W′(q_min)`, evaluated from a single RHS call at the +inner endpoint. + +Returns `NaN + NaN·im` if the stiff integration does not converge (the +caller treats this as a missing diagnostic rather than a hard error). + +Multiply the result by `p.d_beta` to obtain the resistive layer +thickness in meters (see [`slayer_layer_thickness`](@ref)). +""" +function riccati_del_s(p::SLAYERParameters; + q_start::Real=5.0 * p.D_norm, + q_min::Real=1e-5, + reltol::Real=1e-10, + abstol::Real=1e-10, + maxiters::Integer=50_000, + solver=Rodas5P(; autodiff=false)) + if !(q_start > q_min) + @debug "riccati_del_s: degenerate integration span" q_start q_min p.D_norm + return ComplexF64(NaN, NaN) + end + + c = _build_dels_consts(p) + + # Asymptotic boundary condition at large q. P_hat is + # the normalised P_perp, i.e. c.Pperp_hat; α and W₀ are real. + α = sqrt(c.Pperp_hat * c.inv_c) + W0 = ComplexF64(-α * q_start^2 - 0.5) + + f = ODEFunction{false}(_dels_rhs; jac=_dels_jac) + prob = ODEProblem(f, W0, (q_start, q_min), c) + sol = solve(prob, solver; + reltol=reltol, abstol=abstol, maxiters=maxiters, + save_everystep=false, dense=false) + + if sol.retcode != ReturnCode.Success + @debug "SLAYER riccati_del_s did not return Success" sol.retcode + return ComplexF64(NaN, NaN) + end + + W_end = sol.u[end] + dW_end = _dels_rhs(W_end, c, q_min) + return -(π / sqrt(c.one_plus)) * dW_end +end + +""" + LayerWidths + +Resistive inner-layer length scales at one rational surface, in meters. +The primary quantity is `delta_s_m`, the resistive layer thickness from +the SLAYER `del_s` Riccati solve; `d_beta` is the β-weighted ion scale it +is built from, retained as a drift-scale reference. + +# Fields + + - `ising`, `m`, `n` -- surface index and mode numbers (traceability) + - `dels_db` -- dimensionless `δ_s / d_β` from [`riccati_del_s`](@ref) + - `delta_s` -- complex layer thickness `δ_s = dels_db · d_β` [m] + - `delta_s_m` -- `|δ_s|`, the resistive layer thickness [m] (primary) + - `d_beta` -- β-weighted ion scale `c_β·d_i` [m] (drift reference) + +`delta_s_m` should sit within a few orders of magnitude of `d_beta` for a +well-posed surface (`dels_db` is O(1)); a large gap flags a normalisation +or input problem. +""" +struct LayerWidths + ising::Int + m::Int + n::Int + dels_db::ComplexF64 + delta_s::ComplexF64 + delta_s_m::Float64 + d_beta::Float64 +end + +""" + slayer_layer_thickness(p::SLAYERParameters; kwargs...) -> LayerWidths + +Compute the resistive inner-layer thickness in meters at one rational +surface. + +Runs [`riccati_del_s`](@ref) for the dimensionless `δ_s / d_β` and scales +by `p.d_beta` to obtain `δ_s` in meters. Keyword arguments are forwarded +to `riccati_del_s`. +""" +function slayer_layer_thickness(p::SLAYERParameters; kwargs...) + dels_db = riccati_del_s(p; kwargs...) + delta_s = dels_db * p.d_beta + return LayerWidths(p.ising, p.m, p.n, + dels_db, delta_s, abs(delta_s), + p.d_beta) +end diff --git a/src/InnerLayer/SLAYER/Riccati.jl b/src/InnerLayer/SLAYER/Riccati.jl new file mode 100644 index 00000000..87781259 --- /dev/null +++ b/src/InnerLayer/SLAYER/Riccati.jl @@ -0,0 +1,267 @@ +# Riccati.jl +# +# Inner-layer Δ via the Fitzpatrick two-fluid drift-MHD layer Riccati ODE +# (Fitzpatrick, Tearing Mode Dynamics in Tokamak Plasmas, IOP 2023; Park +# et al. 2022, Phys. Plasmas 29, 122505; Burgess et al. 2026), for the +# pressureless tearing channel (no parallel flow, pe = 0). The A, B, C +# coefficients, the dW/dp Riccati equation, the large-p asymptotic boundary +# conditions, the regime test D² > ι_e P_⊥/P_tor^(2/3), the small-p +# Δ = π/W' extraction, and the analytic Jacobian all follow that layer +# formulation; the full C(p) form is retained in every regime (no low-D +# reduction). +# +# The complex normalized growth rate `Q = ω + iγ` is passed directly to +# `solve_inner` rather than carried on the parameter struct. All other +# inputs come from `SLAYERParameters` (see `LayerParameters.jl`). +# +# Returns the parity-projected matching data as an `InnerLayerResponse` +# with only the `tearing` channel populated so callers can treat SLAYER and +# GGJ interchangeably through the shared `InnerLayerModel` interface. +# SLAYER's inner-layer dispersion relation produces a single complex Δ +# (the tearing channel); the interchange channel is zero. + +using OrdinaryDiffEq + +# --------------------------------------------------------------------- +# Coefficient evaluation for the two-fluid layer A, B, C terms (Fitzpatrick 2023). +# +# All x-independent quantities are bundled in `_RiccatiConsts` and computed +# once per `solve_inner` call (see line ~200). The hot RHS / Jacobian +# evaluations then access only the bundled constants and `x`, avoiding the +# tens of thousands of redundant complex muls/adds the prior code did. +# --------------------------------------------------------------------- + +# Pre-computed x-independent constants for the Fitzpatrick Riccati ODE. +# Derived from `(p::SLAYERParameters, Q::ComplexF64)` once per solve. Used as +# the integrator `params` so `_riccati_f_rhs` and `_riccati_f_jac` only need +# the x-dependent algebra. +struct _RiccatiConsts + Q_plus_iQe::ComplexF64 # constant part of denom = Q + iQe + x² + A::ComplexF64 # Q·(Q + iQi) — fB constant term + B::ComplexF64 # (Q + iQi)·(P_perp + P_tor) — fB · x² coefficient + C::Float64 # P_perp · P_tor — fB · x⁴ coefficient + E::ComplexF64 # (Q + iQi) · D² + P_perp — fC · x² coefficient + G::Float64 # P_tor · D² / iota_e — fC · x⁴ coefficient +end + +@inline function _build_riccati_consts(p::SLAYERParameters, Q::ComplexF64) + Q_plus_iQe = Q + im * p.Q_e + Q_plus_iQi = Q + im * p.Q_i + D2 = p.D_norm * p.D_norm + return _RiccatiConsts( + Q_plus_iQe, + Q * Q_plus_iQi, # A + Q_plus_iQi * (p.P_perp + p.P_tor), # B + p.P_perp * p.P_tor, # C + p.P_perp + Q_plus_iQi * D2, # E + p.P_tor * D2 / p.iota_e # G + ) +end + +# Riccati RHS coefficients fA, fA', fB, fC at point x. Receives the +# pre-built `_RiccatiConsts` so each call costs only a handful of muls/adds +# plus one complex division (the fA = p²/denom). +@inline function _riccati_f_coeffs(c::_RiccatiConsts, x::Real) + p2 = x * x + p4 = p2 * p2 + denom = c.Q_plus_iQe + p2 + + fA = p2 / denom + # Use the numerator-subtracts-twice-p² form rather than the algebraic + # identity 1 − 2·fA. The two are mathematically equal, but the + # integrator's adaptive stepping near marginal stability compounds + # ULP-level differences in fA' over thousands of steps; this form keeps + # the result tighter than the identity, which drifts to ~3e-3 relative + # (within abs-tolerance, but tighter is better). + fA_prime = (denom - 2 * p2) / denom + + fB = c.A + c.B * p2 + c.C * p4 + fC = c.Q_plus_iQe + c.E * p2 + c.G * p4 + + return fA, fA_prime, fB, fC +end + +# Scalar ODE right-hand side dW/dp for OrdinaryDiffEq. +# +# This is a 1-equation ODE — modeling W(x) as a `ComplexF64` scalar (rather +# than a 1-element `Vector{ComplexF64}`) lets the integrator's stage updates +# stay on the stack with no per-step allocations. SDIRK + Rosenbrock + BDF +# methods in OrdinaryDiffEq all support scalar `u`. +@inline function _riccati_f_rhs(W::Number, consts::_RiccatiConsts, x::Real) + fA, fA_prime, fB, fC = _riccati_f_coeffs(consts, x) + return -(fA_prime / x) * W - W * W / x + (fB / (fA * fC)) * (x * x * x) +end + +# Analytic Jacobian of the Riccati RHS. The full RHS has +# both the explicit (fA'/p, fB·p³) terms and the W² term; for the +# Jacobian only the W-dependent pieces survive. Returns a scalar — the +# 1×1 Jacobian of the scalar ODE. +@inline function _riccati_f_jac(W::Number, consts::_RiccatiConsts, x::Real) + p2 = x * x + denom = consts.Q_plus_iQe + p2 + fA_prime = (denom - 2 * p2) / denom + return -(fA_prime / x) - 2 * W / x +end + +# --------------------------------------------------------------------- +# Boundary-condition selection (large-p asymptotics, Fitzpatrick 2023). +# Two regimes selected by D_norm² vs. +# iota_e·P_perp/P_tor^(2/3). +# --------------------------------------------------------------------- + +# Returns (p_start, W_at_p_start, branch) where `branch ∈ (:large_D, :small_D)`. +function _riccati_f_initial(p::SLAYERParameters, Q::ComplexF64; + p_floor::Real=6.0) + D2 = p.D_norm * p.D_norm + Pperp_over_Ptor23 = p.P_perp / p.P_tor^(2 / 3) + + if D2 > p.iota_e * Pperp_over_Ptor23 + # Large-D_norm branch. In ((P_tor·D²)/(iota_e·P_tor·P_perp))^(1/4) + # the P_tor factor cancels — written out for traceability. + p_start = max(((p.P_tor * D2) / (p.iota_e * p.P_tor * p.P_perp))^0.25, + p_floor) + + ak = -(Q + im * p.Q_e) + bk = (p.iota_e * p.P_perp * p.P_tor) / (p.P_tor * D2) + ck = bk * (1 + (Q + im * p.Q_i) * ((p.P_tor + p.P_perp) / + (p.P_tor * p.P_perp)) + - + (p.P_perp + (Q + im * p.Q_i) * D2) * + (p.iota_e / (p.P_tor * D2))) + sqrt_bk = sqrt(bk) + xk = (ck - sqrt_bk * (1 - sqrt_bk * ak)) / (2 * sqrt_bk) + + W_bound = xk - sqrt_bk * p_start + return p_start, W_bound, :large_D + else + # Small-D_norm branch. + p_start = max(1.0 / p.P_tor^(1 / 6), p_floor) + + ak = -(Q + im * p.Q_e) + bk = ComplexF64(p.P_tor) # promoted to ComplexF64 for sqrt below + ck = -im * (p.Q_e - p.Q_i) * (p.P_tor / p.P_perp) + (Q + im * p.Q_i) + sqrt_bk = sqrt(bk) + xk = (ak * bk - ck) / (2 * sqrt_bk) + + W_bound = -1.0 + xk * p_start - sqrt_bk * p_start^3 + return p_start, W_bound, :small_D + end +end + +# --------------------------------------------------------------------- +# solve_inner dispatch for SLAYERModel{:fitzpatrick}. +# --------------------------------------------------------------------- + +""" + solve_inner(::SLAYERModel{:fitzpatrick}, + p::SLAYERParameters, Q::Number; + pmin=1e-6, p_floor=6.0, + reltol=1e-10, abstol=1e-10, + maxiters=50_000, + solver=Rodas5P(autodiff=false)) -> InnerLayerResponse + +Solve the Fitzpatrick SLAYER inner-layer Riccati ODE for the complex +normalized growth rate `Q = ω + iγ`. Returns an `InnerLayerResponse` with +`tearing = Δ` and `interchange = 0`, so the result is interface-compatible +with `GGJModel.solve_inner` (which populates both channels); SLAYER's +pressureless layer produces only the tearing channel. + +# Algorithm + +Implements the Fitzpatrick two-fluid drift-MHD layer formulation +(Fitzpatrick 2023; Park et al. 2022) for the pressureless tearing channel +(no parallel flow, pe = 0). Integrates +`dW/dp = -(fA'/p)·W − W²/p + (fB/(fA·fC))·p³` from a large `p_start` +(selected by `_riccati_f_initial` according to whether +`D_norm² ≷ iota_e·P_perp/P_tor^(2/3)`) inward to `pmin`, then computes +`Δ = π / W'(pmin)` from a single RHS evaluation at the inner endpoint. + +# Solver + +Default `Rodas5P(autodiff=false)` (Rosenbrock, stiff-friendly). The +analytic Jacobian wired via the `ODEFunction(jac=...)` field accelerates +the Newton solves. AD is disabled because complex `Dual` propagation +through the chained denominators incurs allocations in this regime; +finite-difference fallback is fast enough for the 1-equation system. + +**Note on solver swaps:** sub-percent floating-point differences between +ODE solvers cascade through the outer AMR's cell-flagging decisions +(`ContourSearchAMR.jl::_crosses_zero`) and produce **structurally +different** AMR cell trees. Swapping the ODE solver has been observed to +change the classified root/pole inventory substantially even when the +most-unstable γ agrees to within ~1e-5 relative. So solver choice is not +just a per-call optimization — it affects the downstream root/pole +inventory. Future solver swaps need to be validated against the topology +fields (`n_valid_roots`, `n_poles`), not just γ. + +# Keyword arguments + + - `pmin` -- inner-layer cutoff (default 1e-6) + - `p_floor` -- floor on `p_start` (default 6.0) + - `reltol`,`abstol`,`maxiters` -- stiff-solver tolerance/iteration limits + - `solver` -- any OrdinaryDiffEq algorithm; pass `Tsit5()` for the + non-stiff path (rarely needed here) +""" +function solve_inner(::SLAYERModel{:fitzpatrick}, + p::SLAYERParameters, Q::Number; + pmin::Real=1e-6, + p_floor::Real=6.0, + reltol::Real=1e-10, + abstol::Real=1e-10, + maxiters::Integer=50_000, + solver=Rodas5P(; autodiff=false)) + # Convention bridge between our scan plane and the layer eigenvalue. + # We scan in the Park plane `Q = ω + iγ` (+γ on the +imag axis; Park + # et al. 2022). Fitzpatrick's normalized layer eigenvalue is + # `ĝ = (γ − iω)·τ_k` in the co-rotating frame (Fitzpatrick 2023: + # γ = Re(ĝ)/τ_k, ω = −Im(ĝ)/τ_k), so +γ sits on his +real axis. The two + # planes differ by a pure −90° rotation, `ĝ = −i·Q` — relabeling which + # axis is growth, no physics. + # + # The layer ODE coefficients are analytic in ĝ with Q_e, Q_i, D, P_⊥, + # P_φ all real, so Schwarz reflection gives `Δ̂_s(conj ĝ) = conj(Δ̂_s(ĝ))`. + # We feed `Q_c = i·conj(Q) = conj(−i·Q) = conj(ĝ)`, hence evaluate + # `conj(Δ̂_s(ĝ))`. Conjugation maps zeros to zeros, so the extracted + # growth-rate roots are identical to those of Δ̂_s(ĝ); it only reflects + # the off-root residual surface, fixing the integration-path orientation + # so |Δ| contours are consistent across the scan plane. + Q_c = im * conj(ComplexF64(Q)) + + # Boundary condition at p_start + p_start, W_bound, _ = _riccati_f_initial(p, Q_c; p_floor=p_floor) + + # Pre-compute x-independent constants ONCE; the integrator threads this + # through to every RHS / Jacobian call instead of recomputing per-step. + rhs_params = _build_riccati_consts(p, Q_c) + + # Scalar `u0`: the ODE state is a single `ComplexF64`, not a 1-element + # vector. OrdinaryDiffEq supports scalar problems via the out-of-place + # form (`ODEFunction{false}`). This eliminates the per-step heap- + # allocation of intermediate `dW` vectors that the in-place form + # incurred for every stage of every accepted/rejected step. + u0 = ComplexF64(W_bound) + f = ODEFunction{false}(_riccati_f_rhs; jac=_riccati_f_jac) + prob = ODEProblem(f, u0, (p_start, pmin), rhs_params) + sol = solve(prob, solver; + reltol=reltol, abstol=abstol, maxiters=maxiters, + save_everystep=false, dense=false) + + if sol.retcode != ReturnCode.Success + # Unconverged solve: return a NaN sentinel so the dispersion scan / AMR + # flags this Q-cell (via its isfinite checks) rather than ingesting a + # bogus finite Δ built from an unconverged W_end. @debug not @warn: in a + # dense Q-plane scan failures cluster near poles and would flood the log. + @debug "SLAYER Riccati integration did not return Success" sol.retcode + return InnerLayerResponse(ComplexF64(NaN, NaN), zero(ComplexF64)) + end + + # Δ = π / W'(pmin) — single RHS evaluation at the inner endpoint + W_end = sol.u[end] + dW_end = _riccati_f_rhs(W_end, rhs_params, pmin) + Δ::ComplexF64 = π / dW_end + + # Fitzpatrick / pressureless SLAYER has no interchange channel + # (the Δ_− / even-parity matching quantity is identically zero in + # the pressureless limit), so populate only the tearing field. + return InnerLayerResponse(Δ, zero(ComplexF64)) +end diff --git a/src/InnerLayer/SLAYER/SLAYER.jl b/src/InnerLayer/SLAYER/SLAYER.jl new file mode 100644 index 00000000..ece897c1 --- /dev/null +++ b/src/InnerLayer/SLAYER/SLAYER.jl @@ -0,0 +1,62 @@ +# SLAYER.jl +# +# SLAYER (Slab Layer) two-fluid drift-MHD inner-layer model (Fitzpatrick, +# Tearing Mode Dynamics in Tokamak Plasmas, IOP 2023; Park et al. 2022, +# Phys. Plasmas 29, 122505; Burgess et al. 2026). The dispersion path uses +# the Fitzpatrick layer formulation — P_perp / P_tor transport, c_beta +# compressibility, D_norm normalized ion-skin scale, two-fluid drift +# coupling via Q_e, Q_i, iota_e (see `Riccati.jl`). +# +# A separate `del_s` layer-thickness diagnostic (`slayer_layer_thickness` in +# `LayerThickness.jl`) returns the resistive layer width in meters at each +# rational surface. +# +# Type-parameter `S` of `SLAYERModel{S}` selects the Riccati formulation +# used for the dispersion relation; only `:fitzpatrick` is implemented. +# +# `Q = ω + iγ` is passed directly to `solve_inner` rather than stored on +# the parameter struct. + +module SLAYER + +using LinearAlgebra +using StaticArrays + +import ..InnerLayerModel, ..InnerLayerResponse, ..solve_inner, ..InnerLayerParameters +using ...Utilities.PhysicalConstants +using ...Utilities.NeoclassicalResistivity +using ...Utilities.NeoclassicalResistivity: NeoResistivityModel, SpitzerModel, + SpitzerHarmModel, SauterNeoModel, RedlNeoModel, + coulomb_log_e, eta_spitzer, tau_ee_spitzer_harm, eta_spitzer_harm, + trapped_fraction_eps, nu_star_e, eta_neoclassical + +""" + SLAYERModel{S} <: InnerLayerModel + +SLAYER inner-layer model selector. The type parameter `S` selects the +Riccati formulation: + + - `:fitzpatrick` -- P_perp/P_tor Fitzpatrick formulation (default; + Fitzpatrick 2023 two-fluid drift-MHD layer) + +Future dispersion variants (e.g. `:standard`) may be added but are not +currently implemented. The `del_s` formulation is exposed separately as +the layer-thickness diagnostic [`slayer_layer_thickness`](@ref), not as +a dispersion `solve_inner` path. +""" +struct SLAYERModel{S} <: InnerLayerModel end + +SLAYERModel(; variant::Symbol=:fitzpatrick) = SLAYERModel{variant}() + +include("LayerParameters.jl") +include("Riccati.jl") +include("LayerThickness.jl") +include("LayerInputs.jl") + +export SLAYERModel, SLAYERParameters, slayer_parameters +export r_based_shear +export riccati_del_s, slayer_layer_thickness, LayerWidths +export surface_minor_radius, surface_da_dpsi, build_slayer_inputs +export NeoResistivityModel, SpitzerModel, SpitzerHarmModel, SauterNeoModel, RedlNeoModel + +end # module SLAYER diff --git a/src/InnerLayer/SLAYER/Slayer.jl b/src/InnerLayer/SLAYER/Slayer.jl deleted file mode 100644 index 5a7f8729..00000000 --- a/src/InnerLayer/SLAYER/Slayer.jl +++ /dev/null @@ -1,4 +0,0 @@ -# Slayer.jl -# -# Placeholder for the SLAYER (Slab Layer) drift-MHD two-fluid inner layer model. -# Implementation pending. diff --git a/src/Tearing/Dispersion/BruteForceScan.jl b/src/Tearing/Dispersion/BruteForceScan.jl new file mode 100644 index 00000000..6e7b8aea --- /dev/null +++ b/src/Tearing/Dispersion/BruteForceScan.jl @@ -0,0 +1,89 @@ +# BruteForceScan.jl +# +# Brute-force evaluation of a complex-Q-callable residual (`SurfaceCoupling`, +# `MultiSurfaceCoupling`, or any user-supplied function) on a regular 2D +# Q-plane grid. The output `ScanResult` is then consumed by +# `find_growth_rates` (`GrowthRateExtraction.jl`) to extract growth-rate +# eigenvalues from the Re(Δ)=0 ∩ Im(Δ)=0 contour intersections. +# +# Resolution and box are entirely user-controlled. Threading is enabled by +# default; pass `threaded=false` for deterministic single-threaded +# evaluation (e.g. when the residual is itself non-thread-safe). + +""" + ScanResult + +Output of a brute-force or AMR Q-plane scan. + +| field | meaning | +|------------|---------------------------------------------------| +| `Q` | Complex Q values (`Matrix` for grid, `Vector` for AMR) | +| `Δ` | Residual values, same shape as `Q` | +| `re_axis` | Real-axis grid (only for regular-grid `ScanResult`) | +| `im_axis` | Imaginary-axis grid (only for regular-grid `ScanResult`) | +""" +struct ScanResult + Q::Matrix{ComplexF64} + Δ::Matrix{ComplexF64} + re_axis::Vector{Float64} + im_axis::Vector{Float64} +end + +""" + brute_force_scan(f, Q_re_range, Q_im_range; nre, nim, + threaded::Bool=true) -> ScanResult + +Evaluate the Q-callable residual `f` on a regular `nre × nim` grid spanning +the rectangle `Q_re_range × Q_im_range` in the complex Q plane. `f` must +accept a single `Complex` argument and return a `Complex` value (typically a +`SurfaceCoupling` or `MultiSurfaceCoupling`, but any callable works). + +Use `find_growth_rates(scan, tauk; ...)` to extract growth-rate eigenvalues +from the result. + +# Arguments + + - `f` -- Q-callable residual (e.g. `SurfaceCoupling`, `MultiSurfaceCoupling`) + - `Q_re_range` -- `(re_min, re_max)` tuple + - `Q_im_range` -- `(im_min, im_max)` tuple + +# Keyword arguments + + - `nre`, `nim` -- grid resolution along each axis + - `threaded` -- distribute Q evaluations across `Threads.@threads` +""" +function brute_force_scan(f, Q_re_range::NTuple{2,<:Real}, + Q_im_range::NTuple{2,<:Real}; + nre::Integer, nim::Integer, + threaded::Bool=true) + nre >= 2 || throw(ArgumentError("brute_force_scan: nre must be ≥ 2")) + nim >= 2 || throw(ArgumentError("brute_force_scan: nim must be ≥ 2")) + re_axis = collect(range(Float64(Q_re_range[1]); stop=Float64(Q_re_range[2]), + length=nre)) + im_axis = collect(range(Float64(Q_im_range[1]); stop=Float64(Q_im_range[2]), + length=nim)) + Q = ComplexF64[(qr + qi*im) for qr in re_axis, qi in im_axis] + Δ = Matrix{ComplexF64}(undef, nre, nim) + if threaded + # Pin BLAS to one thread for the parallel region — `f(Q)` calls `det()`, + # and concurrent multithreaded-BLAS calls give non-reproducible + # reductions that can flip the extracted root run-to-run. The residual + # matrices are tiny, so single-threaded BLAS costs nothing here. + _blas0 = BLAS.get_num_threads() + BLAS.set_num_threads(1) + try + Threads.@threads for j in 1:nim + for i in 1:nre + Δ[i, j] = f(Q[i, j]) + end + end + finally + BLAS.set_num_threads(_blas0) + end + else + for j in 1:nim, i in 1:nre + Δ[i, j] = f(Q[i, j]) + end + end + return ScanResult(Q, Δ, re_axis, im_axis) +end diff --git a/src/Tearing/Dispersion/ContourSearchAMR.jl b/src/Tearing/Dispersion/ContourSearchAMR.jl new file mode 100644 index 00000000..3533a177 --- /dev/null +++ b/src/Tearing/Dispersion/ContourSearchAMR.jl @@ -0,0 +1,634 @@ +# ContourSearchAMR.jl +# +# Cell-based adaptive mesh refinement scanner of the complex Q plane. +# +# Each `AMRCell` is an axis-aligned rectangle holding its 4 corner Q values +# and the corresponding Δ values evaluated by the user-supplied residual +# `f(Q)`. After `passes` refinement steps, every cell that brackets a zero +# in `Re(Δ)` or `Im(Δ)` has been subdivided into 4 quadrant children +# carrying 5 freshly evaluated midpoint Δ values. +# +# All evaluations of `f(Q)` are deduplicated through a `Dict{ComplexF64, +# ComplexF64}` hash cache so that adjacent cells sharing a corner (and +# adjacent refinement levels sharing an edge midpoint) cost only one +# evaluation. +# +# Output: `AMRResult` holds the final list of `AMRCell`s (preserving the +# axis-aligned-rectangle structure that downstream marching-squares contour +# extraction in `GrowthRateExtraction.jl` exploits) plus the flat +# (Q::Vector, Δ::Vector) of all unique evaluations. + +# Corner ordering: 1 = BL, 2 = BR, 3 = TL, 4 = TR. + +""" + AMRCell + +A single axis-aligned-rectangle cell of an AMR scan. The four corner Q +values (`q_bl`, `q_br`, `q_tl`, `q_tr`) and corresponding residual values +(`d_bl`, `d_br`, `d_tl`, `d_tr`) are sufficient for marching-squares +contour extraction. +""" +struct AMRCell + q_bl::ComplexF64 + q_br::ComplexF64 + q_tl::ComplexF64 + q_tr::ComplexF64 + d_bl::ComplexF64 + d_br::ComplexF64 + d_tl::ComplexF64 + d_tr::ComplexF64 +end + +""" + AMRResult + +Output of `amr_scan`. + +| field | meaning | +|:----------- |:-------------------------------------------------------- | +| `cells` | Final list of `AMRCell` after all refinement passes | +| `Q` | Flat `Vector{ComplexF64}` of every unique residual eval | +| `Δ` | Corresponding `Vector{ComplexF64}` of residual values | +| `truncated` | `true` if `max_cells` was hit and remaining refinement | +| | passes were skipped (`max_cells_action=:warn_truncate`). | +| | A `true` result is NOT fully converged — distinguish it | +| | from a converged scan in convergence studies. | +""" +struct AMRResult + cells::Vector{AMRCell} + Q::Vector{ComplexF64} + Δ::Vector{ComplexF64} + truncated::Bool +end + +# Back-compat constructor: a scan that ran to completion is not truncated. +AMRResult(cells::Vector{AMRCell}, Q::Vector{ComplexF64}, Δ::Vector{ComplexF64}) = + AMRResult(cells, Q, Δ, false) + +# Hash-cached residual evaluator. Returns the cached Δ value if `q` is +# already known, otherwise evaluates `f(q)`, stores it, and returns it. +@inline function _cached_eval!(cache::Dict{ComplexF64,ComplexF64}, + f, q::ComplexF64) + haskey(cache, q) && return cache[q] + Δ = ComplexF64(f(q)) + cache[q] = Δ + return Δ +end + +# Parallel-friendly bulk filler: given a list of Q values, evaluates the +# residual at each one that isn't already in `cache` and stores the result. +# When `parallel=true` AND more than one Julia thread is available, the +# evaluations run via `@threads`; the cache is populated serially afterward +# to avoid Dict data races. Per-call evaluations of `f` are assumed to be +# thread-safe (true for `mc_fort(Q)` which constructs its own local state). +function _bulk_eval_into_cache!(cache::Dict{ComplexF64,ComplexF64}, f, + qs::AbstractVector{ComplexF64}; + parallel::Bool) + # First pass: partition `qs` into already-cached vs new. Keep uniqueness. + seen = Set{ComplexF64}() + new_qs = Vector{ComplexF64}() + for q in qs + if !haskey(cache, q) && !(q in seen) + push!(new_qs, q) + push!(seen, q) + end + end + isempty(new_qs) && return + new_vals = Vector{ComplexF64}(undef, length(new_qs)) + if parallel && Threads.nthreads() > 1 + # Pin BLAS to one thread for the Julia-level parallel region. The + # residual `f(Q)` calls `det()` (LAPACK); concurrent multithreaded-BLAS + # calls from several Julia threads give non-reproducible reductions that + # perturb `f` at the ULP level — enough to flip the extracted root + # between near-degenerate solutions run-to-run. The residual matrices are + # tiny (≤ a few ×msing), so single-threaded BLAS costs nothing here. + _blas0 = BLAS.get_num_threads() + BLAS.set_num_threads(1) + try + Threads.@threads for k in eachindex(new_qs) + new_vals[k] = ComplexF64(f(new_qs[k])) + end + finally + BLAS.set_num_threads(_blas0) + end + else + @inbounds for k in eachindex(new_qs) + new_vals[k] = ComplexF64(f(new_qs[k])) + end + end + @inbounds for k in eachindex(new_qs) + cache[new_qs[k]] = new_vals[k] + end + return +end + +# Sign-crossing test: does `vals` straddle zero? Used in both Re and Im +# directions on a cell's 4 corners (mirrors check_cell_crossing_sub). +@inline function _crosses_zero(vals) + # A non-finite corner (NaN/Inf) marks a divergence — typically a pole, where + # the inner-layer solver returns a NaN sentinel. Flag such cells as active so + # they are refined / pre-screened rather than silently dropped: a NaN corner + # makes both the product test below and the `abs >= threshold` pole check + # evaluate to false, which would hide the pole region entirely. + any(!isfinite, vals) && return true + return minimum(vals) * maximum(vals) <= 0 +end + +# Subdivide a parent cell into 4 quadrants, evaluating Δ at the 5 +# midpoints (BM, TM, LM, RM, MM) via the hash cache. +function _subdivide_cell(parent::AMRCell, + cache::Dict{ComplexF64,ComplexF64}, f) + q_bm = 0.5 * (parent.q_bl + parent.q_br) + q_tm = 0.5 * (parent.q_tl + parent.q_tr) + q_lm = 0.5 * (parent.q_bl + parent.q_tl) + q_rm = 0.5 * (parent.q_br + parent.q_tr) + q_mm = 0.25 * (parent.q_bl + parent.q_br + parent.q_tl + parent.q_tr) + + d_bm = _cached_eval!(cache, f, q_bm) + d_tm = _cached_eval!(cache, f, q_tm) + d_lm = _cached_eval!(cache, f, q_lm) + d_rm = _cached_eval!(cache, f, q_rm) + d_mm = _cached_eval!(cache, f, q_mm) + + return ( + AMRCell(parent.q_bl, q_bm, q_lm, q_mm, # bottom-left quadrant + parent.d_bl, d_bm, d_lm, d_mm), + AMRCell(q_bm, parent.q_br, q_mm, q_rm, # bottom-right quadrant + d_bm, parent.d_br, d_mm, d_rm), + AMRCell(q_lm, q_mm, parent.q_tl, q_tm, # top-left quadrant + d_lm, d_mm, parent.d_tl, d_tm), + AMRCell(q_mm, q_rm, q_tm, parent.q_tr, # top-right quadrant + d_mm, d_rm, d_tm, parent.d_tr) + ) +end + +""" + amr_scan(f, Q_re_range, Q_im_range; + nre0, nim0, passes, + max_cells=10_000_000, + max_cells_action=:error, + snapshot_callback=nothing, + parallel=Threads.nthreads() > 1) -> AMRResult + +Adaptively refine a Q-plane scan of the residual `f(Q)`. An initial +`nre0 × nim0` axis-aligned grid of cells is built over `Q_re_range × Q_im_range` and `passes` rounds of refinement are applied. Each pass: + + 1. flags any cell whose 4 corner residuals straddle zero in `Re(Δ)` or + `Im(Δ)`; + 2. subdivides each flagged cell into 4 quadrant children, evaluating `f` + at 5 new midpoints; + 3. unflagged cells are kept unchanged. + +All evaluations of `f` are deduplicated through a `Dict{ComplexF64, ComplexF64}` hash cache so that adjacent cells share a single evaluation +per corner. The returned `AMRResult` carries both the final cell list (for +marching-squares contour extraction) and the flat list of all unique Q/Δ +evaluations. + +# Keyword arguments + + - `nre0`, `nim0` -- initial coarse-grid cell counts along each axis + - `passes` -- number of refinement passes + - `max_cells` -- safety cap on total cells; behavior on hit is set + by `max_cells_action` + - `max_cells_action` -- `:error` (raises) or `:warn_truncate` (logs a + warning and returns the partial result). The latter is useful for + convergence-vs-resolution studies where we deliberately push max_cells + and want graceful degradation. Default `:error` preserves the prior + safety-rail behaviour. + - `snapshot_callback` -- if not `nothing`, a function called after each + pass (and once for the initial grid, pass=0) with arguments + `(pass::Int, cells::Vector{AMRCell}, cache::Dict{ComplexF64,ComplexF64})`. + The callback receives live references — copy if you need persistence. + Used by convergence studies to extract intermediate γ at each pass count. + - `parallel` -- evaluate `f` in parallel via `Threads.@threads` within + each phase (initial grid + each refinement pass). Defaults to `true` + when more than one Julia thread is available. Per-call evaluations of + `f` must be thread-safe. The result is deterministic regardless of thread + count: cache updates and cell-list construction stay serial, AND BLAS is + pinned to one thread during the parallel region so the `det()`-based + residual does not pick up non-reproducible multithreaded-LAPACK reductions + (which otherwise flip the extracted root between near-degenerate solutions). +""" +function amr_scan(f, Q_re_range::NTuple{2,<:Real}, + Q_im_range::NTuple{2,<:Real}; + nre0::Integer, nim0::Integer, passes::Integer, + max_cells::Integer=10_000_000, + max_cells_action::Symbol=:error, + snapshot_callback::Union{Nothing,Function}=nothing, + parallel::Bool=Threads.nthreads() > 1) + nre0 >= 1 || throw(ArgumentError("amr_scan: nre0 must be ≥ 1")) + nim0 >= 1 || throw(ArgumentError("amr_scan: nim0 must be ≥ 1")) + passes >= 0 || throw(ArgumentError("amr_scan: passes must be ≥ 0")) + max_cells_action in (:error, :warn_truncate) || + throw(ArgumentError("amr_scan: max_cells_action must be :error or " * + ":warn_truncate, got :$max_cells_action")) + + re_lo, re_hi = Float64.(Q_re_range) + im_lo, im_hi = Float64.(Q_im_range) + re_step = (re_hi - re_lo) / nre0 + im_step = (im_hi - im_lo) / nim0 + + cache = Dict{ComplexF64,ComplexF64}() + + # ---- 1. coarse initial grid (nre0 × nim0 cells, (nre0+1)·(nim0+1) corners) + # Collect every corner Q, evaluate in parallel, then build the cells using + # cache lookups (no further evaluation happens in the build step). + ncorners_x = nre0 + 1 + ncorners_y = nim0 + 1 + corners = Vector{ComplexF64}(undef, ncorners_x * ncorners_y) + @inbounds for j in 0:nim0, i in 0:nre0 + corners[j*ncorners_x+i+1] = + ComplexF64(re_lo + i * re_step, im_lo + j * im_step) + end + _bulk_eval_into_cache!(cache, f, corners; parallel=parallel) + + cells = Vector{AMRCell}(undef, nre0 * nim0) + @inbounds for j in 0:nim0-1, i in 0:nre0-1 + # Read corner Q values from the same `corners` array used to populate + # the cache. Recomputing them with `x + re_step` here would differ in + # the last floating-point bit from the cache keys, causing spurious + # KeyErrors on lookup. + q_bl = corners[j*ncorners_x+i+1] + q_br = corners[j*ncorners_x+(i+1)+1] + q_tl = corners[(j+1)*ncorners_x+i+1] + q_tr = corners[(j+1)*ncorners_x+(i+1)+1] + cells[j*nre0+i+1] = AMRCell(q_bl, q_br, q_tl, q_tr, + cache[q_bl], cache[q_br], + cache[q_tl], cache[q_tr]) + end + + # Snapshot the initial grid (pass 0) before any refinement. + snapshot_callback === nothing || snapshot_callback(0, cells, cache) + + # ---- 2. refinement passes + truncated = false # set true when max_cells is hit and action == :warn_truncate + for pass_idx in 1:passes + truncated && break + # Phase A: identify flagged parent cells and collect the midpoints we + # need to evaluate. The 5 midpoints per parent (BM, TM, LM, RM, MM) + # mirror _subdivide_cell's coordinates exactly. + flagged_idx = Int[] + new_qs = Vector{ComplexF64}() + sizehint!(new_qs, length(cells)) + for (idx, cell) in enumerate(cells) + re_corners = (real(cell.d_bl), real(cell.d_br), + real(cell.d_tl), real(cell.d_tr)) + im_corners = (imag(cell.d_bl), imag(cell.d_br), + imag(cell.d_tl), imag(cell.d_tr)) + if _crosses_zero(re_corners) || _crosses_zero(im_corners) + push!(flagged_idx, idx) + push!(new_qs, 0.5 * (cell.q_bl + cell.q_br)) + push!(new_qs, 0.5 * (cell.q_tl + cell.q_tr)) + push!(new_qs, 0.5 * (cell.q_bl + cell.q_tl)) + push!(new_qs, 0.5 * (cell.q_br + cell.q_tr)) + push!(new_qs, 0.25 * (cell.q_bl + cell.q_br + + cell.q_tl + cell.q_tr)) + end + end + + # Phase B: evaluate all new midpoints in parallel, fill the cache. + _bulk_eval_into_cache!(cache, f, new_qs; parallel=parallel) + + # Phase C: build the refined cell list using cache lookups. + new_cells = Vector{AMRCell}() + sizehint!(new_cells, length(cells) + 3 * length(flagged_idx)) + flagged_set = Set(flagged_idx) + skip_remaining = false # true once max_cells is hit (warn_truncate path) + for (idx, cell) in enumerate(cells) + if idx in flagged_set && !skip_remaining + q_bm = 0.5 * (cell.q_bl + cell.q_br) + q_tm = 0.5 * (cell.q_tl + cell.q_tr) + q_lm = 0.5 * (cell.q_bl + cell.q_tl) + q_rm = 0.5 * (cell.q_br + cell.q_tr) + q_mm = 0.25 * (cell.q_bl + cell.q_br + + cell.q_tl + cell.q_tr) + d_bm = cache[q_bm] + d_tm = cache[q_tm] + d_lm = cache[q_lm] + d_rm = cache[q_rm] + d_mm = cache[q_mm] + push!(new_cells, + AMRCell(cell.q_bl, q_bm, q_lm, q_mm, + cell.d_bl, d_bm, d_lm, d_mm), + AMRCell(q_bm, cell.q_br, q_mm, q_rm, + d_bm, cell.d_br, d_mm, d_rm), + AMRCell(q_lm, q_mm, cell.q_tl, q_tm, + d_lm, d_mm, cell.d_tl, d_tm), + AMRCell(q_mm, q_rm, q_tm, cell.q_tr, + d_mm, d_rm, d_tm, cell.d_tr)) + else + push!(new_cells, cell) + end + if length(new_cells) > max_cells + if max_cells_action === :error + error( + "amr_scan: exceeded max_cells=$max_cells " * + "(currently $(length(new_cells))). Reduce " * + "`passes` or raise `max_cells`, or pass " * + "max_cells_action=:warn_truncate to truncate gracefully." + ) + else # :warn_truncate (validated at function entry) + @warn "amr_scan: max_cells=$max_cells reached at pass=$pass_idx cell=$idx/$(length(cells)); truncating refinement here and skipping remaining passes" + skip_remaining = true + truncated = true + end + end + end + cells = new_cells + # Snapshot after this pass. + snapshot_callback === nothing || snapshot_callback(pass_idx, cells, cache) + end + + # ---- 3. flatten the cache into output Q/Δ vectors + n = length(cache) + Q = Vector{ComplexF64}(undef, n) + Δ = Vector{ComplexF64}(undef, n) + for (k, (q, d)) in enumerate(cache) + Q[k] = q + Δ[k] = d + end + + return AMRResult(cells, Q, Δ, truncated) +end + +# ============================================================================= +# Multi-box AMR scan with pre-screen +# ============================================================================= +# +# Motivation. A single wide AMR box (e.g. ω ∈ [-100, +100] kHz, γ ∈ [-25, +25]) +# spends most of its evaluations on regions that contain neither roots nor +# poles. Splitting the same area into several smaller boxes and pre-screening +# each on a coarse 25×25 grid lets us skip refinement on inactive boxes +# entirely, while keeping full AMR sensitivity on the active ones. +# +# A box is flagged ACTIVE if any cell of its pre-screen grid satisfies AT LEAST +# ONE of: +# - sign change in Re(Δ) across the cell's 4 corners (zero-isoline of Re(Δ) +# crosses the cell — root candidate); +# - sign change in Im(Δ) across the cell's 4 corners (zero-isoline of Im(Δ) +# crosses the cell — root candidate); +# - any corner with |Δ| ≥ `pole_magnitude_threshold` (likely pole inside or +# near the box; sign-only criteria miss poles unless their fringe sign +# change happens to land inside the pre-screen resolution). +# +# The pole-magnitude criterion is essential: a tight pole tucked inside one +# pre-screen cell can leave all four corners with the same large-magnitude sign +# (because Re(Δ) and Im(Δ) flip together as you orbit the pole, and at the +# corners we may sample the same lobe), so the sign-change tests would miss it. + +""" + BoxActivity + +Why a box was retained or skipped by `multi_box_amr_scan`. `NoActivity` means +the pre-screen grid showed no zero-isoline crossings and no large-`|Δ|` +corners; the box is excluded from refinement. The other variants record which +criterion fired first. +""" +@enum BoxActivity NoActivity ReZeroCrossing ImZeroCrossing PoleMagnitude + +# Pre-screen activity check: scan the pre-built cells and return the first +# satisfied criterion (or NoActivity if none fire). Designed for early exit so +# fully-quiet boxes cost just enough cell scans to confirm. +function _check_box_activity(cells::AbstractVector{AMRCell}, + pole_magnitude_threshold::Real) + @inbounds for cell in cells + re_corners = (real(cell.d_bl), real(cell.d_br), + real(cell.d_tl), real(cell.d_tr)) + im_corners = (imag(cell.d_bl), imag(cell.d_br), + imag(cell.d_tl), imag(cell.d_tr)) + _crosses_zero(re_corners) && return ReZeroCrossing + _crosses_zero(im_corners) && return ImZeroCrossing + if max(abs(cell.d_bl), abs(cell.d_br), + abs(cell.d_tl), abs(cell.d_tr)) >= pole_magnitude_threshold + return PoleMagnitude + end + end + return NoActivity +end + +""" + MultiBoxAMRResult + +Output of `multi_box_amr_scan`. Per-box `AMRResult`s plus the aggregated +cells/Q/Δ across all *active* boxes. Pre-screen-inactive boxes have `nothing` +for their `AMRResult` and contribute nothing to the aggregated arrays. + +| field | meaning | +|:----------------- |:------------------------------------------------------ | +| `box_results` | per-box `AMRResult`, or `nothing` if box was skipped | +| `box_activity` | per-box `BoxActivity` enum | +| `cells` | concatenated `AMRCell`s from all active boxes | +| `Q` | union of all unique `Q` evaluations (active + skipped) | +| `Δ` | corresponding `Δ` values | +| `prescreen_evals` | total `f(Q)` evaluations spent on pre-screening | + +The aggregated `(cells, Q, Δ)` are suitable for direct consumption by +`find_growth_rates`. Pre-screen evaluations are still included in `Q`/`Δ` even +for skipped boxes, so any downstream pole-magnitude diagnostic that uses the +flat residual list sees the full sample. +""" +struct MultiBoxAMRResult + box_results::Vector{Union{Nothing,AMRResult}} + box_activity::Vector{BoxActivity} + cells::Vector{AMRCell} + Q::Vector{ComplexF64} + Δ::Vector{ComplexF64} + prescreen_evals::Int +end + +""" + multi_box_amr_scan(f, boxes; + pole_magnitude_threshold, + prescreen_nre=25, prescreen_nim=25, + nre0=25, nim0=25, passes=4, + max_cells=10_000_000, + max_cells_action=:error, + parallel=Threads.nthreads() > 1) -> MultiBoxAMRResult + +Run `amr_scan` over multiple Q-plane boxes with a coarse pre-screen step that +skips inactive boxes entirely. The typical use case is the three-stripe ω-axis +scan for SLAYER coupled tearing dispersion: + + ω ∈ [-75, -25], γ ∈ [-25, +25] (left stripe) + ω ∈ [-25, +25], γ ∈ [-25, +25] (centre stripe) + ω ∈ [+25, +75], γ ∈ [-25, +25] (right stripe) + +A single 150×50 box is wasteful when the dispersion is concentrated near a +narrow ω band; splitting into stripes and pre-screening lets the AMR effort +land on the active stripe. + +# Pre-screen logic + +Each box is sampled on a `prescreen_nre × prescreen_nim` corner grid (default +25×25, matching the typical AMR initial-grid resolution). A box is ACTIVE if +ANY pre-screen cell satisfies at least one criterion: + + 1. sign change of `Re(Δ)` across the cell's 4 corners (zero-isoline of + `Re(Δ)` crosses the cell — root candidate); + 2. sign change of `Im(Δ)` across the cell's 4 corners (zero-isoline of + `Im(Δ)` crosses the cell — root candidate); + 3. any corner with `|Δ| ≥ pole_magnitude_threshold` (likely pole — the + sign-only criteria miss poles whose fringe doesn't straddle a corner). + +Active boxes get the full `amr_scan` treatment. Inactive boxes are dropped +(their `AMRResult` is `nothing`). + +# Arguments + + - `f`: residual function `Q::ComplexF64 → Δ::ComplexF64`. Must be thread-safe + if `parallel=true`. + - `boxes`: vector of `(Q_re_range, Q_im_range)` tuples, one per box. Boxes + may overlap or share boundaries; the aggregator deduplicates Q values. + +# Required keyword + + - `pole_magnitude_threshold`: activity threshold for `|Δ|`. A natural choice + is `≈ |mean(Δ)|` from a baseline (or the same value used for adaptive + pole_threshold in `find_growth_rates`). + +# Optional keywords + + - `prescreen_nre`, `prescreen_nim` (default 25 each): pre-screen grid + resolution. Coarser misses small features; finer wastes evaluations on + inactive boxes. + - `nre0, nim0, passes, max_cells, max_cells_action, parallel`: forwarded to + each per-box `amr_scan` call. Defaults match `amr_scan`. + +# Returns + +A `MultiBoxAMRResult`. The aggregated `(cells, Q, Δ)` can be wrapped in an +`AMRResult` (helper `as_amr_result` below) for direct use with +`find_growth_rates`. + +# Notes / TODO + + - Each per-box `amr_scan` rebuilds its own cache, so the 25×25 pre-screen + corners get re-evaluated by the AMR initial pass on active boxes + (≈ 676 wasted evals per active box). A future refactor could thread a + shared cache through `amr_scan`. For now the cost is small relative to + the AMR refinement evals. + - Boxes that share a boundary line (e.g. the three ω-stripe layout above) + duplicate ≈ `prescreen_nim+1` corner evaluations per shared edge. Also + small. + +# Example + +```julia +boxes = [((-75.0, -25.0), (-25.0, 25.0)), + ((-25.0, 25.0), (-25.0, 25.0)), + ((25.0, 75.0), (-25.0, 25.0))] +result = multi_box_amr_scan(f_residual, boxes; + pole_magnitude_threshold=1e-3, + prescreen_nre=25, prescreen_nim=25, + nre0=25, nim0=25, passes=4) +amr = AMRResult(result.cells, result.Q, result.Δ) +roots = find_growth_rates(amr, tauk; pole_threshold=1e-3) +``` +""" +function multi_box_amr_scan(f, + boxes::AbstractVector; + pole_magnitude_threshold::Real, + prescreen_nre::Integer=25, prescreen_nim::Integer=25, + nre0::Integer=25, nim0::Integer=25, passes::Integer=4, + max_cells::Integer=10_000_000, + max_cells_action::Symbol=:error, + parallel::Bool=Threads.nthreads() > 1) + prescreen_nre >= 1 || throw(ArgumentError("multi_box_amr_scan: prescreen_nre must be ≥ 1")) + prescreen_nim >= 1 || throw(ArgumentError("multi_box_amr_scan: prescreen_nim must be ≥ 1")) + pole_magnitude_threshold >= 0 || + throw(ArgumentError("multi_box_amr_scan: pole_magnitude_threshold must be ≥ 0")) + + n_boxes = length(boxes) + box_results = Vector{Union{Nothing,AMRResult}}(undef, n_boxes) + box_activity = Vector{BoxActivity}(undef, n_boxes) + prescreen_evals_total = 0 + + # Aggregator: dedupe Q/Δ across all per-box caches and the pre-screen samples. + # Using a Dict keyed by Q gives O(1) dedup and lets us merge results in any + # order. We also collect cells (from active boxes only) for downstream + # marching-squares extraction. + qd_aggregate = Dict{ComplexF64,ComplexF64}() + cells_aggregate = AMRCell[] + + for (b_idx, box) in enumerate(boxes) + Q_re_range, Q_im_range = box + re_lo, re_hi = Float64.(Q_re_range) + im_lo, im_hi = Float64.(Q_im_range) + re_step = (re_hi - re_lo) / prescreen_nre + im_step = (im_hi - im_lo) / prescreen_nim + ncorners_x = prescreen_nre + 1 + ncorners_y = prescreen_nim + 1 + + # Pre-screen corners for THIS box. Local cache so we can both drive the + # activity check and feed into the aggregate without polluting an + # eventual per-box AMR cache. + box_cache = Dict{ComplexF64,ComplexF64}() + corners = Vector{ComplexF64}(undef, ncorners_x * ncorners_y) + @inbounds for j in 0:prescreen_nim, i in 0:prescreen_nre + corners[j*ncorners_x+i+1] = + ComplexF64(re_lo + i * re_step, im_lo + j * im_step) + end + _bulk_eval_into_cache!(box_cache, f, corners; parallel=parallel) + prescreen_evals_total += length(box_cache) + + # Build pre-screen cells + ps_cells = Vector{AMRCell}(undef, prescreen_nre * prescreen_nim) + @inbounds for j in 0:prescreen_nim-1, i in 0:prescreen_nre-1 + q_bl = corners[j*ncorners_x+i+1] + q_br = corners[j*ncorners_x+(i+1)+1] + q_tl = corners[(j+1)*ncorners_x+i+1] + q_tr = corners[(j+1)*ncorners_x+(i+1)+1] + ps_cells[j*prescreen_nre+i+1] = + AMRCell(q_bl, q_br, q_tl, q_tr, + box_cache[q_bl], box_cache[q_br], + box_cache[q_tl], box_cache[q_tr]) + end + + # Activity check + activity = _check_box_activity(ps_cells, pole_magnitude_threshold) + box_activity[b_idx] = activity + + # Merge pre-screen evals into aggregate (for both active and skipped + # boxes — diagnostics see all samples). + for (q, d) in box_cache + qd_aggregate[q] = d + end + + if activity == NoActivity + box_results[b_idx] = nothing + else + res = amr_scan(f, Q_re_range, Q_im_range; + nre0=nre0, nim0=nim0, passes=passes, + max_cells=max_cells, + max_cells_action=max_cells_action, + parallel=parallel) + box_results[b_idx] = res + append!(cells_aggregate, res.cells) + for k in eachindex(res.Q) + qd_aggregate[res.Q[k]] = res.Δ[k] + end + end + end + + # Flatten aggregator + n = length(qd_aggregate) + Q_all = Vector{ComplexF64}(undef, n) + Δ_all = Vector{ComplexF64}(undef, n) + for (k, (q, d)) in enumerate(qd_aggregate) + Q_all[k] = q + Δ_all[k] = d + end + + return MultiBoxAMRResult(box_results, box_activity, cells_aggregate, + Q_all, Δ_all, prescreen_evals_total) +end + +""" + as_amr_result(mbres::MultiBoxAMRResult) -> AMRResult + +Wrap the aggregated cells/Q/Δ from a multi-box scan as a plain `AMRResult` so +it can be passed directly to `find_growth_rates(::AMRResult, tauk; ...)`. +""" +as_amr_result(mbres::MultiBoxAMRResult) = + AMRResult(mbres.cells, mbres.Q, mbres.Δ, + any(r -> r !== nothing && r.truncated, mbres.box_results)) diff --git a/src/Tearing/Dispersion/Coupled.jl b/src/Tearing/Dispersion/Coupled.jl new file mode 100644 index 00000000..4029943b --- /dev/null +++ b/src/Tearing/Dispersion/Coupled.jl @@ -0,0 +1,110 @@ +# Coupled.jl +# +# Multi-surface coupled tearing dispersion residual `det(M(Q))` (the +# coupled, tearing-only reduction of the full Pletzer-Dewar matching in +# `CoupledFullMatch.jl`). Shares the per-surface `SurfaceCoupling` +# Q-callable interface so a brute-force or AMR scan can evaluate either +# residual identically. +# +# Construction: +# +# mc = multi_surface_coupling(surfaces, dp_matrix; ref_idx=1, msing_max=...) +# +# Evaluation: +# +# det = mc(Q::ComplexF64) +# +# At each evaluation, for k = 1 .. msing_max, the inner-layer Δ is computed +# at a Q rescaled by `tauk_ref / tauk_k`, then subtracted (with the dc +# offset) from the diagonal of an `msing_max × msing_max` upper-left +# submatrix of `dp_matrix`. The off-diagonal Δ' couplings are passed +# through unchanged. + +""" + MultiSurfaceCoupling{V<:AbstractVector{<:SurfaceCoupling}} + +Multi-surface dispersion data: a vector of `SurfaceCoupling`, the full Δ' +matrix, the index of the reference surface (whose `tauk` defines the Q +normalization), and the truncation `msing_max` (number of surfaces actually +participating in the determinant). Calling `mc(Q)` returns `det(M(Q))` where + +``` +M[k,k] = dp_matrix[k,k] - scale_k · Δ_inner_k(Q · tauk_ref / tauk_k) - dc_k +M[i,j] = dp_matrix[i,j] for i ≠ j (off-diagonal Δ' couplings) +``` + +A root of `mc` in the complex `Q` plane is a coupled tearing eigenvalue. +""" +struct MultiSurfaceCoupling{V<:AbstractVector{<:SurfaceCoupling}} + surfaces::V + dp_matrix::Matrix{ComplexF64} + ref_idx::Int + msing_max::Int +end + +""" + multi_surface_coupling(surfaces, dp_matrix; + ref_idx=1, + msing_max=min(3, length(surfaces))) + -> MultiSurfaceCoupling + +Construct a multi-surface coupling from a vector of `SurfaceCoupling` and +the full outer-region Δ' matrix. `dp_matrix` must be square with side +length `length(surfaces)` (it is the same matrix returned by +`PerturbedEquilibrium.SingularCoupling`'s Δ' boundary-value problem). + +# Keyword arguments + + - `ref_idx` -- index of the reference surface whose `tauk` defines the + Q normalization. Defaults to `1`. + - `msing_max` -- number of surfaces from the front of `surfaces` to + include in the determinant. Defaults to `min(3, length(surfaces))`: + Δ' off-diagonal couplings beyond the third surface tend to be erratic + in practice, so the determinant is conservatively truncated to the + upper-left `msing_max × msing_max` submatrix of `dp_matrix`. Set + explicitly (up to `length(surfaces)`) to override. +""" +function multi_surface_coupling(surfaces::AbstractVector{<:SurfaceCoupling}, + dp_matrix::AbstractMatrix; + ref_idx::Integer=1, + msing_max::Integer=min(3, length(surfaces))) + n = length(surfaces) + size(dp_matrix) == (n, n) || + throw(ArgumentError("multi_surface_coupling: dp_matrix size " * + "$(size(dp_matrix)) ≠ ($n, $n)")) + 1 <= ref_idx <= n || + throw(ArgumentError("multi_surface_coupling: ref_idx=$ref_idx out " * + "of range 1:$n")) + 1 <= msing_max <= n || + throw(ArgumentError("multi_surface_coupling: msing_max=$msing_max " * + "out of range 1:$n")) + return MultiSurfaceCoupling(surfaces, + Matrix{ComplexF64}(dp_matrix), + Int(ref_idx), Int(msing_max)) +end + +function (mc::MultiSurfaceCoupling)(Q::Number) + n = mc.msing_max + Qc = ComplexF64(Q) + ref_tauk = mc.surfaces[mc.ref_idx].tauk + + M = mc.dp_matrix[1:n, 1:n] + @inbounds for k in 1:n + sc = mc.surfaces[k] + Q_k = Qc * (ref_tauk / sc.tauk) + # m×m scalar coupling: use only the tearing channel. The + # interchange (Glasser-stabilization) channel is carried in the + # full 4m×4m dispersion in `CoupledFullMatch.jl`; this reduced + # form is equivalent for pressureless SLAYER surfaces + # (Δ_interchange=0) and approximate for GGJ surfaces (drops + # Glasser stabilization). + Δ_k = solve_inner(sc.model, sc.params, Q_k).tearing * sc.scale + # The inner-layer solver returns NaN when the Riccati integration fails + # (clustered near poles). Propagate it as the residual so the scan flags + # this Q-cell via its isfinite checks, rather than feeding NaN into the + # LU factorization where it silently contaminates the whole determinant. + isfinite(Δ_k) || return ComplexF64(NaN, NaN) + M[k, k] -= Δ_k + sc.dc + end + return det(M) +end diff --git a/src/Tearing/Dispersion/CoupledFullMatch.jl b/src/Tearing/Dispersion/CoupledFullMatch.jl new file mode 100644 index 00000000..8bf5fb97 --- /dev/null +++ b/src/Tearing/Dispersion/CoupledFullMatch.jl @@ -0,0 +1,214 @@ +# CoupledFullMatch.jl +# +# Full Pletzer-Dewar 4m × 4m tearing+interchange dispersion matrix, with +# the m inner-layer resonances decoupled via the matching-identity rows +# +# C^j_L = d^j_+ − d^j_- +# C^j_R = -(d^j_+ + d^j_-) +# +# (see Wang-Glasser-Brennan-Liu-Park 2020, Phys. Plasmas **27**, 122503, +# Eq. (11a)-(11d) and Glasser-Wang-Park 2016, Phys. Plasmas **23**, 112506, +# Eq. (36)-(40); original matching construction Pletzer & Dewar 1991, +# J. Plasma Phys. **45**, 427). +# +# Why 4m × 4m and not 2m × 2m? +# +# The outer-region matching matrix D' (Julia `intr.delta_prime_raw`) is +# expressed in the side-major basis `[L_s1, R_s1, L_s2, R_s2, …]` of +# large-solution driving amplitudes. The inner-layer Galerkin solver +# (`solve_inner(GGJModel, …)`) returns Δ_tearing and Δ_interchange in +# the even/odd parity (+/−) basis instead. The naive relation +# `det(D' − diag(Δ_+, Δ_-)) = 0` cannot be written directly because +# the two quantities live in different bases. The fix is to introduce +# both sets of amplitudes (`C^j_{L,R}` for outer, `d^j_±` for inner) as +# explicit unknowns and use the ±1 matching identity as two extra rows +# per surface, yielding the 4m × 4m linear system. A naive 2m × 2m +# `det(D' − diag(Δ_+, Δ_-))` form cannot work here: it subtracts the +# inner Δ (parity ± basis) from the outer D' (side-major L/R basis), two +# quantities living in different bases, producing a determinant with +# structurally-wrong magnitude and topology. This module reproduces the +# full Pletzer-Dewar result. +# +# Per surface `k` (1-indexed), the 4 block indices are +# +# idx1 = 2k − 1 (row/col for C^k_L) +# idx2 = 2k (row/col for C^k_R) +# idx3 = idx1 + 2m (row/col for d^k_+) +# idx4 = idx2 + 2m (row/col for d^k_-) +# +# The global 4m × 4m matrix has: +# +# - lower-left 2m × 2m block = transpose(dp_raw) +# - upper-left 2m × 2m block: per-surface 2 × 2 identity +# - upper-right 2m × 2m block: per-surface 2 × 2 matching identity +# - lower-right 2m × 2m block: per-surface 2 × 2 inner Δ block +# +# See the per-surface fill table in the body of `(::MultiSurfaceCouplingFull)`. + +""" + MultiSurfaceCouplingFull{V<:AbstractVector{<:SurfaceCoupling}} + +Full 4m × 4m tearing+interchange Pletzer-Dewar dispersion matrix +(Wang et al. 2020, Phys. Plasmas **27**, 122503). + +Given the raw 2m × 2m outer-region matrix `dp_raw` (side-major ordering +`[L_s1, R_s1, L_s2, R_s2, …]`, from `intr.delta_prime_raw`) and a vector +of `SurfaceCoupling` (each containing the inner-layer model and +parameters), calling `mc(Q)` assembles the 4m × 4m Pletzer-Dewar +matching matrix and returns `det(mat)`. + +This is the correct Pletzer-Dewar dispersion relation for +tearing+interchange coupling. A naive 2m × 2m `det(D' − D(γ))` form is +not equivalent: it subtracts the inner Δ (parity ± basis) from the outer +D' (side-major L/R basis), mixing two different bases. The 4m × 4m +matching system introduced here keeps the bases separate via the explicit +`C^j_{L,R}` / `d^j_±` unknowns. For pure-tearing (pressureless SLAYER) +studies use the reduced m × m `MultiSurfaceCoupling` instead. + +# Fields + + - `surfaces::V` — per-surface `SurfaceCoupling`. + - `dp_raw::Matrix{ComplexF64}` — 2m × 2m outer-region matrix (side-major). + - `ref_idx::Int` — reference surface for Q rescaling (1-based). + - `msing_max::Int` — number of surfaces to include (truncates). + - `rotation::Vector{Float64}` — per-surface rotation frequencies (s⁻¹). + - `ntor::Int` — toroidal mode number `n` (default 1). +""" +struct MultiSurfaceCouplingFull{V<:AbstractVector{<:SurfaceCoupling},K<:NamedTuple} + surfaces::V + dp_raw::Matrix{ComplexF64} + ref_idx::Int + msing_max::Int + rotation::Vector{Float64} + ntor::Int + inner_kwargs::K # kwargs forwarded to solve_inner; e.g. (pfac=0.1, nx=128, nq=5) +end + +""" + multi_surface_coupling_full(surfaces, dp_raw; + ref_idx=1, + msing_max=length(surfaces), + rotation=zeros(length(surfaces)), + ntor=1) -> MultiSurfaceCouplingFull + +Construct the 4m × 4m dispersion matrix driver. `dp_raw` must be the +2m × 2m matrix in side-major ordering (the `intr.delta_prime_raw` +field populated by `ForceFreeStates.compute_delta_prime_matrix!` on the +`use_parallel=true` path). `rotation[k]` is the per-surface rotation +frequency; it shifts the per-surface inner Q argument by +`i·ntor·rotation[k]`. Default zero rotation matches the static-equilibrium +case. + +# Keyword arguments + + - `ref_idx` — index of the reference surface whose `tauk` defines the + Q normalization (1 ≤ ref_idx ≤ m). Defaults to 1. + - `msing_max` — truncate to the leading `msing_max` surfaces; the + matching matrix becomes 4·msing_max × 4·msing_max, built from the + corresponding 2·msing_max × 2·msing_max submatrix of `dp_raw`. + Defaults to `length(surfaces)`. + - `rotation` — per-surface rotation frequencies in s⁻¹ (length m). + Defaults to all zero. + - `ntor` — toroidal mode number n. Defaults to 1. + - `inner_kwargs` — NamedTuple of kwargs forwarded to `solve_inner` at + every Q evaluation, e.g. `(pfac=0.1, xfac=10.0, nx=128, nq=5)` for + Galerkin grid tuning. Defaults to `NamedTuple()`. +""" +function multi_surface_coupling_full(surfaces::AbstractVector{<:SurfaceCoupling}, + dp_raw::AbstractMatrix; + ref_idx::Integer=1, + msing_max::Integer=length(surfaces), + rotation::AbstractVector{<:Real}=zeros(length(surfaces)), + ntor::Integer=1, + inner_kwargs::NamedTuple=NamedTuple()) + m = length(surfaces) + size(dp_raw) == (2m, 2m) || + throw(ArgumentError("multi_surface_coupling_full: dp_raw size " * + "$(size(dp_raw)) ≠ ($(2m), $(2m))")) + 1 <= ref_idx <= m || + throw(ArgumentError("multi_surface_coupling_full: ref_idx=$ref_idx " * + "out of range 1:$m")) + 1 <= msing_max <= m || + throw(ArgumentError("multi_surface_coupling_full: msing_max=$msing_max " * + "out of range 1:$m")) + length(rotation) == m || + throw(ArgumentError("multi_surface_coupling_full: rotation length " * + "$(length(rotation)) ≠ $m")) + return MultiSurfaceCouplingFull(surfaces, + Matrix{ComplexF64}(dp_raw), + Int(ref_idx), Int(msing_max), + Float64.(collect(rotation)), + Int(ntor), + inner_kwargs) +end + +# Assemble and return det(mat) where mat is the 4·msing_max × 4·msing_max +# Pletzer-Dewar matching matrix (Wang et al. 2020, Eq. 11; Glasser-Wang-Park +# 2016, Eqs. 36-40). +function (mc::MultiSurfaceCouplingFull)(Q::Number) + m = mc.msing_max + s2 = 2m + s4 = 4m + Qc = ComplexF64(Q) + ref_tauk = mc.surfaces[mc.ref_idx].tauk + + # Allocate the matching matrix and fill the lower-left 2m × 2m block + # with transpose(dp_raw[1:s2, 1:s2]). + mat = zeros(ComplexF64, s4, s4) + @views mat[s2+1:s4, 1:s2] .= transpose(mc.dp_raw[1:s2, 1:s2]) + + # Per-surface inner-layer assembly + @inbounds for k in 1:m + sc = mc.surfaces[k] + idx1 = 2k - 1 # C^k_L + idx2 = 2k # C^k_R + idx3 = idx1 + s2 # d^k_+ + idx4 = idx2 + s2 # d^k_- + + # Per-surface Q shift: guess_modify = Q + i·n·rotation[k]. + # Also apply ref_tauk / sc.tauk rescaling (we keep the SurfaceCoupling + # tauk normalization that SLAYER needs; GGJ has tauk=1 so it's a no-op). + Q_k = Qc * (ref_tauk / sc.tauk) + 1im * mc.ntor * mc.rotation[k] + resp = solve_inner(sc.model, sc.params, Q_k; mc.inner_kwargs...) + + # delta1 = interchange (parity −), delta2 = tearing (parity +); named + # fields expose the two channels directly. sc.scale converts inner-basis + # Δ to outer units (1.0 for GGJ since rescale_delta is applied inside + # solve_inner; S^(1/3) for SLAYER). + # + # NOTE: the fulldomain matching does NOT add any Δ_crit offset here — + # delta1, delta2 are the raw inner-layer outputs. The full 4m×4m + # Pletzer-Dewar residual includes the interchange channel, which + # provides Glasser (Mercier) stabilization natively; Δ_crit is a + # slab-layer proxy only relevant to SLAYER's tearing-only model. + delta1 = resp.interchange * sc.scale + delta2 = resp.tearing * sc.scale + # The inner-layer solver returns NaN when the Riccati integration fails + # (clustered near poles). Propagate it as the residual so the scan flags + # this Q-cell via its isfinite checks, rather than feeding NaN into the + # LU factorization where it silently contaminates the whole determinant. + (isfinite(delta1) && isfinite(delta2)) || return ComplexF64(NaN, NaN) + + # --- Upper-left 2×2 block: per-surface identity on C_{L,R} --- + mat[idx1, idx1] = 1 + mat[idx2, idx2] = 1 + + # --- Upper-right 2×2 block: matching identity --- + # C^k_L = d^k_+ − d^k_- ⇒ mat[idx1,idx3]=-1, mat[idx1,idx4]=+1 + # C^k_R = -(d^k_+ + d^k_-) ⇒ mat[idx2,idx3]=-1, mat[idx2,idx4]=-1 + mat[idx1, idx3] = -1 + mat[idx1, idx4] = 1 + mat[idx2, idx3] = -1 + mat[idx2, idx4] = -1 + + # --- Lower-right 2×2 block: inner Δ matching --- + # d^k_+ eqn: -Δ_int·d^k_+ + Δ_tear·d^k_- + (outer D' terms) = 0 + # d^k_- eqn: -Δ_int·d^k_+ - Δ_tear·d^k_- + (outer D' terms) = 0 + mat[idx3, idx3] = -delta1 + mat[idx3, idx4] = delta2 + mat[idx4, idx3] = -delta1 + mat[idx4, idx4] = -delta2 + end + + return det(mat) +end diff --git a/src/Tearing/Dispersion/Dispersion.jl b/src/Tearing/Dispersion/Dispersion.jl new file mode 100644 index 00000000..67731e5a --- /dev/null +++ b/src/Tearing/Dispersion/Dispersion.jl @@ -0,0 +1,55 @@ +# Dispersion.jl +# +# Tearing-dispersion-relation solver shared between GGJ and SLAYER inner-layer +# models. Combines the outer-region Δ' from `PerturbedEquilibrium.SingularCoupling` +# with the inner-layer Δ(Q) from any `InnerLayerModel` to find growth-rate +# eigenvalues. +# +# Operating modes: +# - `SurfaceCoupling` -- per-surface residual r(Q) +# - `multi_surface_coupling` -- multi-surface determinant (Coupled.jl) +# - `brute_force_scan` -- regular 2D Q-plane scan +# - `find_growth_rates` -- contour-intersection root extraction (Re=0 ∩ Im=0) +# - `amr_scan` -- adaptive Q-plane refinement +# +# Roots are ISOLATED by 2D contour intersection on Nyquist-style Q-plane scans +# (`find_growth_rates`, Re=0 ∩ Im=0), then optionally POLISHED to the true zero +# of the residual by a bounded, neighbour-aware local solve (pass the residual +# callable to `find_growth_rates`). Polishing makes the extracted root +# resolution- and thread-independent; without it the reported root is the +# marching-squares estimate, sensitive to grid/rounding. This module provides +# both the residual building blocks and the scan/extraction machinery. +# +# The per-surface residual at one rational surface is +# +# r(Q) = Δ'_diag - scale · Δ_inner(Q) - Δ_crit +# +# where `scale` is the inner→outer-units conversion factor (S^(1/3) for SLAYER, +# 1 for GGJ since `rescale_delta` is applied internally) and `Δ_crit` is the +# `dc_tmp` chi-parallel offset (zero by default). + +module Dispersion + +using LinearAlgebra +using StaticArrays + +using ..InnerLayer +using ..InnerLayer: InnerLayerModel, solve_inner, GGJModel, GGJParameters, + SLAYERModel, SLAYERParameters + +include("SurfaceCoupling.jl") +include("Coupled.jl") +include("CoupledFullMatch.jl") +include("BruteForceScan.jl") +include("ContourSearchAMR.jl") +include("GrowthRateExtraction.jl") + +export SurfaceCoupling, surface_coupling +export MultiSurfaceCoupling, multi_surface_coupling +export MultiSurfaceCouplingFull, multi_surface_coupling_full +export ScanResult, brute_force_scan +export AMRCell, AMRResult, amr_scan +export BoxActivity, MultiBoxAMRResult, multi_box_amr_scan, as_amr_result +export GrowthRateResult, find_growth_rates + +end # module Dispersion diff --git a/src/Tearing/Dispersion/GrowthRateExtraction.jl b/src/Tearing/Dispersion/GrowthRateExtraction.jl new file mode 100644 index 00000000..42c2c968 --- /dev/null +++ b/src/Tearing/Dispersion/GrowthRateExtraction.jl @@ -0,0 +1,992 @@ +# GrowthRateExtraction.jl +# +# Extracts tearing-mode growth rates by contour intersection (Re=0 ∩ Im=0) on a +# complex Q-plane scan: finds intersections of the Re(Δ)=0 and Im(Δ)=0 contours, +# classifies each intersection as a root or pole, and applies the "outside Re=0 +# contour, above pole" filter for spurious upper-branch roots. +# +# This PR (5/9) handles the regular-grid path via Contour.jl. PR 6 will add +# a scattered-data path (triangulation) for AMR scans. +# +# Algorithm summary: +# 1. Extract Re(Δ) = re_target and Im(Δ) = im_target contour polylines. +# 2. Find all segment-segment intersections of the two contour families. +# 3. For each intersection, find the closest Im=0 contour and classify as +# a pole if `max(|Re(Δ)|)` along the local arc exceeds `pole_threshold`. +# 4. For each non-pole intersection, find the closest Re=0 contour. If +# that contour is approximately closed, take a small +γ step along the +# Im=0 contour and test whether the step lands inside the Re=0 loop. +# Roots whose +γ step exits the loop AND that lie above the highest +# pole are filtered out (spurious upper branches). +# 5. Return the highest-γ surviving root in physical units. + +using Contour +using DelaunayTriangulation + +# --------------------------------------------------------------------- +# Public result struct + main entry point. +# --------------------------------------------------------------------- + +""" + GrowthRateResult + +Output of `find_growth_rates`. + +| field | meaning | +|:-------------------- |:----------------------------------------------------- | +| `Q_root` | Best (highest-γ surviving) root, normalized | +| `omega_Hz` | `Re(Q_root) / tauk` — physical rotation frequency | +| `gamma_Hz` | `Im(Q_root) / tauk` — physical growth rate | +| `Q_root_secondary` | Second-most-unstable root flagged for ambiguity, or | +| | `NaN+NaNim` if the primary root was unambiguous. | +| `omega_Hz_secondary` | physical ω of the secondary root, or 0 if none | +| `gamma_Hz_secondary` | physical γ of the secondary root, or 0 if none | +| `warning_flags` | `Vector{Symbol}` of warnings raised on `Q_root`: | +| | `:geom`, `:gap` (root accepted with caveat); | +| | `:spurious` when ≥1 contour near-miss was dropped by | +| | the validity gate (parked in `filtered_roots`); or | +| | `:no_root` when NO usable root was found (`Q_root` is | +| | `NaN`; `omega_Hz`/`gamma_Hz` fall back to 0 — check | +| | this flag to tell that apart from a true γ≈0 result). | +| | Empty if root is clean. | +| `valid_roots` | All non-pole intersections that survived pole filter | +| `poles` | Intersections classified as poles | +| `filtered_roots` | Intersections rejected by the above-pole/outside-Re | +| | filter or the new geom+gap recursion | +| `re_contours` | Extracted Re(Δ)=`re_target` polylines | +| `im_contours` | Extracted Im(Δ)=`im_target` polylines | +| `pole_threshold` | Threshold used for pole classification | +""" +struct GrowthRateResult + Q_root::ComplexF64 + omega_Hz::Float64 + gamma_Hz::Float64 + Q_root_secondary::ComplexF64 + omega_Hz_secondary::Float64 + gamma_Hz_secondary::Float64 + warning_flags::Vector{Symbol} + valid_roots::Vector{ComplexF64} + poles::Vector{ComplexF64} + filtered_roots::Vector{ComplexF64} + re_contours::Vector{Vector{ComplexF64}} + im_contours::Vector{Vector{ComplexF64}} + pole_threshold::Float64 +end + +""" + find_growth_rates(scan::ScanResult, tauk::Real; + re_target=0.0, im_target=0.0, + pole_threshold=10.0, + filter_above_poles=true, + filter_outside_re=true, + gap_kHz_threshold=1.0) -> GrowthRateResult + +Extract tearing growth-rate eigenvalues from a brute-force `ScanResult` by +contour-intersection analysis. `tauk` is the per-surface time normalization +used to convert `Q` back to physical (Hz) units (`SurfaceCoupling.tauk` for +single-surface scans; `mc.surfaces[mc.ref_idx].tauk` for coupled scans). + +# Keyword arguments + + - `re_target`, `im_target` -- contour levels (zero for vanilla dispersion + root-finding; nonzero values let the caller probe iso-residual contours) + - `pole_threshold` -- intersection is classified as a pole when + `max(|Re(Δ)|)` along the local arc of the nearest Im=0 contour exceeds + this value + - `filter_above_poles` -- discard roots whose γ exceeds the highest pole γ + - `filter_outside_re` -- restrict the above-pole rejection to roots whose + +γ step along the Im=0 contour exits the Re=0 contour loop. When `true`, + roots that are above a pole but geometrically inside the Re=0 contour + survive (matches the Python default). Note this gate fails when the + Re=0 contour is OPEN (e.g., exits the Q box edge), letting spurious + upper-branch roots through; the `:geom` and `:gap` flags below cover + that case. + - `gap_kHz_threshold` -- if the highest-γ root is unstable (γ > 0) AND its + γ exceeds the next root by more than this many kHz, it is flagged as + a `:gap` warning. Default 1.0 kHz. + - `residual` -- optional dispersion-residual callable `f(Q::Complex)`. When + supplied, each contour-intersection root is POLISHED to the true zero of + `f` by a bounded, neighbour-aware local solve (see `_polish_root`), making + the extracted root resolution- and thread-independent. `nothing` (default) + reports the raw marching-squares estimate, which is sensitive to grid and + floating-point rounding. + - `polish_maxit` -- max polish iterations per root (default 20). Each costs a + handful of `f` evaluations; failures fall back to the unpolished point. + - `validity_rtol` -- with `residual` supplied, a polished root is kept only if + `|residual| < validity_rtol · median(|Δ|)` (default 1e-3). Intersections + that don't polish to a true zero (contour near-misses, or surfaces whose + Δ' BVP failed → `|Δ'|` huge and uncancellable) are dropped from + `valid_roots`, parked in `filtered_roots`, and flagged `:spurious`. If every + candidate is spurious, the result carries `:no_root`. + +# Spurious-root recursion + +After the per-intersection pole / above-pole filters, the remaining roots +are sorted by descending γ. The selection loop walks down this list and at +each candidate evaluates two flags: + + - `:geom` — Re(Δ)=0 contour is locally a downward-concave "hill" at the + candidate (clean polyline-following quadratic fit). + - `:gap` — candidate is unstable AND its γ exceeds the next root's by + more than `gap_kHz_threshold` kHz. + +If BOTH fire, the candidate is discarded as spurious and the next-most- +unstable root is tried. If exactly ONE fires, the candidate is accepted as +primary with that warning recorded, and the next root is exposed as +`Q_root_secondary` so downstream tools can plot or reanalyse it. If +neither fires, the candidate is accepted cleanly. +""" +function find_growth_rates(scan::ScanResult, tauk::Real; + re_target::Real=0.0, im_target::Real=0.0, + pole_threshold::Real=10.0, + filter_above_poles::Bool=true, + filter_outside_re::Bool=true, + gap_kHz_threshold::Real=1.0, + residual=nothing, + polish_maxit::Integer=20, + validity_rtol::Real=1e-3) + return _extract_growth_rates(scan.re_axis, scan.im_axis, scan.Δ, + Float64(tauk); + re_target=Float64(re_target), + im_target=Float64(im_target), + pole_threshold=Float64(pole_threshold), + filter_above_poles=filter_above_poles, + filter_outside_re=filter_outside_re, + gap_kHz_threshold=Float64(gap_kHz_threshold), + residual=residual, + polish_maxit=Int(polish_maxit), + validity_scale=_residual_scale(scan.Δ), + validity_rtol=Float64(validity_rtol)) +end + +""" + find_growth_rates(amr::AMRResult, tauk::Real; + re_target=0.0, im_target=0.0, + pole_threshold=10.0, + filter_above_poles=true, + filter_outside_re=true) -> GrowthRateResult + +Extract tearing growth-rate eigenvalues from an AMR `AMRResult` via Delaunay +triangulation + marching triangles on the scattered evaluation points. The +pipeline after contour extraction (segment intersection, pole classification, +outside-Re filter, physical-Hz conversion) is identical to the brute-force +grid path — only the contour extractor changes. Hanging-node issues from the +quadtree's mixed refinement levels are resolved by the triangulation +respecting every evaluated point uniformly. +""" +function find_growth_rates(amr::AMRResult, tauk::Real; + re_target::Real=0.0, im_target::Real=0.0, + pole_threshold::Real=10.0, + filter_above_poles::Bool=true, + filter_outside_re::Bool=true, + gap_kHz_threshold::Real=1.0, + residual=nothing, + polish_maxit::Integer=20, + validity_rtol::Real=1e-3) + return _extract_growth_rates_amr(amr.Q, amr.Δ, Float64(tauk); + re_target=Float64(re_target), + im_target=Float64(im_target), + pole_threshold=Float64(pole_threshold), + filter_above_poles=filter_above_poles, + filter_outside_re=filter_outside_re, + gap_kHz_threshold=Float64(gap_kHz_threshold), + residual=residual, + polish_maxit=Int(polish_maxit), + validity_scale=_residual_scale(amr.Δ), + validity_rtol=Float64(validity_rtol)) +end + +# --------------------------------------------------------------------- +# Implementation. +# --------------------------------------------------------------------- + +# Bilinear interpolation of `values` on the regular grid `(re_axis, im_axis)` +# at point (qr, qi). Out-of-grid points are clamped to the boundary. +function _bilinear(re_axis::Vector{Float64}, im_axis::Vector{Float64}, + values::Matrix{Float64}, qr::Real, qi::Real) + nre = length(re_axis) + nim = length(im_axis) + i = clamp(searchsortedlast(re_axis, qr), 1, nre - 1) + j = clamp(searchsortedlast(im_axis, qi), 1, nim - 1) + tx = (qr - re_axis[i]) / (re_axis[i+1] - re_axis[i]) + ty = (qi - im_axis[j]) / (im_axis[j+1] - im_axis[j]) + tx = clamp(tx, 0.0, 1.0) + ty = clamp(ty, 0.0, 1.0) + return (1 - tx) * (1 - ty) * values[i, j] + tx * (1 - ty) * values[i+1, j] + + (1 - tx) * ty * values[i, j+1] + tx * ty * values[i+1, j+1] +end + +# Extract polylines for a single contour level on a regular grid. +# Returns Vector{Vector{ComplexF64}} (one polyline per closed/open curve). +function _extract_contours(re_axis::Vector{Float64}, im_axis::Vector{Float64}, + values::Matrix{Float64}, level::Float64) + polylines = Vector{Vector{ComplexF64}}() + for cl in lines(contour(re_axis, im_axis, values, level)) + xs, ys = coordinates(cl) + path = ComplexF64[xs[i] + ys[i] * im for i in eachindex(xs)] + length(path) >= 2 && push!(polylines, path) + end + return polylines +end + +# Segment-segment intersection on the complex plane. Returns the +# intersection point if segments [a,b] and [c,d] cross strictly (parameters +# in (0,1)), else nothing. Endpoint touches return the touch point. +function _segment_intersection(a::ComplexF64, b::ComplexF64, + c::ComplexF64, d::ComplexF64) + d1r, d1i = real(b - a), imag(b - a) + d2r, d2i = real(d - c), imag(d - c) + denom = d1r * d2i - d1i * d2r + abs(denom) < 1e-30 && return nothing # parallel or degenerate + diffr, diffi = real(c - a), imag(c - a) + t = (diffr * d2i - diffi * d2r) / denom + u = (diffr * d1i - diffi * d1r) / denom + if 0 <= t <= 1 && 0 <= u <= 1 + return a + t * (b - a) + end + return nothing +end + +# Find all intersections between two families of polylines. Returns +# Vector{ComplexF64}. +function _all_intersections(re_paths::Vector{Vector{ComplexF64}}, + im_paths::Vector{Vector{ComplexF64}}) + out = ComplexF64[] + for re_path in re_paths + for i in 1:length(re_path)-1 + a, b = re_path[i], re_path[i+1] + for im_path in im_paths + for j in 1:length(im_path)-1 + c, d = im_path[j], im_path[j+1] + pt = _segment_intersection(a, b, c, d) + pt !== nothing && push!(out, pt) + end + end + end + end + return out +end + +# Index of the closest vertex in a polyline to a point. +function _closest_vertex(path::Vector{ComplexF64}, pt::ComplexF64) + best_i = 0 + best_d = Inf + for i in eachindex(path) + d = abs(path[i] - pt) + if d < best_d + best_d = d + best_i = i + end + end + return best_i, best_d +end + +# Find the polyline (and vertex within it) whose vertex is closest to pt. +function _closest_polyline_vertex(paths::Vector{Vector{ComplexF64}}, + pt::ComplexF64) + best_path_idx = 0 + best_vert_idx = 0 + best_d = Inf + for (pi_, path) in enumerate(paths) + vi, d = _closest_vertex(path, pt) + if d < best_d + best_d = d + best_path_idx = pi_ + best_vert_idx = vi + end + end + return best_path_idx, best_vert_idx, best_d +end + +# Ray-casting point-in-polygon. `polygon` need not be closed (function +# closes it internally). +function _point_in_polygon(pt::ComplexF64, polygon::Vector{ComplexF64}) + n = length(polygon) + n < 3 && return false + inside = false + pr, pi_ = real(pt), imag(pt) + j = n + for i in 1:n + xi, yi = real(polygon[i]), imag(polygon[i]) + xj, yj = real(polygon[j]), imag(polygon[j]) + if ((yi > pi_) != (yj > pi_)) && + (pr < (xj - xi) * (pi_ - yi) / (yj - yi) + xi) + inside = !inside + end + j = i + end + return inside +end + +# --------------------------------------------------------------------- +# Shared analysis: intersections + pole classification + outside-Re filter. +# Both the regular-grid path (_extract_growth_rates) and the AMR +# triangulation path (_extract_growth_rates_amr) funnel through this. +# --------------------------------------------------------------------- +# Geometric "spurious upper-branch" detector — flags candidates where the +# Re(Δ)=0 contour is locally a downward-concave "hill" or "hump" (⌒) at the +# candidate location. Legitimate tearing roots sit at the bottom of upward- +# concave "wells" (∪); spurious upper-branch roots sit at the top of hills. +# +# Algorithm: +# 1. Find the closest Re=0 polyline + closest vertex on it. +# 2. Walk outward along that polyline, collecting consecutive vertices +# within `max_walk` Q-distance of the candidate. Walking the polyline +# (rather than averaging over a radius) avoids polluting the fit with +# vertices from disconnected nearby Re=0 fragments — important on +# AMR-triangulated meshes where the contour is fragmented. +# 3. Fit γ = a + b·Δω + c·(Δω)² to the collected vertices via least squares. +# Sign of `c` is the local concavity: +# c < 0 → contour is concave-DOWN (hill, ⌒) ← SPURIOUS pattern +# c > 0 → contour is concave-UP (well, ∪) ← legitimate pattern +# 4. Gate on fit quality: only flag when RMS_residual / γ_spread is below +# `quality_threshold`. Noisy fits (e.g. multiple overlapping contour +# fragments) leave the candidate unflagged — letting the gap criterion +# and downstream review handle ambiguous cases. +# +# Returns `true` when the candidate is on a CLEAN concave-down arc; else +# `false`. The orientation-invariance of the previous 3-point stencil +# version is preserved because we fit γ = f(ω) which has a sign-stable +# second derivative regardless of traversal direction. +function _is_geom_spurious(pt::ComplexF64, + re_paths::Vector{Vector{ComplexF64}}; + max_walk::Float64=0.5, + curvature_threshold::Float64=0.05, + quality_threshold::Float64=0.15) + re_idx, re_v_idx, _ = _closest_polyline_vertex(re_paths, pt) + re_idx == 0 && return false + re_path = re_paths[re_idx] + n_path = length(re_path) + n_path < 5 && return false + + # Walk outward from re_v_idx along the polyline, collecting vertices + # within max_walk Q-distance of pt. Stop in each direction at the first + # vertex that exceeds the walk radius. + collected_idx = Int[re_v_idx] + @inbounds for k in (re_v_idx+1):n_path + if abs(re_path[k] - pt) < max_walk + push!(collected_idx, k) + else + break + end + end + @inbounds for k in (re_v_idx-1):-1:1 + if abs(re_path[k] - pt) < max_walk + push!(collected_idx, k) + else + break + end + end + n = length(collected_idx) + n < 5 && return false + + ω₀ = real(pt) + ωs = Vector{Float64}(undef, n) + γs = Vector{Float64}(undef, n) + @inbounds for (i, k) in enumerate(collected_idx) + ωs[i] = real(re_path[k]) - ω₀ + γs[i] = imag(re_path[k]) + end + ω_sp = maximum(ωs) - minimum(ωs) + γ_sp = maximum(γs) - minimum(γs) + (ω_sp < 1e-6 || γ_sp < 1e-12) && return false + + # Quadratic least-squares fit γ = a + b·ω + c·ω² via the normal equations + # MᵀM·coeffs = Mᵀγ, where M = [1 ω ω²]. Hand-rolled to avoid an allocation + # for the n×3 design matrix (we just need the 3×3 normal-equation matrix). + sx = 0.0 + sx2 = 0.0 + sx3 = 0.0 + sx4 = 0.0 + sy = 0.0 + sxy = 0.0 + sx2y = 0.0 + @inbounds for i in 1:n + ω = ωs[i] + γ = γs[i] + ω2 = ω * ω + sx += ω + sx2 += ω2 + sx3 += ω2 * ω + sx4 += ω2 * ω2 + sy += γ + sxy += ω * γ + sx2y += ω2 * γ + end + M = [Float64(n) sx sx2; + sx sx2 sx3; + sx2 sx3 sx4] + rhs = [sy, sxy, sx2y] + coeffs = M \ rhs + c = coeffs[3] + + # Fit-quality residual norm + rms_sq = 0.0 + @inbounds for i in 1:n + pred = coeffs[1] + coeffs[2] * ωs[i] + coeffs[3] * ωs[i]^2 + rms_sq += (γs[i] - pred)^2 + end + rms = sqrt(rms_sq / n) + rms_norm = rms / γ_sp + + # Spurious if concave-down AND fit is clean enough to trust + return c < -curvature_threshold && rms_norm < quality_threshold +end + +# γ-gap separation: the candidate at `idx` (in γ-descending order) is unstable +# AND clearly separated above the next-most-unstable candidate by more than +# `gap_kHz_threshold` kHz. Flags an outlier "lone peak" root. +function _is_gap_spurious(sorted_roots::Vector{ComplexF64}, idx::Int, + tauk::Float64, gap_kHz_threshold::Float64) + γ_idx = imag(sorted_roots[idx]) / tauk * 1e-3 # kHz + γ_idx > 0.0 || return false # only suspicious if unstable + idx >= length(sorted_roots) && return false # nothing below to compare + γ_next = imag(sorted_roots[idx+1]) / tauk * 1e-3 + return (γ_idx - γ_next) > gap_kHz_threshold +end + +# --------------------------------------------------------------------- +# Root polishing (isolate-then-polish). +# +# The contour scan only *locates* roots to the grid/cell resolution: the +# marching-squares intersection is a linear estimate of where Re(f)=0 and +# Im(f)=0 cross, so the residual there is small only relative to the scan's +# huge dynamic range, not zero. Its exact position is sensitive to ULP-level +# jitter in the sampled `f` values (BLAS/thread reduction order), which can +# shift the reported γ between near-degenerate estimates of the SAME root. +# +# `_polish_root` refines one contour intersection to the actual zero of `f` +# by bounded minimization of `|f(Q)|`, so the reported root becomes +# resolution- and thread-independent. It is a no-op-or-better layer: any +# failure (non-finite, no decrease, pole approach) falls back to the +# unpolished contour point. +# --------------------------------------------------------------------- + +# Median contour-segment length — a robust proxy for the local grid/cell +# resolution, used to cap how far a polish may move a root. +function _median_segment_length(re_paths::Vector{Vector{ComplexF64}}, + im_paths::Vector{Vector{ComplexF64}}) + lens = Float64[] + for paths in (re_paths, im_paths), p in paths + @inbounds for i in 1:length(p)-1 + push!(lens, abs(p[i+1] - p[i])) + end + end + isempty(lens) && return 0.0 + sort!(lens) + return lens[cld(length(lens), 2)] +end + +# Trust-region radius for polishing the intersection at `pts[idx]`. Capped by +# `nn_frac` × distance to the NEAREST other intersection (root OR pole), so a +# polished root can never migrate into a neighbour's basin — `nn_frac < 0.5` +# guarantees, by the triangle inequality, that the result stays strictly +# closer to its own contour point than to any other. Also capped at a few grid +# cells (`k_cell·h`) so refinement only corrects the discretization error. +function _polish_trust_radius(pts::Vector{ComplexF64}, idx::Int, h_grid::Real; + k_cell::Float64=3.0, nn_frac::Float64=0.45) + p0 = pts[idx] + d_nn = Inf + @inbounds for j in eachindex(pts) + j == idx && continue + d = abs(pts[j] - p0) + d < d_nn && (d_nn = d) + end + r_cell = k_cell * Float64(h_grid) + r = isfinite(d_nn) ? min(r_cell, nn_frac * d_nn) : r_cell + return r > 0 ? r : r_cell +end + +# Bounded Gauss-Newton minimization of |f(Q)| within a disk of radius `R` +# around `Q0`. Returns (Q_best, n_evals, improved). `improved == false` ⇒ +# Q0 is returned unchanged (hard fallback). Secant derivative + trust-region +# projection + |f|-decrease backtracking keep it strictly "improve-or-no-op": +# it cannot diverge, jump to another root, or be pulled into a pole. +function _polish_root(f, Q0::ComplexF64, R::Float64; maxit::Int=8, + tol_step::Float64=1e-12, tol_f::Float64=1e-8) + (R > 0) || return (Q0, 0, false) + f0 = ComplexF64(f(Q0)) + nev = 1 + isfinite(f0) || return (Q0, nev, false) + a0 = abs(f0) + a0 == 0 && return (Q0, nev, false) + + # Second point for the secant slope: a small real offset scaled to R. + Qk, fk = Q0, f0 + Qp = Q0 + complex(max(R * 1e-3, eps(Float64)), 0.0) + fp = ComplexF64(f(Qp)) + nev += 1 + Qbest, abest = Q0, a0 + + for _ in 1:maxit + df = (Qp == Qk) ? ComplexF64(0) : (fp - fk) / (Qp - Qk) + (isfinite(df) && abs(df) > 0) || break + step = -fk / df # zero of local linear model + Qt = Qk + step + if abs(Qt - Q0) > R # project into trust region + Qt = Q0 + R * (Qt - Q0) / abs(Qt - Q0) + end + ft = ComplexF64(f(Qt)) + nev += 1 + bt = 0 + while (!isfinite(ft) || abs(ft) >= abest) && bt < 3 # backtrack on no-decrease + Qt = Qk + 0.5 * (Qt - Qk) + (abs(Qt - Q0) <= R) || break + ft = ComplexF64(f(Qt)) + nev += 1 + bt += 1 + end + if isfinite(ft) && abs(ft) < abest + Qp, fp = Qk, fk + Qk, fk = Qt, ft + Qbest, abest = Qt, abs(ft) + (abs(step) < tol_step * (abs(Qk) + tol_step) || abest < tol_f * a0) && break + else + break # no further decrease → stop + end + end + return (abest < a0 ? Qbest : Q0, nev, abest < a0) +end + +# Median |Δ| over the finite scan values — the "typical residual magnitude" +# reference for the validity gate. A genuine root drives the polished |residual| +# orders of magnitude below this; a near-miss stays near it. +function _residual_scale(Δ) + a = Float64[] + for z in Δ + az = abs(z) + (isfinite(az) && az < 1e30) && push!(a, az) + end + isempty(a) && return 0.0 + sort!(a) + n = length(a) + return isodd(n) ? a[(n+1)÷2] : 0.5 * (a[n÷2] + a[n÷2+1]) +end + +function _run_analysis(re_paths::Vector{Vector{ComplexF64}}, + im_paths::Vector{Vector{ComplexF64}}, + im_re_vals::Vector{Vector{Float64}}, + tauk::Float64; + pole_threshold::Float64, + filter_above_poles::Bool, + filter_outside_re::Bool, + gap_kHz_threshold::Float64=1.0, + residual=nothing, + polish_maxit::Int=20, + validity_scale::Float64=0.0, + validity_rtol::Float64=1e-3) + raw_intersections = _all_intersections(re_paths, im_paths) + + poles = ComplexF64[] + candidates = Tuple{ComplexF64,Bool}[] # (pt, on_top_half_re_flag) + spurious_roots = ComplexF64[] # polished, but |residual| ≫ 0 + + # Isolate-then-polish: when a residual callable is supplied, refine each + # (non-pole) contour intersection to the true zero of `f`. Trust radii are + # capped by the spacing to neighbouring intersections so closely-spaced + # coupled roots stay coherently bound to their own contour crossing. + do_polish = residual !== nothing + h_grid = do_polish ? _median_segment_length(re_paths, im_paths) : 0.0 + # Validity gate: a genuine root drives |residual| ≈ 0; a spurious contour + # near-miss (e.g. a surface whose Δ' BVP failed → |Δ'| huge and + # uncancellable by the inner layer) leaves |residual| stuck near the typical + # scan magnitude. Only active when polishing AND a scale is supplied. + do_gate = do_polish && validity_scale > 0 + polish_evals = 0 + + for (i_pt, pt) in enumerate(raw_intersections) + # --- 1. classify as pole or root via local Re-magnitude on Im contour + best_im_path_idx, best_im_vert_idx, _ = + _closest_polyline_vertex(im_paths, pt) + is_pole = false + if best_im_path_idx > 0 + re_vals = im_re_vals[best_im_path_idx] + n = length(re_vals) + i_prev = max(1, best_im_vert_idx - 1) + i_next = min(n, best_im_vert_idx + 1) + local_max = max(abs(re_vals[i_prev]), + abs(re_vals[i_next]), + abs(re_vals[best_im_vert_idx])) + is_pole = local_max > pole_threshold + end + + if is_pole + push!(poles, pt) + continue + end + + # --- 1b. polish this (non-pole) intersection to the true zero of `f`, + # bounded to a neighbour-aware trust region (no-op on failure). + if do_polish + R = _polish_trust_radius(raw_intersections, i_pt, h_grid) + pt, nev, _ = _polish_root(residual, pt, R; maxit=polish_maxit) + polish_evals += nev + # --- 1c. validity gate: drop intersections that don't polish to a + # true zero (|residual| stays ≫ 0 relative to the scan scale). These + # are contour near-misses / failed-BVP surfaces, not real roots. + if do_gate + rmag = abs(ComplexF64(residual(pt))) + polish_evals += 1 + if !(rmag < validity_rtol * validity_scale) # NaN ⇒ spurious + push!(spurious_roots, pt) + continue + end + end + end + + # --- 2. "+γ step inside Re contour" flag for spurious-upper-branch filter + on_top_half_re = false + best_re_path_idx, _, _ = _closest_polyline_vertex(re_paths, pt) + if best_im_path_idx > 0 && best_re_path_idx > 0 + re_path = re_paths[best_re_path_idx] + xs = real.(re_path) + ys = imag.(re_path) + contour_extent = max(maximum(xs) - minimum(xs), + maximum(ys) - minimum(ys)) + closure_gap = abs(re_path[1] - re_path[end]) + + if contour_extent > 0 && closure_gap < 0.1 * contour_extent + # Re=0 contour is approximately closed → containment test applies + im_path = im_paths[best_im_path_idx] + n_im = length(im_path) + im_nearest = best_im_vert_idx + i_a = min(im_nearest + 1, n_im) + i_b = max(im_nearest - 1, 1) + gamma_a = imag(im_path[i_a]) + gamma_b = imag(im_path[i_b]) + gamma_here = imag(im_path[im_nearest]) + + tangent = if gamma_a >= gamma_b && gamma_a > gamma_here + im_path[i_a] - im_path[im_nearest] + elseif gamma_b > gamma_here + im_path[i_b] - im_path[im_nearest] + else + ComplexF64(0.0, 1.0) # fall back to straight up + end + + tlen = abs(tangent) + if tlen > 0 + step_size = 0.01 * contour_extent + step_pt = pt + (step_size / tlen) * tangent + inside = _point_in_polygon(step_pt, re_path) + on_top_half_re = !inside + end + end + end + + push!(candidates, (pt, on_top_half_re)) + end + + # --- 3. pole + closed-loop filter (legacy), then geom + gap recursion (new) + valid_roots = ComplexF64[c[1] for c in candidates] + filtered_roots = copy(spurious_roots) # gate-rejected near-misses kept here + Q_root = ComplexF64(NaN, NaN) + Q_root_2nd = ComplexF64(NaN, NaN) + warning_flags = Symbol[] + isempty(spurious_roots) || push!(warning_flags, :spurious) + + if !isempty(valid_roots) + order = sortperm(valid_roots; by=q -> -imag(q)) + sorted_pts = valid_roots[order] + sorted_top = Bool[c[2] for c in candidates][order] + + max_pole_gamma = isempty(poles) ? -Inf : maximum(imag, poles) + + chosen_idx = 0 + for k in 1:length(sorted_pts) + cand = sorted_pts[k] + top_re = sorted_top[k] + # Legacy filter: above-pole + closed-loop outside-Re + legacy_reject = filter_above_poles && imag(cand) > max_pole_gamma && + (!filter_outside_re || top_re) + if legacy_reject + push!(filtered_roots, cand) + continue + end + # New checks: 2 spurious-root flags — :geom and :gap. + # :geom — Re=0 contour is locally a downward-concave "hill" + # at the candidate (clean polyline-following fit) + # :gap — candidate is unstable AND >1 kHz above next root + # (isolated γ peak — spurious outlier signature) + # + # Policy: WARN, DO NOT DISCARD. The both-flags-fire criterion + # is too aggressive in the kink-approach regime where valid + # roots become sparse — a few-kHz γ separation between the + # dominant unstable root and the next-stable root can be + # genuine dispersion structure rather than a "lone peak" + # artifact, yet :gap fires regardless. So every candidate is + # accepted with whatever warnings apply, and downstream tools + # see the same valid_roots regardless of flag combination. + # filtered_roots is preserved for the legacy above-pole + + # outside-Re reject branch only. + geom_flag = _is_geom_spurious(cand, re_paths) + gap_flag = _is_gap_spurious(sorted_pts, k, tauk, + gap_kHz_threshold) + chosen_idx = k + geom_flag && push!(warning_flags, :geom) + gap_flag && push!(warning_flags, :gap) + break + end + + if chosen_idx > 0 + Q_root = sorted_pts[chosen_idx] + # When a warning fired, expose the next-down root as secondary so + # downstream tools can plot/reanalyse. (Indices > chosen_idx in + # sorted_pts are the next-most-unstable.) + if !isempty(warning_flags) && chosen_idx < length(sorted_pts) + Q_root_2nd = sorted_pts[chosen_idx+1] + end + end + end + + # No usable root: either the scan produced no zero-crossing intersections + # (`valid_roots` empty) or every candidate was rejected by the legacy + # above-pole filter (`chosen_idx == 0`). Flag this explicitly so callers + # can distinguish a genuine marginally-stable result (γ ≈ 0) from a failed + # extraction — both otherwise surface as `gamma_Hz == 0.0` below. + isnan(real(Q_root)) && push!(warning_flags, :no_root) + + omega_Hz = isnan(real(Q_root)) ? 0.0 : real(Q_root) / tauk + gamma_Hz = isnan(imag(Q_root)) ? 0.0 : imag(Q_root) / tauk + omega_Hz_2nd = isnan(real(Q_root_2nd)) ? 0.0 : real(Q_root_2nd) / tauk + gamma_Hz_2nd = isnan(imag(Q_root_2nd)) ? 0.0 : imag(Q_root_2nd) / tauk + + return GrowthRateResult(Q_root, omega_Hz, gamma_Hz, + Q_root_2nd, omega_Hz_2nd, gamma_Hz_2nd, + warning_flags, + valid_roots, poles, filtered_roots, + re_paths, im_paths, pole_threshold) +end + +# Regular-grid path: extract contours via Contour.jl, compute im_re_vals by +# bilinear interpolation on the grid, then run the shared analysis. +function _extract_growth_rates(re_axis::Vector{Float64}, + im_axis::Vector{Float64}, + Δ_grid::Matrix{ComplexF64}, + tauk::Float64; + re_target::Float64, + im_target::Float64, + pole_threshold::Float64, + filter_above_poles::Bool, + filter_outside_re::Bool, + gap_kHz_threshold::Float64=1.0, + residual=nothing, + polish_maxit::Int=20, + validity_scale::Float64=0.0, + validity_rtol::Float64=1e-3) + re_field = real.(Δ_grid) + im_field = imag.(Δ_grid) + + re_paths = _extract_contours(re_axis, im_axis, re_field, re_target) + im_paths = _extract_contours(re_axis, im_axis, im_field, im_target) + + im_re_vals = [Float64[_bilinear(re_axis, im_axis, re_field, + real(v), imag(v)) + for v in path] + for path in im_paths] + + return _run_analysis(re_paths, im_paths, im_re_vals, tauk; + pole_threshold=pole_threshold, + filter_above_poles=filter_above_poles, + filter_outside_re=filter_outside_re, + gap_kHz_threshold=gap_kHz_threshold, + residual=residual, + polish_maxit=polish_maxit, + validity_scale=validity_scale, + validity_rtol=validity_rtol) +end + +# --------------------------------------------------------------------- +# AMR path: Delaunay triangulation + marching triangles. Hanging nodes +# from the quadtree's mixed refinement levels become first-class vertices +# in the triangulation, so contour segments piece together without gaps. +# --------------------------------------------------------------------- + +# Emit a Re=0 and Im=0 segment (if any) from a single triangle. Returns +# `(re_seg, im_seg)` where each may be `nothing`. A segment is a +# `@NamedTuple{p1::ComplexF64, p2::ComplexF64, a1::Float64, a2::Float64}` +# where `a1`, `a2` carry the *complementary* field value at the endpoints +# (Re-value for Im=0 segments, Im-value for Re=0 segments). +function _march_triangle(p1::ComplexF64, p2::ComplexF64, p3::ComplexF64, + v1::ComplexF64, v2::ComplexF64, v3::ComplexF64, + re_target::Float64, im_target::Float64) + return (_march_single(p1, p2, p3, real(v1), real(v2), real(v3), + imag(v1), imag(v2), imag(v3), re_target), + _march_single(p1, p2, p3, imag(v1), imag(v2), imag(v3), + real(v1), real(v2), real(v3), im_target)) +end + +# Core marching step for one scalar field `f` with complementary field `g`. +# Produces the contour segment at level=L (if any) along with the value of +# `g` linearly interpolated at each endpoint. +@inline function _march_single(p1::ComplexF64, p2::ComplexF64, p3::ComplexF64, + f1::Float64, f2::Float64, f3::Float64, + g1::Float64, g2::Float64, g3::Float64, + L::Float64) + a1 = f1 >= L + a2 = f2 >= L + a3 = f3 >= L + count = Int(a1) + Int(a2) + Int(a3) + (count == 0 || count == 3) && return nothing + + # Identify the "odd" vertex and produce crossings on the two edges + # incident to it. + if a1 != a2 && a1 != a3 + pt_a, ga = _cross_edge(p1, p2, f1, f2, g1, g2, L) + pt_b, gb = _cross_edge(p1, p3, f1, f3, g1, g3, L) + elseif a2 != a1 && a2 != a3 + pt_a, ga = _cross_edge(p2, p1, f2, f1, g2, g1, L) + pt_b, gb = _cross_edge(p2, p3, f2, f3, g2, g3, L) + else + pt_a, ga = _cross_edge(p3, p1, f3, f1, g3, g1, L) + pt_b, gb = _cross_edge(p3, p2, f3, f2, g3, g2, L) + end + return (p1=pt_a, p2=pt_b, a1=ga, a2=gb) +end + +# Linear crossing on edge (pa, pb) for field `f` at level `L`, with +# complementary value `g` interpolated at the same parameter. +@inline function _cross_edge(pa::ComplexF64, pb::ComplexF64, + fa::Float64, fb::Float64, + ga::Float64, gb::Float64, L::Float64) + denom = fb - fa + t = denom == 0 ? 0.0 : (L - fa) / denom + t = clamp(t, 0.0, 1.0) + return (pa + t * (pb - pa), ga + t * (gb - ga)) +end + +# Chain segments into polylines by endpoint matching. Each segment endpoint +# is a `ComplexF64` that is shared bit-exactly with any adjacent triangle's +# crossing (both sides of a triangulation edge compute the same linear +# crossing from identical endpoint values). Returns +# `(paths::Vector{Vector{ComplexF64}}, aux::Vector{Vector{Float64}})`. +function _chain_segments(segs::Vector{<:NamedTuple}) + # Build an endpoint → list-of-segment-indices adjacency map. + adj = Dict{ComplexF64,Vector{Int}}() + for (i, s) in enumerate(segs) + push!(get!(adj, s.p1, Int[]), i) + push!(get!(adj, s.p2, Int[]), i) + end + + used = falses(length(segs)) + paths = Vector{Vector{ComplexF64}}() + aux_vals = Vector{Vector{Float64}}() + + # Walk a polyline starting from segment `start_seg` via endpoint + # `start_pt`; returns the path and aux values. + function _walk(start_seg::Int, start_pt::ComplexF64) + path = ComplexF64[start_pt] + aux = Float64[] + # Emit the aux value for start_pt on the first segment + s0 = segs[start_seg] + push!(aux, start_pt == s0.p1 ? s0.a1 : s0.a2) + + cur_seg = start_seg + cur_pt = start_pt + while true + used[cur_seg] = true + s = segs[cur_seg] + next_pt = cur_pt == s.p1 ? s.p2 : s.p1 + next_aux = cur_pt == s.p1 ? s.a2 : s.a1 + push!(path, next_pt) + push!(aux, next_aux) + + nbrs = adj[next_pt] + nxt = 0 + for j in nbrs + if !used[j] && j != cur_seg + nxt = j + break + end + end + nxt == 0 && break + cur_seg = nxt + cur_pt = next_pt + end + return path, aux + end + + # Open polylines first: start from any endpoint touched by exactly + # one still-unused segment. + for (pt, nbrs) in adj + count = 0 + start_seg = 0 + for j in nbrs + if !used[j] + count += 1 + start_seg = j + end + end + if count == 1 + path, aux = _walk(start_seg, pt) + length(path) >= 2 && (push!(paths, path); push!(aux_vals, aux)) + end + end + + # Remaining segments form closed loops. + for i in eachindex(segs) + used[i] && continue + path, aux = _walk(i, segs[i].p1) + length(path) >= 2 && (push!(paths, path); push!(aux_vals, aux)) + end + + return paths, aux_vals +end + +# AMR entry point: triangulate the scattered (Q, Δ) points, march triangles +# to extract Re=0 and Im=0 contour segments with complementary-field values +# at endpoints, chain into polylines, then run the shared analysis. +function _extract_growth_rates_amr(Q::Vector{ComplexF64}, + Δ::Vector{ComplexF64}, + tauk::Float64; + re_target::Float64, + im_target::Float64, + pole_threshold::Float64, + filter_above_poles::Bool, + filter_outside_re::Bool, + gap_kHz_threshold::Float64=1.0, + residual=nothing, + polish_maxit::Int=20, + validity_scale::Float64=0.0, + validity_rtol::Float64=1e-3) + length(Q) == length(Δ) || + throw(ArgumentError("_extract_growth_rates_amr: length(Q) ≠ length(Δ)")) + length(Q) >= 3 || + throw(ArgumentError("_extract_growth_rates_amr: need ≥ 3 points to triangulate")) + + pts = [(real(q), imag(q)) for q in Q] + tri = triangulate(pts) + + # Segment types (carry complementary-field value at each endpoint) + re_segs = NamedTuple{(:p1, :p2, :a1, :a2), + Tuple{ComplexF64,ComplexF64,Float64,Float64}}[] + im_segs = NamedTuple{(:p1, :p2, :a1, :a2), + Tuple{ComplexF64,ComplexF64,Float64,Float64}}[] + + for T in each_solid_triangle(tri) + i1, i2, i3 = T + p1 = Q[i1] + p2 = Q[i2] + p3 = Q[i3] + v1 = Δ[i1] + v2 = Δ[i2] + v3 = Δ[i3] + re_seg, im_seg = _march_triangle(p1, p2, p3, v1, v2, v3, + re_target, im_target) + re_seg !== nothing && push!(re_segs, re_seg) + im_seg !== nothing && push!(im_segs, im_seg) + end + + re_paths, _ = _chain_segments(re_segs) + im_paths, im_re_vals = _chain_segments(im_segs) + + return _run_analysis(re_paths, im_paths, im_re_vals, tauk; + pole_threshold=pole_threshold, + filter_above_poles=filter_above_poles, + filter_outside_re=filter_outside_re, + gap_kHz_threshold=gap_kHz_threshold, + residual=residual, + polish_maxit=polish_maxit, + validity_scale=validity_scale, + validity_rtol=validity_rtol) +end diff --git a/src/Tearing/Dispersion/SurfaceCoupling.jl b/src/Tearing/Dispersion/SurfaceCoupling.jl new file mode 100644 index 00000000..271162c1 --- /dev/null +++ b/src/Tearing/Dispersion/SurfaceCoupling.jl @@ -0,0 +1,103 @@ +# SurfaceCoupling.jl +# +# `SurfaceCoupling` packages everything the dispersion solver needs at one +# rational surface: the inner-layer model, its parameters, the outer Δ' +# diagonal element, the critical-Δ offset, the inner→outer-units scale +# factor, and the per-surface time normalization `tauk`. The struct is +# `Q`-callable and returns the complex residual +# +# r(Q) = Δ'_diag - scale · Δ_inner(Q) - Δ_crit +# +# `tauk` is unused for single-surface evaluation but is required by the +# multi-surface `MultiSurfaceCoupling` to rescale Q between each surface's +# normalization. +# +# Constructor convenience: `surface_coupling(model, params, dp_diag; dc=0.0)` +# auto-fills `scale` and `tauk` based on the model type — `scale = S^(1/3)` +# and `tauk = params.tauk` for SLAYER (de-normalizes the inner-layer Δ to +# outer units), `scale = 1` and `tauk = 1` for GGJ (Δ already in outer units +# after `rescale_delta`; no inter-surface Q rescaling). + +""" + SurfaceCoupling{M<:InnerLayerModel, P} + +Per-surface dispersion data: `(model, params, dp_diag, dc, scale, tauk)`. +Calling `sc(Q)` returns the complex residual + +``` +r(Q) = dp_diag - scale * solve_inner(model, params, Q).tearing - dc +``` + +A root of `sc` in the complex `Q` plane is a **tearing** eigenvalue at +this surface in the *uncoupled* approximation (only the tearing channel +of the inner-layer response appears — the interchange channel enters the +full 2m×2m dispersion via `MultiSurfaceCoupling`, not this scalar form). +Coupled multi-surface eigenvalues come from `MultiSurfaceCoupling` +evaluating the determinant of the modified Δ' matrix. +""" +struct SurfaceCoupling{M<:InnerLayerModel,P} + model::M + params::P + dp_diag::ComplexF64 + dc::Float64 + scale::Float64 + tauk::Float64 +end + +function (sc::SurfaceCoupling)(Q::Number) + Δ = solve_inner(sc.model, sc.params, ComplexF64(Q)).tearing + return sc.dp_diag - sc.scale * Δ - sc.dc +end + +""" + surface_coupling(model::SLAYERModel, params::SLAYERParameters, + dp_diag::Number; dc::Real=0.0) -> SurfaceCoupling + +SLAYER convenience constructor. `scale` is set to `params.lu^(1/3)` so that +the dimensionless Δ from `riccati_f` is mapped to outer ψ-units before +subtraction from the Δ' diagonal. `tauk` is taken from `params.tauk` for use +by `MultiSurfaceCoupling` Q rescaling. +""" +function surface_coupling(model::SLAYERModel, params::SLAYERParameters, + dp_diag::Number; dc::Real=0.0) + return SurfaceCoupling(model, params, ComplexF64(dp_diag), + Float64(dc), params.lu^(1 / 3), params.tauk) +end + +""" + surface_coupling(model::GGJModel, params::GGJParameters, + dp_diag::Number) -> SurfaceCoupling + +GGJ convenience constructor. `scale` is `1.0` because GGJ's `solve_inner` +applies its own `rescale_delta` (S^(2p₁/3)·v1^(2p₁)) internally, so the +returned Δ is already in outer units. `tauk` defaults to `1.0` (GGJ has no +direct analogue of SLAYER's per-surface time normalization, so multi-surface +Q rescaling is a no-op for GGJ surfaces unless overridden). + +**No `dc` kwarg**: GGJ's 4m×4m Pletzer-Dewar residual already includes the +interchange channel, which provides Glasser (Mercier) stabilization +natively. A Δ_crit proxy (χ_parallel-matching offset on the diagonal) is +meaningful only for tearing-only slab-layer approximations like SLAYER; +for GGJ it would double-count the interchange physics. The `SurfaceCoupling` +struct's `dc` field is hard-wired to 0 here. +""" +function surface_coupling(model::GGJModel, params::GGJParameters, + dp_diag::Number) + return SurfaceCoupling(model, params, ComplexF64(dp_diag), + 0.0, 1.0, 1.0) +end + +""" + surface_coupling(model::InnerLayerModel, params, dp_diag::Number; + dc::Real=0.0, scale::Real=1.0, tauk::Real=1.0) + -> SurfaceCoupling + +Generic fallback constructor. Use this when wiring a new inner-layer model +into the dispersion solver — pass the appropriate inner→outer-units `scale` +and per-surface `tauk` explicitly. +""" +function surface_coupling(model::InnerLayerModel, params, dp_diag::Number; + dc::Real=0.0, scale::Real=1.0, tauk::Real=1.0) + return SurfaceCoupling(model, params, ComplexF64(dp_diag), + Float64(dc), Float64(scale), Float64(tauk)) +end diff --git a/src/Tearing/LayerInputs.jl b/src/Tearing/LayerInputs.jl new file mode 100644 index 00000000..3c1e2cc3 --- /dev/null +++ b/src/Tearing/LayerInputs.jl @@ -0,0 +1,135 @@ +# LayerInputs.jl (GGJ) +# +# Build per-surface `GGJParameters` from a solved `PlasmaEquilibrium`, the +# `SingType` rational-surface list (each carrying a populated +# `restype::ResistGeometry` from `ForceFreeStates.resist_eval_all!`), and a +# `KineticProfiles` object — the same three ingredients `build_slayer_inputs` +# consumes. Produces the (E, F, G, H, K, τ_A, τ_R) tuple that GGJ's +# `solve_inner` needs, with τ_A / τ_R built from kinetic profiles using the +# same Spitzer resistivity and mass-density formulas SLAYER uses. +# +# Deliberately does *not* use any hardcoded `ne`/`te` defaults. The kinetic +# content enters through `profiles` alone; this keeps GGJ and SLAYER using +# bit-identical plasma inputs when both are driven by the same +# `KineticProfiles`. + +using ..Utilities: KineticProfiles +using ..Utilities.PhysicalConstants: MU_0, M_E, M_P, E_CHG, EPS_0 +using ..Utilities.NeoclassicalResistivity +using ..Utilities.NeoclassicalResistivity: NeoResistivityModel, SpitzerModel, + SauterNeoModel, RedlNeoModel, + coulomb_log_e, eta_spitzer, nu_star_e, eta_neoclassical +using ..ForceFreeStates: ResistGeometry +using ..InnerLayer.GGJ: GGJParameters + +""" + build_ggj_inputs(equil, sings, profiles; mu_i=2.0, zeff=1.0, + v1_scale=1.0, + resistivity_model::NeoResistivityModel=SpitzerModel(), + lnLambda_form::Symbol=:nrl) -> Vector{GGJParameters} + +Construct a `GGJParameters` for each rational surface in `sings`. Each +surface's geometric coefficients (E, F, G, H, K, M) come from the +`sing.restype::ResistGeometry` populated by `resist_eval_all!`. Kinetic +timescales are derived from the `KineticProfiles` at `sing.psifac`: + +``` +ρ(ψ) = μ_i · m_p · n_e(ψ) +η(ψ) = eta_neoclassical(model, n_e, T_e, Z_eff, f_t, ν*_e) [Ω·m] +τ_A = √(ρ · M · μ_0) / |2π · n · q' · χ₁ / V'| [Alfvén time] +τ_R = (⟨B²/|∇ψ|²⟩ / ⟨B²⟩) · μ_0 / η [resistive diffusion] +``` + +The mode number `n` is taken from `sings[k].n[1]` (first resonant mode at +the surface). `χ₁ = 2π · psio`. The `v1_scale` kwarg is an optional +multiplicative factor on `V'` in the τ_A denominator (a `v1 / volume` +normalization option); default `1.0` means use the raw `V'`. + +# Resistivity model + +`resistivity_model` selects the η closure: + + - `SpitzerModel()` (default) — Sauter 1999 Eq. 18a (Zeff-aware Spitzer), + with the NRL Coulomb log. + - `SauterNeoModel()` — multiplies by Sauter 1999 F_33 using f_t and ν*_e + from the surface's `ResistGeometry`. Produces the physically-correct + trapped-particle-corrected η for H-mode tearing stability. + - `RedlNeoModel()` — Redl 2021 F_33 (improved high-ν* fit). + +`lnLambda_form` selects `:nrl` (default), `:sauter`, or `:wesson`. + +Throws if any surface's `restype` is still `nothing` — call +`ForceFreeStates.resist_eval_all!(intr, equil)` first. +""" +function build_ggj_inputs(equil, sings, profiles::KineticProfiles; + mu_i::Real=2.0, zeff::Real=1.0, + v1_scale::Real=1.0, + resistivity_model::NeoResistivityModel=SpitzerModel(), + lnLambda_form::Symbol=:nrl) + psio = equil.psio + chi1 = 2π * psio + + out = Vector{GGJParameters}(undef, length(sings)) + for (k, sing) in enumerate(sings) + rg = sing.restype + rg === nothing && + throw( + ArgumentError( + "build_ggj_inputs: surface $k has " * + "restype = nothing. Call " * + "ForceFreeStates.resist_eval_all!(intr, equil) " * + "after sing_find! to populate it." + ) + ) + rg isa ResistGeometry || + throw(ArgumentError("build_ggj_inputs: surface $k has " * + "restype of unexpected type $(typeof(rg)).")) + + # Kinetic profiles at this surface + prof = profiles(sing.psifac) + n_e = prof.n_e # [m⁻³] + t_e = prof.T_e # [eV] + + # Shared Coulomb log and resistivity closure (identical to SLAYER + # when the same resistivity_model is selected). + lnLamb = coulomb_log_e(n_e, t_e; form=lnLambda_form) + if resistivity_model isa SpitzerModel + eta_use = eta_spitzer(n_e, t_e, zeff; lnLamb=lnLamb) + else + nuestar = nu_star_e(n_e, t_e, rg.R_major, rg.eps_local, + sing.q, zeff; lnLamb=lnLamb) + eta_use = eta_neoclassical(resistivity_model, n_e, t_e, zeff, + rg.f_trap, nuestar; lnLamb=lnLamb) + end + rho = mu_i * M_P * n_e + + # Alfvén time at the rational surface + n_tor = Int(sing.n[1]) + v1 = rg.v1_local * v1_scale + taua = sqrt(rho * rg.M * MU_0) / + abs(2π * n_tor * sing.q1 * chi1 / v1) + + # Resistive diffusion time + taur = (rg.avg_bsq_over_dpsisq / rg.avg_bsq) * MU_0 / eta_use + + # dV/dψ normalized by total plasma volume (`v1 = v1/volume`). This is + # the `v1` consumed by `rescale_delta` as v1^(2p1); NOT the raw V' + # used in τ_A above. + equil.params.volume === nothing && + throw( + ArgumentError( + "build_ggj_inputs: equil.params.volume " * + "is nothing. Ensure the equilibrium " * + "solver populated the total plasma " * + "volume before building GGJ inputs." + ) + ) + v1_norm = rg.v1_local / equil.params.volume + + out[k] = GGJParameters(; + E=rg.E, F=rg.F, G=rg.G, H=rg.H, K=rg.K, M=rg.M, + taua=taua, taur=taur, v1=v1_norm, ising=k + ) + end + return out +end diff --git a/src/Tearing/Runner/Control.jl b/src/Tearing/Runner/Control.jl new file mode 100644 index 00000000..17128a3d --- /dev/null +++ b/src/Tearing/Runner/Control.jl @@ -0,0 +1,268 @@ +# Control.jl +# +# `SLAYERControl` holds every user-facing knob that drives the SLAYER +# growth-rate analysis. Populated either directly via the `@kwdef` +# constructor or by parsing the `[SLAYER]` (and nested `[SLAYER.*]`) +# section(s) of a `gpec.toml`. + +""" + SLAYERControl + +Configuration for the SLAYER tearing-mode analysis. All fields are +user-facing: read from the `[SLAYER]` TOML section of a `gpec.toml` via +`slayer_control_from_toml`, or built directly via the `@kwdef` keyword +constructor. + +# Core toggles + + - `enabled` -- run the analysis at all (default `false`) + - `inner_model` -- `:slayer_fitzpatrick` (default), `:ggj_shooting`, or + `:ggj_galerkin` + - `scan_mode` -- `:amr` (default) or `:brute_force` + - `coupling_mode` -- `:uncoupled` (default, per-surface) or `:coupled` + (multi-surface determinant) + - `dc_type` -- critical-Δ offset selector, one of `:none`, `:lar`, + `:rfitzp`, `:toroidal` (χ_‖-matching critical-Δ formulas, + Connor-Hastie-Helander 2015) + - `msing_max` -- number of surfaces to include in the coupled + determinant (default 3; capped at `length(sings)` at runtime) + +# Physics knobs + + - `bt` -- toroidal field [T]. `nothing` → use `equil.config.b0exp` + - `mu_i` -- ion mass in proton-mass units (default 2.0 for D) + - `zeff` -- effective charge + - `chi_perp`, `chi_tor` -- fallback perpendicular / toroidal heat + diffusivity [m²/s], used only when the kinetic file carries no usable + `chi_e`/`chi_phi` profile (dataset absent or all-zero); otherwise the + file's χ⊥(ψ)/χ_φ(ψ) take precedence + - `dr_val`, `dgeo_val` -- critical-Δ formula inputs. `nothing` (default) + auto-derives them from the equilibrium: `dr_val` from the resistive + interchange index `D_R = E + F + H²` at each surface, `dgeo_val` from the + toroidal geometric factor (required only by `dc_type=:toroidal`). Supply a + scalar only to override the auto-derivation; an explicit `0.0` disables the + critical-Δ offset (Δ_crit ≡ 0) + - `theta_sample` -- poloidal angle at which to sample minor radius + (default 0.0, outboard midplane) + - `resistivity_model` -- η closure setting τ_R = μ₀r_s²/η: `:sauter` + (default, neoclassical F_33), `:redl`, `:spitzer` (Sauter 18a, no + trapped correction), or `:spitzer_harm` (legacy Fitzpatrick σ_∥) + - `lnLambda_form` -- Coulomb-log form: `:nrl` (default), `:sauter`, or + `:wesson` (legacy; pair with `:spitzer_harm` to reproduce old SLAYER) + +# Scan grid (used for both brute-force and AMR initial mesh) + + - `Q_re_range`, `Q_im_range` -- box in the normalized Q plane + - `nre`, `nim` -- grid resolution along each axis + +# AMR refinement + + - `amr_passes` -- max refinement levels + - `amr_max_cells` -- hard safety cap + +# Growth-rate-extraction filters + + - `pole_threshold` -- threshold for pole classification (default 10) + - `pole_threshold_adaptive` -- if true, pole_threshold is OVERRIDDEN per + scan with `10 × median(|Δ|)` over the scan grid. The median is robust + where `|mean(Δ)|` is not: it resists inflation from the near-pole + samples that dominate a mean, and avoids the phase-cancellation that + can shrink `|mean|`. Useful when |Δ| spans 8+ orders of magnitude + (e.g. SLAYER scans where the hardcoded 10.0 default is too restrictive + and classifies all intersections as poles). + - `filter_above_poles` -- discard roots above the highest pole γ + - `filter_outside_re` -- condition the above-pole filter on the +γ + step exiting the Re(Δ)=0 contour loop + +# Kinetic-profile source + +SLAYER reads kinetic profiles through the shared `Equilibrium.read_kinetic_file` +reader (the same standardized object used by the kinetic/NTV physics), so +there is one consistent interface for resistive and kinetic profiles. + + - `profile_file` -- path to a kinetic-profile file (relative to the run + dir), required when SLAYER is enabled. HDF5 (`.h5`) files use the GPEC + kinetic schema and may carry `chi_e` (χ⊥) and `chi_phi` (χ_φ); ASCII + tables are also accepted but carry no χ. When a χ dataset is absent or + all-zero, the scalar `chi_perp`/`chi_tor` fallbacks below are used (so a + file can keep the χ keys set to 0 to defer to the scalars). + - `profile_group` -- group within the HDF5 file (default `"/"`) + +# Output control + + - `store_scan` -- write the full Q/Δ scan grid to HDF5. `false` by + default to keep the output file small. +""" +@kwdef struct SLAYERControl + enabled::Bool = false + + inner_model::Symbol = :slayer_fitzpatrick + scan_mode::Symbol = :amr + coupling_mode::Symbol = :uncoupled + dc_type::Symbol = :none + msing_max::Int = 3 + + bt::Union{Float64,Nothing} = nothing + mu_i::Float64 = 2.0 + zeff::Float64 = 1.0 + chi_perp::Float64 = 1.0 + chi_tor::Float64 = 1.0 + dr_val::Union{Float64,Nothing} = nothing + dgeo_val::Union{Float64,Nothing} = nothing + theta_sample::Float64 = 0.0 + resistivity_model::Symbol = :sauter + lnLambda_form::Symbol = :nrl + + Q_re_range::Tuple{Float64,Float64} = (-10.0, 10.0) + Q_im_range::Tuple{Float64,Float64} = (-2.0, 5.0) + nre::Int = 41 + nim::Int = 31 + + amr_passes::Int = 4 + amr_max_cells::Int = 10_000_000 + + # Multi-box stripe layout. When non-empty, `scan_mode=:amr` dispatches to + # `multi_box_amr_scan` instead of single-box `amr_scan`. Each entry is a + # dimensionless Q-space rectangle as `(omega_lo, omega_hi, gamma_lo, + # gamma_hi)`. Activity criteria fire on Re(Δ) sign change, Im(Δ) sign + # change, OR |Δ| ≥ pre-screen pole threshold. A typical 25-kHz stripe + # layout for DIII-D-style equilibria (with kHz/Q given by the per-surface + # τ_k) is built externally by the driver, converted to Q-units, and + # passed in here. + boxes::Vector{NTuple{4,Float64}} = NTuple{4,Float64}[] + multi_box_prescreen_n::Int = 25 # pre-screen grid resolution per box + + pole_threshold::Float64 = 10.0 + pole_threshold_adaptive::Bool = false + filter_above_poles::Bool = true + filter_outside_re::Bool = true + gap_kHz_threshold::Float64 = 1.0 # forwarded to find_growth_rates + # Refine each contour-intersection root to the true zero of the dispersion + # residual (isolate-then-polish). Makes the extracted γ resolution- and + # thread-independent; adds ~a handful of residual evals per root. Set false + # to report the raw marching-squares estimate. Applies to the UNCOUPLED + # (per-surface scalar) path; the coupled determinant is ill-conditioned and + # uses raw contour extraction regardless. + polish_roots::Bool = true + # Validity gate (uncoupled + polish_roots only): drop a root whose polished + # |residual| is not below validity_rtol × median(|Δ|) — a contour near-miss + # / failed-Δ'-BVP surface, not a real root. Flagged `:spurious`. + validity_rtol::Float64 = 1e-3 + + profile_file::String = "" + profile_group::String = "/" + + store_scan::Bool = false +end + +const _VALID_INNER_MODELS = (:slayer_fitzpatrick, :ggj_shooting, :ggj_galerkin) +const _VALID_SCAN_MODES = (:amr, :brute_force) +const _VALID_COUPLING_MODES = (:uncoupled, :coupled) +const _VALID_DC_TYPES = (:none, :lar, :rfitzp, :toroidal) +const _VALID_RESISTIVITY_MODELS = (:sauter, :redl, :spitzer, :spitzer_harm) +const _VALID_LNLAMBDA_FORMS = (:nrl, :sauter, :wesson) + +function validate(ctrl::SLAYERControl) + ctrl.inner_model in _VALID_INNER_MODELS || + throw(ArgumentError("SLAYERControl: inner_model=$(ctrl.inner_model) " * + "not in $(_VALID_INNER_MODELS)")) + ctrl.scan_mode in _VALID_SCAN_MODES || + throw(ArgumentError("SLAYERControl: scan_mode=$(ctrl.scan_mode) " * + "not in $(_VALID_SCAN_MODES)")) + ctrl.coupling_mode in _VALID_COUPLING_MODES || + throw(ArgumentError("SLAYERControl: coupling_mode=$(ctrl.coupling_mode) " * + "not in $(_VALID_COUPLING_MODES)")) + ctrl.dc_type in _VALID_DC_TYPES || + throw(ArgumentError("SLAYERControl: dc_type=$(ctrl.dc_type) " * + "not in $(_VALID_DC_TYPES)")) + ctrl.resistivity_model in _VALID_RESISTIVITY_MODELS || + throw(ArgumentError("SLAYERControl: resistivity_model=$(ctrl.resistivity_model) " * + "not in $(_VALID_RESISTIVITY_MODELS)")) + ctrl.lnLambda_form in _VALID_LNLAMBDA_FORMS || + throw(ArgumentError("SLAYERControl: lnLambda_form=$(ctrl.lnLambda_form) " * + "not in $(_VALID_LNLAMBDA_FORMS)")) + ctrl.msing_max >= 1 || + throw(ArgumentError("SLAYERControl: msing_max=$(ctrl.msing_max) must be ≥ 1")) + ctrl.nre >= 2 && ctrl.nim >= 2 || + throw(ArgumentError("SLAYERControl: nre and nim must both be ≥ 2")) + ctrl.amr_passes >= 0 || + throw(ArgumentError("SLAYERControl: amr_passes must be ≥ 0")) + return ctrl +end + +# Helper: coerce range-like values to a 2-tuple of Float64 +_as_range(x::NTuple{2,<:Real}) = (Float64(x[1]), Float64(x[2])) +_as_range(x::AbstractVector) = begin + length(x) == 2 || throw(ArgumentError("range must be length 2, got length $(length(x))")) + (Float64(x[1]), Float64(x[2])) +end + +""" + slayer_control_from_toml(section::AbstractDict) -> SLAYERControl + +Parse a `[SLAYER]` TOML section into a `SLAYERControl`. Known nested +subsections (`[SLAYER.scan_grid]`, `[SLAYER.amr]`, +`[SLAYER.growth_rate_filter]`) are flattened into the top-level fields. +Unknown keys raise an error so typos don't silently produce defaults. +""" +function slayer_control_from_toml(section::AbstractDict) + # Flatten nested sections into the top-level key dictionary + flat = Dict{String,Any}() + for (k, v) in section + if k == "scan_grid" && v isa AbstractDict + # Promote scan_grid fields to top-level + haskey(v, "Q_re_range") && (flat["Q_re_range"] = v["Q_re_range"]) + haskey(v, "Q_im_range") && (flat["Q_im_range"] = v["Q_im_range"]) + haskey(v, "nre") && (flat["nre"] = v["nre"]) + haskey(v, "nim") && (flat["nim"] = v["nim"]) + elseif k == "amr" && v isa AbstractDict + haskey(v, "passes") && (flat["amr_passes"] = v["passes"]) + haskey(v, "max_cells") && (flat["amr_max_cells"] = v["max_cells"]) + elseif k == "growth_rate_filter" && v isa AbstractDict + haskey(v, "pole_threshold") && (flat["pole_threshold"] = v["pole_threshold"]) + haskey(v, "filter_above_poles") && (flat["filter_above_poles"] = v["filter_above_poles"]) + haskey(v, "filter_outside_re") && (flat["filter_outside_re"] = v["filter_outside_re"]) + else + flat[k] = v + end + end + + # Validate keys against the struct fields + field_names = Set(String.(fieldnames(SLAYERControl))) + unknown = [k for k in keys(flat) if !(k in field_names)] + isempty(unknown) || + throw(ArgumentError("slayer_control_from_toml: unknown keys " * + "$(unknown) in [SLAYER] section. Known: " * + "$(sort(collect(field_names))).")) + + # Coerce types where needed + kwargs = Dict{Symbol,Any}() + for (k, v) in flat + sym = Symbol(k) + if sym in (:inner_model, :scan_mode, :coupling_mode, :dc_type, + :resistivity_model, :lnLambda_form) + kwargs[sym] = v isa Symbol ? v : Symbol(String(v)) + elseif sym in (:Q_re_range, :Q_im_range) + kwargs[sym] = _as_range(v) + elseif sym in (:bt, :dr_val, :dgeo_val) + # Allow explicit nothing (auto-derive) or a number (override) + kwargs[sym] = v === nothing ? nothing : Float64(v) + elseif sym === :boxes + # `boxes` is a Vector{NTuple{4,Float64}}; from TOML this comes + # in as a list of 4-element arrays. Coerce each. + kwargs[sym] = NTuple{4,Float64}[ + let bb = collect(Float64, b) + length(bb) == 4 || + throw(ArgumentError("SLAYER.boxes entry must have 4 " * + "elements (omega_lo, omega_hi, " * + "gamma_lo, gamma_hi); got $b")) + (bb[1], bb[2], bb[3], bb[4]) + end + for b in v + ] + else + kwargs[sym] = v + end + end + return validate(SLAYERControl(; kwargs...)) +end diff --git a/src/Tearing/Runner/HDF5Output.jl b/src/Tearing/Runner/HDF5Output.jl new file mode 100644 index 00000000..107a3be1 --- /dev/null +++ b/src/Tearing/Runner/HDF5Output.jl @@ -0,0 +1,238 @@ +# HDF5Output.jl +# +# Write a `SLAYERResult` into an HDF5 group. Designed to be called by the +# existing `PerturbedEquilibrium.write_outputs_to_HDF5` path — the +# top-level GPEC runner wires that up; this file only defines the pure +# writer. +# +# Output layout (relative to the parent group the caller provides): +# +# slayer/ +# ├── settings/ -- control snapshot (strings, scalars) +# ├── per_surface/ -- struct-of-arrays for SLAYERParameters fields +# │ ├── psi, q, q1, ... +# │ └── ... +# ├── roots/ -- Q_root (real, imag), omega_Hz, gamma_Hz +# ├── diagnostics/ -- all_valid_roots, poles, filtered_roots +# │ (flat-plus-offsets ragged encoding) +# └── scan/ -- optional: full Q/Δ scan data + +using HDF5 + +""" + write_slayer_hdf5!(parent::Union{HDF5.File,HDF5.Group}, + result::SLAYERResult) + +Write `result` into a `slayer/` subgroup of `parent`. The subgroup is +created if missing and overwritten if it already exists (keeps the +output file reproducible across reruns). +""" +function write_slayer_hdf5!(parent::Union{HDF5.File,HDF5.Group}, + result::SLAYERResult) + if haskey(parent, "slayer") + delete_object(parent, "slayer") + end + g = create_group(parent, "slayer") + g["enabled"] = Int(result.enabled) + + result.enabled || return g # nothing else to write + + _write_settings!(g, result.control) + _write_per_surface!(g, result.params, result.dp_matrix) + _write_roots!(g, result) + _write_layer_widths!(g, result.layer_widths) + _write_diagnostics!(g, result) + if result.control.store_scan && !isempty(result.scan_data) + _write_scan_data!(g, result) + end + return g +end + +# ---------- settings snapshot ---------- +function _write_settings!(g, ctrl::SLAYERControl) + s = create_group(g, "settings") + s["inner_model"] = String(ctrl.inner_model) + s["scan_mode"] = String(ctrl.scan_mode) + s["coupling_mode"] = String(ctrl.coupling_mode) + s["dc_type"] = String(ctrl.dc_type) + s["msing_max"] = ctrl.msing_max + s["bt"] = ctrl.bt === nothing ? NaN : ctrl.bt + s["mu_i"] = ctrl.mu_i + s["zeff"] = ctrl.zeff + s["chi_perp"] = ctrl.chi_perp + s["chi_tor"] = ctrl.chi_tor + # NaN sentinel records the auto-derive (nothing) setting in a numeric dataset. + s["dr_val"] = ctrl.dr_val === nothing ? NaN : ctrl.dr_val + s["dgeo_val"] = ctrl.dgeo_val === nothing ? NaN : ctrl.dgeo_val + s["theta_sample"] = ctrl.theta_sample + s["resistivity_model"] = String(ctrl.resistivity_model) + s["lnLambda_form"] = String(ctrl.lnLambda_form) + s["Q_re_range"] = collect(ctrl.Q_re_range) + s["Q_im_range"] = collect(ctrl.Q_im_range) + s["nre"] = ctrl.nre + s["nim"] = ctrl.nim + s["amr_passes"] = ctrl.amr_passes + s["amr_max_cells"] = ctrl.amr_max_cells + # Multi-box stripe layout: flatten Vector{NTuple{4}} to an N×4 matrix + # (empty → 0×4) so a rerun can reconstruct the exact scan boxes. + s["boxes"] = isempty(ctrl.boxes) ? Matrix{Float64}(undef, 0, 4) : + permutedims(reduce(hcat, collect.(ctrl.boxes))) + s["multi_box_prescreen_n"] = ctrl.multi_box_prescreen_n + s["pole_threshold"] = ctrl.pole_threshold + s["pole_threshold_adaptive"] = Int(ctrl.pole_threshold_adaptive) + s["filter_above_poles"] = Int(ctrl.filter_above_poles) + s["filter_outside_re"] = Int(ctrl.filter_outside_re) + s["gap_kHz_threshold"] = ctrl.gap_kHz_threshold + s["polish_roots"] = Int(ctrl.polish_roots) + s["validity_rtol"] = ctrl.validity_rtol + s["profile_file"] = ctrl.profile_file + s["profile_group"] = ctrl.profile_group + s["store_scan"] = Int(ctrl.store_scan) + return nothing +end + +# ---------- per-surface layer parameters ---------- +function _write_per_surface!(g, params::AbstractVector{SLAYERParameters}, + dp_matrix::Matrix{ComplexF64}) + ps = create_group(g, "per_surface") + + # Scalar struct-of-arrays for all Float64 / Int fields + for fname in (:ising, :m, :n) + ps[String(fname)] = Int[getfield(p, fname) for p in params] + end + for fname in (:tau, :lu, :c_beta, :D_norm, :P_perp, :P_tor, + :Q_e, :Q_i, :iota_e, + :tauk, :tau_r, :delta_n, + :rs, :R0, :bt, :sval_r, :dr_val, :dgeo_val, + :eta, :d_beta, :dc_tmp) + ps[String(fname)] = Float64[getfield(p, fname) for p in params] + end + # Store dc_type per-surface as string array + ps["dc_type"] = String[String(p.dc_type) for p in params] + + # Full Δ' matrix, split real/imag + dp = create_group(ps, "dp_matrix") + dp["real"] = real.(dp_matrix) + dp["imag"] = imag.(dp_matrix) + return nothing +end + +# GGJ per-surface parameters carry the geometric coefficients (E,F,G,H,K,M) +# and resistive/Alfvén times rather than the SLAYER dimensionless set. +function _write_per_surface!(g, params::AbstractVector{GGJParameters}, + dp_matrix::Matrix{ComplexF64}) + ps = create_group(g, "per_surface") + ps["ising"] = Int[p.ising for p in params] + for fname in (:E, :F, :G, :H, :K, :M, :taua, :taur, :v1) + ps[String(fname)] = Float64[getfield(p, fname) for p in params] + end + dp = create_group(ps, "dp_matrix") + dp["real"] = real.(dp_matrix) + dp["imag"] = imag.(dp_matrix) + return nothing +end + +# ---------- eigenvalue roots ---------- +function _write_roots!(g, r::SLAYERResult) + roots = create_group(g, "roots") + roots["Q_root_real"] = real.(r.Q_root) + roots["Q_root_imag"] = imag.(r.Q_root) + roots["omega_Hz"] = r.omega_Hz + roots["gamma_Hz"] = r.gamma_Hz + # `no_root[k] == 1` flags entries where the extraction found NO usable + # root (Q_root is NaN; omega_Hz/gamma_Hz are 0 placeholders, not a true + # γ≈0 result). Aligned element-wise with Q_root/omega_Hz/gamma_Hz. + extractions = r.coupled_extraction !== nothing ? [r.coupled_extraction] : + r.per_surface_extraction + roots["no_root"] = Int[(:no_root in gr.warning_flags) ? 1 : 0 + for gr in extractions] + return nothing +end + +# ---------- resistive layer thickness (del_s Riccati) ---------- +function _write_layer_widths!(g, widths::Vector{LayerWidths}) + lw = create_group(g, "layer_widths") + for fname in (:ising, :m, :n) + lw[String(fname)] = Int[getfield(w, fname) for w in widths] + end + # Dimensionless del_s/d_beta and the complex layer thickness, split re/im. + lw["dels_db_real"] = Float64[real(w.dels_db) for w in widths] + lw["dels_db_imag"] = Float64[imag(w.dels_db) for w in widths] + lw["delta_s_real"] = Float64[real(w.delta_s) for w in widths] + lw["delta_s_imag"] = Float64[imag(w.delta_s) for w in widths] + # Physical thickness [m] and the β-weighted ion drift scale [m]. + lw["delta_s_m"] = Float64[w.delta_s_m for w in widths] + lw["d_beta"] = Float64[w.d_beta for w in widths] + return nothing +end + +# ---------- diagnostics: valid roots, poles, filtered roots ---------- +function _write_diagnostics!(g, r::SLAYERResult) + diag = create_group(g, "diagnostics") + # Uncoupled: one GrowthRateResult per surface. Coupled: one total. + extractions = if r.coupled_extraction !== nothing + [r.coupled_extraction] + else + r.per_surface_extraction + end + + _write_ragged_complex!(diag, "valid_roots", + [gr.valid_roots for gr in extractions]) + _write_ragged_complex!(diag, "poles", + [gr.poles for gr in extractions]) + _write_ragged_complex!(diag, "filtered_roots", + [gr.filtered_roots for gr in extractions]) + return nothing +end + +# Write a ragged vector-of-vectors of ComplexF64 as (flat_re, flat_im, +# offsets) — `offsets[k+1] - offsets[k]` is the length of row `k`. This +# avoids HDF5 VLEN types, which have patchy cross-language support. +function _write_ragged_complex!(parent, name::String, + data::Vector{Vector{ComplexF64}}) + g = create_group(parent, name) + flat_re = Float64[] + flat_im = Float64[] + offsets = Int[0] + for v in data + append!(flat_re, real.(v)) + append!(flat_im, imag.(v)) + push!(offsets, offsets[end] + length(v)) + end + g["flat_real"] = flat_re + g["flat_imag"] = flat_im + g["offsets"] = offsets + return nothing +end + +# ---------- full scan data (optional) ---------- +function _write_scan_data!(g, r::SLAYERResult) + sc = create_group(g, "scan") + for (k, data) in enumerate(r.scan_data) + sk = create_group(sc, "surface_$(k)") + _write_single_scan!(sk, data) + end + return nothing +end + +function _write_single_scan!(g, data::ScanResult) + g["kind"] = "brute_force" + g["Q_real"] = real.(data.Q) + g["Q_imag"] = imag.(data.Q) + g["Delta_real"] = real.(data.Δ) + g["Delta_imag"] = imag.(data.Δ) + g["re_axis"] = data.re_axis + g["im_axis"] = data.im_axis + return nothing +end + +function _write_single_scan!(g, data::AMRResult) + g["kind"] = "amr" + g["Q_real"] = real.(data.Q) + g["Q_imag"] = imag.(data.Q) + g["Delta_real"] = real.(data.Δ) + g["Delta_imag"] = imag.(data.Δ) + g["n_cells"] = length(data.cells) + g["truncated"] = Int(data.truncated) + return nothing +end diff --git a/src/Tearing/Runner/Result.jl b/src/Tearing/Runner/Result.jl new file mode 100644 index 00000000..f5a57e88 --- /dev/null +++ b/src/Tearing/Runner/Result.jl @@ -0,0 +1,59 @@ +# Result.jl +# +# `SLAYERResult` packages the output of a full SLAYER analysis run: +# per-surface layer parameters, the extracted tearing eigenvalues, and (if +# `control.store_scan`) the full Q-plane scan data for plotting. + +""" + SLAYERResult + +Output of `run_slayer`. Carries both summary eigenvalues (ω_Hz, γ_Hz) and +full diagnostic detail (valid roots, poles, filtered roots, contours) for +downstream inspection and HDF5 output. + +# Fields + + - `enabled` -- `true` only when the analysis actually ran + - `control` -- the `SLAYERControl` used (frozen snapshot) + - `params` -- `Vector{SLAYERParameters}`, one per surface + - `dp_matrix` -- outer-region Δ' matrix used in the analysis + - `Q_root` -- tearing eigenvalue(s) in normalized Q + * length `nsurfaces` in `:uncoupled` mode + * length `1` in `:coupled` mode (global eigenvalue normalized by + `params[1].tauk`) + - `omega_Hz`, `gamma_Hz` -- physical rotation frequency / growth rate + - `per_surface_extraction` -- `Vector{GrowthRateResult}` of length + `nsurfaces` in uncoupled mode (each includes polelines, pole list, + valid roots, filtered roots). Empty in coupled mode. + - `coupled_extraction` -- single `GrowthRateResult` in coupled mode. + `nothing` otherwise. + - `layer_widths` -- `Vector{LayerWidths}`, one per surface: the + resistive layer thickness (in meters) from the `del_s` Riccati solve + plus FKR / visco-resistive sanity scales. Empty when disabled. + - `scan_data` -- scan results (per-surface in uncoupled, single + entry in coupled). Empty unless `control.store_scan == true`. +""" +struct SLAYERResult + enabled::Bool + control::SLAYERControl + params::AbstractVector{<:InnerLayerParameters} + dp_matrix::Matrix{ComplexF64} + Q_root::Vector{ComplexF64} + omega_Hz::Vector{Float64} + gamma_Hz::Vector{Float64} + per_surface_extraction::Vector{GrowthRateResult} + coupled_extraction::Union{Nothing,GrowthRateResult} + layer_widths::Vector{LayerWidths} + scan_data::Vector{Union{ScanResult,AMRResult}} +end + +# Empty result (enabled=false path) +function empty_slayer_result(control::SLAYERControl) + return SLAYERResult(false, control, + SLAYERParameters[], + zeros(ComplexF64, 0, 0), + ComplexF64[], Float64[], Float64[], + GrowthRateResult[], nothing, + LayerWidths[], + Union{ScanResult,AMRResult}[]) +end diff --git a/src/Tearing/Runner/Runner.jl b/src/Tearing/Runner/Runner.jl new file mode 100644 index 00000000..49edcb4b --- /dev/null +++ b/src/Tearing/Runner/Runner.jl @@ -0,0 +1,58 @@ +# Runner.jl +# +# Top-level orchestration module that ties together the building blocks +# from InnerLayer, Dispersion, and Utilities into the user-facing SLAYER +# tearing-mode analysis pipeline. +# +# gpec.toml [SLAYER] → SLAYERControl +# │ +# equilibrium + Δ' │ +# + profiles → build_slayer_inputs → SLAYERParameters[] +# │ +# ▼ +# SurfaceCoupling[] / MultiSurfaceCoupling +# │ +# ▼ +# brute_force_scan / amr_scan +# │ +# ▼ +# find_growth_rates +# │ +# ▼ +# SLAYERResult → HDF5 (`slayer/` group) + +module Runner + +using LinearAlgebra +using Statistics: mean, median +using HDF5 + +using FastInterpolations: cubic_interp +using ..Utilities +using ..Utilities: KineticProfiles +using ...Equilibrium: read_kinetic_file, KineticProfileData +using ..InnerLayer +using ..InnerLayer: InnerLayerParameters, InnerLayerResponse, solve_inner, + SLAYERModel, SLAYERParameters, build_slayer_inputs, + GGJModel, GGJParameters, + LayerWidths, slayer_layer_thickness +import ..build_ggj_inputs # defined at the Tearing level (needs ForceFreeStates) +using ..Dispersion +using ..Dispersion: SurfaceCoupling, surface_coupling, + MultiSurfaceCoupling, multi_surface_coupling, + ScanResult, brute_force_scan, + AMRResult, amr_scan, + MultiBoxAMRResult, multi_box_amr_scan, as_amr_result, + GrowthRateResult, find_growth_rates + +include("Control.jl") +include("Result.jl") +include("run_slayer.jl") +include("HDF5Output.jl") + +export SLAYERControl, slayer_control_from_toml, validate +export SLAYERResult, empty_slayer_result +export run_slayer, run_slayer_from_inputs, ggj_inner_deltas +export write_slayer_hdf5! + +end # module Runner diff --git a/src/Tearing/Runner/run_slayer.jl b/src/Tearing/Runner/run_slayer.jl new file mode 100644 index 00000000..0e254e62 --- /dev/null +++ b/src/Tearing/Runner/run_slayer.jl @@ -0,0 +1,435 @@ +# 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 +# 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`. +# +# A secondary entry point `run_slayer_from_inputs` takes pre-built +# per-surface parameters + a Δ' matrix and bypasses the +# equilibrium-driven `build_slayer_inputs` step. This is what the test +# suite drives; it keeps the end-to-end code covered without requiring a +# full equilibrium solve in every test. + +# --------------------------------------------------------------------- +# Profile loading +# --------------------------------------------------------------------- +# Read kinetic profiles through the shared `Equilibrium.read_kinetic_file` +# reader and adapt them to the SLAYER inner layer. Returns the spline-based +# `KineticProfiles` plus χ⊥(ψ)/χ_φ(ψ) callables built from the file's +# `chi_e`/`chi_phi` (or `nothing` when the file carries no usable χ, in which +# case the caller falls back to the scalar `control.chi_perp`/`chi_tor`). +function _load_profiles(control::SLAYERControl, dir_path::AbstractString) + isempty(control.profile_file) && + error("run_slayer: [SLAYER] profile_file is empty — point it at a " * + "kinetic-profile file (HDF5 GPEC kinetic schema or ASCII table).") + path = isabspath(control.profile_file) ? control.profile_file : + joinpath(dir_path, control.profile_file) + data = read_kinetic_file(path; group=control.profile_group) + + for (name, v) in (("n_e", data.n_e), ("T_e", data.T_e), ("T_i", data.T_i)) + v === nothing && + error("run_slayer: kinetic file '$path' is missing required " * + "dataset '$name' for the SLAYER inner layer.") + end + # ω_*e/ω_*i are recomputed per-surface from equilibrium gradients + # (compute_omega_star), so the diamagnetic-frequency inputs here are + # placeholders; `omega` carries the ExB rotation when present. + npsi = length(data.psi) + omega = data.omega_E === nothing ? zeros(npsi) : data.omega_E + profiles = KineticProfiles(; psi=data.psi, n_e=data.n_e, T_e=data.T_e, + T_i=data.T_i, omega=omega, + omega_e=zeros(npsi), omega_i=zeros(npsi)) + + # χ⊥(ψ)/χ_φ(ψ) splines from the file. A χ array that is absent OR all-zero + # is treated as "not provided" — χ must be positive (χ=0 ⇒ τ_⊥→∞), and the + # all-zero sentinel lets a file keep the chi_e/chi_phi keys while deferring + # to the scalar control.chi_perp/chi_tor fallback. Build the spline only + # from a usable (present, not all-zero) array. + psi_xs = collect(Float64, data.psi) + _chi_spline(v) = (v === nothing || all(iszero, v)) ? nothing : + cubic_interp(psi_xs, collect(Float64, v)) + chi_perp = _chi_spline(data.chi_e) + chi_tor = _chi_spline(data.chi_phi) + return (profiles=profiles, chi_perp=chi_perp, chi_tor=chi_tor) +end + +# --------------------------------------------------------------------- +# Inner-layer model factory +# --------------------------------------------------------------------- +function _build_inner_model(name::Symbol) + if name === :slayer_fitzpatrick + return SLAYERModel(; variant=:fitzpatrick) + elseif name === :ggj_shooting + return GGJModel(; solver=:shooting) + elseif name === :ggj_galerkin + return GGJModel(; solver=:galerkin) + end + throw(ArgumentError("_build_inner_model: unknown model $name")) +end + +# Map the TOML resistivity_model symbol to a NeoResistivityModel instance. +function _build_resistivity_model(name::Symbol) + name === :sauter && return SauterNeoModel() + name === :redl && return RedlNeoModel() + name === :spitzer && return SpitzerModel() + name === :spitzer_harm && return SpitzerHarmModel() + throw(ArgumentError("_build_resistivity_model: unknown model $name")) +end + +# Human-readable surface label for warnings. SLAYER params carry (m, n); +# GGJ params carry only the singular-surface index `ising`. +_surface_label(p::SLAYERParameters) = "m=$(p.m), n=$(p.n)" +_surface_label(p::InnerLayerParameters) = "ising=$(getfield(p, :ising))" + +# Is this an unvalidated GGJ inner-layer model? Used to gate the γ-extraction +# future-work warning. +_is_ggj(::GGJModel) = true +_is_ggj(::Any) = false + +# --------------------------------------------------------------------- +# Scan dispatch +# --------------------------------------------------------------------- +function _run_scan(f, control::SLAYERControl) + if control.scan_mode === :brute_force + return brute_force_scan(f, control.Q_re_range, control.Q_im_range; + nre=control.nre, nim=control.nim) + elseif control.scan_mode === :amr + if !isempty(control.boxes) + # Multi-box stripe layout. Pole magnitude threshold for the + # activity check is derived from a coarse 16×6 sample of the + # union of all boxes, using 10 × median(|Δ|) (median is robust + # to near-pole sample inflation; see the adaptive threshold note + # below). + ω_lo = minimum(b[1] for b in control.boxes) + ω_hi = maximum(b[2] for b in control.boxes) + γ_lo = minimum(b[3] for b in control.boxes) + γ_hi = maximum(b[4] for b in control.boxes) + coarse_pts = ComplexF64[ComplexF64(ω, γ) + for ω in range(ω_lo, ω_hi; length=16) + for γ in range(γ_lo, γ_hi; length=6)] + coarse_Δ = ComplexF64[ComplexF64(f(q)) for q in coarse_pts] + finite = filter(z -> isfinite(z) && abs(z) < 1e30, coarse_Δ) + pole_thr = isempty(finite) ? 1e8 : 10.0 * median(abs.(finite)) + # Convert NTuple{4,Float64} → ((ω_lo,ω_hi),(γ_lo,γ_hi)) tuples + boxes_in = [((b[1], b[2]), (b[3], b[4])) for b in control.boxes] + return multi_box_amr_scan(f, boxes_in; + pole_magnitude_threshold=pole_thr, + prescreen_nre=control.multi_box_prescreen_n, + prescreen_nim=control.multi_box_prescreen_n, + nre0=control.nre, nim0=control.nim, + passes=control.amr_passes, + max_cells=control.amr_max_cells, + max_cells_action=:warn_truncate) |> + as_amr_result # downstream expects AMRResult + end + return amr_scan(f, control.Q_re_range, control.Q_im_range; + nre0=control.nre, nim0=control.nim, + passes=control.amr_passes, + max_cells=control.amr_max_cells) + end + throw(ArgumentError("_run_scan: unknown scan_mode=$(control.scan_mode)")) +end + +# --------------------------------------------------------------------- +# Surface-coupling builder — dispatches on model type to thread the +# correct `scale` and `tauk` through the Dispersion API. +# --------------------------------------------------------------------- +# SLAYER: scale = lu^(1/3), tauk from the surface, dc from the χ‖ proxy. +function _build_surface_coupling(model::SLAYERModel, params::SLAYERParameters, + dp_diag) + return surface_coupling(model, params, dp_diag; dc=params.dc_tmp) +end + +# GGJ: scale = 1.0 (rescale_delta applied inside solve_inner), tauk = 1.0, +# dc = 0 (the 4m×4m Pletzer-Dewar residual carries interchange stabilization +# natively). See the GGJ `surface_coupling` method. +function _build_surface_coupling(model::GGJModel, params::GGJParameters, + dp_diag) + return surface_coupling(model, params, dp_diag) +end + +# --------------------------------------------------------------------- +# Core analysis entry point that takes pre-built parameters. +# --------------------------------------------------------------------- +""" + run_slayer_from_inputs(params::Vector{SLAYERParameters}, + dp_matrix::AbstractMatrix, + control::SLAYERControl) -> SLAYERResult + +Run the SLAYER tearing analysis given pre-built per-surface +`SLAYERParameters` and the outer-region Δ' matrix. Bypasses the +equilibrium-driven `build_slayer_inputs` step — use this when the +parameters are already known (e.g. in unit tests or when rebuilding +from cached HDF5 output). +""" +function run_slayer_from_inputs(params::AbstractVector{<:InnerLayerParameters}, + dp_matrix::AbstractMatrix, + control::SLAYERControl) + validate(control) + control.enabled || return empty_slayer_result(control) + isempty(params) && return empty_slayer_result(control) + + n = length(params) + size(dp_matrix) == (n, n) || + throw(ArgumentError("run_slayer: dp_matrix size $(size(dp_matrix)) " * + "≠ ($n, $n)")) + dp = Matrix{ComplexF64}(dp_matrix) + + model = _build_inner_model(control.inner_model) + + # Guard: the inner-layer model and the parameter eltype must match, or + # `_build_surface_coupling` throws an opaque MethodError downstream. + expected_P = _is_ggj(model) ? GGJParameters : SLAYERParameters + all(p -> p isa expected_P, params) || + throw( + ArgumentError( + "run_slayer: inner_model=$(control.inner_model) requires " * + "$(expected_P) per-surface parameters, but got eltype " * + "$(eltype(params)). Build inputs with the matching builder " * + "(build_slayer_inputs for SLAYER, build_ggj_inputs for GGJ).") + ) + + # The coupled determinant uses the reduced m×m (tearing-only) form, which + # drops the interchange channel. For GGJ that channel carries the Glasser + # interchange stabilization, so coupled-GGJ results omit real physics. + if _is_ggj(model) && control.coupling_mode === :coupled + @warn( + "SLAYER: coupling_mode=:coupled with a GGJ inner model uses the " * + "reduced m×m tearing-only determinant, which DROPS the GGJ " * + "interchange (Glasser stabilization) channel — results are " * + "physically incomplete. Use the full Pletzer-Dewar matching " * + "(multi_surface_coupling_full) for coupled GGJ studies." + ) + end + + # GGJ growth-rate extraction by Re/Im contour matching is not yet + # reliable (the contours do not robustly intersect at a dispersion-relation + # zero). The scan still runs so the user can inspect/plot the contours and + # access both parity Δ channels via `ggj_inner_deltas`, but the reported γ + # is unvalidated and should be treated as future work. + if _is_ggj(model) + @warn( + "SLAYER: GGJ γ-extraction by Re/Im contour matching is " * + "FUTURE WORK — contours do not reliably intersect at a " * + "dispersion-relation root. The scan grid and parity Δ channels " * + "are provided for inspection; reported growth rates are " * + "unvalidated placeholders." + ) + end + + # Per-surface SurfaceCoupling objects + scs = [_build_surface_coupling(model, params[k], dp[k, k]) for k in 1:n] + + # Per-surface resistive layer thickness [m] via the del_s Riccati solve. + # Independent of the dispersion scan / coupling mode — a pure diagnostic. + # SLAYER-only: the del_s Riccati is defined on SLAYERParameters. GGJ + # surfaces carry no analogous layer-thickness diagnostic, so leave it empty. + layer_widths = eltype(params) <: SLAYERParameters ? + LayerWidths[slayer_layer_thickness(params[k]) for k in 1:n] : + LayerWidths[] + + Q_root = ComplexF64[] + omega_Hz = Float64[] + gamma_Hz = Float64[] + per_surface_extraction = GrowthRateResult[] + coupled_extraction = nothing + scan_data_list = Union{ScanResult,AMRResult}[] + + # Helper: compute the pole_threshold actually passed to find_growth_rates. + # When `control.pole_threshold_adaptive` is true, override with + # `10 × median(|Δ|)` over the scan's dispersion residual array. + # + # The median formulation is robust against pre-screen samples landing + # near a pole. A single near-pole sample inflates `|mean(Δ)|` by orders + # of magnitude (and `|mean|` further collapses on oscillating residuals + # whose phases cancel in the complex sum). 10 × median(|Δ|) reflects + # "10× the typical residual magnitude" with median robust to both + # pathologies. + function _pole_threshold_for(scan) + control.pole_threshold_adaptive || return control.pole_threshold + # ScanResult and AMRResult both carry `.Δ` — abstract over both + Δ_arr = hasproperty(scan, :Δ) ? scan.Δ : nothing + Δ_arr === nothing && return control.pole_threshold + finite = filter(z -> isfinite(z) && abs(z) < 1e30, Δ_arr) + isempty(finite) && return control.pole_threshold + return 10.0 * median(abs.(finite)) + end + + if control.coupling_mode === :uncoupled + for (k, sc) in enumerate(scs) + scan = _run_scan(sc, control) + pthr = _pole_threshold_for(scan) + gr = find_growth_rates(scan, sc.tauk; + pole_threshold=pthr, + filter_above_poles=control.filter_above_poles, + filter_outside_re=control.filter_outside_re, + gap_kHz_threshold=control.gap_kHz_threshold, + residual=control.polish_roots ? sc : nothing, + validity_rtol=control.validity_rtol) + :no_root in gr.warning_flags && @warn( + "SLAYER: no usable growth-rate root found for surface " * + "$(_surface_label(params[k])); reported γ=0 is a " * + "placeholder, not a physical result — check scan grid / " * + "pole_threshold.") + push!(Q_root, gr.Q_root) + push!(omega_Hz, gr.omega_Hz) + push!(gamma_Hz, gr.gamma_Hz) + push!(per_surface_extraction, gr) + control.store_scan && push!(scan_data_list, scan) + end + + elseif control.coupling_mode === :coupled + m_use = min(control.msing_max, n) + mc = multi_surface_coupling(scs, dp; ref_idx=1, msing_max=m_use) + scan = _run_scan(mc, control) + pthr = _pole_threshold_for(scan) + ref_tauk = scs[1].tauk + # Coupled path: no root polishing / validity gate. The m×m coupled + # determinant det(D'−D(Q)) is ill-conditioned (its magnitude floors well + # above zero), so |det|-based polishing is a no-op and the residual-scale + # gate is unreliable. A σ_min-based coupled refinement is a dev follow-up; + # for now the coupled determinant uses the raw contour extraction (the + # BLAS pin in the scan still makes it thread-deterministic). + gr = find_growth_rates(scan, ref_tauk; + pole_threshold=pthr, + filter_above_poles=control.filter_above_poles, + filter_outside_re=control.filter_outside_re, + gap_kHz_threshold=control.gap_kHz_threshold) + :no_root in gr.warning_flags && @warn( + "SLAYER: no usable growth-rate root found for the coupled " * + "$(m_use)-surface determinant; reported γ=0 is a placeholder, " * + "not a physical result — check scan grid / pole_threshold.") + push!(Q_root, gr.Q_root) + push!(omega_Hz, gr.omega_Hz) + push!(gamma_Hz, gr.gamma_Hz) + coupled_extraction = gr + control.store_scan && push!(scan_data_list, scan) + end + + return SLAYERResult(true, control, params, dp, + Q_root, omega_Hz, gamma_Hz, + per_surface_extraction, coupled_extraction, + layer_widths, scan_data_list) +end + +# --------------------------------------------------------------------- +# GGJ parity-Δ diagnostic +# --------------------------------------------------------------------- +""" + ggj_inner_deltas(params::AbstractVector{GGJParameters}, Q::Number; + solver=:galerkin) -> Vector{NamedTuple} + +Evaluate both parity channels of the GGJ inner-layer matching data at the +complex normalized growth rate `Q` for each rational surface. Returns a +vector of `(ising, tearing, interchange)` named tuples, where `tearing` +(GWP Δ_+) is the reconnecting channel and `interchange` (GWP Δ_−) the +non-reconnecting Glasser-stabilization channel (see `InnerLayerResponse`). + +This is the supported GGJ diagnostic: both parity Δ's are physical and +directly accessible here. Contour-matching γ extraction from these (the +Re/Im scan intersection) is not yet validated — see the warning in +`run_slayer_from_inputs`. +""" +function ggj_inner_deltas(params::AbstractVector{GGJParameters}, Q::Number; + solver::Symbol=:galerkin) + model = GGJModel(; solver=solver) + out = Vector{NamedTuple{(:ising, :tearing, :interchange), + Tuple{Int,ComplexF64,ComplexF64}}}(undef, length(params)) + for (k, p) in enumerate(params) + r = solve_inner(model, p, ComplexF64(Q)) + out[k] = (ising=p.ising, tearing=r.tearing, interchange=r.interchange) + end + return out +end + +# --------------------------------------------------------------------- +# Full pipeline: equilibrium + ForceFreeStates → parameters → analysis +# --------------------------------------------------------------------- +""" + run_slayer(equil, ffs_intr, control; dir_path="./") -> SLAYERResult + +Orchestrate the full SLAYER analysis against a solved +`PlasmaEquilibrium` and `ForceFreeStatesInternal`. 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). + +Returns an `enabled=false` `SLAYERResult` when `control.enabled` is +false. +""" +function run_slayer(equil, ffs_intr, control::SLAYERControl; + dir_path::AbstractString="./") + validate(control) + control.enabled || return empty_slayer_result(control) + isempty(ffs_intr.sing) && return empty_slayer_result(control) + + loaded = _load_profiles(control, dir_path) + profiles = loaded.profiles + + 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; + mu_i=control.mu_i, + zeff=control.zeff, + resistivity_model=_build_resistivity_model(control.resistivity_model), + lnLambda_form=control.lnLambda_form) + else + bt = control.bt === nothing ? equil.config.b0exp : control.bt + # χ⊥/χ_φ from the kinetic file when present, else the scalar fallbacks. + chi_perp = loaded.chi_perp === nothing ? control.chi_perp : loaded.chi_perp + chi_tor = loaded.chi_tor === nothing ? control.chi_tor : loaded.chi_tor + (loaded.chi_perp === nothing || loaded.chi_tor === nothing) && @warn( + "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; + bt=bt, + mu_i=control.mu_i, + zeff=control.zeff, + chi_perp=chi_perp, + chi_tor=chi_tor, + dr_val=control.dr_val, + dgeo_val=control.dgeo_val, + dc_type=control.dc_type, + theta=control.theta_sample, + resistivity_model=_build_resistivity_model(control.resistivity_model), + lnLambda_form=control.lnLambda_form) + end + + # Δ' 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) + 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) + @warn( + "SLAYER: ffs_intr.delta_prime_matrix is empty or wrong-sized " * + "($(size(ffs_intr.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" * + (n_missing > 0 ? "; $n_missing surface(s) have NO Δ' entry and " * + "default to Δ'=0 (zero tearing drive)." : ".") + ) + M = zeros(ComplexF64, length(params), length(params)) + for (k, s) in enumerate(ffs_intr.sing) + 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) +end diff --git a/src/Tearing/Tearing.jl b/src/Tearing/Tearing.jl new file mode 100644 index 00000000..745a3085 --- /dev/null +++ b/src/Tearing/Tearing.jl @@ -0,0 +1,34 @@ +# Tearing.jl +# +# Umbrella module grouping the tearing-mode analysis stack into a single +# layered hierarchy: +# +# InnerLayer -- pure physics: Δ_inner(Q) for GGJ or SLAYER models +# Dispersion -- physics-agnostic scan + contour-intersection root +# extraction (consumes any InnerLayerModel) +# Runner -- user-facing orchestration: TOML config, profile +# loading, HDF5 output, workflow hooks +# +# `InnerLayer` itself lives at the top level (`src/InnerLayer/`) and is loaded +# before `ForceFreeStates`, which depends on it for the matched-Δ′ Galerkin +# solve. Tearing re-binds it here so `Dispersion` and `Runner` reach it via +# `..InnerLayer`, and owns `build_ggj_inputs`, the equilibrium/ForceFreeStates +# glue that cannot live inside `InnerLayer` without creating a dependency cycle. + +module Tearing + +using ..Utilities + +import ..InnerLayer as InnerLayer + +include("LayerInputs.jl") +include("Dispersion/Dispersion.jl") +include("Runner/Runner.jl") + +import .Dispersion as Dispersion +import .Runner as Runner + +export InnerLayer, Dispersion, Runner +export build_ggj_inputs + +end # module Tearing diff --git a/src/Utilities/KineticProfiles.jl b/src/Utilities/KineticProfiles.jl new file mode 100644 index 00000000..70f1a297 --- /dev/null +++ b/src/Utilities/KineticProfiles.jl @@ -0,0 +1,81 @@ +# KineticProfiles.jl +# +# Radial kinetic-profile container shared across GPEC modules that need +# electron density, electron/ion temperatures, and the three frequencies +# (toroidal rotation + electron/ion diamagnetic) as functions of the +# normalized poloidal flux ψ. SLAYER is the first consumer; PENTRC and +# future resistive-MHD modules will share this object. + +using FastInterpolations + +""" + KineticProfiles + +Radial kinetic-profile container. All six profiles are 1D cubic splines of +the normalized poloidal flux ψ ∈ [0, 1]. + +| field | meaning | units | +|:--------- |:--------------------------------------- |:----- | +| `n_e` | electron density | m⁻³ | +| `T_e` | electron temperature | eV | +| `T_i` | ion temperature | eV | +| `omega` | toroidal rotation | rad/s | +| `omega_e` | electron diamagnetic frequency ω\\_\\*e | rad/s | +| `omega_i` | ion diamagnetic frequency ω\\_\\*i | rad/s | + +Construct via the keyword constructor `KineticProfiles(; psi, n_e, T_e, T_i, omega, omega_e, omega_i)` with matched-length vectors. The SLAYER +runner builds this object from a standardized kinetic-profile file via +`Equilibrium.read_kinetic_file`. + +Evaluate all profiles at a given ψ via the call operator: + +```julia +vals = kp(0.5) # NamedTuple(n_e=..., T_e=..., ..., omega_i=...) +``` +""" +struct KineticProfiles{S} + n_e::S + T_e::S + T_i::S + omega::S + omega_e::S + omega_i::S +end + +function KineticProfiles(; psi::AbstractVector{<:Real}, + n_e::AbstractVector{<:Real}, + T_e::AbstractVector{<:Real}, + T_i::AbstractVector{<:Real}, + omega::AbstractVector{<:Real}, + omega_e::AbstractVector{<:Real}, + omega_i::AbstractVector{<:Real}) + xs = collect(Float64.(psi)) + for (name, v) in (("n_e", n_e), ("T_e", T_e), ("T_i", T_i), + ("omega", omega), ("omega_e", omega_e), + ("omega_i", omega_i)) + length(v) == length(xs) || + throw(ArgumentError("KineticProfiles: length($name) = $(length(v)) " * + "≠ length(psi) = $(length(xs))")) + end + return KineticProfiles(cubic_interp(xs, Float64.(n_e)), + cubic_interp(xs, Float64.(T_e)), + cubic_interp(xs, Float64.(T_i)), + cubic_interp(xs, Float64.(omega)), + cubic_interp(xs, Float64.(omega_e)), + cubic_interp(xs, Float64.(omega_i))) +end + +""" + (kp::KineticProfiles)(psi::Real) -> NamedTuple + +Evaluate all profiles at `psi` and return them as a NamedTuple with fields +`(n_e, T_e, T_i, omega, omega_e, omega_i)`. +""" +(kp::KineticProfiles)(psi::Real) = ( + n_e=kp.n_e(psi), + T_e=kp.T_e(psi), + T_i=kp.T_i(psi), + omega=kp.omega(psi), + omega_e=kp.omega_e(psi), + omega_i=kp.omega_i(psi) +) diff --git a/src/Utilities/NeoclassicalResistivity.jl b/src/Utilities/NeoclassicalResistivity.jl new file mode 100644 index 00000000..de256090 --- /dev/null +++ b/src/Utilities/NeoclassicalResistivity.jl @@ -0,0 +1,339 @@ +# NeoclassicalResistivity.jl +# +# Shared neoclassical-resistivity utilities used by both the GGJ and +# SLAYER inner-layer models. All formulas follow Sauter, Angioni & Lin-Liu +# Phys. Plasmas 6, 2834 (1999) and its errata, with an optional Redl et al. +# Phys. Plasmas 28, 022502 (2021) variant that improves the fit at high +# collisionality. +# +# Two external references were cross-checked during implementation: +# - OpenFUSIONToolkit `TokaMaker/bootstrap.py` (Redl 2021 path) +# - OMFIT `omfit_classes/utils_fusion.py::nclass_conductivity-style +# block` around lines 1255-1319 (Sauter 1999 and `neo_2021` paths) +# +# Formula provenance: +# - eq 18a (Spitzer): Sauter et al. 1999, Eq. (18a) +# - eq 18b (nu*_e): Sauter et al. 1999, Eq. (18b) +# - eq 13 (F_33 Sauter): Sauter et al. 1999, Eqs. (13a)-(13b) +# - eq 17 (F_33 Redl): Redl et al. 2021, Eqs. (17)-(18) +# - f_t (Lin-Liu & Miller): Phys. Plasmas 2, 1666 (1995), Eq. (6) +# - NRL Coulomb log: NRL Plasma Formulary 2009 + +""" + NeoclassicalResistivity + +Spitzer + Sauter / Redl neoclassical resistivity closures, shared between +the GGJ and SLAYER inner-layer models so both see identical plasma-input +physics when the same `NeoResistivityModel` is selected. + +# Exports + +| symbol | role | +|:---------------------- |:------------------------------------------------------ | +| `NeoResistivityModel` | abstract tag | +| `SpitzerModel` | plain Spitzer (no trapped-particle correction) | +| `SpitzerHarmModel` | Fitzpatrick/TJ Spitzer-Härm σ_∥ (legacy SLAYER τ_R) | +| `SauterNeoModel` | Sauter 1999 F_33 neoclassical correction | +| `RedlNeoModel` | Redl 2021 F_33 neoclassical correction | +| `coulomb_log_e` | ln Λ_e (NRL or Sauter form) | +| `eta_spitzer` | Sauter 18a Spitzer resistivity [Ω·m] | +| `tau_ee_spitzer_harm` | electron-electron collision time τ_ee [s] | +| `eta_spitzer_harm` | Fitzpatrick/TJ Spitzer-Härm resistivity 1/σ_∥ [Ω·m] | +| `trapped_fraction` | Lin-Liu & Miller 1995 f_t from ⟨B⟩, ⟨B²⟩, B_min, B_max | +| `trapped_fraction_eps` | simple ε-only f_t fallback | +| `nu_star_e` | Sauter 18b electron collisionality | +| `eta_neoclassical` | dispatched: Spitzer(-Härm) or F_33 · Spitzer | +""" +module NeoclassicalResistivity + +using ..PhysicalConstants: EPS_0, M_E, E_CHG + +export NeoResistivityModel, SpitzerModel, SpitzerHarmModel, SauterNeoModel, RedlNeoModel +export coulomb_log_e, eta_spitzer, tau_ee_spitzer_harm, eta_spitzer_harm +export trapped_fraction, trapped_fraction_eps +export nu_star_e, eta_neoclassical + +""" +Abstract tag for a neoclassical-resistivity closure. +""" +abstract type NeoResistivityModel end + +""" +Plain Spitzer resistivity — no trapped-particle correction. +""" +struct SpitzerModel <: NeoResistivityModel end + +""" +Spitzer-Härm parallel resistivity 1/σ_∥ as used by Fitzpatrick's TJ +(LayerParameters.tex Eqs. 7-8) and legacy SLAYER for τ_R. No +trapped-particle correction. Pair with `lnLambda_form=:wesson` to +reproduce legacy SLAYER behaviour bit-identically. +""" +struct SpitzerHarmModel <: NeoResistivityModel end + +""" +Sauter, Angioni & Lin-Liu 1999 F_33 neoclassical correction (Eqs. 13a,b). +""" +struct SauterNeoModel <: NeoResistivityModel end + +""" +Redl et al. 2021 F_33 neoclassical correction (Eqs. 17-18). Improved +high-collisionality fit vs SauterNeoModel. +""" +struct RedlNeoModel <: NeoResistivityModel end + +# -------------------------------------------------------------------------- +# Coulomb logarithm +# -------------------------------------------------------------------------- + +""" + coulomb_log_e(n_e, T_e; form=:nrl) -> Float64 + +Electron Coulomb logarithm. `n_e` in m⁻³, `T_e` in eV. + +`form=:nrl` (default) uses the NRL Plasma Formulary 2009 expression, which +OpenFUSIONToolkit's `bootstrap.py` also selects as the "more accurate" +option. `form=:sauter` uses the simpler Sauter 1999 Eq. 18d form. +""" +function coulomb_log_e(n_e::Real, T_e::Real; form::Symbol=:nrl) + n_e > 0 || throw(ArgumentError("coulomb_log_e: n_e must be > 0 (got $n_e)")) + T_e > 0 || throw(ArgumentError("coulomb_log_e: T_e must be > 0 (got $T_e)")) + if form === :nrl + # NRL 2009, n_e in cm⁻³; matches utils_fusion.py:1262-1264 + return 23.5 - log(sqrt(n_e / 1e6) * T_e^(-1.25)) - + sqrt(1e-5 + (log(T_e) - 2)^2 / 16.0) + elseif form === :sauter + # Sauter 1999 Eq. 18d; matches utils_fusion.py:1255 + return 31.3 - log(sqrt(n_e) / T_e) + elseif form === :wesson + # Legacy Wesson form used by previous Julia code & SLAYER + return 24.0 + 3.0 * log(10.0) - 0.5 * log(n_e) + log(T_e) + else + throw(ArgumentError("coulomb_log_e: unknown form=$form " * + "(expected :nrl, :sauter, or :wesson)")) + end +end + +# -------------------------------------------------------------------------- +# Spitzer resistivity (Sauter 1999 Eq. 18a) +# -------------------------------------------------------------------------- + +# Sauter 1999 Eq. 18a line 2 — Spitzer conductivity Zeff correction +_N_Z(Z::Real) = 0.58 + 0.74 / (0.76 + Z) + +""" + eta_spitzer(n_e, T_e, Z_eff; lnLamb=nothing) -> Float64 + +Spitzer resistivity in Ω·m, using the Sauter 1999 Eq. 18a form + +``` +σ_Sp = 1.9012e4 · T_e^1.5 / (Z_eff · N(Z_eff) · lnΛ_e) +N(Z) = 0.58 + 0.74 / (0.76 + Z) +η_Sp = 1 / σ_Sp +``` + +`n_e` [m⁻³], `T_e` [eV]. `lnLamb` defaults to `coulomb_log_e(n_e, T_e)` (NRL). +""" +function eta_spitzer(n_e::Real, T_e::Real, Z_eff::Real; + lnLamb::Union{Real,Nothing}=nothing) + T_e > 0 || throw(ArgumentError("eta_spitzer: T_e must be > 0 (got $T_e)")) + Z_eff > 0 || throw(ArgumentError("eta_spitzer: Z_eff must be > 0 (got $Z_eff)")) + lnL = lnLamb === nothing ? coulomb_log_e(n_e, T_e) : Float64(lnLamb) + sigma_sp = 1.9012e4 * T_e^1.5 / (Z_eff * _N_Z(Z_eff) * lnL) + return 1.0 / sigma_sp +end + +# -------------------------------------------------------------------------- +# Spitzer-Härm parallel resistivity (Fitzpatrick TJ LayerParameters.tex Eqs. 7-8) +# -------------------------------------------------------------------------- + +""" + tau_ee_spitzer_harm(n_e, T_e; lnLamb=nothing) -> Float64 + +Electron-electron collision time per Fitzpatrick TJ LayerParameters.tex Eq. 7: + +``` +τ_ee = 6√2 π^1.5 ε₀² √m_e T_e^1.5 / (lnΛ e^2.5 n_e) +``` + +`n_e` [m⁻³], `T_e` [eV] (the `e^2.5` denominator absorbs the eV→J +conversion). `lnLamb` defaults to `coulomb_log_e(n_e, T_e)` (NRL). +""" +function tau_ee_spitzer_harm(n_e::Real, T_e::Real; + lnLamb::Union{Real,Nothing}=nothing) + n_e > 0 || throw(ArgumentError("tau_ee_spitzer_harm: n_e must be > 0")) + T_e > 0 || throw(ArgumentError("tau_ee_spitzer_harm: T_e must be > 0")) + lnL = lnLamb === nothing ? coulomb_log_e(n_e, T_e) : Float64(lnLamb) + return 6.0 * sqrt(2.0) * π^1.5 * EPS_0^2 * sqrt(M_E) * T_e^1.5 / + (lnL * E_CHG^2.5 * n_e) +end + +""" + eta_spitzer_harm(n_e, T_e, Z_eff; lnLamb=nothing) -> Float64 + +Spitzer-Härm parallel resistivity η_∥ = 1/σ_∥ in Ω·m, per Fitzpatrick TJ +LayerParameters.tex Eq. 8: + +``` +σ_∥ = (√2 + 13 Z_eff/4) / (Z_eff (√2 + Z_eff)) · n_e e² τ_ee / m_e +``` + +This is the resistivity entering the legacy SLAYER / TJ resistive +diffusion time τ_R = μ₀ r_s² σ_∥ (LayerParameters.tex Eq. 17). Agrees +with `eta_spitzer` to the fit accuracy of the two formulas (~1% at Z=1). +""" +function eta_spitzer_harm(n_e::Real, T_e::Real, Z_eff::Real; + lnLamb::Union{Real,Nothing}=nothing) + Z_eff > 0 || throw(ArgumentError("eta_spitzer_harm: Z_eff must be > 0")) + tau_ee = tau_ee_spitzer_harm(n_e, T_e; lnLamb=lnLamb) + sigma_par = (sqrt(2.0) + 13.0 * (Z_eff / 4.0)) / + (Z_eff * (sqrt(2.0) + Z_eff)) * + (n_e * E_CHG^2 * tau_ee) / M_E + return 1.0 / sigma_par +end + +# -------------------------------------------------------------------------- +# Trapped fraction +# -------------------------------------------------------------------------- + +""" + trapped_fraction(avg_B, avg_Bsq, B_min, B_max) -> Float64 + +Lin-Liu & Miller 1995, Phys. Plasmas **2**, 1666, Eq. (6): + +``` +f_t = 1 − ⟨B⟩² / ⟨B²⟩ · (1 − √(1 − h) · (1 + h/2)), h = B_min / B_max +``` + +Equivalent to the OMFIT `f_t` / `f_c` pair at full geometric accuracy (uses +both the average-B ratio and the min/max extremes). Arguments are +flux-surface averages computed from the θ-loop in the equilibrium. +""" +function trapped_fraction(avg_B::Real, avg_Bsq::Real, + B_min::Real, B_max::Real) + B_max > 0 || throw(ArgumentError("trapped_fraction: B_max must be > 0")) + avg_Bsq > 0 || throw(ArgumentError("trapped_fraction: avg_Bsq must be > 0")) + h = clamp(B_min / B_max, 0.0, 1.0) + factor = 1.0 - sqrt(1.0 - h) * (1.0 + 0.5 * h) + ft = 1.0 - (avg_B^2 / avg_Bsq) * factor + return clamp(ft, 0.0, 1.0) +end + +""" + trapped_fraction_eps(eps) -> Float64 + +Simple ε-only trapped-fraction approximation (OMFIT `f_t`): + +``` +f_c ≈ (1 − ε)² / (√(1 − ε²) · (1 + 1.46·√ε + 0.2·ε)) +f_t = 1 − f_c +``` + +Used as a fallback when the full (⟨B⟩, ⟨B²⟩, B_min, B_max) moments are +unavailable — e.g. when feeding SLAYER directly from minor-radius geometry +without having evaluated `ResistGeometry` first. +""" +function trapped_fraction_eps(eps::Real) + e = clamp(eps, 0.0, 1.0 - 1e-12) + fc = (1.0 - e)^2 / (sqrt(1.0 - e^2) * (1.0 + 1.46 * sqrt(e) + 0.2 * e)) + return clamp(1.0 - fc, 0.0, 1.0) +end + +# -------------------------------------------------------------------------- +# Electron collisionality (Sauter 1999 Eq. 18b) +# -------------------------------------------------------------------------- + +""" + nu_star_e(n_e, T_e, R_major, eps, q, Z_eff; lnLamb=nothing) -> Float64 + +Electron collisionality ν*_e per Sauter 1999 Eq. 18b: + +``` +ν*_e = 6.921e-18 · |q| · R · n_e · Z_eff · lnΛ_e / (T_e² · ε^1.5) +``` + +`n_e` [m⁻³], `T_e` [eV], `R_major` [m]. Matches OFT `bootstrap.py:640` and +OMFIT `utils_fusion.py:1278`. +""" +function nu_star_e(n_e::Real, T_e::Real, R_major::Real, + eps::Real, q::Real, Z_eff::Real; + lnLamb::Union{Real,Nothing}=nothing) + eps > 0 || throw(ArgumentError("nu_star_e: eps must be > 0")) + T_e > 0 || throw(ArgumentError("nu_star_e: T_e must be > 0")) + lnL = lnLamb === nothing ? coulomb_log_e(n_e, T_e) : Float64(lnLamb) + return 6.921e-18 * abs(q) * R_major * n_e * Z_eff * lnL / + (T_e^2 * eps^1.5) +end + +# -------------------------------------------------------------------------- +# Neoclassical resistivity (F_33 · η_Sp) +# -------------------------------------------------------------------------- + +# Sauter 1999 Eqs. 13a-13b +function _F33_sauter(f_t::Real, nu_star::Real, Z_eff::Real) + x = f_t / (1.0 + (0.55 - 0.1 * f_t) * sqrt(nu_star) + + 0.45 * (1.0 - f_t) * nu_star * Z_eff^(-1.5)) + return 1.0 - (1.0 + 0.36 / Z_eff) * x + + (0.59 / Z_eff) * x^2 - (0.23 / Z_eff) * x^3 +end + +# Redl 2021 Eqs. 17-18 +function _F33_redl(f_t::Real, nu_star::Real, Z_eff::Real) + dZm1 = sqrt(max(Z_eff - 1.0, 0.0)) + x = f_t / (1.0 + 0.25 * (1.0 - 0.7 * f_t) * sqrt(nu_star) * + (1.0 + 0.45 * dZm1) + + 0.61 * (1.0 - 0.41 * f_t) * nu_star / sqrt(Z_eff)) + return 1.0 - (1.0 + 0.21 / Z_eff) * x + + (0.54 / Z_eff) * x^2 - (0.33 / Z_eff) * x^3 +end + +""" + eta_neoclassical(model, n_e, T_e, Z_eff, f_t, nu_e_star; + lnLamb=nothing) -> Float64 + +Neoclassical resistivity η [Ω·m] under the chosen closure. + + - `SpitzerModel()` -- returns `eta_spitzer(n_e, T_e, Z_eff; lnLamb)` + unchanged; `f_t` and `nu_e_star` are ignored. + - `SpitzerHarmModel()` -- returns `eta_spitzer_harm(n_e, T_e, Z_eff; lnLamb)` + (Fitzpatrick/TJ legacy); `f_t` and `nu_e_star` are ignored. + - `SauterNeoModel()` -- Sauter 1999 Eq. 13: η = η_Sp / F_33(Sauter). + - `RedlNeoModel()` -- Redl 2021 Eq. 17: η = η_Sp / F_33(Redl). + +Note that σ_neo = σ_Sp · F_33, so η_neo = η_Sp / F_33. For a banana-regime +plasma with f_t ≈ 0.5 and ν*_e ≪ 1, F_33 ≈ 0.4–0.5, so η_neo is a factor +of ~2 larger than η_Sp — this is the standard H-mode tearing correction. +""" +function eta_neoclassical(::SpitzerModel, n_e::Real, T_e::Real, Z_eff::Real, + f_t::Real, nu_e_star::Real; + lnLamb::Union{Real,Nothing}=nothing) + return eta_spitzer(n_e, T_e, Z_eff; lnLamb=lnLamb) +end + +function eta_neoclassical(::SpitzerHarmModel, n_e::Real, T_e::Real, Z_eff::Real, + f_t::Real, nu_e_star::Real; + lnLamb::Union{Real,Nothing}=nothing) + return eta_spitzer_harm(n_e, T_e, Z_eff; lnLamb=lnLamb) +end + +function eta_neoclassical(::SauterNeoModel, n_e::Real, T_e::Real, Z_eff::Real, + f_t::Real, nu_e_star::Real; + lnLamb::Union{Real,Nothing}=nothing) + eta_sp = eta_spitzer(n_e, T_e, Z_eff; lnLamb=lnLamb) + F33 = _F33_sauter(clamp(f_t, 0.0, 1.0), max(nu_e_star, 0.0), Z_eff) + F33 > 0 || throw(DomainError(F33, "eta_neoclassical: F_33 non-positive — " * + "inputs outside Sauter fit range")) + return eta_sp / F33 +end + +function eta_neoclassical(::RedlNeoModel, n_e::Real, T_e::Real, Z_eff::Real, + f_t::Real, nu_e_star::Real; + lnLamb::Union{Real,Nothing}=nothing) + eta_sp = eta_spitzer(n_e, T_e, Z_eff; lnLamb=lnLamb) + F33 = _F33_redl(clamp(f_t, 0.0, 1.0), max(nu_e_star, 0.0), Z_eff) + F33 > 0 || throw(DomainError(F33, "eta_neoclassical: F_33 non-positive — " * + "inputs outside Redl fit range")) + return eta_sp / F33 +end + +end # module NeoclassicalResistivity diff --git a/src/Utilities/PhysicalConstants.jl b/src/Utilities/PhysicalConstants.jl new file mode 100644 index 00000000..a9668b61 --- /dev/null +++ b/src/Utilities/PhysicalConstants.jl @@ -0,0 +1,22 @@ +""" + PhysicalConstants + +Shared physical constants used across GPEC modules. Values match the +Fortran GPEC/SLAYER conventions (sglobal_mod) so numerical results can +be directly compared. + +All quantities in SI units. +""" +module PhysicalConstants + +# Match the Fortran GPEC/SLAYER sglobal_mod values exactly so cross-code numerical comparison is meaningful. +const MU_0 = 4.0e-7 * π # vacuum permeability [H/m] +const M_E = 9.1094e-31 # electron mass [kg] +const M_P = 1.6726e-27 # proton mass [kg] +const E_CHG = 1.6021917e-19 # elementary charge [C] +const K_B = 1.3807e-23 # Boltzmann constant [J/K] +const EPS_0 = 8.8542e-12 # vacuum permittivity [F/m] + +export MU_0, M_E, M_P, E_CHG, K_B, EPS_0 + +end # module PhysicalConstants diff --git a/src/Utilities/Utilities.jl b/src/Utilities/Utilities.jl index 060a4857..5dab6bdb 100644 --- a/src/Utilities/Utilities.jl +++ b/src/Utilities/Utilities.jl @@ -10,11 +10,17 @@ mathematical utilities. # Submodules - `FourierTransforms`: Efficient Fourier transforms with pre-computed basis functions + - `PhysicalConstants`: SI physical constants matching Fortran GPEC/SLAYER values + - `NeoclassicalResistivity`: Spitzer/Sauter/Redl resistivity closures shared by + the GGJ and SLAYER inner-layer models """ module Utilities include("FourierTransforms.jl") include("FourierCoefficients.jl") +include("PhysicalConstants.jl") +include("KineticProfiles.jl") +include("NeoclassicalResistivity.jl") include("GridUtilities.jl") using .FourierTransforms @@ -22,4 +28,17 @@ export FourierTransform, inverse, compute_fourier_coefficients export FourierCoefficients, empty_FourierCoefficients, get_complex_coeff, get_complex_coeffs! +using .PhysicalConstants +export PhysicalConstants +export MU_0, M_E, M_P, E_CHG, K_B, EPS_0 + +export KineticProfiles + +using .NeoclassicalResistivity +export NeoclassicalResistivity +export NeoResistivityModel, SpitzerModel, SpitzerHarmModel, SauterNeoModel, RedlNeoModel +export coulomb_log_e, eta_spitzer, tau_ee_spitzer_harm, eta_spitzer_harm +export trapped_fraction, trapped_fraction_eps +export nu_star_e, eta_neoclassical + end # module Utilities diff --git a/test/runtests.jl b/test/runtests.jl index 9bd7b0ed..2036e610 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -32,6 +32,18 @@ else include("./runtests_sing.jl") include("./runtests_innerlayer.jl") include("./runtests_tj_analytic.jl") + include("./runtests_kinetic_profiles.jl") + include("./runtests_resist_eval.jl") + include("./runtests_slayer_params.jl") + include("./runtests_slayer_riccati.jl") + include("./runtests_slayer_inputs.jl") + include("./runtests_dispersion_residual.jl") + include("./runtests_dispersion_coupled.jl") + include("./runtests_dispersion_coupled_full.jl") + include("./runtests_dispersion_scan.jl") + include("./runtests_dispersion_amr.jl") + include("./runtests_dispersion_polish.jl") + include("./runtests_slayer_runner.jl") include("./runtests_kinetic.jl") include("./runtests_fullruns.jl") include("./runtests_coils.jl") diff --git a/test/runtests_dispersion_amr.jl b/test/runtests_dispersion_amr.jl new file mode 100644 index 00000000..014f3d01 --- /dev/null +++ b/test/runtests_dispersion_amr.jl @@ -0,0 +1,239 @@ +@testset "Dispersion AMR scan + triangulation extraction" begin + using GeneralizedPerturbedEquilibrium.InnerLayer + using GeneralizedPerturbedEquilibrium.InnerLayer: InnerLayerModel, solve_inner + using GeneralizedPerturbedEquilibrium.Dispersion + using StaticArrays + + @testset "amr_scan: basic structure and hash-caching" begin + eval_count = Ref(0) + function counting_f(Q) + eval_count[] += 1 + return ComplexF64(Q)^2 - 1 + end + + # Small 2×2 initial grid → 9 unique corners + amr = amr_scan(counting_f, (-1.0, 1.0), (-1.0, 1.0); + nre0=2, nim0=2, passes=0) + @test amr isa AMRResult + @test length(amr.cells) == 4 # 2×2 cells + # Dedup: 9 unique corners (3×3) + @test length(amr.Q) == 9 + @test length(amr.Δ) == 9 + @test eval_count[] == 9 # exactly one call per unique Q + end + + @testset "amr_scan: refinement concentrates cells near zero crossings" begin + f(Q) = ComplexF64(Q) - (0.3 + 0.4im) # single zero + amr0 = amr_scan(f, (-1.0, 1.0), (-1.0, 1.0); nre0=4, nim0=4, passes=0) + amr3 = amr_scan(f, (-1.0, 1.0), (-1.0, 1.0); nre0=4, nim0=4, passes=3) + @test length(amr3.cells) > length(amr0.cells) + @test length(amr3.Q) > length(amr0.Q) + # A 4×4 coarse grid is 16 cells; adding 3 refinement passes must + # leave the total bounded by exponential growth of only the cells + # bracketing the root (roughly linear in the path length). + @test length(amr3.cells) < 1000 # not exponential in passes + end + + @testset "amr_scan: argument validation" begin + @test_throws ArgumentError amr_scan(identity, (0.0, 1.0), (0.0, 1.0); + nre0=0, nim0=2, passes=1) + @test_throws ArgumentError amr_scan(identity, (0.0, 1.0), (0.0, 1.0); + nre0=2, nim0=0, passes=1) + @test_throws ArgumentError amr_scan(identity, (0.0, 1.0), (0.0, 1.0); + nre0=2, nim0=2, passes=-1) + end + + @testset "amr_scan: max_cells safety cap fires" begin + # A pathological f that forces every cell to subdivide every pass + f(Q) = 0.0 + 0.0im # identically zero → every cell crosses + @test_throws ErrorException amr_scan(f, (-1.0, 1.0), (-1.0, 1.0); + nre0=4, nim0=4, passes=10, + max_cells=100) + end + + @testset "find_growth_rates(AMR): single isolated root" begin + Q_root = 0.42 + 0.27im + f(Q) = ComplexF64(Q) - Q_root + amr = amr_scan(f, (-1.0, 1.5), (-0.5, 1.0); + nre0=8, nim0=6, passes=4) + result = find_growth_rates(amr, 1.0) + @test result isa GrowthRateResult + @test abs(result.Q_root - Q_root) < 1e-3 # AMR-resolution limited + @test isempty(result.poles) + @test length(result.valid_roots) == 1 + end + + @testset "find_growth_rates(AMR): higher-γ root selected" begin + Q1 = 0.3 + 0.5im # higher γ + Q2 = -0.4 + 0.1im + f(Q) = (ComplexF64(Q) - Q1) * (ComplexF64(Q) - Q2) + amr = amr_scan(f, (-1.0, 1.0), (-0.3, 0.8); + nre0=10, nim0=8, passes=4) + result = find_growth_rates(amr, 1.0) + @test length(result.valid_roots) == 2 + @test abs(result.Q_root - Q1) < 1e-2 + end + + @testset "find_growth_rates(AMR): pole detection" begin + Q_r = 0.4 + 0.2im + Q_p = -0.5 + 0.6im + f(Q) = (ComplexF64(Q) - Q_r) / (ComplexF64(Q) - Q_p) + amr = amr_scan(f, (-1.5, 1.5), (-0.5, 1.5); + nre0=10, nim0=8, passes=5) + result = find_growth_rates(amr, 1.0; pole_threshold=10.0) + @test length(result.poles) >= 1 + @test any(p -> abs(p - Q_p) < 0.05, result.poles) + @test abs(result.Q_root - Q_r) < 1e-3 + end + + @testset "find_growth_rates(AMR): tauk normalization" begin + Q_root = 1.0 + 2.0im + f(Q) = ComplexF64(Q) - Q_root + amr = amr_scan(f, (-2.0, 3.0), (-1.0, 4.0); + nre0=8, nim0=8, passes=4) + tauk = 5e-5 + result = find_growth_rates(amr, tauk) + @test result.omega_Hz ≈ real(result.Q_root) / tauk + @test result.gamma_Hz ≈ imag(result.Q_root) / tauk + end + + @testset "find_growth_rates(AMR): argument validation" begin + # Too few points to triangulate + GRE = GeneralizedPerturbedEquilibrium.Dispersion + @test_throws ArgumentError GRE._extract_growth_rates_amr( + ComplexF64[0.0+0im, 1.0+0im], ComplexF64[1.0+0im, 2.0+0im], 1.0; + re_target=0.0, im_target=0.0, pole_threshold=10.0, + filter_above_poles=true, filter_outside_re=true) + # Length mismatch + @test_throws ArgumentError GRE._extract_growth_rates_amr( + ComplexF64[0.0+0im, 1.0+0im, 1.0+1im], + ComplexF64[1.0+0im, 2.0+0im], 1.0; + re_target=0.0, im_target=0.0, pole_threshold=10.0, + filter_above_poles=true, filter_outside_re=true) + end + + @testset "AMR vs brute-force: same root to within AMR refinement precision" begin + # Sanity: the AMR and brute-force paths should find the same root + # (to roughly the AMR resolution — the AMR typically resolves + # better per-evaluation than a uniform grid). + Q_root = 0.5 + 0.3im + f(Q) = ComplexF64(Q) - Q_root + scan = brute_force_scan(f, (-1.0, 1.0), (-0.5, 1.0); + nre=80, nim=60, threaded=false) + amr = amr_scan(f, (-1.0, 1.0), (-0.5, 1.0); + nre0=8, nim0=6, passes=4) + r_grid = find_growth_rates(scan, 1.0) + r_amr = find_growth_rates(amr, 1.0) + @test abs(r_grid.Q_root - Q_root) < 1e-3 + @test abs(r_amr.Q_root - Q_root) < 1e-3 + @test abs(r_grid.Q_root - r_amr.Q_root) < 5e-3 + end + + @testset "API: SurfaceCoupling and MultiSurfaceCoupling through amr_scan" begin + struct LinModel <: InnerLayerModel + a::ComplexF64 + b::ComplexF64 + end + GeneralizedPerturbedEquilibrium.InnerLayer.solve_inner( + m::LinModel, params, Q::Number) = + InnerLayerResponse(m.a + m.b * ComplexF64(Q), zero(ComplexF64)) + + Q_pin = 0.7 - 0.3im + sc = surface_coupling(LinModel(0.0im, 1.0+0im), nothing, + Q_pin; scale=1.0, tauk=1.0) + amr = amr_scan(sc, (-0.5, 1.5), (-1.0, 0.5); + nre0=8, nim0=6, passes=4) + r = find_growth_rates(amr, sc.tauk) + @test abs(r.Q_root - Q_pin) < 1e-2 + + # Multi-surface coupled scan through AMR + Q_a, Q_b = 0.7 - 0.3im, -0.4 + 0.5im + sc1 = surface_coupling(LinModel(0.0im, 1.0+0im), nothing, + ComplexF64(0); scale=1.0, tauk=1.0) + sc2 = surface_coupling(LinModel(0.0im, 1.0+0im), nothing, + ComplexF64(0); scale=1.0, tauk=1.0) + dp = ComplexF64[Q_a 0.0; 0.0 Q_b] + mc = multi_surface_coupling([sc1, sc2], dp) + amr_c = amr_scan(mc, (-1.0, 1.5), (-1.0, 1.0); + nre0=10, nim0=8, passes=4) + r_c = find_growth_rates(amr_c, mc.surfaces[mc.ref_idx].tauk) + @test abs(r_c.Q_root - Q_b) < 1e-2 # higher-γ root + end + + # ========================================================================= + # multi_box_amr_scan + # ========================================================================= + using GeneralizedPerturbedEquilibrium.Dispersion: BoxActivity, NoActivity, + ReZeroCrossing, ImZeroCrossing, PoleMagnitude, MultiBoxAMRResult, + multi_box_amr_scan, as_amr_result + + @testset "multi_box_amr_scan: 3-box stripe with zero, pole, and inactive box" begin + # Synthetic residual: zero at Q=0 (centre stripe), pole at Q=-50 + # (left stripe), nothing in right stripe. Complex offset 1+1im keeps + # Im(f) above zero in the right stripe so its sign-change tests don't + # fire spuriously on rational-function residuals (Im=0 contour + # otherwise crosses the entire real axis). + f(Q) = (ComplexF64(Q) - 0.0) / (ComplexF64(Q) - (-50.0)) + (1.0 + 1.0im) + boxes = [((-75.0, -25.0), (-25.0, 25.0)), + ((-25.0, 25.0), (-25.0, 25.0)), + (( 25.0, 75.0), (-25.0, 25.0))] + result = multi_box_amr_scan(f, boxes; + pole_magnitude_threshold=10.0, + prescreen_nre=25, prescreen_nim=25, + nre0=25, nim0=25, passes=2, + max_cells=100_000, + max_cells_action=:warn_truncate, + parallel=false) + @test result isa MultiBoxAMRResult + @test length(result.box_results) == 3 + @test length(result.box_activity) == 3 + @test result.box_activity[1] != NoActivity # contains pole + @test result.box_activity[2] != NoActivity # contains zero + @test result.box_activity[3] == NoActivity # empty stripe + @test result.box_results[3] === nothing + @test result.box_results[1] !== nothing + @test result.box_results[2] !== nothing + # prescreen_evals is bounded by 3 boxes × 26×26 = 2028 (some shared + # boundary corners are deduplicated within each box's local cache, so + # the count is ≤ 2028). + @test result.prescreen_evals ≤ 3 * 26 * 26 + + # as_amr_result wraps cleanly + amr = as_amr_result(result) + @test amr isa AMRResult + @test length(amr.cells) == length(result.cells) + @test length(amr.Q) == length(result.Q) + end + + @testset "multi_box_amr_scan: pole-only path" begin + # Sharp pole at Q=-50+0i with complex offset that keeps Re(f),Im(f) one- + # signed across the prescreen grid except in the cell containing the + # pole. Confirms the |Δ| ≥ pole_magnitude_threshold criterion fires + # independent of sign-change tests. + g(Q) = 1000.0 / (ComplexF64(Q) - (-50.0))^2 + (5.0 + 5.0im) + boxes = [((-75.0, -25.0), (-25.0, 25.0)), + ((-25.0, 25.0), (-25.0, 25.0)), + (( 25.0, 75.0), (-25.0, 25.0))] + result = multi_box_amr_scan(g, boxes; + pole_magnitude_threshold=50.0, + prescreen_nre=25, prescreen_nim=25, + nre0=25, nim0=25, passes=1, + max_cells=100_000, + max_cells_action=:warn_truncate, + parallel=false) + @test result.box_activity[1] != NoActivity + @test result.box_activity[2] == NoActivity + @test result.box_activity[3] == NoActivity + end + + @testset "multi_box_amr_scan: argument validation" begin + f(Q) = ComplexF64(Q) + boxes = [((-1.0, 1.0), (-1.0, 1.0))] + @test_throws ArgumentError multi_box_amr_scan(f, boxes; + pole_magnitude_threshold=1.0, prescreen_nre=0) + @test_throws ArgumentError multi_box_amr_scan(f, boxes; + pole_magnitude_threshold=1.0, prescreen_nim=0) + @test_throws ArgumentError multi_box_amr_scan(f, boxes; + pole_magnitude_threshold=-1.0) + end +end diff --git a/test/runtests_dispersion_coupled.jl b/test/runtests_dispersion_coupled.jl new file mode 100644 index 00000000..5a65539f --- /dev/null +++ b/test/runtests_dispersion_coupled.jl @@ -0,0 +1,260 @@ +@testset "Dispersion coupled determinant" begin + using GeneralizedPerturbedEquilibrium.InnerLayer + using GeneralizedPerturbedEquilibrium.InnerLayer: InnerLayerModel, solve_inner + using GeneralizedPerturbedEquilibrium.Dispersion + using LinearAlgebra + using StaticArrays + + # --------------------------------------------------------------- + # Synthetic linear inner-layer model with adjustable per-surface + # tauk for testing the Q rescaling logic. + # Δ_inner(Q) = a + b·Q + # --------------------------------------------------------------- + struct LinTestModel <: InnerLayerModel + a::ComplexF64 + b::ComplexF64 + end + GeneralizedPerturbedEquilibrium.InnerLayer.solve_inner( + m::LinTestModel, params, Q::Number) = + InnerLayerResponse(m.a + m.b * ComplexF64(Q), zero(ComplexF64)) + + function _slayer_ref() + return slayer_parameters( + n_e=5.0e19, t_e=1000.0, t_i=1000.0, + omega=0.0, omega_e=1.0e4, omega_i=5.0e3, + qval=2.0, sval_r=1.0, bt=2.0, + rs=0.5, R0=1.7, mu_i=2.0, zeff=1.0, + chi_perp=1.0, chi_tor=1.0, m=2, n=1) + end + + @testset "Constructor validation" begin + sc1 = surface_coupling(LinTestModel(0.0im, 1.0+0im), nothing, + 1.0+0im; scale=1.0, tauk=1.0) + sc2 = surface_coupling(LinTestModel(0.0im, 1.0+0im), nothing, + 2.0+0im; scale=1.0, tauk=1.0) + good_dp = ComplexF64[1.0 0.1; 0.1 2.0] + + mc = multi_surface_coupling([sc1, sc2], good_dp) + @test mc.ref_idx == 1 + @test mc.msing_max == 2 # min(3, 2) = 2 + @test size(mc.dp_matrix) == (2, 2) + + # 3-surface default also caps at 3 (min(3, 3) = 3) + sc3 = surface_coupling(LinTestModel(0.0im, 1.0+0im), nothing, + 3.0+0im; scale=1.0, tauk=1.0) + good_dp3 = ComplexF64[1.0 0.1 0.0; 0.1 2.0 0.0; 0.0 0.0 3.0] + mc3 = multi_surface_coupling([sc1, sc2, sc3], good_dp3) + @test mc3.msing_max == 3 + + # 4-surface case caps at 3 (the design default — Δ' beyond 3 surfaces + # tends to be erratic in practice) + sc4 = surface_coupling(LinTestModel(0.0im, 1.0+0im), nothing, + 4.0+0im; scale=1.0, tauk=1.0) + good_dp4 = ComplexF64[1.0 0.0 0.0 0.0; + 0.0 2.0 0.0 0.0; + 0.0 0.0 3.0 0.0; + 0.0 0.0 0.0 4.0] + mc4 = multi_surface_coupling([sc1, sc2, sc3, sc4], good_dp4) + @test mc4.msing_max == 3 # default capped at 3 + # Caller can opt in to all 4 + mc4_full = multi_surface_coupling([sc1, sc2, sc3, sc4], good_dp4; + msing_max=4) + @test mc4_full.msing_max == 4 + + # Mismatched dp size + @test_throws ArgumentError multi_surface_coupling( + [sc1, sc2], ComplexF64[1.0 0.0 0.0; 0.0 2.0 0.0; 0.0 0.0 3.0]) + @test_throws ArgumentError multi_surface_coupling( + [sc1, sc2], ComplexF64[1.0 0.0]) + + # Out-of-range ref_idx + @test_throws ArgumentError multi_surface_coupling([sc1, sc2], good_dp; + ref_idx=3) + @test_throws ArgumentError multi_surface_coupling([sc1, sc2], good_dp; + ref_idx=0) + + # Out-of-range msing_max + @test_throws ArgumentError multi_surface_coupling([sc1, sc2], good_dp; + msing_max=3) + @test_throws ArgumentError multi_surface_coupling([sc1, sc2], good_dp; + msing_max=0) + end + + @testset "Diagonal Δ' factorizes (det = ∏ per-surface residuals)" begin + # When dp_matrix is diagonal, no off-diagonal coupling exists and + # the coupled determinant should reduce exactly to the product of + # per-surface residuals. + sc1 = surface_coupling(LinTestModel(1.0+0im, 1.0+0im), nothing, + 5.0+0im; scale=1.0, tauk=1.0) + sc2 = surface_coupling(LinTestModel(2.0+0im, 1.0+0im), nothing, + 7.0+0im; scale=1.0, tauk=1.0) + sc3 = surface_coupling(LinTestModel(0.5+0im, 0.5+0im), nothing, + 3.0+0im; scale=1.0, tauk=1.0) + dp = ComplexF64[5.0 0.0 0.0; + 0.0 7.0 0.0; + 0.0 0.0 3.0] + mc = multi_surface_coupling([sc1, sc2, sc3], dp) + for Q in (0.5+0im, 2.0+0.3im, -1.0-0.5im, 4.5+1.0im) + @test mc(Q) ≈ sc1(Q) * sc2(Q) * sc3(Q) rtol = 1e-12 + end + end + + @testset "Diagonal Δ' roots = single-surface roots" begin + # With Δ_inner(Q) = b·Q and dp_diag = b·Q_root for each surface, + # the coupled determinant has its roots exactly at the union of + # single-surface roots. + Q1, Q2 = 0.5+0.0im, 2.0+0.0im + sc1 = surface_coupling(LinTestModel(0.0im, 1.0+0im), nothing, + Q1; scale=1.0, tauk=1.0) + sc2 = surface_coupling(LinTestModel(0.0im, 1.0+0im), nothing, + Q2; scale=1.0, tauk=1.0) + dp = ComplexF64[real(Q1) 0.0; 0.0 real(Q2)] + mc = multi_surface_coupling([sc1, sc2], dp) + @test abs(mc(Q1)) < 1e-12 + @test abs(mc(Q2)) < 1e-12 + @test abs(mc(0.0+0.0im)) > 0 + end + + @testset "Off-diagonal coupling shifts the roots away from the diagonal" begin + sc1 = surface_coupling(LinTestModel(0.0im, 1.0+0im), nothing, + 0.5+0im; scale=1.0, tauk=1.0) + sc2 = surface_coupling(LinTestModel(0.0im, 1.0+0im), nothing, + 2.0+0im; scale=1.0, tauk=1.0) + # Coupling-free baseline + dp_diag = ComplexF64[0.5 0.0; 0.0 2.0] + mc_diag = multi_surface_coupling([sc1, sc2], dp_diag) + # With off-diagonal coupling + dp_offd = ComplexF64[0.5 0.3; 0.3 2.0] + mc_offd = multi_surface_coupling([sc1, sc2], dp_offd) + + # Single-surface roots are no longer roots of the coupled det + Q1 = 0.5 + 0.0im + @test abs(mc_diag(Q1)) < 1e-12 # diagonal: still a root + @test abs(mc_offd(Q1)) > 0 # coupled: no longer a root + # The shift size matches the off-diagonal magnitude squared + # det = (0.5-Q)(2-Q) - 0.3² ⇒ at Q=0.5 the det = -0.09 + @test mc_offd(Q1) ≈ -0.09 rtol = 1e-12 + end + + @testset "msing_max truncation uses upper-left submatrix" begin + sc1 = surface_coupling(LinTestModel(0.0im, 1.0+0im), nothing, + 1.0+0im; scale=1.0, tauk=1.0) + sc2 = surface_coupling(LinTestModel(0.0im, 1.0+0im), nothing, + 2.0+0im; scale=1.0, tauk=1.0) + sc3 = surface_coupling(LinTestModel(0.0im, 1.0+0im), nothing, + 3.0+0im; scale=1.0, tauk=1.0) + dp = ComplexF64[1.0 0.0 0.0; + 0.0 2.0 0.0; + 0.0 0.0 3.0] + + # msing_max = 1 reduces to sc1(Q) alone + mc1 = multi_surface_coupling([sc1, sc2, sc3], dp; msing_max=1) + for Q in (0.0+0im, 1.0+0im, 2.0+0im) + @test mc1(Q) ≈ sc1(Q) + end + + # msing_max = 2 uses the upper-left 2×2 → sc1·sc2 + mc2 = multi_surface_coupling([sc1, sc2, sc3], dp; msing_max=2) + for Q in (0.0+0im, 0.5+0.5im) + @test mc2(Q) ≈ sc1(Q) * sc2(Q) + end + + # msing_max = 3 (default for ≥3 surfaces) uses the full 3×3 → sc1·sc2·sc3 + mc3 = multi_surface_coupling([sc1, sc2, sc3], dp) + @test mc3.msing_max == 3 # min(3, 3) = 3 + for Q in (0.5+0.5im, 1.5-0.5im) + @test mc3(Q) ≈ sc1(Q) * sc2(Q) * sc3(Q) + end + end + + @testset "Per-surface Q rescaling via tauk_ref / tauk_k" begin + # Each surface evaluates its inner Δ at Q_k = Q · (tauk_ref/tauk_k). + # With Δ(Q) = Q (b=1, a=0), the diagonal modification is + # M[k,k] = dp_diag_k - scale·Q·(tauk_ref/tauk_k) + # Verify against an explicit closed form with mismatched tauks. + sc1 = surface_coupling(LinTestModel(0.0im, 1.0+0im), nothing, + 0.0+0im; scale=1.0, tauk=2.0) # ref tauk + sc2 = surface_coupling(LinTestModel(0.0im, 1.0+0im), nothing, + 0.0+0im; scale=1.0, tauk=4.0) # half rate + dp = ComplexF64[0.0 0.0; 0.0 0.0] + mc = multi_surface_coupling([sc1, sc2], dp; ref_idx=1) + for Q in (1.0+0im, 0.5+0.3im) + # M[1,1] = 0 - Q · (2/2) = -Q + # M[2,2] = 0 - Q · (2/4) = -Q/2 + # det = M[1,1] · M[2,2] = Q·Q/2 = Q²/2 + @test mc(Q) ≈ Q^2 / 2 rtol = 1e-12 + end + + # Switch ref_idx to surface 2 + mc2 = multi_surface_coupling([sc1, sc2], dp; ref_idx=2) + for Q in (1.0+0im, 0.5+0.3im) + # M[1,1] = -Q · (4/2) = -2Q + # M[2,2] = -Q · (4/4) = -Q + # det = 2Q · Q = 2Q² + @test mc2(Q) ≈ 2 * Q^2 rtol = 1e-12 + end + end + + @testset "SLAYER self-consistency: known coupled root" begin + # Build a 2-surface SLAYER MultiSurfaceCoupling, evaluate at + # Q_pin, and back-fill dp_matrix so that det(M(Q_pin)) = 0 + # exactly. + p_a = _slayer_ref() + p_b = _slayer_ref() + m = SLAYERModel() + sc1 = surface_coupling(m, p_a, 0.0+0im) + sc2 = surface_coupling(m, p_b, 0.0+0im) + + Q_pin = 0.3 + 0.4im + ref_tauk = sc1.tauk + + # Compute the diagonal modifications at Q_pin + Δ1 = solve_inner(m, p_a, Q_pin * (ref_tauk/sc1.tauk)).tearing * sc1.scale + Δ2 = solve_inner(m, p_b, Q_pin * (ref_tauk/sc2.tauk)).tearing * sc2.scale + + # Build dp such that M(Q_pin) is exactly singular. + # Choose off-diagonal couplings, then set diagonals so M[k,k]=Δ_k + # makes the matrix singular by setting M[1,1]·M[2,2] = M[1,2]·M[2,1]. + c12, c21 = 0.05+0im, 0.05+0im + # Pick M[1,1] arbitrarily, solve for M[2,2]: + M11 = 0.7 + 0.0im + M22 = (c12 * c21) / M11 + dp = ComplexF64[M11+Δ1 c12; + c21 M22+Δ2] + + mc = multi_surface_coupling([sc1, sc2], dp) + # The constructed M(Q_pin) is exactly singular by construction + @test abs(mc(Q_pin)) < 1e-10 + + # Off-pin Q gives a non-trivial determinant + @test abs(mc(Q_pin + 0.05)) > 1e-3 + end + + @testset "GGJ surfaces flow through the coupled API" begin + p = glasser_wang_2020_eq55() + sc1 = surface_coupling(GGJModel(solver=:shooting), p, -1.0+0im) + sc2 = surface_coupling(GGJModel(solver=:shooting), p, -2.0+0im) + dp = ComplexF64[-1.0 0.1; 0.1 -2.0] + mc = multi_surface_coupling([sc1, sc2], dp) + @test mc isa MultiSurfaceCoupling + @test mc.surfaces[1].tauk == 1.0 # GGJ default + @test mc(1e-3 + 0.0im) isa ComplexF64 + end + + @testset "Broadcast over a 2D Q grid" begin + # Coupled residual must be broadcast-compatible for PR 5/6 scans. + sc1 = surface_coupling(LinTestModel(0.0im, 1.0+0im), nothing, + 0.0+0im; scale=1.0, tauk=1.0) + sc2 = surface_coupling(LinTestModel(0.0im, 1.0+0im), nothing, + 0.0+0im; scale=1.0, tauk=1.0) + dp = ComplexF64[0.0 0.0; 0.0 0.0] + mc = multi_surface_coupling([sc1, sc2], dp) + + Q_grid = [(qr + qi*im) for qr in -1.0:0.5:1.0, qi in -1.0:0.5:1.0] + det_grid = mc.(Q_grid) + @test size(det_grid) == size(Q_grid) + @test all(d -> d isa ComplexF64, det_grid) + # det = Q² with these params; one interior cross-check + @test det_grid[3, 3] ≈ Q_grid[3, 3]^2 + end +end diff --git a/test/runtests_dispersion_coupled_full.jl b/test/runtests_dispersion_coupled_full.jl new file mode 100644 index 00000000..d471650f --- /dev/null +++ b/test/runtests_dispersion_coupled_full.jl @@ -0,0 +1,270 @@ +@testset "Dispersion 4m×4m full Pletzer-Dewar coupled determinant (CoupledFullMatch)" begin + using GeneralizedPerturbedEquilibrium.InnerLayer + using GeneralizedPerturbedEquilibrium.InnerLayer: InnerLayerModel, InnerLayerResponse, solve_inner + using GeneralizedPerturbedEquilibrium.Dispersion + using LinearAlgebra + + # Synthetic inner-layer model with explicit (tearing, interchange) + # pair — lets us probe both channels independently. + struct _LinearInnerF <: InnerLayerModel + a_t::ComplexF64 + b_t::ComplexF64 # tearing: Δ_t(Q) = a_t + b_t·Q + a_i::ComplexF64 + b_i::ComplexF64 # interchange: Δ_i(Q) = a_i + b_i·Q + end + GeneralizedPerturbedEquilibrium.InnerLayer.solve_inner( + m::_LinearInnerF, params, Q::Number) = + InnerLayerResponse(m.a_t + m.b_t * ComplexF64(Q), + m.a_i + m.b_i * ComplexF64(Q)) + + @testset "Constructor validation" begin + sc1 = surface_coupling(_LinearInnerF(-1.0 + 0im, 0 + 0im, 0.1 + 0im, 0 + 0im), + nothing, 0 + 0im; scale=1.0, tauk=1.0) + sc2 = surface_coupling(_LinearInnerF(-0.5 + 0im, 0 + 0im, 0.2 + 0im, 0 + 0im), + nothing, 0 + 0im; scale=1.0, tauk=1.0) + dp_raw = ComplexF64[ + 1.0 0.1 0.2 0.05; + 0.1 1.2 0.05 0.2; + 0.2 0.05 -5.0 0.3; + 0.05 0.2 0.3 -4.0] + mc = multi_surface_coupling_full([sc1, sc2], dp_raw) + @test size(mc.dp_raw) == (4, 4) + @test mc.msing_max == 2 + @test mc.ref_idx == 1 + @test mc.rotation == [0.0, 0.0] + @test mc.ntor == 1 + + # Wrong outer dim + @test_throws ArgumentError multi_surface_coupling_full([sc1, sc2], + dp_raw[1:2, 1:2]) + @test_throws ArgumentError multi_surface_coupling_full([sc1, sc2], + dp_raw; ref_idx=0) + @test_throws ArgumentError multi_surface_coupling_full([sc1, sc2], + dp_raw; ref_idx=3) + @test_throws ArgumentError multi_surface_coupling_full([sc1, sc2], + dp_raw; msing_max=0) + @test_throws ArgumentError multi_surface_coupling_full([sc1, sc2], + dp_raw; msing_max=3) + # Wrong rotation length + @test_throws ArgumentError multi_surface_coupling_full([sc1, sc2], + dp_raw; rotation=[0.0]) + end + + @testset "1-surface 4×4 det matches hand computation" begin + # m=1 case: matrix is 4×4 and fully hand-verifiable. + dp_raw = ComplexF64[1.0 0.5; 0.3 2.0] + sc = surface_coupling(_LinearInnerF(0.7 + 0im, 0 + 0im, 0.2 + 0im, 0 + 0im), + nothing, 0 + 0im; scale=1.0, tauk=1.0, dc=0.0) + mc = multi_surface_coupling_full([sc], dp_raw) + # At Q=0.1 both Δ_t and Δ_i are constants (b=0), so inner Δs independent of Q. + det_jl = mc(0.1 + 0.0im) + # Hand-computed matrix (see the port comment block for the layout): + # mat[3:4, 1:2] = transpose(dp_raw) = [1 0.3; 0.5 2] + # mat[1,1]=1, mat[2,2]=1 + # mat[1,3]=-1, mat[1,4]=+1, mat[2,3]=-1, mat[2,4]=-1 + # delta1=interchange=0.2, delta2=tearing=0.7 + # mat[3,3]=-0.2, mat[3,4]=+0.7, mat[4,3]=-0.2, mat[4,4]=-0.7 + M_hand = ComplexF64[ + 1 0 -1 1; + 0 1 -1 -1; + 1 0.3 -0.2 0.7; + 0.5 2 -0.2 -0.7] + @test det_jl ≈ det(M_hand) + end + + @testset "Static (rotation=0) delta1, delta2 assembly" begin + # Replicate the Pletzer-Dewar match assembly by hand for msing=2 and + # synthetic inner values; confirm the module agrees. + dp_raw = ComplexF64[ + 10.0 0.1 0.2 0.3; + 0.1 11.0 0.4 0.5; + 0.2 0.4 -5.0 0.6; + 0.3 0.5 0.6 -4.0] + sc1 = surface_coupling(_LinearInnerF(0.2 + 0.1im, 0 + 0im, 0.7 - 0.05im, 0 + 0im), + nothing, 0 + 0im; scale=1.0, tauk=1.0, dc=0.0) + sc2 = surface_coupling(_LinearInnerF(-0.3 + 0.0im, 0 + 0im, 1.5 + 0.3im, 0 + 0im), + nothing, 0 + 0im; scale=1.0, tauk=1.0, dc=0.0) + mc = multi_surface_coupling_full([sc1, sc2], dp_raw) + det_jl = mc(0.0 + 0.0im) + + # Hand assembly + M = zeros(ComplexF64, 8, 8) + M[5:8, 1:4] = transpose(dp_raw) + # Surface 1: idx1..4 = 1,2,5,6 + M[1, 1] = 1 + M[2, 2] = 1 + M[1, 5] = -1 + M[1, 6] = 1 + M[2, 5] = -1 + M[2, 6] = -1 + d1_1 = 0.7 - 0.05im # interchange + d2_1 = 0.2 + 0.1im # tearing + M[5, 5] = -d1_1 + M[5, 6] = d2_1 + M[6, 5] = -d1_1 + M[6, 6] = -d2_1 + # Surface 2: idx1..4 = 3,4,7,8 + M[3, 3] = 1 + M[4, 4] = 1 + M[3, 7] = -1 + M[3, 8] = 1 + M[4, 7] = -1 + M[4, 8] = -1 + d1_2 = 1.5 + 0.3im + d2_2 = -0.3 + 0im + M[7, 7] = -d1_2 + M[7, 8] = d2_2 + M[8, 7] = -d1_2 + M[8, 8] = -d2_2 + + @test det_jl ≈ det(M) atol = 1e-12 * abs(det(M)) + end + + @testset "Rotation shift applies i·ntor·rotation to inner Q argument" begin + # Ensure the per-surface rotation enters the inner-layer argument. + # Use a linear Δ_t model so Q-dependence is tractable. + dp_raw = ComplexF64[1.0 0; 0 1.0] + # Δ_t(Q) = Q (pure linear), Δ_i(Q) = 0 + sc = surface_coupling(_LinearInnerF(0 + 0im, 1 + 0im, 0 + 0im, 0 + 0im), + nothing, 0 + 0im; scale=1.0, tauk=1.0, dc=0.0) + # Case A: rotation=0, Q=2+0im → inner sees 2+0im → Δ_t=2, Δ_i=0 + mc0 = multi_surface_coupling_full([sc], dp_raw; rotation=[0.0], ntor=1) + # Case B: rotation=3, Q=2+0im → inner sees 2 + 1j*1*3 = 2+3i → Δ_t=2+3i + mcR = multi_surface_coupling_full([sc], dp_raw; rotation=[3.0], ntor=1) + @test mc0(2.0 + 0.0im) ≠ mcR(2.0 + 0.0im) + + # Check by hand. Both with the same outer matrix: + function detAt(Δ_t, Δ_i) + M = ComplexF64[ + 1 0 -1 1; + 0 1 -1 -1; + 1 0 -Δ_i Δ_t; + 0 1 -Δ_i -Δ_t] + return det(M) + end + @test mc0(2.0 + 0.0im) ≈ detAt(2.0 + 0.0im, 0.0 + 0.0im) + @test mcR(2.0 + 0.0im) ≈ detAt(2.0 + 3.0im, 0.0 + 0.0im) + end + + @testset "SurfaceCoupling scale multiplies both inner channels" begin + # sc.scale should hit both delta1 and delta2 equally. + dp_raw = ComplexF64[1 0; 0 1] + sc_unit = surface_coupling(_LinearInnerF(0.3 + 0im, 0 + 0im, 0.7 + 0im, 0 + 0im), + nothing, 0 + 0im; scale=1.0, tauk=1.0, dc=0.0) + sc_x2 = surface_coupling(_LinearInnerF(0.3 + 0im, 0 + 0im, 0.7 + 0im, 0 + 0im), + nothing, 0 + 0im; scale=2.0, tauk=1.0, dc=0.0) + mc1 = multi_surface_coupling_full([sc_unit], dp_raw) + mc2 = multi_surface_coupling_full([sc_x2], dp_raw) + # Expected hand det for scale=1: d_int=0.7, d_tear=0.3 + # For scale=2: d_int=1.4, d_tear=0.6 + function detAt(Δt, Δi) + M = ComplexF64[1 0 -1 1; 0 1 -1 -1; 1 0 -Δi Δt; 0 1 -Δi -Δt] + return det(M) + end + @test mc1(0.5 + 0im) ≈ detAt(0.3, 0.7) + @test mc2(0.5 + 0im) ≈ detAt(0.6, 1.4) + end + + @testset "msing_max truncation" begin + dp_raw = ComplexF64[ + 1.0 0.1 0.2 0.3; + 0.1 1.2 0.4 0.5; + 0.2 0.4 -5.0 0.6; + 0.3 0.5 0.6 -4.0] + sc1 = surface_coupling(_LinearInnerF(0.5 + 0im, 0 + 0im, 0.2 + 0im, 0 + 0im), + nothing, 0 + 0im; scale=1.0, tauk=1.0) + sc2 = surface_coupling(_LinearInnerF(-0.3 + 0im, 0 + 0im, 1.0 + 0im, 0 + 0im), + nothing, 0 + 0im; scale=1.0, tauk=1.0) + + # With msing_max=1, only surface 1 participates; matrix becomes 4×4 + # using the upper-left 2×2 block of dp_raw. + mc1 = multi_surface_coupling_full([sc1, sc2], dp_raw; msing_max=1) + det1 = mc1(0 + 0im) + # Hand construct the 4×4 + sub_dp = dp_raw[1:2, 1:2] + M1 = zeros(ComplexF64, 4, 4) + M1[3:4, 1:2] = transpose(sub_dp) + M1[1, 1] = 1 + M1[2, 2] = 1 + M1[1, 3] = -1 + M1[1, 4] = 1 + M1[2, 3] = -1 + M1[2, 4] = -1 + M1[3, 3] = -0.2 + M1[3, 4] = 0.5 + M1[4, 3] = -0.2 + M1[4, 4] = -0.5 + @test det1 ≈ det(M1) + + # Full msing_max=2 case must differ + mcfull = multi_surface_coupling_full([sc1, sc2], dp_raw; msing_max=2) + @test mcfull(0 + 0im) ≠ det1 + end + + @testset "SLAYER-like (Δ_interchange=0) still gives correct det" begin + # When both surfaces are pure-tearing (Δ_interchange=0), the matrix + # is non-trivial but still well-defined; verify it's non-zero and + # finite (not NaN from singular inner block). + dp_raw = ComplexF64[1.0 0.1 0.2 0.3; 0.1 1.2 0.4 0.5; + 0.2 0.4 -5.0 0.6; 0.3 0.5 0.6 -4.0] + sc1 = surface_coupling(_LinearInnerF(-2 + 0im, 0 + 0im, 0 + 0im, 0 + 0im), + nothing, 0 + 0im; scale=1.0, tauk=1.0) + sc2 = surface_coupling(_LinearInnerF(-3 + 0im, 0 + 0im, 0 + 0im, 0 + 0im), + nothing, 0 + 0im; scale=1.0, tauk=1.0) + mc = multi_surface_coupling_full([sc1, sc2], dp_raw) + d = mc(0.1 + 0.2im) + @test isfinite(real(d)) + @test isfinite(imag(d)) + end + + @testset "inner_kwargs pass-through" begin + # Verify that inner_kwargs reaches solve_inner at each Q evaluation. + # Use a synthetic model with a tuning parameter to confirm plumbing. + struct _ProbeModel <: InnerLayerModel end + GeneralizedPerturbedEquilibrium.InnerLayer.solve_inner( + ::_ProbeModel, params, Q::Number; scale_factor::Float64=1.0) = + InnerLayerResponse(scale_factor * (1.0 + 0im), + scale_factor * (0.5 + 0im)) + + dp_raw = ComplexF64[1.0 0; 0 1.0] + sc = surface_coupling(_ProbeModel(), nothing, 0 + 0im; + scale=1.0, tauk=1.0, dc=0.0) + mc_native = multi_surface_coupling_full([sc], dp_raw) + mc_tuned = multi_surface_coupling_full([sc], dp_raw; + inner_kwargs=(scale_factor=0.5,)) + @test mc_native.inner_kwargs == NamedTuple() + @test mc_tuned.inner_kwargs == (scale_factor=0.5,) + + # Det should differ because inner Δ's are halved by the kwarg + det_native = mc_native(0.0 + 0.0im) + det_tuned = mc_tuned(0.0 + 0.0im) + @test det_native ≠ det_tuned + @test isfinite(real(det_native)) && isfinite(imag(det_native)) + @test isfinite(real(det_tuned)) && isfinite(imag(det_tuned)) + end + + @testset "Static GGJ-like scenario runs without error" begin + # Smoke test: larger m=3 case, both channels non-trivial, Q shifted + m = 3 + Random_dp = ComplexF64[ + 5.0 0.2 0.1 0.05 0.3 0.2; + 0.2 7.0 0.3 0.1 0.2 0.1; + 0.1 0.3 -3.0 0.4 0.1 0.05; + 0.05 0.1 0.4 -8.0 0.2 0.1; + 0.3 0.2 0.1 0.2 -2.5 0.3; + 0.2 0.1 0.05 0.1 0.3 -6.5] + # Non-trivial Q dependence: Δ_t(Q) = a + 0.5·Q, Δ_i(Q) = b + 0.2·Q + scs = [surface_coupling(_LinearInnerF(0.3 + 0.01k * im, 0.5 + 0im, + 0.7 + 0.02k * im, 0.2 + 0im), + nothing, 0 + 0im; scale=1.0, tauk=1.0) + for k in 1:m] + mc = multi_surface_coupling_full(scs, Random_dp) + @test size(mc.dp_raw) == (6, 6) + d0 = mc(0.0 + 0.0im) + d1 = mc(1.0 + 0.5im) + @test isfinite(real(d0)) && isfinite(imag(d0)) + @test isfinite(real(d1)) && isfinite(imag(d1)) + # Check that it's actually Q-dependent + @test d0 != d1 + end +end diff --git a/test/runtests_dispersion_polish.jl b/test/runtests_dispersion_polish.jl new file mode 100644 index 00000000..8bc4c970 --- /dev/null +++ b/test/runtests_dispersion_polish.jl @@ -0,0 +1,184 @@ +@testset "Dispersion root polishing" begin + using GeneralizedPerturbedEquilibrium.Dispersion + using LinearAlgebra + D = GeneralizedPerturbedEquilibrium.Dispersion + + # ---------------------------------------------------------------- + # _polish_root: core local-solve behaviour on synthetic residuals + # ---------------------------------------------------------------- + @testset "_polish_root: recovers a known simple root, |f| → 0" begin + r = 0.42 + 0.27im + f(Q) = ComplexF64(Q) - r # zero exactly at r + Q0 = r + (0.013 - 0.009im) # off-root start + Qp, nev, improved = D._polish_root(f, Q0, 0.1) + @test improved + @test abs(Qp - r) < 1e-10 + @test abs(f(Qp)) < 1e-10 + @test nev <= 3 * 8 + 2 # bounded eval budget + end + + @testset "_polish_root: nonlinear residual converges" begin + r = -0.3 + 0.5im + f(Q) = (ComplexF64(Q) - r) * (1 + 0.4 * (ComplexF64(Q) - r)) # simple root at r + Qp, _, improved = D._polish_root(f, r + 0.02 - 0.015im, 0.15) + @test improved + @test abs(Qp - r) < 1e-7 + end + + @testset "_polish_root: no-op-or-better invariant (never worse)" begin + # The polish must never return a point with larger |f| than the start. + for r in (0.1 + 0.2im, -0.5 + 0.0im, 0.3 - 0.4im) + f(Q) = (ComplexF64(Q) - r) * (ComplexF64(Q) + 1.7) + for off in (0.01 + 0.0im, 0.05 + 0.0im, 0.0 + 0.08im, -0.03 + 0.02im) + Q0 = r + off + Qp, _, _ = D._polish_root(f, Q0, 0.2) + @test abs(f(Qp)) <= abs(f(Q0)) + 1e-12 + end + end + end + + @testset "_polish_root: trust region bounds the step (no jump to far root)" begin + f(Q) = ComplexF64(Q) - (10.0 + 0.0im) # only root is far outside R + Q0 = 0.0 + 0.0im + R = 0.1 + Qp, _, _ = D._polish_root(f, Q0, R) + @test abs(Qp - Q0) <= R + 1e-9 # never leaves the trust disk + @test abs(Qp - (10.0 + 0.0im)) > 5.0 # did NOT migrate to the far root + @test abs(f(Qp)) <= abs(f(Q0)) # still moved downhill within the disk + end + + @testset "_polish_root: non-finite residual falls back, no crash" begin + fnan(Q) = ComplexF64(NaN, NaN) + Qp, nev, improved = D._polish_root(fnan, 0.3 + 0.1im, 0.1) + @test !improved + @test Qp == 0.3 + 0.1im # hard fallback to the start + @test nev >= 1 + end + + @testset "_polish_root: zero radius is a no-op" begin + f(Q) = ComplexF64(Q) - (0.2 + 0.1im) + Qp, _, improved = D._polish_root(f, 0.25 + 0.05im, 0.0) + @test !improved + @test Qp == 0.25 + 0.05im + end + + @testset "_polish_root: deterministic (same input → same output)" begin + f(Q) = (ComplexF64(Q) - (0.5 + 0.3im)) * (ComplexF64(Q) - 2) + a = D._polish_root(f, 0.52 + 0.31im, 0.1) + b = D._polish_root(f, 0.52 + 0.31im, 0.1) + @test a[1] == b[1] + @test a[2] == b[2] + end + + @testset "_polish_root: BLAS-thread-independent for a det residual" begin + # det() routes through LAPACK; polishing must be invariant to BLAS threads + # (this is the property the parallel-scan BLAS pin + polish together give). + Qstar = 0.3 + 0.2im + fdet(Q) = det(ComplexF64[1 0 0; 0 1 0; 0 0 (ComplexF64(Q)-Qstar)]) + b0 = BLAS.get_num_threads() + try + BLAS.set_num_threads(1) + r1 = D._polish_root(fdet, 0.31 + 0.21im, 0.1) + BLAS.set_num_threads(4) + r4 = D._polish_root(fdet, 0.31 + 0.21im, 0.1) + @test isapprox(r1[1], r4[1]; atol=1e-10) + @test abs(fdet(r1[1])) < 1e-8 + finally + BLAS.set_num_threads(b0) + end + end + + # ---------------------------------------------------------------- + # _polish_trust_radius: neighbour-aware bound keeps roots coherent + # ---------------------------------------------------------------- + @testset "_polish_trust_radius: capped by nearest neighbour spacing" begin + # Two close intersections + one far: radius of #1 is bound by the close one + pts = ComplexF64[0+0im, 0.001+0im, 5+0im] + R1 = D._polish_trust_radius(pts, 1, 0.01) + @test R1 <= 0.45 * 0.001 + 1e-15 # < half the close-pair spacing + # Isolated pair: radius falls back to the grid-cell cap (k_cell·h) + R2 = D._polish_trust_radius(ComplexF64[0+0im, 5+0im], 1, 0.01) + @test R2 <= 3.0 * 0.01 + 1e-12 + @test R2 > 0 + end + + @testset "_polish_trust_radius + polish: close roots stay coherent (no swap/merge)" begin + r1 = 0.30 + 0.20im + r2 = r1 + 0.002 # second root 0.002 away + f(Q) = (ComplexF64(Q) - r1) * (ComplexF64(Q) - r2) + pts = ComplexF64[r1 + 0.0003, r2 - 0.0003] # coarse contour estimates + R1 = D._polish_trust_radius(pts, 1, 0.001) + R2 = D._polish_trust_radius(pts, 2, 0.001) + p1 = D._polish_root(f, pts[1], R1)[1] + p2 = D._polish_root(f, pts[2], R2)[1] + @test abs(p1 - r1) < abs(p1 - r2) # #1 refined toward its OWN root + @test abs(p2 - r2) < abs(p2 - r1) # #2 refined toward its OWN root + @test p1 != p2 # did not collapse to one point + end + + # ---------------------------------------------------------------- + # _median_segment_length: grid-resolution proxy + # ---------------------------------------------------------------- + @testset "_median_segment_length" begin + re_paths = [ComplexF64[0+0im, 0.1+0im, 0.3+0im]] # segment lengths 0.1, 0.2 + im_paths = [ComplexF64[0+0im, 0+0.1im]] # segment length 0.1 + @test D._median_segment_length(re_paths, im_paths) ≈ 0.1 # median of [0.1,0.2,0.1] + @test D._median_segment_length(Vector{Vector{ComplexF64}}(), + Vector{Vector{ComplexF64}}()) == 0.0 + end + + # ---------------------------------------------------------------- + # Integration: find_growth_rates with `residual` polishes the root + # ---------------------------------------------------------------- + @testset "find_growth_rates: polish recovers the true root from a coarse scan" begin + r = 0.42 + 0.27im + f(Q) = (ComplexF64(Q) - r) * (1 + 0.4 * (ComplexF64(Q) - r)) # nonlinear, root at r + scan = brute_force_scan(f, (0.0, 1.0), (0.0, 0.6); nre=15, nim=12, threaded=false) + gr_raw = find_growth_rates(scan, 1.0; residual=nothing) + gr_pol = find_growth_rates(scan, 1.0; residual=f) + @test !isnan(real(gr_raw.Q_root)) # coarse scan still isolates it + @test abs(f(gr_pol.Q_root)) <= abs(f(gr_raw.Q_root)) # polish never worsens |f| + @test abs(gr_pol.Q_root - r) < 1e-4 # and converges to the true root + # polishing must not invent or drop roots vs the raw pass + @test length(gr_pol.valid_roots) == length(gr_raw.valid_roots) + end + + @testset "find_growth_rates: residual=nothing is unchanged (backward compatible)" begin + r = 0.42 + 0.27im + f(Q) = ComplexF64(Q) - r + scan = brute_force_scan(f, (0.0, 1.0), (0.0, 0.6); nre=21, nim=15, threaded=false) + a = find_growth_rates(scan, 1.0) # default: no residual, no polish + b = find_growth_rates(scan, 1.0; residual=nothing) + @test a.Q_root == b.Q_root + end + + # ---------------------------------------------------------------- + # Validity gate: drop intersections that don't polish to a true zero + # ---------------------------------------------------------------- + @testset "find_growth_rates: validity gate keeps real roots, drops spurious" begin + r = 0.42 + 0.27im + f(Q) = (ComplexF64(Q) - r) * (1 + 0.4 * (ComplexF64(Q) - r)) # real root at r + scan = brute_force_scan(f, (0.0, 1.0), (0.0, 0.6); nre=15, nim=12, threaded=false) + + # Normal tolerance: the genuine root polishes to |f|≈0 ⇒ kept, not flagged + gr = find_growth_rates(scan, 1.0; residual=f, validity_rtol=1e-3) + @test !(:spurious in gr.warning_flags) + @test !isnan(real(gr.Q_root)) + @test abs(gr.Q_root - r) < 1e-4 + + # Absurdly tight tolerance: even the true root's residual (~machine eps) + # exceeds validity_rtol·median(|Δ|) ⇒ every candidate dropped. Exercises + # the gate firing + the :spurious / :no_root / filtered_roots plumbing. + gr2 = find_growth_rates(scan, 1.0; residual=f, validity_rtol=1e-300) + @test :spurious in gr2.warning_flags + @test :no_root in gr2.warning_flags # all candidates were spurious + @test isnan(real(gr2.Q_root)) # ⇒ no root reported + @test length(gr2.filtered_roots) >= 1 # dropped candidate parked here + @test isempty(gr2.valid_roots) + + # Gate is inert without a residual (no polishing ⇒ nothing to test against) + gr3 = find_growth_rates(scan, 1.0; residual=nothing, validity_rtol=1e-300) + @test !(:spurious in gr3.warning_flags) + @test !isnan(real(gr3.Q_root)) + end +end diff --git a/test/runtests_dispersion_residual.jl b/test/runtests_dispersion_residual.jl new file mode 100644 index 00000000..d0235a0d --- /dev/null +++ b/test/runtests_dispersion_residual.jl @@ -0,0 +1,117 @@ +@testset "Dispersion residual (SurfaceCoupling)" begin + using GeneralizedPerturbedEquilibrium.InnerLayer + using GeneralizedPerturbedEquilibrium.InnerLayer: InnerLayerModel, solve_inner + using GeneralizedPerturbedEquilibrium.Dispersion + using StaticArrays + + # --------------------------------------------------------------- + # Synthetic linear inner-layer model used to verify the residual + # arithmetic without ODE noise: + # Δ_inner(Q) = a + b·Q + # r(Q) = dp_diag - scale·(a + b·Q) - dc + # --------------------------------------------------------------- + struct LinearTestModel <: InnerLayerModel + a::ComplexF64 + b::ComplexF64 + end + GeneralizedPerturbedEquilibrium.InnerLayer.solve_inner( + m::LinearTestModel, params, Q::Number) = + InnerLayerResponse(m.a + m.b * ComplexF64(Q), zero(ComplexF64)) + + function _slayer_ref() + return slayer_parameters( + n_e=5.0e19, t_e=1000.0, t_i=1000.0, + omega=0.0, omega_e=1.0e4, omega_i=5.0e3, + qval=2.0, sval_r=1.0, bt=2.0, + rs=0.5, R0=1.7, mu_i=2.0, zeff=1.0, + chi_perp=1.0, chi_tor=1.0, m=2, n=1) + end + + @testset "Constructor scale defaults" begin + # SLAYER: scale = lu^(1/3) so the dimensionless Δ from riccati_f + # is mapped to outer ψ-units (Fortran SLAYER growthrates routine) + p_sl = _slayer_ref() + sc_sl = surface_coupling(SLAYERModel(), p_sl, -1.0 + 0.0im) + @test sc_sl.scale ≈ p_sl.lu^(1/3) + @test sc_sl.dc == 0.0 + @test sc_sl.dp_diag == ComplexF64(-1.0) + + # GGJ: scale = 1 because rescale_delta is applied inside solve_inner + p_ggj = glasser_wang_2020_eq55() + sc_ggj = surface_coupling(GGJModel(solver=:shooting), p_ggj, + -1.0 + 0.0im) + @test sc_ggj.scale == 1.0 + + # Generic fallback honors explicit scale + dc kwargs + sc_lin = surface_coupling(LinearTestModel(0.0im, 1.0+0im), nothing, + 3.0 + 0.0im; dc=0.5, scale=2.0) + @test sc_lin.scale == 2.0 + @test sc_lin.dc == 0.5 + end + + @testset "Residual arithmetic on synthetic linear model" begin + # r(Q) = dp_diag - scale·(a + b·Q) - dc + a, b = 1.0 + 2.0im, -0.5 + 1.0im + scale = 3.0 + dc = 0.25 + Q_root = -0.7 + 0.3im + dp_diag = (a + b * Q_root) * scale + dc # construct a known root + + sc = surface_coupling(LinearTestModel(a, b), nothing, dp_diag; + dc=dc, scale=scale) + @test sc(Q_root) ≈ 0 atol = 1e-12 + + # Off-root residual matches the closed form + for Q in (0.0+0im, 1.5-0.5im, -0.2+1.2im) + expected = dp_diag - scale * (a + b * Q) - dc + @test sc(Q) ≈ expected + end + end + + @testset "SLAYER residual: self-consistent zero at known Q" begin + # Build dp_diag = scale · Δ(Q_pin) so the residual is exactly zero + # at Q_pin (residual evaluated through the same ODE that produced Δ). + p = _slayer_ref() + m = SLAYERModel() + Q_pin = 0.3 + 0.4im + Δ_pin = solve_inner(m, p, Q_pin).tearing + dp_diag = p.lu^(1/3) * Δ_pin + + sc = surface_coupling(m, p, dp_diag) + @test abs(sc(Q_pin)) < 1e-13 # self-consistent + + # Perturbing Q gives a non-trivial residual + @test abs(sc(Q_pin + 0.05)) > 1e-3 + @test sc(Q_pin + 0.05) isa ComplexF64 + end + + @testset "Interface compliance: GGJ ↔ SLAYER through abstract dispatch" begin + # Both inner-layer models flow through the same SurfaceCoupling + # API. Numerical agreement is *not* asserted (different physics) — + # only that both pipelines construct and evaluate. + p_sl = _slayer_ref() + sc_sl = surface_coupling(SLAYERModel(), p_sl, -100.0 + 0.0im) + @test sc_sl isa SurfaceCoupling{SLAYERModel{:fitzpatrick},SLAYERParameters} + @test sc_sl(0.0 + 0.5im) isa ComplexF64 + + p_ggj = glasser_wang_2020_eq55() + sc_ggj = surface_coupling(GGJModel(solver=:shooting), p_ggj, + -1.0 + 0.0im) + @test sc_ggj isa SurfaceCoupling{GGJModel{:shooting},GGJParameters} + @test sc_ggj(1e-3 + 0.0im) isa ComplexF64 + end + + @testset "Residual is callable on grids (broadcast)" begin + # Brute-force / AMR scans (PR 5/6) will broadcast `sc` over a 2D + # complex-Q grid; verify that broadcasting works element-wise. + a, b = 0.0+0im, 1.0+0im + sc = surface_coupling(LinearTestModel(a, b), nothing, 2.0+0im; + dc=0.0, scale=1.0) + Q_grid = [(qr + qi*im) for qr in -1.0:0.5:1.0, qi in -1.0:0.5:1.0] + Δ_grid = sc.(Q_grid) + @test size(Δ_grid) == size(Q_grid) + @test all(d -> d isa ComplexF64, Δ_grid) + # Closed-form check at one interior grid point + @test Δ_grid[3, 3] ≈ sc(Q_grid[3, 3]) + end +end diff --git a/test/runtests_dispersion_scan.jl b/test/runtests_dispersion_scan.jl new file mode 100644 index 00000000..f50b449f --- /dev/null +++ b/test/runtests_dispersion_scan.jl @@ -0,0 +1,151 @@ +@testset "Dispersion brute-force scan + growth-rate extraction" begin + using GeneralizedPerturbedEquilibrium.InnerLayer + using GeneralizedPerturbedEquilibrium.InnerLayer: InnerLayerModel, solve_inner + using GeneralizedPerturbedEquilibrium.Dispersion + using StaticArrays + + @testset "brute_force_scan: regular grid evaluation" begin + f(Q) = ComplexF64(Q)^2 - 1 + scan = brute_force_scan(f, (-2.0, 2.0), (-1.0, 1.0); + nre=21, nim=11, threaded=false) + @test scan isa ScanResult + @test size(scan.Q) == (21, 11) + @test size(scan.Δ) == (21, 11) + @test length(scan.re_axis) == 21 + @test length(scan.im_axis) == 11 + @test scan.re_axis[1] == -2.0 + @test scan.re_axis[end] == 2.0 + @test scan.im_axis[1] == -1.0 + @test scan.im_axis[end] == 1.0 + # Spot-check a grid value + i, j = 11, 6 + @test scan.Q[i, j] ≈ scan.re_axis[i] + scan.im_axis[j]*im + @test scan.Δ[i, j] ≈ scan.Q[i, j]^2 - 1 + end + + @testset "brute_force_scan: threaded vs non-threaded agree" begin + f(Q) = sin(ComplexF64(Q)) + s_t = brute_force_scan(f, (-1.0, 1.0), (-0.5, 0.5); + nre=15, nim=10, threaded=true) + s_n = brute_force_scan(f, (-1.0, 1.0), (-0.5, 0.5); + nre=15, nim=10, threaded=false) + @test s_t.Δ == s_n.Δ + end + + @testset "brute_force_scan: argument validation" begin + @test_throws ArgumentError brute_force_scan(identity, (0.0, 1.0), + (0.0, 1.0); nre=1, nim=10) + @test_throws ArgumentError brute_force_scan(identity, (0.0, 1.0), + (0.0, 1.0); nre=10, nim=1) + end + + @testset "find_growth_rates: single isolated root" begin + # Δ(Q) = Q - Q_root → unique zero at Q_root + Q_root = 0.42 + 0.27im + f(Q) = ComplexF64(Q) - Q_root + scan = brute_force_scan(f, (-1.0, 1.5), (-0.5, 1.0); + nre=80, nim=60, threaded=false) + result = find_growth_rates(scan, 1.0) + @test result isa GrowthRateResult + @test isempty(result.poles) + @test length(result.valid_roots) == 1 + @test abs(result.Q_root - Q_root) < 1e-3 # grid-resolution limited + @test result.omega_Hz ≈ real(result.Q_root) + @test result.gamma_Hz ≈ imag(result.Q_root) + end + + @testset "find_growth_rates: multiple roots — picks highest γ" begin + # Two roots; the higher-γ one must be reported + Q1 = 0.3 + 0.5im # higher γ + Q2 = -0.4 + 0.1im # lower γ + f(Q) = (ComplexF64(Q) - Q1) * (ComplexF64(Q) - Q2) + scan = brute_force_scan(f, (-1.0, 1.0), (-0.3, 0.8); + nre=100, nim=80, threaded=false) + result = find_growth_rates(scan, 1.0) + @test length(result.valid_roots) == 2 + @test abs(result.Q_root - Q1) < 1e-3 # higher-γ root chosen + @test imag(result.Q_root) > imag(Q2) + end + + @testset "find_growth_rates: pole detection" begin + # Δ(Q) = (Q - Q_root)/(Q - Q_pole) → 1 zero, 1 pole + Q_r = 0.4 + 0.2im + Q_p = -0.5 + 0.6im # pole at higher γ + f(Q) = (ComplexF64(Q) - Q_r) / (ComplexF64(Q) - Q_p) + scan = brute_force_scan(f, (-1.5, 1.5), (-0.5, 1.5); + nre=120, nim=100, threaded=false) + result = find_growth_rates(scan, 1.0; pole_threshold=10.0) + # Pole correctly classified — but the root is at lower γ than the + # pole, so even with filter_above_poles=true the root must survive. + @test length(result.poles) >= 1 + @test any(p -> abs(p - Q_p) < 0.05, result.poles) + @test abs(result.Q_root - Q_r) < 1e-3 + end + + @testset "find_growth_rates: tauk normalization to physical Hz" begin + Q_root = 1.0 + 2.0im + f(Q) = ComplexF64(Q) - Q_root + scan = brute_force_scan(f, (-2.0, 3.0), (-1.0, 4.0); + nre=80, nim=80, threaded=false) + tauk = 5.0e-5 + result = find_growth_rates(scan, tauk) + @test result.omega_Hz ≈ real(result.Q_root) / tauk + @test result.gamma_Hz ≈ imag(result.Q_root) / tauk + # Check sensible orders of magnitude (Q_root ≈ 1+2im, tauk ≈ 5e-5) + @test result.omega_Hz ≈ 1 / tauk atol = 1 / tauk * 5e-3 + @test result.gamma_Hz ≈ 2 / tauk atol = 2 / tauk * 5e-3 + end + + @testset "find_growth_rates: empty result when no contour intersections" begin + # Δ(Q) = 1 + Q (only a single zero at Q=-1; if scanned over a box + # away from -1 there will be no Im(Δ)=0 contour intersecting Re=0). + f(Q) = 1.0 + ComplexF64(Q) + # Choose a box where Δ has no zeros — far above the real axis + scan = brute_force_scan(f, (1.0, 2.0), (1.0, 2.0); + nre=30, nim=30, threaded=false) + result = find_growth_rates(scan, 1.0) + # Either no valid roots, or a NaN Q_root + @test isempty(result.valid_roots) || isnan(real(result.Q_root)) + end + + @testset "API: SurfaceCoupling and MultiSurfaceCoupling are scannable" begin + # Synthetic linear inner-layer model — verifies the Dispersion API + # accepts the actual residual containers, not just plain functions. + struct LinModel <: InnerLayerModel + a::ComplexF64 + b::ComplexF64 + end + GeneralizedPerturbedEquilibrium.InnerLayer.solve_inner( + m::LinModel, params, Q::Number) = + InnerLayerResponse(m.a + m.b * ComplexF64(Q), zero(ComplexF64)) + + # Single-surface scan via SurfaceCoupling (Q_root by construction = 0.7-0.3im) + Q_pin = 0.7 - 0.3im + sc = surface_coupling(LinModel(0.0im, 1.0+0im), nothing, + Q_pin; scale=1.0, tauk=1.0) + scan = brute_force_scan(sc, (-0.5, 1.5), (-1.0, 0.5); + nre=80, nim=80, threaded=false) + res = find_growth_rates(scan, sc.tauk) + @test abs(res.Q_root - Q_pin) < 1e-3 + + # Coupled scan via MultiSurfaceCoupling — pair two surfaces with + # *different* Q_pin values so the resulting determinant has simple + # (non-degenerate) roots that contour intersection can localize. + # Note: MultiSurfaceCoupling builds M[k,k] = dp[k,k] - Δ_inner_k(Q), + # so to put a root at Q = Q_pin_k we need dp[k,k] = Q_pin_k (the + # full complex value, not just its real part). + Q_a, Q_b = 0.7 - 0.3im, -0.4 + 0.5im + sc1 = surface_coupling(LinModel(0.0im, 1.0+0im), nothing, + ComplexF64(0); scale=1.0, tauk=1.0) + sc2 = surface_coupling(LinModel(0.0im, 1.0+0im), nothing, + ComplexF64(0); scale=1.0, tauk=1.0) + dp = ComplexF64[Q_a 0.0; 0.0 Q_b] # diagonal Δ' + mc = multi_surface_coupling([sc1, sc2], dp) + scan_c = brute_force_scan(mc, (-1.0, 1.5), (-1.0, 1.0); + nre=120, nim=100, threaded=false) + res_c = find_growth_rates(scan_c, mc.surfaces[mc.ref_idx].tauk) + # With diagonal Δ', det = (Q_a - Q)·(Q_b - Q) → roots at Q_a, Q_b. + # The higher-γ root is Q_b (γ = 0.5). + @test abs(res_c.Q_root - Q_b) < 1e-2 + end +end diff --git a/test/runtests_innerlayer.jl b/test/runtests_innerlayer.jl index 7ee36d06..f05483d4 100644 --- a/test/runtests_innerlayer.jl +++ b/test/runtests_innerlayer.jl @@ -40,12 +40,14 @@ const GGJ = IL.GGJ γ = Q_paper * GGJ.q0(p) @test GGJ.inner_Q(p, γ) ≈ Q_paper rtol = 1e-12 Δ = IL.solve_inner(gal, p, γ) - @test all(isfinite, Δ) - # Δ is purely real at this real Q; values cross-checked against Fortran rmatch deltac - # (inps basis) — the two independent codes agree to ~1e-8. rtol absorbs cross-platform jitter. - @test real(Δ[1]) ≈ 3.698368e4 rtol = 1e-3 - @test real(Δ[2]) ≈ 14.759721 rtol = 1e-3 - @test abs(imag(Δ[1])) < 1e-3 * abs(Δ[1]) - @test abs(imag(Δ[2])) < 1e-3 * abs(Δ[2]) + @test all(isfinite, (Δ.tearing, Δ.interchange)) + # Δ is purely real at this real Q; values cross-checked against an independent + # inner-layer reference — the two codes agree to ~1e-8. rtol absorbs cross-platform jitter. + # `solve_inner` returns the named-field `InnerLayerResponse`; the interchange branch is + # the large root, the tearing branch the small one. + @test real(Δ.interchange) ≈ 3.698368e4 rtol = 1e-3 + @test real(Δ.tearing) ≈ 14.759721 rtol = 1e-3 + @test abs(imag(Δ.interchange)) < 1e-3 * abs(Δ.interchange) + @test abs(imag(Δ.tearing)) < 1e-3 * abs(Δ.tearing) end end diff --git a/test/runtests_kinetic_profiles.jl b/test/runtests_kinetic_profiles.jl new file mode 100644 index 00000000..d7308b2d --- /dev/null +++ b/test/runtests_kinetic_profiles.jl @@ -0,0 +1,53 @@ +@testset "Utilities: KineticProfiles" begin + using GeneralizedPerturbedEquilibrium.Utilities + + # Canonical synthetic dataset on ψ ∈ [0, 1] + function _synthetic() + psi = collect(0.0:0.1:1.0) + return ( + psi, + Dict( + "n_e" => fill(5.0e19, length(psi)), + "T_e" => 1000.0 .* (1.0 .- 0.7 .* psi), + "T_i" => 1200.0 .* (1.0 .- 0.6 .* psi), + "omega" => 1.0e4 .* psi, + "omega_e" => fill(1.0e4, length(psi)), + "omega_i" => fill(5.0e3, length(psi)) + ) + ) + end + + @testset "kwarg constructor + evaluation" begin + psi, d = _synthetic() + kp = KineticProfiles(; psi=psi, n_e=d["n_e"], T_e=d["T_e"], + T_i=d["T_i"], omega=d["omega"], + omega_e=d["omega_e"], omega_i=d["omega_i"]) + # Exact recovery at a node + vals = kp(0.5) + @test vals.n_e ≈ 5.0e19 + @test vals.T_e ≈ 1000.0 * (1 - 0.7 * 0.5) + @test vals.T_i ≈ 1200.0 * (1 - 0.6 * 0.5) + @test vals.omega ≈ 1.0e4 * 0.5 + @test vals.omega_e ≈ 1.0e4 + @test vals.omega_i ≈ 5.0e3 + + # Smooth interpolation between nodes + vals2 = kp(0.25) + @test vals2.T_e ≈ 1000.0 * (1 - 0.7 * 0.25) rtol = 1e-6 + + # NamedTuple fields + @test keys(vals) == (:n_e, :T_e, :T_i, :omega, :omega_e, :omega_i) + end + + @testset "length mismatch raises" begin + psi = collect(0.0:0.1:1.0) + @test_throws ArgumentError KineticProfiles(; + psi=psi, + n_e=fill(1.0, length(psi) - 1), # wrong length + T_e=fill(1000.0, length(psi)), + T_i=fill(1000.0, length(psi)), + omega=fill(0.0, length(psi)), + omega_e=fill(0.0, length(psi)), + omega_i=fill(0.0, length(psi))) + end +end diff --git a/test/runtests_parallel_integration.jl b/test/runtests_parallel_integration.jl index aa53ab8e..7f9bd935 100644 --- a/test/runtests_parallel_integration.jl +++ b/test/runtests_parallel_integration.jl @@ -544,20 +544,22 @@ using TOML end # Pinned diagonal `delta_prime_matrix` REAL parts, PEST3-convention self-response Δ' from - # the STRIDE BVP with vacuum coupling, on the two-pass auto grid (rational surfaces pinned - # as mandatory knots). These pin the value at the default psi_accuracy on a fixed grid so - # the test catches unintended changes; they are NOT converged Δ'. The ideal-MHD Δ' - # extraction is intrinsically grid-sensitive: a psi_accuracy scan (2e-3→2.5e-4) swings - # dpm[1,1] by ~50% (6.2–9.9), and a finer ldp mpsi=512 grid gives ≈8.5, so treat the - # diagonal as an order-of-magnitude/sign diagnostic pending the resistive-layer Δ' work. + # the STRIDE BVP with vacuum coupling, on the two-pass measured-curvature grid (rational + # surfaces pinned as mandatory knots). These pin one point at the default psi_accuracy so + # the test catches unintended changes; they are NOT converged Δ'. The extraction is + # intrinsically grid-sensitive — a psi_accuracy scan (2e-3→2.5e-4) swings dpm[1,1] by ~50% + # (6.2–9.9) and a finer ldp mpsi=512 grid gives ≈8.5 — so treat the diagonal as an + # order-of-magnitude/sign diagnostic, and expect to re-pin whenever the grid generator + # changes (as here). See the "pinning grid-sensitive Δ′ robustly" open problem in + # docs/src/developer_notes.md for the plateau criterion meant to replace single-point pins. # (et[1], NTV torque, and ‖resonant flux‖ stay grid-robust to <1% — the sensitivity is # local to the singular-layer matching, not the global response.) Only real parts are # pinned; the imaginary parts are dominated by the PEST3 four-term cancellation and are # FP/platform-sensitive. Near-separatrix surfaces q=5,6 keep only the finiteness/non-zero # checks above. Values use this testset's mode range (mpert=27, vs full-pipeline mpert=35). - @test isapprox(real(dpm[1, 1]), +6.188700e+00; rtol=1e-1) # q=2 - @test isapprox(real(dpm[2, 2]), -5.554900e+00; rtol=1e-1) # q=3 - @test isapprox(real(dpm[3, 3]), -1.578700e+01; rtol=1e-1) # q=4 + @test isapprox(real(dpm[1, 1]), +7.703609e+00; rtol=1e-1) # q=2 + @test isapprox(real(dpm[2, 2]), -5.344199e+00; rtol=1e-1) # q=3 + @test isapprox(real(dpm[3, 3]), -1.590034e+01; rtol=1e-1) # q=4 end end diff --git a/test/runtests_resist_eval.jl b/test/runtests_resist_eval.jl new file mode 100644 index 00000000..31564549 --- /dev/null +++ b/test/runtests_resist_eval.jl @@ -0,0 +1,199 @@ +@testset "ResistEval: GGJ geometric coefficients + GGJ builder" begin + using GeneralizedPerturbedEquilibrium + using GeneralizedPerturbedEquilibrium.Equilibrium + using GeneralizedPerturbedEquilibrium.ForceFreeStates + using GeneralizedPerturbedEquilibrium.ForceFreeStates: SingType, ResistGeometry + using GeneralizedPerturbedEquilibrium.Utilities + using GeneralizedPerturbedEquilibrium.InnerLayer + using GeneralizedPerturbedEquilibrium.Tearing: build_ggj_inputs + using FastInterpolations + using TOML + + # Load the bundled Solovev example equilibrium once for all tests. + dir_path = joinpath(dirname(@__DIR__), "examples", "Solovev_ideal_example") + inputs = TOML.parsefile(joinpath(dir_path, "gpec.toml")) + eq_cfg = Equilibrium.EquilibriumConfig(inputs["Equilibrium"], dir_path) + sol_cfg = Equilibrium.SolovevConfig(inputs["SOL_INPUT"]) + equil = Equilibrium.setup_equilibrium(eq_cfg, sol_cfg) + + @testset "resist_geometry: returns finite values with expected signs" begin + # Pick a few interior surfaces; compute q1 from the equilibrium + dq = deriv_view(equil.profiles.q_spline, 1) + for psi in (0.2, 0.5, 0.8) + q1 = dq(psi) + rg = ForceFreeStates.resist_geometry(equil, psi, q1) + + @test rg isa ResistGeometry + for f in (rg.E, rg.F, rg.G, rg.H, rg.K, rg.M) + @test isfinite(f) + end + # Geometric averages are positive + @test rg.avg_bsq_over_dpsisq > 0 + @test rg.avg_bsq > 0 + # Mass factor M > 0 (denominator in G and K) + @test rg.M > 0 + # Pressure is positive on this Solovev equilibrium + @test rg.p_local > 0 + @test rg.v1_local > 0 + end + end + + @testset "resist_geometry vs ballooning D_I: D_I = E + F + H − ¼" begin + # Independent D_I reference from the ballooning coefficient system: + # det(d0bar) is the Mercier interchange D_I that the local-stability + # scan stores in locstab[:,1]. Build it on the radial grid, interpolate + # to a few surface ψ values, and check against the GGJ reconstruction. + xs = equil.profiles.xs + di_ref = Float64[ForceFreeStates.prepare_ballooning_coefficients(i, equil).di for i in eachindex(xs)] + di_spline = cubic_interp(xs, di_ref) + + dq = deriv_view(equil.profiles.q_spline, 1) + for psi in (0.3, 0.5, 0.7) + q1 = dq(psi) + rg = ForceFreeStates.resist_geometry(equil, psi, q1) + di_from_ggj = rg.E + rg.F + rg.H - 0.25 + + # det(d0bar) is D_I directly (no ψ factor at the coefficient level). + di_from_ballooning = di_spline(psi) + + # The ballooning det(d0bar) route is an INDEPENDENT D_I evaluation + # (not the surface-average θ-integrals that build E,F,H), so the two + # agree only to the few-percent level set by each route's separate + # discretization — looser than the old same-integral cross-check. + @test abs(di_from_ggj - di_from_ballooning) < 8e-2 * abs(di_from_ballooning) + end + end + + @testset "resist_eval_all!: populates restype on every surface" begin + # Build a couple of synthetic SingTypes, run the populator, verify + # restype goes from nothing to ResistGeometry on each. + dq = deriv_view(equil.profiles.q_spline, 1) + s1 = SingType(; psifac=0.3, rho=sqrt(0.3), m=[2], n=[1], + q=2.0, q1=dq(0.3), + grri=zeros(Float64, 0, 0), grre=zeros(Float64, 0, 0), + delta_prime=ComplexF64[], + delta_prime_col=zeros(ComplexF64, 0, 0), + ua_left=zeros(ComplexF64, 0, 0, 0), + ua_right=zeros(ComplexF64, 0, 0, 0), + psi_ua_left=0.0, psi_ua_right=0.0) + s2 = SingType(; psifac=0.7, rho=sqrt(0.7), m=[3], n=[1], + q=3.0, q1=dq(0.7), + grri=zeros(Float64, 0, 0), grre=zeros(Float64, 0, 0), + delta_prime=ComplexF64[], + delta_prime_col=zeros(ComplexF64, 0, 0), + ua_left=zeros(ComplexF64, 0, 0, 0), + ua_right=zeros(ComplexF64, 0, 0, 0), + psi_ua_left=0.0, psi_ua_right=0.0) + + @test s1.restype === nothing + @test s2.restype === nothing + + intr = ForceFreeStates.ForceFreeStatesInternal(; sing=[s1, s2], msing=2) + ForceFreeStates.resist_eval_all!(intr, equil) + + @test intr.sing[1].restype isa ResistGeometry + @test intr.sing[2].restype isa ResistGeometry + # Idempotent — second call shouldn't recompute (already non-nothing) + rg_first = intr.sing[1].restype + ForceFreeStates.resist_eval_all!(intr, equil) + @test intr.sing[1].restype === rg_first + end + + @testset "build_ggj_inputs: builds GGJParameters from sings + profiles" begin + # Synthetic profiles + psi_pts = collect(0.0:0.1:1.0) + profiles = KineticProfiles(; psi=psi_pts, + n_e=fill(5.0e19, length(psi_pts)), + T_e=1000.0 .* (1.0 .- 0.7 .* psi_pts), + T_i=1000.0 .* (1.0 .- 0.6 .* psi_pts), + omega=fill(0.0, length(psi_pts)), + omega_e=fill(1.0e4, length(psi_pts)), + omega_i=fill(5.0e3, length(psi_pts))) + + dq = deriv_view(equil.profiles.q_spline, 1) + s1 = SingType(; psifac=0.3, rho=sqrt(0.3), m=[2], n=[1], + q=2.0, q1=dq(0.3), + grri=zeros(Float64, 0, 0), grre=zeros(Float64, 0, 0), + delta_prime=ComplexF64[], + delta_prime_col=zeros(ComplexF64, 0, 0), + ua_left=zeros(ComplexF64, 0, 0, 0), + ua_right=zeros(ComplexF64, 0, 0, 0), + psi_ua_left=0.0, psi_ua_right=0.0) + intr = ForceFreeStates.ForceFreeStatesInternal(; sing=[s1], msing=1) + ForceFreeStates.resist_eval_all!(intr, equil) + + gs = build_ggj_inputs(equil, intr.sing, profiles; mu_i=2.0, zeff=1.0) + @test length(gs) == 1 + @test gs[1] isa GGJParameters + + # Geometric coefficients flow through unchanged from restype + rg = intr.sing[1].restype + @test gs[1].E ≈ rg.E + @test gs[1].F ≈ rg.F + @test gs[1].G ≈ rg.G + @test gs[1].H ≈ rg.H + @test gs[1].K ≈ rg.K + @test gs[1].M ≈ rg.M + + # Timescales are positive and physical + @test gs[1].taua > 0 + @test gs[1].taur > 0 + @test gs[1].taur > gs[1].taua # resistive ≫ Alfvén for any tokamak + @test gs[1].taur / gs[1].taua > 1e3 # Lundquist S well into resistive regime + + # ising traceability + @test gs[1].ising == 1 + end + + @testset "build_ggj_inputs: errors when restype not populated" begin + # Need ≥4 points for the cubic spline + psi_pts = collect(0.0:0.25:1.0) + n = length(psi_pts) + profiles = KineticProfiles(; psi=psi_pts, + n_e=fill(5.0e19, n), T_e=fill(1000.0, n), T_i=fill(1000.0, n), + omega=fill(0.0, n), omega_e=fill(1.0e4, n), omega_i=fill(5.0e3, n)) + + s_unpop = SingType(; psifac=0.5, rho=sqrt(0.5), m=[2], n=[1], + q=2.0, q1=1.0, + grri=zeros(Float64, 0, 0), grre=zeros(Float64, 0, 0), + delta_prime=ComplexF64[], + delta_prime_col=zeros(ComplexF64, 0, 0), + ua_left=zeros(ComplexF64, 0, 0, 0), + ua_right=zeros(ComplexF64, 0, 0, 0), + psi_ua_left=0.0, psi_ua_right=0.0) + @test s_unpop.restype === nothing + @test_throws ArgumentError build_ggj_inputs(equil, [s_unpop], profiles) + end + + @testset "GGJ solve_inner runs on built parameters" begin + psi_pts = collect(0.0:0.1:1.0) + profiles = KineticProfiles(; psi=psi_pts, + n_e=fill(5.0e19, length(psi_pts)), + T_e=1000.0 .* (1.0 .- 0.7 .* psi_pts), + T_i=fill(1000.0, length(psi_pts)), + omega=fill(0.0, length(psi_pts)), + omega_e=fill(0.0, length(psi_pts)), + omega_i=fill(0.0, length(psi_pts))) + + dq = deriv_view(equil.profiles.q_spline, 1) + s1 = SingType(; psifac=0.3, rho=sqrt(0.3), m=[2], n=[1], + q=2.0, q1=dq(0.3), + grri=zeros(Float64, 0, 0), grre=zeros(Float64, 0, 0), + delta_prime=ComplexF64[], + delta_prime_col=zeros(ComplexF64, 0, 0), + ua_left=zeros(ComplexF64, 0, 0, 0), + ua_right=zeros(ComplexF64, 0, 0, 0), + psi_ua_left=0.0, psi_ua_right=0.0) + intr = ForceFreeStates.ForceFreeStatesInternal(; sing=[s1], msing=1) + ForceFreeStates.resist_eval_all!(intr, equil) + gs = build_ggj_inputs(equil, intr.sing, profiles; mu_i=2.0) + + # Verify D_I < 0 so the GGJ shooting solver doesn't bail + @test mercier_di(gs[1]) < 0 + + Δ = solve_inner(GGJModel(; solver=:shooting), gs[1], 0.01 + 0.0im) + @test Δ isa InnerLayerResponse + @test isfinite(Δ.tearing) + @test isfinite(Δ.interchange) + end +end diff --git a/test/runtests_slayer_inputs.jl b/test/runtests_slayer_inputs.jl new file mode 100644 index 00000000..5e2c5a34 --- /dev/null +++ b/test/runtests_slayer_inputs.jl @@ -0,0 +1,159 @@ +@testset "SLAYER LayerInputs (build from equilibrium + profiles)" begin + using GeneralizedPerturbedEquilibrium + using GeneralizedPerturbedEquilibrium.Equilibrium + using GeneralizedPerturbedEquilibrium.Utilities + using GeneralizedPerturbedEquilibrium.InnerLayer + using GeneralizedPerturbedEquilibrium.ForceFreeStates: SingType + using TOML + + # Load the Solovev analytic equilibrium shipped with the examples. + # This exercise gets run once for all LayerInputs tests. + dir_path = joinpath(dirname(@__DIR__), "examples", "Solovev_ideal_example") + inputs = TOML.parsefile(joinpath(dir_path, "gpec.toml")) + eq_cfg = Equilibrium.EquilibriumConfig(inputs["Equilibrium"], dir_path) + sol_cfg = Equilibrium.SolovevConfig(inputs["SOL_INPUT"]) + equil = Equilibrium.setup_equilibrium(eq_cfg, sol_cfg) + + # Synthetic profiles (simple linear-in-ψ temperature decrease) + psi_pts = collect(0.0:0.1:1.0) + profiles = KineticProfiles(; psi=psi_pts, + n_e=fill(5.0e19, length(psi_pts)), + T_e=1000.0 .* (1.0 .- 0.7 .* psi_pts), + T_i=1000.0 .* (1.0 .- 0.6 .* psi_pts), + omega=fill(0.0, length(psi_pts)), + omega_e=fill(1.0e4, length(psi_pts)), + omega_i=fill(5.0e3, length(psi_pts))) + + # Helper to build a minimal SingType without touching unused fields + _mk_sing(; psi, q, q1, m, n, delta_prime=-10.0+0im) = SingType( + psifac=psi, rho=sqrt(psi), m=[m], n=[n], q=q, q1=q1, + grri=zeros(Float64, 0, 0), grre=zeros(Float64, 0, 0), + delta_prime=ComplexF64[delta_prime], + delta_prime_col=zeros(ComplexF64, 0, 0), + ua_left=zeros(ComplexF64, 0, 0, 0), + ua_right=zeros(ComplexF64, 0, 0, 0), + psi_ua_left=0.0, psi_ua_right=0.0) + + @testset "surface_minor_radius: continuity + outboard > 0" begin + # Minor radius grows monotonically with ψ (outboard midplane). + r1 = surface_minor_radius(equil, 0.1) + r2 = surface_minor_radius(equil, 0.5) + r3 = surface_minor_radius(equil, 0.9) + @test r1 < r2 < r3 + @test r1 > 0 + end + + @testset "surface_da_dpsi: FD agrees with numerical derivative" begin + # Reference via a tighter FD + for psi in (0.1, 0.4, 0.7) + h_ref = 1e-4 + r_p = surface_minor_radius(equil, psi + h_ref) + r_m = surface_minor_radius(equil, psi - h_ref) + ref = (r_p - r_m) / (2 * h_ref) + @test surface_da_dpsi(equil, psi) ≈ ref rtol = 1e-3 + end + end + + @testset "surface_da_dpsi: one-sided near boundaries" begin + # Near ψ=0 and ψ=1, the function falls back to one-sided FD and + # should still produce a finite positive number (minor radius is + # still increasing). + d_near_axis = surface_da_dpsi(equil, 1e-6) + d_near_edge = surface_da_dpsi(equil, 1.0 - 1e-6) + @test isfinite(d_near_axis) && d_near_axis > 0 + @test isfinite(d_near_edge) && d_near_edge > 0 + end + + @testset "build_slayer_inputs: returns correct per-surface data" begin + sings = [_mk_sing(psi=0.3, q=2.0, q1=1.5, m=2, n=1), + _mk_sing(psi=0.6, q=3.0, q1=2.5, m=3, n=1)] + # dr_val=0.0 bypasses the build_slayer_inputs requirement that sing.restype be + # pre-populated by ForceFreeStates.resist_eval_all! — the test sings here are + # minimal stubs without restype, so we supply dr_val explicitly. + # compute_omega_star=false makes Q_e/Q_i pass through directly from profiles.omega_e/i + # rather than being recomputed from n_e/T_e/T_i gradients — required for the Q_e == + # -tauk·omega_e(ψ) identity check below. + sl = build_slayer_inputs(equil, sings, profiles; bt=2.0, dr_val=0.0, + compute_omega_star=false) + + @test length(sl) == 2 + @test sl[1] isa SLAYERParameters + @test sl[2] isa SLAYERParameters + + # ising traceability + @test sl[1].ising == 1 + @test sl[2].ising == 2 + + # Mode numbers flow through + @test sl[1].m == 2 && sl[1].n == 1 + @test sl[2].m == 3 && sl[2].n == 1 + + # Global geometry + @test sl[1].R0 ≈ equil.ro + @test sl[1].bt == 2.0 + + # Minor radius and r-based shear recovered from the equilibrium + rs1 = surface_minor_radius(equil, 0.3) + da1 = surface_da_dpsi(equil, 0.3) + @test sl[1].rs ≈ rs1 + @test sl[1].sval_r ≈ rs1 * 1.5 / (2.0 * da1) + + # Lundquist number and Q_e scale with surface parameters + @test sl[1].lu != sl[2].lu + @test sl[1].tauk != sl[2].tauk + + # Q_e, Q_i follow the SLAYER layerinputs sign convention + @test sl[1].Q_e == -sl[1].tauk * profiles.omega_e(0.3) + @test sl[1].Q_i == -sl[1].tauk * profiles.omega_i(0.3) + end + + @testset "build_slayer_inputs: chi_perp/chi_tor as scalars and callables" begin + sings = [_mk_sing(psi=0.5, q=2.4, q1=1.2, m=2, n=1)] + + # Scalar (dr_val=0.0 bypasses the sing.restype requirement; see comment above) + sl_s = build_slayer_inputs(equil, sings, profiles; + bt=2.0, chi_perp=2.0, chi_tor=1.5, dr_val=0.0) + # Callable with matching value + chi_p(psi) = 2.0 + 0.0*psi + chi_t(psi) = 1.5 + 0.0*psi + sl_c = build_slayer_inputs(equil, sings, profiles; + bt=2.0, chi_perp=chi_p, chi_tor=chi_t, dr_val=0.0) + @test sl_s[1].P_perp ≈ sl_c[1].P_perp + @test sl_s[1].P_tor ≈ sl_c[1].P_tor + + # Callable with ψ-dependence changes the result + chi_p_var(psi) = 1.0 + 10.0 * psi # χ⊥(0.5) = 6.0 > 2.0 + sl_var = build_slayer_inputs(equil, sings, profiles; + bt=2.0, chi_perp=chi_p_var, chi_tor=1.5, dr_val=0.0) + # P_perp = τ_r · χ⊥ / r² grows with χ⊥, so the varying-χ case at + # ψ=0.5 (χ⊥=6) gives a *larger* P_perp than the scalar χ⊥=2. + @test sl_var[1].P_perp > sl_s[1].P_perp + @test sl_var[1].P_perp ≈ sl_s[1].P_perp * 6.0 / 2.0 rtol = 1e-10 + end + + @testset "build_slayer_inputs: dc_type propagates and dr_val activates offset" begin + sings = [_mk_sing(psi=0.5, q=2.4, q1=1.2, m=2, n=1)] + + # dc_type=:none and dr_val=0.0 → dc_tmp = 0 regardless of dr_val + sl_none = build_slayer_inputs(equil, sings, profiles; + bt=2.0, dc_type=:none, dr_val=0.0) + @test sl_none[1].dc_tmp == 0.0 + + # dc_type=:rfitzp with dr_val = 0 still gives zero + sl_rf0 = build_slayer_inputs(equil, sings, profiles; + bt=2.0, dc_type=:rfitzp, dr_val=0.0) + @test sl_rf0[1].dc_tmp == 0.0 + + # dc_type=:rfitzp with dr_val > 0 → nonzero negative offset + sl_rf = build_slayer_inputs(equil, sings, profiles; + bt=2.0, dc_type=:rfitzp, dr_val=0.01) + @test sl_rf[1].dc_tmp < 0 + @test isfinite(sl_rf[1].dc_tmp) + end + + @testset "build_slayer_inputs: empty sings returns empty vector" begin + sl = build_slayer_inputs(equil, SingType[], profiles; bt=2.0) + @test sl isa Vector{SLAYERParameters} + @test isempty(sl) + end +end diff --git a/test/runtests_slayer_params.jl b/test/runtests_slayer_params.jl new file mode 100644 index 00000000..330ba729 --- /dev/null +++ b/test/runtests_slayer_params.jl @@ -0,0 +1,168 @@ +@testset "SLAYER LayerParameters" begin + using GeneralizedPerturbedEquilibrium.InnerLayer + using GeneralizedPerturbedEquilibrium.Utilities: MU_0, M_E, M_P, E_CHG, EPS_0 + using GeneralizedPerturbedEquilibrium.Utilities: SpitzerModel, SpitzerHarmModel, + SauterNeoModel + + # Reference inputs: a simple deuterium plasma case suitable for + # hand-checking the SLAYER params formulas. + function _ref_kwargs(; dr_val=0.0, dc_type=:none) + return ( + n_e=5.0e19, t_e=1000.0, t_i=1000.0, + omega=0.0, omega_e=1.0e4, omega_i=5.0e3, + qval=2.0, sval_r=1.0, bt=2.0, + rs=0.5, R0=1.7, mu_i=2.0, zeff=1.0, + chi_perp=1.0, chi_tor=1.0, + m=2, n=1, + dr_val=dr_val, dgeo_val=0.5, dc_type=dc_type, + ising=3 + ) + end + + @testset "Test 1: round-trip from dimensional inputs" begin + @info "Building SLAYERParameters from a reference deuterium case" + p = slayer_parameters(; _ref_kwargs()...) + + # Identity / passthrough + @test p.ising == 3 + @test p.m == 2 + @test p.n == 1 + @test p.rs == 0.5 + @test p.R0 == 1.7 + @test p.bt == 2.0 + @test p.sval_r == 1.0 + @test p.dc_tmp == 0.0 # dr_val == 0 ⇒ no offset + @test p.dc_type === :none + + # Trivially exact ratios + @test p.tau ≈ 1.0 + # Q_e = −tauk·1e4 = negative; Q_i = −tauk·5e3 = negative + # Q_e − Q_i = −tauk·5e3 = Q_i (since Q_e = 2·Q_i) ⇒ iota_e = Q_e/Q_i = 2 + @test p.iota_e ≈ 2.0 + + # Sign convention check (SLAYER layerinputs) + @test p.Q_e == -p.tauk * 1.0e4 + @test p.Q_i == -p.tauk * 5.0e3 # SLAYER params convention: Q_i = −tauk·ω*i + + # Default resistivity closure is neoclassical (Sauter F_33). The + # trapped-particle correction raises η above plain Spitzer at the + # same lnΛ: η_neo = η_Sp / F_33 with F_33 ∈ (0,1). + p_spitzer = slayer_parameters(; _ref_kwargs()..., + resistivity_model=SpitzerModel(), lnLambda_form=:nrl) + @test p.eta > p_spitzer.eta + @test 1.1 < p.eta / p_spitzer.eta < 4.0 # banana-regime F_33 ≈ 0.3–0.9 + + # Legacy Fitzpatrick/TJ path: the Spitzer-Härm σ_∥ closure agrees with + # the old Wesson 1.65e-9 form to ~2.3% (the two are independent Spitzer + # formulas), so this is a genuine cross-check, not a tautology. + lnLamb_wesson = 24.0 + 3.0 * log(10.0) - 0.5 * log(5.0e19) + log(1000.0) + eta_wesson = 1.65e-9 * lnLamb_wesson / (1000.0 / 1e3)^1.5 + p_legacy = slayer_parameters(; _ref_kwargs()..., + resistivity_model=SpitzerHarmModel(), lnLambda_form=:wesson) + @test p_legacy.eta ≈ eta_wesson rtol = 3e-2 + + # τ_R = μ₀ r_s² / η holds for whichever closure was selected. + @test p.tau_r ≈ MU_0 * p.rs^2 / p.eta rtol = 1e-12 + @test p_legacy.tau_r ≈ MU_0 * p_legacy.rs^2 / p_legacy.eta rtol = 1e-12 + + # Mass density and Alfvén time (independent of conductivity). + rho_expected = 2.0 * M_P * 5.0e19 + tau_h_expected = 1.7 * sqrt(MU_0 * rho_expected) / (1 * 1.0 * 2.0) + # tauk = S^(1/3) · τ_H = (τ_R/τ_H)^(1/3)·τ_H = τ_R^(1/3)·τ_H^(2/3) + @test p.tauk ≈ p.lu^(1 / 3) * tau_h_expected rtol = 1e-12 + @test p.tauk^3 / tau_h_expected^2 ≈ p.tau_r rtol = 1e-12 + + # Lundquist number is large positive + @test p.lu > 1e6 + @test p.lu < 1e9 + + # Compressibility is in (0,1) for finite β + @test 0.0 < p.c_beta < 1.0 + + # Prandtl-like ratios are positive and equal here (chi_perp=chi_tor=1) + @test p.P_perp ≈ p.P_tor + @test p.P_perp > 0 + + # D_norm = (d_β/r_s)·S^(1/3)·√(τ/(1+τ)) + D_norm_expected = (p.d_beta / p.rs) * p.lu^(1 / 3) * sqrt(p.tau / (1 + p.tau)) + @test p.D_norm ≈ D_norm_expected rtol = 1e-12 + + # delta_n = S^(1/3)/r_s + @test p.delta_n ≈ p.lu^(1 / 3) / p.rs rtol = 1e-12 + end + + @testset "Test 1b: dc_tmp formulas activate when dr_val ≠ 0" begin + # All four dc_type branches must produce finite, non-NaN values + # and respect the signs/structure of the formulas in + # the SLAYER params dc_tmp formulas. + p_none = slayer_parameters(; _ref_kwargs(; dr_val=0.01, dc_type=:none)...) + @test p_none.dc_tmp == 0.0 # :none ignores dr_val + + p_lar = slayer_parameters(; _ref_kwargs(; dr_val=0.01, dc_type=:lar)...) + p_rf = slayer_parameters(; _ref_kwargs(; dr_val=0.01, dc_type=:rfitzp)...) + p_tor = slayer_parameters(; _ref_kwargs(; dr_val=0.01, dc_type=:toroidal)...) + + @test isfinite(p_lar.dc_tmp) + @test isfinite(p_rf.dc_tmp) + @test isfinite(p_tor.dc_tmp) + # dr_val > 0 with the (-dr_val) prefactor ⇒ negative dc_tmp for + # :lar, :rfitzp, :toroidal branches. + @test p_lar.dc_tmp < 0 + @test p_rf.dc_tmp < 0 + @test p_tor.dc_tmp < 0 + + # Sign flips with sign of dr_val + p_lar_neg = slayer_parameters(; + _ref_kwargs(; dr_val=-0.01, dc_type=:lar)...) + @test sign(p_lar_neg.dc_tmp) == -sign(p_lar.dc_tmp) + + # Reject unknown dc_type + @test_throws ArgumentError slayer_parameters(; + _ref_kwargs(; dr_val=0.01, dc_type=:bogus)...) + end + + @testset "Test 1c: SLAYERParameters direct kwarg construction" begin + # The @kwdef constructor must accept all required fields and + # default the optional ones. + p = SLAYERParameters(; + tau=1.0, lu=1e7, c_beta=0.1, D_norm=2.0, + P_perp=10.0, P_tor=10.0, + Q_e=-1.0, Q_i=0.5, iota_e=2.0 / 3.0, + tauk=1e-4, tau_r=10.0, delta_n=400.0, + rs=0.5, R0=1.7, bt=2.0, sval_r=1.0, + eta=2.5e-8, d_beta=4e-3 + ) + @test p.tau == 1.0 + @test p.dc_tmp == 0.0 + @test p.dc_type === :none + @test p.dr_val == 0.0 + @test p.ising == 0 + end + + @testset "Test 2: r-based shear conversion" begin + # Direct application of r_s · (dq/dψ) / (q · da/dψ). + @test r_based_shear(0.5, 2.0, 4.0, 0.5) ≈ 2.0 + @test r_based_shear(1.0, 1.0, 1.0, 1.0) ≈ 1.0 + + # Synthetic Solovev-like flux surface: a(ψ) = a₀·√ψ and q(ψ) = + # q₀·(1 + α·ψ). Then dq/dψ = q₀·α, da/dψ = a₀/(2√ψ), + # and the analytic r-based shear is + # s_r(ψ) = a(ψ)·(dq/dr)/q(ψ) + # = a₀√ψ · (dq/dψ)·(dψ/dr) / q(ψ) + # = a₀√ψ · q₀α · (2√ψ/a₀) / (q₀(1+α ψ)) + # = 2αψ / (1+αψ). + a0, q0, alpha = 0.6, 1.2, 1.5 + for psi in (0.1, 0.4, 0.7, 0.95) + a = a0 * sqrt(psi) + q = q0 * (1 + alpha * psi) + dq_dpsi = q0 * alpha + da_dpsi = a0 / (2 * sqrt(psi)) + expected = 2 * alpha * psi / (1 + alpha * psi) + @test r_based_shear(a, q, dq_dpsi, da_dpsi) ≈ expected rtol = 1e-12 + end + + # Argument validation + @test_throws ArgumentError r_based_shear(0.5, 2.0, 1.0, 0.0) + @test_throws ArgumentError r_based_shear(0.5, 0.0, 1.0, 0.5) + end +end diff --git a/test/runtests_slayer_riccati.jl b/test/runtests_slayer_riccati.jl new file mode 100644 index 00000000..22f7fc54 --- /dev/null +++ b/test/runtests_slayer_riccati.jl @@ -0,0 +1,162 @@ +@testset "SLAYER Riccati Δ" begin + using GeneralizedPerturbedEquilibrium.InnerLayer + using StaticArrays + using Statistics: median + + # Reach into the SLAYER submodule to test the BC selector helper + # without exporting it (it's an internal of the Riccati port). + _SLAYER_MOD = GeneralizedPerturbedEquilibrium.InnerLayer.SLAYER + + # A reference deuterium case in the *large-D_norm* regime. + # T_e = T_i = 3 keV (vs 1 keV) lifts D_norm² above the iota_e·P_perp/P_tor^(2/3) threshold: + # D_norm² ∝ T_e² but threshold ∝ T_e^0.5, so D_norm² / threshold ∝ T_e^(3/2). At 3 keV the + # ratio is ~2.4 (vs ~0.5 at 1 keV), placing the fixture solidly on the large_D side of the + # branch boundary. All other inputs unchanged. + function _ref_params_large_D() + return slayer_parameters(; + n_e=5.0e19, t_e=3000.0, t_i=3000.0, + omega=0.0, omega_e=1.0e4, omega_i=5.0e3, + qval=2.0, sval_r=1.0, bt=2.0, + rs=0.5, R0=1.7, mu_i=2.0, zeff=1.0, + chi_perp=1.0, chi_tor=1.0, + m=2, n=1) + end + + # A directly-built parameter set in the *small-D_norm* regime + function _ref_params_small_D() + return SLAYERParameters(; + tau=1.0, lu=1.0e7, c_beta=0.05, D_norm=0.05, + P_perp=20.0, P_tor=10.0, + Q_e=-1.0, Q_i=0.5, iota_e=2.0 / 3.0, + tauk=1.0e-4, tau_r=10.0, delta_n=400.0, + rs=0.5, R0=1.7, bt=2.0, sval_r=1.0, + eta=2.5e-8, d_beta=2.0e-4) + end + + @testset "Interface compliance" begin + p = _ref_params_large_D() + Δ = solve_inner(SLAYERModel(), p, 0.5 + 0.2im) + @test Δ isa InnerLayerResponse + @test Δ.interchange == zero(ComplexF64) # pressureless SLAYER has no interchange channel + @test isfinite(real(Δ.tearing)) + @test isfinite(imag(Δ.tearing)) + end + + @testset "Boundary-condition branch selection" begin + p_large = _ref_params_large_D() + p_small = _ref_params_small_D() + + # Sanity-check the regime ordering used by _riccati_f_initial: + # Branch 1 (large_D) iff D_norm² > iota_e·P_perp/P_tor^(2/3). + threshold(p) = p.iota_e * p.P_perp / p.P_tor^(2 / 3) + @test p_large.D_norm^2 > threshold(p_large) + @test p_small.D_norm^2 < threshold(p_small) + + _, _, branch_large = _SLAYER_MOD._riccati_f_initial(p_large, 0.5 + 0.0im) + _, _, branch_small = _SLAYER_MOD._riccati_f_initial(p_small, 0.5 + 0.0im) + @test branch_large === :large_D + @test branch_small === :small_D + + # Both branches should yield finite Δ values + Δl = solve_inner(SLAYERModel(), p_large, 0.5 + 0.1im) + Δs = solve_inner(SLAYERModel(), p_small, 0.5 + 0.1im) + @test isfinite(Δl.tearing) && isfinite(Δs.tearing) + + # p_floor (=6 by default) is honored even when the branch + # formula would produce a smaller value. + p_start_default, _, _ = _SLAYER_MOD._riccati_f_initial(p_small, 0.5 + 0.0im) + @test p_start_default >= 6.0 + # …and bumping the floor up bumps p_start up. + p_start_high, _, _ = _SLAYER_MOD._riccati_f_initial(p_small, 0.5 + 0.0im; + p_floor=12.0) + @test p_start_high >= 12.0 + end + + @testset "Smoothness across Q sweep" begin + p = _ref_params_large_D() + m = SLAYERModel() + γ = 0.2 + + # Full diamagnetic-band sweep. Δ(ω) is continuous and bounded across + # the whole band, but in the large-D_norm regime it rotates rapidly + # through the complex plane near the upper edge (ω ≳ 0.7 here), so a + # 0.2-spaced grid under-resolves that turn — adjacent samples there are + # legitimately far apart. We therefore test scale-invariant invariants + # (no NaN, bounded |Δ|, no isolated discontinuity) rather than an + # absolute step size, which would only measure sampling resolution and + # drift with the resistivity model. + ωs_full = collect(range(-1.5; stop=1.5, length=16)) + Δ_full = [solve_inner(m, p, ω + γ * im).tearing for ω in ωs_full] + @test all(isfinite.(real.(Δ_full))) + @test all(isfinite.(imag.(Δ_full))) + # Bounded magnitude over the full band — no blow-up / pole crossing. + @test all(0.1 .< abs.(Δ_full) .< 10.0) + + # Discontinuity check on the smooth central window. A solver glitch + # would show up as one step far larger than the local median; a smooth + # curve keeps that ratio small regardless of overall scale. + ωs = collect(range(-1.5; stop=0.5, length=11)) + Δs = [solve_inner(m, p, ω + γ * im).tearing for ω in ωs] + diffs = abs.(diff(Δs)) + @test maximum(diffs) < 6.0 * median(diffs) + + # Δ is genuinely Q-dependent (not silently constant). + @test maximum(diffs) > 1e-6 + end + + @testset "Tolerance self-consistency" begin + p = _ref_params_large_D() + m = SLAYERModel() + Q = 0.5 + 0.2im + # The default reltol=1e-10 matches the Fortran SLAYER LSODE + # setting. Tightening to 1e-13 typically agrees to ~4 digits; + # the long inward integration span amplifies local tolerances + # by roughly 5 orders of magnitude, so 1e-3 relative is the + # realistic self-consistency threshold here. + Δ_default = solve_inner(m, p, Q).tearing + Δ_tight = solve_inner(m, p, Q; reltol=1e-13, abstol=1e-13).tearing + @test abs(Δ_default - Δ_tight) < 1e-3 * abs(Δ_tight) + end + + @testset "p_min reduction stability" begin + # Pulling p_min closer to 0 (from the default 1e-6 down to 1e-7) + # changes Δ only marginally — the solution has well-developed + # asymptotic structure deep in the inner layer. + p = _ref_params_large_D() + m = SLAYERModel() + Q = 0.5 + 0.2im + Δ_default = solve_inner(m, p, Q; pmin=1e-6).tearing + Δ_deeper = solve_inner(m, p, Q; pmin=1e-7).tearing + @test abs(Δ_default - Δ_deeper) < 0.05 * abs(Δ_default) + end + + @testset "Resistive layer thickness (del_s)" begin + p = _ref_params_large_D() + + # riccati_del_s returns the dimensionless δ_s/d_β; must be finite. + dels_db = riccati_del_s(p) + @test dels_db isa ComplexF64 + @test isfinite(dels_db) + + lw = slayer_layer_thickness(p) + @test lw isa LayerWidths + @test lw.ising == p.ising && lw.m == p.m && lw.n == p.n + + # Meters scaling and the magnitude field are self-consistent. + @test lw.dels_db == dels_db + @test lw.delta_s ≈ dels_db * p.d_beta + @test lw.delta_s_m ≈ abs(dels_db * p.d_beta) + @test lw.delta_s_m > 0 + + # Drift-scale reference is carried through unchanged. + @test lw.d_beta == p.d_beta + + # The Riccati width should sit within a few orders of magnitude of + # the β-weighted ion scale it is built from (dels_db is O(1)). + @test 1e-3 * p.d_beta < lw.delta_s_m < 1e3 * p.d_beta + + # Degenerate integration span (q_start ≤ q_min) returns a NaN + # sentinel rather than throwing. + @test isnan(riccati_del_s(p; q_start=1e-6, q_min=1e-5)) + end +end diff --git a/test/runtests_slayer_runner.jl b/test/runtests_slayer_runner.jl new file mode 100644 index 00000000..c3ab8680 --- /dev/null +++ b/test/runtests_slayer_runner.jl @@ -0,0 +1,236 @@ +@testset "Runner: Control + run_slayer + HDF5 output" begin + using GeneralizedPerturbedEquilibrium + using GeneralizedPerturbedEquilibrium.InnerLayer + using GeneralizedPerturbedEquilibrium.Dispersion + using GeneralizedPerturbedEquilibrium.Runner + using HDF5 + + # ------- Helper: build a synthetic SLAYERParameters with full control + function _mk_params(; rs=0.5, lu=1e7, tauk=1e-4, + Q_e=-1.0, Q_i=0.5, m=2, n=1, ising=1, + c_beta=0.1, D_norm=2.0) + return SLAYERParameters(; + tau=1.0, lu=lu, c_beta=c_beta, D_norm=D_norm, + P_perp=20.0, P_tor=10.0, + Q_e=Q_e, Q_i=Q_i, + iota_e=Q_e == Q_i ? 0.0 : Q_e / (Q_e - Q_i), + tauk=tauk, tau_r=1.0, delta_n=lu^(1 / 3) / rs, + rs=rs, R0=1.7, bt=2.0, sval_r=1.0, + eta=2.5e-8, d_beta=4e-3, + m=m, n=n, ising=ising + ) + end + + @testset "SLAYERControl defaults + validation" begin + c = SLAYERControl() + @test c.enabled == false + @test c.inner_model === :slayer_fitzpatrick + @test c.scan_mode === :amr + @test c.coupling_mode === :uncoupled + @test c.msing_max == 3 + + # Validation catches bad symbols + @test_throws ArgumentError Runner.validate( + SLAYERControl(; inner_model=:bogus)) + @test_throws ArgumentError Runner.validate( + SLAYERControl(; scan_mode=:bogus)) + @test_throws ArgumentError Runner.validate( + SLAYERControl(; coupling_mode=:bogus)) + @test_throws ArgumentError Runner.validate( + SLAYERControl(; dc_type=:bogus)) + @test_throws ArgumentError Runner.validate( + SLAYERControl(; msing_max=0)) + @test_throws ArgumentError Runner.validate( + SLAYERControl(; nre=1)) + end + + @testset "slayer_control_from_toml: nested sections flatten" begin + section = Dict{String,Any}( + "enabled" => true, + "inner_model" => "slayer_fitzpatrick", + "scan_mode" => "brute_force", + "coupling_mode" => "coupled", + "dc_type" => "rfitzp", + "msing_max" => 2, + "bt" => 1.8, + "mu_i" => 2.0, + "dr_val" => 0.01, + "scan_grid" => Dict{String,Any}( + "Q_re_range" => [-5.0, 5.0], + "Q_im_range" => [-1.0, 3.0], + "nre" => 50, + "nim" => 40), + "amr" => Dict{String,Any}( + "passes" => 3, + "max_cells" => 50_000), + "growth_rate_filter" => Dict{String,Any}( + "pole_threshold" => 1e5, + "filter_above_poles" => false), + "profile_file" => "profiles.h5" + ) + c = slayer_control_from_toml(section) + @test c.enabled + @test c.inner_model === :slayer_fitzpatrick + @test c.scan_mode === :brute_force + @test c.coupling_mode === :coupled + @test c.dc_type === :rfitzp + @test c.msing_max == 2 + @test c.bt === 1.8 + @test c.dr_val == 0.01 + @test c.Q_re_range == (-5.0, 5.0) + @test c.Q_im_range == (-1.0, 3.0) + @test c.nre == 50 + @test c.nim == 40 + @test c.amr_passes == 3 + @test c.amr_max_cells == 50_000 + @test c.pole_threshold == 1e5 + @test c.filter_above_poles == false + @test c.profile_file == "profiles.h5" + + # Unknown keys should raise + bad = merge(section, Dict{String,Any}("mistyped_key" => 42)) + @test_throws ArgumentError slayer_control_from_toml(bad) + end + + @testset "run_slayer_from_inputs: disabled path is a no-op" begin + c = SLAYERControl(; enabled=false) + params = [_mk_params()] + dp = ComplexF64[0.0 + 0im;;] # 1×1 matrix + r = run_slayer_from_inputs(params, dp, c) + @test r.enabled == false + @test isempty(r.Q_root) + @test isempty(r.params) + end + + @testset "run_slayer_from_inputs: validation catches size mismatch" begin + c = SLAYERControl(; enabled=true) + params = [_mk_params()] + bad_dp = ComplexF64[0.0 0.0; 0.0 0.0] + @test_throws ArgumentError run_slayer_from_inputs(params, bad_dp, c) + end + + @testset "run_slayer_from_inputs: coupled mode finds known root" begin + # Build a 2-surface problem with a known coupled root by construction. + p1 = _mk_params(; rs=0.5, lu=1.0e7, tauk=1.0e-4, Q_e=-1.0, Q_i=0.5, + m=2, ising=1) + p2 = _mk_params(; rs=0.6, lu=2.0e7, tauk=1.2e-4, Q_e=-0.8, Q_i=0.4, + m=3, ising=2) + params = [p1, p2] + + model = SLAYERModel() + # Pick a target Q and pin the diagonal Δ'_kk so det(M(Q_target)) = 0 + Q_target = 0.2 + 0.3im + # Compute what each surface sees at Q_target (with per-surface + # rescaling: surface 2 sees Q_target * tauk_1/tauk_2). + Q_1 = Q_target * (p1.tauk / p1.tauk) # = Q_target + Q_2 = Q_target * (p1.tauk / p2.tauk) + Δ1 = InnerLayer.solve_inner(model, p1, Q_1).tearing * p1.lu^(1 / 3) + Δ2 = InnerLayer.solve_inner(model, p2, Q_2).tearing * p2.lu^(1 / 3) + # Setting dp[k,k] = Δ_k at Q_target makes both diagonals of M vanish, + # which makes det(M) = 0 at Q_target. + dp = ComplexF64[Δ1 0.0; 0.0 Δ2] + + c = SLAYERControl(; enabled=true, + inner_model=:slayer_fitzpatrick, + scan_mode=:brute_force, + coupling_mode=:coupled, + Q_re_range=(-1.0, 1.0), + Q_im_range=(-0.5, 0.8), + nre=80, nim=80, + pole_threshold=1e5) # tuned for lu^(1/3) scale + r = run_slayer_from_inputs(params, dp, c) + @test r.enabled + @test length(r.Q_root) == 1 # single coupled eigenvalue + @test abs(r.Q_root[1] - Q_target) < 2e-2 # grid-resolution limited + @test r.coupled_extraction isa GrowthRateResult + @test isempty(r.per_surface_extraction) + end + + @testset "write_slayer_hdf5!: round-trip structure" begin + p1 = _mk_params(; rs=0.5, lu=1.0e7, tauk=1.0e-4, m=2, ising=1) + p2 = _mk_params(; rs=0.6, lu=2.0e7, tauk=1.2e-4, m=3, ising=2) + params = [p1, p2] + + # Diagonal dp, zero coupling → trivial root structure at Q_target=0 + Q_target = 0.0 + 0.0im + model = SLAYERModel() + Δ1 = InnerLayer.solve_inner(model, p1, Q_target).tearing * p1.lu^(1 / 3) + Δ2 = InnerLayer.solve_inner(model, p2, Q_target).tearing * p2.lu^(1 / 3) + dp = ComplexF64[Δ1 0.0; 0.0 Δ2] + + c = SLAYERControl(; enabled=true, + scan_mode=:brute_force, + coupling_mode=:coupled, + Q_re_range=(-0.5, 0.5), + Q_im_range=(-0.3, 0.3), + nre=40, nim=40, + pole_threshold=1e5, + store_scan=true) + r = run_slayer_from_inputs(params, dp, c) + + mktemp() do path, io + close(io) + h5open(path, "w") do f + write_slayer_hdf5!(f, r) + end + h5open(path, "r") do f + g = f["slayer"] + @test haskey(g, "enabled") && read(g["enabled"]) == 1 + @test haskey(g, "settings") + @test haskey(g, "per_surface") + @test haskey(g, "roots") + @test haskey(g, "diagnostics") + @test haskey(g, "scan") + + # Settings round-trip + @test read(g["settings/inner_model"]) == "slayer_fitzpatrick" + @test read(g["settings/scan_mode"]) == "brute_force" + @test read(g["settings/coupling_mode"]) == "coupled" + @test read(g["settings/nre"]) == 40 + + # Per-surface arrays have the right length + @test length(read(g["per_surface/ising"])) == 2 + @test read(g["per_surface/ising"]) == [1, 2] + @test read(g["per_surface/lu"])[1] ≈ 1.0e7 + @test read(g["per_surface/lu"])[2] ≈ 2.0e7 + + # Roots arrays + @test length(read(g["roots/Q_root_real"])) == 1 # coupled + @test length(read(g["roots/omega_Hz"])) == 1 + + # Layer-thickness diagnostic: one entry per surface, with + # the physical thickness [m] and the drift scale. + @test length(read(g["layer_widths/delta_s_m"])) == 2 + @test all(read(g["layer_widths/delta_s_m"]) .>= 0) + @test haskey(g["layer_widths"], "dels_db_real") + @test haskey(g["layer_widths"], "d_beta") + + # Ragged diagnostics use flat+offsets encoding + @test haskey(g["diagnostics/valid_roots"], "flat_real") + @test haskey(g["diagnostics/valid_roots"], "flat_imag") + @test haskey(g["diagnostics/valid_roots"], "offsets") + + # Scan group present (store_scan=true) + @test haskey(g, "scan/surface_1") + @test read(g["scan/surface_1/kind"]) == "brute_force" + end + end + end + + @testset "write_slayer_hdf5!: disabled result still emits enabled=0" begin + c = SLAYERControl(; enabled=false) + r = empty_slayer_result(c) + mktemp() do path, io + close(io) + h5open(path, "w") do f + write_slayer_hdf5!(f, r) + end + h5open(path, "r") do f + g = f["slayer"] + @test read(g["enabled"]) == 0 + @test !haskey(g, "settings") # no further groups + @test !haskey(g, "per_surface") + end + end + end +end