From 53fffc0d0dfb7f66cc9a91230413abc6dba0daf6 Mon Sep 17 00:00:00 2001 From: d-burg Date: Fri, 28 Aug 2026 02:04:54 -0400 Subject: [PATCH 1/8] 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/8] 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/8] 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/8] 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/8] 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 From 25d285eb8fa4e98676c18c7dc903726a7432686d Mon Sep 17 00:00:00 2001 From: d-burg Date: Fri, 4 Sep 2026 15:26:49 -0400 Subject: [PATCH 6/8] Regression - TEST - Track Q_e/Q_i on the n=1 SLAYER case and drop the n=2 case The n=2 case existed only to guard the n-dependence of the diamagnetic frequencies, which the unit test in runtests_slayer_inputs.jl already covers at a fraction of the cost (273 s/ref for the case). Its quantity set was a strict superset of the n=1 case, so fold the two additions it contributed -- Q_e and Q_i -- into the n=1 case and drop it. iota_e is their ratio and is blind to a common factor, so Q_e/Q_i are what pin the drive itself. The [overrides] rundir fix that this case needed stays: it is a general harness capability, not specific to the removed deck. Co-Authored-By: Claude Opus 5 --- regression-harness/cases/diiid_slayer_n1.toml | 22 ++- regression-harness/cases/diiid_slayer_n2.toml | 173 ------------------ 2 files changed, 21 insertions(+), 174 deletions(-) delete mode 100644 regression-harness/cases/diiid_slayer_n2.toml diff --git a/regression-harness/cases/diiid_slayer_n1.toml b/regression-harness/cases/diiid_slayer_n1.toml index 540ca7e50..90edf2e09 100644 --- a/regression-harness/cases/diiid_slayer_n1.toml +++ b/regression-harness/cases/diiid_slayer_n1.toml @@ -1,6 +1,8 @@ # Regression case: DIII-D-like H-mode n=1 SLAYER tearing-mode growth rates. # Uncoupled per-surface analysis with the AMR Q-plane scan; tracks the per-surface -# layer parameters and the extracted roots (Q, ω, γ) for the inner three rationals. +# layer parameters, the diamagnetic drive Q_e/Q_i, and the extracted roots (Q, ω, γ) +# for the inner three rationals. The n-scaling of Q_e/Q_i, which no single-n case can +# constrain, is covered by a unit test in test/runtests_slayer_inputs.jl instead. # 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] @@ -89,6 +91,24 @@ label = "SLAYER iota_e" noise_threshold = 1e-12 order = 19 +# Normalized diamagnetic frequencies. iota_e above is their ratio, so it is blind +# to a common factor on both; these pin the drive itself. +[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 + # 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/ω/γ diff --git a/regression-harness/cases/diiid_slayer_n2.toml b/regression-harness/cases/diiid_slayer_n2.toml deleted file mode 100644 index 3444a51db..000000000 --- a/regression-harness/cases/diiid_slayer_n2.toml +++ /dev/null @@ -1,173 +0,0 @@ -# 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 47feada50f492d08e1cb580a92efb0a477d522ac Mon Sep 17 00:00:00 2001 From: d-burg Date: Fri, 4 Sep 2026 17:21:19 -0400 Subject: [PATCH 7/8] InnerLayer.SLAYER - REFACTOR - Fold the toroidal mode number into the omega-star helper `_omega_star_at` returned the n = 1 diamagnetic frequency and left the call site to restore the factor n, so a helper named for omega-star did not return omega-star. Take the mode number as an argument instead and return the physical value, which drops the three-line fix-up comment at the call site and collapses the branch to a single expression. The parameter is `n_tor` rather than `n` because `n_e` and `n_i` are densities in the same scope. Numerically identical; the existing test asserts through the returned SLAYERParameters, so it covers the new arrangement unchanged. Co-Authored-By: Claude Opus 5 --- src/InnerLayer/SLAYER/LayerInputs.jl | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/src/InnerLayer/SLAYER/LayerInputs.jl b/src/InnerLayer/SLAYER/LayerInputs.jl index 294090295..1c772a3a6 100644 --- a/src/InnerLayer/SLAYER/LayerInputs.jl +++ b/src/InnerLayer/SLAYER/LayerInputs.jl @@ -189,21 +189,20 @@ function build_slayer_inputs(equil, sings, profiles::KineticProfiles; 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). + # Per-surface ω_*e, ω_*i (diamagnetic frequencies) from spline derivatives. + # In flux coordinates ω_* = n·(dp/dψ)/(e·n_e), so the surface's toroidal mode + # number enters here. Main-ion density is taken equal to the electron density + # (quasi-neutrality, matching the staging step). chi1 = 2π * equil.psio - _omega_star_at(ψ) = begin + _omega_star_at(ψ, n_tor) = 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) + ω_star_e = n_tor * (2π / chi1) * (T_e * dn_e / n_e + dT_e) + ω_star_i = -n_tor * (2π / (Float64(z_i) * chi1)) * (T_i * dn_e / n_e + dT_i) return (ω_star_e, ω_star_i) end @@ -224,15 +223,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. 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) - else - (prof.omega_e, prof.omega_i) - end + # Take ω_*e, ω_*i from the spline derivatives, or from `profiles` when the caller + # supplies them directly. `run_slayer` supplies zeros, so the latter is a library path. + ω_e_use, ω_i_use = compute_omega_star ? _omega_star_at(psi, n_res) : (prof.omega_e, prof.omega_i) # Pull geometric trapped-fraction inputs from ResistGeometry when # available (populated by ForceFreeStates.resist_eval_all!); else From ea5d4658d05a3fbe4051a1aa8ba65191eea68102 Mon Sep 17 00:00:00 2001 From: d-burg Date: Thu, 17 Sep 2026 12:40:07 -0400 Subject: [PATCH 8/8] Regression - BUGFIX - Give each override run its own uniquely named rundir Placing the override rundir beside its example fixed relative file references, but the name became fixed per example, so two harness runs from the same checkout on an override case would delete and rewrite each other's deck and output. Create it with mktempdir under the example's parent instead: still a sibling, so relative references resolve, but unique per invocation, as the override rundir, worktrees and subprocess files already were. Only concurrent local-ref runs could collide; git-ref comparisons each run in their own temporary worktree. Co-Authored-By: Claude Opus 5 --- regression-harness/src/runner.jl | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/regression-harness/src/runner.jl b/regression-harness/src/runner.jl index 048276b2a..1a192a140 100644 --- a/regression-harness/src/runner.jl +++ b/regression-harness/src/runner.jl @@ -27,7 +27,7 @@ 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 sibling of the example dir and the named gpec.toml keys are +the deck is copied to a uniquely named 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. @@ -36,9 +36,9 @@ 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(dirname(example_path), ".regress-override-" * basename(example_path)) - rm(rundir; recursive=true, force=true) # stale leftover from a crashed run - cp(example_path, rundir) + # Unique per invocation, so concurrent runs from one checkout cannot share (and race on) a rundir. + rundir = mktempdir(dirname(example_path); prefix=".regress-override-" * basename(example_path) * "-", cleanup=false) + cp(example_path, rundir; force=true) rm(joinpath(rundir, "gpec.h5"); force=true) # drop any stale output copied along cfg = TOML.parsefile(joinpath(rundir, "gpec.toml")) for (dotted, val) in overrides