diff --git a/.claude/agents/physics-verifier.md b/.claude/agents/physics-verifier.md new file mode 100644 index 00000000..b3e9c485 --- /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 00000000..19bc2a6b --- /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 00000000..14b4b88b --- /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 00000000..57ec10fd --- /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 7e1ff4f6..d7121fb4 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 da32ed04..38e7c113 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 fd2c512d..3d538155 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 00000000..fe273953 --- /dev/null +++ b/benchmarks/islands/README.md @@ -0,0 +1,23 @@ +# 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. + +Figure scripts (docs/07 pipeline) live in `figures/` and read archived +benchmark data only. + +| Script | Ladder ID | Status | +|---|---|---| +| `benchmark_B2_large_w_limits.jl` | B2 | SKIPPED — gated on Q2/Q3/Q4 | +| `benchmark_B4_polarization_omegaE.jl` | B4 | SKIPPED — gated on Q2/Q3 | +| `benchmark_B5_york_thresholds.jl` | B5a/B5b/B5c | 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 00000000..f9123e84 --- /dev/null +++ b/benchmarks/islands/benchmark_B2_large_w_limits.jl @@ -0,0 +1,14 @@ +# benchmark_B2_large_w_limits.jl — ladder B2 (docs/05 §B) +# +# Target: large-w analytic limits — Δ_bs + Δ_cur ∝ 1/w with coefficient against +# WCHH96 Eq. (85) mapped to the island frame (Diss19 p. 86 frame caveat), and the +# Δ_pol ∝ 1/w³ tail [CHECKED: Diss19 pp. 84–86; D21 Fig. 8-class curves]. +# +# STATUS: SKIPPED — gated per docs/src/islands/QUESTIONS.md: +# Q3 the Δ moment prefactors and electron-closure constants are uncleared +# Q4 WCHH96 is not yet in the docs/08 reference library (cited only via +# transcriptions) and the ψ̃ amplitude carries an open [VERIFY] +# Q2 D7 (re-derived equation set) unratified. + +println("SKIPPED: B2 large-w limits — gated on QUESTIONS Q2, Q3, Q4") +println(" (WCHH96 not in the reference library; ψ̃ [VERIFY] open; closure constants uncleared).") diff --git a/benchmarks/islands/benchmark_B4_polarization_omegaE.jl b/benchmarks/islands/benchmark_B4_polarization_omegaE.jl new file mode 100644 index 00000000..d16f9b08 --- /dev/null +++ b/benchmarks/islands/benchmark_B4_polarization_omegaE.jl @@ -0,0 +1,16 @@ +# benchmark_B4_polarization_omegaE.jl — ladder B4 (docs/05 §B) +# +# Target: polarization structure — (i) Wilson–Connor collisionless and Smolyakov +# collisional scalings; (ii) Δ_pol ∝ ω_E² away from zero with the sign reversal +# at ω_E ≈ −0.89 ω_dia,e, reversal point insensitive to w/ρ_θi; (iii) +# torque-balance roots at discrete ω̂_E (Diss19 benchmark root ω₀ = −0.93 +# ω_dia,e) [CHECKED: D23b Fig. 8; Diss19 Fig. 4.18]. +# +# STATUS: SKIPPED — gated per docs/src/islands/QUESTIONS.md: +# Q3 the frame-convention signs (src/Islands/frames/) are NaN-gated until +# cleared — the ω_E-dependence of Δ_pol is exactly the physics the frames +# module must own before any sign-bearing benchmark runs +# Q2 D7 unratified (L0 equation set). + +println("SKIPPED: B4 polarization ω_E structure — gated on QUESTIONS Q2, Q3") +println(" (frame-convention signs NaN-gated; L0 equation set unratified).") diff --git a/benchmarks/islands/benchmark_B5_york_thresholds.jl b/benchmarks/islands/benchmark_B5_york_thresholds.jl new file mode 100644 index 00000000..1766b183 --- /dev/null +++ b/benchmarks/islands/benchmark_B5_york_thresholds.jl @@ -0,0 +1,22 @@ +# benchmark_B5_york_thresholds.jl — ladder B5a/B5b/B5c (docs/05 §B) +# +# Targets (transcribed, awaiting human sign-off — see docs/src/islands/design/05): +# B5a DK-NTM threshold w_c ≃ 2.76 ρ_θi (half-width) ≡ 8.73 ρ_bi at ε = 0.1, +# :original drift model [CHECKED: I19 Fig. 9; PRL p. 4] +# + open [VERIFY]: run collisionality (I19 §4.2 ν_★ = 0.01 vs L23 p. 82 ν_★ = 1e-3). +# B5b RDK-NTM improved-model threshold w_c ≈ 0.45 ρ_θi ≡ 1.46 ρ_bi half-width +# [CHECKED: D21 abstract + Fig. 8; D23a abstract] +# B5c kokuchou finite-ν_★ surface w_c ≈ 0.440 ρ̂_θi + 0.0178 ν_★ − 7.54e-5 +# [CHECKED: L23 Eqs. 6.3.1–6.3.2], L23-amended equation set. +# +# STATUS: SKIPPED — running this requires assigning Level-0 physics +# coefficients that are at most [CHECKED] and not human-cleared: +# Q2 ratify D7 (re-derived L0 equation set) and D8 (the B5a/b/c triangle) +# Q3 clear the L0 coefficient set (ω̂_D/L̂_B toggle, collision kernel, +# electron closure, quasineutrality closure) +# Q4 resolve the ψ̃ amplitude [VERIFY] and the B5a collisionality [VERIFY] +# per docs/src/islands/QUESTIONS.md. Do not fill in a number to make this run. + +println("SKIPPED: B5a/B5b/B5c York thresholds — gated on QUESTIONS Q2, Q3, Q4") +println(" (uncleared [CHECKED] L0 coefficients; D7/D8 unratified).") +println(" See docs/src/islands/QUESTIONS.md and docs/src/islands/design/05-verification.md.") diff --git a/benchmarks/islands/figures/README.md b/benchmarks/islands/figures/README.md new file mode 100644 index 00000000..f0b2ad83 --- /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 00000000..448dfbbe --- /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 b62bf121..5fddf45d 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -37,9 +37,26 @@ 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", + "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" + ] + ], "Citations" => "citations.md", "Developer Notes" => "developer_notes.md", ], 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 00000000..06148fee 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 00000000..5259166b 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 00000000..094341bc 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 00000000..fb50a695 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 00000000..83479cbb 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 00000000..467fc7be 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 00000000..46107246 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 00000000..49db982b 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 00000000..a7d34716 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 00000000..0a616786 --- /dev/null +++ b/docs/src/islands.md @@ -0,0 +1,108 @@ +# 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.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}.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, York thresholds) 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 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] +``` + +### Newton–Krylov solve (`Islands.Solvers`) + +```@autodocs +Modules = [GeneralizedPerturbedEquilibrium.Islands.Solvers] +``` + +### 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 00000000..2119e8e9 --- /dev/null +++ b/docs/src/islands/LOG.md @@ -0,0 +1,142 @@ +# 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-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 00000000..ffdd052f --- /dev/null +++ b/docs/src/islands/QUESTIONS.md @@ -0,0 +1,124 @@ +# 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⁻³`. Resolve before pinning the B5a tolerance. + - Acquire **WCHH96** (analytic electron closure / large-w limits, B2) and **Park + PoP 29 (2022)** (SLAYER Q-convention map, D1) into the `docs/08` reference + library — both are currently cited only via transcription. +- **Options**: (a) user resolves from the source PDFs; (b) an independent + re-derivation pins `ψ̃` and the collisionality is read from the reproduced run. +- **Recommendation**: resolve `ψ̃` by re-derivation (it is a clean dimensional check); + read the B5a collisionality from whichever run the D8 triangle pins as canonical. +- **Gated work**: the `Δ_cos/Δ_sin` normalization; the B5a threshold tolerance; the + B2 large-w and D1 SLAYER comparisons. diff --git a/docs/src/islands/design/00-roadmap.md b/docs/src/islands/design/00-roadmap.md new file mode 100644 index 00000000..1e4d1d54 --- /dev/null +++ b/docs/src/islands/design/00-roadmap.md @@ -0,0 +1,287 @@ +# 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:** (i) large-w analytic limits (ladder B2: Δ_bs+Δ_cur ∝ 1/w vs. WCHH96 +Eq. (85), Δ_pol ∝ 1/w³); (ii) York thresholds with exact configurations +(B5a: w_c ≃ 2.76 ρ_θi ≡ 8.73 ρ_bi, :original drift model; B5b: w_c ≈ 0.45 +ρ_θi ≡ 1.46 ρ_bi half-width, :improved model; B5c: kokuchou's +w_c ≈ 0.440 ρ̂_θi + 0.0178 ν_★ − 7.54×10⁻⁵ finite-ν_★ surface); (iii) +separatrix-layer polarization structure and the Δ_pol(ω_E) sign-reversal curve +of D23b (B4, B6). + +--- + +## 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. 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 00000000..45f1e401 --- /dev/null +++ b/docs/src/islands/design/01-physics-level0.md @@ -0,0 +1,336 @@ +# 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 + [CHECKED: I19 Eq. (5); Diss19 p. 30; L23 Eq. (2.1.4)] + +where **w_ψ is the island HALF-width in ψ-space**, w = w_ψ/(RB_θ) the +half-width in minor radius. Note: one extraction of I19 rendered the amplitude +as (w_ψ²/4)(q_s/q_s′); dimensional analysis and Diss19/D21/L23 all give +q_s′/q_s. [VERIFY: check I19 as printed — possible typo in the paper itself.] + +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). + +### 2.1 Magnetic drift frequency: the original/improved toggle (now precise) + +Orbit-averaged precession [CHECKED: I19 Eq. (32) def.; D21 Eqs. 15, B1]: + + ω̂_D = [σv̂/(1+ε)] [ (1/L̂_q)⟨√(1−yb)/b⟩_θ − (1/2)(1/L̂_B)⟨(2−yb)/(b√(1−yb))⟩_θ ] + +- **: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 + ∂B/∂ψ = −(B_φ/(R₀²B_θ)) cos θ + O(ε²) — the cos θ modulation makes the term + ε-small after orbit averaging; **L̂_B⁻¹ = 0 is the documented proxy** (D21 + footnote 10, Fig. 8 compares proxy vs full cos θ form directly). + [CHECKED: D21 Eq. A2, p. 16] + +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 [CHECKED: I19 Eqs. (9)–(12); +Diss19 Eqs. 2.25–2.30; lineage: WCHH96 Eq. (62)]: + + 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) + +λ-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⁻³. L23 additionally derives the analytic velocity average +⟨ν̂_ii⟩_u = (4ε^{3/2}ν_★/3√π)(√2 − ln(1+√2)) and uses it in place of an +inaccurate numerical u-integral (ν̃ ∝ u⁻² divergence at low u) — adopt this. +[CHECKED: L23 Eq. 4.1.6, p. 88] + +Collisionality normalization: ν_★ = ν_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 [CHECKED: I19 Eqs. (14)–(22); +L23 §2.4]: + + 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 (unit-test target; L23 Eq. 4.1.1). Flux-surface-averaged +electron flow [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; unit-test: L23 reproduces −1.1730) and +f_p ≃ 1 − 1.46√ε. 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 [CHECKED: I19 Eq. (A.11); +L23 Eq. (2.4.14)]: + + e_iΦ̂/T_i = [ δn̄_i/n₀ + x − ĥ(Ω) ] / (2 L̂_{n0}) + +(T_e = T_i assumed in the sources; Islands keeps τ = T_e/T_i general and flags +departures). 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. + +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 ξ [CHECKED: Diss19 Eq. 4.12 for Δ_neo; + sin-moment normalization chosen symmetric — [DERIVED] pin at implementation] + +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 00000000..abc6b72c --- /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 00000000..0efe26b4 --- /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 00000000..e04f20b9 --- /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 00000000..789e0e9c --- /dev/null +++ b/docs/src/islands/design/05-verification.md @@ -0,0 +1,110 @@ +# 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. + +**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: ⟨∂²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: Δ_bs+Δ_cur ∝ 1/w with coefficient, against WCHH96 Eq. (85) mapped to the island frame (Diss19 p. 86 frame caveat); scaling ε^{1/2}(L_q/L_p)(β_θ/w). Δ_pol ∝ 1/w³ tail | [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: (i) Wilson–Connor collisionless and Smolyakov collisional scalings; (ii) the frame-pinned sign structure: Δ_pol ∝ ω_E² away from zero, **sign reversal at ω_E ≈ −0.89 ω_dia,e**, reversal point insensitive to w/ρ_θi; (iii) torque-balance roots at discrete ω̂_E (Diss19 benchmark root ω₀ = −0.93 ω_dia,e) | WCHH96; Waelbroeck & Fitzpatrick PRL 78 (1997); [CHECKED: D23b Fig. 8; Diss19 Fig. 4.18] | +| B5a | **DK-NTM threshold**: w_c ≃ 2.76 ρ_θi (half-width) ≡ 8.73 ρ_bi at ε = 0.1 (ρ_bi = ε^{1/2}ρ_θi; the "8.73" is a unit conversion, not printed in I19). Config: circular, ε = 0.1, L̂_q = 1, m/n = 2/1, T_e = T_i, ω_E = 0, **:original** drift model. [VERIFY: I19 run collisionality — I19 §4.2 states ν_★ = 0.01; L23 p. 82 quotes DK-NTM at ν_★ = 10⁻³ — resolve before pinning tolerance] | [CHECKED: I19 Fig. 9; PRL p. 4] | +| B5b | **RDK-NTM improved-model threshold**: w_c ≈ 0.45 ρ_θi (0.46 fit) ≡ 1.41–1.47 ρ_bi half-width ≡ 2.85 ρ_bi full width. Config: as B5a but **:improved** drift model (L̂_B⁻¹ = 0 proxy), ν_i★ = 10⁻³–10⁻⁴, Φ′_eqm = 0, η_j = 1. The 8.73 → 1.46 pair is authoritative in the D23a abstract | [CHECKED: D21 abstract + Fig. 8; D23a abstract] | +| B5c | **kokuchou finite-ν_★ threshold surface**: w_c[r_s] ≈ 0.440 ρ̂_θi + 0.0178 ν_★ − 7.54×10⁻⁵ (2D OLS, R² = 0.9916), and per-ν_★ fits w_c/ρ̂_θi = {0.397, 0.427, 0.451, 0.487} at ν_★ = {5, 10, 15, 20}×10⁻³. Validity: ρ̂_θi ∈ [1,5]×10⁻³, ε = 0.1, ω_E = 0, m/n = 2/1, **L23-amended equation set**. The ν_★-dependence of the threshold is the new physics here | [CHECKED: L23 Eqs. 6.3.1–6.3.2, Figs. 6.10–6.12] | +| B6 | Δ 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³); layer effect on threshold: w_c 0.78 → 0.52 ρ_θi at ω_E = 0, ν_i = 10⁻³ | [CHECKED: D23b §3–4] | +| B7 | 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-β trends vs. D23a, now with exact targets: (i) triangularity — threshold FULL width 2w_c from 1.82 ρ_bi at δ = +0.42 to 2.90 ρ_bi at δ = −0.5 (ν_★ = 10⁻⁴, m/n = 2/1, Miller geometry), destabilizing trend confirmed against the DIII-D 2/1 onset database; (ii) aspect ratio — w_c ∝ ε^{1/2}ρ_θi up to ε ≈ 0.3, crossing to w_c ∝ ρ_θi beyond; (iii) β_θ — w_c grows with β_θ, benchmarked vs. EAST discharge 91972 RMP-triggered 2/1 onset | [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 — reproduce the + 8.73 → 1.46 ρ_bi finding (B5a/B5b configs), then extend across parameter + space. The toggle is one term (L̂_B⁻¹ handling, docs/01 §2.1) — the cheapest + high-impact study in the program. +- 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} — 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). 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 00000000..bfdc7b33 --- /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 00000000..725b0e35 --- /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 00000000..4a590239 --- /dev/null +++ b/docs/src/islands/design/08-reference-library.md @@ -0,0 +1,60 @@ +# 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 | +|---|---|---|---| +| **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 | + +## Referenced but not yet in the library (acquire) + +- Wilson, Connor, Hastie & Hegna, PoP 3, 248 (1996) — **WCHH96**. The analytic + electron closure (its Eq. 55/74 lineage) and the large-w limit target + (its Eq. 85) are load-bearing for docs/01 §2.4 and ladder B2; currently + cited via I19/L23/Diss19 transcriptions only. +- Park, Phys. Plasmas 29 (2022) — SLAYER. Needed for the D1 Q-convention map. +- 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/M1-launch-prompt.md b/docs/src/islands/design/M1-launch-prompt.md new file mode 100644 index 00000000..1ea55cfe --- /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 00000000..2b406ce1 --- /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 00000000..eef120d4 --- /dev/null +++ b/docs/src/islands/design/M2b-launch-prompt.md @@ -0,0 +1,118 @@ +# 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` +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 B-ladder starts running**: for cleared configurations, un-skip the + corresponding `benchmarks/islands/` scripts and run them with the docs/05 + reporting rules (grid-convergence + tolerance archived with every result; + half-widths with both `ρ_θi` and `ρ_bi` stated). The DoD is **benchmarks + running with archived artifacts and honest triage** — B5 threshold + *agreement* is the Level-0 gate, not this milestone's precondition; + disagreements are triaged per docs/05 rule 3 (our bug / their approximation + / their published-equation error / transcription error) with the resolution + logged before any conclusion. +5. Q4 source acquisition: WCHH96 and Park PoP 29 (2022) PDFs into the `docs/08` + library (ask the user — they may have them); escalate in `QUESTIONS.md` if + unavailable and proceed with what the in-repo sources support. +6. 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. +7. 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; the suite +red; docs not updated with the as-implemented equations; or the tree dirty. +Conversely: B5 threshold *disagreement* after honest triage is a reportable +result, not a failure — do not chase agreement past what the derivations +support. 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 00000000..4021ae87 --- /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 00000000..6431920b 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 00000000..06c1890f 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 00000000..1c13c178 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 00000000..5b2a4dd6 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 00000000..d885a9b6 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 00000000..bb3f44c8 --- /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 00000000..32f9fb74 --- /dev/null +++ b/docs/src/islands/numerics.md @@ -0,0 +1,302 @@ +# 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 , +``` + +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`` | island-induced streaming frequencies | +| `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`` | the closure coefficient ``\alpha`` | + +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). + +*Implementing symbols:* `Operators.AbstractTerm`, `Operators.apply!`, +`Operators.residual!`, `Operators.IslandStack`. + +## 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. 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 | +| — | 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}.jl`); the +`islands_l0_structural` regression case tracks the headline numbers across +commits. + +## 9. What comes next + +The physics story — the drift-kinetic coefficients, the York threshold triangle +(DK-NTM ``8.73\,\rho_{bi}`` → RDK-NTM ``1.46\,\rho_{bi}``), the +``\Delta_{\text{pol}}(\omega_E)`` sign-reversal curve — is written as the +[Paper I figure contract](papers/paper-1/OUTLINE.md) and un-gates claim by +claim as the human clearance queue (`QUESTIONS.md` Q2–Q4) is worked through. +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 00000000..bfc3704d --- /dev/null +++ b/docs/src/islands/papers/paper-1/OUTLINE.md @@ -0,0 +1,44 @@ +# 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 | At large `w` the solver recovers the analytic MRE limits: `Δ_bs + Δ_cur ∝ 1/w` (WCHH96 Eq. 85, frame-mapped) and `Δ_pol ∝ 1/w³` | F5: `Δ` channels vs. `w` with analytic asymptotes | B2 | **gated** — Q2, Q3, Q4 (WCHH96 acquisition; `ψ̃` [VERIFY]) | +| C6 | The three-code threshold triangle is reproduced in its exact configurations: DK-NTM `w_c ≃ 2.76 ρ_θi ≡ 8.73 ρ_bi` (`:original` drift model), RDK-NTM `w_c ≈ 0.45 ρ_θi ≡ 1.46 ρ_bi` (`:improved`), kokuchou's finite-`ν_★` surface `w_c(ρ̂_θi, ν_★)` — and the 8.73 → 1.46 shift is a single drift-model toggle (E1) | F6: threshold `w_c` vs. configuration (the triangle); F7: the E1 toggle scan | B5a, B5b, B5c (E1) | **gated** — Q2 (D7/D8), Q3, Q4 (B5a collisionality) | +| C7 | The polarization contribution is frame-pinned: `Δ_pol ∝ ω_E²` away from zero with sign reversal at `ω_E ≈ −0.89 ω_dia,e`, insensitive to `w/ρ_θi`; single-`ω_E` `Δ` values are misleading (surfaces over `(w, ω_E)` are the deliverable) | F8: `Δ_pol(ω_E)` reversal curve vs. D23b Fig. 8; F9: `Δ(w, ω_E)` surface | B4 | **gated** — Q2, Q3 (frame-convention signs) | +| C8 (candidate headline) | 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) | F10: electron-channel `Δ_pol(ω_E)` decomposition | B4 + E4 | **gated** — Q2, Q3; kinetic-electron toggle is M2+/E4 work | + +## 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) with `[VERIFY]` resolution logged first. + +## Dependencies for un-gating (the human clearance queue) + +`QUESTIONS.md` **Q2** (ratify D7/D8), **Q3** (clear the L0 `[CHECKED]` +coefficient set), **Q4** (resolve the `ψ̃` and B5a-collisionality `[VERIFY]`s; +acquire WCHH96 + Park 2022). C1–C3 are already green as CI artifacts; C4–C8 +un-gate in that order of effort once the queue clears. diff --git a/regression-harness/cases/islands_l0_structural.toml b/regression-harness/cases/islands_l0_structural.toml new file mode 100644 index 00000000..091b4949 --- /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 8705cb4d..d6aa9b21 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 e7ecef8a..26750e78 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 00000000..61ce2cc7 --- /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 00000000..9fec2f9e --- /dev/null +++ b/src/Islands/Islands.jl @@ -0,0 +1,44 @@ +""" + 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("solvers/Solvers.jl") # Newton–Krylov, preconditioner, continuation +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 .Solvers +import .Verify + +end # module Islands diff --git a/src/Islands/fields/Fields.jl b/src/Islands/fields/Fields.jl new file mode 100644 index 00000000..a050d988 --- /dev/null +++ b/src/Islands/fields/Fields.jl @@ -0,0 +1,143 @@ +""" + 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 form (sources: `≃ 1 − 1.46√ε`). + - `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 00000000..079167d9 --- /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 00000000..a9c4d71d --- /dev/null +++ b/src/Islands/moments/Moments.jl @@ -0,0 +1,133 @@ +""" + 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ψ̃` with `ψ̃` under an open `[VERIFY]`, and the sin-moment +normalization still `[DERIVED]`-unpinned — QUESTIONS Q4). Both are **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 + +""" + 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 00000000..3a7afba5 --- /dev/null +++ b/src/Islands/operators/Operators.jl @@ -0,0 +1,643 @@ +""" + 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(α) + +The Level-0 field residual (`01 §3`): `R_Φ = M[g] − α Φ`, where `M[g]` is the +velocity moment `∫dy ∫dE Σ_σ g` (Gauss in `E`, Simpson in `y`) and `α` encodes +the flattened-electron closure scaling (structure of `e_iΦ̂/T_i/(2 L̂_{n0})`, +supplied). This closes `Φ(x, ξ)` inside the global Newton system rather than the +sources' fragile nested Picard loop (`01 §3`, `03 §3`). +""" +struct Quasineutrality{S} <: AbstractTerm + α::S +end + +# --------------------------------------------------------------------------- +# 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.Φ + 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 00000000..025132fa --- /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 00000000..2b4799cf --- /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 00000000..f1948150 --- /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 00000000..b41f3054 --- /dev/null +++ b/src/Islands/verify/Verify.jl @@ -0,0 +1,460 @@ +""" + 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 + +# --------------------------------------------------------------------------- +# 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 + +end # module Verify diff --git a/test/runtests.jl b/test/runtests.jl index 2ae4125e..98a7bfb5 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -36,4 +36,7 @@ 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") end diff --git a/test/runtests_islands_grids.jl b/test/runtests_islands_grids.jl new file mode 100644 index 00000000..10dd3833 --- /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 00000000..007a3ee7 --- /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 00000000..2a56c7cf --- /dev/null +++ b/test/runtests_islands_solve.jl @@ -0,0 +1,227 @@ +# 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 "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 stay NaN-gated (Q3) + @test isnan(Fi2.ElectronClosure().k_HS) + 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