Skip to content

ForceFreeStates - FEATURE! - Cap the integration domain where resistive layers overlap - #298

Open
amlakbekaluachule wants to merge 6 commits into
bugfix/slayer-dprime-reference-lengthfrom
FKR-width
Open

ForceFreeStates - FEATURE! - Cap the integration domain where resistive layers overlap#298
amlakbekaluachule wants to merge 6 commits into
bugfix/slayer-dprime-reference-lengthfrom
FKR-width

Conversation

@amlakbekaluachule

@amlakbekaluachule amlakbekaluachule commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

Release note

  • Audience: users
  • Numerical impact: none on any shipped deck; the auto-grid edge density floor is now gated on the edge q-law fit, which can change the grid for limited plasmas run on grid_type = "auto" (harness @ 5fe6b42)
  • Migration: LayerWidths gains delta_norm, delta_visco and delta_dr; code constructing it positionally must supply them. sing_lim! and refined_psi_grid gain optional keywords and are otherwise unchanged.

GPEC can now locate the point past which adjacent rational surfaces' resistive layers overlap — the outer limit of the matched-asymptotic treatment, after Fitzpatrick, Nucl. Fusion 2025 Sect. 5.9 — and use it as an upper bound on the stability integration domain psilim. The overlap point is recorded whether or not it binds, so a run always shows whether layer physics constrained the domain. psihigh is untouched and remains user-set.

Regression report

Four cases at the rebased head, baf674659 (base, #403) vs 5fe6b4275, both sides run fresh with --force on identical pinned environments (julia 1.11.6, manifest 7e5c34ad).

solovev_n1       Summary: 21 unchanged
solovev_multi_n  Summary: 15 unchanged
diiid_n1         Summary: 47 unchanged

diiid_slayer_n1
Quantity                            baf674659  5fe6b4275  Diff               Status
SLAYER surface indices              [6 elem]   [6 elem]   0.0e+00            OK
SLAYER minor radius rs              [6 elem]   [6 elem]   0.0e+00            OK
SLAYER r-based shear                [6 elem]   [6 elem]   0.0e+00            OK
SLAYER Lundquist S                  [6 elem]   [6 elem]   0.0e+00            OK
SLAYER D_norm                       [6 elem]   [6 elem]   0.0e+00            OK
SLAYER P_perp                       [6 elem]   [6 elem]   0.0e+00            OK
SLAYER tauk                         [6 elem]   [6 elem]   0.0e+00            OK
SLAYER iota_e                       [6 elem]   [6 elem]   0.0e+00            OK
SLAYER Q_root [2/1,3/1,4/1]         [3 elem]   [3 elem]   5.8e-06            OK
SLAYER ω_Hz [2/1,3/1,4/1]           [3 elem]   [3 elem]   1.8e-05            OK
SLAYER γ_Hz [2/1,3/1,4/1]           [3 elem]   [3 elem]   1.452e-01 (0.01%)  ** CHANGED **
SLAYER no_root flags [2/1,3/1,4/1]  [3 elem]   [3 elem]   0.0e+00            OK
Summary: 1 changed, 14 unchanged

The one CHANGED line is solver jitter, not this branch, and it is measured rather than asserted: re-running baf674659 against itself with fresh samples moved γ at the same surface (3/1) by the same 0.1452 Hz, and 5fe6b4275 against itself by 0.1217 Hz at surface 2/1. The SLAYER root search is nondeterministic at the ~0.1 Hz (0.01 %) level per surface, in discrete steps, so the case's γ threshold sits below the solver's own reproducibility; that is a harness/threshold question (#418 and follow-up), not a change here. Every layer input feeding the root is bit-identical.

Coverage gap, stated rather than hidden: the edge_q_law gate on the auto-grid edge floor is exercised by diiid_n1 and diiid_slayer_n1 (both auto grids, both unchanged) but by no limited deck, because every Solovev/LAR case uses grid_type = "ldp" with fixed mpsi. That commit carries ! as a precaution. A Solovev auto-grid harness case would close this.

Notes for reviewers

This branch replaces the earlier design on this PR; the thread above records why. The architecture is the one agreed with @logan-nc: the overlap point is an upper bound on psilim, applied as a cap on qlim at the top of sing_lim!, so dmlim/qhigh still select the final surface from inside it and a cap beyond psihigh is inert with no special case. Nothing re-forms the equilibrium, which removes the trial/back-off loop, EdgeQuality, and the immutable-config collision from the previous version (~1800 insertions → ~700).

Stacked on #403, which supplies radial_label; base is set to that branch so the diff shows only these five commits, and will be retargeted to develop once #403 merges.

Because the base is not develop, the test workflow does not trigger on this PR while it is stacked. The full suite was run locally instead: julia -t 4 --project=. test/runtests.jl on tree b3d65ecf4, 25 testset groups, all passing, exit 0. CI will re-run it when the base is retargeted.

Three things worth a close look:

  1. The Eq. (100) mapping (LayerThickness.jl). The paper's τ_A carries no shear, whereas SLAYER's tau_h divides by n·s, so lu = (n|s|)·S_paper and Eq. (100)'s explicit (n|s|)^(-1/2) cancels exactly. The first implementation kept it and understated every width by √(n|s|).
  2. The radial label. Eq. (100) is not covariant — δ ∝ (q/|dq/dr|)^(1/2) — so it is only valid in the coordinate it was derived in, the toroidal-flux label of the paper's Eq. (30) (rs_method = :flux). On the DIII-D-like deck the label is worth one rational surface: cut at ψ = 0.99752 under :midplane, 0.99939 under :flux. The flux label reduces to the geometric minor radius on the circular LAR deck (r/a = 0.99–1.05) and departs from it by 20–45 % on the shaped deck.
  3. The external-reference test (runtests_layer_overlap_fitzpatrick.jl) rebuilds the paper's Sect. 5.8 JET equilibrium and drives GPEC's own slayer_parametersslayer_layer_thickness, reproducing the paper's overlap boundary at Ψ = 0.9985111 (paper: 0.9985) for n = 1 and 0.9949738 (paper: 0.9952) for n = 4. It covers the exact chain where the bug in (1) lived.

On the shipped DIII-D decks the cap binds nowhere: it sits beyond psihigh for the ideal, riccati and gal_resistive decks, and on the SLAYER deck it binds by 1.1e-4 in ψ without crossing a rational. Two of those decks carry no kinetic profiles, so the scan cannot run on them at all. The two width channels (|δ_s| Riccati vs Eq. 100) disagree by about two surfaces; both are reported, and that disagreement is the subject of the ODE-vs-Eq.(100) reduction issue rather than this PR.

@amlakbekaluachule
amlakbekaluachule marked this pull request as draft June 22, 2026 14:58
@amlakbekaluachule
amlakbekaluachule marked this pull request as ready for review June 22, 2026 14:58
@amlakbekaluachule amlakbekaluachule added refactor Behavior-preserving restructuring auto-merge feature New capability and removed refactor Behavior-preserving restructuring auto-merge labels Jun 22, 2026
@amlakbekaluachule

Copy link
Copy Markdown
Collaborator Author

@logan-nc this PR does two things:

  1. Fixes a bug in slayer_layer_thickness (LayerThickness.jl)
  2. Adds set_resistive_width_based_psihigh (new file,SetResistiveWidthPsihigh.jl)

Let me know if anything looks off.

@d-burg
d-burg changed the base branch from develop to feature/tearing-growthrates July 31, 2026 00:33
@d-burg

d-burg commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

I synced this branch with feature/tearing-growthrates (merge 8a771555) and retargeted the PR base from develop to feature/tearing-growthrates. The diff now shows your 2 files instead of 65.

Worth knowing why the retarget was needed: LayerThickness.jl doesn't exist on develop at all — the whole SLAYER module is tearing-branch work, and develop only carries a Slayer.jl stub. So the PR couldn't have merged there regardless of how clean it was.

The merge hit two conflicts, both caused by src/Tearing/InnerLayer/SLAYER/ being relocated to src/InnerLayer/SLAYER/ on the parent branch (the InnerLayer module had to move above ForceFreeStates to break a dependency cycle). Nothing to do with your changes — resolved keeping your δ_FKR / δ_visco constructor arguments. The package precompiles and the SLAYER tests pass on the merged branch.

Two things worth a look before review:

1. SetResistiveWidthPsihigh.jl is never loaded

SLAYER.jl includes LayerParameters.jl, Riccati.jl, LayerThickness.jl and LayerInputs.jl (lines 51–54) but not SetResistiveWidthPsihigh.jl, so the helper isn't reachable:

julia> using GeneralizedPerturbedEquilibrium
julia> isdefined(GeneralizedPerturbedEquilibrium.InnerLayer.SLAYER, :set_resistive_width_based_psihigh)
false

Adding include("SetResistiveWidthPsihigh.jl") alongside the others should wire it in, plus an export if it's meant to be public API.

2. δ_FKR / δ_visco aren't in the LayerWidths docstring

The # Fields list in LayerThickness.jl (lines 133–139) still stops at d_beta. The repo convention is to document struct fields in the docstring rather than as inline comments, so these want a line each saying what they are and their units.

Minor: I also stripped some trailing whitespace from both files while resolving the merge — the pre-commit hygiene hook rejects it.

Base automatically changed from feature/tearing-growthrates to develop August 11, 2026 22:51
@logan-nc

Copy link
Copy Markdown
Collaborator

@d-burg whats the status of this? Should it be reviewed now? Or should it still be marked as draft?

@amlakbekaluachule

amlakbekaluachule commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Fixed both flagged issues — SetResistiveWidthPsihigh.jl is now included/exported, and δ_FKR/δ_visco are documented in the LayerWidths docstring.

@logan-nc

Copy link
Copy Markdown
Collaborator

@claude review this

@claude

claude Bot commented Aug 15, 2026

Copy link
Copy Markdown

Review

Reminder (per repo policy): this PR must not be merged without third-party human review, regardless of any automated approval. Also — please run the regression harness (regress --cases <relevant_case> --refs develop,local) before merge; this PR touches LayerWidths/slayer_layer_thickness, which is exactly the kind of numerical-output change the harness is meant to catch.

Correctness / design concerns

  1. Hardcoded example data file inside an exported library function. set_resistive_width_based_psihigh unconditionally loads examples/DIIID-like_ideal_example/TkMkr_D3Dlike_Hmode_kinetic.h5 (SetResistiveWidthPsihigh.jl:39) regardless of the equilibrium/kinetic data actually carried by the caller's r. Since the function is exported from both SLAYER and InnerLayer, any user who calls it on a different equilibrium silently gets DIII-D-like kinetic profiles instead of their own, and the relative path only resolves when the working directory happens to be the repo root. build_slayer_inputs already accepts a profiles::KineticProfiles argument (LayerInputs.jl) — this function should take the same, rather than reading a fixed file. If this really is meant to be a one-off sanity-check script for that specific example (as the PR title suggests), it probably shouldn't be a permanent exported module API at all.

  2. Reimplements existing rational-surface-finding instead of reusing it. find_psi_for_q brute-forces a 200,000-point grid of q_of_psi_real per call (called once per m in q_scan, i.e. up to ~39× → ~7.8M spline evaluations) followed by a hand-rolled 60-iteration bisection. src/ForceFreeStates/Sing.jl already has _find_rational_surfaces/rational_psi_nodes, which locate q=m/n surfaces via Roots.jl's find_zero(..., Roots.Brent()) directly on profiles.q_spline, segmented between q-extrema (so it's also reverse-shear-safe, which the new bisection is not — it just finds the first sign change on a dense scan). Roots.jl is already a project dependency. Per CLAUDE.md's minimal-change-discipline section, this should reuse rational_psi_nodes/_find_rational_surfaces instead of re-deriving an independent, less robust q(ψ) inversion.

  3. omega_star_at (SetResistiveWidthPsihigh.jl:59-67) duplicates _omega_star_at in LayerInputs.jl almost verbatim. Same formula, same sign convention, same structure — this is a second copy of logic that already exists and is already exercised by build_slayer_inputs.

  4. Extrapolation edge case: A = (q2 - q1) / (log(1-ψ1) - log(1-ψ2)) (SetResistiveWidthPsihigh.jl:9) divides by a log-difference that goes to -Inf if x_knots[end] == 1.0 exactly, making A = 0; the target_q > q_last branch then computes exp((q_last-target_q)/A) → division by zero. In practice psihigh < 1 is typical, but there's no guard or comment noting this assumption, and this is precisely the kind of degenerate input this function is meant to help users avoid — worth an explicit check/error message.

Physics traceability

  1. δ_FKR = r_s · S^(-1/3) is labeled "FKR resistive-inertial length scale" (LayerThickness.jl docstring), but the classic Furth-Killeen-Rosenbluth (1963) resistive-inertial ("constant-ψ") tearing-layer width scaling is usually quoted as δ ∝ r_s S^{-2/5}, not S^{-1/3} (the S^{-1/3} scaling is closer to the Δ'→∞ / resistive-internal-kink limit). No equation citation is given, and neither docs/development/references.md nor docs/resources/ currently lists an FKR reference — only the 2026 Burgess two-fluid-layer paper is present. Given CLAUDE.md's explicit encouragement to cite the source equation for physics kernels, please add the citation (equation number) this formula comes from, and double check the exponent — this looks worth a pass from the fortran-physics-reviewer agent before merge.

Style / consistency

  1. New struct fields δ_FKR, δ_visco (LayerThickness.jl:155-156) use unicode Greek + mixed casing, while every other field in LayerWidths/SLAYERParameters uses ASCII delta_*/d_* snake_case (delta_s, delta_s_m, d_beta). This is inconsistent with the rest of the struct and forces awkward unicode access like lw.δ_visco in the new call site — recommend delta_fkr/delta_visco to match convention.
  2. Stray blank line before end in the LayerWidths struct definition (between the new fields and end) — minor formatting nit.
  3. q1_val (SetResistiveWidthPsihigh.jl:63) is bound to dqdpsi_hybrid(ψ_m) (a derivative), reusing the name shortly after q1, q2 were bound to actual q-values at ψ1, ψ2 a few lines above — easy to misread; consider dqdpsi_val or similar.
  4. Parameter name r for the response/model object (set_resistive_width_based_psihigh(r; ...)) is easy to confuse with the various rs/rs_val (minor radius) variables used throughout the same function.

Documentation / tests

  1. Both new exported functions (set_resistive_width_based_psihigh, print_layer_width_table) have no docstrings. Per CLAUDE.md, Documenter CI fails on missing_docs for any exported symbol without a docstring — this should fail the docs build as-is.
  2. No test coverage was added for the two new LayerWidths fields or for the new 158-line function (diff touches only src/, no files under test/).

None of the above touch the pre-existing slayer_layer_thickness/LayerWidths core computation beyond the two new fields, so the blast radius of a fix should be contained to the new file plus the docstring/struct nits.

@logan-nc

Copy link
Copy Markdown
Collaborator

Status

Rewritten from the original 172-line contribution, which had zero call sites and compared layer widths in metres against positions in normalized flux. That file is gone.

Feature[Equilibrium] psihigh_from_layer_overlap, opt-in, default off. Forms a first-pass equilibrium, finds where adjacent rational surfaces' resistive layers overlap, checks the reconstruction can actually deliver the resulting domain, and re-forms. Floored by the rational-q window containing psiedge so the dW scan band stays open. The scan runs whenever kinetic profiles are readable regardless of the flag, so gpec.h5 always records Equilibrium/psihigh_resistive_layers beside psihigh_resolved, plus per-surface widths under Equilibrium/ResistiveLayers/.

Default is off because enabling it crosses a rational surface on a diverted plasma: on DIIID-like_ideal_example the domain goes 0.995 → 0.993281, 5 surfaces → 4, mpert 35 → 34, moving et[1] 76% and NTV torque ~1800%. That is the criterion working, not a defect, but not a change to make on every user's behalf silently.

Nine defects fixed along the way, all found by trying to make the feature trustworthy:

defect consequence
field-line ODE retcode never checked truncated solves consumed as closed flux surfaces — silently wrong equilibria
round-trip check sampled only at ψ knots structurally blind to inter-knot ringing; passed while et[1] reached −47707
surface_da_dpsi stencil clamped at 1−1e-4 the real limit on near-boundary accuracy, not psi_cap
run_slayer passed b0exp as B_T Lundquist number off by ~95% — split out as #399
trial formations unguarded crash instead of back-off, psihigh left corrupted
round-trip unavailable on the efit path the path every shipped deck uses
gpec.h5 replay crash stored relative profile paths don't resolve in the output dir
edge q-law duplicated, applicable to limited plasmas one shared helper, with a guard that actually discriminates
inverse equilibria extrapolated fabricated 3 non-existent surfaces on the CHEASE fixture

Verification — 122 + 67 + 59 + 32 + 14 tests; docs clean; the metres→flux conversion triple-checked three independent ways (the binding pair is 1.821 mm apart with half-widths summing to 2.338 mm, argued in pure metres); the back-off gate validated against ForceFreeStates energies as ground truth, and it flips one step before they degrade.

Still open

@logan-nc

Copy link
Copy Markdown
Collaborator

@d-burg — ready for review, but please take #399 first.

#399 is the one-line bt fix (SLAYER was running at b0exp = 1.0 T instead of the physical ~1.95 T, so the Lundquist number was off by ~95%). It is cherry-picked here as 4304caa, so once #399 merges this branch's copy resolves cleanly and the diff shrinks to this PR's own work.

That ordering also separates the regression movement. diiid_slayer_n1 needs re-pinning, and with #399 landed first it is unambiguous which part is the bt correction and which is this feature — measured separately, #399 alone accounts for S +94.5%, D_norm 35%, γ 4.4%.

Worth your attention specifically:

  • The psiedge floor governs every DIII-D deck, so the layer criterion never actually sets the domain in the shipped examples — each asks for ~0.9892 and is held at the q=6 window top. Working as specified, but you may want a deck that exercises the physics path.
  • The bt fix changes the layer widths themselves, so any Tearing/LayerWidths/ numbers you have from before InnerLayer.SLAYER - BUGFIX! - Use the physical toroidal field, not the b0exp normalization #399 were computed at half the true field.
  • delta_s_abs now appears in two h5 groups. They agree to ~1e-6 and the difference in scope is documented in hdf5-conventions.md; flag it if that still reads as ambiguous.

Everything except the opt-in flag is a bug fix that stands on its own — the field-line ODE retcode was never checked, and the round-trip gate was structurally blind to spline ringing (it passed while et[1] hit −47707).

@logan-nc logan-nc changed the title resistive-layer-width sanity checks for SLAYER Resistive-layer-width based equilibrium truncation Aug 18, 2026
@logan-nc logan-nc removed their assignment Aug 18, 2026
@logan-nc
logan-nc removed the request for review from jhalpern30 August 18, 2026 18:37
@logan-nc

Copy link
Copy Markdown
Collaborator

@d-burg, as claude says this has hit a point where it needs your expert review. Workflows are in place to decide the truncation from the resonant layer widths. The issue is that it is reporting big widths and wanting to truncate way in (~0.9892 for DIII-D). I find this suspect, since Fitzpatrick had been finding things like to be very far out past 0.9999. In the process of adding the truncation logic, claude did convince me the units of the layer widths had been wrong in the original overlap-checking-loop (comparing layer widths of m to rational spacing in psin) and a conversion from m to psin was added... so maybe double check that?

Do you think we need to try and reproduce the exact case from Fitzpatricks paper on this as a sanity check?

@d-burg

d-burg commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

the n_kept < 2 refusal and the psiedge floor are all real and do what they claim. Four findings,
one blocking, plus an answer to your question about the widths.

1. Blocking: the trial/back-off loop cannot compile on current develop

The mechanism mutates the config in place — eq_config.psihigh = hi in the back-off loop, and again
on the psiedge-floor path. #367 froze EquilibriumConfig: develop has @kwdef struct, this
branch still carries @kwdef mutable struct, which is why EquilibriumTypes.jl is one of the four
conflicts. This is not a cosmetic rebase — mutation is the mechanism here.

The idiom to move to is your own 31df659f: carry the dict and rebuild per trial,

cfg = EquilibriumConfig(merge(equil_dict, Dict{String,Any}("psihigh" => hi)), dir)

which also removes the "psihigh must be restored, since it was mutated before the attempt" failure
mode the loop currently has to hand-manage in its catch.

2. The width solver is correct — the criterion is what produces ~0.9892

Taking your question about the widths first: I validated riccati_del_s against the textbook and it
reproduces it. GPEC already integrates Fitzpatrick's layer equations verbatim — its E and F match
(6.29)/(6.30) term-for-term, dW/dq = W/q - W^2/q + qE/F is (6.38), and it returns dels_db, which
is literally the plotted δ_s/d_β. Driving it at prescribed normalized parameters (D_norm = 1,
P_perp = P_tor = P̂, Q_e = Q̂*/(1+1/τ), so the code's internal Q_hat equals the book's Q̂*)
reproduces figures 6.2 and 6.3 over Q̂* ∈ [0,4], P̂ ∈ [0,4]. Eight spot checks, all inside the
published contour bands:

(Q̂*, P̂) Re GPEC fig 6.2 Im GPEC fig 6.3
(0.25, 0.25) 0.818 ~0.8–0.9 −0.222 ~−0.15…−0.3
(0.5, 0.5) 0.943 ~0.9–1.0 −0.354 ~−0.3…−0.45
(2.0, 0.5) 1.351 ~1.3–1.5 −1.417 ~−1.2…−1.35
(4.0, 0.5) 2.008 ~1.9–2.1 −2.225 beyond −1.8
(0.5, 4.0) 1.537 ~1.5 −0.119 ~−0.15
(4.0, 4.0) 1.350 ~1.3–1.4 −1.466 ~−1.35…−1.5

(τ = 1 assumed — the caption does not state it; the agreement is the evidence.)

The widths on the actual DIII-D surfaces are also not anomalous. Table 6.2 of the book quotes
|δ_s| = 4.69 mm (low-field reactor) and 2.95 mm (high-field); ours span 1.8–4.4 mm:

m ψ r_s [m] |δ_s| [m] δ_visco [m] S δ_visco/δ_s
2 0.518 0.362 3.91e−3 2.81e−3 1.9e7 0.720
3 0.770 0.458 4.13e−3 2.85e−3 3.6e7 0.689
4 0.893 0.504 3.85e−3 2.62e−3 5.5e7 0.680
5 0.968 0.531 4.42e−3 2.74e−3 3.3e7 0.620
6 0.993 0.540 2.85e−3 1.76e−3 5.4e7 0.615
7 0.999 0.542 1.82e−3 1.20e−3 1.4e8 0.661

So where does 0.9892 come from? Between m=6 (ψ=0.99328) and m=7 (ψ=0.99851) the spacing is
Δψ = 0.0052, while dr/dψ ≈ 0.35 m there, so the two layers occupy δψ ≈ 0.0081 and 0.0052. The
half-widths sum to 0.0067 > 0.0052 — overlap — and cutting at ψ(6) − w(6)/2 gives 0.9893, the
number you are seeing. Nothing is wrong with the width; the surfaces are simply 5 mΨ apart out there.

Two notes on delta_visco. It is a consistent 0.62–0.72 × δ_s across the whole domain, so it is
the narrower width, overlaps later, yields a larger psihigh, and therefore minimum(cands) never
selects it — the viscous criterion is effectively dead code on this deck. And since it is algebraic
(δ_norm·P_perp^(1/6)) rather than from the Riccati solve, it is the weaker of the two anyway. Worth
deciding whether it earns its place. (delta_FKR is a non-issue: it was renamed delta_norm and is
not used as a criterion — the primary width is already the validated Riccati δ_s.)

2b. Why the criterion is nonetheless close to self-fulfilling

This is where I think your suspicion about ~0.9892 is right, and I do not think the metres→ψ_N
conversion is the culprit.

Past the last real surface the edge law is q ~ -A·ln(1-ψ), so surfaces sit at
ψ_m = 1 - exp(-m/(nA)) and their spacing shrinks geometrically as ψ → 1. Meanwhile the layer
width does not shrink: toward the edge T_e falls, η rises, S drops, and δ_s ∝ r_s S^(-1/3)
grows. A shrinking gap against a growing width means overlap is guaranteed at some radius for any
equilibrium — it is a property of extrapolating toward a separatrix, not a measurement of this
plasma.

So "where do layers first overlap" is set by where the geometric spacing crosses the width, and both
inputs there are extrapolations: A from edge_q_law fitted on the outer knots, and the kinetic
profiles continued past the grid. That is consistent with what you are seeing — the criterion always
wants to cut in aggressively, and the psiedge floor ends up binding on every shipped deck.

Fitzpatrick's ψ > 0.9999 is not really the same quantity: TJ places its outermost surface of
interest, it does not ask where layers first touch under an extrapolated q. I would expect these to
disagree by construction.

3. The viscous criterion fails open

da_dpsi (LayerOverlap.jl:257) and delta_s_m (:277) are both finite-checked, each recording a
note and continue-ing. delta_visco is not — :290 pushes lw.delta_visco / abs(da_dpsi)
unguarded. A NaN there makes every comparison in _first_overlap_limit false, so the viscous scan
silently returns "no overlap" with no note, and recommended = minimum(cands) quietly loses its
more conservative half. The bias is outward, which is the unsafe direction, and it is the one path in
this file that does not follow its own record-and-skip discipline.

4. The scan's cost is bounded only by m_max = 2000 layer solves

Every surface in found runs build_slayer_inputs + slayer_layer_thickness, and the latter is a
Riccati solve. The extrapolation terminates on psi_cap, but how many surfaces that takes depends on
A; a slowly-rising edge law reaches the cap late. Worst case is 2000 Riccati solves — and since the
scan runs whenever kinetic profiles are readable, regardless of the flag, that cost lands on runs
that never asked for the feature. Bounding the extrapolated-surface count (or the work) rather than
only the m index would make it predictable. Worth quoting the added runtime on a default deck.

On reproducing the Fitzpatrick case

Done — see §2. It was cheaper than expected because the solver already implements the book's
equations; no new physics was needed, only driving it at prescribed normalized parameters. I would
not chase the width formula any further: it matches the published figures and the published
magnitudes.

I have also pushed two fixes to this branch (0c2b3ac): delta_visco now gets the same
finite-check-and-note treatment delta_s_m and da_dpsi already had (it was the one path that could
fail open), and the layer-solve count is bounded with a note when the bound bites. 122/122 tests pass.

One coverage note on your own observation that the floor governs every DIII-D deck: as it stands the
physics path is never exercised end-to-end by a shipped deck or by runtests_layer_overlap.jl, which
tests the pieces (conversion, q = m/n, the cut rule, the limited-plasma refusal) rather than a run
where the criterion actually sets the domain. A deck that reaches the physics path — or a test that
drives _choose_psihigh with a synthetic scan — would close that.

Also: this is dirty against develop (4 conflicts). In test/runtests_slayer_runner.jl the
cherry-picked 4304caad collides with #399 as merged — #399's source-text test was replaced with a
behavioural one in runtests_slayer_inputs.jl, so take develop's side and drop the cherry-pick.

⚠️ Not an approval — needs a third-party human review before merge.

@logan-nc

Copy link
Copy Markdown
Collaborator

Clean out any approximate visco layer widths from the overlap criteria - those and any other limit approximations can be output in the Tearing section of SlabLayer outputs if flagged on. The Equilibrium truncation should use the general model.

The Fitzpatrick model this is based off of is https://iopscience.iop.org/article/10.1088/1741-4326/ae4fdd/meta (also at https://arxiv.org/pdf/2511.07666 if you hit a paywall), NOT the book (which is a fine benchmark for the layer calculations but doesn't have the truncation logic in it). Figure 10 shows an example for typical JET parameters that finds 0.998 and Fig. 11 for n=4 finds 0.9938. @d-burg can you have your AI add this paper to the resources then cite it in annotations and the docs where appropriate? Might as well also have it double check it's own logic above based on the text... This feature is not much help if it regularly suggests truncating deep into the pedestal 😛 Maybe it gets better (further out) for an ITER case?

@d-burg

d-burg commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

@logan-nc ahh sounds good, I shall dig into this

Comment thread docs/development/references.md Outdated
magnetic perturbation"
- Location: `docs/resources/2025-Fitzpatrick-Response_of_a_magnetically_diverted_tokamak_plasma_to_a_resonant_magnetic_perturbation.pdf`
- Published: Nucl. Fusion (2025), doi 10.1088/1741-4326/ae4fdd; arXiv:2511.07666
- Describes: **the source of the edge-truncation criterion**. q diverges logarithmically at the

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@d-burg this is way longer than the other one-liner descriptions in this md. One liner goes here. The actual docs webpage should describe the layer widths and truncation logics and cite this paper using cross references.

Comment thread docs/development/references.md Outdated
separatrix, so rational surfaces accumulate without bound; the paper shows their spacing
shrinks *faster* than the resistive layer width, so the layers merge in a narrow region
straddling the separatrix. Only surfaces with `0 < Ψ < 1 - ε_c` need be retained, the rest
treated as vacuum — which is exactly the empirical truncation existing response codes

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

"which is exactly" is sooooo AI 🙄
also, no other existing response code actually uses this that I know of

Comment thread docs/development/references.md Outdated
semi-collisional (Eq. 12), and resistive-inertial (Eq. 13). The `P^(1/6)` factor of Eq. (11)
is the `delta_visco` scale in `LayerWidths`.

- **Fitzpatrick (2025)**: "Response of a magnetically diverted tokamak plasma to a resonant

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

In-line annotations of the layer widths and/or truncation logic in src should be citing this as per the claude.md instructions

@d-burg

d-burg commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

@logan-nc read the paper — agreed it changes the picture, and with its width the truncation lands
where you expected instead of in the pedestal.

Sect. 5.6 shows the strong shear near the separatrix forces every resonant layer into the
diffusive-resistive regime, where the layer equation solves analytically: Eq. (100) is a closed-form
width, no ODE integration involved. The 2023 book's general two-fluid Riccati solve (what the scan
uses today) remains a good benchmark of the layer solver — it reproduces the book's figs 6.2/6.3 —
but the book has no truncation logic, and the paper's ε_c values are calibrated in the paper's own
width convention, so the criterion should use Eq. (100) verbatim. The strongest reason: the paper's
fig 9 cross-validates the overlap point against an independent criterion — |Δ_k|/(−E_kk) dropping
through ~1, where the shielding response actually becomes vacuum-like — and the two coincide; that
validation is attached to Eq. (100)'s width definition. Empirically the two widths differ by a
near-uniform factor ~2 on this deck (Eq. 100 ≈ 0.5 × Riccati across m=2–10): the ODE width is
defined through the dispersion relation (the equivalent resistive-sheet thickness, book Eq. 6.24),
Eq. (100) is the spatial decay scale of the layer solution, so a constant O(1) offset is expected —
and that factor is what separates the two truncation points below.

One trap for the implementer: the paper's S = τ_R/τ_A uses the shear-free τ_A of its Eq. (74), while
GPEC's lu carries the shear via tau_h ∝ 1/(n·sval_r·bt). So lu = (n|s|)·S_paper and Eq. (100)'s
explicit (n|s|)^(1/2) cancels, leaving δ_DR = r_s^(3/2)·P_perp^(1/4)/(lu^(1/2)·d_β^(1/2)) in GPEC
variables (tau_r, P_perp, d_beta match the paper's Eqs. 73/75/77 directly; recorded in
references.md, 08b9c5a). Missing the cancellation understates the width by (n|s|)^(1/2),
divergently toward the separatrix.

Both truncation points on the DIII-D SLAYER deck, n=1, in-grid plus edge-q-law surfaces:

width first overlap cut ψ ε_c last kept rational
Riccati two-fluid (current scan) m=6 / m=7 0.98919 1.1e−2 q = 5
Eq. (100) m=7 / m=8 0.99711 2.9e−3 q = 6

The paper's references: ε_c = 1.5e−3 (JET n=1, fig 9) and 4.8e−3 (n=4, fig 10); our 2.9e−3 sits in
that band where 1.1e−2 does not. Also worth noting the derived cut ratifies the existing empirical
guideline on this deck — dmlim = 0.2 gives qlim ≈ 6.2, i.e. the same keep-q≤6 truncation — which
matches the paper's framing: it derives the ε_c that GPEC currently sets by guideline, and
adds the machine/profile/n dependence. Since Eq. (100) is algebraic in quantities
build_slayer_inputs already computes, the scan needs no Riccati solves at all (that width stays
available as SlabLayer diagnostic output; δ_visco is already out of the criterion per your comment,
0c2b3ac).

On where the cut should land: the paper's prescription (p. 22) is to solve the response equations
(its Eqs. 47/48) in 0 < Ψ < 1−ε_c and write b = ∇V, ∇²V = 0 beyond — the overlap region's
response is vacuum-like. Grad-Shafranov is never truncated; figs 8–10 evaluate equilibrium-derived
quantities across and beyond the separatrix, so the background extends as far as it is constructible
and only the response solve stops. In GPEC that surface is psilim, where the Euler-Lagrange
integration ends and the vacuum matching already imposes ∇²V = 0 — and GPEC's empirical guideline
the paper refers to is the dmlim mechanism in sing_lim!, which sets psilim.
Deriving qlim/psilim from the Eq. (100) overlap point is also a strictly smaller change than
what's on the branch: nothing re-forms, so the trial/back-off loop and the immutable-config collision
(finding 1) disappear. That leaves EdgeQuality answering "how far is the equilibrium representable"
(psihigh) and the overlap criterion answering "how far is the response model valid" (psilim).

Was there a reason the feature targeted psihigh originally that this misses? If not I'll prototype
the psilim version.

Your references.md review comments are addressed on the branch (4201f67): the entry is trimmed to
the one-liner convention, the description and the S-convention note moved to the workflow docs page,
and LayerOverlap.jl's header now cites the paper directly.

@d-burg

d-burg commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

@logan-nc prototyped the psilim version. Three things changed since my last comment, one of
which invalidates the numbers in it.

The formula I quoted was right, my implementation of it wasn't. I reintroduced the
(n|s|)^(-1/2) on top of GPEC's lu — exactly the double-count I described two paragraphs
earlier — which understated every width by sqrt(n|s|), about 6.6x at q=7, and reported no
overlap anywhere. Fixed; delta_dr = r_s^(3/2)*P_perp^(1/4)/(lu^(1/2)*d_beta^(1/2)), no shear
factor, as written.

Second, the radial label matters and we had it wrong. Eq. (100) is not covariant — the width
goes as (q/|dq/dr|)^(1/2), so it picks up J^(1/2) under a change of radial variable and is
only valid in the coordinate it was derived in. That is the flux label of the paper's Eq. (30),
dpsi_p/dr = B0*r*g/q, i.e. the toroidal-flux radius, not the geometric minor radius SLAYER
was using. On this deck that is worth one rational surface:

radial label first overlap cut psi eps_c last kept rational
midplane minor radius m=8 / m=9 0.99752 2.5e-3 q = 6
Eq. (30) flux label m=9 / m=10 0.99939 6.1e-4 q = 7

Worth saying plainly that the flux label is not a circular-plasma construct — it is defined
from flux alone, with no geometry input. On the circular LAR deck it reduces to the geometric
minor radius (r/a = 0.99 to 1.05); on the shaped DIII-D deck it departs from it by 20 to 45%.
It is the geometric radii that fail to generalise. Flux-surface-averaged radius does not fix
this: it is still geometric, so its Jacobian collapses at the separatrix, and in the edge band
it could not score the extrapolated surfaces at all (it is well-behaved at interior surfaces —
this is an edge-specific failure, not a general one).

Third, and the reason I would now trust the criterion: it reproduces the paper. Running the
Sect. 5.8 JET model directly — its Eqs. (32)-(36) q profile solved self-consistently for
rhat95/rhat105, its Eqs. (70)-(78) layer quantities, mtanh profiles off its fig. 8 — gives

this implementation paper
n = 1 inner boundary Psi = 0.9985 Psi = 0.9985
n = 4 inner boundary Psi = 0.9936 Psi = 0.9952

n = 1 lands on it; n = 4 is 34% high in eps_c, which I would put on the digitised pedestal. The
n-scaling also comes out right on our own deck: eps_c(n=4)/eps_c(n=1) = 2.85 against the paper's
3.20, both sublinear, as u_c ~ n*|ln u| predicts. Note our own eps_c = 6.1e-4 sits below the
paper's 1.5e-3 — that is a different machine, and the JET reproduction above is the controlled
comparison.

So, to your original worry: the 0.98 truncations were not real. They came from the ODE width
plus the geometric label. With Eq. (100) in its own coordinate the criterion changes no retained
surface on any shipped DIII-D deck — it is beyond psihigh for the ideal (0.995), riccati (0.995)
and gal_resistive (0.993) decks, and on the SLAYER deck (0.9995) it binds by 1.1e-4 in psi
without crossing a rational. Two of those decks carry no kinetic profiles, so the scan cannot
run on them regardless. The feature is a no-op on everything we ship, which argues for shipping
it opt-in with the recorded overlap point as the actual deliverable.

Architecture, per your sign-off: the overlap point is an upper bound on psilim, nothing
more. It is applied as a cap on qlim at the top of sing_lim!, so dmlim/qhigh still select
the final surface from inside it, and a cap beyond psihigh loses the min and is inert with no
special case. psihigh is untouched and stays user-set. Consequences: no equilibrium re-formation,
so the trial/back-off loop, EdgeQuality, the refined_psi_grid retarget and the
immutable-config collision all disappear; roughly 1800 insertions become about 700.

Still open, and I would not gate this PR on it: the two width channels now disagree by about two
surfaces (|delta_s| cuts at 0.98978, Eq. 100 at 0.99939). Both are reported separately. That is
the subject of the ODE-vs-Eq.(100) reduction check.

The radial-label plumbing landed separately as radial_label in #403; this branch stacks on it
rather than duplicating it.

Regression harness: not run yet on this branch — I am re-running it. I will post the table separately. Expected
movement is on the limited decks only (Solovev, LAR): the shared edge_q_law fit now gates the
grid-refinement edge density floor, which was previously applied unconditionally, so a plasma
with finite edge q is no longer packed as if q diverged. Diverted decks should be untouched.

@d-burg
d-burg changed the base branch from develop to bugfix/slayer-dprime-reference-length August 21, 2026 16:48
@d-burg d-burg changed the title Resistive-layer-width based equilibrium truncation ForceFreeStates - FEATURE! - Cap the integration domain where resistive layers overlap Aug 21, 2026
@d-burg
d-burg requested a review from logan-nc August 21, 2026 17:00
@github-actions github-actions Bot added the changed-results Results move or an interface breaks - read before upgrading label Aug 21, 2026
@d-burg
d-burg force-pushed the bugfix/slayer-dprime-reference-length branch from 06be788 to baf6746 Compare August 21, 2026 17:11
d-burg added 5 commits August 21, 2026 13:12
…e layer width

Adds three algebraic scales to `LayerWidths` alongside the Riccati `delta_s`:
`delta_norm` (r_s S^-1/3), `delta_visco` (the P^1/6 viscous-resistive broadening) and
`delta_dr`, the diffusive-resistive width of Fitzpatrick, Nucl. Fusion 2025
(doi 10.1088/1741-4326/ae4fdd) Eq. (100), which sets the edge layer-overlap criterion.

The mapping of Eq. (100) into SLAYER variables is not literal. The paper's tau_A
(its Eq. 74) carries no shear, whereas SLAYER's tau_h divides by n*s, so
lu = tau_R/tau_h = (n|s|) * (tau_R/tau_A). Substituting that cancels Eq. (100)'s
explicit (n|s|)^(-1/2) exactly, leaving

    delta_dr = r_s^(3/2) * P_perp^(1/4) / ( lu^(1/2) * d_beta^(1/2) )

with no shear factor. Carrying the (n|s|)^(-1/2) on top of SLAYER's lu double-counts
the shear and understates the width by sqrt(n|s|) -- a factor of 6.6 at q=7 on the
DIII-D-like deck, enough to report no overlap anywhere.

Validated against the paper's own Sect. 5.8 JET model, which this reproduces at
Psi = 0.9985 for n = 1 (paper: 0.9985) and 0.9936 for n = 4 (paper: 0.9952).

Marked `!` because `LayerWidths` gains fields.
…er-overlap scan

Factors the diverging edge model q ~ -A*ln(1-psi) out of the grid-refinement density
floor into `edge_q_law`, so one fit serves both that floor and the resistive-layer
overlap scan's search for rational surfaces beyond the equilibrium grid. A cubic in
psi (on q or on iota) saturates instead of diverging and is not usable for the
extrapolation; measured on the DIII-D-like deck, extrapolating from a grid ending at
psi=0.97 to the true q=8 surface at psi=0.99976, the log law lands within 5% while a
cubic iota undershoots by 18%.

`edge_q_law` returns nothing when the diverging model does not describe the
equilibrium, judged by knot count, sign of A, and the log fit beating a plain linear
one. That test now gates the edge density floor, which was previously applied
unconditionally: a limited plasma with finite edge q is no longer packed as if q blew
up. Marked `!` because that changes the auto grid, and so results, for limited decks
(Solovev, LAR); diverted decks are unaffected.

Also adds an optional `psihigh` to `refined_psi_grid`, which truncates the measured
knot density so a pass-1 equilibrium can supply the density for a reduced domain.
Locates the q = m/n rational surfaces, computes each one's resistive layer width, and
reports the outermost psi at which adjacent layers are still separated -- the outermost
domain the matched-asymptotic treatment supports, per Fitzpatrick, Nucl. Fusion 2025
Sect. 5.9. Surfaces beyond the equilibrium grid are located on the shared separatrix
edge q-law, so the scan can report an overlap point lying outside psihigh; that is
recorded rather than clipped, since it is the bookkeeping a user needs to see whether
the domain was constrained by layer physics at all.

Widths are compared in normalized flux, converted from metres by the Jacobian of
whichever radial label `rs_method` selects -- the same label that sets the shear,
tau_R and d_beta/r, because Eq. (100) is not covariant (delta ~ J^(1/2) under a change
of radial variable) and is only valid in the coordinate it was derived in. On the
DIII-D-like deck the label is worth one rational surface: the cut sits at psi=0.99752
under `:midplane` and 0.99939 under `:flux`, Fitzpatrick's Eq. (30) label.

Reports both the |delta_s| and Eq. (100) channels separately so the two can be
compared; they disagree by about two surfaces, which is the subject of issue #411.
…ve-layer overlap

`sing_lim!` gains an optional `psilim_cap`. Where the resistive layers of adjacent
rational surfaces overlap, no surface retains a well-separated inner region and
matched asymptotics is not defined, so that location is an upper bound on the useful
domain.

It is applied as a cap on `qlim` rather than by assigning `psilim` directly, which
keeps it a bound rather than a setpoint: the existing `dmlim` / `qhigh` truncation
still selects the final surface from inside it, and a cap lying beyond `psihigh` is
inert by construction with no special case -- it simply loses the `min`. The domain
can therefore only shrink, never grow, so no equilibrium ever has to be re-formed.

Measured on the shipped DIII-D decks with the Eq. (100) width in the `:flux` label,
the cap changes no retained surface on any of them: it is beyond `psihigh` for the
ideal (0.995), riccati (0.995) and gal_resistive (0.993) decks, and on the SLAYER deck
(0.9995) it binds by 1.1e-4 in psi without crossing a rational. The scan needs kinetic
profiles, which two of those decks do not carry, so it is inert there regardless.
…d Fitzpatrick JET case

Rebuilds the model JET equilibrium of Fitzpatrick, Nucl. Fusion 2025 Sect. 5.8 from the
paper's own definitions -- the Eqs. (32)-(35) safety factor with alpha-/alpha+ solved
self-consistently against Eq. (36), and mtanh edge profiles anchored to its Fig. 8 -- and
checks where adjacent resistive layers first overlap against the values the paper reports.

The widths are driven through `slayer_parameters` and `slayer_layer_thickness` rather than
recomputed here, so the test covers the shipped tau_R / tau_A / d_beta chain and the
`delta_dr` formula itself. That distinction matters: the bug this test would have caught --
carrying Eq. (100)'s explicit (n|s|)^(-1/2) on top of a `lu` that already contains it -- lives
in exactly that chain, and a test that reimplemented the width would have passed while the
shipped one was wrong.

Measured against the paper: Psi = 0.9985111 for n = 1 (paper 0.9985, deviation 1.1e-5) and
0.9949738 for n = 4 (paper 0.9952, deviation 2.3e-4). Bounds are set at 1e-4 and 1e-3, leaving
roughly 9x and 4x margin; the model is quadrature and root-finding with no BLAS, so platform
spread is orders of magnitude below either. The test logs both deviations so a future failure
reports how far it moved rather than only that it moved.

This is the suite's second check pinned to an external published reference rather than to
GPEC's own history.
…'s own reproducibility

The `diiid_slayer_n1` γ_Hz threshold of 1e-1 sits below what the threaded dispersion root
search reproduces: re-running one commit against itself with fresh samples moved γ by 0.145 Hz
at surface 3/1, 0.122 Hz at 2/1 and 0.076 Hz at 4/1, in discrete steps, with every layer input
bit-identical. At 1e-1 the case therefore flags a null change as CHANGED roughly half the time.

2.5e-1 clears the largest measured step by 1.7x. This is a stopgap: the underlying fix is a
reproducible root search, after which the threshold can come back down.
@logan-nc

Copy link
Copy Markdown
Collaborator

@d-burg please hold for me to find time to skim the full diff. Quick thoughts from the comments:

Architecture, per your sign-off: the overlap point is an upper bound on psilim, nothing
more. It is applied as a cap on qlim at the top of sing_lim!, ... roughly 1800 insertions become about 700.

Great. I like this simplification a lot. Open physics question: Do we want to always cut inside this stochastic boundary (cut off some valid region) or always get at least to this boundary (include a little invalid region in the name of making sure we capture all the valid region)? The former is numerically easier (we don't have to go out as far), but perhaps the later in more physical (debatable - lets debate it here a bit)?

Still open, and I would not gate this PR on it: the two width channels now disagree by about two
surfaces (|delta_s| cuts at 0.98978, Eq. 100 at 0.99939).

Please explicitly confirm non of the r-vs-rho and other normalization bugs need to be extended to delta_s. Are we tripple sure this one is being implemented correctly for getting a Jacobian independent psi width (note that this is easily testable by running with Hamada and PEST)?

The feature is a no-op on everything we ship.

Do we want this to be true? If the feature is the future and how we want users to default use the code, then shouldn't we be shipping examples that use it (i.e. psihigh 0.9995 for all DIII-D examples)? The DIII-D like example inherited the dmlim truncation behavior only to make it easy to compare to fortran, but we've moved past that I think (and the bechmark_with_fortran script can easily account for it by defaulting to a dmlim type comparison). @jhalpern30 and @matt-pharr may want to add their two cents.

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

Labels

changed-results Results move or an interface breaks - read before upgrading feature New capability

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants