Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
58b5038
lpse2d: LPSE term parity -- TPD coefficient, thermal noise, Landau fo…
physicistphil Sep 14, 2026
87cd442
lpse2d: per-operation EPW energy ledger (terms.epw.energy_ledger)
physicistphil Sep 14, 2026
32f8c44
lpse2d: spectral light propagator and collisional light absorption
physicistphil Sep 14, 2026
77a545b
lpse2d: combined TPD+SRS solver (terms.epw.solver: combined)
physicistphil Sep 14, 2026
91b8de1
lpse2d: LPSE deck translator, metrics/field readers, and LPSE-style d…
physicistphil Sep 15, 2026
9ed9083
lpse2d: make the SRS source k-filter optional (LPSE lw.kFilter, off b…
physicistphil Sep 15, 2026
ffaf4b9
lpse2d: transverse projection of the SRS light sources and band-limit…
physicistphil Sep 15, 2026
744eddb
lpse2d: oblique incidence of the pump (drivers.E0.angle)
physicistphil Sep 15, 2026
a829cce
lpse2d: the original LPSE density profiles (density.basis: lpse-<shape>)
physicistphil Sep 15, 2026
9b78d9b
lpse2d: LPSE HPE controls and instruments (terms.hpe.*)
physicistphil Sep 15, 2026
2c6db21
lpse2d: absolute-threshold bisection driver (adept._lpse2d.threshold)
physicistphil Sep 15, 2026
704782c
lpse2d: separate absorber strength for the light fields (terms.light.…
physicistphil Sep 15, 2026
2cba188
lpse2d: checkpoint / restart (save.checkpoint, restart.file) and LPSE…
physicistphil Sep 15, 2026
a498561
lpse2d: LPSE diagnostics (Poynting fields, Thomson probes) and therma…
physicistphil Sep 15, 2026
c94d7b4
lpse2d: pump beamlets, transverse profile, Kubo-Anderson bandwidth, p…
physicistphil Sep 15, 2026
5c72f57
lpse2d: translator defaults grid.antiAliasing.range to LPSE's none wh…
physicistphil Sep 15, 2026
01805c3
lpse2d: the combined solver damps the pump with the light absorber (n…
physicistphil Sep 15, 2026
e39a9fb
lpse2d: translator reads LPSE's hpe.metrics.fluxMetric.N / powerMetri…
physicistphil Sep 15, 2026
e3b5f8d
lpse2d: wrap the HPE power-metric prefix line; translator test uses L…
physicistphil Sep 15, 2026
4c70767
lpse2d: LPSE particle-wall semantics for HPE; flux bins in keV; VminO…
physicistphil Sep 15, 2026
3c42990
lpse2d: spectral light solver sub-cycles to one cell per sub-step for…
physicistphil Sep 15, 2026
59e01d0
fix(lpse2d): combined solver builds its noise kick for the light sub-…
physicistphil Oct 8, 2026
1e3cf4a
fix(lpse2d): combined solver injects the pump with the spectral injector
physicistphil Oct 8, 2026
f6b1e1a
fix(lpse2d): combined-mode field saves and Raman flux maps exclude th…
physicistphil Oct 8, 2026
27418ef
fix(lpse2d): LPSE binary density files are not transposed twice
physicistphil Oct 8, 2026
f6c7429
fix(lpse2d): quasi-1D HPE push keeps periodic crossings for the wall …
physicistphil Oct 8, 2026
278fda7
test(lpse2d): the test_010 beam-direction translation runs from a che…
physicistphil Oct 8, 2026
72b6df4
fix(lpse2d): combined solver injects the Raman seed (drivers.E1)
physicistphil Oct 9, 2026
c387f68
fix(lpse2d): HPE wall-energy bins count only energies inside their edges
physicistphil Oct 9, 2026
e5c7327
fix(lpse2d): one normal-incidence beam keeps its frequency shift and …
physicistphil Oct 9, 2026
c5fee59
fix(lpse2d): a disabled LPSE laser translates to no pump
physicistphil Oct 16, 2026
bc78533
fix(lpse2d): Raman flux diagnostics use the combined field's carrier
physicistphil Oct 16, 2026
e71050f
fix(lpse2d): the IAW absorber damps over the whole strided IAW step
physicistphil Oct 16, 2026
88a65bd
fix(lpse2d): the IAW and the Raman absorption see the combined light …
physicistphil Oct 16, 2026
bac837e
fix(lpse2d): combined runs skip the separate light solvers and their …
physicistphil Oct 16, 2026
f123c5e
fix(lpse2d): the deck translator runs LPSE's square cells
physicistphil Oct 19, 2026
3fccddb
fix(lpse2d): the FD pump injector refuses the time controls it does n…
physicistphil Oct 19, 2026
c440b7e
fix(lpse2d): the combined field is damped at the Raman absorption
physicistphil Oct 19, 2026
6724180
fix(lpse2d): threshold bisection refuses a zero lower bound
physicistphil Oct 19, 2026
2577b05
fix(lpse2d): build the separate EPW solver only on the separate path
physicistphil Oct 20, 2026
55fd66f
fix(lpse2d): refuse fd rotation coupling with the transverse projection
physicistphil Oct 20, 2026
19cbe2e
fix(lpse2d): put the interpolated field save on a coarser grid, in x-…
physicistphil Oct 20, 2026
952252c
fix(lpse2d): the spectral light band ignores the EPW wavenumber cap
physicistphil Oct 20, 2026
ca6ced8
fix(lpse2d): signed EPW dissipation with HPE allow_growth
physicistphil Oct 20, 2026
9119fd2
fix(lpse2d): combined-mode EPW dissipation and lw heating use the com…
physicistphil Oct 20, 2026
3e8f4f9
docs(lpse2d): HPE gamma limits bound the rate before the energy-conse…
physicistphil Oct 20, 2026
971f999
fix(lpse2d): HPE gamma limits bound the applied rate after energy con…
physicistphil Oct 20, 2026
9eb7ff5
fix(lpse2d): saved EPW ex/ey without the stray 4/(nx ny) factor
physicistphil Oct 20, 2026
984f0f6
docs(lpse2d): scope the saved-field Fourier identities to full-grid s…
physicistphil Oct 20, 2026
86606c8
test(lpse2d): run the slow HPE trapping and smoke regressions again
physicistphil Oct 20, 2026
4d193ad
test(lpse2d): HPE linear closure is measured against the configured L…
physicistphil Oct 20, 2026
df43c9b
test(lpse2d): HPE smoke test with enough particles for its kpeak check
physicistphil Oct 21, 2026
e2bc460
Merge PR #388 (fix/lpse2d-interpolated-field-save, 984f0f6) into lpse…
physicistphil Oct 22, 2026
a85fbd5
test(lpse2d): full-grid field saves where tpd.yaml's save spacing is …
physicistphil Oct 22, 2026
a62b52c
fix(lpse2d): refuse Poynting maps on a single-point x save grid at setup
physicistphil Oct 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
365 changes: 365 additions & 0 deletions adept/_lpse2d/core/combined.py

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P1] Scale the noise kick to the timestep at which it is applied

CombinedSolver.__init__ builds self.noise_kick with noise_kick_spectrum(cfg), which uses grid.dt. However, substep calls add_noise on every light sub-step, while propagation/damping advance by self.dt_l. With light_substeps > 1, this repeatedly injects an outer-step kick against sub-step damping. In particular, thermal noise no longer equilibrates to the configured fluctuation-dissipation spectrum, and changing light subcycling changes the physical seed level. diagnostics.expected_noise_energy still assumes one outer-step kick, so its growth-fit floor also disagrees with the simulation. Build the kick for the actual noise timestep (and keep the diagnostic consistent), or apply the outer-step source only once per outer step. Add a combined-solver thermal-noise/subcycling regression; the current equilibrium test exercises only SpectralEPWSolver.

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 59e01d0. epw.noise_step(cfg) returns the step at which the active solver applies the kick (the light sub-step for the combined solver) and the rate it damps with; CombinedSolver builds its kick from it and diagnostics.expected_noise_energy uses the same values, so the growth-fit floor matches. The flat model's kick is now amplitude * sqrt(dt_EPW * dt) (unchanged for the separate solver), so its seed level does not depend on the sub-step count either. New tests: the combined steady state is independent of light_substeps for thermal and flat noise (8 vs 2 sub-steps gave 3.96x / 3.98x on the old kick), and equals the expected_noise_energy floor (15 %).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P1] Preserve pump-driver settings on the combined-solver path

CombinedSolver.calc_pump_source only constructs a normal-incidence scalar source, and substep adds it only to E0[..., 1]. It never consumes the derived beam angles/fractions/phases/frequency offsets, transverse profile, KAP bandwidth, or pulse table implemented by SpectralCoupledLight. Setup nevertheless accepts these settings with epw.solver: combined because light.solver is spectral, and SplitStep.combined_step bypasses self.coupled_light. Thus switching an oblique/multi-beam or shaped-pulse run to the solver required for simultaneous TPD/SRS silently changes the pump to a different physical problem. Share the existing spectral injector implementation with the combined solver, or explicitly reject unsupported combinations rather than ignoring them; cover an oblique or multi-beam combined run.

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 1e3cf4a by sharing the implementation: the spectral light solver's injector (beam angles, fractions, phases, frequency offsets, transverse profile, KAP bandwidth, pulse table) moved into SmoothPumpInjector, which SpectralCoupledLight and CombinedSolver both use; the combined step adds the full vector source. Normal incidence is bit-identical to the previous combined source, so the combined parity decks (one normal-incidence beam each) are unaffected. New tests: the combined and spectral sources are identical for two oblique beams with a profile, KAP and a pulse table, and a combined run launches both +-20 deg beams (> 95 % of the power in the two ky rows, balanced to 5 %); both fail on the old solver.

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] Apply the configured Raman absorption to the combined field

In CombinedSolver.__init__, self.collisional is built solely from derived['nu_coll'] (the EPW collision setting), and __call__ uses that factor for all of E1. The only call to light_absorption_rates(cfg) is inside the evolved-pump branch and discards its Raman return value. Thus a combined run with terms.epw.damping.collisions: false and terms.light.absorption: true or a positive numeric rate has no configured inverse-bremsstrahlung attenuation of its Raman field, unlike SpectralRamanLight/SpectralCoupledLight. This changes the scattered-light energy and instability evolution; with Raman thermal filamentation enabled, IonAcousticWave even computes heating from an absorption rate that the field update never applies. Wire the configured combined-carrier absorption into the combined evolution, keeping its collision/noise balance consistent, or explicitly reject the unsupported setting. Add a transverse-wave decay regression through the combined update; the current combined-carrier test checks the absorption helper and thermal coefficient, not attenuation of E1.

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 c440b7e by wiring the absorption in. The combined field is now damped at exp(-nu_R dt (n/n_env)^2), where nu_R is the Raman absorption at the combined carrier (light_absorption_rates). This is how LPSE damps the Raman class in combined mode, at the field's critical density n_env, ignoring lw.collisionalDampingRate (LightSolver::calculateScatteringPotential); #378 already did the same.

To keep the collision/noise balance consistent:

  • epw.noise_step calibrates the combined noise kick with the same nu_R;
  • the IAW perturbation enters the absorbing density, as it does in the detuning and in the evolved pump's absorption.

Combined runs that set EPW collisions no longer apply them to the combined field.

Test: test_the_combined_field_is_damped_at_the_raman_absorption, run with collisions off, terms.light.absorption 300/ps, n = 0.15 nc, n_env = 0.2 and a periodic box. Exact identities (rtol 1e-10), fixed before the run:

  • one EPW step multiplies a transverse E1 plane wave by exp(-nu_R dt (n/n_env)^2);
  • with a uniform iaw_density dn it multiplies it by exp(-nu_R dt (n/n_env + dn)^2);
  • noise_step returns nu_R.

Before the fix the field kept its amplitude.

The combined noise steady-state tests now balance the kick against this rate: absorption 80/ps gives nu_R = 20/ps at n_env = 0.25, with collisions off. Before, they balanced it against the EPW collisions that the field no longer feels. Their tolerances are unchanged.

Large diffs are not rendered by default.

487 changes: 404 additions & 83 deletions adept/_lpse2d/core/epw.py

Large diffs are not rendered by default.

290 changes: 229 additions & 61 deletions adept/_lpse2d/core/hpe.py

Large diffs are not rendered by default.

339 changes: 320 additions & 19 deletions adept/_lpse2d/core/iaw.py

Large diffs are not rendered by default.

70 changes: 68 additions & 2 deletions adept/_lpse2d/core/laser.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import numpy as np
from jax import Array
from jax import numpy as jnp

Expand All @@ -16,7 +17,24 @@ def __init__(self, cfg) -> None:
self.Lx = self.nx * self.dx # box length in x (um)
self.dE0x = jnp.zeros((cfg["grid"]["nx"], cfg["grid"]["ny"]))
self.x = cfg["grid"]["x"]
self.y = cfg["grid"]["y"]
self.dy = cfg["grid"]["dy"]
self.dky = 2.0 * jnp.pi / (self.ny * self.dy)
self.background_density = cfg["grid"]["background_density"]
# in-plane angle of incidence from +x (drivers.E0.angle, degrees; LPSE laser.N.direction).
# The pump is one k-mode snapped to the grid in kx *and* ky (LPSE makeStaticField),
# polarised in the plane perpendicular to the snapped k (LPSE polarization 0)
self.angle = float(np.deg2rad(float(cfg["drivers"].get("E0", {}).get("angle", 0.0))))
pump = cfg["drivers"].get("E0", {}).get("derived", {})
self.beam_angle = np.atleast_1d(np.asarray(pump.get("beam_angle", [self.angle]), dtype=np.float64))
self.beam_fraction = np.atleast_1d(np.asarray(pump.get("beam_fraction", [1.0]), dtype=np.float64))
self.beam_phase = np.atleast_1d(np.asarray(pump.get("beam_phase", [0.0]), dtype=np.float64))
self.beam_delta_omega = np.atleast_1d(np.asarray(pump.get("beam_delta_omega", [0.0]), dtype=np.float64))
self.kap_bandwidth = float(pump.get("kap_bandwidth", 0.0) or 0.0)
self.kap_seed = int(pump.get("kap_seed", 0) or 0)
self.pulse_t = jnp.asarray(pump["pulse_t"]) if "pulse_t" in pump else None
self.pulse_amp = jnp.asarray(pump["pulse_amp"]) if "pulse_amp" in pump else None
self.multi_beam = len(self.beam_angle) > 1 or float(self.beam_angle[0]) != 0.0

# Speckle state
self.speckle_profile = None
Expand Down Expand Up @@ -63,11 +81,17 @@ def laser_update(self, t_ps: float, y: jnp.ndarray, light_wave: dict) -> tuple[j
# (envelope-density) plasma frequency wp0 and snapped to the nearest FFT grid mode, so the
# pump is exactly periodic on the grid. The local density swelling is applied as an
# amplitude factor *after* the transform (not as a spatially-varying phase).
if self.multi_beam or self.kap_bandwidth > 0.0 or self.pulse_t is not None:
return self._oblique_update(t_ps, light_wave)
Comment on lines +84 to +85

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] Honor phase and detuning for a single normal-incidence beam

self.multi_beam is false for a one-element beams list at angle zero. Without KAP or a pulse file, that sends the pump through the legacy branch, which reads only the color-level light_wave['delta_omega']/phases, not beam_delta_omega or beam_phase. Thus an explicit beams: [{intensity: 1, angle: 0, delta_omega: 0.01}] loses its frequency shift on the prescribed-pump path, even though SmoothPumpInjector honors it for an evolved pump. This silently changes the pump frequency and resonance conditions. Dispatch based on non-default beam metadata as well, or share the beam-aware implementation while preserving the default/speckle behavior. Cover a single normal-incidence detuned beam, not only oblique or multi-beam cases.

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 e5c7327: the normal-incidence branch adds beams[0]'s delta_omega and phase to every color (exact no-ops at the defaults, so beam-free and speckle configs are unchanged). The FD evolved pump had the same gap (it read no beam metadata, and setup refuses only angles / multiple beams there) and now does the same. On #378 (8588bf1) the branch also takes beams[0]'s polarization; plan 2's general FD injector already honored per-beam metadata. Test: one normal beam with delta_omega: 0.01, phase: 0.3 equals the same shift and phase given at the color level, on the static and the FD evolved pump (rtol 1e-12); both failed before.

# one beam at normal incidence: its own frequency shift and phase (drivers.E0.beams[0], LPSE
# laser.1.frequencyShift / phase) add to every color's, as in the oblique and evolved paths
dw_b = float(self.beam_delta_omega[0])
phase_b = float(self.beam_phase[0])
E0y_k = jnp.zeros((self.nx, self.ny), dtype=jnp.complex128)
for i in range(len(light_wave["delta_omega"])):
delta_omega = light_wave["delta_omega"][i]
delta_omega = light_wave["delta_omega"][i] + dw_b
intensity = light_wave["intensities"][i, :] # (ny,)
phase = light_wave["phases"][i, :] # (ny,)
phase = light_wave["phases"][i, :] + phase_b # (ny,)

# reference-density wavenumber, snapped to the FFT grid (MATLAB lines 1558-1559)
k0 = self.w0 / self.c * jnp.sqrt((1.0 + delta_omega) ** 2 - self.wp0**2 / self.w0**2)
Expand Down Expand Up @@ -99,6 +123,48 @@ def laser_update(self, t_ps: float, y: jnp.ndarray, light_wave: dict) -> tuple[j

return jnp.stack([self.dE0x, dE0y], axis=-1)

def _oblique_update(self, t_ps: float, light_wave: dict) -> jnp.ndarray:
"""Oblique static pump: each color is the single grid mode nearest to
``k0(delta_omega) (cos a, sin a)`` (LPSE ``makeStaticField`` rounds both components),
built in x-space, with the field along ``(-sin a', cos a')`` of the *snapped* direction
so that E0 is exactly transverse; the swelling factor is applied as in the normal case."""
E0 = jnp.zeros((self.nx, self.ny, 2), dtype=jnp.complex128)
xx = self.x[:, None]
yy = self.y[None, :]
pulse = jnp.interp(t_ps, self.pulse_t, self.pulse_amp) if self.pulse_t is not None else 1.0
for b in range(len(self.beam_angle)):
angle = float(self.beam_angle[b])
dw_b = float(self.beam_delta_omega[b])
beam_phase = float(self.beam_phase[b]) + self._kap_phase(t_ps, b)
for i in range(len(light_wave["delta_omega"])):
delta_omega = light_wave["delta_omega"][i] + dw_b
intensity = light_wave["intensities"][i, :] * float(self.beam_fraction[b]) # (ny,)
phase = light_wave["phases"][i, :] + beam_phase # (ny,)
k0 = self.w0 / self.c * jnp.sqrt((1.0 + delta_omega) ** 2 - self.wp0**2 / self.w0**2)
kx = jnp.round(k0 * jnp.cos(angle) / self.dk) * self.dk
ky = jnp.round(k0 * jnp.sin(angle) / self.dky) * self.dky
k_snapped = jnp.sqrt(kx**2 + ky**2)
pol = jnp.stack([-ky / k_snapped, kx / k_snapped]) # unit vector perpendicular to k
amp = (
pulse * self.E0_source * jnp.sqrt(intensity) * jnp.exp(-1j * (delta_omega * self.w0 * t_ps - phase))
)
carrier = jnp.exp(1j * (kx * xx + ky * yy))
E0 = E0 + (amp[None, :] * carrier)[..., None] * pol[None, None, :]
wpe = self.w0 * jnp.sqrt(self.background_density)
E0 = E0 * ((1.0 - wpe**2 / self.w0**2) ** -0.25)[..., None]
if self.speckle_profile is not None:
raise NotImplementedError("drivers.E0.speckle with a non-zero drivers.E0.angle is not supported")
return E0

def _kap_phase(self, t_ps, beam: int):
"""Kubo-Anderson phase jumps (see SpectralCoupledLight.kap_phase)."""
if self.kap_bandwidth <= 0.0:
return 0.0
tau = 2.0 * jnp.pi / (self.kap_bandwidth * self.w0)
index = jnp.floor(t_ps / tau)
seed = 12.9898 * (index + 1.0) + 78.233 * (beam + 1.0) + 37.719 * self.kap_seed
return 2.0 * jnp.pi * jnp.mod(jnp.sin(seed) * 43758.5453, 1.0)

def calc_ey_at_one_point(self, t: float, density: Array, light_wave: dict) -> tuple[jnp.ndarray, jnp.ndarray]:
"""
This function is used to calculate the coherence time of the laser
Expand Down
114 changes: 94 additions & 20 deletions adept/_lpse2d/core/light.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from jax import numpy as jnp

from adept._base_ import get_envelope
from adept._lpse2d.core.raman import RamanLight
from adept._lpse2d.core.raman import RamanLight, transverse_part


class CoupledLight(RamanLight):
Expand Down Expand Up @@ -41,16 +41,20 @@ class CoupledLight(RamanLight):
paths. This class adds the pump: its coefficients, boundary injector, reciprocal
TPD/SRS couplings, and the coupled staggered loop.

The TPD pump term is constructed directly as the discrete reciprocal of
``SpectralEPWSolver.calc_tpd_source`` (rather than using the abandoned historical
Pump2D prototype):
The TPD pump term is LPSE's (LightSolver.cpp, ``Sc_tpd``; LwSolver.cpp, the
transverse projection; Follett's equation document Eqs 53/55):

dE0_y/dt |_TPD = i e/(4 w0 me) exp(+i (w0 - 2 wp0)t)
* Ehy * div(Eh)
dE0/dt |_TPD = i e/(2 w0 me) exp(+i (w0 - 2 wp0)t) * [Eh div(Eh)]_T

The pair conserves ``|E0|^2 + (wp0/w0)|Eh|^2`` for coupling terms alone. Only
the y-polarized pump participates because the existing TPD operator is written
for that polarization.
for every pump component, with ``[.]_T`` the transverse (divergence-free) part taken
in k-space (``terms.light.tpd_projection``, default on) so that a longitudinal
component is never injected into the light field; ``terms.light.tpd_k_filter``
additionally restricts the term to ``|k| < 1.2 k0 sqrt(1 - n_min)`` (LPSE
``lw.kFilter``). The coefficient is exactly twice the EPW-side TPD source coefficient
``i e/(4 me w0)``: with that ratio the pair conserves ``|E0|^2 + (2 wp0/w0)|Eh|^2``
for the coupling terms alone, i.e. the total wave energy at envelope density n_c/4
(settled against the equation document on 2026-09-14; adept main previously carried
half this coefficient).

The pump injector amplitude is divided by sinc(k0 dx) so the launched amplitude is
exactly E0_source * sqrt(intensity) / eps^(1/4) despite the two-point discrete
Expand Down Expand Up @@ -92,6 +96,7 @@ def __init__(self, cfg: dict):
source_cfg = cfg["terms"]["epw"]["source"]
self.srs_enabled = bool(source_cfg.get("srs", False))
self.tpd_enabled = bool(source_cfg.get("tpd", False))
self.kx = cfg["grid"]["kx"]
self.ky = cfg["grid"]["ky"]

# pump detuning/diffraction (MATLAB lines 1616-1626); with wp0^2 = w0^2 * n_env
Expand All @@ -101,13 +106,46 @@ def __init__(self, cfg: dict):
)
self.diffraction_coeff0 = 1j * self.c**2 / (2.0 * self.w0)
self.srs_depletion_coeff0 = -1j * self.e / (4.0 * self.w1 * self.me)
self.tpd_depletion_coeff0 = 1j * self.e / (4.0 * self.w0 * self.me)
self.tpd_depletion_coeff0 = 1j * self.e / (2.0 * self.w0 * self.me)

# how the SRS E0 <-> E1 exchange enters the light sub-step (see the class docstring)
light_cfg = cfg["terms"].get("light", {})
# transverse projection of E div(E) (LPSE LwSolver::makeExyzDivE, k-space
# P_T = I - k k / k^2) and the optional LPSE lw.kFilter on the same term
self.tpd_projection = bool(light_cfg.get("tpd_projection", True))
self.tpd_k_filter = bool(light_cfg.get("tpd_k_filter", False))
kx_arr = np.asarray(cfg["grid"]["kx"])
ky_arr = np.asarray(cfg["grid"]["ky"])
k_sq_np = kx_arr[:, None] ** 2 + ky_arr[None, :] ** 2
k_sq_safe = np.where(k_sq_np > 0, k_sq_np, 1.0)
self.kx_over_k_sq = jnp.asarray(np.where(k_sq_np > 0, kx_arr[:, None] / k_sq_safe, 0.0))
self.ky_over_k_sq = jnp.asarray(np.where(k_sq_np > 0, ky_arr[None, :] / k_sq_safe, 0.0))
if self.tpd_k_filter:
n_min = float(np.min(np.asarray(background_density)))
k0_max_sq = (1.2 * self.w0 / self.c) ** 2 * max(1.0 - n_min, 0.0)
self.tpd_k_mask = jnp.asarray(np.where((k_sq_np > 0) & (k_sq_np < k0_max_sq), 1.0, 0.0))
else:
self.tpd_k_mask = None

# how the SRS E0 <-> E1 exchange enters the light sub-step (see the class docstring)
self.coupling = str(light_cfg.get("coupling", "explicit"))
if self.coupling not in ("explicit", "rotation"):
raise ValueError(f"terms.light.coupling must be 'explicit' or 'rotation', got {self.coupling!r}")
if (
self.coupling == "rotation"
and self.srs_enabled
and self.transverse_source
and str(light_cfg.get("solver", "fd")) == "fd"
):
# the exact exchange rotates E0 and E1 pointwise; the transverse projection of its
# sources is a k-space operation it cannot apply (the spectral solver keeps the fields
# transverse instead), and the FD rotation path used to skip it silently
raise ValueError(
"terms.light.coupling: rotation with the fd light solver cannot apply "
"terms.light.transverse_source: true (the default): use coupling: explicit or "
"terms.light.solver: spectral to keep the projection, or set transverse_source: false "
"explicitly -- what fd rotation runs did so far, with the longitudinal E1 left in the SRS "
"exchange"
)
# local E0 <-> E1 exchange rate per unit |laplacian phi|: Omega = sqrt(|A B|) |lap phi|
self.omega_prefactor = self.e / (4.0 * self.me * np.sqrt(self.w0 * self.w1))

Expand Down Expand Up @@ -143,6 +181,10 @@ def __init__(self, cfg: dict):
self.n_src = n_src
self.pump_turn_on_time = pump["turn_on_time"]
self.source_prefactor0 = self.c**2 / (2.0 * self.w0) / permittivity0**0.25 / self.dx**2
# the single normal-incidence beam's own frequency shift and phase (drivers.E0.beams[0];
# oblique or multiple beams are refused on this path by setup)
self.beam_delta_omega0 = float(np.atleast_1d(pump.get("beam_delta_omega", [0.0]))[0])
self.beam_phase0 = float(np.atleast_1d(pump.get("beam_phase", [0.0]))[0])

def calc_pump_source(self, t: float, pump_args: dict) -> tuple[Array, Array]:
"""
Expand All @@ -160,9 +202,9 @@ def calc_pump_source(self, t: float, pump_args: dict) -> tuple[Array, Array]:
)
turn_on = 1.0 - jnp.exp(-((t / self.pump_turn_on_time) ** 2))

delta_omega = pump_args["delta_omega"] # (nc,)
delta_omega = pump_args["delta_omega"] + self.beam_delta_omega0 # (nc,)
intensities = pump_args["intensities"] # (nc, ny), fractions summing to 1
phases = pump_args["phases"] # (nc, ny)
phases = pump_args["phases"] + self.beam_phase0 # (nc, ny)

# local pump wavenumber per color (MATLAB kSource0). The two-point source
# launches amplitude E_src * sin(k0 dx)/sin(k_grid dx) / eps^(1/4) -- a ~2%
Expand Down Expand Up @@ -202,28 +244,51 @@ def pump_rhs(
k_e0x = self.diffraction_coeff0 * (self._d2y(e0x) - self._dxdy(e0y)) + linear_coeff0 * e0x
k_e0y = self.diffraction_coeff0 * (self._d2x(e0y) - self._dxdy(e0x)) + linear_coeff0 * e0y
if self.srs_enabled and couple:
k_e0x += self.srs_depletion_coeff0 * laplacian_phi * e1x
k_e0y += self.srs_depletion_coeff0 * laplacian_phi * e1y
depletion = (self.srs_depletion_coeff0 * laplacian_phi)[..., None] * E1
if self.transverse_source:
depletion = transverse_part(depletion, self.kx_arr, self.ky_arr, self.one_over_k_sq)
Comment on lines +247 to +249

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] Honor transverse-source projection in the rotation coupling path

The new projection is only applied inside the couple-guarded RHS. For the supported FD evolved-pump configuration coupling: rotation, CoupledLight.__call__ sets couple_in_rhs = False and instead uses couple(), whose pointwise exchange never checks self.transverse_source. Consequently transverse_source: true is silently ignored for both SRS light sources on that path.

In 2-D, even a transverse input pump multiplied by an EPW mode with nonzero transverse wavenumber produces a longitudinal component in the Raman source. The rotation path retains that component, which then enters the EPW SRS feedback instead of evolving the projected light equations. This defeats the longitudinal-source correction implemented here. Make the rotation path compatible with the projection, or explicitly reject the unsupported combination, and cover it with a nonzero-ky regression rather than only the pointwise exchange invariant.

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 55fd66f by refusing the combination rather than adding a projection to the rotation: the exact exchange is a pointwise rotation of (E0, E1), and the k-space projection of its sources has no counterpart in it (the spectral solver keeps the fields themselves transverse instead, which is why it is unaffected).

CoupledLight.__init__ now raises for coupling: rotation + SRS on + transverse_source: true with terms.light.solver: fd, naming the alternatives: coupling: explicit or solver: spectral to keep the projection, or transverse_source: false — which reproduces what fd rotation runs did so far. On this branch transverse_source defaults to true, so an fd rotation deck that leaves it unset is now refused. FDCombinedSolver inherits this __init__, but combined mode already refuses any non-explicit coupling in helpers.

Test test_light_coupling.py::test_fd_rotation_refuses_the_transverse_source_projection_it_cannot_apply (did not raise before). The rotation configs of test_light_coupling.py and the srs+pump_depletion case of test_energy_ledger.py were built with the default and are refused without it; they now set transverse_source: false explicitly, and their behaviour is unchanged. Docs: coupling and transverse_source rows.

On plan2 transverse_source defaults to false (LPSE takeTransversePartOfSourceTerms), so there only an explicit true is refused; 4223609 adapts the message, docs and test, and names transverse_fields: true (applied after the sub-steps under either coupling) as the fd way to keep the light transverse with rotation.

k_e0x += depletion[..., 0]
k_e0y += depletion[..., 1]
if self.tpd_enabled:
if phi_k is None:
raise ValueError("phi_k is required for TPD pump depletion")
k_e0y += self.calc_tpd_depletion(t, phi_k)
tpd_dep = self.calc_tpd_depletion(t, phi_k)
k_e0x += tpd_dep[..., 0]
k_e0y += tpd_dep[..., 1]
row_i0, row_i0p1 = self.calc_pump_source(t, pump_args)
k_e0y = k_e0y.at[self.i0, :].add(row_i0)
k_e0y = k_e0y.at[self.i0 + 1, :].add(row_i0p1)

return jnp.stack([k_e0x, k_e0y], axis=-1)

def calc_tpd_depletion(self, t: float, phi_k: Array) -> Array:
"""Return the clean reciprocal TPD source for the y-polarized pump.
def tpd_depletion_vector(self, phi_k: Array) -> Array:
"""``[E_h div(E_h)]_T`` in real space, shape (nx, ny, 2), from the frozen potential.

``div_e`` uses the same potential convention as the EPW TPD source:
``div_e = ifft2(k^2 phi_k)``.
``div_e = ifft2(k^2 phi_k)``. The transverse projection and the optional k-filter
are LPSE's ``LwSolver::makeExyzDivE``: ``F_T = F - k (k . F) / k^2`` mode by mode.
"""
ex = jnp.fft.ifft2(-1j * self.kx[:, None] * phi_k)
ey = jnp.fft.ifft2(-1j * self.ky[None, :] * phi_k)
div_e = jnp.fft.ifft2(self.k_sq * phi_k)
fx, fy = ex * div_e, ey * div_e
if self.tpd_projection or self.tpd_k_mask is not None:
fx_k = jnp.fft.fft2(fx)
fy_k = jnp.fft.fft2(fy)
if self.tpd_projection:
longitudinal = self.kx[:, None] * fx_k + self.ky[None, :] * fy_k
fx_k = fx_k - self.kx_over_k_sq * longitudinal
fy_k = fy_k - self.ky_over_k_sq * longitudinal
if self.tpd_k_mask is not None:
fx_k = fx_k * self.tpd_k_mask
fy_k = fy_k * self.tpd_k_mask
fx, fy = jnp.fft.ifft2(fx_k), jnp.fft.ifft2(fy_k)
return jnp.stack([fx, fy], axis=-1)

def calc_tpd_depletion(self, t: float, phi_k: Array) -> Array:
"""Return the reciprocal TPD term for both pump components, shape (nx, ny, 2)."""
phase = jnp.exp(1j * (self.w0 - 2.0 * self.wp0) * t)
return self.tpd_depletion_coeff0 * phase * ey * div_e
return self.tpd_depletion_coeff0 * phase * self.tpd_depletion_vector(phi_k)

def coupled_rhs(
self,
Expand Down Expand Up @@ -320,8 +385,17 @@ def substep(i, fields):
E0, E1 = propagate(t_i, E0, E1)
E0 = E0 * self.sub_boundary[..., None]
E1 = E1 * self.sub_boundary[..., None]
if absorb0 is not None:
E0 = E0 * absorb0
E1 = E1 * absorb1
return (E0, E1)

absorb0 = absorb1 = None
if self.absorption_rate0 is not None:
n0 = self.n_over_nc0 if iaw_density is None else self.n_over_nc0 * (1.0 + iaw_density / self.n_over_env)
n1 = self.n_over_nc1 if iaw_density is None else self.n_over_nc1 * (1.0 + iaw_density / self.n_over_env)
absorb0 = jnp.exp(-self.absorption_rate0 * self.dt_l * n0**2)[..., None]
absorb1 = jnp.exp(-self.absorption_rate1 * self.dt_l * n1**2)[..., None]
E0, E1 = lax.fori_loop(0, self.n_sub, substep, (E0, E1))
if self.light_filter is not None:
E0 = jnp.fft.ifft2(jnp.fft.fft2(E0, axes=(0, 1)) * self.light_filter, axes=(0, 1))
Expand Down
Loading
Loading