From 53fffc0d0dfb7f66cc9a91230413abc6dba0daf6 Mon Sep 17 00:00:00 2001 From: d-burg Date: Fri, 28 Aug 2026 02:04:54 -0400 Subject: [PATCH 1/5] InnerLayer.SLAYER - BUGFIX - Restore the toroidal mode number in the diamagnetic frequencies _omega_star_at returns (dp/dpsi)/(e n_e), the n = 1 diamagnetic frequency. The physical omega_* = k_theta v_* carries the poloidal mode number; written in flux coordinates dpsi/dr = r B_phi / q absorbs the q in m = n q and leaves an explicit factor n. Nothing downstream restored it, so Q_e and Q_i were low by n at every rational surface and drift stabilization was underestimated for n >= 2. Confirmed against Fitzpatrick's TJ, which carries the mode number explicitly in both the layer-parameter notes and Rational.cpp; the ratio to the GPEC form is m/(q g) = n. The neighbouring quantities check out unchanged: TJ's tau_H reduces to R0/(n s V_A), matching tau_h exactly. iota_e = Q_e/(Q_e - Q_i) is a ratio, so the factor cancels there and it was already correct. Every shipped deck is n = 1, where the restored factor is exactly 1.0 and the arithmetic is bit-identical. Co-Authored-By: Claude Opus 5 --- src/InnerLayer/SLAYER/LayerInputs.jl | 19 ++++++++++++------- test/runtests_slayer_inputs.jl | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/InnerLayer/SLAYER/LayerInputs.jl b/src/InnerLayer/SLAYER/LayerInputs.jl index 96177903d..70a93ff1b 100644 --- a/src/InnerLayer/SLAYER/LayerInputs.jl +++ b/src/InnerLayer/SLAYER/LayerInputs.jl @@ -217,20 +217,25 @@ function build_slayer_inputs(equil, sings, profiles::KineticProfiles; da_dpsi = _da_dpsi_at(psi) sval_r = r_based_shear(rs, q, q1, da_dpsi) + # 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] + prof = profiles(psi) # Override ω_*e, ω_*i with spline-derivative values when requested. + # ω_* = k_θ·v_* carries the poloidal mode number; writing it in flux + # coordinates absorbs q (dψ/dr = r·B_φ/q), leaving ω_* = n·(dp/dψ)/(e·n_e). + # `_omega_star_at` returns the n = 1 value, so restore the factor n here. + # Values supplied through `profiles` are taken to be physical already. ω_e_use, ω_i_use = if compute_omega_star - _omega_star_at(psi) + ωe1, ωi1 = _omega_star_at(psi) + (n_res * ωe1, n_res * ωi1) 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 diff --git a/test/runtests_slayer_inputs.jl b/test/runtests_slayer_inputs.jl index 55d6124e9..f0b73fa77 100644 --- a/test/runtests_slayer_inputs.jl +++ b/test/runtests_slayer_inputs.jl @@ -183,4 +183,22 @@ @test sl isa Vector{SLAYERParameters} @test isempty(sl) end + + @testset "build_slayer_inputs: omega_star carries the toroidal mode number" begin + # ω_* = k_θ·v_* scales with the poloidal mode number; written in flux + # coordinates the q is absorbed by dψ/dr = r·B_φ/q, leaving + # ω_* = n·(dp/dψ)/(e·n_e). Resolve the same surface at n = 1 and n = 2: + # Q/tauk = -ω_* must double, while iota_e = Q_e/(Q_e - Q_i) is a ratio + # and must not move. Asserted through the returned parameters so the + # check survives a refactor of where the factor is applied. + s1 = [_mk_sing(psi=0.3, q=2.0, q1=1.5, m=2, n=1)] + s2 = [_mk_sing(psi=0.3, q=2.0, q1=1.5, m=4, n=2)] + sl1 = build_slayer_inputs(equil, s1, profiles; bt=2.0, dr_val=0.0) + sl2 = build_slayer_inputs(equil, s2, profiles; bt=2.0, dr_val=0.0) + + @test sl2[1].Q_e / sl2[1].tauk ≈ 2 * (sl1[1].Q_e / sl1[1].tauk) rtol = 1e-12 + @test sl2[1].Q_i / sl2[1].tauk ≈ 2 * (sl1[1].Q_i / sl1[1].tauk) rtol = 1e-12 + @test sl2[1].iota_e ≈ sl1[1].iota_e rtol = 1e-12 + end + end From 28a04256c0170fe1b972292c5aeb9ec85ba4cad7 Mon Sep 17 00:00:00 2001 From: d-burg Date: Fri, 28 Aug 2026 13:23:33 -0400 Subject: [PATCH 2/5] InnerLayer.SLAYER - MINOR - Tighten the omega-star commentary Co-Authored-By: Claude Fable 5 --- src/InnerLayer/SLAYER/LayerInputs.jl | 8 +++----- test/runtests_slayer_inputs.jl | 10 ++++------ 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/InnerLayer/SLAYER/LayerInputs.jl b/src/InnerLayer/SLAYER/LayerInputs.jl index 70a93ff1b..294090295 100644 --- a/src/InnerLayer/SLAYER/LayerInputs.jl +++ b/src/InnerLayer/SLAYER/LayerInputs.jl @@ -224,11 +224,9 @@ function build_slayer_inputs(equil, sings, profiles::KineticProfiles; n_res = sing.n[1] prof = profiles(psi) - # Override ω_*e, ω_*i with spline-derivative values when requested. - # ω_* = k_θ·v_* carries the poloidal mode number; writing it in flux - # coordinates absorbs q (dψ/dr = r·B_φ/q), leaving ω_* = n·(dp/dψ)/(e·n_e). - # `_omega_star_at` returns the n = 1 value, so restore the factor n here. - # Values supplied through `profiles` are taken to be physical already. + # Override ω_*e, ω_*i with spline-derivative values when requested. In flux coordinates + # ω_* = n·(dp/dψ)/(e·n_e); `_omega_star_at` returns the n = 1 value, so restore the + # factor n here. Values supplied through `profiles` are taken to be physical already. ω_e_use, ω_i_use = if compute_omega_star ωe1, ωi1 = _omega_star_at(psi) (n_res * ωe1, n_res * ωi1) diff --git a/test/runtests_slayer_inputs.jl b/test/runtests_slayer_inputs.jl index f0b73fa77..d4d198e20 100644 --- a/test/runtests_slayer_inputs.jl +++ b/test/runtests_slayer_inputs.jl @@ -185,12 +185,10 @@ end @testset "build_slayer_inputs: omega_star carries the toroidal mode number" begin - # ω_* = k_θ·v_* scales with the poloidal mode number; written in flux - # coordinates the q is absorbed by dψ/dr = r·B_φ/q, leaving - # ω_* = n·(dp/dψ)/(e·n_e). Resolve the same surface at n = 1 and n = 2: - # Q/tauk = -ω_* must double, while iota_e = Q_e/(Q_e - Q_i) is a ratio - # and must not move. Asserted through the returned parameters so the - # check survives a refactor of where the factor is applied. + # In flux coordinates ω_* = n·(dp/dψ)/(e·n_e): resolving the same surface at n = 1 + # and n = 2 must double Q/tauk = -ω_*, while the ratio iota_e must not move. + # Asserted through the returned parameters so the check survives a refactor of + # where the factor is applied. s1 = [_mk_sing(psi=0.3, q=2.0, q1=1.5, m=2, n=1)] s2 = [_mk_sing(psi=0.3, q=2.0, q1=1.5, m=4, n=2)] sl1 = build_slayer_inputs(equil, s1, profiles; bt=2.0, dr_val=0.0) From 8ae06cd23568e3a774bb986fd5e12e600c6c2e7a Mon Sep 17 00:00:00 2001 From: d-burg Date: Fri, 28 Aug 2026 14:40:09 -0400 Subject: [PATCH 3/5] Regression - BUGFIX - Materialize override decks beside the example so relative paths resolve An [overrides] case copied its deck to a system temp dir, so a deck whose gpec.toml references files by relative path (the SLAYER deck's profile_file points into a sibling example) lost those references and the run failed. Copy the deck to a throwaway sibling of the example dir instead, and remove that dir itself on cleanup rather than its parent. Co-Authored-By: Claude Opus 5 --- regression-harness/src/runner.jl | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/regression-harness/src/runner.jl b/regression-harness/src/runner.jl index ed7e5f4d5..5ced56890 100644 --- a/regression-harness/src/runner.jl +++ b/regression-harness/src/runner.jl @@ -27,15 +27,17 @@ end Materialize the directory GPEC will actually run in. With no `overrides`, the example deck runs in place (returns it untouched). With overrides, -the deck is copied to a throwaway temp dir and the named gpec.toml keys are patched there, -so one shared example can serve several cases (e.g. a collisionless variant via -`"KineticForces.nutype" => "zero"`). Override keys are dotted paths into the TOML; missing -intermediate tables are created. Returns `(rundir, is_temp)`; the caller removes the temp -tree when `is_temp`. +the deck is copied to a throwaway sibling of the example dir and the named gpec.toml keys are +patched there, so one shared example can serve several cases (e.g. a collisionless variant via +`"KineticForces.nutype" => "zero"`). The copy sits beside the original so relative file +references in the deck (e.g. `SLAYER.profile_file = "..//..."`) still resolve. +Override keys are dotted paths into the TOML; missing intermediate tables are created. +Returns `(rundir, is_temp)`; the caller removes `rundir` when `is_temp`. """ function _materialize_rundir(example_path::String, overrides::Dict{String,Any}) isempty(overrides) && return (example_path, false) - rundir = joinpath(mktempdir(), "deck") + rundir = joinpath(dirname(example_path), ".regress-override-" * basename(example_path)) + rm(rundir; recursive=true, force=true) # stale leftover from a crashed run cp(example_path, rundir) rm(joinpath(rundir, "gpec.h5"); force=true) # drop any stale output copied along cfg = TOML.parsefile(joinpath(rundir, "gpec.toml")) @@ -523,7 +525,7 @@ function run_local(db::SQLite.DB, case_spec::CaseSpec, repo_root::String; rm(runinfo_file; force=true) end if rundir_is_temp && rundir !== nothing - rm(dirname(rundir); recursive=true, force=true) + rm(rundir; recursive=true, force=true) end end end @@ -647,7 +649,7 @@ function run_at_commit(db::SQLite.DB, commit_hash::String, ref_name::String, rm(runinfo_file; force=true) end if rundir_is_temp && rundir !== nothing - rm(dirname(rundir); recursive=true, force=true) + rm(rundir; recursive=true, force=true) end if own_worktree && worktree_path !== nothing remove_worktree(worktree_path, repo_root) From 21fd7240ee8db27abf9e7c01e6bb932e7e9d9fca Mon Sep 17 00:00:00 2001 From: d-burg Date: Fri, 28 Aug 2026 14:40:09 -0400 Subject: [PATCH 4/5] Regression - TEST - Add the n=2 SLAYER case guarding the 3/2 surface and the Q_e n-scaling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No SLAYER case ran above n = 1, so the n-dependence of the layer build — the diamagnetic frequencies omega_* proportional to n feeding Q_e/Q_i — was invisible to the harness; the omega-star factor restored on this branch could be silently reverted without any case moving. Reuse the n=1 deck via [overrides] at nn = 2, where qmin = 1.204 puts the 3/2 surface in the plasma, and track Q_e/Q_i directly alongside the n=1 case's quantity set with roots pinned on 3/2, 4/2, 5/2. Co-Authored-By: Claude Opus 5 --- regression-harness/cases/diiid_slayer_n2.toml | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 regression-harness/cases/diiid_slayer_n2.toml diff --git a/regression-harness/cases/diiid_slayer_n2.toml b/regression-harness/cases/diiid_slayer_n2.toml new file mode 100644 index 000000000..3444a51db --- /dev/null +++ b/regression-harness/cases/diiid_slayer_n2.toml @@ -0,0 +1,173 @@ +# Regression case: DIII-D-like H-mode n=2 SLAYER tearing-mode growth rates. +# Reuses the n=1 SLAYER deck via [overrides] at toroidal mode number 2, where the deck's +# qmin = 1.204 puts the 3/2 surface in the plasma. Exists to guard the n-dependence of the +# layer build — in particular the diamagnetic frequencies ω_* ∝ n feeding Q_e/Q_i, which +# no n=1 case can constrain — with the inner rationals 3/2, 4/2, 5/2 as the tracked roots. +# Each [quantities.*] block names an HDF5 path in the run output, how to extract it, +# and the noise floor below which a difference is treated as zero. +[case] +name = "diiid_slayer_n2" +description = "DIII-D-like H-mode equilibrium, n=2, SLAYER tearing-mode analysis guarding the 3/2 surface and the n-scaling of Q_e/Q_i (reuses the n=1 deck via overrides)" +example_dir = "examples/DIIID-like_SLAYER_example" + +# Run the shared SLAYER deck at n = 2 (no separate n=2 example directory). +[overrides] +"ForceFreeStates.nn_low" = 2 +"ForceFreeStates.nn_high" = 2 + +# Per-surface SLAYER layer parameters (geometry + dimensionless) +[quantities.slayer_ising] +h5path = "Tearing/PerSurface/rational_index" +type = "real_vector" +extract = "all_real" +label = "SLAYER surface indices" +noise_threshold = 0 +order = 10 + +[quantities.slayer_m] +h5path = "Tearing/PerSurface/m" +type = "real_vector" +extract = "all_real" +label = "SLAYER poloidal m" +noise_threshold = 0 +order = 11 + +[quantities.slayer_n] +h5path = "Tearing/PerSurface/n" +type = "real_vector" +extract = "all_real" +label = "SLAYER toroidal n" +noise_threshold = 0 +order = 12 + +[quantities.slayer_rs] +h5path = "Tearing/PerSurface/rs" +type = "real_vector" +extract = "all_real" +label = "SLAYER minor radius rs" +noise_threshold = 1e-10 +order = 13 + +[quantities.slayer_sval_r] +h5path = "Tearing/PerSurface/sval_r" +type = "real_vector" +extract = "all_real" +label = "SLAYER r-based shear" +noise_threshold = 1e-10 +order = 14 + +[quantities.slayer_lu] +h5path = "Tearing/PerSurface/lu" +type = "real_vector" +extract = "all_real" +label = "SLAYER Lundquist S" +noise_threshold = 1e-8 +order = 15 + +[quantities.slayer_D_norm] +h5path = "Tearing/PerSurface/D_norm" +type = "real_vector" +extract = "all_real" +label = "SLAYER D_norm" +noise_threshold = 1e-10 +order = 16 + +[quantities.slayer_P_perp] +h5path = "Tearing/PerSurface/P_perp" +type = "real_vector" +extract = "all_real" +label = "SLAYER P_perp" +noise_threshold = 1e-8 +order = 17 + +[quantities.slayer_tauk] +h5path = "Tearing/PerSurface/tau_k" +type = "real_vector" +extract = "all_real" +label = "SLAYER tauk" +noise_threshold = 1e-12 +order = 18 + +[quantities.slayer_Q_e] +h5path = "Tearing/PerSurface/Q_e" +type = "real_vector" +extract = "all_real" +label = "SLAYER Q_e" +noise_threshold = 1e-12 +order = 20 + +[quantities.slayer_Q_i] +h5path = "Tearing/PerSurface/Q_i" +type = "real_vector" +extract = "all_real" +label = "SLAYER Q_i" +noise_threshold = 1e-12 +order = 21 + +[quantities.slayer_iota_e] +h5path = "Tearing/PerSurface/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 (3/2, 4/2, 5/2) via `first_3`: the Δ'/γ contour search is +# numerically unreliable on the outermost surfaces near the edge, so those are +# deliberately not golden-tracked. +[quantities.slayer_Q] +h5path = "Tearing/Roots/Q_root" +type = "complex_vector" +extract = "first_3_complex" +label = "SLAYER Q_root [3/2,4/2,5/2]" +noise_threshold = 1e-4 +order = 30 + +[quantities.slayer_omega_Hz] +h5path = "Tearing/Roots/omega" +type = "real_vector" +extract = "first_3" +label = "SLAYER ω_Hz [3/2,4/2,5/2]" +noise_threshold = 1.0 +order = 32 + +[quantities.slayer_gamma_Hz] +h5path = "Tearing/Roots/gamma" +type = "real_vector" +extract = "first_3" +label = "SLAYER γ_Hz [3/2,4/2,5/2]" +noise_threshold = 1e-1 +order = 33 + +# no_root flag (1 = extraction failed). Pinned for the inner three surfaces +# so the 3/2, 4/2, 5/2 root inventory cannot drift silently. +[quantities.slayer_no_root] +h5path = "Tearing/Roots/no_root" +type = "real_vector" +extract = "first_3" +label = "SLAYER no_root flags [3/2,4/2,5/2]" +noise_threshold = 0 +order = 34 + +# Settings (catches accidental config drift) +[quantities.slayer_enabled] +h5path = "Tearing/enabled" +type = "int_scalar" +extract = "value" +label = "SLAYER enabled flag" +noise_threshold = 0 +order = 90 + +[quantities.runtime] +h5path = "" +type = "runtime" +extract = "value" +label = "Runtime (s)" +noise_threshold = 0.0 +order = 999 From 4c70d4232960bb9d0202406775c5d24ea43beb6a Mon Sep 17 00:00:00 2001 From: d-burg Date: Fri, 28 Aug 2026 14:47:17 -0400 Subject: [PATCH 5/5] Regression - MINOR - Gitignore the override rundirs and note the load-bearing cleanup path The sibling rundirs live inside the checkout, so without an ignore entry they show as untracked in git status --porcelain and can spuriously mark golden provenance dirty. Also state at both cleanup sites why rm targets rundir and not its parent. Co-Authored-By: Claude Opus 5 --- .gitignore | 3 +++ regression-harness/src/runner.jl | 2 ++ 2 files changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index fcb3cc96c..1d1bf86a2 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ scratch/ # Local profiling scratch (one-off study scripts, not part of the package) profiling/ + +# Override-case rundirs materialized beside their example by the regression harness +.regress-override-* diff --git a/regression-harness/src/runner.jl b/regression-harness/src/runner.jl index 5ced56890..048276b2a 100644 --- a/regression-harness/src/runner.jl +++ b/regression-harness/src/runner.jl @@ -525,6 +525,7 @@ function run_local(db::SQLite.DB, case_spec::CaseSpec, repo_root::String; rm(runinfo_file; force=true) end if rundir_is_temp && rundir !== nothing + # rundir sits beside the example; removing its parent would delete examples/ itself rm(rundir; recursive=true, force=true) end end @@ -649,6 +650,7 @@ function run_at_commit(db::SQLite.DB, commit_hash::String, ref_name::String, rm(runinfo_file; force=true) end if rundir_is_temp && rundir !== nothing + # rundir sits beside the example; removing its parent would delete examples/ itself rm(rundir; recursive=true, force=true) end if own_worktree && worktree_path !== nothing