Skip to content

feat(lpse2d): spatially windowed electron distribution for HPE (stacked on #378) - #387

Open
physicistphil wants to merge 209 commits into
ergodicio:mainfrom
physicistphil:feat/lpse2d-hpe-spatial-fv
Open

physicistphil wants to merge 209 commits into
ergodicio:mainfrom
physicistphil:feat/lpse2d-hpe-spatial-fv

Conversation

@physicistphil

@physicistphil physicistphil commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Summary

HPE's Landau-damping feedback read one box-averaged electron distribution, as LPSE's ElectronTracker does. This adds an optional spatially windowed distribution, terms.hpe.n_windows (adept-only; the default 1 is the existing code path, bit-identical).

Stacked on #378 (lpse2d/lpse-parity-plan2 @ 3b582f6): review only the last commit, e6def50 (git log lpse2d/lpse-parity-plan2..feat/lpse2d-hpe-spatial-fv).

  • Windows: n_windows equal-width x windows with cos² tapers forming a partition of unity (each x in at most two windows; wrapped on periodic boxes, flat to the walls on bounded ones).
  • Distributions: every particle deposits into its two windows with its weights; each window keeps its own EMA histogram (binned exactly as jnp.histogram) and its own rate gamma_j(k) = C(k) Op[f_j](k) with the shared per-k calibration.
  • Damping: the EPW step applies the rates on psi = |k| phi as psi <- sum_j sqrt(w_j) IFFT[exp(-gamma_j dt) FFT(sqrt(w_j) psi)] (SpectralEPWSolver.windowed_landau_damping). It never raises the EPW energy for gamma_j >= 0 and is the per-mode multiply for one shared k-independent rate.
  • State / diagnostics: gamma_L_windows (applied), gamma_L = volume-weighted mean; series hpe_hist_windows (hpe_hist = volume-weighted mean), windowed epw_dissipation, plots/hpe_distribution_windows.png.
  • terms.hpe.hist_smooth: binomial [1,2,1]/4 passes along v before the slope is read, with the calibration through the same filter (ported from the lpse2d/srs 2-D HPE work) — reduces the per-window shot noise without bias.
  • Refused with terms.epw.solver: combined and with energy_conservation (not implemented).

Tests

Criteria and tolerances fixed before the tests were run (research-vault note 2026-10-08-182046-claude-lpse2d-hpe-spatial-fv-b47179bd, including the recorded corrections):

  • default parity: a 20-step SRS + HPE run (quasi-1D and 2-D) is bit-identical to 3b582f6 in all 23 state arrays;
  • tests/test_lpse2d/test_hpe_windows.py (19): partition of unity, per-window histograms summing to the global one, uniform-rate exactness, local-multiplier exactness for k-independent rates, energy contraction, a plateau on half the particles reaching the window rates in proportion to the windows' weight shares, calibration under smoothing, validation, and a 0.2 ps SRS smoke run with 4 windows (slow);
  • full tests/test_lpse2d on macOS: 473 passed, 2 skipped; the 4 failures are the known laptop-only ones (test_lpse_deck time steps for test_019/test_030 need run inputs that are not in git; test_three_component_fields combined bit-identity, failing at 044784f on this machine too).

Status

Draft. Measured on the blackbox (research-vault study hpe-windows-validation):

  • Cost (RTX 3090, float64, quasi-1D scan2 boxes): 4e6 particles cost 80.2 / 88.3 ms per EPW step with n_windows 1 / 9 at Ln200 and 81.6 / 89.1 at Ln1000 (about 32-35 min per 23.29 ps); the 9 windows add ~10 %. float32: 48.7 ms/step.
  • Validation (7 scan2 points, newest LPSE defaults, flat noise recalibrated to the OSIRIS floors): the default path is unchanged; with n_windows: 9 R stays within 0.014 of the box-averaged HPE at 5 points and is lower at Ln400um-Te10keV-x2 (0.031 vs 0.293, reproduced across three particle seeds). With every window held at the analytic rate (feedback: false) the windowed operator reproduces the fluid result there (0.269 vs 0.271), so the difference comes from the per-window rates; why is not established yet.

🤖 Generated with Claude Code

physicistphil and others added 30 commits September 14, 2026 15:20
…rms, spectral IAW

Closes the term-level gaps against the original LPSE C++ code (gap audit
2026-09-14, tiers A.1, B and B' of the parity plan):

- TPD pump depletion: coefficient i e/(2 me w0) (twice the EPW-side source, the
  form settled against Follett's equation document; main carried half of it),
  acting on both pump components, with LPSE's transverse (divergence-free)
  projection of E_h div E_h in k-space (terms.light.tpd_projection) and the
  optional lw.kFilter (tpd_k_filter). The pair test now checks
  d/dt[|E0|^2 + (2 wp0/w0)|E_h|^2] = 0.
- TPD source: every pump component and LPSE's exact coefficient i e/(4 me w0)
  with the (w0/wp0 - 1) charge-density factor (terms.epw.source.tpd_form: lpse,
  default; matlab keeps the prototype's w0 -> 2 wp0 form; identical at envelope
  density 0.25).
- EPW noise: terms.epw.source.noise_model: thermal -- LPSE's fluctuation-
  dissipation source D_k = N A/sqrt(1 + k^2 lambda_D^2) sqrt(1 - e^{-2 gamma dt})/|k|
  added after the damping sub-step, relaxing every mode to the Cerenkov spectrum;
  noise_calibrate sets A from Te by equipartition; noise_max_wavenumber. flat
  (the MATLAB kick) stays the default. The kick spectrum is shared with the
  expected-noise-floor diagnostic.
- Landau damping: landau_form matlab | lpse (sqrt(1+3x^2) prefactor) |
  relativistic / relativistic_3d (LPSE's Maxwell-Juettner Bessel forms),
  landau_lower_threshold, landau_multiplier; one analytic_landau_rate() used by
  the EPW step, HPE calibration, dissipation diagnostic and thermal noise.
- terms.epw.max_wavenumber (lw.maxWavenumber) band cap.
- Absorbing layers: grid.boundary_profile: exp (LPSE absorbingBoundaries.cpp
  profile, lambda = 7, peak rate 200/ps, IAW layer at half), tanh stays default.
- IAW: terms.iaw.solver: spectral -- LPSE's exact per-mode damped-oscillator
  propagator (unconditionally stable) with uniform background flow (Doppler
  phase), stride (advance every N EPW steps), LPSE's fluctuation-dissipation
  noise on div v, and damping.landau_form: full (Z-generalized Krall-Trivelpiece
  rate). The MATLAB explicit stepper is unchanged as solver: explicit.

The light-side n_b/n0 factor turned out to be hard-wired to 1 in LPSE too
(LightSolver.cpp:4217), so no change was needed there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GimrKCs3NPtxjbZED63HDo
Accumulates in the state the change of sum_k k^2 |phi_k|^2 attributed to each
operation of the EPW split step (dispersion, damping, dealias, noise, detuning,
boundary, reprojection, tpd, srs, driver) and reports the cumulative channels
in the default series in epw_energy units, with epw_ledger_closure =
epw_energy - sum(channels). This is the per-step energy-budget check the
original LPSE asserts to 0.1 %; here it closes to round-off by construction
and the channels are the diagnostic. Off by default (no state change).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GimrKCs3NPtxjbZED63HDo
terms.light.solver: spectral -- the original LPSE {laser|raman}.solver = spectral
path: per light sub-step the x-space scattering potential exp(i dt Vo)
(detuning + absorption), the EPW couplings with forward Euler, and the exact
k-space propagator exp(-i dt c^2 k^2/(2 w)) on the transverse part of the field
(longitudinal part left unpropagated, as LPSE), band-limited. No CFL limit
(light_substeps defaults to 1), no grid dispersion; with pump depletion the
SRS exchange is the exact local rotation Strang-split around the propagation.
Injectors become smooth Gaussian sources (drivers.E0/E1.injector_width, half a
local wavelength by default like LPSE's injectorWidth) that launch the
requested amplitude to 0.2 % with negligible wrong-direction leakage.
terms.light.max_wavenumber caps the retained light band.

terms.light.absorption -- inverse-bremsstrahlung absorption at nu (n/nc_w)^2
per wave (LPSE calculateScatteringPotential), nu from the NRL formula as coded
in LPSE (true) or a user rate at nc (1/ps); applied per sub-step on both light
solvers, IAW density perturbation included.

The laser-budget flux correction knows the spectral solver has no grid
dispersion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GimrKCs3NPtxjbZED63HDo
The original LPSE lw.solver = combined formulation, which LPSE requires
whenever TPD and SRS are both on: one wp0-enveloped field E1 carries the Raman
light as its transverse part and the EPW as its longitudinal part, and the
potential is derived from it every step for the diagnostics, IAWs and HPE.

Per light sub-step (LightSolver::evolveSpectral order): density detuning and
collisional damping on the whole field; LPSE's unified source
-i e/(4 me w0) e^{-i(w0-2wp0)t} [grad(E0.E1*) + (1 - w0/wp0) E0 (div E1)*]
(its longitudinal part is the TPD + SRS source, its transverse part the
Raman-light generation -- verified against the separate solver's terms to 1e-9
at n_c/4); the 2x2 longitudinal/transverse propagator (Bohm-Gross + Landau on
k k/k^2 E1, exp(-i dt c^2 k^2/(2 wp0)) on the rest); EPW noise on the
longitudinal part; absorbers. With pump depletion the pump advances in the
same sub-step with the unified depletion i e/(2 me w0) e^{+i dw t} E1 div E1.
Requires terms.light.solver: spectral and tpd == srs (LPSE's rule); the
separate solver now warns when both instabilities are on, quoting LPSE.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GimrKCs3NPtxjbZED63HDo
…ensity/dealias options

- adept/_lpse2d/lpse_deck.py: ZakUnits (LPSE ZAK unit system), parse_parms (with
  #include), translate_parms (LPSE lpse.parms -> adept cfg + report), read_metrics,
  read_frames, CLI entry point
- density bases `lpse-linear` / `lpse-exp` (min/max at min_location/max_location)
- `dealias: rectangular` (LPSE aa_range convention)
- noise_debye_factor toggle (LPSE isCalculated branch)
- tests/test_lpse2d/test_lpse_deck.py

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GimrKCs3NPtxjbZED63HDo
…y default there)

The high-k filter on E1 (and the evolved pump) entering the SRS source cut at
1.2 k1(n_min) for zero detuning. In a box below the envelope density the resonant
back-scattered Raman wavenumber lies above that cutoff, so the filter removed the
SRS mode entirely: a uniform 0.2 nc box with envelope density 0.25 showed no growth
at all, while LPSE (kFilter disabled by default) grows at 25.3/ps in energy.

- terms.epw.source.srs_k_filter (default true, previous behaviour) and
  srs_k_filter_scale (default 1.2), honoured by the separate and combined solvers
- translator sets them from lw.kFilter.enable / lw.kFilter.scale
- band-empty validation only when the filter is on
- test: default filter masks the 0.2 nc resonance; off / scale 1.5 pass it

With the filter off adept's homogeneous SRS energy growth is 25.2/ps vs LPSE 25.3/ps
(theory 2 gamma0 = 27.1/ps), onset times equal to 0.02 ps.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GimrKCs3NPtxjbZED63HDo
…ed longitudinal light

No light propagator moves the longitudinal part of E1, so the longitudinal part of the
SRS source accumulated there and paired with the EPW in a spurious two-wave instability:
on the translated LPSE test_006 deck adept's EPW energy grew at 41/ps (E1 100 %
longitudinal at |k| ~ 37/um, outside the light band) against LPSE's 5.8/ps.

- terms.light.transverse_source (default true; LPSE takeTransversePartOfSourceTerms):
  the SRS source (FD and spectral Raman solvers) and the SRS pump-depletion source
  (FD coupled solver) are projected onto their transverse part; the coupled spectral
  solver keeps the fields transverse instead (its exchange is an exact local rotation)
- the spectral propagators now drop every component outside the retained light band,
  as LPSE does (LightSolver.cpp:3720-3735); before, only the transverse part was masked
- translator maps raman/laser.takeTransversePartOfSourceTerms (LPSE default false)
- tests: longitudinal light kept only inside the band; a purely longitudinal SRS source
  yields no E1 with the projection (spectral and FD)

M1 cross-check (test_006, SRS from noise in a 0.20-0.28 nc linear profile): adept EPW
energy growth 5.96/ps vs LPSE 5.80/ps, Raman 5.96 vs 5.80, growth peaks at the same
positions (potential 13.0 vs 13.0 um, Raman 11.3 vs 11.4 um).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GimrKCs3NPtxjbZED63HDo
- static pump (laser.Light): each color is the grid mode nearest to k0 (cos a, sin a),
  both components snapped as in LPSE makeStaticField, polarised perpendicular to the
  snapped k; swelling as before
- spectral pump injector: transverse wavenumber snapped to the periodic y grid, kx from
  the local dispersion per color, x group velocity sets the injected flux, field
  perpendicular to k; launched amplitude 1.009 (20 deg) vs 0.993 (0 deg) of target
- the FD two-point injector and speckle refuse a non-zero angle with a clear error;
  only in-plane (p) polarisation is representable
- pump k-support for the dealiasing band includes k0 sin(angle)
- translator maps laser.N.direction (in-plane, rightward) to the angle; multi-beam
  decks with differing directions and out-of-plane polarisation are reported
- tests: static mode/polarisation at 0 and 20 deg, launched oblique wave (ky row,
  |k| = k0, transverse), FD refusal, test_010 direction mapping

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GimrKCs3NPtxjbZED63HDo
linear, exp, gaussian, inverse-power, quadratic, qd (parabolic dip), gd (super-Gaussian
dip) and file, ported from ZakharovSolver::backgroundDensityShape: N_min/N_max at two
locations (x and optional y), cartesian or spherical distance, sg_order, quadratic
central density, dip depth/width/offset about an origin (LPSE's box centre), the
maxBackgroundDensity clip (max_density, 1.25). lpse-file reads .npy, text tables or LPSE
grid files. The translator maps densityProfile.shape/geometry/sgOrder/dip.*/
quadratic.centralDensity/maxBackgroundDensity/loadFilename.

Tests: endpoints and midpoints of every shape against the LPSE formulas, dips on the
ramp, spherical geometry, the clip, file round trip, translator key mapping.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GimrKCs3NPtxjbZED63HDo
Ported from ElectronTracker.cu / ZakharovSolver::getLD_multiplierForEnergyConservation:
- gamma_limit_damping / gamma_limit_growth / allow_growth: clip of the applied kinetic rate
- thermalization_probability per wall direction: thermalize (re-inject from the tail) or
  reflect specularly
- magnetic_field: uniform out-of-plane B rotates the momentum in the 2-D push
- energy_conservation (+ energy_conservation_steps): global multiplier on the applied rate
  so the expected EPW loss (linearised 2 gamma dt per mode) equals the particles' gain,
  clamped to [0.1, 10] and running-averaged; reported as hpe_ld_multiplier
- wall-flux instrument (flux_bins, keV) and cone-power instrument (cone_angle,
  cone_direction): cumulative energy leaving each wall per bin / inside the cone, in the
  default series (hpe_wall_energy_<wall>_bin<i>, hpe_cone_energy)
- push / push_2d return the wall instruments and the kinetic-energy change
- translator maps hpe.nElectrons/velocityGrid/startEvolutionAt/thermalizationProbability/
  magneticField/gammaLimit.*/allowGrowth/enforceEnergyConservation/
  numStepsToAverageEnergyChange/metrics.flux|power
- tests for each control and the translator mapping; existing HPE tests adapted

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GimrKCs3NPtxjbZED63HDo
find_threshold bisects the pump intensity between a stable and an unstable bracket
(geometric midpoints), one ergoExo run per point, deciding on the run's metrics (default:
measurable positive EPW growth); bracket check as in LPSE AbsoluteThreshold; MLflow
logging of the bisection. Tests with a fake runner: convergence to a known threshold and
bracket validation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GimrKCs3NPtxjbZED63HDo
…boundary_max_rate)

LPSE damps the light fields at {laser|raman}.evolution.abc.maxDampingRate (default 5e3/ps)
in its exp layers while the EPW layer uses 200/ps. adept applied the EPW layer to the light
too: light crosses a 3 um layer in 0.01 ps, so the layer removed only ~25 % of the amplitude
per pass and an injected pump reflected off both walls into a coherent standing wave -- on
the LPSE test_010 deck the pump in the box was 1.36-1.46x the launched amplitude (found by
bisecting the M2 cross-check, where adept's TPD grew twice as fast as LPSE).

grid.light_absorbing_boundaries (5e3/ps by default with the exp profile, the tanh profile
unchanged) is now what the FD/spectral light solvers apply; the translator maps the LPSE
key. Test on the rate profiles and the transit-integrated attenuation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GimrKCs3NPtxjbZED63HDo
…'s IAW step rule

- save.checkpoint: true|path writes the full solver state at grid.tmax (.npz, in the run's
  binary/ folder when true); restart.file resumes from it: state replaces the initial
  condition, integration and the series/fields axes start at the checkpoint time (snapped to
  the step grid), mismatched state shapes are refused. The per-step noise and wall keys are
  folded in from the time index, so a split run reproduces the unbroken one to round-off
  (test: 10 fs + 10 fs vs 20 fs, fields and energy equal to 1e-9)
- translator: the IAW stride follows Lpse.cpp (own dt capped by maxLightStepsPerStep light
  steps and maxLwStepsPerStep (default 2) EPW steps): test_010 now steps the IAW every EPW
  step as LPSE printed, test_029 every second

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GimrKCs3NPtxjbZED63HDo
…l filamentation

D.6: save.fields.poynting adds s0_x/s0_y/s1_x/s1_y = (c^2/w) Im(E* . grad E) (LPSE
laser/raman.save.S0); save.thomson probes add the complex amplitude and power of the EPW
potential (or IAW density) in a k-window to the default series (LPSE thomsonScattering.N).

D.7: terms.iaw.thermal_filamentation {laser, raman, lw, nonlocal, conductivity_multiplier}
ports ZakharovSolver::getThermalFilamentationSource: inverse-bremsstrahlung heating of the
spatially varying intensity of each wave, balanced by Spitzer conduction, drives the ion
velocity divergence through the electron pressure; the nonlocal k^(4/3) correction with
lambda_nl = 30 (k_B T)^2 / (4 pi e^4 sqrt(Z+1) lnLambda n_e). The source form is LPSE's, the
normalization adept's own (derived from the heat and momentum equations). Translator maps
thermalFil.*.

Tests: plane-wave Poynting = v_g |E|^2, Thomson window selection, thermal source vanishing
for uniform intensity and matching the derived coefficient, nonlocal k^(4/3) scaling,
requirement errors, translator mapping.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GimrKCs3NPtxjbZED63HDo
…ulse-shape file (C.4)

- drivers.E0.beams: LPSE laser.N.* beamlets (intensity fraction, angle, phase, delta_omega)
  for the static pump and the spectral injector; every beam carries every color
- drivers.E0.beam_width / beam_sg_order / beam_offset: super-Gaussian transverse profile of
  the injected beams (LPSE laser.N.width, sgOrder, offset)
- drivers.E0.kap_bandwidth / kap_seed: Kubo-Anderson phase jumps every 2 pi / (dW), with a
  deterministic hash of the interval and beam (LPSE laser.bandwidth.KAP.frequency)
- drivers.E0.pulse_file: (t_ps, amplitude) table multiplied onto the pump (LPSE laser.pulse.file)
- translator maps multi-beam decks and the beam-shape / bandwidth / pulse keys
- tests: two beams launch both transverse wavenumbers with equal power; transverse profile,
  pulse scaling and KAP jump statistics; translator mapping

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GimrKCs3NPtxjbZED63HDo
…en the deck omits it

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GimrKCs3NPtxjbZED63HDo
…ot the EPW one)

Found by the M4 cross-check on test_013 (combined solver + evolving spectral pump): the pump
reflected in the 200/ps EPW layers into a standing wave (transmitted flux 2.17 of nominal).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GimrKCs3NPtxjbZED63HDo
…c.N keys (MeV -> keV)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GimrKCs3NPtxjbZED63HDo
…PSE's fluxMetric/powerMetric keys

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GimrKCs3NPtxjbZED63HDo
…verVminPhase mapping

ElectronTracker.cu counts every crossing of a box side in the flux/power instruments, then
thermalizes the particle with the per-direction probability or lets it pass through
periodically, independently of the field boundaries. adept wrapped silently at periodic field
boundaries and reflected non-thermalized particles at absorbing ones. Now: crossings on all
four sides are counted; thermalize-or-wrap; the default probability keeps the earlier
behaviour (1 at absorbing field boundaries, 0 at periodic ones). The translator's HPE flux
bins are keV (the decks bin a 2 keV plasma at 0.01-20 keV) and hpe.VminOverVminPhase = 0
maps to a whole-Maxwellian tail cutoff (v_min = 0.05 vte).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GimrKCs3NPtxjbZED63HDo
… its injectors

The exact propagator needs no CFL limit, but the smooth injectors add their source with an
Euler step; with light crossing 16 cells per step (translated test_022, no laser.dt in the
deck) the launched pump was 3x its nominal flux. Default light_substeps = ceil(c dt / dx) for
the spectral solver (LPSE's spectral decks use raman.dt ~ dx/c); an explicit smaller value
warns. The translator no longer maps hpe.VminOverVminPhase = 0 to a whole-Maxwellian tail
(adept's kinetic feedback is calibrated for a tail); it reports the option instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GimrKCs3NPtxjbZED63HDo
… 2 P.0, P.2)

adept/_lpse2d/parity/ makes the 2026-09-14 cross-check tooling importable with every
machine path an argument:

- reference.py: `lpse_root()` resolves the original-lpse checkout from $LPSE_ROOT or a
  sibling of the repository / its worktree parents; `reference_run_dir(deck)` returns
  `<root>/runs/<deck>`, else the download cache, else fetches the `lpse_reference/`
  artifact tree of the deck's run on MLflow `lpse-parity` (tag lpse_reference_deck)
  with boto3 into $LPSE_REFERENCE_CACHE (~/.cache/adept/lpse-reference).
- harness.py: `translate_deck` / `run_deck` (through ergoExo), `growth_rate` and
  `compare` (the exact log_lpse_reference.py metrics and key names), `log_reference`,
  `read_flux` / `hpe_wall_power` / `compare_hpe_flux` (compare_m5.py), the baseline fit
  windows per deck (DEFAULT_WINDOWS) and per-deck overrides.
- __main__.py: `python -m adept._lpse2d.parity run|compare|log|fetch|verify`. `verify`
  recomputes a stored cross-check run's ratio metrics from its own binary/series.xr and
  lpse_reference/lpse.metrics artifacts: test_006 (bf56ee6c), test_010 (13665c22) and
  test_032 (c7310b80) reproduce all 40 stored values to <= 8e-15 relative.

tests/test_lpse2d/test_lpse_deck.py and test_oblique_pump.py use the resolver instead
of the hard-coded blackbox path; with the local tree hidden the reference-run tests
run from the MLflow download. Harness tests cover the metric names/values on synthetic
series, the resolver, and LPSE-vs-itself ratios of exactly 1 on the test_006 reference.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…, Ny) layout (plan 2 P.1)

LPSE writes field files in C order (Nx, Ny, Nz) with the last index fastest; the reader
assumed x fastest and transposed every 2-D frame (reported in the 09-15 C++ backward-pump
note). Verified on runs/test_025 (40 x 5 um, 720 x 90, downSampleFactors 1 4): under the
C-order layout the lpse.pots.downSample_4 frame equals pots[::4, ::4] of the full frame
(normalized inner product 1.0000; 0.49 under the old layout), the 2 um lw.Labc skirts
along x lower <|phi|> by 17 % in the outer 36 cells of axis 0, and the field is
isotropically smooth (mean |d/dx| = mean |d/dy|) whereas the old layout was twice as
rough along "y". The plan's stated x-signature (a density gradient) does not apply to
this deck — NminOverNc = NmaxOverNc = 0.2 — so the absorber skirts and the downsample
cross-check are the evidence.

Nz > 1 now returns (Nx, Ny, Nz). The `density.basis: lpse-file` reader in helpers.py
drops its compensating transpose. The 09-14 M1-M5 cross-checks used only lpse.metrics
and are unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…plan 2 N.5)

tests/test_lpse2d/test_gradients.py differentiates the final EPW energy of a 64 x 16
(40 nm cells, k lambda_D <= 0.37 so the SRS-resonant EPW is resolved), 50-step
SplitStep run with respect to the pump intensity multiplier (args.drivers.E0.intensities,
the UniformDriver control) with jax.grad, and compares with a central finite difference
of the same jitted function (rtol 1e-2 as the plan asks; the measured agreement is
<= 2e-7 relative on every path, FD converged between h and h/4):

  separate/fd, separate/spectral, separate/static (prescribed pump), combined/spectral
  (TPD + SRS, pump depletion), combined/spectral + spectral IAW, separate/spectral +
  thermal noise (noise_amplitude 1e-12 so the noise-driven energy is ~2x the seed, not
  1e16x), separate/spectral + HPE.

No path needed an xfail: the HPE push is differentiable as written (the histogram and
the wall / energy bins are piecewise constant, so the derivative through gamma_L is
identically zero and the gradient is the one with gamma_L frozen at the value the push
produced). The HPE test also checks that the HPE objective differs from the
analytic-damping one (0.94 %), i.e. the feedback is in the graph. The pump envelope is
shifted so the pump is fully on from t = 0 (srs.yaml's would be on its rising edge);
sensitivities d ln W / d ln s are 3e-4 to 2.5e-2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nt (plan 2 N.3)

terms.epw.source.noise_calibrate now takes false | true | equipartition | lpse. `lpse`
uses LPSE's own lw.noise.calcNoiseAmp_K0 (ParameterManager.cpp:1440-1444, ported as
ZakUnits.noise_amp_k0 with the ZAK grid quantities h, deltaK3, N_zak, L_zak, Lambda_d)
converted from LPSE's ZAK k-space potential kick to this code's: the per-mode kick is
A calcNoiseAmp_K0 / sqrt(1 + K^2 Lambda_d^2) sqrt(1 - exp(-2 gamma dt)) / |K| times
potential_zak_to_adept. `true` stays the equipartition calibration. The deck translator
maps lw.noise.isCalculated to `lpse` (it used to substitute equipartition with a note).

Verification without a solver run:
- the formula reproduces the value LPSE prints for test_010, 1.7885e+00 (port: 1.78853);
- mode by mode, noise_kick_spectrum(lpse) equals LPSE's kick expression to 1e-6, and
  LPSE's Debye length equals vte/wp0;
- test_025 (EPW-only noise + damping, isCalculated false): the interior <|phi|^2> of
  LPSE's lpse.pots frames at 0.5 and 1.0 ps is 0.911 / 0.916 of adept's
  fluctuation-dissipation expectation for the translated deck including the finite-time
  transient (plan tolerance 10 %). This required a fact not previously recorded: LPSE
  writes its potential frames scaled by potentialNormalizationFactor (saveInNormalizedUnits
  is forced true), so a pots frame holds e phi / (m_e c^2), not the ZAK potential --
  ZakUnits.potential_normalization_factor / rho_normalization_factor, documented on
  read_frames. Without that factor the same comparison reads 3e-4.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…cted TPD term (plan 2 N.4)

Ledger (tests/test_lpse2d/test_tpd_depletion.py): with tpd_projection on, the TPD pair
energy |E0|^2 + (2 wp0/w0)|E_h|^2 is conserved to round-off for a divergence-free pump
(the projector is self-adjoint: <E0, P_T F> = <P_T E0, F> = <E0, F>), exactly as the
unprojected term conserves it for any pump. For a pump with a longitudinal part the
mismatch equals that part's contribution to the EPW source: the EPW is driven by the
whole pump, the projected term returns energy only from its transverse part.

Measurement: the FD pump propagator (curl-curl with compact 3-point second differences
and a centred cross difference) has a non-zero discrete divergence. Free propagation of a
transverse field over one EPW step on the 0.1 um test grid (k0 dx = 1.8) leaves a
longitudinal fraction of 0.02 % (|k| < k0/4), 4-6 % (|k| <= k0), 11-13 % (band edge);
an oblique plane wave at 5 deg 5 %, at 20 deg 11-14 %; a y-uniform plane wave exactly 0.
The fraction saturates rather than grows. No propagator moves that part, so with the
projected depletion term it is an energy source for the EPW that the pump never pays for
-- a candidate mechanism (SPECULATION, needs the P1/P3 reruns) for the separate-solver
FD run going non-finite with the projection on and staying finite without it.

Fix, under the plan's default keep-and-fix: terms.light.transverse_fields (default true,
fd solver) projects the evolved light fields -- E1 in RamanLight, E0 and E1 in
CoupledLight -- onto their transverse part once per EPW step after the sub-steps, as the
spectral solver keeps them by construction. A plane-wave pump is unchanged bit for bit;
the random transverse pump's 4 % becomes < 1e-12. Documented in config.md; `false`
restores the previous behaviour. 73 tests over the light / SRS / ledger / gradient /
parity suites unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…an 2 N.2, code part)

The applied-Landau-rate multiplier now follows ZakharovSolver::getLD_multiplierForEnergyConservation
and getExpectedLwEnergyChange instead of the one-shot linearisation:

- expected_wave_loss(M) = sum_k k^2 |phi_k|^2 (1 - exp(-2 dt M gamma_k)) over the retained
  modes with the HPE-derived gamma_k (the exact per-mode factor; the linear 2 dt M gamma_k
  over-counts the loss of strongly damped modes and so under-estimates M: at 2 gamma dt = 1
  a gain of W/2 needs M = ln 2 = 0.69, the linear form gives 0.5);
- M solved by secant iteration from the proportional guess to LPSE's 1 % tolerance, clipped to
  [0.1, 10], with LPSE's final update; a fixed iteration count with frozen updates once
  converged keeps it a pure, differentiable JAX function;
- the gain is the running average of the per-step particle energy change over
  energy_conservation_steps (LPSE particleEnergyChange_TW; new state entry
  hpe_particle_power, reported in the series as keV per real electron per step) and the
  multiplier is only re-solved once more than that many feedback steps have elapsed since
  t_start (LPSE countParticleUpdateSteps gate);
- the deck translator uses LPSE's default of 10 for hpe.numStepsToAverageEnergyChange
  when the deck omits it (was 1).

This is the "port getExpectedLwEnergyChange literally" item; the plan's wording "wave-side
loss = ledger damping channel" is not what LPSE does -- LPSE evaluates the exponential
expected loss for a candidate M, which is what is ported. The test_037 verification run
(saturation time within 15 % of 2.69 ps, hpe_gamma_ratio_kpeak within 2x) needs the
blackbox or Perlmutter and is not part of this commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…the 2-D grid (plan 2 F.1)

E0 and E1 carry (x, y, z) like LPSE's XcComplex3 on any grid. The grid stays 2-D
(k_z = 0), so the z component has no longitudinal part: the new core/vector.py helpers
(fft2c/ifft2c, k_dot, split_k, transverse_part, dot_conj, with_components) project the
in-plane pair and pass E_z through; the FD propagators advance E_z with the plain
Laplacian (-(curl curl E)_z); the combined solver's L/T propagator, unified source
(E0 . E1* over all components, in-plane gradient), depletion and noise, the spectral
transverse propagator, the static / oblique pump and the injectors all take the field's
component count (2 or 3). The EPW potential and the TPD source see only the in-plane
components; the SRS source is the full E0 . E1*, so an s-polarised pump drives SRS and
no TPD. init_state_and_args allocates 3 components; a two-component checkpoint is
padded with E_z = 0 on restart; the fields output gains e0_z / e1_z; reflectivity sums
the transverse components at the probe. Injector components are attributes
(pump_component / seed_component, default y) for F.2.

Verification (tests/test_lpse2d/test_three_component_fields.py): with E_z = 0 the
three-component run is bit-identical (assert_array_equal) to the two-component one on
separate/fd, separate/spectral, separate/static, combined/spectral and
combined/spectral+iaw; the projectors leave E_z untouched and reduce to the
two-component projection in plane; a z-polarised pump gives an identically zero TPD
source and the same SRS source as the y-polarised pair (crossed pairs: zero); an E0z
plane wave propagates at v_g in both light solvers without acquiring in-plane parts.
The two-component tests are unchanged except test_combined.py's three unpackings of
longitudinal_transverse, whose return is now (L_k, T_k) arrays.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… Raman seed (plan 2 F.2)

drivers.E0.polarization, beams[].polarization and drivers.E1.polarization take LPSE's
laser.N.polarization / raman.N.polarization: degrees about the beam axis (or "p" / "s").
LPSE rotateBeam starts the field along y, rotates it about the beam axis by the angle and
carries it onto the beam direction, so every launch is cos(psi) along the in-plane
transverse direction plus sin(psi) along z: the FD two-point injectors (pump and seed),
the spectral Gaussian sources (per beam, with the oblique in-plane unit vector), the
combined solver's pump source and the static / oblique prescribed pump all write those
two weights. At psi = 0 the arithmetic is unchanged (x 1.0, + 0). A z-polarised launch
into a two-component field is refused with a clear error. The deck translator maps the
angles instead of reporting them unsupported.

Tests (test_three_component_fields.py): on separate/fd, separate/spectral,
separate/static, static+oblique and combined/spectral the s launch puts in E0z exactly
what the p launch puts in the plane (to 1e-9) and nothing in the plane; 30 deg splits as
cos/sin to 1e-12; with TPD on and SRS off the EPW energy under an s-polarised pump equals
the free-decay value bit for bit while the p-polarised pump drives it; the translator
maps a two-beam 90/45 deg deck, an s-polarised Raman seed, and test_017 (polarization 90)
with no unsupported entry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…an 2 F.4)

terms.hpe.magnetic_field takes a number (B_z, as before) or [B_x, B_y, B_z] (LPSE
hpe.magneticField). With an in-plane component the 2-D tracker carries a third momentum
component p_z (0 at load and at thermalising re-injection; the EPW force stays in-plane;
the projected histograms and the cone instrument use the in-plane velocity) and every
sub-step rotates the 3-momentum about B-hat by |omega_c| dt / gamma (Rodrigues, the exact
Boris rotation). With B_z only (float or [0, 0, B_z]) the (p_x, p_y) tracker and its
rotation are unchanged bit for bit. The deck translator maps the full vector instead of
reporting the in-plane components unsupported.

Tests: a B_x-only free-streaming push over a quarter cyclotron period turns p_y into p_z
(1e-9) with |p| and the kinetic energy conserved (1e-12) and the y displacement equal to
the scheme's rotated-momentum sum; [0, 0, B_z] reproduces B_z bit for bit with the
two-momentum shape; the translator maps "0 0 3" -> 3.0 and "1.5 0 3" -> [1.5, 0, 3].

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
physicistphil and others added 5 commits October 20, 2026 17:21
test_srs_smoke_with_hpe (slow) asserted the final hpe_gamma_ratio_kpeak
> 0.5 with 20k particles and an unseeded EPW noise source. That statistic
is shot-noise limited: over noise seeds 1-10 it spanned 0.0-2.39 (4/10
above 0.5), so the test passed or failed by seed (it failed in the round-9
suite run after passing the run before). Following the no-post-hoc-seed
rule: criterion unchanged (> 0.5), n_particles 1M and the noise seed 7919
were fixed before validation; on fresh seeds 7919 and 11-16 the final
ratio was 0.59-1.33 (7/7 pass). The spread at 1M is still about +-40 %;
the docstring records the numbers. Run time 25 s -> 49 s.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…d/lpse-parity-plan2

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… feat/lpse2d-hpe-spatial-fv

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
One table in the lpse2d overview with each intentional departure from the
original LPSE C++ code known as of 2026-10-21: what LPSE does (with the
source line), what adept does, and who decided it / the commit (HPE rate
limits after the energy-conservation multiplier, the FD IAW fine-to-coarse
mask, the FD combined Landau step for negative rates, the FD IAW noise
support, source_window and srs_k_filter on the combined source, the
quasi-1D resonance-absorption filter). USER request. The terms.iaw.noise
row now says the max_wavenumber cap is LPSE's own (IawSolver::addNoise);
only its intersection with the anti-aliasing mask is a departure.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…at/lpse2d-hpe-spatial-fv

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@ergodic-reviewer ergodic-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes for two [P2] correctness issues:

  • The separate solver consumes the previous step's prescribed pump after the step-order change (inline comment).
  • Windowed dissipation omits the energy lost when damped window fields are recombined, leaving the derived energy budget incomplete (inline comment).

Postprocessing: windowed HPE distributions have NetCDF persistence in binary/series.xr, optional separate histogram cadence, and distribution plots. BaseLPSE2D.post_process() is callable independently of the numerical solve. The energy-accounting defect above still needs correction.

The previously reported FD combined negative-rate branch is corrected in this head. Static review only: no tests, benchmarks, or postprocessing were executed, and no generated artifacts were opened. Oversized tool responses prevented exhaustive inspection of some large files; findings are grounded in retrieved source, per-commit patches, and tests.

epw_in = new_y
if iaw_ends is not None:
epw_in = {**new_y, "iaw_density": Linear(*iaw_ends, interpolate=self.interpolate_epw).at(0.5)}
new_y["epw"], deltas = self.epw.advance(t, epw_in, args)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Refresh the prescribed pump before the IAW/EPW updates

Moving light_split_step() after self.epw.advance() also moves the only evaluation of the prescribed pump after its consumers. With pump_depletion: false and drivers.E0 present, light_split_step() stores E0_fn(t) in y["E0"]; consequently the next call's EPW update reads the previous step's pump, not the pump at its current t. BaseLPSE2D.init_state_and_args() initializes E0 to zero, so the first step also omits the prescribed pump entirely. SpectralEPWSolver.advance() reads that array directly for both TPD and SRS, while the Raman update evaluates its callable at the current substep times.

This delays pulse envelopes/KAP phase changes and makes the two sides of the SRS coupling use different pump times; an active IAW likewise receives the stale prescribed-pump drive. Refresh the prescribed field at the current time before the IAW/EPW consumers, without advancing the evolved light early. Add a regression with an actual time-dependent drivers.E0 and nonzero initial EPW/Raman field: the current step-order test uses args = {"drivers": {}}, so it does not exercise this branch.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed on plan2 in 5119513, where 914a0d7 introduced the step order; merged here in 2239faa. With pump_depletion: false and drivers.E0, SplitStep.__call__ now writes the prescribed pump at the step's start t into the state first (SplitStep.prescribed_pump), before the IAW and the EPW, or the combined field, read it. The evolved light is not advanced early.

For the record, this departs from LPSE, which has the lag you describe:

  • ZakharovSolver::evolve runs IAW, then LW, then laser->computeFields(theCurrentTime, …), and LightSolver::computeStaticE0_* evaluates the pump at that start time (exp(I (phase - dOmega time)), applyPulseShapeStatic(time)). Its LW therefore reads the previous step's pump.
  • LPSE never evaluates the static field at setup, so the first step has no pump.

This is now listed in the overview's "Departures from LPSE" table (30493b4).

Regression: test_step_order.py::test_prescribed_pump_is_refreshed_before_the_iaw_and_epw[separate, combined]. One step inside the pump's rise, with an IAW and a nonzero EPW / E1, starts from states whose E0 is zero, the pump at t − dt, or the pump at t; the outputs are identical (exact). Before the fix, the separate EPW differed in every mode, and on the combined path the IAW drive differed.

Comment thread adept/_lpse2d/helpers.py Outdated

loss_density = window_density(jnp.sqrt(jnp.maximum(loss_w, 0.0)))
if signed_loss:
# terms.hpe.allow_growth: each window's gain modes enter with a minus sign (as below)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Account for recombination loss in the windowed energy budget

The windowed get_default_save_func() branch sums each chunk's independent damping loss, but SpectralEPWSolver.windowed_landau_damping() subsequently multiplies the damped chunks by sqrt(w_j) and sums the fields before measuring their energy. The per-window sum does not include the additional loss from that recombination. This already occurs for non-negative, k-independent rates that differ between overlapping windows: the solver averages amplitude attenuation factors, whereas this diagnostic averages their squared attenuation factors. Thus the reported loss is not the energy removed by the implemented damping step, even on a uniform periodic box with no other physics.

diagnostics.series_metrics() integrates this value into electron_energy_final and the EPW absorbed-fraction metrics, leaving part of the windowed solver's energy removal unaccounted for. Derive the budget from the actual before/after damping operator, or expose the recombination loss as a separate numerical-loss channel rather than silently omitting it. advance() already measures the actual damping change for its energy ledger. Please compare the diagnostic/budget against that change for unequal overlapping-window rates; test_windowed_dissipation_is_signed_with_allow_growth currently checks the independent-chunk formula, not the energy change of the reconstructed field.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f0a35b2. The windowed branch now applies the solver's own operator to the current state and takes the energy density before minus after, (|psi|^2 − |psi'|^2) / dt. The operator recombines the damped windows as fields, drops the recombined k = 0 part as windowed_landau_damping does, and applies the collisions to the whole field. The box integral is therefore the energy the damping step removes, and it is signed with growth rates. This replaces 359f9cd's L⁺/L⁻ split for the windowed branch.

Regression: test_hpe_windows.py::test_windowed_dissipation_is_the_energy_the_damping_step_removes, which replaces the old test of the per-window formula. The series equals the energy change of SpectralEPWSolver.windowed_landau_damping, then the collisions, at rtol 1e-12 in two cases:

  • unequal k-independent positive rates (5 / 40 / 120 /ps in 3 overlapping windows), where the old per-window sum is 4.2 % below the energy removed, as you describe;
  • mixed-sign rates.

Non-negative rates stay bitwise identical with and without allow_growth. Docs: the n_windows row.

One assertion was corrected after the first run. The inequality "the energy removed exceeds the per-window sum" first compared the series values directly. The total-to-electric factor is negative on this box, so those values carry the opposite sign; the check now compares the energy removed (both divided by the same prefactor). The rtol 1e-12 equalities were fixed in advance and are unchanged.

physicistphil and others added 2 commits October 20, 2026 18:48
…xer)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…o feat/lpse2d-hpe-spatial-fv

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@ergodic-reviewer ergodic-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes at e7977c32f6ca067692a129bd55cd9c46287946aa. These two [P2] defects remain; linking the existing inline threads rather than duplicating them:

  • Prescribed pump is refreshed after its consumers (thread). On the separate, undepleted-pump path, SplitStep.__call__() advances the IAW/EPW before light_split_step() writes E0_fn(t). The EPW therefore uses the previous step's pump (initially zero), while Raman evolution evaluates the current prescription. Refresh the prescribed pump before those consumers, without advancing evolved light early. test_step_order.py uses empty driver arguments and does not cover this case.
  • Windowed energy accounting omits recombination loss (thread). get_default_save_func() sums independently damped chunk losses, whereas windowed_landau_damping() recombines the attenuated fields before their energy is measured. Unequal rates in overlapping windows consequently remove additional energy that is absent from epw_dissipation and the derived absorption/electron-energy budget. Account for the actual operator's loss, or expose the extra numerical loss separately. The current signed-dissipation test checks the chunk formula, not the reconstructed field's energy change.

Postprocessing: windowed histograms have NetCDF persistence in binary/series.xr, optional separate histogram cadence, and distribution plots. BaseLPSE2D.post_process() is callable separately from the numerical solve; normal ergoExo execution logs the artifacts. The windowed energy-budget defect above still needs correction.

Static review only: no tests, benchmarks, or postprocessing were executed, and no generated artifacts were opened. Oversized tool responses prevented exhaustive full-diff inspection; these findings are supported by retrieved source, relevant per-commit patches, and test code.

physicistphil and others added 3 commits October 21, 2026 01:02
…ong statements

The departures section of the lpse2d overview was built from the last
review rounds. A sweep of the configuration reference, code comments,
commit messages and the LPSE sources (2026-10-21) adds the documented
intentional departures it missed (resonance absorption with
landau_update > 1; the QLE resonance Jacobian, Fokker-Planck form,
Landau-rate gradient and sub-cycling / implicit step; FD IAW PPM
upwinding; non-local thermal filamentation outside the IAW band and its
normalisation; spectral injectors; snap_beam_ky; one light time step;
the x-max injector plane; the HPE tracker), lists undocumented
differences found by the audit as not yet decided (KAP groups on the
evolved pump, the IAW amplitude clamp on every solver, the Landau lower
threshold with HPE / QLE, hpe.dampingRateMinFraction, the
energy-conservation gate), the defaults that differ, the translator
restrictions and the adept-only options. The section says it may still
be incomplete. HPE energy conservation is not a departure on this branch
(exponential per-mode loss, secant-solved, as LPSE; only ergodicio#372 keeps the
linearised form).

Statements corrected (checked against the code / LPSE): the default
spectral injector width is half a local wavelength, pi/k (code), not
2 pi/k; LPSE's resonanceAbsorption is the resonance_absorption option,
not unimplemented; landau_multiplier is adept-only (LPSE's LD_multiplier
is the HPE energy-conservation multiplier); KAP beam independence holds
for LPSE's static pump only; the default landau_form is relativistic
(epw.py docstring); LPSE's implicit QLE theta is 0.5 / 0.67, not 1
(qle.py docstring); the overview's absorbing mask names the exp default;
terms.qle.solver added to the QLE row. Docs and docstrings only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…at/lpse2d-hpe-spatial-fv

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
terms.hpe.n_windows and hist_smooth are adept-only (LPSE's tracker is
box-averaged; its hpe.vdf.smoothing is not mapped by the translator).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@ergodic-reviewer ergodic-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes at cddb6dc26ead67967d4e1391538c0cdf08b51529. Two previously reported [P2] correctness defects remain; linking the existing inline threads rather than duplicating them:

  • Refresh the prescribed pump before its consumers (thread). On the separate, undepleted-pump path, SplitStep.__call__() still advances the IAW/EPW before light_split_step() writes E0_fn(t). The EPW consumes the previous step's pump (initially zero), while Raman evolution evaluates the current prescription. This delays time-dependent forcing and makes the coupled updates use inconsistent pump times. Refresh the prescription before those consumers without advancing evolved light early. The step-order test still uses empty driver arguments, so it does not exercise this case.
  • Account for window recombination loss (thread). get_default_save_func() sums independent chunk losses, but SpectralEPWSolver.windowed_landau_damping() recombines attenuated fields before measuring their energy. Unequal rates in overlapping windows remove additional energy absent from epw_dissipation and the derived electron-energy/absorption budget. Measure the actual operator's loss or expose the additional numerical loss separately; compare against the reconstructed field's energy change, not only the independent-chunk formula used by the current signed-dissipation test.

Postprocessing: windowed histograms have NetCDF persistence in binary/series.xr, optional separate histogram cadence/coordinates, and distribution plots. BaseLPSE2D.post_process() remains callable separately from the numerical solve. The energy-accounting defect above still needs correction.

Static review only: no tests, benchmarks, or postprocessing were executed, and no generated artifacts were opened. Oversized tool responses prevented exhaustive full-diff inspection; the findings above are supported by retrieved source, relevant per-commit patches, and tests.

physicistphil and others added 7 commits October 22, 2026 12:09
…into lpse2d/lpse-parity

Brings the interpolated field-save fix and the saved-EPW ex/ey scale fix
(and the main commits under 984f0f6). helpers.py conflicts resolved by
keeping both sides' new helpers; the interpolated save is ergodicio#388's
_interpolated_field_save, wrapped with this branch's combined-mode
Raman-light projection (raman_light_part) and the epw_ledger skip, as the
full-grid save does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…lpse-parity-plan2

Brings PR ergodicio#388 (interpolated field save on a coarser grid, saved EPW ex/ey
without the stray 4/(nx ny)) via lpse2d/lpse-parity. helpers.py conflicts
resolved toward plan2's additions on ergodicio#388's code:
- make_field_xarrays: ergodicio#388's unscaled ex / ey with plan2's three-component
  light fields (e0z / e1z).
- interpolated save: ergodicio#388's _interpolated_field_save, with plan2's skipped
  keys (fd IAW super-sampled fields, iaw_density_old, epw_ledger) and the
  QLE vdf / gamma_L saved verbatim.
- Needed by the coarser save grid: QLE's gamma_L enters the k-fields at
  the save grid's k modes (the simulation-grid modes of the same integer
  frequency, _grid_index_of_save_modes); with ergodicio#388's coarse k axes the
  full-grid gamma_L no longer matched them and make_field_xarrays raised
  (256 vs 160 modes). Test: test_qle_rate_is_saved_on_an_interpolated_save_grid
  (exact; fails without the index map).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…eat/lpse2d-hpe-spatial-fv

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…finer than the test grid

After the PR ergodicio#388 merge the interpolated field save refuses a save
spacing finer than the simulation grid (it used to write non-finite edge
rows). Five tests build a 0.1 um grid from tpd.yaml, whose save.fields
x / y spacing is 80 nm / 128 nm, and failed in setup with that refusal:
test_combined_split_step_runs_with_pump_depletion_iaw_noise_and_reports_transverse_light,
test_poynting_of_a_plane_wave_is_group_velocity_times_intensity,
test_thomson_probe_picks_the_mode_in_its_window,
test_ledger_reported_in_epw_energy_units,
test_ledger_off_leaves_state_and_series_unchanged.
They test diagnostics on full-grid states (the old interpolated save kept
nx x ny points, so those happened to fit), so their configs now drop
save.fields.x / y; no assertion changed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…d/lpse-parity-plan2

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…rum probes on this branch

- test_field_save.py (from PR ergodicio#388): its box ran TPD and SRS together on
  the separate EPW solver, which this branch refuses (as LPSE); SRS is now
  off (TPD + IAW; the combined solver would save E1 as its transverse
  part, which the analytic longitudinal E1 would not survive). The saved
  field set includes the out-of-plane e0_z / e1_z this branch's state
  carries (plan 2 F.1); tolerances unchanged.
- test_light_spectrum.py combined probes: full-grid field save (tpd.yaml's
  0.08 um save spacing is finer than their grid, refused since PR ergodicio#388).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ons) into feat/lpse2d-hpe-spatial-fv

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@ergodic-reviewer ergodic-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes at 8ed34919fc192e0c8a1abc7b7519bcd7f267f0da. Two [P2] correctness defects remain; reusing the existing inline threads rather than duplicating comments:

  • Refresh the prescribed pump before its consumers (thread). On the separate, undepleted-pump path, SplitStep.__call__() advances the IAW/EPW before light_split_step() writes E0_fn(t). The EPW consumes the previous step's pump (initially zero), while Raman evolution evaluates the current prescription. Time-dependent forcing is therefore delayed and the coupled updates use inconsistent pump times. Refresh the prescription before those consumers without advancing evolved light early. test_step_order.py uses empty driver arguments and does not cover this case.
  • Reconcile default windowed dissipation with recombination loss (thread). get_default_save_func() sums independent window-chunk losses, whereas windowed_landau_damping() recombines the attenuated fields. Unequal overlapping-window rates introduce additional energy loss not represented by that sum. diagnostics.series_metrics() still derives electron-energy/absorption metrics from epw_dissipation, so those results do not account for the full applied loss. The optional operator energy ledger records actual damping deltas, but does not reconcile these default metrics. Incorporate the actual loss or expose the recombination contribution separately in the budget, with a comparison against the reconstructed field's energy change rather than only the chunk formula.

Postprocessing: windowed histogram series and distribution plots are implemented, with optional separate histogram cadence. BaseLPSE2D.post_process() remains callable outside the numerical solve; the parity harness also persists returned datasets for subsequent analysis. The default energy-accounting limitation above remains.

Static review only: no tests, benchmarks, or postprocessing were executed, and no generated artifacts were opened. Oversized tool responses prevented exhaustive inspection of some large diffs/files; these findings are grounded in retrieved source, relevant per-commit patches, and tests.

physicistphil and others added 8 commits October 23, 2026 11:55
With an undepleted, prescribed pump (pump_depletion: false, drivers.E0)
the step order of 914a0d7 (IAW, EPW, then light) left the only
evaluation of the pump, E0_fn(t) in light_split_step, after its
consumers: the IAW drive and the EPW's TPD / SRS sources read the
previous step's pump -- zero on the first step, since the state starts
at E0 = 0 -- while the Raman light evaluates the pump at its own sub-step
times. The pump at the step's start t is now written to the state at the
top of SplitStep.__call__, for the separate and the combined path, before
any consumer (SplitStep.prescribed_pump; the evolved pump is untouched).

This departs from LPSE, whose static pump is computed after the LW step
at the step's start time (ZakharovSolver::evolve -> LightSolver::
computeFields), i.e. the same one-step lag, and is zero on the first step
(no setup-time evaluation). PR ergodicio#387 review; recorded in Departures from
LPSE. The shift is one EPW step of the pump envelope / KAP phase.

Test: test_prescribed_pump_is_refreshed_before_the_iaw_and_epw[separate,
combined]: one step inside the pump's rise from states whose E0 is zero,
the pump at t - dt, or the pump at t gives identical outputs (exact).
Before: the EPW (separate) and the IAW drive (combined) differed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
compare_hpe_flux averaged the whole LPSE lpse.flux run while
hpe_wall_power fits adept's cumulative wall energy over lo < t <= hi, so
startup or late transients made matching wall powers look like a parity
discrepancy. The LPSE samples are now restricted to the same window (ps,
as the file's time column), and an empty window raises (PR ergodicio#378 review).

Test: test_hpe_flux_compares_the_same_time_window (a reference with flux 1
inside and 100 outside the window gives exactly 1; before 64).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… metric

cmd_verify kept a running maximum of the relative differences; a NaN
recomputed metric made its difference NaN, max() kept the finite maximum,
and the run printed MISMATCH yet PASS with exit status 0 (reachable through
harness.compare, e.g. a NaN in iaw_density_abs_max). The comparison is now
_verify_report: non-finite values and out-of-tolerance metrics are failures
counted per metric, alongside missing ones (PR ergodicio#378 review).

Tests: test_hpe_flux_compares_the_same_time_window (previous commit's
regression) and test_verify_fails_when_a_recomputed_metric_is_non_finite
(cmd_verify with MLflow and the download stubbed: NaN -> exit 1, matching
metrics -> 0; before: 0).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
PR ergodicio#388's interpolated save accepts a save grid of one x point
(n_save = max(1, round(L / dx_save)), e.g. save.fields.x.dx the box
length). With save.fields.poynting the flux maps take d/dx on the save
grid, and np.gradient raised in make_field_xarrays after the solve,
before the series, checkpoint and artifacts were written (PR ergodicio#372
review). get_save_quantities now refuses that combination at setup;
two or more x points are accepted. Docs: the save.fields.poynting text.

Test: test_poynting_refuses_a_single_point_x_save_grid (dx = box length
refused, half the box accepted with 2 points). Before: did not raise.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…removes

SpectralEPWSolver.windowed_landau_damping damps each window's chunk
c_j = FFT(sqrt(w_j) psi) of the energy field psi = |k| phi and recombines
the chunks as fields, psi' = sum_j sqrt(w_j) IFFT(exp(-gamma_j dt) c_j),
before the energy counts; by Cauchy-Schwarz |psi'|^2 <= sum_j |chunk_j'|^2,
equal only where overlapping windows share a rate. The windowed branch of
get_default_save_func summed the per-window losses
sum_j (1 - exp(-2 gamma_j dt)) |c_j|^2 instead, so it missed that
recombination loss; series_metrics integrates the series into
electron_energy_final and the EPW absorbed fractions (PR ergodicio#387 review).
The diagnostic now applies the solver's operator to the current state --
recombination, dropping the recombined k = 0 part (it has no phi), the
collisions on the whole field -- and takes the before / after energy
density (|psi|^2 - |psi'|^2) / dt; its box integral is the energy the
step removes, signed with growth rates. This replaces 359f9cd's
L+ / L- split for the windowed branch. Docs: n_windows row.

Test: test_windowed_dissipation_is_signed_with_allow_growth (from 359f9cd)
asserted the per-window formula and is replaced by
test_windowed_dissipation_is_the_energy_the_damping_step_removes: the
series equals the energy change of windowed_landau_damping (then the
collisions) for unequal k-independent positive rates 5 / 40 / 120 /ps in
3 overlapping windows and for mixed-sign rates (rtol 1e-12); non-negative
rates are bitwise the same with and without allow_growth. In the first
case the per-window sum is 4.2 % below the energy removed (the old value).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…d/lpse-parity-plan2

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…oynting check) into feat/lpse2d-hpe-spatial-fv

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@ergodic-reviewer ergodic-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The prescribed-pump timing issue is corrected, and windowed dissipation now includes recombination and collision losses, with focused regression tests. No new high-confidence blocking defect identified in the inspected code. Non-blocking feedback: extend the windowed energy-accounting regression to a density ramp.

Postprocessing: windowed HPE distributions have saved histogram series, optional separate histogram cadence, and distribution plots. BaseLPSE2D.post_process() remains callable separately from the numerical solve; the parity harness persists returned datasets for subsequent analysis.

Static review only: no tests, benchmarks, or postprocessing were executed, and no generated artifacts were opened. Oversized tool responses prevented exhaustive inspection of some large files, notably helpers.py and lpse_deck.py; this is not a full-diff sign-off.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: cover spatial weighting in the windowed-loss regression. test_windowed_dissipation_is_the_energy_the_damping_step_removes() explicitly asserts np.ptp(factor) == 0.0, so its before/after comparison only covers a uniform density. Windowed HPE also supports density ramps, where energy_total_factor varies with position. A ramp case comparing epw_dissipation against before/after energy using the same spatial weighting as the saved energy diagnostics would exercise that part of the scientific output, in addition to recombination. This is a validation gap, not a demonstrated failing run.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant