feat: LPSE with SRS (and 1d support) - #320
Conversation
- New raman.py light-wave module and SRS coupling terms in epw.py - Raman seed/light-wave config in datamodel and helpers - SRS example config (configs/envelope-2d/srs.yaml) and test - Document SRS options in lpse2d config docs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The field-save interpolators assumed >=2 transverse cells: interpax.interp2d returns NaN off a single y-node, and RegularGridInterpolator fills 0 for a single-node y axis. This blanked all real-space/k-space field artifacts (fields.xr, k-fields.xr, plots/<field>/*) for ny=1 runs while the series.xr scalars stayed valid. Add an ny==1 path that interpolates in x only and keeps the single transverse row, for both the in-solve field saver and the background-density save. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- New adept/_lpse2d/diagnostics.py: laser-budget window means (names and
definitions match osiris_lpi/laser_budget.py), EPW growth fit copied
verbatim from osiris_lpi/epw_growth.py (per-w0 rates), electron energy
as cumulative EPW dissipation.
- Default save now also logs epw_energy (OSIRIS units: fields in me*c*w0/e,
lengths in c/w0), epw_dissipation (using the solver's own Landau +
collisional rates via the new module-level landau_damping_rate),
epw_boundary_loss, and discrete two-point flux probes for the laser
budget (incident/transmitted/reflected/backrefl, normalized to I0).
Probes sit at 2*boundary_width, clear of the absorber skirt; the legacy
reflectivity probe at 1.6*bw is unchanged for back-compat.
- post_process logs these scalars as MLflow metrics (previously only
write/plot times) and adds laser-budget / EPW-fit / electron-energy plots.
- terms.epw.source.{noise_amplitude,noise_seed} are config-driven; the
resolved seed is pinned into the cfg pre-log_params so runs are exactly
reproducible. Removed the dead density.noise draws that perturbed the
global RNG stream.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
terms.light.pump_depletion: true evolves the pump with the same staggered explicit FD envelope scheme as the Raman light (new core/light.py, CoupledLight), ported from the isPumpDepletion path of lpse-matlab m201805_matlabLpse_v11.m: - pump RHS: diffraction + local detuning + depletion coupling -i e/(4 w1 me) (laplacian phi) E1 (conjugate-free, partner-frequency denominator; Manley-Rowe-consistent with the E1 and EPW couplings) - two-point boundary injector at xmin + drivers.E0.offset (default 2*bw), multi-color, MATLAB amplitude calibration - both waves advance inside one staggered real/imag update (advancing them independently would break the discrete conservation) - substep limit = min over both carriers; E0 high-k filter in the EPW SRS source on the dynamic-pump path (MATLAB skips it on the static path) - budget flux probes convert the exact discrete two-point flux to physical flux via the FD group-velocity factor sin(k_grid dx)/(k dx); metrics normalize to the measured incident flux (the injector launches sin(k0 dx)/sin(k_grid dx) ~ 0.98 of nominal amplitude at 8 cells/lambda) - default off; the prescribed-pump path is untouched (verified: the three pre-existing SRS tests pass unchanged) Tests: pump injector flux+amplitude calibration against the discrete- dispersion prediction; seeded Raman-amplifier energy-budget closure (S_left - S_right vs 2x the field-only EPW energy rates -- the kinetic sloshing half doubles the electron heating; closes to ~4%, asserted <10%); R+T+absorbed==1; depletion actually reduces transmission vs the prescribed pump; epw_energy normalization; noise-seed reproducibility. Docs: overview pump-depletion note replaced, SRS diagnostics table added, config.md new keys (terms.light, probe_offset, noise_amplitude/seed, drivers.E0.offset/turn_on_time); datamodel updated to match. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lation Add terms.hpe: tail test electrons are pushed relativistically in the de-enveloped electrostatic field Re[Ex exp(-i wp0 t)], their spatially averaged velocity distribution is accumulated by exponential moving average, and the Landau damping rate applied by SpectralEPWSolver is recomputed from that evolving distribution every step (Follett et al., Phys. Plasmas 24, 102134 (2017), Eq. 4). The feedback is Im-only, so this captures trapping-induced damping reduction and hot-electron generation but not the nonlinear frequency shift. Quasi-1D (ny == 1) only. Departures from the paper, for JAX friendliness: - Tail-only loading (|v| > v_min*vte); modes whose phase velocity falls below the cutoff keep the analytic rate, blended per k-mode. - EMA histogram as a state variable instead of interval damping updates. - Per-k calibration of the histogram -> gamma_L operator, so the initial Maxwellian tail reproduces the analytic rate exactly and binning bias cancels. Calibration lands at C(k) in [0.965, 1.013] over the band. - gamma_HPE clamped >= 0. Two details the implementation needed: the damping formula requires a sgn(kx) so each propagation direction damps on its own tail, and the gather spectrally upsamples Ex by gather_refine (default 4) because linear interpolation at k*dx ~ 1-2 rad/cell attenuates the gathered field by sinc^2(k*dx/2) -- 15-30% at SRS wavenumbers, ~1% after. Trapping resonates at the Bohm-Gross v_phi, since the envelope rotation exp(-i dw(k) t) shifts the physical wave there, so omega_res defaults to bohm_gross. Also: terms.epw.damping.landau was previously ignored (damping was unconditionally on) and is now honored, including in the dissipation diagnostic, which reads the dynamic rate from the state when HPE is on. Diagnostics gain fhot_50keV, fhot_100keV, hpe_mean_energy_keV, the tail histogram hpe_hist, and the damping-reduction ratios; MLflow metrics are named to match the OSIRIS scan2 set (t_first_hot_e_50keV, hpe_damping_reduction_final) for one-to-one comparison. The headline ratio is hpe_gamma_ratio_kpeak -- the band-min is shot-noise-limited at low n_particles. Tests in tests/test_lpse2d/test_hpe.py cover free streaming, bounce frequency and carrier sign (M0), histogram normalization and damping calibration (M1), blend/clamp behavior, linear closure (M3a), O'Neil flattening (M3b), and an end-to-end SRS smoke run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts: # docs/source/solvers/lpse2d/config.md
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
HPE: kinetic inflation via hybrid particle evolution (commit fcf7295)This branch now includes a Follett-style hybrid particle evolution (HPE) model so the envelope solver can capture kinetic inflation of SRS — trapping-induced reduction of Landau damping and the associated hot-electron generation (Follett et al., Phys. Plasmas 24, 102134 (2017), Eq. 4). What it doesEnabled with
Departures from the paper (for JAX friendliness)
Implementation details that mattered
Diagnostics & metricsNew outputs: Where things live
Tests
|
|
I'm going to look into the physics details before merging to verify correctness |
Code review — 10 findings (8 correctness, 2 cleanup)Automated deep review of this PR's diff (not the whole package), with adversarial verification; six additional candidates were investigated and refuted. Parking these here to address later. Correctness1. Correlated noise ensembles — 2. EPW total-energy factor — 3. Growth-fit noise floor — 4. Silent zero-pump runs — 5. Silent SRS-source annihilation at the Raman critical density — 6. 7. Config schema is dead code; explicit nulls crash; 8. Light-substep stability bound picks the wrong density endpoint — Cleanup9. 10. KDK loop evaluates each Investigated and refuted (no action needed)
🤖 Generated with Claude Code |
…#320 review Review findings 4, 5, 6, and the actionable parts of 7: - Normalize explicit nulls (terms.hpe/light: null, grid.light_substeps: null, drivers.*.<key>: null) to "absent" at the top of get_derived_quantities, so every spelling the datamodel advertises as optional actually runs instead of crashing in AttributeError/TypeError deep in setup. - Warn loudly when drivers.E0 is missing: a mistyped pump driver used to produce a silent zero-pump run that completes with nothing but noise. - Raise at setup when SRS is on but the minimum box density is at or above the w1 critical density: the noise-seeded path used to silently filter the SRS source to zero and read as "below threshold". - Friendly ValueError for terms.light.pump_depletion without drivers.E0 (was a bare KeyError). - hpe_gamma_ratio_kpeak: emit NaN before the EPW has any in-band energy instead of ratio[0] == 0.0, which structurally pinned the logged hpe_damping_reduction_min to 0.0 for every HPE run; the series metrics are now NaN-aware. - HPE defaults and type coercion come from the datamodel's HPEModel (one definition instead of three); DensityModel now matches what actually runs (val for uniform basis, "gradient scale length" alias, optional min/max) and density.val is documented, including its dangerous 1.0-nc default. Setup-level checks: all null spellings set up cleanly, the new guards fire on the right configs, and the full lpse2d suite passes (37 passed, 1 skipped). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HUwBHLpBbdoEXfRV6FzHHR
The KDK subcycle evaluated _accel twice per substep at bitwise-identical (x, field, t) -- the trailing kick of step i equals the leading kick of step i+1 -- and XLA does not CSE across fori_loop iterations, so the dominant GPU loop ran at twice its necessary gather budget. Carry the acceleration in the loop state and compute only the trailing kick. Verified equivalent on a 150-step srs-hpe run: particle histogram, hot-electron counts, and all field arrays bitwise identical to the previous code (the only deviation anywhere is 1 float32 ulp on one sample of the mean-energy series, from reduction reordering in the recompiled save function). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HUwBHLpBbdoEXfRV6FzHHR
…; delete dead SpectralPotential (review finding 9) CoupledLight duplicated ~120 lines of RamanLight (FD stencils, detuning and SRS-coupling coefficients, seed injector, staggered sub-stepping), so every numerics fix had to land twice or the prescribed-pump and pump-depletion paths would silently diverge. CoupledLight now subclasses RamanLight: the E1 half is the inherited solver verbatim, and this class adds only the pump coefficients, the pump boundary injector, and the coupled staggered loop. SpectralPotential had been dead since SpectralEPWSolver replaced it (its instantiation was commented out), and carried a third Landau-damping copy plus E0/E1 source filters missing the c^2 factor -- filters that could never clip. Deleted. Verified on a 150-step pump-depletion amplifier run: all saved fields and budget series bitwise identical to the previous code. Full lpse2d suite green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HUwBHLpBbdoEXfRV6FzHHR
PRNGKey(step + seed) made nearby seeds share one noise trajectory merely time-shifted by a few steps -- a noise_seed 0,1,2,... ensemble was a single realization -- and the HPE wall-injection stream (+7919 offset) overlapped the EPW stream within one srs-hpe run. Keys are now fold_in(PRNGKey(seed), step) on independently tagged base keys: same seed + same run stays bit-identical, different seeds (and the two streams) are statistically independent. Noise realizations of noise-seeded runs change relative to earlier builds (statistically equivalent). New regression test covers both determinism and the time-shift correlation the old reproducibility test could not see. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HUwBHLpBbdoEXfRV6FzHHR
…view finding 3)
adept's EPW starts at exactly zero and its noise fills in as a damped random
walk with equilibration time 1/(2(gamma+nu)) -- often longer than the run --
so the OSIRIS early-sample median underestimated the floor ~10x and could
certify spurious growth on pure-noise runs. Each mode's noise variance is
closed-form ((dt A)^2 (1 - e^{-2gt})/(1 - e^{-2g dt})), so noise-seeded runs
now feed the otherwise-unchanged OSIRIS fit an analytic floor; the measured
estimate is still logged as epw_energy_floor_measured. Seeded/driven runs are
unchanged.
Validated on a 1.2 ps pure-noise run: predicted-vs-measured energy ratio has
median 1.000 (range 0.92-1.65), and the run correctly reports
epw_growth_measurable = 0 where the measured floor was 10x low.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HUwBHLpBbdoEXfRV6FzHHR
…tability bound (review findings 2, 8) epw_dissipation/epw_boundary_loss hard-coded total EPW energy = 2x electric, correct only at n = n_env. The warm-fluid wave energy d(w*eps)/dw at the envelope carrier, with the solver's detuning relation 3 k^2 vte^2 = wp0^2 - wp^2, gives 2*(2 - n/n_env) -- up to 2.56 at the low end of the shipped ramps (the hard-coded 2 understated electron_energy_* and laser_absorbed_frac_epw by ~28% there). The per-k loss rate is folded into a real-space loss density via sqrt-weighted fields so the x-dependent factor applies exactly (Parseval-identical to the old k-space sum on uniform n = n_env boxes; the pump-depletion budget test still closes). The light-substep stability bound now uses the density endpoint farthest from each carrier's critical density, over min/max/val -- the old max(max, min) picked the wrong end of ramps (2.3x understatement on the shipped profile) and silently fell back to n = 1.0 for uniform boxes, which flips to an underestimate at low envelope density (auto light_substeps too small, E1 blows up with no error). Shipped configs shift by at most one substep. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HUwBHLpBbdoEXfRV6FzHHR
Summary
Adds stimulated Raman scattering (backward SRS) to the
envelope-2d(lpse2d) solver. Until now the solver evolved the EPW potential against a prescribed pump with a TPD source only; theE1scattered-light field existed in the state vector but was never advanced. This PR evolvesE1with a paraxial finite-difference solver and closes the loop by adding the SRS source to the EPW equation, so backscatter grows self-consistently from noise (or from an optional injected seed).The physics is a direct translation of the
raman.solver = 'fd'branch of lpse-matlab (m201805_matlabLpse_v11.m); line references to the MATLAB source are kept in the code comments so the two can be diffed by hand.What's added
Raman light solver (
adept/_lpse2d/core/raman.py, new)Evolves the scattered-light envelope at
w1 = w0 - wp0:with the cross-derivative terms of the 2D paraxial operator, and the same staggered explicit update as MATLAB's
lightSplitStep(real part from the RHS att, imaginary part from the RHS att + dt/2).SRS source in the EPW equation (
core/epw.py)srsSource = i e wp0/(4 me w0 w1) · (n/n_env) · E0·conj(E1), added to the potential each step.E1is high-k filtered before the product (MATLAB'sisSuppressHighKSource, cutoff1.2 × k1_max) so only wavevectors near the light-wave envelope contribute; the pump is prescribed, so it is not filtered, matching the MATLAB static-laser path.Sub-cycling and stability (
helpers.py,datamodel.py)The light update is conditionally stable (
dt < ~dx² w1/c²), so it is sub-cycled inside each EPW step with the EPW potential held fixed. The number of sub-steps is derived from the stability bound generalized to 2D, or can be pinned withgrid.light_substeps— a value that violates the bound raises rather than silently going unstable. Absorbing boundaries are applied every sub-step, since light crosses the absorber at ~c.Optional Raman seed (
drivers.E1)A two-point antisymmetric injector at
x = xmax - offsetlaunches a-x-propagating wave at the localk1, with a configurable turn-on ramp and an optional 4th-order super-Gaussian transverse profile. The default offset (1.6 × boundary_width) keeps the injector clear of the absorber's tanh skirt, and a closer one warns. If the density at the injector is above thew1critical density the seed is evanescent, so setup fails with a message pointing at the three ways out (lowerdensity.max, moveoffset, or dropE1and run noise-seeded).Diagnostics
With SRS on, the default time series gains
e1_sqandreflectivity— the latter issqrt(eps1)·<|E1_y|²>_y / E0_source²at a probe on the low-density side, with thesqrt(eps1)factor accounting for the reduced group velocity relative to the vacuum pump.make_series_xarraysis now generic over whatever keys the save function returns instead of hard-codinge_sq/max_phi.Quasi-1D (
ny = 1) support1D SRS is the cheap configuration to run and the one the MATLAB
srs_1Dcase uses, but the field-save path assumed ≥2 transverse cells:interpax.interp2dreturns NaN off a single y-node andRegularGridInterpolatorrejects a single-node axis (filling 0). Both silently blanked every field artifact while the scalar series stayed valid. Added an x-only interpolation path forny == 1in the in-solve field saver and the background-density save, and adjusted the plotting to emit line plots vskxinstead of emptykx–kymaps.Also in passing:
density.basis: uniformnow honors avalkey instead of always returning 1.0, and the complex-dtype check inmake_field_xarraystests the actual float view rather than assumingcomplex128.Config surface
Example config:
configs/envelope-2d/srs.yaml— noise-seeded backward SRS on a 0.18–0.28 n_c linear ramp, thesrs_1Dcase.Validation
tests/test_lpse2d/test_srs.py:test_srs_growth_rate(parametrized 2D andny = 1) — noise-seeded homogeneous SRS. Fits the log-slope of the EPW energy over the late-time window and compares to the analytic backward-SRS rategamma0 = k v_os/4 · wpe/sqrt(w_ek w_s), evaluated at the phase-matchedkfrom a fixed-point solve of the Bohm–Gross/EM dispersion pair, with the pump wavenumber snapped to the FFT grid the way the solver launches it and the local density swelling folded intov_os. Agrees to 35%.test_srs_seed_propagation— pump and noise off, seed only. Checks the injected wave travels in-x, that its measured wavenumber matches the localk1to 5%, and that its amplitude matches the injector calibrationE1_source · sinc(k1 dx) / eps1^(1/4)to 30%.Notes and limitations
E0is reconstructed from the driver each step and is unaffected byE1, so reflectivities are only meaningful in the undepleted regime. Coupling depletion back intoE0is the natural follow-up.light_substepsextra RHS evaluations per EPW step, each with several FFT-free stencil passes plus oneifft2of the potential Laplacian per step. On the shipped example config this is single-digit sub-steps; shortdxat fixeddtraises it quadratically.drivers.E0.num_colors) composes with this for free — the Raman coupling consumesE0as a field, so broadband SRS works without further changes, though it is not exercised by a test here.Known issue, pre-existing and not addressed here:
drivers.E0.shape: arbitrary(the learnable amplitude/phase driver) looks broken onmain. #168 broadcast the driver output to(num_colors, ny)and switchedlaser.Light.laser_updateto index it as[i, :], butArbitraryDriver.__call__overridesUniformDriver.__call__and still returns 1-Dphases/intensities(modules/driver.py:174-178), so that path would raise on the first laser update.uniform/gaussian/lorentzianall inherit the broadcasting__call__and are fine, and no shipped config or test exercisesarbitrary, which is presumably why it has gone unnoticed. Flagging it rather than fixing it here to keep this PR to SRS — happy to fold in the one-line broadcast fix if reviewers would rather have it in the same change.Base branch
Branched from
mainand independent of the osiris wrapper (#279) — the two lines of work touch a disjoint set of files, and this one is confined toadept/_lpse2d,configs/envelope-2d,docs/source/solvers/lpse2d, andtests/test_lpse2d.🤖 Generated with Claude Code