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:?}" + ); +}