diff --git a/.claude/agents/physics-verifier.md b/.claude/agents/physics-verifier.md new file mode 100644 index 000000000..b3e9c485c --- /dev/null +++ b/.claude/agents/physics-verifier.md @@ -0,0 +1,73 @@ +--- +name: physics-verifier +description: "Use this agent to adversarially audit an Islands-module (src/Islands/) diff for [VERIFY]-policy violations before it is committed — most importantly during autonomous overnight runs, where a guessed physics coefficient is the single worst failure mode. It hunts for numbers pulled from thin air, literature transcriptions committed as fact, and sign/convention errors against the Islands design docs. It is read-only and adversarial by charter. Invoke it before committing ANY physics-adjacent change in src/Islands/, and always before opening a PR that touches physics.\\n\\n\\nContext: An autonomous run just wired a bootstrap-drive term and is about to commit.\\nuser: \"The GradientDrive term is implemented — about to commit.\"\\nassistant: \"Before committing, let me run the physics-verifier agent to confirm no [VERIFY] coefficient was assigned a value and the half-width/sign conventions match docs/01.\"\\nPhysics-adjacent code before a commit — launch physics-verifier to enforce the [VERIFY] policy.\\n\\n\\n\\nContext: A term transcribes ω̂_D from D21 Eq. B1.\\nuser: \"Added the magnetic drift frequency from the paper.\"\\nassistant: \"I'll use the physics-verifier agent to check that the transcription carries a [CHECKED] tag with the exact Eq./page cite and a skipped benchmark, and was not silently promoted to confirmed.\"\\n" +tools: Read, Grep, Glob, Bash +model: opus +color: red +--- + +You are the physics-verifier for the GPEC `Islands` module — a steady-state +drift-kinetic island/layer solver. Your single job is to **find the guessed +number and the silent transcription** before they enter the repository. You are +read-only and adversarial: assume the diff contains a `[VERIFY]` violation and +try to prove it. A fast-but-wrong physics result is worthless; your review is the +last gate before an autonomous run commits physics-adjacent code. + +## Budget discipline + +Hard cap: **≤25 tool uses, ≤8 minutes**. One concrete deliverable: a verdict +(`PASS` / `BLOCK`) plus a short itemized findings list. The invoking prompt hands +you the diff or file paths — do not go spelunking the whole module. If you cannot +finish in budget, stop and report what you checked and what remains. + +## What you enforce (the [VERIFY] policy — Islands CLAUDE.md, `src/Islands/CLAUDE.md`) + +1. **No guessed coefficients.** Every physics O(1) coefficient, threshold number, + sign, or normalization must be one of: (a) `[CHECKED: source, Eq./p.]` with an + exact citation, (b) `[VERIFY: source]` and *parameterized* (not baked into a + literal) with a failing/skipped benchmark referencing it, or (c) + `[DERIVED: date]` with a derivation under `docs/src/islands/derivations/`. A + bare numeric literal in a physics expression with none of these is a **BLOCK**. +2. **No silent promotion.** A `[VERIFY]`/`[CHECKED]` expression implemented as if + confirmed (hardcoded value, no skipped benchmark, no parameter) is a BLOCK — + even if the value happens to be right. Only a human clears a tag. +3. **No "fix the coefficient to make the test pass."** If a benchmark was made to + pass by editing a physics constant rather than the code, BLOCK and flag it. + +## Convention checks (Islands design docs — `docs/src/islands/design/`) + +- **Island width `w` is a HALF-width**; Ω = 2x²/w² − cos ξ; O-point Ω = −1, + separatrix Ω = +1 (docs/01 §1, CLAUDE.md). Flag any full-width/half-width + confusion, especially in threshold numbers (report always with the gyroradius + unit stated; ρ_bi = ε^{1/2}ρ_θi). +- **Frames**: every ω sign convention must live in `src/Islands/frames/` and + nowhere else (docs/01 §5, CLAUDE.md). A sign convention or frame conversion + outside the frames module is a finding. The polarization-current sign depends + on frame — check ω − ω_E vs ω_E vs ω₀ = −ω_E usage against docs/01 §5. +- **No regime branches** in operator physics code (`if collisional … else banana`) + — allowed only in `verify/` and preconditioners (CLAUDE.md). +- Cross-check any transcribed equation against the cited source in the reference + library (`docs/src/islands/design/08-reference-library.md`) — the published + Imada 2019 set has documented errata (L23 §2.6); a term matching I19 Eq. (A.1) + *as printed* rather than the L23-amended form is a finding. + +## Method + +1. `git diff` (or read the named files) — enumerate every physics-adjacent + change: coefficients, signs, normalizations, transcribed equations, thresholds. +2. For each, ask: is it tagged? parameterized? cited to an exact Eq./page? backed + by a skipped benchmark if `[VERIFY]`? Does it match the source and the + half-width/frame/sign conventions? +3. Grep the diff for bare float literals inside physics expressions and for + `[VERIFY]`/`[CHECKED]` tags whose companion benchmark is missing. + +## Output + +- **Verdict**: `PASS` or `BLOCK`. +- **Findings**: each as `file:line — — + ` (e.g. "parameterize + add skipped benchmark", "add [CHECKED] cite", + "write a QUESTIONS.md entry and escalate"). Rank most-severe first. +- If BLOCK, state plainly that the change must not be committed until the flagged + coefficient is escalated to `docs/src/islands/QUESTIONS.md` (never guessed). + +You never edit code. You never clear a `[VERIFY]` tag. You find the error. diff --git a/.claude/hooks/guard-bash.sh b/.claude/hooks/guard-bash.sh new file mode 100755 index 000000000..19bc2a6b0 --- /dev/null +++ b/.claude/hooks/guard-bash.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# PreToolUse(Bash) guard for unattended Islands runs. +# Reads the hook JSON on stdin; `exit 2` blocks the tool call and returns the +# stderr text to Claude as the denial reason. `exit 0` allows it. +# Defense-in-depth under `dontAsk` (permissions.deny is the first layer; this is +# the second, since glob deny-rules are coarse). +set -euo pipefail + +payload="$(cat)" +cmd="$(printf '%s' "$payload" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("tool_input",{}).get("command",""))' 2>/dev/null || true)" + +# Never force-push. +if printf '%s' "$cmd" | grep -Eq 'git[[:space:]]+push([[:space:]]|.)*(--force|--force-with-lease|[[:space:]]-f([[:space:]]|$))'; then + echo "BLOCKED: force-push is not allowed in unattended runs." >&2 + exit 2 +fi + +# Never push to protected branches — pushes go only to feature/islands*. +if printf '%s' "$cmd" | grep -Eq 'git[[:space:]]+push'; then + if printf '%s' "$cmd" | grep -Eq '(^|[[:space:]/])(main|develop)([[:space:]]|$|:)'; then + echo "BLOCKED: push to main/develop is not allowed; push only to feature/islands*." >&2 + exit 2 + fi +fi + +# Refuse obviously destructive filesystem ops on absolute/home roots. +if printf '%s' "$cmd" | grep -Eq 'rm[[:space:]]+(-[a-zA-Z]*r[a-zA-Z]*[[:space:]]+)(/|~|\$HOME|/mnt/homes)'; then + echo "BLOCKED: refusing recursive rm on an absolute/home path." >&2 + exit 2 +fi + +exit 0 diff --git a/.claude/hooks/stop-check.sh b/.claude/hooks/stop-check.sh new file mode 100755 index 000000000..14b4b88b7 --- /dev/null +++ b/.claude/hooks/stop-check.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Stop hook for unattended Islands runs. +# `exit 2` blocks the session from ending (Claude keeps working, sees stderr); +# `exit 0` lets it stop. Purpose: never end a night with a dirty tree or a +# broken build. +set -uo pipefail + +payload="$(cat)" + +# Avoid an infinite loop: if we are already continuing because of this hook, +# let the session stop. +active="$(printf '%s' "$payload" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("stop_hook_active", False))' 2>/dev/null || echo False)" +if [ "$active" = "True" ]; then + exit 0 +fi + +root="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || echo .)}" +cd "$root" 2>/dev/null || exit 0 + +# 1. Working tree must be clean (commit granularly, per the protocol). +if [ -n "$(git status --porcelain 2>/dev/null)" ]; then + echo "BLOCKED stop: uncommitted changes present. Commit (to feature/islands) or stash before ending; then write a LOG.md entry." >&2 + exit 2 +fi + +# 2. Build/tests must pass. Requires julia on PATH (see QUESTIONS.md Q1); if +# julia is absent the check is skipped with a warning rather than blocking. +# Run julia with LD_LIBRARY_PATH stripped: a loaded OMFIT module leaks the conda +# env's libs onto the path, shadowing Julia's bundled artifacts (CHOLMOD, glib) +# and giving false "broken build" blocks. Julia uses its own RPATH, so unsetting +# is a no-op on a clean shell / CI. +if command -v julia >/dev/null 2>&1; then + # Once M1 lands test/runtests_islands_*.jl, prefer running just those: + # julia --project=. test/runtests.jl test/runtests_islands_grids.jl ... + # Until then, a package-load smoke check. + if ! env -u LD_LIBRARY_PATH julia --project=. -e 'using GeneralizedPerturbedEquilibrium' >/dev/null 2>&1; then + echo "BLOCKED stop: package fails to load (using GeneralizedPerturbedEquilibrium errored)." >&2 + exit 2 + fi +else + echo "WARN: julia not on PATH — skipping the build check in the Stop hook (QUESTIONS.md Q1)." >&2 +fi + +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..57ec10fdb --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,65 @@ +{ + "$comment": "Project-scoped Claude Code settings for GPEC. The permissions.allow list is sized for the Islands overnight autonomous loop run under --permission-mode dontAsk (anything not allowed is auto-denied, no prompt). permissions.deny + the PreToolUse hook are defense-in-depth against destructive git/filesystem ops. The Stop hook keeps an unattended session from ending with a dirty tree or a broken build. DRY-RUN THESE ONCE, SUPERVISED, before trusting an unattended run (design doc docs/src/islands/design/06 §2.3).", + "permissions": { + "allow": [ + "Read", + "Grep", + "Glob", + "Edit", + "Write", + "NotebookEdit", + "TodoWrite", + "Task", + "Bash(julia:*)", + "Bash(git status:*)", + "Bash(git diff:*)", + "Bash(git log:*)", + "Bash(git show:*)", + "Bash(git add:*)", + "Bash(git commit:*)", + "Bash(git branch:*)", + "Bash(git checkout:*)", + "Bash(git switch:*)", + "Bash(git stash:*)", + "Bash(git pull --ff-only:*)", + "Bash(git push origin feature/islands:*)", + "Bash(gh pr:*)", + "Bash(gh run:*)", + "Bash(gh api:*)", + "Bash(ls:*)", + "Bash(cat:*)", + "Bash(mkdir:*)", + "Bash(cp:*)", + "Bash(mv:*)" + ], + "deny": [ + "Bash(git push --force:*)", + "Bash(git push -f:*)", + "Bash(git push origin main:*)", + "Bash(git push origin develop:*)", + "Bash(git push* main)", + "Bash(git push* develop)", + "Read(./.env)", + "Read(~/.ssh/**)", + "Read(~/.claude.json)", + "Read(~/.config/gh/**)" + ] + }, + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/guard-bash.sh\"" } + ] + } + ], + "Stop": [ + { + "hooks": [ + { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/stop-check.sh\"" } + ] + } + ] + } +} diff --git a/.gitignore b/.gitignore index 7e1ff4f6b..d7121fb4d 100644 --- a/.gitignore +++ b/.gitignore @@ -14,8 +14,10 @@ *.DS_Store *.pdf !docs/resources/*.pdf +!docs/resources/**/*.pdf *.png !docs/src/assets/**/*.png +!docs/src/islands/figures/*.png *.jld2 .gitattributes Manifest.toml diff --git a/CLAUDE.md b/CLAUDE.md index da32ed045..38e7c1137 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -327,6 +327,15 @@ This workflow is reflected in the modular structure and data flow. GPEC consists of **seven main modules** organized in `src/`: +**Module naming convention**: `src/` module names are simple, intuitive descriptions +of what the module *is* (`Vacuum`, `Equilibrium`, `ForceFreeStates`, `KineticForces`, +`Islands`) — CamelCase, matching the directory. Do **not** give a feature a fancy +standalone-sounding acronym or codename as if it were an independent code needing its +own name recognition (e.g. no `ISLET`, `PENTRC`-style names for new modules). The +domain the module addresses is the name. Working titles/acronyms from a paper or design +doc are dropped when the module lands in `src/`. This keeps the codebase legible as one +integrated tool rather than a bag of separately-branded programs. + #### Foundation Modules 1. **Splines** (`src/Splines/`) - Numerical interpolation library diff --git a/Project.toml b/Project.toml index fd2c512d4..3d5381551 100644 --- a/Project.toml +++ b/Project.toml @@ -14,9 +14,11 @@ DoubleFloats = "497a8b3b-efae-58df-a0af-a86822472b78" FFTW = "7a1cc6ca-52ef-59f5-83cd-3a7055c09341" FastGaussQuadrature = "442a2c76-b920-505d-bb47-c5924d526838" FastInterpolations = "9ea80cae-fc13-4c00-8066-6eaedb12f34b" +ForwardDiff = "f6369f11-7733-5829-9624-2563aa707210" HDF5 = "f67ccb44-e63f-5c2f-98bd-6dc0ccc4ba2f" IMASdd = "c5a45a97-b3f9-491c-b9a7-aa88c3bc0067" JLD2 = "033835bb-8acc-5ee8-8aae-3f567f8a3819" +Krylov = "ba0b0d4f-ebba-5204-a429-3ac8c609bfb7" LaTeXStrings = "b964fa9f-0449-5b57-a5c2-d3ea65f4040f" LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" OrdinaryDiffEq = "1dea7af3-3e70-54e6-95c3-0bf5283fa5ed" @@ -43,9 +45,11 @@ DoubleFloats = "1.6.2" FFTW = "1.9.0" FastGaussQuadrature = "1.1.0" FastInterpolations = "0.4.10" +ForwardDiff = "1.4.1" HDF5 = "0.17.2" IMASdd = "8" JLD2 = "0.6.3" +Krylov = "0.10.8" LaTeXStrings = "1.4.0" LinearAlgebra = "1" OrdinaryDiffEq = "6.102.0" diff --git a/benchmarks/islands/README.md b/benchmarks/islands/README.md new file mode 100644 index 000000000..2e8e8e6fc --- /dev/null +++ b/benchmarks/islands/README.md @@ -0,0 +1,30 @@ +# Islands benchmark ladder (docs/05) + +Benchmark scripts for the Islands verification ladder +(`docs/src/islands/design/05-verification.md`). Each script names its ladder +ID, configuration, target (with source cites), and status. + +**Status policy (the `[VERIFY]` discipline, `src/Islands/CLAUDE.md`):** every +physics benchmark whose target or input coefficients are `[VERIFY]`/uncleared- +`[CHECKED]` ships **skipped** — the script states exactly which +`docs/src/islands/QUESTIONS.md` entries gate it and exits without running. +Un-skipping a benchmark requires the human clearances it names; silently +filling in a coefficient to make one run is the failure mode this project +exists to prevent. The structural A-ladder (A1–A8) runs in CI via +`test/runtests_islands_*.jl`, not here. + +**Targets are tiered (Decision D9, docs/05 "Target tiers").** The primary +literature-facing gates are **scalings, trends, existence, and internal +differentials** (T1/T2/T3); **absolute literature numbers (T4) are audit-gated** +— never pass/fail without a published input manifest and sensitivity scan, and +downgraded to a trend where the source is under-specified. Each script's header +labels its targets by tier. + +Figure scripts (docs/07 pipeline) live in `figures/` and read archived +benchmark data only. + +| Script | Ladder ID | Primary tier(s) | Status | +|---|---|---|---| +| `benchmark_B2_large_w_limits.jl` | B2 | T3 scalings (1/w, 1/w³); coeff T4 | SKIPPED — gated on Q2/Q3/Q4 | +| `benchmark_B4_polarization_omegaE.jl` | B4 | T3 ω_E² + reversal existence; location T4 | SKIPPED — gated on Q2/Q3 | +| `benchmark_B5_york_thresholds.jl` | B5a/b/c | T2 toggle ratio + T3 existence/trend; absolutes T4 | SKIPPED — gated on Q2/Q3/Q4 | diff --git a/benchmarks/islands/benchmark_B2_large_w_limits.jl b/benchmarks/islands/benchmark_B2_large_w_limits.jl new file mode 100644 index 000000000..92702271e --- /dev/null +++ b/benchmarks/islands/benchmark_B2_large_w_limits.jl @@ -0,0 +1,46 @@ +# benchmark_B2_large_w_limits.jl — ladder B2 (docs/05 §B) +# +# Targets are TIERED (Decision D9; docs/05 "Target tiers"): +# PRIMARY (T3): the large-w SCALINGS Delta_bs + Delta_cur ~ 1/w and +# Delta_pol ~ 1/w^3, plus the parametric trend +# eps^(1/2) (L_q/L_p) (beta_theta/w). Checked by fitting the +# exponent over a w sweep — reproducible without absolute inputs. +# AUDIT-GATED (T4): the 1/w COEFFICIENT vs WCHH96 Eq. (85) mapped to the +# island frame (Diss19 p. 86 frame caveat) [CHECKED: Diss19 +# pp. 84-86; D21 Fig. 8-class curves]. Report only with an input +# manifest + sensitivity scan (docs/05 reporting rules 6-8). +# +# WCHH96 is now in the reference library (docs/08): Wilson, Connor, Hastie & +# Hegna, Phys. Plasmas 3, 248 (1996). +# +# STATUS: SKIPPED. Wired to the M2c assembly (Islands.Configure.configure_level0) +# the same way as benchmark_B5 — un-skip is the ONE-LINE `const UNGATED = true`. +# Gated per docs/src/islands/QUESTIONS.md: +# Q5 the remaining L0 coefficient families + the QN (x-h) field source are +# uncleared, so configure_level0 runs only STRUCTURALLY (no physics Δ(w)) +# Q3/Q4 the Δ moment prefactors are now CLEARED (M2b); the electron-closure +# constants and ψ̃ [VERIFY] are folded into the Q5 lane + +using GeneralizedPerturbedEquilibrium +const Isl = GeneralizedPerturbedEquilibrium.Islands + +""" +Flip to `true` only when QUESTIONS Q5 clears the remaining L0 coefficient families. +""" +const UNGATED = false + +# The large-w Δ(w) sweep would assemble one configure_level0 per island width w +# (varying w_psi in Level0Physics) and fit the 1/w, 1/w^3 exponents (T3). GATED: +# a physics Δ(w) needs the Q5-cleared far field + QN source (see benchmark_B5). +function run_b2() + error("B2 large-w scalings are gated on QUESTIONS Q5 (physics Δ(w) unavailable).") +end + +if UNGATED + run_b2() +else + println("SKIPPED: B2 large-w limits — gated on QUESTIONS Q5 (Δ moment prefactors CLEARED).") + println(" Scaffold wired to configure_level0; un-skip = `const UNGATED = true`.") + println(" Primary tier when un-gated: the 1/w and 1/w^3 SCALINGS (T3, fit the") + println(" exponent). The WCHH96 Eq. 85 coefficient is T4 (audit-gated).") +end diff --git a/benchmarks/islands/benchmark_B4_polarization_omegaE.jl b/benchmarks/islands/benchmark_B4_polarization_omegaE.jl new file mode 100644 index 000000000..ebb7568fd --- /dev/null +++ b/benchmarks/islands/benchmark_B4_polarization_omegaE.jl @@ -0,0 +1,46 @@ +# benchmark_B4_polarization_omegaE.jl — ladder B4 (docs/05 §B) +# +# Targets are TIERED (Decision D9; docs/05 "Target tiers"): +# PRIMARY (T3): (i) the Wilson-Connor collisionless and Smolyakov collisional +# SCALINGS; (ii) Delta_pol ~ omega_E^2 away from zero with a sign +# reversal EXISTING at an omega_E of order -omega_dia,e, reversal +# location insensitive to w/rho_theta_i; (iii) torque-balance +# roots (Delta_sin = 0) EXISTING at discrete omega_hat_E. +# AUDIT-GATED (T4): the reversal LOCATION (sources: ~ -0.89 omega_dia,e) and +# the root VALUES (sources: +/-0.93, +/-1.28 omega_dia,e) +# [CHECKED: D23b Fig. 8; Diss19 Fig. 4.18]. +# +# STATUS: SKIPPED. Wired to the M2c assembly (Islands.Configure.configure_level0) +# the same way as benchmark_B5 — un-skip is the ONE-LINE `const UNGATED = true`. +# Gated per docs/src/islands/QUESTIONS.md: +# Q3 the frame-convention signs (src/Islands/frames/) are NaN-gated until +# cleared — the omega_E-dependence of Delta_pol is exactly the physics the +# frames module must own before any sign-bearing benchmark runs +# Q5 the L0 assembly's gradient-drive (which carries the omega_E frame shift) +# and the QN (x-h) source are uncleared, so configure_level0 runs only +# STRUCTURALLY (no physics Delta_pol(omega_E)) + +using GeneralizedPerturbedEquilibrium +const Isl = GeneralizedPerturbedEquilibrium.Islands + +""" +Flip to `true` only when QUESTIONS Q3 (frame signs) and Q5 clear. +""" +const UNGATED = false + +# The Delta_pol(omega_E) scan would assemble configure_level0 across an omega_E +# grid (through the gradient-drive frame shift) and check the omega_E^2 scaling + +# reversal/root existence (T3). GATED: needs the cleared frames convention (Q3) +# and the Q5 gradient-drive/QN-source (see benchmark_B5). +function run_b4() + error("B4 polarization structure is gated on QUESTIONS Q3 (frame signs) and Q5.") +end + +if UNGATED + run_b4() +else + println("SKIPPED: B4 polarization omega_E structure — gated on QUESTIONS Q3, Q5.") + println(" Scaffold wired to configure_level0; un-skip = `const UNGATED = true`.") + println(" Primary tier when un-gated: omega_E^2 scaling + reversal/root EXISTENCE") + println(" (T3). The -0.89 reversal location and root values are T4 (audit-gated).") +end diff --git a/benchmarks/islands/benchmark_B5_york_thresholds.jl b/benchmarks/islands/benchmark_B5_york_thresholds.jl new file mode 100644 index 000000000..1c1077a5c --- /dev/null +++ b/benchmarks/islands/benchmark_B5_york_thresholds.jl @@ -0,0 +1,83 @@ +# benchmark_B5_york_thresholds.jl — ladder B5a/B5b/B5c (docs/05 §B) +# +# Targets are TIERED by reproducibility (Decision D9; docs/05 "Target tiers"): +# +# PRIMARY (run these first — reproducible without a full input manifest): +# B5b/E1 T2 the :original -> :improved drift-model TOGGLE DIFFERENTIAL — +# a ~x6 reduction in w_c in an otherwise identical Islands +# configuration (the robust form of the sources' "8.73 -> 1.46 +# rho_bi" story; shared input uncertainty cancels in the ratio). +# B5a T3 threshold EXISTENCE at w_c ~ O(rho_theta_i), :original model. +# B5c T3 the v_star TREND dw_c/dv_star > 0 (roughly linear over +# v_star in [5,20]e-3) and w_c proportional to rho_hat_theta_i. +# +# AUDIT-GATED (T4 — absolute numbers, NOT pass/fail without an input manifest; +# see docs/src/islands/design/09-input-manifests.md): +# B5a w_c ~= 2.76 rho_theta_i (half) = 8.73 rho_bi at eps=0.1 [CHECKED: I19 Fig. 9] +# NB: I19 is internally inconsistent on its own run collisionality +# (S4.2 v_star=0.01 vs L23 p.82 v_star=1e-3) — the type specimen of +# why absolute matches need the input-completeness audit first (docs/09). +# B5b w_c ~= 0.45 rho_theta_i = 1.46 rho_bi half-width [CHECKED: D21/D23a] +# B5c w_c ~= 0.440 rho_hat_theta_i + 0.0178 v_star - 7.54e-5 [CHECKED: L23 Eq. 6.3.2] +# (best T4 candidate — a thesis documents its numerics most fully) +# +# STATUS: SKIPPED. The scaffold below is wired to the M2c Level-0 assembly +# (`Islands.Configure.configure_level0`): un-skipping is the ONE-LINE change +# `const UNGATED = true`. It stays gated because the assembly's remaining +# coefficient families are uncleared — parallel streaming, E×B, gradient drive, +# the quasineutrality (x-h) field source, the collision magnitude, the +# orbit-averaged pitch measure, and the neoclassical far field (QUESTIONS Q5). +# Until Q5 clears, `configure_level0` runs only STRUCTURALLY (placeholders), so +# no physics w_c can be extracted; flipping UNGATED before Q5 clears asserts-out. + +using GeneralizedPerturbedEquilibrium +const Isl = GeneralizedPerturbedEquilibrium.Islands + +""" +Flip to `true` only when QUESTIONS Q5 clears the remaining L0 coefficient families. +""" +const UNGATED = false + +# The B5 physics parameter set (eps=0.1, m/n=2/1, tau=1; docs/09 I19/D21 manifest). +_b5_phys(variant) = Isl.Configure.Level0Physics(; epsilon=0.1, inv_Lq=1.0, inv_LB=1.0, + q_s=2.0, dq_dpsi=0.5, w_psi=0.05, mu0_R=1.0, inv_Ln0=1.0, rho_hat_theta_i=0.05, + eta_i=1.0, tau=1.0, variant=variant) + +# Assemble the :original and :improved configurations that the T2 toggle compares. +# Structurally valid today; a *physics* w_c needs the cleared gated inputs (Q5). +function _assemble_b5(variant) + grid = Isl.PhaseSpace.IslandGrid(; nx=41, nxi=16, ny=17, nE=6, halfwidth_x=8.0, + clustering_x=1.2, y_max=1.2, y_c=1.0, clustering_y=0.8, order=4) + species = [Isl.SpeciesLists.Species(; name=:i, Z=1.0, m=1.0, + background=Isl.SpeciesLists.Maxwellian(; n=1.0, T=1.0), role=Isl.SpeciesLists.Bulk)] + gated = Isl.Configure.level0_placeholders(grid) # PLACEHOLDER — not physics (Q5) + return Isl.Configure.configure_level0(grid, _b5_phys(variant), species; gated=gated) +end + +# threshold_width(cfg) — the marginal-island w_c from the MRE root dw/dt=0. GATED: +# needs the cleared far field + the (x-h) QN source to produce a physical Δ_neo(w) +# (QUESTIONS Q5). Placeholder assembly cannot yield a physics threshold. +function threshold_width(_cfg) + error("threshold_width is gated on QUESTIONS Q5 (cleared far field + QN source).") +end + +function run_b5() + # T2 PRIMARY — the :original/:improved toggle differential (E1). + w_orig = threshold_width(_assemble_b5(:original)) + w_impr = threshold_width(_assemble_b5(:improved)) + ratio = w_orig / w_impr + println("B5b/E1 (T2) toggle differential: w_c(:original)/w_c(:improved) = ", ratio) + println(" expect a ~x6 reduction (sources' 8.73 -> 1.46 rho_bi story).") + # T3 PRIMARY — existence + v_star trend would follow here, over the Q5-cleared solve. + return ratio +end + +if UNGATED + run_b5() +else + println("SKIPPED: B5a/B5b/B5c — gated on QUESTIONS Q5 (and Q2/Q3/Q4 clearances).") + println(" Scaffold wired to Islands.Configure.configure_level0; un-skip = `const UNGATED = true`.") + println(" Primary tier when un-gated: B5b/E1 toggle differential (T2), threshold") + println(" existence (T3), dw_c/dv_star trend (T3). Absolute w_c values are T4") + println(" (audit-gated; docs/09 manifests + docs/05 'Target tiers').") +end diff --git a/benchmarks/islands/figures/README.md b/benchmarks/islands/figures/README.md new file mode 100644 index 000000000..f0b2ad835 --- /dev/null +++ b/benchmarks/islands/figures/README.md @@ -0,0 +1,9 @@ +# Islands figure pipeline (docs/07 §2) + +Pinned figure scripts reading **archived benchmark data only** — the same +script feeds CI artifacts, the state gallery, and paper panels. A figure that +cannot be regenerated from archived data is a release-blocking bug. + +Empty until the first B-ladder benchmark is un-skipped (gated on +`docs/src/islands/QUESTIONS.md` Q2–Q4); the Paper-I figure contract is +`docs/src/islands/papers/paper-1/OUTLINE.md`. diff --git a/benchmarks/islands/figures/make_structural_figures.jl b/benchmarks/islands/figures/make_structural_figures.jl new file mode 100644 index 000000000..448dfbbe9 --- /dev/null +++ b/benchmarks/islands/figures/make_structural_figures.jl @@ -0,0 +1,151 @@ +# make_structural_figures.jl — pinned figure script (design docs/07 §2) +# +# Regenerates the structural (A-ladder) figures embedded in +# docs/src/islands/numerics.md from the verification machinery itself — no +# archived data needed yet because everything here is deterministic M1/M2 +# structure (manufactured coefficients, no physics). Physics (B-ladder) figures +# will read archived benchmark data once QUESTIONS Q2–Q4 clear. +# +# Usage (repo root): +# env -u LD_LIBRARY_PATH julia --project=. benchmarks/islands/figures/make_structural_figures.jl +# +# Writes PNGs into docs/src/islands/figures/ (committed as docs assets so the +# Documenter CI build does not need to run this script). + +ENV["GKSwstype"] = "100" # headless GR + +using Plots +using LinearAlgebra +using GeneralizedPerturbedEquilibrium +const Isl = GeneralizedPerturbedEquilibrium.Islands +const PS = Isl.PhaseSpace +const Op = Isl.Operators +const So = Isl.Solvers +const V = Isl.Verify +const Fi = Isl.Fields + +outdir = normpath(joinpath(@__DIR__, "..", "..", "..", "docs", "src", "islands", "figures")) +mkpath(outdir) +saved = String[] +function save!(p, name) + path = joinpath(outdir, name) + savefig(p, path) + push!(saved, path) + return path +end + +# --------------------------------------------------------------------------- +# F1 — layer-clustered grids: the sinh maps and node placement (04 §1) +# --------------------------------------------------------------------------- +let + gx = PS.MappedFDGrid(33; halfwidth=6.0, clustering=2.0, order=4) + gy = PS.MappedFDGrid(25; halfwidth=4.0, clustering=2.0, center=1.0, domain=:half, order=4) + s = range(-1, 1; length=33) + p1 = plot(s, gx.nodes; lw=2, xlabel="computational coordinate s", ylabel="x", + label="x(s), β=2", legend=:topleft, title="radial map (packs x = 0)") + scatter!(p1, s, gx.nodes; ms=3, label="nodes") + p2 = plot(; xlabel="node index", ylabel="y", title="pitch grid (packs y_c = 1)", legend=:topleft) + scatter!(p2, 1:gy.n, gy.nodes; ms=4, label="y nodes, β=2") + hline!(p2, [1.0]; ls=:dash, lc=:red, label="y_c (trapped–passing)") + p = plot(p1, p2; layout=(1, 2), size=(950, 380), left_margin=12Plots.mm, bottom_margin=6Plots.mm) + save!(p, "grids_clustering.png") +end + +# --------------------------------------------------------------------------- +# F2 — verification ladder A1: MMS convergence, operators + assembled + solve +# --------------------------------------------------------------------------- +let + mkg(n) = PS.IslandGrid(; nx=n, nxi=8, ny=n, nE=3, halfwidth_x=6.0, clustering_x=1.0, + y_max=4.0, y_c=1.0, clustering_y=0.8, order=4) + ns = [9, 17, 33] + p = plot(; xscale=:log10, yscale=:log10, xlabel="radial/pitch resolution n", + ylabel="max error", legend=:bottomleft, title="MMS convergence (ladder A1)", + size=(700, 480), left_margin=12Plots.mm, bottom_margin=6Plots.mm) + for (term, lab) in ((:streaming, "streaming"), (:exb, "E×B bracket"), + (:collisions, "collisions"), (:perp, "⊥ transport")) + errs = [V.mms_operator_error(mkg(n), term) for n in ns] + plot!(p, ns, errs; marker=:circle, lw=2, label=lab) + end + errs = [V.mms_assembled_error(mkg(n)) for n in ns] + plot!(p, ns, errs; marker=:square, lw=3, lc=:black, label="assembled residual") + solve_errs = [V.solve_mms(n).err for n in (9, 17, 33)] + plot!(p, [9, 17, 33], solve_errs; marker=:diamond, lw=3, ls=:dash, label="converged solve (A1-solve)") + guide = errs[1] .* (ns[1] ./ ns) .^ 4 + plot!(p, ns, guide; ls=:dot, lc=:gray, label="4th order") + save!(p, "mms_convergence.png") +end + +# --------------------------------------------------------------------------- +# F3 — the flattened-electron geometry functions and the A7 identity (01 §2.4) +# --------------------------------------------------------------------------- +let + Ωout = 1.02:0.02:5.0 + Ωin = -0.98:0.02:0.98 + Q = [Fi.Q_omega(Ω) for Ω in Ωout] + Qin = [Fi.Q_omega(Ω) for Ω in Ωin] + h = [Fi.h_profile(Ω; prefactor=1.0) for Ω in Ωout] + p1 = plot(Ωin, Qin; lw=2, label="Q(Ω), inside", xlabel="Ω", ylabel="Q", + title="Q(Ω) = (1/2π)∮√(Ω+cos ξ) dξ", legend=:topleft) + plot!(p1, Ωout, Q; lw=2, label="Q(Ω), outside") + vline!(p1, [1.0]; ls=:dash, lc=:red, label="separatrix") + a7 = [abs(Fi.flat_average_d2h_dx2(Ω, 1.0)) for Ω in (1.2, 2.0, 3.0, 5.0)] + p2 = plot(Ωout, h; lw=2, label="h(Ω) (unit prefactor)", xlabel="Ω", ylabel="h", + title="electron profile h(Ω)", legend=:topleft) + vline!(p2, [1.0]; ls=:dash, lc=:red, label="separatrix (h ≡ 0 inside)") + annotate!(p2, 1.6, 0.35, text("A7: max |⟨∂²h/∂x²⟩_Ω| = $(round(maximum(a7); sigdigits=2))", 9, :left)) + p = plot(p1, p2; layout=(1, 2), size=(950, 380), left_margin=12Plots.mm, bottom_margin=6Plots.mm, right_margin=6Plots.mm) + save!(p, "hQ_profiles.png") +end + +# --------------------------------------------------------------------------- +# F4 — preconditioner quality (04 §5): GMRES iterations with/without YBlockJacobi +# --------------------------------------------------------------------------- +let + g = PS.IslandGrid(; nx=9, nxi=8, ny=9, nE=2, halfwidth_x=6.0, clustering_x=1.0, + y_max=4.0, y_c=1.0, clustering_y=0.8, order=4) + nx, nξ, ny, nE, nσ = PS.nnodes(g) + P = @. g.y.nodes * (4.0 - g.y.nodes) + K, = Op.conservative_pitch_operator(g.y, P, ones(ny)) + cstiff = fill(30.0, nx, nξ, nE, nσ) + shift = fill(-1.0, nx, nξ, ny, nE, nσ) + stack = Op.IslandStack((Op.PitchAngleDiffusion(K, cstiff), Op.RadiationSink(shift)), + Op.Quasineutrality(1.3)) + f0! = So.flat_residual(stack, g) + N = Op.statelength(g) + b = sin.((1:N) ./ 7) + f!(out, u) = (f0!(out, u); out .-= b; out) + pc = So.YBlockJacobi(g, (ix, iξ, iE, iσ) -> I(ny) + cstiff[ix, iξ, iE, iσ] .* K; phi_scale=-1.3) + s0 = So.newton_krylov(f!, zeros(N); rtol=1e-10, memory=300) + s1 = So.newton_krylov(f!, zeros(N); rtol=1e-10, memory=300, precond=pc) + p = bar(["unpreconditioned", "y-block Jacobi (TSVD)"], [s0.gmres_iters, s1.gmres_iters]; + ylabel="total GMRES iterations", legend=false, + title="preconditioner: stiff collisional solve", + ylims=(0, 1.25 * s0.gmres_iters), + size=(600, 430), left_margin=12Plots.mm, bottom_margin=6Plots.mm, top_margin=4Plots.mm) + annotate!(p, [(1, s0.gmres_iters + 0.06 * s0.gmres_iters, text("$(s0.gmres_iters)", 10)), + (2, s1.gmres_iters + 0.06 * s0.gmres_iters, text("$(s1.gmres_iters)", 10))]) + save!(p, "preconditioner_gmres.png") +end + +# --------------------------------------------------------------------------- +# F5 — pseudo-arclength continuation around the toy fold (03 §3) +# --------------------------------------------------------------------------- +let + ftoy!(out, u, p) = (out[1] = u[1]^2 + p; out) + pa = So.pseudo_arclength(ftoy!, [1.0], -1.0; ds=0.15, nsteps=30, rtol=1e-12, atol=1e-12) + us = [z[1] for z in pa.us] + p = plot(pa.ps, us; marker=:circle, lw=2, xlabel="parameter p", ylabel="u", + label="continuation path", legend=:topleft, + title="pseudo-arclength steps around the fold (u² + p = 0)", + size=(650, 430), left_margin=12Plots.mm, bottom_margin=6Plots.mm) + plot!(p, -1.2:0.01:0.0, sqrt.(-(-1.2:0.01:0.0)); ls=:dash, lc=:gray, label="analytic ±√(−p)") + plot!(p, -1.2:0.01:0.0, -sqrt.(-(-1.2:0.01:0.0)); ls=:dash, lc=:gray, label="") + if !isempty(pa.folds) + k = pa.folds[1] + 1 + scatter!(p, [pa.ps[k]], [us[k]]; ms=8, mc=:red, label="fold detected") + end + save!(p, "continuation_fold.png") +end + +println("Saved figures:") +foreach(f -> println(" ", f), saved) diff --git a/docs/make.jl b/docs/make.jl index b62bf121e..6940c2c2d 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -37,11 +37,42 @@ makedocs(; "Forcing Terms" => "forcing_terms.md", "Perturbed Equilibrium" => "perturbed_equilibrium.md", "Inner Layer" => "inner_layer.md", + "Islands" => "islands.md", "Analysis" => "analysis.md", "Utilities" => "utilities.md" - ], + ], + "Islands" => [ + "Overview" => "islands/index.md", + "Numerics (as implemented)" => "islands/numerics.md", + "State dashboard" => "islands/state/STATE.md", + "Derivations" => [ + "Overview" => "islands/derivations/index.md", + "ψ̃ amplitude" => "islands/derivations/psi-tilde-amplitude.md", + "ω̂_D drift frequency" => "islands/derivations/omega-D-drift-frequency.md", + "Collision operator" => "islands/derivations/collision-operator.md", + "Electron closure" => "islands/derivations/electron-closure.md", + "Quasineutrality closure" => "islands/derivations/quasineutrality-closure.md", + "Δ-moment prefactors" => "islands/derivations/delta-moment-prefactors.md", + "Parallel streaming" => "islands/derivations/parallel-streaming.md", + "Gradient drive" => "islands/derivations/gradient-drive.md", + "Passing fraction" => "islands/derivations/passing-fraction.md" + ], + "Paper I — figure contract" => "islands/papers/paper-1/OUTLINE.md", + "Design documents" => [ + "00 — Roadmap" => "islands/design/00-roadmap.md", + "01 — Level-0 physics" => "islands/design/01-physics-level0.md", + "02 — Species and EPs" => "islands/design/02-species-and-eps.md", + "03 — Architecture" => "islands/design/03-architecture.md", + "04 — Numerics" => "islands/design/04-numerics.md", + "05 — Verification ladder" => "islands/design/05-verification.md", + "06 — Autonomy and tooling" => "islands/design/06-autonomy-and-tooling.md", + "07 — Documentation and papers" => "islands/design/07-documentation-and-papers.md", + "08 — Reference library" => "islands/design/08-reference-library.md", + "09 — Input manifests" => "islands/design/09-input-manifests.md" + ] + ], "Citations" => "citations.md", - "Developer Notes" => "developer_notes.md", + "Developer Notes" => "developer_notes.md" ], checkdocs=:exports ) diff --git a/docs/resources/Drift_Kinetic_Island_References/1996-Wilson-Threshold_for_neoclassical_magnetic_islands_in_a_low_collision_frequency_tokamak.pdf b/docs/resources/Drift_Kinetic_Island_References/1996-Wilson-Threshold_for_neoclassical_magnetic_islands_in_a_low_collision_frequency_tokamak.pdf new file mode 100644 index 000000000..a3d3b2f41 Binary files /dev/null and b/docs/resources/Drift_Kinetic_Island_References/1996-Wilson-Threshold_for_neoclassical_magnetic_islands_in_a_low_collision_frequency_tokamak.pdf differ diff --git a/docs/resources/Drift_Kinetic_Island_References/2018-Dudkovskaia-Island_Stability_in_Phase_Space.pdf b/docs/resources/Drift_Kinetic_Island_References/2018-Dudkovskaia-Island_Stability_in_Phase_Space.pdf new file mode 100644 index 000000000..06148fee4 Binary files /dev/null and b/docs/resources/Drift_Kinetic_Island_References/2018-Dudkovskaia-Island_Stability_in_Phase_Space.pdf differ diff --git a/docs/resources/Drift_Kinetic_Island_References/2018-Imada-Drift_kinetic_response_of_ions_to_magnetic_island_perturbation_and_effects_on_NTM_threshold.pdf b/docs/resources/Drift_Kinetic_Island_References/2018-Imada-Drift_kinetic_response_of_ions_to_magnetic_island_perturbation_and_effects_on_NTM_threshold.pdf new file mode 100644 index 000000000..5259166be Binary files /dev/null and b/docs/resources/Drift_Kinetic_Island_References/2018-Imada-Drift_kinetic_response_of_ions_to_magnetic_island_perturbation_and_effects_on_NTM_threshold.pdf differ diff --git a/docs/resources/Drift_Kinetic_Island_References/2018-Imada-Nonlinear_Kinetic_Ion_Response_to_Small_Scale_Magnetic_Islands_in_Tokamak_Plasmas.pdf b/docs/resources/Drift_Kinetic_Island_References/2018-Imada-Nonlinear_Kinetic_Ion_Response_to_Small_Scale_Magnetic_Islands_in_Tokamak_Plasmas.pdf new file mode 100644 index 000000000..094341bc7 Binary files /dev/null and b/docs/resources/Drift_Kinetic_Island_References/2018-Imada-Nonlinear_Kinetic_Ion_Response_to_Small_Scale_Magnetic_Islands_in_Tokamak_Plasmas.pdf differ diff --git a/docs/resources/Drift_Kinetic_Island_References/2019-Dudkovskaia-Modelling_NTMs_in_tokamak_plasmas_PhD_dissertation.pdf b/docs/resources/Drift_Kinetic_Island_References/2019-Dudkovskaia-Modelling_NTMs_in_tokamak_plasmas_PhD_dissertation.pdf new file mode 100644 index 000000000..fb50a695a Binary files /dev/null and b/docs/resources/Drift_Kinetic_Island_References/2019-Dudkovskaia-Modelling_NTMs_in_tokamak_plasmas_PhD_dissertation.pdf differ diff --git a/docs/resources/Drift_Kinetic_Island_References/2019-Imada-Finite_ion_orbit_width_effect_on_the_neoclassical_tearing_mode_threshold_in_a_tokamak_plasma.pdf b/docs/resources/Drift_Kinetic_Island_References/2019-Imada-Finite_ion_orbit_width_effect_on_the_neoclassical_tearing_mode_threshold_in_a_tokamak_plasma.pdf new file mode 100644 index 000000000..83479cbb8 Binary files /dev/null and b/docs/resources/Drift_Kinetic_Island_References/2019-Imada-Finite_ion_orbit_width_effect_on_the_neoclassical_tearing_mode_threshold_in_a_tokamak_plasma.pdf differ diff --git a/docs/resources/Drift_Kinetic_Island_References/2021-Dudkovskaia-Drift_kinetic_theory_of_neoclassical_tearing_modes_in_a_low_collisionality_tokamak_plasma_magnetic_island_threshold_physics.pdf b/docs/resources/Drift_Kinetic_Island_References/2021-Dudkovskaia-Drift_kinetic_theory_of_neoclassical_tearing_modes_in_a_low_collisionality_tokamak_plasma_magnetic_island_threshold_physics.pdf new file mode 100644 index 000000000..467fc7bef Binary files /dev/null and b/docs/resources/Drift_Kinetic_Island_References/2021-Dudkovskaia-Drift_kinetic_theory_of_neoclassical_tearing_modes_in_a_low_collisionality_tokamak_plasma_magnetic_island_threshold_physics.pdf differ diff --git a/docs/resources/Drift_Kinetic_Island_References/2023-Dudkovskaia-Drift_kinetic_theory_of_neoclassical_tearing_modes_in_tokamak_plasmas_polarisation_current_and_its_effect_on_magnetic_island_threshold_physics.pdf b/docs/resources/Drift_Kinetic_Island_References/2023-Dudkovskaia-Drift_kinetic_theory_of_neoclassical_tearing_modes_in_tokamak_plasmas_polarisation_current_and_its_effect_on_magnetic_island_threshold_physics.pdf new file mode 100644 index 000000000..461072467 Binary files /dev/null and b/docs/resources/Drift_Kinetic_Island_References/2023-Dudkovskaia-Drift_kinetic_theory_of_neoclassical_tearing_modes_in_tokamak_plasmas_polarisation_current_and_its_effect_on_magnetic_island_threshold_physics.pdf differ diff --git a/docs/resources/Drift_Kinetic_Island_References/2023-Dudkovskaia-Drift_kinetic_theory_of_the_NTM_magnetic_islands_in_a_finite_beta_general_geometry_tokamak_plasma.pdf b/docs/resources/Drift_Kinetic_Island_References/2023-Dudkovskaia-Drift_kinetic_theory_of_the_NTM_magnetic_islands_in_a_finite_beta_general_geometry_tokamak_plasma.pdf new file mode 100644 index 000000000..49db982b3 Binary files /dev/null and b/docs/resources/Drift_Kinetic_Island_References/2023-Dudkovskaia-Drift_kinetic_theory_of_the_NTM_magnetic_islands_in_a_finite_beta_general_geometry_tokamak_plasma.pdf differ diff --git a/docs/resources/Drift_Kinetic_Island_References/2023-Leigh-Drift_kinetic_simulations_of_Neoclassical_Tearing_Mode_instabilities_in_finite_collisionality_tokamak_plasmas.pdf b/docs/resources/Drift_Kinetic_Island_References/2023-Leigh-Drift_kinetic_simulations_of_Neoclassical_Tearing_Mode_instabilities_in_finite_collisionality_tokamak_plasmas.pdf new file mode 100644 index 000000000..a7d347163 Binary files /dev/null and b/docs/resources/Drift_Kinetic_Island_References/2023-Leigh-Drift_kinetic_simulations_of_Neoclassical_Tearing_Mode_instabilities_in_finite_collisionality_tokamak_plasmas.pdf differ diff --git a/docs/src/islands.md b/docs/src/islands.md new file mode 100644 index 000000000..2d22e176f --- /dev/null +++ b/docs/src/islands.md @@ -0,0 +1,136 @@ +# Islands Module + +The Islands module is a steady-state, multi-species drift-kinetic solver for the +resonant magnetic island/layer region in tokamaks — the nonlinear analog of +SLAYER, generalizing the Modified Rutherford Equation. It is under active +development; the module conventions live in `src/Islands/CLAUDE.md`. + +This page is the **API reference**. The narrative documentation lives in the +**Islands** section of this site: the [project overview](islands/index.md), +the equations-and-figures chapter of what is +[implemented and verified so far](islands/numerics.md), the +[Paper I figure contract](islands/papers/paper-1/OUTLINE.md), and the full +[design document set](islands/design/00-roadmap.md). + +## Status — M1 skeleton + M2 Level-0 solve machinery (structure, gated physics) + +M1 landed the numerical skeleton and M2 the Level-0 solve machinery — structure +only, no physics numbers (module `CLAUDE.md`, the `[VERIFY]` policy): + + - `Islands.PhaseSpace` — the `(x, ξ, λ→y, E, σ)` phase-space grids with + layer-clustered mappings (design `04 §1`): Fourier spectral `∂ξ`, high-order + finite-difference `∂x`/`∂y` on stretched grids, and Gauss energy quadrature. + Pure numerics; no physics coefficients. + - `Islands.Operators` — the `AbstractTerm` operator stack and residual assembly + (design `03 §2`) as allocation-free, AD-compatible structure, including the + mimetic (exactly conservative) pitch-angle diffusion and the neoclassical- + matching far-field boundary conditions (`01 §3` — never bare Neumann). Every + physics coefficient is a supplied data field, never a literal. + - `Islands.SpeciesLists` — first-class species arrays (`02 §1`, Decision D3): + backgrounds, `Bulk`/`Trace` roles, trace-criteria checks that warn and never + silently degrade. + - `Islands.Frames` — THE frequency/frame conversion module (`01 §5`): the + conversion *forms* with every sign/normalization a NaN-gated + `FrameConvention` field until human-cleared (QUESTIONS Q3). + - `Islands.Fields` — the Level-0 quasineutrality closure structure: `Q(Ω)`, + `h(Ω)` (supplied prefactor), the coefficient-free identity + `⟨∂²h/∂x²⟩_Ω = 0` (ladder A7), and the NaN-gated `ElectronClosure` constants. + - `Islands.Moments` — `J̄_∥` assembly and the `Δ_cos`/`Δ_sin` Ampère + projections (`01 §4`) with **required, gated** prefactors; `Ω`-average and + channel-split diagnostics. + - `Islands.Solvers` — matrix-free Newton–Krylov (ForwardDiff JVP + GMRES, + Eisenstat–Walker forcing), the TSVD-regularized physics-block preconditioner + skeleton (`04 §3, §5`), tiny-grid dense debug Jacobian, and pseudo-arclength + continuation with fold detection. + - `Islands.Configure` — the Level-0 named-configuration assembly (`03 §2`): + wires the cleared `Coefficients.*` builders onto the operator stack (`c_D`, + the pitch-collision shapes, the `Δ` prefactors) and supplies the still-gated + coefficient families (QUESTIONS Q5) as named inputs, producing a runnable + `IslandStack` + far-field BCs + `Δ` prefactors. + - `Islands.Verify` — MMS/JVP harness (ladder A1/A2), solve-level MMS and + zero-drive configurations (A5), and the `y_c`-block conditioning monitor + (A8), exercised by `test/runtests_islands_{grids,operators,solve,configure}.jl`. + +Structural gates **A1–A5, A7 (coefficient-free part), A8** run in CI. The +physics numbers (drift-frequency coefficients, collision kernels, closure +constants, the Δ prefactors, threshold values) remain `[VERIFY]`-gated: the +B-ladder benchmarks in `benchmarks/islands/` ship skipped, each naming the +`QUESTIONS.md` entries (Q2–Q4) whose human clearance un-gates it. The +literature-facing physics gates are **tiered by reproducibility** (Decision D9, +docs/05): scalings, regime trends, and internal differentials are the primary +quantitative checks, and absolute literature numbers are audit-gated (reported +only with input manifests). The Paper-I figure contract is +`docs/src/islands/papers/paper-1/OUTLINE.md`. + +## API Reference + +```@autodocs +Modules = [GeneralizedPerturbedEquilibrium.Islands] +``` + +### Phase-space grids (`Islands.PhaseSpace`) + +```@autodocs +Modules = [GeneralizedPerturbedEquilibrium.Islands.PhaseSpace] +``` + +### Species (`Islands.SpeciesLists`) + +```@autodocs +Modules = [GeneralizedPerturbedEquilibrium.Islands.SpeciesLists] +``` + +### Frames and parameters (`Islands.Frames`) + +```@autodocs +Modules = [GeneralizedPerturbedEquilibrium.Islands.Frames] +``` + +### Operator stack (`Islands.Operators`) + +```@autodocs +Modules = [GeneralizedPerturbedEquilibrium.Islands.Operators] +``` + +### Field-equation closure structure (`Islands.Fields`) + +```@autodocs +Modules = [GeneralizedPerturbedEquilibrium.Islands.Fields] +``` + +### Output moments (`Islands.Moments`) + +```@autodocs +Modules = [GeneralizedPerturbedEquilibrium.Islands.Moments] +``` + +### Cleared physics coefficients (`Islands.Coefficients`) + +Human-cleared Level-0 coefficient builders (the M2b derivation-lane fill-ins; +see the Derivations section). + +```@autodocs +Modules = [GeneralizedPerturbedEquilibrium.Islands.Coefficients] +``` + +### Newton–Krylov solve (`Islands.Solvers`) + +```@autodocs +Modules = [GeneralizedPerturbedEquilibrium.Islands.Solvers] +``` + +### Level-0 configuration assembly (`Islands.Configure`) + +Wires the cleared `Coefficients.*` builders onto the operator stack and supplies +the still-gated coefficient families (QUESTIONS Q5) as named inputs; produces the +runnable `IslandStack` + far-field BCs + `Δ` prefactors. + +```@autodocs +Modules = [GeneralizedPerturbedEquilibrium.Islands.Configure] +``` + +### Verification harness (`Islands.Verify`) + +```@autodocs +Modules = [GeneralizedPerturbedEquilibrium.Islands.Verify] +``` diff --git a/docs/src/islands/LOG.md b/docs/src/islands/LOG.md new file mode 100644 index 000000000..8a5715250 --- /dev/null +++ b/docs/src/islands/LOG.md @@ -0,0 +1,348 @@ +# Islands — session LOG + +Cross-session memory spine (design doc `06 §2.5`). Read this at session start +together with `QUESTIONS.md`. Append a short entry before every session end: +**what moved / what's blocked / next action**. Newest entries at the top. +Reference `QUESTIONS.md` IDs (`Q`) and ladder IDs (`A1`, `B5a`, …) where +relevant. + +--- + +## 2026-07-12 — Q5: clear the gradient drive (far-field BC, no frame convention) + +- **Moved**: completed the gradient drive by re-reading I19 Eq. 29 first-hand. + **Correction**: the earlier draft misread the ratio as `ω_si^T/ω_ci` (⇒ frame + convention); it is `ω_si^T/ω_si = 1+(v̂²−3/2)η_i` — a **temperature factor, not + a frequency ratio**, so **no frame convention is needed**. The drive is the + standard neoclassical `p_φ F'_Mi`, imposed as the **far-field BC** (master eq + homogeneous, I19 Formulation A). Cleared: `Operators.GradientDrive = 0` and + `Configure.gradient_far_field` builds `g_far = x L̂_{n0}⁻¹[1+(E−3/2)η_i]` + (`Φ̂_far = 0`), with a new `Level0Physics.eta_i`. Both `gradient_drive` and + `far_field` moved gated→cleared; `drive`/`bc` dropped from `GatedLevel0Inputs`. + 1511 islands assertions green (the assembly now solves with the *physical* far + field). `gradient-drive.md` signed off; docs/01 §2, QUESTIONS Q5, numerics.md, + index/nav updated. +- **Blocked**: only three kinetic families remain gated (Q5): the `E×B` coupling + `c_E`, the collision magnitude `⟨ν̂_ii⟩_u`, and the orbit-averaged pitch + measure. Plus the deferred `k ≃ −1.173`. +- **Next**: E×B coupling (Poisson-bracket normalization) or the collision + magnitude `⟨ν̂_ii⟩_u` (needs L23 Eq. 4.1.6). With these three, the L0 solve is + fully physical. + +## 2026-07-11 — Q3/Q5: clear the passing fraction f_p + +- **Moved**: signed off `derivations/passing-fraction.md` and cleared + `Coefficients.passing_fraction(ε) = 1 − 1.4624√ε` (the effective + trapped-fraction coefficient, derived + numerically confirmed, = the sources' + quoted 1.46 to 3 s.f.). Authorizes `Fields.ElectronClosure.f_p`. 1499 islands + assertions green (limits + monotonicity + the 1.46 match); docs green. + docs/01 §2.4, QUESTIONS Q3/Q5, derivations index/nav updated. +- **Blocked**: the companion Hirshman–Sigmar `k ≃ −1.173` stays deferred (needs + the parallel-viscosity moment problem). Gradient-drive amplitude + frame + convention still pending (bundled sign-off, `gradient-drive.md`). +- **Next**: the gradient-drive amplitude/frame convention is the last structural + blocker; then E×B, collision magnitude, pitch measure. `k` its own derivation. + +## 2026-07-11 — Q5: gradient-drive structural finding (drive = far-field BC) + +- **Moved**: read I19 first-hand (Eqs. 8, 23–32) to derive the gradient drive. + **Finding** (`derivations/gradient-drive.md`, draft): the master equation + (I19 Eq. 32) is **homogeneous** — no interior source; the drive is the + **far-field boundary condition** `Ḡ₀ → p_φ(ω_si^T/ω_ci)(n'/n)F_Mi` (Eq. 29). + So `Operators.GradientDrive = 0` at Level 0, and the Q5 `gradient_drive` and + `far_field` items **merge** into one object — the diamagnetic far field + `g_drive = D_dia·x·[1+(v̂²−3/2)η_i]·F_Mi`. The `x`-linearity, temperature + correction, and Maxwellian are cleared structure; the **normalized amplitude + `D_dia` bundles the frame convention** `Frames.C_dia` (NaN-gated) — clearing + the drive = clearing `C_dia` (ion `ω_dia` normalization). Nothing entered + `src/` (draft, docs-only). QUESTIONS Q5 + derivations index/nav updated. +- **Blocked**: `D_dia` + the frame convention `C_dia`/`sign_omega0`/ + `C_gradient_shift` (Q3) — a bundled human sign-off; the normalized ion + diamagnetic amplitude needs the careful `ω_si/ω_ci` normalization algebra. +- **Next**: complete `D_dia` (ion `ω_dia` in the code normalization) + sign off + the frame convention, then wire `g_far` and set `GradientDrive = 0`. This is + the last structural blocker for a real `g` to develop. + +## 2026-07-11 — Q5: clear the parallel (island) streaming coefficients + +- **Moved**: re-derived the island-streaming channel from the master DKE + (I19 Eq. 32) — `derivations/parallel-streaming.md`, **human sign-off**. Key + result: the two coefficients factor **exactly** into `{Ω, g}` flux-surface + advection, `a_ξ = (L̂_q⁻¹/ρ̂_θi)x Θ`, `a_x = −(L̂_q⁻¹ŵ²/4ρ̂_θi)sinξ Θ` (passing- + only via `Θ(y_c−y)`) — a coefficient-free structural check that leaves no + freedom. Normalization chosen (÷ −m ρ̂_θi) to keep the cleared `c_D = ω̂_D` + untouched. Implemented as `Configure.streaming_coefficients` with a new + `Level0Physics.rho_hat_theta_i`; `:streaming` moved from gated to cleared; + `a_xi`/`a_x` removed from `GatedLevel0Inputs`. **physics-verifier PASS**; 1494 + islands assertions green (incl. a per-node `{Ω,g}` structure test); + `build_docs_local.jl` green. Doc-first: docs/01 §2, QUESTIONS Q5, + numerics.md §2/§8 amended. +- **Blocked**: nothing new. Cleared now: streaming, drift, collision *shapes*, + quasineutrality field, Δ prefactors. Still gated (Q5): `E×B` coupling, gradient + drive, collision magnitude `⟨ν̂_ii⟩_u`, orbit-averaged pitch measure, far field. +- **Next**: the gradient drive + far field are the remaining structural blockers + for a real `g` to develop (the drive is the source; the far field is the BC). + `f_p` sign-off still pending. Same rhythm: derive → present → sign off → clear. + +## 2026-07-11 — Q5 field fix: wire the cleared quasineutrality closure (Φ now driven) + +- **Moved**: closed the M2c-surfaced QN **structural gap** (QUESTIONS Q5). The + quasineutrality closure was signed off in M2b but the operator carried only + `R_Φ = M[g] − αΦ` (no drive), so the Level-0 potential collapsed to zero. + Implemented the full cleared closure: `Operators.Quasineutrality` gained an + optional `source` field; `Configure.configure_level0` now builds + **`α = (τ+1)/τ`** (= `1/quasineutrality_coefficient(τ)` — the reciprocal, =2 at + τ=1) and the drive **`S = L̂_{n0}⁻¹(x − ĥ(Ω))`** (`Configure.quasineutrality_source`, + from the cleared `h_amplitude`/`h_profile`, one width `w=w_psi` for both `Ω` + and the `ĥ` prefactor per `electron-closure.md §3`). Added `inv_Ln0` to + `Level0Physics`; removed `alpha` from the gated inputs (`quasineutrality` moved + to `cleared`). **Verified: max|Φ| ≈ 5.7 after solve** (was ~0). 194 islands + tests green; **physics-verifier PASS** (α reciprocal, source sign, width + convention all checked vs the signed-off derivation); `build_docs_local.jl` + green. Doc-first: docs/01 §3, `quasineutrality-closure.md §6`, numerics.md, + QUESTIONS Q5 all amended. +- **Blocked**: nothing new. The *kinetic* Q5 families (streaming, E×B, gradient + drive, `⟨ν̂_ii⟩_u`, pitch measure, far field) remain gated — the field equation + is now the fully cleared closure, but a physics threshold still needs those. +- **Next**: (human/next lane) the remaining Q5 kinetic clearances — the + parallel-streaming coefficients are the highest-leverage next (they + a far + field would let a real `g` develop). `f_p` sign-off still pending + (`passing-fraction.md`). The B-ladder scaffolding is wired to light up as each + clears. + +## 2026-07-11 — M2c: L0 configuration assembly + input-completeness audit (autonomous) + +- **Moved (M2b lane complete → M2c started)**: + - **Derivation lane 6/6 cleared** (earlier this session): ψ̃, ω̂_D + drift + toggle, collision operator, h(Ω) closure, quasineutrality, Δ prefactors — all + human-signed-off and in `src/` via `Coefficients.*`/`Moments.*` (recorded in + docs/01 + `derivations/`). Re-derivation caught the I19 ψ̃ published typo, the + collision low-v limit error, and the quasineutrality δn normalization. + - **Input-completeness audit** (Decision D9 deliverable): new + `docs/09-input-manifests.md` — per-source manifests (I19, D21, D23a/b, L23). + Headline: I19's own run collisionality is contradictory (0.01 vs 10⁻³) and its + Δ′ unspecified, so B5a's absolute threshold is only a T3 (existence) target; + **L23 (thesis) is the only clean T4 candidate**. Itself a reproducibility + result (Paper-I C9). Nav-wired. + - **M2c goal prompt** authored (`design/M2c-launch-prompt.md`): L0 assembly + + audit + docs/07 infra, autonomous-mode (un-gate nothing, escalate to + QUESTIONS, never guess). + - **L0 configuration assembly** (`src/Islands/configure/Configure.jl`, + `configure_level0`): wires the **cleared** coefficients onto the operator + stack — `c_D` node-for-node from `magnetic_drift_frequency` (verified Δ=0.0, + with the `:improved` toggle and forbidden-region zeroing), the pitch-collision + shapes from `pitch_diffusivity`/`deflection_frequency`, the Δ prefactors from + `delta_moment_prefactors`. Everything uncleared is a **supplied gated input** + (`GatedLevel0Inputs`); `level0_placeholders` gives documented non-physics + values so the assembled stack **converges structurally** (verified: 5 Newton + iters, ‖F‖=1.3e-9). 184 islands tests green (new `runtests_islands_configure.jl`); + the y=0 orbit-average guard was relaxed (`y>0`→`y>=0`, a domain-boundary fix, + no y>0 value changes). **Physics-verifier PASS** on the diff. + - **docs/07 STATE dashboard** (M2c #3a): `Verify.write_state_dashboard` + + `ladder_status` generate `docs/src/islands/state/STATE.md` (auto-gen header, + do-not-hand-edit) — the docs/05 ladder as a status table (8 A-ladder rows + green, B/C physics rows gated on QUESTIONS). Nav-wired. + - **B-ladder scaffolding** (M2c #4): `benchmarks/islands/benchmark_B{2,4,5}*.jl` + wired to `configure_level0` with a one-line un-skip (`const UNGATED = true`), + kept skipped on QUESTIONS Q3/Q5. B5 carries the full T2 toggle scaffold. + - **Anchor-sync check** (M2c #3b, docs/07 §1.1): `Verify.check_anchor_sync` + enforces the bidirectional operator↔docs sync — every `AbstractTerm` operator + named by an `Implemented by:` marker in `numerics.md` (forward), every marker + symbol resolving to a real Islands binding (reverse). numerics.md §8 gained + the as-implemented assembly section + `Implemented by:` markers. Tested with + negative controls (a missing operator ⇒ undocumented; a bogus symbol ⇒ + dangling). 189 islands tests green; `build_docs_local.jl` green. + - **Deferred-constant draft** (M2c #5): `derivations/passing-fraction.md` + `[DERIVED]` derives the electron-closure passing fraction `f_p ≃ 1−1.46√ε` + from the effective trapped-fraction integral and **numerically confirms** the + coefficient (`1.4624`, = quoted `1.46` to 3 s.f.). **Drafted, awaiting + sign-off** — does NOT clear `Fields.ElectronClosure.f_p` (stays NaN-gated). + One open reviewer item (I19 Eq. 22's f_p definition). `⟨ν̂_ii⟩_u` and the + Hirshman–Sigmar `k` left escalated (need specific source integrands) — not + drafted speculatively. +- **Blocked (escalated → QUESTIONS Q5)**: the L0 assembly surfaced that several + operator-stack coefficient families are **not yet cleared** — parallel + streaming (`a_xi`/`a_x`), `E×B` `c_E`, gradient drive, the collision magnitude + `⟨ν̂_ii⟩_u`/`ν_★`, the orbit-averaged pitch measure, and the neoclassical far + field — plus a **structural gap**: the quasineutrality operator lacks the + `L̂_{n0}⁻¹(x−ĥ)` field source the cleared closure requires (and its α is the + reciprocal of `quasineutrality_coefficient`), so no Level-0 *physics* run is + possible until that lands. This is why M2c delivers the assembly **scaffold**, + not a physics result. These need a second derivation lane (an "M2d", + human-present) run like M2b. + Autonomous M2c is now complete (#1 assembly, #2 audit, #3 docs infra [STATE + + anchor-sync], #4 B-ladder scaffolding, #6 as-implemented numerics.md; all + green, physics-verifier PASS on the assembly). +- **Next**: (human) work **Q5** — clear the remaining coefficient families and fix + the QN operator structure (doc-first: amend docs/01 §3 + docs/03 §2). That is + the only thing gating a Level-0 *physics* run; it un-gates the B-ladder T2/T3 + gates (scaffolding, STATE dashboard, anchor-sync all already wired). Then #5 + (deferred sub-constants ⟨ν̂_ii⟩_u/k/f_p) is a focused sign-off session like M2b. + When the full as-implemented Physics Book chapters (docs/07 §1.1) are scoped, + point the operators' anchors there; `Verify.check_anchor_sync` already enforces + the sync against `numerics.md` today. The M2c goal prompt is re-entrant. + +## 2026-07-11 — Re-scope verification targets: tiered by reproducibility (Decision D9) + +- **Moved**: user flagged that absolute literature numbers (w_c ≃ 2.76 ρ_θi ≡ + 8.73 ρ_bi, 0.45 ρ_θi ≡ 1.46 ρ_bi, the kokuchou 0.440… fit, the −0.89 ω_dia,e + reversal, the D23a shaping widths) were quoted as if they were pass/fail + targets — but reproducing an absolute number needs *every* input of the + source's exact scenario, which the lineage under-specifies (B5a's own + collisionality is internally contradictory). Direction: qualitative/scaling + checks (the Park 2022 / Burgess 2026 modality) are the real physics gates. +- **Decision D9** (adopted, docs/00): a **four-tier target taxonomy** written + into docs/05 ("Target tiers and reproducibility"): T1 exact math / T2 internal + cross-checks & toggle differentials (the sharpest quantitative claims) / T3 + scalings-trends-existence vs. literature (primary literature-facing gates) / + T4 absolute reproduction — **audit-gated**, never pass/fail without an *input + manifest*, downgraded to T3 where the source is under-specified. Added a fifth + triage outcome ("under-specified source configuration") and three reporting + rules (publish the manifest; prefer differentials/ratios; sensitivity scans). +- **Applied** across docs/05 (every B/C row retagged; A7 constants marked T1), + docs/00 (Level-0 gate softened, D9 logged), the Paper-I OUTLINE (C5–C7 + reframed scaling-first; new C9 = the input-completeness audit as a methods + deliverable), the three B-benchmark scripts + README (tier-labeled headers), + the M2b prompt (new deliverable: per-source input manifests in a `docs/09` + audit; B-ladder DoD = T2 differential + T3 scalings, T4 only with manifests), + QUESTIONS (B5a collisionality reframed as the audit type specimen), and the + numerics chapter / islands.md status. Docs-only; no `src/` or test changes. +- **Why it strengthens the project**: T2 internal differentials give *sharper* + claims than absolute matches (we control both sides); the input audit is + itself publishable reproducibility content; and it aligns the ladder with the + SLAYER-validation precedent Islands models itself on. +- **Next**: unchanged — M2b derivation lane, now with the input-completeness + audit folded into its DoD. + +## 2026-07-08 — M2 L0 solve machinery: Newton–Krylov + moments + species/frames/fields (structure, gated physics) + +- **Contract**: `docs/src/islands/design/M2-launch-prompt.md` (interactive /goal + run). Branch `feature/islands-m2`, **PR #324** (stacked on + `feature/islands-m1`/PR #320; retargets to `feature/islands` when #320 merges). + Full suite green locally. +- **Moved**: the full L0 solve *structure*, every physics coefficient a supplied + `[VERIFY]`-gated parameter (physics-verifier: **PASS**): + - `solvers/` — matrix-free Newton–Krylov (Krylov.jl GMRES on a preallocated + ForwardDiff JVP; Eisenstat–Walker; line search; convergence on norm AND + max-norm per `04 §5`), `YBlockJacobi` physics-block preconditioner with + TSVD-regularized pencil solves (the `04 §3` y_c treatment), dense tiny-grid + debug Jacobian, pseudo-arclength continuation with fold detection (toy fold + found at step 6 of the test problem). + - `species/` (D3 plumbing), `frames/` (conversion forms, NaN-gated + `FrameConvention`), `fields/` (Q(Ω)/h(Ω) structure + NaN-gated + `ElectronClosure`), `moments/` (J̄_∥, Δ projections with required gated + prefactors, ⟨·⟩_Ω diagnostics), operators additions (mimetic + `PitchAngleDiffusion`, `FarFieldConditions` — never bare Neumann, + `weighted_moment!`). + - **Structural gates green** (67 new tests in `runtests_islands_solve.jl`): + A5 (residual exactly 0 at g≡0), solve-MMS at design order (3.98 observed, + nx 17→33), A4 (conservation ≲1e-11, entropy sign exact), A3 parity, A7 + ⟨∂²h/∂x²⟩_Ω ≈ 1e-16, A8 σ_min monitor + singular detection. Preconditioner + cuts a stiff collisional solve from 79.5 s/28 Newton to 0.6 s/7 (GMRES + 1700→200-class); all new kernels pass `--check-bounds=yes`. + - `benchmarks/islands/` created: B2/B4/B5 scripts **skipped**, each naming its + gating QUESTIONS IDs; `regression-harness` case `islands_l0_structural` + (solve-MMS err 5.254e-2, 6 Newton/1210 GMRES, A7 8.0e-17, σ_min 0.1139). + - Paper-I figure contract: `docs/src/islands/papers/paper-1/OUTLINE.md` + (claims C1–C3 green as CI artifacts; C4–C8 gated on Q2–Q4). + - **Rendered docs story** (user-flagged gap vs docs/07's M0–M1 intent): new + `docs/src/islands/numerics.md` — the equations + figures of everything as + implemented — plus the pinned figure script + (`benchmarks/islands/figures/make_structural_figures.jl`, five structural + figures committed as docs assets) and a full "Islands" site-nav section + (overview, numerics chapter, Paper-I contract, design docs 00–08). + Remaining docs/07 infra for later milestones: anchor-sync CI check, + STATE.md dashboard. +- **Physics debugging note**: the first solve-MMS attempt failed to converge — + the generic `Collisions` (a_y ∂²y) term has no y-BCs, so its BVP + discretization is unstable under refinement; the *mimetic* divergence form + (degenerate P → 0 endpoints, zero-flux built in) is the correct structure and + the far-field x-BCs are what make the advective solve well-posed. Exactly the + design's point (`01 §3`, `04 §1`). +- **Blocked**: the York gates (B5a/b/c, B2, B4) and Paper-I claims C4–C8 — all + on the human clearance queue **Q2/Q3/Q4** (unchanged). +- **Next**: human clears Q2–Q4 → thin run fills the L0 coefficients from the + D7 re-derivation and un-skips the B-ladder; independent M2+ work: kinetic- + electron toggle (E4), io/ TOML section, trace-species linear pass. + +## 2026-07-08 — M1 skeleton: phase-space grids + operator stack + MMS/AD harness + +- **PR**: #320 (`feature/islands-m1` → `feature/islands`); full suite green. +- **Moved**: Landed the M1 core (design `03 §1–2`, `04`, ladder `A1/A2`). Three + `src/Islands/` submodules, all structure-only (no `[VERIFY]` physics numbers): + - `phasespace/PhaseSpace.jl` — the `(x, ξ, y, E, σ)` grids with layer-clustered + maps: Fourier spectral `∂ξ`, Fornberg high-order FD `∂x`/`∂y` on `sinh`-stretched + grids (window sized per-derivative so `D1`/`D2` are both 4th-order incl. + boundaries), composite-Simpson quadrature weights, Gauss–Laguerre energy nodes. + - `operators/Operators.jl` — `AbstractTerm` + `apply!` + `residual!`; the term + structs of `03 §2` (`ParallelStreaming`, `MagneticDrift` with the + `:original/:improved` toggle, `ExBDrift` as the `(x,ξ)` Poisson bracket, + `Collisions`, `GradientDrive`, `PerpTransport`/`RadiationSink` L4 stubs, + `Quasineutrality` field residual). Every physics coefficient is a **supplied + data field** — no literal in `src/`. Allocation-free, AD-generic. + - `verify/Verify.jl` — manufactured-solution + AD-vs-FD JVP harness. + - Tests `test/runtests_islands_{grids,operators}.jl` (wired into `runtests.jl`): + A1 per-operator MMS → 4th order for `∂x/∂y` terms, machine-precision for the + `∂ξ` term; assembled kinetic residual → 4th order; A2 JVP-vs-FD agree to ~6e-9; + **allocation regression = 0 bytes** for every `apply!` and `residual!`. All + 53 Islands tests green. Added `ForwardDiff` to `Project.toml` (design `04 §9`). +- **physics-verifier**: PASS — audited all six new/changed files, no + `[VERIFY]`-policy violation; the flagged literature numbers (8.73/1.46 ρ_bi, + k=−1.173, …) appear only in docstring prose, never assigned to a coefficient. +- **Blocked**: nothing. **Q1 RESOLVED**: julia is at + `/mnt/homes_global/ncl2128/software/julia-1.11.7/bin/julia`; must be run with + `env -u LD_LIBRARY_PATH` (OMFIT contamination). Used it to run the suite here. +- **Next**: M2 — wire moments (`Δ_cos`, `Δ_sin`), `frames/`, `species/`, and the + Newton–Krylov solver toward the L0 single-species solve; every physics + coefficient stays `[VERIFY]`-gated with a skipped benchmark until cleared. + +## 2026-07-08 — Harden Stop hook against OMFIT LD_LIBRARY_PATH contamination + +- **Moved**: Diagnosed why the Stop hook's package-load check fails on this box. + A loaded OMFIT module (`module load omfit/unstable`) leaks + `LD_LIBRARY_PATH=/mnt/codes/atom/mambaforge/envs/omfit/lib:` into the session; + those conda libs shadow Julia's bundled artifacts, giving `undefined symbol` + errors in CHOLMOD and the Plots/Cairo/GR native stack (and the ubiquitous + `libtinfo.so.6` bash warning). Not a code issue — CI is green, and + `env -u LD_LIBRARY_PATH julia … using GeneralizedPerturbedEquilibrium` loads + clean (exit 0). Fixed `stop-check.sh` to run the build check with + `env -u LD_LIBRARY_PATH` (no-op on a clean shell / CI). Repo deps were + instantiated here; the shared depot (`/mnt/codes/ncl2128/.julia`) is populated. +- **Blocked**: nothing new. This is the concrete shape of **Q1** — the + automation shell must invoke julia with a clean `LD_LIBRARY_PATH` (unload + OMFIT, or unset the var) or the overnight loop's *actual* gpec runs fail the + same way, not just the hook. +- **Next**: (human) launch the loop from a shell without the OMFIT module + (`module unload omfit`); hook hardening is defense-in-depth on top of that. + +## 2026-07-08 — Fix invalid deny rules in `.claude/settings.json` + +- **Moved**: `/doctor` flagged two skipped permission-deny rules — + `Bash(git push:* main)` / `Bash(git push:* develop)` — invalid because `:*` + (prefix match) is only allowed at the end of a pattern. Rewrote them with a + mid-pattern wildcard (`Bash(git push* main)` / `Bash(git push* develop)`) so + they load and again deny pushes to `main`/`develop` for any remote/flags. +- **Blocked**: nothing. +- **Next**: unchanged — pending items are the Phase A bootstrap **Next** below. + +## 2026-07-08 — Phase A bootstrap (supervised) + +- **Moved**: Created the `Islands` submodule skeleton (`src/Islands/Islands.jl`, + empty `module Islands`) and wired it into `src/GeneralizedPerturbedEquilibrium.jl` + (`include` + `import . as` + `export`, last submodule slot before `Rerun.jl`). + Stood up this `LOG.md` and `QUESTIONS.md`, the `.claude` unattended-run + guardrails, the `physics-verifier` subagent, and the M1 launch prompt. +- **Landed CI-green** on `feature/islands` (PR #318): both `runtests` jobs pass + (the wiring is valid — the package loads and the full suite passes) and the + docs build passes. One fix was needed en route: the exported `Islands` module + docstring required a manual page under `checkdocs=:exports`, so + `docs/src/islands.md` (an `@autodocs` block) was added and wired into + `docs/make.jl` (repo-root CLAUDE.md docs-coverage rule). +- **Blocked**: `julia` is not on the automation shell's PATH (no module, not in + `$HOME`) → changes could not be run locally; CI is the only Julia validation + here. See **Q1** — the overnight loop's scratch-clone environment must expose + `julia` or it cannot run tests / meet M1's definition-of-done. +- **Next**: (human) resolve Q1 + one supervised `dontAsk` dry-run of the hooks, + then launch the overnight loop on milestone **M1** (design `00 §M1`) — + phase-space grids + operator-stack skeleton + MMS/AD harness (ladder A1, A2), + no `[VERIFY]` physics coefficients. diff --git a/docs/src/islands/QUESTIONS.md b/docs/src/islands/QUESTIONS.md new file mode 100644 index 000000000..74f79f4fc --- /dev/null +++ b/docs/src/islands/QUESTIONS.md @@ -0,0 +1,217 @@ +# Islands — blocker queue (QUESTIONS) + +Append-only non-blocking escalation queue (design doc `06 §2.2`). When blocked +on anything the CLAUDE.md forbids guessing — `[VERIFY]` clearances, physics +coefficients, signs, normalizations, convention/doc contradictions, or a tooling +prerequisite — write an entry here **and switch to the next unblocked task**. +Never stall waiting for a human; never resolve silently. + +Each entry: +- **ID**: `Q` (monotonic). Commits/PRs reference the IDs they were blocked on + or unblocked by. +- **Status**: `OPEN` / `RESOLVED (by , )`. +- **Context**: what you were doing. +- **Question**: the specific thing a human must decide. +- **Options**: the alternatives considered. +- **Recommendation**: your best guess (not acted on until cleared). +- **Gated work**: what is blocked until this resolves. + +The human's recurring job is clearing this queue (and `[VERIFY]` tags), not +supervising sessions. + +--- + +## Q1 — Julia not on the automation shell PATH — RESOLVED (by Claude, 2026-07-08) + +- **Context**: Phase A bootstrap. Verifying the `Islands` module skeleton loads + (`using GeneralizedPerturbedEquilibrium`) and running the test suite requires + `julia`, but it was not on the non-interactive shell's PATH (no `julia` module, + none in `$HOME`; other users have installs under `/mnt/homes*/…/julia*`). +- **Question**: What is the canonical `julia` invocation for automation on this + cluster, and will the overnight loop's scratch-clone environment expose it? +- **Resolution**: the `ncl2128`-owned install is at + `/mnt/homes_global/ncl2128/software/julia-1.11.7/bin/julia` (option (b): an + absolute path to a user-owned binary), and it is on this session's PATH. The + M1 run used it to build and run `test/runtests.jl` locally. The **only caveat** + is the OMFIT `LD_LIBRARY_PATH` contamination already documented in LOG + (2026-07-08): the binary must be invoked with a clean loader path — + `env -u LD_LIBRARY_PATH /mnt/homes_global/ncl2128/software/julia-1.11.7/bin/julia + --project=. …` — or the conda libs shadow Julia's bundled artifacts. The Stop + hook already applies `env -u LD_LIBRARY_PATH`; the overnight loop's launch + script must do the same for its own gpec runs. +- **Gated work (now unblocked)**: local verification of every Julia change; the + overnight loop's ability to run tests / meet its definition-of-done. + +## Q2 — Ratify Decisions D7 and D8 — RESOLVED (by the user, 2026-07-08) + +- **Resolution**: both ratified as written (option (a)); recorded in the + `docs/00` Decision Log. D7 additionally carries the user's clearance-mode + choice for Q3: **re-derivation first** — the L0 coefficient set is cleared by + human sign-off of in-repo derivations, not literature transcriptions. + +- **Context**: M2 setup. The L0 equation set and benchmark targets rest on two + Decision-Log proposals (`docs/00`) that are dated 2026-07-07 and still marked + "needs human ratification". Until ratified, M2 cannot fix the L0 physics form or + pin its benchmark tolerances. +- **Question**: Ratify (or amend) **D7** — implement Level-0 physics from an + *independent re-derivation* cross-checked against the L23-amended equation set, + treating I19 Eq. (A.1) as printed as known-errata (L23 §2.6), with ω_E a scanned + input from day one — and **D8** — pin the benchmark grid as the three-code + triangle (DK-NTM / RDK-NTM / kokuchou) with B5a/b/c configs, superseding the + single "York thresholds" gate item. +- **Options**: (a) ratify both as written; (b) ratify with amendments; (c) direct a + different L0 derivation strategy. +- **Recommendation**: ratify both — they encode the project's core anti-guessing + thesis (published O(1) coefficients in this lineage are demonstrably wrong) and + the benchmark structure the ladder already assumes. +- **Gated work**: pinning any L0 physics coefficient; the York gates B5a/b/c; the + Paper-I physics claims. + +## Q3 — Clear the Level-0 coefficient set ([CHECKED] → human sign-off) — OPEN (mode decided) + +- **Mode decision (user, 2026-07-08, via Q2/D7)**: option (b) — **re-derivation + first**. The next milestone (M2b) produces independent derivations of each + item below in `docs/src/islands/derivations/` (marked `[DERIVED]`, with a + cross-check table against the `[CHECKED]` transcriptions and every + discrepancy flagged); the human then signs off the *derivations*, which + clears the corresponding coefficients. Items remain individually OPEN until + that sign-off. + +- **Context**: M2 builds the L0 solve machinery with every physics coefficient a + parameterized `[VERIFY]` stub. Reaching the York gates needs these `[CHECKED]` + (AI-transcribed, cited, but not human-signed-off) items cleared. `[CHECKED]` is + not permission to hardcode (`docs/01` header). Each is implemented structurally; + none has a value in `src/`. +- **Question**: Check each against its source PDF and sign off (record paper + Eq./p. + in `docs/01`), or flag a discrepancy: + - `ω̂_D` magnetic-drift frequency + the `:original`/`:improved` `L̂_B⁻¹` toggle + `[CHECKED: I19 Eq. (32) def.; D21 Eqs. 15, B1; D21 Eq. A2, p. 16]` — drives the + 8.73 → 1.46 ρ_bi threshold shift. + - Pitch-angle collision kernel `[CHECKED: I19 Eqs. (9)–(12); Diss19 Eqs. 2.25–2.30; + WCHH96 Eq. (62)]` + the analytic velocity average + `⟨ν̂_ii⟩_u = (4ε^{3/2}ν_★/3√π)(√2 − ln(1+√2))` `[CHECKED: L23 Eq. 4.1.6, p. 88]` + + normalization `ν_★ = ν_jj Rq/(ε^{3/2}v_th)` `[CHECKED: L23 Eq. (2.3.40)]`. + - Electron-closure constants `k ≃ −1.173` (Hirshman–Sigmar) and `f_p ≃ 1 − 1.46√ε` + `[CHECKED: I19 Eq. (22); L23 Eqs. 2.5.5–2.5.8]`. + - Quasineutrality closure `e_iΦ̂/T_i = [δn̄_i/n₀ + x − ĥ(Ω)]/(2 L̂_{n0})` + `[CHECKED: I19 Eq. (A.11); L23 Eq. (2.4.14)]` and the Picard form + `δΦ̂ = (δn̂_i − δn̂_e)/2` `[CHECKED: Diss19 Eq. 2.45]`. +- **Options**: (a) clear item-by-item after PDF check; (b) require an independent + re-derivation first (couples to Q2/D7) before clearing. +- **Recommendation**: clear via re-derivation (per D7) rather than transcription + alone — L23 §2.6 documents concrete errors in the published set. +- **Gated work**: populating any of these coefficients; the York/large-w/polarization + gates (B5a/b/c, B2, B4); the A7 number-bearing identities (`k`, `f_p`, `⟨ν̂_ii⟩`). + +## Q4 — Resolve open [VERIFY]s and acquire two missing sources — OPEN + +- **Context**: Independent of the `[CHECKED]` clearances above, three genuinely + open `[VERIFY]` items block pinning M2's moment normalization and B5a tolerance. +- **Question**: + - `ψ̃` amplitude: is it `(w_ψ²/4)(q_s′/q_s)` (Diss19/D21/L23, dimensional analysis) + or `(w_ψ²/4)(q_s/q_s′)` (one I19 extraction)? `[VERIFY: check I19 as printed — + possible typo in the paper itself]` — sets the `Δ_cos/Δ_sin` prefactor. + - B5a run collisionality: I19 §4.2 states `ν_★ = 0.01`; L23 p. 82 quotes DK-NTM at + `ν_★ = 10⁻³`. **Reframed by Decision D9 (2026-07-11)**: this is no longer a + tolerance to pin but the **type specimen of the input-completeness problem** + — I19 is internally inconsistent about its own run, so its absolute w_c is a + T4 (audit-gated) target, not a pass/fail number. It is resolved *in the M2b + input manifest* (`docs/09`/audit), recording both values as the source's + ambiguity; the primary B5 gates (T2 toggle differential, T3 existence/trend) + do not depend on it. + - ~~Acquire WCHH96 and Park PoP 29 (2022)~~ **Both resolved (2026-07-09)**: + the user added WCHH96 (Wilson, Connor, Hastie & Hegna, PoP **3**, 248 + (1996), doi:10.1063/1.871830) to the island reference library, and Park + 2022 was found already in-repo in the general `docs/resources/` dir (the + docs/08 island-subfolder map had missed it; map corrected). The user also + flagged **Burgess 2026** (`docs/resources/2026-Burgess-…Two-Fluid Slab + Layer.pdf`) as the methodological template: toroidal outer-region Δ′ + + SLAYER regime-generalized linear layer — Islands extends that inner region + from zero-width linear layers to finite-width islands (recorded in docs/08 + as **B26**). Remaining open in Q4: the `ψ̃` amplitude check and the B5a run + collisionality. +- **Options**: (a) user resolves from the source PDFs; (b) an independent + re-derivation pins `ψ̃` and the collisionality is recorded in the input manifest. +- **Recommendation**: resolve `ψ̃` by re-derivation (a clean dimensional check, M2b); + record the B5a collisionality ambiguity in the input manifest rather than + "resolving" it — per D9 the absolute B5a value is audit-gated, not a gate. +- **Gated work**: the `Δ_cos/Δ_sin` normalization (the `ψ̃` item); the T4 B5a/B2 + absolute comparisons (the manifest items) — the T2/T3 primary gates are not + blocked by these. + +**Tier note (Decision D9, 2026-07-11):** verification targets are now tiered by +reproducibility (docs/05 "Target tiers"). This changes what the Q3/Q4 clearances +*buy*: a cleared coefficient set un-gates the **primary** T2 (internal +differentials) and T3 (scalings/trends/existence) physics gates directly; the T4 +absolute literature comparisons additionally require the M2b input-completeness +audit and are reported only with input manifests + sensitivity scans, never as +bare pass/fail. The derivation lane inherits this framing. + +## Q5 — The remaining un-cleared Level-0 operator coefficient families (a second derivation lane) — OPEN + +- **Context**: M2c assembled `Configure.configure_level0` — the Level-0 + named-configuration builder. Wiring the cleared coefficients onto the operator + stack surfaced that the M2b derivation lane cleared the coefficient families + that appear as *closures/moments* (`ω̂_D`, the collision `P`/`ν` shapes, the + quasineutrality scalar, the `Δ` prefactors), but **several operator-stack + coefficients are not yet a cleared family** and were left supplied/gated in + `Configure.GatedLevel0Inputs`. `ω̂_D` (`MagneticDrift.c_D`), the pitch + diffusivity shape, the deflection-frequency shape, and the `Δ` prefactors *are* + wired from cleared `Coefficients.*`; the items below are not. +- **Question**: clear (by the D7 re-derivation-first route, `docs/derivations/`, + human sign-off) each remaining Level-0 operator coefficient: + - **Parallel-streaming** `a_xi`, `a_x` (`Operators.ParallelStreaming`): + **RESOLVED (2026-07-11)** — re-derived (`parallel-streaming.md`, signed off) + from I19 Eq. 32; the coefficients factor exactly into `{Ω, ·}` flux-surface + advection (a coefficient-free structural check). Implemented as + `Configure.streaming_coefficients` with a new `Level0Physics.rho_hat_theta_i`; + normalization chosen to keep the cleared `c_D = ω̂_D` unchanged. + - **`E×B` coupling** `c_E` (`Operators.ExBDrift`): the Poisson-bracket + normalization; entangled with the frames convention (Frames NaN-gated). + - **Gradient drive** `drive` + **far field** `bc` — **RESOLVED (2026-07-11**, + `gradient-drive.md`, signed off): reading I19 first-hand (Eqs. 28–32), the + master equation is **homogeneous** (no interior source); the drive is the + **far-field boundary condition** `Ḡ₀ → p_φ(ω_si^T/ω_si)(n'/n)F_Mi = p_φ F'_Mi` + (Eq. 29 — the ratio is `ω_si^T/ω_si = 1+(v̂²−3/2)η_i`, a **temperature factor, + not a frequency ratio**; an earlier reading of `ω_ci` was a misread, so **no + frame convention is needed**). Implemented: `Operators.GradientDrive = 0` and + `Configure.gradient_far_field` builds `g_far = x L̂_{n0}⁻¹[1+(E−3/2)η_i]` + (`Φ̂_far = 0` at `ω_E = 0`), with a new `Level0Physics.eta_i`. The + `gradient_drive` **and** `far_field` families are both cleared. + - **Quasineutrality closure — RESOLVED (2026-07-11).** The cleared closure + `Φ̂ = τ/(τ+1)[δn̄_i/n₀ + L̂_{n0}⁻¹(x−ĥ)]` (Q3, `quasineutrality-closure.md`, + signed off) is now implemented: `Operators.Quasineutrality` carries a + `source` field, and `Configure.configure_level0` builds the residual + `R_Φ = M[g] − α Φ̂ + S` with `α = (τ+1)/τ` (= `1/quasineutrality_coefficient(τ)`) + and `S = L̂_{n0}⁻¹(x−ĥ)` (`Configure.quasineutrality_source`, from the cleared + `h_amplitude`/`h_profile`). The Level-0 potential is now driven (was trivially + zero). docs/01 §3 records it; the derivation §6 was the authorization. **No + longer gates a Level-0 physics run.** + - **Collision magnitude** `nu_tilde`: the `⟨ν̂_ii⟩_u`/`ν_★` normalization scaling + the cleared `ν_{jj}(v̂)` shape — the deferred sub-constant already tracked in + Q3 (`⟨ν̂_ii⟩_u = (4ε^{3/2}ν_★/3√π)(√2−ln(1+√2))`, L23 Eq. 4.1.6). Still + escalated (needs the L23 integrand read in detail). + - **Passing fraction** `f_p ≃ 1 − 1.46√ε` (electron closure, Q3): **RESOLVED + (2026-07-11)** — `derivations/passing-fraction.md` signed off; cleared as + `Coefficients.passing_fraction(ε) = 1 − 1.4624√ε` (the effective + trapped-fraction coefficient, = quoted `1.46` to 3 s.f.), authorizes + `Fields.ElectronClosure.f_p`. The Hirshman–Sigmar `k ≃ −1.173` remains + escalated (needs the parallel-viscosity moment problem). + - **Orbit-averaged pitch measure** `B_profile`: the collision operator's `|B|` + on the `y`-grid is the *orbit-averaged* field (turning-point structure), not a + single local `B`; the cleared `pitch_diffusivity(λ,B)` is the local building + block. Clear the orbit-averaged measure form. + - **Neoclassical far field** `bc` (`Operators.FarFieldConditions`): the + no-island `g_far`/`Φ_far` (never bare Neumann — L23 §5.3), gated physics + already flagged under Q3. +- **Options**: (a) a focused second derivation lane (an "M2d") clearing these + item-by-item like M2b, human-present; (b) clear the highest-leverage first + (streaming + the QN structural fix un-gate a genuine physics residual). +- **Recommendation**: (a) — run it exactly like the M2b lane (re-derive → + physics-verifier → sign-off → clear into `Coefficients.*` / operator structure). + The QN structural gap is the highest priority: without the `(x−ĥ)` source the + Level-0 quasineutrality field is trivially `Φ = 0`, so no Level-0 *physics* run + is possible until it lands (the M2c assembly runs *structurally* on placeholders + only). **This is why M2c delivers the assembly scaffold, not a physics result.** +- **Gated work**: any Level-0 *physics* solve (as opposed to the structural + convergence check); the B-ladder T2/T3 physics gates; the L23/B5c T4 attempt. diff --git a/docs/src/islands/derivations/collision-operator.md b/docs/src/islands/derivations/collision-operator.md new file mode 100644 index 000000000..30c7cf7ac --- /dev/null +++ b/docs/src/islands/derivations/collision-operator.md @@ -0,0 +1,226 @@ +# Derivation — the Level-0 pitch-angle collision operator, deflection frequency, and ``\nu_\star`` normalization + +**Provenance:** `[DERIVED: 2026-07-11]` — independent re-derivation (Decision D7). +**Clears (on sign-off):** the momentum-conserving pitch-angle (Lorentz) +collision operator structure, the deflection-frequency velocity dependence +``\nu_{jj}(v)`` with the Chandrasekhar/``v^{-3}`` sub-toggle, and the ``\nu_\star`` +normalization (`[CHECKED: I19 Eqs. 9–12; Diss19 Eqs. 2.25–2.30; WCHH96 Eq. 62]`, +QUESTIONS Q3). +**Deferred (flagged sub-items, §7):** the analytic velocity average +``\langle\hat\nu_{ii}\rangle_u`` (L23 Eq. 4.1.6 — a separate short derivation), +and the orbit-averaged/discretized diffusivity profile that feeds +`PitchAngleDiffusion` (numerics, ties to the conservation gate A4). + +**Status:** ✅ **signed off 2026-07-11** (clearance recorded in docs/01 §2.3) for +the operator structure, deflection frequency, and ``\nu_\star`` normalization — +implemented as `Coefficients.pitch_diffusivity` and +`Coefficients.deflection_frequency`. The ``\langle\hat\nu_{ii}\rangle_u`` +constant (§7) and the discretized diffusivity profile remain **deferred / +gated**. + +## 1. Starting point and what must be shown + +At Level 0 the collision operator is the momentum-conserving **pitch-angle +(Lorentz) model** (orderings O6; docs/01 §2.3). From the drift-kinetic equation +(I19 Eq. 8) the like-species operator is (I19 Eq. 9, verified first-hand, +print p. 4) + +```math +C_{jj}(f) = 2\nu_{jj}(v)\left[\frac{\sqrt{1-\lambda B}}{B}\, + \frac{\partial}{\partial\lambda}\!\Big(\lambda\sqrt{1-\lambda B}\, + \frac{\partial f}{\partial\lambda}\Big) + \;+\; \frac{v_\parallel\,\bar u_{\parallel j}}{v_{thj}^2}\,F_{Mj}\right], +``` + +the ``\lambda``-derivatives taken at fixed ``\psi``, ``\lambda=\mu/\mathcal E`` the +pitch, ``\mathcal E=v^2/2``. This derivation establishes three things: (i) the +first bracket **is** the pitch-angle scattering operator, in self-adjoint +(divergence) form, with diffusivity ``P(\lambda)=\lambda\sqrt{1-\lambda B}``; +(ii) the velocity dependence ``\nu_{jj}(v)``; (iii) the ``\nu_\star`` +normalization. The second bracket is the momentum-restoring term (§6). + +## 2. The pitch-angle operator is Lorentz scattering in self-adjoint form + +The full Fokker–Planck test-particle operator, in the small-mass-ratio / +dominant-deflection limit that defines the Lorentz model, reduces to pure +pitch-angle scattering — diffusion of the velocity-vector *direction* at fixed +speed. In the pitch cosine ``\xi_p=v_\parallel/v`` it is the standard Lorentz +operator ``C=\tfrac{\nu_{jj}}{2}\,\partial_{\xi_p}\!\big[(1-\xi_p^2)\, +\partial_{\xi_p}f\big]``. Change to the constant-of-motion pitch +``\lambda=\mu/\mathcal E`` via ``1-\lambda B=\xi_p^2=v_\parallel^2/v^2`` (at +fixed ``B,v``): then ``\partial_{\xi_p}=-\tfrac{2\sqrt{1-\lambda B}}{B} +\partial_\lambda`` and ``1-\xi_p^2=\lambda B``, so + +```math +\partial_{\xi_p}\!\big[(1-\xi_p^2)\partial_{\xi_p}f\big] + = \frac{4\sqrt{1-\lambda B}}{B}\,\frac{\partial}{\partial\lambda}\! + \Big(\lambda\sqrt{1-\lambda B}\,\frac{\partial f}{\partial\lambda}\Big), +``` + +and therefore + +```math +C = 2\nu_{jj}\,\frac{\sqrt{1-\lambda B}}{B}\, + \frac{\partial}{\partial\lambda}\!\Big(\lambda\sqrt{1-\lambda B}\, + \frac{\partial f}{\partial\lambda}\Big) , +``` + +**exactly** the first term of Eq. 9 — the ``2\nu_{jj}`` prefactor is the +change-of-variables Jacobian, so the coefficient is not free. Two structural +facts follow directly: + +- **Self-adjoint (divergence) form.** The operator is + ``w(\lambda)^{-1}\,\partial_\lambda[P(\lambda)\,\partial_\lambda]`` with + **diffusivity** ``P(\lambda)=\lambda\sqrt{1-\lambda B}\ge 0`` and **measure** + ``w(\lambda)=B/\sqrt{1-\lambda B}`` (so the prefactor is ``1/w``). This is + exactly the form the implemented mimetic operator + `Operators.conservative_pitch_operator` discretizes + (``K=-W_q^{-1}G^{\mathsf T}\mathrm{diag}(P\,w_q)G``), which is why particle + conservation and the entropy sign hold *exactly in floating point* (gate A4, + already green). This derivation identifies its physics profile: + ``P=\lambda\sqrt{1-\lambda B}``. +- **Zero-flux endpoints.** ``P(\lambda)`` vanishes at ``\lambda=0`` (``v_\parallel + =v``, ``P=0``) and at ``\lambda=1/B`` (``v_\parallel=0``, ``\sqrt{1-\lambda B}=0``), + so the scattering flux ``P\,\partial_\lambda f`` vanishes at both ends — no + pitch boundary conditions are needed, matching the mimetic construction. + +## 3. The deflection frequency (velocity dependence) + +The speed dependence is carried entirely by ``\nu_{jj}(v)`` (I19 Eq. 11): + +```math +\nu_{jj}(v) = \tilde\nu_{jj}\,\frac{\phi(\hat v)-G(\hat v)}{\hat v^{3}}, +\qquad \hat v = v/v_{thj},\ \ v_{thj}^2=2T_j/m_j , +``` + +with the error function and Chandrasekhar function + +```math +\phi(X)=\frac{2}{\sqrt\pi}\int_0^X e^{-t^2}dt, +\qquad +G(X)=\frac{\phi(X)-X\phi'(X)}{2X^2},\quad \phi'=\frac{d\phi}{dX} . +``` + +This is the standard test-particle **deflection** (perpendicular-diffusion) +rate. Two limits fix the structure and the sub-toggle (docs/01 §2.3, ladder E3). +Expanding with ``\phi(X)=\tfrac{2}{\sqrt\pi}(X-\tfrac{X^3}{3}+\dots)`` and +``\phi'(X)=\tfrac{2}{\sqrt\pi}e^{-X^2}``: + +```math +G(X)=\frac{\phi-X\phi'}{2X^2} + =\frac{2}{\sqrt\pi}\Big(\frac{X}{3}-\frac{X^3}{5}+\dots\Big), +\qquad +\phi-G=\frac{2}{\sqrt\pi}\Big(\frac{2X}{3}-\frac{2X^3}{15}+\dots\Big). +``` + +- **Low speed** ``\hat v\to 0``: ``\phi-G\to \tfrac{4}{3\sqrt\pi}\hat v`` + (**linear**, not cubic), so + ``\nu_{jj}\to \tfrac{4\tilde\nu_{jj}}{3\sqrt\pi}\,\hat v^{-2}`` — the deflection + frequency **diverges as ``\hat v^{-2}``** even in the *full* Chandrasekhar + form. This is precisely the "``\tilde\nu\propto u^{-2}`` divergence at low + ``u``" of docs/01 §2.3 that spoils a naive velocity quadrature and motivates + the analytic ``\langle\hat\nu_{ii}\rangle_u`` (§7). +- **High speed** ``\hat v\to\infty``: ``\phi-G\to 1``, so ``\nu_{jj}\to + \tilde\nu_{jj}/\hat v^{3}`` — the ``v^{-3}`` tail. +- **Sub-toggle:** I19/L23 use the full ``[\phi-G]/\hat v^3`` (Chandrasekhar, + ``\sim\hat v^{-2}`` at low ``\hat v``, needed for neoclassical fidelity); + Diss19/D21 use the pure ``\hat v^{-3}`` (a stronger low-``\hat v`` divergence). + In both, the divergence is *integrable* in the velocity moments (``\int + \hat v^4 e^{-\hat v^2}\nu\,d\hat v`` converges), but it makes naive quadrature + inaccurate — hence L23's analytic value (§7). + +## 4. The ``\nu_\star`` normalization + +The banana-regime collisionality is (docs/01 §2.3; L23 Eq. 2.3.40) + +```math +\nu_\star = \frac{\nu_{jj}\,Rq}{\varepsilon^{3/2} v_{th}}, +\qquad +\hat\nu_{jj} = \varepsilon^{3/2}\nu_\star\,\tilde\nu_{jj}(\hat v) , +``` + +i.e. ``\nu_\star`` is the ratio of the effective (``\varepsilon^{-1}`` +trapped-boundary-enhanced) collision rate to the bounce rate ``v_{th}/Rq``; +``\nu_\star\ll 1`` is the banana regime (O6). The ``\varepsilon^{3/2}`` collects +the trapped fraction ``\sim\sqrt\varepsilon`` and the effective-collisionality +boundary-layer width ``\sim\varepsilon`` (the ``\propto\nu^{1/2}`` layer of +docs/04 §2). The dimensionless drift-kinetic equation carries +``\hat\nu_{jj}=\varepsilon^{3/2}\nu_\star\tilde\nu_{jj}(\hat v)`` as the collision +coefficient. + +## 5. Species coupling (electron–ion) + +The electron operator ``C_{ei}`` (I19 Eq. 10) has the identical pitch-angle +structure with ``\nu_{ei}`` and a momentum-restoring term +``v_\parallel u_{\parallel i}/v_{the}^2\,F_{Me}`` that drags on the **ion** flow +``u_{\parallel i}`` — so electrons and ions are coupled through momentum +conservation (the flattened-electron closure, docs/01 §2.4, is a separate +derivation). At Level 0 the bulk operator is ``C_{ii}``; multi-species +field-particle coupling is Level 1 (`Collisions{FokkerPlanckMulti}`), plumbing +already present. + +## 6. The momentum-restoring term + +The second bracket of Eq. 9, ``v_\parallel\bar u_{\parallel j}F_{Mj}/v_{thj}^2``, +restores parallel momentum removed by the pitch-angle scattering (pure Lorentz +scattering is not momentum-conserving on its own). The restoring flow is +(I19 Eq. 12) + +```math +\bar u_{\parallel j}(f) = \frac{1}{n\langle\nu_{jj}\rangle_v}\int d^3v\, + \nu_{jj}\,v_\parallel f, +\qquad +\langle\nu_{jj}\rangle_v = \frac{8}{3\sqrt\pi}\int_0^\infty d\hat v\, + \hat v^4 e^{-\hat v^2}\,\nu_{jj}(\hat v), +``` + +with the velocity element ``\int d^3v = \pi B\sum_\sigma\int_0^\infty v^2 dv +\int_0^{B^{-1}} d\lambda/\sqrt{1-\lambda B}`` (I19 Eq. 13). This term is +structurally fixed here; its **magnitude** enters through +``\langle\nu_{jj}\rangle_v``, whose closed form is the deferred item §7. + +## 7. Deferred sub-items (flagged, not asserted) + +- **Analytic velocity average ``\langle\hat\nu_{ii}\rangle_u``.** The low-``\hat v`` + divergence of ``\tilde\nu`` (``\hat v^{-2}`` Chandrasekhar / ``\hat v^{-3}`` + reduced, §3) makes the momentum-restoring velocity integral of §6 poorly + behaved under naive quadrature; L23 Eq. 4.1.6 (p. 88) gives the closed form + ``\langle\hat\nu_{ii}\rangle_u = \tfrac{4\varepsilon^{3/2}\nu_\star}{3\sqrt\pi} + (\sqrt2-\ln(1+\sqrt2))``. **This specific constant is not derived here** — it + requires L23's exact reduced integrand and is a self-contained follow-up + derivation (policy rule 4: not presented as derived until it is). The + ``\sqrt2`` reflects the ion self-collision reduced mass; ``\ln(1+\sqrt2)= + \operatorname{arcsinh}(1)`` points to a ``\int d\hat v/\sqrt{1+\hat v^2}``-type + reduction. +- **Discretized diffusivity profile for `PitchAngleDiffusion`.** The mapping of + ``P(\lambda)=\lambda\sqrt{1-\lambda B}`` and the measure through the + orbit-average and onto the ``y=\lambda B_{\max}`` grid (with the ``\theta``- + and ``\hat v``-dependence) is numerics that ties to the already-green A4 + conservation gate; scoped with the L0 collisional solve. + +## 8. Cross-check table + +| Source | Form | Agrees with `[DERIVED]`? | +|---|---|---| +| I19 Eq. (9) (first-hand, print p. 4) | ``C_{jj}=2\nu_{jj}[\tfrac{\sqrt{1-\lambda B}}{B}\partial_\lambda(\lambda\sqrt{1-\lambda B}\partial_\lambda f)+\tfrac{v_\parallel\bar u_{\parallel j}}{v_{thj}^2}F_{Mj}]`` | ✅ operator structure + self-adjoint form + ``P=\lambda\sqrt{1-\lambda B}`` | +| I19 Eq. (11) (first-hand) | ``\nu_{jj}=\tilde\nu_{jj}[\phi(\hat v)-G(\hat v)]/\hat v^3`` | ✅ deflection frequency + limits | +| I19 Eqs. (12)–(13) (first-hand) | ``\bar u_{\parallel j}``, ``\langle\nu_{jj}\rangle_v``, ``\int d^3v`` | ✅ structure (constant deferred, §7) | +| L23 Eq. 2.3.40 | ``\nu_\star=\nu_{jj}Rq/(\varepsilon^{3/2}v_{th})`` | ✅ normalization | +| L23 Eq. 4.1.6 | ``\langle\hat\nu_{ii}\rangle_u=\tfrac{4\varepsilon^{3/2}\nu_\star}{3\sqrt\pi}(\sqrt2-\ln(1+\sqrt2))`` | ⏳ deferred (§7) | + +**Triage:** operator structure, deflection frequency, and normalization agree +with all first-hand sources — no discrepancy. The one number that carries a +specific closed form (``\langle\hat\nu_{ii}\rangle_u``) is honestly deferred to +its own derivation rather than transcribed. + +## 9. What sign-off authorizes + +On sign-off (recorded in docs/01 §2.3): the pitch-angle **diffusivity profile** +``P(\lambda)=\lambda\sqrt{1-\lambda B}`` and **measure** +``w=B/\sqrt{1-\lambda B}`` may be used to build the `PitchAngleDiffusion` +operator (via `conservative_pitch_operator`, preserving A4); the deflection +frequency ``\nu_{jj}(\hat v)=\tilde\nu[\phi-G]/\hat v^3`` (with the +``:chandrasekhar``/``:vcubed`` sub-toggle) and the ``\hat\nu=\varepsilon^{3/2} +\nu_\star\tilde\nu`` normalization populate the collision coefficient. The +momentum-restoring magnitude waits on ``\langle\hat\nu_{ii}\rangle_u`` (§7); the +electron closure constants (``k``, ``f_p``) are a separate derivation. diff --git a/docs/src/islands/derivations/delta-moment-prefactors.md b/docs/src/islands/derivations/delta-moment-prefactors.md new file mode 100644 index 000000000..de4016df9 --- /dev/null +++ b/docs/src/islands/derivations/delta-moment-prefactors.md @@ -0,0 +1,123 @@ +# Derivation — the ``\Delta_{\cos}``/``\Delta_{\sin}`` moment prefactors + +**Provenance:** `[DERIVED: 2026-07-11]` — independent derivation of the structure +plus a documented normalization pin (Decision D7). The sin-moment normalization +is explicitly a `[DERIVED]` choice (docs/01 §4), made here. +**Clears (on sign-off):** the ``\Delta_{\cos}``/``\Delta_{\sin}`` output-moment +prefactors ``\mp\mu_0 R/(2\tilde\psi)`` (`[CHECKED: Diss19 Eq. 4.12 for +\Delta_{\cos}; sin-normalization DERIVED]`, QUESTIONS Q4), building on the +already-cleared ``\tilde\psi`` (`island_flux_amplitude`). + +**Status:** ✅ **signed off 2026-07-11** (clearance recorded in docs/01 §4). +Implemented as `Coefficients.delta_moment_prefactors` (returns +`(cos=-μ₀R/2ψ̃, sin=+μ₀R/2ψ̃)`), feeding `Moments.delta_moments`. + +## 1. The two Ampère projections + +The Level-0 outputs are the resonant projections of the parallel current +``\bar J_\parallel(x,\xi)=\langle\sum_j e_j n_j u_{\parallel j}\rangle_\theta`` +through the island (docs/01 §4; O3: Ampère is a diagnostic, not solved). The +perturbed parallel Ampère law for the resonant helical harmonic, matched to the +outer-region tearing index ``\Delta'``, and the torque (rotation) condition are +(Diss19 Eqs. 2.9–2.10) + +```math +\frac{1}{\mu_0 R}\,\Delta'\,\tilde\psi = \int_{\mathbb R} d\psi\oint d\xi\; + \bar J_\parallel\cos\xi , +\qquad +0 = \int_{\mathbb R} d\psi\oint d\xi\;\bar J_\parallel\sin\xi . +``` + +The ``\cos\xi`` projection matches the growth drive to ``\Delta'``; the +``\sin\xi`` projection is the torque-balance (steady-rotation) condition. Both +are the ``m``-harmonic projections of ``\bar J_\parallel`` against the island +perturbation ``A_\parallel=-(\tilde\psi/R)\cos\xi`` (I19 Eq. 5). + +## 2. The growth moment ``\Delta_{\cos}\equiv\Delta_{\rm neo}`` + +The Modified Rutherford Equation is the amplitude equation for the island +half-width; its stationarity (marginal island) is the balance of the outer +drive ``\Delta'`` against the kinetic inner drive. Define the kinetic growth +moment ``\Delta_{\rm neo}`` so that **stationarity reads ``\Delta'+\Delta_{\rm +neo}=0``** (docs/01 §4; Diss19 Eq. 4.12): + +```math +\boxed{\; +\Delta_{\cos}\equiv\Delta_{\rm neo} + = -\frac{\mu_0 R}{2\tilde\psi}\int_{\mathbb R} d\psi\oint d\xi\; + \bar J_\parallel\cos\xi +\;} +``` + +The prefactor is fixed piece by piece — each piece sourced (the ``1/2`` adopted +from the Diss19 Eq. 4.12 convention, not re-derived from scratch): + +- ``\mu_0 R`` is the geometric factor of parallel Ampère (``\nabla^2 A_\parallel + = -\mu_0 J_\parallel``; the ``R`` from the helical/toroidal metric of I19 + Eq. 5's ``A_\parallel=-\tilde\psi\cos\xi/R``). +- ``\tilde\psi = \tfrac{w_\psi^2}{4}\,q_s'/q_s`` is the island flux amplitude — + **already cleared** (`island_flux_amplitude`, docs/01 §1); dividing by it makes + ``\Delta_{\rm neo}`` the drive *per unit island flux*, with units of ``\Delta'`` + (inverse length), as the MRE requires. +- The ``1/2`` is the Rutherford matching normalization: the ``\cos\xi`` + projection of the constant-``\psi`` perturbation carries the standard factor + that makes ``\Delta_{\rm neo}`` combine additively with ``\Delta'`` in the + amplitude equation (Diss19 Eq. 4.12 convention). + +## 3. The torque moment ``\Delta_{\sin}`` — the `[DERIVED]` symmetric pin + +The ``\sin\xi`` projection has no external ``\Delta'`` to match against (its +outer counterpart vanishes — the torque condition is ``0=\int\!\int\bar +J_\parallel\sin\xi``, §1). Its overall normalization is therefore a **choice**, +flagged `[DERIVED]` in docs/01 §4. **Pin it symmetrically to** ``\Delta_{\cos}`` +— same magnitude prefactor, opposite sign so that ``\Delta_{\cos}+i\Delta_{\sin}`` +forms the natural complex growth+torque moment that maps onto the linear-layer +``\Delta(Q)`` in the small-amplitude limit (docs/01 §4; ladder D1): + +```math +\boxed{\; +\Delta_{\sin} + = +\frac{\mu_0 R}{2\tilde\psi}\int_{\mathbb R} d\psi\oint d\xi\; + \bar J_\parallel\sin\xi +\;} +``` + +so that + +```math +\Delta_{\cos}+i\,\Delta_{\sin} + = \frac{\mu_0 R}{2\tilde\psi}\int d\psi\oint d\xi\; + \bar J_\parallel\,(-\cos\xi + i\sin\xi) . +``` + +The symmetric choice is what makes ``\Delta_{\sin}=0`` the clean torque-balance +root (Level-4 ``\omega_E`` closure) and the parity relations of ladder A3 +(``\Delta_{\cos}`` even / ``\Delta_{\sin}`` odd under ``\xi\to-\xi``) hold with +matched normalization — both already verified structurally (A3 green). This is a +`[DERIVED: 2026-07-11]` normalization *decision*, recorded as such (policy +rule 4), not a literature transcription. + +## 4. Cross-check table + +| Source | Form | Agrees with `[DERIVED]`? | +|---|---|---| +| Diss19 Eq. (2.9)–(2.10) (via docs/01 §4) | the ``\cos``/``\sin`` Ampère projections | ✅ structure (§1) | +| Diss19 Eq. (4.12) | ``\Delta_{\rm neo}=-\tfrac{\mu_0 R}{2\tilde\psi}\int\!\int\bar J_\parallel\cos\xi`` | ✅ (§2) | +| docs/01 §4 | ``\Delta_{\sin}`` normalization "chosen symmetric — [DERIVED] pin at implementation" | ✅ — pinned here (§3) | +| A3 gate (green) | ``\Delta_{\cos}`` even / ``\Delta_{\sin}`` odd | ✅ consistent with the matched-normalization pin | + +**Triage:** the ``\cos``-moment prefactor is derived structure + the cleared +``\tilde\psi`` + the standard Rutherford ``1/2`` (Diss19 4.12 convention); no +discrepancy. The ``\sin``-moment normalization is a `[DERIVED]` symmetric choice, +now pinned and recorded. + +## 5. What sign-off authorizes + +On sign-off (recorded in docs/01 §4): the prefactors +``\text{prefactor\_cos}=-\mu_0 R/(2\tilde\psi)`` and +``\text{prefactor\_sin}=+\mu_0 R/(2\tilde\psi)`` may be constructed +(``\tilde\psi`` from the cleared `island_flux_amplitude`, ``\mu_0 R`` from the +equilibrium) and passed to `Moments.delta_moments`, replacing its required gated +arguments. This closes the ``\Delta``-prefactor `[VERIFY]`/`[DERIVED]` items; the +channel decompositions (bootstrap/polarization split, ``\langle\cdot\rangle_\Omega``) +are already implemented structure. diff --git a/docs/src/islands/derivations/electron-closure.md b/docs/src/islands/derivations/electron-closure.md new file mode 100644 index 000000000..ccbe042a7 --- /dev/null +++ b/docs/src/islands/derivations/electron-closure.md @@ -0,0 +1,157 @@ +# Derivation — the flattened-electron closure ``h(\Omega)`` and its amplitude + +**Provenance:** `[DERIVED: 2026-07-11]` — independent re-derivation (Decision D7). +**Clears (on sign-off):** the flattened-electron profile function ``h(\Omega)`` +and its amplitude ``w_\psi/2\sqrt2`` (`[CHECKED: I19 Eqs. 14–22; WCHH96 §; L23 +§2.4]`, QUESTIONS Q3), and the closure ODE that ties to the already-green A7 +identity. +**Deferred (flagged, §6):** the Hirshman–Sigmar flow coefficient ``k\simeq +-1.173`` and the passing-fraction constant in ``f_p\simeq 1-1.46\sqrt\varepsilon`` +— specific neoclassical constants, each its own short derivation. + +**Status:** ✅ **signed off 2026-07-11** (clearance recorded in docs/01 §2.4) for +the ``h(\Omega)`` form and amplitude — implemented as `Coefficients.h_amplitude` +(``C=w_\psi/2\sqrt2``), feeding `Fields.h_profile`'s prefactor. The flow constants +``k`` and ``f_p`` (§6) remain **deferred / NaN-gated**. + +## 1. Setup + +At Level 0 electrons have ``\rho_{\theta e}\ll w`` (O7), so their drift islands +coincide with the magnetic island and their response is the analytic +flattened-electron (WCHH96) closure (docs/01 §2.4). The electron distribution is +(I19 Eq. 17, first-hand) + +```math +f_e = \Big(1-\frac{e\Phi}{T_e}\Big)F_{Mes} + h(\Omega)\,F'_{Mes} + - \frac{Iv_\parallel}{\omega_{ce}}F'_{Mes}\frac{\partial h}{\partial\psi} + + \bar h_e , +``` + +where ``h(\Omega)`` is the perturbed profile function — **constant on island +flux surfaces ``\Omega``** (the flattening), exactly flat inside the separatrix +(``\Omega<1``) and ``\to x`` far outside. This derivation determines ``h`` and +its amplitude. Convention (module CLAUDE.md): ``\Omega=2x^2/w^2-\cos\xi`` with +``w=w_\psi``, and the flux-surface average +``\langle f\rangle_\Omega=\oint f\,(\Omega+\cos\xi)^{-1/2}d\xi\,/\oint(\Omega+ +\cos\xi)^{-1/2}d\xi`` (I19 Eq. 21). + +## 2. The closure constraint determines ``h(\Omega)`` + +Flattening on ``\Omega`` surfaces with quasineutrality requires the +flux-surface-averaged radial curvature of ``h`` to vanish (I19; the unit target +``\langle\partial^2 h/\partial x^2\rangle_\Omega=0``, docs/01 §6, ladder A7). +Compute it for ``h=h(\Omega)``. With ``\partial\Omega/\partial x=4x/w^2`` and +``x^2=\tfrac{w^2}{2}(\Omega+\cos\xi)``, + +```math +\frac{\partial^2 h}{\partial x^2} + = h''(\Omega)\Big(\frac{4x}{w^2}\Big)^2 + h'(\Omega)\frac{4}{w^2} + = \frac{8}{w^2}h''(\Omega)(\Omega+\cos\xi) + \frac{4}{w^2}h'(\Omega). +``` + +Flux-averaging and setting to zero, + +```math +2\,h''(\Omega)\,\langle\Omega+\cos\xi\rangle_\Omega + h'(\Omega) = 0. \tag{$\star$} +``` + +Now relate ``\langle\Omega+\cos\xi\rangle_\Omega`` to the geometry function +``Q(\Omega)=\tfrac1{2\pi}\oint\sqrt{\Omega+\cos\xi}\,d\xi`` (I19 Eq. 18), whose +derivative is ``Q'(\Omega)=\tfrac1{4\pi}\oint(\Omega+\cos\xi)^{-1/2}d\xi``: + +```math +\langle\Omega+\cos\xi\rangle_\Omega + = \frac{\oint(\Omega+\cos\xi)^{1/2}d\xi}{\oint(\Omega+\cos\xi)^{-1/2}d\xi} + = \frac{2\pi Q}{4\pi Q'} = \frac{Q}{2Q'} . +``` + +Substituting into ``(\star)``: ``2h''\,\tfrac{Q}{2Q'}+h'=0``, i.e. +``\dfrac{h''}{h'}=-\dfrac{Q'}{Q}``, which integrates to + +```math +\boxed{\; h'(\Omega)=\frac{C}{Q(\Omega)},\qquad + h(\Omega)=\Theta(\Omega-1)\,C\!\int_1^\Omega\frac{d\Omega'}{Q(\Omega')} \;} +``` + +flat inside the separatrix (the ``\Theta(\Omega-1)``, since there is no +flattening gradient on the closed field lines within). This is I19 Eq. 18 up to +the amplitude ``C``. **The A7 identity ``\langle\partial^2h/\partial x^2\rangle_ +\Omega=0`` is exactly ``(\star)`` with ``h'=C/Q``** — the already-green A7 gate +*is* this closure constraint, verified for any ``C`` (`Fields.flat_average_d2h_dx2`). + +## 3. The amplitude ``C=w_\psi/2\sqrt2`` from far-field matching + +Far from the island (``\Omega\to\infty``) the flattened profile must match the +unperturbed radial coordinate, ``h\to x``. Large-``\Omega`` asymptotics: +``Q(\Omega)=\tfrac1{2\pi}\oint\sqrt{\Omega+\cos\xi}\,d\xi\to\sqrt\Omega``, so + +```math +h(\Omega)\to C\int^\Omega\frac{d\Omega'}{\sqrt{\Omega'}} = 2C\sqrt\Omega . +``` + +Meanwhile ``x=\tfrac{w}{\sqrt2}\sqrt{\Omega+\cos\xi}\to\tfrac{w}{\sqrt2}\sqrt\Omega`` +(from ``x^2=\tfrac{w^2}{2}(\Omega+\cos\xi)``). Matching ``h\to x``: + +```math +2C\sqrt\Omega = \frac{w}{\sqrt2}\sqrt\Omega +\quad\Longrightarrow\quad +\boxed{\; C = \frac{w}{2\sqrt2} = \frac{w_\psi}{2\sqrt2} \;} +``` + +exactly the I19 Eq. 18 amplitude. So both the *form* and the *amplitude* of +``h(\Omega)`` are fixed — the former by the flattening constraint, the latter by +far-field matching. + +## 4. Cross-check table (the derived parts) + +| Source | Form | Agrees with `[DERIVED]`? | +|---|---|---| +| I19 Eq. (18) (first-hand, print p. 4) | ``h=\Theta(\Omega-1)\tfrac{w_\psi}{2\sqrt2}\int_1^\Omega d\Omega'/Q``, ``Q=\tfrac1{2\pi}\oint\sqrt{\Omega+\cos\xi}d\xi`` | ✅ form (§2) + amplitude ``w_\psi/2\sqrt2`` (§3) | +| I19 §6 / L23 Eq. 4.1.1 | ``\langle\partial^2h/\partial x^2\rangle_\Omega=0`` | ✅ = the closure constraint ``(\star)`` (§2); already the green A7 gate | +| I19 Eq. (17) | ``f_e`` structure with ``h(\Omega)``, ``-Iv_\parallel/\omega_{ce}F'_{Mes}\partial h/\partial\psi`` | ✅ structure | + +**Triage:** the ``h(\Omega)`` form and amplitude agree first-hand; no discrepancy. + +## 5. The flux-surface-averaged electron flow (structure) + +The electron parallel flow that carries the bootstrap current is (I19 Eq. 22, +first-hand, print p. 5) + +```math +\frac{\langle\langle Bu_{\parallel e}\rangle_\theta\rangle_\Omega}{B_0 v_{the}} + = -\frac{f_t}{1+f_t}\frac{Iv_{the}}{\omega_{ce}}\frac{n'}{n} + \Big(1+\eta_e+\tfrac12 k f_c\eta_e\Big)\Big\langle\frac{\partial h}{\partial\psi}\Big\rangle_\Omega + + \frac{f_c}{1+f_t} + \frac{\langle\langle Bu_{\parallel i}\rangle_\theta\rangle_\Omega}{B_0 v_{thi}} , +``` + +with ``f_t`` the trapped fraction, ``f_c=1-f_t`` passing, ``\eta_e=L_n/L_{Te}``, +and ``k`` the Hirshman–Sigmar coefficient. The **structure** is derived here (it +follows from the parallel momentum balance with the pitch-angle operator §2 of +the collision derivation, and the ``h``-gradient drive of §2–3); the two +**numerical constants** are §6. Note the flow depends on the *numerically +computed ion flow* ``u_{\parallel i}`` — the closure is coupled, not one-way +(the `electrons = :flattened` vs `:kinetic` toggle, E4, is a separate study). + +## 6. Deferred constants (flagged, not asserted) + +- **``k\simeq -1.173`` (Hirshman–Sigmar).** A specific parallel-viscosity / + flow coefficient obtained by solving the Spitzer-problem moment hierarchy with + the pitch-angle operator; a self-contained constant, not derived here (policy + rule 4). L23 reproduces ``-1.1730`` as a unit test. +- **``f_p\simeq 1-1.46\sqrt\varepsilon`` (passing fraction).** The trapped + fraction ``f_t=1.46\sqrt\varepsilon`` at low ``\varepsilon`` follows from the + pitch-angle integral ``f_t=1-\tfrac34\langle B^2\rangle\int_0^{1/B_{\max}} + \lambda\,d\lambda/\langle\sqrt{1-\lambda B}\rangle_\theta`` in the + large-aspect-ratio limit; the specific ``1.46`` is its own short reduction and + is left deferred rather than asserted. + +## 7. What sign-off authorizes + +On sign-off (recorded in docs/01 §2.4): the ``h(\Omega)`` amplitude +``C=w_\psi/2\sqrt2`` clears `Fields.h_profile`'s `prefactor` (and hence +`ElectronClosure.h_prefactor`), with the `Q(\Omega)`/`h(\Omega)` machinery and +the A7 identity already implemented. The flow-relation *structure* (§5) is +cleared; its constants ``k`` and ``f_p`` (`ElectronClosure.k_HS`, `.f_p`) stay +NaN-gated pending their own derivations (§6). The quasineutrality closure +coefficient (``C_\phi``) is a separate derivation. diff --git a/docs/src/islands/derivations/gradient-drive.md b/docs/src/islands/derivations/gradient-drive.md new file mode 100644 index 000000000..50fd66a92 --- /dev/null +++ b/docs/src/islands/derivations/gradient-drive.md @@ -0,0 +1,98 @@ +# Derivation — the gradient drive (= the diamagnetic far-field boundary condition) + +**Provenance:** `[DERIVED: 2026-07-11]` — independent re-derivation (Decision D7) +of the Level-0 gradient drive, from the ion response of I19 (first-hand, +Eqs. 8, 23–32). +**Status:** ✅ **signed off 2026-07-11** — implemented as +`Configure.gradient_far_field` (the far-field `g_far`) with the Level-0 +`GradientDrive` source set to zero; a new `Level0Physics.eta_i`. + +> **Correction (2026-07-11).** An earlier draft of this chapter read I19 Eq. (29) +> as `p_φ(ω_si^T/ω_ci)(n'/n)F_Mi` and concluded the amplitude was bundled with the +> frame convention `C_dia`. A first-hand re-read (PDF p. 5) shows the ratio is +> **`ω_si^T/ω_si`** — a *dimensionless temperature correction*, not a frequency +> ratio. The drive is the standard neoclassical `p_φ F'_Mi` and needs **no frame +> convention**. The corrected result is below. + +## 1. The finding: the drive is a boundary condition, not an interior source + +I19 §4 splits the ion distribution (Eq. 28) as +`f_i = (1 − Ze\Phi/T_i)F_{Mis} + \bar G_0(p_\phi, \xi, v)`, and the orbit-averaged +non-adiabatic part is (Eq. **29**, first-hand p. 5) + +```math +\bar G_0 = p_\phi\,\frac{\omega_{si}^T}{\omega_{si}}\,\frac{n'}{n}\,F_{Mi} + \bar h_0 , +\qquad +\frac{\omega_{si}^T}{\omega_{si}} = 1 + \Big(\frac{v^2}{v_{thi}^2} - \tfrac32\Big)\eta_i , +``` + +\noindent +with `η_i = (T_i'/T_i)/(n'/n)`. The master orbit-averaged equation (I19 **Eq. 32**, +docs/01 §2) is **homogeneous** in `Ḡ₀` — transport `= ⟨(1/v̂_∥)Ĉ_ii(Ḡ₀)⟩_θ`, +**no interior source**. The inhomogeneity is imposed through the **far-field +boundary condition**: I19 seeks "a Maxwellian solution in the vicinity of the +island (the equilibrium profile assumed far away)" (§4), so `h̄₀ → 0` and + +```math +\boxed{\; +\bar G_0 \;\to\; g_{\rm drive} + \;=\; p_\phi\,\frac{\omega_{si}^T}{\omega_{si}}\,\frac{n'}{n}\,F_{Mi} + \;=\; p_\phi\,F'_{Mi} + \quad\text{as } |x| \to L_x . +\;} +``` + +The equality `g_drive = p_φ F'_{Mi}` follows from +`F'_{Mi} = (n'/n)(1+(v̂²−3/2)η_i)F_{Mi}` (differentiate the Maxwellian at fixed +`v`): the drive is the **standard neoclassical drive** — canonical momentum times +the equilibrium-gradient Maxwellian — recognizable and coefficient-free. + +**Consequence for the operator stack.** In I19's formulation the Level-0 +`Operators.GradientDrive` source is **zero**; the drive is the neoclassical +far-field state `Operators.FarFieldConditions.g_far`. This merges the two Q5 +"gated" items (`gradient_drive` and `far_field`) into one physical object. + +## 2. The normalized far-field (cleared, no frame convention) + +Far away `p_φ → ψ_s x` (the orbit-width term `Iv_∥/ω_ci = O(ρ̂_θi)` is a small +shift at `|x| = L_x`), and `n'/n = L̂_{n0}^{-1}/ψ_s`, so `p_φ(n'/n) → x L̂_{n0}^{-1}`. +The Maxwellian `F_{Mi} ∝ e^{-v̂^2} = e^{-E}` is carried by the energy-grid measure +(Gauss–Laguerre, `04 §1`; `velocity_moment!` sums against `e^{-E}`), so the code's +`g` is the distribution with that factor stripped. Hence + +```math +\boxed{\; +g_{\rm far}(x{=}\pm L_x,\,\xi,\,y,\,E,\,\sigma) + \;=\; x\;\hat L_{n0}^{-1}\;\big[\,1 + (E - \tfrac32)\,\eta_i\,\big] +\;} +``` + +\noindent +— linear in the boundary `x`, the temperature correction through `E = v̂²`, +independent of `ξ, y, σ` at leading order (the finite-orbit-width `σ`-dependence +is an interior effect near `x = 0`, not the far field). `L̂_{n0}^{-1} = inv_Ln0` +(existing input); `η_i = eta_i` is the only new parameter — a standard scenario +ratio (`= L_n/L_T`), **not a gated coefficient**. At Level 0 `ω_E = 0`, the +far-field potential is `Φ̂_far = 0`. + +## 3. Cross-check table + +| Source | Statement | Agrees with `[DERIVED]`? | +|---|---|---| +| I19 Eq. (29) (first-hand p. 5) | `Ḡ₀ = p_φ(ω_si^T/ω_si)(n'/n)F_Mi + h̄₀` | ✅ (§1) — **ω_si^T/ω_si**, not ω_ci | +| I19 Eq. (32) (first-hand p. 6) | master equation **homogeneous** in `Ḡ₀` | ✅ ⇒ drive is a BC, not a source (§1) | +| Maxwellian derivative | `F'_Mi = (n'/n)(1+(v̂²−3/2)η_i)F_Mi` | ✅ ⇒ `g_drive = p_φ F'_Mi` (§1) | +| design docs/01 §3 far-field spec | `g → neoclassical (no-island) solution` | ✅ = `g_drive` | + +**Triage:** no discrepancy. The drive is the neoclassical far field `p_φ F'_Mi` +and the interior source is zero (I19 Formulation A). The only new input is `η_i`; +no frame convention enters (the corrected reading of Eq. 29). + +## 4. What sign-off authorizes — now implemented + +On sign-off (recorded in docs/01 §2/§4): (i) `Operators.GradientDrive` source set +to zero (I19 Formulation A); (ii) the far-field +`Operators.FarFieldConditions.g_far = x·L̂_{n0}⁻¹·[1+(E−3/2)η_i]` (and `Φ̂_far = 0`) +built by `Configure.gradient_far_field` from `inv_Ln0` and the new `eta_i`. This +un-gates the `gradient_drive` **and** `far_field` families. The `E×B` coupling, +collision magnitude `⟨ν̂_ii⟩_u`, and orbit-averaged pitch measure remain gated. diff --git a/docs/src/islands/derivations/index.md b/docs/src/islands/derivations/index.md new file mode 100644 index 000000000..9d41f65a3 --- /dev/null +++ b/docs/src/islands/derivations/index.md @@ -0,0 +1,38 @@ +# Derivations + +Independent re-derivations of the Level-0 physics coefficients (Decision D7, +"re-derivation first"): each is marked `[DERIVED: date]`, carries a cross-check +table against the `[CHECKED]` literature transcriptions (docs/01), and flags +every discrepancy in the open (policy rule 4, docs/05 triage). **A derivation +authorizes a coefficient in `src/` only after human sign-off** (recorded in +docs/01, policy rule 3); until then the corresponding coefficient stays a gated, +supplied argument. + +These pages are *not* literature transcriptions — they derive the result from a +stated starting point and assumptions, then compare. That distinction is the +spine of the milestone (policy rule 4: never present a derivation as a +transcription or vice versa). + +## Chapters + +| Coefficient | Chapter | Status | +|---|---|---| +| Island flux amplitude ``\tilde\psi`` | [ψ̃ amplitude](psi-tilde-amplitude.md) | ✅ signed off 2026-07-11 | +| Magnetic drift frequency ``\hat\omega_D`` + `:original`/`:improved` toggle | [ω̂_D drift frequency](omega-D-drift-frequency.md) | ✅ signed off 2026-07-11 | +| Pitch-angle collision operator + deflection frequency + ``\nu_\star`` | [collision operator](collision-operator.md) | ✅ signed off 2026-07-11 (``\langle\hat\nu_{ii}\rangle_u`` deferred) | +| Flattened-electron closure ``h(\Omega)`` + amplitude | [electron closure](electron-closure.md) | ✅ signed off 2026-07-11 (``k``, ``f_p`` deferred) | +| Quasineutrality closure ``1/(2\hat L_{n0})`` (arbitrary ``\tau``) | [quasineutrality closure](quasineutrality-closure.md) | ✅ signed off 2026-07-11 | +| ``\Delta_{\cos}/\Delta_{\sin}`` moment prefactors ``\mp\mu_0 R/2\tilde\psi`` | [Δ-moment prefactors](delta-moment-prefactors.md) | ✅ signed off 2026-07-11 | +| Parallel (island) streaming ``a_\xi``, ``a_x`` (advection along ``\Omega``) | [parallel streaming](parallel-streaming.md) | ✅ signed off 2026-07-11 | +| Gradient drive = the diamagnetic far-field BC (I19 Eq. 29) | [gradient drive](gradient-drive.md) | ✅ signed off 2026-07-11 | +| Passing fraction ``f_p \simeq 1-1.46\sqrt\varepsilon`` (electron closure) | [passing fraction](passing-fraction.md) | ✅ signed off 2026-07-11 | + +The six main Q3/Q4 coefficient families are signed off, plus the parallel +streaming and the passing fraction ``f_p`` (`Coefficients.passing_fraction`). +The gradient drive's *structure* is found (it is the far-field BC, not a source); +its normalized amplitude is bundled with the frame convention and awaits +completion. Of the **deferred numerical sub-constants**, ``f_p`` is now cleared; +the remaining two — ``\langle\hat\nu_{ii}\rangle_u`` (collision) and the +Hirshman–Sigmar ``k`` (electron closure) — are escalated in `QUESTIONS.md` +Q3/Q5 (each needs its specific source integrand) rather than derived +speculatively. diff --git a/docs/src/islands/derivations/omega-D-drift-frequency.md b/docs/src/islands/derivations/omega-D-drift-frequency.md new file mode 100644 index 000000000..3f01a56ea --- /dev/null +++ b/docs/src/islands/derivations/omega-D-drift-frequency.md @@ -0,0 +1,226 @@ +# Derivation — the orbit-averaged magnetic drift frequency ``\hat\omega_D`` and the `:original`/`:improved` toggle + +**Provenance:** `[DERIVED: 2026-07-11]` — independent re-derivation (Decision D7). +**Clears (on sign-off):** the ``\hat\omega_D`` expression and the +`:original`/`:improved` ``\hat L_B^{-1}`` toggle (`[CHECKED: I19 Eq. 32 def.; +D21 Eqs. 15, B1; D21 Eq. A2, p. 16]`, QUESTIONS Q3). This toggle is the single +highest-impact physics item — it is the ``\hat\omega_D`` term that produces the +``\sim\!\times 6`` threshold-width differential between the two drift models +(the reproducible T2 form of the sources' ``8.73 \to 1.46\,\rho_{bi}`` story; +Decision D9, docs/05). + +**Status:** ✅ **signed off 2026-07-11** (clearance recorded in docs/01 §2.1). +Implemented as `Coefficients.magnetic_drift_frequency` (both variants); it builds +`MagneticDrift.c_D` on the phase-space grid. + +## 1. What ``\hat\omega_D`` is + +In the orbit-averaged (4D) Level-0 kinetic equation (docs/01 §2, I19 Eq. 32 — +verified first-hand, print p. 6), the ``\partial\bar G_0/\partial\xi`` +coefficient collects three transport channels, + +```math +-m\Big[\underbrace{\tfrac{\hat p}{\hat L_q}\,\Theta(y_c-y)}_{\text{streaming}} + + \underbrace{\hat\rho_{\theta i}\,\hat\omega_D}_{\text{magnetic drift}} + - \underbrace{\tfrac{\hat\rho_{\theta i}}{2}\big\langle \tfrac{1}{\hat v_\parallel}\tfrac{\partial\hat\Phi}{\partial x}\big\rangle_\theta}_{E\times B} + \Big]\frac{\partial\bar G_0}{\partial\xi} , +``` + +so ``\hat\rho_{\theta i}\,\hat\omega_D`` is the **orbit-averaged magnetic-drift +advection of the distribution in the helical angle ``\xi``**. This derivation +computes it from the guiding-centre magnetic drift (I19 Eq. 8, +``\mathbf v_b = -v_\parallel\,\mathbf b\times\nabla(v_\parallel/\omega_{cj})``) +and the conserved canonical momentum, in the large-aspect-ratio circular +geometry (orderings O1–O5). + +Normalizations (docs/01 §5, I19 p. 6, verified first-hand): ``x=(\psi-\psi_s)/\psi_s``, +``y=\lambda B_{\max}``, ``\hat v=v/v_{thi}``, ``b=B/B_{\max}=(1-\varepsilon\cos\theta)/(1+\varepsilon)``, +``\hat L_q^{-1}=(\psi_s/q_s)\,dq/d\psi|_s``, ``\hat L_B^{-1}=(\psi_s/B)\,\partial B/\partial\psi``, +``\hat\rho_{\theta i}=\rho_{\theta i}/r_s``, ``\sigma=\mathrm{sgn}(v_\parallel)``, +``v_\parallel=\sigma v\sqrt{1-\lambda B}=\sigma v\sqrt{1-yb}``. The orbit average +``\langle\cdot\rangle_\theta`` is ``\tfrac{1}{2\pi}\oint d\theta`` (passing) or +``\tfrac{1}{2\pi}\sum_\sigma\int_{-\theta_b}^{\theta_b} d\theta`` (trapped), at +fixed ``p_\phi`` (I19 Eq. 31). + +## 2. The drift-orbit radial width (from ``p_\phi`` conservation) + +The toroidal canonical momentum ``p_\phi = (\psi-\psi_s) - I v_\parallel/\omega_{cj}`` +(I19 Eq. 2) is conserved along orbits, so a particle's flux-surface label +oscillates about its orbit centre by the **drift-orbit width** + +```math +x - \hat p = \frac{\psi-\psi_s-p_\phi}{\psi_s} + = \frac{I\,v_\parallel}{\omega_{cj}\,\psi_s} . +``` + +Evaluate it in the normalized variables. With ``I=RB_\phi``, +``\omega_{cj}=e_jB/m_j``, ``v_\parallel=\sigma v\sqrt{1-yb}``, +``RB_\phi/B \simeq R_0/b`` (large aspect ratio, ``B=B_{\max}b``, +``RB_\phi=I\simeq R_0 B_{\phi0}``), and ``\psi_s\simeq r_s R_0 B_\theta`` +(``d\psi/dr=RB_\theta``), together with the poloidal gyroradius +``\rho_{\theta i}=v_{thi}m_i/(e_iB_\theta)``: + +```math +\boxed{\; +x_D(\theta;y,\hat v,\sigma) \equiv x-\hat p + = \hat\rho_{\theta i}\,\frac{\sigma\hat v}{1+\varepsilon}\,\frac{\sqrt{1-yb}}{b} +\;} +``` + +(the ``1+\varepsilon`` from ``B_{\max}=B_{\phi0}(1+\varepsilon)``). This is the +finite ion orbit width — the physical heart of the DK-NTM: guiding centres do +not sit on a flux surface but on a surface shifted by ``x_D``, and this shift is +``\sigma``-, ``y``-, and ``\hat v``-dependent (docs/01 §2.2, the "drift island"). + +## 3. Term 1 — the shear-coupled piece (``1/\hat L_q``) + +The equilibrium term ``\langle 1-q/q_s\rangle_\theta`` of the orbit-averaged +equation (I19 Eq. 31) carries the shear. Expanding ``q`` about ``\psi_s``, + +```math +1 - \frac{q(\psi)}{q_s} = -\frac{q_s'}{q_s}(\psi-\psi_s) + \mathcal O((\psi-\psi_s)^2) + = -\frac{x}{\hat L_q}, +``` + +and orbit-averaging at fixed ``\hat p`` using ``x = \hat p + x_D(\theta)`` (§2): + +```math +\big\langle 1-q/q_s\big\rangle_\theta + = -\frac{\hat p}{\hat L_q}\,\Theta(y_c-y)\;-\;\frac{1}{\hat L_q}\big\langle x_D\big\rangle_\theta . +``` + +The first piece is parallel streaming (nonzero only for passing particles, +``\Theta(y_c-y)``). The second is a **magnetic-drift** contribution — the shear +acting on the finite orbit width — and with ``x_D`` from §2 it is + +```math +-\frac{1}{\hat L_q}\big\langle x_D\big\rangle_\theta + = -\,\hat\rho_{\theta i}\,\frac{\sigma\hat v}{1+\varepsilon}\, + \frac{1}{\hat L_q}\Big\langle \frac{\sqrt{1-yb}}{b}\Big\rangle_\theta . +``` + +## 4. Term 2 — the grad-``B`` piece (``1/\hat L_B``) + +The explicit magnetic-drift term of I19 Eq. 31 is +``-\langle I\,\partial(v_\parallel/\omega_{cj})/\partial\psi\rangle_\theta``. +With ``v_\parallel/\omega_{cj} = (\sigma v m_j/e_j)\sqrt{1-\lambda B}/B`` and +``\partial/\partial\psi`` acting on ``B`` (fixed ``\lambda,v``), + +```math +\frac{\partial}{\partial\psi}\!\left(\frac{\sqrt{1-\lambda B}}{B}\right) + = -\,\frac{\partial B}{\partial\psi}\,\frac{2-\lambda B}{2B^2\sqrt{1-\lambda B}} , +``` + +since ``\dfrac{d}{dB}\!\dfrac{\sqrt{1-\lambda B}}{B} + = -\dfrac{2-\lambda B}{2B^2\sqrt{1-\lambda B}}``. Multiplying by ``-I`` and +normalizing (``\lambda B = yb``, ``\hat L_B^{-1}=(\psi_s/B)\,\partial B/\partial\psi``) +gives the second magnetic-drift contribution, + +```math +-\Big\langle I\,\frac{\partial}{\partial\psi}\frac{v_\parallel}{\omega_{cj}}\Big\rangle_\theta + = -\,\frac{\hat\rho_{\theta i}}{2}\,\frac{\sigma\hat v}{1+\varepsilon} + \Big\langle \frac{1}{\hat L_B}\,\frac{2-yb}{b\sqrt{1-yb}}\Big\rangle_\theta . +``` + +## 5. Result + +Collect the two magnetic-drift contributions. The ``\partial\bar G_0/\partial\xi`` +coefficient of §1 carries the equilibrium term with an **overall minus sign**, +``-m\langle 1-q/q_s\rangle_\theta`` (this is what makes the streaming piece +``+\hat p/\hat L_q``, §3); so the §3 second piece enters ``\hat\rho_{\theta i} +\hat\omega_D`` as ``-\big(-\tfrac1{\hat L_q}\langle x_D\rangle\big) += +\tfrac1{\hat L_q}\langle x_D\rangle``, while the §4 grad-``B`` term (already +written with its sign) adds directly: + +```math +\hat\rho_{\theta i}\hat\omega_D + = +\frac{1}{\hat L_q}\big\langle x_D\big\rangle_\theta + \;-\;\frac{\hat\rho_{\theta i}}{2}\frac{\sigma\hat v}{1+\varepsilon} + \Big\langle\frac{1}{\hat L_B}\frac{2-yb}{b\sqrt{1-yb}}\Big\rangle_\theta . +``` + +Substituting ``\langle x_D\rangle`` from §2 and dividing by +``\hat\rho_{\theta i}``: + +```math +\boxed{\; +\hat\omega_D = \frac{\sigma\hat v}{1+\varepsilon} + \left[\;\frac{1}{\hat L_q}\Big\langle\frac{\sqrt{1-yb}}{b}\Big\rangle_\theta + \;-\;\frac{1}{2}\Big\langle\frac{1}{\hat L_B}\,\frac{2-yb}{b\sqrt{1-yb}}\Big\rangle_\theta\;\right] +\;} +``` + +matching I19 Eq. (32) (first-hand) and D21 Eq. (B1) term-for-term (§7). The two +terms are the shear-coupled orbit-width precession (``1/\hat L_q``) and the +grad-``B`` drift (``1/\hat L_B``). + +## 6. The `:original` / `:improved` toggle — treatment of ``\partial B/\partial\psi`` + +The toggle is entirely in ``\hat L_B^{-1}=(\psi_s/B)\,\partial B/\partial\psi``. +In the model circular equilibrium ``R=R_0(1+\varepsilon\cos\theta)``, the radial +field gradient is (D21 App. A, verified first-hand, print p. 16) + +```math +\frac{\partial B}{\partial\psi} = \frac{I'}{R} - \frac{I}{R^2}\frac{\partial R}{\partial\psi}, +\qquad I' \sim R^2 p'/I \ \Rightarrow\ \frac{I'}{R}\sim \frac{\beta}{r^2}\ (\text{low }\beta,\ \text{drop}), +``` + +so, keeping the geometric term with ``\partial R/\partial\psi = \cos\theta/(R_0B_\theta)``, + +```math +\frac{\partial B}{\partial\psi} + = -\,\frac{B_\phi}{R_0^2 B_\theta}\cos\theta + \mathcal O(\varepsilon^2/r^2) +\qquad\text{(D21 Eq. A2).} +``` + +The field gradient is **``\propto\cos\theta``** — odd about the outboard +midplane. This is the crux: + +- **`:original`** (I19 / DK-NTM): treat ``\hat L_B^{-1}`` as a *finite constant* + (the ``\cos\theta`` structure not resolved). The ``1/\hat L_B`` bracket in + ``\hat\omega_D`` survives orbit-averaging with an ``\mathcal O(1)`` value. +- **`:improved`** (D21 / RDK-NTM): keep the ``\cos\theta``. In the ``1/\hat L_B`` + bracket the remaining factor ``(2-yb)/(b\sqrt{1-yb})`` is even in ``\theta`` + and ``\mathcal O(1)`` over a passing orbit, so + ``\langle\cos\theta\times(\text{even})\rangle_\theta = \mathcal O(\varepsilon)``: + the grad-``B`` term is ``\varepsilon``-small after orbit averaging. The + **documented proxy is ``\hat L_B^{-1}=0``** (D21 footnote; its Fig. 8 compares + the proxy against the full ``\cos\theta`` form directly). + +**Physical consequence (T2 gate, docs/05 D9).** Dropping the ``1/\hat L_B`` term +removes a drift contribution that, in `:original`, partially cancels the shear +term over the trapped/barely-passing population; its removal changes the +drift-island structure and lowers the threshold half-width by a factor of order +several. Islands measures this **within the code** as the `:original → :improved` +``w_c`` ratio (the reproducible form of the sources' ``8.73 \to 1.46\,\rho_{bi}`` +absolute pair, which is T4/audit-gated). The absolute numbers are *not* the gate. + +## 7. Cross-check table + +| Source | Form | Agrees with `[DERIVED]`? | +|---|---|---| +| I19 Eq. (32) (first-hand, print p. 6) | ``\hat\omega_D=\tfrac{\sigma\hat v}{1+\varepsilon}[\tfrac1{\hat L_q}\langle\tfrac{\sqrt{1-yb}}{b}\rangle-\tfrac12\langle\tfrac1{\hat L_B}\tfrac{2-yb}{b\sqrt{1-yb}}\rangle]`` | ✅ exact | +| D21 Eq. (B1) (first-hand, print p. 16) | ``\hat\omega_D=-\tfrac{\hat w}{\hat L_q}\langle\hat V_\parallel\rangle+\langle\tfrac{B^2}{B_\phi^2}\tfrac{\hat w}{\hat L_B}[\hat V_\parallel+\tfrac{\lambda\hat V^2}{2\hat V_\parallel}]B\rangle`` | ✅ (D21 ``w``-normalization; ``\hat V_\parallel+\tfrac{\lambda\hat V^2}{2\hat V_\parallel}=\hat V\tfrac{2-\lambda B}{2\sqrt{1-\lambda B}}`` = the same ``(2-yb)/\sqrt{1-yb}`` structure) | +| D21 Eq. (A2) (first-hand, print p. 16) | ``\partial B/\partial\psi=-(B_\phi/R_0^2B_\theta)\cos\theta+\mathcal O(\varepsilon^2)`` | ✅ (basis of the `:improved` proxy, §6) | +| Diss19 §2 / docs/01 §2.1 | same two-term ``\hat\omega_D``; `:original`/`:improved` toggle | ✅ | + +**Triage:** all first-hand sources agree; no discrepancy (unlike the ψ̃ typo). +The one *modelling choice* is the `:improved` ``\hat L_B^{-1}=0`` **proxy** — +D21 documents it as a proxy for the ``\cos\theta`` form (accurate to +``\mathcal O(\varepsilon)``), not an identity. Islands should therefore carry +**both**: `:original` (finite ``\hat L_B^{-1}``) and `:improved` +(``\hat L_B^{-1}=0``), exactly as the `MagneticDrift.variant` field already +provides — the toggle *is* the deliverable. + +## 8. What sign-off authorizes + +On human sign-off (recorded in docs/01 §2.1), the coefficient array +`MagneticDrift.c_D` may be constructed from the boxed ``\hat\omega_D`` of §5: +``c_D = -m\,\hat\rho_{\theta i}\,\hat\omega_D`` evaluated on the ``(y,E,\sigma)`` +grid, with the ``\hat L_B^{-1}`` treatment selected by +`MagneticDrift.variant` (`:original` → finite ``\hat L_B^{-1}``; `:improved` +→ ``\hat L_B^{-1}=0``). The orbit averages ``\langle\cdot\rangle_\theta`` are +evaluated with the existing quadrature machinery; the ``\hat L_q``, ``\hat L_B``, +``\varepsilon`` inputs come from the parameter vector (`Frames.Level0Parameters` ++ the equilibrium). Nothing here authorizes the collision, closure, or E×B +coefficients. diff --git a/docs/src/islands/derivations/parallel-streaming.md b/docs/src/islands/derivations/parallel-streaming.md new file mode 100644 index 000000000..1b31e1ba6 --- /dev/null +++ b/docs/src/islands/derivations/parallel-streaming.md @@ -0,0 +1,124 @@ +# Derivation — the parallel (island) streaming coefficients + +**Provenance:** `[DERIVED: 2026-07-11]` — independent re-derivation (Decision D7) +of the island-induced parallel-streaming channel of the master orbit-averaged +drift-kinetic equation. +**Clears:** the `Operators.ParallelStreaming` coefficients `a_xi`, `a_x` +(`[VERIFY: I19 Eq. (32) streaming term, with the L23 §2.6 amendments]`, +QUESTIONS Q5), building on the already-cleared `ω̂_D` +(`magnetic_drift_frequency`) whose normalization it must match. +**Status:** ✅ **signed off 2026-07-11** — implemented as +`Configure.streaming_coefficients` and wired into `Operators.ParallelStreaming`; +the `{Ω, g}` advection structure (§3) is verified in +`test/runtests_islands_configure.jl`. + +## 1. The channel in the master equation + +The Level-0 master equation is I19 Eq. (32) (docs/01 §2), the orbit-averaged DKE +for `Ḡ₀(p̂, ξ, y; v̂, σ)`: + +```math +-m\Big[\underbrace{\tfrac{p̂}{\hat L_q}\Theta(y_c-y)}_{\text{streaming}} + + \hat\rho_{\theta i}\,\hat\omega_D + - \tfrac{\hat\rho_{\theta i}}{2}\big\langle\tfrac1{\hat v_\parallel}\partial_x\hat\Phi\big\rangle_\theta\Big] + \partial_\xi \bar G_0 ++ m\Big[\underbrace{\tfrac{\hat w^2}{4\hat L_q}\sin\xi\,\Theta(y_c-y)}_{\text{streaming}} + - \tfrac{\hat\rho_{\theta i}}{2}\big\langle\tfrac1{\hat v_\parallel}\partial_\xi\hat\Phi\big\rangle_\theta\Big] + \partial_{p̂} \bar G_0 += \big\langle\tfrac1{\hat v_\parallel}\hat C_{ii}(\bar G_0)\big\rangle_\theta . +``` + +\noindent +The **island-streaming** channel is the two braced terms — the equilibrium +magnetic shear (`p̂/L̂_q`) driving the `∂_ξ` transit and the island radial field +(`ŵ²/4L̂_q · sinξ`, from `A_∥ = −(ψ̃/R)cosξ` → `B̃_r ∝ sinξ`) driving the `∂_{p̂}` +advection. `Θ(y_c-y)` restricts it to **passing** particles (`y < y_c = 1`): +trapped particles bounce and carry no net parallel transit, so they do not stream +along the island (I19 Eq. 32; L23 §2.6). The other braced terms are the drift +(`ω̂_D`, cleared) and the `E×B` channels (gated). + +To leading order in the ``\Delta = w/r`` ordering the canonical momentum equals +the radial coordinate, ``p̂ = x - I\hat v_\parallel/\omega_c + \dots \to x`` (the +orbit-width correction is `O(ρ̂_θi)`), so `∂_{p̂} → ∂_x` and `p̂ → x` in the +operator, whose solve coordinate is `x` (docs/03 §2, Decision D1). + +## 2. Normalization — matched to the cleared ``\hat\omega_D`` + +The operator residual sums `a_xi ∂_ξ g + c_D ∂_ξ g + …` and `a_x ∂_x g + …`, so +every coefficient must share one normalization. `c_D` is **already cleared** as +`c_D = ω̂_D` (`magnetic_drift_frequency`), which pins the normalization: divide +the whole master equation by `−m ρ̂_θi`. The drift term then reads +`(−m ρ̂_θi ω̂_D)/(−m ρ̂_θi) = ω̂_D = c_D` ✅ (unchanged), and the streaming terms +become + +```math +a_\xi = \frac{-m\,(x/\hat L_q)\,\Theta}{-m\,\hat\rho_{\theta i}} + = \frac{\hat L_q^{-1}\,x}{\hat\rho_{\theta i}}\,\Theta(y_c-y), +\qquad +a_x = \frac{+m\,(\hat w^2/4\hat L_q)\sin\xi\,\Theta}{-m\,\hat\rho_{\theta i}} + = -\frac{\hat L_q^{-1}\,\hat w^2\,\sin\xi}{4\,\hat\rho_{\theta i}}\,\Theta(y_c-y) . +``` + +\noindent +`m` cancels; `ρ̂_θi` (normalized ion poloidal gyroradius, `~ ŵ` by the O2 +ordering) survives as a genuine parameter because fixing `c_D = ω̂_D` referred the +whole equation to the drift scale. `ŵ = w_ψ` is the island half-width in `Ω` +(same `w` as `Ω = 2x²/ŵ² − cosξ` and the `ĥ` amplitude, `electron-closure.md §3`). + +```math +\boxed{\; +a_\xi = \frac{\hat L_q^{-1}}{\hat\rho_{\theta i}}\,x\,\Theta(y_c-y), +\qquad +a_x = -\frac{\hat L_q^{-1}\hat w^2}{4\,\hat\rho_{\theta i}}\,\sin\xi\,\Theta(y_c-y) +\;} +``` + +## 3. The consistency check — streaming *is* advection along `Ω` + +Parallel streaming must advect `g` along the island flux surfaces `Ω = const`; +that advection is the Poisson bracket `\{\Omega, g\} = \partial_x\Omega\,\partial_\xi g - \partial_\xi\Omega\,\partial_x g`. With `Ω = 2x²/ŵ² − cosξ`, +`∂_xΩ = 4x/ŵ²` and `∂_ξΩ = sinξ`, so + +```math +\{\Omega,g\} = \frac{4x}{\hat w^2}\,\partial_\xi g - \sin\xi\,\partial_x g . +``` + +The derived coefficients factor **exactly** into this bracket: + +```math +a_\xi\,\partial_\xi g + a_x\,\partial_x g + = \frac{\hat L_q^{-1}\hat w^2}{4\,\hat\rho_{\theta i}}\,\Theta(y_c-y) + \Big[\frac{4x}{\hat w^2}\partial_\xi g - \sin\xi\,\partial_x g\Big] + = \frac{\hat L_q^{-1}\hat w^2}{4\,\hat\rho_{\theta i}}\,\Theta(y_c-y)\,\{\Omega,g\} . +``` + +\noindent +So the island-streaming operator is `(L̂_q⁻¹ ŵ²/4ρ̂_θi)Θ · {Ω, ·}` — pure +flux-surface advection, vanishing on `Ω`-contours (`{Ω,Ω}=0`) exactly as parallel +streaming must. This is a coefficient-free structural check that pins the relative +sign and magnitude of `a_ξ` and `a_x` with no freedom — the derivation's own +proof. + +## 4. Cross-check table + +| Source / check | Statement | Agrees with `[DERIVED]`? | +|---|---|---| +| I19 Eq. (32) streaming braces (docs/01 §2) | `(x/L̂_q)Θ ∂_ξ`, `(ŵ²/4L̂_q)sinξ Θ ∂_{p̂}` | ✅ structure (§1) | +| cleared `c_D = ω̂_D` normalization | divide by `−m ρ̂_θi` ⇒ `c_D` unchanged | ✅ (§2) | +| Poisson-bracket advection `{Ω, g}` | streaming `∝ {Ω, ·}`, `=0` on `Ω`-contours | ✅ **exact** (§3) | +| passing/trapped split `Θ(y_c−y)` | trapped carry no net transit | ✅ (§1) | + +**Triage:** no discrepancy. The `{Ω, g}` factorization (§3) is a hard internal +check that leaves no coefficient freedom; the only supplied scale is the physical +parameter `ρ̂_θi`. + +## 5. What sign-off authorizes + +On sign-off (recorded in docs/01 §2): `Configure` may build, on the phase-space +grid, `a_ξ = (inv_Lq/ρ̂_θi)·x·Θ(y_c−y)` and +`a_x = −(inv_Lq·ŵ²/4ρ̂_θi)·sinξ·Θ(y_c−y)` (with `ŵ = w_psi`, `ρ̂_θi` a new +`Level0Physics` field, `Θ` from `y < 1`) and wire them into +`Operators.ParallelStreaming`, replacing the gated `a_xi`/`a_x`. `c_D` is +unchanged (the normalization was chosen to keep it `= ω̂_D`). This un-gates the +`:streaming` family (QUESTIONS Q5). The `E×B`, gradient drive, collision +magnitude, pitch measure, and far field remain gated. diff --git a/docs/src/islands/derivations/passing-fraction.md b/docs/src/islands/derivations/passing-fraction.md new file mode 100644 index 000000000..7776fb416 --- /dev/null +++ b/docs/src/islands/derivations/passing-fraction.md @@ -0,0 +1,121 @@ +# Derivation — the passing fraction ``f_p \simeq 1 - 1.46\sqrt\varepsilon`` + +**Provenance:** `[DERIVED: 2026-07-11]` — independent derivation of the +electron-closure passing-fraction constant (Decision D7), one of the deferred +sub-constants of the flattened-electron closure (QUESTIONS Q3/Q5). +**Status:** ✅ **signed off 2026-07-11** — implemented as +`Coefficients.passing_fraction(ε) = 1 − 1.4624√ε`, which may populate +`Fields.ElectronClosure.f_p`. This chapter derives the constant and cross-checks +it numerically (§3); the reviewer accepted the `1.4624 ≈ 1.46` match (§4). The +companion Hirshman–Sigmar `k` remains gated. + +## 1. What `f_p` is + +The flattened-electron closure (docs/01 §2.4; `electron-closure.md`) carries the +**passing (circulating) particle fraction** ``f_p`` of a large-aspect-ratio +circular flux surface — the velocity-space fraction of electrons that complete a +poloidal circuit rather than mirror-trap in the outboard well. +The sources quote ``f_p \simeq 1 - 1.46\sqrt\varepsilon`` `[CHECKED: I19 +Eq. (22); L23 Eqs. 2.5.5–2.5.8]`, i.e. ``f_p = 1 - f_t`` with the effective +trapped fraction ``f_t \simeq 1.46\sqrt\varepsilon``. +This chapter derives ``f_t`` and hence ``f_p``. + +## 2. The effective trapped-fraction integral + +Use the model field modulation pinned in `Coefficients.jl` (docs/01 §1), + +```math +b(\theta) \;=\; \frac{B(\theta)}{B_{\max}} \;=\; \frac{1-\varepsilon\cos\theta}{1+\varepsilon}, +\qquad b\in[b_{\min},\,1],\quad b(\pi)=1 , +``` + +\noindent +so ``B_{\max}`` sits on the inboard side ``\theta=\pi``. +A particle of pitch ``\lambda = \mu B_{\max}/E`` is trapped when ``\lambda b(\theta)=1`` somewhere on the surface, i.e. for ``\lambda\in(1,\,1/b_{\min})``; it is passing for ``\lambda<1``. +The neoclassical **effective** trapped fraction is the pitch-space average +(Lin-Liu & Miller; Wesson, *Tokamaks*) + +```math +f_t \;=\; 1 - \frac{3}{4}\,\langle b^2\rangle + \int_0^{1}\frac{\lambda\,d\lambda}{\big\langle\sqrt{1-\lambda b}\,\big\rangle}, +\qquad +\langle\cdot\rangle \equiv \frac{1}{2\pi}\oint d\theta , +``` + +\noindent +the same flux-surface average ``\langle\cdot\rangle_\theta`` the drift brackets +use (`omega-D-drift-frequency.md`). + +## 3. The ``\varepsilon\to0`` limit and the ``\sqrt\varepsilon`` coefficient + +At ``\varepsilon=0`` the field is uniform (``b\equiv1``, ``\langle b^2\rangle=1``), and + +```math +\int_0^1\frac{\lambda\,d\lambda}{\sqrt{1-\lambda}} + \;=\; B(2,\tfrac12) \;=\; \frac{\Gamma(2)\,\Gamma(\tfrac12)}{\Gamma(\tfrac52)} + \;=\; \frac{4}{3}, +\qquad\Rightarrow\qquad +f_t(0) = 1-\tfrac34\cdot1\cdot\tfrac43 = 0 , +``` + +\noindent +every particle circulates when there is no well — the correct zeroth order. +The leading correction is ``O(\sqrt\varepsilon)``, not ``O(\varepsilon)``: +it comes from the boundary layer near ``\lambda=1``, where ``1-\lambda b(\theta)`` +vanishes over part of the circuit (the barely-passing/barely-trapped particles), +so ``\langle\sqrt{1-\lambda b}\rangle`` acquires a ``\sqrt{\varepsilon}``-scale +behaviour. +Writing ``f_t = c_1\sqrt\varepsilon + O(\varepsilon)``, the coefficient ``c_1`` +is the ``\varepsilon\to0`` limit of ``f_t/\sqrt\varepsilon``. + +Evaluating the integral numerically in this convention (QuadGK, the same +machinery as the cleared brackets) gives a clean limit: + +| ``\varepsilon`` | ``f_t`` | ``f_t/\sqrt\varepsilon`` | +|---|---|---| +| ``10^{-2}`` | ``0.145171`` | ``1.451714`` | +| ``10^{-3}`` | ``0.046214`` | ``1.461401`` | +| ``10^{-4}`` | ``0.014623`` | ``1.462324`` | +| ``10^{-5}`` | ``0.0046246`` | ``1.462415`` | + +\noindent +so ``c_1 = 1.4624\ldots``, and + +```math +\boxed{\; +f_p \;=\; 1 - f_t \;=\; 1 - c_1\sqrt\varepsilon + \;\simeq\; 1 - 1.46\,\sqrt\varepsilon +\;} +``` + +\noindent +The derived leading coefficient ``1.4624`` matches the sources' quoted ``1.46`` +to three significant figures — the quote is the rounded asymptotic constant, not +a distinct number. + +## 4. Cross-check table and the open sign-off item + +| Source | Form | Agrees with `[DERIVED]`? | +|---|---|---| +| I19 Eq. (22) / L23 §2.5 (via docs/01 §2.4) | ``f_p \simeq 1-1.46\sqrt\varepsilon`` | ✅ to 3 s.f. (``c_1=1.4624``) | +| ``\varepsilon\to0`` analytic limit | ``f_t(0)=0`` (all-passing), ``B(2,\tfrac12)=\tfrac43`` | ✅ exact (§3) | +| numerical asymptotics (this chapter) | ``c_1 = 1.4624\ldots`` | ✅ converged | + +**Open for the reviewer (sign-off gate):** this derivation uses the standard +Lin-Liu–Miller *effective* trapped fraction. Confirm that I19 Eq. (22) / +L23 §2.5 define ``f_p`` by this same effective fraction (and not a bare +pitch-boundary fraction, which carries a slightly different ``O(\sqrt\varepsilon)`` +coefficient) before clearing. The ``0.16\%`` gap between ``1.4624`` and the +quoted ``1.46`` is consistent with rounding, but a definition mismatch would show +up here — hence the gate. + +## 5. What sign-off would authorize + +On sign-off (to be recorded in docs/01 §2.4): `f_p = 1 - 1.4624·√ε` (or the +source's exact constant, if the reviewer pins it) may replace the `NaN`-gated +`Fields.ElectronClosure.f_p`. Until then `f_p` stays gated (QUESTIONS Q3/Q5) and +this chapter remains a **draft**, not a clearance. The companion deferred +constants ``\langle\hat\nu_{ii}\rangle_u`` (collision) and the Hirshman–Sigmar +``k`` (parallel flow) are **not** drafted here — each needs its specific source +integrand (L23 Eq. 4.1.6; the parallel-viscosity moment problem) read in detail, +and are left escalated in QUESTIONS Q3/Q5 rather than derived speculatively. diff --git a/docs/src/islands/derivations/psi-tilde-amplitude.md b/docs/src/islands/derivations/psi-tilde-amplitude.md new file mode 100644 index 000000000..981b04236 --- /dev/null +++ b/docs/src/islands/derivations/psi-tilde-amplitude.md @@ -0,0 +1,202 @@ +# Derivation — the island flux amplitude ``\tilde\psi`` + +**Provenance:** `[DERIVED: 2026-07-11]` — independent re-derivation (Decision D7). +**Resolves:** the open `[VERIFY]` on ``\tilde\psi`` (QUESTIONS Q4, docs/01 §1): +is the amplitude ``\tilde\psi = \tfrac{w_\psi^2}{4}\,\tfrac{q_s'}{q_s}`` or +``\tfrac{w_\psi^2}{4}\,\tfrac{q_s}{q_s'}``? **First-hand check of I19 (2026-07-11): +Imada 2019 as printed (print p. 3, text following Eq. 6) shows the second form, +``\tfrac{w_\psi^2}{4}\,\tfrac{q_s}{q_s'}``.** This derivation shows that form is +a typo in the published paper — the physical amplitude is ``q_s'/q_s`` — by three +independent arguments, including I19's *own* internally-inconsistent ``\Omega`` +convention (its Eq. 7). + +**Status:** ✅ **signed off 2026-07-11** (clearance recorded in docs/01 §1). The +relation is implemented as `Moments.island_flux_amplitude`; the +``\Delta_{\cos}/\Delta_{\sin}`` moment *prefactors* ``\mp\mu_0 R/(2\tilde\psi)`` +remain caller-supplied because the ``\mu_0 R`` normalization and the sin-moment +normalization pin (docs/01 §4) are separate, still-open items. + +## 1. Setup and orderings + +Level-0 configuration (docs/01 §1, orderings O1–O3): a single-helicity, +constant-``\psi`` magnetic island of helicity ``(m, n)`` at the rational surface +``\psi = \psi_s`` where ``q(\psi_s) = m/n``. Coordinates: ``\psi`` the poloidal +flux, ``\theta`` poloidal angle, ``\phi`` toroidal angle, and the helical angle + +```math +\xi = m\theta - n\phi , +``` + +equivalent to the docs/01 form ``\xi = m(\theta - \phi/q_s)`` since +``m/q_s = n``. The island is prescribed and fixed (O3): ``\tilde\psi`` is an +input amplitude, not solved. The task is purely the **island geometry** — how +``\tilde\psi`` relates to the island half-width ``w_\psi`` — so no kinetics +enter. + +The perturbation is given in vector-potential form (docs/01 §1): + +```math +A_\parallel = -\frac{\tilde\psi}{R}\cos\xi , +``` + +where ``\tilde\psi`` is the amplitude of the perturbed **helical flux** (the +factor ``1/R`` is the metric relation between the parallel vector potential and +the poloidal-flux-like amplitude; ``A_\parallel`` has dimensions of +flux/length, so ``\tilde\psi`` has dimensions of poloidal flux — used in §5). + +## 2. The equilibrium helical flux and its curvature + +Define the **helical flux** as the poloidal flux minus the resonant fraction of +the toroidal flux ``\psi_{\rm tor}`` (with ``d\psi_{\rm tor}/d\psi = q``): + +```math +\chi_0(\psi) \;=\; \psi \;-\; \frac{1}{q_s}\,\psi_{\rm tor}(\psi), +\qquad +\frac{d\chi_0}{d\psi} \;=\; 1 - \frac{q(\psi)}{q_s}. +``` + +This is the natural flux for the ``(m, n)`` resonance: its contours are the +equilibrium field lines *projected into the helical frame* (a field line has +``d\xi/d\theta = m - n\,q(\psi)``, which vanishes at ``\psi_s``), and + +```math +\left.\frac{d\chi_0}{d\psi}\right|_{\psi_s} = 1 - \frac{q_s}{q_s} = 0 , +``` + +so ``\chi_0`` is **stationary at the rational surface** — the defining property +of a resonant flux. Its curvature there is the load-bearing quantity: + +```math +\boxed{\; +\chi_0''(\psi_s) \;=\; \frac{d}{d\psi}\!\left(1 - \frac{q}{q_s}\right)_{\psi_s} + \;=\; -\,\frac{q_s'}{q_s} +\;} +\qquad (q_s' \equiv dq/d\psi|_{\psi_s}). +``` + +## 3. The constant-``\psi`` island and its half-width + +Add the single-helicity perturbation of constant amplitude (the constant-``\psi`` +approximation, O3) to the equilibrium helical flux and expand about ``\psi_s`` +using ``\chi_0'(\psi_s)=0``, with ``x \equiv \psi - \psi_s``: + +```math +\chi(x, \xi) \;=\; \chi_0(\psi_s) \;+\; \tfrac{1}{2}\,\chi_0''(\psi_s)\,x^2 + \;+\; \tilde\psi\,\cos\xi . +``` + +The contours of ``\chi`` are the perturbed field lines; they form an island. +The two stationary points on ``x = 0`` are ``(x,\xi) = (0, 0)`` and ``(0, \pi)`` +— one the O-point (elliptic), one the X-point (hyperbolic), their roles set by +the signs of ``\chi_0''`` and ``\tilde\psi``. The **separatrix** is the contour +through the X-point. Writing ``\chi_X`` for its value and evaluating the +separatrix contour at the O-point's poloidal angle gives the maximum radial +excursion ``x_{\rm sep}``; for either sign assignment, + +```math +\tfrac{1}{2}\,|\chi_0''|\,x_{\rm sep}^2 = 2\,|\tilde\psi| +\quad\Longrightarrow\quad +x_{\rm sep} = 2\sqrt{\frac{|\tilde\psi|}{|\chi_0''|}} . +``` + +By definition ``w_\psi`` is the island **half-width** in ``\psi``-space (the +maximum excursion of the separatrix from ``\psi_s``, docs/01 §1), so +``w_\psi = x_{\rm sep}`` and + +```math +w_\psi = 2\sqrt{\frac{|\tilde\psi|}{|\chi_0''|}} +\quad\Longleftrightarrow\quad +|\tilde\psi| = \frac{w_\psi^2}{4}\,|\chi_0''| . +``` + +## 4. Result + +Substituting ``|\chi_0''| = q_s'/q_s`` from §2: + +```math +\boxed{\; +\tilde\psi \;=\; \frac{w_\psi^2}{4}\,\frac{q_s'}{q_s} +\;} +``` + +(with ``\tilde\psi \ge 0``, ``w_\psi`` the half-width, and ``q_s'/q_s`` taken as +its magnitude; the sign is fixed by the pinned ``\Omega`` convention of §5). The +alternative ``\tfrac{w_\psi^2}{4}\,\tfrac{q_s}{q_s'}`` is **excluded** — see the +two independent checks below. + +## 5. Cross-checks + +**(a) Dimensional necessity (kills the ``q_s/q_s'`` form).** ``\chi_0`` is a +flux, so ``\chi_0'' = d^2\chi_0/d\psi^2`` has dimensions ``[\psi]^{-1}``. +``w_\psi`` is a half-width in ``\psi``-space, so ``w_\psi^2 \sim [\psi]^2``, and +``\tilde\psi`` is a flux (``\sim [\psi]``, from ``A_\parallel = -(\tilde\psi/R) +\cos\xi``, §1). Check each candidate: + +| candidate | dimensions | verdict | +|---|---|---| +| ``\tfrac{q_s'}{q_s}`` | ``[\psi]^{-1}`` (``q'/q``, since ``q'\sim[\psi]^{-1}``, ``q\sim 1``) | ``\tfrac{w_\psi^2}{4}\tfrac{q_s'}{q_s}\sim[\psi]^2[\psi]^{-1}=[\psi]`` ✓ = ``[\tilde\psi]`` | +| ``\tfrac{q_s}{q_s'}`` | ``[\psi]`` | ``\tfrac{w_\psi^2}{4}\tfrac{q_s}{q_s'}\sim[\psi]^3`` ✗ | + +Only ``q_s'/q_s`` gives ``\tilde\psi`` the dimensions of a flux. The +``q_s/q_s'`` form is dimensionally impossible. + +**(b) Consistency with the pinned island label ``\Omega``.** docs/01 §1 pins +(``[CHECKED: I19 Eq. 7; Diss19 Eq. 2.7; L23 Eq. 2.1.8]``) + +```math +\Omega(x,\xi) = \frac{2(\psi-\psi_s)^2}{w_\psi^2} - \cos\xi, +\qquad \Omega = -1 \ (\text{O-point}),\ \ \Omega = +1 \ (\text{separatrix}). +``` + +Take the island-supporting branch ``\chi_0'' > 0`` with the O-point at +``\xi = \pi`` (equivalently, absorb the signs into the orientation of ``\xi`` and +the sign of ``\tilde\psi`` — this is the convention the ``\Omega`` label *fixes*, +not an independent assumption). Normalizing the helical flux of §3 by +``\tilde\psi`` and using the §4 result ``|\chi_0''|/\tilde\psi = 4/w_\psi^2``: + +```math +\frac{\chi - \chi_0(\psi_s)}{\tilde\psi} += \frac{|\chi_0''|}{2\tilde\psi}\,x^2 - \cos\xi += \frac{2x^2}{w_\psi^2} - \cos\xi += \Omega . +``` + +(The bare substitution gives ``(\chi_0''/2\tilde\psi)x^2 + \cos\xi``; the O-point +at ``\Omega=-1`` requires the ``\cos\xi`` term negative, which is exactly the +sign convention just stated — the ``w_\psi^2/4`` magnitude coefficient, the +load-bearing result, is the same on either branch.) The island label ``\Omega`` +**is** the ``\tilde\psi``-normalized helical flux, +reproducing the pinned convention exactly (including the factor of 2 and the +O-point/separatrix values). This both fixes the sign convention and confirms the +``w_\psi^2/4`` coefficient self-consistently. + +## 6. Cross-check table against the [CHECKED] transcriptions + +| Source | Transcribed form (docs/01 §1) | Agrees with `[DERIVED]`? | +|---|---|---| +| Diss19 p. 30 | ``\tilde\psi = \tfrac{w_\psi^2}{4}\,q_s'/q_s`` | ✅ | +| D21 / L23 Eq. (2.1.4) | ``q_s'/q_s`` | ✅ | +| Ω convention (I19 Eq. 7; Diss19 Eq. 2.7; L23 Eq. 2.1.8) | ``\Omega = 2(\psi-\psi_s)^2/w_\psi^2 - \cos\xi`` | ✅ (reproduced in §5b) | +| I19 as printed (p. 3, text after Eq. 6) — **first-hand, 2026-07-11** | ``\tilde\psi = \tfrac{w_\psi^2}{4}\,q_s/q_s'`` | ❌ dimensionally impossible (§5a); typo | +| I19 Eq. (7) — its own ``\Omega`` convention (first-hand) | ``\Omega = 2(\psi-\psi_s)^2/w_\psi^2 - \cos\xi`` | ✅ — and this *requires* ``q_s'/q_s`` (§5b), so I19 is internally inconsistent | + +**Triage (docs/05 rule 3) — resolved:** the physics is unambiguous +(``q_s'/q_s``), agrees with Diss19/D21/L23 and with I19's own ``\Omega`` +convention (Eq. 7), and the alternative is dimensionally impossible. I19 as +printed shows ``q_s/q_s'`` in the amplitude text, but its Eq. (7) ``\Omega`` +requires ``q_s'/q_s`` — so **I19 is internally inconsistent, and the amplitude +text is a published typo** (triage outcome: *their published-equation error*, +the standing docs/05 York-lineage rule; the same class as the L23 §2.6 +amendments). Note I19 defines its shear length ``L_q = q\,(dq/dr)^{-1} = q/q'`` +(p. 2), consistent with ``q'/q`` structure. **The `[VERIFY]` is closed: use +``q_s'/q_s``.** + +## 7. What sign-off authorizes + +On human sign-off (recorded in docs/01 with paper/equation/date, policy rule 3), +the ``\tilde\psi`` amplitude +``\tilde\psi = \tfrac{w_\psi^2}{4}\,(q_s'/q_s)`` may be used to construct the +``\Delta_{\cos}/\Delta_{\sin}`` moment prefactors ``\mp\mu_0 R/(2\tilde\psi)`` +(`Moments.delta_moments`), replacing the currently-gated supplied argument. The +sin-moment normalization pin (docs/01 §4, a separate `[DERIVED]` item) is **not** +covered here. diff --git a/docs/src/islands/derivations/quasineutrality-closure.md b/docs/src/islands/derivations/quasineutrality-closure.md new file mode 100644 index 000000000..87e8a7d36 --- /dev/null +++ b/docs/src/islands/derivations/quasineutrality-closure.md @@ -0,0 +1,136 @@ +# Derivation — the Level-0 quasineutrality closure + +**Provenance:** `[DERIVED: 2026-07-11]` — independent re-derivation (Decision D7). +**Clears (on sign-off):** the Level-0 quasineutrality relation and its closure +coefficient ``1/(2\hat L_{n0})`` (`[CHECKED: I19 Eq. A.11; L23 Eq. 2.4.14; +Picard form Diss19 Eq. 2.45]`, QUESTIONS Q3), plus the arbitrary-``\tau`` +generalization docs/01 §3 asks for. + +**Status:** ✅ **signed off 2026-07-11** (clearance recorded in docs/01 §3), +after an independent triple-check of the δn normalization — implemented as +`Coefficients.quasineutrality_coefficient(τ)` (``= τ/(τ+1)``). The code uses the +raw-moment form, so I19's `δn_i` normalization is a cross-check nuance only. + +## 1. Setup + +At Level 0 the only field equation is quasineutrality, ``n_i[\Phi;g_i] = +n_e[\Phi;\text{closure}]`` (docs/01 §3; Ampère is a diagnostic, O3). The two +species' densities are the velocity moments of their responses (I19 Eqs. 17, 23, +first-hand). This derivation takes those responses and solves ``n_i=n_e`` for +``\Phi``, deriving the closure coefficient. Normalizations (docs/01 §5): +``x=(\psi-\psi_s)/\psi_s``, ``\hat L_{n0}^{-1}=(\psi_s/n_0)\,dn_0/d\psi``, +``\hat\Phi=e_i\Phi/T_i``, ``\tau=T_e/T_i``, ``\hat h=h/\psi_s`` (so ``\hat h\to x`` +far away, electron-closure derivation §3). + +## 2. The species densities + +**Ions** (I19 Eq. 23): ``f_i=(1-\tfrac{e_i\Phi}{T_i})F_{Mis}+(\psi-\psi_s)F'_{Mis} ++g_i``. The velocity moment (``e_i=+e``, ``Z_i=1``): + +```math +n_i = n_0\Big(1-\frac{e\Phi}{T_i}\Big) + n_0'\,(\psi-\psi_s) + \delta\bar n_i, +\qquad \delta\bar n_i=\int g_i\,d^3v , +``` + +using ``\int F_{Mis}d^3v=n_0`` and ``\int F'_{Mis}d^3v=n_0'=dn_0/d\psi``. + +**Electrons** (flattened closure, electron-closure derivation; I19 Eq. 17): +``f_e=(1-\tfrac{e_e\Phi}{T_e})F_{Mes}+h(\Omega)F'_{Mes}-\tfrac{Iv_\parallel}{\omega_{ce}} +F'_{Mes}\partial_\psi h+\bar h_e``. The ``v_\parallel``-odd term vanishes in the +density moment; with ``e_e=-e`` and the leading closure (``\bar h_e`` higher +order), + +```math +n_e = n_0\Big(1+\frac{e\Phi}{T_e}\Big) + n_0'\,h(\Omega) . +``` + +The ``n_0' h(\Omega)`` term **is** the electron density perturbation carried by +the flattening — the flattened electrons pile up/deplete along ``\Omega`` surfaces +exactly as ``h`` prescribes. + +## 3. Quasineutrality → the closure + +Impose ``n_i=n_e``. The equilibrium ``n_0`` cancels: + +```math +-\,n_0 e\Phi\Big(\frac{1}{T_i}+\frac{1}{T_e}\Big) + = n_0'\big[h(\Omega)-(\psi-\psi_s)\big] - \delta\bar n_i . +``` + +The ``(1/T_i+1/T_e)`` is the **sum of the ion and electron adiabatic responses** +— both species shield the potential — and is the origin of the closure +denominator. Solving for ``\hat\Phi=e\Phi/T_i`` and using +``T_i(1/T_i+1/T_e)=1+T_i/T_e=(\tau+1)/\tau``: + +```math +\boxed{\; +\hat\Phi = \frac{\tau}{\tau+1}\, + \Big[\, \frac{\delta\bar n_i}{n_0} + \hat L_{n0}^{-1}\big(x-\hat h(\Omega)\big) \,\Big] +\;} +``` + +using ``n_0'(\psi-\psi_s)/n_0=\hat L_{n0}^{-1}x`` and +``n_0'h/n_0=\hat L_{n0}^{-1}\hat h``. + +**At ``\tau=1``** (the sources' ``T_e=T_i``), ``\tau/(\tau+1)=1/2``: + +```math +\hat\Phi = \frac{1}{2}\Big[\frac{\delta\bar n_i}{n_0}+\hat L_{n0}^{-1}(x-\hat h)\Big] + = \frac{1}{2\hat L_{n0}}\Big[\underbrace{\hat L_{n0}\,\frac{\delta\bar n_i}{n_0}}_{\equiv\,\delta n_i/n_0} + + x - \hat h\Big], +``` + +**exactly I19 Eq. A.11**, ``\hat\Phi=[\delta n_i/n_0+x-\hat h]/(2\hat L_{n0})``, +provided I19's normalized ion perturbation is +``\delta n_i/n_0\equiv\hat L_{n0}\,\delta\bar n_i/n_0``. This is a +**normalization convention, not a discrepancy**, and it is *physically forced*: +the raw kinetic moment ``\delta\bar n_i=\int g_i\,d^3v`` is gradient-**driven** +(``g_i`` is sourced by the Maxwellian-gradient terms of the drift-kinetic +equation, so ``\delta\bar n_i\propto\hat L_{n0}^{-1}`` already), so I19 factors +that common gradient out for a uniform bracket. The physically-invariant +statement is the **boxed general-``\tau`` form of §3 with the raw moment +``\delta\bar n_i``**, whose ``x-\hat h`` piece and coefficient match I19 exactly. + +**Implementation note (removes any convention ambiguity from `src`):** the code +uses the **raw-moment form** — ``\delta\bar n_i`` is the actual velocity moment +``M[g_i]`` that `velocity_moment!` already computes — so `Operators.Quasineutrality` +never references I19's ``\delta n_i`` normalization. The un-reverified I19 scaling +affects only the *cross-check reading* of A.11, not what is built. + +## 4. Kinetic-electron (Picard) form + +When electrons are solved kinetically (E4 toggle, not the flattened closure), the +same quasineutrality reads ``\delta\hat\Phi=(\delta\hat n_i-\delta\hat n_e)/2`` +(Diss19 Eq. 2.45) — the ``\hat h`` term is replaced by the kinetic electron +density perturbation ``\delta\hat n_e``. In Islands both are one residual block +inside the global Newton system (`Operators.Quasineutrality`); the sources' +nested Picard loop is what Newton–Krylov replaces (docs/01 §3). + +## 5. Cross-check table + +| Source | Form | Agrees with `[DERIVED]`? | +|---|---|---| +| I19 Eq. (A.11) (first-hand, print p. 11) | ``e_i\Phi/T_i=[\delta n_i/n_0+x-\hat h(\Omega)]/(2\hat L_{n0})`` | ✅ (``\tau=1`` limit of §3; ``\delta n_i`` normalization convention noted) | +| L23 Eq. (2.4.14) | same closed form | ✅ | +| Diss19 Eq. (2.45) | Picard form ``\delta\hat\Phi=(\delta\hat n_i-\delta\hat n_e)/2`` | ✅ (§4) | +| docs/01 §3 (τ general) | "keep ``\tau=T_e/T_i`` general and flag departures" | ✅ — the boxed ``\tau/(\tau+1)`` form delivers it | + +**Triage:** no discrepancy. The one subtlety (the ``\delta n_i`` normalization) +is a definitional convention, resolved in §3; the derivation additionally +supplies the arbitrary-``\tau`` generalization the sources omit +(they assume ``T_e=T_i``). + +## 6. What sign-off authorizes — now implemented (2026-07-11) + +On sign-off (recorded in docs/01 §3): the closure coefficient +``\tau/(\tau+1)`` (``\to 1/2`` at ``\tau=1``) and the ``\hat L_{n0}^{-1}(x-\hat h)`` +structure populate `Operators.Quasineutrality`'s residual. **Implemented:** the +field residual is ``R_\Phi = M[g]-\alpha\hat\Phi + S`` with +``\alpha = (\tau+1)/\tau`` (the reciprocal of the closure coefficient — solving +the boxed relation for the residual root; `Configure.configure_level0` builds it +as `1/quasineutrality_coefficient(τ)`) and the drive +``S = \hat L_{n0}^{-1}(x-\hat h(\Omega))`` from `Configure.quasineutrality_source` +(the ``\hat h`` amplitude ``w/2\sqrt2`` from the cleared `Coefficients.h_amplitude`, +the profile from `Fields.h_profile`). The moment machinery (`velocity_moment!`) +and the ``\hat h``/``Q`` functions were already implemented; nothing here +authorizes the ``\Delta`` prefactors (a separate, also-cleared derivation). diff --git a/docs/src/islands/design/00-roadmap.md b/docs/src/islands/design/00-roadmap.md new file mode 100644 index 000000000..13708023c --- /dev/null +++ b/docs/src/islands/design/00-roadmap.md @@ -0,0 +1,306 @@ +# 00 — Roadmap: the Level structure + +Each Level relaxes specific orderings of the Imada 2019 / Dudkovskaia / Leigh +lineage (reference library: docs/08). The code never branches on "which level" +— levels are *configurations* of the operator stack (docs/03), so any +intermediate combination of toggles is legal. A Level is "done" when: (i) its +verification gate below is green (docs/05), (ii) the Physics Book chapters +covering its equations are complete and [VERIFY]-cleared, and (iii) its +manuscript in the paper series is submission-ready, with every claim backed by +a ladder ID and every figure regenerable from archived data (docs/07). Papers +I–VI map to gates as defined in docs/07 §3; each level *starts* by writing the +paper outline (the figure contract), not ends with it. + +--- + +## Level 0 — DK-NTM reproduction (the benchmark configuration) + +**Orderings retained** (the I19/L23 set): +- O1. Large aspect ratio ε ≪ 1, circular concentric surfaces, low β. +- O2. Radially local: w ≪ r_s, constant background gradients across the domain. +- O3. Prescribed island: single-harmonic, constant-ψ, fixed w (half-width + convention, docs/01 §1). No Ampère solve. +- O4. Fixed equilibrium-E_r parameter ω_E (≡ −ω₀, the island propagation + frequency in the zero-E_r frame; docs/01 §5). The published York + thresholds sit at ω_E = 0; Islands treats ω_E as a scanned input from day + one (D23b already does), because Δ_pol ∝ ω_E² with a sign reversal near + −0.89 ω_dia,e makes single-ω_E polarization values misleading. +- O5. Timescale ordering ω, ω_*, ω_D ≪ ω_bounce (orbit-averaged leading order + at fixed p_φ → 4D). +- O6. Momentum-conserving pitch-angle collision model, banana regime ν_★ ≪ 1 + (exact operator + the energy-dependence sub-toggle: docs/01 §2.3). +- O7. Ions drift-kinetic; electrons via the WCHH96 analytic closure (flattened + h(Ω) profile + coupled parallel-flow relation, docs/01 §2.4), with + `electrons = :kinetic` (the RDK-NTM treatment) available as the E4 + toggle. +- O8. No perpendicular transport operator (w_d physics external). +- O9. Maxwellian backgrounds, single bulk ion species *in the physics* — but the + species list is a first-class array from day one (docs/02). Multi-species + *plumbing* is a Level 0 requirement even though multi-species *physics* + is Level 1+. + +**Critical architectural decision made at Level 0 even though it only pays at +Level 3:** discretize in (x, ξ), not island coordinates Ω or drift-surface +coordinates S. Island/drift coordinates presuppose a separatrix and cannot +represent shielded linear states; (x, ξ) representation makes shielding, +penetration, and saturated islands points on one solution manifold. Island +flux-surface averages are *diagnostics*. The RDK S-coordinate solve path +exists as a cross-check mode (its full coefficient set is published: Diss19 +Eqs. D.60–D.62, D23b Eq. 19 + App. A), never as the primary representation. + +**Prior-art baseline to beat (new since the original plan):** kokuchou (L23) +is a direct 4D implementation of this exact level and documents where it +breaks: ν_★ floor 5×10⁻³ and ŵ ceiling 0.75 ρ̂_θi set by memory + separatrix +resolution, Picard non-convergence, a singular trapped-passing matching +matrix, and a spurious solution branch from Neumann far-field BCs (docs/04 +§§2–3, 6). Islands' architecture (matrix-free Newton–Krylov, adaptive +layer-packed grids, neoclassical-matching BCs) is chosen point-by-point +against that failure list. Getting *below* kokuchou's ν_★ floor while matching +its thresholds is the headline Level-0 numerics deliverable. + +**Outputs:** Δ_cos(w, ω_E; p), Δ_sin(w, ω_E; p); flux-surface profiles (n, T, +Φ, flows) across the island; J_∥(x, ξ) with species/channel partitions. + +**Gate** (tiered per Decision D9 — the gate is the tiered B-ladder green, not +absolute-number matches; docs/05 "Target tiers"): (i) large-w *scalings* +Δ_bs+Δ_cur ∝ 1/w and Δ_pol ∝ 1/w³ (B2, T3), the WCHH96 Eq. (85) coefficient +audit-gated; (ii) the drift-model **toggle differential** — the ~×6 w_c shift +:original → :improved measured within Islands (B5b, T2, the robust form of the +sources' 8.73 → 1.46 ρ_bi story) — plus threshold *existence* at w_c ~ O(ρ_θi) +and kokuchou's dw_c/dν_★ > 0 trend (B5a/B5c, T3); the absolute w_c values +(2.76/0.45 ρ_θi, the 0.440… fit surface) are T4, reportable only with input +manifests; (iii) separatrix-layer polarization structure and the *existence and +ω_E²-scaling* of the Δ_pol(ω_E) sign reversal (B4, B6, T3), its −0.89 location +T4. + +--- + +## Level 1 — Arbitrary collisionality + multi-species collisions + +**Relaxes:** O6. + +- Full linearized multi-species Fokker–Planck operator (momentum- and + energy-conserving; field-particle terms between all species pairs). + Bootstrap physics is unforgiving about non-conservative collision operators — + this is a correctness requirement, not a nicety. +- Brute-force numerical resolution of the trapped-passing dissipation layer and + the separatrix layer at arbitrary ν (both widths ∝ ν^{1/2} at low ν, with the + E×B-dominated regime where the layer width tracks the iterating potential — + docs/04 §2). These are the layers RDK-NTM handles analytically and that set + kokuchou's operating floor; Level 1 owns beating them numerically. +- **Tungsten physics lands here** (docs/02 §W): mixed-regime neoclassics (W in + Pfirsch–Schlüter/plateau while bulk ions are banana), ion–impurity friction + modification of the bootstrap drive, Z_eff effect on electron channel. + Deliverable: predicted in-island impurity density asymmetries, comparable to + AUG/DIII-D W-accumulation measurements. + +**Gate:** (i) in the no-island limit, bootstrap current and multi-species +neoclassical flows vs. NEO/NCLASS across ν_★ (this doubles as the single most +powerful global correctness check of the velocity-space discretization); +(ii) Sauter coefficients recovered in the large-w limit across banana–plateau–PS; +(iii) smooth connection of Level-0 low-ν results to collisional regimes, +including closing the gap between kokuchou's ν_★ ≥ 5×10⁻³ window and +RDK-NTM's ν_★ ≤ 10⁻³ window — the two prior codes never overlapped cleanly +(ladder B7). + +--- + +## Level 2 — General geometry, drop orbit averaging, energetic particles + +**Relaxes:** O1, O5, O2 (partially), O9 (backgrounds). + +- Retain poloidal angle: the kinetic problem becomes 5D (x, ξ, θ; λ, E). +- Geometry arrives in two steps: **Miller analytic parametrization first** + (κ, δ, s_κ, s_δ, Shafranov shift — exactly D23a's geometry, giving direct + benchmark access to its shaping results), then equilibrium ingested from the + same numerical representations DCON/GPEC use (docs/03 §interfaces); shaped, + finite-β (the finite-β drift terms are the D23a Eq. 28–31 extension). + Analytic circular remains a regression toggle. +- Widened, potentially nonlocal radial domain (several ρ_θα), with background + profile *variation* across the domain as a toggle (relaxing strict locality). +- **Energetic particles land here** (docs/02 §EP): slowing-down F₀ (touches drive + terms and collision drag), alpha finite-orbit-width nonlocality (the drift-island + shift is no longer perturbative), and the precession resonance ω ~ ω_D,α — a + collisionless polarization-type contribution to Δ with no fluid analog. Highest + physics novelty in the program for burning plasmas (ITER/SPARC NTM thresholds + with self-consistent alpha kinetics). +- Because alphas and W are trace in density, their response solves are *linear in + the trace species* given the bulk fields — cheap post-processing passes with an + additive contribution to Δ_cos/Δ_sin (SpeciesRole mechanism, docs/02 §roles). + +**Gate:** (i) general-geometry neoclassics vs. NEO; (ii) orbit frequencies +(bounce, transit, precession) vs. analytic large-aspect-ratio formulas and a +standalone orbit integrator; (iii) Level-0 results recovered when the circular +low-β toggle set is applied; (iv) D23a shaping/finite-β targets with exact +numbers (ladder C4: triangularity 2w_c 1.82 → 2.90 ρ_bi across δ = +0.42 → +−0.5; the ε ≈ 0.3 crossover from w_c ∝ ε^{1/2}ρ_θi to ∝ ρ_θi; β_θ trend vs. +EAST 91972). + +--- + +## Level 3 — Self-consistent electromagnetics: the unification level + +**Relaxes:** O3 (and enables retiring O7). + +- Solve Ampère's law for the resonant helical harmonic(s) of A_∥ alongside the + kinetics. Island width/shape becomes an *output*; the external drive enters as + a boundary condition carrying Δ'(w) and/or the error-field amplitude from the + outer-region code. +- Multiple ξ-harmonics; island deformation. +- Kinetic (or reduced-fluid) electrons become necessary here: shielding currents + are carried by electrons; the flattened-electron closure O7 cannot shield. + (The RDK-NTM kinetic-electron machinery, already the E4 toggle, is the + starting point.) +- **Small-amplitude limit = linear layer problem.** Verification against SLAYER's + Δ(Q) across its drift-MHD regimes. **Dependency note:** the SLAYER Δ(Q) + implementation arrives with GPEC's Tearing module work (PR #238, + `feature/tearing-growthrates`: `src/Tearing/InnerLayer/SLAYER/` Riccati layer + model + GGJ under the same interface, dispersion root-finding, and the + `delta_prime_raw` outer-region Δ′). That PR is sequenced to land before Islands + work begins (docs/06 §1); ladder D1's in-CI form then calls it directly. + The transition regime w ~ δ_layer (penetration bifurcation, kinetic) is the + flagship new-physics deliverable. +- De-risking sub-track (strongly recommended, can start during Level 1): a + fluid-electron reduced configuration of Level 3 — essentially a kinetic-ion + analog of nonlinear cylindrical two-fluid codes (TM1-class) — to shake out the + (x, ξ) electromagnetic solve before full kinetics arrive. + +**Gate:** (i) linear limit vs. SLAYER Δ(Q) curves in shared regimes; (ii) +constant-ψ recovery at small Δ' and w ≫ δ_layer; (iii) fluid-limit toggles vs. +an established nonlinear cylindrical code (TM1 / XTOR-2F-class case); (iv) +qualitative reproduction of Fitzpatrick's penetration bifurcation. + +--- + +## Level 4 — Closures: rotation, transport, radiation + +**Relaxes:** O4, O8. + +- **Torque balance:** ω_E becomes an unknown closed by the Δ_sin = 0 root (the + sin ξ Ampère projection *is* the torque-balance condition — Diss19 Eq. 2.10) + plus flux-surface-averaged momentum balance against viscous/NTV restoring + torques (the in-repo KineticForces module is the natural NTV source), + appended to the Newton system — the nonlinear analog of SLAYER's + torque-balance closure. Multiple roots exist (Diss19 Fig. 4.18 found ±0.93, + ±1.28 ω_dia,e); continuation must track root branches, not assume + uniqueness. Quasi-static evolution: dw/dt from the MRE assembly, solved as a + sequence of steady states (arclength continuation in time-like parameter). +- **Perpendicular transport operator:** model χ_⊥ (and D_⊥) as an explicit + operator, bringing Fitzpatrick's w_d threshold *inside* the fundamental + equation. Documented honestly as a closure knob (turbulence–island interaction + is not first-principles here). +- **Radiative/thermo-resistive channel for W:** energy transport closure with a + radiation sink L_Z(T_e) n_W n_e inside the island and η(T_e) coupling — the + radiation-driven island / density-limit mechanism (Gates & Delgado-Aparicio + class). Requires Level 3 (η enters Ampère/Ohm) + Level 1 W transport. + +**Gate:** (i) w_d scaling vs. Fitzpatrick 1995; (ii) penetration thresholds with +self-consistent torque balance vs. SLAYER-based thresholds in the linear limit +and vs. empirical scalings in trend (La Haye database context, ladder B9); +(iii) radiation-driven island growth vs. published thermo-resistive island +models. + +--- + +## Milestone sequencing (dependency graph, not a schedule) + +``` +M0 repo + CLAUDE.md + docs (this) ──┐ +M1 phase-space grids + operator stack skeleton + AD │ Level 0 +M2 L0 single-species solve, Δ moments, York gates │ + → Paper I ──┘ +M3 FP collision operator + NEO cross-check ──┐ Level 1 +M4 W minority: friction/bootstrap + asymmetry result │ + → Paper II ──┘ +M5 Miller + general geometry + 5D + orbit benchmarks ──┐ Level 2 +M6 slowing-down F0 + alpha trace response + ω_D res. │ + → Paper III ──┘ +M7 fluid-electron (x,ξ) EM solve [start after M2] ──┐ +M8 kinetic-electron Ampère; SLAYER-limit gate │ Level 3 +M9 w ~ δ_layer transition study │ + → Paper IV (flagship) ──┘ +M10 torque balance + χ⊥ + w_d gate ──┐ +M11 radiative W channel │ Level 4 + → Paper V │ +M12 Δ-surface dataset + emulator release → Paper VI ──┘ +``` + +Documentation infrastructure (Physics Book skeleton, anchor-sync CI, figure +pipeline, STATE dashboard — docs/07) is part of M0–M1, not deferred: the first +operator merged is the first operator anchored. + +Parallelism: M3–M4 and M7 are independent of each other; M5–M6 depends on M3 +(collision operator) but not M7. + +**Sequencing against GPEC:** the Tearing module PR (#238, +`feature/tearing-growthrates` — SLAYER + GGJ inner layers, dispersion solver, +Δ′ machinery) lands **before** M0. If M0 starts while #238 is still open, the +Islands branch is cut from `feature/tearing-growthrates` rather than `develop`, +so the SLAYER/Δ′ interfaces Islands consumes are in hand from the first commit. + +## Risk register + +| Risk | Level | Mitigation | +|---|---|---| +| Separatrix + trapped-passing layers unresolvable at low ν without RDK-style analytics — **confirmed by kokuchou hitting a ν_★ = 5×10⁻³ floor** | 0–1 | Mapped/adaptive grids clustered at both layers using the now-known ∝ν^{1/2} width estimates (docs/04 §2); matrix-free removes kokuchou's memory wall; RDK reduction retained as cross-check; accept and document a ν floor per resolution tier | +| Trapped-passing matching block is intrinsically singular; plain linear algebra yields silent noise, not errors | 0+ | Explicit regularized (TSVD-style) treatment of the y_c block in the preconditioner; smallest-singular-value CI monitor (ladder A8); basis-change spike in M1 (docs/04 §3) | +| Separatrix-layer width depends on Φ̂ and moves between nonlinear iterations (E×B-dominated regime) | 0–1 | Pack from lower-bound width estimates over the expected Φ̂ range; post-solve validation of layer resolution; re-mesh-and-continue fallback (docs/04 §2) | +| Far-field BCs admit spurious solution branches (kokuchou's "winged" states under Neumann) | 0+ | Neoclassical-matching far-field BCs, never bare Neumann; continuation warm-starts; spurious-branch detection via far-field flow comparison against no-island neoclassics (docs/01 §3) | +| Published equation sets in the lineage contain errors (L23 §2.6 amendment list against I19 Eq. A.1) | 0 | Independent re-derivation before implementation ([VERIFY]/[DERIVED] policy); benchmark against L23-amended physics; standing triage category in docs/05 reporting rules | +| (x, ξ) small-amplitude limit fails to reproduce delicate linear layer structure | 3 | This is *the* physics risk. De-risk via M7 fluid track; verify against SLAYER regime-by-regime; budget the painful months here | +| In-repo SLAYER not merged yet (on `develop` it is a placeholder; the implementation lives on PR #238 `feature/tearing-growthrates`) | 3 | Sequence #238 before M0; branch Islands from `feature/tearing-growthrates` if starting earlier; fall back to published Park 2022 curves only if that branch stalls | +| Non-conservative collision discretization poisons bootstrap | 1 | NEO no-island cross-check as a CI-level gate; conservation tests as unit tests | +| ω_E sensitivity of polarization makes single-point Δ misleading (Δ_pol ∝ ω_E², sign flip near −0.89 ω_dia,e; L23's anomalous electron Δ_pol at ω_E = 0) | 0–4 | Always publish Δ as surfaces over (w, ω_E), never single points, until Level 4 closes ω_E; E4/E6 toggle studies address the open electron-Δ_pol question | +| 5D cost explosion at Level 2 | 2 | Orbit-averaged (4D) mode retained as toggle; trace-species linear passes; emulator strategy assumes expensive solves | +| Turbulence–island interaction hiding in χ⊥ | 4 | Explicit closure-knob documentation; sensitivity scans part of every Level-4 result | + +## Decision log (append-only) + +- D1 (adopted): primary representation (x, ξ), never Ω or S. Rationale: Level-3 + unification; island coordinates cannot represent shielded states. +- D2 (adopted): steady-state Newton–Krylov + continuation, not initial-value + time-stepping and not nested Picard loops. Rationale: Δ-surface generation is + the product; continuation produces it as a byproduct; bifurcation tracking + needs it; kokuchou's documented Picard non-convergence (L23 §6.1.1) is the + empirical case against the alternative. +- D3 (adopted): species list first-class at Level 0. Rationale: retrofit cost ≫ + upfront cost; trace-role machinery needed by both W and EP tracks. +- D4 (adopted): Julia, as a submodule of the GeneralizedPerturbedEquilibrium + package (`src/Islands/`, `module Islands` — no separate Project.toml). + Rationale: AD through the operator stack (exact Jacobians + ∂Δ/∂p + sensitivities), and GPEC-stack affinity (direct calls to the Δ′/SLAYER/ + equilibrium machinery, docs/06 §1). +- D5 (open): velocity coordinates (λ, E) vs. (v_∥, v_⊥) vs. (θ_b-aligned). + Default (λ, E) with σ = sgn(v_∥); revisit at Level 2 when θ is retained. + Note: prior art all uses y = λB_max with the y_c = 1 boundary; the singular + matching block (docs/04 §3) is a point against inheriting it unexamined. +- D6 (open): kinetic electron treatment at Level 3 — full DKE vs. reduced + (parallel-kinetic) electron model. Decide after M7 results. The RDK-NTM + kinetic-electron formulation (Diss19 Eq. D.61) is the full-DKE candidate. +- D7 (adopted; ratified by the user 2026-07-08, QUESTIONS Q2): implement Level-0 + physics from an independent re-derivation cross-checked against the + L23-amended equation set, treating I19 Eq. (A.1) as printed as known-errata; + ω_E enters as a scanned input parameter at Level 0 (not deferred to L4). + Rationale: L23 §2.6 amendment list; D23b ω_E-parametric formulation. + Clearance mode (user, 2026-07-08): **re-derivation first** — the Q3 + coefficient set is cleared by human sign-off of in-repo derivations + (`docs/src/islands/derivations/`), not of literature transcriptions. +- D8 (adopted; ratified by the user 2026-07-08, QUESTIONS Q2): benchmark grid = + the three-code triangle (DK-NTM published numbers, RDK-NTM improved-model + numbers, kokuchou finite-ν_★ surface) with the B5a/B5b/B5c configurations + pinned in docs/05, superseding the single "York thresholds" gate item. +- D9 (adopted; user-directed 2026-07-11): **verification targets are tiered by + reproducibility** (docs/05 "Target tiers and reproducibility"). Reproducing a + published *absolute* number requires every input of the source's exact + scenario, and the lineage under-specifies these (B5a's own collisionality is + internally contradictory) — so the primary literature-facing physics gates are + **scalings, trends, and internal differentials** (T1 exact math / T2 internal + cross-checks & toggle ratios / T3 scaling-and-existence vs. literature), and + absolute-number reproduction (T4) is aspirational and **audit-gated**: never + pass/fail without a published input manifest, and downgraded to T3 where the + source is under-specified. Clarifies (does not revoke) D8 — the three-code + triangle stays the comparison set, but internal differentials, not absolute + matches, are the sharp quantitative claims. Rationale: the SLAYER-validation + precedent (Park 2022 / Burgess 2026, docs/08 B26) validates by regime scalings, + not single points; and a fifth triage outcome ("under-specified source + configuration") is added. diff --git a/docs/src/islands/design/01-physics-level0.md b/docs/src/islands/design/01-physics-level0.md new file mode 100644 index 000000000..aa55cc5e6 --- /dev/null +++ b/docs/src/islands/design/01-physics-level0.md @@ -0,0 +1,399 @@ +# 01 — Level 0 physics formulation + +Scope: the equation set for the benchmark configuration (roadmap O1–O9). + +**Tag semantics (updated 2026-07-07).** The full source set now lives in-repo at +`docs/resources/Drift_Kinetic_Island_References/` (see docs/08 for the library +map). Expressions below were transcribed from the PDFs and checked against them +by AI extraction; these carry **[CHECKED: source, Eq./p.]** and still require +one human sign-off before the corresponding [VERIFY] discipline is considered +cleared (CLAUDE.md policy). Anything still uncertain carries **[VERIFY: ...]** +with the specific question stated. + +Primary sources: Imada et al. NF 59, 046016 (2019) — **I19** (complete DK-NTM +reference; the 2018 PRL 121, 175001 and JPCS 1125, 012013 are its compact +antecedents); Dudkovskaia PhD dissertation, York 2019 — **Diss19** (full RDK +derivation chain, Appendices C–E); Dudkovskaia et al. PPCF 63, 054001 (2021) — +**D21**; Dudkovskaia et al. NF 63, 016020 (2023) — **D23a** (finite-β, shaped +geometry); NF 63, 126040 (2023) — **D23b** (separatrix layer, polarization, +ω-dependence); Leigh PhD thesis, York Dec 2023 — **L23** (the `kokuchou` code: +amended DK-NTM equations, finite-ν★ thresholds, numerics forensics); Wilson, +Connor, Hastie & Hegna, PoP 3, 248 (1996) — **WCHH96** (analytic electron +closure and large-w limits). + +> **Load-bearing warning.** L23 §2.6 (pp. 59–60) documents concrete errors in +> the *published* I19 equation set (Eq. A.1): a missing ρ̂_θi factor on the +> ∂²ĝ/∂p̂² diffusion term (making it ∝ ρ̂²_θi), a missing ν̂_ii ρ̂_θi coefficient +> on ∂ĝ/∂p̂, missing factors on the Maxwellian-gradient drive terms, a corrected +> momentum-conserving term Û_∥i(ĝ + p̂F̂′_Ms), and a sign fix in the Δ_loc +> relation. **Islands must implement from an independently re-derived equation +> set benchmarked against L23's amended form, never from I19 Eq. (A.1) as +> printed.** This is the empirical justification for the whole [VERIFY] +> policy: the literature's O(1) coefficients are demonstrably not to be +> trusted without re-derivation. [CHECKED: L23 §2.6] + +--- + +## 1. Geometry and coordinates + +Local region around the rational surface ψ_s (minor radius r_s) of an m/n mode +in a large-aspect-ratio circular tokamak, B₀ = I(ψ)∇φ + ∇φ×∇ψ, I = RB_φ, +B ≈ B₀(1 − ε cos θ), ε = r_s/R₀ ≪ 1 [CHECKED: I19 Eq. (3)]. + +- Radial coordinate: x ∝ ψ − ψ_s. **Pin one normalization and write the map to + the others**: I19 uses x = (ψ−ψ_s)/ψ_s with ŵ = w/r_s, ρ̂_θi = ρ_θi/r_s; + D21/D23b normalize radial quantities to the island width w_ψ + (ρ̂_θj = I V_Tj/(ω_cj w_ψ)); the 2018 PRL normalizes to ψ_s. These + inconsistent conventions across the same lineage are a transcription hazard — + Islands' own normalization (§5) is r_s-based, with conversion factors in one + place. [CHECKED: I19 p. 6; D21 Eq. 19; PRL Eq. (4) note] +- Helical angle: ξ = m(θ − φ/q_s) (I19 Eq. (6)); Diss19/D21 use ξ = φ − q_s θ + with the cos nξ harmonic — same island, different angle multiplicity. Pin + Islands' ξ to the I19 form and document the map. **Island rest frame:** all + Level-0 solves are steady in this frame. +- Poloidal angle θ is eliminated at leading order by orbit averaging at fixed + p_φ (O5); it reappears at Level 2. + +Perturbation (prescribed at Level 0, O3), single-helicity, constant-ψ: + + A_∥ = −(ψ̃/R) cos ξ, ψ̃ = (w_ψ²/4)(q_s′/q_s), q_s′ = dq/dψ|_s + [CLEARED: human sign-off 2026-07-11 — + derivation docs/src/islands/derivations/psi-tilde-amplitude.md; + matches Diss19 p. 30, L23 Eq. (2.1.4), and I19's own Ω (I19 Eq. 7)] + +where **w_ψ is the island HALF-width in ψ-space**, w = w_ψ/(RB_θ) the +half-width in minor radius. **[VERIFY] RESOLVED (2026-07-11):** first-hand check +confirmed I19 as printed (print p. 3, text after Eq. 6) shows (w_ψ²/4)(q_s/q_s′) +— a **published typo**. I19 is internally inconsistent: its own Ω convention +(Eq. 7) requires q_s′/q_s, as do dimensional analysis and Diss19/D21/L23. The +cleared form is **q_s′/q_s** (independent re-derivation, Decision D7; triage: +their published-equation error, the docs/05 York-lineage standing rule). + +Island label and convention (pinned, matches every source in the lineage): + + Ω(x, ξ) = 2(ψ−ψ_s)²/w_ψ² − cos ξ, Ω = −1 at O-point, Ω = +1 at separatrix + [CHECKED: I19 Eq. (7); Diss19 Eq. 2.7; L23 Eq. (2.1.8)] + +**All York threshold numbers are HALF-widths** (D23a abstract states +"threshold magnetic island half-width"; L23 footnote p. 130 notes La Haye's +experimental fits quote the *full* width w_marg = 2w_c). This half/full-width +bookkeeping is pinned here and in docs/05. + +Even at Level 0, the *stored representation* of the field is A_∥(x, ξ) on the +(x, ξ) grid (decision D1); Ω is computed, never fundamental. + +## 2. Kinetic equation + +Per species j, phase space (x, ξ, λ, v, σ) with pitch λ = μ/E (grid variable +y = λB_max, trapped–passing boundary y_c = 1), v the speed, σ = sgn(v_∥). Split + + f_j = (1 − e_j Φ/T_j) F_Mj(ψ_s) + g_j, [CHECKED: I19 Eq. (28) form; Diss19 Eq. 2.15] + +with F_M a Maxwellian carrying background gradients at r_s (strictly local, +O2). The steady drift-kinetic equation in the island frame [CHECKED: I19 Eq. (8)]: + + v_∥∇_∥f + v_E·∇f + v_b·∇f − (e_j/m_j v)(v_∥∇_∥Φ + v_b·∇Φ) ∂f/∂v = C_j(f) + +with v_E = B×∇Φ/B², v_b = −v_∥ b×∇(v_∥/ω_cj). Orderings: Δ = w/r ≪ 1; +e_jΦ/T_j ~ g_j/F_M ~ Δ; B₁/B₀ ~ εΔ²; collisions O(Δ) below free streaming; +ions retain ρ_θi ~ w (finite orbit width — the key relaxation), electrons have +ρ_θe ≪ w. [CHECKED: I19 §1, §4; Diss19 p. 33] + +The radial coordinate is traded for the canonical momentum + + p_φ = (ψ − ψ_s) − I v_∥/ω_cj [CHECKED: I19 Eq. (2)] + +and θ is annihilated by orbit averaging at fixed p_φ (passing: (1/2π)∮dθ; +trapped: (1/2π)Σ_σ σ∫_{−θ_b}^{θ_b}dθ) [CHECKED: I19 Eq. (31); Diss19 Eq. 2.24]. +The master 4D equation for the orbit-averaged distribution Ḡ₀(p̂, ξ, y; v̂, σ) +is **I19 Eq. (32)** (structure confirmed; coefficients subject to the L23 §2.6 +amendments — implement from re-derivation): + + −m[ (p̂/L̂_q)Θ(y_c−y) + ρ̂_θi ω̂_D − (ρ̂_θi/2)⟨(1/v̂_∥)∂Φ̂/∂x⟩_θ ] ∂Ḡ₀/∂ξ|_p̂ + + m[ (ŵ²/4L̂_q) sin ξ Θ(y_c−y) − (ρ̂_θi/2)⟨(1/v̂_∥)∂Φ̂/∂ξ⟩_θ ] ∂Ḡ₀/∂p̂ + = ⟨(1/v̂_∥)Ĉ_ii(Ḡ₀)⟩_θ + +The three transport channels of the design (island-induced streaming, magnetic +drift, E×B) are the three bracketed frequencies above; they map one-to-one onto +the operator stack (docs/03 §2). + +**Island-streaming [CLEARED: human sign-off 2026-07-11 — derivation +docs/src/islands/derivations/parallel-streaming.md]:** the streaming braces +`(x/L̂_q)Θ(y_c−y) ∂_ξ` and `(ŵ²/4L̂_q) sinξ Θ(y_c−y) ∂_p̂` give, in the +`c_D = ω̂_D` normalization (divide by −m ρ̂_θi), `a_ξ = (L̂_q⁻¹/ρ̂_θi) x Θ` and +`a_x = −(L̂_q⁻¹ŵ²/4ρ̂_θi) sinξ Θ` — which factor exactly into +`(L̂_q⁻¹ŵ²/4ρ̂_θi)Θ·{Ω, ·}`, advection along the island flux surfaces (a +coefficient-free structural check). Implemented as +`Configure.streaming_coefficients` → `Operators.ParallelStreaming`, passing-only +(`Θ`), leaving the cleared `c_D` unchanged. + +**Gradient drive [CLEARED: human sign-off 2026-07-11 — derivation +gradient-drive.md]:** the master equation (Eq. 32) is **homogeneous** — I19's +`Ḡ₀ = p_φ(ω_si^T/ω_si)(n'/n)F_Mi + h̄₀` (Eq. 29, `ω_si^T/ω_si = 1+(v̂²−3/2)η_i` +a temperature factor, *not* a frequency ratio) is the standard neoclassical drive +`p_φ F'_Mi`, imposed as the **far-field boundary condition**, not an interior +source. So `Operators.GradientDrive = 0` and the far field is +`g_far = x L̂_{n0}⁻¹[1+(E−3/2)η_i]` (`Φ̂_far = 0` at `ω_E = 0`), built by +`Configure.gradient_far_field`. No frame convention enters (Level-0, `ω_E = 0`). + +### 2.1 Magnetic drift frequency: the original/improved toggle (now precise) + +Orbit-averaged precession [CLEARED: human sign-off 2026-07-11 — derivation +docs/src/islands/derivations/omega-D-drift-frequency.md; first-hand agreement +with I19 Eq. (32), D21 Eqs. (B1), (A2), Diss19; no discrepancy]: + + ω̂_D = [σv̂/(1+ε)] [ (1/L̂_q)⟨√(1−yb)/b⟩_θ − (1/2)(1/L̂_B)⟨(2−yb)/(b√(1−yb))⟩_θ ] + +The two terms are the shear-coupled drift-orbit-width precession (1/L̂_q, from the +finite orbit width x_D = ρ̂_θi(σv̂/(1+ε))√(1−yb)/b) and the grad-B drift (1/L̂_B). + +- **:original** (I19/DK-NTM): finite constant L̂_B⁻¹ = (ψ_s/B)∂B/∂ψ — retains a + non-vanishing ∇B term after orbit averaging. +- **:improved** (D21/RDK-NTM): Appendix A of D21 shows (from + ∂B/∂ψ = I′/R − (I/R²)∂R/∂ψ, low-β) ∂B/∂ψ = −(B_φ/(R₀²B_θ)) cos θ + O(ε²) — the + cos θ modulation makes ⟨cos θ·even⟩_θ = O(ε), so the term is ε-small after + orbit averaging; **L̂_B⁻¹ = 0 is the documented proxy** (D21 footnote 10, Fig. 8 + compares proxy vs full cos θ form directly). [CLEARED 2026-07-11, same + derivation; the toggle is carried by MagneticDrift.variant.] + +This single toggle is what moved the threshold half-width 8.73 ρ_bi → 1.46 ρ_bi +(D23a abstract). It is the archetype of the toggle-impact studies (docs/05 E1). + +### 2.2 The drift-island structure (must emerge from the solve, not be assumed) + +The exact drift-surface label [CHECKED: I19 Eq. (33); D21 Eq. 21; Diss19 Eq. 2.37]: + + S = (ŵ²/4L̂_q)[ 2(p̂ − ρ̂_θi ω̂_D L̂_q)²/ŵ² − cos ξ ] Θ(y_c−y) + − p̂ ρ̂_θi ω̂_D Θ(y−y_c) − (1/2)⟨(ρ̂_θi/v̂_∥) Φ̂⟩_θ + +Passing particles: constant-S surfaces are the magnetic island **radially +shifted by x_D = ρ̂_θi ω̂_D(y, v̂; σ) L̂_q** — σ-dependent, equal and opposite +for v_∥ ≷ 0, pitch/energy-dependent through ω̂_D. (There is no separate +"h(λ,E)" shift function in the sources; the shift *is* ρ̂_θi ω̂_D L̂_q. The +symbol h(Ω) is reserved for the electron profile function, §2.4.) Flattening +of f on drift islands rather than the magnetic island sustains pressure +gradients across small islands (w ~ ρ_θi) and weakens the bootstrap drive — +the kinetic threshold mechanism, carried by **passing** particles (D21 §7: +passing-particle physics, not banana-orbit physics; ρ_bi is merely the natural +unit at ε = 0.1). Trapped particles: S ∝ p̂ (no island structure); response +tied to the magnetic island. + +In DK (4D direct) mode Islands does **not** impose S-structure; it must *emerge*. +The RDK reduction — solve the 1D collisional constraint ⟨Ĉ/𝒜⟩_ξ^S g^(0,0) = 0 +per S-contour [CHECKED: D21 Eqs. 23–24; explicit coefficient forms Diss19 +Eqs. D.60–D.62 and D23b Eq. 19 + Appendix A] — is retained as a cross-check +mode valid for δ_j = ν_j/(εω_b) ≪ 1. + +### 2.3 Collision operator (Level 0) + +Momentum-conserving pitch-angle (Lorentz) model [CLEARED: human sign-off +2026-07-11 — derivation docs/src/islands/derivations/collision-operator.md; +operator structure, deflection frequency, and ν_★ normalization agree first-hand +with I19 Eqs. (9)–(12), L23 Eq. (2.3.40); no discrepancy]: + + C_jj(f) = 2ν_jj(v)[ (√(1−λB)/B) ∂_λ( λ√(1−λB) ∂_λ f ) + v_∥ ū_∥j f /v²_thj · F_Mj-normalized ] + ū_∥j(f) = (1/(n⟨ν_jj⟩_v)) ∫d³v ν_jj v_∥ f (momentum restoring) + C_ei drags on the ION flow u_∥i (species coupling) + +The pitch-angle bracket is the Lorentz operator in self-adjoint form +w⁻¹∂_λ(P∂_λ) with diffusivity P(λ) = λ√(1−λB) and measure w = B/√(1−λB) (the +change of variables from the pitch cosine fixes the 2ν_jj prefactor exactly). +λ-derivatives at **fixed ψ**, not fixed p_φ (a classic transcription trap). +Energy dependence: two variants exist in the lineage and become a documented +sub-toggle — I19/L23 use the full ν_jj(v) = ν̃_jj[φ(v̂) − G(v̂)]/v̂³ (Chandrasekhar +G; needed for neoclassical fidelity), while Diss19/D21 use the simpler +ν(V) ∝ V⁻³. Both diverge as v̂ → 0 (φ − G → (4/3√π)v̂ linear ⟹ ν̃ ~ v̂⁻² for the +Chandrasekhar form, v̂⁻³ for the reduced), motivating the analytic velocity +average L23 additionally derives: +⟨ν̂_ii⟩_u = (4ε^{3/2}ν_★/3√π)(√2 − ln(1+√2)). **[This ⟨ν̂_ii⟩_u constant remains +[CHECKED]-uncleared — its own short derivation is a deferred M2b sub-item; +L23 Eq. 4.1.6, p. 88.]** + +Collisionality normalization [CLEARED 2026-07-11, same derivation]: +ν_★ = ν_jj Rq/(ε^{3/2} v_th) (banana regime +ν_★ ≪ 1); ν̂_jj = ε^{3/2}ν_★ ν̃_jj(u). [CHECKED: L23 Eq. (2.3.40); Diss19 +footnote 26] + +Replaced wholesale at Level 1 by the multi-species Fokker–Planck operator. + +### 2.4 Electrons at Level 0 (O7) — closure now exact + +ρ_θe ≪ w ⇒ electron drift islands coincide with the magnetic island. The +analytic closure is WCHH96's, as used by I19/L23. The h(Ω) profile and its +amplitude are [CLEARED: human sign-off 2026-07-11 — derivation +docs/src/islands/derivations/electron-closure.md; the closure constraint +⟨∂²h/∂x²⟩_Ω = 0 gives h′ = C/Q, far-field matching h → x gives C = w_ψ/2√2, +matching I19 Eq. 18]: + + f_e = (1 − e_eΦ/T_e) F_Mes + h(Ω) F′_Mes − (Iv_∥/ω_ce) F′_Mes ∂h/∂ψ + h̄_e + h(Ω) = Θ(Ω−1) (w_ψ/2√2) ∫₁^Ω dΩ′/Q(Ω′), Q(Ω) = (1/2π)∮√(Ω+cos ξ) dξ + +h(Ω) is exactly flat inside the separatrix, → x far away, and satisfies +⟨∂²h/∂x²⟩_Ω = 0 (the closure constraint itself; unit-test target, L23 Eq. 4.1.1, +green as ladder A7). Flux-surface-averaged electron flow — **structure** +[CLEARED 2026-07-11], **constants k, f_p deferred** [CHECKED: I19 Eq. (22); +L23 Eqs. 2.5.5–2.5.8]: + + ⟨⟨Bu_∥e⟩_θ⟩_Ω/(B₀v_the) = −[f_t/(1+f_t)](Iv_the/ω_ce)(n′/n)(1 + η_e + ½ k f_c η_e)⟨∂h/∂ψ⟩_Ω + + [f_c/(1+f_t)] ⟨⟨Bu_∥i⟩_θ⟩_Ω/(B₀v_thi) + +with k ≃ −1.173 (Hirshman–Sigmar; **[CHECKED, uncleared — deferred, own +derivation]**; unit-test: L23 reproduces −1.1730) and f_p ≃ 1 − 1.46√ε +**[CLEARED: human sign-off 2026-07-11 — derivation passing-fraction.md; +`Coefficients.passing_fraction(ε) = 1 − 1.4624√ε`, the effective trapped-fraction +coefficient, = 1.46 to 3 s.f.]**. Note the electron current depends on the +*numerically computed ion flow* (momentum conservation) — the closure is coupled, not +one-way. Toggle `electrons = :flattened | :kinetic`: the `:kinetic` option is +exactly RDK-NTM's defining feature (electrons solved with the same drift-island +machinery as ions, Diss19 Eq. D.61 / D21 §5) and is *required* at Level 3 +(shielding); running it at Level 0 against `:flattened` is toggle study E4. + +## 3. Field equation (Level 0: quasineutrality only) + + n_i[Φ; g_i] = n_e[Φ; closure] → Φ(x, ξ) + +Exact Level-0 closed form with flattened electrons [CLEARED: human sign-off +2026-07-11 — derivation docs/src/islands/derivations/quasineutrality-closure.md; +derived from the ion/electron density moments, matches I19 Eq. (A.11) exactly at +τ=1; the general-τ form is the new result]: + + e_iΦ̂/T_i = (τ/(τ+1)) [ δn̄_i/n₀ + L̂_{n0}⁻¹ (x − ĥ(Ω)) ] (arbitrary τ) + = [ δn̄_i/n₀·L̂_{n0} + x − ĥ(Ω) ] / (2 L̂_{n0}) (τ=1, I19 A.11 form) + +(T_e = T_i assumed in the sources; Islands keeps τ = T_e/T_i general — the +τ/(τ+1) closure coefficient is the sum of ion+electron adiabatic responses. The +code uses the raw-moment form with δn̄_i = ∫g_i d³v the actual velocity moment, +so I19's δn_i normalization convention is a cross-check nuance only, not a code +dependency.) With kinetic electrons, the Picard form δΦ̂ = (δn̂_i − δn̂_e)/2 +[CHECKED: Diss19 Eq. 2.45]. In Islands both reduce to one quasineutrality +residual inside the global Newton system (docs/03) — the sources' nested +Picard loops (Φ outer, ū_∥i inner; I19 fig. A1) are precisely the fragile +iteration structure Newton–Krylov replaces; L23 §6.1.1 reports the Picard +convergence criterion was *never met* in production (Φ̂ array-max residuals +> 100%/iteration at large ŵ) even as Δ stabilized — treat that as the +cautionary tale motivating D2. + +**Implemented (2026-07-11):** `Operators.Quasineutrality` carries the full closure +— the residual is `R_Φ = M[g] − α Φ̂ + S`, with `α = (τ+1)/τ` (the reciprocal of +the `τ/(τ+1)` closure coefficient, from `Coefficients.quasineutrality_coefficient`) +and the field source `S = L̂_{n0}⁻¹(x − ĥ(Ω))` built by +`Configure.quasineutrality_source` from the cleared `ĥ` profile +(`Coefficients.h_amplitude`, `Fields.h_profile`). This closes the earlier gap +where the operator carried only `M[g] − α Φ`: without the `(x − ĥ)` source the +Level-0 potential was trivially zero (QUESTIONS Q5, field term now resolved). + +Boundary conditions: g → neoclassical (no-island) solution and Φ̂ → background +E_r potential as |x| → L_x; periodic in ξ. **Do not use bare Neumann +∂ĝ/∂p̂ = 0**: L23 §5.3/§7.1 traces its non-physical "winged" solution branch +(flows extending 8–10 island widths, disagreeing with neoclassical theory) to +the Neumann condition admitting multiple numerically-valid solutions, and +recommends matching to the analytic far-field limit — which is exactly Islands' +neoclassical-matching BC. [CHECKED: L23 pp. 113–115, 141] + +Ampère is **not** solved at Level 0 (O3). The Ampère residual is evaluated as +a diagnostic from day one; its resonant moments are the Δ outputs: + +## 4. Output moments and MRE assembly (normalization now exact) + +Parallel current J̄_∥ = θ-average of Σ_j e_j n_j u_∥j. The two projections of +parallel Ampère through the island [CHECKED: Diss19 Eqs. 2.9–2.10; D21 +Eqs. 7–8, 32]: + + (1/μ₀R) Δ′ ψ̃ = ∫_ℝ dψ ∮ dξ J̄_∥ cos ξ (growth: matching to Δ′) + 0 = ∫_ℝ dψ ∮ dξ J̄_∥ sin ξ (torque balance / rotation) + +so the kinetic drive and torque moments are + + Δ_cos ≡ Δ_neo = −(μ₀R/2ψ̃) ∫ dψ ∮ dξ J̄_∥ cos ξ, stationarity: Δ′ + Δ_neo = 0 + Δ_sin = (μ₀R/2ψ̃) ∫ dψ ∮ dξ J̄_∥ sin ξ [CLEARED: human sign-off 2026-07-11 — + derivation docs/src/islands/derivations/delta-moment-prefactors.md; + Δ_cos matches Diss19 Eq. 4.12, ψ̃ cleared (§1), μ₀R geometry; + sin-normalization pinned symmetric ([DERIVED: 2026-07-11])] + +with ψ̃ = (w_ψ²/4)(q_s′/q_s), and the Rutherford LHS (2τ_R/r_s²) dw/dt (w = +half-width). Decomposition diagnostics [CHECKED: Diss19 Eqs. 4.13–4.15; D21 +Eqs. 33–34; D23b §4]: + +- **Bootstrap+curvature part**: the Ω-flux-surface-constant part of J̄_∥, + ⟨J̄_∥⟩_Ω with ⟨·⟩_Ω = ∮·(Ω+cosξ)^{−1/2}dξ / ∮(Ω+cosξ)^{−1/2}dξ. +- **Polarization part**: Δ_pol = Δ_neo − (Δ_bs+Δ_cur) — the piece that + flux-surface-averages to zero. (L23 Eq. 2.5.3 flags this split as + approximate bookkeeping — "could comprise similar contributions from other + sources" — which is the design's position: partition is diagnostic, the + solve never separates channels.) +- Species partition (ion vs electron) alongside: L23 finds the *electron* + channel dominates both Δ_bs and (unexpectedly, at ω_E = 0) the stabilizing + Δ_pol — an open physics question Islands can settle with the ω_E scan. + +**Analytic large-w limits to recover** (ladder B2): Δ_bs+Δ_cur ∝ 1/w matching +WCHH96 Eq. (85) — with the caveat that Eq. (85) is derived in the E_r = 0 +frame while the island-frame calculation must be mapped before comparison +(Diss19 p. 86) — generic scaling Δ_bs ~ ε^{1/2}(L_q/L_p)(β_θ/w); and +Δ_pol ∝ 1/w³ at large w. [CHECKED: Diss19 pp. 84–86; D21 p. 2] + +In the linear limit, (Δ_cos + iΔ_sin) ↔ the complex layer Δ(Q) (SLAYER +convention map still [VERIFY: Park PoP 29 (2022) — paper not yet in the +reference library; acquire]). + +## 5. Nondimensionalization and frames (the input parameter vector p) + +Normalizations (r_s-based, following I19): x = (ψ−ψ_s)/ψ_s, ŵ = w/r_s, +ρ̂_θj = ρ_θj/r_s, v̂ = v/v_thj, y = λB_max, b = B/B_max, L̂_q⁻¹ = (ψ_s/q)dq/dψ, +L̂_n⁻¹ = (ψ_s/n)dn/dψ, Φ̂ = e_jΦ/T_j, ν̂ = ε^{3/2}ν_★ν̃(v̂). Conversion maps to +the D21 (w_ψ-based) and PRL (ψ_s-based) conventions live in `src/frames/` +alongside the frequency maps. [CHECKED: I19 p. 6; L23 Eqs. 2.3.40–2.3.46] + +**Frame identities (now source-confirmed, the frames-module spec):** + +- ω_dia,e = m T_e n₀′/(−e q_s n₀); ω_E ≡ m Φ′_eqm/q_s; ω̂_E = ω_E/ω_dia,e. + [CHECKED: Diss19 p. 46] +- The combination **ω − ω_E is frame-independent**; with ω₀ the island + propagation frequency in the frame where E_r → 0 far from the island, + **ω₀ = −ω_E** (island-rest-frame calculation at equilibrium-potential + gradient ω_E ⇔ island rotating at −ω_E in the zero-E_r frame). + [CHECKED: Diss19 pp. 47–48] +- The effective density gradient shifts with frame: + L_n⁻¹ = L_{n0}⁻¹(1 + Z_j ω_E/ω_dia,e). [CHECKED: Diss19 p. 46] +- Level-0 sources' published thresholds are at ω_E = 0 (no equilibrium E_r); + D23b treats ω_E as an input parameter — exactly Islands' O4. Torque-balance + roots (Δ_sin = 0) exist at discrete ω̂_E (Diss19 benchmark: ω₀ = −0.93 + ω_dia,e selected among ±0.93, ±1.28); Δ_pol ∝ ω_E² away from zero and + **reverses sign at ω_E ≈ −0.89 ω_dia,e** (D23b Fig. 8) — the modern, frame- + pinned statement of the polarization sign controversy. These are ladder-B4 + targets. + +Level-0 input vector: + + p = ( ŵ = w/ρ_θi (half-width), + ω̂_E = ω_E/ω_dia,e (≡ −ω₀/ω_dia,e; SLAYER Q-map [VERIFY: Park 2022]), + ν̂_j = ν_★j per species, + ε, ŝ (via L̂_q), q_s, τ = T_e/T_i, + η_j = L_n/L_Tj, + species list: {Z_j, m_j/m_i, n_j/n_e, T_j/T_i, gradients, F0 type, role} ) + +Frequency bookkeeping owns its own unit tests; the polarization-sign disputes +in the literature are largely frame disputes, and the identities above make +the conversions mechanical. One module (`src/frames/`) owns them. + +## 6. Symmetries and conserved checks (unit-test targets) + +- Parity: Δ_cos even / Δ_sin odd under the appropriate (ξ, σ, ω_E) reflection + [derive at implementation and record as [DERIVED]; consistency targets: + Δ_pol(ω_E) parabolic/even to leading order away from the linear-in-ω_E + region near zero, D23b Fig. 8]. +- Zero-gradient, zero-Φ̃ Maxwellian: g = 0 exactly; residual = machine zero. +- No island (ψ̃ → 0), gradients on: recover standard local neoclassics + (bootstrap J_∥ vs. Sauter/NEO) — the most powerful global check (docs/05 B1). +- Electron-closure identities: ⟨∂²h/∂x²⟩_Ω = 0; k → −1.173; f_p → 1 − 1.46√ε; + ⟨ν̂_ii⟩_u analytic value (§2.3). [CHECKED: L23 Ch. 4] +- Collision operator: particle conservation (L0); +momentum/energy per pair + (L1); discrete entropy sign ∫ g C[g]/F_M ≤ 0. + +## 7. Explicitly out of Level-0 scope (recorded to prevent creep) + +Ampère & multi-harmonic (L3); torque-balance closure of ω_E (L4 — but ω_E is +an input *parameter scan* from day one; publishing single-ω_E Δ values is +forbidden per the risk register); χ_⊥/w_d (L4); general geometry & 5D (L2); +slowing-down F₀ (L2); radiation (L4); gyroaveraging beyond drift order (out of +program scope — documented limitation vs. gyrokinetic island studies; L23 +p. 142 draws the same boundary: ŵ approaching ρ_i needs gyrokinetics). diff --git a/docs/src/islands/design/02-species-and-eps.md b/docs/src/islands/design/02-species-and-eps.md new file mode 100644 index 000000000..abc6b72c4 --- /dev/null +++ b/docs/src/islands/design/02-species-and-eps.md @@ -0,0 +1,146 @@ +# 02 — Species abstraction, tungsten, and energetic particles + +## 1. Species as a first-class dimension (Level 0 requirement, D3) + +Every kinetic object in Islands is indexed by species. The solve is per-species DKEs +coupled through (i) quasineutrality (and Ampère at L3), (ii) the collision +operator's field-particle terms (L1+), and (iii) the output moments, which sum +over species. Designing for N species at Level 0 costs ~nothing (the L0 test is a +trace deuterium copy of the bulk); retrofitting costs a rewrite. + +### 1.1 Species definition + +```julia +abstract type AbstractBackground end +struct Maxwellian <: AbstractBackground # n, T, dlnn/dr, dlnT/dr at r_s +struct SlowingDown <: AbstractBackground # S0, v_birth, v_crit(T_e, composition), + # dln(source)/dr ; isotropic at L2 entry +struct Species{B<:AbstractBackground} + name::Symbol + Z::Float64 # charge number (allow Float for mean-charge-state W) + m::Float64 # mass ratio to reference ion + background::B + role::SpeciesRole + collisional_coupling::Bool # participate in field-particle terms? (see §1.3) +end +``` + +### 1.2 Roles: the trace-species economy + +``` +@enum SpeciesRole Bulk Trace +``` + +- **Bulk**: full nonlinear participant. In quasineutrality (+ Ampère at L3); + its g enters the Newton state vector. +- **Trace** (n_j Z_j ≪ n_e for charge, n_j ≪ n_e for current — check both): the + trace DKE is *linear in g_j* given the converged bulk fields (Φ̃, island, bulk + flows for friction). Solved as a post-processing pass — one linear solve, no + Newton coupling — with an additive contribution to Δ_cos/Δ_sin and to profiles. + This is the computational backbone of both the W and alpha tracks: parameter + scans over trace-species properties reuse one bulk solve. +- Promotion rule: any species violating trace criteria (e.g. W at high + concentration where Z n_W is non-negligible, or when friction back-reaction on + bulk flows matters — see §2) must be run as Bulk; the code checks the criteria + and warns, never silently degrades. + +### 1.3 Collisional coupling matrix + +Friction is directional: a trace species always *feels* the bulk (drag, +pitch-angle scattering off bulk); whether the bulk feels the trace +(field-particle back-reaction) is the `collisional_coupling` flag. W at reactor- +relevant concentrations: back-reaction ON (Z² n_W friction on bulk ions is not +small even when charge-trace holds — this is exactly the mixed case the analytic +MRE cannot do). Dilute alphas: back-reaction OFF is usually safe; verify with the +flag flip (a one-line toggle — the whole point of the architecture). + +--- + +## 2. Tungsten (physics gate: Level 1; radiative channel: Level 4) + +### 2.1 Why W breaks the analytic MRE terms + +Collisionality scales ~ Z²(?) with low v_th: W sits in Pfirsch–Schlüter or plateau +while bulk D and electrons are banana — a *mixed-regime* multi-species problem. +Analytic bootstrap terms in the MRE assume a per-species regime; the friction +between a PS impurity and banana bulk ions modifies the bootstrap current (and +hence Δ_bs) in ways only a full multi-species collision operator captures. +Additional channels: Z_eff shift of electron collisionality (electron bootstrap +and, at L3, resistive layer physics); impurity contribution to polarization is +small (tiny ρ_θW) — a prediction to *verify*, not assume. + +### 2.2 Level-1 deliverables + +- Δ_bs(w; n_W, Z_W, ν̂) surfaces with W friction — quantified departure from + Sauter-based MRE terms. +- **In-island impurity asymmetry**: n_W(Ω, ξ) structure (parallel compression + + friction with flattened bulk flows inside the island). Directly comparable to + AUG/DIII-D measurements of W behavior at islands, and the input to the L4 + radiative channel. +- Charge state: single mean-Z at L1 (Z̄(T_e) from coronal tables as a parameter); + multi-charge-state bundle only if asymmetry results prove sensitive. + +### 2.3 Level-4 radiative/thermo-resistive channel + +Energy closure with radiation sink Q_rad = n_e n_W L_W(T_e) inside the island, +temperature-dependent resistivity η(T_e) entering the L3 Ohm/Ampère system → +radiation-driven island growth (Gates & Delgado-Aparicio-class mechanism, +density-limit relevance). Gate: reproduce published thermo-resistive island model +trends (docs/05 D3). Depends on: L1 (W transport into island) + L3 (η in field +equation) + L4 energy closure. This is deliberately the *last* W milestone. + +--- + +## 3. Energetic particles (physics gate: Level 2) + +### 3.1 Why EPs are the reason Level 2 exists + +Alphas violate the orderings W leaves intact: + +- **ρ_θα ≳ w** (and possibly ≳ L_x): the drift-island shift is not a perturbative + O(ρ_θ) displacement — it is the dominant structure. Radially local, constant- + gradient assumptions fail; the domain must span several ρ_θα with profile + variation toggles. +- **Orbit-average survives, resonance does not**: ω_bα is still fast (O5 holds for + alphas), but island rotation can satisfy **ω ~ ω_D,α** (precession) — a + collisionless, resonant polarization-type contribution to Δ with no fluid + analog and no regime formula. Flagship EP physics target: ITER/SPARC NTM + thresholds with self-consistent alpha kinetics. +- **Non-Maxwellian F₀**: slowing-down background changes the drive terms + (∂F₀/∂r structure, no temperature gradient in the usual sense) and the + collision physics (drag on electrons + bulk ions rather than self-collisions; + self-collisions negligible). Mechanically modest once L2 orbits are right; + pointless before. + +### 3.2 Implementation sequence within Level 2 + +1. Trace Maxwellian "hot ion" with artificially large ρ_θ — isolates finite-orbit + nonlocality from F₀ shape. +2. SlowingDown F₀, isotropic — adds drive/drag changes. +3. ω-scan through ω_D,α — the precession-resonance study. Requires the L2 + orbit machinery to deliver accurate ω_D(λ, E) (benchmark vs. standalone orbit + integrator, docs/05 C2). Methodological antecedent: Dudkovskaia et al. JPCS + 1125, 012009 (2018) — *phase-space* island stability for EP-driven modes; + its bounce/angle-variable and separatrix-layer machinery is the same toolkit + (docs/08), though its physics (bump-on-tail secondary modes) is not part of + this study. +4. (Optional/later) anisotropic F₀ for NBI/RF fast ions — same machinery, + different B; parked unless a collaborator needs it. + +### 3.3 Division of labor with the outer region + +EP pressure also modifies Δ′ (outer-region kinetic corrections — same physics +class as GPEC/PENT stability integrals). That stays on the perturbed-equilibrium +side and arrives through the Δ′(w) input. Islands owns only resonant/orbit-width EP +physics at the island. State this in every EP paper to preempt double-counting +questions, and define the split precisely: outer kinetic Δ′ evaluated with the +island region excised at the matching radius |x| = L_x [interface spec in +docs/03 §5]. + +### 3.4 Alpha–W interplay (free deliverable) + +Both tracks live in the same species list; an L2 run with {D bulk, e bulk/model, +W trace, α trace} costs one bulk solve + two linear passes. Alpha drag heating +asymmetries vs. W radiative cooling inside islands is an unexplored combination — +cheap to look at once both tracks exist, potentially interesting for burning- +plasma island stability. Not a milestone; an opportunity. diff --git a/docs/src/islands/design/03-architecture.md b/docs/src/islands/design/03-architecture.md new file mode 100644 index 000000000..0efe26b49 --- /dev/null +++ b/docs/src/islands/design/03-architecture.md @@ -0,0 +1,158 @@ +# 03 — Architecture (Julia) + +Design principle: **the physics levels are configurations, not code paths.** One +discretization, one Newton solver, one state vector; orderings are swappable +operators and flags. If implementing a new level requires touching the solver +loop, the architecture has failed. + +## 1. Package layout + +Islands is a GPEC submodule (`module Islands`, no separate `Project.toml` — it +shares the GeneralizedPerturbedEquilibrium package environment). Its files are +distributed across the repo's existing trees, not a self-contained package dir: + +``` +src/Islands/ # the module (module Islands) +├── CLAUDE.md # module conventions (nested; Claude Code auto-loads) +├── Islands.jl # module entry +├── geometry/ # AbstractEquilibrium: AnalyticCircular (L0), +│ # MillerAnalytic (L2 entry; the D23a geometry), +│ # NumericalEquilibrium (L2; DCON/gEQDSK ingest) +├── phasespace/ # grids (x, ξ[, θ]; λ, E, σ), maps, quadrature, +│ # layer-clustered mappings (docs/04) +├── species/ # Species, backgrounds, roles (docs/02) +├── frames/ # THE frequency/frame conversion module (docs/01 §5) +├── operators/ # the stack (see §2) +├── fields/ # Φ̃ quasineutrality residual; A_∥ Ampère residual (L3) +├── closures/ # torque balance, χ⊥ transport, radiation (L4) +├── moments/ # Δ_cos, Δ_sin, profiles, channel decompositions +├── solvers/ # Newton–Krylov, continuation, trace-species linear pass +├── io/ # config (TOML), results (HDF5/JLD2), provenance +└── verify/ # benchmark harness callable from tests AND scripts + +docs/src/islands/ # docs (rendered by the GPEC Documenter site) +├── index.md # overview / landing page +├── design/ # these design documents (normative, aspirational) +├── (Physics Book chapters) # as-implemented equations (docs/07) +├── derivations/ papers/ state/ notes/ LOG.md QUESTIONS.md + +test/runtests_islands_*.jl # unit + symmetry + conservation tests (fast), + # included from the repo's test/runtests.jl +benchmarks/islands/ # the docs/05 ladder (slow; CI-gated subsets) +└── figures/ # surface generation + paper/gallery figure scripts +regression-harness/ # islands cases integrated with the rest (islands_*) +``` + +## 2. The operator stack + +The state is `U = (g_1, …, g_Nbulk, Φ̃ [, A_∥ at L3] [, ω, E_r at L4])`. The +residual is assembled as a sum of operator applications: + +```julia +abstract type AbstractTerm end +# each implements: apply!(R, term, U, cache), and is either matrix-free or +# provides a local stencil for preconditioning + +struct ParallelStreaming <: AbstractTerm end # includes island B̃_r ∂x +struct MagneticDrift <: AbstractTerm # variant = :original (finite L̂_B⁻¹, I19) + # | :improved (L̂_B⁻¹ → 0 proxy of + # the cosθ ∂B/∂ψ structure, D21 + # Eq. A2) — the 8.73→1.46 ρ_bi + # toggle, docs/01 §2.1 +struct ExBDrift <: AbstractTerm end +struct Collisions{M} <: AbstractTerm # M = PitchAngle (L0; energy-dependence + # sub-toggle :chandrasekhar (I19) | + # :vcubed (D21), docs/01 §2.3) | + # FokkerPlanckMulti (L1) +struct GradientDrive <: AbstractTerm end # (v_E+v_D+v_ψ̃)·∇F₀ ; dispatches + # on background type (Maxwellian / + # SlowingDown) +struct PerpTransport <: AbstractTerm end # χ⊥, D⊥ (L4) +struct RadiationSink <: AbstractTerm end # (L4, energy closure) +``` + +Configuration = list of terms per species + field-equation set + closure set, +read from a TOML config. **Named configurations are pinned in `src/Islands/verify/`**: +`:imada2019` (B5a; note it targets the L23-amended physics, not I19 Eq. A.1 as +printed — docs/01 header), `:dudkovskaia2021` (B5b), `:leigh2023` (B5c), +`:sauter_limit`, `:slayer_limit`, … — the toggle-comparison studies the +project exists to do are then config diffs, and every published figure names +its configuration. + +Rules: +- No term may inspect which other terms are active (no hidden coupling). +- Every term carries its own verification hook (an analytic limit or manufactured + solution registered in `src/Islands/verify/`). +- Orderings that *remove* structure (e.g. orbit averaging O5) are implemented as + alternative phase-space configurations, not operators: `phase = :orbit_averaged + (4D)` vs `:full (5D)`. Terms are written against an abstract phase-space + interface so both share implementations where possible. + +## 3. Solver strategy + +- **Steady-state Newton–Krylov** (D2): matrix-free JVPs via AD; GMRES; physics- + block preconditioner (docs/04 §4). +- **AD policy**: JVPs and small-parameter sensitivities via ForwardDiff duals + through the operator stack (write terms generically over `eltype`); evaluate + Enzyme for reverse-mode ∂Δ/∂p over the full parameter vector when surface + generation begins. AD-compatibility is a CI test per term (no + `Float64`-hardcoded buffers — reuse the per-thread preallocation patterns from + GPEC/QuadGK work, but typed generically). +- **Continuation**: pseudo-arclength in any component of p (and in ψ̃₀/w). + Purposes: (i) Newton globalization by homotopy from analytic-limit solutions, + (ii) Δ-surface generation as a byproduct, (iii) bifurcation tracking — the + penetration bifurcation at Level 3 *is* a fold in the continuation curve, so the + solver must detect/step around folds from the start. +- **Trace pass**: after bulk convergence, each Trace species is one linear solve + with frozen bulk fields (docs/02 §1.2); reuses the same operator stack and + Krylov machinery with a linear RHS. +- **ω closure (L4)**: ω, E_r appended to U; torque-balance residual appended to R. + Structurally identical Newton system — this is why the closure is cheap *if* + the architecture holds. + +## 4. Data and provenance + +- Config: TOML in, full resolved config (all defaults expanded, git SHA, term + list, grid spec) stored inside every output file. A result that can't + regenerate itself is a bug. +- Output: HDF5/JLD2 with (p, Δ_cos, Δ_sin, channel decompositions, convergence + metadata, profiles on demand). Surface datasets are append-only stores keyed by + p; the emulator (M12) trains from these. + +## 5. External interfaces + +> **Superseded where in-repo (docs/06 §1):** with Islands living inside the GPEC +> repository, the Δ′ and SLAYER interfaces below become direct Julia calls +> against GPEC's implementations, exercised in CI. The file-based forms remain +> the spec for any standalone/external consumers. + +- **Δ′(w) input**: file-based interface (resistive DCON/STRIDE output → a small + table Δ′ vs. w at the rational surface, with the matching radius L_x recorded). + Keep it dumb and versioned; no live coupling until the physics settles. +- **Equilibrium input (L2)**: gEQDSK + profiles, mapped through the same + representations the GPEC stack uses; `AnalyticCircular` remains permanently + available for regression. +- **SLAYER (L3 verification)**: comparison harness that maps Islands' linear-limit + (Δ_cos + iΔ_sin) onto SLAYER's Δ(Q) convention — the frames module owns the Q + mapping [VERIFY: Park 2022 conventions — paper not yet in the reference + library]. **Status:** the in-repo SLAYER arrives with the Tearing module PR + (#238, `src/Tearing/InnerLayer/SLAYER/`), sequenced before Islands starts + (docs/06 §1); code against the Tearing-module layout, not `develop`'s old + `src/InnerLayer` placeholder. +- **NEO/NCLASS (L1 verification)**: no-island neoclassical cross-check driver + (export local parameters → run → compare bootstrap/flows). + +## 6. Performance posture (sizing, not optimization) + +L0 4D: N_x × N_ξ × N_λ × N_E × σ ~ 400×64×128×32×2 ≈ 2×10⁸ dof upper bound +per bulk species before layer-adapted grids reduce N_x, N_λ needs — matrix-free +mandatory. The prior-art cost baseline makes the case concrete: kokuchou's +dense-block shooting method needed ≈16.6 GB *per energy grid point* and +O(100 GB) total at n_ξ = 30, n_p = 145, and memory (not physics) set its +accessible parameter window (docs/04 §6). Single-node multithread first (Julia +threads; the GC-contention lessons from parallel QuadGK apply: preallocate +per-thread caches). MPI only if +L2 5D demands it; do not architect for MPI speculatively, but keep halo-friendly +array layouts (x outermost) so the option stays open. Every solve logs a cost +model entry (dof, iterations, wall time) — the emulator strategy depends on +knowing what a point costs. diff --git a/docs/src/islands/design/04-numerics.md b/docs/src/islands/design/04-numerics.md new file mode 100644 index 000000000..e04f20b9d --- /dev/null +++ b/docs/src/islands/design/04-numerics.md @@ -0,0 +1,190 @@ +# 04 — Numerics + +The numerics posture is now informed by three generations of prior-art +implementation experience: DK-NTM (I19 Appendix A — shooting method, nested +Picard loops), RDK-NTM (Diss19 Appendix E — S-space reduction, analytic +layers), and kokuchou (L23 Chs. 3–6 — the most complete forensic record of +where the direct 4D approach actually breaks). Citations per docs/01 header. + +## 1. Discretization by coordinate + +- **ξ (helical angle)**: Fourier pseudo-spectral, periodic. Nonlinear terms + (v_E·∇g, Φ̃ coupling) via dealiased transforms. At L0 the field has one + harmonic but g does not — keep the full spectrum from the start. Harmonic + content of the residual doubles as a resolution diagnostic. (kokuchou used + 2nd-order FD with n_ξ = 30 and flagged accuracy limits; spectral ξ is a + deliberate upgrade.) +- **x (radial)**: mapped collocation or high-order FD on a stretched grid. + **Packing must target the drift-island separatrices, not the magnetic + island**: the layer follows contours shifted by ±ρ̂_θi ω̂_D L̂_q per (y, v̂, σ) + (docs/01 §2.2), so the packed envelope is max over the σ-shifts of the Ω=1 + curve — L23 §3.1.6 identifies the rectilinear-mesh/shifted-round-island + mismatch as kokuchou's dominant accuracy limiter, and proposes a mapped + radial coordinate absorbing the θ-dependent orbit shift (its Eq. 7.1.1, + p̃ = ψ − I(v_∥(θ)/ω_ci(θ) − v_∥(0)/ω_ci(0))) — adopt as a candidate *grid + map* x(s; y, v̂, σ-envelope), never as a solve-coordinate change (D1 stands). + Far-field BCs at |x| = L_x: g → neoclassical (no-island) solution, Φ̃ → + background E_r potential. **Never bare Neumann ∂g/∂x = 0**: L23 traced its + spurious "winged" solution branch to Neumann non-uniqueness (docs/01 §3). + L_x is a convergence parameter reported with every result (and is the Δ′ + excision radius, docs/03 §5). +- **λ (pitch)**: collocation with clustering at the trapped-passing boundary + y_c = 1. Layer width **in pitch angle: ε_λ ≈ [(2ν̂_j/v̂) a(λ_c, v̂; σ)]^{1/2} + ~ √ν_★** with a(λ) = ⟨σλ(1−λB)^{1/2}R/B_φ⟩_θ — now a confirmed scaling + [CHECKED: Diss19 p. 58; D23b §3.1], electron layer wider by (m_i/m_e)^{1/4}. + Packing parameter set adaptively from input ν̂. Internal boundary + conditions at y_c (the DK-NTM/kokuchou matching set): + Σ_σ σg^p = 0, Σ_σ g^p = 2g^t, Σ_σ ∂_y g^p = 2 ∂_y g^t + [CHECKED: I19 Eqs. A.7–A.9; L23 Eqs. 2.3.52–2.3.54]. σ = ± sheets joined at + bounce points for trapped particles. +- **E (energy)**: Gauss-type quadrature nodes on a mapped semi-infinite domain + weighted by F₀ — Maxwellian weights at L0; slowing-down weights (L2) change + the map, not the machinery. Mind the integrable divergences the sources hit: + ν̃(v̂) ∝ v̂⁻² at low v̂ (use the analytic ⟨ν̂⟩_v, docs/01 §2.3), (1−yb)^{−1/2} + at bounce points, and the y → 1/b flow integrals — analytic correction + terms/asymptotics, not naive quadrature [CHECKED: L23 pp. 69, 87–88, App. 8.4]. + Collision-operator energy diffusion (L1) prefers modest-order collocation + over pure spectral here; decide with a convergence study on the Sauter + benchmark. +- **θ (poloidal, L2)**: Fourier. The 4D orbit-averaged mode must be recoverable + as the θ-average of the 5D solve — a built-in cross-check of both. + +## 2. The two internal layers (the numerical cliff — now quantified) + +Both layers are collisional with **width ∝ ν^{1/2}** [CHECKED: D23b §3.1, +footnote 11]: + +- **Trapped-passing dissipation layer** at y_c: ε_λ ~ [(2ν̂/v̂)a(λ_c)]^{1/2}. +- **Drift-island separatrix layer** at S = S_c: ε_S ~ [(2ν̂/v̂)C_SS(S_c)]^{1/2} + in the S variable; in kokuchou's (p, ξ) variables the equivalent widths are + Δ_p,pass ~ √(ν̂L̂_q/ŵ)·ρ̂_θi and Δ_p,trap ~ √(ν̂L̂_q/ŵ)·√(ŵρ̂_θi) + [CHECKED: L23 §6.1.2]. + +Two prior-art failure modes to design against: + +1. **The layer moves during the solve.** When E×B dominates the layer balance + (ν̂/(ŵL̂_q) < 1 — satisfied over part of the velocity grid in *every* L23 + production run), the width becomes Δ_p,E×B ~ ν̂ρ̂_θi/ŵ and **depends on Φ̂, + so it shifts between nonlinear iterations** — a mesh packed for the initial + iterate can be unresolved at the converged one [CHECKED: L23 Eqs. 6.1.1–6.1.2]. + Mitigation: pack from the layer-width *lower bound* over the expected Φ̂ + range; validate the estimate against measured solution structure post-solve + (estimates logged); re-mesh-and-continue as a fallback. +2. **The accessible parameter window shrinks with correct physics.** L23's + corrected ∂²ĝ/∂p̂² coefficient (∝ ρ̂²_θi, per the §2.6 amendments) makes + separatrix gradients *steeper* than in the original DK-NTM runs — kokuchou + could not reach DK-NTM's ν_★ = 10⁻³ operating point (floor at 5×10⁻³) nor + ŵ > 0.75ρ̂_θi at that floor, with memory as the binding constraint + [CHECKED: L23 §5.3, §6.1.2, p. 116]. Islands' matrix-free posture removes + kokuchou's specific memory wall (§6 below) but not the resolution demand. + +Posture (unchanged in spirit, now with numbers): adaptive packing driven by +the ε_λ, ε_S, Δ_p,E×B estimates from input parameters; a documented ν̂ floor +per resolution tier; below the floor, results flagged `layer_unresolved` and +the RDK cross-check mode (analytic layer treatment per Diss19 §3/D23b §3.1.1 — +Fourier-matched layer solutions) is the reference. Disagreement between DK and +RDK modes above the floor is a release-blocking bug; below it, it's the +measured cost of the RDK ordering — a publishable toggle-impact result. +Grid-convergence studies are first-class benchmark artifacts. + +## 3. The trapped-passing boundary is *singular* — regularize deliberately + +The hardest-won lesson in the reference set [CHECKED: L23 §4.2, pp. 94–97]: +the linear system coupling the two sides of y_c through the matching +conditions is **intrinsically singular/ill-conditioned** (kokuchou measured +rcond ≈ 10⁻¹⁶–10⁻¹⁹, det = 0 or ±Inf, at every energy grid point; plain LU +gave machine-dependent noise in ĝ(v̂); the same latent defect was reproduced in +DK-NTM once other bugs were fixed). kokuchou's fix: truncated SVD (cutoff +10⁻⁷; exactly one singular value truncated) applied only at the boundary +solve. + +Implications for Islands' Newton–Krylov (no y-sweep, one global residual): + +- The same near-null-space will reappear as **Jacobian ill-conditioning + localized at the y_c block**. The physics-block preconditioner must treat + the y_c matching rows explicitly (small dense block per (x-locality, v̂): + factor with SVD/complete pivoting, truncate/regularize below a documented + cutoff), so GMRES never has to resolve the near-singular directions itself. +- Add a CI-level diagnostic: monitor the smallest singular value of the y_c + matching block and the GMRES convergence stagnation signature; a silent + regression here produced noise, not crashes, in the prior art — it must be + *tested for*, not observed. +- Root cause is the asymptotic v_∥⁻¹ structure at the boundary; any basis + change that removes the 1/v_∥ divergence from the matched unknowns (e.g. + solving for flux-like variables across y_c) is worth a design spike in M1. + +## 4. Manufactured solutions + +Before any physics benchmark: MMS per operator and for the assembled L0 system +(source terms chosen so a prescribed smooth g*, Φ̃* solve the equations). +Verifies discretization order and the AD-generated JVPs simultaneously +(JVP checked against finite differences of the residual). MMS configs live in +`src/Islands/verify/` and run in CI at low resolution. Supplement with the sources' cheap +analytic unit targets (docs/01 §6: h(Ω) identities, k = −1.173, ⟨ν̂⟩_v, +f_p = 1 − 1.46√ε) — L23 Ch. 4 demonstrates these catch inherited bugs that +integration tests miss. + +## 5. Newton–Krylov details + +- Inexact Newton (Eisenstat–Walker forcing), line search + continuation + globalization (docs/03 §3). Expect and handle fold points (penetration + bifurcation at L3): pseudo-arclength with tangent monitoring from day one. +- The prior art's nested Picard loops (Φ outer / ū_∥i inner) are the explicit + anti-pattern: kokuchou's production runs *never met* their Picard convergence + criterion (Φ̂ array-max residual >100%/iteration at large ŵ) even as Δ_loc + stabilized, and the array-averaged residual hid locally-divergent regions + [CHECKED: L23 §3.1.5, §6.1.1]. Islands solves (g_j, Φ̃) as one Newton system; + convergence is measured by the global residual norm *and* its spatial max, + both archived. +- **Preconditioning** (the make-or-break): physics-block preconditioner — + approximate inverse built from the ξ-averaged, drift-free operator + (streaming + collisions per species: block-tridiagonal-ish in x per (λ, E)), + a Schur-type block for Φ̃ (and A_∥ at L3), plus the explicit y_c matching + block of §3. Cheap to factor, captures the stiff parallel/collisional + physics; Krylov handles drifts and nonlinear coupling. Iteration counts vs. + p logged; preconditioner quality is a tracked metric, not folklore. +- Krylov: GMRES (restarted) via Krylov.jl or LinearSolve.jl; matrix-free JVP + through the operator stack (ForwardDiff duals). No global sparse Jacobian is + ever formed except in tiny-grid debug mode (also useful for eigenvalue + diagnostics near folds and for the y_c singular-value monitor). + +## 6. Cost model and why matrix-free is mandatory (prior-art data point) + +kokuchou's y-sweep shooting method stores dense (n_ξn_p)² recursion blocks: +at n_ξ = 30, n_p = 145 that is ≈16.6 GB *per energy grid point* and +O(0.67 N³) ≈ 5.5×10¹⁰ flops per y-point (0.4 hr/energy-point on ARCHER2, +O(100 GB) RAM total) — and memory, not physics, set its ν_★ floor and ŵ +ceiling [CHECKED: L23 pp. 80–84]. Islands' matrix-free Newton–Krylov stores +O(#dof) vectors instead; the trade is preconditioner engineering (§5). Every +solve logs a cost-model entry (dof, iterations, wall time) — the emulator +strategy depends on knowing what a point costs, and the L23 numbers are the +baseline to beat. + +## 7. Trace-species linear pass + +One GMRES solve per trace species with frozen bulk fields; same preconditioner +with the trace species' collision blocks. Sensitivities of Δ w.r.t. trace +parameters (n_W, Z̄_W, alpha source) are nearly free here (linear problem + +AD) — the W/EP parameter scans (docs/02) should exploit this before any bulk +rescans. + +## 8. Surface generation & emulator posture (M12) + +- Continuation walks generate curves; a scheduler tiles (ŵ, ω̂_E) planes per + remaining-parameter grid point, warm-starting from nearest neighbors. (L23's + practical trick — warm-starting Φ̂ from a stable neighboring run to avoid the + spurious branch — is the same mechanism; continuation makes it systematic.) +- Store everything (docs/03 §4); train emulator (GP or small NN — decide later) + on (p → Δ_cos, Δ_sin) with AD sensitivities as extra supervision. +- Publish surfaces with the named configuration and grid tier; emulator + uncertainty must exceed measured grid-convergence error or the tier is bumped. + +## 9. Language/tooling specifics + +Julia ≥ LTS current at project start. Key packages: Krylov.jl / LinearSolve.jl, +ForwardDiff.jl (+ Enzyme.jl evaluation), FFTW.jl, Interpolations.jl (equilibrium +ingest; mind boundary conditions), HDF5.jl/JLD2.jl, TOML stdlib. Threading with +per-thread preallocated caches (no allocation in `apply!` hot paths — enforce +with an allocation regression test). Revise.jl workflow assumed; keep +world-age-safe (no runtime `eval` in the stack). All hot kernels `@inbounds`-safe +with explicit bounds-check test coverage first. diff --git a/docs/src/islands/design/05-verification.md b/docs/src/islands/design/05-verification.md new file mode 100644 index 000000000..605254da4 --- /dev/null +++ b/docs/src/islands/design/05-verification.md @@ -0,0 +1,168 @@ +# 05 — Verification ladder + +The project's credibility is this document. Every claim of the form "Islands +generalizes X" is backed by "Islands reproduces X in its limit." Benchmarks are +code in `benchmarks/islands/`, each with: named configuration, target (formula/number/ +dataset), tolerance, grid-convergence requirement, and status. CI runs a fast +subset; full ladder runs before any tagged release or paper submission. + +Source abbreviations and the [CHECKED]/[VERIFY] tag semantics per docs/01 +header; the reference-library map is docs/08. Targets below marked [CHECKED] +have been transcribed from the in-repo PDFs with equation/page cites and await +one human sign-off; remaining [VERIFY] items state exactly what is missing. + +## Target tiers and reproducibility (normative — Decision D9) + +Reproducing an *absolute* number from a publication requires every input of +that publication's exact scenario — profiles/gradients, ν★, ω_E, τ, q_s, ŝ, +domain size L_x, resolution, boundary treatment, and even the +threshold-extraction procedure — and publications under-specify these (the +type specimen is B5a below: I19 is *internally inconsistent about its own run +collisionality*). Following the SLAYER-validation precedent (Park 2022 / +Burgess 2026, docs/08 B26), the primary literature-facing physics checks are +**scaling and regime behavior**, not single-number matches. Every target +carries a tier: + +- **T1 — exact (mathematical).** Scenario-independent: MMS orders, + conservation/entropy identities, closure *constants* that are well-defined + integrals (k, f_p, ⟨ν̂_ii⟩_u). Tight pass/fail. +- **T2 — internal cross-checks (we control all inputs).** DK vs RDK + cross-check mode, 4D vs 5D, and **differential/toggle results measured + within Islands itself** (e.g. the E1 drift-model toggle ratio). Ratios and + differentials cancel shared input uncertainty — they are the sharpest + quantitative statements available and are the **primary quantitative + physics gates**. Pass/fail. +- **T3 — scaling/regime checks vs literature (primary literature-facing + gates).** Power-law exponents, trend signs, regime boundaries, landmark + existence, curve morphology. Pass/fail on exponent/sign/existence within a + stated window. +- **T4 — quantitative reproduction attempts (aspirational, audit-gated).** + Absolute literature numbers. **Never pass/fail** until an + **input-completeness audit** of the source yields a full *input manifest* + (template below): every required input either cited to the paper or + recorded as "unspecified → assumption + sensitivity scan required". An + incomplete manifest permanently downgrades the target to T3 + (order-of-magnitude + trend), and any residual gap is reported together + with an input-sensitivity scan (∂(result)/∂(input) over plausible input + ranges) quantifying how much of the discrepancy input uncertainty can + explain. + +**Input-manifest template** (per T4 target, filled during the audit and +published with any comparison): ε; q_s and ŝ (or L̂_q); density/temperature +gradients (L̂_n, L̂_T or η_j); τ = T_e/T_i; ν★ per species; ω_E; m/n; domain +half-width L_x; grid resolution / convergence statement; far-field boundary +treatment; threshold-extraction procedure (which Δ crosses zero, at what +assumed Δ′); Δ′ convention; frame convention; species list. Each entry: value ++ where the source states it, or "unspecified". + +**Standing triage rule for York-lineage targets**: L23 §2.6 documents errors +in the published I19 equation set (docs/01 header warning). Where Islands +disagrees with a published DK-NTM number but agrees with the L23-amended +physics, the triage outcome "their published equation set" is available — but +only after the [VERIFY] resolution is logged with the specific amended term. + +## A. Structural (pre-physics) + +| ID | Target | +|---|---| +| A1 | MMS: per-operator and assembled-system convergence at design order | +| A2 | JVP vs. finite-difference residual directional derivatives | +| A3 | Symmetry/parity relations of Δ_cos, Δ_sin (docs/01 §6) | +| A4 | Conservation: particles (L0); +momentum/energy per collision pair (L1); discrete entropy sign | +| A5 | Zero-drive null test: g ≡ 0, residual = machine zero | +| A6 | 4D orbit-averaged mode = θ-average of 5D mode (once L2 exists) | +| A7 | Closure identities (**T1 — scenario-independent mathematical constants/identities, tight pass/fail**): ⟨∂²h/∂x²⟩_Ω = 0; k → −1.173; f_p → 1−1.46√ε; analytic ⟨ν̂_ii⟩_v [CHECKED: L23 Ch. 4 — kokuchou's unit set, which caught inherited DK-NTM bugs] | +| A8 | Trapped-passing block conditioning monitor: smallest singular value of the y_c matching block tracked; regression = silent-noise failure mode of L23 §4.2 | + +## B. Level 0–1 physics + +| ID | Target | Source / configuration | +|---|---|---| +| B1 | **No-island limit**: bootstrap current & neoclassical flows vs. NEO/NCLASS across ν_★; single- and multi-species | NEO; Sauter PoP 6, 2834 (1999) | +| B2 | Large-w limit — **T3 (primary)**: the Δ_bs+Δ_cur ∝ 1/w and Δ_pol ∝ 1/w³ scalings, and the parametric trend ε^{1/2}(L_q/L_p)(β_θ/w). **T4 (audit-gated)**: the 1/w *coefficient* against WCHH96 Eq. (85) mapped to the island frame (Diss19 p. 86 frame caveat) | [CHECKED: Diss19 pp. 84–86; D21 Fig. 8-class curves] | +| B3 | Curvature: GGJ/D_R contribution in the appropriate fluid-ish limit; note Δ_cur = O(ε²), negligible in the York configs — pick a configuration where it isn't [VERIFY accessible configuration — may require L4 transport toggle] | Glasser–Greene–Johnson 1975; in-repo GGJ inner-layer model (src/InnerLayer/GGJ) as cross-check | +| B4 | Polarization — **T3 (primary)**: (i) Wilson–Connor collisionless and Smolyakov collisional *scalings*; (ii) Δ_pol ∝ ω_E² away from zero with **a sign reversal existing** at an ω_E of order −ω_dia,e, reversal location insensitive to w/ρ_θi; (iii) torque-balance roots (Δ_sin = 0) *existing* at discrete ω̂_E. **T4 (audit-gated)**: the reversal location (sources: ≈ −0.89 ω_dia,e) and root values (sources: ±0.93, ±1.28 ω_dia,e) | WCHH96; Waelbroeck & Fitzpatrick PRL 78 (1997); [CHECKED: D23b Fig. 8; Diss19 Fig. 4.18] | +| B5a | **DK-NTM threshold comparison** — **T3 (primary)**: a finite threshold *exists* at w_c ~ O(ρ_θi) in the `:original`-drift configuration (circular, ε = 0.1, L̂_q = 1, m/n = 2/1, T_e = T_i, ω_E = 0). **T4 (audit-gated)**: the absolute value (sources: w_c ≃ 2.76 ρ_θi half-width ≡ 8.73 ρ_bi at ε = 0.1; the "8.73" is a unit conversion, not printed in I19). The audit's type specimen: [VERIFY: I19 is internally inconsistent on its own run collisionality — §4.2 states ν_★ = 0.01; L23 p. 82 quotes DK-NTM at ν_★ = 10⁻³] — an *absolute* match is not meaningful until the input manifest resolves this | [CHECKED: I19 Fig. 9; PRL p. 4] | +| B5b | **RDK-NTM improved-model comparison** — **T2 (primary, promoted)**: the **drift-model toggle differential** measured within Islands — `:original` → `:improved` (L̂_B⁻¹ = 0 proxy) reduces w_c by a factor ~6 in an otherwise identical configuration (the robust form of the sources' "8.73 → 1.46 ρ_bi" story; shared input uncertainties cancel in the ratio). **T4 (audit-gated)**: the absolute value (sources: w_c ≈ 0.45 ρ_θi ≡ 1.41–1.47 ρ_bi half-width; config as B5a with ν_i★ = 10⁻³–10⁻⁴, Φ′_eqm = 0, η_j = 1) | [CHECKED: D21 abstract + Fig. 8; D23a abstract] | +| B5c | **kokuchou finite-ν_★ comparison** — **T3 (primary)**: the threshold *grows with ν_★* (dw_c/dν_★ > 0, roughly linear over ν_★ ∈ [5, 20]×10⁻³) and scales ∝ ρ̂_θi — the ν_★-dependence is the new physics. **T4 (audit-gated; best audit candidate — a thesis documents its numerics most fully)**: the fitted surface w_c[r_s] ≈ 0.440 ρ̂_θi + 0.0178 ν_★ − 7.54×10⁻⁵ (2D OLS, R² = 0.9916; validity ρ̂_θi ∈ [1,5]×10⁻³, ε = 0.1, ω_E = 0, m/n = 2/1, **L23-amended equation set**) | [CHECKED: L23 Eqs. 6.3.1–6.3.2, Figs. 6.10–6.12] | +| B6 | **T3 (morphology — qualitative by nature)**: Δ vs. w curves and separatrix-layer structure vs. published RDK-NTM figures: D23b Fig. 3 (layer-resolved g at both σ), Fig. 4 (g(p_φ) separatrix zoom), Fig. 6 (u_∥i contours with/without layer), Fig. 7 (δΦ contours), Fig. 9 (Δ_neo, Δ*_bs, Δ_pol vs. w against 1/w and 1/w³). **T2 (internal)**: the layer-on/layer-off threshold *differential* measured within Islands (sources report w_c 0.78 → 0.52 ρ_θi at ω_E = 0, ν_i = 10⁻³ — the ~⅓ reduction is the comparable, the absolutes are T4) | [CHECKED: D23b §3–4] | +| B7 | **T2 (primary quantitative gate — we control all inputs on both sides)**: DK mode vs. RDK cross-check mode agreement above the documented ν̂ floor; prior-art agreement window ν_★ ~ 10⁻³–10⁻⁴ (D21 Appendix C benchmarked DK-NTM against RDK-NTM there); note kokuchou could not operate below ν_★ = 5×10⁻³ — beating that floor is an Islands numerics deliverable | internal; [CHECKED: D21 App. C; L23 §5.3] | +| B8 | W minority: multi-species neoclassical fluxes & bootstrap modification vs. NEO multi-species; PS-impurity/banana-bulk mixed regime | NEO | +| B9 | Threshold-vs-experiment context check (not pass/fail): La Haye NSTX+DIII-D fit w_c = 0.26 ρ̂_θi ≈ 0.955 ρ_bi half-width (quoted as full width 1.91 ρ_bi in the source), vs. B5b/B5c — the residual gap (rotation, shaping, finite ε) is the Level 2–4 motivation | [CHECKED: L23 pp. 131–132, Fig. 6.12; La Haye 2012] | + +## C. Level 2 physics + +| ID | Target | +|---|---| +| C1 | General-geometry no-island neoclassics vs. NEO on a shaped numerical equilibrium | +| C2 | Orbit frequencies ω_b, ω_t, ω_D(λ, E) vs. standalone guiding-center integrator and large-ε analytic formulas; includes the :original/:improved ω̂_D forms (docs/01 §2.1) as analytic checks | +| C3 | Circular/low-β toggle set reproduces Level 0–1 results on the same equilibria | +| C4 | Shaping/finite-β vs. D23a — **T3 (primary)**: (i) triangularity is *destabilizing* (w_c grows as δ decreases; trend direction also seen in the DIII-D 2/1 onset database); (ii) the aspect-ratio *crossover* — w_c ∝ ε^{1/2}ρ_θi at small ε turning to ∝ ρ_θi beyond ε ≈ 0.3; (iii) w_c *grows with β_θ* (trend vs. EAST 91972 context). **T4 (audit-gated)**: the absolute widths (sources: 2w_c 1.82 ρ_bi at δ = +0.42 → 2.90 ρ_bi at δ = −0.5, at ν_★ = 10⁻⁴, m/n = 2/1, Miller geometry) | [CHECKED: D23a §6.3, Figs. 5–9] | +| C5 | Slowing-down F₀: analytic slowing-down flux/current limits in no-island geometry [identify best analytic target — candidate: classical alpha-driven current / electron shielding results] | +| C6 | Precession resonance: trace-EP Δ contribution vs. a reduced analytic resonant-response model in a controlled limit [derive companion analytic limit as part of the study — publishable on its own; the bounce/angle-variable + separatrix-layer machinery of Dudkovskaia JPCS 1125 012009 (2018) is the methodological antecedent] | + +## D. Level 3–4 physics + +| ID | Target | +|---|---| +| D1 | **Linear limit vs. SLAYER**: (Δ_cos + iΔ_sin)(Q) across drift-MHD regimes; frame/Q mapping documented. **Prerequisite**: the in-repo SLAYER Δ(Q) lands with the Tearing module PR (#238, `src/Tearing/InnerLayer/SLAYER/`), sequenced before Islands M0 (docs/00, docs/06 §1); this benchmark then calls it directly in CI. Published Park 2022 curves remain the independent cross-check. [VERIFY: Park PoP 29 (2022) conventions — paper not in the reference library; acquire] | +| D2 | Constant-ψ recovery: prescribed-island results re-derived as the small-Δ′, w ≫ δ_layer limit of the self-consistent solve | +| D3 | Fluid-limit toggles vs. an established nonlinear cylindrical two-fluid code (TM1-class case) for island growth and penetration | +| D4 | Penetration bifurcation: fold structure and hysteresis qualitatively vs. Fitzpatrick 1998; thresholds with L4 torque balance vs. SLAYER-derived thresholds in the linear limit | +| D5 | w_d: threshold island width scaling vs. Fitzpatrick PoP 2, 825 (1995) with the χ⊥ operator on [VERIFY target formula] | +| D6 | Radiative island: growth/threshold trends vs. published thermo-resistive island model (Gates–Delgado-Aparicio class) [select specific reference case] | +| D7 | Torque moment: linear-limit Δ_sin ↔ SLAYER torque; NTV-side consistency check against the in-repo KineticForces (PENTRC) module in the appropriate limit [scope carefully — may be a paper, not a benchmark] | + +## E. The toggle-impact studies (deliverable science, run on the ladder) + +Not pass/fail — measured differences, each a figure or paper section: + +- E1: drift-frequency model :original vs. :improved — the **T2 toggle + differential** (the ~×6 w_c reduction measured within Islands, the robust + form of the sources' 8.73 → 1.46 ρ_bi finding; B5a/B5b configs), then extend + the *ratio* across parameter space. The toggle is one term (L̂_B⁻¹ handling, + docs/01 §2.1) — the cheapest high-impact study in the program, and a ratio + so it needs no absolute-input manifest. +- E2: orbit-averaged 4D vs. full 5D (the O5 ordering's cost). +- E3: pitch-angle vs. full FP collisions across ν̂ (the O6 cost); plus the + energy-dependence sub-toggle ν(v) Chandrasekhar-form vs. V⁻³ (I19 vs. D21 + operator variants, docs/01 §2.3) — quantifies a known inconsistency *within* + the York lineage. +- E4: flattened vs. kinetic electrons (O7) — the DK-NTM/kokuchou closure vs. + the RDK-NTM kinetic treatment; mandatory before trusting anything at small w + at L3. Includes reproducing (or refuting) L23's unexplained stabilizing + electron Δ_pol at ω_E = 0 (L23 §6.2.2/§7) — a live physics question the + ω_E scan can settle. +- E5: local vs. profile-variation domains for EPs (O2 cost at large ρ_θα). +- E6: fixed-ω_E vs. torque-balance ω_E (O4) — the polarization-term + sensitivity study; publish Δ surfaces both ways. D23b Fig. 8 (sign reversal + at ω_E ≈ −0.89 ω_dia,e) is the anchor curve. + +## Reporting rules + +1. No benchmark "passes" on a single grid: convergence demonstrated, tolerance + stated, both archived with the result. +2. Every figure in every paper names its configuration (docs/03 §2) and git SHA. +3. Disagreements with published targets are triaged as {our bug, their + approximation, **their published-equation error** (see standing triage + rule), transcription error, **under-specified or irreproducible source + configuration** (T4 targets with incomplete input manifests)} — with + [VERIFY] resolution logged in this file's history before the triage + concludes anything other than "our bug." +4. Every ladder benchmark ships with a figure script per the docs/07 §2 + pipeline; the same script feeds CI artifacts, the state gallery, and paper + panels. Ladder status renders automatically into docs/src/islands/state/STATE.md — this + file defines targets; the dashboard reports reality. +5. Threshold numbers are always reported as **half-widths with the unit + stated** (ρ_θi and ρ_bi = ε^{1/2}ρ_θi both given at the run's ε), because + the literature mixes half/full widths and both gyroradius units (docs/01 §1). +6. Any T4 comparison publishes its **input manifest** (template above) + alongside the result — a comparison without a manifest is not a comparison. +7. **Prefer differential/ratio comparisons over absolute ones** wherever the + sources permit: toggle differentials, trend slopes, and dimensionless ratios + cancel shared input uncertainty and are the project's strongest quantitative + claims (Decision D9). +8. Any claimed quantitative agreement *or* disagreement with a T4 target is + accompanied by an **input-sensitivity scan** over the manifest's + unspecified/assumed entries, so the reader can judge how much of the + residual input uncertainty explains. diff --git a/docs/src/islands/design/06-autonomy-and-tooling.md b/docs/src/islands/design/06-autonomy-and-tooling.md new file mode 100644 index 000000000..bfdc7b33c --- /dev/null +++ b/docs/src/islands/design/06-autonomy-and-tooling.md @@ -0,0 +1,286 @@ +# 06 — Autonomy, tooling, and GPEC-repo integration + +Audience: (a) the human setting up the environment once, (b) every Claude Code / +Fable session working this project. Sections marked **[HUMAN SETUP]** are +one-time actions; everything else is standing guidance for agent sessions. + +--- + +## 1. Living inside the GPEC repo (supersedes docs/03 §5 where in-repo) + +Islands develops inside the OpenFUSIONToolkit GPEC repository as a **submodule of +the GeneralizedPerturbedEquilibrium package** (`src/Islands/`, `module Islands` — +no separate `Project.toml`; it shares the repo's environment and CI). Layout: +code in `src/Islands/`, docs in `docs/src/islands/` (see `src/Islands/CLAUDE.md` +for the full map): + +- **The interfaces become function calls.** docs/03 §5 specified file-based + interfaces to Δ′ and SLAYER because a standalone repo needed them. In-repo, + everything Islands consumes arrives with the Tearing module work (PR #238, + `feature/tearing-growthrates`): SLAYER Δ(Q) (Fitzpatrick Riccati layer model, + `src/Tearing/InnerLayer/SLAYER/`), GGJ under the same `InnerLayer` interface, + the dispersion/root-finding layer (`src/Tearing/Dispersion/`), and the + outer-region Δ′ exposed as the full 2m×2m matrix (`delta_prime_raw` / + `pest3_decompose` from ForceFreeStates, fed by the new `Riccati.jl` ideal + solver) — all as direct Julia calls, no digitization, no format drift. + Equilibrium representations are reused, not re-ingested. This is the single + biggest win of colocating. **Sequencing:** #238 lands before Islands M0; if + Islands starts earlier, branch from `feature/tearing-growthrates` (docs/00 + milestone sequencing) so the interfaces are in hand from the first commit. + On `develop` today the old `src/InnerLayer/SLAYER/Slayer.jl` is still a + placeholder — do not code against `develop`'s module layout; #238 also moves + GGJ under `src/Tearing/`. `src/KineticForces` (PENTRC/NTV) exists on + `develop` and is the natural Level-4 torque-balance counterpart. +- **Namespace discipline**: Islands imports GPEC; GPEC never imports Islands until + Islands is stable enough to be a documented feature. One-way dependency, + enforced by CI (a test that greps GPEC sources for `using .Islands`-type + references). +- **CLAUDE.md layering**: Claude Code loads nested CLAUDE.md files; the repo + root keeps GPEC-wide conventions (including the existing merge-conflict + synthesis policy), and `src/Islands/CLAUDE.md` applies within the module + subtree. On conflict, the more specific file governs for work inside + `src/Islands/`; flag genuine contradictions to the human rather than + resolving silently. +- **CI**: Islands tests/benchmarks run as separate CI jobs so a red Islands ladder + never blocks unrelated GPEC merges (and vice versa), but the SLAYER/DCON + cross-checks run in *both* suites once they exist — they protect the + interface from both sides. +- **Branch discipline for autonomous work**: `main` protected; all agent work + on `feature/islands--` branches via PR (GPEC GitFlow + convention, repo-root CLAUDE.md); parallel milestones (e.g. + M3–M4 alongside M7, per docs/00) in separate git worktrees so concurrent + sessions never share a working tree. + +## 2. The operating model: autonomy without babysitting + +The goal is milestone-sized unattended runs with human attention concentrated +at a few high-leverage points. Three mechanisms make this safe: + +### 2.1 Machine-checkable definition of done + +Every autonomous run is launched against a milestone (docs/00) whose exit +criterion is a set of ladder IDs (docs/05) going green plus CI passing. "Done" +is never a judgment call. Launch prompts follow this template: + +> Work milestone M per docs/00-roadmap.md. Definition of done: ladder IDs +> green with convergence artifacts archived, full test suite passing, +> PR opened with the docs/05 reporting requirements. If blocked, follow the +> escalation protocol (docs/06 §2.2) and continue with the next parallelizable +> task. Do not weaken a benchmark tolerance or re-baseline a target to reach +> done; that is a blocker, not a fix. + +### 2.2 Non-blocking escalation: `docs/src/islands/QUESTIONS.md` + +Autonomous runs must never stall waiting for a human, and must never guess on +the things CLAUDE.md forbids guessing (coefficients, signs, normalizations, +[VERIFY] clearances). The resolution: an append-only `QUESTIONS.md` queue. +When blocked, the agent writes an entry — context, the specific question, +options considered, its recommendation, and what work is gated — then switches +to the next unblocked task. The human's recurring job is clearing this queue +(and [VERIFY] tags), not supervising sessions. Entries get IDs; commits/PRs +reference the IDs they were blocked on or unblocked by. + +### 2.3 Guardrails in tooling, not vigilance + +**[HUMAN SETUP]** in the repo-root `.claude/settings.json` (the checked-in +project settings shared across GPEC; add Islands-specific rules here): + +- `permissions`: allow the routine loop (edit within repo, `julia --project` + test/benchmark commands, git branch/commit/push, `gh pr` on this repo); deny + destructive git (force-push, `push` to main), package registry publishes, + and credential paths (extend the existing personal deny rules — keep those + global, add repo-specific ones here so collaborators inherit them). +- Truly unattended runs (`--dangerously-skip-permissions` or equivalent + auto modes) only inside a container/devcontainer or on a cluster node with a + scratch clone — never on a laptop checkout with credentials in reach. The + existing tmux-on-cluster workflow is the natural home for long runs: one + tmux window per worktree per milestone. +- **Hooks** (checked in with the project): PostToolUse on Edit/Write → run the + fast test subset for the touched module (seconds, not the ladder); a Stop + hook that blocks session completion if the working tree has uncommitted + changes or failing fast tests; PreToolUse on Bash → deny-pattern for + destructive commands as a second layer under permissions. +- **Checkpointing** stays on (default) so exploratory refactors are cheaply + reversible. + +Consult https://code.claude.com/docs/en/ (docs map) when configuring — hook +names, permission syntax, and flags change; verify against the installed +version rather than this document. + +### 2.4 GitHub Actions layer + +**[HUMAN SETUP]**, building on the prior GPEC GitHub-App exploration (org +permission constraints noted there still apply — may need org-owner action): + +- `anthropics/claude-code-action` for PR review on `src/Islands/**` and + `docs/src/islands/**` paths, with a + review prompt pointing at CLAUDE.md and docs/: check [VERIFY] policy + compliance, no regime-branches in operators, ladder IDs addressed. +- Issue-triggered autonomous work: label `claude-task` on an issue containing + a milestone-template prompt → action runs headless (`claude -p`) in a + container, opens a PR. This is the "assign work from a phone" channel. +- Scheduled (cron) workflows: nightly fast-ladder regression + allocation + regression; weekly full ladder on `main`. Failures open issues automatically + (which can themselves be `claude-task`-labeled — closing the loop on + maintenance without human dispatch). +- Secrets: `ANTHROPIC_API_KEY` as a repo/org secret. Local-config gotcha from + prior experience: if Claude Code ignores a correctly set key and shows the + login menu, check `~/.claude.json` for a stale `customApiKeyResponses.rejected` + entry. + +### 2.5 Session-level habits (standing guidance for agents) + +- Start milestone work in plan mode; write the plan against docs/00 before + editing. Persist the session objective (goal/directive mechanisms) so long + sessions don't drift from the milestone definition of done. +- Delegate to subagents (§4) for review/verification passes so the main + context stays on implementation; summarize subagent findings into the PR. +- Commit granularly with ladder/QUESTIONS references; a session that ends + without a pushed branch and status note has failed its exit criteria. +- Post-session, append a short entry to `docs/src/islands/LOG.md`: what moved, what's + blocked, next action. This file is the cross-session memory spine — read it + at session start along with QUESTIONS.md. + +## 3. Get Physics Done (GPD) — assessment and setup + +**What it is**: open-source (Apache-2.0) agentic physics-research command pack +from Physical Superintelligence PBC, released March 2026; installs into Claude +Code (also Codex/Gemini/OpenCode) via `npx -y get-physics-done`, adding a +command ladder (`/gpd:help` → `start` → `tour` → `new-project` / +`map-research` → `resume-work`) plus an autopilot mode for directed autonomous +research. It targets exactly this project's genre — long-horizon problems +needing rigorous verification, structured research memory, multi-step +analytical work, and manuscript preparation — with a stated bias toward rigor +over agreeability. Plasma physics is among its supported subfields. +Repo: https://github.com/psi-oss/get-physics-done + +**Recommendation: install and trial it, scoped to a specific lane.** The +division of labor: + +- **GPD lane — derivation and verification work**: clearing [VERIFY] tags by + independent re-derivation (its verification discipline is exactly the + [VERIFY] workflow's counterpart); deriving the companion analytic limits the + ladder needs (e.g. docs/05 C6 resonant-EP limit); literature mapping + (`map-research`) for the polarization-current sign genealogy before touching + B4; eventually manuscript drafting. GPD derivations feed + `docs/src/islands/derivations/` as `[DERIVED]` artifacts per CLAUDE.md — they *propose* + [VERIFY] clearances; a human still signs off. +- **Native lane — implementation**: code, numerics, CI, benchmarks stay under + the Islands module's CLAUDE.md + docs. GPD is a research harness, not a + software-engineering harness; don't let two workflow systems fight over the + same task. + +**[HUMAN SETUP]** cautions: install project-local first (not global), read +what it injects before granting — a command pack is prompt-layer software and +should be audited and version-pinned like any dependency. Confirm its +project-artifact directories (`GPD/`, `~/.gpd`) are gitignored or deliberately +committed, and that its instructions don't contradict CLAUDE.md (if they do, +CLAUDE.md wins inside `src/Islands/`; note conflicts in QUESTIONS.md). + +Its sibling GSD (general-purpose "Get Shit Done" workflow, which GPD is +modeled on) is an optional trial for milestone execution on the native lane; +adopt only if the plain milestone-prompt + hooks + ladder setup proves +insufficient — more workflow machinery is not automatically better. + +## 4. Skills and subagents + +### 4.1 Public skills **[HUMAN SETUP]** + +Baseline installs from Anthropic's official marketplace +(`/plugin marketplace add anthropics/claude-plugins-official`, and +`anthropics/skills` as a second marketplace): + +- **skill-creator** — the important one. It scaffolds skills interactively and + runs eval loops (test cases in `evals/evals.json`, isolated subagent runs, + graded assertions). All custom skills below get built and *evaluated* with + it rather than hand-written. +- **code-simplifier** — Anthropic's internal cleanup pass (behavior-preserving + simplification); run it at the end of implementation sessions to counter + agent-accumulated complexity. +- Document skills (pdf/docx/pptx/xlsx) as needed for reports; low priority. + +Community directories (skills.sh, claudeskills.info, curated lists) are worth +a periodic browse, but the ecosystem is flooded and physics-specific offerings +are thin: expect nothing that knows Julia plasma physics. Adopt sparingly, +pin versions, and audit anything that runs scripts. A community Julia skill, +if a well-maintained one exists at setup time, can seed §4.2's `julia-conventions` +skill; verify currency (Julia ecosystem skills go stale fast) and strip +anything conflicting with project conventions. + +### 4.2 Custom project skills (the real leverage; build with skill-creator) + +These live in-repo (`.claude/skills/` at the appropriate level) so every +session and CI agent inherits them. They exist to make *subagents and fresh +sessions* cheap to orient — progressive disclosure of exactly the context that +would otherwise be re-explained: + +1. **gpec-map** (repo root): GPEC/OFT architecture — where DCON Δ′, SLAYER, + equilibrium representations, and coordinate machinery live; module naming; + how to run each test suite; SFL coordinate conventions (Hamada/Boozer/PEST) + and Jacobian gotchas. Mostly distilled from existing GPEC docs + the + maintainer's head; this skill is the highest-value few hours of human + dictation in the whole setup. +2. **julia-conventions** (repo root): project Julia idioms — Revise workflow, + per-thread preallocation patterns, allocation-test policy, `@inbounds` + policy, OrdinaryDiffEq-vs-QuadGK division of labor, Interpolations boundary + conditions. Encodes the lessons already learned so no session relearns them. +3. **islands-conventions** (repo-root `.claude/skills/`): the load-bearing + distillation of docs/01–05 — half-width convention, frames module rule, + [VERIFY]/[DERIVED] workflow, operator-stack rules, escalation protocol. + Keeps subagents aligned without loading the full docs. +4. **benchmark-ladder** (repo-root `.claude/skills/`): how to run + `src/Islands/verify/` and `benchmarks/islands/`, where reference data lives, + how to read convergence artifacts, what re-baselining requires. Paired with + the ladder from day one. +5. **paper-figures** (later): publication figure conventions (Makie/matplotlib + styles, the editable-SVG lessons), once results exist. + +Maintain skills with the same [VERIFY]-grade discipline as docs: they are +normative context, and a stale skill is worse than none. skill-creator's eval +loop is the regression test. + +### 4.3 Project subagents + +Defined in-repo so they're versioned. Minimal set: + +- **physics-verifier** (read-only tools): audits diffs against docs/01 + conventions and the [VERIFY] policy; adversarial by instruction ("find the + sign error" posture). Runs before every PR. +- **numerics-reviewer** (read-only): convergence-artifact and + allocation-regression review; checks that "passing" benchmarks meet the + docs/05 reporting rules. +- **literature-scout** (read + web): given a [VERIFY] tag, retrieves the source + (arXiv/DOI), extracts the exact equation context, and drafts the clearance + proposal for human sign-off. Pairs with an arXiv/paper-search MCP server if + one is connected **[HUMAN SETUP — optional]**; audit any third-party MCP + server before connecting, same rules as command packs. + +## 5. Human attention budget (what "not babysitting" costs instead) + +Steady state, the human's recurring surface is: (1) the QUESTIONS.md queue, +(2) [VERIFY]/[DERIVED] sign-offs, (3) PR review of milestone branches (with +the action + subagents having pre-reviewed), (4) gate sign-offs and Decision +Log entries at level boundaries. Everything else — implementation, tests, +regression triage, benchmark bookkeeping, nightly maintenance — is delegated. +If any other category starts consuming attention, that's a tooling bug: fix +the hook/skill/prompt, don't absorb the load manually. + +## 6. Setup checklist (condensed) + +**[HUMAN SETUP]**, in order: +1. Create the `src/Islands/` module skeleton (submodule of + GeneralizedPerturbedEquilibrium — no separate `Project.toml`); the design + docs already live under `docs/src/islands/`. Commit docs before any code. +2. Project settings: permissions allow/deny, hooks, checked into the repo-root + `.claude/`. +3. Branch protection on main; worktree convention documented in LOG.md. +4. GitHub Actions: claude-code-action PR review; `claude-task` issue workflow; + nightly/weekly ladder crons; API key secret. +5. Plugin marketplaces + skill-creator + code-simplifier. +6. Build gpec-map and julia-conventions skills (human-dictated, skill-creator + evaluated); islands-conventions and benchmark-ladder skills alongside M0–M1. +7. Define the three subagents. +8. GPD: project-local install, audit, trial on one [VERIFY] clearance and one + derivation task; decide lane adoption after two weeks of use. +9. First autonomous run: M1 (operator-stack skeleton + MMS harness) with the + §2.1 template — deliberately low-physics-risk to shake out the tooling. diff --git a/docs/src/islands/design/07-documentation-and-papers.md b/docs/src/islands/design/07-documentation-and-papers.md new file mode 100644 index 000000000..725b0e352 --- /dev/null +++ b/docs/src/islands/design/07-documentation-and-papers.md @@ -0,0 +1,147 @@ +# 07 — Documentation and the paper series + +Principle: **documentation is a build artifact, not a chore.** The repository +always contains an accurate, current statement of (a) what physics is +implemented, as equations, (b) what state the code is in, and (c) what has been +verified, as figures. Level gates are redefined so that each level concludes +with a journal-grade manuscript whose equations are the implemented equations +and whose figures are the passing verification tests. If the docs and the code +disagree, the build is broken. + +--- + +## 1. The four documentation layers + +### 1.1 The Physics Book — `docs/src/islands/` + +A chaptered, continuously-maintained derivation-and-implementation reference +(Markdown + LaTeX math; rendered with the API docs). One chapter per physics +component: coordinates & conventions, the DKE and each operator, collision +operators, field equations, moments & the MRE assembly, species/backgrounds, +closures. Rules: + +- **As-implemented, not as-aspired.** Every equation in the Physics Book is the + equation the code solves, in the code's normalization, with the code's sign + conventions. Aspirational/planned physics lives in docs/00–02, never here. +- **Bidirectional anchors.** Every operator/term in `src/` carries a comment + citing its Physics Book anchor (`# physics: dke.md#magnetic-drift-improved`), + and every equation block in the Book carries the implementing symbol + (`Implemented by: MagneticDrift{:improved}`). A CI script checks both + directions: no operator without an anchor, no `Implemented by:` pointing at + a nonexistent symbol. Orphans fail CI. +- **[VERIFY]/[DERIVED] tags live here** (migrating from docs/01 as + implementation proceeds); the tag state is part of the rendered page, so the + epistemic status of every equation is always visible. +- **Change discipline:** any PR that changes physics behavior must change the + corresponding Physics Book section *in the same PR* (see §4). + +### 1.2 API documentation — Documenter.jl + +Standard Julia docstrings on all public API, built with Documenter.jl in CI +(doctest-checked) and deployed with the GPEC docs. Docstrings for physics +functions are thin — one-line purpose + the Physics Book anchor — so the +physics prose has a single home. Worked examples via Literate.jl scripts that +double as smoke tests. + +### 1.3 The State Dashboard — `docs/src/islands/state/` + +The always-current answer to "what works right now": + +- `STATE.md` — auto-generated (script in `src/Islands/verify/`, run in CI on main): + a table of levels × orderings showing each toggle's status + (implemented / partial / planned), and the full docs/05 ladder as a status + table (ID, target, status, tolerance achieved, grid tier, date, commit). + Hand-editing this file is forbidden; it regenerates from benchmark artifacts. +- `LOG.md` and `QUESTIONS.md` (docs/06) — session memory and blocker queue. +- The **figure gallery** (§2) index. + +### 1.4 The paper series — `docs/src/islands/papers/` + +Each level's gate deliverable (§3). In-repo LaTeX, figures generated only by +pinned scripts from archived benchmark data. + +## 2. One figure pipeline for tests, gallery, and papers + +The same figure serves three masters — CI verification artifact, gallery page, +paper panel — so there is exactly one implementation: + +- Every ladder benchmark (docs/05) ships with a figure script in + `benchmarks/islands/figures/` that reads *archived benchmark output* (HDF5/JLD2 with + embedded config + git SHA, per docs/03 §4), never re-runs physics. Output: + publication-grade PDF/SVG + a PNG for the gallery. +- Standard verification-figure grammar, enforced by a shared plotting module: + Islands result (points/solid) vs. analytic limit (dashed) vs. published + reference data (symbols, digitized where unavoidable — in-repo SLAYER/DCON + comparisons are computed live in CI, one of the payoffs of colocating); + inset or companion panel showing grid-convergence; caption footer + auto-stamped with configuration name, commit, and ladder ID. +- The gallery (`docs/src/islands/state/gallery/`) is rebuilt in the nightly/weekly ladder + runs — so "are we still meeting the limits we expect" is a page you (or a + collaborator, or a referee) can look at any day, not a claim in a README. +- Papers `\includegraphics` from `benchmarks/islands/figures/` output directly. A paper + figure that cannot be regenerated by `make figures` from archived data is a + release-blocking bug. This is the reproducibility posture stated in + docs/05's reporting rules, made mechanical. + +## 3. The paper series: gates as manuscripts + +A level gate is now: **ladder IDs green + Physics Book chapters complete and +[VERIFY]-cleared for the level's equations + a submission-ready manuscript.** +The manuscript is not overhead on top of the gate; it *is* the gate review — +writing it is how gaps get found. Provisional series (titles indicative; +venues by convention of the field): + +| Paper | Gate | Content | Indicative venue | +|---|---|---|---| +| **I** | Level 0 | Formulation & numerics of the generalized island drift-kinetic solver; verification figures: MMS convergence, no-island neoclassics, large-w bootstrap limit, three-code threshold reproduction (DK-NTM 8.73 ρ_bi → RDK-NTM 1.46 ρ_bi drift-model story + kokuchou's finite-ν_★ w_c(ρ̂_θi, ν_★) surface, docs/05 B5a–c), Δ_pol(ω_E) sign-reversal curve; resolution of the L23 open question (stabilizing electron Δ_pol at ω_E = 0) is a candidate headline result | Phys. Plasmas (methods) | +| **II** | Level 1 | Arbitrary-collisionality unification of bootstrap & polarization MRE terms; figures: Δ vs. ν̂ sweeping banana→PS with analytic corners marked; W minority: friction-modified Δ_bs and in-island impurity asymmetry | Nucl. Fusion | +| **III** | Level 2 | Shaped/finite-β geometry; energetic-particle island response: slowing-down alphas, finite-orbit nonlocality, precession resonance ω ~ ω_D,α; burning-plasma NTM threshold implications | Nucl. Fusion (PRL letter if the resonance result warrants) | +| **IV** | Level 3 | **Flagship:** unification of linear layer (SLAYER limit) and nonlinear island response; the w ~ δ_layer kinetic penetration-to-NTM transition; figures: Δ(w, Q) surface with SLAYER curves recovered on the small-w edge and MRE terms on the large-w edge | PRL/letter + long-form companion | +| **V** | Level 4 | Self-consistent thresholds: torque-balance ω, w_d with explicit χ⊥, radiative/W thermo-resistive islands; confrontation with empirical penetration & onset databases | Nucl. Fusion | +| **VI** | M12 | Δ-surface dataset + emulator; code/data release paper | Comput. Phys. Commun. or NF | + +Rules: + +- **Paper = milestone structure.** Each paper gets `docs/src/islands/papers/paper-N/` with + OUTLINE.md (claims → figure list → ladder IDs backing each claim) created at + level *start*, not level end. The outline is the level's figure contract: + agents implementing benchmarks know which figures are paper figures from day + one. Outline claims lacking a ladder ID are flagged — every claim is backed + by a test. +- **Companion derivations** (e.g. the C6 resonant-EP analytic limit) are + developed in `docs/src/islands/derivations/` (GPD lane, docs/06 §3) and either fold into + the paper or spin out — decided at outline review. +- **Authorship/collaboration** is a human matter; agents draft methods, + verification sections, and figure captions (which they can ground in the + archived artifacts), humans own claims, framing, and submission. No + submission without every [VERIFY] tag in the paper's equation set cleared. +- Interim results that don't warrant a paper still get the same treatment at + milestone scale: a short technical note in `docs/src/islands/notes/` with gallery + figures — the habit is uniform, only the polish varies. + +## 4. Enforcement: docs change when code changes + +Mechanisms, weakest to strongest: + +1. **CLAUDE.md policy** (amended): physics-behavior PRs must update the + Physics Book section and, if outputs change, regenerate affected figures. + The PR description lists doc anchors touched, or states `docs-not-needed:` + with justification (reviewer-visible, greppable, audited). +2. **Anchor-sync CI check** (§1.1): orphaned operators or dangling + `Implemented by:` references fail CI. +3. **State regeneration**: STATE.md and the gallery rebuild on main; a PR that + turns a ladder ID red turns the dashboard red — visible drift, fast. +4. **physics-verifier subagent** (docs/06 §4.3) gains an explicit doc-sync + audit: diff the operator changes against the Physics Book diff and flag + semantic mismatches (the thing CI string checks can't catch). +5. **Doctest/Literate examples** run in CI, so API docs can't silently rot. + +## 5. Division of documentation labor + +Agents maintain: docstrings, Physics Book edits accompanying their own PRs, +figure scripts, STATE regeneration, gallery, first drafts of methods/ +verification prose, technical notes. Humans own: [VERIFY] clearances, Physics +Book review at gates, paper claims/framing/submission, and the outline reviews +that start each level. The recurring human documentation surface is therefore +the same queue-shaped work as docs/06 §5 — review and sign-off, not authorship +from scratch. diff --git a/docs/src/islands/design/08-reference-library.md b/docs/src/islands/design/08-reference-library.md new file mode 100644 index 000000000..137de853a --- /dev/null +++ b/docs/src/islands/design/08-reference-library.md @@ -0,0 +1,65 @@ +# 08 — Reference library + +The primary sources live in-repo at +`docs/resources/Drift_Kinetic_Island_References/`. This file maps each PDF to +its role in the project, its abbreviation used across docs/00–05, and the +load-bearing content a reader (human or agent) should pull from it. Equation +transcriptions from these sources into docs/01 carry [CHECKED]/[VERIFY] tags +per the docs/01 header semantics. + +## The DK-NTM / RDK-NTM / kokuchou lineage (core Level-0/1 sources) + +| Abbrev. | File | What it is | Load-bearing content | +|---|---|---|---| +| **WCHH96** | `1996-Wilson-Threshold_for_neoclassical_magnetic_islands_in_a_low_collision_frequency_tokamak.pdf` | Wilson, Connor, Hastie & Hegna, PoP **3**, 248 (1996), doi:10.1063/1.871830. The foundational analytic NTM-threshold paper of the lineage (added 2026-07-09) | The analytic flattened-electron closure (the h(Ω) construction and coupled electron-flow relation — the primary source behind docs/01 §2.4, previously cited only via I19/L23/Diss19 transcriptions); the large-w bootstrap limit **Eq. (85)** (ladder B2 target); the collisional polarization discussion. First-hand source for the M2b electron-closure derivation cross-checks | +| **PRL18** | `2018-Imada-Nonlinear_Kinetic_Ion_Response_to_Small_Scale_Magnetic_Islands_in_Tokamak_Plasmas.pdf` | Imada et al., PRL 121, 175001 (2018). First announcement of DK-NTM | Compact statement of the drift-island result (w_c ≃ 2.7 ρ_θi); **caution: uses ψ_s-based normalizations, different from I19's r_s-based ones** — see docs/01 §1 | +| **JPCS18** | `2018-Imada-Drift_kinetic_response_of_ions_to_magnetic_island_perturbation_and_effects_on_NTM_threshold.pdf` | Imada et al., Varenna proceedings (2018) | Condensed DK-NTM derivation; explicit electron-flow and h(Ω) formulas; renames Δ′_bs → Δ′_loc; MRE context incl. Δ_pol ∝ 1/w³ discussion | +| **I19** | `2019-Imada-Finite_ion_orbit_width_effect_on_the_neoclassical_tearing_mode_threshold_in_a_tokamak_plasma.pdf` | Imada et al., NF 59, 046016 (2019). The complete DK-NTM reference paper | Full equation hierarchy (Eqs. 23–34); master 4D equation Eq. (32); S-function Eq. (33); collision operator Eqs. (9)–(12); electron closure §3 (Eqs. 14–22); numerics appendix (shooting method, y_c matching Eqs. A.7–A.10, Picard loops, Eq. A.11 quasineutrality); w_c ≃ 2.76 ρ_θi (Fig. 9). **Known errata: see L23 §2.6 amendment list** (docs/01 header warning) | +| **Diss19** | `2019-Dudkovskaia-Modelling_NTMs_in_tokamak_plasmas_PhD_dissertation.pdf` | Dudkovskaia PhD dissertation, York 2019 | The full RDK derivation chain: island geometry & Ω convention (Ch. 2), S-coordinate reduction, trapped-passing layer analytics (Ch. 3, width √(ν/εω) in λ), Δ_neo normalization (Eq. 4.12) and bootstrap/polarization split (Eqs. 4.13–4.15), frame identities ω₀ = −ω_E (pp. 47–48), torque-balance roots (Fig. 4.18), **complete solver coefficient sets in Appendices C–E (Eqs. D.60–D.62)** — the RDK cross-check mode's spec | +| **D21** | `2021-Dudkovskaia-Drift_kinetic_theory_of_neoclassical_tearing_modes_in_a_low_collisionality_tokamak_plasma_magnetic_island_threshold_physics.pdf` | Dudkovskaia et al., PPCF 63, 054001 (2021). RDK-NTM v.1 | The improved magnetic-drift model (App. A, Eq. A2: cos θ structure of ∂B/∂ψ; L̂_B⁻¹ = 0 proxy, footnote 10) → w_c ≈ 0.45 ρ_θi ≡ 1.41–1.47 ρ_bi half-width; DK-NTM benchmark in App. C (agreement window ν_★ ~ 10⁻³–10⁻⁴); threshold-mechanism statement (§7: passing-particle physics) | +| **D23a** | `2023-Dudkovskaia-Drift_kinetic_theory_of_the_NTM_magnetic_islands_in_a_finite_beta_general_geometry_tokamak_plasma.pdf` | NF 63, 016020 (2023). RDK-NTM v.2: finite β, shaped (Miller) geometry | Authoritative "8.73 → 1.46 ρ_bi half-width" statement (abstract); finite-β drift terms (Eqs. 28–31); Miller parametrization (Eq. 33); shaping results: triangularity 2w_c = 1.82 ρ_bi (δ=+0.42) → 2.90 ρ_bi (δ=−0.5); ε ≈ 0.3 crossover of the w_c scaling; β_θ trend vs. EAST 91972 (ladder C4) | +| **D23b** | `2023-Dudkovskaia-Drift_kinetic_theory_of_neoclassical_tearing_modes_in_tokamak_plasmas_polarisation_current_and_its_effect_on_magnetic_island_threshold_physics.pdf` | NF 63, 126040 (2023). RDK-NTM v.3: separatrix layer + polarization + ω dependence | **Table 1** = the code-family comparison (DK-NTM vs RDK v.1/v.2/v.3); both layer widths ∝ ν^{1/2} (§3.1, footnote 11); ω_E as input parameter; Δ_pol(ω_E) sign reversal at ≈ −0.89 ω_dia,e (Fig. 8); layer effect on threshold 0.78 → 0.52 ρ_θi (Fig. 9); the B6 figure set (Figs. 3, 4, 6, 7, 9, 11, 13) | +| **L23** | `2023-Leigh-Drift_kinetic_simulations_of_Neoclassical_Tearing_Mode_instabilities_in_finite_collisionality_tokamak_plasmas.pdf` | Leigh PhD thesis, York Dec 2023. The `kokuchou` code (DK-NTM successor, finite ν_★) | **§2.6 amendment list against I19 Eq. (A.1)** (the [VERIFY] policy's empirical justification); WCHH96 electron closure spelled out (§2.4–2.5, k = −1.173, f_p = 1−1.46√ε); TSVD treatment of the singular y_c matching matrix (§4.2); separatrix-layer width scalings incl. the iteration-dependent E×B regime (§6.1.2); Picard non-convergence forensics (§6.1.1); spurious "winged" Neumann branch (§5.3, §7.1); memory/cost data for the dense shooting method (pp. 80–84); **w_c ≈ 0.440 ρ̂_θi + 0.0178 ν_★ − 7.54×10⁻⁵** (Eq. 6.3.2, ν_★ ∈ [0.005, 0.020]); future-work list §7.1 (mapped p̃ coordinate, analytic far-field BC) | + +## Background / adjacent + +| Abbrev. | File | What it is | Role | +|---|---|---|---| +| **JOP18** | `2018-Dudkovskaia-Island_Stability_in_Phase_Space.pdf` | Dudkovskaia, Garbet, Lesur, Wilson — JPCS 1125, 012009 (2018) | **Not about magnetic islands.** Bump-on-tail *phase-space* island stability (Vlasov–Fokker-Planck–Poisson secondary modes). Relevant only as (a) the methodological antecedent of the RDK bounce/angle-variable and separatrix-layer machinery, (b) EP-physics background for the Level-2 precession-resonance study (ladder C6). Do not cite it as an NTM threshold source | +| **B26** | `../2026-Burgess-Tearing Stability Prediction Combining Toroidal Calculations With a Two-Fluid Slab Layer.pdf` (general `docs/resources/` dir) | Burgess et al. (2026): tearing stability prediction combining toroidal outer-region calculations with the Park 2022 SLAYER two-fluid slab layer | **The methodological template Islands generalizes** (user-flagged, 2026-07-09): outer-region toroidal Δ′ matched to a regime-generalized *linear, zero-width* inner layer (SLAYER Δ(Q)) gives classical-tearing stability across drift-MHD regimes. Islands is the same architecture with the inner region extended from the zero-width linear layer to finite-width islands (NTMs) — the D1/L3 unification target and the `Δ_cos + iΔ_sin ↔ Δ(Q)` small-amplitude limit (ladder D1). Read alongside Park 2022 for the Q-convention and the outer/inner matching interface (`docs/03 §5`) | + +## Referenced but not yet in the library (acquire) + +- ~~WCHH96~~ **Acquired** (2026-07-09): now in the library table above. +- ~~Park, Phys. Plasmas 29 (2022) — SLAYER. Needed for the D1 Q-convention + map.~~ **Found in-repo** (2026-07-09): it lives in the general GPEC library, + `docs/resources/2022-Park-Parametric dependencies of resonant layer responses + across linear, two-fluid, drift-MHD regimes.pdf` — outside this island + subfolder, which is why this map missed it. The D1 Q-convention `[VERIFY]` + can be worked from that file; see also **B26** above for the SLAYER-based + outer/inner matching workflow Islands generalizes. +- La Haye et al. (2012 NSTX/DIII-D scaling; 2006) — experimental threshold fits + behind ladder B9. +- Sauter et al., PoP 6, 2834 (1999); Glasser–Greene–Johnson 1975; Fitzpatrick + 1993/1995/1998; Cole & Fitzpatrick 2006; Rutherford 1973; Waelbroeck & + Fitzpatrick 1997; Smolyakov — the classical MRE-term and penetration + literature (ladder B1–B4, D4–D5 targets). + +## Known cross-source inconsistencies (pinned so nobody re-trips on them) + +1. **Normalization drift within the lineage**: PRL18 normalizes to ψ_s, I19/L23 + to r_s, D21/D23b to w_ψ. Islands pins r_s-based forms with maps in + `src/frames/` (docs/01 §5). +2. **Helical angle**: I19/L23 use ξ = m(θ − φ/q_s); Diss19/D21 use + ξ = φ − q_s θ with cos nξ. Same island, different angle multiplicity. +3. **Collision-frequency energy dependence**: I19/L23 use the Chandrasekhar + form; Diss19/D21 use V⁻³ (docs/05 E3 sub-toggle). +4. **ψ̃ amplitude**: one I19 extraction rendered ψ̃ = (w_ψ²/4)(q_s/q_s′); + dimensional analysis and Diss19/D21/L23 give (w_ψ²/4)(q_s′/q_s) + [VERIFY against I19 as printed — possible typo in the paper]. +5. **DK-NTM run collisionality**: I19 §4.2 states ν_★ = 0.01; L23 p. 82 quotes + DK-NTM at ν_★ = 10⁻³; D21 App. C benchmarks at ν_★ ~ 10⁻³–10⁻⁴ + [VERIFY before pinning B5a tolerances]. +6. **Half vs. full width**: all York w_c values are half-widths; La Haye + experimental fits are quoted as full widths (w_marg = 2w_c). Ladder + reporting rule 5 exists because of this. diff --git a/docs/src/islands/design/09-input-manifests.md b/docs/src/islands/design/09-input-manifests.md new file mode 100644 index 000000000..4c1260097 --- /dev/null +++ b/docs/src/islands/design/09-input-manifests.md @@ -0,0 +1,143 @@ +# 09 — Input-completeness audit (the source input manifests) + +**Decision D9 (docs/05 "Target tiers"), Paper-I claim C9.** Reproducing an +*absolute* threshold number from a publication (a T4 target) requires **every +input** of that publication's exact scenario. This file audits what each +DK-NTM/RDK-NTM/kokuchou source actually pins down. The rule (docs/05): a T4 +comparison is attemptable only where the manifest is complete; where a required +input is **unspecified**, the target permanently downgrades to T3 (scaling + +trend) and any residual gap is reported with an input-sensitivity scan. + +**This is itself a reproducibility result** — it documents that the published +NTM-threshold configurations are, in several places, under-specified or +internally contradictory (the type specimen: I19's own run collisionality). + +## Manifest template + +Each required input, per source: **value + where the paper states it**, or +**"unspecified → assumption + sensitivity needed"**. Fields: +`ε` · `q_s` · shear `ŝ`/`L̂_q` · density gradient `L̂_n` · temperature gradient +`L̂_T`/`η_j` · `τ=T_e/T_i` · `ν_★` per species · `ω_E` · `m/n` · domain `L_x` · +resolution / convergence · far-field BC · threshold-extraction procedure · +`Δ′` convention · frame · species list. + +--- + +## I19 — Imada et al., NF 59, 046016 (2019) — DK-NTM (B5a) + +Audited first-hand (print pp. 2–6, 10–11). The flagship manifest. + +| Input | Value | Source | Status | +|---|---|---|---| +| `ε = r_s/R₀` | 0.1 | §4 (figures) | ✅ specified | +| `m/n` | 2/1 | §4 | ✅ | +| `q_s` | 2 (= m/n) | Eq. 6 def. | ✅ | +| shear `L̂_q` | 1 (`L̂_q⁻¹ = (ψ_s/q_s)dq/dψ = 1`) | §4 normalization | ✅ | +| `τ = T_e/T_i` | 1 (`T_e = T_i`) | §3 ("hydrogenic, quasi-neutral") | ✅ | +| density gradient `L̂_n` | present (`F'_M` drive), value **unspecified** | §2 | ⚠️ unspecified | +| temperature gradient `η_j` | **unspecified** (η appears in Eq. 22 but run value not given) | §3 | ⚠️ unspecified | +| **`ν_★`** | **CONTRADICTORY: §4.2 states `ν_★ = 0.01`; L23 p. 82 quotes this same DK-NTM run at `ν_★ = 10⁻³`** | §4.2 vs L23 | ❌ **contradictory** | +| `ω_E` | 0 (island rest frame, no equilibrium E_r) | §2 | ✅ | +| domain `L_x` | **unspecified** (shooting method; far-field "away from the island") | Appendix | ⚠️ unspecified | +| resolution / convergence | shooting (`n_ξ`, `n_p`, `n_y` grids, Fig. A1); no convergence table | Appendix, Eq. A.2 | ⚠️ partial | +| far-field BC | localized: `ĥ` radial gradient → 0 away; equilibrium gradient present | Appendix (after A.1) | ✅ described | +| threshold-extraction | `w_c` where `dw/dt = 0` (MRE Eq. 1 with a given `Δ′`); the `Δ′` value assumed is not stated | Eq. 1, §5 | ⚠️ `Δ′` value unspecified | +| `Δ′` convention | standard tearing index, jump in `A_∥` log-derivative | Eq. 1 | ✅ convention / ⚠️ value | +| frame | island rest frame | §2 | ✅ | +| species | 1 bulk ion (DK) + flattened electrons (WCHH96) | §2–3 | ✅ | +| **Reported T4 number** | `w_c ≃ 2.76 ρ_θi` half-width (`≡ 8.73 ρ_bi` at ε=0.1) | Fig. 9 | — | + +**Verdict (I19 / B5a): T4 NOT cleanly attemptable.** Two required inputs are +missing or contradictory: the run **`ν_★` is internally inconsistent** (0.01 vs +10⁻³ — a factor of 10), and the **`Δ′` value** behind the `dw/dt=0` threshold is +not stated. B5a therefore stays **T3** (threshold *exists* at `w_c ~ O(ρ_θi)`); +any absolute comparison must scan `ν_★ ∈ [10⁻³, 10⁻²]` and report the sensitivity +`∂w_c/∂ν_★` (which is exactly what kokuchou's B5c surface quantifies). + +## D21 — Dudkovskaia et al., PPCF 63, 054001 (2021) — RDK-NTM v.1 (B5b) + +Audited: abstract + App. A/B (first-hand); config from the abstract/§7. + +| Input | Value | Source | Status | +|---|---|---|---| +| `ε` | 0.1 (ρ_bi = ε^{1/2}ρ_θi context) | abstract | ✅ | +| `m/n`, `q_s`, `L̂_q` | 2/1, 2, 1 (as B5a) | §7 | ✅ | +| `τ` | 1 | (lineage) | ✅ | +| `η_j` | `η_j = 1` | (D23a config; carried) | ⚠️ confirm per-run | +| `ν_i★` | `10⁻³–10⁻⁴` (a *range*, not a point) | abstract/App. C | ⚠️ range not point | +| `Φ′_eqm` (`ω_E`) | 0 | abstract | ✅ | +| drift model | **:improved** (`L̂_B⁻¹ = 0` proxy) | App. A, footnote 10 | ✅ | +| domain / resolution | S-space reduction; analytic layers | §3 | ⚠️ different method | +| **Reported T4 number** | `w_c ≈ 0.45 ρ_θi ≡ 1.41–1.47 ρ_bi` half-width | abstract, Fig. 8 | — | + +**Verdict (D21 / B5b): T4 partial (ν_★ a range).** The **T2 primary gate is the +`:original→:improved` toggle differential** — a within-code ratio that needs no +absolute manifest and is the reproducible form of the `8.73→1.46` story. The +absolute `0.45 ρ_θi` is a *range* over `ν_i★`, so report it as a T3 band, not a +point. + +## D23a — Dudkovskaia et al., NF 63, 016020 (2023) — finite-β, shaped (C4) + +| Input | Value | Source | Status | +|---|---|---|---| +| `ν_★` | `10⁻⁴` | §6.3 | ✅ | +| `m/n` | 2/1 | §6.3 | ✅ | +| geometry | Miller (κ, δ, s_κ, s_δ, Shafranov) | Eq. 33 | ✅ parametrized | +| triangularity `δ` scan | +0.42 → −0.5 | Figs. 5–9 | ✅ | +| other Miller shape params (κ, s_κ, s_δ) at each δ | **unspecified in the transcription** — need first-hand read | §6.3 | ⚠️ unaudited | +| `β_θ` | scanned; EAST 91972 context | §6.3 | ⚠️ partial | +| **Reported T4 numbers** | `2w_c` full width 1.82 ρ_bi (δ=+0.42) → 2.90 ρ_bi (δ=−0.5) | §6.3 | — | + +**Verdict (D23a / C4): Level-2 milestone; T3 primary** (triangularity +*destabilizing trend*, the ε-crossover, β_θ trend). Absolute widths T4, pending a +first-hand audit of the full Miller parameter set at each δ. + +## D23b — Dudkovskaia et al., NF 63, 126040 (2023) — separatrix/polarization (B4, B6) + +| Input | Value | Source | Status | +|---|---|---|---| +| `ε`, `m/n`, `ν_i` | 0.1, 2/1, `10⁻³` | §3–4 | ✅ | +| `ω_E` | **scanned** (the point — Δ_pol vs ω_E) | §4, Fig. 8 | ✅ (scan) | +| layer resolution | separatrix-layer resolved (Figs. 3–4) | §3 | ✅ described | +| **Reported T4** | reversal at `ω_E ≈ −0.89 ω_dia,e`; layer effect `w_c 0.78→0.52 ρ_θi` | Fig. 8–9 | — | + +**Verdict (D23b / B4): T3 primary** (ω_E² scaling + reversal *existence*; layer +threshold *reduction ratio* ~⅓ is T2). The −0.89 location is T4 (a curve +feature, comparable in morphology). + +## L23 — Leigh PhD thesis, York (2023) — kokuchou (B5c) + +The most fully-documented source (a thesis) — the **best T4 candidate**. + +| Input | Value | Source | Status | +|---|---|---|---| +| `ε` | 0.1 | §6.3 | ✅ | +| `m/n`, `ω_E` | 2/1, 0 | §6.3 | ✅ | +| `ρ̂_θi` range | `[1,5]×10⁻³` | Eq. 6.3.2 validity | ✅ | +| `ν_★` grid | `{5,10,15,20}×10⁻³` | Figs. 6.10–12 | ✅ (grid) | +| equation set | **L23-amended** (the §2.6 corrections) | §2.6 | ✅ (explicit) | +| resolution / `ν_★` floor | `ν_★ ≥ 5×10⁻³` (memory-bound); `ŵ ≤ 0.75 ρ̂_θi` | §5.3, §6.1.2 | ✅ documented | +| far-field BC | neoclassical-matching (not Neumann — §5.3 forensics) | §5.3, §7.1 | ✅ | +| threshold-extraction | `w_c[r_s]` 2D OLS fit, R²=0.9916 | Eq. 6.3.1–2 | ✅ (with fit quality) | +| **Reported T4 surface** | `w_c ≈ 0.440 ρ̂_θi + 0.0178 ν_★ − 7.54×10⁻⁵` | Eq. 6.3.2 | — | + +**Verdict (L23 / B5c): T4 attemptable** — the manifest is essentially complete +(a thesis documents its numerics + fit quality + the amended equation set). This +is the source Islands should target for a genuine absolute comparison **after the +deferred constants clear**, reporting grid-convergence + the `∂w_c/∂ν_★` slope +against the fitted `0.0178`. + +## Summary — what the audit changes + +| Ladder | Reported number | Manifest verdict | Gate tier | +|---|---|---|---| +| B5a (I19) | `2.76 ρ_θi` | contradictory `ν_★`, `Δ′` unspecified | **T3** (existence) — absolute needs ν_★ scan | +| B5b (D21) | `0.45 ρ_θi` | `ν_★` a range | **T2** toggle ratio primary; absolute a T3 band | +| B5c (L23) | `0.440 ρ̂_θi + …` | essentially complete | **T4 attemptable** (best candidate) | +| B4 (D23b) | reversal `−0.89` | scan documented | **T3** (existence/scaling); location T4 | +| C4 (D23a) | `1.82→2.90 ρ_bi` | Miller params unaudited | **T3** trend (L2 milestone) | + +The primary quantitative physics gates remain the **T2 internal differentials** +(the drift-model toggle) and **T3 scalings/trends** — none of which need a +complete input manifest. Absolute comparisons are attempted only for L23/B5c +(complete manifest) with a sensitivity scan, per D9. diff --git a/docs/src/islands/design/M1-launch-prompt.md b/docs/src/islands/design/M1-launch-prompt.md new file mode 100644 index 000000000..1ea55cfe9 --- /dev/null +++ b/docs/src/islands/design/M1-launch-prompt.md @@ -0,0 +1,74 @@ +# M1 launch prompt (Islands overnight autonomous run) + +> This file is fed verbatim to the overnight loop: +> `claude --permission-mode dontAsk --continue -p "$(cat docs/src/islands/design/M1-launch-prompt.md)"`. +> It is the milestone contract (design doc `06 §2.1`). Keep it stable across +> relaunches so `--continue` resumes the same objective. + +You are working milestone **M1** of the Islands module (`src/Islands/`), a +steady-state drift-kinetic island/layer solver, autonomously and unattended. + +## Read first (every session) + +`src/Islands/CLAUDE.md` (module conventions + the [VERIFY] policy), +`docs/src/islands/LOG.md` and `docs/src/islands/QUESTIONS.md` (session memory + +open blockers), then the design docs +`docs/src/islands/design/{00-roadmap,03-architecture,04-numerics,05-verification}.md`. +The repo-root `CLAUDE.md` governs GPEC-wide conventions. + +## Goal (set this as your `/goal` completion condition) + +**M1 is done when all of the following hold:** +1. `test/runtests_islands_*.jl` exist and are included in `test/runtests.jl`, and + they implement ladder **A1** (MMS: per-operator + assembled-system convergence + at design order) and **A2** (JVP vs. finite-difference residual), plus an + allocation-regression test for the `apply!` hot paths — all **green**. +2. The full suite passes: `julia --project=. test/runtests.jl`. +3. A PR is open onto `feature/islands` with the changes. +4. **No `[VERIFY]` coefficient has been assigned a value** without a + `docs/src/islands/QUESTIONS.md` entry. + +## Scope + +- **M1 core** (design `03 §1–2`, `04`): the phase-space grids `(x, ξ, λ, E, σ)` + with the layer-clustered mappings; the operator-stack skeleton (`AbstractTerm`, + `apply!`, and the term structs from `03 §2`) as **AD-compatible, + allocation-free stubs**; the MMS harness and per-term AD-vs-FD JVP checks + (`04 §3`). Build the structure and the tests, not the physics numbers. +- **Early-M2 structure, best-effort after M1 core is green**: wire the + term/moment/field structure toward the L0 solve — but **every physics + coefficient stays a parameterized, `[VERIFY]`-tagged stub with a skipped + benchmark** (CLAUDE.md rule 1). + +## The hard rule (non-negotiable) + +**Never assign a value to a `[VERIFY]` physics coefficient, sign, or +normalization.** The moment you would need a specific number/sign from the +literature that isn't already `[CHECKED]`-cleared: (a) implement the *structure* +with the coefficient as a named parameter, (b) add a skipped benchmark +referencing the `[VERIFY]` tag, (c) write a `QUESTIONS.md` entry (context / +question / options / recommendation / gated work), and (d) **switch to the next +unblocked task**. Guessing a coefficient is the exact failure this project +exists to prevent. + +## Working discipline + +- Before committing any physics-adjacent change, run the **`physics-verifier`** + subagent; if it returns BLOCK, fix or escalate — do not commit. +- Commit granularly to `feature/islands` with messages in the repo format + (`ISLANDS - - `); reference `QUESTIONS.md` IDs where relevant. + Push only to `feature/islands` (never `develop`/`main`; the hooks enforce this). +- Never weaken a tolerance or re-baseline a target to reach "done" — that is a + blocker, not a fix. +- Append a `LOG.md` entry (what moved / blocked / next) before ending each + session. The `Stop` hook will keep you from ending with a dirty tree or a + broken build. +- If you exhaust the milestone's unblocked work (everything remaining is gated on + `QUESTIONS.md`), commit, log, and let the session end — the outer loop and the + human will pick it up. + +## Definition of NOT done (do not stop early) + +Do not declare M1 done if any A1/A2/allocation test is failing or skipped-as-a- +shortcut, if the suite is red, if the tree is dirty, or if any coefficient was +guessed. Those are blockers, not completion. diff --git a/docs/src/islands/design/M2-launch-prompt.md b/docs/src/islands/design/M2-launch-prompt.md new file mode 100644 index 000000000..2b406ce1b --- /dev/null +++ b/docs/src/islands/design/M2-launch-prompt.md @@ -0,0 +1,169 @@ +# M2 milestone contract (Islands) + +> The milestone contract for Islands M2 (design doc `06 §2.1`). Set it as the +> `/goal` completion condition when working M2. + +You are working milestone **M2** of the Islands module (`src/Islands/`), a +steady-state drift-kinetic island/layer solver. M1 (phase-space grids + +operator-stack skeleton + MMS/AD harness) is complete (PR #320); M2 builds the +**Level-0 solve machinery** on top of it. + +## Read first (every session) + +`src/Islands/CLAUDE.md` (module conventions + the [VERIFY] policy), +`docs/src/islands/LOG.md` and `docs/src/islands/QUESTIONS.md` (session memory + +open blockers), then the design docs +`docs/src/islands/design/{00-roadmap,01-physics-level0,02-species-and-eps,03-architecture,04-numerics,05-verification,06-autonomy-and-tooling,07-documentation-and-papers}.md`. +The repo-root `CLAUDE.md` governs GPEC-wide conventions. + +## The gating reality (read this before you plan) + +M2's roadmap headline is "L0 single-species solve, Δ moments, **York gates** → +Paper I." **The York gates are NOT reachable in this run** and reaching one is a +policy violation, not completion. Every L0 physics coefficient the gates need +(the `ω̂_D`/`L̂_B` toggle, the collision kernel, `k=−1.173`, `f_p=1−1.46√ε`, +`⟨ν̂_ii⟩_u`, the quasineutrality closure, the York numbers `8.73`/`1.46` ρ_bi) is +at most **`[CHECKED]`** — none is human-cleared — and Decisions **D7** (implement +L0 from an independent re-derivation vs the L23-amended set) and **D8** (pin the +B5a/b/c benchmark triangle) are **unratified** (`docs/00` Decision Log). `[CHECKED]` +is *not* permission to hardcode a number (`docs/01` header; `CLAUDE.md` policy). + +So M2 here = **build the full L0 solve *structure* with every physics coefficient +a `[VERIFY]`-gated parameter, close the physics-free structural gates, and escalate +a prioritized clearance queue** for the human. A thin follow-up run fills the +numbers and hits the York gates *after* a human clears the tags. + +## Goal (set this as your `/goal` completion condition) + +**M2 is done when all of the following hold:** + +1. **The L0 solve machinery exists** as AD-compatible, allocation-free structure, + with **every physics coefficient a supplied `[VERIFY]`-gated parameter (no + literal in `src/`)** — new submodules per design `03 §1`: + - `solvers/` — matrix-free **Newton–Krylov** (GMRES on the M1 ForwardDiff JVP; + inexact-Newton / Eisenstat–Walker forcing; line search; a pseudo-arclength + continuation scaffold that detects folds from day one; a physics-block + preconditioner that treats the `y_c` matching block explicitly), `04 §3, §5`. + - `moments/` — `Δ_cos`/`Δ_sin` assembly: species-charge-weighted velocity moment + of `g` → `J̄_∥(x, ξ)` → the `∮ J̄_∥ {cos, sin} ξ dξ ∫ dψ` projections (`01 §4`), + plus the bootstrap/polarization channel decomposition *structure*. The `μ₀R/2ψ̃` + prefactor and `ψ̃` stay **gated** (open `[VERIFY]`); the projection + quadrature + is pure numerics. + - `frames/` — THE `ω`/normalization conversion module (`01 §5`): the conversion + *forms* with every sign and normalization flagged and gated. This module owns + all `ω`-sign conventions (the polarization sign disputes are frame disputes — + do not reproduce them elsewhere). Unit-test only the mechanical, sign-free + identities. + - `fields/` — formalize the quasineutrality residual (already stubbed in + `operators/`); the flattened-electron closure *structure* gated. + - `species/` — `Species`, `AbstractBackground`, `SpeciesRole {Bulk, Trace}` + plumbing (`02 §1`, Decision D3) — pure data structures, fully unblocked. The L0 + test is a single bulk ion + a trace-deuterium copy. + - **neoclassical-matching far-field BCs** (`01 §3`, `04 §1`): structure only; the + no-island neoclassical far-field solution is gated. **Never bare Neumann** (the + L23 "winged" spurious-branch failure). + +2. **Physics-free structural gates green** (`05 §A`), in `test/runtests_islands_*.jl`, + passing in the full suite: + - **A5** zero-drive null: gradients and `Φ̃` off ⇒ `g ≡ 0` exactly, residual = + machine zero, Newton converges trivially. + - **Assembled solve-MMS**: a manufactured `g*` with `GradientDrive` set so `g*` is + the exact Newton solution; the solver recovers `g*` at design order (extends + M1's residual-MMS to a full converged solve). + - **A8** `y_c` matching-block smallest-singular-value conditioning monitor + (regression = the silent-noise failure mode of L23 §4.2). + - **A4** (L0): particle conservation + discrete entropy sign `∫ g C[g]/F_M ≤ 0` + of the discretized collision operator (structural — holds for any valid + discretization, independent of the physical `ν` value). + - **A3** parity: `Δ_cos` even / `Δ_sin` odd under the appropriate `(ξ, σ, ω_E)` + reflection, tested on a manufactured `J̄_∥` (coefficient-free). + - **A7** the coefficient-free identity `⟨∂²h/∂x²⟩_Ω = 0` (defer the number-bearing + `k`, `f_p`, `⟨ν̂_ii⟩` identities to a post-clearance run). + +3. The full suite passes: `julia --project=. test/runtests.jl`. + +4. **`docs/src/islands/papers/paper-1/OUTLINE.md`** written as the Paper-I figure + contract (`07 §3`): claims → figures → ladder IDs (B5a/b/c, B2, B4). This is the + level-*start* deliverable, not the level-*gate*. + +5. **The clearance queue** (the parallel-human deliverable): a consolidated, + prioritized set of `QUESTIONS.md` entries covering every coefficient the York + gates need + D7/D8 ratification + the open `[VERIFY]`s, **each paired with a + skipped B-series benchmark** in `benchmarks/islands/` that references its tag — so + the human can clear in parallel and the follow-up run fills numbers and hits gates. + +6. A PR is open onto `feature/islands` (a sub-branch → `feature/islands`, as M1's + PR #320 did). + +7. **No `[VERIFY]`/uncleared-`[CHECKED]` coefficient has been assigned a value** + without a `docs/src/islands/QUESTIONS.md` entry. + +**Explicitly NOT in the M2 DoD (gated on human clearance — do not attempt):** the +York gates B5a/b/c, B2, B4; B1 (needs an external NEO/NCLASS run); any physics +number. A "green York gate" reached by hardcoding is the exact failure this project +exists to prevent. + +## Scope + +- **Reuse existing GPEC machinery; do not reimplement** (repo-root CLAUDE.md + minimal-change discipline): `FastInterpolations.integrate` / + `cumulative_integrate` for the ψ/flux integrals (`src/Equilibrium/Equilibrium.jl`, + `src/ForceFreeStates/Resist.jl`); the `src/KineticForces/BounceAveraging.jl` + velocity-space λ-averaging pattern for the `(y, E, σ)` moments; the allocation-free + `QuadGK.quadgk!` pattern (`src/ForceFreeStates/Galerkin/GalerkinAssembly.jl`) for + hot quadrature; the `EquilibriumConfig` / `build_inputs_from_toml` TOML-config + pattern (`src/Equilibrium/EquilibriumTypes.jl`, + `src/GeneralizedPerturbedEquilibrium.jl`) for an `[Islands]` `gpec.toml` section in + a new `io/`. +- **Add `Krylov.jl`** to `Project.toml` `[deps]` + `[compat]` (matrix-free GMRES; + `04 §9` names Krylov.jl / LinearSolve.jl — prefer Krylov.jl: lighter and + purpose-built). The JVP is the M1 ForwardDiff operator; form **no** global sparse + Jacobian except in a tiny-grid debug mode. +- Create `benchmarks/islands/` (+ `figures/`) and at least one `islands_*` regression + case integrated with `regression-harness/` — every physics benchmark ships + **skipped**, referencing its `[VERIFY]`/`QUESTIONS` id. + +## The hard rule (non-negotiable) + +**Never assign a value to a `[VERIFY]` or uncleared-`[CHECKED]` physics coefficient, +sign, or normalization.** The moment you would need a specific number/sign from the +literature that isn't human-cleared: (a) implement the *structure* with the +coefficient as a named parameter, (b) add a skipped benchmark referencing the tag, +(c) write a `QUESTIONS.md` entry (context / question / options / recommendation / +gated work), and (d) **ask the user to clear it if they are available, otherwise +move to the next unblocked task**. Guessing a coefficient is the exact failure this +project exists to prevent. Manufactured, order-unity test +coefficients in the MMS/verification harness are legitimate (they test numerics, not +physics) and carry no tag — do not confuse the two. + +## Working discipline + +- Before committing any physics-adjacent change, run the **`physics-verifier`** + subagent; if it returns BLOCK, fix or escalate — do not commit. +- **Run every new kernel once under `--check-bounds=yes`** before trusting it. (M1 + lesson: an `@inbounds` index-swap corrupted memory and passed silently until a + forced bounds-checked run caught it. Assume new nested-index kernels are guilty + until a bounds-checked run clears them.) +- Invoke julia with a clean loader path: + `env -u LD_LIBRARY_PATH /mnt/homes_global/ncl2128/software/julia-1.11.7/bin/julia + --project=. …` (Q1 resolution: the OMFIT `LD_LIBRARY_PATH` shadows Julia's + artifacts otherwise). +- Commit granularly to a milestone sub-branch with messages in the repo format + (`ISLANDS - - `); reference `QUESTIONS.md`/ladder IDs where relevant. + Push only Islands branches (never `develop`/`main`; the hooks enforce this). +- Never weaken a tolerance or re-baseline a target to reach "done" — that is a + blocker, not a fix. +- Append a `LOG.md` entry (what moved / blocked / next) before wrapping up a working + session, and keep the tree clean and the build green (`06 §2.5`). +- If the remaining work is all gated on human clearance, surface the blockers to the + user (they can clear a tag or ratify D7/D8 live) rather than stalling. + +## Definition of NOT done (do not stop early) + +Do not declare M2 done if: any structural gate (A5, solve-MMS, A8, A4, A3, A7's +`⟨∂²h/∂x²⟩=0`) is failing or skipped-as-a-shortcut; the L0 machinery submodules are +absent; the suite is red; the tree is dirty; the Paper-I OUTLINE or the clearance +queue is missing; or any coefficient was guessed. Those are blockers, not +completion. Conversely, do **not** keep working past a green structural ladder in an +attempt to reach the York gates — those are gated on human clearance and out of +scope for this run. diff --git a/docs/src/islands/design/M2b-launch-prompt.md b/docs/src/islands/design/M2b-launch-prompt.md new file mode 100644 index 000000000..0740553d6 --- /dev/null +++ b/docs/src/islands/design/M2b-launch-prompt.md @@ -0,0 +1,139 @@ +# M2b milestone contract (Islands) — Level-0 physics via the derivation lane + +> The milestone contract for Islands M2b (design doc `06 §2.1`). Set it as the +> `/goal` completion condition when working M2b. Prerequisites are in place: +> M1 (PR #320) and M2 (PR #324) are merged — the full L0 solve *structure* +> exists with every physics coefficient gated — and the user has ratified +> **D7/D8** and chosen the **re-derivation-first** clearance mode (QUESTIONS +> Q2 resolved, Q3 mode pinned, 2026-07-08). + +You are working milestone **M2b** of the Islands module (`src/Islands/`): +filling in the Level-0 physics that M2 deliberately gated, by the route the +project's own thesis demands — **independent re-derivation, human sign-off, +then implementation** — ending with the B-ladder physics benchmarks running. + +## Read first (every session) + +`src/Islands/CLAUDE.md` (the [VERIFY]/[DERIVED]/[CHECKED] policy — rules 3 and +4 are the spine of this milestone), `docs/src/islands/LOG.md` and +`QUESTIONS.md` (Q3 items and mode, Q4), then design docs +`docs/src/islands/design/{00-roadmap,01-physics-level0,03-architecture,04-numerics,05-verification}.md` +— **especially docs/05 "Target tiers and reproducibility" and Decision D9 +(00): the physics gates are scalings/differentials, absolute numbers are +audit-gated** — and the as-implemented chapter `docs/src/islands/numerics.md`. +The source PDFs are in the `docs/08` reference library. The repo-root +`CLAUDE.md` governs +GPEC-wide conventions. + +## Goal (set this as your `/goal` completion condition) + +**M2b is done when all of the following hold:** + +1. **The derivation set exists** in `docs/src/islands/derivations/`, one + chapter per Q3 item, each marked `[DERIVED: date]` and structured as: + stated starting point (the drift-kinetic equation and orderings O1–O9, + cited to `01 §2`), explicit assumptions, the derivation with no skipped + sign-bearing steps, a boxed final coefficient form, and a **cross-check + table** against the `[CHECKED]` transcriptions (I19/Diss19/D21/L23, + honoring the L23 §2.6 amendments). Items: + - (a) the orbit-averaged drift frequency `ω̂_D` including the + `:original`/`:improved` `L̂_B⁻¹` treatment; + - (b) the pitch-angle collision kernel, the `ν_★` normalization, and the + analytic `⟨ν̂_ii⟩_u` velocity average; + - (c) the flattened-electron closure: the `h(Ω)` prefactor, the coupled + flow relation, and the `k`/`f_p` constants; + - (d) the quasineutrality closure coefficient; + - (e) the `ψ̃` island amplitude — deriving it settles the Q4 + `q_s′/q_s` vs `q_s/q_s′` question by dimensional necessity; + - (f) the `Δ_cos`/`Δ_sin` prefactors, including pinning the sin-moment + normalization (a `[DERIVED]` pin per `01 §4`). + **Every discrepancy against a transcription is flagged in the table and in + `QUESTIONS.md` — never resolved silently** (policy rule 2). +2. **Human sign-off, item by item.** Present each derivation to the user (they + are working interactively; ask). A signed-off derivation clears the + corresponding coefficients: record the clearance in `docs/01` (source, + equation, date, derivation link) per policy rule 3. **You never sign off + your own derivation.** +3. **Implementation fill-in for signed-off items only**: a single Level-0 + coefficient/configuration builder in `src/Islands` (the named-configuration + mechanism of `03 §2` — e.g. `:dkntm_original`, `:rdkntm_improved`) that + populates the M2 gated parameters, each value annotated with its derivation + anchor. Un-gate progressively: partial sign-off ⇒ partial fill-in; anything + unsigned stays NaN/supplied. +4. **The input-completeness audit** (Decision D9, docs/05 "Target tiers"): a + per-source *input manifest* (using the docs/05 template) for each threshold + source — I19, D21, D23a/b, L23 — written into a new + `docs/src/islands/design/09-input-manifests.md` (or a docs/05 appendix; + executor's choice), each required input either cited to where the paper + states it or recorded as "unspecified → assumption + sensitivity scan + needed". This audit is what decides, per target, whether a T4 absolute + comparison is even attemptable or the target stays T3. It is itself a + Paper-I methods deliverable (OUTLINE claim C9). B5a's collisionality + contradiction is the type specimen — resolve it *in the manifest*, not by + picking a number. +5. **The B-ladder starts running — tiered (D9)**: for cleared configurations, + un-skip the `benchmarks/islands/` scripts. The DoD is the **primary-tier + gates running with archived artifacts**: the T2 drift-model **toggle + differential** (:original → :improved w_c ratio, measured within Islands), + and the T3 **scaling/existence/trend** sweeps (1/w and 1/w³, layer widths + ∝ ν^{1/2}, ω_E² with a sign reversal existing, threshold existence at + w_c ~ O(ρ_θi), dw_c/dν_★ > 0) with fitted exponents/signs. **T4 absolute + comparisons are attempted only for manifests the audit completes, and + reported only with the manifest + an input-sensitivity scan** (docs/05 + reporting rules 6–8) — never as bare pass/fail. Follow the docs/05 reporting + rules (grid-convergence archived; half-widths with both ρ_θi and ρ_bi + stated); triage disagreements per docs/05 rule 3 (now including + "under-specified source configuration"). B5 absolute *agreement* is never + this milestone's precondition. +6. Q4 source status: WCHH96 and Park 2022 are both in the library (resolved + 2026-07-09; docs/08). Nothing to acquire — proceed with the in-repo sources. +7. The full suite passes; the Physics Book chapter + (`docs/src/islands/numerics.md` or a new physics chapter) is updated **in + the same PR** for every equation that becomes as-implemented (docs/07 + policy), with figures regenerated via the pinned script where outputs + change. +8. A PR is open onto `feature/islands`, and `physics-verifier` has passed on + every physics-adjacent commit — its job here is checking **provenance**: + derivations marked `[DERIVED]`, transcriptions never silently promoted, + no unsigned coefficient in `src/`. + +## The hard rules (non-negotiable, sharpened for this milestone) + +- **Never present a derivation as a literature transcription or vice versa** + (policy rule 4). The provenance tag is part of the result. +- **Never assign an unsigned coefficient in `src/`** — sign-off happens in the + conversation and is recorded in `docs/01` before the value lands in code. +- **Never tune a derived coefficient to make a benchmark pass** (policy rule + 2). If a benchmark disagrees, the triage path is docs/05 rule 3, in the open. +- If a derivation stalls (a step you cannot justify), flag it in + `QUESTIONS.md` with the specific step and move to the next item — a partial + derivation set with honest gaps beats a complete one with a glossed step. + +## Working discipline + +- Derivation chapters are Documenter pages: one-sentence-per-line source, LaTeX + `math` blocks, cross-check tables as Markdown tables; wire new pages into the + Islands nav section in `docs/make.jl` and verify with a local docs build + (`env -u LD_LIBRARY_PATH …/julia --project=. build_docs_local.jl`). +- Run every new kernel once under `--check-bounds=yes`; keep `apply!` paths + allocation-free (regression-tested); julia via + `env -u LD_LIBRARY_PATH /mnt/homes_global/ncl2128/software/julia-1.11.7/bin/julia --project=. …`. +- Commit granularly to a milestone sub-branch (`ISLANDS - - …`, + referencing Q3 items and ladder IDs); update the `islands_l0_structural` + regression case if tracked numbers legitimately move (with justification — + never re-baseline to reach green); append a `LOG.md` entry per session. +- The user is present: surface each completed derivation for sign-off rather + than batching everything to the end. + +## Definition of NOT done + +Any coefficient in `src/` without a recorded human sign-off; a derivation +presented without its cross-check table; a discrepancy resolved silently; a +benchmark "passing" via tuned coefficients or weakened tolerance; a **T4 +absolute comparison reported without its input manifest and sensitivity scan** +(Decision D9); the suite red; docs not updated with the as-implemented +equations; or the tree dirty. Conversely: a T4 absolute *disagreement* after +honest triage — including "the source is under-specified" — is a reportable +result, not a failure. **Do not chase absolute agreement**; the primary gates +are the T2 differentials and T3 scalings, and pursuing an absolute number past +what the derivations and input manifests support is itself out of bounds. diff --git a/docs/src/islands/design/M2c-launch-prompt.md b/docs/src/islands/design/M2c-launch-prompt.md new file mode 100644 index 000000000..3bcedc3e9 --- /dev/null +++ b/docs/src/islands/design/M2c-launch-prompt.md @@ -0,0 +1,107 @@ +# M2c milestone contract (Islands) — Level-0 assembly + audit + docs infrastructure (autonomous) + +> The milestone contract for Islands M2c. Fed to the autonomous loop +> (`claude --permission-mode dontAsk --continue -p "$(cat …/M2c-launch-prompt.md)"`) +> or set as a `/goal` completion condition. Keep it stable across relaunches. + +You are working milestone **M2c** of the Islands module, **autonomously and +unattended**. The M2b derivation lane is complete: all six main Level-0 +coefficient families (`ψ̃`, `ω̂_D` + drift toggle, collision operator, `h(Ω)` +closure, quasineutrality, `Δ` prefactors) are human-signed-off and cleared into +`Coefficients`/`Moments`. M2c **assembles** that cleared physics into a runnable +Level-0 configuration, produces the input-completeness audit, and stands up the +`docs/07` infrastructure — all **without any new human sign-off**. + +## Read first (every session) + +`src/Islands/CLAUDE.md`, `docs/src/islands/LOG.md` + `QUESTIONS.md`, then design +docs `00-roadmap`, `01-physics-level0`, `03-architecture`, `04-numerics`, +`05-verification` (**especially "Target tiers", Decision D9**), `06-autonomy-and-tooling`, +`07-documentation-and-papers`, and the derivations `docs/src/islands/derivations/` +(the cleared physics). Repo-root `CLAUDE.md` governs GPEC conventions. + +## The hard constraint (you are unattended) + +**Human sign-off is unavailable this milestone.** Therefore: + +- **Un-gate nothing new.** Only the six already-cleared coefficient families may + enter `src/` (via `Coefficients.*`). The deferred sub-constants — + `⟨ν̂_ii⟩_u`, the Hirshman–Sigmar `k ≃ −1.173`, the `1.46` in `f_p` — stay + `[CHECKED]`-gated. Anywhere the assembly needs them, use a **named, supplied + parameter (or NaN-gate)** and record a `QUESTIONS.md` entry; never guess. +- **Draft, don't clear.** You may *draft* the deferred-constant derivations + (`⟨ν̂_ii⟩_u`, `k`, `f_p`) as `[DERIVED]` chapters *awaiting sign-off* — but only + if you can derive them rigorously from the in-repo sources (read L23 Eq. 4.1.6 + for `⟨ν̂_ii⟩_u`; the trapped-fraction integral for `f_p`; the parallel-viscosity + moment problem for `k`). **If a derivation is not rigorous, do not fake it** — + write a `QUESTIONS.md` entry stating exactly what's missing and move on + (policy rule 4). Physics-verifier every draft. +- **Never weaken a tolerance or re-baseline to reach "done."** + +## Goal (set this as your `/goal` completion condition) + +**M2c is done when all of the following hold:** + +1. **Level-0 coefficient assembly.** A named-configuration builder in `src/Islands` + (design `03 §2`, e.g. `configure_level0(grid, params, species; variant)` → + an `IslandStack` + far-field BCs + `Δ`-prefactors) that populates every + operator-stack coefficient from the **cleared** `Coefficients.*` functions on + the phase-space grid (orbit-averaged `ω̂_D` per `(y,E,σ)`; the mimetic + collision `P(y)` from `pitch_diffusivity` + `deflection_frequency`; the + `Quasineutrality` coefficient; the `GradientDrive` from the cleared closure; + the `Δ` prefactors). The deferred `⟨ν̂_ii⟩_u`/`k`/`f_p` enter as clearly-named + gated parameters. Allocation-free, AD-compatible (the M1 discipline). **Tests:** + the assembly builds; the assembled residual/`newton_krylov` **runs and + converges structurally** on the `:imada2019`/`:dudkovskaia2021` configs (with + the deferred constants set to documented placeholder values *for the + structural run only*, flagged in the test as non-physics); no cleared + coefficient is a literal in `src/` (all via `Coefficients.*`). +2. **Input-completeness audit** (Decision D9, `docs/05` "Target tiers"): per-source + input manifests (I19, D21, D23a/b, L23) in a new + `docs/src/islands/design/09-input-manifests.md`, each required input cited to + where the paper states it or marked "unspecified → assumption + sensitivity + needed". This is the D9/Paper-I C9 deliverable and pins which B5/C4 numbers are + even T4-attemptable. +3. **`docs/07` infrastructure:** the auto-generated **STATE dashboard** + (`docs/src/islands/state/STATE.md`, ladder status from test/benchmark + artifacts — never hand-edited) and the **anchor-sync CI check** (every operator + cites its Physics-Book section; every equation names its implementing symbol). +4. **B-ladder scaffolding wired to the assembly:** the skipped `benchmarks/islands/` + scripts updated so that, the moment the deferred constants clear, the **T2/T3 + primary gates** (the `:original→:improved` `w_c` toggle differential; the + `1/w`, `1/w³`, `ν^{1/2}`, `ω_E²` scalings) run against the assembled solve. Keep + them skipped (gated on QUESTIONS) but make un-skipping a one-line change. +5. **Deferred-constant derivations** drafted where rigorously possible (§ hard + constraint), each `[DERIVED]`, physics-verifier PASS, **awaiting sign-off** (not + cleared); the rest escalated in `QUESTIONS.md` with the specific missing piece. +6. Full suite green (`julia --project=. test/runtests.jl`); the Physics-Book / + `numerics.md` updated for the assembly (docs/07); a PR open onto + `feature/islands`; physics-verifier PASS on every physics-adjacent commit. + +**Explicitly NOT in the M2c DoD (needs human sign-off / clearance):** un-gating +`⟨ν̂_ii⟩_u`/`k`/`f_p`; the B-ladder physics gates; any York number. A physics +threshold result is *not* reachable until a human clears the last constants. + +## Working discipline (autonomous) + +- Reuse existing GPEC machinery (`FastInterpolations.integrate`, `QuadGK.quadgk!`, + the `KineticForces/BounceAveraging` λ-averaging, the `EquilibriumConfig` TOML + pattern) — don't reimplement (repo-root CLAUDE.md). +- Run every new kernel once under `--check-bounds=yes` (the M1 corruption lesson). +- Invoke julia with `env -u LD_LIBRARY_PATH /mnt/homes_global/ncl2128/software/julia-1.11.7/bin/julia --project=. …`. +- Any new Islands submodule needs its `@autodocs` block in `docs/src/islands.md` + (checkdocs=:exports — this silently reddened docs CI once; verify with a local + `build_docs_local.jl`). +- Commit granularly (`ISLANDS - - …`), reference QUESTIONS/ladder IDs; append + a `LOG.md` entry per session; end with the branch pushed. +- When blocked on anything requiring human judgment (a sign-off, a coefficient, a + convention), write a `QUESTIONS.md` entry and switch to the next unblocked task — + never stall, never guess. + +## Definition of NOT done + +Any deferred constant un-gated without sign-off; any coefficient guessed; a +deferred derivation faked (presented as rigorous when it isn't); the assembly not +running structurally; the suite red; the tree dirty; the audit or `docs/07` infra +missing. A "physics result" reached by placeholder constants is a structural +check, not a milestone — label it so. diff --git a/docs/src/islands/design/REVISION-2026-07-07.md b/docs/src/islands/design/REVISION-2026-07-07.md new file mode 100644 index 000000000..4021ae879 --- /dev/null +++ b/docs/src/islands/design/REVISION-2026-07-07.md @@ -0,0 +1,119 @@ +# Plan revision — 2026-07-07 + +The original design bundle for the Islands module (drafted in Claude chat from +memory of the literature, under the working acronym "ISLET") was revised against +a full read of the nine PDFs in `docs/resources/Drift_Kinetic_Island_References/` +plus a reality check of the GPEC repo's actual module status. It was then placed +in-repo: module conventions at `src/Islands/CLAUDE.md`, overview at +`docs/src/islands/index.md`, and these design docs at +`docs/src/islands/design/`. (The "ISLET" acronym was subsequently retired in +favour of the plain module name `Islands`, per the repo module-naming +convention.) This note records the physics/scope revision; the later in-repo +relocation and rename are tracked in git history. + +## What changed and why + +### 1. A whole prior-art code was missing from the plan +The Leigh thesis (York, Dec 2023) describes **kokuchou**, a direct 4D +drift-kinetic NTM threshold code — effectively a working prototype of Islands' +Level 0 — with: +- a **finite-collisionality threshold surface** + w_c ≈ 0.440 ρ̂_θi + 0.0178 ν_★ − 7.54×10⁻⁵ (new ladder target B5c); +- an **amendment list (§2.6) documenting errors in the published Imada 2019 + equation set** (missing ρ̂_θi factors, missing collision coefficients, a + sign) — now a load-bearing warning in docs/01 and a new [VERIFY] policy + rule 6 + proposed decision D7; +- forensic documentation of the numerical failure modes: intrinsically + **singular trapped-passing matching matrix** (TSVD required; new ladder A8 + monitor and docs/04 §3), **separatrix-layer width that moves between + nonlinear iterations** in the E×B-dominated regime, **Picard + non-convergence** in production, **spurious "winged" branches under Neumann + far-field BCs**, and a **memory wall** (16.6 GB/energy-point) that set its + ν_★ ≥ 5×10⁻³ floor. All entered into docs/04 and the docs/00 risk register; + each maps onto an Islands architecture choice (matrix-free Newton–Krylov, + neoclassical-matching BCs, adaptive layer packing). + +### 2. Threshold numbers and conventions pinned exactly (former [VERIFY]s) +All York thresholds are **half-widths**; ρ_bi = ε^{1/2}ρ_θi: +- DK-NTM: w_c ≃ 2.76 ρ_θi ≡ 8.73 ρ_bi at ε = 0.1 ("8.73" is a unit + conversion, not printed in I19) — B5a. +- RDK-NTM improved drift model: w_c ≈ 0.45 ρ_θi ≡ 1.46 ρ_bi (2.85 ρ_bi full + width); the 8.73→1.46 pair is authoritative in the NF 016020 abstract — B5b. +- The improved-vs-original drift model is now concrete: the cos θ structure of + ∂B/∂ψ (D21 Eq. A2), proxied by L̂_B⁻¹ = 0 — a one-term toggle + (`MagneticDrift` variants in docs/03). +- Exact Ω convention, ψ̃ = (w_ψ²/4)(q_s′/q_s), Δ_neo normalization + (Diss19 Eq. 4.12), bootstrap/polarization split, and the WCHH96 large-w + limits (Δ_bs ∝ 1/w, Δ_pol ∝ 1/w³) are transcribed into docs/01 with + equation/page cites. + +### 3. Frames and polarization physics grounded +docs/01 §5 now carries the source-confirmed identities: ω − ω_E is +frame-invariant; ω₀ = −ω_E; L_n⁻¹ shifts with frame; Δ_pol ∝ ω_E² with a +**sign reversal at ω_E ≈ −0.89 ω_dia,e** (D23b Fig. 8) and discrete +torque-balance roots (Diss19 Fig. 4.18). Consequence: ω_E is a scanned Level-0 +input parameter (roadmap O4 reworded; proposed decision D7), and single-ω_E +polarization values are forbidden in publications (risk register). L23's +unexplained stabilizing electron Δ_pol at ω_E = 0 is recorded as an open +physics question Islands can settle (E4/E6; Paper I candidate result). + +### 4. Electron closure and collision operator now exact +docs/01 §2.3–2.4: the WCHH96 flattened-electron closure with h(Ω), the coupled +u_∥e(u_∥i) relation (k = −1.173, f_p = 1 − 1.46√ε), the momentum-conserving +pitch-angle operator with the Chandrasekhar-form vs. V⁻³ energy-dependence +sub-toggle (an inconsistency *within* the York lineage, now toggle study E3), +and the analytic ⟨ν̂_ii⟩_v average. The `:kinetic` electron option is +identified with the RDK-NTM treatment (full coefficient sets in Diss19 +Appendix D). + +### 5. Verification ladder rewritten with exact targets +docs/05: B5 split into B5a/B5b/B5c (three-code triangle, proposed decision +D8); B4/B6 get the D23b figure list and sign-reversal curve; B7 gets the +prior-art agreement window and the "beat kokuchou's ν_★ floor" deliverable; +B9 (new) places the La Haye experimental fits as context; C4 gets D23a's exact +shaping numbers (triangularity 1.82→2.90 ρ_bi full width, ε ≈ 0.3 scaling +crossover, EAST 91972); A7/A8 (new) add kokuchou's analytic unit-test set and +the singular-block conditioning monitor. New reporting rule 5: thresholds +always as half-widths with the unit stated. New standing triage category: +"their published-equation error." + +### 6. Repo reality check (amended after review) +On `develop`, `src/InnerLayer/SLAYER/Slayer.jl` is a placeholder — but the +SLAYER implementation is in flight on **PR #238** +(`feature/tearing-growthrates`): the `src/Tearing/` umbrella with SLAYER +(Fitzpatrick Riccati Δ(Q)) and GGJ inner-layer models, the dispersion +root-finding layer, and the outer-region Δ′ as a full 2m×2m matrix +(`delta_prime_raw` + the new ForceFreeStates `Riccati.jl` solver). +**Sequencing decision:** #238 lands before Islands M0; if Islands starts earlier, +the Islands branch is cut from `feature/tearing-growthrates` rather than +`develop`, so the SLAYER/Δ′ interfaces are in hand from the first commit. +README, docs/00 (milestone sequencing + risk register), docs/03, docs/05 D1, +and docs/06 §1 encode this; docs/06 also warns not to code against `develop`'s +old `src/InnerLayer` layout since #238 moves GGJ under `src/Tearing/`. +KineticForces (NTV, the Level-4 torque-balance counterpart) exists on +`develop` already. + +### 7. Reference hygiene +New `docs/08-reference-library.md`: per-PDF role map, abbreviations +(PRL18/JPCS18/I19/Diss19/D21/D23a/D23b/L23/JOP18), and six pinned +cross-source inconsistencies (normalization drift, helical-angle conventions, +collision-frequency forms, a suspected ψ̃ typo, the DK-NTM run-collisionality +discrepancy, half/full widths). **Dudkovskaia 2018 JOP reclassified**: it is +about *phase-space* (bump-on-tail) islands, not magnetic islands — +methodological antecedent and EP background only. Missing sources flagged for +acquisition: WCHH96 (load-bearing), Park 2022 (SLAYER), La Haye fits, and the +classical MRE-term papers. + +### 8. Tag semantics extended +New `[CHECKED: source, Eq./p.]` state in the Islands module CLAUDE.md: transcribed from +an in-repo PDF with exact cite and machine-checked, pending the human sign-off +that rule 3 still requires. Applied throughout docs/01/04/05. + +## Items needing human ratification +- Decision D7 (implement from re-derivation vs. L23-amended set; ω_E as + Level-0 scan parameter) and D8 (three-code benchmark triangle) in the + docs/00 decision log. +- All [CHECKED] tags (one sign-off pass over docs/01 against the PDFs). +- Open [VERIFY] items: the I19 ψ̃ possible typo; the DK-NTM run collisionality + (0.01 vs 10⁻³) before pinning B5a tolerances; Park 2022 Q-convention; + B3 curvature configuration; D5 w_d target formula. diff --git a/docs/src/islands/figures/continuation_fold.png b/docs/src/islands/figures/continuation_fold.png new file mode 100644 index 000000000..6431920b1 Binary files /dev/null and b/docs/src/islands/figures/continuation_fold.png differ diff --git a/docs/src/islands/figures/grids_clustering.png b/docs/src/islands/figures/grids_clustering.png new file mode 100644 index 000000000..06c1890f8 Binary files /dev/null and b/docs/src/islands/figures/grids_clustering.png differ diff --git a/docs/src/islands/figures/hQ_profiles.png b/docs/src/islands/figures/hQ_profiles.png new file mode 100644 index 000000000..1c13c1785 Binary files /dev/null and b/docs/src/islands/figures/hQ_profiles.png differ diff --git a/docs/src/islands/figures/mms_convergence.png b/docs/src/islands/figures/mms_convergence.png new file mode 100644 index 000000000..5b2a4dd67 Binary files /dev/null and b/docs/src/islands/figures/mms_convergence.png differ diff --git a/docs/src/islands/figures/preconditioner_gmres.png b/docs/src/islands/figures/preconditioner_gmres.png new file mode 100644 index 000000000..d885a9b62 Binary files /dev/null and b/docs/src/islands/figures/preconditioner_gmres.png differ diff --git a/docs/src/islands/index.md b/docs/src/islands/index.md new file mode 100644 index 000000000..bb3f44c87 --- /dev/null +++ b/docs/src/islands/index.md @@ -0,0 +1,129 @@ +# Islands — drift-kinetic island/layer solver + +> GPEC submodule (`src/Islands/`, `module Islands`). The name is a plain +> description of the domain — the resonant magnetic island/layer region — per the +> repo module-naming convention (simple, intuitive names, not standalone-code +> acronyms; repo-root CLAUDE.md). Earlier drafts used the working acronym +> "ISLET"; that has been retired. + +## One-paragraph pitch + +The Modified Rutherford Equation (MRE) is assembled, by decades-long practice, from +regime-specific analytic terms (Rutherford resistive drive, Sauter/Hegna–Callen +bootstrap, GGJ curvature, Wilson–Connor/Smolyakov polarization, Fitzpatrick's +transport threshold w_d), each valid only in its asymptotic corner of parameter +space. This mirrors the pre-SLAYER state of linear error-field penetration theory, +where Fitzpatrick's and Cole & Fitzpatrick's regime-specific thresholds coexisted +until Park's SLAYER (Phys. Plasmas 29, 2022) solved the underlying layer equations +numerically for arbitrary parameters and recovered every analytic limit. Islands is +the nonlinear analog: a steady-state, multi-species drift-kinetic solver for the +resonant island/layer region that returns the growth moment Δ_cos(w, ω; p) and +torque moment Δ_sin(w, ω; p) for arbitrary parameters, replacing the sum of +regime-specific MRE terms with a single calculation — and, in its small-amplitude +limit, reducing to the linear layer response so that error-field shielding, +penetration, seeded-NTM onset, and island saturation become faces of one solution +manifold. + +## Key deliverables (priority order) + +1. **Unification of small (SLAYER) and large (MRE) island response.** One inner-region + framework spanning shielded linear response → penetration bifurcation → saturated + island. The transition regime w ~ δ_layer has no kinetic treatment in the + literature; it is the flagship result. +2. **Toggleable ordering stack.** Every approximation in the Imada 2019 / + Dudkovskaia 2021–2023 / Leigh 2023 (DK-NTM / RDK-NTM / kokuchou) lineage is a + runtime toggle, so their theory is a *benchmark configuration* of Islands, and + the impact of each ordering is measurable all the way up to the unreduced + problem. +3. **Multi-species physics.** High-Z minority impurities (W: mixed-collisionality + bootstrap/friction physics, radiative island destabilization) and energetic + particles (alphas: finite-orbit-width nonlocality, slowing-down backgrounds, + precession resonance with island rotation). +4. **Δ(w, ω; p) surfaces + emulator.** Precomputed response surfaces over + nondimensional parameter space for integrated modeling, the way SLAYER is used + for penetration thresholds. + +## Relationship to the existing toolchain + +Islands develops **inside the OpenFUSIONToolkit GPEC repository** as a +subdirectory Julia package (see docs/06 §1). Status of the GPEC-side assets +(checked 2026-07-07): + +- **Outer region + linear layer:** everything Islands consumes arrives with the + Tearing module work (PR #238, `feature/tearing-growthrates`, sequenced to + land before Islands starts): SLAYER Δ(Q) and GGJ inner-layer models + (`src/Tearing/InnerLayer/`), the dispersion/root-finding layer, and the + outer-region Δ′ as a full 2m×2m matrix (`delta_prime_raw` from + ForceFreeStates with the new Riccati ideal solver). Islands never recomputes + global ideal-MHD physics; the SLAYER Δ(Q) is a Level-3 *verification target* + called directly in CI (docs/05 D1). If Islands work begins before #238 merges, + branch from `feature/tearing-growthrates`, not `develop`. +- **EP corrections to Δ'** (fast-ion pressure in the outer region) stay on the + GPEC side (`src/KineticForces`, the PENTRC/NTV machinery — also the natural + NTV restoring-torque source for Level 4 torque balance); Islands handles + resonant/orbit-width EP physics at the island. + +## Document map + +Design docs live in `docs/src/islands/design/`; module conventions in +`src/Islands/CLAUDE.md`. The `docs/NN` shorthand used throughout means design +doc `NN` (`docs/src/islands/design/NN-*.md`). + +| File | Contents | +|---|---| +| `src/Islands/CLAUDE.md` | Module conventions for Claude Code: layout map, style, testing gates, [VERIFY] policy, merge policy | +| `design/00-roadmap.md` | Level 0–4 plan, milestones, risk register, decision log | +| `design/01-physics-level0.md` | Level 0 equation set: coordinates, DKE, quasineutrality, moments, nondimensionalization | +| `design/02-species-and-eps.md` | Species abstraction; tungsten (Level 1) and energetic particles (Level 2) physics specs | +| `design/03-architecture.md` | In-repo module layout, operator stack, toggles, AD strategy, coupling interfaces | +| `design/04-numerics.md` | Discretization, boundary layers, Newton–Krylov, continuation, performance model | +| `design/05-verification.md` | The benchmark ladder: every analytic limit and published number Islands must recover, per level | +| `design/06-autonomy-and-tooling.md` | GPEC-repo integration, autonomous Claude Code workflows, GPD, skills, subagents, setup checklist | +| `design/07-documentation-and-papers.md` | Living documentation system (Physics Book, State Dashboard, figure pipeline) and the paper series; level gates are manuscripts | +| `design/08-reference-library.md` | Map of the in-repo PDF sources (`docs/resources/Drift_Kinetic_Island_References/`), per-source load-bearing content, and known cross-source inconsistencies | + +## Reading order for a new contributor (human or Claude) + +`00-roadmap` → `01-physics-level0` → `03-architecture` → `05-verification`, then +`02` and `04` as needed. Nothing in `src/Islands/` may contradict these documents; +when it must, the document is amended *first* (doc-first workflow, see +`src/Islands/CLAUDE.md`). + +## Canonical references + +The York drift-kinetic lineage is in-repo as PDFs +(`docs/resources/Drift_Kinetic_Island_References/`) — see **docs/08** for the +per-source map, abbreviations, and known cross-source inconsistencies: + +- K. Imada et al., PRL 121, 175001 (2018); JPCS 1125, 012013 (2018); **Nucl. + Fusion 59, 046016 (2019)** — DK-NTM: 4D nonlinear drift-kinetic island + theory. The 2019 paper is the complete reference; **its published equation + set carries known errata (see Leigh 2023 §2.6)**. +- A. V. Dudkovskaia, PhD dissertation, York (2019) — full RDK derivation chain + and solver coefficient sets (Appendices C–E). +- A. V. Dudkovskaia et al., PPCF 63, 054001 (2021); Nucl. Fusion 63, 016020 + (2023); Nucl. Fusion 63, 126040 (2023) — RDK-NTM v.1–v.3: improved drift + model, shaping/finite-β, separatrix layer & polarization / ω-dependence. +- S. Leigh, PhD thesis, York (Dec 2023) — `kokuchou`: amended DK-NTM equation + set, finite-ν★ threshold surface w_c(ρ̂_θi, ν_★), and the most complete + forensic record of the numerical failure modes (singular trapped-passing + matching, separatrix-layer resolution, Picard non-convergence, spurious + Neumann branches). +- A. V. Dudkovskaia et al., JPCS 1125, 012009 (2018) — *phase-space* island + stability (bump-on-tail); methodological antecedent and EP background only, + not an NTM threshold source. + +Classical MRE-term and penetration literature (not yet in the PDF library): + +- R. Fitzpatrick, Nucl. Fusion 33, 1049 (1993); Phys. Plasmas 5, 3325 (1998) — linear penetration thresholds, torque balance. +- A. Cole & R. Fitzpatrick, Phys. Plasmas 13, 032503 (2006) — drift-MHD regime-specific penetration thresholds. +- J.-K. Park, Phys. Plasmas 29 (2022) — SLAYER: parametric layer responses across linear two-fluid drift-MHD regimes. +- P. H. Rutherford, Phys. Fluids 16, 1903 (1973) — nonlinear island evolution. +- O. Sauter et al., Phys. Plasmas 6, 2834 (1999) — bootstrap coefficients (arbitrary ν*). +- A. H. Glasser, J. M. Greene, J. L. Johnson, Phys. Fluids 18, 875 (1975) — curvature (GGJ). +- H. R. Wilson, J. W. Connor, R. J. Hastie & C. C. Hegna, Phys. Plasmas 3, 248 (1996) — the analytic electron closure and large-w limits (load-bearing for Level 0; acquire the PDF); F. L. Waelbroeck & R. Fitzpatrick, PRL 78, 1703 (1997); A. I. Smolyakov — polarization current (regime-dependent). +- R. Fitzpatrick, Phys. Plasmas 2, 825 (1995) — transport threshold w_d. + +Equation transcriptions from the in-repo PDFs carry [CHECKED: source, Eq./p.] +tags (AI-verified against the PDF, one human sign-off pending); everything +else carries [VERIFY] until the source is in hand (see CLAUDE.md). diff --git a/docs/src/islands/numerics.md b/docs/src/islands/numerics.md new file mode 100644 index 000000000..31efcb230 --- /dev/null +++ b/docs/src/islands/numerics.md @@ -0,0 +1,362 @@ +# Islands — numerics as implemented (M1–M2) + +This chapter documents the Islands machinery **as it exists in the code today** +— the discretization, operator stack, solver, and output-moment assembly landed +by milestones M1 and M2, with the verification evidence behind each piece. It +is the "Physics Book" companion to the aspirational [design documents](design/00-roadmap.md): +the design docs say what Islands *will* compute; this page says what is +*implemented and verified now*, equation by equation. + +!!! warning "Where the physics is (and isn't)" + Everything on this page is **structure and numerics**. Under the module's + `[VERIFY]` policy (`src/Islands/CLAUDE.md`), no physics coefficient, sign, + or normalization from the drift-kinetic literature has been assigned a + value anywhere in `src/`: every such quantity enters as a *supplied, + gated parameter* (many deliberately default to `NaN` so an un-cleared + convention poisons results rather than guessing). The human clearance + queue that un-gates the physics is `QUESTIONS.md` entries **Q2–Q4**; until + then the physics benchmarks (`benchmarks/islands/`) ship skipped by design. + +## 1. Phase space and discretization + +The Level-0 solve lives on the orbit-averaged phase space +``(x, \xi;\, y, E, \sigma)`` — radial distance from the rational surface, +helical angle, pitch ``y = \lambda B_{\max}``, energy, and the parallel-velocity +sign ``\sigma = \pm 1`` (design `03 §1`). Implemented in `Islands.PhaseSpace`: + +**Helical angle ``\xi``** — Fourier pseudo-spectral on the periodic domain. The +dense spectral derivative on an even number ``n`` of uniform nodes is + +```math +(D_1)_{jk} \;=\; \frac{(-1)^{j-k}}{2}\,\cot\!\Big(\frac{(j-k)\,h}{2}\Big), +\qquad j \neq k,\quad h = \tfrac{2\pi}{n}, +``` + +exact for bandlimited data (verified to ``6\times10^{-15}`` in the tests). + +**Radial ``x`` and pitch ``y``** — high-order finite differences on +layer-clustered grids. A uniform computational coordinate ``s \in [-1, 1]`` maps +to the physical coordinate through a monotone ``\sinh`` stretching that packs +nodes at the internal layers the drift-kinetic problem develops (`04 §1–2`): + +```math +x(s) \;=\; x_c + L\,\frac{\sinh(\beta s)}{\sinh(\beta)} +\qquad\Longrightarrow\qquad +\frac{d}{dx} = \frac{1}{x'(s)}\frac{d}{ds},\quad +\frac{d^2}{dx^2} = \frac{1}{x'(s)^2}\frac{d^2}{ds^2} - \frac{x''(s)}{x'(s)^3}\frac{d}{ds}. +``` + +The radial grid packs toward the rational surface ``x = 0``; the pitch grid +toward the trapped–passing boundary ``y_c`` — the two layers whose widths scale +as ``\nu^{1/2}`` and set the prior art's operating floor (`04 §2`). +Derivative matrices use Fornberg weights on windows of ``\mathrm{order}+d`` +points for the ``d``-th derivative, so ``D_1`` **and** ``D_2`` hold the design +order uniformly, including at boundary rows. Composite-Simpson weights on the +same nodes (pushed through the map Jacobian) give quadrature at matching order. + +**Energy ``E``** — Gauss–Laguerre nodes and weights: the Level-0 Maxwellian +weight ``\int_0^\infty f(E)\, e^{-E}\, dE = \sum_i w_i f(E_i)`` (a slowing-down +background at Level 2 changes the map, not the machinery). + +![layer-clustered grids](figures/grids_clustering.png) + +*Implementing symbols:* `PhaseSpace.FourierGrid`, `PhaseSpace.MappedFDGrid`, +`PhaseSpace.GaussGrid`, `PhaseSpace.IslandGrid`, `PhaseSpace.fd_weights`. + +## 2. The operator stack + +The unknowns are ``U = (g,\, \tilde\Phi)`` — the orbit-averaged distribution +``g(x, \xi, y, E, \sigma)`` per species and the electrostatic potential +``\tilde\Phi(x, \xi)`` — and the steady-state residual is assembled as a sum of +independent operator applications (design `03 §2`; no term inspects which +others are active, no regime branches anywhere): + +```math +R_g(U) \;=\; \sum_{\text{terms } T} T[U], +\qquad +R_\Phi(U) \;=\; M[g] - \alpha\,\tilde\Phi + S_\Phi , +``` + +with the Level-0 term structures (each coefficient below is **supplied data**, +its physics value gated): + +| Term | Structure | Gated coefficient | +|---|---|---| +| `ParallelStreaming` | ``a_\xi\, \partial_\xi g + a_x\, \partial_x g`` | **cleared** — ``(\hat L_q^{-1}\hat w^2/4\hat\rho_{\theta i})\Theta\,\{\Omega,\cdot\}`` advection along island surfaces (§8) | +| `MagneticDrift` | ``c_D\, \partial_\xi g`` (with the `:original`/`:improved` ``\hat L_B^{-1}`` toggle) | the precession frequency ``\hat\omega_D(y, E; \sigma)`` | +| `ExBDrift` | ``c_E \left( \partial_\xi\tilde\Phi\, \partial_x g - \partial_x\tilde\Phi\, \partial_\xi g \right)`` — the ``(x,\xi)`` Poisson bracket, the one state-nonlinear Level-0 term | the ``E\times B`` coupling | +| `PitchAngleDiffusion` | ``c\,(K g)`` along ``y`` (mimetic form, §3) | ``\hat\nu(E)`` and the pitch diffusivity profile | +| `GradientDrive` | additive source | the ``(\mathbf v_E + \mathbf v_D + \mathbf v_{\tilde\psi})\cdot\nabla F_0`` drive | +| `Quasineutrality` | ``M[g] - \alpha\tilde\Phi + S_\Phi`` | **cleared** — ``\alpha=(\tau+1)/\tau`` and the drive ``S_\Phi=\hat L_{n0}^{-1}(x-\hat h)`` (§8) | + +Every `apply!` kernel is allocation-free (a CI regression test holds this at +**0 bytes**) and generic over the element type, so ForwardDiff dual numbers +flow through the entire stack — that is what makes the solver's Jacobian exact +(§5). + +Implemented by: `Operators.ParallelStreaming`, `Operators.MagneticDrift`, +`Operators.ExBDrift`, `Operators.Collisions`, `Operators.PitchAngleDiffusion`, +`Operators.GradientDrive`, `Operators.PerpTransport`, `Operators.RadiationSink`, +`Operators.Quasineutrality`. + +The last two are Level-4 closure stubs, and `Collisions` is the non-mimetic +collision slot superseded at Level 0 by `PitchAngleDiffusion` (§3); the stack is +assembled by `Operators.residual!` over an `Operators.IslandStack`. The +anchor-sync check `Verify.check_anchor_sync` holds this list in step with the +`AbstractTerm` subtypes so a new operator without documentation, or a doc naming +a deleted symbol, fails the check. + +## 3. The conservative collision structure + +Bootstrap physics is unforgiving about non-conservative collision operators +(design `00`, risk register), so the pitch-angle operator is implemented in +**mimetic divergence form**. With gradient matrix ``G`` (the ``y``-grid ``D_1``), +quadrature weights ``w_q``, a supplied non-negative diffusivity profile ``P(y)`` +and measure ``w(y)``: + +```math +K \;=\; -\,W_q^{-1}\, G^{\mathsf T}\, \mathrm{diag}(P \circ w_q)\, G, +\qquad W_q = \mathrm{diag}(w \circ w_q), +``` + +which enjoys the two Level-0 conservation properties **exactly in floating +point**, not just asymptotically: + +```math +\mathbf 1^{\mathsf T} W_q K g + = -\,(G\mathbf 1)^{\mathsf T}\,\mathrm{diag}(P \circ w_q)\,(G g) = 0 +\quad\text{(particles; } G\mathbf 1 = 0\text{)}, +``` +```math +g^{\mathsf T} W_q K g + = -\,(G g)^{\mathsf T}\,\mathrm{diag}(P \circ w_q)\,(G g) \;\le\; 0 +\quad\text{(entropy sign)}. +``` + +Verified to ``10^{-14}`` (ladder **A4**). A physically-profiled ``P`` vanishes +at the pitch-domain endpoints, so zero-flux boundary behavior is built into the +operator — no artificial ``y`` boundary conditions. (This mattered in practice: +the generic ``a_y \partial_y^2`` form *without* boundary conditions is an +unstable BVP discretization under refinement; the mimetic degenerate form is +the correct structure.) + +*Implementing symbols:* `Operators.conservative_pitch_operator`, +`Operators.PitchAngleDiffusion`. + +## 4. Boundary conditions + +Far-field rows at ``|x| = L_x`` are replaced by matching conditions +``g - g_\infty`` and ``\tilde\Phi - \tilde\Phi_\infty`` (Dirichlet-type against +supplied far-field states). **Never bare Neumann** ``\partial_x g = 0``: the +prior art traced a spurious "winged" solution branch directly to Neumann +non-uniqueness (`01 §3`). The physical far field — the no-island neoclassical +solution — is gated physics, so the code takes it as supplied data; the tests +use manufactured far fields. These conditions are also what make the +first-order-in-``x`` advective solve well-posed. + +*Implementing symbols:* `Operators.FarFieldConditions`, `Operators.apply_farfield!`. + +## 5. The Newton–Krylov solve + +Decision D2: steady-state Newton–Krylov, never time-stepping and never the +sources' nested Picard loops (which, per the prior-art forensics, *never met* +their own convergence criterion in production). The pieces (design `04 §5`): + +**Exact matrix-free Jacobian.** The directional derivative comes from one dual- +number sweep of the residual — no finite-difference Jacobian, no global sparse +matrix: + +```math +J(u)\,v \;=\; \left.\frac{d}{d\varepsilon}\right|_{\varepsilon=0} F(u + \varepsilon v) +\quad\text{via ForwardDiff duals through the stack.} +``` + +**Inexact Newton with Eisenstat–Walker forcing.** Each step solves +``J\,\delta u = -F`` by GMRES only to the tolerance the outer iteration needs, + +```math +\eta_k = \gamma \left( \frac{\lVert F_k \rVert}{\lVert F_{k-1} \rVert} \right)^{2}, +``` + +with a backtracking line search on ``\lVert F \rVert``. Convergence is declared +on the norm **and** the pointwise maximum of the residual — the array-averaged +residual famously hid locally divergent regions in the prior art. + +**Physics-block preconditioning with explicit regularization.** The stiff +pitch-direction blocks are factored per pencil by SVD and truncated below +``\epsilon\,\sigma_{\max}`` — the deliberate treatment of the intrinsically +near-singular trapped–passing matching block (`04 §3`; the prior art measured +``\mathrm{rcond} \sim 10^{-16}`` there and got machine-dependent *noise, not +crashes*, under plain LU). On a collision-dominated test solve the block-Jacobi +preconditioner cuts the work by an order of magnitude: + +![preconditioner comparison](figures/preconditioner_gmres.png) + +A companion diagnostic tracks the smallest singular value of the ``y_c``-block +of the (tiny-grid, debug) dense Jacobian — ladder **A8** — so a silent +conditioning regression is *tested for*, not observed. + +*Implementing symbols:* `Solvers.newton_krylov`, `Solvers.JVPOperator`, +`Solvers.YBlockJacobi`, `Solvers.dense_jacobian`, `Verify.yc_block_sigma_min`. + +## 6. Continuation and fold detection + +Δ-surface generation, Newton globalization, and (at Level 3) the penetration +bifurcation all ride on pseudo-arclength continuation, so fold handling is in +from day one. The corrector solves the extended system + +```math +G(z) = \begin{pmatrix} F(u, p) \\ t \cdot (z - z_{\text{pred}}) \end{pmatrix} = 0, +\qquad z = (u, p), +``` + +with a secant tangent ``t`` and fold detection via sign reversal of the +tangent's parameter component ``t_p``: + +![continuation fold](figures/continuation_fold.png) + +*Implementing symbol:* `Solvers.pseudo_arclength`. + +## 7. Island geometry, the electron-closure functions, and the Δ moments + +The island flux-surface label is the pinned module convention (half-width +``w``): + +```math +\Omega(x, \xi) = \frac{2x^2}{w^2} - \cos\xi, +\qquad \Omega = -1 \text{ at the O-point},\quad \Omega = +1 \text{ at the separatrix}, +``` + +with the flux-surface average +``\langle f \rangle_\Omega = \oint f\,(\Omega + \cos\xi)^{-1/2} d\xi \,/\, +\oint (\Omega + \cos\xi)^{-1/2} d\xi`` — a *diagnostic* only, never a solve +coordinate (Decision D1). The flattened-electron closure geometry is +implemented as structure with a supplied amplitude: + +```math +Q(\Omega) = \frac{1}{2\pi} \oint \sqrt{\Omega + \cos\xi}\; d\xi, +\qquad +h(\Omega) = \Theta(\Omega - 1)\; C \int_1^{\Omega} \frac{d\Omega'}{Q(\Omega')}, +``` + +so ``h`` is exactly flat inside the separatrix. Because ``h'(\Omega) = C/Q``, +the chain rule gives the **coefficient-free consistency identity** (ladder +**A7** — the unit target that historically caught inherited bugs in this +lineage): + +```math +\Big\langle \frac{\partial^2 h}{\partial x^2} \Big\rangle_{\!\Omega} + = \frac{4}{w^2}\left[ h''(\Omega)\, \frac{Q}{Q'} \; +\; h'(\Omega) \right] + \;\equiv\; 0 , +``` + +verified to ``10^{-16}`` for arbitrary amplitude ``C``: + +![Q and h profiles](figures/hQ_profiles.png) + +The output moments are the two Ampère projections of the species-summed +parallel current ``\bar J_\parallel = \sum_j Z_j \int W_j\, g_j`` (`01 §4`): + +```math +\Delta_{\cos} = C_{\cos} \int dx \oint d\xi\; \bar J_\parallel \cos\xi, +\qquad +\Delta_{\sin} = C_{\sin} \int dx \oint d\xi\; \bar J_\parallel \sin\xi, +``` + +where the ``\xi``-projection is spectrally exact on the periodic grid and the +prefactors ``C_{\cos}, C_{\sin}`` (physically ``\mp\mu_0 R / 2\tilde\psi``) are +**required, gated arguments** — ``\tilde\psi`` carries an open `[VERIFY]` and +the sin normalization is `[DERIVED]`-unpinned (QUESTIONS Q4). The parity +structure ``\Delta_{\cos}`` even / ``\Delta_{\sin}`` odd under ``\xi``-reflection +is verified exactly (ladder **A3**). + +*Implementing symbols:* `Moments.parallel_current!`, `Moments.delta_moments`, +`Moments.omega_average`, `Fields.Q_omega`, `Fields.h_profile`, +`Fields.flat_average_d2h_dx2`. + +## 8. The Level-0 configuration assembly (M2c) + +`Islands.Configure.configure_level0(grid, phys, species; gated)` assembles a +Level-0 named configuration — the `IslandStack` + far-field conditions + `Δ` +prefactors the solver consumes — by wiring the **human-cleared** coefficient +builders (§7, the M2b derivation lane) onto the operator stack: + +- the magnetic drift `c_D[ix, iξ, iy, iE, iσ]` from `magnetic_drift_frequency`, + evaluated on the phase-space grid (``\hat v = \sqrt E``, the ``:original`` / + ``:improved`` toggle, and the forbidden pitch region ``y \ge (1+\varepsilon)/ + (1-\varepsilon)`` zeroed since it carries no particles); +- the **island-streaming** coefficients ``a_\xi``, ``a_x`` from + `streaming_coefficients` — the passing-particle (`Θ(y_c−y)`) advection along + island flux surfaces, ``(\hat L_q^{-1}\hat w^2/4\hat\rho_{\theta i})\Theta\,\{\Omega,\cdot\}`` + (`parallel-streaming.md`; normalized to leave `c_D = ω̂_D` unchanged); +- the pitch-collision diffusivity ``P`` and the energy-dependent deflection + coefficient from `pitch_diffusivity` / `deflection_frequency`, fed to the + mimetic `conservative_pitch_operator` (§3); +- the ``\Delta_{\cos}`` / ``\Delta_{\sin}`` prefactors from + `delta_moment_prefactors` (§7); +- the **quasineutrality field term** — ``\alpha=(\tau+1)/\tau`` from + `quasineutrality_coefficient` and the drive + ``S_\Phi=\hat L_{n0}^{-1}(x-\hat h(\Omega))`` from `quasineutrality_source` + (the ``\hat h`` amplitude ``w/2\sqrt2`` from `h_amplitude`, the profile from + `Fields.h_profile`), closing the Level-0 potential (§2; the drive whose absence + had left ``\Phi`` trivially zero). + +The gradient drive is cleared as I19 Formulation A — a **zero** interior +`GradientDrive` source plus the neoclassical far field +``g_{\rm far} = x\hat L_{n0}^{-1}[1+(E-\tfrac32)\eta_i]`` (`gradient_far_field`; +`Φ̂_far = 0` at `ω_E = 0`). The families that remain **not yet cleared** — the +``E\times B`` coupling, the collision magnitude ``\langle\hat\nu_{ii}\rangle_u``, +and the orbit-averaged pitch measure — are **supplied** through +`GatedLevel0Inputs`, never assigned a physics value here (QUESTIONS Q5). So the +assembly is still a **scaffold** for those three *kinetic* pieces, even though +the streaming, drift, gradient drive, far field, and field equation are now +cleared: with `level0_placeholders` +(documented non-physics values for the gated kinetic inputs) the assembled +residual is well-formed, ``\Phi`` is genuinely driven, and Newton–Krylov +converges. A physics threshold still awaits the remaining Q5 kinetic clearances. +Implemented by: `Configure.configure_level0`. + +## 9. Verification evidence (the A-ladder, all green in CI) + +The manufactured-solution ladder verifies discretization order and the AD +plumbing simultaneously — per operator, for the assembled residual, and through +a full converged Newton solve forced by the analytic source: + +![MMS convergence](figures/mms_convergence.png) + +| Gate | Statement | Result | +|---|---|---| +| A1 | per-operator + assembled MMS at design order | 4th order (``\xi`` spectral to ``10^{-15}``) | +| A1-solve | converged Newton solve recovers the manufactured state | observed order **3.98** | +| A2 | AD Jacobian–vector product vs. central finite differences | agree to ``\sim 10^{-9}`` | +| A3 | ``\Delta_{\cos}`` even / ``\Delta_{\sin}`` odd parity | exact | +| A4 | particle conservation + entropy sign of the collision operator | exact (``10^{-14}`` / definite) | +| A5 | zero-drive null: ``g \equiv 0 \Rightarrow R = 0`` | **exactly** machine zero | +| A7 | ``\langle \partial^2 h / \partial x^2 \rangle_\Omega = 0`` | ``10^{-16}`` | +| A8 | ``y_c``-block ``\sigma_{\min}`` monitor + singular detection | active | +| M2c | L0 assembly builds; cleared ``c_D`` faithful; placeholder solve converges | exact / 5 Newton iters | +| — | allocation regression on every hot kernel | 0 bytes | + +Everything above regenerates from one pinned script +(`benchmarks/islands/figures/make_structural_figures.jl`) and runs in the test +suite (`test/runtests_islands_{grids,operators,solve,configure}.jl`); the +`islands_l0_structural` regression case tracks the headline numbers across +commits. The always-current ladder status is the +[State dashboard](state/STATE.md). + +## 10. What comes next + +The physics story — the drift-kinetic coefficients, the drift-model threshold +*scalings and toggle differentials*, the ``\Delta_{\text{pol}}(\omega_E)`` +sign-reversal *behavior* — is written as the +[Paper I figure contract](papers/paper-1/OUTLINE.md) and un-gates claim by claim +as the derivation lane and human clearances (`QUESTIONS.md` Q2–Q5) are worked +through — Q5 being the remaining Level-0 coefficient families the M2c assembly +surfaced as still-gated. Following the SLAYER-validation precedent (Park 2022 / Burgess 2026), +the physics gates are **tiered by reproducibility** (Decision D9, docs/05): +scalings, regime trends, and internally-controlled differentials are the primary +quantitative checks; absolute literature numbers (e.g. the "``8.73 \to 1.46\, +\rho_{bi}``" drift-model shift) are reported only alongside an input manifest and +sensitivity scan, because reproducing an absolute threshold requires every input +of the source's exact scenario — which the lineage under-specifies. The +[design documents](design/00-roadmap.md) hold the full eight-milestone program. diff --git a/docs/src/islands/papers/paper-1/OUTLINE.md b/docs/src/islands/papers/paper-1/OUTLINE.md new file mode 100644 index 000000000..a4e8cc0ed --- /dev/null +++ b/docs/src/islands/papers/paper-1/OUTLINE.md @@ -0,0 +1,55 @@ +# Paper I — OUTLINE (the Level-0 figure contract) + +> Created at level *start* per design doc `07 §3`: claims → figures → ladder +> IDs. This outline is the figure contract — agents implementing benchmarks +> know which figures are paper figures from day one. Every claim must be backed +> by a ladder ID; claims lacking one are flagged. Status reflects the +> `[VERIFY]` gating of `docs/src/islands/QUESTIONS.md` (Q2–Q4) — **no +> submission until every tag in the paper's equation set is cleared**. + +**Working title:** Formulation and verification of a generalized drift-kinetic +solver for magnetic-island stability (Islands, Level 0). + +**Indicative venue:** Phys. Plasmas (methods paper). **Gate:** Level 0 +(design `00`), i.e. ladder A + B1/B2/B4/B5a–c green with convergence artifacts. + +## Claims and figures + +| # | Claim | Figure(s) | Ladder ID(s) | Status | +|---|---|---|---|---| +| C1 | The `(x, ξ; y, E, σ)` discretization converges at design order: spectral in `ξ`, 4th-order FD on layer-clustered `x`/`y` grids, per operator and for the assembled solve | F1: MMS convergence panels (per-operator + assembled residual + assembled solve error vs. resolution) | A1, A2 | **green** (M1/M2; CI artifacts) | +| C2 | The steady-state Newton–Krylov solve is exact-Jacobian (AD), globally convergent from zero states, and its conditioning is monitored at the trapped–passing boundary (no silent-noise regime, contra L23 §4.2) | F2: Newton/GMRES convergence histories with and without the physics-block preconditioner; `σ_min(y_c)` track | A5, A8 (+ solver gates) | **green** (M2) | +| C3 | The discretized collision operator conserves particles exactly and has definite entropy sign — bootstrap-relevant structure holds discretely, not just asymptotically | F3: conservation/entropy residuals vs. resolution and profile | A4 | **green** (L0 parts, M2) | +| C4 | In the no-island limit the solver reproduces standard local neoclassics (bootstrap current vs. Sauter/NEO) — the strongest global check of the velocity-space discretization | F4: `J_bs` vs. `ν_★` against NEO/Sauter | B1 | **gated** — needs cleared L0 coefficient set (Q2, Q3) + external NEO runs | +| C5 | **(T3, primary)** At large `w` the solver recovers the analytic MRE *scalings* `Δ_bs + Δ_cur ∝ 1/w` and `Δ_pol ∝ 1/w³`; **(T4)** the 1/w coefficient vs. WCHH96 Eq. 85 (frame-mapped) is audit-gated | F5: `Δ` channels vs. `w` with fitted exponents and analytic asymptotes | B2 | **gated** — Q2, Q3, Q4 (`ψ̃` [VERIFY]); WCHH96 acquired | +| C6 | **(T2, robust headline)** The `:original → :improved` drift-model **toggle differential** — a ~×6 reduction in `w_c` in an otherwise identical configuration, measured within Islands (the reproducible form of the sources' `8.73 → 1.46 ρ_bi` story) — plus **(T3)** threshold *existence* at `w_c ~ O(ρ_θi)` and kokuchou's `dw_c/dν_★ > 0` trend. **(T4, audit-gated)** the absolute triangle values (`2.76`/`0.45 ρ_θi`, the `0.440…` fit surface) reported only with input manifests | F6: `w_c` vs. configuration with tier labels; F7: the E1 toggle *ratio* scan | B5a, B5b, B5c (E1) | **gated** — Q2 (D7/D8), Q3, Q4 | +| C7 | **(T3, primary)** The polarization `Δ_pol ∝ ω_E²` away from zero with a sign reversal *existing* at an `ω_E` of order `−ω_dia,e`, reversal location insensitive to `w/ρ_θi`; single-`ω_E` `Δ` values are misleading (surfaces over `(w, ω_E)` are the deliverable). **(T4)** the reversal location (`≈ −0.89 ω_dia,e`) audit-gated | F8: `Δ_pol(ω_E)` reversal curve (morphology vs. D23b Fig. 8); F9: `Δ(w, ω_E)` surface | B4 | **gated** — Q2, Q3 (frame-convention signs) | +| C8 (candidate headline) | **(T2, internal)** Resolution of L23's open question: the stabilizing *electron* `Δ_pol` at `ω_E = 0` — reproduced or refuted by the `ω_E` scan with kinetic vs. flattened electrons (E4), a channel-decomposition comparison we control end-to-end | F10: electron-channel `Δ_pol(ω_E)` decomposition | B4 + E4 | **gated** — Q2, Q3; kinetic-electron toggle is M2+/E4 work | +| C9 (methods) | **Input-completeness audit** of the DK-NTM/RDK-NTM/kokuchou configurations: a per-source manifest of what the published NTM-threshold scenarios actually pin down — itself a reproducibility contribution that frames every T4 comparison and pre-empts benchmark-provenance questions | Table: input manifests per source (docs/05 "input-manifest" template) | — (methods) | **in progress** (M2b deliverable) | + +## Verification-artifact rules (docs/05 reporting) + +- Every figure names its configuration (docs/03 §2) and git SHA; no benchmark + "passes" on a single grid — convergence + tolerance archived with the result. +- Threshold numbers are reported as **half-widths** with both `ρ_θi` and + `ρ_bi = ε^{1/2} ρ_θi` stated at the run's `ε` (docs/05 rule 5). +- Disagreements with published targets are triaged per the standing rule + (docs/05: our bug / their approximation / their published-equation error / + transcription error / **under-specified source configuration**) with + `[VERIFY]` resolution logged first. +- **Targets are tiered (Decision D9, docs/05).** The paper's quantitative + physics claims are T1 (exact math), T2 (internal differentials/cross-checks — + the sharpest), and T3 (scalings/trends/existence vs. literature). Absolute + literature numbers (T4) appear only with their input manifests and + sensitivity scans; where the source is under-specified they are reported as + order-of-magnitude + trend, not agreement claims. + +## Dependencies for un-gating (the human clearance queue) + +`QUESTIONS.md` **Q2** (ratify D7/D8 — done), **Q3** (clear the L0 `[CHECKED]` +coefficient set via re-derivation), **Q4** (resolve the `ψ̃` and +B5a-collisionality `[VERIFY]`s; sources acquired). C1–C3 are already green as +CI artifacts. The physics claims un-gate as the M2b derivation lane and the +input-completeness audit (C9) proceed — the T2/T3 scaling gates need only the +cleared coefficients, while the T4 absolute comparisons additionally need the +per-source input manifests. diff --git a/docs/src/islands/state/STATE.md b/docs/src/islands/state/STATE.md new file mode 100644 index 000000000..b85b32b2b --- /dev/null +++ b/docs/src/islands/state/STATE.md @@ -0,0 +1,36 @@ +# Islands — State Dashboard + +!!! warning "Auto-generated — do not hand-edit" + Generated by `Islands.Verify.write_state_dashboard` (docs/07 §1.3). + Regenerate rather than edit; it refreshes from the ladder spec and, + as they land, archived benchmark artifacts. + +Snapshot: commit `03fee6bb` — 2026-07-11. + +The docs/05 verification ladder. The **A-ladder** (structural, pre-physics) +is green via `test/runtests_islands_*.jl`. The **B/C physics ladder** is +gated on the `QUESTIONS.md` clearances — no physics result is claimed until +human sign-off (the `[VERIFY]` policy, module CLAUDE.md). + +| ID | Tier | Target | Status | Gate | +|---|---|---|---|---| +| A1 | structural | MMS convergence (ξ spectral; x, y order-4) | ✅ green | test suite | +| A2 | structural | AD-vs-FD JVP agreement | ✅ green | test suite | +| A3 | structural | Δ_cos even / Δ_sin odd under ξ-reflection | ✅ green | test suite | +| A4 | structural | mimetic pitch operator: exact discrete conservation + entropy sign | ✅ green | test suite | +| A5 | structural | zero-drive null: g≡0 ⇒ residual machine-zero | ✅ green | test suite | +| A7 | T1 | coefficient-free closure identity ⟨∂²h/∂x²⟩_Ω = 0 | ✅ green | test suite | +| A8 | structural | y_c matching-block conditioning monitor | ✅ green | test suite | +| M2c | structural | L0 assembly builds + solves structurally (placeholders) | ✅ green | test suite | +| B2 | T3 | large-w scalings Δ_bs+Δ_cur ∝ 1/w, Δ_pol ∝ 1/w³ | 🔒 gated | QUESTIONS Q5 | +| B4 | T3 | Δ_pol ∝ ω_E² + sign-reversal existence | 🔒 gated | QUESTIONS Q3, Q5 | +| B5a | T3 | threshold existence w_c ~ O(ρ_θi), :original | 🔒 gated | QUESTIONS Q5 | +| B5b/E1 | T2 | :original→:improved w_c toggle differential (~×6) | 🔒 gated | QUESTIONS Q5 | +| B5c | T3 | ν_★ trend dw_c/dν_★ > 0; w_c ∝ ρ̂_θi | 🔒 gated | QUESTIONS Q5 | +| B7 | T2 | DK vs RDK cross-check mode | 🔒 gated | QUESTIONS Q5 | +| C4 | T3 | finite-β/shaping triangularity trend + ε-crossover | ⏳ planned | Level 2 | + +Summary: **8 green** (structural), 6 gated (physics, awaiting clearance), 1 planned. + +Tiers (Decision D9, docs/05 "Target tiers"): T1 exact math · T2 internal +differentials · T3 scalings/trends/existence · T4 absolute (audit-gated). diff --git a/regression-harness/cases/islands_l0_structural.toml b/regression-harness/cases/islands_l0_structural.toml new file mode 100644 index 000000000..091b49491 --- /dev/null +++ b/regression-harness/cases/islands_l0_structural.toml @@ -0,0 +1,60 @@ +# Regression case: Islands L0 solve machinery, structural (pre-physics) quantities. +# A "computed" case (kind = "computed", no example_dir) tracking the M2 structural +# gates: the solve-level MMS error and solver iteration counts (Newton-Krylov on the +# manufactured advective stack at nx = 17), the coefficient-free flattened-electron +# closure identity |_Omega| at Omega = 2, and the y_c-block smallest singular +# value (the L23 SS4.2 silent-noise tripwire). The physics B-ladder stays skipped until +# [VERIFY] clearance (docs/src/islands/QUESTIONS.md Q2-Q4); these are the numbers that +# silently move if the discretization, solver, or quadratures drift. +[case] +name = "islands_l0_structural" +description = "Islands L0 structural: solve-MMS error, solver iterations, A7 identity, y_c sigma_min" +kind = "computed" + +[quantities.solve_mms_err] +h5path = "islands/solve_mms_err" +type = "real_scalar" +extract = "value" +label = "solve-MMS max error (nx=17)" +noise_threshold = 1e-8 +order = 10 + +[quantities.newton_iters] +h5path = "islands/newton_iters" +type = "real_scalar" +extract = "value" +label = "Newton iterations" +noise_threshold = 0.0 +order = 11 + +[quantities.gmres_iters] +h5path = "islands/gmres_iters" +type = "real_scalar" +extract = "value" +label = "GMRES iterations (total)" +noise_threshold = 0.0 +order = 12 + +[quantities.a7_identity] +h5path = "islands/a7_identity" +type = "real_scalar" +extract = "value" +label = "|_Omega| (A7)" +noise_threshold = 1e-12 +order = 13 + +[quantities.yc_sigma_min] +h5path = "islands/yc_sigma_min" +type = "real_scalar" +extract = "value" +label = "y_c block sigma_min (A8)" +noise_threshold = 1e-10 +order = 14 + +[quantities.runtime] +h5path = "" +type = "runtime" +extract = "value" +label = "Runtime (s)" +noise_threshold = 0.0 +order = 90 diff --git a/regression-harness/src/runner.jl b/regression-harness/src/runner.jl index 8705cb4d5..d6aa9b217 100644 --- a/regression-harness/src/runner.jl +++ b/regression-harness/src/runner.jl @@ -2,7 +2,9 @@ Runner: orchestrates checking out commits, running GPEC, and extracting results. """ -"""Read the GPEC-only runtime from the timing file written by the subprocess.""" +""" +Read the GPEC-only runtime from the timing file written by the subprocess. +""" function _read_timing_file(path::String)::Float64 if isfile(path) return parse(Float64, strip(read(path, String))) @@ -29,7 +31,7 @@ function _materialize_rundir(example_path::String, overrides::Dict{String,Any}) for (dotted, val) in overrides ks = split(dotted, ".") d = cfg - for k in ks[1:(end - 1)] + for k in ks[1:(end-1)] d = get!(d, k, Dict{String,Any}()) end d[ks[end]] = val @@ -109,28 +111,60 @@ open(ARGS[2], "w") do f end """ +# Islands L0 structural regression: the solve-level MMS (Newton-Krylov recovers +# the manufactured state), the coefficient-free A7 closure identity, and the A8 +# y_c-block conditioning monitor. Tracks the structural (pre-physics) numbers +# that would silently move if the discretization, solver, or quadratures drift; +# the physics B-ladder stays skipped until [VERIFY] clearance (QUESTIONS Q2-Q4). +const COMPUTED_ISLANDS_SCRIPT_TEMPLATE = """ +using Pkg +%INSTANTIATE% +using GeneralizedPerturbedEquilibrium +using HDF5 +const Isl = GeneralizedPerturbedEquilibrium.Islands +t_start = time() +r = Isl.Verify.solve_mms(17) +a7 = Isl.Fields.flat_average_d2h_dx2(2.0, 1.0) +grid = Isl.PhaseSpace.IslandGrid(nx=7, nxi=8, ny=7, nE=2, halfwidth_x=6.0, clustering_x=1.0, + y_max=4.0, y_c=1.0, clustering_y=0.8, order=4) +setup = Isl.Verify.zero_drive_setup(grid) +J = Isl.Solvers.dense_jacobian(setup.f, zeros(setup.N)) +mon = Isl.Verify.yc_block_sigma_min(J, grid) +elapsed = time() - t_start +h5open(ARGS[1], "w") do fid + fid["islands/solve_mms_err"] = r.err + fid["islands/newton_iters"] = Float64(r.iterations) + fid["islands/gmres_iters"] = Float64(r.gmres_iters) + fid["islands/a7_identity"] = abs(a7) + fid["islands/yc_sigma_min"] = mon.sigma_min +end +open(ARGS[2], "w") do f + println(f, elapsed) +end +""" + """ Run GPEC for a single commit/ref and case. Dispatches to run_local for the working tree or run_at_commit for a git ref. """ function run_commit(db::SQLite.DB, commit_hash::String, ref_name::String, - case_spec::CaseSpec, repo_root::String; - force::Bool=false, verbose::Bool=false, - no_instantiate::Bool=false) + case_spec::CaseSpec, repo_root::String; + force::Bool=false, verbose::Bool=false, + no_instantiate::Bool=false) if case_spec.kind == "computed" if commit_hash == LOCAL_REF return run_computed_local(db, case_spec, repo_root; - verbose=verbose, no_instantiate=no_instantiate) + verbose=verbose, no_instantiate=no_instantiate) end return run_computed_at_commit(db, commit_hash, ref_name, case_spec, repo_root; - force=force, verbose=verbose, no_instantiate=no_instantiate) + force=force, verbose=verbose, no_instantiate=no_instantiate) end if commit_hash == LOCAL_REF return run_local(db, case_spec, repo_root; - force=force, verbose=verbose, no_instantiate=no_instantiate) + force=force, verbose=verbose, no_instantiate=no_instantiate) end return run_at_commit(db, commit_hash, ref_name, case_spec, repo_root; - force=force, verbose=verbose, no_instantiate=no_instantiate) + force=force, verbose=verbose, no_instantiate=no_instantiate) end """ @@ -141,6 +175,8 @@ function _computed_script_template(case_spec::CaseSpec) return COMPUTED_GGJ_SCRIPT_TEMPLATE elseif case_spec.name == "efit_fixedbdy_separatrix" return COMPUTED_SEPARATRIX_SCRIPT_TEMPLATE + elseif case_spec.name == "islands_l0_structural" + return COMPUTED_ISLANDS_SCRIPT_TEMPLATE end error("No computed-script template registered for case '$(case_spec.name)'") end @@ -153,11 +189,11 @@ import GeneralizedPerturbedEquilibrium), reads the resulting tempfile h5 with so callers can handle store_failed_run uniformly. """ function _execute_computed(case_spec::CaseSpec, project_root::String; - verbose::Bool, no_instantiate::Bool, - stderr_buf::IO) + verbose::Bool, no_instantiate::Bool, + stderr_buf::IO) instantiate_line = no_instantiate ? "" : "Pkg.instantiate()" script_content = replace(_computed_script_template(case_spec), - "%INSTANTIATE%" => instantiate_line) + "%INSTANTIATE%" => instantiate_line) tmpscript = tempname() * ".jl" h5path = tempname() * ".h5" timingfile = tempname() * ".timing" @@ -166,8 +202,8 @@ function _execute_computed(case_spec::CaseSpec, project_root::String; if verbose run(pipeline(`julia --project=$project_root $tmpscript $h5path $timingfile`)) else - run(pipeline(`julia --project=$project_root $tmpscript $h5path $timingfile`, - stdout=devnull, stderr=stderr_buf)) + run(pipeline(`julia --project=$project_root $tmpscript $h5path $timingfile`; + stdout=devnull, stderr=stderr_buf)) end runtime_s = _read_timing_file(timingfile) if !isfile(h5path) @@ -186,18 +222,18 @@ end Run a kind="computed" case against the working tree. """ function run_computed_local(db::SQLite.DB, case_spec::CaseSpec, repo_root::String; - verbose::Bool=false, no_instantiate::Bool=false) + verbose::Bool=false, no_instantiate::Bool=false) delete_cached(db, LOCAL_REF, case_spec.name) date = Dates.format(Dates.now(), "yyyy-mm-ddTHH:MM:SS") @info "Running: $(case_spec.name) @ local (working tree, computed)" stderr_buf = IOBuffer() try extracted, runtime_s = _execute_computed(case_spec, repo_root; - verbose=verbose, - no_instantiate=no_instantiate, - stderr_buf=stderr_buf) + verbose=verbose, + no_instantiate=no_instantiate, + stderr_buf=stderr_buf) store_run(db, LOCAL_REF, "local", date, "working tree", case_spec.name, - runtime_s, extracted) + runtime_s, extracted) @info " Completed in $(round(runtime_s, digits=3))s — $(length(extracted)) quantities extracted" catch e err_msg = if e isa ProcessFailedException @@ -212,7 +248,7 @@ function run_computed_local(db::SQLite.DB, case_spec::CaseSpec, repo_root::Strin err_msg_short = length(err_msg) > 2000 ? "..." * last(err_msg, 2000) : err_msg @warn "Run failed (local computed): $(first(err_msg_short, 200))" store_failed_run(db, LOCAL_REF, "local", date, "working tree", case_spec.name, - err_msg_short) + err_msg_short) end end @@ -220,9 +256,9 @@ end Run a kind="computed" case at a specific git commit via worktree. """ function run_computed_at_commit(db::SQLite.DB, commit_hash::String, ref_name::String, - case_spec::CaseSpec, repo_root::String; - force::Bool=false, verbose::Bool=false, - no_instantiate::Bool=false) + case_spec::CaseSpec, repo_root::String; + force::Bool=false, verbose::Bool=false, + no_instantiate::Bool=false) if !force && is_cached(db, commit_hash, case_spec.name) info = get_run_info(db, commit_hash, case_spec.name) if info !== nothing @@ -243,11 +279,11 @@ function run_computed_at_commit(db::SQLite.DB, commit_hash::String, ref_name::St try worktree_path = create_worktree(commit_hash, repo_root) extracted, runtime_s = _execute_computed(case_spec, worktree_path; - verbose=verbose, - no_instantiate=no_instantiate, - stderr_buf=stderr_buf) + verbose=verbose, + no_instantiate=no_instantiate, + stderr_buf=stderr_buf) store_run(db, commit_hash, commit_info.short, commit_info.date, - commit_info.msg, case_spec.name, runtime_s, extracted) + commit_info.msg, case_spec.name, runtime_s, extracted) @info " Completed in $(round(runtime_s, digits=3))s — $(length(extracted)) quantities extracted" catch e err_msg = if e isa ProcessFailedException @@ -262,7 +298,7 @@ function run_computed_at_commit(db::SQLite.DB, commit_hash::String, ref_name::St err_msg_short = length(err_msg) > 2000 ? "..." * last(err_msg, 2000) : err_msg @warn "Run failed (computed) for $(commit_info.short): $(first(err_msg_short, 200))" store_failed_run(db, commit_hash, commit_info.short, commit_info.date, - commit_info.msg, case_spec.name, err_msg_short) + commit_info.msg, case_spec.name, err_msg_short) finally if worktree_path !== nothing remove_worktree(worktree_path, repo_root) @@ -275,8 +311,8 @@ Run GPEC in the current working tree (uncommitted changes included). Always re-runs (local results are never cached since the working tree is mutable). """ function run_local(db::SQLite.DB, case_spec::CaseSpec, repo_root::String; - force::Bool=false, verbose::Bool=false, - no_instantiate::Bool=false) + force::Bool=false, verbose::Bool=false, + no_instantiate::Bool=false) # Always delete previous local results and re-run delete_cached(db, LOCAL_REF, case_spec.name) @@ -287,7 +323,7 @@ function run_local(db::SQLite.DB, case_spec::CaseSpec, repo_root::String; if !isdir(example_path) @warn "Example directory not found: $(case_spec.example_dir)" store_failed_run(db, LOCAL_REF, "local", date, "working tree", case_spec.name, - "Example directory not found: $(case_spec.example_dir)") + "Example directory not found: $(case_spec.example_dir)") return end @@ -309,8 +345,8 @@ function run_local(db::SQLite.DB, case_spec::CaseSpec, repo_root::String; if verbose run(pipeline(`julia --project=$repo_root $tmpscript $rundir $timingfile`)) else - run(pipeline(`julia --project=$repo_root $tmpscript $rundir $timingfile`, - stdout=devnull, stderr=stderr_buf)) + run(pipeline(`julia --project=$repo_root $tmpscript $rundir $timingfile`; + stdout=devnull, stderr=stderr_buf)) end runtime_s = _read_timing_file(timingfile) @@ -318,13 +354,13 @@ function run_local(db::SQLite.DB, case_spec::CaseSpec, repo_root::String; if !isfile(h5path) @warn "gpec.h5 not produced" store_failed_run(db, LOCAL_REF, "local", date, "working tree", case_spec.name, - "gpec.h5 not produced after successful run") + "gpec.h5 not produced after successful run") return end extracted = extract_quantities(h5path, case_spec.quantities, runtime_s) store_run(db, LOCAL_REF, "local", date, "working tree", case_spec.name, - runtime_s, extracted) + runtime_s, extracted) @info " Completed in $(round(runtime_s, digits=1))s — $(length(extracted)) quantities extracted" @@ -341,7 +377,7 @@ function run_local(db::SQLite.DB, case_spec::CaseSpec, repo_root::String; err_msg_short = length(err_msg) > 2000 ? "..." * last(err_msg, 2000) : err_msg @warn "Run failed (local): $(first(err_msg_short, 200))" store_failed_run(db, LOCAL_REF, "local", date, "working tree", case_spec.name, - err_msg_short) + err_msg_short) finally if tmpscript !== nothing rm(tmpscript; force=true) @@ -360,9 +396,9 @@ Run GPEC for a specific git commit via worktree. Stores results in the database. Skips if already cached (unless force=true). """ function run_at_commit(db::SQLite.DB, commit_hash::String, ref_name::String, - case_spec::CaseSpec, repo_root::String; - force::Bool=false, verbose::Bool=false, - no_instantiate::Bool=false) + case_spec::CaseSpec, repo_root::String; + force::Bool=false, verbose::Bool=false, + no_instantiate::Bool=false) # Check cache if !force && is_cached(db, commit_hash, case_spec.name) info = get_run_info(db, commit_hash, case_spec.name) @@ -398,8 +434,8 @@ function run_at_commit(db::SQLite.DB, commit_hash::String, ref_name::String, if !isdir(example_path) @warn "Example directory not found at commit $(commit_info.short): $(case_spec.example_dir)" store_failed_run(db, commit_hash, commit_info.short, commit_info.date, - commit_info.msg, case_spec.name, - "Example directory not found: $(case_spec.example_dir)") + commit_info.msg, case_spec.name, + "Example directory not found: $(case_spec.example_dir)") return end @@ -418,8 +454,8 @@ function run_at_commit(db::SQLite.DB, commit_hash::String, ref_name::String, if verbose run(pipeline(`julia --project=$project_root $tmpscript $rundir $timingfile`)) else - run(pipeline(`julia --project=$project_root $tmpscript $rundir $timingfile`, - stdout=devnull, stderr=stderr_buf)) + run(pipeline(`julia --project=$project_root $tmpscript $rundir $timingfile`; + stdout=devnull, stderr=stderr_buf)) end runtime_s = _read_timing_file(timingfile) @@ -428,8 +464,8 @@ function run_at_commit(db::SQLite.DB, commit_hash::String, ref_name::String, if !isfile(h5path) @warn "gpec.h5 not produced at $(commit_info.short)" store_failed_run(db, commit_hash, commit_info.short, commit_info.date, - commit_info.msg, case_spec.name, - "gpec.h5 not produced after successful run") + commit_info.msg, case_spec.name, + "gpec.h5 not produced after successful run") return end @@ -438,7 +474,7 @@ function run_at_commit(db::SQLite.DB, commit_hash::String, ref_name::String, # Store in database store_run(db, commit_hash, commit_info.short, commit_info.date, - commit_info.msg, case_spec.name, runtime_s, extracted) + commit_info.msg, case_spec.name, runtime_s, extracted) @info " Completed in $(round(runtime_s, digits=1))s — $(length(extracted)) quantities extracted" @@ -456,7 +492,7 @@ function run_at_commit(db::SQLite.DB, commit_hash::String, ref_name::String, err_msg_short = length(err_msg) > 2000 ? "..." * last(err_msg, 2000) : err_msg @warn "Run failed for $(commit_info.short): $(first(err_msg_short, 200))" store_failed_run(db, commit_hash, commit_info.short, commit_info.date, - commit_info.msg, case_spec.name, err_msg_short) + commit_info.msg, case_spec.name, err_msg_short) finally # Clean up if tmpscript !== nothing diff --git a/src/GeneralizedPerturbedEquilibrium.jl b/src/GeneralizedPerturbedEquilibrium.jl index e7ecef8a7..26750e78f 100755 --- a/src/GeneralizedPerturbedEquilibrium.jl +++ b/src/GeneralizedPerturbedEquilibrium.jl @@ -48,6 +48,10 @@ include("Analysis/Analysis.jl") import .Analysis as Analysis export Analysis +include("Islands/Islands.jl") +import .Islands as Islands +export Islands + include("Rerun.jl") # Import ForceFreeStates types and functions needed for main diff --git a/src/Islands/CLAUDE.md b/src/Islands/CLAUDE.md new file mode 100644 index 000000000..61ce2cc75 --- /dev/null +++ b/src/Islands/CLAUDE.md @@ -0,0 +1,156 @@ +# CLAUDE.md — Islands module conventions + +Islands is a steady-state, multi-species drift-kinetic solver for the resonant +island/layer region in tokamaks, generalizing the Modified Rutherford Equation +the way SLAYER generalized linear layer theory. It is a GPEC submodule +(`src/Islands/`, `module Islands`), not a standalone package. + +**Where things live** (in-repo layout, reconciled from the original standalone +design bundle): + +- Module source: `src/Islands/` (this file governs work here). +- Design docs (normative, aspirational): `docs/src/islands/design/00-roadmap.md` + … `08-reference-library.md`. Throughout the docs and code, the shorthand + `docs/NN §X` (e.g. `docs/03 §2`) means design doc `NN` — read it as + `docs/src/islands/design/NN-*.md`. +- Physics Book (as-implemented equations, rendered by Documenter): + `docs/src/islands/` chapters. Derivations: `docs/src/islands/derivations/`. + Papers: `docs/src/islands/papers/`. State dashboard + gallery: + `docs/src/islands/state/`. Notes: `docs/src/islands/notes/`. +- Session memory / blocker queue: `docs/src/islands/LOG.md`, + `docs/src/islands/QUESTIONS.md`. +- Tests: `test/runtests_islands_.jl`, included from `test/runtests.jl`. +- Benchmarks + figures: `benchmarks/islands/`, `benchmarks/islands/figures/`. +- Regression cases: integrated with the rest under `regression-harness/`. + +Read `docs/src/islands/index.md`, then `docs/src/islands/design/00-roadmap.md`. +The design docs are **normative**: code must not contradict them; when physics +or design must change, amend the doc in the same PR (doc-first workflow) and +append to the Decision Log in `docs/src/islands/design/00-roadmap.md`. + +## The [VERIFY] policy (most important rule) + +Equations and numeric targets transcribed from literature carry `[VERIFY: source]` +tags in docs and in code comments. Rules: + +1. Never implement physics against a [VERIFY]-tagged expression as if it were + confirmed. Implement the *structure*, parameterize the uncertain coefficient, + and add a failing/skipped benchmark referencing the tag. +2. Never silently "fix" a coefficient to make a benchmark pass. Flag the + discrepancy for human review with the source citation. +3. Only a human clears a [VERIFY] tag, after checking the source paper. Record + the clearance (paper, equation number) in the doc. +4. If you (Claude) derive an expression yourself, mark it `[DERIVED: date]` with + the derivation in `docs/src/islands/derivations/` — never present a + derivation as a literature transcription or vice versa. +5. `[CHECKED: source, Eq./p.]` is the intermediate state: the expression was + transcribed from a PDF in the in-repo reference library (docs/08) with an + exact equation/page cite and machine-checked against it, but has not yet + received the human sign-off of rule 3. [CHECKED] expressions may guide + design but are implemented under the same rule-1 discipline as [VERIFY]. +6. **The policy is not paranoia — it is calibrated to this literature.** Leigh + 2023 §2.6 documents concrete coefficient/sign errors in the *published* + Imada 2019 equation set (docs/01 header). Published equations in this + lineage are re-derived before implementation, full stop. + +## Physics conventions (pinned; changing any of these is a Decision Log entry) + +- Island width `w` = **half**-width; Ω = 2x²/w² − cos ξ; O-point Ω = −1, + separatrix Ω = +1. (Matches every source in the York lineage — [CHECKED: + I19 Eq. 7; Diss19 Eq. 2.7; L23 Eq. 2.1.8]. Thresholds are always reported + as half-widths with the gyroradius unit stated; docs/05 reporting rule 5.) +- Primary field representation: A_∥(x, ξ) on the grid. Island coordinates Ω are + diagnostics only (Decision D1). Any PR introducing Ω as a solve coordinate + outside the RDK cross-check mode is rejected. +- All frame/frequency conversions live in `src/Islands/frames/`. No other part + of the module may contain an ω sign convention. The polarization-current sign + disputes in the literature are largely frame disputes; we will not reproduce + them internally. +- Normalizations per `docs/01-physics-level0.md §5`. SI only at I/O boundaries. +- Species lists are first-class everywhere; no function may assume a single ion + species (Decision D3). Trace species go through the linear post-pass + (`docs/02 §1.2`), with trace-criteria checks that warn, never silently degrade. + +## Code conventions + +- Julia. Style: 4-space indent, explicit imports, no `using X` wildcard in + `src/Islands/`. Public API documented with docstrings; physics functions cite + the doc section they implement (`# docs/01 §2.1`). +- Operator stack rules (`docs/03 §2`): terms are independent (no term inspects + other terms), generic over `eltype` (AD compatibility is tested), and + allocation-free in `apply!` hot paths (allocation regression test in CI). +- No regime-specific branches in physics code. If you find yourself writing + `if collisional ... else banana ...` inside an operator, stop: that is the + disease this project treats. Regime logic is allowed only in + `src/Islands/verify/` (choosing analytic comparison targets) and in + preconditioners (approximations there are legitimate). +- Threading: per-thread preallocated caches; no shared mutable state in kernels. + +## Testing gates + +- `test/runtests_islands_*.jl` (included from `test/runtests.jl`): fast unit + + symmetry + conservation + MMS-at-low-resolution + AD checks. Must pass on + every commit. +- `benchmarks/islands/`: the docs/05 ladder. CI runs the fast subset; the full + ladder runs before any tag/paper. A PR that changes physics must state which + ladder IDs it affects and show them green (or explicitly re-baselined with + justification). +- New operators/terms ship with: MMS test, at least one analytic-limit hook in + `src/Islands/verify/`, and an AD-compatibility test. + +## Merge conflict policy + +Synthesize both branch sides rather than choosing one; preserve current-branch +naming conventions; flag ambiguous cases for human review rather than guessing. +Conflicts touching the design docs or physics conventions always go to human +review. + +## Documentation policy (docs/07) + +Documentation is a build artifact; stale docs are broken builds. + +- Any PR changing physics behavior updates the corresponding Physics Book + section (`docs/src/islands/`) **in the same PR**, and regenerates affected + verification figures if outputs change. Otherwise the PR description carries + `docs-not-needed:` with justification. +- Bidirectional anchors are mandatory: every operator cites its Physics Book + section (`# physics: #`); every Physics Book equation block + names its implementing symbol. The anchor-sync CI check must stay green. +- The Physics Book documents equations **as implemented** (code normalization, + code sign conventions). Aspirational physics belongs in the design docs + (`docs/src/islands/design/`, docs 00–02). +- Never hand-edit `docs/src/islands/state/STATE.md` or gallery pages — they + regenerate from benchmark artifacts. +- Paper figures are generated only by pinned scripts in + `benchmarks/islands/figures/` reading archived benchmark data; a figure that + can't be regenerated by `make figures` is a release-blocking bug. +- Each level begins with the paper OUTLINE.md (claims → figures → ladder IDs); + implementing agents treat it as the figure contract. + +## Autonomous-session protocol (docs/06) + +- Definition of done for any milestone run = its docs/05 ladder IDs green with + convergence artifacts + CI passing + PR opened. Never weaken a tolerance or + re-baseline a target to reach done — that is a blocker. +- When blocked on anything CLAUDE.md forbids guessing ([VERIFY] clearances, + coefficients, signs, conventions, doc contradictions): append an entry to + `docs/src/islands/QUESTIONS.md` (context, question, options, recommendation, + gated work) and continue with the next unblocked task. Never stall waiting for + a human; never resolve silently. +- Read `docs/src/islands/LOG.md` and `docs/src/islands/QUESTIONS.md` at session + start; append a LOG.md entry (what moved / blocked / next) before session end. + End every session with the branch pushed. +- This file governs work inside `src/Islands/` (code) and `docs/src/islands/` + (docs); the repo-root CLAUDE.md governs GPEC-wide conventions. On conflict the + repo-root file wins; flag genuine contradictions via QUESTIONS.md. +- GPD (`/gpd:*` commands, if installed) is for derivation/verification/ + literature tasks feeding `docs/src/islands/derivations/` and [VERIFY] + proposals — not for implementation work, which follows this file and + docs/03–05. + +## What to do when uncertain + +Prefer: (a) parameterize and flag, (b) add a skipped test documenting the +uncertainty, (c) ask — in that order. Do not guess coefficients, sign +conventions, or normalizations; in this project those are the entire failure +mode of the field we're trying to fix. diff --git a/src/Islands/Islands.jl b/src/Islands/Islands.jl new file mode 100644 index 000000000..993ce8a07 --- /dev/null +++ b/src/Islands/Islands.jl @@ -0,0 +1,48 @@ +""" + Islands + +Steady-state, multi-species drift-kinetic solver for the resonant island/layer +region in tokamaks. Generalizes the Modified Rutherford Equation the way SLAYER +generalized linear layer theory: it returns the growth moment `Δ_cos(w, ω; p)` +and torque moment `Δ_sin(w, ω; p)` for arbitrary parameters, and in its +small-amplitude limit reduces to the linear layer response. + +Design docs live in `docs/src/islands/` (module conventions in +`src/Islands/CLAUDE.md`, design docs in `docs/src/islands/design/`). Physics +equations transcribed from the York drift-kinetic lineage carry `[VERIFY]` / +`[CHECKED]` tags until human-cleared — see the module CLAUDE.md. + +Status: skeleton. The operator stack, phase-space grids, field/moment assembly, +solvers, and verification harness (design doc `03-architecture.md §1`) land as +milestone M1 proceeds. +""" +module Islands + +# Submodule layout follows docs/src/islands/design/03-architecture.md §1. +# M1 landed the discretization + operator stack + MMS/AD harness; M2 lands the +# L0 solve machinery (species, frames, solve, fields, moments) as gated +# structure. Remaining design dirs (geometry/, closures/, io/) arrive with the +# milestones that need them (L2/L4/M2-io). +include("phasespace/PhaseSpace.jl") # grids (x, ξ, λ→y, E, σ), layer-clustered maps +include("species/Species.jl") # Species, backgrounds, roles (D3) +include("frames/Frames.jl") # THE frequency/frame conversion module (gated forms) +include("operators/Operators.jl") # the AbstractTerm stack + residual assembly +include("fields/Fields.jl") # quasineutrality closure structure, h(Ω)/Q(Ω) +include("moments/Moments.jl") # J̄_∥, Δ_cos/Δ_sin projections, ⟨·⟩_Ω diagnostics +include("coefficients/Coefficients.jl") # human-cleared L0 physics coefficient builders (M2b, D7) +include("solvers/Solvers.jl") # Newton–Krylov, preconditioner, continuation +include("configure/Configure.jl") # L0 named-config assembly (cleared coeffs wired, gated pieces supplied) +include("verify/Verify.jl") # MMS + AD-vs-FD JVP harness, y_c monitor + +import .PhaseSpace +import .SpeciesLists +import .Frames +import .Operators +import .Fields +import .Moments +import .Coefficients +import .Solvers +import .Configure +import .Verify + +end # module Islands diff --git a/src/Islands/coefficients/Coefficients.jl b/src/Islands/coefficients/Coefficients.jl new file mode 100644 index 000000000..a2a361c8f --- /dev/null +++ b/src/Islands/coefficients/Coefficients.jl @@ -0,0 +1,250 @@ +""" + Coefficients + +Home for the **human-cleared** Level-0 physics coefficient builders (the M2b +derivation-lane fill-ins). Every function here computes a coefficient whose form +was independently re-derived (Decision D7) and signed off by a human, with the +clearance recorded in `docs/01` and the derivation in +`docs/src/islands/derivations/`. Uncleared coefficients stay gated in their home +modules (`Frames`, `Fields`, `Operators`, `Moments`); nothing is promoted here +without that paper trail. + +Cleared so far: + + - [`magnetic_drift_frequency`](@ref) — the orbit-averaged drift frequency + ``\\hat\\omega_D`` and the `:original`/`:improved` ``\\hat L_B^{-1}`` toggle + (sign-off 2026-07-11; derivation `omega-D-drift-frequency.md`, docs/01 §2.1). + - [`pitch_diffusivity`](@ref), [`deflection_frequency`](@ref) — the Lorentz + collision operator's self-adjoint diffusivity ``P(\\lambda)=\\lambda\\sqrt{1-\\lambda B}`` + and the velocity dependence ``\\nu_{jj}(\\hat v)=\\tilde\\nu[\\phi-G]/\\hat v^3`` + (sign-off 2026-07-11; derivation `collision-operator.md`, docs/01 §2.3). The + ``\\langle\\hat\\nu_{ii}\\rangle_u`` momentum-restoring constant is a deferred + sub-item and stays gated. +""" +module Coefficients + +import QuadGK + +export magnetic_drift_frequency, orbit_average_drift_brackets +export pitch_diffusivity, deflection_frequency +export h_amplitude, passing_fraction +export quasineutrality_coefficient +export delta_moment_prefactors + +# Model circular equilibrium field modulation (docs/01 §1, I19 p. 6): +# b(θ) = B/B_max = (1 − ε cos θ)/(1 + ε); b ∈ [b_min, 1], b_min = b(0), b(π)=1. +@inline _b(θ, ε) = (1 - ε * cos(θ)) / (1 + ε) + +""" + orbit_average_drift_brackets(; y, epsilon, rtol=1e-8) + +The two poloidal-orbit integrals that appear in ``\\hat\\omega_D`` (docs/01 §2.1; +derivation `omega-D-drift-frequency.md` §5), in I19's ``\\langle\\cdot\\rangle_\\theta = \\tfrac{1}{2\\pi}\\oint`` form: + +```math +A(y) = \\Big\\langle \\frac{\\sqrt{1-yb}}{b} \\Big\\rangle_\\theta, +\\qquad +G(y) = \\Big\\langle \\frac{2-yb}{b\\sqrt{1-yb}} \\Big\\rangle_\\theta , +``` + +with `b(θ) = (1−ε cos θ)/(1+ε)`. Returns `(A, G)`. + +**Passing** particles (`y < y_c = 1`): the full poloidal circuit, ``\\tfrac1{2\\pi} \\int_0^{2\\pi}``. **Trapped** particles (`1 < y < (1+ε)/(1−ε)`): the bounce +integral ``\\tfrac1{2\\pi}\\sum_\\sigma\\int_{-\\theta_b}^{\\theta_b}`` between the +turning points ``\\cos\\theta_b = [1-(1+ε)/y]/ε``. `G`'s integrand has an +integrable ``1/\\sqrt{1-yb}`` singularity at the turning points, handled by the +adaptive quadrature. The signed-off derivation covers the passing drift-island +mechanism; the trapped brackets follow I19's stated ``\\langle\\cdot\\rangle_\\theta``. +""" +function orbit_average_drift_brackets(; y::Real, epsilon::Real, rtol::Real=1e-8) + ε = float(epsilon) + 0 < ε < 1 || throw(ArgumentError("epsilon must be in (0, 1) (got $epsilon)")) + # y = 0 (deeply-passing endpoint) is physical: A=⟨1/b⟩, G=⟨2/b⟩ are finite; the + # guard admits the closed pitch domain [0, 1/b_min) the grid samples. + y >= 0 || throw(ArgumentError("y must be nonnegative")) + y_forbidden = (1 + ε) / (1 - ε) # 1/b_min: no particle beyond this + y < y_forbidden || throw(ArgumentError("y = $y exceeds 1/b_min = $y_forbidden (forbidden region)")) + + fA(θ) = sqrt(max(1 - y * _b(θ, ε), 0.0)) / _b(θ, ε) + fG(θ) = (2 - y * _b(θ, ε)) / (_b(θ, ε) * sqrt(max(1 - y * _b(θ, ε), 0.0))) + + if y < 1 # passing: full circuit + A, _ = QuadGK.quadgk(fA, 0.0, 2π; rtol=rtol) + G, _ = QuadGK.quadgk(fG, 0.0, 2π; rtol=rtol) + return (A / (2π), G / (2π)) + else # trapped: bounce integral between turning points + cosθb = (1 - (1 + ε) / y) / ε + θb = acos(clamp(cosθb, -1.0, 1.0)) + # (1/2π) Σ_σ ∫_{-θb}^{θb} = (1/π) ∫_{-θb}^{θb} for σ-even integrands; split at 0 + A, _ = QuadGK.quadgk(fA, -θb, 0.0, θb; rtol=rtol) + G, _ = QuadGK.quadgk(fG, -θb, 0.0, θb; rtol=rtol) + return (A / π, G / π) + end +end + +""" + magnetic_drift_frequency(; y, v_hat, sigma, epsilon, inv_Lq, inv_LB, variant=:original, rtol=1e-8) + +The cleared orbit-averaged magnetic drift frequency (docs/01 §2.1; derivation +`omega-D-drift-frequency.md`, sign-off 2026-07-11): + +```math +\\hat\\omega_D = \\frac{\\sigma\\hat v}{1+\\varepsilon} + \\Big[\\; \\hat L_q^{-1}\\,A(y) \\;-\\; \\tfrac12\\,\\hat L_B^{-1}\\,G(y) \\;\\Big], +``` + +with `A(y)`, `G(y)` the orbit brackets of +[`orbit_average_drift_brackets`](@ref). The **drift-model toggle** is `variant`: + + - `:original` — finite `inv_LB` (the I19/DK-NTM ∇B term is retained); + - `:improved` — `inv_LB` is forced to `0` (the D21/RDK-NTM proxy: ∂B/∂ψ ∝ cos θ + orbit-averages to O(ε); derivation §6). This is the ~×6 threshold-width + toggle (measured as the internal `:original`/`:improved` ratio — the D9 T2 + gate; the absolute 8.73→1.46 ρ_bi pair is T4/audit-gated). + +`inv_Lq = L̂_q⁻¹`, `inv_LB = L̂_B⁻¹`, `epsilon = ε`, `v_hat = v/v_thi`, +`sigma = ±1`, `y = λ B_max`. +""" +function magnetic_drift_frequency(; y::Real, v_hat::Real, sigma::Real, epsilon::Real, + inv_Lq::Real, inv_LB::Real, variant::Symbol=:original, rtol::Real=1e-8) + variant in (:original, :improved) || throw(ArgumentError("variant must be :original or :improved (got $variant)")) + LB = variant === :improved ? zero(float(inv_LB)) : float(inv_LB) + A, G = orbit_average_drift_brackets(; y=y, epsilon=epsilon, rtol=rtol) + return (sigma * v_hat / (1 + epsilon)) * (inv_Lq * A - 0.5 * LB * G) +end + +# --------------------------------------------------------------------------- +# Collision operator (cleared 2026-07-11; derivation collision-operator.md) +# --------------------------------------------------------------------------- +""" + pitch_diffusivity(λ, B) + +The Lorentz pitch-angle operator's self-adjoint **diffusivity** +``P(\\lambda) = \\lambda\\sqrt{1-\\lambda B} \\ge 0`` (docs/01 §2.3; derivation +`collision-operator.md` §2), for the operator ``w^{-1}\\partial_\\lambda(P \\partial_\\lambda)`` with measure ``w = B/\\sqrt{1-\\lambda B}``. `P` vanishes at +`λ=0` and the trapped–passing edge `λ=1/B` (zero-flux endpoints), so it feeds +`conservative_pitch_operator` directly and preserves the A4 conservation gate. +""" +function pitch_diffusivity(λ::Real, B::Real) + B > 0 || throw(ArgumentError("B must be positive")) + (0 <= λ <= 1 / B) || throw(ArgumentError("λ must be in [0, 1/B]")) + return λ * sqrt(max(1 - λ * B, 0.0)) +end + +# error function φ(X) = (2/√π)∫₀ˣ e^{−t²}dt and its derivative φ'(X) = (2/√π)e^{−X²} +_phi(X; rtol=1e-10) = (2 / sqrt(π)) * first(QuadGK.quadgk(t -> exp(-t^2), 0.0, X; rtol=rtol)) +_dphi(X) = (2 / sqrt(π)) * exp(-X^2) +# Chandrasekhar function G(X) = [φ − Xφ']/(2X²) +_chandrasekhar_G(X; rtol=1e-10) = (_phi(X; rtol=rtol) - X * _dphi(X)) / (2 * X^2) + +""" + deflection_frequency(v_hat; nu_tilde=1.0, model=:chandrasekhar, rtol=1e-10) + +The velocity dependence of the deflection frequency (docs/01 §2.3; derivation +`collision-operator.md` §3): + +```math +\\nu_{jj}(\\hat v) = \\tilde\\nu\\,\\frac{\\phi(\\hat v) - G(\\hat v)}{\\hat v^3} +\\quad(\\texttt{:chandrasekhar}), +\\qquad +\\nu_{jj}(\\hat v) = \\tilde\\nu\\,\\hat v^{-3}\\quad(\\texttt{:vcubed}), +``` + +with ``\\phi`` the error function and ``G`` the Chandrasekhar function. The +`:chandrasekhar` form ``\\to \\tfrac{4\\tilde\\nu}{3\\sqrt\\pi}\\hat v^{-2}`` at low +speed (the derived linear ``\\phi-G`` limit) and ``\\to \\tilde\\nu\\hat v^{-3}`` at +high speed; the `:vcubed` form is the reduced Diss19/D21 model. The energy- +dependence sub-toggle (ladder E3). +""" +function deflection_frequency(v_hat::Real; nu_tilde::Real=1.0, model::Symbol=:chandrasekhar, rtol::Real=1e-10) + v_hat > 0 || throw(ArgumentError("v_hat must be positive")) + if model === :vcubed + return nu_tilde / v_hat^3 + elseif model === :chandrasekhar + return nu_tilde * (_phi(v_hat; rtol=rtol) - _chandrasekhar_G(v_hat; rtol=rtol)) / v_hat^3 + else + throw(ArgumentError("model must be :chandrasekhar or :vcubed (got $model)")) + end +end + +# --------------------------------------------------------------------------- +# Flattened-electron closure (cleared 2026-07-11; derivation electron-closure.md) +# --------------------------------------------------------------------------- +""" + h_amplitude(w_psi) + +The flattened-electron profile amplitude ``C = w_\\psi/(2\\sqrt2)`` (docs/01 §2.4; +derivation `electron-closure.md` §3), i.e. the prefactor of +``h(\\Omega)=\\Theta(\\Omega-1)\\,C\\int_1^\\Omega d\\Omega'/Q(\\Omega')``. Derived +from the flattening constraint ``\\langle\\partial^2 h/\\partial x^2\\rangle_\\Omega=0`` +(``\\Rightarrow h'=C/Q``) plus far-field matching ``h\\to x``. Feeds +`Fields.h_profile`'s `prefactor` (and `Fields.ElectronClosure.h_prefactor`); the +closure constants ``k`` and ``f_p`` remain gated. +""" +h_amplitude(w_psi::Real) = w_psi / (2 * sqrt(2)) + +""" + passing_fraction(epsilon) + +The flattened-electron closure **passing (circulating) particle fraction** +``f_p = 1 - f_t \\simeq 1 - 1.4624\\,\\sqrt\\varepsilon`` (docs/01 §2.4; derivation +`passing-fraction.md`, human sign-off 2026-07-11). The leading coefficient +`1.4624` is the ``\\varepsilon\\to0`` limit of the effective trapped-fraction +integral (Lin-Liu–Miller / Wesson) derived and numerically confirmed in the +derivation; it matches the sources' quoted ``1.46`` (I19 Eq. 22) to three +significant figures. Clears `Fields.ElectronClosure.f_p`; the Hirshman–Sigmar +`k` constant of the same closure remains gated (QUESTIONS Q3/Q5). +""" +function passing_fraction(epsilon::Real) + epsilon >= 0 || throw(ArgumentError("epsilon must be nonnegative")) + return 1 - 1.4624 * sqrt(epsilon) +end + +# --------------------------------------------------------------------------- +# Quasineutrality closure (cleared 2026-07-11; derivation quasineutrality-closure.md) +# --------------------------------------------------------------------------- +""" + quasineutrality_coefficient(tau) + +The Level-0 quasineutrality closure coefficient ``\\tau/(\\tau+1)`` (docs/01 §3; +derivation `quasineutrality-closure.md`), where ``\\tau=T_e/T_i``: + +```math +\\hat\\Phi = \\frac{\\tau}{\\tau+1}\\Big[\\frac{\\delta\\bar n_i}{n_0} + + \\hat L_{n0}^{-1}\\,(x-\\hat h)\\Big]. +``` + +The ``\\tau/(\\tau+1)`` factor is the sum of the ion and electron adiabatic +shielding responses (``\\to 1/2`` at ``\\tau=1``, the sources' ``T_e=T_i``). The +raw ion-density moment ``\\delta\\bar n_i=M[g_i]`` (`velocity_moment!`) enters +directly — no ``\\delta n_i`` normalization convention — so this coefficient plus +the ``\\hat L_{n0}^{-1}(x-\\hat h)`` drive populate `Operators.Quasineutrality`. +""" +function quasineutrality_coefficient(tau::Real) + tau > 0 || throw(ArgumentError("tau = T_e/T_i must be positive")) + return tau / (tau + 1) +end + +# --------------------------------------------------------------------------- +# Δ-moment prefactors (cleared 2026-07-11; derivation delta-moment-prefactors.md) +# --------------------------------------------------------------------------- +""" + delta_moment_prefactors(; mu0_R, w_psi, dq_dpsi, q_s) + +The ``\\Delta_{\\cos}``/``\\Delta_{\\sin}`` output-moment prefactors (docs/01 §4; +derivation `delta-moment-prefactors.md`): returns +`(cos=-\\mu_0 R/(2\\tilde\\psi), sin=+\\mu_0 R/(2\\tilde\\psi))` for +`Moments.delta_moments`, with ``\\tilde\\psi`` the cleared island flux +amplitude `Moments.island_flux_amplitude`. ``\\Delta_{\\cos}=\\Delta_{\\rm neo}`` +matches Diss19 Eq. 4.12 (stationarity ``\\Delta'+\\Delta_{\\rm neo}=0``); the sin +normalization is the symmetric `[DERIVED]` pin so that +``\\Delta_{\\cos}+i\\Delta_{\\sin}`` maps onto the linear-layer ``\\Delta(Q)``. +""" +function delta_moment_prefactors(; mu0_R::Real, w_psi::Real, dq_dpsi::Real, q_s::Real) + ψ̃ = (w_psi^2 / 4) * (dq_dpsi / q_s) # = island_flux_amplitude (docs/01 §1) + ψ̃ != 0 || throw(ArgumentError("ψ̃ must be nonzero (check w_psi, dq_dpsi)")) + pref = mu0_R / (2 * ψ̃) + return (cos=-pref, sin=+pref) +end + +end # module Coefficients diff --git a/src/Islands/configure/Configure.jl b/src/Islands/configure/Configure.jl new file mode 100644 index 000000000..e11efaa31 --- /dev/null +++ b/src/Islands/configure/Configure.jl @@ -0,0 +1,464 @@ +""" + Configure + +Level-0 **named-configuration assembly** (design `03 §2`): turns a physics +parameter set + a species list into a runnable `Operators.IslandStack`, its +far-field boundary conditions, and the `Δ`-moment prefactors — the object the +solver consumes. + +**What this module does and does not do (the honest state, M2c).** The M2b +derivation lane cleared six Level-0 coefficient families. Only some of those map +onto operator-stack coefficients *cleanly*; the assembly wires exactly those and +**gates the rest**: + + - **Cleared, wired here** (populated by `Coefficients.*`, never literals): + the orbit-averaged magnetic drift `c_D` ([`drift_coefficient_table`], from + `Coefficients.magnetic_drift_frequency`); the **island-streaming** + coefficients `a_xi`/`a_x` ([`streaming_coefficients`], the `{Ω, ·}` + flux-surface advection, `01 §2`); the pitch-collision + *shapes* (`Coefficients.pitch_diffusivity` → + `Operators.conservative_pitch_operator`, and + `Coefficients.deflection_frequency` → the energy-dependent collision + coefficient); the **quasineutrality field term** — `α = (τ+1)/τ` from + `Coefficients.quasineutrality_coefficient` and the `L̂_{n0}⁻¹(x − ĥ)` drive + ([`quasineutrality_source`], from the cleared `ĥ` profile), closing the + Level-0 potential (`01 §3`); the **gradient drive** — zero interior source + plus the neoclassical far field `g_far = x L̂_{n0}⁻¹[1+(E−3/2)η_i]` + ([`gradient_far_field`], I19 Formulation A, `01 §2`); and the `Δ`-moment + prefactors (`Coefficients.delta_moment_prefactors`). + - **Not yet a cleared coefficient family → supplied, gated** (QUESTIONS Q5): + the `E×B` coupling `c_E`, the orbit-averaged pitch measure/field `B_profile`, + and the collision magnitude `nu_tilde` (carries the deferred `⟨ν̂_ii⟩_u`). + These enter through [`GatedLevel0Inputs`]; nothing here assigns a physics + value to them. + +The gated pieces are the subject of a future derivation lane (QUESTIONS Q5): the +assembly *surfaces* exactly what Level-0 physics is still uncleared rather than +papering over it with guesses. [`level0_placeholders`](@ref) supplies documented +**non-physics** values for the gated inputs so the assembled residual/solve can +be exercised *structurally* (that the stack is well-formed and solvable) — never +for a physics result. +""" +module Configure + +using LinearAlgebra +import ..PhaseSpace: IslandGrid, nnodes, MappedFDGrid +import ..Operators: IslandStack, FarFieldConditions, + ParallelStreaming, MagneticDrift, ExBDrift, PitchAngleDiffusion, + GradientDrive, Quasineutrality, conservative_pitch_operator +import ..Coefficients: + magnetic_drift_frequency, orbit_average_drift_brackets, + pitch_diffusivity, deflection_frequency, delta_moment_prefactors, + h_amplitude, quasineutrality_coefficient +import ..Fields: h_profile +import ..SpeciesLists: Species, Maxwellian, Bulk, validate_species + +export Level0Physics, GatedLevel0Inputs, configure_level0, level0_placeholders +export drift_coefficient_table, collision_coefficient, pitch_diffusivity_profile +export quasineutrality_source, streaming_coefficients, gradient_far_field + +# --------------------------------------------------------------------------- +# Physics parameter carrier (the cleared inputs) +# --------------------------------------------------------------------------- +""" + Level0Physics(; epsilon, inv_Lq, inv_LB, q_s, dq_dpsi, w_psi, mu0_R, + tau=1.0, variant=:original, collision_model=:chandrasekhar) + +The Level-0 physics parameters that feed the **cleared** coefficient builders +(`01 §1`–`§4`). These are ordinary scenario inputs, not gated coefficients — the +gated (uncleared) pieces live in [`GatedLevel0Inputs`]. + +## Fields + + - `epsilon` — inverse aspect ratio `ε = r_s/R₀`. + - `inv_Lq` — `L̂_q⁻¹ = (ψ_s/q_s) dq/dψ` at `r_s` (drift, `01 §2.1`). + - `inv_LB` — `L̂_B⁻¹` (∇B drift term; forced to `0` when + `variant = :improved`, `01 §2.1`). + - `q_s`, `dq_dpsi` — safety factor and its `ψ`-derivative at `r_s` + (`ψ̃` and `Δ`-prefactor, `01 §1`, `§4`). + - `w_psi` — island **half**-width (the `w` in `Ω = 2x²/w² − cosξ` + and the `ĥ` amplitude `w/2√2`, `01 §1`, `§2.4`). + - `mu0_R` — `μ₀R` geometric factor of the `Δ`-moment prefactor + (`01 §4`). + - `inv_Ln0` — inverse density scale length `L̂_{n0}⁻¹` at `r_s`, the + quasineutrality drive amplitude (`01 §3`). + - `rho_hat_theta_i` — normalized ion poloidal gyroradius `ρ̂_θi` (`~ ŵ` by the + O2 ordering); sets the island-streaming scale (`01 §2`, `parallel-streaming.md`). + - `eta_i` — `η_i = L_n/L_{T_i} = (T_i'/T_i)/(n'/n)`, the + temperature-gradient ratio in the gradient-drive far field (`01 §2`, + `gradient-drive.md`); `0` = flat temperature. + - `tau` — `T_e/T_i` (quasineutrality closure, `01 §3`). + - `variant` — `:original`/`:improved` drift-model toggle (`01 §2.1`). + - `collision_model` — `:chandrasekhar`/`:vcubed` deflection-frequency energy + dependence (`01 §2.3`). +""" +Base.@kwdef struct Level0Physics + epsilon::Float64 + inv_Lq::Float64 + inv_LB::Float64 + q_s::Float64 + dq_dpsi::Float64 + w_psi::Float64 + mu0_R::Float64 + inv_Ln0::Float64 + rho_hat_theta_i::Float64 + eta_i::Float64 = 0.0 + tau::Float64 = 1.0 + variant::Symbol = :original + collision_model::Symbol = :chandrasekhar +end + +# --------------------------------------------------------------------------- +# Gated inputs carrier (the uncleared pieces — supplied, never guessed here) +# --------------------------------------------------------------------------- +""" + GatedLevel0Inputs(; c_E, nu_tilde, B_profile) + +The Level-0 operator coefficients that are **not yet a cleared coefficient +family** and are therefore *supplied* to [`configure_level0`], not derived from +`Coefficients.*` (QUESTIONS Q5). Nothing in this module assigns them a physics +value; a caller either supplies cleared physics (once available) or the +documented non-physics placeholders of [`level0_placeholders`](@ref). + +## Fields + + - `c_E` — `E×B` coupling scalar (`Operators.ExBDrift`; the frame/ + normalization is uncleared, QUESTIONS Q3/Q5). + - `nu_tilde` — collision magnitude scaling the cleared `ν_{jj}(v̂)` shape; + carries the deferred `⟨ν̂_ii⟩_u`/`ν_★` normalization (QUESTIONS Q3). + - `B_profile` — orbit-averaged `|B|/B_max` on the `y`-grid, feeding the + cleared `pitch_diffusivity` shape and the collision measure (the orbit + average is gated, QUESTIONS Q5). +""" +Base.@kwdef struct GatedLevel0Inputs{S,V} + c_E::S + nu_tilde::S + B_profile::V +end + +# --------------------------------------------------------------------------- +# Cleared coefficient wiring +# --------------------------------------------------------------------------- +""" + drift_coefficient_table(grid, phys) -> Array{Float64,5} + +Build the orbit-averaged magnetic-drift coefficient `c_D[ix, iξ, iy, iE, iσ]` for +`Operators.MagneticDrift` by evaluating the **cleared** +`Coefficients.magnetic_drift_frequency` on the phase-space grid +(`01 §2.1`). `ω̂_D` depends on `(y, E, σ)` only (through `v̂ = √E` and the orbit +brackets `A(y)`, `G(y)`), so the `(y, E, σ)` table is broadcast over `(x, ξ)`. +The orbit brackets are computed once per `y` (they depend only on `y`, `ε`) and +reused across `E`, `σ` — the drift is linear in `σ v̂`. Uses `phys.variant` for +the `:original`/`:improved` `L̂_B⁻¹` toggle. Grid nodes in the forbidden pitch +region `y ≥ 1/b_min = (1+ε)/(1−ε)` carry no particles, so `c_D ≡ 0` there (a +grid may extend past the physical pitch edge; the deeply-trapped limit is +unambiguous). +""" +# Cleared orbit-average with a graceful miss at the near-separatrix y_c layer: +# returns (A, G), or `nothing` if the bounce-average quadrature cannot converge +# (the gated y_c layer, handled by the caller). Only the singular layer misses; +# every well-defined node returns the exact cleared value. +function _try_drift_brackets(y::Real, ε::Real) + try + return orbit_average_drift_brackets(; y=y, epsilon=ε) + catch err + err isa Union{ErrorException,DomainError} || rethrow(err) + return nothing + end +end + +function drift_coefficient_table(grid::IslandGrid, phys::Level0Physics) + nx, nξ, ny, nE, nσ = nnodes(grid) + ε = phys.epsilon + LB = phys.variant === :improved ? 0.0 : phys.inv_LB + y_forbidden = (1 + ε) / (1 - ε) # 1/b_min: no particle beyond (01 §2.1) + cD = Array{Float64}(undef, nx, nξ, ny, nE, nσ) + @inbounds for iy in 1:ny + y = grid.y.nodes[iy] + if y >= y_forbidden # forbidden region carries no particles ⇒ ω̂_D ≡ 0 + @views cD[:, :, iy, :, :] .= 0.0 + continue + end + # The trapped-passing boundary y = y_c = 1 is the near-separatrix pitch + # layer: θ_b → π and the bounce average develops the integrable + # 1/√(1−yb) turning-point singularity, so the cleared orbit-average + # quadrature cannot reach tolerance there. That layer's drift is part of + # the gated y_c-matching treatment (04 §3, ladder A8, QUESTIONS Q5); the + # node gets a documented gated placeholder (0), not a guessed value. + brackets = _try_drift_brackets(y, ε) + if brackets === nothing + @views cD[:, :, iy, :, :] .= 0.0 + continue + end + A, G = brackets + bracket = phys.inv_Lq * A - 0.5 * LB * G # the [·] of ω̂_D (01 §2.1) + for iσ in 1:nσ + σ = grid.σ[iσ] + for iE in 1:nE + v̂ = sqrt(grid.E.nodes[iE]) # E = v̂² (Maxwellian energy) + val = (σ * v̂ / (1 + ε)) * bracket + for iξ in 1:nξ, ix in 1:nx + cD[ix, iξ, iy, iE, iσ] = val + end + end + end + end + return cD +end + +""" + pitch_diffusivity_profile(grid, B_profile) -> (P, wmeas) + +Evaluate the **cleared** Lorentz pitch diffusivity +`Coefficients.pitch_diffusivity` `P(λ) = λ√(1−λB)` and the collision +measure `w = B/√(1−λB)` on the `y`-grid (`01 §2.3`), with `λ = y` in the +`B_max = 1` normalization and `B = B_profile[iy]` the (gated) orbit-averaged +field. Returns `(P, wmeas)` for `Operators.conservative_pitch_operator`, which +builds the mimetic operator preserving the A4 conservation gate for any `P ≥ 0`. +The caller must supply a `B_profile` keeping `0 ≤ y·B ≤ 1` on the grid (the +turning-point structure is part of the gated orbit average, QUESTIONS Q5). +""" +function pitch_diffusivity_profile(grid::IslandGrid, B_profile::AbstractVector) + ny = grid.y.n + length(B_profile) == ny || throw(ArgumentError("B_profile must have length ny = $ny")) + P = Vector{Float64}(undef, ny) + wmeas = Vector{Float64}(undef, ny) + @inbounds for iy in 1:ny + λ = grid.y.nodes[iy] + B = B_profile[iy] + arg = 1 - λ * B + (0 <= λ * B <= 1) || throw(ArgumentError("λB = $(λ * B) at iy=$iy outside [0,1]; supply a valid orbit-averaged B_profile")) + P[iy] = pitch_diffusivity(λ, B) # cleared: λ√(1−λB) + # collision measure w = B/√(1−λB); regularize the zero-flux edge (√arg→0) + wmeas[iy] = B / sqrt(max(arg, 1e-12)) + end + return P, wmeas +end + +""" + collision_coefficient(grid, phys, nu_tilde) -> Array{Float64,4} + +Build the energy-dependent collision coefficient `c[ix, iξ, iE, iσ]` for +`Operators.PitchAngleDiffusion` from the **cleared** +`Coefficients.deflection_frequency` `ν_{jj}(v̂)` (`01 §2.3`), scaled by the +gated magnitude `nu_tilde` (QUESTIONS Q3, carries `⟨ν̂_ii⟩_u`/`ν_★`). It is +**`y`-independent by construction** (dimensions `(x, ξ, E, σ)`), as +`PitchAngleDiffusion` requires so the mimetic `K`'s exact conservation is +preserved; the physical velocity dependence lives entirely in the `E`-axis via +`v̂ = √E`. Uses `phys.collision_model` for the `:chandrasekhar`/`:vcubed` toggle. +""" +function collision_coefficient(grid::IslandGrid, phys::Level0Physics, nu_tilde::Real) + nx, nξ, ny, nE, nσ = nnodes(grid) + c = Array{Float64}(undef, nx, nξ, nE, nσ) + @inbounds for iE in 1:nE + v̂ = sqrt(grid.E.nodes[iE]) + ν = deflection_frequency(v̂; nu_tilde=nu_tilde, model=phys.collision_model) + for iσ in 1:nσ, iξ in 1:nξ, ix in 1:nx + c[ix, iξ, iE, iσ] = ν + end + end + return c +end + +""" + streaming_coefficients(grid, phys) -> (a_xi, a_x) + +Build the **cleared** island-streaming coefficients for +`Operators.ParallelStreaming` (`01 §2`; derivation `parallel-streaming.md`, +sign-off 2026-07-11): + +```math +a_\\xi = \\frac{\\hat L_q^{-1}}{\\hat\\rho_{\\theta i}}\\,x\\,\\Theta(y_c-y), +\\qquad +a_x = -\\frac{\\hat L_q^{-1}\\hat w^2}{4\\,\\hat\\rho_{\\theta i}}\\,\\sin\\xi\\,\\Theta(y_c-y), +``` + +with `ŵ = phys.w_psi`, `ρ̂_θi = phys.rho_hat_theta_i`, and `Θ(y_c − y)` the +passing-particle mask (`1` for `y < grid.y_c`, `0` for trapped). Together they +are `(L̂_q⁻¹ ŵ²/4ρ̂_θi)Θ · {Ω, ·}` — advection along the island flux surfaces +(derivation §3). Depends on `(x, ξ, y)` only, broadcast over `(E, σ)`. The +normalization is chosen so the cleared drift `c_D = ω̂_D` is unchanged (§2). +Returns `(a_xi, a_x)`, each shaped like `g`. +""" +function streaming_coefficients(grid::IslandGrid, phys::Level0Physics) + nx, nξ, ny, nE, nσ = nnodes(grid) + w = phys.w_psi + ρ = phys.rho_hat_theta_i + ρ != 0 || throw(ArgumentError("rho_hat_theta_i must be nonzero")) + a_xi = Array{Float64}(undef, nx, nξ, ny, nE, nσ) + a_x = Array{Float64}(undef, nx, nξ, ny, nE, nσ) + @inbounds for iy in 1:ny + Θ = grid.y.nodes[iy] < grid.y_c ? 1.0 : 0.0 # passing-only (01 §2) + for iξ in 1:nξ + sξ = sin(grid.ξ.nodes[iξ]) + for ix in 1:nx + x = grid.x.nodes[ix] + v_xi = (phys.inv_Lq / ρ) * x * Θ # (L̂_q⁻¹/ρ̂_θi) x Θ + v_x = -(phys.inv_Lq * w^2 / (4 * ρ)) * sξ * Θ # −(L̂_q⁻¹ŵ²/4ρ̂_θi) sinξ Θ + for iσ in 1:nσ, iE in 1:nE + a_xi[ix, iξ, iy, iE, iσ] = v_xi + a_x[ix, iξ, iy, iE, iσ] = v_x + end + end + end + end + return a_xi, a_x +end + +""" + gradient_far_field(grid, phys) -> FarFieldConditions + +Build the **cleared** neoclassical far-field boundary state — the Level-0 +gradient drive (`01 §2`; derivation `gradient-drive.md`, sign-off 2026-07-11). +I19's master equation is homogeneous (no interior source); the gradients enter +as the far field `Ḡ₀ → g_drive = p_φ F'_{Mi}`, which in the code normalization is + +```math +g_{\\rm far}(x{=}\\pm L_x, \\xi, y, E, \\sigma) = x\\,\\hat L_{n0}^{-1}\\,[\\,1 + (E-\\tfrac32)\\eta_i\\,] +``` + +\\noindent +— linear in the boundary `x = ±halfwidth`, the temperature correction through +`E = v̂²`, isotropic in `ξ, y, σ` at leading order (the Maxwellian `e^{-E}` is +carried by the energy-grid measure). `L̂_{n0}⁻¹ = phys.inv_Ln0`, +`η_i = phys.eta_i`. At Level 0 `ω_E = 0`, so `Φ̂_far = 0`. Returns the +`Operators.FarFieldConditions` (the companion `Operators.GradientDrive` source is +zero — I19 Formulation A). +""" +function gradient_far_field(grid::IslandGrid, phys::Level0Physics) + nx, nξ, ny, nE, nσ = nnodes(grid) + x_left = grid.x.nodes[1] + x_right = grid.x.nodes[nx] + g_left = Array{Float64}(undef, nξ, ny, nE, nσ) + g_right = Array{Float64}(undef, nξ, ny, nE, nσ) + @inbounds for iσ in 1:nσ, iE in 1:nE, iy in 1:ny, iξ in 1:nξ + temp = 1 + (grid.E.nodes[iE] - 1.5) * phys.eta_i # 1 + (E − 3/2)η_i + g_left[iξ, iy, iE, iσ] = x_left * phys.inv_Ln0 * temp + g_right[iξ, iy, iE, iσ] = x_right * phys.inv_Ln0 * temp + end + return FarFieldConditions(g_left, g_right, zeros(nξ), zeros(nξ)) # Φ̂_far = 0 (ω_E = 0) +end + +""" + quasineutrality_source(grid, phys) -> Matrix{Float64} + +Build the **cleared** flattened-electron drive `S[ix, iξ] = L̂_{n0}⁻¹(x − ĥ(Ω))` +for `Operators.Quasineutrality` (`01 §3`; derivation `quasineutrality-closure.md`, +sign-off 2026-07-11). `Ω = 2x²/w² − cosξ` with `w = phys.w_psi`, and +`ĥ(Ω) = Coefficients.h_amplitude(w) · ∫₁^Ω dΩ′/Q(Ω′)` (`Fields.h_profile`, the +cleared far-field-matched profile: `ĥ = 0` inside the separatrix, `ĥ → x` +outside). `L̂_{n0}⁻¹ = phys.inv_Ln0`. This is the drive whose absence left the +Level-0 potential trivially zero (QUESTIONS Q5, now closed for the field term). +""" +function quasineutrality_source(grid::IslandGrid, phys::Level0Physics) + nx, nξ = grid.x.n, grid.ξ.n + w = phys.w_psi + C = h_amplitude(w) # cleared ĥ amplitude w/(2√2) + S = Array{Float64}(undef, nx, nξ) + @inbounds for iξ in 1:nξ + cξ = cos(grid.ξ.nodes[iξ]) + for ix in 1:nx + x = grid.x.nodes[ix] + Ω = 2 * x^2 / w^2 - cξ # island label (Moments.omega_label) + ĥ = h_profile(Ω; prefactor=C) # cleared: 0 inside, →x outside + S[ix, iξ] = phys.inv_Ln0 * (x - ĥ) + end + end + return S +end + +# --------------------------------------------------------------------------- +# The assembly +# --------------------------------------------------------------------------- +""" + configure_level0(grid, phys, species; gated) + +Assemble the Level-0 named configuration (`03 §2`): returns a NamedTuple +`(stack, bc, delta_prefactors, cleared, gated)` where + + - `stack::Operators.IslandStack` — the operator stack (streaming, magnetic + drift, `E×B`, mimetic pitch collisions, gradient drive) + the + quasineutrality field term; + - `bc` — the cleared far-field `Operators.FarFieldConditions` (the gradient + drive, from [`gradient_far_field`]); + - `delta_prefactors` — the cleared `(cos, sin)` `Δ`-moment prefactors + (`Coefficients.delta_moment_prefactors`); + - `cleared`, `gated` — the provenance tuples naming which coefficients came + from cleared `Coefficients.*` builders vs. supplied gated inputs. + +The magnetic drift `c_D`, the island streaming `a_xi`/`a_x`, the pitch-collision +`P`/`K` and `c`, the quasineutrality field term (`α` + the `L̂_{n0}⁻¹(x − ĥ)` +drive), the gradient drive (zero source + the far field `bc`), and the `Δ` +prefactors are populated from the **cleared** coefficient builders. The `E×B`, +collision magnitude, and pitch measure are **supplied** through +`gated::GatedLevel0Inputs` (QUESTIONS Q5) — this function assigns no physics +value to them. + +`species` is validated (a Level-0 config must have a bulk ion); its +per-species roles/backgrounds drive the gated builders that are not yet cleared. +""" +function configure_level0(grid::IslandGrid, phys::Level0Physics, species::AbstractVector{<:Species}; + gated::GatedLevel0Inputs) + validate_species(species) + + # cleared coefficient wiring + c_D = drift_coefficient_table(grid, phys) + P, wmeas = pitch_diffusivity_profile(grid, gated.B_profile) + K, _ = conservative_pitch_operator(grid.y, P, wmeas) + c_coll = collision_coefficient(grid, phys, gated.nu_tilde) + Δpref = delta_moment_prefactors(; mu0_R=phys.mu0_R, w_psi=phys.w_psi, dq_dpsi=phys.dq_dpsi, q_s=phys.q_s) + + # cleared island streaming (advection along Ω; parallel-streaming.md) + a_xi, a_x = streaming_coefficients(grid, phys) + # cleared quasineutrality field term (α + drive from the signed-off closure) + α = 1 / quasineutrality_coefficient(phys.tau) # (τ+1)/τ, adiabatic shielding + S_Φ = quasineutrality_source(grid, phys) # L̂_{n0}⁻¹(x − ĥ) + + # cleared gradient drive (I19 Formulation A, gradient-drive.md): the master + # equation is homogeneous — zero interior source; the drive is the far field. + nx, nξ, ny, nE, nσ = nnodes(grid) + drive0 = zeros(Float64, nx, nξ, ny, nE, nσ) + bc = gradient_far_field(grid, phys) + + kinetic = ( + ParallelStreaming(a_xi, a_x), # cleared (01 §2) + MagneticDrift(c_D; variant=phys.variant), # cleared + ExBDrift(gated.c_E), # gated + PitchAngleDiffusion(K, c_coll), # cleared shape (magnitude gated) + GradientDrive(drive0) # cleared: zero source (drive is the far field) + ) + stack = IslandStack(kinetic, Quasineutrality(α, S_Φ)) # cleared closure (01 §3) + + return (stack=stack, bc=bc, delta_prefactors=Δpref, + cleared=(:magnetic_drift, :streaming, :pitch_diffusivity, :deflection_frequency, :delta_prefactors, :quasineutrality, :gradient_drive, :far_field), + gated=(:exb, :nu_tilde, :pitch_measure)) +end + +# --------------------------------------------------------------------------- +# Documented non-physics placeholders (structural runs only) +# --------------------------------------------------------------------------- +""" + level0_placeholders(grid; c_E=0.0, nu_tilde=1.0, B_edge=0.999) + +Build a [`GatedLevel0Inputs`] of **documented non-physics placeholder** values so +the assembled stack can be exercised *structurally* — that `configure_level0` +produces a well-formed, solvable `IslandStack` — **never for a physics result** +(the remaining gated coefficients are uncleared, QUESTIONS Q5). Choices: + + - `c_E = 0` (drop the `E×B` nonlinearity so the structural residual is linear); + - `nu_tilde = 1` (order-unity); + - a `B_profile` that keeps `y·B ≤ 1` on the grid (`B = min(1, B_edge/y)`), so + the cleared `pitch_diffusivity` stays in-domain. + +Every value is a structural stand-in; a physics run supplies cleared inputs. The +gradient drive and far field are now *cleared* (built by `configure_level0` from +`phys`), so they are no longer placeholders here. +""" +function level0_placeholders(grid::IslandGrid; c_E::Real=0.0, nu_tilde::Real=1.0, B_edge::Real=0.999) + ny = grid.y.n + # B_profile ≤ 1/y keeps the cleared pitch_diffusivity in [0,1]; B ≤ 1 (B_max norm) + B_profile = [min(1.0, B_edge / max(grid.y.nodes[iy], eps())) for iy in 1:ny] + return GatedLevel0Inputs(; c_E=Float64(c_E), nu_tilde=Float64(nu_tilde), B_profile=B_profile) +end + +end # module Configure diff --git a/src/Islands/fields/Fields.jl b/src/Islands/fields/Fields.jl new file mode 100644 index 000000000..7724cf6fc --- /dev/null +++ b/src/Islands/fields/Fields.jl @@ -0,0 +1,144 @@ +""" + Fields + +The Level-0 field-equation layer (design `01 §3`, `03 §1`): quasineutrality is +the only field equation at Level 0 (Ampère arrives at Level 3). The residual +term itself lives in `Operators.Quasineutrality`; this module provides the +closure *structure* around it: + + - the island-geometry functions `Q(Ω)`, `h(Ω)` of the flattened-electron + (WCHH96-class) closure — implemented as **structure with a supplied + prefactor** (the physics prefactor `w_ψ/2√2` is `[CHECKED]`-uncleared, + QUESTIONS Q3); + - the coefficient-free consistency identity `⟨∂²h/∂x²⟩_Ω = 0` (ladder A7 — + kokuchou's unit set, which caught inherited DK-NTM bugs); + - [`ElectronClosure`](@ref) — the gated constant set of the closure, NaN- + poisoned until human-cleared. +""" +module Fields + +import QuadGK + +export Q_omega, dQ_domega, h_profile, dh_domega, d2h_domega2 +export flat_average_d2h_dx2, ElectronClosure, is_cleared + +""" + Q_omega(Ω; rtol=1e-10) + +`Q(Ω) = (1/2π) ∮ √(Ω + cos ξ) dξ` over the region where the radicand is +positive (`01 §2.4` structure). For `Ω > 1` the integral covers the full +period; for `|Ω| < 1` it runs between the turning points `cos ξ_b = −Ω`. +""" +function Q_omega(Ω; rtol::Real=1e-10) + if Ω > 1 + val, _ = QuadGK.quadgk(ξ -> sqrt(Ω + cos(ξ)), 0.0, 2π; rtol=rtol) + return val / (2π) + elseif -1 < Ω <= 1 + ξb = acos(-Ω) + val, _ = QuadGK.quadgk(ξ -> sqrt(max(Ω + cos(ξ), 0.0)), -ξb, ξb; rtol=rtol) + return val / (2π) + else + throw(DomainError(Ω, "Q_omega needs Ω > −1")) + end +end + +""" + dQ_domega(Ω; rtol=1e-10) + +`dQ/dΩ = (1/4π) ∮ (Ω + cos ξ)^{−1/2} dξ` (differentiating `Q_omega` under the +integral; the inside-separatrix endpoint singularity is integrable). +""" +function dQ_domega(Ω; rtol::Real=1e-10) + if Ω > 1 + val, _ = QuadGK.quadgk(ξ -> 1.0 / sqrt(Ω + cos(ξ)), 0.0, 2π; rtol=rtol) + return val / (4π) + elseif -1 < Ω < 1 + ξb = acos(-Ω) + val, _ = QuadGK.quadgk(ξ -> 1.0 / sqrt(Ω + cos(ξ)), -ξb, ξb; rtol=rtol) + return val / (4π) + else + throw(DomainError(Ω, "dQ_domega needs Ω ∈ (−1, 1) ∪ (1, ∞)")) + end +end + +""" + h_profile(Ω; prefactor, rtol=1e-10) + +The flattened-electron profile function *structure* `h(Ω) = Θ(Ω − 1) · prefactor · ∫₁^Ω dΩ′/Q(Ω′)` (`01 §2.4`): exactly flat inside the separatrix, +`→ x` far outside. The physics prefactor (`w_ψ/2√2` in the sources) is +`[CHECKED]`-uncleared (QUESTIONS Q3) and therefore **supplied**; the A7 +identity below is prefactor-independent. +""" +function h_profile(Ω; prefactor, rtol::Real=1e-10) + Ω <= 1 && return zero(float(Ω)) + val, _ = QuadGK.quadgk(Ωp -> 1.0 / Q_omega(Ωp; rtol=rtol), 1.0, Ω; rtol=rtol) + return prefactor * val +end + +""" + dh_domega(Ω; prefactor, rtol=1e-10) + +`h′(Ω) = prefactor / Q(Ω)` for `Ω > 1`, zero inside. +""" +dh_domega(Ω; prefactor, rtol::Real=1e-10) = Ω > 1 ? prefactor / Q_omega(Ω; rtol=rtol) : zero(float(Ω)) + +""" + d2h_domega2(Ω; prefactor, rtol=1e-10) + +`h″(Ω) = −prefactor · Q′(Ω)/Q(Ω)²` for `Ω > 1`, zero inside. +""" +function d2h_domega2(Ω; prefactor, rtol::Real=1e-10) + Ω <= 1 && return zero(float(Ω)) + Q = Q_omega(Ω; rtol=rtol) + return -prefactor * dQ_domega(Ω; rtol=rtol) / Q^2 +end + +""" + flat_average_d2h_dx2(Ω, w; prefactor=1.0, rtol=1e-10) + +The ladder-A7 identity integrand: `⟨∂²h/∂x²⟩_Ω` assembled from the chain rule +`∂²h/∂x² = h″(Ω)(∂Ω/∂x)² + h′(Ω) ∂²Ω/∂x²` with `∂Ω/∂x = 4x/w²` on the surface +(`(∂Ω/∂x)² = 8(Ω + cos ξ)/w²`), averaged with the `(Ω + cos ξ)^{−1/2}` weight. +Analytically **exactly zero** for any prefactor — a coefficient-free +consistency check of the `Q`, `h` quadratures and the `⟨·⟩_Ω` machinery +(L23 Eq. 4.1.1-class unit target). Valid for `Ω > 1`. +""" +function flat_average_d2h_dx2(Ω, w; prefactor::Real=1.0, rtol::Real=1e-10) + Ω > 1 || throw(DomainError(Ω, "the A7 identity applies outside the separatrix (Ω > 1)")) + hp = dh_domega(Ω; prefactor=prefactor, rtol=rtol) + hpp = d2h_domega2(Ω; prefactor=prefactor, rtol=rtol) + num, _ = QuadGK.quadgk(ξ -> (hpp * 8 * (Ω + cos(ξ)) / w^2 + hp * 4 / w^2) / sqrt(Ω + cos(ξ)), 0.0, 2π; rtol=rtol) + den, _ = QuadGK.quadgk(ξ -> 1.0 / sqrt(Ω + cos(ξ)), 0.0, 2π; rtol=rtol) + return num / den +end + +""" + ElectronClosure(; k_HS=NaN, f_p=NaN, h_prefactor=NaN, C_phi=NaN) + +The gated constant set of the Level-0 flattened-electron closure (`01 §2.4`, +`§3`), all `[CHECKED]`-uncleared (QUESTIONS Q3) and **NaN-defaulted** so an +un-cleared closure poisons results instead of committing to a guess: + +## Fields + + - `k_HS` — the Hirshman–Sigmar flow coefficient (sources: `≃ −1.173`). + - `f_p` — the passing fraction (**cleared** 2026-07-11: + `Coefficients.passing_fraction(ε) = 1 − 1.4624√ε`; may populate this field). + - `h_prefactor` — the `h(Ω)` amplitude (sources: `w_ψ/2√2`). + - `C_phi` — the quasineutrality closure coefficient (sources: `1/2L̂_{n0}`). +""" +Base.@kwdef struct ElectronClosure + k_HS::Float64 = NaN + f_p::Float64 = NaN + h_prefactor::Float64 = NaN + C_phi::Float64 = NaN +end + +""" + is_cleared(ec::ElectronClosure) + +`true` only when every gated closure constant has been assigned (no NaN). +""" +is_cleared(ec::ElectronClosure) = !(isnan(ec.k_HS) || isnan(ec.f_p) || isnan(ec.h_prefactor) || isnan(ec.C_phi)) + +end # module Fields diff --git a/src/Islands/frames/Frames.jl b/src/Islands/frames/Frames.jl new file mode 100644 index 000000000..079167d9c --- /dev/null +++ b/src/Islands/frames/Frames.jl @@ -0,0 +1,119 @@ +""" + Frames + +THE frequency/frame conversion module (design `01 §5`, module CLAUDE.md): no +other part of Islands may contain an ω sign convention. The polarization-current +sign disputes in the literature are largely frame disputes; this module owns the +conversions so they cannot be reproduced inconsistently elsewhere. + +**Milestone-M2 status: forms only, signs gated.** The frame identities of +`01 §5` are `[CHECKED: Diss19 pp. 46–48]` but not human-cleared (QUESTIONS Q3), +so every sign/normalization here is a `FrameConvention` field with a **NaN +default**: using an un-cleared convention poisons every downstream number +instead of silently committing to a guess. Only the mechanical, sign-free +bookkeeping (`frame_shift`, round-trips, parameter validation) is active. +""" +module Frames + +export Level0Parameters, FrameConvention +export frame_shift, omega_dia_form, effective_dlnn_form, is_cleared + +""" + Level0Parameters(; w_hat, omega_E_hat, epsilon, inv_Lq_hat, q_s, tau, nu_star) + +The Level-0 input parameter vector `p` (`01 §5`). Species-resolved quantities +(gradients, `η_j`, backgrounds, roles) live on the species list; this struct +carries the scalars. + +## Fields + + - `w_hat` — island **half**-width `w/ρ_θi` (half-width convention pinned + in the module CLAUDE.md; thresholds are always reported as half-widths). + - `omega_E_hat` — `ω_E/ω_dia,e` (`≡ −ω₀/ω_dia,e`; a scanned input from day one, + ordering O4). + - `epsilon` — inverse aspect ratio `r_s/R₀`. + - `inv_Lq_hat` — `L̂_q⁻¹ = (ψ_s/q) dq/dψ` at `r_s`. + - `q_s` — safety factor at the rational surface. + - `tau` — `T_e/T_i`. + - `nu_star` — per-species banana collisionality `ν_★j`, keyed by species name. +""" +struct Level0Parameters + w_hat::Float64 + omega_E_hat::Float64 + epsilon::Float64 + inv_Lq_hat::Float64 + q_s::Float64 + tau::Float64 + nu_star::Dict{Symbol,Float64} + function Level0Parameters(w_hat, omega_E_hat, epsilon, inv_Lq_hat, q_s, tau, nu_star) + w_hat > 0 || throw(ArgumentError("w_hat must be positive (half-width)")) + epsilon > 0 || throw(ArgumentError("epsilon must be positive")) + q_s > 0 || throw(ArgumentError("q_s must be positive")) + tau > 0 || throw(ArgumentError("tau must be positive")) + all(v -> v >= 0, values(nu_star)) || throw(ArgumentError("nu_star entries must be nonnegative")) + return new(w_hat, omega_E_hat, epsilon, inv_Lq_hat, q_s, tau, Dict{Symbol,Float64}(nu_star)) + end +end + +function Level0Parameters(; w_hat, omega_E_hat, epsilon, inv_Lq_hat, q_s, tau=1.0, nu_star=Dict{Symbol,Float64}()) + return Level0Parameters(w_hat, omega_E_hat, epsilon, inv_Lq_hat, q_s, tau, nu_star) +end + +""" + FrameConvention(; C_dia=NaN, sign_omega0=NaN, C_gradient_shift=NaN) + +The gated sign/normalization set of the frame identities (`01 §5`, +`[CHECKED: Diss19 pp. 46–48]`, awaiting human clearance — QUESTIONS **Q3**). +All fields default to **NaN** so an un-cleared convention poisons results +rather than silently committing to a guess; `is_cleared` tests for this. + +## Fields + + - `C_dia` — prefactor (incl. sign) of the electron diamagnetic + frequency form `ω_dia,e = C_dia · m T̂_e L̂_n⁻¹ / q_s`. + - `sign_omega0` — the island-propagation relation `ω₀ = sign_omega0 · ω_E`. + - `C_gradient_shift` — prefactor of the frame shift of the effective density + gradient, `L_n⁻¹ = L_{n0}⁻¹ (1 + C_gradient_shift · Z_j ω_E/ω_dia,e)`. +""" +Base.@kwdef struct FrameConvention + C_dia::Float64 = NaN + sign_omega0::Float64 = NaN + C_gradient_shift::Float64 = NaN +end + +""" + is_cleared(conv::FrameConvention) + +`true` only when every gated field has been assigned (no NaN). Downstream code +must check this before producing physics numbers. +""" +is_cleared(conv::FrameConvention) = !(isnan(conv.C_dia) || isnan(conv.sign_omega0) || isnan(conv.C_gradient_shift)) + +""" + frame_shift(omega, omega_E) + +The frame-invariant combination `ω − ω_E` (`01 §5`): mechanical bookkeeping, +no sign convention (the invariance is what *defines* the shift). +""" +frame_shift(omega, omega_E) = omega - omega_E + +""" + omega_dia_form(m_mode, T_hat, inv_Ln_hat, q_s, conv) + +The electron diamagnetic frequency *form* `C_dia · m T̂ L̂_n⁻¹ / q_s` +(structure of `01 §5`; value gated on `conv.C_dia`, QUESTIONS Q3). Returns NaN +until the convention is cleared. +""" +omega_dia_form(m_mode, T_hat, inv_Ln_hat, q_s, conv::FrameConvention) = conv.C_dia * m_mode * T_hat * inv_Ln_hat / q_s + +""" + effective_dlnn_form(inv_Ln0_hat, Z, omega_E_hat, conv) + +The frame shift of the effective density gradient *form* +`L̂_n⁻¹ = L̂_{n0}⁻¹ (1 + C_gradient_shift · Z ω̂_E)` (structure of `01 §5`; value +gated on `conv.C_gradient_shift`, QUESTIONS Q3). `omega_E_hat` is already +normalized to `ω_dia,e`. Returns NaN until the convention is cleared. +""" +effective_dlnn_form(inv_Ln0_hat, Z, omega_E_hat, conv::FrameConvention) = inv_Ln0_hat * (1 + conv.C_gradient_shift * Z * omega_E_hat) + +end # module Frames diff --git a/src/Islands/moments/Moments.jl b/src/Islands/moments/Moments.jl new file mode 100644 index 000000000..bd4c3db8c --- /dev/null +++ b/src/Islands/moments/Moments.jl @@ -0,0 +1,161 @@ +""" + Moments + +Output-moment assembly (design `01 §4`, `03 §1`): the parallel current +`J̄_∥(x, ξ)` from species-summed velocity moments, its `cos ξ`/`sin ξ` Ampère +projections `Δ_cos`/`Δ_sin`, and the island flux-surface-average diagnostics +(`Ω` label, `⟨·⟩_Ω`, bootstrap/polarization channel split). + +**Gating:** the projection and quadrature machinery here is pure numerics. The +physics enters through (i) the per-species velocity-space weights `W_j` (the +`v̂_∥`-structure — `[VERIFY]`-gated, QUESTIONS Q3), and (ii) the `Δ` moment +prefactors (`±μ₀R/2ψ̃`). The `ψ̃` amplitude is **cleared** — see +[`island_flux_amplitude`](@ref) (human sign-off 2026-07-11; derivation +`docs/src/islands/derivations/psi-tilde-amplitude.md`, docs/01 §1) — but the +`μ₀R` normalization and the sin-moment normalization pin (`[DERIVED]`, +docs/01 §4) are not, so `delta_moments`' prefactors remain **required +caller-supplied arguments** with no defaults; nothing here assigns them. + +The island label convention is the module-CLAUDE.md pin: `Ω = 2x²/w² − cos ξ`, +O-point `Ω = −1`, separatrix `Ω = +1`, `w` = **half**-width. +""" +module Moments + +using LinearAlgebra +import QuadGK +import ..PhaseSpace: IslandGrid, nnodes +import ..Operators: weighted_moment! +import ..SpeciesLists: Species + +export parallel_current!, delta_moments, omega_label, omega_average, channel_split +export island_flux_amplitude + +""" + island_flux_amplitude(; w_psi, dq_dpsi, q_s) + +The prescribed single-helicity island flux amplitude (`01 §1`, ordering O3): + +```math +\\tilde\\psi = \\frac{w_\\psi^2}{4}\\,\\frac{q_s'}{q_s}, +\\qquad q_s' = dq/d\\psi|_s , +``` + +with `w_psi` the island **half**-width in `ψ`-space and `dq_dpsi`, `q_s` the +safety-factor derivative and value at the rational surface. This is a **cleared +physics relation** — human sign-off 2026-07-11, independent re-derivation +(Decision D7): `docs/src/islands/derivations/psi-tilde-amplitude.md`. It +resolves the former `q_s'/q_s` vs `q_s/q_s'` `[VERIFY]` (I19 as printed has a +typo; the physical form is `q_s'/q_s`, consistent with I19's own Ω convention +and Diss19/D21/L23). Feeds the `Δ`-moment prefactor `∓μ₀R/(2 ψ̃)` +([`delta_moments`](@ref); the `μ₀R` and sin normalization remain gated). +""" +function island_flux_amplitude(; w_psi::Real, dq_dpsi::Real, q_s::Real) + q_s != 0 || throw(ArgumentError("q_s must be nonzero")) + return (w_psi^2 / 4) * (dq_dpsi / q_s) +end + +""" + parallel_current!(Jpar, gs, species, weights, grid) + +Assemble `J̄_∥(x, ξ) = Σ_j Z_j ∫ W_j g_j` into `Jpar[ix, iξ]` (`01 §4`): one +`weighted_moment!` per species, charge-scaled and accumulated. `gs`, `species` +and `weights` are aligned vectors; each `W_j` is the supplied `(ny, nE, nσ)` +parallel-flow velocity weight (gated physics, QUESTIONS Q3). +""" +function parallel_current!(Jpar, gs::AbstractVector, species::AbstractVector{<:Species}, weights::AbstractVector, grid::IslandGrid) + length(gs) == length(species) == length(weights) || + throw(ArgumentError("gs, species, weights must be aligned (got $(length(gs)), $(length(species)), $(length(weights)))")) + fill!(Jpar, zero(eltype(Jpar))) + for (g, sp, W) in zip(gs, species, weights) + weighted_moment!(Jpar, g, W, grid; scale=sp.Z, accumulate=true) + end + return Jpar +end + +""" + delta_moments(Jpar, grid; prefactor_cos, prefactor_sin) + +The two Ampère projections of `J̄_∥` through the island (`01 §4`): + + Δ_cos = prefactor_cos · ∫dx ∮dξ J̄_∥ cos ξ + Δ_sin = prefactor_sin · ∫dx ∮dξ J̄_∥ sin ξ + +`ξ`-integration is the uniform-grid trapezoid (spectrally exact on the periodic +grid); `x`-integration uses the grid's Simpson weights. The prefactors +(`∓μ₀R/2ψ̃`, `01 §4`) are **gated** — `ψ̃` carries an open `[VERIFY]` and the +sin normalization is `[DERIVED]`-unpinned (QUESTIONS Q4) — so both are required +arguments. Returns `(Δcos, Δsin)`. +""" +function delta_moments(Jpar, grid::IslandGrid; prefactor_cos, prefactor_sin) + nx, nξ = size(Jpar) + (nx, nξ) == (grid.x.n, grid.ξ.n) || throw(ArgumentError("Jpar must be (nx, nξ) = $((grid.x.n, grid.ξ.n))")) + wξ = grid.ξ.L / grid.ξ.n + pc = zero(eltype(Jpar)) + ps = zero(eltype(Jpar)) + @inbounds for iξ in 1:nξ + c = cos(grid.ξ.nodes[iξ]) + s = sin(grid.ξ.nodes[iξ]) + for ix in 1:nx + w = grid.x.wq[ix] * wξ * Jpar[ix, iξ] + pc += w * c + ps += w * s + end + end + return (Δcos=prefactor_cos * pc, Δsin=prefactor_sin * ps) +end + +""" + omega_label(x, ξ, w) + +The island flux-surface label `Ω = 2x²/w² − cos ξ` (pinned convention, module +CLAUDE.md; O-point `Ω = −1`, separatrix `Ω = +1`, `w` = half-width). A +diagnostic label only — never a solve coordinate (Decision D1). +""" +omega_label(x, ξ, w) = 2 * x^2 / w^2 - cos(ξ) + +# x on the Ω surface at helical angle ξ (branch = ±1 selects the x-sign). +_x_on_surface(Ω, ξ, w, branch) = branch * (w / sqrt(2)) * sqrt(max(Ω + cos(ξ), 0.0)) + +""" + omega_average(f, Ω, w; branch=+1, rtol=1e-10) + +Island flux-surface average `⟨f⟩_Ω = ∮ f (Ω + cos ξ)^{−1/2} dξ / ∮ (Ω + cos ξ)^{−1/2} dξ` +(`01 §4` decomposition weight) of a callable `f(x, ξ)`: + + - `Ω > 1` (outside the separatrix): the `±x` branches are distinct surfaces; + `branch` selects one, integrating over the full `ξ ∈ [0, 2π)`. + - `−1 < Ω < 1` (inside): the surface closes through both branches between the + turning points `cos ξ_b = −Ω`; the endpoint weight singularity is integrable + and handled by the adaptive quadrature. +""" +function omega_average(f, Ω, w; branch::Int=1, rtol::Real=1e-10) + if Ω > 1 + num, _ = QuadGK.quadgk(ξ -> f(_x_on_surface(Ω, ξ, w, branch), ξ) / sqrt(Ω + cos(ξ)), 0.0, 2π; rtol=rtol) + den, _ = QuadGK.quadgk(ξ -> 1.0 / sqrt(Ω + cos(ξ)), 0.0, 2π; rtol=rtol) + return num / den + elseif -1 < Ω < 1 + ξb = acos(-Ω) + integrand(ξ) = (f(_x_on_surface(Ω, ξ, w, +1), ξ) + f(_x_on_surface(Ω, ξ, w, -1), ξ)) / sqrt(Ω + cos(ξ)) + num, _ = QuadGK.quadgk(integrand, -ξb, ξb; rtol=rtol) + den, _ = QuadGK.quadgk(ξ -> 2.0 / sqrt(Ω + cos(ξ)), -ξb, ξb; rtol=rtol) + return num / den + else + throw(DomainError(Ω, "omega_average needs Ω ∈ (−1, 1) ∪ (1, ∞) (O-point/separatrix excluded)")) + end +end + +""" + channel_split(Jfun, Ω, w; branch=+1) + +The `01 §4` bootstrap/polarization bookkeeping at one `Ω` surface: returns +`(bs, pol)` where `bs = ⟨J⟩_Ω` (the flux-surface-constant "bootstrap+curvature" +part) and `pol(x, ξ) = Jfun(x, ξ) − bs` (the piece that `Ω`-averages to zero). +Diagnostic only — the solve never separates channels; the split is approximate +bookkeeping (L23 Eq. 2.5.3 caveat). +""" +function channel_split(Jfun, Ω, w; branch::Int=1) + bs = omega_average(Jfun, Ω, w; branch=branch) + return (bs=bs, pol=(x, ξ) -> Jfun(x, ξ) - bs) +end + +end # module Moments diff --git a/src/Islands/operators/Operators.jl b/src/Islands/operators/Operators.jl new file mode 100644 index 000000000..8501fcff5 --- /dev/null +++ b/src/Islands/operators/Operators.jl @@ -0,0 +1,657 @@ +""" + Operators + +The Islands operator stack (`03 §2`): the state vector, the residual assembly, +and the `AbstractTerm` family whose sum is the drift-kinetic residual. + +**Milestone-M1 status: allocation-free, AD-compatible *structural* stubs.** Each +term implements its discretized differential/algebraic *structure* — which +derivatives act, in which coordinate, with which sign pattern — but takes every +physics coefficient as *supplied data* (`term.a_xi`, `term.c_D`, …). No literal +physics coefficient, sign, or normalization is written here: those carry +`[VERIFY]`/`[CHECKED]` tags (module `CLAUDE.md`) and are populated only after +human clearance. The verification harness (`Verify`) exercises the discretization +with arbitrary manufactured test coefficients, which is legitimate — it tests +the numerics, not the physics. + +Operator-stack rules enforced here (`03 §2`, `CLAUDE.md`): + + - terms are independent — no term inspects which others are active; + - `apply!` is generic over `eltype(U)` (ForwardDiff duals flow through) and + allocation-free on the hot path (regression-tested); + - orderings that *remove* structure are phase-space configurations, not terms. + +Term ↔ physics map (`03 §2`, `01 §2`): `ParallelStreaming` (island-induced +streaming, `∂ξ` + `∂x`), `MagneticDrift` (precession, `∂ξ`, `:original`/`:improved` +toggle), `ExBDrift` (the `E×B` Poisson bracket in `(x, ξ)`), `Collisions` +(pitch-angle diffusion in `y`), `GradientDrive` (`(v·∇F₀)` source), +`PerpTransport`/`RadiationSink` (Level-4 stubs), and `Quasineutrality` (the +Level-0 field residual, `01 §3`). +""" +module Operators + +using LinearAlgebra +import ..PhaseSpace: IslandGrid, nnodes + +export IslandState, IslandCache, IslandStack, AbstractTerm +export ParallelStreaming, MagneticDrift, ExBDrift, Collisions, GradientDrive, + PerpTransport, RadiationSink, Quasineutrality +export PitchAngleDiffusion, conservative_pitch_operator +export FarFieldConditions, apply_farfield! +export apply!, residual!, velocity_moment!, weighted_moment!, statelength, + flatten!, unflatten!, g_flat_index, Φ_flat_index + +# --------------------------------------------------------------------------- +# State, cache +# --------------------------------------------------------------------------- +""" + IslandState(g, Φ) + +The Level-0 unknowns (`03 §2`): the orbit-averaged distribution +`g[ix, iξ, iy, iE, iσ]` per grid point and the electrostatic potential +`Φ[ix, iξ]`. Parametric in the element type so ForwardDiff duals flow through. + +## Fields + + - `g` — 5D array over `(x, ξ, y, E, σ)`. + - `Φ` — 2D array over `(x, ξ)`. +""" +struct IslandState{T,A5<:AbstractArray{T,5},A2<:AbstractArray{T,2}} + g::A5 + Φ::A2 +end + +function IslandState{T}(grid::IslandGrid) where {T} + nx, nξ, ny, nE, nσ = nnodes(grid) + return IslandState(zeros(T, nx, nξ, ny, nE, nσ), zeros(T, nx, nξ)) +end +IslandState(grid::IslandGrid) = IslandState{Float64}(grid) + +Base.eltype(::IslandState{T}) where {T} = T + +function Base.similar(U::IslandState{T}) where {T} + return IslandState(similar(U.g), similar(U.Φ)) +end + +function fill_state!(U::IslandState, v) + fill!(U.g, v) + fill!(U.Φ, v) + return U +end + +""" + IslandCache{T}(grid) + +Per-solve scratch buffers, typed to the state element type `T` so the hot path +allocates nothing (and so AD duals get dual-typed scratch). Currently holds the +two potential-gradient fields consumed by the `E×B` bracket. +""" +struct IslandCache{T} + dΦdx::Matrix{T} + dΦdξ::Matrix{T} +end + +function IslandCache{T}(grid::IslandGrid) where {T} + nx, nξ, = nnodes(grid) + return IslandCache{T}(zeros(T, nx, nξ), zeros(T, nx, nξ)) +end +IslandCache(grid::IslandGrid) = IslandCache{Float64}(grid) + +# --------------------------------------------------------------------------- +# Term family +# --------------------------------------------------------------------------- +""" + AbstractTerm + +Supertype of every operator-stack term (`03 §2`). Each concrete term implements +`apply!(R, term, U, grid, cache)` to accumulate its contribution to the residual. +Terms are independent (no term inspects which others are active), generic over +`eltype(U)`, and allocation-free on the hot path. +""" +abstract type AbstractTerm end + +""" + ParallelStreaming(a_xi, a_x) + +Island-induced parallel streaming (`01 §2`, the `∂ξ`/`∂x` channel): adds +`a_xi ∂g/∂ξ + a_x ∂g/∂x` to the residual. `a_xi`, `a_x` are supplied coefficient +arrays shaped like `g` (physics values `[VERIFY]`-gated; never literals here). +""" +struct ParallelStreaming{A} <: AbstractTerm + a_xi::A + a_x::A +end + +""" + MagneticDrift(c_D; variant=:original) + +Orbit-averaged magnetic (precession) drift (`01 §2.1`): adds `c_D ∂g/∂ξ`. The +`variant` selects the `:original` (finite `L̂_B⁻¹`, I19) vs `:improved` +(`L̂_B⁻¹ → 0` proxy, D21) drift-frequency structure — the 8.73→1.46 ρ_bi toggle +(`docs/05 E1`). Structure only: `c_D` (the `ω̂_D` coefficient over `(y, E, σ)`) +is supplied data. +""" +struct MagneticDrift{A} <: AbstractTerm + c_D::A + variant::Symbol +end +MagneticDrift(c_D; variant::Symbol=:original) = MagneticDrift(c_D, variant) + +""" + ExBDrift(c_E) + +`E×B` advection as the Poisson bracket of `Φ` and `g` in `(x, ξ)` +(`01 §2`): adds `c_E (∂Φ/∂ξ · ∂g/∂x − ∂Φ/∂x · ∂g/∂ξ)`. This is the one Level-0 +kinetic term nonlinear in the state (couples `g` and `Φ`); `c_E` is a supplied +scalar coupling. +""" +struct ExBDrift{S} <: AbstractTerm + c_E::S +end + +""" + Collisions(a_y, b_y; model=:pitch_angle) + +Pitch-angle (Lorentz) collision operator (`01 §2.3`) as a second-order operator +in `y`: adds `a_y ∂²g/∂y² + b_y ∂g/∂y`. `model = :pitch_angle` is the Level-0 +form; `:fokker_planck` (Level 1) reuses the same slot. `a_y`, `b_y` are supplied +coefficient arrays (the `ν̂`-weighted diffusion structure, `[VERIFY]`-gated). +""" +struct Collisions{A} <: AbstractTerm + a_y::A + b_y::A + model::Symbol +end +Collisions(a_y, b_y; model::Symbol=:pitch_angle) = Collisions(a_y, b_y, model) + +""" + PitchAngleDiffusion(K, c) + +Pitch-angle collision operator in **discretely conservative (mimetic) form** +(`01 §2.3` structure; ladder A4): adds `c ⋅ (K g)` along `y`, where `K` is the +divergence-form matrix built by [`conservative_pitch_operator`](@ref) and `c` is +a supplied coefficient array over `(x, ξ, E, σ)` — **`y`-independent by +construction**, so the exact discrete particle conservation and entropy-sign +properties of `K` are preserved (the physical `ν̂(v̂)` energy dependence lives in +`c`'s `E`-dependence and is `[VERIFY]`-gated). The momentum-restoring +field-particle piece of the Level-0 operator is gated physics (QUESTIONS Q3) +and is not part of this structure. +""" +struct PitchAngleDiffusion{M<:AbstractMatrix,A} <: AbstractTerm + K::M + c::A +end + +""" + conservative_pitch_operator(ygrid, P, wmeas) + +Build the mimetic divergence-form pitch operator on `ygrid` +(`C[g] = (1/w) d/dy(P w dg/dy)` discretized as `K = −Wq⁻¹ Gᵀ diag(P .* wq) G` +with `G = ygrid.D1` and `Wq = wmeas .* ygrid.wq`), returning `(K, Wq)`. + +Because `G` differentiates constants exactly and the quadrature weights are +positive, `K` satisfies **exactly** (to machine precision, ladder A4): + + - particle conservation: `Wqᵀ (K g) = 0` for any `g` (zero-flux built in); + - entropy sign: `gᵀ diag(Wq) K g = −(Gg)ᵀ diag(P .* wq)(Gg) ≤ 0` for `P ≥ 0`. + +`P` (the pitch-space diffusivity profile, physically the `λ√(1−λB)`-structure) +and `wmeas` (the velocity-space measure) are **supplied** profiles — their +Level-0 physics forms are `[VERIFY]`-gated (QUESTIONS Q3); any positive test +profiles exercise the conservation structure. +""" +function conservative_pitch_operator(ygrid, P::AbstractVector, wmeas::AbstractVector) + length(P) == ygrid.n || throw(ArgumentError("P must have length $(ygrid.n)")) + length(wmeas) == ygrid.n || throw(ArgumentError("wmeas must have length $(ygrid.n)")) + all(>=(0), P) || throw(ArgumentError("diffusivity profile P must be nonnegative")) + all(>(0), wmeas) || throw(ArgumentError("measure weights wmeas must be positive")) + G = ygrid.D1 + Wq = wmeas .* ygrid.wq + K = -Diagonal(1.0 ./ Wq) * (G' * Diagonal(P .* ygrid.wq) * G) + return Matrix(K), Wq +end + +""" + GradientDrive(drive) + +The `(v_E + v_D + v_ψ̃)·∇F₀` gradient drive (`03 §2`, `01 §2`): a state-independent +source, adds `drive` to the residual. `drive` is a supplied array shaped like +`g`; at Level 0 it is built from the background Maxwellian gradients (`[VERIFY]`). +""" +struct GradientDrive{A} <: AbstractTerm + drive::A +end + +""" + PerpTransport(χ) + +Perpendicular transport (Level-4 closure stub, `03 §2`): adds `χ ∂²g/∂x²`. +Present as structure only; the closure value `χ` is a supplied knob. +""" +struct PerpTransport{S} <: AbstractTerm + χ::S +end + +""" + RadiationSink(κ) + +Radiative energy sink (Level-4 closure stub, `03 §2`): adds `-κ g`. Structure +only; `κ` is a supplied coefficient array. +""" +struct RadiationSink{A} <: AbstractTerm + κ::A +end + +""" + Quasineutrality(α) + Quasineutrality(α, source) + +The Level-0 field residual (`01 §3`): `R_Φ = M[g] − α Φ + source`, where `M[g]` +is the velocity moment `∫dy ∫dE Σ_σ g` (Gauss in `E`, Simpson in `y`), `α` is +the adiabatic-shielding coefficient `(τ+1)/τ`, and `source` is the +`L̂_{n0}⁻¹(x − ĥ(Ω))` flattened-electron drive of the cleared closure (`01 §3`, +`quasineutrality-closure.md`). Both come from the **human-cleared** closure +(sign-off 2026-07-11) — built by `Configure.configure_level0` from +`Coefficients.quasineutrality_coefficient` and the `ĥ` profile. `source` +defaults to `nothing` (a pure `M[g] − α Φ` residual) for the manufactured-test +and pre-drive configurations. This closes `Φ(x, ξ)` inside the global Newton +system rather than the sources' fragile nested Picard loop (`01 §3`, `03 §3`). + +## Fields + + - `α` — adiabatic-shielding coefficient multiplying `Φ` (`(τ+1)/τ`). + - `source` — the `(nx, nξ)` `L̂_{n0}⁻¹(x − ĥ)` field drive, or `nothing`. +""" +struct Quasineutrality{S,A} <: AbstractTerm + α::S + source::A +end +Quasineutrality(α) = Quasineutrality(α, nothing) + +# --------------------------------------------------------------------------- +# Discrete kernels (allocation-free, generic over eltype) +# +# Directional-derivative kernels accumulate `coef · ∂g/∂dir` into `Rg`. The +# dense matrices `D` are Float64; `D·g` with `g::Dual` promotes correctly, so +# the whole stack is AD-transparent. +# --------------------------------------------------------------------------- +# adds coef .* (∂g/∂ξ) along dim 2 +@inline function _add_dxi!(Rg, coef, g, D) + nx, nξ, ny, nE, nσ = size(g) + @inbounds for iσ in 1:nσ, iE in 1:nE, iy in 1:ny, ix in 1:nx + for a in 1:nξ + acc = zero(eltype(Rg)) + for b in 1:nξ + acc += D[a, b] * g[ix, b, iy, iE, iσ] + end + Rg[ix, a, iy, iE, iσ] += coef[ix, a, iy, iE, iσ] * acc + end + end + return Rg +end + +# adds coef .* (∂g/∂x) along dim 1 +@inline function _add_dx!(Rg, coef, g, D) + nx, nξ, ny, nE, nσ = size(g) + @inbounds for iσ in 1:nσ, iE in 1:nE, iy in 1:ny, iξ in 1:nξ + for a in 1:nx + acc = zero(eltype(Rg)) + for b in 1:nx + acc += D[a, b] * g[b, iξ, iy, iE, iσ] + end + Rg[a, iξ, iy, iE, iσ] += coef[a, iξ, iy, iE, iσ] * acc + end + end + return Rg +end + +# adds coef .* (Dⁿ g along dim 3, y). D is D1 or D2. +@inline function _add_dy!(Rg, coef, g, D) + nx, nξ, ny, nE, nσ = size(g) + @inbounds for iσ in 1:nσ, iE in 1:nE, iξ in 1:nξ, ix in 1:nx + for a in 1:ny + acc = zero(eltype(Rg)) + for b in 1:ny + acc += D[a, b] * g[ix, iξ, b, iE, iσ] + end + Rg[ix, iξ, a, iE, iσ] += coef[ix, iξ, a, iE, iσ] * acc + end + end + return Rg +end + +# scalar-coefficient ∂²/∂x² (PerpTransport): adds χ .* D2x g along dim 1 +@inline function _add_dx2_scalar!(Rg, χ, g, D) + nx, nξ, ny, nE, nσ = size(g) + @inbounds for iσ in 1:nσ, iE in 1:nE, iy in 1:ny, iξ in 1:nξ + for a in 1:nx + acc = zero(eltype(Rg)) + for b in 1:nx + acc += D[a, b] * g[b, iξ, iy, iE, iσ] + end + Rg[a, iξ, iy, iE, iσ] += χ * acc + end + end + return Rg +end + +# ∂Φ/∂x and ∂Φ/∂ξ into cache buffers (allocation-free) +@inline function _potential_gradients!(cache::IslandCache, Φ, Dx, Dξ) + nx, nξ = size(Φ) + dΦdx, dΦdξ = cache.dΦdx, cache.dΦdξ + @inbounds for iξ in 1:nξ, ix in 1:nx + ax = zero(eltype(Φ)) + for b in 1:nx + ax += Dx[ix, b] * Φ[b, iξ] + end + dΦdx[ix, iξ] = ax + end + @inbounds for ix in 1:nx, iξ in 1:nξ + aξ = zero(eltype(Φ)) + for b in 1:nξ + aξ += Dξ[iξ, b] * Φ[ix, b] + end + dΦdξ[ix, iξ] = aξ + end + return cache +end + +# --------------------------------------------------------------------------- +# apply!(R, term, U, grid, cache) — accumulate the term's contribution. +# --------------------------------------------------------------------------- +""" + apply!(R, term, U, grid, cache) + +Accumulate `term`'s contribution to the residual `R` at state `U` on `grid`, +using `cache` for scratch. Each `AbstractTerm` defines a method; the assembly in +[`residual!`](@ref) walks the stack. Allocation-free and generic over `eltype(U)` +so ForwardDiff duals flow through (verification ladder A2). +""" +function apply!(R::IslandState, t::ParallelStreaming, U::IslandState, grid::IslandGrid, ::IslandCache) + _add_dxi!(R.g, t.a_xi, U.g, grid.ξ.D1) + _add_dx!(R.g, t.a_x, U.g, grid.x.D1) + return R +end + +function apply!(R::IslandState, t::MagneticDrift, U::IslandState, grid::IslandGrid, ::IslandCache) + _add_dxi!(R.g, t.c_D, U.g, grid.ξ.D1) + return R +end + +function apply!(R::IslandState, t::Collisions, U::IslandState, grid::IslandGrid, ::IslandCache) + _add_dy!(R.g, t.a_y, U.g, grid.y.D2) + _add_dy!(R.g, t.b_y, U.g, grid.y.D1) + return R +end + +function apply!(R::IslandState, t::PitchAngleDiffusion, U::IslandState, grid::IslandGrid, ::IslandCache) + K = t.K + c = t.c + g = U.g + nx, nξ, ny, nE, nσ = size(g) + @inbounds for iσ in 1:nσ, iE in 1:nE, iξ in 1:nξ, ix in 1:nx + cv = c[ix, iξ, iE, iσ] + for a in 1:ny + acc = zero(eltype(R.g)) + for b in 1:ny + acc += K[a, b] * g[ix, iξ, b, iE, iσ] + end + R.g[ix, iξ, a, iE, iσ] += cv * acc + end + end + return R +end + +function apply!(R::IslandState, t::GradientDrive, U::IslandState, ::IslandGrid, ::IslandCache) + @inbounds @. R.g += t.drive + return R +end + +function apply!(R::IslandState, t::PerpTransport, U::IslandState, grid::IslandGrid, ::IslandCache) + _add_dx2_scalar!(R.g, t.χ, U.g, grid.x.D2) + return R +end + +function apply!(R::IslandState, t::RadiationSink, U::IslandState, ::IslandGrid, ::IslandCache) + @inbounds @. R.g += -t.κ * U.g + return R +end + +function apply!(R::IslandState, t::ExBDrift, U::IslandState, grid::IslandGrid, cache::IslandCache) + _potential_gradients!(cache, U.Φ, grid.x.D1, grid.ξ.D1) + dΦdx, dΦdξ = cache.dΦdx, cache.dΦdξ + g = U.g + Dx, Dξ = grid.x.D1, grid.ξ.D1 + cE = t.c_E + nx, nξ, ny, nE, nσ = size(g) + @inbounds for iσ in 1:nσ, iE in 1:nE, iy in 1:ny, iξ in 1:nξ, ix in 1:nx + dgdx = zero(eltype(R.g)) + for b in 1:nx + dgdx += Dx[ix, b] * g[b, iξ, iy, iE, iσ] + end + dgdξ = zero(eltype(R.g)) + for b in 1:nξ + dgdξ += Dξ[iξ, b] * g[ix, b, iy, iE, iσ] + end + R.g[ix, iξ, iy, iE, iσ] += cE * (dΦdξ[ix, iξ] * dgdx - dΦdx[ix, iξ] * dgdξ) + end + return R +end + +function apply!(R::IslandState, t::Quasineutrality, U::IslandState, grid::IslandGrid, ::IslandCache) + velocity_moment!(R.Φ, U.g, grid; accumulate=true) + @inbounds @. R.Φ += -t.α * U.Φ + t.source === nothing || @inbounds(@. R.Φ += t.source) + return R +end + +# --------------------------------------------------------------------------- +# Velocity moment M[g](x,ξ) = ∫dy ∫dE Σ_σ g (Simpson in y, Gauss in E). +# --------------------------------------------------------------------------- +""" + velocity_moment!(M, g, grid; accumulate=false) + +Accumulate the phase-space velocity moment `∫dy ∫dE Σ_σ g` into `M[ix, iξ]` +using the Simpson `y`-weights and Gauss `E`-weights of `grid` (`03 §2`). Set +`accumulate=true` to add into `M` (residual assembly); otherwise `M` is zeroed +first. +""" +function velocity_moment!(M, g, grid::IslandGrid; accumulate::Bool=false) + accumulate || fill!(M, zero(eltype(M))) + wy = grid.y.wq + wE = grid.E.weights + nx, nξ, ny, nE, nσ = size(g) + @inbounds for iσ in 1:nσ, iE in 1:nE, iy in 1:ny + w = wy[iy] * wE[iE] + for iξ in 1:nξ, ix in 1:nx + M[ix, iξ] += w * g[ix, iξ, iy, iE, iσ] + end + end + return M +end + +""" + weighted_moment!(M, g, W, grid; scale=1, accumulate=false) + +Accumulate the weighted velocity moment `scale · ∫dy ∫dE Σ_σ W(y, E, σ) g` into +`M[ix, iξ]` (`03 §2`, moments). `W` is a supplied `(ny, nE, nσ)` velocity-space +weight — e.g. the `v̂_∥`-structure of the parallel-flow moment, whose Level-0 +physics form is `[VERIFY]`-gated (QUESTIONS Q3). `scale` carries a per-species +factor (e.g. the charge `Z_j`). `velocity_moment!` is the `W ≡ 1` special case. +""" +function weighted_moment!(M, g, W, grid::IslandGrid; scale=1, accumulate::Bool=false) + accumulate || fill!(M, zero(eltype(M))) + wy = grid.y.wq + wE = grid.E.weights + nx, nξ, ny, nE, nσ = size(g) + size(W) == (ny, nE, nσ) || throw(ArgumentError("weight W must have size (ny, nE, nσ) = $((ny, nE, nσ))")) + @inbounds for iσ in 1:nσ, iE in 1:nE, iy in 1:ny + w = scale * wy[iy] * wE[iE] * W[iy, iE, iσ] + for iξ in 1:nξ, ix in 1:nx + M[ix, iξ] += w * g[ix, iξ, iy, iE, iσ] + end + end + return M +end + +# --------------------------------------------------------------------------- +# Far-field boundary conditions (01 §3, 04 §1): match g and Φ to supplied +# far-field states at |x| = L_x. NEVER bare Neumann — L23 traced its spurious +# "winged" solution branch to Neumann non-uniqueness; the physical far field is +# the neoclassical (no-island) solution, which is [VERIFY]-gated physics and +# therefore *supplied* here, not computed. +# --------------------------------------------------------------------------- +""" + FarFieldConditions(g_left, g_right, Φ_left, Φ_right) + +Dirichlet-type far-field matching data at the two radial boundaries (`01 §3`): +`g → g_far` and `Φ̂ → Φ̂_far` as `|x| → L_x`. The residual rows at `ix = 1, nx` +are replaced by `(U − far)` so the Newton solve pins the far field. The +neoclassical no-island `g_far` is gated physics (QUESTIONS Q3) — callers supply +it (tests use manufactured far fields). + +## Fields + + - `g_left`, `g_right` — `(nξ, ny, nE, nσ)` far-field distributions at `ix = 1`, `ix = nx`. + - `Φ_left`, `Φ_right` — length-`nξ` far-field potentials at `ix = 1`, `ix = nx`. +""" +struct FarFieldConditions{T,A4<:AbstractArray{T,4},V<:AbstractVector{T}} + g_left::A4 + g_right::A4 + Φ_left::V + Φ_right::V +end + +""" + apply_farfield!(R, U, bc, grid) + +Replace the residual rows at the radial boundaries with the far-field matching +conditions `U − far` (`01 §3`). Called after the operator stack in +[`residual!`](@ref); allocation-free and AD-transparent. +""" +function apply_farfield!(R::IslandState, U::IslandState, bc::FarFieldConditions, grid::IslandGrid) + nx, nξ, ny, nE, nσ = size(U.g) + @inbounds for iσ in 1:nσ, iE in 1:nE, iy in 1:ny, iξ in 1:nξ + R.g[1, iξ, iy, iE, iσ] = U.g[1, iξ, iy, iE, iσ] - bc.g_left[iξ, iy, iE, iσ] + R.g[nx, iξ, iy, iE, iσ] = U.g[nx, iξ, iy, iE, iσ] - bc.g_right[iξ, iy, iE, iσ] + end + @inbounds for iξ in 1:nξ + R.Φ[1, iξ] = U.Φ[1, iξ] - bc.Φ_left[iξ] + R.Φ[nx, iξ] = U.Φ[nx, iξ] - bc.Φ_right[iξ] + end + return R +end + +# --------------------------------------------------------------------------- +# Stack + residual assembly +# --------------------------------------------------------------------------- +""" + IslandStack(kinetic, field) + +A named configuration (`03 §2`): the tuple of kinetic terms acting on `R.g` and +the `field` term (`Quasineutrality`) closing `R.Φ`. Stored as a tuple for type +stability so `residual!` stays allocation-free. + +## Fields + + - `kinetic` — tuple of `AbstractTerm`s contributing to the kinetic residual. + - `field` — the field-equation term. +""" +struct IslandStack{K<:Tuple,F<:AbstractTerm} + kinetic::K + field::F +end + +""" + residual!(R, U, stack, grid, cache) + +Assemble the full residual `R = (R_g, R_Φ)` in place: zero `R`, apply every +kinetic term, then the field term. Allocation-free and AD-transparent. +""" +function residual!(R::IslandState, U::IslandState, stack::IslandStack, grid::IslandGrid, cache::IslandCache) + fill_state!(R, zero(eltype(R))) + _apply_kinetic!(R, stack.kinetic, U, grid, cache) + apply!(R, stack.field, U, grid, cache) + return R +end + +""" + residual!(R, U, stack, grid, cache, bc) + +Residual assembly with far-field boundary conditions: assemble the stack, then +replace the radial-boundary rows with the `FarFieldConditions` matching +residual (`01 §3`). +""" +function residual!(R::IslandState, U::IslandState, stack::IslandStack, grid::IslandGrid, cache::IslandCache, bc::FarFieldConditions) + residual!(R, U, stack, grid, cache) + apply_farfield!(R, U, bc, grid) + return R +end + +# recursive tuple walk keeps the loop type-stable (no dynamic dispatch, no alloc) +@inline _apply_kinetic!(R, ::Tuple{}, U, grid, cache) = R +@inline function _apply_kinetic!(R, terms::Tuple, U, grid, cache) + apply!(R, terms[1], U, grid, cache) + return _apply_kinetic!(R, Base.tail(terms), U, grid, cache) +end + +# --------------------------------------------------------------------------- +# Flatten / unflatten for the Newton–Krylov / JVP interface +# --------------------------------------------------------------------------- +""" + statelength(grid) + +Number of scalar unknowns in the flattened state (`g` plus `Φ`). +""" +function statelength(grid::IslandGrid) + nx, nξ, ny, nE, nσ = nnodes(grid) + return nx * nξ * ny * nE * nσ + nx * nξ +end + +""" + flatten!(v, U) + +Copy state `U` into the flat vector `v` (`g` block then `Φ` block). +""" +function flatten!(v, U::IslandState) + ng = length(U.g) + copyto!(view(v, 1:ng), vec(U.g)) + copyto!(view(v, (ng+1):(ng+length(U.Φ))), vec(U.Φ)) + return v +end + +""" + unflatten!(U, v) + +Copy the flat vector `v` back into state `U`. +""" +function unflatten!(U::IslandState, v) + ng = length(U.g) + copyto!(vec(U.g), view(v, 1:ng)) + copyto!(vec(U.Φ), view(v, (ng+1):(ng+length(U.Φ)))) + return U +end + +""" + g_flat_index(grid, ix, iξ, iy, iE, iσ) + +Flat-state-vector index of `g[ix, iξ, iy, iE, iσ]` under the `flatten!` layout +(`g` block column-major, then `Φ`). Used by the `y_c`-block conditioning +monitor (ladder A8) to address pitch-pencil sub-blocks of the dense Jacobian. +""" +function g_flat_index(grid::IslandGrid, ix::Int, iξ::Int, iy::Int, iE::Int, iσ::Int) + nx, nξ, ny, nE, = nnodes(grid) + return ix + nx * ((iξ - 1) + nξ * ((iy - 1) + ny * ((iE - 1) + nE * (iσ - 1)))) +end + +""" + Φ_flat_index(grid, ix, iξ) + +Flat-state-vector index of `Φ[ix, iξ]` under the `flatten!` layout. +""" +function Φ_flat_index(grid::IslandGrid, ix::Int, iξ::Int) + nx, nξ, ny, nE, nσ = nnodes(grid) + return nx * nξ * ny * nE * nσ + ix + nx * (iξ - 1) +end + +end # module Operators diff --git a/src/Islands/phasespace/PhaseSpace.jl b/src/Islands/phasespace/PhaseSpace.jl new file mode 100644 index 000000000..025132fa8 --- /dev/null +++ b/src/Islands/phasespace/PhaseSpace.jl @@ -0,0 +1,342 @@ +""" + PhaseSpace + +Phase-space grids and discretization operators for the Islands drift-kinetic +solver: the `(x, ξ, λ→y, E, σ)` coordinates of design doc `03 §1` with the +layer-clustered mappings of `04 §1`. + +This module is **pure numerics** — grid node placement, spectral/finite-difference +differentiation matrices, and quadrature weights. It contains no physics +coefficients: nothing here carries a `[VERIFY]`/`[CHECKED]` tag, and the +milestone-M1 discipline (build the discretization, not the physics numbers) +lives one layer up in `Operators`. + +Design-order summary (verified by the MMS ladder A1, `Verify`): + + - `ξ` (helical angle): Fourier pseudo-spectral on the periodic domain `[0, L)` + — exponential convergence for smooth periodic data (`04 §1`). + - `x` (radial) and `y` (pitch): high-order finite differences on a stretched, + layer-clustered grid — algebraic convergence at the requested `order` + (`04 §1`; the layer packing targets `x = 0` and `y = y_c = 1`). + - `E` (energy): Gauss quadrature on the `F₀`-weighted semi-infinite domain + (`04 §1`, Maxwellian weight at Level 0 via Gauss–Laguerre). + - `σ = ±1`: the two `sgn(v_∥)` sheets. +""" +module PhaseSpace + +using LinearAlgebra +import FastGaussQuadrature + +export FourierGrid, MappedFDGrid, GaussGrid, IslandGrid +export nnodes, differentiate_fourier, fd_weights + +# --------------------------------------------------------------------------- +# Finite-difference weights (Fornberg 1988, Math. Comp. 51, 699) — weights for +# derivatives 0..m of a function sampled at arbitrary `nodes`, evaluated at x0. +# Returns `w[j+1, d+1]` = weight of node j for the d-th derivative. +# --------------------------------------------------------------------------- +""" + fd_weights(m, nodes, x0) + +Finite-difference weights for derivatives `0:m` of a function sampled at +`nodes`, evaluated at `x0`, via Fornberg's recurrence. `w[j, d+1]` multiplies +`f(nodes[j])` in the approximation of the `d`-th derivative. Exact for +polynomials up to degree `length(nodes) - 1`. +""" +function fd_weights(m::Int, nodes::AbstractVector{<:Real}, x0::Real) + n = length(nodes) + @assert m < n "need at least m+1 nodes for the m-th derivative" + w = zeros(Float64, n, m + 1) + c1 = 1.0 + c4 = nodes[1] - x0 + w[1, 1] = 1.0 + for i in 2:n + mn = min(i, m + 1) + c2 = 1.0 + c5 = c4 + c4 = nodes[i] - x0 + for j in 1:(i-1) + c3 = nodes[i] - nodes[j] + c2 *= c3 + if j == i - 1 + for k in mn:-1:2 + w[i, k] = c1 * ((k - 1) * w[i-1, k-1] - c5 * w[i-1, k]) / c2 + end + w[i, 1] = -c1 * c5 * w[i-1, 1] / c2 + end + for k in mn:-1:2 + w[j, k] = (c4 * w[j, k] - (k - 1) * w[j, k-1]) / c3 + end + w[j, 1] = c4 * w[j, 1] / c3 + end + c1 = c2 + end + return w +end + +# --------------------------------------------------------------------------- +# ξ: Fourier pseudo-spectral, periodic on [0, L). +# --------------------------------------------------------------------------- +""" + FourierGrid(n; L=2π) + +Uniform periodic grid of `n` nodes on `[0, L)` with the Fourier spectral +first-derivative matrix `D1` (`04 §1`, `ξ` coordinate). `n` must be even. + +## Fields + + - `n` — number of nodes. + - `L` — period length. + - `nodes` — node positions `j·L/n`, `j = 0:n-1`. + - `D1` — `n×n` spectral first-derivative matrix. +""" +struct FourierGrid + n::Int + L::Float64 + nodes::Vector{Float64} + D1::Matrix{Float64} +end + +function FourierGrid(n::Int; L::Real=2π) + iseven(n) || throw(ArgumentError("FourierGrid needs an even node count (got $n)")) + h = 2π / n # computational grid spacing on [0, 2π) + nodes = collect((0:(n-1)) .* (L / n)) + D1 = zeros(Float64, n, n) + @inbounds for i in 1:n, j in 1:n + if i != j + k = i - j + D1[i, j] = 0.5 * (-1.0)^k / tan(k * h / 2) + end + end + D1 .*= (2π / L) # rescale d/dξ from [0,2π) to [0,L) + return FourierGrid(n, Float64(L), nodes, D1) +end + +""" + differentiate_fourier(g, grid) + +Spectral first derivative of a periodic sample vector `g` on `grid` (allocating +convenience wrapper around `grid.D1 * g`; hot paths use the matrix directly). +""" +differentiate_fourier(g::AbstractVector, grid::FourierGrid) = grid.D1 * g + +# --------------------------------------------------------------------------- +# x, y: high-order finite differences on a layer-clustered stretched grid. +# +# A uniform computational coordinate s ∈ [-1, 1] is mapped to the physical +# coordinate by a smooth, monotonic, layer-clustering map; physical derivative +# matrices follow by the chain rule so the FD order is preserved (`04 §1`). +# --------------------------------------------------------------------------- +""" + MappedFDGrid(n; halfwidth, clustering=0.0, center=0.0, domain=:symmetric, order=4) + +High-order finite-difference grid of `n` nodes with layer clustering. + +The uniform computational coordinate `s ∈ [-1, 1]` is mapped to the physical +coordinate by `sinh` stretching (`clustering = β`): points cluster toward the +map center as `β` grows, and the map degenerates to uniform as `β → 0`. This is +the design-doc packing that targets the internal layers — `x = 0` (rational +surface) and `y = y_c = 1` (trapped–passing boundary), `04 §1–2`. + +`domain = :symmetric` gives `[center - halfwidth, center + halfwidth]` with +clustering at `center`; `domain = :half` gives `[0, halfwidth]` with clustering +at `center` (used for `y ∈ [0, y_max]` packed at `y_c`). + +Physical first/second-derivative matrices `D1`, `D2` are built with Fornberg +weights on windows sized per derivative (`order + d` points for the `d`-th +derivative, shifted one-sided near the boundaries) so both reach `order`-th +order accuracy *uniformly*, including at the boundary rows; the chain rule uses +the analytic map Jacobian `x'(s)` and `x''(s)`. + +`wq` holds composite-Simpson quadrature weights on the same nodes (built on the +uniform computational grid and pushed through the map Jacobian), so +`∫ f dx ≈ Σ wq[j] f(nodes[j])` at fourth order — matching the FD order for the +velocity-moment integrals (`03 §2`, moments). Requires an odd `n`. + +## Fields + + - `n`, `order` — node count and nominal FD order. + - `nodes` — physical node positions. + - `D1`, `D2` — `n×n` physical first/second-derivative matrices. + - `wq` — composite-Simpson quadrature weights on `nodes`. +""" +struct MappedFDGrid + n::Int + order::Int + nodes::Vector{Float64} + D1::Matrix{Float64} + D2::Matrix{Float64} + wq::Vector{Float64} +end + +function MappedFDGrid(n::Int; halfwidth::Real, clustering::Real=0.0, center::Real=0.0, + domain::Symbol=:symmetric, order::Int=4) + isodd(n) || throw(ArgumentError("MappedFDGrid needs odd n for composite Simpson (got $n)")) + # widest window is for D2 (order+2 points); need enough nodes for it. + n > order + 2 || throw(ArgumentError("need n > order+2 ($n ≤ $(order + 2))")) + + s = collect(range(-1.0, 1.0; length=n)) # uniform computational grid + hw = Float64(halfwidth) + β = Float64(clustering) + + # Map s → physical, with analytic first/second derivatives of the map. + if domain === :symmetric + # x(s) = center + hw·sinh(β s)/sinh(β): monotone, clusters at s=0 ↔ x=center. + if abs(β) < 1e-12 + xs = center .+ hw .* s + dxds = fill(hw, n) + d2xds2 = zeros(n) + else + sb = sinh(β) + xs = center .+ hw .* sinh.(β .* s) ./ sb + dxds = hw .* β .* cosh.(β .* s) ./ sb + d2xds2 = hw .* β^2 .* sinh.(β .* s) ./ sb + end + elseif domain === :half + # Map [-1,1] → [0, hw] with clustering at s* ↔ x=center via sinh about s*. + # u(s) = sinh(β(s - s*)); x = hw·(u - u(-1))/(u(1) - u(-1)). + sstar = clamp(2 * (center / hw) - 1, -1.0, 1.0) + if abs(β) < 1e-12 + xs = hw .* (s .+ 1) ./ 2 + dxds = fill(hw / 2, n) + d2xds2 = zeros(n) + else + u(z) = sinh(β * (z - sstar)) + um1 = u(-1.0) + span = u(1.0) - um1 + xs = hw .* (u.(s) .- um1) ./ span + dxds = hw .* β .* cosh.(β .* (s .- sstar)) ./ span + d2xds2 = hw .* β^2 .* sinh.(β .* (s .- sstar)) ./ span + end + else + throw(ArgumentError("unknown domain $domain (use :symmetric or :half)")) + end + + D1s = _fd_matrix(s, 1, order) + D2s = _fd_matrix(s, 2, order) + + # Chain rule: d/dx = (1/x')·d/ds ; d²/dx² = (1/x'²)·d²/ds² − (x''/x'³)·d/ds. + invp = 1.0 ./ dxds + D1 = Diagonal(invp) * D1s + D2 = Diagonal(invp .^ 2) * D2s - Diagonal(d2xds2 .* invp .^ 3) * D1s + + # Composite Simpson on uniform s (ds = 2/(n-1)), times the map Jacobian dx/ds. + ds = 2.0 / (n - 1) + wq = similar(dxds) + @inbounds for j in 1:n + c = (j == 1 || j == n) ? 1.0 : (iseven(j) ? 4.0 : 2.0) + wq[j] = c * ds / 3 * dxds[j] + end + + return MappedFDGrid(n, order, xs, Matrix(D1), Matrix(D2), wq) +end + +# Dense derivative matrix for the `deriv`-th derivative on an arbitrary 1D node +# set at the requested `order`, using windows of `order + deriv` points (the +# minimum for `order`-th accuracy of the `deriv`-th derivative), rounded up to +# an odd width so the interior window is centered; near the ends the window is +# shifted one-sided. Weights are Fornberg's, exact for polynomials up to the +# window degree. +function _fd_matrix(nodes::AbstractVector{<:Real}, deriv::Int, order::Int) + n = length(nodes) + stencil = order + deriv + isodd(stencil) || (stencil += 1) # centered interior window needs odd width + stencil = min(stencil, n) + D = zeros(Float64, n, n) + half = stencil ÷ 2 + @inbounds for i in 1:n + lo = clamp(i - half, 1, n - stencil + 1) + hi = lo + stencil - 1 + w = fd_weights(deriv, @view(nodes[lo:hi]), nodes[i]) + for (col, j) in enumerate(lo:hi) + D[i, j] = w[col, deriv+1] + end + end + return D +end + +# --------------------------------------------------------------------------- +# E: Gauss quadrature on the F₀-weighted semi-infinite energy domain. +# --------------------------------------------------------------------------- +""" + GaussGrid(n; kind=:laguerre) + +Gauss quadrature nodes/weights for velocity-space (energy) integrals over the +`F₀`-weighted semi-infinite domain (`04 §1`). `kind = :laguerre` gives the +Level-0 Maxwellian weight `∫₀^∞ f(E) e^{-E} dE ≈ Σ wᵢ f(Eᵢ)` (Gauss–Laguerre); +the slowing-down weight (Level 2) only changes `kind`, not the machinery. + +## Fields + + - `n` — number of quadrature nodes. + - `nodes` — abscissae `Eᵢ`. + - `weights` — quadrature weights `wᵢ` (the `F₀` weight is folded in). +""" +struct GaussGrid + n::Int + nodes::Vector{Float64} + weights::Vector{Float64} +end + +function GaussGrid(n::Int; kind::Symbol=:laguerre) + if kind === :laguerre + nodes, weights = FastGaussQuadrature.gausslaguerre(n) + elseif kind === :legendre + nodes, weights = FastGaussQuadrature.gausslegendre(n) + else + throw(ArgumentError("unknown quadrature kind $kind")) + end + return GaussGrid(n, collect(nodes), collect(weights)) +end + +# --------------------------------------------------------------------------- +# Bundle: the full Level-0 orbit-averaged 4D phase space (x, ξ, y, E) plus σ. +# --------------------------------------------------------------------------- +""" + IslandGrid(; nx, nxi, ny, nE, halfwidth_x, clustering_x, y_max, y_c, + clustering_y, xi_period=2π, energy_kind=:laguerre, order=4) + +The assembled Level-0 phase-space grid: radial `x`, helical `ξ`, pitch `y`, +energy `E`, and the two `σ = ±1` sheets (`03 §1`). Grids are packed at the +internal layers (`x = 0`, `y = y_c`) per `04 §1`. + +## Fields + + - `x` — radial `MappedFDGrid` (clustered at `x = 0`). + - `ξ` — helical `FourierGrid`. + - `y` — pitch `MappedFDGrid` on `[0, y_max]` (clustered at `y_c`). + - `E` — energy `GaussGrid`. + - `σ` — `[+1.0, -1.0]`. + - `y_c` — trapped–passing boundary location in `y` (the layer the pitch grid + packs toward; consumed by the `y_c`-block conditioning monitor, ladder A8). +""" +struct IslandGrid + x::MappedFDGrid + ξ::FourierGrid + y::MappedFDGrid + E::GaussGrid + σ::Vector{Float64} + y_c::Float64 +end + +function IslandGrid(; nx::Int, nxi::Int, ny::Int, nE::Int, + halfwidth_x::Real, clustering_x::Real=0.0, + y_max::Real, y_c::Real=1.0, clustering_y::Real=0.0, + xi_period::Real=2π, energy_kind::Symbol=:laguerre, order::Int=4) + x = MappedFDGrid(nx; halfwidth=halfwidth_x, clustering=clustering_x, center=0.0, + domain=:symmetric, order=order) + ξ = FourierGrid(nxi; L=xi_period) + y = MappedFDGrid(ny; halfwidth=y_max, clustering=clustering_y, center=y_c, + domain=:half, order=order) + E = GaussGrid(nE; kind=energy_kind) + return IslandGrid(x, ξ, y, E, [1.0, -1.0], Float64(y_c)) +end + +""" + nnodes(grid::IslandGrid) + +Tuple `(nx, nξ, ny, nE, nσ)` of the phase-space grid dimensions. +""" +nnodes(g::IslandGrid) = (g.x.n, g.ξ.n, g.y.n, g.E.n, length(g.σ)) + +end # module PhaseSpace diff --git a/src/Islands/solvers/Solvers.jl b/src/Islands/solvers/Solvers.jl new file mode 100644 index 000000000..2b4799cf8 --- /dev/null +++ b/src/Islands/solvers/Solvers.jl @@ -0,0 +1,331 @@ +""" + Solvers + +The Islands steady-state solve (design `03 §3`, `04 §5`, Decision D2): +matrix-free **Newton–Krylov** — never initial-value time-stepping, never nested +Picard loops (kokuchou's Picard criterion was *never met* in production; the +sources' Φ-outer/ū_∥i-inner iteration is the explicit anti-pattern). + +Pieces: + + - [`flat_residual`](@ref) — wrap an operator stack (+ optional far-field BCs) + as a flat-vector residual `f!(out, u)`, generic over `eltype` with + preallocated real and dual workspaces. + - [`JVPOperator`](@ref) — the exact Jacobian–vector product via ForwardDiff + duals through the stack (`04 §5`); no global sparse Jacobian is ever formed + except in [`dense_jacobian`](@ref) tiny-grid debug mode. + - [`YBlockJacobi`](@ref) — the physics-block preconditioner skeleton: per-pitch- + pencil blocks with **TSVD-regularized** solves, the explicit `y_c`-matching + treatment of `04 §3` (GMRES must never resolve the near-singular directions + itself). + - [`newton_krylov`](@ref) — inexact Newton with Eisenstat–Walker forcing and + a backtracking line search. + - [`pseudo_arclength`](@ref) — the continuation scaffold with fold detection + from day one (`03 §3`: the Level-3 penetration bifurcation is a fold; the + solver must step around folds, not fail at them). + +Pure numerics — no physics coefficients (the stack it solves carries the gated +parameters). +""" +module Solvers + +using LinearAlgebra +import Krylov +import ForwardDiff +import ..PhaseSpace: IslandGrid, nnodes +import ..Operators: IslandState, IslandCache, IslandStack, FarFieldConditions, + residual!, flatten!, unflatten!, statelength + +export flat_residual, JVPOperator, dense_jacobian +export YBlockJacobi, newton_krylov, pseudo_arclength + +# Tag for the solver's ForwardDiff duals (one directional derivative). +struct JVPTag end +const JVPDual = ForwardDiff.Dual{JVPTag,Float64,1} + +# --------------------------------------------------------------------------- +# Flat residual wrapper +# --------------------------------------------------------------------------- +""" + flat_residual(stack, grid; bc=nothing) + +Wrap `residual!` for `stack` on `grid` (with optional `FarFieldConditions`) as +a flat-vector function `f!(out, u)` usable by [`newton_krylov`](@ref) and +[`JVPOperator`](@ref). Real (`Float64`) and dual workspaces are preallocated so +repeated evaluations allocate nothing. +""" +function flat_residual(stack::IslandStack, grid::IslandGrid; bc::Union{Nothing,FarFieldConditions}=nothing) + U = IslandState(grid) + R = IslandState(grid) + cache = IslandCache(grid) + Ud = IslandState{JVPDual}(grid) + Rd = IslandState{JVPDual}(grid) + cached = IslandCache{JVPDual}(grid) + function f!(out, u) + if eltype(u) === Float64 + unflatten!(U, u) + bc === nothing ? residual!(R, U, stack, grid, cache) : residual!(R, U, stack, grid, cache, bc) + flatten!(out, R) + else + unflatten!(Ud, u) + bc === nothing ? residual!(Rd, Ud, stack, grid, cached) : residual!(Rd, Ud, stack, grid, cached, bc) + flatten!(out, Rd) + end + return out + end + return f! +end + +# --------------------------------------------------------------------------- +# Matrix-free Jacobian-vector product +# --------------------------------------------------------------------------- +""" + JVPOperator(f!, u) + +Matrix-free linear operator for the Jacobian of `f!` at the point `u` (which is +**aliased**, so updating `u` in place moves the linearization point): `mul!(y, J, v)` computes the exact directional derivative via one dual-mode evaluation +of `f!` (`04 §5`). Satisfies the `mul!`/`size`/`eltype` interface Krylov.jl +expects. +""" +struct JVPOperator{F} + f!::F + u::Vector{Float64} + udual::Vector{JVPDual} + rdual::Vector{JVPDual} +end + +function JVPOperator(f!, u::Vector{Float64}) + n = length(u) + return JVPOperator(f!, u, Vector{JVPDual}(undef, n), Vector{JVPDual}(undef, n)) +end + +Base.size(J::JVPOperator) = (length(J.u), length(J.u)) +Base.size(J::JVPOperator, d::Int) = d <= 2 ? length(J.u) : 1 +Base.eltype(::JVPOperator) = Float64 + +function LinearAlgebra.mul!(y::AbstractVector, J::JVPOperator, v::AbstractVector) + @inbounds for i in eachindex(J.u) + J.udual[i] = JVPDual(J.u[i], ForwardDiff.Partials((v[i],))) + end + J.f!(J.rdual, J.udual) + @inbounds for i in eachindex(y) + y[i] = ForwardDiff.partials(J.rdual[i])[1] + end + return y +end + +""" + dense_jacobian(f!, u) + +Dense Jacobian of `f!` at `u` by column-wise dual sweeps — **tiny-grid debug +mode only** (`04 §5`): used for eigenvalue/conditioning diagnostics (the +`y_c`-block singular-value monitor, ladder A8), never in the production solve. +""" +function dense_jacobian(f!, u::Vector{Float64}) + n = length(u) + J = zeros(n, n) + ud = Vector{JVPDual}(undef, n) + rd = Vector{JVPDual}(undef, n) + for j in 1:n + @inbounds for i in 1:n + ud[i] = JVPDual(u[i], ForwardDiff.Partials((i == j ? 1.0 : 0.0,))) + end + f!(rd, ud) + @inbounds for i in 1:n + J[i, j] = ForwardDiff.partials(rd[i])[1] + end + end + return J +end + +# --------------------------------------------------------------------------- +# Physics-block preconditioner skeleton (04 §3, §5) +# --------------------------------------------------------------------------- +""" + YBlockJacobi(grid, block; svd_cutoff=1e-10, phi_scale=1.0) + +Block-Jacobi preconditioner over pitch pencils — the skeleton of the `04 §5` +physics-block preconditioner. `block(ix, iξ, iE, iσ)` returns the `ny × ny` +stiff-direction block for that pencil (typically the identity-plus-collisions +operator); each block is factored by SVD and **truncated below +`svd_cutoff · σ_max`** — the explicit, regularized `y_c`-matching treatment of +`04 §3` (kokuchou's rcond ≈ 1e-16 block gave machine-dependent noise under +plain LU; TSVD is the documented fix). `phi_scale` is the diagonal applied to +the `Φ` rows. Apply via `ldiv!` (pass as `N=...` with `ldiv=true` to GMRES). +""" +struct YBlockJacobi + grid::IslandGrid + pinvs::Array{Matrix{Float64},4} + phi_scale::Float64 +end + +function YBlockJacobi(grid::IslandGrid, block; svd_cutoff::Real=1e-10, phi_scale::Real=1.0) + nx, nξ, ny, nE, nσ = nnodes(grid) + pinvs = Array{Matrix{Float64},4}(undef, nx, nξ, nE, nσ) + for iσ in 1:nσ, iE in 1:nE, iξ in 1:nξ, ix in 1:nx + B = Matrix{Float64}(block(ix, iξ, iE, iσ)) + size(B) == (ny, ny) || throw(ArgumentError("block must be ny×ny = ($ny, $ny)")) + F = svd(B) + smax = F.S[1] + sinv = [s > svd_cutoff * smax ? 1.0 / s : 0.0 for s in F.S] + pinvs[ix, iξ, iE, iσ] = F.V * Diagonal(sinv) * F.U' + end + return YBlockJacobi(grid, pinvs, Float64(phi_scale)) +end + +function LinearAlgebra.ldiv!(y::AbstractVector, P::YBlockJacobi, x::AbstractVector) + nx, nξ, ny, nE, nσ = nnodes(P.grid) + ng = nx * nξ * ny * nE * nσ + xg = reshape(view(x, 1:ng), nx, nξ, ny, nE, nσ) + yg = reshape(view(y, 1:ng), nx, nξ, ny, nE, nσ) + @inbounds for iσ in 1:nσ, iE in 1:nE, iξ in 1:nξ, ix in 1:nx + Pi = P.pinvs[ix, iξ, iE, iσ] + for a in 1:ny + acc = 0.0 + for b in 1:ny + acc += Pi[a, b] * xg[ix, iξ, b, iE, iσ] + end + yg[ix, iξ, a, iE, iσ] = acc + end + end + @inbounds for i in (ng+1):length(x) + y[i] = x[i] / P.phi_scale + end + return y +end + +# --------------------------------------------------------------------------- +# Inexact Newton-Krylov +# --------------------------------------------------------------------------- +""" + newton_krylov(f!, u0; rtol=1e-8, atol=1e-10, max_iter=25, precond=nothing, + eta0=0.5, eta_max=0.9, gamma=0.9, ls_max=10, memory=30, + verbose=false) + +Matrix-free inexact Newton–Krylov solve of `f!(out, u) = 0` (design `03 §3`, +`04 §5`, Decision D2): + + - GMRES (Krylov.jl) on the ForwardDiff [`JVPOperator`](@ref), with the + **Eisenstat–Walker** (choice-2) forcing term `η_k = γ(‖F_k‖/‖F_{k−1}‖)²` so + early Newton steps do not over-solve the linear system; + - a backtracking (Armijo-on-`‖F‖`) line search; + - optional right preconditioner `precond` applied via `ldiv!` (e.g. + [`YBlockJacobi`](@ref)). + +Convergence is declared on **both** the global residual norm and its max-norm +(`04 §5`: the array-averaged residual hid locally divergent regions in the +prior art). Returns a NamedTuple `(u, converged, iterations, residual_norms, residual_max, gmres_iters)` — the total Krylov iteration count is a tracked +preconditioner-quality metric (`04 §5`), not folklore. +""" +function newton_krylov(f!, u0::AbstractVector{Float64}; + rtol::Real=1e-8, atol::Real=1e-10, max_iter::Int=25, precond=nothing, + eta0::Real=0.5, eta_max::Real=0.9, gamma::Real=0.9, ls_max::Int=10, + memory::Int=30, verbose::Bool=false) + u = collect(u0) + n = length(u) + F = similar(u) + f!(F, u) + nF = norm(F) + nF0 = max(nF, eps()) + tol = max(atol, rtol * nF0) + hist = Float64[nF] + J = JVPOperator(f!, u) # aliases u: updates move the linearization point + Ftrial = similar(u) + utrial = similar(u) + eta = float(eta0) + nF_prev = nF + k = 0 + gmres_total = 0 + while nF > tol && k < max_iter + k += 1 + k > 1 && (eta = clamp(gamma * (nF / nF_prev)^2, 1e-4, eta_max)) + rhs = -F + du, stats = + precond === nothing ? + Krylov.gmres(J, rhs; rtol=eta, atol=0.1 * atol, memory=memory, itmax=10n) : + Krylov.gmres(J, rhs; rtol=eta, atol=0.1 * atol, memory=memory, itmax=10n, N=precond, ldiv=true) + gmres_total += stats.niter + verbose && @info "newton iter $k: ‖F‖=$nF, η=$eta, gmres iters=$(stats.niter)" + # backtracking line search on ‖F‖ + λ = 1.0 + nFtrial = Inf + for _ in 1:ls_max + @. utrial = u + λ * du + f!(Ftrial, utrial) + nFtrial = norm(Ftrial) + nFtrial <= (1 - 1e-4 * λ) * nF && break + λ /= 2 + end + nF_prev = nF + if nFtrial < nF + u .= utrial + F .= Ftrial + nF = nFtrial + else + verbose && @warn "newton line search stagnated at iter $k" + break + end + push!(hist, nF) + end + return (u=u, converged=(nF <= tol), iterations=k, residual_norms=hist, residual_max=maximum(abs, F), gmres_iters=gmres_total) +end + +# --------------------------------------------------------------------------- +# Pseudo-arclength continuation scaffold (03 §3) +# --------------------------------------------------------------------------- +""" + pseudo_arclength(f!, u0, p0; ds=0.1, nsteps=10, newton_kwargs...) + +Pseudo-arclength continuation scaffold for a parameterized residual +`f!(out, u, p)` (`03 §3`): predictor along the (secant) tangent, corrector = +[`newton_krylov`](@ref) on the extended system `[F(u, p); t·(z − z_pred)]`, and +**fold detection from day one** via sign changes of the tangent's `p`-component +(the Level-3 penetration bifurcation is a fold in this curve). Returns +`(us, ps, folds)` where `folds` lists the step indices at which `dp/ds` +reversed. +""" +function pseudo_arclength(f!, u0::AbstractVector{Float64}, p0::Real; + ds::Real=0.1, nsteps::Int=10, newton_kwargs...) + n = length(u0) + # converge onto the curve at fixed p0 + sol = newton_krylov((out, u) -> f!(out, u, p0), collect(u0); newton_kwargs...) + sol.converged || error("pseudo_arclength: initial solve at p0 did not converge") + us = [copy(sol.u)] + ps = [float(p0)] + folds = Int[] + # initial tangent from du/dp: J du = -dF/dp (finite difference in p) + Fp = zeros(n) + Fm = zeros(n) + hp = max(1e-7, 1e-7 * abs(p0)) + f!(Fp, sol.u, p0 + hp) + f!(Fm, sol.u, p0 - hp) + dFdp = (Fp .- Fm) ./ (2hp) + Jop = JVPOperator((out, u) -> f!(out, u, p0), copy(sol.u)) + dudp, _ = Krylov.gmres(Jop, -dFdp; rtol=1e-10) + t = vcat(dudp, 1.0) + t ./= norm(t) + tp_prev = t[end] + for step in 1:nsteps + zpred = vcat(us[end], ps[end]) .+ ds .* t + tloc = copy(t) # freeze tangent for this corrector + function ext!(out, z) + f!(view(out, 1:n), view(z, 1:n), z[n+1]) + out[n+1] = LinearAlgebra.dot(tloc, z) - LinearAlgebra.dot(tloc, zpred) + return out + end + sol = newton_krylov(ext!, zpred; newton_kwargs...) + sol.converged || break + push!(us, sol.u[1:n]) + push!(ps, sol.u[n+1]) + # secant tangent for the next step; fold when dp/ds changes sign + t = vcat(us[end] .- us[end-1], ps[end] - ps[end-1]) + t ./= norm(t) + if t[end] * tp_prev < 0 + push!(folds, step) + end + tp_prev = t[end] + end + return (us=us, ps=ps, folds=folds) +end + +end # module Solvers diff --git a/src/Islands/species/Species.jl b/src/Islands/species/Species.jl new file mode 100644 index 000000000..f19481507 --- /dev/null +++ b/src/Islands/species/Species.jl @@ -0,0 +1,170 @@ +""" + SpeciesLists + +Species as a first-class dimension of the Islands solve (design `02 §1`, +Decision D3): every kinetic object is indexed by species, and the species list +is an array from day one even though Level-0 *physics* is single-bulk-ion. + +Pure data structures and bookkeeping — no physics coefficients live here. +Quantities follow the Level-0 normalization (`01 §5`): densities to `n_e`, +temperatures to `T_i`, masses to the reference bulk ion, gradients as inverse +scale lengths at `r_s`. +""" +module SpeciesLists + +export AbstractBackground, Maxwellian, SlowingDown +export SpeciesRole, Bulk, Trace +export Species, is_bulk, is_trace, bulk_species, trace_species +export validate_species, check_trace_criteria + +# --------------------------------------------------------------------------- +# Backgrounds +# --------------------------------------------------------------------------- +""" + AbstractBackground + +Supertype of species background distributions (`02 §1.1`). `Maxwellian` is the +Level-0 background; `SlowingDown` enters at Level 2 (energetic particles) and +changes the energy-grid weight map, not the machinery. +""" +abstract type AbstractBackground end + +""" + Maxwellian(; n, T, dlnn_dr, dlnT_dr) + +Maxwellian background at the rational surface `r_s` (`02 §1.1`), carrying the +strictly-local constant gradients of ordering O2. + +## Fields + + - `n` — density normalized to `n_e`. + - `T` — temperature normalized to `T_i`. + - `dlnn_dr` — inverse density scale length `L̂_n⁻¹` at `r_s`. + - `dlnT_dr` — inverse temperature scale length `L̂_T⁻¹` at `r_s`. +""" +Base.@kwdef struct Maxwellian <: AbstractBackground + n::Float64 + T::Float64 + dlnn_dr::Float64 = 0.0 + dlnT_dr::Float64 = 0.0 +end + +""" + SlowingDown(; S0, v_birth, v_crit, dlnS_dr) + +Slowing-down background (isotropic at Level-2 entry, `02 §3`). Present now so +the background abstraction is exercised from day one; the Level-2 physics that +consumes it is out of Level-0 scope. + +## Fields + + - `S0` — source rate normalization. + - `v_birth` — birth speed (normalized to the reference thermal speed). + - `v_crit` — critical speed (from `T_e` and composition). + - `dlnS_dr` — inverse source scale length at `r_s`. +""" +Base.@kwdef struct SlowingDown <: AbstractBackground + S0::Float64 + v_birth::Float64 + v_crit::Float64 + dlnS_dr::Float64 = 0.0 +end + +# --------------------------------------------------------------------------- +# Roles and the species struct +# --------------------------------------------------------------------------- +""" + SpeciesRole + +`Bulk` = full nonlinear participant (its `g` enters the Newton state vector). +`Trace` = linear post-processing pass with frozen bulk fields (`02 §1.2`) — +one linear solve, an additive contribution to `Δ_cos`/`Δ_sin`. +""" +@enum SpeciesRole Bulk Trace + +""" + Species(; name, Z, m, background, role, collisional_coupling=false) + +One species of the Islands solve (`02 §1.1`). + +## Fields + + - `name` — identifying symbol (unique within a list). + - `Z` — charge number (`Float64` to allow mean-charge-state impurities). + - `m` — mass ratio to the reference bulk ion. + - `background` — an `AbstractBackground` (Maxwellian at Level 0). + - `role` — `Bulk` or `Trace` (`02 §1.2`). + - `collisional_coupling` — whether the bulk feels this species' field-particle + back-reaction (`02 §1.3`; L1+ physics, plumbing present from day one). +""" +Base.@kwdef struct Species{B<:AbstractBackground} + name::Symbol + Z::Float64 + m::Float64 + background::B + role::SpeciesRole = Bulk + collisional_coupling::Bool = false +end + +is_bulk(sp::Species) = sp.role == Bulk +is_trace(sp::Species) = sp.role == Trace + +""" + bulk_species(list) / trace_species(list) + +Filter a species list by role. +""" +bulk_species(list::AbstractVector{<:Species}) = filter(is_bulk, list) +trace_species(list::AbstractVector{<:Species}) = filter(is_trace, list) + +# --------------------------------------------------------------------------- +# Validation and the trace-criteria check +# --------------------------------------------------------------------------- +""" + validate_species(list) + +Structural validation of a species list: unique names, positive `Z`-magnitude, +`m`, density and temperature, and at least one `Bulk` species. Throws +`ArgumentError` on violation; returns the list for chaining. +""" +function validate_species(list::AbstractVector{<:Species}) + isempty(list) && throw(ArgumentError("species list is empty")) + names = [sp.name for sp in list] + length(unique(names)) == length(names) || throw(ArgumentError("duplicate species names: $names")) + any(is_bulk, list) || throw(ArgumentError("species list needs at least one Bulk species")) + for sp in list + sp.m > 0 || throw(ArgumentError("species $(sp.name): mass ratio must be positive")) + abs(sp.Z) > 0 || throw(ArgumentError("species $(sp.name): charge must be nonzero")) + if sp.background isa Maxwellian + sp.background.n > 0 || throw(ArgumentError("species $(sp.name): density must be positive")) + sp.background.T > 0 || throw(ArgumentError("species $(sp.name): temperature must be positive")) + end + end + return list +end + +""" + check_trace_criteria(list; threshold=0.05) + +Check every `Trace` species against both trace criteria (`02 §1.2`): charge +trace (`n_j Z_j ≪ n_e`) and current trace (`n_j ≪ n_e`), with densities already +normalized to `n_e`. A violating species gets an `@warn` recommending `Bulk` +promotion — **warn, never silently degrade** (`02 §1.2` promotion rule). +`threshold` is a diagnostic heuristic knob for "≪", not a physics coefficient. +Returns the vector of violating species names (empty when clean). +""" +function check_trace_criteria(list::AbstractVector{<:Species}; threshold::Real=0.05) + violators = Symbol[] + for sp in trace_species(list) + sp.background isa Maxwellian || continue + n = sp.background.n + charge_frac = n * abs(sp.Z) + if charge_frac > threshold || n > threshold + push!(violators, sp.name) + @warn "species $(sp.name) violates trace criteria (n Z = $(charge_frac), n = $(n) vs threshold $(threshold)); run it as Bulk" maxlog = 1 + end + end + return violators +end + +end # module SpeciesLists diff --git a/src/Islands/verify/Verify.jl b/src/Islands/verify/Verify.jl new file mode 100644 index 000000000..a7274e9f4 --- /dev/null +++ b/src/Islands/verify/Verify.jl @@ -0,0 +1,616 @@ +""" + Verify + +The Islands verification harness (`04 §4`, `05 §A`): manufactured-solution (MMS) +convergence checks and AD-vs-finite-difference JVP checks for the operator +stack. Callable from the test suite (`test/runtests_islands_*.jl`) and from +scripts. + +**These are structural (pre-physics) checks — ladder A1/A2.** They exercise the +*discretization order* and the *AD plumbing* using arbitrary, order-unity +manufactured coefficients. That is deliberate and policy-clean: nothing here is +a physics coefficient, so nothing here carries a `[VERIFY]` tag. Physics +benchmarks (ladder B+) live in `benchmarks/islands/` and stay `[VERIFY]`-gated +until human-cleared. + +Design orders checked: + + - `ξ` derivatives — Fourier spectral (a bandlimited manufactured `ξ`-profile is + differentiated to machine precision). + - `x`, `y` derivatives — high-order finite differences at the grid `order` + (default 4): halving the mesh cuts the error by `2^order`. + - assembled kinetic residual — the min of the above (algebraic, set by `x`/`y`). +""" +module Verify + +using LinearAlgebra +import ForwardDiff +import ..PhaseSpace: IslandGrid, MappedFDGrid, GaussGrid, nnodes +import ..Operators: IslandState, IslandCache, IslandStack, residual!, velocity_moment!, + apply!, statelength, flatten!, unflatten!, g_flat_index, + ParallelStreaming, MagneticDrift, ExBDrift, Collisions, GradientDrive, + PerpTransport, RadiationSink, Quasineutrality, FarFieldConditions +import ..Solvers: flat_residual, newton_krylov + +export manufactured_state, + test_coefficients, build_stack, + mms_operator_error, mms_assembled_error, estimate_order, + jvp_fd_maxerror, moment_selfconvergence, + term_allocations, residual_allocations, + yc_block_sigma_min, solve_mms, zero_drive_setup +export ladder_status, write_state_dashboard +export check_anchor_sync + +# --------------------------------------------------------------------------- +# Manufactured solution: smooth, separable, ξ-periodic and bandlimited. +# Each factor's analytic first/second derivatives are known in closed form, +# so the continuous value of every operator is exact at the nodes. +# --------------------------------------------------------------------------- +_Gx(x) = exp(-x^2 / 2) +_Gx′(x) = -x * _Gx(x) +_Gx″(x) = (x^2 - 1) * _Gx(x) +_Gξ(ξ) = sin(ξ) + 0.5 * cos(2ξ) +_Gξ′(ξ) = cos(ξ) - sin(2ξ) +_Gy(y) = exp(-(y - 1)^2 / 2) +_Gy′(y) = -(y - 1) * _Gy(y) +_Gy″(y) = ((y - 1)^2 - 1) * _Gy(y) +_GE(E) = exp(-0.3 * E) +_Gσ(σ) = 1 + 0.25 * σ + +_Px(x) = exp(-x^2 / 4) +_Px′(x) = -(x / 2) * _Px(x) +_Pξ(ξ) = cos(ξ) +_Pξ′(ξ) = -sin(ξ) + +# Fill a 5D array over (x, ξ, y, E, σ) from a scalar function of the node coords. +function _fill5(grid::IslandGrid, f) + nx, nξ, ny, nE, nσ = nnodes(grid) + A = Array{Float64}(undef, nx, nξ, ny, nE, nσ) + @inbounds for iσ in 1:nσ, iE in 1:nE, iy in 1:ny, iξ in 1:nξ, ix in 1:nx + A[ix, iξ, iy, iE, iσ] = f(grid.x.nodes[ix], grid.ξ.nodes[iξ], grid.y.nodes[iy], + grid.E.nodes[iE], grid.σ[iσ]) + end + return A +end + +function _fill2(grid::IslandGrid, f) + nx, nξ, = nnodes(grid) + A = Array{Float64}(undef, nx, nξ) + @inbounds for iξ in 1:nξ, ix in 1:nx + A[ix, iξ] = f(grid.x.nodes[ix], grid.ξ.nodes[iξ]) + end + return A +end + +""" + manufactured_state(grid) + +Return `(U, deriv)` where `U::IslandState` holds the manufactured `g*`, `Φ*` on +`grid` and `deriv` is a NamedTuple of the analytic partial-derivative arrays +(`dgdx`, `dgdξ`, `dgdy`, `d2gdy2`, `d2gdx2`, `dΦdx`, `dΦdξ`) used to form exact +continuous operator values. +""" +function manufactured_state(grid::IslandGrid) + g = _fill5(grid, (x, ξ, y, E, σ) -> _Gx(x) * _Gξ(ξ) * _Gy(y) * _GE(E) * _Gσ(σ)) + Φ = _fill2(grid, (x, ξ) -> _Px(x) * _Pξ(ξ)) + U = IslandState(g, Φ) + deriv = ( + dgdx=_fill5(grid, (x, ξ, y, E, σ) -> _Gx′(x) * _Gξ(ξ) * _Gy(y) * _GE(E) * _Gσ(σ)), + dgdξ=_fill5(grid, (x, ξ, y, E, σ) -> _Gx(x) * _Gξ′(ξ) * _Gy(y) * _GE(E) * _Gσ(σ)), + dgdy=_fill5(grid, (x, ξ, y, E, σ) -> _Gx(x) * _Gξ(ξ) * _Gy′(y) * _GE(E) * _Gσ(σ)), + d2gdy2=_fill5(grid, (x, ξ, y, E, σ) -> _Gx(x) * _Gξ(ξ) * _Gy″(y) * _GE(E) * _Gσ(σ)), + d2gdx2=_fill5(grid, (x, ξ, y, E, σ) -> _Gx″(x) * _Gξ(ξ) * _Gy(y) * _GE(E) * _Gσ(σ)), + dΦdx=_fill2(grid, (x, ξ) -> _Px′(x) * _Pξ(ξ)), + dΦdξ=_fill2(grid, (x, ξ) -> _Px(x) * _Pξ′(ξ)) + ) + return U, deriv +end + +# --------------------------------------------------------------------------- +# Test coefficients: arbitrary, smooth, order-unity. NOT physics — they exercise +# the discretization. `a_y > 0` keeps the pitch operator a sensible diffusion. +# --------------------------------------------------------------------------- +""" + test_coefficients(grid) + +Build a NamedTuple of arbitrary smooth manufactured coefficient arrays/scalars +for every term (`a_xi`, `a_x`, `c_D`, `a_y`, `b_y`, `drive`, `c_E`, `χ`, `α`). +These are MMS test values, not physics coefficients. +""" +function test_coefficients(grid::IslandGrid) + return ( + a_xi=_fill5(grid, (x, ξ, y, E, σ) -> 1.0 + 0.2 * cos(ξ) + 0.1 * x), + a_x=_fill5(grid, (x, ξ, y, E, σ) -> 0.8 + 0.1 * sin(2ξ) + 0.05 * y), + c_D=_fill5(grid, (x, ξ, y, E, σ) -> 0.5 * σ * (1 + 0.1 * E)), + a_y=_fill5(grid, (x, ξ, y, E, σ) -> 0.3 + 0.05 * x^2), + b_y=_fill5(grid, (x, ξ, y, E, σ) -> 0.1 * (y - 1)), + drive=_fill5(grid, (x, ξ, y, E, σ) -> 0.2 * exp(-x^2) * cos(ξ)), + c_E=0.7, + χ=0.15, + α=1.3 + ) +end + +# --------------------------------------------------------------------------- +# Continuous (exact) operator values from the analytic manufactured derivatives. +# --------------------------------------------------------------------------- +function _continuous(term::Symbol, deriv, c) + if term === :streaming + return c.a_xi .* deriv.dgdξ .+ c.a_x .* deriv.dgdx + elseif term === :drift + return c.c_D .* deriv.dgdξ + elseif term === :collisions + return c.a_y .* deriv.d2gdy2 .+ c.b_y .* deriv.dgdy + elseif term === :perp + return c.χ .* deriv.d2gdx2 + elseif term === :drive + return c.drive + elseif term === :exb + # broadcast the 2D potential gradients over (y, E, σ) + dΦdx = reshape(deriv.dΦdx, size(deriv.dΦdx, 1), size(deriv.dΦdx, 2), 1, 1, 1) + dΦdξ = reshape(deriv.dΦdξ, size(deriv.dΦdξ, 1), size(deriv.dΦdξ, 2), 1, 1, 1) + return c.c_E .* (dΦdξ .* deriv.dgdx .- dΦdx .* deriv.dgdξ) + else + throw(ArgumentError("unknown term $term")) + end +end + +function _term_object(term::Symbol, c) + if term === :streaming + return ParallelStreaming(c.a_xi, c.a_x) + elseif term === :drift + return MagneticDrift(c.c_D; variant=:original) + elseif term === :collisions + return Collisions(c.a_y, c.b_y; model=:pitch_angle) + elseif term === :perp + return PerpTransport(c.χ) + elseif term === :drive + return GradientDrive(c.drive) + elseif term === :exb + return ExBDrift(c.c_E) + else + throw(ArgumentError("unknown term $term")) + end +end + +# --------------------------------------------------------------------------- +# MMS error drivers +# --------------------------------------------------------------------------- +""" + mms_operator_error(grid, term) + +Max-norm error between the discrete `apply!` of a single kinetic `term` +(`:streaming`, `:drift`, `:exb`, `:collisions`, `:perp`, `:drive`) on the +manufactured state and its exact continuous value on `grid`. +""" +function mms_operator_error(grid::IslandGrid, term::Symbol) + U, deriv = manufactured_state(grid) + c = test_coefficients(grid) + R = IslandState(grid) + cache = IslandCache(grid) + apply!(R, _term_object(term, c), U, grid, cache) + exact = _continuous(term, deriv, c) + return maximum(abs, R.g .- exact) +end + +""" + mms_assembled_error(grid; terms=(:streaming,:drift,:exb,:collisions,:perp,:drive)) + +Max-norm error between the assembled discrete kinetic residual (sum of `terms`) +on the manufactured state and the summed continuous values. +""" +function mms_assembled_error(grid::IslandGrid; + terms=(:streaming, :drift, :exb, :collisions, :perp, :drive)) + U, deriv = manufactured_state(grid) + c = test_coefficients(grid) + R = IslandState(grid) + cache = IslandCache(grid) + exact = zeros(size(R.g)) + for t in terms + apply!(R, _term_object(t, c), U, grid, cache) + exact .+= _continuous(t, deriv, c) + end + return maximum(abs, R.g .- exact) +end + +""" + estimate_order(errors, refine) + +Observed convergence order from a sequence of `errors` at mesh-refinement +factors `refine` (ratio of successive node counts): `log(eₖ/eₖ₊₁)/log(rₖ)`. +Returns the vector of per-step orders. +""" +function estimate_order(errors::AbstractVector, refine::AbstractVector) + return [log(errors[k] / errors[k+1]) / log(refine[k]) for k in 1:(length(errors)-1)] +end + +# --------------------------------------------------------------------------- +# Assembled stack for the JVP check (includes the E×B nonlinearity + field). +# --------------------------------------------------------------------------- +""" + build_stack(grid; coeffs=test_coefficients(grid)) + +Assemble an `IslandStack` with the full Level-0-shaped term set (streaming, +drift, E×B, collisions, drive) plus the quasineutrality field term, using the +supplied (manufactured) coefficients. Used by the JVP check. +""" +function build_stack(grid::IslandGrid; coeffs=test_coefficients(grid)) + kinetic = ( + ParallelStreaming(coeffs.a_xi, coeffs.a_x), + MagneticDrift(coeffs.c_D; variant=:original), + ExBDrift(coeffs.c_E), + Collisions(coeffs.a_y, coeffs.b_y; model=:pitch_angle), + GradientDrive(coeffs.drive) + ) + return IslandStack(kinetic, Quasineutrality(coeffs.α)) +end + +""" + jvp_fd_maxerror(grid; h=1e-6, seed=1) + +Compare the forward-mode-AD Jacobian–vector product of the assembled residual +against a central finite-difference directional derivative, at the manufactured +state in a deterministic direction. Returns the max-norm difference (ladder A2). +The residual is nonlinear (E×B bracket + g/Φ coupling), so this is a genuine +check of both the AD plumbing and its correctness. +""" +function jvp_fd_maxerror(grid::IslandGrid; h::Float64=1e-6, seed::Int=1) + stack = build_stack(grid) + cache_f = IslandCache{Float64}(grid) + U, = manufactured_state(grid) + + N = statelength(grid) + u0 = Vector{Float64}(undef, N) + flatten!(u0, U) + # deterministic direction (no RNG dependence) + v = Float64[0.5 + 0.5 * sin(seed * k) for k in 1:N] + v ./= norm(v) + + # residual as a flat-vector function, generic over eltype + function resid_vec!(out, uvec) + T = eltype(uvec) + Ut = IslandState(reshape(view(uvec, 1:length(U.g)), size(U.g)), + reshape(view(uvec, (length(U.g)+1):length(uvec)), size(U.Φ))) + Rt = IslandState(reshape(view(out, 1:length(U.g)), size(U.g)), + reshape(view(out, (length(U.g)+1):length(out)), size(U.Φ))) + cache = IslandCache{T}(grid) + residual!(Rt, Ut, stack, grid, cache) + return out + end + + # AD JVP: d/dε residual(u0 + ε v) |_{ε=0} + jvp_ad = ForwardDiff.derivative(0.0) do ε + out = Vector{typeof(ε)}(undef, N) + resid_vec!(out, u0 .+ ε .* v) + end + + # central-difference JVP + rp = similar(u0) + rm = similar(u0) + resid_vec!(rp, u0 .+ h .* v) + resid_vec!(rm, u0 .- h .* v) + jvp_fd = (rp .- rm) ./ (2h) + + return maximum(abs, jvp_ad .- jvp_fd) +end + +# --------------------------------------------------------------------------- +# Velocity-moment quadrature self-convergence (Simpson in y, at grid order). +# --------------------------------------------------------------------------- +""" + moment_selfconvergence(grid_coarse, grid_fine) + +Max-norm difference between the velocity moment of the manufactured `g*` on two +`y`-resolutions. The two grids must share `(nx, nξ)` so the moments live on the +same `(x, ξ)` grid and compare pointwise; refine `ny` (and/or `nE`) between them +to probe the `y`-quadrature order. +""" +function moment_selfconvergence(grid_coarse::IslandGrid, grid_fine::IslandGrid) + nnodes(grid_coarse)[1:2] == nnodes(grid_fine)[1:2] || + throw(ArgumentError("moment_selfconvergence needs grids sharing (nx, nξ)")) + Uc, = manufactured_state(grid_coarse) + Uf, = manufactured_state(grid_fine) + Mc = zeros(nnodes(grid_coarse)[1], nnodes(grid_coarse)[2]) + Mf = zeros(nnodes(grid_fine)[1], nnodes(grid_fine)[2]) + velocity_moment!(Mc, Uc.g, grid_coarse) + velocity_moment!(Mf, Uf.g, grid_fine) + return maximum(abs, Mc .- Mf) +end + +# --------------------------------------------------------------------------- +# Allocation probes (hot-path allocation regression, `04 §9`). +# --------------------------------------------------------------------------- +""" + term_allocations(grid, term) + +Bytes allocated by a single warmed `apply!` of kinetic `term` on `grid`. Should +be zero for the allocation-free hot path. +""" +function term_allocations(grid::IslandGrid, term::Symbol) + c = test_coefficients(grid) + U, = manufactured_state(grid) + R = IslandState(grid) + cache = IslandCache(grid) + t = _term_object(term, c) + apply!(R, t, U, grid, cache) # warm up compilation + return @allocated apply!(R, t, U, grid, cache) +end + +""" + residual_allocations(grid; coeffs=test_coefficients(grid)) + +Bytes allocated by a single warmed full-`residual!` assembly on `grid`. Should +be zero for the allocation-free hot path. +""" +function residual_allocations(grid::IslandGrid; coeffs=test_coefficients(grid)) + stack = build_stack(grid; coeffs=coeffs) + U, = manufactured_state(grid) + R = IslandState(grid) + cache = IslandCache(grid) + residual!(R, U, stack, grid, cache) # warm up compilation + return @allocated residual!(R, U, stack, grid, cache) +end + +# --------------------------------------------------------------------------- +# Solve-level MMS configurations (ladder A1-solve, A5) — the manufactured +# configurations live here per 04 §4 so tests and benchmark scripts share them. +# --------------------------------------------------------------------------- +""" + zero_drive_setup(grid) + +The ladder-A5 configuration: the manufactured advective stack with **all drives +off** (no `GradientDrive`, no source), so `g ≡ 0, Φ ≡ 0` is the exact solution +and the residual there is machine zero (`01 §6`). Returns `(f, N)` — the flat +residual function and state length — for the null test and the trivial-Newton +check. The `RadiationSink(−1)` entry is a **unit relaxation shift** (a +manufactured test coefficient, not physics) making the zero state locally unique. +""" +function zero_drive_setup(grid::IslandGrid) + c = test_coefficients(grid) + shift = fill(-1.0, size(c.drive)) + kin = (ParallelStreaming(c.a_xi, c.a_x), MagneticDrift(c.c_D; variant=:original), + ExBDrift(c.c_E), Collisions(c.a_y, c.b_y; model=:pitch_angle), RadiationSink(shift)) + stack = IslandStack(kin, Quasineutrality(c.α)) + return (f=flat_residual(stack, grid), N=statelength(grid)) +end + +""" + solve_mms(nx; nxi=8, ny=9, nE=2, rtol=1e-10, memory=300) + +The assembled **solve-level** MMS (the ladder-A1 extension to a full converged +Newton–Krylov solve): the advective manufactured stack (streaming + drift + +E×B + unit relaxation shift + quasineutrality) with far-field matching BCs +taken from the manufactured state, forced by the *analytic* continuous source +so the discrete solution differs from `g*` by the discretization error. The +first-order-in-`x` stack needs only the `x` far-field conditions (the pitch +operator's degenerate zero-flux structure is exercised separately, ladder A4). +Returns `(err, converged, iterations, gmres_iters)` where `err` is the max-norm +solution error against the manufactured state — refining `nx` must show the +design order. +""" +function solve_mms(nx::Int; nxi::Int=8, ny::Int=9, nE::Int=2, rtol::Real=1e-10, memory::Int=300) + grid = IslandGrid(; nx=nx, nxi=nxi, ny=ny, nE=nE, halfwidth_x=6.0, clustering_x=1.0, + y_max=4.0, y_c=1.0, clustering_y=0.8, order=4) + c = test_coefficients(grid) + shift = fill(-1.0, size(c.drive)) + kin = (ParallelStreaming(c.a_xi, c.a_x), MagneticDrift(c.c_D; variant=:original), + ExBDrift(c.c_E), RadiationSink(shift)) + stack = IslandStack(kin, Quasineutrality(c.α)) + Ustar, deriv = manufactured_state(grid) + bc = FarFieldConditions(Ustar.g[1, :, :, :, :], Ustar.g[nx, :, :, :, :], Ustar.Φ[1, :], Ustar.Φ[nx, :]) + f0! = flat_residual(stack, grid; bc=bc) + N = statelength(grid) + # continuous source: the analytic values of every active term at (g*, Φ*) + Sg = c.a_xi .* deriv.dgdξ .+ c.a_x .* deriv.dgdx .+ c.c_D .* deriv.dgdξ .+ Ustar.g + dPx = reshape(deriv.dΦdx, nx, nxi, 1, 1, 1) + dPξ = reshape(deriv.dΦdξ, nx, nxi, 1, 1, 1) + Sg .+= c.c_E .* (dPξ .* deriv.dgdx .- dPx .* deriv.dgdξ) + Sg[1, :, :, :, :] .= 0.0 + Sg[nx, :, :, :, :] .= 0.0 # BC rows carry no source + MΦ = zeros(nx, nxi) + velocity_moment!(MΦ, Ustar.g, grid) + SΦ = MΦ .- c.α .* Ustar.Φ # field rows: discretely consistent source + SΦ[1, :] .= 0.0 + SΦ[nx, :] .= 0.0 + S = zeros(N) + flatten!(S, IslandState(Sg, SΦ)) + f!(out, u) = (f0!(out, u); out .-= S; out) + sol = newton_krylov(f!, zeros(N); rtol=rtol, atol=1e-13, max_iter=30, memory=memory) + ustar = zeros(N) + flatten!(ustar, Ustar) + return (err=maximum(abs, sol.u .- ustar), converged=sol.converged, + iterations=sol.iterations, gmres_iters=sol.gmres_iters) +end + +# --------------------------------------------------------------------------- +# y_c matching-block conditioning monitor (ladder A8, 04 §3). +# --------------------------------------------------------------------------- +""" + yc_block_sigma_min(J, grid; window=3) + +Ladder-A8 conditioning monitor: the smallest singular value of the pitch-pencil +sub-block of the (tiny-grid debug) Jacobian `J` nearest the trapped–passing +boundary `y_c`, minimized over all `(x, ξ, E, σ)` pencils. The prior art's +`y_c` matching system was intrinsically near-singular (rcond ≈ 1e-16, L23 §4.2) +and produced machine-dependent **noise, not crashes** — so this must be +*tested for*, not observed. Returns `(sigma_min, pencil)` where `pencil` is the +`(ix, iξ, iE, iσ)` of the minimizing block. +""" +function yc_block_sigma_min(J::AbstractMatrix, grid::IslandGrid; window::Int=3) + nx, nξ, ny, nE, nσ = nnodes(grid) + window <= ny || throw(ArgumentError("window ($window) exceeds ny ($ny)")) + # contiguous y-index window centered on the node nearest y_c + ic = argmin(abs.(grid.y.nodes .- grid.y_c)) + lo = clamp(ic - window ÷ 2, 1, ny - window + 1) + iys = lo:(lo+window-1) + σmin = Inf + pencil = (0, 0, 0, 0) + idx = Vector{Int}(undef, window) + for iσ in 1:nσ, iE in 1:nE, iξ in 1:nξ, ix in 1:nx + for (k, iy) in enumerate(iys) + idx[k] = g_flat_index(grid, ix, iξ, iy, iE, iσ) + end + s = svdvals(J[idx, idx])[end] + if s < σmin + σmin = s + pencil = (ix, iξ, iE, iσ) + end + end + return (sigma_min=σmin, pencil=pencil) +end + +# --------------------------------------------------------------------------- +# State dashboard generator (docs/07 §1.3): the auto-generated status table. +# --------------------------------------------------------------------------- +# The docs/05 ladder as a declarative status spec. `status` is the current +# reality: the A-ladder (structural) is green via the islands test suite; the +# B/C physics ladder is gated on the QUESTIONS clearances (never a physics +# result until human-cleared). This is the artifact the dashboard renders; when +# benchmark runs archive convergence artifacts, they refine the B/C rows. +const _LADDER = ( + (id="A1", tier="structural", target="MMS convergence (ξ spectral; x, y order-4)", status="green", gate="test suite"), + (id="A2", tier="structural", target="AD-vs-FD JVP agreement", status="green", gate="test suite"), + (id="A3", tier="structural", target="Δ_cos even / Δ_sin odd under ξ-reflection", status="green", gate="test suite"), + (id="A4", tier="structural", target="mimetic pitch operator: exact discrete conservation + entropy sign", status="green", gate="test suite"), + (id="A5", tier="structural", target="zero-drive null: g≡0 ⇒ residual machine-zero", status="green", gate="test suite"), + (id="A7", tier="T1", target="coefficient-free closure identity ⟨∂²h/∂x²⟩_Ω = 0", status="green", gate="test suite"), + (id="A8", tier="structural", target="y_c matching-block conditioning monitor", status="green", gate="test suite"), + (id="M2c", tier="structural", target="L0 assembly builds + solves structurally (placeholders)", status="green", gate="test suite"), + (id="B2", tier="T3", target="large-w scalings Δ_bs+Δ_cur ∝ 1/w, Δ_pol ∝ 1/w³", status="gated", gate="QUESTIONS Q5"), + (id="B4", tier="T3", target="Δ_pol ∝ ω_E² + sign-reversal existence", status="gated", gate="QUESTIONS Q3, Q5"), + (id="B5a", tier="T3", target="threshold existence w_c ~ O(ρ_θi), :original", status="gated", gate="QUESTIONS Q5"), + (id="B5b/E1", tier="T2", target=":original→:improved w_c toggle differential (~×6)", status="gated", gate="QUESTIONS Q5"), + (id="B5c", tier="T3", target="ν_★ trend dw_c/dν_★ > 0; w_c ∝ ρ̂_θi", status="gated", gate="QUESTIONS Q5"), + (id="B7", tier="T2", target="DK vs RDK cross-check mode", status="gated", gate="QUESTIONS Q5"), + (id="C4", tier="T3", target="finite-β/shaping triangularity trend + ε-crossover", status="planned", gate="Level 2") +) + +_status_badge(s) = s == "green" ? "✅ green" : s == "gated" ? "🔒 gated" : s == "planned" ? "⏳ planned" : s + +""" + ladder_status() + +Return the docs/05 verification-ladder status as a vector of NamedTuples +`(id, tier, target, status, gate)` — the declarative spec the State Dashboard +renders (docs/07 §1.3). The A-ladder rows are `green` (structural, via the +islands test suite); the B/C physics rows are `gated`/`planned` on the QUESTIONS +clearances (no physics result is claimed until human-cleared). +""" +ladder_status() = collect(_LADDER) + +""" + write_state_dashboard(io=stdout; commit="unknown", date="unknown") + +Emit the Islands **State Dashboard** (docs/07 §1.3) as Markdown to `io`: the +docs/05 ladder as a status table (ID, tier, target, status, gate) stamped with +`commit` and `date`. This is the generator for +`docs/src/islands/state/STATE.md`; that file is **auto-generated — never +hand-edit it** (docs/07 §1.3), it regenerates from this function (and, as they +land, from archived benchmark artifacts). Deterministic given its arguments. +""" +function write_state_dashboard(io::IO=stdout; commit::AbstractString="unknown", date::AbstractString="unknown") + println(io, "# Islands — State Dashboard") + println(io) + println(io, "!!! warning \"Auto-generated — do not hand-edit\"") + println(io, " Generated by `Islands.Verify.write_state_dashboard` (docs/07 §1.3).") + println(io, " Regenerate rather than edit; it refreshes from the ladder spec and,") + println(io, " as they land, archived benchmark artifacts.") + println(io) + println(io, "Snapshot: commit `", commit, "` — ", date, ".") + println(io) + println(io, "The docs/05 verification ladder. The **A-ladder** (structural, pre-physics)") + println(io, "is green via `test/runtests_islands_*.jl`. The **B/C physics ladder** is") + println(io, "gated on the `QUESTIONS.md` clearances — no physics result is claimed until") + println(io, "human sign-off (the `[VERIFY]` policy, module CLAUDE.md).") + println(io) + println(io, "| ID | Tier | Target | Status | Gate |") + println(io, "|---|---|---|---|---|") + for r in _LADDER + println(io, "| ", r.id, " | ", r.tier, " | ", r.target, " | ", _status_badge(r.status), " | ", r.gate, " |") + end + println(io) + ngreen = count(r -> r.status == "green", _LADDER) + ngated = count(r -> r.status == "gated", _LADDER) + nplan = count(r -> r.status == "planned", _LADDER) + println(io, "Summary: **", ngreen, " green** (structural), ", ngated, " gated (physics, awaiting clearance), ", nplan, " planned.") + println(io) + println(io, "Tiers (Decision D9, docs/05 \"Target tiers\"): T1 exact math · T2 internal") + println(io, "differentials · T3 scalings/trends/existence · T4 absolute (audit-gated).") + return io +end + +# --------------------------------------------------------------------------- +# Anchor-sync check (docs/07 §1.1, §4.2): the bidirectional operator ↔ docs +# consistency the CI enforces. +# --------------------------------------------------------------------------- +# Resolve a dotted symbol (e.g. "Operators.MagneticDrift") from `root` (Islands), +# walking submodules. Returns true iff every component is defined. +function _resolve_symbol(qualified::AbstractString, root::Module) + m = root + parts = split(qualified, '.') + for (i, p) in enumerate(parts) + sym = Symbol(p) + isdefined(m, sym) || return false + i == length(parts) && return true + v = getfield(m, sym) + v isa Module || return false + m = v + end + return true +end + +# Extract the backtick-quoted symbols from every `Implemented by:` block of a +# doc — the block runs from the marker to the next blank line, so a wrapped +# multi-line list is captured whole. +function _implemented_by_symbols(docfile::AbstractString) + text = read(docfile, String) + syms = String[] + for block in eachmatch(r"Implemented by:(.*?)(?:\n\n|\z)"s, text) + for m in eachmatch(r"`([^`]+)`", block.captures[1]) + push!(syms, String(m.captures[1])) + end + end + return syms +end + +# The AbstractTerm operator names declared in the Operators source. The `<: +# AbstractTerm` must sit *outside* the type-parameter braces, so a struct that +# merely carries an `F<:AbstractTerm` type parameter (e.g. `IslandStack`) is not +# a false match. +function _operator_names(operators_file::AbstractString) + text = read(operators_file, String) + return [String(m.captures[1]) for m in eachmatch(r"struct\s+(\w+)(?:\{[^}]*\})?\s*<:\s*AbstractTerm\b", text)] +end + +""" + check_anchor_sync(root=parentmodule(@__MODULE__); operators_file, docfiles) + +The docs/07 §1.1 bidirectional anchor-sync check, as a pure function CI can gate +on. Returns `(undocumented, dangling)`: + + - `undocumented` — `AbstractTerm` operators declared in `operators_file` whose + name is **not** claimed by any `Implemented by:` marker in `docfiles` (an + operator with no as-implemented documentation); + - `dangling` — backtick-quoted symbols on an `Implemented by:` line that do + **not** resolve to a defined name under `root` (a doc pointing at a + nonexistent/renamed symbol). + +Both empty ⇒ the operator stack and the as-implemented docs are in sync. `root` +defaults to the `Islands` module; `operators_file`/`docfiles` default to the +in-repo paths relative to this source file. +""" +function check_anchor_sync(root::Module=parentmodule(@__MODULE__); + operators_file::AbstractString=normpath(joinpath(@__DIR__, "..", "operators", "Operators.jl")), + docfiles=[normpath(joinpath(@__DIR__, "..", "..", "..", "docs", "src", "islands", "numerics.md"))]) + implemented = String[] + for f in docfiles + isfile(f) && append!(implemented, _implemented_by_symbols(f)) + end + implemented_leaves = Set(last(split(s, '.')) for s in implemented) + ops = _operator_names(operators_file) + undocumented = [op for op in ops if !(op in implemented_leaves)] + dangling = [s for s in implemented if !_resolve_symbol(s, root)] + return (undocumented=undocumented, dangling=dangling) +end + +end # module Verify diff --git a/test/runtests.jl b/test/runtests.jl index 2ae4125ea..a1e642284 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -36,4 +36,8 @@ else include("./runtests_coils.jl") include("./runtests_imas.jl") include("./runtests_rerun_from_h5.jl") + include("./runtests_islands_grids.jl") + include("./runtests_islands_operators.jl") + include("./runtests_islands_solve.jl") + include("./runtests_islands_configure.jl") end diff --git a/test/runtests_islands_configure.jl b/test/runtests_islands_configure.jl new file mode 100644 index 000000000..527aa6eef --- /dev/null +++ b/test/runtests_islands_configure.jl @@ -0,0 +1,235 @@ +# runtests_islands_configure.jl +# +# Islands M2c — the Level-0 configuration-assembly gates +# (docs/src/islands/design/03 §2; M2c milestone contract, deliverable #1): +# - configure_level0 builds a well-formed IslandStack + far-field BCs + Δ prefactors; +# - the CLEARED coefficients are wired faithfully onto the operator stack +# (c_D ≡ Coefficients.magnetic_drift_frequency node-for-node; the :improved +# toggle; the pitch diffusivity/deflection shapes; the Δ prefactors); +# - the still-gated coefficient families (QUESTIONS Q5) are the supplied inputs; +# - the assembled residual runs and Newton–Krylov converges STRUCTURALLY on the +# documented non-physics placeholder config (never a physics result — the +# gated coefficients are uncleared). +# +# STRUCTURAL check: the placeholder gated inputs are explicitly non-physics +# (Configure.level0_placeholders); a physics run supplies cleared inputs once the +# Q5 derivation lane clears the remaining coefficient families. + +using LinearAlgebra +using Test + +const IslC = GeneralizedPerturbedEquilibrium.Islands +const PSc = IslC.PhaseSpace +const Opc = IslC.Operators +const Soc = IslC.Solvers +const Coc = IslC.Coefficients +const Spc = IslC.SpeciesLists +const Cfg = IslC.Configure + +# small physical grid: y_max spans the full pitch domain (forbidden region + +# y_c layer) so the assembly's robustness there is exercised. +_grid() = PSc.IslandGrid(; nx=9, nxi=8, ny=9, nE=3, halfwidth_x=6.0, clustering_x=1.0, + y_max=4.0, y_c=1.0, clustering_y=0.8, order=4) + +# ρ̂_θi is order-unity here so the island-streaming a_xi = (inv_Lq/ρ̂_θi)·x stays +# commensurate with the (structural, non-physics) test domain and the naive +# Newton–Krylov converges without the physics preconditioner; the cleared +# coefficient *structure* (the {Ω,g} advection) is ρ̂_θi-independent in form. +_phys(; variant=:original, model=:chandrasekhar) = Cfg.Level0Physics(; epsilon=0.1, + inv_Lq=1.0, inv_LB=1.0, q_s=2.0, dq_dpsi=0.5, w_psi=0.05, mu0_R=1.0, inv_Ln0=1.0, + rho_hat_theta_i=1.0, eta_i=0.5, tau=1.0, variant=variant, collision_model=model) + +_ion() = [Spc.Species(; name=:i, Z=1.0, m=1.0, background=Spc.Maxwellian(; n=1.0, T=1.0), role=Spc.Bulk)] + +@testset "Islands Configure — Level-0 assembly (M2c)" begin + grid = _grid() + phys = _phys() + species = _ion() + gated = Cfg.level0_placeholders(grid) + cfg = Cfg.configure_level0(grid, phys, species; gated=gated) + + @testset "well-formed stack + provenance" begin + @test cfg.stack isa Opc.IslandStack + @test length(cfg.stack.kinetic) == 5 # streaming, drift, E×B, collisions, drive + @test cfg.stack.field isa Opc.Quasineutrality + @test cfg.bc isa Opc.FarFieldConditions + # provenance tuples name exactly which coefficients are cleared vs gated + @test :magnetic_drift in cfg.cleared + @test :delta_prefactors in cfg.cleared + @test :quasineutrality in cfg.cleared # closure now wired (01 §3) + @test !(:quasineutrality_alpha in cfg.gated) # no longer a structural gap + @test :streaming in cfg.cleared # island streaming now wired (01 §2) + @test :gradient_drive in cfg.cleared # far-field drive, zero source (01 §2) + @test :far_field in cfg.cleared + @test :exb in cfg.gated # E×B still gated + end + + @testset "gradient drive = zero source + diamagnetic far field (01 §2)" begin + nx, nξ, ny, nE, nσ = PSc.nnodes(grid) + # I19 Formulation A: the interior GradientDrive source is zero + gd = cfg.stack.kinetic[5] + @test gd isa Opc.GradientDrive + @test all(iszero, gd.drive) + # far field g_far = x·L̂_n0⁻¹·[1+(E−3/2)η_i] at the boundaries, Φ_far = 0 + bc = Cfg.gradient_far_field(grid, phys) + xL, xR = grid.x.nodes[1], grid.x.nodes[nx] + for iE in 1:nE + temp = 1 + (grid.E.nodes[iE] - 1.5) * phys.eta_i + @test bc.g_left[2, 3, iE, 1] ≈ xL * phys.inv_Ln0 * temp atol = 1e-12 + @test bc.g_right[2, 3, iE, 1] ≈ xR * phys.inv_Ln0 * temp atol = 1e-12 + end + @test all(iszero, bc.Φ_left) && all(iszero, bc.Φ_right) # ω_E = 0 + # isotropic in ξ, y, σ (leading order) + @test bc.g_left[1, 1, 2, 1] == bc.g_left[nξ, ny, 2, nσ] + end + + @testset "CLEARED c_D wired faithfully vs magnetic_drift_frequency" begin + cD = Cfg.drift_coefficient_table(grid, phys) + nx, nξ, ny, nE, nσ = PSc.nnodes(grid) + y_forbidden = (1 + 0.1) / (1 - 0.1) + # every well-defined node equals the direct cleared call, node-for-node + for iσ in 1:nσ, iE in 1:nE, iy in 1:ny + y = grid.y.nodes[iy] + (y >= y_forbidden) && continue # forbidden region ⇒ 0 (no particles) + (abs(y - 1.0) < 5e-2) && continue # skip the gated y_c layer + v̂ = sqrt(grid.E.nodes[iE]) + σ = grid.σ[iσ] + direct = Coc.magnetic_drift_frequency(; y=y, v_hat=v̂, sigma=σ, epsilon=0.1, + inv_Lq=1.0, inv_LB=1.0, variant=:original) + @test cD[4, 3, iy, iE, iσ] ≈ direct atol = 1e-12 + end + # forbidden region carries no particles ⇒ c_D ≡ 0 + for iy in 1:ny + if grid.y.nodes[iy] >= y_forbidden + @test all(iszero, @view cD[:, :, iy, :, :]) + end + end + # σ-odd: reversing the sign of v_∥ flips the drift + @test cD[4, 3, 2, 2, 1] ≈ -cD[4, 3, 2, 2, 2] atol = 1e-12 + end + + @testset ":improved drift toggle zeroes the ∇B term" begin + cD_orig = Cfg.drift_coefficient_table(grid, _phys(; variant=:original)) + cD_imp = Cfg.drift_coefficient_table(grid, _phys(; variant=:improved)) + iy, iE, iσ = 2, 2, 1 + y = grid.y.nodes[iy] + v̂ = sqrt(grid.E.nodes[iE]) + σ = grid.σ[iσ] + A, G = Coc.orbit_average_drift_brackets(; y=y, epsilon=0.1) + @test cD_imp[4, 3, iy, iE, iσ] ≈ (σ * v̂ / 1.1) * (1.0 * A) atol = 1e-10 # LB → 0 + @test cD_orig[4, 3, iy, iE, iσ] ≈ (σ * v̂ / 1.1) * (1.0 * A - 0.5 * 1.0 * G) atol = 1e-10 + @test cD_imp[4, 3, iy, iE, iσ] != cD_orig[4, 3, iy, iE, iσ] # the toggle bites + end + + @testset "CLEARED collision shapes wired from Coefficients" begin + # c is y-independent, energy-dependent = nu_tilde · deflection_frequency(√E) + c = Cfg.collision_coefficient(grid, phys, 1.0) + nx, nξ, nE, nσ = size(c) + for iE in 1:nE + v̂ = sqrt(grid.E.nodes[iE]) + ν = Coc.deflection_frequency(v̂; nu_tilde=1.0, model=:chandrasekhar) + @test c[3, 2, iE, 1] ≈ ν atol = 1e-12 + end + # P profile from the cleared pitch_diffusivity, in-domain and nonnegative + P, wmeas = Cfg.pitch_diffusivity_profile(grid, gated.B_profile) + @test all(>=(0), P) + @test all(>(0), wmeas) + iy = 4 + λ = grid.y.nodes[iy] + @test P[iy] ≈ Coc.pitch_diffusivity(λ, gated.B_profile[iy]) atol = 1e-12 + end + + @testset "CLEARED Δ prefactors (symmetric, from delta_moment_prefactors)" begin + direct = Coc.delta_moment_prefactors(; mu0_R=1.0, w_psi=0.05, dq_dpsi=0.5, q_s=2.0) + @test cfg.delta_prefactors.cos ≈ direct.cos atol = 1e-9 + @test cfg.delta_prefactors.sin ≈ direct.sin atol = 1e-9 + @test cfg.delta_prefactors.cos ≈ -cfg.delta_prefactors.sin atol = 1e-9 # symmetric pin + end + + @testset "structural solve converges on the placeholder config" begin + f! = Soc.flat_residual(cfg.stack, grid; bc=cfg.bc) + N = Opc.statelength(grid) + sol = Soc.newton_krylov(f!, zeros(N); rtol=1e-8, atol=1e-12, max_iter=30, memory=200) + @test sol.converged + @test sol.residual_norms[end] < 1e-7 + # residual is finite everywhere at a nonzero state + U = Opc.IslandState(grid) + Opc.fill_state!(U, 0.3) + R = Opc.IslandState(grid) + cache = Opc.IslandCache(grid) + Opc.residual!(R, U, cfg.stack, grid, cache, cfg.bc) + @test all(isfinite, R.g) && all(isfinite, R.Φ) + end + + @testset "island streaming = advection along Ω (the {Ω,g} structure)" begin + a_xi, a_x = Cfg.streaming_coefficients(grid, phys) + nx, nξ, ny, nE, nσ = PSc.nnodes(grid) + w = phys.w_psi + pref = phys.inv_Lq * w^2 / (4 * phys.rho_hat_theta_i) + for iy in 1:ny + Θ = grid.y.nodes[iy] < grid.y_c ? 1.0 : 0.0 + for iξ in 1:nξ, ix in 1:nx + x = grid.x.nodes[ix] + ξ = grid.ξ.nodes[iξ] + # a_xi must equal pref·Θ·∂ₓΩ = pref·Θ·(4x/w²); a_x = pref·Θ·(−∂_ξΩ) = pref·Θ·(−sinξ) + @test a_xi[ix, iξ, iy, 1, 1] ≈ pref * Θ * (4 * x / w^2) atol = 1e-12 + @test a_x[ix, iξ, iy, 1, 1] ≈ pref * Θ * (-sin(ξ)) atol = 1e-12 + end + end + # passing-only: trapped nodes (y ≥ y_c) carry zero streaming + itrap = findfirst(>=(grid.y_c), grid.y.nodes) + if itrap !== nothing + @test all(iszero, @view a_xi[:, :, itrap, :, :]) + @test all(iszero, @view a_x[:, :, itrap, :, :]) + end + # E, σ independence (broadcast) + @test a_xi[3, 2, 2, 1, 1] == a_xi[3, 2, 2, nE, nσ] + end + + @testset "quasineutrality drive makes Φ nonzero (the Q5 field fix)" begin + # the cleared L̂_{n0}⁻¹(x−ĥ) source drives Φ; without it Φ collapses to 0. + S = Cfg.quasineutrality_source(grid, phys) + @test any(!iszero, S) # the drive is nontrivial + @test cfg.stack.field.source !== nothing # wired into the operator + @test cfg.stack.field.α ≈ (phys.tau + 1) / phys.tau # α = (τ+1)/τ, not τ/(τ+1) + # solved Φ is nonzero in the interior (boundaries pinned by the far field) + f! = Soc.flat_residual(cfg.stack, grid; bc=cfg.bc) + N = Opc.statelength(grid) + sol = Soc.newton_krylov(f!, zeros(N); rtol=1e-9, atol=1e-13, max_iter=40, memory=200) + Usol = Opc.IslandState(grid) + Opc.unflatten!(Usol, sol.u) + @test maximum(abs, Usol.Φ) > 1e-6 # Φ no longer trivially zero + end + + @testset "assembly validates the species list" begin + @test_throws ArgumentError Cfg.configure_level0(grid, phys, Spc.Species[]; gated=gated) + end +end + +@testset "Islands anchor-sync (docs/07 §1.1)" begin + Vfy = IslC.Verify + ops_file = normpath(joinpath(@__DIR__, "..", "src", "Islands", "operators", "Operators.jl")) + + @testset "the operator stack and the as-implemented docs are in sync" begin + r = Vfy.check_anchor_sync() + @test isempty(r.undocumented) # every AbstractTerm operator is documented + @test isempty(r.dangling) # every `Implemented by:` symbol resolves + end + + @testset "the check CATCHES drift (negative controls)" begin + # a doc missing an operator ⇒ that operator is flagged undocumented + missing_doc = tempname() * ".md" + write(missing_doc, "Implemented by: `Operators.MagneticDrift`.\n") + r1 = Vfy.check_anchor_sync(; docfiles=[missing_doc]) + @test "ParallelStreaming" in r1.undocumented + @test isempty(r1.dangling) # the one symbol it names does resolve + rm(missing_doc; force=true) + + # a doc naming a nonexistent symbol ⇒ that symbol is flagged dangling + bogus_doc = tempname() * ".md" + write(bogus_doc, "Implemented by: `Operators.NotARealOperator`.\n") + r2 = Vfy.check_anchor_sync(; docfiles=[bogus_doc]) + @test "Operators.NotARealOperator" in r2.dangling + rm(bogus_doc; force=true) + end +end diff --git a/test/runtests_islands_grids.jl b/test/runtests_islands_grids.jl new file mode 100644 index 000000000..10dd38336 --- /dev/null +++ b/test/runtests_islands_grids.jl @@ -0,0 +1,93 @@ +# runtests_islands_grids.jl +# +# Islands module — phase-space grid / discretization unit tests (src/Islands/phasespace). +# Pure numerics: spectral and finite-difference differentiation and quadrature. +# No physics coefficients here (nothing [VERIFY]-tagged); these back the MMS +# ladder A1 checks in runtests_islands_operators.jl. + +const PS = GeneralizedPerturbedEquilibrium.Islands.PhaseSpace + +@testset "Islands phase-space grids" begin + + @testset "Fourier spectral ∂ξ is exact for bandlimited data" begin + fg = PS.FourierGrid(16; L=2π) + x = fg.nodes + g = @. sin(3x) + cos(2x) - 0.5 * sin(x) + dg_exact = @. 3cos(3x) - 2sin(2x) - 0.5cos(x) + @test maximum(abs, fg.D1 * g .- dg_exact) < 1e-12 + # odd node count is rejected + @test_throws ArgumentError PS.FourierGrid(15) + end + + @testset "Mapped FD converges at design order (uniform grid)" begin + # smooth decaying test function on [-6, 6] + f(x) = exp(-x^2 / 2) + f′(x) = -x * exp(-x^2 / 2) + f″(x) = (x^2 - 1) * exp(-x^2 / 2) + err1 = Float64[] + err2 = Float64[] + ns = [17, 33, 65] + for n in ns + g = PS.MappedFDGrid(n; halfwidth=6.0, order=4) + xn = g.nodes + push!(err1, maximum(abs, g.D1 * f.(xn) .- f′.(xn))) + push!(err2, maximum(abs, g.D2 * f.(xn) .- f″.(xn))) + end + # 4th-order: halving h cuts error by ≳ 2^4 (allow margin for pre-asymptotics) + @test log(err1[2] / err1[3]) / log(ns[3] / ns[2]) > 3.7 + @test log(err2[2] / err2[3]) / log(ns[3] / ns[2]) > 3.7 + @test err1[end] < 1e-3 + @test err2[end] < 1e-3 + end + + @testset "Layer-clustered map preserves order and packs the center" begin + # a strongly clustered grid still differentiates a smooth function accurately + g = PS.MappedFDGrid(65; halfwidth=6.0, clustering=2.0, order=4) + # node spacing is smallest near the clustering center (x = 0) + Δ = diff(g.nodes) + icenter = argmin(abs.(g.nodes[1:(end-1)] .+ g.nodes[2:end]) ./ 2) + @test Δ[icenter] < Δ[1] + @test Δ[icenter] < Δ[end] + f(x) = sin(x) * exp(-x^2 / 8) + f′(x) = (cos(x) - x / 4 * sin(x)) * exp(-x^2 / 8) + @test maximum(abs, g.D1 * f.(g.nodes) .- f′.(g.nodes)) < 1e-3 + end + + @testset "Half-domain grid packs at y_c and spans [0, y_max]" begin + g = PS.MappedFDGrid(17; halfwidth=4.0, clustering=1.0, center=1.0, domain=:half, order=4) + @test g.nodes[1] ≈ 0.0 atol = 1e-12 + @test g.nodes[end] ≈ 4.0 atol = 1e-12 + @test issorted(g.nodes) + end + + @testset "Simpson quadrature weights integrate at design order" begin + # ∫_0^4 exp(-(y-1)^2/2) dy — self-convergence (no closed form needed): + # successive-refinement differences must shrink at ≳ 4th order. + quad(n) = + let g = PS.MappedFDGrid(n; halfwidth=4.0, clustering=1.0, center=1.0, domain=:half, order=4) + sum(g.wq .* exp.(-(g.nodes .- 1) .^ 2 ./ 2)) + end + q = quad.([17, 33, 65, 129]) + d1, d2, d3 = abs(q[2] - q[1]), abs(q[3] - q[2]), abs(q[4] - q[3]) + @test log(d1 / d2) / log(2) > 3.3 + @test log(d2 / d3) / log(2) > 3.3 + end + + @testset "Gauss–Laguerre quadrature is exact on polynomials × e^{-E}" begin + gg = PS.GaussGrid(6; kind=:laguerre) + # ∫_0^∞ E^k e^{-E} dE = k! + for (k, want) in ((0, 1.0), (1, 1.0), (2, 2.0), (3, 6.0), (4, 24.0)) + @test sum(gg.weights .* gg.nodes .^ k) ≈ want rtol = 1e-10 + end + end + + @testset "IslandGrid assembles all five coordinates" begin + ig = PS.IslandGrid(; nx=17, nxi=16, ny=9, nE=4, halfwidth_x=6.0, clustering_x=1.5, + y_max=4.0, y_c=1.0, clustering_y=1.0) + @test PS.nnodes(ig) == (17, 16, 9, 4, 2) + @test ig.σ == [1.0, -1.0] + @test length(ig.x.wq) == 17 + # even nx is rejected (Simpson needs odd) + @test_throws ArgumentError PS.MappedFDGrid(16; halfwidth=6.0) + end +end diff --git a/test/runtests_islands_operators.jl b/test/runtests_islands_operators.jl new file mode 100644 index 000000000..007a3ee77 --- /dev/null +++ b/test/runtests_islands_operators.jl @@ -0,0 +1,97 @@ +# runtests_islands_operators.jl +# +# Islands operator-stack skeleton — verification ladder A1/A2 (docs/src/islands/design/05). +# A1 MMS: per-operator and assembled-system convergence at design order. +# A2 JVP (forward-mode AD) vs. finite-difference residual directional derivative. +# + allocation regression for the `apply!` / `residual!` hot paths (docs/04 §9). +# +# These are STRUCTURAL (pre-physics) checks. The manufactured coefficients they +# use are arbitrary order-unity test values — not physics — so nothing here is +# [VERIFY]-gated. Physics benchmarks (ladder B+) stay skipped until human-cleared. + +const Isl = GeneralizedPerturbedEquilibrium.Islands +const V = Isl.Verify +const PSg = Isl.PhaseSpace +const Op = Isl.Operators + +# nx = ny refined together; ξ bandlimited (nxi small is exact); nE small. +_grid(n) = PSg.IslandGrid(; nx=n, nxi=8, ny=n, nE=3, halfwidth_x=6.0, clustering_x=1.0, + y_max=4.0, y_c=1.0, clustering_y=0.8, order=4) +_order(e_coarse, e_fine, n_coarse, n_fine) = log(e_coarse / e_fine) / log(n_fine / n_coarse) + +@testset "Islands operator stack (A1/A2)" begin + + @testset "A1 — per-operator MMS convergence at design order" begin + # x/y differential operators: fourth-order finite differences. + for term in (:streaming, :exb, :collisions, :perp) + e17 = V.mms_operator_error(_grid(17), term) + e33 = V.mms_operator_error(_grid(33), term) + @test _order(e17, e33, 17, 33) > 3.3 + @test e33 < e17 + end + # ξ-only operator (magnetic drift): Fourier spectral → machine precision + # on the bandlimited manufactured ξ-profile. + @test V.mms_operator_error(_grid(17), :drift) < 1e-10 + # state-independent source is reproduced exactly (no discretization). + @test V.mms_operator_error(_grid(17), :drive) < 1e-12 + end + + @testset "A1 — assembled kinetic-residual MMS convergence" begin + e17 = V.mms_assembled_error(_grid(17)) + e33 = V.mms_assembled_error(_grid(33)) + @test _order(e17, e33, 17, 33) > 3.3 + @test e33 < 5e-3 + end + + @testset "A1 — velocity-moment quadrature convergence (refine y)" begin + mky(ny) = PSg.IslandGrid(; nx=9, nxi=8, ny=ny, nE=3, halfwidth_x=6.0, clustering_x=1.0, + y_max=4.0, y_c=1.0, clustering_y=0.8, order=4) + d1 = V.moment_selfconvergence(mky(17), mky(33)) + d2 = V.moment_selfconvergence(mky(33), mky(65)) + @test _order(d1, d2, 33, 65) > 3.3 + # grids must share (nx, nξ) + @test_throws ArgumentError V.moment_selfconvergence(mky(17), _grid(17)) + end + + @testset "A2 — AD JVP matches finite-difference directional derivative" begin + # residual is nonlinear (E×B bracket couples g and Φ), so this exercises + # the AD plumbing and its correctness together. + @test V.jvp_fd_maxerror(_grid(9)) < 1e-6 + @test V.jvp_fd_maxerror(_grid(9); seed=7) < 1e-6 + end + + @testset "allocation regression — apply!/residual! are allocation-free" begin + g = _grid(9) + for term in (:streaming, :drift, :exb, :collisions, :perp, :drive) + @test V.term_allocations(g, term) == 0 + end + @test V.residual_allocations(g) == 0 + end + + @testset "structural — state, stack, flatten/unflatten" begin + g = _grid(9) + U, = V.manufactured_state(g) + @test eltype(U) == Float64 + @test size(U.g) == PSg.nnodes(g) + @test size(U.Φ) == (9, 8) + + # flatten/unflatten round-trips + n = Op.statelength(g) + @test n == prod(PSg.nnodes(g)) + 9 * 8 + v = zeros(n) + Op.flatten!(v, U) + U2 = Op.IslandState(g) + Op.unflatten!(U2, v) + @test U2.g == U.g && U2.Φ == U.Φ + + # residual! runs and produces a finite, correctly-shaped result + stack = V.build_stack(g) + R = Op.IslandState(g) + cache = Op.IslandCache(g) + Op.residual!(R, U, stack, g, cache) + @test all(isfinite, R.g) && all(isfinite, R.Φ) + + # the magnetic-drift variant toggle is carried on the term + @test Op.MagneticDrift(zeros(1, 1, 1, 1, 1); variant=:improved).variant == :improved + end +end diff --git a/test/runtests_islands_solve.jl b/test/runtests_islands_solve.jl new file mode 100644 index 000000000..d8b105965 --- /dev/null +++ b/test/runtests_islands_solve.jl @@ -0,0 +1,312 @@ +# runtests_islands_solve.jl +# +# Islands M2 — Level-0 solve machinery, structural verification gates +# (docs/src/islands/design/05 §A; the M2 milestone contract): +# A5 zero-drive null: g ≡ 0 ⇒ residual = machine zero; Newton converges trivially. +# A1+ assembled solve-MMS: Newton–Krylov recovers the manufactured state at design order. +# A8 y_c matching-block smallest-singular-value conditioning monitor. +# A4 (L0) exact discrete particle conservation + entropy sign of the mimetic +# pitch-angle collision operator. +# A3 Δ_cos even / Δ_sin odd parity under ξ-reflection (manufactured J̄_∥). +# A7 the coefficient-free closure identity ⟨∂²h/∂x²⟩_Ω = 0. +# + preconditioner quality (GMRES iteration reduction), far-field BC rows, +# pseudo-arclength fold detection, species/frames plumbing. +# +# STRUCTURAL (pre-physics) checks: manufactured order-unity coefficients only. +# Every physics coefficient in src/ is a [VERIFY]-gated supplied parameter +# (QUESTIONS Q2–Q4); the York-gate physics benchmarks stay skipped in +# benchmarks/islands/ until human clearance. + +using LinearAlgebra + +const IslM2 = GeneralizedPerturbedEquilibrium.Islands +const PS2 = IslM2.PhaseSpace +const Op2 = IslM2.Operators +const So2 = IslM2.Solvers +const V2 = IslM2.Verify +const Mo2 = IslM2.Moments +const Fi2 = IslM2.Fields +const Sp2 = IslM2.SpeciesLists +const Fr2 = IslM2.Frames + +_sgrid(n; ny=n) = PS2.IslandGrid(; nx=n, nxi=8, ny=ny, nE=2, halfwidth_x=6.0, clustering_x=1.0, + y_max=4.0, y_c=1.0, clustering_y=0.8, order=4) + +@testset "Islands L0 solve machinery (M2)" begin + + @testset "species plumbing (02 §1, D3)" begin + bg = Sp2.Maxwellian(; n=1.0, T=1.0, dlnn_dr=-1.0) + ion = Sp2.Species(; name=:D, Z=1.0, m=1.0, background=bg, role=Sp2.Bulk) + trc = Sp2.Species(; name=:Dtrace, Z=1.0, m=1.0, background=Sp2.Maxwellian(; n=1e-4, T=1.0), role=Sp2.Trace) + @test Sp2.validate_species([ion, trc]) == [ion, trc] + @test Sp2.is_bulk(ion) && Sp2.is_trace(trc) + @test Sp2.bulk_species([ion, trc]) == [ion] + # the L0 test pair: trace deuterium copy of the bulk passes the criteria + @test isempty(Sp2.check_trace_criteria([ion, trc])) + # a heavy trace at bulk-like density violates and is flagged (warn, never degrade) + w = Sp2.Species(; name=:W, Z=40.0, m=92.0, background=Sp2.Maxwellian(; n=0.01, T=1.0), role=Sp2.Trace) + @test Sp2.check_trace_criteria([ion, w]) == [:W] + # structural validation + @test_throws ArgumentError Sp2.validate_species([trc]) # no Bulk + @test_throws ArgumentError Sp2.validate_species([ion, ion]) # duplicate names + end + + @testset "frames: gated conventions poison, mechanics work (01 §5)" begin + conv = Fr2.FrameConvention() # all NaN until human-cleared (Q3) + @test !Fr2.is_cleared(conv) + @test isnan(Fr2.omega_dia_form(2, 1.0, -1.0, 2.0, conv)) + @test isnan(Fr2.effective_dlnn_form(-1.0, 1.0, 0.5, conv)) + # the frame-invariant combination is pure bookkeeping + @test Fr2.frame_shift(1.7, 0.4) ≈ 1.3 + # parameter-vector validation + p = Fr2.Level0Parameters(; w_hat=1.0, omega_E_hat=0.0, epsilon=0.1, inv_Lq_hat=1.0, q_s=2.0) + @test p.tau == 1.0 + @test_throws ArgumentError Fr2.Level0Parameters(-1.0, 0.0, 0.1, 1.0, 2.0, 1.0, Dict{Symbol,Float64}()) + end + + @testset "A4 — mimetic pitch operator: exact conservation + entropy sign" begin + g = _sgrid(9) + P = @. g.y.nodes * (4.0 - g.y.nodes) + 0.1 # arbitrary positive test profile + wm = @. 1.0 + 0.1 * g.y.nodes + K, Wq = Op2.conservative_pitch_operator(g.y, P, wm) + for gv in (sin.(g.y.nodes) .+ 0.3 .* g.y.nodes .^ 2, exp.(-g.y.nodes), g.y.nodes) + @test abs(dot(Wq, K * gv)) < 1e-11 # particle conservation, machine level + @test dot(gv .* Wq, K * gv) <= 1e-13 # entropy sign (0 only for constants) + end + @test abs(dot(ones(g.y.n) .* Wq, K * ones(g.y.n))) < 1e-12 # constants in the null space + @test_throws ArgumentError Op2.conservative_pitch_operator(g.y, -P, wm) + # the term applies c ⋅ (K g) and is allocation-free + nx, nξ, ny, nE, nσ = PS2.nnodes(g) + c4 = fill(2.0, nx, nξ, nE, nσ) + term = Op2.PitchAngleDiffusion(K, c4) + U, = V2.manufactured_state(g) + R = Op2.IslandState(g) + cache = Op2.IslandCache(g) + Op2.apply!(R, term, U, g, cache) + @test R.g[3, 2, :, 1, 1] ≈ 2.0 .* (K * U.g[3, 2, :, 1, 1]) + @test (@allocated Op2.apply!(R, term, U, g, cache)) == 0 + end + + @testset "A5 — zero-drive null test" begin + g = _sgrid(7; ny=7) + setup = V2.zero_drive_setup(g) + r = ones(setup.N) + setup.f(r, zeros(setup.N)) + @test maximum(abs, r) == 0.0 # residual is EXACTLY machine zero + # Newton from a small perturbation falls back to the zero state + sol = So2.newton_krylov(setup.f, 1e-3 .* sin.(1:setup.N); rtol=1e-12, atol=1e-12) + @test sol.converged + @test norm(sol.u) < 1e-9 + end + + @testset "A1 — assembled solve-MMS at design order" begin + r17 = V2.solve_mms(17) + r33 = V2.solve_mms(33) + @test r17.converged && r33.converged + # solution error against the manufactured state converges at design order + @test log(r17.err / r33.err) / log(33 / 17) > 3.3 + @test r33.err < 5e-3 + end + + @testset "preconditioner: y-block Jacobi with TSVD cuts GMRES iterations (04 §5)" begin + g = _sgrid(9) + nx, nξ, ny, nE, nσ = PS2.nnodes(g) + P = @. g.y.nodes * (4.0 - g.y.nodes) # degenerate endpoints: zero-flux built in + K, = Op2.conservative_pitch_operator(g.y, P, ones(ny)) + cstiff = fill(30.0, nx, nξ, nE, nσ) # stiff collisional pencil + shift = fill(-1.0, nx, nξ, ny, nE, nσ) + stack = Op2.IslandStack((Op2.PitchAngleDiffusion(K, cstiff), Op2.RadiationSink(shift)), + Op2.Quasineutrality(1.3)) + f0! = So2.flat_residual(stack, g) + N = Op2.statelength(g) + b = sin.((1:N) ./ 7) + f!(out, u) = (f0!(out, u); out .-= b; out) + pc = So2.YBlockJacobi(g, (ix, iξ, iE, iσ) -> I(ny) + cstiff[ix, iξ, iE, iσ] .* K; phi_scale=-1.3) + s0 = So2.newton_krylov(f!, zeros(N); rtol=1e-10, memory=300) + s1 = So2.newton_krylov(f!, zeros(N); rtol=1e-10, memory=300, precond=pc) + @test s0.converged && s1.converged + @test maximum(abs, s0.u .- s1.u) < 1e-8 # same solution + @test s1.gmres_iters < s0.gmres_iters ÷ 2 # preconditioner earns its keep + end + + @testset "far-field BCs replace the boundary residual rows (01 §3)" begin + g = _sgrid(7; ny=7) + nx, nξ, ny, nE, nσ = PS2.nnodes(g) + U, = V2.manufactured_state(g) + bc = Op2.FarFieldConditions(0.1 .+ zeros(nξ, ny, nE, nσ), 0.2 .+ zeros(nξ, ny, nE, nσ), + fill(0.3, nξ), fill(0.4, nξ)) + stack = V2.build_stack(g) + R = Op2.IslandState(g) + cache = Op2.IslandCache(g) + Op2.residual!(R, U, stack, g, cache, bc) + @test R.g[1, 2, 3, 1, 1] ≈ U.g[1, 2, 3, 1, 1] - 0.1 + @test R.g[nx, 2, 3, 1, 2] ≈ U.g[nx, 2, 3, 1, 2] - 0.2 + @test R.Φ[1, 5] ≈ U.Φ[1, 5] - 0.3 + @test R.Φ[nx, 5] ≈ U.Φ[nx, 5] - 0.4 + # interior rows are untouched by the BC application + R2 = Op2.IslandState(g) + Op2.residual!(R2, U, stack, g, cache) + @test R.g[2:(nx-1), :, :, :, :] == R2.g[2:(nx-1), :, :, :, :] + end + + @testset "A8 — y_c matching-block conditioning monitor" begin + g = _sgrid(7; ny=7) + setup = V2.zero_drive_setup(g) + J = So2.dense_jacobian(setup.f, zeros(setup.N)) + mon = V2.yc_block_sigma_min(J, g) + @test isfinite(mon.sigma_min) && mon.sigma_min > 0 + @test all(mon.pencil .>= 1) + # the monitor detects an artificially singularized pencil block (the + # silent-noise regression of L23 §4.2 must be *tested for*) + idx = [Op2.g_flat_index(g, 1, 1, iy, 1, 1) for iy in 3:5] + Jsing = copy(J) + Jsing[idx, :] .= 0.0 + @test V2.yc_block_sigma_min(Jsing, g).sigma_min < 1e-14 + end + + @testset "A3 — Δ_cos even / Δ_sin odd under ξ-reflection" begin + g = _sgrid(9) + J = [exp(-x^2) * (2.0 + 1.5 * cos(ξ) + 0.7 * sin(ξ)) for x in g.x.nodes, ξ in g.ξ.nodes] + Jr = [exp(-x^2) * (2.0 + 1.5 * cos(-ξ) + 0.7 * sin(-ξ)) for x in g.x.nodes, ξ in g.ξ.nodes] + d = Mo2.delta_moments(J, g; prefactor_cos=1.0, prefactor_sin=1.0) + dr = Mo2.delta_moments(Jr, g; prefactor_cos=1.0, prefactor_sin=1.0) + @test d.Δcos ≈ dr.Δcos atol = 1e-12 # even + @test d.Δsin ≈ -dr.Δsin atol = 1e-12 # odd + @test abs(d.Δsin) > 0.1 # the projection actually sees the sin part + # ξ-projection is spectrally exact: a pure cos ξ current has zero sin moment + Jc = [cos(ξ) for x in g.x.nodes, ξ in g.ξ.nodes] + @test abs(Mo2.delta_moments(Jc, g; prefactor_cos=1.0, prefactor_sin=1.0).Δsin) < 1e-13 + end + + @testset "moments: J̄_∥ assembly and Ω-average diagnostics" begin + g = _sgrid(9) + nx, nξ, ny, nE, nσ = PS2.nnodes(g) + U, = V2.manufactured_state(g) + W = ones(ny, nE, nσ) + ion = Sp2.Species(; name=:D, Z=1.0, m=1.0, background=Sp2.Maxwellian(; n=1.0, T=1.0), role=Sp2.Bulk) + anti = Sp2.Species(; name=:A, Z=-1.0, m=1.0, background=Sp2.Maxwellian(; n=1.0, T=1.0), role=Sp2.Bulk) + Jp = zeros(nx, nξ) + # equal & opposite charges with identical g cancel exactly + Mo2.parallel_current!(Jp, [U.g, U.g], [ion, anti], [W, W], g) + @test maximum(abs, Jp) < 1e-14 + # single species with W ≡ 1 reduces to the plain velocity moment + Mo2.parallel_current!(Jp, [U.g], [ion], [W], g) + Mref = zeros(nx, nξ) + Op2.velocity_moment!(Mref, U.g, g) + @test Jp ≈ Mref + # ⟨1⟩_Ω = 1 outside and inside the separatrix; constant J has no polarization part + @test Mo2.omega_average((x, ξ) -> 1.0, 1.5, 1.0) ≈ 1.0 rtol = 1e-8 + @test Mo2.omega_average((x, ξ) -> 1.0, 0.2, 1.0) ≈ 1.0 rtol = 1e-6 + cs = Mo2.channel_split((x, ξ) -> 3.0, 2.0, 1.0) + @test cs.bs ≈ 3.0 rtol = 1e-8 + @test abs(cs.pol(0.5, 1.0)) < 1e-8 + @test Mo2.omega_label(0.0, 0.0, 1.0) ≈ -1.0 # O-point + @test Mo2.omega_label(1.0, float(π), 1.0) ≈ 3.0 + end + + @testset "island_flux_amplitude — cleared ψ̃ = (w_ψ²/4)(q_s'/q_s) relation" begin + # cleared physics relation (sign-off 2026-07-11; derivations/psi-tilde-amplitude.md) + w, dq, q = 0.3, 0.8, 1.2 + ψ̃ = Mo2.island_flux_amplitude(; w_psi=w, dq_dpsi=dq, q_s=q) + @test ψ̃ ≈ (w^2 / 4) * (dq / q) + # round-trip: ψ̃ inverts the half-width relation w_ψ = 2√(ψ̃/|χ₀''|), χ₀'' = q_s'/q_s + χ0pp = dq / q + @test 2 * sqrt(ψ̃ / χ0pp) ≈ w # recovers the input half-width + # scales as w² + @test Mo2.island_flux_amplitude(; w_psi=2w, dq_dpsi=dq, q_s=q) ≈ 4 * ψ̃ + @test_throws ArgumentError Mo2.island_flux_amplitude(; w_psi=w, dq_dpsi=dq, q_s=0.0) + end + + @testset "magnetic_drift_frequency — cleared ω̂_D + :original/:improved toggle" begin + Co = IslM2.Coefficients + # cleared physics (sign-off 2026-07-11; derivations/omega-D-drift-frequency.md) + # ε→0 analytic limit: b→1 ⟹ A→√(1−y), G→(2−y)/√(1−y) + for y in (0.2, 0.5, 0.8) + A, G = Co.orbit_average_drift_brackets(; y=y, epsilon=1e-5) + @test A ≈ sqrt(1 - y) rtol = 1e-3 + @test G ≈ (2 - y) / sqrt(1 - y) rtol = 1e-3 + end + # the :improved toggle forces the L̂_B term to zero + kw = (; y=0.5, v_hat=1.2, sigma=1.0, epsilon=0.1, inv_Lq=1.0) + ωimp = Co.magnetic_drift_frequency(; kw..., inv_LB=0.7, variant=:improved) + ωlb0 = Co.magnetic_drift_frequency(; kw..., inv_LB=0.0, variant=:original) + @test ωimp ≈ ωlb0 # :improved == :original with L̂_B⁻¹=0 + # σ-odd, v̂-linear (the σv̂/(1+ε) prefactor) + ωp = Co.magnetic_drift_frequency(; kw..., inv_LB=0.7, variant=:original) + ωm = Co.magnetic_drift_frequency(; y=0.5, v_hat=1.2, sigma=-1.0, epsilon=0.1, inv_Lq=1.0, inv_LB=0.7) + @test ωp ≈ -ωm + @test Co.magnetic_drift_frequency(; y=0.5, v_hat=2.4, sigma=1.0, epsilon=0.1, inv_Lq=1.0, inv_LB=0.7) ≈ 2 * ωp + # the toggle is a real, large effect here (grad-B nearly cancels the shear term) + @test !isapprox(ωp, ωimp; rtol=0.5) + # trapped particles (1 < y < (1+ε)/(1−ε)) give finite brackets; forbidden y rejected + At, Gt = Co.orbit_average_drift_brackets(; y=1.1, epsilon=0.1) + @test isfinite(At) && isfinite(Gt) && At > 0 && Gt > 0 + @test_throws ArgumentError Co.orbit_average_drift_brackets(; y=1.5, epsilon=0.1) + @test_throws ArgumentError Co.magnetic_drift_frequency(; kw..., inv_LB=0.7, variant=:bogus) + end + + @testset "collision operator — cleared diffusivity P(λ) + deflection frequency ν(v̂)" begin + Co = IslM2.Coefficients + # P(λ) = λ√(1−λB) ≥ 0, vanishing at both endpoints (zero-flux) + @test Co.pitch_diffusivity(0.0, 2.0) == 0.0 + @test Co.pitch_diffusivity(0.5, 2.0) == 0.0 # λ = 1/B endpoint + @test Co.pitch_diffusivity(0.25, 2.0) ≈ 0.25 * sqrt(1 - 0.5) + @test Co.pitch_diffusivity(0.3, 2.0) > 0 + @test_throws ArgumentError Co.pitch_diffusivity(0.6, 2.0) # λ > 1/B + # deflection frequency: high-v → ν̃/v̂³ (φ−G → 1 − 1/2v̂² + …); low-v → (4/3√π)/v̂² + @test Co.deflection_frequency(6.0) * 6.0^3 ≈ 1.0 rtol = 2e-2 # φ−G → 1 (slow 1/2v̂² tail) + @test Co.deflection_frequency(30.0) * 30.0^3 ≈ 1.0 rtol = 1e-3 # tighter at larger v̂ + @test Co.deflection_frequency(0.02) * 0.02^2 ≈ 4 / (3 * sqrt(π)) rtol = 1e-2 # 1/v̂² divergence + # the Chandrasekhar form diverges slower than the reduced v̂⁻³ at low v̂ + @test Co.deflection_frequency(0.05; model=:chandrasekhar) < Co.deflection_frequency(0.05; model=:vcubed) + @test Co.deflection_frequency(1.0; model=:vcubed) == 1.0 + @test_throws ArgumentError Co.deflection_frequency(1.0; model=:bogus) + @test_throws ArgumentError Co.deflection_frequency(-1.0) + end + + @testset "A7 — flattened-electron identity ⟨∂²h/∂x²⟩_Ω = 0 (coefficient-free)" begin + for Ω in (1.2, 2.0, 5.0), pref in (1.0, 3.7) # prefactor-independent + @test abs(Fi2.flat_average_d2h_dx2(Ω, 1.0; prefactor=pref)) < 1e-10 + end + @test Fi2.h_profile(0.5; prefactor=1.0) == 0.0 # exactly flat inside the separatrix + @test Fi2.h_profile(2.0; prefactor=1.0) > 0.0 + @test Fi2.Q_omega(3.0) > Fi2.Q_omega(1.5) # Q grows with Ω + @test !Fi2.is_cleared(Fi2.ElectronClosure()) # closure constants (k, f_p) stay NaN-gated (Q3) + @test isnan(Fi2.ElectronClosure().k_HS) + # the h(Ω) amplitude C = w_ψ/2√2 is cleared (sign-off 2026-07-11); feeds h_profile's prefactor + Co = IslM2.Coefficients + @test Co.h_amplitude(0.3) ≈ 0.3 / (2 * sqrt(2)) + # far-field: with C = w_ψ/2√2 and Q → √Ω, h = C ∫₁^Ω dΩ'/Q → 2C(√Ω − 1) + # = (w_ψ/√2)(√Ω − 1), approaching x = (w_ψ/√2)√Ω (derivation §3) + w = 0.4 + Ω = 400.0 + @test Fi2.h_profile(Ω; prefactor=Co.h_amplitude(w)) ≈ (w / sqrt(2)) * (sqrt(Ω) - 1) rtol = 2e-2 + # quasineutrality closure coefficient τ/(τ+1) → 1/2 at τ=1 (cleared 2026-07-11) + @test Co.quasineutrality_coefficient(1.0) ≈ 0.5 + @test Co.quasineutrality_coefficient(2.0) ≈ 2 / 3 + @test Co.quasineutrality_coefficient(1e6) ≈ 1.0 rtol = 1e-5 # τ → ∞ (cold ions) + @test_throws ArgumentError Co.quasineutrality_coefficient(0.0) + # passing fraction f_p = 1 − 1.4624√ε (cleared 2026-07-11; = quoted 1.46 to 3 s.f.) + @test Co.passing_fraction(0.0) == 1.0 # no trapping at ε=0 + @test Co.passing_fraction(0.1) ≈ 1 - 1.4624 * sqrt(0.1) + @test Co.passing_fraction(0.01) < Co.passing_fraction(0.001) # f_p decreases with ε + @test isapprox(1 - Co.passing_fraction(0.1), 1.46 * sqrt(0.1); rtol=2e-3) # matches 1.46 + @test_throws ArgumentError Co.passing_fraction(-0.1) + # Δ-moment prefactors ∓μ₀R/2ψ̃ (cleared 2026-07-11), ψ̃ = (w²/4)(q'/q) + pf = Co.delta_moment_prefactors(; mu0_R=3.0, w_psi=0.3, dq_dpsi=0.8, q_s=1.2) + ψt = Mo2.island_flux_amplitude(; w_psi=0.3, dq_dpsi=0.8, q_s=1.2) + @test pf.cos ≈ -3.0 / (2 * ψt) + @test pf.sin ≈ +3.0 / (2 * ψt) + @test pf.sin ≈ -pf.cos # symmetric [DERIVED] pin + end + + @testset "pseudo-arclength continuation detects the toy fold (03 §3)" begin + ftoy!(out, u, p) = (out[1] = u[1]^2 + p; out) + pa = So2.pseudo_arclength(ftoy!, [1.0], -1.0; ds=0.3, nsteps=15, rtol=1e-12, atol=1e-12) + @test length(pa.ps) > 10 # stepped through, no stall at the fold + @test !isempty(pa.folds) # the fold at p = 0 is detected + @test maximum(pa.ps) < 0.05 # never steps past the fold parameter + # both branches visited: u > 0 before the fold, u < 0 after + @test any(z -> z[1] > 0.5, pa.us) && any(z -> z[1] < -0.5, pa.us) + end +end