diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 4a19d51..5169f0c 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -58,7 +58,11 @@ When you change or add a model, update the docs in the same PR:
line at the top of the section. A green test that asserts a known disagreement
is a `pinned` row, not a `validated` one; if you cannot say which of the four
statuses your new gate has, the gate is not finished. An asserted constant with
- no test behind it gets an `ungated` row rather than no row.
+ no test behind it gets an `ungated` row rather than no row. The census and the
+ four statuses are **enforced** by `tests/docs.rs`, which fails with the
+ corrected line — but only the counts are mechanical. A row's *number* is
+ checked by nobody: the ground truth for it is the `assert!` in the gate the row
+ names, so quote that, and re-read it whenever a default changes.
- **`docs/M*_SPEC.md`** — per-milestone pre-spec gates and design records. New
milestone work is spec'd here before it is implemented.
- **`README.md`** — the milestone status table and the CLI examples. A finished
diff --git a/README.md b/README.md
index f3ac0db..3f2e9c1 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,7 @@ Several effects stack when a laser crosses air, and `beamprop` aims to model eac
- **Thermal blooming** — the beam heats the air, the refractive index changes, wind and slew clear it, and the beam self-distorts. A coupled radiative-transport ↔ thermal-fluid problem.
- **Optical breakdown** *(in progress)* — above a threshold irradiance the air ionizes: an electron avalanche, then a plasma that absorbs the beam that made it. A standalone 0-D rate kernel (M6a) supplies the ignition threshold.
- **Laser-supported detonation** — once lit, that plasma does not sit still: the absorption wave runs *back up the beam* toward the laser as a detonation, at kilometres per second, closing the channel behind it. A 1-D Euler solver with laser deposition (M6c), coupled to the propagator as a pure absorber.
+- **Radial relief** — a real beam has a finite diameter, so the shocked gas escapes sideways and the detonation runs slower than the planar theory says. An axisymmetric `(r, x)` Euler solver (M6d) measures the cost: about a quarter of the front speed. It also shows the front going **transversely unstable**, growing the cellular structure real detonations have.
## Scope
@@ -29,7 +30,7 @@ The table below is a summary; the **[claims ledger](docs/MODELS.md#claims-ledger
is the precise version. It states, claim by claim, what is *verified* against a
closed form, *validated* against an external measurement, *pinned* as a known
disagreement asserted green so it cannot drift, and simply *ungated*. The suite's
-219 passing tests are not 219 validations — ten claims in this solver are checked
+243 passing tests are not 243 validations — ten claims in this solver are checked
against external measured data, and the *level* of the breakdown-threshold curve
is not one of them (its high-pressure slope now is).
@@ -39,13 +40,15 @@ is not one of them (its high-pressure slope now is).
| M1 | Symmetric split-step propagator through a `Medium` trait, validated: Gaussian evolution & divergence <1%, power conservation ~1e-14, boundary wraparound, 2nd-order convergence, long-throw Fresnel path | **done** |
| M2 | Beer–Lambert attenuation via the `Medium` trait, Kruse visibility model, validated: uniform extinction matches `exp(−α·z)` to ~1e-13, transverse absorber removes exactly the predicted power, `α = 0` bit-identical to vacuum | **done** |
| M3 | Von Kármán phase screens (FFT + subharmonics) + reproducible Monte-Carlo, validated: Kolmogorov structure function <10% over a decade of lags, long-exposure spread 0.5% off Andrews–Phillips, scintillation index 1.6% off Rytov, bitwise thread-count reproducibility | **done** |
-| M3.5 | M4 pre-spec gate ([docs/M4_SPEC.md](docs/M4_SPEC.md)): fluid model (steady-state isobaric, convection-dominated), slab-local predictor–corrector coupling with a 2nd-order gate, stability/resolution bounds, closed-form anchor benchmark (erf blooming phase) + Gebhardt/Smith trend curve, air-property tabulation pinned (no FFI) | **done** |
+| M3.5 | M4 pre-spec gate ([spec](docs/M4_SPEC.md)): fluid model, coupling scheme, stability bounds and the two anchor benchmarks (closed-form erf blooming phase, Gebhardt/Smith trend curve) pinned before code; air properties tabulated, no FFI | **done** |
| M4 | Coupled thermal blooming (steady-state isobaric, convection-dominated) through a field-aware `Medium`, frozen air-property table, validated: closed-form erf blooming phase 0.39% max, coupling 2nd-order by self-convergence (slope 2.000), weak-blooming first-order limit 0.008% with quadratic back-reaction residual (ratio 3.65 vs 4), stable at N_φ = 20 with closed power budget, upwind bend + crescent + irradiance-rollover signatures, and the Smith-1977 whole-beam I_REL(N) curve reproduced to 7.2% over N ∈ [0.5, 1.8] (F₀ = 5) | **done** |
-| M5 | Python bindings (PyO3, abi3) + CI wheels ([docs/M5_SPEC.md](docs/M5_SPEC.md)): `import beamprop` exposes the core classes and `run_*` helpers — since bindings v2 that is **every** CLI case, the three propagation ones plus `run_breakdown`, `run_lsd` and `run_ignition` — validated: CLI compute loops extracted to shared pure runners with bit-identical `.npy` outputs, Python results bit-identical to the CLI for all three cases, closed-form Gaussian width <1% (≈2e-11 observed), seed-exact Monte-Carlo determinism, solver validity errors as `ValueError` (including M6's refuse-don't-mis-model guards, and the LSD below-threshold *clean report* arriving as data rather than an exception); wheels built+gated on linux/macOS/windows in CI | **done** |
-| M6a | 0-D optical-breakdown threshold kernel ([docs/M6A_SPEC.md](docs/M6A_SPEC.md)): electron-avalanche balance (inverse-bremsstrahlung heating − inelastic loss − attachment − diffusion), exact per-slice logistic integrator, log-bisection threshold, pressure sweep, `breakdown` CLI case. Validated against Thiyagarajan & Thompson 2012 (Fig. 4, digitized): collision frequency 1.05× of literature and flat over 46–1858 Torr, `E_eff(p)` slope `p^+0.695` vs predicted `p^+0.642`, wavelength scaling `λ^-2.000` over a 20× span matching the paper's cascade closed form (Eq. 4) with the plateau coefficient agreeing to 1.01×. Absolute level sits inside the ungated 3–10× inter-lab scatter. **`D_e` gated (2026-07-30):** kinetic theory ties it to the already-validated `K_m`, so `D_e,ref` = 0.2 m²/s *is* the statement `ε` = 6.740 eV; sweeping the whole band that admits (`ε` = 2 eV → `U_i`, a 6.0× range) moves the fitted slope only 0.053 → 0.155 (mean-trajectory closure) against a measured 0.329, so `D_e` **cannot** explain the slope gap — and it exposes a pinned 2.25× inconsistency with the `⟨ε⟩` = 3 eV the loss term assumes. **General-gas kernel (2026-07-30):** gas constants split into a `Gas` value (bit-identical output), unlocking Chylek's He/Ar/Xe curves. The cascade's plateau floor `δ_eff·U_i·m_e c ε₀ ω²/e²` contains *no* transport constant — `ν_m` cancels exactly — so for a monatomic gas, where `δ = 2m_e/M` is the atomic mass, it is a **parameter-free** prediction: the He > Ar > Xe ordering is right, the spacing is not (He/Ar 15.6 predicted vs ≈2.5 measured), and the headroom above the floor runs 1.85× / 7.8× / 13.2×, mass-ordered — though that floor is a hard bound only for the mean-trajectory closure, which the next entry replaces. **Distribution-resolved cascade (2026-07-30):** the mean-trajectory closure ionizes only above `ε_∞ = U_i`, a hard bifurcation the model sits on top of (`ε_∞/U_i` = 1.032 at 760 Torr). Replacing the trajectory with the Ornstein–Uhlenbeck process the photon shot noise `D_ε = ½·P_heat·ħω` implies — **no new constant** — and solving first passage to `U_i` by Siegert's formula **fixes the high-pressure branch**: at the untouched literature centre the T&T slope goes 0.0951 → 0.2793 (measured 0.329) and Chylek's 0.1717 → 0.4665 (measured 0.468), moving the measurement from *outside* the model's one-free-constant envelope to *inside* it. It does **not** fix the low-pressure branch (1.952 → 1.954 vs 0.428, so that failure is diffusion, not the cascade closure) and does **not** close the wavelength gap (4.00 → 3.39 vs ≈0.80 — the right sign at last, 15 % of a 5× gap). **Promoted to default** once those numbers were measured, which **retired M6a's red gate**: `tt2012_threshold_slope_matches_measurement` had been `#[ignore]`d and failing since 2026-07-25, and now passes with no tolerance moved and no constant touched — the measured 0.329 sits inside the `δ_eff` envelope `[0.183, 0.407]` where the old closure's `[0.023, 0.231]` excluded it. The suite has no ignored gates left. Promotion also refined an M6c claim: M6a's threshold is an *asymptotic* intensity floor rather than a flat one — 8.510e15 at 6 ns converging to 6.797e15 by ~10 µs, a bounded 1.25× fall, not the fluence criterion that would break M6c's two-stage argument. **Free-molecular escape (2026-07-30):** the low-pressure branch turned out to be a *validity* failure, not a value one. `ν = D_e/Λ²` is a continuum random-walk result, and the Knudsen number `Kn = λ_mfp/ℓ` runs 0.013 at 760 Torr to **0.96 at 10 Torr** — the kernel was applying a continuum formula in the collisionless regime, across the whole window where it was worst. Escape time is the diffusive time *plus* the ballistic transit time, `ν_esc = 1/(Λ²/D_e + ℓ/v̄)`, with **no new constant** (`v̄ = √(3·D_e,ref·p_ref·K_m)` = 6.740 eV, the same energy `D_e` implies) and `ℓ = 4V/S` the Cauchy mean chord of the pinned focus — which is **4.0× `Λ`**, since `Λ` is a diffusion eigenvalue and not a distance. That takes the low-pressure slope **1.954 → 1.293** against a measured 0.428: about half the error, and the gate asserts the remaining 2.6× as loudly as the improvement. Both source papers say MPI dominates below 100 Torr, which is what a cascade-plus-loss model cannot reach. **PPT for molecular O₂ (2026-07-31):** the multiphoton question closes, and not the way it was framed. Keldysh's soft prefactor was replaced by PPT's *derived* one — fully determined once `Z_eff` is given, and `Z_eff` = 0.53 for O₂ is published (Talebpour 1999), so nothing is fitted. The rate is then **validated in absolute magnitude against a measured cross-section** rather than against threshold data: `σ₈ = (3.3 ± 0.3)×10⁻¹³⁰ W⁻⁸m¹⁶s⁻¹` for O₂ at 800 nm, obtained by counting the electrons directly with Rayleigh microwave scattering (Sci. Rep. **8**, 2874 (2018)) — and it lands at **1.99×**, high, the direction that paper reports for purely theoretical predictions. `K` = 8 there sits between the kernel's `K` = 11 and 6, so it is interpolation. Two consequences. **The prefactor escape hatch is shut**: `n* = Z_eff/κ` = 0.563 makes the Coulomb exponent `2n* − 3/2` *negative*, so the correction that lifts an atomic rate by orders of magnitude is order-unity for a molecule — the λ ratio moves only 3.349 → 2.947 against a measured 0.80, 16 % of the gap, and that is now a statement about a validated rate rather than about a free multiplier. **And the two anchor experiments turn out not to be in the same regime**: evaluated at each paper's own measured threshold, with no model threshold in the calculation at all, multiphoton ionization supplies 3.15 electrons per pulse at Chylek's 532 nm point — the measured threshold *is* the seeding threshold, to 17 % — against 5.4×10⁻⁹ at T&T's 1064 nm point, which is 5.7× short. So the residual wavelength discrepancy is part cascade closure and part a comparison between two different mechanisms. A gate written to assert PPT's ponderomotively shifted order also found, by failing, that the above-threshold sum returns the **integer** photon order (10.998 at 1064 nm where `ν` = 10.34) where the bare Keldysh exponential gives a fractional one. **Seed production (2026-07-31):** the last knowingly-false assumption on the default path, deleted. The kernel started every pulse from `n_e0 = 1/V_focal` — one electron assumed present in the focus — and clamped `n_e` at that value throughout. Both are gone: the initial condition is now the physical ambient density `n_e0 = q/ν_att`, the equilibrium between cosmic-ray ionization and the kernel's **own already-validated** attachment rate, and the pulse produces its own electrons through the PPT channel above. **The retired assumption was wrong by ~14 orders, not the ~10⁴ the docs claimed** — that comparison was against the cosmic-ray *ion* density, but air is electronegative (`ν_att` = 6.7×10⁷ s⁻¹ at 1 atm, so a free electron survives ~15 ns), making the free-*electron* background 0.149 m⁻³, i.e. **1.2×10⁻¹⁴ electrons in the focus**. The lower atmosphere holds essentially none, and a tight focus cannot expect to find one waiting. **The one new constant is not load-bearing, and that was the acceptance test rather than an afterthought**: the threshold is *bit-identical* across twelve decades of seed density and takes ~10 orders to move 0.04 %, whereas the retired `1/V_focal` sat exactly where the seed does matter — the old constant was load-bearing and wrong, the new one is neither. **It repaired the low-pressure branch**, M6a's worst residual for the milestone's entire life and the one *both* source papers attribute to multiphoton ionization: Chylek's 10–100 Torr slope goes **1.292 → 0.501** against a measured 0.428, from 3.0× too steep to 1.17×. The largest single improvement M6a has had, and it came from deleting an assumption rather than adding a term. The wavelength ratio moves 3.39 → 2.854 against 0.80 (overshoot 4.24× → 3.57×), since multiphoton production is `I⁶` at 532 nm against `I¹¹` at 1064. Window independence now holds for a better reason — production replaced the decaying initial condition the floor was patching, and the spread over `w ∈ [1,4]` is 5×10⁻⁵ — and the threshold stays an intensity floor (8.815e15 at 6 ns → 6.745e15 by ~10 µs, a bounded 1.31× fall), so M6c's two-stage argument is untouched. The costs are recorded rather than smoothed over: the 300–786 Torr window slips 0.431 → 0.386 against 0.468, and a mid-pressure residual survives. **That residual was then diagnosed properly (2026-07-31), starting with a correction to how it had been measured:** the three wide windows compare the model's *local* behaviour against a measured *window* average, which overstates it and misplaces it. Redone like-for-like on six narrow bands over the same abscissae, Chylek's own curve turns out **not** to be locally flat (its local exponent runs 0.31–0.55), and the kernel tracks it to better than 0.25 everywhere except **70–350 Torr**, where it is 2.0–2.3× too steep — the one band nothing masks, since free-molecular escape and multiphoton seeding both bite below ~30 Torr and diffusion is sub-dominant to the cascade plateau above ~350. Two candidates were then tested and **both fail**: it is *not* the absolute level (sweeping `δ_eff` walks the level from 15.8× to 3.4× and the bump gets **worse**, peak local exponent 1.04 → 1.42 — which also disposes of "fit `δ_eff`"), and it is *not* space-charge screening (ambipolar diffusion above the Debye threshold `n_e` = 6.2×10¹⁸ m⁻³ was prototyped with a cited ion mobility and **redistributes** the error rather than removing it — 70–150 Torr improves 1.022 → 0.339 while 4–12 Torr degrades 0.237 → 0.702 — for ~13 % net at the cost of a new constant, so it was not landed). The residual is a genuine shape defect in the continuum diffusion loss, unreachable by any constant the model already carries, and it is M6a's sharpest open question. The independent-anchor debts this leaves open are recorded in [docs/MODELS.md](docs/MODELS.md) | **done** |
-| M6c.0 | M6c pre-spec gate ([docs/M6C_SPEC.md](docs/M6C_SPEC.md)): 1-D Euler + laser deposition for the laser-supported-detonation wave — laser-agnostic HLLC/MUSCL-Hancock core, Strang-split source, plasma column coupled to the propagator as absorption-only (read-only `Medium`, no Drude index), offline plasma-property table. Gates pinned before code: Sod + observed-order (solver verification), Raizer's LSD velocity closed form (**verification, not validation** — it is the Chapman–Jouguet construction the model is built from), the parameter-free `D ∝ S^(1/3)`, `ρ₀^(−1/3)` scaling as the physics gate, energy-budget closure, table consistency, and absolute velocity vs measurement documented but ungated (a planar solver has no radial relief, so the known experimental gap is a prediction of the omissions) | **done** |
-| M6c | Laser-supported detonation wave ([docs/M6C_SPEC.md](docs/M6C_SPEC.md)): laser-agnostic 1-D Euler core (HLLC + MUSCL-Hancock, minmod, per-step CFL, positivity guard that bails rather than clamps), `IntensityScale` extracted from M4 (T4), frozen equilibrium plasma table to 30,000 K, the coupled `LsdColumn` driver (Beer–Lambert attenuation, discretely conservative deposition, Strang-split source), and the `lsd` CLI case + `scripts/render_lsd.py`. **Verification:** Sod vs the exact Riemann solution, L1(ρ) 6.55e-3 → 6.55e-4 over n = 100 → 1600 at rate 0.79–0.88 (G1); observed order 1.86 → 1.94 on smooth flow (G2); coupled hydro↔source order 1.99/2.03/1.99 against a deliberate 1st-order contrast at 0.88/1.02/1.07 (G2b); Raizer's LSD velocity 5402 vs 5392 m/s, +0.19% (G3 — **verification, not validation**: it is the Chapman–Jouguet construction the model is built from); residual −8.26% → +0.19% as the absorption layer halves (G3b); seed-independent to 1.1e-3 (G3c); energy budget closed to 2.1e-16 (G5); plasma table vs direct Mutation++ off-grid, worst 1.48e-3 in `n_e` (G6); a real beam marched through the plasma column matching `exp(−τ)` to 1.7e-13 at τ = 339, with `δn ≡ 0` asserted at every slab (G8 — D7's absorption-only coupling, gated end to end). **Physics gate (G4):** `D ∝ S^(+0.33190)` over 1.52 decades and `ρ₀^(−0.33020)` over 1.50 decades, gated inside ±0.01 of ±1/3, with the level shown to move 59% under `γ` while the exponents move by 0.001. The demonstration run ignites at the M6a threshold and tracks the wave (`D` = 5401 vs 5391 m/s); its headline is that M6a's threshold is an *intensity floor* — it does not fall with pulse length — so the sustaining drive sits 10⁵ below the intensity that could light the wave, which is the model reproducing why an LSD wave needs a separate initiating spark. G7 (absolute velocity vs measurement) is documented and **ungated on purpose**: a planar solver has no radial relief | **done** |
-| M6a.2 | Turbulence-degraded ignition statistics ([docs/M6A2_SPEC.md](docs/M6A2_SPEC.md)): pupil optics (`src/aperture.rs`) + the Monte-Carlo driver + the `ignition` CLI case and `scripts/render_ignition.py`. **No focal grid** — in the Fraunhofer regime the on-axis focal amplitude is the DC component of the pupil field's Fourier transform, so peak focal intensity is a pupil integral; turbulence needs centimetre samples over a kilometre while the focal spot is micrometres across, and one grid cannot carry both. **Physics gates:** pupil residual phase variance against Noll (1976) — tip/tilt-removed **0.1407 vs 0.134**, banded at ±12% by the measured ensemble spread (N1), and piston-removed converging to 1.0299 as `L₀/D → ∞`, 0.34 → 0.99 over `L₀/D` = 10 → 2000 (N2); RMS focal-spot wander `∝ Cn²^(+0.4953/0.4977/0.4987)` against the theoretical 1/2 (W1); an aperture-dependence gate (W2) was landed and then **retired as seed-dependent** — the observation (the pupil only matters once it truncates the beam) is documented, but the fitted exponent swings −0.10 to −0.32 across seeds at any affordable ensemble size, so it was measuring the draw. **Verification:** the estimator reduces to Maréchal in the weak limit, a pure tilt steers without dimming, an amplitude-only perturbation is not counted as wavefront error, tilt survives phase wrapping where a plane fit does not; ensemble convergence and bitwise thread-count reproducibility (E1, E2). **Ungated by design:** where the ignition curve sits on the `Cn²` axis rides M6a's absolute threshold, so the shape is the result and the position is not — the figure says so in-panel. A `(D/r₀)^(5/3)` exponent gate was specified, implemented, passed at 1.66667, and **withdrawn as a tautology** (the generator scales the screen linearly in `r₀`); so was a width gate, for having no independent anchor | **done** |
+| M5 | Python bindings (PyO3, abi3) + CI wheels ([spec](docs/M5_SPEC.md)): `import beamprop` exposes the core classes and a `run_*` helper for **every** CLI case. Gated: Python results bit-identical to the CLI, closed-form Gaussian width <1% (≈2e-11 observed), seed-exact Monte-Carlo determinism, solver validity errors surfaced as `ValueError`; wheels built and gated on linux/macOS/windows | **done** |
+| M6a | 0-D optical-breakdown threshold kernel ([spec](docs/M6A_SPEC.md)): electron-avalanche balance (inverse-bremsstrahlung heating − inelastic loss − attachment − diffusion) with a distribution-resolved cascade closure, PPT photoionization, a produced (not assumed) seed, free-molecular escape, an exact per-slice logistic integrator and the `breakdown` CLI case. **Validated:** the PPT rate in absolute magnitude against a measured O₂ cross-section, landing 1.99× high (Sci. Rep. **8**, 2874); collision frequency 1.05× of literature and flat over 46–1858 Torr; the threshold slope against Thiyagarajan & Thompson 2012 (Fig. 4, digitized) — the measured 0.329 sits inside the model's `δ_eff` envelope [0.174, 0.382] (centre 0.264); Chylek's 10–100 Torr branch at 0.501 vs 0.428 measured. **Pinned (known disagreements, asserted green):** the wavelength ratio at 2.854 vs 0.80 measured, and a mid-pressure shape defect — 70–350 Torr runs 2.0–2.3× too steep, unreachable by any constant the model carries, and M6a's sharpest open question. **Ungated:** the absolute level, which sits inside the 3–10× inter-lab scatter | **done** |
+| M6c.0 | M6c pre-spec gate ([spec](docs/M6C_SPEC.md)): 1-D Euler + laser deposition for the laser-supported-detonation wave — laser-agnostic HLLC/MUSCL-Hancock core, Strang-split source, plasma column coupled to the propagator as absorption-only, offline plasma-property table. Gates pinned before code: Sod and observed-order as solver verification; Raizer's LSD velocity closed form labelled **verification, not validation** (it is the Chapman–Jouguet construction the model is built from); the parameter-free `D ∝ S^(1/3)`, `ρ₀^(−1/3)` scaling as the physics gate; energy-budget closure; and absolute velocity vs measurement documented but **ungated** | **done** |
+| M6c | Laser-supported detonation wave ([spec](docs/M6C_SPEC.md)): 1-D Euler core (HLLC + MUSCL-Hancock, minmod, per-step CFL, a positivity guard that bails rather than clamps), frozen equilibrium plasma table to 30,000 K, the coupled `LsdColumn` driver, and the `lsd` CLI case. **Physics gate (G4):** `D ∝ S^(+0.33190)` over 1.52 decades and `ρ₀^(−0.33020)` over 1.50 decades, gated inside ±0.01 of ±1/3 — with the level moving 59% under `γ` while the exponents move by 0.001. **Verification:** Sod vs the exact Riemann solution, L1(ρ) 6.55e-3 → 6.55e-4 over n = 100 → 1600 (G1); observed order 1.86 → 1.94 on smooth flow (G2); coupled hydro↔source order ≈1.99 against a deliberate 1st-order contrast (G2b); Raizer's velocity 5402 vs 5392 m/s, +0.19% (G3 — **verification, not validation**, and the spec says why); energy budget closed to 2.1e-16 (G5); plasma table vs direct Mutation++, worst 1.48e-3 (G6); a beam marched through the column matching `exp(−τ)` to 1.7e-13 at τ = 339 (G8). The demonstration run's headline is that M6a's threshold is an *intensity floor*, so the sustaining drive sits 10⁵ below the intensity that could light the wave — the model reproducing why an LSD wave needs a separate initiating spark. G7 (absolute velocity vs measurement) stays **ungated** — since M6d, for one reason only: no measured dataset has been anchored | **done** |
+| M6a.2 | Turbulence-degraded ignition statistics ([spec](docs/M6A2_SPEC.md)): pupil optics (`src/aperture.rs`), the Monte-Carlo driver and the `ignition` CLI case. **No focal grid** — in the Fraunhofer regime peak focal intensity is a pupil integral, and one grid cannot carry both centimetre samples over a kilometre and a micrometre-wide focal spot. **Physics gates:** pupil residual phase variance against Noll (1976) — tip/tilt-removed 0.1407 vs 0.134, banded at ±12% by the measured ensemble spread (N1), and piston-removed converging to 1.0299 as `L₀/D → ∞` (N2); RMS focal-spot wander `∝ Cn²^(+0.495–0.499)` against the theoretical 1/2 (W1). **Verification:** the estimator reduces to Maréchal in the weak limit, a pure tilt steers without dimming, ensemble convergence and bitwise thread-count reproducibility (E1, E2). **Ungated by design:** where the ignition curve sits on the `Cn²` axis rides M6a's absolute threshold, so the shape is the result and the position is not — the figure says so in-panel. Three gates were specified and then **withdrawn** — a `(D/r₀)^(5/3)` exponent as a tautology, an aperture-dependence gate as seed-dependent, and a width gate for having no independent anchor; the spec records each | **done** |
+| M6d.0 | M6d pre-spec gate ([spec](docs/M6D_SPEC.md)): axisymmetric `(r, x)` Euler, so a **finite-diameter** beam can relieve laterally — the one effect M6c's planar geometry removed by assumption, and the entire justification its G7 gave for being ungated. Pinned before code: an area-weighted finite-volume discretisation on annular cells, which puts zero area on the axis interface and cancels the geometric source against the pressure flux bit-exactly for a radially uniform state; Strang with the radial sweep outside, so the planar limit is bit-for-bit `Euler1d`; the 1-D HLLC reused unmodified; the axis as odd-parity ghosts. Gates pinned before code: the planar limit, **Sedov–Taylor** (the repo's first multidimensional anchor), 2nd order on smooth axisymmetric flow, conservation in the `r`-weighted measure, the wide-beam limit, and — the headline — the **relief deficit `δ = 1 − D_2D/D_1D` measured and pinned**, required to be insensitive to grid, seed and ignition threshold before it is pinned at all. **No validation gate, on purpose** | **spec'd** |
+| M6d | Axisymmetric gas dynamics and radial relief ([spec](docs/M6D_SPEC.md)): `src/euler2d.rs`, `src/lsd2d.rs` (the coupled column with a finite-diameter beam) and the `lsd2d` CLI case. **Pinned physics:** radial relief costs **δ = 0.230** of the front speed at `R_b·α` = 3.2 and 0.305 at 1.6, monotone in beam radius, banded at ±13 % — a width *measured* from the grid (+6 %), seed (−7 %) and ignition-threshold (±8 %) sensitivities rather than chosen, and shown not to be a boundary effect. M6c's G4 argued relief can only enter as a coefficient; M6d measures it — the exponent survives at `S^0.34666` while the level moves 23 % (G16). **Verification:** the planar limit reproduces `Euler1d` **bit for bit** (G9); Sedov–Taylor exponent 0.38628 vs the exact 2/5, with level and peak compression gated as trends under refinement (G10); 2nd order on smooth axisymmetric flow, 1.861/1.964 against a split-source contrast at 1.030/1.155 (G11); conservation in the `r dr dx` measure to <1e-13 with an escape-flux leg (G12); the axis does not heat, 2.99e-7 → 3.12e-8 under refinement against 3.02e-6 for an even-parity contrast (G13); the wide-beam limit reproduces the 1-D column to 3.1e-13 (G14). **The unlooked-for result:** the modelled front is **transversely unstable**, growing cellular structure out of round-off and saturating at \|u_r\| ≈ 200–400 m/s in planar and axisymmetric geometry alike. A 1-D solver structurally cannot show it, and it had to be separated from relief before the relief number meant anything. G7 stays **ungated**, now for one reason only — the missing measured dataset | **done** |
## Build & run
@@ -79,11 +82,16 @@ cargo run --release -- lsd --out lsd
# the air across a Cn^2 sweep, and where the spark lands
cargo run --release -- ignition --out ignition
+# axisymmetric LSD (M6d): the same detonation driven by a finite-diameter beam,
+# so the shocked gas can escape sideways and the front slows down
+cargo run --release -- lsd2d --out lsd2d
+
# render the images: GIFs/PNGs with physical axes and a labeled colorbar (matplotlib)
python3 scripts/render.py out/turb
python3 scripts/render_breakdown.py out/breakdown # breakdown runs
python3 scripts/render_lsd.py out/lsd # LSD runs
python3 scripts/render_ignition.py out/ignition # ignition sweeps
+python3 scripts/render_lsd2d.py out/lsd2d # axisymmetric LSD runs
# remove generated results (images, .npy and sidecars in the output directory)
cargo run --release -- clean
diff --git a/docs/M6A2_SPEC.md b/docs/M6A2_SPEC.md
index 6e2b9fd..1de1f1c 100644
--- a/docs/M6A2_SPEC.md
+++ b/docs/M6A2_SPEC.md
@@ -68,7 +68,7 @@ Stated up front, in the D5 spirit that M6a learned and M6c applied.
**Not gateable: the position of the `P_ig` curve.** Whether a given `Cn²`
ignites the air depends on M6a's absolute breakdown threshold, which is M6a's
-explicitly ungated quantity (4.8–7.0× above the measured Thiyagarajan & Thompson
+explicitly ungated quantity (3.90–4.69× above the measured Thiyagarajan & Thompson
curve, inside the 3–10× inter-lab scatter). Every `P_ig(Cn²)` this rung produces
inherits that offset. The curve's location on the `Cn²` axis is therefore **a
statement about the model, not about the world**, and must be labelled so
diff --git a/docs/M6C_SPEC.md b/docs/M6C_SPEC.md
index b430347..25c33ee 100644
--- a/docs/M6C_SPEC.md
+++ b/docs/M6C_SPEC.md
@@ -225,7 +225,7 @@ per hydro step dt:
The velocity gates are independent of M6a. The **ignition time and position**
are not: they come from `AirBreakdown`'s absolute threshold, which is M6a's
-explicitly ungated quantity (4.8–7.0× above the measured T&T curve, inside the
+explicitly ungated quantity (3.90–4.69× above the measured T&T curve, inside the
3–10× inter-lab scatter). So M6c can pass every velocity gate while lighting
the spark at the wrong intensity. This is a stated limitation, not a blocker —
`D` depends on the absorbed `S` at the front, not on where ignition happened —
@@ -450,6 +450,25 @@ velocities, and the spec says so in advance rather than discovering it:
- Radiation losses and incomplete absorption push the same direction and are
also out of scope (§ NOT in scope).
+**Amended (M6d): the first bullet is no longer available as an excuse.** Radial
+relief is modelled, and its size is measured and pinned:
+`δ = 1 − D/D_wide` = **0.230** at `R_b·α` = 3.2 and 0.305 at 1.6, i.e. a
+finite-diameter beam costs roughly a quarter of the front speed. That is a real
+effect and it is **not the whole ~2× gap** — so relief is part of the answer,
+not the answer. The remaining candidates are the ones already named here
+(radiation losses, incomplete absorption) plus the production EOS and, new with
+M6d, the assumption that the beam travels in straight pencils and is not
+refracted by the plasma it creates.
+
+M6d also found something that changes how any future G7 comparison must be
+made: the modelled front is **transversely unstable**, so a single run's speed
+carries the instability's signature and a comparison against a 1-D calculation
+would attribute that to relief. See [M6D_SPEC.md](M6D_SPEC.md) § G14.
+
+**G7 therefore remains ungated for exactly one reason: there is no anchored
+measured dataset.** That is open question 1 below, inherited by M6d and still
+unpaid.
+
So the honest claim M6c can make is: **the 1-D model agrees with CJ/Raizer where
that theory applies, and the gap to experiment is in the predicted direction and
of the predicted order, for reasons the model has explicitly excluded.** That is
@@ -485,7 +504,11 @@ is labelled as such.
here, it is unreachable from this solver. It needs a non-paraxial method,
which is a different solver, not a `Medium`.
- **Radial / quasi-1-D expansion.** Planar first. This is also precisely why
- G7 is ungated and expected high.
+ G7 is ungated and expected high. **Retired (M6d).** Axisymmetric `(r, x)`
+ hydro landed in `src/euler2d.rs`, and the relief deficit is measured and
+ pinned at 23 % of the front speed — see [M6D_SPEC.md](M6D_SPEC.md). The bullet
+ stays here, struck through rather than deleted, so the record of what M6c did
+ not do remains readable.
- **Runtime Mutation++ FFI.** Offline tabulation only (D8/P3). Re-opened only if
the frozen LTE table provably fails.
- **Non-LTE / two-temperature plasma**, and finite-rate ionization kinetics.
@@ -495,11 +518,14 @@ is labelled as such.
- **LSC and LSR regimes.** LSD only; the regime is asserted, not assumed.
- **Recombination / afterglow / multi-pulse.**
- **2-D/3-D hydro.** The propagator is 3-D; the hydro is not, by construction.
+ **Narrowed to 3-D by M6d**, which made the hydro axisymmetric. 3-D remains out
+ of scope: axisymmetry assumes no azimuthal structure.
## Open questions
-1. **Which measured LSD dataset anchors G7.** The M6c counterpart of M6a's
- digitized anchor, and the one input this spec cannot supply itself. Needs
+1. **Which measured LSD dataset anchors G7.** *Inherited by M6d and still
+ open — and now the **only** thing keeping G7 ungated.* The M6c counterpart of
+ M6a's digitized anchor, and the one input this spec cannot supply itself. Needs
the same treatment
`tests/data/tt2012_*.csv` got: a named paper, the figure number pinned in the
provenance header at digitization time, and the setup quoted so the solver's
@@ -547,7 +573,21 @@ is labelled as such.
what *sustains* it — which is the known experimental situation, where LSD
waves in clean air are started on a target, on an aerosol, or by a separate
spike. M6a's ungated absolute level does not touch the conclusion: the gap is
- 10⁵ against a ~7× uncertainty. Pinned by
+ 10⁵ against a ~7× uncertainty.
+
+ **Amended (M6a, 2026-07-30/31): the numbers above are the ones this milestone
+ landed with, and the shape of the claim has since changed.** The
+ distribution-resolved closure removed the hard `ε_∞ = U_i` cutoff, so a longer
+ pulse now buys *something* rather than nothing, and seed production raised the
+ short-pulse end. The threshold is therefore **asymptotic rather than flat**:
+ 8.815×10¹⁵ at 6 ns falling to 6.745×10¹⁵ by 1 ms, a bounded 1.31× fall that is
+ flat to 1 % over the last two decades. The focus figure is now 6 % over a 500×
+ range, and the level uncertainty is 3.90–4.69× rather than ~7×. **The
+ two-stage argument is untouched**, which is the only thing this step depended
+ on: a bounded fall to a floor is still an intensity criterion, and the drive
+ still sits ~10⁵ below it. What would break it is a threshold falling without
+ limit, and the gate below asserts that it does not. Current numbers live in
+ `docs/MODELS.md` § "The `lsd` demonstration run". Pinned by
`the_sustaining_drive_is_far_below_the_breakdown_threshold`.
5. **How hot the run is allowed to get** — *new, and answered (step 4).* The
`Z̄ ≡ 1` ceiling recorded under "Property closure" bounds where `α_IB` can be
diff --git a/docs/M6D_SPEC.md b/docs/M6D_SPEC.md
new file mode 100644
index 0000000..711d318
--- /dev/null
+++ b/docs/M6D_SPEC.md
@@ -0,0 +1,639 @@
+# M6d pre-spec — axisymmetric gas dynamics and radial relief (2-D Euler)
+
+Written **before** any M6d code, per the project's pre-spec discipline (cf.
+[M4_SPEC.md](M4_SPEC.md), [M6A_SPEC.md](M6A_SPEC.md), [M6C_SPEC.md](M6C_SPEC.md)):
+pin the geometry, the discretisation, the axis condition, the beam model, and —
+most importantly — *which* checks are verification, which are a pinned
+measurement, and which would be validation if the data existed. If any of this
+proves wrong during implementation, amend this document first, then the code.
+
+Scope: M6c's hydro is planar 1-D. This milestone makes it axisymmetric `(r, x)`
+so that a **finite-diameter beam** can relieve laterally, and then measures what
+that does to the front speed.
+
+The reason is specific and is written down in M6c. Its G7 — absolute LSD
+velocity against measurement — is ungated, and [M6C_SPEC.md](M6C_SPEC.md)
+§ G7 justifies that with one omission:
+
+> A planar 1-D code has **no radial relief**. […] it is the one effect the
+> geometry has removed by assumption.
+
+M6c's "NOT in scope" says the same thing twice — "Radial / quasi-1-D expansion"
+and "2-D/3-D hydro". **M6d retires exactly those two bullets.** After it, G7 is
+still ungated, but for one reason only: there is no anchored measured dataset.
+That is a different and much smaller claim, and it is the deliverable.
+
+Conventions follow [MODELS.md](MODELS.md): SI units, `f64`. Symbols inherited
+from M6c: `ρ`, `p`, `E`, `γ`, `c`, `D`, `S`, `α_pl`. New symbols: radius `r` (m),
+radial velocity `u_r` (m/s), axial velocity `u_x` (m/s), beam radius `R_b` (m),
+domain radius `R_dom` (m), on-axis front speed `D_2D` (m/s), **relief deficit**
+`δ ≡ 1 − D_2D/D_wide` — amended from `D_1D` once the transverse instability was
+found; see § Results — and front curvature `κ` (1/m).
+
+## Gate decisions (recorded)
+
+1. **Axisymmetric `(r, x)`, no swirl** (`u_θ ≡ 0`, `∂/∂θ ≡ 0`), discretised as
+ an **area-weighted finite volume on annular cells** — *not* as Cartesian
+ fluxes plus a `p/r` source. This is the decision the whole milestone rests on
+ and § "The axis is not a special case" says why.
+2. **A new `src/euler2d.rs`; `src/euler1d.rs` gains visibility changes and
+ nothing else.** `hllc_flux` becomes a free `pub(crate)` function; `flux`,
+ `minmod` and `is_positive` become `pub(crate)`. No change to `Euler1d`'s
+ state, update loop, guards or public API. **CRITICAL guard:** every M6c gate
+ number unchanged, and the `lsd` CLI artifacts byte-identical — verified by
+ `shasum` against hashes recorded before the refactor, the recipe M6a's `Gas`
+ split used.
+3. **Strang, with the radial sweep outside**: `R(dt/2) → X(dt) → R(dt/2)`. Not
+ an aesthetic choice — it is what makes the planar radially-uniform limit an
+ *exact identity* in `R`, so `X(dt)` is bit-for-bit `Euler1d::step(dt)` and
+ G9 can assert equality rather than a tolerance. Sweep order is **not**
+ alternated between steps; that is 2nd order only on average and would make
+ G11 read noise.
+4. **`euler2d.rs` is laser-agnostic.** M6c gate decision 4, carried forward
+ verbatim: the Riemann core has no laser physics in it, so its verification
+ gates test a general-purpose solver against standard closed forms. Laser
+ deposition lives one layer up, in `src/lsd2d.rs`.
+5. **The beam is a bundle of independent parallel pencils** — one Beer–Lambert
+ march per ring, no refraction, no diffraction. Making the beam bend in the
+ radially structured plasma would make the coupling two-way, which
+ [M6C_SPEC.md](M6C_SPEC.md) open question 3 already reserves for a later
+ milestone with its own gate. It is not smuggled in here.
+6. **The headline is verification plus one pinned measurement, not a dataset
+ comparison.** M6d does not depend on acquiring a paper. G7 stays ungated;
+ what changes is its justification. The measured-dataset debt is inherited and
+ restated in § Open questions.
+7. **Deferrals are recorded in this document's "NOT in scope"** (M6c D9). The
+ repo has no `TODOS.md`.
+
+## The circularity, stated up front
+
+M6c had to say this about Raizer, and the same discipline applies here to two
+new things.
+
+**What is still circular.** Raizer's `D = [2(γ²−1)S/ρ₀]^(1/3)` is the
+Chapman–Jouguet construction the deposition model is built from
+([M6C_SPEC.md](M6C_SPEC.md) § "The circularity"). Nothing in M6d changes that,
+so G14 — the wide-beam limit reproducing the 1-D column — is **verification**,
+and so is any comparison of `D_2D` against `D_CJ` in the wide-beam limit.
+
+**What is genuinely not circular, and is new.**
+
+- **Sedov–Taylor.** A self-similar blast solution the model is *not* built from:
+ no laser, no deposition closure, no CJ construction, and it shares only
+ `Primitive`/`IdealGas` with the solver, per the independence rule at
+ `src/validate.rs` § "M6c references". It is the repo's **first
+ multidimensional verification anchor** — there is currently none — and it
+ exercises both sweeps, the geometric source and the axis at once.
+- **The relief deficit `δ`.** No closed form in this model predicts it. It is
+ a measurement of the solver, pinned so its size cannot drift.
+
+**One warning, recorded now so it cannot be forgotten later.** Condensed-phase
+detonation theory has a *diameter effect*: `D` falls roughly as `D_CJ(1 − a/R)`,
+with a failure radius below which the wave dies (Eyring; Wood–Kirkwood). It is
+tempting to quote that as an anchor for `δ`. It is **not** one unless the
+coefficient `a` comes from somewhere other than this solver. If `a` is fitted
+here, the `1/R` law is a *description* of the measurement, not evidence for it.
+This document therefore records the fit in a doc comment and **does not** put it
+in `validate.rs`. Promoting it is § Open questions item 4, and it would need its
+own honesty about what was fitted.
+
+## Gas-dynamic model
+
+### Governing equations
+
+Axisymmetric Euler with a volumetric laser source, `x` along the beam axis (the
+front propagating toward the laser, `−x`, as in M6c):
+
+```text
+∂U/∂t + (1/r)·∂(r·F_r)/∂r + ∂F_x/∂x = Ṡ_geom + Ṡ_laser
+
+U = (ρ, ρu_r, ρu_x, E)ᵀ
+F_r = (ρu_r, ρu_r² + p, ρu_r u_x, (E + p)u_r)ᵀ
+F_x = (ρu_x, ρu_r u_x, ρu_x² + p, (E + p)u_x)ᵀ
+Ṡ_geom = (0, p/r, 0, 0)ᵀ
+Ṡ_laser = (0, 0, 0, q(r, x))ᵀ
+E = p/(γ−1) + ½ρ(u_r² + u_x²)
+```
+
+`Ṡ_geom` is the only non-conservative term. It is not an extra physical effect:
+it is what is left over when the divergence `(1/r)∂(r·)/∂r` is written as a
+plain derivative, because the *pressure* part of the radial momentum flux is not
+divided by `r`.
+
+### The axis is not a special case
+
+The `1/r` above is why axisymmetric codes are reputed to be delicate. It is an
+artefact of writing the scheme in the differential form. On annular
+finite-volume cells it disappears.
+
+Ring `j` spans `[r_{j−1/2}, r_{j+1/2}]`; interfaces carry area `A ∝ r`, cells
+carry volume `V ∝ (r_+² − r_−²)/2`. The radial update is
+
+```text
+U_j ← U_j − (dt/V_j)·(A_{j+1/2}·F_{j+1/2} − A_{j−1/2}·F_{j−1/2})
+ + dt·(0, p_j·(A_{j+1/2} − A_{j−1/2})/V_j, 0, 0)ᵀ
+```
+
+**The geometric source is written as `p_j·(A_{j+1/2} − A_{j−1/2})/V_j` — the same
+expression as the flux term — and never as `p_j/r_j`.** Three consequences, each
+of which turns a classic hazard into a structural non-issue:
+
+1. **The axis interface has zero area.** `A_{1/2} = 0`, so nothing crosses
+ `r = 0` by construction. The "`1/r` blows up on the axis" failure cannot
+ arise, because there is no `1/r` anywhere in the code.
+2. **The source is exactly the volume average of `1/r`.** Analytically
+ `(A_+ − A_−)/V = 2/(r_+ + r_−) = 1/r_j` with `r_j` the arithmetic cell
+ centre — finite at `r_1 = Δr/2`. The singularity is removed by the algebra,
+ not by a floor or an epsilon.
+3. **Well-balanced bit-exactly.** For a radially uniform state the pressure flux
+ difference and the source are the same floating-point expression with
+ opposite signs, so they cancel to *bit* precision, not to round-off. A
+ radially uniform state is therefore an exact fixed point of the radial
+ operator. This is what lets G9, G13(i) and G14 assert equality instead of a
+ tolerance, and it is the single most valuable implementation detail in the
+ milestone.
+
+Mass, axial momentum and total energy are exactly conserved in the discrete
+`r dr dx` measure. **Radial momentum is deliberately not conserved** — the
+geometric source is real, and an implementation that conserved it would be
+wrong. G12 asserts both halves of that sentence.
+
+### The axis boundary, and the failure it invites
+
+`r = 0` is a reflective boundary with **odd parity on `ρu_r`** and even parity on
+`ρ`, `ρu_x`, `E`.
+
+Getting this wrong — even parity on `u_r` — produces *wall heating*: a thin,
+artificially hot and under-dense column on the axis that grows with time and
+looks exactly like a physical result. It matters here more than in a generic
+code, because the on-axis ring is precisely where this milestone's headline
+number is measured. Two of the three usual causes (sampling `1/r` at a cell
+centre; a source inconsistent with the face areas) are removed by the
+formulation above. The parity one is removed by the ghost fill and **gated
+directly**, with a deliberate even-parity contrast so the gate is non-vacuous
+(G13).
+
+### What relief is expected to do
+
+The controlling dimensionless group is `R_b·α_pl` — the beam radius in units of
+the deposition length. Relief acts by letting the shocked, heated gas expand
+sideways out of the driving region before it has finished pushing the front, so
+it bites when the transverse relief time is comparable to the time the gas
+spends in the absorption zone.
+
+At M6c's CJ state (`S = 10¹¹ W/m²`, `ρ₀ = 1.225 kg/m³`, `γ = 1.4`): `D` = 5.4
+km/s, post-front sound speed `c₁ = γD/(γ+1)` = 3.1 km/s. With M6c's grey
+`α_pl = 2×10⁴ 1/m` the absorption length is 50 µm, crossed by the front in
+≈9 ns, while a rarefaction crosses a beam of radius `R_b` in `R_b/c₁`. Those are
+comparable at `R_b ≈ 30 µm`, i.e. `R_b·α_pl ≈ O(1)`.
+
+Three predictions follow, and G15 tests them as predictions rather than fitting
+them:
+
+- `D_2D < D_1D` always, and `δ` decreasing in `R_b`.
+- `δ → 0` as `R_b·α_pl → ∞` (G14 is that limit, taken to its extreme).
+- There is a **failure radius** below which the wave does not sustain at all.
+
+It also follows that the front is **curved** — leading on the axis, lagging at
+the beam edge — and that curvature *is* relief made visible. It is a diagnostic
+and a figure, not a gate.
+
+**A practical consequence worth recording, because it sets the cost.** `R_b`
+must be within a couple of absorption lengths for relief to be measurable at
+all, so `R_b` is tens of microns and the relief time `R_b/c₁` is tens of
+nanoseconds — two orders below M6c's `LSD_SETTLE` of 1.8 µs. Relief equilibrates
+long before the front speed has settled from the seed, which means the 2-D runs
+need a *short* column, not a long one. That is what keeps them affordable.
+
+### Equation of state
+
+Verification mode only for every gate in this document: ideal gas at constant
+`γ`, as M6c's G1–G3 use. The production table EOS is not exercised by M6d —
+see § NOT in scope, and M6c's own note that the EOS difference is a ~25 % shift
+in `D` and a wrong reason to expect the experimental gap to close.
+
+### Validity checks asserted at run start
+
+In the M4 Péclet spirit — refuse, don't mis-model. `src/lsd2d.rs` inherits every
+check in `LsdColumn::check_regime` (`src/lsd.rs`) and adds three:
+
+- **The beam is radially resolved.** At least `MIN_CELLS_PER_BEAM_RADIUS` cells
+ across `R_b`. Below that the deposition profile is a staircase and `δ` would
+ be a mesh artefact rather than a measurement.
+- **The domain is wide enough.** `R_dom` at least a stated multiple of `R_b`,
+ *and* the outermost ring undisturbed. Otherwise the outer wall is doing the
+ relief and the number means nothing.
+- **The front is still in the domain**, on the axis and at the beam edge. A
+ curved front can leave through `x_min` at the axis while the edge is still
+ inside, and the on-axis measurement would silently be reading the boundary.
+
+## Numerics
+
+- **HLLC** and **MUSCL-Hancock with minmod**, reused from `src/euler1d.rs`
+ unmodified (see below). CFL ≤ 0.8, asserted per step from the current wave
+ speeds in **both** directions:
+ `dt = CFL / max( max(|u_r|+c)/Δr , max(|u_x|+c)/Δx )`, refused rather than
+ quietly reduced.
+- **Positivity guard after every stage: bails, never clamps** — M6c's rule,
+ M6a's `n_e`-runaway lesson. The bail names the cell `(j, i)`, its physical
+ `(r, x)`, the step, **and the stage**: `r-sweep`, `x-sweep`, or
+ `geometric source`. That last is a genuinely new failure mode — the geometric
+ source adds radial momentum without changing `E`, so it raises kinetic energy
+ at fixed total energy and can drive `p < 0` in the innermost rings of a strong
+ converging flow.
+- **Ghost cells**: `N_GHOST = 2` per side per direction, as the MUSCL stencil
+ needs, materialised per sweep exactly as `Euler1d` does.
+
+### The 1-D Riemann solver is reused, not reimplemented
+
+A 2-D sweep carries a transverse momentum component that `Conserved` does not
+have. It does **not** need a new Riemann solver. In HLLC the transverse velocity
+is constant across the acoustic waves within each star state, so the transverse
+flux is
+
+```text
+F_{ρv} = F_ρ · v_K, K = L if S* ≥ 0 else R
+```
+
+and the supersonic branches (`S_L ≥ 0`, `S_R ≤ 0`) satisfy the same identity
+because they return the physical flux of one side. So a sweep packs
+`(ρ, ρu_∥, E)` into the existing `Conserved`, calls the existing `hllc_flux`,
+and recovers the transverse flux with one multiplication by the upwind
+transverse velocity.
+
+**Zero duplicated Riemann code** — which is the point. The alternative,
+generalising `Conserved` over a component count, would touch every line of the
+flux, reconstruction and update path, i.e. exactly the code M6c's gates measure,
+for no capability this identity does not already give free.
+
+## Beam ↔ plasma coupling
+
+```text
+per hydro step dt:
+ 1. per ring j: march I_j(x) through the current α_pl(r_j, x) — Beer–Lambert
+ 2. deposit q(r, x) = (I_k − I_{k+1})/Δx, discretely conservative per ring
+ 3. Strang: deposit(dt/2) → R(dt/2) → X(dt) → R(dt/2) → deposit(dt/2)
+ 4. recompute α_pl(r, x) from the new state
+```
+
+What does **not** change from M6c, and is reused rather than reimplemented:
+`Absorption` (including `GreyThreshold`, and M6c's reasoning for why the
+verification gates run the grey closure), `IonizationCeiling`,
+`raizer_lsd_velocity`, and the discretely conservative deposition
+`q_k = (I_k − I_{k+1})/Δx` that lets the budget close to round-off instead of to
+a quadrature tolerance.
+
+What is new:
+
+- **Per-ring marches.** `I_j(0) = S·b(r_j)` for a beam profile `b`. Because each
+ ring's march is independently conservative, the `r`-weighted budget still
+ closes to round-off.
+- **`R_b` sits on a cell face**, so a top-hat has no partial cell and `δ` cannot
+ pick up an edge-quantisation artefact.
+- **Relief comes from the beam being finite, not the domain.** With `R_dom` well
+ outside `R_b` the lateral rarefaction is entirely interior, so
+ `boundaries_undisturbed` generalises verbatim (both end planes plus the
+ outermost ring) and there is no boundary-flux accounting at all.
+- **An escape accumulator** for the long demonstration runs where relief *does*
+ reach `r_max`: `escaped_energy()` sums `∮(E+p)u_r dA` per step, so
+ `deposited = ΔE + escaped`. The escaped fraction is itself the physically
+ meaningful number — it is literally the energy relief carries away — and it
+ is what makes G12's second leg possible.
+- **The seed gains a radius.** That is a second free parameter beside its
+ pressure, so M6c's seed-independence discipline (G3c) is extended to it.
+ Otherwise the headline number sits on an unexamined knob.
+
+### Inherited limitation: the beam does not bend
+
+The pencils are straight. A real beam crossing a radially structured plasma
+refracts, and near the front it would be defocused by the density depression.
+This is stated, not modelled (decision 5), and it is the second reason — after
+the missing dataset — that M6d does not claim to close G7.
+
+## Gates
+
+Numbering continues from M6c's G1–G8. Following M6a's and M6c's discipline, each
+gate is labelled by what it actually establishes. **Verification** = "the code
+solves the equations I wrote down". **Validation** = "the equations describe the
+world". **Pinned** = "a known departure, asserted green so its size cannot
+drift".
+
+M6d contains **no validation gate**, and that is a deliberate consequence of
+decision 6 rather than an oversight.
+
+### G9 — the planar limit reproduces `Euler1d` bit for bit (verification)
+
+`euler2d_planar_limit_reproduces_euler1d_bit_for_bit`
+
+Sod data uniform in `r`, `Geometry::Planar`, marched alongside an `Euler1d` of
+the same `n_x`, `dx` and CFL. Assert `==` on every component of every cell at
+every step.
+
+Two non-vacuity legs, both required: perturbing one ring by 1e-12 must make the
+comparison **fail** (the test can see a difference), and an axisymmetric run with
+a radial gradient must diverge measurably (the geometric source is switched on).
+
+This is also the **standing guard** on decision 2: it keeps proving the reused
+HLLC has not drifted long after the one-time byte comparison has scrolled away.
+Cost: milliseconds.
+
+### G10 — Sedov–Taylor point blast (verification)
+
+`sedov_blast_matches_the_self_similar_solution`
+
+**Which Sedov, and why.** In `(r, x)` axisymmetric coordinates a *point* deposit
+gives the **spherical** blast (`ν = 3`, `R ∝ (E/ρ₀)^{1/5} t^{2/5}`); a *line*
+deposit uniform in `x` gives the cylindrical one (`ν = 2`) and degenerates to a
+purely radial problem that exercises one sweep. **The point blast is gated**: it
+is the only problem here that drives both sweeps, the geometric source and the
+axis simultaneously.
+
+Three legs:
+
+- **Exponent (parameter-free, and the tightest).** Fit `log R` against `log t`
+ over the self-similar window with `validate::loglog_slope_xy` and gate the
+ slope inside ±0.01 of 2/5. No `ξ₀` enters, so there is no constant to get
+ wrong. This is G4's idea applied to a geometry gate.
+- **Level.** `R(t) = ξ₀·(E t²/ρ₀)^{1/5}` against the published `ξ₀` for
+ `γ = 1.4`, at a few percent — a finite-size initial deposit is only
+ asymptotically self-similar and a coarse mesh under-resolves the shock.
+- **Jump.** The immediate post-shock density ratio against strong-shock
+ Rankine–Hugoniot, `ρ₂/ρ₁ = (γ+1)/(γ−1) = 6`: a closed form with no constant
+ in it at all.
+
+The reference is a full parametric Sedov solution in `src/validate.rs`, with its
+own unit tests (published `ξ₀`, the exponent identity, the jump, and the energy
+integral returning `E`). CI runs a coarse grid; the L1-under-refinement ladder is
+measured once and recorded in the gate's doc comment, per the out-of-band
+practice G4's `γ` table already uses.
+
+### G11 — 2nd order on smooth axisymmetric flow (verification)
+
+`euler2d_is_second_order_on_smooth_axisymmetric_flow`
+
+Self-convergence against a fine reference, refining `(Δr, Δx, Δt)` together at
+fixed CFL — the structure of M6c's `coupled_pressure_profile` /
+`restrict_profile` / `coupled_orders`, in two dimensions.
+
+Made affordable by choosing a problem that is **r-structured and x-uniform**
+(a smooth radial pressure blob, `n_x` small). It still exercises the r-sweep,
+the axis and the geometric source — the only new terms — while the 8× reference
+costs 8×, not 64×. The x-sweep's order is already G2, on unchanged code.
+
+**Non-vacuity is mandatory**, mirroring G2b: a contrast run applying the
+geometric source Godunov-style outside the sweep must read ≈1. Without it, a
+measurement that cannot tell 1st from 2nd order passes silently.
+
+### G12 — conservation in the `r`-weighted measure (verification)
+
+`euler2d_conserves_mass_and_energy_in_the_r_weighted_measure`
+
+Closed box, no source: `∫ρ r dr dx` and `∫E r dr dx` constant to ~1e-13. With
+the laser on and the boundaries undisturbed: `ΔE = deposited` to ~1e-10 — G5's
+2-D twin.
+
+Non-vacuity, two legs. Axial momentum must **also** be conserved, and radial
+momentum must **not** be — a version that conserved it would be wrong, and this
+is the gate a naive `−G/r` source fails. Second: with the outer wall far out,
+`escaped ≈ 0` and the budget closes; with it close in, the budget closes **only**
+when the escape term is included. That is what proves the accounting rather than
+assuming it.
+
+### G13 — the axis is not a wall (verification)
+
+`the_axis_boundary_does_not_heat_or_starve_the_on_axis_cells`
+
+- **Leg (i)**, an in-module unit test mirroring `euler1d`'s
+ `uniform_flow_is_a_fixed_point`: a uniform state with uniform axial flow is a
+ **bit-exact** fixed point of the *axisymmetric* operator. Well-balancedness in
+ one assertion.
+- **Leg (ii)**, on the G10 run: on-axis entropy `p/ρ^γ` deviates from the
+ neighbouring rings by less than a pinned bound, and that bound **falls** under
+ radial refinement.
+
+**Non-vacuity:** an even-parity `u_r` ghost fill must break leg (ii) loudly.
+Without that contrast the gate proves nothing.
+
+### A cylindrical Sod is deliberately not gated
+
+Recorded so a reader does not wonder why it is missing. A radial Riemann problem
+is self-similar in `r/t` but has **no closed form**, so it could only be a
+self-convergence check — which G11 already provides, more cheaply and with a
+non-vacuity contrast. Adding it would grow the suite without adding an anchor.
+
+### G14 — the wide-beam limit reproduces the 1-D column (verification)
+
+`lsd2d_with_a_full_width_beam_reproduces_the_one_dimensional_column`
+
+A beam wider than the domain, radially uniform: the axisymmetric column must
+reproduce `LsdColumn`'s front speed to round-off.
+
+**This must land before G15.** It is G15's non-vacuity partner: it proves that
+any deficit G15 measures is relief, and not an artefact of the geometric source,
+the ring binning, or the front tracker.
+
+### G15 — radial relief lowers the front speed by a pinned amount (**pinned**)
+
+`radial_relief_lowers_the_lsd_front_speed_by_a_pinned_amount`
+
+**The milestone's headline.** M6c's G3 configuration exactly — same `S`, `ρ₀`,
+`γ`, `α`, seed multiple and settle discipline — except the beam has a finite
+radius. Measure the on-axis front speed and report `δ = 1 − D_2D/D_1D`.
+
+Three legs:
+
+1. **Sign and monotonicity**, as predictions rather than fits: `D_2D < D_1D`
+ always, and `δ` decreasing in `R_b`.
+2. **The pinned number**: `δ` at a named `(R_b, α_pl)`, with a band. This is the
+ ledger row.
+3. **The failure radius**: below some `R_b` the wave does not sustain. Its
+ existence and its value are pinned.
+
+Before anything is pinned, three insensitivities must be demonstrated, because
+each of them is a way for this number to be measuring something else:
+
+- **grid** — `δ` converged under refinement in both directions;
+- **seed** — `δ` insensitive to seed pressure *and* seed radius (G3c extended);
+- **threshold** — `δ` insensitive to the `GreyThreshold` ignition multiple. In
+ 2-D the beam edge can cool below the threshold and the plasma edge retreat, so
+ the wave can self-narrow for reasons belonging to the threshold rather than to
+ relief. This is G4's amendment-3 problem in a new geometry. **If `δ` is
+ sensitive to it, the number is not measuring relief and must not be pinned.**
+
+Status **pinned**: not `verified`, because no closed form predicts it; not
+`validated`, because nothing measured is being compared to.
+
+### G16 — the one-third scaling survives radial relief (verification)
+
+`the_one_third_scaling_survives_radial_relief`
+
+M6c's G4 argues that relief can only enter as a *coefficient*, and that no
+coefficient can produce a 1/3 exponent. In M6c that was an argument. Here it is
+testable: sweep `S` over a decade at fixed `R_b` and check the exponent is still
+≈1/3 while the level has moved by `δ`.
+
+Two to four runs, and it upgrades the strongest claim the project owns from an
+argument to a measurement.
+
+### G7 — absolute velocity vs measurement: still UNGATED, for a smaller reason
+
+M6d does not close G7 and does not claim to. What it changes is the
+justification. After this milestone, "the geometry removed the effect" is no
+longer available: relief is modelled, and its size is pinned. G7 remains ungated
+because **there is no anchored measured dataset** — [M6C_SPEC.md](M6C_SPEC.md)
+open question 1, inherited here unchanged.
+
+Whatever `δ` turns out to be gets written into M6c's G7 section and into
+`MODELS.md`. In particular, **if `δ` accounts for only a fraction of the ~2× gap
+to measurement, that is the result**, and the remaining candidates — radiation
+losses, incomplete absorption, the production EOS, and the un-bent beam — are
+named there rather than left implicit.
+
+## Failure modes (new codepaths)
+
+| Failure | Detection | Response | Silent? |
+|---|---|---|---|
+| Axis ghost parity wrong → on-axis wall heating | G13 leg (ii) + its even-parity contrast | gate fails | no |
+| Geometric source written as `p/r` → not well-balanced | G13 leg (i) (bit-exact fixed point) | gate fails | no |
+| Geometric source applied outside the sweep | G11 reads ≈1 against the contrast | gate fails | no |
+| Geometric source drives `p < 0` in the inner rings | positivity guard, stage-named | **bails**, names `(j, i)`, `(r, x)`, step, stage | no (loud) |
+| Beam radially unresolved → `δ` is a mesh artefact | `check_regime` cell-count check | bails | no |
+| Domain too narrow → the wall does the relief | `check_regime` + `boundaries_undisturbed` | bails | no |
+| Escape flux unaccounted → budget silently open | G12 second leg | gate fails | no |
+| Sweep order slipped (R and X swapped) | G9 bit-identity | gate fails | no |
+| Curved front leaves the domain on the axis | `check_regime` front-in-domain check | bails | no |
+| CFL taken from one direction only | G9 (planar) stays green but G10/G11 destabilise | loud crash or gate failure | no |
+| M6c regression during the refactor | byte comparison of `lsd` artifacts + full suite | caught at step 1, before any 2-D code exists | no |
+
+## NOT in scope (M6d)
+
+- **Beam refraction and diffraction in the plasma.** Decision 5. The pencils are
+ straight; a two-way beam↔plasma loop is a later milestone with its own gate,
+ as [M6C_SPEC.md](M6C_SPEC.md) open question 3 already says.
+- **3-D hydro.** Axisymmetry assumes no azimuthal structure. A tilted or
+ astigmatic beam, or an azimuthal instability of the front, is outside this.
+- **Swirl** (`u_θ`). Zero by assumption.
+- **The production table EOS in the 2-D hydro.** Verification mode (constant
+ `γ`) only, so every M6d gate measures the solver rather than the table's
+ interpolation. M6c's G6 already gates the table itself.
+- **Non-LTE, two-temperature plasma, finite-rate ionization** — inherited from
+ M6c, unchanged.
+- **Radiation transport** — inherited from M6c, unchanged, and still one of the
+ named reasons G7 is expected high.
+- **The M6a ignition stage in the 2-D case.** `lsd2d` seeds directly. The `lsd`
+ case already owns the ignition-is-not-sustaining story, and seeding keeps
+ `lsd2d` from re-inheriting M6a's explicitly ungated absolute threshold.
+- **Python bindings.** Every case since bindings v2 has one; `lsd2d` will not,
+ until a bindings v3 change adds it deliberately rather than as a side effect.
+- **The measured LSD dataset for G7** — see § Open questions.
+
+## Open questions
+
+1. **Which measured LSD dataset anchors G7.** Inherited verbatim from
+ [M6C_SPEC.md](M6C_SPEC.md) open question 1, and **still open**. It needs the
+ treatment `tests/data/tt2012_*.csv` got: a named paper, the figure number
+ pinned in the provenance header at digitization time, and the setup quoted so
+ the solver's inputs are fixed by the source rather than chosen. M6d makes
+ this debt *more* worth paying, not less — with relief modelled and pinned,
+ the comparison would finally be against a model that contains the effect the
+ comparison is about.
+2. **Does relief explain the experimental gap?** Answered by this milestone,
+ either way, and the negative answer is as publishable as the positive one.
+3. **Full Sedov profile, or trajectory plus jump only?** Provisionally the full
+ parametric profile, because it gives an L1-under-refinement leg in the G1
+ style and is the repo's only multidimensional reference. Revisit if the
+ algebra proves disproportionate.
+4. **Is the `1/R` diameter-effect law an anchor or a description?** A
+ description, for now, recorded in a doc comment. Promoting it to
+ `validate.rs` requires the coefficient to come from outside this solver —
+ see § "The circularity, stated up front".
+5. **Top-hat or super-Gaussian beam?** *Resolved as spec'd.* Top-hat for the
+ gates, because `R_b` is then unambiguous and the diameter effect reads
+ cleanly; `--beam-order` offers a super-Gaussian for the demonstration run,
+ where the profile is a picture rather than a measurement.
+6. **Where is the failure radius?** New, and open. The eight-cells-across-`R_b`
+ guard puts the smallest affordable beam at `R_b·α` = 1.6, where δ = 0.305 and
+ the wave is healthy. Reaching the failure radius needs `Δr` decoupled from
+ `Δx`, which costs CFL. Worth doing: it is the sharpest qualitative prediction
+ the diameter effect makes.
+7. **What sets the transverse instability's wavelength, and does it change the
+ mean front speed?** New, and open. M6d establishes the instability exists and
+ separates it from relief; it does not characterise it. Cell size, growth
+ rate, and whether the saturated state runs slower than the smooth one are all
+ unmeasured, and the last of those would bear directly on G7.
+
+## Results (filled in as the milestone landed)
+
+Recorded here so the spec is not left describing an intention. Every number
+below is in a gate's doc comment with the run that produced it.
+
+- **G9** bit-identical; **G10** exponent 0.38628 vs 2/5, `ξ₀` derived at 1.03278
+ against the published ≈1.033; **G11** 1.861/1.964 vs a 1.030/1.155 contrast;
+ **G12** <1e-13; **G13** 2.99e-7 → 3.12e-8 vs 3.02e-6 broken; **G14** 3.1e-13
+ in the smooth window; **G15** δ = 0.230 at `R_b·α` = 3.2, 0.305 at 1.6;
+ **G16** `S^0.34666`.
+- **Two implementation defects were found by these gates and are recorded with
+ their measured before-numbers**: ghost cells borrowing a neighbour's face
+ areas leaked 3.3e-4 of the mass (a reflective wall is only exact when the
+ ghost's *metric* mirrors too), and the Hancock predictor built its geometric
+ source from face rather than cell pressures, which is well-balanced and costs
+ an order (0.86/1.12 against planar's 1.71/1.89).
+- **The unlooked-for result: the front is transversely unstable.** Not
+ anticipated by this spec. See § G14 and `docs/MODELS.md` § M6d.
+
+### Amendments this document owes the reader
+
+1. **G15's reference changed from the 1-D column to the wide-beam 2-D run.**
+ The original text compares `D_2D` against `D_1D`. That is wrong now that the
+ instability is known: it is present at *every* beam radius including
+ infinite, so a 1-D reference reports it as relief. Both runs in G15 carry it,
+ and it cancels.
+2. **G15 pins a band, not a value.** The spec asked for "the pinned deficit `δ`
+ at a named `(R_b, α_pl)`, with a band". The band is ±13 %, and its width was
+ measured — grid +6 %, seed −7 %, ignition threshold ±8 % — rather than
+ chosen. A tighter pin would assert precision three knobs say is not there.
+3. **No failure radius is gated.** Predicted, and not reached: the eight-cells-
+ across-`R_b` guard means the smallest affordable beam still carries a healthy
+ wave. Open item, below.
+4. **`boundaries_undisturbed` was split.** A radially uniform seed disturbs the
+ rim at `t = 0` by construction, and the seed's own blast always leaves
+ downstream, so a single flag cried wolf on every run. The laser-side plane is
+ the validity condition; the rim and the downstream plane are information. The
+ question the rim flag was standing in for — is the wall doing the relief? —
+ is answered directly instead: δ = 21.1/21.3/21.3 % at 3/5/8 beam radii.
+
+## Implementation order
+
+0. This document, and the README `M6d.0` row. **Record the baseline `lsd` CLI
+ artifact hashes before touching any code.**
+1. `src/euler1d.rs` — visibility-only refactor, and nothing else. Verified by
+ the full suite, the M4 blooming gate, and the byte comparison.
+2. `src/euler2d.rs` — state, geometry, boundaries, area weights, sweeps, the
+ Strang sandwich, CFL, guards. Gates **G9**, **G13(i)**. Measure the real CI
+ cost here, before any gate grid is chosen.
+3. `src/validate.rs` — the Sedov reference and its own unit tests.
+4. Gates **G10**, **G11**, **G12**.
+5. `src/lsd2d.rs` — the coupled axisymmetric column. Gate **G14**.
+6. The measurement: **G15**, **G16**.
+7. The `lsd2d` CLI case, `scripts/render_lsd2d.py`, and the documentation pass —
+ `MODELS.md` rows and census, this document's amendments, M6c's G7 and
+ NOT-in-scope amendments, and the README.
+
+## References
+
+- L. I. Sedov, *Similarity and Dimensional Methods in Mechanics*, Academic Press
+ (1959) — the self-similar blast solution.
+- L. D. Landau and E. M. Lifshitz, *Fluid Mechanics*, 2nd ed., §106 — the Sedov
+ solution and the published `ξ₀`.
+- J. R. Kamm and F. X. Timmes, LA-UR-07-2849 (2007) — the standard write-up of
+ the Sedov solution as a verification reference.
+- E. F. Toro, *Riemann Solvers and Numerical Methods for Fluid Dynamics*, 3rd
+ ed., Springer (2009) — HLLC, MUSCL-Hancock, and dimensional splitting.
+- G. Strang, SIAM J. Numer. Anal. **5**, 506 (1968) — the operator splitting.
+- H. Eyring et al., Chem. Rev. **45**, 69 (1949); W. W. Wood and J. G. Kirkwood,
+ J. Chem. Phys. **22**, 1920 (1954) — the diameter effect and the
+ curvature–velocity relation, cited as context for `δ` and **not** as an anchor.
+- Yu. P. Raizer, *Laser-Induced Discharge Phenomena*, Consultants Bureau (1977)
+ — LSD wave theory and the velocity closed form.
+- [M6C_SPEC.md](M6C_SPEC.md) — the 1-D milestone this one extends, and the
+ source of the G7 debt inherited here.
diff --git a/docs/MODELS.md b/docs/MODELS.md
index c5fc23f..c9edae0 100644
--- a/docs/MODELS.md
+++ b/docs/MODELS.md
@@ -15,7 +15,7 @@ depends on looking a number up.
## Claims ledger
-**Read this before the test count.** `cargo test` runs 219 tests. That number is
+**Read this before the test count.** `cargo test` runs 245 tests. That number is
not a measure of how much of this solver is validated against the world, and
reading it as one would be a mistake: most of those tests check that the code
solves the equations it was given, several deliberately assert a *known
@@ -50,7 +50,7 @@ Two rows carry an extra flag in the number column:
not with the measurement (`tt2012_cascade_theory_reference`,
`tt2012_wavelength_scaling_matches_cascade_theory`).
-**Census of the 108 rows below: 72 verified, 10 validated, 15 pinned, 11 ungated.**
+**Census of the 122 rows below: 83 verified, 10 validated, 17 pinned, 12 ungated.**
Every `site` names a test function or a `src/` symbol; a claim with neither does
not belong in this table. The remaining unit tests in `src/` are code-level
verification (constructors, guards, closed-form limits of individual rate terms)
@@ -131,18 +131,18 @@ measured datasets, and the disagreement is pinned rather than papered over.
| **PPT does not close the wavelength gap either** | `ppt_does_not_close_the_wavelength_gap_either` | **pinned** | derived prefactor lands at 2.947 vs measured 0.80 — 16 % of the gap; `2n* − 3/2 < 0` for `Z_eff` = 0.53, so the Coulomb correction is order-unity, not orders |
| **The two anchor experiments sit either side of the MPI seeding threshold** | `ppt_seeding_thresholds_separate_the_two_experiments` | **validated** | at each paper's own measured `I_th`: `N_seed` = 5.4e-9 (1064 nm) vs 3.15 (532 nm); seeding threshold 5.73× above measured at 1064 nm, 0.83× at 532 nm |
| Threshold is independent of the integration window | `breakdown0d::tests::threshold_is_window_independent` | verified | invariant in `w` |
-| The high-pressure slope lies between the model's analytic limits | `breakdown0d::tests::high_pressure_threshold_slope_lies_between_analytic_limits` | verified | 0.095 … 0.468 |
+| The high-pressure slope lies between the model's analytic limits | `breakdown0d::tests::high_pressure_threshold_slope_lies_between_analytic_limits` | verified | `n ∈ (0, 1)`, below the loss-only floor of 1; the closures give 0.086 / 0.264 / 0.440 |
| The literature-range inelastic envelope brackets the slope | `breakdown0d::tests::inelastic_loss_envelope_brackets_the_slope` | verified | over `δ_eff` 0.01–0.05, `⟨ε⟩` 2–5 eV |
| Per-slice integrator is exact and step-size independent | `breakdown0d::tests::{pure_cascade_is_exponential_growth, pure_loss_is_exponential_decay, mpi_only_seeding_is_linear, balance_point_is_linear_from_seed, slice_refinement_is_consistent}` | verified | 1e-9 relative |
| **Measured `I_thr(p)` slope, against the `δ_eff` literature envelope** | `tt2012_threshold_slope_matches_measurement` | **validated** | measured 0.329 inside `[0.174, 0.382]`; centre 0.264. Red and `#[ignore]`d 2026-07-25 → 2026-07-30, retired by the closure change, not by re-banding |
-| **Chylek's low-pressure branch: the kernel is still far too steep** | `chylek1990_air_is_a_power_law_and_the_cascade_kernel_is_not` | **pinned** | 10–100 Torr: kernel 1.293 vs measured 0.428, down from 1.954 once the free-molecular escape landed. The high-pressure window *agrees* (0.431 vs 0.468), so the failure is one-sided |
+| **Chylek's low-pressure branch: the kernel is still far too steep** | `chylek1990_air_is_a_power_law_and_the_cascade_kernel_is_not` | **pinned** | 10–100 Torr: kernel **0.501** vs measured 0.428 (1.17×), down from 1.292 once seed production landed. The residual is now the 100–300 Torr window, 0.857 vs 0.413 |
| **The residual against Chylek is localised to 70–350 Torr** | `chylek1990_residual_is_localised_to_mid_pressure` | **pinned** | six matched bands: kernel tracks to <0.25 outside, 2.0–2.3× too steep inside; the measurement is *not* locally flat (0.31–0.55) |
| The mid-pressure residual is not a level artifact | `the_mid_pressure_residual_is_not_a_level_artifact` | verified | walking `δ_eff` from a 15.8× level to 3.4× makes the peak local exponent *rise*, 1.04 → 1.42 |
-| **The wavelength ratio is falsified against measurement, in sign** | `chylek1990_tt2012_wavelength_ratio_falsifies_cascade_lambda_squared` | **pinned** | kernel 3.39 vs measured ≈ 0.80; overshoot ≈ 4.24× (was 3.99 / 4.99× before the closure change) |
+| **The wavelength ratio is falsified against measurement, in sign** | `chylek1990_tt2012_wavelength_ratio_falsifies_cascade_lambda_squared` | **pinned** | kernel **2.85** vs measured ≈ 0.80; overshoot ≈ **3.57×** (4.00 cascade-only → 3.39 after the closure change → 2.85 after seed production) |
| **Keldysh MPI does not close the wavelength gap** | `keldysh_mpi_does_not_close_the_wavelength_gap` | **pinned** | order-unity prefactor lands at 2.89 vs measured 0.80; the 18 % of the gap it closes is a smaller denominator, not a better rate |
| **T&T's own MPI calibration undershoots their own measurement** | `breakdown0d::tests::tt2012_mpi_calibration_undershoots_the_data` | **pinned** | 37× below |
| Level offset stays inside the inter-lab scatter, with a drift pin | `tt2012_level_ratio_is_bounded_within_scatter` | **pinned** | 3.90–4.69× high; drift 1.48× → 1.20× as the slope error shrank |
-| The two cascade limits bracket the measurement | `breakdown0d::tests::the_two_cascade_models_bracket_the_measurement` | **pinned** | 0.468 / 0.095 straddle 0.329 — but this is a **one-parameter sensitivity**, not two independent limits |
+| The two cascade limits bracket the measurement | `breakdown0d::tests::the_two_cascade_models_bracket_the_measurement` | **pinned** | 0.440 / 0.086 straddle 0.329 — but this is a **one-parameter sensitivity**, not two independent limits |
| **The continuum diffusion loss is invalid at low pressure** | `the_diffusion_approximation_is_invalid_at_low_pressure` | verified | `Kn` = 0.013 at 760 Torr → **0.96 at 10 Torr**; `Kn ∝ 1/p` exactly |
| Escape rate recovers the continuum and free-molecular limits | `escape_rate_recovers_both_limits` | verified | 0.9375 / 0.9757 / 0.9951 of `D_e/Λ²` at 760 / 2000 / 10⁴ Torr; saturates at `v̄/ℓ` |
| The escape correction adds no constant | `the_escape_correction_adds_no_constant` | verified | `v̄` reproduces `D_e` to 1e-12; 6.740 eV, the same energy `D_e` implies |
@@ -173,7 +173,7 @@ measured datasets, and the disagreement is pinned rather than papered over.
| The ionization background is not load-bearing | `breakdown0d::tests::ionization_background_is_not_load_bearing` | verified | threshold **bit-identical** over 12 decades of seed (10⁻⁶–10⁶ m⁻³) |
| The seed floor applies to an explicit seed only | `breakdown0d::tests::seed_floor_applies_only_to_an_explicit_seed` | verified | floored vs free peak differ by >10³ at 8×10¹⁵ W/m² |
| `Λ` = 7.74 µm and `ℓ` = 30.72 µm | `Focus::cylinder` | **ungated** | both pinned from T&T's Eq. 5 geometry, never fit; `Λ` matches the 8 µm the paper states |
-| The `ε_∞ → U_i` margin at threshold | — | **ungated** | `ε_∞/U_i` = 1.032 at 760 Torr, 1.011 at 1500 — the model sits at the bifurcation that *is* its plateau |
+| The `ε_∞ → U_i` margin at threshold | — | **ungated** | `ε_∞/U_i` = 1.032 at 760 Torr, 1.011 at 1500. **Mean-trajectory closure only** — that closure sits on the bifurcation that *is* its plateau, which is what the shipped `DistributionResolved` removed |
### M6a.2 — Aperture optics and ignition statistics
@@ -208,7 +208,26 @@ screen linearly in `r₀`), and a width gate (no independent anchor).
| **G6 — frozen plasma table vs direct Mutation++ off-grid** | `plasma_table_matches_direct_mutationpp_off_grid` | **validated** | worst 1.48e-3 in `n_e` (independent third-party code) |
| G8 — a real beam through the plasma column is Beer–Lambert, `δn ≡ 0` | `plasma_column_absorbs_as_beer_lambert` | verified | 1.7e-13 at τ = 339 |
| The table's charge-state ceiling | `plasma_table_charge_state_ceiling_is_pinned` | **pinned** | regression pin on the table's extrapolation limit |
-| G7 — absolute LSD velocity vs measurement | — | **ungated** | on purpose: a planar solver has no radial relief, so the known experimental gap is a *prediction* of the omissions |
+| G7 — absolute LSD velocity vs measurement | — | **ungated** | **Amended by M6d.** Radial relief is no longer the excuse — it is modelled and pinned at δ = 0.23 (R_b·α = 3.2), i.e. ~23 % of the front speed, which covers part but not all of the ~2× gap to measurement. G7 stays ungated for exactly one reason now: there is no anchored measured dataset (the M6a-D5-style debt, inherited by M6d). Remaining candidates for the rest: radiation losses, incomplete absorption, the production EOS, and the un-refracted beam |
+
+### M6d — Axisymmetric gas dynamics and radial relief
+
+| claim | site | status | number |
+|---|---|---|---|
+| G9 — the planar 2-D solver reproduces `Euler1d` bit for bit | `euler2d_planar_limit_reproduces_euler1d_bit_for_bit` | verified | bit-identical over 240 cells x 40 steps, with both non-vacuity legs |
+| **G10 — Sedov–Taylor point blast** | `sedov_blast_matches_the_self_similar_solution` | verified | exponent 0.38628 vs 2/5; level 1.0842x falling to 1.0587 under refinement; peak compression 2.09 → 2.62 climbing toward 6 |
+| The Sedov reference reproduces the published `ξ₀` | `sedov_xi_0_matches_the_published_value` | verified | `ξ₀` = 1.03278 **derived** from the energy integral vs the published ≈1.033 |
+| The Sedov profile solves the Euler equations it was derived from | `sedov_profile_satisfies_the_euler_equations` | verified | worst residual 6.9e-5, falling as the finite-difference step squared |
+| G11 — 2nd order on smooth axisymmetric flow | `euler2d_is_second_order_on_smooth_axisymmetric_flow` | verified | 1.861 / 1.964 against a split-source contrast at 1.030 / 1.155 |
+| G12 — conservation in the `r`-weighted measure | `euler2d_conserves_mass_and_energy_in_the_r_weighted_measure` | verified | mass and energy < 1e-13 closed box; escape term needed and sufficient when the wall is brought in |
+| G13 — the axis is not a wall | `the_axis_boundary_does_not_heat_or_starve_the_on_axis_cells` | verified | on-axis entropy defect 2.99e-7 → 3.12e-8 under refinement, vs 3.02e-6 for an even-parity contrast |
+| G13(i) — a radially uniform state is a fixed point | `a_radially_uniform_state_is_a_fixed_point_of_the_axisymmetric_operator` | verified | bit-identical in mass and both momenta; energy drift < 1e-13 |
+| **G14 — the wide-beam limit reproduces the 1-D column** | `lsd2d_with_a_full_width_beam_reproduces_the_one_dimensional_column` | verified | 3.1e-13 while the front is smooth |
+| **The modelled LSD front is transversely unstable** | `lsd2d_with_a_full_width_beam_reproduces_the_one_dimensional_column` | **pinned** | grows out of round-off, amplitude-proportional (10⁶× seed → 10⁶× response), saturating at `\|u_r\|` ≈ 200–400 m/s. Identical in planar and axisymmetric geometry, so it is not the geometric source. A planar solver structurally cannot show it |
+| **G15 — radial relief lowers the front speed** | `radial_relief_lowers_the_lsd_front_speed_by_a_pinned_amount` | **pinned** | δ = 0.230 at `R_b·α` = 3.2 and 0.305 at 1.6, monotone in `R_b`; banded at ±13 %, which is the measured spread over grid (+6 %), seed (−7 %) and ignition threshold (±8 %) |
+| The relief deficit is not a boundary effect | `src/lsd2d.rs` (`rim_undisturbed`) | verified | 21.1 / 21.3 / 21.3 % at domain radii of 3 / 5 / 8 beam radii |
+| G16 — the one-third scaling survives relief | `the_one_third_scaling_survives_radial_relief` | verified | `S^0.34666` at finite `R_b` against the parameter-free 1/3, while the level moves 23 % |
+| The beam is not refracted by the plasma | `src/lsd2d.rs` (`BeamProfile`) | **ungated** | independent parallel pencils, by assumption; a two-way beam↔plasma loop is a later milestone |
### What this table says, in one paragraph
@@ -232,6 +251,21 @@ published experimental curve. M6c's core is verified to high order but its one
headline agreement (Raizer) is circular by construction, which is why G4 — a
parameter-free scaling exponent — is the milestone's real physics gate.
+M6d changes two things about that picture and neither is a validation. First, it
+adds the repo's **first multidimensional verification anchor**: the Sedov–Taylor
+blast is a self-similar solution the model is not built from, and its
+coefficient `ξ₀` is derived here from the energy integral rather than quoted, so
+agreeing with the published value to 0.03 % is evidence rather than bookkeeping.
+Second, it retires an *excuse*. M6c's G7 was ungated on the grounds that a
+planar solver structurally cannot show radial relief; relief is now modelled and
+pinned at 23 % of the front speed, which is a real effect and not the whole ~2×
+gap to measurement. G7 remains ungated, but only because no anchored measured
+dataset has been acquired — a smaller and more actionable claim than the one it
+replaces. M6d also produced something nobody asked it for: the modelled front is
+**transversely unstable**, growing cellular structure out of round-off, which a
+1-D solver has no way to exhibit and which had to be separated from relief before
+the relief number meant anything.
+
## M1 — Diffraction
### Scalar paraxial propagation, split-step spectral method
@@ -477,7 +511,7 @@ References:
At a point in dry air the electron density obeys the avalanche balance
```text
-dn_e/dt = (ν_i(I, p) − ν_att(p) − ν_diff(p))·n_e + S_mpi(I, p)
+dn_e/dt = (ν_i(I, p) − ν_att(p) − ν_esc(p))·n_e + S(I, p)
```
with cascade ionization driven by the **net** power — inverse-bremsstrahlung
@@ -496,12 +530,20 @@ constant high-pressure **plateau** on top of the `1/p` avalanche term. Without
lets it approach the measured trend at all. `L′ = δ_eff·K_m·⟨ε⟩` is one lumped
constant taken from the centre of its literature range and never tuned.
+That closed form is the **mean-trajectory** closure (`CascadeModel::SelfConsistentClimb`).
+It is no longer what ships: the default since 2026-07-30 is
+`CascadeModel::DistributionResolved`, which replaces the trajectory with a
+first-passage rate and removes the `ε_∞ = U_i` bifurcation the expression above
+sits on. See § "Distribution-resolved cascade" below for the shipped rate; the
+mean-trajectory form is kept here because the plateau argument is clearest in it
+and because both closures are still selectable.
+
The loss terms are attachment from measured rate coefficients (dissociative
`k₂·n_O₂ ∝ p` plus three-body `k₃·n_O₂·n ∝ p²`; the two-body channel leads at
1 atm, `5.4×10⁷` against `1.4×10⁷ s⁻¹`, with three-body overtaking it only above
-`n = k₂/k₃ = 10²⁶ m⁻³` ≈ 4 atm) and free-electron diffusion,
-free-electron **escape** from the focal volume. Attachment is negligible against
-escape throughout the gate window — `6.7×10⁷` vs `3.3×10⁹ s⁻¹` at 1 atm.
+`n = k₂/k₃ = 10²⁶ m⁻³` ≈ 4 atm) and free-electron **escape** from the focal
+volume. Attachment is negligible against escape throughout the gate window —
+`6.7×10⁷` vs `3.3×10⁹ s⁻¹` at 1 atm.
Escape is *not* `D_e/Λ²`. That is a continuum random-walk result and assumes the
electron collides many times while crossing the focus, which fails badly at low
@@ -533,245 +575,148 @@ theory ties it to the externally-gated `K_m` by `D_e = 2ε/(3 m_e K_m p)`, so
`D_e,ref = 0.2 m²/s` **is** the statement `ε = 6.740 eV`
(`d_e_ref_implies_a_stated_electron_energy`). Sweeping the whole band that
formula admits — `ε` from 2 eV to `U_i`, a 6.0× range in `D_e` — moves the fitted
-slope by only 0.101 (0.0532 → 0.1545), against a 0.234 shortfall to the measured
-0.329, so `D_e` **cannot** account for the slope gap
+slope by less than 0.1, against a shortfall to the measured 0.329 that is more
+than twice that, so `D_e` **cannot** account for the slope gap
(`d_e_sensitivity_is_pinned_across_the_kinetic_band`). Two debts remain, both
recorded rather than papered over: diffusion assumes 6.74 eV while the
`FixedMeanEnergy` loss term assumes `⟨ε⟩` = 3 eV (a 2.25× internal
inconsistency), and there is still no measurement of `D_e` at the cascade's own
-energy — swarm data reaches only 0.1–2 eV. See `docs/M6A_SPEC.md`.
+energy — swarm data reaches only 0.1–2 eV.
`Λ` is the diffusion length of T&T's **divergence-limited** focus, from their
Eq. 5: `(1/Λ)² = (π/l₀)² + (2.405/r₀)²` with `r₀ = f·α/2 = 20 µm` and
`l₀ = 0.414·(α/d)·f² = 66 µm`, giving **`Λ = 7.74 µm`** — matching the 8 µm the
-paper states. **Pinned from geometry, never fit.** (Two earlier guesses were
-wrong in opposite directions: a *sphere*, `Λ = r₀/π = 6.37 µm`, overstated
-`ν_diff` by 1.48×; a diffraction-limited *filament* put the depth of focus at
-2.4 mm instead of 66 µm.) Because the focus is set by the beam's 1 mrad
-divergence rather than by diffraction, `Λ` and the focal volume are
-wavelength-independent — which is what makes the wavelength gate below a clean
-one-variable test.
-
-Finally a swappable multiphoton seed `S_mpi = σ_K·I^K·N` (off by default; the
-seed is one electron in the focal volume). Breakdown at `n_e ≥ n_bd = 10²³ m⁻³`.
-
-The per-slice ODE is advanced by its exact solution. With `σ_K = 0` the slice
-is Bernoulli — growth is **logistic**, since ionization depletes the neutrals it
-feeds on — and is evaluated as `n_e' = n_e/(e^{−βdt} + b·n_e·(1 − e^{−βdt})/β)`
-with `β = ν_i − ν_loss`, `b = ν_i/N`; that form underflows harmlessly instead of
-overflowing to `NaN` far above threshold, and `n_e` saturates at full ionization
-rather than running away. Threshold intensity is found by log-bisection; a
-pressure sweep gives `I_thr(p)`.
+paper states. **Pinned from geometry, never fit.** Because the focus is set by
+the beam's 1 mrad divergence rather than by diffraction, `Λ` and the focal volume
+are wavelength-independent — which is what makes the wavelength gate below a
+clean one-variable test.
+
+**The seed and the multiphoton source.** The kernel does not assume a starting
+electron. `n_e0(p) = q(p)/ν_att(p)` is the physical ambient free-electron
+density — the equilibrium between cosmic-ray ionization and the kernel's own
+attachment rate — and the pulse **produces** its own electrons through PPT
+photoionization, which is **on by default**. Both are covered in § "Seed
+production" below. The older `S_mpi = σ_K·I^K·N` term and the Keldysh rate remain
+implemented and selectable, but both are off by default.
+
+Breakdown at `n_e ≥ n_bd = 10²³ m⁻³`. The per-slice ODE is advanced by its exact
+solution: with no source term the slice is Bernoulli — growth is **logistic**,
+since ionization depletes the neutrals it feeds on — and is evaluated as
+`n_e' = n_e/(e^{−βdt} + b·n_e·(1 − e^{−βdt})/β)` with `β = ν_i − ν_loss`,
+`b = ν_i/N`; that form underflows harmlessly instead of overflowing to `NaN` far
+above threshold, and `n_e` saturates at full ionization rather than running away.
+Threshold intensity is found by log-bisection; a pressure sweep gives `I_thr(p)`.
Implemented in `src/breakdown0d.rs`.
-Gate (internal, model self-consistency): solving the avalanche criterion gives
+### The threshold, and what it is gated against
+
+Solving the avalanche criterion for the mean-trajectory closure gives
```text
-I_thr(p) = L′/h + U_i·(ν_att + ν_diff + G)/(h·p), G = ln(n_bd/n_seed)/τ
+I_thr(p) = L′/h + U_i·(ν_att + ν_esc + G)/(h·p), G = ln(n_bd/n_seed)/τ
```
Attachment is negligible here, so the exponent runs between two **exact**
-limits — plateau-dominated `n → 0` and diffusion-dominated `n → 2` — with the
-growth-limited `p^-1` in between. Fitted over the pinned range **300–2000
-Torr** (8 log-spaced points, 6 ns FWHM) the model gives **n = 0.095**, and the
-gate asserts `n ∈ (0, 1)`: the plateau must be doing work, since without `L`
-the model is stuck at `n ≥ 1`. Sweeping the literature ranges of `L′`
-(δ_eff ≈ 0.01–0.05) gives an envelope `n ∈ [0.023, 0.231]`, separately pinned so
-it cannot drift. The level at 760 Torr is **1.18×10¹² W/cm²**, and the
-`FixedMeanEnergy` variant gives `n = 0.468` (level 4.58×10¹¹) as the other end
-of the bracket. Absolute threshold level is **not** gated (3–10× inter-lab
-scatter). Integrator sub-gates are unit tests of the exact per-slice solver,
-not physics validation.
-
-External gates (Thiyagarajan & Thompson 2012, digitized into
-`tests/data/tt2012_*.csv`; the paper's setup — 1064 nm, 6 ns FWHM, 20 µm radius
-focus, 10–2000 Torr — is exactly what the kernel assumes, so nothing is fitted):
-
-- **`K_m` collision frequency — passes.** `E_eff/E_B = ν_m/√(ν_m²+ω²)`, so the
+limits — plateau-dominated `n → 0` and escape-dominated `n → 2` — with the
+growth-limited `p^-1` in between. The two selectable closures give **0.086**
+(`SelfConsistentClimb`) and **0.440** (`FixedMeanEnergy`) over the pinned
+300–2000 Torr window, straddling the measured 0.329; that bracket is gated by
+`the_two_cascade_models_bracket_the_measurement` and must be read narrowly — the
+two variants differ only in where the cascade cuts off (`⟨ε⟩` = 3 eV vs
+`U_i` = 12.06 eV), so it is a *one-parameter sensitivity*, not two independent
+limits and not a bound. The **shipped** distribution-resolved closure gives
+**0.264** at the untouched literature centre. Absolute threshold level is
+**not** gated (3–10× inter-lab scatter). Integrator sub-gates are unit tests of
+the exact per-slice solver, not physics validation.
+
+External gates (Thiyagarajan & Thompson 2012 and Chylek et al. 1990, digitized
+into `tests/data/`; T&T's setup — 1064 nm, 6 ns FWHM, 20 µm radius focus,
+10–2000 Torr — is exactly what the kernel assumes, so nothing is fitted):
+
+- **`K_m` collision frequency — validated.** `E_eff/E_B = ν_m/√(ν_m²+ω²)`, so the
paper's two curves measure `ν_m` independently of this crate. Implied
`K_m = 4.21×10⁷` vs the kernel's `3.90×10⁷ s⁻¹Pa⁻¹`; ratio flat at
1.05 ± 0.01 over 46–1858 Torr. Non-circular anchor.
-- **`E_eff(p)` slope — passes.** Predicted `p^+0.642`, measured `p^+0.695`;
+- **`E_eff(p)` slope — validated.** Predicted `p^+0.642`, measured `p^+0.695`;
the positive sign confirms the `ν_m ≪ ω` branch.
-- **`I_thr(p)` slope vs the MEASURED curve — RED, and now known to be the
- wrong target.** Measured `p^-0.329`; kernel `p^-0.095`. The measured curve
- is not cascade-only (88 % cascade / 12 % MPI at 760 Torr per the paper), so
- no cascade-only kernel can match it. This gate was previously green at `p^-0.356` (an "8 % match"),
- but that rested on an integration artifact: the seed decayed by `e^-60`
- before the pulse arrived, so an arbitrary integration bound supplied most of
- the threshold requirement and, being pressure-dependent, manufactured slope.
- Corrected route: `p^-1.74` → `p^-0.468` (inelastic loss) → `p^-0.095`
- (`⟨ε⟩` eliminated). What survives is a **bracket** — the two cascade limits
- give 0.095 and 0.468 and straddle the measurement — gated by
- `the_two_cascade_models_bracket_the_measurement`. Read narrowly: the two
- variants differ only in where the cascade cuts off (`⟨ε⟩` = 3 eV vs
- `U_i` = 12.06 eV), so the bracket is a *one-parameter sensitivity*, not two
- independent limits, and not a bound — `⟨ε⟩` ≈ 5 eV gives `n` = 0.346 on its
- own, and `⟨ε⟩ = U_i` gives 0.192, inside the interval.
-- **"Too flat" is window-specific — the real error is CURVATURE.** The
- `n = 0.095` above is fitted over 300–2000 Torr. Chylek's 532 nm curve extends
- the comparison two decades lower and shows the kernel is not a power law at
- all: local exponents **1.951** (10–100 Torr), **1.047** (100–300), **0.170**
- (300–786), against a measurement that holds **0.41–0.47** throughout on 1.5–6 %
- scatter. The kernel is 4.6× too steep at the bottom, 2.8× too flat at the top,
- and crosses the data near 250 Torr — an 11.5× swing where the measurement
- varies by 1.13×. Same behaviour at 1064 nm, so this is shape, not level or
- wavelength. Gated as
- `chylek1990_air_is_a_power_law_and_the_cascade_kernel_is_not`. Any statement
- that the kernel is simply "too flat" holds only above ~250 Torr.
-- **Cascade theory (T&T Eq. 4) — PASSES, and is the apples-to-apples
- reference.** `I_B(CC) = 1.44×10⁶(p_atm² + 2.2×10⁵λ_µm⁻²)` W/cm², implemented
- as `validate::tt2012_cascade_threshold`. Flat at 1064 nm (`n = −0.00002`)
- because `λ⁻²` dominates `p²` by 10⁵ — so the kernel's flatness *agrees* with
- cascade theory. Level: `SelfConsistentClimb` 4.1–5.1× high, `FixedMeanEnergy`
- 1.3–3.2×.
-- **Wavelength scaling vs Eq. 4 — PASSES, and is the strongest shape check in
- M6a.** Both terms of `I_thr` carry `1/h ∝ ω²`, so the kernel predicts
- `I_thr ∝ λ⁻²`, the same exponent as Eq. 4's dominant term. Over
+- **`I_thr(p)` slope vs the MEASURED curve — validated since 2026-07-30.**
+ Measured `p^-0.329`. Sweeping the literature range of the model's one free
+ constant, `δ_eff ∈ [0.01, 0.05]`, the shipped closure gives an envelope
+ `[0.174, 0.382]` which **contains** the measurement, centre 0.264; the
+ mean-trajectory closure's `[0.023, 0.231]` excluded it. The gate pins the
+ envelope as well as the containment, so "contains the measurement" cannot
+ later be satisfied by an envelope that has quietly grown. This gate was red and
+ `#[ignore]`d from 2026-07-25 to 2026-07-30 and was retired by the closure
+ change, with no tolerance moved and no constant touched — the history is in
+ `docs/M6A_SPEC.md` § "Distribution-resolved cascade", and it matters, because
+ the same test once passed for a bad reason (an integration artifact that let an
+ arbitrary bound supply most of the threshold).
+- **Chylek's curve: the residual is a shape defect, and it is localised.** The
+ kernel is not a power law across two further decades of pressure. On three wide
+ windows it gives 0.501 / 0.857 / 0.386 against a measurement holding 0.428 /
+ 0.413 / 0.468 (`chylek1990_air_is_a_power_law_and_the_cascade_kernel_is_not`).
+ Redone like-for-like on six narrow bands the failure narrows sharply — see
+ § "The mid-pressure residual" below, which is the honest statement of where the
+ kernel is wrong and by how much.
+- **Cascade theory (T&T Eq. 4) — verified, same lineage.**
+ `I_B(CC) = 1.44×10⁶(p_atm² + 2.2×10⁵λ_µm⁻²)` W/cm², implemented as
+ `validate::tt2012_cascade_threshold`. Flat at 1064 nm because `λ⁻²` dominates
+ `p²` by 10⁵ — so the kernel's flatness *agrees* with cascade theory. Level:
+ 4.1–5.1× high (climb), 1.3–3.2× (fixed `⟨ε⟩`).
+- **Wavelength scaling vs Eq. 4 — verified, same lineage, and the strongest
+ shape check in M6a.** Both terms of `I_thr` carry `1/h ∝ ω²`, so the kernel
+ predicts `I_thr ∝ λ⁻²`, the same exponent as Eq. 4's dominant term. Over
**0.53–10.6 µm** (a 20× span, geometry frozen — legitimate, since the focus is
divergence- not diffraction-limited) both give **−2.000**, with the ratio
- between them constant to `2×10⁻⁵`; the residual is the `(ν_m/ω)²` correction
- at 10.6 µm. Level offsets are flat at 1.64× (`FixedMeanEnergy`) and 4.22×
- (`SelfConsistentClimb`). Sharper still: the plateau `L′/h` and Eq. 4's `λ⁻²`
- coefficient are the *same physical quantity* — `ω²` times the inelastic energy
- loss per collision — and agree to **1.01×** at the literature centre
- (`δ_eff` = 0.02, `⟨ε⟩` = 3 eV). This is a shape agreement on an axis where
- nothing is tunable: `δ_eff·⟨ε⟩` sets the level and cannot produce a `λ`
- exponent. It does *not* independently discover the scaling (the `λ⁻²` is
- analytic in the `ν_m ≪ ω` limit) — it establishes that the two theories share
- it exactly, and fails loudly if that limit is left. Gated by
- `tt2012_wavelength_scaling_matches_cascade_theory`. **Not a pin:**
- `δ_eff·⟨ε⟩` stays asserted from its literature range; re-pinning it *from*
+ between them constant to `2×10⁻⁵`. Sharper still: the plateau `L′/h` and Eq. 4's
+ `λ⁻²` coefficient are the *same physical quantity* — `ω²` times the inelastic
+ energy loss per collision — and agree to **1.01×** at the literature centre.
+ This is a shape agreement on an axis where nothing is tunable. It does *not*
+ independently discover the scaling (the `λ⁻²` is analytic in the `ν_m ≪ ω`
+ limit) — it establishes that the two theories share it exactly, and fails
+ loudly if that limit is left. **Not a pin:** re-pinning `δ_eff·⟨ε⟩` *from*
Eq. 4 would make the level assertions here and in
- `tt2012_cascade_theory_reference` circular, and both would have to be retired
- in the same change, leaving only the exponent.
-- **Level vs the measured curve — bounded, drifting, ungated.** The model sits
- 4.84× above the data at 380 Torr and 6.97× at 1896 Torr, inside the ungated
- 3–10× inter-lab scatter. The 1.48× drift is the residual slope error in
- absolute clothing; an earlier "flat within 1.16×" claim was withdrawn with the
- artifact that produced it. Converting `E_B` to intensity uses
- `I = ε₀cE_rms²`, since the `E_eff` ratio establishes `E_B` is an RMS
- amplitude.
-- **MPI calibrated to the paper's own estimate — implemented, left OFF.**
+ `tt2012_cascade_theory_reference` circular.
+- **Level vs the measured curve — bounded, drifting, ungated.** 3.90–4.69× high
+ across the window, inside the ungated 3–10× inter-lab scatter, with the
+ remaining 1.20× drift being the residual slope error in absolute clothing.
+ Converting `E_B` to intensity uses `I = ε₀cE_rms²`, since the `E_eff` ratio
+ establishes `E_B` is an RMS amplitude.
+- **T&T's own MPI calibration undershoots their own measurement — pinned.**
Anchoring a rate to their `I_B(MPI) = 4.42×10⁹ W/cm²` collapses the threshold
to 5.5×10⁹, 37× below their own measurement, and contradicts the paper's own
88 %-cascade accounting. Their number is an order-of-magnitude significance
indicator (Nelson's flux-density criterion, whose constant the paper never
- states), not a rate anchor. A real `σ_K` from multiphoton cross-section data
- is the open item.
-
-**D5 debt — DISCHARGED 2026-07-30, in the negative.** With the slope gate red,
-the plan's fallback clause required an anchor independent of the kernel's own
-coefficients. Eq. 4 supplied that for the **exponent** (`λ⁻²` is untouched by
-any coefficient choice) but not for the **level**: it is the same paper, and its
-`λ⁻²` coefficient implies the same `δ_eff·⟨ε⟩ = 0.060 eV` the kernel already
-assumes, so the 1.01× agreement is intra-lineage consistency, not corroboration.
-
-The second dataset the clause asked for is now in the suite — **Chylek et al.
-1990, clean air at 532 nm** (`tests/data/chylek1990_air_threshold_vs_pressure.csv`,
-digitized programmatically by `scripts/digitize_chylek1990.py`). It is the anchor
-D5 specified: different group, different apparatus, and a different wavelength —
-exactly half T&T's 1064 nm, at a nearly identical pulse length (6.5 vs 6 ± 1 ns)
-and focal radius (16.5 vs 20 µm). That last point is what makes it usable: the
-paper's own Sec. II names pulse duration and focal spot as the reason literature
-values of `α` contradict one another, and here they are matched, so the 532/1064
-comparison measures the *wavelength* scaling rather than two different benches.
-
-It does not corroborate the model — it falsifies the `λ⁻²` prediction against
-measurement:
+ states), not a rate anchor.
+
+**The wavelength ratio is falsified against measurement, in sign.** Chylek et al.
+1990 is the independent anchor D5 asked for — different group, different
+apparatus, and exactly half T&T's wavelength at a nearly identical pulse length
+(6.5 vs 6 ± 1 ns) and focal radius (16.5 vs 20 µm). That match is what makes the
+532/1064 comparison a measurement of *wavelength* scaling rather than of two
+different benches. It does not corroborate the model:
```text
-cascade / kernel: I_th(532)/I_th(1064) = 3.99 (= λ⁻²; shorter λ costs more)
-measured: I_th(532)/I_th(1064) ≈ 0.80 (532 nm breaks down EASIER)
+kernel (shipped): I_th(532)/I_th(1064) = 2.85
+measured: ≈ 0.80 (532 nm breaks down EASIER)
```
-Wrong by ~5×, and wrong in **sign**. At 532 nm the multiphoton order falls from
-`K = ⌈12.06/1.166⌉ = 11` photons to `⌈12.06/2.33⌉ = 6`, so the MPI channel the
-kernel leaves OFF is enormously stronger exactly where the measurement drops —
-the same missing channel the pressure-slope gates indict, seen on a second axis.
-Gated as `chylek1990_tt2012_wavelength_ratio_falsifies_cascade_lambda_squared`.
-
-**Consequence for how M6a is described.** `λ⁻²` remains a correct statement about
-the kernel's internal structure and its gate stays — it fails loudly if the IB
-Lorentzian limit is ever left. It may **not** be called external agreement with
-measurement, and it is no longer M6a's headline. M6a's honest status: verified
-against cascade theory, falsified against air on both the pressure axis and the
-wavelength axis. Does not block M6c (gated separately on Chapman–Jouguet
-velocity). See `docs/M6A_SPEC.md` § Fallback.
-
-Open question M6a hands forward: the measured `n` = 0.329 is unreachable by any
-cascade-only model, since accepted cascade theory is flat at this wavelength.
-Closing it means the MPI contribution the paper itself invokes (12 % at
-760 Torr, dominant below 100 Torr), not a flatter cascade — and separately, a
-distribution-resolved cascade rate, since the default variant's near-flatness
-comes from putting every electron on the mean trajectory (at threshold it runs
-within 0.8 % of the `ε_∞ = U_i` pole at 2000 Torr, where that idealization is
-least defensible).
-
-**The obvious MPI candidate has been tried and it fails — 2026-07-30.** Keldysh
-photoionization is implemented (`breakdown0d::keldysh_rate`) and **verified**
-against both closed-form limits of its own exponent: the multiphoton branch
-recovers the photon order `U_i/ħω` to better than 0.2 % (10.35 at 1064 nm, 5.17
-at 532 nm) and the tunnelling branch reproduces the static-field exponent
-`4√(2m)U_i^{3/2}/(3ħeE)` to 1 part in 10⁶. There is nothing tunable in that
-exponent, which is what makes it a legitimate test rather than a fit.
-
-It does not close the wavelength gap:
-
-| prefactor × ω | `I_th(532)/I_th(1064)` |
-|---|---|
-| 0 (cascade only) | 3.99 |
-| **1 (order unity)** | **3.87** |
-| 10³ | 1.84 |
-| 10⁶ | 0.48 |
-| **measured** | **0.80** |
-
-At an order-unity prefactor MPI closes 3 % of the gap. Reaching the measurement
-needs `~10⁵·ω`, i.e. an ionization rate faster than the optical frequency, which
-is not a rate. Gated as `keldysh_mpi_does_not_close_the_wavelength_gap`.
-
-Two by-products worth keeping:
-
-- **The seed density is unphysical, and it is a latent defect.** `n_e0 = 1/V_focal
- = 1.2×10¹³ m⁻³` is ~10⁴ above the cosmic-ray background (10⁹–10¹⁰ m⁻³), which
- puts ~10⁻⁴ electrons in an `8.3×10⁻¹⁴ m³` focus — the focus essentially never
- holds one. Seeding *should* therefore be MPI's job, importing the photon-order
- asymmetry (at prefactor 1, MPI makes 295 electrons per pulse in the focus at
- 532 nm and 2×10⁻⁸ at 1064 nm — ten orders of magnitude). Removing the seed
- changes the ratio only 3.99 → 3.85, because the model's threshold is already
- 5.7–28× too high and MPI is copious there at both wavelengths. So the defect is
- real but masked by the level error. Exposed as
- `AirBreakdown::with_seed_density`.
-- **An earlier claim of mine, withdrawn.** The wavelength ratio is *not*
- prefactor-insensitive. The `x^(1/K)` suppression argument only holds once MPI
- dominates at both wavelengths; the ratio then scales as `x^(−0.097)`, so three
- decades of prefactor still move it 1.9×, and across the transition it runs 3.99
- → 0.48. Any future prefactor claim has to be justified to ~2 orders, not waved
- through.
-
-The open item is therefore narrower than "add MPI": either a PPT-corrected rate
-for molecular O₂ (Coulomb corrections can lift the prefactor by orders of
-magnitude — checkable against published `σ_K`), or a systematic in the two-paper
-comparison. It is *not* a missing channel at these intensities.
-
-**Both branches were settled on 2026-07-31 — see the PPT section below.** The
-prefactor branch is closed by measurement: PPT's prefactor is *derived* once
-`Z_eff` is given, `Z_eff` = 0.53 for O₂ is published, and the resulting absolute
-rate reproduces a measured cross-section within 2×. It is not orders above
-unity, and it moves the ratio only to 2.947. The systematic branch turns out to
-be real: the two anchor experiments sit on opposite sides of the multiphoton
-*seeding* threshold.
-
-Chylek's 532 nm data sharpens the remaining question into a quantitative target
-rather than a direction. Any candidate MPI channel now has to do three things at
-once:
-lift the 532 nm threshold's *ratio* to 1064 nm from 3.99 down to ≈0.80, flatten
-the low-pressure branch from 1.951 to ≈0.43, and steepen the high-pressure
-branch from 0.170 to ≈0.47 — with `K = 6` photons at 532 nm against `K = 11` at
-1064 nm supplying most of the wavelength leverage for free. The three
-`chylek1990_*` gates pin all three numbers, so a channel that fixes one while
-breaking another cannot land quietly.
+An overshoot of 3.57×, and wrong in **sign**. Gated as
+`chylek1990_tt2012_wavelength_ratio_falsifies_cascade_lambda_squared`. `λ⁻²`
+remains a correct statement about the kernel's internal structure and its gate
+stays — it fails loudly if the IB Lorentzian limit is ever left — but it may
+**not** be called external agreement with measurement.
+
+Three candidate explanations have been implemented and gated, and none closes
+the gap: Keldysh photoionization (order-unity prefactor, 2.89), PPT with the
+published `Z_eff` (2.947, and the Coulomb correction is order-unity rather than
+orders for a molecule), and seed production (which moved it 3.39 → 2.85). What
+*did* come out of the attempt is the finding in § "PPT photoionization" below:
+the two anchor experiments sit on **opposite sides of the multiphoton seeding
+threshold**, so their threshold ratio is not a measurement of one mechanism's
+wavelength scaling at all. The full history of these attempts, including two
+claims of mine that were withdrawn, is in `docs/M6A_SPEC.md`.
### Distribution-resolved cascade (`CascadeModel::DistributionResolved`)
@@ -809,14 +754,16 @@ the rate is a function of `(ε_∞/U_i, ħω/U_i)` alone, preserving `ν_i ∝ p
**Validated — the high-pressure slope** (`distribution_resolved_cascade_fixes_the_high_pressure_slope`).
At the untouched literature centre `δ_eff` = 0.02: T&T 300–2000 Torr goes
-0.0951 → **0.2793** against a measured 0.329, and Chylek 300–786 Torr goes
-0.1717 → **0.4665** against 0.468. Over the literature range of that single free
+0.086 → **0.264** against a measured 0.329, and Chylek 300–786 Torr goes
+0.151 → **0.431** against 0.468. Over the literature range of that single free
constant the envelope moves from `[0.023, 0.231]`, which *excludes* 0.329, to
-`[0.183, 0.407]`, which contains it — and on Chylek's window from `[0.039,
-0.414]` to `[0.307, 0.657]` containing 0.468.
+`[0.174, 0.382]`, which contains it. (These are the isolating comparison — both
+closures run with seeding suppressed, so the change measured is the closure's
+alone. The shipped default also produces its own seed; § "Seed production" gives
+the numbers with everything on.)
**Pinned — what it does not fix.** The low-pressure branch is unmoved,
-1.952 → 1.954 against a measured 0.428, which localises that failure to diffusion
+1.289 → 1.293 against a measured 0.428, which localises that failure to diffusion
loss rather than to the cascade closure
(`distribution_resolved_does_not_fix_the_low_pressure_branch`). The wavelength
ratio moves 4.00 → 3.39 against ≈0.80 — the right sign at last, since `D_ε ∝ ħω`
@@ -896,7 +843,7 @@ landed and for Ar and Xe those cross sections swing two orders of magnitude
across the Ramsauer minimum. Full noble-gas *threshold curves* are therefore not
computed here; the plateau gate needs neither constant.
-Full model and constants: `docs/M6A_SPEC.md`.
+Milestone record and the reasoning behind these choices: `docs/M6A_SPEC.md`.
References:
- Yu. P. Raizer, *Gas Discharge Physics*, Springer (1991) — cascade ionization
@@ -952,8 +899,9 @@ References:
### PPT photoionization for molecular O₂ (`breakdown0d::ppt_rate`)
Added 2026-07-31, to settle the prefactor branch left open above. **Site:**
-`breakdown0d::ppt_rate`, off by default, enabled by
-`AirBreakdown::with_ppt_mpi(Z_EFF_O2)`.
+`breakdown0d::ppt_rate`, **on by default** since seed production landed (the
+other two multiphoton paths stay off); `AirBreakdown::with_ppt_mpi(Z_EFF_O2)`
+selects it explicitly.
```text
W = |C_n*|²·√(6/π)·U_i·(2F₀/(F√(1+γ²)))^{2n*−3/2}·A₀(ω,γ)·exp[−(2U_i/ħω)·f(γ)]
@@ -1631,9 +1579,14 @@ Gates (`tests/validation.rs`):
bin, so `α_slab·dz = Σ α_k·dx` exactly — is what makes marching a 2500-cell
hydro state through an FFT propagator affordable.
-Not yet gated: **G7**, absolute velocity against measurement, which is expected
-to land high and is documented-but-ungated because a planar 1-D solver has no
-radial relief. See `docs/M6C_SPEC.md`.
+Not yet gated: **G7**, absolute velocity against measurement. It is expected to
+land high, and **as of M6d the reason is no longer that a planar solver has no
+radial relief** — relief is modelled and pinned at `δ` = 0.230 of the front
+speed, which covers part but not all of the ~2× gap. G7 stays ungated for one
+reason only: no measured dataset has been anchored. The remaining candidates for
+the rest of the gap are radiation losses, incomplete absorption, the production
+EOS, and the un-refracted beam. See `docs/M6C_SPEC.md` § G7 and
+`docs/M6D_SPEC.md`.
### The `lsd` demonstration run (CLI case)
@@ -1648,12 +1601,12 @@ consumer, and the reason it was extracted.
*igniting* pulse and a separate long *sustaining* drive, and the two models
together say the second could never have produced the first:
-- M6a's threshold in air at 1 atm saturates at **≈1.14×10¹⁶ W/m² and does not
- fall with pulse length** (6 ns → 1.18×10¹⁶, 1 ms → 1.14×10¹⁶). It is an
- intensity floor, not a fluence one: below it the inelastic losses paid
- climbing to the ionization potential exceed the inverse-bremsstrahlung
- heating, the net cascade rate is negative, and no exposure time rescues it.
- Widening the focus moves it by 4 % over a 500× range of spot radius.
+- M6a's threshold in air at 1 atm converges to **an intensity floor of
+ ≈6.75×10¹⁵ W/m², not a fluence criterion**: 8.815×10¹⁵ at 6 ns falling to
+ 6.745×10¹⁵ by 1 ms, and flat to 1 % over the last two decades of pulse length.
+ The fall is bounded at 1.31× — the distribution-resolved closure has no hard
+ cutoff, so a longer pulse buys *something*, and then stops. Widening the focus
+ buys about as little: 6 % over a 500× range of spot radius, saturating.
- The sustaining LSD drive is ~10¹¹ W/m² — five orders of magnitude below.
So the detonation must be *initiated* by something far brighter than what
@@ -1664,7 +1617,7 @@ to either model that closes the gap fails rather than quietly invalidating the
write-up.
**What each half is worth.** *When and where* the spark lights inherits M6a's
-explicitly ungated absolute level (4.8–7.0× above the measured T&T curve). The
+explicitly ungated absolute level (3.90–4.69× above the measured T&T curve). The
front speed does not: it depends on the absorbed intensity at the front and on
`ρ₀`, not on where the spark was lit — which is why G3/G3b/G3c and the G4
physics gate all use seeded ignition and never touch `AirBreakdown`. The gap
@@ -1719,3 +1672,111 @@ metres.
Reference: S. van der Walt, N. Smith, *matplotlib colormaps* (magma),
.
+
+## M6d — Axisymmetric gas dynamics and radial relief
+
+### Axisymmetric Euler, area-weighted finite volume
+
+```text
+∂U/∂t + (1/r)·∂(r·F_r)/∂r + ∂F_x/∂x = Ṡ_geom + Ṡ_laser
+
+U = (ρ, ρu_r, ρu_x, E)ᵀ
+F_r = (ρu_r, ρu_r² + p, ρu_r u_x, (E + p)u_r)ᵀ
+F_x = (ρu_x, ρu_r u_x, ρu_x² + p, (E + p)u_x)ᵀ
+Ṡ_geom = (0, p/r, 0, 0)ᵀ
+```
+
+Site: `src/euler2d.rs`. HLLC + MUSCL-Hancock with minmod, Strang-split
+dimensionally as `R(dt/2) → X(dt) → R(dt/2)`, CFL asserted per step in both
+directions, positivity guard that bails with the cell **and stage** rather than
+clamping. **No laser physics is in this module**, deliberately — M6c gate
+decision 4, carried forward.
+
+Two implementation facts carry the milestone and both are recorded in the code:
+
+- **The `1/r` never appears.** Cells are annuli, interfaces carry area `A ∝ r`,
+ and the axis interface has zero area, so nothing crosses `r = 0` by
+ construction. `(A_+ − A_−)/V` *is* `1/r_j` analytically, finite in the
+ innermost ring without a floor.
+- **The geometric source is written as the same floating-point expression as
+ the pressure part of the flux difference**, so a radially uniform state is a
+ bit-exact fixed point. That is what lets G9, G13(i) and G14 assert equality
+ rather than a tolerance.
+
+The 1-D Riemann solver is **reused, not reimplemented**: a sweep packs
+`(ρ, ρu_∥, E − ½ρv_t²)` into `euler1d`'s `Conserved`, calls its `hllc_flux`, and
+recovers the transverse flux as `F_ρ·v_t` with the upwind side read off
+`sign(F_ρ)`. Both identities are exact.
+
+- **G9 — the planar limit reproduces `Euler1d` bit for bit**
+ (`euler2d_planar_limit_reproduces_euler1d_bit_for_bit`). Not a tolerance: the
+ same floating-point operations. Two non-vacuity legs.
+- **G10 — Sedov–Taylor** (`sedov_blast_matches_the_self_similar_solution`).
+ Exponent 0.38628 vs the exact 2/5; level and peak compression both gated as
+ *trends* under refinement, because a spherical blast's density spike is one or
+ two cells wide at any affordable resolution.
+- **G11 — 2nd order on smooth axisymmetric flow** (1.861 / 1.964 against a
+ split-source contrast at 1.030 / 1.155). This gate found a real defect: the
+ Hancock predictor originally built the geometric source from the reconstructed
+ *face* pressures, which is well-balanced and wrong — the pressure terms then
+ cancel identically against the flux difference and the gradient disappears
+ from the predictor. It cost an order.
+- **G12 — conservation in the `r dr dx` measure**, with radial momentum
+ deliberately *not* conserved, and an escape-flux leg that closes the budget
+ when relief reaches the wall.
+- **G13 — the axis is not a wall**, against a deliberate even-parity contrast.
+
+### The Sedov–Taylor reference
+
+Site: `src/validate.rs` (`SedovBlast`). The self-similar ODEs are integrated
+inward from the strong-shock Rankine–Hugoniot state and `ξ₀` follows from the
+energy integral, so **nothing external is quoted**: the published ≈1.033 for
+`γ` = 1.4 is an independent cross-check that the derived 1.03278 passes to
+0.03 %. Its own unit tests include putting the profile back into the Euler PDEs,
+where the residual is 6.9e-5 and falls as the finite-difference step squared —
+the check that verifies the hand derivation rather than the arithmetic.
+
+### Radial relief and the transverse instability
+
+Site: `src/lsd2d.rs`. M6c's `LsdColumn` with a beam of finite radius: one
+independent Beer–Lambert march per ring, no refraction, no diffraction.
+`Absorption`, `IonizationCeiling` and `raizer_lsd_velocity` are reused
+unchanged — the closures depend on `(ρ, p)` alone.
+
+**The transverse instability, found rather than sought.** A radially uniform
+run diverges exponentially from the 1-D column, out of round-off, reaching 3 %
+by M6c's settle. Three measurements identify it: it is bit-identical in planar
+and axisymmetric geometry (so not the geometric source), amplitude-proportional
+(a 10⁶× larger seed gives a 10⁶× larger early response), and it saturates at
+`|u_r|` ≈ 200–400 m/s. That is a linear instability going nonlinear — the
+mechanism behind the cellular structure real detonations have. It is pinned, not
+validated: no measurement has been compared to.
+
+**The relief deficit** (G15, `pinned`). Because the instability is present at
+*every* beam radius including infinite, the deficit is measured against the
+**wide-beam 2-D run**, not the 1-D column — otherwise the instability would be
+reported as relief. Measured `δ = 1 − D/D_wide` = 0.305 at `R_b·α` = 1.6 and
+0.230 at 3.2, monotone in `R_b`, with the wide-beam limit itself within 1 % of
+Raizer. The pinned claim is a **band** of ±13 %, and that width is measured
+rather than chosen: grid (+6 % on halving `Δx`), seed (−7 % at a 1× rather than
+2× CJ-pressure seed), and ignition threshold (±8 % over a 4× sweep). Pinning a
+third digit would assert a precision three separate knobs say is not there.
+
+A **failure radius** is predicted and was not reached: `check_regime` requires
+eight cells across `R_b`, so the smallest beam affordable at this `Δr` still
+carries a healthy wave. Recorded as an open item rather than asserted.
+
+- **G16 — the one-third scaling survives relief**
+ (`the_one_third_scaling_survives_radial_relief`): `S^0.34666` against the
+ parameter-free 1/3 while the level moves 23 %. M6c's G4 argues that relief can
+ only enter as a coefficient; this measures it.
+
+### The `lsd2d` demonstration run (CLI case)
+
+`cargo run --release -- lsd2d` seeds a wave, drives it with a 160 µm top-hat
+beam at `R_b·α` = 3.2, and reports the deficit against a matching wide-beam run.
+Writes `_fields.npy` `[frame, quantity, ring, cell]`, a front-track CSV carrying
+both the axis and the beam edge, `_meta.json` and `_notes.md`; images come from
+`scripts/render_lsd2d.py`. No M6a ignition stage, deliberately — the `lsd` case
+owns that story, and seeding keeps this one from re-inheriting M6a's ungated
+absolute threshold.
diff --git a/scripts/render_lsd2d.py b/scripts/render_lsd2d.py
new file mode 100644
index 0000000..19aa4a2
--- /dev/null
+++ b/scripts/render_lsd2d.py
@@ -0,0 +1,291 @@
+#!/usr/bin/env python3
+"""Render M6d axisymmetric laser-supported-detonation results: the solver writes
+data, this makes the images.
+
+Reads the NPY/CSV/meta a `beamprop lsd2d` run writes and renders:
+
+ _wave.gif — the wave in (x, ±r), mirrored about the axis, one frame
+ per recorded snapshot, with the beam edge marked
+ _relief.png — the front track on the axis and at the beam edge against
+ the wide-beam limit, and the front's shape
+ _budget.png — deposited vs gained vs escaped energy
+
+Usage:
+ python3 scripts/render_lsd2d.py out/lsd2d
+ python3 scripts/render_lsd2d.py out/lsd2d --fps 10
+
+Requires: numpy, matplotlib (pip install numpy matplotlib).
+
+Three things the figures are drawn to make unmissable, because they are the
+three easiest to misread about this case.
+
+1. **The front runs in −x.** The laser is off the left edge and the beam travels
+ left-to-right, so the detonation moving *toward* the laser means the front
+ position *decreases*. Inherited from `render_lsd.py`, and still true.
+
+2. **The field is mirrored about r = 0 on purpose.** An axisymmetric solution
+ only exists for r ≥ 0; showing ±r is the standard presentation, and it makes
+ an axis artifact instantly visible as a discontinuity down the centre line.
+ If that seam ever appears, gate G13 has stopped doing its job.
+
+3. **The dashed wide-beam line is not a measurement the solver failed to
+ reproduce.** It is the same solver with the beam made infinitely wide — the
+ closed-form limit with the effect removed — and the gap between it and the
+ solid line *is* the result. Note it is deliberately the wide-beam 2-D run and
+ not the 1-D column: the modelled front is transversely unstable (gate G14),
+ so a 1-D reference would show that instability as if it were relief.
+"""
+
+import argparse
+import csv
+import json
+
+import matplotlib.pyplot as plt
+import numpy as np
+from matplotlib import animation
+from matplotlib.colors import LogNorm
+
+# Matches scripts/render.py, render_breakdown.py and render_lsd.py.
+P_C = "#f1605d" # magma mid — pressure, the wave itself
+ALPHA_C = "#721f81" # magma dark — the plasma's absorption
+BEAM_C = "#feca8d" # magma light — the beam
+EDGE_C = "#3b0f70" # magma darker — the beam edge
+REF_C = "#888888"
+GRID_C = "#cccccc"
+
+# Index of each quantity along axis 1 of _fields.npy. Mirrors the
+# "quantities" list the CLI writes into _meta.json; asserted against it below,
+# so a reordering on the Rust side fails loudly here instead of silently
+# plotting the wrong field.
+QUANTITIES = ["p_Pa", "rho_kg_m3", "u_x_m_s", "u_r_m_s", "alpha_1_m", "I_W_m2"]
+I_P, I_RHO, I_UX, I_UR, I_ALPHA, I_I = range(6)
+
+
+def style(ax):
+ ax.grid(True, which="both", color=GRID_C, lw=0.5, alpha=0.6)
+ ax.set_axisbelow(True)
+ for side in ("top", "right"):
+ ax.spines[side].set_visible(False)
+
+
+def mark_laser_side(ax):
+ """Annotate which way the beam travels and where the laser is."""
+ ax.annotate(
+ "laser",
+ xy=(0.0, 1.02), xycoords="axes fraction", fontsize=9,
+ color=BEAM_C, fontweight="bold", ha="left", va="bottom",
+ )
+ ax.annotate(
+ "", xy=(0.16, 1.035), xytext=(0.055, 1.035), xycoords="axes fraction",
+ arrowprops=dict(arrowstyle="->", color=BEAM_C, lw=1.6),
+ )
+ ax.annotate(
+ "beam", xy=(0.175, 1.02), xycoords="axes fraction", fontsize=9,
+ color=BEAM_C, ha="left", va="bottom",
+ )
+
+
+def read_trajectory(path):
+ """Read _trajectory.csv into (t, x_axis, x_edge, lag)."""
+ cols = [[] for _ in range(4)]
+ with open(path) as fh:
+ for row in csv.reader(fh):
+ if not row or row[0].lstrip().startswith("#"):
+ continue
+ try:
+ vals = [float(row[i]) for i in range(4)]
+ except (ValueError, IndexError):
+ continue # header row
+ for i, v in enumerate(vals):
+ cols[i].append(v)
+ return [np.array(c) for c in cols]
+
+
+def load_fields(base, meta):
+ fields = np.load(f"{base}_fields.npy")
+ if fields.ndim != 4 or fields.shape[1] != len(QUANTITIES):
+ raise SystemExit(
+ f"expected [frame, {len(QUANTITIES)}, ring, cell], got {fields.shape}"
+ )
+ if meta.get("quantities") and list(meta["quantities"]) != QUANTITIES:
+ raise SystemExit(
+ f"field layout changed: meta says {meta['quantities']}, "
+ f"this script plots {QUANTITIES}"
+ )
+ return fields
+
+
+def mirror(field_2d):
+ """Stack a (ring, cell) field into (−r … +r, cell) for display."""
+ return np.vstack([field_2d[::-1, :], field_2d])
+
+
+def render_wave(base, meta, fps):
+ """The animation: pressure in (x, ±r), with the beam edge marked."""
+ fields = load_fields(base, meta)
+ n_frames, _, n_r, n_x = fields.shape
+ t, x_axis, x_edge, _ = read_trajectory(f"{base}_trajectory.csv")
+
+ x_mm = np.linspace(meta["x_min"], meta["x_max"], n_x) * 1e3
+ r_mm = np.linspace(meta["r_min"], meta["r_max"], n_r) * 1e3
+ extent = [x_mm[0], x_mm[-1], -r_mm[-1], r_mm[-1]]
+ p0 = meta["p0"]
+ rb_mm = meta["beam_radius"] * 1e3
+
+ p_max = float(fields[:, I_P, :, :].max())
+ fig, ax = plt.subplots(figsize=(9.0, 5.0))
+ im = ax.imshow(
+ mirror(fields[0, I_P] / p0),
+ origin="lower",
+ aspect="auto",
+ extent=extent,
+ cmap="magma",
+ norm=LogNorm(vmin=1.0, vmax=max(p_max / p0, 2.0)),
+ interpolation="nearest",
+ )
+ cb = fig.colorbar(im, ax=ax, pad=0.02)
+ cb.set_label("pressure p/p₀")
+
+ # The beam edge: everything outside it is undriven, and the gas that crosses
+ # it is the relief this case exists to measure.
+ for sign in (-1.0, 1.0):
+ ax.axhline(sign * rb_mm, color=BEAM_C, lw=1.2, ls="--", alpha=0.9)
+ ax.annotate(
+ "beam edge", xy=(x_mm[-1], rb_mm), xytext=(-6, 4),
+ textcoords="offset points", ha="right", va="bottom",
+ color=BEAM_C, fontsize=8,
+ )
+ front_line = ax.axvline(x_axis[0] * 1e3, color="#ffffff", lw=1.0, alpha=0.7)
+ ax.set_xlabel("position along the beam axis (mm)"
+ " — the front runs toward the laser, so it moves left")
+ ax.set_ylabel("radius ±r (mm)")
+ mark_laser_side(ax)
+ title = ax.set_title("")
+
+ def draw(k):
+ im.set_data(mirror(fields[k, I_P] / p0))
+ front_line.set_xdata([x_axis[k] * 1e3, x_axis[k] * 1e3])
+ title.set_text(
+ f"t = {t[k] * 1e9:7.1f} ns front (axis) = {x_axis[k] * 1e3:6.3f} mm"
+ f" lag at beam edge = {(x_edge[k] - x_axis[k]) * 1e6:5.1f} µm"
+ )
+ return im, front_line, title
+
+ anim = animation.FuncAnimation(fig, draw, frames=n_frames, interval=1000 / fps)
+ out = f"{base}_wave.gif"
+ anim.save(out, writer=animation.PillowWriter(fps=fps))
+ plt.close(fig)
+ print(f"wrote {out}")
+
+
+def render_relief(base, meta):
+ """The result: the front track against the wide-beam limit, and its shape."""
+ fields = load_fields(base, meta)
+ t, x_axis, x_edge, lag = read_trajectory(f"{base}_trajectory.csv")
+ n_r, n_x = fields.shape[2], fields.shape[3]
+ r_mm = np.linspace(meta["r_min"], meta["r_max"], n_r) * 1e3
+ rb_mm = meta["beam_radius"] * 1e3
+
+ fig, (ax_t, ax_s) = plt.subplots(1, 2, figsize=(11.0, 4.6))
+
+ ax_t.plot(t * 1e9, x_axis * 1e3, color=P_C, lw=2.0, label="front, on axis")
+ ax_t.plot(t * 1e9, x_edge * 1e3, color=EDGE_C, lw=1.6, ls="-",
+ label="front, at the beam edge")
+ # The wide-beam limit, anchored at the first tracked point.
+ good = np.isfinite(x_axis)
+ if good.any():
+ t0, x0 = t[good][0], x_axis[good][0]
+ ax_t.plot(
+ t * 1e9, (x0 - meta["d_wide"] * (t - t0)) * 1e3,
+ color=REF_C, lw=1.4, ls="--",
+ label=f"wide-beam limit, {meta['d_wide']:.0f} m/s",
+ )
+ ax_t.set_xlabel("time (ns)")
+ ax_t.set_ylabel("front position (mm)")
+ ax_t.set_title(
+ f"radial relief costs {100 * meta['relief_deficit']:.1f} % of the front speed\n"
+ f"{meta['d_measured']:.0f} m/s against the wide-beam {meta['d_wide']:.0f} m/s",
+ fontsize=10,
+ )
+ ax_t.legend(fontsize=8, frameon=False)
+ style(ax_t)
+
+ # The front's shape at the last frame: the curvature IS the relief.
+ p = fields[-1, I_P]
+ p0 = meta["p0"]
+ level = p0 + 0.5 * (p.max() - p0)
+ x_mm = np.linspace(meta["x_min"], meta["x_max"], n_x) * 1e3
+ # Only inside the beam. Outside it the gas is undriven, so the half-maximum
+ # crossing there is the seed's own decaying blast rather than the
+ # detonation, and plotting it would draw a second, unrelated branch.
+ shape, shape_r = [], []
+ for j in range(n_r):
+ if r_mm[j] > rb_mm:
+ break
+ hit = np.nonzero(p[j] >= level)[0]
+ shape.append(x_mm[hit[0]] if hit.size else np.nan)
+ shape_r.append(r_mm[j])
+ ax_s.plot(np.array(shape), np.array(shape_r), color=P_C, lw=2.0)
+ ax_s.axhline(rb_mm, color=BEAM_C, lw=1.2, ls="--", label="beam edge")
+ ax_s.set_ylim(0.0, 1.15 * rb_mm)
+ ax_s.set_xlabel("front position (mm)")
+ ax_s.set_ylabel("radius (mm)")
+ ax_s.set_title(
+ "the front is curved: the axis leads, the beam edge lags\n"
+ "— that curvature is the relief, made visible",
+ fontsize=10,
+ )
+ ax_s.legend(fontsize=8, frameon=False)
+ style(ax_s)
+
+ fig.tight_layout()
+ out = f"{base}_relief.png"
+ fig.savefig(out, dpi=150)
+ plt.close(fig)
+ print(f"wrote {out}")
+
+
+def render_budget(base, meta):
+ """Deposited vs escaped: where the beam's energy went."""
+ fig, ax = plt.subplots(figsize=(6.4, 4.2))
+ labels = ["deposited\nby the beam", "escaped\nthrough the walls"]
+ values = [meta["deposited_energy"], meta["escaped_energy"]]
+ ax.bar(labels, values, color=[P_C, ALPHA_C])
+ ax.set_ylabel("energy (J/rad, in the r dr dx measure)")
+ ax.set_title(
+ f"energy budget closes to {meta['energy_residual']:.2e}\n"
+ "— the escape term is what lets it close on a run where relief\n"
+ "actually reaches the wall, not only on the runs where nothing happens",
+ fontsize=9,
+ )
+ style(ax)
+ fig.tight_layout()
+ out = f"{base}_budget.png"
+ fig.savefig(out, dpi=150)
+ plt.close(fig)
+ print(f"wrote {out}")
+
+
+def main():
+ ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
+ ap.add_argument("base", help="output basename, e.g. out/lsd2d")
+ ap.add_argument("--fps", type=int, default=8)
+ args = ap.parse_args()
+
+ with open(f"{args.base}_meta.json") as fh:
+ meta = json.load(fh)
+ case = meta.get("case")
+ if case != "lsd2d":
+ raise SystemExit(
+ f"{args.base} is a '{case}' run; use scripts/render_lsd.py for 'lsd', "
+ f"render_breakdown.py for 'breakdown', render_ignition.py for 'ignition', "
+ f"or render.py for the field cases"
+ )
+
+ render_wave(args.base, meta, args.fps)
+ render_relief(args.base, meta)
+ render_budget(args.base, meta)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/breakdown0d.rs b/src/breakdown0d.rs
index 2356d60..0fe3523 100644
--- a/src/breakdown0d.rs
+++ b/src/breakdown0d.rs
@@ -1178,10 +1178,9 @@ impl AirBreakdown {
cascade_model: CascadeModel::DistributionResolved,
window_half_widths: 2.0,
focus,
- // MPI source off by default: the seed electron (n_e0 = 1/V_focal)
- // is the initial condition, and the avalanche multiplies it. The
- // continuous multiphoton source is the swappable term (docs Open
- // Question 2); a physical σ_K would be supplied when it is enabled.
+ // The σ_K power-law MPI source stays off: seed production is PPT's
+ // job (below), and this term is the swappable stand-in that would
+ // need a physical σ_K supplied before it meant anything.
mpi_rate_ref: 0.0,
mpi_i_ref: 1.0,
k_photons,
@@ -1352,8 +1351,10 @@ impl AirBreakdown {
/// absolute magnitude against a measured cross-section
/// (`ppt_rate_matches_the_measured_o2_cross_section`).
///
- /// Off by default, like the other two, so every pre-existing gate keeps its
- /// published numbers.
+ /// **On by default** since seed production landed — with a physical ambient
+ /// electron density the focus holds ~10⁻¹⁵ electrons, so without production
+ /// nothing would ever break down. This selects it explicitly, and turns the
+ /// other two multiphoton paths off.
pub fn with_ppt_mpi(mut self, z_eff: f64) -> Self {
self.ppt_z_eff = z_eff;
self.keldysh_prefactor = 0.0;
@@ -1363,18 +1364,20 @@ impl AirBreakdown {
/// Override the initial electron density `n_e0` (m⁻³).
///
- /// The default is one electron in the focal volume, `1/V_focal`, which for
- /// T&T's geometry is `1.2×10¹³ m⁻³`. **That is not a physical background
- /// density**: cosmic-ray ionization maintains ~`10⁹–10¹⁰ m⁻³` in the lower
- /// atmosphere, so an `8.3×10⁻¹⁴ m³` focus contains ~`10⁻⁴` free electrons —
- /// it essentially never has one. Assuming a seed is present is a ~10⁴
- /// overestimate, and it is not a harmless one: handing the cascade a free
- /// electron removes the *seed-production* step, which is where almost all of
- /// the wavelength dependence of real breakdown lives.
- ///
- /// Set this small and enable [`Self::with_keldysh_mpi`] to let multiphoton
- /// ionization create the seed instead, which is the physically ordered
- /// calculation. See `docs/M6A_SPEC.md` § "Seeding".
+ /// The default is [`Self::background_electron_density`] — the physical
+ /// ambient free-electron density, `q/ν_att`, which at 1 atm is `0.149 m⁻³`,
+ /// i.e. about `1.2×10⁻¹⁴` electrons in an `8.3×10⁻¹⁴ m³` focus. The pulse
+ /// then produces its own electrons through the PPT channel.
+ ///
+ /// This kernel previously *assumed* `n_e0 = 1/V_focal` = `1.2×10¹³ m⁻³` —
+ /// one electron sitting in the focus — which was wrong by ~14 orders and
+ /// load-bearing, since it sat exactly where the seed still moves the
+ /// threshold. See `docs/M6A_SPEC.md` § "Superseded 2026-07-31".
+ ///
+ /// An explicit override set here still behaves as a **floor**, which is what
+ /// keeps a source-free run independent of the integration window; the
+ /// derived background does not, because it must be free to deplete
+ /// (`seed_floor_applies_only_to_an_explicit_seed`).
pub fn with_seed_density(mut self, n_seed: f64) -> Self {
self.n_seed = Some(n_seed);
self
@@ -2225,9 +2228,9 @@ mod tests {
// Those two bracketed the model to n ∈ [1, 2] BEFORE the inelastic-loss
// term existed (observed then: n = 1.737). The term adds a third,
// pressure-independent contribution — a genuine plateau — which drags
- // the slope below that old floor, to n = 0.095 with the default
- // SelfConsistentClimb (0.468 with FixedMeanEnergy). So this test now
- // gates the *combined* form:
+ // the slope below that old floor: 0.086 for `SelfConsistentClimb` and
+ // 0.440 for `FixedMeanEnergy`, with the shipped `DistributionResolved`
+ // default at 0.264. So this test now gates the *combined* form:
//
// I_thr(p) = L′/h + U_i·(ν_diff + ν_att + G)/(h·p)
//
@@ -2240,9 +2243,12 @@ mod tests {
// so far above this window the model leaves them entirely — the slope
// turns positive above ~10^4 Torr. That is why the range is pinned.
//
- // This is the model's own consistency, NOT agreement with experiment:
- // T&T measure n = 0.33, outside this interval entirely. That gap is the
- // real M6a finding and is gated separately in tests/validation.rs.
+ // This is the model's own consistency, NOT agreement with experiment.
+ // Agreement is a separate question and a separate gate: T&T measure
+ // n = 0.329, which the shipped closure's δ_eff envelope contains since
+ // 2026-07-30 (`tt2012_threshold_slope_matches_measurement`). Nothing
+ // here asserts that; this gate would still pass if it stopped being
+ // true.
let m = model();
let n_points = 8;
let curve = m.pressure_sweep(GATE_P_LO, GATE_P_HI, n_points, 6e-9, 400);
diff --git a/src/cases.rs b/src/cases.rs
index 46766bd..6c8f26a 100644
--- a/src/cases.rs
+++ b/src/cases.rs
@@ -10,16 +10,18 @@
//! original `main.rs` loops exactly.
use anyhow::{Context, Result};
-use ndarray::{Array2, Array3, s};
+use ndarray::{Array2, Array3, Array4, s};
use crate::airprops::AirTable;
use crate::aperture::Aperture;
use crate::blooming::ThermalBlooming;
use crate::breakdown0d::{AirBreakdown, Focus, Gas};
use crate::euler1d::{IdealGas, Primitive};
+use crate::euler2d::Primitive2d;
use crate::field::{Field, IntensityScale};
use crate::grid::Grid;
use crate::lsd::{Absorption, IonizationCeiling, LsdColumn, SeededIgnition, raizer_lsd_velocity};
+use crate::lsd2d::{BeamProfile, Lsd2dColumn, SeededIgnition2d};
use crate::medium::{UniformExtinction, kruse_extinction};
use crate::montecarlo::seeded_ensemble;
use crate::plasmaprops::PlasmaTable;
@@ -625,14 +627,15 @@ pub struct LsdRun {
/// question "does the beam that drives the detonation also light it?", and the
/// answer the two models give together is **no, by five orders of magnitude**:
///
-/// - M6a's breakdown threshold in air at 1 atm **saturates at ≈1.14×10¹⁶ W/m²
-/// and does not fall with pulse length** — 6 ns and 1 ms give 1.18×10¹⁶ and
-/// 1.14×10¹⁶. It is an intensity floor, not a fluence one: below it the
-/// inelastic losses paid climbing to the ionization potential exceed the
-/// inverse-bremsstrahlung heating, the net cascade rate is negative, and no
-/// exposure time rescues it. Widening the focus does not help either — over a
-/// 500× range of spot radius the threshold moves by 4 %, because diffusion
-/// loss is not what sets it at this pressure.
+/// - M6a's breakdown threshold in air at 1 atm **converges to an intensity
+/// floor of ≈6.75×10¹⁵ W/m², rather than falling without limit** — 6 ns gives
+/// 8.815×10¹⁵ and 1 ms gives 6.745×10¹⁵, a bounded 1.31× fall that is flat to
+/// 1 % over the last two decades of pulse length. It is an intensity floor,
+/// not a fluence one, which is what the two-stage argument below needs; see
+/// `the_sustaining_drive_is_far_below_the_breakdown_threshold` for why the
+/// claim is the *asymptotic* one rather than exact flatness. Widening the
+/// focus does not help either — over a 500× range of spot radius the
+/// threshold moves by 6 % and saturates.
/// - The sustaining drive an LSD wave runs on is ~10¹¹ W/m² (10⁷ W/cm², the
/// spec's representative value).
///
@@ -640,9 +643,9 @@ pub struct LsdRun {
/// a defect in either model — it is the known experimental situation, where LSD
/// waves in clean air are initiated on a target, on an aerosol, or by a separate
/// high-intensity spike, and are then *sustained* far below breakdown by the
-/// plasma that already exists. M6a's ungated absolute level (4.8–7.0× above the
-/// measured Thiyagarajan & Thompson curve) does not touch the conclusion: the
-/// gap is 10⁵ and the uncertainty is ~7×.
+/// plasma that already exists. M6a's ungated absolute level (3.90–4.69× above
+/// the measured Thiyagarajan & Thompson curve) does not touch the conclusion:
+/// the gap is 10⁵ and the uncertainty is ~5×.
///
/// # What each half is worth
///
@@ -1269,7 +1272,7 @@ pub struct IgnitionRun {
///
/// **The position of `p_ignite` on the `cn2` axis is not.** Whether a given
/// realization lights depends on [`AirBreakdown`]'s absolute threshold, which is
-/// M6a's explicitly ungated quantity (4.8–7.0× above the measured Thiyagarajan
+/// M6a's explicitly ungated quantity (3.90–4.69× above the measured Thiyagarajan
/// & Thompson curve, inside the 3–10× inter-lab scatter). Every ignition
/// probability here carries that offset, and it must be labelled so wherever it
/// is plotted (`docs/M6A2_SPEC.md` § "What this rung can and cannot claim").
@@ -1434,7 +1437,7 @@ pub struct IgnitionSweepRun {
///
/// The **position** of the curve on the `Cn²` axis is not a claim about the
/// world. It is set by where `AirBreakdown`'s absolute threshold falls, and
-/// that threshold is M6a's explicitly ungated quantity — 4.8–7.0× above the
+/// that threshold is M6a's explicitly ungated quantity — 3.90–4.69× above the
/// measured Thiyagarajan & Thompson curve, inside the 3–10× inter-lab scatter.
/// Shifting the threshold slides the whole curve sideways without changing its
/// shape. Any plot of this must say so.
@@ -1518,3 +1521,222 @@ fn transition_width_decades(cn2: &[f64], p: &[f64]) -> f64 {
_ => f64::NAN,
}
}
+
+/// Parameters of the `lsd2d` case (M6d): an axisymmetric laser-supported
+/// detonation driven by a **finite-diameter** beam.
+///
+/// No ignition stage, deliberately. The `lsd` case already owns the
+/// ignition-is-not-sustaining story, and seeding directly keeps this case from
+/// re-inheriting M6a's explicitly ungated absolute threshold — see
+/// `docs/M6D_SPEC.md` § NOT in scope.
+pub struct Lsd2dParams {
+ /// Sustaining drive intensity on the beam axis (W/m²).
+ pub drive: f64,
+ /// Beam radius (m).
+ pub beam_radius: f64,
+ /// Super-Gaussian order; `0` selects a top-hat.
+ pub beam_order: u32,
+ /// Ambient pressure (Pa).
+ pub p0: f64,
+ /// Ambient temperature (K).
+ pub t0: f64,
+ /// Column length (m).
+ pub length: f64,
+ /// Domain radius, in beam radii.
+ pub domain_radii: f64,
+ /// Hydro cells along the beam.
+ pub cells_x: usize,
+ /// Rings across the domain radius.
+ pub cells_r: usize,
+ /// Grey-plasma absorption coefficient (1/m).
+ pub alpha: f64,
+ /// Fraction of the column the front is asked to cross.
+ pub cross_fraction: f64,
+ /// Number of recorded snapshots.
+ pub frames: usize,
+}
+
+/// Results of the `lsd2d` case.
+pub struct Lsd2dRun {
+ /// Ambient density from `(T₀, p₀)` (kg/m³).
+ pub rho_0: f64,
+ /// Raizer's closed form for this drive (m/s).
+ pub d_raizer: f64,
+ /// On-axis front speed with the finite beam (m/s).
+ pub d_measured: f64,
+ /// On-axis front speed of the matching **wide-beam** run (m/s) — the
+ /// reference the deficit is measured against, because the transverse
+ /// instability G14 documents is then common to both.
+ pub d_wide: f64,
+ /// `1 − D/D_wide`, the relief deficit.
+ pub relief_deficit: f64,
+ /// Ring centres (m).
+ pub r: Vec,
+ /// Cell centres along the beam (m).
+ pub x: Vec,
+ /// Snapshot times (s).
+ pub frame_time: Vec,
+ /// Fields `[frame, quantity, ring, cell]`, quantities ordered
+ /// `[p, ρ, u_x, u_r, α, I]`.
+ pub fields: Array4,
+ /// On-axis front position at each frame (m); `NaN` before a front exists.
+ pub front_x_axis: Vec,
+ /// Front position at the beam edge at each frame (m).
+ pub front_x_edge: Vec,
+ /// Energy deposited, in the `r dr dx` measure.
+ pub deposited_energy: f64,
+ /// Energy that left through the boundaries, same measure.
+ pub escaped_energy: f64,
+ /// Relative closure of the energy budget.
+ pub energy_residual: f64,
+ /// Whether the **axial** end planes are still undisturbed — the ones that
+ /// would contaminate a front-speed measurement.
+ pub boundaries_undisturbed: bool,
+ /// Whether the outermost ring is still undisturbed. A radially uniform seed
+ /// disturbs it at `t = 0` by construction, so this is information rather
+ /// than a validity flag.
+ pub rim_undisturbed: bool,
+}
+
+/// Run the `lsd2d` case: seed a wave, drive it with a finite-diameter beam, and
+/// measure how much radial relief slows it against the wide-beam limit.
+pub fn run_lsd2d(p: &Lsd2dParams) -> Result {
+ if !(p.length > 0.0 && p.cells_x >= 8 && p.cells_r >= 8) {
+ anyhow::bail!(
+ "lsd2d: need a positive length and at least 8 cells per direction, got {} m / \
+ {} x {}",
+ p.length,
+ p.cells_r,
+ p.cells_x
+ );
+ }
+ if p.frames < 2 {
+ anyhow::bail!("lsd2d: need at least 2 frames, got {}", p.frames);
+ }
+ if !(p.drive > 0.0 && p.drive.is_finite()) {
+ anyhow::bail!("lsd2d: drive intensity must be positive, got {}", p.drive);
+ }
+ if !(p.t0 > 0.0 && p.p0 > 0.0) {
+ anyhow::bail!("lsd2d: need positive ambient T and p");
+ }
+ if !(p.beam_radius > 0.0 && p.beam_radius.is_finite()) {
+ anyhow::bail!("lsd2d: beam radius must be positive, got {}", p.beam_radius);
+ }
+ if !(p.cross_fraction > 0.0 && p.cross_fraction < LSD_FOCUS_FRACTION) {
+ anyhow::bail!(
+ "lsd2d: --cross must be in (0, {LSD_FOCUS_FRACTION}) so the front stays inside \
+ the domain, got {}",
+ p.cross_fraction
+ );
+ }
+
+ let rho_0 = p.p0 / (R_AIR * p.t0);
+ let gas = IdealGas::AIR;
+ let d_raizer = raizer_lsd_velocity(&gas, p.drive, rho_0);
+ let ambient = Primitive2d {
+ rho: rho_0,
+ u_r: 0.0,
+ u_x: 0.0,
+ p: p.p0,
+ };
+ let e_ignite = 5.0 * gas.specific_internal_energy(rho_0, p.p0);
+ let domain_radius = p.domain_radii * p.beam_radius;
+ let beam = if p.beam_order == 0 {
+ BeamProfile::TopHat {
+ radius: p.beam_radius,
+ }
+ } else {
+ BeamProfile::SuperGaussian {
+ radius: p.beam_radius,
+ order: p.beam_order,
+ }
+ };
+ let build = |b: BeamProfile| -> Result {
+ Lsd2dColumn::seeded(
+ gas,
+ p.cells_r,
+ p.cells_x,
+ domain_radius,
+ p.length,
+ ambient,
+ SeededIgnition2d {
+ centre_x: LSD_FOCUS_FRACTION * p.length,
+ width_x: 6e-4,
+ radius: domain_radius * 2.0,
+ pressure: LSD_SEED_MULTIPLE * rho_0 * d_raizer * d_raizer / (gas.gamma + 1.0),
+ },
+ Absorption::GreyThreshold {
+ alpha: p.alpha,
+ e_ignite,
+ },
+ p.drive,
+ b,
+ )
+ };
+
+ let mut column = build(beam)?;
+ let dx = p.length / p.cells_x as f64;
+ let dr = domain_radius / p.cells_r as f64;
+ let x: Vec = (0..p.cells_x).map(|i| (i as f64 + 0.5) * dx).collect();
+ let r: Vec = (0..p.cells_r).map(|j| (j as f64 + 0.5) * dr).collect();
+ let t_end = p.cross_fraction * p.length / d_raizer;
+
+ let mut fields = Array4::::zeros((p.frames, 6, p.cells_r, p.cells_x));
+ let mut frame_time = Vec::with_capacity(p.frames);
+ let mut front_x_axis = Vec::with_capacity(p.frames);
+ let mut front_x_edge = Vec::with_capacity(p.frames);
+
+ for frame in 0..p.frames {
+ let t = frame as f64 * t_end / (p.frames - 1) as f64;
+ column.advance_to(t)?;
+ let alpha = column.alpha_profile()?;
+ let beam_i = column.intensity_profile()?;
+ for j in 0..p.cells_r {
+ for i in 0..p.cells_x {
+ let w = column.hydro().cell(j, i).to_primitive(&gas);
+ let k = j * p.cells_x + i;
+ fields[[frame, 0, j, i]] = w.p;
+ fields[[frame, 1, j, i]] = w.rho;
+ fields[[frame, 2, j, i]] = w.u_x;
+ fields[[frame, 3, j, i]] = w.u_r;
+ fields[[frame, 4, j, i]] = alpha[k];
+ fields[[frame, 5, j, i]] = beam_i[k];
+ }
+ }
+ frame_time.push(column.hydro().time());
+ front_x_axis.push(column.front_position().unwrap_or(f64::NAN));
+ let edge = ((p.beam_radius / dr).floor() as usize).min(p.cells_r - 1);
+ front_x_edge.push(column.front_position_at(edge).unwrap_or(f64::NAN));
+ }
+
+ // The reference: the same run with a beam wider than the domain, so the
+ // transverse instability is common to both and the difference is relief.
+ let mut wide = build(BeamProfile::TopHat { radius: 1.0 })?;
+ let settle = 0.6 * t_end;
+ let window = 0.25 * t_end;
+ wide.advance_to(settle)?;
+ let d_wide = wide.measure_front_speed(window)?;
+
+ let mut narrow = build(beam)?;
+ narrow.advance_to(settle)?;
+ let d_measured = narrow.measure_front_speed(window)?;
+
+ Ok(Lsd2dRun {
+ rho_0,
+ d_raizer,
+ d_measured,
+ d_wide,
+ relief_deficit: 1.0 - d_measured / d_wide,
+ r,
+ x,
+ frame_time,
+ fields,
+ front_x_axis,
+ front_x_edge,
+ deposited_energy: column.deposited_energy(),
+ escaped_energy: column.hydro().escaped_energy(),
+ energy_residual: column.energy_residual(),
+ boundaries_undisturbed: column.axial_boundaries_undisturbed(),
+ rim_undisturbed: column.rim_undisturbed(),
+ })
+}
diff --git a/src/euler1d.rs b/src/euler1d.rs
index 834e629..6b186e4 100644
--- a/src/euler1d.rs
+++ b/src/euler1d.rs
@@ -126,7 +126,7 @@ impl Conserved {
}
/// Physical flux `F(U)`.
-fn flux(gas: &IdealGas, u: Conserved) -> Conserved {
+pub(crate) fn flux(gas: &IdealGas, u: Conserved) -> Conserved {
let w = u.to_primitive(gas);
Conserved {
rho: u.mom,
@@ -149,13 +149,13 @@ pub enum Boundary {
/// That is the point of routing every validity check through it: written as
/// `!is_positive(x)`, a NaN state is refused, where the natural `x <= 0.0`
/// would wave it through.
-fn is_positive(x: f64) -> bool {
+pub(crate) fn is_positive(x: f64) -> bool {
x > 0.0 && x.is_finite()
}
/// Minmod limiter on two slopes: the TVD choice, and the one that makes the
/// Sod contact and shock monotone.
-fn minmod(a: f64, b: f64) -> f64 {
+pub(crate) fn minmod(a: f64, b: f64) -> f64 {
if a * b <= 0.0 {
0.0
} else if a.abs() < b.abs() {
@@ -386,65 +386,78 @@ impl Euler1d {
}
padded
}
+}
- /// HLLC flux across the interface between left state `ul` and right `ur`.
- ///
- /// Wave speeds are the Einfeldt/Davis estimates built on the Roe-averaged
- /// velocity and sound speed, bounded by the one-sided characteristics:
- /// `S_L = min(u_L − c_L, ũ − c̃)`, `S_R = max(u_R + c_R, ũ + c̃)`. Those are
- /// the standard choice that keeps HLLC positivity-preserving for the
- /// isolated Riemann problem (Toro §10.5–10.6).
- fn hllc_flux(&self, ul: Conserved, ur: Conserved) -> Conserved {
- let gas = &self.gas;
- let wl = ul.to_primitive(gas);
- let wr = ur.to_primitive(gas);
- let cl = gas.sound_speed(wl.rho, wl.p);
- let cr = gas.sound_speed(wr.rho, wr.p);
-
- // Roe averages (density-weighted), for the Einfeldt bound.
- let sl_rho = wl.rho.sqrt();
- let sr_rho = wr.rho.sqrt();
- let u_roe = (sl_rho * wl.u + sr_rho * wr.u) / (sl_rho + sr_rho);
- let hl = (ul.energy + wl.p) / wl.rho;
- let hr = (ur.energy + wr.p) / wr.rho;
- let h_roe = (sl_rho * hl + sr_rho * hr) / (sl_rho + sr_rho);
- let c_roe2 = (gas.gamma - 1.0) * (h_roe - 0.5 * u_roe * u_roe);
- let c_roe = if c_roe2 > 0.0 { c_roe2.sqrt() } else { 0.0 };
-
- let s_l = (wl.u - cl).min(u_roe - c_roe);
- let s_r = (wr.u + cr).max(u_roe + c_roe);
-
- if s_l >= 0.0 {
- return flux(gas, ul);
- }
- if s_r <= 0.0 {
- return flux(gas, ur);
- }
-
- // Contact speed (Toro Eq. 10.37).
- let ml = wl.rho * (s_l - wl.u);
- let mr = wr.rho * (s_r - wr.u);
- let s_star = (wr.p - wl.p + ml * wl.u - mr * wr.u) / (ml - mr);
-
- // Star state on the upwind side, and its flux F_K + S_K(U*_K − U_K).
- let star = |u: Conserved, w: Primitive, s_k: f64| -> Conserved {
- let coef = w.rho * (s_k - w.u) / (s_k - s_star);
- Conserved {
- rho: coef,
- mom: coef * s_star,
- energy: coef
- * (u.energy / w.rho + (s_star - w.u) * (s_star + w.p / (w.rho * (s_k - w.u)))),
- }
- };
- if s_star >= 0.0 {
- let u_star = star(ul, wl, s_l);
- flux(gas, ul).zip(u_star.zip(ul, |a, b| a - b), |f, du| f + s_l * du)
- } else {
- let u_star = star(ur, wr, s_r);
- flux(gas, ur).zip(u_star.zip(ur, |a, b| a - b), |f, du| f + s_r * du)
+/// HLLC flux across the interface between left state `ul` and right `ur`.
+///
+/// Wave speeds are the Einfeldt/Davis estimates built on the Roe-averaged
+/// velocity and sound speed, bounded by the one-sided characteristics:
+/// `S_L = min(u_L − c_L, ũ − c̃)`, `S_R = max(u_R + c_R, ũ + c̃)`. Those are
+/// the standard choice that keeps HLLC positivity-preserving for the
+/// isolated Riemann problem (Toro §10.5–10.6).
+///
+/// A free function rather than a method, and `pub(crate)`, so that
+/// `euler2d`'s directional sweeps can call the *same* Riemann
+/// solver instead of carrying a second copy of it. That module needs one extra
+/// fact, and it can read it off the returned flux rather than from a wider
+/// signature: **the sign of the mass flux identifies the upwind side of the
+/// contact.** In the star branches `F_ρ = ρ*_K·S*` with `ρ*_K > 0`, so
+/// `sign(F_ρ) = sign(S*)`; in the supersonic branches `S_L ≥ 0` forces
+/// `u_L > c_L > 0` and `S_R ≤ 0` forces `u_R < −c_R < 0`, so the sign still
+/// points at the donor side. A transverse momentum component therefore rides
+/// along as `F_ρ · v_upwind`, exactly (Toro §10.5: the transverse velocity is
+/// constant across the acoustic waves within each star state).
+pub(crate) fn hllc_flux(gas: &IdealGas, ul: Conserved, ur: Conserved) -> Conserved {
+ let wl = ul.to_primitive(gas);
+ let wr = ur.to_primitive(gas);
+ let cl = gas.sound_speed(wl.rho, wl.p);
+ let cr = gas.sound_speed(wr.rho, wr.p);
+
+ // Roe averages (density-weighted), for the Einfeldt bound.
+ let sl_rho = wl.rho.sqrt();
+ let sr_rho = wr.rho.sqrt();
+ let u_roe = (sl_rho * wl.u + sr_rho * wr.u) / (sl_rho + sr_rho);
+ let hl = (ul.energy + wl.p) / wl.rho;
+ let hr = (ur.energy + wr.p) / wr.rho;
+ let h_roe = (sl_rho * hl + sr_rho * hr) / (sl_rho + sr_rho);
+ let c_roe2 = (gas.gamma - 1.0) * (h_roe - 0.5 * u_roe * u_roe);
+ let c_roe = if c_roe2 > 0.0 { c_roe2.sqrt() } else { 0.0 };
+
+ let s_l = (wl.u - cl).min(u_roe - c_roe);
+ let s_r = (wr.u + cr).max(u_roe + c_roe);
+
+ if s_l >= 0.0 {
+ return flux(gas, ul);
+ }
+ if s_r <= 0.0 {
+ return flux(gas, ur);
+ }
+
+ // Contact speed (Toro Eq. 10.37).
+ let ml = wl.rho * (s_l - wl.u);
+ let mr = wr.rho * (s_r - wr.u);
+ let s_star = (wr.p - wl.p + ml * wl.u - mr * wr.u) / (ml - mr);
+
+ // Star state on the upwind side, and its flux F_K + S_K(U*_K − U_K).
+ let star = |u: Conserved, w: Primitive, s_k: f64| -> Conserved {
+ let coef = w.rho * (s_k - w.u) / (s_k - s_star);
+ Conserved {
+ rho: coef,
+ mom: coef * s_star,
+ energy: coef
+ * (u.energy / w.rho + (s_star - w.u) * (s_star + w.p / (w.rho * (s_k - w.u)))),
}
+ };
+ if s_star >= 0.0 {
+ let u_star = star(ul, wl, s_l);
+ flux(gas, ul).zip(u_star.zip(ul, |a, b| a - b), |f, du| f + s_l * du)
+ } else {
+ let u_star = star(ur, wr, s_r);
+ flux(gas, ur).zip(u_star.zip(ur, |a, b| a - b), |f, du| f + s_r * du)
}
+}
+impl Euler1d {
/// MUSCL-Hancock interface fluxes for the whole domain: minmod-limited
/// slopes, boundary-extrapolated values, a half-step evolution of each, then
/// one HLLC solve per interface. Returns `n + 1` fluxes.
@@ -485,7 +498,13 @@ impl Euler1d {
// Interface j sits between padded cells N_GHOST-1+j and N_GHOST+j.
Ok((0..=self.cells.len())
- .map(|j| self.hllc_flux(right_face[N_GHOST - 1 + j], left_face[N_GHOST + j]))
+ .map(|j| {
+ hllc_flux(
+ &self.gas,
+ right_face[N_GHOST - 1 + j],
+ left_face[N_GHOST + j],
+ )
+ })
.collect())
}
@@ -686,7 +705,7 @@ mod tests {
p: 1e5,
}
.to_conserved(&gas);
- let f = s.hllc_flux(fast_right, fast_right);
+ let f = hllc_flux(&s.gas, fast_right, fast_right);
let exact = flux(&gas, fast_right);
assert!((f.rho - exact.rho).abs() / exact.rho.abs() < 1e-14);
assert!((f.mom - exact.mom).abs() / exact.mom.abs() < 1e-14);
diff --git a/src/euler2d.rs b/src/euler2d.rs
new file mode 100644
index 0000000..a3923e5
--- /dev/null
+++ b/src/euler2d.rs
@@ -0,0 +1,1349 @@
+//! 2-D compressible Euler solver, planar or axisymmetric: HLLC + MUSCL-Hancock
+//! with dimensional splitting (M6d, step 2).
+//!
+//! ```text
+//! ∂U/∂t + (1/r^m)·∂(r^m·F_r)/∂r + ∂F_x/∂x = 0 m = 0 planar, 1 axisymmetric
+//!
+//! U = (ρ, ρu_r, ρu_x, E)ᵀ
+//! F_r = (ρu_r, ρu_r² + p, ρu_r u_x, (E + p)u_r)ᵀ
+//! F_x = (ρu_x, ρu_r u_x, ρu_x² + p, (E + p)u_x)ᵀ
+//! E = p/(γ−1) + ½ρ(u_r² + u_x²)
+//! ```
+//!
+//! **There is no laser physics in this module, deliberately** — `docs/M6D_SPEC.md`
+//! gate decision 4, carried forward verbatim from M6c. The coupled axisymmetric
+//! LSD column lives one layer up in `lsd2d.rs`; this module exposes
+//! [`Euler2d::step_with_source`] and [`Euler2d::add_energy`] as the seams it
+//! attaches to.
+//!
+//! # The Riemann solver is reused, not reimplemented
+//!
+//! Each directional sweep is the 1-D MUSCL-Hancock update, calling
+//! `euler1d::hllc_flux` itself. A sweep carries a transverse momentum component
+//! the 1-D state does not have, and it rides along exactly, with no second
+//! Riemann solver:
+//!
+//! - **Pack** by removing the transverse kinetic energy, `E_∥ = E − ½ρv_t²`, so
+//! the 1-D solver sees a state whose pressure is the true 2-D pressure.
+//! - **Unpack** with `F_{ρv_t} = F_ρ·v_t` and `F_E = F_{E_∥} + ½v_t²·F_ρ`, both
+//! exact identities (Toro §10.5: the transverse velocity is constant across
+//! the acoustic waves within each star state).
+//! - The **upwind side** is read off `sign(F_ρ)`; see `hllc_flux`'s own comment
+//! for why that is exactly the side of the contact.
+//!
+//! # The axis is not a special case
+//!
+//! The `1/r` that makes axisymmetric codes delicate never appears. Cells are
+//! annuli, interfaces carry area `A ∝ r`, and the axis interface has `A = 0`, so
+//! nothing crosses `r = 0` by construction. The geometric source is written as
+//! the **same expression** as the pressure part of the flux difference, so for a
+//! radially uniform state the two cancel to *bit* precision and such a state is
+//! an exact fixed point of the radial operator. See [`Geometry`] and
+//! [`Euler2d::sweep_line`].
+//!
+//! # Splitting
+//!
+//! Strang, with the radial sweep outside: `R(dt/2) → X(dt) → R(dt/2)`. The order
+//! is not arbitrary — it makes the planar, radially uniform limit an exact
+//! identity in `R`, so `X(dt)` is bit-for-bit `Euler1d::step`, which is what
+//! gate G9 asserts. Sweep order is never alternated between steps; that is 2nd
+//! order only on average and would make the convergence gate read noise.
+//!
+//! Numerics otherwise follow `euler1d`: minmod-limited MUSCL-Hancock, CFL
+//! asserted per step from the current wave speeds in **both** directions, and a
+//! positivity guard that **bails with the cell and stage, never clamps**.
+
+use anyhow::{Result, bail};
+
+use crate::euler1d::{Conserved, IdealGas, N_GHOST, flux, hllc_flux, is_positive, minmod};
+
+/// Which sweep a directional helper is serving.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum Dir {
+ /// Along the beam axis `x`. Never area-weighted: on an annular cell the
+ /// axial faces and the cell volume carry the same ring area, and it
+ /// cancels.
+ Axial,
+ /// Along `r`. Area-weighted when [`Geometry::Axisymmetric`].
+ Radial,
+}
+
+/// Planar slab or axisymmetric annuli.
+///
+/// The two differ only in the interface areas and cell volumes the radial sweep
+/// uses, which is the point of routing both through one code path: the planar
+/// case is then demonstrably the same arithmetic, and gate G9 can assert
+/// bit-identity against `Euler1d` rather than a tolerance.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Geometry {
+ /// `m = 0`. Radial interfaces have unit area; cell volume is `Δr`.
+ Planar,
+ /// `m = 1`. The radial interface at `r` has area `∝ r`; the annulus between
+ /// `r_−` and `r_+` has volume `∝ (r_+² − r_−²)/2`.
+ Axisymmetric,
+}
+
+impl Geometry {
+ /// Area of the radial interface at radius `r`, in units where the planar
+ /// case is 1.
+ ///
+ /// The axis interface (`r = 0`) therefore has **zero** area, which is what
+ /// removes the `1/r` singularity: no flux can cross the axis, and no
+ /// division by a vanishing radius is ever performed.
+ fn face_area(self, r: f64) -> f64 {
+ match self {
+ Self::Planar => 1.0,
+ Self::Axisymmetric => r,
+ }
+ }
+
+ /// Volume of the ring `[r_−, r_+]`, in the same units.
+ ///
+ /// `(r_+² − r_−²)/2` rather than `r_j·Δr`: with this choice
+ /// `(A_+ − A_−)/V` is *analytically* `1/r_j` at the arithmetic cell centre,
+ /// so the geometric source is the exact volume average of `1/r` and is
+ /// finite in the innermost ring without a floor or an epsilon.
+ fn cell_volume(self, r_minus: f64, r_plus: f64) -> f64 {
+ match self {
+ Self::Planar => r_plus - r_minus,
+ Self::Axisymmetric => 0.5 * (r_plus * r_plus - r_minus * r_minus),
+ }
+ }
+}
+
+/// What the solver does at a domain edge.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Boundary2d {
+ /// Zero-gradient outflow.
+ Transmissive,
+ /// Solid wall: normal momentum reflected, everything else mirrored.
+ Reflective,
+ /// The symmetry axis `r = 0`. Identical to [`Reflective`](Self::Reflective)
+ /// in code, and named separately because it is a statement about the
+ /// *coordinates* rather than about a physical wall — and because getting
+ /// its parity wrong is the classic axisymmetric failure (gate G13).
+ Axis,
+}
+
+impl Boundary2d {
+ /// Whether a ghost fill mirrors the interior with a sign flip on the normal
+ /// momentum.
+ fn mirrors(self) -> bool {
+ matches!(self, Self::Reflective | Self::Axis)
+ }
+}
+
+/// Primitive state `(ρ, u_r, u_x, p)` — SI: kg/m³, m/s, m/s, Pa.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct Primitive2d {
+ /// Density (kg/m³).
+ pub rho: f64,
+ /// Radial velocity (m/s).
+ pub u_r: f64,
+ /// Axial velocity (m/s).
+ pub u_x: f64,
+ /// Pressure (Pa).
+ pub p: f64,
+}
+
+/// Conserved state `(ρ, ρu_r, ρu_x, E)`.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct Conserved2d {
+ /// Density (kg/m³).
+ pub rho: f64,
+ /// Radial momentum density (kg/(m²·s)).
+ pub mom_r: f64,
+ /// Axial momentum density (kg/(m²·s)).
+ pub mom_x: f64,
+ /// Total energy density (J/m³).
+ pub energy: f64,
+}
+
+impl Conserved2d {
+ fn zip(self, other: Self, f: impl Fn(f64, f64) -> f64) -> Self {
+ Self {
+ rho: f(self.rho, other.rho),
+ mom_r: f(self.mom_r, other.mom_r),
+ mom_x: f(self.mom_x, other.mom_x),
+ energy: f(self.energy, other.energy),
+ }
+ }
+
+ fn map(self, f: impl Fn(f64) -> f64) -> Self {
+ Self {
+ rho: f(self.rho),
+ mom_r: f(self.mom_r),
+ mom_x: f(self.mom_x),
+ energy: f(self.energy),
+ }
+ }
+
+ /// `a·self − b·other`, componentwise: the area-weighted flux difference.
+ ///
+ /// Written this way — areas multiplying *inside* the difference — rather
+ /// than as `(self − other)` scaled afterwards, because with unit areas
+ /// `1.0 * F` is exactly `F` and the whole expression collapses to
+ /// `euler1d`'s. That is what makes the planar path bit-identical.
+ fn weighted_difference(self, a: f64, other: Self, b: f64) -> Self {
+ Self {
+ rho: a * self.rho - b * other.rho,
+ mom_r: a * self.mom_r - b * other.mom_r,
+ mom_x: a * self.mom_x - b * other.mom_x,
+ energy: a * self.energy - b * other.energy,
+ }
+ }
+
+ /// Convert to primitive variables under `gas`.
+ ///
+ /// Unchecked, like its 1-D counterpart: callers that can produce a
+ /// non-physical state run the guard first.
+ pub fn to_primitive(self, gas: &IdealGas) -> Primitive2d {
+ let u_r = self.mom_r / self.rho;
+ let u_x = self.mom_x / self.rho;
+ Primitive2d {
+ rho: self.rho,
+ u_r,
+ u_x,
+ p: (gas.gamma - 1.0) * (self.energy - 0.5 * (self.mom_r * u_r + self.mom_x * u_x)),
+ }
+ }
+}
+
+impl Primitive2d {
+ /// Convert to conserved variables under `gas`.
+ pub fn to_conserved(self, gas: &IdealGas) -> Conserved2d {
+ Conserved2d {
+ rho: self.rho,
+ mom_r: self.rho * self.u_r,
+ mom_x: self.rho * self.u_x,
+ energy: self.p / (gas.gamma - 1.0)
+ + 0.5 * self.rho * (self.u_r * self.u_r + self.u_x * self.u_x),
+ }
+ }
+}
+
+/// Split a 2-D state into the 1-D triple the shared Riemann solver takes, plus
+/// the transverse velocity that rides along.
+///
+/// The transverse kinetic energy is **removed** from the energy, so the packed
+/// state's pressure under `euler1d`'s own `to_primitive` is the true 2-D
+/// pressure. Without that the sweep would silently solve a different problem —
+/// every flux would carry a pressure short of the transverse contribution.
+fn pack(u: Conserved2d, dir: Dir) -> (Conserved, f64) {
+ let (par, tr) = match dir {
+ Dir::Axial => (u.mom_x, u.mom_r),
+ Dir::Radial => (u.mom_r, u.mom_x),
+ };
+ let v_t = tr / u.rho;
+ (
+ Conserved {
+ rho: u.rho,
+ mom: par,
+ energy: u.energy - 0.5 * tr * v_t,
+ },
+ v_t,
+ )
+}
+
+/// Reassemble a directional flux from the 1-D solver's answer and the upwind
+/// transverse velocity. Both identities are exact — see the module header.
+fn unpack_flux(f: Conserved, v_t: f64, dir: Dir) -> Conserved2d {
+ let mom_t = f.rho * v_t;
+ let energy = f.energy + 0.5 * v_t * mom_t;
+ match dir {
+ Dir::Axial => Conserved2d {
+ rho: f.rho,
+ mom_r: mom_t,
+ mom_x: f.mom,
+ energy,
+ },
+ Dir::Radial => Conserved2d {
+ rho: f.rho,
+ mom_r: f.mom,
+ mom_x: mom_t,
+ energy,
+ },
+ }
+}
+
+/// Physical flux `F_dir(U)`.
+fn dir_flux(gas: &IdealGas, u: Conserved2d, dir: Dir) -> Conserved2d {
+ let (c, v_t) = pack(u, dir);
+ unpack_flux(flux(gas, c), v_t, dir)
+}
+
+/// HLLC flux across an interface, the transverse component carried by the
+/// upwind side of the contact.
+fn dir_hllc(gas: &IdealGas, ul: Conserved2d, ur: Conserved2d, dir: Dir) -> Conserved2d {
+ let (cl, v_tl) = pack(ul, dir);
+ let (cr, v_tr) = pack(ur, dir);
+ let f = hllc_flux(gas, cl, cr);
+ let v_t = if f.rho >= 0.0 { v_tl } else { v_tr };
+ unpack_flux(f, v_t, dir)
+}
+
+/// Radius of a radial interface, with ghost interfaces **reflected about the
+/// wall** rather than continued past it.
+///
+/// This is a correctness requirement, not a refinement, and it is the one place
+/// where axisymmetry differs from a Cartesian code in a way that is easy to get
+/// silently wrong.
+///
+/// A mirroring boundary works because the ghost's reconstruction is the exact
+/// mirror of the interior cell's, so the interface Riemann problem is
+/// symmetric, its contact speed is zero, and no mass crosses. In cylindrical
+/// geometry that symmetry also has to hold for the *metric*: the mirror of the
+/// ring `[r_w − Δr, r_w]` is the ring `[r_w, r_w + Δr]` only if the ghost's
+/// faces carry the mirrored areas. Continuing the radius past the wall instead
+/// gives the ghost a larger area than its mirror, the Hancock predictor then
+/// evolves the two sides differently, the contact speed is no longer zero, and
+/// the wall leaks. **Measured with the naive metric: 3.3×10⁻⁴ of the mass over
+/// 30 steps of the closed-box test, and growing.**
+///
+/// At the axis `r_w = 0`, so this reduces to `|r|` — including the zero-area
+/// face the first ring and its ghost share.
+fn ghost_face_radius(raw: f64, r_min: f64, r_max: f64, lo: Boundary2d, hi: Boundary2d) -> f64 {
+ if lo.mirrors() && raw < r_min {
+ 2.0 * r_min - raw
+ } else if hi.mirrors() && raw > r_max {
+ 2.0 * r_max - raw
+ } else {
+ raw
+ }
+}
+
+/// Pressure of a 2-D state, obtained through the *packed* 1-D state so the
+/// geometric source and the momentum flux are built from the same number by the
+/// same route.
+fn pressure(gas: &IdealGas, u: Conserved2d, dir: Dir) -> f64 {
+ pack(u, dir).0.to_primitive(gas).p
+}
+
+/// A uniform-mesh 2-D Euler domain and its state.
+///
+/// Cells are stored row-major: ring `j`, axial cell `i`, at `j·n_x + i`.
+#[derive(Debug, Clone)]
+pub struct Euler2d {
+ gas: IdealGas,
+ geometry: Geometry,
+ bc_r_min: Boundary2d,
+ bc_r_max: Boundary2d,
+ bc_x_min: Boundary2d,
+ bc_x_max: Boundary2d,
+ n_r: usize,
+ n_x: usize,
+ dr: f64,
+ dx: f64,
+ r_min: f64,
+ x_min: f64,
+ cells: Vec,
+ /// Interface areas, `n_r + 1` of them, for the radial sweep.
+ r_areas: Vec,
+ /// Ring volumes in the `r dr` measure, `n_r` of them.
+ r_volumes: Vec,
+ cfl: f64,
+ time: f64,
+ step_count: usize,
+ escaped_energy: f64,
+}
+
+impl Euler2d {
+ /// Build from cell-centred primitive states on a uniform `(r, x)` mesh.
+ ///
+ /// `initial` is row-major: `initial[j·n_x + i]`. `boundaries` is
+ /// `[r_min, r_max, x_min, x_max]`.
+ #[allow(clippy::too_many_arguments)]
+ pub fn new(
+ gas: IdealGas,
+ geometry: Geometry,
+ boundaries: [Boundary2d; 4],
+ r_min: f64,
+ x_min: f64,
+ dr: f64,
+ dx: f64,
+ n_r: usize,
+ n_x: usize,
+ initial: &[Primitive2d],
+ ) -> Result {
+ if n_r < 2 * N_GHOST || n_x < 2 * N_GHOST {
+ bail!(
+ "euler2d: {n_r} x {n_x} cells is below the {} per direction the MUSCL stencil needs",
+ 2 * N_GHOST
+ );
+ }
+ if initial.len() != n_r * n_x {
+ bail!(
+ "euler2d: got {} initial states for a {n_r} x {n_x} mesh",
+ initial.len()
+ );
+ }
+ if !is_positive(dr) || !is_positive(dx) {
+ bail!("euler2d: dr and dx must be positive, got dr = {dr}, dx = {dx}");
+ }
+ if r_min < 0.0 {
+ bail!("euler2d: r_min must be non-negative, got {r_min}");
+ }
+ if geometry == Geometry::Axisymmetric && r_min == 0.0 && boundaries[0] != Boundary2d::Axis {
+ bail!(
+ "euler2d: an axisymmetric domain reaching r = 0 must use Boundary2d::Axis there, \
+ got {:?}",
+ boundaries[0]
+ );
+ }
+ for (k, w) in initial.iter().enumerate() {
+ if !is_positive(w.rho) || !is_positive(w.p) {
+ bail!(
+ "euler2d: non-physical initial state in cell (j = {}, i = {}): ρ = {}, p = {}",
+ k / n_x,
+ k % n_x,
+ w.rho,
+ w.p
+ );
+ }
+ }
+ // Padded metrics: index `k` is padded cell `k`, i.e. interior ring
+ // `k − N_GHOST`. Ghosts carry their **own** areas rather than borrowing
+ // a neighbour's — see `ghost_face_radius` for why that is a correctness
+ // requirement and not a refinement.
+ let r_max = r_min + n_r as f64 * dr;
+ let face_radius = |k: isize| {
+ ghost_face_radius(
+ r_min + k as f64 * dr,
+ r_min,
+ r_max,
+ boundaries[0],
+ boundaries[1],
+ )
+ };
+ let r_areas = (0..=(n_r + 2 * N_GHOST))
+ .map(|k| geometry.face_area(face_radius(k as isize - N_GHOST as isize)))
+ .collect();
+ let r_volumes = (0..(n_r + 2 * N_GHOST))
+ .map(|k| {
+ let lo = face_radius(k as isize - N_GHOST as isize);
+ let hi = face_radius(k as isize + 1 - N_GHOST as isize);
+ geometry.cell_volume(lo.min(hi), lo.max(hi))
+ })
+ .collect();
+ Ok(Self {
+ gas,
+ geometry,
+ bc_r_min: boundaries[0],
+ bc_r_max: boundaries[1],
+ bc_x_min: boundaries[2],
+ bc_x_max: boundaries[3],
+ n_r,
+ n_x,
+ dr,
+ dx,
+ r_min,
+ x_min,
+ cells: initial.iter().map(|w| w.to_conserved(&gas)).collect(),
+ r_areas,
+ r_volumes,
+ cfl: 0.8,
+ time: 0.0,
+ step_count: 0,
+ escaped_energy: 0.0,
+ })
+ }
+
+ /// Sample `initial(r, x)` at the cell centres.
+ #[allow(clippy::too_many_arguments)]
+ pub fn from_fn(
+ gas: IdealGas,
+ geometry: Geometry,
+ boundaries: [Boundary2d; 4],
+ r_min: f64,
+ x_min: f64,
+ dr: f64,
+ dx: f64,
+ n_r: usize,
+ n_x: usize,
+ initial: impl Fn(f64, f64) -> Primitive2d,
+ ) -> Result {
+ let states: Vec = (0..n_r * n_x)
+ .map(|k| {
+ let (j, i) = (k / n_x, k % n_x);
+ initial(r_min + (j as f64 + 0.5) * dr, x_min + (i as f64 + 0.5) * dx)
+ })
+ .collect();
+ Self::new(
+ gas, geometry, boundaries, r_min, x_min, dr, dx, n_r, n_x, &states,
+ )
+ }
+
+ /// Courant number used to size each step. Capped at 0.8 as in `euler1d`;
+ /// values above it are rejected rather than quietly reduced.
+ pub fn set_cfl(&mut self, cfl: f64) -> Result<()> {
+ if !is_positive(cfl) || cfl > 0.8 {
+ bail!("euler2d: CFL must be in (0, 0.8], got {cfl}");
+ }
+ self.cfl = cfl;
+ Ok(())
+ }
+
+ /// Courant number in force.
+ pub fn cfl(&self) -> f64 {
+ self.cfl
+ }
+
+ /// Elapsed simulated time (s).
+ pub fn time(&self) -> f64 {
+ self.time
+ }
+
+ /// Completed hydro steps.
+ pub fn steps(&self) -> usize {
+ self.step_count
+ }
+
+ /// Rings.
+ pub fn n_r(&self) -> usize {
+ self.n_r
+ }
+
+ /// Axial cells.
+ pub fn n_x(&self) -> usize {
+ self.n_x
+ }
+
+ /// Radial spacing (m).
+ pub fn dr(&self) -> f64 {
+ self.dr
+ }
+
+ /// Axial spacing (m).
+ pub fn dx(&self) -> f64 {
+ self.dx
+ }
+
+ /// The EOS in force.
+ pub fn gas(&self) -> IdealGas {
+ self.gas
+ }
+
+ /// The geometry in force.
+ pub fn geometry(&self) -> Geometry {
+ self.geometry
+ }
+
+ /// Centre radius of ring `j` (m).
+ pub fn r_centre(&self, j: usize) -> f64 {
+ self.r_min + (j as f64 + 0.5) * self.dr
+ }
+
+ /// Centre coordinate of axial cell `i` (m).
+ pub fn x_centre(&self, i: usize) -> f64 {
+ self.x_min + (i as f64 + 0.5) * self.dx
+ }
+
+ /// Conserved state, interior cells, row-major.
+ pub fn cells(&self) -> &[Conserved2d] {
+ &self.cells
+ }
+
+ /// Conserved state of cell `(j, i)`.
+ pub fn cell(&self, j: usize, i: usize) -> Conserved2d {
+ self.cells[j * self.n_x + i]
+ }
+
+ /// Primitive state, interior cells, row-major.
+ pub fn primitives(&self) -> Vec {
+ self.cells
+ .iter()
+ .map(|u| u.to_primitive(&self.gas))
+ .collect()
+ }
+
+ /// Domain integral of a per-cell quantity in the discrete `r dr dx`
+ /// measure — the measure in which mass, axial momentum and energy are
+ /// exactly conserved.
+ fn integrate(&self, f: impl Fn(Conserved2d) -> f64) -> f64 {
+ (0..self.n_r)
+ .map(|j| {
+ let vol = self.r_volumes[N_GHOST + j] * self.dx;
+ (0..self.n_x)
+ .map(|i| f(self.cells[j * self.n_x + i]))
+ .sum::()
+ * vol
+ })
+ .sum()
+ }
+
+ /// Domain-integrated mass `∫ρ r dr dx`.
+ pub fn total_mass(&self) -> f64 {
+ self.integrate(|u| u.rho)
+ }
+
+ /// Domain-integrated total energy `∫E r dr dx`.
+ pub fn total_energy(&self) -> f64 {
+ self.integrate(|u| u.energy)
+ }
+
+ /// Domain-integrated axial momentum `∫ρu_x r dr dx` — conserved.
+ pub fn total_axial_momentum(&self) -> f64 {
+ self.integrate(|u| u.mom_x)
+ }
+
+ /// Domain-integrated radial momentum `∫ρu_r r dr dx`.
+ ///
+ /// **Deliberately not conserved** in axisymmetry: the geometric source is a
+ /// real term, and an implementation that conserved this would be wrong.
+ /// Gate G12 asserts both halves of that sentence.
+ pub fn total_radial_momentum(&self) -> f64 {
+ self.integrate(|u| u.mom_r)
+ }
+
+ /// Energy that has left through transmissive boundaries, in the same
+ /// measure as [`total_energy`](Self::total_energy).
+ ///
+ /// Without this the budget is only closable on runs where nothing escapes,
+ /// which is exactly the case that proves nothing — hence G12's second leg.
+ pub fn escaped_energy(&self) -> f64 {
+ self.escaped_energy
+ }
+
+ /// Largest signal speed in each direction, `max(|u_d| + c)` (m/s).
+ pub fn max_wave_speeds(&self) -> (f64, f64) {
+ self.cells.iter().fold((0.0f64, 0.0f64), |(sr, sx), &u| {
+ let w = u.to_primitive(&self.gas);
+ let c = self.gas.sound_speed(w.rho, w.p);
+ (sr.max(w.u_r.abs() + c), sx.max(w.u_x.abs() + c))
+ })
+ }
+
+ /// Stable step from the current wave speeds in **both** directions:
+ /// `dt = CFL / max(s_r/Δr, s_x/Δx)`.
+ pub fn stable_dt(&self) -> Result {
+ let (s_r, s_x) = self.max_wave_speeds();
+ let rate = (s_r / self.dr).max(s_x / self.dx);
+ if !is_positive(rate) {
+ bail!(
+ "euler2d: no finite wave speed at step {} (max |u_r| + c = {s_r}, \
+ max |u_x| + c = {s_x})",
+ self.step_count
+ );
+ }
+ Ok(self.cfl / rate)
+ }
+
+ /// Bail loudly on any non-physical cell, naming the cell, its physical
+ /// position, the step **and the stage**. Never clamps.
+ ///
+ /// The stage matters more here than in 1-D because there are three places a
+ /// state can go bad and they mean different things: an `r-sweep` failure
+ /// points at the axis or the geometric source, an `x-sweep` failure at the
+ /// same physics `euler1d` would have hit, and a `geometric source` failure
+ /// at the genuinely new mode — that source adds radial momentum at fixed
+ /// total energy, so it raises kinetic energy and can drive `p < 0` in the
+ /// innermost rings of a strong converging flow.
+ fn assert_physical(
+ &self,
+ cells: &[Conserved2d],
+ stage: &str,
+ locate: impl Fn(usize) -> (usize, usize),
+ ) -> Result<()> {
+ for (k, &u) in cells.iter().enumerate() {
+ let w = u.to_primitive(&self.gas);
+ if !is_positive(w.rho) || !is_positive(w.p) || !w.u_r.is_finite() || !w.u_x.is_finite()
+ {
+ let (j, i) = locate(k);
+ bail!(
+ "euler2d: non-physical state after {stage} at step {}, cell \
+ (j = {j}, i = {i}) (r = {:.6e} m, x = {:.6e} m, t = {:.6e} s): \
+ ρ = {:.6e}, u_r = {:.6e}, u_x = {:.6e}, p = {:.6e}",
+ self.step_count,
+ self.r_centre(j.min(self.n_r - 1)),
+ self.x_centre(i.min(self.n_x - 1)),
+ self.time,
+ w.rho,
+ w.u_r,
+ w.u_x,
+ w.p
+ );
+ }
+ }
+ Ok(())
+ }
+
+ /// Pad a line of states with [`N_GHOST`] ghosts per side.
+ ///
+ /// `Reflective` and `Axis` mirror the line and **flip the sign of the
+ /// normal momentum**. That odd parity is the whole axis condition, and
+ /// getting it wrong is the classic axisymmetric failure: an even-parity
+ /// fill produces a thin, artificially hot, under-dense column on the axis
+ /// that grows with time and looks exactly like a physical result. Gate G13
+ /// checks it against a deliberate even-parity contrast, which is why
+ /// `mirror_flips_normal_momentum` is a parameter rather than a constant.
+ fn pad_line(
+ &self,
+ line: &[Conserved2d],
+ dir: Dir,
+ lo: Boundary2d,
+ hi: Boundary2d,
+ mirror_flips_normal_momentum: bool,
+ ) -> Vec {
+ let n = line.len();
+ let flip = |u: Conserved2d| {
+ if !mirror_flips_normal_momentum {
+ return u;
+ }
+ match dir {
+ Dir::Axial => Conserved2d {
+ mom_x: -u.mom_x,
+ ..u
+ },
+ Dir::Radial => Conserved2d {
+ mom_r: -u.mom_r,
+ ..u
+ },
+ }
+ };
+ let mut padded = Vec::with_capacity(n + 2 * N_GHOST);
+ for g in 0..N_GHOST {
+ // Ghost g (outermost first) mirrors interior cell N_GHOST-1-g.
+ padded.push(if lo.mirrors() {
+ flip(line[N_GHOST - 1 - g])
+ } else {
+ line[0]
+ });
+ }
+ padded.extend_from_slice(line);
+ for g in 0..N_GHOST {
+ padded.push(if hi.mirrors() {
+ flip(line[n - 1 - g])
+ } else {
+ line[n - 1]
+ });
+ }
+ padded
+ }
+
+ /// One MUSCL-Hancock sweep along `dir` over a single line of cells.
+ ///
+ /// `areas` holds the `n + 1` interface areas and `volumes` the `n` cell
+ /// volumes. For every planar sweep these are `1.0` and `h`, and the
+ /// arithmetic below then reduces **exactly** to `Euler1d`'s: `1.0 * F` is
+ /// `F`, and `dt / h` is the same `lambda`. That is what makes gate G9 an
+ /// equality rather than a tolerance, and it is why the area weights
+ /// multiply *inside* the flux difference while the volume divides *through
+ /// the scalar*.
+ ///
+ /// Returns the updated line and the energy that crossed its two end
+ /// interfaces, per unit of the transverse measure.
+ #[allow(clippy::too_many_arguments)]
+ fn sweep_line(
+ &self,
+ line: &[Conserved2d],
+ dt: f64,
+ dir: Dir,
+ lo: Boundary2d,
+ hi: Boundary2d,
+ areas: &[f64],
+ volumes: &[f64],
+ geometric: bool,
+ mirror_flips: bool,
+ locate: impl Fn(usize) -> (usize, usize) + Copy,
+ ) -> Result<(Vec, f64)> {
+ let n = line.len();
+ let padded = self.pad_line(line, dir, lo, hi, mirror_flips);
+ let n_pad = padded.len();
+ let gas = &self.gas;
+
+ let mut left_face = vec![padded[0]; n_pad];
+ let mut right_face = vec![padded[0]; n_pad];
+ let mut half_cell = vec![padded[0]; n_pad];
+ for k in 1..n_pad - 1 {
+ let back = padded[k].zip(padded[k - 1], |a, b| a - b);
+ let fwd = padded[k + 1].zip(padded[k], |a, b| a - b);
+ let slope = back.zip(fwd, minmod);
+ let l = padded[k].zip(slope, |u, d| u - 0.5 * d);
+ let r = padded[k].zip(slope, |u, d| u + 0.5 * d);
+
+ // Each padded cell carries its OWN metric, ghosts included. Letting
+ // ghosts borrow the nearest interior cell's areas is a real bug and
+ // not a harmless approximation: a reflective wall works because the
+ // ghost's reconstruction is the exact mirror of the interior cell's,
+ // and a mismatched metric in the Hancock predictor destroys that
+ // antisymmetry, so the wall leaks. Measured before the fix: 3.4e-4
+ // of the mass, growing, on the closed-box test below.
+ let (a_lo, a_hi, vol) = (areas[k], areas[k + 1], volumes[k]);
+ let half = 0.5 * dt / vol;
+
+ // Hancock predictor. `a_lo·F(l) − a_hi·F(r)` mirrors `euler1d`'s
+ // `F(l) − F(r)` and is bit-identical to it when the areas are 1.
+ let mut df =
+ dir_flux(gas, l, dir).weighted_difference(a_lo, dir_flux(gas, r, dir), a_hi);
+ if geometric {
+ // The geometric source uses the **cell** pressure, written as
+ // `a_hi·p − a_lo·p` so that for a radially uniform state it is
+ // the exact negative of the pressure part of the flux
+ // difference above and the pair vanishes bit-exactly.
+ //
+ // Using the *face* pressures here instead — `a_hi·p_R −
+ // a_lo·p_L` — is well-balanced too, and wrong. Expanded against
+ // the flux difference the pressure terms then cancel
+ // identically, deleting the pressure gradient from the
+ // predictor and leaving a leading-order error. It costs an
+ // order: measured 0.86 / 1.12 against the planar path's
+ // 1.71 / 1.89 on the same problem, which is how it was found.
+ let p_cell = pressure(gas, padded[k], dir);
+ df.mom_r += a_hi * p_cell - a_lo * p_cell;
+ }
+ let increment = df.map(|d| half * d);
+ left_face[k] = l.zip(increment, |a, b| a + b);
+ right_face[k] = r.zip(increment, |a, b| a + b);
+ // The half-step cell average, for a time-centred geometric source.
+ half_cell[k] = left_face[k].zip(right_face[k], |a, b| 0.5 * (a + b));
+ }
+ let stage = match dir {
+ Dir::Axial => "x-sweep MUSCL half-step",
+ Dir::Radial => "r-sweep MUSCL half-step",
+ };
+ let ghost_locate = |k: usize| locate(k.saturating_sub(1));
+ self.assert_physical(&left_face[1..n_pad - 1], stage, ghost_locate)?;
+ self.assert_physical(&right_face[1..n_pad - 1], stage, ghost_locate)?;
+
+ let fluxes: Vec = (0..=n)
+ .map(|k| {
+ dir_hllc(
+ gas,
+ right_face[N_GHOST - 1 + k],
+ left_face[N_GHOST + k],
+ dir,
+ )
+ })
+ .collect();
+
+ // Interior cell k is padded cell N_GHOST + k, and its two interfaces are
+ // padded faces N_GHOST + k and N_GHOST + k + 1.
+ let g = N_GHOST;
+ let mut escaped = 0.0;
+ if lo == Boundary2d::Transmissive {
+ escaped -= fluxes[0].energy * areas[g] * dt;
+ }
+ if hi == Boundary2d::Transmissive {
+ escaped += fluxes[n].energy * areas[g + n] * dt;
+ }
+
+ let updated: Vec = (0..n)
+ .map(|k| {
+ let lambda = dt / volumes[g + k];
+ let mut div =
+ fluxes[k + 1].weighted_difference(areas[g + k + 1], fluxes[k], areas[g + k]);
+ if geometric {
+ // Same grouping once more, and subtracted *inside* the
+ // divergence rather than added to the result afterwards: a
+ // uniform state then gives `div.mom_r == 0.0` exactly, so
+ // `u − λ·0` is `u`, bit for bit. Adding the source as a
+ // separate term would leave `(u − y) + y`, which is not
+ // generally `u`.
+ let p_src = pressure(gas, half_cell[g + k], dir);
+ div.mom_r -= areas[g + k + 1] * p_src - areas[g + k] * p_src;
+ }
+ line[k].zip(div, |c, d| c - lambda * d)
+ })
+ .collect();
+ let stage = match dir {
+ Dir::Axial => "x-sweep conservative update",
+ Dir::Radial => "r-sweep conservative update",
+ };
+ self.assert_physical(&updated, stage, locate)?;
+ Ok((updated, escaped))
+ }
+
+ /// Sweep every ring along `x`.
+ fn sweep_x(&mut self, dt: f64) -> Result<()> {
+ // Unit areas everywhere, ghosts included: on an annular cell the axial
+ // faces and the cell volume both carry the ring area, and it cancels.
+ let areas = vec![1.0; self.n_x + 1 + 2 * N_GHOST];
+ let volumes = vec![self.dx; self.n_x + 2 * N_GHOST];
+ let mut escaped = 0.0;
+ for j in 0..self.n_r {
+ let line: Vec = self.cells[j * self.n_x..(j + 1) * self.n_x].to_vec();
+ let (updated, esc) = self.sweep_line(
+ &line,
+ dt,
+ Dir::Axial,
+ self.bc_x_min,
+ self.bc_x_max,
+ &areas,
+ &volumes,
+ false,
+ true,
+ |i| (j, i),
+ )?;
+ // The axial end faces of ring `j` carry its ring area.
+ escaped += esc * self.r_volumes[N_GHOST + j];
+ self.cells[j * self.n_x..(j + 1) * self.n_x].copy_from_slice(&updated);
+ }
+ self.escaped_energy += escaped;
+ Ok(())
+ }
+
+ /// Sweep every axial column along `r`.
+ fn sweep_r(&mut self, dt: f64, mirror_flips: bool, well_balanced: bool) -> Result<()> {
+ let geometric = well_balanced && self.geometry == Geometry::Axisymmetric;
+ let areas = self.r_areas.clone();
+ let volumes = self.r_volumes.clone();
+ let mut escaped = 0.0;
+ for i in 0..self.n_x {
+ let line: Vec = (0..self.n_r)
+ .map(|j| self.cells[j * self.n_x + i])
+ .collect();
+ let (updated, esc) = self.sweep_line(
+ &line,
+ dt,
+ Dir::Radial,
+ self.bc_r_min,
+ self.bc_r_max,
+ &areas,
+ &volumes,
+ geometric,
+ mirror_flips,
+ |j| (j, i),
+ )?;
+ // The radial faces of column `i` span its axial extent.
+ escaped += esc * self.dx;
+ for (j, &u) in updated.iter().enumerate() {
+ self.cells[j * self.n_x + i] = u;
+ }
+ }
+ self.escaped_energy += escaped;
+ Ok(())
+ }
+
+ /// Advance the homogeneous system by `dt` — Strang, radial sweep outside.
+ pub fn step(&mut self, dt: f64) -> Result<()> {
+ self.step_inner(dt, true, true)
+ }
+
+ /// [`step`](Self::step) with the axis ghost parity deliberately broken.
+ ///
+ /// Exists **only** so gate G13 has a non-vacuous contrast: without a run
+ /// that gets the parity wrong, "the axis does not heat" is a claim no
+ /// measurement has ever been able to fail. Never call it from a model.
+ pub fn step_with_even_parity_axis_for_gate_contrast(&mut self, dt: f64) -> Result<()> {
+ self.step_inner(dt, false, true)
+ }
+
+ /// [`step`](Self::step) with the geometric source **split out of the sweep**
+ /// and applied as a separate forward-Euler update.
+ ///
+ /// The deliberately worse scheme, and it exists for the same reason
+ /// `euler1d::step_with_source` does: an order gate that has never seen a
+ /// first-order run cannot tell 2nd order from a measurement too coarse to
+ /// resolve the difference. Gate G11 requires this contrast to read ≈1 while
+ /// the real scheme reads ≈2.
+ ///
+ /// It also loses well-balancedness — a radially uniform state stops being a
+ /// fixed point — which is the concrete reason the production path folds the
+ /// source into the sweep instead.
+ pub fn step_with_split_geometric_source_for_gate_contrast(&mut self, dt: f64) -> Result<()> {
+ self.step_inner(dt, true, false)
+ }
+
+ fn step_inner(&mut self, dt: f64, mirror_flips: bool, well_balanced: bool) -> Result<()> {
+ if !is_positive(dt) {
+ bail!(
+ "euler2d: non-positive step dt = {dt} at step {}",
+ self.step_count
+ );
+ }
+ let limit = self.stable_dt()?;
+ if dt > limit * (1.0 + 1e-12) {
+ bail!(
+ "euler2d: dt = {dt:.6e} s violates CFL at step {} (limit {limit:.6e} s, \
+ CFL = {}, dr = {:.6e} m, dx = {:.6e} m)",
+ self.step_count,
+ self.cfl,
+ self.dr,
+ self.dx
+ );
+ }
+ self.sweep_r(0.5 * dt, mirror_flips, well_balanced)?;
+ self.sweep_x(dt)?;
+ self.sweep_r(0.5 * dt, mirror_flips, well_balanced)?;
+ if !well_balanced && self.geometry == Geometry::Axisymmetric {
+ self.apply_split_geometric_source(dt)?;
+ }
+ self.time += dt;
+ self.step_count += 1;
+ Ok(())
+ }
+
+ /// The geometric source as a stand-alone forward-Euler update — the
+ /// contrast path only. See
+ /// [`step_with_split_geometric_source_for_gate_contrast`](Self::step_with_split_geometric_source_for_gate_contrast).
+ fn apply_split_geometric_source(&mut self, dt: f64) -> Result<()> {
+ for j in 0..self.n_r {
+ let r = self.r_centre(j);
+ for i in 0..self.n_x {
+ let k = j * self.n_x + i;
+ let p = self.cells[k].to_primitive(&self.gas).p;
+ self.cells[k].mom_r += dt * p / r;
+ }
+ }
+ let cells = self.cells.clone();
+ self.assert_physical(&cells, "split geometric source", |k| {
+ (k / self.n_x, k % self.n_x)
+ })
+ }
+
+ /// Apply a volumetric energy source `source(j, i, r, x)` in W/m³ for `dt`,
+ /// **without** the flux update — the source half of a Strang split.
+ pub fn add_energy(
+ &mut self,
+ dt: f64,
+ source: impl Fn(usize, usize, f64, f64) -> f64,
+ ) -> Result<()> {
+ if !dt.is_finite() || dt < 0.0 {
+ bail!("euler2d: non-finite or negative source dt = {dt}");
+ }
+ for j in 0..self.n_r {
+ for i in 0..self.n_x {
+ let q = source(j, i, self.r_centre(j), self.x_centre(i));
+ self.cells[j * self.n_x + i].energy += dt * q;
+ }
+ }
+ let cells = self.cells.clone();
+ self.assert_physical(&cells, "energy source", |k| (k / self.n_x, k % self.n_x))
+ }
+
+ /// Advance by `dt` with the source folded into the step.
+ ///
+ /// # This method alone is only 1st-order accurate
+ ///
+ /// Exactly as in `euler1d`: folding the source into the update is Godunov
+ /// splitting. The Strang sandwich —
+ /// `add_energy(dt/2) → step(dt) → recompute → add_energy(dt/2)` — is what
+ /// keeps the coupled scheme 2nd order, and it is what `lsd2d` does. This
+ /// method exists as the cheap contrast gate G11 needs, and for callers
+ /// where 1st order is genuinely acceptable and said so at the call site.
+ pub fn step_with_source(
+ &mut self,
+ dt: f64,
+ source: impl Fn(usize, usize, f64, f64) -> f64,
+ ) -> Result<()> {
+ self.step(dt)?;
+ self.add_energy(dt, source)
+ }
+
+ /// March to `t_end` at the stable step.
+ pub fn advance_to(&mut self, t_end: f64) -> Result<()> {
+ while self.time < t_end {
+ let dt = self.stable_dt()?.min(t_end - self.time);
+ if !is_positive(dt) {
+ break;
+ }
+ self.step(dt)?;
+ }
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const AIR: IdealGas = IdealGas { gamma: 1.4 };
+
+ fn ambient(u_r: f64, u_x: f64) -> Primitive2d {
+ Primitive2d {
+ rho: 1.2256,
+ u_r,
+ u_x,
+ p: 101_325.0,
+ }
+ }
+
+ fn uniform(geometry: Geometry, n_r: usize, n_x: usize, u_x: f64) -> Euler2d {
+ Euler2d::from_fn(
+ AIR,
+ geometry,
+ [
+ Boundary2d::Axis,
+ Boundary2d::Transmissive,
+ Boundary2d::Transmissive,
+ Boundary2d::Transmissive,
+ ],
+ 0.0,
+ 0.0,
+ 1e-4,
+ 1e-4,
+ n_r,
+ n_x,
+ |_, _| ambient(0.0, u_x),
+ )
+ .expect("uniform setup")
+ }
+
+ /// The geometric source is the exact volume average of `1/r`.
+ ///
+ /// `(A_+ − A_−)/V = 2/(r_+ + r_−) = 1/r_j` analytically, with `r_j` the
+ /// arithmetic cell centre. This is what makes the innermost ring finite
+ /// without a floor: at `r_1 = Δr/2` the value is `2/Δr`, large but not
+ /// singular.
+ #[test]
+ fn the_geometric_source_is_the_volume_average_of_one_over_r() {
+ let g = Geometry::Axisymmetric;
+ let dr = 1e-4;
+ for j in 0..8 {
+ let lo = j as f64 * dr;
+ let hi = lo + dr;
+ let ratio = (g.face_area(hi) - g.face_area(lo)) / g.cell_volume(lo, hi);
+ let centre = 0.5 * (lo + hi);
+ assert!(
+ (ratio - 1.0 / centre).abs() / (1.0 / centre) < 1e-15,
+ "ring {j}: (A+ − A−)/V = {ratio:.17e} but 1/r_j = {:.17e}",
+ 1.0 / centre
+ );
+ }
+ // And the axis interface carries no area at all, which is why no flux
+ // can cross r = 0 and why there is no 1/r in the code.
+ assert_eq!(g.face_area(0.0), 0.0);
+ }
+
+ /// **G13(i)** — a radially uniform state is a fixed point of the
+ /// axisymmetric operator.
+ ///
+ /// Well-balancedness. The geometric source and the pressure part of the
+ /// radial flux difference are written as the same floating-point
+ /// expression, so they cancel exactly and the innermost ring does not
+ /// spontaneously heat or evacuate.
+ ///
+ /// Measured: mass and both momenta are **bit-identical** after 20 steps.
+ /// Energy is not, and the reason is inherited rather than new — HLLC's star
+ /// state computes `ρ·(E/ρ)`, which is within one ulp of `E` but not equal
+ /// to it, so a uniform state carries a round-off-level radial energy flux
+ /// that the area weights fail to cancel. It does not grow: see the bound
+ /// asserted below.
+ #[test]
+ fn a_radially_uniform_state_is_a_fixed_point_of_the_axisymmetric_operator() {
+ let mut s = uniform(Geometry::Axisymmetric, 12, 8, 300.0);
+ let before = s.cells().to_vec();
+ let e0 = s.total_energy();
+ let dt = s.stable_dt().expect("dt");
+ for _ in 0..20 {
+ s.step(dt).expect("step");
+ }
+ for (k, (&a, &b)) in before.iter().zip(s.cells()).enumerate() {
+ assert_eq!(
+ (a.rho, a.mom_r, a.mom_x),
+ (b.rho, b.mom_r, b.mom_x),
+ "cell {k}: a radially uniform state moved in mass or momentum, so the \
+ geometric source is not cancelling the pressure flux exactly"
+ );
+ }
+ let drift = (s.total_energy() - e0).abs() / e0;
+ assert!(
+ drift < 1e-13,
+ "uniform-state energy drift {drift:.3e} over 20 steps is above round-off; \
+ the radial operator is doing something to a state it should not touch"
+ );
+ }
+
+ /// The planar operator leaves a uniform state alone in **every** component,
+ /// including energy: with unit areas the flux difference is `F − F`, which
+ /// is exactly zero whatever round-off `F` itself carries.
+ #[test]
+ fn a_uniform_state_is_a_bit_exact_fixed_point_in_planar_geometry() {
+ let mut s = uniform(Geometry::Planar, 12, 8, 300.0);
+ let before = s.cells().to_vec();
+ let dt = s.stable_dt().expect("dt");
+ for _ in 0..20 {
+ s.step(dt).expect("step");
+ }
+ assert_eq!(
+ before,
+ s.cells(),
+ "planar uniform state is not a fixed point"
+ );
+ }
+
+ /// Packing removes the transverse kinetic energy so the 1-D solver sees the
+ /// true pressure, and unpacking restores the state.
+ #[test]
+ fn pack_removes_transverse_kinetic_energy_and_round_trips() {
+ let u = ambient(120.0, -450.0).to_conserved(&AIR);
+ for dir in [Dir::Axial, Dir::Radial] {
+ let (packed, v_t) = pack(u, dir);
+ let p_2d = u.to_primitive(&AIR).p;
+ let p_1d = packed.to_primitive(&AIR).p;
+ assert!(
+ (p_1d - p_2d).abs() / p_2d < 1e-14,
+ "{dir:?}: packed pressure {p_1d:.6e} != 2-D pressure {p_2d:.6e}"
+ );
+ let restored = packed.energy + 0.5 * u.rho * v_t * v_t;
+ assert!((restored - u.energy).abs() / u.energy < 1e-14);
+ }
+ }
+
+ /// The axis fill mirrors and flips the radial momentum. Odd parity is the
+ /// whole axis condition.
+ #[test]
+ fn the_axis_ghost_fill_flips_radial_momentum() {
+ let s = uniform(Geometry::Axisymmetric, 8, 8, 0.0);
+ let line: Vec = (0..8)
+ .map(|j| {
+ Primitive2d {
+ rho: 1.0 + j as f64,
+ u_r: 10.0 + j as f64,
+ u_x: 3.0,
+ p: 1e5,
+ }
+ .to_conserved(&AIR)
+ })
+ .collect();
+ let padded = s.pad_line(
+ &line,
+ Dir::Radial,
+ Boundary2d::Axis,
+ Boundary2d::Transmissive,
+ true,
+ );
+ // Ghost N_GHOST-1 mirrors interior 0, ghost N_GHOST-2 mirrors interior 1.
+ for g in 0..N_GHOST {
+ let ghost = padded[N_GHOST - 1 - g];
+ assert_eq!(ghost.rho, line[g].rho);
+ assert_eq!(ghost.mom_r, -line[g].mom_r);
+ assert_eq!(ghost.mom_x, line[g].mom_x);
+ }
+ // And the contrast the gate needs: with the flip disabled the radial
+ // momentum is mirrored *even*, which is the classic wall-heating bug.
+ let broken = s.pad_line(
+ &line,
+ Dir::Radial,
+ Boundary2d::Axis,
+ Boundary2d::Transmissive,
+ false,
+ );
+ assert_eq!(broken[N_GHOST - 1].mom_r, line[0].mom_r);
+ }
+
+ #[test]
+ fn cfl_above_the_cap_is_refused() {
+ let mut s = uniform(Geometry::Planar, 8, 8, 0.0);
+ assert!(s.set_cfl(0.9).is_err());
+ assert!(s.set_cfl(0.0).is_err());
+ assert!(s.set_cfl(0.4).is_ok());
+ }
+
+ #[test]
+ fn a_step_beyond_the_cfl_limit_is_refused() {
+ let mut s = uniform(Geometry::Planar, 8, 8, 0.0);
+ let dt = s.stable_dt().expect("dt");
+ let err = s.step(dt * 2.0).expect_err("must refuse");
+ assert!(format!("{err}").contains("violates CFL"), "got: {err}");
+ }
+
+ #[test]
+ fn a_non_physical_initial_state_is_refused() {
+ let bad = Euler2d::from_fn(
+ AIR,
+ Geometry::Planar,
+ [Boundary2d::Transmissive; 4],
+ 0.0,
+ 0.0,
+ 1e-3,
+ 1e-3,
+ 8,
+ 8,
+ |_, _| Primitive2d {
+ rho: -1.0,
+ u_r: 0.0,
+ u_x: 0.0,
+ p: 1e5,
+ },
+ );
+ assert!(bad.is_err());
+ }
+
+ /// An axisymmetric domain that reaches `r = 0` must say so. Silently
+ /// treating the axis as an outflow would leak mass through a face of zero
+ /// area and is exactly the kind of thing that produces a plausible wrong
+ /// answer.
+ #[test]
+ fn an_axisymmetric_domain_at_the_axis_requires_the_axis_boundary() {
+ let bad = Euler2d::from_fn(
+ AIR,
+ Geometry::Axisymmetric,
+ [Boundary2d::Transmissive; 4],
+ 0.0,
+ 0.0,
+ 1e-3,
+ 1e-3,
+ 8,
+ 8,
+ |_, _| ambient(0.0, 0.0),
+ );
+ assert!(
+ bad.is_err(),
+ "the axis boundary requirement is not enforced"
+ );
+ }
+
+ /// Mass and axial momentum are conserved in the `r dr dx` measure; radial
+ /// momentum is not, and must not be.
+ ///
+ /// Measured, closed box, 30 steps: mass 3.30e-16, energy 1.36e-16 relative,
+ /// radial momentum 2.76e-7 (i.e. not zero, which is the point).
+ ///
+ /// **This test earned its place by failing.** With ghost cells borrowing the
+ /// nearest interior cell's areas — the obvious implementation — it read
+ /// 3.3e-4 and growing, because a reflective wall is only exact when the
+ /// ghost's *metric* is the mirror of the interior cell's too. See
+ /// [`ghost_face_radius`].
+ #[test]
+ fn the_geometric_source_breaks_radial_momentum_conservation_only() {
+ let mut s = Euler2d::from_fn(
+ AIR,
+ Geometry::Axisymmetric,
+ [
+ Boundary2d::Axis,
+ Boundary2d::Reflective,
+ Boundary2d::Reflective,
+ Boundary2d::Reflective,
+ ],
+ 0.0,
+ 0.0,
+ 1e-4,
+ 1e-4,
+ 16,
+ 16,
+ |r, _| {
+ let hot = r < 4e-4;
+ Primitive2d {
+ rho: 1.2256,
+ u_r: 0.0,
+ u_x: 0.0,
+ p: if hot { 1e6 } else { 1e5 },
+ }
+ },
+ )
+ .expect("blob setup");
+ let (m0, e0, px0) = (s.total_mass(), s.total_energy(), s.total_axial_momentum());
+ let dt = s.stable_dt().expect("dt");
+ for _ in 0..30 {
+ let dt = s.stable_dt().expect("dt").min(dt);
+ s.step(dt).expect("step");
+ }
+ assert!(
+ (s.total_mass() - m0).abs() / m0 < 1e-13,
+ "mass not conserved"
+ );
+ assert!(
+ (s.total_energy() - e0).abs() / e0 < 1e-13,
+ "energy not conserved"
+ );
+ assert!(
+ (s.total_axial_momentum() - px0).abs() < 1e-9 * m0,
+ "axial momentum not conserved"
+ );
+ assert!(
+ s.total_radial_momentum().abs() > 0.0,
+ "radial momentum is conserved, which means the geometric source is missing"
+ );
+ }
+}
diff --git a/src/lib.rs b/src/lib.rs
index 3077475..33eb4d3 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -42,9 +42,11 @@ pub mod blooming;
pub mod breakdown0d;
pub mod cases;
pub mod euler1d;
+pub mod euler2d;
pub mod field;
pub mod grid;
pub mod lsd;
+pub mod lsd2d;
pub mod medium;
pub mod montecarlo;
pub mod plasmaprops;
diff --git a/src/lsd2d.rs b/src/lsd2d.rs
new file mode 100644
index 0000000..2b881fe
--- /dev/null
+++ b/src/lsd2d.rs
@@ -0,0 +1,609 @@
+//! Axisymmetric laser-supported detonation: a **finite-diameter** beam, so the
+//! shocked gas can relieve sideways (M6d, step 5).
+//!
+//! This is `lsd.rs` with the one assumption M6c could not drop. There the beam
+//! is infinitely wide by construction — a planar slab has no transverse
+//! direction — and `docs/M6C_SPEC.md` § G7 rests its whole case on that:
+//! "a planar 1-D code has **no radial relief** […] it is the one effect the
+//! geometry has removed by assumption". Here the beam has a radius `R_b`, the
+//! gas can escape across it, and the front slows down. How much is the
+//! milestone's headline measurement.
+//!
+//! # What is reused rather than rebuilt
+//!
+//! [`Absorption`] (including the `GreyThreshold` verification closure and M6c's
+//! reasoning for it), [`IonizationCeiling`], [`raizer_lsd_velocity`], the Strang
+//! cadence, and the discretely conservative deposition
+//! `q_k = (I_k − I_{k+1})/Δx` that lets the energy budget close to round-off
+//! instead of to a quadrature tolerance. The absorption closures are functions
+//! of `(ρ, p)` alone, so they carry over to two dimensions untouched.
+//!
+//! # The beam is a bundle of independent pencils
+//!
+//! One Beer–Lambert march per ring, no refraction and no diffraction
+//! (`docs/M6D_SPEC.md` gate decision 5). Because each ring's march is
+//! separately conservative, the `r`-weighted budget still closes exactly.
+//!
+//! Making the beam bend in the radially structured plasma would turn the
+//! coupling two-way, which `docs/M6C_SPEC.md` open question 3 already reserves
+//! for a later milestone with its own gate. It is stated here, not smuggled in.
+//!
+//! # Relief comes from the beam being finite, not the domain
+//!
+//! With the outer boundary well outside `R_b` the lateral rarefaction is
+//! entirely interior, so the budget closes with no boundary-flux accounting at
+//! all and [`Lsd2dColumn::check_regime`] refuses the configurations where that
+//! stops being true. When relief *does* reach the wall — the long demonstration
+//! runs — [`Euler2d::escaped_energy`] accounts for it.
+
+use anyhow::{Result, bail};
+
+use crate::euler1d::{IdealGas, Primitive};
+use crate::euler2d::{Boundary2d, Conserved2d, Euler2d, Geometry, Primitive2d};
+use crate::lsd::Absorption;
+
+/// Minimum cells across the beam radius.
+///
+/// Below this the deposition profile is a staircase and the relief deficit
+/// would be a mesh artefact rather than a measurement — the number G15 exists
+/// to pin is exactly the one this protects.
+pub const MIN_CELLS_PER_BEAM_RADIUS: f64 = 8.0;
+
+/// Minimum domain radius, in beam radii.
+///
+/// Relief has to come from the beam being finite. If the outer wall is close
+/// enough to participate, the measured deficit is partly the boundary's.
+pub const MIN_DOMAIN_RADII: f64 = 3.0;
+
+/// Radial shape of the incident beam.
+#[derive(Debug, Clone, Copy)]
+pub enum BeamProfile {
+ /// Uniform out to `radius`, zero beyond.
+ ///
+ /// The gates use this: `R_b` is then unambiguous and the diameter effect
+ /// reads cleanly against it. Place `R_b` on a cell face — `check_regime`
+ /// does not enforce that, but a top-hat edge cutting a cell in half is a
+ /// good way to give `δ` a spurious grid dependence.
+ TopHat {
+ /// Beam radius (m).
+ radius: f64,
+ },
+ /// `exp(−(r/radius)^(2·order))` — a soft-edged beam for the demonstration
+ /// run, where the profile is a picture rather than a measurement.
+ SuperGaussian {
+ /// `1/e` radius (m).
+ radius: f64,
+ /// Super-Gaussian order; 1 is an ordinary Gaussian.
+ order: u32,
+ },
+}
+
+impl BeamProfile {
+ /// Fraction of the peak incident intensity at radius `r`.
+ pub fn weight(&self, r: f64) -> f64 {
+ match *self {
+ Self::TopHat { radius } => {
+ if r <= radius {
+ 1.0
+ } else {
+ 0.0
+ }
+ }
+ Self::SuperGaussian { radius, order } => (-(r / radius).powi(2 * order as i32)).exp(),
+ }
+ }
+
+ /// Nominal beam radius (m).
+ pub fn radius(&self) -> f64 {
+ match *self {
+ Self::TopHat { radius } | Self::SuperGaussian { radius, .. } => radius,
+ }
+ }
+}
+
+/// Where and how hard to light the initial spark.
+///
+/// M6c's [`SeededIgnition`](crate::lsd::SeededIgnition) with a radius. That
+/// radius is a **second free parameter beside the pressure**, so M6c's
+/// seed-independence discipline (G3c) has to cover it too — otherwise the
+/// headline deficit sits on an unexamined knob.
+#[derive(Debug, Clone, Copy)]
+pub struct SeededIgnition2d {
+ /// Axial centre of the hot spot (m).
+ pub centre_x: f64,
+ /// Full axial width of the hot spot (m).
+ pub width_x: f64,
+ /// Radius of the hot spot (m).
+ pub radius: f64,
+ /// Pressure inside it (Pa). Density stays ambient, so this is a pure energy
+ /// deposit — the numerical analogue of a spark.
+ pub pressure: f64,
+}
+
+/// An axisymmetric laser-supported detonation: gas dynamics, a finite-diameter
+/// beam, and the deposition that couples them.
+pub struct Lsd2dColumn {
+ hydro: Euler2d,
+ absorption: Absorption,
+ /// Peak incident intensity at `x = 0` (W/m²).
+ incident: f64,
+ beam: BeamProfile,
+ ambient: Primitive2d,
+ deposited: f64,
+ initial_energy: f64,
+}
+
+impl Lsd2dColumn {
+ /// Build a column of `n_r × n_x` cells over `[0, radius] × [0, length]`,
+ /// filled with `ambient`, with a seeded hot spot, driven by a beam of peak
+ /// intensity `incident` (W/m²) entering at `x = 0` and travelling `+x`.
+ ///
+ /// The front then runs toward the laser, i.e. toward **decreasing** `x`, as
+ /// in M6c.
+ #[allow(clippy::too_many_arguments)]
+ pub fn seeded(
+ gas: IdealGas,
+ n_r: usize,
+ n_x: usize,
+ radius: f64,
+ length: f64,
+ ambient: Primitive2d,
+ ignition: SeededIgnition2d,
+ absorption: Absorption,
+ incident: f64,
+ beam: BeamProfile,
+ ) -> Result {
+ Self::seeded_with_geometry(
+ gas,
+ Geometry::Axisymmetric,
+ n_r,
+ n_x,
+ radius,
+ length,
+ ambient,
+ ignition,
+ absorption,
+ incident,
+ beam,
+ )
+ }
+
+ /// [`seeded`](Self::seeded) with the geometry chosen explicitly.
+ ///
+ /// `Geometry::Planar` is the **control**, not a physical model: it is the
+ /// same coupled driver with the geometric source switched off, which is what
+ /// separates "the wave is transversely unstable" from "the axisymmetric
+ /// terms are injecting noise". Gate G14 uses it for exactly that.
+ #[allow(clippy::too_many_arguments)]
+ pub fn seeded_with_geometry(
+ gas: IdealGas,
+ geometry: Geometry,
+ n_r: usize,
+ n_x: usize,
+ radius: f64,
+ length: f64,
+ ambient: Primitive2d,
+ ignition: SeededIgnition2d,
+ absorption: Absorption,
+ incident: f64,
+ beam: BeamProfile,
+ ) -> Result {
+ if !(incident > 0.0 && incident.is_finite()) {
+ bail!("lsd2d: incident intensity must be positive and finite, got {incident}");
+ }
+ if !(length > 0.0 && length.is_finite() && radius > 0.0 && radius.is_finite()) {
+ bail!("lsd2d: domain must be positive and finite, got {radius} m x {length} m");
+ }
+ if !(ignition.width_x > 0.0
+ && ignition.width_x.is_finite()
+ && ignition.radius > 0.0
+ && ignition.radius.is_finite())
+ || ignition.pressure <= ambient.p
+ {
+ bail!(
+ "lsd2d: the seed must be a hot spot — {} m x {} m, pressure {} Pa vs \
+ ambient {} Pa",
+ ignition.radius,
+ ignition.width_x,
+ ignition.pressure,
+ ambient.p
+ );
+ }
+ let half = 0.5 * ignition.width_x;
+ let hydro = Euler2d::from_fn(
+ gas,
+ geometry,
+ [
+ Boundary2d::Axis,
+ Boundary2d::Transmissive,
+ Boundary2d::Transmissive,
+ Boundary2d::Transmissive,
+ ],
+ 0.0,
+ 0.0,
+ radius / n_r as f64,
+ length / n_x as f64,
+ n_r,
+ n_x,
+ |r, x| {
+ if (x - ignition.centre_x).abs() <= half && r <= ignition.radius {
+ Primitive2d {
+ p: ignition.pressure,
+ ..ambient
+ }
+ } else {
+ ambient
+ }
+ },
+ )?;
+ let initial_energy = hydro.total_energy();
+ Ok(Self {
+ hydro,
+ absorption,
+ incident,
+ beam,
+ ambient,
+ deposited: 0.0,
+ initial_energy,
+ })
+ }
+
+ /// The gas dynamics, read-only.
+ pub fn hydro(&self) -> &Euler2d {
+ &self.hydro
+ }
+
+ /// The gas dynamics, mutably — for the step controller and the CLI case.
+ pub fn hydro_mut(&mut self) -> &mut Euler2d {
+ &mut self.hydro
+ }
+
+ /// The beam's radial profile.
+ pub fn beam(&self) -> BeamProfile {
+ self.beam
+ }
+
+ /// Peak incident intensity at `x = 0` (W/m²).
+ pub fn incident_intensity(&self) -> f64 {
+ self.incident
+ }
+
+ /// The 1-D state an absorption closure sees.
+ ///
+ /// Every closure in [`Absorption`] is a function of `(ρ, p)` alone — the
+ /// grey threshold keys on specific internal energy, inverse bremsstrahlung
+ /// on the table's inversion of `(ρ, p)` — so the velocity component is
+ /// irrelevant and is passed as zero rather than invented.
+ fn as_1d(w: Primitive2d) -> Primitive {
+ Primitive {
+ rho: w.rho,
+ u: 0.0,
+ p: w.p,
+ }
+ }
+
+ /// Absorption coefficient per cell (1/m), row-major.
+ pub fn alpha_profile(&self) -> Result> {
+ let gas = self.hydro.gas();
+ self.hydro
+ .primitives()
+ .into_iter()
+ .map(|w| self.absorption.coefficient(&gas, Self::as_1d(w)))
+ .collect()
+ }
+
+ /// Absorbed power density per cell (W/m³), row-major.
+ ///
+ /// One independent Beer–Lambert march per ring, starting from
+ /// `S·b(r_j)`. `q = (I_k − I_{k+1})/Δx` per cell, so each ring's deposition
+ /// sums exactly to the intensity that ring's pencil lost.
+ pub fn deposition(&self) -> Result> {
+ let alpha = self.alpha_profile()?;
+ let (n_r, n_x, dx) = (self.hydro.n_r(), self.hydro.n_x(), self.hydro.dx());
+ let mut q = vec![0.0; n_r * n_x];
+ for j in 0..n_r {
+ let mut i_beam = self.incident * self.beam.weight(self.hydro.r_centre(j));
+ for i in 0..n_x {
+ let k = j * n_x + i;
+ let next = i_beam * (-alpha[k] * dx).exp();
+ q[k] = (i_beam - next) / dx;
+ i_beam = next;
+ }
+ }
+ Ok(q)
+ }
+
+ /// Beam intensity at each cell's **upstream face** (W/m²), row-major — what
+ /// the renderer draws as "the beam being eaten", per ring.
+ pub fn intensity_profile(&self) -> Result> {
+ let alpha = self.alpha_profile()?;
+ let (n_r, n_x, dx) = (self.hydro.n_r(), self.hydro.n_x(), self.hydro.dx());
+ let mut out = vec![0.0; n_r * n_x];
+ for j in 0..n_r {
+ let mut i_beam = self.incident * self.beam.weight(self.hydro.r_centre(j));
+ for i in 0..n_x {
+ out[j * n_x + i] = i_beam;
+ i_beam *= (-alpha[j * n_x + i] * dx).exp();
+ }
+ }
+ Ok(out)
+ }
+
+ /// Ring volume in the `r dr` measure, matching the hydro's own metric.
+ fn ring_volume(&self, j: usize) -> f64 {
+ let dr = self.hydro.dr();
+ let lo = j as f64 * dr;
+ 0.5 * ((lo + dr) * (lo + dr) - lo * lo)
+ }
+
+ /// Deposition integrated over the domain in the `r dr dx` measure (W).
+ fn deposition_rate(&self, q: &[f64]) -> f64 {
+ let (n_r, n_x, dx) = (self.hydro.n_r(), self.hydro.n_x(), self.hydro.dx());
+ (0..n_r)
+ .map(|j| {
+ let vol = self.ring_volume(j) * dx;
+ (0..n_x).map(|i| q[j * n_x + i]).sum::() * vol
+ })
+ .sum()
+ }
+
+ /// Stable step for the **coupled** system (s).
+ ///
+ /// M6c's predictor, unchanged in substance: the Strang sandwich deposits
+ /// energy *before* the flux update, so the leading half-step raises `p`,
+ /// raises `c`, and shrinks the true CFL limit below what the pre-deposition
+ /// state advertises. Handing the hydro that larger step is refused outright
+ /// by its guard, which is the guard working.
+ pub fn stable_dt(&self) -> Result {
+ let q = self.deposition()?;
+ let gas = self.hydro.gas();
+ let w = self.hydro.primitives();
+ let (dr, dx, cfl) = (self.hydro.dr(), self.hydro.dx(), self.hydro.cfl());
+ let mut dt = self.hydro.stable_dt()?;
+ for _ in 0..8 {
+ let rate = w
+ .iter()
+ .zip(&q)
+ .map(|(c, &qi)| {
+ let p = c.p + (gas.gamma - 1.0) * qi * 0.5 * dt;
+ let sound = gas.sound_speed(c.rho, p.max(c.p));
+ ((c.u_r.abs() + sound) / dr).max((c.u_x.abs() + sound) / dx)
+ })
+ .fold(0.0, f64::max);
+ let next = cfl / rate;
+ let converged = (next - dt).abs() <= 1e-10 * dt;
+ dt = next;
+ if converged {
+ break;
+ }
+ }
+ if !(dt > 0.0 && dt.is_finite()) {
+ bail!(
+ "lsd2d: no stable coupled step at t = {:.4e} s (got dt = {dt})",
+ self.hydro.time()
+ );
+ }
+ Ok(dt * (1.0 - 1e-9))
+ }
+
+ /// Advance one coupled step of `dt` with Strang splitting.
+ pub fn advance(&mut self, dt: f64) -> Result<()> {
+ self.deposit(0.5 * dt)?;
+ self.hydro.step(dt)?;
+ self.deposit(0.5 * dt)?;
+ Ok(())
+ }
+
+ /// One source half-step: deposit for `dt` and bank the energy.
+ fn deposit(&mut self, dt: f64) -> Result<()> {
+ let q = self.deposition()?;
+ let n_x = self.hydro.n_x();
+ self.hydro.add_energy(dt, |j, i, _, _| q[j * n_x + i])?;
+ self.deposited += dt * self.deposition_rate(&q);
+ Ok(())
+ }
+
+ /// Run to `t_end` (s), sizing each step from the current wave speeds.
+ pub fn advance_to(&mut self, t_end: f64) -> Result<()> {
+ while self.hydro.time() < t_end {
+ let dt = self.stable_dt()?.min(t_end - self.hydro.time());
+ if dt <= 0.0 || !dt.is_finite() {
+ break;
+ }
+ self.advance(dt)?;
+ }
+ Ok(())
+ }
+
+ /// Position of the front on ring `j` (m): the laser-side edge of the
+ /// pressure rise at half maximum, linearly interpolated.
+ ///
+ /// Same construction as M6c's, so the on-axis number is directly comparable
+ /// to the 1-D one.
+ pub fn front_position_at(&self, j: usize) -> Option {
+ let n_x = self.hydro.n_x();
+ let p = |i: usize| self.hydro.cell(j, i).to_primitive(&self.hydro.gas()).p;
+ let p_max = (0..n_x).map(p).fold(f64::NEG_INFINITY, f64::max);
+ if p_max < 2.0 * self.ambient.p {
+ return None;
+ }
+ let level = self.ambient.p + 0.5 * (p_max - self.ambient.p);
+ let i = (0..n_x).find(|&i| p(i) >= level)?;
+ if i == 0 {
+ return Some(self.hydro.x_centre(0));
+ }
+ let f = (level - p(i - 1)) / (p(i) - p(i - 1));
+ Some(self.hydro.x_centre(i - 1) + f * self.hydro.dx())
+ }
+
+ /// Position of the front on the axis (m) — the number `D_2D` is measured
+ /// from, and the one directly comparable to M6c.
+ pub fn front_position(&self) -> Option {
+ self.front_position_at(0)
+ }
+
+ /// How far the front at the beam edge lags the front on the axis (m).
+ ///
+ /// Positive when the axis leads, which is what relief produces: the edge of
+ /// the beam is where gas escapes sideways, so it is driven least. The
+ /// curvature *is* relief made visible, and it is a diagnostic and a figure
+ /// rather than a gate.
+ pub fn front_lag_at_beam_edge(&self) -> Option {
+ let edge = self.beam.radius();
+ let j = ((edge / self.hydro.dr()).floor() as usize).min(self.hydro.n_r() - 1);
+ Some(self.front_position_at(j)? - self.front_position()?)
+ }
+
+ /// Advance by `span` (s) and return the mean on-axis front speed over it
+ /// (m/s), **positive toward the laser**.
+ pub fn measure_front_speed(&mut self, span: f64) -> Result {
+ let Some(x0) = self.front_position() else {
+ bail!(
+ "lsd2d: no front to track at t = {:.4e} s (peak pressure has not reached \
+ twice ambient)",
+ self.hydro.time()
+ );
+ };
+ let t0 = self.hydro.time();
+ self.advance_to(t0 + span)?;
+ let Some(x1) = self.front_position() else {
+ bail!("lsd2d: the front vanished during the {span:.4e} s measurement window");
+ };
+ Ok((x0 - x1) / (self.hydro.time() - t0))
+ }
+
+ /// Energy the beam has deposited so far, in the `r dr dx` measure (J/rad).
+ pub fn deposited_energy(&self) -> f64 {
+ self.deposited
+ }
+
+ /// Relative closure of the energy budget:
+ /// `|(E_now + escaped − E_0) − deposited| / deposited`.
+ ///
+ /// The escape term is what lets this close on a run where relief actually
+ /// reaches the wall, rather than only on the runs where nothing happens.
+ pub fn energy_residual(&self) -> f64 {
+ if self.deposited <= 0.0 {
+ return 0.0;
+ }
+ let gained = self.hydro.total_energy() + self.hydro.escaped_energy() - self.initial_energy;
+ (gained - self.deposited).abs() / self.deposited
+ }
+
+ /// Whether the **laser-side** end plane is still undisturbed.
+ ///
+ /// Only `x_min` is checked, and that is the physically meaningful choice
+ /// rather than a relaxation. The front runs toward the laser, so `x_min` is
+ /// the plane it approaches and the one whose disturbance would contaminate
+ /// the speed. The downstream plane `x_max` is where the seed's own blast
+ /// leaves, and it always does: a spark radiates both ways, and the outward
+ /// half has no reason to stay in the domain. Reporting that as
+ /// contamination would be crying wolf on every run.
+ pub fn axial_boundaries_undisturbed(&self) -> bool {
+ (0..self.hydro.n_r()).all(|j| !self.disturbed(self.hydro.cell(j, 0)))
+ }
+
+ /// Whether the downstream plane is still undisturbed — information, not a
+ /// validity condition. See [`axial_boundaries_undisturbed`](Self::axial_boundaries_undisturbed).
+ pub fn downstream_undisturbed(&self) -> bool {
+ let n_x = self.hydro.n_x();
+ (0..self.hydro.n_r()).all(|j| !self.disturbed(self.hydro.cell(j, n_x - 1)))
+ }
+
+ /// Whether the outermost ring is still undisturbed.
+ ///
+ /// **Not a validity condition on its own**, and saying so is the point. A
+ /// radially uniform seed — which is what these runs use, so that no radial
+ /// structure is present before the beam creates it — disturbs the rim at
+ /// `t = 0` by construction. What matters is whether the *wall* is doing the
+ /// relief, and that is answered by widening the domain: measured, the
+ /// deficit is 21.1 % / 21.3 % / 21.3 % at domain radii of 3, 5 and 8 beam
+ /// radii, i.e. converged by five and not a boundary effect.
+ pub fn rim_undisturbed(&self) -> bool {
+ let n_r = self.hydro.n_r();
+ (0..self.hydro.n_x()).all(|i| !self.disturbed(self.hydro.cell(n_r - 1, i)))
+ }
+
+ fn disturbed(&self, c: Conserved2d) -> bool {
+ let tol = 1e-6;
+ let w = c.to_primitive(&self.hydro.gas());
+ (w.p - self.ambient.p).abs() > tol * self.ambient.p
+ || (w.rho - self.ambient.rho).abs() > tol * self.ambient.rho
+ }
+
+ /// Both of the above.
+ pub fn boundaries_undisturbed(&self) -> bool {
+ self.axial_boundaries_undisturbed()
+ && self.rim_undisturbed()
+ && self.downstream_undisturbed()
+ }
+
+ /// Refuse, don't mis-model — M6c's `check_regime` plus the three ways an
+ /// axisymmetric run can produce a plausible wrong deficit.
+ pub fn check_regime(&self) -> Result<()> {
+ let (dr, dx) = (self.hydro.dr(), self.hydro.dx());
+ let length = dx * self.hydro.n_x() as f64;
+ let domain_radius = dr * self.hydro.n_r() as f64;
+ let alpha_max = self.alpha_profile()?.into_iter().fold(0.0, f64::max);
+ if alpha_max <= 0.0 {
+ bail!(
+ "lsd2d: nothing absorbs at t = {:.4e} s — there is no front, and the \
+ detonation model has nothing to describe",
+ self.hydro.time()
+ );
+ }
+ let absorption_length = 1.0 / alpha_max;
+ let cells = absorption_length / dx;
+ if cells < 5.0 {
+ bail!(
+ "lsd2d: the absorption length 1/α = {absorption_length:.4e} m spans only \
+ {cells:.2} cells (dx = {dx:.4e} m); the deposition is unresolved"
+ );
+ }
+ if absorption_length > 0.1 * length {
+ bail!(
+ "lsd2d: the absorption length is {:.0}% of the {length:.4e} m domain; the \
+ deposition is volumetric rather than a front, which is the LSC regime \
+ and out of scope",
+ 100.0 * absorption_length / length
+ );
+ }
+
+ // The three that are new in two dimensions.
+ let r_b = self.beam.radius();
+ let beam_cells = r_b / dr;
+ if beam_cells < MIN_CELLS_PER_BEAM_RADIUS {
+ bail!(
+ "lsd2d: the beam radius {r_b:.4e} m spans only {beam_cells:.2} cells \
+ (dr = {dr:.4e} m), below the {MIN_CELLS_PER_BEAM_RADIUS} needed. The \
+ deposition profile is a staircase, and the relief deficit measured from \
+ it would be a property of the mesh"
+ );
+ }
+ if domain_radius < MIN_DOMAIN_RADII * r_b {
+ bail!(
+ "lsd2d: the domain radius {domain_radius:.4e} m is under {MIN_DOMAIN_RADII}x \
+ the beam radius {r_b:.4e} m; relief must come from the beam being finite, \
+ not from the wall"
+ );
+ }
+ if self.front_position().is_none() {
+ bail!(
+ "lsd2d: no front on the axis at t = {:.4e} s",
+ self.hydro.time()
+ );
+ }
+ // A curved front can leave through x_min on the axis while the edge is
+ // still inside, and the on-axis measurement would silently be reading
+ // the boundary.
+ if let Some(x) = self.front_position()
+ && x <= 2.0 * dx
+ {
+ bail!(
+ "lsd2d: the on-axis front has reached x = {x:.4e} m, within two cells of \
+ the laser-side boundary; the measurement is reading the boundary"
+ );
+ }
+ Ok(())
+ }
+}
diff --git a/src/main.rs b/src/main.rs
index 301a1c3..be33cce 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -12,9 +12,9 @@ use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use beamprop::cases::{
- BloomingParams, BreakdownParams, IgnitionParams, IgnitionSweepParams, LsdParams,
+ BloomingParams, BreakdownParams, IgnitionParams, IgnitionSweepParams, Lsd2dParams, LsdParams,
PropagateParams, TurbulenceParams, run_blooming, run_breakdown, run_ignition_sweep, run_lsd,
- run_propagate, run_turbulence,
+ run_lsd2d, run_propagate, run_turbulence,
};
use beamprop::field::Field;
use beamprop::grid::Grid;
@@ -204,6 +204,57 @@ enum Cmd {
/// _trajectory.csv (the front track and the column's optical depth),
/// and _meta.json/_notes.md. Render with `python3 scripts/render_lsd.py`.
/// 1-D gas dynamics: the beam arguments do not apply.
+ /// Axisymmetric LSD wave (M6d): the same detonation driven by a
+ /// **finite-diameter** beam, so the shocked gas can relieve sideways.
+ ///
+ /// Reports how much that slows the front against the wide-beam limit —
+ /// the effect M6c's planar geometry removed by assumption.
+ Lsd2d {
+ /// Sustaining drive intensity on the beam axis in W/m^2.
+ #[arg(long, default_value_t = 1e11)]
+ drive: f64,
+ /// Beam radius in metres. The default 1.6e-4 puts R_b*alpha at 3.2,
+ /// where the relief deficit is ~0.23.
+ #[arg(long, default_value_t = 1.6e-4)]
+ beam_radius: f64,
+ /// Super-Gaussian order; 0 selects a top-hat.
+ #[arg(long, default_value_t = 0)]
+ beam_order: u32,
+ /// Ambient pressure in pascals.
+ #[arg(long, default_value_t = 101_325.0)]
+ p0: f64,
+ /// Ambient temperature in kelvin.
+ #[arg(long, default_value_t = 288.0)]
+ t0: f64,
+ /// Column length in metres.
+ #[arg(long, default_value_t = 5e-3)]
+ length: f64,
+ /// Domain radius, in beam radii. Must be at least 3 so relief comes
+ /// from the beam being finite rather than from the wall.
+ #[arg(long, default_value_t = 3.0)]
+ domain_radii: f64,
+ /// Hydro cells along the beam.
+ #[arg(long, default_value_t = 500)]
+ cells_x: usize,
+ /// Rings across the domain radius.
+ #[arg(long, default_value_t = 48)]
+ cells_r: usize,
+ /// Grey-plasma absorption coefficient in 1/m.
+ #[arg(long, default_value_t = 2e4)]
+ alpha: f64,
+ /// Fraction of the column the front is asked to cross.
+ #[arg(long, default_value_t = 0.4)]
+ cross: f64,
+ /// Number of recorded snapshots (= animation frames).
+ #[arg(long, default_value_t = 32)]
+ frames: usize,
+ /// Output basename (within --out-dir).
+ #[arg(long, default_value = "lsd2d")]
+ out: String,
+ /// Directory for all generated files; created if missing.
+ #[arg(long, default_value = "out")]
+ out_dir: PathBuf,
+ },
Lsd {
/// Vacuum wavelength in metres.
#[arg(long, default_value_t = 1064e-9)]
@@ -386,6 +437,39 @@ fn main() -> Result<()> {
} => breakdown(
wavelength, fwhm, p_min, p_max, points, steps, drive, &out, &out_dir,
),
+ Cmd::Lsd2d {
+ drive,
+ beam_radius,
+ beam_order,
+ p0,
+ t0,
+ length,
+ domain_radii,
+ cells_x,
+ cells_r,
+ alpha,
+ cross,
+ frames,
+ out,
+ out_dir,
+ } => lsd2d(
+ &Lsd2dParams {
+ drive,
+ beam_radius,
+ beam_order,
+ p0,
+ t0,
+ length,
+ domain_radii,
+ cells_x,
+ cells_r,
+ alpha,
+ cross_fraction: cross,
+ frames,
+ },
+ &out,
+ &out_dir,
+ ),
Cmd::Lsd {
wavelength,
fwhm,
@@ -1627,3 +1711,164 @@ fn propagate(
);
Ok(())
}
+
+/// Write the `lsd2d` case: data only, images from `scripts/render_lsd2d.py`.
+fn lsd2d(p: &Lsd2dParams, out: &str, out_dir: &Path) -> Result<()> {
+ let run = run_lsd2d(p)?;
+
+ fs::create_dir_all(out_dir)
+ .with_context(|| format!("creating output directory {}", out_dir.display()))?;
+ let path = |name: &str| out_dir.join(name);
+
+ println!(
+ "lsd2d: {:.3e} W/m² through a {:.1} µm beam ({}), R_b·α = {:.2}; ρ₀ = {:.4} kg/m³",
+ p.drive,
+ p.beam_radius * 1e6,
+ if p.beam_order == 0 {
+ "top-hat".to_string()
+ } else {
+ format!("super-Gaussian order {}", p.beam_order)
+ },
+ p.beam_radius * p.alpha,
+ run.rho_0
+ );
+ println!(
+ " wide-beam limit D = {:.1} m/s vs Raizer {:.1} m/s ({:+.2} %)",
+ run.d_wide,
+ run.d_raizer,
+ 100.0 * (run.d_wide / run.d_raizer - 1.0)
+ );
+ println!(
+ " finite beam D = {:.1} m/s → radial relief costs {:.1} % of the front speed",
+ run.d_measured,
+ 100.0 * run.relief_deficit
+ );
+ println!(
+ " the front is curved: the axis leads the beam edge by {:.2e} m at the last frame",
+ run.front_x_edge[run.front_x_edge.len() - 1] - run.front_x_axis[run.front_x_axis.len() - 1]
+ );
+ println!(
+ " deposited {:.4e} J/rad, escaped {:.4e}; budget closes to {:.2e}",
+ run.deposited_energy, run.escaped_energy, run.energy_residual
+ );
+ if !run.boundaries_undisturbed {
+ println!(" WARNING: the wave reached an axial boundary; the front speed is contaminated.");
+ }
+ if !run.rim_undisturbed {
+ println!(
+ " the outer rim is disturbed — expected, since the seed is radially uniform, \
+ and the escape term above accounts for what leaves. The deficit is \
+ insensitive to the domain radius (21.1 / 21.3 / 21.3 % at 3 / 5 / 8 beam \
+ radii), so the wall is not doing the relief."
+ );
+ }
+
+ let npy_path = path(&format!("{out}_fields.npy"));
+ ndarray_npy::write_npy(&npy_path, &run.fields)
+ .map_err(|e| anyhow::anyhow!("writing {}: {e}", npy_path.display()))?;
+
+ let mut csv = String::from(
+ "# beamprop M6d axisymmetric laser-supported detonation: front track\n\
+ # columns: time_s, front_x_axis_m, front_x_edge_m, lag_m\n\
+ # Both front positions DECREASE: the wave runs back up the beam toward\n\
+ # the laser. lag = edge - axis is positive because the axis leads —\n\
+ # the beam edge is where gas escapes sideways, so it is driven least,\n\
+ # and that curvature IS the radial relief this case measures.\n\
+ time_s,front_x_axis_m,front_x_edge_m,lag_m\n",
+ );
+ for i in 0..run.frame_time.len() {
+ csv.push_str(&format!(
+ "{:.9e},{:.9e},{:.9e},{:.9e}\n",
+ run.frame_time[i],
+ run.front_x_axis[i],
+ run.front_x_edge[i],
+ run.front_x_edge[i] - run.front_x_axis[i]
+ ));
+ }
+ let csv_path = path(&format!("{out}_trajectory.csv"));
+ fs::write(&csv_path, csv).with_context(|| format!("writing {}", csv_path.display()))?;
+
+ let meta = format!(
+ "{{\n \"case\": \"lsd2d\",\n \"drive\": {drive},\n \
+ \"beam_radius\": {rb},\n \"beam_order\": {bo},\n \"p0\": {p0},\n \
+ \"t0\": {t0},\n \"rho_0\": {rho},\n \"length\": {len},\n \
+ \"domain_radius\": {domr},\n \"cells_x\": {cx},\n \"cells_r\": {cr},\n \
+ \"alpha\": {alpha},\n \"frames\": {frames},\n \
+ \"d_measured\": {dm},\n \"d_wide\": {dw},\n \"d_raizer\": {dr},\n \
+ \"relief_deficit\": {rd},\n \"deposited_energy\": {dep},\n \
+ \"escaped_energy\": {esc},\n \"energy_residual\": {res},\n \
+ \"boundaries_undisturbed\": {bnd},\n \
+ \"r_min\": {rmin},\n \"r_max\": {rmax},\n \
+ \"x_min\": {xmin},\n \"x_max\": {xmax},\n \
+ \"quantities\": [\"p_Pa\", \"rho_kg_m3\", \"u_x_m_s\", \"u_r_m_s\", \
+ \"alpha_1_m\", \"I_W_m2\"]\n}}\n",
+ drive = p.drive,
+ rb = p.beam_radius,
+ bo = p.beam_order,
+ p0 = p.p0,
+ t0 = p.t0,
+ rho = run.rho_0,
+ len = p.length,
+ domr = p.domain_radii * p.beam_radius,
+ cx = p.cells_x,
+ cr = p.cells_r,
+ alpha = p.alpha,
+ frames = p.frames,
+ dm = run.d_measured,
+ dw = run.d_wide,
+ dr = run.d_raizer,
+ rd = run.relief_deficit,
+ dep = run.deposited_energy,
+ esc = run.escaped_energy,
+ res = run.energy_residual,
+ bnd = run.boundaries_undisturbed,
+ rmin = run.r[0],
+ rmax = run.r[run.r.len() - 1],
+ xmin = run.x[0],
+ xmax = run.x[run.x.len() - 1],
+ );
+ let meta_path = path(&format!("{out}_meta.json"));
+ fs::write(&meta_path, meta).with_context(|| format!("writing {}", meta_path.display()))?;
+
+ let notes = format!(
+ "# lsd2d — axisymmetric laser-supported detonation (M6d)\n\n\
+ A seeded detonation driven by a beam of finite radius {rb:.2e} m through air at \
+ {p0:.0} Pa, in axisymmetric (r, x) coordinates. The beam enters at x = 0 and \
+ travels +x; the front runs the other way, back up the beam toward the laser.\n\n\
+ ## What this case measures\n\n\
+ M6c's planar solver has no transverse direction, so its beam is infinitely wide \
+ and the shocked gas cannot escape sideways. Here it can. Against the wide-beam \
+ limit ({dw:.1} m/s, itself within {wpct:+.2} % of Raizer's closed form), the \
+ finite beam runs at {dm:.1} m/s — radial relief costs **{pct:.1} %** of the \
+ front speed at R_b·α = {rba:.2}.\n\n\
+ The reference is the wide-beam *2-D* run, not the 1-D column, and deliberately: \
+ the modelled front is transversely unstable (see gate G14), so a 1-D comparison \
+ would report that instability as relief. Both runs here carry it.\n\n\
+ ## Files\n\n\
+ - `{out}_fields.npy` — [frame, quantity, ring, cell], quantities \
+ [p, rho, u_x, u_r, alpha, I].\n\
+ - `{out}_trajectory.csv` — the front on the axis and at the beam edge. The axis \
+ leads; that curvature is the relief made visible.\n\
+ - `{out}_meta.json` — parameters and derived quantities.\n\n\
+ Render with `python3 scripts/render_lsd2d.py `.\n",
+ rb = p.beam_radius,
+ p0 = p.p0,
+ dw = run.d_wide,
+ wpct = 100.0 * (run.d_wide / run.d_raizer - 1.0),
+ dm = run.d_measured,
+ pct = 100.0 * run.relief_deficit,
+ rba = p.beam_radius * p.alpha,
+ out = out,
+ );
+ let notes_path = path(&format!("{out}_notes.md"));
+ fs::write(¬es_path, notes).with_context(|| format!("writing {}", notes_path.display()))?;
+
+ println!(
+ " wrote {}, {}, {} and {}",
+ npy_path.display(),
+ csv_path.display(),
+ meta_path.display(),
+ notes_path.display()
+ );
+ Ok(())
+}
diff --git a/src/validate.rs b/src/validate.rs
index 6ad01c6..66dbf5a 100644
--- a/src/validate.rs
+++ b/src/validate.rs
@@ -477,6 +477,319 @@ impl RiemannProblem {
}
}
+// ------------------------------------------------------------------------
+// M6d reference: the self-similar Sedov–Taylor point blast.
+//
+// The repo's first *multidimensional* verification anchor. Like
+// `RiemannProblem` it shares only the plain `(ρ, u, p)` struct with the solver
+// under test — no flux, wave-speed or EOS code in common — and it contains no
+// laser physics, no deposition closure and no Chapman–Jouguet construction, so
+// unlike Raizer's LSD velocity it is not something the model is built from.
+// ------------------------------------------------------------------------
+
+/// Self-similar solution of a strong point explosion in a uniform ambient gas
+/// (Sedov 1959; Landau & Lifshitz §106; Kamm & Timmes 2007).
+///
+/// A point release of energy `E` into a uniform gas of density `ρ₀`, with the
+/// ambient pressure neglected, has no length or time scale of its own, so the
+/// shock radius is fixed by dimensional analysis alone:
+///
+/// ```text
+/// R(t) = ξ₀·(E t²/ρ₀)^(1/(ν+2)) ν = 2 cylindrical, 3 spherical
+/// ```
+///
+/// The **exponent** `2/(ν+2)` is therefore parameter-free — it is what the
+/// gate's tightest leg measures — while the **coefficient** `ξ₀` requires the
+/// interior profile.
+///
+/// # `ξ₀` is derived here, not quoted
+///
+/// This matters for the same reason M6a's D5 mattered. Writing a remembered
+/// constant into a verification anchor makes the anchor only as good as the
+/// memory. Instead the self-similar ODEs are integrated inward from the shock
+/// and `ξ₀` follows from the energy integral, which is the statement that the
+/// profile carries exactly the energy `E` that was deposited:
+///
+/// ```text
+/// E = σ_ν·ρ₀·D²·R^ν·J, J = ∫₀¹ λ^(ν+1)·[½GV² + P/(γ−1)] dλ
+/// ⇒ ξ₀ = [1/(σ_ν·δ²·J)]^(1/(ν+2)), δ = 2/(ν+2)
+/// ```
+///
+/// So nothing external enters. The published value is then available as an
+/// **independent cross-check** rather than as an input, and the unit test below
+/// uses it that way: computed `ξ₀ = 1.03278` for `γ = 1.4`, `ν = 3`, against the
+/// ≈1.033 the references quote.
+///
+/// # The similarity variables
+///
+/// With `λ = r/R`, `x = ln λ`, `δ = 2/(ν+2)` and `D = δR/t` the shock speed:
+///
+/// ```text
+/// u = δ·(r/t)·V(λ), ρ = ρ₀·G(λ), p = ρ₀·δ²·(r/t)²·P(λ), Q ≡ P/G
+/// ```
+///
+/// Substituting into the Euler equations and eliminating gives one ODE for `V`
+/// and two logarithmic derivatives:
+///
+/// ```text
+/// dV/dx = [ −V(V − 1/δ)(V − 1) − 2Q(1/δ − 1) + γνQV ] / [ (V − 1)² − γQ ]
+/// dlnG/dx = −(dV/dx + νV)/(V − 1)
+/// dlnQ/dx = W + (γ−1)·dlnG/dx, W = (2/δ − 2V)/(V − 1)
+/// ```
+///
+/// integrated from the immediate post-shock state, which is the strong-shock
+/// Rankine–Hugoniot condition and involves no free choice:
+///
+/// ```text
+/// V(1) = 2/(γ+1), G(1) = (γ+1)/(γ−1), P(1) = 2/(γ+1)
+/// ```
+///
+/// The denominator `(V−1)² − γQ` starts negative and only grows more so as `Q`
+/// diverges toward the centre, so the inward integration crosses no sonic
+/// point.
+#[derive(Debug, Clone)]
+pub struct SedovBlast {
+ gamma: f64,
+ nu: usize,
+ energy: f64,
+ rho_0: f64,
+ xi_0: f64,
+ energy_integral: f64,
+ /// `x = ln λ`, descending from 0.
+ xs: Vec,
+ v: Vec,
+ g: Vec,
+ q: Vec,
+ /// `d/dx` of each of the three, stored at the nodes.
+ ///
+ /// Kept so the profile can be interpolated with a **cubic Hermite** rather
+ /// than a straight line. That is not polish: the reference is differentiated
+ /// numerically by its own verification test, and piecewise-linear values
+ /// have piecewise-constant derivatives, so a linear interpolant puts a floor
+ /// under that test — measured, it held the worst Euler residual at 2.6e-3
+ /// where the Hermite form reaches 4e-5 and keeps converging.
+ dv: Vec,
+ dg: Vec,
+ dq: Vec,
+}
+
+/// How far inward the profile is integrated, as `ln λ`.
+///
+/// The energy integrand carries `λ^(ν+2)`, and `P` diverges only as `λ^(−2)`
+/// toward the centre, so the tail falls off like `λ^ν` and is negligible long
+/// before here. Measured: `J` changes by 3e-8 relative between `x_end = −6` and
+/// `−12`, which is far below the tolerance of anything gated against it.
+const SEDOV_X_END: f64 = -9.5;
+
+/// RK4 steps over that range. Measured convergence in `J`:
+/// 0.4232886797 (n = 50k) → 0.4232884807 → 0.4232884310 → 0.4232884186 (400k),
+/// i.e. already better than 1e-6 relative at the default.
+const SEDOV_STEPS: usize = 80_000;
+
+impl SedovBlast {
+ /// Integrate the profile for `γ` in `ν` dimensions, for a blast of energy
+ /// `energy` in ambient density `rho_0`.
+ ///
+ /// `energy` is per unit length for `ν = 2` and total for `ν = 3`.
+ pub fn new(gamma: f64, nu: usize, energy: f64, rho_0: f64) -> Result {
+ if !(1.0..3.0).contains(&gamma) {
+ bail!("sedov: γ must lie in (1, 3), got {gamma}");
+ }
+ if nu != 2 && nu != 3 {
+ bail!(
+ "sedov: only ν = 2 (cylindrical) and ν = 3 (spherical) are implemented, got {nu}"
+ );
+ }
+ if !(energy > 0.0 && energy.is_finite() && rho_0 > 0.0 && rho_0.is_finite()) {
+ bail!("sedov: energy and rho_0 must be positive and finite");
+ }
+ let delta = 2.0 / (nu as f64 + 2.0);
+ let nu_f = nu as f64;
+
+ // Strong-shock Rankine–Hugoniot, at λ = 1.
+ let mut v = 2.0 / (gamma + 1.0);
+ let mut g = (gamma + 1.0) / (gamma - 1.0);
+ let mut q = 2.0 * (gamma - 1.0) / ((gamma + 1.0) * (gamma + 1.0));
+
+ let rhs = |v: f64, g: f64, q: f64| -> (f64, f64, f64) {
+ let num = -v * (v - 1.0 / delta) * (v - 1.0) - 2.0 * q * (1.0 / delta - 1.0)
+ + gamma * nu_f * q * v;
+ let den = (v - 1.0) * (v - 1.0) - gamma * q;
+ let vx = num / den;
+ let dlng = -(vx + nu_f * v) / (v - 1.0);
+ let w = (2.0 / delta - 2.0 * v) / (v - 1.0);
+ let dlnq = w + (gamma - 1.0) * dlng;
+ (vx, dlng * g, dlnq * q)
+ };
+
+ let h = SEDOV_X_END / SEDOV_STEPS as f64;
+ let mut xs = Vec::with_capacity(SEDOV_STEPS + 1);
+ let (mut vs, mut gs, mut qs) = (
+ Vec::with_capacity(SEDOV_STEPS + 1),
+ Vec::with_capacity(SEDOV_STEPS + 1),
+ Vec::with_capacity(SEDOV_STEPS + 1),
+ );
+ let mut x = 0.0;
+ xs.push(x);
+ vs.push(v);
+ gs.push(g);
+ qs.push(q);
+ for _ in 0..SEDOV_STEPS {
+ let k1 = rhs(v, g, q);
+ let k2 = rhs(v + 0.5 * h * k1.0, g + 0.5 * h * k1.1, q + 0.5 * h * k1.2);
+ let k3 = rhs(v + 0.5 * h * k2.0, g + 0.5 * h * k2.1, q + 0.5 * h * k2.2);
+ let k4 = rhs(v + h * k3.0, g + h * k3.1, q + h * k3.2);
+ v += h / 6.0 * (k1.0 + 2.0 * k2.0 + 2.0 * k3.0 + k4.0);
+ g += h / 6.0 * (k1.1 + 2.0 * k2.1 + 2.0 * k3.1 + k4.1);
+ q += h / 6.0 * (k1.2 + 2.0 * k2.2 + 2.0 * k3.2 + k4.2);
+ if !v.is_finite() || !g.is_finite() || !q.is_finite() {
+ bail!("sedov: the inward integration left the reals at x = {x}");
+ }
+ x += h;
+ xs.push(x);
+ vs.push(v);
+ gs.push(g);
+ qs.push(q);
+ }
+
+ // Node derivatives for the Hermite interpolant, in a second pass: they
+ // are just the ODE right-hand sides, so nothing new is being solved.
+ let mut dvs = Vec::with_capacity(xs.len());
+ let mut dgs = Vec::with_capacity(xs.len());
+ let mut dqs = Vec::with_capacity(xs.len());
+ for k in 0..xs.len() {
+ let d = rhs(vs[k], gs[k], qs[k]);
+ dvs.push(d.0);
+ dgs.push(d.1);
+ dqs.push(d.2);
+ }
+
+ // J = ∫ λ^(ν+2)·[½GV² + P/(γ−1)] dx, trapezoid on the (descending) grid.
+ let integrand = |k: usize| {
+ let lam = xs[k].exp();
+ let p = qs[k] * gs[k];
+ lam.powi(nu as i32 + 2) * (0.5 * gs[k] * vs[k] * vs[k] + p / (gamma - 1.0))
+ };
+ let mut j = 0.0;
+ for k in 0..xs.len() - 1 {
+ j += 0.5 * (integrand(k) + integrand(k + 1)) * (xs[k] - xs[k + 1]);
+ }
+
+ let sigma = if nu == 3 {
+ 4.0 * std::f64::consts::PI
+ } else {
+ 2.0 * std::f64::consts::PI
+ };
+ let xi_0 = (1.0 / (sigma * delta * delta * j)).powf(1.0 / (nu as f64 + 2.0));
+
+ Ok(Self {
+ gamma,
+ nu,
+ energy,
+ rho_0,
+ xi_0,
+ energy_integral: j,
+ xs,
+ v: vs,
+ g: gs,
+ q: qs,
+ dv: dvs,
+ dg: dgs,
+ dq: dqs,
+ })
+ }
+
+ /// `ξ₀`, derived from the energy integral.
+ pub fn xi_0(&self) -> f64 {
+ self.xi_0
+ }
+
+ /// The dimensionless energy integral `J`.
+ pub fn energy_integral(&self) -> f64 {
+ self.energy_integral
+ }
+
+ /// Similarity exponent `2/(ν+2)` — the parameter-free part.
+ pub fn radius_exponent(&self) -> f64 {
+ 2.0 / (self.nu as f64 + 2.0)
+ }
+
+ /// Shock radius at time `t`.
+ pub fn shock_radius(&self, t: f64) -> f64 {
+ self.xi_0 * (self.energy * t * t / self.rho_0).powf(1.0 / (self.nu as f64 + 2.0))
+ }
+
+ /// Shock speed at time `t`.
+ pub fn shock_speed(&self, t: f64) -> f64 {
+ self.radius_exponent() * self.shock_radius(t) / t
+ }
+
+ /// Strong-shock density ratio `(γ+1)/(γ−1)` — the immediate post-shock
+ /// compression, a closed form with no constant in it.
+ pub fn density_jump(&self) -> f64 {
+ (self.gamma + 1.0) / (self.gamma - 1.0)
+ }
+
+ /// Profile values `(V, G, Q)` at `λ ∈ (0, 1]`, linearly interpolated.
+ fn profile(&self, lambda: f64) -> (f64, f64, f64) {
+ let x = lambda.ln();
+ if x >= self.xs[0] {
+ return (self.v[0], self.g[0], self.q[0]);
+ }
+ let last = self.xs.len() - 1;
+ if x <= self.xs[last] {
+ return (self.v[last], self.g[last], self.q[last]);
+ }
+ // `xs` descends uniformly, so the index is direct.
+ let h = self.xs[1] - self.xs[0];
+ let f = (x - self.xs[0]) / h;
+ let k = (f.floor() as usize).min(last - 1);
+ let s = f - k as f64;
+ // Cubic Hermite: the node derivatives are the ODE right-hand sides, so
+ // this is the natural interpolant for a solution defined by an ODE, and
+ // it is what keeps the verification test's finite differences honest.
+ let (s2, s3) = (s * s, s * s * s);
+ let (h00, h10, h01, h11) = (
+ 2.0 * s3 - 3.0 * s2 + 1.0,
+ s3 - 2.0 * s2 + s,
+ -2.0 * s3 + 3.0 * s2,
+ s3 - s2,
+ );
+ let hermite = |a: &[f64], d: &[f64]| {
+ h00 * a[k] + h10 * h * d[k] + h01 * a[k + 1] + h11 * h * d[k + 1]
+ };
+ (
+ hermite(&self.v, &self.dv),
+ hermite(&self.g, &self.dg),
+ hermite(&self.q, &self.dq),
+ )
+ }
+
+ /// The self-similar state at radius `r` and time `t`.
+ ///
+ /// Outside the shock the gas is undisturbed; the strong-shock idealisation
+ /// carries no ambient pressure, so `p_ambient` is supplied by the caller
+ /// rather than invented here.
+ pub fn state_at(&self, r: f64, t: f64, p_ambient: f64) -> Primitive {
+ let shock = self.shock_radius(t);
+ if r >= shock {
+ return Primitive {
+ rho: self.rho_0,
+ u: 0.0,
+ p: p_ambient,
+ };
+ }
+ let lambda = (r / shock).max(f64::MIN_POSITIVE);
+ let (v, g, q) = self.profile(lambda);
+ let delta = self.radius_exponent();
+ Primitive {
+ rho: self.rho_0 * g,
+ u: delta * (r / t) * v,
+ p: self.rho_0 * delta * delta * (r / t) * (r / t) * q * g,
+ }
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -700,4 +1013,180 @@ mod tests {
assert!((b.long_exposure_width(z, 0.0) - b.width_at(z)).abs() < 1e-15);
assert!(b.long_exposure_width(z, 1e-14) > b.width_at(z));
}
+
+ // --------------------------------------------------------------------
+ // M6d reference: the Sedov–Taylor blast.
+ // --------------------------------------------------------------------
+
+ fn sedov_air() -> SedovBlast {
+ SedovBlast::new(1.4, 3, 1.0, 1.0).expect("sedov")
+ }
+
+ /// `ξ₀` is *derived* from the energy integral, so the published value is an
+ /// independent cross-check rather than an input.
+ ///
+ /// Measured: `ξ₀` = 1.03278, `J` = 0.423288, for `γ` = 1.4, `ν` = 3, against
+ /// the ≈1.033 quoted by Sedov (1959) and Landau & Lifshitz §106. Agreement
+ /// to 0.03 % of a number nothing in this file was told.
+ #[test]
+ fn sedov_xi_0_matches_the_published_value() {
+ let s = sedov_air();
+ assert!(
+ (s.xi_0() - 1.033).abs() < 3e-3,
+ "derived ξ₀ = {:.6} against the published ≈1.033; either the ODEs or the \
+ energy integral is wrong, and the profile cannot be trusted as a reference",
+ s.xi_0()
+ );
+ assert!(
+ (s.energy_integral() - 0.4232884).abs() < 1e-5,
+ "energy integral J = {:.8}, pinned at 0.4232884",
+ s.energy_integral()
+ );
+ }
+
+ /// The immediate post-shock state is the strong-shock Rankine–Hugoniot
+ /// condition — a closed form with no fitted constant anywhere in it.
+ #[test]
+ fn sedov_post_shock_state_is_the_strong_shock_jump() {
+ let s = sedov_air();
+ let (gamma, t) = (1.4, 1.0);
+ let r_shock = s.shock_radius(t);
+ let d = s.shock_speed(t);
+ // Just inside the shock.
+ let w = s.state_at(r_shock * (1.0 - 1e-9), t, 0.0);
+ assert!(
+ (w.rho / 1.0 - (gamma + 1.0) / (gamma - 1.0)).abs() < 1e-6,
+ "post-shock compression {:.6}, expected {:.6}",
+ w.rho,
+ (gamma + 1.0) / (gamma - 1.0)
+ );
+ assert!(
+ (w.u - 2.0 * d / (gamma + 1.0)).abs() / d < 1e-6,
+ "post-shock velocity {:.6e}, expected {:.6e}",
+ w.u,
+ 2.0 * d / (gamma + 1.0)
+ );
+ assert!(
+ (w.p - 2.0 * d * d / (gamma + 1.0)).abs() / (d * d) < 1e-6,
+ "post-shock pressure {:.6e}, expected {:.6e}",
+ w.p,
+ 2.0 * d * d / (gamma + 1.0)
+ );
+ assert!(
+ (s.density_jump() - 6.0).abs() < 1e-12,
+ "the γ = 1.4 strong-shock ratio is 6 exactly"
+ );
+ }
+
+ /// `R ∝ t^(2/(ν+2))` — the parameter-free half of the solution.
+ #[test]
+ fn sedov_radius_follows_the_similarity_exponent() {
+ let s = sedov_air();
+ let (t1, t2) = (0.3, 3.0);
+ let measured = (s.shock_radius(t2) / s.shock_radius(t1)).ln() / (t2 / t1).ln();
+ assert!(
+ (measured - 0.4).abs() < 1e-12,
+ "similarity exponent {measured:.12} != 2/5"
+ );
+ }
+
+ /// **The check that verifies the derivation itself.**
+ ///
+ /// The three ODEs were derived by hand from the Euler equations; a slip in
+ /// that algebra would produce a smooth, plausible profile that is simply
+ /// not a solution. So the reconstructed fields are put back into the
+ /// *original* PDEs — continuity, momentum and entropy advection, in
+ /// spherical symmetry — and the residuals are required to vanish.
+ ///
+ /// Measured: worst relative residual **6.9e-5** over `λ ∈ [0.45, 0.85]` at a
+ /// finite-difference step of `1e-3`, and it falls as that step squared —
+ /// 1.1e-3 → 1.0e-4 → 1.0e-5 → 1.7e-6 for steps 1e-2 → 3e-3 → 1e-3 → 3e-4,
+ /// bottoming out near 1e-6 where the profile's own interpolation takes
+ /// over. Clean 2nd-order convergence to zero is the statement that the
+ /// profile solves the PDEs; a slip in the derivation would leave a residual
+ /// that refinement does not touch.
+ ///
+ /// The window is chosen, not cropped to taste. Below `λ ≈ 0.4` the density
+ /// has fallen through seven decades toward the evacuated core and the
+ /// momentum equation's `(1/ρ)∂p/∂r` is numerically ill-conditioned; above
+ /// `λ ≈ 0.9` the finite differences start to straddle the shock.
+ #[test]
+ fn sedov_profile_satisfies_the_euler_equations() {
+ let s = sedov_air();
+ let gamma = 1.4;
+ let nu = 3.0;
+ let t = 1.0;
+ let r_shock = s.shock_radius(t);
+ let d = s.shock_speed(t);
+ let mut worst = 0.0f64;
+ for k in 0..=8 {
+ let lambda = 0.45 + 0.05 * k as f64;
+ let r = lambda * r_shock;
+ let hr = 1e-3 * r;
+ let ht = 1e-3 * t;
+ let at = |r: f64, t: f64| s.state_at(r, t, 0.0);
+
+ let c = at(r, t);
+ let (rp, rm) = (at(r + hr, t), at(r - hr, t));
+ let (tp, tm) = (at(r, t + ht), at(r, t - ht));
+
+ let d_dr = |f: fn(&Primitive) -> f64| (f(&rp) - f(&rm)) / (2.0 * hr);
+ let d_dt = |f: fn(&Primitive) -> f64| (f(&tp) - f(&tm)) / (2.0 * ht);
+
+ // Continuity: ∂ρ/∂t + ∂(ρu)/∂r + (ν−1)ρu/r = 0
+ let mass_flux = |w: &Primitive| w.rho * w.u;
+ let cont = d_dt(|w| w.rho)
+ + (mass_flux(&rp) - mass_flux(&rm)) / (2.0 * hr)
+ + (nu - 1.0) * c.rho * c.u / r;
+ worst = worst.max((cont * t / c.rho).abs());
+
+ // Momentum: ∂u/∂t + u ∂u/∂r + (1/ρ)∂p/∂r = 0
+ let mom = d_dt(|w| w.u) + c.u * d_dr(|w| w.u) + d_dr(|w| w.p) / c.rho;
+ worst = worst.max((mom * t / d).abs());
+
+ // Entropy: (∂/∂t + u ∂/∂r) ln(p ρ^−γ) = 0
+ let entropy = |w: &Primitive| w.p.ln() - gamma * w.rho.ln();
+ let ent = (entropy(&tp) - entropy(&tm)) / (2.0 * ht)
+ + c.u * (entropy(&rp) - entropy(&rm)) / (2.0 * hr);
+ worst = worst.max((ent * t).abs());
+ }
+ assert!(
+ worst < 5e-4,
+ "worst Euler residual {worst:.3e} against a measured 4e-5 — the self-similar \
+ profile does not solve the equations it was derived from, so the ODEs are \
+ wrong and ξ₀ agreeing with the literature was luck"
+ );
+ }
+
+ /// The profile carries exactly the energy that was deposited.
+ ///
+ /// This is how `ξ₀` was defined, so it does not re-verify the ODEs; what it
+ /// does catch is a unit or geometry slip between the profile and
+ /// [`SedovBlast::state_at`] — the two are written independently.
+ #[test]
+ fn sedov_profile_carries_the_deposited_energy() {
+ let (energy, rho_0) = (2.5, 0.9);
+ let s = SedovBlast::new(1.4, 3, energy, rho_0).expect("sedov");
+ let t = 0.7;
+ let r_shock = s.shock_radius(t);
+ let n = 200_000;
+ let mut total = 0.0;
+ for k in 0..n {
+ let r = r_shock * (k as f64 + 0.5) / n as f64;
+ let w = s.state_at(r, t, 0.0);
+ let e_density = 0.5 * w.rho * w.u * w.u + w.p / (1.4 - 1.0);
+ total += e_density * 4.0 * PI * r * r * (r_shock / n as f64);
+ }
+ assert!(
+ (total - energy).abs() / energy < 2e-3,
+ "the profile integrates to {total:.6} but {energy} was deposited"
+ );
+ }
+
+ #[test]
+ fn sedov_refuses_unsupported_parameters() {
+ assert!(SedovBlast::new(1.4, 1, 1.0, 1.0).is_err());
+ assert!(SedovBlast::new(0.9, 3, 1.0, 1.0).is_err());
+ assert!(SedovBlast::new(1.4, 3, -1.0, 1.0).is_err());
+ }
}
diff --git a/tests/docs.rs b/tests/docs.rs
new file mode 100644
index 0000000..ce0f734
--- /dev/null
+++ b/tests/docs.rs
@@ -0,0 +1,175 @@
+//! Documentation-consistency gates for `docs/MODELS.md`.
+//!
+//! These are **not** physics. They gate the one property of the claims ledger
+//! that a reader relies on and that nothing else checks: that the census line
+//! at the top of the section describes the table underneath it.
+//!
+//! The reason this exists: `CONTRIBUTING.md` requires the census to move in the
+//! same change as any added, retired or re-statused row, and on 2026-08-01 it
+//! had not. The line claimed 118 rows (80 verified, 10 validated, 16 pinned,
+//! 12 ungated) against an actual 122 (83 / 10 / 17 / 12). Nothing failed,
+//! because prose cannot fail — so the gap was invisible until someone counted.
+//! A reader who takes "10 of 118 claims are validated" as the honest summary of
+//! this solver deserves that ratio to be arithmetic rather than recollection.
+//!
+//! What this does not gate: whether each row's *number* still matches the
+//! assertion in the gate it cites. That drifted too, and catching it
+//! mechanically is harder, because the ledger quotes numbers in prose. The
+//! ground truth for any such number is the `assert!` in the named test.
+
+use std::fs;
+use std::path::PathBuf;
+
+/// The four statuses every ledger row must carry, in census order.
+const STATUSES: [&str; 4] = ["verified", "validated", "pinned", "ungated"];
+
+fn models_md() -> String {
+ let path: PathBuf = [env!("CARGO_MANIFEST_DIR"), "docs", "MODELS.md"]
+ .iter()
+ .collect();
+ fs::read_to_string(&path).unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()))
+}
+
+/// The `## Claims ledger` section, up to the next top-level heading.
+fn claims_ledger(md: &str) -> &str {
+ let start = md
+ .find("\n## Claims ledger")
+ .expect("docs/MODELS.md has no `## Claims ledger` section")
+ + 1;
+ let rest = &md[start..];
+ let end = rest.find("\n## ").unwrap_or(rest.len());
+ &rest[..end]
+}
+
+/// Split one markdown table row into trimmed cells, or `None` if the line is
+/// not a data row (header, separator, or not a table row at all).
+fn data_row(line: &str) -> Option> {
+ let line = line.trim();
+ if !line.starts_with('|') {
+ return None;
+ }
+ let cells: Vec<&str> = line
+ .trim_matches('|')
+ .split('|')
+ .map(str::trim)
+ .collect::>();
+ if cells.len() < 4 {
+ return None;
+ }
+ // The header row, and the `|---|---|` separator under it.
+ if cells[0].eq_ignore_ascii_case("claim")
+ || cells[0].chars().all(|c| c == '-' || c == ':' || c == ' ')
+ {
+ return None;
+ }
+ Some(cells)
+}
+
+/// Normalise a status cell: `**pinned**` → `pinned`, and
+/// `verified *(same lineage)*` → `verified`, since the parenthetical is a flag
+/// on the claim rather than a fifth status.
+fn status_of(cell: &str) -> Option<&'static str> {
+ let stripped = cell.replace('*', "");
+ let head = stripped
+ .split('(')
+ .next()
+ .unwrap_or_default()
+ .trim()
+ .to_string();
+ STATUSES.into_iter().find(|s| *s == head)
+}
+
+/// Every digit run in a line, in order — enough to read the census without a
+/// regex crate.
+fn numbers_in(line: &str) -> Vec {
+ let mut out = Vec::new();
+ let mut cur = String::new();
+ for c in line.chars() {
+ if c.is_ascii_digit() {
+ cur.push(c);
+ } else if !cur.is_empty() {
+ out.push(cur.parse().expect("digit run parses"));
+ cur.clear();
+ }
+ }
+ if !cur.is_empty() {
+ out.push(cur.parse().expect("digit run parses"));
+ }
+ out
+}
+
+/// **Every ledger row carries exactly one of the four statuses.**
+///
+/// A typo'd or missing status would silently skew the census below, and the
+/// ledger's own preamble is explicit that a claim which cannot be given one of
+/// the four is a claim that is not finished.
+#[test]
+fn every_claims_ledger_row_has_a_recognised_status() {
+ let md = models_md();
+ let ledger = claims_ledger(&md);
+ let mut bad = Vec::new();
+ for line in ledger.lines() {
+ if let Some(cells) = data_row(line)
+ && status_of(cells[2]).is_none()
+ {
+ bad.push(format!(" {} → status cell {:?}", cells[0], cells[2]));
+ }
+ }
+ assert!(
+ bad.is_empty(),
+ "ledger rows whose status is not one of {STATUSES:?}:\n{}",
+ bad.join("\n")
+ );
+}
+
+/// **The census line matches the table it introduces.**
+///
+/// `CONTRIBUTING.md` § "Documentation to keep in sync" requires the census to
+/// be updated in the same change as the row it describes. This is that
+/// requirement, enforced.
+#[test]
+fn the_claims_ledger_census_matches_the_rows() {
+ let md = models_md();
+ let ledger = claims_ledger(&md);
+
+ let mut counts = [0usize; 4];
+ let mut total = 0usize;
+ for line in ledger.lines() {
+ if let Some(cells) = data_row(line) {
+ let status =
+ status_of(cells[2]).unwrap_or_else(|| panic!("unrecognised status {:?}", cells[2]));
+ let idx = STATUSES
+ .iter()
+ .position(|s| *s == status)
+ .expect("status is one of STATUSES");
+ counts[idx] += 1;
+ total += 1;
+ }
+ }
+
+ let census = ledger
+ .lines()
+ .find(|l| l.trim_start().starts_with("**Census of the "))
+ .expect("the claims ledger has no `**Census of the …**` line");
+ let stated = numbers_in(census);
+ assert_eq!(
+ stated.len(),
+ 5,
+ "census line should state the total then the four statuses in the order \
+ {STATUSES:?}; found {} numbers in {census:?}",
+ stated.len()
+ );
+
+ let want = format!(
+ "**Census of the {total} rows below: {} verified, {} validated, \
+ {} pinned, {} ungated.**",
+ counts[0], counts[1], counts[2], counts[3]
+ );
+ assert_eq!(
+ (stated[0], stated[1], stated[2], stated[3], stated[4]),
+ (total, counts[0], counts[1], counts[2], counts[3]),
+ "the census line has drifted from the table.\n states: {census}\n \
+ actual: {want}\nUpdate the census in the same change as the row, per \
+ CONTRIBUTING.md."
+ );
+}
diff --git a/tests/validation.rs b/tests/validation.rs
index 2db9831..a651b2e 100644
--- a/tests/validation.rs
+++ b/tests/validation.rs
@@ -18,17 +18,19 @@ use ndarray::Array2;
use beamprop::aperture::{Aperture, TiltRemoval};
use beamprop::cases::{IgnitionParams, run_ignition};
use beamprop::euler1d::{Boundary, Euler1d, IdealGas, Primitive};
+use beamprop::euler2d::{Boundary2d, Euler2d, Geometry, Primitive2d};
use beamprop::field::Field;
use beamprop::grid::Grid;
use beamprop::lsd::{Absorption, LsdColumn, PlasmaColumn, SeededIgnition, raizer_lsd_velocity};
+use beamprop::lsd2d::{BeamProfile, Lsd2dColumn, SeededIgnition2d};
use beamprop::medium::{ConstantDeltaN, Medium, UniformExtinction, Vacuum};
use beamprop::montecarlo::seeded_ensemble;
use beamprop::plasmaprops::{NE_ACCURACY_FLOOR, PlasmaTable, SECOND_IONIZATION_K};
use beamprop::propagate::{DiffractionMethod, Propagator, beam_width, centroid};
use beamprop::turbulence::{ScreenGenerator, TurbulentPath};
use beamprop::validate::{
- GaussianBeam, SOD_SHOCK_TUBE, fried_r0, kolmogorov_structure_function, loglog_slope_xy,
- observed_order, rytov_variance,
+ GaussianBeam, SOD_SHOCK_TUBE, SedovBlast, fried_r0, kolmogorov_structure_function,
+ loglog_slope_xy, observed_order, rytov_variance,
};
/// A smooth defocusing Gaussian duct: `δn(r) = -A·exp(-r²/(2s²))`.
@@ -1030,13 +1032,13 @@ fn tt2012_effective_field_rises_with_pressure() {
///
/// ```text
/// mean-trajectory closure: n ∈ [0.023, 0.231] measurement OUTSIDE
-/// distribution-resolved (default): n ∈ [0.183, 0.407] measurement INSIDE
+/// distribution-resolved (default): n ∈ [0.174, 0.382] measurement INSIDE
/// ```
///
/// Nothing here was tuned and no tolerance moved. What changed is that the
/// cascade no longer collapses the electron energy distribution onto its mean,
/// so ionization is not gated by a hard `ε_∞ = U_i` bifurcation the model was
-/// sitting on top of. At the untouched literature centre the slope is 0.279
+/// sitting on top of. At the untouched literature centre the slope is 0.264
/// against the measured 0.329. See
/// `distribution_resolved_cascade_fixes_the_high_pressure_slope` for the
/// before/after on both datasets, and `docs/M6A_SPEC.md` for why this is a
@@ -4877,3 +4879,1262 @@ fn free_molecular_escape_flattens_the_low_pressure_branch() {
correction is supposed to leave this branch essentially alone"
);
}
+
+// ---------------------------------------------------------------------------
+// M6d gate G9 — the planar limit of the 2-D solver (docs/M6D_SPEC.md).
+//
+// Verification, and the standing guard on the milestone's central structural
+// decision: `euler2d` reuses `euler1d`'s Riemann solver rather than carrying a
+// second copy. If that reuse ever drifts — a changed wave-speed estimate, a
+// reordered sweep, a ghost fill that stops mirroring — this gate says so
+// immediately, in the strongest form available: not "close", but equal.
+// ---------------------------------------------------------------------------
+
+/// Sod data, uniform in `r`, for the 2-D solver.
+fn sod_2d(n_r: usize, n_x: usize, geometry: Geometry) -> Euler2d {
+ Euler2d::from_fn(
+ IdealGas::AIR,
+ geometry,
+ [
+ Boundary2d::Axis,
+ Boundary2d::Transmissive,
+ Boundary2d::Transmissive,
+ Boundary2d::Transmissive,
+ ],
+ 0.0,
+ 0.0,
+ 1.0 / n_x as f64,
+ 1.0 / n_x as f64,
+ n_r,
+ n_x,
+ |_, x| {
+ if x < 0.5 {
+ Primitive2d {
+ rho: 1.0,
+ u_r: 0.0,
+ u_x: 0.0,
+ p: 1.0,
+ }
+ } else {
+ Primitive2d {
+ rho: 0.125,
+ u_r: 0.0,
+ u_x: 0.0,
+ p: 0.1,
+ }
+ }
+ },
+ )
+ .expect("2-D Sod setup")
+}
+
+/// The same Sod data for the 1-D solver.
+fn sod_1d(n_x: usize) -> Euler1d {
+ Euler1d::from_fn(
+ IdealGas::AIR,
+ Boundary::Transmissive,
+ 0.0,
+ 1.0 / n_x as f64,
+ n_x,
+ |x| {
+ if x < 0.5 {
+ Primitive {
+ rho: 1.0,
+ u: 0.0,
+ p: 1.0,
+ }
+ } else {
+ Primitive {
+ rho: 0.125,
+ u: 0.0,
+ p: 0.1,
+ }
+ }
+ },
+ )
+ .expect("1-D Sod setup")
+}
+
+/// **G9 — the planar 2-D solver reproduces `Euler1d` bit for bit
+/// (verification).**
+///
+/// A planar, radially uniform run is the 1-D problem embedded in two
+/// dimensions. The claim is not that it agrees to a tolerance but that it
+/// performs the *same floating-point operations*, which is what the area-
+/// weighted formulation was chosen to make true: with unit interface areas
+/// `1.0·F` is exactly `F`, `dt/h` is the same `lambda`, and the radial sweep of
+/// a radially uniform state is an exact no-op, so `X(dt)` is
+/// `Euler1d::step(dt)`.
+///
+/// Measured: **bit-identical in all three shared components, in every one of
+/// 240 cells, at every one of 40 steps** (checked at n_x = 240, n_r = 6).
+///
+/// Two non-vacuity legs, because an equality test that cannot fail proves
+/// nothing:
+///
+/// 1. A 1e-12 perturbation of one ring must break the comparison — the test can
+/// see a difference when there is one.
+/// 2. The *axisymmetric* run of the same data must diverge measurably from 1-D.
+/// Otherwise the geometric source might simply never be switched on, and
+/// leg 1 would still pass.
+#[test]
+fn euler2d_planar_limit_reproduces_euler1d_bit_for_bit() {
+ const N_X: usize = 240;
+ const N_R: usize = 6;
+ const STEPS: usize = 40;
+ const CFL: f64 = 0.4;
+
+ let mut two = sod_2d(N_R, N_X, Geometry::Planar);
+ let mut one = sod_1d(N_X);
+ two.set_cfl(CFL).expect("2-D cfl");
+ one.set_cfl(CFL).expect("1-D cfl");
+
+ for step in 0..STEPS {
+ // Both solvers take the same dt: in the planar radially uniform limit
+ // the radial wave speed never exceeds the axial one, so `stable_dt`
+ // agrees, but taking the 1-D value explicitly keeps the comparison
+ // about the update rather than about the step controller.
+ let dt = one.stable_dt().expect("dt");
+ one.step(dt).expect("1-D step");
+ two.step(dt).expect("2-D step");
+
+ for j in 0..N_R {
+ for i in 0..N_X {
+ let a = two.cell(j, i);
+ let b = one.cells()[i];
+ assert_eq!(
+ (a.rho, a.mom_x, a.energy),
+ (b.rho, b.mom, b.energy),
+ "step {step}, cell (j = {j}, i = {i}): the planar 2-D solver has \
+ diverged from euler1d. This is an equality by construction, so any \
+ difference at all means the sweep no longer performs the same \
+ arithmetic — check the Strang order, the ghost fill, or whether an \
+ area weight has stopped being exactly 1"
+ );
+ assert_eq!(
+ a.mom_r, 0.0,
+ "step {step}, cell (j = {j}, i = {i}): radial momentum appeared in a \
+ planar, radially uniform run"
+ );
+ }
+ }
+ }
+
+ // Non-vacuity 1: the comparison can fail.
+ let mut baseline = sod_1d(N_X);
+ baseline.set_cfl(CFL).expect("cfl");
+ let dt = baseline.stable_dt().expect("dt");
+ // Nudge one cell by a part in 1e12 through the initial data.
+ let mut nudged = Euler2d::from_fn(
+ IdealGas::AIR,
+ Geometry::Planar,
+ [
+ Boundary2d::Axis,
+ Boundary2d::Transmissive,
+ Boundary2d::Transmissive,
+ Boundary2d::Transmissive,
+ ],
+ 0.0,
+ 0.0,
+ 1.0 / N_X as f64,
+ 1.0 / N_X as f64,
+ N_R,
+ N_X,
+ |r, x| {
+ let scale = if r < 1.0 / N_X as f64 {
+ 1.0 + 1e-12
+ } else {
+ 1.0
+ };
+ if x < 0.5 {
+ Primitive2d {
+ rho: scale,
+ u_r: 0.0,
+ u_x: 0.0,
+ p: 1.0,
+ }
+ } else {
+ Primitive2d {
+ rho: 0.125 * scale,
+ u_r: 0.0,
+ u_x: 0.0,
+ p: 0.1,
+ }
+ }
+ },
+ )
+ .expect("nudged setup");
+ nudged.set_cfl(CFL).expect("cfl");
+ baseline.step(dt).expect("1-D step");
+ nudged.step(dt).expect("2-D step");
+ assert!(
+ (0..N_X).any(|i| nudged.cell(0, i).rho != baseline.cells()[i].rho),
+ "a 1e-12 perturbation did not change the answer, so the bit-identity check above \
+ is not measuring anything"
+ );
+
+ // Non-vacuity 2: the axisymmetric geometry is genuinely different physics.
+ // A radial gradient, so the geometric source has something to act on.
+ let mut axi = Euler2d::from_fn(
+ IdealGas::AIR,
+ Geometry::Axisymmetric,
+ [
+ Boundary2d::Axis,
+ Boundary2d::Transmissive,
+ Boundary2d::Transmissive,
+ Boundary2d::Transmissive,
+ ],
+ 0.0,
+ 0.0,
+ 1e-2,
+ 1e-2,
+ 16,
+ 16,
+ |r, _| Primitive2d {
+ rho: 1.0,
+ u_r: 0.0,
+ u_x: 0.0,
+ p: if r < 4e-2 { 10.0 } else { 1.0 },
+ },
+ )
+ .expect("axi setup");
+ let mut plane = Euler2d::from_fn(
+ IdealGas::AIR,
+ Geometry::Planar,
+ [
+ Boundary2d::Reflective,
+ Boundary2d::Transmissive,
+ Boundary2d::Transmissive,
+ Boundary2d::Transmissive,
+ ],
+ 0.0,
+ 0.0,
+ 1e-2,
+ 1e-2,
+ 16,
+ 16,
+ |r, _| Primitive2d {
+ rho: 1.0,
+ u_r: 0.0,
+ u_x: 0.0,
+ p: if r < 4e-2 { 10.0 } else { 1.0 },
+ },
+ )
+ .expect("plane setup");
+ let dt = axi
+ .stable_dt()
+ .expect("dt")
+ .min(plane.stable_dt().expect("dt"))
+ * 0.5;
+ for _ in 0..20 {
+ axi.step(dt).expect("axi step");
+ plane.step(dt).expect("plane step");
+ }
+ let spread: f64 = (0..16)
+ .map(|j| (axi.cell(j, 8).rho - plane.cell(j, 8).rho).abs() / plane.cell(j, 8).rho)
+ .fold(0.0, f64::max);
+ assert!(
+ spread > 1e-3,
+ "axisymmetric and planar geometries agree to {spread:.3e} on a radially structured \
+ problem, so the geometric source is not doing anything and leg 1 above proves \
+ nothing about it"
+ );
+}
+
+// ---------------------------------------------------------------------------
+// M6d gates G10 + G11 + G12 — the axisymmetric solver against closed forms
+// (docs/M6D_SPEC.md).
+//
+// All three are **verification**. G10 is the repo's first multidimensional
+// anchor: a self-similar solution the model is not built from, which drives
+// both sweeps, the geometric source and the axis at once. G11 and G12 are the
+// numerical properties the scheme must have for G10 to mean anything.
+// ---------------------------------------------------------------------------
+
+/// Half-space Sedov setup: a point blast at the origin of the `(r, x)` quarter
+/// plane, with the axis at `r = 0` and a symmetry plane at `x = 0`.
+///
+/// The computational domain is a half-space, so a *spherical* blast of total
+/// energy `E` deposits `E/2` here. In the solver's `r dr dx` measure that is
+/// `E/(4π)` of excess `total_energy`.
+fn sedov_setup(n: usize, extent: f64, e_full: f64, rho_0: f64, blast_cells: usize) -> Euler2d {
+ let h = extent / n as f64;
+ let p_ambient = 1e-6;
+ let blast_radius = blast_cells as f64 * h;
+ // Excess energy density inside the seeded sphere, in the r dr dx measure.
+ let seeded_volume = 2.0 / 3.0 * blast_radius.powi(3) / 2.0; // ∫∫ r dr dx over the octant sphere
+ let e_density = e_full / (4.0 * std::f64::consts::PI) / seeded_volume;
+ Euler2d::from_fn(
+ IdealGas::AIR,
+ Geometry::Axisymmetric,
+ [
+ Boundary2d::Axis,
+ Boundary2d::Transmissive,
+ Boundary2d::Reflective,
+ Boundary2d::Transmissive,
+ ],
+ 0.0,
+ 0.0,
+ h,
+ h,
+ n,
+ n,
+ |r, x| {
+ let inside = (r * r + x * x).sqrt() < blast_radius;
+ Primitive2d {
+ rho: rho_0,
+ u_r: 0.0,
+ u_x: 0.0,
+ p: if inside {
+ (IdealGas::AIR.gamma - 1.0) * e_density
+ } else {
+ p_ambient
+ },
+ }
+ },
+ )
+ .expect("sedov setup")
+}
+
+/// Front radius along the `x` axis: the outermost crossing of `level·ρ₀`,
+/// linearly interpolated.
+///
+/// The level is fixed rather than adaptive, and that was measured rather than
+/// assumed. A level that tracks the local peak drifts as the blast develops —
+/// the peak climbs while the shock sharpens — and a time-dependent locator
+/// distorts a fitted power law: measured 0.372 against the exact 0.400. A fixed
+/// level does not move, so its bias is a near-constant offset that the fit
+/// absorbs; the same run then reads 0.399.
+///
+/// The cost is that the level must sit below the smeared peak at *every*
+/// sampled time, which is why the exponent is fitted on the finer grid only.
+fn sedov_shock_radius(s: &Euler2d, rho_0: f64, level: f64) -> Option {
+ let target = level * rho_0;
+ let rho = |i: usize| s.cell(0, i).to_primitive(&s.gas()).rho;
+ for i in (1..s.n_x()).rev() {
+ if rho(i - 1) >= target && rho(i) < target {
+ let (a, b) = (rho(i - 1), rho(i));
+ let w = (a - target) / (a - b);
+ return Some(s.x_centre(i - 1) + w * s.dx());
+ }
+ }
+ None
+}
+
+/// **G10 — the Sedov–Taylor point blast (verification).**
+///
+/// The repo's **first multidimensional verification anchor**. A point release of
+/// energy into a uniform gas has no length or time scale, so the blast is
+/// self-similar; the reference in `validate::SedovBlast` integrates that
+/// solution and derives `ξ₀` from its own energy integral, so nothing external
+/// is quoted (see that type's unit tests, including the one that puts the
+/// profile back into the Euler PDEs).
+///
+/// It is the only problem in this milestone that drives **both sweeps, the
+/// geometric source and the axis at once**.
+///
+/// Three legs, in decreasing order of how much they constrain:
+///
+/// 1. **Exponent** `R ∝ t^(2/5)`, fitted in log-log. Parameter-free — no `ξ₀`
+/// enters — and much the tightest thing here.
+/// 2. **Level** against `ξ₀`, *and* the requirement that the discrepancy
+/// **falls under refinement**. The second half is what makes this a
+/// verification statement rather than a tolerance someone picked: a finite
+/// seeded sphere and a smeared shock both bias the radius high, and both
+/// must vanish with resolution.
+/// 3. **Jump**, likewise as a trend. The Sedov density peak is a spike one or
+/// two cells wide at any affordable resolution, so the measured compression
+/// is far below the strong-shock `(γ+1)/(γ−1) = 6`; what is gated is that it
+/// climbs toward it and never exceeds it.
+///
+/// Measured, `E` = 1 J into `ρ₀` = 1 kg/m³ over a 1 m half-space:
+///
+/// | n | R/R_sedov | peak ρ/ρ₀ |
+/// |-----|-----------|-----------|
+/// | 48 | 1.0976 | 2.089 |
+/// | 96 | 1.0842 | 2.616 |
+/// | 192 | 1.0587 | 3.020 |
+///
+/// with a fitted exponent of **0.38628** against the exact 0.400 at n = 96. The
+/// n = 192 row is recorded out of band; CI runs the first two, the cheapest
+/// pair that still measures a trend. Both the level and the peak converge the
+/// right way and neither is close to converged — that is the honest state of a
+/// spherical blast at a resolution the suite can afford, and it is why two of
+/// the three legs gate the trend rather than the value.
+#[test]
+fn sedov_blast_matches_the_self_similar_solution() {
+ const EXTENT: f64 = 1.0;
+ const E_FULL: f64 = 1.0;
+ const RHO_0: f64 = 1.0;
+ const T_END: f64 = 0.06;
+ /// Density level marking the front, in units of `ρ₀`. Below the smeared
+ /// peak at every sampled time on the fine grid, above the ambient.
+ const LEVEL: f64 = 2.0;
+
+ let reference = SedovBlast::new(IdealGas::AIR.gamma, 3, E_FULL, RHO_0).expect("reference");
+
+ /// Peak compression along the axis.
+ fn peak(s: &Euler2d, rho_0: f64) -> f64 {
+ (0..s.n_x())
+ .map(|i| s.cell(0, i).to_primitive(&s.gas()).rho / rho_0)
+ .fold(0.0, f64::max)
+ }
+
+ // The fine run carries the trajectory, so the exponent is fitted where the
+ // front is resolved well enough for a fixed level to find it throughout.
+ let mut fine = sedov_setup(96, EXTENT, E_FULL, RHO_0, 3);
+ fine.set_cfl(0.4).expect("cfl");
+ let samples = 6;
+ let (t_start, mut times, mut radii) = (0.02, Vec::new(), Vec::new());
+ for k in 0..samples {
+ let t = t_start + (T_END - t_start) * k as f64 / (samples - 1) as f64;
+ fine.advance_to(t).expect("advance");
+ times.push(t);
+ radii.push(
+ sedov_shock_radius(&fine, RHO_0, LEVEL)
+ .expect("no front on the fine grid — the blast has not developed"),
+ );
+ }
+ let exponent = loglog_slope_xy(×, &radii).expect("slope");
+ let ratio = radii[samples - 1] / reference.shock_radius(T_END);
+ let fine_peak = peak(&fine, RHO_0);
+
+ // The coarse run is only asked for the endpoint, which is all the two trend
+ // legs need and a quarter of the cost.
+ let mut coarse = sedov_setup(48, EXTENT, E_FULL, RHO_0, 3);
+ coarse.set_cfl(0.4).expect("cfl");
+ coarse.advance_to(T_END).expect("advance");
+ let coarse_ratio = sedov_shock_radius(&coarse, RHO_0, LEVEL)
+ .expect("no front on the coarse grid")
+ / reference.shock_radius(T_END);
+ let coarse_peak = peak(&coarse, RHO_0);
+
+ println!(
+ "MEAS exponent={exponent:.5} ratio48={coarse_ratio:.4} ratio96={ratio:.4} peak48={coarse_peak:.3} peak96={fine_peak:.3}"
+ );
+ // Leg 1 — the parameter-free exponent.
+ assert!(
+ (exponent - 0.4).abs() < 0.02,
+ "Sedov similarity exponent {exponent:.5} against the exact 2/5. No ξ₀ and no \
+ fitted constant enter this leg, so a miss here is the solver, not the reference. \
+ Radii {radii:?}"
+ );
+
+ // Leg 2 — the level, and the requirement that it be converging.
+ assert!(
+ (ratio - 1.0).abs() < 0.10,
+ "shock radius is {ratio:.4}x the self-similar solution (ξ₀ = {:.5})",
+ reference.xi_0()
+ );
+ assert!(
+ (ratio - 1.0).abs() < (coarse_ratio - 1.0).abs(),
+ "the level error did not fall under refinement ({coarse_ratio:.4} at n = 48 → \
+ {ratio:.4} at n = 96), so it is not discretization — energy is being lost, or the \
+ seeded sphere is too large a fraction of the domain"
+ );
+
+ // Leg 3 — the strong-shock jump, likewise as a trend.
+ let jump = reference.density_jump();
+ assert!(
+ fine_peak > coarse_peak,
+ "peak compression did not rise under refinement ({coarse_peak:.3} at n = 48 → \
+ {fine_peak:.3} at n = 96); a smeared shock must sharpen when the mesh does"
+ );
+ assert!(
+ fine_peak < 1.02 * jump,
+ "peak compression {fine_peak:.3} exceeds the strong-shock limit {jump:.3}, which \
+ no amount of resolution should allow"
+ );
+}
+
+/// A smooth, radially structured, axially uniform state — the cheap problem
+/// G11 refines.
+fn smooth_radial_blob_geom(n_r: usize, extent: f64, geom: Geometry) -> Euler2d {
+ let h = extent / n_r as f64;
+ Euler2d::from_fn(
+ IdealGas::AIR,
+ geom,
+ [
+ Boundary2d::Axis,
+ Boundary2d::Reflective,
+ Boundary2d::Reflective,
+ Boundary2d::Reflective,
+ ],
+ 0.0,
+ 0.0,
+ h,
+ extent / 4.0,
+ n_r,
+ 4,
+ |r, _| {
+ // A small perturbation on purpose. At 30 % the pulse steepens into
+ // a shock well before the end of the run and the measured order
+ // collapses to first — 0.52 / 0.94, which is what a limiter does at
+ // a discontinuity, not what the scheme does on smooth flow.
+ let s = (r - 0.35 * extent) / (0.18 * extent);
+ let bump = 0.02 * (-s * s).exp();
+ Primitive2d {
+ rho: 1.0 + bump,
+ u_r: 0.0,
+ u_x: 0.0,
+ p: 1.0 + 1.4 * bump,
+ }
+ },
+ )
+ .expect("blob setup")
+}
+
+/// Density profile of the axially-uniform smooth run at `t_end`, sampled on the
+/// ring centres, for `n_r` rings — optionally with the geometric source split
+/// out of the sweep (the first-order contrast).
+fn smooth_blob_profile_geom(
+ n_r: usize,
+ t_end: f64,
+ well_balanced: bool,
+ geom: Geometry,
+) -> Vec {
+ const EXTENT: f64 = 1.0;
+ const CFL: f64 = 0.4;
+ let mut s = smooth_radial_blob_geom(n_r, EXTENT, geom);
+ s.set_cfl(CFL).expect("cfl");
+ // Refine dt with dr: a fixed CFL ties them, which is what makes the
+ // measured order the order of the whole scheme rather than of the sweep.
+ while s.time() < t_end {
+ let dt = s.stable_dt().expect("dt").min(t_end - s.time());
+ if dt <= 0.0 {
+ break;
+ }
+ if well_balanced {
+ s.step(dt).expect("step");
+ } else {
+ s.step_with_split_geometric_source_for_gate_contrast(dt)
+ .expect("contrast step");
+ }
+ }
+ (0..s.n_r())
+ .map(|j| s.cell(j, 2).to_primitive(&s.gas()).rho)
+ .collect()
+}
+
+/// Conservatively restrict a fine profile onto a coarse ring grid.
+fn restrict_rings(fine: &[f64], coarse_len: usize) -> Vec {
+ let factor = fine.len() / coarse_len;
+ (0..coarse_len)
+ .map(|j| fine[j * factor..(j + 1) * factor].iter().sum::() / factor as f64)
+ .collect()
+}
+
+/// Observed orders of the smooth axisymmetric run, coarse → fine.
+fn smooth_blob_orders_geom(well_balanced: bool, geom: Geometry) -> Vec {
+ const T_END: f64 = 0.08;
+ const REFERENCE: usize = 1024;
+ let reference = smooth_blob_profile_geom(REFERENCE, T_END, well_balanced, geom);
+ let errors: Vec = [64usize, 128, 256]
+ .iter()
+ .map(|&n| {
+ let coarse = smooth_blob_profile_geom(n, T_END, well_balanced, geom);
+ let target = restrict_rings(&reference, n);
+ coarse
+ .iter()
+ .zip(&target)
+ .map(|(a, b)| (a - b).abs())
+ .sum::()
+ / n as f64
+ })
+ .collect();
+ errors
+ .windows(2)
+ .map(|w| beamprop::validate::observed_order(w[0], w[1]))
+ .collect()
+}
+
+/// **G11 — 2nd order on smooth axisymmetric flow (verification).**
+///
+/// Self-convergence against a fine reference, refining `Δr`, `Δx` and `Δt`
+/// together at fixed CFL, so the number measured is the order of the whole
+/// scheme and not of one sweep in isolation.
+///
+/// The problem is deliberately **r-structured and x-uniform** with only four
+/// axial cells. It still exercises everything M6d adds — the radial sweep, the
+/// axis, the geometric source — while the 8× reference costs 8× rather than
+/// 64×. The axial sweep's order is already M6c's G2, on unchanged code.
+///
+/// **Non-vacuity is mandatory here**, mirroring G2b. A measurement too coarse to
+/// resolve 1st from 2nd order would pass silently, so the same study is run with
+/// the geometric source split out of the sweep and required to read ≈1.
+///
+/// Measured: **1.861 / 1.964** for the real scheme against **1.030 / 1.155**
+/// for the split-source contrast.
+///
+/// **This gate found a real defect and is the reason the milestone has one.**
+/// The Hancock predictor originally built its geometric source from the
+/// *reconstructed face* pressures rather than the cell pressure. That is
+/// well-balanced — a uniform state still sits still, so every fixed-point check
+/// passed — but expanded against the flux difference the pressure terms cancel
+/// identically, deleting the pressure gradient from the predictor. It measured
+/// 0.86 / 1.12 where the same problem in planar geometry gave 1.71 / 1.89, and
+/// that gap between the two geometries is what localised it.
+#[test]
+fn euler2d_is_second_order_on_smooth_axisymmetric_flow() {
+ println!(
+ "PLANAR {:?}",
+ smooth_blob_orders_geom(true, Geometry::Planar)
+ );
+ println!(
+ "AXISYM {:?}",
+ smooth_blob_orders_geom(true, Geometry::Axisymmetric)
+ );
+ let orders = smooth_blob_orders_geom(true, Geometry::Axisymmetric);
+ for (k, &p) in orders.iter().enumerate() {
+ assert!(
+ p > 1.7,
+ "observed order {p:.3} at refinement pair {k} is below 2nd order; the full \
+ ladder is {orders:?}"
+ );
+ assert!(
+ p < 2.4,
+ "observed order {p:.3} at refinement pair {k} is above 2nd order, which \
+ usually means the reference is not converged: {orders:?}"
+ );
+ }
+
+ let contrast = smooth_blob_orders_geom(false, Geometry::Axisymmetric);
+ let worst = contrast.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
+ assert!(
+ worst < 1.5,
+ "the deliberately first-order contrast (geometric source split out of the sweep) \
+ measured orders {contrast:?}, which is not distinguishable from the real scheme's \
+ {orders:?}. Without that separation this gate proves nothing"
+ );
+}
+
+/// **G12 — conservation in the `r`-weighted measure (verification).**
+///
+/// M6c's G5 in two dimensions, plus the leg that makes the accounting provable
+/// rather than assumed.
+///
+/// - **Closed box, no source.** Mass and energy constant to round-off in the
+/// discrete `r dr dx` measure; axial momentum too. **Radial momentum is
+/// deliberately *not* conserved** — the geometric source is a real term, and
+/// an implementation that conserved it would be wrong. This is the leg a naive
+/// `−G/r` source fails.
+/// - **Closed box with a volumetric energy source.** `ΔE` equals what was
+/// deposited, to round-off.
+/// - **Open box.** With the outer wall far from the disturbance nothing escapes
+/// and the budget closes on its own; with the wall brought in, the budget
+/// closes **only** when the escape flux is included. That contrast is what
+/// proves `escaped_energy` is measuring the right thing.
+#[test]
+fn euler2d_conserves_mass_and_energy_in_the_r_weighted_measure() {
+ let closed = |source: f64| -> (f64, f64, f64, f64) {
+ let mut s = Euler2d::from_fn(
+ IdealGas::AIR,
+ Geometry::Axisymmetric,
+ [
+ Boundary2d::Axis,
+ Boundary2d::Reflective,
+ Boundary2d::Reflective,
+ Boundary2d::Reflective,
+ ],
+ 0.0,
+ 0.0,
+ 1e-3,
+ 1e-3,
+ 24,
+ 24,
+ |r, _| Primitive2d {
+ rho: 1.2256,
+ u_r: 0.0,
+ u_x: 0.0,
+ p: if r < 6e-3 { 5e5 } else { 1e5 },
+ },
+ )
+ .expect("closed box");
+ s.set_cfl(0.4).expect("cfl");
+ let (m0, e0, px0) = (s.total_mass(), s.total_energy(), s.total_axial_momentum());
+ let mut deposited = 0.0;
+ for _ in 0..40 {
+ let dt = s.stable_dt().expect("dt");
+ s.step(dt).expect("step");
+ if source > 0.0 {
+ s.add_energy(dt, |_, _, _, _| source).expect("source");
+ // ∫ q r dr dx over the whole domain.
+ deposited += dt * source * s.total_mass() / 1.2256;
+ }
+ }
+ (
+ (s.total_mass() - m0).abs() / m0,
+ (s.total_energy() - e0 - deposited).abs() / e0,
+ (s.total_axial_momentum() - px0).abs() / m0,
+ s.total_radial_momentum().abs(),
+ )
+ };
+
+ let (mass, energy, px, pr) = closed(0.0);
+ assert!(mass < 1e-13, "mass drift {mass:.3e} in a closed box");
+ assert!(energy < 1e-13, "energy drift {energy:.3e} in a closed box");
+ assert!(px < 1e-12, "axial momentum drift {px:.3e} in a closed box");
+ assert!(
+ pr > 0.0,
+ "radial momentum is exactly conserved, which means the geometric source is not \
+ being applied at all — the axisymmetric solver is silently planar"
+ );
+
+ let (_, energy_src, _, _) = closed(1e7);
+ assert!(
+ energy_src < 1e-12,
+ "energy budget with a volumetric source closes only to {energy_src:.3e}"
+ );
+
+ // The escape leg: same disturbance, transmissive outer wall, near and far.
+ let open = |n_r: usize| -> (f64, f64) {
+ let mut s = Euler2d::from_fn(
+ IdealGas::AIR,
+ Geometry::Axisymmetric,
+ [
+ Boundary2d::Axis,
+ Boundary2d::Transmissive,
+ Boundary2d::Reflective,
+ Boundary2d::Reflective,
+ ],
+ 0.0,
+ 0.0,
+ 1e-3,
+ 1e-3,
+ n_r,
+ 16,
+ |r, _| Primitive2d {
+ rho: 1.2256,
+ u_r: 0.0,
+ u_x: 0.0,
+ p: if r < 4e-3 { 5e5 } else { 1e5 },
+ },
+ )
+ .expect("open box");
+ s.set_cfl(0.4).expect("cfl");
+ let e0 = s.total_energy();
+ for _ in 0..60 {
+ let dt = s.stable_dt().expect("dt");
+ s.step(dt).expect("step");
+ }
+ let naive = (s.total_energy() - e0).abs() / e0;
+ let accounted = (s.total_energy() + s.escaped_energy() - e0).abs() / e0;
+ (naive, accounted)
+ };
+
+ let (far_naive, far_accounted) = open(64);
+ assert!(
+ far_naive < 1e-10 && far_accounted < 1e-10,
+ "with the wall far away nothing should escape, but the naive budget is \
+ {far_naive:.3e} and the accounted one {far_accounted:.3e}"
+ );
+
+ let (near_naive, near_accounted) = open(12);
+ assert!(
+ near_naive > 1e-4,
+ "with the wall close in, energy must visibly leave the domain, but the raw budget \
+ only moved by {near_naive:.3e} — the escape leg is not testing anything"
+ );
+ assert!(
+ near_accounted < 1e-10,
+ "energy left the domain ({near_naive:.3e} of it) and the escape accounting \
+ recovered only to {near_accounted:.3e}"
+ );
+}
+
+/// Largest relative jump in entropy `p/ρ^γ` between the first two rings.
+///
+/// By symmetry the radial derivative of every scalar vanishes at `r = 0`, so a
+/// correct axis leaves `S(ring 0) ≈ S(ring 1)` to `O(Δr²)` however violent the
+/// flow. A mis-signed ghost breaks that symmetry directly, and entropy is the
+/// variable that shows it: a genuine converging shock raises pressure and
+/// density together, while wall heating raises one without the other.
+fn on_axis_entropy_defect(s: &Euler2d, gamma: f64) -> f64 {
+ let entropy = |j: usize, i: usize| {
+ let w = s.cell(j, i).to_primitive(&s.gas());
+ w.p / w.rho.powf(gamma)
+ };
+ (0..s.n_x())
+ .map(|i| (entropy(0, i) - entropy(1, i)).abs() / entropy(1, i))
+ .fold(0.0, f64::max)
+}
+
+/// **G13 — the axis is not a wall (verification).**
+///
+/// Leg (i) lives in `euler2d`'s own unit tests: a uniform state is a
+/// **bit-exact** fixed point of the axisymmetric operator, which is
+/// well-balancedness in one assertion. This is leg (ii), on a wave that
+/// converges on the axis, focuses, and re-expands — the flow that actually
+/// stresses `r = 0`.
+///
+/// The failure this guards against is the classic one, and it matters more here
+/// than in a generic code: a mis-signed axis ghost produces a thin hot column on
+/// `r = 0` that grows with time and looks entirely physical — and `r = 0` is
+/// exactly where M6d's headline number, the on-axis LSD front speed, is
+/// measured. An artifact there would sit underneath the result.
+///
+/// **The Sedov run is deliberately not used for this.** Its interior is a
+/// near-vacuum and its disturbed gas is a thin shell at the front, where
+/// entropy jumps by orders of magnitude across a cell; the ring-to-ring
+/// difference in where the discrete shock sits then swamps everything. Measured
+/// that way the defect came out at 0.70 — and the *broken*-parity run scored
+/// **lower**, at 0.57, which is how the measure was found to be looking at the
+/// shock rather than at the axis.
+///
+/// Two requirements, because a small number on its own proves nothing:
+///
+/// 1. The defect **falls under radial refinement** — discretisation, not a
+/// boundary condition.
+/// 2. A deliberately **even-parity** ghost fill must break it loudly.
+///
+/// Measured: **2.99e-7** at n = 64 → **3.12e-8** at n = 128, a factor of 9.6 for
+/// a factor of 2 in resolution, against **3.02e-6** for the even-parity
+/// contrast at n = 64 — an order of magnitude clear of the correct fill.
+#[test]
+fn the_axis_boundary_does_not_heat_or_starve_the_on_axis_cells() {
+ const EXTENT: f64 = 1.0;
+ /// Long enough for the inward half of the pulse to reach the axis, focus,
+ /// and start back out — which is when the axis is under the most stress.
+ const T_END: f64 = 0.45;
+ let gamma = IdealGas::AIR.gamma;
+
+ let run = |n: usize, correct_parity: bool| -> f64 {
+ let mut s = smooth_radial_blob_geom(n, EXTENT, Geometry::Axisymmetric);
+ s.set_cfl(0.4).expect("cfl");
+ while s.time() < T_END {
+ let dt = s.stable_dt().expect("dt").min(T_END - s.time());
+ if dt <= 0.0 {
+ break;
+ }
+ if correct_parity {
+ s.step(dt).expect("step");
+ } else {
+ s.step_with_even_parity_axis_for_gate_contrast(dt)
+ .expect("contrast step");
+ }
+ }
+ on_axis_entropy_defect(&s, gamma)
+ };
+
+ let coarse = run(64, true);
+ let fine = run(128, true);
+ let broken = run(64, false);
+ assert!(
+ fine < coarse,
+ "the on-axis entropy defect did not fall under refinement ({coarse:.3e} at \
+ n = 64 → {fine:.3e} at n = 128), so it is a boundary condition rather than \
+ discretisation"
+ );
+ assert!(
+ broken > 5.0 * coarse,
+ "an even-parity axis ghost fill produced a defect of {broken:.3e} against the \
+ correct fill's {coarse:.3e}. They are not separated, so this gate cannot tell a \
+ working axis from a broken one and proves nothing"
+ );
+}
+
+// ---------------------------------------------------------------------------
+// M6d gate G14 — the wide-beam limit (docs/M6D_SPEC.md).
+//
+// Verification, and G15's non-vacuity partner. It must land first: it is what
+// proves that any deficit G15 measures is *relief*, and not an artefact of the
+// geometric source, the ring binning, or the front tracker.
+// ---------------------------------------------------------------------------
+
+/// The M6c G3 configuration in the axisymmetric solver, with a beam wider than
+/// the domain and a radially uniform seed — the planar problem embedded in
+/// cylindrical coordinates.
+///
+/// `dr = dx`, so both solvers' CFL controllers select from the same direction:
+/// with `u_r = 0` the radial signal speed is `c` and the axial one is
+/// `|u_x| + c ≥ c`.
+fn lsd2d_wide_beam_column(n_r: usize, n_x: usize, alpha: f64, seed_p: f64) -> Lsd2dColumn {
+ let dx = LSD_LENGTH / n_x as f64;
+ let wide = 1.0;
+ Lsd2dColumn::seeded(
+ IdealGas::AIR,
+ n_r,
+ n_x,
+ n_r as f64 * dx,
+ LSD_LENGTH,
+ Primitive2d {
+ rho: LSD_AMBIENT.rho,
+ u_r: 0.0,
+ u_x: 0.0,
+ p: LSD_AMBIENT.p,
+ },
+ SeededIgnition2d {
+ centre_x: 0.72 * LSD_LENGTH,
+ width_x: 6e-4,
+ radius: wide,
+ pressure: seed_p,
+ },
+ Absorption::GreyThreshold {
+ alpha,
+ e_ignite: LSD_E_IGNITE,
+ },
+ LSD_INTENSITY,
+ BeamProfile::TopHat { radius: wide },
+ )
+ .expect("2-D LSD setup")
+}
+
+/// Largest `|u_r|` anywhere in the domain (m/s).
+fn worst_radial_velocity(column: &Lsd2dColumn) -> f64 {
+ let gas = column.hydro().gas();
+ (0..column.hydro().n_r())
+ .flat_map(|j| (0..column.hydro().n_x()).map(move |i| (j, i)))
+ .map(|(j, i)| column.hydro().cell(j, i).to_primitive(&gas).u_r.abs())
+ .fold(0.0, f64::max)
+}
+
+/// The seed pressure M6c's G3 uses.
+fn lsd_seed_pressure() -> f64 {
+ let gas = IdealGas::AIR;
+ let d_cj = raizer_lsd_velocity(&gas, LSD_INTENSITY, LSD_AMBIENT.rho);
+ LSD_SEED_MULTIPLE * LSD_AMBIENT.rho * d_cj * d_cj / (gas.gamma + 1.0)
+}
+
+/// **G14 — the wide-beam limit reproduces the 1-D column (verification).**
+///
+/// A beam wider than the domain, a radially uniform seed, and axisymmetric
+/// geometry: relief has nothing to act on, so the axisymmetric column must
+/// reproduce M6c's planar answer.
+///
+/// **This is the gate that makes G15 mean anything.** A deficit is only
+/// evidence of radial relief if the solver returns the 1-D answer when there is
+/// no relief to be had.
+///
+/// # The agreement window is short, and why is the milestone's main result
+///
+/// The two solvers agree to **3.1e-13** for the first ~300 steps and then
+/// diverge exponentially, reaching 3 % by M6c's 1.8 µs settle. That is not a
+/// defect, and three measurements say so:
+///
+/// - It is **identical, bit for bit, in planar and axisymmetric geometry**, so
+/// the geometric source is not producing it.
+/// - It is **amplitude-proportional**: seeding a transverse perturbation 10⁶×
+/// larger than round-off produces a response 10⁶× larger at early times
+/// (`|u_r|` = 5.8e-7 against 7.5e-13 at t = 10 ns).
+/// - It **saturates**, at `|u_r|` ≈ 200–400 m/s whatever the seed.
+///
+/// Linear growth to nonlinear saturation, seeded by whatever asymmetry is
+/// available, is a **transverse detonation instability** — the mechanism behind
+/// the cellular structure real detonations have. The model has it. A planar
+/// solver cannot, which is why M6c never saw it.
+///
+/// So this gate asserts what the wide-beam limit actually supports: exact
+/// agreement while the front is still smooth, and the instability's presence
+/// afterwards. `check_regime` is deliberately not called — a beam wider than
+/// the domain is outside the regime it defines, and that is the point.
+#[test]
+fn lsd2d_with_a_full_width_beam_reproduces_the_one_dimensional_column() {
+ const N_X: usize = 2_500;
+ const N_R: usize = 6;
+ const ALPHA: f64 = 2e4;
+ /// Before the transverse instability has grown out of round-off. Measured:
+ /// the two pressure fields agree to 3.1e-13 here, and to 2.7e-4 by 1 µs.
+ const SMOOTH_SETTLE: f64 = 3e-7;
+
+ let gas = IdealGas::AIR;
+ let seed_p = lsd_seed_pressure();
+
+ let mut one = LsdColumn::seeded(
+ gas,
+ N_X,
+ LSD_LENGTH,
+ LSD_AMBIENT,
+ SeededIgnition {
+ centre: 0.72 * LSD_LENGTH,
+ width: 6e-4,
+ pressure: seed_p,
+ },
+ Absorption::GreyThreshold {
+ alpha: ALPHA,
+ e_ignite: LSD_E_IGNITE,
+ },
+ LSD_INTENSITY,
+ )
+ .expect("1-D setup");
+ let mut two = lsd2d_wide_beam_column(N_R, N_X, ALPHA, seed_p);
+
+ one.advance_to(SMOOTH_SETTLE).expect("1-D settle");
+ two.advance_to(SMOOTH_SETTLE).expect("2-D settle");
+
+ let p1: Vec = one.hydro().primitives().iter().map(|w| w.p).collect();
+ let worst = (0..N_X)
+ .map(|i| {
+ let p2 = two.hydro().cell(0, i).to_primitive(&gas).p;
+ (p1[i] - p2).abs() / p1[i]
+ })
+ .fold(0.0, f64::max);
+ assert!(
+ worst < 1e-11,
+ "wide-beam axisymmetric and 1-D pressure fields differ by {worst:.3e} at \
+ t = {SMOOTH_SETTLE:.1e} s, before any instability has grown. With no relief to \
+ measure these must agree; a difference here means the geometric source, the \
+ ring binning or the front tracker is doing something, and every number G15 \
+ reports would inherit it"
+ );
+
+ let u_r_smooth = worst_radial_velocity(&two);
+ assert!(
+ u_r_smooth < 1e-9,
+ "the radially uniform run already carries |u_r| = {u_r_smooth:.3e} m/s while the \
+ front is still smooth"
+ );
+
+ // And the instability is real: it must actually be there later, or the
+ // short window above is a tolerance dodge rather than a physical statement.
+ two.advance_to(1.8e-6).expect("2-D to the M6c settle");
+ let u_r_late = worst_radial_velocity(&two);
+ assert!(
+ u_r_late > 1.0,
+ "no transverse structure had developed by 1.8 µs (|u_r| = {u_r_late:.3e} m/s), so \
+ the short comparison window above is unexplained rather than physical"
+ );
+
+ assert!(
+ two.energy_residual() < 1e-10,
+ "energy budget closes only to {:.3e}",
+ two.energy_residual()
+ );
+}
+
+// ---------------------------------------------------------------------------
+// M6d gates G15 + G16 — radial relief (docs/M6D_SPEC.md).
+//
+// G15 is the milestone's headline and the only **pinned** row it adds: a known
+// departure from the closed form, asserted green so its size cannot drift.
+// G16 upgrades M6c's strongest claim from an argument to a measurement.
+// ---------------------------------------------------------------------------
+
+/// Geometry of the relief runs.
+///
+/// Deliberately a **short** column, and that follows from the physics rather
+/// than from impatience. Relief bites when the transverse relief time `R_b/c₁`
+/// is comparable to the time the gas spends in the absorption zone, which puts
+/// `R_b` within a few absorption lengths — tens of microns — so relief
+/// equilibrates in tens of nanoseconds, two orders below M6c's 1.8 µs settle.
+/// The wave therefore needs to run only far enough to be measured.
+const RELIEF_LENGTH: f64 = 5e-3;
+const RELIEF_N_X: usize = 500;
+const RELIEF_SETTLE: f64 = 3.0e-7;
+const RELIEF_WINDOW: f64 = 1.2e-7;
+/// Beam radii, in metres. `R_b·α` = 1.6 and 3.2 at the default `α` = 2e4.
+const RELIEF_R_SMALL: f64 = 8e-5;
+const RELIEF_R_LARGE: f64 = 1.6e-4;
+
+/// Settled on-axis front speed (m/s) for a beam of radius `r_b`.
+///
+/// `n_r` is set to keep the domain at three beam radii — the minimum
+/// `check_regime` allows — so relief comes from the beam being finite rather
+/// than from the wall.
+fn relief_front_speed(r_b: f64, n_r: usize, intensity: f64, settle: f64, window: f64) -> f64 {
+ let gas = IdealGas::AIR;
+ let dx = RELIEF_LENGTH / RELIEF_N_X as f64;
+ let d_cj = raizer_lsd_velocity(&gas, intensity, LSD_AMBIENT.rho);
+ let mut column = Lsd2dColumn::seeded(
+ gas,
+ n_r,
+ RELIEF_N_X,
+ n_r as f64 * dx,
+ RELIEF_LENGTH,
+ Primitive2d {
+ rho: LSD_AMBIENT.rho,
+ u_r: 0.0,
+ u_x: 0.0,
+ p: LSD_AMBIENT.p,
+ },
+ SeededIgnition2d {
+ centre_x: 0.80 * RELIEF_LENGTH,
+ width_x: 6e-4,
+ // Radially uniform, so the initial condition carries no radial
+ // structure at all and every deficit measured develops from the
+ // beam's finite width.
+ radius: 1.0,
+ pressure: LSD_SEED_MULTIPLE * LSD_AMBIENT.rho * d_cj * d_cj / (gas.gamma + 1.0),
+ },
+ Absorption::GreyThreshold {
+ alpha: 2e4,
+ e_ignite: LSD_E_IGNITE,
+ },
+ intensity,
+ BeamProfile::TopHat { radius: r_b },
+ )
+ .expect("relief setup");
+ column.advance_to(settle).expect("settle");
+ column.measure_front_speed(window).expect("front speed")
+}
+
+/// The same run with a beam wider than the domain — the reference every deficit
+/// is measured against.
+fn relief_reference_speed(intensity: f64, settle: f64, window: f64) -> f64 {
+ relief_front_speed(1.0, 24, intensity, settle, window)
+}
+
+/// **G15 — radial relief lowers the front speed by a pinned amount (PINNED).**
+///
+/// The milestone's headline, and the reason it exists. M6c's G7 is ungated, and
+/// `docs/M6C_SPEC.md` justifies that with one omission: "a planar 1-D code has
+/// **no radial relief** […] it is the one effect the geometry has removed by
+/// assumption". Here the beam has a radius, the shocked gas escapes across it,
+/// and the front slows down. This measures by how much.
+///
+/// # The reference is the wide-beam 2-D run, not the 1-D column
+///
+/// That is not a convenience. G14 established that the modelled front is
+/// **transversely unstable** — it develops cellular structure out of round-off
+/// and diverges from the 1-D answer by 3 % at M6c's settle, with no relief
+/// present at all. Measuring against the 1-D column would report that
+/// instability as relief. Both runs here carry it, so it is common-mode and
+/// cancels; what is left is the beam being finite.
+///
+/// # Measured
+///
+/// | `R_b` | `R_b·α` | `δ = 1 − D/D_wide` | where |
+/// |-------|---------|--------------------|-------|
+/// | 8e-5 | 1.6 | 0.30516 | gated |
+/// | 1.2e-4| 2.4 | 0.274 | out of band |
+/// | 1.6e-4| 3.2 | 0.23009 | gated |
+/// | 2.4e-4| 4.8 | 0.193 | out of band |
+///
+/// The two out-of-band rows were measured on the finer `Δx` = 10 µm grid and
+/// fill in the trend; CI runs the two that bracket it. Wide-beam reference
+/// `D` = 5339.4 m/s against Raizer's 5390.7 — the limit still reproduces the
+/// closed form to 1 %, which is what makes the deficit attributable to the
+/// beam rather than to the solver.
+///
+/// # Why a band and not a digit
+///
+/// `δ` is a large, robust effect whose precise value carries a systematic
+/// uncertainty of about ±13 %, measured rather than guessed:
+///
+/// - **grid**: 0.2394 at `Δx` = 10 µm → 0.2537 at 5 µm (+6 %), not converged;
+/// - **seed**: 0.2394 at a 2× CJ-pressure seed → 0.2238 at 1× (−7 %), and
+/// 0.2004 for a seed matching the beam rather than spanning the domain;
+/// - **ignition threshold**: 0.2279 → 0.2394 → 0.2582 over `e_ignite`
+/// 1 → 2 → 4 MJ/kg (±8 % over a 4× sweep).
+///
+/// So the pinned claim is the **band**, plus sign and monotonicity as
+/// predictions. Pinning a third digit would be asserting a precision three
+/// separate knobs say is not there — the M6a.2 W2 lesson, applied before the
+/// number is published rather than after.
+///
+/// **No failure radius is gated.** It is predicted and was not reached:
+/// `check_regime` requires eight cells across `R_b`, so the smallest beam
+/// affordable at this `Δr` is `R_b·α` = 1.6, where the wave is still healthy.
+/// Recorded in the spec as an open item rather than asserted.
+#[test]
+fn radial_relief_lowers_the_lsd_front_speed_by_a_pinned_amount() {
+ let d_wide = relief_reference_speed(LSD_INTENSITY, RELIEF_SETTLE, RELIEF_WINDOW);
+ let d_raizer = raizer_lsd_velocity(&IdealGas::AIR, LSD_INTENSITY, LSD_AMBIENT.rho);
+ assert!(
+ (d_wide / d_raizer - 1.0).abs() < 0.02,
+ "the wide-beam reference is {d_wide:.1} m/s against Raizer's {d_raizer:.1}; if the \
+ limit does not reproduce the closed form then no deficit measured from it is \
+ attributable to relief"
+ );
+
+ let d_small = relief_front_speed(
+ RELIEF_R_SMALL,
+ 24,
+ LSD_INTENSITY,
+ RELIEF_SETTLE,
+ RELIEF_WINDOW,
+ );
+ let d_large = relief_front_speed(
+ RELIEF_R_LARGE,
+ 48,
+ LSD_INTENSITY,
+ RELIEF_SETTLE,
+ RELIEF_WINDOW,
+ );
+ let delta_small = 1.0 - d_small / d_wide;
+ let delta_large = 1.0 - d_large / d_wide;
+ println!(
+ "MEAS d_wide={d_wide:.2} d_small={d_small:.2} d_large={d_large:.2} ds={delta_small:.5} dl={delta_large:.5}"
+ );
+
+ // Leg 1 — sign and monotonicity, as predictions rather than fits.
+ assert!(
+ delta_small > 0.0 && delta_large > 0.0,
+ "radial relief did not slow the front: δ = {delta_small:.5} at R_b = \
+ {RELIEF_R_SMALL:.1e} m and {delta_large:.5} at {RELIEF_R_LARGE:.1e} m. A finite \
+ beam cannot drive a wave harder than an infinite one"
+ );
+ assert!(
+ delta_small > delta_large,
+ "the deficit did not fall with beam radius ({delta_small:.5} at R_b = \
+ {RELIEF_R_SMALL:.1e} m against {delta_large:.5} at {RELIEF_R_LARGE:.1e} m). \
+ Monotonicity in R_b is the diameter effect, and its absence would mean the \
+ measurement is not tracking relief"
+ );
+
+ // Leg 2 — the pinned band.
+ assert!(
+ (0.16..0.32).contains(&delta_large),
+ "relief deficit δ = {delta_large:.5} at R_b·α = 3.2, outside the pinned band \
+ [0.16, 0.32]. That band is ±13 % of 0.24 and is set by the measured grid, seed \
+ and ignition-threshold sensitivities, so a value outside it is a change in the \
+ physics rather than in the knobs"
+ );
+ assert!(
+ (0.22..0.40).contains(&delta_small),
+ "relief deficit δ = {delta_small:.5} at R_b·α = 1.6, outside the pinned band \
+ [0.22, 0.40]"
+ );
+}
+
+/// **G16 — the one-third scaling survives radial relief (verification).**
+///
+/// M6c's G4 argues that relief can only enter as a *coefficient*, and that no
+/// coefficient can produce the `1/3` exponent. In M6c that was an argument —
+/// the geometry had no relief in it to test against. Here it is a measurement:
+/// sweep `S` over a decade at a fixed beam radius and check the exponent
+/// survives while the level has moved by `δ`.
+///
+/// This upgrades the strongest claim the project owns, for three runs.
+///
+/// Settle and measurement windows are expressed as **distances** and converted
+/// to times through the CJ speed, exactly as G4 does, so a slower point is not
+/// given less time to relax and the exponent is not biased by the schedule.
+///
+/// Measured: exponent **0.34666** at `R_b·α` = 3.2 over `S` ∈ [3e10, 3e11]
+/// (`D` = 2701.9, 4101.6, 6002.4 m/s), against the parameter-free 1/3 — 4 %
+/// high, while the *level* sits 23 % below the wide-beam runs. So relief moves
+/// the coefficient by a quarter and the exponent by a twenty-fifth, which is
+/// the statement G4 could only argue for.
+///
+/// The 4 % is not nothing, and it is bounded rather than explained: this runs
+/// at a quarter of G4's axial resolution and a tenth of its settle, and G4's
+/// own planar exponent is 0.33190 on a mesh four times finer. The gate is set
+/// at ±0.02 to admit that, which is wide enough to be honest and narrow enough
+/// to exclude the 1/2 or 1/4 a different balance would give.
+#[test]
+fn the_one_third_scaling_survives_radial_relief() {
+ const INTENSITIES: [f64; 3] = [3e10, 1e11, 3e11];
+ /// Settle and window as fractions of the domain the front crosses, so both
+ /// scale with the wave's own speed.
+ const SETTLE_FRACTION: f64 = 0.32;
+ const WINDOW_FRACTION: f64 = 0.13;
+
+ let speeds: Vec = INTENSITIES
+ .iter()
+ .map(|&s| {
+ let d_cj = raizer_lsd_velocity(&IdealGas::AIR, s, LSD_AMBIENT.rho);
+ let settle = SETTLE_FRACTION * RELIEF_LENGTH / d_cj;
+ let window = WINDOW_FRACTION * RELIEF_LENGTH / d_cj;
+ relief_front_speed(RELIEF_R_LARGE, 48, s, settle, window)
+ })
+ .collect();
+
+ let exponent = loglog_slope_xy(&INTENSITIES, &speeds).expect("slope");
+ assert!(
+ (exponent - 1.0 / 3.0).abs() < 0.02,
+ "with a finite beam the front speed scales as S^{exponent:.5}, against the \
+ parameter-free 1/3. M6c's G4 rests on relief entering as a coefficient; an \
+ exponent that moves when relief is switched on would falsify that, and with it \
+ the strongest claim in the project. Speeds {speeds:?}"
+ );
+}