From 1bf57eb183e54b6eaa7e55a15ede3af2bc04afd5 Mon Sep 17 00:00:00 2001 From: John Travers Date: Fri, 23 Oct 2026 23:46:15 +0100 Subject: [PATCH 01/50] SEP 31: in progress Co-Authored-By: Claude Opus 5.5 --- docs/dev/roadmap.md | 1 + docs/dev/seps/index.md | 2 +- docs/dev/seps/sep-0031-simulation-coupling.md | 7 +++++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/dev/roadmap.md b/docs/dev/roadmap.md index 9014f5699..e56476d7a 100644 --- a/docs/dev/roadmap.md +++ b/docs/dev/roadmap.md @@ -55,6 +55,7 @@ being implemented. 37.5 μm tutorial; the incident-energy defect is its first implementation phase, after SEP 34's full public activation. Qualified provider-pair envelopes may replace conservative weak-guidance screens within the total model budget. + In progress: implementation has started on SEP 34's branch. - [SEP 34 — Medium flux for free-space propagation](seps/sep-0034-medium-flux.md): physical launch energy, complex-admittance local flux, consistent total-energy diff --git a/docs/dev/seps/index.md b/docs/dev/seps/index.md index 606a9cf34..4cbaf1a42 100644 --- a/docs/dev/seps/index.md +++ b/docs/dev/seps/index.md @@ -36,7 +36,7 @@ Proposals that are active, in draft, accepted, in progress or deferred. | [17](sep-0017-gas-two-photon-ionisation.md) | Broadband two-photon continuum response beside strong-field ionisation | Accepted | Feature | | [21](sep-0021-absorbing-boundaries.md) | Temporal boundaries: wider windows, absorption and the escaped field | Draft | Change | | [29](sep-0029-compiled-backend-distribution.md) | The native backend in solprop, distributed as binaries | In progress | Change | -| [31](sep-0031-simulation-coupling.md) | Coupling simulations and transporting ultrafast fields | Accepted | Feature | +| [31](sep-0031-simulation-coupling.md) | Coupling simulations and transporting ultrafast fields | In progress | Feature | | [32](sep-0032-raman-maxwell-bloch.md) | Raman Maxwell–Bloch dynamics | Draft | Feature | | [33](sep-0033-single-precision-mlx.md) | Single-precision propagation, and an MLX backend for Apple GPUs | Draft | Feature | | [34](sep-0034-medium-flux.md) | Medium flux for free-space propagation | Accepted | Change | diff --git a/docs/dev/seps/sep-0031-simulation-coupling.md b/docs/dev/seps/sep-0031-simulation-coupling.md index afefc8d92..62afce078 100644 --- a/docs/dev/seps/sep-0031-simulation-coupling.md +++ b/docs/dev/seps/sep-0031-simulation-coupling.md @@ -6,7 +6,7 @@ | **Title** | Coupling simulations and transporting ultrafast fields | | **Author** | John Travers | | **Drafting agent** | GPT-6 (draft and 2026-10-23 review response) | -| **Status** | Accepted | +| **Status** | In progress | | **Review status** | Reviewed | | **Reviewers** | John Travers (human); Opus 5.5 (AI) | | **Type** | Feature | @@ -15,7 +15,7 @@ | **Requires** | [SEP 22](archive/sep-0022-memory-lean-spatial-execution.md): plane storage and input replacement; [SEP 23](archive/sep-0023-far-field-aperture-spectra.md): aperture definitions; [SEP 25](archive/sep-0025-rounding-level-reproducibility.md): numerical qualification; [SEP 34](sep-0034-medium-flux.md): shared medium flux and radial adiabatic transport | | **Supersedes** | — | | **Superseded-by** | — | -| **Implementation** | — | +| **Implementation** | John Travers with Opus 5.5 (AI); on `feature/sep-0031-simulation-coupling` | | **Manual pages** | — | ## Part I — Proposal @@ -1128,6 +1128,9 @@ No new material constant or experimental validation is claimed. explicit request to commit and merge both SEPs 31 and 34. Part I is frozen. SEP 34 is implemented first; this proposal's implementation record remains empty until its phases begin. +- 2026-10-23: In progress. Implementation started on a branch cut from SEP 34's + implementation branch; it reaches main through its own pull request after + SEP 34 has merged. ### AI review — 2026-10-23 From 3f356c9b167a8a5f9897b9bcb92c9cd454b34e81 Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 00:04:48 +0100 Subject: [PATCH 02/50] Pulses: sample a SpatialPulse normalised over its whole incident plane SpatialPulse.incident gives the beam its requested energy or peak power in the paraxial measure n eps0 c dA/2 of the launch medium, over the closed-form plane integral of its profile, and only then samples it at given points, so light a receiver misses still counts as incident. It agrees with build on a radial grid to the beam's obliquity. Co-Authored-By: Claude Opus 5.5 --- src/solprop/pulses.py | 197 +++++++++++++++++++++++++++++++++++-- tests/test_spatial_flux.py | 80 +++++++++++++++ 2 files changed, 269 insertions(+), 8 deletions(-) diff --git a/src/solprop/pulses.py b/src/solprop/pulses.py index 01f2e5662..5f00e82d2 100644 --- a/src/solprop/pulses.py +++ b/src/solprop/pulses.py @@ -59,6 +59,7 @@ "CustomPulse", "DataPulse", "GaussianPulse", + "IncidentField", "PlanarPulse", "Pulse", "PulseSum", @@ -281,19 +282,13 @@ def build( ) raise PulseError(msg) flux = SpatialFlux.resolve(grid, space, medium, z=z, side=side) - limit = _GAUSS_EXPONENT_CLIP ** (1 / (2 * self.order)) - radius = np.minimum(space.r, self.waist * limit) / self.waist - profile = np.exp(-(radius ** (2 * self.order))) + profile = self._profile(space.r) spectrum = ( space.hankel.forward(profile) if isinstance(space, RadialGrid) else np.fft.fft2(profile.reshape(space.n_y, space.n_x)).ravel() ) - jones = np.asarray(self.jones, dtype=np.complex128) - jones = jones / max( - float(np.max(np.abs(np.real(jones)))), float(np.max(np.abs(np.imag(jones)))) - ) - jones = jones / np.sqrt(np.sum(np.abs(jones) ** 2)) + jones = _unit_jones(self.jones) live = np.concatenate( [flux.admittance(columns) != 0 for columns in flux.chunks()] ) @@ -331,6 +326,192 @@ def build( raise PulseError(msg) return np.asarray(total, dtype=np.complex128) + def incident( + self, + grid: TimeGrid, + xy: Float[np.ndarray, "n_nodes 2"], + *, + index: Float[np.ndarray, " n_omega"], + ) -> "IncidentField": # noqa: UP037 - defined below this class + r"""Sample the beam, normalised over its whole incident plane, at given points. + + The incident-field boundary of a guide launch (SEP 31, phase 1): the beam is + given its requested energy or peak power **over the whole transverse plane** + and only then sampled where a receiving guide wants it, so light that misses + the guide still counts as incident and is lost, not restored. The measure is + the paraxial local one, :math:`M_0 = Y_0\,dA/2` with + :math:`Y_0 = n_{\rm ref}(\omega)\varepsilon_0 c`: the axial Poynting flux + of a paraxial beam in a medium of phase index :math:`n_{\rm ref}`, which is + what an energy meter in that medium reads (SEP 34). The plane integral of the + super-Gaussian profile :math:`u = \exp(-(r/w)^{2N})` is exact, + :math:`\int|u|^2 dA = \pi w^2 2^{-1/N}\Gamma(1 + 1/N)`, so no transverse + window or quadrature enters the incident energy. + + A one-component Jones vector is polarised along x; two components are the + x and y components. The beam is centred on the origin of `xy`. + + Parameters + ---------- + grid : solprop.grid.TimeGrid + Temporal grid. + xy : numpy.ndarray + Transverse sample positions in m, shape ``(n_nodes, 2)``. + index : numpy.ndarray + Real phase index of the launch medium at each of the grid's frequencies, + shape ``(n_omega,)``, positive on every bin that carries flux; a zero + bin carries none and is masked. + + Returns + ------- + IncidentField + The normalised field at the points and the energy each bin carries + through the whole plane. + + Raises + ------ + PulseError + If the beam is a :class:`PlanarPulse` or is launched away from its waist, + the index is not finite and non-negative on the grid, the temporal pulse + is not scalar, or the beam carries no forward flux. + + Examples + -------- + >>> import numpy as np + >>> from solprop.grid import FieldGrid + >>> from solprop.pulses import GaussianPulse, SpatialPulse + >>> grid = FieldGrid.from_limits(1e-3, 800e-9, (700e-9, 900e-9), 200e-15) + >>> beam = SpatialPulse( + ... GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=1e-6), 50e-6 + ... ) + >>> index = np.where(grid.omega > 0, 1.45, 0.0) + >>> sampled = beam.incident(grid, np.zeros((1, 2)), index=index) + >>> f"{np.sum(sampled.energy):.6e} J" # the whole plane, wherever it is sampled + '1.000000e-06 J' + """ + if isinstance(self, PlanarPulse): + msg = ( + "a PlanarPulse launches into a PlanarGrid, projected onto its slab " + "modes; a guide's incident beam is a SpatialPulse" + ) + raise PulseError(msg) + if self.propz != 0: + msg = ( + f"this beam is launched {self.propz!r} m from its waist, and carrying " + "it to the launch plane is a transport step that sampling does not " + "model: launch at the waist (propz=0)" + ) + raise PulseError(msg) + reference = np.asarray(index, dtype=np.float64) + if reference.shape != (grid.n_omega,) or not np.all( + np.isfinite(reference) & (reference >= 0.0) + ): + msg = ( + f"the launch medium's index is finite and non-negative on each of the " + f"grid's {grid.n_omega} frequencies; got shape {reference.shape}" + ) + raise PulseError(msg) + live = grid.active_mask & (grid.omega > 0) & (reference > 0.0) + order = self.order + area = ( + math.pi * self.waist**2 * 2.0 ** (-1.0 / order) * math.gamma(1 + 1 / order) + ) + # One "column", the whole plane: its transverse integral is the exact `area`. + plane = _Plane( + weights=np.ones(1), + admittance=np.where(live, reference, 0.0).astype(np.complex128)[None, :], + scale=0.5 * EPS0 * C * area, + ) + jones = _unit_jones(self.jones) + if jones.size == 1: + jones = np.array([jones[0], 0.0], dtype=np.complex128) + spectrum = np.zeros(grid.n_omega, complex) + for member in _members(self.temporal): + temporal = member.build(grid) + if temporal.shape != (1, 1, grid.n_omega): + msg = "SpatialPulse requires a scalar single-column temporal pulse" + raise PulseError(msg) + # The request is the unmasked pulse's, as `build` takes it; only the + # bins that carry flux are measured and launched. + masked = temporal[0, 0] * live + beam = jones[None, :, None] * masked[None, None, :] + factor = _normalising_factor(member, temporal, beam, plane, grid) + spectrum += factor * masked + weights = fourier.energy_weights(grid.n_omega, grid.dt, kind=grid.kind) + energy = ( + plane.scale * weights * np.real(plane.admittance[0]) * np.abs(spectrum) ** 2 + ) + profile = self._profile(np.hypot(xy[:, 0], xy[:, 1])) + field = ( + profile[:, None, None] + * fourier.to_internal(jones)[None, :, None] + * spectrum[None, None, :] + ) + if not (np.all(np.isfinite(field)) and np.all(np.isfinite(energy))): + msg = "the incident beam is nonfinite: its temporal pulse overflowed" + raise PulseError(msg) + return IncidentField(field, np.asarray(energy, dtype=np.float64)) + + def _profile(self, radius: Float[np.ndarray, " n_r"]) -> Float[np.ndarray, " n_r"]: + """Return the unit-peak field profile at the given radii. + + Parameters + ---------- + radius : numpy.ndarray + Distances from the beam axis in m, shape ``(n_r,)``. + + Returns + ------- + numpy.ndarray + ``exp(-(r/waist)**(2 order))``, shape ``(n_r,)``. + """ + limit = _GAUSS_EXPONENT_CLIP ** (1 / (2 * self.order)) + scaled = np.minimum(radius, self.waist * limit) / self.waist + return np.exp(-(scaled ** (2 * self.order))) + + +@dataclass(frozen=True, eq=False) +class IncidentField: + """An incident beam, normalised over its whole plane, sampled at given points. + + The result of :meth:`SpatialPulse.incident`. + + Attributes + ---------- + field : numpy.ndarray + The beam's x and y components at the points, complex128, shape + ``(n_nodes, 2, n_omega)``, in the internal convention: the electric + field in V/m per square root of watt times a stored spectrum in the square + root of watts, the units in which a guide's node profiles project to + channel amplitudes. + energy : numpy.ndarray + The energy each bin carries through the **whole** plane, float64, shape + ``(n_omega,)``, J; its sum is the beam's incident energy. + """ + + field: Complex[np.ndarray, "n_nodes n_pol n_omega"] + energy: Float[np.ndarray, " n_omega"] + + +def _unit_jones(jones: Sequence[complex]) -> Complex[np.ndarray, " n_pol"]: + """Return a Jones vector scaled to unit squared magnitude. + + Parameters + ---------- + jones : sequence of complex + One or two finite components, not all zero. + + Returns + ------- + numpy.ndarray + The vector divided by its norm, complex128, shape ``(n_pol,)``. + """ + vector = np.asarray(jones, dtype=np.complex128) + # Dividing by the largest part first keeps the norm from overflowing. + vector = vector / max( + float(np.max(np.abs(np.real(vector)))), float(np.max(np.abs(np.imag(vector)))) + ) + return vector / np.sqrt(np.sum(np.abs(vector) ** 2)) + @dataclass(frozen=True) class PlanarPulse(SpatialPulse): diff --git a/tests/test_spatial_flux.py b/tests/test_spatial_flux.py index 01c107139..32fcaa8a0 100644 --- a/tests/test_spatial_flux.py +++ b/tests/test_spatial_flux.py @@ -239,6 +239,86 @@ def test_noise_is_one_photon_per_admitted_bin() -> None: assert np.all(draw.spectrum[:, 0][~live] == 0) +@pytest.mark.parametrize("order", [1, 2]) +@pytest.mark.parametrize("requested", [{"energy": 1e-6}, {"peak_power": 1e9}]) +@pytest.mark.parametrize("kind", ["envelope", "field"]) +def test_an_incident_beam_is_the_launch_its_build_gives(order, requested, kind) -> None: + """`SpatialPulse.incident` normalises over the whole plane, as `build` does. + + Two independent normalisations of one beam in a glass of index 1.45: `build` + on a radial grid with the exact admittance of every k_r column, and + `incident` with the paraxial measure and the profile's closed-form plane + integral, sampled at the grid's own radii. They differ by the beam's + obliquity, ``(lambda / (2 pi n w))**2 ~ 2e-7`` for a 200 um waist (measured + 1.5e-7 of the peak field), and by nothing else; the super-Gaussian checks + the area formula. + """ + limits = (0.1, 800e-9, (650e-9, 1000e-9), 600e-15) + grid = ( + EnvelopeGrid.from_limits(*limits) + if kind == "envelope" + else FieldGrid.from_limits(*limits) + ) + space = RadialGrid(1e-3, 64) + pulse = SpatialPulse( + GaussianPulse(wavelength=800e-9, fwhm=30e-15, **requested), 2e-4, order=order + ) + built = pulse.build(grid, space, medium=SpatialMedium(1.45)) + field = np.moveaxis(space.hankel.inverse(np.moveaxis(built, 0, -1)), -1, 0) + index = np.where(grid.active_mask & (grid.omega > 0), 1.45, 0.0) + xy = np.stack([space.r, np.zeros_like(space.r)], axis=1) + sampled = pulse.incident(grid, xy, index=index) + assert sampled.field.shape == (space.n_r, 2, grid.n_omega) + scale = np.max(np.abs(field)) + assert np.max(np.abs(field[:, 0] - sampled.field[:, 0])) < 1e-6 * scale + # One component is x-polarised: nothing in y. + assert not np.any(sampled.field[:, 1]) + if "energy" in requested: + # The energy sum of one rescaling: a few rounding units. + assert np.sum(sampled.energy) == pytest.approx(1e-6, rel=1e-13) + + +def test_an_incident_beam_counts_light_it_is_not_sampled_on() -> None: + """The incident energy is the whole plane's, wherever the beam is sampled.""" + grid = _envelope() + index = np.where(grid.active_mask & (grid.omega > 0), 1.45, 0.0) + pulse = SpatialPulse( + GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=1e-6), + 2e-4, + jones=(1.0, 1.0j), + ) + near = pulse.incident(grid, np.zeros((1, 2)), index=index) + far = pulse.incident(grid, np.array([[1e-3, 0.0], [0.0, 2e-3]]), index=index) + np.testing.assert_array_equal(near.energy, far.energy) + # A circular Jones vector: equal x and y a quarter period apart, conjugated + # into the internal convention. + np.testing.assert_allclose( + near.field[0, 1], -1j * near.field[0, 0], rtol=1e-15, atol=0.0 + ) + # At a fixed energy the field falls as the square root of the index. + glassier = pulse.incident(grid, np.zeros((1, 2)), index=4.0 * index) + np.testing.assert_allclose( + np.abs(glassier.field), np.abs(near.field) / 2.0, rtol=1e-14, atol=0.0 + ) + + +def test_an_incident_beam_refuses_what_it_cannot_sample() -> None: + """A planar beam, a launch away from the waist and a malformed index.""" + grid = _envelope() + index = np.where(grid.active_mask & (grid.omega > 0), 1.45, 0.0) + temporal = GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=1e-6) + origin = np.zeros((1, 2)) + with pytest.raises(PulseError, match="PlanarGrid"): + PlanarPulse(temporal, 2e-4, waist_x=5e-5).incident(grid, origin, index=index) + with pytest.raises(PulseError, match="propz=0"): + SpatialPulse(temporal, 2e-4, propz=-1e-3).incident(grid, origin, index=index) + for bad in (index[:-1], -index, np.full_like(index, np.nan)): + with pytest.raises(PulseError, match="finite and non-negative"): + SpatialPulse(temporal, 2e-4).incident(grid, origin, index=bad) + with pytest.raises(PulseError, match="no positive forward flux"): + SpatialPulse(temporal, 2e-4).incident(grid, origin, index=np.zeros_like(index)) + + def test_flux_refusals() -> None: """Widths refuse a signed profile; a side needs a jump; the index is required.""" grid = _envelope() From 7c2edfabbf160024de24eb2ed68fe030cd98fb56 Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 00:04:48 +0100 Subject: [PATCH 03/50] General modal launch: an incident beam keeps only what the channels capture from_waveguide and prop_waveguide take incident=SpatialPulse in place of the node-sampled transverse= profile. The beam is normalised over its own plane in the core medium, sampled at the guide's nodes and projected; the captured amplitudes are launched without rescaling to energy=, and a zero capture is a valid zero launch. IncidentLaunch records the incident, captured and uncaptured energy per frequency in configuration["launch"], replacing the 3 % uncaptured warning and LAUNCH_UNCAPTURED_WARNING. Co-Authored-By: Claude Opus 5.5 --- benchmarks/guides.py | 17 +- src/solprop/driver/interface.py | 21 +- src/solprop/driver/simulation.py | 560 ++++++++++++++++++-------- tests/driver/test_general_modal.py | 359 ++++++++++------- tests/test_display.py | 9 + tests/test_display/IncidentLaunch.txt | 5 + 6 files changed, 632 insertions(+), 339 deletions(-) create mode 100644 tests/test_display/IncidentLaunch.txt diff --git a/benchmarks/guides.py b/benchmarks/guides.py index 7e1fc9e02..e5ede1d6e 100644 --- a/benchmarks/guides.py +++ b/benchmarks/guides.py @@ -37,6 +37,7 @@ from solprop.modes.material import JsonValue from solprop.physdata import fused_silica from solprop.profiles import PiecewiseLinear +from solprop.pulses import GaussianPulse, SpatialPulse __all__ = ["ScaledPermittivity", "advanced", "coupled", "grin", "moving", "step_index"] @@ -222,17 +223,23 @@ def grin(*, nonlinear: bool) -> Simulation: / C ) period = 2 * np.pi / float(np.mean(-np.diff(beta))) - beam = np.zeros((nodes.xy.shape[0], 3)) - beam[:, 0] = np.exp(-((np.hypot(nodes.xy[:, 0], nodes.xy[:, 1]) / 12e-6) ** 2)) + # The incident beam's energy is over its whole plane; the channels launch + # the share they capture. + beam = SpatialPulse( + GaussianPulse( + wavelength=wavelength, + fwhm=300e-15, + energy=5e5 * 300e-15 * np.sqrt(np.pi / (4 * np.log(2))), + ), + 12e-6, + ) return Simulation.from_waveguide( (), 0.1, provider=guide, grid=grid, - transverse=beam, + incident=beam, lambda0=wavelength, - fwhm=300e-15, - energy=5e5 * 300e-15 * np.sqrt(np.pi / (4 * np.log(2))), noise="input", seed=41, dz_max=period / 10, diff --git a/src/solprop/driver/interface.py b/src/solprop/driver/interface.py index 3cde4b8c6..906dbb5eb 100644 --- a/src/solprop/driver/interface.py +++ b/src/solprop/driver/interface.py @@ -24,7 +24,7 @@ from typing import Literal import numpy as np -from jaxtyping import Float, Inexact +from jaxtyping import Float from solprop.boundaries import Absorber from solprop.driver.simulation import ( @@ -1163,9 +1163,7 @@ def prop_waveguide( # noqa: PLR0913 - the interface's keyword surface *, provider: ModalGuide | None = None, channel: int | str | None = None, - transverse: Inexact[np.ndarray, "n_nodes n_components n_omega"] - | Inexact[np.ndarray, "n_nodes n_components"] - | None = None, + incident: SpatialPulse | None = None, kerr_form: KerrForm = "real", observable_rtol: float | None = None, observable_atol: float | None = None, @@ -1258,12 +1256,13 @@ def prop_waveguide( # noqa: PLR0913 - the interface's keyword surface channel : int or str or None, optional Which channel the pulse enters, by index or display label; ``None`` takes the first. Requires `provider`. - transverse : numpy.ndarray or None, optional - A declared transverse field at the guide's own nodes, real or complex, - projected onto every - channel it overlaps instead of entering one by name (see - :meth:`~solprop.driver.simulation.Simulation.from_waveguide`). Requires - `provider`, and is refused beside `channel`. + incident : solprop.pulses.SpatialPulse or None, optional + An incident beam carrying its own pulse, whose energy is the physical + energy over the whole beam at the entrance; the channels launch the share + they capture and the rest is lost, recorded in the run's configuration + (see :meth:`~solprop.driver.simulation.Simulation.from_waveguide`). + Requires `provider`, and is refused beside `channel` and the pulse + keywords. kerr_form : {'real', 'analytic'}, optional Which instantaneous cubic a general modal run's regions respond with: the real-field cubic of the field route, the default, or the analytic-signal @@ -1477,7 +1476,7 @@ def prop_waveguide( # noqa: PLR0913 - the interface's keyword surface pressure, provider=provider, channel=channel, - transverse=transverse, + incident=incident, kerr_form=kerr_form, observable_rtol=observable_rtol, observable_atol=observable_atol, diff --git a/src/solprop/driver/simulation.py b/src/solprop/driver/simulation.py index 48fc7ae9b..f2792213b 100644 --- a/src/solprop/driver/simulation.py +++ b/src/solprop/driver/simulation.py @@ -39,10 +39,11 @@ from typing import Final, Literal, cast import numpy as np -from jaxtyping import Complex, Float, Inexact +from jaxtyping import Complex, Float from scipy.special import j0, jn_zeros import solprop.driver.tdse as tdse_runtime +from solprop import fourier from solprop._display import Displayed, engineering, line, summarise from solprop.adiabatic import AdiabaticAccuracy from solprop.backends.multistep import CallLedger @@ -79,12 +80,12 @@ AdiabaticTaperWarning, DriverError, GasFlowWarning, - GeneralModalBasisWarning, GrazingIncidenceWarning, MaterialRangeWarning, MemoryBudgetError, MemoryBudgetWarning, ModeError, + PulseError, SampledPeakWarning, SolpropWarning, TDSEArchiveError, @@ -279,13 +280,13 @@ "DEFAULT_ATOL", "DEFAULT_N_SAVE", "DEFAULT_RTOL", - "LAUNCH_UNCAPTURED_WARNING", "LOOSE_ADAMS_RTOL", "LOW_STORAGE_BACKENDS", "ORTHONORMALITY_TOLERANCE", "PLASMA_STATISTICS", "WAVEGUIDE_ATOL", "WAVEGUIDE_RTOL", + "IncidentLaunch", "NoiseModel", "Simulation", "SimulationRunner", @@ -298,14 +299,161 @@ logger = logging.getLogger(__name__) -LAUNCH_UNCAPTURED_WARNING: float = 0.03 -"""Share of a projected launch's transverse field its channels may miss unwarned. -A projected launch (``transverse=``) is rescaled to the pulse's energy whatever share of -the declared profile the guide's channels capture; at 3 % missed or more that rescaling -hides a mismatch worth seeing, so the launch warns (`GeneralModalBasisWarning`). The -captured share is logged at INFO on every projected launch. -""" +@dataclass(frozen=True, eq=False) +class IncidentLaunch(Displayed): + r"""The energy account of an incident beam launched into a general modal guide. + + Recorded as ``configuration["launch"]`` by + ``Simulation.from_waveguide(provider=..., incident=...)`` (SEP 31, phase 1). + The **incident** energy is the beam's over its whole plane, in the paraxial + measure :math:`M_0 = n_{\rm ref}\varepsilon_0 c\,dA/2` of the guide's core + medium at the entrance; the **captured** energy is the launched state's physical + energy under the run's own declared flux metric, the number + :func:`solprop.stats.physical_energy` gives at the entrance. The launch keeps the + captured amplitudes as projected: nothing is rescaled to the requested energy. + + **The uncaptured energy is their difference, and it is a physical loss only in + a common positive metric** (SEP 31 section 4.3): where the guide's own flux + metric and the incident measure agree, as for a weakly guiding provider, the + least-squares remainder is orthogonal to the channels and the difference is + the light the guide does not take. Elsewhere it also carries the two + measures' disagreement, and it is reported unclipped: a negative value is an + apparent gain of the model, never a negative loss. The electric-field residual + :attr:`solprop.translate.modal_tables.ProjectedLaunch.residual` is a + reconstruction error on the guide's nodes, not radiated power, and is not + this account. + + Attributes + ---------- + reference_index : numpy.ndarray + Real phase index of the core medium at each frequency, float64, shape + ``(n_omega,)``; zero on bins that carry no flux. + incident : numpy.ndarray + The energy each bin carries through the whole incident plane, float64, + shape ``(n_omega,)``, J. + captured : numpy.ndarray + The energy each bin carries in the launched channels, float64, shape + ``(n_omega,)``, J. + + Raises + ------ + DriverError + If the arrays disagree in shape or are not finite. + """ + + reference_index: Float[np.ndarray, " n_omega"] + incident: Float[np.ndarray, " n_omega"] + captured: Float[np.ndarray, " n_omega"] + + def __post_init__(self) -> None: + """Check that the three arrays describe one launch. + + Raises + ------ + DriverError + Naming what disagrees. + """ + shapes = { + np.shape(value) + for value in (self.reference_index, self.incident, self.captured) + } + if len(shapes) != 1: + msg = ( + "an incident launch's index, incident and captured energies share " + f"one frequency axis; got shapes {sorted(shapes)}" + ) + raise DriverError(msg) + for name, value in ( + ("reference_index", self.reference_index), + ("incident", self.incident), + ("captured", self.captured), + ): + if not np.all(np.isfinite(value)): + msg = f"an incident launch's {name} must be finite" + raise DriverError(msg) + + @property + def uncaptured(self) -> Float[np.ndarray, " n_omega"]: + """Return the incident energy each bin does not carry into the channels. + + Returns + ------- + numpy.ndarray + ``incident - captured``, shape ``(n_omega,)``, J, not clipped. + """ + return self.incident - self.captured + + @property + def incident_energy(self) -> float: + """Return the beam's whole incident energy. + + Returns + ------- + float + Energy, J. + """ + return float(np.sum(self.incident)) + + @property + def captured_energy(self) -> float: + """Return the energy the launch carries into the guide. + + Returns + ------- + float + Energy, J. + """ + return float(np.sum(self.captured)) + + @property + def uncaptured_energy(self) -> float: + """Return the incident energy the launch does not carry, not clipped. + + Returns + ------- + float + Energy, J. + """ + return float(np.sum(self.uncaptured)) + + def __repr__(self) -> str: + """Return a one-line description, never an array. + + Returns + ------- + str + For example ``IncidentLaunch(1 uJ incident, 302 nJ captured)``. + """ + return line( + "IncidentLaunch", + [ + ("", f"{engineering(self.incident_energy, 'J')} incident"), + ("", f"{engineering(self.captured_energy, 'J')} captured"), + ], + ) + + def _describe(self) -> tuple[str, list[tuple[str, object]]]: + """Return the title and rows of the printed block. + + Returns + ------- + tuple + The title and ``(key, value)`` rows. + """ + incident = self.incident_energy + share = ( + "undefined (nothing incident)" + if incident == 0.0 + else f"{self.captured_energy / incident:.4f} of the incident energy" + ) + return "IncidentLaunch: an incident beam's energy in a guide", [ + ("incident", engineering(incident, "J")), + ("captured", engineering(self.captured_energy, "J")), + ("uncaptured", engineering(self.uncaptured_energy, "J")), + ("captured share", share), + ] + LOOSE_ADAMS_RTOL = 5e-2 """The ``rtol`` from which an adaptive Adams run is warned on. @@ -1727,6 +1875,8 @@ def _derived_configuration( If a caller's key is one the run records itself. """ recorded = dict(self.configuration) + # Nor does the constructor's incident-beam account describe it. + recorded.pop("launch", None) # A constructor's pulse recipe must never describe a replacement spectrum. # Arrays carry their exact input in metadata, without an inferred recipe. if "pulse" in recorded: @@ -3680,9 +3830,7 @@ def from_waveguide( # noqa: PLR0913 - the interface's keyword surface *, provider: ModalGuide | None = None, channel: int | str | None = None, - transverse: Inexact[np.ndarray, "n_nodes n_components n_omega"] - | Inexact[np.ndarray, "n_nodes n_components"] - | None = None, + incident: SpatialPulse | None = None, kerr_form: KerrForm = "real", observable_rtol: float | None = None, observable_atol: float | None = None, @@ -3798,22 +3946,25 @@ def from_waveguide( # noqa: PLR0913 - the interface's keyword surface amplitude placed in that channel unchanged -- a Poynting basis needs no conversion -- so the launched true-power energy is the pulse's. Requires `provider`. - transverse : numpy.ndarray or None, optional - A declared transverse **field** to launch instead of a named channel: - the profile at the guide's own quadrature nodes, real or complex (a - real profile is the common case), shape - ``(n_nodes, n_components)`` or ``(n_nodes, n_components, n_omega)``, - projected onto every channel it overlaps through the record's - coordinate dual - (:func:`solprop.translate.modal_tables.project_transverse_field`). The - pulse supplies the spectrum and the energy: the projection's **shape** - -- the share each channel takes -- is what the profile decides, and - the whole is scaled so that the launched true-power energy is the - pulse's, as it is for `channel`. The profile's own scale is therefore - immaterial. What the bound channels do not capture is reported by that - function as its residual share; call it directly to see the number - before committing to a run. Requires `provider`, and is refused beside - `channel`. + incident : solprop.pulses.SpatialPulse or None, optional + An **incident beam** to launch instead of a named channel: the beam + and its temporal pulse, whose energy or peak power is the physical one + crossing the entrance plane in the guide's core medium, over the + **whole** beam (SEP 31, phase 1). The beam is normalised over its own + plane, sampled at the guide's quadrature nodes and projected onto every + channel it overlaps through the record's coordinate dual + (:func:`solprop.translate.modal_tables.project_transverse_field`); the + projected amplitudes are launched as they are. **Light the channels do + not capture is lost, not restored**: a beam much wider than the core + launches a small share of its energy, and a beam the channels cannot see + launches nothing, which is a valid zero launch. The account -- incident, + captured and uncaptured energy, per frequency and in total -- is + recorded as ``configuration["launch"]``, an + :class:`IncidentLaunch`. The beam is launched at its waist + (``propz=0``) and centred on the guide's axis. The pulse keywords + (`fwhm`, `width`, `energy`, `peak_power`, `phases`, `pulses` and + `pulseshape`) are refused beside it, because the beam carries its own + pulse. Requires `provider`, and is refused beside `channel`. kerr_form : {'real', 'analytic'}, optional Which instantaneous cubic a general modal run's material regions respond with. ``"real"``, the default, is the real-field cubic @@ -4049,7 +4200,7 @@ def from_waveguide( # noqa: PLR0913 - the interface's keyword surface pressure_floor=pressure_floor, vacuum_end=vacuum_end, channel=channel, - transverse=transverse, + incident=incident, kerr_form=kerr_form, observable_rtol=observable_rtol, observable_atol=observable_atol, @@ -4099,7 +4250,7 @@ def from_waveguide( # noqa: PLR0913 - the interface's keyword surface for name, value in ( ("memory_budget", memory_budget), ("channel", channel), - ("transverse", transverse), + ("incident", incident), ("kerr_form", None if kerr_form == "real" else kerr_form), ("observable_rtol", observable_rtol), ("observable_atol", observable_atol), @@ -4178,9 +4329,7 @@ def _from_general( # noqa: PLR0913 - it refuses from_waveguide's whole surface length: float, *, channel: int | str | None, - transverse: Inexact[np.ndarray, "n_nodes n_components n_omega"] - | Inexact[np.ndarray, "n_nodes n_components"] - | None, + incident: SpatialPulse | None, kerr_form: KerrForm, observable_rtol: float | None, observable_atol: float | None, @@ -4230,9 +4379,9 @@ def _from_general( # noqa: PLR0913 - it refuses from_waveguide's whole surface Guide length, m. channel : int or str or None Which channel the pulse enters; ``None`` takes the first. - transverse : numpy.ndarray or None - A declared transverse field to project onto the channels instead, - as :meth:`from_waveguide` documents it. + incident : solprop.pulses.SpatialPulse or None + An incident beam to project onto the channels instead, as + :meth:`from_waveguide` documents it. kerr_form : {'real', 'analytic'} Which instantaneous cubic the regions respond with, as :meth:`from_waveguide` documents it. @@ -4319,7 +4468,9 @@ def _from_general( # noqa: PLR0913 - it refuses from_waveguide's whole surface ) raise DriverError(msg) planes = _save_planes(length, save_planes, n_save) - pulse = _shaped_pulse( + pulse = _general_pulse( + incident, + channel, pulseshape, lambda0=lambda0, fwhm=fwhm, @@ -4329,14 +4480,6 @@ def _from_general( # noqa: PLR0913 - it refuses from_waveguide's whole surface phases=phases, pulses=pulses, ) - if channel is not None and transverse is not None: - msg = ( - "channel= and transverse= are two ways to say what is launched and " - "this call gives both: name a channel and the pulse enters it " - "unchanged, or declare a transverse field and the pulse's spectrum " - "is projected onto every channel it overlaps" - ) - raise DriverError(msg) if provider.sampling is not None: # Before the route and not after it: the route builds the dense E/H # reference and the maps, which on a large guide are nearly all of the @@ -4348,7 +4491,9 @@ def _from_general( # noqa: PLR0913 - it refuses from_waveguide's whole surface backend=backend, memory_budget=memory_budget, ) - built = pulse.build(working) + built = (pulse.temporal if isinstance(pulse, SpatialPulse) else pulse).build( + working + ) try: route = general_modal_route( provider, @@ -4372,21 +4517,21 @@ def _from_general( # noqa: PLR0913 - it refuses from_waveguide's whole surface # `DriverError` is what its `Raises` names. The message is the bridge's # own, unchanged, and the cause is kept. raise DriverError(str(error)) from error - field0 = ( - _in_channel(built, provider, channel) - if transverse is None - else _projected_launch( - built, + launch: IncidentLaunch | None = None + if isinstance(pulse, SpatialPulse): + field0, launch = _incident_launch( + pulse, + working, route.tables, - transverse, + _core_index(provider.regions, working), # The metric the run's own statistics are weighted by, at the - # entrance, so that the launched physical energy is the pulse's on - # a declared basis as exactly as it is on the identity. An - # identity route's `metric_at` is `None`, which is the unweighted - # path. + # entrance, so that the captured energy is the launched state's + # physical energy on a declared basis as on the identity. An + # identity route's `metric_at` is `None`, the unweighted path. metric_at(route.metric_tables, 0.0), ) - ) + else: + field0 = _in_channel(built, provider, channel) settings = SimulationSettings( controller=PIController(rtol=rtol, atol=atol), save_planes=planes, @@ -4417,6 +4562,7 @@ def _from_general( # noqa: PLR0913 - it refuses from_waveguide's whole surface "provider": dict(provider.identity), }, "pulse": pulse, + **({} if launch is None else {"launch": launch}), }, general=route, guide=provider, @@ -8472,55 +8618,170 @@ def _in_channel( return state -def _projected_launch( - built: Complex[np.ndarray, "n_modes_in n_pol n_omega"], +def _general_pulse( # noqa: PLR0913 - the pulse's keyword surface and the launch + incident: SpatialPulse | None, + channel: int | str | None, + pulseshape: str, + *, + lambda0: float, + fwhm: float | None, + width: float | None, + energy: float | None, + peak_power: float | None, + phases: Sequence[float] | np.ndarray, + pulses: Sequence[Pulse] | None, +) -> GaussianPulse | SechPulse | PulseSum | SpatialPulse: + """Return a general modal run's input: a shaped pulse, or an incident beam. + + Parameters + ---------- + incident : solprop.pulses.SpatialPulse or None + The incident beam, which carries its own temporal pulse; ``None`` builds the + pulse from the keywords. + channel : int or str or None + The named channel, refused beside `incident`. + pulseshape, lambda0, fwhm, width, energy, peak_power, phases, pulses : object + The pulse keywords, as :func:`_shaped_pulse` takes them. + + Returns + ------- + solprop.pulses.GaussianPulse or SechPulse or PulseSum or SpatialPulse + The input. + + Raises + ------ + DriverError + If `incident` is given beside `channel` or a pulse keyword, is a planar + beam, or is launched away from its waist. + """ + if incident is None: + return _shaped_pulse( + pulseshape, + lambda0=lambda0, + fwhm=fwhm, + width=width, + energy=energy, + peak_power=peak_power, + phases=phases, + pulses=pulses, + ) + if channel is not None: + msg = ( + "channel= and incident= are two ways to say what is launched and this " + "call gives both: name a channel and the pulse enters it unchanged, or " + "declare an incident beam and the channels capture what they overlap" + ) + raise DriverError(msg) + given = [ + name + for name, value in ( + ("fwhm", fwhm), + ("width", width), + ("energy", energy), + ("peak_power", peak_power), + ("pulses", pulses), + ) + if value is not None + ] + if len(phases): + given.append("phases") + if pulseshape != "gauss": + given.append("pulseshape") + if given: + msg = ( + f"incident= carries its own temporal pulse, and this call also sets " + f"{', '.join(given)}: give the beam's pulse its duration, energy and " + "phase, and leave the pulse keywords unset" + ) + raise DriverError(msg) + if isinstance(incident, PlanarPulse): + msg = ( + "a PlanarPulse launches into a planar guide's slab modes; the incident " + "beam of a general modal guide is a SpatialPulse" + ) + raise DriverError(msg) + if incident.propz != 0: + msg = ( + f"incident= is launched {incident.propz!r} m from its waist; carrying a " + "beam to the guide's entrance is a transport step this launch does not " + "model, so the beam is launched at its waist (propz=0)" + ) + raise DriverError(msg) + return incident + + +def _core_index( + regions: Sequence[MaterialRegion], grid: TimeGrid +) -> Float[np.ndarray, " n_omega"]: + """Return the real phase index of a guide's core at each of a grid's frequencies. + + The incident medium of a beam launched into the guide: the innermost material + region, whose law does not move with z, so it is the entrance's. + + Parameters + ---------- + regions : sequence of solprop.modes.material.MaterialRegion + The guide's ordered material regions. + grid : solprop.grid.TimeGrid + The run's grid. + + Returns + ------- + numpy.ndarray + ``Re sqrt(eps)`` on the active positive band and zero elsewhere, shape + ``(n_omega,)``. + + Raises + ------ + DriverError + If the guide's first region does not start on the axis. + """ + core = regions[0] + if not isinstance(core.inner_radius, float | int) or core.inner_radius != 0: + msg = ( + f"an incident beam enters a guide through its core, the region on the " + f"axis, and this guide's first region {core.name!r} does not start there" + ) + raise DriverError(msg) + live = grid.active_mask & (grid.omega > 0) + index = np.zeros(grid.n_omega) + index[live] = np.real(np.sqrt(core.material.permittivity(grid.omega[live]))) + return index + + +def _incident_launch( + pulse: SpatialPulse, + grid: TimeGrid, tables: ModalTransformTables, - transverse: Inexact[np.ndarray, "n_nodes n_components n_omega"] - | Inexact[np.ndarray, "n_nodes n_components"], + index: Float[np.ndarray, " n_omega"], metric: Complex[np.ndarray, "n_modes_metric_out n_modes_metric_in n_omega_metric"] | None = None, -) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: - """Launch a declared transverse profile, shaped in time by the pulse. - - The declared field is the pulse's **spatial** shape: a profile at the guide's - nodes, normalised here so that the launched true-power energy is the pulse's - however the caller scaled it, times the pulse's own spectrum. The projection - itself is - :func:`solprop.translate.modal_tables.project_transverse_field`, whose - docstring carries the map and the forward-field closure; what is added here is - the normalisation, so that ``energy=`` means the same thing on both launches. - - **The norm rescaled against is the run's own declared metric**, so that - "the launched physical energy is the pulse's" is exact on every metric this - route admits and not only on the identity. On an identity metric it is - ``sum |a_m|^2``, bit for bit the unweighted sum; on a declared dense basis it - is ``a.H W a`` for the same ``W`` the run's - :func:`solprop.stats.physical_energy` is weighted by, and the two differ there - by the basis's departure from orthonormal. It is the **declared** metric and - not :attr:`~solprop.translate.modal_tables.ProjectedLaunch.captured`, which is - the interpolated flux the guide's stored E and H actually carry: rescaling on - that would leave the identity route's launch off by the interpolated - departure and stop it being bitwise the pulse. - - **What the channels miss is reported, not deducted.** A profile the channels - capture badly still launches the full declared ``energy=``, redistributed over - what they do capture, and the share is not clipped at one; the residual share - the projection reports is logged here and is the caller's to read from - :func:`~solprop.translate.modal_tables.project_transverse_field` directly. - What this launch guarantees is that the energy that *does* enter the guide is - the declared one, which is the only statement a run's own statistics can check. +) -> tuple[Complex[np.ndarray, "n_modes n_pol n_omega"], IncidentLaunch]: + """Launch an incident beam into a guide's channels and account for its energy. + + The beam is normalised over its **own** whole plane in the paraxial measure of + the core medium (:meth:`solprop.pulses.SpatialPulse.incident`), so light + outside the guide's quadrature counts as incident; the correctly scaled field is + sampled at the guide's nodes and projected onto its channels by + :func:`solprop.translate.modal_tables.project_transverse_field`. The projected + amplitudes are the launch: **nothing is rescaled to the requested energy**, so a + badly captured beam launches the little the channels take, and a beam they + cannot see at all is a valid zero launch. The captured energy is + ``a.H W a`` under the run's own declared metric, which is what + :func:`solprop.stats.physical_energy` measures at the entrance. Captured and + uncaptured are physical shares only in a common positive metric (SEP 31 + section 4.3); see :class:`IncidentLaunch`. Parameters ---------- - built : numpy.ndarray - The pulse on the run's grid, shape ``(1, n_pol, n_omega)``. + pulse : solprop.pulses.SpatialPulse + The incident beam and its temporal pulse. + grid : solprop.grid.TimeGrid + The run's grid. tables : solprop.translate.modal_tables.ModalTransformTables The guide's reference, carrying the nodes and the coordinate dual. - transverse : numpy.ndarray - The declared profile at those nodes, real or complex, shape - ``(n_nodes, n_components)`` or - ``(n_nodes, n_components, n_omega)``; the first is broadcast over - frequency. Its overall scale is immaterial. + index : numpy.ndarray + Real phase index of the core at each frequency, shape ``(n_omega,)``. metric : numpy.ndarray or None, optional The run's declared basis metric ``W(omega)`` at the entrance, shape ``(n_modes, n_modes, n_omega)``, as @@ -8529,95 +8790,48 @@ def _projected_launch( Returns ------- - numpy.ndarray - The launched state, shape ``(n_modes, n_pol, n_omega)``, in the square - root of watts. + tuple + The launched state, shape ``(n_modes, 1, n_omega)``, in the square root of + watts, and its :class:`IncidentLaunch` account. Raises ------ DriverError - If the pulse carries more than one channel, the profile does not lie on - the guide's nodes, or the channels capture none of it. + If the beam cannot be normalised or the record cannot project it. """ - if built.shape[0] != 1: - msg = ( - f"a projected launch shapes one pulse with one transverse profile and " - f"this input carries {built.shape[0]} channel(s); build the state " - "yourself and pass it to Simulation.with_input" - ) - raise DriverError(msg) nodes, components = tables.electric.shape[1], tables.electric.shape[2] - bins = int(built.shape[-1]) - profile = np.asarray(transverse, dtype=np.complex128) - if profile.shape == (nodes, components): - profile = profile[:, :, None] * np.ones(bins) - if profile.shape != (nodes, components, bins): - msg = ( - f"a declared transverse field lies on this guide's nodes, components " - f"and coarse frequencies, shape ({nodes}, {components}) or " - f"({nodes}, {components}, {bins}); got {profile.shape}" - ) - raise DriverError(msg) try: - launched = project_transverse_field(tables, profile * built[0, 0]) + beam = pulse.incident(grid, tables.node_xy[0], index=index) + except PulseError as error: + raise DriverError(str(error)) from error + sampled = np.zeros((nodes, components, grid.n_omega), dtype=np.complex128) + # The dual's longitudinal columns are zero, so a transverse beam needs no E_z. + sampled[:, :2] = beam.field + try: + projected = project_transverse_field(tables, sampled) except ModeError as error: raise DriverError(str(error)) from error - # The norm the pulse's energy is matched against, under the run's own declared - # metric: `sum_m |a_m|^2` on the identity, which is bitwise the unweighted sum, - # and `a.H W a` on a declared dense basis, which is what `physical_energy` will - # weigh the launched state by. - weighted = ( - np.abs(launched.amplitudes) ** 2 + amplitudes = projected.amplitudes + flux = ( + np.sum(np.abs(amplitudes) ** 2, axis=0) if metric is None - else np.real( - np.einsum( - "mw,mnw,nw->mw", - np.conj(launched.amplitudes), - metric, - launched.amplitudes, - ) - ) + else np.real(np.einsum("mw,mnw,nw->w", np.conj(amplitudes), metric, amplitudes)) ) - total = float(np.sum(weighted)) - if not np.isfinite(total) or total <= 0.0: - msg = ( - "this guide's channels capture none of the declared transverse field: " - "every projected amplitude is zero, so there is nothing to launch and " - "nothing to normalise. Check the profile against the guide's own node " - "positions (tables.node_xy) and its channels' transverse patterns" - ) - raise DriverError(msg) - # The caller's profile has an arbitrary scale, and `energy=` must mean the same - # thing here as on the per-channel launch. Rescaling the whole projection is - # the one operation that leaves the **shape** of the launch -- the share each - # channel takes, which is what the projection is for -- exactly as projected. - scale = float(np.sqrt(np.sum(np.abs(built[0, 0]) ** 2) / total)) - state = np.zeros( - (launched.amplitudes.shape[0], *built.shape[1:]), dtype=np.complex128 + weights = fourier.energy_weights(grid.n_omega, grid.dt, kind=grid.kind) + report = IncidentLaunch( + index, beam.energy, np.asarray(weights * flux, dtype=np.float64) ) - state[:, 0, :] = launched.amplitudes * scale - # The share the channels miss is the profile's own norm the launch leaves out, so - # rescaling to `energy=` puts all of the pulse's energy into the captured shape: - # always reported, and a warning once enough is missed to matter. - missed = launched.residual_share + state = np.zeros((amplitudes.shape[0], 1, grid.n_omega), dtype=np.complex128) + state[:, 0, :] = amplitudes logger.info( - "general modal launch projected onto %d channel(s): they capture %.1f %% of " - "the declared transverse field (%.1f %% not captured)", + "incident beam launched into %d channel(s): %.4g J incident, %.4g J " + "captured, %.4g J not captured", state.shape[0], - 100.0 * max(0.0, 1.0 - missed), - 100.0 * missed, + report.incident_energy, + report.captured_energy, + report.uncaptured_energy, ) - if missed >= LAUNCH_UNCAPTURED_WARNING: - warnings.warn( - f"the guide's {state.shape[0]} channel(s) capture only " - f"{100.0 * max(0.0, 1.0 - missed):.1f} % of the declared transverse field " - f"({100.0 * missed:.1f} % not captured), and the launch still carries the " - "pulse's whole energy in the captured shape; check the profile against " - "the guide's channels, or add the channels it needs", - GeneralModalBasisWarning, - skip_file_prefixes=_PACKAGE_FILES, - ) - return state + return state, report def _in_lowest_mode( diff --git a/tests/driver/test_general_modal.py b/tests/driver/test_general_modal.py index 0abbaa77e..39a5eee19 100644 --- a/tests/driver/test_general_modal.py +++ b/tests/driver/test_general_modal.py @@ -25,8 +25,6 @@ import dataclasses import inspect -import logging -import warnings from collections.abc import Mapping from contextlib import ExitStack from typing import Any, cast @@ -66,8 +64,10 @@ from solprop.driver.scan import Scan, run_scan from solprop.driver.simulation import ( _GAS_KEYWORD_DEFAULTS, + IncidentLaunch, + _core_index, _in_channel, - _projected_launch, + _incident_launch, _refuse_gas_keywords, _shot_noise, ) @@ -89,7 +89,7 @@ from solprop.physdata import n2_from_chi3 from solprop.physdata.sellmeier import gamma as gas_susceptibility from solprop.processing import scans -from solprop.pulses import GaussianPulse +from solprop.pulses import GaussianPulse, PlanarPulse, SpatialPulse from solprop.stats import StatsSpec, analytic_field, energy, physical_energy from solprop.stepper import FixedSchedule from solprop.translate.coupled import metric_at @@ -507,131 +507,191 @@ def circular_pair_guide(grid): ) +def incident_beam(waist, jones=(1.0,)): + """Return an incident beam carrying the tests' pulse, launched at its waist.""" + return SpatialPulse( + GaussianPulse( + wavelength=PULSE["lambda0"], fwhm=PULSE["fwhm"], energy=PULSE["energy"] + ), + waist, + jones=jones, + ) + + +def launched(guide, grid, incident, **overrides): + """Return a general modal simulation launching an incident beam.""" + return Simulation.from_waveguide( + (), + GENERAL_Z_END, + provider=guide, + grid=grid, + lambda0=PULSE["lambda0"], + incident=incident, + **(RUN | overrides), + ) + + +def account(simulation) -> IncidentLaunch: + """Return the incident-energy account a simulation recorded.""" + report = simulation.configuration["launch"] + assert isinstance(report, IncidentLaunch) + return report + + @pytest.mark.parametrize("kind", ["identity", "dense"]) -def test_the_projected_launch_carries_the_pulses_energy_and_the_declared_shape( +def test_an_incident_launch_keeps_what_the_channels_capture_under_the_runs_metric( pair_guide, circular_pair_guide, grid, kind ): - """`transverse=` launches the profile's shape at the pulse's energy. - - Three statements. The **shape** is the projection's: the share each channel takes is - what the declared field decides, here a 1 : 0.5 amplitude ratio. The **scale** is - the pulse's, so that ``energy=`` means on this launch what it means on ``channel=``. - And the energy that is matched is the run's own ``physical_energy`` **under its own - declared metric**, which on a declared dense basis is not the unweighted sum: - rescaling on ``sum_m |a_m|^2`` there would leave the launched energy short by the - basis's departure from orthonormal. On the identity route the weighted and - unweighted norms are the same object, so that launch is bitwise what it was. + """`incident=` launches the captured amplitudes, and its account says so. + + The incident energy is the pulse's over the whole beam; the captured energy + is the launched state's physical energy under the run's own declared metric, + which on the dense circular basis is not the unweighted sum. Nothing is + rescaled, so the launched energy is the captured one and falls short of + ``energy=`` by what the channels miss. An x-polarised beam enters the even + parity channel alone, and splits equally between the two circular ones. """ guide = pair_guide if kind == "identity" else circular_pair_guide - route = general_modal_route( - guide, grid, z_max=GENERAL_Z_END, omega_reference=float(grid.omega0) - ) - assert route.metric.kind == kind - tables = route.tables - centre = int(tables.omega.size // 2) - profile = np.einsum( - "qcm,m->qc", tables.electric[0, :, :, :, centre], np.array([1.0, 0.5]) - ) - simulation = build(guide, grid, transverse=profile) - state = simulation._field0 - assert state.shape[0] == 2 - live = np.abs(state[0, 0]) > 0.0 - assert_allclose( - np.abs(state[1, 0][live]) / np.abs(state[0, 0][live]), - 0.5, - rtol=1e-6, - atol=0.0, - ) + simulation = launched(guide, grid, incident_beam(1.3e-6)) + report = account(simulation) + # One rescaling of the beam, summed over bins: a few rounding units. + assert report.incident_energy == pytest.approx(PULSE["energy"], rel=1e-13) + assert 0.5 * report.incident_energy < report.captured_energy + assert report.captured_energy < report.incident_energy + assert_allclose(report.uncaptured, report.incident - report.captured, rtol=0.0) problem = simulation.translate() metric = metric_at(problem.linear.modal_metric, 0.0) assert (metric is None) == (kind == "identity") assert_allclose( float(physical_energy(grid, problem.initial.field0[0], metric=metric)), - PULSE["energy"], - # The whitening is an eigendecomposition per bin, so the weighted norm the - # launch was scaled on and the one the statistic forms agree to rounding - # over the 288-bin reduction and not to the bit. + report.captured_energy, + # The statistic whitens by an eigendecomposition per bin and the account + # forms the quadratic form directly: rounding over the 65-bin reduction. rtol=1e-12, atol=0.0, ) + state = simulation._field0[:, 0] + live = np.abs(state[0]) > 0.0 if kind == "identity": - # And there the unweighted sum is the same statement, bit for bit. - assert float(physical_energy(grid, problem.initial.field0[0])) == float( - np.sum(energy(grid, problem.initial.field0[0])) - ) + # The even channel's E_y and the odd channel's E_x vary as sin(2 theta), + # so their overlap with a uniformly polarised beam cancels on the + # azimuthal rule to rounding of the node sum. + assert np.max(np.abs(state[1])) < 1e-12 * np.max(np.abs(state[0])) + else: + assert_allclose(np.abs(state[1][live]), np.abs(state[0][live]), rtol=1e-6) -def test_a_real_transverse_profile_is_the_contract_and_launches_as_its_complex_copy( +def test_an_incident_launch_shares_a_coherent_beam_between_two_channels( pair_guide, grid ): - """A real profile is accepted under the hook and means what it says. + """A beam polarised between the parity channels fills both, coherently. + + The Jones vector ``(1, 0.5j)`` puts half the even channel's amplitude in the + odd one, a quarter period behind (conjugated into the internal convention). + The two channels are a rotation of each other, so the total captured is the + x-polarised beam's: what is projected is the field, and the account is the + quadratic form of its amplitudes, not a sum of separately projected + intensities. + """ + tilted = launched(pair_guide, grid, incident_beam(1.3e-6, (1.0, 0.5j))) + straight = launched(pair_guide, grid, incident_beam(1.3e-6, (1.0, 0.0))) + state = tilted._field0[:, 0] + live = np.abs(state[0]) > 0.0 + assert_allclose(state[1][live] / state[0][live], -0.5j, rtol=1e-6) + assert_allclose( + account(tilted).captured_energy, + account(straight).captured_energy, + rtol=1e-6, + ) - A real array is the common way to declare a beam, so the annotations say "real - or complex" and the launch converts it: the same values declared as a complex - array build the same state and the same projection, to the bit. + +def test_a_beam_the_channels_cannot_see_is_a_valid_zero_launch(guide, grid): + """Zero capture by symmetry launches nothing and runs; no division by zero. + + A y-polarised beam against the x-polarised even channel: their overlap is the + channel's E_y, which varies as sin(2 theta) and cancels on the azimuthal + rule. The bound is the square of the node sum's rounding, ``(n_nodes eps)**2`` + of the incident energy, since an energy is quadratic in the amplitude. """ - tables = projected_tables(pair_guide, grid) - centre = int(tables.omega.size // 2) - profile = np.real( - np.einsum( - "qcm,m->qc", tables.electric[0, :, :, :, centre], np.array([1.0, 0.5]) - ) + simulation = launched(guide, grid, incident_beam(1.3e-6, (0.0, 1.0))) + report = account(simulation) + assert simulation.general is not None + nodes = int(simulation.general.tables.electric.shape[1]) + bound = (nodes * np.finfo(float).eps) ** 2 + assert 0.0 <= report.captured_energy < bound * report.incident_energy + assert report.uncaptured_energy == pytest.approx(report.incident_energy, rel=1e-13) + output = cast("MemoryOutput", simulation.run(progress="none")) + assert all(np.all(np.isfinite(kept(plane.field))) for plane in output.planes()) + assert "captured share" in str(report) + + +def test_the_incident_energy_does_not_depend_on_the_guides_aperture(guide, grid): + """The beam is normalised over its own plane, not over a guide's nodes. + + The same beam into two strands of different radius carries the same incident + account, bin for bin and to the bit, because no node of either guide enters + it; what each captures differs. + """ + wider = general_guide(grid=grid, radius=2.5e-6) + beam = incident_beam(1.3e-6) + narrow_account = account(launched(guide, grid, beam)) + wide_account = account(launched(wider, grid, beam)) + np.testing.assert_array_equal(narrow_account.incident, wide_account.incident) + np.testing.assert_array_equal( + narrow_account.reference_index, wide_account.reference_index ) - assert profile.dtype == np.float64 - assert np.any(profile != 0.0) - real = build(pair_guide, grid, transverse=profile)._field0 - declared = build(pair_guide, grid, transverse=profile.astype(complex))._field0 - np.testing.assert_array_equal(real, declared) - spread = np.repeat(profile[:, :, None], tables.omega.size, axis=2) - projected = project_transverse_field(tables, spread) - expected = project_transverse_field(tables, spread.astype(complex)) - np.testing.assert_array_equal(projected.amplitudes, expected.amplitudes) + assert narrow_account.captured_energy != wide_account.captured_energy -def test_the_two_launches_are_refused_together_and_an_empty_one_by_name( - pair_guide, grid -): - """Two ways to say what is launched, and a profile no channel sees.""" +def test_the_incident_launch_refuses_what_it_does_not_carry(pair_guide, grid): + """Two launches at once, pulse keywords beside a beam, and beams it cannot place.""" + beam = incident_beam(1.3e-6) with pytest.raises(DriverError, match="two ways to say what is launched"): - build(pair_guide, grid, channel=0, transverse=np.ones((2, 3), dtype=complex)) - with pytest.raises(DriverError, match="lies on this guide's nodes"): - build(pair_guide, grid, transverse=np.ones((2, 3), dtype=complex)) - tables = projected_tables(pair_guide, grid) - nodes, components = tables.electric.shape[1], tables.electric.shape[2] - with pytest.raises(DriverError, match="capture none of the declared"): - build( - pair_guide, - grid, - transverse=np.zeros((nodes, components), dtype=complex), - ) - with pytest.raises(DriverError, match="one transverse profile"): - _projected_launch( - np.ones((2, 1, grid.n_omega), dtype=complex), - tables, - np.ones((nodes, components), dtype=complex), - ) + launched(pair_guide, grid, beam, channel=0) + for keyword, value in ( + ("fwhm", 20e-15), + ("width", 10e-15), + ("energy", 1e-12), + ("peak_power", 1.0), + ("phases", (0.0, 0.0, 1e-30)), + ("pulses", (GaussianPulse(wavelength=835e-9, fwhm=2e-14, energy=1e-12),)), + ("pulseshape", "sech"), + ): + with pytest.raises(DriverError, match=f"carries its own temporal.*{keyword}"): + launched(pair_guide, grid, beam, **{keyword: value}) + temporal = beam.temporal + with pytest.raises(DriverError, match="PlanarPulse"): + launched(pair_guide, grid, PlanarPulse(temporal, 1e-6, waist_x=1e-6)) + with pytest.raises(DriverError, match="propz=0"): + launched(pair_guide, grid, SpatialPulse(temporal, 1.3e-6, propz=-1e-4)) # And a record the map itself refuses reaches the caller as this entry point's # own error: the launch promises `DriverError` and the map raises `ModeError`. + tables = projected_tables(pair_guide, grid) bare = dataclasses.replace(tables) object.__setattr__(bare, "coordinate_dual", None) + index = np.where(grid.active_mask & (grid.omega > 0), 1.45, 0.0) with pytest.raises(DriverError, match="carry no coordinate_dual"): - _projected_launch( - np.ones((1, 1, grid.n_omega), dtype=complex), - bare, - np.ones((nodes, components), dtype=complex), - ) + _incident_launch(beam, grid, bare, index) + with pytest.raises(DriverError, match="finite and non-negative"): + _incident_launch(beam, grid, tables, -index) # And without a provider it is one of the general-guide keywords the # gas-filled route refuses by name, beside `channel=` and `kerr_form=`. - with pytest.raises(DriverError, match="transverse= belongs to the general modal"): + with pytest.raises(DriverError, match="incident= belongs to the general modal"): Simulation.from_waveguide( (MarcatiliMode(125e-6, "He", 1.0),), GENERAL_Z_END, gas="He", pressure=1.0, grid=grid, - transverse=np.ones((2, 3), dtype=complex), + incident=beam, **PULSE, ) + # A guide whose first region is not on the axis has no core to enter through. + provider = pair_guide.field_provider + core, cladding = provider.regions + shifted = (dataclasses.replace(core, inner_radius=1e-7), cladding) + with pytest.raises(DriverError, match="does not start there"): + _core_index(shifted, grid) class _LossyLaw: @@ -683,49 +743,81 @@ def test_a_complex_permittivity_never_reaches_the_forward_field_closure(guide): @pytest.mark.medium -@pytest.mark.parametrize("dtype", [complex, float]) -def test_prop_waveguide_takes_a_declared_transverse_field_too(pair_guide, grid, dtype): - """The functional entry point forwards `transverse=`, and its run is the same. - - `prop_waveguide` is the one-call form of `Simulation.from_waveguide(...).run`, - and a keyword it accepts but drops would silently launch into the lowest - channel instead of the declared field. Two steps are enough: the launch is - what is being checked. +def test_a_poorly_captured_beam_launches_only_what_the_guide_takes(guide, grid): + """A beam much wider than the core enters with the little the guide captures. + + A 20 um beam on a 1.5 um strand: the channel captures about one per cent of + it. The run's own entrance plane, through `prop_waveguide`, carries the + captured energy and not ``energy=``: nothing restores what missed. The + account is in the file's configuration as well as the simulation's. """ - tables = projected_tables(pair_guide, grid) - centre = int(tables.omega.size // 2) - profile = np.einsum( - "qcm,m->qc", tables.electric[0, :, :, :, centre], np.array([1.0, 0.5]) - ) - # A real profile is the contract's common case; these fields are real - # to rounding, so its real part launches the same shape. - profile = profile.astype(complex) if dtype is complex else np.real(profile) + beam = incident_beam(20e-6) output = cast( "MemoryOutput", prop_waveguide( (), GENERAL_Z_END, - provider=pair_guide, + provider=guide, grid=grid, - transverse=profile, + lambda0=PULSE["lambda0"], + incident=beam, progress="none", - **(PULSE | RUN), + **RUN, ), ) + report = account(launched(guide, grid, beam)) + assert report.captured_energy < 0.02 * report.incident_energy + assert report.incident_energy == pytest.approx(PULSE["energy"], rel=1e-13) entrance = kept(output.planes()[0].field) - expected = build(pair_guide, grid, transverse=profile)._field0 - # The same declared launch, to the bit: the file's plane is the state the - # entry point built, carried through one conjugation and back (ADR-017). - live = np.abs(expected[0, 0]) > 0.0 + # Identity metric: the energy statistic is the sum of |a|^2, formed in the + # same order as the account's up to the frequency-sum association. assert_allclose( - np.abs(entrance[1, 0][live]) / np.abs(entrance[0, 0][live]), - 0.5, - rtol=1e-6, + float(physical_energy(grid, entrance)), + report.captured_energy, + rtol=1e-12, atol=0.0, ) + assert output.metadata is not None + recorded = output.metadata.configuration["launch"] + assert isinstance(recorded, IncidentLaunch) + assert recorded.captured_energy == report.captured_energy + + +@pytest.mark.medium +@pytest.mark.parametrize("backend", ["numpy_ref", "numba_cpu", "native"]) +def test_the_incident_launch_is_the_same_on_every_cpu_receiver(guide, grid, backend): + """The launch is built before any backend: every CPU backend starts from it. + + Ten half-micrometre steps on each receiver: the stored entrance + plane is the launched state, a lossless copy, so it agrees bit for bit with the + one the simulation built, and its energy is the captured energy. + """ + if backend == "native": + pytest.importorskip("solprop_native") + beam = incident_beam(2e-6) + # Inside the default collar's explicit stability limit, 7e-7 m. + step = 5e-7 + simulation = launched( + guide, + grid, + beam, + backend=backend, + save_planes=[10 * step], + dz_max=step, + dz_init=step, + schedule=FixedSchedule(np.full(10, step)), + ) + output = cast("MemoryOutput", simulation.run(progress="none")) + entrance = kept(output.planes()[0].field) + reference = launched(guide, grid, beam)._field0 + np.testing.assert_array_equal(entrance, reference) assert_allclose( - float(np.sum(energy(grid, entrance))), PULSE["energy"], rtol=1e-12, atol=0.0 + float(physical_energy(grid, entrance)), + account(simulation).captured_energy, + rtol=1e-12, + atol=0.0, ) + assert all(np.all(np.isfinite(kept(plane.field))) for plane in output.planes()) def test_a_multichannel_input_is_refused_by_the_launch(pair_guide, grid): @@ -2439,36 +2531,3 @@ def simulation(): output = stable.run(progress="none") assert isinstance(output, MemoryOutput) assert all(np.all(np.isfinite(kept(plane.field))) for plane in output.planes()) - - -def test_a_projected_launch_reports_its_captured_share_and_warns_past_three_percent( - pair_guide, grid, caplog -): - """`transverse=` logs the captured share at INFO and warns at 3 % missed or more. - - A profile that is exactly a combination of the guide's channels at the centre bin is - captured there, and the launch logs that without a warning. Adding a random - perturbation of a fifth of the profile's norm leaves about half of it uncaptured, - and the launch warns, still at the pulse's energy. - """ - route = general_modal_route( - pair_guide, grid, z_max=GENERAL_Z_END, omega_reference=float(grid.omega0) - ) - tables = route.tables - centre = int(tables.omega.size // 2) - captured = np.einsum( - "qcm,m->qc", tables.electric[0, :, :, :, centre], np.array([1.0, 0.5]) - ) - with ( - caplog.at_level(logging.INFO, logger="solprop.driver.simulation"), - warnings.catch_warnings(), - ): - warnings.simplefilter("error", GeneralModalBasisWarning) - build(pair_guide, grid, transverse=captured) - reports = [r.getMessage() for r in caplog.records if "capture" in r.getMessage()] - assert reports and "not captured" in reports[-1] - noise = np.random.default_rng(1).standard_normal(captured.shape) - noise *= 0.2 * np.linalg.norm(captured) / np.linalg.norm(noise) - with pytest.warns(GeneralModalBasisWarning, match="not captured"): - simulation = build(pair_guide, grid, transverse=captured + noise) - assert np.all(np.isfinite(simulation._field0)) diff --git a/tests/test_display.py b/tests/test_display.py index 8bd074e0b..da1326fff 100644 --- a/tests/test_display.py +++ b/tests/test_display.py @@ -18,6 +18,7 @@ from solprop import _display, tools from solprop.driver import Simulation, SimulationSettings from solprop.driver.scan import Scan +from solprop.driver.simulation import IncidentLaunch from solprop.exceptions import MaterialRangeWarning, ModeError, OutputError from solprop.grid import EnvelopeGrid, FreeGrid, RadialGrid from solprop.modes import ( @@ -218,6 +219,12 @@ def modal_records(): # A declared transverse field projected onto this guide's one channel: the # channel's own profile, so the projection captures all of it. "ProjectedLaunch": project_transverse_field(printed_tables, printed_field), + # An incident beam's account: synthetic per-bin energies, a third captured. + "IncidentLaunch": IncidentLaunch( + np.full(3, 1.45), + np.array([2e-7, 6e-7, 2e-7]), + np.array([0.6e-7, 2e-7, 0.7e-7]), + ), "ProviderChannelMode": grin_guide.mode_collection.modes[0], "ModalSampling": grin_guide.sampling, # A moving guide's path record: synthetic numbers of the shape a sampled path @@ -506,6 +513,7 @@ def examples(tmp_path_factory): "ProviderChannelMode", "ModalTransformTables", "ProjectedLaunch", + "IncidentLaunch", "GeneralModalRoute", "ModalMetricTables", "CoupledFlowTables", @@ -898,6 +906,7 @@ def rounding(block: str) -> str: "ProviderChannelMode", "ModalTransformTables", "ProjectedLaunch", + "IncidentLaunch", "GeneralModalRoute", "ModalMetricTables", "CoupledFlowTables", diff --git a/tests/test_display/IncidentLaunch.txt b/tests/test_display/IncidentLaunch.txt new file mode 100644 index 000000000..06415ecff --- /dev/null +++ b/tests/test_display/IncidentLaunch.txt @@ -0,0 +1,5 @@ +IncidentLaunch: an incident beam's energy in a guide + incident: 1 uJ + captured: 330 nJ + uncaptured: 670 nJ + captured share: 0.3300 of the incident energy From 5101f1ff3f4bf7e049396cedc185810a67179b13 Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 00:07:53 +0100 Subject: [PATCH 04/50] Docs: incident beams launch the captured share of their energy Reword the general modal, supplied-guide and solid-fibre pages and the graded-index tutorial for incident=: energy= is the physical energy over the whole beam in the core medium, the channels launch what they capture, and the account is recorded in the run's configuration. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019qNYqtCKh4iTKj7544yARb --- CHANGELOG.md | 10 ++ docs/guide/waveguides/general-modal.md | 99 ++++++++++++------- docs/guide/waveguides/solid-fibres.md | 44 ++++----- docs/guide/waveguides/supplied-guides.md | 6 +- .../waveguides/plot_06_graded_index.py | 4 +- 5 files changed, 97 insertions(+), 66 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index daaed9eac..597ffd326 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,16 @@ The first public release. It contains: `SimulationSettings.adiabatic_accuracy`). `poynting_spectrum` is removed: `beam_spectrum` is the axial-flux spectrum. Kernel schema 35 (`flux`) and output schema 23 (`flux_model`, `flux_plan`). +- **Incident beams into general modal guides** + ([SEP 31](https://solprop.readthedocs.io/en/latest/dev/seps/sep-0031-simulation-coupling.html), + phase 1). `from_waveguide(provider=..., incident=SpatialPulse(...))` and + `prop_waveguide` launch the share of an incident beam that the guide's channels + capture. The beam's energy is the physical energy over its whole plane in the core + medium, and the light that misses is lost rather than restored. `IncidentLaunch` + records the incident, captured and uncaptured energy per frequency in + `configuration["launch"]`, and `SpatialPulse.incident` samples a beam normalised over + its own plane. The node-sampled `transverse=` profile, its rescaling to `energy=`, the + 3 % uncaptured warning and `LAUNCH_UNCAPTURED_WARNING` are removed. - **Media and responses.** - Gases, gas mixtures, glasses and metals from the [optmat](https://github.com/LupoLab/optmat) material library. diff --git a/docs/guide/waveguides/general-modal.md b/docs/guide/waveguides/general-modal.md index 83af85427..823099ea3 100644 --- a/docs/guide/waveguides/general-modal.md +++ b/docs/guide/waveguides/general-modal.md @@ -113,17 +113,36 @@ Poynting flux, so a channel amplitude already is a true-power amplitude in $\sqr and no $\sqrt{\operatorname{Re}n_{\rm eff}}$ conversion happens anywhere on this route. The launched energy is the pulse's, to the last bit. -**`transverse=` launches a declared field instead.** Where `channel=` names one channel, -`transverse=` takes a transverse **field** at the guide's own quadrature nodes — shape -`(n_nodes, n_components)`, or `(n_nodes, n_components, n_omega)` for a profile that -changes across the band — and projects it onto every channel it overlaps. The map is the -record's **coordinate dual**, $\mathbf a = W^{-1}\mathbf B$ with +**`incident=` launches an incident beam instead.** Where `channel=` names one channel, +`incident=` takes a {class}`~solprop.pulses.SpatialPulse` — a Gaussian or super-Gaussian +beam carrying its own temporal pulse — and launches what the guide's channels capture of +it ([SEP 31](../../dev/seps/sep-0031-simulation-coupling.md), phase 1): + +```text +beam = SpatialPulse(GaussianPulse(wavelength=835e-9, fwhm=20e-15, energy=1e-9), 2e-6) +simulation = Simulation.from_waveguide((), length, provider=guide, lambda0=835e-9, + grid=grid, incident=beam) +simulation.configuration["launch"] # incident, captured and uncaptured energy +``` + +The beam's `energy=` (or `peak_power=`) is the **physical energy crossing the entrance +plane in the guide's core medium, over the whole beam**: what an energy meter in that +medium would read. It is fixed in the paraxial measure $M_0 = Y_0\,\mathrm dA/2$ with +$Y_0 = n_{\rm ref}(\omega)\varepsilon_0 c$, $n_{\rm ref}$ the real index of the core at +each frequency, integrated over the beam's own plane in closed form +({meth}`~solprop.pulses.SpatialPulse.incident`) — never over the guide's quadrature +nodes, which cover only the guide. The correctly scaled field is then sampled at the +nodes and projected onto every channel it overlaps. + +The map is the record's **coordinate dual**, $\mathbf a = W^{-1}\mathbf B$ with $B_m = \int(\mathbf E\times\mathbf h_m^* + \mathbf e_m^*\times\mathbf H)_z\,\mathrm dA/4$: the same Hermitian Poynting pairing the flux metric is built from, so synthesising a declared amplitude vector and projecting it back returns that vector exactly. It is *not* $Q$, the record's other map ([modal projection](modal-projection.md#the-synthesis-and-projection-maps)), which contracts a *polarisation* for a **source** term and carries the separate $-i\omega$; the two are -different operations and give different numbers. +different operations and give different numbers. The same map is public as +{py:func}`~solprop.translate.modal_tables.project_transverse_field`, for a field you +sample yourself. Only $\mathbf E$ is declared, so the $\mathbf H$ half of $B_m$ is closed by the **forward-field assumption**: for any superposition of these channels travelling forward @@ -133,39 +152,47 @@ through this map to $1.4\times10^{-15}$ of them — rounding, not the basis's Gr so the closure is exact for this package's providers. It is exact for a **lossless** basis: the argument is that the Poynting cross matrix of such a basis is Hermitian, so the $\mathbf E$ and $\mathbf H$ halves of $B_m$ contribute equally. A complex -{py:class}`~solprop.modes.material.PermittivityLaw` would break that symmetry and show -up as a growing residual share, and no such basis can reach this map: both providers -here refuse a complex permittivity when they solve, by name. An optmat index is never +{py:class}`~solprop.modes.material.PermittivityLaw` would break that symmetry, and no +such basis can reach this map: both providers here refuse a complex permittivity when +they solve, by name. An optmat index is never complex here: {py:class}`~solprop.modes.material.IndexMaterial` and the step-index modes hold it as its recorded transparent approximation, warning once per run where the absorption it drops exceeds its bound ([materials](../media/material-selection.md#materials-from-optmat)). A region's `SilicaTensorResponse()` is optmat's tensor silica bundle by default, which agrees with Lin and Agrawal's own parameters to $4\times10^{-16}$. A future lossy provider -would have to re-derive the closure rather than inherit it. A declared field that is not -forward-propagating is not what comes back either, and nothing in the arithmetic can see -the difference. - -The pulse still supplies the spectrum and the energy: the profile decides the projection's -**shape**, the share each channel takes, and the whole is scaled so that `energy=` means -what it means on `channel=`. The norm it is scaled against is the run's own **declared -metric**, so the statement is exact on a declared circular basis as well as on the -identity. The profile's own scale is therefore immaterial, and the two keywords are -refused together. **What the bound channels do not capture is named, not discarded**: -call {py:func}`~solprop.translate.modal_tables.project_transverse_field` directly and -read its `residual_share` — the fraction of the declared field, in the guide's own -quadrature norm, that the channels miss — before committing to a run. A declared field -with power in radiation modes, in a channel the guide never solved for, or simply outside -the node rule's reach shows up there. - -The energy is **not** reduced by what was missed: a profile the channels capture badly -still launches the full declared `energy=`, redistributed over what they do capture. -The launch logs the captured percentage at INFO every time, and warns -({py:class}`~solprop.exceptions.GeneralModalBasisWarning`) when the channels miss 3 % or -more ({py:data}`~solprop.driver.simulation.LAUNCH_UNCAPTURED_WARNING`), because the -rescaling would otherwise hide the mismatch. The missed share is not clipped at one -either — on a basis that is not exactly orthonormal the flux projection can overshoot, -and a share above one says exactly that rather than "missed everything". +would have to re-derive the closure rather than inherit it. Whether an incident beam +has the guide's magnetic field is a separate question: the closure is exact on the +channels' own span, and the beam's part outside that span is not captured. + +**The launch is the captured amplitudes, unscaled.** Light the channels do not capture +is lost at the entrance, not restored: a beam much wider than the core launches a small +share of its energy, and a beam the channels cannot see at all — a y-polarised beam +against a single x-polarised channel — launches nothing, which is a valid zero launch. +The account is recorded as `configuration["launch"]`, an +{class}`~solprop.driver.simulation.IncidentLaunch`, and reaches the file's configuration: +the **incident** energy per frequency bin, the **captured** energy (the launched state's +physical energy under the run's own declared metric, which is what the run's +`physical_energy` statistic reads at the entrance), and their difference, the +**uncaptured** energy, with totals. The captured energy is logged at INFO. No warning is +raised for a poor capture: it is physics, not a defect. + +Read the uncaptured energy with its model in mind. It is a physical loss only where the +incident measure and the guide's own flux metric are a common positive metric +(SEP 31 §4.3) — a weakly guiding guide, whose channels see an admittance close to +$n_{\rm ref}\varepsilon_0 c$ wherever they carry power. On a strongly guiding strand the +difference also carries the two measures' disagreement, and it is reported unclipped: a +negative value would be an apparent gain of the model, never a negative loss. The +projection's own `residual_share` is an electric-field reconstruction error in the +guide's quadrature norm, not radiated power, and is not this account. + +The beam is launched at its waist (`propz=0`; otherwise it is refused, because carrying a +beam to the entrance is transport, which this launch does not model) and centred on the +guide's axis; a one-component Jones vector is polarised along x. The beam carries its own +pulse, so `fwhm`, `width`, `energy`, `peak_power`, `phases`, `pulses` and `pulseshape` are +refused beside it, as is `channel=`. Direct amplitudes — a named channel, or a state +passed to {meth}`~solprop.driver.simulation.Simulation.with_input` — already describe +light inside the guide and are launched as they are. Every gas keyword is **refused by name** beside a provider — `gas`, `pressure`, `temperature`, `kerr`, `thg`, `plasma`, `raman`, `n_r`, `n_theta` and the rest — because @@ -342,7 +369,7 @@ it. | the guide: a provider, its material regions and their responses, solved on its own node rule at knots spanning the run's **active** band | `ModalGuide.from_provider(provider, omega, nodes=...)` | [running one](#running-one), [the three objects](modal-projection.md#the-three-objects) | | each region's response: the instantaneous cubic by `chi3=`, or by `n2=` with the wavelength `at=` it was measured at, and the silica tensor Raman model beside it; or a gas fill, whose law the run's electronic policy sets | `MaterialRegion(..., (MaterialKerr(...), SilicaTensorResponse(...)))`, `MaterialRegion(..., (GasKerr(mixture),))` | [the response at a node](modal-projection.md#the-response-at-a-node) | | the cubic's form | `kerr_form="real"` (the default, third harmonic included) or `"analytic"` | [the response at a node](modal-projection.md#the-response-at-a-node) | -| the launch | `channel=` (an index or a label) or `transverse=` (a field at the guide's nodes) | [running one](#running-one) | +| the launch | `channel=` (an index or a label) or `incident=` (a `SpatialPulse`, of which the captured share enters) | [running one](#running-one) | | a unitary change of basis — of an exactly degenerate block, which stays diagonal, or of a split one, which becomes a coupled flow | `basis=` and `degenerate_blocks=` on `from_provider` | [declaring a circular basis](modal-bases.md#declaring-a-circular-basis), [a split block](modal-bases.md#a-declared-basis-of-a-block-that-is-only-nearly-degenerate) | | the statistics | `physical_energy` and `analytic_flux` (the defaults), `spectral_flux` by name | [noise, statistics and what comes back](#noise-statistics-and-what-comes-back) | @@ -469,8 +496,8 @@ Not implemented, and refused rather than approximated: for — over the table's frequency interpolation, the transverse rule and the step — is not implemented, and accepting a number nothing enforces would be a silent claim about accuracy; -* a **backward or evanescent** declared launch. `transverse=` (above) projects a declared - field through the flux pairing with the forward-field closure, and nothing in the +* a **backward or evanescent** declared launch. `incident=` (above) projects the + incident field through the flux pairing with the forward-field closure, and nothing in the arithmetic can see that a declared field was not forward-propagating; such a launch is built by hand and passed to {meth}`~solprop.driver.simulation.Simulation.with_input`; diff --git a/docs/guide/waveguides/solid-fibres.md b/docs/guide/waveguides/solid-fibres.md index e66073400..f8afb28ff 100644 --- a/docs/guide/waveguides/solid-fibres.md +++ b/docs/guide/waveguides/solid-fibres.md @@ -247,49 +247,41 @@ cutoff there is refused by name, with the fraction of its power beyond the match radius. A larger core or contrast, or a grid that reaches less far into the infrared, admits more. -**A launch declared as a field.** `transverse=` takes a field at the guide's own nodes -— real or complex, shape `(n_nodes, 3)`, or `(n_nodes, 3, n_omega)` — and projects it onto every channel -through the Poynting pairing. The pulse still sets the spectrum and the energy; the -profile sets the share each channel takes. What the channels do not capture is -reported, not discarded: {func}`~solprop.translate.modal_tables.project_transverse_field` -returns it as `residual_share` before you commit to a run. +**A launch declared as an incident beam.** `incident=` takes a +{class}`~solprop.pulses.SpatialPulse`, whose energy is the physical energy over the whole +beam at the entrance, in the core medium. The beam is normalised over its own plane, +sampled at the guide's nodes and projected onto every channel through the Poynting +pairing; the channels launch what they capture, and the rest is lost at the entrance +rather than restored. The account, incident, captured and uncaptured energy per +frequency, is recorded in the run's configuration +({class}`~solprop.driver.simulation.IncidentLaunch`). ```python -from solprop.translate.general import general_modal_route -from solprop.translate.modal_tables import project_transverse_field +from solprop.pulses import GaussianPulse, SpatialPulse -nodes = grin_guide.sampling.nodes -radius = np.hypot(nodes.xy[:, 0], nodes.xy[:, 1]) -beam = np.zeros((nodes.xy.shape[0], 3)) # a real profile; complex is accepted too -beam[:, 0] = np.exp(-((radius / 8e-6) ** 2)) # x-polarised Gaussian, 8 um waist - -route = general_modal_route( - grin_guide, grin_grid, z_max=1e-3, omega_reference=float(grin_grid.omega0) +beam = SpatialPulse( # an x-polarised Gaussian, 12 um waist, at its waist + GaussianPulse(wavelength=1.03e-6, fwhm=50e-15, energy=1e-9), 12e-6 ) -held = np.repeat(beam[:, :, None], grin_grid.n_omega, axis=2) -projection = project_transverse_field(route.tables, held) -print(f"missed share over the band: {projection.residual_share:.3f}") - grin_run = prop_waveguide( (), 1e-3, provider=grin_guide, grid=grin_grid, - transverse=beam, + incident=beam, lambda0=1.03e-6, - fwhm=50e-15, - energy=1e-9, n_save=3, dz_max=1e-4, dz_init=1e-4, progress="none", ) +launch = grin_run.metadata.configuration["launch"] +print(f"captured {launch.captured_energy / launch.incident_energy:.3f} of the beam") ``` -`residual_share` is measured over the whole band, in the guide's quadrature norm: a -profile held at one shape across the band stops being a guided superposition away from -the frequency it was declared at, and that shows up here too. The energy is not reduced -by what is missed: the declared `energy=` is launched into what the channels capture. +The captured share is a physical loss in a common positive metric, which a weakly +guiding fibre like this one approaches ([general modal propagation](general-modal.md)). +The projected amplitudes are launched unscaled, so the run's `physical_energy` at the +entrance is the captured energy, not `energy=`. The [graded-index worked example](../../auto_examples/waveguides/plot_04_graded_index_multimode.rst) runs a 100 µm core at full power over ten centimetres: self-imaging, geometric diff --git a/docs/guide/waveguides/supplied-guides.md b/docs/guide/waveguides/supplied-guides.md index 11088935c..71fcf2f21 100644 --- a/docs/guide/waveguides/supplied-guides.md +++ b/docs/guide/waveguides/supplied-guides.md @@ -117,9 +117,9 @@ wants. The real form needs `n_to >= 2 n_t`, which is `FieldGrid`'s default ([general modal propagation](general-modal.md)). It takes a **graded-index** guide as readily as a step-index one, and launches -either into a named channel with `channel=` or as a declared transverse field with -`transverse=`, which it projects onto the channels and whose uncaptured share it -reports. It runs on a **field** grid on `numpy_ref`, `numba_cpu` and the native +either into a named channel with `channel=` or as an incident beam with +`incident=`, of which the channels launch the share they capture, the rest recorded as +uncaptured rather than restored. It runs on a **field** grid on `numpy_ref`, `numba_cpu` and the native backend, CPU and GPU builds alike, for a constant guide and for a moving one or a declared coupled flow (a split block or a coherent wall); a CF4 wall runs on `numpy_ref` alone. `backend="auto"` selects `native` where it is installed. diff --git a/docs/tutorials/waveguides/plot_06_graded_index.py b/docs/tutorials/waveguides/plot_06_graded_index.py index e135e1f8f..77a834f35 100644 --- a/docs/tutorials/waveguides/plot_06_graded_index.py +++ b/docs/tutorials/waveguides/plot_06_graded_index.py @@ -200,7 +200,9 @@ # The same happens, less obviously, to a profile declared at **one** frequency and # held across the band: the channels' patterns change with frequency, so away from # where it was declared the field is no longer a guided superposition. The -# projection reports that too, which is why a launch keyword reports it at all. +# projection reports that too. The share is a reconstruction residual on the +# guide's nodes, not radiated power: the energy account of an incident beam is +# what ``incident=`` records (:doc:`/guide/waveguides/general-modal`). held = np.zeros_like(declared) support = np.any(np.abs(route.tables.electric[0]) > 0.0, axis=(0, 1, 2)) held[:, :, support] = declared[:, :, centre][:, :, None] From ab0838402d2ff47294768c76e404583e274fe3d4 Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 00:08:47 +0100 Subject: [PATCH 05/50] SEP 31: Part II report for phase 1, the incident-energy correction Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019qNYqtCKh4iTKj7544yARb --- docs/dev/seps/sep-0031-simulation-coupling.md | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/docs/dev/seps/sep-0031-simulation-coupling.md b/docs/dev/seps/sep-0031-simulation-coupling.md index 62afce078..faecc946d 100644 --- a/docs/dev/seps/sep-0031-simulation-coupling.md +++ b/docs/dev/seps/sep-0031-simulation-coupling.md @@ -1097,6 +1097,117 @@ No new material constant or experimental validation is claimed. ## Part II — Implementation record +### Phase 1 report — incident-energy correction + +**Delivered (2026-10-24, `feature/sep-0031-simulation-coupling`).** + +- `pulses.SpatialPulse.incident(grid, xy, index=...)` returns an `IncidentField`: + the beam given its requested energy or peak power over its **whole** transverse + plane in the paraxial measure $M_0 = n_{\rm ref}\varepsilon_0c\,dA/2$, then + sampled at the given points, with the energy each bin carries through the plane. + It reuses `_Plane` and `_normalising_factor` (one "column" whose transverse + integral is the profile's closed form + $\int|u|^2dA=\pi w^2 2^{-1/N}\Gamma(1+1/N)$), as `PlanarPulse.build` does, and + shares the profile and Jones normalisation with `SpatialPulse.build`. No second + Gaussian is written. +- `Simulation.from_waveguide(provider=..., incident=SpatialPulse(...))` and + `prop_waveguide(..., incident=...)` replace `transverse=`. The driver takes + $n_{\rm ref}(\omega)=\operatorname{Re}\sqrt{\varepsilon}$ of the guide's core + region (the region on the axis, whose law is z-independent, so it is the + entrance's), samples the beam at the entrance nodes, projects it with + `project_transverse_field` and launches the projected amplitudes **unscaled**. + A zero capture is a zero launch. +- `driver.simulation.IncidentLaunch` records `reference_index`, `incident` and + `captured` per frequency bin (J), with `uncaptured` (not clipped) and totals, + as `configuration["launch"]`; it reaches the file's configuration and hash. The + captured energy is $\mathbf a^\dagger W\mathbf a$ under the run's declared + metric, the number `physical_energy` reads at the entrance. A derived run + (`with_input`) drops the record with the constructor's pulse. +- Removed with no alias: `transverse=`, `_projected_launch`, the rescaling to + `energy=`, the 3 % `GeneralModalBasisWarning` and `LAUNCH_UNCAPTURED_WARNING`. + Channel launches and `with_input` arrays are unchanged. +- The advanced benchmark's graded-index case (`benchmarks/guides.py`) launches + an incident 12 µm beam, so its launched energy is now the captured share. +- Manual: [general modal propagation](../../guide/waveguides/general-modal.md), + [solid fibres](../../guide/waveguides/solid-fibres.md) (a runnable snippet), + [supplied guides](../../guide/waveguides/supplied-guides.md), a comment in the + graded-index tutorial, and the changelog. + +**Deviations and why.** + +1. *No analytic-callable input (item b).* A callable profile needs its own + public record of support, tail bound and quadrature. Phase 3's + `FieldPlane.from_analytic` is that record, and a phase-1 record would be a + public interface made only to be replaced. It is deferred to phase 3. +2. *`propz` must be 0.* Carrying a beam from its waist to the entrance is + transport, and §5.1 changes the `propz` meaning in phase 3. The driver and + `SpatialPulse.incident` refuse a nonzero `propz` by name. +3. *Keyword `incident=`, not `transverse=`.* The beam carries its own temporal + pulse, so the pulse keywords are refused beside it, as is `channel=`. +4. *No warning remains.* A poor capture is physics. No numerical defect is + meaningful to warn about here: the projection's own failures are refusals. +5. *Scalar Jones on a vector guide is x-polarised*, the package's capillary + default. + +**Verification run.** Targeted modules only, `uv run --no-sync pytest -n auto`: +`tests/driver/test_general_modal.py`, `tests/translate/test_modal_transform_tables.py`, +`tests/test_pulses.py`, `tests/test_spatial_flux.py` and `tests/test_display.py`, +548 passed (medium tier included); `tests/test_benchmark_guides.py` 9 passed; +`tests/test_guide_snippets.py` (compilation) 18 passed; doctests of `pulses.py` +and `driver/simulation.py` pass. The solid-fibres page's snippets were executed +by hand (3.3 s; a 12 µm beam into the 25 µm graded-index guide captures 0.964). +ruff, ruff format, basedpyright (0 errors), numpydoc and codespell are clean on +the changed files. Evidence, on the 1.5 µm step-index strand (index 1.45 in air): + +- `SpatialPulse.incident` agrees with `SpatialPulse.build` on a radial grid in + index 1.45 to 1.5e-7 of the peak field, the beam's obliquity, for Gaussian and + order-2 beams, energy and peak power, envelope and field grids. +- Poor capture: a 20 µm beam captures 0.9 % (bound asserted: < 2 %); the run's + entrance-plane `physical_energy` equals the captured energy to 1e-12, not + `energy=`. A 1.3 µm beam captures 92 %. +- Zero capture by symmetry: a y-polarised beam into the even HE11 channel captures + 8e-36 of the incident energy (bound $(n_{\rm nodes}\epsilon)^2$) and runs. +- Incident energy is bitwise the same for a 1.5 µm and a 2.5 µm strand; captured + differs. +- Coherent shares: Jones $(1, 0.5i)$ gives $a_{\rm odd}/a_{\rm even}=-0.5i$ + (internal convention) to 1e-6, with the captured total of the x-polarised beam; + on the dense circular basis an x-polarised beam splits equally. +- Receivers: ten 0.5 µm steps on `numpy_ref`, `numba_cpu` and native CPU each store + an entrance plane bitwise equal to the launched state, whose energy is the + captured energy. + +Not run: the full gate, slow and coverage lanes (end of SEP), and GPU. The +graded-index worked example (`docs/examples/waveguides/plot_04_graded_index_multimode.py`) +still uses `transverse=`; it was **not** edited, because an edited worked example +must be re-run by hand at full length and the normal build refuses a source that +differs from its committed output. + +**Limitations.** The measure uses the core's index over the whole plane, so on a +strongly guiding strand the uncaptured energy also carries the difference between +$M_0$ and the guide's flux metric (the 1.45-in-air strand's best capture is 98 % +for a 1 µm beam). The beam is centred, launched at its waist and transverse +(no $E_z$). §4.3's admission screens, model budget and passivity certificate are +not applied; they are phase 3's. + +**Open questions.** + +1. Should `with_input` accept a `SpatialPulse` on a general modal run, routed + through the same incident launch, or wait for phase 2's target-bound launch? +2. Should `IncidentLaunch` carry the projection's electric-field residual as a + labelled numerical diagnostic, or stay energy-only as now? +3. For a graded-index core, the on-axis law fixes $n_{\rm ref}$. Is that the + intended reference, or should it be the index averaged over the beam? +4. The worked example `plot_04_graded_index_multimode` needs `incident=` and a + re-run by hand. Its launched energy will fall to the captured share unless its + requested energy is raised. + +**Handoff to phases 2 and 3.** `SpatialPulse.incident` is the normalisation +`FieldPlane.from_pulse` should wrap, and `IncidentLaunch` is the precursor of +`CouplingResult.report`. Phase 3 replaces `_incident_launch` with `couple(..., +CouplingModel("scalar_paraxial", ...))`, adds the analytic and sampled inputs, +`propz` transport and the §4.3 admission, and should fold `IncidentLaunch` into +the report rather than keep both. + ## Part III — History - 2026-10-22: Drafted after auditing existing launch, projection, spatial flux, From ffaa29e5b9b0aceaa87e840c38c068874610a60a Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 00:11:20 +0100 Subject: [PATCH 06/50] SEP 31: record the phase 1 docs build Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019qNYqtCKh4iTKj7544yARb --- docs/dev/seps/sep-0031-simulation-coupling.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/dev/seps/sep-0031-simulation-coupling.md b/docs/dev/seps/sep-0031-simulation-coupling.md index faecc946d..b5fa4f6f5 100644 --- a/docs/dev/seps/sep-0031-simulation-coupling.md +++ b/docs/dev/seps/sep-0031-simulation-coupling.md @@ -1157,7 +1157,7 @@ No new material constant or experimental validation is claimed. and `driver/simulation.py` pass. The solid-fibres page's snippets were executed by hand (3.3 s; a 12 µm beam into the 25 µm graded-index guide captures 0.964). ruff, ruff format, basedpyright (0 errors), numpydoc and codespell are clean on -the changed files. Evidence, on the 1.5 µm step-index strand (index 1.45 in air): +the changed files, and `poe docs` builds clean (warnings as errors, 2 min 45 s). Evidence, on the 1.5 µm step-index strand (index 1.45 in air): - `SpatialPulse.incident` agrees with `SpatialPulse.build` on a radial grid in index 1.45 to 1.5e-7 of the peak field, the beam's obliquity, for Gaussian and From b54329e3be0057c5ebf528a62094abeb8267f8c4 Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 00:53:10 +0100 Subject: [PATCH 07/50] SEP 31 transport: linear elements on a spatial plane FreeSpace (exact angular spectrum on the radial QDHT or Cartesian DFT, with direct angular or Fresnel summation onto an independent output plane), ThinLens, Aperture, TransmissionMask and Alignment as certified thin screens, PolarisationTransform, Mirror with explicit p = s x k frames, the normal-incidence PlanarInterface and SpectralTransfer for optics transfer functions, chains and compressor responses. Each returns the new plane and a joule-ledger report measured with SpatialFlux. The package __init__ is a placeholder for the main SEP 31 branch's. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019qNYqtCKh4iTKj7544yARb --- src/solprop/coupling/__init__.py | 7 + src/solprop/coupling/elements.py | 3106 ++++++++++++++++++++++++++++++ 2 files changed, 3113 insertions(+) create mode 100644 src/solprop/coupling/__init__.py create mode 100644 src/solprop/coupling/elements.py diff --git a/src/solprop/coupling/__init__.py b/src/solprop/coupling/__init__.py new file mode 100644 index 000000000..8f954ec3e --- /dev/null +++ b/src/solprop/coupling/__init__.py @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Couple simulations through a physical field plane (SEP 31).""" + +__all__: list[str] = [] diff --git a/src/solprop/coupling/elements.py b/src/solprop/coupling/elements.py new file mode 100644 index 000000000..d92deee65 --- /dev/null +++ b/src/solprop/coupling/elements.py @@ -0,0 +1,3106 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +r"""Linear optical elements acting on a spatial field between planes (SEP 31 §4.6). + +Each element is a frozen record with an ``apply`` method that takes a +:class:`SpatialState` -- a radial or Cartesian spectrum with its temporal grid, its +homogeneous medium and its oriented local frame -- and returns the new state with an +:class:`ElementReport`. The stored spectrum keeps the layout and units of +:meth:`solprop.pulses.SpatialPulse.build`: the internal (FFT) temporal convention, +a raw order-zero Hankel spectrum in V m on a :class:`solprop.grid.RadialGrid` or a +raw two-dimensional DFT in V/m on a :class:`solprop.grid.FreeGrid`, shape +``(n_modes, n_pol, n_omega)``. Every physical expression in this module -- the +propagation phase :math:`e^{+ik_zL}`, a lens :math:`e^{-ikr^2/(2f)}`, a mask, a +Jones matrix or a reflection coefficient -- is written in the optics convention of +``docs/dev/conventions.md`` section 2 and crosses to the stored spectrum as its +complex conjugate, as :func:`solprop.optics.stored_factor` does for a transfer +function. Transverse transforms are not conjugated: they act on positions, which +are the same in both conventions. + +Energies are physical: the axial Poynting flux of the medium at the plane, +:class:`solprop.spatial.SpatialFlux` and :func:`solprop.spatial.spatial_energy` +(SEP 34). Every report closes its own ledger in joules, + +.. math:: U_{\rm out} = U_{\rm in} - L_{\rm specified} - U_{\rm removed} + + \epsilon_{\rm approximation}, + +so the approximation error is the signed part of the energy change that neither a +declared loss nor an intentional removal explains. +""" + +import math +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass, field, replace +from types import MappingProxyType +from typing import Literal, Protocol, runtime_checkable + +import numpy as np +from jaxtyping import Bool, Complex, Float +from numpy.typing import ArrayLike +from scipy.special import j0 + +from solprop import fourier +from solprop.constants import C +from solprop.exceptions import ( + ConvergenceError, + GridError, + MaterialError, + OpticsGeometryError, + PolarisationError, +) +from solprop.grid import FreeGrid, LineGrid, RadialGrid, TimeGrid +from solprop.optics import ( + Chain, + ChirpedMirror, + GratingPair, + Material, + PrismPair, + TaylorPhase, + TransferFunction, + stored_factor, +) +from solprop.spatial import SpatialFlux, SpatialMedium, aperture_profile, spatial_energy + +__all__ = [ + "Alignment", + "Aperture", + "Element", + "ElementReport", + "ElementResult", + "FreeSpace", + "Mirror", + "PlanarInterface", + "PolarisationTransform", + "ScreenCertificate", + "SpatialState", + "SpectralCalibration", + "SpectralTransfer", + "ThinLens", + "TransmissionMask", +] + +_EPS = float(np.finfo(float).eps) +_CHUNK = 2**20 +"""Complex elements of a direct-summation kernel block (16 MiB).""" +_OCCUPIED = 1e-12 +"""Relative content below which a node or column counts as empty in a diagnostic.""" +_FRAME_TOLERANCE = 1e-12 +"""Orthonormality tolerance of a local frame and of a declared direction.""" + +_Positions = Float[np.ndarray, " n_nodes"] +_Band = Float[np.ndarray, " n_band"] +Transmission = Callable[[_Positions, _Positions, _Band], ArrayLike] +"""A field transmission ``T(x, y, omega)`` in the optics convention. + +Positions in m, shape ``(n_nodes,)``; positive in-band angular frequencies in rad/s, +shape ``(n_band,)``. The result broadcasts to ``(n_nodes, n_band)``. +""" + +SpectralFunction = Callable[[Float[np.ndarray, " n_band"]], ArrayLike] +"""A function of positive angular frequency in rad/s, shape ``(n_band,)``.""" + + +# --------------------------------------------------------------------- state record + + +@dataclass(frozen=True, eq=False) +class SpatialState: + """A spatial field at one plane, with its grids, medium and local frame. + + The plain input of every element. The single component of a scalar state + (``n_pol = 1``) is polarised along the frame's x axis; two components are the + optics-convention ``(x, y)`` Jones components stored conjugated, as + :meth:`solprop.pulses.SpatialPulse.build` stores them. + + Attributes + ---------- + grid : solprop.grid.TimeGrid + Temporal grid of the spectrum. + space : solprop.grid.RadialGrid or solprop.grid.FreeGrid + Transverse representation. A :class:`solprop.grid.LineGrid` (per unit + width) and a planar grid (slab amplitudes) are refused. + spectrum : numpy.ndarray + Internal-convention Hankel/temporal spectrum in V m or Cartesian + DFT/temporal spectrum in V/m, complex128, shape ``(n_modes, n_pol, + n_omega)``; exactly zero outside the active band and at nonpositive + frequency. Stored as a read-only copy. + medium : solprop.spatial.SpatialMedium + Homogeneous medium at the plane: every parameter path is constant. + frame : numpy.ndarray + Right-handed orthonormal local frame in laboratory coordinates, shape + ``(3, 3)``: the rows are the transverse x and y axes and the forward + propagation direction. Default: the identity. Stored as a read-only copy. + + Raises + ------ + GridError + If the representation, the spectrum's shape, finiteness or band, or the + frame is invalid. + MaterialError + If the medium varies along z. + """ + + grid: TimeGrid + space: RadialGrid | FreeGrid + spectrum: Complex[np.ndarray, "n_modes n_pol n_omega"] + medium: SpatialMedium + frame: Float[np.ndarray, "3 3"] = field(default_factory=lambda: np.eye(3)) + + def __post_init__(self) -> None: + """Validate and freeze the representation, the spectrum and the frame.""" + space = self.space + if isinstance(space, LineGrid) or not isinstance(space, RadialGrid | FreeGrid): + msg = ( + "transport takes a radial or Cartesian field plane; a line grid " + "(per unit width) and a planar grid (slab amplitudes) are not field " + f"planes of a finite beam, got {type(space).__name__}" + ) + raise GridError(msg) + spectrum = np.array(self.spectrum, dtype=np.complex128, copy=True) + if ( + spectrum.ndim != 3 + or spectrum.shape[0] != space.n_columns + or spectrum.shape[1] not in (1, 2) + or spectrum.shape[2] != self.grid.n_omega + or not np.all(np.isfinite(spectrum)) + ): + msg = ( + "a spatial state's spectrum must be finite with shape " + f"({space.n_columns}, 1 or 2, {self.grid.n_omega}), got " + f"{spectrum.shape}" + ) + raise GridError(msg) + if np.any(spectrum[:, :, ~_band(self.grid)]): + msg = ( + "a spatial state is exactly zero outside the active band and at " + "nonpositive frequency, as a launch or a saved plane is" + ) + raise GridError(msg) + if not all(path.is_constant for path in self.medium.parameter_paths.values()): + msg = ( + "transport between planes needs a homogeneous medium; a medium that " + "varies along z is an ordinary propagation, not a free-space element" + ) + raise MaterialError(msg) + frame = _unit_frame(self.frame) + spectrum.setflags(write=False) + object.__setattr__(self, "spectrum", spectrum) + object.__setattr__(self, "frame", frame) + + @property + def n_pol(self) -> int: + """Return the number of stored polarisation components. + + Returns + ------- + int + One for a scalar state, two for ``(x, y)``. + """ + return int(self.spectrum.shape[1]) + + def with_spectrum( + self, + spectrum: Complex[np.ndarray, "n_columns n_components n_omega"], + *, + space: RadialGrid | FreeGrid | None = None, + medium: SpatialMedium | None = None, + frame: Float[np.ndarray, "3 3"] | None = None, + ) -> "SpatialState": # noqa: UP037 - beartype evaluates the class body + """Return a state with a new spectrum and, optionally, new plane data. + + Parameters + ---------- + spectrum : numpy.ndarray + New internal spectrum, shape ``(n_columns, n_components, n_omega)``. + space : solprop.grid.RadialGrid, solprop.grid.FreeGrid or None, optional + New transverse representation; None keeps this one. + medium : solprop.spatial.SpatialMedium or None, optional + New medium; None keeps this one. + frame : numpy.ndarray or None, optional + New local frame, shape ``(3, 3)``; None keeps this one. + + Returns + ------- + SpatialState + The validated new state. + """ + return SpatialState( + self.grid, + self.space if space is None else space, + spectrum, + self.medium if medium is None else medium, + self.frame if frame is None else frame, + ) + + +def _unit_frame(frame: ArrayLike) -> Float[np.ndarray, "3 3"]: + """Return a validated, read-only right-handed orthonormal frame. + + Parameters + ---------- + frame : array_like + Rows x, y and forward direction in laboratory coordinates, shape ``(3, 3)``. + + Returns + ------- + numpy.ndarray + A read-only float64 copy. + + Raises + ------ + GridError + If the frame is not finite, orthonormal and right-handed. + """ + values = np.array(frame, dtype=np.float64, copy=True) + if ( + values.shape != (3, 3) + or not np.all(np.isfinite(values)) + or np.max(np.abs(values @ values.T - np.eye(3))) > _FRAME_TOLERANCE + or np.linalg.det(values) <= 0 + ): + msg = ( + "a local frame is a right-handed orthonormal 3x3 array whose rows are " + "the x, y and forward directions" + ) + raise GridError(msg) + values.setflags(write=False) + return values + + +# --------------------------------------------------------------------- reports + + +@dataclass(frozen=True, eq=False) +class ScreenCertificate: + r"""The single-field thin-screen admission record of SEP 31 §4.6. + + A thin screen is exact in the local reference measure + :math:`U_0=\int Y_0|\tilde E|^2\,d\kappa\,d\omega/2` with + :math:`Y_0=\operatorname{Re}n\,\varepsilon_0c`; the physical axial energy + :math:`U_Y` weighs each plane wave by :math:`\cos\theta` in a transparent + medium. With :math:`1-\cos\theta_*=\epsilon_m/8`, the defect + :math:`d(E)=(1-\cos\theta_*)U_{0,\rm cone}+U_{0,\rm tail}` bounds + :math:`U_0-U_Y`, and the screen is admitted when + :math:`[d(E_{\rm in})+d(E_{\rm out})]/U_Y(E_{\rm in})` plus the numerical + error is at most :math:`\epsilon_m`. + + Attributes + ---------- + model_error : float + The required budget :math:`\epsilon_m`, in (0, 1). + cone_cosine : float + :math:`\cos\theta_* = 1 - \epsilon_m/8`. + defect_in, defect_out : float + :math:`d(E)` of the input and output fields, J (reference measure). + reference_energy : float + :math:`U_Y(E_{\rm in})`, J. + numerical_error : float + Relative numerical and domain error added to the ratio: the transform + round trip's energy discrepancy bound :math:`2\delta+\delta^2` plus the + field's share at the window's edge on input and output. + ratio : float + The admitted quantity, at most `model_error`; zero for a zero input. + exact_zero : bool + True when the input is exactly zero, the exact separate case. + scope : str + Always ``"single field"``: the certificate applies only to this field, + not to a reusable map over a subspace. + """ + + model_error: float + cone_cosine: float + defect_in: float + defect_out: float + reference_energy: float + numerical_error: float + ratio: float + exact_zero: bool + scope: str = "single field" + + +@dataclass(frozen=True) +class SpectralCalibration: + """Which affine spectral phase a frequency-only element removes or keeps. + + A plane's reference record (SEP 31 §4.5) accumulates the carrier phase and the + delay of its path; an element that removes them from its own response leaves + them unknown to that record unless the removal is recorded. + + Attributes + ---------- + removes_phase : bool or None + True if the element drops its constant phase at the reference frequency, + False if it keeps it, None if unknown. + removes_delay : bool or None + True if the element drops its group delay at the reference frequency, + False if it keeps it, None if unknown. + reference_wavelength : float or None + Vacuum wavelength in m at which the removal is referenced, or None. + note : str + How the record was obtained. + + Examples + -------- + >>> from solprop.coupling.elements import SpectralCalibration + >>> SpectralCalibration(True, True, 800e-9, "measured compressor").removes_delay + True + """ + + removes_phase: bool | None + removes_delay: bool | None + reference_wavelength: float | None + note: str = "" + + +@dataclass(frozen=True, eq=False) +class ElementReport: + """The accounting of one element's application (SEP 31 §4.7). + + Energies are the physical axial flux of the plane's medium (SEP 34) on the + whole represented plane. They satisfy ``energy_out = energy_in - + specified_loss - removed + approximation_error`` exactly. + + Attributes + ---------- + element : str + A short description of the element. + position : int + Index of the element in a transport sequence; 0 when applied alone. + energy_in, energy_out : float + Energy crossing the input and output planes, J. + specified_loss : float + Loss the element declares: material absorption, reflection, a mask's + local loss :math:`L_0` or a transfer function's attenuation, J. + removed : float + Energy intentionally removed: an explicit angular band limit, or the + bins a transfer function sets to exactly zero, J. + approximation_error : float + Signed remainder of the ledger, J: the thin-screen axial-flux defect, + obliquity at an interface, output-window truncation and resampling, or + rounding for an exact element. Never absorption. + numerical_error : float + Estimated numerical energy error of the representation, J. + spectrum_in, spectrum_out : numpy.ndarray + Energy per temporal bin at the input and output planes, J, shape + ``(n_omega,)``; each sums to its energy. + certificate : ScreenCertificate or None + The thin-screen certificate of a lens, mask, aperture or tilt. + calibration : tuple of SpectralCalibration + One record per frequency-only factor; empty for a spatial element. + reconstruction : bool + True for an operation that is not a passive physical element: a negative + length, an inverse phase or a backward propagation. + diagnostics : collections.abc.Mapping + Element-specific numbers (sampling and wrap shares, bounds), read-only. + notes : tuple of str + Element-specific statements, such as the factors of a chain. + """ + + element: str + position: int + energy_in: float + energy_out: float + specified_loss: float + removed: float + approximation_error: float + numerical_error: float + spectrum_in: Float[np.ndarray, " n_omega_in"] + spectrum_out: Float[np.ndarray, " n_omega_out"] + certificate: ScreenCertificate | None = None + calibration: tuple[SpectralCalibration, ...] = () + reconstruction: bool = False + diagnostics: Mapping[str, float] = field(default_factory=dict) + notes: tuple[str, ...] = () + + def __post_init__(self) -> None: + """Freeze the arrays and the diagnostics.""" + for name in ("spectrum_in", "spectrum_out"): + values = np.array(getattr(self, name), dtype=np.float64, copy=True) + values.setflags(write=False) + object.__setattr__(self, name, values) + frozen = MappingProxyType(dict(self.diagnostics)) + object.__setattr__(self, "diagnostics", frozen) + + +@dataclass(frozen=True, eq=False) +class ElementResult: + """The state after an element and the report of its application. + + Attributes + ---------- + state : SpatialState + The field at the element's output plane. + report : ElementReport + Its accounting. + """ + + state: SpatialState + report: ElementReport + + +@runtime_checkable +class Element(Protocol): + """A linear element between two planes.""" + + def apply(self, state: SpatialState) -> ElementResult: + """Apply the element to a field plane. + + Parameters + ---------- + state : SpatialState + The field at the element's input plane. + + Returns + ------- + ElementResult + The output plane and the report. + """ + ... + + +# --------------------------------------------------------------------- shared numerics + + +def _band(grid: TimeGrid) -> Bool[np.ndarray, " n_omega"]: + """Return the bins that can carry field: active and at positive frequency. + + Parameters + ---------- + grid : solprop.grid.TimeGrid + Temporal grid. + + Returns + ------- + numpy.ndarray + Boolean mask, shape ``(n_omega,)``. + """ + return grid.active_mask & (grid.omega > 0) + + +def _index(medium: SpatialMedium, grid: TimeGrid) -> Complex[np.ndarray, " n_omega"]: + """Return the medium's complex index on the band, zero elsewhere. + + Parameters + ---------- + medium : solprop.spatial.SpatialMedium + Homogeneous medium. + grid : solprop.grid.TimeGrid + Temporal grid. + + Returns + ------- + numpy.ndarray + Refractive index, dimensionless, shape ``(n_omega,)``. + """ + band = _band(grid) + index = np.zeros(grid.n_omega, dtype=np.complex128) + index[band] = medium.index(grid.omega[band]) + return index + + +def _same_medium(first: SpatialMedium, second: SpatialMedium, grid: TimeGrid) -> bool: + """Return whether two media have the same index on the band. + + Parameters + ---------- + first, second : solprop.spatial.SpatialMedium + The media. + grid : solprop.grid.TimeGrid + Temporal grid. + + Returns + ------- + bool + True for the same object or exactly equal indices on every band bin. + """ + return first is second or bool( + np.array_equal(_index(first, grid), _index(second, grid)) + ) + + +def _transverse_squares(space: RadialGrid | FreeGrid) -> Float[np.ndarray, " n_modes"]: + """Return the squared transverse wave number of each stored column. + + Parameters + ---------- + space : solprop.grid.RadialGrid or solprop.grid.FreeGrid + Transverse representation. + + Returns + ------- + numpy.ndarray + ``k_r**2`` or ``kperp_sq`` in m^-2, shape ``(n_modes,)``. + """ + return space.k_r**2 if isinstance(space, RadialGrid) else space.kperp_sq + + +def _to_field( + space: RadialGrid | FreeGrid, spectrum: Complex[np.ndarray, "n_modes n_pol n_omega"] +) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: + """Synthesise the field samples from a stored transverse spectrum. + + Parameters + ---------- + space : solprop.grid.RadialGrid or solprop.grid.FreeGrid + Transverse representation. + spectrum : numpy.ndarray + Stored spectrum, shape ``(n_modes, n_pol, n_omega)``. + + Returns + ------- + numpy.ndarray + Field samples in the internal temporal convention, V/m times the temporal + DFT scaling, at the grid's nodes (x fastest on a Cartesian grid). + """ + if isinstance(space, RadialGrid): + return np.moveaxis(space.hankel.inverse(np.moveaxis(spectrum, 0, -1)), -1, 0) + shape = (space.n_y, space.n_x, *spectrum.shape[1:]) + return np.fft.ifft2(spectrum.reshape(shape), axes=(0, 1)).reshape(spectrum.shape) + + +def _to_spectrum( + space: RadialGrid | FreeGrid, samples: Complex[np.ndarray, "n_modes n_pol n_omega"] +) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: + """Transform field samples to the stored transverse spectrum. + + Parameters + ---------- + space : solprop.grid.RadialGrid or solprop.grid.FreeGrid + Transverse representation. + samples : numpy.ndarray + Field samples at the grid's nodes, shape ``(n_modes, n_pol, n_omega)``. + + Returns + ------- + numpy.ndarray + The raw Hankel or DFT spectrum of the samples, the same shape. + """ + if isinstance(space, RadialGrid): + return np.moveaxis(space.hankel.forward(np.moveaxis(samples, 0, -1)), -1, 0) + shape = (space.n_y, space.n_x, *samples.shape[1:]) + return np.fft.fft2(samples.reshape(shape), axes=(0, 1)).reshape(samples.shape) + + +def _nodes(space: RadialGrid | FreeGrid) -> tuple[np.ndarray, np.ndarray]: + """Return the x and y coordinates of the grid's nodes. + + Parameters + ---------- + space : solprop.grid.RadialGrid or solprop.grid.FreeGrid + Transverse representation; radial nodes lie on the x axis. + + Returns + ------- + tuple of numpy.ndarray + ``x`` and ``y`` in m, each shape ``(n_nodes,)``, x fastest. + """ + if isinstance(space, RadialGrid): + return np.asarray(space.r), np.zeros(space.n_r) + return ( + np.tile(space.x, space.n_y), + np.repeat(space.y, space.n_x), + ) + + +def _flux(state: SpatialState) -> SpatialFlux: + """Resolve the axial-flux measure of the state's plane. + + Parameters + ---------- + state : SpatialState + The plane; its medium is homogeneous, so the position is immaterial. + + Returns + ------- + solprop.spatial.SpatialFlux + The measure of SEP 34. + """ + return SpatialFlux.resolve(state.grid, state.space, state.medium, z=0.0) + + +def _energy_spectrum( + spectrum: Complex[np.ndarray, "n_modes n_pol n_omega"], flux: SpatialFlux +) -> Float[np.ndarray, " n_omega"]: + """Split :func:`solprop.spatial.spatial_energy` into temporal bins. + + The same weighted sum, accumulated per bin rather than in total: the axial + admittance of :meth:`solprop.spatial.SpatialFlux.admittance`, the transverse + Parseval weights and the analytic-signal endpoint weights of + :func:`solprop.fourier.energy_weights`. + + Parameters + ---------- + spectrum : numpy.ndarray + Stored spectrum, shape ``(n_modes, n_pol, n_omega)``. + flux : solprop.spatial.SpatialFlux + The plane's measure. + + Returns + ------- + numpy.ndarray + Energy per bin in J, shape ``(n_omega,)``. + """ + grid = flux.grid + weights = fourier.energy_weights(grid.n_omega, grid.dt, kind=grid.kind) + total = np.zeros(grid.n_omega) + for columns in flux.chunks(): + real = np.real(flux.admittance(columns)) + density = np.sum(np.abs(spectrum[columns]) ** 2, axis=1) + total += np.sum(flux.space.spectral_weights[columns, None] * real * density, 0) + return flux.energy_scale * weights * total + + +@dataclass(frozen=True) +class _Measured: + """The physical energy of a plane, in total and per bin. + + Attributes + ---------- + energy : float + Total energy in J (:func:`solprop.spatial.spatial_energy`). + spectrum : numpy.ndarray + Energy per bin in J. + """ + + energy: float + spectrum: Float[np.ndarray, " n_omega"] + + +def _measure(state: SpatialState, flux: SpatialFlux) -> _Measured: + """Measure the physical energy of a state. + + Parameters + ---------- + state : SpatialState + The plane. + flux : solprop.spatial.SpatialFlux + Its measure. + + Returns + ------- + _Measured + Total and per-bin energy, J. + """ + return _Measured( + spatial_energy(state.spectrum, flux), _energy_spectrum(state.spectrum, flux) + ) + + +@dataclass(frozen=True) +class _Angular: + """The reference-measure content of a plane split by propagation angle. + + Attributes + ---------- + cone : float + Content of propagating columns inside the cone, J. + tail : float + Content outside the cone, nonpropagating columns included, J. + evanescent : float + Content of nonpropagating columns, J; part of `tail`. + """ + + cone: float + tail: float + evanescent: float + + @property + def total(self) -> float: + """Return the whole reference-measure content. + + Returns + ------- + float + ``cone + tail`` in J. + """ + return self.cone + self.tail + + +def _angular_content( + state: SpatialState, flux: SpatialFlux, cone_cosine: float +) -> _Angular: + r"""Split the local reference measure :math:`U_0` by propagation angle. + + :math:`U_0` weighs each column with :math:`Y_0=\operatorname{Re}n\,\varepsilon_0c` + in place of the axial admittance; the obliquity of a propagating column is the + admittance over the index, :math:`\cos\theta`. + + Parameters + ---------- + state : SpatialState + The plane. + flux : solprop.spatial.SpatialFlux + Its measure. + cone_cosine : float + :math:`\cos\theta_*`; columns with a smaller obliquity are the tail. + + Returns + ------- + _Angular + The split content, J. + """ + grid = state.grid + weights = fourier.energy_weights(grid.n_omega, grid.dt, kind=grid.kind) + index = np.real(_index(state.medium, grid)) + cone = tail = evanescent = 0.0 + for columns in flux.chunks(): + density = np.sum(np.abs(state.spectrum[columns]) ** 2, axis=1) + content = ( + flux.energy_scale + * state.space.spectral_weights[columns, None] + * (weights * index) + * density + ) + admittance = np.real(flux.admittance(columns)) + cosine = np.divide( + admittance, + index, + out=np.zeros_like(admittance), + where=index > 0, + ) + propagating = admittance > 0 + inside = propagating & (cosine >= cone_cosine) + cone += float(np.sum(content[inside])) + tail += float(np.sum(content[~inside])) + evanescent += float(np.sum(content[~propagating])) + return _Angular(cone, tail, evanescent) + + +def _reference_norm( + space: RadialGrid | FreeGrid, + grid: TimeGrid, + index: Float[np.ndarray, " n_omega"], + spectrum: Complex[np.ndarray, "n_modes n_pol n_omega"], +) -> float: + """Return the squared reference-measure norm of a spectrum, up to a constant. + + Parameters + ---------- + space : solprop.grid.RadialGrid or solprop.grid.FreeGrid + Transverse representation. + grid : solprop.grid.TimeGrid + Temporal grid. + index : numpy.ndarray + Real index per bin, zero off band, shape ``(n_omega,)``. + spectrum : numpy.ndarray + Stored spectrum, shape ``(n_modes, n_pol, n_omega)``. + + Returns + ------- + float + ``sum v_j gamma_k n_k |X_jpk|^2``, in the units of ``|X|^2`` m^-2 s. + """ + weights = fourier.energy_weights(grid.n_omega, grid.dt, kind=grid.kind) + density = np.sum(np.abs(spectrum) ** 2, axis=1) + return float(np.sum(space.spectral_weights[:, None] * (weights * index) * density)) + + +def _edge_nodes(space: RadialGrid | FreeGrid) -> Bool[np.ndarray, " n_nodes"]: + """Return the nodes of the window's outer band. + + The declared collar where there is one, otherwise the outer tenth of each + half-width. + + Parameters + ---------- + space : solprop.grid.RadialGrid or solprop.grid.FreeGrid + Transverse representation. + + Returns + ------- + numpy.ndarray + Boolean mask over the nodes, shape ``(n_nodes,)``. + """ + if space.window_factor > 0: + return np.asarray(space.window < 1) + if isinstance(space, RadialGrid): + return np.asarray(space.r > 0.9 * space.radius) + x, y = _nodes(space) + return np.asarray( + (np.abs(x) > 0.9 * np.max(np.abs(space.x))) + | (np.abs(y) > 0.9 * np.max(np.abs(space.y))) + ) + + +def _node_content( + space: RadialGrid | FreeGrid, + grid: TimeGrid, + index: Float[np.ndarray, " n_omega"], + samples: Complex[np.ndarray, "n_modes n_pol n_omega"], +) -> Float[np.ndarray, " n_modes"]: + """Return each node's reference-measure content, up to a constant. + + Parameters + ---------- + space : solprop.grid.RadialGrid or solprop.grid.FreeGrid + Transverse representation. + grid : solprop.grid.TimeGrid + Temporal grid. + index : numpy.ndarray + Real index per bin, zero off band. + samples : numpy.ndarray + Field samples at the nodes, shape ``(n_modes, n_pol, n_omega)``. + + Returns + ------- + numpy.ndarray + Area-weighted content per node, shape ``(n_modes,)``. + """ + weights = fourier.energy_weights(grid.n_omega, grid.dt, kind=grid.kind) + density = np.sum(np.abs(samples) ** 2, axis=1) + radial = isinstance(space, RadialGrid) + area = space.radial_weights if radial else space.spatial_weights + return area * np.sum((weights * index) * density, axis=1) + + +def _edge_share(state: SpatialState) -> float: + """Return the share of the field in the window's outer band. + + Parameters + ---------- + state : SpatialState + The plane. + + Returns + ------- + float + Reference-measure share of the field at the edge nodes, in [0, 1]; zero for + a zero field. + """ + index = np.real(_index(state.medium, state.grid)) + content = _node_content( + state.space, state.grid, index, _to_field(state.space, state.spectrum) + ) + total = float(np.sum(content)) + if total == 0: + return 0.0 + return float(np.sum(content[_edge_nodes(state.space)])) / total + + +def _round_trip_error(state: SpatialState) -> float: + r"""Return the relative energy error bound of one synthesis round trip. + + The raw QDHT is only approximately self-inverse; a field that is synthesised + and transformed back differs by :math:`\delta` in the reference norm, which + bounds the relative energy discrepancy by :math:`2\delta+\delta^2` (SEP 31 + §4.7). On a Cartesian grid it is rounding. + + Parameters + ---------- + state : SpatialState + The plane. + + Returns + ------- + float + :math:`2\delta+\delta^2`; zero for a zero field. + """ + index = np.real(_index(state.medium, state.grid)) + norm = _reference_norm(state.space, state.grid, index, state.spectrum) + if norm == 0: + return 0.0 + back = _to_spectrum(state.space, _to_field(state.space, state.spectrum)) + delta = math.sqrt( + _reference_norm(state.space, state.grid, index, back - state.spectrum) / norm + ) + return 2 * delta + delta * delta + + +@dataclass(frozen=True) +class _Extras: + """The optional parts of an element report. + + Attributes + ---------- + certificate : ScreenCertificate or None + Thin-screen certificate. + calibration : tuple of SpectralCalibration + Affine-phase records. + reconstruction : bool + Whether the operation is a reconstruction. + diagnostics : collections.abc.Mapping + Element-specific numbers. + notes : tuple of str + Element-specific statements. + """ + + certificate: ScreenCertificate | None = None + calibration: tuple[SpectralCalibration, ...] = () + reconstruction: bool = False + diagnostics: Mapping[str, float] = field(default_factory=dict) + notes: tuple[str, ...] = () + + +def _report( # noqa: PLR0913 - the ledger's terms, each named at the call + element: str, + measured_in: _Measured, + measured_out: _Measured, + *, + specified_loss: float = 0.0, + removed: float = 0.0, + numerical_error: float = 0.0, + extras: _Extras | None = None, +) -> ElementReport: + """Close an element's ledger and build its report. + + Parameters + ---------- + element : str + Description of the element. + measured_in, measured_out : _Measured + Energies of the input and output planes. + specified_loss : float, optional + Declared loss, J. + removed : float, optional + Intentional removal, J. + numerical_error : float, optional + Estimated numerical error, J. + extras : _Extras or None, optional + The optional report fields. + + Returns + ------- + ElementReport + The report, with the approximation error that closes the ledger. + """ + extras = _Extras() if extras is None else extras + approximation = measured_out.energy - measured_in.energy + specified_loss + removed + return ElementReport( + element=element, + position=0, + energy_in=measured_in.energy, + energy_out=measured_out.energy, + specified_loss=specified_loss, + removed=removed, + approximation_error=approximation, + numerical_error=numerical_error, + spectrum_in=measured_in.spectrum, + spectrum_out=measured_out.spectrum, + certificate=extras.certificate, + calibration=extras.calibration, + reconstruction=extras.reconstruction, + diagnostics=extras.diagnostics, + notes=extras.notes, + ) + + +def _spectral_values( + values: float | complex | SpectralFunction, + omega: Float[np.ndarray, " n_band"], + name: str, +) -> Complex[np.ndarray, " n_band"]: + """Evaluate a constant or a function of frequency on the band. + + Parameters + ---------- + values : float, complex or callable + A constant, or a function of positive angular frequency. + omega : numpy.ndarray + Band frequencies in rad/s, shape ``(n_band,)``. + name : str + The quantity, for messages. + + Returns + ------- + numpy.ndarray + Complex values, shape ``(n_band,)``. + + Raises + ------ + MaterialError + If the values are not finite or do not match the band. + """ + result = values(omega) if callable(values) else values + array = np.broadcast_to(np.asarray(result, dtype=np.complex128), omega.shape) + if not np.all(np.isfinite(array)): + msg = f"{name} must be finite on every band frequency" + raise MaterialError(msg) + return np.array(array) + + +def _check_model_error(model_error: float) -> None: + """Refuse a model-error budget outside (0, 1). + + Parameters + ---------- + model_error : float + The budget. + + Raises + ------ + ConvergenceError + If it is not finite and strictly between zero and one. + """ + if not (math.isfinite(model_error) and 0 < model_error < 1): + msg = f"model_error is a required budget in (0, 1), got {model_error!r}" + raise ConvergenceError(msg) + + +# --------------------------------------------------------------------- thin screens + + +def _occupied(content: Float[np.ndarray, " n_modes"]) -> Bool[np.ndarray, " n_modes"]: + """Return the nodes whose content is above the diagnostic floor. + + Parameters + ---------- + content : numpy.ndarray + Content per node. + + Returns + ------- + numpy.ndarray + Boolean mask; all False for a zero field. + """ + peak = float(np.max(content)) if content.size else 0.0 + return content > _OCCUPIED * peak if peak > 0 else np.zeros(content.shape, bool) + + +def _phase_step( + space: RadialGrid | FreeGrid, + transmission: Complex[np.ndarray, "n_modes n_band"], + occupied: Bool[np.ndarray, " n_modes"], +) -> float: + """Return a screen's largest phase change between occupied neighbouring nodes. + + A step of pi or more samples the screen's phase below its Nyquist rate: the + spatial frequencies it generates alias onto the grid. + + Parameters + ---------- + space : solprop.grid.RadialGrid or solprop.grid.FreeGrid + Transverse representation. + transmission : numpy.ndarray + Optics-convention transmission at the nodes, shape ``(n_modes, n_band)``. + occupied : numpy.ndarray + Nodes that hold field, shape ``(n_modes,)``. + + Returns + ------- + float + The largest step in rad over neighbours that are both occupied and both + transmitting; zero if there are none. + """ + pairs: list[tuple[np.ndarray, np.ndarray, np.ndarray]] = [] + if isinstance(space, RadialGrid): + pairs.append( + (transmission[1:], transmission[:-1], occupied[1:] & occupied[:-1]) + ) + else: + values = transmission.reshape(space.n_y, space.n_x, -1) + mask = occupied.reshape(space.n_y, space.n_x) + pairs.append((values[:, 1:], values[:, :-1], mask[:, 1:] & mask[:, :-1])) + pairs.append((values[1:], values[:-1], mask[1:] & mask[:-1])) + step = 0.0 + for later, earlier, both in pairs: + ratio = later * np.conj(earlier) + valid = both[..., None] & (np.abs(ratio) > 0) + if np.any(valid): + step = max(step, float(np.max(np.abs(np.angle(ratio[valid]))))) + return step + + +def _apply_screen( # noqa: PLR0913 - the screen and its declarations + state: SpatialState, + transmission: Complex[np.ndarray, "n_modes n_band"], + *, + element: str, + model_error: float, + phase_only: bool, + gradient: Float[np.ndarray, "n_modes n_band"] | None = None, + notes: tuple[str, ...] = (), +) -> ElementResult: + """Apply a thin local screen and certify it against its budget. + + Parameters + ---------- + state : SpatialState + The input plane. + transmission : numpy.ndarray + Optics-convention field transmission at the nodes on the band bins, + shape ``(n_modes, n_band)``. + element : str + Description, for the report. + model_error : float + The required budget in (0, 1). + phase_only : bool + Whether the screen is declared unit-modulus; its local loss is then zero. + gradient : numpy.ndarray or None, optional + The screen's analytic phase gradient magnitude in rad/m at the nodes, + shape ``(n_modes, n_band)``, when it is known. Sampled phases wrap, so + only an analytic gradient detects a step beyond pi. + notes : tuple of str, optional + Statements for the report. + + Returns + ------- + ElementResult + The output plane and its report with the certificate. + + Raises + ------ + MaterialError + If the screen amplifies anywhere. + GridError + If the screen's phase is undersampled where the field is. + ConvergenceError + If the certificate exceeds the budget. + """ + _check_model_error(model_error) + if not np.all(np.isfinite(transmission)): + msg = f"{element}: the transmission must be finite" + raise MaterialError(msg) + if np.max(np.abs(transmission), initial=0.0) > 1 + 8 * _EPS: + msg = ( + f"{element}: a passive screen has |T| <= 1; a gain screen is not " + "a linear passive element" + ) + raise MaterialError(msg) + band = _band(state.grid) + index = np.real(_index(state.medium, state.grid)) + samples = _to_field(state.space, state.spectrum) + occupied = _occupied(_node_content(state.space, state.grid, index, samples)) + step = _phase_step(state.space, transmission, occupied) + if gradient is not None and np.any(occupied): + analytic = float(np.max(gradient[occupied])) * _spacing(state.space) + step = max(step, analytic) + if step >= math.pi: + msg = ( + f"{element}: the screen's phase changes by {step:.3g} rad between " + "neighbouring nodes where the field is, beyond the grid's Nyquist rate; " + "refine the transverse grid" + ) + raise GridError(msg) + factor = np.ones((state.space.n_columns, state.grid.n_omega), dtype=np.complex128) + factor[:, band] = fourier.to_internal(transmission) + after = state.with_spectrum(_to_spectrum(state.space, factor[:, None, :] * samples)) + flux = _flux(state) + measured_in = _measure(state, flux) + measured_out = _measure(after, flux) + # Numerical and domain error: the synthesis round trip, plus the field at the + # window's edge before and after, which the representation may wrap or clip. + numerical = _round_trip_error(state) + _edge_share(state) + _edge_share(after) + certificate = _screen_certificate( + state, after, flux, model_error=model_error, numerical=numerical + ) + local_loss = 0.0 + if not phase_only: + cosine = certificate.cone_cosine + local_loss = ( + _angular_content(state, flux, cosine).total + - _angular_content(after, flux, cosine).total + ) + report = _report( + element, + measured_in, + measured_out, + specified_loss=local_loss, + numerical_error=numerical * measured_in.energy, + extras=_Extras( + certificate=certificate, diagnostics={"phase_step": step}, notes=notes + ), + ) + return ElementResult(after, report) + + +def _screen_certificate( + before: SpatialState, + after: SpatialState, + flux: SpatialFlux, + *, + model_error: float, + numerical: float, +) -> ScreenCertificate: + """Evaluate the thin-screen defect on input and output and admit or refuse. + + Parameters + ---------- + before, after : SpatialState + The input and output planes, on one representation and medium. + flux : solprop.spatial.SpatialFlux + Their common measure. + model_error : float + The budget in (0, 1). + numerical : float + Relative numerical error added to the ratio. + + Returns + ------- + ScreenCertificate + The admitted certificate. + + Raises + ------ + GridError + If a nonzero input carries no forward flux. + ConvergenceError + If the defect ratio exceeds the budget. + """ + cosine = 1 - model_error / 8 + if not np.any(before.spectrum): + return ScreenCertificate(model_error, cosine, 0.0, 0.0, 0.0, 0.0, 0.0, True) + content_in = _angular_content(before, flux, cosine) + content_out = _angular_content(after, flux, cosine) + defect_in = (1 - cosine) * content_in.cone + content_in.tail + defect_out = (1 - cosine) * content_out.cone + content_out.tail + reference = spatial_energy(before.spectrum, flux) + if reference <= 0: + msg = "a thin screen's input carries no forward flux to certify against" + raise GridError(msg) + ratio = (defect_in + defect_out) / reference + numerical + if ratio > model_error: + msg = ( + f"the thin-screen defect is {ratio:.3e} of the input energy, beyond " + f"model_error={model_error:g}: the field leaves the paraxial cone " + "cos(theta*) = 1 - model_error/8 or the transform is not resolved; " + "relax the budget only if a larger axial-flux error is acceptable" + ) + raise ConvergenceError(msg) + return ScreenCertificate( + model_error, cosine, defect_in, defect_out, reference, numerical, ratio, False + ) + + +def _evaluate_transmission( + transmission: Transmission, + x: Float[np.ndarray, " n_nodes"], + y: Float[np.ndarray, " n_nodes"], + omega: Float[np.ndarray, " n_band"], + element: str, +) -> Complex[np.ndarray, "n_nodes n_band"]: + """Evaluate a transmission callable and check its shape. + + Parameters + ---------- + transmission : callable + ``T(x, y, omega)`` in the optics convention. + x, y : numpy.ndarray + Node positions in m, shape ``(n_nodes,)``. + omega : numpy.ndarray + Band frequencies in rad/s, shape ``(n_band,)``. + element : str + Description, for messages. + + Returns + ------- + numpy.ndarray + The transmission, shape ``(n_nodes, n_band)``. + + Raises + ------ + MaterialError + If the result does not broadcast to ``(n_nodes, n_band)``. + """ + values = np.asarray(transmission(x, y, omega), dtype=np.complex128) + try: + return np.array(np.broadcast_to(values, (x.size, omega.size))) + except ValueError as error: + msg = ( + f"{element}: the transmission must broadcast to (n_nodes, n_band) = " + f"({x.size}, {omega.size}), got {values.shape}" + ) + raise MaterialError(msg) from error + + +def _symmetric_transmission( + transmission: Transmission, + space: RadialGrid, + omega: Float[np.ndarray, " n_band"], + element: str, +) -> Complex[np.ndarray, "n_nodes n_band"]: + """Evaluate a transmission on a radial grid, refusing an asymmetric one. + + The order-zero Hankel representation holds a cylindrically symmetric field; a + screen that depends on the azimuth would break it silently. + + Parameters + ---------- + transmission : callable + ``T(x, y, omega)``. + space : solprop.grid.RadialGrid + Radial representation. + omega : numpy.ndarray + Band frequencies in rad/s. + element : str + Description, for messages. + + Returns + ------- + numpy.ndarray + The transmission at the radial nodes, shape ``(n_r, n_band)``. + + Raises + ------ + GridError + If the transmission depends on the azimuth. + """ + radius = np.asarray(space.r) + reference = _evaluate_transmission( + transmission, radius, np.zeros_like(radius), omega, element + ) + for angle in (0.7, 1.9, 3.3, 4.6): + rotated = _evaluate_transmission( + transmission, + radius * math.cos(angle), + radius * math.sin(angle), + omega, + element, + ) + if np.max(np.abs(rotated - reference)) > 64 * _EPS * max( + 1.0, float(np.max(np.abs(reference))) + ): + msg = ( + f"{element} depends on the azimuth, and a radial grid holds only " + "cylindrically symmetric fields: use a Cartesian FreeGrid" + ) + raise GridError(msg) + return reference + + +@dataclass(frozen=True, eq=False) +class ThinLens: + r"""A thin paraxial lens, the phase screen :math:`e^{-ik(\omega)r^2/(2f(\omega))}`. + + :math:`k=\operatorname{Re}n\,\omega/c` is the wave number of the plane's + medium and :math:`r` the distance from the plane's axis. The screen is + certified per field against `model_error` (:class:`ScreenCertificate`). + + Attributes + ---------- + focal_length : float or callable + Focal length in m, finite and nonzero (negative diverges), or a function of + positive angular frequency in rad/s returning it per band bin. + model_error : float + Required thin-screen budget in (0, 1). + + Examples + -------- + >>> from solprop.coupling.elements import ThinLens + >>> ThinLens(0.5, model_error=1e-3).focal_length + 0.5 + """ + + focal_length: float | SpectralFunction + model_error: float + + def apply(self, state: SpatialState) -> ElementResult: + """Apply the lens screen. + + Parameters + ---------- + state : SpatialState + The input plane. + + Returns + ------- + ElementResult + The output plane and its certified report. + + Raises + ------ + MaterialError + If a focal length is zero or not finite. + """ + band = _band(state.grid) + omega = state.grid.omega[band] + focal = np.real(_spectral_values(self.focal_length, omega, "focal_length")) + if np.any(focal == 0): + msg = "a thin lens needs a nonzero focal length on every band bin" + raise MaterialError(msg) + wavenumber = np.real(_index(state.medium, state.grid))[band] * omega / C + x, y = _nodes(state.space) + square = x * x + y * y + curvature = wavenumber / (2 * focal) + transmission = np.exp(-1j * square[:, None] * curvature[None, :]) + gradient = np.sqrt(square)[:, None] * np.abs(2 * curvature)[None, :] + label = ( + f"ThinLens(f={self.focal_length:g} m)" + if isinstance(self.focal_length, float | int) + else "ThinLens(chromatic f)" + ) + return _apply_screen( + state, + transmission, + element=label, + model_error=self.model_error, + phase_only=True, + gradient=gradient, + ) + + +@dataclass(frozen=True, eq=False) +class Aperture: + """An aperture whose intensity weight is converted to an amplitude. + + :func:`solprop.spatial.aperture_profile` gives the SEP 23 edge profile ``W``, + used here as the **intensity** transmission; the field is multiplied by + ``sqrt(W)``. A hard aperture is the same either way. + + Attributes + ---------- + radius : float + Half-width of the profile in m, finite and positive. + model_error : float + Required thin-screen budget in (0, 1). + edge : float + Edge width in m for ``tanh`` and ``rcos``, order for ``supergauss``, + unused for ``hard``. Default 0. + kind : {'hard', 'tanh', 'rcos', 'supergauss'} + Edge profile. Default ``'hard'``. + centre : tuple of float + Centre ``(x, y)`` in m in the local frame; a radial grid needs the axis. + """ + + radius: float + model_error: float + edge: float = 0.0 + kind: Literal["hard", "tanh", "rcos", "supergauss"] = "hard" + centre: tuple[float, float] = (0.0, 0.0) + + def apply(self, state: SpatialState) -> ElementResult: + """Apply the aperture. + + Parameters + ---------- + state : SpatialState + The input plane. + + Returns + ------- + ElementResult + The output plane and its certified report. + + Raises + ------ + GridError + If an off-axis aperture is asked of a radial grid. + """ + if isinstance(state.space, RadialGrid) and any(self.centre): + msg = ( + "an off-axis aperture breaks cylindrical symmetry: use a Cartesian " + "FreeGrid" + ) + raise GridError(msg) + x, y = _nodes(state.space) + distance = np.hypot(x - self.centre[0], y - self.centre[1]) + weight = aperture_profile(distance, self.radius, self.edge, self.kind) + amplitude = np.sqrt(weight) + n_band = int(np.sum(_band(state.grid))) + transmission = np.repeat(amplitude[:, None], n_band, axis=1).astype(complex) + return _apply_screen( + state, + transmission, + element=f"Aperture(radius={self.radius:g} m, {self.kind})", + model_error=self.model_error, + phase_only=False, + notes=("intensity weight W converted to field amplitude sqrt(W)",), + ) + + +@dataclass(frozen=True, eq=False) +class TransmissionMask: + """A complex field transmission, as a function or as samples at the nodes. + + Attributes + ---------- + transmission : callable or array_like + ``T(x, y, omega)`` in the optics convention (positions in m, shape + ``(n_nodes,)``; band angular frequencies in rad/s, shape ``(n_band,)``; + a result broadcasting to ``(n_nodes, n_band)``), or samples at the + state's nodes, shape ``(n_nodes,)`` or ``(n_nodes, n_band)``. A + function on a radial grid must not depend on the azimuth. + ``|T| <= 1`` everywhere. + model_error : float + Required thin-screen budget in (0, 1). + phase_only : bool + Declare ``|T| = 1``: the local loss is then exactly zero. Default False. + """ + + transmission: Transmission | ArrayLike + model_error: float + phase_only: bool = False + + def apply(self, state: SpatialState) -> ElementResult: + """Apply the mask. + + Parameters + ---------- + state : SpatialState + The input plane. + + Returns + ------- + ElementResult + The output plane and its certified report. + + Raises + ------ + MaterialError + If sampled values have the wrong shape or a declared phase-only mask + changes the modulus. + GridError + If a function depends on the azimuth on a radial grid. + """ + band = _band(state.grid) + omega = state.grid.omega[band] + n_nodes = state.space.n_columns + if callable(self.transmission): + if isinstance(state.space, RadialGrid): + values = _symmetric_transmission( + self.transmission, state.space, omega, "TransmissionMask" + ) + else: + x, y = _nodes(state.space) + values = _evaluate_transmission( + self.transmission, x, y, omega, "TransmissionMask" + ) + else: + array = np.asarray(self.transmission, dtype=np.complex128) + if array.ndim == 1: + array = array[:, None] + if ( + array.ndim != 2 + or array.shape[0] != n_nodes + or array.shape[1] + not in ( + 1, + omega.size, + ) + ): + msg = ( + "TransmissionMask samples have shape (n_nodes,) or (n_nodes, " + f"n_band) = ({n_nodes}, {omega.size}), got {array.shape}" + ) + raise MaterialError(msg) + values = np.array(np.broadcast_to(array, (n_nodes, omega.size))) + if self.phase_only and np.max(np.abs(np.abs(values) - 1), initial=0.0) > ( + 8 * _EPS + ): + msg = "a mask declared phase-only must have unit modulus" + raise MaterialError(msg) + return _apply_screen( + state, + values, + element="TransmissionMask", + model_error=self.model_error, + phase_only=self.phase_only, + ) + + +@dataclass(frozen=True, eq=False) +class Alignment: + """A lateral displacement and a paraxial tilt of a Cartesian field. + + The field is tilted about the plane's origin and then displaced rigidly: + ``E'(x, y) = T(x - d_x, y - d_y) E(x - d_x, y - d_y)`` with the optics-convention + tilt ``T = exp(+i k (x sin(theta_x) + y sin(theta_y)))``, ``k`` the medium's + real wave number. A positive displacement moves the beam towards +x (+y); a + positive tilt makes it propagate towards +x (+y). The displacement is exact in + the periodic DFT representation; the tilt is a phase screen certified against + `model_error`. A radial grid refuses both. + + Attributes + ---------- + displacement : tuple of float + ``(d_x, d_y)`` in m, finite. Default no displacement. + tilt : tuple of float + ``(theta_x, theta_y)`` in rad, finite, each below pi/2 in magnitude. + Default no tilt. + model_error : float or None + Thin-screen budget in (0, 1), required for a nonzero tilt. + """ + + displacement: tuple[float, float] = (0.0, 0.0) + tilt: tuple[float, float] = (0.0, 0.0) + model_error: float | None = None + + def apply(self, state: SpatialState) -> ElementResult: + """Apply the tilt, then the displacement. + + Parameters + ---------- + state : SpatialState + The input plane. + + Returns + ------- + ElementResult + The output plane and its report. + + Raises + ------ + GridError + If a nonzero alignment is asked of a radial grid. + ConvergenceError + If a tilt has no budget or exceeds it. + """ + values = (*self.displacement, *self.tilt) + if not all(math.isfinite(value) for value in values) or any( + abs(angle) >= math.pi / 2 for angle in self.tilt + ): + msg = "alignment needs finite displacements and tilts below pi/2" + raise GridError(msg) + if isinstance(state.space, RadialGrid): + if any(values): + msg = ( + "displacement and tilt break cylindrical symmetry: use a " + "Cartesian FreeGrid" + ) + raise GridError(msg) + flux = _flux(state) + measured = _measure(state, flux) + return ElementResult(state, _report("Alignment(none)", measured, measured)) + space = state.space + result: ElementResult | None = None + current = state + if any(self.tilt): + if self.model_error is None: + msg = "a tilt is a phase screen and needs its model_error budget" + raise ConvergenceError(msg) + band = _band(state.grid) + index = np.real(_index(state.medium, state.grid))[band] + wavenumber = index * state.grid.omega[band] / C + x, y = _nodes(space) + projection = x * math.sin(self.tilt[0]) + y * math.sin(self.tilt[1]) + slope = math.hypot(math.sin(self.tilt[0]), math.sin(self.tilt[1])) + result = _apply_screen( + state, + np.exp(1j * projection[:, None] * wavenumber[None, :]), + element=f"Alignment(tilt={self.tilt} rad)", + model_error=self.model_error, + phase_only=True, + gradient=np.broadcast_to(slope * wavenumber, (x.size, wavenumber.size)), + ) + current = result.state + shift = np.exp( + -1j + * ( + np.tile(space.k_x, space.n_y) * self.displacement[0] + + np.repeat(space.k_y, space.n_x) * self.displacement[1] + ) + ) + moved = current.with_spectrum(shift[:, None, None] * current.spectrum) + flux = _flux(state) + measured_in = _measure(state, flux) + measured_out = _measure(moved, flux) + label = f"Alignment(displacement={self.displacement} m, tilt={self.tilt} rad)" + if result is None: + return ElementResult(moved, _report(label, measured_in, measured_out)) + report = replace( + result.report, + element=label, + energy_out=measured_out.energy, + spectrum_out=measured_out.spectrum, + approximation_error=measured_out.energy + - measured_in.energy + + result.report.specified_loss, + ) + return ElementResult(moved, report) + + +# --------------------------------------------------------------------- uniform maps + + +def _uniform_result( + before: SpatialState, + after: SpatialState, + *, + element: str, + removed_bins: Bool[np.ndarray, " n_omega"] | None = None, + reflected: Float[np.ndarray, " n_omega"] | None = None, + extras: _Extras | None = None, +) -> ElementResult: + """Account for a spatially uniform map. + + A uniform map commutes with the transverse transform: on one medium every + column keeps its obliquity, the loss per bin is the energy change per bin and + the ledger closes to rounding. Across a medium change the loss is the declared + reflection and the rest is obliquity. + + Parameters + ---------- + before, after : SpatialState + The input and output planes. + element : str + Description. + removed_bins : numpy.ndarray or None, optional + Bins set to exactly zero by the element; their input energy is reported as + removed. + reflected : numpy.ndarray or None, optional + Declared reflectance per bin across a medium change; the specified loss is + then ``sum(R * spectrum_in)``. + extras : _Extras or None, optional + The optional report fields. + + Returns + ------- + ElementResult + The output plane and its report. + """ + measured_in = _measure(before, _flux(before)) + measured_out = _measure(after, _flux(after)) + removed = 0.0 + if removed_bins is not None: + removed = float(np.sum(measured_in.spectrum[removed_bins])) + if reflected is not None: + loss = float(np.sum(reflected * measured_in.spectrum)) + else: + kept = np.ones(before.grid.n_omega, bool) + if removed_bins is not None: + kept = ~removed_bins + loss = float(np.sum((measured_in.spectrum - measured_out.spectrum)[kept])) + report = _report( + element, + measured_in, + measured_out, + specified_loss=loss, + removed=removed, + extras=extras, + ) + return ElementResult(after, report) + + +def _apply_jones( + state: SpatialState, jones: Complex[np.ndarray, "n_band 2 2"] +) -> Complex[np.ndarray, "n_modes n_components n_omega"]: + """Apply an optics-convention Jones matrix per band bin to the stored spectrum. + + A scalar state is polarised along x. It stays scalar when the map creates no + y component and becomes a two-component state otherwise. + + Parameters + ---------- + state : SpatialState + The input plane. + jones : numpy.ndarray + Jones matrices acting on ``(E_x, E_y)``, shape ``(n_band, 2, 2)``. + + Returns + ------- + numpy.ndarray + The new internal spectrum, shape ``(n_modes, 1 or 2, n_omega)``. + """ + band = _band(state.grid) + stored = np.zeros((state.grid.n_omega, 2, 2), dtype=np.complex128) + stored[:] = np.eye(2) + stored[band] = fourier.to_internal(jones) + spectrum = state.spectrum + if state.n_pol == 1: + if not np.any(stored[:, 1, 0]): + return stored[None, None, :, 0, 0] * spectrum + spectrum = np.concatenate([spectrum, np.zeros_like(spectrum)], axis=1) + return np.einsum("kab,jbk->jak", stored, spectrum) + + +def _check_passive_jones( + jones: Complex[np.ndarray, "n_band 2 2"], element: str +) -> None: + """Refuse a Jones map that amplifies some polarisation. + + Parameters + ---------- + jones : numpy.ndarray + Jones matrices, shape ``(n_band, 2, 2)``. + element : str + Description, for messages. + + Raises + ------ + PolarisationError + If a matrix is not finite or its largest singular value exceeds one. + """ + if not np.all(np.isfinite(jones)): + msg = f"{element}: the Jones matrices must be finite" + raise PolarisationError(msg) + if jones.size and float(np.max(np.linalg.svd(jones, compute_uv=False))) > ( + 1 + 8 * _EPS + ): + msg = f"{element}: a passive Jones map has singular values of at most one" + raise PolarisationError(msg) + + +@dataclass(frozen=True, eq=False) +class PolarisationTransform: + """A spatially uniform Jones matrix in the plane's local ``(x, y)`` axes. + + Attributes + ---------- + jones : array_like or callable + Optics-convention 2x2 matrix acting on ``(E_x, E_y)``, or a function of + positive angular frequency in rad/s returning shape ``(n_band, 2, 2)``. + Passive: singular values of at most one. + + Examples + -------- + >>> import numpy as np + >>> from solprop.coupling.elements import PolarisationTransform + >>> PolarisationTransform(np.diag([1.0, -1.0])).jones.shape + (2, 2) + """ + + jones: ArrayLike | Callable[[Float[np.ndarray, " n_band"]], ArrayLike] + + def apply(self, state: SpatialState) -> ElementResult: + """Apply the Jones matrix. + + Parameters + ---------- + state : SpatialState + The input plane. + + Returns + ------- + ElementResult + The output plane and its report. + + Raises + ------ + PolarisationError + If the matrices have the wrong shape or amplify. + """ + omega = state.grid.omega[_band(state.grid)] + values = self.jones(omega) if callable(self.jones) else self.jones + array = np.asarray(values, dtype=np.complex128) + try: + jones = np.array(np.broadcast_to(array, (omega.size, 2, 2))) + except ValueError as error: + msg = f"a Jones matrix is 2x2 per band bin, got shape {array.shape}" + raise PolarisationError(msg) from error + _check_passive_jones(jones, "PolarisationTransform") + after = state.with_spectrum(_apply_jones(state, jones)) + return _uniform_result(state, after, element="PolarisationTransform") + + +def _unit_vector( + vector: Sequence[float] | np.ndarray, name: str +) -> Float[np.ndarray, " 3"]: + """Return a normalised three-vector. + + Parameters + ---------- + vector : sequence of float or numpy.ndarray + Three finite components, not all zero. + name : str + The quantity, for messages. + + Returns + ------- + numpy.ndarray + The unit vector, shape ``(3,)``. + + Raises + ------ + OpticsGeometryError + If the vector is not a finite nonzero three-vector. + """ + array = np.asarray(vector, dtype=np.float64) + if array.shape != (3,) or not np.all(np.isfinite(array)) or not np.any(array): + msg = f"{name} must be a finite nonzero three-vector" + raise OpticsGeometryError(msg) + return array / np.linalg.norm(array) + + +@dataclass(frozen=True, eq=False) +class Mirror: + r"""A flat mirror: spectral p/s reflection and the incoming/outgoing frame map. + + Each beam has the right-handed local frame :math:`(\hat p,\hat s,\hat k)` with + :math:`\hat p=\hat s\times\hat k`, for incoming and outgoing directions alike. + A field with components :math:`(E_p,E_s)` leaves as + :math:`(r_pE_p,r_sE_s)` on :math:`(\hat p_{\rm out},\hat s)`; there is no + conjugation. The outgoing local axes have the same p/s components as the + incoming ones, so the frame stays right-handed, and the improper reflection + appears as a mirror image of the transverse pattern along p (SEP 31 §4.6). + In this convention a perfect conductor at any incidence has + :math:`r_s=-1, r_p=+1`, and a dielectric at normal incidence + :math:`r_p=-r_s`; coefficients calibrated in another p/s convention must be + converted first. + + Attributes + ---------- + reflectivity_p, reflectivity_s : complex or callable + Optics-convention amplitude reflection coefficients, constants or functions + of positive angular frequency in rad/s; ``|r| <= 1``. + incident : sequence of float or numpy.ndarray + Incoming propagation direction in laboratory coordinates; must match the + plane's forward axis. + outgoing : sequence of float or numpy.ndarray + Outgoing propagation direction; not parallel to `incident`. + s_axis : sequence of float or numpy.ndarray + Direction perpendicular to both, hence normal to the plane of incidence + when there is one; at normal incidence it resolves the degeneracy. Its + sign does not change the map. + """ + + reflectivity_p: complex | SpectralFunction + reflectivity_s: complex | SpectralFunction + incident: Sequence[float] | np.ndarray + outgoing: Sequence[float] | np.ndarray + s_axis: Sequence[float] | np.ndarray + + def __post_init__(self) -> None: + """Check the geometry at construction.""" + self._geometry() + + def _geometry(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Validate the directions and return them normalised. + + Returns + ------- + tuple of numpy.ndarray + Incoming direction, outgoing direction and s axis, each shape ``(3,)``. + + Raises + ------ + OpticsGeometryError + If the directions do not define a reflection with this s axis. + """ + incident = _unit_vector(self.incident, "incident") + outgoing = _unit_vector(self.outgoing, "outgoing") + s_axis = _unit_vector(self.s_axis, "s_axis") + if float(incident @ outgoing) > 1 - _FRAME_TOLERANCE: + msg = "a mirror changes the propagation direction: outgoing == incident" + raise OpticsGeometryError(msg) + if ( + abs(float(s_axis @ incident)) > _FRAME_TOLERANCE + or abs(float(s_axis @ outgoing)) > _FRAME_TOLERANCE + ): + msg = "the s axis must be perpendicular to both propagation directions" + raise OpticsGeometryError(msg) + return incident, outgoing, s_axis + + def apply(self, state: SpatialState) -> ElementResult: + """Reflect the plane. + + Parameters + ---------- + state : SpatialState + The input plane, propagating along `incident`. + + Returns + ------- + ElementResult + The reflected plane in its outgoing frame, and the report. + + Raises + ------ + OpticsGeometryError + If the geometry is invalid or the plane does not propagate along + `incident`. + PolarisationError + If a coefficient exceeds one in modulus. + GridError + If the mirror image cannot be represented exactly on a Cartesian grid. + """ + incident, outgoing, s_axis = self._geometry() + frame = state.frame + if float(frame[2] @ incident) < 1 - _FRAME_TOLERANCE: + msg = "the plane does not propagate along the mirror's incident direction" + raise OpticsGeometryError(msg) + p_in = np.cross(s_axis, incident) + p_out = np.cross(s_axis, outgoing) + rotation = np.array( + [[p_in @ frame[0], p_in @ frame[1]], [s_axis @ frame[0], s_axis @ frame[1]]] + ) + omega = state.grid.omega[_band(state.grid)] + r_p = _spectral_values(self.reflectivity_p, omega, "reflectivity_p") + r_s = _spectral_values(self.reflectivity_s, omega, "reflectivity_s") + diagonal = np.zeros((omega.size, 2, 2), dtype=np.complex128) + diagonal[:, 0, 0] = r_p + diagonal[:, 1, 1] = r_s + jones = np.einsum("ba,kbc,cd->kad", rotation, diagonal, rotation) + _check_passive_jones(jones, "Mirror") + spectrum = _apply_jones(state, jones) + if isinstance(state.space, FreeGrid): + image = rotation.T @ np.diag([-1.0, 1.0]) @ rotation + spectrum = _mirror_image(state.space, spectrum, image) + new_frame = np.array( + [ + rotation[0, 0] * p_out + rotation[1, 0] * s_axis, + rotation[0, 1] * p_out + rotation[1, 1] * s_axis, + outgoing, + ] + ) + after = state.with_spectrum(spectrum, frame=new_frame) + return _uniform_result( + state, + after, + element="Mirror", + extras=_Extras(notes=("p = s x k for both beams; no conjugation",)), + ) + + +def _mirror_image( + space: FreeGrid, + spectrum: Complex[np.ndarray, "n_modes n_components n_omega"], + image: Float[np.ndarray, "2 2"], +) -> Complex[np.ndarray, "n_modes n_components n_omega"]: + """Reflect a Cartesian field's transverse pattern through a local axis. + + Parameters + ---------- + space : solprop.grid.FreeGrid + Cartesian representation. + spectrum : numpy.ndarray + Stored spectrum, shape ``(n_modes, n_components, n_omega)``. + image : numpy.ndarray + The 2x2 position map, ``diag(-1, 1)`` or ``diag(1, -1)`` to rounding. + + Returns + ------- + numpy.ndarray + The reflected spectrum. + + Raises + ------ + GridError + If the map is not a reflection through x or y, or the reflected axis has + an odd sample count and so is not symmetric about zero. + """ + flips = np.round(np.diag(image)) + if np.max(np.abs(image - np.diag(flips))) > _FRAME_TOLERANCE: + msg = ( + "this mirror image is a reflection through an axis oblique to the grid; " + "align the s axis with the local x or y axis" + ) + raise GridError(msg) + shape = (space.n_y, space.n_x, *spectrum.shape[1:]) + samples = _to_field(space, spectrum).reshape(shape) + for axis, count, flip in ((1, space.n_x, flips[0]), (0, space.n_y, flips[1])): + if flip < 0: + if count % 2: + msg = ( + "an odd sample count places no sample at zero, so the mirror " + "image is not on the grid; use an even count" + ) + raise GridError(msg) + samples = np.take(samples, (-np.arange(count)) % count, axis=axis) + return _to_spectrum(space, samples.reshape(spectrum.shape)) + + +@dataclass(frozen=True, eq=False) +class PlanarInterface: + r"""A plane boundary between two lossless media, at normal incidence. + + Tangential E/H continuity gives :math:`t_E=2n_1/(n_1+n_2)` and + :math:`r_E=(n_1-n_2)/(n_1+n_2)` per frequency, with + :math:`T=(n_2/n_1)|t_E|^2` and :math:`R+T=1`. The factor is applied to every + angular column, and the departure of an oblique column's flux from the + normal-incidence coefficient is reported as approximation error. The output + plane is in `second`, so a second application of the same interface is + refused: a Fresnel factor is applied once. + + Attributes + ---------- + first : solprop.spatial.SpatialMedium + The incident medium; must be the plane's medium. + second : solprop.spatial.SpatialMedium + The transmitted medium. + """ + + first: SpatialMedium + second: SpatialMedium + + def apply(self, state: SpatialState) -> ElementResult: + """Transmit the plane through the interface. + + Parameters + ---------- + state : SpatialState + The input plane, in `first`. + + Returns + ------- + ElementResult + The transmitted plane, in `second`, and the report. + + Raises + ------ + MaterialError + If the plane is not in `first` or either medium is lossy. + """ + grid = state.grid + if not _same_medium(state.medium, self.first, grid): + msg = ( + "the plane is not in the interface's first medium; a Fresnel factor " + "is applied once, at the declared interface" + ) + raise MaterialError(msg) + band = _band(grid) + first = _index(self.first, grid)[band] + second = _index(self.second, grid)[band] + for values in (first, second): + if np.any(np.abs(np.imag(values)) > 1e-12 * np.real(values)): + msg = "the normal-incidence interface model is for lossless media" + raise MaterialError(msg) + n_1, n_2 = np.real(first), np.real(second) + transmission = 2 * n_1 / (n_1 + n_2) + reflectance = np.zeros(grid.n_omega) + reflectance[band] = ((n_1 - n_2) / (n_1 + n_2)) ** 2 + factor = np.ones(grid.n_omega) + factor[band] = transmission + after = state.with_spectrum( + factor[None, None, :] * state.spectrum, medium=self.second + ) + defect = ( + float(np.max(np.abs(reflectance[band] + (n_2 / n_1) * transmission**2 - 1))) + if n_1.size + else 0.0 + ) + return _uniform_result( + state, + after, + element="PlanarInterface(normal incidence)", + reflected=reflectance, + extras=_Extras(diagnostics={"fresnel_defect": defect}), + ) + + +# --------------------------------------------------------------------- spectral optics + + +def _known_calibration( # noqa: PLR0911 - one return per known element type + transfer: TransferFunction, +) -> tuple[SpectralCalibration, ...] | None: + """Return the affine-phase record of a known optics element. + + Parameters + ---------- + transfer : solprop.optics.TransferFunction + The element. + + Returns + ------- + tuple of SpectralCalibration or None + One record per factor (a chain gives one per member), or None for an + unknown type. + """ + if isinstance(transfer, Chain): + records: list[SpectralCalibration] = [] + for member in transfer.elements: + known = _known_calibration(member) + if known is None: + return None + records.extend(known) + return tuple(records) + if isinstance(transfer, TaylorPhase): + return ( + SpectralCalibration( + False, + False, + transfer.wavelength, + "TaylorPhase applies its coefficients as given", + ), + ) + if isinstance(transfer, Material): + removes = transfer.wavelength is not None + return ( + SpectralCalibration( + False, + removes, + transfer.wavelength, + "Material keeps beta0" + + (" and removes the group delay at its wavelength" if removes else ""), + ), + ) + if isinstance(transfer, ChirpedMirror): + return ( + SpectralCalibration( + True, + True, + None, + "ChirpedMirror data lack constant phase and delay", + ), + ) + if isinstance(transfer, GratingPair | PrismPair): + return ( + SpectralCalibration( + True, + True, + transfer.wavelength, + f"{type(transfer).__name__} removes phase and group delay", + ), + ) + return None + + +def _phase_only(transfer: TransferFunction) -> bool | None: + """Return whether a known optics model omits all loss. + + Parameters + ---------- + transfer : solprop.optics.TransferFunction + The element. + + Returns + ------- + bool or None + True for a phase-only model, False for one with loss, None if unknown. + """ + if isinstance(transfer, Chain): + members = [_phase_only(member) for member in transfer.elements] + if any(member is None for member in members): + return None + return all(members) + if isinstance(transfer, TaylorPhase | GratingPair | PrismPair): + return True + if isinstance(transfer, Material): + return not transfer.loss + if isinstance(transfer, ChirpedMirror): + return transfer.reflections < 0 or transfer.amplitude.size == 0 + return None + + +def _reconstruction(transfer: TransferFunction) -> bool: + """Return whether a known optics element is a reconstruction operation. + + Parameters + ---------- + transfer : solprop.optics.TransferFunction + The element. + + Returns + ------- + bool + True for a negative material length or an inverse mirror phase, in the + element or any member of a chain. + """ + if isinstance(transfer, Chain): + return any(_reconstruction(member) for member in transfer.elements) + if isinstance(transfer, Material): + return transfer.thickness < 0 + if isinstance(transfer, ChirpedMirror): + return transfer.reflections < 0 + return False + + +@dataclass(frozen=True, eq=False) +class SpectralTransfer: + """A frequency-only transfer function acting coherently on every sample. + + Adapts any :class:`solprop.optics.TransferFunction` -- ``Material``, + ``ChirpedMirror``, ``GratingPair``, ``PrismPair``, ``TaylorPhase`` -- an + :class:`solprop.optics.Chain` as one block whose factors commute among + themselves only, or a compressor's response described by its callable and + metadata. The response crosses to the stored spectrum through + :func:`solprop.optics.stored_factor`. Losses are kept per frequency. + + Attributes + ---------- + transfer : solprop.optics.TransferFunction + The optics-convention response ``H(omega)``. + efficiency : float, callable or None + Power efficiency per frequency in [0, 1], applied as ``sqrt(efficiency)`` + on top of a **phase-only** model. None applies none. + calibration : SpectralCalibration, tuple of SpectralCalibration or None + The affine phase the element removes. None derives it from a known type + and is refused for any other callable. + predict_loss : bool + Ask for a lossy prediction: refused for a phase-only model without an + explicit `efficiency`. Default False. + reconstruction : bool + Declare a reconstruction (inverse) operation; negative material lengths and + inverse mirror phases are detected and flagged regardless. Default False. + """ + + transfer: TransferFunction + efficiency: float | SpectralFunction | None = None + calibration: SpectralCalibration | tuple[SpectralCalibration, ...] | None = None + predict_loss: bool = False + reconstruction: bool = False + + def _calibrations(self) -> tuple[SpectralCalibration, ...]: + """Return the affine-phase records, derived or given. + + Returns + ------- + tuple of SpectralCalibration + One record per factor. + + Raises + ------ + MaterialError + If an unknown callable carries no calibration. + """ + if isinstance(self.calibration, SpectralCalibration): + return (self.calibration,) + if self.calibration is not None: + return tuple(self.calibration) + known = _known_calibration(self.transfer) + if known is None: + msg = ( + f"{type(self.transfer).__name__} is not a known optics element: give " + "calibration=SpectralCalibration(...) saying which affine phase it " + "removes" + ) + raise MaterialError(msg) + return known + + def apply(self, state: SpatialState) -> ElementResult: + """Apply the transfer function. + + Parameters + ---------- + state : SpatialState + The input plane. + + Returns + ------- + ElementResult + The output plane and its report, with the calibration records. + + Raises + ------ + MaterialError + If a lossy prediction lacks its efficiency, an efficiency is given to a + lossy model, an efficiency is outside [0, 1], or the calibration is + missing. + """ + calibrations = self._calibrations() + phase_only = _phase_only(self.transfer) + grid = state.grid + band = _band(grid) + factor = stored_factor(grid, self.transfer) + if phase_only is None: + occupied = np.any(state.spectrum != 0, axis=(0, 1)) + phase_only = bool(np.all(np.abs(np.abs(factor[occupied]) - 1) <= 8 * _EPS)) + if self.efficiency is not None: + if not phase_only: + msg = ( + "the transfer function already carries loss; an efficiency on " + "top of it would count the loss twice" + ) + raise MaterialError(msg) + efficiency = np.real( + _spectral_values(self.efficiency, grid.omega[band], "efficiency") + ) + if np.any((efficiency < 0) | (efficiency > 1)): + msg = "an efficiency is a power fraction in [0, 1]" + raise MaterialError(msg) + factor = factor.copy() + factor[band] = factor[band] * np.sqrt(efficiency) + elif self.predict_loss and phase_only: + msg = ( + f"{type(self.transfer).__name__} is a phase-only model: a lossy " + "prediction needs an explicit efficiency=" + ) + raise MaterialError(msg) + if not np.all(np.isfinite(factor)) or np.max(np.abs(factor)) > 1 + 8 * _EPS: + msg = "a passive transfer function is finite with |H| <= 1" + raise MaterialError(msg) + after = state.with_spectrum(factor[None, None, :] * state.spectrum) + notes: tuple[str, ...] = () + if isinstance(self.transfer, Chain): + notes = tuple( + f"factor {number}: {member!r}" + for number, member in enumerate(self.transfer.elements) + ) + return _uniform_result( + state, + after, + element=f"SpectralTransfer({type(self.transfer).__name__})", + removed_bins=band & (factor == 0), + extras=_Extras( + calibration=calibrations, + reconstruction=self.reconstruction or _reconstruction(self.transfer), + notes=notes, + ), + ) + + +# --------------------------------------------------------------------- free space + + +@dataclass(frozen=True, eq=False) +class FreeSpace: + r"""Homogeneous propagation over a distance, :math:`e^{+ik_zL}` per frequency. + + :math:`k_z=\sqrt{(\omega n/c)^2-\kappa^2}` at each absolute frequency and + transverse wave number, on the order-zero Hankel columns of a radial grid or the + DFT columns of a Cartesian one. A pure delay ``frame_delay`` is removed from the + whole field, :math:`e^{-i\omega\tau}`, so that the pulse stays in its window; + the report records it for the plane's reference. + + Without `output` the field stays on its grid, propagated exactly in its + periodic (Cartesian) or Dirichlet (radial) representation; the report states + the share that would wrap or reach the window edge. With `output`, a plane of + another radius or sampling is evaluated by chunked direct summation: + + - ``output_method='angular'`` sums the propagated source spectrum at the output + nodes (:math:`\sum_j v_jH_jJ_0(k_j\rho)/2\pi` or the trigonometric + interpolant), exact and nonparaxial, inside the source window only; the field + must stay away from the source window's edge. + - ``output_method='fresnel'`` evaluates the Fresnel integral of the source + samples (zero outside the source window) at the output nodes with the + paraxial kernel, radial + :math:`\frac{k}{iL}e^{ikL}e^{ik\rho^2/2L}\int E\,e^{ikr^2/2L}J_0(k\rho r/L)r\,dr`, + Cartesian separably. It reaches any observation window; its paraxial phase + bound :math:`k(\rho_{\max}+r_{\rm occ})^4/(8L^3)` is reported, and an + undersampled kernel is refused. + + Significant evanescent content is refused rather than capped, unless an explicit + `band_limit` removes it, with the removed energy reported. + + Attributes + ---------- + distance : float + Propagation distance in m, finite; negative back-propagates and is flagged + as a reconstruction. + medium : solprop.spatial.SpatialMedium + The homogeneous medium of the gap; must be the plane's medium (a change of + medium is a :class:`PlanarInterface`). + output : solprop.grid.RadialGrid, solprop.grid.FreeGrid or None + Output plane of the same kind as the source; None keeps the source grid. + output_method : {'angular', 'fresnel'} + How an `output` plane is evaluated. Default ``'angular'``. + band_limit : float or None + Largest retained propagation angle in rad, in (0, pi/2]; columns beyond it, + and every nonpropagating column, are removed and reported. None removes + nothing. + frame_delay : float or None + Delay in s removed from the whole field; None removes the group delay of the + medium at the grid's reference frequency, ``beta1 L``. + evanescent_tolerance : float or None + Largest admitted reference-measure share of nonpropagating content; None is + the rounding budget of the transforms that formed the field. + sampling_tolerance : float + Share above which the wrap and edge diagnostics call the result inadequate. + Default 1e-6. + """ + + distance: float + medium: SpatialMedium + output: RadialGrid | FreeGrid | None = None + output_method: Literal["angular", "fresnel"] = "angular" + band_limit: float | None = None + frame_delay: float | None = None + evanescent_tolerance: float | None = None + sampling_tolerance: float = 1e-6 + + def _validate(self, state: SpatialState) -> None: + """Check the element against the plane. + + Parameters + ---------- + state : SpatialState + The input plane. + + Raises + ------ + GridError + If the distance, method, band limit or output plane is invalid. + MaterialError + If the gap's medium is not the plane's. + """ + if not math.isfinite(self.distance): + msg = f"distance must be finite, got {self.distance!r}" + raise GridError(msg) + if self.output_method not in ("angular", "fresnel"): + msg = f"output_method is 'angular' or 'fresnel', got {self.output_method!r}" + raise GridError(msg) + if self.band_limit is not None and not 0 < self.band_limit <= math.pi / 2: + msg = f"band_limit is an angle in (0, pi/2], got {self.band_limit!r}" + raise GridError(msg) + if not _same_medium(state.medium, self.medium, state.grid): + msg = ( + "the gap's medium is not the plane's: a change of medium is a " + "PlanarInterface, applied once" + ) + raise MaterialError(msg) + if self.output is not None and ( + isinstance(self.output, LineGrid) + or isinstance(self.output, RadialGrid) + != isinstance(state.space, RadialGrid) + ): + msg = "the output plane must be the same kind of grid as the source" + raise GridError(msg) + + def _delay(self, state: SpatialState) -> float: + """Return the delay removed from the whole field. + + Parameters + ---------- + state : SpatialState + The input plane. + + Returns + ------- + float + ``frame_delay``, or ``beta1(omega0) L`` in s. + """ + if self.frame_delay is not None: + return self.frame_delay + reference = np.array([state.grid.omega0]) + index = float(np.real(self.medium.index(reference))[0]) + slope = float(np.real(self.medium.dindex_domega(reference))[0]) + return (index + reference[0] * slope) / C * self.distance + + def apply(self, state: SpatialState) -> ElementResult: + """Propagate the plane. + + Parameters + ---------- + state : SpatialState + The input plane. + + Returns + ------- + ElementResult + The output plane and the report, whose diagnostics include + ``delay_removed`` in s. + + Raises + ------ + GridError + If the element is invalid, the field holds significant evanescent + content, the angular output leaves the source window, or the Fresnel + kernel is undersampled. + MaterialError + If the medium is not the plane's. + """ + self._validate(state) + delay = self._delay(state) + flux = _flux(state) + measured_in = _measure(state, flux) + kept, removed = self._limited(state, flux, measured_in) + if self.output is not None and self.output_method == "fresnel": + return self._fresnel(state, kept, measured_in, removed, delay) + propagated, absorbed, diagnostics = self._propagate(kept, flux, delay) + notes: tuple[str, ...] = () + result = propagated + if self.output is not None: + edge = _edge_share(propagated) + if edge > self.sampling_tolerance: + msg = ( + f"the propagated field puts {edge:.3e} of itself at the source " + "window's edge, and the angular output treats the source as zero " + "beyond it: enlarge the source window or use " + "output_method='fresnel'" + ) + raise GridError(msg) + result = propagated.with_spectrum( + _angular_output(propagated, self.output), space=self.output + ) + diagnostics["output_edge_share"] = _edge_share(result) + notes = ("output plane by direct angular-spectrum summation",) + diagnostics["delay_removed"] = delay + measured_out = _measure(result, _flux(result)) + report = _report( + f"FreeSpace(L={self.distance:g} m)", + measured_in, + measured_out, + specified_loss=absorbed, + removed=removed, + numerical_error=diagnostics["wrap_share"] * measured_in.energy, + extras=_Extras( + reconstruction=self.distance < 0, diagnostics=diagnostics, notes=notes + ), + ) + return ElementResult(result, report) + + def _limited( + self, state: SpatialState, flux: SpatialFlux, measured: _Measured + ) -> tuple[SpatialState, float]: + """Refuse evanescent content, or remove the declared angular band. + + Parameters + ---------- + state : SpatialState + The input plane. + flux : solprop.spatial.SpatialFlux + Its measure. + measured : _Measured + Its energy. + + Returns + ------- + tuple + The retained plane and the physical energy removed, J. + + Raises + ------ + GridError + If evanescent content exceeds its tolerance, or any remains for a + backward propagation. + """ + content = _angular_content(state, flux, 1.0) + if self.band_limit is None: + tolerance = self.evanescent_tolerance + if tolerance is None: + tolerance = 8 * state.space.n_columns * state.grid.n_omega * _EPS + share = 0.0 if content.total == 0 else content.evanescent / content.total + if share > tolerance or (self.distance < 0 and content.evanescent > 0): + msg = ( + f"the field puts {share:.3e} of its reference measure into " + "nonpropagating columns, which no forward plane carries; remove " + "them explicitly with band_limit= or widen the beam" + ) + raise GridError(msg) + return state, 0.0 + index = np.real(_index(state.medium, state.grid)) + reach = (index * state.grid.omega / C * math.sin(self.band_limit)) ** 2 + squares = _transverse_squares(state.space) + keep = (squares[:, None] <= reach[None, :]) & _band(state.grid)[None, :] + # A nonpropagating column is never kept, whatever the angle: sin <= 1. + kept = state.with_spectrum(keep[:, None, :] * state.spectrum) + return kept, measured.energy - spatial_energy(kept.spectrum, flux) + + def _wavenumber( + self, state: SpatialState, columns: slice + ) -> Complex[np.ndarray, "n_chunk n_omega"]: + """Return the longitudinal wave number of a chunk of columns. + + Parameters + ---------- + state : SpatialState + The plane. + columns : slice + Transverse columns. + + Returns + ------- + numpy.ndarray + :math:`k_z` in m^-1 with nonnegative imaginary part for a lossless + medium (decay of a nonpropagating column), shape ``(n_chunk, n_omega)``; + zero off band. + """ + grid = state.grid + band = _band(grid) + square = np.zeros(grid.n_omega, dtype=np.complex128) + square[band] = (grid.omega[band] * _index(state.medium, grid)[band] / C) ** 2 + longitudinal = square[None, :] - _transverse_squares(state.space)[columns, None] + # +0j keeps the principal root's branch at +i|k_z| on the cutoff side. + root = np.sqrt(longitudinal + 0j) + return np.where(band[None, :], root, 0.0) + + def _propagate( + self, state: SpatialState, flux: SpatialFlux, delay: float + ) -> tuple[SpatialState, float, dict[str, float]]: + """Propagate the stored spectrum exactly on its own grid. + + Parameters + ---------- + state : SpatialState + The (band-limited) input plane. + flux : solprop.spatial.SpatialFlux + Its measure. + delay : float + Delay removed from the whole field, s. + + Returns + ------- + tuple + The propagated plane, the energy absorbed by the medium in J, and the + wrap diagnostics. + """ + grid = state.grid + band = _band(grid) + spectrum = np.array(state.spectrum) + attenuated = np.array(state.spectrum) + wrap = _WrapCount() + for columns in flux.chunks(): + k_z = self._wavenumber(state, columns) + # Optics exp(+i k_z L - i omega tau) crosses as its conjugate. + exponent = -1j * np.conj(k_z) * self.distance + 1j * grid.omega * delay + factor = np.where(band[None, :], np.exp(exponent), 1.0) + spectrum[columns] *= factor[:, None, :] + attenuated[columns] *= np.abs(factor)[:, None, :] + wrap.add(state, columns, k_z, self.distance) + absorbed = spatial_energy(state.spectrum, flux) - spatial_energy( + attenuated, flux + ) + if np.all(np.imag(_index(state.medium, grid))[band] == 0): + absorbed = 0.0 + propagated = state.with_spectrum(spectrum) + edge = _edge_share(propagated) + share = wrap.share() + diagnostics = { + "wrap_share": share, + "max_ray_shift_ratio": wrap.max_ratio, + "edge_share": edge, + "sampling_adequate": float( + share <= self.sampling_tolerance and edge <= self.sampling_tolerance + ), + } + return propagated, absorbed, diagnostics + + def _fresnel( + self, + state: SpatialState, + kept: SpatialState, + measured_in: _Measured, + removed: float, + delay: float, + ) -> ElementResult: + """Evaluate the Fresnel integral of the source samples at the output nodes. + + Parameters + ---------- + state : SpatialState + The input plane, for the medium and grid. + kept : SpatialState + The band-limited input plane. + measured_in : _Measured + Energy of the input plane. + removed : float + Energy removed by the band limit, J. + delay : float + Delay removed from the whole field, s. + + Returns + ------- + ElementResult + The output plane and the report. + + Raises + ------ + GridError + If the distance is not positive or the kernel is undersampled on the + source grid. + """ + output = self.output + assert output is not None + if self.distance <= 0: + msg = "the Fresnel output needs a positive distance" + raise GridError(msg) + grid = state.grid + band = _band(grid) + index = _index(state.medium, grid) + samples = _to_field(kept.space, kept.spectrum) + real_index = np.real(index) + occupied = _occupied(_node_content(kept.space, grid, real_index, samples)) + source_x, source_y = _nodes(kept.space) + source_radius = np.hypot(source_x, source_y) + reach = float(np.max(source_radius[occupied], initial=0.0)) + out_x, out_y = _nodes(output) + out_reach = float(np.max(np.hypot(out_x, out_y))) + wavenumber = real_index * grid.omega / C + k_max = float(np.max(wavenumber[band], initial=0.0)) + spacing = _spacing(kept.space) + # The integrand's local spatial frequency is the source's own transverse + # wave number plus the kernel's chirp; content whose sum passes the nodes' + # Nyquist rate aliases in the node rule. + chirp = k_max * (reach + out_reach) / self.distance + step = chirp * spacing + aliased = _share_beyond(kept, real_index, math.pi / spacing - chirp) + if aliased > self.sampling_tolerance: + msg = ( + f"the Fresnel kernel's phase changes by {step:.3g} rad between source " + f"nodes, and {aliased:.3e} of the field would alias with it; refine " + "the source grid or move the plane further" + ) + raise GridError(msg) + bound = k_max * (reach + out_reach) ** 4 / (8 * self.distance**3) + field_out = np.zeros( + (output.n_columns, kept.n_pol, grid.n_omega), dtype=np.complex128 + ) + for bin_index in np.flatnonzero(band): + k = complex(index[bin_index]) * grid.omega[bin_index] / C + field_out[:, :, bin_index] = _fresnel_bin( + kept.space, output, samples[:, :, bin_index], k, self.distance + ) * np.exp(1j * grid.omega[bin_index] * delay) + result = kept.with_spectrum(_to_spectrum(output, field_out), space=output) + measured_out = _measure(result, _flux(result)) + absorbed = float( + np.sum( + measured_in.spectrum + * (1 - np.exp(-2 * np.imag(index) * grid.omega / C * self.distance)) + ) + ) + report = _report( + f"FreeSpace(L={self.distance:g} m, Fresnel output)", + measured_in, + measured_out, + specified_loss=absorbed, + removed=removed, + extras=_Extras( + diagnostics={ + "fresnel_phase_bound": bound, + "kernel_phase_step": step, + "kernel_alias_share": aliased, + "output_edge_share": _edge_share(result), + "delay_removed": delay, + }, + notes=("paraxial Fresnel kernel from the source samples",), + ), + ) + return ElementResult(result, report) + + +class _WrapCount: + """Accumulate the reference-measure share whose rays leave the window.""" + + def __init__(self) -> None: + """Start an empty count.""" + self.total = 0.0 + self.wrapped = 0.0 + self.max_ratio = 0.0 + + def add( + self, + state: SpatialState, + columns: slice, + k_z: Complex[np.ndarray, "n_chunk n_omega"], + distance: float, + ) -> None: + """Add a chunk of columns. + + A column at angle theta moves its rays by ``L tan(theta) = L kappa / k_z``; + beyond the window's half-width the periodic (Cartesian) or reflecting + (radial) representation folds them back. + + Parameters + ---------- + state : SpatialState + The plane. + columns : slice + Transverse columns. + k_z : numpy.ndarray + Their longitudinal wave numbers, shape ``(n_chunk, n_omega)``. + distance : float + Propagation distance in m. + """ + grid = state.grid + space = state.space + weights = fourier.energy_weights(grid.n_omega, grid.dt, kind=grid.kind) + index = np.real(_index(state.medium, grid)) + density = np.sum(np.abs(state.spectrum[columns]) ** 2, axis=1) + content = space.spectral_weights[columns, None] * (weights * index) * density + real = np.real(k_z) + if isinstance(space, RadialGrid): + ratios = [space.k_r[columns][:, None] / space.radius] + else: + k_x = np.tile(space.k_x, space.n_y)[columns] + k_y = np.repeat(space.k_y, space.n_x)[columns] + half_x = space.n_x * float(space.x[1] - space.x[0]) / 2 + half_y = space.n_y * float(space.y[1] - space.y[0]) / 2 + ratios = [np.abs(k_x)[:, None] / half_x, np.abs(k_y)[:, None] / half_y] + ratio = np.zeros(content.shape) + positive = real > 0 + for part in ratios: + shift = np.divide( + abs(distance) * np.broadcast_to(part, real.shape), + real, + out=np.zeros(real.shape), + where=positive, + ) + ratio = np.maximum(ratio, shift) + occupied = content > _OCCUPIED * float(np.max(content, initial=0.0)) + self.total += float(np.sum(content)) + self.wrapped += float(np.sum(content[ratio > 1])) + if np.any(occupied): + self.max_ratio = max(self.max_ratio, float(np.max(ratio[occupied]))) + + def share(self) -> float: + """Return the wrapped share. + + Returns + ------- + float + Share in [0, 1]; zero for a zero field. + """ + return 0.0 if self.total == 0 else self.wrapped / self.total + + +def _share_beyond( + state: SpatialState, index: Float[np.ndarray, " n_omega"], limit: float +) -> float: + """Return the reference-measure share in columns beyond a transverse wave number. + + Parameters + ---------- + state : SpatialState + The plane. + index : numpy.ndarray + Real index per bin, zero off band. + limit : float + Transverse wave number in rad/m; a nonpositive limit counts every column. + + Returns + ------- + float + Share in [0, 1]; zero for a zero field. + """ + grid = state.grid + weights = fourier.energy_weights(grid.n_omega, grid.dt, kind=grid.kind) + density = np.sum(np.abs(state.spectrum) ** 2, axis=1) + content = state.space.spectral_weights * np.sum((weights * index) * density, axis=1) + total = float(np.sum(content)) + if total == 0: + return 0.0 + beyond = _transverse_squares(state.space) > max(limit, 0.0) ** 2 + if limit <= 0: + beyond[:] = True + return float(np.sum(content[beyond])) / total + + +def _spacing(space: RadialGrid | FreeGrid) -> float: + """Return the largest spacing between neighbouring nodes. + + Parameters + ---------- + space : solprop.grid.RadialGrid or solprop.grid.FreeGrid + Transverse representation. + + Returns + ------- + float + Spacing in m. + """ + if isinstance(space, RadialGrid): + return float(np.max(np.diff(np.concatenate([[0.0], space.r])))) + return max(float(space.x[1] - space.x[0]), float(space.y[1] - space.y[0])) + + +def _angular_output( + state: SpatialState, output: RadialGrid | FreeGrid +) -> Complex[np.ndarray, "n_out n_pol n_omega"]: + r"""Sum a stored spectrum at another grid's nodes and transform it there. + + Radial: :math:`E(\rho)=\sum_jv_jH_jJ_0(k_j\rho)/2\pi`, the QDHT inverse + evaluated off its nodes. Cartesian: the trigonometric interpolant with its + first-sample origin, the Nyquist column split symmetrically. Nodes outside the + source window are zero. The kernel is built in blocks of output nodes; no + transverse-by-transverse-by-frequency array is formed. + + Parameters + ---------- + state : SpatialState + The source plane. + output : solprop.grid.RadialGrid or solprop.grid.FreeGrid + The output representation, of the same kind. + + Returns + ------- + numpy.ndarray + The output's stored spectrum, shape ``(n_out, n_pol, n_omega)``. + """ + space = state.space + spectrum = state.spectrum + flat = spectrum.reshape(spectrum.shape[0], -1) + if isinstance(space, RadialGrid): + assert isinstance(output, RadialGrid) + rho = np.asarray(output.r) + samples = np.zeros((rho.size, flat.shape[1]), dtype=np.complex128) + scale = space.spectral_weights / (2 * math.pi) + block = max(1, _CHUNK // space.n_r) + for start in range(0, rho.size, block): + part = rho[start : start + block] + kernel = j0(np.outer(part, space.k_r)) * scale[None, :] + values = kernel @ flat + samples[start : start + block] = np.where( + (part < space.radius)[:, None], values, 0.0 + ) + field_out = samples.reshape(rho.size, *spectrum.shape[1:]) + return _to_spectrum(output, field_out) + assert isinstance(output, FreeGrid) + kernel_x = _interpolant(space.x, space.k_x, output.x) + kernel_y = _interpolant(space.y, space.k_y, output.y) + cube = spectrum.reshape(space.n_y, space.n_x, -1) + n_inner = cube.shape[2] + block = max(1, _CHUNK // max(1, space.n_y * output.n_x)) + result = np.zeros((output.n_y, output.n_x, n_inner), dtype=np.complex128) + for start in range(0, n_inner, block): + part = cube[:, :, start : start + block] + along_x = np.einsum("yxp,xq->yqp", part, kernel_x) + result[:, :, start : start + block] = np.einsum( + "yqp,yr->rqp", along_x, kernel_y + ) + field_out = result.reshape(output.n_columns, *spectrum.shape[1:]) / space.n_columns + return _to_spectrum(output, field_out) + + +def _interpolant( + axis: Float[np.ndarray, " n_source"], + frequencies: Float[np.ndarray, " n_source"], + target: Float[np.ndarray, " n_target"], +) -> Complex[np.ndarray, "n_source n_target"]: + """Return the trigonometric interpolation kernel of one periodic axis. + + Parameters + ---------- + axis : numpy.ndarray + Source sample positions in m, uniform, shape ``(n_source,)``. + frequencies : numpy.ndarray + Their DFT angular frequencies in rad/m, FFT order. + target : numpy.ndarray + Output positions in m, shape ``(n_target,)``. + + Returns + ------- + numpy.ndarray + ``exp(i k (x' - x_0))``, zero for outputs outside the source period; the + Nyquist row of an even axis is the cosine, so a real field stays real. + """ + origin = float(axis[0]) + spacing = float(axis[1] - axis[0]) + offset = target - origin + kernel = np.exp(1j * np.outer(frequencies, offset)) + count = axis.size + if count % 2 == 0: + kernel[count // 2] = np.cos(frequencies[count // 2] * offset) + inside = (offset >= -spacing / 2) & (offset < count * spacing - spacing / 2) + return kernel * inside[None, :] + + +def _fresnel_bin( + source: RadialGrid | FreeGrid, + output: RadialGrid | FreeGrid, + samples: Complex[np.ndarray, "n_source n_pol"], + wavenumber: complex, + distance: float, +) -> Complex[np.ndarray, "n_target n_pol"]: + r"""Evaluate the paraxial Fresnel integral at one frequency. + + Optics convention, crossed to the stored samples by conjugating the kernel: + radial :math:`\frac{k}{iL}e^{ikL}e^{ik\rho^2/2L}\sum_m\frac{v_m}{2\pi}E_m + e^{ikr_m^2/2L}J_0(k\rho r_m/L)`, Cartesian + :math:`\frac{k}{2\pi iL}e^{ikL}\sum_nE_ne^{ik|\boldsymbol\rho-\mathbf r_n|^2/2L} + \Delta A`, separable in x and y. The real part of ``k`` sets the phases and + the imaginary part attenuates through ``exp(ikL)`` only. + + Parameters + ---------- + source, output : solprop.grid.RadialGrid or solprop.grid.FreeGrid + Source and output representations, of one kind. + samples : numpy.ndarray + Source field samples at one bin, shape ``(n_source, n_pol)``. + wavenumber : complex + :math:`k = \omega n / c` in m^-1. + distance : float + Positive distance in m. + + Returns + ------- + numpy.ndarray + Output samples at that bin, shape ``(n_target, n_pol)``. + """ + k = wavenumber.real + prefactor = (wavenumber / (1j * distance)) * np.exp(1j * wavenumber * distance) + common = np.conj(prefactor) + if isinstance(source, RadialGrid): + assert isinstance(output, RadialGrid) + rho = np.asarray(output.r) + weights = source.radial_weights / (2 * math.pi) + chirp_in = np.exp(-1j * k * source.r**2 / (2 * distance)) + inner = samples * (weights * chirp_in)[:, None] + values = np.zeros((rho.size, samples.shape[1]), dtype=np.complex128) + block = max(1, _CHUNK // source.n_r) + for start in range(0, rho.size, block): + part = rho[start : start + block] + kernel = j0(np.outer(part, source.r) * (k / distance)) + values[start : start + block] = kernel @ inner + chirp = np.exp(-1j * k * rho**2 / (2 * distance)) + return common * chirp[:, None] * values + assert isinstance(output, FreeGrid) + assert isinstance(source, FreeGrid) + phase = k / (2 * distance) + kernel_x = np.exp(-1j * phase * np.subtract.outer(output.x, source.x) ** 2) + kernel_y = np.exp(-1j * phase * np.subtract.outer(output.y, source.y) ** 2) + area = float(source.spatial_weights[0]) + plane = samples.reshape(source.n_y, source.n_x, -1) + values = np.einsum("ry,yxp,qx->rqp", kernel_y, plane, kernel_x, optimize=True) + scale = common / (2 * math.pi) * area + return scale * values.reshape(output.n_columns, -1) From 1518d035bf3c0072a2d986a2780a82ff16c939f0 Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 00:53:10 +0100 Subject: [PATCH 08/50] SEP 31 transport: ordered transport with a sequential ledger transport() applies elements in order, adapts a Chain or known transfer function as one frequency-only block at its position, and sums losses, removals and approximation errors in joules. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019qNYqtCKh4iTKj7544yARb --- src/solprop/coupling/transport.py | 187 ++++++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 src/solprop/coupling/transport.py diff --git a/src/solprop/coupling/transport.py b/src/solprop/coupling/transport.py new file mode 100644 index 000000000..aa4eb4fc0 --- /dev/null +++ b/src/solprop/coupling/transport.py @@ -0,0 +1,187 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Transport a spatial field through an ordered sequence of linear elements (SEP 31). + +:func:`transport` applies spatial and spectral elements in the order given and keeps +a sequential ledger in joules (SEP 31 §4.7). A frequency-only block -- an +:class:`solprop.optics.Chain` or any known :class:`solprop.optics.TransferFunction` +-- may appear directly in the sequence; it is adapted by +:class:`solprop.coupling.elements.SpectralTransfer`, reported at its position, and +never moved past a spatial element. Losses add in joules and the per-frequency +energy spectra stay in every report; broadband efficiencies are never multiplied. +""" + +from collections.abc import Sequence +from dataclasses import dataclass, replace + +from solprop.coupling.elements import ( + Element, + ElementReport, + SpatialState, + SpectralTransfer, +) +from solprop.exceptions import GridError +from solprop.optics import TransferFunction +from solprop.spatial import SpatialFlux, spatial_energy + +__all__ = ["TransportReport", "TransportResult", "transport"] + + +@dataclass(frozen=True, eq=False) +class TransportReport: + """The sequential ledger of a transport. + + Every element's input energy is the previous element's output energy, so + ``energy_out = energy_in - specified_loss - removed + approximation_error`` + holds for the whole sequence as for each element, to the rounding of the sums. + + Attributes + ---------- + elements : tuple of ElementReport + One report per element, in order, each with its position. + energy_in, energy_out : float + Energy crossing the first input and the last output plane, J. + specified_loss : float + Sum of the declared losses, J. + removed : float + Sum of the intentional removals, J. + approximation_error : float + Sum of the signed approximation errors, J. + numerical_error : float + Sum of the numerical error estimates, J; a conservative composition. + """ + + elements: tuple[ElementReport, ...] + energy_in: float + energy_out: float + specified_loss: float + removed: float + approximation_error: float + numerical_error: float + + @property + def ledger_residual(self) -> float: + """Return the part of the energy change the ledger does not account for. + + Returns + ------- + float + ``energy_out - (energy_in - specified_loss - removed + + approximation_error)`` in J; rounding only. + """ + explained = self.energy_in - self.specified_loss - self.removed + return self.energy_out - (explained + self.approximation_error) + + +@dataclass(frozen=True, eq=False) +class TransportResult: + """The transported plane and its ledger. + + Attributes + ---------- + state : SpatialState + The field at the last element's output plane. + report : TransportReport + The sequential accounting. + """ + + state: SpatialState + report: TransportReport + + +def _as_element(item: object, position: int) -> Element: + """Return an element, adapting a frequency-only transfer function. + + Parameters + ---------- + item : object + An element, or an :class:`solprop.optics.TransferFunction` such as a chain. + position : int + Its position, for messages. + + Returns + ------- + Element + The element itself, or its :class:`SpectralTransfer` adapter. + + Raises + ------ + GridError + If the item is neither. + """ + if isinstance(item, Element): + return item + if isinstance(item, TransferFunction): + return SpectralTransfer(item) + msg = ( + f"item {position} of a transport sequence is neither an element nor a " + f"transfer function: {type(item).__name__}" + ) + raise GridError(msg) + + +def transport(state: SpatialState, elements: Sequence[object]) -> TransportResult: + """Apply an ordered sequence of linear elements to a field plane. + + Parameters + ---------- + state : solprop.coupling.elements.SpatialState + The field at the first plane. + elements : sequence + Elements of :mod:`solprop.coupling.elements`, applied in order. A + :class:`solprop.optics.Chain` or another known transfer function is one + frequency-only block at its position; an unknown callable needs an explicit + :class:`solprop.coupling.elements.SpectralTransfer` with its calibration. + + Returns + ------- + TransportResult + The last plane and the sequential ledger. + + Raises + ------ + GridError + If an item is neither an element nor a transfer function. + + Examples + -------- + >>> import numpy as np + >>> from solprop.coupling.elements import FreeSpace, SpatialState, ThinLens + >>> from solprop.coupling.transport import transport + >>> from solprop.grid import EnvelopeGrid, RadialGrid + >>> from solprop.pulses import GaussianPulse, SpatialPulse + >>> from solprop.spatial import SpatialMedium + >>> grid = EnvelopeGrid.from_limits(0.1, 800e-9, (750e-9, 850e-9), 200e-15) + >>> space, air = RadialGrid(4e-3, 128), SpatialMedium() + >>> beam = SpatialPulse(GaussianPulse(wavelength=800e-9, fwhm=30e-15, + ... energy=1e-6), 1e-3) + >>> plane = SpatialState(grid, space, beam.build(grid, space, medium=air), air) + >>> result = transport(plane, [ThinLens(1.0, model_error=1e-3), + ... FreeSpace(0.5, air)]) + >>> f"{result.report.energy_out:.4e} J" + '1.0000e-06 J' + """ + current = state + reports: list[ElementReport] = [] + for position, item in enumerate(elements): + applied = _as_element(item, position).apply(current) + reports.append(replace(applied.report, position=position)) + current = applied.state + if reports: + energy_in = reports[0].energy_in + energy_out = reports[-1].energy_out + else: + flux = SpatialFlux.resolve(state.grid, state.space, state.medium) + energy_in = energy_out = spatial_energy(state.spectrum, flux) + report = TransportReport( + elements=tuple(reports), + energy_in=energy_in, + energy_out=energy_out, + specified_loss=sum(item.specified_loss for item in reports), + removed=sum(item.removed for item in reports), + approximation_error=sum(item.approximation_error for item in reports), + numerical_error=sum(item.numerical_error for item in reports), + ) + return TransportResult(current, report) From e06de690784913dc38565667920a5a4456d56cd5 Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 00:53:10 +0100 Subject: [PATCH 09/50] SEP 31 transport: tests against analytic and independent references Gaussian waist, curvature and Gouy phase; direct Fresnel against the angular spectrum and adaptive quadrature; first-order convergence to Fraunhofer; the Airy pattern; the thin-lens Fourier-plane waist; radial against Cartesian; alignment, mirror and Jones signs; Fresnel R + T = 1; spectral attenuation, efficiencies and calibration records; thin-screen certificates; refusals. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019qNYqtCKh4iTKj7544yARb --- tests/coupling/__init__.py | 3 + tests/coupling/conftest.py | 64 ++++ tests/coupling/test_elements.py | 515 ++++++++++++++++++++++++++ tests/coupling/test_transport.py | 596 +++++++++++++++++++++++++++++++ 4 files changed, 1178 insertions(+) create mode 100644 tests/coupling/__init__.py create mode 100644 tests/coupling/conftest.py create mode 100644 tests/coupling/test_elements.py create mode 100644 tests/coupling/test_transport.py diff --git a/tests/coupling/__init__.py b/tests/coupling/__init__.py new file mode 100644 index 000000000..2774627f4 --- /dev/null +++ b/tests/coupling/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later diff --git a/tests/coupling/conftest.py b/tests/coupling/conftest.py new file mode 100644 index 000000000..c89f5b7ea --- /dev/null +++ b/tests/coupling/conftest.py @@ -0,0 +1,64 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Shared beams and grids for the transport tests. + +A 30 fs pulse at 800 nm on a narrow envelope grid keeps every transport cheap while +leaving a band of several dozen frequencies for the per-bin ledger. +""" + +from collections.abc import Callable + +import numpy as np +import pytest + +from solprop.coupling.elements import SpatialState +from solprop.grid import EnvelopeGrid, FreeGrid, RadialGrid +from solprop.pulses import GaussianPulse, SpatialPulse +from solprop.spatial import SpatialMedium + +WAVELENGTH = 800e-9 +ENERGY = 1e-6 + +BeamFactory = Callable[..., SpatialState] + + +@pytest.fixture(scope="module") +def grid() -> EnvelopeGrid: + """Return a narrow envelope grid around 800 nm.""" + return EnvelopeGrid.from_limits(0.1, WAVELENGTH, (750e-9, 850e-9), 200e-15) + + +@pytest.fixture(scope="module") +def air() -> SpatialMedium: + """Return vacuum, whose index is exactly one.""" + return SpatialMedium() + + +@pytest.fixture(scope="module") +def centre(grid: EnvelopeGrid) -> int: + """Return the storage index of the bin nearest the carrier.""" + return int(np.argmin(np.abs(grid.omega - grid.omega0))) + + +@pytest.fixture(scope="module") +def beam(grid: EnvelopeGrid, air: SpatialMedium) -> BeamFactory: + """Return a factory of Gaussian or super-Gaussian beam planes.""" + + def make( # noqa: PLR0913 - the beam's independent parameters + space: RadialGrid | FreeGrid, + waist: float, + *, + medium: SpatialMedium = air, + order: int = 1, + jones: tuple[complex, ...] = (1.0 + 0.0j,), + fwhm: float = 30e-15, + wavelength: float = WAVELENGTH, + ) -> SpatialState: + pulse = GaussianPulse(wavelength=wavelength, fwhm=fwhm, energy=ENERGY) + spatial = SpatialPulse(pulse, waist, order=order, jones=jones) + spectrum = spatial.build(grid, space, medium=medium) + return SpatialState(grid, space, spectrum, medium) + + return make diff --git a/tests/coupling/test_elements.py b/tests/coupling/test_elements.py new file mode 100644 index 000000000..f8d91a275 --- /dev/null +++ b/tests/coupling/test_elements.py @@ -0,0 +1,515 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Test reflection frames, interfaces, spectral optics, thin screens and refusals.""" + +import math +from collections.abc import Callable + +import numpy as np +import pytest + +from solprop.constants import C +from solprop.coupling.elements import ( + Alignment, + Aperture, + FreeSpace, + Mirror, + PlanarInterface, + PolarisationTransform, + SpatialState, + SpectralCalibration, + SpectralTransfer, + ThinLens, + TransmissionMask, +) +from solprop.coupling.transport import transport +from solprop.exceptions import ( + ConvergenceError, + GridError, + MaterialError, + OpticsGeometryError, + PolarisationError, +) +from solprop.grid import EnvelopeGrid, FreeGrid, LineGrid, RadialGrid +from solprop.optics import ( + Chain, + ChirpedMirror, + GratingPair, + Material, + TaylorPhase, + littrow_angle, +) +from solprop.profiles import Linear +from solprop.spatial import SpatialMedium + +BeamFactory = Callable[..., SpatialState] +EPS = np.finfo(float).eps + + +def _lab_field(state: SpatialState, column: int, bin_index: int) -> np.ndarray: + """Return a column's optics-convention field vector in laboratory axes.""" + stored = state.spectrum[column, :, bin_index] + jones = np.conj(stored) + if jones.size == 1: + jones = np.array([jones[0], 0.0]) + return jones[0] * state.frame[0] + jones[1] * state.frame[1] + + +def _reflection(incident: np.ndarray, outgoing: np.ndarray) -> np.ndarray: + """Return the improper reflection taking one direction into the other.""" + normal = outgoing - incident + normal = normal / np.linalg.norm(normal) + return np.eye(3) - 2 * np.outer(normal, normal) + + +CONDUCTOR = {"reflectivity_p": 1.0 + 0j, "reflectivity_s": -1.0 + 0j} + + +@pytest.mark.parametrize("s_sign", [1.0, -1.0]) +def test_perfect_mirror_reverses_the_tangential_field_at_normal_incidence( + beam: BeamFactory, centre: int, s_sign +): + space = RadialGrid(1e-3, 32) + state = beam(space, 0.3e-3, jones=(1.0, 0.4 + 0.3j)) + mirror = Mirror( + **CONDUCTOR, incident=(0, 0, 1), outgoing=(0, 0, -1), s_axis=(0, s_sign, 0) + ) + result = mirror.apply(state) + # A perfect conductor cancels the tangential field: E_out = -E_in in the lab. + np.testing.assert_allclose( + _lab_field(result.state, 3, centre), + -_lab_field(state, 3, centre), + rtol=0, + atol=8 * EPS * np.max(np.abs(state.spectrum)), + ) + np.testing.assert_allclose(result.state.frame[2], [0, 0, -1]) + assert np.linalg.det(result.state.frame) == pytest.approx(1.0) + back = Mirror( + **CONDUCTOR, incident=(0, 0, -1), outgoing=(0, 0, 1), s_axis=(0, 1, 0) + ) + twice = back.apply(result.state) + np.testing.assert_allclose(twice.state.frame, np.eye(3), atol=1e-15) + np.testing.assert_allclose( + twice.state.spectrum, + state.spectrum, + rtol=0, + atol=8 * EPS * np.max(np.abs(state.spectrum)), + ) + assert result.report.specified_loss == pytest.approx(0.0, abs=1e-15 * 1e-6) + + +def test_dielectric_mirror_reflects_isotropically_at_normal_incidence( + beam: BeamFactory, centre: int +): + space = RadialGrid(1e-3, 32) + state = beam(space, 0.3e-3, jones=(0.6, 0.8j)) + r_s = (1 - 1.5) / (1 + 1.5) + # With p = s x k on both beams, normal incidence has r_p = -r_s. + mirror = Mirror( + -r_s, r_s, incident=(0, 0, 1), outgoing=(0, 0, -1), s_axis=(1, 0, 0) + ) + result = mirror.apply(state) + np.testing.assert_allclose( + _lab_field(result.state, 0, centre), + r_s * _lab_field(state, 0, centre), + rtol=1e-14, + ) + assert result.report.specified_loss == pytest.approx( + (1 - r_s**2) * result.report.energy_in, rel=1e-12 + ) + + +def test_fold_mirrors_follow_the_physical_reflection(beam: BeamFactory, centre: int): + space = RadialGrid(1e-3, 32) + state = beam(space, 0.3e-3, jones=(0.6, 0.8j)) + z, x, y = np.eye(3)[2], np.eye(3)[0], np.eye(3)[1] + first = Mirror(**CONDUCTOR, incident=z, outgoing=x, s_axis=y).apply(state) + # A perfect conductor's reflected field is -M E for the reflection M. + np.testing.assert_allclose( + _lab_field(first.state, 0, centre), + -_reflection(z, x) @ _lab_field(state, 0, centre), + atol=1e-12 * np.max(np.abs(state.spectrum)), + ) + np.testing.assert_allclose(first.state.frame[2], x, atol=1e-15) + second = Mirror(**CONDUCTOR, incident=x, outgoing=z, s_axis=y).apply(first.state) + np.testing.assert_allclose(second.state.frame, np.eye(3), atol=1e-15) + np.testing.assert_allclose( + _lab_field(second.state, 0, centre), + _reflection(x, z) @ _reflection(z, x) @ _lab_field(state, 0, centre), + atol=1e-12 * np.max(np.abs(state.spectrum)), + ) + + +def test_mirror_images_a_displaced_cartesian_beam(beam: BeamFactory, centre: int): + space = FreeGrid(1.5e-3, 32, 1.5e-3, 32) + shift = 0.2e-3 + state = Alignment(displacement=(shift, 0.0)).apply(beam(space, 0.3e-3)).state + result = Mirror( + **CONDUCTOR, incident=(0, 0, 1), outgoing=(0, 0, -1), s_axis=(0, 1, 0) + ).apply(state) + samples = np.fft.ifft2( + result.state.spectrum[:, 0, centre].reshape(space.n_y, space.n_x) + ).ravel() + intensity = np.abs(samples) ** 2 + local = np.sum(np.tile(space.x, space.n_y) * intensity) / np.sum(intensity) + # The outgoing x axis is the lab -x, so the beam sits at -d locally and at the + # same lab position. + assert local == pytest.approx(-shift, rel=1e-9) + np.testing.assert_allclose(result.state.frame[0], [-1, 0, 0], atol=1e-15) + + +def test_mirror_and_jones_refusals(beam: BeamFactory): + space = RadialGrid(1e-3, 32) + state = beam(space, 0.3e-3) + with pytest.raises(OpticsGeometryError, match="perpendicular"): + Mirror(**CONDUCTOR, incident=(0, 0, 1), outgoing=(0, 0, -1), s_axis=(0, 1, 1)) + with pytest.raises(OpticsGeometryError, match="three-vector"): + Mirror(**CONDUCTOR, incident=(0, 0, 0), outgoing=(0, 0, -1), s_axis=(0, 1, 0)) + with pytest.raises(OpticsGeometryError, match="incident direction"): + Mirror( + **CONDUCTOR, incident=(1, 0, 0), outgoing=(0, 0, 1), s_axis=(0, 1, 0) + ).apply(state) + with pytest.raises(OpticsGeometryError, match="changes the propagation"): + Mirror( + **CONDUCTOR, incident=(0, 0, 1), outgoing=(0, 0, 1), s_axis=(0, 1, 0) + ).apply(state) + with pytest.raises(PolarisationError, match="singular values"): + Mirror( + 1.2, 1.0, incident=(0, 0, 1), outgoing=(0, 0, -1), s_axis=(0, 1, 0) + ).apply(state) + with pytest.raises(PolarisationError, match="singular values"): + PolarisationTransform(np.array([[1.0, 1.0], [0.0, 1.0]])).apply(state) + with pytest.raises(PolarisationError, match="2x2"): + PolarisationTransform(np.eye(3)).apply(state) + cartesian = FreeGrid(1e-3, 16, 1e-3, 16) + with pytest.raises(GridError, match="oblique"): + Mirror( + **CONDUCTOR, + incident=(0, 0, 1), + outgoing=(0, 0, -1), + s_axis=(1, 1, 0), + ).apply(beam(cartesian, 0.3e-3)) + odd = FreeGrid(1e-3, 15, 1e-3, 16) + with pytest.raises(GridError, match="odd sample count"): + Mirror( + **CONDUCTOR, incident=(0, 0, 1), outgoing=(0, 0, -1), s_axis=(0, 1, 0) + ).apply(beam(odd, 0.3e-3)) + + +def test_polarisation_transform_promotes_a_scalar_state_and_keeps_energy( + beam: BeamFactory, centre: int +): + space = RadialGrid(1e-3, 32) + state = beam(space, 0.3e-3) + quarter = np.array([[1.0, 0.0], [0.0, 1j]]) + kept = PolarisationTransform(quarter).apply(state) + assert kept.state.n_pol == 1 + rotation = np.array( + [[math.cos(0.3), -math.sin(0.3)], [math.sin(0.3), math.cos(0.3)]] + ) + rotated = PolarisationTransform(rotation).apply(state) + assert rotated.state.n_pol == 2 + field = _lab_field(rotated.state, 0, centre) + original = _lab_field(state, 0, centre) + np.testing.assert_allclose(field[:2], rotation @ original[:2], rtol=1e-14) + report = rotated.report + assert abs(report.specified_loss) <= 1e-14 * report.energy_in + assert abs(report.approximation_error) <= 1e-14 * report.energy_in + polariser = PolarisationTransform(np.diag([math.sqrt(0.3), 0.0])).apply(state) + assert polariser.report.energy_out == pytest.approx( + 0.3 * polariser.report.energy_in, rel=1e-12 + ) + + +def test_fresnel_interface_conserves_energy_and_tracks_the_medium( + beam: BeamFactory, grid: EnvelopeGrid, air: SpatialMedium +): + glass = SpatialMedium(1.5) + space = RadialGrid(4e-3, 64) + state = beam(space, 1e-3) + interface = PlanarInterface(air, glass) + result = interface.apply(state) + report = result.report + reflectance, transmittance = 0.04, 1.5 * (2 / 2.5) ** 2 + assert reflectance + transmittance == pytest.approx(1.0, abs=4 * EPS) + assert report.diagnostics["fresnel_defect"] <= 4 * EPS + assert report.specified_loss == pytest.approx( + reflectance * report.energy_in, rel=1e-12 + ) + # A 1 mm beam's obliquity theta^2 ~ 6e-8 is all that departs from R + T = 1. + assert report.energy_out == pytest.approx( + transmittance * report.energy_in, rel=1e-7 + ) + assert abs(report.approximation_error) <= 1e-7 * report.energy_in + assert result.state.medium is glass + with pytest.raises(MaterialError, match="applied once"): + interface.apply(result.state) + with pytest.raises(MaterialError, match="PlanarInterface"): + FreeSpace(0.1, air).apply(result.state) + FreeSpace(0.1, glass).apply(result.state) + with pytest.raises(MaterialError, match="lossless"): + PlanarInterface(air, SpatialMedium(1.5 + 0.01j)).apply(state) + assert grid.n_omega > 0 + + +def test_spectral_transfer_attenuates_by_its_squared_modulus( + beam: BeamFactory, grid: EnvelopeGrid +): + space = RadialGrid(1e-3, 32) + state = beam(space, 0.3e-3) + lossy = Material("SiO2", 0.02, 800e-9, loss=True) + result = SpectralTransfer(lossy).apply(state) + band = grid.active_mask & (grid.omega > 0) + factor = np.abs(lossy(grid.omega[band])) ** 2 + report = result.report + np.testing.assert_allclose( + report.spectrum_out[band], factor * report.spectrum_in[band], rtol=1e-12 + ) + assert report.specified_loss == pytest.approx( + report.energy_in - report.energy_out, rel=1e-12 + ) + assert report.calibration[0].removes_delay is True + assert report.calibration[0].removes_phase is False + + +def test_phase_only_transfer_is_invariant_and_efficiency_is_per_frequency( + beam: BeamFactory, grid: EnvelopeGrid +): + space = RadialGrid(1e-3, 32) + gdd = TaylorPhase((0.0, 0.0, 500e-30), 800e-9) + narrow = beam(space, 0.3e-3, fwhm=60e-15) + broad = beam(space, 0.3e-3, fwhm=15e-15, wavelength=790e-9) + invariant = SpectralTransfer(gdd).apply(narrow).report + assert invariant.energy_out == pytest.approx(invariant.energy_in, rel=1e-14) + assert invariant.specified_loss == pytest.approx(0.0, abs=1e-14 * 1e-6) + + def efficiency(omega: np.ndarray) -> np.ndarray: + return 0.5 + 0.4 * np.tanh((omega - 2.355e15) / 5e13) + + band = grid.active_mask & (grid.omega > 0) + ratios = [] + for plane in (narrow, broad): + report = ( + SpectralTransfer(gdd, efficiency=efficiency, predict_loss=True) + .apply(plane) + .report + ) + np.testing.assert_allclose( + report.spectrum_out[band], + efficiency(grid.omega[band]) * report.spectrum_in[band], + rtol=1e-12, + ) + ratios.append(report.energy_out / report.energy_in) + # The broadband efficiency depends on the spectrum it is applied to. + assert abs(ratios[0] - ratios[1]) > 1e-2 + + +def test_spectral_transfer_refusals_and_records(beam: BeamFactory, grid: EnvelopeGrid): + space = RadialGrid(1e-3, 32) + state = beam(space, 0.3e-3) + gdd = TaylorPhase((0.0, 0.0, 500e-30), 800e-9) + with pytest.raises(MaterialError, match="explicit efficiency"): + SpectralTransfer(gdd, predict_loss=True).apply(state) + with pytest.raises(MaterialError, match="twice"): + SpectralTransfer(Material("SiO2", 1e-3, loss=True), efficiency=0.9).apply(state) + with pytest.raises(MaterialError, match=r"\[0, 1\]"): + SpectralTransfer(gdd, efficiency=1.2).apply(state) + with pytest.raises(MaterialError, match="calibration"): + transport(state, [lambda omega: np.ones_like(omega, dtype=complex)]) + record = SpectralCalibration(True, True, 800e-9, "measured compressor") + custom = SpectralTransfer( + lambda omega: np.exp(1j * 1e-30 * (omega - 2.35e15) ** 2), calibration=record + ).apply(state) + assert custom.report.calibration == (record,) + inverse = SpectralTransfer(ChirpedMirror.lookup("PC70").bounces(-2)).apply(state) + assert inverse.report.reconstruction + assert inverse.report.calibration[0].removes_phase is True + negative = SpectralTransfer(Material("SiO2", -1e-3, 800e-9)).apply(state) + assert negative.report.reconstruction + chained = SpectralTransfer(Chain((gdd, Material("SiO2", -1e-3)))).apply(state) + assert chained.report.reconstruction + assert chained.report.calibration[1].removes_delay is False + grating = GratingPair( + 600e3, + 0.01, + littrow_angle(600e3, 800e-9), + 800e-9, + band=(790e-9, 810e-9), + ) + selected = SpectralTransfer(grating).apply(state).report + band = grid.active_mask & (grid.omega > 0) + wavelength = 2 * math.pi * C / grid.omega[band] + outside = (wavelength < 790e-9) | (wavelength > 810e-9) + assert selected.removed == pytest.approx( + float(np.sum(selected.spectrum_in[band][outside])), rel=1e-12 + ) + assert selected.calibration[0].removes_phase is True + assert abs(selected.approximation_error) <= 1e-14 * selected.energy_in + + +def test_thin_screens_stay_inside_their_certificates(beam: BeamFactory): + space = RadialGrid(4e-3, 256) + state = beam(space, 1e-3) + lens = ThinLens(0.5, model_error=1e-3).apply(state) + certificate = lens.report.certificate + assert certificate is not None + # A phase screen keeps U_0; its axial-energy change stays inside the defects. + assert lens.report.specified_loss == 0.0 + assert abs(lens.report.approximation_error) <= ( + certificate.defect_in + certificate.defect_out + ) + aperture = Aperture(1e-3, model_error=1e-2, edge=0.1e-3, kind="tanh").apply(state) + certificate = aperture.report.certificate + assert certificate is not None + assert aperture.report.specified_loss > 0 + # L0 minus the axial decrement is bounded by the same defects (SEP 31 4.6). + assert abs(aperture.report.approximation_error) <= ( + certificate.defect_in + certificate.defect_out + ) + chromatic = ThinLens(lambda omega: 0.5 * (omega / 2.35e15) ** 2, model_error=1e-3) + assert chromatic.apply(state).report.element == "ThinLens(chromatic f)" + + +def test_thin_screen_refusals(beam: BeamFactory, grid: EnvelopeGrid): + space = RadialGrid(4e-3, 256) + state = beam(space, 1e-3) + with pytest.raises(ConvergenceError, match="model_error"): + ThinLens(0.5, model_error=1e-7).apply(state) + with pytest.raises(ConvergenceError, match=r"\(0, 1\)"): + ThinLens(0.5, model_error=0.0).apply(state) + with pytest.raises(GridError, match="Nyquist"): + ThinLens(5e-3, model_error=0.5).apply(state) + with pytest.raises(MaterialError, match="nonzero focal"): + ThinLens(0.0, model_error=1e-3).apply(state) + with pytest.raises(MaterialError, match="passive"): + TransmissionMask(np.full(space.n_r, 1.1), model_error=1e-3).apply(state) + with pytest.raises(MaterialError, match="unit modulus"): + TransmissionMask( + np.full(space.n_r, 0.5), model_error=1e-3, phase_only=True + ).apply(state) + with pytest.raises(MaterialError, match="n_nodes"): + TransmissionMask(np.ones(3), model_error=1e-3).apply(state) + with pytest.raises(MaterialError, match="broadcast"): + TransmissionMask(lambda x, y, omega: np.ones(3), model_error=1e-3).apply( + state.with_spectrum(state.spectrum, space=space) + ) + + +def test_radial_grids_refuse_symmetry_breaking(beam: BeamFactory): + space = RadialGrid(4e-3, 64) + state = beam(space, 1e-3) + with pytest.raises(GridError, match="azimuth"): + TransmissionMask( + lambda x, y, omega: np.exp(-((x / 2e-3) ** 2))[:, None] + 0 * omega, + model_error=1e-2, + ).apply(state) + symmetric = TransmissionMask( + lambda x, y, omega: np.exp(-((x**2 + y**2) / 4e-6))[:, None] + 0 * omega, + model_error=1e-2, + ).apply(state) + assert symmetric.report.specified_loss > 0 + with pytest.raises(GridError, match="off-axis"): + Aperture(1e-3, model_error=1e-2, centre=(1e-4, 0.0)).apply(state) + with pytest.raises(GridError, match="cylindrical symmetry"): + Alignment(displacement=(1e-4, 0.0)).apply(state) + with pytest.raises(GridError, match="cylindrical symmetry"): + Alignment(tilt=(1e-3, 0.0), model_error=1e-3).apply(state) + assert Alignment().apply(state).report.element == "Alignment(none)" + + +def test_cartesian_masks_and_alignment_refusals(beam: BeamFactory): + space = FreeGrid(1.5e-3, 32, 1.5e-3, 32) + state = beam(space, 0.3e-3) + offset = Aperture( + 0.3e-3, model_error=0.05, edge=50e-6, kind="rcos", centre=(0.1e-3, 0.0) + ).apply(state) + assert offset.report.specified_loss > 0 + phase = TransmissionMask( + lambda x, y, omega: np.exp(1j * 1e3 * x)[:, None] + 0 * omega, + model_error=1e-3, + phase_only=True, + ).apply(state) + assert phase.report.specified_loss == 0.0 + with pytest.raises(ConvergenceError, match="tilt"): + Alignment(tilt=(1e-3, 0.0)).apply(state) + with pytest.raises(GridError, match="finite"): + Alignment(tilt=(2.0, 0.0), model_error=1e-3).apply(state) + + +def test_state_validation(grid: EnvelopeGrid, air: SpatialMedium, beam: BeamFactory): + space = RadialGrid(1e-3, 16) + zero = np.zeros((16, 1, grid.n_omega), complex) + with pytest.raises(GridError, match="line grid"): + SpatialState(grid, LineGrid(1e-3, 16), zero, air) + with pytest.raises(GridError, match="shape"): + SpatialState(grid, space, np.zeros((15, 1, grid.n_omega), complex), air) + outside = zero.copy() + outside[0, 0, np.flatnonzero(~grid.active_mask)[0]] = 1.0 + with pytest.raises(GridError, match="outside the active band"): + SpatialState(grid, space, outside, air) + taper = SpatialMedium("Ar", Linear(1.0, 2.0, 0.1)) + with pytest.raises(MaterialError, match="homogeneous"): + SpatialState(grid, space, zero, taper) + with pytest.raises(GridError, match="right-handed"): + SpatialState(grid, space, zero, air, np.diag([1.0, 1.0, -1.0])) + state = beam(space, 0.3e-3) + assert not state.spectrum.flags.writeable + assert not state.frame.flags.writeable + with pytest.raises(GridError, match="neither"): + transport(state, [3]) + + +def test_nonfinite_and_out_of_range_inputs_are_refused( + beam: BeamFactory, grid: EnvelopeGrid, air: SpatialMedium +): + space = RadialGrid(1e-3, 32) + state = beam(space, 0.3e-3) + with pytest.raises(MaterialError, match="finite on every band"): + ThinLens(lambda omega: np.full(omega.shape, np.nan), model_error=1e-3).apply( + state + ) + with pytest.raises(MaterialError, match="transmission must be finite"): + TransmissionMask(np.full(space.n_r, np.nan), model_error=1e-3).apply(state) + with pytest.raises(PolarisationError, match="finite"): + PolarisationTransform(np.full((2, 2), np.inf)).apply(state) + record = SpectralCalibration(None, None, None) + with pytest.raises(MaterialError, match=r"\|H\| <= 1"): + SpectralTransfer( + lambda omega: np.full(omega.shape, 2.0 + 0j), calibration=(record,) + ).apply(state) + unknown_member = Chain((lambda omega: np.ones(omega.shape, complex),)) + declared = SpectralTransfer(unknown_member, calibration=record).apply(state) + assert declared.report.energy_out == pytest.approx( + declared.report.energy_in, rel=1e-14 + ) + with pytest.raises(MaterialError, match="known optics element"): + SpectralTransfer(Chain((lambda omega: np.ones(omega.shape, complex),))).apply( + state + ) + with pytest.raises(GridError, match="finite"): + FreeSpace(math.inf, air).apply(state) + unknown = FreeSpace(0.1, air) + object.__setattr__(unknown, "output_method", "spherical") + with pytest.raises(GridError, match="output_method"): + unknown.apply(state) + samples = np.ones((space.n_r, int(np.sum(grid.active_mask & (grid.omega > 0))))) + sampled = TransmissionMask(0.5 * samples, model_error=1e-3).apply(state) + assert sampled.report.energy_out == pytest.approx( + 0.25 * sampled.report.energy_in, rel=1e-10 + ) + + +def test_a_field_with_no_forward_flux_cannot_be_certified( + grid: EnvelopeGrid, air: SpatialMedium +): + space = RadialGrid(10e-6, 64) + band = grid.active_mask & (grid.omega > 0) + spectrum = np.zeros((space.n_r, 1, grid.n_omega), complex) + # Every column beyond the largest band wave number is nonpropagating. + beyond = space.k_r > 1.1 * np.max(grid.omega[band]) / C + spectrum[beyond, 0, :] = band + state = SpatialState(grid, space, spectrum, air) + with pytest.raises(GridError, match="no forward flux"): + ThinLens(1.0, model_error=1e-3).apply(state) diff --git a/tests/coupling/test_transport.py b/tests/coupling/test_transport.py new file mode 100644 index 000000000..5750f4c73 --- /dev/null +++ b/tests/coupling/test_transport.py @@ -0,0 +1,596 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Test homogeneous transport, observation planes and the sequential ledger.""" + +import itertools +import math +from collections.abc import Callable + +import numpy as np +import pytest +from scipy.integrate import quad +from scipy.special import j0, j1 + +from solprop import fourier +from solprop.constants import C +from solprop.coupling.elements import ( + Alignment, + Aperture, + FreeSpace, + SpatialState, + ThinLens, +) +from solprop.coupling.transport import transport +from solprop.exceptions import GridError, MaterialError +from solprop.grid import EnvelopeGrid, FieldGrid, FreeGrid, RadialGrid +from solprop.optics import Chain, TaylorPhase +from solprop.pulses import GaussianPulse, SpatialPulse +from solprop.spatial import ( + SpatialFlux, + SpatialMedium, + aperture_profile, + spatial_energy, + spatial_power, +) + +BeamFactory = Callable[..., SpatialState] + + +def _radial_field(space: RadialGrid, state: SpatialState, bin_index: int) -> np.ndarray: + """Return the optics-convention field samples of one bin.""" + return np.conj(space.hankel.inverse(state.spectrum[:, 0, bin_index])) + + +def _on_axis(state: SpatialState, bin_index: int) -> complex: + """Return the optics-convention on-axis field of one bin, from the spectrum.""" + column = state.spectrum[:, 0, bin_index] + space = state.space + if isinstance(space, RadialGrid): + return complex(np.conj(np.sum(space.spectral_weights / (2 * np.pi) * column))) + return complex(np.conj(np.sum(space.on_axis * column))) + + +def _second_moment_radius(space: RadialGrid, field: np.ndarray) -> float: + """Return the 1/e^2 radius of a Gaussian from its second intensity moment.""" + intensity = np.abs(field) ** 2 + weights = space.radial_weights + return math.sqrt( + 2 * np.sum(weights * space.r**2 * intensity) / np.sum(weights * intensity) + ) + + +def _wavenumber(grid: EnvelopeGrid, bin_index: int) -> float: + """Return the vacuum wave number of one bin.""" + return float(grid.omega[bin_index] / C) + + +@pytest.mark.parametrize("fraction", [0.5, 1.0, 2.0]) +def test_gaussian_waist_curvature_and_gouy_phase( + beam: BeamFactory, grid: EnvelopeGrid, air: SpatialMedium, centre: int, fraction +): + waist = 100e-6 + space = RadialGrid(1.2e-3, 128) + state = beam(space, waist) + k = _wavenumber(grid, centre) + rayleigh = k * waist**2 / 2 + distance = fraction * rayleigh + result = FreeSpace(distance, air, frame_delay=0.0).apply(state) + radius = waist * math.hypot(1.0, distance / rayleigh) + curvature = distance * (1 + (rayleigh / distance) ** 2) + gouy = math.atan(distance / rayleigh) + ratio = _on_axis(result.state, centre) / _on_axis(state, centre) + field = _radial_field(space, result.state, centre) + inside = space.r < radius + phase = np.unwrap(np.angle(field[inside] / field[0])) + fitted = k / (2 * np.polyfit(space.r[inside] ** 2, phase, 1)[0]) + # The exact angular spectrum departs from the paraxial Gaussian by + # O(theta^2) ~ (lambda/(pi w0))^2 = 6.5e-6 of the width and curvature, and the + # on-axis phase by k L theta^4 / 8 < 1e-6 rad; the grid is converged beyond that. + assert abs(ratio) == pytest.approx(waist / radius, rel=2e-5) + assert np.angle(ratio * np.exp(-1j * k * distance)) == pytest.approx( + -gouy, abs=2e-5 + ) + assert _second_moment_radius(space, field) == pytest.approx(radius, rel=2e-5) + assert fitted == pytest.approx(curvature, rel=2e-5) + # Free space conserves the physical energy; the ledger closes at rounding. + assert result.report.specified_loss == 0.0 + assert abs(result.report.approximation_error) <= 64 * np.finfo(float).eps * ( + result.report.energy_in + ) + assert result.report.diagnostics["sampling_adequate"] == 1.0 + + +def test_direct_fresnel_matches_the_angular_spectrum_and_quadrature( + beam: BeamFactory, grid: EnvelopeGrid, air: SpatialMedium, centre: int +): + """Two independent references for the direct Bessel evaluation of an output.""" + waist = 20e-6 + k = _wavenumber(grid, centre) + rayleigh = k * waist**2 / 2 + source = RadialGrid(0.4e-3, 256) + state = beam(source, waist) + output = RadialGrid(0.2e-3, 128) + distance = 2 * rayleigh + angular = FreeSpace(distance, air, output=output).apply(state) + fresnel = FreeSpace(distance, air, output=output, output_method="fresnel").apply( + state + ) + first = angular.state.spectrum[:, 0, centre] + second = fresnel.state.spectrum[:, 0, centre] + # The two differ by the paraxial approximation alone: k L theta^4/8 with + # theta ~ 2 lambda/(pi w0) is ~1e-4 rad at the beam's edge. + assert np.linalg.norm(first - second) <= 2e-4 * np.linalg.norm(first) + assert fresnel.report.diagnostics["fresnel_phase_bound"] > 0 + + # A soft-edged flat top at Fresnel number ~1, against adaptive quadrature of + # the same Fresnel integral. + radius, edge = 100e-6, 5e-6 + source = RadialGrid(0.5e-3, 512) + flat = beam(source, 0.3e-3, order=10) + clipped = Aperture(radius, model_error=0.05, edge=edge, kind="rcos").apply(flat) + distance = radius**2 * k / (2 * math.pi) + observed = RadialGrid(0.25e-3, 64) + # The 5 um edge leaves 2e-6 of the field near the source grid's Nyquist rate, + # where the kernel's chirp would alias it; the comparison below is coarser. + result = FreeSpace( + distance, + air, + output=observed, + output_method="fresnel", + frame_delay=0.0, + sampling_tolerance=1e-5, + ).apply(clipped.state) + field = _radial_field(observed, result.state, centre) + amplitude = _radial_field(source, flat, centre)[0] + expected = [] + for rho in observed.r[[0, 10, 30]]: + + def integrand(r, rho=rho, part=np.real): + profile = math.sqrt(aperture_profile(r, radius, edge, "rcos")) + value = profile * np.exp(1j * k * r**2 / (2 * distance)) + return float(part(value * j0(k * rho * r / distance) * r)) + + upper = radius + edge + real = quad(integrand, 0, upper, limit=400, epsabs=1e-16)[0] + imag = quad(integrand, 0, upper, args=(rho, np.imag), limit=400)[0] + factor = k / (1j * distance) * np.exp(1j * k * distance) + factor *= np.exp(1j * k * rho**2 / (2 * distance)) + expected.append(amplitude * factor * (real + 1j * imag)) + # The QDHT node rule integrates the 5 um edge on 1 um nodes; the comparison is + # limited by that quadrature and by the flat top's residual curvature. + np.testing.assert_allclose( + field[[0, 10, 30]], expected, rtol=2e-3, atol=2e-3 * abs(expected[0]) + ) + + +def test_finite_distance_converges_to_fraunhofer_at_first_order( + beam: BeamFactory, grid: EnvelopeGrid, air: SpatialMedium, centre: int +): + waist = 20e-6 + k = _wavenumber(grid, centre) + rayleigh = k * waist**2 / 2 + source = RadialGrid(0.12e-3, 96) + state = beam(source, waist) + amplitude = _radial_field(source, state, centre)[0] / math.exp( + -((source.r[0] / waist) ** 2) + ) + errors = [] + for ratio in (10.0, 20.0, 40.0): + distance = ratio * rayleigh + output = RadialGrid(4 * waist * ratio, 128) + result = FreeSpace( + distance, air, output=output, output_method="fresnel", frame_delay=0.0 + ).apply(state) + field = _radial_field(output, result.state, centre) + # SEP 31 section 4.6 with the continuous Hankel transform of the Gaussian, + # (w^2/2) exp(-q^2 w^2/4) at q = k r / L. + spatial_frequency = k * output.r / distance + fraunhofer = ( + k + * np.exp(1j * k * distance) + / (1j * distance) + * np.exp(1j * k * output.r**2 / (2 * distance)) + * amplitude + * waist**2 + / 2 + * np.exp(-((spatial_frequency * waist) ** 2) / 4) + ) + weights = output.radial_weights + errors.append( + math.sqrt( + np.sum(weights * np.abs(field - fraunhofer) ** 2) + / np.sum(weights * np.abs(fraunhofer) ** 2) + ) + ) + # The neglected source phase k r^2/(2L) makes the error z_R/L: first order. + assert errors[0] < 0.1 + assert errors[0] / errors[1] == pytest.approx(2.0, rel=0.05) + assert errors[1] / errors[2] == pytest.approx(2.0, rel=0.05) + + +def test_aperture_far_field_is_the_airy_pattern( + beam: BeamFactory, grid: EnvelopeGrid, air: SpatialMedium, centre: int +): + radius, distance = 100e-6, 0.5 + source = RadialGrid(0.5e-3, 512) + flat = beam(source, 0.3e-3, order=10) + output = RadialGrid(8e-3, 256) + result = transport( + flat, + [ + Aperture(radius, model_error=0.05, edge=2e-6, kind="rcos"), + # The 2 um edge puts 4e-6 of the field where the kernel aliases it. + FreeSpace( + distance, + air, + output=output, + output_method="fresnel", + frame_delay=0.0, + sampling_tolerance=1e-5, + ), + ], + ) + k = _wavenumber(grid, centre) + field = _radial_field(output, result.state, centre) + peak = abs(_on_axis(result.state, centre)) ** 2 + argument = k * radius * output.r / distance + airy = (2 * j1(argument) / argument) ** 2 + inside = output.r < 6e-3 + # Fresnel number a^2/(lambda L) = 0.025 and a 2 um soft edge set the departure + # from the hard-edge Fraunhofer pattern at a few 1e-3 of the peak. + assert np.max(np.abs(np.abs(field[inside]) ** 2 / peak - airy[inside])) < 1e-2 + first_zero = 3.8317059702 * distance / (k * radius) + dark = np.argmin(np.where(output.r < 1.5 * first_zero, np.abs(field), np.inf)) + assert output.r[dark] == pytest.approx(first_zero, rel=0.03) + + +def test_thin_lens_focus_matches_the_fourier_plane_waist( + beam: BeamFactory, grid: EnvelopeGrid, air: SpatialMedium, centre: int +): + waist, focal = 1e-3, 0.2 + space = RadialGrid(4e-3, 512) + state = beam(space, waist) + k = _wavenumber(grid, centre) + expected = 2 * focal / (k * waist) # lambda f / (pi w0) + focused = transport( + state, [ThinLens(focal, model_error=1e-3), FreeSpace(focal, air)] + ) + spot = _second_moment_radius(space, _radial_field(space, focused.state, centre)) + # The back focal plane is the paraxial Fourier plane, exactly; the remainder + # is the angular spectrum's O(theta^2) = 2.5e-5 correction. + assert spot == pytest.approx(expected, rel=5e-5) + zoom = RadialGrid(0.3e-3, 128) + zoomed = transport( + state, [ThinLens(focal, model_error=1e-3), FreeSpace(focal, air, output=zoom)] + ) + zoom_spot = _second_moment_radius(zoom, _radial_field(zoom, zoomed.state, centre)) + assert zoom_spot == pytest.approx(spot, rel=1e-6) + # Resampling onto a finer, smaller plane keeps the energy to its window. + assert zoomed.report.energy_out == pytest.approx(zoomed.report.energy_in, rel=1e-5) + certificate = focused.report.elements[0].certificate + assert certificate is not None + assert certificate.ratio <= certificate.model_error + assert certificate.scope == "single field" + + +@pytest.mark.parametrize("n_samples", [48, 64]) +def test_radial_and_cartesian_agree_for_a_symmetric_beam( + beam: BeamFactory, air: SpatialMedium, centre: int, n_samples +): + radial = RadialGrid(1.5e-3, 128) + cartesian = FreeGrid(1.5e-3, n_samples, 1.5e-3, n_samples) + elements = [ThinLens(0.3, model_error=1e-3), FreeSpace(0.1, air)] + first = transport(beam(radial, 0.3e-3), elements) + second = transport(beam(cartesian, 0.3e-3), elements) + # Both representations are spectrally converged at these sizes (the 32-sample + # grid still departs by 3e-5). + assert _on_axis(second.state, centre) == pytest.approx( + _on_axis(first.state, centre), rel=1e-8 + ) + assert second.report.energy_out == pytest.approx(first.report.energy_out, rel=1e-12) + + +def test_cartesian_displacement_and_tilt_follow_the_synthesis_map( + beam: BeamFactory, air: SpatialMedium, centre: int +): + space = FreeGrid(1.5e-3, 64, 1.5e-3, 64) + state = beam(space, 0.3e-3) + shift, angle, distance = (0.1e-3, -0.05e-3), (2e-3, 0.0), 0.1 + result = transport( + state, + [ + Alignment(displacement=shift, tilt=angle, model_error=1e-3), + FreeSpace(distance, air), + ], + ) + samples = np.fft.ifft2( + result.state.spectrum[:, 0, centre].reshape(space.n_y, space.n_x) + ).ravel() + intensity = np.abs(samples) ** 2 + x = np.tile(space.x, space.n_y) + y = np.repeat(space.y, space.n_x) + # Positive displacement moves the beam towards +x; a positive tilt sends it + # towards +x by L tan(theta) after propagation. + expected = shift[0] + distance * math.tan(angle[0]) + assert np.sum(x * intensity) / np.sum(intensity) == pytest.approx( + expected, rel=1e-5 + ) + assert np.sum(y * intensity) / np.sum(intensity) == pytest.approx( + shift[1], rel=1e-9 + ) + + +def test_element_order_matters_and_a_chain_commutes_only_internally( + beam: BeamFactory, air: SpatialMedium +): + space = RadialGrid(1.5e-3, 128) + state = beam(space, 0.3e-3) + aperture = Aperture(0.25e-3, model_error=0.05, edge=20e-6, kind="rcos") + gap = FreeSpace(0.2, air) + first = transport(state, [aperture, gap]) + second = transport(state, [gap, aperture]) + assert not np.allclose(first.state.spectrum, second.state.spectrum, rtol=1e-3) + gdd = TaylorPhase((0.0, 0.0, 200e-30), 800e-9) + tod = TaylorPhase((0.0, 0.0, 0.0, 3000e-45), 800e-9) + forward = transport(state, [Chain((gdd, tod)), gap]) + backward = transport(state, [Chain((tod, gdd)), gap]) + np.testing.assert_allclose( + forward.state.spectrum, + backward.state.spectrum, + rtol=0, + atol=1e-15 * np.max(np.abs(forward.state.spectrum)), + ) + report = forward.report.elements[0] + assert report.position == 0 + assert forward.report.elements[1].position == 1 + assert report.element == "SpectralTransfer(Chain)" + assert len(report.notes) == 2 + assert len(report.calibration) == 2 + + +def test_sequential_ledger_adds_losses_in_joules(beam: BeamFactory, air: SpatialMedium): + # The clipped beam's edge diffraction must stay inside the window: the lens's + # certificate counts the field at the window's edge as domain error. + space = RadialGrid(3e-3, 256) + state = beam(space, 0.3e-3) + result = transport( + state, + [ + Aperture(0.3e-3, model_error=0.05, edge=20e-6, kind="rcos"), + FreeSpace(0.1, air), + ThinLens(0.5, model_error=1e-3), + Aperture(0.2e-3, model_error=0.05, edge=20e-6, kind="rcos"), + ], + ) + report = result.report + reports = report.elements + for before, after in itertools.pairwise(reports): + assert after.energy_in == before.energy_out + assert report.specified_loss == pytest.approx( + sum(item.specified_loss for item in reports), rel=1e-15 + ) + assert abs(report.ledger_residual) <= 1e-14 * report.energy_in + assert report.energy_out < report.energy_in + # Every report keeps its per-bin spectra, which sum to its energy. + for item in reports: + assert np.sum(item.spectrum_out) == pytest.approx(item.energy_out, rel=1e-12) + # The losses are not a product of broadband efficiencies. + product = math.prod(item.energy_out / item.energy_in for item in reports) + assert report.energy_out / report.energy_in == pytest.approx(product, rel=1e-12) + + +def test_energy_spectrum_splits_spatial_energy(beam: BeamFactory, air: SpatialMedium): + space = RadialGrid(1.5e-3, 64) + state = beam(space, 0.3e-3) + report = FreeSpace(0.0, air).apply(state).report + flux = SpatialFlux.resolve(state.grid, space, air) + assert np.sum(report.spectrum_in) == pytest.approx( + spatial_energy(state.spectrum, flux), rel=1e-13 + ) + assert report.energy_in == pytest.approx(1e-6, rel=1e-12) + + +def test_default_frame_delay_keeps_a_field_grid_pulse_in_its_window(): + grid = FieldGrid.from_limits(0.1, 800e-9, (650e-9, 1000e-9), 300e-15) + argon = SpatialMedium("Ar", 1.0) + space = RadialGrid(2e-3, 32) + pulse = GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=1e-6) + state = SpatialState( + grid, space, SpatialPulse(pulse, 0.5e-3).build(grid, space, medium=argon), argon + ) + result = FreeSpace(1.0, argon).apply(state) + + def arrival(plane: SpatialState) -> float: + flux = SpatialFlux.resolve(grid, space, argon) + power = spatial_power(plane.spectrum, flux) + return float(np.sum(grid.t * power) / np.sum(power)) + + # beta1 L = 3.3 ns for a metre of argon, ten thousand windows; removing it + # leaves only the dispersive and diffractive reshaping, well under a femtosecond. + assert result.report.diagnostics["delay_removed"] > 3e-9 + assert abs(arrival(result.state) - arrival(state)) <= 1e-15 + assert result.report.energy_out == pytest.approx(result.report.energy_in, rel=1e-12) + + +def test_backward_propagation_round_trips_and_is_flagged( + beam: BeamFactory, air: SpatialMedium +): + space = RadialGrid(1.5e-3, 128) + state = beam(space, 0.3e-3) + there = FreeSpace(0.2, air).apply(state) + back = FreeSpace(-0.2, air).apply(there.state) + assert back.report.reconstruction + assert not there.report.reconstruction + np.testing.assert_allclose( + back.state.spectrum, + state.spectrum, + rtol=0, + atol=1e-12 * np.max(np.abs(state.spectrum)), + ) + + +def _narrow_state(grid: EnvelopeGrid, air: SpatialMedium) -> SpatialState: + """Return a beam narrower than a wavelength, built without a launch check.""" + space = RadialGrid(20e-6, 64) + profile = np.exp(-((space.r / 0.3e-6) ** 2)) + envelope = np.exp(-((grid.t / 30e-15) ** 2)) + 0j + temporal = fourier.forward(envelope, kind=grid.kind) + temporal = temporal * (grid.active_mask & (grid.omega > 0)) + spectrum = space.hankel.forward(profile)[:, None, None] * temporal[None, None, :] + return SpatialState(grid, space, spectrum, air) + + +def test_evanescent_content_is_refused_or_removed_explicitly( + grid: EnvelopeGrid, air: SpatialMedium +): + state = _narrow_state(grid, air) + with pytest.raises(GridError, match="nonpropagating"): + FreeSpace(1e-6, air).apply(state) + limited = FreeSpace(1e-6, air, band_limit=0.5).apply(state) + report = limited.report + assert report.removed > 0 + assert abs(report.approximation_error) <= 1e-12 * report.energy_in + assert report.energy_out == pytest.approx( + report.energy_in - report.removed, rel=1e-12 + ) + + +def test_output_plane_refusals(beam: BeamFactory, air: SpatialMedium): + space = RadialGrid(0.5e-3, 64) + state = beam(space, 0.1e-3) + wide = RadialGrid(5e-3, 64) + # The beam spreads past the source window: the angular output cannot see it. + with pytest.raises(GridError, match="edge"): + FreeSpace(1.0, air, output=wide).apply(state) + # A short Fresnel distance undersamples the kernel on the source nodes. + with pytest.raises(GridError, match="Fresnel kernel"): + FreeSpace(1e-3, air, output=wide, output_method="fresnel").apply(state) + with pytest.raises(GridError, match="positive distance"): + FreeSpace(-1.0, air, output=wide, output_method="fresnel").apply(state) + with pytest.raises(GridError, match="same kind"): + FreeSpace(0.1, air, output=FreeGrid(1e-3, 8, 1e-3, 8)).apply(state) + with pytest.raises(MaterialError, match="PlanarInterface"): + FreeSpace(0.1, SpatialMedium(1.5)).apply(state) + with pytest.raises(GridError, match="band_limit"): + FreeSpace(0.1, air, band_limit=2.0).apply(state) + + +def test_an_empty_sequence_and_a_zero_field_are_exact( + grid: EnvelopeGrid, air: SpatialMedium +): + space = RadialGrid(1e-3, 32) + zero = SpatialState(grid, space, np.zeros((32, 1, grid.n_omega), complex), air) + identity = transport(zero, []) + assert identity.report.energy_in == identity.report.energy_out == 0.0 + result = transport(zero, [ThinLens(0.5, model_error=1e-3), FreeSpace(0.1, air)]) + assert not np.any(result.state.spectrum) + certificate = result.report.elements[0].certificate + assert certificate is not None + assert certificate.exact_zero + with pytest.raises(GridError, match="neither an element"): + transport(zero, [object()]) + + +def test_cartesian_output_planes_agree_with_radial( + beam: BeamFactory, air: SpatialMedium, centre: int +): + """The Cartesian direct summations against the radial ones, for one beam.""" + lens = ThinLens(0.5, model_error=1e-3) + radial = beam(RadialGrid(1.5e-3, 128), 0.3e-3) + cartesian = beam(FreeGrid(1.5e-3, 64, 1.5e-3, 64), 0.3e-3) + zoom_r = RadialGrid(0.9e-3, 96) + zoom_c = FreeGrid(0.9e-3, 48, 0.9e-3, 48) + angular_r = transport(radial, [lens, FreeSpace(0.1, air, output=zoom_r)]) + angular_c = transport(cartesian, [lens, FreeSpace(0.1, air, output=zoom_c)]) + # Both are converged trigonometric/Fourier-Bessel interpolants of converged + # propagations; the on-axis values agree to their transforms' accuracy. + assert _on_axis(angular_c.state, centre) == pytest.approx( + _on_axis(angular_r.state, centre), rel=1e-8 + ) + far_r = RadialGrid(2.5e-3, 128) + far_c = FreeGrid(2.5e-3, 48, 2.5e-3, 48) + # A finer Cartesian source keeps the wider kernel's chirp below its Nyquist rate. + cartesian = beam(FreeGrid(1.5e-3, 96, 1.5e-3, 96), 0.3e-3) + fresnel_r = transport( + radial, [lens, FreeSpace(1.0, air, output=far_r, output_method="fresnel")] + ) + fresnel_c = transport( + cartesian, [lens, FreeSpace(1.0, air, output=far_c, output_method="fresnel")] + ) + # The radial and Cartesian node rules integrate the same smooth integrand; + # each output window holds the beam to 1e-7 of its energy. + assert _on_axis(fresnel_c.state, centre) == pytest.approx( + _on_axis(fresnel_r.state, centre), rel=1e-6 + ) + assert fresnel_c.report.energy_out == pytest.approx( + fresnel_r.report.energy_out, rel=1e-6 + ) + # The exact propagation on a window wide enough to hold the beam agrees with + # both to the paraxial bound the report states. + exact = transport(beam(RadialGrid(3e-3, 256), 0.3e-3), [lens, FreeSpace(1.0, air)]) + bound = fresnel_r.report.elements[1].diagnostics["fresnel_phase_bound"] + assert _on_axis(fresnel_r.state, centre) == pytest.approx( + _on_axis(exact.state, centre), rel=max(bound, 1e-6) + ) + + +def test_windows_without_a_collar_use_their_outer_tenth( + beam: BeamFactory, air: SpatialMedium +): + for space in ( + RadialGrid(0.6e-3, 64, window_factor=0.0), + FreeGrid(0.6e-3, 32, 0.6e-3, 32, window_factor=0.0), + ): + state = beam(space, 0.15e-3) + report = FreeSpace(0.5, air).apply(state).report + # Half a metre spreads the 150 um beam well past this window. + assert report.diagnostics["edge_share"] > 1e-3 + assert report.diagnostics["sampling_adequate"] == 0.0 + assert report.diagnostics["wrap_share"] > 0 + + +def test_an_absorbing_gap_reports_its_loss(beam: BeamFactory): + lossy = SpatialMedium(1.0 + 1e-7j) + space = RadialGrid(1.5e-3, 64) + state = beam(space, 0.3e-3, medium=lossy) + report = FreeSpace(0.1, lossy).apply(state).report + # Im(n) = 1e-7 attenuates the power by exp(-2 k Im(n) L) ~ 0.86 over 0.1 m. + assert 0.1 < report.specified_loss / report.energy_in < 0.2 + assert abs(report.approximation_error) <= 1e-12 * report.energy_in + explicit = FreeSpace(0.1, lossy, evanescent_tolerance=0.0).apply(state).report + assert explicit.energy_out == report.energy_out + + +@pytest.mark.parametrize( + "space", + [ + RadialGrid(1e-3, 48), + FreeGrid(1e-3, 24, 1e-3, 24), + FreeGrid(1e-3, 25, 1e-3, 23), + ], +) +def test_resampling_onto_the_source_nodes_is_the_identity( + beam: BeamFactory, air: SpatialMedium, space +): + state = beam(space, 0.2e-3) + result = FreeSpace(0.0, air, output=space).apply(state) + # Direct summation at the source's own nodes is its inverse transform, so the + # round trip is the transforms' rounding (the raw QDHT is near-self-inverse). + np.testing.assert_allclose( + result.state.spectrum, + state.spectrum, + rtol=0, + atol=1e-12 * np.max(np.abs(state.spectrum)), + ) + + +def test_a_zero_field_reaches_a_fresnel_plane(grid: EnvelopeGrid, air: SpatialMedium): + space = RadialGrid(1e-3, 32) + zero = SpatialState(grid, space, np.zeros((32, 1, grid.n_omega), complex), air) + result = FreeSpace( + 1.0, air, output=RadialGrid(2e-3, 16), output_method="fresnel" + ).apply(zero) + assert not np.any(result.state.spectrum) + assert result.report.diagnostics["kernel_alias_share"] == 0.0 From c4d36ca52fa77df3eeac43b49e234fdbfa400d64 Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 00:56:14 +0100 Subject: [PATCH 10/50] Coupling: the field plane, its port and temporal remapping (SEP 31) A physics-layer package for plane exchange: FieldPlane with its reference, geometry, metric, basis identity and provenance; InputPort and the bound CouplingLaunch; the same_basis identity model; and regrid, the direct finite-time Fourier remap with its loss report. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019qNYqtCKh4iTKj7544yARb --- src/solprop/__init__.py | 1 + src/solprop/coupling/__init__.py | 75 ++ src/solprop/coupling/plane.py | 1358 ++++++++++++++++++++++++++++++ src/solprop/coupling/port.py | 465 ++++++++++ src/solprop/coupling/remap.py | 554 ++++++++++++ src/solprop/exceptions.py | 5 + tests/coupling/__init__.py | 3 + tests/coupling/test_plane.py | 292 +++++++ tests/coupling/test_port.py | 171 ++++ tests/coupling/test_remap.py | 296 +++++++ 10 files changed, 3220 insertions(+) create mode 100644 src/solprop/coupling/__init__.py create mode 100644 src/solprop/coupling/plane.py create mode 100644 src/solprop/coupling/port.py create mode 100644 src/solprop/coupling/remap.py create mode 100644 tests/coupling/__init__.py create mode 100644 tests/coupling/test_plane.py create mode 100644 tests/coupling/test_port.py create mode 100644 tests/coupling/test_remap.py diff --git a/src/solprop/__init__.py b/src/solprop/__init__.py index 401e0f692..b7551d4be 100644 --- a/src/solprop/__init__.py +++ b/src/solprop/__init__.py @@ -38,6 +38,7 @@ "backends", "boundaries", "constants", + "coupling", "driver", "exceptions", "fourier", diff --git a/src/solprop/coupling/__init__.py b/src/solprop/coupling/__init__.py new file mode 100644 index 000000000..f1208eb12 --- /dev/null +++ b/src/solprop/coupling/__init__.py @@ -0,0 +1,75 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Couple simulations through a coherent field at a reference plane (SEP 31). + +A physics-layer package: NumPy, SciPy and other physics modules only. A +:class:`FieldPlane` is one physical plane of a field in its native representation, +with its references, geometry, medium, metric and provenance; :func:`regrid` moves +it onto another temporal lattice or reference; an :class:`InputPort` describes what +a receiving simulation accepts at its entrance, and :func:`same_basis` launches a +plane into a port of the same basis. Extraction from a saved run is +:func:`solprop.processing.field_plane`; installing a launch is +:meth:`solprop.driver.Simulation.with_input`. The manual page is +``docs/guide/workflows/coupling.md``. +""" + +from solprop.coupling.plane import ( + PLANE_KINDS, + Calibration, + Exterior, + FieldPlane, + NodalField, + PlaneBasis, + PlaneGeometry, + PlaneKind, + PlaneMetric, + PlaneProvenance, + PlaneReference, + basis_identity, + lattice_identity, + modal_basis_identity, + reference_at, + space_record, +) +from solprop.coupling.port import ( + ENTRANCE_TOLERANCE, + METRIC_TOLERANCE, + CouplingLaunch, + InputPort, + same_basis, +) +from solprop.coupling.remap import DEFAULT_TOLERANCE, RegridReport, regrid + +# The transport core (solprop.coupling.transport and solprop.coupling.elements: +# transport, FreeSpace, ThinLens, Aperture, TransmissionMask, PlanarInterface, +# PolarisationTransform, Mirror, SpectralTransfer, Outcouple) is exported here when +# it lands, beside the names below. + +__all__ = [ + "DEFAULT_TOLERANCE", + "ENTRANCE_TOLERANCE", + "METRIC_TOLERANCE", + "PLANE_KINDS", + "Calibration", + "CouplingLaunch", + "Exterior", + "FieldPlane", + "InputPort", + "NodalField", + "PlaneBasis", + "PlaneGeometry", + "PlaneKind", + "PlaneMetric", + "PlaneProvenance", + "PlaneReference", + "RegridReport", + "basis_identity", + "lattice_identity", + "modal_basis_identity", + "reference_at", + "regrid", + "same_basis", + "space_record", +] diff --git a/src/solprop/coupling/plane.py b/src/solprop/coupling/plane.py new file mode 100644 index 000000000..750141116 --- /dev/null +++ b/src/solprop/coupling/plane.py @@ -0,0 +1,1358 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +r"""One physical plane of an ultrafast field, with everything needed to use it. + +A :class:`FieldPlane` binds a field's **native coefficients** to what they mean +(SEP 31, sections 4.1, 4.5 and 5.1): the representation and its units, the temporal +lattice, the reference the spectrum is written in, the oriented plane, the medium and +power metric there, the scoped path certificate of the run that produced it, and its +provenance. It is immutable and owns read-only copies of its arrays, so a validated +plane cannot be changed through an alias. + +**Samples.** The stored array is what an output file stores: the optics-convention +raw forward DFT of each column, referenced to the centre of the time window +(:func:`solprop.fourier.spectrum_to_optics`), shape ``(n_modes, n_pol, n_omega)``. +Its columns are + +================= ================================================================== +``kind`` columns and units +================= ================================================================== +``modal`` true-power coefficients ``b = sqrt(Re n_eff) a`` of an ordinary + mode set, sqrt(W); metric the identity +``general_modal`` general-modal coordinates, sqrt(W); metric the basis Gram +``radial`` order-zero Hankel spectrum of the electric field, V m +``cartesian`` transverse raw DFT of the electric field, V/m +``planar`` slab amplitude per y frequency, sqrt(W/m) +================= ================================================================== + +**Reference.** A run's spectrum is in its moving frame. The absolute +optics-convention spectrum is the stored one times +:math:`\exp[i(\phi + \omega\tau)]` (:class:`PlaneReference`), with ω the absolute +angular frequency of each bin: τ is the absolute time of the record's ``t = 0`` and +φ a carrier phase. The pair is the frame integral the linear operator removed, +:math:`\int R\,dz`, so a long path never has to translate a femtosecond pulse +across its window. + +The plane is a P-layer value type: NumPy and other physics modules only. Extracting +one from a run is :func:`solprop.processing.field_plane`, and a receiving simulation +is described by :class:`solprop.coupling.InputPort`. +""" + +import dataclasses +import hashlib +import json +import math +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field +from types import MappingProxyType +from typing import Literal, cast + +import numpy as np +from jaxtyping import Complex, Float + +from solprop import fourier +from solprop.exceptions import CouplingError +from solprop.grid import FreeGrid, RadialGrid, TimeGrid +from solprop.modes.protocol import Mode +from solprop.modes.slab import PlanarGrid +from solprop.spatial import FluxSide, SpatialFlux, SpatialMedium + +__all__ = [ + "PLANE_KINDS", + "Calibration", + "Exterior", + "FieldPlane", + "NodalField", + "PlaneBasis", + "PlaneGeometry", + "PlaneKind", + "PlaneMetric", + "PlaneProvenance", + "PlaneReference", + "basis_identity", + "lattice_identity", + "modal_basis_identity", + "reference_at", + "space_record", +] + +PlaneKind = Literal["modal", "general_modal", "radial", "cartesian", "planar"] +"""The native representation of a plane's columns.""" + +PLANE_KINDS: tuple[PlaneKind, ...] = ( + "modal", + "general_modal", + "radial", + "cartesian", + "planar", +) +"""Every :data:`PlaneKind`, in a fixed order.""" + +Exterior = Literal["zero", "unknown"] +"""What a finite record says outside its samples: zero, or not known.""" + +Calibration = Literal["phase", "delay"] +"""An affine reference term a plane knows absolutely.""" + +MetricKind = Literal["identity", "dense", "admittance", "unknown"] +"""How a plane's native power metric is represented.""" + +_UNIT_TOLERANCE = 1e-12 +"""Allowed departure of a declared axis from unit length and orthogonality.""" + + +def _frozen( + values: object, dtype: type, name: str, shape: tuple[int, ...] | None = None +) -> np.ndarray: + """Return an owned, read-only, finite copy of an array. + + Parameters + ---------- + values : object + Anything :func:`numpy.array` accepts. + dtype : type + The dtype of the copy. + name : str + The attribute name, for the refusal. + shape : tuple of int or None, optional + The required shape, when one is fixed. + + Returns + ------- + numpy.ndarray + The copy, with ``flags.writeable`` False. + + Raises + ------ + CouplingError + If the array is not finite or has the wrong shape. + """ + array = np.array(values, dtype=dtype, copy=True) + if shape is not None and array.shape != shape: + msg = f"{name} has shape {array.shape}, expected {shape}" + raise CouplingError(msg) + if not np.all(np.isfinite(array)): + msg = f"{name} must be finite" + raise CouplingError(msg) + array.setflags(write=False) + return array + + +def _canonical(record: object) -> str: + """Return the canonical JSON text an identity is a digest of. + + Parameters + ---------- + record : object + JSON-ready data. + + Returns + ------- + str + Sorted, compact JSON. A material's open interval bound is infinite, and + JSON's ``Infinity`` spells it deterministically. + """ + return json.dumps(record, sort_keys=True, separators=(",", ":")) + + +def basis_identity(route: PlaneKind, record: Mapping[str, object]) -> str: + """Return the identity of a plane's basis: a digest of its route and record. + + The one function both ends of a transfer call, so that a plane read from a + file and the port of a receiving simulation compare the same digest. The + record is JSON-ready: for a spatial route, :func:`space_record` of the grid; for + a modal route, the run's serialised mode records and general-modal identity. + + Parameters + ---------- + route : {'modal', 'general_modal', 'radial', 'cartesian', 'planar'} + The representation. + record : collections.abc.Mapping + The JSON-ready description of the basis. + + Returns + ------- + str + Sixty-four lowercase hexadecimal characters. + + Examples + -------- + >>> from solprop.coupling import basis_identity, space_record + >>> from solprop.grid import RadialGrid + >>> record = space_record(RadialGrid(1e-3, 64)) + >>> basis_identity("radial", record) == basis_identity("radial", dict(record)) + True + """ + text = _canonical({"route": route, "record": dict(record)}) + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +_QUANTUM_DIGITS = 12 +"""Significant digits a modal basis's sampled quantities enter its identity with. + +The same guide evaluated at the same plane agrees to a few ulp whatever path led +there (a constant fill of another length, a gradient met at the same pressure); twelve +digits sit well above that and far below any physical difference of guide.""" + + +def _quantised(values: np.ndarray) -> list[float]: + """Return values rounded to :data:`_QUANTUM_DIGITS` significant digits. + + Parameters + ---------- + values : numpy.ndarray + Finite real values. + + Returns + ------- + list of float + The rounded values. + """ + return [float(f"{float(one):.{_QUANTUM_DIGITS}g}") for one in np.ravel(values)] + + +def modal_basis_identity(modes: Sequence[Mode], grid: TimeGrid, z: float) -> str: + """Return the identity of an ordinary mode set's basis at one plane. + + An ordinary mode's saved coefficient is a true-power amplitude of the mode's + own profile at the plane, so the basis there is fixed by each mode's class and + channel label, its effective area and its real effective index on the grid's + active band at that plane, quantised to twelve significant digits. The guide's + path elsewhere does not enter: the exit of one capillary and the entrance of an + identical one of another length are the same basis. + + Parameters + ---------- + modes : sequence of solprop.modes.protocol.Mode + The mode set, in state order. + grid : solprop.grid.TimeGrid + The temporal grid; its active band is sampled. + z : float + The plane's position along the guide, m. + + Returns + ------- + str + Sixty-four lowercase hexadecimal characters. + """ + omega = grid.omega[grid.active_mask & (grid.omega > 0)] + record = { + "modes": [ + { + "kind": f"{type(mode).__module__}.{type(mode).__qualname__}", + "label": dict(mode.channel_label.as_mapping()), + "aeff": _quantised(np.array([mode.aeff(z)])), + "neff": _quantised(np.real(np.asarray(mode.neff(omega, z)))), + } + for mode in modes + ], + "lattice": lattice_identity(grid), + } + return basis_identity("modal", record) + + +def space_record(space: RadialGrid | FreeGrid | PlanarGrid) -> dict[str, object]: + """Return the JSON-ready record of a transverse grid: its class and arguments. + + The grid's constructor arguments determine every axis, weight and transform, so + they are its identity; the record has the form the output layer serialises a + grid in, which is what makes a plane read from a file comparable with a live + grid. A :class:`~solprop.modes.slab.PlanarGrid` carries slab modes and has no + such record. + + Parameters + ---------- + space : solprop.grid.RadialGrid or solprop.grid.FreeGrid + The transverse grid. + + Returns + ------- + dict + ``{"__class__": "module.Name", ...}``. + + Raises + ------ + CouplingError + For a planar grid. + + Examples + -------- + >>> from solprop.coupling import space_record + >>> from solprop.grid import RadialGrid + >>> space_record(RadialGrid(1e-3, 64))["n_r"] + 64 + """ + if isinstance(space, PlanarGrid): + msg = ( + "a planar grid's columns are slab amplitudes of its own modes and it has " + "no plain grid record; planar planes are not exchanged in this release" + ) + raise CouplingError(msg) + kind = type(space) + record: dict[str, object] = {"__class__": f"{kind.__module__}.{kind.__qualname__}"} + for one in dataclasses.fields(space): + if one.init: + value = getattr(space, one.name) + record[one.name] = float(value) if isinstance(value, float) else value + return record + + +def lattice_identity(grid: TimeGrid) -> str: + """Return the identity of a temporal lattice: its layout, samples and reference. + + Two grids with the same identity have the same time samples and the same + absolute frequency of every bin, so their spectra can be copied bin for bin. + The spectral collar (the requested band) is not part of the lattice. + + Parameters + ---------- + grid : solprop.grid.TimeGrid + The grid. + + Returns + ------- + str + Sixty-four lowercase hexadecimal characters. + + Examples + -------- + >>> from solprop.coupling import lattice_identity + >>> from solprop.grid import EnvelopeGrid + >>> one = EnvelopeGrid.from_limits(1.0, 800e-9, (600e-9, 1.2e-6), 1e-12) + >>> two = EnvelopeGrid.from_limits(2.0, 800e-9, (600e-9, 1.2e-6), 1e-12) + >>> lattice_identity(one) == lattice_identity(two) + True + """ + record = { + "kind": grid.kind, + "n_t": grid.n_t, + "n_to": grid.n_to, + "dt": float(grid.dt), + "dt_fine": float(grid.dt_fine), + "reference_wavelength": float(grid.reference_wavelength), + } + return hashlib.sha256(_canonical(record).encode("utf-8")).hexdigest() + + +@dataclass(frozen=True) +class PlaneGeometry: + """An oriented physical plane: its position, side, origin and local axes. + + The local transverse basis is right-handed: ``x_axis`` cross ``y_axis`` = + ``normal``, and the field's propagation is forward along ``normal`` + (SEP 31, section 4.1). A reflection changes these axes and the polarisation + together; it is never an unexplained conjugation. + + Attributes + ---------- + z : float + Position of the plane along its own path, m. + side : {'before', 'after'} or None, optional + Side of a declared index jump at ``z``; None away from one. + origin : tuple of float, optional + Physical origin of the transverse coordinates, m, in the laboratory frame. + normal : tuple of float, optional + Unit forward normal, laboratory frame. Default ``(0, 0, 1)``. + x_axis : tuple of float, optional + Unit local x axis, orthogonal to the normal. Default ``(1, 0, 0)``. + + Raises + ------ + CouplingError + If a value is not finite, an axis is not of unit length, or the two axes + are not orthogonal. + + Examples + -------- + >>> from solprop.coupling import PlaneGeometry + >>> PlaneGeometry(0.02).y_axis + (0.0, 1.0, 0.0) + """ + + z: float + side: FluxSide | None = None + origin: tuple[float, float, float] = (0.0, 0.0, 0.0) + normal: tuple[float, float, float] = (0.0, 0.0, 1.0) + x_axis: tuple[float, float, float] = (1.0, 0.0, 0.0) + + def __post_init__(self) -> None: + """Check the position, the side and the axes.""" + values = [self.z, *self.origin, *self.normal, *self.x_axis] + if not all(math.isfinite(value) for value in values): + msg = "a plane's position, origin and axes must be finite" + raise CouplingError(msg) + normal = np.asarray(self.normal, dtype=float) + x_axis = np.asarray(self.x_axis, dtype=float) + if ( + abs(float(np.linalg.norm(normal)) - 1.0) > _UNIT_TOLERANCE + or abs(float(np.linalg.norm(x_axis)) - 1.0) > _UNIT_TOLERANCE + or abs(float(normal @ x_axis)) > _UNIT_TOLERANCE + ): + msg = ( + "a plane's normal and x axis are orthogonal unit vectors; got normal " + f"{self.normal} and x axis {self.x_axis}" + ) + raise CouplingError(msg) + object.__setattr__(self, "z", float(self.z)) + object.__setattr__(self, "origin", tuple(float(one) for one in self.origin)) + object.__setattr__(self, "normal", tuple(float(one) for one in self.normal)) + object.__setattr__(self, "x_axis", tuple(float(one) for one in self.x_axis)) + + @property + def y_axis(self) -> tuple[float, float, float]: + """Return the local y axis, ``normal`` cross ``x_axis``. + + Returns + ------- + tuple of float + The unit vector completing the right-handed frame. + """ + value = np.cross(np.asarray(self.normal), np.asarray(self.x_axis)) + return (float(value[0]), float(value[1]), float(value[2])) + + def oriented_as(self, other: "PlaneGeometry") -> bool: # noqa: UP037 - beartype evaluates the class body + """Return whether two planes share their normal, axes and origin. + + Parameters + ---------- + other : PlaneGeometry + The other plane. + + Returns + ------- + bool + True when origin, normal and x axis agree to rounding. + """ + return all( + np.allclose(getattr(self, name), getattr(other, name), rtol=0.0, atol=1e-12) + for name in ("origin", "normal", "x_axis") + ) + + +@dataclass(frozen=True) +class PlaneReference: + r"""The affine reference a stored spectrum is written in. + + The absolute optics-convention spectrum is the stored one times + :math:`\exp[i(\phi + \omega\tau)]`, with ω the absolute angular frequency of + each bin (SEP 31, section 4.5). In the time domain τ is the absolute time of the + record's ``t = 0``: the field at record time t happened at ``t + delay``. + + Attributes + ---------- + phase : float, optional + Carrier phase φ, rad. + delay : float, optional + Time origin τ, s. + calibrated : tuple of {'phase', 'delay'}, optional + Which terms are known absolutely. A run knows both from its frame; a + phase-only optical model may leave the constant unknown. + + Raises + ------ + CouplingError + If a value is not finite. + + Examples + -------- + >>> import numpy as np + >>> from solprop.coupling import PlaneReference + >>> reference = PlaneReference(phase=0.5, delay=1e-12) + >>> bool(np.isclose(np.angle(reference.factor(np.array([0.0]))[0]), 0.5)) + True + """ + + phase: float = 0.0 + delay: float = 0.0 + calibrated: tuple[Calibration, ...] = ("phase", "delay") + + def __post_init__(self) -> None: + """Check the values and order the calibration names.""" + if not (math.isfinite(self.phase) and math.isfinite(self.delay)): + msg = "a plane reference's phase and delay must be finite" + raise CouplingError(msg) + object.__setattr__(self, "phase", float(self.phase)) + object.__setattr__(self, "delay", float(self.delay)) + object.__setattr__( + self, + "calibrated", + tuple(name for name in ("phase", "delay") if name in self.calibrated), + ) + + def factor( + self, omega: Float[np.ndarray, " n_omega"] + ) -> Complex[np.ndarray, " n_omega"]: + r"""Return the factor taking stored to absolute spectra. + + Parameters + ---------- + omega : numpy.ndarray + Absolute angular frequencies, rad/s, shape ``(n_omega,)``. + + Returns + ------- + numpy.ndarray + :math:`\exp[i(\phi + \omega\tau)]`, complex128, shape ``(n_omega,)``. + """ + return np.exp(1j * (self.phase + np.asarray(omega, dtype=float) * self.delay)) + + def minus(self, other: "PlaneReference") -> "PlaneReference": # noqa: UP037 - beartype evaluates the class body + """Return this reference measured from another. + + Parameters + ---------- + other : PlaneReference + The reference subtracted. + + Returns + ------- + PlaneReference + ``(phase - other.phase, delay - other.delay)``, calibrated where both + are. + """ + return PlaneReference( + self.phase - other.phase, + self.delay - other.delay, + tuple(name for name in self.calibrated if name in other.calibrated), + ) + + def plus(self, other: "PlaneReference") -> "PlaneReference": # noqa: UP037 - beartype evaluates the class body + """Return the sum of two references, calibrated where both are. + + Parameters + ---------- + other : PlaneReference + The reference added. + + Returns + ------- + PlaneReference + ``(phase + other.phase, delay + other.delay)``. + """ + return PlaneReference( + self.phase + other.phase, + self.delay + other.delay, + tuple(name for name in self.calibrated if name in other.calibrated), + ) + + def record(self) -> dict[str, object]: + """Return the JSON-ready record of the reference. + + Returns + ------- + dict + ``phase``, ``delay`` and ``calibrated``. + """ + return { + "phase": self.phase, + "delay": self.delay, + "calibrated": list(self.calibrated), + } + + @classmethod + def from_record(cls, record: Mapping[str, object]) -> "PlaneReference": # noqa: UP037 - beartype evaluates the class body + """Return the reference a record describes. + + Parameters + ---------- + record : collections.abc.Mapping + As :meth:`record` writes it. + + Returns + ------- + PlaneReference + The reference. + """ + return cls( + float(cast("float", record["phase"])), + float(cast("float", record["delay"])), + tuple(cast("Sequence[Calibration]", record["calibrated"])), + ) + + +def _chebyshev_at(table: Mapping[str, object], z: float) -> float: + """Evaluate a persisted piecewise Chebyshev series at one position. + + Parameters + ---------- + table : collections.abc.Mapping + ``breakpoints`` (increasing interval edges, m) and ``coefficients`` (one + list per interval, lowest degree first). + z : float + The position, inside the span to rounding. + + Returns + ------- + float + The value. + + Raises + ------ + CouplingError + If ``z`` lies outside the span. + """ + edges = np.asarray(table["breakpoints"], dtype=float) + slack = 1e-9 * max(1.0, abs(float(edges[-1]))) + if not float(edges[0]) - slack <= z <= float(edges[-1]) + slack: + msg = ( + f"z = {z!r} m is outside the frame record's span " + f"[{edges[0]}, {edges[-1]}] m" + ) + raise CouplingError(msg) + coefficients = cast("Sequence[Sequence[float]]", table["coefficients"]) + index = int(np.clip(np.searchsorted(edges, z, side="right") - 1, 0, edges.size - 2)) + lo, hi = float(edges[index]), float(edges[index + 1]) + x = min(1.0, max(-1.0, (2.0 * z - lo - hi) / (hi - lo))) + return float(np.polynomial.chebyshev.chebval(x, np.asarray(coefficients[index]))) + + +def reference_at(record: Mapping[str, object], z: float) -> PlaneReference: + r"""Return the reference of a run's plane at ``z`` from its persisted record. + + The record is the run's plane-reference record of output schema 24: + ``origin``, the reference of the run's own path origin (its parent's, for a run + launched from another), and ``frame``, the antiderivatives + :math:`\Phi_0(z)=\int_0^z\beta_0` and :math:`T(z)=\int_0^z\beta_1` of the terms + its linear operator removed, with the frequency origin :math:`\omega_o` of the + linear term (the reference frequency on an envelope grid, zero on a field + grid). Then :math:`\phi = \phi_0 + \Phi_0 - \omega_oT` and + :math:`\tau = \tau_0 + T`. A record without a frame (an operator supplied + directly) leaves both terms uncalibrated. + + Parameters + ---------- + record : collections.abc.Mapping + The persisted plane-reference record. + z : float + Position of the plane along the run's path, m. + + Returns + ------- + PlaneReference + The reference of the stored spectrum at ``z``. + + Raises + ------ + CouplingError + If ``z`` lies outside the frame record's span. + + Examples + -------- + >>> from solprop.coupling import reference_at + >>> record = { + ... "origin": {"phase": 0.0, "delay": 1e-12, "calibrated": ["phase", "delay"]}, + ... "frame": {"frequency_origin": 0.0, + ... "phase": {"breakpoints": [0.0, 1.0], "coefficients": [[0.0]]}, + ... "delay": {"breakpoints": [0.0, 1.0], + ... "coefficients": [[2e-9, 2e-9]]}}, + ... } + >>> f"{reference_at(record, 1.0).delay:.3e}" + '4.001e-09' + """ + origin = PlaneReference.from_record(cast("Mapping[str, object]", record["origin"])) + frame = record.get("frame") + if frame is None: + return PlaneReference(origin.phase, origin.delay, ()) + frame = cast("Mapping[str, object]", frame) + phase = _chebyshev_at(cast("Mapping[str, object]", frame["phase"]), z) + delay = _chebyshev_at(cast("Mapping[str, object]", frame["delay"]), z) + removed = PlaneReference( + phase - float(cast("float", frame["frequency_origin"])) * delay, delay + ) + return origin.plus(removed) + + +@dataclass(frozen=True, eq=False) +class PlaneMetric: + """The native power metric of a plane's columns at its own position. + + Each kind gives the energy crossing the plane per stored bin as a quadratic + form of the samples (SEP 31, section 4.3, the metric Q): + + ============== ============================================================== + ``identity`` ``e_k sum |s|^2``: ordinary true-power coefficients + ``dense`` ``e_k s_k^H Q_k s_k``: a general basis Gram, optics convention + ``admittance`` ``scale e_k sum_j v_j Re(Y_jk) |s_jk|^2``: a spatial plane's + axial admittance ``Y = c k_z / omega`` (SEP 34) + ``unknown`` no energy can be formed; `reason` says why + ============== ============================================================== + + with ``e_k`` the bin weights of :func:`solprop.fourier.energy_weights`. A + dense metric's per-channel diagonal is not a set of channel powers. + + Attributes + ---------- + kind : {'identity', 'dense', 'admittance', 'unknown'} + The representation. + matrix : numpy.ndarray or None, optional + The optics-convention Gram ``Q = conj(W)``, shape ``(n_modes, n_modes, + n_omega)``, dimensionless; exactly for ``dense``. + admittance : numpy.ndarray or None, optional + Dimensionless ``c k_z / omega`` of each column, shape ``(n_modes, + n_omega)``; exactly for ``admittance``. + weights : numpy.ndarray or None, optional + Transverse Parseval weights of the columns, shape ``(n_modes,)``; with + ``admittance``. + scale : float, optional + Factor turning the weighted sum into joules: ``eps0 c / 2`` for a field in + V/m, one otherwise. + flux : solprop.spatial.SpatialFlux or None, optional + The plane's measure, when its medium is at hand; it can be resolved again + on another temporal grid. + reason : str, optional + Why an ``unknown`` metric is unknown. + + Raises + ------ + CouplingError + If the arrays present are not the ones `kind` names, or an unknown metric + gives no reason. + """ + + kind: MetricKind + matrix: Complex[np.ndarray, "n_modes n_modes_q n_omega"] | None = None + admittance: Complex[np.ndarray, "n_modes n_omega"] | None = None + weights: Float[np.ndarray, " n_modes"] | None = None + scale: float = 1.0 + flux: SpatialFlux | None = None + reason: str = "" + + def __post_init__(self) -> None: + """Own the arrays and check they are the ones the kind names.""" + wanted = { + "identity": (), + "dense": ("matrix",), + "admittance": ("admittance", "weights"), + "unknown": (), + } + present = tuple( + name + for name in ("matrix", "admittance", "weights") + if getattr(self, name) is not None + ) + if present != wanted[self.kind]: + msg = f"a {self.kind} metric carries {wanted[self.kind]}, got {present}" + raise CouplingError(msg) + if self.kind == "unknown" and not self.reason: + msg = "an unknown metric says why it is unknown" + raise CouplingError(msg) + for name, dtype in ( + ("matrix", np.complex128), + ("admittance", np.complex128), + ("weights", np.float64), + ): + value = getattr(self, name) + if value is not None: + object.__setattr__(self, name, _frozen(value, dtype, name)) + + @classmethod + def from_flux(cls, flux: SpatialFlux) -> "PlaneMetric": # noqa: UP037 - beartype evaluates the class body + """Return the admittance metric of a resolved spatial measure. + + Parameters + ---------- + flux : solprop.spatial.SpatialFlux + The plane's measure (SEP 34). + + Returns + ------- + PlaneMetric + The ``admittance`` metric, keeping `flux`. + """ + return cls( + "admittance", + admittance=flux.admittance(), + weights=flux.space.spectral_weights, + scale=flux.energy_scale, + flux=flux, + ) + + def spectral_energy( + self, samples: Complex[np.ndarray, "n_modes n_pol n_omega"], grid: TimeGrid + ) -> Float[np.ndarray, " n_omega"]: + """Return the energy each bin carries through the plane, J. + + Parameters + ---------- + samples : numpy.ndarray + Stored spectrum, shape ``(n_modes, n_pol, n_omega)``. + grid : solprop.grid.TimeGrid + Its temporal grid. + + Returns + ------- + numpy.ndarray + Energy per bin, J (J/m per unit width on a line), shape ``(n_omega,)``. + + Raises + ------ + CouplingError + For an ``unknown`` metric, or arrays of the wrong shape. + """ + bins = fourier.energy_weights(grid.n_omega, grid.dt, kind=grid.kind) + if self.kind == "unknown": + msg = f"this plane's power metric is unknown: {self.reason}" + raise CouplingError(msg) + if self.kind == "identity": + return bins * np.sum(np.abs(samples) ** 2, axis=(0, 1)) + if self.kind == "dense": + matrix = cast("np.ndarray", self.matrix) + if matrix.shape != (samples.shape[0], samples.shape[0], samples.shape[2]): + msg = f"metric shape {matrix.shape} does not match {samples.shape}" + raise CouplingError(msg) + form = np.einsum("mpw,mnw,npw->w", np.conj(samples), matrix, samples) + return bins * np.real(form) + admittance = cast("np.ndarray", self.admittance) + weights = cast("np.ndarray", self.weights) + if admittance.shape != (samples.shape[0], samples.shape[2]): + msg = f"admittance shape {admittance.shape} does not match {samples.shape}" + raise CouplingError(msg) + density = np.sum(np.abs(samples) ** 2, axis=1) + return ( + self.scale + * bins + * np.sum(weights[:, None] * np.real(admittance) * density, axis=0) + ) + + +@dataclass(frozen=True, eq=False) +class PlaneBasis: + """What the columns of a plane are, and the identity they are checked by. + + Attributes + ---------- + kind : {'modal', 'general_modal', 'radial', 'cartesian', 'planar'} + The representation. + identity : str + :func:`basis_identity` of the basis: a plane and a port with the same + identity have the same columns. + labels : tuple of str, optional + Channel labels of a modal basis, in column order. + space : solprop.grid.RadialGrid, solprop.grid.FreeGrid or None, optional + The transverse grid of a spatial basis. + + Raises + ------ + CouplingError + If the identity is not a digest, or a spatial basis has no grid. + """ + + kind: PlaneKind + identity: str + labels: tuple[str, ...] = () + space: RadialGrid | FreeGrid | None = None + + def __post_init__(self) -> None: + """Check the digest and the grid.""" + if len(self.identity) != 64: + msg = "a basis identity is a SHA-256 hex digest" + raise CouplingError(msg) + if self.kind in ("radial", "cartesian") and self.space is None: + msg = f"a {self.kind} basis carries its transverse grid" + raise CouplingError(msg) + object.__setattr__(self, "labels", tuple(self.labels)) + + @classmethod + def of_space(cls, space: RadialGrid | FreeGrid) -> "PlaneBasis": # noqa: UP037 - beartype evaluates the class body + """Return the basis of a spatial grid. + + Parameters + ---------- + space : solprop.grid.RadialGrid or solprop.grid.FreeGrid + The grid. + + Returns + ------- + PlaneBasis + ``radial`` for a radial grid and ``cartesian`` for a Cartesian one. + """ + kind: PlaneKind = "radial" if isinstance(space, RadialGrid) else "cartesian" + return cls(kind, basis_identity(kind, space_record(space)), space=space) + + +@dataclass(frozen=True, eq=False) +class NodalField: + """A plane's physical electric and magnetic fields at transverse nodes. + + Attributes + ---------- + nodes : numpy.ndarray + Node positions ``(x, y)`` in m, shape ``(n_nodes, 2)``. + weights : numpy.ndarray + Area weights in m^2, shape ``(n_nodes,)``. + electric : numpy.ndarray + Optics-convention raw spectra of ``E_x, E_y, E_z`` in V/m, shape + ``(n_nodes, 3, n_omega)``. + magnetic : numpy.ndarray or None, optional + The same of ``H`` in A/m, when the basis supplies it. + """ + + nodes: Float[np.ndarray, "n_nodes 2"] + weights: Float[np.ndarray, " n_nodes"] + electric: Complex[np.ndarray, "n_nodes 3 n_omega"] + magnetic: Complex[np.ndarray, "n_nodes 3 n_omega"] | None = None + + def __post_init__(self) -> None: + """Own read-only copies.""" + object.__setattr__(self, "nodes", _frozen(self.nodes, np.float64, "nodes")) + object.__setattr__( + self, "weights", _frozen(self.weights, np.float64, "weights") + ) + object.__setattr__( + self, "electric", _frozen(self.electric, np.complex128, "electric") + ) + if self.magnetic is not None: + object.__setattr__( + self, + "magnetic", + _frozen(self.magnetic, np.complex128, "magnetic", self.electric.shape), + ) + + +@dataclass(frozen=True) +class PlaneProvenance: + """Where a plane came from and what was done to it. + + Attributes + ---------- + source : str, optional + The source: a run's configuration hash, ``"pulse"`` or ``"samples"``. + plane : int or None, optional + The saved-plane index it was read from, counted from the start. + z : float or None, optional + The saved plane's position, m. + lineage : tuple of str, optional + Configuration hashes of the runs this one descends from, oldest first. + history : tuple of str, optional + The preparation steps applied, in order. + """ + + source: str = "samples" + plane: int | None = None + z: float | None = None + lineage: tuple[str, ...] = () + history: tuple[str, ...] = () + + def then(self, step: str) -> "PlaneProvenance": # noqa: UP037 - beartype evaluates the class body + """Return the provenance with one more preparation step. + + Parameters + ---------- + step : str + What was done. + + Returns + ------- + PlaneProvenance + The same source, with `step` appended to the history. + """ + return dataclasses.replace(self, history=(*self.history, step)) + + def record(self) -> dict[str, object]: + """Return the JSON-ready record of the provenance. + + Returns + ------- + dict + Every field. + """ + return { + "source": self.source, + "plane": self.plane, + "z": self.z, + "lineage": list(self.lineage), + "history": list(self.history), + } + + +@dataclass(frozen=True, eq=False) +class FieldPlane: + """A coherent field at one physical plane, in its native representation. + + Build one with :func:`solprop.processing.field_plane` from a saved run, with + :meth:`from_pulse` from the package's beam builder, or with :meth:`from_samples` + from arrays; never by hand unless the parts are already validated. + + Attributes + ---------- + kind : {'modal', 'general_modal', 'radial', 'cartesian', 'planar'} + The native representation; the module's table gives each one's columns. + samples : numpy.ndarray + Optics-convention raw spectrum of each column referenced to the window + centre, complex128, shape ``(n_modes, n_pol, n_omega)``, read-only. + grid : solprop.grid.TimeGrid + The temporal grid: layout (``field`` or ``envelope``), time samples, the + absolute frequency of each bin and the carrier (``grid.omega0``). + units : str + Units of `samples`: ``"sqrt(W)"``, ``"V m"``, ``"V/m"`` or ``"sqrt(W/m)"``. + basis : PlaneBasis + What the columns are, with their identity. + reference : PlaneReference + The affine reference of the stored spectrum. + geometry : PlaneGeometry + The oriented plane. + metric : PlaneMetric + The native power metric at the plane. + exterior : {'zero', 'unknown'}, optional + What the record says outside its samples. A run's own planes are zero + outside their window and grid; a cropped measurement may not be. + medium : solprop.spatial.SpatialMedium or None, optional + The bulk medium at a spatial plane, when it is at hand. + certificate : collections.abc.Mapping or None, optional + The scoped path certificate of the producing run (SEP 34, section 4.5), + read-only; it bounds the linear background path, not the whole run. + nodal : NodalField or None, optional + Physical E (and H) at transverse nodes, for a general modal plane read with + its source simulation. + provenance : PlaneProvenance, optional + Source identity, plane index, lineage and preparation history. + + Raises + ------ + CouplingError + If the samples are not finite and three-dimensional, or disagree with the + grid or the basis kind. + """ + + kind: PlaneKind + samples: Complex[np.ndarray, "n_modes n_pol n_omega"] + grid: TimeGrid + units: str + basis: PlaneBasis + reference: PlaneReference + geometry: PlaneGeometry + metric: PlaneMetric + exterior: Exterior = "zero" + medium: SpatialMedium | None = None + certificate: Mapping[str, object] | None = None + nodal: NodalField | None = None + provenance: PlaneProvenance = field(default_factory=PlaneProvenance) + + def __post_init__(self) -> None: + """Own read-only samples and check them against the grid and the basis.""" + samples = _frozen(self.samples, np.complex128, "samples") + if samples.ndim != 3 or samples.shape[2] != self.grid.n_omega: + msg = ( + f"samples have shape {samples.shape}; a plane holds (n_modes, n_pol, " + f"n_omega) with n_omega = {self.grid.n_omega}" + ) + raise CouplingError(msg) + if self.basis.kind != self.kind: + msg = ( + f"a {self.kind} plane needs a {self.kind} basis, got {self.basis.kind}" + ) + raise CouplingError(msg) + object.__setattr__(self, "samples", samples) + if self.certificate is not None: + object.__setattr__( + self, "certificate", MappingProxyType(dict(self.certificate)) + ) + + def __repr__(self) -> str: + """Return a one-line description without arrays. + + Returns + ------- + str + The kind, shape, layout and position. + """ + n_modes, n_pol, n_omega = self.samples.shape + return ( + f"FieldPlane({self.kind}, {n_modes}x{n_pol}x{n_omega} {self.grid.kind}, " + f"z={self.geometry.z:.6g} m, {self.units})" + ) + + @property + def omega(self) -> Float[np.ndarray, " n_omega"]: + """Return the absolute angular frequency of each stored bin. + + Returns + ------- + numpy.ndarray + Angular frequencies in rad/s, shape ``(n_omega,)``, in storage order. + """ + return self.grid.omega + + def absolute(self) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: + """Return the samples in the absolute reference. + + Returns + ------- + numpy.ndarray + ``samples * reference.factor(omega)``, shape ``(n_modes, n_pol, + n_omega)``. + + Raises + ------ + CouplingError + If the reference is not calibrated in both terms. + """ + if self.reference.calibrated != ("phase", "delay"): + msg = ( + "this plane's absolute reference is not known in " + f"{sorted({'phase', 'delay'} - set(self.reference.calibrated))}" + ) + raise CouplingError(msg) + return self.samples * self.reference.factor(self.omega)[None, None, :] + + def spectral_energy(self) -> Float[np.ndarray, " n_omega"]: + """Return the energy each bin carries through the plane, J. + + Returns + ------- + numpy.ndarray + :meth:`PlaneMetric.spectral_energy` of the samples, shape + ``(n_omega,)``. + """ + return self.metric.spectral_energy(self.samples, self.grid) + + def energy(self) -> float: + """Return the physical energy crossing the plane, J. + + Returns + ------- + float + The sum of :meth:`spectral_energy`; J per metre of y on a line. + """ + return float(np.sum(self.spectral_energy())) + + def node_spectra(self) -> Complex[np.ndarray, "n_nodes n_pol n_omega"]: + """Return a spatial plane's electric field at its grid's nodes. + + Returns + ------- + numpy.ndarray + Optics-convention raw spectra in V/m, shape ``(n_nodes, n_pol, + n_omega)``, nodes in the grid's order. + + Raises + ------ + CouplingError + For a plane that is not radial or Cartesian. + """ + space = self.basis.space + if space is None or self.kind not in ("radial", "cartesian"): + msg = f"a {self.kind} plane has no node samples of its own" + raise CouplingError(msg) + internal = fourier.spectrum_to_internal(self.samples, kind=self.grid.kind) + return fourier.spectrum_to_optics( + _transverse(internal, space, inverse=True), kind=self.grid.kind + ) + + @classmethod + def from_pulse( + cls, + pulse: object, + grid: TimeGrid, + geometry: PlaneGeometry, + *, + space: RadialGrid | FreeGrid, + medium: SpatialMedium, + reference: PlaneReference | None = None, + ) -> "FieldPlane": # noqa: UP037 - beartype evaluates the class body + """Build a spatial plane from the package's beam builder. + + The beam is :meth:`solprop.pulses.SpatialPulse.build` on the grid, with its + requested energy or peak power the physical axial flux through the plane in + the medium there (SEP 34); no second Gaussian is written. Its exterior is + zero: the closed-form beam is resolved on the grid's whole window. + + Parameters + ---------- + pulse : solprop.pulses.SpatialPulse + The beam. + grid : solprop.grid.TimeGrid + Temporal grid. + geometry : PlaneGeometry + The plane; its ``z`` and ``side`` select the medium's sample. + space : solprop.grid.RadialGrid or solprop.grid.FreeGrid + Transverse grid. + medium : solprop.spatial.SpatialMedium + The medium at the plane. + reference : PlaneReference or None, optional + The reference of the built spectrum; default zero phase and delay. + + Returns + ------- + FieldPlane + A radial or Cartesian plane with its admittance metric. + + Raises + ------ + CouplingError + If `pulse` has no spatial ``build``. + """ + build = getattr(pulse, "build", None) + if build is None or not callable(build): + msg = "from_pulse takes a SpatialPulse" + raise CouplingError(msg) + spectrum = cast( + "np.ndarray", + build(grid, space, medium=medium, z=geometry.z, side=geometry.side), + ) + flux = SpatialFlux.resolve( + grid, space, medium, z=geometry.z, side=geometry.side + ) + basis = PlaneBasis.of_space(space) + return cls( + basis.kind, + fourier.spectrum_to_optics(spectrum, kind=grid.kind), + grid, + "V m" if basis.kind == "radial" else "V/m", + basis, + PlaneReference() if reference is None else reference, + geometry, + PlaneMetric.from_flux(flux), + "zero", + medium, + provenance=PlaneProvenance("pulse", history=(repr(pulse),)), + ) + + @classmethod + def from_samples( # noqa: PLR0913 - the arrays and every declaration they need + cls, + samples: Complex[np.ndarray, "n_nodes n_pol n_omega"], + grid: TimeGrid, + geometry: PlaneGeometry, + *, + exterior: Exterior, + space: RadialGrid | FreeGrid | None = None, + medium: SpatialMedium | None = None, + basis: PlaneBasis | None = None, + metric: PlaneMetric | None = None, + reference: PlaneReference | None = None, + ) -> "FieldPlane": # noqa: UP037 - beartype evaluates the class body + """Bind physical arrays to explicit axes, units, domain, medium and reference. + + Two forms. With `space` and `medium`, `samples` is the electric field at the + grid's nodes: optics-convention raw temporal spectra in V/m, referenced to + the window centre, shape ``(n_nodes, n_pol, n_omega)``; they are transformed + to the grid's native columns and measured with the medium's axial + admittance. With `basis` (a modal basis), `samples` is already the + optics-convention coefficient spectrum in sqrt(W), shape ``(n_modes, + n_pol, n_omega)``, measured with `metric` (the identity by default). + + Parameters + ---------- + samples : numpy.ndarray + The arrays, as above. + grid : solprop.grid.TimeGrid + Temporal grid of the samples. + geometry : PlaneGeometry + The plane. + exterior : {'zero', 'unknown'} + Whether the field is declared zero outside the samples, or unknown + there. There is no default: a crop of a measured beam is not a + complete beam. + space : solprop.grid.RadialGrid or solprop.grid.FreeGrid or None, optional + Transverse grid of node samples. + medium : solprop.spatial.SpatialMedium or None, optional + Medium at the plane, required with `space`. + basis : PlaneBasis or None, optional + Modal basis of coefficient samples, instead of `space`. + metric : PlaneMetric or None, optional + Metric of a modal basis; default the identity. + reference : PlaneReference or None, optional + Reference of the spectrum; default zero phase and delay. + + Returns + ------- + FieldPlane + The plane. + + Raises + ------ + CouplingError + If neither or both forms are given, the shapes disagree, or a spatial + form has no medium. + """ + values = np.asarray(samples, dtype=np.complex128) + reference = PlaneReference() if reference is None else reference + provenance = PlaneProvenance("samples") + if (space is None) == (basis is None): + msg = ( + "from_samples takes node samples on a space or coefficients on a basis" + ) + raise CouplingError(msg) + if space is not None: + if medium is None: + msg = "node samples are measured in their medium: give medium=" + raise CouplingError(msg) + n_nodes = space.r.size + if values.ndim != 3 or values.shape[0] != n_nodes: + msg = f"node samples need {n_nodes} nodes, got shape {values.shape}" + raise CouplingError(msg) + internal = fourier.spectrum_to_internal(values, kind=grid.kind) + columns = fourier.spectrum_to_optics( + _transverse(internal, space, inverse=False), kind=grid.kind + ) + flux = SpatialFlux.resolve( + grid, space, medium, z=geometry.z, side=geometry.side + ) + plane_basis = PlaneBasis.of_space(space) + return cls( + plane_basis.kind, + columns, + grid, + "V m" if plane_basis.kind == "radial" else "V/m", + plane_basis, + reference, + geometry, + PlaneMetric.from_flux(flux), + exterior, + medium, + provenance=provenance, + ) + assert basis is not None + if basis.kind not in ("modal", "general_modal"): + msg = f"coefficient samples need a modal basis, got {basis.kind}" + raise CouplingError(msg) + return cls( + basis.kind, + values, + grid, + "sqrt(W)", + basis, + reference, + geometry, + PlaneMetric("identity") if metric is None else metric, + exterior, + provenance=provenance, + ) + + +def _transverse( + values: Complex[np.ndarray, "n_columns n_pol n_omega"], + space: RadialGrid | FreeGrid, + *, + inverse: bool, +) -> Complex[np.ndarray, "n_columns n_pol n_omega"]: + """Apply a grid's transverse transform along the column axis. + + Parameters + ---------- + values : numpy.ndarray + Internal-convention spectra, shape ``(n_columns, n_pol, n_omega)``. + space : solprop.grid.RadialGrid or solprop.grid.FreeGrid + The grid whose QDHT or two-dimensional FFT is applied. + inverse : bool + True for columns to nodes, False for nodes to columns. + + Returns + ------- + numpy.ndarray + The transformed spectra, same shape. + """ + if isinstance(space, RadialGrid): + moved = np.moveaxis(values, 0, -1) + result = space.hankel.inverse(moved) if inverse else space.hankel.forward(moved) + return np.moveaxis(np.asarray(result), -1, 0) + shaped = values.reshape(space.n_y, space.n_x, *values.shape[1:]) + result = ( + np.fft.ifft2(shaped, axes=(0, 1)) + if inverse + else np.fft.fft2(shaped, axes=(0, 1)) + ) + return result.reshape(values.shape) diff --git a/src/solprop/coupling/port.py b/src/solprop/coupling/port.py new file mode 100644 index 000000000..ee0e90d2b --- /dev/null +++ b/src/solprop/coupling/port.py @@ -0,0 +1,465 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +r"""The receiving side of a transfer: a simulation's input port and its launch. + +An :class:`InputPort` describes what a simulation can receive at its entrance, +already sampled (SEP 31, section 5.1): the route, the temporal lattice and basis +with their identities, the entrance plane and the frame accumulated there, the +native metric Q, the reference index of the medium, and the coupling models it +admits. :meth:`solprop.driver.Simulation.input_port` returns one; the translation +layer fills it. It is a P-layer value type, so that a coupling computed with NumPy +alone never imports the translation layer. + +A :class:`CouplingLaunch` is bound to one port: it carries the port, the target +coefficients and the reference origin the launched run inherits, and +:meth:`solprop.driver.Simulation.with_input` refuses it for any other port, whatever +the array shapes. + +:func:`same_basis` is the identity model of section 4.3: a plane whose basis, +lattice, orientation and metric are the port's is copied as it stands, and its +reference becomes the launched run's origin. Phase 3 adds the general ``couple``. +""" + +import hashlib +import json +import math +from collections.abc import Mapping +from dataclasses import dataclass +from types import MappingProxyType +from typing import cast + +import numpy as np +from jaxtyping import Complex, Float + +from solprop import fourier +from solprop.coupling.plane import ( + FieldPlane, + PlaneBasis, + PlaneGeometry, + PlaneKind, + PlaneMetric, + PlaneReference, + lattice_identity, +) +from solprop.exceptions import CouplingError +from solprop.grid import TimeGrid + +__all__ = [ + "ENTRANCE_TOLERANCE", + "METRIC_TOLERANCE", + "CouplingLaunch", + "InputPort", + "same_basis", +] + +ENTRANCE_TOLERANCE = 1e-12 +"""Relative agreement of two entrance positions, of the larger of 1 m and |z|. + +A launch names its entrance by position; within this, two positions are the same +plane, as a plane the run reached by summing steps sits within a few ulp of it. +""" + +METRIC_TOLERANCE = 1e-10 +"""Largest relative difference of two admittance metrics that counts as one medium. + +The same medium at the same plane evaluates its admittance through one arithmetic +path (SEP 34) and agrees to rounding; a different medium differs by its index, a +few parts in a million for a gas. Anything between is a different medium. +""" + + +def _occupied_outside( + samples: Complex[np.ndarray, "n_modes n_pol n_omega"], grid: TimeGrid +) -> float: + """Return the share of a record's squared samples outside a grid's active band. + + Parameters + ---------- + samples : numpy.ndarray + Stored spectrum, shape ``(n_modes, n_pol, n_omega)``. + grid : solprop.grid.TimeGrid + The receiving grid, on the same lattice. + + Returns + ------- + float + The share, zero for a zero record. + """ + power = np.sum(np.abs(samples) ** 2, axis=(0, 1)) + total = float(np.sum(power)) + return 0.0 if total == 0.0 else float(np.sum(power[~grid.active_mask]) / total) + + +@dataclass(frozen=True, eq=False) +class InputPort: + """What a simulation can receive at its entrance, already sampled. + + Attributes + ---------- + route : {'modal', 'general_modal', 'radial', 'cartesian', 'planar'} + The receiving representation. + grid : solprop.grid.TimeGrid + The receiving temporal grid. + basis : PlaneBasis + The receiving basis, with its identity, labels and transverse grid. + shape : tuple of int + The launch's ``(n_modes, n_pol, n_omega)``. + geometry : PlaneGeometry + The entrance plane: its position along the receiving path and side. + frame : PlaneReference + The receiving run's own frame accumulated from its path origin to the + entrance, without any inherited origin: zero at a path's start. + metric : PlaneMetric + The native metric Q at the entrance: the identity for ordinary + true-power coefficients, the basis Gram (optics convention) for a general + guide, the axial admittance for a spatial grid. + reference_index : numpy.ndarray or None, optional + Real phase index of the entrance medium at each bin, shape + ``(n_omega,)``, where the route defines one (a spatial medium). + nodes : numpy.ndarray or None, optional + Transverse nodes ``(x, y)`` the target basis is sampled at, m, shape + ``(n_nodes, 2)``; None where phase 2 samples no profiles. + node_weights : numpy.ndarray or None, optional + Their area weights, m^2, shape ``(n_nodes,)``. + electric : numpy.ndarray or None, optional + The target basis's electric field at the nodes, optics convention, in + V/(m sqrt(W)), shape ``(n_nodes, 3, n_modes, n_omega)``: a general guide's + E reference at its entrance. + magnetic : numpy.ndarray or None, optional + Its magnetic field in A/(m sqrt(W)), the same shape. + models : tuple of str, optional + The coupling models the port admits. Phase 2 admits ``'same_basis'``. + + Raises + ------ + CouplingError + If the shape disagrees with the grid or the basis kind with the route. + """ + + route: PlaneKind + grid: TimeGrid + basis: PlaneBasis + shape: tuple[int, int, int] + geometry: PlaneGeometry + frame: PlaneReference + metric: PlaneMetric + reference_index: Float[np.ndarray, " n_omega"] | None = None + nodes: Float[np.ndarray, "n_nodes 2"] | None = None + node_weights: Float[np.ndarray, " n_nodes"] | None = None + electric: Complex[np.ndarray, "n_nodes 3 n_modes n_omega"] | None = None + magnetic: Complex[np.ndarray, "n_nodes 3 n_modes n_omega"] | None = None + models: tuple[str, ...] = ("same_basis",) + + def __post_init__(self) -> None: + """Check the shape, the basis and the reference index.""" + if self.basis.kind != self.route: + msg = f"a {self.route} port needs a {self.route} basis" + raise CouplingError(msg) + shape = tuple(int(one) for one in self.shape) + if len(shape) != 3 or shape[2] != self.grid.n_omega or min(shape) < 1: + msg = f"a port's shape is (n_modes, n_pol, n_omega), got {self.shape}" + raise CouplingError(msg) + object.__setattr__(self, "shape", shape) + object.__setattr__(self, "models", tuple(self.models)) + if self.reference_index is not None: + index = np.array(self.reference_index, dtype=np.float64, copy=True) + if index.shape != (self.grid.n_omega,) or not np.all(np.isfinite(index)): + msg = "a reference index is finite, one value per bin" + raise CouplingError(msg) + index.setflags(write=False) + object.__setattr__(self, "reference_index", index) + for name, dtype in ( + ("nodes", np.float64), + ("node_weights", np.float64), + ("electric", np.complex128), + ("magnetic", np.complex128), + ): + value = getattr(self, name) + if value is not None: + array = np.array(value, dtype=dtype, copy=True) + array.setflags(write=False) + object.__setattr__(self, name, array) + + @property + def lattice(self) -> str: + """Return the identity of the receiving temporal lattice. + + Returns + ------- + str + :func:`~solprop.coupling.lattice_identity` of `grid`. + """ + return lattice_identity(self.grid) + + @property + def identity(self) -> str: + """Return the port's identity: route, lattice, basis and entrance. + + Returns + ------- + str + A SHA-256 hex digest; two ports with the same identity accept the same + launch. + """ + record = { + "route": self.route, + "lattice": self.lattice, + "basis": self.basis.identity, + "shape": list(self.shape), + "z": self.geometry.z, + "side": self.geometry.side, + } + text = json.dumps(record, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + def accepts(self, other: "InputPort") -> str | None: # noqa: UP037 - beartype evaluates the class body + """Return why a launch bound to `other` cannot enter this port, or None. + + Parameters + ---------- + other : InputPort + The port a launch was bound to. + + Returns + ------- + str or None + The first disagreement -- route, lattice, basis, shape, entrance, + orientation or metric (the medium) -- named; None when the two are the + same port. + """ + for name, mine, theirs in ( + ("route", self.route, other.route), + ("lattice", self.lattice, other.lattice), + ("basis", self.basis.identity, other.basis.identity), + ("shape", self.shape, other.shape), + ("side", self.geometry.side, other.geometry.side), + ): + if mine != theirs: + return f"the launch's {name} is not this simulation's" + scale = max(1.0, abs(self.geometry.z)) + if abs(self.geometry.z - other.geometry.z) > ENTRANCE_TOLERANCE * scale: + return ( + f"the launch enters at z = {other.geometry.z!r} m and this simulation " + f"at z = {self.geometry.z!r} m" + ) + if not self.geometry.oriented_as(other.geometry): + return "the launch's entrance plane is oriented differently" + try: + _check_same_medium(other.metric, self.metric) + except CouplingError as refused: + return f"the launch's metric is not this simulation's: {refused}" + return None + + +@dataclass(frozen=True, eq=False) +class CouplingLaunch: + """A launch bound to one input port. + + Attributes + ---------- + port : InputPort + The port it was computed for; its grid, basis, metric and entrance are the + launch's identity. + samples : numpy.ndarray + The target coefficients, optics-convention stored spectrum, shape + ``port.shape``, read-only, in the port's native units. + origin : PlaneReference + The reference of the launched run's path origin: the launched spectrum's + reference minus the frame the port has accumulated at its entrance. The + launched run's planes add their own frame to it. + model : str + The coupling model that produced it. + provenance : collections.abc.Mapping + JSON-ready record of where it came from: the source plane's provenance and + the model, recorded in the launched run's configuration. + + Raises + ------ + CouplingError + If the samples do not have the port's shape or are not finite. + """ + + port: InputPort + samples: Complex[np.ndarray, "n_modes n_pol n_omega"] + origin: PlaneReference + model: str + provenance: Mapping[str, object] + + def __post_init__(self) -> None: + """Own read-only samples of the port's shape.""" + samples = np.array(self.samples, dtype=np.complex128, copy=True) + if samples.shape != self.port.shape or not np.all(np.isfinite(samples)): + msg = ( + f"a launch holds finite samples of its port's shape {self.port.shape}, " + f"got {samples.shape}" + ) + raise CouplingError(msg) + samples.setflags(write=False) + object.__setattr__(self, "samples", samples) + object.__setattr__(self, "provenance", MappingProxyType(dict(self.provenance))) + + def __repr__(self) -> str: + """Return a one-line description without arrays. + + Returns + ------- + str + The model, route and entrance. + """ + return ( + f"CouplingLaunch({self.model}, {self.port.route}, " + f"z={self.port.geometry.z:.6g} m)" + ) + + def internal(self) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: + """Return the samples in the internal convention a simulation takes. + + Returns + ------- + numpy.ndarray + :func:`solprop.fourier.spectrum_to_internal` of `samples`. + """ + return np.asarray( + fourier.spectrum_to_internal(self.samples, kind=self.port.grid.kind), + dtype=np.complex128, + ) + + def record(self) -> dict[str, object]: + """Return the coupling provenance a launched run records. + + Returns + ------- + dict + The model, the port identity, the origin and the source provenance. + """ + return { + "model": self.model, + "port": self.port.identity, + "origin": self.origin.record(), + "source": dict(self.provenance), + } + + +def same_basis(plane: FieldPlane, port: InputPort) -> CouplingLaunch: + """Launch a plane into a port of the same basis, unchanged. + + The identity model of SEP 31 section 4.3: exact coordinate recovery in a + checked common reference, which here is the identical basis on the identical + lattice, at an identically oriented plane, in the same medium. The coefficients + are copied as they stand and the plane's reference becomes the launched run's + origin, so the launched run continues the source's absolute time and carrier + phase without moving the pulse in its window. Anything else is a different + calculation, refused by name: a different lattice needs + :func:`~solprop.coupling.regrid` first, a different basis or medium a coupling + model or an interface. + + Parameters + ---------- + plane : FieldPlane + The source plane. + port : InputPort + The receiving port. + + Returns + ------- + CouplingLaunch + The launch, bound to `port`. + + Raises + ------ + CouplingError + If the port does not admit the model; if the route, basis, lattice, shape, + orientation or medium differ; or if the plane carries light outside the + port's active band. + """ + if "same_basis" not in port.models: + msg = f"this port admits {port.models}, not same_basis" + raise CouplingError(msg) + if plane.kind != port.route or plane.basis.identity != port.basis.identity: + msg = ( + f"a {plane.kind} plane of basis {plane.basis.identity[:12]} is not the " + f"{port.route} port's basis {port.basis.identity[:12]}: a different basis " + "is a coupling, not a relaunch" + ) + raise CouplingError(msg) + if lattice_identity(plane.grid) != port.lattice or not np.array_equal( + plane.grid.omega, port.grid.omega + ): + msg = "the plane's temporal lattice is not the port's: regrid it first" + raise CouplingError(msg) + if plane.samples.shape != port.shape: + msg = f"the plane's shape {plane.samples.shape} is not the port's {port.shape}" + raise CouplingError(msg) + if not plane.geometry.oriented_as(port.geometry): + msg = "the plane and the entrance are oriented differently" + raise CouplingError(msg) + _check_same_medium(plane.metric, port.metric) + outside = _occupied_outside(plane.samples, port.grid) + floor = (plane.grid.n_omega * float(np.finfo(float).eps)) ** 2 + if outside > floor: + msg = ( + f"{outside:.3e} of the plane lies outside the port's active band, which " + "the receiving run would remove: regrid with crop_band=True to remove " + "it explicitly" + ) + raise CouplingError(msg) + provenance = { + "plane": plane.provenance.record(), + "reference": plane.reference.record(), + } + return CouplingLaunch( + port, + plane.samples, + plane.reference.minus(port.frame), + "same_basis", + provenance, + ) + + +def _check_same_medium(source: PlaneMetric, target: PlaneMetric) -> None: + """Refuse a relaunch across a change of medium or metric. + + Parameters + ---------- + source : PlaneMetric + The plane's metric. + target : PlaneMetric + The port's metric. + + Raises + ------ + CouplingError + If the kinds differ, or the admittances or Grams differ by more than + :data:`METRIC_TOLERANCE`. + """ + if source.kind == "unknown": + msg = ( + f"the plane's metric is unknown ({source.reason}), so its medium cannot " + "be checked" + ) + raise CouplingError(msg) + if source.kind != target.kind: + msg = f"the plane's metric is {source.kind} and the port's {target.kind}" + raise CouplingError(msg) + for name in ("admittance", "matrix", "weights"): + mine = getattr(source, name) + theirs = getattr(target, name) + if mine is None: + continue + mine = cast("np.ndarray", mine) + theirs = cast("np.ndarray", theirs) + scale = float(np.max(np.abs(theirs))) if theirs.size else 0.0 + if mine.shape != theirs.shape or ( + scale > 0.0 + and float(np.max(np.abs(mine - theirs))) > METRIC_TOLERANCE * scale + ): + msg = ( + f"the plane's {name} differs from the port's: a change of medium " + "needs an interface, not a relaunch" + ) + raise CouplingError(msg) + if not math.isclose(source.scale, target.scale, rel_tol=1e-15): + msg = "the plane's metric scale is not the port's" + raise CouplingError(msg) diff --git a/src/solprop/coupling/remap.py b/src/solprop/coupling/remap.py new file mode 100644 index 000000000..b38d3f5be --- /dev/null +++ b/src/solprop/coupling/remap.py @@ -0,0 +1,554 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +r"""Move a field plane onto another temporal lattice, or another reference. + +The one controlled temporal conversion of SEP 31 (section 4.5). Three things are +kept apart: the sign convention (a plane's samples are already optics-convention), +the raw DFT's time origin (the window centre) and its amplitude scaling. With +:math:`s_k` a plane's stored spectrum, its normalised analytic spectral density is + +.. math:: f_k = \frac{\Delta t}{\sqrt{2\pi}}\,q_k s_k, + +:math:`q_k = 2` on interior positive bins of a field grid, 1 at its zero-frequency +and Nyquist bins, and 1 on an envelope grid, so that +:math:`\Delta\omega\sum_k f_k^\dagger Q_k f_k` is the energy in the native metric Q. +Copying raw DFT amplitudes between lattices of different N or Δt is not this +operation; copying f between identical physical frequencies is. + +**The map.** The source record is the analytic signal at its own time samples, +:math:`a_j = N^{-1}\sum_k q_k s_k e^{-i\omega_k t_j}`, zero outside its window. A +target bin of absolute frequency :math:`\omega'_m` receives its finite-time +Fourier sum + +.. math:: s'_m = \frac{\Delta t}{\Delta t'\,q'_m}\,e^{i\Delta\phi} + \sum_j a_j\,e^{i\omega'_m (t_j + \Delta)}, + +with :math:`\Delta = \tau - \tau'` and :math:`\Delta\phi = \phi - \phi'` the change of +reference (:class:`~solprop.coupling.PlaneReference`), so that the absolute spectrum is +unchanged. The sum is evaluated only on target bins inside the source lattice's own +span: beyond it the sum of samples spaced Δt repeats with period 2π/Δt and returns +aliases of the record, not light. The direct sum is the reference implementation; +it is evaluated in bounded chunks of target bins. The one acceleration is the +identical lattice in an unchanged reference, where the map is the identity and the +samples are copied; the tests check it against the direct sum. Complex spectral +zeros need no special case: no phase is unwrapped and nothing is interpolated. + +**What is lost is reported, and what cannot be represented is refused.** Light +outside the target's active band, or outside its time window after the shift, is +removed only on request (`crop_band`, `truncate`), with its share in the report. A +record whose edges carry light, or whose exterior is unknown, is not extended into a +larger window without `truncate`, which declares it zero outside. Nothing is +renormalised: the energy out is whatever the map gives. +""" + +import math +from dataclasses import dataclass +from typing import Literal + +import numpy as np +from jaxtyping import Complex, Float + +from solprop.coupling.plane import ( + FieldPlane, + PlaneMetric, + PlaneReference, + lattice_identity, +) +from solprop.exceptions import CouplingError +from solprop.grid import TimeGrid +from solprop.spatial import SpatialFlux + +__all__ = ["DEFAULT_TOLERANCE", "RegridReport", "regrid"] + +DEFAULT_TOLERANCE = 1e-10 +"""Share of a record's norm below which a band or a window is unoccupied. + +Ten digits below the record: well above the rounding of a double-precision sum over +a few thousand samples (about 1e-13 relative), and well below any share a person +would call physical light. A share above it is occupied, and removing it is lossy. +""" + +_CHUNK_ELEMENTS = 2**22 +"""Largest number of complex exponentials held at once by the direct sum (64 MiB).""" + + +@dataclass(frozen=True) +class RegridReport: + """What a temporal conversion did, and what it removed. + + The shares are of the source's **record norm**: the time-integrated squared + analytic signal of every column, weighted by the column's transverse weight + (one for a modal basis, the Parseval weight times ``eps0 c / 2`` for a spatial + one), so that on an identity metric it is the energy in joules, and on a + spatial one the vacuum-equivalent energy. The energies are physical. + + Attributes + ---------- + method : {'copy', 'direct'} + Identity copy on an identical lattice and reference, or the direct sum. + source_lattice, target_lattice : str + :func:`~solprop.coupling.lattice_identity` of the two grids. + shift : float + Record-time shift applied, ``old delay - new delay``, s. + phase_shift : float + Phase applied, ``old phase - new phase``, rad. + record_norm : float + The source's record norm, in its units (above). + window_loss : float + Share of the record norm outside the target window after the shift. + band_loss : float + Share of the record norm outside the target's active band. + edge_share : float + Share of the record norm inside the source's temporal collar. + truncated : bool + Whether the record was declared zero outside its window or cropped to the + target's (`truncate`). + energy_in : float or None + Physical energy of the source, J; None when its metric is unknown. + energy_out : float or None + Physical energy of the result, J; None when its metric is unknown. + """ + + method: Literal["copy", "direct"] + source_lattice: str + target_lattice: str + shift: float + phase_shift: float + record_norm: float + window_loss: float + band_loss: float + edge_share: float + truncated: bool + energy_in: float | None + energy_out: float | None + + +def _q(grid: TimeGrid) -> Float[np.ndarray, " n_omega"]: + """Return the analytic-signal factor of each stored bin. + + Parameters + ---------- + grid : solprop.grid.TimeGrid + The grid. + + Returns + ------- + numpy.ndarray + Two on interior bins of a field grid, one at its end bins, one on an + envelope grid; shape ``(n_omega,)``. + """ + factor = np.ones(grid.n_omega) + if grid.kind == "field": + factor[1:-1] = 2.0 + return factor + + +def _analytic( + samples: Complex[np.ndarray, "n_columns n_omega"], grid: TimeGrid +) -> Complex[np.ndarray, "n_columns n_t"]: + """Return a record's analytic signal at its own time samples. + + ``a_j = sum_k q_k s_k exp(-i omega_k t_j) / N`` in the optics convention. With + ``omega_k = omega_c + kappa_k d_omega`` (``omega_c`` the carrier of an envelope + grid, zero on a field grid) and ``t_j = t_0 + j dt`` it is one FFT. + + Parameters + ---------- + samples : numpy.ndarray + Stored spectra, shape ``(n_columns, n_omega)``. + grid : solprop.grid.TimeGrid + Their grid. + + Returns + ------- + numpy.ndarray + The analytic signal, shape ``(n_columns, n_t)``. + """ + centre = grid.omega0 if grid.kind == "envelope" else 0.0 + kappa = np.rint((grid.omega - centre) / grid.d_omega).astype(np.int64) + full = np.zeros((samples.shape[0], grid.n_t), dtype=np.complex128) + origin = np.exp(-1j * kappa * grid.d_omega * float(grid.t[0])) + full[:, kappa % grid.n_t] = samples * (_q(grid) * origin)[None, :] + carrier = np.exp(-1j * centre * grid.t) + return carrier[None, :] * np.fft.fft(full, axis=-1) / grid.n_t + + +def _column_weights(plane: FieldPlane) -> Float[np.ndarray, " n_modes"]: + """Return the transverse weight each column's record norm carries. + + Parameters + ---------- + plane : FieldPlane + The source. + + Returns + ------- + numpy.ndarray + One per column: the metric's Parseval weights times its scale for a spatial + plane, ones otherwise. + """ + metric = plane.metric + if metric.weights is not None: + return metric.scale * metric.weights + return np.ones(plane.samples.shape[0]) + + +def _target_metric(plane: FieldPlane, grid: TimeGrid, same: bool) -> PlaneMetric: + """Return the result's metric on the target lattice. + + Parameters + ---------- + plane : FieldPlane + The source. + grid : solprop.grid.TimeGrid + The target grid. + same : bool + Whether the lattices are identical. + + Returns + ------- + PlaneMetric + The identity kept, an admittance resolved again from the plane's medium, the + source's metric on an identical lattice, or ``unknown``. + """ + metric = plane.metric + if metric.kind in ("identity", "unknown") or same: + return metric + if metric.flux is not None: + flux = metric.flux + return PlaneMetric.from_flux( + SpatialFlux.resolve(grid, flux.space, flux.medium, z=flux.z, side=flux.side) + ) + return PlaneMetric( + "unknown", + reason=( + "the source metric is tabulated on its own frequencies and the plane " + "carries no medium to evaluate it on the new lattice" + ), + ) + + +def _within_band(grid: TimeGrid, omega: np.ndarray) -> np.ndarray: + """Return which absolute frequencies lie inside a grid's active band. + + Parameters + ---------- + grid : solprop.grid.TimeGrid + The receiving grid. + omega : numpy.ndarray + Absolute angular frequencies, rad/s. + + Returns + ------- + numpy.ndarray + Boolean mask of the same shape. + """ + active = grid.omega[grid.active_mask] + return (omega >= float(np.min(active))) & (omega <= float(np.max(active))) + + +def regrid( # noqa: PLR0913 - the target and each explicitly lossy permission + plane: FieldPlane, + grid: TimeGrid, + *, + phase: float | None = None, + delay: float | None = None, + truncate: bool = False, + crop_band: bool = False, + tolerance: float = DEFAULT_TOLERANCE, + method: Literal["auto", "direct"] = "auto", +) -> tuple[FieldPlane, RegridReport]: + r"""Convert a plane to another temporal lattice and reference. + + The module's notes give the map. The plane's transverse representation, basis, + geometry and medium are unchanged; only the temporal record moves. + + Parameters + ---------- + plane : FieldPlane + The source. + grid : solprop.grid.TimeGrid + The receiving temporal grid, field or envelope, of any length, spacing and + carrier. + phase : float or None, optional + The result's reference phase, rad; None keeps the plane's. + delay : float or None, optional + The result's reference delay (time origin), s; None keeps the plane's. A + later delay moves the record earlier in the window. + truncate : bool, optional + Declare the record zero outside its window, so that a larger or shifted + target may extend it, and allow the part outside the target window to be + cut, with its share reported. Default False refuses both. + crop_band : bool, optional + Allow light outside the target's active band to be removed, with its share + reported. Default False refuses it. + tolerance : float, optional + Share of the record norm below which a band or a window counts as + unoccupied; default :data:`DEFAULT_TOLERANCE`. + method : {'auto', 'direct'}, optional + ``'auto'`` copies on an identical lattice in an unchanged reference and + evaluates the direct sum otherwise; ``'direct'`` always evaluates it. + + Returns + ------- + tuple + The converted :class:`~solprop.coupling.FieldPlane` and its + :class:`RegridReport`. + + Raises + ------ + CouplingError + If the target band cannot hold the occupied frequencies, the target window + the record, or a nonlocalised or unknown-exterior record would be extended, + without the matching permission; or a value is invalid. + + Examples + -------- + >>> import numpy as np + >>> from solprop.coupling import FieldPlane, PlaneBasis, PlaneGeometry, regrid + >>> from solprop.coupling import basis_identity + >>> from solprop.fourier import spectrum_to_optics + >>> from solprop.grid import EnvelopeGrid + >>> from solprop.pulses import GaussianPulse + >>> source = EnvelopeGrid.from_limits(1.0, 800e-9, (600e-9, 1.2e-6), 1e-12) + >>> target = EnvelopeGrid.from_limits(1.0, 800e-9, (500e-9, 1.5e-6), 2e-12) + >>> pulse = GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=1e-6) + >>> samples = spectrum_to_optics(pulse.build(source), kind="envelope") + >>> basis = PlaneBasis("modal", basis_identity("modal", {"modes": ["one"]})) + >>> plane = FieldPlane.from_samples(samples, source, PlaneGeometry(0.0), + ... exterior="zero", basis=basis) + >>> moved, report = regrid(plane, target) + >>> bool(np.isclose(moved.energy(), plane.energy(), rtol=1e-9)) + True + >>> report.method, report.band_loss < 1e-10 + ('direct', True) + """ + if not (math.isfinite(tolerance) and 0.0 <= tolerance < 1.0): + msg = f"tolerance is a share in [0, 1), got {tolerance!r}" + raise CouplingError(msg) + old = plane.reference + new = PlaneReference( + old.phase if phase is None else phase, + old.delay if delay is None else delay, + old.calibrated, + ) + shift = old.delay - new.delay + phase_shift = old.phase - new.phase + source_grid = plane.grid + same = lattice_identity(source_grid) == lattice_identity(grid) and np.array_equal( + source_grid.omega, grid.omega + ) + n_modes, n_pol, _ = plane.samples.shape + columns = plane.samples.reshape(n_modes * n_pol, source_grid.n_omega) + weights = np.repeat(_column_weights(plane), n_pol) + + q_source = _q(source_grid) + per_bin = np.sum(weights[:, None] * np.abs(columns * q_source) ** 2, axis=0) + total_bins = float(np.sum(per_bin)) + # Parseval on the source lattice: sum_j |a_j|^2 dt = (dt/N) sum_k |q s|^2. + record_norm = total_bins * source_grid.dt / source_grid.n_t + if total_bins == 0.0: + band_loss = 0.0 + else: + outside = ~_within_band(grid, source_grid.omega) + band_loss = float(np.sum(per_bin[outside]) / total_bins) + if band_loss > tolerance and not crop_band: + msg = ( + f"{band_loss:.3e} of the record lies outside the target's active band " + f"[{np.min(grid.omega[grid.active_mask]):.6e}, " + f"{np.max(grid.omega[grid.active_mask]):.6e}] rad/s, which cannot " + "represent it; widen the target or allow the loss with crop_band=True" + ) + raise CouplingError(msg) + + if method == "auto" and same and shift == 0.0 and phase_shift == 0.0: + # The identity is exact: what lies outside the band, below the tolerance, is + # left for the receiving run's own hard mask, as the sum would zero it. + result = columns.copy() + report_method: Literal["copy", "direct"] = "copy" + window_loss = edge_share = 0.0 + else: + report_method = "direct" + analytic = _analytic(columns, source_grid) + inside, window_loss, edge_share = _record_window( + plane, + analytic, + weights, + grid, + shift, + truncate=truncate, + tolerance=tolerance, + ) + times = source_grid.t + shift + result = _direct_sum( + analytic[:, inside], times[inside], grid, source_grid, phase_shift + ) + + samples = result.reshape(n_modes, n_pol, grid.n_omega) + history = ( + f"regrid({report_method}; n_t {source_grid.n_t}->{grid.n_t}, " + f"{source_grid.kind}->{grid.kind}, shift {shift:.6e} s, " + f"phase {phase_shift:.6e} rad)" + ) + moved = FieldPlane( + plane.kind, + samples, + grid, + plane.units, + plane.basis, + new, + plane.geometry, + _target_metric(plane, grid, same), + plane.exterior, + plane.medium, + plane.certificate, + plane.nodal if report_method == "copy" else None, + plane.provenance.then(history), + ) + energy_in = None if plane.metric.kind == "unknown" else plane.energy() + energy_out = None if moved.metric.kind == "unknown" else moved.energy() + report = RegridReport( + report_method, + lattice_identity(source_grid), + lattice_identity(grid), + shift, + phase_shift, + record_norm, + window_loss, + band_loss, + edge_share, + truncate, + energy_in, + energy_out, + ) + return moved, report + + +def _record_window( # noqa: PLR0913 - the record and each permission it is checked by + plane: FieldPlane, + analytic: Complex[np.ndarray, "n_columns n_t"], + weights: Float[np.ndarray, " n_columns"], + grid: TimeGrid, + shift: float, + *, + truncate: bool, + tolerance: float, +) -> tuple[np.ndarray, float, float]: + """Return which source samples the target window keeps, and check the record. + + Parameters + ---------- + plane : FieldPlane + The source. + analytic : numpy.ndarray + Its analytic signal, shape ``(n_columns, n_t)``. + weights : numpy.ndarray + Each column's transverse weight. + grid : solprop.grid.TimeGrid + The target grid. + shift : float + Record-time shift, s. + truncate : bool + Whether the record may be cut and declared zero outside its window. + tolerance : float + Share below which a window counts as unoccupied. + + Returns + ------- + tuple + The kept-sample mask, the share outside the target window and the share in + the source's temporal collar. + + Raises + ------ + CouplingError + If light falls outside the target window, or a nonlocalised or + unknown-exterior record would be extended, without `truncate`. + """ + source = plane.grid + power = np.sum(weights[:, None] * np.abs(analytic) ** 2, axis=0) + total = float(np.sum(power)) + collar = source.t_collar + edge = (source.t < collar.one_low) | (source.t > collar.one_high) + edge_share = 0.0 if total == 0.0 else float(np.sum(power[edge]) / total) + times = source.t + shift + half = 0.5 * grid.dt + inside = (times >= grid.t[0] - half) & (times <= grid.t[-1] + half) + window_loss = 0.0 if total == 0.0 else float(np.sum(power[~inside]) / total) + if window_loss > tolerance and not truncate: + msg = ( + f"{window_loss:.3e} of the record falls outside the target window after " + f"a shift of {shift:.6e} s; widen the target, change the delay, or cut " + "it with truncate=True" + ) + raise CouplingError(msg) + source_half = 0.5 * source.dt + extends = (grid.t[0] - half < times[0] - source_half) or ( + grid.t[-1] + half > times[-1] + source_half + ) + if ( + extends + and not truncate + and (edge_share > tolerance or plane.exterior == "unknown") + ): + reason = ( + "its exterior is unknown" + if plane.exterior == "unknown" + else f"{edge_share:.3e} of it lies in the temporal collar" + ) + msg = ( + f"the target window extends this record beyond its own, and {reason}: " + "declare it zero outside with truncate=True" + ) + raise CouplingError(msg) + return inside, window_loss, edge_share + + +def _direct_sum( + analytic: Complex[np.ndarray, "n_columns n_inside"], + times: Float[np.ndarray, " n_inside"], + grid: TimeGrid, + source: TimeGrid, + phase_shift: float, +) -> Complex[np.ndarray, "n_columns n_omega"]: + """Evaluate the finite-time Fourier sum at the target's in-band bins. + + The sum of samples spaced ``dt`` is periodic in frequency with period + ``2 pi / dt``; outside the source lattice's own span it returns aliases of the + record, not light, so a target bin outside that span is zero: the source + represents nothing there. + + Parameters + ---------- + analytic : numpy.ndarray + Source analytic signal at the kept samples, shape ``(n_columns, n_inside)``. + times : numpy.ndarray + Their shifted times, s. + grid : solprop.grid.TimeGrid + The target grid. + source : solprop.grid.TimeGrid + The source grid. + phase_shift : float + Phase applied, rad. + + Returns + ------- + numpy.ndarray + Target stored spectra, zero outside the target's active band and outside + the source lattice's span, shape ``(n_columns, n_omega)``. + """ + result = np.zeros((analytic.shape[0], grid.n_omega), dtype=np.complex128) + half = 0.5 * source.d_omega + represented = (grid.omega >= float(np.min(source.omega)) - half) & ( + grid.omega <= float(np.max(source.omega)) + half + ) + bins = np.flatnonzero(_within_band(grid, grid.omega) & represented) + scale = source.dt * np.exp(1j * phase_shift) / (grid.dt * _q(grid)) + size = max(1, _CHUNK_ELEMENTS // max(1, times.size)) + for start in range(0, bins.size, size): + chosen = bins[start : start + size] + kernel = np.exp(1j * np.outer(times, grid.omega[chosen])) + result[:, chosen] = (analytic @ kernel) * scale[chosen][None, :] + return result diff --git a/src/solprop/exceptions.py b/src/solprop/exceptions.py index f47b8deef..dd8e8371e 100644 --- a/src/solprop/exceptions.py +++ b/src/solprop/exceptions.py @@ -15,6 +15,7 @@ "ApertureEdgeWarning", "BackendError", "ConvergenceError", + "CouplingError", "DiluteScalingWarning", "DriverError", "ExponentBudgetWarning", @@ -178,6 +179,10 @@ class ConvergenceError(SolpropError, RuntimeError): """A numerical procedure did not converge within its budget.""" +class CouplingError(SolpropError, ValueError): + """A field plane, a remap or a launch between simulations is refused (SEP 31).""" + + class ResponseBudgetError(ConvergenceError): """A run stopped before its end because its source-call budget was spent. diff --git a/tests/coupling/__init__.py b/tests/coupling/__init__.py new file mode 100644 index 000000000..2774627f4 --- /dev/null +++ b/tests/coupling/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later diff --git a/tests/coupling/test_plane.py b/tests/coupling/test_plane.py new file mode 100644 index 000000000..e79b175c2 --- /dev/null +++ b/tests/coupling/test_plane.py @@ -0,0 +1,292 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""The field plane, its references, geometry, metric and identities (SEP 31).""" + +import numpy as np +import pytest +from numpy.testing import assert_allclose + +from solprop.coupling import ( + FieldPlane, + PlaneBasis, + PlaneGeometry, + PlaneMetric, + PlaneProvenance, + PlaneReference, + basis_identity, + lattice_identity, + modal_basis_identity, + reference_at, + space_record, +) +from solprop.exceptions import CouplingError +from solprop.fourier import spectrum_to_optics +from solprop.grid import EnvelopeGrid, FieldGrid, FreeGrid, LineGrid, RadialGrid +from solprop.modes import MarcatiliMode +from solprop.output.schema import serialise +from solprop.pulses import GaussianPulse, SpatialPulse +from solprop.spatial import SpatialFlux, SpatialMedium, spatial_energy + +EPS = np.finfo(float).eps + + +def envelope_grid(trange=1e-12): + return EnvelopeGrid.from_limits(1.0, 800e-9, (600e-9, 1.2e-6), trange) + + +def modal_plane(grid=None, **changes): + grid = envelope_grid() if grid is None else grid + pulse = GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=1e-6) + samples = spectrum_to_optics(pulse.build(grid), kind=grid.kind) + basis = PlaneBasis("modal", basis_identity("modal", {"modes": ["test"]})) + return FieldPlane.from_samples( + samples, grid, PlaneGeometry(0.0), exterior="zero", basis=basis, **changes + ) + + +def test_a_plane_s_geometry_is_right_handed(): + geometry = PlaneGeometry(0.1, normal=(1.0, 0.0, 0.0), x_axis=(0.0, 1.0, 0.0)) + assert geometry.y_axis == (0.0, 0.0, 1.0) + assert geometry.oriented_as(geometry) + assert not geometry.oriented_as(PlaneGeometry(0.1)) + + +@pytest.mark.parametrize( + "changes", + [ + {"normal": (0.0, 0.0, 2.0)}, + {"x_axis": (0.0, 0.0, 1.0)}, + {"z": float("nan")}, + ], +) +def test_a_plane_s_geometry_refuses_what_is_not_a_frame(changes): + arguments = {"z": 0.0, **changes} + with pytest.raises(CouplingError): + PlaneGeometry(**arguments) + + +def test_a_reference_composes_and_keeps_only_shared_calibrations(): + one = PlaneReference(0.5, 2e-12) + two = PlaneReference(0.25, 1e-12, ("delay",)) + difference = one.minus(two) + assert (difference.phase, difference.delay) == (0.25, 1e-12) + assert difference.calibrated == ("delay",) + assert one.plus(two).calibrated == ("delay",) + assert PlaneReference.from_record(one.record()) == one + omega = np.array([0.0, 2e15]) + # One exponential of a product of two doubles: a few rounding errors. + assert_allclose( + one.factor(omega), np.exp(1j * (0.5 + omega * 2e-12)), rtol=8 * EPS, atol=0 + ) + + +@pytest.mark.parametrize("arguments", [(float("inf"), 0.0), (0.0, float("nan"))]) +def test_a_reference_refuses_nonfinite_values(arguments): + with pytest.raises(CouplingError): + PlaneReference(*arguments) + + +def test_the_persisted_record_rebuilds_the_reference(): + frame = { + "frequency_origin": 2e15, + "phase": {"breakpoints": [0.0, 1.0], "coefficients": [[0.5, 0.5]]}, + "delay": {"breakpoints": [0.0, 1.0], "coefficients": [[1e-9, 1e-9]]}, + } + record = {"origin": PlaneReference(0.1, 1e-12).record(), "frame": frame} + # On [0, 1] the series 0.5 (T0 + T1) is z, so Phi0(z) = z and T(z) = 2e-9 z. + reference = reference_at(record, 0.5) + assert reference.delay == pytest.approx(1e-12 + 1e-9, rel=1e-15) + assert reference.phase == pytest.approx(0.1 + 0.5 - 2e15 * 1e-9, rel=1e-14) + assert reference.calibrated == ("phase", "delay") + unknown = reference_at({"origin": record["origin"], "frame": None}, 0.5) + assert unknown.calibrated == () + with pytest.raises(CouplingError, match="outside the frame record"): + reference_at(record, 1.5) + + +def test_a_plane_owns_read_only_samples(): + grid = envelope_grid() + pulse = GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=1e-6) + samples = spectrum_to_optics(pulse.build(grid), kind=grid.kind) + basis = PlaneBasis("modal", basis_identity("modal", {"modes": ["test"]})) + plane = FieldPlane.from_samples( + samples, grid, PlaneGeometry(0.0), exterior="zero", basis=basis + ) + assert not plane.samples.flags.writeable + before = plane.samples.copy() + samples[...] = 0.0 + # An exact structural contract: the plane is a copy, untouched by its source. + assert np.array_equal(plane.samples, before) + with pytest.raises(ValueError, match="read-only"): + plane.samples[0, 0, 0] = 1.0 + assert "array(" not in repr(plane) + + +def test_a_modal_plane_measures_its_energy_with_the_identity_metric(): + plane = modal_plane() + # Parseval on 512 bins: a sum of squares, rounding of order n EPS. + assert plane.energy() == pytest.approx(1e-6, rel=1e-12) + dense = modal_plane( + metric=PlaneMetric( + "dense", matrix=np.ones((1, 1, plane.grid.n_omega), dtype=complex) + ) + ) + assert dense.energy() == pytest.approx(plane.energy(), rel=1e-14) + + +def test_a_plane_refuses_inconsistent_parts(): + plane = modal_plane() + with pytest.raises(CouplingError, match="samples have shape"): + FieldPlane( + "modal", + plane.samples[..., :-1], + plane.grid, + plane.units, + plane.basis, + plane.reference, + plane.geometry, + plane.metric, + ) + with pytest.raises(CouplingError, match="needs a radial basis"): + FieldPlane( + "radial", + plane.samples, + plane.grid, + plane.units, + plane.basis, + plane.reference, + plane.geometry, + plane.metric, + ) + with pytest.raises(CouplingError, match="not known"): + modal_plane(reference=PlaneReference(calibrated=("delay",))).absolute() + + +@pytest.mark.parametrize( + ("kind", "arguments", "match"), + [ + ("dense", {}, "carries"), + ("identity", {"weights": np.ones(2)}, "carries"), + ("unknown", {}, "says why"), + ], +) +def test_a_metric_carries_exactly_what_its_kind_names(kind, arguments, match): + with pytest.raises(CouplingError, match=match): + PlaneMetric(kind, **arguments) # pyright: ignore[reportArgumentType] - refused + + +def test_an_unknown_metric_measures_nothing(): + plane = modal_plane(metric=PlaneMetric("unknown", reason="not at hand")) + with pytest.raises(CouplingError, match="not at hand"): + plane.energy() + + +@pytest.mark.parametrize( + "space", [RadialGrid(4e-4, 48), FreeGrid(3e-4, 32, 3e-4, 32)], ids=["radial", "xy"] +) +@pytest.mark.parametrize("kind", ["field", "envelope"]) +def test_a_beam_plane_carries_its_physical_energy_and_node_round_trip(space, kind): + constructor = FieldGrid if kind == "field" else EnvelopeGrid + grid = constructor.from_limits(1e-3, 800e-9, (600e-9, 1.2e-6), 150e-15) + medium = SpatialMedium("Ar", 1.0) + beam = SpatialPulse( + GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=3e-9), 6e-5 + ) + plane = FieldPlane.from_pulse( + beam, grid, PlaneGeometry(0.0), space=space, medium=medium + ) + flux = SpatialFlux.resolve(grid, space, medium) + built = beam.build(grid, space, medium=medium) + # The same weighted sum of squares as spatial_energy, in another order. + assert plane.energy() == pytest.approx(spatial_energy(built, flux), rel=1e-12) + # The requested energy is the physical flux through the plane (SEP 34). + assert plane.energy() == pytest.approx(3e-9, rel=1e-9) + assert plane.units == ("V m" if isinstance(space, RadialGrid) else "V/m") + nodes = plane.node_spectra() + again = FieldPlane.from_samples( + nodes, grid, PlaneGeometry(0.0), exterior="zero", space=space, medium=medium + ) + # A transform and its inverse: the QDHT and FFT are well conditioned, so the round + # trip is a few hundred roundings of the largest sample. + scale = float(np.max(np.abs(plane.samples))) + assert_allclose(again.samples, plane.samples, rtol=0, atol=1e3 * EPS * scale) + assert again.basis.identity == plane.basis.identity + + +def test_from_samples_refuses_ambiguous_or_incomplete_declarations(): + grid = envelope_grid() + space = RadialGrid(4e-4, 16) + values = np.zeros((16, 1, grid.n_omega), complex) + basis = PlaneBasis("modal", basis_identity("modal", {"modes": []})) + geometry = PlaneGeometry(0.0) + with pytest.raises(CouplingError, match="space or coefficients"): + FieldPlane.from_samples(values, grid, geometry, exterior="zero") + with pytest.raises(CouplingError, match="medium"): + FieldPlane.from_samples(values, grid, geometry, exterior="zero", space=space) + with pytest.raises(CouplingError, match="16 nodes"): + FieldPlane.from_samples( + values[:4], + grid, + geometry, + exterior="zero", + space=space, + medium=SpatialMedium(), + ) + radial = PlaneBasis.of_space(space) + with pytest.raises(CouplingError, match="modal basis"): + FieldPlane.from_samples(values, grid, geometry, exterior="zero", basis=radial) + with pytest.raises(CouplingError, match="from_pulse takes"): + FieldPlane.from_pulse( + object(), grid, geometry, space=space, medium=SpatialMedium() + ) + assert basis.labels == () + + +def test_the_record_of_a_grid_is_the_record_a_file_keeps(): + for space in ( + RadialGrid(1e-3, 64), + FreeGrid(1e-3, 32, 2e-3, 16), + LineGrid(1e-3, 64), + ): + assert space_record(space) == serialise(space) + with pytest.raises(CouplingError, match="planar"): + from solprop.modes.slab import PlanarGrid # noqa: PLC0415 + + space_record(object.__new__(PlanarGrid)) + + +def test_a_lattice_is_its_samples_not_its_band_or_length(): + one = EnvelopeGrid.from_limits(1.0, 800e-9, (600e-9, 1.2e-6), 1e-12) + longer = EnvelopeGrid.from_limits(5.0, 800e-9, (600e-9, 1.2e-6), 1e-12) + wider = EnvelopeGrid.from_limits(1.0, 800e-9, (500e-9, 1.2e-6), 1e-12) + assert lattice_identity(one) == lattice_identity(longer) + assert lattice_identity(one) != lattice_identity(wider) + assert lattice_identity(one) != lattice_identity(envelope_grid(2e-12)) + + +def test_a_mode_set_s_basis_is_its_value_at_the_plane(): + grid = envelope_grid() + short = [MarcatiliMode(75e-6, "He", 1.0)] + same = [MarcatiliMode(75e-6, "He", 1.0)] + wider = [MarcatiliMode(80e-6, "He", 1.0)] + assert modal_basis_identity(short, grid, 0.0) == modal_basis_identity( + same, grid, 0.3 + ) + assert modal_basis_identity(short, grid, 0.0) != modal_basis_identity( + wider, grid, 0.0 + ) + + +def test_provenance_appends_its_history(): + provenance = PlaneProvenance("abc").then("one").then("two") + assert provenance.history == ("one", "two") + assert provenance.record()["source"] == "abc" + + +def test_a_basis_identity_is_a_digest(): + with pytest.raises(CouplingError, match="SHA-256"): + PlaneBasis("modal", "short") + with pytest.raises(CouplingError, match="transverse grid"): + PlaneBasis("radial", basis_identity("radial", {})) diff --git a/tests/coupling/test_port.py b/tests/coupling/test_port.py new file mode 100644 index 000000000..415d34560 --- /dev/null +++ b/tests/coupling/test_port.py @@ -0,0 +1,171 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Input ports, bound launches and the same-basis model (SEP 31).""" + +import dataclasses + +import numpy as np +import pytest + +from solprop.coupling import ( + CouplingLaunch, + FieldPlane, + InputPort, + PlaneBasis, + PlaneGeometry, + PlaneMetric, + PlaneReference, + basis_identity, + same_basis, +) +from solprop.exceptions import CouplingError +from solprop.fourier import spectrum_to_internal, spectrum_to_optics +from solprop.grid import EnvelopeGrid +from solprop.pulses import GaussianPulse + +LIMITS = (600e-9, 1.2e-6) + + +def grid_of(trange=1e-12, limits=LIMITS): + return EnvelopeGrid.from_limits(1.0, 800e-9, limits, trange) + + +BASIS = PlaneBasis("modal", basis_identity("modal", {"modes": ["HE11"]}), ("HE11",)) + + +def plane_of(grid=None, reference=None, metric=None): + grid = grid_of() if grid is None else grid + pulse = GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=1e-6) + samples = spectrum_to_optics(pulse.build(grid), kind=grid.kind) + samples[..., ~grid.active_mask] = 0.0 + return FieldPlane.from_samples( + samples, + grid, + PlaneGeometry(0.02), + exterior="zero", + basis=BASIS, + reference=PlaneReference(0.4, 3e-11) if reference is None else reference, + metric=metric, + ) + + +def port_of(grid=None, **changes): + grid = grid_of() if grid is None else grid + arguments = { + "route": "modal", + "grid": grid, + "basis": BASIS, + "shape": (1, 1, grid.n_omega), + "geometry": PlaneGeometry(0.02), + "frame": PlaneReference(0.1, 1e-11), + "metric": PlaneMetric("identity"), + } + arguments.update(changes) + return InputPort(**arguments) + + +def test_a_same_basis_launch_copies_and_inherits_the_reference(): + plane = plane_of() + port = port_of() + launch = same_basis(plane, port) + # The identity model copies the coefficients: an exact structural contract. + assert np.array_equal(launch.samples, plane.samples) + assert not launch.samples.flags.writeable + assert launch.origin == PlaneReference(0.4 - 0.1, 3e-11 - 1e-11) + assert np.array_equal( + launch.internal(), spectrum_to_internal(plane.samples, kind="envelope") + ) + record = launch.record() + assert record["model"] == "same_basis" + assert record["port"] == port.identity + assert record["origin"] == launch.origin.record() + assert "array(" not in repr(launch) + assert port.accepts(port_of()) is None + + +def test_a_port_names_every_disagreement_with_another(): + port = port_of() + other_basis = PlaneBasis("modal", basis_identity("modal", {"modes": ["HE12"]})) + cases = { + "basis": port_of(basis=other_basis), + "lattice": port_of(grid_of(2e-12), shape=(1, 1, grid_of(2e-12).n_omega)), + "side": port_of(geometry=PlaneGeometry(0.02, "after")), + "enters at": port_of(geometry=PlaneGeometry(0.03)), + "oriented": port_of(geometry=PlaneGeometry(0.02, x_axis=(0.0, 1.0, 0.0))), + "metric": port_of( + metric=PlaneMetric("dense", matrix=np.ones((1, 1, 512), complex)) + ), + } + for name, other in cases.items(): + refusal = port.accepts(other) + assert refusal is not None and name in refusal, name + + +def test_same_basis_refuses_anything_but_the_identical_reference(): + plane = plane_of() + other_basis = PlaneBasis("modal", basis_identity("modal", {"modes": ["HE12"]})) + longer = grid_of(2e-12) + cases = [ + (port_of(models=()), "not same_basis"), + (port_of(basis=other_basis), "a coupling, not a relaunch"), + (port_of(longer, shape=(1, 1, longer.n_omega)), "regrid it first"), + (port_of(shape=(2, 1, 512)), "shape"), + (port_of(geometry=PlaneGeometry(0.02, normal=(0.0, 1.0, 0.0))), "oriented"), + ( + port_of(metric=PlaneMetric("dense", matrix=np.ones((1, 1, 512), complex))), + "metric is identity", + ), + ] + for port, match in cases: + with pytest.raises(CouplingError, match=match): + same_basis(plane, port) + unknown = plane_of(metric=PlaneMetric("unknown", reason="no medium")) + with pytest.raises(CouplingError, match="no medium"): + same_basis(unknown, port_of()) + + +def test_same_basis_refuses_light_the_receiving_band_would_remove(): + wide = grid_of(limits=(500e-9, 1.6e-6)) + plane = plane_of(wide) + # The same lattice and a narrower requested band: the receiving run's hard mask + # would remove light, and the relaunch refuses to do so silently. + narrow = dataclasses.replace( + wide, omega_collar=grid_of(limits=(780e-9, 820e-9)).omega_collar + ) + with pytest.raises(CouplingError, match="outside the port's active band"): + same_basis(plane, port_of(narrow)) + + +def test_admittance_metrics_must_be_the_same_medium(): + grid = grid_of() + weights = np.ones(1) + gas = np.full((1, grid.n_omega), 1.0003 + 0j) + port = port_of( + metric=PlaneMetric("admittance", admittance=gas, weights=weights, scale=2.0) + ) + same = PlaneMetric("admittance", admittance=gas.copy(), weights=weights, scale=2.0) + assert port.accepts(dataclasses.replace(port, metric=same)) is None + for metric in ( + PlaneMetric("admittance", admittance=gas * 1.0001, weights=weights, scale=2.0), + PlaneMetric("admittance", admittance=gas, weights=weights, scale=1.0), + ): + assert port.accepts(dataclasses.replace(port, metric=metric)) is not None + + +def test_ports_and_launches_refuse_malformed_parts(): + grid = grid_of() + with pytest.raises(CouplingError, match="needs a radial basis"): + port_of(route="radial") + with pytest.raises(CouplingError, match="shape"): + port_of(shape=(1, 1, 3)) + with pytest.raises(CouplingError, match="reference index"): + port_of(reference_index=np.ones(3)) + port = port_of(reference_index=np.ones(grid.n_omega), nodes=np.zeros((2, 2))) + assert port.reference_index is not None and not port.reference_index.flags.writeable + assert port.nodes is not None and not port.nodes.flags.writeable + with pytest.raises(CouplingError, match="port's shape"): + CouplingLaunch( + port, np.zeros((2, 1, grid.n_omega), complex), PlaneReference(), "x", {} + ) diff --git a/tests/coupling/test_remap.py b/tests/coupling/test_remap.py new file mode 100644 index 000000000..23933309e --- /dev/null +++ b/tests/coupling/test_remap.py @@ -0,0 +1,296 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Temporal remapping of a field plane against the direct finite-time sum (SEP 31).""" + +import numpy as np +import pytest +from numpy.testing import assert_allclose + +from solprop.coupling import ( + FieldPlane, + PlaneBasis, + PlaneGeometry, + PlaneMetric, + PlaneReference, + basis_identity, + regrid, +) +from solprop.exceptions import CouplingError +from solprop.fourier import spectrum_to_optics +from solprop.grid import EnvelopeGrid, FieldGrid, RadialGrid +from solprop.pulses import GaussianPulse, PulseSum, SpatialPulse +from solprop.spatial import SpatialMedium + +EPS = np.finfo(float).eps +LIMITS = (600e-9, 1.2e-6) + + +def envelope(trange=1e-12, wavelength=800e-9, limits=LIMITS): + return EnvelopeGrid.from_limits(1.0, wavelength, limits, trange) + + +def field(trange=1e-12, limits=LIMITS): + return FieldGrid.from_limits(1.0, 800e-9, limits, trange) + + +def plane_of(pulse, grid, **changes): + samples = spectrum_to_optics(pulse.build(grid), kind=grid.kind) + basis = PlaneBasis("modal", basis_identity("modal", {"modes": ["test"]})) + return FieldPlane.from_samples( + samples, + grid, + PlaneGeometry(0.0), + exterior=changes.pop("exterior", "zero"), + basis=basis, + **changes, + ) + + +def q_factor(grid): + factor = np.ones(grid.n_omega) + if grid.kind == "field": + factor[1:-1] = 2.0 + return factor + + +def direct_sum(plane, grid, shift=0.0, phase=0.0): + """Evaluate the finite-time Fourier sum as written, one double sum, no FFT.""" + source = plane.grid + spectrum = plane.samples[:, :, :] + analytic = ( + np.einsum( + "mpk,kj->mpj", + spectrum * q_factor(source), + np.exp(-1j * np.outer(source.omega, source.t)), + ) + / source.n_t + ) + active = grid.omega[grid.active_mask] + half = 0.5 * source.d_omega + inside = ( + (grid.omega >= active.min()) + & (grid.omega <= active.max()) + # Beyond the source lattice the sum repeats: aliases, not light. + & (grid.omega >= source.omega.min() - half) + & (grid.omega <= source.omega.max() + half) + ) + kernel = np.exp(1j * np.outer(source.t + shift, grid.omega)) + result = np.einsum("mpj,jk->mpk", analytic, kernel) * source.dt * np.exp(1j * phase) + result /= grid.dt * q_factor(grid) + result[..., ~inside] = 0.0 + return result + + +def gaussian(energy=1e-6, wavelength=800e-9, t0=0.0): + return GaussianPulse(wavelength=wavelength, fwhm=30e-15, energy=energy, t0=t0) + + +def reference_budget(plane, grid): + """Rounding of two sums of N terms each, relative to the largest sample.""" + return 64 * (plane.grid.n_t + grid.n_t) * EPS * float(np.max(np.abs(plane.samples))) + + +def test_an_identical_lattice_copies_and_the_direct_sum_agrees(): + plane = plane_of(gaussian(), envelope()) + copied, report = regrid(plane, plane.grid) + assert report.method == "copy" + # The identity map is an exact structural contract. + assert np.array_equal(copied.samples, plane.samples) + direct, report = regrid(plane, plane.grid, method="direct") + assert report.method == "direct" + assert_allclose( + direct.samples, plane.samples, rtol=0, atol=reference_budget(plane, plane.grid) + ) + assert report.energy_out == pytest.approx(report.energy_in, rel=1e-12) + + +@pytest.mark.parametrize( + "target", + [ + envelope(2e-12), + envelope(1.5e-12, limits=(500e-9, 1.5e-6)), + field(2e-12), + envelope(2e-12, wavelength=900e-9, limits=(550e-9, 1.4e-6)), + ], + ids=["longer", "finer", "field", "carrier"], +) +@pytest.mark.parametrize("source_kind", ["envelope", "field"]) +def test_unequal_lattices_follow_the_direct_sum_and_the_continuous_pulse( + target, source_kind +): + source = envelope() if source_kind == "envelope" else field() + pulse = gaussian() + plane = plane_of(pulse, source) + moved, report = regrid(plane, target) + assert report.method == "direct" + assert_allclose( + moved.samples, + direct_sum(plane, target), + rtol=0, + atol=reference_budget(plane, target), + ) + # The same localised, band-limited pulse built on the target: two DFT samplings + # of one continuous spectrum differ by its aliased tails, below 1e-12 here. + expected = spectrum_to_optics(pulse.build(target), kind=target.kind) + scale = float(np.max(np.abs(expected))) + assert_allclose(moved.samples, expected, rtol=0, atol=1e-12 * scale) + # No renormalisation: the energy out is what the map gives, and it agrees. + assert moved.energy() == pytest.approx(plane.energy(), rel=1e-10) + assert report.band_loss < 1e-12 and report.window_loss == 0.0 + + +def test_a_delay_moves_the_record_and_leaves_the_absolute_spectrum(): + grid = envelope() + plane = plane_of(gaussian(), grid, reference=PlaneReference(0.3, 2e-12)) + step = 40 * grid.dt + moved, report = regrid(plane, grid, delay=2e-12 - step) + assert report.shift == pytest.approx(step, rel=1e-15) + assert_allclose( + moved.samples, + direct_sum(plane, grid, shift=step), + rtol=0, + atol=reference_budget(plane, grid), + ) + # The absolute spectrum is invariant under a change of reference. + assert_allclose( + moved.absolute(), plane.absolute(), rtol=0, atol=reference_budget(plane, grid) + ) + # A whole-sample delay of a localised record is a shift of its samples: the + # pulse peaks 40 samples later in the window. + synthesis = np.exp(-1j * np.outer(grid.omega, grid.t)) / grid.n_t + before = np.abs(plane.samples[0, 0] @ synthesis) + after = np.abs(moved.samples[0, 0] @ synthesis) + assert int(np.argmax(after)) - int(np.argmax(before)) == 40 + + +def test_a_carrier_phase_change_is_a_phase_of_the_stored_spectrum(): + grid = field() + plane = plane_of(gaussian(), grid) + moved, report = regrid(plane, grid, phase=-0.7) + assert report.phase_shift == pytest.approx(0.7) + active = grid.active_mask + # A copy times one exponential, against the direct sum's rounding. + assert_allclose( + moved.samples[..., active], + np.exp(0.7j) * plane.samples[..., active], + rtol=0, + atol=reference_budget(plane, grid), + ) + assert_allclose( + moved.absolute(), plane.absolute(), rtol=0, atol=reference_budget(plane, grid) + ) + + +def test_separated_bands_and_spectral_zeros_are_carried_without_interpolation(): + source = envelope(1e-12, limits=(500e-9, 1.6e-6)) + target = field(1.4e-12, limits=(450e-9, 1.8e-6)) + pulse = PulseSum((gaussian(wavelength=650e-9), gaussian(wavelength=1.3e-6))) + plane = plane_of(pulse, source) + moved, _ = regrid(plane, target) + expected = spectrum_to_optics(pulse.build(target), kind=target.kind) + scale = float(np.max(np.abs(expected))) + assert_allclose(moved.samples, expected, rtol=0, atol=1e-12 * scale) + # Between the bands the spectrum is at the level of the tails, not of a fit. + middle = (target.omega > 2.0e15) & (target.omega < 2.35e15) + assert float(np.max(np.abs(moved.samples[..., middle]))) < 1e-12 * scale + # A spectral zero with a sign change: the sum carries the complex value, so the + # pi phase jump is exact where an unwrapped-phase interpolation would smear it. + odd = plane.samples * (source.omega - source.omega0)[None, None, :] + signed = FieldPlane( + "modal", + odd, + source, + plane.units, + plane.basis, + plane.reference, + plane.geometry, + plane.metric, + ) + moved_odd, _ = regrid(signed, target) + assert_allclose( + moved_odd.samples, + direct_sum(signed, target), + rtol=0, + atol=reference_budget(signed, target), + ) + + +def test_an_occupied_band_the_target_cannot_hold_is_refused_or_reported(): + plane = plane_of(gaussian(), envelope(limits=(500e-9, 1.6e-6))) + # A field grid holds every frequency up to its Nyquist one, but its active band + # here starts at twice 400 nm, the pulse's own carrier: about half is outside. + narrow = field(limits=(300e-9, 400e-9)) + with pytest.raises(CouplingError, match="outside the target's active band"): + regrid(plane, narrow) + moved, report = regrid(plane, narrow, crop_band=True) + assert report.band_loss > 0.1 + # Nothing restores the removed light. + assert report.energy_out is not None and report.energy_in is not None + assert report.energy_out < report.energy_in * (1 - 0.5 * report.band_loss) + assert moved.energy() == report.energy_out + + +def test_a_window_that_cannot_hold_the_record_is_refused_or_cut(): + plane = plane_of(gaussian(t0=300e-15), envelope(1e-12)) + short = envelope(0.4e-12) + with pytest.raises(CouplingError, match="outside the target window"): + regrid(plane, short) + _, report = regrid(plane, short, truncate=True) + assert report.window_loss > 0.5 and report.truncated + + +def test_a_nonlocalised_or_unknown_record_is_not_extended_without_truncation(): + grid = envelope(0.2e-12) + edge = plane_of(GaussianPulse(wavelength=800e-9, fwhm=80e-15, energy=1e-6), grid) + longer = envelope(1e-12) + # A record cut by its window leaks across the band too; that loss is allowed + # here so that the window's refusal is the one met. + with pytest.raises(CouplingError, match="temporal collar"): + regrid(edge, longer, crop_band=True) + _, report = regrid(edge, longer, truncate=True, crop_band=True) + assert report.edge_share > 1e-10 + unknown = plane_of(gaussian(), envelope(1e-12), exterior="unknown") + with pytest.raises(CouplingError, match="exterior is unknown"): + regrid(unknown, envelope(2e-12)) + # The same window is no extension, so an unknown exterior is not consulted. + same, _ = regrid(unknown, envelope(1e-12), method="direct") + assert same.exterior == "unknown" + + +def test_regrid_refuses_an_invalid_tolerance(): + plane = plane_of(gaussian(), envelope()) + with pytest.raises(CouplingError, match="tolerance"): + regrid(plane, plane.grid, tolerance=1.0) + + +def test_a_spatial_metric_follows_the_plane_to_its_new_lattice(): + source = FieldGrid.from_limits(1e-3, 800e-9, LIMITS, 400e-15) + target = FieldGrid.from_limits(1e-3, 800e-9, LIMITS, 800e-15) + space = RadialGrid(4e-4, 32) + medium = SpatialMedium("Ar", 1.0) + beam = SpatialPulse(gaussian(energy=3e-9), 6e-5) + plane = FieldPlane.from_pulse( + beam, source, PlaneGeometry(0.0), space=space, medium=medium + ) + moved, report = regrid(plane, target) + assert moved.metric.kind == "admittance" and moved.metric.flux is not None + assert report.energy_out == pytest.approx(report.energy_in, rel=1e-10) + bare = FieldPlane( + plane.kind, + plane.samples, + plane.grid, + plane.units, + plane.basis, + plane.reference, + plane.geometry, + PlaneMetric( + "admittance", + admittance=plane.metric.admittance, + weights=plane.metric.weights, + scale=plane.metric.scale, + ), + ) + unknown, report = regrid(bare, target) + assert unknown.metric.kind == "unknown" and report.energy_out is None From 6e7b9dc0e7415a4a354ff8bbf1d6644eafd3cba8 Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 00:56:14 +0100 Subject: [PATCH 11/50] Plane exchange: output schema 24, field_plane and same-basis relaunch Runs persist the plane-reference record (frame integral, carrier, inherited origin, parent and basis identity per planned plane); processing.field_plane reads a saved plane back; Simulation.input_port describes the entrance and with_input installs a bound launch, recording its coupling provenance and drawing no new noise. The layers figure draws the new modules. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019qNYqtCKh4iTKj7544yARb --- docs/dev/figures/build.py | 4 + docs/dev/figures/layers.svg | 30 +- src/solprop/driver/simulation.py | 249 ++++++++++- src/solprop/output/schema.py | 41 +- src/solprop/processing/__init__.py | 3 + src/solprop/processing/planes.py | 513 +++++++++++++++++++++++ src/solprop/translate/coupling.py | 224 ++++++++++ tests/driver/test_coherent_capillary.py | 2 +- tests/driver/test_coupling_relaunch.py | 202 +++++++++ tests/driver/test_envelope_plasma.py | 2 +- tests/driver/test_general_modal.py | 2 +- tests/driver/test_material_manifest.py | 2 +- tests/driver/test_prop_free.py | 2 +- tests/driver/test_prop_radial.py | 2 +- tests/driver/test_prop_slab.py | 2 +- tests/driver/test_tdse_hacc_run.py | 2 +- tests/driver/test_temperature_paths.py | 2 +- tests/driver/test_vector_raman_output.py | 2 +- tests/output/test_handlers.py | 4 +- tests/output/test_material_resume.py | 2 +- tests/output/test_modal_resume.py | 8 +- tests/output/test_record_stability.py | 2 +- tests/output/test_schema.py | 2 +- tests/output/test_schema_lineages.py | 6 +- tests/processing/test_mixtures.py | 2 +- tests/processing/test_planes.py | 282 +++++++++++++ tests/test_display.py | 2 +- 27 files changed, 1548 insertions(+), 48 deletions(-) create mode 100644 src/solprop/processing/planes.py create mode 100644 src/solprop/translate/coupling.py create mode 100644 tests/driver/test_coupling_relaunch.py create mode 100644 tests/processing/test_planes.py diff --git a/docs/dev/figures/build.py b/docs/dev/figures/build.py index 29d80a12e..2bdfa80a0 100644 --- a/docs/dev/figures/build.py +++ b/docs/dev/figures/build.py @@ -597,6 +597,7 @@ class Layer(NamedTuple): Module("polarisation.py"), Module("optics.py"), Module("tools.py"), + Module("coupling/", ("plane", "port", "remap")), Module( "physdata/", ( @@ -669,6 +670,7 @@ class Layer(NamedTuple): "planar", "spatial", "flux", + "coupling", "responses", "ionisation", "plasma_integration", @@ -792,6 +794,7 @@ class Layer(NamedTuple): "compression", "mixtures", "modal", + "planes", "scans", ), ), @@ -805,6 +808,7 @@ class Layer(NamedTuple): """Every module of the package, in its layer's band. All of these exist.""" PLANNED = { + "P": (Module("coupling/", ("transport", "elements")),), "X": (Module("processing/", ("windows",)),), } """Modules `architecture.md` section 4 names that are not written yet. diff --git a/docs/dev/figures/layers.svg b/docs/dev/figures/layers.svg index 2a4f2693e..ef2f07987 100644 --- a/docs/dev/figures/layers.svg +++ b/docs/dev/figures/layers.svg @@ -121,13 +121,16 @@ text { font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; fill: optics.py tools.py - -physdata/ -sellmeier · equation_of_state -gases · glasses · metals -nonlinear_data · raman_types -species · mixtures · transport -selection + +coupling/ +plane · port · remap + +physdata/ +sellmeier · equation_of_state +gases · glasses · metals +nonlinear_data · raman_types +species · mixtures · transport +selection modes/ protocol · base · capillary @@ -142,6 +145,9 @@ text { font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; fill: optmat_rates · envelope_plasma raman · gnlse · normalisation mixtures · optmat_raman · tdse + +coupling/ +transport · elements T @@ -154,10 +160,10 @@ text { font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; fill: chebyshev · modal · modal_tables modal_path · coupled · general radial · free · planar · spatial -flux · responses · ionisation -plasma_integration · plasma_source -envelope_plasma · raman · masks -tdse · tdse_source +flux · coupling · responses +ionisation · plasma_integration +plasma_source · envelope_plasma +raman · masks · tdse · tdse_source B @@ -231,7 +237,7 @@ text { font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; fill: processing/ axes · fields · spectra · time coherence · compression · mixtures -modal · scans +modal · planes · scans plotting/ propagation · statistics diff --git a/src/solprop/driver/simulation.py b/src/solprop/driver/simulation.py index f2792213b..670b0c037 100644 --- a/src/solprop/driver/simulation.py +++ b/src/solprop/driver/simulation.py @@ -57,6 +57,15 @@ ) from solprop.boundaries import absorber as collar from solprop.constants import C +from solprop.coupling import ( + CouplingLaunch, + InputPort, + PlaneBasis, + PlaneReference, + basis_identity, + modal_basis_identity, + space_record, +) from solprop.driver import tdse_consistency from solprop.driver.material_output import ( material_manifest, @@ -240,6 +249,8 @@ taper_identity, wall_identity, ) +from solprop.translate.coupling import frame_record +from solprop.translate.coupling import input_port as fill_port from solprop.translate.envelope_plasma import ( CycleIntegralData, assembly_record, @@ -1025,6 +1036,7 @@ def metadata_for( # noqa: PLR0913 - the record's parts, each a distinct object modal: Mapping[str, JsonValue] | None = None, materials: MaterialManifest | None = None, quadrature: TransverseQuadrature | None = None, + frame: Frame | None = None, ) -> RunMetadata: """Build the record that says what produced a run, and whether it may be resumed. @@ -1066,6 +1078,10 @@ def metadata_for( # noqa: PLR0913 - the record's parts, each a distinct object The transverse rule of a resolved modal run, recorded with its nodes and physical weights on the first mode's cross-section and part of the schema-17 resume key (SEP 19). ``None`` for every other route. + frame : solprop.linear.Frame or None, optional + The run's frame, whose reference frequency is the origin of the linear frame + term on an envelope grid; ``None`` is the grid's co-moving frame. It enters + the schema-24 plane-reference record, not the hash. Returns ------- @@ -1190,6 +1206,7 @@ def metadata_for( # noqa: PLR0913 - the record's parts, each a distinct object } tdse_record = None if problem.tdse is None else dataclasses.asdict(problem.tdse) return RunMetadata( + reference=_plane_reference_record(problem, grid, record, frame, modes, modal), grid=GridRecord.from_grid(grid), modes=tuple(ModeRecord.from_mode(mode) for mode in modes), simulation_kind=( @@ -1213,6 +1230,119 @@ def metadata_for( # noqa: PLR0913 - the record's parts, each a distinct object ) +def _plane_reference_record( + problem: KernelProblem, + grid: TimeGrid, + configuration: Mapping[str, object], + frame: Frame | None, + modes: Sequence[Mode], + modal: Mapping[str, JsonValue] | None, +) -> dict[str, object]: + """Return the schema-24 plane-reference record of a run (SEP 31). + + Parameters + ---------- + problem : solprop.translate.problem.KernelProblem + The translated problem, whose frame tables are integrated. + grid : solprop.grid.TimeGrid + Its temporal grid. + configuration : collections.abc.Mapping + The run's configuration; a ``coupling`` entry supplies the inherited origin + and the parent's provenance, and a ``spatial_grid`` the transverse basis. + frame : solprop.linear.Frame or None + The run's frame, or ``None`` for the co-moving one. + modes : collections.abc.Sequence + The propagated modes of a modal run. + modal : collections.abc.Mapping or None + The general modal identity, for a general guide. + + Returns + ------- + dict + ``carrier``, ``origin``, ``parent``, ``frame`` and ``basis``, as + :attr:`solprop.output.schema.RunMetadata.reference` documents them. + """ + reference = grid.omega0 if frame is None else frame.omega0 + coupling = configuration.get("coupling") + origin: object = PlaneReference().record() + parent: object = None + if isinstance(coupling, Mapping): + coupled = cast("Mapping[str, object]", coupling) + origin = coupled["origin"] + parent = coupled["source"] + return { + "carrier": float(grid.omega0), + "origin": origin, + "parent": parent, + "frame": frame_record( + problem.linear, reference if grid.kind == "envelope" else 0.0 + ), + "basis": _plane_bases(problem, grid, configuration, modes, modal), + } + + +def _plane_bases( + problem: KernelProblem, + grid: TimeGrid, + configuration: Mapping[str, object], + modes: Sequence[Mode], + modal: Mapping[str, JsonValue] | None, +) -> dict[str, object]: + """Return a run's route and its basis identity at each planned plane. + + A spatial basis is its transverse grid and a general one its checked reference, + the same at every plane; an ordinary mode set's basis is evaluated at each plane + (:func:`solprop.coupling.modal_basis_identity`), because a guide may vary. + + Parameters + ---------- + problem : solprop.translate.problem.KernelProblem + The translated problem: its launch position and save planes. + grid : solprop.grid.TimeGrid + Its temporal grid. + configuration : collections.abc.Mapping + The run's configuration, carrying a spatial run's ``spatial_grid``. + modes : collections.abc.Sequence + The propagated modes of a modal run. + modal : collections.abc.Mapping or None + The general modal identity. + + Returns + ------- + dict + ``route``, ``z`` (the launch plane first, then the save planes, m) and + ``identity`` (one digest per plane, ``None`` on a planar run). + """ + planes = [float(problem.initial.z), *map(float, problem.stepper.save_planes)] + geometry = problem.transform.geometry + identity: str | None + if geometry is not None: + route = {"radial": "radial", "free": "cartesian", "slab": "planar"}[ + geometry.kind + ] + space = configuration.get("spatial_grid") + identity = ( + basis_identity(cast("Literal['radial']", route), space_record(space)) + if isinstance(space, RadialGrid | FreeGrid) + else None + ) + identities = [identity] * len(planes) + elif modal is not None and modal.get("basis") == "general_modal": + route = "general_modal" + metric = cast("Mapping[str, JsonValue]", modal.get("metric") or {}) + record = { + "channels": modal.get("channels"), + "tables": modal.get("tables"), + "guide": modal.get("guide"), + "metric": {"kind": metric.get("kind"), "gauge_id": metric.get("gauge_id")}, + } + identities = [basis_identity("general_modal", record)] * len(planes) + else: + route = "modal" + identities = [modal_basis_identity(modes, grid, z) for z in planes] + return {"route": route, "z": planes, "identity": identities} + + def _one_material_context[**P, R](build: Callable[P, R]) -> Callable[P, R]: """Run a constructor inside one material evaluation context, the run's own. @@ -1710,9 +1840,79 @@ def translate(self) -> KernelProblem: ) return self._problem + def input_port(self, z: float | None = None) -> InputPort: + """Return what this simulation can receive at its entrance (SEP 31). + + The port is the receiving side of a transfer between simulations: its route, + temporal lattice and basis with their identities, the entrance plane and the + frame accumulated there, the native metric at the entrance, the reference + index of a spatial medium and, for a general guide that does not move, its + E/H reference at the nodes. A launch computed for it + (:func:`solprop.coupling.same_basis`) is accepted by :meth:`with_input`, and + a launch computed for any other port is refused. + + Parameters + ---------- + z : float or None, optional + An interior entrance in m, for a modal run continued from inside its own + path; None is the simulation's launch position. + + Returns + ------- + solprop.coupling.InputPort + The port, admitting the ``same_basis`` model. + + Raises + ------ + DriverError + For a hybrid planar run, whose slab columns are not exchanged in this + release, or an interior entrance outside the run. + """ + problem = self.translate() + metadata = self.metadata() + reference = cast("Mapping[str, object]", metadata.reference) + bases = cast("Mapping[str, object]", reference["basis"]) + route = cast("str", bases["route"]) + if route == "planar": + msg = ( + "a hybrid planar run's columns are slab amplitudes per y frequency; " + "planar planes are not exchanged in this release" + ) + raise DriverError(msg) + if z is not None and not ( + math.isfinite(z) and 0.0 <= z < float(np.max(self.settings.save_planes)) + ): + msg = f"an interior entrance lies inside the run, got z = {z!r} m" + raise DriverError(msg) + identity = cast("list[str]", bases["identity"])[0] + if route == "modal" and z is not None: + identity = modal_basis_identity(self.modes, self.grid, z) + labels = tuple(mode.channel_label.display_label for mode in self.modes) + space = ( + self.spatial_grid + if isinstance(self.spatial_grid, RadialGrid | FreeGrid) + else None + ) + basis = PlaneBasis( + cast("Literal['modal', 'general_modal', 'radial', 'cartesian']", route), + identity, + labels, + space, + ) + return fill_port( + problem, + self.grid, + route=basis.kind, + basis=basis, + frame=cast("dict[str, object] | None", reference["frame"]), + medium=self.medium, + tables=None if self.general is None else self.general.tables, + z=z, + ) + def with_input( self, - initial: Pulse | SpatialPulse | np.ndarray, + initial: Pulse | SpatialPulse | np.ndarray | CouplingLaunch, *, noise: NoiseRealisation | Literal["keep"] | None = "keep", initial_z: float | None = None, @@ -1731,13 +1931,20 @@ def with_input( Parameters ---------- - initial : Pulse, SpatialPulse or numpy.ndarray + initial : Pulse, SpatialPulse, numpy.ndarray or solprop.coupling.CouplingLaunch Pulse or internal-convention spectrum on the existing grid. The built shape must match the original input, including modes and polarisation. Modal amplitudes are in sqrt(W); spatial units follow the spatial grid. + A :class:`~solprop.coupling.CouplingLaunch` (SEP 31) must be bound to + this simulation's :meth:`input_port` -- its route, lattice, basis, + entrance and metric -- or it is refused whatever its shape; its + entrance sets the launch position, and its provenance and inherited + reference origin are recorded as ``configuration["coupling"]``. noise : NoiseRealisation, {'keep'} or None, optional Keep the existing noise, replace it explicitly, or disable it with None. - Noise spectra must match the existing grid and input shape. + Noise spectra must match the existing grid and input shape. A launch + carries the realised field of its source, so for a launch ``'keep'`` + means no noise: no second floor is drawn unless a realisation is given. initial_z : float or None, optional Absolute launch position in metres. None retains this simulation's launch. An explicit position keeps only existing save planes beyond @@ -1759,8 +1966,9 @@ def with_input( Raises ------ DriverError - If the new field or noise shape differs or any sample is not finite, or - a `configuration` key is one the run records itself. + If the new field or noise shape differs or any sample is not finite, a + `configuration` key is one the run records itself, or a launch is bound + to another port or given beside an `initial_z`. Warns ----- @@ -1771,6 +1979,21 @@ def with_input( """ if isinstance(noise, str) and noise != "keep": raise DriverError("noise must be keep, None, or a NoiseRealisation") + coupling = None + if isinstance(initial, CouplingLaunch): + if initial_z is not None: + msg = "a launch carries its own entrance: give no initial_z beside it" + raise DriverError(msg) + entrance = initial.port.geometry.z + interior = None if entrance == self.initial_z else entrance + refusal = self.input_port(interior).accepts(initial.port) + if refusal is not None: + msg = f"this launch was computed for another input port: {refusal}" + raise DriverError(msg) + coupling = initial.record() + initial_z = interior + noise = None if isinstance(noise, str) else noise + initial = initial.internal() original = self.translate() launch_z = self.initial_z if initial_z is None else initial_z settings = self.settings @@ -1783,7 +2006,7 @@ def with_input( ) settings = dataclasses.replace(settings, save_planes=remaining) replacement_noise = self.noise if isinstance(noise, str) else noise - recorded = self._derived_configuration(initial, configuration) + recorded = self._derived_configuration(initial, configuration, coupling) updated = Simulation( self._base_grid, self.modes, @@ -1854,6 +2077,7 @@ def _derived_configuration( self, initial: Pulse | SpatialPulse | np.ndarray, configuration: Mapping[str, object] | None, + coupling: Mapping[str, object] | None = None, ) -> dict[str, object]: """Return a derived run's extra configuration: this run's, the caller's keys. @@ -1863,6 +2087,8 @@ def _derived_configuration( The replacement input. configuration : collections.abc.Mapping or None The caller's keys. + coupling : collections.abc.Mapping or None, optional + The provenance of a coupling launch, recorded as ``coupling``. Returns ------- @@ -1875,8 +2101,10 @@ def _derived_configuration( If a caller's key is one the run records itself. """ recorded = dict(self.configuration) - # Nor does the constructor's incident-beam account describe it. + # Nor does the constructor's incident-beam account describe it, nor a + # coupling that launched this run. recorded.pop("launch", None) + recorded.pop("coupling", None) # A constructor's pulse recipe must never describe a replacement spectrum. # Arrays carry their exact input in metadata, without an inferred recipe. if "pulse" in recorded: @@ -1884,7 +2112,9 @@ def _derived_configuration( if not isinstance(initial, np.ndarray): recorded["pulse"] = initial if configuration: - clash = sorted(set(configuration) & set(self.metadata().configuration)) + clash = sorted( + set(configuration) & ({"coupling"} | set(self.metadata().configuration)) + ) if clash: msg = ( f"configuration keys {clash} are recorded by the run itself; " @@ -1892,6 +2122,8 @@ def _derived_configuration( ) raise DriverError(msg) recorded.update(configuration) + if coupling is not None: + recorded["coupling"] = dict(coupling) return recorded def _check_replaced_input( @@ -2128,6 +2360,7 @@ def metadata(self) -> RunMetadata: modal=self._modal_identity(), materials=self.material_manifest(), quadrature=self.settings.quadrature, + frame=self.frame, ) return self._metadata diff --git a/src/solprop/output/schema.py b/src/solprop/output/schema.py index 0fd3274d0..5173434be 100644 --- a/src/solprop/output/schema.py +++ b/src/solprop/output/schema.py @@ -113,7 +113,7 @@ logger = logging.getLogger(__name__) -SCHEMA_VERSION = 23 +SCHEMA_VERSION = 24 """Version of the record layout; incremented whenever a stored field changes meaning. Version 9 adds the SEP 13 stepping method. Independent version-10 branches added @@ -170,6 +170,14 @@ ``flux_model`` and carries the run's plane-measure coefficients (``flux_plan``), from which a saved plane's admittance at its own position is rebuilt. Earlier spatial records used the vacuum measure and are refused by the processing that needs one. +Version 24 (SEP 31) carries the plane-reference record (``RunMetadata.reference``): +the antiderivatives of the frame quantities the run's linear operator removed, the +frequency origin of its linear term, the carrier, and the reference origin of its +path, which a run launched from another run's plane inherits with that parent's +identity (``configuration["coupling"]``). Any saved plane's absolute phase and time +reference is rebuilt from it alone. The tables are derived numbers, outside the hash; +the inherited origin is in the configuration, inside it. Planes of an earlier file +cannot be exchanged. """ INLINE_ARRAY_LIMIT = 2**20 @@ -2541,6 +2549,21 @@ class RunMetadata(Displayed): family, the counts, the nodes and the physical weights. Output schema 17 and in its resume key (SEP 19). ``None`` for the mode-averaged and spatial routes, and for any record older than schema 17, where it means "not recorded". + reference : collections.abc.Mapping or None + The plane-reference record of output schema 24 (SEP 31): ``carrier``, the + grid's reference frequency in rad/s; ``origin``, the reference of the run's + path origin (phase, delay, calibrated), inherited from a parent plane for a + run launched from one; ``parent``, that parent's provenance or ``None``; + ``frame``, the frame integral of + :func:`solprop.translate.coupling.frame_record`, or ``None`` for an operator + supplied without frame tables; and ``basis``, the route and the basis + identity (:func:`solprop.coupling.basis_identity`) at each planned plane + ``z``, the launch plane first. :func:`solprop.coupling.reference_at` + rebuilds any saved plane's reference from it. Derived numbers, so outside + the hash; the inherited origin enters it through + ``configuration["coupling"]``. + Written to JSON from schema 24 only; ``None`` for any record read back from + an older file. Examples -------- @@ -2582,6 +2605,7 @@ class RunMetadata(Displayed): initial_z: float = 0.0 tdse: Mapping[str, object] | None = None quadrature: Mapping[str, object] | None = None + reference: Mapping[str, object] | None = None # The two development branches both used schema 10. Preserve presence, not a # cached digest: changing a loaded scientific value must still change its hash. _stored_launch_fields: tuple[str, ...] | None = dataclasses.field( @@ -2651,10 +2675,12 @@ def __post_init__(self) -> None: object.__setattr__(self, "modes", tuple(self.modes)) object.__setattr__(self, "configuration", dict(self.configuration)) object.__setattr__(self, "versions", dict(self.versions)) - if self.spatial is not None: - object.__setattr__(self, "spatial", dict(self.spatial)) - if self.modal is not None: - object.__setattr__(self, "modal", dict(self.modal)) + # The plane-reference record is outside the hash, so a record of an earlier + # schema may hold one; it is written to JSON from schema 24 only. + for name in ("spatial", "modal", "reference"): + value = getattr(self, name) + if value is not None: + object.__setattr__(self, name, dict(value)) if not np.isfinite(self.initial_z) or self.initial_z < 0: raise OutputError("initial_z must be finite and nonnegative") if self.tdse is not None: @@ -2955,6 +2981,10 @@ def to_json(self) -> str: if self.schema_version >= 8: # The whole manifest, measurements included, beside its hashed part. document["materials"] = serialise(self.materials.to_dict()) + if self.schema_version >= 24: + # Derived from the hashed problem, so stored beside the key, not in it: a + # resume on another build may evaluate the frame to a different last bit. + document["reference"] = serialise(self.reference) document["spectral_convention"] = self.spectral_convention document["field_layout"] = self.field_layout document["backend"] = serialise(self.backend) @@ -3023,6 +3053,7 @@ def from_json(cls, text: str) -> "RunMetadata": # noqa: UP037 - bound after the initial_z=document.get("initial_z", 0.0), tdse=document.get("tdse"), quadrature=document.get("quadrature"), + reference=document.get("reference"), _stored_launch_fields=tuple( key for key in ("initial_z", "tdse") if key in document ), diff --git a/src/solprop/processing/__init__.py b/src/solprop/processing/__init__.py index 1ce2b657e..74f49dee8 100644 --- a/src/solprop/processing/__init__.py +++ b/src/solprop/processing/__init__.py @@ -68,6 +68,7 @@ physical_energy, reconstructed_flux, ) +from solprop.processing.planes import PLANE_TOLERANCE, field_plane from solprop.processing.spectra import ( DEFAULT_BANDPASS_BINS, DEFAULT_BANDPASS_FRACTION, @@ -97,6 +98,7 @@ "DEFAULT_BANDPASS_BINS", "DEFAULT_BANDPASS_FRACTION", "FIELD_KINDS", + "PLANE_TOLERANCE", "SPECTRAL_AXES", "ArrivalMethod", "Band", @@ -123,6 +125,7 @@ "electric_field", "energy", "field_autocorrelation", + "field_plane", "fwhm", "fwhm_f", "fwhm_t", diff --git a/src/solprop/processing/planes.py b/src/solprop/processing/planes.py new file mode 100644 index 000000000..351659e88 --- /dev/null +++ b/src/solprop/processing/planes.py @@ -0,0 +1,513 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Read one saved plane of a run back as a coherent field plane (SEP 31). + +:func:`field_plane` turns a stored plane into a :class:`solprop.coupling.FieldPlane`: +its native coefficients exactly as stored, the reference rebuilt from the run's +schema-24 plane-reference record, the medium's metric at the plane's own position, +the scoped path certificate and the provenance. It is the extraction half of plane +exchange; the manual page is ``docs/guide/workflows/coupling.md``. + +A file records what it can: the frame integral, the spatial plane measure and the +identities of a general guide's reference, not the reference's arrays. A general +modal plane's physical E/H and a dense basis Gram are therefore read with the +simulation that ran it (``source=``), checked against the file's identities first +through :mod:`solprop.processing.modal`. +""" + +from collections.abc import Callable, Mapping, Sequence +from typing import cast + +import numpy as np + +from solprop import fourier +from solprop.constants import EPS0, C +from solprop.coupling import ( + FieldPlane, + NodalField, + PlaneBasis, + PlaneGeometry, + PlaneKind, + PlaneMetric, + PlaneProvenance, + basis_identity, + reference_at, +) +from solprop.exceptions import OutputError, ProcessingError +from solprop.grid import FreeGrid, LineGrid, RadialGrid, TimeGrid +from solprop.modes.guide import ModalGuide +from solprop.output.schema import Output, RunMetadata +from solprop.processing.axes import _plane_field, grid_of +from solprop.processing.modal import _checked_tables, _plane_rule, _spectra +from solprop.spatial import SpatialFlux, SpatialMedium +from solprop.translate.coupled import ModalMetricTables, metric_at +from solprop.translate.flux import flux_from_record +from solprop.translate.modal_tables import ModalTransformTables +from solprop.translate.spatial import axial_admittance + +__all__ = ["PLANE_TOLERANCE", "field_plane"] + +PLANE_TOLERANCE = 1e-9 +"""Relative distance, of the larger of 1 m and |z|, within which a z names a plane. + +A requested position names a stored plane only if it is that plane to well within a +step; a nearby physical plane that was not stored is refused, not substituted. +""" + +_SPACES = { + "solprop.grid.RadialGrid": RadialGrid, + "solprop.grid.FreeGrid": FreeGrid, + "solprop.grid.LineGrid": LineGrid, +} +"""The transverse grids a spatial file's record names, by qualified class name.""" + + +def _source_part(source: object, name: str) -> object: + """Return an attribute of a ``source=`` simulation, or refuse an object without it. + + Parameters + ---------- + source : object + The :class:`solprop.driver.Simulation` handed in. + name : str + The attribute. + + Returns + ------- + object + Its value. + + Raises + ------ + ProcessingError + If `source` has no such attribute. + """ + if not hasattr(source, name): + msg = f"source= is a Simulation; {type(source).__name__} has no {name}" + raise ProcessingError(msg) + return getattr(source, name) + + +def _selected(output: Output, plane: int, z: float | None) -> tuple[int, float]: + """Return the index and stored position of the plane a selection names. + + Parameters + ---------- + output : solprop.output.schema.Output + An opened output. + plane : int + Plane index, negative from the end. + z : float or None + Position that must match a stored plane, m. + + Returns + ------- + tuple + The index counted from the start, and its stored position in m. + + Raises + ------ + ProcessingError + If the index is out of range, or no stored plane is at `z`. + """ + positions = np.asarray(output["z"], dtype=float) + if positions.size == 0: + msg = "the output holds no saved plane" + raise ProcessingError(msg) + if z is not None: + distance = np.abs(positions - z) + index = int(np.argmin(distance)) + if distance[index] > PLANE_TOLERANCE * max(1.0, abs(z)): + msg = ( + f"no plane is stored at z = {z!r} m; the nearest is at " + f"{positions[index]!r} m. Name a stored plane, or save one there" + ) + raise ProcessingError(msg) + return index, float(positions[index]) + index = plane + positions.size if plane < 0 else plane + if not 0 <= index < positions.size: + msg = f"plane {plane} is out of range for {positions.size} saved planes" + raise ProcessingError(msg) + return index, float(positions[index]) + + +def _space(record: object) -> RadialGrid | FreeGrid: + """Rebuild a spatial file's transverse grid from its configuration record. + + Parameters + ---------- + record : object + The ``spatial_grid`` entry: the grid itself in a memory output, its + serialised record in a file. + + Returns + ------- + solprop.grid.RadialGrid or solprop.grid.FreeGrid + The grid. + + Raises + ------ + ProcessingError + If the record names no known grid. + """ + if isinstance(record, RadialGrid | FreeGrid): + # A memory output keeps the live grid; a file keeps its serialised record. + return record + if not isinstance(record, Mapping): + msg = "this spatial run's record carries no transverse grid to rebuild" + raise ProcessingError(msg) + fields = dict(cast("Mapping[str, object]", record)) + kind = _SPACES.get(cast("str", fields.pop("__class__", ""))) + if kind is None: + msg = f"this run's transverse grid {record!r} is not exchanged in this release" + raise ProcessingError(msg) + return kind(**fields) # pyright: ignore[reportArgumentType] - the grid's own fields + + +def _lineage(metadata: RunMetadata) -> tuple[str, ...]: + """Return the configuration hashes a run descends from, oldest first. + + Parameters + ---------- + metadata : solprop.output.schema.RunMetadata + The run's record. + + Returns + ------- + tuple of str + Its parent's lineage and its parent, or empty for a run launched directly. + """ + reference = cast("Mapping[str, object]", metadata.reference) + parent = reference.get("parent") + if not isinstance(parent, Mapping): + return () + plane = cast("Mapping[str, object]", parent).get("plane") + if not isinstance(plane, Mapping): + return () + record = cast("Mapping[str, object]", plane) + earlier = tuple(cast("Sequence[str]", record.get("lineage", ()))) + return (*earlier, cast("str", record["source"])) + + +def _recorded_basis(metadata: RunMetadata, z: float) -> tuple[str, str]: + """Return a run's route and the basis identity it recorded at a plane. + + Parameters + ---------- + metadata : solprop.output.schema.RunMetadata + The run's record. + z : float + The stored plane's position, m. + + Returns + ------- + tuple of str + The route and the identity. A spatial or general basis is the same at every + plane; an ordinary mode set's plane that was not planned (a natural-step + save) has no recorded basis, and gets an identity that names it as + unrecorded, which no port matches. + """ + record = cast("Mapping[str, object]", metadata.reference)["basis"] + bases = cast("Mapping[str, object]", record) + route = cast("str", bases["route"]) + planned = np.asarray(cast("Sequence[float]", bases["z"]), dtype=float) + identities = cast("Sequence[str | None]", bases["identity"]) + distance = np.abs(planned - z) + index = int(np.argmin(distance)) + if route != "modal": + index = 0 + elif distance[index] > PLANE_TOLERANCE * max(1.0, abs(z)): + return route, basis_identity("modal", {"unrecorded at": z}) + identity = identities[index] + return route, "" if identity is None else identity + + +def _spatial_metric( + metadata: RunMetadata, + grid: TimeGrid, + space: RadialGrid | FreeGrid, + z: float, + source: object | None, +) -> tuple[PlaneMetric, SpatialMedium | None, Mapping[str, object] | None]: + """Return a spatial plane's admittance metric, medium and path certificate. + + Parameters + ---------- + metadata : solprop.output.schema.RunMetadata + The run's record. + grid : solprop.grid.TimeGrid + Its temporal grid. + space : solprop.grid.RadialGrid or solprop.grid.FreeGrid + Its transverse grid. + z : float + The plane's position, m. + source : Simulation or None + The simulation that ran it, for its medium. + + Returns + ------- + tuple + The metric (with a resolved measure when the medium is at hand), the + medium or None, and the path certificate or None. + + Raises + ------ + ProcessingError + If the record has no plane measure, or `source` is another run. + """ + spatial = cast("Mapping[str, object]", metadata.spatial) + if "flux_plan" not in spatial: + msg = "this spatial output carries no plane measure (output schema 23)" + raise ProcessingError(msg) + plan_record = cast("Mapping[str, object]", spatial["flux_plan"]) + admittance = axial_admittance( + flux_from_record(plan_record).at(z), grid.omega, slice(None) + ) + certificate = plan_record.get("admission") + medium = None + flux = None + if source is not None: + record = cast( + "RunMetadata", + cast("Callable[[], object]", _source_part(source, "metadata"))(), + ) + if record.configuration_hash() != metadata.configuration_hash(): + msg = "source= is not the simulation that wrote this output" + raise ProcessingError(msg) + medium = cast("SpatialMedium | None", _source_part(source, "medium")) + if medium is not None: + flux = SpatialFlux.resolve(grid, space, medium, z=z) + metric = PlaneMetric( + "admittance", + admittance=admittance, + weights=space.spectral_weights, + scale=0.5 * EPS0 * C, + flux=flux, + ) + return ( + metric, + medium, + None if certificate is None else cast("Mapping[str, object]", certificate), + ) + + +def _general_parts( + output: Output, + metadata: RunMetadata, + index: int, + z: float, + source: object | None, +) -> tuple[PlaneMetric, NodalField | None]: + """Return a general modal plane's metric and, with its source, its E/H. + + Parameters + ---------- + output : solprop.output.schema.Output + The opened output. + metadata : solprop.output.schema.RunMetadata + Its record. + index : int + The plane's index. + z : float + The plane's position, m. + source : Simulation or None + The simulation that ran it. + + Returns + ------- + tuple + The metric -- the identity, the source's dense Gram at the plane, or + unknown without a source -- and the nodal field or None. + + Raises + ------ + ProcessingError + If `source` carries no general guide or its reference is not the file's. + """ + modal = cast("Mapping[str, object]", metadata.modal) + declared = cast("Mapping[str, object]", modal.get("metric") or {}).get("kind") + if source is None: + if declared in (None, "identity"): + return PlaneMetric("identity"), None + return ( + PlaneMetric( + "unknown", + reason=( + f"the basis metric is {declared!r} and the file stores its " + "identity, not its arrays: read the plane with source=" + ), + ), + None, + ) + general = _source_part(source, "general") + guide = _source_part(source, "guide") + if general is None or not isinstance(guide, ModalGuide): + msg = "source= carries no general modal guide" + raise ProcessingError(msg) + try: + checked = _checked_tables( + metadata, + cast("ModalTransformTables", _source_part(general, "tables")), + guide, + ) + # The E/H synthesis is electric_field's and magnetic_field's, admitted by + # that name on every general basis. + electric, magnetic, _ = _spectra( + output, checked, index, None, "electric_field", guide + ) + nodes, weights = _plane_rule(checked, guide, z) + except OutputError as refused: + msg = f"source= is not the reference this output was written with: {refused}" + raise ProcessingError(msg) from refused + kind = metadata.grid.kind + nodal = NodalField( + nodes, + weights, + fourier.spectrum_to_optics(electric, kind=kind), + fourier.spectrum_to_optics(magnetic, kind=kind), + ) + problem = cast("Callable[[], object]", _source_part(source, "translate"))() + linear = _source_part(problem, "linear") + matrix = metric_at( + cast("ModalMetricTables | None", _source_part(linear, "modal_metric")), z + ) + metric = ( + PlaneMetric("identity") + if matrix is None + else PlaneMetric("dense", matrix=np.conj(matrix)) + ) + return metric, nodal + + +def field_plane( + output: Output, + plane: int = -1, + *, + z: float | None = None, + source: object | None = None, +) -> FieldPlane: + """Read one saved plane of a run as a :class:`~solprop.coupling.FieldPlane`. + + The samples are the stored optics-convention spectrum, unchanged. The reference + is rebuilt from the run's plane-reference record (output schema 24) at the + plane's stored position; the metric is the run's own at that position -- the + axial admittance of a spatial plane (SEP 34), the identity for ordinary + true-power coefficients, a general basis's Gram; the path certificate is the + spatial record's, with its scope. Radial, Cartesian, ordinary modal and general + modal runs are read; a hybrid planar run is refused by name. + + Parameters + ---------- + output : solprop.output.schema.Output + An opened memory or HDF5 output of output schema 24. + plane : int, optional + Index of the saved plane, negative from the end; default the last. + z : float or None, optional + Position of the plane instead, m; it must be a stored plane's to + :data:`PLANE_TOLERANCE`. A nearby plane is never substituted. + source : solprop.driver.Simulation or None, optional + The simulation that wrote the output, for what the file deliberately does + not embed: a general guide's E/H reference and a dense Gram, checked against + the file's identities, or a spatial run's medium, checked by configuration + hash. Not needed otherwise. + + Returns + ------- + solprop.coupling.FieldPlane + The plane. + + Raises + ------ + ProcessingError + If the output predates schema 24 or holds no record, both `plane` and `z` + are given, the selection names no stored plane, the run is planar, or + `source` is not the run that wrote it. + + Examples + -------- + >>> from solprop.driver import Simulation + >>> from solprop.processing import field_plane + >>> simulation = Simulation.from_gnlse( + ... 0.11, 0.01, [0.0, 0.0, -1.183e-26], lambda0=835e-9, + ... wavelength_limits=(500e-9, 2e-6), trange=1e-12, + ... width=100e-15, peak_power=9.8, pulseshape="sech", n_save=3, + ... ) + >>> plane = field_plane(simulation.run()) + >>> plane.kind, plane.geometry.z, plane.units + ('modal', 0.01, 'sqrt(W)') + """ + metadata = output.metadata + if metadata is None: + msg = "field_plane needs an opened output holding its run record" + raise ProcessingError(msg) + if metadata.schema_version < 24 or metadata.reference is None: + msg = ( + f"this output was written at schema {metadata.schema_version} and carries " + "no plane-reference record (output schema 24, SEP 31): its planes " + "cannot be exchanged" + ) + raise ProcessingError(msg) + if z is not None and plane != -1: + msg = f"give a plane index or a position, not both: plane={plane}, z={z}" + raise ProcessingError(msg) + index, position = _selected(output, plane, z) + samples = _plane_field(output, index, None, convention="optics") + grid = grid_of(output) + route, identity = _recorded_basis(metadata, position) + if route == "planar": + msg = ( + "a hybrid planar run's columns are slab amplitudes per y frequency; " + "planar planes are not exchanged in this release" + ) + raise ProcessingError(msg) + kind = cast("PlaneKind", route) + reference = reference_at(metadata.reference, position) + provenance = PlaneProvenance( + metadata.configuration_hash(), index, position, _lineage(metadata), () + ) + geometry = PlaneGeometry(position) + if kind in ("radial", "cartesian"): + space = _space(metadata.configuration.get("spatial_grid")) + basis = PlaneBasis(kind, identity, space=space) + metric, medium, certificate = _spatial_metric( + metadata, grid, space, position, source + ) + return FieldPlane( + kind, + samples, + grid, + "V m" if kind == "radial" else "V/m", + basis, + reference, + geometry, + metric, + "zero", + medium, + certificate, + provenance=provenance, + ) + labels = tuple( + cast("str", mode.label["display_label"]) + for mode in metadata.modes + if mode.label is not None + ) + basis = PlaneBasis(kind, identity, labels) + nodal = None + metric = PlaneMetric("identity") + if kind == "general_modal": + metric, nodal = _general_parts(output, metadata, index, position, source) + return FieldPlane( + kind, + samples, + grid, + "sqrt(W)", + basis, + reference, + geometry, + metric, + "zero", + nodal=nodal, + provenance=provenance, + ) diff --git a/src/solprop/translate/coupling.py b/src/solprop/translate/coupling.py new file mode 100644 index 000000000..a216b9981 --- /dev/null +++ b/src/solprop/translate/coupling.py @@ -0,0 +1,224 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Sample a translated problem's entrance into a coupling port (SEP 31). + +Two products of the translation layer feed plane exchange. :func:`frame_record` is +the persisted frame integral of a run: the antiderivatives of the frame quantities +its linear operator removed, so that any saved plane's reference can be rebuilt +without the run (output schema 24). :func:`input_port` fills the +:class:`solprop.coupling.InputPort` of a translated problem's entrance: its native +metric at that plane, the frame it has accumulated there, the entrance medium's +index and, for a general guide that does not move, its E/H reference at the nodes. +Both read the contract's own leaves, so a port and a saved plane of the same run see +the same numbers. +""" + +from typing import cast + +import numpy as np + +from solprop.constants import EPS0, C +from solprop.coupling import ( + InputPort, + PlaneBasis, + PlaneGeometry, + PlaneKind, + PlaneMetric, + PlaneReference, + reference_at, +) +from solprop.exceptions import TranslationError +from solprop.grid import TimeGrid +from solprop.spatial import FluxSide, SpatialMedium +from solprop.translate.chebyshev import PiecewiseChebyshev +from solprop.translate.coupled import metric_at +from solprop.translate.linop import LinearOperator +from solprop.translate.modal_tables import ModalTransformTables +from solprop.translate.problem import KernelProblem +from solprop.translate.spatial import axial_admittance + +__all__ = ["frame_record", "input_port", "port_metric"] + + +def frame_record( + linear: LinearOperator, frequency_origin: float +) -> dict[str, object] | None: + r"""Return the persisted frame integral of a run's linear operator. + + The operator removed :math:`R = \beta_0(z) + \beta_1(z)(\omega - \omega_o)` + (``docs/dev/conventions.md`` section 2; :class:`solprop.linear.Frame`), with + :math:`\beta_0` zero where the frame keeps it. The record holds the exact + Chebyshev antiderivatives :math:`\Phi_0 = \int_0^z\beta_0` and + :math:`T = \int_0^z\beta_1` of the contract's own frame tables and the frequency + origin, which is all :func:`solprop.coupling.reference_at` needs. + + Parameters + ---------- + linear : solprop.translate.linop.LinearOperator + The contract's linear operator. + frequency_origin : float + :math:`\omega_o` in rad/s: the frame's reference frequency on an envelope + grid, zero on a field grid. + + Returns + ------- + dict or None + JSON-ready ``frequency_origin`` and the ``phase`` (rad) and ``delay`` (s) + antiderivatives, each its ``breakpoints`` (m) and per-interval + ``coefficients``, lowest degree first; None for an operator supplied + without its frame tables. + """ + if linear.beta0 is None or linear.beta1 is None: + return None + return { + "frequency_origin": float(frequency_origin), + "phase": _antiderivative(linear.beta0), + "delay": _antiderivative(linear.beta1), + } + + +def _antiderivative(values: PiecewiseChebyshev) -> dict[str, object]: + """Return the edges and coefficients of a scalar table's antiderivative. + + Parameters + ---------- + values : solprop.translate.chebyshev.PiecewiseChebyshev + A scalar frame table. + + Returns + ------- + dict + JSON-ready ``breakpoints`` (m) and per-interval ``coefficients``, lowest + degree first, of the exact antiderivative vanishing at the first edge. + """ + integral = values.integral() + coefficients = np.real(integral.coefficients).reshape(integral.degrees.size, -1) + return { + "breakpoints": [float(one) for one in integral.breakpoints], + "coefficients": coefficients.tolist(), + } + + +def port_metric(problem: KernelProblem, grid: TimeGrid, z: float) -> PlaneMetric: + """Return the native metric of a translated problem's state at a plane. + + Parameters + ---------- + problem : solprop.translate.problem.KernelProblem + The translated problem. + grid : solprop.grid.TimeGrid + Its temporal grid. + z : float + The plane's position, m. + + Returns + ------- + solprop.coupling.PlaneMetric + The axial admittance of a spatial run's plane measure at ``z`` (SEP 34), + the optics-convention Gram ``conj(W)`` of a modal basis that declares one, + and the identity for true-power coefficients otherwise. + """ + geometry = problem.transform.geometry + if geometry is not None: + if problem.flux is None: + msg = "a spatial problem carries its plane measure" + raise TranslationError(msg) + admittance = axial_admittance(problem.flux.at(z), grid.omega, slice(None)) + return PlaneMetric( + "admittance", + admittance=admittance, + weights=geometry.spectral_weights, + scale=1.0 if geometry.kind == "slab" else 0.5 * EPS0 * C, + ) + matrix = metric_at(problem.linear.modal_metric, z) + if matrix is None: + return PlaneMetric("identity") + # The state is internal-convention; a plane's samples are optics-convention, a + # conjugation away, so the same quadratic form takes conj(W). + return PlaneMetric("dense", matrix=np.conj(matrix)) + + +def input_port( # noqa: PLR0913 - the problem and each part of the entrance it does not hold + problem: KernelProblem, + grid: TimeGrid, + *, + route: PlaneKind, + basis: PlaneBasis, + frame: dict[str, object] | None, + medium: SpatialMedium | None = None, + tables: ModalTransformTables | None = None, + side: FluxSide | None = None, + z: float | None = None, +) -> InputPort: + """Fill the input port of a translated problem's entrance. + + Parameters + ---------- + problem : solprop.translate.problem.KernelProblem + The translated problem; its launch position is the entrance. + grid : solprop.grid.TimeGrid + Its temporal grid. + route : {'modal', 'general_modal', 'radial', 'cartesian', 'planar'} + The receiving representation. + basis : solprop.coupling.PlaneBasis + The receiving basis and its identity, as the driver computes it from the + run's record. + frame : dict or None + :func:`frame_record` of the problem; None leaves the entrance frame + uncalibrated. + medium : solprop.spatial.SpatialMedium or None, optional + The bulk medium of a spatial route, for the reference index. + tables : solprop.translate.modal_tables.ModalTransformTables or None, optional + The E/H reference of a general guide; sampled into the port when the guide + does not move. + side : {'before', 'after'} or None, optional + Side of a declared jump at the entrance. + z : float or None, optional + An interior entrance, m, inside the problem's span; None is its launch + position. + + Returns + ------- + solprop.coupling.InputPort + The port, admitting the ``same_basis`` model. + """ + z = float(problem.initial.z) if z is None else float(z) + record: dict[str, object] = { + "origin": PlaneReference().record(), + "frame": frame, + } + entrance = reference_at(record, z) + index = None + if medium is not None: + live = grid.omega > 0 + values = np.zeros(grid.n_omega) + values[live] = np.real(medium.index(grid.omega[live], z)) + index = values + nodes = weights = electric = magnetic = None + if tables is not None: + synthesis = tables.synthesis + moves = synthesis is not None and synthesis.shape[0] * synthesis.shape[1] > 1 + if not moves: + nodes = tables.node_xy[0] + weights = tables.weights[0] + # Internal-convention reference, conjugated to the optics convention of + # a plane's samples. + electric = np.conj(tables.electric[0]) + magnetic = np.conj(tables.magnetic[0]) + shape = cast("tuple[int, int, int]", tuple(int(one) for one in problem.shape[1:])) + return InputPort( + route, + grid, + basis, + shape, + PlaneGeometry(z, side), + entrance, + port_metric(problem, grid, z), + index, + nodes, + weights, + electric, + magnetic, + ) diff --git a/tests/driver/test_coherent_capillary.py b/tests/driver/test_coherent_capillary.py index d25684eb2..2f62fe25d 100644 --- a/tests/driver/test_coherent_capillary.py +++ b/tests/driver/test_coherent_capillary.py @@ -494,7 +494,7 @@ def test_the_route_records_its_flow_on_the_schema_seven_identity(): assert record["residual_norm_bound"] == 0.0 assert record["radius"] == RADIUS assert 0.0 < float(cast("float", record["quasi_static"])) < 0.25 - assert metadata.schema_version == 23 + assert metadata.schema_version == 24 assert capability_of(metadata) == "modal" assert "mean impedance coherent wall" in str(metadata) # Nothing that is not a scalar reaches the resume key. diff --git a/tests/driver/test_coupling_relaunch.py b/tests/driver/test_coupling_relaunch.py new file mode 100644 index 000000000..d53d3b36a --- /dev/null +++ b/tests/driver/test_coupling_relaunch.py @@ -0,0 +1,202 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Same-basis relaunch: a saved plane continues as a new run (SEP 31, phase 2). + +The continued run must reproduce the uninterrupted one after the frame conversion. +Both are adaptive, so the comparison is an accuracy envelope against a tighter +reference run, not a rounding budget: the relaunch restarts the step controller and +the Adams history, so it takes other steps (``docs/dev/testing.md`` section 3). +""" + +import itertools +from typing import Any, cast + +import numpy as np +import pytest + +from solprop.backends.registry import available +from solprop.coupling import same_basis +from solprop.driver import Simulation, SimulationSettings +from solprop.exceptions import CouplingError, DriverError +from solprop.grid import FieldGrid, RadialGrid +from solprop.processing import field_plane +from solprop.pulses import GaussianPulse, SpatialPulse +from solprop.spatial import SpatialMedium +from solprop.stepper import PIController + +ENVELOPE = 1e-9 +"""Accuracy envelope of a run at rtol 1e-8 against one at 1e-11, of the peak sample. + +Measured on these 2 mm argon capillary cases: the uninterrupted run is 1.3e-10 from +the reference and the relaunched one 1.4e-10, against 1.3e-8 and 1.5e-8 at rtol +1e-6, so both follow the tolerance. The envelope leaves a factor of about seven. +""" + + +def capillary( + length, n_save, *, rtol=1e-8, backend="numpy_ref", radius=100e-6, **changes +): + arguments = { + "lambda0": 800e-9, + "wavelength_limits": (400e-9, 1.6e-6), + "trange": 200e-15, + "fwhm": 20e-15, + "energy": 20e-6, + "modes": 2, + "n_save": n_save, + "plasma": False, + "noise": None, + "backend": backend, + "rtol": rtol, + **changes, + } + return Simulation.from_capillary(radius, length, "Ar", 1.0, **arguments) + + +@pytest.fixture(scope="module") +def reference(): + return field_plane(capillary(2e-3, 3, rtol=1e-11).run(progress="none")) + + +def relaunched(source, receiver): + """Run 2 mm in one go and as 1 mm + 1 mm, and return both final planes.""" + whole = capillary(2e-3, 3, backend=source).run(progress="none") + first = capillary(1e-3, 2, backend=source).run(progress="none") + second = capillary(1e-3, 2, backend=receiver) + launch = same_basis(field_plane(first), second.input_port()) + rest = second.with_input(launch).run(progress="none") + return field_plane(whole), field_plane(rest) + + +def deviation(plane, reference): + scale = float(np.max(np.abs(reference.samples))) + return float(np.max(np.abs(plane.samples - reference.samples))) / scale + + +def test_a_relaunched_run_continues_the_uninterrupted_one(reference): + whole, rest = relaunched("numpy_ref", "numpy_ref") + assert deviation(whole, reference) < ENVELOPE + assert deviation(rest, reference) < ENVELOPE + # The inherited origin plus the second run's own frame is the whole path's: two + # Chebyshev antiderivatives of the same constant frame, to rounding. + assert rest.reference.delay == pytest.approx(whole.reference.delay, rel=1e-12) + assert rest.reference.phase == pytest.approx(whole.reference.phase, abs=1e-9) + assert rest.provenance.lineage == ( + field_plane(capillary(1e-3, 2).run(progress="none")).provenance.source, + ) + + +@pytest.mark.medium +@pytest.mark.parametrize( + ("source", "receiver"), + [ + pair + for pair in itertools.product(available(), repeat=2) + if pair != ("numpy_ref", "numpy_ref") + ], +) +def test_every_cpu_source_and_receiver_pair_relaunches(source, receiver, reference): + _, rest = relaunched(source, receiver) + assert deviation(rest, reference) < ENVELOPE + + +def test_an_interior_relaunch_continues_its_own_run(reference): + simulation = capillary(2e-3, 3) + output = simulation.run(progress="none") + port = simulation.input_port(1e-3) + assert port.geometry.z == 1e-3 + assert port.frame.delay == pytest.approx(field_plane(output, 1).reference.delay) + launch = same_basis(field_plane(output, z=1e-3), port) + # The run's own frame up to 1 mm is the launch's reference: no origin is added. + assert launch.origin.delay == pytest.approx(0.0, abs=1e-24) + child = simulation.with_input(launch) + assert child.initial_z == 1e-3 + rest = field_plane(child.run(progress="none")) + assert deviation(rest, reference) < ENVELOPE + with pytest.raises(DriverError, match="inside the run"): + simulation.input_port(5e-3) + + +def radial(pressure=1.0, length=2e-3, backend="numpy_ref"): + grid = FieldGrid.from_limits(2e-3, 800e-9, (600e-9, 1.2e-6), 400e-15) + settings = SimulationSettings( + controller=PIController(rtol=1e-10, atol=1e-16), + save_planes=np.linspace(0.0, length, 3)[1:], + dz_max=2e-4, + dz_init=2e-4, + backend=backend, + ) + pulse = SpatialPulse( + GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=3e-9), 6e-5 + ) + return Simulation.from_radial( + grid, + RadialGrid(6e-4, 32), + SpatialMedium("Ar", pressure), + [], + pulse, + settings=settings, + ) + + +def test_a_radial_plane_relaunches_into_the_same_grid_and_medium(): + whole = field_plane(radial().run(progress="none")) + first = field_plane(radial(length=1e-3).run(progress="none")) + second = radial(length=1e-3) + rest = field_plane(second.with_input(same_basis(first, second.input_port())).run()) + # A linear radial run is the exact flow of each column; the two stages and the + # whole differ by the step sequence's rounding, a few hundred ulp of the phase. + scale = float(np.max(np.abs(whole.samples))) + assert float(np.max(np.abs(rest.samples - whole.samples))) < 1e-10 * scale + assert rest.energy() == pytest.approx(whole.energy(), rel=1e-12) + assert rest.reference.delay == pytest.approx(whole.reference.delay, rel=1e-12) + denser = radial(pressure=2.0, length=1e-3) + with pytest.raises(CouplingError, match="change of medium"): + same_basis(first, denser.input_port()) + launch = same_basis(first, second.input_port()) + with pytest.raises(DriverError, match="metric"): + denser.with_input(launch) + + +def test_a_launch_is_refused_by_any_other_simulation(): + first = field_plane(capillary(1e-3, 2).run(progress="none")) + second = capillary(1e-3, 2) + launch = same_basis(first, second.input_port()) + wider = capillary(1e-3, 2, radius=110e-6) + with pytest.raises(DriverError, match="basis"): + wider.with_input(launch) + with pytest.raises(CouplingError, match="not a relaunch"): + same_basis(first, wider.input_port()) + longer = capillary(1e-3, 2, trange=400e-15) + with pytest.raises(DriverError, match="lattice"): + longer.with_input(launch) + with pytest.raises(DriverError, match="initial_z"): + second.with_input(launch, initial_z=0.0) + with pytest.raises(DriverError, match="recorded by the run itself"): + second.with_input(launch, configuration={"coupling": "mine"}) + + +def test_a_launch_records_its_provenance_and_draws_no_noise(): + first = field_plane(capillary(1e-3, 2).run(progress="none")) + noisy = capillary(1e-3, 2, noise="input", seed=5) + assert noisy.noise is not None + launch = same_basis(first, noisy.input_port()) + child = noisy.with_input(launch) + assert child.noise is None + record = cast("dict[str, Any]", child.metadata().configuration["coupling"]) + assert record["model"] == "same_basis" + assert record["port"] == launch.port.identity + assert record["source"]["plane"]["source"] == first.provenance.source + reference = child.metadata().reference + assert reference is not None + assert reference["origin"] == first.reference.minus(launch.port.frame).record() + assert reference["parent"] == record["source"] + kept = noisy.with_input(launch, noise=noisy.noise) + assert kept.noise is noisy.noise + # A plain array input into a coupled run is not that coupling any more. + plain = child.with_input(launch.internal()) + assert "coupling" not in plain.configuration + grandchild_port = plain.input_port() + assert grandchild_port.identity == launch.port.identity diff --git a/tests/driver/test_envelope_plasma.py b/tests/driver/test_envelope_plasma.py index c0f7ea85e..865a39ecf 100644 --- a/tests/driver/test_envelope_plasma.py +++ b/tests/driver/test_envelope_plasma.py @@ -261,7 +261,7 @@ def test_envelope_hdf5_resume_and_reconstruction( budget = max(budget, fresh) np.testing.assert_allclose(actual, expected, rtol=0, atol=budget * scale) assert restored.metadata is not None - assert restored.metadata.schema_version == 23 + assert restored.metadata.schema_version == 24 assert restored.metadata.configuration["statistic_rate_conventions"] == { "peak_ionisation_rate": "cycle-averaged", "species_peak_ionisation_rate": "cycle-averaged", diff --git a/tests/driver/test_general_modal.py b/tests/driver/test_general_modal.py index 39a5eee19..ea60dc574 100644 --- a/tests/driver/test_general_modal.py +++ b/tests/driver/test_general_modal.py @@ -1106,7 +1106,7 @@ def test_an_hdf5_file_carries_the_labels_and_the_identities(guide, grid, tmp_pat simulation = build(guide, grid) simulation.run(path, progress="none") stored = resume(path).metadata - assert stored.schema_version == 23 + assert stored.schema_version == 24 assert stored.modal is not None assert dict(stored.modal)["basis"] == "general_modal" labels = [dict(one.label) for one in stored.modes if one.label is not None] diff --git a/tests/driver/test_material_manifest.py b/tests/driver/test_material_manifest.py index 5ff0500f6..fb329f780 100644 --- a/tests/driver/test_material_manifest.py +++ b/tests/driver/test_material_manifest.py @@ -541,7 +541,7 @@ def test_ten_steps_write_the_manifest_and_a_changed_identity_is_refused(tmp_path **options, ) stored: Any = open_file(path).metadata - assert stored.schema_version == 23 + assert stored.schema_version == 24 assert names(stored.materials) == [ "eos/Ar", "fit/Ar", diff --git a/tests/driver/test_prop_free.py b/tests/driver/test_prop_free.py index 63b40adcd..df43bcf94 100644 --- a/tests/driver/test_prop_free.py +++ b/tests/driver/test_prop_free.py @@ -324,7 +324,7 @@ def test_cartesian_resume_distinguishes_legacy_cutoff_arithmetic(free_case): grid, space, SpatialMedium(), [], pulse, settings=settings ) current = simulation.metadata() - assert current.schema_version == 23 + assert current.schema_version == 24 assert current.configuration["spatial_arithmetic"] == "split-square-v1" legacy_configuration = dict(current.configuration) del legacy_configuration["spatial_arithmetic"] diff --git a/tests/driver/test_prop_radial.py b/tests/driver/test_prop_radial.py index bec208e51..30950175f 100644 --- a/tests/driver/test_prop_radial.py +++ b/tests/driver/test_prop_radial.py @@ -72,7 +72,7 @@ def test_radial_interface_caches_and_saves_geometry(case, backend, request): # Preserve the compact pulse specification; serializing the built transverse # volume here would duplicate the input and make Cartesian metadata enormous. assert metadata.configuration["initial"] is pulse - assert metadata.schema_version == 23 + assert metadata.schema_version == 24 assert metadata.simulation_kind == "radial" assert metadata.spatial is not None assert metadata.spatial["stored_field_units"] == "V m" diff --git a/tests/driver/test_prop_slab.py b/tests/driver/test_prop_slab.py index 1579ae611..e2357c01f 100644 --- a/tests/driver/test_prop_slab.py +++ b/tests/driver/test_prop_slab.py @@ -315,7 +315,7 @@ def test_the_run_records_its_measure_and_labels_per_unit_width(): back = RunMetadata.from_json(metadata.to_json()) assert back.transverse_measure == "per_unit_width" assert back.configuration_hash() == metadata.configuration_hash() - assert json.loads(metadata.to_json())["schema_version"] == 23 + assert json.loads(metadata.to_json())["schema_version"] == 24 units = transverse_units(output) assert (units.power, units.energy, units.intensity) == ("W/m", "J/m", "W/m^2") figures = stats(output) diff --git a/tests/driver/test_tdse_hacc_run.py b/tests/driver/test_tdse_hacc_run.py index d4d42d519..bf9c69e19 100644 --- a/tests/driver/test_tdse_hacc_run.py +++ b/tests/driver/test_tdse_hacc_run.py @@ -110,7 +110,7 @@ def test_hacc_run_records_model_window_consistency_and_ledger(fake, cache, windo simulation = _simulation(atomic_window=window) output = _run(simulation) assert output.metadata is not None - assert output.metadata.schema_version == SCHEMA_VERSION == 23 + assert output.metadata.schema_version == SCHEMA_VERSION == 24 descriptor = json.loads(plain(output.metadata.tdse)["configuration_json"]) assert descriptor["schema"] == "solprop.tdse-descriptor.v5" assert descriptor["response"]["model"] == "hacc" diff --git a/tests/driver/test_temperature_paths.py b/tests/driver/test_temperature_paths.py index 5f628a397..e2a6acb0e 100644 --- a/tests/driver/test_temperature_paths.py +++ b/tests/driver/test_temperature_paths.py @@ -205,7 +205,7 @@ def test_three_populations_on_the_native_z_table_with_each_march(integrator): def test_the_run_records_the_temperature_path(): metadata = build(PAIR, WARM).metadata() configuration = cast("dict", serialise(metadata.configuration)) - assert metadata.schema_version == 23 + assert metadata.schema_version == 24 # The constructor's record keeps the path as the dataclass it is, and the # medium's record samples it with its breakpoints, as it does a density. assert configuration["capillary"]["temperature"] == { diff --git a/tests/driver/test_vector_raman_output.py b/tests/driver/test_vector_raman_output.py index cfd82ea0f..0889eaa81 100644 --- a/tests/driver/test_vector_raman_output.py +++ b/tests/driver/test_vector_raman_output.py @@ -89,7 +89,7 @@ def simulation(): # The same stored realisation carried by one march: its rounding budget. assert_matched(actual, expected, march, what=key) metadata = resume(part).metadata - assert metadata.schema_version == 23 + assert metadata.schema_version == 24 record = repr(metadata.configuration) assert "tensor_terms" in record and "rank2" in record and "isotropic" in record diff --git a/tests/output/test_handlers.py b/tests/output/test_handlers.py index c24e073c8..d7b33ab55 100644 --- a/tests/output/test_handlers.py +++ b/tests/output/test_handlers.py @@ -1200,8 +1200,8 @@ def test_a_plane_whose_channel_changes_its_statistics_is_refused(grid, metadata) def test_the_schema_version_is_twelve_for_integrated_run_records(metadata): """Combined launch, TDSE, step-limit and saved-plane policies version the file.""" - assert SCHEMA_VERSION == 23 - assert metadata.schema_version == 23 + assert SCHEMA_VERSION == 24 + assert metadata.schema_version == 24 @pytest.mark.skipif(sys.platform == "win32", reason="Windows has no SIGKILL") diff --git a/tests/output/test_material_resume.py b/tests/output/test_material_resume.py index e3db93032..b81d5a288 100644 --- a/tests/output/test_material_resume.py +++ b/tests/output/test_material_resume.py @@ -75,7 +75,7 @@ def selected(): def test_the_current_schema_carries_the_manifest_through_memory_and_json(selected): - assert SCHEMA_VERSION == 23 == selected.schema_version + assert SCHEMA_VERSION == 24 == selected.schema_version memory = MemoryOutput() memory.open(selected) stored: Any = memory.metadata diff --git a/tests/output/test_modal_resume.py b/tests/output/test_modal_resume.py index 5ad73cdbb..15e1f8214 100644 --- a/tests/output/test_modal_resume.py +++ b/tests/output/test_modal_resume.py @@ -139,7 +139,7 @@ def written(guide, grid, tmp_path_factory): def test_the_record_carries_every_modal_identity(written, guide): """Labels, tables, metric, responses, guide, frame, band, noise and accessors.""" stored = resume(written).metadata - assert stored.schema_version == SCHEMA_VERSION == 23 + assert stored.schema_version == SCHEMA_VERSION == 24 modal = stored.modal assert modal is not None assert set(modal) == { @@ -274,7 +274,7 @@ def test_the_printed_block_prints_every_schema_seven_field_with_its_value(writte assert value in block, (line, value) # The long `model_id` itself is on the record, and not in the printed row. assert str(at(modal, "tables", "model_id")) not in block - assert "schema 23" in block + assert "schema 24" in block # Twelve rows for thirteen fields: the basis row carries `n_components` too. printed = {key for key, _ in stored._describe()[1]} assert len(printed & MODAL_ROWS) == len(MODAL_ROWS) @@ -493,7 +493,7 @@ def test_a_schema_six_file_cannot_resume_any_schema_seven_run(tmp_path): backend="numpy_ref", ).metadata() assert ordinary.modal is None - assert ordinary.schema_version == SCHEMA_VERSION == 23 + assert ordinary.schema_version == SCHEMA_VERSION == 24 with pytest.raises(OutputError, match=r"different run.*schema_version"): check_cache(path, ordinary) assert resume(path).metadata.configuration_hash() == SCHEMA_SIX_DIGEST @@ -718,7 +718,7 @@ def test_a_scan_point_writes_its_spool_and_reads_the_identity_back( results = capture.results() assert float(results["reduced/spooled_flux"]) > 0.0 stored = resume(capture.custom_path).metadata - assert stored.schema_version == SCHEMA_VERSION == 23 + assert stored.schema_version == SCHEMA_VERSION == 24 assert stored.modal is not None assert at(stored.modal, "basis") == "general_modal" assert [one.label for one in stored.modes] != [None] diff --git a/tests/output/test_record_stability.py b/tests/output/test_record_stability.py index 0cf5f13ea..05021bccd 100644 --- a/tests/output/test_record_stability.py +++ b/tests/output/test_record_stability.py @@ -107,7 +107,7 @@ def build(kind: str) -> Simulation: def split(kind: str): metadata = build(kind).metadata() - assert metadata.schema_version == SCHEMA_VERSION == 23 + assert metadata.schema_version == SCHEMA_VERSION == 24 # SEP 27 changes the global resume boundary, not these non-plasma records. # SEP 34 adds the plane measure to the spatial record (output schema 23); the # schema-21 projection leaves it out, and everything else must be unchanged. diff --git a/tests/output/test_schema.py b/tests/output/test_schema.py index d16472c62..1fb652093 100644 --- a/tests/output/test_schema.py +++ b/tests/output/test_schema.py @@ -930,4 +930,4 @@ def test_raman_tensor_identity_survives_metadata_round_trip_and_controls_resume( assert original.configuration_hash() != other.configuration_hash() with pytest.raises(OutputError, match=r"configuration\.raman"): resolve_metadata_write(restored, other) - assert original.schema_version == 23 + assert original.schema_version == 24 diff --git a/tests/output/test_schema_lineages.py b/tests/output/test_schema_lineages.py index b27a5e5a4..be64a04d6 100644 --- a/tests/output/test_schema_lineages.py +++ b/tests/output/test_schema_lineages.py @@ -25,6 +25,8 @@ def test_independent_schema_lineages_preserve_hashes_and_field_presence( record["schema_version"] = int(lineage.rsplit("-", 1)[1]) # Schema 17's quadrature record did not exist in these layouts. record.pop("quadrature") + # Nor did schema 24's plane-reference record. + record.pop("reference") limits = record["stepper"]["limits"] if lineage == "sep12-10": for name in ("interval_cap", "auto_init", "interpolate_planes", "step_planes"): @@ -169,7 +171,7 @@ def test_schema_18_round_trips_hacc_and_sae_records(tmp_path, monkeypatch, kind) "sae": lambda: dataclasses.replace(_sae(), sampling=TDSESampling(4)), }[kind]() metadata = _tdse_metadata(response) - assert metadata.schema_version == 23 + assert metadata.schema_version == 24 record = metadata.tdse assert record is not None descriptor = json.loads(str(record["configuration_json"])) @@ -197,7 +199,7 @@ def test_schema_18_round_trips_hacc_and_sae_records(tmp_path, monkeypatch, kind) ) with open_file(path) as stored: assert stored.metadata is not None - assert stored.metadata.schema_version == 23 + assert stored.metadata.schema_version == 24 assert stored.metadata.configuration_hash() == metadata.configuration_hash() assert stored.metadata.tdse == metadata.tdse assert stored.outcome is not None diff --git a/tests/processing/test_mixtures.py b/tests/processing/test_mixtures.py index 97c0c5d7c..88cda50ad 100644 --- a/tests/processing/test_mixtures.py +++ b/tests/processing/test_mixtures.py @@ -195,7 +195,7 @@ def test_saved_record_contains_ordered_species_and_complete_optics(case, outputs simulation, _ = case memory, reloaded = outputs for output in (memory, reloaded): - assert output.metadata.schema_version == 23 + assert output.metadata.schema_version == 24 material = output.metadata.configuration["medium"] assert material["labels"] == ["population-0", "population-1"] assert ( diff --git a/tests/processing/test_planes.py b/tests/processing/test_planes.py new file mode 100644 index 000000000..633094b27 --- /dev/null +++ b/tests/processing/test_planes.py @@ -0,0 +1,282 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Saved planes read back as coherent field planes (SEP 31).""" + +import dataclasses + +import numpy as np +import pytest +from numpy.testing import assert_allclose + +from solprop.coupling import reference_at +from solprop.driver import Simulation, SimulationSettings +from solprop.exceptions import ProcessingError +from solprop.fourier import spectrum_to_internal, spectrum_to_optics +from solprop.grid import FieldGrid, FreeGrid, RadialGrid +from solprop.modes import ( + GradedIndexProvider, + IndexMaterial, + MaterialKerr, + ModalGuide, + PowerLawPermittivity, +) +from solprop.output.hdf5 import open_file +from solprop.output.memory import MemoryOutput +from solprop.output.schema import SavedPlane, StepHistory +from solprop.processing import electric_field, field_plane, magnetic_field +from solprop.profiles import PressureGradient +from solprop.pulses import GaussianPulse, SpatialPulse +from solprop.spatial import SpatialMedium +from solprop.stats import analytic_field +from solprop.stepper import PIController + +EPS = np.finfo(float).eps + + +def capillary(length=2e-3, n_save=3, **changes): + arguments = { + "lambda0": 800e-9, + "wavelength_limits": (400e-9, 1.6e-6), + "trange": 200e-15, + "fwhm": 20e-15, + "energy": 20e-6, + "modes": 2, + "n_save": n_save, + "plasma": False, + "noise": None, + "backend": "numpy_ref", + **changes, + } + return Simulation.from_capillary(100e-6, length, "Ar", 1.0, **arguments) + + +def spatial(space, pressure: float | PressureGradient = 1.0): + grid = FieldGrid.from_limits(2e-3, 800e-9, (600e-9, 1.2e-6), 400e-15) + settings = SimulationSettings( + controller=PIController(rtol=1e-8, atol=1e-14), + save_planes=[1e-3, 2e-3], + dz_max=2e-4, + dz_init=2e-4, + backend="numpy_ref", + ) + pulse = SpatialPulse( + GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=3e-9), 6e-5 + ) + medium = SpatialMedium("Ar", pressure) + if isinstance(space, RadialGrid): + return Simulation.from_radial(grid, space, medium, [], pulse, settings=settings) + return Simulation.from_free(grid, space, medium, [], pulse, settings=settings) + + +@pytest.fixture(scope="module") +def capillary_run(): + simulation = capillary() + return simulation, simulation.run(progress="none") + + +def test_a_modal_plane_is_the_stored_spectrum_in_its_rebuilt_reference(capillary_run): + simulation, output = capillary_run + plane = field_plane(output) + assert plane.kind == "modal" and plane.units == "sqrt(W)" + # The plane holds the stored array, a copy: an exact structural contract. + assert np.array_equal(plane.samples, output.fields()[-1]) + assert plane.geometry.z == float(output["z"][-1]) + assert plane.reference == reference_at(output.metadata.reference, plane.geometry.z) + # The group delay of 2 mm of argon-filled capillary, about 2 mm / c. + assert plane.reference.delay == pytest.approx(2e-3 / 2.998e8, rel=1e-3) + assert plane.metric.kind == "identity" + assert plane.basis.labels == tuple( + mode.channel_label.display_label for mode in simulation.modes + ) + assert plane.basis.identity == simulation.input_port().basis.identity + provenance = plane.provenance + assert provenance.source == output.metadata.configuration_hash() + assert (provenance.plane, provenance.z, provenance.lineage) == (2, 2e-3, ()) + middle = field_plane(output, z=1e-3) + assert middle.provenance.plane == 1 + assert middle.reference.delay == pytest.approx( + 0.5 * plane.reference.delay, rel=1e-9 + ) + + +def test_a_selection_names_one_stored_plane_or_is_refused(capillary_run): + _, output = capillary_run + with pytest.raises(ProcessingError, match="no plane is stored"): + field_plane(output, z=1.5e-3) + with pytest.raises(ProcessingError, match="not both"): + field_plane(output, 0, z=1e-3) + with pytest.raises(ProcessingError, match="out of range"): + field_plane(output, 5) + + +def test_a_file_without_the_schema_24_record_is_refused(capillary_run): + _, output = capillary_run + older = MemoryOutput() + older.open(dataclasses.replace(output.metadata, schema_version=23, reference=None)) + with pytest.raises(ProcessingError, match="schema 23"): + field_plane(older) + empty = MemoryOutput() + with pytest.raises(ProcessingError, match="run record"): + field_plane(empty) + + +def test_the_record_survives_a_file(capillary_run, tmp_path): + simulation, memory = capillary_run + path = tmp_path / "capillary.h5" + simulation.run(path, progress="none") + with open_file(path) as stored: + assert stored.metadata is not None and memory.metadata is not None + assert stored.metadata.schema_version == 24 + assert stored.metadata.reference == memory.metadata.reference + assert ( + stored.metadata.configuration_hash() == memory.metadata.configuration_hash() + ) + plane = field_plane(stored) + expected = field_plane(memory) + # A run repeated on the same backend; its stored planes agree to its rounding. + assert_allclose( + plane.samples, + expected.samples, + rtol=0, + atol=1e-12 * np.max(np.abs(expected.samples)), + ) + assert plane.reference == expected.reference + assert plane.basis.identity == expected.basis.identity + + +@pytest.mark.parametrize( + "space", [RadialGrid(6e-4, 32), FreeGrid(4e-4, 16, 4e-4, 16)], ids=["radial", "xy"] +) +def test_a_spatial_plane_carries_its_medium_s_measure(space, tmp_path): + simulation = spatial(space) + path = tmp_path / "spatial.h5" + memory = simulation.run(progress="none") + simulation.run(path, progress="none") + for output in (memory, open_file(path)): + plane = field_plane(output, 1) + assert plane.kind == ( + "radial" if isinstance(space, RadialGrid) else "cartesian" + ) + assert plane.metric.kind == "admittance" and plane.metric.flux is None + # The run's own energy statistic reads the same plane measure (SEP 34); + # two orders of the same weighted sum. + energy = float(output["stats/energy"][1]) + assert plane.energy() == pytest.approx(energy, rel=1e-12) + assert plane.certificate is None + assert plane.basis.identity == simulation.input_port().basis.identity + with_source = field_plane(memory, 1, source=simulation) + assert with_source.medium is simulation.medium + assert with_source.metric.flux is not None + other = spatial(space, pressure=2.0) + with pytest.raises(ProcessingError, match="not the simulation"): + field_plane(memory, 1, source=other) + + +def test_a_gradient_plane_carries_its_scoped_path_certificate(): + simulation = spatial( + RadialGrid(6e-4, 32), PressureGradient.two_point(2e-3, 1.0, 2.0) + ) + plane = field_plane(simulation.run(progress="none")) + assert plane.certificate is not None + assert plane.certificate["scope"] == "linear-background forward field" + with pytest.raises(TypeError): + plane.certificate["scope"] = "anything" # pyright: ignore[reportIndexIssue] + + +@pytest.fixture(scope="module") +def general_run(): + wavelength, length, contrast = 1.03e-6, 1e-5, 0.0095 + grid = FieldGrid.from_limits(length, wavelength, (1.0e-6, 1.06e-6), 100e-15) + provider = GradedIndexProvider( + permittivity=PowerLawPermittivity(IndexMaterial(1.45), contrast), + exterior=IndexMaterial(1.45 * np.sqrt(1.0 - 2.0 * contrast)), + radius=25e-6, + family="LP", + count=1, + elements=16, + extent=2.0, + core_responses=(MaterialKerr(n2=2.7e-20, at=wavelength),), + ) + active = grid.omega[grid.active_mask] + knots = np.linspace(0.99 * float(active[0]), 1.01 * float(active[-1]), 9) + guide = ModalGuide.from_provider( + provider, + knots, + nodes=provider.node_rule(float(knots[0]), 0.0, n_r=2, n_theta=2), + ) + simulation = Simulation.from_waveguide( + (), + length, + provider=guide, + grid=grid, + lambda0=wavelength, + fwhm=30e-15, + energy=1e-12, + dz_max=length / 2, + dz_init=length / 2, + n_save=2, + backend="numpy_ref", + ) + return simulation, simulation.run(progress="none") + + +def test_a_general_plane_reads_its_fields_with_its_source(general_run): + simulation, output = general_run + bare = field_plane(output) + assert bare.kind == "general_modal" and bare.nodal is None + assert bare.metric.kind == "identity" + plane = field_plane(output, source=simulation) + assert plane.nodal is not None + tables, guide = simulation.general.tables, simulation.guide + # The nodal spectra are processing.modal's, in the optics convention: through + # the one boundary and back they give its analytic signals exactly. + grid = simulation.grid + for accessor, stored in ( + (electric_field, plane.nodal.electric), + (magnetic_field, plane.nodal.magnetic), + ): + assert stored is not None + signal = accessor( + output, tables, plane=-1, analytic=True, convention="fft", guide=guide + ) + rebuilt = analytic_field(grid, spectrum_to_internal(stored, kind=grid.kind)) + assert np.array_equal(rebuilt, signal) + assert plane.basis.identity == simulation.input_port().basis.identity + port = simulation.input_port() + assert port.electric is not None and port.nodes is not None + with pytest.raises(ProcessingError, match="general modal guide"): + field_plane(output, source=capillary()) + + +def test_planar_runs_are_refused_by_name(capillary_run): + _, output = capillary_run + reference = dict(output.metadata.reference) + reference["basis"] = {**reference["basis"], "route": "planar"} + planar = MemoryOutput() + planar.open(dataclasses.replace(output.metadata, reference=reference)) + for plane in output.fields(): + planar.save(dataclasses.replace(_saved(output), field=_internal(plane))) + with pytest.raises(ProcessingError, match="planar"): + field_plane(planar) + + +def _saved(output): + return SavedPlane(z=0.0, field=None, step_history=StepHistory.empty(), stats={}) + + +def _internal(plane): + return spectrum_to_internal(plane, kind="field") + + +def test_the_launch_plane_is_the_launched_spectrum_in_the_optics_convention( + capillary_run, +): + simulation, output = capillary_run + plane = field_plane(output, 0) + expected = spectrum_to_optics(simulation._field0, kind="field") + # The true-power boundary there and back: a square root and two products. + assert_allclose( + plane.samples, expected, rtol=0, atol=1e-12 * np.max(np.abs(expected)) + ) diff --git a/tests/test_display.py b/tests/test_display.py index da1326fff..b12a716d5 100644 --- a/tests/test_display.py +++ b/tests/test_display.py @@ -689,7 +689,7 @@ def test_the_run_record_names_its_power_convention(examples): # the spectral convention; a schema-5 record (which carries no material manifest) # reads back as the vacuum label. metadata = quiet(examples["Simulation"].metadata) - assert "optics spectra, poynting amplitudes, schema 23" in str(metadata) + assert "optics spectra, poynting amplitudes, schema 24" in str(metadata) older = replace( metadata, schema_version=5, quadrature=None, materials=MaterialManifest() ) From f3dcb14de0778df39ea08e63182dd70b8b255516 Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 01:03:39 +0100 Subject: [PATCH 12/50] Docs: plane exchange, the coupling chapter and output schema 24 A draft coupling chapter (field planes, regrid, same-basis relaunch), the plane-reference record on the provenance and output pages, the architecture's package table and layer edges, the API index and the changelog. Cross-references use the home modules. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019qNYqtCKh4iTKj7544yARb --- CHANGELOG.md | 10 ++ docs/api/index.md | 3 +- docs/dev/architecture.md | 20 +++- docs/dev/kernel-contract.md | 2 +- docs/dev/structure.md | 4 +- docs/guide/results/interior-launch.md | 6 + docs/guide/results/output.md | 8 +- docs/guide/results/provenance.md | 18 ++- docs/guide/workflows/coupling.md | 158 ++++++++++++++++++++++++++ docs/guide/workflows/index.md | 1 + src/solprop/coupling/__init__.py | 17 +-- src/solprop/coupling/plane.py | 7 +- src/solprop/coupling/port.py | 16 +-- src/solprop/coupling/remap.py | 21 ++-- src/solprop/driver/simulation.py | 8 +- src/solprop/output/schema.py | 4 +- src/solprop/processing/planes.py | 15 +-- src/solprop/translate/coupling.py | 4 +- 18 files changed, 267 insertions(+), 55 deletions(-) create mode 100644 docs/guide/workflows/coupling.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 597ffd326..c14c3fce4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,16 @@ The first public release. It contains: `configuration["launch"]`, and `SpatialPulse.incident` samples a beam normalised over its own plane. The node-sampled `transverse=` profile, its rescaling to `energy=`, the 3 % uncaptured warning and `LAUNCH_UNCAPTURED_WARNING` are removed. +- **Plane exchange between simulations** + ([SEP 31](https://solprop.readthedocs.io/en/latest/dev/seps/sep-0031-simulation-coupling.html), + phase 2). `solprop.coupling` holds `FieldPlane`, a coherent field at one plane with + its reference, geometry, metric, basis identity and provenance; `regrid`, the direct + finite-time Fourier remap between temporal grids with its loss report and no + renormalisation; and `InputPort` with the `same_basis` relaunch. + `processing.field_plane` reads a saved plane back, `Simulation.input_port()` + describes a simulation's entrance and `with_input` takes a launch bound to it, + recording `configuration["coupling"]` and drawing no new noise. Output schema 24 + records each run's plane-reference record (`RunMetadata.reference`). - **Media and responses.** - Gases, gas mixtures, glasses and metals from the [optmat](https://github.com/LupoLab/optmat) material library. diff --git a/docs/api/index.md b/docs/api/index.md index 23aacca36..ffc5c1c6a 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -46,7 +46,8 @@ In scripts and library code, import from the home modules listed below. | module | what it holds | |---|---| | {mod}`solprop.output` | the in-memory and HDF5 output handlers, the run's metadata record, `open_file` and resuming a run, live snapshots (`SnapshotController`, `read_snapshot`, `restore_snapshot`; [the output page](../guide/results/output.md)), and the raw TDSE response archive (`TDSEArchive`, `open_tdse_archive`; [the TDSE response](../guide/media/tdse.md#the-raw-response-archive)) | -| {mod}`solprop.processing` | post-processing of a saved run: fields, spectra, pulse metrics, coherence, compression, and the physical fields of a general modal run | +| {mod}`solprop.processing` | post-processing of a saved run: fields, spectra, pulse metrics, coherence, compression, the physical fields of a general modal run, and `field_plane`, a saved plane read back for coupling | +| {mod}`solprop.coupling` | coupling simulations through a field plane: `FieldPlane`, `regrid`, `InputPort` and the `same_basis` relaunch ([coupling simulations](../guide/workflows/coupling.md)) | | {mod}`solprop.stats` | the statistics recorded at the saved planes, and `StatsSpec` to choose them | | {mod}`solprop.plotting` | the standard figures: propagation maps, slices, spectrograms, statistics | | {mod}`solprop.optics` | linear optics applied to a pulse outside a propagation: materials, chirped mirrors, grating and prism pairs | diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md index 8bf995e73..20c26bac3 100644 --- a/docs/dev/architecture.md +++ b/docs/dev/architecture.md @@ -98,7 +98,7 @@ records the absolute launch, the TDSE identity, the outcome and any terminal che beside the step limits and the dense and step-plane policies. The full-band sensitivity to the window and the atomic cut error are unresolved ([TDSE accuracy and limitations](../guide/media/tdse.md#accuracy-and-limitations)). The -current versions are kernel schema 34 and saved-output schema 22 +current versions are kernel schema 35 and saved-output schema 24 ([kernel contract](kernel-contract.md)). **haCC helium** ([SEP 20](seps/archive/sep-0020-hacc-helium-tdse.md)). The same route carries a second atomic model, the @@ -154,7 +154,18 @@ the table identity, the guide identity -- and not the reference's tens of megaby so the reference is rebuilt in **T** by `general_modal_route` and read in **X**, which checks it against the stored identity, path by path, before it synthesises anything. **X** takes the record type and the identity functions, never an execution map, never a -`KernelProblem` and never a backend. No other module of **X** imports **T**. +`KernelProblem` and never a backend. Two further modules read what a file records the +same way. `processing.mixtures` and `processing.planes` rebuild a spatial plane's +measure from the record with `translate.flux.flux_from_record` and +`translate.spatial.axial_admittance` (SEP 34), and `processing.planes` reuses +`processing.modal`'s checked tables and evaluates a general basis Gram with +`translate.coupled.metric_at`. No other module of **X** imports **T**. + +**Coupling crosses the layers without breaking them** (SEP 31). The field plane, the +input port and the remap are P value types; the driver's `Simulation.input_port()` +asks **T** (`translate.coupling`) to fill the port from the translated problem, and +extraction is **X** (`processing.field_plane`), because **P** must not import **X**. +A coupling computed in **P** therefore never imports **T** or reads a file. Rules: imports flow P → T → B; B never builds a physics object — it does not know how to make a mode or a gas, and imports P only for the grid, mode and profile types its statistics are @@ -178,16 +189,17 @@ against `src/solprop` by `tests/test_structure_figures.py`. | `__init__.py`, `exceptions.py`, `constants.py` | Lazy public imports, package logging, exception hierarchy and SI constants | package / P | | `grid.py`, `fourier.py`, `hankel.py`, `spatial.py`, `adiabatic.py` | Temporal and spatial grids (radial, the Cartesian plane, and `LineGrid`, its x axis uniform in y per unit width), Fourier conventions, raw Hankel transforms, spatial media, the axial-flux measure of a plane (`SpatialFlux`, SEP 34) and the adiabatic path certificate of a z-varying medium | P | | `profiles.py`, `gasflow.py`, `pulses.py`, `noise.py`, `polarisation.py`, `optics.py`, `tools.py` | Longitudinal paths, steady gas flow in a hollow fibre (the taper- and temperature-aware, choked, regime-checked fill, its flow report and calculators), temporal/spatial inputs, noise, polarisation algebra, linear optical elements as transfer functions, and the soliton and phase-matching design calculators | P | +| `coupling/` | Coupling simulations through a coherent field at a reference plane (SEP 31): the field plane with its reference, geometry, metric, basis identity and provenance (`plane.py`), the input port of a receiving simulation and its bound launch with the same-basis model (`port.py`), and temporal remapping (`remap.py`); NumPy and other P modules only. The transport core (`transport.py`, `elements.py`) is planned | P | | `physdata/` | Species, mixtures, equations of state, transport properties (viscosity, sound speed, mean free path), optical material data, metals, nonlinear and Raman data | P | | `modes/` | Mode protocols and derived quantities; capillary, antiresonant, tabulated, step-index, rectangular and planar (slab, uniform in y, per unit width) modes, with `PlanarGrid` for the planar guide resolved in y; shared gas fill, quadrature and collections; full-vector modal records, material laws and regions, bound step-index and radial graded-index providers, and the guide that pairs a provider with a mode collection, and the connection of a basis that moves along z | P | | `linear.py`, `boundaries.py`, `nonlinear/` | Linear assembly, absorbing rates, response definitions, ionisation, Raman, normalisation and the GNLSE mapping | P | -| `translate/` | Validated `KernelProblem`; parameter expansions and z tables; modal/radial/Cartesian transforms and the hybrid planar one (slab modes in x × FFT in y, `translate/planar.py`); dense tracked modal tables, the general modal route and the coupled-flow and metric records (`translate/general.py`, `translate/coupled.py`); the spatial plane measure (`translate/flux.py`, the `FluxPlan` of schema 35); response tables, masks and stage bundles | T | +| `translate/` | Validated `KernelProblem`; parameter expansions and z tables; modal/radial/Cartesian transforms and the hybrid planar one (slab modes in x × FFT in y, `translate/planar.py`); dense tracked modal tables, the general modal route and the coupled-flow and metric records (`translate/general.py`, `translate/coupled.py`); the spatial plane measure (`translate/flux.py`, the `FluxPlan` of schema 35); the persisted frame integral and the filled input port of a problem's entrance (`translate/coupling.py`); response tables, masks and stage bundles | T | | `backends/` | Registry and protocols, shared step and batch drivers, scalar bodies, spatial and population statistics | B | | `backends/numpy_ref/`, `backends/numba_cpu/` | Reference and compiled CPU execution | B | | `stepper/`, `log.py`, `stats.py` | Tableaux, controllers, schedules, progress reporters and statistics specifications | D | | `driver/`, `driver/executors/`, `scan.py` | Simulation and convenience interfaces, scan/batch orchestration, Local/Pool/Queue/Slurm execution and scan CLI | D | | `output/` | Memory and HDF5 outputs, provenance/resume, scan storage and artifacts | D | -| `processing/`, `plotting/`, `sol.py` | Field, spectrum, time, coherence, compression, mixture and scan analysis; propagation/statistics plots and spectrograms; the interactive session namespace, the one module whose import has side effects | X | +| `processing/`, `plotting/`, `sol.py` | Field, spectrum, time, coherence, compression, mixture and scan analysis; a saved plane read back as a field plane (`processing/planes.py`); propagation/statistics plots and spectrograms; the interactive session namespace, the one module whose import has side effects | X | `solprop-native` is the backend's second distribution, built from `native/` in this repository at solprop's own version and installed through the `native` extra. It depends diff --git a/docs/dev/kernel-contract.md b/docs/dev/kernel-contract.md index 05c64678e..d58fccf3a 100644 --- a/docs/dev/kernel-contract.md +++ b/docs/dev/kernel-contract.md @@ -18,7 +18,7 @@ operator, the quadrature, the compiled responses — are in [algorithms.md](algo already converted from the optics-convention user inputs; the output layer converts observations back. Backends never conjugate. -**Current kernel schema: 35; saved-output schema: 23.** These versions are independent. +**Current kernel schema: 35; saved-output schema: 24.** These versions are independent. Internal modal amplitudes are vacuum normalised; the launch and observation boundaries carry the true-power conversion, and every backend reads the modal power weight (§3) in its statistics alone. Radial columns are Hankel diff --git a/docs/dev/structure.md b/docs/dev/structure.md index 425c4d9b5..4f80db491 100644 --- a/docs/dev/structure.md +++ b/docs/dev/structure.md @@ -32,7 +32,9 @@ One colour per layer, used in every figure on this page and stated in the legend Read the bands top to bottom in the order imports flow, and the arrows in the other direction: each points from the layer that imports to the layer it imports. Solid boxes are modules that exist; dashed ones are named in [architecture.md §4](architecture.md) -as candidates to be written only for an agreed use case. The numbered arrows on the right +as candidates to be written only for an agreed use case, or, for `coupling/transport` +and `coupling/elements`, as the transport core a later phase of +[SEP 31](seps/sep-0031-simulation-coupling.md) writes. The numbered arrows on the right skip a band; one of them is the one import of P that [architecture.md §3](architecture.md) allows a backend: the grid, the modes and the profiles its statistics are handed at construction, types and constants and never a diff --git a/docs/guide/results/interior-launch.md b/docs/guide/results/interior-launch.md index 859d7f677..48275815b 100644 --- a/docs/guide/results/interior-launch.md +++ b/docs/guide/results/interior-launch.md @@ -39,6 +39,12 @@ a new `Simulation` with the parent grid, modes, profiles and frame, the replacem responses, this `spectrum` and `initial_z=z_start`; include the parent hash and plane index in its `configuration` provenance and write a new output file. +The same continuation can be made through plane exchange, which checks the basis and +records the provenance for you: `same_basis(field_plane(parent_output, plane_index), +simulation.input_port(z_start))` is a launch that `simulation.with_input` accepts, at +`z_start`, with the parent named in `configuration["coupling"]` +([coupling simulations](../workflows/coupling.md#relaunching-into-the-same-basis)). + For a TDSE response, `SimulationSettings.tdse_execution` explicitly selects the atomic runtime resources beside `backend="numpy_ref"` and a `Multistep` integrator. The declared response sampling refines the existing fine lattice once, retaining diff --git a/docs/guide/results/output.md b/docs/guide/results/output.md index 3c640789c..a8f8d9759 100644 --- a/docs/guide/results/output.md +++ b/docs/guide/results/output.md @@ -305,8 +305,12 @@ it with `open_tdse_archive` ([the TDSE response](../media/tdse.md#the-raw-respon ## The schema version -Every file records its `schema_version`; this release writes schema 22 -(`solprop.output.SCHEMA_VERSION`). A file of an earlier schema is still read, with the +Every file records its `schema_version`; this release writes schema 24 +(`solprop.output.SCHEMA_VERSION`). Schema 24 adds the plane-reference record +(`RunMetadata.reference`, [provenance](provenance.md#the-metadata-schema)), from which a +saved plane is read back as a coherent field plane +([coupling simulations](../workflows/coupling.md)); planes of an earlier file are not +exchanged. A file of an earlier schema is still read, with the layout of its own version, and verifies against the digest it was stamped with. The schema version is inside the resume key ([the resume key](resuming.md#the-resume-key)), so a run is only continued in a file of the current schema: for an older file the diff --git a/docs/guide/results/provenance.md b/docs/guide/results/provenance.md index 79a9e7e76..705649993 100644 --- a/docs/guide/results/provenance.md +++ b/docs/guide/results/provenance.md @@ -72,13 +72,25 @@ A saved result needs the grid, field convention, material models, response choic * - ``versions``, ``git``, ``created`` - Provenance: package versions, the commit and dirty flag of the running solprop checkout when there is one, and when the run started. + * - ``reference`` + - The **plane-reference record** (output schema 24, + `SEP 31 <../../dev/seps/sep-0031-simulation-coupling.html>`_): the carrier, the + frame integral of the terms the linear operator removed (exact Chebyshev + antiderivatives of the frame's ``beta_0`` and ``beta_1`` tables, with the frequency + origin of the linear term), the reference origin of the run's path, the parent + plane of a run launched from one, and the basis identity at each planned plane. + :func:`~solprop.coupling.plane.reference_at` rebuilds any saved plane's phase and time + reference from it alone; :func:`~solprop.processing.planes.field_plane` reads it + (:doc:`coupling simulations <../workflows/coupling>`). * - ``schema_version``, ``spectral_convention``, ``field_layout`` - - The version of this record layout (currently 17), the sign convention of the + - The version of this record layout (currently 24), the sign convention of the stored field (always ``"optics"``) and the axis order of the stored field. ``` -`backend`, `versions`, `git` and `created` are outside the [resume key](resuming.md); every -other part is inside it, the material manifest's identities and policies included. +`backend`, `versions`, `git`, `created` and `reference` are outside the +[resume key](resuming.md): the plane-reference record is derived from the hashed +problem, and a run launched from another plane carries its inherited origin and parent +in `configuration["coupling"]`, which is inside it. Every other part is inside it, the material manifest's identities and policies included. `versions` names optmat's full version string beside solprop's, NumPy's, SciPy's, h5py's and Python's. diff --git a/docs/guide/workflows/coupling.md b/docs/guide/workflows/coupling.md new file mode 100644 index 000000000..3ff926648 --- /dev/null +++ b/docs/guide/workflows/coupling.md @@ -0,0 +1,158 @@ +# Coupling simulations + +A simulated pulse is often the input to another optical component: a second capillary, +a fibre, a gas cell. Carrying only its spectrum or duration loses its wavefront, its +modal coherence and the frequency dependence of its transverse profile; carrying a bare +array leaves its basis, units, frame and energy meaning implicit. +{py:mod}`solprop.coupling` connects simulations through a **coherent field at a +reference plane** instead ([SEP 31](../../dev/seps/sep-0031-simulation-coupling.md)). +This page covers the first part of that design: reading a saved plane as a field +plane, moving it between temporal grids, and relaunching it into a simulation of the +same basis. Coupling between different guides, incident beams and the optical +transport between planes follow in later releases of this chapter. + +## A plane, and what it means + +A {py:class}`~solprop.coupling.plane.FieldPlane` holds a field's native coefficients +exactly as an output stores them -- the optics-convention raw spectrum of each +column, referenced to the centre of the time window -- together with everything +needed to use them: + +| part | what it says | +|---|---| +| `kind`, `units`, `basis` | the representation: ordinary true-power mode coefficients (`modal`, √W), general-modal coordinates (`general_modal`, √W), the Hankel spectrum of a radial field (`radial`, V m) or the transverse DFT of a Cartesian one (`cartesian`, V/m); the basis carries an identity digest | +| `grid` | the temporal grid: field or envelope layout, time samples, the absolute frequency of every bin, the carrier | +| `reference` | the affine reference of the stored spectrum (below) | +| `geometry` | the plane's position, the side of a declared index jump, and its oriented right-handed transverse frame | +| `metric` | how energy is measured there: the identity for true-power coefficients, a general basis's Gram, or the medium's axial admittance for a spatial plane ([medium flux](../free-space/flux.md)) | +| `exterior` | whether the record is zero outside its samples or unknown there | +| `certificate` | the scoped path certificate of a run through a gas gradient: it bounds the linear background path, not the whole nonlinear run | +| `provenance` | the source run's configuration hash, the plane, its lineage and what was done to it | + +The plane owns read-only copies of its arrays, so it cannot be changed through an +alias. `plane.energy()` is the physical energy crossing the plane in joules, +`plane.spectral_energy()` the energy per bin. + +**The reference.** A run's spectrum is stored in its moving frame: the linear operator +removed $R(\omega,z) = \beta_0(z) + \beta_1(z)(\omega - \omega_o)$ (the +[frame](../../dev/conventions.md), with $\omega_o$ the reference frequency on an envelope +grid and zero on a field grid). The absolute optics-convention spectrum is + +$$ +\tilde E_{\rm abs}(\omega) = \tilde E_{\rm stored}(\omega)\, +e^{i(\phi + \omega\tau)},\qquad \phi + \omega\tau = \int_0^z R\,dz , +$$ + +with ω the absolute frequency. τ is the absolute time of the record's $t = 0$ and φ a +carrier phase; `plane.absolute()` applies them. Keeping the pair as numbers, rather +than shifting the samples, means that a long path never translates a femtosecond pulse +out of its window. Each run records its frame integral, so any saved plane's reference +is rebuilt from the file alone (output schema 24, +[provenance](../results/provenance.md#the-metadata-schema)). + +## Reading a plane back + +{py:func}`~solprop.processing.planes.field_plane` reads one saved plane; by default the last. +A position must name a stored plane: a nearby plane that was not saved is refused, +never substituted. + +```python +from solprop.driver import Simulation +from solprop.processing import field_plane + +simulation = Simulation.from_capillary( + 100e-6, 1e-3, "Ar", 1.0, lambda0=800e-9, wavelength_limits=(400e-9, 1.6e-6), + trange=200e-15, fwhm=20e-15, energy=20e-6, modes=2, n_save=3, noise=None, +) +run = simulation.run(progress="none") +plane = field_plane(run) # the exit plane +middle = field_plane(run, z=0.5e-3) # a stored plane by position +print(plane, plane.reference.delay, plane.energy()) +``` + +A general modal file records the identities of its E/H reference and not the +reference's arrays, so its physical fields (`plane.nodal`) and a dense basis Gram are +read with the simulation that ran it, `field_plane(run, source=simulation)`; the +identities are checked first, as {py:mod}`solprop.processing.modal` checks them. A +spatial plane's measure is rebuilt from the file's plane measure; with `source=` it +also carries the medium. A hybrid planar run's planes are refused by name. + +## Moving a plane between grids + +{py:func}`~solprop.coupling.remap.regrid` puts a plane on another temporal grid -- a longer +or finer window, a field grid instead of an envelope one, another carrier -- or into +another reference. Copying raw DFT amplitudes between grids of different size or +spacing is not this operation. The plane's analytic signal is its finite record, zero +outside its window, and each target bin receives its finite-time Fourier sum + +$$ +s'_m = \frac{\Delta t}{\Delta t'\,q'_m}\,e^{i\Delta\phi} + \sum_j a_j\,e^{i\omega'_m (t_j + \Delta)}, +$$ + +with $q = 2$ on interior bins of a field grid and 1 otherwise, and $\Delta$, +$\Delta\phi$ the change of reference, so that the absolute spectrum is unchanged. The +direct sum is the implementation; complex spectral zeros and separated bands need no +special case, since no phase is unwrapped and nothing is interpolated. + +```python +from solprop.coupling import regrid +from solprop.grid import FieldGrid + +wider = FieldGrid.from_limits(1e-3, 800e-9, (300e-9, 2e-6), 400e-15) +moved, report = regrid(plane, wider) +print(report.method, report.band_loss, report.window_loss) +print(moved.energy() / plane.energy()) # no renormalisation, and none needed here +``` + +What cannot be represented is refused, and what is removed is reported. Light outside +the target's active band is removed only with `crop_band=True`, light outside its time +window only with `truncate=True`, each with its share in the +{py:class}`~solprop.coupling.remap.RegridReport`. A record whose edges carry light, or whose +exterior is unknown, is not extended into a larger window unless `truncate=True` +declares it zero outside. Nothing is ever renormalised: a conversion does not recover +clipped, aliased or absorbed light. `delay=` and `phase=` change the reference: a later +delay moves the record earlier in its window, with the absolute field unchanged. + +## Relaunching into the same basis + +A receiving simulation describes its entrance with +{py:meth}`~solprop.driver.simulation.Simulation.input_port`: the route, the temporal lattice and +basis with their identities, the entrance plane and the frame accumulated there, the +native metric at the entrance and the models it admits. The identity model, +{py:func}`~solprop.coupling.port.same_basis`, launches a plane into a port of the **same** +basis on the same lattice, at an identically oriented plane in the same medium: the +coefficients are copied as they stand, and the plane's reference becomes the launched +run's origin. The launched run therefore continues the source's absolute time and +carrier phase without moving the pulse in its window. + +```python +from solprop.coupling import same_basis + +second = Simulation.from_capillary( + 100e-6, 1e-3, "Ar", 1.0, lambda0=800e-9, wavelength_limits=(400e-9, 1.6e-6), + trange=200e-15, fwhm=20e-15, energy=20e-6, modes=2, n_save=3, noise=None, +) +launch = same_basis(field_plane(run), second.input_port()) +continued = second.with_input(launch).run(progress="none") +print(field_plane(continued).reference.delay) # the whole 2 mm path's +``` + +Two 1 mm capillaries relaunched this way reproduce one 2 mm capillary: the comparison +is an accuracy envelope, since the second run restarts its step controller, and the +tests hold it to the tolerance's level on every pair of the three CPU backends. The +basis of an ordinary mode set is its value at the plane -- each mode's class, label, +effective area and effective index on the active band -- so the exit of one capillary +and the entrance of an identical one of another length are the same basis, and a +different radius is not. A simulation can also continue from inside its own path: +`simulation.input_port(z)` is an interior entrance of a modal run. + +{py:meth}`~solprop.driver.simulation.Simulation.with_input` refuses a launch computed for any other +port -- another basis, lattice, entrance, orientation or medium -- whatever the shapes of +the arrays. It records the coupling in `configuration["coupling"]` (the model, the port, +the inherited origin and the source plane's provenance, so a run launched from a plane +names its parent), and it draws no new noise: the launch carries the realised field of +its source, and a realisation is added only when one is given explicitly. The receiving +run keeps its ionisation tables and their ranges, as every derived run does. A different +basis, lattice or medium is a coupling or an interface, not a relaunch, and is refused by +name. diff --git a/docs/guide/workflows/index.md b/docs/guide/workflows/index.md index bc7be1708..48bf0ffda 100644 --- a/docs/guide/workflows/index.md +++ b/docs/guide/workflows/index.md @@ -10,6 +10,7 @@ their assumptions; the [examples](../../examples/index.md) show them in context. design-tools compression +coupling scans interactive luna diff --git a/src/solprop/coupling/__init__.py b/src/solprop/coupling/__init__.py index f1208eb12..5560ff7ff 100644 --- a/src/solprop/coupling/__init__.py +++ b/src/solprop/coupling/__init__.py @@ -5,13 +5,14 @@ """Couple simulations through a coherent field at a reference plane (SEP 31). A physics-layer package: NumPy, SciPy and other physics modules only. A -:class:`FieldPlane` is one physical plane of a field in its native representation, -with its references, geometry, medium, metric and provenance; :func:`regrid` moves -it onto another temporal lattice or reference; an :class:`InputPort` describes what -a receiving simulation accepts at its entrance, and :func:`same_basis` launches a -plane into a port of the same basis. Extraction from a saved run is -:func:`solprop.processing.field_plane`; installing a launch is -:meth:`solprop.driver.Simulation.with_input`. The manual page is +:class:`~solprop.coupling.plane.FieldPlane` is one physical plane of a field in its +native representation, with its references, geometry, medium, metric and provenance; +:func:`~solprop.coupling.remap.regrid` moves it onto another temporal lattice or +reference; an :class:`~solprop.coupling.port.InputPort` describes what a receiving +simulation accepts at its entrance, and :func:`~solprop.coupling.port.same_basis` +launches a plane into a port of the same basis. Extraction from a saved run is +:func:`solprop.processing.planes.field_plane`; installing a launch is +:meth:`solprop.driver.simulation.Simulation.with_input`. The manual page is ``docs/guide/workflows/coupling.md``. """ @@ -20,6 +21,7 @@ Calibration, Exterior, FieldPlane, + MetricKind, NodalField, PlaneBasis, PlaneGeometry, @@ -57,6 +59,7 @@ "Exterior", "FieldPlane", "InputPort", + "MetricKind", "NodalField", "PlaneBasis", "PlaneGeometry", diff --git a/src/solprop/coupling/plane.py b/src/solprop/coupling/plane.py index 750141116..b3726ed94 100644 --- a/src/solprop/coupling/plane.py +++ b/src/solprop/coupling/plane.py @@ -36,8 +36,8 @@ across its window. The plane is a P-layer value type: NumPy and other physics modules only. Extracting -one from a run is :func:`solprop.processing.field_plane`, and a receiving simulation -is described by :class:`solprop.coupling.InputPort`. +one from a run is :func:`solprop.processing.planes.field_plane`, and a receiving +simulation is described by :class:`solprop.coupling.port.InputPort`. """ import dataclasses @@ -64,6 +64,7 @@ "Calibration", "Exterior", "FieldPlane", + "MetricKind", "NodalField", "PlaneBasis", "PlaneGeometry", @@ -971,7 +972,7 @@ def record(self) -> dict[str, object]: class FieldPlane: """A coherent field at one physical plane, in its native representation. - Build one with :func:`solprop.processing.field_plane` from a saved run, with + Build one with :func:`solprop.processing.planes.field_plane` from a saved run, with :meth:`from_pulse` from the package's beam builder, or with :meth:`from_samples` from arrays; never by hand unless the parts are already validated. diff --git a/src/solprop/coupling/port.py b/src/solprop/coupling/port.py index ee0e90d2b..f1d635965 100644 --- a/src/solprop/coupling/port.py +++ b/src/solprop/coupling/port.py @@ -8,14 +8,14 @@ already sampled (SEP 31, section 5.1): the route, the temporal lattice and basis with their identities, the entrance plane and the frame accumulated there, the native metric Q, the reference index of the medium, and the coupling models it -admits. :meth:`solprop.driver.Simulation.input_port` returns one; the translation -layer fills it. It is a P-layer value type, so that a coupling computed with NumPy -alone never imports the translation layer. +admits. :meth:`solprop.driver.simulation.Simulation.input_port` returns one; the +translation layer fills it. It is a P-layer value type, so that a coupling computed +with NumPy alone never imports the translation layer. A :class:`CouplingLaunch` is bound to one port: it carries the port, the target coefficients and the reference origin the launched run inherits, and -:meth:`solprop.driver.Simulation.with_input` refuses it for any other port, whatever -the array shapes. +:meth:`solprop.driver.simulation.Simulation.with_input` refuses it for any other port, +whatever the array shapes. :func:`same_basis` is the identity model of section 4.3: a plane whose basis, lattice, orientation and metric are the port's is copied as it stands, and its @@ -55,7 +55,7 @@ ] ENTRANCE_TOLERANCE = 1e-12 -"""Relative agreement of two entrance positions, of the larger of 1 m and |z|. +"""Relative agreement of two entrance positions, of the larger of 1 m and abs(z). A launch names its entrance by position; within this, two positions are the same plane, as a plane the run reached by summing steps sits within a few ulp of it. @@ -189,7 +189,7 @@ def lattice(self) -> str: Returns ------- str - :func:`~solprop.coupling.lattice_identity` of `grid`. + :func:`~solprop.coupling.plane.lattice_identity` of `grid`. """ return lattice_identity(self.grid) @@ -352,7 +352,7 @@ def same_basis(plane: FieldPlane, port: InputPort) -> CouplingLaunch: origin, so the launched run continues the source's absolute time and carrier phase without moving the pulse in its window. Anything else is a different calculation, refused by name: a different lattice needs - :func:`~solprop.coupling.regrid` first, a different basis or medium a coupling + :func:`~solprop.coupling.remap.regrid` first, a different basis or medium a coupling model or an interface. Parameters diff --git a/src/solprop/coupling/remap.py b/src/solprop/coupling/remap.py index b38d3f5be..a71567856 100644 --- a/src/solprop/coupling/remap.py +++ b/src/solprop/coupling/remap.py @@ -26,14 +26,15 @@ \sum_j a_j\,e^{i\omega'_m (t_j + \Delta)}, with :math:`\Delta = \tau - \tau'` and :math:`\Delta\phi = \phi - \phi'` the change of -reference (:class:`~solprop.coupling.PlaneReference`), so that the absolute spectrum is -unchanged. The sum is evaluated only on target bins inside the source lattice's own -span: beyond it the sum of samples spaced Δt repeats with period 2π/Δt and returns -aliases of the record, not light. The direct sum is the reference implementation; -it is evaluated in bounded chunks of target bins. The one acceleration is the -identical lattice in an unchanged reference, where the map is the identity and the -samples are copied; the tests check it against the direct sum. Complex spectral -zeros need no special case: no phase is unwrapped and nothing is interpolated. +reference (:class:`~solprop.coupling.plane.PlaneReference`), so that the absolute +spectrum is unchanged. The sum is evaluated only on target bins inside the source +lattice's own span: beyond it the sum of samples spaced Δt repeats with period 2π/Δt +and returns aliases of the record, not light. The direct sum is the reference +implementation; it is evaluated in bounded chunks of target bins. The one +acceleration is the identical lattice in an unchanged reference, where the map is the +identity and the samples are copied; the tests check it against the direct sum. +Complex spectral zeros need no special case: no phase is unwrapped and nothing is +interpolated. **What is lost is reported, and what cannot be represented is refused.** Light outside the target's active band, or outside its time window after the shift, is @@ -89,7 +90,7 @@ class RegridReport: method : {'copy', 'direct'} Identity copy on an identical lattice and reference, or the direct sum. source_lattice, target_lattice : str - :func:`~solprop.coupling.lattice_identity` of the two grids. + :func:`~solprop.coupling.plane.lattice_identity` of the two grids. shift : float Record-time shift applied, ``old delay - new delay``, s. phase_shift : float @@ -294,7 +295,7 @@ def regrid( # noqa: PLR0913 - the target and each explicitly lossy permission Returns ------- tuple - The converted :class:`~solprop.coupling.FieldPlane` and its + The converted :class:`~solprop.coupling.plane.FieldPlane` and its :class:`RegridReport`. Raises diff --git a/src/solprop/driver/simulation.py b/src/solprop/driver/simulation.py index 670b0c037..0cbfdca6b 100644 --- a/src/solprop/driver/simulation.py +++ b/src/solprop/driver/simulation.py @@ -1292,7 +1292,7 @@ def _plane_bases( A spatial basis is its transverse grid and a general one its checked reference, the same at every plane; an ordinary mode set's basis is evaluated at each plane - (:func:`solprop.coupling.modal_basis_identity`), because a guide may vary. + (:func:`solprop.coupling.plane.modal_basis_identity`), because a guide may vary. Parameters ---------- @@ -1848,8 +1848,8 @@ def input_port(self, z: float | None = None) -> InputPort: frame accumulated there, the native metric at the entrance, the reference index of a spatial medium and, for a general guide that does not move, its E/H reference at the nodes. A launch computed for it - (:func:`solprop.coupling.same_basis`) is accepted by :meth:`with_input`, and - a launch computed for any other port is refused. + (:func:`solprop.coupling.port.same_basis`) is accepted by :meth:`with_input`, + and a launch computed for any other port is refused. Parameters ---------- @@ -1935,7 +1935,7 @@ def with_input( Pulse or internal-convention spectrum on the existing grid. The built shape must match the original input, including modes and polarisation. Modal amplitudes are in sqrt(W); spatial units follow the spatial grid. - A :class:`~solprop.coupling.CouplingLaunch` (SEP 31) must be bound to + A :class:`~solprop.coupling.port.CouplingLaunch` (SEP 31) must be bound to this simulation's :meth:`input_port` -- its route, lattice, basis, entrance and metric -- or it is refused whatever its shape; its entrance sets the launch position, and its provenance and inherited diff --git a/src/solprop/output/schema.py b/src/solprop/output/schema.py index 5173434be..7eed9971e 100644 --- a/src/solprop/output/schema.py +++ b/src/solprop/output/schema.py @@ -2557,8 +2557,8 @@ class RunMetadata(Displayed): ``frame``, the frame integral of :func:`solprop.translate.coupling.frame_record`, or ``None`` for an operator supplied without frame tables; and ``basis``, the route and the basis - identity (:func:`solprop.coupling.basis_identity`) at each planned plane - ``z``, the launch plane first. :func:`solprop.coupling.reference_at` + identity (:func:`solprop.coupling.plane.basis_identity`) at each planned plane + ``z``, the launch plane first. :func:`solprop.coupling.plane.reference_at` rebuilds any saved plane's reference from it. Derived numbers, so outside the hash; the inherited origin enters it through ``configuration["coupling"]``. diff --git a/src/solprop/processing/planes.py b/src/solprop/processing/planes.py index 351659e88..315e86062 100644 --- a/src/solprop/processing/planes.py +++ b/src/solprop/processing/planes.py @@ -4,11 +4,12 @@ """Read one saved plane of a run back as a coherent field plane (SEP 31). -:func:`field_plane` turns a stored plane into a :class:`solprop.coupling.FieldPlane`: -its native coefficients exactly as stored, the reference rebuilt from the run's -schema-24 plane-reference record, the medium's metric at the plane's own position, -the scoped path certificate and the provenance. It is the extraction half of plane -exchange; the manual page is ``docs/guide/workflows/coupling.md``. +:func:`field_plane` turns a stored plane into a +:class:`solprop.coupling.plane.FieldPlane`: its native coefficients exactly as stored, +the reference rebuilt from the run's schema-24 plane-reference record, the medium's +metric at the plane's own position, the scoped path certificate and the provenance. +It is the extraction half of plane exchange; the manual page is +``docs/guide/workflows/coupling.md``. A file records what it can: the frame integral, the spatial plane measure and the identities of a general guide's reference, not the reference's arrays. A general @@ -50,7 +51,7 @@ __all__ = ["PLANE_TOLERANCE", "field_plane"] PLANE_TOLERANCE = 1e-9 -"""Relative distance, of the larger of 1 m and |z|, within which a z names a plane. +"""Relative distance, of the larger of 1 m and abs(z), within which a z names a plane. A requested position names a stored plane only if it is that plane to well within a step; a nearby physical plane that was not stored is refused, not substituted. @@ -388,7 +389,7 @@ def field_plane( z: float | None = None, source: object | None = None, ) -> FieldPlane: - """Read one saved plane of a run as a :class:`~solprop.coupling.FieldPlane`. + """Read one saved plane of a run as a :class:`~solprop.coupling.plane.FieldPlane`. The samples are the stored optics-convention spectrum, unchanged. The reference is rebuilt from the run's plane-reference record (output schema 24) at the diff --git a/src/solprop/translate/coupling.py b/src/solprop/translate/coupling.py index a216b9981..70b4191f9 100644 --- a/src/solprop/translate/coupling.py +++ b/src/solprop/translate/coupling.py @@ -8,7 +8,7 @@ the persisted frame integral of a run: the antiderivatives of the frame quantities its linear operator removed, so that any saved plane's reference can be rebuilt without the run (output schema 24). :func:`input_port` fills the -:class:`solprop.coupling.InputPort` of a translated problem's entrance: its native +:class:`solprop.coupling.port.InputPort` of a translated problem's entrance: its native metric at that plane, the frame it has accumulated there, the entrance medium's index and, for a general guide that does not move, its E/H reference at the nodes. Both read the contract's own leaves, so a port and a saved plane of the same run see @@ -52,7 +52,7 @@ def frame_record( :math:`\beta_0` zero where the frame keeps it. The record holds the exact Chebyshev antiderivatives :math:`\Phi_0 = \int_0^z\beta_0` and :math:`T = \int_0^z\beta_1` of the contract's own frame tables and the frequency - origin, which is all :func:`solprop.coupling.reference_at` needs. + origin, which is all :func:`solprop.coupling.plane.reference_at` needs. Parameters ---------- From 5f7bdaa24ff040c4dfe8b27f5ab638a9b6d556a0 Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 01:03:40 +0100 Subject: [PATCH 13/50] SEP 31: Part II report for phase 2, plane exchange Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019qNYqtCKh4iTKj7544yARb --- docs/dev/seps/sep-0031-simulation-coupling.md | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) diff --git a/docs/dev/seps/sep-0031-simulation-coupling.md b/docs/dev/seps/sep-0031-simulation-coupling.md index b5fa4f6f5..e309f613f 100644 --- a/docs/dev/seps/sep-0031-simulation-coupling.md +++ b/docs/dev/seps/sep-0031-simulation-coupling.md @@ -1208,6 +1208,193 @@ CouplingModel("scalar_paraxial", ...))`, adds the analytic and sampled inputs, `propz` transport and the §4.3 admission, and should fold `IncidentLaunch` into the report rather than keep both. +### Phase 2 report — plane exchange + +**Delivered (2026-10-24, `feature/sep-0031-simulation-coupling`).** + +- `src/solprop/coupling/` (P): `plane.py` (`FieldPlane` and its records), `port.py` + (`InputPort`, `CouplingLaunch`, `same_basis`), `remap.py` (`regrid`, + `RegridReport`) and `__init__.py`. NumPy and P modules only. The `__init__` holds a + comment where the transport core's names are exported. +- `src/solprop/processing/planes.py` (X): `field_plane(output, plane=-1, *, z=None, + source=None)`, re-exported from `solprop.processing`. +- `src/solprop/translate/coupling.py` (T): `frame_record` (the persisted frame integral), + `port_metric` and `input_port` (fills an `InputPort` from a `KernelProblem`). +- `driver.Simulation.input_port(z=None)` and `with_input(launch)`; `metadata_for` writes + the plane-reference record. +- Output schema 24: `RunMetadata.reference`. Kernel schema unchanged at 35: no protected + contract field changed. +- `CouplingError` in `solprop.exceptions`; `coupling` in the package's submodules. +- Docs: architecture §3 (the X → T edges and how coupling crosses the layers) and §4, + structure figure 1 (the new modules, with `coupling/transport` and `coupling/elements` + dashed as planned), provenance and output pages, the API index, a draft + `docs/guide/workflows/coupling.md` in the workflows index, a note in + interior-launch, and the changelog. + +**The `FieldPlane` contract** (frozen, `eq=False`; arrays owned, `writeable=False`): + +| field | type and meaning | +|---|---| +| `kind` | `'modal' \| 'general_modal' \| 'radial' \| 'cartesian' \| 'planar'` (`planar` is defined but not yet produced) | +| `samples` | complex128 `(n_modes, n_pol, n_omega)`: the optics-convention raw DFT of each column referenced to the window centre, exactly as an output stores it | +| `grid` | the `TimeGrid` (layout, time samples, absolute ω lattice, carrier `grid.omega0`) | +| `units` | `'sqrt(W)'` (modal, general modal), `'V m'` (radial Hankel), `'V/m'` (Cartesian DFT) | +| `basis` | `PlaneBasis(kind, identity, labels, space)`: `identity` is a SHA-256 digest; `space` is the `RadialGrid`/`FreeGrid` of a spatial plane | +| `reference` | `PlaneReference(phase, delay, calibrated)`: absolute spectrum = samples × exp(i(φ + ωτ)), ω absolute; τ is the absolute time of the record's t = 0 | +| `geometry` | `PlaneGeometry(z, side, origin, normal, x_axis)`, right-handed (`y_axis = normal × x_axis`) | +| `metric` | `PlaneMetric(kind, matrix, admittance, weights, scale, flux, reason)`; kinds `identity`, `dense` (Q = conj(W), `(n, n, n_omega)`), `admittance` (`c k_z/ω` per column, Parseval weights, `eps0 c/2`, optional `SpatialFlux`), `unknown` (with a reason) | +| `exterior` | `'zero' \| 'unknown'` | +| `medium` | the `SpatialMedium` of a spatial plane when at hand, else None | +| `certificate` | read-only mapping: the raw `flux_plan["admission"]` dict with its `scope` | +| `nodal` | `NodalField(nodes, weights, electric, magnetic)`, `(n_nodes, 3, n_omega)` optics spectra, for a general modal plane read with `source=` | +| `provenance` | `PlaneProvenance(source, plane, z, lineage, history)` | + +Methods: `omega`, `absolute()`, `spectral_energy()`, `energy()`, `node_spectra()` (a +spatial plane's E at its nodes), `from_pulse(pulse, grid, geometry, *, space, medium, +reference=None)` (reuses `SpatialPulse.build`) and `from_samples(samples, grid, geometry, +*, exterior, space=, medium= | basis=, metric=, reference=)`. Helpers: +`reference_at(record, z)`, `basis_identity(route, record)`, `space_record(space)`, +`lattice_identity(grid)` and `modal_basis_identity(modes, grid, z)`. + +**The `InputPort` contract** (P value type, frozen): `route`, `grid`, `basis` +(identity, labels, space), `shape` `(n_modes, n_pol, n_omega)`, `geometry` (the +entrance z and side), `frame` (the receiving run's own frame accumulated from its path +origin to the entrance, without any inherited origin), `metric` (Q at the entrance: +identity for ordinary true-power coefficients, conj(W) for a declared general basis, the +axial admittance of the spatial plane measure), `reference_index` (real phase index +per bin, spatial media only so far), `nodes`, `node_weights`, `electric` and `magnetic` +(optics-convention `(n_nodes, 3, n_modes, n_omega)` per √W, filled for a general guide +that does not move), and `models` (`('same_basis',)`). `identity` digests the route, +lattice, basis, shape, z and side. `accepts(other)` names the first disagreement among +route, lattice, basis, shape, side, entrance (relative 1e-12), orientation and metric +(relative 1e-10). A `CouplingLaunch(port, samples, origin, model, provenance)` is bound +to its port; `internal()` gives the internal-convention array and `record()` the +`configuration["coupling"]` entry (`model`, `port`, `origin`, `source`). + +**Plane-reference record (output schema 24).** `RunMetadata.reference` = +`{carrier, origin, parent, frame, basis}`. `frame` holds `frequency_origin` (the frame's +reference frequency on an envelope grid, zero on a field grid) and the exact Chebyshev +antiderivatives of the contract's frame tables `linear.beta0` and `linear.beta1` +(`phase`, `delay`, each `breakpoints` and `coefficients`). Then φ = φ₀ + Φ₀ − ω_o T and +τ = τ₀ + T. This uses the terms the route removes: `frame_quantities` and the spatial +`frame_quantities` return β₀ = 0 where the frame keeps it, so a field grid and an +envelope with THG carry only the linear term, measured from ω = 0 on a field grid and +from ω_ref on an envelope (`linear.py` `Frame`, `dispersion_operator`). `basis` is the +route and the basis identity at each planned plane (launch plane first). +`origin`/`parent` come from `configuration["coupling"]`. + +**Deviations and why.** + +1. *Extraction is `processing.field_plane`, not `FieldPlane.from_output`* (§5.1). + P must not import X; extraction reads an `Output` and reuses + `processing.modal`'s checked identities. +2. *The reference tables are outside the hash.* They are derived from the hashed problem. + A resume on another build could evaluate them to a different last bit, and SEP 25 + forbids a bitwise resume key. The inherited origin, which carries information, is in + `configuration["coupling"]`, inside the hash. +3. *An ordinary mode set's basis identity is its value at the plane*, recorded per planned + plane: each mode's class, label, effective area and real n_eff on the active band at + that z, quantised to 12 digits, plus the lattice. The full `ModeRecord` would make the + exit of a 1 mm capillary a different basis from the entrance of a 2 mm one: a + constant-pressure path records its span. A natural-step plane (not planned) has no + recorded identity, so it gets an "unrecorded" digest that no port matches. +4. *`from_pulse` takes `space=`* beside the temporal `grid`, because the beam builder + needs both. +5. *`same_basis` is a public function.* It is the minimal internal path the brief + allows, needed by the guide page. Phase 3's `couple(..., CouplingModel("same_basis"))` + should dispatch to it. +6. *Remap alias guard.* The direct sum is evaluated only on target bins within the source + lattice's own span. Beyond it, the sum of samples spaced Δt repeats with period + 2π/Δt and returned aliased light. The first test of a cropped band found this: + the energy rose from 1.0 to 1.48 μJ. Loss shares are of a **record norm** + (column weights; vacuum-equivalent on a spatial plane, energy on an identity metric). + The report's energies are physical. +7. *Planar planes are refused by name*: a `PlanarGrid` carries its slab modes and has no + plain record to rebuild from. +8. *For a launch, `noise="keep"` means no noise*: the launch carries its source's + realised field. Another floor is added only when a realisation is given. +9. *Runtime literal checks.* Membership checks on `Literal` fields were dropped. The test + session's beartype rejects such values first, so the checks would be unreachable + under coverage. Static typing covers them. +10. *Certificate.* On this branch, `flux_from_record` does not rebuild `AdiabaticAdmission`, + so the plane carries the raw `flux_plan["admission"]` dict, read-only, with its scope. +11. *A general-modal plane reads E/H through the `electric_field` capability name*, + since `field_plane` is not on the general-modal accessor whitelist. + +**Verification run** (targeted, `uv run --no-sync pytest -n auto`; none fail): + +- `tests/coupling/` (`test_plane.py`, `test_port.py`, `test_remap.py`): 48 passed. +- `tests/processing/test_planes.py`: 10 passed. +- `tests/driver/test_coupling_relaunch.py`: 13 passed, including the 8 medium-tier + source/receiver pairs over `numpy_ref`, `numba_cpu` and native CPU. +- Schema-literal and record modules: 547 passed. Driver, output, processing and + backend modules that read the schema or use `with_input`: 627 passed and 302 passed. +- `tests/test_structure_figures.py`: 278 passed; `tests/test_guide_snippets.py` + (compiled): 19 passed. The coupling page's snippets run by hand in 3.7 s. +- Doctests of the changed modules: 62 passed. +- ruff, ruff format, basedpyright (0 errors), numpydoc and codespell are clean on the + changed files. + +Evidence: + +- Remap against an independent double sum (no FFT) on equal, longer, finer, field and + carrier-shifted targets, from envelope and field sources: within 64(N+N′)ε of the peak. + Against the same pulse built on the target: 1e-12. Energy agrees to 1e-10. The copy + path on an identical lattice is bitwise. A 40-sample delay moves the peak 40 samples + later, and the absolute spectrum is invariant. A phase change is an exact phase. Two + separated bands keep the gap at the tails' level (< 1e-12), and a sign-changing + spectral zero follows the direct sum. +- Relaunch: 1 mm + 1 mm of an argon capillary (2 modes, field grid) against 2 mm. At + rtol 1e-8 the uninterrupted run is 1.3e-10 from a 1e-11 reference and the relaunched + one 1.4e-10. At rtol 1e-6 they are 1.3e-8 and 1.5e-8, so both follow the tolerance. + The two references agree to 1e-12. Interior relaunch into the same run and a linear + radial two-stage run (1e-10) also pass. +- A spatial plane's `energy()` equals the run's own `energy` statistic to 1e-12 (radial, + Cartesian, uniform and gradient argon). A gradient plane carries its certificate. + +`uv run poe docs` builds clean with warnings as errors. +Not run: the full gate, slow and coverage lanes (end of SEP, per AGENTS.md), and GPU. + +**Limitations.** Only the identity model exists: a different basis, lattice or medium is +refused, not coupled. Ordinary-modal ports carry no profiles or reference index yet. The +remap's loss shares on a spatial plane are vacuum-weighted. Natural-step modal planes +cannot be relaunched by `same_basis`. Saved fields are taken as stored, and a +modified-noise run's auxiliary `noise_phase` is not reconstructed (§4.7). Planar planes +are refused. + +**Open questions.** + +1. Should the modal basis identity be a tolerance comparison of n_eff and A_eff, rather + than a 12-digit digest? A value within a few ulp of a rounding boundary can split + two identical guides. This is improbable but possible on gradient guides. +2. Should regrid's band loss be reported in physical energy, using the per-bin + admittance, as well as in the record norm? +3. Should a modified-noise run's plane be refused, or reconstructed from its documented + saved-field meaning (§4.7)? It is currently taken as stored. +4. Should `field_plane` join the general-modal accessor whitelist, so the capability gate + names it? +5. `same_basis` refuses light outside the port's active band on an identical lattice. + Should it crop with a report instead, as `regrid(crop_band=True)` does? +6. Should the architecture record `processing.mixtures`' existing import of + `translate.flux` as a deliberate edge (now documented in §3), or move the plane-measure + rebuild behind a D-layer accessor? + +**Handoff to phases 3 and 4.** + +- Phase 3 should add `couple(plane, port, model=CouplingModel(...))` with `same_basis` + as its identity branch, and fold `IncidentLaunch` into `CouplingResult.report`. The + ports need extending: ordinary capillary scalar profiles and the core-gas + `reference_index`, weak-guidance step-index, and general modal E/H for moving guides + (`translate.coupling.input_port` is the one place to fill them). `from_analytic` is + still to be written. `CouplingLaunch` and `with_input`'s binding check are reusable as + they stand. +- Phase 4 should keep `FieldPlane` immutable and return new planes. It should append + to `provenance.history`, update `PlaneReference.calibrated` where an optical model + drops an affine term, and re-resolve a spatial metric through `PlaneMetric.flux`. + Mirrors and rotations should change `PlaneGeometry.normal`/`x_axis` together with the + polarisation. The names belong at the marked comment in `coupling/__init__.py`, and + the dashed boxes in `docs/dev/figures/build.py` turn solid when the files land. + ## Part III — History - 2026-10-22: Drafted after auditing existing launch, projection, spatial flux, @@ -1242,6 +1429,7 @@ the report rather than keep both. - 2026-10-23: In progress. Implementation started on a branch cut from SEP 34's implementation branch; it reaches main through its own pull request after SEP 34 has merged. +- 2026-10-24: Phase 2, plane exchange, implemented; report in Part II. ### AI review — 2026-10-23 From ea6d05f42e285bd51b545ef376f2cb71557d6558 Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 01:19:18 +0100 Subject: [PATCH 14/50] SEP 31 transport: field planes in and out, coupling refusals transport() now takes a radial or Cartesian FieldPlane and returns the transported plane with its ledger. The frame term each FreeSpace removes is added to the plane's reference (from the plane's Frame when given, otherwise beta1(omega0) L, recorded as such); spectral calibration records clear the reference terms an element drops; a mirror turns the plane's axes with its polarisation; the metric is resolved again with SpatialFlux.resolve in the output medium and grid; each operation is appended to the provenance history. A modal plane is refused in favour of Outcouple. Every refusal raises CouplingError. Rays leaving the window now refuse unless beyond_window='remove' declares the angular band removal, which is reported. Aperture gains weighting='amplitude'|'quadrature' with the meaning of the in-run aperture statistics. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019qNYqtCKh4iTKj7544yARb --- src/solprop/coupling/__init__.py | 30 +- src/solprop/coupling/elements.py | 402 +++++++++++++--------- src/solprop/coupling/transport.py | 459 +++++++++++++++++++++---- tests/coupling/conftest.py | 18 +- tests/coupling/test_elements.py | 110 +++--- tests/coupling/test_transport.py | 93 +++-- tests/coupling/test_transport_plane.py | 313 +++++++++++++++++ 7 files changed, 1109 insertions(+), 316 deletions(-) create mode 100644 tests/coupling/test_transport_plane.py diff --git a/src/solprop/coupling/__init__.py b/src/solprop/coupling/__init__.py index 5560ff7ff..757f6a438 100644 --- a/src/solprop/coupling/__init__.py +++ b/src/solprop/coupling/__init__.py @@ -16,6 +16,18 @@ ``docs/guide/workflows/coupling.md``. """ +from solprop.coupling.elements import ( + Alignment, + Aperture, + FreeSpace, + Mirror, + PlanarInterface, + PolarisationTransform, + SpectralCalibration, + SpectralTransfer, + ThinLens, + TransmissionMask, +) from solprop.coupling.plane import ( PLANE_KINDS, Calibration, @@ -43,31 +55,38 @@ same_basis, ) from solprop.coupling.remap import DEFAULT_TOLERANCE, RegridReport, regrid - -# The transport core (solprop.coupling.transport and solprop.coupling.elements: -# transport, FreeSpace, ThinLens, Aperture, TransmissionMask, PlanarInterface, -# PolarisationTransform, Mirror, SpectralTransfer, Outcouple) is exported here when -# it lands, beside the names below. +from solprop.coupling.transport import TransportReport, transport __all__ = [ "DEFAULT_TOLERANCE", "ENTRANCE_TOLERANCE", "METRIC_TOLERANCE", "PLANE_KINDS", + "Alignment", + "Aperture", "Calibration", "CouplingLaunch", "Exterior", "FieldPlane", + "FreeSpace", "InputPort", "MetricKind", + "Mirror", "NodalField", + "PlanarInterface", "PlaneBasis", "PlaneGeometry", "PlaneKind", "PlaneMetric", "PlaneProvenance", "PlaneReference", + "PolarisationTransform", "RegridReport", + "SpectralCalibration", + "SpectralTransfer", + "ThinLens", + "TransmissionMask", + "TransportReport", "basis_identity", "lattice_identity", "modal_basis_identity", @@ -75,4 +94,5 @@ "regrid", "same_basis", "space_record", + "transport", ] diff --git a/src/solprop/coupling/elements.py b/src/solprop/coupling/elements.py index d92deee65..ee98a07aa 100644 --- a/src/solprop/coupling/elements.py +++ b/src/solprop/coupling/elements.py @@ -19,6 +19,12 @@ function. Transverse transforms are not conjugated: they act on positions, which are the same in both conventions. +A :class:`~solprop.coupling.plane.FieldPlane` is carried through a sequence of these +elements by :func:`solprop.coupling.transport`, which converts it to this state and +back and updates its reference, geometry, medium and metric; the state and the +:class:`Element` protocol are the interface a new element is written against. Every +refusal raises :class:`solprop.exceptions.CouplingError`. + Energies are physical: the axial Poynting flux of the medium at the plane, :class:`solprop.spatial.SpatialFlux` and :func:`solprop.spatial.spatial_energy` (SEP 34). Every report closes its own ledger in joules, @@ -43,13 +49,7 @@ from solprop import fourier from solprop.constants import C -from solprop.exceptions import ( - ConvergenceError, - GridError, - MaterialError, - OpticsGeometryError, - PolarisationError, -) +from solprop.exceptions import CouplingError from solprop.grid import FreeGrid, LineGrid, RadialGrid, TimeGrid from solprop.optics import ( Chain, @@ -109,7 +109,9 @@ class SpatialState: """A spatial field at one plane, with its grids, medium and local frame. - The plain input of every element. The single component of a scalar state + The plain input of every element; :func:`solprop.coupling.transport` builds it + from a :class:`~solprop.coupling.plane.FieldPlane` (the internal conjugate of + the plane's samples, its grid's frame axes as rows). The single component of a scalar state (``n_pol = 1``) is polarised along the frame's x axis; two components are the optics-convention ``(x, y)`` Jones components stored conjugated, as :meth:`solprop.pulses.SpatialPulse.build` stores them. @@ -135,11 +137,9 @@ class SpatialState: Raises ------ - GridError + CouplingError If the representation, the spectrum's shape, finiteness or band, or the - frame is invalid. - MaterialError - If the medium varies along z. + frame is invalid, or the medium varies along z. """ grid: TimeGrid @@ -157,7 +157,7 @@ def __post_init__(self) -> None: "(per unit width) and a planar grid (slab amplitudes) are not field " f"planes of a finite beam, got {type(space).__name__}" ) - raise GridError(msg) + raise CouplingError(msg) spectrum = np.array(self.spectrum, dtype=np.complex128, copy=True) if ( spectrum.ndim != 3 @@ -171,19 +171,19 @@ def __post_init__(self) -> None: f"({space.n_columns}, 1 or 2, {self.grid.n_omega}), got " f"{spectrum.shape}" ) - raise GridError(msg) + raise CouplingError(msg) if np.any(spectrum[:, :, ~_band(self.grid)]): msg = ( "a spatial state is exactly zero outside the active band and at " "nonpositive frequency, as a launch or a saved plane is" ) - raise GridError(msg) + raise CouplingError(msg) if not all(path.is_constant for path in self.medium.parameter_paths.values()): msg = ( "transport between planes needs a homogeneous medium; a medium that " "varies along z is an ordinary propagation, not a free-space element" ) - raise MaterialError(msg) + raise CouplingError(msg) frame = _unit_frame(self.frame) spectrum.setflags(write=False) object.__setattr__(self, "spectrum", spectrum) @@ -250,7 +250,7 @@ def _unit_frame(frame: ArrayLike) -> Float[np.ndarray, "3 3"]: Raises ------ - GridError + CouplingError If the frame is not finite, orthonormal and right-handed. """ values = np.array(frame, dtype=np.float64, copy=True) @@ -264,7 +264,7 @@ def _unit_frame(frame: ArrayLike) -> Float[np.ndarray, "3 3"]: "a local frame is a right-handed orthonormal 3x3 array whose rows are " "the x, y and forward directions" ) - raise GridError(msg) + raise CouplingError(msg) values.setflags(write=False) return values @@ -1009,14 +1009,14 @@ def _spectral_values( Raises ------ - MaterialError + CouplingError If the values are not finite or do not match the band. """ result = values(omega) if callable(values) else values array = np.broadcast_to(np.asarray(result, dtype=np.complex128), omega.shape) if not np.all(np.isfinite(array)): msg = f"{name} must be finite on every band frequency" - raise MaterialError(msg) + raise CouplingError(msg) return np.array(array) @@ -1030,12 +1030,12 @@ def _check_model_error(model_error: float) -> None: Raises ------ - ConvergenceError + CouplingError If it is not finite and strictly between zero and one. """ if not (math.isfinite(model_error) and 0 < model_error < 1): msg = f"model_error is a required budget in (0, 1), got {model_error!r}" - raise ConvergenceError(msg) + raise CouplingError(msg) # --------------------------------------------------------------------- thin screens @@ -1141,23 +1141,20 @@ def _apply_screen( # noqa: PLR0913 - the screen and its declarations Raises ------ - MaterialError - If the screen amplifies anywhere. - GridError - If the screen's phase is undersampled where the field is. - ConvergenceError - If the certificate exceeds the budget. + CouplingError + If the screen amplifies anywhere, its phase is undersampled where the + field is, or the certificate exceeds the budget. """ _check_model_error(model_error) if not np.all(np.isfinite(transmission)): msg = f"{element}: the transmission must be finite" - raise MaterialError(msg) + raise CouplingError(msg) if np.max(np.abs(transmission), initial=0.0) > 1 + 8 * _EPS: msg = ( f"{element}: a passive screen has |T| <= 1; a gain screen is not " "a linear passive element" ) - raise MaterialError(msg) + raise CouplingError(msg) band = _band(state.grid) index = np.real(_index(state.medium, state.grid)) samples = _to_field(state.space, state.spectrum) @@ -1172,7 +1169,7 @@ def _apply_screen( # noqa: PLR0913 - the screen and its declarations "neighbouring nodes where the field is, beyond the grid's Nyquist rate; " "refine the transverse grid" ) - raise GridError(msg) + raise CouplingError(msg) factor = np.ones((state.space.n_columns, state.grid.n_omega), dtype=np.complex128) factor[:, band] = fourier.to_internal(transmission) after = state.with_spectrum(_to_spectrum(state.space, factor[:, None, :] * samples)) @@ -1233,10 +1230,9 @@ def _screen_certificate( Raises ------ - GridError - If a nonzero input carries no forward flux. - ConvergenceError - If the defect ratio exceeds the budget. + CouplingError + If a nonzero input carries no forward flux or the defect ratio exceeds + the budget. """ cosine = 1 - model_error / 8 if not np.any(before.spectrum): @@ -1248,7 +1244,7 @@ def _screen_certificate( reference = spatial_energy(before.spectrum, flux) if reference <= 0: msg = "a thin screen's input carries no forward flux to certify against" - raise GridError(msg) + raise CouplingError(msg) ratio = (defect_in + defect_out) / reference + numerical if ratio > model_error: msg = ( @@ -1257,7 +1253,7 @@ def _screen_certificate( "cos(theta*) = 1 - model_error/8 or the transform is not resolved; " "relax the budget only if a larger axial-flux error is acceptable" ) - raise ConvergenceError(msg) + raise CouplingError(msg) return ScreenCertificate( model_error, cosine, defect_in, defect_out, reference, numerical, ratio, False ) @@ -1290,7 +1286,7 @@ def _evaluate_transmission( Raises ------ - MaterialError + CouplingError If the result does not broadcast to ``(n_nodes, n_band)``. """ values = np.asarray(transmission(x, y, omega), dtype=np.complex128) @@ -1301,7 +1297,7 @@ def _evaluate_transmission( f"{element}: the transmission must broadcast to (n_nodes, n_band) = " f"({x.size}, {omega.size}), got {values.shape}" ) - raise MaterialError(msg) from error + raise CouplingError(msg) from error def _symmetric_transmission( @@ -1333,7 +1329,7 @@ def _symmetric_transmission( Raises ------ - GridError + CouplingError If the transmission depends on the azimuth. """ radius = np.asarray(space.r) @@ -1355,7 +1351,7 @@ def _symmetric_transmission( f"{element} depends on the azimuth, and a radial grid holds only " "cylindrically symmetric fields: use a Cartesian FreeGrid" ) - raise GridError(msg) + raise CouplingError(msg) return reference @@ -1400,7 +1396,7 @@ def apply(self, state: SpatialState) -> ElementResult: Raises ------ - MaterialError + CouplingError If a focal length is zero or not finite. """ band = _band(state.grid) @@ -1408,7 +1404,7 @@ def apply(self, state: SpatialState) -> ElementResult: focal = np.real(_spectral_values(self.focal_length, omega, "focal_length")) if np.any(focal == 0): msg = "a thin lens needs a nonzero focal length on every band bin" - raise MaterialError(msg) + raise CouplingError(msg) wavenumber = np.real(_index(state.medium, state.grid))[band] * omega / C x, y = _nodes(state.space) square = x * x + y * y @@ -1432,11 +1428,15 @@ def apply(self, state: SpatialState) -> ElementResult: @dataclass(frozen=True, eq=False) class Aperture: - """An aperture whose intensity weight is converted to an amplitude. + """An aperture built on the SEP 23 edge profile, with its weighting declared. - :func:`solprop.spatial.aperture_profile` gives the SEP 23 edge profile ``W``, - used here as the **intensity** transmission; the field is multiplied by - ``sqrt(W)``. A hard aperture is the same either way. + :func:`solprop.spatial.aperture_profile` gives the edge profile ``W``. Its + meaning is fixed by `weighting`, exactly as for the in-run aperture statistics + of :class:`solprop.stats.ApertureSet`: ``'amplitude'`` (the default) takes + ``W`` as the **field** transmission, so the power goes as ``W**2``; + ``'quadrature'`` takes ``W`` as a **power** weight, applied to the field as + ``sqrt(W)``. SEP 31 §4.6's intensity-defined aperture is the ``'quadrature'`` + case. A hard aperture (``W`` zero or one) is the same either way. Attributes ---------- @@ -1451,6 +1451,15 @@ class Aperture: Edge profile. Default ``'hard'``. centre : tuple of float Centre ``(x, y)`` in m in the local frame; a radial grid needs the axis. + weighting : {'amplitude', 'quadrature'} + ``'amplitude'`` applies ``W`` to the field, ``'quadrature'`` applies + ``sqrt(W)``. Default ``'amplitude'``. + + Examples + -------- + >>> from solprop.coupling import Aperture + >>> Aperture(1e-3, model_error=1e-2, weighting="quadrature").weighting + 'quadrature' """ radius: float @@ -1458,6 +1467,7 @@ class Aperture: edge: float = 0.0 kind: Literal["hard", "tanh", "rcos", "supergauss"] = "hard" centre: tuple[float, float] = (0.0, 0.0) + weighting: Literal["amplitude", "quadrature"] = "amplitude" def apply(self, state: SpatialState) -> ElementResult: """Apply the aperture. @@ -1474,28 +1484,38 @@ def apply(self, state: SpatialState) -> ElementResult: Raises ------ - GridError - If an off-axis aperture is asked of a radial grid. + CouplingError + If the weighting is unknown or an off-axis aperture is asked of a + radial grid. """ + if self.weighting not in ("amplitude", "quadrature"): + msg = f"weighting is 'amplitude' or 'quadrature', got {self.weighting!r}" + raise CouplingError(msg) if isinstance(state.space, RadialGrid) and any(self.centre): msg = ( "an off-axis aperture breaks cylindrical symmetry: use a Cartesian " "FreeGrid" ) - raise GridError(msg) + raise CouplingError(msg) x, y = _nodes(state.space) distance = np.hypot(x - self.centre[0], y - self.centre[1]) weight = aperture_profile(distance, self.radius, self.edge, self.kind) - amplitude = np.sqrt(weight) + amplitude = np.sqrt(weight) if self.weighting == "quadrature" else weight n_band = int(np.sum(_band(state.grid))) transmission = np.repeat(amplitude[:, None], n_band, axis=1).astype(complex) return _apply_screen( state, transmission, - element=f"Aperture(radius={self.radius:g} m, {self.kind})", + element=( + f"Aperture(radius={self.radius:g} m, {self.kind}, {self.weighting})" + ), model_error=self.model_error, phase_only=False, - notes=("intensity weight W converted to field amplitude sqrt(W)",), + notes=( + "power weight W applied to the field as sqrt(W)" + if self.weighting == "quadrature" + else "field transmission W; power goes as W**2", + ), ) @@ -1537,11 +1557,10 @@ def apply(self, state: SpatialState) -> ElementResult: Raises ------ - MaterialError - If sampled values have the wrong shape or a declared phase-only mask - changes the modulus. - GridError - If a function depends on the azimuth on a radial grid. + CouplingError + If sampled values have the wrong shape, a declared phase-only mask + changes the modulus, or a function depends on the azimuth on a + radial grid. """ band = _band(state.grid) omega = state.grid.omega[band] @@ -1573,13 +1592,13 @@ def apply(self, state: SpatialState) -> ElementResult: "TransmissionMask samples have shape (n_nodes,) or (n_nodes, " f"n_band) = ({n_nodes}, {omega.size}), got {array.shape}" ) - raise MaterialError(msg) + raise CouplingError(msg) values = np.array(np.broadcast_to(array, (n_nodes, omega.size))) if self.phase_only and np.max(np.abs(np.abs(values) - 1), initial=0.0) > ( 8 * _EPS ): msg = "a mask declared phase-only must have unit modulus" - raise MaterialError(msg) + raise CouplingError(msg) return _apply_screen( state, values, @@ -1631,24 +1650,23 @@ def apply(self, state: SpatialState) -> ElementResult: Raises ------ - GridError - If a nonzero alignment is asked of a radial grid. - ConvergenceError - If a tilt has no budget or exceeds it. + CouplingError + If a nonzero alignment is asked of a radial grid, or a tilt has no + budget or exceeds it. """ values = (*self.displacement, *self.tilt) if not all(math.isfinite(value) for value in values) or any( abs(angle) >= math.pi / 2 for angle in self.tilt ): msg = "alignment needs finite displacements and tilts below pi/2" - raise GridError(msg) + raise CouplingError(msg) if isinstance(state.space, RadialGrid): if any(values): msg = ( "displacement and tilt break cylindrical symmetry: use a " "Cartesian FreeGrid" ) - raise GridError(msg) + raise CouplingError(msg) flux = _flux(state) measured = _measure(state, flux) return ElementResult(state, _report("Alignment(none)", measured, measured)) @@ -1658,7 +1676,7 @@ def apply(self, state: SpatialState) -> ElementResult: if any(self.tilt): if self.model_error is None: msg = "a tilt is a phase screen and needs its model_error budget" - raise ConvergenceError(msg) + raise CouplingError(msg) band = _band(state.grid) index = np.real(_index(state.medium, state.grid))[band] wavenumber = index * state.grid.omega[band] / C @@ -1808,17 +1826,17 @@ def _check_passive_jones( Raises ------ - PolarisationError + CouplingError If a matrix is not finite or its largest singular value exceeds one. """ if not np.all(np.isfinite(jones)): msg = f"{element}: the Jones matrices must be finite" - raise PolarisationError(msg) + raise CouplingError(msg) if jones.size and float(np.max(np.linalg.svd(jones, compute_uv=False))) > ( 1 + 8 * _EPS ): msg = f"{element}: a passive Jones map has singular values of at most one" - raise PolarisationError(msg) + raise CouplingError(msg) @dataclass(frozen=True, eq=False) @@ -1857,7 +1875,7 @@ def apply(self, state: SpatialState) -> ElementResult: Raises ------ - PolarisationError + CouplingError If the matrices have the wrong shape or amplify. """ omega = state.grid.omega[_band(state.grid)] @@ -1867,7 +1885,7 @@ def apply(self, state: SpatialState) -> ElementResult: jones = np.array(np.broadcast_to(array, (omega.size, 2, 2))) except ValueError as error: msg = f"a Jones matrix is 2x2 per band bin, got shape {array.shape}" - raise PolarisationError(msg) from error + raise CouplingError(msg) from error _check_passive_jones(jones, "PolarisationTransform") after = state.with_spectrum(_apply_jones(state, jones)) return _uniform_result(state, after, element="PolarisationTransform") @@ -1892,13 +1910,13 @@ def _unit_vector( Raises ------ - OpticsGeometryError + CouplingError If the vector is not a finite nonzero three-vector. """ array = np.asarray(vector, dtype=np.float64) if array.shape != (3,) or not np.all(np.isfinite(array)) or not np.any(array): msg = f"{name} must be a finite nonzero three-vector" - raise OpticsGeometryError(msg) + raise CouplingError(msg) return array / np.linalg.norm(array) @@ -1954,7 +1972,7 @@ def _geometry(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]: Raises ------ - OpticsGeometryError + CouplingError If the directions do not define a reflection with this s axis. """ incident = _unit_vector(self.incident, "incident") @@ -1962,13 +1980,13 @@ def _geometry(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]: s_axis = _unit_vector(self.s_axis, "s_axis") if float(incident @ outgoing) > 1 - _FRAME_TOLERANCE: msg = "a mirror changes the propagation direction: outgoing == incident" - raise OpticsGeometryError(msg) + raise CouplingError(msg) if ( abs(float(s_axis @ incident)) > _FRAME_TOLERANCE or abs(float(s_axis @ outgoing)) > _FRAME_TOLERANCE ): msg = "the s axis must be perpendicular to both propagation directions" - raise OpticsGeometryError(msg) + raise CouplingError(msg) return incident, outgoing, s_axis def apply(self, state: SpatialState) -> ElementResult: @@ -1986,19 +2004,16 @@ def apply(self, state: SpatialState) -> ElementResult: Raises ------ - OpticsGeometryError - If the geometry is invalid or the plane does not propagate along - `incident`. - PolarisationError - If a coefficient exceeds one in modulus. - GridError - If the mirror image cannot be represented exactly on a Cartesian grid. + CouplingError + If the geometry is invalid, the plane does not propagate along + `incident`, a coefficient exceeds one in modulus, or the mirror image + cannot be represented exactly on a Cartesian grid. """ incident, outgoing, s_axis = self._geometry() frame = state.frame if float(frame[2] @ incident) < 1 - _FRAME_TOLERANCE: msg = "the plane does not propagate along the mirror's incident direction" - raise OpticsGeometryError(msg) + raise CouplingError(msg) p_in = np.cross(s_axis, incident) p_out = np.cross(s_axis, outgoing) rotation = np.array( @@ -2055,7 +2070,7 @@ def _mirror_image( Raises ------ - GridError + CouplingError If the map is not a reflection through x or y, or the reflected axis has an odd sample count and so is not symmetric about zero. """ @@ -2065,7 +2080,7 @@ def _mirror_image( "this mirror image is a reflection through an axis oblique to the grid; " "align the s axis with the local x or y axis" ) - raise GridError(msg) + raise CouplingError(msg) shape = (space.n_y, space.n_x, *spectrum.shape[1:]) samples = _to_field(space, spectrum).reshape(shape) for axis, count, flip in ((1, space.n_x, flips[0]), (0, space.n_y, flips[1])): @@ -2075,7 +2090,7 @@ def _mirror_image( "an odd sample count places no sample at zero, so the mirror " "image is not on the grid; use an even count" ) - raise GridError(msg) + raise CouplingError(msg) samples = np.take(samples, (-np.arange(count)) % count, axis=axis) return _to_spectrum(space, samples.reshape(spectrum.shape)) @@ -2118,7 +2133,7 @@ def apply(self, state: SpatialState) -> ElementResult: Raises ------ - MaterialError + CouplingError If the plane is not in `first` or either medium is lossy. """ grid = state.grid @@ -2127,14 +2142,14 @@ def apply(self, state: SpatialState) -> ElementResult: "the plane is not in the interface's first medium; a Fresnel factor " "is applied once, at the declared interface" ) - raise MaterialError(msg) + raise CouplingError(msg) band = _band(grid) first = _index(self.first, grid)[band] second = _index(self.second, grid)[band] for values in (first, second): if np.any(np.abs(np.imag(values)) > 1e-12 * np.real(values)): msg = "the normal-incidence interface model is for lossless media" - raise MaterialError(msg) + raise CouplingError(msg) n_1, n_2 = np.real(first), np.real(second) transmission = 2 * n_1 / (n_1 + n_2) reflectance = np.zeros(grid.n_omega) @@ -2321,7 +2336,7 @@ def _calibrations(self) -> tuple[SpectralCalibration, ...]: Raises ------ - MaterialError + CouplingError If an unknown callable carries no calibration. """ if isinstance(self.calibration, SpectralCalibration): @@ -2335,7 +2350,7 @@ def _calibrations(self) -> tuple[SpectralCalibration, ...]: "calibration=SpectralCalibration(...) saying which affine phase it " "removes" ) - raise MaterialError(msg) + raise CouplingError(msg) return known def apply(self, state: SpatialState) -> ElementResult: @@ -2353,7 +2368,7 @@ def apply(self, state: SpatialState) -> ElementResult: Raises ------ - MaterialError + CouplingError If a lossy prediction lacks its efficiency, an efficiency is given to a lossy model, an efficiency is outside [0, 1], or the calibration is missing. @@ -2372,13 +2387,13 @@ def apply(self, state: SpatialState) -> ElementResult: "the transfer function already carries loss; an efficiency on " "top of it would count the loss twice" ) - raise MaterialError(msg) + raise CouplingError(msg) efficiency = np.real( _spectral_values(self.efficiency, grid.omega[band], "efficiency") ) if np.any((efficiency < 0) | (efficiency > 1)): msg = "an efficiency is a power fraction in [0, 1]" - raise MaterialError(msg) + raise CouplingError(msg) factor = factor.copy() factor[band] = factor[band] * np.sqrt(efficiency) elif self.predict_loss and phase_only: @@ -2386,10 +2401,10 @@ def apply(self, state: SpatialState) -> ElementResult: f"{type(self.transfer).__name__} is a phase-only model: a lossy " "prediction needs an explicit efficiency=" ) - raise MaterialError(msg) + raise CouplingError(msg) if not np.all(np.isfinite(factor)) or np.max(np.abs(factor)) > 1 + 8 * _EPS: msg = "a passive transfer function is finite with |H| <= 1" - raise MaterialError(msg) + raise CouplingError(msg) after = state.with_spectrum(factor[None, None, :] * state.spectrum) notes: tuple[str, ...] = () if isinstance(self.transfer, Chain): @@ -2419,14 +2434,20 @@ class FreeSpace: :math:`k_z=\sqrt{(\omega n/c)^2-\kappa^2}` at each absolute frequency and transverse wave number, on the order-zero Hankel columns of a radial grid or the - DFT columns of a Cartesian one. A pure delay ``frame_delay`` is removed from the - whole field, :math:`e^{-i\omega\tau}`, so that the pulse stays in its window; - the report records it for the plane's reference. + DFT columns of a Cartesian one. An affine frame term is removed from the whole + field, :math:`e^{-i(\phi_f+\omega\tau_f)}`, so that the pulse stays in its + window; the report records ``delay_removed`` and ``phase_removed`` and + :func:`solprop.coupling.transport` adds them to the plane's reference. Without `output` the field stays on its grid, propagated exactly in its - periodic (Cartesian) or Dirichlet (radial) representation; the report states - the share that would wrap or reach the window edge. With `output`, a plane of - another radius or sampling is evaluated by chunked direct summation: + periodic (Cartesian) or Dirichlet (radial) representation. A column at angle + :math:`\theta` moves its rays by :math:`L\tan\theta`; content whose rays leave + the window would wrap (or reflect), which is the same condition as sampling the + propagator's phase :math:`k_zL` below its Nyquist rate in :math:`\kappa`. Content + beyond ``sampling_tolerance`` is refused, unless ``beyond_window='remove'`` + declares its removal, which is then reported as removed energy. With `output`, + a plane of another radius or sampling is evaluated by chunked direct summation; + the method is always the caller's explicit choice: - ``output_method='angular'`` sums the propagated source spectrum at the output nodes (:math:`\sum_j v_jH_jJ_0(k_j\rho)/2\pi` or the trigonometric @@ -2460,14 +2481,30 @@ class FreeSpace: and every nonpropagating column, are removed and reported. None removes nothing. frame_delay : float or None - Delay in s removed from the whole field; None removes the group delay of the - medium at the grid's reference frequency, ``beta1 L``. + Delay :math:`\tau_f` in s removed from the whole field; None removes the + group delay of the medium at the grid's reference frequency, + ``beta1(omega0) L``. :func:`solprop.coupling.transport` fills it from the + plane's frame when it is given one. + frame_phase : float + Constant phase :math:`\phi_f` in rad removed with the delay. Default 0. evanescent_tolerance : float or None Largest admitted reference-measure share of nonpropagating content; None is the rounding budget of the transforms that formed the field. sampling_tolerance : float - Share above which the wrap and edge diagnostics call the result inadequate. - Default 1e-6. + Largest admitted reference-measure share of content whose rays leave the + window (same-grid and angular routes), of field at the source edge + (angular output) and of aliased content (Fresnel output). Default 1e-6. + beyond_window : {'refuse', 'remove'} + What to do with content whose rays leave the window over the distance: + refuse it (the default), or remove it explicitly as an angular band, + reported as ``removed`` with ``window_removed`` in the diagnostics. + + Examples + -------- + >>> from solprop.coupling import FreeSpace + >>> from solprop.spatial import SpatialMedium + >>> FreeSpace(0.5, SpatialMedium(), beyond_window="remove").beyond_window + 'remove' """ distance: float @@ -2476,8 +2513,10 @@ class FreeSpace: output_method: Literal["angular", "fresnel"] = "angular" band_limit: float | None = None frame_delay: float | None = None + frame_phase: float = 0.0 evanescent_tolerance: float | None = None sampling_tolerance: float = 1e-6 + beyond_window: Literal["refuse", "remove"] = "refuse" def _validate(self, state: SpatialState) -> None: """Check the element against the plane. @@ -2489,33 +2528,41 @@ def _validate(self, state: SpatialState) -> None: Raises ------ - GridError - If the distance, method, band limit or output plane is invalid. - MaterialError - If the gap's medium is not the plane's. + CouplingError + If the distance, method, band limit or output plane is invalid, or + the gap's medium is not the plane's. """ - if not math.isfinite(self.distance): - msg = f"distance must be finite, got {self.distance!r}" - raise GridError(msg) + if not (math.isfinite(self.distance) and math.isfinite(self.frame_phase)): + msg = ( + f"distance and frame_phase must be finite, got {self.distance!r} and " + f"{self.frame_phase!r}" + ) + raise CouplingError(msg) + if self.frame_delay is not None and not math.isfinite(self.frame_delay): + msg = f"frame_delay must be finite, got {self.frame_delay!r}" + raise CouplingError(msg) + if self.beyond_window not in ("refuse", "remove"): + msg = f"beyond_window is 'refuse' or 'remove', got {self.beyond_window!r}" + raise CouplingError(msg) if self.output_method not in ("angular", "fresnel"): msg = f"output_method is 'angular' or 'fresnel', got {self.output_method!r}" - raise GridError(msg) + raise CouplingError(msg) if self.band_limit is not None and not 0 < self.band_limit <= math.pi / 2: msg = f"band_limit is an angle in (0, pi/2], got {self.band_limit!r}" - raise GridError(msg) + raise CouplingError(msg) if not _same_medium(state.medium, self.medium, state.grid): msg = ( "the gap's medium is not the plane's: a change of medium is a " "PlanarInterface, applied once" ) - raise MaterialError(msg) + raise CouplingError(msg) if self.output is not None and ( isinstance(self.output, LineGrid) or isinstance(self.output, RadialGrid) != isinstance(state.space, RadialGrid) ): msg = "the output plane must be the same kind of grid as the source" - raise GridError(msg) + raise CouplingError(msg) def _delay(self, state: SpatialState) -> float: """Return the delay removed from the whole field. @@ -2549,16 +2596,15 @@ def apply(self, state: SpatialState) -> ElementResult: ------- ElementResult The output plane and the report, whose diagnostics include - ``delay_removed`` in s. + ``delay_removed`` in s and ``phase_removed`` in rad. Raises ------ - GridError - If the element is invalid, the field holds significant evanescent - content, the angular output leaves the source window, or the Fresnel - kernel is undersampled. - MaterialError - If the medium is not the plane's. + CouplingError + If the element is invalid, the medium is not the plane's, the field + holds significant evanescent content, its rays wrap beyond the window, + the angular output leaves the source window, or the Fresnel kernel is + undersampled. """ self._validate(state) delay = self._delay(state) @@ -2567,7 +2613,10 @@ def apply(self, state: SpatialState) -> ElementResult: kept, removed = self._limited(state, flux, measured_in) if self.output is not None and self.output_method == "fresnel": return self._fresnel(state, kept, measured_in, removed, delay) - propagated, absorbed, diagnostics = self._propagate(kept, flux, delay) + propagated, absorbed, window_removed, diagnostics = self._propagate( + kept, flux, delay + ) + removed += window_removed notes: tuple[str, ...] = () result = propagated if self.output is not None: @@ -2576,16 +2625,17 @@ def apply(self, state: SpatialState) -> ElementResult: msg = ( f"the propagated field puts {edge:.3e} of itself at the source " "window's edge, and the angular output treats the source as zero " - "beyond it: enlarge the source window or use " - "output_method='fresnel'" + "beyond it: enlarge the source window, or evaluate this plane " + 'with output_method="fresnel", which reaches any window' ) - raise GridError(msg) + raise CouplingError(msg) result = propagated.with_spectrum( _angular_output(propagated, self.output), space=self.output ) diagnostics["output_edge_share"] = _edge_share(result) notes = ("output plane by direct angular-spectrum summation",) diagnostics["delay_removed"] = delay + diagnostics["phase_removed"] = self.frame_phase measured_out = _measure(result, _flux(result)) report = _report( f"FreeSpace(L={self.distance:g} m)", @@ -2593,7 +2643,10 @@ def apply(self, state: SpatialState) -> ElementResult: measured_out, specified_loss=absorbed, removed=removed, - numerical_error=diagnostics["wrap_share"] * measured_in.energy, + numerical_error=( + 0.0 if self.beyond_window == "remove" else diagnostics["wrap_share"] + ) + * measured_in.energy, extras=_Extras( reconstruction=self.distance < 0, diagnostics=diagnostics, notes=notes ), @@ -2621,7 +2674,7 @@ def _limited( Raises ------ - GridError + CouplingError If evanescent content exceeds its tolerance, or any remains for a backward propagation. """ @@ -2637,7 +2690,7 @@ def _limited( "nonpropagating columns, which no forward plane carries; remove " "them explicitly with band_limit= or widen the beam" ) - raise GridError(msg) + raise CouplingError(msg) return state, 0.0 index = np.real(_index(state.medium, state.grid)) reach = (index * state.grid.omega / C * math.sin(self.band_limit)) ** 2 @@ -2677,7 +2730,7 @@ def _wavenumber( def _propagate( self, state: SpatialState, flux: SpatialFlux, delay: float - ) -> tuple[SpatialState, float, dict[str, float]]: + ) -> tuple[SpatialState, float, float, dict[str, float]]: """Propagate the stored spectrum exactly on its own grid. Parameters @@ -2692,39 +2745,70 @@ def _propagate( Returns ------- tuple - The propagated plane, the energy absorbed by the medium in J, and the - wrap diagnostics. + The propagated plane, the energy absorbed by the medium in J, the + energy removed beyond the window in J, and the wrap diagnostics. + + Raises + ------ + CouplingError + If content whose rays leave the window exceeds ``sampling_tolerance`` + and its removal is not declared. """ grid = state.grid band = _band(grid) - spectrum = np.array(state.spectrum) - attenuated = np.array(state.spectrum) wrap = _WrapCount() + beyond = np.zeros((state.space.n_columns, grid.n_omega), dtype=bool) + for columns in flux.chunks(): + k_z = self._wavenumber(state, columns) + beyond[columns] = wrap.add(state, columns, k_z, self.distance) + share = wrap.share() + window_removed = 0.0 + source = state + if self.beyond_window == "remove": + source = state.with_spectrum(~beyond[:, None, :] * state.spectrum) + window_removed = spatial_energy(state.spectrum, flux) - spatial_energy( + source.spectrum, flux + ) + elif share > self.sampling_tolerance: + msg = ( + f"{share:.3e} of the field travels beyond the window over " + f"{self.distance:g} m (largest ray shift {wrap.max_ratio:.3g} " + "half-widths) and would wrap: the propagator's phase is " + "undersampled there. Enlarge the window" + + ( + ', evaluate this output plane with output_method="fresnel", ' + "which reaches any window," + if self.output is not None + else ", evaluate a larger output plane with output_method=" + '"fresnel",' + ) + + " or declare the angular band removal with beyond_window='remove'" + ) + raise CouplingError(msg) + spectrum = np.array(source.spectrum) + attenuated = np.array(source.spectrum) for columns in flux.chunks(): k_z = self._wavenumber(state, columns) - # Optics exp(+i k_z L - i omega tau) crosses as its conjugate. - exponent = -1j * np.conj(k_z) * self.distance + 1j * grid.omega * delay + # Optics exp(+i(k_z L - phi - omega tau)) crosses as its conjugate. + exponent = -1j * np.conj(k_z) * self.distance + 1j * ( + grid.omega * delay + self.frame_phase + ) factor = np.where(band[None, :], np.exp(exponent), 1.0) spectrum[columns] *= factor[:, None, :] attenuated[columns] *= np.abs(factor)[:, None, :] - wrap.add(state, columns, k_z, self.distance) - absorbed = spatial_energy(state.spectrum, flux) - spatial_energy( + absorbed = spatial_energy(source.spectrum, flux) - spatial_energy( attenuated, flux ) if np.all(np.imag(_index(state.medium, grid))[band] == 0): absorbed = 0.0 propagated = state.with_spectrum(spectrum) - edge = _edge_share(propagated) - share = wrap.share() diagnostics = { "wrap_share": share, "max_ray_shift_ratio": wrap.max_ratio, - "edge_share": edge, - "sampling_adequate": float( - share <= self.sampling_tolerance and edge <= self.sampling_tolerance - ), + "edge_share": _edge_share(propagated), + "window_removed": window_removed, } - return propagated, absorbed, diagnostics + return propagated, absorbed, window_removed, diagnostics def _fresnel( self, @@ -2756,7 +2840,7 @@ def _fresnel( Raises ------ - GridError + CouplingError If the distance is not positive or the kernel is undersampled on the source grid. """ @@ -2764,7 +2848,7 @@ def _fresnel( assert output is not None if self.distance <= 0: msg = "the Fresnel output needs a positive distance" - raise GridError(msg) + raise CouplingError(msg) grid = state.grid band = _band(grid) index = _index(state.medium, grid) @@ -2791,7 +2875,7 @@ def _fresnel( f"nodes, and {aliased:.3e} of the field would alias with it; refine " "the source grid or move the plane further" ) - raise GridError(msg) + raise CouplingError(msg) bound = k_max * (reach + out_reach) ** 4 / (8 * self.distance**3) field_out = np.zeros( (output.n_columns, kept.n_pol, grid.n_omega), dtype=np.complex128 @@ -2800,7 +2884,7 @@ def _fresnel( k = complex(index[bin_index]) * grid.omega[bin_index] / C field_out[:, :, bin_index] = _fresnel_bin( kept.space, output, samples[:, :, bin_index], k, self.distance - ) * np.exp(1j * grid.omega[bin_index] * delay) + ) * np.exp(1j * (grid.omega[bin_index] * delay + self.frame_phase)) result = kept.with_spectrum(_to_spectrum(output, field_out), space=output) measured_out = _measure(result, _flux(result)) absorbed = float( @@ -2822,6 +2906,7 @@ def _fresnel( "kernel_alias_share": aliased, "output_edge_share": _edge_share(result), "delay_removed": delay, + "phase_removed": self.frame_phase, }, notes=("paraxial Fresnel kernel from the source samples",), ), @@ -2844,8 +2929,8 @@ def add( columns: slice, k_z: Complex[np.ndarray, "n_chunk n_omega"], distance: float, - ) -> None: - """Add a chunk of columns. + ) -> Bool[np.ndarray, "n_chunk n_omega"]: + """Add a chunk of columns and return which of their bins leave the window. A column at angle theta moves its rays by ``L tan(theta) = L kappa / k_z``; beyond the window's half-width the periodic (Cartesian) or reflecting @@ -2861,6 +2946,12 @@ def add( Their longitudinal wave numbers, shape ``(n_chunk, n_omega)``. distance : float Propagation distance in m. + + Returns + ------- + numpy.ndarray + True where a propagating column's rays move beyond the window's + half-width, shape ``(n_chunk, n_omega)``. """ grid = state.grid space = state.space @@ -2892,6 +2983,7 @@ def add( self.wrapped += float(np.sum(content[ratio > 1])) if np.any(occupied): self.max_ratio = max(self.max_ratio, float(np.max(ratio[occupied]))) + return ratio > 1 def share(self) -> float: """Return the wrapped share. diff --git a/src/solprop/coupling/transport.py b/src/solprop/coupling/transport.py index aa4eb4fc0..8e3990d1d 100644 --- a/src/solprop/coupling/transport.py +++ b/src/solprop/coupling/transport.py @@ -2,31 +2,66 @@ # # SPDX-License-Identifier: GPL-3.0-or-later -"""Transport a spatial field through an ordered sequence of linear elements (SEP 31). - -:func:`transport` applies spatial and spectral elements in the order given and keeps -a sequential ledger in joules (SEP 31 §4.7). A frequency-only block -- an -:class:`solprop.optics.Chain` or any known :class:`solprop.optics.TransferFunction` --- may appear directly in the sequence; it is adapted by -:class:`solprop.coupling.elements.SpectralTransfer`, reported at its position, and -never moved past a spatial element. Losses add in joules and the per-frequency -energy spectra stay in every report; broadband efficiencies are never multiplied. +r"""Transport a field plane through an ordered sequence of linear elements (SEP 31). + +:func:`transport` takes a radial or Cartesian +:class:`~solprop.coupling.plane.FieldPlane`, applies spatial and spectral elements in +the order given and returns the plane at the last element's output with a sequential +ledger in joules (SEP 31 §4.7). A frequency-only block -- an +:class:`solprop.optics.Chain` or any known :class:`solprop.optics.TransferFunction` -- +may appear directly in the sequence; it is adapted by +:class:`solprop.coupling.elements.SpectralTransfer`, reported at its position, and never +moved past a spatial element. Losses add in joules and the per-frequency energy spectra +stay in every report; broadband efficiencies are never multiplied. + +The plane's record follows the field. The frame term every +:class:`~solprop.coupling.elements.FreeSpace` removes is added to the plane's +reference, so the absolute spectrum +(:meth:`~solprop.coupling.plane.FieldPlane.absolute`) stays exact over any distance; +a spectral element that drops its own constant phase or group delay leaves that term +of the reference uncalibrated; a mirror turns the plane's axes with its polarisation; +an interface changes its medium; and the power metric is resolved again with SEP 34's +:meth:`solprop.spatial.SpatialFlux.resolve` in the output medium and grid. + +The elements themselves act on the plain internal record +:class:`~solprop.coupling.elements.SpatialState`, the documented interface for +writing a new element; :func:`transport` is the one public way to run a sequence. """ -from collections.abc import Sequence -from dataclasses import dataclass, replace +import dataclasses +from collections.abc import Mapping, Sequence +from dataclasses import dataclass + +import numpy as np +from solprop.constants import C from solprop.coupling.elements import ( Element, ElementReport, + FreeSpace, + Mirror, SpatialState, SpectralTransfer, ) -from solprop.exceptions import GridError +from solprop.coupling.plane import ( + Calibration, + FieldPlane, + PlaneBasis, + PlaneGeometry, + PlaneMetric, + PlaneReference, +) +from solprop.exceptions import CouplingError +from solprop.fourier import spectrum_to_internal, spectrum_to_optics +from solprop.grid import TimeGrid +from solprop.linear import Frame from solprop.optics import TransferFunction -from solprop.spatial import SpatialFlux, spatial_energy +from solprop.spatial import FluxSide, SpatialFlux, SpatialMedium, spatial_energy + +__all__ = ["TransportReport", "transport"] -__all__ = ["TransportReport", "TransportResult", "transport"] +_AXIS_TOLERANCE = 1e-12 +"""Agreement required between a declared output axis and the transported one.""" @dataclass(frozen=True, eq=False) @@ -51,6 +86,14 @@ class TransportReport: Sum of the signed approximation errors, J. numerical_error : float Sum of the numerical error estimates, J; a conservative composition. + reference_in, reference_out : PlaneReference or None + The plane's reference before and after. + frame : str + Where each free-space frame term came from; empty without a gap. + source_certificate : collections.abc.Mapping or None + The source plane's scoped path certificate (SEP 34 §4.5), carried as its + own item: it bounds the producing run's linear background path, not this + transport. """ elements: tuple[ElementReport, ...] @@ -60,6 +103,10 @@ class TransportReport: removed: float approximation_error: float numerical_error: float + reference_in: PlaneReference | None = None + reference_out: PlaneReference | None = None + frame: str = "" + source_certificate: Mapping[str, object] | None = None @property def ledger_residual(self) -> float: @@ -75,22 +122,6 @@ def ledger_residual(self) -> float: return self.energy_out - (explained + self.approximation_error) -@dataclass(frozen=True, eq=False) -class TransportResult: - """The transported plane and its ledger. - - Attributes - ---------- - state : SpatialState - The field at the last element's output plane. - report : TransportReport - The sequential accounting. - """ - - state: SpatialState - report: TransportReport - - def _as_element(item: object, position: int) -> Element: """Return an element, adapting a frequency-only transfer function. @@ -108,7 +139,7 @@ def _as_element(item: object, position: int) -> Element: Raises ------ - GridError + CouplingError If the item is neither. """ if isinstance(item, Element): @@ -119,55 +150,137 @@ def _as_element(item: object, position: int) -> Element: f"item {position} of a transport sequence is neither an element nor a " f"transfer function: {type(item).__name__}" ) - raise GridError(msg) + raise CouplingError(msg) -def transport(state: SpatialState, elements: Sequence[object]) -> TransportResult: - """Apply an ordered sequence of linear elements to a field plane. +def _frame_term( + frame: Frame | None, medium: SpatialMedium, grid: TimeGrid, distance: float +) -> tuple[float, float, str]: + r"""Return the frame phase and delay a gap removes, and how they were chosen. + + With a frame, the gap removes :math:`RL` with :math:`R=\beta_0+\beta_1 + (\omega-\omega_o)` of the gap's medium at the frame's reference frequency (its + fixed values where it has them), the term a run's linear operator removes + (:class:`solprop.linear.Frame`): :math:`\beta_0` only on an envelope grid that + subtracts it, and :math:`\omega_o` the reference frequency on an envelope grid, + zero on a field grid. In the reference this is the phase + :math:`(\beta_0-\omega_o\beta_1)L` and the delay :math:`\beta_1L`, as in + :func:`solprop.coupling.plane.reference_at`. Without a frame only the delay + :math:`\beta_1(\omega_0)L` at the grid's reference frequency is removed. Parameters ---------- - state : solprop.coupling.elements.SpatialState - The field at the first plane. - elements : sequence - Elements of :mod:`solprop.coupling.elements`, applied in order. A - :class:`solprop.optics.Chain` or another known transfer function is one - frequency-only block at its position; an unknown callable needs an explicit - :class:`solprop.coupling.elements.SpectralTransfer` with its calibration. + frame : solprop.linear.Frame or None + The plane's frame, or None. + medium : solprop.spatial.SpatialMedium + The gap's homogeneous medium. + grid : solprop.grid.TimeGrid + The temporal grid. + distance : float + The gap's length, m. + + Returns + ------- + tuple + The phase in rad, the delay in s, and a description of the source. + """ + reference = grid.omega0 if frame is None else frame.omega0 + at = np.array([reference]) + index = float(np.real(medium.index(at))[0]) + slope = float(np.real(medium.dindex_domega(at))[0]) + beta1 = (index + reference * slope) / C + if frame is None: + return 0.0, beta1 * distance, "beta1(omega0) L of the medium (no frame given)" + beta1 = beta1 if frame.beta1 is None else frame.beta1 + envelope = grid.kind == "envelope" + beta0 = 0.0 + if envelope and frame.subtract_beta0: + beta0 = index * reference / C if frame.beta0 is None else frame.beta0 + origin = reference if envelope else 0.0 + return (beta0 - origin * beta1) * distance, beta1 * distance, "the plane's frame" + + +def _state(plane: FieldPlane) -> SpatialState: + """Return the internal spatial state of a radial or Cartesian plane. + + Parameters + ---------- + plane : FieldPlane + The plane. Returns ------- - TransportResult - The last plane and the sequential ledger. + SpatialState + Its internal-convention spectrum on its grids, medium and local frame. Raises ------ - GridError - If an item is neither an element nor a transfer function. + CouplingError + If the plane is modal or planar, or carries no medium. + """ + if plane.kind in ("modal", "general_modal"): + msg = ( + f"a {plane.kind} plane holds guide coefficients, not a field on a " + "transverse grid: synthesise the guide's exit field with Outcouple " + "before transporting it" + ) + raise CouplingError(msg) + space = plane.basis.space + if plane.kind not in ("radial", "cartesian") or space is None: + msg = f"transport takes a radial or Cartesian plane, got a {plane.kind} plane" + raise CouplingError(msg) + if plane.medium is None: + msg = ( + "transport needs the plane's medium: read a saved plane with " + "field_plane(..., source=simulation), or build it with its medium" + ) + raise CouplingError(msg) + geometry = plane.geometry + frame = np.array([geometry.x_axis, geometry.y_axis, geometry.normal]) + internal = spectrum_to_internal(plane.samples, kind=plane.grid.kind) + return SpatialState(plane.grid, space, internal, plane.medium, frame) - Examples - -------- - >>> import numpy as np - >>> from solprop.coupling.elements import FreeSpace, SpatialState, ThinLens - >>> from solprop.coupling.transport import transport - >>> from solprop.grid import EnvelopeGrid, RadialGrid - >>> from solprop.pulses import GaussianPulse, SpatialPulse - >>> from solprop.spatial import SpatialMedium - >>> grid = EnvelopeGrid.from_limits(0.1, 800e-9, (750e-9, 850e-9), 200e-15) - >>> space, air = RadialGrid(4e-3, 128), SpatialMedium() - >>> beam = SpatialPulse(GaussianPulse(wavelength=800e-9, fwhm=30e-15, - ... energy=1e-6), 1e-3) - >>> plane = SpatialState(grid, space, beam.build(grid, space, medium=air), air) - >>> result = transport(plane, [ThinLens(1.0, model_error=1e-3), - ... FreeSpace(0.5, air)]) - >>> f"{result.report.energy_out:.4e} J" - '1.0000e-06 J' + +def _sequence( + state: SpatialState, + elements: Sequence[object], + frame: Frame | None = None, + *, + track: bool = False, +) -> tuple[SpatialState, TransportReport]: + """Apply an ordered sequence to an internal state and keep the ledger. + + Parameters + ---------- + state : SpatialState + The field at the first plane. + elements : sequence + Elements or transfer functions, applied in order. + frame : solprop.linear.Frame or None, optional + The frame of each free-space gap without an explicit ``frame_delay``. + track : bool, optional + Whether the caller tracks a reference, so that a gap without an explicit + ``frame_delay`` takes its frame term from `frame`. Default False: the + element's own default applies. + + Returns + ------- + tuple + The last state and the sequential report. """ current = state reports: list[ElementReport] = [] + sources: list[str] = [] for position, item in enumerate(elements): - applied = _as_element(item, position).apply(current) - reports.append(replace(applied.report, position=position)) + element = _as_element(item, position) + if track and isinstance(element, FreeSpace) and element.frame_delay is None: + phase, delay, source = _frame_term( + frame, element.medium, state.grid, element.distance + ) + element = dataclasses.replace(element, frame_delay=delay, frame_phase=phase) + sources.append(f"item {position}: {source}") + applied = element.apply(current) + reports.append(dataclasses.replace(applied.report, position=position)) current = applied.state if reports: energy_in = reports[0].energy_in @@ -183,5 +296,221 @@ def transport(state: SpatialState, elements: Sequence[object]) -> TransportResul removed=sum(item.removed for item in reports), approximation_error=sum(item.approximation_error for item in reports), numerical_error=sum(item.numerical_error for item in reports), + frame="; ".join(sources), + ) + return current, report + + +def _reference( + reference: PlaneReference, reports: Sequence[ElementReport] +) -> PlaneReference: + """Return the plane's reference after the elements. + + Each free-space gap adds the phase and delay it removed. A spectral element + that drops its constant phase or its group delay, or does not say, leaves that + term uncalibrated: the physical element adds what its model omits. + + Parameters + ---------- + reference : PlaneReference + The input reference. + reports : sequence of ElementReport + The element reports, in order. + + Returns + ------- + PlaneReference + The output reference. + """ + phase, delay = reference.phase, reference.delay + known = set(reference.calibrated) + for report in reports: + phase += float(report.diagnostics.get("phase_removed", 0.0)) + delay += float(report.diagnostics.get("delay_removed", 0.0)) + for record in report.calibration: + if record.removes_phase is not False: + known.discard("phase") + if record.removes_delay is not False: + known.discard("delay") + calibrated: tuple[Calibration, ...] = tuple( + name for name in ("phase", "delay") if name in known + ) + return PlaneReference(phase, delay, calibrated) + + +def _geometry( + plane: FieldPlane, + state: SpatialState, + elements: Sequence[object], + declared: PlaneGeometry | None, +) -> PlaneGeometry: + """Return the output plane's geometry. + + Without a declaration the plane moves forward by every gap's distance along its + current normal and keeps its origin otherwise; a gap leaves it away from any + declared jump (no side); its axes are the state's frame, turned by any mirror. + + Parameters + ---------- + plane : FieldPlane + The input plane. + state : SpatialState + The output state, whose frame holds the output axes. + elements : sequence + The applied elements, in order. + declared : PlaneGeometry or None + The caller's output geometry, checked against the output axes. + + Returns + ------- + PlaneGeometry + The output geometry. + + Raises + ------ + CouplingError + If a declared geometry's normal or x axis is not the transported one. + """ + row_normal, row_x = state.frame[2], state.frame[0] + normal = (float(row_normal[0]), float(row_normal[1]), float(row_normal[2])) + x_axis = (float(row_x[0]), float(row_x[1]), float(row_x[2])) + if declared is not None: + if not ( + np.allclose(declared.normal, normal, rtol=0.0, atol=_AXIS_TOLERANCE) + and np.allclose(declared.x_axis, x_axis, rtol=0.0, atol=_AXIS_TOLERANCE) + ): + msg = ( + f"the declared output plane has normal {declared.normal} and x axis " + f"{declared.x_axis}, but the transported field arrives with normal " + f"{normal} and x axis {x_axis}" + ) + raise CouplingError(msg) + return declared + geometry = plane.geometry + z = geometry.z + origin = np.array(geometry.origin) + side: FluxSide | None = geometry.side + direction = np.array(geometry.normal) + for item in elements: + if isinstance(item, FreeSpace): + z += item.distance + origin = origin + item.distance * direction + side = None + elif isinstance(item, Mirror): + outgoing = np.asarray(item.outgoing, dtype=float) + direction = outgoing / np.linalg.norm(outgoing) + return PlaneGeometry( + z, + side, + (float(origin[0]), float(origin[1]), float(origin[2])), + normal, + x_axis, + ) + + +def transport( + plane: FieldPlane, + elements: Sequence[object], + *, + geometry: PlaneGeometry | None = None, + frame: Frame | None = None, +) -> tuple[FieldPlane, TransportReport]: + """Apply an ordered sequence of linear elements to a field plane. + + Parameters + ---------- + plane : solprop.coupling.FieldPlane + A radial or Cartesian plane with its medium. A modal plane is refused: + its guide field is synthesised first by ``Outcouple``. + elements : sequence + Elements of :mod:`solprop.coupling.elements`, applied in order. A + :class:`solprop.optics.Chain` or another known transfer function is one + frequency-only block at its position; an unknown callable needs an explicit + :class:`solprop.coupling.elements.SpectralTransfer` with its calibration. + geometry : solprop.coupling.PlaneGeometry or None, optional + The output plane, declared in the receiving system's coordinates; its axes + must be the transported ones. None moves the input plane along its path. + frame : solprop.linear.Frame or None, optional + The frame the plane is written in, such as the source simulation's + ``frame``: every :class:`~solprop.coupling.elements.FreeSpace` without + its own ``frame_delay`` then removes the affine term the run would have + removed over its distance. None removes only the delay + ``beta1(omega0) L`` of the gap's medium, as the report records. + + Returns + ------- + tuple + The plane at the last element's output -- reference, geometry, medium, + metric and provenance updated -- and the :class:`TransportReport`. + + Raises + ------ + CouplingError + If the plane cannot be transported, its exterior is unknown and a gap + propagates it, an item is neither an element nor a transfer function, an + element refuses, or a declared geometry disagrees with the transported + axes. + + Examples + -------- + >>> from solprop.coupling import (FieldPlane, FreeSpace, PlaneGeometry, + ... ThinLens, transport) + >>> from solprop.grid import EnvelopeGrid, RadialGrid + >>> from solprop.pulses import GaussianPulse, SpatialPulse + >>> from solprop.spatial import SpatialMedium + >>> grid = EnvelopeGrid.from_limits(0.1, 800e-9, (750e-9, 850e-9), 200e-15) + >>> air = SpatialMedium() + >>> beam = SpatialPulse(GaussianPulse(wavelength=800e-9, fwhm=30e-15, + ... energy=1e-6), 1e-3) + >>> plane = FieldPlane.from_pulse(beam, grid, PlaneGeometry(0.0), + ... space=RadialGrid(4e-3, 128), medium=air) + >>> focus, report = transport(plane, [ThinLens(1.0, model_error=1e-3), + ... FreeSpace(0.5, air)]) + >>> f"{report.energy_out:.4e} J at z = {focus.geometry.z} m" + '1.0000e-06 J at z = 0.5 m' + """ + state = _state(plane) + if plane.exterior == "unknown" and any( + isinstance(item, FreeSpace) for item in elements + ): + msg = ( + "this plane's field outside its samples is unknown, and propagation " + "depends on the whole field: declare the exterior zero or pad the record" + ) + raise CouplingError(msg) + final, report = _sequence(state, elements, frame, track=True) + reference = _reference(plane.reference, report.elements) + output = _geometry(plane, final, elements, geometry) + flux = SpatialFlux.resolve( + final.grid, final.space, final.medium, z=output.z, side=output.side + ) + basis = PlaneBasis.of_space(final.space) + provenance = plane.provenance + for item in report.elements: + step = f"transport[{item.position}]: {item.element}" + if item.reconstruction: + step += " (reconstruction)" + provenance = provenance.then(step) + if report.frame: + provenance = provenance.then(f"transport frame: {report.frame}") + result = FieldPlane( + basis.kind, + spectrum_to_optics(final.spectrum, kind=final.grid.kind), + final.grid, + "V m" if basis.kind == "radial" else "V/m", + basis, + reference, + output, + PlaneMetric.from_flux(flux), + plane.exterior, + final.medium, + plane.certificate, + provenance=provenance, + ) + report = dataclasses.replace( + report, + reference_in=plane.reference, + reference_out=reference, + source_certificate=plane.certificate, ) - return TransportResult(current, report) + return result, report diff --git a/tests/coupling/conftest.py b/tests/coupling/conftest.py index c89f5b7ea..496b1a4bf 100644 --- a/tests/coupling/conftest.py +++ b/tests/coupling/conftest.py @@ -8,12 +8,14 @@ leaving a band of several dozen frequencies for the per-bin ledger. """ -from collections.abc import Callable +from collections.abc import Callable, Sequence +from dataclasses import dataclass import numpy as np import pytest from solprop.coupling.elements import SpatialState +from solprop.coupling.transport import TransportReport, _sequence from solprop.grid import EnvelopeGrid, FreeGrid, RadialGrid from solprop.pulses import GaussianPulse, SpatialPulse from solprop.spatial import SpatialMedium @@ -24,6 +26,20 @@ BeamFactory = Callable[..., SpatialState] +@dataclass(frozen=True) +class Applied: + """The state after a sequence and its ledger, for the element-level tests.""" + + state: SpatialState + report: TransportReport + + +def apply_sequence(state: SpatialState, elements: Sequence[object]) -> Applied: + """Run a sequence on a plain state: the core that transport() wraps.""" + final, report = _sequence(state, elements) + return Applied(final, report) + + @pytest.fixture(scope="module") def grid() -> EnvelopeGrid: """Return a narrow envelope grid around 800 nm.""" diff --git a/tests/coupling/test_elements.py b/tests/coupling/test_elements.py index f8d91a275..3ebd4f61b 100644 --- a/tests/coupling/test_elements.py +++ b/tests/coupling/test_elements.py @@ -9,6 +9,7 @@ import numpy as np import pytest +from tests.coupling.conftest import apply_sequence from solprop.constants import C from solprop.coupling.elements import ( @@ -24,14 +25,7 @@ ThinLens, TransmissionMask, ) -from solprop.coupling.transport import transport -from solprop.exceptions import ( - ConvergenceError, - GridError, - MaterialError, - OpticsGeometryError, - PolarisationError, -) +from solprop.exceptions import CouplingError from solprop.grid import EnvelopeGrid, FreeGrid, LineGrid, RadialGrid from solprop.optics import ( Chain, @@ -163,28 +157,28 @@ def test_mirror_images_a_displaced_cartesian_beam(beam: BeamFactory, centre: int def test_mirror_and_jones_refusals(beam: BeamFactory): space = RadialGrid(1e-3, 32) state = beam(space, 0.3e-3) - with pytest.raises(OpticsGeometryError, match="perpendicular"): + with pytest.raises(CouplingError, match="perpendicular"): Mirror(**CONDUCTOR, incident=(0, 0, 1), outgoing=(0, 0, -1), s_axis=(0, 1, 1)) - with pytest.raises(OpticsGeometryError, match="three-vector"): + with pytest.raises(CouplingError, match="three-vector"): Mirror(**CONDUCTOR, incident=(0, 0, 0), outgoing=(0, 0, -1), s_axis=(0, 1, 0)) - with pytest.raises(OpticsGeometryError, match="incident direction"): + with pytest.raises(CouplingError, match="incident direction"): Mirror( **CONDUCTOR, incident=(1, 0, 0), outgoing=(0, 0, 1), s_axis=(0, 1, 0) ).apply(state) - with pytest.raises(OpticsGeometryError, match="changes the propagation"): + with pytest.raises(CouplingError, match="changes the propagation"): Mirror( **CONDUCTOR, incident=(0, 0, 1), outgoing=(0, 0, 1), s_axis=(0, 1, 0) ).apply(state) - with pytest.raises(PolarisationError, match="singular values"): + with pytest.raises(CouplingError, match="singular values"): Mirror( 1.2, 1.0, incident=(0, 0, 1), outgoing=(0, 0, -1), s_axis=(0, 1, 0) ).apply(state) - with pytest.raises(PolarisationError, match="singular values"): + with pytest.raises(CouplingError, match="singular values"): PolarisationTransform(np.array([[1.0, 1.0], [0.0, 1.0]])).apply(state) - with pytest.raises(PolarisationError, match="2x2"): + with pytest.raises(CouplingError, match="2x2"): PolarisationTransform(np.eye(3)).apply(state) cartesian = FreeGrid(1e-3, 16, 1e-3, 16) - with pytest.raises(GridError, match="oblique"): + with pytest.raises(CouplingError, match="oblique"): Mirror( **CONDUCTOR, incident=(0, 0, 1), @@ -192,7 +186,7 @@ def test_mirror_and_jones_refusals(beam: BeamFactory): s_axis=(1, 1, 0), ).apply(beam(cartesian, 0.3e-3)) odd = FreeGrid(1e-3, 15, 1e-3, 16) - with pytest.raises(GridError, match="odd sample count"): + with pytest.raises(CouplingError, match="odd sample count"): Mirror( **CONDUCTOR, incident=(0, 0, 1), outgoing=(0, 0, -1), s_axis=(0, 1, 0) ).apply(beam(odd, 0.3e-3)) @@ -244,12 +238,12 @@ def test_fresnel_interface_conserves_energy_and_tracks_the_medium( ) assert abs(report.approximation_error) <= 1e-7 * report.energy_in assert result.state.medium is glass - with pytest.raises(MaterialError, match="applied once"): + with pytest.raises(CouplingError, match="applied once"): interface.apply(result.state) - with pytest.raises(MaterialError, match="PlanarInterface"): + with pytest.raises(CouplingError, match="PlanarInterface"): FreeSpace(0.1, air).apply(result.state) FreeSpace(0.1, glass).apply(result.state) - with pytest.raises(MaterialError, match="lossless"): + with pytest.raises(CouplingError, match="lossless"): PlanarInterface(air, SpatialMedium(1.5 + 0.01j)).apply(state) assert grid.n_omega > 0 @@ -310,14 +304,14 @@ def test_spectral_transfer_refusals_and_records(beam: BeamFactory, grid: Envelop space = RadialGrid(1e-3, 32) state = beam(space, 0.3e-3) gdd = TaylorPhase((0.0, 0.0, 500e-30), 800e-9) - with pytest.raises(MaterialError, match="explicit efficiency"): + with pytest.raises(CouplingError, match="explicit efficiency"): SpectralTransfer(gdd, predict_loss=True).apply(state) - with pytest.raises(MaterialError, match="twice"): + with pytest.raises(CouplingError, match="twice"): SpectralTransfer(Material("SiO2", 1e-3, loss=True), efficiency=0.9).apply(state) - with pytest.raises(MaterialError, match=r"\[0, 1\]"): + with pytest.raises(CouplingError, match=r"\[0, 1\]"): SpectralTransfer(gdd, efficiency=1.2).apply(state) - with pytest.raises(MaterialError, match="calibration"): - transport(state, [lambda omega: np.ones_like(omega, dtype=complex)]) + with pytest.raises(CouplingError, match="calibration"): + apply_sequence(state, [lambda omega: np.ones_like(omega, dtype=complex)]) record = SpectralCalibration(True, True, 800e-9, "measured compressor") custom = SpectralTransfer( lambda omega: np.exp(1j * 1e-30 * (omega - 2.35e15) ** 2), calibration=record @@ -375,23 +369,23 @@ def test_thin_screens_stay_inside_their_certificates(beam: BeamFactory): def test_thin_screen_refusals(beam: BeamFactory, grid: EnvelopeGrid): space = RadialGrid(4e-3, 256) state = beam(space, 1e-3) - with pytest.raises(ConvergenceError, match="model_error"): + with pytest.raises(CouplingError, match="model_error"): ThinLens(0.5, model_error=1e-7).apply(state) - with pytest.raises(ConvergenceError, match=r"\(0, 1\)"): + with pytest.raises(CouplingError, match=r"\(0, 1\)"): ThinLens(0.5, model_error=0.0).apply(state) - with pytest.raises(GridError, match="Nyquist"): + with pytest.raises(CouplingError, match="Nyquist"): ThinLens(5e-3, model_error=0.5).apply(state) - with pytest.raises(MaterialError, match="nonzero focal"): + with pytest.raises(CouplingError, match="nonzero focal"): ThinLens(0.0, model_error=1e-3).apply(state) - with pytest.raises(MaterialError, match="passive"): + with pytest.raises(CouplingError, match="passive"): TransmissionMask(np.full(space.n_r, 1.1), model_error=1e-3).apply(state) - with pytest.raises(MaterialError, match="unit modulus"): + with pytest.raises(CouplingError, match="unit modulus"): TransmissionMask( np.full(space.n_r, 0.5), model_error=1e-3, phase_only=True ).apply(state) - with pytest.raises(MaterialError, match="n_nodes"): + with pytest.raises(CouplingError, match="n_nodes"): TransmissionMask(np.ones(3), model_error=1e-3).apply(state) - with pytest.raises(MaterialError, match="broadcast"): + with pytest.raises(CouplingError, match="broadcast"): TransmissionMask(lambda x, y, omega: np.ones(3), model_error=1e-3).apply( state.with_spectrum(state.spectrum, space=space) ) @@ -400,7 +394,7 @@ def test_thin_screen_refusals(beam: BeamFactory, grid: EnvelopeGrid): def test_radial_grids_refuse_symmetry_breaking(beam: BeamFactory): space = RadialGrid(4e-3, 64) state = beam(space, 1e-3) - with pytest.raises(GridError, match="azimuth"): + with pytest.raises(CouplingError, match="azimuth"): TransmissionMask( lambda x, y, omega: np.exp(-((x / 2e-3) ** 2))[:, None] + 0 * omega, model_error=1e-2, @@ -410,11 +404,11 @@ def test_radial_grids_refuse_symmetry_breaking(beam: BeamFactory): model_error=1e-2, ).apply(state) assert symmetric.report.specified_loss > 0 - with pytest.raises(GridError, match="off-axis"): + with pytest.raises(CouplingError, match="off-axis"): Aperture(1e-3, model_error=1e-2, centre=(1e-4, 0.0)).apply(state) - with pytest.raises(GridError, match="cylindrical symmetry"): + with pytest.raises(CouplingError, match="cylindrical symmetry"): Alignment(displacement=(1e-4, 0.0)).apply(state) - with pytest.raises(GridError, match="cylindrical symmetry"): + with pytest.raises(CouplingError, match="cylindrical symmetry"): Alignment(tilt=(1e-3, 0.0), model_error=1e-3).apply(state) assert Alignment().apply(state).report.element == "Alignment(none)" @@ -432,33 +426,33 @@ def test_cartesian_masks_and_alignment_refusals(beam: BeamFactory): phase_only=True, ).apply(state) assert phase.report.specified_loss == 0.0 - with pytest.raises(ConvergenceError, match="tilt"): + with pytest.raises(CouplingError, match="tilt"): Alignment(tilt=(1e-3, 0.0)).apply(state) - with pytest.raises(GridError, match="finite"): + with pytest.raises(CouplingError, match="finite"): Alignment(tilt=(2.0, 0.0), model_error=1e-3).apply(state) def test_state_validation(grid: EnvelopeGrid, air: SpatialMedium, beam: BeamFactory): space = RadialGrid(1e-3, 16) zero = np.zeros((16, 1, grid.n_omega), complex) - with pytest.raises(GridError, match="line grid"): + with pytest.raises(CouplingError, match="line grid"): SpatialState(grid, LineGrid(1e-3, 16), zero, air) - with pytest.raises(GridError, match="shape"): + with pytest.raises(CouplingError, match="shape"): SpatialState(grid, space, np.zeros((15, 1, grid.n_omega), complex), air) outside = zero.copy() outside[0, 0, np.flatnonzero(~grid.active_mask)[0]] = 1.0 - with pytest.raises(GridError, match="outside the active band"): + with pytest.raises(CouplingError, match="outside the active band"): SpatialState(grid, space, outside, air) taper = SpatialMedium("Ar", Linear(1.0, 2.0, 0.1)) - with pytest.raises(MaterialError, match="homogeneous"): + with pytest.raises(CouplingError, match="homogeneous"): SpatialState(grid, space, zero, taper) - with pytest.raises(GridError, match="right-handed"): + with pytest.raises(CouplingError, match="right-handed"): SpatialState(grid, space, zero, air, np.diag([1.0, 1.0, -1.0])) state = beam(space, 0.3e-3) assert not state.spectrum.flags.writeable assert not state.frame.flags.writeable - with pytest.raises(GridError, match="neither"): - transport(state, [3]) + with pytest.raises(CouplingError, match="neither"): + apply_sequence(state, [3]) def test_nonfinite_and_out_of_range_inputs_are_refused( @@ -466,16 +460,16 @@ def test_nonfinite_and_out_of_range_inputs_are_refused( ): space = RadialGrid(1e-3, 32) state = beam(space, 0.3e-3) - with pytest.raises(MaterialError, match="finite on every band"): + with pytest.raises(CouplingError, match="finite on every band"): ThinLens(lambda omega: np.full(omega.shape, np.nan), model_error=1e-3).apply( state ) - with pytest.raises(MaterialError, match="transmission must be finite"): + with pytest.raises(CouplingError, match="transmission must be finite"): TransmissionMask(np.full(space.n_r, np.nan), model_error=1e-3).apply(state) - with pytest.raises(PolarisationError, match="finite"): + with pytest.raises(CouplingError, match="finite"): PolarisationTransform(np.full((2, 2), np.inf)).apply(state) record = SpectralCalibration(None, None, None) - with pytest.raises(MaterialError, match=r"\|H\| <= 1"): + with pytest.raises(CouplingError, match=r"\|H\| <= 1"): SpectralTransfer( lambda omega: np.full(omega.shape, 2.0 + 0j), calibration=(record,) ).apply(state) @@ -484,15 +478,23 @@ def test_nonfinite_and_out_of_range_inputs_are_refused( assert declared.report.energy_out == pytest.approx( declared.report.energy_in, rel=1e-14 ) - with pytest.raises(MaterialError, match="known optics element"): + with pytest.raises(CouplingError, match="known optics element"): SpectralTransfer(Chain((lambda omega: np.ones(omega.shape, complex),))).apply( state ) - with pytest.raises(GridError, match="finite"): + with pytest.raises(CouplingError, match="finite"): FreeSpace(math.inf, air).apply(state) unknown = FreeSpace(0.1, air) object.__setattr__(unknown, "output_method", "spherical") - with pytest.raises(GridError, match="output_method"): + with pytest.raises(CouplingError, match="output_method"): + unknown.apply(state) + with pytest.raises(CouplingError, match="frame_phase must be finite"): + FreeSpace(0.1, air, frame_phase=math.nan).apply(state) + with pytest.raises(CouplingError, match="frame_delay must be finite"): + FreeSpace(0.1, air, frame_delay=math.inf).apply(state) + unknown = FreeSpace(0.1, air) + object.__setattr__(unknown, "beyond_window", "clip") + with pytest.raises(CouplingError, match="beyond_window"): unknown.apply(state) samples = np.ones((space.n_r, int(np.sum(grid.active_mask & (grid.omega > 0))))) sampled = TransmissionMask(0.5 * samples, model_error=1e-3).apply(state) @@ -511,5 +513,5 @@ def test_a_field_with_no_forward_flux_cannot_be_certified( beyond = space.k_r > 1.1 * np.max(grid.omega[band]) / C spectrum[beyond, 0, :] = band state = SpatialState(grid, space, spectrum, air) - with pytest.raises(GridError, match="no forward flux"): + with pytest.raises(CouplingError, match="no forward flux"): ThinLens(1.0, model_error=1e-3).apply(state) diff --git a/tests/coupling/test_transport.py b/tests/coupling/test_transport.py index 5750f4c73..2595cb34b 100644 --- a/tests/coupling/test_transport.py +++ b/tests/coupling/test_transport.py @@ -12,6 +12,7 @@ import pytest from scipy.integrate import quad from scipy.special import j0, j1 +from tests.coupling.conftest import apply_sequence from solprop import fourier from solprop.constants import C @@ -22,8 +23,7 @@ SpatialState, ThinLens, ) -from solprop.coupling.transport import transport -from solprop.exceptions import GridError, MaterialError +from solprop.exceptions import CouplingError from solprop.grid import EnvelopeGrid, FieldGrid, FreeGrid, RadialGrid from solprop.optics import Chain, TaylorPhase from solprop.pulses import GaussianPulse, SpatialPulse @@ -99,7 +99,7 @@ def test_gaussian_waist_curvature_and_gouy_phase( assert abs(result.report.approximation_error) <= 64 * np.finfo(float).eps * ( result.report.energy_in ) - assert result.report.diagnostics["sampling_adequate"] == 1.0 + assert result.report.diagnostics["wrap_share"] <= 1e-6 def test_direct_fresnel_matches_the_angular_spectrum_and_quadrature( @@ -129,7 +129,9 @@ def test_direct_fresnel_matches_the_angular_spectrum_and_quadrature( radius, edge = 100e-6, 5e-6 source = RadialGrid(0.5e-3, 512) flat = beam(source, 0.3e-3, order=10) - clipped = Aperture(radius, model_error=0.05, edge=edge, kind="rcos").apply(flat) + clipped = Aperture( + radius, model_error=0.05, edge=edge, kind="rcos", weighting="quadrature" + ).apply(flat) distance = radius**2 * k / (2 * math.pi) observed = RadialGrid(0.25e-3, 64) # The 5 um edge leaves 2e-6 of the field near the source grid's Nyquist rate, @@ -217,7 +219,7 @@ def test_aperture_far_field_is_the_airy_pattern( source = RadialGrid(0.5e-3, 512) flat = beam(source, 0.3e-3, order=10) output = RadialGrid(8e-3, 256) - result = transport( + result = apply_sequence( flat, [ Aperture(radius, model_error=0.05, edge=2e-6, kind="rcos"), @@ -254,7 +256,7 @@ def test_thin_lens_focus_matches_the_fourier_plane_waist( state = beam(space, waist) k = _wavenumber(grid, centre) expected = 2 * focal / (k * waist) # lambda f / (pi w0) - focused = transport( + focused = apply_sequence( state, [ThinLens(focal, model_error=1e-3), FreeSpace(focal, air)] ) spot = _second_moment_radius(space, _radial_field(space, focused.state, centre)) @@ -262,7 +264,7 @@ def test_thin_lens_focus_matches_the_fourier_plane_waist( # is the angular spectrum's O(theta^2) = 2.5e-5 correction. assert spot == pytest.approx(expected, rel=5e-5) zoom = RadialGrid(0.3e-3, 128) - zoomed = transport( + zoomed = apply_sequence( state, [ThinLens(focal, model_error=1e-3), FreeSpace(focal, air, output=zoom)] ) zoom_spot = _second_moment_radius(zoom, _radial_field(zoom, zoomed.state, centre)) @@ -282,8 +284,8 @@ def test_radial_and_cartesian_agree_for_a_symmetric_beam( radial = RadialGrid(1.5e-3, 128) cartesian = FreeGrid(1.5e-3, n_samples, 1.5e-3, n_samples) elements = [ThinLens(0.3, model_error=1e-3), FreeSpace(0.1, air)] - first = transport(beam(radial, 0.3e-3), elements) - second = transport(beam(cartesian, 0.3e-3), elements) + first = apply_sequence(beam(radial, 0.3e-3), elements) + second = apply_sequence(beam(cartesian, 0.3e-3), elements) # Both representations are spectrally converged at these sizes (the 32-sample # grid still departs by 3e-5). assert _on_axis(second.state, centre) == pytest.approx( @@ -298,7 +300,7 @@ def test_cartesian_displacement_and_tilt_follow_the_synthesis_map( space = FreeGrid(1.5e-3, 64, 1.5e-3, 64) state = beam(space, 0.3e-3) shift, angle, distance = (0.1e-3, -0.05e-3), (2e-3, 0.0), 0.1 - result = transport( + result = apply_sequence( state, [ Alignment(displacement=shift, tilt=angle, model_error=1e-3), @@ -328,14 +330,16 @@ def test_element_order_matters_and_a_chain_commutes_only_internally( space = RadialGrid(1.5e-3, 128) state = beam(space, 0.3e-3) aperture = Aperture(0.25e-3, model_error=0.05, edge=20e-6, kind="rcos") - gap = FreeSpace(0.2, air) - first = transport(state, [aperture, gap]) - second = transport(state, [gap, aperture]) + # The aperture's diffraction reaches the window over 0.2 m; its removal is + # declared, which keeps both orders on the same small grid. + gap = FreeSpace(0.2, air, beyond_window="remove") + first = apply_sequence(state, [aperture, gap]) + second = apply_sequence(state, [gap, aperture]) assert not np.allclose(first.state.spectrum, second.state.spectrum, rtol=1e-3) gdd = TaylorPhase((0.0, 0.0, 200e-30), 800e-9) tod = TaylorPhase((0.0, 0.0, 0.0, 3000e-45), 800e-9) - forward = transport(state, [Chain((gdd, tod)), gap]) - backward = transport(state, [Chain((tod, gdd)), gap]) + forward = apply_sequence(state, [Chain((gdd, tod)), gap]) + backward = apply_sequence(state, [Chain((tod, gdd)), gap]) np.testing.assert_allclose( forward.state.spectrum, backward.state.spectrum, @@ -355,7 +359,7 @@ def test_sequential_ledger_adds_losses_in_joules(beam: BeamFactory, air: Spatial # certificate counts the field at the window's edge as domain error. space = RadialGrid(3e-3, 256) state = beam(space, 0.3e-3) - result = transport( + result = apply_sequence( state, [ Aperture(0.3e-3, model_error=0.05, edge=20e-6, kind="rcos"), @@ -446,7 +450,7 @@ def test_evanescent_content_is_refused_or_removed_explicitly( grid: EnvelopeGrid, air: SpatialMedium ): state = _narrow_state(grid, air) - with pytest.raises(GridError, match="nonpropagating"): + with pytest.raises(CouplingError, match="nonpropagating"): FreeSpace(1e-6, air).apply(state) limited = FreeSpace(1e-6, air, band_limit=0.5).apply(state) report = limited.report @@ -461,19 +465,25 @@ def test_output_plane_refusals(beam: BeamFactory, air: SpatialMedium): space = RadialGrid(0.5e-3, 64) state = beam(space, 0.1e-3) wide = RadialGrid(5e-3, 64) - # The beam spreads past the source window: the angular output cannot see it. - with pytest.raises(GridError, match="edge"): + # The beam spreads past the source window: the angular output cannot see it, + # and the refusal points to the Fresnel route. + with pytest.raises(CouplingError, match='output_method="fresnel"'): FreeSpace(1.0, air, output=wide).apply(state) # A short Fresnel distance undersamples the kernel on the source nodes. - with pytest.raises(GridError, match="Fresnel kernel"): + # A beam filling the window's outer band cannot be read off it by the angular + # sum even without any spreading; the refusal points to the Fresnel route. + filling = beam(space, 0.4e-3) + with pytest.raises(CouplingError, match=r'edge.*output_method="fresnel"'): + FreeSpace(1e-6, air, output=wide).apply(filling) + with pytest.raises(CouplingError, match="Fresnel kernel"): FreeSpace(1e-3, air, output=wide, output_method="fresnel").apply(state) - with pytest.raises(GridError, match="positive distance"): + with pytest.raises(CouplingError, match="positive distance"): FreeSpace(-1.0, air, output=wide, output_method="fresnel").apply(state) - with pytest.raises(GridError, match="same kind"): + with pytest.raises(CouplingError, match="same kind"): FreeSpace(0.1, air, output=FreeGrid(1e-3, 8, 1e-3, 8)).apply(state) - with pytest.raises(MaterialError, match="PlanarInterface"): + with pytest.raises(CouplingError, match="PlanarInterface"): FreeSpace(0.1, SpatialMedium(1.5)).apply(state) - with pytest.raises(GridError, match="band_limit"): + with pytest.raises(CouplingError, match="band_limit"): FreeSpace(0.1, air, band_limit=2.0).apply(state) @@ -482,15 +492,17 @@ def test_an_empty_sequence_and_a_zero_field_are_exact( ): space = RadialGrid(1e-3, 32) zero = SpatialState(grid, space, np.zeros((32, 1, grid.n_omega), complex), air) - identity = transport(zero, []) + identity = apply_sequence(zero, []) assert identity.report.energy_in == identity.report.energy_out == 0.0 - result = transport(zero, [ThinLens(0.5, model_error=1e-3), FreeSpace(0.1, air)]) + result = apply_sequence( + zero, [ThinLens(0.5, model_error=1e-3), FreeSpace(0.1, air)] + ) assert not np.any(result.state.spectrum) certificate = result.report.elements[0].certificate assert certificate is not None assert certificate.exact_zero - with pytest.raises(GridError, match="neither an element"): - transport(zero, [object()]) + with pytest.raises(CouplingError, match="neither an element"): + apply_sequence(zero, [object()]) def test_cartesian_output_planes_agree_with_radial( @@ -502,8 +514,8 @@ def test_cartesian_output_planes_agree_with_radial( cartesian = beam(FreeGrid(1.5e-3, 64, 1.5e-3, 64), 0.3e-3) zoom_r = RadialGrid(0.9e-3, 96) zoom_c = FreeGrid(0.9e-3, 48, 0.9e-3, 48) - angular_r = transport(radial, [lens, FreeSpace(0.1, air, output=zoom_r)]) - angular_c = transport(cartesian, [lens, FreeSpace(0.1, air, output=zoom_c)]) + angular_r = apply_sequence(radial, [lens, FreeSpace(0.1, air, output=zoom_r)]) + angular_c = apply_sequence(cartesian, [lens, FreeSpace(0.1, air, output=zoom_c)]) # Both are converged trigonometric/Fourier-Bessel interpolants of converged # propagations; the on-axis values agree to their transforms' accuracy. assert _on_axis(angular_c.state, centre) == pytest.approx( @@ -513,10 +525,10 @@ def test_cartesian_output_planes_agree_with_radial( far_c = FreeGrid(2.5e-3, 48, 2.5e-3, 48) # A finer Cartesian source keeps the wider kernel's chirp below its Nyquist rate. cartesian = beam(FreeGrid(1.5e-3, 96, 1.5e-3, 96), 0.3e-3) - fresnel_r = transport( + fresnel_r = apply_sequence( radial, [lens, FreeSpace(1.0, air, output=far_r, output_method="fresnel")] ) - fresnel_c = transport( + fresnel_c = apply_sequence( cartesian, [lens, FreeSpace(1.0, air, output=far_c, output_method="fresnel")] ) # The radial and Cartesian node rules integrate the same smooth integrand; @@ -529,7 +541,9 @@ def test_cartesian_output_planes_agree_with_radial( ) # The exact propagation on a window wide enough to hold the beam agrees with # both to the paraxial bound the report states. - exact = transport(beam(RadialGrid(3e-3, 256), 0.3e-3), [lens, FreeSpace(1.0, air)]) + exact = apply_sequence( + beam(RadialGrid(3e-3, 256), 0.3e-3), [lens, FreeSpace(1.0, air)] + ) bound = fresnel_r.report.elements[1].diagnostics["fresnel_phase_bound"] assert _on_axis(fresnel_r.state, centre) == pytest.approx( _on_axis(exact.state, centre), rel=max(bound, 1e-6) @@ -544,11 +558,18 @@ def test_windows_without_a_collar_use_their_outer_tenth( FreeGrid(0.6e-3, 32, 0.6e-3, 32, window_factor=0.0), ): state = beam(space, 0.15e-3) - report = FreeSpace(0.5, air).apply(state).report - # Half a metre spreads the 150 um beam well past this window. + # Half a metre spreads the 150 um beam well past this window: the wrap is + # refused unless its removal is declared, and then it is reported. + with pytest.raises(CouplingError, match="beyond_window='remove'"): + FreeSpace(0.5, air).apply(state) + report = FreeSpace(0.5, air, beyond_window="remove").apply(state).report assert report.diagnostics["edge_share"] > 1e-3 - assert report.diagnostics["sampling_adequate"] == 0.0 assert report.diagnostics["wrap_share"] > 0 + assert report.removed == report.diagnostics["window_removed"] > 0 + assert report.numerical_error == 0.0 + assert abs(report.approximation_error) <= 64 * np.finfo(float).eps * ( + report.energy_in + ) def test_an_absorbing_gap_reports_its_loss(beam: BeamFactory): diff --git a/tests/coupling/test_transport_plane.py b/tests/coupling/test_transport_plane.py new file mode 100644 index 000000000..9087571c1 --- /dev/null +++ b/tests/coupling/test_transport_plane.py @@ -0,0 +1,313 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Transport of a field plane: its record follows the field (SEP 31 §4.5-4.7).""" + +import numpy as np +import pytest + +from solprop.coupling import ( + Aperture, + FieldPlane, + FreeSpace, + Mirror, + PlanarInterface, + PlaneBasis, + PlaneGeometry, + PlaneMetric, + SpectralTransfer, + ThinLens, + basis_identity, + transport, +) +from solprop.driver import Simulation, SimulationSettings +from solprop.exceptions import CouplingError +from solprop.grid import EnvelopeGrid, FreeGrid, RadialGrid +from solprop.linear import Frame +from solprop.optics import GratingPair, TaylorPhase, littrow_angle +from solprop.processing import field_plane +from solprop.pulses import GaussianPulse, SpatialPulse +from solprop.spatial import SpatialMedium, aperture_profile +from solprop.stepper import PIController + +ENERGY = 1e-6 +CONDUCTOR = {"reflectivity_p": 1.0, "reflectivity_s": -1.0} + + +def _plane(grid, space, medium, waist=0.3e-3, jones=(1.0 + 0.0j,), geometry=None): + """Return a Gaussian beam plane built by the package's beam builder.""" + pulse = GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=ENERGY) + return FieldPlane.from_pulse( + SpatialPulse(pulse, waist, jones=jones), + grid, + PlaneGeometry(0.0) if geometry is None else geometry, + space=space, + medium=medium, + ) + + +@pytest.fixture(scope="module") +def radial_run(): + """A short linear radial run in argon, saved at 1 mm and 2 mm.""" + grid = EnvelopeGrid.from_limits(2e-3, 800e-9, (600e-9, 1.2e-6), 400e-15) + settings = SimulationSettings( + controller=PIController(rtol=1e-8, atol=1e-14), + save_planes=[1e-3, 2e-3], + dz_max=2e-4, + dz_init=2e-4, + backend="numpy_ref", + ) + pulse = SpatialPulse( + GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=3e-9), 2.5e-5 + ) + medium = SpatialMedium("Ar", 1.0) + simulation = Simulation.from_radial( + grid, RadialGrid(3e-4, 64), medium, [], pulse, settings=settings + ) + return simulation, simulation.run(progress="none") + + +def test_free_space_continues_a_radial_run(radial_run): + simulation, output = radial_run + start = field_plane(output, 1, source=simulation) + end = field_plane(output, 2, source=simulation) + # A 25 um waist diffracts measurably over the millimetre (z_R ~ 2.5 mm). + on_axis = np.sum(end.node_spectra()[0]) / np.sum(start.node_spectra()[0]) + assert abs(on_axis) < 0.95 + gap = FreeSpace(1e-3, simulation.medium) + # Both apply the exact k_z on the same QDHT columns and lattice, the run in five + # exponential steps: they agree to the rounding of those products, ~1e-13 of + # the field (measured 6e-13); 1e-11 leaves margin across platforms. + framed, report = transport(start, [gap], frame=simulation.frame) + assert framed.reference.calibrated == ("phase", "delay") + assert framed.reference.phase == pytest.approx(end.reference.phase, rel=1e-12) + assert framed.reference.delay == pytest.approx(end.reference.delay, rel=1e-12) + scale = np.linalg.norm(end.samples) + assert np.linalg.norm(framed.samples - end.samples) <= 1e-11 * scale + assert framed.geometry.z == pytest.approx(end.geometry.z, abs=1e-15) + assert framed.basis.identity == end.basis.identity + assert framed.energy() == pytest.approx(end.energy(), rel=1e-12) + assert report.frame == "item 0: the plane's frame" + # Without the frame only the group delay is removed; the stored spectrum + # differs by the carrier phase, which the reference keeps, so the absolute + # spectra agree. + plain, report = transport(start, [gap]) + assert "no frame given" in report.frame + assert plain.reference.phase == start.reference.phase + assert plain.reference.delay == pytest.approx(end.reference.delay, rel=1e-12) + difference = np.linalg.norm(plain.absolute() - end.absolute()) + assert difference <= 1e-11 * np.linalg.norm(end.absolute()) + assert plain.provenance.history[-2] == "transport[0]: FreeSpace(L=0.001 m)" + assert plain.provenance.source == start.provenance.source + + +def test_the_plane_record_follows_the_elements(): + grid = EnvelopeGrid.from_limits(0.1, 800e-9, (750e-9, 850e-9), 200e-15) + air = SpatialMedium() + plane = _plane(grid, RadialGrid(4e-3, 128), air) + pair = GratingPair(600e3, littrow_angle(600e3, 800e-9), 0.01, 800e-9) + gdd = TaylorPhase((0.0, 0.0, 100e-30), 800e-9) + focus, report = transport( + plane, + [ + ThinLens(1.0, model_error=1e-3), + FreeSpace(0.25, air, frame_delay=1e-12, frame_phase=0.5), + gdd, + FreeSpace(0.25, air), + ], + ) + # Each gap adds what it removed: its declared term, or beta1 L of vacuum. + assert focus.reference.delay == pytest.approx(1e-12 + 0.25 / 299792458.0) + assert focus.reference.phase == 0.5 + # A Taylor phase keeps its affine terms, so the reference stays calibrated. + assert focus.reference.calibrated == ("phase", "delay") + assert report.reference_in == plane.reference + assert report.reference_out == focus.reference + assert focus.geometry.z == 0.5 + assert focus.geometry.origin == (0.0, 0.0, 0.5) + assert focus.metric.flux is not None + assert focus.energy() == pytest.approx(report.energy_out, rel=1e-12) + assert abs(report.ledger_residual) <= 1e-14 * report.energy_in + assert [step.split(":")[0] for step in focus.provenance.history[-5:]] == [ + "transport[0]", + "transport[1]", + "transport[2]", + "transport[3]", + "transport frame", + ] + # A grating pair removes its phase and group delay: both become unknown. + compressed, _ = transport(focus, [SpectralTransfer(pair)]) + assert compressed.reference.calibrated == () + with pytest.raises(CouplingError, match="not known"): + compressed.absolute() + + +def test_a_mirror_turns_the_plane_axes_with_the_polarisation(): + grid = EnvelopeGrid.from_limits(0.1, 800e-9, (750e-9, 850e-9), 200e-15) + air = SpatialMedium() + space = FreeGrid(1.5e-3, 32, 1.5e-3, 32) + plane = _plane(grid, space, air, jones=(1.0, 0.5j)) + fold = Mirror(**CONDUCTOR, incident=(0, 0, 1), outgoing=(1, 0, 0), s_axis=(0, 1, 0)) + folded, report = transport(plane, [fold, FreeSpace(0.1, air)]) + assert folded.geometry.normal == pytest.approx((1.0, 0.0, 0.0), abs=1e-15) + # The outgoing frame keeps the p/s components of the incoming axes: x (along + # p_in = s x k_in) maps to p_out = s x k_out = -z. + assert folded.geometry.x_axis == pytest.approx((0.0, 0.0, -1.0), abs=1e-15) + assert np.cross(folded.geometry.x_axis, folded.geometry.y_axis) == pytest.approx( + folded.geometry.normal + ) + # The gap after the fold moves the plane along the new normal. + assert folded.geometry.origin == pytest.approx((0.1, 0.0, 0.0), abs=1e-15) + assert folded.samples.shape[1] == 2 + assert folded.energy() == pytest.approx(report.energy_out, rel=1e-12) + declared = PlaneGeometry( + 0.0, normal=folded.geometry.normal, x_axis=folded.geometry.x_axis + ) + received, _ = transport(plane, [fold, FreeSpace(0.1, air)], geometry=declared) + assert received.geometry is declared + with pytest.raises(CouplingError, match="arrives with normal"): + transport(plane, [fold], geometry=PlaneGeometry(0.0)) + + +def test_an_interface_changes_the_plane_medium_and_metric(): + grid = EnvelopeGrid.from_limits(0.1, 800e-9, (750e-9, 850e-9), 200e-15) + air, glass = SpatialMedium(), SpatialMedium(1.5) + plane = _plane(grid, RadialGrid(4e-3, 64), air) + inside, report = transport(plane, [PlanarInterface(air, glass)]) + assert inside.medium is glass + assert inside.energy() == pytest.approx(0.96 * ENERGY, rel=1e-7) + assert report.specified_loss == pytest.approx(0.04 * ENERGY, rel=1e-7) + + +@pytest.mark.parametrize("weighting", ["amplitude", "quadrature"]) +def test_aperture_weighting_matches_the_in_run_statistics(weighting): + grid = EnvelopeGrid.from_limits(0.1, 800e-9, (750e-9, 850e-9), 200e-15) + air = SpatialMedium() + space = RadialGrid(2e-3, 128) + plane = _plane(grid, space, air, waist=0.5e-3) + radius, edge = 0.4e-3, 0.1e-3 + aperture = Aperture( + radius, model_error=0.05, edge=edge, kind="tanh", weighting=weighting + ) + clipped, report = transport(plane, [aperture]) + profile = aperture_profile(np.asarray(space.r), radius, edge, "tanh") + factor = profile if weighting == "amplitude" else np.sqrt(profile) + before, after = plane.node_spectra(), clipped.node_spectra() + inside = space.r < radius + # The screen multiplies the node samples; the QDHT round trip that carries it + # back to columns is accurate to ~1e-9 of the field on this grid. + np.testing.assert_allclose( + after[inside], + factor[inside, None, None] * before[inside], + rtol=0, + atol=1e-8 * np.max(np.abs(before)), + ) + assert weighting in report.elements[0].element + + +def test_the_two_weightings_differ_on_a_soft_edge(): + grid = EnvelopeGrid.from_limits(0.1, 800e-9, (750e-9, 850e-9), 200e-15) + air = SpatialMedium() + plane = _plane(grid, RadialGrid(2e-3, 128), air, waist=0.5e-3) + energies = { + weighting: transport( + plane, + [ + Aperture( + 0.4e-3, + model_error=0.05, + edge=0.1e-3, + kind="tanh", + weighting=weighting, + ) + ], + )[0].energy() + for weighting in ("amplitude", "quadrature") + } + # W <= 1, so W^2 <= W: the amplitude reading transmits less. + assert energies["amplitude"] < 0.99 * energies["quadrature"] + # The literal is checked at the call in the test session; a value set past it + # is still refused by the element. + unknown = Aperture(0.4e-3, model_error=0.05) + object.__setattr__(unknown, "weighting", "field") + with pytest.raises(CouplingError, match="weighting"): + transport(plane, [unknown]) + + +def test_transport_refuses_what_it_cannot_carry(): + grid = EnvelopeGrid.from_limits(0.1, 800e-9, (750e-9, 850e-9), 200e-15) + air = SpatialMedium() + basis = PlaneBasis("modal", basis_identity("modal", {"modes": ["test"]})) + samples = np.zeros((1, 1, grid.n_omega), complex) + modal = FieldPlane.from_samples( + samples, grid, PlaneGeometry(0.0), exterior="zero", basis=basis + ) + with pytest.raises(CouplingError, match="Outcouple"): + transport(modal, [FreeSpace(0.1, air)]) + space = RadialGrid(4e-3, 64) + plane = _plane(grid, space, air) + bare = FieldPlane( + plane.kind, + plane.samples, + plane.grid, + plane.units, + plane.basis, + plane.reference, + plane.geometry, + plane.metric, + ) + with pytest.raises(CouplingError, match="source=simulation"): + transport(bare, []) + nodes = plane.node_spectra() + cropped = FieldPlane.from_samples( + nodes, grid, PlaneGeometry(0.0), exterior="unknown", space=space, medium=air + ) + with pytest.raises(CouplingError, match="unknown"): + transport(cropped, [FreeSpace(0.1, air)]) + # A local screen needs no exterior; the record is carried as it is. + lensed, _ = transport(cropped, [ThinLens(1.0, model_error=1e-3)]) + assert lensed.exterior == "unknown" + with pytest.raises(CouplingError, match="neither an element"): + transport(plane, [object()]) + + +def test_a_fixed_frame_sets_the_removed_affine_term(): + grid = EnvelopeGrid.from_limits(0.1, 800e-9, (750e-9, 850e-9), 200e-15) + air = SpatialMedium() + plane = _plane(grid, RadialGrid(4e-3, 64), air) + gap = FreeSpace(0.2, air) + fixed = Frame(grid.omega0, subtract_beta0=True, beta0=3.0, beta1=4e-9) + moved, _ = transport(plane, [gap], frame=fixed) + # The run's reference law: phase (beta0 - omega_o beta1) L, delay beta1 L. + assert moved.reference.delay == pytest.approx(4e-9 * 0.2, rel=1e-15) + assert moved.reference.phase == pytest.approx( + (3.0 - grid.omega0 * 4e-9) * 0.2, rel=1e-15 + ) + kept = Frame(grid.omega0, subtract_beta0=False) + moved, _ = transport(plane, [gap], frame=kept) + assert moved.reference.phase == pytest.approx( + -grid.omega0 * 0.2 / 299792458.0, rel=1e-12 + ) + back, _ = transport(moved, [FreeSpace(-0.2, air)], frame=kept) + assert back.provenance.history[-2].endswith("(reconstruction)") + assert back.reference.phase == pytest.approx(0.0, abs=1e-9) + assert back.reference.delay == pytest.approx(0.0, abs=1e-24) + + +def test_a_planar_plane_is_refused(): + grid = EnvelopeGrid.from_limits(0.1, 800e-9, (750e-9, 850e-9), 200e-15) + basis = PlaneBasis("planar", basis_identity("planar", {})) + reference = _plane(grid, RadialGrid(4e-3, 16), SpatialMedium()) + planar = FieldPlane( + "planar", + np.zeros((4, 1, grid.n_omega), complex), + grid, + "sqrt(W/m)", + basis, + reference.reference, + reference.geometry, + PlaneMetric("identity"), + ) + with pytest.raises(CouplingError, match="planar plane"): + transport(planar, []) From e7e62f24d06d62ffc1527388749823d2afcaeb49 Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 01:22:07 +0100 Subject: [PATCH 15/50] Docs: transport between planes, its API entries and the solid transport core The coupling chapter gains a self-contained transport section: the elements, their order, the plane record, free-space output methods, the thin-screen certificate, mirrors and Jones frames, the Fresnel interface, spectral optics with Chain, and a lens-to-focus example. The structure figure draws coupling/transport and coupling/elements solid. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019qNYqtCKh4iTKj7544yARb --- CHANGELOG.md | 11 + docs/api/index.md | 2 +- docs/dev/architecture.md | 2 +- docs/dev/figures/build.py | 3 +- docs/dev/figures/layers.svg | 338 +++++++++++++++---------------- docs/dev/structure.md | 11 +- docs/guide/workflows/coupling.md | 145 +++++++++++++ 7 files changed, 331 insertions(+), 181 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c14c3fce4..4862ab0cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,17 @@ The first public release. It contains: describes a simulation's entrance and `with_input` takes a launch bound to it, recording `configuration["coupling"]` and drawing no new noise. Output schema 24 records each run's plane-reference record (`RunMetadata.reference`). +- **Transport between planes** + ([SEP 31](https://solprop.readthedocs.io/en/latest/dev/seps/sep-0031-simulation-coupling.html), + phase 4). `coupling.transport` carries a radial or Cartesian `FieldPlane` through an + ordered sequence of linear elements -- `FreeSpace` (exact angular-spectrum + propagation, with an explicit `"angular"` or `"fresnel"` output plane), `ThinLens`, + `Aperture`, `TransmissionMask`, `Alignment`, `PolarisationTransform`, `Mirror`, + `PlanarInterface` and `SpectralTransfer` (any `optics` transfer function or `Chain`) + -- and returns the plane with its reference, geometry, medium, metric and provenance + updated and a sequential ledger in joules. Thin screens carry their angular-flux + certificate; undersampled phases, wrapped rays and evanescent content are refused + unless their removal is declared. Refusals raise `CouplingError`. - **Media and responses.** - Gases, gas mixtures, glasses and metals from the [optmat](https://github.com/LupoLab/optmat) material library. diff --git a/docs/api/index.md b/docs/api/index.md index ffc5c1c6a..e24202a98 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -47,7 +47,7 @@ In scripts and library code, import from the home modules listed below. |---|---| | {mod}`solprop.output` | the in-memory and HDF5 output handlers, the run's metadata record, `open_file` and resuming a run, live snapshots (`SnapshotController`, `read_snapshot`, `restore_snapshot`; [the output page](../guide/results/output.md)), and the raw TDSE response archive (`TDSEArchive`, `open_tdse_archive`; [the TDSE response](../guide/media/tdse.md#the-raw-response-archive)) | | {mod}`solprop.processing` | post-processing of a saved run: fields, spectra, pulse metrics, coherence, compression, the physical fields of a general modal run, and `field_plane`, a saved plane read back for coupling | -| {mod}`solprop.coupling` | coupling simulations through a field plane: `FieldPlane`, `regrid`, `InputPort` and the `same_basis` relaunch ([coupling simulations](../guide/workflows/coupling.md)) | +| {mod}`solprop.coupling` | coupling simulations through a field plane: `FieldPlane`, `regrid`, `InputPort` and the `same_basis` relaunch; `transport` through the linear elements `FreeSpace`, `ThinLens`, `Aperture`, `TransmissionMask`, `Alignment`, `PolarisationTransform`, `Mirror`, `PlanarInterface` and `SpectralTransfer` ([coupling simulations](../guide/workflows/coupling.md)) | | {mod}`solprop.stats` | the statistics recorded at the saved planes, and `StatsSpec` to choose them | | {mod}`solprop.plotting` | the standard figures: propagation maps, slices, spectrograms, statistics | | {mod}`solprop.optics` | linear optics applied to a pulse outside a propagation: materials, chirped mirrors, grating and prism pairs | diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md index 20c26bac3..8c0040c42 100644 --- a/docs/dev/architecture.md +++ b/docs/dev/architecture.md @@ -189,7 +189,7 @@ against `src/solprop` by `tests/test_structure_figures.py`. | `__init__.py`, `exceptions.py`, `constants.py` | Lazy public imports, package logging, exception hierarchy and SI constants | package / P | | `grid.py`, `fourier.py`, `hankel.py`, `spatial.py`, `adiabatic.py` | Temporal and spatial grids (radial, the Cartesian plane, and `LineGrid`, its x axis uniform in y per unit width), Fourier conventions, raw Hankel transforms, spatial media, the axial-flux measure of a plane (`SpatialFlux`, SEP 34) and the adiabatic path certificate of a z-varying medium | P | | `profiles.py`, `gasflow.py`, `pulses.py`, `noise.py`, `polarisation.py`, `optics.py`, `tools.py` | Longitudinal paths, steady gas flow in a hollow fibre (the taper- and temperature-aware, choked, regime-checked fill, its flow report and calculators), temporal/spatial inputs, noise, polarisation algebra, linear optical elements as transfer functions, and the soliton and phase-matching design calculators | P | -| `coupling/` | Coupling simulations through a coherent field at a reference plane (SEP 31): the field plane with its reference, geometry, metric, basis identity and provenance (`plane.py`), the input port of a receiving simulation and its bound launch with the same-basis model (`port.py`), and temporal remapping (`remap.py`); NumPy and other P modules only. The transport core (`transport.py`, `elements.py`) is planned | P | +| `coupling/` | Coupling simulations through a coherent field at a reference plane (SEP 31): the field plane with its reference, geometry, metric, basis identity and provenance (`plane.py`), the input port of a receiving simulation and its bound launch with the same-basis model (`port.py`), temporal remapping (`remap.py`), and the transport core: the linear elements between planes (`elements.py`) and the ordered transport of a plane with its ledger (`transport.py`); NumPy, SciPy and other P modules only | P | | `physdata/` | Species, mixtures, equations of state, transport properties (viscosity, sound speed, mean free path), optical material data, metals, nonlinear and Raman data | P | | `modes/` | Mode protocols and derived quantities; capillary, antiresonant, tabulated, step-index, rectangular and planar (slab, uniform in y, per unit width) modes, with `PlanarGrid` for the planar guide resolved in y; shared gas fill, quadrature and collections; full-vector modal records, material laws and regions, bound step-index and radial graded-index providers, and the guide that pairs a provider with a mode collection, and the connection of a basis that moves along z | P | | `linear.py`, `boundaries.py`, `nonlinear/` | Linear assembly, absorbing rates, response definitions, ionisation, Raman, normalisation and the GNLSE mapping | P | diff --git a/docs/dev/figures/build.py b/docs/dev/figures/build.py index 2bdfa80a0..136269a06 100644 --- a/docs/dev/figures/build.py +++ b/docs/dev/figures/build.py @@ -597,7 +597,7 @@ class Layer(NamedTuple): Module("polarisation.py"), Module("optics.py"), Module("tools.py"), - Module("coupling/", ("plane", "port", "remap")), + Module("coupling/", ("plane", "port", "remap", "transport", "elements")), Module( "physdata/", ( @@ -808,7 +808,6 @@ class Layer(NamedTuple): """Every module of the package, in its layer's band. All of these exist.""" PLANNED = { - "P": (Module("coupling/", ("transport", "elements")),), "X": (Module("processing/", ("windows",)),), } """Modules `architecture.md` section 4 names that are not written yet. diff --git a/docs/dev/figures/layers.svg b/docs/dev/figures/layers.svg index ef2f07987..a64b73c86 100644 --- a/docs/dev/figures/layers.svg +++ b/docs/dev/figures/layers.svg @@ -1,6 +1,6 @@ + width="1200" height="1456.5" viewBox="0 0 1200 1456.5"> - + The layers of solprop, and every module in each Bands and colours from architecture.md §3 and decisions.md ADR-001; modules from src/solprop. Arrows point from the layer that imports to the layer it imports. @@ -84,13 +84,13 @@ text { font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; fill: X unwritten candidate, not scheduled (architecture.md §4) - - -P -physics / convenience -may import -numpy, scipy, stdlib -other P modules + + +P +physics / convenience +may import +numpy, scipy, stdlib +other P modules constants.py @@ -115,169 +115,167 @@ text { font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; fill: linear.py boundaries.py - + polarisation.py - + optics.py - + tools.py - + coupling/ -plane · port · remap - -physdata/ -sellmeier · equation_of_state -gases · glasses · metals -nonlinear_data · raman_types -species · mixtures · transport -selection - -modes/ -protocol · base · capillary -antiresonant · tabulated · fill -stepindex · material · vector -guide · graded_index · connection -rectangular · slab · collection -quadrature - -nonlinear/ -responses · ionisation -optmat_rates · envelope_plasma -raman · gnlse · normalisation -mixtures · optmat_raman · tdse - -coupling/ -transport · elements - - -T -translation -may import -P, numpy, scipy - -translate/ -problem · stage · linop · expansion -chebyshev · modal · modal_tables -modal_path · coupled · general -radial · free · planar · spatial -flux · coupling · responses -ionisation · plasma_integration -plasma_source · envelope_plasma -raman · masks · tdse · tdse_source - - -B -backends -may import -numpy, numba -pyfftw; the native package in native/ - -backends/ -protocol · registry · driver -batch · bodies · spatial -populations · memory · multistep -exponential · low_storage - -backends/numpy_ref/ -backend · batch · passes -admission · envelope_plasma -general_modal · tdse - -backends/numba_cpu/ -backend · batch · kernels -envelope_plasma · general_modal -coupled_flow · field_plasma · fft -openmp · spatial · planar · tables -multistep · dispersive -dispersive_spatial -dispersive_general · low_storage -pass_timers - - -D -driver -may import -P, T -B through the seam, h5py - -log.py - -stats.py - -stepper/ -tableau · runge_kutta · multistep -low_storage · controller · schedule - -output/ -schema · capability · memory · hdf5 -tdse_archive · snapshots · scan -scan_artifacts - -scan.py - -driver/executors/ -local · pool · queue · slurm -batch - -driver/ -simulation · run · interface -scaffold · mixtures -material_output · tdse -tdse_consistency · scan · batch -memory - - -X -processing / plotting -may import -numpy, scipy -matplotlib, D (outputs) -T (table identities) - -processing/ -axes · fields · spectra · time -coherence · compression · mixtures -modal · planes · scans - -plotting/ -propagation · statistics -spectrograms · style - -sol.py - -processing/ -windows - - -T → P physics objects - - -B → T delta_phi, prefactor_at, the stage bundle - - -D → B backends.select, then the seam - - -X → D a saved run, through the Output protocol - - - -1 - - - -2 - - - -3 - - - -4 -1 Simulation calls translate.assemble -2 the driver builds the grid, the modes, the pulse and the responses -3 the statistics path: a backend is handed grid, modes and profiles at construction: the one import of P that architecture.md §3 allows B, types and constants, never a physics object -4 processing.modal takes the run's own ModalTransformTables beside the handler: a general modal file records what its reference is and not the arrays, so the reference is rebuilt in T and read in X -The package-wide modules carry no layer letter: __init__.py · exceptions.py +plane · port · remap · transport +elements + +physdata/ +sellmeier · equation_of_state +gases · glasses · metals +nonlinear_data · raman_types +species · mixtures · transport +selection + +modes/ +protocol · base · capillary +antiresonant · tabulated · fill +stepindex · material · vector +guide · graded_index · connection +rectangular · slab · collection +quadrature + +nonlinear/ +responses · ionisation +optmat_rates · envelope_plasma +raman · gnlse · normalisation +mixtures · optmat_raman · tdse + + +T +translation +may import +P, numpy, scipy + +translate/ +problem · stage · linop · expansion +chebyshev · modal · modal_tables +modal_path · coupled · general +radial · free · planar · spatial +flux · coupling · responses +ionisation · plasma_integration +plasma_source · envelope_plasma +raman · masks · tdse · tdse_source + + +B +backends +may import +numpy, numba +pyfftw; the native package in native/ + +backends/ +protocol · registry · driver +batch · bodies · spatial +populations · memory · multistep +exponential · low_storage + +backends/numpy_ref/ +backend · batch · passes +admission · envelope_plasma +general_modal · tdse + +backends/numba_cpu/ +backend · batch · kernels +envelope_plasma · general_modal +coupled_flow · field_plasma · fft +openmp · spatial · planar · tables +multistep · dispersive +dispersive_spatial +dispersive_general · low_storage +pass_timers + + +D +driver +may import +P, T +B through the seam, h5py + +log.py + +stats.py + +stepper/ +tableau · runge_kutta · multistep +low_storage · controller · schedule + +output/ +schema · capability · memory · hdf5 +tdse_archive · snapshots · scan +scan_artifacts + +scan.py + +driver/executors/ +local · pool · queue · slurm +batch + +driver/ +simulation · run · interface +scaffold · mixtures +material_output · tdse +tdse_consistency · scan · batch +memory + + +X +processing / plotting +may import +numpy, scipy +matplotlib, D (outputs) +T (table identities) + +processing/ +axes · fields · spectra · time +coherence · compression · mixtures +modal · planes · scans + +plotting/ +propagation · statistics +spectrograms · style + +sol.py + +processing/ +windows + + +T → P physics objects + + +B → T delta_phi, prefactor_at, the stage bundle + + +D → B backends.select, then the seam + + +X → D a saved run, through the Output protocol + + + +1 + + + +2 + + + +3 + + + +4 +1 Simulation calls translate.assemble +2 the driver builds the grid, the modes, the pulse and the responses +3 the statistics path: a backend is handed grid, modes and profiles at construction: the one import of P that architecture.md §3 allows B, types and constants, never a physics object +4 processing.modal takes the run's own ModalTransformTables beside the handler: a general modal file records what its reference is and not the arrays, so the reference is rebuilt in T and read in X +The package-wide modules carry no layer letter: __init__.py · exceptions.py \ No newline at end of file diff --git a/docs/dev/structure.md b/docs/dev/structure.md index 4f80db491..982084bc2 100644 --- a/docs/dev/structure.md +++ b/docs/dev/structure.md @@ -32,13 +32,10 @@ One colour per layer, used in every figure on this page and stated in the legend Read the bands top to bottom in the order imports flow, and the arrows in the other direction: each points from the layer that imports to the layer it imports. Solid boxes are modules that exist; dashed ones are named in [architecture.md §4](architecture.md) -as candidates to be written only for an agreed use case, or, for `coupling/transport` -and `coupling/elements`, as the transport core a later phase of -[SEP 31](seps/sep-0031-simulation-coupling.md) writes. The numbered arrows on the right -skip a band; one of them is the one import of P that -[architecture.md §3](architecture.md) allows a backend: the grid, the modes and the -profiles its statistics are handed at construction, types and constants and never a -physics object. +as candidates to be written only for an agreed use case. The numbered arrows on the +right skip a band; one of them is the one import of P that [architecture.md +§3](architecture.md) allows a backend: the grid, the modes and the profiles its +statistics are handed at construction, types and constants and never a physics object. ![The five layers of solprop, with every module in its band and the import edges between them](figures/layers.svg) diff --git a/docs/guide/workflows/coupling.md b/docs/guide/workflows/coupling.md index 3ff926648..0fc9df2c2 100644 --- a/docs/guide/workflows/coupling.md +++ b/docs/guide/workflows/coupling.md @@ -156,3 +156,148 @@ its source, and a realisation is added only when one is given explicitly. The re run keeps its ionisation tables and their ranges, as every derived run does. A different basis, lattice or medium is a coupling or an interface, not a relaunch, and is refused by name. + +## Transporting a plane between planes + +{py:func}`~solprop.coupling.transport.transport` carries a radial or Cartesian field +plane through an ordered sequence of linear optical elements and returns the plane at +the last element's output together with a +{py:class}`~solprop.coupling.transport.TransportReport`. A modal plane holds guide +coefficients, not a field on a transverse grid, and is refused: its exit field is +synthesised first by `Outcouple`. The plane needs its medium, so a saved plane is read +with `field_plane(run, source=simulation)`. + +| element | what it does | +|---|---| +| {py:class}`~solprop.coupling.elements.FreeSpace` | homogeneous propagation $e^{ik_zL}$ with the exact $k_z=\sqrt{(\omega n/c)^2-\kappa^2}$, on the plane's own grid or onto another output plane | +| {py:class}`~solprop.coupling.elements.ThinLens` | the paraxial phase screen $e^{-ik(\omega)r^2/2f(\omega)}$, with a constant or chromatic focal length | +| {py:class}`~solprop.coupling.elements.Aperture` | the edge profile $W$ of {py:func}`~solprop.spatial.aperture_profile`, with the weighting of the in-run aperture statistics | +| {py:class}`~solprop.coupling.elements.TransmissionMask` | a complex field transmission $T(x, y, \omega)$, as a function or as samples | +| {py:class}`~solprop.coupling.elements.Alignment` | a lateral displacement and a tilt of a Cartesian field | +| {py:class}`~solprop.coupling.elements.PolarisationTransform` | a uniform Jones matrix in the plane's local axes | +| {py:class}`~solprop.coupling.elements.Mirror` | spectral p/s reflection and the change of frame | +| {py:class}`~solprop.coupling.elements.PlanarInterface` | the normal-incidence Fresnel interface between two lossless media | +| {py:class}`~solprop.coupling.elements.SpectralTransfer` | any {py:mod}`solprop.optics` transfer function, or a compressor's response, acting on every sample | + +**Order.** The elements are applied exactly in the order given. An +{py:class}`~solprop.optics.Chain` or any other known transfer function may stand in the +sequence directly: it is one frequency-only block, its factors commute among +themselves, and it is never moved past focusing, clipping, propagation or a Jones map. +Every element reports its input and output energies in joules (the axial Poynting flux +of the medium, [medium flux](../free-space/flux.md)), the per-frequency spectra of both, +the declared loss, any intentional removal and the signed approximation error that +closes its ledger. The report of the sequence chains them: each element's input energy +is the previous one's output, losses add in joules, and broadband efficiencies are never +multiplied. A source plane's path certificate is carried as its own item, +`report.source_certificate`, because it bounds the producing run, not the transport. + +**The plane's record follows the field.** Each `FreeSpace` removes a frame term from the +whole field so that the pulse stays in its window, and adds it to the plane's +reference, so `plane.absolute()` stays exact over any distance. Pass the source +simulation's frame, `transport(plane, elements, frame=simulation.frame)`, and a gap +removes the same affine term $R L$ the run would have removed: the transported plane +then has the same stored spectrum and reference as continuing the run. Without a frame a +gap removes only the group delay $\beta_1(\omega_0)L$ of its medium, and the report says +so. A spectral element that drops its own constant phase or group delay (a grating or +prism pair, a chirped mirror's data) leaves that term of the reference uncalibrated. An +interface changes the plane's medium, a mirror turns its axes, and the power metric is +resolved again in the output medium and grid. Every operation is appended to +`plane.provenance.history`. `geometry=` declares the output plane in the receiving +system's coordinates; its axes must be the ones the field arrives with. + +**Free space and its output plane.** Without `output=`, the field stays on its grid, +propagated exactly in its periodic (Cartesian) or reflecting (radial) representation. +A column at angle θ moves its rays by $L\tan\theta$; content whose rays would leave the +window wraps back, which is the same as sampling the propagator's phase below its +Nyquist rate, and is refused beyond `sampling_tolerance`. `beyond_window="remove"` +declares the removal of that angular band instead, and the removed energy is reported. +Significant nonpropagating content is refused too, or removed explicitly with +`band_limit=`. An output plane of another radius or sampling is the caller's explicit +choice of method; nothing switches between them automatically: + +- `output_method="angular"` sums the propagated spectrum at the output nodes. It is + exact and nonparaxial but lives inside the source window: a field that reaches the + source window's edge is refused, and the refusal suggests the Fresnel route. +- `output_method="fresnel"` evaluates the paraxial Fresnel integral of the source + samples at the output nodes. It reaches any observation window, including the far + field; it reports its paraxial phase bound $k(\rho_{\max}+r_{\rm occ})^4/8L^3$ and + refuses a kernel that would alias on the source nodes. + +Both build their kernels in blocks; no transverse-by-transverse-by-frequency array is +formed. + +**The thin-screen certificate.** A lens, an aperture, a mask and a tilt are local +electric-field screens at each frequency. A screen is exact in the local reference +measure $U_0$, which weighs every plane wave by $\operatorname{Re} n\,\varepsilon_0 c$; +the physical axial energy $U_Y$ weighs it by a further $\cos\theta$. With the required +`model_error` $\epsilon_m$ and the cone $1-\cos\theta_*=\epsilon_m/8$, the defect +$d(E)=(1-\cos\theta_*)U_{0,\rm cone}+U_{0,\rm tail}$ bounds $U_0-U_Y$ for the input +and for the output field, and the screen is admitted only when +$[d(E_{\rm in})+d(E_{\rm out})]/U_Y(E_{\rm in})$, plus the transform's round-trip and +edge error, is at most $\epsilon_m$ (SEP 31 §4.6). The certificate, +`report.elements[i].certificate`, applies to that one field. A screen whose phase changes +by π or more between neighbouring nodes where the field is, or one with gain, is +refused. An aperture's `weighting` has the meaning it has for the in-run aperture +statistics: `"amplitude"` (the default) applies $W$ to the field, so the power goes as +$W^2$; `"quadrature"` takes $W$ as a power weight and applies $\sqrt W$. The +intensity-defined aperture of SEP 31 is the `"quadrature"` case; a hard aperture is the +same either way. + +**Mirrors and Jones frames.** A mirror takes its incoming and outgoing propagation +directions and an s axis perpendicular to both (at normal incidence the s axis +resolves the degeneracy). Each beam has the right-handed frame +$(\hat p, \hat s, \hat k)$ with $\hat p = \hat s\times\hat k$; the field leaves as +$(r_pE_p, r_sE_s)$ with no conjugation, and the outgoing plane's axes keep the p/s +components of the incoming ones, so the reflection appears as a mirror image of the +transverse pattern along p. In this convention a perfect conductor has $r_p=+1$, +$r_s=-1$ at any incidence; coefficients calibrated in another convention are converted +first. Two reflections restore the field and its frame. + +**The Fresnel interface.** {py:class}`~solprop.coupling.elements.PlanarInterface` +applies $t_E=2n_1/(n_1+n_2)$ per frequency, with power transmission +$T=(n_2/n_1)|t_E|^2$ and the reflected $R=|r_E|^2$ reported as loss; $R+T=1$. It is +the normal-incidence model: oblique columns' departure from $T$ is reported as +approximation error. The output plane is in the second medium, so applying the same +factor twice -- in an element and again in a launch -- is refused. + +**Spectral optics.** {py:class}`~solprop.coupling.elements.SpectralTransfer` adapts +`Material`, `ChirpedMirror`, `GratingPair`, `PrismPair`, `TaylorPhase` and a `Chain` of +them, keeping each factor's affine-phase record (`report.calibration`). Losses are kept +per frequency; bins a transfer function sets exactly to zero are reported as removed. A +phase-only model asked for a lossy prediction needs an explicit `efficiency=`, and an +efficiency on a lossy model is refused because it would count the loss twice. A +negative material length or an inverse mirror phase is flagged as a reconstruction, +not a passive element. A callable that is not a known optics element states its +calibration with +{py:class}`~solprop.coupling.elements.SpectralCalibration`. + +**Example: a Gaussian focused by a lens.** + +```python +from solprop.coupling import FieldPlane, FreeSpace, PlaneGeometry, ThinLens, transport +from solprop.grid import EnvelopeGrid, RadialGrid +from solprop.pulses import GaussianPulse, SpatialPulse +from solprop.spatial import SpatialMedium + +grid = EnvelopeGrid.from_limits(0.1, 800e-9, (750e-9, 850e-9), 200e-15) +air = SpatialMedium() +beam = SpatialPulse(GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=1e-6), 1e-3) +plane = FieldPlane.from_pulse( + beam, grid, PlaneGeometry(0.0), space=RadialGrid(4e-3, 256), medium=air +) +small = RadialGrid(0.5e-3, 128) # the focal plane, sampled finely +focus, report = transport( + plane, + [ThinLens(0.5, model_error=1e-3), + FreeSpace(0.5, air, output=small, output_method="fresnel")], +) +print(focus.geometry.z, focus.energy(), report.elements[0].certificate.ratio) +``` + +The second-moment radius at the focus is the Fourier-plane waist +$\lambda f/\pi w_0 \approx 127$ µm to about $10^{-6}$ here (the tests hold the lens +and the free-space output to their analytic references); the focal plane carries the +launched microjoule less $10^{-6}$ of it. That is the lens's own axial-flux defect: the +converging plane waves cross the plane obliquely, carrying $\cos\theta$ of their local +measure, and the report books it as the lens's approximation error, inside its +certificate (ratio $2.5\times10^{-4}$ against the budget of $10^{-3}$). From 1bec4b94cae7adace7c0afd2cb57f545aa72e03c Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 01:25:34 +0100 Subject: [PATCH 16/50] SEP 31: Part II report for phase 4, the transport core Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019qNYqtCKh4iTKj7544yARb --- docs/dev/seps/sep-0031-simulation-coupling.md | 171 ++++++++++++++++++ 1 file changed, 171 insertions(+) diff --git a/docs/dev/seps/sep-0031-simulation-coupling.md b/docs/dev/seps/sep-0031-simulation-coupling.md index e309f613f..8c7412b9e 100644 --- a/docs/dev/seps/sep-0031-simulation-coupling.md +++ b/docs/dev/seps/sep-0031-simulation-coupling.md @@ -1395,6 +1395,177 @@ are refused. polarisation. The names belong at the marked comment in `coupling/__init__.py`, and the dashed boxes in `docs/dev/figures/build.py` turn solid when the files land. +### Phase 4 report — transport core + +**Delivered (2026-10-24, `sep31/transport-integration`, on top of phase 2).** The +transport core of §4.6–§4.7 on `FieldPlane`: homogeneous propagation with finite and +far observation planes, bounded thin screens, explicit reflection and Jones frames, the +normal-incidence interface and the existing spectral optics. `Outcouple` (guide-exit +synthesis) belongs to phase 3 and is not part of this delivery. + +- `src/solprop/coupling/elements.py` (P; NumPy and SciPy only): + - `SpatialState`, the plain internal record every element acts on: a `TimeGrid`, a + `RadialGrid` or `FreeGrid`, the internal-convention spectrum + `(n_modes, n_pol ∈ {1, 2}, n_omega)` in the layout of `SpatialPulse.build`, a + homogeneous `SpatialMedium` and a right-handed 3×3 local frame. It stays public in + `solprop.coupling.elements` with the `Element` protocol, as the interface a new + element is written against; it is not exported from `solprop.coupling`. + - Elements (frozen; `apply(state) -> ElementResult(state, report)`): `FreeSpace` + (`distance, medium, output, output_method, band_limit, frame_delay, frame_phase, + evanescent_tolerance, sampling_tolerance, beyond_window`), `ThinLens`, `Aperture` + (with `weighting`), `TransmissionMask`, `Alignment`, `PolarisationTransform`, + `Mirror`, `PlanarInterface` and `SpectralTransfer`. + - Records: `ElementReport`, `ScreenCertificate`, `SpectralCalibration`. +- `src/solprop/coupling/transport.py` (P): `transport(plane, elements, *, + geometry=None, frame=None) -> (FieldPlane, TransportReport)` and `TransportReport` + (the sequential ledger with `ledger_residual`, `reference_in`/`reference_out`, + `frame` and `source_certificate`). The plain-state sequence is the private + `_sequence`; the element-level tests use it through a conftest helper. +- `src/solprop/coupling/__init__.py`: exports `transport`, `TransportReport`, the nine + elements and `SpectralCalibration`; nothing else changed there. +- `tests/coupling/`: `test_elements.py`, `test_transport.py` (element level, on + `SpatialState`) and `test_transport_plane.py` (the `FieldPlane` path, including the + run round trip); 46 tests. +- Docs: a self-contained "Transporting a plane between planes" section at the end of + `docs/guide/workflows/coupling.md`; the API index row; architecture §4; structure + figure 1 with `coupling/transport` and `coupling/elements` solid; the changelog. + +**The adapter.** No accessor was added to `plane.py`; the private helpers in +`transport.py` are `_state` (plane → state), `_reference`, `_geometry` and +`_frame_term`. + +- *In.* Radial and Cartesian planes only. A modal or general modal plane is refused + with a message naming `Outcouple`; a planar plane is refused by name. The plane must + carry its medium (`field_plane(..., source=simulation)` or a constructor); the + samples are converted with `fourier.spectrum_to_internal`, and the frame rows are + `x_axis`, `y_axis`, `normal`. A plane whose exterior is `unknown` is refused when the + sequence contains a `FreeSpace` (propagation depends on the whole field); local + screens and spectral maps carry it. +- *Out.* `spectrum_to_optics` of the final state; `PlaneBasis.of_space` of the output + grid; `PlaneMetric.from_flux(SpatialFlux.resolve(grid, space, medium, z, side))` in + the output medium and grid; the exterior and the source certificate carried; one + `provenance.history` entry per element (`transport[i]: `, with + `(reconstruction)` where flagged) and one for the frame source. +- *Reference.* Each gap removes `exp[-i(φ_f + ωτ_f)]` from the whole field and the + reference gains `(φ_f, τ_f)`, so `absolute()` is unchanged by the removal. With + `frame=` (a `solprop.linear.Frame`, normally `simulation.frame`) a gap without its + own `frame_delay` removes `R L`, `R = β₀ + β₁(ω − ω_o)` of the gap's medium at the + frame's reference frequency, with `β₀` only on an envelope grid that subtracts it + and `ω_o` zero on a field grid: phase `(β₀ − ω_oβ₁)L`, delay `β₁L`, the law of + `reference_at`. Without a frame only `β₁(ω₀)L` is removed and the report and history + say so. Calibration flags: a `SpectralCalibration` with `removes_phase` (or + `removes_delay`) True or None clears that term; False keeps it; a gap keeps the + input's flags, since the removed term is known exactly. +- *Geometry.* Without `geometry=`, the plane moves by each gap's distance along its + current normal (turned by a mirror's `outgoing`), `z` advances by the distances, a + gap clears `side`, and the axes are the final frame's. A declared `geometry` is used + as given after its normal and x axis are checked against the transported ones. + +**Deviations and decisions, with reasons.** + +1. **Errors.** Every refusal in `elements.py` and `transport.py` raises + `CouplingError`, including grid-shaped ones (undersampled screens, a mirror image + off the grid, wrong sample shapes): each is a refusal to admit a field or an element + into a transport, not a defect of a grid object, so no borrowed class remained the + right one. +2. **Frame source.** A `FieldPlane` records its reference, not the rate of its frame, + so "the delay taken from the plane's reference frame" is the explicit `frame=` + argument; its default reproduces the previous `β₁(ω₀)L`. The round trip below shows + that `frame=simulation.frame` makes transport and continued propagation agree in + stored samples and reference alike. +3. **Output methods** stay an explicit choice, `"angular"` or `"fresnel"`; nothing + selects one. Both the out-of-window angular refusal and the wrap refusal of an + angular output suggest `output_method="fresnel"`. +4. **Sampling and wrap now refuse.** A column at angle θ moves its rays by `L tan θ`; + content whose rays pass the window half-width wraps (periodic) or reflects (radial), + which is the same condition as sampling the propagator phase `k_zL` below its + Nyquist rate in κ. Its reference-measure share above `sampling_tolerance` + (default 1e-6) is refused on the same-grid and angular routes. `beyond_window= + "remove"` declares the removal of those (column, bin) pairs as an angular band; it + is booked as `removed` with `window_removed` in the diagnostics. The edge share stays + a diagnostic on the same-grid route (field near a reflecting edge is not yet an + error), and refuses on the angular-output route as before. `sampling_adequate` is + gone. +5. **Aperture weighting — clarification.** `aperture_profile`'s `W` means what SEP 23's + `ApertureSet.weighting` makes it mean (`translate/spatial.py`, `exponent = 1 if + weighting == "quadrature" else 2`): `"amplitude"` (default) is a field transmission, + power `W²`; `"quadrature"` is a power weight, field `√W`. `Aperture` takes + `weighting` with that meaning, so a transported aperture and an in-run aperture + statistic agree. §4.6's "intensity-defined aperture … explicit conversion from its + intensity weight" is the `"quadrature"` case; the default follows the in-run + statistics instead. The draft's unconditional `√W` is replaced. +6. **Doc drift, not fixed (`spatial.py` frozen).** `aperture_profile`'s docstring calls + its result an "amplitude transmission" without saying that SEP 23's quadrature + weighting uses it as a power weight. +7. The numerical-core decisions of the draft stand: per-bin energy repeats + `spatial_energy`'s weighted sum (`_energy_spectrum`, checked against it to 1e-13); + the reference measure `U₀` weighs by `Re n ε₀c`; the certificate's numerical term + is `2δ + δ²` of the synthesis round trip plus the edge shares; tilt uses `k sin θ`; + the mirror frame is `(p̂, ŝ, k̂)` with `p̂ = ŝ × k̂` (perfect conductor `r_p = +1, + r_s = −1`); `PlanarInterface` is lossless at normal incidence with obliquity booked + as approximation error; `SpectralTransfer` recognises phase-only models by type, + refuses an efficiency on a lossy model, books zero bins as removed and flags + negative lengths and inverse mirror phases as reconstructions. + +**Verification actually run.** Targeted lanes only: +`uv run --no-sync pytest -n auto tests/coupling tests/test_structure_figures.py +tests/test_seps.py` (631 passed, 2 skipped), the doctests of the two modules, targeted +coverage of `coupling/elements.py` and `coupling/transport.py` (100 % of statements and +branches), `ruff check --fix`, `ruff format`, `ruff check`, `basedpyright` (0 errors), +`numpydoc lint`, `codespell`, `git diff --check` and `poe docs`. + +| Check | Result (measured) | +|---|---| +| Round trip: `field_plane` at 1 mm of a linear radial argon run (25 µm waist, envelope grid, 64 radial nodes) → `transport([FreeSpace(1 mm)], frame=simulation.frame)` vs the run's 2 mm plane | stored samples agree to 5.6e-13 relative, reference phase and delay to 1e-12, energy to 1e-12; on-axis amplitude changes by 9 % over the gap; asserted at 1e-11 | +| The same without `frame=` | stored samples differ by the carrier phase (3.4e-2), absolute spectra agree to 1.0e-12 | +| The same on a field grid (by hand, not in the suite: 2.5 s) | 4.8e-13 with or without the frame (no `β₀` on a field grid) | +| Gaussian at L = 0.5, 1, 2 z_R: on-axis amplitude, Gouy phase, second-moment waist, curvature | within 3e-6 of the paraxial formulas (asserted 2e-5); wrap share ≤ 1e-6 | +| Fresnel vs angular output; vs `scipy.integrate.quad` of the Fresnel integral; Fresnel → Fraunhofer orders; Airy pattern; lens focus vs `λf/πw₀`; radial vs Cartesian | unchanged from the core (5.4e-5; rtol 2e-3; ratios 1.998 and 1.9995; 5.7e-3 of the peak; 1.3e-5; ≤ 1e-8) | +| Aperture `tanh` edge, both weightings | node samples are `W·E` or `√W·E` to 1e-8 of the field inside the radius; amplitude transmits less than quadrature by more than 1 % | +| Plane record | delays and phases accumulate (declared and default terms), a `TaylorPhase` keeps the reference calibrated, a `GratingPair` clears both terms and `absolute()` then refuses; a fixed `Frame` gives `(β₀ − ω₀β₁)L` and `β₁L`; a back-propagation restores the reference and is flagged in the history; a 90° fold turns the normal to +x and x to −z, moves the origin along the new normal and keeps the frame right-handed; a declared geometry is checked; an interface sets the medium and books 4 % reflection | +| Refusals | modal (naming Outcouple), planar, medium missing, unknown exterior before a gap, wrapped rays (with the `beyond_window="remove"` alternative reported), angular output at the source edge (suggesting `"fresnel"`), unknown weighting, every element refusal as `CouplingError` | + +Not run: the full gate, the slow lanes, the whole-package coverage lane and backend +parity (nothing here crosses the backend seam). Sources: the Fresnel and Fraunhofer +integrals and the Gaussian-beam formulas are checked by the tests above; no new +constant or coefficient was transcribed. + +**Limitations.** + +- `fresnel_phase_bound` is conservative, often by orders of magnitude for a Gaussian. +- The wrap test is a ray criterion per column; it does not see a field that is + already at the window edge (the edge share reports that on the same-grid route). +- The sampled phase-step check of a general mask cannot see a step beyond π; the lens + and the tilt use their analytic gradients. +- The Fresnel route attenuates a lossy medium through `e^{ikL}` only. +- Cartesian Fresnel output is a dense per-bin separable sum, not benchmarked. +- A radial two-component state holds uniform polarisation only. +- The frame rate of a plane is not part of `FieldPlane`; without `frame=`, a plane + written in a frame other than its medium's `β₁(ω₀)` gets a different (still exact) + reference split. + +**Open questions.** + +1. Should `spatial.py` gain a public per-bin `spatial_energy_spectrum`, replacing + `_energy_spectrum`? +2. Should a `FieldPlane` record its frame (or the run's frame record) so that + `transport` needs no `frame=` argument? +3. Should `aperture_profile`'s docstring (item 6) be corrected when `spatial.py` is + next open? +4. Should the same-grid edge share also refuse, with its own tolerance? + +**Handoff.** + +- Phase 3 places `Outcouple` in front of a sequence and adds it to the elements table + in the manual's transport section and to `coupling/__init__.py`. The introduction of + `docs/guide/workflows/coupling.md` still says transport follows later; it should be + updated at merge with phase 3's own changes to that paragraph. +- `couple` takes the transported plane as it is: its metric is resolved, its reference + and calibration flags are current, and `report.source_certificate` carries SEP 34's + certificate separately. +- Phase 5 composes the eight §2 cases from these elements; a compressor result enters as + `SpectralTransfer(callable, calibration=SpectralCalibration(...))`. + ## Part III — History - 2026-10-22: Drafted after auditing existing launch, projection, spatial flux, From 2e86ff260cd2e564e23ede632bec3d005f9f9fb0 Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 01:28:58 +0100 Subject: [PATCH 17/50] Docs: the transport report's API page and clean cross-references transport, the function, shadows its module in solprop.coupling, as physdata.species does; TransportReport gets a hand-written API page. The element module's callable aliases become private so the generated reference lists no undocumented targets, and a docstring line is rewrapped. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019qNYqtCKh4iTKj7544yARb --- docs/api/index.md | 3 +- docs/api/transport.md | 16 +++++++++++ docs/dev/seps/sep-0031-simulation-coupling.md | 12 ++++++-- docs/guide/workflows/coupling.md | 2 +- src/solprop/coupling/elements.py | 28 +++++++++---------- src/solprop/coupling/transport.py | 3 +- 6 files changed, 45 insertions(+), 19 deletions(-) create mode 100644 docs/api/transport.md diff --git a/docs/api/index.md b/docs/api/index.md index e24202a98..086f7db22 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -47,7 +47,7 @@ In scripts and library code, import from the home modules listed below. |---|---| | {mod}`solprop.output` | the in-memory and HDF5 output handlers, the run's metadata record, `open_file` and resuming a run, live snapshots (`SnapshotController`, `read_snapshot`, `restore_snapshot`; [the output page](../guide/results/output.md)), and the raw TDSE response archive (`TDSEArchive`, `open_tdse_archive`; [the TDSE response](../guide/media/tdse.md#the-raw-response-archive)) | | {mod}`solprop.processing` | post-processing of a saved run: fields, spectra, pulse metrics, coherence, compression, the physical fields of a general modal run, and `field_plane`, a saved plane read back for coupling | -| {mod}`solprop.coupling` | coupling simulations through a field plane: `FieldPlane`, `regrid`, `InputPort` and the `same_basis` relaunch; `transport` through the linear elements `FreeSpace`, `ThinLens`, `Aperture`, `TransmissionMask`, `Alignment`, `PolarisationTransform`, `Mirror`, `PlanarInterface` and `SpectralTransfer` ([coupling simulations](../guide/workflows/coupling.md)) | +| {mod}`solprop.coupling` | coupling simulations through a field plane: `FieldPlane`, `regrid`, `InputPort` and the `same_basis` relaunch; `transport` through the linear elements `FreeSpace`, `ThinLens`, `Aperture`, `TransmissionMask`, `Alignment`, `PolarisationTransform`, `Mirror`, `PlanarInterface` and `SpectralTransfer` ([coupling simulations](../guide/workflows/coupling.md); [the transport report](transport.md)) | | {mod}`solprop.stats` | the statistics recorded at the saved planes, and `StatsSpec` to choose them | | {mod}`solprop.plotting` | the standard figures: propagation maps, slices, spectrograms, statistics | | {mod}`solprop.optics` | linear optics applied to a pulse outside a propagation: materials, chirped mirrors, grating and prism pairs | @@ -74,6 +74,7 @@ design behind both is in the [developer documentation](../dev/index.md). species tabulated scans +transport ``` ## Complete generated reference diff --git a/docs/api/transport.md b/docs/api/transport.md new file mode 100644 index 000000000..7c2007e8e --- /dev/null +++ b/docs/api/transport.md @@ -0,0 +1,16 @@ +# Transport between planes + +{func}`solprop.coupling.transport` carries a radial or Cartesian +{class}`~solprop.coupling.plane.FieldPlane` through an ordered sequence of the linear +elements of {mod}`solprop.coupling.elements` and returns the transported plane with its +{class}`~solprop.coupling.transport.TransportReport`, the sequential ledger in joules +([transporting a plane](../guide/workflows/coupling.md#transporting-a-plane-between-planes)). + +Because the function has the same name as its module, `solprop.coupling.transport` +refers to the function, not the module; the module's report class is documented on this +page. Both names are importable from {mod}`solprop.coupling`. + +```{eval-rst} +.. autoclass:: solprop.coupling.transport.TransportReport + :members: +``` diff --git a/docs/dev/seps/sep-0031-simulation-coupling.md b/docs/dev/seps/sep-0031-simulation-coupling.md index 8c7412b9e..c1e80ab03 100644 --- a/docs/dev/seps/sep-0031-simulation-coupling.md +++ b/docs/dev/seps/sep-0031-simulation-coupling.md @@ -1427,7 +1427,8 @@ synthesis) belongs to phase 3 and is not part of this delivery. `SpatialState`) and `test_transport_plane.py` (the `FieldPlane` path, including the run round trip); 46 tests. - Docs: a self-contained "Transporting a plane between planes" section at the end of - `docs/guide/workflows/coupling.md`; the API index row; architecture §4; structure + `docs/guide/workflows/coupling.md`; the API index row and an API page, + `docs/api/transport.md`, for `TransportReport` (below); architecture §4; structure figure 1 with `coupling/transport` and `coupling/elements` solid; the changelog. **The adapter.** No accessor was added to `plane.py`; the private helpers in @@ -1497,7 +1498,14 @@ synthesis) belongs to phase 3 and is not part of this delivery. 6. **Doc drift, not fixed (`spatial.py` frozen).** `aperture_profile`'s docstring calls its result an "amplitude transmission" without saying that SEP 23's quadrature weighting uses it as a power weight. -7. The numerical-core decisions of the draft stand: per-bin energy repeats +7. **The function shadows its module.** §5.1 makes `transport` a member of + `solprop.coupling`, so `solprop.coupling.transport` names the function and the + generated reference documents the function on the module's page. As for + `solprop.physdata.species`, the module's `TransportReport` is documented on a + hand-written API page. The element module's two callable type aliases are private + (`_Transmission`, `_SpectralFunction`), so the reference does not list + undocumented targets. +8. The numerical-core decisions of the draft stand: per-bin energy repeats `spatial_energy`'s weighted sum (`_energy_spectrum`, checked against it to 1e-13); the reference measure `U₀` weighs by `Re n ε₀c`; the certificate's numerical term is `2δ + δ²` of the synthesis round trip plus the edge shares; tilt uses `k sin θ`; diff --git a/docs/guide/workflows/coupling.md b/docs/guide/workflows/coupling.md index 0fc9df2c2..36f3296e4 100644 --- a/docs/guide/workflows/coupling.md +++ b/docs/guide/workflows/coupling.md @@ -159,7 +159,7 @@ name. ## Transporting a plane between planes -{py:func}`~solprop.coupling.transport.transport` carries a radial or Cartesian field +{py:func}`~solprop.coupling.transport` carries a radial or Cartesian field plane through an ordered sequence of linear optical elements and returns the plane at the last element's output together with a {py:class}`~solprop.coupling.transport.TransportReport`. A modal plane holds guide diff --git a/src/solprop/coupling/elements.py b/src/solprop/coupling/elements.py index ee98a07aa..e92c06f4a 100644 --- a/src/solprop/coupling/elements.py +++ b/src/solprop/coupling/elements.py @@ -91,14 +91,14 @@ _Positions = Float[np.ndarray, " n_nodes"] _Band = Float[np.ndarray, " n_band"] -Transmission = Callable[[_Positions, _Positions, _Band], ArrayLike] +_Transmission = Callable[[_Positions, _Positions, _Band], ArrayLike] """A field transmission ``T(x, y, omega)`` in the optics convention. Positions in m, shape ``(n_nodes,)``; positive in-band angular frequencies in rad/s, shape ``(n_band,)``. The result broadcasts to ``(n_nodes, n_band)``. """ -SpectralFunction = Callable[[Float[np.ndarray, " n_band"]], ArrayLike] +_SpectralFunction = Callable[[Float[np.ndarray, " n_band"]], ArrayLike] """A function of positive angular frequency in rad/s, shape ``(n_band,)``.""" @@ -111,10 +111,10 @@ class SpatialState: The plain input of every element; :func:`solprop.coupling.transport` builds it from a :class:`~solprop.coupling.plane.FieldPlane` (the internal conjugate of - the plane's samples, its grid's frame axes as rows). The single component of a scalar state - (``n_pol = 1``) is polarised along the frame's x axis; two components are the - optics-convention ``(x, y)`` Jones components stored conjugated, as - :meth:`solprop.pulses.SpatialPulse.build` stores them. + the plane's samples, with the plane's axes as the frame's rows). The single + component of a scalar state (``n_pol = 1``) is polarised along the frame's x + axis; two components are the optics-convention ``(x, y)`` Jones components + stored conjugated, as :meth:`solprop.pulses.SpatialPulse.build` stores them. Attributes ---------- @@ -987,7 +987,7 @@ def _report( # noqa: PLR0913 - the ledger's terms, each named at the call def _spectral_values( - values: float | complex | SpectralFunction, + values: float | complex | _SpectralFunction, omega: Float[np.ndarray, " n_band"], name: str, ) -> Complex[np.ndarray, " n_band"]: @@ -1260,7 +1260,7 @@ def _screen_certificate( def _evaluate_transmission( - transmission: Transmission, + transmission: _Transmission, x: Float[np.ndarray, " n_nodes"], y: Float[np.ndarray, " n_nodes"], omega: Float[np.ndarray, " n_band"], @@ -1301,7 +1301,7 @@ def _evaluate_transmission( def _symmetric_transmission( - transmission: Transmission, + transmission: _Transmission, space: RadialGrid, omega: Float[np.ndarray, " n_band"], element: str, @@ -1378,7 +1378,7 @@ class ThinLens: 0.5 """ - focal_length: float | SpectralFunction + focal_length: float | _SpectralFunction model_error: float def apply(self, state: SpatialState) -> ElementResult: @@ -1538,7 +1538,7 @@ class TransmissionMask: Declare ``|T| = 1``: the local loss is then exactly zero. Default False. """ - transmission: Transmission | ArrayLike + transmission: _Transmission | ArrayLike model_error: float phase_only: bool = False @@ -1952,8 +1952,8 @@ class Mirror: sign does not change the map. """ - reflectivity_p: complex | SpectralFunction - reflectivity_s: complex | SpectralFunction + reflectivity_p: complex | _SpectralFunction + reflectivity_s: complex | _SpectralFunction incident: Sequence[float] | np.ndarray outgoing: Sequence[float] | np.ndarray s_axis: Sequence[float] | np.ndarray @@ -2321,7 +2321,7 @@ class SpectralTransfer: """ transfer: TransferFunction - efficiency: float | SpectralFunction | None = None + efficiency: float | _SpectralFunction | None = None calibration: SpectralCalibration | tuple[SpectralCalibration, ...] | None = None predict_loss: bool = False reconstruction: bool = False diff --git a/src/solprop/coupling/transport.py b/src/solprop/coupling/transport.py index 8e3990d1d..4ad90c12c 100644 --- a/src/solprop/coupling/transport.py +++ b/src/solprop/coupling/transport.py @@ -441,7 +441,8 @@ def transport( ------- tuple The plane at the last element's output -- reference, geometry, medium, - metric and provenance updated -- and the :class:`TransportReport`. + metric and provenance updated -- and the + :class:`~solprop.coupling.transport.TransportReport`. Raises ------ From 30b36cf9b405b863a1c5eacb68b577b5aa5fcde2 Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 02:04:24 +0100 Subject: [PATCH 18/50] Coupling: models, couple, admission budget and Outcouple (SEP 31 phase 3) couple(plane, port, model=CouplingModel(kind, model_error=...)) is the one public path from a field plane to a target-bound launch, with an immutable CouplingReport. same_basis (exact copy, declared basis change, F^-1 B recovery) replaces the phase-2 function; scalar_paraxial fits scalar beams and capillary or weak-guidance step-index planes onto HE1m/LP0m profiles in the common paraxial measure; vector_paraxial recovers coordinates in a general modal guide with the flux pairing and the forward H closure. Admission items: angular tails, contrast screen or recorded pair envelope, metric defects, H closure, passive-map certificate and numerical errors. Ports carry scalar profiles, the core reference index and contrast; field planes from analytic fields record domain and resolution checks; modal planes read with their simulation carry their profiles. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019qNYqtCKh4iTKj7544yARb --- docs/dev/figures/build.py | 5 +- docs/dev/figures/layers.svg | 341 ++-- src/solprop/coupling/__init__.py | 49 +- src/solprop/coupling/couple.py | 2613 ++++++++++++++++++++++++ src/solprop/coupling/envelopes.py | 134 ++ src/solprop/coupling/models.py | 897 ++++++++ src/solprop/coupling/outcouple.py | 226 ++ src/solprop/coupling/plane.py | 534 ++++- src/solprop/coupling/port.py | 115 +- src/solprop/processing/planes.py | 55 +- src/solprop/translate/coupling.py | 122 +- tests/coupling/test_couple.py | 905 ++++++++ tests/coupling/test_envelopes.py | 325 +++ tests/coupling/test_port.py | 10 +- tests/driver/test_coupling_relaunch.py | 7 +- tests/test_display/IncidentLaunch.txt | 5 - 16 files changed, 6036 insertions(+), 307 deletions(-) create mode 100644 src/solprop/coupling/couple.py create mode 100644 src/solprop/coupling/envelopes.py create mode 100644 src/solprop/coupling/models.py create mode 100644 src/solprop/coupling/outcouple.py create mode 100644 tests/coupling/test_couple.py create mode 100644 tests/coupling/test_envelopes.py delete mode 100644 tests/test_display/IncidentLaunch.txt diff --git a/docs/dev/figures/build.py b/docs/dev/figures/build.py index 2bdfa80a0..a999b1f3a 100644 --- a/docs/dev/figures/build.py +++ b/docs/dev/figures/build.py @@ -597,7 +597,10 @@ class Layer(NamedTuple): Module("polarisation.py"), Module("optics.py"), Module("tools.py"), - Module("coupling/", ("plane", "port", "remap")), + Module( + "coupling/", + ("plane", "port", "remap", "models", "couple", "outcouple", "envelopes"), + ), Module( "physdata/", ( diff --git a/docs/dev/figures/layers.svg b/docs/dev/figures/layers.svg index ef2f07987..dd534e52f 100644 --- a/docs/dev/figures/layers.svg +++ b/docs/dev/figures/layers.svg @@ -1,6 +1,6 @@ + width="1200" height="1517.0" viewBox="0 0 1200 1517.0"> - + The layers of solprop, and every module in each Bands and colours from architecture.md §3 and decisions.md ADR-001; modules from src/solprop. Arrows point from the layer that imports to the layer it imports. @@ -84,13 +84,13 @@ text { font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; fill: X unwritten candidate, not scheduled (architecture.md §4) - - -P -physics / convenience -may import -numpy, scipy, stdlib -other P modules + + +P +physics / convenience +may import +numpy, scipy, stdlib +other P modules constants.py @@ -115,169 +115,170 @@ text { font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; fill: linear.py boundaries.py - + polarisation.py - + optics.py - + tools.py - + coupling/ -plane · port · remap - -physdata/ -sellmeier · equation_of_state -gases · glasses · metals -nonlinear_data · raman_types -species · mixtures · transport -selection - -modes/ -protocol · base · capillary -antiresonant · tabulated · fill -stepindex · material · vector -guide · graded_index · connection -rectangular · slab · collection -quadrature - -nonlinear/ -responses · ionisation -optmat_rates · envelope_plasma -raman · gnlse · normalisation -mixtures · optmat_raman · tdse - -coupling/ -transport · elements - - -T -translation -may import -P, numpy, scipy - -translate/ -problem · stage · linop · expansion -chebyshev · modal · modal_tables -modal_path · coupled · general -radial · free · planar · spatial -flux · coupling · responses -ionisation · plasma_integration -plasma_source · envelope_plasma -raman · masks · tdse · tdse_source - - -B -backends -may import -numpy, numba -pyfftw; the native package in native/ - -backends/ -protocol · registry · driver -batch · bodies · spatial -populations · memory · multistep -exponential · low_storage - -backends/numpy_ref/ -backend · batch · passes -admission · envelope_plasma -general_modal · tdse - -backends/numba_cpu/ -backend · batch · kernels -envelope_plasma · general_modal -coupled_flow · field_plasma · fft -openmp · spatial · planar · tables -multistep · dispersive -dispersive_spatial -dispersive_general · low_storage -pass_timers - - -D -driver -may import -P, T -B through the seam, h5py - -log.py - -stats.py - -stepper/ -tableau · runge_kutta · multistep -low_storage · controller · schedule - -output/ -schema · capability · memory · hdf5 -tdse_archive · snapshots · scan -scan_artifacts - -scan.py - -driver/executors/ -local · pool · queue · slurm -batch - -driver/ -simulation · run · interface -scaffold · mixtures -material_output · tdse -tdse_consistency · scan · batch -memory - - -X -processing / plotting -may import -numpy, scipy -matplotlib, D (outputs) -T (table identities) - -processing/ -axes · fields · spectra · time -coherence · compression · mixtures -modal · planes · scans - -plotting/ -propagation · statistics -spectrograms · style - -sol.py - -processing/ -windows - - -T → P physics objects - - -B → T delta_phi, prefactor_at, the stage bundle - - -D → B backends.select, then the seam - - -X → D a saved run, through the Output protocol - - - -1 - - - -2 - - - -3 - - - -4 -1 Simulation calls translate.assemble -2 the driver builds the grid, the modes, the pulse and the responses -3 the statistics path: a backend is handed grid, modes and profiles at construction: the one import of P that architecture.md §3 allows B, types and constants, never a physics object -4 processing.modal takes the run's own ModalTransformTables beside the handler: a general modal file records what its reference is and not the arrays, so the reference is rebuilt in T and read in X -The package-wide modules carry no layer letter: __init__.py · exceptions.py +plane · port · remap · models +couple · outcouple · envelopes + +physdata/ +sellmeier · equation_of_state +gases · glasses · metals +nonlinear_data · raman_types +species · mixtures · transport +selection + +modes/ +protocol · base · capillary +antiresonant · tabulated · fill +stepindex · material · vector +guide · graded_index · connection +rectangular · slab · collection +quadrature + +nonlinear/ +responses · ionisation +optmat_rates · envelope_plasma +raman · gnlse · normalisation +mixtures · optmat_raman · tdse + +coupling/ +transport · elements + + +T +translation +may import +P, numpy, scipy + +translate/ +problem · stage · linop · expansion +chebyshev · modal · modal_tables +modal_path · coupled · general +radial · free · planar · spatial +flux · coupling · responses +ionisation · plasma_integration +plasma_source · envelope_plasma +raman · masks · tdse · tdse_source + + +B +backends +may import +numpy, numba +pyfftw; the native package in native/ + +backends/ +protocol · registry · driver +batch · bodies · spatial +populations · memory · multistep +exponential · low_storage + +backends/numpy_ref/ +backend · batch · passes +admission · envelope_plasma +general_modal · tdse + +backends/numba_cpu/ +backend · batch · kernels +envelope_plasma · general_modal +coupled_flow · field_plasma · fft +openmp · spatial · planar · tables +multistep · dispersive +dispersive_spatial +dispersive_general · low_storage +pass_timers + + +D +driver +may import +P, T +B through the seam, h5py + +log.py + +stats.py + +stepper/ +tableau · runge_kutta · multistep +low_storage · controller · schedule + +output/ +schema · capability · memory · hdf5 +tdse_archive · snapshots · scan +scan_artifacts + +scan.py + +driver/executors/ +local · pool · queue · slurm +batch + +driver/ +simulation · run · interface +scaffold · mixtures +material_output · tdse +tdse_consistency · scan · batch +memory + + +X +processing / plotting +may import +numpy, scipy +matplotlib, D (outputs) +T (table identities) + +processing/ +axes · fields · spectra · time +coherence · compression · mixtures +modal · planes · scans + +plotting/ +propagation · statistics +spectrograms · style + +sol.py + +processing/ +windows + + +T → P physics objects + + +B → T delta_phi, prefactor_at, the stage bundle + + +D → B backends.select, then the seam + + +X → D a saved run, through the Output protocol + + + +1 + + + +2 + + + +3 + + + +4 +1 Simulation calls translate.assemble +2 the driver builds the grid, the modes, the pulse and the responses +3 the statistics path: a backend is handed grid, modes and profiles at construction: the one import of P that architecture.md §3 allows B, types and constants, never a physics object +4 processing.modal takes the run's own ModalTransformTables beside the handler: a general modal file records what its reference is and not the arrays, so the reference is rebuilt in T and read in X +The package-wide modules carry no layer letter: __init__.py · exceptions.py \ No newline at end of file diff --git a/src/solprop/coupling/__init__.py b/src/solprop/coupling/__init__.py index 5560ff7ff..d3e62dd1e 100644 --- a/src/solprop/coupling/__init__.py +++ b/src/solprop/coupling/__init__.py @@ -9,15 +9,35 @@ native representation, with its references, geometry, medium, metric and provenance; :func:`~solprop.coupling.remap.regrid` moves it onto another temporal lattice or reference; an :class:`~solprop.coupling.port.InputPort` describes what a receiving -simulation accepts at its entrance, and :func:`~solprop.coupling.port.same_basis` -launches a plane into a port of the same basis. Extraction from a saved run is -:func:`solprop.processing.planes.field_plane`; installing a launch is -:meth:`solprop.driver.simulation.Simulation.with_input`. The manual page is -``docs/guide/workflows/coupling.md``. +simulation accepts at its entrance, and :func:`~solprop.coupling.couple.couple` +launches a plane into it under a declared +:class:`~solprop.coupling.models.CouplingModel`; +:class:`~solprop.coupling.outcouple.Outcouple` synthesises a guide's exit field. +Extraction from a saved run is :func:`solprop.processing.planes.field_plane`; +installing a launch is :meth:`solprop.driver.simulation.Simulation.with_input`. +The manual page is ``docs/guide/workflows/coupling.md``. """ +from solprop.coupling.couple import ( + AdmissionItem, + CouplingReport, + CouplingResult, + couple, +) +from solprop.coupling.envelopes import ( + CAPILLARY_ENVELOPE, + STEP_INDEX_ENVELOPE, + PairEnvelope, +) +from solprop.coupling.models import ( + BasisChange, + CouplingModel, + ScalarModeBasis, +) +from solprop.coupling.outcouple import Outcouple, OutcoupleResult from solprop.coupling.plane import ( PLANE_KINDS, + AnalyticRecord, Calibration, Exterior, FieldPlane, @@ -40,27 +60,37 @@ METRIC_TOLERANCE, CouplingLaunch, InputPort, - same_basis, ) from solprop.coupling.remap import DEFAULT_TOLERANCE, RegridReport, regrid # The transport core (solprop.coupling.transport and solprop.coupling.elements: # transport, FreeSpace, ThinLens, Aperture, TransmissionMask, PlanarInterface, -# PolarisationTransform, Mirror, SpectralTransfer, Outcouple) is exported here when -# it lands, beside the names below. +# PolarisationTransform, Mirror, SpectralTransfer) is exported here when it lands, +# beside the names below. Outcouple lives in solprop.coupling.outcouple. __all__ = [ + "CAPILLARY_ENVELOPE", "DEFAULT_TOLERANCE", "ENTRANCE_TOLERANCE", "METRIC_TOLERANCE", "PLANE_KINDS", + "STEP_INDEX_ENVELOPE", + "AdmissionItem", + "AnalyticRecord", + "BasisChange", "Calibration", "CouplingLaunch", + "CouplingModel", + "CouplingReport", + "CouplingResult", "Exterior", "FieldPlane", "InputPort", "MetricKind", "NodalField", + "Outcouple", + "OutcoupleResult", + "PairEnvelope", "PlaneBasis", "PlaneGeometry", "PlaneKind", @@ -68,11 +98,12 @@ "PlaneProvenance", "PlaneReference", "RegridReport", + "ScalarModeBasis", "basis_identity", + "couple", "lattice_identity", "modal_basis_identity", "reference_at", "regrid", - "same_basis", "space_record", ] diff --git a/src/solprop/coupling/couple.py b/src/solprop/coupling/couple.py new file mode 100644 index 000000000..7311386be --- /dev/null +++ b/src/solprop/coupling/couple.py @@ -0,0 +1,2613 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +r"""Couple a field plane into a simulation's input port (SEP 31, sections 4.3 and 4.7). + +:func:`couple` is the one public path from a :class:`~solprop.coupling.plane.FieldPlane` +to a target-bound :class:`~solprop.coupling.port.CouplingLaunch`. It applies one of +the three models of :class:`~solprop.coupling.models.CouplingModel` and returns a +:class:`CouplingResult`: the launch and an immutable :class:`CouplingReport`. + +**Measures.** Three quadratic forms are kept apart (section 4.3): ``F``, the +Hermitian E/H flux pairing of a modal basis; ``G = S^H M S``, the scalar +least-squares Gram in the common paraxial measure :math:`M_0 = Y_0\,dA/2`, +:math:`Y_0 = n_{\rm ref}\varepsilon_0c`; and ``Q``, a representation's native power +metric. Only in the common positive metric ``M_0`` does the orthogonal-residual +identity :math:`\|f\|^2_M = a^\dagger Ga + \|f - Sa\|^2_M` hold, so only the scalar +model reports an uncaptured energy; the others report the incident and launched +energies and leave the difference unattributed. + +**Overlaps in the plane's own quadrature.** A spatial plane's columns are the +Hankel or Fourier spectrum of its field, so the scalar overlap of a target profile is +taken by Parseval in that spectral quadrature, with the profile's closed-form +transform (:meth:`~solprop.coupling.models.ScalarModeBasis.hankel`): the incident +norm, the overlaps and the Gram all use one quadrature, and the identity above holds +to rounding. A modal source is overlapped on a composite Gauss--Legendre rule that +breaks at both core radii. + +**Admission** (section 4.3). Each approximate coupling is admitted against its +``model_error`` budget :math:`\epsilon_m` through a list of +:class:`AdmissionItem` s, each a relative energy discrepancy: + +- ``angular``: :math:`[d(E_{\rm in}) + d_{\rm target}]/U` with + :math:`1 - \cos\theta_* = \epsilon_m/8` and + :math:`d = (1 - \cos\theta_*)U_{\rm cone} + U_{\rm tail}` (section 4.6), for the + incident field and for the retained target subspace; +- ``contrast``: the weak-guidance screen + :math:`\Delta \le \epsilon_m/8` of a solid provider, or the recorded pair + envelope that replaces it; not applied to a capillary wall; +- ``target metric`` and ``source metric``: the relative defect between ``G`` (or + ``F``) and the target's native ``Q``, and between ``M_0`` and the source's native + metric, each at most :math:`\epsilon_m/8`; +- ``closure`` (vector model): :math:`2\delta + \delta^2` of the H-closure field error + :math:`\delta`, at most :math:`\epsilon_m/8`; +- ``passivity``: the whitened certificate :math:`C^\dagger Q_BC \preceq Q_A`; an + apparent gain within the model's own defects is reported as a model defect, never + rescaled or clipped, and a larger one refuses; +- ``envelope``: the recorded pair envelope's bound; +- ``numerical``: quadrature, representation and domain errors. + +The counted items must sum to at most :math:`\epsilon_m`. A source plane's SEP 34 path +certificate is carried separately in the report, with its scope. A zero field is a +valid zero launch with an undefined efficiency. +""" + +import math +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field +from types import MappingProxyType +from typing import Literal, cast + +import numpy as np +from jaxtyping import Complex, Float, Inexact, Int +from scipy.special import j0 + +from solprop import fourier +from solprop._display import Displayed, engineering, line +from solprop.constants import EPS0, C +from solprop.coupling.envelopes import ( + CAPILLARY_ENVELOPE, + STEP_INDEX_ENVELOPE, + PairEnvelope, +) +from solprop.coupling.models import CouplingModel, ScalarModeBasis, radial_rule +from solprop.coupling.plane import FieldPlane, PlaneMetric, lattice_identity +from solprop.coupling.port import ( + METRIC_TOLERANCE, + CouplingLaunch, + InputPort, + check_same_medium, + occupied_outside, +) +from solprop.exceptions import CouplingError +from solprop.grid import FreeGrid, RadialGrid + +__all__ = [ + "PASSIVITY_ROUNDING", + "AdmissionItem", + "CouplingReport", + "CouplingResult", + "couple", + "target_columns", +] + +PASSIVITY_ROUNDING = 1e-10 +"""Apparent gain of a whitened map below which it is rounding, not a model defect. + +The certificate's largest squared singular value is formed from sums over at most +a few thousand columns of terms within a few ulp of each other, then whitened by a +Cholesky factor of a Gram whose condition is checked; 1e-10 is well above that +rounding and well below any model defect worth naming. +""" + +_OCCUPIED = 1e-14 +"""Share of the peak bin's energy below which a bin is not occupied. + +The per-bin admission items are maxima over occupied bins; a bin carrying less than +this share of the peak carries less energy than the rounding of the total. +""" + +_TARGET_BINS = 5 +"""Occupied bins at which a general guide's angular content is evaluated. + +The node Fourier transform of every channel at every bin would cost nodes times +cone samples times bins; the content varies smoothly with frequency, so the two +band edges, the peak bin and two between them bound it. +""" + +_SCALE = 0.5 * EPS0 * C +"""``eps0 c / 2``: joules per weighted ``|E|^2`` sum of a field in V/m.""" + +ItemScope = Literal["field", "map", "model"] +"""What an admission item bounds: this field, every input of the map, or the model.""" + + +@dataclass(frozen=True) +class AdmissionItem: + r"""One term of a coupling's admission budget (SEP 31, section 4.3). + + Attributes + ---------- + name : str + ``angular``, ``contrast``, ``target metric``, ``source metric``, + ``closure``, ``passivity``, ``envelope`` or ``numerical``. + value : float or None + The relative energy discrepancy it bounds, dimensionless; None where the + item does not apply (`note` says why). + allowance : float or None + Its own cap, where the model gives it one (:math:`\epsilon_m/8`); None + when only the total budget limits it. + counted : bool + Whether `value` is part of the budget's sum. A screen (``contrast``) + bounds a model's validity, not an energy, and is not counted. + scope : {'field', 'map', 'model'} + Whether it bounds this field only, every input of the map, or the model. + note : str, optional + What it measured, or why it does not apply. + """ + + name: str + value: float | None + allowance: float | None + counted: bool + scope: ItemScope + note: str = "" + + @property + def passed(self) -> bool: + """Return whether the item is within its own cap. + + Returns + ------- + bool + True when it has no value or no cap, or its value is at most the cap. + """ + return ( + self.value is None or self.allowance is None or self.value <= self.allowance + ) + + +@dataclass(frozen=True, eq=False) +class CouplingReport(Displayed): + r"""The structured account of one coupling (SEP 31, sections 4.7 and 5.3). + + Energies are per bin, J, in the measure each names; the totals are their sums. + + Attributes + ---------- + model : str + The coupling model. + model_error : float or None + Its budget; None for ``same_basis``. + reference_index : numpy.ndarray + :math:`n_{\rm ref}` of the common measure at each bin, shape + ``(n_omega,)``; zero where it is not defined. + incident : numpy.ndarray + The incident field's energy over its whole plane, shape ``(n_omega,)``: in + the common measure :math:`M_0` for the approximate models, the plane's own + metric for ``same_basis``. + launched : numpy.ndarray + The launch's energy in the target's native metric, shape ``(n_omega,)``: + what the receiving run's entrance plane carries. + captured : numpy.ndarray or None + The captured energy :math:`a^\dagger Ga` in the common metric, shape + ``(n_omega,)``; None where the model has no common positive metric. + captured_reason : str + Why `captured` is None; empty otherwise. + items : tuple of AdmissionItem + The admission budget's terms. + exterior : str + What the source plane says beyond its record: ``'zero'`` or ``'unknown'`` + (unobserved tails are unknown, not lost). + certificate : collections.abc.Mapping or None + The source plane's SEP 34 path certificate, with its scope, carried + separately: it bounds the linear background path, not the coupling. + source : collections.abc.Mapping + The source plane's provenance. + port : str + The target port's identity. + """ + + model: str + model_error: float | None + reference_index: Float[np.ndarray, " n_omega"] + incident: Float[np.ndarray, " n_omega"] + launched: Float[np.ndarray, " n_omega"] + captured: Float[np.ndarray, " n_omega"] | None + captured_reason: str + items: tuple[AdmissionItem, ...] + exterior: str + certificate: Mapping[str, object] | None + source: Mapping[str, object] + port: str + + def __post_init__(self) -> None: + """Own read-only arrays and mappings, checked against one another.""" + n_omega = np.shape(self.incident) + for name in ("reference_index", "incident", "launched", "captured"): + value = getattr(self, name) + if value is None: + continue + array = np.array(value, dtype=np.float64, copy=True) + if array.shape != n_omega or not np.all(np.isfinite(array)): + msg = f"a coupling report's {name} is finite, one value per bin" + raise CouplingError(msg) + array.setflags(write=False) + object.__setattr__(self, name, array) + object.__setattr__(self, "items", tuple(self.items)) + object.__setattr__(self, "source", MappingProxyType(dict(self.source))) + if self.certificate is not None: + object.__setattr__( + self, "certificate", MappingProxyType(dict(self.certificate)) + ) + + @property + def uncaptured(self) -> Float[np.ndarray, " n_omega"] | None: + """Return the incident energy each bin leaves outside the target, not clipped. + + Returns + ------- + numpy.ndarray or None + ``incident - captured``, J, in the common positive metric where the + orthogonal-residual identity makes it a nonnegative physical share; + None where `captured` is None. + """ + return None if self.captured is None else self.incident - self.captured + + @property + def incident_energy(self) -> float: + """Return the whole incident energy. + + Returns + ------- + float + J. + """ + return float(np.sum(self.incident)) + + @property + def launched_energy(self) -> float: + """Return the energy the launch carries into the target. + + Returns + ------- + float + J. + """ + return float(np.sum(self.launched)) + + @property + def captured_energy(self) -> float | None: + """Return the captured energy in the common metric. + + Returns + ------- + float or None + J; None where it is not defined. + """ + return None if self.captured is None else float(np.sum(self.captured)) + + @property + def uncaptured_energy(self) -> float | None: + """Return the uncaptured energy in the common metric, not clipped. + + Returns + ------- + float or None + J; None where it is not defined. + """ + uncaptured = self.uncaptured + return None if uncaptured is None else float(np.sum(uncaptured)) + + @property + def efficiency(self) -> float | None: + """Return the launched energy as a share of the incident energy. + + Returns + ------- + float or None + Dimensionless; None when nothing is incident, where it is undefined. + """ + incident = self.incident_energy + return None if incident == 0.0 else self.launched_energy / incident + + @property + def budget(self) -> float: + """Return the sum of the counted admission items. + + Returns + ------- + float + Dimensionless; at most `model_error` for an admitted coupling. + """ + return float( + sum(item.value for item in self.items if item.counted and item.value) + ) + + def __repr__(self) -> str: + """Return a one-line description, never an array. + + Returns + ------- + str + For example ``CouplingReport(scalar_paraxial, 1 uJ incident, 906 nJ + launched)``. + """ + return line( + "CouplingReport", + [ + ("", self.model), + ("", f"{engineering(self.incident_energy, 'J')} incident"), + ("", f"{engineering(self.launched_energy, 'J')} launched"), + ], + ) + + def _describe(self) -> tuple[str, list[tuple[str, object]]]: + """Return the title and rows of the printed block. + + Returns + ------- + tuple + The title and ``(key, value)`` rows. + """ + efficiency = self.efficiency + rows: list[tuple[str, object]] = [ + ("model", self.model), + ("incident", engineering(self.incident_energy, "J")), + ("launched", engineering(self.launched_energy, "J")), + ( + "launched share", + "undefined (nothing incident)" + if efficiency is None + else f"{efficiency:.4f} of the incident energy", + ), + ] + uncaptured = self.uncaptured_energy + rows.append( + ( + "uncaptured", + engineering(uncaptured, "J") + if uncaptured is not None + else "not defined", + ) + ) + if self.model_error is not None: + rows.append( + ("budget", f"{self.budget:.3e} of model_error {self.model_error:g}") + ) + for item in self.items: + value = "n/a" if item.value is None else f"{item.value:.3e}" + rows.append((item.name, f"{value} ({item.scope}) {item.note}".rstrip())) + rows.append(("exterior", self.exterior)) + return "CouplingReport: a field coupled into an input port", rows + + +@dataclass(frozen=True, eq=False) +class CouplingResult: + """A launch bound to its port, and the account of how it was computed. + + Attributes + ---------- + launch : solprop.coupling.port.CouplingLaunch + What :meth:`solprop.driver.simulation.Simulation.with_input` takes. + report : CouplingReport + The energies, the admission budget and the provenance. + """ + + launch: CouplingLaunch + report: CouplingReport + + +def couple( + plane: FieldPlane, port: InputPort, *, model: CouplingModel +) -> CouplingResult: + r"""Couple a field plane into an input port under a declared model. + + SEP 31 section 4.3. The plane must already be on the port's temporal lattice + (:func:`~solprop.coupling.remap.regrid` moves it), at an identically oriented + plane, and inside the port's active band. Every pair the model is not + qualified for is refused by name; nothing restores energy the target does not + take, and nothing is rescaled. + + ``same_basis`` + Exact recovery: a copy for an identical basis; a declared + :class:`~solprop.coupling.models.BasisChange`; or, for a general modal + plane carrying E/H at the port's own nodes, :math:`a = F^{-1}B`. + ``scalar_paraxial`` + A radial or Cartesian plane into a capillary port, a capillary plane into + another capillary (any radii and mode sets), or a weakly guiding + step-index fibre's plane into another's, within the recorded envelopes. + ``vector_paraxial`` + A radial or Cartesian plane, or an ordinary modal plane with its profiles, + into a general modal guide that carries E/H at fixed nodes, with the + forward closure :math:`H_t = Y_0\hat z\times E_t`. + + Parameters + ---------- + plane : solprop.coupling.plane.FieldPlane + The source plane. + port : solprop.coupling.port.InputPort + The receiving port. + model : solprop.coupling.models.CouplingModel + The model and its budget. + + Returns + ------- + CouplingResult + The launch, bound to `port`, and its report. + + Raises + ------ + CouplingError + If the port does not admit the model, the lattice, orientation or band + differ, the pair is not one the model is qualified for, or an admission + item or the total budget is exceeded. + """ + if model.kind not in port.models: + msg = f"this port admits {port.models}, not {model.kind}" + raise CouplingError(msg) + _check_common(plane, port) + if model.kind == "same_basis": + samples, report = _same_basis(plane, port, model) + elif model.kind == "scalar_paraxial": + samples, report = _scalar_paraxial(plane, port, model) + else: + samples, report = _vector_paraxial(plane, port, model) + provenance = { + "plane": plane.provenance.record(), + "reference": plane.reference.record(), + } + launch = CouplingLaunch( + port, samples, plane.reference.minus(port.frame), model.kind, provenance + ) + return CouplingResult(launch, report) + + +def _check_common(plane: FieldPlane, port: InputPort) -> None: + """Refuse a plane on another lattice, orientation or outside the port's band. + + Parameters + ---------- + plane : solprop.coupling.plane.FieldPlane + The source plane. + port : solprop.coupling.port.InputPort + The receiving port. + + Raises + ------ + CouplingError + Naming the disagreement. + """ + if lattice_identity(plane.grid) != port.lattice or not np.array_equal( + plane.grid.omega, port.grid.omega + ): + msg = "the plane's temporal lattice is not the port's: regrid it first" + raise CouplingError(msg) + if not plane.geometry.oriented_as(port.geometry): + msg = "the plane and the entrance are oriented differently" + raise CouplingError(msg) + outside = occupied_outside(plane.samples, port.grid) + floor = (plane.grid.n_omega * float(np.finfo(float).eps)) ** 2 + if outside > floor: + msg = ( + f"{outside:.3e} of the plane lies outside the port's active band, which " + "the receiving run would remove: regrid with crop_band=True to remove " + "it explicitly" + ) + raise CouplingError(msg) + + +def _report( # noqa: PLR0913 - the account's terms, each named at the call + plane: FieldPlane, + port: InputPort, + model: CouplingModel, + *, + index: Float[np.ndarray, " n_omega"], + incident: Float[np.ndarray, " n_omega"], + launched: Float[np.ndarray, " n_omega"], + captured: Float[np.ndarray, " n_omega"] | None, + captured_reason: str = "", + items: tuple[AdmissionItem, ...] = (), +) -> CouplingReport: + """Assemble a report, refusing a coupling whose budget is exceeded. + + Parameters + ---------- + plane : solprop.coupling.plane.FieldPlane + The source plane. + port : solprop.coupling.port.InputPort + The receiving port. + model : solprop.coupling.models.CouplingModel + The model. + index : numpy.ndarray + The common measure's reference index per bin. + incident, launched : numpy.ndarray + Per-bin energies, J. + captured : numpy.ndarray or None + Per-bin captured energy in the common metric, J. + captured_reason : str, optional + Why `captured` is None. + items : tuple of AdmissionItem, optional + The admission terms. + + Returns + ------- + CouplingReport + The report. + + Raises + ------ + CouplingError + If an item exceeds its own cap or the counted items exceed `model_error`. + """ + report = CouplingReport( + model.kind, + model.model_error, + index, + incident, + launched, + captured, + captured_reason, + items, + plane.exterior, + plane.certificate, + plane.provenance.record(), + port.identity, + ) + failed = [item for item in items if not item.passed] + if failed: + named = ", ".join( + f"{item.name} {cast('float', item.value):.3e} > {item.allowance:.3e}" + f" ({item.note})" + for item in failed + ) + msg = f"{model.kind} is not admitted for this pair: {named}" + raise CouplingError(msg) + if model.model_error is not None and report.budget > model.model_error: + terms = ", ".join( + f"{item.name} {item.value:.3e}" + for item in items + if item.counted and item.value + ) + msg = ( + f"{model.kind}'s defects sum to {report.budget:.3e}, more than its " + f"model_error {model.model_error:g}: {terms}" + ) + raise CouplingError(msg) + return report + + +def _bin_weights(port: InputPort) -> Float[np.ndarray, " n_omega"]: + """Return the energy weight of each stored bin of the port's lattice. + + Parameters + ---------- + port : solprop.coupling.port.InputPort + The port. + + Returns + ------- + numpy.ndarray + :func:`solprop.fourier.energy_weights`, s per bin. + """ + grid = port.grid + return fourier.energy_weights(grid.n_omega, grid.dt, kind=grid.kind) + + +def _occupied(energy: Float[np.ndarray, " n_omega"]) -> np.ndarray: + """Return the bins that carry energy above rounding of the peak. + + Parameters + ---------- + energy : numpy.ndarray + Per-bin energy, shape ``(n_omega,)``. + + Returns + ------- + numpy.ndarray + Boolean mask, shape ``(n_omega,)``. + """ + peak = float(np.max(energy)) if energy.size else 0.0 + return energy > _OCCUPIED * peak if peak > 0.0 else np.zeros(energy.shape, bool) + + +def _weighted( + defect: Float[np.ndarray, " n_omega"], energy: Float[np.ndarray, " n_omega"] +) -> float: + """Return a per-bin relative defect weighted by energy: a bound on the total. + + Parameters + ---------- + defect : numpy.ndarray + Relative defect per bin. + energy : numpy.ndarray + Energy per bin. + + Returns + ------- + float + ``sum(defect energy) / sum(energy)``; zero for a zero field. + """ + total = float(np.sum(energy)) + return 0.0 if total == 0.0 else float(np.sum(np.abs(defect) * energy) / total) + + +def _metric_matrices( + metric: PlaneMetric, n_modes: int, n_omega: int +) -> Complex[np.ndarray, "n_omega n_modes n_modes"]: + """Return a modal metric as one Hermitian matrix per bin. + + Parameters + ---------- + metric : solprop.coupling.plane.PlaneMetric + An ``identity`` or ``dense`` metric. + n_modes : int + The number of columns. + n_omega : int + The number of bins. + + Returns + ------- + numpy.ndarray + Shape ``(n_omega, n_modes, n_modes)``, optics convention. + + Raises + ------ + CouplingError + For a spatial or unknown metric. + """ + if metric.kind == "identity": + return np.broadcast_to( + np.eye(n_modes, dtype=np.complex128), (n_omega, n_modes, n_modes) + ) + if metric.kind == "dense": + return np.moveaxis(cast("np.ndarray", metric.matrix), 2, 0) + msg = f"a modal metric is identity or dense, got {metric.kind}" + raise CouplingError(msg) + + +def _whitened_extremes( + form: Inexact[np.ndarray, "n n"], metric: Inexact[np.ndarray, "n n"] +) -> tuple[float, float]: + """Return the extreme generalised eigenvalues of ``form`` against ``metric``. + + Parameters + ---------- + form : numpy.ndarray + Hermitian quadratic form. + metric : numpy.ndarray + Hermitian positive-definite metric. + + Returns + ------- + tuple of float + The smallest and largest eigenvalue of ``metric^{-1/2} form metric^{-1/2}``. + + Raises + ------ + CouplingError + If the metric is not positive definite. + """ + try: + factor = np.linalg.cholesky(0.5 * (metric + metric.conj().T)) + except np.linalg.LinAlgError as error: + msg = "a native metric is not positive definite: it cannot whiten a map" + raise CouplingError(msg) from error + left = np.linalg.solve(factor, 0.5 * (form + form.conj().T)) + whitened = np.linalg.solve(factor, left.conj().T).conj().T + values = np.linalg.eigvalsh(0.5 * (whitened + whitened.conj().T)) + return float(values[0]), float(values[-1]) + + +# --------------------------------------------------------------------- same basis + + +def _same_basis( + plane: FieldPlane, port: InputPort, model: CouplingModel +) -> tuple[Complex[np.ndarray, "n_modes n_pol n_omega"], CouplingReport]: + """Recover a plane's coordinates exactly in the port's basis. + + Parameters + ---------- + plane : solprop.coupling.plane.FieldPlane + The source plane. + port : solprop.coupling.port.InputPort + The receiving port. + model : solprop.coupling.models.CouplingModel + The ``same_basis`` model, with its optional basis change. + + Returns + ------- + tuple + The target samples and the report. + + Raises + ------ + CouplingError + If the bases differ with no declared change and no common E/H nodes, or a + declared change does not preserve energy, or the recovered field is not in + the port's span. + """ + incident = plane.spectral_energy() + if model.basis_change is not None: + samples = _declared_change(plane, port, model) + note = "declared basis change" + elif plane.kind == port.route and plane.basis.identity == port.basis.identity: + if plane.samples.shape != port.shape: + msg = ( + f"the plane's shape {plane.samples.shape} is not the port's " + f"{port.shape}" + ) + raise CouplingError(msg) + check_same_medium(plane.metric, port.metric) + samples = np.array(plane.samples) + note = "identical basis: exact copy" + elif plane.kind == "general_modal" and port.route == "general_modal": + samples = _flux_recovery(plane, port) + note = "F^-1 B recovery at common nodes" + else: + msg = ( + f"a {plane.kind} plane of basis {plane.basis.identity[:12]} is not the " + f"{port.route} port's basis {port.basis.identity[:12]}: a different basis " + "is a coupling, not a relaunch; declare a BasisChange or choose an " + "approximate model" + ) + raise CouplingError(msg) + launched = port.metric.spectral_energy(samples, port.grid) + return samples, _report( + plane, + port, + model, + index=np.zeros(port.grid.n_omega) + if port.reference_index is None + else np.asarray(port.reference_index), + incident=incident, + launched=launched, + captured=launched, + items=(AdmissionItem("numerical", None, None, False, "map", note),), + ) + + +def _declared_change( + plane: FieldPlane, port: InputPort, model: CouplingModel +) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: + """Apply a declared basis change after checking it preserves energy. + + Parameters + ---------- + plane : solprop.coupling.plane.FieldPlane + The source plane, modal. + port : solprop.coupling.port.InputPort + The receiving port, modal. + model : solprop.coupling.models.CouplingModel + The model carrying the change. + + Returns + ------- + numpy.ndarray + The target samples, shape ``port.shape``. + + Raises + ------ + CouplingError + If the change names other bases, has the wrong shape, or does not map the + plane's metric onto the port's. + """ + change = model.basis_change + assert change is not None + if change.source != plane.basis.identity or change.target != port.basis.identity: + msg = "the basis change was declared for another pair of bases" + raise CouplingError(msg) + n_target, n_pol, n_omega = port.shape + n_source = plane.samples.shape[0] + matrices = change.at(n_omega) + if matrices.shape[1:] != (n_target, n_source) or plane.samples.shape[1] != n_pol: + msg = ( + f"the basis change maps {matrices.shape[2]} columns to " + f"{matrices.shape[1]}, " + f"not the plane's {n_source} to the port's {n_target}" + ) + raise CouplingError(msg) + source = _metric_matrices(plane.metric, n_source, n_omega) + target = _metric_matrices(port.metric, n_target, n_omega) + pulled = np.einsum("wtm,wtu,wun->wmn", matrices.conj(), target, matrices) + scale = np.max(np.abs(source), axis=(1, 2)) + defect = np.max(np.abs(pulled - source), axis=(1, 2)) + if np.any(defect > METRIC_TOLERANCE * np.maximum(scale, 1e-300)): + msg = ( + f"the declared basis change does not preserve energy (largest relative " + f"defect {float(np.max(defect / np.maximum(scale, 1e-300))):.3e}): it is " + "not a relabelling of the same field" + ) + raise CouplingError(msg) + return np.einsum("wtm,mpw->tpw", matrices, plane.samples) + + +def _flux_recovery( + plane: FieldPlane, port: InputPort +) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: + r"""Recover a general modal plane's coordinates in the port's basis by F. + + :math:`a = F^{-1}B` with + :math:`B_m = \frac14\int(\mathbf E\times\mathbf h_m^* + + \mathbf e_m^*\times\mathbf H)\cdot\hat z\,dA` on the common nodes, using the + plane's own E and H. The field must lie in the port's span: its recovered + energy agrees with the plane's to the port's own metric defect, or it is + refused. + + Parameters + ---------- + plane : solprop.coupling.plane.FieldPlane + A general modal plane read with its source simulation (``nodal`` E and H). + port : solprop.coupling.port.InputPort + A general modal port carrying E/H at fixed nodes. + + Returns + ------- + numpy.ndarray + The target samples, shape ``port.shape``. + + Raises + ------ + CouplingError + If either side lacks E/H, the nodes differ, or the field is not in the span. + """ + nodal = plane.nodal + if nodal is None or nodal.magnetic is None: + msg = "an F^-1 B recovery needs the plane's E and H: read it with source=" + raise CouplingError(msg) + if port.electric is None or port.magnetic is None or port.nodes is None: + msg = "an F^-1 B recovery needs the port's E/H at fixed nodes" + raise CouplingError(msg) + if nodal.nodes.shape != port.nodes.shape or not np.allclose( + nodal.nodes, + port.nodes, + rtol=0.0, + atol=1e-12 * float(np.max(np.abs(port.nodes))), + ): + msg = "the plane's nodes are not the port's: F^-1 B needs a common quadrature" + raise CouplingError(msg) + weights = cast("np.ndarray", port.node_weights) + electric, magnetic = port.electric, port.magnetic + flux = _flux_matrix(electric, magnetic, weights) + pairing = _pairing(nodal.electric, nodal.magnetic, electric, magnetic, weights) + coordinates = np.linalg.solve(flux, pairing.T[..., None])[..., 0].T + samples = coordinates[:, None, :] + recovered = port.metric.spectral_energy(samples, port.grid) + original = plane.spectral_energy() + q_matrices = _metric_matrices(port.metric, port.shape[0], port.grid.n_omega) + defect = 0.0 + for k in np.flatnonzero(_occupied(original)): + low, high = _whitened_extremes(flux[k], q_matrices[k]) + defect = max(defect, abs(low - 1.0), abs(high - 1.0)) + tolerance = defect + 1e-10 + mismatch = abs(float(np.sum(recovered)) - float(np.sum(original))) + if mismatch > tolerance * max(float(np.sum(original)), 1e-300): + msg = ( + f"the recovered energy differs from the plane's by " + f"{mismatch / float(np.sum(original)):.3e}, beyond the port's metric " + f"defect {defect:.3e}: the field is not in the port's span" + ) + raise CouplingError(msg) + return samples + + +def _flux_matrix( + electric: Complex[np.ndarray, "n_nodes 3 n_modes n_omega"], + magnetic: Complex[np.ndarray, "n_nodes 3 n_modes n_omega"], + weights: Float[np.ndarray, " n_nodes"], +) -> Complex[np.ndarray, "n_omega n_modes n_modes"]: + r"""Return the flux pairing :math:`F_{mn}` of a basis at its nodes, per bin. + + :math:`F_{mn} = \frac14\sum_q w_q[(\mathbf e_n\times\mathbf h_m^*) + + (\mathbf e_m^*\times\mathbf h_n)]_z`; conjugating every field (optics against + internal convention) conjugates F, so the formula holds in either. + + Parameters + ---------- + electric, magnetic : numpy.ndarray + Basis E and H per √W, shape ``(n_nodes, 3, n_modes, n_omega)``. + weights : numpy.ndarray + Node area weights, m^2. + + Returns + ------- + numpy.ndarray + Shape ``(n_omega, n_modes, n_modes)``, dimensionless. + """ + ex, ey = electric[:, 0], electric[:, 1] + hx, hy = magnetic[:, 0], magnetic[:, 1] + first = np.einsum("q,qnw,qmw->wmn", weights, ex, hy.conj()) - np.einsum( + "q,qnw,qmw->wmn", weights, ey, hx.conj() + ) + second = np.einsum("q,qmw,qnw->wmn", weights, ex.conj(), hy) - np.einsum( + "q,qmw,qnw->wmn", weights, ey.conj(), hx + ) + return 0.25 * (first + second) + + +def _pairing( + field_e: Complex[np.ndarray, "n_nodes 3 n_omega"], + field_h: Complex[np.ndarray, "n_nodes 3 n_omega"], + electric: Complex[np.ndarray, "n_nodes 3 n_modes n_omega"], + magnetic: Complex[np.ndarray, "n_nodes 3 n_modes n_omega"], + weights: Float[np.ndarray, " n_nodes"], +) -> Complex[np.ndarray, "n_modes n_omega"]: + r"""Return the flux pairing of a field against each basis member, per bin. + + :math:`B_m = \frac14\sum_q w_q[\mathbf E\times\mathbf h_m^* + + \mathbf e_m^*\times\mathbf H]_z`. + + Parameters + ---------- + field_e, field_h : numpy.ndarray + The field's E (V/m) and H (A/m) spectra at the nodes, shape + ``(n_nodes, 3, n_omega)``. + electric, magnetic : numpy.ndarray + Basis E and H per √W, shape ``(n_nodes, 3, n_modes, n_omega)``. + weights : numpy.ndarray + Node area weights, m^2. + + Returns + ------- + numpy.ndarray + In √W, shape ``(n_modes, n_omega)``. + """ + first = np.einsum("q,qw,qmw->mw", weights, field_e[:, 0], magnetic[:, 1].conj()) + first -= np.einsum("q,qw,qmw->mw", weights, field_e[:, 1], magnetic[:, 0].conj()) + second = np.einsum("q,qmw,qw->mw", weights, electric[:, 0].conj(), field_h[:, 1]) + second -= np.einsum("q,qmw,qw->mw", weights, electric[:, 1].conj(), field_h[:, 0]) + return 0.25 * (first + second) + + +# ---------------------------------------------------------------- scalar paraxial + + +def target_columns( + profiles: ScalarModeBasis, space: RadialGrid | FreeGrid +) -> Complex[np.ndarray, "n_modes n_columns"]: + r"""Return scalar profiles in a spatial grid's own column representation. + + The columns a profile centred on the grid's axis would have if it were that + grid's field: on a radial grid its Hankel transform at the grid's + :math:`k_r`; on a Cartesian grid its Fourier transform + :math:`2\pi\tilde v(|\mathbf k|)` at the grid's :math:`\mathbf k`, with the + raw DFT's origin phase :math:`e^{i\mathbf k\cdot\mathbf x_0}/(\Delta x\Delta y)`. + Both are the continuous transform, in closed form, so a profile's kink at the + core radius is not sampled. Then + :math:`\int v^*f\,dA = \sum_j w_j\,c_j^*s_j` with the grid's spectral weights. + + Parameters + ---------- + profiles : solprop.coupling.models.ScalarModeBasis + The profiles. + space : solprop.grid.RadialGrid or solprop.grid.FreeGrid + The grid. + + Returns + ------- + numpy.ndarray + Shape ``(n_modes, n_columns)``: in V m per (V/m) on a radial grid, V/m per + (V/m) on a Cartesian one, in the internal convention of the grid's forward + transform (the optics-convention columns of a field are conjugated in the + transverse phase as well). + """ + if isinstance(space, RadialGrid): + return profiles.hankel(np.asarray(space.k_r)).astype(np.complex128) + kx = np.tile(space.k_x, space.n_y) + ky = np.repeat(space.k_y, space.n_x) + transform = 2.0 * np.pi * profiles.hankel(np.hypot(kx, ky)) + area = float(space.spatial_weights[0]) + origin = np.exp(1j * (kx * space.x[0] + ky * space.y[0])) / area + return transform * origin[None, :] + + +def _channels( + profiles: ScalarModeBasis, n_pol: int +) -> tuple[Float[np.ndarray, "n_channels 2"], Int[np.ndarray, " n_channels"]]: + """Return each channel's Jones vector and mode index, channel order m-major. + + Parameters + ---------- + profiles : solprop.coupling.models.ScalarModeBasis + The profiles. + n_pol : int + 1: each mode with its own polarisation; 2: each mode in x and in y. + + Returns + ------- + tuple of numpy.ndarray + Jones vectors, shape ``(n_channels, 2)``, and the mode of each channel. + """ + if n_pol == 1: + return np.asarray(profiles.jones), np.arange(profiles.n_modes) + jones = np.tile(np.eye(2), (profiles.n_modes, 1)) + return jones, np.repeat(np.arange(profiles.n_modes), 2) + + +@dataclass +class _Fit: + """The per-bin quantities of a scalar least-squares fit. + + Attributes + ---------- + overlap : numpy.ndarray + ``S^H M f`` per bin, shape ``(n_omega, n_channels)``. + norm : numpy.ndarray + ``||f||_M^2`` per bin, shape ``(n_omega,)``. + gram : numpy.ndarray + ``G`` per bin, shape ``(n_omega, n_channels, n_channels)``. + items : list of AdmissionItem + The admission items the fit measured. + gain_form : numpy.ndarray or None + ``S^H M Q_A^-1 M S`` per bin, from which the passivity certificate is + whitened. + """ + + overlap: np.ndarray + norm: np.ndarray + gram: np.ndarray + items: list[AdmissionItem] = field(default_factory=list) + gain_form: np.ndarray | None = None + + +def _scalar_paraxial( + plane: FieldPlane, port: InputPort, model: CouplingModel +) -> tuple[Complex[np.ndarray, "n_modes n_pol n_omega"], CouplingReport]: + """Fit a scalar field onto a port's scalar profiles in the common measure. + + Parameters + ---------- + plane : solprop.coupling.plane.FieldPlane + The source plane. + port : solprop.coupling.port.InputPort + The receiving port, carrying scalar profiles and a reference index. + model : solprop.coupling.models.CouplingModel + The model and its budget. + + Returns + ------- + tuple + The target samples and the report. + + Raises + ------ + CouplingError + For an unqualified pair or an exceeded budget. + """ + profiles = port.profiles + if profiles is None or port.reference_index is None: + msg = ( + f"this {port.route} port has no scalar profiles: scalar_paraxial couples " + "into a capillary's HE1m modes or a weakly guiding step-index fibre's" + ) + raise CouplingError(msg) + epsilon = cast("float", model.model_error) + index = np.asarray(port.reference_index) + bins = _bin_weights(port) + n_modes, n_pol, n_omega = port.shape + jones, mode_of = _channels(profiles, n_pol) + if plane.kind in ("radial", "cartesian"): + if profiles.family != "capillary": + msg = ( + "scalar_paraxial is qualified for a beam into a capillary; a beam into " + f"a {profiles.family} fibre has no recorded envelope" + ) + raise CouplingError(msg) + fit, native, envelope_parameters = _spatial_fit( + plane, profiles, jones, mode_of, index, epsilon + ) + elif plane.kind == "modal": + source = plane.profiles + if source is None: + msg = ( + "this modal plane carries no mode profiles: read it with " + "field_plane(run, source=simulation)" + ) + raise CouplingError(msg) + if source.family != profiles.family: + msg = ( + f"scalar_paraxial is not qualified for a {source.family} plane into a " + f"{profiles.family} port: no recorded envelope covers the pair" + ) + raise CouplingError(msg) + fit, native, envelope_parameters = _modal_fit( + plane, + source, + profiles, + jones=jones, + mode_of=mode_of, + index=index, + epsilon=epsilon, + ) + else: + msg = ( + "scalar_paraxial takes a radial, Cartesian or modal plane, not " + f"{plane.kind}" + ) + raise CouplingError(msg) + coordinates = np.zeros((n_omega, jones.shape[0]), dtype=np.complex128) + occupied = _occupied(fit.norm) + for k in np.flatnonzero(occupied): + coordinates[k] = np.linalg.solve(fit.gram[k], fit.overlap[k]) + captured = bins * np.real(np.einsum("wc,wc->w", fit.overlap.conj(), coordinates)) + # The fit is formed in the internal convention, where a Cartesian plane's + # transverse DFT carries its origin phase; the launch is stored in the optics one. + samples = fourier.spectrum_to_optics( + coordinates.T.reshape(n_modes, n_pol, n_omega), kind=port.grid.kind + ) + launched = port.metric.spectral_energy(samples, port.grid) + incident = bins * fit.norm + items = list(fit.items) + items.append(_target_metric(profiles, mode_of, index, occupied, epsilon)) + items.append( + _target_angular_scalar( + profiles, (jones, mode_of), index, port, occupied, epsilon + ) + ) + items.append(_source_metric_item(incident, native, epsilon)) + items.extend( + _contrast_items(profiles, plane, index, occupied, epsilon, envelope_parameters) + ) + items.append(_gain_item(_scalar_gain(fit, occupied), items)) + return samples, _report( + plane, + port, + model, + index=index, + incident=incident, + launched=launched, + captured=captured, + items=tuple(items), + ) + + +def _kappa_star( + index: Float[np.ndarray, " n_omega"], + omega: Float[np.ndarray, " n_omega"], + epsilon: float, +) -> Float[np.ndarray, " n_omega"]: + r"""Return the admitted cone's transverse wavenumber at each bin. + + :math:`\kappa_* = n_{\rm ref}(\omega/c)\sin\theta_*`, with + :math:`1 - \cos\theta_* = \epsilon_m/8`. + + Parameters + ---------- + index : numpy.ndarray + Reference index per bin. + omega : numpy.ndarray + Angular frequency per bin, rad/s. + epsilon : float + The model budget. + + Returns + ------- + numpy.ndarray + Transverse wavenumber in rad/m, zero where the index or the frequency is + not positive. + """ + sine = math.sin(math.acos(1.0 - epsilon / 8.0)) + return np.where((index > 0) & (omega > 0), index * omega / C * sine, 0.0) + + +def _spatial_fit( + plane: FieldPlane, + profiles: ScalarModeBasis, + jones: Float[np.ndarray, "n_channels 2"], + mode_of: np.ndarray, + index: Float[np.ndarray, " n_omega"], + epsilon: float, +) -> tuple[_Fit, Float[np.ndarray, " n_omega"], dict[str, float]]: + """Return the scalar fit of a spatial plane onto capillary profiles. + + Parameters + ---------- + plane : solprop.coupling.plane.FieldPlane + A radial or Cartesian plane. + profiles : solprop.coupling.models.ScalarModeBasis + The target profiles. + jones : numpy.ndarray + Each target channel's Jones vector. + mode_of : numpy.ndarray + Each target channel's mode. + index : numpy.ndarray + Reference index per bin. + epsilon : float + The model budget. + + Returns + ------- + tuple + The fit, the plane's native energy per bin (J) and the envelope's range + parameters. + + Raises + ------ + CouplingError + If the plane occupies nonpropagating columns, or its metric is not an + admittance. + """ + space = cast("RadialGrid | FreeGrid", plane.basis.space) + metric = plane.metric + if metric.kind != "admittance": + msg = f"a spatial plane's metric is an admittance, got {metric.kind}" + raise CouplingError(msg) + weights = np.asarray(space.spectral_weights) + columns = target_columns(profiles, space) + samples = fourier.spectrum_to_internal(plane.samples, kind=plane.grid.kind) + n_pol_source = samples.shape[1] + # The field's projection onto each target channel's polarisation, per column. + projected = np.einsum( + "cp,jpw->cjw", jones[:, :n_pol_source].astype(complex), samples + ) + rows = columns[mode_of] + overlap_raw = np.einsum("j,cj,cjw->wc", weights, rows.conj(), projected) + # The profiles are orthonormal over the whole plane, so their exact Gram is the + # identity (times the Jones overlaps). It, and not the Gram of their transforms + # truncated to the plane's window, is the least-squares matrix: the overlaps are + # exact for a field the plane resolves, whatever of a profile lies beyond the + # window, and then the orthogonal-residual identity holds in the continuum. + exact = profiles.gram()[np.ix_(mode_of, mode_of)] * (jones @ jones.T) + effective = profiles.effective_index[mode_of] + root = np.sqrt(np.where(effective > 0, effective, 1.0)) + density = np.einsum("j,jpw->w", weights, np.abs(samples) ** 2) + norm = _SCALE * index * density + overlap = index[:, None] * math.sqrt(_SCALE) * overlap_raw / root.T + gram = ( + index[:, None, None] * exact[None] / (root.T[:, :, None] * root.T[:, None, :]) + ) + native = plane.spectral_energy() + fit = _Fit(overlap, norm, gram) + occupied = _occupied(norm) + # Nonpropagating content carries no energy and no coupling model. + admittance = np.real(cast("np.ndarray", metric.admittance)) + column_energy = weights[:, None] * np.sum(np.abs(samples) ** 2, axis=1) + _refuse_nonpropagating(column_energy, admittance, occupied) + fit.items.append(_spatial_angular(plane, column_energy, index, epsilon)) + # The overlaps are exact for a field the plane resolves: what the plane holds + # near its spectral edge bounds what it may miss beyond it. + edge = _spectral_edge_share(space, column_energy) + analytic = plane.analytic + domain = 0.0 if analytic is None else analytic.error + fit.items.append( + AdmissionItem( + "numerical", + edge + domain, + None, + True, + "field", + f"source content at the plane's spectral edge {edge:.1e}, " + f"source domain/resolution {domain:.1e}", + ) + ) + # The passivity form: C Q_A^-1 C^H = G^-1 X G^-1 with X weighted by 1/Re Y. + live = admittance > 0.0 + inverse = np.where(live, 1.0 / np.where(live, admittance, 1.0), 0.0) + cross = np.einsum("j,cj,dj,jw->wcd", weights, rows.conj(), rows, inverse) + cross = cross * (jones @ jones.T)[None] + fit.gain_form = ( + index[:, None, None] ** 2 * cross / (root.T[:, :, None] * root.T[:, None, :]) + ) + parameters = {"modes": float(profiles.n_modes), "radius_ratio": 1.0} + return fit, native, parameters + + +def _spectral_edge_share( + space: RadialGrid | FreeGrid, column_energy: Float[np.ndarray, "n_columns n_omega"] +) -> float: + """Return a spatial field's energy share in the outer fifth of its spectral window. + + Parameters + ---------- + space : solprop.grid.RadialGrid or solprop.grid.FreeGrid + The grid. + column_energy : numpy.ndarray + Each column's weighted squared samples per bin. + + Returns + ------- + float + Dimensionless; columns with a transverse wavenumber above 0.8 of the + window's largest along an axis. + """ + if isinstance(space, RadialGrid): + k_r = np.asarray(space.k_r) + outer = k_r > 0.8 * float(np.max(k_r)) + else: + kx = np.abs(np.tile(space.k_x, space.n_y)) + ky = np.abs(np.repeat(space.k_y, space.n_x)) + outer = (kx > 0.8 * float(np.max(kx))) | (ky > 0.8 * float(np.max(ky))) + total = float(np.sum(column_energy)) + return 0.0 if total == 0.0 else float(np.sum(column_energy[outer]) / total) + + +def _bin_like(plane: FieldPlane) -> Float[np.ndarray, " n_omega"]: + """Return the plane's bin energy weights. + + Parameters + ---------- + plane : solprop.coupling.plane.FieldPlane + The plane. + + Returns + ------- + numpy.ndarray + :func:`solprop.fourier.energy_weights` of its grid. + """ + grid = plane.grid + return fourier.energy_weights(grid.n_omega, grid.dt, kind=grid.kind) + + +def _modal_fit( # noqa: PLR0913 - the plane, both profile sets and the measure + plane: FieldPlane, + source: ScalarModeBasis, + profiles: ScalarModeBasis, + *, + jones: Float[np.ndarray, "n_channels 2"], + mode_of: np.ndarray, + index: Float[np.ndarray, " n_omega"], + epsilon: float, +) -> tuple[_Fit, Float[np.ndarray, " n_omega"], dict[str, float]]: + """Return the scalar fit of a modal plane's synthesised field onto target profiles. + + Parameters + ---------- + plane : solprop.coupling.plane.FieldPlane + An ordinary modal plane with its profiles. + source : solprop.coupling.models.ScalarModeBasis + Its profiles. + profiles : solprop.coupling.models.ScalarModeBasis + The target profiles. + jones : numpy.ndarray + Each target channel's Jones vector. + mode_of : numpy.ndarray + Each target channel's mode. + index : numpy.ndarray + Reference index per bin. + epsilon : float + The model budget. + + Returns + ------- + tuple + The fit, the plane's native energy per bin (J) and the envelope's range + parameters. + """ + source_jones, source_mode = _channels(source, plane.samples.shape[1]) + overlap_matrix, gram_source, _, error = _profile_overlaps(source, profiles) + pol = jones @ source_jones.T + cross = overlap_matrix[np.ix_(mode_of, source_mode)] * pol + k_source = gram_source[np.ix_(source_mode, source_mode)] * ( + source_jones @ source_jones.T + ) + n_source = source.effective_index[source_mode] + n_target = profiles.effective_index[mode_of] + root_source = np.sqrt(np.where(n_source > 0, n_source, 1.0)).T + root_target = np.sqrt(np.where(n_target > 0, n_target, 1.0)).T + coefficients = ( + fourier.spectrum_to_internal(plane.samples, kind=plane.grid.kind) + .reshape(-1, plane.grid.n_omega) + .T + ) + scaled = coefficients / root_source + overlap = index[:, None] * np.einsum("cs,ws->wc", cross, scaled) / root_target + norm = index * np.real(np.einsum("ws,st,wt->w", scaled.conj(), k_source, scaled)) + # The closed-form normalisation makes both profile sets orthonormal; their + # quadrature Grams' departure from the identity is part of the numerical item. + exact = profiles.gram()[np.ix_(mode_of, mode_of)] * (jones @ jones.T) + gram = ( + index[:, None, None] + * exact[None] + / (root_target[:, :, None] * root_target[:, None, :]) + ) + fit = _Fit(overlap, norm, gram) + fit.items.append( + _modal_angular( + source, + jones=source_jones, + mode_of=source_mode, + scaled=scaled.T, + norm=norm, + index=index, + plane=plane, + epsilon=epsilon, + ) + ) + fit.items.append( + AdmissionItem( + "numerical", error, None, True, "map", "profile overlap quadrature" + ) + ) + # Native source metric is the identity, so C Q_A^-1 C^H = G^-1 R R^H G^-1. + cross_scaled = ( + index[:, None, None] + * cross[None] + / (root_target[:, :, None] * root_source[:, None, :]) + ) + fit.gain_form = np.einsum("wcs,wds->wcd", cross_scaled, cross_scaled.conj()) + parameters = { + "modes": float(max(source.n_modes, profiles.n_modes)), + "radius_ratio": source.radius / profiles.radius, + } + if profiles.family == "step_index": + parameters.update(_step_index_parameters(source, profiles)) + return fit, plane.spectral_energy(), parameters + + +def _profile_overlaps( + source: ScalarModeBasis, target: ScalarModeBasis +) -> tuple[ + Float[np.ndarray, "n_target n_source"], + Float[np.ndarray, "n_source n_source"], + Float[np.ndarray, "n_target n_target"], + float, +]: + """Return the profiles' overlaps on a composite rule, with its error estimate. + + The rule breaks at both core radii and extends to where both sets are + negligible; panels hold at most a quarter period of the most oscillatory + profile. The same overlaps on the rule refined by halving every panel bound the + quadrature error, together with each Gram's departure from the identity the + profiles' closed-form normalisation fixes. + + Parameters + ---------- + source, target : solprop.coupling.models.ScalarModeBasis + The two profile sets. + + Returns + ------- + tuple + The target-by-source overlaps, both Grams and the error estimate. + """ + radii = (source.radius, target.radius) + outer = max(source.outer(), target.outer()) + wavenumber = max( + float(np.max(source.u)) / source.radius, float(np.max(target.u)) / target.radius + ) + panel = 0.5 * math.pi / wavenumber + + def overlaps(step: float) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Return the three overlap matrices on a rule of the given panel length. + + Parameters + ---------- + step : float + Largest panel length, m. + + Returns + ------- + tuple of numpy.ndarray + Target-by-source overlaps and the two Grams. + """ + r, w = radial_rule(radii, outer, panel=step) + vs = source.values(r) + vt = target.values(r) + return (vt * w) @ vs.T, (vs * w) @ vs.T, (vt * w) @ vt.T + + cross, gram_source, gram_target = overlaps(panel) + fine = overlaps(0.5 * panel) + error = max( + float(np.max(np.abs(a - b))) + for a, b in zip((cross, gram_source, gram_target), fine, strict=True) + ) + error = max( + error, + float(np.max(np.abs(gram_source - source.gram()))), + float(np.max(np.abs(gram_target - target.gram()))), + ) + return cross, gram_source, gram_target, error + + +def _step_index_parameters( + source: ScalarModeBasis, target: ScalarModeBasis +) -> dict[str, float]: + """Return a step-index pair's envelope parameters at each profile wavelength. + + Parameters + ---------- + source, target : solprop.coupling.models.ScalarModeBasis + The two LP sets. + + Returns + ------- + dict + ``v_source`` and ``v_target``: each fibre's V at its profile wavelength, + from its LP01 U and W. + """ + return { + "v_source": float(math.hypot(source.u[0], source.w[0])), + "v_target": float(math.hypot(target.u[0], target.w[0])), + } + + +def _target_metric( + profiles: ScalarModeBasis, + mode_of: np.ndarray, + index: Float[np.ndarray, " n_omega"], + occupied: np.ndarray, + epsilon: float, +) -> AdmissionItem: + r"""Return the relative defect between the exact G and the target's native Q. + + The profiles are orthonormal, so in true-power coordinates + :math:`G = \operatorname{diag}(n_{\rm ref}/\operatorname{Re}n_{\rm eff})` and + ``Q`` is the identity: the whitened eigenvalues are the ratios. + + Parameters + ---------- + profiles : solprop.coupling.models.ScalarModeBasis + The target profiles. + mode_of : numpy.ndarray + Each channel's mode. + index : numpy.ndarray + Reference index per bin. + occupied : numpy.ndarray + Occupied bins. + epsilon : float + The model budget. + + Returns + ------- + AdmissionItem + ``target metric``, capped at ``epsilon / 8``. + """ + effective = profiles.effective_index[mode_of][:, occupied] + if effective.size == 0: + value = 0.0 + else: + value = float(np.max(np.abs(index[occupied][None, :] / effective - 1.0))) + return AdmissionItem( + "target metric", + value, + epsilon / 8.0, + True, + "map", + "G (n_ref/Re n_eff) against the native identity", + ) + + +def _target_angular_scalar( + profiles: ScalarModeBasis, + channels: tuple[np.ndarray, np.ndarray], + index: Float[np.ndarray, " n_omega"], + port: InputPort, + occupied: np.ndarray, + epsilon: float, +) -> AdmissionItem: + """Return the retained target subspace's angular defect, its worst unit field. + + Parameters + ---------- + profiles : solprop.coupling.models.ScalarModeBasis + The target profiles. + channels : tuple of numpy.ndarray + Each channel's Jones vector and mode (:func:`_channels`). + index : numpy.ndarray + Reference index per bin. + port : solprop.coupling.port.InputPort + The port. + occupied : numpy.ndarray + Occupied bins. + epsilon : float + The model budget. + + Returns + ------- + AdmissionItem + ``angular`` of the target subspace: the largest eigenvalue of + ``(1 - cos theta*) K + (1 - (1 - cos theta*)) T`` against the channel Gram + ``K``, with T the tail Gram. + """ + jones, mode_of = channels + polarisation = jones @ jones.T + gram = profiles.gram()[np.ix_(mode_of, mode_of)] * polarisation + star = _kappa_star(index, port.grid.omega, epsilon) + worst = 0.0 + for k in np.flatnonzero(occupied): + cone = profiles.cone_gram(float(star[k]))[np.ix_(mode_of, mode_of)] + tail = gram - cone * polarisation + defect = (epsilon / 8.0) * gram + (1.0 - epsilon / 8.0) * tail + worst = max(worst, _whitened_extremes(defect, gram)[1]) + return AdmissionItem( + "angular", + worst if np.any(occupied) else 0.0, + None, + True, + "map", + "retained target subspace: (1 - cos theta*) cone + tail", + ) + + +def _source_metric_item( + incident: Float[np.ndarray, " n_omega"], + native: Float[np.ndarray, " n_omega"], + epsilon: float, +) -> AdmissionItem: + """Return the relative defect between the common measure and the source's own. + + Parameters + ---------- + incident : numpy.ndarray + The incident energy per bin in the common measure, J. + native : numpy.ndarray + The plane's energy per bin in its native metric, J. + epsilon : float + The model budget. + + Returns + ------- + AdmissionItem + ``source metric``: ``sum |M_0 - Q_A| / sum Q_A``, capped at ``epsilon/8``. + """ + total = float(np.sum(native)) + value = 0.0 if total == 0.0 else float(np.sum(np.abs(incident - native)) / total) + return AdmissionItem( + "source metric", + value, + epsilon / 8.0, + True, + "field", + "common measure M0 against the plane's native metric", + ) + + +def _contrast_items( + profiles: ScalarModeBasis, + plane: FieldPlane, + index: Float[np.ndarray, " n_omega"], + occupied: np.ndarray, + epsilon: float, + parameters: dict[str, float], +) -> list[AdmissionItem]: + r"""Return the contrast screen and the pair envelope of a scalar coupling. + + A capillary wall is not a weakly guiding dielectric, so the screen does not + apply there; a step-index pair is screened by + :math:`\Delta \le \epsilon_m/8` over both fibres' regions, unless its + recorded envelope covers it. Either family needs its envelope to cover the pair. + + Parameters + ---------- + profiles : solprop.coupling.models.ScalarModeBasis + The target profiles. + plane : solprop.coupling.plane.FieldPlane + The source plane. + index : numpy.ndarray + Reference index per bin. + occupied : numpy.ndarray + Occupied bins. + epsilon : float + The model budget. + parameters : dict + The pair's envelope parameters. + + Returns + ------- + list of AdmissionItem + ``contrast`` and ``envelope``. + + Raises + ------ + CouplingError + If no recorded envelope covers the pair. + """ + envelope: PairEnvelope = ( + CAPILLARY_ENVELOPE if profiles.family == "capillary" else STEP_INDEX_ENVELOPE + ) + if profiles.family == "capillary": + screen = AdmissionItem( + "contrast", + None, + None, + False, + "model", + "not applied: a capillary wall is not a weakly guiding dielectric", + ) + else: + contrast = 0.0 + sets = [profiles] + if plane.profiles is not None: + sets.append(plane.profiles) + for one in sets: + for values in (one.core_index, one.cladding_index): + values = cast("np.ndarray", values) + ratio = np.abs(values[occupied] ** 2 - index[occupied] ** 2) / ( + 2.0 * index[occupied] ** 2 + ) + contrast = max(contrast, float(np.max(ratio)) if ratio.size else 0.0) + parameters["contrast"] = contrast + within = contrast <= epsilon / 8.0 + screen = AdmissionItem( + "contrast", + contrast, + None if not within else epsilon / 8.0, + False, + "model", + "weak-guidance screen Delta" + + ("" if within else f" > model_error/8, replaced by {envelope.identity}"), + ) + outside = envelope.covers(parameters) + if outside is not None: + msg = ( + f"scalar_paraxial is not qualified for this {profiles.family} pair: " + f"{outside}" + ) + raise CouplingError(msg) + return [ + screen, + AdmissionItem( + "envelope", + envelope.bound, + None, + True, + "model", + envelope.identity, + ), + ] + + +def _scalar_gain(fit: _Fit, occupied: np.ndarray) -> float: + """Return the largest apparent gain of a scalar fit's map over occupied bins. + + The largest whitened squared singular value of ``C = G^-1 S^H M`` from the + source's native coordinates to the target's, ``C Q_A^-1 C^H`` against ``Q_B`` + (the identity), minus one. + + Parameters + ---------- + fit : _Fit + The fit, with its ``gain_form``. + occupied : numpy.ndarray + Occupied bins. + + Returns + ------- + float + Dimensionless; negative for a strictly contracting map. + """ + form = cast("np.ndarray", fit.gain_form) + gain = -1.0 + for k in np.flatnonzero(occupied): + inverse = np.linalg.inv(fit.gram[k]) + whitened = inverse @ form[k] @ inverse.conj().T + largest = float(np.linalg.eigvalsh(0.5 * (whitened + whitened.conj().T))[-1]) + gain = max(gain, largest - 1.0) + return gain + + +@dataclass(frozen=True, eq=False) +class _Source: + """How a plane's coordinates make a field at a general guide's nodes. + + Attributes + ---------- + transfer : collections.abc.Callable + ``transfer(k)``: the internal-convention transverse field at the nodes per + unit source coordinate at bin k, shape ``(n_nodes, 2, n_source)``. + coordinates : numpy.ndarray + Internal-convention source coordinates, shape ``(n_source, n_omega)``. + native : numpy.ndarray + Each coordinate's native metric weight, J per squared coordinate before the + bin weight, shape ``(n_source, n_omega)``. + norm : numpy.ndarray + The incident norm in the common measure per bin, before the bin weight. + items : tuple of AdmissionItem + The source's admission items. + """ + + transfer: Callable[[int], np.ndarray] + coordinates: np.ndarray + native: np.ndarray + norm: np.ndarray + items: tuple[AdmissionItem, ...] + + +def _vector_paraxial( + plane: FieldPlane, port: InputPort, model: CouplingModel +) -> tuple[Complex[np.ndarray, "n_modes n_pol n_omega"], CouplingReport]: + r"""Recover a beam's coordinates in a general guide with the flux pairing F. + + The incident E is the plane's field at the port's nodes (a spatial plane's + spectral interpolant, zero beyond its window, or an ordinary modal plane's + synthesis from its profiles); its H is the forward closure + :math:`H_t = Y_0\hat z\times E_t`. Then :math:`a = F^{-1}B`. Everything is formed + in the internal convention, where the port's tables live, and the launch is + converted to the optics convention at the end. + + Parameters + ---------- + plane : solprop.coupling.plane.FieldPlane + The source plane. + port : solprop.coupling.port.InputPort + A general modal port carrying E/H at fixed nodes and its contrast. + model : solprop.coupling.models.CouplingModel + The model and its budget. + + Returns + ------- + tuple + The target samples and the report. + + Raises + ------ + CouplingError + For an unqualified pair or an exceeded budget. + """ + if ( + port.route != "general_modal" + or port.electric is None + or port.magnetic is None + or port.nodes is None + or port.node_weights is None + or port.reference_index is None + ): + msg = ( + "vector_paraxial couples into a general modal guide that carries its E/H " + "at fixed nodes; this port does not" + ) + raise CouplingError(msg) + epsilon = cast("float", model.model_error) + index = np.asarray(port.reference_index) + weights = np.asarray(port.node_weights) + electric = np.conj(port.electric) + magnetic = np.conj(port.magnetic) + admittance = index * EPS0 * C + source = _vector_source(plane, port, index, epsilon) + occupied = _occupied(source.norm) + amplitudes, gain, metric_defect = _vector_coordinates( + source, port, admittance, occupied + ) + samples = fourier.spectrum_to_optics(amplitudes, kind=port.grid.kind)[:, None, :] + contrast_values = ( + np.zeros(0) if port.contrast is None else np.asarray(port.contrast)[occupied] + ) + probes = _probe_bins(occupied, source.norm) + items = [ + *source.items, + AdmissionItem( + "target metric", + metric_defect, + epsilon / 8.0, + True, + "map", + "F at the port's nodes against the native Q", + ), + AdmissionItem( + "contrast", + float(np.max(contrast_values)) if contrast_values.size else 0.0, + epsilon / 8.0, + False, + "model", + "weak-guidance screen Delta over the guide's regions", + ), + _closure_item(electric, magnetic, weights, admittance, probes, epsilon), + _target_angular_vector(electric, weights, port, index, probes, epsilon), + AdmissionItem( + "envelope", + None, + None, + False, + "model", + "no recorded pair envelope: omitted facet reflection is bounded only " + "by the contrast screen and the closure", + ), + ] + items.append(_gain_item(gain, items, "field")) + return samples, _report( + plane, + port, + model, + index=index, + incident=_bin_weights(port) * source.norm, + launched=port.metric.spectral_energy(samples, port.grid), + captured=None, + captured_reason=( + "vector_paraxial recovers coordinates with the flux pairing F; F and the " + "incident measure M0 are not one positive metric, so no orthogonal " + "remainder is a physical loss (section 4.3)" + ), + items=tuple(items), + ) + + +def _vector_coordinates( + source: _Source, + port: InputPort, + admittance: Float[np.ndarray, " n_omega"], + occupied: np.ndarray, +) -> tuple[Complex[np.ndarray, "n_modes n_omega"], float, float]: + r"""Return the recovered coordinates, the map's apparent gain and F's defect. + + At each occupied bin :math:`a = F^{-1}B` with + :math:`B_m = \frac14\sum_q w_q[\mathbf E\times\mathbf h_m^* + + \mathbf e_m^*\times Y_0\hat z\times\mathbf E]_z`, and the whitened certificate + of ``C = F^-1 L`` (``L`` the pairing per unit source coordinate) against the + native metrics. + + Parameters + ---------- + source : _Source + The source's field map. + port : solprop.coupling.port.InputPort + The general modal port. + admittance : numpy.ndarray + :math:`Y_0` per bin, A/V. + occupied : numpy.ndarray + Occupied bins. + + Returns + ------- + tuple + Internal-convention amplitudes, shape ``(n_modes, n_omega)``; the largest + apparent gain; the largest whitened defect of F against the native Q. + """ + weights = np.asarray(port.node_weights) + electric = np.conj(cast("np.ndarray", port.electric)) + magnetic = np.conj(cast("np.ndarray", port.magnetic)) + n_modes = port.shape[0] + flux = _flux_matrix(electric, magnetic, weights) + target_q = _metric_matrices(port.metric, n_modes, port.grid.n_omega) + amplitudes = np.zeros((n_modes, port.grid.n_omega), dtype=np.complex128) + gain = 0.0 + metric_defect = 0.0 + for k in np.flatnonzero(occupied): + # The bracket of B for a unit transverse field along x (last index 0) or y. + hx, hy = magnetic[:, 0, :, k], magnetic[:, 1, :, k] + ex, ey = electric[:, 0, :, k], electric[:, 1, :, k] + bracket = np.stack( + ( + hy.conj() + admittance[k] * ex.conj(), + -hx.conj() + admittance[k] * ey.conj(), + ), + axis=2, + ) + pairing = 0.25 * np.einsum( + "q,qmp,qps->ms", weights, bracket, source.transfer(int(k)) + ) + amplitudes[:, k] = np.linalg.solve(flux[k], pairing @ source.coordinates[:, k]) + # The certificate covers the coordinates this field occupies: a column the + # guide's node rule cannot resolve has an aliased pairing, and a field with + # nothing in it is not mapped through it (section 4.6, a single field's + # certificate is labelled as such). + energy = source.native[:, k] * np.abs(source.coordinates[:, k]) ** 2 + live = (source.native[:, k] > 0.0) & ( + energy > _OCCUPIED * float(np.max(energy)) + ) + mapped = np.linalg.solve(flux[k], pairing[:, live]) + form = (mapped / source.native[live, k][None, :]) @ mapped.conj().T + native_q = np.conj(target_q[k]) + gain = max(gain, _whitened_extremes(form, native_q)[1] - 1.0) + low, high = _whitened_extremes(flux[k], native_q) + metric_defect = max(metric_defect, abs(low - 1.0), abs(high - 1.0)) + return amplitudes, gain, metric_defect + + +def _gain_item( + gain: float, items: list[AdmissionItem], scope: ItemScope = "map" +) -> AdmissionItem: + """Return the passivity item of a map's apparent gain, or refuse it. + + Its excess over one is rounding below :data:`PASSIVITY_ROUNDING`, a model + defect within the model's own metric, angular and closure items, and refused + beyond them. It is never rescaled or clipped. + + Parameters + ---------- + gain : float + The largest whitened squared singular value minus one. + items : list of AdmissionItem + The items already measured. + scope : {'map', 'field'}, optional + Whether the certificate covers the whole map or the coordinates this field + occupies. + + Returns + ------- + AdmissionItem + ``passivity``. + + Raises + ------ + CouplingError + If the gain exceeds the model's own defects. + """ + allowance = sum( + item.value + for item in items + if item.value is not None + and item.name in ("target metric", "source metric", "angular", "closure") + ) + if gain <= PASSIVITY_ROUNDING: + return AdmissionItem("passivity", 0.0, None, True, scope, "passive to rounding") + if gain > allowance: + msg = ( + f"the coupling map has an apparent gain {gain:.3e}, beyond the model's " + f"own defects {allowance:.3e}: refine the planes or refuse the pair" + ) + raise CouplingError(msg) + return AdmissionItem( + "passivity", + gain, + None, + True, + scope, + "apparent gain within the model's defects, reported as a model defect", + ) + + +def _vector_source( + plane: FieldPlane, + port: InputPort, + index: Float[np.ndarray, " n_omega"], + epsilon: float, +) -> _Source: + """Return how a plane's coordinates make a field at a port's nodes. + + Parameters + ---------- + plane : solprop.coupling.plane.FieldPlane + A radial or Cartesian plane, or an ordinary modal plane with profiles. + port : solprop.coupling.port.InputPort + The general modal port. + index : numpy.ndarray + Reference index per bin. + epsilon : float + The model budget. + + Returns + ------- + _Source + The field map, coordinates, native weights, incident norm and items. + + Raises + ------ + CouplingError + For an unsupported plane. + """ + nodes = cast("np.ndarray", port.nodes) + if plane.kind in ("radial", "cartesian"): + return _spatial_source(plane, nodes, index, epsilon) + if plane.kind == "modal" and plane.profiles is not None: + return _modal_source(plane, nodes, index, epsilon) + msg = ( + f"vector_paraxial takes a radial or Cartesian plane, or an ordinary modal " + f"plane with its profiles, not a {plane.kind} plane" + + (" without profiles" if plane.kind == "modal" else "") + ) + raise CouplingError(msg) + + +def _spatial_angular( + plane: FieldPlane, + column_energy: Float[np.ndarray, "n_columns n_omega"], + index: Float[np.ndarray, " n_omega"], + epsilon: float, +) -> AdmissionItem: + """Return a spatial plane's angular defect, energy-weighted (section 4.6). + + Parameters + ---------- + plane : solprop.coupling.plane.FieldPlane + A radial or Cartesian plane. + column_energy : numpy.ndarray + Each column's weighted squared samples per bin. + index : numpy.ndarray + Reference index per bin. + epsilon : float + The model budget. + + Returns + ------- + AdmissionItem + ``angular`` of the incident field. + """ + space = cast("RadialGrid | FreeGrid", plane.basis.space) + kappa = np.sqrt( + np.asarray(space.k_r) ** 2 + if isinstance(space, RadialGrid) + else np.asarray(space.kperp_sq) + ) + star = _kappa_star(index, plane.grid.omega, epsilon) + tail = kappa[:, None] > star[None, :] + density = np.sum(column_energy, axis=0) + defect = (epsilon / 8.0) * np.sum(np.where(tail, 0.0, column_energy), axis=0) + defect += np.sum(np.where(tail, column_energy, 0.0), axis=0) + relative = np.where(density > 0, defect / np.where(density > 0, density, 1.0), 0.0) + return AdmissionItem( + "angular", + _weighted(relative, density * _bin_like(plane)), + None, + True, + "field", + "incident field: (1 - cos theta*) cone + tail", + ) + + +def _refuse_nonpropagating( + column_energy: Float[np.ndarray, "n_columns n_omega"], + admittance: Float[np.ndarray, "n_columns n_omega"], + occupied: np.ndarray, +) -> None: + """Refuse a plane with more than rounding in nonpropagating columns. + + Parameters + ---------- + column_energy : numpy.ndarray + Each column's weighted squared samples per bin. + admittance : numpy.ndarray + Each column's real admittance per bin. + occupied : numpy.ndarray + Occupied bins. + + Raises + ------ + CouplingError + If the nonpropagating share exceeds 1e-12. + """ + dead = (admittance <= 0.0) & occupied[None, :] + total = float(np.sum(column_energy[:, occupied])) + share = 0.0 if total == 0.0 else float(np.sum(column_energy[dead])) / total + if share > 1e-12: + msg = ( + f"the plane puts {share:.3e} of its field norm in columns that do not " + "propagate: remove that content with its own report before coupling" + ) + raise CouplingError(msg) + + +def _spatial_source( + plane: FieldPlane, + nodes: Float[np.ndarray, "n_nodes 2"], + index: Float[np.ndarray, " n_omega"], + epsilon: float, +) -> _Source: + """Return a radial or Cartesian plane's field map at a guide's nodes. + + Parameters + ---------- + plane : solprop.coupling.plane.FieldPlane + A radial or Cartesian plane. + nodes : numpy.ndarray + The guide's nodes, m. + index : numpy.ndarray + Reference index per bin. + epsilon : float + The model budget. + + Returns + ------- + _Source + The field map and its items. + + Raises + ------ + CouplingError + If the nodes reach beyond a window whose exterior is unknown, or the plane + occupies nonpropagating columns. + """ + grid = plane.grid + space = cast("RadialGrid | FreeGrid", plane.basis.space) + inside = np.hypot(nodes[:, 0], nodes[:, 1]) <= _window(space) + if not np.all(inside) and plane.exterior != "zero": + msg = ( + "the port's nodes reach beyond the plane's window, where its exterior " + "is unknown: widen the plane or declare its exterior zero" + ) + raise CouplingError(msg) + samples = fourier.spectrum_to_internal(plane.samples, kind=grid.kind) + n_columns, n_pol = samples.shape[:2] + spectral = np.asarray(space.spectral_weights) + admittance = np.real(cast("np.ndarray", plane.metric.admittance)) + column_energy = spectral[:, None] * np.sum(np.abs(samples) ** 2, axis=1) + norm = _SCALE * index * np.sum(column_energy, axis=0) + _refuse_nonpropagating(column_energy, admittance, _occupied(norm)) + functions = _column_functions(space, nodes, inside) + cache = np.zeros((nodes.shape[0], 2, n_columns * n_pol), dtype=np.complex128) + for p in range(n_pol): + cache[:, p, p::n_pol] = functions + analytic = plane.analytic + domain = 0.0 if analytic is None else analytic.error + edge = _window_edge_share(space, samples, inside) + items = ( + _spatial_angular(plane, column_energy, index, epsilon), + AdmissionItem( + "numerical", + domain + edge, + None, + True, + "field", + f"source domain/resolution {domain:.1e}, field beyond the window " + f"{edge:.1e}", + ), + _source_metric_item(_bin_like(plane) * norm, plane.spectral_energy(), epsilon), + ) + return _Source( + lambda _bin: cache, + samples.reshape(n_columns * n_pol, grid.n_omega), + np.repeat(_SCALE * spectral[:, None] * admittance, n_pol, axis=0), + norm, + items, + ) + + +def _modal_source( + plane: FieldPlane, + nodes: Float[np.ndarray, "n_nodes 2"], + index: Float[np.ndarray, " n_omega"], + epsilon: float, +) -> _Source: + """Return an ordinary modal plane's synthesised field map at a guide's nodes. + + Parameters + ---------- + plane : solprop.coupling.plane.FieldPlane + An ordinary modal plane with its profiles. + nodes : numpy.ndarray + The guide's nodes, m. + index : numpy.ndarray + Reference index per bin. + epsilon : float + The model budget. + + Returns + ------- + _Source + The field map and its items. + """ + grid = plane.grid + source = cast("ScalarModeBasis", plane.profiles) + jones, mode_of = _channels(source, plane.samples.shape[1]) + values = source.values(np.hypot(nodes[:, 0], nodes[:, 1]))[mode_of] + effective = source.effective_index[mode_of] + root = np.sqrt(np.where(effective > 0, effective, 1.0)) + coordinates = fourier.spectrum_to_internal(plane.samples, kind=grid.kind).reshape( + -1, grid.n_omega + ) + + def transfer(k: int) -> np.ndarray: + """Return the field at the nodes per unit true-power coefficient. + + Parameters + ---------- + k : int + The bin. + + Returns + ------- + numpy.ndarray + Shape ``(n_nodes, 2, n_channels)``, V/m per √W. + """ + scale = np.sqrt(2.0 / (EPS0 * C)) / root[:, k] + return values.T[:, None, :] * (jones.T * scale[None, :])[None, :, :] + + scaled = coordinates / root + norm = index * np.sum(np.abs(scaled) ** 2, axis=0) + bins = _bin_like(plane) + items = ( + _modal_angular( + source, + jones=jones, + mode_of=mode_of, + scaled=scaled, + norm=norm, + index=index, + plane=plane, + epsilon=epsilon, + ), + _source_metric_item(bins * norm, plane.spectral_energy(), epsilon), + ) + return _Source(transfer, coordinates, np.ones(coordinates.shape), norm, items) + + +def _modal_angular( # noqa: PLR0913 - the source set, its field and the measure + source: ScalarModeBasis, + *, + jones: Float[np.ndarray, "n_channels 2"], + mode_of: np.ndarray, + scaled: Complex[np.ndarray, "n_channels n_omega"], + norm: Float[np.ndarray, " n_omega"], + index: Float[np.ndarray, " n_omega"], + plane: FieldPlane, + epsilon: float, +) -> AdmissionItem: + """Return a modal source field's angular defect, energy-weighted. + + Parameters + ---------- + source : solprop.coupling.models.ScalarModeBasis + The source profiles. + jones : numpy.ndarray + Each source channel's Jones vector. + mode_of : numpy.ndarray + Each source channel's mode. + scaled : numpy.ndarray + The vacuum-normalised source amplitudes ``b / sqrt(Re n_eff)``. + norm : numpy.ndarray + The incident norm in the common measure per bin. + index : numpy.ndarray + Reference index per bin. + plane : solprop.coupling.plane.FieldPlane + The source plane. + epsilon : float + The model budget. + + Returns + ------- + AdmissionItem + ``angular`` of the incident field. + """ + star = _kappa_star(index, plane.grid.omega, epsilon) + defect = np.zeros(plane.grid.n_omega) + for k in np.flatnonzero(_occupied(norm)): + tail = source.gram() - source.cone_gram(float(star[k])) + tail_c = tail[np.ix_(mode_of, mode_of)] * (jones @ jones.T) + tail_energy = index[k] * float( + np.real(scaled[:, k].conj() @ tail_c @ scaled[:, k]) + ) + defect[k] = (epsilon / 8.0) * (norm[k] - tail_energy) + tail_energy + relative = np.where(norm > 0, defect / np.where(norm > 0, norm, 1.0), 0.0) + return AdmissionItem( + "angular", + _weighted(relative, norm * _bin_like(plane)), + None, + True, + "field", + "incident field: (1 - cos theta*) cone + tail", + ) + + +def _window(space: RadialGrid | FreeGrid) -> float: + """Return the radius inside which a spatial plane's interpolant is its field, m. + + Parameters + ---------- + space : solprop.grid.RadialGrid or solprop.grid.FreeGrid + The grid. + + Returns + ------- + float + The radial window, or the smaller Cartesian half-width. + """ + if isinstance(space, RadialGrid): + return float(space.hankel.radius) + return float(min(np.max(np.abs(space.x)), np.max(np.abs(space.y)))) + + +def _column_functions( + space: RadialGrid | FreeGrid, + nodes: Float[np.ndarray, "n_nodes 2"], + inside: np.ndarray, +) -> Complex[np.ndarray, "n_nodes n_columns"]: + """Return each column's internal-convention field at given nodes, per unit sample. + + On a radial grid the inverse Hankel series + ``f(r) = sum_j w_j H_j J0(k_j r) / (2 pi)``; on a Cartesian grid the inverse + DFT's trigonometric interpolant ``f(x) = sum_j S_j exp(i k_j (x - x0)) / N``. + Zero at nodes outside the window. + + Parameters + ---------- + space : solprop.grid.RadialGrid or solprop.grid.FreeGrid + The grid. + nodes : numpy.ndarray + Node positions ``(x, y)``, m, shape ``(n_nodes, 2)``. + inside : numpy.ndarray + Nodes inside the window. + + Returns + ------- + numpy.ndarray + Shape ``(n_nodes, n_columns)``. + """ + if isinstance(space, RadialGrid): + radius = np.hypot(nodes[:, 0], nodes[:, 1]) + values = ( + np.asarray(space.spectral_weights)[None, :] + * j0(np.outer(radius, space.k_r)) + / (2.0 * np.pi) + ) + return np.where(inside[:, None], values, 0.0).astype(np.complex128) + kx = np.tile(space.k_x, space.n_y) + ky = np.repeat(space.k_y, space.n_x) + phase = np.exp( + 1j + * ( + np.outer(nodes[:, 0] - space.x[0], kx) + + np.outer(nodes[:, 1] - space.y[0], ky) + ) + ) / (space.n_x * space.n_y) + return np.where(inside[:, None], phase, 0.0) + + +def _window_edge_share( + space: RadialGrid | FreeGrid, + samples: Complex[np.ndarray, "n_columns n_pol n_omega"], + inside: np.ndarray, +) -> float: + """Return a bound on the share of the field the port's nodes miss beyond the window. + + Zero when every node lies inside the window; otherwise the plane's energy + share in the outer tenth of its window, which bounds the tail the zero + exterior discards to leading order. + + Parameters + ---------- + space : solprop.grid.RadialGrid or solprop.grid.FreeGrid + The grid. + samples : numpy.ndarray + Internal-convention columns. + inside : numpy.ndarray + Port nodes inside the window. + + Returns + ------- + float + Dimensionless. + """ + if np.all(inside): + return 0.0 + if isinstance(space, RadialGrid): + nodal = space.hankel.inverse(np.moveaxis(samples, 0, -1)) + weights = np.asarray(space.radial_weights) + radius = np.asarray(space.r) + energy = np.sum(np.abs(nodal) ** 2, axis=(0, 1)) * weights + outer = radius > 0.9 * _window(space) + else: + shaped = samples.reshape(space.n_y, space.n_x, *samples.shape[1:]) + nodal = np.fft.ifft2(shaped, axes=(0, 1)).reshape(samples.shape) + energy = np.sum(np.abs(nodal) ** 2, axis=(1, 2)) + outer = np.asarray(space.r) > 0.9 * _window(space) + total = float(np.sum(energy)) + return 0.0 if total == 0.0 else float(np.sum(energy[outer]) / total) + + +def _probe_bins( + occupied: np.ndarray, norm: Float[np.ndarray, " n_omega"] +) -> np.ndarray: + """Return up to :data:`_TARGET_BINS` occupied bins spanning the band. + + Parameters + ---------- + occupied : numpy.ndarray + Occupied bins. + norm : numpy.ndarray + Incident norm per bin. + + Returns + ------- + numpy.ndarray + Bin indices: the edges, the peak and points between. + """ + live = np.flatnonzero(occupied) + if live.size == 0: + return live + picks = {int(live[0]), int(live[-1]), int(np.argmax(norm))} + picks.update( + int(one) + for one in live[np.linspace(0, live.size - 1, _TARGET_BINS).astype(int)] + ) + return np.array(sorted(picks))[: max(_TARGET_BINS, 3)] + + +def _closure_item( + electric: np.ndarray, + magnetic: np.ndarray, + weights: Float[np.ndarray, " n_nodes"], + admittance: Float[np.ndarray, " n_omega"], + probes: np.ndarray, + epsilon: float, +) -> AdmissionItem: + r"""Return the H-closure's energy discrepancy over the retained target subspace. + + :math:`\delta^2 = \lambda_{\max}(X, Y)` with :math:`X` the Gram of + :math:`\mathbf h_t - Y_0\hat z\times\mathbf e_t` and :math:`Y` that of + :math:`Y_0\hat z\times\mathbf e_t`; the energy discrepancy is + :math:`2\delta + \delta^2` (section 4.7). + + Parameters + ---------- + electric, magnetic : numpy.ndarray + Basis E and H at the nodes, internal convention. + weights : numpy.ndarray + Node area weights. + admittance : numpy.ndarray + :math:`Y_0` per bin, A/V. + probes : numpy.ndarray + The bins evaluated. + epsilon : float + The model budget. + + Returns + ------- + AdmissionItem + ``closure``, capped at ``epsilon / 8``. + """ + worst = 0.0 + for k in probes: + ex, ey = electric[:, 0, :, k], electric[:, 1, :, k] + hx, hy = magnetic[:, 0, :, k], magnetic[:, 1, :, k] + closed_x, closed_y = -admittance[k] * ey, admittance[k] * ex + dx, dy = hx - closed_x, hy - closed_y + gram_d = np.einsum("q,qm,qn->mn", weights, dx.conj(), dx) + np.einsum( + "q,qm,qn->mn", weights, dy.conj(), dy + ) + gram_c = np.einsum( + "q,qm,qn->mn", weights, closed_x.conj(), closed_x + ) + np.einsum("q,qm,qn->mn", weights, closed_y.conj(), closed_y) + worst = max(worst, _whitened_extremes(gram_d, gram_c)[1]) + delta = math.sqrt(max(worst, 0.0)) + return AdmissionItem( + "closure", + 2.0 * delta + delta * delta, + epsilon / 8.0, + True, + "map", + f"H_t = Y0 z x E_t against provider H, field error {delta:.2e}", + ) + + +def _target_angular_vector( + electric: np.ndarray, + weights: Float[np.ndarray, " n_nodes"], + port: InputPort, + index: Float[np.ndarray, " n_omega"], + probes: np.ndarray, + epsilon: float, +) -> AdmissionItem: + """Return a general guide's retained subspace angular defect from its node FT. + + The transverse E of each channel is Fourier transformed by its node quadrature + on a polar rule inside the cone; the tail is the node Gram minus the cone. + + Parameters + ---------- + electric : numpy.ndarray + Basis E at the nodes, internal convention. + weights : numpy.ndarray + Node area weights. + port : solprop.coupling.port.InputPort + The port. + index : numpy.ndarray + Reference index per bin. + probes : numpy.ndarray + The bins evaluated. + epsilon : float + The model budget. + + Returns + ------- + AdmissionItem + ``angular`` of the target subspace. + """ + nodes = cast("np.ndarray", port.nodes) + extent = float(np.max(np.hypot(nodes[:, 0], nodes[:, 1]))) + star = _kappa_star(index, port.grid.omega, epsilon) + angles = 2.0 * np.pi * np.arange(16) / 16 + worst = 0.0 + for k in probes: + radial, area = radial_rule([], float(star[k]) * extent, panel=0.5 * math.pi) + kappa_r = radial / extent + area_k = area / extent**2 / 16 + kx = np.outer(kappa_r, np.cos(angles)).ravel() + ky = np.outer(kappa_r, np.sin(angles)).ravel() + cone_w = np.repeat(area_k, 16) / (2.0 * np.pi) ** 2 + phase = np.exp(-1j * (np.outer(kx, nodes[:, 0]) + np.outer(ky, nodes[:, 1]))) + transverse = electric[:, :2, :, k] + gram = np.einsum("q,qcm,qcn->mn", weights, transverse.conj(), transverse) + spectra = np.einsum("kq,q,qcm->kcm", phase, weights, transverse) + cone = np.einsum("k,kcm,kcn->mn", cone_w, spectra.conj(), spectra) + tail = gram - cone + defect = (epsilon / 8.0) * gram + (1.0 - epsilon / 8.0) * tail + worst = max(worst, _whitened_extremes(defect, gram)[1]) + return AdmissionItem( + "angular", + worst, + None, + True, + "map", + "retained target subspace: (1 - cos theta*) cone + tail", + ) diff --git a/src/solprop/coupling/envelopes.py b/src/solprop/coupling/envelopes.py new file mode 100644 index 000000000..67e5d7722 --- /dev/null +++ b/src/solprop/coupling/envelopes.py @@ -0,0 +1,134 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Recorded benchmark envelopes of the scalar coupling model's provider pairs. + +SEP 31 section 4.3 admits a provider pair to an approximate coupling model only with +an independent benchmark envelope for what the model omits, recorded with its +identity, range and independently bounded numerical error, and never extrapolated. +Each :class:`PairEnvelope` here is one such record. The numbers were measured by the +oracles in ``tests/coupling/test_envelopes.py``, which recompute them and hold the +recorded bounds; the manual page ``docs/guide/workflows/coupling.md`` describes them. + +- :data:`CAPILLARY_ENVELOPE`: the scalar J0 overlap the model uses against the overlap + of the capillary's own vector Marcatili mode fields + (:meth:`solprop.modes.MarcatiliMode.field` and its normalisation) on an + independent two-dimensional quadrature, for centred, displaced and tilted + Gaussian beams and for a change of capillary radius. +- :data:`STEP_INDEX_ENVELOPE`: the weak-guidance LP01 overlap of two step-index + fibres of one material pair and different radii against the full-vector flux + overlap of their exact bound HE11 fields + (:meth:`solprop.modes.StepIndexMode.electromagnetic_field`). It replaces the + contrast screen within its range. +""" + +from collections.abc import Mapping +from dataclasses import dataclass +from types import MappingProxyType + +__all__ = ["CAPILLARY_ENVELOPE", "STEP_INDEX_ENVELOPE", "PairEnvelope"] + + +@dataclass(frozen=True) +class PairEnvelope: + """A recorded benchmark envelope of one provider pair under the scalar model. + + Attributes + ---------- + identity : str + The envelope's name, recorded in a coupling report. + family : {'capillary', 'step_index'} + The profile family of both members of the pair. + ranges : collections.abc.Mapping + Each range parameter's closed interval ``(low, high)``; a pair outside any + one is not covered. + bound : float + The largest measured difference of captured energy share, scalar model + against the independent reference, over the range, dimensionless, numerical + error included: an energy discrepancy relative to the incident energy. + numerical : float + The independently estimated numerical error of the measurement, included + in `bound`. + reference : str + What the independent reference is. + """ + + identity: str + family: str + ranges: Mapping[str, tuple[float, float]] + bound: float + numerical: float + reference: str + + def __post_init__(self) -> None: + """Hold the ranges read-only.""" + object.__setattr__(self, "ranges", MappingProxyType(dict(self.ranges))) + + def covers(self, parameters: Mapping[str, float]) -> str | None: + """Return why a pair lies outside the envelope, or None if it is covered. + + Parameters + ---------- + parameters : collections.abc.Mapping + The pair's value of each range parameter. + + Returns + ------- + str or None + The first parameter outside its range, named with its value; None when + every parameter is inside. + """ + for name, (low, high) in self.ranges.items(): + value = parameters.get(name) + if value is None or not low <= value <= high: + return ( + f"{name} = {value!r} lies outside the envelope " + f"{self.identity}'s range [{low:.6g}, {high:.6g}]" + ) + return None + + +CAPILLARY_ENVELOPE = PairEnvelope( + identity="capillary-he1m-scalar-vs-marcatili-2026-10", + family="capillary", + ranges={"modes": (1.0, 8.0), "radius_ratio": (0.5, 2.0)}, + bound=1e-12, + numerical=1e-13, + reference=( + "overlap of MarcatiliMode.field (both transverse components, normalised by " + "MarcatiliMode.normalisation) on an independent polar Gauss-Legendre " + "quadrature; Gaussian waists 0.3a to 1.0a, displacement to 0.2a and tilt to " + "1 mrad at 800 nm in a 75 um argon capillary with eight HE1m modes, and " + "capillary radius ratios 0.5 to 2; largest difference 1.8e-14 of the " + "incident energy, the reference quadrature's own rounding" + ), +) +"""The capillary HE1m envelope; ``radius_ratio`` is source over target radius (1 for +an incident beam), ``modes`` the larger HE1m count of the pair.""" + + +STEP_INDEX_ENVELOPE = PairEnvelope( + identity="step-index-lp01-vs-vector-he11-2026-10", + family="step_index", + ranges={ + "contrast": (0.0, 0.01), + "v_source": (1.5, 2.4), + "v_target": (1.5, 2.4), + "radius_ratio": (0.7, 1.43), + "modes": (1.0, 1.0), + }, + bound=3.0e-4, + numerical=1e-12, + reference=( + "full-vector flux overlap of StepIndexMode.electromagnetic_field (exact bound " + "HE11 even, E and H) of the two fibres on an independent polar quadrature; " + "core 1.45, contrast 0.001 to 0.01, V 1.5 to 2.4 and radius ratios 0.7 to " + "1.43 at 1064 nm (constant indices: the overlaps depend on V alone); largest " + "difference 2.92e-4, at contrast 0.01, V 2.4 and ratio 0.7" + ), +) +"""The weak-guidance step-index LP01 envelope; ``contrast`` is the largest +``|n^2 - n_ref^2|/(2 n_ref^2)`` of either fibre, ``v_*`` each fibre's normalised +frequency at its profile wavelength, ``radius_ratio`` source over target core radius, +``modes`` the larger LP0m count (LP01 only).""" diff --git a/src/solprop/coupling/models.py b/src/solprop/coupling/models.py new file mode 100644 index 000000000..b7d0a6b2e --- /dev/null +++ b/src/solprop/coupling/models.py @@ -0,0 +1,897 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +r"""The coupling models of SEP 31 section 4.3 and the scalar mode profiles they use. + +:class:`CouplingModel` selects one of three models and carries its approximation +budget: + +``same_basis`` + Exact coordinate recovery in a checked common reference: a copy for an identical + basis, a declared invertible :class:`BasisChange`, or the flux-pairing recovery + :math:`\mathbf a = F^{-1}\mathbf B` of a general modal plane into a port of the + same guide. Not an interface calculation, so it takes no budget. +``scalar_paraxial`` + The weighted least-squares fit :math:`G = S^\dagger M S`, + :math:`\mathbf a = G^{-1}S^\dagger M\mathbf f` of a scalar field onto scalar + target profiles in the common paraxial measure :math:`M_0 = Y_0\,dA/2`, + :math:`Y_0 = n_{\rm ref}\varepsilon_0c`, followed by the conversion to the + target's native true-power coefficients :math:`b = \sqrt{\operatorname{Re} + n_{\rm eff}}\,a`. +``vector_paraxial`` + The flux-pairing recovery :math:`\mathbf a = F^{-1}\mathbf B` onto a general + modal provider's E/H at its nodes, with the incident H or the forward closure + :math:`\mathbf H_t = Y_0\hat{\mathbf z}\times\mathbf E_t`. + +:class:`ScalarModeBasis` is the scalar transverse profile set the scalar model uses: +the capillary :math:`HE_{1m}` family, :math:`J_0(u_m r/a)` inside the core and zero +outside (SEP 31 section 4.4), and the weak-guidance :math:`LP_{0m}` family of a +step-index fibre, :math:`J_0(Ur/a)` inside and :math:`J_0(U)K_0(Wr/a)/K_0(W)` +outside (Snyder and Love 1983, section 14-3; Gloge 1971). Each profile is normalised +to unit area integral of its square, and its order-zero Hankel transform is +evaluated in closed form, so an overlap can be taken in a plane's own spectral +quadrature without sampling a profile's kink. + +The manual page is ``docs/guide/workflows/coupling.md``. +""" + +import itertools +import math +from collections.abc import Sequence +from dataclasses import dataclass, field +from typing import Literal, cast + +import numpy as np +from jaxtyping import Complex, Float +from scipy.optimize import brentq +from scipy.special import j0, j1, jn_zeros, k0, k0e, k1, k1e + +from solprop.exceptions import CouplingError +from solprop.grid import TimeGrid +from solprop.modes.capillary import MarcatiliMode +from solprop.modes.protocol import Mode +from solprop.modes.stepindex import StepIndexMode, _material + +__all__ = [ + "MODEL_KINDS", + "BasisChange", + "CouplingModel", + "ModelKind", + "ProfileFamily", + "ScalarModeBasis", + "cone_angle", + "radial_rule", +] + +ModelKind = Literal["same_basis", "scalar_paraxial", "vector_paraxial"] +"""A coupling model of SEP 31 section 4.3.""" + +MODEL_KINDS: tuple[ModelKind, ...] = ( + "same_basis", + "scalar_paraxial", + "vector_paraxial", +) +"""Every :data:`ModelKind`, in a fixed order.""" + +ProfileFamily = Literal["capillary", "step_index"] +"""The scalar profile family of a :class:`ScalarModeBasis`.""" + +_PANEL_NODES = 24 +"""Gauss--Legendre nodes per panel of the profile integrals. + +A panel spans at most half a period of the Bessel functions involved, so 24 nodes +integrate the smooth piece on it to rounding; the panels break at every core radius, +where a profile's derivative jumps. +""" + +_EXTERIOR_DECAY = math.log(1e17) +"""Decay, in units of the profile's own e-folding, of the last exterior node. + +An LP profile's cladding tail falls as ``exp(-W r/a)``; at this many e-foldings its +square is below 1e-34 of the core value, beneath rounding of any overlap. +""" + + +def cone_angle(model_error: float) -> float: + r"""Return the admitted cone half-angle :math:`\theta_*` of a model budget. + + SEP 31 sections 4.3 and 4.6: :math:`1 - \cos\theta_* = \epsilon_m/8`. + + Parameters + ---------- + model_error : float + The budget :math:`\epsilon_m`, in (0, 1). + + Returns + ------- + float + :math:`\theta_*` in rad. + + Examples + -------- + >>> f"{cone_angle(0.08):.6f}" + '0.141539' + """ + return math.acos(1.0 - model_error / 8.0) + + +@dataclass(frozen=True, eq=False) +class BasisChange: + """A declared invertible change of modal basis or gauge for ``same_basis``. + + The target coefficients are ``matrix @ b`` per bin and polarisation. The change + must preserve energy between the plane's metric and the port's, + ``T^H Q_B T = Q_A``, to :data:`solprop.coupling.port.METRIC_TOLERANCE` + relative; it relabels the same physical field and is refused otherwise. + + Attributes + ---------- + matrix : numpy.ndarray + Optics-convention map from the plane's columns to the port's, shape + ``(n_modes_target, n_modes_source)`` or with a trailing ``n_omega`` axis for + a frequency-dependent gauge, dimensionless. + source : str + The plane's basis identity the change was declared for. + target : str + The port's basis identity. + + Raises + ------ + CouplingError + If the matrix is not finite and two- or three-dimensional. + """ + + matrix: Complex[np.ndarray, "n_modes_target n_modes_source *n_omega"] + source: str + target: str + + def __post_init__(self) -> None: + """Own a read-only copy of the matrix.""" + matrix = np.array(self.matrix, dtype=np.complex128, copy=True) + if matrix.ndim not in (2, 3) or not np.all(np.isfinite(matrix)): + msg = "a basis change is a finite (n_target, n_source[, n_omega]) matrix" + raise CouplingError(msg) + matrix.setflags(write=False) + object.__setattr__(self, "matrix", matrix) + + def at(self, n_omega: int) -> Complex[np.ndarray, "n_omega n_modes_t n_modes_s"]: + """Return the matrix at every bin, frequency axis first. + + Parameters + ---------- + n_omega : int + The number of bins. + + Returns + ------- + numpy.ndarray + Shape ``(n_omega, n_modes_target, n_modes_source)``. + + Raises + ------ + CouplingError + If a frequency-dependent matrix has another number of bins. + """ + if self.matrix.ndim == 2: + return np.broadcast_to(self.matrix, (n_omega, *self.matrix.shape)) + if self.matrix.shape[2] != n_omega: + msg = f"the basis change has {self.matrix.shape[2]} bins, not {n_omega}" + raise CouplingError(msg) + return np.moveaxis(self.matrix, 2, 0) + + +@dataclass(frozen=True) +class CouplingModel: + r"""Select a coupling model of SEP 31 section 4.3 and its approximation budget. + + Attributes + ---------- + kind : {'same_basis', 'scalar_paraxial', 'vector_paraxial'} + The model. + model_error : float or None, optional + The dimensionless energy budget :math:`\epsilon_m` in (0, 1) that the + approximation's defects, angular tails, closure, passivity excess and + numerical errors together must fit; required by the two approximate models, + refused by ``same_basis``, which approximates nothing. It is separate from + numerical accuracy and is not a coupling-efficiency floor. + basis_change : BasisChange or None, optional + A declared invertible change of basis or gauge, ``same_basis`` only. + + Raises + ------ + CouplingError + If a budget is missing, outside (0, 1) or given to ``same_basis``, or a + basis change is given to an approximate model. + + Examples + -------- + >>> CouplingModel("scalar_paraxial", model_error=0.05) + CouplingModel(kind='scalar_paraxial', model_error=0.05, basis_change=None) + """ + + kind: ModelKind + model_error: float | None = None + basis_change: BasisChange | None = None + + def __post_init__(self) -> None: + """Check the budget against the kind.""" + # The kind's membership is the Literal's: static typing and the test + # session's runtime checks hold it, so no unreachable check is repeated here. + if self.kind == "same_basis": + if self.model_error is not None: + msg = "same_basis approximates nothing, so it takes no model_error" + raise CouplingError(msg) + return + if self.basis_change is not None: + msg = "a basis change belongs to same_basis, not to an approximate model" + raise CouplingError(msg) + if self.model_error is None: + msg = ( + f"{self.kind} is an approximation: give its energy budget " + "model_error=, a number in (0, 1)" + ) + raise CouplingError(msg) + if not (math.isfinite(self.model_error) and 0.0 < self.model_error < 1.0): + msg = f"model_error lies in (0, 1), got {self.model_error!r}" + raise CouplingError(msg) + + @property + def cone(self) -> float: + """Return the admitted cone half-angle of the budget. + + Returns + ------- + float + :func:`cone_angle` of `model_error`, rad. + + Raises + ------ + CouplingError + For ``same_basis``, which has no budget. + """ + if self.model_error is None: + msg = "same_basis has no angular budget" + raise CouplingError(msg) + return cone_angle(self.model_error) + + +def radial_rule( + breaks: Sequence[float], outer: float, *, panel: float +) -> tuple[Float[np.ndarray, " n_r"], Float[np.ndarray, " n_r"]]: + """Return a composite Gauss--Legendre rule for ``int f(r) 2 pi r dr``. + + Panels break at each of `breaks` (a profile's derivative jumps there) and are at + most `panel` long, so each holds a smooth piece of the integrand. + + Parameters + ---------- + breaks : sequence of float + Interior radii where the integrand is not smooth, m. + outer : float + The rule's outer radius, m. + panel : float + Largest panel length, m. + + Returns + ------- + tuple of numpy.ndarray + Nodes in m and area weights in m^2 (the Jacobian ``2 pi r`` included), each + shape ``(n_r,)``. + + Examples + -------- + >>> import numpy as np + >>> r, w = radial_rule([1.0], 2.0, panel=0.5) + >>> f"{np.sum(w):.12f}" # the area of the disc of radius 2 + '12.566370614359' + """ + edges = sorted({0.0, *(float(b) for b in breaks if 0.0 < b < outer), float(outer)}) + nodes, weights = np.polynomial.legendre.leggauss(_PANEL_NODES) + radii: list[np.ndarray] = [] + area: list[np.ndarray] = [] + for low, high in itertools.pairwise(edges): + count = max(1, math.ceil((high - low) / panel)) + bounds = np.linspace(low, high, count + 1) + for left, right in itertools.pairwise(bounds): + half = 0.5 * (right - left) + r = left + half * (nodes + 1.0) + radii.append(r) + area.append(half * weights * 2.0 * np.pi * r) + return np.concatenate(radii), np.concatenate(area) + + +def _hankel_core( + u: float, q: Float[np.ndarray, " n_k"], radius: float +) -> Float[np.ndarray, " n_k"]: + r"""Return :math:`\int_0^a J_0(ur/a)J_0(\kappa r)r\,dr` in closed form. + + The Lommel integral + :math:`a^2[uJ_1(u)J_0(q) - qJ_0(u)J_1(q)]/(u^2 - q^2)`, :math:`q = \kappa a`; + within 1e-4 of :math:`q = u`, where the quotient cancels, the integral is + evaluated by quadrature instead. + + Parameters + ---------- + u : float + The profile's dimensionless transverse wavenumber. + q : numpy.ndarray + :math:`\kappa a`, dimensionless, shape ``(n_k,)``. + radius : float + The core radius a, m. + + Returns + ------- + numpy.ndarray + The integral in m^2, shape ``(n_k,)``. + """ + q = np.asarray(q, dtype=np.float64) + near = np.abs(q - u) < 1e-4 * max(u, 1.0) + safe = np.where(near, u + 1.0, q) + value = (u * j1(u) * j0(safe) - safe * j0(u) * j1(safe)) / (u * u - safe * safe) + if np.any(near): + # The quotient cancels where q meets u; there the integrand is smooth and + # a 64-node Gauss--Legendre rule on [0, 1] integrates it to rounding. + nodes, weights = np.polynomial.legendre.leggauss(64) + x = 0.5 * (nodes + 1.0) + direct = 0.5 * np.sum( + (weights * x * j0(u * x))[None, :] * j0(q[near][:, None] * x[None, :]), + axis=1, + ) + value = value.copy() + value[near] = direct + return radius * radius * value + + +def _hankel_cladding( + w: float, q: Float[np.ndarray, " n_k"], radius: float +) -> Float[np.ndarray, " n_k"]: + r"""Return :math:`\int_a^\infty K_0(wr/a)J_0(\kappa r)r\,dr` in closed form. + + From the antiderivative + :math:`x[\beta K_0(\alpha x)J_1(\beta x) - \alpha K_1(\alpha x)J_0(\beta x)] + /(\alpha^2 + \beta^2)` of :math:`xK_0(\alpha x)J_0(\beta x)`, which vanishes at + infinity: :math:`a^2[wK_1(w)J_0(q) - qK_0(w)J_1(q)]/(w^2 + q^2)`. + + Parameters + ---------- + w : float + The profile's dimensionless cladding decay. + q : numpy.ndarray + :math:`\kappa a`, dimensionless, shape ``(n_k,)``. + radius : float + The core radius a, m. + + Returns + ------- + numpy.ndarray + The integral in m^2, shape ``(n_k,)``. + """ + q = np.asarray(q, dtype=np.float64) + return radius * radius * (w * k1(w) * j0(q) - q * k0(w) * j1(q)) / (w * w + q * q) + + +def _lp_parameter(v: float, m: int) -> tuple[float, float]: + r"""Solve the weak-guidance :math:`LP_{0m}` eigenvalue equation. + + :math:`UJ_1(U)/J_0(U) = WK_1(W)/K_0(W)` with :math:`U^2 + W^2 = V^2` + (Gloge 1971, eq. 15 with l = 0), written without poles as + :math:`UJ_1(U)K_0(W) - WK_1(W)J_0(U) = 0` and bracketed between consecutive + zeros of :math:`J_1`, where the :math:`m`-th root lies. + + Parameters + ---------- + v : float + The normalised frequency V. + m : int + Radial order, at least 1. + + Returns + ------- + tuple of float + U and W. + + Raises + ------ + CouplingError + If the mode is below its cutoff at this V. + """ + lower = 0.0 if m == 1 else float(jn_zeros(1, m - 1)[-1]) + if v <= lower * (1.0 + 1e-9): + msg = f"LP0{m} is cut off at V = {v:.6g}" + raise CouplingError(msg) + upper = min(float(jn_zeros(0, m)[-1]), v) + + def equation(u: float) -> float: + """Return the pole-free characteristic function at U. + + Parameters + ---------- + u : float + The core parameter U. + + Returns + ------- + float + The function's value, zero at an eigenvalue. + """ + w = math.sqrt(max(v * v - u * u, 0.0)) + # Exponentially scaled K keeps W large finite; the common factor exp(-W) + # is positive and does not move the root. + return float(u * j1(u) * k0e(w) - w * k1e(w) * j0(u)) + + tiny = 1e-12 * max(v, 1.0) + u = brentq(equation, lower + tiny, upper - tiny, xtol=1e-15, rtol=1e-15) + return float(u), math.sqrt(v * v - u * u) + + +def _waveguide_of(mode: Mode) -> Mode: + """Return the waveguide mode a mode-averaged fibre was built from, or the mode. + + Parameters + ---------- + mode : solprop.modes.protocol.Mode + A run's mode. + + Returns + ------- + solprop.modes.protocol.Mode + The capillary or step-index mode its dispersion forwards to, when it has + one; otherwise `mode` itself. + """ + inner = getattr(getattr(mode, "dispersion", None), "mode", None) + return inner if isinstance(inner, MarcatiliMode | StepIndexMode) else mode + + +@dataclass(frozen=True, eq=False) +class ScalarModeBasis: + r"""Scalar transverse profiles of an ordinary mode set, bound to a temporal grid. + + Each channel is a profile :math:`v_m(r)` normalised to + :math:`\int|v_m|^2dA = 1`, times a real unit Jones vector. The guide's own + vacuum-normalised field per internal amplitude is + :math:`\sqrt{2/(\varepsilon_0c)}\,v_m`, so a true-power coefficient + :math:`b_m = \sqrt{\operatorname{Re}n_{{\rm eff},m}}a_m` carries the field + :math:`\sqrt{2/(\varepsilon_0c\operatorname{Re}n_{{\rm eff},m})}\,b_m v_m` + (``docs/dev/conventions.md`` section 4). The profiles are those of the run's + own basis: a capillary's do not depend on frequency, and a step-index mode's are + frozen at its declared profile wavelength exactly as the run's are. + + Build one with :meth:`from_modes`. + + Attributes + ---------- + family : {'capillary', 'step_index'} + The profile family. + labels : tuple of str + Channel labels, in column order. + radius : float + Core radius at the plane, m. + u : numpy.ndarray + Dimensionless core wavenumber of each profile, shape ``(n_modes,)``: + the J0 zero of a capillary HE1m mode, U of an LP0m mode. + w : numpy.ndarray + Dimensionless cladding decay W of each LP0m profile, shape ``(n_modes,)``; + zero for a capillary, whose profile vanishes outside the core. + jones : numpy.ndarray + Real unit Jones vector ``(x, y)`` of each channel, shape ``(n_modes, 2)``. + effective_index : numpy.ndarray + :math:`\operatorname{Re}n_{\rm eff}` of each channel at each bin, shape + ``(n_modes, n_omega)``; zero on bins outside the active positive band. + core_index : numpy.ndarray or None, optional + Real core index at each bin, shape ``(n_omega,)``: a capillary's gas, a + step-index fibre's core; zero off the active positive band. + cladding_index : numpy.ndarray or None, optional + Real cladding index at each bin of a step-index basis, shape + ``(n_omega,)``. + identity : str, optional + A short description of the source modes, for reports. + + Raises + ------ + CouplingError + If the arrays disagree in shape. + """ + + family: ProfileFamily + labels: tuple[str, ...] + radius: float + u: Float[np.ndarray, " n_modes"] + w: Float[np.ndarray, " n_modes"] + jones: Float[np.ndarray, "n_modes 2"] + effective_index: Float[np.ndarray, "n_modes n_omega"] + core_index: Float[np.ndarray, " n_omega"] | None = None + cladding_index: Float[np.ndarray, " n_omega"] | None = None + identity: str = "" + _norms: Float[np.ndarray, " n_modes"] = field(init=False, repr=False) + + def __post_init__(self) -> None: + """Own read-only arrays and precompute each profile's normalisation.""" + n_modes = len(self.labels) + for name, shape in ( + ("u", (n_modes,)), + ("w", (n_modes,)), + ("jones", (n_modes, 2)), + ): + array = np.array(getattr(self, name), dtype=np.float64, copy=True) + if array.shape != shape or not np.all(np.isfinite(array)): + msg = f"a scalar basis's {name} has shape {shape}, got {array.shape}" + raise CouplingError(msg) + array.setflags(write=False) + object.__setattr__(self, name, array) + index = np.array(self.effective_index, dtype=np.float64, copy=True) + if index.ndim != 2 or index.shape[0] != n_modes: + msg = "a scalar basis's effective index has shape (n_modes, n_omega)" + raise CouplingError(msg) + index.setflags(write=False) + object.__setattr__(self, "effective_index", index) + for name in ("core_index", "cladding_index"): + value = getattr(self, name) + if value is not None: + array = np.array(value, dtype=np.float64, copy=True) + array.setflags(write=False) + object.__setattr__(self, name, array) + object.__setattr__(self, "labels", tuple(self.labels)) + object.__setattr__(self, "_norms", self._square_integrals()) + + def __repr__(self) -> str: + """Return a one-line description without arrays. + + Returns + ------- + str + The family, channels and radius. + """ + return ( + f"ScalarModeBasis({self.family}, {', '.join(self.labels)}, " + f"a={self.radius:.6g} m)" + ) + + @property + def n_modes(self) -> int: + """Return the number of channels. + + Returns + ------- + int + ``len(labels)``. + """ + return len(self.labels) + + def _square_integrals(self) -> Float[np.ndarray, " n_modes"]: + r"""Return :math:`\int|\hat v_m|^2dA` of each unnormalised profile, m^2. + + Returns + ------- + numpy.ndarray + Shape ``(n_modes,)``: :math:`\pi a^2J_1(u)^2` for a capillary profile, + :math:`\pi a^2[J_0^2 + J_1^2](U) + \pi a^2(J_0(U)/K_0(W))^2 + [K_1^2 - K_0^2](W)` for an LP profile. + """ + area = math.pi * self.radius**2 + core = area * (j0(self.u) ** 2 + j1(self.u) ** 2) + if self.family == "capillary": + return core + ratio = j0(self.u) / k0(self.w) + return core + area * ratio**2 * (k1(self.w) ** 2 - k0(self.w) ** 2) + + def outer(self) -> float: + """Return the radius beyond which every profile is negligible, m. + + Returns + ------- + float + The core radius for a capillary; for an LP set, where the slowest + cladding tail has decayed by :data:`_EXTERIOR_DECAY` e-foldings. + """ + if self.family == "capillary": + return self.radius + return self.radius * (1.0 + _EXTERIOR_DECAY / float(np.min(self.w))) + + def values(self, r: Float[np.ndarray, " n_r"]) -> Float[np.ndarray, "n_modes n_r"]: + """Return each normalised profile at radii. + + Parameters + ---------- + r : numpy.ndarray + Non-negative radii, m, shape ``(n_r,)``. + + Returns + ------- + numpy.ndarray + :math:`v_m(r)` in 1/m, shape ``(n_modes, n_r)``. + """ + x = np.asarray(r, dtype=np.float64)[None, :] / self.radius + inside = x <= 1.0 + core = j0(self.u[:, None] * x) + if self.family == "capillary": + profile = np.where(inside, core, 0.0) + else: + ratio = (j0(self.u) / k0(self.w))[:, None] + tail = ratio * k0(self.w[:, None] * np.maximum(x, 1.0)) + profile = np.where(inside, core, tail) + return profile / np.sqrt(self._norms)[:, None] + + def hankel( + self, kappa: Float[np.ndarray, " n_k"] + ) -> Float[np.ndarray, "n_modes n_k"]: + r"""Return each normalised profile's order-zero Hankel transform. + + :math:`\tilde v_m(\kappa) = \int_0^\infty v_m(r)J_0(\kappa r)r\,dr`, the + convention of :mod:`solprop.hankel`, in closed form; the two-dimensional + Fourier transform of the axisymmetric profile is :math:`2\pi\tilde v_m`. + + Parameters + ---------- + kappa : numpy.ndarray + Transverse wavenumbers, rad/m, non-negative, shape ``(n_k,)``. + + Returns + ------- + numpy.ndarray + In m, shape ``(n_modes, n_k)``. + """ + q = np.asarray(kappa, dtype=np.float64) * self.radius + rows = [] + for one in range(self.n_modes): + value = _hankel_core(float(self.u[one]), q, self.radius) + if self.family == "step_index": + ratio = float(j0(self.u[one]) / k0(self.w[one])) + value = value + ratio * _hankel_cladding( + float(self.w[one]), q, self.radius + ) + rows.append(value / math.sqrt(float(self._norms[one]))) + return np.array(rows) + + def gram(self) -> Float[np.ndarray, "n_modes n_modes"]: + """Return the exact whole-plane Gram of the normalised profiles. + + Distinct profiles of one family are orthogonal (different zeros of the + core's eigenvalue equation); the same profile carried by two channels, the + two polarisations of one mode, overlaps itself fully, and their Jones + vectors make the channels orthogonal. + + Returns + ------- + numpy.ndarray + One where two profiles are the same and zero otherwise, shape + ``(n_modes, n_modes)``. + """ + same = (self.u[:, None] == self.u[None, :]) & ( + self.w[:, None] == self.w[None, :] + ) + return same.astype(np.float64) + + def cone_gram(self, kappa_star: float) -> Float[np.ndarray, "n_modes n_modes"]: + r"""Return the Gram of the profiles' angular content inside a cone. + + :math:`\int_{|\boldsymbol\kappa|<\kappa_*}\tilde V_m\tilde V_n + d^2\kappa/(2\pi)^2 + = \int_0^{\kappa_*}\tilde v_m\tilde v_n\,2\pi\kappa\,d\kappa`, + by composite Gauss--Legendre panels of at most half a period of + :math:`J_0(\kappa a)`. The tail outside the cone is the whole-plane + :meth:`gram` minus this. + + Parameters + ---------- + kappa_star : float + The cone's transverse wavenumber, rad/m. + + Returns + ------- + numpy.ndarray + Real symmetric, dimensionless, shape ``(n_modes, n_modes)``. + """ + if kappa_star <= 0.0: + return np.zeros((self.n_modes, self.n_modes)) + # The transform of a compactly supported or exponentially decaying profile + # is smooth in kappa, so panels of half a period of J0(kappa a) suffice. + nodes, weights = radial_rule([], kappa_star * self.radius, panel=0.5 * math.pi) + kappa = nodes / self.radius + transforms = self.hankel(kappa) + # radial_rule's weights are 2 pi q dq; dividing by a^2 gives 2 pi k dk. + return (transforms * (weights / self.radius**2)[None, :]) @ transforms.T + + @classmethod + def from_modes( + cls, modes: Sequence[Mode], grid: TimeGrid, z: float = 0.0 + ) -> "ScalarModeBasis": # noqa: UP037 - beartype evaluates the class body + """Return the scalar profiles of an ordinary mode set at a plane. + + Admitted are a capillary's :math:`HE_{1m}` modes (Marcatili; the scalar + J0 profile is exactly their transverse field) and a step-index fibre's + :math:`HE_{1m}` modes taken in the weak-guidance limit as :math:`LP_{0m}`. + Every other family has angular structure the scalar model cannot hold and is + refused by name. + + Parameters + ---------- + modes : sequence of solprop.modes.protocol.Mode + The run's modes, in column order. + grid : solprop.grid.TimeGrid + The run's temporal grid, on whose active positive bins the effective + indices are evaluated. + z : float, optional + The plane's position along the guide, m. + + Returns + ------- + ScalarModeBasis + The profiles. + + Raises + ------ + CouplingError + For an empty set, mixed or unsupported families, or modes of different + radii. + """ + if not modes: + msg = "a scalar basis needs at least one mode" + raise CouplingError(msg) + live = grid.active_mask & (grid.omega > 0) + omega = grid.omega[live] + index = np.zeros((len(modes), grid.n_omega)) + for row, mode in enumerate(modes): + index[row, live] = np.real(np.asarray(mode.neff(omega, z))) + labels = tuple(mode.channel_label.display_label for mode in modes) + # A mode-averaged GNLSE fibre wraps the waveguide it was built from (its + # dispersion forwards to that mode); the profile is the waveguide's, the + # effective index the run's own. + modes = [_waveguide_of(mode) for mode in modes] + if all(isinstance(mode, MarcatiliMode) for mode in modes): + return cls._capillary( + cast("Sequence[MarcatiliMode]", modes), labels, index, grid, z + ) + if all(isinstance(mode, StepIndexMode) for mode in modes): + return cls._step_index( + cast("Sequence[StepIndexMode]", modes), labels, index, grid, z + ) + msg = ( + "the scalar model has profiles for capillary HE1m modes and weakly " + f"guiding step-index HE1m modes; got {[type(m).__name__ for m in modes]}" + ) + raise CouplingError(msg) + + @classmethod + def _capillary( + cls, + modes: Sequence[MarcatiliMode], + labels: tuple[str, ...], + index: Float[np.ndarray, "n_modes n_omega"], + grid: TimeGrid, + z: float, + ) -> "ScalarModeBasis": # noqa: UP037 - beartype evaluates the class body + """Return the J0 profiles of capillary HE1m modes. + + Parameters + ---------- + modes : sequence of MarcatiliMode + The modes. + labels : tuple of str + Their labels. + index : numpy.ndarray + Their real effective indices, shape ``(n_modes, n_omega)``. + grid : solprop.grid.TimeGrid + The temporal grid, for the core gas's index. + z : float + The plane's position, m. + + Returns + ------- + ScalarModeBasis + The profiles. + + Raises + ------ + CouplingError + For a family other than HE1m, or modes of different radii. + """ + radii = {float(mode.dimlimits(z)[2][0]) for mode in modes} + if len(radii) != 1: + msg = f"the capillary modes have different radii {sorted(radii)}" + raise CouplingError(msg) + for mode, label in zip(modes, labels, strict=True): + if mode.kind != "HE" or mode.n != 1: + msg = ( + f"{label} has angular structure: the scalar model holds the " + "capillary's HE1m modes only" + ) + raise CouplingError(msg) + jones = np.array([[math.cos(mode.phi), math.sin(mode.phi)] for mode in modes]) + live = grid.active_mask & (grid.omega > 0) + core = np.zeros(grid.n_omega) + core[live] = 1.0 + np.real( + np.asarray(modes[0].core_index_minus_one(grid.omega[live], z)) + ) + radius = radii.pop() + return cls( + "capillary", + labels, + radius, + np.array([mode.u_nm for mode in modes]), + np.zeros(len(modes)), + jones, + index, + core, + identity=f"capillary HE1m x{len(modes)}, a={radius:.6g} m", + ) + + @classmethod + def _step_index( + cls, + modes: Sequence[StepIndexMode], + labels: tuple[str, ...], + index: Float[np.ndarray, "n_modes n_omega"], + grid: TimeGrid, + z: float, + ) -> "ScalarModeBasis": # noqa: UP037 - beartype evaluates the class body + """Return the weak-guidance LP0m profiles of step-index HE1m modes. + + Parameters + ---------- + modes : sequence of StepIndexMode + The modes, of one fibre. + labels : tuple of str + Their labels. + index : numpy.ndarray + Their real effective indices, shape ``(n_modes, n_omega)``. + grid : solprop.grid.TimeGrid + The temporal grid. + z : float + The plane's position, m. + + Returns + ------- + ScalarModeBasis + The profiles, frozen at the modes' profile wavelength. + + Raises + ------ + CouplingError + For a family other than HE1m, or modes of different fibres. + """ + first = modes[0] + for mode, label in zip(modes, labels, strict=True): + if mode.kind != "HE" or mode.n != 1: + msg = ( + f"{label} has angular structure: the weak-guidance scalar model " + "holds a step-index fibre's HE1m (LP0m) modes only" + ) + raise CouplingError(msg) + if ( + mode.profile_wavelength != first.profile_wavelength + or mode._core != first._core + or mode._cladding != first._cladding + or mode.dimlimits(z)[2] != first.dimlimits(z)[2] + ): + msg = "step-index modes of one scalar basis share one fibre and profile" + raise CouplingError(msg) + radius = float(first._radius(z)) + omega_p = 2.0 * math.pi * 299792458.0 / first.profile_wavelength + core_p = float(_material(first._core, np.array([omega_p]))[0][0]) + cladding_p = float(_material(first._cladding, np.array([omega_p]))[0][0]) + v = omega_p / 299792458.0 * radius * math.sqrt(core_p**2 - cladding_p**2) + parameters = [_lp_parameter(v, mode.m) for mode in modes] + live = grid.active_mask & (grid.omega > 0) + core = np.zeros(grid.n_omega) + cladding = np.zeros(grid.n_omega) + core[live] = _material(first._core, grid.omega[live])[0] + cladding[live] = _material(first._cladding, grid.omega[live])[0] + jones = np.array( + [[1.0, 0.0] if mode.parity == "even" else [0.0, 1.0] for mode in modes] + ) + return cls( + "step_index", + labels, + radius, + np.array([one[0] for one in parameters]), + np.array([one[1] for one in parameters]), + jones, + index, + core, + cladding, + identity=( + f"step-index LP0m x{len(modes)}, a={radius:.6g} m, " + f"n={core_p:.6g}/{cladding_p:.6g} at {first.profile_wavelength:.6g} m" + ), + ) diff --git a/src/solprop/coupling/outcouple.py b/src/solprop/coupling/outcouple.py new file mode 100644 index 000000000..453a8c853 --- /dev/null +++ b/src/solprop/coupling/outcouple.py @@ -0,0 +1,226 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +r"""Synthesise a guide's exit field on a spatial grid (SEP 31, sections 4.1 and 5.2). + +:class:`Outcouple` turns an ordinary modal plane -- true-power coefficients with +their scalar profiles (:class:`~solprop.coupling.models.ScalarModeBasis`) -- into a +radial or Cartesian :class:`~solprop.coupling.plane.FieldPlane` of the physical +field, under an **explicitly declared** forward interface model. No Fresnel factor +is implied: the one model offered, ``"matched"``, is the guide's exit into a medium +of its own core index, where the transverse field is continuous; a change of index is +a separate ``PlanarInterface`` element in the transport sequence that follows, +applied once. + +The guide's field per true-power coefficient is +:math:`\sqrt{2/(\varepsilon_0c\operatorname{Re}n_{\rm eff})}\,v_m` (``docs/dev/ +conventions.md`` section 4). It is written into the grid's columns through each +profile's closed-form transform (:func:`~solprop.coupling.couple.target_columns`), +so a profile's kink at the core radius is not sampled; what the grid's spectral +window cannot hold is reported, not renormalised. +""" + +from dataclasses import dataclass +from typing import Literal, cast + +import numpy as np +from jaxtyping import Float + +from solprop import fourier +from solprop.constants import EPS0, C +from solprop.coupling.couple import target_columns +from solprop.coupling.plane import FieldPlane, PlaneBasis, PlaneMetric +from solprop.coupling.port import METRIC_TOLERANCE +from solprop.exceptions import CouplingError +from solprop.grid import FreeGrid, RadialGrid +from solprop.spatial import SpatialFlux, SpatialMedium + +__all__ = ["Outcouple", "OutcoupleResult", "OutcouplingModel"] + +OutcouplingModel = Literal["matched"] +"""A declared forward interface model of :class:`Outcouple`.""" + + +@dataclass(frozen=True, eq=False) +class OutcoupleResult: + """The synthesised exit plane and its energy account. + + Attributes + ---------- + plane : solprop.coupling.plane.FieldPlane + The radial or Cartesian plane of the exit field. + modal : numpy.ndarray + The modal plane's energy per bin in its native metric, J, shape + ``(n_omega,)``. + synthesised : numpy.ndarray + The synthesised plane's energy per bin in its axial-admittance metric, J, + shape ``(n_omega,)``. It differs from `modal` by the scalar model's metric + defect (core index against effective index), the profiles' angular content + and what the grid's window misses; nothing is renormalised. + window : float + The largest departure of the profiles' Gram in the grid's spectral window + from the identity: the share of a profile the grid cannot represent. + """ + + plane: FieldPlane + modal: Float[np.ndarray, " n_omega"] + synthesised: Float[np.ndarray, " n_omega"] + window: float + + +@dataclass(frozen=True, eq=False) +class Outcouple: + """Synthesise an ordinary guide's exit field onto a spatial grid. + + Attributes + ---------- + space : solprop.grid.RadialGrid or solprop.grid.FreeGrid + The grid of the synthesised plane, centred on the guide's axis. + interface : {'matched'} + The declared forward interface model. ``'matched'``: the exit medium has + the guide's core index, so the transverse field is continuous and there is + no reflection; any other exit medium is refused, and its index change is a + separate planar interface. + medium : solprop.spatial.SpatialMedium or None, optional + The exit medium, whose index must match the core's. + index : numpy.ndarray or None, optional + Its real phase index at each bin instead, shape ``(n_omega,)``; by default + the guide's own core index. + + Raises + ------ + CouplingError + For both a medium and an index. + + Examples + -------- + >>> from solprop.grid import RadialGrid + >>> Outcouple(RadialGrid(300e-6, 128)) + Outcouple(matched, RadialGrid) + """ + + space: RadialGrid | FreeGrid + interface: OutcouplingModel = "matched" + medium: SpatialMedium | None = None + index: Float[np.ndarray, " n_omega"] | None = None + + def __post_init__(self) -> None: + """Check that the exit medium is given once.""" + if self.medium is not None and self.index is not None: + msg = "give the exit medium or its index, not both" + raise CouplingError(msg) + + def __repr__(self) -> str: + """Return a one-line description without arrays. + + Returns + ------- + str + The interface model and the grid's class. + """ + return f"Outcouple({self.interface}, {type(self.space).__name__})" + + def apply(self, plane: FieldPlane) -> OutcoupleResult: + """Synthesise a modal plane's exit field on the grid. + + Parameters + ---------- + plane : solprop.coupling.plane.FieldPlane + An ordinary modal plane carrying its scalar profiles (read with + ``field_plane(run, source=simulation)``). + + Returns + ------- + OutcoupleResult + The spatial plane and its energy account. + + Raises + ------ + CouplingError + If the plane is not modal or has no profiles, or the exit medium's index + is not the core's. + """ + profiles = plane.profiles + if plane.kind != "modal" or profiles is None: + msg = ( + "Outcouple synthesises an ordinary modal plane with its profiles: read " + "it with field_plane(run, source=simulation)" + ) + raise CouplingError(msg) + grid = plane.grid + core = cast("np.ndarray", profiles.core_index) + if self.medium is not None: + flux = SpatialFlux.resolve(grid, self.space, self.medium, z=0.0) + metric = PlaneMetric.from_flux(flux) + live = (grid.omega > 0) & grid.active_mask + outside = np.zeros(grid.n_omega) + outside[live] = np.real( + np.asarray(self.medium.index(grid.omega[live], 0.0)) + ) + else: + outside = ( + core if self.index is None else np.asarray(self.index, dtype=np.float64) + ) + metric = PlaneMetric.paraxial(self.space, outside, grid) + occupied = np.sum(np.abs(plane.samples) ** 2, axis=(0, 1)) > 0.0 + scale = np.maximum(np.abs(core[occupied]), 1e-300) + if np.any( + np.abs(outside[occupied] - core[occupied]) > METRIC_TOLERANCE * scale + ): + msg = ( + "the exit medium's index is not the guide's core index: the 'matched' " + "model has no index change; put a PlanarInterface after it" + ) + raise CouplingError(msg) + columns = target_columns(profiles, self.space) + jones = np.asarray(profiles.jones) + n_pol_in = plane.samples.shape[1] + if n_pol_in == 2: + jones_c = np.tile(np.eye(2), (profiles.n_modes, 1)) + mode_of = np.repeat(np.arange(profiles.n_modes), 2) + else: + jones_c, mode_of = jones, np.arange(profiles.n_modes) + n_pol = 2 if np.any(jones_c[:, 1] != 0.0) else 1 + effective = profiles.effective_index[mode_of] + factor = np.where( + effective > 0, + np.sqrt(2.0 / (EPS0 * C * np.where(effective > 0, effective, 1.0))), + 0.0, + ) + coefficients = fourier.spectrum_to_internal(plane.samples, kind=grid.kind) + amplitudes = coefficients.reshape(-1, grid.n_omega) * factor + internal = np.einsum( + "cj,cp,cw->jpw", + columns[mode_of], + jones_c[:, :n_pol].astype(complex), + amplitudes, + ) + samples = fourier.spectrum_to_optics(internal, kind=grid.kind) + weights = np.asarray(self.space.spectral_weights) + gram = np.einsum("j,mj,nj->mn", weights, columns.conj(), columns) + values = np.linalg.eigvalsh(0.5 * (gram + gram.conj().T)) + window = float(max(abs(values[0] - 1.0), abs(values[-1] - 1.0))) + basis = PlaneBasis.of_space(self.space) + synthesised_plane = FieldPlane( + basis.kind, + samples, + grid, + "V m" if basis.kind == "radial" else "V/m", + basis, + plane.reference, + plane.geometry, + metric, + plane.exterior, + self.medium, + plane.certificate, + provenance=plane.provenance.then( + f"Outcouple({self.interface}) onto {type(self.space).__name__}" + ), + ) + return OutcoupleResult( + synthesised_plane, + plane.spectral_energy(), + synthesised_plane.spectral_energy(), + window, + ) diff --git a/src/solprop/coupling/plane.py b/src/solprop/coupling/plane.py index b3726ed94..c73e5511d 100644 --- a/src/solprop/coupling/plane.py +++ b/src/solprop/coupling/plane.py @@ -44,7 +44,7 @@ import hashlib import json import math -from collections.abc import Mapping, Sequence +from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass, field from types import MappingProxyType from typing import Literal, cast @@ -53,7 +53,9 @@ from jaxtyping import Complex, Float from solprop import fourier -from solprop.exceptions import CouplingError +from solprop.constants import EPS0, C +from solprop.coupling.models import ScalarModeBasis +from solprop.exceptions import CouplingError, PulseError from solprop.grid import FreeGrid, RadialGrid, TimeGrid from solprop.modes.protocol import Mode from solprop.modes.slab import PlanarGrid @@ -61,6 +63,8 @@ __all__ = [ "PLANE_KINDS", + "AnalyticField", + "AnalyticRecord", "Calibration", "Exterior", "FieldPlane", @@ -770,6 +774,60 @@ def from_flux(cls, flux: SpatialFlux) -> "PlaneMetric": # noqa: UP037 - beartyp flux=flux, ) + @classmethod + def paraxial( + cls, + space: RadialGrid | FreeGrid, + index: Float[np.ndarray, " n_omega"], + grid: TimeGrid, + ) -> "PlaneMetric": # noqa: UP037 - beartype evaluates the class body + r"""Return the axial admittance metric of a lossless medium given by its index. + + Each column's admittance is :math:`ck_z/\omega = \sqrt{n^2 - + (c\kappa/\omega)^2}`, zero where the column does not propagate and on bins + where the index is zero: the definition SEP 34's flux uses, for a medium + known only by its real phase index. + + Parameters + ---------- + space : solprop.grid.RadialGrid or solprop.grid.FreeGrid + The transverse grid. + index : numpy.ndarray + Real phase index at each bin, shape ``(n_omega,)``, non-negative. + grid : solprop.grid.TimeGrid + The temporal grid. + + Returns + ------- + PlaneMetric + The ``admittance`` metric, with ``eps0 c / 2``; no `flux`. + + Raises + ------ + CouplingError + If the index has the wrong shape or is negative or nonfinite. + """ + values = np.asarray(index, dtype=np.float64) + if values.shape != (grid.n_omega,) or not np.all( + np.isfinite(values) & (values >= 0.0) + ): + msg = "a reference index is finite and non-negative, one value per bin" + raise CouplingError(msg) + kappa_sq = _kappa_squared(space) + live = (grid.omega > 0) & (values > 0) + ratio = np.zeros(grid.n_omega) + ratio[live] = C / grid.omega[live] + square = values[None, :] ** 2 - kappa_sq[:, None] * ratio[None, :] ** 2 + admittance = np.where( + live[None, :] & (square > 0), np.sqrt(np.abs(square)), 0.0 + ) + return cls( + "admittance", + admittance=admittance.astype(np.complex128), + weights=space.spectral_weights, + scale=0.5 * EPS0 * C, + ) + def spectral_energy( self, samples: Complex[np.ndarray, "n_modes n_pol n_omega"], grid: TimeGrid ) -> Float[np.ndarray, " n_omega"]: @@ -968,6 +1026,45 @@ def record(self) -> dict[str, object]: } +AnalyticField = Callable[..., np.ndarray] +"""An analytic field: ``field(r, omega)`` or ``field(x, y, omega)``, see +:meth:`FieldPlane.from_analytic`.""" + + +@dataclass(frozen=True) +class AnalyticRecord: + """What a plane sampled from an analytic field declares and was checked for. + + Attributes + ---------- + support : float or None + The declared radius beyond which the field is zero, m; None for an + unbounded field. + domain_change : float + Relative change of the plane's energy when its window is doubled at the + same node spacing: the share of the beam the window misses, to leading + order. + resolution_change : float + Relative change of the plane's energy when the node spacing is halved in the + same window: the sampling error of the energy. + """ + + support: float | None + domain_change: float + resolution_change: float + + @property + def error(self) -> float: + """Return the larger of the two checks, the energy's numerical error bound. + + Returns + ------- + float + ``max(domain_change, resolution_change)``, dimensionless. + """ + return max(self.domain_change, self.resolution_change) + + @dataclass(frozen=True, eq=False) class FieldPlane: """A coherent field at one physical plane, in its native representation. @@ -1009,6 +1106,13 @@ class FieldPlane: its source simulation. provenance : PlaneProvenance, optional Source identity, plane index, lineage and preparation history. + profiles : solprop.coupling.models.ScalarModeBasis or None, optional + The scalar transverse profiles of an ordinary modal plane's columns, for a + plane read with its source simulation; what a coupling model or + :class:`~solprop.coupling.outcouple.Outcouple` synthesises the field from. + analytic : AnalyticRecord or None, optional + The declared support and the domain and resolution checks of a plane + sampled from an analytic field (:meth:`from_analytic`). Raises ------ @@ -1030,6 +1134,8 @@ class FieldPlane: certificate: Mapping[str, object] | None = None nodal: NodalField | None = None provenance: PlaneProvenance = field(default_factory=PlaneProvenance) + profiles: ScalarModeBasis | None = None + analytic: AnalyticRecord | None = None def __post_init__(self) -> None: """Own read-only samples and check them against the grid and the basis.""" @@ -1045,6 +1151,11 @@ def __post_init__(self) -> None: f"a {self.kind} plane needs a {self.kind} basis, got {self.basis.kind}" ) raise CouplingError(msg) + if self.profiles is not None and ( + self.kind != "modal" or self.profiles.n_modes != samples.shape[0] + ): + msg = "scalar profiles describe the columns of an ordinary modal plane" + raise CouplingError(msg) object.__setattr__(self, "samples", samples) if self.certificate is not None: object.__setattr__( @@ -1143,35 +1254,51 @@ def node_spectra(self) -> Complex[np.ndarray, "n_nodes n_pol n_omega"]: ) @classmethod - def from_pulse( + def from_pulse( # noqa: PLR0913 - the beam, its grids and the plane's medium cls, pulse: object, grid: TimeGrid, geometry: PlaneGeometry, *, space: RadialGrid | FreeGrid, - medium: SpatialMedium, + medium: SpatialMedium | None = None, + index: Float[np.ndarray, " n_omega"] | None = None, reference: PlaneReference | None = None, ) -> "FieldPlane": # noqa: UP037 - beartype evaluates the class body - """Build a spatial plane from the package's beam builder. + r"""Build a spatial plane from the package's beam builder. - The beam is :meth:`solprop.pulses.SpatialPulse.build` on the grid, with its - requested energy or peak power the physical axial flux through the plane in - the medium there (SEP 34); no second Gaussian is written. Its exterior is - zero: the closed-form beam is resolved on the grid's whole window. + No second Gaussian is written: the beam is the + :class:`~solprop.pulses.SpatialPulse`'s own, in one of its two measures. + + - With `medium`, it is :meth:`solprop.pulses.SpatialPulse.build` on the grid, + its requested energy or peak power the physical axial flux through the + plane in that medium (SEP 34). + - With `index` -- the real phase index of a medium for which no + :class:`~solprop.spatial.SpatialMedium` exists, such as a guide's core -- + it is :meth:`solprop.pulses.SpatialPulse.incident` sampled at the grid's + nodes: the request is met over the beam's whole plane in the paraxial + measure :math:`M_0 = n\varepsilon_0c\,dA/2`, in closed form, and the + plane's metric is the axial admittance :math:`\sqrt{n^2 - (c\kappa/ + \omega)^2}` of that index. The two measures differ by the beam's + obliquity, which a coupling model reports as its source-measure defect. + + The exterior is zero: the closed-form beam is resolved on the grid's window. Parameters ---------- pulse : solprop.pulses.SpatialPulse - The beam. + The beam, launched at its waist with `index`. grid : solprop.grid.TimeGrid Temporal grid. geometry : PlaneGeometry The plane; its ``z`` and ``side`` select the medium's sample. space : solprop.grid.RadialGrid or solprop.grid.FreeGrid Transverse grid. - medium : solprop.spatial.SpatialMedium + medium : solprop.spatial.SpatialMedium or None, optional The medium at the plane. + index : numpy.ndarray or None, optional + Real phase index at each bin, shape ``(n_omega,)``, instead of `medium`; + zero on bins that carry no flux. reference : PlaneReference or None, optional The reference of the built spectrum; default zero phase and delay. @@ -1183,32 +1310,218 @@ def from_pulse( Raises ------ CouplingError - If `pulse` has no spatial ``build``. + If `pulse` is not a spatial beam, or not exactly one of `medium` and + `index` is given, or the beam builder refuses it. """ build = getattr(pulse, "build", None) - if build is None or not callable(build): + incident = getattr(pulse, "incident", None) + if not (callable(build) and callable(incident)): msg = "from_pulse takes a SpatialPulse" raise CouplingError(msg) - spectrum = cast( - "np.ndarray", - build(grid, space, medium=medium, z=geometry.z, side=geometry.side), + if (medium is None) == (index is None): + msg = "from_pulse measures the beam in a medium= or an index=, one of them" + raise CouplingError(msg) + basis = PlaneBasis.of_space(space) + provenance = PlaneProvenance("pulse", history=(repr(pulse),)) + reference = PlaneReference() if reference is None else reference + units = "V m" if basis.kind == "radial" else "V/m" + if medium is not None: + spectrum = cast( + "np.ndarray", + build(grid, space, medium=medium, z=geometry.z, side=geometry.side), + ) + flux = SpatialFlux.resolve( + grid, space, medium, z=geometry.z, side=geometry.side + ) + return cls( + basis.kind, + fourier.spectrum_to_optics(spectrum, kind=grid.kind), + grid, + units, + basis, + reference, + geometry, + PlaneMetric.from_flux(flux), + "zero", + medium, + provenance=provenance, + ) + values = np.asarray(index, dtype=np.float64) + xy = _node_positions(space) + try: + sampled = incident(grid, xy, index=values) + except PulseError as error: + raise CouplingError(str(error)) from error + nodal = np.asarray(getattr(sampled, "field"), dtype=np.complex128) # noqa: B009 - the pulse is duck-typed in this P module + if len(getattr(pulse, "jones", (1.0,))) == 1: + nodal = nodal[:, :1] + columns = fourier.spectrum_to_optics( + _transverse(nodal, space, inverse=False), kind=grid.kind + ) + return cls( + basis.kind, + columns, + grid, + units, + basis, + reference, + geometry, + PlaneMetric.paraxial(space, values, grid), + "zero", + provenance=provenance, ) - flux = SpatialFlux.resolve( - grid, space, medium, z=geometry.z, side=geometry.side + + @classmethod + def from_analytic( # noqa: PLR0913 - the field, its grids and its declarations + cls, + grid: TimeGrid, + geometry: PlaneGeometry, + field: "AnalyticField", # noqa: UP037 - defined below this class + *, + space: RadialGrid | FreeGrid, + medium: SpatialMedium | None = None, + index: Float[np.ndarray, " n_omega"] | None = None, + support: float | None = None, + exterior: Exterior = "zero", + reference: PlaneReference | None = None, + ) -> "FieldPlane": # noqa: UP037 - beartype evaluates the class body + r"""Sample a physical analytic field on a transverse grid (SEP 31, section 5.1). + + `field` is called with the grid's node positions and the absolute angular + frequency of every positive bin, and returns the optics-convention Fourier + transform :math:`\tilde E(\mathbf r,\omega) = \int E(\mathbf r,t) + e^{+i\omega t}\,dt` of the real electric field, in V s/m, with t measured + from the plane's reference time (the window centre, plus `reference`): + + - on a radial grid ``field(r, omega)`` with ``r`` of shape ``(n_nodes,)``; + - on a Cartesian grid ``field(x, y, omega)`` with ``x`` and ``y`` of shape + ``(n_nodes,)``; + + each returning shape ``(n_nodes, n_pol, n_bins)`` (``n_pol`` 1 for x, 2 for + x and y) or ``(n_nodes, n_bins)`` for an x-polarised field. A raw DFT bin of + a localised record is :math:`q\tilde E/\Delta t`, with :math:`q = 1` on a + field grid and 2 on an envelope grid, whose envelope is the analytic signal. + + **Support and exterior.** `support` declares the field zero beyond a radius + (m), where it is not evaluated; without it the field is unbounded and only a + convergence check bounds what the window misses. The plane records + (:class:`AnalyticRecord`) the relative change of its energy when the window + is doubled at the same spacing (the domain check) and when the spacing is + halved in the same window (the resolution check), so a coupling counts both + as numerical error rather than assuming a crop is the whole beam. `exterior` + is what the record says beyond the window: ``'zero'`` for a field whose + tails the domain check bounds, ``'unknown'`` where they are not known. + + Parameters + ---------- + grid : solprop.grid.TimeGrid + Temporal grid. + geometry : PlaneGeometry + The plane. + field : callable + The analytic field, as above. + space : solprop.grid.RadialGrid or solprop.grid.FreeGrid + Transverse grid. + medium : solprop.spatial.SpatialMedium or None, optional + The medium at the plane. + index : numpy.ndarray or None, optional + Real phase index at each bin instead of `medium`, shape ``(n_omega,)``. + support : float or None, optional + Radius beyond which the field is declared zero, m; inside the window. + exterior : {'zero', 'unknown'}, optional + What the record says beyond its window. + reference : PlaneReference or None, optional + Reference of the spectrum; default zero phase and delay. + + Returns + ------- + FieldPlane + A radial or Cartesian plane with its admittance metric and its + :class:`AnalyticRecord`. + + Raises + ------ + CouplingError + If not exactly one of `medium` and `index` is given, the support lies + outside the window, or the field returns the wrong shape or nonfinite + values. + + Examples + -------- + >>> import numpy as np + >>> from solprop.coupling import FieldPlane, PlaneGeometry + >>> from solprop.grid import EnvelopeGrid, RadialGrid + >>> grid = EnvelopeGrid.from_limits(1e-3, 800e-9, (700e-9, 900e-9), 300e-15) + >>> def beam(r, omega): + ... spectral = np.exp(-(((omega - grid.omega0) * 20e-15) ** 2)) + ... return np.exp(-((r / 50e-6) ** 2))[:, None] * spectral[None, :] + >>> index = np.where(grid.omega > 0, 1.0, 0.0) + >>> plane = FieldPlane.from_analytic( + ... grid, PlaneGeometry(0.0), beam, space=RadialGrid(300e-6, 128), + ... index=index, + ... ) + >>> plane.analytic.domain_change < 1e-12 + True + """ + if (medium is None) == (index is None): + msg = "from_analytic measures the field in a medium= or an index=, one" + raise CouplingError(msg) + extent = _extent(space) + if support is not None and not ( + math.isfinite(support) and 0 < support <= extent + ): + msg = ( + f"the declared support {support!r} m lies outside the window " + f"({extent:.6g} m): widen the grid" + ) + raise CouplingError(msg) + values = None if index is None else np.asarray(index, dtype=np.float64) + columns = _analytic_columns(field, grid, space, support) + metric = ( + PlaneMetric.from_flux( + SpatialFlux.resolve( + grid, space, medium, z=geometry.z, side=geometry.side + ) + ) + if medium is not None + else PlaneMetric.paraxial(space, cast("np.ndarray", values), grid) ) + energy = float(np.sum(metric.spectral_energy(columns, grid))) + checks = [] + for wider in (_enlarged(space), _refined(space)): + wide_metric = ( + PlaneMetric.from_flux( + SpatialFlux.resolve( + grid, wider, medium, z=geometry.z, side=geometry.side + ) + ) + if medium is not None + else PlaneMetric.paraxial(wider, cast("np.ndarray", values), grid) + ) + wide = float( + np.sum( + wide_metric.spectral_energy( + _analytic_columns(field, grid, wider, support), grid + ) + ) + ) + checks.append( + 0.0 if wide == energy else abs(wide - energy) / max(wide, energy) + ) basis = PlaneBasis.of_space(space) return cls( basis.kind, - fourier.spectrum_to_optics(spectrum, kind=grid.kind), + columns, grid, "V m" if basis.kind == "radial" else "V/m", basis, PlaneReference() if reference is None else reference, geometry, - PlaneMetric.from_flux(flux), - "zero", + metric, + exterior, medium, - provenance=PlaneProvenance("pulse", history=(repr(pulse),)), + provenance=PlaneProvenance("analytic", history=(repr(field),)), + analytic=AnalyticRecord(support, checks[0], checks[1]), ) @classmethod @@ -1224,6 +1537,7 @@ def from_samples( # noqa: PLR0913 - the arrays and every declaration they need basis: PlaneBasis | None = None, metric: PlaneMetric | None = None, reference: PlaneReference | None = None, + profiles: ScalarModeBasis | None = None, ) -> "FieldPlane": # noqa: UP037 - beartype evaluates the class body """Bind physical arrays to explicit axes, units, domain, medium and reference. @@ -1257,6 +1571,8 @@ def from_samples( # noqa: PLR0913 - the arrays and every declaration they need Metric of a modal basis; default the identity. reference : PlaneReference or None, optional Reference of the spectrum; default zero phase and delay. + profiles : solprop.coupling.models.ScalarModeBasis or None, optional + The scalar profiles of an ordinary modal basis's columns. Returns ------- @@ -1321,6 +1637,7 @@ def from_samples( # noqa: PLR0913 - the arrays and every declaration they need PlaneMetric("identity") if metric is None else metric, exterior, provenance=provenance, + profiles=profiles, ) @@ -1357,3 +1674,176 @@ def _transverse( else np.fft.fft2(shaped, axes=(0, 1)) ) return result.reshape(values.shape) + + +def _kappa_squared(space: RadialGrid | FreeGrid) -> Float[np.ndarray, " n_columns"]: + """Return each transverse column's squared wavenumber, m^-2. + + Parameters + ---------- + space : solprop.grid.RadialGrid or solprop.grid.FreeGrid + The grid. + + Returns + ------- + numpy.ndarray + Shape ``(n_columns,)``. + """ + if isinstance(space, RadialGrid): + return np.asarray(space.k_r, dtype=np.float64) ** 2 + return np.asarray(space.kperp_sq, dtype=np.float64) + + +def _node_positions(space: RadialGrid | FreeGrid) -> Float[np.ndarray, "n_nodes 2"]: + """Return a grid's nodes as ``(x, y)`` pairs; radial nodes lie on the x axis. + + Parameters + ---------- + space : solprop.grid.RadialGrid or solprop.grid.FreeGrid + The grid. + + Returns + ------- + numpy.ndarray + Positions in m, shape ``(n_nodes, 2)``, in the grid's node order. + """ + if isinstance(space, RadialGrid): + return np.column_stack((space.r, np.zeros_like(space.r))) + x = np.tile(space.x, space.n_y) + y = np.repeat(space.y, space.n_x) + return np.column_stack((x, y)) + + +def _extent(space: RadialGrid | FreeGrid) -> float: + """Return the radius of the largest centred disc inside a grid's window, m. + + Parameters + ---------- + space : solprop.grid.RadialGrid or solprop.grid.FreeGrid + The grid. + + Returns + ------- + float + The radial window, or the smaller Cartesian half-width. + """ + if isinstance(space, RadialGrid): + return float(space.hankel.radius) + return float(min(np.max(np.abs(space.x)), np.max(np.abs(space.y)))) + + +def _enlarged(space: RadialGrid | FreeGrid) -> RadialGrid | FreeGrid: + """Return the grid with its window doubled at the same node spacing. + + Parameters + ---------- + space : solprop.grid.RadialGrid or solprop.grid.FreeGrid + The grid. + + Returns + ------- + solprop.grid.RadialGrid or solprop.grid.FreeGrid + The doubled grid. + """ + if isinstance(space, RadialGrid): + return RadialGrid(2.0 * space.radius, 2 * space.n_r, space.window_factor) + return FreeGrid( + 2.0 * space.radius_x, + 2 * space.n_x, + 2.0 * space.radius_y, + 2 * space.n_y, + space.window_factor, + ) + + +def _refined(space: RadialGrid | FreeGrid) -> RadialGrid | FreeGrid: + """Return the grid with its node spacing halved in the same window. + + Parameters + ---------- + space : solprop.grid.RadialGrid or solprop.grid.FreeGrid + The grid. + + Returns + ------- + solprop.grid.RadialGrid or solprop.grid.FreeGrid + The refined grid. + """ + if isinstance(space, RadialGrid): + return RadialGrid(space.radius, 2 * space.n_r, space.window_factor) + return FreeGrid( + space.radius_x, + 2 * space.n_x, + space.radius_y, + 2 * space.n_y, + space.window_factor, + ) + + +def _analytic_columns( + field: AnalyticField, + grid: TimeGrid, + space: RadialGrid | FreeGrid, + support: float | None, +) -> Complex[np.ndarray, "n_columns n_pol n_omega"]: + """Sample an analytic field at a grid's nodes and return its plane columns. + + Parameters + ---------- + field : callable + The analytic field (:meth:`FieldPlane.from_analytic`). + grid : solprop.grid.TimeGrid + Temporal grid. + space : solprop.grid.RadialGrid or solprop.grid.FreeGrid + Transverse grid. + support : float or None + Declared support radius, m. + + Returns + ------- + numpy.ndarray + Optics-convention raw column spectra referenced to the window centre, + shape ``(n_columns, n_pol, n_omega)``. + + Raises + ------ + CouplingError + If the field returns the wrong shape or nonfinite values. + """ + xy = _node_positions(space) + radius = np.hypot(xy[:, 0], xy[:, 1]) + inside = np.ones(radius.size, dtype=bool) if support is None else radius <= support + live = grid.omega > 0 + omega = grid.omega[live] + if isinstance(space, RadialGrid): + raw = field(radius[inside], omega) + else: + raw = field(xy[inside, 0], xy[inside, 1], omega) + values = np.asarray(raw, dtype=np.complex128) + if values.ndim == 2: + values = values[:, None, :] + if ( + values.ndim != 3 + or values.shape[0] != int(np.count_nonzero(inside)) + or values.shape[1] not in (1, 2) + or values.shape[2] != omega.size + ): + msg = ( + "an analytic field returns (n_nodes, n_pol, n_bins) or (n_nodes, n_bins) " + f"with n_pol 1 or 2; got {values.shape}" + ) + raise CouplingError(msg) + if not np.all(np.isfinite(values)): + msg = "the analytic field returned nonfinite values" + raise CouplingError(msg) + # A raw DFT bin of a localised record is q E~/dt: q = 2 on an envelope grid, + # whose envelope is the analytic signal (twice the positive-frequency part). + factor = (2.0 if grid.kind == "envelope" else 1.0) / grid.dt + nodal = np.zeros((radius.size, values.shape[1], grid.n_omega), dtype=np.complex128) + nodal[np.ix_(inside, np.arange(values.shape[1]), np.flatnonzero(live))] = ( + factor * values + ) + internal = fourier.spectrum_to_internal(nodal, kind=grid.kind) + return fourier.spectrum_to_optics( + _transverse(internal, space, inverse=False), kind=grid.kind + ) diff --git a/src/solprop/coupling/port.py b/src/solprop/coupling/port.py index f1d635965..674d15414 100644 --- a/src/solprop/coupling/port.py +++ b/src/solprop/coupling/port.py @@ -15,11 +15,7 @@ A :class:`CouplingLaunch` is bound to one port: it carries the port, the target coefficients and the reference origin the launched run inherits, and :meth:`solprop.driver.simulation.Simulation.with_input` refuses it for any other port, -whatever the array shapes. - -:func:`same_basis` is the identity model of section 4.3: a plane whose basis, -lattice, orientation and metric are the port's is copied as it stands, and its -reference becomes the launched run's origin. Phase 3 adds the general ``couple``. +whatever the array shapes. :func:`solprop.coupling.couple.couple` computes one. """ import hashlib @@ -34,8 +30,8 @@ from jaxtyping import Complex, Float from solprop import fourier +from solprop.coupling.models import ScalarModeBasis from solprop.coupling.plane import ( - FieldPlane, PlaneBasis, PlaneGeometry, PlaneKind, @@ -51,7 +47,8 @@ "METRIC_TOLERANCE", "CouplingLaunch", "InputPort", - "same_basis", + "check_same_medium", + "occupied_outside", ] ENTRANCE_TOLERANCE = 1e-12 @@ -70,7 +67,7 @@ """ -def _occupied_outside( +def occupied_outside( samples: Complex[np.ndarray, "n_modes n_pol n_omega"], grid: TimeGrid ) -> float: """Return the share of a record's squared samples outside a grid's active band. @@ -94,7 +91,7 @@ def _occupied_outside( @dataclass(frozen=True, eq=False) class InputPort: - """What a simulation can receive at its entrance, already sampled. + r"""What a simulation can receive at its entrance, already sampled. Attributes ---------- @@ -130,7 +127,17 @@ class InputPort: magnetic : numpy.ndarray or None, optional Its magnetic field in A/(m sqrt(W)), the same shape. models : tuple of str, optional - The coupling models the port admits. Phase 2 admits ``'same_basis'``. + The coupling models of :class:`~solprop.coupling.models.CouplingModel` the + port admits; ``same_basis`` always. + profiles : solprop.coupling.models.ScalarModeBasis or None, optional + The scalar transverse profiles of an ordinary mode set's columns: a + capillary's HE1m modes or a weakly guiding step-index fibre's LP0m modes. + contrast : numpy.ndarray or None, optional + The weak-guidance screen + :math:`\Delta = \max|n^2 - n_{\rm ref}^2|/(2n_{\rm ref}^2)` over the + guide's dielectric regions at each bin, shape ``(n_omega,)``, zero off the + active band; None where the screen does not apply (a capillary wall, a + spatial medium). Raises ------ @@ -151,6 +158,8 @@ class InputPort: electric: Complex[np.ndarray, "n_nodes 3 n_modes n_omega"] | None = None magnetic: Complex[np.ndarray, "n_nodes 3 n_modes n_omega"] | None = None models: tuple[str, ...] = ("same_basis",) + profiles: ScalarModeBasis | None = None + contrast: Float[np.ndarray, " n_omega"] | None = None def __post_init__(self) -> None: """Check the shape, the basis and the reference index.""" @@ -170,11 +179,15 @@ def __post_init__(self) -> None: raise CouplingError(msg) index.setflags(write=False) object.__setattr__(self, "reference_index", index) + if self.profiles is not None and self.profiles.n_modes != shape[0]: + msg = "a port's scalar profiles describe its columns, one per mode" + raise CouplingError(msg) for name, dtype in ( ("nodes", np.float64), ("node_weights", np.float64), ("electric", np.complex128), ("magnetic", np.complex128), + ("contrast", np.float64), ): value = getattr(self, name) if value is not None: @@ -247,7 +260,7 @@ def accepts(self, other: "InputPort") -> str | None: # noqa: UP037 - beartype e if not self.geometry.oriented_as(other.geometry): return "the launch's entrance plane is oriented differently" try: - _check_same_medium(other.metric, self.metric) + check_same_medium(other.metric, self.metric) except CouplingError as refused: return f"the launch's metric is not this simulation's: {refused}" return None @@ -342,85 +355,11 @@ def record(self) -> dict[str, object]: } -def same_basis(plane: FieldPlane, port: InputPort) -> CouplingLaunch: - """Launch a plane into a port of the same basis, unchanged. - - The identity model of SEP 31 section 4.3: exact coordinate recovery in a - checked common reference, which here is the identical basis on the identical - lattice, at an identically oriented plane, in the same medium. The coefficients - are copied as they stand and the plane's reference becomes the launched run's - origin, so the launched run continues the source's absolute time and carrier - phase without moving the pulse in its window. Anything else is a different - calculation, refused by name: a different lattice needs - :func:`~solprop.coupling.remap.regrid` first, a different basis or medium a coupling - model or an interface. - - Parameters - ---------- - plane : FieldPlane - The source plane. - port : InputPort - The receiving port. - - Returns - ------- - CouplingLaunch - The launch, bound to `port`. - - Raises - ------ - CouplingError - If the port does not admit the model; if the route, basis, lattice, shape, - orientation or medium differ; or if the plane carries light outside the - port's active band. - """ - if "same_basis" not in port.models: - msg = f"this port admits {port.models}, not same_basis" - raise CouplingError(msg) - if plane.kind != port.route or plane.basis.identity != port.basis.identity: - msg = ( - f"a {plane.kind} plane of basis {plane.basis.identity[:12]} is not the " - f"{port.route} port's basis {port.basis.identity[:12]}: a different basis " - "is a coupling, not a relaunch" - ) - raise CouplingError(msg) - if lattice_identity(plane.grid) != port.lattice or not np.array_equal( - plane.grid.omega, port.grid.omega - ): - msg = "the plane's temporal lattice is not the port's: regrid it first" - raise CouplingError(msg) - if plane.samples.shape != port.shape: - msg = f"the plane's shape {plane.samples.shape} is not the port's {port.shape}" - raise CouplingError(msg) - if not plane.geometry.oriented_as(port.geometry): - msg = "the plane and the entrance are oriented differently" - raise CouplingError(msg) - _check_same_medium(plane.metric, port.metric) - outside = _occupied_outside(plane.samples, port.grid) - floor = (plane.grid.n_omega * float(np.finfo(float).eps)) ** 2 - if outside > floor: - msg = ( - f"{outside:.3e} of the plane lies outside the port's active band, which " - "the receiving run would remove: regrid with crop_band=True to remove " - "it explicitly" - ) - raise CouplingError(msg) - provenance = { - "plane": plane.provenance.record(), - "reference": plane.reference.record(), - } - return CouplingLaunch( - port, - plane.samples, - plane.reference.minus(port.frame), - "same_basis", - provenance, - ) - - -def _check_same_medium(source: PlaneMetric, target: PlaneMetric) -> None: +def check_same_medium(source: PlaneMetric, target: PlaneMetric) -> None: """Refuse a relaunch across a change of medium or metric. + Used by :meth:`InputPort.accepts` and by the ``same_basis`` model. + Parameters ---------- source : PlaneMetric diff --git a/src/solprop/processing/planes.py b/src/solprop/processing/planes.py index 315e86062..1c8ae5953 100644 --- a/src/solprop/processing/planes.py +++ b/src/solprop/processing/planes.py @@ -33,12 +33,14 @@ PlaneKind, PlaneMetric, PlaneProvenance, + ScalarModeBasis, basis_identity, reference_at, ) -from solprop.exceptions import OutputError, ProcessingError +from solprop.exceptions import CouplingError, OutputError, ProcessingError from solprop.grid import FreeGrid, LineGrid, RadialGrid, TimeGrid from solprop.modes.guide import ModalGuide +from solprop.modes.protocol import Mode from solprop.output.schema import Output, RunMetadata from solprop.processing.axes import _plane_field, grid_of from solprop.processing.modal import _checked_tables, _plane_rule, _spectra @@ -294,6 +296,47 @@ def _spatial_metric( ) +def _modal_profiles( + metadata: RunMetadata, grid: TimeGrid, position: float, source: object +) -> ScalarModeBasis | None: + """Return an ordinary modal plane's scalar profiles from its source simulation. + + Parameters + ---------- + metadata : solprop.output.schema.RunMetadata + The output's record. + grid : solprop.grid.TimeGrid + Its temporal grid. + position : float + The plane's position, m. + source : Simulation + The simulation that wrote the output. + + Returns + ------- + solprop.coupling.ScalarModeBasis or None + The profiles; None for a mode family with angular structure, which has + none. + + Raises + ------ + ProcessingError + If `source` is not the run that wrote the output. + """ + record = cast( + "RunMetadata", + cast("Callable[[], object]", _source_part(source, "metadata"))(), + ) + if record.configuration_hash() != metadata.configuration_hash(): + msg = "source= is not the simulation that wrote this output" + raise ProcessingError(msg) + modes = cast("Sequence[Mode]", _source_part(source, "modes")) + try: + return ScalarModeBasis.from_modes(modes, grid, position) + except CouplingError: + return None + + def _general_parts( output: Output, metadata: RunMetadata, @@ -411,8 +454,10 @@ def field_plane( source : solprop.driver.Simulation or None, optional The simulation that wrote the output, for what the file deliberately does not embed: a general guide's E/H reference and a dense Gram, checked against - the file's identities, or a spatial run's medium, checked by configuration - hash. Not needed otherwise. + the file's identities; a spatial run's medium, or an ordinary mode set's + scalar profiles (:class:`~solprop.coupling.models.ScalarModeBasis`, for a + capillary's HE1m or a step-index fibre's HE1m modes), checked by + configuration hash. Not needed otherwise. Returns ------- @@ -496,9 +541,12 @@ def field_plane( ) basis = PlaneBasis(kind, identity, labels) nodal = None + profiles = None metric = PlaneMetric("identity") if kind == "general_modal": metric, nodal = _general_parts(output, metadata, index, position, source) + elif source is not None: + profiles = _modal_profiles(metadata, grid, position, source) return FieldPlane( kind, samples, @@ -511,4 +559,5 @@ def field_plane( "zero", nodal=nodal, provenance=provenance, + profiles=profiles, ) diff --git a/src/solprop/translate/coupling.py b/src/solprop/translate/coupling.py index 70b4191f9..98db738ba 100644 --- a/src/solprop/translate/coupling.py +++ b/src/solprop/translate/coupling.py @@ -10,11 +10,14 @@ without the run (output schema 24). :func:`input_port` fills the :class:`solprop.coupling.port.InputPort` of a translated problem's entrance: its native metric at that plane, the frame it has accumulated there, the entrance medium's -index and, for a general guide that does not move, its E/H reference at the nodes. -Both read the contract's own leaves, so a port and a saved plane of the same run see -the same numbers. +index, the coupling models it admits and what they need: an ordinary mode set's +scalar profiles, and for a general guide that does not move its E/H reference at the +nodes and the weak-guidance contrast of its regions (SEP 31, section 4.3). Both read +the contract's own leaves, so a port and a saved plane of the same run see the same +numbers. """ +from collections.abc import Sequence from typing import cast import numpy as np @@ -27,10 +30,13 @@ PlaneKind, PlaneMetric, PlaneReference, + ScalarModeBasis, reference_at, ) -from solprop.exceptions import TranslationError +from solprop.exceptions import CouplingError, TranslationError from solprop.grid import TimeGrid +from solprop.modes.guide import ModalGuide +from solprop.modes.protocol import Mode from solprop.spatial import FluxSide, SpatialMedium from solprop.translate.chebyshev import PiecewiseChebyshev from solprop.translate.coupled import metric_at @@ -39,7 +45,7 @@ from solprop.translate.problem import KernelProblem from solprop.translate.spatial import axial_admittance -__all__ = ["frame_record", "input_port", "port_metric"] +__all__ = ["frame_record", "guide_contrast", "input_port", "port_metric"] def frame_record( @@ -151,9 +157,17 @@ def input_port( # noqa: PLR0913 - the problem and each part of the entrance it tables: ModalTransformTables | None = None, side: FluxSide | None = None, z: float | None = None, + modes: Sequence[Mode] = (), + guide: ModalGuide | None = None, ) -> InputPort: """Fill the input port of a translated problem's entrance. + The models it admits: ``same_basis`` always; ``scalar_paraxial`` for an + ordinary mode set whose scalar profiles exist (a capillary's HE1m modes, a + step-index fibre's HE1m modes in the weak-guidance limit), with the core's index + as the reference; ``vector_paraxial`` for a general guide whose E/H the port + carries, with its core's index and its regions' contrast. + Parameters ---------- problem : solprop.translate.problem.KernelProblem @@ -178,11 +192,15 @@ def input_port( # noqa: PLR0913 - the problem and each part of the entrance it z : float or None, optional An interior entrance, m, inside the problem's span; None is its launch position. + modes : sequence of solprop.modes.protocol.Mode, optional + An ordinary modal run's modes, for their scalar profiles. + guide : solprop.modes.guide.ModalGuide or None, optional + A general modal run's guide, for its core index and contrast. Returns ------- solprop.coupling.InputPort - The port, admitting the ``same_basis`` model. + The port and the models it admits. """ z = float(problem.initial.z) if z is None else float(z) record: dict[str, object] = { @@ -197,6 +215,8 @@ def input_port( # noqa: PLR0913 - the problem and each part of the entrance it values[live] = np.real(medium.index(grid.omega[live], z)) index = values nodes = weights = electric = magnetic = None + models: tuple[str, ...] = ("same_basis",) + profiles = contrast = None if tables is not None: synthesis = tables.synthesis moves = synthesis is not None and synthesis.shape[0] * synthesis.shape[1] > 1 @@ -207,6 +227,21 @@ def input_port( # noqa: PLR0913 - the problem and each part of the entrance it # a plane's samples. electric = np.conj(tables.electric[0]) magnetic = np.conj(tables.magnetic[0]) + if guide is not None: + index, contrast = guide_contrast(guide, grid) + models = ("same_basis", "vector_paraxial") + if route == "modal" and modes: + try: + profiles = ScalarModeBasis.from_modes(modes, grid, z) + except CouplingError: + # A family with angular structure has no scalar profiles; the port then + # admits only the identity model, and couple names why. + profiles = None + if profiles is not None: + index = np.asarray(profiles.core_index) + models = ("same_basis", "scalar_paraxial") + if profiles.family == "step_index": + contrast = _scalar_contrast(profiles, index) shape = cast("tuple[int, int, int]", tuple(int(one) for one in problem.shape[1:])) return InputPort( route, @@ -221,4 +256,79 @@ def input_port( # noqa: PLR0913 - the problem and each part of the entrance it weights, electric, magnetic, + models, + profiles, + contrast, + ) + + +def _scalar_contrast(profiles: ScalarModeBasis, index: np.ndarray) -> np.ndarray: + """Return a step-index profile set's weak-guidance contrast at each bin. + + Parameters + ---------- + profiles : solprop.coupling.ScalarModeBasis + A step-index LP set, carrying its core and cladding indices. + index : numpy.ndarray + The reference index per bin. + + Returns + ------- + numpy.ndarray + ``max |n^2 - n_ref^2| / (2 n_ref^2)`` over core and cladding, zero where + the reference index is zero, shape ``(n_omega,)``. + """ + live = index > 0 + contrast = np.zeros(index.shape) + for values in (profiles.core_index, profiles.cladding_index): + values = cast("np.ndarray", values) + contrast[live] = np.maximum( + contrast[live], + np.abs(values[live] ** 2 - index[live] ** 2) / (2.0 * index[live] ** 2), + ) + return contrast + + +def guide_contrast(guide: ModalGuide, grid: TimeGrid) -> tuple[np.ndarray, np.ndarray]: + r"""Return a general guide's core index and weak-guidance contrast at each bin. + + The core is the region on the axis, whose real phase index is the reference + :math:`n_{\rm ref}`; the contrast is + :math:`\Delta = \max|n^2 - n_{\rm ref}^2|/(2n_{\rm ref}^2)` over every region's + law and, for a radial-law provider, over its core profile sampled at 33 radii + (SEP 31, section 4.3). + + Parameters + ---------- + guide : solprop.modes.guide.ModalGuide + The guide. + grid : solprop.grid.TimeGrid + The run's grid. + + Returns + ------- + tuple of numpy.ndarray + The reference index and the contrast, each shape ``(n_omega,)``, zero off + the active positive band. + """ + live = grid.active_mask & (grid.omega > 0) + omega = grid.omega[live] + index = np.zeros(grid.n_omega) + index[live] = np.real(np.sqrt(guide.regions[0].material.permittivity(omega))) + squares = [ + np.real(np.asarray(region.material.permittivity(omega))) + for region in guide.regions + ] + radial = getattr(guide.field_provider, "permittivity", None) + if radial is not None and callable(getattr(radial, "permittivity", None)): + fractions = np.linspace(0.0, 1.0, 33) + profile = np.array( + [np.real(radial.permittivity(float(one), fractions)) for one in omega] + ) + squares.extend([profile.min(axis=1), profile.max(axis=1)]) + reference = index[live] ** 2 + contrast = np.zeros(grid.n_omega) + contrast[live] = np.max( + [np.abs(one - reference) / (2.0 * reference) for one in squares], axis=0 ) + return index, contrast diff --git a/tests/coupling/test_couple.py b/tests/coupling/test_couple.py new file mode 100644 index 000000000..1edbd5c56 --- /dev/null +++ b/tests/coupling/test_couple.py @@ -0,0 +1,905 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""The coupling models of ``couple`` against analytic and independent references. + +SEP 31 sections 4.3, 4.4 and 7: the Gaussian overlap formulas, an independent +Gaussian--Bessel quadrature, nested mode sets with domain, quadrature and modal +refinement kept apart, poor and zero capture with nothing restored, rotated +degenerate bases, coherent interference and gauge invariance, a change of capillary +radius by direct overlap and by synthesis then projection, the passive-map +certificate, and the refusals. A propagation is ten steps at most. +""" + +import dataclasses +import itertools +import math +from collections.abc import Sequence + +import numpy as np +import pytest +from scipy.special import j0, j1, jn_zeros + +from solprop import fourier +from solprop.constants import EPS0, C +from solprop.coupling import ( + BasisChange, + CouplingModel, + FieldPlane, + InputPort, + Outcouple, + PlaneBasis, + PlaneGeometry, + PlaneMetric, + PlaneReference, + ScalarModeBasis, + basis_identity, + couple, +) +from solprop.coupling.couple import _contrast_items, _gain_item +from solprop.coupling.models import cone_angle, radial_rule +from solprop.driver import Simulation +from solprop.exceptions import CouplingError +from solprop.grid import FreeGrid, RadialGrid +from solprop.modes import MarcatiliMode, Mode, StepIndexMode +from solprop.processing import field_plane +from solprop.pulses import GaussianPulse, SpatialPulse +from solprop.spatial import SpatialMedium + +RADIUS = 75e-6 +PRESSURE = 3.0 +GRID = { + "lambda0": 800e-9, + "wavelength_limits": (700e-9, 920e-9), + "trange": 200e-15, +} +MEDIUM = SpatialMedium("Ar", PRESSURE) +SCALAR_BUDGET = 0.05 +SCALAR = CouplingModel("scalar_paraxial", model_error=SCALAR_BUDGET) +SAME = CouplingModel("same_basis") + + +def capillary(radius: float = RADIUS, modes: int | Sequence[Mode] = 4) -> Simulation: + """Return a short argon capillary simulation on the tests' envelope grid.""" + return Simulation.from_capillary( + radius, + 1e-4, + "Ar", + PRESSURE, + modes=modes, + n_save=2, + noise=None, + envelope=True, + energy=1e-6, + fwhm=30e-15, + **GRID, + ) + + +@pytest.fixture(scope="module") +def port(): + """The 75 um capillary's port with four HE1m modes.""" + return capillary().input_port() + + +def spectral(grid, omega): + """Return a 30 fs Gaussian spectrum about the carrier, V s/m per unit field.""" + width = 30e-15 / (2 * math.sqrt(math.log(2))) + return 1e-9 * np.exp(-(((omega - grid.omega0) * width) ** 2) / 2) + + +def radial_beam(grid, waist): + """Return an x-polarised Gaussian of 1/e field radius `waist` on a radial grid.""" + + def field(r, omega): + return np.exp(-((r / waist) ** 2))[:, None] * spectral(grid, omega)[None, :] + + return field + + +def cartesian_beam( + grid, waist, *, shift=0.0, tilt=0.0, curvature=math.inf, jones=(1, 0) +): + """Return a displaced, tilted, curved Gaussian on a Cartesian grid. + + The field is ``exp(-((x - d)^2 + y^2)/w^2) exp(i k tilt x) exp(-i k r^2 / 2R)``, + in the optics convention, with Jones vector ``jones``. + """ + + def field(x, y, omega): + k = omega / C + spatial = np.exp(-((x - shift) ** 2 + y**2) / waist**2)[:, None] + phase = np.exp(1j * np.outer(x, k) * tilt) + if math.isfinite(curvature): + phase = phase * np.exp(-1j * np.outer(x**2 + y**2, k) / (2 * curvature)) + values = spatial * phase * spectral(grid, omega)[None, :] + return np.stack([jones[0] * values, jones[1] * values], axis=1) + + return field + + +def shares(result, grid): + """Return each launched channel's energy over the incident energy, all bins.""" + weights = fourier.energy_weights(grid.n_omega, grid.dt, kind=grid.kind) + launched = np.sum(weights * np.abs(result.launch.samples[:, 0, :]) ** 2, axis=1) + return launched / result.report.incident_energy + + +# ------------------------------------------------------------- analytic overlaps + + +def gaussian_efficiency(w1, w2, shift, tilt_kappa, curvature_k=0.0): + """Return the section 4.4 capture of two scalar Gaussians, in closed form.""" + a1 = 1 / w1**2 + a2 = 1 / w2**2 + 1j * curvature_k + aligned = 4 * a1 * a2.real / abs(np.conj(a1) + a2) ** 2 + if curvature_k == 0.0: + aligned = (2 * w1 * w2 / (w1**2 + w2**2)) ** 2 + return ( + aligned + * math.exp(-2 * shift**2 / (w1**2 + w2**2)) + * math.exp(-(tilt_kappa**2) * w1**2 * w2**2 / (2 * (w1**2 + w2**2))) + ) + + +@pytest.mark.parametrize( + ("w2", "shift", "tilt", "curvature"), + [ + (30e-6, 0.0, 0.0, math.inf), + (45e-6, 0.0, 0.0, math.inf), + (30e-6, 12e-6, 0.0, math.inf), + (30e-6, 0.0, 4e-3, math.inf), + (30e-6, 0.0, 0.0, 0.02), + ], +) +def test_the_gaussian_overlap_formulas_hold_against_direct_integration( + port, w2, shift, tilt, curvature +): + """Waist, displacement, tilt and curvature: formula, direct sum, plane overlap. + + The closed forms of section 4.4 are checked against a direct two-dimensional + sum over a fine grid, and the same overlap of two sampled Cartesian planes + (``FieldPlane.from_analytic``) in their spectral quadrature checks the sampling, + the sign of the tilt phase and the amplitude. + """ + grid = port.grid + w1 = 30e-6 + k_bin = int(np.argmin(np.abs(grid.omega - grid.omega0))) + k = float(grid.omega[k_bin]) / C + curvature_k = 0.0 if not math.isfinite(curvature) else k / (2 * curvature) + formula = gaussian_efficiency(w1, w2, shift, k * tilt, curvature_k) + axis = np.linspace(-300e-6, 300e-6, 1201) + x, y = np.meshgrid(axis, axis) + u1 = np.exp(-(x**2 + y**2) / w1**2) + u2 = np.exp(-((x - shift) ** 2 + y**2) / w2**2) * np.exp(1j * k * tilt * x) + u2 = u2 * np.exp(-1j * curvature_k * (x**2 + y**2)) + direct = abs(np.sum(u1.conj() * u2)) ** 2 / ( + np.sum(abs(u1) ** 2) * np.sum(abs(u2) ** 2) + ) + # A uniform sum of rapidly decaying Gaussians is spectrally accurate. + assert direct == pytest.approx(formula, rel=1e-10, abs=1e-14) + space = FreeGrid(300e-6, 128, 300e-6, 128) + index = np.where(grid.omega > 0, 1.0, 0.0) + first = FieldPlane.from_analytic( + grid, PlaneGeometry(0.0), cartesian_beam(grid, w1), space=space, index=index + ) + second = FieldPlane.from_analytic( + grid, + PlaneGeometry(0.0), + cartesian_beam(grid, w2, shift=shift, tilt=tilt, curvature=curvature), + space=space, + index=index, + ) + weights = space.spectral_weights + a = fourier.spectrum_to_internal(first.samples, kind=grid.kind)[:, 0, k_bin] + b = fourier.spectrum_to_internal(second.samples, kind=grid.kind)[:, 0, k_bin] + planar = abs(np.sum(weights * a.conj() * b)) ** 2 / ( + np.sum(weights * abs(a) ** 2) * np.sum(weights * abs(b) ** 2) + ) + # Both beams decay far inside the window and are resolved by its spacing. + assert planar == pytest.approx(formula, rel=1e-9, abs=1e-12) + + +def bessel_shares(waist, modes): + """Return each HE1m's captured share of a Gaussian by direct quadrature. + + ``s_m = |2 pi int_0^a r v_m u dr|^2`` with ``v_m = J0(u_m r/a)/(sqrt(pi) a + |J1(u_m)|)`` and the Gaussian normalised over the whole plane (section 4.4), + on one 400-node Gauss--Legendre rule over the core: the integrand is smooth + there, with at most four oscillations, so the rule is exact to rounding. + """ + gaussian_norm = math.sqrt(2 / (math.pi * waist**2)) + nodes, weights = np.polynomial.legendre.leggauss(400) + r = 0.5 * RADIUS * (nodes + 1.0) + w = 0.5 * RADIUS * weights + values = [] + for zero in jn_zeros(0, modes): + norm = 1 / (math.sqrt(math.pi) * RADIUS * abs(j1(zero))) + integrand = r * norm * j0(zero * r / RADIUS) * np.exp(-((r / waist) ** 2)) + values.append((2 * math.pi * gaussian_norm * float(np.sum(w * integrand))) ** 2) + return np.array(values) + + +@pytest.mark.parametrize("ratio", [0.3, 0.5, 0.7, 1.0]) +def test_gaussian_bessel_capture_agrees_with_an_independent_quadrature(port, ratio): + """The scalar model's HE1m shares against direct Gaussian--Bessel integrals. + + The captured share in the common metric (``n_ref/Re n_eff`` times the + launched share) at the carrier bin is the overlap squared, mode by mode. At + w/a = 0.5 the fundamental takes about 0.906 and the next mode 0.092 (SEP 31, + section 8.1): a check, not a target. + """ + grid = port.grid + waist = ratio * RADIUS + plane = FieldPlane.from_analytic( + grid, + PlaneGeometry(0.0), + radial_beam(grid, waist), + space=RadialGrid(8 * waist, 256), + medium=MEDIUM, + ) + result = couple(plane, port, model=SCALAR) + k_bin = int(np.argmin(np.abs(grid.omega - grid.omega0))) + profiles = port.profiles + assert profiles is not None + weights = fourier.energy_weights(grid.n_omega, grid.dt, kind=grid.kind) + captured = ( + weights[k_bin] + * port.reference_index[k_bin] + / profiles.effective_index[:, k_bin] + * np.abs(result.launch.samples[:, 0, k_bin]) ** 2 + / result.report.incident[k_bin] + ) + expected = bessel_shares(waist, port.shape[0]) + # quad at 1e-13 relative, and the plane resolves the beam to rounding. + np.testing.assert_allclose(captured, expected, rtol=0.0, atol=1e-11) + if ratio == 0.5: + assert captured[0] == pytest.approx(0.906, abs=1e-3) + assert captured[1] == pytest.approx(0.092, abs=1e-3) + assert result.report.budget <= SCALAR_BUDGET + + +# ------------------------------------------------------------------- refinement + + +def test_nested_mode_sets_domain_and_quadrature_converge_separately(): + """Modal span, transverse window and node count are refined one at a time. + + Orthogonal profiles make each mode's share independent of the set it sits in: + a larger nested set adds shares and changes none (to rounding). Doubling the + window at the same spacing changes the shares by the domain check's size, and + doubling the nodes in the same window by the resolution check's. + """ + ports = [capillary(modes=count).input_port() for count in (2, 4, 6)] + grid = ports[0].grid + waist = 0.6 * RADIUS + plane = FieldPlane.from_analytic( + grid, + PlaneGeometry(0.0), + radial_beam(grid, waist), + space=RadialGrid(6 * waist, 192), + medium=MEDIUM, + ) + sets = [shares(couple(plane, one, model=SCALAR), grid) for one in ports] + for smaller, larger in itertools.pairwise(sets): + np.testing.assert_allclose(larger[: smaller.size], smaller, rtol=1e-12) + assert np.sum(larger) > np.sum(smaller) + wider = FieldPlane.from_analytic( + grid, + PlaneGeometry(0.0), + radial_beam(grid, waist), + space=RadialGrid(12 * waist, 384), + medium=MEDIUM, + ) + finer = FieldPlane.from_analytic( + grid, + PlaneGeometry(0.0), + radial_beam(grid, waist), + space=RadialGrid(6 * waist, 384), + medium=MEDIUM, + ) + analytic = plane.analytic + assert analytic is not None + for other in (wider, finer): + changed = shares(couple(other, ports[1], model=SCALAR), grid) + # The window holds the beam to exp(-72) in intensity and its nodes resolve + # it, so each refinement moves the shares by rounding of the reductions. + np.testing.assert_allclose(changed, sets[1], rtol=0.0, atol=1e-12) + assert analytic.domain_change < 1e-12 + assert analytic.resolution_change < 1e-12 + + +# --------------------------------------------------------------- poor and zero + + +def test_poor_and_zero_capture_restore_no_energy(port): + """A wide beam launches its small captured share; zero fields launch zero. + + The uncaptured energy is the incident minus the captured, unclipped and + nonnegative in the common metric; a y-polarised beam into x-polarised modes is + an exact zero launch with a zero efficiency, and a zero field a zero launch + whose efficiency is undefined. + """ + grid = port.grid + space = FreeGrid(1.2e-3, 128, 1.2e-3, 128) + wide = FieldPlane.from_analytic( + grid, + PlaneGeometry(0.0), + cartesian_beam(grid, 4 * RADIUS), + space=space, + medium=MEDIUM, + ) + result = couple(wide, port, model=CouplingModel("scalar_paraxial", model_error=0.1)) + report = result.report + assert report.efficiency is not None and report.efficiency < 0.15 + uncaptured = report.uncaptured + assert uncaptured is not None and report.captured is not None + np.testing.assert_array_equal(uncaptured, report.incident - report.captured) + assert np.all(uncaptured >= -1e-12 * np.max(report.incident)) + assert report.launched_energy < report.incident_energy + crossed = FieldPlane.from_analytic( + grid, + PlaneGeometry(0.0), + cartesian_beam(grid, 0.6 * RADIUS, jones=(0, 1)), + space=FreeGrid(300e-6, 64, 300e-6, 64), + medium=MEDIUM, + ) + zero = couple(crossed, port, model=SCALAR) + assert np.all(zero.launch.samples == 0.0) + assert zero.report.efficiency == 0.0 + assert zero.report.incident_energy > 0.0 + + def nothing(x, y, omega): + return np.zeros((x.size, omega.size)) + + empty = FieldPlane.from_analytic( + grid, + PlaneGeometry(0.0), + nothing, + space=FreeGrid(300e-6, 32, 300e-6, 32), + medium=MEDIUM, + ) + blank = couple(empty, port, model=SCALAR) + assert np.all(blank.launch.samples == 0.0) + assert blank.report.efficiency is None + assert "undefined" in str(blank.report) + + +# ------------------------------------------------------- bases, gauge, coherence + + +def handmade_port(modes, grid): + """Return a modal port of hand-chosen capillary modes, admitting scalar fits.""" + profiles = ScalarModeBasis.from_modes(modes, grid) + return InputPort( + "modal", + grid, + PlaneBasis("modal", basis_identity("modal", {"labels": list(profiles.labels)})), + (len(modes), 1, grid.n_omega), + PlaneGeometry(0.0), + PlaneReference(), + PlaneMetric("identity"), + profiles.core_index, + models=("same_basis", "scalar_paraxial"), + profiles=profiles, + ) + + +def test_a_rotated_degenerate_basis_shares_the_same_total(port): + """An x-polarised beam enters x alone, or splits evenly over a rotated pair. + + The launched total is the same in either basis to rounding: what is fitted is + the field. + """ + grid = port.grid + straight = [ + MarcatiliMode(RADIUS, "Ar", PRESSURE, phi=phi) for phi in (0.0, math.pi / 2) + ] + rotated = [ + MarcatiliMode(RADIUS, "Ar", PRESSURE, phi=phi) + for phi in (math.pi / 4, 3 * math.pi / 4) + ] + plane = FieldPlane.from_analytic( + grid, + PlaneGeometry(0.0), + cartesian_beam(grid, 0.6 * RADIUS), + space=FreeGrid(300e-6, 64, 300e-6, 64), + medium=MEDIUM, + ) + first = couple(plane, handmade_port(straight, grid), model=SCALAR) + second = couple(plane, handmade_port(rotated, grid), model=SCALAR) + a, b = shares(first, grid), shares(second, grid) + # The crossed polarisation's share is the Cartesian sampling's rounding squared. + assert a[1] < 1e-30 + assert b[0] == pytest.approx(b[1], rel=1e-12) + assert np.sum(b) == pytest.approx(np.sum(a), rel=1e-12) + + +def modal_plane(simulation, coefficients): + """Return a modal plane of given coefficients with a simulation's profiles.""" + grid = simulation.grid + profiles = ScalarModeBasis.from_modes(simulation.modes, grid) + samples = np.zeros((profiles.n_modes, 1, grid.n_omega), dtype=complex) + envelope = np.sqrt(spectral(grid, grid.omega) / 1e-9) * grid.active_mask + samples[:, 0, :] = np.asarray(coefficients)[:, None] * envelope[None, :] + return FieldPlane.from_samples( + samples, + grid, + PlaneGeometry(0.0), + exterior="zero", + basis=PlaneBasis("modal", "0" * 64, profiles.labels), + profiles=profiles, + ) + + +def test_coherent_modes_interfere_and_a_gauge_change_leaves_the_field(port): + """Two coherent source modes add as fields; flipping a profile's sign does not. + + The launch is linear in the source coefficients, so the two-mode launch is the + sum of the single-mode ones and its energy is not the sum of theirs; a source + profile set whose Jones vector and coefficient are both negated is the same + physical field and launches identically. + """ + source = capillary(radius=60e-6, modes=2) + target = capillary(radius=RADIUS, modes=4).input_port() + both = couple(modal_plane(source, [1.0, 0.7]), target, model=SCALAR) + first = couple(modal_plane(source, [1.0, 0.0]), target, model=SCALAR) + second = couple(modal_plane(source, [0.0, 0.7]), target, model=SCALAR) + np.testing.assert_allclose( + both.launch.samples, + first.launch.samples + second.launch.samples, + rtol=0.0, + atol=1e-12 * np.max(np.abs(both.launch.samples)), + ) + # Orthogonal source modes keep their total; the target fundamental's share is + # where they interfere, and a sign of the second mode moves it. + grid = target.grid + fundamental = shares(both, grid)[0] + separate = shares(first, grid)[0] + shares(second, grid)[0] + assert abs(fundamental - separate) > 1e-3 * separate + opposite = couple(modal_plane(source, [1.0, -0.7]), target, model=SCALAR) + assert shares(opposite, grid)[0] != pytest.approx(fundamental, rel=1e-3) + plane = modal_plane(source, [1.0, 0.7]) + profiles = cast_profiles(plane) + flipped = dataclasses.replace( + profiles, jones=profiles.jones * np.array([[-1.0], [1.0]]) + ) + negated = np.array(plane.samples) + negated[0] *= -1.0 + gauged = dataclasses.replace(plane, samples=negated, profiles=flipped) + np.testing.assert_allclose( + couple(gauged, target, model=SCALAR).launch.samples, + both.launch.samples, + rtol=0.0, + atol=1e-13 * np.max(np.abs(both.launch.samples)), + ) + + +def cast_profiles(plane): + """Return a plane's profiles, asserting it has them.""" + assert plane.profiles is not None + return plane.profiles + + +def test_a_declared_basis_change_relabels_without_changing_energy(port): + """``same_basis`` relabels with a unitary change and refuses a non-unitary one.""" + grid = port.grid + source = capillary(modes=2) + plane = modal_plane(source, [1.0, 0.5]) + target_identity = basis_identity("modal", {"rotated": True}) + target = dataclasses.replace( + source.input_port(), + basis=PlaneBasis("modal", target_identity), + ) + angle = 0.3 + unitary = np.array( + [[math.cos(angle), -math.sin(angle)], [math.sin(angle), math.cos(angle)]] + ) * np.exp(0.2j) + model = CouplingModel( + "same_basis", + basis_change=BasisChange(unitary, plane.basis.identity, target_identity), + ) + result = couple(plane, target, model=model) + assert result.report.launched_energy == pytest.approx( + result.report.incident_energy, rel=1e-13 + ) + np.testing.assert_allclose( + result.launch.samples[:, 0, :], unitary @ plane.samples[:, 0, :], rtol=1e-14 + ) + stretched = CouplingModel( + "same_basis", + basis_change=BasisChange(1.01 * unitary, plane.basis.identity, target_identity), + ) + with pytest.raises(CouplingError, match="does not preserve energy"): + couple(plane, target, model=stretched) + other = CouplingModel( + "same_basis", + basis_change=BasisChange(unitary, "1" * 64, target_identity), + ) + with pytest.raises(CouplingError, match="another pair of bases"): + couple(plane, target, model=other) + del grid + + +# ----------------------------------------------------------- guides and planes + + +def test_a_capillary_radius_change_by_direct_overlap_and_by_synthesis(): + """A capillary's exit into a wider one: direct overlap against synthesise→project. + + The first capillary runs ten steps; its exit plane read with its simulation + carries its profiles. Coupled directly, and outcoupled onto a radial and a + Cartesian plane then coupled, the launches agree to the synthesis window's + numerical error, and the synthesised plane carries the modal energy to the + scalar model's metric defect. + """ + first = capillary(modes=3) + run = first.run(progress="none") + plane = field_plane(run, source=first) + assert plane.profiles is not None + second = capillary(radius=100e-6, modes=6).input_port() + direct = couple(plane, second, model=SCALAR) + assert direct.report.launched_energy < direct.report.incident_energy + radial = Outcouple(RadialGrid(500e-6, 512)).apply(plane) + # Matched exit: the field's flux in the core gas against the modal power differs + # by the profiles' obliquity, n_core cos(theta) against n_eff. + np.testing.assert_allclose( + np.sum(radial.synthesised), np.sum(radial.modal), rtol=1e-6 + ) + through = couple(radial.plane, second, model=SCALAR) + scale = float(np.max(np.abs(direct.launch.samples))) + assert ( + np.max(np.abs(through.launch.samples - direct.launch.samples)) + <= (10 * radial.window + 1e-12) * scale + ) + cartesian = Outcouple(FreeGrid(300e-6, 128, 300e-6, 128)).apply(plane) + across = couple(cartesian.plane, second, model=SCALAR) + assert ( + np.max(np.abs(across.launch.samples - direct.launch.samples)) + <= (10 * cartesian.window + 1e-12) * scale + ) + + +def test_the_passive_map_certificate_reports_and_refuses(port): + """A small apparent gain is a model defect; one beyond the model's defects refuses. + + The capillary map's whitened gain is the effective-index defect, reported in + the budget and never rescaled; a gain larger than every metric, angular and + closure defect together is refused, never clipped. + """ + grid = port.grid + plane = FieldPlane.from_analytic( + grid, + PlaneGeometry(0.0), + radial_beam(grid, 0.5 * RADIUS), + space=RadialGrid(300e-6, 192), + medium=MEDIUM, + ) + result = couple(plane, port, model=SCALAR) + passivity = next(item for item in result.report.items if item.name == "passivity") + metric = next(item for item in result.report.items if item.name == "target metric") + assert passivity.value is not None and metric.value is not None + assert passivity.value <= metric.value + 1e-3 + with pytest.raises(CouplingError, match="apparent gain"): + _gain_item(0.5, []) + assert _gain_item(-0.1, []).value == 0.0 + + +def step_index(radius, contrast, wavelength=1064e-9, core=1.45, grid=None): + """Return a GNLSE simulation of an HE11 step-index fibre, by default at 1064 nm.""" + cladding = core * math.sqrt(1 - 2 * contrast) + fibre = StepIndexMode( + radius, core=core, cladding=cladding, profile_wavelength=wavelength + ) + grid = ( + { + "lambda0": wavelength, + "wavelength_limits": (900e-9, 1300e-9), + "trange": 400e-15, + } + if grid is None + else grid + ) + return Simulation.from_gnlse( + None, + 1e-3, + fibre=fibre, + n2=2.6e-20, + fwhm=100e-15, + energy=1e-12, + n_save=2, + **grid, + ) + + +def test_a_weakly_guiding_fibre_pair_is_admitted_within_its_envelope(): + """One weak-guidance step-index pair: the LP01 overlap, the screen and the envelope. + + Two fibres of contrast 0.005 and core radii in ratio 0.85: the launched share is + the LP01 overlap squared times the metric ratio, the screen passes at a 0.1 + budget and is replaced by the envelope where it does not, and a contrast beyond + the envelope's range is refused. + """ + wavelength = 1064e-9 + aperture = math.sqrt(1.45**2 * 2 * 0.005) + target_radius = 2.2 / (2 * math.pi / wavelength * aperture) + source = step_index(0.85 * target_radius, 0.005) + target = step_index(target_radius, 0.005).input_port() + assert target.models == ("same_basis", "scalar_paraxial") + assert target.contrast is not None + plane = modal_plane(source, [1.0]) + result = couple( + plane, target, model=CouplingModel("scalar_paraxial", model_error=0.1) + ) + names = {item.name: item for item in result.report.items} + assert names["contrast"].value == pytest.approx(0.005, rel=1e-9) + assert names["envelope"].value is not None + profiles_source = cast_profiles(plane) + profiles_target = target.profiles + assert profiles_target is not None + r, w = radial_rule( + [profiles_source.radius, profiles_target.radius], + max(profiles_source.outer(), profiles_target.outer()), + panel=profiles_source.radius / 8, + ) + overlap = float( + np.sum(w * profiles_source.values(r)[0] * profiles_target.values(r)[0]) + ) + captured = result.report.captured + assert captured is not None + assert np.sum(captured) / result.report.incident_energy == pytest.approx( + overlap**2, rel=1e-12 + ) + # At a budget whose eighth is below the contrast, the recorded envelope stands in + # for the screen (the pair's angular content then decides the total). + occupied = np.asarray(target.reference_index) > 0 + replaced = _contrast_items( + profiles_target, + plane, + np.asarray(target.reference_index), + occupied, + 0.02, + {"modes": 1.0, "radius_ratio": 0.85, "v_source": 1.87, "v_target": 2.2}, + ) + assert "replaced by" in replaced[0].note + assert replaced[0].passed + strong = step_index(target_radius, 0.02).input_port() + with pytest.raises(CouplingError, match="contrast"): + couple( + modal_plane(step_index(0.85 * target_radius, 0.02), [1.0]), + strong, + model=CouplingModel("scalar_paraxial", model_error=0.5), + ) + + +# --------------------------------------------------------------------- refusals + + +def test_couple_refuses_by_name(port): + """Every unqualified pair, missing budget and mismatch is refused by name.""" + grid = port.grid + with pytest.raises(CouplingError, match="takes no model_error"): + CouplingModel("same_basis", model_error=0.1) + with pytest.raises(CouplingError, match="give its energy budget"): + CouplingModel("scalar_paraxial") + with pytest.raises(CouplingError, match=r"lies in \(0, 1\)"): + CouplingModel("vector_paraxial", model_error=1.0) + unitary = BasisChange(np.eye(1, dtype=complex), "0" * 64, "0" * 64) + with pytest.raises(CouplingError, match="belongs to same_basis"): + CouplingModel("scalar_paraxial", model_error=0.1, basis_change=unitary) + with pytest.raises(CouplingError, match="finite"): + BasisChange(np.full((1, 1), np.nan, dtype=complex), "0" * 64, "0" * 64) + beam = FieldPlane.from_analytic( + grid, + PlaneGeometry(0.0), + radial_beam(grid, 0.5 * RADIUS), + space=RadialGrid(300e-6, 128), + medium=MEDIUM, + ) + with pytest.raises(CouplingError, match="not vector_paraxial"): + couple(beam, port, model=CouplingModel("vector_paraxial", model_error=0.1)) + with pytest.raises(CouplingError, match="a coupling, not a relaunch"): + couple(beam, port, model=SAME) + with pytest.raises(CouplingError, match="sum to"): + couple(beam, port, model=CouplingModel("scalar_paraxial", model_error=3e-3)) + + +def test_couple_refuses_unqualified_pairs_by_name(port): + """A port without profiles, a plane without them, beyond the envelope, a fibre + target for a beam, mixed families, a general plane, another orientation or + lattice.""" + grid = port.grid + beam = FieldPlane.from_analytic( + grid, + PlaneGeometry(0.0), + radial_beam(grid, 0.5 * RADIUS), + space=RadialGrid(300e-6, 128), + medium=MEDIUM, + ) + bare = dataclasses.replace(beam, profiles=None) + profiles = cast_profiles_or_none(port) + no_profile_port = dataclasses.replace(port, profiles=None) + with pytest.raises(CouplingError, match="no scalar profiles"): + couple(bare, no_profile_port, model=SCALAR) + del profiles + modal = FieldPlane.from_samples( + np.zeros((4, 1, grid.n_omega), dtype=complex), + grid, + PlaneGeometry(0.0), + exterior="zero", + basis=PlaneBasis("modal", "0" * 64), + ) + with pytest.raises(CouplingError, match="carries no mode profiles"): + couple(modal, port, model=SCALAR) + many = capillary(modes=9).input_port() + with pytest.raises(CouplingError, match="outside the envelope"): + couple(beam, many, model=SCALAR) + hybrid = capillary(modes=[MarcatiliMode(RADIUS, "Ar", PRESSURE, n=2)]) + assert hybrid.input_port().models == ("same_basis",) + fibre = step_index(2.4e-6, 0.005, wavelength=800e-9, grid=GRID).input_port() + assert fibre.lattice == port.lattice + with pytest.raises(CouplingError, match="no recorded envelope"): + couple(beam, fibre, model=SCALAR) + with pytest.raises(CouplingError, match="not qualified for a capillary plane"): + couple(modal_plane(capillary(modes=2), [1.0, 0.0]), fibre, model=SCALAR) + general = dataclasses.replace( + modal, kind="general_modal", basis=PlaneBasis("general_modal", "0" * 64) + ) + with pytest.raises(CouplingError, match="radial, Cartesian or modal"): + couple(general, port, model=SCALAR) + shifted = dataclasses.replace( + beam, + geometry=PlaneGeometry(0.0, normal=(0.0, 1.0, 0.0), x_axis=(1.0, 0.0, 0.0)), + ) + with pytest.raises(CouplingError, match="oriented differently"): + couple(shifted, port, model=SCALAR) + other = capillary() + longer = Simulation.from_capillary( + RADIUS, + 1e-4, + "Ar", + PRESSURE, + modes=4, + n_save=2, + noise=None, + envelope=True, + energy=1e-6, + fwhm=30e-15, + lambda0=800e-9, + wavelength_limits=(700e-9, 920e-9), + trange=400e-15, + ) + with pytest.raises(CouplingError, match="regrid it first"): + couple(beam, longer.input_port(), model=SCALAR) + del other + + +def cast_profiles_or_none(port): + """Return a port's profiles, which may be None.""" + return port.profiles + + +def test_from_analytic_records_and_refuses(port): + """The analytic constructor's checks and its refusals.""" + grid = port.grid + space = RadialGrid(300e-6, 64) + index = np.where(grid.omega > 0, 1.0, 0.0) + supported = FieldPlane.from_analytic( + grid, + PlaneGeometry(0.0), + radial_beam(grid, 30e-6), + space=space, + index=index, + support=200e-6, + ) + assert supported.analytic is not None + assert supported.analytic.support == 200e-6 + assert supported.analytic.error == max( + supported.analytic.domain_change, supported.analytic.resolution_change + ) + with pytest.raises(CouplingError, match="one"): + FieldPlane.from_analytic( + grid, PlaneGeometry(0.0), radial_beam(grid, 30e-6), space=space + ) + with pytest.raises(CouplingError, match="outside the window"): + FieldPlane.from_analytic( + grid, + PlaneGeometry(0.0), + radial_beam(grid, 30e-6), + space=space, + index=index, + support=1.0, + ) + + def wrong(r, omega): + return np.zeros((r.size, 3, omega.size)) + + with pytest.raises(CouplingError, match="n_pol 1 or 2"): + FieldPlane.from_analytic( + grid, PlaneGeometry(0.0), wrong, space=space, index=index + ) + + def broken(r, omega): + return np.full((r.size, omega.size), np.nan) + + with pytest.raises(CouplingError, match="nonfinite"): + FieldPlane.from_analytic( + grid, PlaneGeometry(0.0), broken, space=space, index=index + ) + with pytest.raises(CouplingError, match="reference index"): + PlaneMetric.paraxial(space, -index, grid) + with pytest.raises(CouplingError, match="takes a SpatialPulse"): + FieldPlane.from_pulse(object(), grid, PlaneGeometry(0.0), space=space) + beam = SpatialPulse( + GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=1e-6), 3e-5 + ) + with pytest.raises(CouplingError, match="medium= or an index="): + FieldPlane.from_pulse(beam, grid, PlaneGeometry(0.0), space=space) + with pytest.raises(CouplingError, match="propz=0"): + FieldPlane.from_pulse( + SpatialPulse(beam.temporal, 3e-5, propz=1e-3), + grid, + PlaneGeometry(0.0), + space=space, + index=index, + ) + measured = FieldPlane.from_pulse( + beam, grid, PlaneGeometry(0.0), space=space, index=index + ) + # The incident measure M0 holds the request; the axial flux is below it by the + # beam's obliquity, a few parts in 1e5 for a 30 um beam at 800 nm. + assert measured.energy() == pytest.approx(1e-6, rel=1e-4) + assert measured.energy() < 1e-6 + + +def test_outcouple_refuses_what_it_does_not_model(port): + """Only a modal plane with profiles, into a matched medium, under 'matched'.""" + grid = port.grid + plane = modal_plane(capillary(modes=2), [1.0, 0.0]) + space = RadialGrid(300e-6, 128) + with pytest.raises(CouplingError, match="not both"): + Outcouple(space, medium=MEDIUM, index=np.ones(grid.n_omega)) + with pytest.raises(CouplingError, match="core index"): + Outcouple(space, medium=SpatialMedium()).apply(plane) + with pytest.raises(CouplingError, match="with its profiles"): + Outcouple(space).apply(dataclasses.replace(plane, profiles=None)) + matched = Outcouple(space, medium=MEDIUM).apply(plane) + assert matched.plane.kind == "radial" + assert matched.plane.provenance.history[-1].startswith("Outcouple(matched)") + assert repr(Outcouple(space)) == "Outcouple(matched, RadialGrid)" + + +def test_the_report_prints_and_carries_its_terms(port): + """The report's block, its budget and its admission items.""" + grid = port.grid + plane = FieldPlane.from_analytic( + grid, + PlaneGeometry(0.0), + radial_beam(grid, 0.5 * RADIUS), + space=RadialGrid(300e-6, 128), + medium=MEDIUM, + ) + report = couple(plane, port, model=SCALAR).report + names = [item.name for item in report.items] + for name in ( + "angular", + "numerical", + "target metric", + "source metric", + "contrast", + "envelope", + "passivity", + ): + assert name in names + assert report.budget == pytest.approx( + sum(item.value for item in report.items if item.counted and item.value) + ) + assert report.budget <= SCALAR_BUDGET + assert all(item.passed for item in report.items) + text = str(report) + assert "budget" in text and "uncaptured" in text + assert "array(" not in repr(report) + angle = cone_angle(0.05) + assert 1 - math.cos(angle) == pytest.approx(0.05 / 8, rel=1e-14) + assert EPS0 > 0 diff --git a/tests/coupling/test_envelopes.py b/tests/coupling/test_envelopes.py new file mode 100644 index 000000000..0a3276d31 --- /dev/null +++ b/tests/coupling/test_envelopes.py @@ -0,0 +1,325 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""The oracles behind the recorded coupling envelopes (SEP 31, section 4.3). + +Each recorded :class:`~solprop.coupling.envelopes.PairEnvelope` is recomputed here +from an independent reference and held to its bound: + +- the capillary envelope: the scalar model's captured shares (``couple`` on sampled + planes, through the closed-form Hankel transforms) against the overlap of the + capillary's own :meth:`~solprop.modes.MarcatiliMode.field` with its + :meth:`~solprop.modes.MarcatiliMode.normalisation`, both transverse components, + on an independent polar Gauss--Legendre quadrature; +- the step-index envelope: the weak-guidance LP01 overlap the scalar model uses + against the full-vector flux overlap of the exact bound HE11 fields + (:meth:`~solprop.modes.StepIndexMode.electromagnetic_field`). + +The numerical error of each reference is estimated by refining its quadrature. +""" + +import math +from typing import cast + +import numpy as np +import pytest + +from solprop import fourier +from solprop.constants import EPS0, C +from solprop.coupling import ( + CAPILLARY_ENVELOPE, + STEP_INDEX_ENVELOPE, + CouplingModel, + FieldPlane, + PlaneBasis, + PlaneGeometry, + ScalarModeBasis, + couple, +) +from solprop.coupling.models import radial_rule +from solprop.driver import Simulation +from solprop.grid import EnvelopeGrid, FreeGrid +from solprop.modes import MarcatiliMode, StepIndexMode +from solprop.spatial import SpatialMedium + +RADIUS = 75e-6 +PRESSURE = 3.0 +WAVELENGTH = 800e-9 + + +def polar_rule(outer, breaks, n_theta=32, panel=None): + """Return an independent polar rule: Gauss--Legendre in r, uniform in theta.""" + panel = outer / 16 if panel is None else panel + radii, area = radial_rule(breaks, outer, panel=panel) + theta = 2 * np.pi * (np.arange(n_theta) + 0.5) / n_theta + r = np.repeat(radii, n_theta) + t = np.tile(theta, radii.size) + w = np.repeat(area, n_theta) / n_theta + return r, t, w + + +def capillary_simulation(radius, modes, grid_kwargs): + """Return a short argon capillary simulation on the tests' envelope grid.""" + return Simulation.from_capillary( + radius, + 1e-4, + "Ar", + PRESSURE, + modes=modes, + n_save=2, + noise=None, + envelope=True, + energy=1e-6, + fwhm=30e-15, + **grid_kwargs, + ) + + +GRID = { + "lambda0": WAVELENGTH, + "wavelength_limits": (700e-9, 920e-9), + "trange": 200e-15, +} + + +def gaussian_field(grid, waist, *, shift=0.0, tilt=0.0): + """Return an x-polarised Gaussian of unit-peak spectrum, displaced and tilted.""" + spectral_width = 30e-15 / (2 * math.sqrt(math.log(2))) + + def field(x, y, omega): + k = omega / C + spatial = np.exp(-((x - shift) ** 2 + y**2) / waist**2) + phase = np.exp(1j * np.outer(x, k) * tilt) + spectral = np.exp(-(((omega - grid.omega0) * spectral_width) ** 2) / 2) + return 1e-9 * spatial[:, None] * phase * spectral[None, :] + + return field + + +def reference_shares(modes, grid, waist, shift, tilt, index): + """Return each HE1m's captured share by the Marcatili field on a polar rule. + + The overlap in the common measure ``M0 = n eps0 c dA / 2`` of the analytic + Gaussian with each mode's vacuum-normalised field ``field / sqrt(N)``, divided + by the Gaussian's whole-plane norm (closed form), at the peak bin. + """ + k_bin = int(np.argmin(np.abs(grid.omega - grid.omega0))) + omega = grid.omega[k_bin] + k = omega / C + r, t, w = polar_rule(RADIUS, [], panel=RADIUS / 24) + x, y = r * np.cos(t), r * np.sin(t) + gaussian = np.exp(-((x - shift) ** 2 + y**2) / waist**2) * np.exp(1j * k * tilt * x) + norm = 0.5 * index * EPS0 * C * math.pi * waist**2 / 2 + shares = [] + for mode in modes: + profile = mode.field(np.column_stack((r, t)))[:, 0] / math.sqrt( + mode.normalisation() + ) + overlap = 0.5 * index * EPS0 * C * np.sum(w * profile * gaussian) + self_norm = 0.5 * index * EPS0 * C * np.sum(w * profile * profile) + shares.append(abs(overlap) ** 2 / self_norm / norm) + return np.array(shares), k_bin + + +@pytest.fixture(scope="module") +def capillary_port(): + """A 75 um argon capillary's port with eight HE1m modes.""" + simulation = capillary_simulation(RADIUS, 8, GRID) + return simulation.input_port() + + +@pytest.mark.parametrize( + ("waist", "shift", "tilt"), + [ + (0.3, 0.0, 0.0), + (0.5, 0.0, 0.0), + (0.7, 0.1, 0.0), + (1.0, 0.0, 5e-4), + (0.5, 0.2, 1e-3), + ], +) +def test_the_capillary_envelope_bounds_the_scalar_model_against_marcatili( + capillary_port, waist, shift, tilt +): + """Scalar shares of a Gaussian against Marcatili's own fields, within the bound. + + The beam is sampled on a Cartesian plane (so displacement and tilt are kept); + the reference integrates the analytic beam against ``MarcatiliMode.field``. + The comparison is at the peak bin, in the common measure, share by share. + """ + port = capillary_port + grid = port.grid + index = float(port.reference_index[np.argmin(np.abs(grid.omega - grid.omega0))]) + medium = SpatialMedium("Ar", PRESSURE) + space = FreeGrid(4 * RADIUS, 128, 4 * RADIUS, 128) + plane = FieldPlane.from_analytic( + grid, + PlaneGeometry(0.0), + gaussian_field(grid, waist * RADIUS, shift=shift * RADIUS, tilt=tilt), + space=space, + medium=medium, + ) + result = couple( + plane, port, model=CouplingModel("scalar_paraxial", model_error=0.1) + ) + modes = [ + MarcatiliMode(RADIUS, "Ar", PRESSURE, m=m) for m in range(1, port.shape[0] + 1) + ] + expected, k_bin = reference_shares( + modes, grid, waist * RADIUS, shift * RADIUS, tilt, index + ) + profiles = port.profiles + assert profiles is not None + # Captured share per mode in the common metric: G_mm |b_m|^2 / incident. + b = result.launch.samples[:, 0, k_bin] + g = index / profiles.effective_index[:, k_bin] + weights = fourier.energy_weights(grid.n_omega, grid.dt, kind=grid.kind) + shares = weights[k_bin] * g * np.abs(b) ** 2 / result.report.incident[k_bin] + # The bound covers the envelope; the plane's own representation error of the + # sampled beam (its numerical item) is added, as the admission adds it. + numerical = next( + item.value for item in result.report.items if item.name == "numerical" + ) + assert numerical is not None + allowance = CAPILLARY_ENVELOPE.bound + 4 * numerical + 1e-9 + assert np.max(np.abs(shares - expected)) <= allowance + + +@pytest.mark.parametrize("ratio", [0.5, 0.8, 1.25, 2.0]) +def test_the_capillary_envelope_bounds_a_change_of_radius(ratio): + """A capillary's HE1m coefficients into another's against Marcatili's fields. + + The source carries a coherent mixture of its first three modes; the reference + overlaps ``MarcatiliMode.field`` of both capillaries on a polar rule broken at + both radii, and the scalar model's direct overlap is compared share by share. + """ + source_radius = ratio * RADIUS + source = capillary_simulation(source_radius, 3, GRID) + target = capillary_simulation(RADIUS, 6, GRID) + port = target.input_port() + grid = port.grid + k_bin = int(np.argmin(np.abs(grid.omega - grid.omega0))) + profiles = ScalarModeBasis.from_modes(source.modes, grid) + coefficients = np.zeros((3, 1, grid.n_omega), dtype=complex) + coefficients[:, 0, k_bin] = [1.0, 0.4j, -0.2] + plane = FieldPlane.from_samples( + coefficients, + grid, + PlaneGeometry(0.0), + exterior="zero", + basis=PlaneBasis("modal", "0" * 64, profiles.labels), + profiles=profiles, + ) + result = couple( + plane, port, model=CouplingModel("scalar_paraxial", model_error=0.1) + ) + assert port.reference_index is not None + index = float(port.reference_index[k_bin]) + outer = max(source_radius, RADIUS) + r, t, w = polar_rule(outer, [min(source_radius, RADIUS)], panel=outer / 32) + points = np.column_stack((r, t)) + field = np.zeros(r.size, dtype=complex) + for mode, value in zip( + cast("list[MarcatiliMode]", source.modes), + coefficients[:, 0, k_bin], + strict=True, + ): + n_eff = float(np.real(mode.neff(grid.omega[k_bin : k_bin + 1]))[0]) + profile = mode.field(points)[:, 0] * (r <= source_radius) + field += value / math.sqrt(n_eff) * profile / math.sqrt(mode.normalisation()) + expected = [] + for mode in cast("list[MarcatiliMode]", target.modes): + profile = ( + mode.field(points)[:, 0] * (r <= RADIUS) / math.sqrt(mode.normalisation()) + ) + n_eff = float(np.real(mode.neff(grid.omega[k_bin : k_bin + 1]))[0]) + amplitude = np.sum(w * profile * field) / np.sum(w * profile * profile) + expected.append(math.sqrt(n_eff) * amplitude * index) + # The target's vacuum-normalised amplitude in M0 is overlap / self-norm; the + # scalar model's b = sqrt(n_eff) a carries the factor n_ref of G^-1 P. + launched = result.launch.samples[:, 0, k_bin] + scale = float(np.max(np.abs(launched))) + expected_b = np.array(expected) / index + assert ( + np.max(np.abs(np.abs(launched) ** 2 - np.abs(expected_b) ** 2)) + <= (CAPILLARY_ENVELOPE.bound + 1e-12) * scale**2 + ) + + +def lp01_overlap(source, target): + """Return the weak-guidance captured share |integral v_A v_B dA|^2.""" + outer = max(source.outer(), target.outer()) + r, w = radial_rule([source.radius, target.radius], outer, panel=source.radius / 8) + return float(np.sum(w * source.values(r)[0] * target.values(r)[0]) ** 2) + + +def vector_overlap(first, second, omega, refine=1): + """Return |F_AB|^2 of two unit-power HE11 fields on a polar rule.""" + radii = sorted({first._radius(0.0), second._radius(0.0)}) + outer = max(first.dimlimits()[2][0], second.dimlimits()[2][0]) + r, t, w = polar_rule( + outer, radii, n_theta=16 * refine, panel=radii[0] / (8 * refine) + ) + xy = np.column_stack((r * np.cos(t), r * np.sin(t))) + ea, ha = first.electromagnetic_field(xy, omega) + eb, hb = second.electromagnetic_field(xy, omega) + cross = (ea[:, 0] * hb[:, 1].conj() - ea[:, 1] * hb[:, 0].conj()) + ( + eb[:, 0].conj() * ha[:, 1] - eb[:, 1].conj() * ha[:, 0] + ) + return float(abs(0.25 * np.sum(w * cross)) ** 2) + + +def step_index_cases(): + """Yield the envelope's sampled pairs: contrast, wavelength, V_target, ratio. + + With constant indices the overlaps depend on the wavelength only through V, so + one wavelength samples the whole range; both ends of every range are included. + """ + wavelength = 1064e-9 + for contrast in (0.001, 0.005, 0.01): + for v_target in (1.5, 1.8, 2.1, 2.4): + for ratio in (0.7, 0.85, 1.0, 1.2, 1.43): + if 1.5 <= ratio * v_target <= 2.4: + yield contrast, wavelength, v_target, ratio + + +def fibre(radius, cladding, core, wavelength): + """Return an HE11 even step-index mode of the given materials.""" + return StepIndexMode( + radius, core=core, cladding=cladding, profile_wavelength=wavelength + ) + + +@pytest.mark.medium +def test_the_step_index_envelope_bounds_lp01_against_the_vector_he11_overlap(): + """Weak-guidance LP01 overlap against the exact HE11 flux overlap, within bound. + + Each pair shares its materials and wavelength and differs in core radius; + the vector reference's error is estimated by doubling its quadrature. + """ + core = 1.45 + worst = 0.0 + numerical = 0.0 + for contrast, wavelength, v_target, ratio in step_index_cases(): + cladding = core * math.sqrt(1.0 - 2.0 * contrast) + aperture = math.sqrt(core**2 - cladding**2) + k = 2 * math.pi / wavelength + radius_target = v_target / (k * aperture) + radius_source = ratio * radius_target + source = fibre(radius_source, cladding, core, wavelength) + target = fibre(radius_target, cladding, core, wavelength) + grid = EnvelopeGrid.from_limits( + 1e-3, wavelength, (0.95 * wavelength, 1.05 * wavelength), 200e-15 + ) + scalar = lp01_overlap( + ScalarModeBasis.from_modes([source], grid), + ScalarModeBasis.from_modes([target], grid), + ) + omega = 2 * math.pi * C / wavelength + vector = vector_overlap(source, target, omega) + refined = vector_overlap(source, target, omega, refine=2) + numerical = max(numerical, abs(vector - refined)) + worst = max(worst, abs(scalar - vector)) + assert numerical <= STEP_INDEX_ENVELOPE.numerical + assert worst + numerical <= STEP_INDEX_ENVELOPE.bound diff --git a/tests/coupling/test_port.py b/tests/coupling/test_port.py index 415d34560..1f41e7bc1 100644 --- a/tests/coupling/test_port.py +++ b/tests/coupling/test_port.py @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: GPL-3.0-or-later -"""Input ports, bound launches and the same-basis model (SEP 31).""" +"""Input ports, bound launches and the same-basis model of ``couple`` (SEP 31).""" import dataclasses @@ -11,6 +11,7 @@ from solprop.coupling import ( CouplingLaunch, + CouplingModel, FieldPlane, InputPort, PlaneBasis, @@ -18,7 +19,7 @@ PlaneMetric, PlaneReference, basis_identity, - same_basis, + couple, ) from solprop.exceptions import CouplingError from solprop.fourier import spectrum_to_internal, spectrum_to_optics @@ -51,6 +52,11 @@ def plane_of(grid=None, reference=None, metric=None): ) +def same_basis(plane, port): + """Return the identity model's launch of a plane into a port.""" + return couple(plane, port, model=CouplingModel("same_basis")).launch + + def port_of(grid=None, **changes): grid = grid_of() if grid is None else grid arguments = { diff --git a/tests/driver/test_coupling_relaunch.py b/tests/driver/test_coupling_relaunch.py index d53d3b36a..f7e089ac6 100644 --- a/tests/driver/test_coupling_relaunch.py +++ b/tests/driver/test_coupling_relaunch.py @@ -17,7 +17,7 @@ import pytest from solprop.backends.registry import available -from solprop.coupling import same_basis +from solprop.coupling import CouplingModel, couple from solprop.driver import Simulation, SimulationSettings from solprop.exceptions import CouplingError, DriverError from solprop.grid import FieldGrid, RadialGrid @@ -35,6 +35,11 @@ """ +def same_basis(plane, port): + """Return the identity model's launch of a plane into a port.""" + return couple(plane, port, model=CouplingModel("same_basis")).launch + + def capillary( length, n_save, *, rtol=1e-8, backend="numpy_ref", radius=100e-6, **changes ): diff --git a/tests/test_display/IncidentLaunch.txt b/tests/test_display/IncidentLaunch.txt deleted file mode 100644 index 06415ecff..000000000 --- a/tests/test_display/IncidentLaunch.txt +++ /dev/null @@ -1,5 +0,0 @@ -IncidentLaunch: an incident beam's energy in a guide - incident: 1 uJ - captured: 330 nJ - uncaptured: 670 nJ - captured share: 0.3300 of the incident energy From 21997122a9be924f77b9b75df0a239c2e928a575 Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 02:04:24 +0100 Subject: [PATCH 19/50] Driver: incident beams are coupled under a declared vector model from_waveguide(provider=..., incident=..., coupling=CouplingModel( 'vector_paraxial', model_error=...)) builds the beam's radial plane in the core index and launches what couple recovers, through with_input; the CouplingReport replaces IncidentLaunch as configuration['launch']. A strongly guiding core is refused by the contrast screen. The incident tests move to a weakly guiding strand; the graded-index benchmark declares its model. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019qNYqtCKh4iTKj7544yARb --- benchmarks/guides.py | 4 +- src/solprop/driver/interface.py | 17 +- src/solprop/driver/simulation.py | 402 +++++++++----------------- tests/driver/test_general_modal.py | 240 +++++++++------ tests/test_display.py | 24 +- tests/test_display/CouplingReport.txt | 10 + 6 files changed, 334 insertions(+), 363 deletions(-) create mode 100644 tests/test_display/CouplingReport.txt diff --git a/benchmarks/guides.py b/benchmarks/guides.py index e5ede1d6e..8d76a241a 100644 --- a/benchmarks/guides.py +++ b/benchmarks/guides.py @@ -21,6 +21,7 @@ from benchmarks.records import SuiteError from solprop.boundaries import absorber from solprop.constants import C +from solprop.coupling import CouplingModel from solprop.driver import Simulation from solprop.grid import FieldGrid from solprop.modes import ( @@ -224,7 +225,7 @@ def grin(*, nonlinear: bool) -> Simulation: ) period = 2 * np.pi / float(np.mean(-np.diff(beta))) # The incident beam's energy is over its whole plane; the channels launch - # the share they capture. + # the share they capture, coupled under the vector paraxial model. beam = SpatialPulse( GaussianPulse( wavelength=wavelength, @@ -239,6 +240,7 @@ def grin(*, nonlinear: bool) -> Simulation: provider=guide, grid=grid, incident=beam, + coupling=CouplingModel("vector_paraxial", model_error=0.2), lambda0=wavelength, noise="input", seed=41, diff --git a/src/solprop/driver/interface.py b/src/solprop/driver/interface.py index 906dbb5eb..15fc42b68 100644 --- a/src/solprop/driver/interface.py +++ b/src/solprop/driver/interface.py @@ -27,6 +27,7 @@ from jaxtyping import Float from solprop.boundaries import Absorber +from solprop.coupling.models import CouplingModel from solprop.driver.simulation import ( DEFAULT_ATOL, DEFAULT_RTOL, @@ -1164,6 +1165,7 @@ def prop_waveguide( # noqa: PLR0913 - the interface's keyword surface provider: ModalGuide | None = None, channel: int | str | None = None, incident: SpatialPulse | None = None, + coupling: CouplingModel | None = None, kerr_form: KerrForm = "real", observable_rtol: float | None = None, observable_atol: float | None = None, @@ -1257,12 +1259,16 @@ def prop_waveguide( # noqa: PLR0913 - the interface's keyword surface Which channel the pulse enters, by index or display label; ``None`` takes the first. Requires `provider`. incident : solprop.pulses.SpatialPulse or None, optional - An incident beam carrying its own pulse, whose energy is the physical - energy over the whole beam at the entrance; the channels launch the share - they capture and the rest is lost, recorded in the run's configuration - (see :meth:`~solprop.driver.simulation.Simulation.from_waveguide`). - Requires `provider`, and is refused beside `channel` and the pulse + An incident beam carrying its own pulse, whose energy is met over the whole + beam at the entrance; it is coupled into the channels under `coupling`, + they launch the share they capture and the rest is lost, with the coupling + report recorded in the run's configuration (see + :meth:`~solprop.driver.simulation.Simulation.from_waveguide`). Requires + `provider` and `coupling`, and is refused beside `channel` and the pulse keywords. + coupling : solprop.coupling.models.CouplingModel or None, optional + The model and budget an `incident` beam is coupled under, a + ``vector_paraxial`` model. Requires `incident`. kerr_form : {'real', 'analytic'}, optional Which instantaneous cubic a general modal run's regions respond with: the real-field cubic of the field route, the default, or the analytic-signal @@ -1477,6 +1483,7 @@ def prop_waveguide( # noqa: PLR0913 - the interface's keyword surface provider=provider, channel=channel, incident=incident, + coupling=coupling, kerr_form=kerr_form, observable_rtol=observable_rtol, observable_atol=observable_atol, diff --git a/src/solprop/driver/simulation.py b/src/solprop/driver/simulation.py index 0cbfdca6b..44ae25a7d 100644 --- a/src/solprop/driver/simulation.py +++ b/src/solprop/driver/simulation.py @@ -43,7 +43,6 @@ from scipy.special import j0, jn_zeros import solprop.driver.tdse as tdse_runtime -from solprop import fourier from solprop._display import Displayed, engineering, line, summarise from solprop.adiabatic import AdiabaticAccuracy from solprop.backends.multistep import CallLedger @@ -59,10 +58,14 @@ from solprop.constants import C from solprop.coupling import ( CouplingLaunch, + CouplingModel, + FieldPlane, InputPort, PlaneBasis, + PlaneGeometry, PlaneReference, basis_identity, + couple, modal_basis_identity, space_record, ) @@ -87,14 +90,13 @@ ) from solprop.exceptions import ( AdiabaticTaperWarning, + CouplingError, DriverError, GasFlowWarning, GrazingIncidenceWarning, MaterialRangeWarning, MemoryBudgetError, MemoryBudgetWarning, - ModeError, - PulseError, SampledPeakWarning, SolpropWarning, TDSEArchiveError, @@ -245,7 +247,6 @@ from solprop.stepper.tableau import DOPRI5 from solprop.translate.coupled import ( CF4_GROWTH_LIMIT, - metric_at, taper_identity, wall_identity, ) @@ -266,10 +267,6 @@ from solprop.translate.linop import power_weight_at from solprop.translate.masks import DEFAULT_ABSORBER_SCHEME, AbsorberScheme from solprop.translate.modal import DEFAULT_NODE_BUDGET -from solprop.translate.modal_tables import ( - ModalTransformTables, - project_transverse_field, -) from solprop.translate.plasma_integration import ( integration_record, ) @@ -297,7 +294,6 @@ "PLASMA_STATISTICS", "WAVEGUIDE_ATOL", "WAVEGUIDE_RTOL", - "IncidentLaunch", "NoiseModel", "Simulation", "SimulationRunner", @@ -311,161 +307,6 @@ logger = logging.getLogger(__name__) -@dataclass(frozen=True, eq=False) -class IncidentLaunch(Displayed): - r"""The energy account of an incident beam launched into a general modal guide. - - Recorded as ``configuration["launch"]`` by - ``Simulation.from_waveguide(provider=..., incident=...)`` (SEP 31, phase 1). - The **incident** energy is the beam's over its whole plane, in the paraxial - measure :math:`M_0 = n_{\rm ref}\varepsilon_0 c\,dA/2` of the guide's core - medium at the entrance; the **captured** energy is the launched state's physical - energy under the run's own declared flux metric, the number - :func:`solprop.stats.physical_energy` gives at the entrance. The launch keeps the - captured amplitudes as projected: nothing is rescaled to the requested energy. - - **The uncaptured energy is their difference, and it is a physical loss only in - a common positive metric** (SEP 31 section 4.3): where the guide's own flux - metric and the incident measure agree, as for a weakly guiding provider, the - least-squares remainder is orthogonal to the channels and the difference is - the light the guide does not take. Elsewhere it also carries the two - measures' disagreement, and it is reported unclipped: a negative value is an - apparent gain of the model, never a negative loss. The electric-field residual - :attr:`solprop.translate.modal_tables.ProjectedLaunch.residual` is a - reconstruction error on the guide's nodes, not radiated power, and is not - this account. - - Attributes - ---------- - reference_index : numpy.ndarray - Real phase index of the core medium at each frequency, float64, shape - ``(n_omega,)``; zero on bins that carry no flux. - incident : numpy.ndarray - The energy each bin carries through the whole incident plane, float64, - shape ``(n_omega,)``, J. - captured : numpy.ndarray - The energy each bin carries in the launched channels, float64, shape - ``(n_omega,)``, J. - - Raises - ------ - DriverError - If the arrays disagree in shape or are not finite. - """ - - reference_index: Float[np.ndarray, " n_omega"] - incident: Float[np.ndarray, " n_omega"] - captured: Float[np.ndarray, " n_omega"] - - def __post_init__(self) -> None: - """Check that the three arrays describe one launch. - - Raises - ------ - DriverError - Naming what disagrees. - """ - shapes = { - np.shape(value) - for value in (self.reference_index, self.incident, self.captured) - } - if len(shapes) != 1: - msg = ( - "an incident launch's index, incident and captured energies share " - f"one frequency axis; got shapes {sorted(shapes)}" - ) - raise DriverError(msg) - for name, value in ( - ("reference_index", self.reference_index), - ("incident", self.incident), - ("captured", self.captured), - ): - if not np.all(np.isfinite(value)): - msg = f"an incident launch's {name} must be finite" - raise DriverError(msg) - - @property - def uncaptured(self) -> Float[np.ndarray, " n_omega"]: - """Return the incident energy each bin does not carry into the channels. - - Returns - ------- - numpy.ndarray - ``incident - captured``, shape ``(n_omega,)``, J, not clipped. - """ - return self.incident - self.captured - - @property - def incident_energy(self) -> float: - """Return the beam's whole incident energy. - - Returns - ------- - float - Energy, J. - """ - return float(np.sum(self.incident)) - - @property - def captured_energy(self) -> float: - """Return the energy the launch carries into the guide. - - Returns - ------- - float - Energy, J. - """ - return float(np.sum(self.captured)) - - @property - def uncaptured_energy(self) -> float: - """Return the incident energy the launch does not carry, not clipped. - - Returns - ------- - float - Energy, J. - """ - return float(np.sum(self.uncaptured)) - - def __repr__(self) -> str: - """Return a one-line description, never an array. - - Returns - ------- - str - For example ``IncidentLaunch(1 uJ incident, 302 nJ captured)``. - """ - return line( - "IncidentLaunch", - [ - ("", f"{engineering(self.incident_energy, 'J')} incident"), - ("", f"{engineering(self.captured_energy, 'J')} captured"), - ], - ) - - def _describe(self) -> tuple[str, list[tuple[str, object]]]: - """Return the title and rows of the printed block. - - Returns - ------- - tuple - The title and ``(key, value)`` rows. - """ - incident = self.incident_energy - share = ( - "undefined (nothing incident)" - if incident == 0.0 - else f"{self.captured_energy / incident:.4f} of the incident energy" - ) - return "IncidentLaunch: an incident beam's energy in a guide", [ - ("incident", engineering(incident, "J")), - ("captured", engineering(self.captured_energy, "J")), - ("uncaptured", engineering(self.uncaptured_energy, "J")), - ("captured share", share), - ] - - LOOSE_ADAMS_RTOL = 5e-2 """The ``rtol`` from which an adaptive Adams run is warned on. @@ -1846,9 +1687,10 @@ def input_port(self, z: float | None = None) -> InputPort: The port is the receiving side of a transfer between simulations: its route, temporal lattice and basis with their identities, the entrance plane and the frame accumulated there, the native metric at the entrance, the reference - index of a spatial medium and, for a general guide that does not move, its - E/H reference at the nodes. A launch computed for it - (:func:`solprop.coupling.port.same_basis`) is accepted by :meth:`with_input`, + index of its entrance medium, the coupling models it admits and what they + need: an ordinary mode set's scalar profiles, or a general guide's E/H + reference at its nodes and its regions' contrast. A launch computed for it + (:func:`solprop.coupling.couple.couple`) is accepted by :meth:`with_input`, and a launch computed for any other port is refused. Parameters @@ -1860,7 +1702,7 @@ def input_port(self, z: float | None = None) -> InputPort: Returns ------- solprop.coupling.InputPort - The port, admitting the ``same_basis`` model. + The port and the models it admits. Raises ------ @@ -1908,6 +1750,8 @@ def input_port(self, z: float | None = None) -> InputPort: medium=self.medium, tables=None if self.general is None else self.general.tables, z=z, + modes=tuple(self.modes) if route == "modal" else (), + guide=self.guide if isinstance(self.guide, ModalGuide) else None, ) def with_input( @@ -4064,6 +3908,7 @@ def from_waveguide( # noqa: PLR0913 - the interface's keyword surface provider: ModalGuide | None = None, channel: int | str | None = None, incident: SpatialPulse | None = None, + coupling: CouplingModel | None = None, kerr_form: KerrForm = "real", observable_rtol: float | None = None, observable_atol: float | None = None, @@ -4181,23 +4026,31 @@ def from_waveguide( # noqa: PLR0913 - the interface's keyword surface `provider`. incident : solprop.pulses.SpatialPulse or None, optional An **incident beam** to launch instead of a named channel: the beam - and its temporal pulse, whose energy or peak power is the physical one - crossing the entrance plane in the guide's core medium, over the - **whole** beam (SEP 31, phase 1). The beam is normalised over its own - plane, sampled at the guide's quadrature nodes and projected onto every - channel it overlaps through the record's coordinate dual - (:func:`solprop.translate.modal_tables.project_transverse_field`); the - projected amplitudes are launched as they are. **Light the channels do - not capture is lost, not restored**: a beam much wider than the core + and its temporal pulse, whose energy or peak power is met over the + **whole** beam in the paraxial measure of the guide's core medium + (:meth:`solprop.pulses.SpatialPulse.incident`). The beam is coupled into + the channels by :func:`solprop.coupling.couple.couple` under `coupling` + (SEP 31): it is sampled on a radial plane that resolves it, evaluated at + the guide's nodes, and its coordinates are recovered with the flux + pairing and the forward closure ``H_t = Y0 z x E_t``. The recovered + amplitudes are launched as they are. **Light the channels do not + capture is lost, not restored**: a beam much wider than the core launches a small share of its energy, and a beam the channels cannot see - launches nothing, which is a valid zero launch. The account -- incident, - captured and uncaptured energy, per frequency and in total -- is - recorded as ``configuration["launch"]``, an - :class:`IncidentLaunch`. The beam is launched at its waist + launches nothing, which is a valid zero launch. The coupling's + :class:`~solprop.coupling.couple.CouplingReport` -- incident and launched + energy per frequency and in total, and its admission budget -- is + recorded as ``configuration["launch"]``, and the launch's provenance as + ``configuration["coupling"]``. The beam is launched at its waist (``propz=0``) and centred on the guide's axis. The pulse keywords (`fwhm`, `width`, `energy`, `peak_power`, `phases`, `pulses` and `pulseshape`) are refused beside it, because the beam carries its own - pulse. Requires `provider`, and is refused beside `channel`. + pulse. Requires `provider` and `coupling`, and is refused beside + `channel`. + coupling : solprop.coupling.models.CouplingModel or None, optional + The model an `incident` beam is coupled under: + ``CouplingModel("vector_paraxial", model_error=...)``, whose budget the + guide's contrast, closure and angular content must fit (SEP 31, section + 4.3); a strongly guiding core is refused. Requires `incident`. kerr_form : {'real', 'analytic'}, optional Which instantaneous cubic a general modal run's material regions respond with. ``"real"``, the default, is the real-field cubic @@ -4434,6 +4287,7 @@ def from_waveguide( # noqa: PLR0913 - the interface's keyword surface vacuum_end=vacuum_end, channel=channel, incident=incident, + coupling=coupling, kerr_form=kerr_form, observable_rtol=observable_rtol, observable_atol=observable_atol, @@ -4484,6 +4338,7 @@ def from_waveguide( # noqa: PLR0913 - the interface's keyword surface ("memory_budget", memory_budget), ("channel", channel), ("incident", incident), + ("coupling", coupling), ("kerr_form", None if kerr_form == "real" else kerr_form), ("observable_rtol", observable_rtol), ("observable_atol", observable_atol), @@ -4563,6 +4418,7 @@ def _from_general( # noqa: PLR0913 - it refuses from_waveguide's whole surface *, channel: int | str | None, incident: SpatialPulse | None, + coupling: CouplingModel | None, kerr_form: KerrForm, observable_rtol: float | None, observable_atol: float | None, @@ -4613,8 +4469,11 @@ def _from_general( # noqa: PLR0913 - it refuses from_waveguide's whole surface channel : int or str or None Which channel the pulse enters; ``None`` takes the first. incident : solprop.pulses.SpatialPulse or None - An incident beam to project onto the channels instead, as + An incident beam to couple into the channels instead, as :meth:`from_waveguide` documents it. + coupling : solprop.coupling.models.CouplingModel or None + The model it is coupled under; required beside `incident` and refused + without it. kerr_form : {'real', 'analytic'} Which instantaneous cubic the regions respond with, as :meth:`from_waveguide` documents it. @@ -4750,19 +4609,20 @@ def _from_general( # noqa: PLR0913 - it refuses from_waveguide's whole surface # `DriverError` is what its `Raises` names. The message is the bridge's # own, unchanged, and the cause is kept. raise DriverError(str(error)) from error - launch: IncidentLaunch | None = None - if isinstance(pulse, SpatialPulse): - field0, launch = _incident_launch( - pulse, - working, - route.tables, - _core_index(provider.regions, working), - # The metric the run's own statistics are weighted by, at the - # entrance, so that the captured energy is the launched state's - # physical energy on a declared basis as on the identity. An - # identity route's `metric_at` is `None`, the unweighted path. - metric_at(route.metric_tables, 0.0), + beam = pulse if isinstance(pulse, SpatialPulse) else None + if (beam is None) != (coupling is None): + msg = ( + "an incident beam is coupled under a declared model: give incident= " + "with coupling=CouplingModel('vector_paraxial', model_error=...), or " + "neither" ) + raise DriverError(msg) + if beam is not None: + # Checked before the port is built: a guide whose first region is off the + # axis has no core for a beam to enter through. + _core_index(provider.regions, working) + # The launch is the coupling's, installed through `with_input` below. + field0 = np.zeros((len(channels), 1, working.n_omega), dtype=np.complex128) else: field0 = _in_channel(built, provider, channel) settings = SimulationSettings( @@ -4781,7 +4641,7 @@ def _from_general( # noqa: PLR0913 - it refuses from_waveguide's whole surface stats=_general_stats() if stats is None else stats, backend=backend, ) - return cls( + simulation = cls( working, channels, [], @@ -4794,13 +4654,15 @@ def _from_general( # noqa: PLR0913 - it refuses from_waveguide's whole surface "length": length, "provider": dict(provider.identity), }, - "pulse": pulse, - **({} if launch is None else {"launch": launch}), + **({} if beam is not None else {"pulse": pulse}), }, general=route, guide=provider, materials=materials, ) + if beam is None: + return simulation + return _incident_launch(simulation, beam, cast("CouplingModel", coupling)) @classmethod def _from_hollow( # noqa: PLR0913 - the interface's keyword surface @@ -8982,89 +8844,111 @@ def _core_index( return index +BEAM_EXTENT = 60.0 +"""Field exponent at the edge of an incident beam's radial plane. + +The plane extends to where a super-Gaussian of order N has fallen to +``exp(-BEAM_EXTENT)`` in field, ``r = waist * BEAM_EXTENT**(1/(2N))``, times the +margin :data:`BEAM_MARGIN`; its intensity there is below 1e-52 of the peak. +""" + +BEAM_MARGIN = 1.5 +"""Factor on the incident beam's plane radius beyond its decay radius.""" + +BEAM_NODES = 256 +"""Radial nodes of an incident beam's plane. + +With the plane radius above, the largest transverse wavenumber is about +``pi * 256 / (1.5 * 60**(1/2N) * waist)``, 69/w for a Gaussian, where its spectrum +is below exp(-1000): the beam is resolved to rounding. +""" + + +def _beam_grid(pulse: SpatialPulse) -> RadialGrid: + """Return the radial plane an incident beam is sampled on before coupling. + + Parameters + ---------- + pulse : solprop.pulses.SpatialPulse + The beam. + + Returns + ------- + solprop.grid.RadialGrid + A grid that resolves the beam and holds it to rounding. + """ + radius = BEAM_MARGIN * pulse.waist * BEAM_EXTENT ** (1.0 / (2.0 * pulse.order)) + return RadialGrid(radius, BEAM_NODES) + + def _incident_launch( + simulation: "Simulation", # noqa: UP037 - defined above, referenced while it loads pulse: SpatialPulse, - grid: TimeGrid, - tables: ModalTransformTables, - index: Float[np.ndarray, " n_omega"], - metric: Complex[np.ndarray, "n_modes_metric_out n_modes_metric_in n_omega_metric"] - | None = None, -) -> tuple[Complex[np.ndarray, "n_modes n_pol n_omega"], IncidentLaunch]: - """Launch an incident beam into a guide's channels and account for its energy. - - The beam is normalised over its **own** whole plane in the paraxial measure of - the core medium (:meth:`solprop.pulses.SpatialPulse.incident`), so light - outside the guide's quadrature counts as incident; the correctly scaled field is - sampled at the guide's nodes and projected onto its channels by - :func:`solprop.translate.modal_tables.project_transverse_field`. The projected - amplitudes are the launch: **nothing is rescaled to the requested energy**, so a + model: CouplingModel, +) -> "Simulation": # noqa: UP037 - as above + """Couple an incident beam into a general modal simulation and install it. + + The beam becomes a radial :class:`~solprop.coupling.plane.FieldPlane` in the + guide's core index (:meth:`~solprop.coupling.plane.FieldPlane.from_pulse`, its + request met over its whole plane in the paraxial measure), and + :func:`~solprop.coupling.couple.couple` recovers its coordinates in the guide's + channels under `model`. **Nothing is rescaled to the requested energy**: a badly captured beam launches the little the channels take, and a beam they - cannot see at all is a valid zero launch. The captured energy is - ``a.H W a`` under the run's own declared metric, which is what - :func:`solprop.stats.physical_energy` measures at the entrance. Captured and - uncaptured are physical shares only in a common positive metric (SEP 31 - section 4.3); see :class:`IncidentLaunch`. + cannot see is a valid zero launch. The launch is installed through + :meth:`Simulation.with_input`, which records its provenance as + ``configuration["coupling"]``; the coupling's report is recorded as + ``configuration["launch"]`` and the beam as ``configuration["pulse"]``. Parameters ---------- + simulation : Simulation + The general modal simulation, built with a zero input. pulse : solprop.pulses.SpatialPulse The incident beam and its temporal pulse. - grid : solprop.grid.TimeGrid - The run's grid. - tables : solprop.translate.modal_tables.ModalTransformTables - The guide's reference, carrying the nodes and the coordinate dual. - index : numpy.ndarray - Real phase index of the core at each frequency, shape ``(n_omega,)``. - metric : numpy.ndarray or None, optional - The run's declared basis metric ``W(omega)`` at the entrance, shape - ``(n_modes, n_modes, n_omega)``, as - :func:`solprop.translate.coupled.metric_at` evaluates it; ``None``, the - default, is the identity. + model : solprop.coupling.models.CouplingModel + The coupling model. Returns ------- - tuple - The launched state, shape ``(n_modes, 1, n_omega)``, in the square root of - watts, and its :class:`IncidentLaunch` account. + Simulation + The simulation launching the coupled amplitudes, sharing the translation. Raises ------ DriverError - If the beam cannot be normalised or the record cannot project it. + If the beam cannot be built or the coupling refuses it. """ - nodes, components = tables.electric.shape[1], tables.electric.shape[2] - try: - beam = pulse.incident(grid, tables.node_xy[0], index=index) - except PulseError as error: - raise DriverError(str(error)) from error - sampled = np.zeros((nodes, components, grid.n_omega), dtype=np.complex128) - # The dual's longitudinal columns are zero, so a transverse beam needs no E_z. - sampled[:, :2] = beam.field + port = simulation.input_port() + index = ( + np.zeros(simulation.grid.n_omega) + if port.reference_index is None + else (np.asarray(port.reference_index)) + ) try: - projected = project_transverse_field(tables, sampled) - except ModeError as error: + plane = FieldPlane.from_pulse( + pulse, + simulation.grid, + PlaneGeometry(port.geometry.z), + space=_beam_grid(pulse), + index=index, + ) + result = couple(plane, port, model=model) + except CouplingError as error: raise DriverError(str(error)) from error - amplitudes = projected.amplitudes - flux = ( - np.sum(np.abs(amplitudes) ** 2, axis=0) - if metric is None - else np.real(np.einsum("mw,mnw,nw->w", np.conj(amplitudes), metric, amplitudes)) - ) - weights = fourier.energy_weights(grid.n_omega, grid.dt, kind=grid.kind) - report = IncidentLaunch( - index, beam.energy, np.asarray(weights * flux, dtype=np.float64) - ) - state = np.zeros((amplitudes.shape[0], 1, grid.n_omega), dtype=np.complex128) - state[:, 0, :] = amplitudes + report = result.report logger.info( - "incident beam launched into %d channel(s): %.4g J incident, %.4g J " - "captured, %.4g J not captured", - state.shape[0], + "incident beam coupled into %d channel(s) under %s: %.4g J incident, " + "%.4g J launched", + port.shape[0], + model.kind, report.incident_energy, - report.captured_energy, - report.uncaptured_energy, + report.launched_energy, + ) + return simulation.with_input( + result.launch, + noise=simulation.noise, + configuration={"pulse": pulse, "launch": report}, ) - return state, report def _in_lowest_mode( diff --git a/tests/driver/test_general_modal.py b/tests/driver/test_general_modal.py index ea60dc574..df16e9692 100644 --- a/tests/driver/test_general_modal.py +++ b/tests/driver/test_general_modal.py @@ -51,6 +51,7 @@ from solprop.backends.batch import LockstepRunner from solprop.backends.numpy_ref.batch import NumpyBatchBackend from solprop.constants import ROOM_TEMPERATURE, C +from solprop.coupling import CouplingModel, CouplingReport from solprop.driver import Simulation, prop_gnlse, prop_waveguide from solprop.driver import batch as batch_module from solprop.driver.batch import ( @@ -64,10 +65,8 @@ from solprop.driver.scan import Scan, run_scan from solprop.driver.simulation import ( _GAS_KEYWORD_DEFAULTS, - IncidentLaunch, _core_index, _in_channel, - _incident_launch, _refuse_gas_keywords, _shot_noise, ) @@ -491,19 +490,60 @@ def test_a_declared_field_the_channels_do_not_span_names_its_remainder( assert np.all(launched.residual[~empty] >= 0.0) +INCIDENT_GUIDE = {"radius": 4e-6, "indices": (1.45, 1.44), "n_r": 24, "knots": 9} +"""A weakly guiding strand for the incident launch: a 4 um core at a 0.01 step. + +The vector coupling model admits a solid guide only below its contrast screen, +``Delta = (1.45**2 - 1.44**2) / (2 * 1.45**2) = 6.9e-3`` against ``model_error / 8`` +(SEP 31 section 4.3); the 1.45-in-air strand of the other tests is refused. +""" + +INCIDENT_BUDGET = 0.2 +INCIDENT_MODEL = CouplingModel("vector_paraxial", model_error=INCIDENT_BUDGET) +"""The budget the incident launches are coupled under: the 3 um beam's angular +content, the closure and the contrast together take about a quarter of it.""" + +INCIDENT_FILTER = pytest.mark.filterwarnings( + "ignore::solprop.exceptions.GeneralModalBasisWarning" +) +"""Silence the active-band basis warning of the incident strand's nine knots. + +The interpolated basis departs 1.8e-4 from orthonormal near the band's long edge, +just above ``gram_atol``, where the launched spectrum puts nothing; the gate, weighted +by the launch, passes. The warning itself is asserted elsewhere in this module. +""" + + +@pytest.fixture(scope="module") +def incident_guide(grid): + """One HE11 channel of the weakly guiding strand.""" + return general_guide(grid=grid, **INCIDENT_GUIDE) + + @pytest.fixture(scope="module") -def circular_pair_guide(grid): +def incident_pair(grid): + """The two parities of that channel, declared degenerate.""" + return general_guide( + descriptors=(("HE", 1, 1, "even"), ("HE", 1, 1, "odd")), + blocks=((0, 1),), + grid=grid, + **INCIDENT_GUIDE, + ) + + +@pytest.fixture(scope="module") +def incident_circular(grid): """A declared circular basis of the same parity pair. - The one fixture here whose `linear.modal_metric` is genuinely **dense**, so - the one on which a launch rescaled on the unweighted `sum |a|^2` and one - rescaled on `a.H W a` are different launches. + The one fixture here whose `linear.modal_metric` is genuinely **dense**, so the + launched energy is ``a.H W a`` and not the unweighted ``sum |a|^2``. """ return general_guide( descriptors=(("HE", 1, 1, "even"), ("HE", 1, 1, "odd")), blocks=((0, 1),), circular=(0, 1), grid=grid, + **INCIDENT_GUIDE, ) @@ -519,7 +559,7 @@ def incident_beam(waist, jones=(1.0,)): def launched(guide, grid, incident, **overrides): - """Return a general modal simulation launching an incident beam.""" + """Return a general modal simulation coupling an incident beam.""" return Simulation.from_waveguide( (), GENERAL_Z_END, @@ -527,45 +567,51 @@ def launched(guide, grid, incident, **overrides): grid=grid, lambda0=PULSE["lambda0"], incident=incident, + coupling=overrides.pop("coupling", INCIDENT_MODEL), **(RUN | overrides), ) -def account(simulation) -> IncidentLaunch: - """Return the incident-energy account a simulation recorded.""" +def account(simulation) -> CouplingReport: + """Return the coupling report a simulation recorded.""" report = simulation.configuration["launch"] - assert isinstance(report, IncidentLaunch) + assert isinstance(report, CouplingReport) return report +@INCIDENT_FILTER @pytest.mark.parametrize("kind", ["identity", "dense"]) def test_an_incident_launch_keeps_what_the_channels_capture_under_the_runs_metric( - pair_guide, circular_pair_guide, grid, kind + incident_pair, incident_circular, grid, kind ): - """`incident=` launches the captured amplitudes, and its account says so. - - The incident energy is the pulse's over the whole beam; the captured energy - is the launched state's physical energy under the run's own declared metric, - which on the dense circular basis is not the unweighted sum. Nothing is - rescaled, so the launched energy is the captured one and falls short of - ``energy=`` by what the channels miss. An x-polarised beam enters the even + """`incident=` launches the recovered amplitudes, and its report says so. + + The incident energy is the beam's over its whole plane in the paraxial measure; + the launched energy is the state's physical energy under the run's own declared + metric, which on the dense circular basis is not the unweighted sum. Nothing is + rescaled, so the launched energy falls short of ``energy=`` by what the + channels miss. The vector model names no uncaptured energy: F and the incident + measure are not one positive metric. An x-polarised beam enters the even parity channel alone, and splits equally between the two circular ones. """ - guide = pair_guide if kind == "identity" else circular_pair_guide - simulation = launched(guide, grid, incident_beam(1.3e-6)) + guide = incident_pair if kind == "identity" else incident_circular + simulation = launched(guide, grid, incident_beam(3e-6)) report = account(simulation) - # One rescaling of the beam, summed over bins: a few rounding units. - assert report.incident_energy == pytest.approx(PULSE["energy"], rel=1e-13) - assert 0.5 * report.incident_energy < report.captured_energy - assert report.captured_energy < report.incident_energy - assert_allclose(report.uncaptured, report.incident - report.captured, rtol=0.0) + # The beam's closed-form normalisation, then its radial plane's Parseval sum. + assert report.incident_energy == pytest.approx(PULSE["energy"], rel=1e-10) + assert 0.5 * report.incident_energy < report.launched_energy + assert report.launched_energy < report.incident_energy + assert report.captured is None and report.uncaptured is None + assert report.budget <= INCIDENT_BUDGET + coupling = cast("Mapping[str, object]", simulation.configuration["coupling"]) + assert coupling["model"] == "vector_paraxial" problem = simulation.translate() metric = metric_at(problem.linear.modal_metric, 0.0) assert (metric is None) == (kind == "identity") assert_allclose( float(physical_energy(grid, problem.initial.field0[0], metric=metric)), - report.captured_energy, - # The statistic whitens by an eigendecomposition per bin and the account + report.launched_energy, + # The statistic whitens by an eigendecomposition per bin and the report # forms the quadratic form directly: rounding over the 65-bin reduction. rtol=1e-12, atol=0.0, @@ -574,80 +620,87 @@ def test_an_incident_launch_keeps_what_the_channels_capture_under_the_runs_metri live = np.abs(state[0]) > 0.0 if kind == "identity": # The even channel's E_y and the odd channel's E_x vary as sin(2 theta), - # so their overlap with a uniformly polarised beam cancels on the + # so their pairing with a uniformly polarised beam cancels on the # azimuthal rule to rounding of the node sum. assert np.max(np.abs(state[1])) < 1e-12 * np.max(np.abs(state[0])) else: assert_allclose(np.abs(state[1][live]), np.abs(state[0][live]), rtol=1e-6) +@INCIDENT_FILTER def test_an_incident_launch_shares_a_coherent_beam_between_two_channels( - pair_guide, grid + incident_pair, grid ): """A beam polarised between the parity channels fills both, coherently. The Jones vector ``(1, 0.5j)`` puts half the even channel's amplitude in the odd one, a quarter period behind (conjugated into the internal convention). - The two channels are a rotation of each other, so the total captured is the - x-polarised beam's: what is projected is the field, and the account is the - quadratic form of its amplitudes, not a sum of separately projected - intensities. + The two channels are a rotation of each other, so the total launched is the + x-polarised beam's: what is recovered is the field, not a sum of separately + projected intensities. """ - tilted = launched(pair_guide, grid, incident_beam(1.3e-6, (1.0, 0.5j))) - straight = launched(pair_guide, grid, incident_beam(1.3e-6, (1.0, 0.0))) + tilted = launched(incident_pair, grid, incident_beam(3e-6, (1.0, 0.5j))) + straight = launched(incident_pair, grid, incident_beam(3e-6, (1.0, 0.0))) state = tilted._field0[:, 0] live = np.abs(state[0]) > 0.0 assert_allclose(state[1][live] / state[0][live], -0.5j, rtol=1e-6) assert_allclose( - account(tilted).captured_energy, - account(straight).captured_energy, + account(tilted).launched_energy, + account(straight).launched_energy, rtol=1e-6, ) -def test_a_beam_the_channels_cannot_see_is_a_valid_zero_launch(guide, grid): +@INCIDENT_FILTER +def test_a_beam_the_channels_cannot_see_is_a_valid_zero_launch(incident_guide, grid): """Zero capture by symmetry launches nothing and runs; no division by zero. - A y-polarised beam against the x-polarised even channel: their overlap is the - channel's E_y, which varies as sin(2 theta) and cancels on the azimuthal + A y-polarised beam against the x-polarised even channel: their pairing is the + channel's E_y and H_x, which vary as sin(2 theta) and cancel on the azimuthal rule. The bound is the square of the node sum's rounding, ``(n_nodes eps)**2`` of the incident energy, since an energy is quadratic in the amplitude. """ - simulation = launched(guide, grid, incident_beam(1.3e-6, (0.0, 1.0))) + simulation = launched(incident_guide, grid, incident_beam(3e-6, (0.0, 1.0))) report = account(simulation) assert simulation.general is not None nodes = int(simulation.general.tables.electric.shape[1]) bound = (nodes * np.finfo(float).eps) ** 2 - assert 0.0 <= report.captured_energy < bound * report.incident_energy - assert report.uncaptured_energy == pytest.approx(report.incident_energy, rel=1e-13) + assert 0.0 <= report.launched_energy < bound * report.incident_energy output = cast("MemoryOutput", simulation.run(progress="none")) assert all(np.all(np.isfinite(kept(plane.field))) for plane in output.planes()) - assert "captured share" in str(report) + assert "launched share" in str(report) -def test_the_incident_energy_does_not_depend_on_the_guides_aperture(guide, grid): +@INCIDENT_FILTER +def test_the_incident_energy_does_not_depend_on_the_guides_aperture( + incident_guide, grid +): """The beam is normalised over its own plane, not over a guide's nodes. The same beam into two strands of different radius carries the same incident - account, bin for bin and to the bit, because no node of either guide enters - it; what each captures differs. + energy, bin for bin and to the bit, because no node of either guide enters it; + what each launches differs. """ - wider = general_guide(grid=grid, radius=2.5e-6) - beam = incident_beam(1.3e-6) - narrow_account = account(launched(guide, grid, beam)) + wider = general_guide(grid=grid, radius=5e-6, indices=(1.45, 1.44), n_r=24, knots=9) + beam = incident_beam(3e-6) + narrow_account = account(launched(incident_guide, grid, beam)) wide_account = account(launched(wider, grid, beam)) np.testing.assert_array_equal(narrow_account.incident, wide_account.incident) np.testing.assert_array_equal( narrow_account.reference_index, wide_account.reference_index ) - assert narrow_account.captured_energy != wide_account.captured_energy + assert narrow_account.launched_energy != wide_account.launched_energy -def test_the_incident_launch_refuses_what_it_does_not_carry(pair_guide, grid): - """Two launches at once, pulse keywords beside a beam, and beams it cannot place.""" - beam = incident_beam(1.3e-6) +@INCIDENT_FILTER +def test_the_incident_launch_refuses_what_it_does_not_carry( + incident_pair, pair_guide, grid +): + """Two launches at once, pulse keywords beside a beam, beams it cannot place, + a missing or stray model, and a strongly guiding core the model does not admit.""" + beam = incident_beam(3e-6) with pytest.raises(DriverError, match="two ways to say what is launched"): - launched(pair_guide, grid, beam, channel=0) + launched(incident_pair, grid, beam, channel=0) for keyword, value in ( ("fwhm", 20e-15), ("width", 10e-15), @@ -658,22 +711,19 @@ def test_the_incident_launch_refuses_what_it_does_not_carry(pair_guide, grid): ("pulseshape", "sech"), ): with pytest.raises(DriverError, match=f"carries its own temporal.*{keyword}"): - launched(pair_guide, grid, beam, **{keyword: value}) + launched(incident_pair, grid, beam, **{keyword: value}) temporal = beam.temporal with pytest.raises(DriverError, match="PlanarPulse"): - launched(pair_guide, grid, PlanarPulse(temporal, 1e-6, waist_x=1e-6)) + launched(incident_pair, grid, PlanarPulse(temporal, 1e-6, waist_x=1e-6)) with pytest.raises(DriverError, match="propz=0"): - launched(pair_guide, grid, SpatialPulse(temporal, 1.3e-6, propz=-1e-4)) - # And a record the map itself refuses reaches the caller as this entry point's - # own error: the launch promises `DriverError` and the map raises `ModeError`. - tables = projected_tables(pair_guide, grid) - bare = dataclasses.replace(tables) - object.__setattr__(bare, "coordinate_dual", None) - index = np.where(grid.active_mask & (grid.omega > 0), 1.45, 0.0) - with pytest.raises(DriverError, match="carry no coordinate_dual"): - _incident_launch(beam, grid, bare, index) - with pytest.raises(DriverError, match="finite and non-negative"): - _incident_launch(beam, grid, tables, -index) + launched(incident_pair, grid, SpatialPulse(temporal, 3e-6, propz=-1e-4)) + with pytest.raises(DriverError, match="coupled under a declared model"): + launched(incident_pair, grid, beam, coupling=None) + with pytest.raises(DriverError, match="coupled under a declared model"): + build(incident_pair, grid, coupling=INCIDENT_MODEL) + # The 1.45-in-air strand fails the weak-guidance screen by name. + with pytest.raises(DriverError, match="contrast"): + launched(pair_guide, grid, beam) # And without a provider it is one of the general-guide keywords the # gas-filled route refuses by name, beside `channel=` and `kerr_form=`. with pytest.raises(DriverError, match="incident= belongs to the general modal"): @@ -687,7 +737,7 @@ def test_the_incident_launch_refuses_what_it_does_not_carry(pair_guide, grid): **PULSE, ) # A guide whose first region is not on the axis has no core to enter through. - provider = pair_guide.field_provider + provider = incident_pair.field_provider core, cladding = provider.regions shifted = (dataclasses.replace(core, inner_radius=1e-7), cladding) with pytest.raises(DriverError, match="does not start there"): @@ -742,63 +792,69 @@ def test_a_complex_permittivity_never_reaches_the_forward_field_closure(guide): ModalGuide.from_provider(lossy, sampling.omega, nodes=sampling.nodes) +@INCIDENT_FILTER @pytest.mark.medium -def test_a_poorly_captured_beam_launches_only_what_the_guide_takes(guide, grid): +def test_a_poorly_captured_beam_launches_only_what_the_guide_takes( + incident_guide, grid +): """A beam much wider than the core enters with the little the guide captures. - A 20 um beam on a 1.5 um strand: the channel captures about one per cent of - it. The run's own entrance plane, through `prop_waveguide`, carries the - captured energy and not ``energy=``: nothing restores what missed. The - account is in the file's configuration as well as the simulation's. + A 40 um beam on the 4 um strand: the channel takes a few per cent of it. The + run's own entrance plane, through `prop_waveguide`, carries the launched energy + and not ``energy=``: nothing restores what missed. The report is in the file's + configuration as well as the simulation's. """ - beam = incident_beam(20e-6) + beam = incident_beam(40e-6) output = cast( "MemoryOutput", prop_waveguide( (), GENERAL_Z_END, - provider=guide, + provider=incident_guide, grid=grid, lambda0=PULSE["lambda0"], incident=beam, + coupling=INCIDENT_MODEL, progress="none", **RUN, ), ) - report = account(launched(guide, grid, beam)) - assert report.captured_energy < 0.02 * report.incident_energy - assert report.incident_energy == pytest.approx(PULSE["energy"], rel=1e-13) + report = account(launched(incident_guide, grid, beam)) + assert report.launched_energy < 0.05 * report.incident_energy + assert report.incident_energy == pytest.approx(PULSE["energy"], rel=1e-10) entrance = kept(output.planes()[0].field) # Identity metric: the energy statistic is the sum of |a|^2, formed in the - # same order as the account's up to the frequency-sum association. + # same order as the report's up to the frequency-sum association. assert_allclose( float(physical_energy(grid, entrance)), - report.captured_energy, + report.launched_energy, rtol=1e-12, atol=0.0, ) assert output.metadata is not None recorded = output.metadata.configuration["launch"] - assert isinstance(recorded, IncidentLaunch) - assert recorded.captured_energy == report.captured_energy + assert isinstance(recorded, CouplingReport) + assert recorded.launched_energy == report.launched_energy +@INCIDENT_FILTER @pytest.mark.medium @pytest.mark.parametrize("backend", ["numpy_ref", "numba_cpu", "native"]) -def test_the_incident_launch_is_the_same_on_every_cpu_receiver(guide, grid, backend): +def test_the_incident_launch_is_the_same_on_every_cpu_receiver( + incident_guide, grid, backend +): """The launch is built before any backend: every CPU backend starts from it. - Ten half-micrometre steps on each receiver: the stored entrance - plane is the launched state, a lossless copy, so it agrees bit for bit with the - one the simulation built, and its energy is the captured energy. + Ten half-micrometre steps on each receiver: the stored entrance plane is the + launched state, a lossless copy, so it agrees bit for bit with the one the + simulation built, and its energy is the launched energy. """ if backend == "native": pytest.importorskip("solprop_native") - beam = incident_beam(2e-6) - # Inside the default collar's explicit stability limit, 7e-7 m. + beam = incident_beam(3e-6) step = 5e-7 simulation = launched( - guide, + incident_guide, grid, beam, backend=backend, @@ -809,11 +865,11 @@ def test_the_incident_launch_is_the_same_on_every_cpu_receiver(guide, grid, back ) output = cast("MemoryOutput", simulation.run(progress="none")) entrance = kept(output.planes()[0].field) - reference = launched(guide, grid, beam)._field0 + reference = launched(incident_guide, grid, beam)._field0 np.testing.assert_array_equal(entrance, reference) assert_allclose( float(physical_energy(grid, entrance)), - account(simulation).captured_energy, + account(simulation).launched_energy, rtol=1e-12, atol=0.0, ) diff --git a/tests/test_display.py b/tests/test_display.py index b12a716d5..9fc54f407 100644 --- a/tests/test_display.py +++ b/tests/test_display.py @@ -16,9 +16,9 @@ from record_numbers import assert_record_numbers, record_skeleton, skeleton_hash from solprop import _display, tools +from solprop.coupling import AdmissionItem, CouplingReport from solprop.driver import Simulation, SimulationSettings from solprop.driver.scan import Scan -from solprop.driver.simulation import IncidentLaunch from solprop.exceptions import MaterialRangeWarning, ModeError, OutputError from solprop.grid import EnvelopeGrid, FreeGrid, RadialGrid from solprop.modes import ( @@ -219,11 +219,23 @@ def modal_records(): # A declared transverse field projected onto this guide's one channel: the # channel's own profile, so the projection captures all of it. "ProjectedLaunch": project_transverse_field(printed_tables, printed_field), - # An incident beam's account: synthetic per-bin energies, a third captured. - "IncidentLaunch": IncidentLaunch( - np.full(3, 1.45), + # A coupling's account: synthetic per-bin energies, a third captured. + "CouplingReport": CouplingReport( + "scalar_paraxial", + 0.05, + np.full(3, 1.00028), np.array([2e-7, 6e-7, 2e-7]), np.array([0.6e-7, 2e-7, 0.7e-7]), + np.array([0.6e-7, 2e-7, 0.7e-7]), + "", + ( + AdmissionItem("angular", 6.3e-3, None, True, "field", "cone + tail"), + AdmissionItem("contrast", None, None, False, "model", "not applied"), + ), + "zero", + None, + {"source": "pulse"}, + "0" * 64, ), "ProviderChannelMode": grin_guide.mode_collection.modes[0], "ModalSampling": grin_guide.sampling, @@ -513,7 +525,7 @@ def examples(tmp_path_factory): "ProviderChannelMode", "ModalTransformTables", "ProjectedLaunch", - "IncidentLaunch", + "CouplingReport", "GeneralModalRoute", "ModalMetricTables", "CoupledFlowTables", @@ -906,7 +918,7 @@ def rounding(block: str) -> str: "ProviderChannelMode", "ModalTransformTables", "ProjectedLaunch", - "IncidentLaunch", + "CouplingReport", "GeneralModalRoute", "ModalMetricTables", "CoupledFlowTables", diff --git a/tests/test_display/CouplingReport.txt b/tests/test_display/CouplingReport.txt new file mode 100644 index 000000000..daef740d0 --- /dev/null +++ b/tests/test_display/CouplingReport.txt @@ -0,0 +1,10 @@ +CouplingReport: a field coupled into an input port + model: scalar_paraxial + incident: 1 uJ + launched: 330 nJ + launched share: 0.3300 of the incident energy + uncaptured: 670 nJ + budget: 6.300e-03 of model_error 0.05 + angular: 6.300e-03 (field) cone + tail + contrast: n/a (model) not applied + exterior: zero From b4b85ba9138e004f3e3c3df4e6a9342fceaae760 Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 02:11:43 +0100 Subject: [PATCH 20/50] Coupling: name the module coupler, so the function couple is not shadowed The package exports couple(); a submodule of the same name made solprop.coupling.couple ambiguous and the API reference unable to document both. Also makes the incident beam grid's constants private and fixes the envelope module's cross-references. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019qNYqtCKh4iTKj7544yARb --- docs/dev/figures/build.py | 2 +- docs/dev/figures/layers.svg | 4 ++-- src/solprop/coupling/__init__.py | 4 ++-- .../coupling/{couple.py => coupler.py} | 1 + src/solprop/coupling/envelopes.py | 4 ++-- src/solprop/coupling/models.py | 2 +- src/solprop/coupling/outcouple.py | 4 ++-- src/solprop/coupling/port.py | 2 +- src/solprop/driver/simulation.py | 22 +++++++++---------- tests/coupling/test_couple.py | 2 +- 10 files changed, 24 insertions(+), 23 deletions(-) rename src/solprop/coupling/{couple.py => coupler.py} (99%) diff --git a/docs/dev/figures/build.py b/docs/dev/figures/build.py index a999b1f3a..56c16b41d 100644 --- a/docs/dev/figures/build.py +++ b/docs/dev/figures/build.py @@ -599,7 +599,7 @@ class Layer(NamedTuple): Module("tools.py"), Module( "coupling/", - ("plane", "port", "remap", "models", "couple", "outcouple", "envelopes"), + ("plane", "port", "remap", "models", "coupler", "outcouple", "envelopes"), ), Module( "physdata/", diff --git a/docs/dev/figures/layers.svg b/docs/dev/figures/layers.svg index dd534e52f..7a9931ce3 100644 --- a/docs/dev/figures/layers.svg +++ b/docs/dev/figures/layers.svg @@ -121,10 +121,10 @@ text { font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; fill: optics.py tools.py - + coupling/ plane · port · remap · models -couple · outcouple · envelopes +coupler · outcouple · envelopes physdata/ sellmeier · equation_of_state diff --git a/src/solprop/coupling/__init__.py b/src/solprop/coupling/__init__.py index d3e62dd1e..00bae3222 100644 --- a/src/solprop/coupling/__init__.py +++ b/src/solprop/coupling/__init__.py @@ -9,7 +9,7 @@ native representation, with its references, geometry, medium, metric and provenance; :func:`~solprop.coupling.remap.regrid` moves it onto another temporal lattice or reference; an :class:`~solprop.coupling.port.InputPort` describes what a receiving -simulation accepts at its entrance, and :func:`~solprop.coupling.couple.couple` +simulation accepts at its entrance, and :func:`~solprop.coupling.coupler.couple` launches a plane into it under a declared :class:`~solprop.coupling.models.CouplingModel`; :class:`~solprop.coupling.outcouple.Outcouple` synthesises a guide's exit field. @@ -18,7 +18,7 @@ The manual page is ``docs/guide/workflows/coupling.md``. """ -from solprop.coupling.couple import ( +from solprop.coupling.coupler import ( AdmissionItem, CouplingReport, CouplingResult, diff --git a/src/solprop/coupling/couple.py b/src/solprop/coupling/coupler.py similarity index 99% rename from src/solprop/coupling/couple.py rename to src/solprop/coupling/coupler.py index 7311386be..18a940a8d 100644 --- a/src/solprop/coupling/couple.py +++ b/src/solprop/coupling/coupler.py @@ -88,6 +88,7 @@ "AdmissionItem", "CouplingReport", "CouplingResult", + "ItemScope", "couple", "target_columns", ] diff --git a/src/solprop/coupling/envelopes.py b/src/solprop/coupling/envelopes.py index 67e5d7722..b5fed934b 100644 --- a/src/solprop/coupling/envelopes.py +++ b/src/solprop/coupling/envelopes.py @@ -13,13 +13,13 @@ - :data:`CAPILLARY_ENVELOPE`: the scalar J0 overlap the model uses against the overlap of the capillary's own vector Marcatili mode fields - (:meth:`solprop.modes.MarcatiliMode.field` and its normalisation) on an + (:meth:`solprop.modes.capillary.MarcatiliMode.field` and its normalisation) on an independent two-dimensional quadrature, for centred, displaced and tilted Gaussian beams and for a change of capillary radius. - :data:`STEP_INDEX_ENVELOPE`: the weak-guidance LP01 overlap of two step-index fibres of one material pair and different radii against the full-vector flux overlap of their exact bound HE11 fields - (:meth:`solprop.modes.StepIndexMode.electromagnetic_field`). It replaces the + (:meth:`solprop.modes.stepindex.StepIndexMode.electromagnetic_field`). It replaces the contrast screen within its range. """ diff --git a/src/solprop/coupling/models.py b/src/solprop/coupling/models.py index b7d0a6b2e..2a44b00e3 100644 --- a/src/solprop/coupling/models.py +++ b/src/solprop/coupling/models.py @@ -582,7 +582,7 @@ def outer(self) -> float: ------- float The core radius for a capillary; for an LP set, where the slowest - cladding tail has decayed by :data:`_EXTERIOR_DECAY` e-foldings. + cladding tail has decayed by 39 e-foldings, ``log(1e17)``. """ if self.family == "capillary": return self.radius diff --git a/src/solprop/coupling/outcouple.py b/src/solprop/coupling/outcouple.py index 453a8c853..65de8c2b0 100644 --- a/src/solprop/coupling/outcouple.py +++ b/src/solprop/coupling/outcouple.py @@ -16,7 +16,7 @@ The guide's field per true-power coefficient is :math:`\sqrt{2/(\varepsilon_0c\operatorname{Re}n_{\rm eff})}\,v_m` (``docs/dev/ conventions.md`` section 4). It is written into the grid's columns through each -profile's closed-form transform (:func:`~solprop.coupling.couple.target_columns`), +profile's closed-form transform (:func:`~solprop.coupling.coupler.target_columns`), so a profile's kink at the core radius is not sampled; what the grid's spectral window cannot hold is reported, not renormalised. """ @@ -29,7 +29,7 @@ from solprop import fourier from solprop.constants import EPS0, C -from solprop.coupling.couple import target_columns +from solprop.coupling.coupler import target_columns from solprop.coupling.plane import FieldPlane, PlaneBasis, PlaneMetric from solprop.coupling.port import METRIC_TOLERANCE from solprop.exceptions import CouplingError diff --git a/src/solprop/coupling/port.py b/src/solprop/coupling/port.py index 674d15414..c10254bb5 100644 --- a/src/solprop/coupling/port.py +++ b/src/solprop/coupling/port.py @@ -15,7 +15,7 @@ A :class:`CouplingLaunch` is bound to one port: it carries the port, the target coefficients and the reference origin the launched run inherits, and :meth:`solprop.driver.simulation.Simulation.with_input` refuses it for any other port, -whatever the array shapes. :func:`solprop.coupling.couple.couple` computes one. +whatever the array shapes. :func:`solprop.coupling.coupler.couple` computes one. """ import hashlib diff --git a/src/solprop/driver/simulation.py b/src/solprop/driver/simulation.py index 44ae25a7d..32261655e 100644 --- a/src/solprop/driver/simulation.py +++ b/src/solprop/driver/simulation.py @@ -1690,7 +1690,7 @@ def input_port(self, z: float | None = None) -> InputPort: index of its entrance medium, the coupling models it admits and what they need: an ordinary mode set's scalar profiles, or a general guide's E/H reference at its nodes and its regions' contrast. A launch computed for it - (:func:`solprop.coupling.couple.couple`) is accepted by :meth:`with_input`, + (:func:`solprop.coupling.coupler.couple`) is accepted by :meth:`with_input`, and a launch computed for any other port is refused. Parameters @@ -4029,7 +4029,7 @@ def from_waveguide( # noqa: PLR0913 - the interface's keyword surface and its temporal pulse, whose energy or peak power is met over the **whole** beam in the paraxial measure of the guide's core medium (:meth:`solprop.pulses.SpatialPulse.incident`). The beam is coupled into - the channels by :func:`solprop.coupling.couple.couple` under `coupling` + the channels by :func:`solprop.coupling.coupler.couple` under `coupling` (SEP 31): it is sampled on a radial plane that resolves it, evaluated at the guide's nodes, and its coordinates are recovered with the flux pairing and the forward closure ``H_t = Y0 z x E_t``. The recovered @@ -4037,7 +4037,7 @@ def from_waveguide( # noqa: PLR0913 - the interface's keyword surface capture is lost, not restored**: a beam much wider than the core launches a small share of its energy, and a beam the channels cannot see launches nothing, which is a valid zero launch. The coupling's - :class:`~solprop.coupling.couple.CouplingReport` -- incident and launched + :class:`~solprop.coupling.coupler.CouplingReport` -- incident and launched energy per frequency and in total, and its admission budget -- is recorded as ``configuration["launch"]``, and the launch's provenance as ``configuration["coupling"]``. The beam is launched at its waist @@ -8844,18 +8844,18 @@ def _core_index( return index -BEAM_EXTENT = 60.0 +_BEAM_EXTENT = 60.0 """Field exponent at the edge of an incident beam's radial plane. The plane extends to where a super-Gaussian of order N has fallen to -``exp(-BEAM_EXTENT)`` in field, ``r = waist * BEAM_EXTENT**(1/(2N))``, times the -margin :data:`BEAM_MARGIN`; its intensity there is below 1e-52 of the peak. +``exp(-_BEAM_EXTENT)`` in field, ``r = waist * _BEAM_EXTENT**(1/(2N))``, times the +margin :data:`_BEAM_MARGIN`; its intensity there is below 1e-52 of the peak. """ -BEAM_MARGIN = 1.5 +_BEAM_MARGIN = 1.5 """Factor on the incident beam's plane radius beyond its decay radius.""" -BEAM_NODES = 256 +_BEAM_NODES = 256 """Radial nodes of an incident beam's plane. With the plane radius above, the largest transverse wavenumber is about @@ -8877,8 +8877,8 @@ def _beam_grid(pulse: SpatialPulse) -> RadialGrid: solprop.grid.RadialGrid A grid that resolves the beam and holds it to rounding. """ - radius = BEAM_MARGIN * pulse.waist * BEAM_EXTENT ** (1.0 / (2.0 * pulse.order)) - return RadialGrid(radius, BEAM_NODES) + radius = _BEAM_MARGIN * pulse.waist * _BEAM_EXTENT ** (1.0 / (2.0 * pulse.order)) + return RadialGrid(radius, _BEAM_NODES) def _incident_launch( @@ -8891,7 +8891,7 @@ def _incident_launch( The beam becomes a radial :class:`~solprop.coupling.plane.FieldPlane` in the guide's core index (:meth:`~solprop.coupling.plane.FieldPlane.from_pulse`, its request met over its whole plane in the paraxial measure), and - :func:`~solprop.coupling.couple.couple` recovers its coordinates in the guide's + :func:`~solprop.coupling.coupler.couple` recovers its coordinates in the guide's channels under `model`. **Nothing is rescaled to the requested energy**: a badly captured beam launches the little the channels take, and a beam they cannot see is a valid zero launch. The launch is installed through diff --git a/tests/coupling/test_couple.py b/tests/coupling/test_couple.py index 1edbd5c56..885d5eb5b 100644 --- a/tests/coupling/test_couple.py +++ b/tests/coupling/test_couple.py @@ -37,7 +37,7 @@ basis_identity, couple, ) -from solprop.coupling.couple import _contrast_items, _gain_item +from solprop.coupling.coupler import _contrast_items, _gain_item from solprop.coupling.models import cone_angle, radial_rule from solprop.driver import Simulation from solprop.exceptions import CouplingError From 593c9522a787bf8d8c342713598ed772709db45c Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 02:11:43 +0100 Subject: [PATCH 21/50] Docs: coupling models, admission budget and report (SEP 31 phase 3) The coupling chapter gains the models, the admission budget and report, a Gaussian into a capillary, a capillary-radius change by direct overlap and through Outcouple, a solid-fibre overlap and the vector entry; the general modal, solid-fibre, supplied-guide and interior-launch pages, architecture, the API index and the changelog follow; Part II records the phase 3 report. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019qNYqtCKh4iTKj7544yARb --- CHANGELOG.md | 41 +++- docs/api/index.md | 2 +- docs/dev/architecture.md | 14 +- docs/dev/seps/sep-0031-simulation-coupling.md | 213 +++++++++++++++++ docs/guide/results/interior-launch.md | 7 +- docs/guide/waveguides/general-modal.md | 112 ++++----- docs/guide/waveguides/solid-fibres.md | 27 ++- docs/guide/waveguides/supplied-guides.md | 4 +- docs/guide/workflows/coupling.md | 221 ++++++++++++++++-- 9 files changed, 528 insertions(+), 113 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c14c3fce4..260eb6823 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,24 +42,43 @@ The first public release. It contains: schema 23 (`flux_model`, `flux_plan`). - **Incident beams into general modal guides** ([SEP 31](https://solprop.readthedocs.io/en/latest/dev/seps/sep-0031-simulation-coupling.html), - phase 1). `from_waveguide(provider=..., incident=SpatialPulse(...))` and - `prop_waveguide` launch the share of an incident beam that the guide's channels - capture. The beam's energy is the physical energy over its whole plane in the core - medium, and the light that misses is lost rather than restored. `IncidentLaunch` - records the incident, captured and uncaptured energy per frequency in + phases 1 and 3). `from_waveguide(provider=..., incident=SpatialPulse(...), + coupling=CouplingModel("vector_paraxial", model_error=...))` and `prop_waveguide` + launch the share of an incident beam that the guide's channels capture. The beam's + energy is met over its whole plane in the core medium, and the light that misses is + lost rather than restored. The coupling's `CouplingReport` is recorded in `configuration["launch"]`, and `SpatialPulse.incident` samples a beam normalised over its own plane. The node-sampled `transverse=` profile, its rescaling to `energy=`, the - 3 % uncaptured warning and `LAUNCH_UNCAPTURED_WARNING` are removed. + 3 % uncaptured warning, `LAUNCH_UNCAPTURED_WARNING` and `IncidentLaunch` are removed; + a strongly guiding core is refused by the vector model's contrast screen. - **Plane exchange between simulations** ([SEP 31](https://solprop.readthedocs.io/en/latest/dev/seps/sep-0031-simulation-coupling.html), phase 2). `solprop.coupling` holds `FieldPlane`, a coherent field at one plane with its reference, geometry, metric, basis identity and provenance; `regrid`, the direct finite-time Fourier remap between temporal grids with its loss report and no - renormalisation; and `InputPort` with the `same_basis` relaunch. - `processing.field_plane` reads a saved plane back, `Simulation.input_port()` - describes a simulation's entrance and `with_input` takes a launch bound to it, - recording `configuration["coupling"]` and drawing no new noise. Output schema 24 - records each run's plane-reference record (`RunMetadata.reference`). + renormalisation; and `InputPort`. `processing.field_plane` reads a saved plane back, + `Simulation.input_port()` describes a simulation's entrance and `with_input` takes a + launch bound to it, recording `configuration["coupling"]` and drawing no new noise. + Output schema 24 records each run's plane-reference record (`RunMetadata.reference`). +- **Coupling models** + ([SEP 31](https://solprop.readthedocs.io/en/latest/dev/seps/sep-0031-simulation-coupling.html), + phase 3). `couple(plane, port, model=CouplingModel(kind, model_error=...))` is the one + path from a field plane to a launch bound to a port, returning the launch and an + immutable `CouplingReport`. `same_basis` copies an identical basis, applies a declared + energy-preserving `BasisChange` or recovers a general modal plane by the flux pairing; + `scalar_paraxial` fits Gaussian, analytic or sampled beams into a capillary's HE1m + modes, one capillary's exit into another of any radius, and one weakly guiding + step-index fibre into another, in the common paraxial measure; `vector_paraxial` + couples into a general modal guide with the forward H closure. Each is admitted + against its energy budget: angular tails, the weak-guidance contrast screen or a + recorded pair envelope (`CAPILLARY_ENVELOPE`, `STEP_INDEX_ENVELOPE`), metric defects, + the H closure, a passive-map certificate and numerical errors; uncaptured energy is + reported only where a common positive metric exists. `FieldPlane.from_analytic` + samples an analytic field with its support and domain checks, `FieldPlane.from_pulse` + takes a reference `index=`, `field_plane(run, source=...)` carries an ordinary mode + set's scalar profiles (`ScalarModeBasis`), and `Outcouple` synthesises a guide's exit + field on a spatial grid under a declared interface model. Ports state the models they + admit. The phase-2 `same_basis` function is removed in favour of `couple`. - **Media and responses.** - Gases, gas mixtures, glasses and metals from the [optmat](https://github.com/LupoLab/optmat) material library. diff --git a/docs/api/index.md b/docs/api/index.md index ffc5c1c6a..9c58b67f2 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -47,7 +47,7 @@ In scripts and library code, import from the home modules listed below. |---|---| | {mod}`solprop.output` | the in-memory and HDF5 output handlers, the run's metadata record, `open_file` and resuming a run, live snapshots (`SnapshotController`, `read_snapshot`, `restore_snapshot`; [the output page](../guide/results/output.md)), and the raw TDSE response archive (`TDSEArchive`, `open_tdse_archive`; [the TDSE response](../guide/media/tdse.md#the-raw-response-archive)) | | {mod}`solprop.processing` | post-processing of a saved run: fields, spectra, pulse metrics, coherence, compression, the physical fields of a general modal run, and `field_plane`, a saved plane read back for coupling | -| {mod}`solprop.coupling` | coupling simulations through a field plane: `FieldPlane`, `regrid`, `InputPort` and the `same_basis` relaunch ([coupling simulations](../guide/workflows/coupling.md)) | +| {mod}`solprop.coupling` | coupling simulations through a field plane: `FieldPlane` (from a run, a `SpatialPulse`, an analytic field or arrays), `regrid`, `InputPort`, `couple` under a `CouplingModel` with its `CouplingReport`, the scalar profiles `ScalarModeBasis`, the recorded pair envelopes, and `Outcouple` ([coupling simulations](../guide/workflows/coupling.md)) | | {mod}`solprop.stats` | the statistics recorded at the saved planes, and `StatsSpec` to choose them | | {mod}`solprop.plotting` | the standard figures: propagation maps, slices, spectrograms, statistics | | {mod}`solprop.optics` | linear optics applied to a pulse outside a propagation: materials, chirped mirrors, grating and prism pairs | diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md index 20c26bac3..2802746d1 100644 --- a/docs/dev/architecture.md +++ b/docs/dev/architecture.md @@ -162,10 +162,14 @@ measure from the record with `translate.flux.flux_from_record` and `translate.coupled.metric_at`. No other module of **X** imports **T**. **Coupling crosses the layers without breaking them** (SEP 31). The field plane, the -input port and the remap are P value types; the driver's `Simulation.input_port()` -asks **T** (`translate.coupling`) to fill the port from the translated problem, and -extraction is **X** (`processing.field_plane`), because **P** must not import **X**. -A coupling computed in **P** therefore never imports **T** or reads a file. +input port, the remap and `couple` are **P**; the driver's `Simulation.input_port()` +asks **T** (`translate.coupling`) to fill the port from the translated problem -- its +metric, reference index, the scalar profiles of its modes (built in **P** from the +modes, `coupling.models.ScalarModeBasis`) or a general guide's E/H and contrast -- and +extraction is **X** (`processing.field_plane`, which builds the same profiles from a +`source=` simulation's modes), because **P** must not import **X**. A coupling computed +in **P** therefore never imports **T** or reads a file. The driver's incident-beam +launch calls `couple` and installs its launch through `with_input`. Rules: imports flow P → T → B; B never builds a physics object — it does not know how to make a mode or a gas, and imports P only for the grid, mode and profile types its statistics are @@ -189,7 +193,7 @@ against `src/solprop` by `tests/test_structure_figures.py`. | `__init__.py`, `exceptions.py`, `constants.py` | Lazy public imports, package logging, exception hierarchy and SI constants | package / P | | `grid.py`, `fourier.py`, `hankel.py`, `spatial.py`, `adiabatic.py` | Temporal and spatial grids (radial, the Cartesian plane, and `LineGrid`, its x axis uniform in y per unit width), Fourier conventions, raw Hankel transforms, spatial media, the axial-flux measure of a plane (`SpatialFlux`, SEP 34) and the adiabatic path certificate of a z-varying medium | P | | `profiles.py`, `gasflow.py`, `pulses.py`, `noise.py`, `polarisation.py`, `optics.py`, `tools.py` | Longitudinal paths, steady gas flow in a hollow fibre (the taper- and temperature-aware, choked, regime-checked fill, its flow report and calculators), temporal/spatial inputs, noise, polarisation algebra, linear optical elements as transfer functions, and the soliton and phase-matching design calculators | P | -| `coupling/` | Coupling simulations through a coherent field at a reference plane (SEP 31): the field plane with its reference, geometry, metric, basis identity and provenance (`plane.py`), the input port of a receiving simulation and its bound launch with the same-basis model (`port.py`), and temporal remapping (`remap.py`); NumPy and other P modules only. The transport core (`transport.py`, `elements.py`) is planned | P | +| `coupling/` | Coupling simulations through a coherent field at a reference plane (SEP 31): the field plane with its reference, geometry, metric, basis identity and provenance (`plane.py`), the input port of a receiving simulation and its bound launch (`port.py`), temporal remapping (`remap.py`), the coupling models, their scalar mode profiles and the recorded pair envelopes (`models.py`, `envelopes.py`), `couple` with its admission budget and report (`coupler.py`), and the synthesis of a guide's exit field (`outcouple.py`); NumPy, SciPy and other P modules only. The transport core (`transport.py`, `elements.py`) is planned | P | | `physdata/` | Species, mixtures, equations of state, transport properties (viscosity, sound speed, mean free path), optical material data, metals, nonlinear and Raman data | P | | `modes/` | Mode protocols and derived quantities; capillary, antiresonant, tabulated, step-index, rectangular and planar (slab, uniform in y, per unit width) modes, with `PlanarGrid` for the planar guide resolved in y; shared gas fill, quadrature and collections; full-vector modal records, material laws and regions, bound step-index and radial graded-index providers, and the guide that pairs a provider with a mode collection, and the connection of a basis that moves along z | P | | `linear.py`, `boundaries.py`, `nonlinear/` | Linear assembly, absorbing rates, response definitions, ionisation, Raman, normalisation and the GNLSE mapping | P | diff --git a/docs/dev/seps/sep-0031-simulation-coupling.md b/docs/dev/seps/sep-0031-simulation-coupling.md index e309f613f..ea37bbe09 100644 --- a/docs/dev/seps/sep-0031-simulation-coupling.md +++ b/docs/dev/seps/sep-0031-simulation-coupling.md @@ -1395,6 +1395,219 @@ are refused. polarisation. The names belong at the marked comment in `coupling/__init__.py`, and the dashed boxes in `docs/dev/figures/build.py` turn solid when the files land. +### Phase 3 report — coupling + +**Delivered (2026-10-24, `feature/sep-0031-simulation-coupling`).** + +- `src/solprop/coupling/models.py` (P): `CouplingModel(kind, model_error=, basis_change=)`, + `BasisChange`, `cone_angle`, `radial_rule` and `ScalarModeBasis` — the scalar profiles + of an ordinary mode set: capillary HE1m as $J_0(u_mr/a)$ inside the core and zero + outside, and a step-index fibre's HE1m as weak-guidance LP0m ($J_0(Ur/a)$, + $J_0(U)K_0(Wr/a)/K_0(W)$, from the pole-free form of + $UJ_1(U)/J_0(U) = WK_1(W)/K_0(W)$), frozen at the mode's declared profile wavelength as + the run's profile is; closed-form normalisations and closed-form order-zero Hankel + transforms (the Lommel integral inside, the $K_0J_0$ antiderivative outside, each + checked against direct quadrature to 1e-15 relative), the exact whole-plane Gram and + the Gram of the angular content inside a cone. A mode-averaged GNLSE fibre is + unwrapped to the waveguide its dispersion forwards to. +- `src/solprop/coupling/coupler.py` (P): `couple(plane, port, *, model)` → + `CouplingResult(launch, report)`; `CouplingReport` (Displayed) and `AdmissionItem`; + `target_columns`; `PASSIVITY_ROUNDING`. +- `src/solprop/coupling/envelopes.py` (P): `PairEnvelope`, `CAPILLARY_ENVELOPE`, + `STEP_INDEX_ENVELOPE`; their oracles are `tests/coupling/test_envelopes.py`. +- `src/solprop/coupling/outcouple.py` (P): `Outcouple(space, interface="matched", + medium=|index=)` and `OutcoupleResult(plane, modal, synthesised, window)`. +- `coupling/plane.py`: `FieldPlane.from_analytic(grid, geometry, field, *, space, + medium=|index=, support=, exterior=, reference=)` with `AnalyticRecord(support, + domain_change, resolution_change)`; `FieldPlane.profiles` and `.analytic`; + `from_pulse(..., index=)`; `from_samples(..., profiles=)`; `PlaneMetric.paraxial`. +- `coupling/port.py`: `InputPort.profiles` and `.contrast`; the public `same_basis` is + removed (folded into `couple`); `check_same_medium` and `occupied_outside` are public + helpers of the module. +- `translate/coupling.py`: `input_port(..., modes=, guide=)` fills the scalar profiles, + the core's `reference_index` (the core gas of a capillary from + `MarcatiliMode.core_index_minus_one`, a fibre's core), the step-index contrast, and for + a general guide its contrast (`guide_contrast`) and admitted models. +- `processing/planes.py`: `field_plane(run, source=simulation)` attaches an ordinary + modal plane's profiles, checked by configuration hash. +- Driver: `from_waveguide`/`prop_waveguide(..., incident=, coupling=)`; + `_incident_launch` builds a radial plane of the beam in the core index and calls + `couple`, installing the launch by `with_input` (so `configuration["coupling"]` is + recorded) with the report as `configuration["launch"]`. `IncidentLaunch` is removed. +- Docs: the coupling chapter (models, budget and report, a Gaussian into a capillary, a + radius change by direct overlap and through `Outcouple`, a solid-fibre overlap, the + vector entry); general-modal, solid-fibres, supplied-guides and interior-launch pages; + architecture §3 and §4 and the structure figure; the API index; the changelog. + `benchmarks/guides.py` declares its model. + +**Admission (§4.3) as implemented.** Each item is a relative energy discrepancy with a +scope (`field`, `map`, `model`); the counted items must sum to `model_error`, and the +items the SEP caps at $\epsilon_m/8$ are capped individually. + +- *Angular.* $1-\cos\theta_* = \epsilon_m/8$, $\kappa_* = n_{\rm ref}(\omega/c) + \sin\theta_*$. Source: a spatial plane's column energies (cone vs tail by $|\kappa|$), + a modal plane's coefficients against its profiles' tail Gram; energy-weighted over + bins. Target: the largest generalised eigenvalue of + $(\epsilon_m/8)K + (1-\epsilon_m/8)T$ against the channel Gram $K$, maximised over + occupied bins (scalar: closed-form transforms; general guide: node Fourier transform + of the channels' $\mathbf e_t$ on a polar rule inside the cone, at up to five bins). +- *Contrast.* Scalar step-index pairs: $\Delta$ over both fibres' core and cladding + against the target core; general guides: every region's law and a radial law sampled + at 33 radii. Not applied to a capillary (item present, value `None`). A step-index pair + whose $\Delta > \epsilon_m/8$ is admitted if `STEP_INDEX_ENVELOPE` covers it (the + item notes the replacement); the envelope's bound is always counted. +- *Metric defects.* Target: $G = \operatorname{diag}(n_{\rm ref}/\operatorname{Re}n_{\rm + eff})$ (times the Jones Gram) against $Q = I$; vector: the whitened extremes of $F$ + at the port's nodes against the run's declared $Q$. Source: $\sum|M_0 - Q_A|/\sum Q_A$ + over bins. +- *Closure.* $\delta^2 = \lambda_{\max}$ of the Gram of $\mathbf h_t - Y_0\hat z\times + \mathbf e_t$ against that of $Y_0\hat z\times\mathbf e_t$, energy $2\delta+\delta^2$. +- *Passivity.* Largest whitened squared singular value of the map, $C Q_A^{-1}C^\dagger$ + against $Q_B$, over occupied bins and propagating columns; excess $\le 10^{-10}$ is + rounding, up to the model's own metric, angular and closure items a reported model + defect, beyond it a refusal. Never rescaled or clipped. +- *Numerical.* A spatial source's content in the outer fifth of its spectral window, an + analytic plane's domain and resolution checks, the modal overlap quadrature (rule + against its halving, plus each Gram against the exact one). +- *Report.* Incident energy in $M_0$ over the whole plane; launched energy in the native + $Q$; captured $\mathbf a^\dagger G\mathbf a$ and uncaptured (unclipped) only for + `scalar_paraxial`; the source's SEP 34 certificate carried separately; a zero field + is a zero launch with `efficiency` `None`. + +**Envelopes.** + +- Capillary (`capillary-he1m-scalar-vs-marcatili-2026-10`): the scalar shares from + sampled Cartesian planes against `MarcatiliMode.field` with its `normalisation` on an + independent polar Gauss–Legendre rule; waists 0.3a–1.0a, displacement to 0.2a, tilt to + 1 mrad, 75 µm argon capillary at 800 nm with eight HE1m modes, and radius ratios + 0.5–2. Largest difference 1.8e-14 of the incident energy (the reference quadrature's + rounding: the Marcatili HE1m field *is* the scalar profile); recorded bound 1e-12, + numerical 1e-13; range HE1m count 1–8, radius ratio 0.5–2. +- Step index (`step-index-lp01-vs-vector-he11-2026-10`): LP01 overlap squared against + $|F_{AB}|^2$ of the exact unit-power HE11 even fields + (`StepIndexMode.electromagnetic_field`); core 1.45, contrast 0.001–0.01, V 1.5–2.4, + radius ratio 0.7–1.43 at 1064 nm (constant indices: the overlaps depend on V alone). + Largest difference 2.92e-4, at contrast 0.01, V 2.4 and ratio 0.7; the reference's + refinement changes it by 7e-16. Recorded bound 3.0e-4, numerical 1e-12; LP01 only. + +**Deviations and why.** + +1. *The least-squares Gram is the exact one.* The fit uses the profiles' exact Gram + (orthonormal, times the Jones Gram), not their transforms' Gram truncated to a plane's + window: for a field the plane resolves the overlaps are exact by Parseval whatever of + a profile lies beyond the window, so the coefficients are exact and the + orthogonal-residual identity holds in the continuum. With the truncated Gram the + capillary shares were biased by up to 7e-5 at 128² nodes. +2. *Weak-guidance step-index is coupled on the ordinary (GNLSE) route*, through the + waveguide its mode-averaged fibre wraps, and only fibre → fibre: no envelope covers a + beam into a fibre or a capillary ↔ fibre pair, so those are refused. +3. *The vector model has no recorded pair envelope.* The brief lists envelopes for the + capillary and the solid scalar pair only; `vector_paraxial` reports an `envelope` + item with value `None` and the reason, and relies on the contrast screen, the closure + and the other items (open question 1). +4. *The vector passivity certificate covers the coordinates the field occupies* + (scope `field`): a spatial plane's high-$\kappa$ columns are aliased on a guide's node + rule and would show a spurious gain (0.38 on the test strand) through columns the + field does not occupy. +5. *The driver's incident path builds the simulation twice.* It constructs the run with a + zero input, takes its `input_port()`, couples, and installs the launch by + `with_input` (sharing the translation); `configuration["pulse"]` and `["launch"]` are + caller keys of that derived run, and `["coupling"]` is recorded. +6. *`from_pulse(index=)` keeps phase 1's measure.* With a reference index and no + `SpatialMedium` (a guide's core), the beam is `SpatialPulse.incident` sampled on the + grid, normalised in $M_0$; with a medium it is `build`, normalised in the axial flux. + The difference is the beam's obliquity, reported as the source-metric item. +7. *Incident test guides changed.* The 1.45-in-air strand fails the contrast screen + ($\Delta = 0.26$); the phase-1 incident tests now use a 4 µm core at a 0.01 step under + `model_error=0.2`. +8. *Runtime `Literal` membership checks are not repeated* (`CouplingModel.kind`, + `Outcouple.interface`), as in phase 2: the test session's runtime type checks reject + them first, so the checks would be unreachable under coverage. +9. *`Outcouple` returns a `FieldPlane`, not a transport `Element`.* The transport core + works on `SpatialState`, which the parallel transport branch is wiring to + `FieldPlane`; `Outcouple` lives in its own module and is not exported from the + transport section of `coupling/__init__.py`. +10. *The module is `coupler.py`, not `couple.py`.* The package exports the function + `couple`, which would shadow a submodule of the same name: the API reference could + not document both, and `solprop.coupling.couple` would mean two things. +11. *No schema change.* Neither the kernel schema (35) nor the output schema (24) moved; + the report enters the configuration record as a dataclass. + +**Verification run** (targeted, `uv run --no-sync pytest -n auto`; none fail except the +three pre-existing structure-figure tests named below): + +- `tests/coupling/test_couple.py` (22), `test_envelopes.py` (10, medium included), + `test_port.py`, `test_plane.py`, `test_remap.py`, `tests/processing/test_planes.py`, + `tests/driver/test_coupling_relaunch.py` (medium included), `tests/driver/ + test_general_modal.py` (incident tests on all three CPU receivers), + `tests/test_display.py`, `tests/test_pulses.py`, `tests/test_benchmark_guides.py`: + all pass (869 + 9 in the last combined runs); doctests of the coupling modules pass. +- Evidence: §4.4 Gaussian formulas (waist, displacement, tilt, curvature) against a + direct 2-D sum to 1e-10 and against two sampled planes' spectral overlap to 1e-9; + Gaussian–Bessel shares against a 400-node Gauss–Legendre integral to 1e-11 for + $w/a$ = 0.3, 0.5, 0.7, 1.0 (0.906 and 0.092 at 0.5); nested sets of 2, 4, 6 modes share + their common shares to 1e-12, and doubling the window or the nodes moves them by + 1e-12; a 4a beam launches < 15 % with the uncaptured energy exactly incident minus + captured; crossed polarisation and zero fields are exact zero launches; a rotated + degenerate pair splits evenly with the same total to 1e-12; coherent two-mode sources + add as fields and a gauge flip launches identically to 1e-13; a declared unitary basis + change preserves energy to 1e-13 and a 1 % stretch is refused; a capillary's exit into + a wider one by direct overlap and through `Outcouple` (radial 512 nodes, Cartesian + 128²) agree to 10× the synthesis window's representation error (measured 1.3e-8 and + 8e-7 relative); the synthesised plane carries the modal energy to 7e-8 (the matched + exit's obliquity); a step-index pair's captured share is the LP01 overlap squared to + 1e-12. +- ruff, ruff format, basedpyright (0 errors), numpydoc and codespell are clean on the + changed files. The coupling page's snippets run by hand in 5.5 s, the solid-fibres + page's in 3.4 s (a 12 µm beam into the 25 µm graded-index guide launches 0.964 within + a 0.032 budget). +- *Failing, pre-existing:* `tests/test_structure_figures.py` — + `test_every_planned_module_is_still_unwritten[coupling/transport.py]` and + `[coupling/elements.py]`, and `test_the_layers_figure_draws_the_whole_package`: the + merged transport core's modules are still drawn as planned. This branch added its own + four modules to the figure and redrew it; the transport branch owns turning its boxes + solid. + +Not run: the full gate, slow and coverage lanes (end of SEP), GPU. The guide snippets +were compiled only in the default lane and run by hand. + +**Sources checked.** The LP0m eigenvalue equation and field (Gloge, *Appl. Opt.* **10**, +2252 (1971), eq. 15 with l = 0; Snyder and Love, *Optical Waveguide Theory*, §14-3) were +re-derived and checked numerically (U = 1.528 at V = 2); the Lommel and $K_0J_0$ +integrals were derived and checked against quadrature; the capillary normalisation +against `MarcatiliMode.normalisation`. Not checked: Gloge's equation numbering against the +printed paper. + +**Limitations.** The vector model's omitted facet reflection is bounded only through the +contrast screen and the closure. The general guide's angular item samples at most five +bins. The scalar profiles of a step-index fibre are its frozen-wavelength profile, as the +run's are; a fibre whose profile moves strongly across the band is represented as the run +represents it. `Outcouple` offers only the matched interface. Ports of general guides that +move (a sampled path) carry no E/H, so they admit only `same_basis`. + +**Open questions.** + +1. Should `vector_paraxial` require a recorded provider envelope (for example + `StepIndexProvider` against an independent mode-matching calculation), as §4.3's + "each provider/model pair" reads, or is the contrast screen with the closure enough? +2. Small-core weakly guiding fibres need budgets of about 0.1 because the LP01 field's + angular spread is a tenth of a radian; is the §4.6 cone rule, designed for thin + screens, the intended angular bound for a direct modal overlap? +3. Should a beam into a weakly guiding fibre (Gaussian → LP01) be admitted with its own + envelope? The brief admits one solid pair only. +4. Should the target-subspace angular item of a general guide be evaluated at every + occupied bin rather than five? +5. Should `Outcouple` become a transport `Element` once the transport core takes + `FieldPlane`, and should it offer a declared planar-interface model itself? + +**Handoff to phases 4 and 5.** Merge with the transport branch: `coupling/__init__.py` +exports both sets; the structure figure's coupling box lists both sets of modules +(regenerate `layers.svg` after the merge); the coupling chapter's transport section and +this page's coupling sections are independent. Phase 5's tutorial can use +`field_plane` → `couple(..., scalar_paraxial)` for the +radial entrance field into the capillary; its $s_{11}$, $s_{12}$ come from the launch and +the report as the tests compute them. + ## Part III — History - 2026-10-22: Drafted after auditing existing launch, projection, spatial flux, diff --git a/docs/guide/results/interior-launch.md b/docs/guide/results/interior-launch.md index 48275815b..6cb0a50b8 100644 --- a/docs/guide/results/interior-launch.md +++ b/docs/guide/results/interior-launch.md @@ -40,9 +40,10 @@ responses, this `spectrum` and `initial_z=z_start`; include the parent hash and index in its `configuration` provenance and write a new output file. The same continuation can be made through plane exchange, which checks the basis and -records the provenance for you: `same_basis(field_plane(parent_output, plane_index), -simulation.input_port(z_start))` is a launch that `simulation.with_input` accepts, at -`z_start`, with the parent named in `configuration["coupling"]` +records the provenance for you: `couple(field_plane(parent_output, plane_index), +simulation.input_port(z_start), model=CouplingModel("same_basis")).launch` is a launch +that `simulation.with_input` accepts, at `z_start`, with the parent named in +`configuration["coupling"]` ([coupling simulations](../workflows/coupling.md#relaunching-into-the-same-basis)). For a TDSE response, `SimulationSettings.tdse_execution` explicitly selects the diff --git a/docs/guide/waveguides/general-modal.md b/docs/guide/waveguides/general-modal.md index 823099ea3..68d85be6f 100644 --- a/docs/guide/waveguides/general-modal.md +++ b/docs/guide/waveguides/general-modal.md @@ -116,75 +116,61 @@ The launched energy is the pulse's, to the last bit. **`incident=` launches an incident beam instead.** Where `channel=` names one channel, `incident=` takes a {class}`~solprop.pulses.SpatialPulse` — a Gaussian or super-Gaussian beam carrying its own temporal pulse — and launches what the guide's channels capture of -it ([SEP 31](../../dev/seps/sep-0031-simulation-coupling.md), phase 1): +it, coupled under a declared model +([SEP 31](../../dev/seps/sep-0031-simulation-coupling.md); +[coupling simulations](../workflows/coupling.md)): ```text -beam = SpatialPulse(GaussianPulse(wavelength=835e-9, fwhm=20e-15, energy=1e-9), 2e-6) -simulation = Simulation.from_waveguide((), length, provider=guide, lambda0=835e-9, - grid=grid, incident=beam) -simulation.configuration["launch"] # incident, captured and uncaptured energy +beam = SpatialPulse(GaussianPulse(wavelength=835e-9, fwhm=20e-15, energy=1e-9), 3e-6) +simulation = Simulation.from_waveguide( + (), length, provider=guide, lambda0=835e-9, grid=grid, incident=beam, + coupling=CouplingModel("vector_paraxial", model_error=0.2), +) +simulation.configuration["launch"] # the CouplingReport: incident, launched, budget ``` -The beam's `energy=` (or `peak_power=`) is the **physical energy crossing the entrance -plane in the guide's core medium, over the whole beam**: what an energy meter in that -medium would read. It is fixed in the paraxial measure $M_0 = Y_0\,\mathrm dA/2$ with -$Y_0 = n_{\rm ref}(\omega)\varepsilon_0 c$, $n_{\rm ref}$ the real index of the core at -each frequency, integrated over the beam's own plane in closed form +The beam's `energy=` (or `peak_power=`) is met **over the whole beam** in the paraxial +measure $M_0 = Y_0\,\mathrm dA/2$ of the guide's core medium, $Y_0 = n_{\rm ref}(\omega) +\varepsilon_0 c$ with $n_{\rm ref}$ the real index of the core at each frequency, +integrated over the beam's own plane in closed form ({meth}`~solprop.pulses.SpatialPulse.incident`) — never over the guide's quadrature -nodes, which cover only the guide. The correctly scaled field is then sampled at the -nodes and projected onto every channel it overlaps. - -The map is the record's **coordinate dual**, $\mathbf a = W^{-1}\mathbf B$ with -$B_m = \int(\mathbf E\times\mathbf h_m^* + \mathbf e_m^*\times\mathbf H)_z\,\mathrm dA/4$: -the same Hermitian Poynting pairing the flux metric is built from, so synthesising a -declared amplitude vector and projecting it back returns that vector exactly. It is *not* -$Q$, the record's other map ([modal projection](modal-projection.md#the-synthesis-and-projection-maps)), which contracts -a *polarisation* for a **source** term and carries the separate $-i\omega$; the two are -different operations and give different numbers. The same map is public as -{py:func}`~solprop.translate.modal_tables.project_transverse_field`, for a field you -sample yourself. - -Only $\mathbf E$ is declared, so the $\mathbf H$ half of $B_m$ is closed by the -**forward-field assumption**: for any superposition of these channels travelling forward -the two halves are equal, so $B_m = 2\int(\mathbf E\times\mathbf h_m^*)_z\,\mathrm dA/4$. -Measured on the step-index parity pair, a field synthesised from known amplitudes returns -through this map to $1.4\times10^{-15}$ of them — rounding, not the basis's Gram defect — -so the closure is exact for this package's providers. It is exact for a **lossless** -basis: the argument is that the Poynting cross matrix of such a basis is Hermitian, so -the $\mathbf E$ and $\mathbf H$ halves of $B_m$ contribute equally. A complex -{py:class}`~solprop.modes.material.PermittivityLaw` would break that symmetry, and no -such basis can reach this map: both providers here refuse a complex permittivity when -they solve, by name. An optmat index is never -complex here: {py:class}`~solprop.modes.material.IndexMaterial` and the step-index -modes hold it as its recorded transparent approximation, warning once per run where -the absorption it drops exceeds its bound -([materials](../media/material-selection.md#materials-from-optmat)). A region's -`SilicaTensorResponse()` is optmat's tensor silica bundle by default, which agrees with -Lin and Agrawal's own parameters to $4\times10^{-16}$. A future lossy provider -would have to re-derive the closure rather than inherit it. Whether an incident beam -has the guide's magnetic field is a separate question: the closure is exact on the -channels' own span, and the beam's part outside that span is not captured. - -**The launch is the captured amplitudes, unscaled.** Light the channels do not capture +nodes, which cover only the guide. The beam becomes a radial +{py:class}`~solprop.coupling.plane.FieldPlane` that resolves it +({py:meth}`~solprop.coupling.plane.FieldPlane.from_pulse` with the core's index), and +{py:func}`~solprop.coupling.coupler.couple` recovers its coordinates in the channels. + +The map is the flux pairing, $\mathbf a = F^{-1}\mathbf B$ with +$B_m = \int(\mathbf E\times\mathbf h_m^* + \mathbf e_m^*\times\mathbf H)_z\,\mathrm dA/4$ +and $F$ the same pairing of the channels with each other, on the guide's nodes. The +beam's $\mathbf E$ is its field at the nodes; its $\mathbf H$ is the explicit forward +closure $\mathbf H_t = Y_0\hat{\mathbf z}\times\mathbf E_t$ of a paraxial beam in the +core. That closure is not exact for a guided field -- a channel's own $\mathbf h_t$ is +$n_{\rm eff}\varepsilon_0c\,\hat{\mathbf z}\times\mathbf e_t$ to leading order, with +corrections where the index changes -- so the model admits a guide only where it is +small: the coupling's admission budget (`model_error`) bounds the closure's error +against the provider's own $\mathbf H$, the weak-guidance contrast of the guide's +regions, the angular content of the beam and of the channels, and the metric defect of +$F$ against the run's declared metric. A strongly guiding core, such as a silica strand +in air, fails the contrast screen and is refused by name. It is *not* $Q$, the record's +other map ([modal projection](modal-projection.md#the-synthesis-and-projection-maps)), +which contracts a *polarisation* for a **source** term. For a field you sample yourself, +{py:func}`~solprop.translate.modal_tables.project_transverse_field` applies the +coordinate dual of the channels' own span, whose E-only closure is exact for a forward +superposition of the channels. + +**The launch is the recovered amplitudes, unscaled.** Light the channels do not capture is lost at the entrance, not restored: a beam much wider than the core launches a small share of its energy, and a beam the channels cannot see at all — a y-polarised beam against a single x-polarised channel — launches nothing, which is a valid zero launch. -The account is recorded as `configuration["launch"]`, an -{class}`~solprop.driver.simulation.IncidentLaunch`, and reaches the file's configuration: -the **incident** energy per frequency bin, the **captured** energy (the launched state's -physical energy under the run's own declared metric, which is what the run's -`physical_energy` statistic reads at the entrance), and their difference, the -**uncaptured** energy, with totals. The captured energy is logged at INFO. No warning is -raised for a poor capture: it is physics, not a defect. - -Read the uncaptured energy with its model in mind. It is a physical loss only where the -incident measure and the guide's own flux metric are a common positive metric -(SEP 31 §4.3) — a weakly guiding guide, whose channels see an admittance close to -$n_{\rm ref}\varepsilon_0 c$ wherever they carry power. On a strongly guiding strand the -difference also carries the two measures' disagreement, and it is reported unclipped: a -negative value would be an apparent gain of the model, never a negative loss. The -projection's own `residual_share` is an electric-field reconstruction error in the -guide's quadrature norm, not radiated power, and is not this account. +The coupling's {class}`~solprop.coupling.coupler.CouplingReport` is recorded as +`configuration["launch"]` and reaches the file's configuration: the **incident** energy +per frequency bin, the **launched** energy (the state's physical energy under the run's +own declared metric, which is what the run's `physical_energy` statistic reads at the +entrance), the admission items and the budget; the launch's provenance is +`configuration["coupling"]`. The vector model names no uncaptured energy: $F$ and the +incident measure are not one positive metric, so their difference is not a physical +loss, and the report says so rather than inventing one. No warning is raised for a poor +capture: it is physics, not a defect. The beam is launched at its waist (`propz=0`; otherwise it is refused, because carrying a beam to the entrance is transport, which this launch does not model) and centred on the @@ -369,7 +355,7 @@ it. | the guide: a provider, its material regions and their responses, solved on its own node rule at knots spanning the run's **active** band | `ModalGuide.from_provider(provider, omega, nodes=...)` | [running one](#running-one), [the three objects](modal-projection.md#the-three-objects) | | each region's response: the instantaneous cubic by `chi3=`, or by `n2=` with the wavelength `at=` it was measured at, and the silica tensor Raman model beside it; or a gas fill, whose law the run's electronic policy sets | `MaterialRegion(..., (MaterialKerr(...), SilicaTensorResponse(...)))`, `MaterialRegion(..., (GasKerr(mixture),))` | [the response at a node](modal-projection.md#the-response-at-a-node) | | the cubic's form | `kerr_form="real"` (the default, third harmonic included) or `"analytic"` | [the response at a node](modal-projection.md#the-response-at-a-node) | -| the launch | `channel=` (an index or a label) or `incident=` (a `SpatialPulse`, of which the captured share enters) | [running one](#running-one) | +| the launch | `channel=` (an index or a label) or `incident=` (a `SpatialPulse` coupled under `coupling=`, of which the captured share enters) | [running one](#running-one) | | a unitary change of basis — of an exactly degenerate block, which stays diagonal, or of a split one, which becomes a coupled flow | `basis=` and `degenerate_blocks=` on `from_provider` | [declaring a circular basis](modal-bases.md#declaring-a-circular-basis), [a split block](modal-bases.md#a-declared-basis-of-a-block-that-is-only-nearly-degenerate) | | the statistics | `physical_energy` and `analytic_flux` (the defaults), `spectral_flux` by name | [noise, statistics and what comes back](#noise-statistics-and-what-comes-back) | @@ -496,7 +482,7 @@ Not implemented, and refused rather than approximated: for — over the table's frequency interpolation, the transverse rule and the step — is not implemented, and accepting a number nothing enforces would be a silent claim about accuracy; -* a **backward or evanescent** declared launch. `incident=` (above) projects the +* a **backward or evanescent** declared launch. `incident=` (above) recovers the incident field through the flux pairing with the forward-field closure, and nothing in the arithmetic can see that a declared field was not forward-propagating; such a launch is built by hand and passed to diff --git a/docs/guide/waveguides/solid-fibres.md b/docs/guide/waveguides/solid-fibres.md index f8afb28ff..a1e63221d 100644 --- a/docs/guide/waveguides/solid-fibres.md +++ b/docs/guide/waveguides/solid-fibres.md @@ -248,15 +248,17 @@ radius. A larger core or contrast, or a grid that reaches less far into the infr admits more. **A launch declared as an incident beam.** `incident=` takes a -{class}`~solprop.pulses.SpatialPulse`, whose energy is the physical energy over the whole -beam at the entrance, in the core medium. The beam is normalised over its own plane, -sampled at the guide's nodes and projected onto every channel through the Poynting -pairing; the channels launch what they capture, and the rest is lost at the entrance -rather than restored. The account, incident, captured and uncaptured energy per -frequency, is recorded in the run's configuration -({class}`~solprop.driver.simulation.IncidentLaunch`). +{class}`~solprop.pulses.SpatialPulse`, whose energy is met over the whole beam at the +entrance, in the core medium, and `coupling=` the model it is coupled under +([coupling simulations](../workflows/coupling.md)). The beam's coordinates in every +channel are recovered through the Poynting pairing; the channels launch what they +capture, and the rest is lost at the entrance rather than restored. The coupling's +report -- incident and launched energy per frequency and its admission budget -- is +recorded in the run's configuration +({class}`~solprop.coupling.coupler.CouplingReport`). ```python +from solprop.coupling import CouplingModel from solprop.pulses import GaussianPulse, SpatialPulse beam = SpatialPulse( # an x-polarised Gaussian, 12 um waist, at its waist @@ -268,6 +270,7 @@ grin_run = prop_waveguide( provider=grin_guide, grid=grin_grid, incident=beam, + coupling=CouplingModel("vector_paraxial", model_error=0.2), lambda0=1.03e-6, n_save=3, dz_max=1e-4, @@ -275,13 +278,13 @@ grin_run = prop_waveguide( progress="none", ) launch = grin_run.metadata.configuration["launch"] -print(f"captured {launch.captured_energy / launch.incident_energy:.3f} of the beam") +print(f"launched {launch.efficiency:.3f} of the beam within {launch.budget:.3f}") ``` -The captured share is a physical loss in a common positive metric, which a weakly -guiding fibre like this one approaches ([general modal propagation](general-modal.md)). -The projected amplitudes are launched unscaled, so the run's `physical_energy` at the -entrance is the captured energy, not `energy=`. +The graded-index core's contrast, 0.005, is below the budget's eighth, as the vector +model requires ([general modal propagation](general-modal.md)). The recovered amplitudes +are launched unscaled, so the run's `physical_energy` at the entrance is the launched +energy, not `energy=`. The [graded-index worked example](../../auto_examples/waveguides/plot_04_graded_index_multimode.rst) runs a 100 µm core at full power over ten centimetres: self-imaging, geometric diff --git a/docs/guide/waveguides/supplied-guides.md b/docs/guide/waveguides/supplied-guides.md index 71fcf2f21..3888dff91 100644 --- a/docs/guide/waveguides/supplied-guides.md +++ b/docs/guide/waveguides/supplied-guides.md @@ -118,8 +118,8 @@ wants. The real form needs `n_to >= 2 n_t`, which is `FieldGrid`'s default It takes a **graded-index** guide as readily as a step-index one, and launches either into a named channel with `channel=` or as an incident beam with -`incident=`, of which the channels launch the share they capture, the rest recorded as -uncaptured rather than restored. It runs on a **field** grid on `numpy_ref`, `numba_cpu` and the native +`incident=` coupled under `coupling=`, of which the channels launch the share they +capture, the rest lost rather than restored. It runs on a **field** grid on `numpy_ref`, `numba_cpu` and the native backend, CPU and GPU builds alike, for a constant guide and for a moving one or a declared coupled flow (a split block or a coherent wall); a CF4 wall runs on `numpy_ref` alone. `backend="auto"` selects `native` where it is installed. diff --git a/docs/guide/workflows/coupling.md b/docs/guide/workflows/coupling.md index 3ff926648..efa1b6e41 100644 --- a/docs/guide/workflows/coupling.md +++ b/docs/guide/workflows/coupling.md @@ -6,10 +6,10 @@ modal coherence and the frequency dependence of its transverse profile; carrying array leaves its basis, units, frame and energy meaning implicit. {py:mod}`solprop.coupling` connects simulations through a **coherent field at a reference plane** instead ([SEP 31](../../dev/seps/sep-0031-simulation-coupling.md)). -This page covers the first part of that design: reading a saved plane as a field -plane, moving it between temporal grids, and relaunching it into a simulation of the -same basis. Coupling between different guides, incident beams and the optical -transport between planes follow in later releases of this chapter. +This page covers reading a saved plane as a field plane, moving it between temporal +grids, and coupling it into another simulation: a relaunch into the same basis, a +beam or a guide's exit into a capillary or another fibre, and a guide's exit field +synthesised on a spatial grid. ## A plane, and what it means @@ -119,22 +119,24 @@ delay moves the record earlier in its window, with the absolute field unchanged. A receiving simulation describes its entrance with {py:meth}`~solprop.driver.simulation.Simulation.input_port`: the route, the temporal lattice and basis with their identities, the entrance plane and the frame accumulated there, the -native metric at the entrance and the models it admits. The identity model, -{py:func}`~solprop.coupling.port.same_basis`, launches a plane into a port of the **same** -basis on the same lattice, at an identically oriented plane in the same medium: the -coefficients are copied as they stand, and the plane's reference becomes the launched -run's origin. The launched run therefore continues the source's absolute time and -carrier phase without moving the pulse in its window. +native metric at the entrance, the reference index of its entrance medium and the +coupling models it admits. {py:func}`~solprop.coupling.coupler.couple` computes a launch +bound to that port under a declared {py:class}`~solprop.coupling.models.CouplingModel`. +The identity model, `same_basis`, launches a plane into a port of the **same** basis on +the same lattice, at an identically oriented plane in the same medium: the coefficients +are copied as they stand, and the plane's reference becomes the launched run's origin. +The launched run therefore continues the source's absolute time and carrier phase +without moving the pulse in its window. ```python -from solprop.coupling import same_basis +from solprop.coupling import CouplingModel, couple second = Simulation.from_capillary( 100e-6, 1e-3, "Ar", 1.0, lambda0=800e-9, wavelength_limits=(400e-9, 1.6e-6), trange=200e-15, fwhm=20e-15, energy=20e-6, modes=2, n_save=3, noise=None, ) -launch = same_basis(field_plane(run), second.input_port()) -continued = second.with_input(launch).run(progress="none") +relaunch = couple(field_plane(run), second.input_port(), model=CouplingModel("same_basis")) +continued = second.with_input(relaunch.launch).run(progress="none") print(field_plane(continued).reference.delay) # the whole 2 mm path's ``` @@ -147,12 +149,199 @@ and the entrance of an identical one of another length are the same basis, and a different radius is not. A simulation can also continue from inside its own path: `simulation.input_port(z)` is an interior entrance of a modal run. +`same_basis` also takes a declared invertible change of basis or gauge, +`CouplingModel("same_basis", basis_change=BasisChange(matrix, source, target))`, which +must carry the plane's metric onto the port's -- it relabels the same field -- and, for +a general modal plane read with its simulation, recovers the coordinates in a port of +the same guide with the flux pairing, $\mathbf a = F^{-1}\mathbf B$, refusing a field +that is not in the port's span. + {py:meth}`~solprop.driver.simulation.Simulation.with_input` refuses a launch computed for any other port -- another basis, lattice, entrance, orientation or medium -- whatever the shapes of the arrays. It records the coupling in `configuration["coupling"]` (the model, the port, the inherited origin and the source plane's provenance, so a run launched from a plane names its parent), and it draws no new noise: the launch carries the realised field of its source, and a realisation is added only when one is given explicitly. The receiving -run keeps its ionisation tables and their ranges, as every derived run does. A different -basis, lattice or medium is a coupling or an interface, not a relaunch, and is refused by -name. +run keeps its ionisation tables and their ranges, as every derived run does. + +## Coupling between different representations + +A different guide, radius or medium is not a relaunch. Three quantities must then be +kept apart ([SEP 31](../../dev/seps/sep-0031-simulation-coupling.md), section 4.3): the +Hermitian E/H flux pairing $F$ of a modal basis, the Gram $G = S^\dagger MS$ of a scalar +least-squares fit in a common measure $M$, and a representation's native power metric +$Q$ -- the identity for ordinary true-power coefficients, a dense Gram for a declared +general basis, the axial admittance for a spatial plane. They coincide only where a +model establishes it, and the uncaptured energy is a physical, nonnegative loss only +in a common positive metric. The models are: + +| model | what it admits | what it computes | +|---|---|---| +| `same_basis` | an identical basis, a declared basis change, a general modal plane into its own guide | the coordinates exactly; no budget | +| `scalar_paraxial` | a radial or Cartesian beam (Gaussian, analytic or sampled) into a capillary; a capillary's exit into another capillary of any radius and mode set; one weakly guiding step-index fibre into another | the fit $\mathbf a = G^{-1}S^\dagger M\mathbf f$ in $M_0 = Y_0\,dA/2$, $Y_0 = n_{\rm ref}\varepsilon_0c$, then $b = \sqrt{\operatorname{Re}n_{\rm eff}}\,a$ | +| `vector_paraxial` | a beam, or an ordinary guide's exit, into a general modal guide that carries its E/H at fixed nodes | $\mathbf a = F^{-1}\mathbf B$ with the incident E and the forward closure $\mathbf H_t = Y_0\hat z\times\mathbf E_t$ | + +Every other pair is refused by name. The reference index $n_{\rm ref}$ is the port's: +the core gas of a capillary, the core of a fibre. The scalar profiles are the run's own: +a capillary's $HE_{1m}$ modes are $J_0(u_mr/a)$ inside the core and zero outside, and a +step-index fibre's $HE_{1m}$ modes are taken in the weak-guidance limit as $LP_{0m}$, +$J_0(Ur/a)$ inside and $J_0(U)K_0(Wr/a)/K_0(W)$ outside, frozen at the mode's declared +profile wavelength as the run's profile is +({py:class}`~solprop.coupling.models.ScalarModeBasis`). A family with angular structure +(TE, TM, EH, $HE_{nm}$ with $n \ne 1$) has no scalar profile, and its port admits +`same_basis` alone. + +**Overlaps.** A spatial plane's columns are the Hankel or Fourier spectrum of its field, +so a profile's overlap is taken by Parseval in that same spectral quadrature, with the +profile's closed-form transform: the incident norm, the overlaps and the Gram all use one +measure, the orthogonal-residual identity +$\|f\|_M^2 = \mathbf a^\dagger G\mathbf a + \|f - S\mathbf a\|_M^2$ holds, and a +profile's kink at the core radius is never sampled. Two modal planes are overlapped on a +Gauss--Legendre rule broken at both core radii. + +### The admission budget and the report + +`model_error` is a required energy budget $\epsilon_m$ in (0, 1) for the two approximate +models: the relative energy discrepancy the approximation, its tails and its numerical +errors together may cause. It is not a coupling-efficiency floor -- a poorly captured +beam is physics -- and it is separate from numerical accuracy. A cone half-angle +follows from it, $1 - \cos\theta_* = \epsilon_m/8$, and each coupling is admitted against +these items, every one a relative energy discrepancy: + +| item | what it bounds | limit | +|---|---|---| +| `angular` | $[(1 - \cos\theta_*)U_{\rm cone} + U_{\rm tail}]/U$ of the incident field, and of the worst unit field in the retained target subspace | the total | +| `contrast` | the weak-guidance screen $\Delta = \max|n^2 - n_{\rm ref}^2|/(2n_{\rm ref}^2)$ of a solid guide; not applied to a capillary wall; a recorded pair envelope may replace it | $\epsilon_m/8$ | +| `target metric` | $G$ (or $F$) against the target's native $Q$, whitened | $\epsilon_m/8$ | +| `source metric` | the common measure $M_0$ against the source's native metric | $\epsilon_m/8$ | +| `closure` | $2\delta + \delta^2$ of the H-closure's field error $\delta$ (vector model) | $\epsilon_m/8$ | +| `passivity` | the whitened certificate $C^\dagger Q_BC \preceq Q_A$: an apparent gain within the model's own defects is reported here, never rescaled or clipped; a larger one refuses | the total | +| `envelope` | the recorded provider-pair envelope's bound | the total | +| `numerical` | the source's content at its spectral edge, an analytic field's domain and resolution checks, the overlap quadrature | the total | + +The counted items must sum to at most $\epsilon_m$. The +{py:class}`~solprop.coupling.coupler.CouplingReport` carries the incident energy over the +whole plane in $M_0$, the launched energy in the target's native metric, the captured +and uncaptured energy where a common positive metric exists (`None`, with the reason, +for the vector model), every item with its scope -- this field, the whole map, or the +model -- the source plane's exterior, and the source's SEP 34 path certificate, +separately and with its scope. A zero field is a valid zero launch whose `efficiency` is +`None`. The recorded envelopes ({py:mod}`solprop.coupling.envelopes`) are measured by the +test suite against independent references: the capillary's against the overlap of its +own vector Marcatili fields (a difference of 1.8e-14 of the incident energy over waists +0.3a--1.0a, displacements to 0.2a, tilts to 1 mrad and radius ratios 0.5--2), the +step-index pair's against the full-vector flux overlap of the exact $HE_{11}$ fields +(3.0e-4 over contrasts to 0.01, V from 1.5 to 2.4 and radius ratios 0.7--1.43). A pair +outside its envelope's range is refused, not extrapolated. + +### A Gaussian beam into a capillary + +A beam is a field plane: {py:meth}`~solprop.coupling.plane.FieldPlane.from_pulse` builds +one from the package's {py:class}`~solprop.pulses.SpatialPulse`, and +{py:meth}`~solprop.coupling.plane.FieldPlane.from_analytic` samples any analytic field, +a callable of the node positions and the absolute angular frequency returning the +optics-convention spectrum $\tilde E(\mathbf r,\omega)$ in V s/m, with a declared +support and exterior; it records how the plane's energy changes when the window is +doubled and when the spacing is halved, and a coupling counts both as numerical error. + +```python +import numpy as np + +from solprop.grid import RadialGrid +from solprop.pulses import GaussianPulse, SpatialPulse +from solprop.spatial import SpatialMedium +from solprop.coupling import FieldPlane, PlaneGeometry + +capillary = Simulation.from_capillary( + 75e-6, 1e-3, "Ar", 1.0, lambda0=800e-9, wavelength_limits=(400e-9, 1.6e-6), + trange=200e-15, fwhm=20e-15, energy=20e-6, modes=4, n_save=3, noise=None, +) +port = capillary.input_port() +beam = SpatialPulse(GaussianPulse(wavelength=800e-9, fwhm=20e-15, energy=10e-6), 37.5e-6) +incident = FieldPlane.from_pulse( + beam, port.grid, PlaneGeometry(0.0), space=RadialGrid(400e-6, 256), + medium=SpatialMedium("Ar", 1.0), +) +coupled = couple(incident, port, model=CouplingModel("scalar_paraxial", model_error=0.05)) +print(coupled.report) # incident, launched, uncaptured, the budget +entered = capillary.with_input(coupled.launch) +``` + +At $w/a = 0.5$ the fundamental takes about 0.906 of the incident energy and the next +radial mode 0.092. The launch carries what the modes capture: nothing is rescaled to +the requested energy. A displaced, tilted or curved beam is a Cartesian plane +(`space=FreeGrid(...)`); a radial plane cannot carry them. + +### A change of capillary radius + +A modal plane read with its simulation, `field_plane(run, source=simulation)`, carries +the run's scalar profiles; its coefficients are coupled into another capillary's port +by direct overlap, without a transverse grid: + +```python +exit_plane = field_plane(run, source=simulation) # 100 um, two modes +wider = Simulation.from_capillary( + 120e-6, 1e-3, "Ar", 1.0, lambda0=800e-9, wavelength_limits=(400e-9, 1.6e-6), + trange=200e-15, fwhm=20e-15, energy=20e-6, modes=4, n_save=3, noise=None, +) +into_wider = couple(exit_plane, wider.input_port(), + model=CouplingModel("scalar_paraxial", model_error=0.05)) +print(into_wider.report.efficiency) +``` + +The same result comes from synthesising the exit field and projecting it: +{py:class}`~solprop.coupling.outcouple.Outcouple` writes the modal field onto a radial or +Cartesian grid under an explicitly declared forward interface model. The one model is +`"matched"`, an exit into a medium of the core's own index where the transverse field is +continuous; a change of index is a separate planar interface, applied once, never an +implied Fresnel factor. + +```python +from solprop.coupling import Outcouple + +synthesised = Outcouple(RadialGrid(600e-6, 512)).apply(exit_plane) +through = couple(synthesised.plane, wider.input_port(), + model=CouplingModel("scalar_paraxial", model_error=0.05)) +difference = np.abs(through.launch.samples - into_wider.launch.samples) +print(np.max(difference) / np.max(np.abs(into_wider.launch.samples))) +``` + +The tests hold the two routes together to the synthesis window's numerical error. + +### A solid fibre overlap + +A step-index fibre's mode-averaged run carries its waveguide's profile, taken in the +weak-guidance limit; two fibres of one material pair and different radii are coupled by +the same direct overlap. The pair is admitted within the recorded envelope's range; +the contrast screen applies too, and the envelope stands in for it at a budget whose +eighth is below the contrast. Small cores are not very paraxial: the LP01 field of a +2 µm core spreads over a tenth of a radian, so such a pair needs a budget of about 0.1. + +```python +from solprop.modes import StepIndexMode + +def fibre(radius): + mode = StepIndexMode(radius, core=1.45, cladding=1.45 * np.sqrt(0.99), + profile_wavelength=1064e-9) + return Simulation.from_gnlse( + None, 1e-3, fibre=mode, n2=2.6e-20, lambda0=1064e-9, + wavelength_limits=(900e-9, 1300e-9), trange=400e-15, fwhm=100e-15, + energy=1e-12, n_save=2, + ) + +source_fibre, target_fibre = fibre(2.18e-6), fibre(2.57e-6) +fibre_run = source_fibre.run(progress="none") +spliced = couple(field_plane(fibre_run, source=source_fibre), target_fibre.input_port(), + model=CouplingModel("scalar_paraxial", model_error=0.1)) +print(spliced.report.efficiency) +``` + +### A beam into a general modal guide + +The vector model couples into a guide that solves its own E/H. Its entry point is +`Simulation.from_waveguide(provider=..., incident=SpatialPulse(...), +coupling=CouplingModel("vector_paraxial", model_error=...))`, which couples the beam +through `couple` and records the report as `configuration["launch"]` +([general modal propagation](../waveguides/general-modal.md)). A strongly guiding core +fails the contrast screen and is refused; the closure is qualified against the +provider's own H. From 9bd24ca862cf98942a71b044fc9fd7ecf79fed5f Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 02:16:41 +0100 Subject: [PATCH 22/50] SEP 31: plain wording in the phase reports Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019qNYqtCKh4iTKj7544yARb --- docs/dev/seps/sep-0031-simulation-coupling.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/dev/seps/sep-0031-simulation-coupling.md b/docs/dev/seps/sep-0031-simulation-coupling.md index 249b7cb68..e093c9eb6 100644 --- a/docs/dev/seps/sep-0031-simulation-coupling.md +++ b/docs/dev/seps/sep-0031-simulation-coupling.md @@ -1300,7 +1300,7 @@ route and the basis identity at each planned plane (launch plane first). recorded identity, so it gets an "unrecorded" digest that no port matches. 4. *`from_pulse` takes `space=`* beside the temporal `grid`, because the beam builder needs both. -5. *`same_basis` is a public function.* It is the minimal internal path the brief +5. *`same_basis` is a public function.* It is the minimal internal path the phase plan allows, needed by the guide page. Phase 3's `couple(..., CouplingModel("same_basis"))` should dispatch to it. 6. *Remap alias guard.* The direct sum is evaluated only on target bins within the source @@ -1501,7 +1501,7 @@ items the SEP caps at $\epsilon_m/8$ are capped individually. 2. *Weak-guidance step-index is coupled on the ordinary (GNLSE) route*, through the waveguide its mode-averaged fibre wraps, and only fibre → fibre: no envelope covers a beam into a fibre or a capillary ↔ fibre pair, so those are refused. -3. *The vector model has no recorded pair envelope.* The brief lists envelopes for the +3. *The vector model has no recorded pair envelope.* The maintainer-agreed scope lists envelopes for the capillary and the solid scalar pair only; `vector_paraxial` reports an `envelope` item with value `None` and the reason, and relies on the contrast screen, the closure and the other items (open question 1). @@ -1594,7 +1594,7 @@ move (a sampled path) carry no E/H, so they admit only `same_basis`. angular spread is a tenth of a radian; is the §4.6 cone rule, designed for thin screens, the intended angular bound for a direct modal overlap? 3. Should a beam into a weakly guiding fibre (Gaussian → LP01) be admitted with its own - envelope? The brief admits one solid pair only. + envelope? The agreed scope admits one solid pair only. 4. Should the target-subspace angular item of a general guide be evaluated at every occupied bin rather than five? 5. Should `Outcouple` become a transport `Element` once the transport core takes From 356e0225613dc2358041aa7950f88ee078f190f9 Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 02:29:23 +0100 Subject: [PATCH 23/50] Coupling: carry the rebuilt AdiabaticAdmission as a plane's path certificate field_plane now takes the certificate from flux_from_record's FluxPlan, so a FieldPlane, CouplingReport and TransportReport carry the frozen SEP 34 record instead of the raw output dict; the coupling report prints it. Also sorts the package's imports. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019qNYqtCKh4iTKj7544yARb --- src/solprop/coupling/__init__.py | 22 ++++++++-------- src/solprop/coupling/coupler.py | 23 +++++++++++------ src/solprop/coupling/plane.py | 16 +++++------- src/solprop/coupling/transport.py | 12 ++++----- src/solprop/processing/planes.py | 25 +++++++----------- tests/coupling/test_couple.py | 43 +++++++++++++++++++++++++++++-- tests/processing/test_planes.py | 30 ++++++++++++++++----- 7 files changed, 114 insertions(+), 57 deletions(-) diff --git a/src/solprop/coupling/__init__.py b/src/solprop/coupling/__init__.py index 959649f72..b865422e6 100644 --- a/src/solprop/coupling/__init__.py +++ b/src/solprop/coupling/__init__.py @@ -26,17 +26,6 @@ CouplingResult, couple, ) -from solprop.coupling.envelopes import ( - CAPILLARY_ENVELOPE, - STEP_INDEX_ENVELOPE, - PairEnvelope, -) -from solprop.coupling.models import ( - BasisChange, - CouplingModel, - ScalarModeBasis, -) -from solprop.coupling.outcouple import Outcouple, OutcoupleResult from solprop.coupling.elements import ( Alignment, Aperture, @@ -49,6 +38,17 @@ ThinLens, TransmissionMask, ) +from solprop.coupling.envelopes import ( + CAPILLARY_ENVELOPE, + STEP_INDEX_ENVELOPE, + PairEnvelope, +) +from solprop.coupling.models import ( + BasisChange, + CouplingModel, + ScalarModeBasis, +) +from solprop.coupling.outcouple import Outcouple, OutcoupleResult from solprop.coupling.plane import ( PLANE_KINDS, AnalyticRecord, diff --git a/src/solprop/coupling/coupler.py b/src/solprop/coupling/coupler.py index 18a940a8d..05f4a35d8 100644 --- a/src/solprop/coupling/coupler.py +++ b/src/solprop/coupling/coupler.py @@ -65,6 +65,7 @@ from solprop import fourier from solprop._display import Displayed, engineering, line +from solprop.adiabatic import AdiabaticAdmission from solprop.constants import EPS0, C from solprop.coupling.envelopes import ( CAPILLARY_ENVELOPE, @@ -201,9 +202,10 @@ class CouplingReport(Displayed): exterior : str What the source plane says beyond its record: ``'zero'`` or ``'unknown'`` (unobserved tails are unknown, not lost). - certificate : collections.abc.Mapping or None - The source plane's SEP 34 path certificate, with its scope, carried - separately: it bounds the linear background path, not the coupling. + certificate : solprop.adiabatic.AdiabaticAdmission or None + The source plane's SEP 34 path certificate, carried separately: its scope + is the linear background's forward field, so it bounds the source's + background path, not the coupling. source : collections.abc.Mapping The source plane's provenance. port : str @@ -219,7 +221,7 @@ class CouplingReport(Displayed): captured_reason: str items: tuple[AdmissionItem, ...] exterior: str - certificate: Mapping[str, object] | None + certificate: AdiabaticAdmission | None source: Mapping[str, object] port: str @@ -238,10 +240,6 @@ def __post_init__(self) -> None: object.__setattr__(self, name, array) object.__setattr__(self, "items", tuple(self.items)) object.__setattr__(self, "source", MappingProxyType(dict(self.source))) - if self.certificate is not None: - object.__setattr__( - self, "certificate", MappingProxyType(dict(self.certificate)) - ) @property def uncaptured(self) -> Float[np.ndarray, " n_omega"] | None: @@ -381,6 +379,15 @@ def _describe(self) -> tuple[str, list[tuple[str, object]]]: value = "n/a" if item.value is None else f"{item.value:.3e}" rows.append((item.name, f"{value} ({item.scope}) {item.note}".rstrip())) rows.append(("exterior", self.exterior)) + if self.certificate is not None: + certificate = self.certificate + rows.append( + ( + "path certificate", + f"field error {certificate.field_error:.3e}, reflection " + f"{certificate.reflection:.3e} (source's linear background path)", + ) + ) return "CouplingReport: a field coupled into an input port", rows diff --git a/src/solprop/coupling/plane.py b/src/solprop/coupling/plane.py index c73e5511d..ddbefc057 100644 --- a/src/solprop/coupling/plane.py +++ b/src/solprop/coupling/plane.py @@ -46,13 +46,13 @@ import math from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass, field -from types import MappingProxyType from typing import Literal, cast import numpy as np from jaxtyping import Complex, Float from solprop import fourier +from solprop.adiabatic import AdiabaticAdmission from solprop.constants import EPS0, C from solprop.coupling.models import ScalarModeBasis from solprop.exceptions import CouplingError, PulseError @@ -1098,9 +1098,11 @@ class FieldPlane: outside their window and grid; a cropped measurement may not be. medium : solprop.spatial.SpatialMedium or None, optional The bulk medium at a spatial plane, when it is at hand. - certificate : collections.abc.Mapping or None, optional - The scoped path certificate of the producing run (SEP 34, section 4.5), - read-only; it bounds the linear background path, not the whole run. + certificate : solprop.adiabatic.AdiabaticAdmission or None, optional + The path certificate of the producing run through a varying medium (SEP 34, + section 4.5), as its record rebuilds it, the path's identity kept as the + recorded text. Its scope is the linear background's forward field: it + bounds the background path, not the nonlinear run or an omitted reflection. nodal : NodalField or None, optional Physical E (and H) at transverse nodes, for a general modal plane read with its source simulation. @@ -1131,7 +1133,7 @@ class FieldPlane: metric: PlaneMetric exterior: Exterior = "zero" medium: SpatialMedium | None = None - certificate: Mapping[str, object] | None = None + certificate: AdiabaticAdmission | None = None nodal: NodalField | None = None provenance: PlaneProvenance = field(default_factory=PlaneProvenance) profiles: ScalarModeBasis | None = None @@ -1157,10 +1159,6 @@ def __post_init__(self) -> None: msg = "scalar profiles describe the columns of an ordinary modal plane" raise CouplingError(msg) object.__setattr__(self, "samples", samples) - if self.certificate is not None: - object.__setattr__( - self, "certificate", MappingProxyType(dict(self.certificate)) - ) def __repr__(self) -> str: """Return a one-line description without arrays. diff --git a/src/solprop/coupling/transport.py b/src/solprop/coupling/transport.py index 4ad90c12c..671010289 100644 --- a/src/solprop/coupling/transport.py +++ b/src/solprop/coupling/transport.py @@ -29,11 +29,12 @@ """ import dataclasses -from collections.abc import Mapping, Sequence +from collections.abc import Sequence from dataclasses import dataclass import numpy as np +from solprop.adiabatic import AdiabaticAdmission from solprop.constants import C from solprop.coupling.elements import ( Element, @@ -90,10 +91,9 @@ class TransportReport: The plane's reference before and after. frame : str Where each free-space frame term came from; empty without a gap. - source_certificate : collections.abc.Mapping or None - The source plane's scoped path certificate (SEP 34 §4.5), carried as its - own item: it bounds the producing run's linear background path, not this - transport. + source_certificate : solprop.adiabatic.AdiabaticAdmission or None + The source plane's path certificate (SEP 34 §4.5), carried as its own item: + it bounds the producing run's linear background path, not this transport. """ elements: tuple[ElementReport, ...] @@ -106,7 +106,7 @@ class TransportReport: reference_in: PlaneReference | None = None reference_out: PlaneReference | None = None frame: str = "" - source_certificate: Mapping[str, object] | None = None + source_certificate: AdiabaticAdmission | None = None @property def ledger_residual(self) -> float: diff --git a/src/solprop/processing/planes.py b/src/solprop/processing/planes.py index 1c8ae5953..6998de57b 100644 --- a/src/solprop/processing/planes.py +++ b/src/solprop/processing/planes.py @@ -7,7 +7,7 @@ :func:`field_plane` turns a stored plane into a :class:`solprop.coupling.plane.FieldPlane`: its native coefficients exactly as stored, the reference rebuilt from the run's schema-24 plane-reference record, the medium's -metric at the plane's own position, the scoped path certificate and the provenance. +metric at the plane's own position, the path certificate and the provenance. It is the extraction half of plane exchange; the manual page is ``docs/guide/workflows/coupling.md``. @@ -24,6 +24,7 @@ import numpy as np from solprop import fourier +from solprop.adiabatic import AdiabaticAdmission from solprop.constants import EPS0, C from solprop.coupling import ( FieldPlane, @@ -233,7 +234,7 @@ def _spatial_metric( space: RadialGrid | FreeGrid, z: float, source: object | None, -) -> tuple[PlaneMetric, SpatialMedium | None, Mapping[str, object] | None]: +) -> tuple[PlaneMetric, SpatialMedium | None, AdiabaticAdmission | None]: """Return a spatial plane's admittance metric, medium and path certificate. Parameters @@ -253,7 +254,7 @@ def _spatial_metric( ------- tuple The metric (with a resolved measure when the medium is at hand), the - medium or None, and the path certificate or None. + medium or None, and the path certificate the record rebuilds, or None. Raises ------ @@ -264,11 +265,8 @@ def _spatial_metric( if "flux_plan" not in spatial: msg = "this spatial output carries no plane measure (output schema 23)" raise ProcessingError(msg) - plan_record = cast("Mapping[str, object]", spatial["flux_plan"]) - admittance = axial_admittance( - flux_from_record(plan_record).at(z), grid.omega, slice(None) - ) - certificate = plan_record.get("admission") + plan = flux_from_record(cast("Mapping[str, object]", spatial["flux_plan"])) + admittance = axial_admittance(plan.at(z), grid.omega, slice(None)) medium = None flux = None if source is not None: @@ -289,11 +287,7 @@ def _spatial_metric( scale=0.5 * EPS0 * C, flux=flux, ) - return ( - metric, - medium, - None if certificate is None else cast("Mapping[str, object]", certificate), - ) + return metric, medium, plan.admission def _modal_profiles( @@ -439,8 +433,9 @@ def field_plane( plane's stored position; the metric is the run's own at that position -- the axial admittance of a spatial plane (SEP 34), the identity for ordinary true-power coefficients, a general basis's Gram; the path certificate is the - spatial record's, with its scope. Radial, Cartesian, ordinary modal and general - modal runs are read; a hybrid planar run is refused by name. + :class:`~solprop.adiabatic.AdiabaticAdmission` the spatial record rebuilds. + Radial, Cartesian, ordinary modal and general modal runs are read; a hybrid + planar run is refused by name. Parameters ---------- diff --git a/tests/coupling/test_couple.py b/tests/coupling/test_couple.py index 885d5eb5b..f2cfb94c3 100644 --- a/tests/coupling/test_couple.py +++ b/tests/coupling/test_couple.py @@ -39,13 +39,15 @@ ) from solprop.coupling.coupler import _contrast_items, _gain_item from solprop.coupling.models import cone_angle, radial_rule -from solprop.driver import Simulation +from solprop.driver import Simulation, SimulationSettings from solprop.exceptions import CouplingError -from solprop.grid import FreeGrid, RadialGrid +from solprop.grid import FieldGrid, FreeGrid, RadialGrid from solprop.modes import MarcatiliMode, Mode, StepIndexMode from solprop.processing import field_plane +from solprop.profiles import PressureGradient from solprop.pulses import GaussianPulse, SpatialPulse from solprop.spatial import SpatialMedium +from solprop.stepper import PIController RADIUS = 75e-6 PRESSURE = 3.0 @@ -903,3 +905,40 @@ def test_the_report_prints_and_carries_its_terms(port): angle = cone_angle(0.05) assert 1 - math.cos(angle) == pytest.approx(0.05 / 8, rel=1e-14) assert EPS0 > 0 + + +def test_a_source_path_certificate_is_carried_beside_the_coupling(port): + """A gradient run's rebuilt certificate reaches the report, separately.""" + grid = FieldGrid.from_limits(2e-3, 800e-9, (600e-9, 1.2e-6), 400e-15) + gradient = Simulation.from_radial( + grid, + RadialGrid(6e-4, 32), + SpatialMedium("Ar", PressureGradient.two_point(2e-3, 1.0, 2.0)), + [], + SpatialPulse(GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=3e-9), 6e-5), + settings=SimulationSettings( + controller=PIController(rtol=1e-8, atol=1e-14), + save_planes=[2e-3], + dz_max=2e-4, + dz_init=2e-4, + backend="numpy_ref", + ), + ) + flux = gradient.translate().flux + assert flux is not None and flux.admission is not None + plane = dataclasses.replace( + FieldPlane.from_analytic( + port.grid, + PlaneGeometry(0.0), + radial_beam(port.grid, 0.5 * RADIUS), + space=RadialGrid(400e-6, 128), + medium=MEDIUM, + ), + certificate=flux.admission, + ) + report = couple(plane, port, model=SCALAR).report + assert report.certificate is flux.admission + assert "path certificate" in str(report) + plain = couple(dataclasses.replace(plane, certificate=None), port, model=SCALAR) + assert plain.report.certificate is None + assert "path certificate" not in str(plain.report) diff --git a/tests/processing/test_planes.py b/tests/processing/test_planes.py index 633094b27..356bb3db4 100644 --- a/tests/processing/test_planes.py +++ b/tests/processing/test_planes.py @@ -10,6 +10,7 @@ import pytest from numpy.testing import assert_allclose +from solprop.adiabatic import AdiabaticAdmission from solprop.coupling import reference_at from solprop.driver import Simulation, SimulationSettings from solprop.exceptions import ProcessingError @@ -174,15 +175,32 @@ def test_a_spatial_plane_carries_its_medium_s_measure(space, tmp_path): field_plane(memory, 1, source=other) -def test_a_gradient_plane_carries_its_scoped_path_certificate(): +def test_a_gradient_plane_carries_its_rebuilt_path_certificate(tmp_path): simulation = spatial( RadialGrid(6e-4, 32), PressureGradient.two_point(2e-3, 1.0, 2.0) ) - plane = field_plane(simulation.run(progress="none")) - assert plane.certificate is not None - assert plane.certificate["scope"] == "linear-background forward field" - with pytest.raises(TypeError): - plane.certificate["scope"] = "anything" # pyright: ignore[reportIndexIssue] + admitted = simulation.translate().flux + assert admitted is not None and admitted.admission is not None + expected = admitted.admission + path = tmp_path / "gradient.h5" + memory = simulation.run(progress="none") + simulation.run(path, progress="none") + for output in (memory, open_file(path)): + plane = field_plane(output) + certificate = plane.certificate + assert isinstance(certificate, AdiabaticAdmission) + # The record rebuilds the run's own certificate: its numbers are copied + # through the file, so they are exact; the path's identity comes back as + # the text it was recorded as. + assert certificate.z_span == expected.z_span + assert certificate.variation == expected.variation + assert certificate.field_error == expected.field_error + assert certificate.reflection == expected.reflection + assert certificate.accuracy == expected.accuracy + assert np.array_equal(certificate.support, expected.support) + assert certificate.identity == {"recorded": repr(dict(expected.identity))} + with pytest.raises(dataclasses.FrozenInstanceError): + certificate.variation = 0.0 # pyright: ignore[reportAttributeAccessIssue] @pytest.fixture(scope="module") From 0422429e7607de4bb04fe3a4cc310fbc9f9efe91 Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 02:33:05 +0100 Subject: [PATCH 24/50] Tutorial: nonlinear beam preparation and coupling into a capillary A beam focused through 20 mm of 3 bar argon at B_lin = 0.3 rad (12.1 uJ), propagated linearly and with Kerr, each entrance plane coupled into the same 75 um capillary with couple(scalar_paraxial) and run through 5 mm. Kerr preparation moves 2.4 % of the incident energy from HE11 to HE12. Runs in about 5 s on a docs build. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019qNYqtCKh4iTKj7544yARb --- .../plot_08_radial_capillary_coupling.py | 509 ++++++++++++++++++ 1 file changed, 509 insertions(+) create mode 100644 docs/tutorials/free_space/plot_08_radial_capillary_coupling.py diff --git a/docs/tutorials/free_space/plot_08_radial_capillary_coupling.py b/docs/tutorials/free_space/plot_08_radial_capillary_coupling.py new file mode 100644 index 000000000..279e2e372 --- /dev/null +++ b/docs/tutorials/free_space/plot_08_radial_capillary_coupling.py @@ -0,0 +1,509 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +r""" +Nonlinear beam preparation and coupling into a capillary +======================================================== + +A beam focused onto a gas-filled capillary often crosses some centimetres of the +same gas first. When it is strong, the Kerr effect along that gap changes the beam +before it reaches the entrance. This tutorial asks how much that changes the modes +the capillary takes up. It propagates one physically focused beam through a 20 mm +argon gap twice, once linearly and once with the Kerr effect. It takes each complete +complex radial field at the entrance with +:func:`~solprop.processing.planes.field_plane`, couples it into the same capillary +mode set with :func:`~solprop.coupling.coupler.couple` under the ``scalar_paraxial`` +model, and launches both through the same 5 mm Kerr-only capillary +(:doc:`/guide/workflows/coupling`). + +**The system.** A scalar, linearly polarised Gaussian at 800 nm, 30 fs intensity FWHM, +on an envelope grid, in uniform argon at 3 bar and 293.15 K: the reference +temperature of the argon polarisability, which the run records. The beam is built +in the argon 20 mm before its linear focus, where its 1/e² intensity radius is +37.5 um. That focus is the entrance of a 75 um radius capillary (w/a = 0.5). + +**The model.** Kerr self-focusing and self-phase modulation only +(:class:`~solprop.nonlinear.responses.KerrResponse` with ``thg=False``). Plasma, +third-harmonic generation and Raman scattering are omitted, in the gap and in the +capillary. The ionisation they would carry is estimated offline at the end. + +**The strength.** The energy is not a chosen number. It follows from the material +functions and a target on-axis nonlinear phase of the linear Gaussian beam, +:math:`B_{\rm lin} = 0.3` rad, accumulated from the launch plane to the focus. +""" + +import math +import time + +import matplotlib.pyplot as plt +import numpy as np +from scipy.special import j0, jn_zeros + +from solprop import fourier +from solprop.constants import EPS0, C +from solprop.coupling import CouplingModel, couple +from solprop.driver import Simulation, SimulationSettings +from solprop.grid import EnvelopeGrid, RadialGrid +from solprop.modes.capillary import MarcatiliMode +from solprop.nonlinear.optmat_rates import OptmatRate +from solprop.nonlinear.responses import KerrResponse +from solprop.physdata import chi3, gamma3, refractive_index +from solprop.physdata.species import species +from solprop.processing import field_plane, get_spectral_density +from solprop.pulses import GaussianPulse, SpatialPulse +from solprop.spatial import SpatialMedium, critical_power +from solprop.stats import StatsSpec +from solprop.stepper import PIController + +started = time.perf_counter() + +# %% +# The energy from the material functions +# -------------------------------------- +# In the narrowband paraxial sizing model, a Gaussian of waist :math:`w_0` in a +# medium of index :math:`n_0` has Rayleigh length +# :math:`z_R = \pi n_0 w_0^2/\lambda_0` and on-axis intensity +# :math:`I_0(z) = 2P_0/(\pi w_0^2[1+(z/z_R)^2])`. With the package's scalar Kerr +# normalisation, :math:`n_2 = 3\chi^{(3)}/(4\varepsilon_0 c n_0^2)`. +# Integrating :math:`k_0 n_2 I_0` from :math:`z = -L` to the focus gives +# +# .. math:: +# +# B_{\rm lin} = \frac{2k_0 n_2 P_0 z_R}{\pi w_0^2}\arctan(L/z_R),\qquad +# U = P_0\,\tau_{\rm FWHM}\frac{\sqrt\pi}{2\sqrt{\ln 2}} . +# +# :math:`n_0` and :math:`\chi^{(3)}` are optmat's argon at 3 bar and 293.15 K. The +# energy is that of the beam through its launch plane, in the argon +# (:doc:`/guide/free-space/flux`). + +WAVELENGTH = 800e-9 # m +FWHM = 30e-15 # s, intensity +PRESSURE = 3.0 # bar +GAP = 20e-3 # m, gas gap before the entrance +WAIST = 37.5e-6 # m, 1/e^2 intensity radius of the linear focus, at the entrance +RADIUS = 75e-6 # m, capillary core radius +LENGTH = 5e-3 # m, capillary segment +B_LINEAR = 0.3 # rad + +medium = SpatialMedium("Ar", pressure=PRESSURE) +reference = species("Ar").susceptibility.reference_state.temperature +omega0 = 2 * math.pi * C / WAVELENGTH +n0 = float(refractive_index("Ar", omega0, pressure=PRESSURE)) +susceptibility = chi3("Ar", pressure=PRESSURE, wavelength=WAVELENGTH) +n2 = 3 * susceptibility / (4 * EPS0 * C * n0**2) +k0 = 2 * math.pi / WAVELENGTH +rayleigh = math.pi * n0 * WAIST**2 / WAVELENGTH +peak_power = ( + B_LINEAR * math.pi * WAIST**2 / (2 * k0 * n2 * rayleigh * math.atan(GAP / rayleigh)) +) +energy = peak_power * FWHM * math.sqrt(math.pi) / (2 * math.sqrt(math.log(2))) +critical = critical_power(WAVELENGTH, susceptibility, index=n0) +print( + f"temperature {medium.temperature} K (argon polarisability reference {reference} K)" +) +print(f"n0 - 1 = {n0 - 1:.4e}, n2 = {n2:.4e} m^2/W, z_R = {rayleigh * 1e3:.3f} mm") +print(f"energy {energy * 1e6:.3f} uJ, peak power {peak_power * 1e-9:.4f} GW") +print(f"P0/Pcr = {peak_power / critical:.4f} (Pcr = {critical * 1e-9:.3f} GW)") + +# %% +# Two propagations through the gap +# -------------------------------- +# One :class:`~solprop.pulses.SpatialPulse`, focused 20 mm downstream +# (``propz=-GAP``) and built in the argon, starts both runs. The grids are the +# converged ones of the refinement study described at the end: 1 mm radial domain +# with 256 nodes, a 300 fs window over 500 nm to 1.4 um, and steps no longer than +# 0.25 mm. The only difference between the two runs is the response list. + +grid = EnvelopeGrid.from_limits(GAP, WAVELENGTH, (500e-9, 1.4e-6), 300e-15, thg=False) +space = RadialGrid(1e-3, 256) +beam = SpatialPulse( + GaussianPulse(wavelength=WAVELENGTH, fwhm=FWHM, energy=energy), WAIST, propz=-GAP +) +settings = SimulationSettings( + controller=PIController(rtol=1e-7, atol=1e-14), + save_planes=np.linspace(0, GAP, 41)[1:], + dz_init=1e-5, + dz_max=2.5e-4, + stats=StatsSpec(names=("energy", "on_axis_intensity", "beam_radius")), +) +kerr = [KerrResponse(gamma3("Ar", wavelength=WAVELENGTH), thg=False)] +gap = {} +for name, responses in (("linear", []), ("Kerr", kerr)): + simulation = Simulation.from_radial( + grid, space, medium, responses, beam, settings=settings + ) + gap[name] = (simulation, simulation.run(progress="none")) + +# %% +# The beam radius is the second moment of the time-integrated axial flux. The Kerr +# lens pulls the beam to a slightly smaller spot and moves its focus a little +# upstream. Its peak intensity rises by about 14 % but stays far from collapse. + +figure, axes = plt.subplots(1, 2, figsize=(9, 3.5)) +for name, (_, run) in gap.items(): + z = np.asarray(run["z"]) + radius = np.asarray(run["stats/beam_radius"])[..., 1].reshape(-1) + intensity = np.asarray(run["stats/on_axis_intensity"]).reshape(-1) + axes[0].plot(z * 1e3, radius * 1e6, label=name) + axes[1].plot(z * 1e3, intensity * 1e-4, label=name) +axes[0].set(xlabel="z in the gap (mm)", ylabel="1/e² fluence radius (um)") +axes[1].set(xlabel="z in the gap (mm)", ylabel="on-axis peak intensity (W/cm²)") +axes[0].legend() +figure.tight_layout() +peak = { + name: float(np.max(np.asarray(run["stats/on_axis_intensity"]))) * 1e-4 + for name, (_, run) in gap.items() +} +print( + f"peak on-axis intensity: linear {peak['linear']:.3e}, " + f"Kerr {peak['Kerr']:.3e} W/cm²" +) + +# %% +# The fields at the entrance +# -------------------------- +# ``field_plane(run, source=simulation)`` reads the last saved plane, the entrance, +# as a radial :class:`~solprop.coupling.plane.FieldPlane` in the argon. Its columns +# are the Hankel spectrum of the field. The field anywhere in the plane is the +# inverse transform :math:`E(r) = \int k\tilde E(k)J_0(kr)\,dk`, so the field on the +# axis, which is not a radial node, is evaluated directly. +# +# The wavefront change is the phase of the Kerr field relative to the linear one at +# the carrier frequency bin (800 nm), :math:`\arg[E_{\rm K}(r,\omega_0)/E_{\rm +# L}(r,\omega_0)]`. Both planes carry the same reference, because both runs share +# one frame. It is masked where the linear field's amplitude at that bin falls below +# 1 % of its peak, where neither phase means much. + +planes = {name: field_plane(run, source=sim) for name, (sim, run) in gap.items()} +hankel = space.hankel +r_plot = np.linspace(0, 150e-6, 301) +synthesis = j0(np.outer(r_plot, hankel.k_r)) * hankel.spectral_weights / (2 * math.pi) +centre = int(np.argmin(np.abs(grid.omega - grid.omega0))) +weights = fourier.energy_weights(grid.n_omega, grid.dt, kind=grid.kind) +admittance = n0 * EPS0 * C / 2 +fields = {name: synthesis @ plane.samples[:, 0, :] for name, plane in planes.items()} +fluence = { + name: admittance * np.sum(weights * np.abs(field) ** 2, axis=1) + for name, field in fields.items() +} + +figure, axes = plt.subplots(1, 2, figsize=(9, 3.5)) +for name, values in fluence.items(): + axes[0].plot(r_plot * 1e6, values * 1e-4, label=name) +axes[0].axvline(RADIUS * 1e6, color="0.5", ls=":", label="core radius") +axes[0].set(xlabel="r (um)", ylabel="entrance fluence (J/cm²)") +axes[0].legend() +carrier = fields["linear"][:, centre] +resolved = np.abs(carrier) > 1e-2 * np.max(np.abs(carrier)) +change = np.angle(fields["Kerr"][:, centre] / np.where(resolved, carrier, 1)) +axes[1].plot(r_plot[resolved] * 1e6, change[resolved]) +axes[1].axvline(RADIUS * 1e6, color="0.5", ls=":") +axes[1].set( + xlim=(0, 150), xlabel="r (um)", ylabel="Kerr minus linear phase at 800 nm (rad)" +) +figure.tight_layout() +print( + f"phase change at 800 nm: {change[0]:.3f} rad on axis, " + f"{change[np.argmin(np.abs(r_plot - WAIST))]:.3f} rad at r = w0" +) + +# %% +# The fluence is the time integral of the axial flux, :math:`n_0\varepsilon_0c|E|^2/2` +# summed over the spectrum (:doc:`/guide/free-space/flux`). The Kerr beam arrives +# about 10 % brighter on the axis and slightly narrower. At 800 nm it leads the +# linear field in phase by about 0.1 rad near the axis, and the lead falls away +# across the beam. That curvature is the Kerr lens: the beam is still converging as +# it reaches the entrance, while the linear beam is at its waist with a flat +# wavefront. +# +# In time and frequency the changes are small. On the axis the pulse arrives +# brighter, and the power through the whole plane is unchanged, since the gap is +# lossless. The plane's spectrum broadens a little by self-phase modulation, which +# shows only in its wings, three decades down. + +t = grid.t +nodes = { + name: fourier.inverse( + fourier.spectrum_to_internal(plane.node_spectra()[:, 0, :], kind="envelope"), + kind="envelope", + ) + for name, plane in planes.items() +} +bin_width = 2 * math.pi / (grid.n_omega * grid.dt) + +figure, axes = plt.subplots(1, 3, figsize=(11, 3.5)) +for name, plane in planes.items(): + axis = fourier.inverse( + fourier.spectrum_to_internal(fields[name][0], kind="envelope"), kind="envelope" + ) + power = admittance * np.sum( + hankel.radial_weights[:, None] * np.abs(nodes[name]) ** 2, axis=0 + ) + axes[0].plot(t * 1e15, admittance * np.abs(axis) ** 2 * 1e-4, label=name) + axes[1].plot(t * 1e15, power * 1e-9, label=name) + omega = plane.omega + keep = omega > 0 + wavelength = 2 * math.pi * C / omega[keep] + density = ( + plane.spectral_energy()[keep] / bin_width * omega[keep] ** 2 / (2 * math.pi * C) + ) + sort = np.argsort(wavelength) + axes[2].semilogy(wavelength[sort] * 1e9, density[sort] * 1e-9 * 1e6, label=name) +axes[0].set(xlim=(-80, 80), xlabel="time (fs)", ylabel="on-axis intensity (W/cm²)") +axes[0].legend() +axes[1].set(xlim=(-80, 80), xlabel="time (fs)", ylabel="power through the plane (GW)") +axes[2].set( + xlim=(700, 900), + ylim=(1e-4, 1), + xlabel="wavelength (nm)", + ylabel="plane spectral energy (uJ/nm)", +) +figure.tight_layout() + +# %% +# Coupling into the capillary +# --------------------------- +# The receiving simulation is the 5 mm capillary on the same temporal grid, with +# eight :math:`HE_{1m}` modes (the range of the recorded capillary envelope) and +# Kerr-only physics. Its :meth:`~solprop.driver.simulation.Simulation.input_port` +# describes the entrance. Each entrance plane is coupled under ``scalar_paraxial`` +# with an energy budget ``model_error = 0.05`` for the approximation, its tails and +# its numerical errors (:doc:`/guide/workflows/coupling`). The launch is what the +# modes capture: nothing is rescaled. +# +# The modal share is :math:`s_m = U_m/U_{\rm incident}`: the energy launched into +# mode *m*, in its native true-power metric, over the incident energy of the +# whole entrance plane in the coupling's common measure. + +capillary = Simulation.from_capillary( + RADIUS, + LENGTH, + "Ar", + PRESSURE, + lambda0=WAVELENGTH, + grid=grid, + envelope=True, + fwhm=FWHM, + energy=energy, + modes=8, + kerr=True, + thg=False, + plasma=False, + raman=False, + noise=None, + n_save=21, +) +port = capillary.input_port() +model = CouplingModel("scalar_paraxial", model_error=0.05) +coupled = {name: couple(plane, port, model=model) for name, plane in planes.items()} +shares = { + name: np.sum(weights * np.abs(result.launch.samples[:, 0, :]) ** 2, axis=1) + / result.report.incident_energy + for name, result in coupled.items() +} +print(coupled["Kerr"].report) +for name, result in coupled.items(): + report = result.report + print( + f"{name:>6}: s11 = {shares[name][0]:.5f}, s12 = {shares[name][1]:.5f}, " + f"s13 = {shares[name][2]:.2e}; launched {report.efficiency:.5f} " + f"of {report.incident_energy * 1e6:.3f} uJ; budget {report.budget:.3f}" + ) +delta = shares["Kerr"] - shares["linear"] +print(f"change: ds11 = {delta[0]:+.3e}, ds12 = {delta[1]:+.3e}, ds13 = {delta[2]:+.2e}") + +# %% +# The Kerr-prepared beam puts 2.4 % of the incident energy less into +# :math:`HE_{11}` and almost all of it into :math:`HE_{12}`. The total captured +# share barely moves: the change is a redistribution between modes, not a loss. +# The narrower, more curved entrance field looks more like the second mode. + +labels = [f"HE1{m}" for m in range(1, 5)] +position = np.arange(4) +figure, axes = plt.subplots(1, 2, figsize=(9, 3.5)) +for offset, name in ((-0.2, "linear"), (0.2, "Kerr")): + axes[0].bar(position + offset, shares[name][:4], width=0.4, label=name) + report = coupled[name].report + axes[1].bar( + np.arange(3) + offset, + np.array( + [report.incident_energy, report.captured_energy, report.launched_energy] + ) + * 1e6, + width=0.4, + label=name, + ) +axes[0].set( + yscale="log", + ylim=(1e-6, 1.5), + xticks=position, + xticklabels=labels, + ylabel="share of incident energy", +) +axes[1].set( + xticks=range(3), + xticklabels=["incident", "captured", "launched"], + ylabel="energy (uJ)", + ylim=(11.9, 12.15), +) +axes[0].legend() +figure.tight_layout() + +# %% +# The 5 mm capillary +# ------------------ +# Both launches enter the identical capillary. Even linearly, :math:`HE_{11}` and +# :math:`HE_{12}` beat. With :math:`\beta_m \simeq nk_0 - u_m^2/(2nk_0a^2)`, where +# :math:`u_m` is the m-th zero of :math:`J_0`, the beat length is +# +# .. math:: +# +# L_{\rm beat} = \frac{2\pi}{|\beta_{12}-\beta_{11}|} +# = \frac{8\pi^2 n a^2}{(u_{12}^2-u_{11}^2)\lambda_0} . +# +# This is about 22.5 mm at the carrier, so the 5 mm segment holds about 0.22 beat +# periods. The output contains partial linear mode beating as well as modal +# filtering and the capillary's own Kerr evolution. The capillary's Marcatili modes +# below give the actual dispersive beat length, 19 to 28 mm across the pulse's +# band, and the wall loss: over 5 mm, 0.2--0.5 % of :math:`HE_{11}` and 1--2.4 % +# of :math:`HE_{12}`. Because both cases go through the same capillary physics, the +# beating, the loss and the Kerr coupling are the same in both. The difference +# between the outputs comes from the prepared incident field. + +zeros = jn_zeros(0, 2) +carrier_beat = ( + 8 * math.pi**2 * n0 * RADIUS**2 / ((zeros[1] ** 2 - zeros[0] ** 2) * WAVELENGTH) +) +print( + f"carrier estimate: L_beat = {carrier_beat * 1e3:.2f} mm, " + f"{LENGTH / carrier_beat:.3f} periods" +) +modes = [MarcatiliMode(RADIUS, "Ar", PRESSURE, kind="HE", n=1, m=m) for m in (1, 2)] +for wavelength in (650e-9, 800e-9, 950e-9): + omega = np.array([2 * math.pi * C / wavelength]) + neff = [complex(mode.neff(omega)[0]) for mode in modes] + beat = 2 * math.pi * C / (abs(neff[0].real - neff[1].real) * omega[0]) + alpha = [2 * value.imag * omega[0] / C for value in neff] + print( + f"{wavelength * 1e9:.0f} nm: L_beat = {beat * 1e3:.2f} mm; " + f"power loss over 5 mm HE11 {1 - math.exp(-alpha[0] * LENGTH):.2e}, " + f"HE12 {1 - math.exp(-alpha[1] * LENGTH):.2e}" + ) + +outputs = { + name: capillary.with_input(result.launch).run(progress="none") + for name, result in coupled.items() +} +figure, axes = plt.subplots(1, 3, figsize=(11, 3.5)) +for name, run in outputs.items(): + z = np.asarray(run["z"]) + modal = np.asarray(run["stats/energy"])[:, :, 0] + axes[0].plot(z * 1e3, modal[:, 0] * 1e6, label=name) + axes[1].plot(z * 1e3, modal[:, 1] * 1e6, label=name) + wavelength, density = get_spectral_density(run, "wavelength", plane=-1, modes="sum") + axes[2].semilogy(wavelength * 1e9, density * 1e-9 * 1e6, label=name) + print( + f"{name:>6} output: HE11 {modal[-1, 0] * 1e6:.4f} uJ, " + f"HE12 {modal[-1, 1] * 1e6:.4f} uJ, " + f"total {modal[-1].sum() * 1e6:.4f} uJ" + ) +axes[0].set(xlabel="z in the capillary (mm)", ylabel="HE11 energy (uJ)") +axes[1].set(xlabel="z in the capillary (mm)", ylabel="HE12 energy (uJ)") +axes[0].legend() +axes[2].set( + xlim=(700, 900), + ylim=(1e-4, 1), + xlabel="wavelength (nm)", + ylabel="output spectral energy (uJ/nm)", +) +figure.tight_layout() + +# %% +# In both cases :math:`HE_{12}` gives energy to :math:`HE_{11}` along the +# capillary, far more than its wall loss. Linear modes of a uniform capillary do not +# exchange energy, so this is the capillary's Kerr coupling between the two modes. +# Its direction depends on their relative phase, which the partial beating +# advances. The Kerr-prepared launch leaves the capillary with about 0.27 uJ less +# in :math:`HE_{11}` and 0.27 uJ more in :math:`HE_{12}`, close to the +# 0.29 uJ moved at the entrance. + +# %% +# Checks +# ------ +# **Collapse, collar and aliasing.** The peak power is an eighth of the critical +# power, and the peak intensity rises smoothly, so there is no collapse to resolve. +# The fluence in the outer tenth of the radial domain, the region next to the +# window, is printed below. So is the coupling's numerical item, which measures the +# entrance planes' content in the outer fifth of their transverse spectral window. +# Both are far below anything that could affect the shares. + +for name, plane in planes.items(): + flux = admittance * np.sum( + weights * np.abs(plane.node_spectra()[:, 0, :]) ** 2, axis=1 + ) + outer = space.r > 0.9 * space.radius + collar = np.sum((flux * hankel.radial_weights)[outer]) / np.sum( + flux * hankel.radial_weights + ) + numerical = next( + item for item in coupled[name].report.items if item.name == "numerical" + ) + print( + f"{name:>6}: outer-domain fluence {collar:.1e}, " + f"spectral-edge content {numerical.value:.1e}" + ) + +# %% +# **Convergence.** A separate refinement study varied the radial domain +# (0.8 to 1.5 mm), the radial nodes (192 to 512), the window (200 to 500 fs), the +# band (to 400 nm--2 um), the time step, the retained modes (4 to 8), the tolerance +# (1e-6 to 1e-9) and the maximum step (1 to 0.1 mm), one at a time. No variation +# moved :math:`s_{11}` or :math:`s_{12}` of either run by more than 3e-8. The +# combined uncertainty of the two runs is about 1e-6 of each change, far inside a +# tenth. An independent check projected each entrance field onto +# :math:`J_0(u_mr/a)` with a Gauss--Legendre rule over the core. It agrees with +# ``couple`` to eight digits, once the captured share is converted to the launched +# share. The two differ by the ratio of the gas index to the mode index, about +# 8e-6. +# +# **Omitted ionisation.** optmat's recommended argon rate at 800 nm +# (:class:`~solprop.nonlinear.optmat_rates.OptmatRate`) is integrated over the real +# field of a 30 fs Gaussian at the highest on-axis intensity of the Kerr run. This +# is an offline estimate, not part of the propagation. + +rate = OptmatRate("Ar", WAVELENGTH) +times = np.linspace(-100e-15, 100e-15, 200001) +amplitude = math.sqrt(2 * peak["Kerr"] * 1e4 / (n0 * EPS0 * C)) +envelope = amplitude * np.exp(-2 * math.log(2) * (times / FWHM) ** 2) +ionised = 1 - math.exp( + -float(np.trapezoid(rate(envelope * np.cos(omega0 * times)), times)) +) +electrons = ionised * float(medium.density(0.0)) +plasma_index = electrons / ( + 2 * EPS0 * 9.1093837139e-31 * omega0**2 / 1.602176634e-19**2 +) +print(f"ionised fraction at {peak['Kerr']:.3e} W/cm²: {ionised:.1e}") +print( + f"plasma index change {plasma_index:.1e} against Kerr {n2 * peak['Kerr'] * 1e4:.1e}" +) + +# %% +# An ionised fraction of about 1e-7 lowers the index by about 2e-9. The Kerr index +# change it competes with is 6e-6. Leaving plasma out is defensible at this +# strength. +# +# **Energy meters.** The energy here is the energy crossing the launch plane in the +# argon. A meter reading taken in air before a cell window is not that energy. Pass +# it through the window's Fresnel transmission yourself, or transport the plane +# through a :class:`~solprop.coupling.elements.PlanarInterface`, before giving it +# (:doc:`/guide/free-space/flux`, :doc:`/guide/workflows/coupling`). + +print(f"run time {time.perf_counter() - started:.1f} s") + +# %% +# Related reading +# --------------- +# :doc:`/guide/workflows/coupling` describes planes, ports, coupling models and +# their budgets. :doc:`/guide/free-space/model` and +# :doc:`/guide/waveguides/capillaries` describe the two propagations. From 85a90128455aaa21cf61bf9cbcc80c81d1a75cce Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 02:33:06 +0100 Subject: [PATCH 25/50] Docs: link the capillary coupling tutorial from the guides Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019qNYqtCKh4iTKj7544yARb --- docs/guide/free-space/index.md | 4 +++- docs/guide/waveguides/capillaries.md | 5 ++++- docs/guide/workflows/coupling.md | 5 ++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/guide/free-space/index.md b/docs/guide/free-space/index.md index 8aa2e62fd..5afcda82f 100644 --- a/docs/guide/free-space/index.md +++ b/docs/guide/free-space/index.md @@ -7,7 +7,9 @@ coordinates, or a line grid for a field uniform in one direction. Read the is unfamiliar. The [radial filament lesson](../../introduction/lessons/plot_03_filament.rst) and [Cartesian crossing beams](../../introduction/lessons/plot_04_crossing_beams.rst) introduce the main workflows. The [focused examples](../../auto_tutorials/free_space/index.rst) -extend them to focusing, diffraction and analysis. +extend them to focusing, diffraction and analysis. The +[capillary coupling tutorial](../../auto_tutorials/free_space/plot_08_radial_capillary_coupling.rst) +prepares a beam in a gas gap and couples its entrance field into a capillary. ```{toctree} :maxdepth: 1 diff --git a/docs/guide/waveguides/capillaries.md b/docs/guide/waveguides/capillaries.md index 261b97155..27f08e91d 100644 --- a/docs/guide/waveguides/capillaries.md +++ b/docs/guide/waveguides/capillaries.md @@ -39,7 +39,10 @@ wall, couples every pair. * **More than one mode carries energy that matters.** A launch that is not a perfect `HE_11`, strong self-focusing or ionisation that fills the higher modes, a mode - converter: the wall's exchange between modes is then part of the physics. + converter: the wall's exchange between modes is then part of the physics. How a + beam focused through gas is coupled into the modes is in + [coupling simulations](../workflows/coupling.md) and the + [capillary coupling tutorial](../../auto_tutorials/free_space/plot_08_radial_capillary_coupling.rst). * **The bore tapers and the wall's loss matters**: a multimode taper converts between its modes whether or not a wall is declared — the **taper coupling** is on by default ([below](#the-taper-coupling-without-a-wall)) — and the wall adds the coherent loss of diff --git a/docs/guide/workflows/coupling.md b/docs/guide/workflows/coupling.md index 729afe50b..16e5f5346 100644 --- a/docs/guide/workflows/coupling.md +++ b/docs/guide/workflows/coupling.md @@ -271,7 +271,10 @@ entered = capillary.with_input(coupled.launch) At $w/a = 0.5$ the fundamental takes about 0.906 of the incident energy and the next radial mode 0.092. The launch carries what the modes capture: nothing is rescaled to the requested energy. A displaced, tilted or curved beam is a Cartesian plane -(`space=FreeGrid(...)`); a radial plane cannot carry them. +(`space=FreeGrid(...)`); a radial plane cannot carry them. The +[capillary coupling tutorial](../../auto_tutorials/free_space/plot_08_radial_capillary_coupling.rst) +couples the entrance field of a beam focused through 20 mm of argon, with and without +the Kerr effect, and follows both launches through a capillary. ### A change of capillary radius From 20ab95ba4fdd5c5d633cc2c88da1e9577cea9b9e Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 02:33:57 +0100 Subject: [PATCH 26/50] Docs: list the capillary coupling tutorial in the free-space gallery Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019qNYqtCKh4iTKj7544yARb --- docs/tutorials/free_space/README.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/tutorials/free_space/README.rst b/docs/tutorials/free_space/README.rst index 83a8f8f5d..fc3094b7d 100644 --- a/docs/tutorials/free_space/README.rst +++ b/docs/tutorials/free_space/README.rst @@ -4,8 +4,9 @@ Free space Beams in radial and Cartesian geometries, a self-focusing diagnostic, a line beam uniform in one direction, a planar guide resolved across its width, a weak band calibrated under the low-storage march, a four-wave-mixing delay scan run through one backend with -statistics-only planes, and far-field spectra collected through apertures in a mask -plane. +statistics-only planes, far-field spectra collected through apertures in a mask +plane, and a beam prepared by Kerr self-focusing in a gas gap and coupled into a +capillary. For the full filament and crossing-beam lessons, begin with the :doc:`/introduction/index`. From a1599b903086e456f0187d236f7dc273e1cc8af0 Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 02:43:39 +0100 Subject: [PATCH 27/50] Coupling: an honest capillary envelope and the review's admission fixes The capillary envelope compared the scalar J0 profile with MarcatiliMode.field, which is the same function; that check is now labelled as the quadrature and implementation consistency test it is. The envelope is re-measured against Marcatili's leaky HE1m core field J0(u(1 - i delta)r/a), delta = |nu_n|c/(omega a), over its declared range (delta to 1e-2): largest 2.9e-3, bound 6e-3, allowance scaled linearly by the pair's energy-weighted delta. Bins outside an envelope's range (a capillary's delta, a fibre's V across the band) are counted in full. Also: the modal overlap quadrature enters as 2 delta + delta^2; the angular items say they are cone-rule allocations; a test pins the launch phase of a displaced, tilted beam; the package docstring's transport reference resolves. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019qNYqtCKh4iTKj7544yARb --- src/solprop/coupling/__init__.py | 2 +- src/solprop/coupling/coupler.py | 129 +++++++++++++++-- src/solprop/coupling/envelopes.py | 71 +++++++--- src/solprop/coupling/models.py | 44 +++++- tests/coupling/test_couple.py | 82 +++++++++++ tests/coupling/test_envelopes.py | 224 +++++++++++++++++++++++++++--- 6 files changed, 506 insertions(+), 46 deletions(-) diff --git a/src/solprop/coupling/__init__.py b/src/solprop/coupling/__init__.py index b865422e6..b1cbbb611 100644 --- a/src/solprop/coupling/__init__.py +++ b/src/solprop/coupling/__init__.py @@ -13,7 +13,7 @@ launches a plane into it under a declared :class:`~solprop.coupling.models.CouplingModel`; :class:`~solprop.coupling.outcouple.Outcouple` synthesises a guide's exit field, -and :func:`~solprop.coupling.transport.transport` carries a spatial plane through +and :func:`~solprop.coupling.transport` carries a spatial plane through an ordered sequence of linear elements. Extraction from a saved run is :func:`solprop.processing.planes.field_plane`; installing a launch is :meth:`solprop.driver.simulation.Simulation.with_input`. diff --git a/src/solprop/coupling/coupler.py b/src/solprop/coupling/coupler.py index 05f4a35d8..f456954eb 100644 --- a/src/solprop/coupling/coupler.py +++ b/src/solprop/coupling/coupler.py @@ -33,7 +33,9 @@ - ``angular``: :math:`[d(E_{\rm in}) + d_{\rm target}]/U` with :math:`1 - \cos\theta_* = \epsilon_m/8` and :math:`d = (1 - \cos\theta_*)U_{\rm cone} + U_{\rm tail}` (section 4.6), for the - incident field and for the retained target subspace; + incident field and for the retained target subspace. The cone term is the cone + rule's **allocation**, about :math:`\epsilon_m/8` of the cone share, not a + measured defect; only the tail beyond :math:`\theta_*` is measured; - ``contrast``: the weak-guidance screen :math:`\Delta \le \epsilon_m/8` of a solid provider, or the recorded pair envelope that replaces it; not applied to a capillary wall; @@ -45,11 +47,13 @@ - ``passivity``: the whitened certificate :math:`C^\dagger Q_BC \preceq Q_A`; an apparent gain within the model's own defects is reported as a model defect, never rescaled or clipped, and a larger one refuses; -- ``envelope``: the recorded pair envelope's bound; -- ``numerical``: quadrature, representation and domain errors. +- ``envelope``: the recorded pair envelope's allowance (scaled by the pair's leaky + parameter for a capillary), plus the incident share of any bins outside its range; +- ``numerical``: quadrature, representation and domain errors, an amplitude error + :math:`\delta` entered as :math:`2\delta + \delta^2`. The counted items must sum to at most :math:`\epsilon_m`. A source plane's SEP 34 path -certificate is carried separately in the report, with its scope. A zero field is a +certificate is carried separately in the report. A zero field is a valid zero launch with an undefined efficiency. """ @@ -1155,6 +1159,12 @@ def _scalar_paraxial( ) ) items.append(_source_metric_item(incident, native, epsilon)) + if profiles.family == "capillary": + envelope_parameters.update(_leaky_parameters(profiles, plane, incident)) + elif plane.profiles is not None: + envelope_parameters["uncovered"] = _uncovered_v_share( + plane.profiles, profiles, plane.grid.omega, incident + ) items.extend( _contrast_items(profiles, plane, index, occupied, epsilon, envelope_parameters) ) @@ -1423,9 +1433,16 @@ def _modal_fit( # noqa: PLR0913 - the plane, both profile sets and the measure epsilon=epsilon, ) ) + # The quadrature error bounds overlap (field-amplitude) entries; as an energy + # discrepancy it enters as 2 delta + delta^2 (section 4.7). fit.items.append( AdmissionItem( - "numerical", error, None, True, "map", "profile overlap quadrature" + "numerical", + 2.0 * error + error**2, + None, + True, + "map", + f"profile overlap quadrature, amplitude error {error:.1e}", ) ) # Native source metric is the identity, so C Q_A^-1 C^H = G^-1 R R^H G^-1. @@ -1625,7 +1642,8 @@ def _target_angular_scalar( None, True, "map", - "retained target subspace: (1 - cos theta*) cone + tail", + "retained target subspace: cone-rule allocation (1 - cos theta*) = " + "model_error/8 of the cone, not a measured defect, plus the tail", ) @@ -1662,6 +1680,86 @@ def _source_metric_item( ) +def _leaky_parameters( + profiles: ScalarModeBasis, + plane: FieldPlane, + incident: Float[np.ndarray, " n_omega"], +) -> dict[str, float]: + r"""Return a capillary pair's leaky-field parameter and its uncovered share. + + At each bin the larger :math:`\delta = |\nu_n|c/(\omega a)` of the target and a + capillary source. ``leaky`` is its mean weighted by the incident energy, as the + other per-bin items are: the envelope's allowance is linear in + :math:`\delta`, so the weighted mean bounds the whole field's discrepancy over + the bins inside the range. ``uncovered`` is the incident energy share of the + bins beyond the range, which the envelope does not bound and the item counts + in full. + + Parameters + ---------- + profiles : solprop.coupling.models.ScalarModeBasis + The target's capillary profiles. + plane : solprop.coupling.plane.FieldPlane + The source plane. + incident : numpy.ndarray + Incident energy per bin, J. + + Returns + ------- + dict + ``leaky`` and ``uncovered``, dimensionless. + """ + omega = plane.grid.omega + delta = cast("np.ndarray", profiles.leaky(omega)) + source = None if plane.profiles is None else plane.profiles.leaky(omega) + if source is not None: + delta = np.maximum(delta, source) + beyond = delta > CAPILLARY_ENVELOPE.ranges["leaky"][1] + return { + "leaky": _weighted(delta, incident), + "uncovered": _weighted(beyond.astype(float), incident), + } + + +def _uncovered_v_share( + source: ScalarModeBasis, + target: ScalarModeBasis, + omega: Float[np.ndarray, " n_omega"], + incident: Float[np.ndarray, " n_omega"], +) -> float: + r"""Return the incident share of bins where either fibre's V leaves the range. + + The profiles are frozen at the profile wavelength, which the envelope's range + must contain, but the vector physics it bounds depends on the actual + :math:`V(\omega) = (\omega a/c)\sqrt{n_{\rm core}^2 - n_{\rm clad}^2}` at + each bin. A bin where either fibre's V is outside the range is not bounded by + the envelope, so its share of the incident energy is counted in full. + + Parameters + ---------- + source, target : solprop.coupling.models.ScalarModeBasis + The two LP sets, with their core and cladding indices. + omega : numpy.ndarray + Absolute angular frequency of each bin, rad/s. + incident : numpy.ndarray + Incident energy per bin, J. + + Returns + ------- + float + Dimensionless. + """ + beyond = np.zeros(omega.shape, bool) + for name, basis in (("v_source", source), ("v_target", target)): + core = cast("np.ndarray", basis.core_index) + cladding = cast("np.ndarray", basis.cladding_index) + aperture = np.sqrt(np.maximum(core**2 - cladding**2, 0.0)) + v = omega * basis.radius * aperture / C + low, high = STEP_INDEX_ENVELOPE.ranges[name] + beyond |= (incident > 0.0) & ((v < low) | (v > high)) + return _weighted(beyond.astype(float), incident) + + def _contrast_items( profiles: ScalarModeBasis, plane: FieldPlane, @@ -1737,6 +1835,7 @@ def _contrast_items( "weak-guidance screen Delta" + ("" if within else f" > model_error/8, replaced by {envelope.identity}"), ) + uncovered = parameters.get("uncovered", 0.0) outside = envelope.covers(parameters) if outside is not None: msg = ( @@ -1748,11 +1847,16 @@ def _contrast_items( screen, AdmissionItem( "envelope", - envelope.bound, + envelope.allowance(parameters) + uncovered, None, True, "model", - envelope.identity, + envelope.identity + + ( + f", plus the {uncovered:.1e} incident share outside its range" + if uncovered > 0.0 + else "" + ), ), ] @@ -2130,7 +2234,8 @@ def _spatial_angular( None, True, "field", - "incident field: (1 - cos theta*) cone + tail", + "incident field: cone-rule allocation (1 - cos theta*) = model_error/8 " + "of the cone, not a measured defect, plus the tail", ) @@ -2363,7 +2468,8 @@ def _modal_angular( # noqa: PLR0913 - the source set, its field and the measure None, True, "field", - "incident field: (1 - cos theta*) cone + tail", + "incident field: cone-rule allocation (1 - cos theta*) = model_error/8 " + "of the cone, not a measured defect, plus the tail", ) @@ -2617,5 +2723,6 @@ def _target_angular_vector( None, True, "map", - "retained target subspace: (1 - cos theta*) cone + tail", + "retained target subspace: cone-rule allocation (1 - cos theta*) = " + "model_error/8 of the cone, not a measured defect, plus the tail", ) diff --git a/src/solprop/coupling/envelopes.py b/src/solprop/coupling/envelopes.py index b5fed934b..1d2d3e329 100644 --- a/src/solprop/coupling/envelopes.py +++ b/src/solprop/coupling/envelopes.py @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: GPL-3.0-or-later -"""Recorded benchmark envelopes of the scalar coupling model's provider pairs. +r"""Recorded benchmark envelopes of the scalar coupling model's provider pairs. SEP 31 section 4.3 admits a provider pair to an approximate coupling model only with an independent benchmark envelope for what the model omits, recorded with its @@ -11,11 +11,11 @@ oracles in ``tests/coupling/test_envelopes.py``, which recompute them and hold the recorded bounds; the manual page ``docs/guide/workflows/coupling.md`` describes them. -- :data:`CAPILLARY_ENVELOPE`: the scalar J0 overlap the model uses against the overlap - of the capillary's own vector Marcatili mode fields - (:meth:`solprop.modes.capillary.MarcatiliMode.field` and its normalisation) on an - independent two-dimensional quadrature, for centred, displaced and tilted - Gaussian beams and for a change of capillary radius. +- :data:`CAPILLARY_ENVELOPE`: the scalar J0 profile the model projects onto against + Marcatili's leaky HE1m core field :math:`J_0(u_m(1 - i\delta)r/a)`, + :math:`\delta = |\nu_n|c/(\omega a)`, the physics the scalar model omits, swept + over centred, displaced and tilted Gaussian beams and changes of capillary radius. + Its bound scales linearly with the pair's :math:`\delta`. - :data:`STEP_INDEX_ENVELOPE`: the weak-guidance LP01 overlap of two step-index fibres of one material pair and different radii against the full-vector flux overlap of their exact bound HE11 fields @@ -52,6 +52,10 @@ class PairEnvelope: in `bound`. reference : str What the independent reference is. + scaled_by : str or None, optional + A range parameter the discrepancy grows with: the allowance is then `bound` + times that parameter over its range's upper end (:meth:`allowance`). None + (the default) holds `bound` over the whole range. """ identity: str @@ -60,6 +64,7 @@ class PairEnvelope: bound: float numerical: float reference: str + scaled_by: str | None = None def __post_init__(self) -> None: """Hold the ranges read-only.""" @@ -88,24 +93,56 @@ def covers(self, parameters: Mapping[str, float]) -> str | None: ) return None + def allowance(self, parameters: Mapping[str, float]) -> float: + """Return the envelope's energy allowance for a covered pair. + + Parameters + ---------- + parameters : collections.abc.Mapping + The pair's value of each range parameter. + + Returns + ------- + float + Dimensionless: `bound`, scaled linearly by `scaled_by` when it is set. + + Examples + -------- + >>> from solprop.coupling import CAPILLARY_ENVELOPE as envelope + >>> high = envelope.ranges["leaky"][1] + >>> envelope.allowance({"leaky": 0.5 * high}) == 0.5 * envelope.bound + True + """ + if self.scaled_by is None: + return self.bound + return self.bound * parameters[self.scaled_by] / self.ranges[self.scaled_by][1] + CAPILLARY_ENVELOPE = PairEnvelope( - identity="capillary-he1m-scalar-vs-marcatili-2026-10", + identity="capillary-he1m-scalar-vs-leaky-marcatili-2026-10", family="capillary", - ranges={"modes": (1.0, 8.0), "radius_ratio": (0.5, 2.0)}, - bound=1e-12, - numerical=1e-13, + ranges={"modes": (1.0, 8.0), "radius_ratio": (0.5, 2.0), "leaky": (0.0, 1e-2)}, + bound=6e-3, + numerical=1e-10, reference=( - "overlap of MarcatiliMode.field (both transverse components, normalised by " - "MarcatiliMode.normalisation) on an independent polar Gauss-Legendre " - "quadrature; Gaussian waists 0.3a to 1.0a, displacement to 0.2a and tilt to " - "1 mrad at 800 nm in a 75 um argon capillary with eight HE1m modes, and " - "capillary radius ratios 0.5 to 2; largest difference 1.8e-14 of the " - "incident energy, the reference quadrature's own rounding" + "Marcatili's leaky HE1m core field J0(u_m (1 - i delta) r/a), delta = " + "|nu_n| c/(omega a), against the scalar J0 profile, both overlapped on an " + "independent polar Gauss-Legendre rule at delta = 1e-2 (the larger of a " + "pair's two capillaries): Gaussian beams of waist 0.3a to 1.0a, displaced to " + "0.2a, tilted to 0.59/(k a) (1 mrad at a = 75 um and 800 nm), 1, 2, 4 and 8 " + "HE1m modes, and coherent three-mode capillary exits into 8 modes at radius " + "ratios 0.5 to 2. The discrepancy is the larger of the summed per-mode share " + "changes and the least-squares captured share change: largest 2.9e-3, at " + "equal radii (the leaky modes' nonorthogonality); bound twice that. It " + "falls at least linearly with delta (halving delta halves the beam cases and " + "quarters the radius cases), so the allowance is scaled by delta/1e-2" ), + scaled_by="leaky", ) """The capillary HE1m envelope; ``radius_ratio`` is source over target radius (1 for -an incident beam), ``modes`` the larger HE1m count of the pair.""" +an incident beam), ``modes`` the larger HE1m count of the pair, ``leaky`` the larger +:meth:`~solprop.coupling.models.ScalarModeBasis.leaky` of the pair over the occupied +bins (about 5.0e-3 for a 37.5 um silica capillary at 800 nm).""" STEP_INDEX_ENVELOPE = PairEnvelope( diff --git a/src/solprop/coupling/models.py b/src/solprop/coupling/models.py index 2a44b00e3..2ace8c6e6 100644 --- a/src/solprop/coupling/models.py +++ b/src/solprop/coupling/models.py @@ -47,6 +47,7 @@ from scipy.optimize import brentq from scipy.special import j0, j1, jn_zeros, k0, k0e, k1, k1e +from solprop.constants import C from solprop.exceptions import CouplingError from solprop.grid import TimeGrid from solprop.modes.capillary import MarcatiliMode @@ -484,6 +485,12 @@ class ScalarModeBasis: cladding_index : numpy.ndarray or None, optional Real cladding index at each bin of a step-index basis, shape ``(n_omega,)``. + wall_term : numpy.ndarray or None, optional + :math:`|\nu_n|` of a capillary's HE modes at each bin, shape + ``(n_omega,)``: the Marcatili wall term + :math:`\nu_n = (\varepsilon + 1)/(2\sqrt{\varepsilon - 1})` of the wall + permittivity :math:`\varepsilon`; zero off the active positive band. It + sets the leaky field's departure from the scalar profile (:meth:`leaky`). identity : str, optional A short description of the source modes, for reports. @@ -502,6 +509,7 @@ class ScalarModeBasis: effective_index: Float[np.ndarray, "n_modes n_omega"] core_index: Float[np.ndarray, " n_omega"] | None = None cladding_index: Float[np.ndarray, " n_omega"] | None = None + wall_term: Float[np.ndarray, " n_omega"] | None = None identity: str = "" _norms: Float[np.ndarray, " n_modes"] = field(init=False, repr=False) @@ -525,7 +533,7 @@ def __post_init__(self) -> None: raise CouplingError(msg) index.setflags(write=False) object.__setattr__(self, "effective_index", index) - for name in ("core_index", "cladding_index"): + for name in ("core_index", "cladding_index", "wall_term"): value = getattr(self, name) if value is not None: array = np.array(value, dtype=np.float64, copy=True) @@ -547,6 +555,37 @@ def __repr__(self) -> str: f"a={self.radius:.6g} m)" ) + def leaky( + self, omega: Float[np.ndarray, " n_omega"] + ) -> Float[np.ndarray, " n_omega"] | None: + r"""Return a capillary's leaky-field parameter at each bin. + + Marcatili's leaky HE1m core field is :math:`J_0(u_m(1 - i\delta)r/a)` with + :math:`\delta = \nu_n c/(\omega a)`, where the scalar profile has + :math:`\delta = 0` (Marcatili and Schmeltzer 1964, + doi:10.1002/j.1538-7305.1964.tb04108.x). Its energy-weighted mean over a + coupling's occupied bins is the range parameter of + :data:`~solprop.coupling.envelopes.CAPILLARY_ENVELOPE`. + + Parameters + ---------- + omega : numpy.ndarray + Absolute angular frequency of each bin, rad/s, shape ``(n_omega,)``. + + Returns + ------- + numpy.ndarray or None + :math:`|\nu_n|c/(\omega a)`, dimensionless, shape ``(n_omega,)``, zero + off the active positive band; None for a basis without a wall term (a + step-index one). + """ + if self.wall_term is None: + return None + live = (self.wall_term > 0.0) & (omega > 0.0) + values = np.zeros(omega.shape) + values[live] = self.wall_term[live] * C / (omega[live] * self.radius) + return values + @property def n_modes(self) -> int: """Return the number of channels. @@ -803,6 +842,8 @@ def _capillary( core[live] = 1.0 + np.real( np.asarray(modes[0].core_index_minus_one(grid.omega[live], z)) ) + wall = np.zeros(grid.n_omega) + wall[live] = np.abs(modes[0].sampled_terms(grid.omega[live]).cladding_term) radius = radii.pop() return cls( "capillary", @@ -813,6 +854,7 @@ def _capillary( jones, index, core, + wall_term=wall, identity=f"capillary HE1m x{len(modes)}, a={radius:.6g} m", ) diff --git a/tests/coupling/test_couple.py b/tests/coupling/test_couple.py index f2cfb94c3..11a1ad198 100644 --- a/tests/coupling/test_couple.py +++ b/tests/coupling/test_couple.py @@ -24,6 +24,7 @@ from solprop import fourier from solprop.constants import EPS0, C from solprop.coupling import ( + STEP_INDEX_ENVELOPE, BasisChange, CouplingModel, FieldPlane, @@ -942,3 +943,84 @@ def test_a_source_path_certificate_is_carried_beside_the_coupling(port): plain = couple(dataclasses.replace(plane, certificate=None), port, model=SCALAR) assert plain.report.certificate is None assert "path certificate" not in str(plain.report) + + +def test_a_displaced_tilted_beam_launches_with_the_overlap_s_phase(port): + """The launch's phase is that of the overlap, pinning the transform's sign. + + Every share is invariant under conjugating the field, and on every FreeGrid the + origin phase is exp(-i pi m) for either sign, so neither catches a sign error; + the phase of a coefficient does. A beam displaced by a distance that is not a + multiple of the spacing and tilted has complex overlaps + ``int J0(u_m r/a) E dA`` (optics convention), here by an independent polar + Gauss--Legendre rule; the optics-convention launch b_m = sqrt(n_eff) a_m has + the same phase, mode by mode. + """ + grid = port.grid + shift, tilt = 0.13 * RADIUS, 1.5e-3 + beam = cartesian_beam(grid, 0.5 * RADIUS, shift=shift, tilt=tilt) + plane = FieldPlane.from_analytic( + grid, + PlaneGeometry(0.0), + beam, + space=FreeGrid(4 * RADIUS, 128, 4 * RADIUS, 128), + medium=MEDIUM, + ) + k_bin = int(np.argmin(np.abs(grid.omega - grid.omega0))) + launched = couple(plane, port, model=SCALAR).launch.samples[:, 0, k_bin] + nodes, weights = np.polynomial.legendre.leggauss(300) + radii = 0.5 * RADIUS * (nodes + 1) + n_theta = 128 + theta = 2 * np.pi * (np.arange(n_theta) + 0.5) / n_theta + r = np.repeat(radii, n_theta) + t = np.tile(theta, radii.size) + area = np.repeat(0.5 * RADIUS * weights * radii, n_theta) * 2 * np.pi / n_theta + field = beam(r * np.cos(t), r * np.sin(t), grid.omega[k_bin : k_bin + 1])[:, 0, 0] + overlaps = np.array( + [np.sum(area * j0(u * r / RADIUS) * field) for u in jn_zeros(0, 4)] + ) + assert np.all(np.abs(np.angle(overlaps)) > 0.05) # genuinely complex + # Matched computations of one integral by two rules: measured 2e-14 rad. + np.testing.assert_allclose(np.angle(launched / overlaps), 0.0, atol=1e-9) + + +def test_band_bins_outside_an_envelope_s_range_are_counted_in_full(): + """V across the band, and a capillary's leaky parameter, beyond the envelope. + + The step-index profiles are frozen at 1064 nm, but V grows with frequency: the + bins where either fibre's V leaves the envelope's range are not bounded by it, + so their incident share is added to the envelope item. A capillary bin whose + leaky parameter exceeds the range is counted the same way. + """ + wavelength = 1064e-9 + aperture = math.sqrt(1.45**2 * 2 * 0.005) + target_radius = 2.35 / (2 * math.pi / wavelength * aperture) + source = step_index(0.95 * target_radius, 0.005) + target = step_index(target_radius, 0.005).input_port() + plane = modal_plane(source, [1.0]) + # A budget that holds the uncovered share (about a quarter of the energy). + report = couple( + plane, target, model=CouplingModel("scalar_paraxial", model_error=0.5) + ).report + grid = target.grid + v = grid.omega * target_radius * aperture / C + beyond = (report.incident > 0) & (v > 2.4) + share = float(np.sum(report.incident[beyond]) / report.incident_energy) + assert share > 1e-3 # the blue side of the band: V above 2.4 + item = next(item for item in report.items if item.name == "envelope") + # The same weighted sum in another order (the indices are constant here). + assert item.value == pytest.approx(STEP_INDEX_ENVELOPE.bound + share, rel=1e-9) + assert "outside its range" in item.note + narrow = capillary(radius=21e-6, modes=1).input_port() + wide = FieldPlane.from_analytic( + narrow.grid, + PlaneGeometry(0.0), + radial_beam(narrow.grid, 12e-6), + space=RadialGrid(150e-6, 128), + medium=MEDIUM, + ) + leaky_report = couple( + wide, narrow, model=CouplingModel("scalar_paraxial", model_error=0.2) + ).report + leaky_item = next(item for item in leaky_report.items if item.name == "envelope") + assert "outside its range" in leaky_item.note diff --git a/tests/coupling/test_envelopes.py b/tests/coupling/test_envelopes.py index 0a3276d31..ccfce6910 100644 --- a/tests/coupling/test_envelopes.py +++ b/tests/coupling/test_envelopes.py @@ -2,16 +2,19 @@ # # SPDX-License-Identifier: GPL-3.0-or-later -"""The oracles behind the recorded coupling envelopes (SEP 31, section 4.3). +r"""The oracles behind the recorded coupling envelopes (SEP 31, section 4.3). Each recorded :class:`~solprop.coupling.envelopes.PairEnvelope` is recomputed here from an independent reference and held to its bound: -- the capillary envelope: the scalar model's captured shares (``couple`` on sampled - planes, through the closed-form Hankel transforms) against the overlap of the - capillary's own :meth:`~solprop.modes.MarcatiliMode.field` with its - :meth:`~solprop.modes.MarcatiliMode.normalisation`, both transverse components, - on an independent polar Gauss--Legendre quadrature; +- the capillary envelope: the scalar J0 profiles against Marcatili's leaky HE1m core + field :math:`J_0(u_m(1 - i\delta)r/a)`, the physics the scalar model omits, on an + independent polar Gauss--Legendre rule over the envelope's declared range. Beside + it, two consistency checks hold the implementation (``couple`` on sampled planes, + through the closed-form Hankel transforms) to the overlap of + :meth:`~solprop.modes.MarcatiliMode.field` by quadrature. ``MarcatiliMode.field`` + of an HE1m mode *is* the scalar J0 profile, so those checks bound the quadrature + and the code, not the model; - the step-index envelope: the weak-guidance LP01 overlap the scalar model uses against the full-vector flux overlap of the exact bound HE11 fields (:meth:`~solprop.modes.StepIndexMode.electromagnetic_field`). @@ -24,6 +27,7 @@ import numpy as np import pytest +from scipy.special import jn_zeros, jv from solprop import fourier from solprop.constants import EPS0, C @@ -39,6 +43,7 @@ ) from solprop.coupling.models import radial_rule from solprop.driver import Simulation +from solprop.exceptions import CouplingError from solprop.grid import EnvelopeGrid, FreeGrid from solprop.modes import MarcatiliMode, StepIndexMode from solprop.spatial import SpatialMedium @@ -139,14 +144,17 @@ def capillary_port(): (0.5, 0.2, 1e-3), ], ) -def test_the_capillary_envelope_bounds_the_scalar_model_against_marcatili( +def test_the_scalar_capillary_overlap_matches_marcatili_fields_by_quadrature( capillary_port, waist, shift, tilt ): - """Scalar shares of a Gaussian against Marcatili's own fields, within the bound. + """Scalar shares of a Gaussian against Marcatili's own fields: a consistency check. The beam is sampled on a Cartesian plane (so displacement and tilt are kept); - the reference integrates the analytic beam against ``MarcatiliMode.field``. - The comparison is at the peak bin, in the common measure, share by share. + the reference integrates the analytic beam against ``MarcatiliMode.field``, + which for HE1m is the same J0 profile, so this checks the closed-form transforms + and the fit against an independent quadrature, not the scalar model's physics + (the leaky-field oracle below bounds that). The comparison is at the peak bin, + in the common measure, share by share. """ port = capillary_port grid = port.grid @@ -176,19 +184,23 @@ def test_the_capillary_envelope_bounds_the_scalar_model_against_marcatili( g = index / profiles.effective_index[:, k_bin] weights = fourier.energy_weights(grid.n_omega, grid.dt, kind=grid.kind) shares = weights[k_bin] * g * np.abs(b) ** 2 / result.report.incident[k_bin] - # The bound covers the envelope; the plane's own representation error of the - # sampled beam (its numerical item) is added, as the admission adds it. + # The same profile on both sides: the difference is quadrature and the plane's + # own representation error of the sampled beam (its numerical item); measured + # 1.8e-14 of the incident energy, the reference rule's rounding. numerical = next( item.value for item in result.report.items if item.name == "numerical" ) assert numerical is not None - allowance = CAPILLARY_ENVELOPE.bound + 4 * numerical + 1e-9 + allowance = 1e-12 + 4 * numerical + 1e-9 assert np.max(np.abs(shares - expected)) <= allowance @pytest.mark.parametrize("ratio", [0.5, 0.8, 1.25, 2.0]) -def test_the_capillary_envelope_bounds_a_change_of_radius(ratio): - """A capillary's HE1m coefficients into another's against Marcatili's fields. +def test_a_scalar_change_of_radius_matches_marcatili_fields_by_quadrature(ratio): + """A capillary's HE1m coefficients into another's: a consistency check. + + As above, ``MarcatiliMode.field`` is the scalar profile, so the comparison bounds + the direct overlap's quadrature, not the model. The source carries a coherent mixture of its first three modes; the reference overlaps ``MarcatiliMode.field`` of both capillaries on a polar rule broken at @@ -243,7 +255,187 @@ def test_the_capillary_envelope_bounds_a_change_of_radius(ratio): expected_b = np.array(expected) / index assert ( np.max(np.abs(np.abs(launched) ** 2 - np.abs(expected_b) ** 2)) - <= (CAPILLARY_ENVELOPE.bound + 1e-12) * scale**2 + <= 1e-12 * scale**2 + ) + + +# ------------------------------------------------------ the leaky-field oracle + +HE_ZEROS = jn_zeros(0, 8) +"""The first eight J0 zeros, the HE1m core wavenumbers u_m.""" + +LEAKY_WALL_TERM = 1.477 +"""|nu_n| of silica (index 1.45): (eps + 1)/(2 sqrt(eps - 1)), sizing k a only.""" + + +def polar_disc(radius, n_r, n_theta): + """Return a Gauss--Legendre (r) by uniform (theta) rule on a disc, with weights.""" + x, weights = np.polynomial.legendre.leggauss(n_r) + r = 0.5 * radius * (x + 1) + radial = 0.5 * radius * weights * r + theta = 2 * np.pi * (np.arange(n_theta) + 0.5) / n_theta + return ( + np.repeat(r, n_theta), + np.tile(theta, n_r), + np.repeat(radial, n_theta) * 2 * np.pi / n_theta, + ) + + +def leaky_profiles(radius, delta, count, r): + """Return Marcatili's HE1m core fields J0(u (1 - i delta) r / a), zero outside.""" + return np.array( + [ + jv(0, u * (1 - 1j * delta) * r / radius) * (r <= radius) + for u in HE_ZEROS[:count] + ] + ) + + +def projected_shares(profiles, field, weights, field_norm): + """Return the per-mode shares and the least-squares captured share of a field.""" + gram = (profiles.conj() * weights) @ profiles.T + overlaps = (profiles.conj() * weights) @ field + per_mode = np.abs(overlaps) ** 2 / np.real(np.diag(gram)) / field_norm + total = np.real(overlaps.conj() @ np.linalg.solve(gram, overlaps)) / field_norm + return per_mode, total + + +def discrepancy(scalar, leaky): + """Return the larger of the summed per-mode and the total share changes.""" + return max( + float(np.sum(np.abs(leaky[0] - scalar[0]))), float(abs(leaky[1] - scalar[1])) + ) + + +def beam_discrepancy(delta, beam, n_r=100, n_theta=32): + """Return a Gaussian beam's share discrepancy, leaky against scalar, a = 1. + + ``beam`` is (HE1m count, waist/a, displacement/a, tilt angle times k a). + """ + count, waist, shift, tilt_ka = beam + r, t, w = polar_disc(1.0, n_r, n_theta) + x, y = r * np.cos(t), r * np.sin(t) + field = np.exp(-((x - shift) ** 2 + y**2) / waist**2) * np.exp(1j * tilt_ka * x) + norm = math.pi * waist**2 / 2 + return discrepancy( + projected_shares(leaky_profiles(1.0, 0.0, count, r), field, w, norm), + projected_shares(leaky_profiles(1.0, delta, count, r), field, w, norm), + ) + + +def radius_discrepancy(delta, ratio, n_r=100, n_theta=32): + """Return a coherent three-mode exit's discrepancy into eight modes at a ratio. + + ``delta`` is the larger of the two capillaries' parameters; it scales as 1/a. + """ + r, _, w = polar_disc(max(1.0, ratio), n_r, n_theta) + shares = [] + for scale in (0.0, delta): + source_delta, target_delta = ( + (scale, scale * ratio) if ratio < 1 else (scale / ratio, scale) + ) + source = leaky_profiles(ratio, source_delta, 3, r) + source = source / np.sqrt(np.sum(w * np.abs(source) ** 2, axis=1))[:, None] + field = source.sum(axis=0) + norm = float(np.sum(w * np.abs(field) ** 2)) + shares.append( + projected_shares(leaky_profiles(1.0, target_delta, 8, r), field, w, norm) + ) + return discrepancy(*shares) + + +BEAMS = [ + (count, waist, shift, tilt) + for count in (1, 2, 4, 8) + for waist in (0.3, 0.5, 0.7, 1.0) + for shift in (0.0, 0.1, 0.2) + for tilt in (0.0, 0.59) +] +"""The envelope's beams: HE1m count, waist/a, displacement/a, tilt angle times k a.""" + +RATIOS = (0.5, 0.8, 1.0, 1.25, 2.0) + + +def worst_leaky_discrepancy(delta, **rule): + """Return the largest discrepancy over the envelope's beams and radius ratios.""" + beams = max(beam_discrepancy(delta, case, **rule) for case in BEAMS) + radii = max(radius_discrepancy(delta, ratio, **rule) for ratio in RATIOS) + return beams, radii + + +@pytest.mark.medium +def test_the_capillary_envelope_bounds_the_leaky_core_field(): + """The scalar J0 model against Marcatili's leaky HE1m field, over the range. + + At the range's top, delta = 1e-2 (a 37.5 um silica capillary at 1.6 um, or + 18.8 um at 800 nm), the largest discrepancy is 2.9e-3 (equal radii: the leaky + modes are not orthogonal) and 1.8e-3 for the beams; the recorded bound is twice + the largest. Halving delta halves the beam cases (first order, from the tilt and + displacement) and quarters the radius cases, which is what lets the allowance + scale linearly with delta. The quadrature's numerical error is estimated by + refining the rule. + """ + top = CAPILLARY_ENVELOPE.ranges["leaky"][1] + beams, radii = worst_leaky_discrepancy(top) + measured = max(beams, radii) + assert measured == pytest.approx(2.9e-3, rel=0.05) + assert 2 * measured <= CAPILLARY_ENVELOPE.bound + fine_beams, fine_radii = worst_leaky_discrepancy(top, n_r=200, n_theta=48) + assert abs(max(fine_beams, fine_radii) - measured) <= CAPILLARY_ENVELOPE.numerical + half_beams, half_radii = worst_leaky_discrepancy(0.5 * top) + # At most linear below the top: the allowance's scaling is conservative there. + assert half_beams <= 0.55 * beams + assert half_radii <= 0.3 * radii + assert CAPILLARY_ENVELOPE.allowance({"leaky": 0.5 * top}) >= 2 * max( + half_beams, half_radii + ) + + +def test_a_capillary_beyond_the_leaky_range_is_refused(): + """A capillary beyond the leaky range is refused, not extrapolated.""" + port = capillary_simulation(12e-6, 1, GRID).input_port() + profiles = port.profiles + assert profiles is not None + leaky = profiles.leaky(port.grid.omega) + assert leaky is not None + centre = int(np.argmin(np.abs(port.grid.omega - port.grid.omega0))) + # A 12 um silica capillary at 800 nm: 1.475 c/(omega a) = 1.57e-2. + assert leaky[centre] == pytest.approx(1.57e-2, rel=0.01) + plane = FieldPlane.from_analytic( + port.grid, + PlaneGeometry(0.0), + gaussian_field(port.grid, 6e-6), + space=FreeGrid(60e-6, 64, 60e-6, 64), + medium=SpatialMedium("Ar", PRESSURE), + ) + with pytest.raises(CouplingError, match="leaky"): + couple(plane, port, model=CouplingModel("scalar_paraxial", model_error=0.2)) + + +def test_the_capillary_envelope_item_scales_with_the_pair(capillary_port): + """The reported envelope item is the bound scaled by the energy-weighted delta.""" + port = capillary_port + plane = FieldPlane.from_analytic( + port.grid, + PlaneGeometry(0.0), + gaussian_field(port.grid, 0.5 * RADIUS), + space=FreeGrid(4 * RADIUS, 64, 4 * RADIUS, 64), + medium=SpatialMedium("Ar", PRESSURE), + ) + report = couple( + plane, port, model=CouplingModel("scalar_paraxial", model_error=0.1) + ).report + profiles = port.profiles + assert profiles is not None + delta = profiles.leaky(port.grid.omega) + assert delta is not None + weighted = float(np.sum(delta * report.incident) / np.sum(report.incident)) + # A 75 um silica capillary at 800 nm: 1.475 c/(omega a) = 2.5e-3. + assert weighted == pytest.approx(2.5e-3, rel=0.02) + item = next(item for item in report.items if item.name == "envelope") + # The same weighted sum, formed in another order: rounding only. + assert item.value == pytest.approx( + CAPILLARY_ENVELOPE.allowance({"leaky": weighted}), rel=1e-12 ) From 192216560536137244ab72cb6193aa3091230b25 Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 02:49:29 +0100 Subject: [PATCH 28/50] Tests: the eight SEP 31 compositions as short integrated runs Capillary into capillary (direct and outcouple -> gap -> recouple), a Kerr gas gap into a capillary, a field-grid Kerr/plasma/THG gap whose harmonic band is transferred with its energy and reference, fibre into fibre, a Gaussian, an analytic chromatic-waist field and a displaced, tilted Cartesian beam into a capillary, the far field of a capillary exit, and SPM -> compressor -> 4f relay with an aperture -> a second capillary. Each asserts the energy ledger; several pair numpy_ref or numba_cpu sources with native receivers. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019qNYqtCKh4iTKj7544yARb --- tests/coupling/test_workflows.py | 759 +++++++++++++++++++++++++++++++ 1 file changed, 759 insertions(+) create mode 100644 tests/coupling/test_workflows.py diff --git a/tests/coupling/test_workflows.py b/tests/coupling/test_workflows.py new file mode 100644 index 000000000..5a3a58f5c --- /dev/null +++ b/tests/coupling/test_workflows.py @@ -0,0 +1,759 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""The eight compositions of SEP 31 section 2, each as a short integrated run. + +Each composition joins ordinary simulations through field planes: extraction +(``field_plane``), transport, coupling (``couple``) and a launch (``with_input``) +into a receiving run of about ten steps. Every test asserts the energy ledger of +what it composed -- the incident, captured, uncaptured and launched energies and the +transport ledger -- that the reported quantities are the ones the model defines, and +that no energy is restored: a launch never exceeds its incident energy beyond the +coupling's reported defects, and the receiving run's entrance carries exactly the +launch. The mandatory field-grid Kerr/plasma/THG case checks that the generated +third-harmonic band is transferred with its energy and its reference. + +Several compositions pair different backends: the source on ``numpy_ref`` or +``numba_cpu``, the receiver on the native CPU backend when it is installed (else +``numba_cpu``). The physics of each piece is qualified by its own module's tests; +these check the seams between them. +""" + +import math +from collections.abc import Mapping +from typing import cast + +import numpy as np +import pytest +from scipy.integrate import quad +from scipy.special import j0, jn_zeros, k0 + +from solprop import fourier +from solprop.backends.registry import available +from solprop.constants import C +from solprop.coupling import ( + Alignment, + Aperture, + CouplingModel, + CouplingReport, + FieldPlane, + FreeSpace, + Outcouple, + PlaneGeometry, + SpectralTransfer, + ThinLens, + couple, + transport, +) +from solprop.coupling.transport import TransportReport +from solprop.driver import Simulation, SimulationSettings +from solprop.exceptions import CouplingError +from solprop.grid import EnvelopeGrid, FieldGrid, FreeGrid, RadialGrid +from solprop.modes import StepIndexMode +from solprop.nonlinear.optmat_rates import OptmatRate +from solprop.nonlinear.responses import KerrResponse, PlasmaResponse +from solprop.physdata import gamma3 +from solprop.processing import field_plane +from solprop.pulses import GaussianPulse, SpatialPulse +from solprop.spatial import SpatialMedium +from solprop.stepper import PIController + +pytestmark = pytest.mark.medium + +RECEIVER = "native" if "native" in available() else "numba_cpu" +"""The receiving runs' backend in the mixed-backend compositions.""" + +PRESSURE = 3.0 +ARGON = SpatialMedium("Ar", PRESSURE) +SCALAR = CouplingModel("scalar_paraxial", model_error=0.05) +ENVELOPE_GRID = { + "lambda0": 800e-9, + "wavelength_limits": (700e-9, 920e-9), + "trange": 200e-15, +} +LAUNCH_ROUNDING = 1e-12 +"""Relative agreement of a run's entrance-plane energy with the launch's. + +The entrance plane stores the launched coefficients; the two energies are the +same weighted sum in another order, so they agree to its rounding (measured 3e-16). +""" + + +def capillary( + radius=75e-6, + modes=4, + *, + length=1e-3, + energy=1e-6, + backend="numpy_ref", + **changes, +): + """Return a short argon capillary on the tests' envelope grid.""" + arguments = { + "modes": modes, + "n_save": 2, + "noise": None, + "envelope": True, + "energy": energy, + "fwhm": 30e-15, + "plasma": False, + "backend": backend, + **ENVELOPE_GRID, + **changes, + } + return Simulation.from_capillary(radius, length, "Ar", PRESSURE, **arguments) + + +def assert_coupled(report: CouplingReport, received=None) -> None: + """Assert a scalar coupling's ledger, and the receiving run's entrance. + + The scalar model's captured energy lives in a common positive metric, so it is + reported with its uncaptured complement, which is never clipped: the + orthogonal-residual identity makes both nonnegative per bin, and they sum to + the incident energy. The launch is the captured coordinates in the target's + native metric; it may differ from the captured energy only by the reported + target-metric defect, never by a renormalisation to the incident energy. + """ + assert report.model == "scalar_paraxial" + assert report.captured is not None and report.captured_reason == "" + uncaptured = report.uncaptured + assert uncaptured is not None + scale = float(np.max(report.incident)) + # The residual identity holds to the fit's rounding per bin. + assert np.all(uncaptured >= -1e-12 * scale) + captured, missing = report.captured_energy, report.uncaptured_energy + assert captured is not None and missing is not None + assert captured + missing == pytest.approx(report.incident_energy, rel=1e-12) + assert report.model_error is not None and report.budget <= report.model_error + target_metric = next(item for item in report.items if item.name == "target metric") + assert target_metric.value is not None + # Nothing restored: the launch is the captured energy up to the reported + # metric defect between the common measure and the target's native one. + assert report.launched_energy <= captured * (1 + target_metric.value) + 1e-30 + assert abs(report.launched_energy - captured) <= target_metric.value * captured + if received is not None: + entrance = field_plane(received, 0) + assert entrance.energy() == pytest.approx( + report.launched_energy, rel=LAUNCH_ROUNDING + ) + + +def assert_transported(report: TransportReport) -> None: + """Assert a transport ledger: passive, closed and with nothing restored.""" + assert report.energy_out <= report.energy_in + # Each element books its change as loss, removal or signed approximation + # error; the chain closes to the rounding of those sums. + assert abs(report.ledger_residual) <= 1e-12 * report.energy_in + # The signed approximation error is booked as energy gained, so the energy + # change is the losses and removals less it. + accounted = report.specified_loss + report.removed - report.approximation_error + assert report.energy_in - report.energy_out == pytest.approx( + accounted, rel=1e-9, abs=1e-12 * report.energy_in + ) + + +def band_energy(spectrum, omega, low, high): + """Return the energy of the bins with vacuum wavelength in [low, high], J.""" + wavelength = 2 * math.pi * C / np.where(omega > 0, omega, np.inf) + return float(np.sum(spectrum[(wavelength >= low) & (wavelength <= high)])) + + +# ---------------------------------------------------------------- (a) capillaries + + +@pytest.fixture(scope="module") +def capillary_exit(): + """Ten steps of a 75 um argon capillary, read with its simulation.""" + source = capillary(modes=3) + run = source.run(progress="none") + return source, field_plane(run, source=source) + + +def test_a_capillary_exit_into_a_wider_capillary_two_ways(capillary_exit): + """(a) Direct overlap, and outcouple -> 2 mm of argon -> recouple. + + Source on ``numpy_ref``, receiver on the native CPU backend. The direct overlap + and the synthesise-then-project route agree to the synthesis window's error + with no gap (the coupling module holds that); here a 2 mm argon gap between + the exit and the wider entrance lowers the capture (0.99962 direct, 0.99853 + through the gap, measured), and every step books its energy. + """ + source, plane = capillary_exit + wider = capillary(radius=100e-6, modes=6, backend=RECEIVER) + port = wider.input_port() + direct = couple(plane, port, model=SCALAR) + assert_coupled(direct.report) + # The incident energy in M0 is the exit's modal energy to the source-metric + # item (core index against effective index). + source_metric = next( + item for item in direct.report.items if item.name == "source metric" + ) + assert source_metric.value is not None + assert direct.report.incident_energy == pytest.approx( + plane.energy(), rel=2 * source_metric.value + ) + synthesised = Outcouple(RadialGrid(400e-6, 256), medium=ARGON).apply(plane) + moved, ledger = transport( + synthesised.plane, + [FreeSpace(2e-3, ARGON)], + frame=source.frame, + geometry=port.geometry, + ) + assert_transported(ledger) + assert moved.reference.delay > plane.reference.delay + through = couple(moved, port, model=SCALAR) + received = wider.with_input(through.launch).run(progress="none") + assert_coupled(through.report, received) + direct_share, gap_share = direct.report.efficiency, through.report.efficiency + assert direct_share is not None and gap_share is not None + assert 0.99 < gap_share < direct_share < 1.0 + # The launched run continues the plane's absolute time and carrier phase. + entrance = field_plane(received, 0) + assert entrance.reference == moved.reference + assert received.metadata is not None + record = cast("Mapping[str, object]", received.metadata.configuration["coupling"]) + assert record["model"] == "scalar_paraxial" + + +# ------------------------------------------------- (b) a nonlinear radial beam + + +def test_a_kerr_prepared_radial_beam_into_a_capillary(): + """(b) A Kerr gas gap on a radial grid, its entrance plane, couple, capillary. + + 5 mm of 3 bar argon on an envelope grid, the beam 37.5 um (w/a = 0.5), run on + ``numba_cpu``; the capillary receives on the native CPU backend. The same gap + without Kerr is the control: Kerr changes the fundamental's share (0.8715 + linear, 0.8805 with Kerr, measured), so the prepared field, not a fitted + Gaussian, is what the capillary receives. + """ + gap = 5e-3 + grid = EnvelopeGrid.from_limits(gap, 800e-9, (650e-9, 1000e-9), 300e-15, thg=False) + pulse = SpatialPulse( + GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=10e-6), 37.5e-6 + ) + settings = SimulationSettings( + controller=PIController(rtol=1e-6, atol=1e-12), + save_planes=[gap], + dz_max=gap / 10, + dz_init=gap / 10, + backend="numba_cpu", + ) + receiver = Simulation.from_capillary( + 75e-6, + 2e-3, + "Ar", + PRESSURE, + lambda0=800e-9, + grid=grid, + modes=4, + plasma=False, + thg=False, + noise=None, + energy=1e-9, + fwhm=30e-15, + n_save=2, + backend=RECEIVER, + ) + port = receiver.input_port() + weights = fourier.energy_weights(grid.n_omega, grid.dt, kind=grid.kind) + fundamental = {} + for name, responses in ( + ("linear", []), + ("kerr", [KerrResponse(gamma3("Ar", wavelength=800e-9), thg=False)]), + ): + gas = Simulation.from_radial( + grid, RadialGrid(4e-4, 96), ARGON, responses, pulse, settings=settings + ) + plane = field_plane(gas.run(progress="none"), source=gas) + coupled = couple(plane, port, model=SCALAR) + received = receiver.with_input(coupled.launch).run(progress="none") + assert_coupled(coupled.report, received) + # The radial plane's own energy is the incident energy to the + # source-metric item: M0 against the axial flux. + assert coupled.report.incident_energy == pytest.approx(plane.energy(), rel=1e-3) + launched = np.sum(weights * np.abs(coupled.launch.samples[:, 0, :]) ** 2, 1) + fundamental[name] = launched[0] / np.sum(launched) + assert fundamental["linear"] == pytest.approx(0.8715, abs=1e-3) + assert fundamental["kerr"] - fundamental["linear"] > 5e-3 + + +# ------------------------------------------- the mandatory Kerr/plasma/THG case + + +def test_a_field_grid_kerr_plasma_thg_entrance_is_transferred_with_its_harmonic(): + """A radial field-grid gap with Kerr, plasma and THG, then a capillary. + + 2 mm of 1 bar argon, a 40 uJ, 30 fs, 30 um beam (about 9e13 W/cm^2 on axis) on + a field grid whose band reaches 200 nm, so the third harmonic (near 267 nm) is + generated in the gap; the gap runs on ``numpy_ref``, the capillary on the native + CPU backend. The plasma takes 1.6e-3 of the energy (the Kerr-only gap keeps it + to 1e-8, measured), so the ionisation response acts. + + The harmonic band, 230-300 nm, carries 82 nJ at the entrance plane. Its + incident energy in the report is the plane's own band energy to the + source-metric item; the capillary captures 0.998 of it and the run's entrance + stores exactly the launch in that band. Its reference is transferred: the + capillary's entrance plane has the source plane's reference, and the harmonic's + energy-weighted arrival time in the launched modes is the source plane's to an + attosecond (measured 131 as and 132 as from the window centre). + """ + gap = 2e-3 + grid = FieldGrid.from_limits(gap, 800e-9, (200e-9, 1.6e-6), 150e-15) + settings = SimulationSettings( + controller=PIController(rtol=1e-6, atol=1e-14), + save_planes=[gap], + dz_max=gap / 10, + dz_init=gap / 10, + backend="numpy_ref", + ionisation_field_range=(1e7, 1e11), + ) + pulse = SpatialPulse( + GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=40e-6), 30e-6 + ) + responses = [ + KerrResponse(gamma3("Ar", wavelength=800e-9), thg=True), + PlasmaResponse("Ar", OptmatRate("Ar", 800e-9)), + ] + medium = SpatialMedium("Ar", 1.0) + gas = Simulation.from_radial( + grid, RadialGrid(3e-4, 64), medium, responses, pulse, settings=settings + ) + run = gas.run(progress="none") + energy = np.asarray(run["stats/energy"]) + absorbed = 1 - energy[-1] / energy[0] + assert 1e-3 < absorbed < 3e-3 + plane = field_plane(run, source=gas) + start = field_plane(run, 0, source=gas) + harmonic = (230e-9, 300e-9) + generated = band_energy(plane.spectral_energy(), plane.omega, *harmonic) + assert generated > 1e3 * band_energy( + start.spectral_energy(), start.omega, *harmonic + ) + assert 5e-8 < generated < 1.2e-7 + receiver = Simulation.from_capillary( + 75e-6, + 1e-3, + "Ar", + 1.0, + lambda0=800e-9, + grid=grid, + modes=4, + plasma=False, + noise=None, + energy=1e-9, + fwhm=30e-15, + n_save=2, + backend=RECEIVER, + ) + coupled = couple(plane, receiver.input_port(), model=SCALAR) + report = coupled.report + received = receiver.with_input(coupled.launch).run(progress="none") + assert_coupled(report, received) + incident = band_energy(report.incident, plane.omega, *harmonic) + assert report.captured is not None + captured = band_energy(report.captured, plane.omega, *harmonic) + launched = band_energy(report.launched, plane.omega, *harmonic) + # M0 against the axial flux: the harmonic's obliquity is small (5e-6, measured). + assert incident == pytest.approx(generated, rel=1e-4) + assert 0.99 < captured / incident <= 1.0 + entrance = field_plane(received, 0) + stored = band_energy(entrance.spectral_energy(), entrance.omega, *harmonic) + assert stored == pytest.approx(launched, rel=LAUNCH_ROUNDING) + assert entrance.reference == plane.reference + # The harmonic band's energy-weighted arrival time, from the spectral phase + # slope: in the optics convention t = sum Im(conj(S) dS/domega) / sum |S|^2. + wavelength = 2 * math.pi * C / np.where(plane.omega > 0, plane.omega, np.inf) + band = (wavelength >= harmonic[0]) & (wavelength <= harmonic[1]) + + def arrival(samples, weights): + values = samples[:, 0, band] + slope = np.gradient(values, plane.omega[band], axis=-1) + return float( + np.sum(weights[:, None] * np.imag(np.conj(values) * slope)) + / np.sum(weights[:, None] * np.abs(values) ** 2) + ) + + space = plane.basis.space + assert isinstance(space, RadialGrid) + source_time = arrival(plane.samples, np.asarray(space.spectral_weights)) + launched_time = arrival(coupled.launch.samples, np.ones(4)) + # The coupling is a real, frequency-independent projection per mode: the + # band's timing changes only by the different radial weighting of the + # projection (measured 1.1 as of a 30 fs pulse). + assert abs(launched_time - source_time) < 1e-17 + # The harmonic propagates on in the capillary (it is still generated there). + exit_plane = field_plane(received) + assert band_energy(exit_plane.spectral_energy(), exit_plane.omega, *harmonic) > 0 + + +# ------------------------------------------------------------ (c) solid fibres + + +def lp01(radius, aperture, wavelength): + """Return the weak-guidance LP01 profile function and its U, W.""" + v = 2 * math.pi / wavelength * radius * aperture + from scipy.optimize import brentq # noqa: PLC0415 - only this oracle needs it + from scipy.special import j1, k1 # noqa: PLC0415 + + u = brentq( + lambda u: ( + u * j1(u) / j0(u) + - math.sqrt(v**2 - u**2) + * k1(math.sqrt(v**2 - u**2)) + / k0(math.sqrt(v**2 - u**2)) + ), + 0.1, + min(v, 2.4048) - 1e-9, + ) + w = math.sqrt(v**2 - u**2) + + def profile(r): + inside = j0(u * r / radius) + outside = j0(u) * k0(w * r / radius) / k0(w) + return np.where(r <= radius, inside, outside) + + return profile + + +def test_a_solid_fibre_into_another_fibre(): + """(c) The admitted weak-guidance pair: a step-index fibre into a wider one. + + Contrast 0.005, core radii 2.18 and 2.57 um at 1064 nm, 1 mm each on the GNLSE + route. The captured share is the LP01 overlap squared, here by an independent + quadrature of the weak-guidance profiles (0.9947 launched, measured). + """ + aperture = 1.45 * math.sqrt(1 - 0.99) + + def fibre(radius, backend): + mode = StepIndexMode( + radius, + core=1.45, + cladding=1.45 * math.sqrt(0.99), + profile_wavelength=1064e-9, + ) + return Simulation.from_gnlse( + None, + 1e-3, + fibre=mode, + n2=2.6e-20, + lambda0=1064e-9, + wavelength_limits=(900e-9, 1300e-9), + trange=400e-15, + fwhm=100e-15, + energy=1e-9, + n_save=2, + backend=backend, + ) + + source = fibre(2.18e-6, "numba_cpu") + target = fibre(2.57e-6, RECEIVER) + plane = field_plane(source.run(progress="none"), source=source) + model = CouplingModel("scalar_paraxial", model_error=0.1) + coupled = couple(plane, target.input_port(), model=model) + received = target.with_input(coupled.launch).run(progress="none") + report = coupled.report + assert report.captured is not None + first = lp01(2.18e-6, aperture, 1064e-9) + second = lp01(2.57e-6, aperture, 1064e-9) + + # The integrands are ~1e-12 m^2: quad's default absolute tolerance would + # swamp them, so only a relative one is set. + rule = {"points": [2.18e-6, 2.57e-6], "limit": 400, "epsabs": 0, "epsrel": 1e-13} + + def norm(profile): + return quad(lambda r: profile(r) ** 2 * r, 0, 30e-6, **rule)[0] + + overlap = quad(lambda r: first(r) * second(r) * r, 0, 30e-6, **rule)[0] + expected = overlap**2 / (norm(first) * norm(second)) + # Two quadratures of one integral; the model's own is closed form (1e-10 here). + captured = report.captured_energy + assert captured is not None + assert captured / report.incident_energy == pytest.approx(expected, rel=1e-8) + model_budget = report.model_error + assert model_budget is not None and report.budget <= model_budget + uncaptured = report.uncaptured_energy + assert uncaptured is not None and uncaptured > 0 + entrance = field_plane(received, 0) + assert entrance.energy() == pytest.approx( + report.launched_energy, rel=LAUNCH_ROUNDING + ) + assert report.launched_energy < report.incident_energy + + +# -------------------------------------------- (d) a Gaussian, (e) an analytic field + + +@pytest.fixture(scope="module") +def receiving_capillary(): + """A 75 um argon capillary with four HE1m modes, and its port.""" + simulation = capillary() + return simulation, simulation.input_port() + + +def test_a_gaussian_beam_into_a_capillary(receiving_capillary): + """(d) A Gaussian beam at w/a = 0.5 into four HE1m modes, then ten steps. + + The modal shares are the Gaussian--Bessel overlaps (0.906 and 0.092 of the + incident energy); the launch carries what the modes capture, not the + requested microjoule. + """ + simulation, port = receiving_capillary + beam = SpatialPulse( + GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=1e-6), 37.5e-6 + ) + incident = FieldPlane.from_pulse( + beam, port.grid, PlaneGeometry(0.0), space=RadialGrid(400e-6, 256), medium=ARGON + ) + coupled = couple(incident, port, model=SCALAR) + received = simulation.with_input(coupled.launch).run(progress="none") + assert_coupled(coupled.report, received) + # The beam's requested energy is its axial flux; the report's incident energy + # is M0 over the whole plane: they differ by the beam's obliquity. + assert coupled.report.incident_energy == pytest.approx(1e-6, rel=1e-4) + grid = port.grid + weights = fourier.energy_weights(grid.n_omega, grid.dt, kind=grid.kind) + launched = np.sum(weights * np.abs(coupled.launch.samples[:, 0, :]) ** 2, axis=1) + shares = launched / coupled.report.incident_energy + assert shares[0] == pytest.approx(0.906, abs=1e-3) + assert shares[1] == pytest.approx(0.092, abs=1e-3) + assert coupled.report.launched_energy < 1e-6 + + +def test_an_analytic_field_with_a_chromatic_waist_into_a_capillary( + receiving_capillary, +): + """(e) A nonseparable analytic field, waist 0.6a at 800 nm scaling as 1/omega. + + Sampled by ``FieldPlane.from_analytic``; the captured share differs from bin + to bin, and at each bin equals the four-mode Gaussian--Bessel overlap at that + bin's waist, by an independent Gauss--Legendre integral. + """ + simulation, port = receiving_capillary + grid = port.grid + radius = 75e-6 + waist = 0.6 * radius + width = 30e-15 / (2 * math.sqrt(math.log(2))) + + def field(r, omega): + local = waist * grid.omega0 / omega + spectral = 1e-9 * np.exp(-(((omega - grid.omega0) * width) ** 2) / 2) + return np.exp(-((r[:, None] / local[None, :]) ** 2)) * spectral[None, :] + + plane = FieldPlane.from_analytic( + grid, PlaneGeometry(0.0), field, space=RadialGrid(400e-6, 256), medium=ARGON + ) + assert plane.analytic is not None + coupled = couple(plane, port, model=SCALAR) + received = simulation.with_input(coupled.launch).run(progress="none") + assert_coupled(coupled.report, received) + numerical = next(item for item in coupled.report.items if item.name == "numerical") + assert numerical.value is not None + assert numerical.value >= plane.analytic.error + nodes, weights = np.polynomial.legendre.leggauss(400) + r = 0.5 * radius * (nodes + 1) + area = 0.5 * radius * weights * r + report = coupled.report + assert report.captured is not None + peak = int(np.argmax(report.incident)) + shares = [] + for k in (peak - 6, peak, peak + 6): + local = waist * grid.omega0 / grid.omega[k] + gaussian = np.exp(-((r / local) ** 2)) + expected = sum( + np.sum(area * gaussian * j0(u * r / radius)) ** 2 + / (local**2 / 4 * np.sum(area * j0(u * r / radius) ** 2)) + for u in jn_zeros(0, 4) + ) + share = report.captured[k] / report.incident[k] + # Two quadratures of the same overlaps: rounding (measured 3e-14). + assert share == pytest.approx(expected, abs=1e-10) + shares.append(share) + assert shares[0] != pytest.approx(shares[2], abs=1e-6) + + +# ------------------------------------------- (f) a Cartesian beam, no symmetry + + +def test_a_displaced_tilted_cartesian_beam_into_a_capillary(receiving_capillary): + """(f) A Cartesian beam displaced by 15 um and tilted by 2 mrad, then coupled. + + The alignment is a transport element on a Cartesian plane; on a radial plane it + is refused, since it would destroy the symmetry the grid imposes. The aligned + beam couples less into the HE1m modes (0.792 against 0.9994 centred, measured), + and its loss is the reported uncaptured energy, not restored. The same beam + built directly displaced and tilted couples identically to the plane's + representation error. + """ + simulation, port = receiving_capillary + grid = port.grid + beam = SpatialPulse( + GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=1e-6), 37.5e-6 + ) + space = FreeGrid(300e-6, 128, 300e-6, 128) + centred = FieldPlane.from_pulse( + beam, grid, PlaneGeometry(0.0), space=space, medium=ARGON + ) + alignment = Alignment(displacement=(15e-6, 0.0), tilt=(2e-3, 0.0), model_error=0.05) + aligned, ledger = transport(centred, [alignment]) + assert_transported(ledger) + coupled = couple(aligned, port, model=SCALAR) + received = simulation.with_input(coupled.launch).run(progress="none") + assert_coupled(coupled.report, received) + reference = couple(centred, port, model=SCALAR).report.efficiency + efficiency = coupled.report.efficiency + assert reference is not None and efficiency is not None + assert efficiency == pytest.approx(0.792, abs=2e-3) + assert reference - efficiency > 0.2 + uncaptured = coupled.report.uncaptured_energy + assert uncaptured is not None + assert uncaptured > 0.2 * coupled.report.incident_energy + width = 30e-15 / (2 * math.sqrt(math.log(2))) + index = float(np.max(np.asarray(port.reference_index))) + + def direct(x, y, omega): + k = index * omega / C + envelope = np.exp(-(((omega - grid.omega0) * width) ** 2) / 2) + spatial = np.exp(-((x - 15e-6) ** 2 + y**2) / 37.5e-6**2) + return spatial[:, None] * np.exp(1j * np.outer(x, k) * 2e-3) * envelope[None, :] + + built = FieldPlane.from_analytic( + grid, PlaneGeometry(0.0), direct, space=space, medium=ARGON + ) + rebuilt = couple(built, port, model=SCALAR).report.efficiency + assert rebuilt is not None + # The tilt's k uses each bin's own index in the element and the carrier's + # here; the shares agree to the plane's sampling and that difference. + assert rebuilt == pytest.approx(efficiency, abs=2e-3) + radial = FieldPlane.from_pulse( + beam, grid, PlaneGeometry(0.0), space=RadialGrid(400e-6, 128), medium=ARGON + ) + with pytest.raises(CouplingError, match="cylindrical symmetry"): + transport(radial, [Alignment(displacement=(15e-6, 0.0))]) + + +# ------------------------------------------------------- (g) a distant plane + + +def test_a_capillary_exit_at_a_distant_free_space_plane(capillary_exit): + """(g) Outcouple, then 0.1, 0.2 and 0.4 m of argon onto scaled output planes. + + The Fresnel output reaches any observation window. Each output grid is twice + the last on a twice longer path, so its nodes sit at the same angles: the + intensity pattern then converges to the Fraunhofer limit, the mismatch between + successive distances falling as the Fresnel number does. Energy is booked: + the far planes carry the exit's energy less the Fresnel route's reported + paraxial approximation error (2.1e-4 of it, measured). + """ + _, plane = capillary_exit + synthesised = Outcouple(RadialGrid(400e-6, 256), medium=ARGON).apply(plane) + centre = int(np.argmax(synthesised.synthesised)) + patterns = [] + for distance in (0.1, 0.2, 0.4): + far, ledger = transport( + synthesised.plane, + [ + FreeSpace( + distance, + ARGON, + output=RadialGrid(30e-3 * distance, 256), + output_method="fresnel", + ) + ], + ) + assert_transported(ledger) + assert far.geometry.z == pytest.approx(plane.geometry.z + distance) + assert abs(ledger.approximation_error) < 1e-3 * ledger.energy_in + assert far.energy() == pytest.approx(ledger.energy_out, rel=1e-12) + intensity = np.abs(far.node_spectra()[:, 0, centre]) ** 2 + patterns.append(distance**2 * intensity) + first = np.max(np.abs(patterns[1] - patterns[0])) / np.max(patterns[1]) + second = np.max(np.abs(patterns[2] - patterns[1])) / np.max(patterns[2]) + assert second < 0.6 * first + + +# ----------------------------------- (h) SPM, a compressor, focusing, a capillary + + +def test_spm_then_a_compressor_and_a_4f_relay_into_a_second_capillary(): + """(h) Capillary SPM -> compressor -> 4f relay with a Fourier-plane aperture. + + 20 mm of 3 bar argon at 100 uJ broadens the spectrum about sevenfold (measured + 6.9 in rms bandwidth) with positive chirp. The exit is synthesised, compressed + by -50 fs^2 (a ``SpectralTransfer`` of an optics ``Chain``), relayed 1:1 by two + 20 mm lenses with a 300 um tanh aperture in the Fourier plane, and coupled into + an identical capillary, which runs ten steps. The compressor is phase-only, so + its ledger entry is zero, and it raises the launched fundamental's peak power + (by 2.05, measured); the aperture's loss is booked as specified loss, the + windows' angular removals as removed energy. + """ + grid = { + "lambda0": 800e-9, + "wavelength_limits": (600e-9, 1100e-9), + "trange": 300e-15, + } + first = capillary(length=0.02, energy=100e-6, **grid) + run = first.run(progress="none") + start, plane = field_plane(run, 0), field_plane(run, source=first) + + def bandwidth(samples): + power = np.sum(np.abs(samples) ** 2, axis=(0, 1)) + omega = plane.omega + mean = np.sum(omega * power) / np.sum(power) + return math.sqrt(np.sum((omega - mean) ** 2 * power) / np.sum(power)) + + assert bandwidth(plane.samples) > 5 * bandwidth(start.samples) + second = capillary(energy=1e-9, backend=RECEIVER, **grid) + port = second.input_port() + synthesised = Outcouple(RadialGrid(1.5e-3, 512), medium=ARGON).apply(plane) + focal = 0.02 + + def gap(): + return FreeSpace(focal, ARGON, beyond_window="remove") + + relay = [ + gap(), + ThinLens(focal, model_error=0.05), + gap(), + Aperture(300e-6, model_error=0.05, edge=20e-6, kind="tanh"), + gap(), + ThinLens(focal, model_error=0.05), + gap(), + ] + peaks = {} + for name, gdd in (("uncompressed", 0.0), ("compressed", -50e-30)): + from solprop.optics import Chain, TaylorPhase # noqa: PLC0415 + + compressor = SpectralTransfer(Chain((TaylorPhase((0.0, 0.0, gdd), 800e-9),))) + relayed, ledger = transport( + synthesised.plane, + [compressor, *relay], + frame=first.frame, + geometry=port.geometry, + ) + assert_transported(ledger) + assert ledger.elements[0].energy_in == pytest.approx( + ledger.elements[0].energy_out, rel=1e-12 + ) + assert relayed.reference.calibrated == ("phase", "delay") + aperture = ledger.elements[4] + assert aperture.specified_loss > 0 + assert ledger.specified_loss == pytest.approx( + aperture.specified_loss, rel=1e-12 + ) + coupled = couple(relayed, port, model=SCALAR) + assert_coupled(coupled.report) + efficiency = coupled.report.efficiency + assert efficiency is not None and efficiency > 0.99 + field = np.fft.fft(coupled.launch.samples[0, 0, :]) + peaks[name] = float(np.max(np.abs(field) ** 2)) + if name == "compressed": + received = second.with_input(coupled.launch).run(progress="none") + assert_coupled(coupled.report, received) + assert received.metadata is not None + record = cast( + "Mapping[str, object]", received.metadata.configuration["coupling"] + ) + assert record["model"] == "scalar_paraxial" + assert peaks["compressed"] > 1.8 * peaks["uncompressed"] From 6abc0bb8380dc198cf30949dcf2ab04d57ef4058 Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 02:56:40 +0100 Subject: [PATCH 29/50] Docs: SEP 31 phase 5 -- compositions, conventions, accuracy, capability row The coupling chapter covers all eight compositions with the chaining rules and a runnable relay example, and states the corrected capillary envelope; conventions record the plane reference, affine phase and delay and the spectral-density scaling; accuracy says how a coupling's uncertainties are kept apart; backends gain a coupling row; the Luna page states the launch difference; pulses, beams, reading output, polarisation and compression link to the chapter. The SEP gains its phase 5 report and corrects the phase 3 envelope claim. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019qNYqtCKh4iTKj7544yARb --- CHANGELOG.md | 14 ++ docs/dev/accuracy.md | 43 +++++ docs/dev/backends.md | 9 + docs/dev/conventions.md | 38 ++++ docs/dev/seps/sep-0031-simulation-coupling.md | 171 +++++++++++++++++- docs/guide/free-space/beams.md | 4 +- docs/guide/results/reading-output.md | 4 +- docs/guide/setup/polarisation.md | 4 + docs/guide/setup/pulses.md | 5 + docs/guide/workflows/compression.md | 6 +- docs/guide/workflows/coupling.md | 101 +++++++++-- docs/guide/workflows/luna.md | 13 ++ 12 files changed, 394 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 311c8d3f7..1da9cd476 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -90,6 +90,20 @@ The first public release. It contains: updated and a sequential ledger in joules. Thin screens carry their angular-flux certificate; undersampled phases, wrapped rays and evanescent content are refused unless their removal is declared. Refusals raise `CouplingError`. +- **Coupled workflows** + ([SEP 31](https://solprop.readthedocs.io/en/latest/dev/seps/sep-0031-simulation-coupling.html), + phase 5). The eight compositions of the coupling chapter -- capillary into capillary, + a nonlinear gas gap into a capillary, fibre into fibre, Gaussian, analytic and + Cartesian beams into a guide, a guide's far field, and SPM, compression and relay + optics into a second capillary -- run as short integrated tests that assert their + energy ledgers, with sources and receivers on different CPU backends. A plane's path + certificate (`FieldPlane.certificate`, `CouplingReport.certificate`, + `TransportReport.source_certificate`) is the rebuilt `AdiabaticAdmission`, not its raw + record. `CAPILLARY_ENVELOPE` now bounds the scalar model against Marcatili's leaky core + field (bound 6e-3 at its range's top, delta = 1e-2, scaled by the pair's delta) rather + than against the scalar profile itself; bins outside an envelope's range, including a + fibre's V across the band, are counted in full; the modal overlap quadrature enters + the budget as `2 delta + delta^2`. - **Media and responses.** - Gases, gas mixtures, glasses and metals from the [optmat](https://github.com/LupoLab/optmat) material library. diff --git a/docs/dev/accuracy.md b/docs/dev/accuracy.md index b3f2e0ba6..3efc81e4a 100644 --- a/docs/dev/accuracy.md +++ b/docs/dev/accuracy.md @@ -742,3 +742,46 @@ For a declared z-dependent unitary change of basis, dropping the connection term propagated state by at least a fifth of its own size in the test case, while keeping it reproduces the direct propagation to $10^{-10}$ of the state. That is a measurement for that comparison, not a general estimate for a changing basis. + +## 12. Coupling between simulations + +A coupling ([SEP 31](seps/sep-0031-simulation-coupling.md), +[the manual's chapter](../guide/workflows/coupling.md)) joins two runs that each have +their own accuracy envelope; the coupling adds its own, and the three are kept apart +rather than folded into one number. + +- **The source and the receiver** are ordinary runs: their step-control and + discretisation envelopes are those of their routes (§1 to §11). A coupling takes the + source plane as stored and makes no claim about it. A source run through a gas + gradient carries SEP 34's adiabatic path certificate, which the coupling report and + the transport report carry separately (`report.certificate`, + `report.source_certificate`): it bounds the linear background path, not the coupling. +- **The coupling model's error** is the `model_error` budget $\epsilon_m$ and the + admission items that must fit inside it: the angular cone rule (an *allocation* + $(1-\cos\theta_*)$ of the cone share, plus the measured tail), the metric defects + between the common measure and each native metric, the H-closure of the vector model, + the passive-map certificate and the recorded pair envelope. The envelopes are + measured against independent physics, not the model's own profiles: the capillary's + against Marcatili's leaky core field $J_0(u_m(1 - i\delta)r/a)$, + $\delta = |\nu_n|c/(\omega a)$ (largest share discrepancy $2.9\times10^{-3}$ at + $\delta = 10^{-2}$ over its range, bound $6\times10^{-3}$, scaled linearly by the + pair's energy-weighted $\delta$: about $1.5\times10^{-3}$ for a 75 µm silica + capillary at 800 nm); the step-index pair's against the exact vector $HE_{11}$ flux + overlap ($3.0\times10^{-4}$). Bins outside an envelope's range are counted in full. +- **The coupling's numerical error** is its own item: the source plane's content at + its spectral edge, an analytic field's domain and resolution checks (the energy + change on doubling the window and halving the spacing), and the overlap quadrature + (its amplitude error $\delta$ entered as $2\delta + \delta^2$). Representation errors + of a synthesised exit (`Outcouple.window`) and a transport's thin-screen and + Fresnel bounds are reported by those operations. + +Refinement is separate for each: a quadrature is refined by halving its panels, a +plane's domain and resolution independently, the captured subspace by nested mode sets +(which share their common shares to $10^{-12}$ on the capillary tests), and the +propagation by the receiving run's own tolerance. A launch is never renormalised, so an +error in any one of them appears as an energy discrepancy in the report rather than +being hidden by rescaling to the requested energy. The integrated compositions of SEP +31 §2 (`tests/coupling/test_workflows.py`) assert the ledger of each: incident equals +captured plus uncaptured, the launch differs from the captured energy only by the +reported target-metric defect, and the receiving run's entrance carries exactly the +launch. diff --git a/docs/dev/backends.md b/docs/dev/backends.md index 0c6d07145..ba0f2c525 100644 --- a/docs/dev/backends.md +++ b/docs/dev/backends.md @@ -1676,6 +1676,7 @@ that execution space. | Adams snapshots and a resume that continues the history (`MarchSnapshot`) | yes | yes | yes: the resident engine copies its rows (`store`, and `states`, `rates`, `applied` where kept) to the host at a kept save and back on a resume | compiles; not qualified | | field-free observation (`observe(field=False)`), the runner, a transverse collar's rate and a set evanescent rate on the separable element | yes | yes | yes (field-free observation needs no native change; the rate is one view in `SpatialCoefficients`) | compiles; not qualified | | materials from optmat: gas Raman leaves of many rows on gradients, rate tables, optmat walls, the silica bundles | yes | yes | yes | not qualified | +| coupling between runs (`field_plane`, `regrid`, `couple`, `Outcouple`, `transport`, `with_input`; SEP 31) | host-side: any source and receiving backend | host-side | host-side | receiving runs not qualified | **Materials from optmat** ([SEP 3](seps/archive/sep-0003-optmat-materials.md), a run under `MaterialSelection`) reach the backends as the arrays any other run carries: index and @@ -1693,6 +1694,14 @@ views sized from the problem. A fill whose populations follow independent densit paths takes the z table, which every CPU backend runs (on `native` the CPU build only); a shared pressure path does not. Device builds are not qualified on these runs. +**Coupling between runs** ([SEP 31](seps/sep-0031-simulation-coupling.md)) never crosses +the seam: a field plane is read from a run's stored output and its record, coupled and +transported in NumPy and SciPy on the host, and installed in the receiving run as its +initial state through `with_input`. A source and a receiver may therefore run on +different backends; the integrated compositions (`tests/coupling/test_workflows.py`) +pair `numpy_ref` and `numba_cpu` sources with native CPU receivers, and the relaunch +tests cover every pair of the three CPU backends. Device receivers are not qualified. + `numpy_ref` is the oracle: every other backend is qualified against it, and it is the one to run when two backends disagree. `numba_cpu` is always installed. The native backend's device builds are qualified on a data-centre NVIDIA GPU (CUDA 13) and an AMD Instinct GPU (a ROCm 7 diff --git a/docs/dev/conventions.md b/docs/dev/conventions.md index 2e4d1f691..b841bd727 100644 --- a/docs/dev/conventions.md +++ b/docs/dev/conventions.md @@ -234,6 +234,44 @@ admitted only when every propagating column meets the adiabatic certificate: `delta* = expm1(Lambda tanh Lambda)` within `AdiabaticAccuracy` (default 1e-4 each; `solprop.adiabatic`). +**Field planes and coupling** ([SEP 31](seps/sep-0031-simulation-coupling.md)). A +`FieldPlane` holds a stored spectrum exactly as the output does: the optics-convention +raw DFT of each column, referenced to the window centre, in √W (ordinary and general +modal coordinates), V m (the Hankel spectrum of a radial field) or V/m (the DFT of a +Cartesian one). + +- *Plane reference.* The absolute spectrum is + `E_abs(omega) = E_stored(omega) exp[i(phi + omega tau)]` with `omega` the absolute + angular frequency of the bin, `tau` the absolute time of the record's `t = 0` and + `phi` a carrier phase. A run's reference is the integral of the affine term its + linear operator removed, `phi + omega tau = integral_0^z R dz`, + `R = beta0 + beta1 (omega - omega_o)`, with `omega_o` the frame's reference + frequency on an envelope grid and zero on a field grid (where `beta0` is kept in + the operator and contributes nothing here). Output schema 24 stores the exact + Chebyshev antiderivatives of the frame tables, so the pair is rebuilt from the + file; a launched run inherits its source plane's pair as its origin. +- *Affine phase and delay.* An operation that removes a known affine term adds it to + `(phi, tau)`, so `E_abs` is unchanged and a pulse is never translated out of its + window: a free-space gap removes `R L` of its medium (or `beta1(omega0) L` without + a frame), and `regrid(delay=, phase=)` moves the record against the reference. A + spectral element that drops its own constant phase or group delay (a grating or + prism pair, measured mirror data) clears that term's entry in + `PlaneReference.calibrated`, and `absolute()` then refuses rather than invent it. +- *Spectral-density scaling.* A raw DFT bin of a localised record is + `q E(omega)/dt`, with `E(omega)` the optics-convention transform in V s/m (times + the transverse unit) and `q = 1` on a field grid, 2 on an envelope grid, whose + envelope is the analytic signal. Moving a record between lattices keeps the + continuous transform, not the bin values: target bin `m` receives + `(dt/(dt' q'_m)) exp(i dphi) sum_j a_j exp(i omega'_m (t_j + Delta))` + (`coupling.regrid`), with `q'` the target's factor (2 on a field grid's interior + bins), so equal-bin copying is exact only on an identical lattice. +- *Energy.* Each representation keeps its native metric: the identity for + true-power coordinates, `conj(W)` for a declared general basis, the axial admittance + `Y_z` above for a spatial plane. The scalar coupling model fits in the common + paraxial measure `M0 = n_ref eps0 c dA/2`, `n_ref` the receiving port's core index; + the captured and uncaptured energies are reported in it, and the launch in the + target's native metric. Nothing is rescaled to a requested energy. + ## 5. Modes and polarisation - `n_eff(ω, z)` complex with `Im n_eff > 0` for loss; `n_eff − 1` computed directly for gases. diff --git a/docs/dev/seps/sep-0031-simulation-coupling.md b/docs/dev/seps/sep-0031-simulation-coupling.md index e093c9eb6..8012aec73 100644 --- a/docs/dev/seps/sep-0031-simulation-coupling.md +++ b/docs/dev/seps/sep-0031-simulation-coupling.md @@ -1482,7 +1482,11 @@ items the SEP caps at $\epsilon_m/8$ are capped individually. 1 mrad, 75 µm argon capillary at 800 nm with eight HE1m modes, and radius ratios 0.5–2. Largest difference 1.8e-14 of the incident energy (the reference quadrature's rounding: the Marcatili HE1m field *is* the scalar profile); recorded bound 1e-12, - numerical 1e-13; range HE1m count 1–8, radius ratio 0.5–2. + numerical 1e-13; range HE1m count 1–8, radius ratio 0.5–2. *Corrected in phase 5:* + because the reference is the scalar profile itself, this comparison bounds the + quadrature and the implementation, not the omitted physics; it is now labelled a + consistency check, and the envelope is re-measured against Marcatili's leaky core + field (phase 5 report, item 1). - Step index (`step-index-lp01-vs-vector-he11-2026-10`): LP01 overlap squared against $|F_{AB}|^2$ of the exact unit-power HE11 even fields (`StepIndexMode.electromagnetic_field`); core 1.45, contrast 0.001–0.01, V 1.5–2.4, @@ -1787,6 +1791,171 @@ constant or coefficient was transcribed. - Phase 5 composes the eight §2 cases from these elements; a compressor result enters as `SpectralTransfer(callable, calibration=SpectralCalibration(...))`. +### Phase 5 report — integrated qualification + +**Delivered (2026-10-24, `feature/sep-0031-simulation-coupling`).** The focused +tutorial and the graded-index worked example were delivered separately, on their own +sub-branch; this report covers the rest of phase 5. + +1. **The capillary envelope, corrected** (an independent physics review found it). + `CAPILLARY_ENVELOPE` compared the scalar J0 overlap with `MarcatiliMode.field`, which + for HE1m is the same J0, so its 1e-12 bound measured the quadrature, not the omitted + physics §4.3 asks for. Those two tests are now named as consistency checks of the + closed-form transforms against quadrature. The envelope + (`capillary-he1m-scalar-vs-leaky-marcatili-2026-10`) now compares the scalar profile + with Marcatili's leaky core field $J_0(u_m(1 - i\delta)r/a)$, + $\delta = |\nu_n|c/(\omega a)$, $\nu_n = (\varepsilon+1)/(2\sqrt{\varepsilon-1})$ + (Marcatili and Schmeltzer 1964, doi:10.1002/j.1538-7305.1964.tb04108.x), on an + independent polar rule at the range's top $\delta = 10^{-2}$: Gaussian beams (waist + 0.3a–1.0a, displacement to 0.2a, tilt to $0.59/ka$, 1, 2, 4 and 8 modes) and + coherent three-mode exits into eight modes at radius ratios 0.5–2. The discrepancy is + the larger of the summed per-mode share changes and the least-squares captured share + change: largest $2.9\times10^{-3}$ (equal radii, from the leaky modes' + nonorthogonality), beams $1.8\times10^{-3}$; bound $6\times10^{-3}$ (safety factor + two), numerical $10^{-10}$ (refining the rule changes it by $10^{-14}$). Halving + $\delta$ halves the beam cases (first order, from tilt and displacement) and + quarters the radius cases, so the allowance is the bound scaled linearly by the + pair's energy-weighted $\delta$ (`PairEnvelope.scaled_by`, `allowance`): about + $1.5\times10^{-3}$ for a 75 µm silica capillary at 800 nm (the reviewer's direct + probe: $8.5\times10^{-5}$ at 75 µm, $3.4\times10^{-4}$ at 37.5 µm, both inside). + `ScalarModeBasis.wall_term` carries $|\nu_n|$ from `MarcatiliMode.sampled_terms`; + `ScalarModeBasis.leaky(omega)` gives $\delta$ per bin. A pair whose weighted + $\delta$ exceeds $10^{-2}$ is refused (a 12 µm capillary at 800 nm is, at + $1.57\times10^{-2}$); bins beyond the range in a covered pair add their incident + share to the item in full. +2. **Other review items.** The modal overlap quadrature, an amplitude error, now + enters the budget as $2\delta+\delta^2$ (§4.7). A step-index pair's V is checked at + every occupied bin as well as at the profile wavelength; bins where either fibre's V + leaves the envelope's range add their incident share to the envelope item. The + angular items' text says that their cone term is the cone rule's allocation, not a + measured defect. A new test pins the launch phase of a displaced, tilted beam against + an independent quadrature of the complex overlaps (they agree to 2e-14 rad): no + share can catch a sign error, and on every `FreeGrid` the origin phase is + $e^{\mp i\pi m}$ for either sign, so `target_columns`' origin-phase sign is not + observable on the grids the package builds. +3. **The certificate.** `processing.field_plane` takes the plane's path certificate + from `flux_from_record(...).admission`, the rebuilt `AdiabaticAdmission` (path + identity as `{"recorded": text}`), instead of the raw `flux_plan["admission"]` dict; + `FieldPlane.certificate`, `CouplingReport.certificate` and + `TransportReport.source_certificate` carry it, and the report prints its field error + and reflection. Its scope (the linear background's forward field) is the record + type's, stated in the docstrings. Phase 2 deviation 10 is resolved. +4. **The eight compositions** (`tests/coupling/test_workflows.py`, medium tier, 9 tests, + 10.6 s wall under four workers, the slowest 3.7 s). Each asserts incident = captured + + uncaptured, uncaptured ≥ 0 per bin, a launch within the reported target-metric defect + of the captured energy (never rescaled), the receiving run's entrance energy equal to + the launch to rounding, the budget within `model_error`, and for transports a passive, + closed ledger. + - (a) A 75 µm, 3-mode capillary exit into a 100 µm, 6-mode one: direct overlap + captures 0.99962, `Outcouple` → 2 mm argon → `couple` 0.99853; the launched run + inherits the plane's reference. Source `numpy_ref`, receiver native CPU. + - (b) 5 mm of 3 bar argon on a radial envelope grid (`numba_cpu`), 10 µJ at + w/a = 0.5, into a capillary on native CPU: the fundamental share is 0.8715 linear + and 0.8805 with Kerr. + - Mandatory: 2 mm of 1 bar argon on a radial **field** grid with Kerr (THG on) and + field plasma, 40 µJ, 30 µm: plasma absorbs 1.6e-3 (Kerr alone 1e-8). The harmonic + band 230–300 nm carries 82 nJ; its incident energy matches the plane's band energy + to 5e-6, the capillary captures 0.998 of it, the entrance stores the launch in that + band to rounding, the entrance reference equals the source plane's, and the band's + energy-weighted arrival time agrees to 1.1 as. Receiver native CPU. + - (c) Step-index 2.18 µm into 2.57 µm (contrast 0.005, 1064 nm): captured share equal + to an independent `quad` of the LP01 overlap squared to 1e-8 (0.99731); source + `numba_cpu`, receiver native CPU. + - (d) A Gaussian at w/a = 0.5: shares 0.906 and 0.092. + - (e) An analytic field whose waist scales as 1/ω: per-bin captured share equal to a + four-mode Gaussian–Bessel quadrature at that bin's waist to 1e-10. + - (f) A Cartesian beam displaced 15 µm and tilted 2 mrad by `Alignment`: 0.792 + against 0.9994 centred; the same beam built directly agrees within 2e-3 (the tilt's + index differs per bin); `Alignment` on a radial plane is refused. + - (g) `Outcouple` then `FreeSpace(..., output_method="fresnel")` at 0.1, 0.2, 0.4 m on + scaled grids: the scaled intensity patterns converge (the second mismatch below 0.6 + of the first); the Fresnel route books 2.1e-4 as approximation error. + - (h) 20 mm of 3 bar argon at 100 µJ (bandwidth ×6.9) → `Outcouple` → + `SpectralTransfer(Chain(TaylorPhase(-50 fs²)))` → a 4f relay (two 20 mm lenses, a + 300 µm tanh aperture in the Fourier plane, declared `beyond_window="remove"`) → + an identical capillary on native CPU: efficiency 0.998, the compressor's ledger + entry zero, its reference calibrated, the aperture's loss booked as specified + loss, and the launched fundamental's peak power raised ×2.05. +5. **Documentation.** The coupling chapter: a "Composing simulations" section with all + eight compositions, the chaining rules (lattices, `geometry=port.geometry`, separate + budgets) and a runnable relay example; the corrected envelope text; the allocation + and $2\delta+\delta^2$ notes; a stray duplicated paragraph removed. Conventions §4 + (plane reference, affine phase and delay, spectral-density scaling, metrics); + accuracy §12 (how coupling uncertainties are kept apart); backends §9 (a coupling row + and paragraph); the Luna page (Luna's single-overlap `GaussBeamPulse` against the + per-bin coherent projection; nothing restored in either); cross-links from pulses, + beams, reading output, polarisation and compression (general-modal already linked); + the changelog. The kernel and output contract pages already record schemas 35 and 24. + The package docstring's reference to `transport` now resolves. + +**Deviations.** The tutorial is not part of this delivery (parallel sub-branch). The +capillary envelope's allowance scales with $\delta$, a new `PairEnvelope.scaled_by` +field, rather than holding one number over the range; the alternative, a constant +$6\times10^{-3}$, would overstate a 250 µm capillary's discrepancy tenfold. The +integrated compositions are medium tier by purpose (functional checks of short runs); +together they add about 18 s of CPU time to that tier. + +**Verification run** (targeted, `uv run --no-sync pytest -n auto`; nothing fails): +`tests/coupling/` (all modules, including the new workflow, leaky-envelope, phase and +uncovered-band tests), `tests/processing/test_planes.py`, +`tests/driver/test_coupling_relaunch.py`, `tests/driver/test_general_modal.py`, +`tests/test_display.py`, `tests/test_benchmark_guides.py`, +`tests/test_structure_figures.py`, `tests/test_seps.py`, `tests/test_guide_snippets.py` +(compiled); the coupling page's snippets run by hand (4.4 s). ruff, ruff format, +basedpyright (0 errors), numpydoc and codespell on the changed files; `poe docs`. Not +run: the full gate, slow and coverage lanes, GPU. + +**Sources checked.** Marcatili and Schmeltzer's leaky-mode core wavenumber +$k_ta = u_m(1 - i\nu_n/(ka))$ and $\nu_n$ for HE modes were taken as the package's own +`MarcatiliMode` uses them (`_cladding_term_and_derivative`) and as the reviewer stated them; the printed +paper's equation numbering was not checked. Luna's `GaussBeamPulse` launch +(`Interface.makeinputs`) was read to state the Luna difference accurately. + +**Limitations.** The leaky-field envelope is a first measure of the omitted physics: +it does not include the field in the wall, the polarisation dependence of $\nu_n$ +across EH/TE/TM families (not admitted to the scalar model anyway), or a lossy wall's +complex $\nu_n$ beyond its modulus. The step-index envelope still covers LP01 only. + +**Open questions** (earlier phases' questions that remain, with recommendations; the +plain defects among them are fixed above or were superseded): + +1. *(Phase 1 Q1, Q2)* Superseded: `couple` then `with_input` is the one path, and + `CouplingReport` replaced `IncidentLaunch` with its items. Close. +2. *(Phase 1 Q3)* A graded core's on-axis index as $n_{\rm ref}$: keep it (it is the + port's declared reference, and the vector model's metric item measures the rest). +3. *(Phase 1 Q4)* The graded-index worked example: handled on the tutorial sub-branch. +4. *(Phase 2 Q1)* Basis identity by digest: keep the digest; revisit only if a gradient + guide splits on a rounding boundary in practice. +5. *(Phase 2 Q2)* Regrid band loss in physical energy: add it beside the record-norm + share when the transport core's per-bin admittance is reused; not a defect. +6. *(Phase 2 Q3)* A modified-noise run's plane: refuse it by name until its auxiliary + phase is reconstructed (recommended; currently taken as stored). +7. *(Phase 2 Q4)* `field_plane` on the general-modal accessor whitelist: yes, but the + whitelist feeds the schema-7 records, so it belongs with the next output-schema + change. +8. *(Phase 2 Q5)* `same_basis` outside the band: keep refusing; `regrid(crop_band=True)` + is the explicit crop. +9. *(Phase 2 Q6)* `processing` → `translate.flux`: record it as a deliberate edge (both + `processing.mixtures` and `processing.planes` rebuild the plane measure there). +10. *(Phase 3 Q1)* A recorded provider envelope for `vector_paraxial`: recommended before + Final, against an independent mode-matching calculation for `StepIndexProvider`. +11. *(Phase 3 Q2, Q3)* The cone rule for small-core direct overlaps and a + Gaussian → LP01 envelope: maintainer decision; the cone rule is conservative there. +12. *(Phase 3 Q4)* The general guide's target angular item at every occupied bin: yes if + its cost stays within a coupling's; measure first. +13. *(Phase 3 Q5)* `Outcouple` stays a separate synthesis step (it changes the + representation), not an `Element`. +14. *(Phase 4 Q1, Q3)* `spatial_energy_spectrum` and the `aperture_profile` docstring: + when `spatial.py` is next open. +15. *(Phase 4 Q2)* Record the frame in `FieldPlane`: yes, with the next output-schema + change, so `transport` needs no `frame=`. +16. *(Phase 4 Q4)* Refuse the same-grid edge share with its own tolerance: recommended. +17. *(New)* Should a capillary's envelope range be extended past $\delta = 10^{-2}$ + (narrow capillaries in the infrared), with a re-measured bound? + +**Handoff.** Final needs the tutorial's merge, the end-of-SEP full gate, slow and +coverage lanes, and the maintainer's decisions on questions 10, 11 and 17. + ## Part III — History - 2026-10-22: Drafted after auditing existing launch, projection, spatial flux, diff --git a/docs/guide/free-space/beams.md b/docs/guide/free-space/beams.md index ffbc1c9de..e13d56a61 100644 --- a/docs/guide/free-space/beams.md +++ b/docs/guide/free-space/beams.md @@ -303,4 +303,6 @@ on the model page under ## Related calculations -The [crossing-beam lesson](../../introduction/lessons/plot_04_crossing_beams.rst) shows how to launch a custom coherent array. The [radial filament lesson](../../introduction/lessons/plot_03_filament.rst) connects fluence, peak intensity and plasma. Refine the spatial grid and temporal bandwidth independently before interpreting either example quantitatively. +A saved plane of a free-space run is a coherent field plane that can be transported +through lenses, apertures and free space or coupled into a capillary or fibre +([coupling simulations](../workflows/coupling.md)). The [crossing-beam lesson](../../introduction/lessons/plot_04_crossing_beams.rst) shows how to launch a custom coherent array. The [radial filament lesson](../../introduction/lessons/plot_03_filament.rst) connects fluence, peak intensity and plasma. Refine the spatial grid and temporal bandwidth independently before interpreting either example quantitatively. diff --git a/docs/guide/results/reading-output.md b/docs/guide/results/reading-output.md index 52a389131..3247be7d0 100644 --- a/docs/guide/results/reading-output.md +++ b/docs/guide/results/reading-output.md @@ -6,7 +6,9 @@ figures of {mod}`solprop.plotting`, exactly as for any other run ([analysing a run](../../auto_tutorials/basics/plot_03_analysing_a_run.rst), [output](output.md)). This chapter covers what is different about a capillary with a coherent wall and about the general modal route: which quantities a basis defines, what the record holds, and how the -physical fields are rebuilt. +physical fields are rebuilt. A saved plane read as a field plane, with its reference, +metric and provenance, for transport or for launching another run, is +{py:func}`~solprop.processing.planes.field_plane` ([coupling simulations](../workflows/coupling.md)). ## A capillary with a coherent wall diff --git a/docs/guide/setup/polarisation.md b/docs/guide/setup/polarisation.md index b8e98b3c2..da88f8051 100644 --- a/docs/guide/setup/polarisation.md +++ b/docs/guide/setup/polarisation.md @@ -75,6 +75,10 @@ interest. ``` +A coupling between runs keeps the polarisation as a Jones state in each plane's own +oriented frame; mirrors and Jones elements change the frame and the field together +([coupling simulations](../workflows/coupling.md#transporting-a-plane-between-planes)). + ## Two equivalent representations The state has shape `(n_modes, n_pol, n_omega)` before assembly adds its batch axis. diff --git a/docs/guide/setup/pulses.md b/docs/guide/setup/pulses.md index 4b154edd7..96c6bc571 100644 --- a/docs/guide/setup/pulses.md +++ b/docs/guide/setup/pulses.md @@ -267,6 +267,11 @@ z=..., side=...)`, names that medium and plane; there is no vacuum default. A me index $n_0$ therefore carries a field $\sqrt{n_0}$ lower than vacuum for the same energy ([energy and flux in a medium](../free-space/flux.md)). +A beam launched into a guide's modes, or the output field of another run used as an +input, is a coupling rather than a pulse: the beam keeps its energy over its whole +incident plane and the modes receive only what they capture +([coupling simulations](../workflows/coupling.md)). + ## Where the quantum floor sits Building a pulse says nothing about the noise it sits on. The shot-noise background is a diff --git a/docs/guide/workflows/compression.md b/docs/guide/workflows/compression.md index bfa565fd0..11da9f962 100644 --- a/docs/guide/workflows/compression.md +++ b/docs/guide/workflows/compression.md @@ -9,7 +9,11 @@ gas, chirped mirrors, grating pairs and prism pairs (the last two with explicit tuning coordinates), the compressors that search each of them, and the optimisation of the carrier-envelope phase. Everything here is applied to a saved plane after the run, so no propagation is repeated; read this page to see a simulated pulse as it would -arrive at an experiment, or to design the compressor that gets it there. +arrive at an experiment, or to design the compressor that gets it there. To send the +compressed pulse on into another simulation -- through focusing optics into a second +capillary, say -- apply the same elements to a field plane with +{py:class}`~solprop.coupling.elements.SpectralTransfer` +([coupling simulations](coupling.md#transporting-a-plane-between-planes)). ## One concept: a transfer function diff --git a/docs/guide/workflows/coupling.md b/docs/guide/workflows/coupling.md index 729afe50b..6b483538f 100644 --- a/docs/guide/workflows/coupling.md +++ b/docs/guide/workflows/coupling.md @@ -211,14 +211,14 @@ these items, every one a relative energy discrepancy: | item | what it bounds | limit | |---|---|---| -| `angular` | $[(1 - \cos\theta_*)U_{\rm cone} + U_{\rm tail}]/U$ of the incident field, and of the worst unit field in the retained target subspace | the total | +| `angular` | $[(1 - \cos\theta_*)U_{\rm cone} + U_{\rm tail}]/U$ of the incident field, and of the worst unit field in the retained target subspace; the cone term is the cone rule's allocation, about $\epsilon_m/8$ of the cone share, not a measured defect | the total | | `contrast` | the weak-guidance screen $\Delta = \max|n^2 - n_{\rm ref}^2|/(2n_{\rm ref}^2)$ of a solid guide; not applied to a capillary wall; a recorded pair envelope may replace it | $\epsilon_m/8$ | | `target metric` | $G$ (or $F$) against the target's native $Q$, whitened | $\epsilon_m/8$ | | `source metric` | the common measure $M_0$ against the source's native metric | $\epsilon_m/8$ | | `closure` | $2\delta + \delta^2$ of the H-closure's field error $\delta$ (vector model) | $\epsilon_m/8$ | | `passivity` | the whitened certificate $C^\dagger Q_BC \preceq Q_A$: an apparent gain within the model's own defects is reported here, never rescaled or clipped; a larger one refuses | the total | -| `envelope` | the recorded provider-pair envelope's bound | the total | -| `numerical` | the source's content at its spectral edge, an analytic field's domain and resolution checks, the overlap quadrature | the total | +| `envelope` | the recorded provider-pair envelope's allowance, plus the incident share of any bins outside its range | the total | +| `numerical` | the source's content at its spectral edge, an analytic field's domain and resolution checks, the overlap quadrature (an amplitude error $\delta$ counted as $2\delta + \delta^2$) | the total | The counted items must sum to at most $\epsilon_m$. The {py:class}`~solprop.coupling.coupler.CouplingReport` carries the incident energy over the @@ -227,13 +227,26 @@ and uncaptured energy where a common positive metric exists (`None`, with the re for the vector model), every item with its scope -- this field, the whole map, or the model -- the source plane's exterior, and the source's SEP 34 path certificate, separately and with its scope. A zero field is a valid zero launch whose `efficiency` is -`None`. The recorded envelopes ({py:mod}`solprop.coupling.envelopes`) are measured by the -test suite against independent references: the capillary's against the overlap of its -own vector Marcatili fields (a difference of 1.8e-14 of the incident energy over waists -0.3a--1.0a, displacements to 0.2a, tilts to 1 mrad and radius ratios 0.5--2), the -step-index pair's against the full-vector flux overlap of the exact $HE_{11}$ fields -(3.0e-4 over contrasts to 0.01, V from 1.5 to 2.4 and radius ratios 0.7--1.43). A pair -outside its envelope's range is refused, not extrapolated. +`None`. The recorded envelopes ({py:mod}`solprop.coupling.envelopes`) bound what the +scalar model omits, measured by the test suite against independent physics: + +- *Capillary.* The scalar profile $J_0(u_mr/a)$ against Marcatili's leaky core field + $J_0(u_m(1 - i\delta)r/a)$, $\delta = |\nu_n|c/(\omega a)$ with $\nu_n$ the wall + term (Marcatili and Schmeltzer 1964). Over waists 0.3a--1.0a, displacements to 0.2a, + tilts to $0.59/ka$, 1--8 modes and radius ratios 0.5--2 the captured shares differ by + at most $2.9\times10^{-3}$ at $\delta = 10^{-2}$, the range's top (an 18.8 µm silica + capillary at 800 nm); the bound is twice that, $6\times10^{-3}$. The difference + falls at least linearly with $\delta$, so the reported allowance is the bound scaled + by the pair's energy-weighted $\delta$: about $1.5\times10^{-3}$ for a 75 µm + capillary at 800 nm. +- *Step index.* The LP01 overlap against the full-vector flux overlap of the exact + $HE_{11}$ fields: $3.0\times10^{-4}$ over contrasts to 0.01, V from 1.5 to 2.4 and + radius ratios 0.7--1.43. V is checked at the profile wavelength and at every occupied + bin, since it grows with frequency. + +A pair outside its envelope's range is refused, not extrapolated; bins of a covered pair +whose $\delta$ or V leave the range carry an unbounded discrepancy, so their share of +the incident energy is added to the item in full. ### A Gaussian beam into a capillary @@ -347,10 +360,6 @@ through `couple` and records the report as `configuration["launch"]` fails the contrast screen and is refused; the closure is qualified against the provider's own H. -run keeps its ionisation tables and their ranges, as every derived run does. A different -basis, lattice or medium is a coupling or an interface, not a relaunch, and is refused by -name. - ## Transporting a plane between planes {py:func}`~solprop.coupling.transport` carries a radial or Cartesian field @@ -495,3 +504,67 @@ launched microjoule less $10^{-6}$ of it. That is the lens's own axial-flux defe converging plane waves cross the plane obliquely, carrying $\cos\theta$ of their local measure, and the report books it as the lens's approximation error, inside its certificate (ratio $2.5\times10^{-4}$ against the budget of $10^{-3}$). + +## Composing simulations + +The blocks above compose into the calculations +[SEP 31](../../dev/seps/sep-0031-simulation-coupling.md) set out to support. Each is an +ordinary chain of runs and planes; nothing schedules them, and each run keeps its own +step control. The test suite runs every one of them as a short integrated case +(`tests/coupling/test_workflows.py`), asserting its energy ledger. + +| calculation | composition | +|---|---| +| a capillary's output into another capillary | `field_plane(run, source=simulation)`, then `couple` into the second port by direct overlap, or `Outcouple` → `transport([FreeSpace(...)])` → `couple` across a gap | +| a nonlinear beam into a capillary | a radial (or Cartesian) gas gap with the ordinary Kerr, plasma and harmonic responses; its last plane read with `field_plane`, coupled with `scalar_paraxial` into a capillary on the same lattice | +| a solid-core fibre into another fibre | the exit plane of a step-index run coupled into another step-index run, within the recorded envelope ([a solid fibre overlap](#a-solid-fibre-overlap)) | +| a Gaussian beam into a guide | `FieldPlane.from_pulse` of a `SpatialPulse`, or `from_waveguide(incident=...)` for a general modal guide | +| an arbitrary analytic field into a guide | `FieldPlane.from_analytic`, a callable of the nodes and the absolute frequency; nonseparable fields (a waist that changes with frequency) are fine | +| an arbitrary Cartesian beam into a guide | a Cartesian plane, aligned with `Alignment` (displacement and tilt) or sampled directly; a radial plane refuses anything that would break its symmetry | +| a capillary's output at a distant plane | `Outcouple` onto a spatial grid in the exit medium, then `FreeSpace(L, medium, output=..., output_method="fresnel")` to any observation plane, the far field included | +| SPM, a compressor and another capillary | the first capillary's exit, `Outcouple`, `SpectralTransfer` of an optics element or `Chain`, lenses, apertures and free space, then `couple` into the second capillary | + +Three things to keep in mind when chaining: + +- **Lattices.** `couple` needs the plane on the port's temporal lattice. Build the + receiving run on the source's grid (`grid=` of the constructors), or move the plane + with `regrid` first. A gap whose output carries a harmonic needs a receiving band that + holds it: the active band is checked, and light outside it is refused unless cropped + explicitly. +- **Planes in the receiver's coordinates.** A transported plane moves along its normal, + so its origin is where the optics put it. Pass `geometry=port.geometry` to `transport` + to declare the last plane in the receiving run's own coordinates; its axes must be the + ones the field arrives with, or the coupling is refused as oriented differently. +- **Budgets.** Every approximate step has its own budget: a coupling's `model_error`, a + thin screen's `model_error`, a free-space gap's `sampling_tolerance` (or + `beyond_window="remove"`, which books the removed angular band). They are reported + separately and never multiplied into one efficiency. + +For example, a capillary's output relayed 1:1 by two lenses, compressed on the way, into +an identical capillary: + +```python +from solprop.coupling import Aperture, SpectralTransfer +from solprop.optics import TaylorPhase + +argon = SpatialMedium("Ar", 1.0) +receiving = Simulation.from_capillary( + 100e-6, 1e-3, "Ar", 1.0, lambda0=800e-9, wavelength_limits=(400e-9, 1.6e-6), + trange=200e-15, fwhm=20e-15, energy=20e-6, modes=2, n_save=3, noise=None, +) +port = receiving.input_port() +exit_field = Outcouple(RadialGrid(1.2e-3, 512), medium=argon).apply( + field_plane(run, source=simulation) +) +gap = FreeSpace(0.05, argon, beyond_window="remove") +relayed, ledger = transport( + exit_field.plane, + [SpectralTransfer(TaylorPhase((0.0, 0.0, -20e-30), 800e-9)), gap, + ThinLens(0.05, model_error=0.05), gap, + Aperture(600e-6, model_error=0.05, edge=20e-6, kind="tanh"), gap, + ThinLens(0.05, model_error=0.05), gap], + frame=simulation.frame, geometry=port.geometry, +) +second = couple(relayed, port, model=CouplingModel("scalar_paraxial", model_error=0.05)) +print(ledger.specified_loss, ledger.removed, second.report.efficiency) +``` diff --git a/docs/guide/workflows/luna.md b/docs/guide/workflows/luna.md index 38d9be6de..829c1f432 100644 --- a/docs/guide/workflows/luna.md +++ b/docs/guide/workflows/luna.md @@ -215,6 +215,18 @@ one draw has the same shape for single-mode and multimode runs. **Pulses.** A measured pulse (`DataPulse`) keeps its own time origin; a continuous wave (`CWPulse`) is built in one pass on either grid and is seeded; envelopes are validated. +**Launching a beam into a guide.** Luna's `GaussBeamPulse` takes one scalar overlap of +the beam with each mode at the central wavelength and gives that mode the fraction +$|c_m|^2$ of the pulse's energy, so the captured share is the same at every frequency. +solprop projects the beam at **every** frequency bin, coherently and in the paraxial +measure of the guide's core index, after giving the beam its requested energy over its +whole incident plane: the modal shares follow the frequency dependence of the overlap, +and the launch is the projected amplitudes as they are. The energy the modes do not +capture is not restored by renormalising to the requested energy (nor is it in Luna), +and the coupling report states the incident, captured and uncaptured energies. A field taken from another run is coupled at its own +physical scale ([coupling simulations](coupling.md), +[SEP 31](../../dev/seps/sep-0031-simulation-coupling.md)). + ## More accurate numerics, the same physics These do not change the model, but they change results at the level of the numerical @@ -301,6 +313,7 @@ error. medium flux in free space, with forward amplitude transport through a certified radial pressure gradient ([energy and flux in a medium](../free-space/flux.md)). - Grating and prism compressors. +- Coupling one simulation's output field into another (a capillary, a fibre, a gas gap) through coherent field planes, with transport through free space, lenses, apertures, mirrors and spectral optics ([coupling simulations](coupling.md)). - Design tools with multi-soliton content, and the inverse design of a capillary's pressure and energy for a soliton order, count or largest eigenvalue. - Scans on process pools and Slurm clusters. From 5cb74622acb36631dfff1fff057dfad2c8be10da Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 02:59:42 +0100 Subject: [PATCH 30/50] SEP 31: Part II report for the capillary coupling tutorial Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019qNYqtCKh4iTKj7544yARb --- docs/dev/seps/sep-0031-simulation-coupling.md | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) diff --git a/docs/dev/seps/sep-0031-simulation-coupling.md b/docs/dev/seps/sep-0031-simulation-coupling.md index 8012aec73..2e2b2702e 100644 --- a/docs/dev/seps/sep-0031-simulation-coupling.md +++ b/docs/dev/seps/sep-0031-simulation-coupling.md @@ -1956,6 +1956,164 @@ plain defects among them are fixed above or were superseded): **Handoff.** Final needs the tutorial's merge, the end-of-SEP full gate, slow and coverage lanes, and the maintainer's decisions on questions 10, 11 and 17. +### Phase 5 tutorial report — nonlinear beam preparation and coupling into a capillary + +**Delivered.** `docs/tutorials/free_space/plot_08_radial_capillary_coupling.py` +(focused tutorial, runs on every docs build), linked from +`docs/guide/workflows/coupling.md` (the Gaussian-into-capillary section), +`docs/guide/waveguides/capillaries.md` ("When to use it") and +`docs/guide/free-space/index.md`. + +**Specification as run (§8.1).** Scalar, linearly polarised Gaussian on an envelope +grid; uniform argon at 3 bar and 293.15 K, the reference temperature of optmat's argon +polarisability (`species("Ar").susceptibility.reference_state`, recorded in the +output); 800 nm, 30 fs intensity FWHM; 20 mm gap; linear-focus 1/e² radius 37.5 µm at +the entrance; capillary radius 75 µm (w/a = 0.5), 5 mm long, eight HE1m modes. Kerr +only (`KerrResponse(gamma3("Ar"), thg=False)` in the gap; `kerr=True, thg=False, +plasma=False, raman=False` in the capillary). The beam is one `SpatialPulse` with +`propz=-20 mm`, built in the argon, for both runs. + +**Strength from the material functions.** n0 − 1 = 7.7206e-4, n2 = 3χ3/(4ε0cn0²) = +3.0931e-23 m²/W, z_R = 5.527 mm; B_lin = 0.3 rad gives P0 = 0.3793 GW and +**U = 12.114 µJ**, P0/Pcr = 0.1238 (Pcr = 3.064 GW from `critical_power`). + +**Results through `couple`** (`scalar_paraxial`, `model_error=0.05`; s_m = launched +energy of mode m in its native metric over `report.incident_energy`): + +| | s11 | s12 | s13 | launched/incident | budget used | +|---|---|---|---|---|---| +| linear | 0.906180 | 0.092289 | 9.36e-4 | 0.99953 | 0.018 | +| Kerr | 0.882348 | 0.115623 | 1.68e-3 | 0.99971 | 0.020 | +| change | **−2.3832e-2** | **+2.3334e-2** | +7.4e-4 | +1.8e-4 | | + +Peak on-axis intensity: linear 1.717e13, Kerr 1.956e13 W/cm². Wavefront change at the +800 nm bin (Kerr minus linear, masked below 1 % of the linear amplitude): +0.097 rad +on axis, +0.082 rad at r = w0, falling across the beam (the Kerr lens still +converging at the entrance). Capillary outputs (5 mm): linear HE11 11.060 µJ, HE12 +0.987 µJ; Kerr-prepared HE11 10.790 µJ, HE12 1.252 µJ. In both cases HE12 hands energy +to HE11 along the capillary well beyond its wall loss: the capillary's own Kerr +intermodal coupling, identical physics in both cases. + +**Agreement with an earlier scalar probe.** The probe's s11 = 0.90620 / +0.88237 and s12 = 0.09228 / 0.11562 differ from the `couple` shares by ≤ 2e-5. The +difference is the metric, not the field: the same planes give, for the linear run, +0.906180 (launched / M0 incident), 0.906188 (captured share in the common metric, +×n_ref/Re n_eff ≈ 1 + 8e-6) and 0.906191 (launched / the plane's axial-flux energy, +which differs from the M0 incident by the beam's obliquity, 1.2e-5). The changes +Δs11, Δs12 agree with the probe to 1e-5 of themselves. + +**Independent confirmation.** A separate script evaluated each entrance field off the +nodes by the inverse Hankel series E(r) = Σ_j (w_j/2π) Ẽ(k_j) J0(k_j r) (it reproduces +`node_spectra()` to 1.6e-15), projected it on J0(u_m r/a)/(√π a|J1(u_m)|) with a +600-node Gauss–Legendre rule over the core, and normalised by the plane quadrature of +|E|² weighted by n_ref. Its common-metric shares agree with `couple`'s captured shares +to all eight printed digits (linear 0.90618793 / 0.09229346, Kerr 0.88235581 / +0.11562827). + +**Refinement study** (scratch script, one variation at a time from the tutorial's +settings: radius 1 mm, n_r 256, window 300 fs, band 500 nm–1.4 µm (256 bins, +dt = 1.76 fs), rtol 1e-7, dz_max 1 mm in the study (0.25 mm in the gallery), eight +modes). δΔ is the change of the claimed difference relative to the baseline. + +| variation | bins | lin s11 | lin s12 | Kerr s11 | Kerr s12 | δΔs11 | δΔs12 | +|---|---|---|---|---|---|---|---| +| baseline | 256 | 0.906180 | 0.092289 | 0.882348 | 0.115623 | 0 | 0 | +| radius 0.8 mm | 256 | = | = | = | = | +8.4e-12 | −5.9e-12 | +| radius 1.5 mm | 256 | = | = | = | = | −1.1e-11 | +8.1e-12 | +| n_r 192 | 256 | = | = | = | = | +6.7e-16 | −3.2e-16 | +| n_r 512 | 256 | = | = | = | = | 0 | 0 | +| window 200 fs | 128 | = | = | = | = | +2.2e-16 | +9.0e-16 | +| window 500 fs | 512 | = | = | = | = | −6.7e-16 | +2.8e-17 | +| band 400 nm–2 µm | 512 | = | = | = | = | +1.0e-15 | −2.1e-15 | +| dt halved | 256 | = | = | = | = | +1.1e-16 | −1.4e-17 | +| modes 4 | 256 | = | = | = | = | 0 | 0 | +| modes 6 | 256 | = | = | = | = | 0 | 0 | +| rtol 1e-6 | 256 | = | = | = | = | −2.7e-8 | +1.5e-8 | +| rtol 1e-9 | 256 | = | = | = | = | +4.6e-9 | −3.5e-9 | +| dz_max 0.25 mm | 256 | = | = | = | = | +1.7e-9 | −1.2e-9 | +| dz_max 0.1 mm | 256 | = | = | = | = | +4.6e-9 | −3.5e-9 | +| rtol 1e-9 + dz_max 0.1 mm | 256 | = | = | = | = | +4.6e-9 | −3.5e-9 | +| model_error 0.1 | 256 | = | = | = | = | 0 | 0 | + +"=" is equal to the baseline at the six printed digits. Per-run uncertainty is the +root-sum-square over the five groups (domain, radial nodes, temporal, modes, +stepping) of each group's largest change; the linear run's is 1.6e-15, the Kerr run's +2.7e-8 (s11) and 1.5e-8 (s12). **Combined uncertainty: 2.7e-8 for Δs11 (1.1e-6 of +the change) and 1.5e-8 for Δs12 (6.2e-7 of it)**, each far below the §7 criterion of +one tenth; summing the two runs linearly instead changes nothing at that precision. +The step size, not rtol, is what limits the gap run here: with dz_max = 1 mm the +controller is capped and rtol alone does not refine it, so dz_max was varied too. +The modal span cannot move s11 or s12 (orthogonal profiles; the nested-set property +the phase 3 tests hold to 1e-12), and the recorded capillary envelope caps the span at +eight modes (`modes=16` is refused by name). Capture changes with the span (0.99945 +with four modes against 0.99954 with eight, linear), as it should. + +The 5 mm capillary outputs moved by ≤ 2e-6 µJ in HE11 and HE12 between eight and six +modes and between rtol at its default and 1e-8. + +**Checks.** No collapse: P0/Pcr = 0.124 and the on-axis intensity rises smoothly by +14 %. Collar: the fluence in the outer tenth of the radial domain is 1.5e-30 +(linear) and 3.5e-27 (Kerr) of the total. Aliasing: the coupling's numerical item +(content in the outer fifth of the transverse spectral window) is 7.9e-31 and +9.0e-28; the window and node refinements above confirm it. + +**Omitted ionisation (offline).** optmat's recommended argon rate at 800 nm +(`OptmatRate("Ar", 800e-9)`, record `rate/default/Ar@800nm`, validated, sources +NIST ASD 2026, Tong 2002, Tong 2005) integrated over the real field of a 30 fs +Gaussian at the Kerr run's peak on-axis intensity, **1.96e13 W/cm², gives an ionised +fraction of 1.1e-7** (3.7e-8 at the linear 1.72e13 W/cm²). The electron density, +about 8e18 m⁻³, lowers the index by 2.2e-9 against a Kerr increase of 6.1e-6: +Kerr-only is defensible and the strength was not reduced. + +**Beat length and the capillary.** Carrier formula 22.50 mm (0.222 periods in 5 mm). +From `MarcatiliMode` HE11/HE12 at 3 bar: L_beat 27.70, 22.50 and 18.95 mm at 650, 800 +and 950 nm; 5 mm power loss HE11 2.2e-3 / 3.3e-3 / 4.6e-3 and HE12 1.1e-2 / 1.7e-2 / +2.4e-2. + +**Meter reading.** The tutorial states that the energy is that through the launch +plane in the argon and points to `guide/free-space/flux.md` ("What energy means") and +the coupling chapter's `PlanarInterface` for converting a reading taken before a cell +window. No new manual text was needed: the flux page already says to multiply by the +window's transmission. + +**Runtime.** 5.0 s in the Sphinx build (sphinx-gallery's own timing), 4.7–6.4 s +standalone, on the development Mac (gap runs about 0.3 s each, capillary runs about +0.1 s each). The whole `poe docs` build took 26 s with the other galleries cached. + +**Docs build.** `uv run --no-sync poe docs` renders the tutorial; the build ends with +one warning, which is not from this work: `solprop/coupling/__init__.py`'s module +docstring references `solprop.coupling.transport.transport`, which Sphinx cannot +resolve (`py:func reference target not found`). It looks like the same +function-shadows-module problem phase 3 solved by naming its module `coupler.py`. + +**Deviations and notes.** + +1. The gallery's s_m are launched shares over the M0 incident energy, the + definition the phase 3 tests use; the captured (common-metric) shares are larger + by n_ref/Re n_eff (8e-6 for HE11) and are not printed. +2. The free-space gallery's `README.rst` still lists the older tutorials only; it + was outside this task's file list. +3. The wavefront panel uses the 800 nm bin, masked below 1 % of the linear field's + amplitude at that bin. + +### Worked example `plot_04_graded_index_multimode.py` — not re-run + +The example still calls `prop_waveguide(..., transverse=beam)` and builds its launch +shares with `project_transverse_field`. Its committed output recorded **50 min 36 s** +(`docs/auto_examples/waveguides/plot_04_graded_index_multimode.rst`), above the +30-minute limit for this task, so the source was left unchanged (an edited source +without regenerated output fails the normal docs build). The same guide is admitted +on the current path: `benchmarks/guides.py`'s `grin(nonlinear=True)` (100 µm core, +Δ = 0.0095, 12 µm beam) builds with `incident=SpatialPulse(GaussianPulse(1064 nm, +300 fs, 159.7 nJ), 12e-6)` and `coupling=CouplingModel("vector_paraxial", +model_error=0.2)` in 10.5 s: budget 3.08e-2 of 0.2 (contrast 9.5e-3, angular 2.5e-2, +closure 4.6e-3, passivity 1.1e-3), launched share 0.99998 of the incident 159.7 nJ. +The update is that substitution, the launch-share cell read from +`configuration["launch"]`, and the prose that says the launch is renormalised to the +declared energy (it now carries what the channels capture). It then needs one +`poe docs-examples plot_04_graded_index_multimode` run of about 50 minutes on an idle +machine. + ## Part III — History - 2026-10-22: Drafted after auditing existing launch, projection, spatial flux, From 3d7c1cc1f4c33665b83b8e24f85d87bc0bd6b2d1 Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 03:17:32 +0100 Subject: [PATCH 31/50] Example: graded-index launch as an incident SpatialPulse The graded-index worked example declared its beam with the removed transverse= keyword. It now launches an incident SpatialPulse under CouplingModel("vector_paraxial", model_error=0.1), reads the launch account from configuration["launch"], drops the project_transverse_field preview and the unneeded GeneralModalBasisWarning filters, and says that the launched energy is the captured share of the incident beam's. Output regenerated with poe docs-examples. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019qNYqtCKh4iTKj7544yARb --- ...glr_plot_04_graded_index_multimode_001.png | Bin 32532 -> 32520 bytes ...glr_plot_04_graded_index_multimode_002.png | Bin 59956 -> 59958 bytes ...glr_plot_04_graded_index_multimode_003.png | Bin 76195 -> 76174 bytes ...glr_plot_04_graded_index_multimode_004.png | Bin 49068 -> 49015 bytes ...glr_plot_04_graded_index_multimode_005.png | Bin 67276 -> 67305 bytes ...r_plot_04_graded_index_multimode_thumb.png | Bin 26670 -> 26702 bytes ...lot_04_graded_index_multimode.codeobj.json | 634 ++++++++++++------ .../plot_04_graded_index_multimode.ipynb | 18 +- .../plot_04_graded_index_multimode.py | 179 ++--- .../plot_04_graded_index_multimode.py.md5 | 2 +- .../plot_04_graded_index_multimode.rst | 269 ++++---- .../plot_04_graded_index_multimode.zip | Bin 39346 -> 41990 bytes .../plot_04_graded_index_multimode.py | 179 ++--- 13 files changed, 784 insertions(+), 497 deletions(-) diff --git a/docs/auto_examples/waveguides/images/sphx_glr_plot_04_graded_index_multimode_001.png b/docs/auto_examples/waveguides/images/sphx_glr_plot_04_graded_index_multimode_001.png index f32bdde10dcbf3d2c62288b28325e57eb607ae06..c95dc578b244cd68a2e7995bace88a0df56dd4a3 100644 GIT binary patch literal 32520 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 literal 32532 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 Js$m!O{{WUFxg-Dp diff --git a/docs/auto_examples/waveguides/images/sphx_glr_plot_04_graded_index_multimode_002.png b/docs/auto_examples/waveguides/images/sphx_glr_plot_04_graded_index_multimode_002.png index 698f87ed8eb3967fda8fbbe1b061ef8dc42410b6..aafa7c9573b0a084a0d0bff1e9fa1b88bb0edf0e 100644 GIT binary patch delta 57486 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 YBqSlrL!%Rg7(D})j;21mLft0xe@`-yG5`Po delta 57218 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 diff --git a/docs/auto_examples/waveguides/images/sphx_glr_plot_04_graded_index_multimode_003.png b/docs/auto_examples/waveguides/images/sphx_glr_plot_04_graded_index_multimode_003.png index 3973c05b9b35d3e5e3f7968b0ac93331d805aa42..ee6ec5685c6514b5b79427b936abb78577d3d6b4 100644 GIT binary patch literal 76174 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 literal 76195 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

J2rD}b9eA-$W_?odzV-nHG2F1!y|9Jcr&TchBx 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 diff --git a/docs/auto_examples/waveguides/images/sphx_glr_plot_04_graded_index_multimode_005.png b/docs/auto_examples/waveguides/images/sphx_glr_plot_04_graded_index_multimode_005.png index cde347b32ea149efe3792fa023b27b66bbbed92e..97888e1dd8e05eb0cf802ee864c66db477ca79dc 100644 GIT binary patch literal 67305 zcmbSz1zS{Y7w!z*-Q9wubazRYgmj2>cXy|NbhorL5r(%l^b(#_ete&_sv<8`3} z!rptHT6ZlXRg`2X@;Kh! zAUeqEID=a+>A6xa{qG zYMV(n?mKvqfrF2aAKbg)z)OM_K4rn?JB<(d_erm3ivxJ*fB!MG4wdlln@SVAVJX7@ zd#ywrj4X=w-|H-CIQ#(M3a?*}MPUK){d-MBs0VU-y>jmcCwmKM`@g&NTAKa;Uifce zXFyxupQYw^rUNL~Pst4GXXn3IwGQs&im@CW9c%0A0wW{eGwaj-d#HOkN5^;H>+3fT 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 diff --git a/docs/auto_examples/waveguides/images/thumb/sphx_glr_plot_04_graded_index_multimode_thumb.png b/docs/auto_examples/waveguides/images/thumb/sphx_glr_plot_04_graded_index_multimode_thumb.png index 8f7863794fccd6fb3f4ed6af1a09b953588bf54e..5e5be38741b04f363031dce272da370ab15e9842 100644 GIT binary patch literal 26702 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 y5%`;KDls9hl-+y1sr6=l&0~2?8kq diff --git a/docs/examples/waveguides/plot_04_graded_index_multimode.py b/docs/examples/waveguides/plot_04_graded_index_multimode.py index 721a085ba..a1bef0d13 100644 --- a/docs/examples/waveguides/plot_04_graded_index_multimode.py +++ b/docs/examples/waveguides/plot_04_graded_index_multimode.py @@ -33,8 +33,9 @@ The fibre is a 100 um core (50 um radius), parabolic, index contrast :math:`\Delta = 0.0095` (a numerical aperture of 0.2), on dispersive fused silica. The pump is a 300 fs pulse at 1064 nm with 500 kW peak power -- well below the -critical power for self-focusing, about 4 MW -- in a centred Gaussian beam of 12 um -waist, declared as a **transverse field** and projected onto the fibre's channels. +critical power for self-focusing, about 4 MW -- carried by a centred Gaussian beam of +12 um waist, declared as an **incident beam** and coupled into the fibre's channels +under the vector paraxial model (:doc:`/guide/workflows/coupling`). **What to look for.** The on-axis intensity oscillates at the self-imaging period from the entrance; the spectrum grows sidebands near the GPI frequencies predicted @@ -45,13 +46,13 @@ radially symmetric modes, so the guide is the weak-guidance ``LP_0m`` family (the provider's scalar approximation, whose contrast limit this fibre is inside); four of them, because the guide must be solved over the run's whole *active* band, which -reaches 3.6 um, and a fifth ``LP_0m`` channel is not bound there. The projection -reports the -fraction of the requested transverse profile -outside those four channels. Propagation then uses only the captured profile, -renormalised to the declared pulse energy; the uncaptured spatial content is -not propagated. This is a truncated model, not a mode-count convergence study. The -quadrature +reaches 3.6 um, and a fifth ``LP_0m`` channel is not bound there. The beam's +peak power and energy are those of the whole incident beam; the run launches only +the share of it the four channels capture -- light they miss would be lost at the +entrance, not restored -- and the coupling report gives both energies. A 12 um beam +is close to the fibre's fundamental mode (about 9 um), so the four ``LP_0m`` +channels take all but a few parts in :math:`10^5` of it. This is a truncated +model, not a mode-count convergence study. The quadrature uses two angular nodes, exact for fields with no azimuthal dependence. Raman scattering is left out, to keep the run to minutes: a silica core would declare :class:`~solprop.nonlinear.raman.SilicaTensorResponse` beside its Kerr term. The @@ -71,15 +72,14 @@ # response by :math:`n_2`, at the wavelength it was measured. import time -import warnings from dataclasses import dataclass import matplotlib.pyplot as plt import numpy as np from solprop.constants import C +from solprop.coupling import CouplingModel from solprop.driver import prop_waveguide -from solprop.exceptions import GeneralModalBasisWarning from solprop.grid import FieldGrid from solprop.modes import ( GradedIndexProvider, @@ -92,8 +92,8 @@ from solprop.plotting import prop_2d from solprop.processing import get_spectral_density from solprop.processing.modal import physical_energy, reconstructed_flux +from solprop.pulses import GaussianPulse, SpatialPulse from solprop.translate.general import general_modal_route -from solprop.translate.modal_tables import project_transverse_field RADIUS = 50e-6 """Core radius, m.""" @@ -108,13 +108,13 @@ """Fibre length, m.""" WAIST = 12e-6 -"""1/e field radius of the launched Gaussian beam, m.""" +"""1/e field (1/e^2 intensity) radius of the incident Gaussian beam, m.""" PEAK_POWER = 5e5 -"""Launched peak power, W.""" +"""Peak power of the whole incident beam, W.""" FWHM = 300e-15 -"""Launched duration, s.""" +"""Incident duration, s.""" started = time.perf_counter() silica = IndexMaterial(fused_silica(model="malitson_1965/sellmeier")) @@ -157,8 +157,8 @@ def identity(self): # grid's lowest declared frequency, 3.6 um, to its highest -- on a node rule of two # Gauss points per radial element and two angular nodes. The route gates the # interpolated basis's departure from orthonormality where the launched pulse is, -# and warns (it is silenced here, and quoted) that at the very longest active -# wavelengths, 3.5 um, where this run puts no light, it is 3e-4. +# and would warn if it exceeded its budget anywhere else in the active band; this +# guide's sampling passes without a warning. grid = FieldGrid.from_limits(LENGTH, WAVELENGTH, (450e-9, 1.8e-6), 1.2e-12) active = grid.omega[grid.active_mask] @@ -177,11 +177,6 @@ def identity(self): # the pattern repeats every ``z_p = pi a / sqrt(2 Delta)``. The solved spacings of # the finite, dispersive fibre are close to that and not equal to it. -with warnings.catch_warnings(): - warnings.simplefilter("ignore", GeneralModalBasisWarning) - route = general_modal_route( - guide, grid, z_max=LENGTH, omega_reference=float(grid.omega0) - ) centre = int(np.argmin(np.abs(grid.omega - grid.omega0))) neff = np.array( [ @@ -204,68 +199,97 @@ def identity(self): f"(ideal {np.pi * RADIUS / np.sqrt(2 * DELTA) * 1e3:.4f} mm)" ) -# %% -# The launch -# ---------- -# A Gaussian beam at the guide's nodes, the same at every frequency. The route -# projects it through the flux pairing onto the four channels and reports the -# share of it they do not capture: the higher ``LP_0m`` groups a 12 um beam also -# excites, which this guide does not carry. The energy is not reduced by it -- the -# run launches the declared energy into what the channels capture. - -radius = np.hypot(nodes.xy[:, 0], nodes.xy[:, 1]) -beam = np.zeros((nodes.xy.shape[0], 3)) -beam[:, 0] = np.exp(-((radius / WAIST) ** 2)) -projection = project_transverse_field( - route.tables, np.repeat(beam[:, :, None], grid.n_omega, axis=2) -) -shares = np.abs(projection.amplitudes[:, centre]) ** 2 -shares /= shares.sum() -print( - "launched shares:", - ", ".join( - f"{name} {share:.3f}" for name, share in zip(labels, shares, strict=True) - ), -) -print(f"share of the beam the four channels miss: {projection.residual_share:.3f}") - # %% # The run # ------- -# ``prop_waveguide(..., provider=guide, transverse=beam)``. Shot noise is added to -# the input (``noise="input"``, one photon per bin and channel, with a fixed seed), -# because GPI grows from noise at frequencies the pump does not contain. ``dz_max`` -# is a tenth of the self-imaging period: the source oscillates at that period, and -# the adaptive stepper would otherwise try steps that straddle it. The default -# cubic is the real one, so the third harmonic is carried too. - -energy = PEAK_POWER * FWHM * np.sqrt(np.pi / (4.0 * np.log(2.0))) +# ``prop_waveguide(..., provider=guide, incident=beam, coupling=...)``. The beam is +# a :class:`~solprop.pulses.SpatialPulse`: a Gaussian of the 12 um waist, centred +# on the axis and polarised along x, carrying the 300 fs pulse; its energy is the +# whole beam's, over its own plane. The run couples it into the four channels under +# the vector paraxial model -- the flux pairing of the beam's field, with the forward +# paraxial closure for its magnetic field, against the channels' own E/H at the +# nodes -- whose admission budget, ``model_error``, bounds that closure's error, +# the guide's contrast and the angular content of the beam and channels. The +# recovered amplitudes are launched unscaled: the launched energy is the share of +# the incident energy the channels capture, and anything they miss is lost at the +# entrance. ``model_error=0.1`` admits this guide: the weak-guidance contrast, +# 0.0095, must stay below an eighth of the budget, and the counted terms (printed +# below) sum to about 0.02. +# +# Shot noise is added to the input (``noise="input"``, one photon per bin and +# channel, with a fixed seed), because GPI grows from noise at frequencies the pump +# does not contain. ``dz_max`` is a tenth of the self-imaging period: the source +# oscillates at that period, and the adaptive stepper would otherwise try steps that +# straddle it. The default cubic is the real one, so the third harmonic is carried +# too. + +beam = SpatialPulse( + GaussianPulse( + wavelength=WAVELENGTH, + fwhm=FWHM, + energy=PEAK_POWER * FWHM * np.sqrt(np.pi / (4.0 * np.log(2.0))), + ), + WAIST, +) +coupling = CouplingModel("vector_paraxial", model_error=0.1) def propagate(length, n_save): - """Propagate the declared beam through `length` of the fibre.""" - with warnings.catch_warnings(): - warnings.simplefilter("ignore", GeneralModalBasisWarning) - return prop_waveguide( - (), - length, - provider=guide, - grid=grid, - transverse=beam, - lambda0=WAVELENGTH, - fwhm=FWHM, - energy=energy, - noise="input", - seed=41, - dz_max=z_p / 10.0, - n_save=n_save, - progress="none", - ) + """Propagate the incident beam through `length` of the fibre.""" + return prop_waveguide( + (), + length, + provider=guide, + grid=grid, + incident=beam, + coupling=coupling, + lambda0=WAVELENGTH, + noise="input", + seed=41, + dz_max=z_p / 10.0, + n_save=n_save, + progress="none", + ) run = propagate(LENGTH, 201) print(run) +# %% +# The launch account +# ------------------ +# The run records the coupling's report as ``configuration["launch"]``: the +# incident energy over the beam's whole plane, the energy the channels launch, the +# captured energy where the model defines one, and the admission budget against +# ``model_error``. The vector model names no captured or uncaptured energy -- the +# flux pairing and the incident measure are not one positive metric -- so its +# launched share is the measure of what the four channels take: here all but a +# few parts in :math:`10^5`, mostly in ``LP_01``. The launched energy is what the +# run's ``physical_energy`` reads at the entrance. + +launch = run.metadata.configuration["launch"] +print(launch) +captured = launch.captured_energy +print( + f"incident {launch.incident_energy * 1e9:.2f} nJ, " + f"launched {launch.launched_energy * 1e9:.2f} nJ " + f"({launch.efficiency:.5f} of the incident energy), captured " + + ( + f"{captured * 1e9:.2f} nJ" + if captured is not None + else f"not defined ({launch.captured_reason})" + ) +) +print(f"admission budget {launch.budget:.3e} of model_error {launch.model_error:g}") +entrance = np.sum(np.abs(np.asarray(run["field"])[0, :, 0, :]) ** 2, axis=-1) +print( + "launched shares:", + ", ".join( + f"{name} {share:.3f}" + for name, share in zip(labels, entrance / entrance.sum(), strict=True) + ), +) + # %% # Self-imaging # ------------ @@ -281,11 +305,10 @@ def propagate(length, n_save): # ``LP_01`` with a few per cent of ``LP_02``, so what oscillates is their beat. imaging = propagate(4.0 * z_p, 81) -with warnings.catch_warnings(): - warnings.simplefilter("ignore", GeneralModalBasisWarning) - imaging_route = general_modal_route( - guide, grid, z_max=4.0 * z_p, omega_reference=float(grid.omega0) - ) +imaging_route = general_modal_route( + guide, grid, z_max=4.0 * z_p, omega_reference=float(grid.omega0) +) +radius = np.hypot(nodes.xy[:, 0], nodes.xy[:, 1]) axis_nodes = radius <= np.min(radius) * 1.0001 z_imaging = np.asarray(imaging["z"]) on_axis = np.array( From 2d4b97cb17872e3471fc13a37e4eabba29ed35b3 Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 03:18:44 +0100 Subject: [PATCH 32/50] Docs: format the coupling guide's code blocks Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019qNYqtCKh4iTKj7544yARb --- docs/guide/workflows/coupling.md | 165 +++++++++++++++++++++++-------- 1 file changed, 126 insertions(+), 39 deletions(-) diff --git a/docs/guide/workflows/coupling.md b/docs/guide/workflows/coupling.md index f39a01952..c20834e36 100644 --- a/docs/guide/workflows/coupling.md +++ b/docs/guide/workflows/coupling.md @@ -62,12 +62,22 @@ from solprop.driver import Simulation from solprop.processing import field_plane simulation = Simulation.from_capillary( - 100e-6, 1e-3, "Ar", 1.0, lambda0=800e-9, wavelength_limits=(400e-9, 1.6e-6), - trange=200e-15, fwhm=20e-15, energy=20e-6, modes=2, n_save=3, noise=None, + 100e-6, + 1e-3, + "Ar", + 1.0, + lambda0=800e-9, + wavelength_limits=(400e-9, 1.6e-6), + trange=200e-15, + fwhm=20e-15, + energy=20e-6, + modes=2, + n_save=3, + noise=None, ) run = simulation.run(progress="none") -plane = field_plane(run) # the exit plane -middle = field_plane(run, z=0.5e-3) # a stored plane by position +plane = field_plane(run) # the exit plane +middle = field_plane(run, z=0.5e-3) # a stored plane by position print(plane, plane.reference.delay, plane.energy()) ``` @@ -103,7 +113,7 @@ from solprop.grid import FieldGrid wider = FieldGrid.from_limits(1e-3, 800e-9, (300e-9, 2e-6), 400e-15) moved, report = regrid(plane, wider) print(report.method, report.band_loss, report.window_loss) -print(moved.energy() / plane.energy()) # no renormalisation, and none needed here +print(moved.energy() / plane.energy()) # no renormalisation, and none needed here ``` What cannot be represented is refused, and what is removed is reported. Light outside @@ -133,12 +143,24 @@ without moving the pulse in its window. from solprop.coupling import CouplingModel, couple second = Simulation.from_capillary( - 100e-6, 1e-3, "Ar", 1.0, lambda0=800e-9, wavelength_limits=(400e-9, 1.6e-6), - trange=200e-15, fwhm=20e-15, energy=20e-6, modes=2, n_save=3, noise=None, + 100e-6, + 1e-3, + "Ar", + 1.0, + lambda0=800e-9, + wavelength_limits=(400e-9, 1.6e-6), + trange=200e-15, + fwhm=20e-15, + energy=20e-6, + modes=2, + n_save=3, + noise=None, +) +relaunch = couple( + field_plane(run), second.input_port(), model=CouplingModel("same_basis") ) -relaunch = couple(field_plane(run), second.input_port(), model=CouplingModel("same_basis")) continued = second.with_input(relaunch.launch).run(progress="none") -print(field_plane(continued).reference.delay) # the whole 2 mm path's +print(field_plane(continued).reference.delay) # the whole 2 mm path's ``` Two 1 mm capillaries relaunched this way reproduce one 2 mm capillary: the comparison @@ -267,17 +289,34 @@ from solprop.spatial import SpatialMedium from solprop.coupling import FieldPlane, PlaneGeometry capillary = Simulation.from_capillary( - 75e-6, 1e-3, "Ar", 1.0, lambda0=800e-9, wavelength_limits=(400e-9, 1.6e-6), - trange=200e-15, fwhm=20e-15, energy=20e-6, modes=4, n_save=3, noise=None, + 75e-6, + 1e-3, + "Ar", + 1.0, + lambda0=800e-9, + wavelength_limits=(400e-9, 1.6e-6), + trange=200e-15, + fwhm=20e-15, + energy=20e-6, + modes=4, + n_save=3, + noise=None, ) port = capillary.input_port() -beam = SpatialPulse(GaussianPulse(wavelength=800e-9, fwhm=20e-15, energy=10e-6), 37.5e-6) +beam = SpatialPulse( + GaussianPulse(wavelength=800e-9, fwhm=20e-15, energy=10e-6), 37.5e-6 +) incident = FieldPlane.from_pulse( - beam, port.grid, PlaneGeometry(0.0), space=RadialGrid(400e-6, 256), + beam, + port.grid, + PlaneGeometry(0.0), + space=RadialGrid(400e-6, 256), medium=SpatialMedium("Ar", 1.0), ) -coupled = couple(incident, port, model=CouplingModel("scalar_paraxial", model_error=0.05)) -print(coupled.report) # incident, launched, uncaptured, the budget +coupled = couple( + incident, port, model=CouplingModel("scalar_paraxial", model_error=0.05) +) +print(coupled.report) # incident, launched, uncaptured, the budget entered = capillary.with_input(coupled.launch) ``` @@ -296,13 +335,26 @@ the run's scalar profiles; its coefficients are coupled into another capillary's by direct overlap, without a transverse grid: ```python -exit_plane = field_plane(run, source=simulation) # 100 um, two modes +exit_plane = field_plane(run, source=simulation) # 100 um, two modes wider = Simulation.from_capillary( - 120e-6, 1e-3, "Ar", 1.0, lambda0=800e-9, wavelength_limits=(400e-9, 1.6e-6), - trange=200e-15, fwhm=20e-15, energy=20e-6, modes=4, n_save=3, noise=None, + 120e-6, + 1e-3, + "Ar", + 1.0, + lambda0=800e-9, + wavelength_limits=(400e-9, 1.6e-6), + trange=200e-15, + fwhm=20e-15, + energy=20e-6, + modes=4, + n_save=3, + noise=None, +) +into_wider = couple( + exit_plane, + wider.input_port(), + model=CouplingModel("scalar_paraxial", model_error=0.05), ) -into_wider = couple(exit_plane, wider.input_port(), - model=CouplingModel("scalar_paraxial", model_error=0.05)) print(into_wider.report.efficiency) ``` @@ -317,8 +369,11 @@ implied Fresnel factor. from solprop.coupling import Outcouple synthesised = Outcouple(RadialGrid(600e-6, 512)).apply(exit_plane) -through = couple(synthesised.plane, wider.input_port(), - model=CouplingModel("scalar_paraxial", model_error=0.05)) +through = couple( + synthesised.plane, + wider.input_port(), + model=CouplingModel("scalar_paraxial", model_error=0.05), +) difference = np.abs(through.launch.samples - into_wider.launch.samples) print(np.max(difference) / np.max(np.abs(into_wider.launch.samples))) ``` @@ -337,19 +392,32 @@ eighth is below the contrast. Small cores are not very paraxial: the LP01 field ```python from solprop.modes import StepIndexMode + def fibre(radius): - mode = StepIndexMode(radius, core=1.45, cladding=1.45 * np.sqrt(0.99), - profile_wavelength=1064e-9) + mode = StepIndexMode( + radius, core=1.45, cladding=1.45 * np.sqrt(0.99), profile_wavelength=1064e-9 + ) return Simulation.from_gnlse( - None, 1e-3, fibre=mode, n2=2.6e-20, lambda0=1064e-9, - wavelength_limits=(900e-9, 1300e-9), trange=400e-15, fwhm=100e-15, - energy=1e-12, n_save=2, + None, + 1e-3, + fibre=mode, + n2=2.6e-20, + lambda0=1064e-9, + wavelength_limits=(900e-9, 1300e-9), + trange=400e-15, + fwhm=100e-15, + energy=1e-12, + n_save=2, ) + source_fibre, target_fibre = fibre(2.18e-6), fibre(2.57e-6) fibre_run = source_fibre.run(progress="none") -spliced = couple(field_plane(fibre_run, source=source_fibre), target_fibre.input_port(), - model=CouplingModel("scalar_paraxial", model_error=0.1)) +spliced = couple( + field_plane(fibre_run, source=source_fibre), + target_fibre.input_port(), + model=CouplingModel("scalar_paraxial", model_error=0.1), +) print(spliced.report.efficiency) ``` @@ -491,11 +559,13 @@ beam = SpatialPulse(GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=1e-6), plane = FieldPlane.from_pulse( beam, grid, PlaneGeometry(0.0), space=RadialGrid(4e-3, 256), medium=air ) -small = RadialGrid(0.5e-3, 128) # the focal plane, sampled finely +small = RadialGrid(0.5e-3, 128) # the focal plane, sampled finely focus, report = transport( plane, - [ThinLens(0.5, model_error=1e-3), - FreeSpace(0.5, air, output=small, output_method="fresnel")], + [ + ThinLens(0.5, model_error=1e-3), + FreeSpace(0.5, air, output=small, output_method="fresnel"), + ], ) print(focus.geometry.z, focus.energy(), report.elements[0].certificate.ratio) ``` @@ -552,8 +622,18 @@ from solprop.optics import TaylorPhase argon = SpatialMedium("Ar", 1.0) receiving = Simulation.from_capillary( - 100e-6, 1e-3, "Ar", 1.0, lambda0=800e-9, wavelength_limits=(400e-9, 1.6e-6), - trange=200e-15, fwhm=20e-15, energy=20e-6, modes=2, n_save=3, noise=None, + 100e-6, + 1e-3, + "Ar", + 1.0, + lambda0=800e-9, + wavelength_limits=(400e-9, 1.6e-6), + trange=200e-15, + fwhm=20e-15, + energy=20e-6, + modes=2, + n_save=3, + noise=None, ) port = receiving.input_port() exit_field = Outcouple(RadialGrid(1.2e-3, 512), medium=argon).apply( @@ -562,11 +642,18 @@ exit_field = Outcouple(RadialGrid(1.2e-3, 512), medium=argon).apply( gap = FreeSpace(0.05, argon, beyond_window="remove") relayed, ledger = transport( exit_field.plane, - [SpectralTransfer(TaylorPhase((0.0, 0.0, -20e-30), 800e-9)), gap, - ThinLens(0.05, model_error=0.05), gap, - Aperture(600e-6, model_error=0.05, edge=20e-6, kind="tanh"), gap, - ThinLens(0.05, model_error=0.05), gap], - frame=simulation.frame, geometry=port.geometry, + [ + SpectralTransfer(TaylorPhase((0.0, 0.0, -20e-30), 800e-9)), + gap, + ThinLens(0.05, model_error=0.05), + gap, + Aperture(600e-6, model_error=0.05, edge=20e-6, kind="tanh"), + gap, + ThinLens(0.05, model_error=0.05), + gap, + ], + frame=simulation.frame, + geometry=port.geometry, ) second = couple(relayed, port, model=CouplingModel("scalar_paraxial", model_error=0.05)) print(ledger.specified_loss, ledger.removed, second.report.efficiency) From a0e0e85298fe3e773432429df85fc50ed3e9d796 Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 04:58:18 +0100 Subject: [PATCH 33/50] Tests: exclude the coupling extraction from solprop.sol; allow rounding in a lossless ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019qNYqtCKh4iTKj7544yARb --- tests/coupling/test_workflows.py | 3 ++- tests/test_sol.py | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/coupling/test_workflows.py b/tests/coupling/test_workflows.py index 5a3a58f5c..d5a9678e7 100644 --- a/tests/coupling/test_workflows.py +++ b/tests/coupling/test_workflows.py @@ -141,7 +141,8 @@ def assert_coupled(report: CouplingReport, received=None) -> None: def assert_transported(report: TransportReport) -> None: """Assert a transport ledger: passive, closed and with nothing restored.""" - assert report.energy_out <= report.energy_in + # A lossless element such as FreeSpace keeps the energy to rounding. + assert report.energy_out <= report.energy_in * (1 + 1e-12) # Each element books its change as loss, removal or signed approximation # error; the chain closes to the rounding of those sums. assert abs(report.ledger_residual) <= 1e-12 * report.energy_in diff --git a/tests/test_sol.py b/tests/test_sol.py index 87d2130a3..da5406326 100644 --- a/tests/test_sol.py +++ b/tests/test_sol.py @@ -72,6 +72,10 @@ "node_fluence", "species_ionisation", "species_ionisation_energy", + # The coupling extraction belongs to the solprop.coupling workflow, which the + # session does not flatten. + "PLANE_TOLERANCE", + "field_plane", }, # Two type aliases. "tools": {"Fill", "PulseShape"}, From 141dabfe1f23655d3708e4ef1678805a4b3a40b0 Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 10:10:56 +0100 Subject: [PATCH 34/50] SEP 31: record the graded-index example's re-run Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019qNYqtCKh4iTKj7544yARb --- docs/dev/seps/sep-0031-simulation-coupling.md | 28 ++++++++----------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/docs/dev/seps/sep-0031-simulation-coupling.md b/docs/dev/seps/sep-0031-simulation-coupling.md index 2e2b2702e..773494b1f 100644 --- a/docs/dev/seps/sep-0031-simulation-coupling.md +++ b/docs/dev/seps/sep-0031-simulation-coupling.md @@ -2096,23 +2096,17 @@ function-shadows-module problem phase 3 solved by naming its module `coupler.py` 3. The wavefront panel uses the 800 nm bin, masked below 1 % of the linear field's amplitude at that bin. -### Worked example `plot_04_graded_index_multimode.py` — not re-run - -The example still calls `prop_waveguide(..., transverse=beam)` and builds its launch -shares with `project_transverse_field`. Its committed output recorded **50 min 36 s** -(`docs/auto_examples/waveguides/plot_04_graded_index_multimode.rst`), above the -30-minute limit for this task, so the source was left unchanged (an edited source -without regenerated output fails the normal docs build). The same guide is admitted -on the current path: `benchmarks/guides.py`'s `grin(nonlinear=True)` (100 µm core, -Δ = 0.0095, 12 µm beam) builds with `incident=SpatialPulse(GaussianPulse(1064 nm, -300 fs, 159.7 nJ), 12e-6)` and `coupling=CouplingModel("vector_paraxial", -model_error=0.2)` in 10.5 s: budget 3.08e-2 of 0.2 (contrast 9.5e-3, angular 2.5e-2, -closure 4.6e-3, passivity 1.1e-3), launched share 0.99998 of the incident 159.7 nJ. -The update is that substitution, the launch-share cell read from -`configuration["launch"]`, and the prose that says the launch is renormalised to the -declared energy (it now carries what the channels capture). It then needs one -`poe docs-examples plot_04_graded_index_multimode` run of about 50 minutes on an idle -machine. +### Worked example `plot_04_graded_index_multimode.py` — re-run + +The example now calls `prop_waveguide(..., incident=beam, coupling=CouplingModel( +"vector_paraxial", model_error=0.1))` instead of `transverse=`, reads its launch account +from `configuration["launch"]`, and says that the launched energy is the captured share +of the incident beam's (no longer renormalised to the declared energy). The +`project_transverse_field` preview and the `GeneralModalBasisWarning` filters are gone. +Its output was regenerated with `poe docs-examples plot_04_graded_index_multimode` +(10 min 23 s): incident 159.67 nJ, launched 0.99998 of it, budget 1.832e-2 of 0.1 +(angular 1.25e-2, contrast 9.5e-3, closure 4.6e-3, passivity 1.1e-3); entrance shares +LP01 0.933, LP02 0.0625. The figures changed only in their last digits. ## Part III — History From 02578398397d04acd187345519c976191688b5d4 Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 10:24:07 +0100 Subject: [PATCH 35/50] Tests: cover the plane, port and extraction refusals Cover the remaining refusals and edge branches of the coupling plane, port, regrid and outcoupling, the saved-plane extraction, the translated port metric, the planar input port and the incident beam. A spatial run's writing simulation always holds its medium, so the extraction resolves its flux without a dead None branch. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019qNYqtCKh4iTKj7544yARb --- src/solprop/processing/planes.py | 6 +- tests/coupling/test_outcouple.py | 60 ++++++++++++++ tests/coupling/test_plane.py | 55 +++++++++++++ tests/coupling/test_port.py | 26 ++++++ tests/coupling/test_remap.py | 18 +++++ tests/driver/test_prop_planar.py | 8 ++ tests/processing/test_planes.py | 131 ++++++++++++++++++++++++++++++- tests/test_spatial_flux.py | 22 ++++++ tests/translate/test_coupling.py | 57 ++++++++++++++ 9 files changed, 378 insertions(+), 5 deletions(-) create mode 100644 tests/coupling/test_outcouple.py create mode 100644 tests/translate/test_coupling.py diff --git a/src/solprop/processing/planes.py b/src/solprop/processing/planes.py index 6998de57b..d74be1482 100644 --- a/src/solprop/processing/planes.py +++ b/src/solprop/processing/planes.py @@ -277,9 +277,9 @@ def _spatial_metric( if record.configuration_hash() != metadata.configuration_hash(): msg = "source= is not the simulation that wrote this output" raise ProcessingError(msg) - medium = cast("SpatialMedium | None", _source_part(source, "medium")) - if medium is not None: - flux = SpatialFlux.resolve(grid, space, medium, z=z) + # The simulation that wrote a spatial run always holds its bulk medium. + medium = cast("SpatialMedium", _source_part(source, "medium")) + flux = SpatialFlux.resolve(grid, space, medium, z=z) metric = PlaneMetric( "admittance", admittance=admittance, diff --git a/tests/coupling/test_outcouple.py b/tests/coupling/test_outcouple.py new file mode 100644 index 000000000..ba077fb44 --- /dev/null +++ b/tests/coupling/test_outcouple.py @@ -0,0 +1,60 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""The matched outcoupling of a modal plane with both polarisations (SEP 31).""" + +import math + +import numpy as np +import pytest + +from solprop.coupling import ( + FieldPlane, + Outcouple, + PlaneBasis, + PlaneGeometry, + ScalarModeBasis, +) +from solprop.grid import EnvelopeGrid, RadialGrid +from solprop.modes import MarcatiliMode + + +def test_both_polarisation_columns_of_a_mode_are_synthesised_as_x_and_y(): + grid = EnvelopeGrid.from_limits(1e-4, 800e-9, (700e-9, 920e-9), 200e-15) + modes = [MarcatiliMode(75e-6, "Ar", 3.0, m=m) for m in (1, 2)] + profiles = ScalarModeBasis.from_modes(modes, grid) + width = 30e-15 / (2 * math.sqrt(math.log(2))) + envelope = np.exp(-(((grid.omega - grid.omega0) * width) ** 2) / 4) + envelope = envelope * grid.active_mask + samples = np.zeros((2, 2, grid.n_omega), dtype=complex) + # HE11 carries x and, a quarter period later, half its amplitude in y; HE12 + # carries a little x only. + samples[0, 0] = envelope + samples[0, 1] = 0.5j * envelope + samples[1, 0] = 0.2 * envelope + plane = FieldPlane.from_samples( + samples, + grid, + PlaneGeometry(0.0), + exterior="zero", + basis=PlaneBasis("modal", "0" * 64, profiles.labels), + profiles=profiles, + ) + space = RadialGrid(500e-6, 512) + result = Outcouple(space).apply(plane) + synthesised = result.plane.samples + assert synthesised.shape == (512, 2, grid.n_omega) + # Orthonormal profiles: the y share is HE11's |0.5|^2 against the x columns' + # 1 + 0.2^2, to the modes' effective-index factors (1e-4) and the synthesis + # window. + weights = np.asarray(space.spectral_weights)[:, None] + x_energy = np.sum(weights * np.abs(synthesised[:, 0]) ** 2) + y_energy = np.sum(weights * np.abs(synthesised[:, 1]) ** 2) + assert y_energy / x_energy == pytest.approx(0.25 / 1.04, rel=1e-3) + # Matched exit: the field's flux and the modal power agree to the profiles' + # obliquity, as for a single polarisation. + np.testing.assert_allclose( + np.sum(result.synthesised), np.sum(result.modal), rtol=1e-6 + ) + assert np.sum(result.modal) == pytest.approx(plane.energy(), rel=1e-15) diff --git a/tests/coupling/test_plane.py b/tests/coupling/test_plane.py index e79b175c2..ae04a03f5 100644 --- a/tests/coupling/test_plane.py +++ b/tests/coupling/test_plane.py @@ -10,17 +10,20 @@ from solprop.coupling import ( FieldPlane, + NodalField, PlaneBasis, PlaneGeometry, PlaneMetric, PlaneProvenance, PlaneReference, + ScalarModeBasis, basis_identity, lattice_identity, modal_basis_identity, reference_at, space_record, ) +from solprop.coupling.plane import _frozen from solprop.exceptions import CouplingError from solprop.fourier import spectrum_to_optics from solprop.grid import EnvelopeGrid, FieldGrid, FreeGrid, LineGrid, RadialGrid @@ -290,3 +293,55 @@ def test_a_basis_identity_is_a_digest(): PlaneBasis("modal", "short") with pytest.raises(CouplingError, match="transverse grid"): PlaneBasis("radial", basis_identity("radial", {})) + + +def test_a_nodal_field_owns_finite_copies_of_matching_shapes(): + n_omega = 4 + nodes, weights = np.zeros((2, 2)), np.ones(2) + electric = np.ones((2, 3, n_omega), complex) + nodal = NodalField(nodes, weights, electric) + assert nodal.magnetic is None and not nodal.electric.flags.writeable + with pytest.raises(CouplingError, match="nodes must be finite"): + NodalField(np.full((2, 2), np.nan), weights, electric) + # A magnetic field of another shape is refused at the boundary; under the test + # session's runtime annotation checks it never reaches it, so the helper is + # asked directly. + with pytest.raises(CouplingError, match="magnetic has shape"): + _frozen(electric[..., :-1], np.complex128, "magnetic", electric.shape) + + +def test_a_metric_of_another_plane_s_shape_measures_nothing(): + plane = modal_plane() + n_omega = plane.grid.n_omega + dense = PlaneMetric("dense", matrix=np.ones((2, 2, n_omega), complex)) + with pytest.raises(CouplingError, match="metric shape"): + dense.spectral_energy(plane.samples, plane.grid) + admittance = PlaneMetric( + "admittance", + admittance=np.ones((2, n_omega), complex), + weights=np.ones(2), + scale=1.0, + ) + with pytest.raises(CouplingError, match="admittance shape"): + admittance.spectral_energy(plane.samples, plane.grid) + + +def test_scalar_profiles_describe_the_columns_of_a_modal_plane(): + plane = modal_plane() + profiles = ScalarModeBasis( + "capillary", + ("HE11", "HE12"), + 75e-6, + np.array([2.405, 5.520]), + np.zeros(2), + np.array([[1.0, 0.0], [1.0, 0.0]]), + np.ones((2, plane.grid.n_omega)), + ) + # Two profiles for a plane of one column. + with pytest.raises(CouplingError, match="scalar profiles"): + modal_plane(profiles=profiles) + + +def test_a_modal_plane_has_no_node_samples(): + with pytest.raises(CouplingError, match="no node samples"): + modal_plane().node_spectra() diff --git a/tests/coupling/test_port.py b/tests/coupling/test_port.py index 1f41e7bc1..7e5bb75d8 100644 --- a/tests/coupling/test_port.py +++ b/tests/coupling/test_port.py @@ -18,6 +18,7 @@ PlaneGeometry, PlaneMetric, PlaneReference, + ScalarModeBasis, basis_identity, couple, ) @@ -175,3 +176,28 @@ def test_ports_and_launches_refuse_malformed_parts(): CouplingLaunch( port, np.zeros((2, 1, grid.n_omega), complex), PlaneReference(), "x", {} ) + + +def test_a_port_s_profiles_describe_its_columns(): + grid = grid_of() + profiles = ScalarModeBasis( + "capillary", + ("HE11", "HE12"), + 75e-6, + np.array([2.405, 5.520]), + np.zeros(2), + np.array([[1.0, 0.0], [1.0, 0.0]]), + np.ones((2, grid.n_omega)), + ) + # Two profiles for a port of one column. + with pytest.raises(CouplingError, match="one per mode"): + port_of(profiles=profiles) + + +def test_a_launch_of_unknown_metric_cannot_be_checked_for_its_medium(): + port = port_of() + unknown = dataclasses.replace( + port, metric=PlaneMetric("unknown", reason="the Gram was not at hand") + ) + refusal = port.accepts(unknown) + assert refusal is not None and "the Gram was not at hand" in refusal diff --git a/tests/coupling/test_remap.py b/tests/coupling/test_remap.py index 23933309e..06901c2fb 100644 --- a/tests/coupling/test_remap.py +++ b/tests/coupling/test_remap.py @@ -294,3 +294,21 @@ def test_a_spatial_metric_follows_the_plane_to_its_new_lattice(): ) unknown, report = regrid(bare, target) assert unknown.metric.kind == "unknown" and report.energy_out is None + + +def test_a_zero_plane_moves_to_zero_with_nothing_lost(): + source = envelope() + basis = PlaneBasis("modal", basis_identity("modal", {"modes": ["test"]})) + plane = FieldPlane.from_samples( + np.zeros((1, 1, source.n_omega), complex), + source, + PlaneGeometry(0.0), + exterior="zero", + basis=basis, + ) + moved, report = regrid(plane, envelope(trange=2e-12, limits=(500e-9, 1.5e-6))) + # An empty record has no norm to share: no loss is reported, and the sum of + # zeros is exactly zero. + assert report.method == "direct" and report.record_norm == 0.0 + assert report.band_loss == 0.0 and report.window_loss == 0.0 + assert not np.any(moved.samples) diff --git a/tests/driver/test_prop_planar.py b/tests/driver/test_prop_planar.py index e61283960..3480ccd38 100644 --- a/tests/driver/test_prop_planar.py +++ b/tests/driver/test_prop_planar.py @@ -246,6 +246,14 @@ def test_the_simulation_builds_the_fills_medium_and_frame(): assert empty.medium.material == 1.0 +def test_a_planar_run_has_no_input_port(): + """Slab columns per y frequency are not exchanged in this release (SEP 31).""" + space = PlanarGrid(slab_modes((1,)), 1e-3, 8) + simulation = Simulation.from_planar(GRID, space, [], pulse(), settings=settings()) + with pytest.raises(DriverError, match="planar planes are not exchanged"): + simulation.input_port() + + def test_a_planar_run_binds_an_optmat_rate_to_the_inputs_peak_field(): """SEP 3, Q14 on the planar route, as on the free-space routes.""" space = PlanarGrid(slab_modes((1,)), 1e-3, 8) diff --git a/tests/processing/test_planes.py b/tests/processing/test_planes.py index 356bb3db4..832821201 100644 --- a/tests/processing/test_planes.py +++ b/tests/processing/test_planes.py @@ -11,14 +11,15 @@ from numpy.testing import assert_allclose from solprop.adiabatic import AdiabaticAdmission -from solprop.coupling import reference_at +from solprop.coupling import basis_identity, reference_at from solprop.driver import Simulation, SimulationSettings -from solprop.exceptions import ProcessingError +from solprop.exceptions import CouplingError, ProcessingError from solprop.fourier import spectrum_to_internal, spectrum_to_optics from solprop.grid import FieldGrid, FreeGrid, RadialGrid from solprop.modes import ( GradedIndexProvider, IndexMaterial, + MarcatiliMode, MaterialKerr, ModalGuide, PowerLawPermittivity, @@ -32,6 +33,7 @@ from solprop.spatial import SpatialMedium from solprop.stats import analytic_field from solprop.stepper import PIController +from solprop.translate.coupling import input_port EPS = np.finfo(float).eps @@ -298,3 +300,128 @@ def test_the_launch_plane_is_the_launched_spectrum_in_the_optics_convention( assert_allclose( plane.samples, expected, rtol=0, atol=1e-12 * np.max(np.abs(expected)) ) + + +def rewritten(output, **changes): + """Return a memory copy of an output whose run record has the given changes.""" + copy = MemoryOutput() + copy.open(dataclasses.replace(output.metadata, **changes)) + for saved in output.planes(): + copy.save(saved) + return copy + + +def test_an_output_without_a_saved_plane_is_refused(capillary_run): + _, output = capillary_run + empty = MemoryOutput() + empty.open(output.metadata) + with pytest.raises(ProcessingError, match="no saved plane"): + field_plane(empty) + + +def test_a_source_that_is_not_the_writing_simulation_is_refused(capillary_run): + _, output = capillary_run + with pytest.raises(ProcessingError, match="object has no metadata"): + field_plane(output, source=object()) + with pytest.raises(ProcessingError, match="not the simulation"): + field_plane(output, source=capillary(length=3e-3)) + + +def test_a_mode_family_with_angular_structure_has_no_scalar_profiles(): + modes = [ + MarcatiliMode(100e-6, "Ar", 1.0), + MarcatiliMode(100e-6, "Ar", 1.0, kind="TE", n=0), + ] + simulation = capillary(length=1e-3, n_save=2, modes=modes) + plane = field_plane(simulation.run(progress="none"), source=simulation) + # The TE01 mode is not a scalar HE1m profile: the plane carries none, and the + # scalar model is not offered for it. + assert plane.kind == "modal" and plane.profiles is None + + +def test_a_parent_record_without_its_plane_gives_no_lineage(capillary_run): + _, output = capillary_run + reference = {**output.metadata.reference, "parent": {"model": "same_basis"}} + plane = field_plane(rewritten(output, reference=reference)) + assert plane.provenance.lineage == () + + +def test_a_modal_plane_saved_off_the_planned_positions_has_an_unrecorded_basis( + capillary_run, +): + simulation, output = capillary_run + reference = dict(output.metadata.reference) + bases = reference["basis"] + # Every planned position moved a tenth of a millimetre: no saved plane is one. + reference["basis"] = {**bases, "z": [one + 1e-4 for one in bases["z"]]} + plane = field_plane(rewritten(output, reference=reference)) + assert plane.basis.identity == basis_identity("modal", {"unrecorded at": 2e-3}) + assert plane.basis.identity != simulation.input_port().basis.identity + + +@pytest.fixture(scope="module") +def radial_run(): + return spatial(RadialGrid(6e-4, 32)).run(progress="none") + + +def test_a_spatial_record_without_a_known_grid_or_measure_is_refused(radial_run): + metadata = radial_run.metadata + configuration = dict(metadata.configuration) + del configuration["spatial_grid"] + with pytest.raises(ProcessingError, match="no transverse grid"): + field_plane(rewritten(radial_run, configuration=configuration)) + configuration["spatial_grid"] = {"__class__": "solprop.modes.PlanarGrid"} + with pytest.raises(ProcessingError, match="not exchanged"): + field_plane(rewritten(radial_run, configuration=configuration)) + spatial_record = dict(metadata.spatial) + del spatial_record["flux_plan"] + with pytest.raises(ProcessingError, match="no plane measure"): + field_plane(rewritten(radial_run, spatial=spatial_record)) + + +def test_a_general_plane_without_its_source_has_an_unknown_dense_metric(general_run): + _, output = general_run + modal = dict(output.metadata.modal) + modal["metric"] = {**modal["metric"], "kind": "dense"} + plane = field_plane(rewritten(output, modal=modal)) + assert plane.metric.kind == "unknown" and "source=" in plane.metric.reason + with pytest.raises(CouplingError, match="unknown"): + plane.energy() + + +def test_a_general_source_with_other_tables_is_refused(general_run): + simulation, output = general_run + modal = dict(output.metadata.modal) + modal["tables"] = {**modal["tables"], "gauge_id": "another gauge"} + with pytest.raises(ProcessingError, match="not the reference"): + field_plane(rewritten(output, modal=modal), source=simulation) + + +def test_a_general_port_admits_the_vector_model_only_with_a_fixed_reference_and_guide( + general_run, +): + simulation, _ = general_run + problem, grid, tables = ( + simulation.translate(), + simulation.grid, + simulation.general.tables, + ) + port = simulation.input_port() + assert port.models == ("same_basis", "vector_paraxial") + common = {"route": "general_modal", "basis": port.basis, "frame": None} + bare = input_port(problem, grid, tables=tables, **common) + # Without its guide the port carries the E/H reference but no contrast, so it + # admits only the identity model. + assert bare.models == ("same_basis",) + assert bare.electric is not None and bare.contrast is None + # Tables whose synthesis varies along z (two degrees, as a tapered guide's): + # there is no one reference to sample, and the identity model alone is offered. + assert tables.synthesis is not None and tables.projection is not None + moving = dataclasses.replace( + tables, + synthesis=np.concatenate([tables.synthesis] * 2, axis=1), + projection=np.concatenate([tables.projection] * 2, axis=1), + ) + tapered = input_port(problem, grid, tables=moving, guide=simulation.guide, **common) + assert tapered.models == ("same_basis",) + assert tapered.electric is None and tapered.nodes is None diff --git a/tests/test_spatial_flux.py b/tests/test_spatial_flux.py index 39e701c29..5b4c11644 100644 --- a/tests/test_spatial_flux.py +++ b/tests/test_spatial_flux.py @@ -323,6 +323,28 @@ def test_an_incident_beam_refuses_what_it_cannot_sample() -> None: SpatialPulse(temporal, 2e-4).incident(grid, origin, index=np.zeros_like(index)) +def test_an_incident_beam_refuses_a_temporal_pulse_it_cannot_carry() -> None: + """A temporal pulse of two columns, and one whose sampled beam overflows.""" + grid = _envelope() + index = np.where(grid.active_mask & (grid.omega > 0), 1.45, 0.0) + origin = np.zeros((1, 2)) + + class TwoColumns: + def build(self, grid): + return np.zeros((2, 1, grid.n_omega), complex) + + with pytest.raises(PulseError, match="scalar single-column"): + SpatialPulse(TwoColumns(), 2e-4).incident(grid, origin, index=index) # pyright: ignore[reportArgumentType] - a duck-typed temporal pulse + # The request itself is finite, but its field in V/m squares past the largest + # double. + huge = GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=1e290) + with ( + pytest.warns(RuntimeWarning, match="overflow"), + pytest.raises(PulseError, match="incident beam is nonfinite"), + ): + SpatialPulse(huge, 2e-4).incident(grid, origin, index=index) + + def test_flux_refusals() -> None: """Widths refuse a signed profile; a side needs a jump; the index is required.""" grid = _envelope() diff --git a/tests/translate/test_coupling.py b/tests/translate/test_coupling.py new file mode 100644 index 000000000..cc18ef791 --- /dev/null +++ b/tests/translate/test_coupling.py @@ -0,0 +1,57 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""The frame record and the port metric of a translated problem (SEP 31).""" + +import dataclasses + +import pytest + +from solprop.driver import Simulation, SimulationSettings +from solprop.exceptions import TranslationError +from solprop.grid import FieldGrid, RadialGrid +from solprop.pulses import GaussianPulse, SpatialPulse +from solprop.spatial import SpatialMedium +from solprop.stepper import PIController +from solprop.translate.coupling import frame_record, port_metric + + +def radial(): + grid = FieldGrid.from_limits(1e-3, 800e-9, (600e-9, 1.2e-6), 400e-15) + settings = SimulationSettings( + controller=PIController(rtol=1e-8, atol=1e-14), + save_planes=[1e-3], + dz_max=2e-4, + dz_init=2e-4, + backend="numpy_ref", + ) + pulse = SpatialPulse( + GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=3e-9), 6e-5 + ) + return Simulation.from_radial( + grid, + RadialGrid(6e-4, 16), + SpatialMedium("Ar", 1.0), + [], + pulse, + settings=settings, + ) + + +def test_an_operator_without_its_frame_tables_has_no_frame_record(): + simulation = radial() + linear = simulation.translate().linear + assert frame_record(linear, 0.0) is not None + # An operator supplied without its frame tables leaves the entrance frame + # uncalibrated: there is nothing to integrate. + assert frame_record(dataclasses.replace(linear, beta0=None), 0.0) is None + + +def test_a_spatial_problem_without_its_plane_measure_has_no_port_metric(): + simulation = radial() + problem = simulation.translate() + assert port_metric(problem, simulation.grid, 0.0).kind == "admittance" + # A hand-assembled, unvalidated problem that dropped its plane measure. + with pytest.raises(TranslationError, match="plane measure"): + port_metric(dataclasses.replace(problem, flux=None), simulation.grid, 0.0) From 12abb5903068d4697f6162b08ee4c287f3e0390a Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 10:27:50 +0100 Subject: [PATCH 36/50] Tests: cover the coupler's and models' refusals Cover every refusal and edge branch of solprop.coupling.coupler and solprop.coupling.models: declared basis changes per bin and of the wrong shape, the vector model's Cartesian, modal and zero planes and its refusals, the F^-1 B recovery of a general plane, and the scalar profile sets. The F^-1 B recovery now solves only the occupied bins: off a guide's support its stored E/H are zeros and F is singular there. Two branches the scalar path cannot reach are folded away. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019qNYqtCKh4iTKj7544yARb --- src/solprop/coupling/coupler.py | 27 ++- src/solprop/coupling/models.py | 4 +- tests/coupling/test_couple.py | 112 ++++++++- tests/coupling/test_models.py | 114 ++++++++++ tests/coupling/test_vector_coupling.py | 299 +++++++++++++++++++++++++ 5 files changed, 544 insertions(+), 12 deletions(-) create mode 100644 tests/coupling/test_models.py create mode 100644 tests/coupling/test_vector_coupling.py diff --git a/src/solprop/coupling/coupler.py b/src/solprop/coupling/coupler.py index f456954eb..e86f6b0ca 100644 --- a/src/solprop/coupling/coupler.py +++ b/src/solprop/coupling/coupler.py @@ -884,13 +884,18 @@ def _flux_recovery( electric, magnetic = port.electric, port.magnetic flux = _flux_matrix(electric, magnetic, weights) pairing = _pairing(nodal.electric, nodal.magnetic, electric, magnetic, weights) - coordinates = np.linalg.solve(flux, pairing.T[..., None])[..., 0].T + original = plane.spectral_energy() + occupied = np.flatnonzero(_occupied(original)) + # Only the occupied bins are solved: off the guide's support its stored E/H + # are explicit zeros, F is singular there and the plane carries nothing. + coordinates = np.zeros(pairing.shape, dtype=np.complex128) + for k in occupied: + coordinates[:, k] = np.linalg.solve(flux[k], pairing[:, k]) samples = coordinates[:, None, :] recovered = port.metric.spectral_energy(samples, port.grid) - original = plane.spectral_energy() q_matrices = _metric_matrices(port.metric, port.shape[0], port.grid.n_omega) defect = 0.0 - for k in np.flatnonzero(_occupied(original)): + for k in occupied: low, high = _whitened_extremes(flux[k], q_matrices[k]) defect = max(defect, abs(low - 1.0), abs(high - 1.0)) tolerance = defect + 1e-10 @@ -1161,9 +1166,13 @@ def _scalar_paraxial( items.append(_source_metric_item(incident, native, epsilon)) if profiles.family == "capillary": envelope_parameters.update(_leaky_parameters(profiles, plane, incident)) - elif plane.profiles is not None: + else: + # A step-index target takes only a step-index modal plane with its profiles. envelope_parameters["uncovered"] = _uncovered_v_share( - plane.profiles, profiles, plane.grid.omega, incident + cast("ScalarModeBasis", plane.profiles), + profiles, + plane.grid.omega, + incident, ) items.extend( _contrast_items(profiles, plane, index, occupied, epsilon, envelope_parameters) @@ -1780,7 +1789,7 @@ def _contrast_items( profiles : solprop.coupling.models.ScalarModeBasis The target profiles. plane : solprop.coupling.plane.FieldPlane - The source plane. + The source plane; for a step-index target, a modal plane with its profiles. index : numpy.ndarray Reference index per bin. occupied : numpy.ndarray @@ -1814,10 +1823,8 @@ def _contrast_items( ) else: contrast = 0.0 - sets = [profiles] - if plane.profiles is not None: - sets.append(plane.profiles) - for one in sets: + # A step-index pair: the source is a modal plane with its own profiles. + for one in (profiles, cast("ScalarModeBasis", plane.profiles)): for values in (one.core_index, one.cladding_index): values = cast("np.ndarray", values) ratio = np.abs(values[occupied] ** 2 - index[occupied] ** 2) / ( diff --git a/src/solprop/coupling/models.py b/src/solprop/coupling/models.py index 2ace8c6e6..e1ffc93f7 100644 --- a/src/solprop/coupling/models.py +++ b/src/solprop/coupling/models.py @@ -528,7 +528,9 @@ def __post_init__(self) -> None: array.setflags(write=False) object.__setattr__(self, name, array) index = np.array(self.effective_index, dtype=np.float64, copy=True) - if index.ndim != 2 or index.shape[0] != n_modes: + # The test session's shape checker refuses a wrong shape before this guard, + # which is the only one outside it. + if index.ndim != 2 or index.shape[0] != n_modes: # pragma: no cover - see above msg = "a scalar basis's effective index has shape (n_modes, n_omega)" raise CouplingError(msg) index.setflags(write=False) diff --git a/tests/coupling/test_couple.py b/tests/coupling/test_couple.py index 11a1ad198..19e61cb05 100644 --- a/tests/coupling/test_couple.py +++ b/tests/coupling/test_couple.py @@ -38,7 +38,7 @@ basis_identity, couple, ) -from solprop.coupling.coupler import _contrast_items, _gain_item +from solprop.coupling.coupler import _contrast_items, _gain_item, _whitened_extremes from solprop.coupling.models import cone_angle, radial_rule from solprop.driver import Simulation, SimulationSettings from solprop.exceptions import CouplingError @@ -525,6 +525,116 @@ def test_a_declared_basis_change_relabels_without_changing_energy(port): del grid +def test_a_declared_change_per_bin_relabels_and_one_of_another_shape_is_refused( + port, +): + """A frequency-dependent gauge is applied bin by bin; a mismatched one refuses. + + A gauge ``exp(i phi(omega))`` on each mode is a relabelling at every bin, so the + launch is the plane's times it and the energy is unchanged; the exact report + names no budget. A change of the wrong size, a change into the wrong number of + bins, and a change from a spatial plane, whose metric is not modal, are refused. + """ + grid = port.grid + source = capillary(modes=2) + plane = modal_plane(source, [1.0, 0.5]) + target_identity = basis_identity("modal", {"gauged": True}) + target = dataclasses.replace( + source.input_port(), basis=PlaneBasis("modal", target_identity) + ) + phase = np.exp(1j * np.linspace(-1.0, 1.0, grid.n_omega)) + gauge = np.zeros((2, 2, grid.n_omega), dtype=complex) + gauge[0, 0], gauge[1, 1] = phase, phase.conj() + + def declared(matrix, source_identity=plane.basis.identity): + return CouplingModel( + "same_basis", + basis_change=BasisChange(matrix, source_identity, target_identity), + ) + + result = couple(plane, target, model=declared(gauge)) + np.testing.assert_allclose( + result.launch.samples[:, 0, :], + np.einsum("tsw,sw->tw", gauge, plane.samples[:, 0, :]), + rtol=1e-14, + ) + assert result.report.launched_energy == pytest.approx( + result.report.incident_energy, rel=1e-13 + ) + text = str(result.report) + assert "same_basis" in text and "budget" not in text + with pytest.raises(CouplingError, match="maps 3 columns to 3"): + couple(plane, target, model=declared(np.eye(3, dtype=complex))) + with pytest.raises(CouplingError, match=rf"has 5 bins, not {grid.n_omega}"): + couple(plane, target, model=declared(gauge[:, :, :5])) + beam = FieldPlane.from_analytic( + grid, + PlaneGeometry(0.0), + radial_beam(grid, 0.5 * RADIUS), + space=RadialGrid(300e-6, 64), + medium=MEDIUM, + ) + columns = np.zeros((4, 64), dtype=complex) + columns[:, :4] = np.eye(4) + relabel = CouplingModel( + "same_basis", + basis_change=BasisChange(columns, beam.basis.identity, port.basis.identity), + ) + with pytest.raises(CouplingError, match="identity or dense, got admittance"): + couple(beam, port, model=relabel) + + +def test_an_exact_copy_and_a_report_refuse_inconsistent_shapes(port): + """A plane of the port's basis but not its shape is no copy; a report's per-bin + arrays are finite.""" + grid = port.grid + short = FieldPlane.from_samples( + np.zeros((3, 1, grid.n_omega), dtype=complex), + grid, + PlaneGeometry(0.0), + exterior="zero", + basis=port.basis, + ) + with pytest.raises(CouplingError, match=r"shape \(3, 1, \d+\) is not the port's"): + couple(short, port, model=SAME) + beam = FieldPlane.from_analytic( + grid, + PlaneGeometry(0.0), + radial_beam(grid, 0.5 * RADIUS), + space=RadialGrid(300e-6, 128), + medium=MEDIUM, + ) + report = couple(beam, port, model=SCALAR).report + spoiled = np.array(report.incident) + spoiled[0] = np.nan + with pytest.raises(CouplingError, match="incident is finite, one value per bin"): + dataclasses.replace(report, incident=spoiled) + + +def test_a_spatial_plane_without_an_admittance_is_refused(port): + """The scalar fit weighs a spatial plane's columns by its admittance.""" + beam = FieldPlane.from_analytic( + port.grid, + PlaneGeometry(0.0), + radial_beam(port.grid, 0.5 * RADIUS), + space=RadialGrid(300e-6, 64), + medium=MEDIUM, + ) + unweighted = dataclasses.replace(beam, metric=PlaneMetric("identity")) + with pytest.raises(CouplingError, match="an admittance, got identity"): + couple(unweighted, port, model=SCALAR) + + +def test_whitening_refuses_a_metric_that_is_not_positive_definite(): + """The extremes are the generalised eigenvalues, and an indefinite metric has no + whitening.""" + form = np.diag([2.0, 0.5]).astype(complex) + metric = np.diag([1.0, 2.0]).astype(complex) + assert _whitened_extremes(form, metric) == pytest.approx((0.25, 2.0), rel=1e-14) + with pytest.raises(CouplingError, match="not positive definite"): + _whitened_extremes(form, np.diag([1.0, -1.0]).astype(complex)) + + # ----------------------------------------------------------- guides and planes diff --git a/tests/coupling/test_models.py b/tests/coupling/test_models.py new file mode 100644 index 000000000..982eadd59 --- /dev/null +++ b/tests/coupling/test_models.py @@ -0,0 +1,114 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""The coupling models' declarations and scalar profile sets, and their refusals. + +SEP 31 section 4.3: a declared basis change at every bin, the budget's cone, the +weak-guidance LP0m eigenvalue below its cutoff, and the scalar profile sets a +capillary's or a step-index fibre's HE1m modes give, with every inconsistent set +refused by name. +""" + +import dataclasses +import math + +import numpy as np +import pytest +from scipy.special import jn_zeros + +from solprop.coupling import BasisChange, CouplingModel, ScalarModeBasis +from solprop.coupling.models import _lp_parameter, cone_angle +from solprop.exceptions import CouplingError +from solprop.modes import MarcatiliMode, StepIndexMode + +CORE, CLADDING = 1.45, 1.44 +"""A weakly guiding step: Delta = 6.9e-3.""" + + +def step_index_mode(radius=4e-6, **keywords): + """Return a step-index mode of the weak step, profiled at 800 nm.""" + return StepIndexMode( + radius, core=CORE, cladding=CLADDING, profile_wavelength=800e-9, **keywords + ) + + +def test_a_frequency_dependent_basis_change_is_taken_bin_by_bin(grid): + """A trailing frequency axis is moved first; another bin count is refused.""" + phases = np.exp(1j * np.linspace(0.0, 1.0, grid.n_omega)) + gauge = np.zeros((2, 2, grid.n_omega), dtype=complex) + gauge[0, 0] = phases + gauge[1, 1] = phases.conj() + change = BasisChange(gauge, "0" * 64, "1" * 64) + matrices = change.at(grid.n_omega) + assert matrices.shape == (grid.n_omega, 2, 2) + np.testing.assert_array_equal(matrices[:, 0, 0], phases) + np.testing.assert_array_equal(matrices[:, 0, 1], 0.0) + constant = BasisChange(np.eye(2, dtype=complex), "0" * 64, "1" * 64).at( + grid.n_omega + ) + assert constant.shape == (grid.n_omega, 2, 2) + with pytest.raises(CouplingError, match=rf"has {grid.n_omega} bins, not 5"): + change.at(5) + + +def test_the_cone_is_the_budget_s_and_same_basis_has_none(): + """A model with a budget admits its cone; exact recovery has no angular budget.""" + model = CouplingModel("scalar_paraxial", model_error=0.05) + assert model.cone == cone_angle(0.05) + assert 1 - math.cos(model.cone) == pytest.approx(0.05 / 8, rel=1e-14) + with pytest.raises(CouplingError, match="no angular budget"): + _ = CouplingModel("same_basis").cone + + +def test_an_lp_mode_below_its_cutoff_is_refused(): + """LP02 exists only above the first zero of J1; at or below it there is no root.""" + cutoff = float(jn_zeros(1, 1)[0]) + u, w = _lp_parameter(1.2 * cutoff, 2) + assert cutoff < u < float(jn_zeros(0, 2)[-1]) + assert u**2 + w**2 == pytest.approx((1.2 * cutoff) ** 2, rel=1e-14) + for v in (0.9 * cutoff, cutoff): + with pytest.raises(CouplingError, match="LP02 is cut off"): + _lp_parameter(v, 2) + + +def test_a_step_index_basis_prints_and_has_no_leaky_field(grid): + """A dielectric fibre has no wall term, so no leaky parameter; a zero cone has + no angular content.""" + profiles = ScalarModeBasis.from_modes([step_index_mode()], grid) + assert repr(profiles) == "ScalarModeBasis(step_index, HE1,1:even, a=4e-06 m)" + assert profiles.leaky(grid.omega) is None + np.testing.assert_array_equal(profiles.cone_gram(0.0), np.zeros((1, 1))) + assert np.all(profiles.cone_gram(1e6) > 0.0) + + +def test_a_scalar_basis_refuses_nonfinite_parameters(grid): + """The profile parameters and Jones vectors are finite. + + A wrong shape is refused by the test session's shape checker before the + constructor's own guard, which is the only one outside it. + """ + profiles = ScalarModeBasis.from_modes([step_index_mode()], grid) + with pytest.raises(CouplingError, match=r"u has shape \(1,\)"): + dataclasses.replace(profiles, u=np.array([np.nan])) + with pytest.raises(CouplingError, match=r"jones has shape \(1, 2\)"): + dataclasses.replace(profiles, jones=np.array([[np.inf, 0.0]])) + + +def test_a_scalar_basis_refuses_sets_it_has_no_profiles_for(grid): + """An empty set, mixed families, capillaries of two radii, a step-index mode with + angular structure and step-index modes of two fibres are refused by name.""" + with pytest.raises(CouplingError, match="at least one mode"): + ScalarModeBasis.from_modes([], grid) + with pytest.raises(CouplingError, match="'MarcatiliMode', 'StepIndexMode'"): + ScalarModeBasis.from_modes( + [MarcatiliMode(75e-6, "Ar", 3.0), step_index_mode()], grid + ) + with pytest.raises(CouplingError, match="different radii"): + ScalarModeBasis.from_modes( + [MarcatiliMode(75e-6, "Ar", 3.0), MarcatiliMode(50e-6, "Ar", 3.0)], grid + ) + with pytest.raises(CouplingError, match="HE2,1:even has angular structure"): + ScalarModeBasis.from_modes([step_index_mode(6e-6, n=2)], grid) + with pytest.raises(CouplingError, match="share one fibre and profile"): + ScalarModeBasis.from_modes([step_index_mode(), step_index_mode(4.4e-6)], grid) diff --git a/tests/coupling/test_vector_coupling.py b/tests/coupling/test_vector_coupling.py new file mode 100644 index 000000000..d1d7752d3 --- /dev/null +++ b/tests/coupling/test_vector_coupling.py @@ -0,0 +1,299 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Coupling into a general modal guide: the vector model and the flux recovery. + +SEP 31 section 4.3. ``vector_paraxial`` recovers a beam's coordinates in a guide +that carries E/H at fixed nodes with the flux pairing F; a radial and a Cartesian +plane of one beam launch the same amplitudes, an ordinary modal plane enters +through its synthesised field, and every unqualified pair is refused by name. +``same_basis`` recovers a general plane read with its simulation in another basis +at the same nodes by ``a = F^-1 B``. The guide is the weakly guiding 4 um strand +of the incident-launch tests; a propagation is two steps. +""" + +import dataclasses +import math + +import numpy as np +import pytest + +from modal_provider_cases import GENERAL_Z_END, general_grid, general_guide +from solprop.coupling import ( + CouplingModel, + FieldPlane, + PlaneBasis, + PlaneGeometry, + ScalarModeBasis, + couple, +) +from solprop.driver import Simulation +from solprop.exceptions import CouplingError +from solprop.grid import FreeGrid, RadialGrid +from solprop.modes import StepIndexMode +from solprop.processing import field_plane +from solprop.pulses import GaussianPulse, SpatialPulse + +pytestmark = pytest.mark.filterwarnings( + "ignore::solprop.exceptions.GeneralModalBasisWarning" +) +"""The strand's nine knots depart from orthonormal near the band's long edge, where +the launched spectrum puts nothing (``tests/driver/test_general_modal.py``).""" + +PARITIES = (("HE", 1, 1, "even"), ("HE", 1, 1, "odd")) +BUDGET = 0.2 +VECTOR = CouplingModel("vector_paraxial", model_error=BUDGET) +SAME = CouplingModel("same_basis") +WAVELENGTH, FWHM, ENERGY = 835e-9, 20e-15, 1e-12 + + +def strand(grid, descriptors=PARITIES[:1], blocks=(), n_r=24): + """Return a guide of a 4 um core at a 0.01 index step, nine knots. + + The step is below the vector model's contrast screen. + """ + return general_guide( + descriptors=descriptors, + blocks=blocks, + radius=4e-6, + grid=grid, + n_r=n_r, + knots=9, + indices=(1.45, 1.44), + ) + + +@pytest.fixture(scope="module") +def grid(): + """The general modal tests' field grid.""" + return general_grid() + + +def simulation(guide, grid): + """Return a short general modal simulation of a guide on the grid.""" + return Simulation.from_waveguide( + (), + GENERAL_Z_END / 10, + provider=guide, + grid=grid, + lambda0=WAVELENGTH, + energy=ENERGY, + fwhm=FWHM, + pulseshape="gauss", + backend="numpy_ref", + dz_max=GENERAL_Z_END / 20, + dz_init=GENERAL_Z_END / 20, + n_save=2, + ) + + +@pytest.fixture(scope="module") +def pair(grid): + """The strand's two HE11 parities, declared degenerate, as a simulation.""" + guide = strand(grid, PARITIES, ((0, 1),)) + return simulation(guide, grid) + + +@pytest.fixture(scope="module") +def pair_port(pair): + """The pair's input port.""" + return pair.input_port() + + +def beam(waist): + """Return an x-polarised Gaussian beam carrying the tests' pulse.""" + return SpatialPulse( + GaussianPulse(wavelength=WAVELENGTH, fwhm=FWHM, energy=ENERGY), waist + ) + + +def step_index_plane(grid, jones): + """Return an ordinary modal plane of the strand's LP01 profile. + + ``jones`` gives the x and, for two polarisations, the y amplitude; the spectrum + is the tests' pulse's Gaussian. + """ + profiles = ScalarModeBasis.from_modes( + [StepIndexMode(4e-6, core=1.45, cladding=1.44, profile_wavelength=WAVELENGTH)], + grid, + ) + width = FWHM / (2 * math.sqrt(math.log(2))) + envelope = np.exp(-(((grid.omega - grid.omega0) * width) ** 2) / 2) + samples = np.zeros((1, len(jones), grid.n_omega), dtype=complex) + for p, amplitude in enumerate(jones): + samples[0, p] = amplitude * envelope * grid.active_mask + return FieldPlane.from_samples( + samples, + grid, + PlaneGeometry(0.0), + exterior="zero", + basis=PlaneBasis("modal", "0" * 64, profiles.labels), + profiles=profiles, + ) + + +def channel_energy(result): + """Return each launched channel's energy, J (identity metric).""" + return np.sum( + result.launch.samples[:, 0, :].real ** 2 + + result.launch.samples[:, 0, :].imag ** 2, + axis=1, + ) + + +def test_a_cartesian_plane_launches_what_the_radial_plane_of_the_beam_does( + grid, pair_port +): + """One beam on a radial and on a Cartesian plane gives one launch. + + Both planes resolve the 6 um beam to rounding, so the field each gives at the + guide's nodes is the same and so are the recovered amplitudes. The Cartesian + window's half-width, 25 um, is inside the guide's outermost node: the nodes + beyond it see the declared zero exterior, and the beam's share near the window's + edge, counted as numerical error, is rounding. + """ + index = np.asarray(pair_port.reference_index) + radial = FieldPlane.from_pulse( + beam(6e-6), + grid, + PlaneGeometry(0.0), + space=RadialGrid(60e-6, 128), + index=index, + ) + cartesian = FieldPlane.from_pulse( + beam(6e-6), + grid, + PlaneGeometry(0.0), + space=FreeGrid(25e-6, 64, 25e-6, 64), + index=index, + ) + assert cartesian.exterior == "zero" + assert np.max(np.hypot(*np.asarray(pair_port.nodes).T)) > 25e-6 + first = couple(radial, pair_port, model=VECTOR) + second = couple(cartesian, pair_port, model=VECTOR) + scale = float(np.max(np.abs(first.launch.samples))) + np.testing.assert_allclose( + second.launch.samples, first.launch.samples, rtol=0.0, atol=1e-9 * scale + ) + numerical = next(i for i in second.report.items if i.name == "numerical") + assert numerical.value is not None and numerical.value < 1e-12 + assert 0.0 < second.report.launched_energy < second.report.incident_energy + + +def test_an_ordinary_modal_plane_enters_through_its_synthesised_field(grid, pair_port): + """A step-index LP01 plane fills the strand's HE11 to its weak-guidance overlap. + + The LP01 profile is the weak-guidance limit of HE11, so nearly all of it is + launched. With a y component of half the x amplitude, the even channel takes + the x field and the odd one the y field, a quarter of the energy. + """ + single = couple(step_index_plane(grid, (1.0,)), pair_port, model=VECTOR) + report = single.report + assert 0.999 < report.launched_energy / report.incident_energy < 1.0 + assert report.budget <= BUDGET + both = couple(step_index_plane(grid, (1.0, 0.5j)), pair_port, model=VECTOR) + assert both.report.incident_energy == pytest.approx( + 1.25 * report.incident_energy, rel=1e-12 + ) + even, odd = channel_energy(both) + assert odd / even == pytest.approx(0.25, rel=1e-6) + + +def test_a_plane_with_no_field_is_a_valid_zero_launch(grid, pair_port): + """Nothing is occupied, so nothing is probed and nothing is launched.""" + empty = dataclasses.replace( + step_index_plane(grid, (1.0,)), + samples=np.zeros((1, 1, grid.n_omega), dtype=complex), + ) + result = couple(empty, pair_port, model=VECTOR) + assert result.report.incident_energy == 0.0 + assert result.report.launched_energy == 0.0 + assert not np.any(result.launch.samples) + + +def test_vector_paraxial_refuses_unqualified_pairs_by_name(grid, pair_port): + """A port without nodal E/H, a modal plane without profiles, a general plane, a + window short of the nodes with an unknown exterior, and evanescent columns.""" + plane = step_index_plane(grid, (1.0,)) + for port in ( + dataclasses.replace(pair_port, electric=None), + dataclasses.replace( + pair_port, route="modal", basis=PlaneBasis("modal", "0" * 64) + ), + ): + with pytest.raises(CouplingError, match="this port does not"): + couple(plane, port, model=VECTOR) + with pytest.raises(CouplingError, match="not a modal plane without profiles"): + couple(dataclasses.replace(plane, profiles=None), pair_port, model=VECTOR) + general = FieldPlane.from_samples( + np.zeros((2, 1, grid.n_omega), dtype=complex), + grid, + PlaneGeometry(0.0), + exterior="zero", + basis=PlaneBasis("general_modal", "0" * 64), + ) + with pytest.raises(CouplingError, match=r"not a general_modal plane$"): + couple(general, pair_port, model=VECTOR) + index = np.asarray(pair_port.reference_index) + + def gaussian(waist): + def field(r, omega): + # Narrow enough to vanish to rounding at the active band's edges. + spectrum = np.exp(-(((omega - grid.omega0) * FWHM) ** 2)) + return np.exp(-((r / waist) ** 2))[:, None] * spectrum[None, :] + + return field + + cropped = FieldPlane.from_analytic( + grid, + PlaneGeometry(0.0), + gaussian(6e-6), + space=RadialGrid(30e-6, 128), + index=index, + exterior="unknown", + ) + with pytest.raises(CouplingError, match="exterior is unknown"): + couple(cropped, pair_port, model=VECTOR) + # A 0.2 um waist has angular content far beyond n omega / c. + evanescent = FieldPlane.from_analytic( + grid, + PlaneGeometry(0.0), + gaussian(0.2e-6), + space=RadialGrid(10e-6, 256), + index=index, + ) + with pytest.raises(CouplingError, match="columns that do not propagate"): + couple(evanescent, pair_port, model=VECTOR) + + +@pytest.mark.medium +def test_a_general_plane_is_recovered_in_another_basis_at_common_nodes(grid, pair): + """``same_basis`` recovers a general plane through F^-1 B, or names why not. + + The pair is launched in its even channel and run two steps; its exit plane, + read with its simulation, carries E and H at the strand's nodes. Recovered in + the even channel alone (another basis at the same nodes) it keeps its energy to + rounding: the odd channel it leaves out is empty. The odd channel alone does not + span it; a plane read without its simulation has no E/H; a port without them, + or at other nodes, cannot pair it. + """ + output = pair.run(progress="none") + plane = field_plane(output, source=pair) + assert plane.nodal is not None + even = simulation(strand(grid), grid).input_port() + assert even.basis.identity != plane.basis.identity + result = couple(plane, even, model=SAME) + assert result.report.launched_energy == pytest.approx( + result.report.incident_energy, rel=1e-10 + ) + odd_guide = strand(grid, PARITIES[1:]) + with pytest.raises(CouplingError, match="not in the port's span"): + couple(plane, simulation(odd_guide, grid).input_port(), model=SAME) + with pytest.raises(CouplingError, match="read it with source="): + couple(field_plane(output), even, model=SAME) + with pytest.raises(CouplingError, match="the port's E/H at fixed nodes"): + couple(plane, dataclasses.replace(even, magnetic=None), model=SAME) + coarse = strand(grid, n_r=20) + with pytest.raises(CouplingError, match="common quadrature"): + couple(plane, simulation(coarse, grid).input_port(), model=SAME) From 6c6eeffc9859b171379b656528db9ebd2fc7a734 Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 10:34:31 +0100 Subject: [PATCH 37/50] Coupling: refuse modified-noise planes and same-grid fields at the window edge field_plane refuses a run with the modified noise model, whose auxiliary noise phase is not reconstructed into a plane. FreeSpace gains edge_tolerance (default 1e-4): on the same-grid route a propagated field filling the window's outer band is refused, suggesting a larger window or beyond_window='remove'. SEP 31 records both maintainer decisions. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019qNYqtCKh4iTKj7544yARb --- CHANGELOG.md | 6 ++- docs/dev/seps/sep-0031-simulation-coupling.md | 40 ++++++++++++++- docs/guide/workflows/coupling.md | 17 +++++-- src/solprop/coupling/elements.py | 50 ++++++++++++++++++- src/solprop/processing/planes.py | 19 +++++-- tests/coupling/test_transport.py | 40 +++++++++++++-- tests/processing/test_planes.py | 13 +++++ 7 files changed, 170 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1da9cd476..265869801 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,7 +56,8 @@ The first public release. It contains: phase 2). `solprop.coupling` holds `FieldPlane`, a coherent field at one plane with its reference, geometry, metric, basis identity and provenance; `regrid`, the direct finite-time Fourier remap between temporal grids with its loss report and no - renormalisation; and `InputPort`. `processing.field_plane` reads a saved plane back, + renormalisation; and `InputPort`. `processing.field_plane` reads a saved plane back + (refusing a modified-noise run's, whose auxiliary noise phase it does not reconstruct), `Simulation.input_port()` describes a simulation's entrance and `with_input` takes a launch bound to it, recording `configuration["coupling"]` and drawing no new noise. Output schema 24 records each run's plane-reference record (`RunMetadata.reference`). @@ -89,7 +90,8 @@ The first public release. It contains: -- and returns the plane with its reference, geometry, medium, metric and provenance updated and a sequential ledger in joules. Thin screens carry their angular-flux certificate; undersampled phases, wrapped rays and evanescent content are refused - unless their removal is declared. Refusals raise `CouplingError`. + unless their removal is declared, and a same-grid field filling the window's outer + band is refused beyond `FreeSpace(edge_tolerance=...)` (default 1e-4). Refusals raise `CouplingError`. - **Coupled workflows** ([SEP 31](https://solprop.readthedocs.io/en/latest/dev/seps/sep-0031-simulation-coupling.html), phase 5). The eight compositions of the coupling chapter -- capillary into capillary, diff --git a/docs/dev/seps/sep-0031-simulation-coupling.md b/docs/dev/seps/sep-0031-simulation-coupling.md index 773494b1f..9733501d1 100644 --- a/docs/dev/seps/sep-0031-simulation-coupling.md +++ b/docs/dev/seps/sep-0031-simulation-coupling.md @@ -1929,7 +1929,8 @@ plain defects among them are fixed above or were superseded): 5. *(Phase 2 Q2)* Regrid band loss in physical energy: add it beside the record-norm share when the transport core's per-bin admittance is reused; not a defect. 6. *(Phase 2 Q3)* A modified-noise run's plane: refuse it by name until its auxiliary - phase is reconstructed (recommended; currently taken as stored). + phase is reconstructed (recommended; currently taken as stored). **Resolved + 2026-10-24**: refused (A2 below). 7. *(Phase 2 Q4)* `field_plane` on the general-modal accessor whitelist: yes, but the whitelist feeds the schema-7 records, so it belongs with the next output-schema change. @@ -1950,6 +1951,7 @@ plain defects among them are fixed above or were superseded): 15. *(Phase 4 Q2)* Record the frame in `FieldPlane`: yes, with the next output-schema change, so `transport` needs no `frame=`. 16. *(Phase 4 Q4)* Refuse the same-grid edge share with its own tolerance: recommended. + **Resolved 2026-10-24**: refused beyond `edge_tolerance` (A3 below). 17. *(New)* Should a capillary's envelope range be extended past $\delta = 10^{-2}$ (narrow capillaries in the infrared), with a re-measured bound? @@ -2108,6 +2110,42 @@ Its output was regenerated with `poe docs-examples plot_04_graded_index_multimod (angular 1.25e-2, contrast 9.5e-3, closure 4.6e-3, passivity 1.1e-3); entrance shares LP01 0.933, LP02 0.0625. The figures changed only in their last digits. +### Maintainer decisions — 2026-10-24 + +- **A2 — modified-noise planes are refused** (Phase 5 open question 6, from Phase 2 + question 3; resolved). `processing.field_plane` raises `ProcessingError` for a run whose + `RunMetadata.noise.model` is `"modified"`, saying that the saved field leaves the + one-photon background to the auxiliary `noise_phase`, which is not reconstructed into a + plane, and suggesting `noise="input"` or `noise=None` for a source run. The check comes + before the plane is selected, so every plane of such a run is refused; an input-noise + run is read as stored, its background being in the saved field. Reconstruction + according to the documented saved-field meaning (§4.7) stays possible later and would + lift the refusal. Delivered: `src/solprop/processing/planes.py`, + `tests/processing/test_planes.py` + (`test_a_modified_noise_run_is_refused_by_name_and_an_input_noise_run_read`, a 0.5 mm + capillary run), the coupling chapter and the changelog's phase 2 line. +- **A3 — the same-grid edge share refuses with its own tolerance** (Phase 5 open + question 16, from Phase 4 question 4; resolved). `FreeSpace(edge_tolerance=1e-4)` is + new; on the same-grid route (no `output`) a propagated field whose reference-measure + share in the window's outer band (the declared collar, or the outer tenth of each + half-width) exceeds it raises `CouplingError`, suggesting a larger window, + `beyond_window="remove"` when the removal is not yet declared, or a Fresnel output + plane. `edge_share` stays in the diagnostics; the angular-output route keeps its + existing refusal at `sampling_tolerance`. The default was set from the shares the + existing tests produce: the eight workflow compositions reach at most 6.3e-7 (the + capillary exit into a wider capillary) and 3.6e-6 (the SPM, compressor and 4f relay + composition, with its declared removal), and the well-resolved transport tests at most + 5.4e-6 (the sequential ledger); 1e-4 leaves a factor of about 20 above those and is met + by a Gaussian beam on a collarless radial window of about 2.4 times its 1/e² radius. + Three tests built deliberately near the edge now pass an explicit tolerance or assert + the refusal: the sub-wavelength evanescent beam (1.4e-3 in the outer band before + propagation), the aperture-then-gap order test (1.9e-3 after the declared removal), and + the collarless-window test (1.6e-2 and 0.12 after removal, which now asserts the + refusal and then admits it with `edge_tolerance=1.0`). Delivered: + `src/solprop/coupling/elements.py`, `tests/coupling/test_transport.py` + (`test_a_same_grid_field_in_the_outer_band_is_refused_by_its_own_tolerance`), the + coupling chapter and the changelog's phase 4 line. + ## Part III — History - 2026-10-22: Drafted after auditing existing launch, projection, spatial flux, diff --git a/docs/guide/workflows/coupling.md b/docs/guide/workflows/coupling.md index c20834e36..d80903fe1 100644 --- a/docs/guide/workflows/coupling.md +++ b/docs/guide/workflows/coupling.md @@ -86,7 +86,12 @@ reference's arrays, so its physical fields (`plane.nodal`) and a dense basis Gra read with the simulation that ran it, `field_plane(run, source=simulation)`; the identities are checked first, as {py:mod}`solprop.processing.modal` checks them. A spatial plane's measure is rebuilt from the file's plane measure; with `source=` it -also carries the medium. A hybrid planar run's planes are refused by name. +also carries the medium. A hybrid planar run's planes are refused by name, and so are +the planes of a run with the modified noise model (`noise="modified"`): its saved field +leaves the one-photon background to an auxiliary noise phase, which is not reconstructed +into a plane, so the stored samples alone would drop it in the next stage. A source run +meant for coupling uses `noise="input"`, whose background travels in the saved field, +or `noise=None`. ## Moving a plane between grids @@ -486,8 +491,14 @@ window wraps back, which is the same as sampling the propagator's phase below it Nyquist rate, and is refused beyond `sampling_tolerance`. `beyond_window="remove"` declares the removal of that angular band instead, and the removed energy is reported. Significant nonpropagating content is refused too, or removed explicitly with -`band_limit=`. An output plane of another radius or sampling is the caller's explicit -choice of method; nothing switches between them automatically: +`band_limit=`. The ray test does not see a field that has already spread into the +window's outer band (the declared collar, or the outer tenth of each half-width), where +the representation wraps or reflects it; a propagated field whose share there exceeds +`edge_tolerance` (default $10^{-4}$) is refused too, and the refusal suggests a larger +window, `beyond_window="remove"` when the removal is not yet declared, or a Fresnel +output plane. The share is reported as `edge_share` either way. An output plane of +another radius or sampling is the caller's explicit choice of method; nothing switches +between them automatically: - `output_method="angular"` sums the propagated spectrum at the output nodes. It is exact and nonparaxial but lives inside the source window: a field that reaches the diff --git a/src/solprop/coupling/elements.py b/src/solprop/coupling/elements.py index e92c06f4a..9d421d354 100644 --- a/src/solprop/coupling/elements.py +++ b/src/solprop/coupling/elements.py @@ -2445,7 +2445,11 @@ class FreeSpace: the window would wrap (or reflect), which is the same condition as sampling the propagator's phase :math:`k_zL` below its Nyquist rate in :math:`\kappa`. Content beyond ``sampling_tolerance`` is refused, unless ``beyond_window='remove'`` - declares its removal, which is then reported as removed energy. With `output`, + declares its removal, which is then reported as removed energy. A propagated + field whose share in the window's outer band (the declared collar, or the outer + tenth of each half-width) exceeds ``edge_tolerance`` is refused too: it is + already meeting the periodic or reflecting edge, which the ray test does not + see. With `output`, a plane of another radius or sampling is evaluated by chunked direct summation; the method is always the caller's explicit choice: @@ -2494,6 +2498,12 @@ class FreeSpace: Largest admitted reference-measure share of content whose rays leave the window (same-grid and angular routes), of field at the source edge (angular output) and of aliased content (Fresnel output). Default 1e-6. + edge_tolerance : float + Largest admitted reference-measure share of the propagated field in the + window's outer band on the same-grid route. Default 1e-4: on a radial + window without a collar a Gaussian beam meets it when the window's radius + is about 2.4 times its 1/e^2 radius, and the well-resolved transports of + the coupling chapter stay at or below a few 1e-6. beyond_window : {'refuse', 'remove'} What to do with content whose rays leave the window over the distance: refuse it (the default), or remove it explicitly as an angular band, @@ -2516,6 +2526,7 @@ class FreeSpace: frame_phase: float = 0.0 evanescent_tolerance: float | None = None sampling_tolerance: float = 1e-6 + edge_tolerance: float = 1e-4 beyond_window: Literal["refuse", "remove"] = "refuse" def _validate(self, state: SpatialState) -> None: @@ -2603,6 +2614,7 @@ def apply(self, state: SpatialState) -> ElementResult: CouplingError If the element is invalid, the medium is not the plane's, the field holds significant evanescent content, its rays wrap beyond the window, + the propagated field fills the window's outer band on the same grid, the angular output leaves the source window, or the Fresnel kernel is undersampled. """ @@ -2619,7 +2631,9 @@ def apply(self, state: SpatialState) -> ElementResult: removed += window_removed notes: tuple[str, ...] = () result = propagated - if self.output is not None: + if self.output is None: + self._check_edge(diagnostics["edge_share"]) + else: edge = _edge_share(propagated) if edge > self.sampling_tolerance: msg = ( @@ -2653,6 +2667,38 @@ def apply(self, state: SpatialState) -> ElementResult: ) return ElementResult(result, report) + def _check_edge(self, edge: float) -> None: + """Refuse a same-grid result that fills the window's outer band. + + Parameters + ---------- + edge : float + Reference-measure share of the propagated field in the outer band. + + Raises + ------ + CouplingError + If `edge` exceeds ``edge_tolerance``. + """ + if edge <= self.edge_tolerance: + return + # The wrap test follows rays per column; a field that has spread into the + # outer band is already meeting the periodic (Cartesian) or reflecting + # (radial) edge whatever its rays do next, so it is refused on its own. + msg = ( + f"the propagated field puts {edge:.3e} of itself in the window's outer " + f"band (edge_tolerance {self.edge_tolerance:.1e}), where the " + "representation wraps or reflects it: enlarge the window" + + ( + ", or declare the removal of content leaving it with " + "beyond_window='remove'" + if self.beyond_window == "refuse" + else "" + ) + + ', or evaluate a larger output plane with output_method="fresnel"' + ) + raise CouplingError(msg) + def _limited( self, state: SpatialState, flux: SpatialFlux, measured: _Measured ) -> tuple[SpatialState, float]: diff --git a/src/solprop/processing/planes.py b/src/solprop/processing/planes.py index d74be1482..9fd1237e0 100644 --- a/src/solprop/processing/planes.py +++ b/src/solprop/processing/planes.py @@ -435,7 +435,8 @@ def field_plane( true-power coefficients, a general basis's Gram; the path certificate is the :class:`~solprop.adiabatic.AdiabaticAdmission` the spatial record rebuilds. Radial, Cartesian, ordinary modal and general modal runs are read; a hybrid - planar run is refused by name. + planar run is refused by name, and so is a run with the modified noise model, + whose auxiliary noise phase is not reconstructed into the plane. Parameters ---------- @@ -463,8 +464,8 @@ def field_plane( ------ ProcessingError If the output predates schema 24 or holds no record, both `plane` and `z` - are given, the selection names no stored plane, the run is planar, or - `source` is not the run that wrote it. + are given, the selection names no stored plane, the run is planar or used + the modified noise model, or `source` is not the run that wrote it. Examples -------- @@ -490,6 +491,18 @@ def field_plane( "cannot be exchanged" ) raise ProcessingError(msg) + if metadata.noise is not None and metadata.noise.model == "modified": + # A modified-noise run injects its one-photon background as a source term + # and saves the field without it; the auxiliary ``noise_phase`` is what lets + # processing restore the background. Exchanging the stored samples alone + # would silently drop it in the next stage (SEP 31 section 4.7). + msg = ( + "this run used the modified noise model, whose saved field leaves the " + "one-photon background to the auxiliary noise_phase; that phase is not " + "reconstructed into a plane, so its planes are not exchanged: rerun with " + "noise='input' or noise=None to couple the output" + ) + raise ProcessingError(msg) if z is not None and plane != -1: msg = f"give a plane index or a position, not both: plane={plane}, z={z}" raise ProcessingError(msg) diff --git a/tests/coupling/test_transport.py b/tests/coupling/test_transport.py index 2595cb34b..73893c6cc 100644 --- a/tests/coupling/test_transport.py +++ b/tests/coupling/test_transport.py @@ -331,8 +331,9 @@ def test_element_order_matters_and_a_chain_commutes_only_internally( state = beam(space, 0.3e-3) aperture = Aperture(0.25e-3, model_error=0.05, edge=20e-6, kind="rcos") # The aperture's diffraction reaches the window over 0.2 m; its removal is - # declared, which keeps both orders on the same small grid. - gap = FreeSpace(0.2, air, beyond_window="remove") + # declared, and the 1.9e-3 left in the outer band admitted, which keeps both + # orders on the same small grid. + gap = FreeSpace(0.2, air, beyond_window="remove", edge_tolerance=1e-2) first = apply_sequence(state, [aperture, gap]) second = apply_sequence(state, [gap, aperture]) assert not np.allclose(first.state.spectrum, second.state.spectrum, rtol=1e-3) @@ -452,7 +453,9 @@ def test_evanescent_content_is_refused_or_removed_explicitly( state = _narrow_state(grid, air) with pytest.raises(CouplingError, match="nonpropagating"): FreeSpace(1e-6, air).apply(state) - limited = FreeSpace(1e-6, air, band_limit=0.5).apply(state) + # The sub-wavelength beam rings across this 64-node window, 1.4e-3 of it in the + # outer band before any propagation; only the band removal is under test. + limited = FreeSpace(1e-6, air, band_limit=0.5, edge_tolerance=1e-2).apply(state) report = limited.report assert report.removed > 0 assert abs(report.approximation_error) <= 1e-12 * report.energy_in @@ -461,6 +464,26 @@ def test_evanescent_content_is_refused_or_removed_explicitly( ) +def test_a_same_grid_field_in_the_outer_band_is_refused_by_its_own_tolerance( + beam: BeamFactory, air: SpatialMedium +): + space = RadialGrid(0.5e-3, 64) + # A 0.4 mm beam on a 0.5 mm window fills its collar before it moves: no ray + # leaves over a micrometre, so only the edge share can see it. + filling = beam(space, 0.4e-3) + with pytest.raises(CouplingError, match="outer band") as refusal: + FreeSpace(1e-6, air).apply(filling) + message = str(refusal.value) + assert "enlarge the window" in message + assert "beyond_window='remove'" in message + report = FreeSpace(1e-6, air, edge_tolerance=1.0).apply(filling).report + assert report.diagnostics["edge_share"] > 1e-4 + assert report.diagnostics["wrap_share"] <= 1e-6 + # A beam well inside the window passes the default. + inside = FreeSpace(1e-2, air).apply(beam(space, 0.1e-3)).report + assert inside.diagnostics["edge_share"] <= 1e-4 + + def test_output_plane_refusals(beam: BeamFactory, air: SpatialMedium): space = RadialGrid(0.5e-3, 64) state = beam(space, 0.1e-3) @@ -562,7 +585,16 @@ def test_windows_without_a_collar_use_their_outer_tenth( # refused unless its removal is declared, and then it is reported. with pytest.raises(CouplingError, match="beyond_window='remove'"): FreeSpace(0.5, air).apply(state) - report = FreeSpace(0.5, air, beyond_window="remove").apply(state).report + # What stays still fills the outer tenth, which is refused by its own + # tolerance, and the refusal no longer offers the removal already made. + removing = FreeSpace(0.5, air, beyond_window="remove") + with pytest.raises( + CouplingError, match=r"outer band.*window, or ev" + ) as refusal: + removing.apply(state) + assert "beyond_window" not in str(refusal.value) + admitted = FreeSpace(0.5, air, beyond_window="remove", edge_tolerance=1.0) + report = admitted.apply(state).report assert report.diagnostics["edge_share"] > 1e-3 assert report.diagnostics["wrap_share"] > 0 assert report.removed == report.diagnostics["window_removed"] > 0 diff --git a/tests/processing/test_planes.py b/tests/processing/test_planes.py index 832821201..8ef3a21a9 100644 --- a/tests/processing/test_planes.py +++ b/tests/processing/test_planes.py @@ -311,6 +311,19 @@ def rewritten(output, **changes): return copy +def test_a_modified_noise_run_is_refused_by_name_and_an_input_noise_run_read(): + output = capillary(length=5e-4, n_save=2, noise="modified").run(progress="none") + with pytest.raises(ProcessingError, match=r"modified noise model.*noise_phase"): + field_plane(output) + # The same planes under the input model carry their background in the saved + # field itself, so they are exchanged as stored. + assert output.metadata is not None + assert output.metadata.noise is not None + noise = dataclasses.replace(output.metadata.noise, model="input") + plane = field_plane(rewritten(output, noise=noise)) + assert plane.geometry.z == pytest.approx(5e-4, rel=0, abs=1e-15) + + def test_an_output_without_a_saved_plane_is_refused(capillary_run): _, output = capillary_run empty = MemoryOutput() From 602c598309ff822317aeb39a59fcc41bd4ffd5ab Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 10:37:59 +0100 Subject: [PATCH 38/50] Tests: move the transport sequence helper out of the conftest Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019qNYqtCKh4iTKj7544yARb --- tests/coupling/conftest.py | 18 +----------------- tests/coupling/sequence_cases.py | 29 +++++++++++++++++++++++++++++ tests/coupling/test_elements.py | 2 +- tests/coupling/test_transport.py | 2 +- 4 files changed, 32 insertions(+), 19 deletions(-) create mode 100644 tests/coupling/sequence_cases.py diff --git a/tests/coupling/conftest.py b/tests/coupling/conftest.py index 496b1a4bf..c89f5b7ea 100644 --- a/tests/coupling/conftest.py +++ b/tests/coupling/conftest.py @@ -8,14 +8,12 @@ leaving a band of several dozen frequencies for the per-bin ledger. """ -from collections.abc import Callable, Sequence -from dataclasses import dataclass +from collections.abc import Callable import numpy as np import pytest from solprop.coupling.elements import SpatialState -from solprop.coupling.transport import TransportReport, _sequence from solprop.grid import EnvelopeGrid, FreeGrid, RadialGrid from solprop.pulses import GaussianPulse, SpatialPulse from solprop.spatial import SpatialMedium @@ -26,20 +24,6 @@ BeamFactory = Callable[..., SpatialState] -@dataclass(frozen=True) -class Applied: - """The state after a sequence and its ledger, for the element-level tests.""" - - state: SpatialState - report: TransportReport - - -def apply_sequence(state: SpatialState, elements: Sequence[object]) -> Applied: - """Run a sequence on a plain state: the core that transport() wraps.""" - final, report = _sequence(state, elements) - return Applied(final, report) - - @pytest.fixture(scope="module") def grid() -> EnvelopeGrid: """Return a narrow envelope grid around 800 nm.""" diff --git a/tests/coupling/sequence_cases.py b/tests/coupling/sequence_cases.py new file mode 100644 index 000000000..3123a90a0 --- /dev/null +++ b/tests/coupling/sequence_cases.py @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""The element-level sequence the transport tests run on a plain state. + +It lives outside ``conftest.py`` because pytest discourages importing a conftest as a +module. +""" + +from collections.abc import Sequence +from dataclasses import dataclass + +from solprop.coupling.elements import SpatialState +from solprop.coupling.transport import TransportReport, _sequence + + +@dataclass(frozen=True) +class Applied: + """The state after a sequence and its ledger, for the element-level tests.""" + + state: SpatialState + report: TransportReport + + +def apply_sequence(state: SpatialState, elements: Sequence[object]) -> Applied: + """Run a sequence on a plain state: the core that transport() wraps.""" + final, report = _sequence(state, elements) + return Applied(final, report) diff --git a/tests/coupling/test_elements.py b/tests/coupling/test_elements.py index 3ebd4f61b..39023351f 100644 --- a/tests/coupling/test_elements.py +++ b/tests/coupling/test_elements.py @@ -9,7 +9,7 @@ import numpy as np import pytest -from tests.coupling.conftest import apply_sequence +from tests.coupling.sequence_cases import apply_sequence from solprop.constants import C from solprop.coupling.elements import ( diff --git a/tests/coupling/test_transport.py b/tests/coupling/test_transport.py index 73893c6cc..67685c16b 100644 --- a/tests/coupling/test_transport.py +++ b/tests/coupling/test_transport.py @@ -12,7 +12,7 @@ import pytest from scipy.integrate import quad from scipy.special import j0, j1 -from tests.coupling.conftest import apply_sequence +from tests.coupling.sequence_cases import apply_sequence from solprop import fourier from solprop.constants import C From 9642d710a6cf4bf66d1870031c5458ac1aafcd27 Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 10:54:39 +0100 Subject: [PATCH 39/50] Coupling: admit a beam into a weakly guiding fibre; widen the flux span check scalar_paraxial now couples a radial or Cartesian plane into a step-index fibre's LP01 within STEP_INDEX_BEAM_ENVELOPE: the scalar capture against the field's full-vector flux coupling into the exact HE11 pair (H from its own plane-wave spectrum), maximised over every field in the widest admitted cone. The discrepancy is 0.467 Delta at V 1.5, so the bound, 1e-2 at contrast 0.01, scales with the fibre's contrast; contrast and V outside the range are refused by name, and band bins beyond the V range are counted in full. same_basis's F^-1 B recovery checked that a field lies in the port's span against the port's metric defect only; it now also allows the plane's own measured defect against its flux on the common nodes, and reports both. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019qNYqtCKh4iTKj7544yARb --- CHANGELOG.md | 9 +- docs/dev/seps/sep-0031-simulation-coupling.md | 72 ++++ docs/guide/workflows/coupling.md | 33 +- src/solprop/coupling/__init__.py | 2 + src/solprop/coupling/coupler.py | 166 ++++++--- src/solprop/coupling/envelopes.py | 50 ++- tests/coupling/test_couple.py | 83 ++++- tests/coupling/test_envelopes.py | 333 +++++++++++++++++- tests/coupling/test_vector_coupling.py | 29 +- tests/coupling/test_workflows.py | 68 ++++ 10 files changed, 787 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 265869801..86d44f5d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -105,7 +105,14 @@ The first public release. It contains: field (bound 6e-3 at its range's top, delta = 1e-2, scaled by the pair's delta) rather than against the scalar profile itself; bins outside an envelope's range, including a fibre's V across the band, are counted in full; the modal overlap quadrature enters - the budget as `2 delta + delta^2`. + the budget as `2 delta + delta^2`. `scalar_paraxial` also admits a radial or + Cartesian beam into a weakly guiding step-index fibre (LP01, V 1.5 to 2.4, contrast + to 0.01) within `STEP_INDEX_BEAM_ENVELOPE`, the scalar overlap against the beam's + full-vector flux coupling into the exact HE11 pair maximised over every field in the + admitted cone (bound 1e-2 at contrast 0.01, scaled by the fibre's contrast). + `same_basis`'s flux recovery of a general modal plane allows the plane's own metric + defect as well as the port's when it checks that the field lies in the port's span, + and reports both. - **Media and responses.** - Gases, gas mixtures, glasses and metals from the [optmat](https://github.com/LupoLab/optmat) material library. diff --git a/docs/dev/seps/sep-0031-simulation-coupling.md b/docs/dev/seps/sep-0031-simulation-coupling.md index 9733501d1..8d272b4bd 100644 --- a/docs/dev/seps/sep-0031-simulation-coupling.md +++ b/docs/dev/seps/sep-0031-simulation-coupling.md @@ -1942,6 +1942,8 @@ plain defects among them are fixed above or were superseded): Final, against an independent mode-matching calculation for `StepIndexProvider`. 11. *(Phase 3 Q2, Q3)* The cone rule for small-core direct overlaps and a Gaussian → LP01 envelope: maintainer decision; the cone rule is conservative there. + *Resolved 2026-10-24* (maintainer decisions, B1): the cone rule stays as it is, and + a beam into a weakly guiding fibre is admitted within a recorded envelope. 12. *(Phase 3 Q4)* The general guide's target angular item at every occupied bin: yes if its cost stays within a coupling's; measure first. 13. *(Phase 3 Q5)* `Outcouple` stays a separate synthesis step (it changes the @@ -2145,6 +2147,76 @@ LP01 0.933, LP02 0.0625. The figures changed only in their last digits. `src/solprop/coupling/elements.py`, `tests/coupling/test_transport.py` (`test_a_same_grid_field_in_the_outer_band_is_refused_by_its_own_tolerance`), the coupling chapter and the changelog's phase 4 line. +- **B1 — a beam into a weakly guiding step-index fibre is admitted within a recorded + envelope** (Phase 5 open question 11, from Phase 3 questions 2 and 3; resolved; Phase 3 + deviation 2's beam refusal is lifted). The cone rule $1-\cos\theta_* = \epsilon_m/8$ + is kept unchanged. `STEP_INDEX_BEAM_ENVELOPE` + (`step-index-beam-lp01-vs-vector-he11-2026-10`) compares the scalar LP01 capture + `couple` computes with the full-vector coupling of the same field into the exact + unit-power HE11 even and odd modes (`StepIndexMode.electromagnetic_field`): the flux + pairing $\frac14\int(\mathbf E\times\mathbf h^*+\mathbf e^*\times\mathbf H)\cdot\hat + z\,dA$, the field's $E_z$ and $\mathbf H$ from its own plane-wave spectrum in the core + index ($\hat{\mathbf H}_t = Y(\mathbf k)\hat{\mathbf E}_t$, not the closure), on an + independent quadrature: the Fourier plane inside the widest admitted cone + ($1-\cos\theta = 1/8$; every budget's cone lies inside it), the HE11 fields split + exactly into azimuthal orders 0 and 2 and Hankel-transformed by Gauss–Legendre sums + broken at the core. Both captures are rank-two quadratic forms of the incident field + in $M_0$, so the largest eigenvalue modulus of their difference bounds the captured + energy discrepancy over incident energy for **every** field in the cone, whatever its + shape, alignment and polarisation; Gaussian beams sample it. *Measured* (core 1.45, + 1064 nm): $0.467\Delta$ at V 1.5, $0.413\Delta$ at 1.6, $0.356\Delta$ at 1.75, + $0.288\Delta$ at 2.2, $0.284\Delta$ at 2.4, for $\Delta$ from $5\times10^{-4}$ to + $10^{-2}$ (the ratio moves by 0.3 % over that range), unchanged to $10^{-14}$ for core + indices 1.33–2.0 and wavelengths 0.8–1.55 µm (the overlaps depend on V and $\Delta$ + alone); largest $4.67\times10^{-3}$ at $\Delta = 0.01$, V 1.5. It is dominated by the + first-order departure of the HE11 transverse field from LP01 (a field orthogonal to + LP01 but overlapping that departure is the worst case), not by the plane-wave H: the + impedance terms enter at second order. Gaussian beams — waists 0.5a–2.6a, displaced to + 0.5a and tilted to half the acceptance angle $\sqrt{2\Delta}$ along x and along y, + x-polarised — reach $3.3\times10^{-3}$ (a centred 0.8a beam, whose waist mismatch + projects onto that departure), and every one lies below the field bound. Smaller + cones only lower it ($4.07\times10^{-3}$ at $\epsilon_m = 0.05$). *Numerical:* + doubling every rule changes the maximum by $2\times10^{-13}$; recorded $10^{-12}$. + *Bound and scaling:* the discrepancy is first order in $\Delta$ (unlike the + fibre-pair envelope's second-order $3\times10^{-4}$), so the bound scales with the + contrast as the capillary one does with $\delta$ (`scaled_by="contrast"`): $10^{-2}$ + at contrast 0.01, twice the largest per unit contrast ($0.934\Delta$) rounded up, so + the allowance is $\Delta$ itself, about $5\times10^{-3}$ for a 0.005 step; a + constant bound would overstate a 0.001 fibre's tenfold. *Range:* contrast to 0.01, V + 1.5–2.4 at the profile wavelength (the field bound grows below 1.5), LP01 only (V + below 2.405 guides no other profile). V is checked at every occupied bin as for the + fibre pair, and bins outside add their incident share to the item; a contrast or V + outside is refused by name. The envelope bounds the transverse-field physics only: + the facet reflection off the core–cladding step, which the forward pairing omits, is + second order, $((n_{\rm core}-n_{\rm clad})/2n)^2 \le (\Delta/2)^2 = 2.5\times10^{-5}$ + of the energy at the range's top, inside the bound's margin; the obliquity of the + beam's axial flux against $M_0$ is the angular item's allocation, and a beam in + another medium is refused by the source-metric item. As for the fibre pair, the + profile is the run's frozen one, so the bound is measured at the profile wavelength. + Delivered: `src/solprop/coupling/envelopes.py` (`STEP_INDEX_BEAM_ENVELOPE`), + `src/solprop/coupling/coupler.py` (beam → step-index admission: the contrast screen + over the fibre alone, the beam envelope's V range per bin), + `tests/coupling/test_envelopes.py` (the field-bound oracle, medium; the Gaussian + sweep; the link to `couple`'s captured share, 1e-8), `tests/coupling/test_couple.py` + (admission, the screen's replacement, refusal by contrast and by V), + `tests/coupling/test_workflows.py` (a Gaussian into a 1 mm fibre, ten steps on the + receiving backend), the coupling chapter and the changelog's phase 5 line. +- **S — the flux recovery's span check allows the source plane's metric defect** + (a defect found in review). `same_basis` recovering a general modal plane into + another basis at the same nodes refused a pair-basis plane (identity metric, true + flux about $1+10^{-6}$ of it) entering the same guide's dense circular basis: its + tolerance held only the port's metric defect ($9\times10^{-16}$ there) against a + $10^{-6}$ mismatch. For a field in the port's span, $P = a^\dagger F_Ba$ exactly, + where $P = \frac12\operatorname{Re}\int(\mathbf E\times\mathbf H^*)\cdot\hat z\,dA$ is + the field's own flux on the common nodes; so the recovered energy $a^\dagger Q_Ba$ is + within $d_B$ (the whitened extremes of $F_B$ against $Q_B$) of P, and the plane's + energy $c^\dagger Q_Ac$ is within its measured defect $|c^\dagger Q_Ac - P|$ of P. + The check now allows $d_B E_{\rm rec} + \sum_k|E_{A,k} - P_k| + 10^{-10}E_A$ and + reports both as uncounted `target metric` and `source metric` items (`same_basis` has + no budget). Measured on the test strand: source defect $1.09\times10^{-6}$, mismatch + $9.98\times10^{-7}$. Delivered: `src/solprop/coupling/coupler.py` (`_flux_recovery`), + `tests/coupling/test_vector_coupling.py` (the medium recovery test now also enters + the circular basis). ## Part III — History diff --git a/docs/guide/workflows/coupling.md b/docs/guide/workflows/coupling.md index d80903fe1..295f4b898 100644 --- a/docs/guide/workflows/coupling.md +++ b/docs/guide/workflows/coupling.md @@ -206,7 +206,7 @@ in a common positive metric. The models are: | model | what it admits | what it computes | |---|---|---| | `same_basis` | an identical basis, a declared basis change, a general modal plane into its own guide | the coordinates exactly; no budget | -| `scalar_paraxial` | a radial or Cartesian beam (Gaussian, analytic or sampled) into a capillary; a capillary's exit into another capillary of any radius and mode set; one weakly guiding step-index fibre into another | the fit $\mathbf a = G^{-1}S^\dagger M\mathbf f$ in $M_0 = Y_0\,dA/2$, $Y_0 = n_{\rm ref}\varepsilon_0c$, then $b = \sqrt{\operatorname{Re}n_{\rm eff}}\,a$ | +| `scalar_paraxial` | a radial or Cartesian beam (Gaussian, analytic or sampled) into a capillary or a weakly guiding step-index fibre; a capillary's exit into another capillary of any radius and mode set; one weakly guiding step-index fibre into another | the fit $\mathbf a = G^{-1}S^\dagger M\mathbf f$ in $M_0 = Y_0\,dA/2$, $Y_0 = n_{\rm ref}\varepsilon_0c$, then $b = \sqrt{\operatorname{Re}n_{\rm eff}}\,a$ | | `vector_paraxial` | a beam, or an ordinary guide's exit, into a general modal guide that carries its E/H at fixed nodes | $\mathbf a = F^{-1}\mathbf B$ with the incident E and the forward closure $\mathbf H_t = Y_0\hat z\times\mathbf E_t$ | Every other pair is refused by name. The reference index $n_{\rm ref}$ is the port's: @@ -270,6 +270,20 @@ scalar model omits, measured by the test suite against independent physics: $HE_{11}$ fields: $3.0\times10^{-4}$ over contrasts to 0.01, V from 1.5 to 2.4 and radius ratios 0.7--1.43. V is checked at the profile wavelength and at every occupied bin, since it grows with frequency. +- *A beam into a step-index fibre.* The scalar LP01 overlap against the beam's + full-vector coupling into the exact $HE_{11}$ pair: the flux pairing + $\frac14\int(\mathbf E\times\mathbf h^* + \mathbf e^*\times\mathbf H)\cdot\hat z\,dA$ + with the beam's $\mathbf H$ and $E_z$ from its own plane-wave spectrum in the core + index. The bound is not sampled over beams but maximised over every incident field + inside the widest admitted cone (both captures are rank-two quadratic forms, so the + largest eigenvalue of their difference bounds them all): $0.467\Delta$ at V 1.5, + falling to $0.284\Delta$ at V 2.4, independent of the core index and the wavelength. + Gaussian beams of waist 0.5a--2.6a, displaced to 0.5a and tilted to half the + acceptance angle reach $3.3\times10^{-3}$ at $\Delta = 0.01$ and V 1.5, a centred + beam of waist 0.8a. The bound is $10^{-2}$ at contrast 0.01, scaled by the fibre's + contrast (it is first order in $\Delta$): about $5\times10^{-3}$ for a 0.005 step. + It holds for any beam shape, alignment and polarisation, for V from 1.5 to 2.4 (LP01 + alone); the angular item counts what lies outside the cone. A pair outside its envelope's range is refused, not extrapolated; bins of a covered pair whose $\delta$ or V leave the range carry an unbounded discrepancy, so their share of @@ -393,6 +407,10 @@ the same direct overlap. The pair is admitted within the recorded envelope's ran the contrast screen applies too, and the envelope stands in for it at a budget whose eighth is below the contrast. Small cores are not very paraxial: the LP01 field of a 2 µm core spreads over a tenth of a radian, so such a pair needs a budget of about 0.1. +A beam enters a fibre the same way, as a radial or Cartesian plane sampled in the +core index (a plane in another medium is a change of medium, which the source-metric +item refuses: a bulk entrance face is a separate planar interface), within its own +envelope above. ```python from solprop.modes import StepIndexMode @@ -424,6 +442,19 @@ spliced = couple( model=CouplingModel("scalar_paraxial", model_error=0.1), ) print(spliced.report.efficiency) + +fibre_port = target_fibre.input_port() +fibre_beam = FieldPlane.from_pulse( + SpatialPulse(GaussianPulse(wavelength=1064e-9, fwhm=100e-15, energy=1e-12), 3e-6), + fibre_port.grid, + PlaneGeometry(0.0), + space=RadialGrid(100e-6, 256), + index=np.asarray(fibre_port.reference_index), +) +launched = couple( + fibre_beam, fibre_port, model=CouplingModel("scalar_paraxial", model_error=0.1) +) +print(launched.report.efficiency) # about 0.992: a 3 um waist nearly matches LP01 ``` ### A beam into a general modal guide diff --git a/src/solprop/coupling/__init__.py b/src/solprop/coupling/__init__.py index b1cbbb611..3d5ca2864 100644 --- a/src/solprop/coupling/__init__.py +++ b/src/solprop/coupling/__init__.py @@ -40,6 +40,7 @@ ) from solprop.coupling.envelopes import ( CAPILLARY_ENVELOPE, + STEP_INDEX_BEAM_ENVELOPE, STEP_INDEX_ENVELOPE, PairEnvelope, ) @@ -84,6 +85,7 @@ "ENTRANCE_TOLERANCE", "METRIC_TOLERANCE", "PLANE_KINDS", + "STEP_INDEX_BEAM_ENVELOPE", "STEP_INDEX_ENVELOPE", "AdmissionItem", "Alignment", diff --git a/src/solprop/coupling/coupler.py b/src/solprop/coupling/coupler.py index e86f6b0ca..e1b747016 100644 --- a/src/solprop/coupling/coupler.py +++ b/src/solprop/coupling/coupler.py @@ -48,7 +48,8 @@ apparent gain within the model's own defects is reported as a model defect, never rescaled or clipped, and a larger one refuses; - ``envelope``: the recorded pair envelope's allowance (scaled by the pair's leaky - parameter for a capillary), plus the incident share of any bins outside its range; + parameter for a capillary, by the fibre's contrast for a beam into a step-index + fibre), plus the incident share of any bins outside its range; - ``numerical``: quadrature, representation and domain errors, an amplitude error :math:`\delta` entered as :math:`2\delta + \delta^2`. @@ -73,6 +74,7 @@ from solprop.constants import EPS0, C from solprop.coupling.envelopes import ( CAPILLARY_ENVELOPE, + STEP_INDEX_BEAM_ENVELOPE, STEP_INDEX_ENVELOPE, PairEnvelope, ) @@ -427,9 +429,10 @@ def couple( :class:`~solprop.coupling.models.BasisChange`; or, for a general modal plane carrying E/H at the port's own nodes, :math:`a = F^{-1}B`. ``scalar_paraxial`` - A radial or Cartesian plane into a capillary port, a capillary plane into - another capillary (any radii and mode sets), or a weakly guiding - step-index fibre's plane into another's, within the recorded envelopes. + A radial or Cartesian plane into a capillary port or a weakly guiding + step-index fibre's, a capillary plane into another capillary (any radii and + mode sets), or a weakly guiding step-index fibre's plane into another's, + within the recorded envelopes. ``vector_paraxial`` A radial or Cartesian plane, or an ordinary modal plane with its profiles, into a general modal guide that carries E/H at fixed nodes, with the @@ -742,6 +745,7 @@ def _same_basis( the port's span. """ incident = plane.spectral_energy() + recovery: tuple[AdmissionItem, ...] = () if model.basis_change is not None: samples = _declared_change(plane, port, model) note = "declared basis change" @@ -756,7 +760,7 @@ def _same_basis( samples = np.array(plane.samples) note = "identical basis: exact copy" elif plane.kind == "general_modal" and port.route == "general_modal": - samples = _flux_recovery(plane, port) + samples, recovery = _flux_recovery(plane, port) note = "F^-1 B recovery at common nodes" else: msg = ( @@ -777,7 +781,7 @@ def _same_basis( incident=incident, launched=launched, captured=launched, - items=(AdmissionItem("numerical", None, None, False, "map", note),), + items=(AdmissionItem("numerical", None, None, False, "map", note), *recovery), ) @@ -838,15 +842,22 @@ def _declared_change( def _flux_recovery( plane: FieldPlane, port: InputPort -) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: +) -> tuple[Complex[np.ndarray, "n_modes n_pol n_omega"], tuple[AdmissionItem, ...]]: r"""Recover a general modal plane's coordinates in the port's basis by F. :math:`a = F^{-1}B` with :math:`B_m = \frac14\int(\mathbf E\times\mathbf h_m^* + \mathbf e_m^*\times\mathbf H)\cdot\hat z\,dA` on the common nodes, using the - plane's own E and H. The field must lie in the port's span: its recovered - energy agrees with the plane's to the port's own metric defect, or it is - refused. + plane's own E and H. The field must lie in the port's span, which is checked + against its flux :math:`P = \frac12\operatorname{Re}\int(\mathbf E\times + \mathbf H^*)\cdot\hat z\,dA` on the same nodes. A field in the span has + :math:`P = a^\dagger Fa` exactly, so its recovered energy :math:`a^\dagger Q_Ba` + is within the port's metric defect :math:`d_B` (the whitened extremes of F + against :math:`Q_B`) of P, and the plane's own energy :math:`c^\dagger Q_Ac` is + within its measured defect :math:`|c^\dagger Q_Ac - P|` of P. The recovered and + the plane's energies must therefore agree to the sum of the two defects, or the + field is refused as outside the span. Both defects are reported (not counted: + ``same_basis`` has no budget). Parameters ---------- @@ -857,8 +868,9 @@ def _flux_recovery( Returns ------- - numpy.ndarray - The target samples, shape ``port.shape``. + tuple + The target samples, shape ``port.shape``, and the ``target metric`` and + ``source metric`` items. Raises ------ @@ -898,16 +910,44 @@ def _flux_recovery( for k in occupied: low, high = _whitened_extremes(flux[k], q_matrices[k]) defect = max(defect, abs(low - 1.0), abs(high - 1.0)) - tolerance = defect + 1e-10 + # The field's own flux on the common nodes, per bin (J): 1/2 Re (E x H*).z. + cross = ( + nodal.electric[:, 0] * nodal.magnetic[:, 1].conj() + - nodal.electric[:, 1] * nodal.magnetic[:, 0].conj() + ) + field_flux = ( + _bin_weights(port) * 0.5 * np.real(np.einsum("q,qw->w", weights, cross)) + ) + total = max(float(np.sum(original)), 1e-300) + source_defect = float(np.sum(np.abs(original - field_flux)[occupied])) / total + allowed = defect * float(np.sum(recovered)) + (source_defect + 1e-10) * total mismatch = abs(float(np.sum(recovered)) - float(np.sum(original))) - if mismatch > tolerance * max(float(np.sum(original)), 1e-300): + if mismatch > allowed: msg = ( f"the recovered energy differs from the plane's by " - f"{mismatch / float(np.sum(original)):.3e}, beyond the port's metric " - f"defect {defect:.3e}: the field is not in the port's span" + f"{mismatch / total:.3e}, beyond the port's metric defect {defect:.3e} " + f"and the plane's {source_defect:.3e}: the field is not in the port's span" ) raise CouplingError(msg) - return samples + items = ( + AdmissionItem( + "target metric", + defect, + None, + False, + "map", + "whitened extremes of the port's F against its native Q", + ), + AdmissionItem( + "source metric", + source_defect, + None, + False, + "field", + "the plane's native energy against its flux on the common nodes", + ), + ) + return samples, items def _flux_matrix( @@ -1106,12 +1146,6 @@ def _scalar_paraxial( n_modes, n_pol, n_omega = port.shape jones, mode_of = _channels(profiles, n_pol) if plane.kind in ("radial", "cartesian"): - if profiles.family != "capillary": - msg = ( - "scalar_paraxial is qualified for a beam into a capillary; a beam into " - f"a {profiles.family} fibre has no recorded envelope" - ) - raise CouplingError(msg) fit, native, envelope_parameters = _spatial_fit( plane, profiles, jones, mode_of, index, epsilon ) @@ -1166,11 +1200,18 @@ def _scalar_paraxial( items.append(_source_metric_item(incident, native, epsilon)) if profiles.family == "capillary": envelope_parameters.update(_leaky_parameters(profiles, plane, incident)) + elif plane.kind == "modal": + # A step-index modal source is a step-index fibre with its profiles. + envelope_parameters["uncovered"] = _uncovered_v_share( + {"v_source": cast("ScalarModeBasis", plane.profiles), "v_target": profiles}, + STEP_INDEX_ENVELOPE, + plane.grid.omega, + incident, + ) else: - # A step-index target takes only a step-index modal plane with its profiles. envelope_parameters["uncovered"] = _uncovered_v_share( - cast("ScalarModeBasis", plane.profiles), - profiles, + {"v_target": profiles}, + STEP_INDEX_BEAM_ENVELOPE, plane.grid.omega, incident, ) @@ -1227,7 +1268,7 @@ def _spatial_fit( index: Float[np.ndarray, " n_omega"], epsilon: float, ) -> tuple[_Fit, Float[np.ndarray, " n_omega"], dict[str, float]]: - """Return the scalar fit of a spatial plane onto capillary profiles. + """Return the scalar fit of a spatial plane onto a port's scalar profiles. Parameters ---------- @@ -1317,7 +1358,10 @@ def _spatial_fit( fit.gain_form = ( index[:, None, None] ** 2 * cross / (root.T[:, :, None] * root.T[:, None, :]) ) - parameters = {"modes": float(profiles.n_modes), "radius_ratio": 1.0} + if profiles.family == "capillary": + parameters = {"modes": float(profiles.n_modes), "radius_ratio": 1.0} + else: + parameters = _beam_step_index_parameters(profiles) return fit, native, parameters @@ -1557,6 +1601,26 @@ def _step_index_parameters( } +def _beam_step_index_parameters(profiles: ScalarModeBasis) -> dict[str, float]: + """Return a beam-into-fibre pair's envelope parameters at the profile wavelength. + + Parameters + ---------- + profiles : solprop.coupling.models.ScalarModeBasis + The target fibre's LP set. + + Returns + ------- + dict + ``v_target``: the fibre's V from its LP01 U and W; ``modes``: the number of + distinct LP0m profiles (two polarisations of one profile count once). + """ + return { + "v_target": float(math.hypot(profiles.u[0], profiles.w[0])), + "modes": float(np.unique(profiles.u).size), + } + + def _target_metric( profiles: ScalarModeBasis, mode_of: np.ndarray, @@ -1731,23 +1795,26 @@ def _leaky_parameters( def _uncovered_v_share( - source: ScalarModeBasis, - target: ScalarModeBasis, + fibres: Mapping[str, ScalarModeBasis], + envelope: PairEnvelope, omega: Float[np.ndarray, " n_omega"], incident: Float[np.ndarray, " n_omega"], ) -> float: - r"""Return the incident share of bins where either fibre's V leaves the range. + r"""Return the incident share of bins where a fibre's V leaves the range. The profiles are frozen at the profile wavelength, which the envelope's range must contain, but the vector physics it bounds depends on the actual :math:`V(\omega) = (\omega a/c)\sqrt{n_{\rm core}^2 - n_{\rm clad}^2}` at - each bin. A bin where either fibre's V is outside the range is not bounded by + each bin. A bin where any fibre's V is outside the range is not bounded by the envelope, so its share of the incident energy is counted in full. Parameters ---------- - source, target : solprop.coupling.models.ScalarModeBasis - The two LP sets, with their core and cladding indices. + fibres : collections.abc.Mapping + Each fibre's LP set, with its core and cladding indices, by the name of + its V range in `envelope` (``v_source``, ``v_target``). + envelope : solprop.coupling.envelopes.PairEnvelope + The pair's envelope. omega : numpy.ndarray Absolute angular frequency of each bin, rad/s. incident : numpy.ndarray @@ -1759,12 +1826,12 @@ def _uncovered_v_share( Dimensionless. """ beyond = np.zeros(omega.shape, bool) - for name, basis in (("v_source", source), ("v_target", target)): + for name, basis in fibres.items(): core = cast("np.ndarray", basis.core_index) cladding = cast("np.ndarray", basis.cladding_index) aperture = np.sqrt(np.maximum(core**2 - cladding**2, 0.0)) v = omega * basis.radius * aperture / C - low, high = STEP_INDEX_ENVELOPE.ranges[name] + low, high = envelope.ranges[name] beyond |= (incident > 0.0) & ((v < low) | (v > high)) return _weighted(beyond.astype(float), incident) @@ -1781,15 +1848,18 @@ def _contrast_items( A capillary wall is not a weakly guiding dielectric, so the screen does not apply there; a step-index pair is screened by - :math:`\Delta \le \epsilon_m/8` over both fibres' regions, unless its - recorded envelope covers it. Either family needs its envelope to cover the pair. + :math:`\Delta \le \epsilon_m/8` over both fibres' regions (the target's alone + for an incident beam), unless its recorded envelope covers it. Every pair needs + its envelope to cover it: the capillary one, the fibre-pair one, or for a beam + into a fibre :data:`~solprop.coupling.envelopes.STEP_INDEX_BEAM_ENVELOPE`. Parameters ---------- profiles : solprop.coupling.models.ScalarModeBasis The target profiles. plane : solprop.coupling.plane.FieldPlane - The source plane; for a step-index target, a modal plane with its profiles. + The source plane; for a step-index target, a spatial plane or a modal plane + with its profiles. index : numpy.ndarray Reference index per bin. occupied : numpy.ndarray @@ -1809,9 +1879,11 @@ def _contrast_items( CouplingError If no recorded envelope covers the pair. """ - envelope: PairEnvelope = ( - CAPILLARY_ENVELOPE if profiles.family == "capillary" else STEP_INDEX_ENVELOPE - ) + envelope: PairEnvelope = CAPILLARY_ENVELOPE + if profiles.family == "step_index": + envelope = ( + STEP_INDEX_ENVELOPE if plane.kind == "modal" else STEP_INDEX_BEAM_ENVELOPE + ) if profiles.family == "capillary": screen = AdmissionItem( "contrast", @@ -1823,8 +1895,14 @@ def _contrast_items( ) else: contrast = 0.0 - # A step-index pair: the source is a modal plane with its own profiles. - for one in (profiles, cast("ScalarModeBasis", plane.profiles)): + # A step-index modal source is a fibre with its own profiles; a beam's + # medium is measured by the source-metric item instead. + fibres = ( + (profiles, cast("ScalarModeBasis", plane.profiles)) + if plane.kind == "modal" + else (profiles,) + ) + for one in fibres: for values in (one.core_index, one.cladding_index): values = cast("np.ndarray", values) ratio = np.abs(values[occupied] ** 2 - index[occupied] ** 2) / ( diff --git a/src/solprop/coupling/envelopes.py b/src/solprop/coupling/envelopes.py index 1d2d3e329..d614d8e4c 100644 --- a/src/solprop/coupling/envelopes.py +++ b/src/solprop/coupling/envelopes.py @@ -21,13 +21,24 @@ overlap of their exact bound HE11 fields (:meth:`solprop.modes.stepindex.StepIndexMode.electromagnetic_field`). It replaces the contrast screen within its range. +- :data:`STEP_INDEX_BEAM_ENVELOPE`: an incident beam's scalar overlap with a weakly + guiding fibre's LP01 profile against its full-vector flux coupling into the exact + HE11 pair, the beam's H from its own plane-wave spectrum. The bound is the largest + discrepancy over every incident field inside the widest admitted cone, so it + covers any beam shape, alignment and polarisation; it scales linearly with the + fibre's contrast. """ from collections.abc import Mapping from dataclasses import dataclass from types import MappingProxyType -__all__ = ["CAPILLARY_ENVELOPE", "STEP_INDEX_ENVELOPE", "PairEnvelope"] +__all__ = [ + "CAPILLARY_ENVELOPE", + "STEP_INDEX_BEAM_ENVELOPE", + "STEP_INDEX_ENVELOPE", + "PairEnvelope", +] @dataclass(frozen=True) @@ -39,7 +50,8 @@ class PairEnvelope: identity : str The envelope's name, recorded in a coupling report. family : {'capillary', 'step_index'} - The profile family of both members of the pair. + The profile family of the pair's guided members: the target, and a modal + source (an incident beam has none). ranges : collections.abc.Mapping Each range parameter's closed interval ``(low, high)``; a pair outside any one is not covered. @@ -169,3 +181,37 @@ def allowance(self, parameters: Mapping[str, float]) -> float: ``|n^2 - n_ref^2|/(2 n_ref^2)`` of either fibre, ``v_*`` each fibre's normalised frequency at its profile wavelength, ``radius_ratio`` source over target core radius, ``modes`` the larger LP0m count (LP01 only).""" + + +STEP_INDEX_BEAM_ENVELOPE = PairEnvelope( + identity="step-index-beam-lp01-vs-vector-he11-2026-10", + family="step_index", + ranges={"contrast": (0.0, 0.01), "v_target": (1.5, 2.4), "modes": (1.0, 1.0)}, + bound=1.0e-2, + numerical=1e-12, + reference=( + "the full-vector flux coupling 1/4 int(E x h* + e* x H).z dA of an incident " + "field into the exact unit-power HE11 even and odd modes " + "(StepIndexMode.electromagnetic_field), the field's H and E_z from its own " + "plane-wave spectrum in the core index, against the scalar LP01 overlap the " + "model takes, both in the common measure M0; evaluated in the transverse " + "Fourier plane by azimuthal decomposition and Gauss-Legendre Hankel " + "transforms, inside the widest admitted cone (1 - cos theta = 1/8). The " + "discrepancy of captured energy over incident energy, maximised over every " + "field in that cone (the largest eigenvalue modulus of the difference of the " + "two rank-two capture forms), is 0.467 Delta at V 1.5, falling to 0.284 " + "Delta at V 2.4, for Delta 5e-4 to 1e-2, independent of the core index and " + "the wavelength: largest 4.67e-3 at Delta 0.01 and V 1.5. Gaussian beams " + "(waists 0.5a to 2.6a, displaced to 0.5a and tilted to half the acceptance " + "angle along x and y, x-polarised) reach 3.3e-3. The bound is twice the " + "largest per unit contrast, rounded up, so the allowance is scaled by " + "Delta/1e-2; refining the quadrature changes the maximum by 2e-13" + ), + scaled_by="contrast", +) +"""The beam-into-fibre LP01 envelope; ``contrast`` is the fibre's largest +``|n^2 - n_ref^2|/(2 n_ref^2)`` against its core over the occupied bins, ``v_target`` +its normalised frequency at the profile wavelength, ``modes`` the number of distinct +LP0m profiles (LP01 only: V below 2.405 guides no other). The bound holds for any +incident field inside the admitted cone; the angular item counts what lies outside +it, and the source-metric item the beam's medium.""" diff --git a/tests/coupling/test_couple.py b/tests/coupling/test_couple.py index 19e61cb05..4e528f7e5 100644 --- a/tests/coupling/test_couple.py +++ b/tests/coupling/test_couple.py @@ -24,6 +24,7 @@ from solprop import fourier from solprop.constants import EPS0, C from solprop.coupling import ( + STEP_INDEX_BEAM_ENVELOPE, STEP_INDEX_ENVELOPE, BasisChange, CouplingModel, @@ -786,6 +787,81 @@ def test_a_weakly_guiding_fibre_pair_is_admitted_within_its_envelope(): ) +def test_a_beam_into_a_weakly_guiding_fibre_is_admitted_within_its_envelope(): + """A Gaussian beam into a step-index fibre: the LP01 overlap and its envelope. + + A fibre of contrast 0.005 and V 2.2 at 1064 nm, the beam sampled in the core + index: the envelope item is the beam-into-fibre allowance at the fibre's + contrast, the screen passes at a 0.1 budget and is replaced by the envelope + where it does not, and a contrast or a V beyond the envelope's range is refused + by name. + """ + wavelength = 1064e-9 + + def radius_for(v, contrast): + return v / (2 * math.pi / wavelength * math.sqrt(1.45**2 * 2 * contrast)) + + def beam_into(port, waist): + return FieldPlane.from_analytic( + port.grid, + PlaneGeometry(0.0), + radial_beam(port.grid, waist), + space=RadialGrid(40 * waist, 256), + index=np.asarray(port.reference_index), + ) + + radius = radius_for(2.2, 0.005) + target = step_index(radius, 0.005).input_port() + plane = beam_into(target, 1.2 * radius) + report = couple( + plane, target, model=CouplingModel("scalar_paraxial", model_error=0.1) + ).report + names = {item.name: item for item in report.items} + assert names["contrast"].value == pytest.approx(0.005, rel=1e-9) + assert names["contrast"].passed + # The contrast-scaled allowance, plus the incident share of the blue bins + # where the fibre's V exceeds 2.4 (2.4e-5 of this 30 fs pulse). + v = target.grid.omega * radius * math.sqrt(1.45**2 * 2 * 0.005) / C + beyond = (report.incident > 0) & (v > 2.4) + share = float(np.sum(report.incident[beyond]) / report.incident_energy) + assert share > 1e-6 + assert names["envelope"].value == pytest.approx( + STEP_INDEX_BEAM_ENVELOPE.allowance({"contrast": 0.005}) + share, rel=1e-9 + ) + assert names["envelope"].note.startswith(STEP_INDEX_BEAM_ENVELOPE.identity) + assert report.efficiency is not None and 0.95 < report.efficiency < 1.0 + profiles = target.profiles + assert profiles is not None + occupied = np.asarray(target.reference_index) > 0 + replaced = _contrast_items( + profiles, + plane, + np.asarray(target.reference_index), + occupied, + 0.02, + {"modes": 1.0, "v_target": 2.2}, + ) + assert f"replaced by {STEP_INDEX_BEAM_ENVELOPE.identity}" in replaced[0].note + assert replaced[1].value == pytest.approx(5e-3, rel=1e-9) + strong = step_index(radius_for(2.2, 0.02), 0.02).input_port() + with pytest.raises( + CouplingError, + match=r"contrast = 0\.02\d* lies outside the envelope step-index-beam", + ): + couple( + beam_into(strong, radius_for(2.2, 0.02)), + strong, + model=CouplingModel("scalar_paraxial", model_error=0.5), + ) + thick = step_index(radius_for(2.6, 0.005), 0.005).input_port() + with pytest.raises(CouplingError, match=r"v_target = 2\.(6|59)\d* lies outside"): + couple( + beam_into(thick, radius_for(2.6, 0.005)), + thick, + model=CouplingModel("scalar_paraxial", model_error=0.5), + ) + + # --------------------------------------------------------------------- refusals @@ -820,8 +896,8 @@ def test_couple_refuses_by_name(port): def test_couple_refuses_unqualified_pairs_by_name(port): """A port without profiles, a plane without them, beyond the envelope, a fibre - target for a beam, mixed families, a general plane, another orientation or - lattice.""" + target for a beam beyond its envelope, mixed families, a general plane, another + orientation or lattice.""" grid = port.grid beam = FieldPlane.from_analytic( grid, @@ -852,7 +928,8 @@ def test_couple_refuses_unqualified_pairs_by_name(port): assert hybrid.input_port().models == ("same_basis",) fibre = step_index(2.4e-6, 0.005, wavelength=800e-9, grid=GRID).input_port() assert fibre.lattice == port.lattice - with pytest.raises(CouplingError, match="no recorded envelope"): + # V = 2.7 at 800 nm: beyond the beam-into-fibre envelope's range. + with pytest.raises(CouplingError, match="v_target"): couple(beam, fibre, model=SCALAR) with pytest.raises(CouplingError, match="not qualified for a capillary plane"): couple(modal_plane(capillary(modes=2), [1.0, 0.0]), fibre, model=SCALAR) diff --git a/tests/coupling/test_envelopes.py b/tests/coupling/test_envelopes.py index ccfce6910..e471fe29b 100644 --- a/tests/coupling/test_envelopes.py +++ b/tests/coupling/test_envelopes.py @@ -17,11 +17,16 @@ and the code, not the model; - the step-index envelope: the weak-guidance LP01 overlap the scalar model uses against the full-vector flux overlap of the exact bound HE11 fields - (:meth:`~solprop.modes.StepIndexMode.electromagnetic_field`). + (:meth:`~solprop.modes.StepIndexMode.electromagnetic_field`); +- the beam-into-fibre envelope: an incident field's scalar LP01 overlap against its + full-vector flux coupling into the exact HE11 pair, the field's H from its own + plane-wave spectrum, maximised over every field inside the widest admitted cone + and sampled by Gaussian beams. The numerical error of each reference is estimated by refining its quadrature. """ +import itertools import math from typing import cast @@ -33,6 +38,7 @@ from solprop.constants import EPS0, C from solprop.coupling import ( CAPILLARY_ENVELOPE, + STEP_INDEX_BEAM_ENVELOPE, STEP_INDEX_ENVELOPE, CouplingModel, FieldPlane, @@ -44,7 +50,7 @@ from solprop.coupling.models import radial_rule from solprop.driver import Simulation from solprop.exceptions import CouplingError -from solprop.grid import EnvelopeGrid, FreeGrid +from solprop.grid import EnvelopeGrid, FreeGrid, RadialGrid from solprop.modes import MarcatiliMode, StepIndexMode from solprop.spatial import SpatialMedium @@ -515,3 +521,326 @@ def test_the_step_index_envelope_bounds_lp01_against_the_vector_he11_overlap(): worst = max(worst, abs(scalar - vector)) assert numerical <= STEP_INDEX_ENVELOPE.numerical assert worst + numerical <= STEP_INDEX_ENVELOPE.bound + + +# ------------------------------------------------- the beam-into-fibre oracle + +BEAM_CORE = 1.45 +BEAM_WAVELENGTH = 1064e-9 +WIDEST_CONE = math.acos(1.0 - 1.0 / 8.0) +"""The cone of a budget approaching one: every admitted cone lies inside it.""" + + +def gauss_panels(edges, panel, nodes=16): + """Return Gauss--Legendre nodes and weights on panels at most `panel` long.""" + x, w = np.polynomial.legendre.leggauss(nodes) + points, weights = [], [] + for low, high in itertools.pairwise(edges): + bounds = np.linspace(low, high, max(1, math.ceil((high - low) / panel)) + 1) + for left, right in itertools.pairwise(bounds): + half = 0.5 * (right - left) + points.append(left + half * (x + 1)) + weights.append(half * w) + return np.concatenate(points), np.concatenate(weights) + + +class BeamFibreReference: + r"""The full-vector coupling of incident fields into a fibre's exact HE11 pair. + + A field in the core index is its transverse plane-wave spectrum + :math:`\hat E_t(\mathbf k) = \int E_t e^{i\mathbf k\cdot\mathbf r}dA` inside the + widest admitted cone; each plane wave's :math:`E_z = -\mathbf k_t\cdot + \hat E_t/k_z` and :math:`\mathbf H = \mathbf k\times\mathbf E/(\omega\mu_0)` + (the convention of ``electromagnetic_field``, :math:`e^{i\omega t - i\mathbf + k\cdot\mathbf r}`), so :math:`\hat H_t = Y(\mathbf k)\hat E_t`. A mode's + amplitude is the flux pairing :math:`\frac14\int(\mathbf E\times\mathbf h^* + + \mathbf e^*\times\mathbf H)\cdot\hat z\,dA`, taken by Parseval in the Fourier + plane. The HE11 fields are split exactly into azimuthal orders 0 and 2 and + transformed by Gauss--Legendre Hankel sums broken at the core radius; the + LP01 profile the model uses is transformed the same way (the model itself uses + its closed form). The k plane is a Gauss--Legendre (radius) by uniform (angle) + rule. + """ + + def __init__(self, contrast, v, refine=1): + cladding = BEAM_CORE * math.sqrt(1 - 2 * contrast) + self.k0 = 2 * math.pi / BEAM_WAVELENGTH + self.omega = self.k0 * C + radius = v / (self.k0 * math.sqrt(BEAM_CORE**2 - cladding**2)) + self.radius = radius + modes = { + parity: StepIndexMode( + radius, + core=BEAM_CORE, + cladding=cladding, + profile_wavelength=BEAM_WAVELENGTH, + parity=parity, + ) + for parity in ("even", "odd") + } + grid = EnvelopeGrid.from_limits( + 1e-3, + BEAM_WAVELENGTH, + (0.95 * BEAM_WAVELENGTH, 1.05 * BEAM_WAVELENGTH), + 200e-15, + ) + lp01 = ScalarModeBasis.from_modes([modes["even"]], grid) + kappa_star = BEAM_CORE * self.k0 * math.sin(WIDEST_CONE) + outer = modes["even"].dimlimits()[2][0] + panel = min(radius / 2, 2 * math.pi / kappa_star) / refine + self.r, self.r_weights = gauss_panels([0.0, radius, outer], panel) + self.kappa, kappa_weights = gauss_panels( + [0.0, kappa_star], kappa_star / (24 * refine) + ) + n_psi = 64 * refine + psi = 2 * np.pi * (np.arange(n_psi) + 0.5) / n_psi + self.n_psi = n_psi + kappa = np.repeat(self.kappa, n_psi) + angle = np.tile(psi, self.kappa.size) + self.angle = angle + self.weights = ( + np.repeat(kappa_weights * self.kappa, n_psi) / n_psi / (2 * np.pi) + ) + self.kx, self.ky = kappa * np.cos(angle), kappa * np.sin(angle) + kz = np.sqrt((BEAM_CORE * self.k0) ** 2 - kappa**2) + scale = EPS0 * C**2 / (self.omega * kz) # 1/(omega mu0 kz) + kx, ky = self.kx, self.ky + self.admittance = np.empty((kappa.size, 2, 2)) + self.admittance[:, 0, 0] = -kx * ky * scale + self.admittance[:, 0, 1] = -(ky**2 + kz**2) * scale + self.admittance[:, 1, 0] = (kz**2 + kx**2) * scale + self.admittance[:, 1, 1] = kx * ky * scale + self._bessel = {} + self.decomposition = 0.0 + self.coupling = {name: self._coupling(mode) for name, mode in modes.items()} + self.lp01 = ( + 2 * np.pi * np.repeat(self._hankel(lp01.values(self.r)[0], 0), n_psi) + ) + + def _hankel(self, values, order): + if order not in self._bessel: + self._bessel[order] = jv(order, np.outer(self.kappa, self.r)) * ( + self.r * self.r_weights + ) + return self._bessel[order] @ values + + def _coupling(self, mode): + """Return c(k) with a(f) = sum_k w_k c(k) . E_t(k), shape (n_k, 2).""" + r = self.r + samples = {} + for phi in (0.0, math.pi / 4, math.pi / 2, 0.37): + xy = np.column_stack((r * math.cos(phi), r * math.sin(phi))) + samples[phi] = mode.electromagnetic_field(xy, self.omega) + # An HE11 field is exactly f0(r) + f2(r) cos 2phi in one Cartesian + # component and g2(r) sin 2phi in the other (cos and sin swap with parity). + even = mode.parity == "even" + cosine = {("e", 0): even, ("e", 1): not even, ("h", 0): not even} + cosine["h", 1] = even + spectra = {} + for (field, component), is_cosine in cosine.items(): + index = 0 if field == "e" else 1 + at = {phi: values[index][:, component] for phi, values in samples.items()} + if is_cosine: + zeroth = 0.5 * (at[0.0] + at[math.pi / 2]) + second = 0.5 * (at[0.0] - at[math.pi / 2]) + trig, angular = math.cos(2 * 0.37), np.cos(2 * self.angle) + else: + zeroth = np.zeros_like(at[0.0]) + second = at[math.pi / 4] + trig, angular = math.sin(2 * 0.37), np.sin(2 * self.angle) + # The split is exact: check it at a fourth angle. + residual = np.max(np.abs(zeroth + second * trig - at[0.37])) + peak = np.max(np.abs(samples[0.0][index])) + self.decomposition = max(self.decomposition, float(residual / peak)) + # f(r) cos(n phi) transforms to 2 pi i^n cos(n psi) H_n[f]. + spectra[field, component] = ( + 2 + * np.pi + * ( + np.repeat(self._hankel(zeroth, 0), self.n_psi) + - np.repeat(self._hankel(second, 2), self.n_psi) * angular + ) + ) + first = np.stack((np.conj(spectra["h", 1]), -np.conj(spectra["h", 0])), axis=1) + dual = np.stack((-np.conj(spectra["e", 1]), np.conj(spectra["e", 0])), axis=1) + return 0.25 * (first + np.einsum("kji,kj->ki", self.admittance, dual)) + + def worst_field(self): + """Return the largest |captured discrepancy| / incident over all fields. + + In the M0 inner product the scalar capture is sum ||^2 over the + x and y LP01 channels and the vector capture sum ||^2 over the + HE11 pair; the largest modulus of their difference's eigenvalues on the + span of the four vectors bounds every field in the cone. + """ + root = np.sqrt(self.weights) + zero = np.zeros_like(self.lp01) + scalar = [ + np.concatenate((self.lp01 * root, zero)), + np.concatenate((zero, self.lp01 * root)), + ] + factor = math.sqrt(2 / (BEAM_CORE * EPS0 * C)) + vector = [ + (factor * np.conj(self.coupling[name]) * root[:, None]).T.ravel() + for name in ("even", "odd") + ] + basis, _ = np.linalg.qr(np.column_stack(scalar + vector)) + s = basis.conj().T @ np.column_stack(scalar) + q = basis.conj().T @ np.column_stack(vector) + difference = s @ s.conj().T - q @ q.conj().T + return float(np.max(np.abs(np.linalg.eigvalsh(difference)))) + + def gaussian(self, waist, shift=(0.0, 0.0), tilt=(0.0, 0.0)): + """Return an x-polarised Gaussian's transverse spectrum, shape (n_k, 2).""" + qx, qy = (BEAM_CORE * self.k0 * angle for angle in tilt) + dx, dy = self.kx - qx, self.ky - qy + spectrum = ( + math.pi + * waist**2 + * np.exp(-(dx**2 + dy**2) * waist**2 / 4) + * np.exp(1j * (dx * shift[0] + dy * shift[1])) + ) + return np.stack((spectrum, np.zeros_like(spectrum)), axis=1) + + def captured(self, field): + """Return the scalar and vector captures per channel over M0 incident.""" + half_admittance = 0.5 * BEAM_CORE * EPS0 * C + w = self.weights + incident = half_admittance * np.sum(w * np.sum(np.abs(field) ** 2, axis=1)) + scalar = [ + half_admittance * abs(np.sum(w * self.lp01 * field[:, p])) ** 2 + for p in (0, 1) + ] + vector = [ + abs(np.sum(w[:, None] * self.coupling[name] * field)) ** 2 + for name in ("even", "odd") + ] + return np.array(scalar) / incident, np.array(vector) / incident + + +@pytest.mark.medium +def test_the_beam_fibre_envelope_bounds_every_field_in_the_cone(): + """The scalar LP01 capture against the vector HE11 pair, maximised over fields. + + The largest discrepancy over every field inside the widest admitted cone is + 0.467 Delta at V 1.5 and falls with V (0.284 Delta at 2.4); it is linear in + Delta from 5e-4 to 1e-2 and does not depend on the core index or the + wavelength (the overlaps depend on V and Delta alone). The recorded bound is + at least twice it at every contrast, through the allowance's linear scaling; + the reference's numerical error is estimated by refining every rule. + """ + top = STEP_INDEX_BEAM_ENVELOPE.ranges["contrast"][1] + worst = {v: BeamFibreReference(top, v) for v in (1.5, 1.8, 2.1, 2.4)} + largest = {v: reference.worst_field() for v, reference in worst.items()} + for reference in worst.values(): + # The azimuthal split of the exact fields is exact to rounding. + assert reference.decomposition < 1e-12 + assert largest[1.5] == pytest.approx(4.67e-3, rel=0.01) + assert largest[2.4] == pytest.approx(2.84e-3, rel=0.01) + assert max(largest, key=largest.__getitem__) == 1.5 + refined = BeamFibreReference(top, 1.5, refine=2).worst_field() + numerical = abs(refined - largest[1.5]) + assert numerical <= STEP_INDEX_BEAM_ENVELOPE.numerical + assert 2 * largest[1.5] + numerical <= STEP_INDEX_BEAM_ENVELOPE.bound + for contrast in (5e-4, 1e-3, 5e-3): + low = BeamFibreReference(contrast, 1.5).worst_field() + assert low / contrast == pytest.approx(largest[1.5] / top, rel=0.01) + assert 2 * low <= STEP_INDEX_BEAM_ENVELOPE.allowance({"contrast": contrast}) + + +@pytest.mark.parametrize("v", [1.5, 2.4]) +def test_gaussian_beams_lie_within_the_beam_fibre_envelope(v): + """Gaussian beams of every waist, offset and tilt stay inside the field bound. + + Waists 0.5a to 2.6a, displaced to 0.5a and tilted to half the acceptance + angle sqrt(2 Delta) along x and along y, x-polarised, at contrast 0.01: each + beam's discrepancy (summed over both polarisation channels) is below the + largest over all fields, and the worst reaches 3.3e-3 at V 1.5 (a centred + beam at 0.8a, whose waist mismatch projects onto the vector correction). + """ + contrast = STEP_INDEX_BEAM_ENVELOPE.ranges["contrast"][1] + reference = BeamFibreReference(contrast, v) + bound = reference.worst_field() + radius = reference.radius + acceptance = math.sqrt(2 * contrast) + worst = 0.0 + for waist in (0.5, 0.8, 1.1, 1.4, 1.8, 2.2, 2.6): + for shift, tilt in ( + ((0.0, 0.0), (0.0, 0.0)), + ((0.25, 0.0), (0.0, 0.0)), + ((0.5, 0.0), (0.0, 0.0)), + ((0.0, 0.5), (0.0, 0.0)), + ((0.0, 0.0), (0.25, 0.0)), + ((0.0, 0.0), (0.5, 0.0)), + ((0.0, 0.0), (0.0, 0.5)), + ((0.5, 0.0), (0.0, 0.5)), + ): + field = reference.gaussian( + waist * radius, + (shift[0] * radius, shift[1] * radius), + (tilt[0] * acceptance, tilt[1] * acceptance), + ) + scalar, vector = reference.captured(field) + discrepancy = float(np.sum(np.abs(scalar - vector))) + # Per channel the bound is the same eigenvalue problem's: rounding. + assert discrepancy <= bound + 1e-12 + worst = max(worst, discrepancy) + if v == 1.5: + assert worst == pytest.approx(3.3e-3, rel=0.02) + + +def test_the_beam_fibre_reference_takes_the_overlap_couple_computes(): + """The reference's scalar capture is the share ``couple`` reports for a beam. + + A centred Gaussian (waist a) into a fibre of contrast 0.001 and V 2 at + 1064 nm, sampled on a radial plane in the core index: ``couple``'s captured + share at the profile bin equals the reference's scalar capture of the same + beam, which lies wholly inside the cone. + """ + contrast, v = 1e-3, 2.0 + reference = BeamFibreReference(contrast, v) + radius = reference.radius + scalar, _ = reference.captured(reference.gaussian(radius)) + cladding = BEAM_CORE * math.sqrt(1 - 2 * contrast) + fibre = StepIndexMode( + radius, core=BEAM_CORE, cladding=cladding, profile_wavelength=BEAM_WAVELENGTH + ) + simulation = Simulation.from_gnlse( + None, + 1e-3, + fibre=fibre, + n2=2.6e-20, + fwhm=100e-15, + energy=1e-12, + n_save=2, + lambda0=BEAM_WAVELENGTH, + wavelength_limits=(900e-9, 1300e-9), + trange=400e-15, + ) + port = simulation.input_port() + grid = port.grid + width = 100e-15 / (2 * math.sqrt(math.log(2))) + + def field(r, omega): + spectral = np.exp(-(((omega - grid.omega0) * width) ** 2) / 2) + return np.exp(-((r / radius) ** 2))[:, None] * spectral[None, :] + + plane = FieldPlane.from_analytic( + grid, + PlaneGeometry(0.0), + field, + space=RadialGrid(30 * radius, 256), + index=np.asarray(port.reference_index), + ) + report = couple( + plane, port, model=CouplingModel("scalar_paraxial", model_error=0.1) + ).report + k_bin = int(np.argmin(np.abs(grid.omega - grid.omega0))) + captured = report.captured + assert captured is not None + # Two quadratures of one overlap: the plane's radial spectral sum with the + # closed-form LP01 transform, and the reference's k-plane rule. + assert captured[k_bin] / report.incident[k_bin] == pytest.approx( + scalar[0], rel=1e-8 + ) + assert scalar[1] == 0.0 diff --git a/tests/coupling/test_vector_coupling.py b/tests/coupling/test_vector_coupling.py index d1d7752d3..f8978683f 100644 --- a/tests/coupling/test_vector_coupling.py +++ b/tests/coupling/test_vector_coupling.py @@ -48,10 +48,11 @@ WAVELENGTH, FWHM, ENERGY = 835e-9, 20e-15, 1e-12 -def strand(grid, descriptors=PARITIES[:1], blocks=(), n_r=24): +def strand(grid, descriptors=PARITIES[:1], blocks=(), n_r=24, circular=None): """Return a guide of a 4 um core at a 0.01 index step, nine knots. - The step is below the vector model's contrast screen. + The step is below the vector model's contrast screen. ``circular`` declares + the circular partners of a degenerate pair as the basis. """ return general_guide( descriptors=descriptors, @@ -61,6 +62,7 @@ def strand(grid, descriptors=PARITIES[:1], blocks=(), n_r=24): n_r=n_r, knots=9, indices=(1.45, 1.44), + circular=circular, ) @@ -274,9 +276,12 @@ def test_a_general_plane_is_recovered_in_another_basis_at_common_nodes(grid, pai The pair is launched in its even channel and run two steps; its exit plane, read with its simulation, carries E and H at the strand's nodes. Recovered in the even channel alone (another basis at the same nodes) it keeps its energy to - rounding: the odd channel it leaves out is empty. The odd channel alone does not - span it; a plane read without its simulation has no E/H; a port without them, - or at other nodes, cannot pair it. + rounding: the odd channel it leaves out is empty. Recovered in the same pair's + dense circular basis it keeps its energy to the plane's own metric defect: the + pair's identity metric departs from its flux at the band's long edge, and the + span check allows that defect as well as the port's. The odd channel alone does + not span it; a plane read without its simulation has no E/H; a port without + them, or at other nodes, cannot pair it. """ output = pair.run(progress="none") plane = field_plane(output, source=pair) @@ -287,6 +292,20 @@ def test_a_general_plane_is_recovered_in_another_basis_at_common_nodes(grid, pai assert result.report.launched_energy == pytest.approx( result.report.incident_energy, rel=1e-10 ) + circular = simulation(strand(grid, PARITIES, ((0, 1),), circular=(0, 1)), grid) + circular_port = circular.input_port() + assert circular_port.metric.kind == "dense" + recovered = couple(plane, circular_port, model=SAME).report + items = {item.name: item for item in recovered.items} + source_defect = items["source metric"].value + assert source_defect is not None + # Measured 1.1e-6: the pair's identity metric against its flux, weighted by + # the plane's spectrum; the dense circular metric is the port's own F. + assert 1e-8 < source_defect < 1e-5 + assert items["target metric"].value == pytest.approx(0.0, abs=1e-12) + assert abs(recovered.launched_energy / recovered.incident_energy - 1.0) <= ( + source_defect + 1e-10 + ) odd_guide = strand(grid, PARITIES[1:]) with pytest.raises(CouplingError, match="not in the port's span"): couple(plane, simulation(odd_guide, grid).input_port(), model=SAME) diff --git a/tests/coupling/test_workflows.py b/tests/coupling/test_workflows.py index d5a9678e7..14e34786d 100644 --- a/tests/coupling/test_workflows.py +++ b/tests/coupling/test_workflows.py @@ -33,6 +33,7 @@ from solprop.backends.registry import available from solprop.constants import C from solprop.coupling import ( + STEP_INDEX_BEAM_ENVELOPE, Alignment, Aperture, CouplingModel, @@ -483,6 +484,73 @@ def norm(profile): assert report.launched_energy < report.incident_energy +def test_a_gaussian_beam_into_a_solid_fibre(): + """(c') A Gaussian beam into a weakly guiding step-index fibre, then ten steps. + + Contrast 0.005 and V 2.2 at 1064 nm (core 2.57 um), the beam built in the core + index at a 2.6 um waist. The captured share is the Gaussian--LP01 overlap + squared, here by an independent quadrature, and the beam-into-fibre envelope + is counted at its contrast-scaled allowance (plus the blue bins where V + leaves its range). + """ + radius = 2.57e-6 + aperture = 1.45 * math.sqrt(1 - 0.99) + mode = StepIndexMode( + radius, core=1.45, cladding=1.45 * math.sqrt(0.99), profile_wavelength=1064e-9 + ) + target = Simulation.from_gnlse( + None, + 1e-3, + fibre=mode, + n2=2.6e-20, + lambda0=1064e-9, + wavelength_limits=(900e-9, 1300e-9), + trange=400e-15, + fwhm=100e-15, + energy=1e-9, + n_save=2, + backend=RECEIVER, + ) + port = target.input_port() + waist = 2.6e-6 + beam = SpatialPulse( + GaussianPulse(wavelength=1064e-9, fwhm=100e-15, energy=1e-9), waist + ) + incident = FieldPlane.from_pulse( + beam, + port.grid, + PlaneGeometry(0.0), + space=RadialGrid(40 * radius, 256), + index=np.asarray(port.reference_index), + ) + coupled = couple( + incident, port, model=CouplingModel("scalar_paraxial", model_error=0.1) + ) + received = target.with_input(coupled.launch).run(progress="none") + report = coupled.report + assert_coupled(report, received) + profile = lp01(radius, aperture, 1064e-9) + rule = {"points": [radius], "limit": 400, "epsabs": 0, "epsrel": 1e-13} + overlap = quad( + lambda r: profile(r) * np.exp(-((r / waist) ** 2)) * r, 0, 40e-6, **rule + )[0] + norm = quad(lambda r: profile(r) ** 2 * r, 0, 40e-6, **rule)[0] + expected = overlap**2 / (norm * waist**2 / 4) + captured = report.captured_energy + assert captured is not None + # Two quadratures of one integral, the model's through the radial plane's + # spectral representation of the sampled beam (its numerical item). + assert captured / report.incident_energy == pytest.approx(expected, rel=1e-6) + envelope = next(item for item in report.items if item.name == "envelope") + assert envelope.value is not None + # The contrast-scaled allowance; the blue bins beyond V 2.4 carry ~1e-20 of + # this narrow pulse. + assert envelope.value == pytest.approx( + STEP_INDEX_BEAM_ENVELOPE.allowance({"contrast": 0.005}), rel=1e-9 + ) + assert envelope.note.startswith(STEP_INDEX_BEAM_ENVELOPE.identity) + + # -------------------------------------------- (d) a Gaussian, (e) an analytic field From a965c0c180e297f4847c2c989838d92af7102ba4 Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 11:30:54 +0100 Subject: [PATCH 40/50] Coupling: record a facet envelope for vector_paraxial into step-index guides VECTOR_STEP_INDEX_ENVELOPE bounds the reflection the vector model omits at a step-index entrance facet against an independent full-vector mode-matching solution (tests/facet_mode_matching.py): bound 1.4e-3 at contrast 0.05, V 1.5 to 8, scaled by the squared contrast. A step-index port carries its normalised frequency; the envelope item is counted, a guide outside the range is refused by name, and a graded-index guide keeps an uncounted item without a value. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019qNYqtCKh4iTKj7544yARb --- src/solprop/coupling/__init__.py | 2 + src/solprop/coupling/coupler.py | 101 +++++- src/solprop/coupling/envelopes.py | 64 +++- src/solprop/coupling/port.py | 8 + src/solprop/translate/coupling.py | 56 +++- tests/coupling/test_envelopes.py | 361 +++++++++++++++++++++- tests/coupling/test_vector_coupling.py | 110 +++++++ tests/facet_mode_matching.py | 408 +++++++++++++++++++++++++ 8 files changed, 1086 insertions(+), 24 deletions(-) create mode 100644 tests/facet_mode_matching.py diff --git a/src/solprop/coupling/__init__.py b/src/solprop/coupling/__init__.py index b1cbbb611..bf2634b82 100644 --- a/src/solprop/coupling/__init__.py +++ b/src/solprop/coupling/__init__.py @@ -41,6 +41,7 @@ from solprop.coupling.envelopes import ( CAPILLARY_ENVELOPE, STEP_INDEX_ENVELOPE, + VECTOR_STEP_INDEX_ENVELOPE, PairEnvelope, ) from solprop.coupling.models import ( @@ -85,6 +86,7 @@ "METRIC_TOLERANCE", "PLANE_KINDS", "STEP_INDEX_ENVELOPE", + "VECTOR_STEP_INDEX_ENVELOPE", "AdmissionItem", "Alignment", "AnalyticRecord", diff --git a/src/solprop/coupling/coupler.py b/src/solprop/coupling/coupler.py index e86f6b0ca..84a88fbf9 100644 --- a/src/solprop/coupling/coupler.py +++ b/src/solprop/coupling/coupler.py @@ -48,7 +48,10 @@ apparent gain within the model's own defects is reported as a model defect, never rescaled or clipped, and a larger one refuses; - ``envelope``: the recorded pair envelope's allowance (scaled by the pair's leaky - parameter for a capillary), plus the incident share of any bins outside its range; + parameter for a capillary, by the squared contrast for the vector model into a + step-index guide), plus the incident share of any bins outside its range; a + general guide without a recorded envelope (a graded index) reports it without a + value, uncounted; - ``numerical``: quadrature, representation and domain errors, an amplitude error :math:`\delta` entered as :math:`2\delta + \delta^2`. @@ -74,6 +77,7 @@ from solprop.coupling.envelopes import ( CAPILLARY_ENVELOPE, STEP_INDEX_ENVELOPE, + VECTOR_STEP_INDEX_ENVELOPE, PairEnvelope, ) from solprop.coupling.models import CouplingModel, ScalarModeBasis, radial_rule @@ -1984,6 +1988,7 @@ def _vector_paraxial( np.zeros(0) if port.contrast is None else np.asarray(port.contrast)[occupied] ) probes = _probe_bins(occupied, source.norm) + incident = _bin_weights(port) * source.norm items = [ *source.items, AdmissionItem( @@ -2004,15 +2009,7 @@ def _vector_paraxial( ), _closure_item(electric, magnetic, weights, admittance, probes, epsilon), _target_angular_vector(electric, weights, port, index, probes, epsilon), - AdmissionItem( - "envelope", - None, - None, - False, - "model", - "no recorded pair envelope: omitted facet reflection is bounded only " - "by the contrast screen and the closure", - ), + _vector_envelope_item(port, incident), ] items.append(_gain_item(gain, items, "field")) return samples, _report( @@ -2020,7 +2017,7 @@ def _vector_paraxial( port, model, index=index, - incident=_bin_weights(port) * source.norm, + incident=incident, launched=port.metric.spectral_energy(samples, port.grid), captured=None, captured_reason=( @@ -2032,6 +2029,88 @@ def _vector_paraxial( ) +def _vector_envelope_item( + port: InputPort, incident: Float[np.ndarray, " n_omega"] +) -> AdmissionItem: + r"""Return the vector model's recorded facet envelope item, or refuse the guide. + + Only a :class:`~solprop.modes.guide.StepIndexProvider` guide has a recorded + envelope (:data:`~solprop.coupling.envelopes.VECTOR_STEP_INDEX_ENVELOPE`); its + port carries the guide's normalised frequency. Its parameters are the contrast, + V and squared contrast weighted by the incident energy, as the other per-bin + items are: the allowance is linear in the squared contrast, so the weighted + mean bounds the whole field over the bins inside the range. Bins whose contrast + or V leave the range are not bounded, and their incident share is counted in + full. Any other general guide (a graded index) keeps an uncounted item without + a value: its omitted facet reflection is bounded only by the contrast screen + and the closure. + + Parameters + ---------- + port : solprop.coupling.port.InputPort + The general modal port. + incident : numpy.ndarray + Incident energy per bin, J. + + Returns + ------- + AdmissionItem + ``envelope``. + + Raises + ------ + CouplingError + If the weighted parameters lie outside the envelope's range. + """ + if port.normalised_frequency is None: + return AdmissionItem( + "envelope", + None, + None, + False, + "model", + "no recorded pair envelope for this provider (only a step-index guide " + "has one): omitted facet reflection is bounded only by the contrast " + "screen and the closure", + ) + envelope = VECTOR_STEP_INDEX_ENVELOPE + frequency = np.asarray(port.normalised_frequency) + # A step-index port carries its contrast beside its frequency. + contrast = np.asarray(port.contrast) + parameters = { + "contrast": _weighted(contrast, incident), + "v": _weighted(frequency, incident), + "contrast_squared": _weighted(contrast**2, incident), + } + if float(np.sum(incident)) > 0.0: + outside = envelope.covers(parameters) + if outside is not None: + msg = ( + f"vector_paraxial is not qualified for this step-index guide: {outside}" + ) + raise CouplingError(msg) + low, high = envelope.ranges["v"] + beyond = ( + (contrast > envelope.ranges["contrast"][1]) + | (frequency < low) + | (frequency > high) + ) + uncovered = _weighted(beyond.astype(float), incident) + return AdmissionItem( + "envelope", + envelope.allowance(parameters) + uncovered, + None, + True, + "model", + envelope.identity + + ( + f", plus the {uncovered:.1e} incident share outside its range" + if uncovered > 0.0 + else "" + ), + ) + + def _vector_coordinates( source: _Source, port: InputPort, diff --git a/src/solprop/coupling/envelopes.py b/src/solprop/coupling/envelopes.py index 1d2d3e329..ba0fd9ba2 100644 --- a/src/solprop/coupling/envelopes.py +++ b/src/solprop/coupling/envelopes.py @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: GPL-3.0-or-later -r"""Recorded benchmark envelopes of the scalar coupling model's provider pairs. +r"""Recorded benchmark envelopes of the approximate coupling models' provider pairs. SEP 31 section 4.3 admits a provider pair to an approximate coupling model only with an independent benchmark envelope for what the model omits, recorded with its @@ -21,32 +21,43 @@ overlap of their exact bound HE11 fields (:meth:`solprop.modes.stepindex.StepIndexMode.electromagnetic_field`). It replaces the contrast screen within its range. +- :data:`VECTOR_STEP_INDEX_ENVELOPE`: the vector model's flux recovery into a + :class:`~solprop.modes.guide.StepIndexProvider` guide against an independent + full-vector mode-matching solution of the planar entrance facet, the reflection + and backward fields the model omits. Its bound scales with the square of the + guide's contrast. """ from collections.abc import Mapping from dataclasses import dataclass from types import MappingProxyType -__all__ = ["CAPILLARY_ENVELOPE", "STEP_INDEX_ENVELOPE", "PairEnvelope"] +__all__ = [ + "CAPILLARY_ENVELOPE", + "STEP_INDEX_ENVELOPE", + "VECTOR_STEP_INDEX_ENVELOPE", + "PairEnvelope", +] @dataclass(frozen=True) class PairEnvelope: - """A recorded benchmark envelope of one provider pair under the scalar model. + """A recorded benchmark envelope of one provider pair under an approximate model. Attributes ---------- identity : str The envelope's name, recorded in a coupling report. - family : {'capillary', 'step_index'} - The profile family of both members of the pair. + family : {'capillary', 'step_index', 'step_index_vector'} + The profile family of both members of the pair, or for the vector model + the target guide's provider. ranges : collections.abc.Mapping Each range parameter's closed interval ``(low, high)``; a pair outside any one is not covered. bound : float - The largest measured difference of captured energy share, scalar model - against the independent reference, over the range, dimensionless, numerical - error included: an energy discrepancy relative to the incident energy. + The largest measured difference of captured energy share, model against + the independent reference, over the range, dimensionless, numerical error + included: an energy discrepancy relative to the incident energy. numerical : float The independently estimated numerical error of the measurement, included in `bound`. @@ -169,3 +180,40 @@ def allowance(self, parameters: Mapping[str, float]) -> float: ``|n^2 - n_ref^2|/(2 n_ref^2)`` of either fibre, ``v_*`` each fibre's normalised frequency at its profile wavelength, ``radius_ratio`` source over target core radius, ``modes`` the larger LP0m count (LP01 only).""" + + +VECTOR_STEP_INDEX_ENVELOPE = PairEnvelope( + identity="step-index-vector-facet-mode-matching-2026-10", + family="step_index_vector", + ranges={ + "contrast": (0.0, 0.05), + "v": (1.5, 8.0), + "contrast_squared": (0.0, 2.5e-3), + }, + bound=1.4e-3, + numerical=2e-5, + reference=( + "independent full-vector mode matching of the planar entrance facet " + "(incident and reflected fields in the core-index input medium, the " + "fibre's forward guided and radiation fields, tangential E and H " + "continuous), solved per azimuthal order in a conducting cylinder of 8 " + "core radii on its own TE/TM Bessel basis, against the model's flux " + "pairing with the same incident H: x-polarised Gaussian beams of waist " + "1a to 2a, displaced to 0.5a and tilted to 0.3 NA, into HE11 at V 1.5 to " + "8 and HE12, HE21, TE01 and TM01 at V 4; contrast 0.05 (the range's top), " + "input medium of the core's index. Largest omitted share 6.9e-4 (HE11, " + "V 1.5, a 2a waist); bound twice that; refining the basis and widening " + "the cylinder changes it by 1.1e-5. It is second order in the index step " + "(halving the contrast quarters it, and at weak guidance it is the local " + "Fresnel estimate to a few per cent), so the allowance is scaled by the " + "squared contrast over 0.05**2" + ), + scaled_by="contrast_squared", +) +"""The vector model's step-index facet envelope; ``contrast`` is the guide's +``|n^2 - n_ref^2|/(2 n_ref^2)``, ``v`` its normalised frequency at the entrance and +``contrast_squared`` the square of the contrast, each weighted by the incident energy +over the occupied bins. It bounds the reflection and backward fields at the facet, +not the incident beam's H closure (the angular item) or the guide's tabulation; it +covers a beam in the core's index only, since a bulk index change is a separate +planar interface (SEP 31, section 4.3).""" diff --git a/src/solprop/coupling/port.py b/src/solprop/coupling/port.py index c10254bb5..a72dc5e00 100644 --- a/src/solprop/coupling/port.py +++ b/src/solprop/coupling/port.py @@ -138,6 +138,12 @@ class InputPort: guide's dielectric regions at each bin, shape ``(n_omega,)``, zero off the active band; None where the screen does not apply (a capillary wall, a spatial medium). + normalised_frequency : numpy.ndarray or None, optional + A step-index general guide's normalised frequency + :math:`V = (\omega/c)\,a\sqrt{n_{\rm co}^2 - n_{\rm cl}^2}` at its entrance + at each bin, shape ``(n_omega,)``, zero off the active band: what the + recorded vector envelope's range is checked against. None for every other + port. Raises ------ @@ -160,6 +166,7 @@ class InputPort: models: tuple[str, ...] = ("same_basis",) profiles: ScalarModeBasis | None = None contrast: Float[np.ndarray, " n_omega"] | None = None + normalised_frequency: Float[np.ndarray, " n_omega"] | None = None def __post_init__(self) -> None: """Check the shape, the basis and the reference index.""" @@ -188,6 +195,7 @@ def __post_init__(self) -> None: ("electric", np.complex128), ("magnetic", np.complex128), ("contrast", np.float64), + ("normalised_frequency", np.float64), ): value = getattr(self, name) if value is not None: diff --git a/src/solprop/translate/coupling.py b/src/solprop/translate/coupling.py index 98db738ba..b01a4f5f9 100644 --- a/src/solprop/translate/coupling.py +++ b/src/solprop/translate/coupling.py @@ -21,6 +21,7 @@ from typing import cast import numpy as np +from jaxtyping import Float from solprop.constants import EPS0, C from solprop.coupling import ( @@ -35,7 +36,7 @@ ) from solprop.exceptions import CouplingError, TranslationError from solprop.grid import TimeGrid -from solprop.modes.guide import ModalGuide +from solprop.modes.guide import ModalGuide, StepIndexProvider from solprop.modes.protocol import Mode from solprop.spatial import FluxSide, SpatialMedium from solprop.translate.chebyshev import PiecewiseChebyshev @@ -45,7 +46,13 @@ from solprop.translate.problem import KernelProblem from solprop.translate.spatial import axial_admittance -__all__ = ["frame_record", "guide_contrast", "input_port", "port_metric"] +__all__ = [ + "frame_record", + "guide_contrast", + "input_port", + "port_metric", + "step_index_frequency", +] def frame_record( @@ -216,7 +223,7 @@ def input_port( # noqa: PLR0913 - the problem and each part of the entrance it index = values nodes = weights = electric = magnetic = None models: tuple[str, ...] = ("same_basis",) - profiles = contrast = None + profiles = contrast = frequency = None if tables is not None: synthesis = tables.synthesis moves = synthesis is not None and synthesis.shape[0] * synthesis.shape[1] > 1 @@ -229,6 +236,7 @@ def input_port( # noqa: PLR0913 - the problem and each part of the entrance it magnetic = np.conj(tables.magnetic[0]) if guide is not None: index, contrast = guide_contrast(guide, grid) + frequency = step_index_frequency(guide, grid, z) models = ("same_basis", "vector_paraxial") if route == "modal" and modes: try: @@ -259,6 +267,7 @@ def input_port( # noqa: PLR0913 - the problem and each part of the entrance it models, profiles, contrast, + frequency, ) @@ -289,6 +298,47 @@ def _scalar_contrast(profiles: ScalarModeBasis, index: np.ndarray) -> np.ndarray return contrast +def step_index_frequency( + guide: ModalGuide, grid: TimeGrid, z: float +) -> Float[np.ndarray, " n_omega"] | None: + r"""Return a step-index guide's normalised frequency at its entrance, per bin. + + :math:`V = (\omega/c)\,a(z)\sqrt{n_{\rm co}^2 - n_{\rm cl}^2}`, with the core + radius at the entrance and the real parts of the core's and the cladding's + declared laws, is what the recorded vector envelope's range + (:data:`solprop.coupling.envelopes.VECTOR_STEP_INDEX_ENVELOPE`) is stated in. + + Parameters + ---------- + guide : solprop.modes.guide.ModalGuide + The guide. + grid : solprop.grid.TimeGrid + The run's grid. + z : float + The entrance, m. + + Returns + ------- + numpy.ndarray or None + V at each bin, shape ``(n_omega,)``, zero off the active positive band; + None for a guide whose provider is not a + :class:`~solprop.modes.guide.StepIndexProvider`. + """ + provider = guide.field_provider + if not isinstance(provider, StepIndexProvider): + return None + live = grid.active_mask & (grid.omega > 0) + omega = grid.omega[live] + radius = float(provider.modes[0].core_radius(np.asarray([z]))[0]) + core, cladding = ( + np.real(np.asarray(region.material.permittivity(omega))) + for region in provider.regions + ) + frequency = np.zeros(grid.n_omega) + frequency[live] = omega / C * radius * np.sqrt(np.maximum(core - cladding, 0.0)) + return frequency + + def guide_contrast(guide: ModalGuide, grid: TimeGrid) -> tuple[np.ndarray, np.ndarray]: r"""Return a general guide's core index and weak-guidance contrast at each bin. diff --git a/tests/coupling/test_envelopes.py b/tests/coupling/test_envelopes.py index ccfce6910..b7d459a36 100644 --- a/tests/coupling/test_envelopes.py +++ b/tests/coupling/test_envelopes.py @@ -29,11 +29,21 @@ import pytest from scipy.special import jn_zeros, jv +from facet_mode_matching import ( + Y_VACUUM, + Beam, + StepIndexFacet, + hybrid_eigenvalue, + mode_shares, + ring_rule, +) +from modal_provider_cases import make_provider from solprop import fourier -from solprop.constants import EPS0, C +from solprop.constants import EPS0, MU0, C from solprop.coupling import ( CAPILLARY_ENVELOPE, STEP_INDEX_ENVELOPE, + VECTOR_STEP_INDEX_ENVELOPE, CouplingModel, FieldPlane, PlaneBasis, @@ -44,8 +54,9 @@ from solprop.coupling.models import radial_rule from solprop.driver import Simulation from solprop.exceptions import CouplingError -from solprop.grid import EnvelopeGrid, FreeGrid +from solprop.grid import EnvelopeGrid, FieldGrid, FreeGrid from solprop.modes import MarcatiliMode, StepIndexMode +from solprop.modes.guide import ModalGuide from solprop.spatial import SpatialMedium RADIUS = 75e-6 @@ -515,3 +526,349 @@ def test_the_step_index_envelope_bounds_lp01_against_the_vector_he11_overlap(): worst = max(worst, abs(scalar - vector)) assert numerical <= STEP_INDEX_ENVELOPE.numerical assert worst + numerical <= STEP_INDEX_ENVELOPE.bound + + +FACET_WAVELENGTH = 1e-6 +FACET_OMEGA = 2 * math.pi * C / FACET_WAVELENGTH +FACET_CORE = 1.45 + + +def facet_fibre(contrast, v): + """Return (radius, (input, core, cladding) indices) of a matched-input fibre.""" + cladding = FACET_CORE * math.sqrt(1.0 - 2.0 * contrast) + k = FACET_OMEGA / C + radius = v / (k * math.sqrt(FACET_CORE**2 - cladding**2)) + return radius, (FACET_CORE, FACET_CORE, cladding) + + +def test_the_facet_reference_solves_the_exact_vector_eigenvalue(): + """The reference's own HE11 converges to the exact vector eigenvalue equation. + + Snyder and Love's equation, solved directly, and the package's step-index + solver agree to rounding; the reference reaches them as 1/N in its basis + size, as a smooth basis meeting a jump in E_r does, without using either. + """ + radius, indices = facet_fibre(0.01, 2.0) + k = FACET_OMEGA / C + u = hybrid_eigenvalue(1, 2.0, indices[1], indices[2]) + exact = math.sqrt(indices[1] ** 2 - (u / (k * radius)) ** 2) + package = StepIndexMode( + radius, + core=indices[1], + cladding=indices[2], + profile_wavelength=FACET_WAVELENGTH, + ).neff(np.array([FACET_OMEGA]))[0] + assert abs(float(np.real(package)) - exact) < 1e-12 + errors = [] + for count in (50, 100, 200): + facet = StepIndexFacet(1, radius, indices, FACET_OMEGA, 8 * radius, count) + assert facet.orthonormality < 1e-12 + guided = facet.guided() + assert guided.size == 1 + errors.append(abs(float(np.real(facet.beta[guided[0]])) / k - exact)) + # First order in 1/N once asymptotic: the last doubling about halves it. + assert errors[0] > errors[1] + assert errors[2] < 0.6 * errors[1] + assert errors[1] < 2e-6 + + +def test_the_facet_reference_meets_fresnel_and_conserves_power(): + """A uniform guide is a planar interface: every basis field meets Fresnel. + + With the core and cladding of one index the guide's modes are the cylinder's + own fields, its admittance is diagonal, and each field's transmission is the + modal Fresnel coefficient 2 Y_0 / (Y_0 + Y_1) of its TE or TM admittance. On a + real step-index facet the incident power equals the reflected plus the + transmitted power to rounding. + """ + radius = 2e-6 + facet = StepIndexFacet(1, radius, (1.45, 1.5, 1.5), FACET_OMEGA, 8 * radius, 60) + k = FACET_OMEGA / C + basis = facet.basis + live = basis.k < 0.9 * k * 1.45 + beta_in = np.sqrt((k * 1.45) ** 2 - basis.k[live] ** 2) + beta_out = np.sqrt((k * 1.5) ** 2 - basis.k[live] ** 2) + te = basis.is_te[live] + y_in = np.where( + te, beta_in / (FACET_OMEGA * MU0), FACET_OMEGA * EPS0 * 1.45**2 / beta_in + ) + y_out = np.where( + te, beta_out / (FACET_OMEGA * MU0), FACET_OMEGA * EPS0 * 1.5**2 / beta_out + ) + np.testing.assert_allclose(np.real(np.diag(facet.y_fibre))[live], y_out, rtol=1e-9) + for j in np.flatnonzero(live)[:: max(1, int(np.sum(live)) // 8)]: + unit = np.zeros(basis.k.size, dtype=complex) + unit[j] = 1.0 + transmitted = 2 * np.linalg.solve( + facet.y_input + facet.y_fibre, facet.y_input @ unit + ) + position = int(np.flatnonzero(np.flatnonzero(live) == j)[0]) + expected = 2 * y_in[position] / (y_in[position] + y_out[position]) + assert abs(transmitted[j] - expected) < 1e-9 + radius, indices = facet_fibre(0.03, 2.0) + step = StepIndexFacet(1, radius, indices, FACET_OMEGA, 8 * radius, 60) + radii, weights, theta = ring_rule((0.0, radius, 8 * radius), radius / 4) + r, t = radii[:, None], theta[None, :] + ex = Beam(1.0, (0.3, 0.0), 0.2).field( + r * np.cos(t), r * np.sin(t), radius, k * 0.35 + ) + coefficients = step.basis.project( + radii, weights, theta, ex * np.cos(t), -ex * np.sin(t) + ) + incident, reflected, transmitted_power = step.amplitudes( + coefficients, int(step.guided()[0]) + )[3] + assert reflected > 0.0 + assert abs(incident - reflected - transmitted_power) < 1e-10 * incident + + +def test_the_facet_reference_guided_field_is_the_package_he11(): + """The reference's HE11 has the radial profile of the package's exact field. + + The order-one mode is circularly polarised, so its |e_t|^2 is the azimuthal + mean of the even HE11's from + :meth:`~solprop.modes.StepIndexMode.electromagnetic_field`. Both are unit + power; the reference's profile converges as 1/N (1e-2 at N = 100). + """ + radius, indices = facet_fibre(0.01, 2.0) + facet = StepIndexFacet(1, radius, indices, FACET_OMEGA, 8 * radius, 100) + u, _ = facet.unit_mode(int(facet.guided()[0])) + probe = radius * np.array([0.1, 0.4, 0.7, 1.3, 1.8, 2.5]) + er, ep, _ = facet.basis.fields(probe) + reference = np.abs(er @ u) ** 2 + np.abs(ep @ u) ** 2 + mode = StepIndexMode( + radius, + core=indices[1], + cladding=indices[2], + profile_wavelength=FACET_WAVELENGTH, + ) + theta = 2 * np.pi * (np.arange(64) + 0.5) / 64 + xy = np.column_stack( + ( + np.repeat(probe, 64) * np.tile(np.cos(theta), 6), + np.repeat(probe, 64) * np.tile(np.sin(theta), 6), + ) + ) + electric, _ = mode.electromagnetic_field(xy, FACET_OMEGA) + package = np.mean( + (np.abs(electric[:, 0]) ** 2 + np.abs(electric[:, 1]) ** 2).reshape(6, 64), + axis=1, + ) + np.testing.assert_allclose(reference, package, rtol=1e-2) + + +@pytest.mark.parametrize(("contrast", "v"), [(0.003, 2.0), (0.003, 4.0), (0.01, 2.0)]) +def test_the_facet_correction_is_the_local_fresnel_estimate(contrast, v): + """At weak guidance the omitted facet term is the local Fresnel estimate. + + To leading order the admittance difference D = Y_f - Y_0 is the local index + step Y_vac (n(r) - n_co) and Y_0 + Y_f is 2 Y_vac n_co, so the identity + a_exact - a_model = -/4 becomes + -(Y_vac (n_cl - n_co)^2 / (8 n_co)) times the cladding overlap of beam and + mode: second order in the step, and negative. The rigorous solution agrees to + a few per cent, the size of the next order. + """ + radius, indices = facet_fibre(contrast, v) + radii, weights, theta = ring_rule((0.0, radius, 8 * radius), radius / 4) + r, t = radii[:, None], theta[None, :] + ex = Beam(1.0).field(r * np.cos(t), r * np.sin(t), radius, 0.0) + incident = ( + ( + 0.5 + * FACET_CORE + * Y_VACUUM + * np.sum((radii * weights)[:, None] * np.abs(ex) ** 2) + ) + * 2 + * np.pi + / theta.size + ) + facet = StepIndexFacet(1, radius, indices, FACET_OMEGA, 8 * radius, 100) + coefficients = facet.basis.project( + radii, weights, theta, ex * np.cos(t), -ex * np.sin(t) + ) + mode = int(facet.guided()[0]) + u, _ = facet.unit_mode(mode) + exact, model_exact_h, _, _ = facet.amplitudes(coefficients, mode) + step = indices[2] - indices[1] + local = -(Y_VACUUM * step**2 / (8 * FACET_CORE)) * ( + u.conj() @ facet.cladding_gram @ coefficients + ) + rigorous = (abs(exact) ** 2 - abs(model_exact_h) ** 2) / incident + estimate = 2 * float(np.real(np.conj(model_exact_h) * local)) / incident + assert rigorous < 0.0 + assert rigorous == pytest.approx(estimate, rel=0.05) + + +FACET_CASES = [ + # (V, beam, orders, which): HE11 over the V range, then higher channels. + *( + (v, beam, (1, -1), 0) + for v in (1.5, 2.4, 4.0, 8.0) + for beam in (Beam(1.0), Beam(2.0), Beam(1.5, (0.5, 0.0), 0.3)) + ), + (4.0, Beam(2.0), (1, -1), 2), + (4.0, Beam(2.0, (0.0, 0.0), 0.3), (2, -2), 0), + (4.0, Beam(2.0, (0.0, 0.5)), (0,), 0), + (4.0, Beam(2.0, (0.0, 0.0), 0.3), (0,), 1), +] +"""The envelope's sampled cases: V, beam, azimuthal orders and guided index. + +HE11 at both ends of the V range and between, centred, wide and displaced and +tilted; then at V = 4 the HE12 (third mode of order one, the largest higher-mode +discrepancy found), HE21 (order two), TE01 (a y-displaced x-polarised beam is the +one that excites it) and TM01.""" + + +def worst_facet(contrast, **discretisation): + """Return the largest |facet| share over the cases at a contrast, and its case.""" + worst, where = 0.0, None + for v, beam, orders, which in FACET_CASES: + radius, indices = facet_fibre(contrast, v) + shares = mode_shares( + beam, radius, indices, FACET_OMEGA, orders, which, **discretisation + ) + if abs(shares.facet) > worst: + worst, where = abs(shares.facet), (v, beam, orders, which) + return worst, where + + +@pytest.mark.medium +def test_the_vector_step_index_envelope_bounds_the_omitted_facet(): + """The vector model against the full facet solution, over the envelope's range. + + At the top contrast, 0.05, the largest omitted share is 6.9e-4 (HE11 at + V = 1.5, a wide beam: the most power in the cladding, where the index steps); + the bound is twice it. Halving the contrast quarters it: the discrepancy is + second order in the step, so the allowance scales with the squared contrast. + Refining the basis and widening the cylinder estimates the numerical error. + """ + envelope = VECTOR_STEP_INDEX_ENVELOPE + top = envelope.ranges["contrast"][1] + worst, where = worst_facet(top) + assert where is not None + assert worst == pytest.approx(6.9e-4, rel=0.05) + v, beam, orders, which = where + radius, indices = facet_fibre(top, v) + fine = mode_shares( + beam, radius, indices, FACET_OMEGA, orders, which, outer=12.0, count=200 + ) + assert abs(abs(fine.facet) - worst) <= envelope.numerical + assert 2 * worst + envelope.numerical <= envelope.bound + radius, indices = facet_fibre(0.5 * top, v) + half = mode_shares(beam, radius, indices, FACET_OMEGA, orders, which) + assert abs(half.facet) <= 0.3 * worst + assert envelope.allowance({"contrast_squared": (0.5 * top) ** 2}) >= 2 * abs( + half.facet + ) + + +def facet_port(contrast, v_centre): + """Return a general modal port of a step-index HE11 pair, V at 835 nm given. + + 48 azimuthal nodes: with the provider's default nine a narrow guide's node rule + aliases a sampled beam's oblique columns into a spurious apparent gain. + """ + wavelength = 835e-9 + radius, (_, core, cladding) = facet_fibre(contrast, v_centre) + radius *= wavelength / FACET_WAVELENGTH + grid = FieldGrid.from_limits(1e-3, wavelength, (815e-9, 860e-9), 60e-15) + provider = make_provider( + radius=radius, + descriptors=(("HE", 1, 1, "even"), ("HE", 1, 1, "odd")), + indices=(core, cladding), + ) + active = grid.omega[grid.active_mask] + guide = ModalGuide.from_provider( + provider, + np.linspace(0.99 * active[0], 1.01 * active[-1], 17), + nodes=provider.node_rule(0.99 * active[0], n_r=48, n_theta=48), + degenerate_blocks=((0, 1),), + ) + simulation = Simulation.from_waveguide( + (), + 1e-4, + provider=guide, + grid=grid, + lambda0=wavelength, + energy=1e-12, + fwhm=20e-15, + pulseshape="gauss", + backend="numpy_ref", + dz_max=5e-5, + dz_init=5e-5, + n_save=2, + ) + return simulation.input_port(), radius, (core, core, cladding) + + +@pytest.mark.medium +@pytest.mark.filterwarnings("ignore::solprop.exceptions.GeneralModalBasisWarning") +def test_vector_paraxial_launches_the_facet_reference_within_its_items(): + """``couple`` into a step-index guide against the facet solution, bin by bin. + + One Cartesian plane carries a different beam in each of four bins, so one + coupling samples V from 1.8 to 2.4. In each bin the launched share equals + the reference's own evaluation of the model to the guide's tabulation + (measured to 4e-6); the incident beam's H closure, the model's other omission, + is within the angular item; and the facet the model omits is within the + envelope item. The envelope item is the allowance at the plane's weighted + squared contrast. + """ + port, radius, indices = facet_port(0.01, 2.4) + grid = port.grid + aperture = math.sqrt(indices[1] ** 2 - indices[2] ** 2) + frequency = np.asarray(port.normalised_frequency) + active = np.flatnonzero(grid.active_mask) + beams = { + int(active[np.argmin(np.abs(frequency[active] - v))]): beam + for v, beam in ( + (1.8, Beam(2.0)), + (2.0, Beam(1.5, (0.5, 0.0), 0.3)), + (2.2, Beam(1.0, (0.3, 0.0))), + (2.4, Beam(2.0, (0.25, 0.0), 0.2)), + ) + } + + def field(x, y, omega): + samples = np.zeros((x.size, omega.size), dtype=complex) + for k, beam in beams.items(): + j = int(np.argmin(np.abs(omega - grid.omega[k]))) + samples[:, j] = beam.field(x, y, radius, omega[j] / C * aperture) + return samples + + lowest = min(grid.omega[k] for k in beams) + # The corner column, sqrt(2) pi / spacing, stays below 0.9 n k at the lowest + # bin: no column is evanescent in any bin. + spacing = 0.8 * 2 * math.pi * C / lowest / indices[0] + half = 8 * radius + count = 2 * math.ceil(half / spacing) + plane = FieldPlane.from_analytic( + grid, + PlaneGeometry(0.0), + field, + space=FreeGrid(half, count, half, count), + index=np.asarray(port.reference_index), + ) + report = couple( + plane, port, model=CouplingModel("vector_paraxial", model_error=0.5) + ).report + items = {(item.name, item.scope): item for item in report.items} + envelope = items["envelope", "model"] + angular = cast("float", items["angular", "field"].value) + contrast = np.asarray(port.contrast) + weighted = float(np.sum(contrast**2 * report.incident) / np.sum(report.incident)) + assert envelope.counted + assert VECTOR_STEP_INDEX_ENVELOPE.identity in envelope.note + assert envelope.value == pytest.approx( + VECTOR_STEP_INDEX_ENVELOPE.allowance({"contrast_squared": weighted}), rel=1e-12 + ) + for k, beam in beams.items(): + shares = mode_shares(beam, radius, indices, grid.omega[k], outer=12.0) + launched = report.launched[k] / report.incident[k] + # The guide's tables interpolate E/H between 17 knots: the measured + # difference from the reference's own model evaluation is 4e-6. + assert abs(launched - shares.model) < 1e-5 + assert abs(shares.model_exact_h - shares.model) <= angular + assert abs(shares.facet) <= cast("float", envelope.value) + assert abs(launched - shares.exact) <= cast("float", envelope.value) + angular diff --git a/tests/coupling/test_vector_coupling.py b/tests/coupling/test_vector_coupling.py index d1d7752d3..4edb7b4fc 100644 --- a/tests/coupling/test_vector_coupling.py +++ b/tests/coupling/test_vector_coupling.py @@ -15,12 +15,15 @@ import dataclasses import math +from typing import cast import numpy as np import pytest from modal_provider_cases import GENERAL_Z_END, general_grid, general_guide +from solprop.constants import C from solprop.coupling import ( + VECTOR_STEP_INDEX_ENVELOPE, CouplingModel, FieldPlane, PlaneBasis, @@ -297,3 +300,110 @@ def test_a_general_plane_is_recovered_in_another_basis_at_common_nodes(grid, pai coarse = strand(grid, n_r=20) with pytest.raises(CouplingError, match="common quadrature"): couple(plane, simulation(coarse, grid).input_port(), model=SAME) + + +def envelope_item(report): + """Return a report's envelope item.""" + return next(item for item in report.items if item.name == "envelope") + + +def test_a_step_index_guide_counts_its_recorded_facet_envelope(grid, pair_port): + """A StepIndexProvider port carries V; the facet envelope's allowance is counted. + + The strand is V = 5.1 at 835 nm with a 0.0069 contrast, inside the recorded + range; the item is the bound scaled by the weighted squared contrast, plus the + incident share of any bins whose V leaves the range. + """ + frequency = np.asarray(pair_port.normalised_frequency) + centre = int(np.argmin(np.abs(grid.omega - grid.omega0))) + # V = (omega/c) a sqrt(1.45^2 - 1.44^2) for the 4 um strand. + expected = grid.omega[centre] / C * 4e-6 * math.sqrt(1.45**2 - 1.44**2) + assert frequency[centre] == pytest.approx(expected, rel=1e-12) + report = couple(step_index_plane(grid, (1.0,)), pair_port, model=VECTOR).report + item = envelope_item(report) + assert item.counted + assert item.note.startswith(VECTOR_STEP_INDEX_ENVELOPE.identity) + incident = report.incident / np.sum(report.incident) + contrast = np.asarray(pair_port.contrast) + low, high = VECTOR_STEP_INDEX_ENVELOPE.ranges["v"] + beyond = float(np.sum(incident[(frequency < low) | (frequency > high)])) + allowance = VECTOR_STEP_INDEX_ENVELOPE.allowance( + {"contrast_squared": float(np.sum(contrast**2 * incident))} + ) + assert item.value == pytest.approx(allowance + beyond, rel=1e-12) + assert report.budget <= BUDGET + + +def test_bins_beyond_the_facet_envelope_add_their_incident_share(grid, pair_port): + """Bins whose V leaves the range are counted in full, and the item says so.""" + frequency = np.asarray(pair_port.normalised_frequency) + centre = int(np.argmin(np.abs(grid.omega - grid.omega0))) + # Move the centre bin alone to V = 8.2, just beyond the range. + stretched = frequency.copy() + stretched[centre] = 8.2 + port = dataclasses.replace(pair_port, normalised_frequency=stretched) + model = CouplingModel("vector_paraxial", model_error=0.9) + report = couple(step_index_plane(grid, (1.0,)), port, model=model).report + share = report.incident[centre] / np.sum(report.incident) + item = envelope_item(report) + assert "incident share outside its range" in item.note + within = envelope_item( + couple(step_index_plane(grid, (1.0,)), pair_port, model=VECTOR).report + ) + assert cast("float", item.value) == pytest.approx( + cast("float", within.value) + share, rel=1e-6 + ) + + +@pytest.mark.parametrize( + ("change", "named"), + [({"frequency": 2.0}, "v = "), ({"contrast": 8.0}, "contrast = ")], +) +def test_a_step_index_guide_outside_the_facet_envelope_is_refused( + grid, pair_port, change, named +): + """A guide whose weighted V or contrast leaves the range is refused by name.""" + port = dataclasses.replace( + pair_port, + normalised_frequency=np.asarray(pair_port.normalised_frequency) + * change.get("frequency", 1.0), + contrast=np.asarray(pair_port.contrast) * change.get("contrast", 1.0), + ) + model = CouplingModel("vector_paraxial", model_error=0.9) + with pytest.raises( + CouplingError, match=VECTOR_STEP_INDEX_ENVELOPE.identity + ) as error: + couple(step_index_plane(grid, (1.0,)), port, model=model) + assert named in str(error.value) + assert "not qualified for this step-index guide" in str(error.value) + + +def test_a_guide_without_a_recorded_envelope_keeps_an_uncounted_item(grid, pair_port): + """A general guide that is not step-index (a graded index) carries no V; its + envelope item has no value and says why, as before the envelope was recorded.""" + port = dataclasses.replace(pair_port, normalised_frequency=None) + report = couple(step_index_plane(grid, (1.0,)), port, model=VECTOR).report + item = envelope_item(report) + assert item.value is None + assert not item.counted + assert "only a step-index guide has one" in item.note + + +def test_a_beam_in_another_medium_than_the_core_is_refused(grid, pair_port): + """The facet envelope covers a beam in the core's index only. + + A beam in air meeting a 1.45 core reflects 3 per cent at the bulk step alone + (the facet reference puts the model 6 per cent high there), which is a separate + planar interface (SEP 31, section 4.3). The model measures the beam in the + core's admittance, so the source metric item, the beam's own measure against + that, refuses it. + """ + plane = FieldPlane.from_pulse( + beam(6e-6), + grid, + PlaneGeometry(0.0), + space=RadialGrid(60e-6, 128), + index=np.where(np.asarray(pair_port.reference_index) > 0, 1.0, 0.0), + ) + with pytest.raises(CouplingError, match="source metric"): + couple(plane, pair_port, model=VECTOR) diff --git a/tests/facet_mode_matching.py b/tests/facet_mode_matching.py new file mode 100644 index 000000000..bd13a3216 --- /dev/null +++ b/tests/facet_mode_matching.py @@ -0,0 +1,408 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +r"""An independent full-vector mode-matching solution of a step-index fibre facet. + +The oracle of the vector coupling model's recorded envelope +(:data:`solprop.coupling.envelopes.VECTOR_STEP_INDEX_ENVELOPE`). A beam in a +homogeneous medium of permittivity :math:`\varepsilon_{\rm in}` meets the planar +entrance of a step-index fibre (core :math:`\varepsilon_{\rm co}` inside radius a, +cladding :math:`\varepsilon_{\rm cl}` beyond) at z = 0. Tangential E and H are +continuous there, the input side carries the incident and a reflected (backward) +field, and the fibre side its forward guided and radiation fields. ``vector_paraxial`` +keeps only the incident field in the flux pairing; this module solves the whole +interface. + +**Formulation** (optics convention, :math:`e^{i(\beta z - \omega t)}`, SI). With the +transverse fields :math:`\mathbf u = \mathbf E_t` and +:math:`\mathbf v = -\hat z\times\mathbf H_t` (so a forward plane wave has +:math:`\mathbf v = Y\mathbf u`), Maxwell's equations in a z-invariant medium give +:math:`\beta\mathbf u = A\mathbf v` and :math:`\beta\mathbf v = P\mathbf u` with + +.. math:: + + A\mathbf v = \omega\mu_0\mathbf v + \frac{1}{\omega\varepsilon_0} + \nabla_t\bigl(\varepsilon^{-1}\nabla_t\cdot\mathbf v\bigr),\qquad + P\mathbf u = \omega\varepsilon_0\varepsilon\mathbf u - \frac{1}{\omega\mu_0} + \hat z\times\nabla_t(\hat z\cdot\nabla_t\times\mathbf u), + +both symmetric. Forward modes solve :math:`AP\mathbf u = \beta^2\mathbf u` with +:math:`\operatorname{Re}\beta > 0` or :math:`\operatorname{Im}\beta > 0`, and the +forward admittance operator is :math:`Y = P(AP)^{-1/2}`. Continuity at the facet, +:math:`\mathbf u_i + \mathbf u_r = \mathbf u_t` and +:math:`Y_0\mathbf u_i - Y_0\mathbf u_r = Y_f\mathbf u_t`, gives the transmitted field +:math:`\mathbf u_t = 2(Y_0 + Y_f)^{-1}Y_0\mathbf u_i` exactly. Its guided amplitude is +the flux pairing of :math:`(\mathbf u_t, Y_f\mathbf u_t)` with the unit-power mode; +the fibre's forward modes are flux-orthogonal to every other forward and backward +mode, so nothing else contributes. The difference from ``vector_paraxial``'s +amplitude with the same incident H is, by the algebra of the two expressions, + +.. math:: + + a_{\rm exact} - a_{\rm model} = -\tfrac14\langle\mathbf u_i, + D(Y_0 + Y_f)^{-1}D\,\mathbf e_m\rangle,\qquad D = Y_f - Y_0, + +second order in the index step, and :math:`D\mathbf e_m = \mathbf h_m - +Y_0\mathbf e_m` is the mode's own closure defect. + +**Discretisation.** The fibre and the input medium are enclosed in a perfectly +conducting cylinder of radius R much larger than a and the beam, and each azimuthal +order :math:`e^{in\phi}` is solved separately (the structure is rotationally +symmetric, so orders do not mix). The basis is the cylinder's own orthonormal TM +(:math:`\nabla_t J_n(k r)e^{in\phi}`, :math:`J_n(kR) = 0`) and TE +(:math:`\hat z\times\nabla_t J_n(kr)e^{in\phi}`, :math:`J_n'(kR) = 0`) transverse +fields, which carry the conductor's boundary condition for both u and v. In it +:math:`Y_0` is diagonal (:math:`\beta/(\omega\mu_0)` for TE, +:math:`\omega\varepsilon_0\varepsilon_{\rm in}/\beta` for TM) and A, P are Galerkin +matrices of the weak forms above, integrated by Gauss--Legendre panels broken at the +core radius. The fibre's modes are the eigenvectors of AP. Nothing here uses the +package's step-index solver: the guided eigenvalue converges to the exact vector +eigenvalue equation (Snyder and Love, *Optical Waveguide Theory*, 1983, eq. 12-11), +which :func:`hybrid_eigenvalue` solves directly, and the fields converge to +:meth:`solprop.modes.StepIndexMode.electromagnetic_field`. + +**Convergence.** The basis is smooth and the fields' radial component jumps at the +core, so the eigenvalues and shares converge as 1/N in the basis size N (the change +from N to 2N estimates the error); the facet correction, a difference of two +amplitudes formed from one discretisation, converges much faster. +""" + +from dataclasses import dataclass +from itertools import pairwise + +import numpy as np +from scipy.optimize import brentq +from scipy.special import jn_zeros, jnp_zeros, jv, jvp, kv, kvp + +from solprop.constants import EPS0, MU0, C + +__all__ = [ + "Beam", + "FacetShares", + "StepIndexFacet", + "hybrid_eigenvalue", + "ring_rule", +] + +Y_VACUUM = float(np.sqrt(EPS0 / MU0)) +"""The vacuum admittance, A/V.""" + + +def ring_rule( + breaks: tuple[float, ...], panel: float, n_theta: int = 96, nodes: int = 24 +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Return a polar rule: Gauss--Legendre panels in r, uniform in theta. + + The radial panels break at every entry of ``breaks`` (the first is the axis, + the last the outer radius) and are at most ``panel`` long; the weights are + plain dr weights, the Jacobian is applied by the caller. + """ + radii, weights = [], [] + for low, high in pairwise(breaks): + count = max(1, int(np.ceil((high - low) / panel))) + edges = np.linspace(low, high, count + 1) + x, w = np.polynomial.legendre.leggauss(nodes) + for left, right in pairwise(edges): + radii.append(0.5 * (right - left) * x + 0.5 * (right + left)) + weights.append(0.5 * (right - left) * w) + theta = 2 * np.pi * (np.arange(n_theta) + 0.5) / n_theta + return np.concatenate(radii), np.concatenate(weights), theta + + +class _DiscBasis: + """The orthonormal TM and TE transverse fields of a conducting disc, order n.""" + + def __init__(self, order: int, outer: float, count: int) -> None: + self.order = order + m = abs(order) + self.k_tm = jn_zeros(m, count) / outer + # J_0' = -J_1, whose zero at the origin is not a mode. + self.k_te = (jnp_zeros(m, count) if m > 0 else jn_zeros(1, count)) / outer + self.k = np.concatenate((self.k_tm, self.k_te)) + self.is_te = np.arange(2 * count) >= count + # Closed-form norms: the Dirichlet and Neumann Bessel integrals. + self._norm_tm = ( + self.k_tm * np.sqrt(np.pi) * outer * np.abs(jv(m + 1, self.k_tm * outer)) + ) + kr = self.k_te * outer + self._norm_te = ( + self.k_te + * np.sqrt(np.pi) + * outer + * np.sqrt(1 - m**2 / kr**2) + * np.abs(jv(m, kr)) + ) + + def fields(self, r: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Return e_r, e_phi and div e of every basis field at radii r (> 0).""" + n, m = self.order, abs(self.order) + r = np.asarray(r, dtype=float)[:, None] + x = self.k_tm[None, :] * r + tm = self._norm_tm[None, :] + er_tm = self.k_tm * jvp(m, x) / tm + ep_tm = 1j * n * jv(m, x) / r / tm + div_tm = -(self.k_tm**2) * jv(m, x) / tm + x = self.k_te[None, :] * r + te = self._norm_te[None, :] + er_te = -1j * n * jv(m, x) / r / te + ep_te = self.k_te * jvp(m, x) / te + return ( + np.concatenate((er_tm, er_te), axis=1), + np.concatenate((ep_tm, ep_te), axis=1), + np.concatenate((div_tm, np.zeros_like(er_te)), axis=1), + ) + + def grams(self, r: np.ndarray, w: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """Return the field and divergence Grams over a radial rule (r dr weights).""" + er, ep, div = self.fields(r) + weight = 2 * np.pi * r * w + field = (er.conj().T * weight) @ er + (ep.conj().T * weight) @ ep + return field, (div.conj().T * weight) @ div + + def project( + self, + radii: np.ndarray, + weights: np.ndarray, + theta: np.ndarray, + radial: np.ndarray, + azimuthal: np.ndarray, + ) -> np.ndarray: + """Return the coefficients of a field sampled on rings, shape (n_basis,).""" + phase = np.exp(-1j * self.order * theta) + radial_n = radial @ phase / theta.size + azimuthal_n = azimuthal @ phase / theta.size + er, ep, _ = self.fields(radii) + weight = 2 * np.pi * radii * weights + return (er.conj().T * weight) @ radial_n + (ep.conj().T * weight) @ azimuthal_n + + +@dataclass(frozen=True) +class FacetShares: + """One mode's captured share of an incident beam, three ways. + + Attributes + ---------- + exact : float + The mode-matching solution's guided power over the incident power. + model_exact_h : float + ``vector_paraxial``'s flux pairing with the incident beam's exact H. + model : float + ``vector_paraxial``'s flux pairing with the closure + :math:`H_t = Y_0\\hat z\\times E_t`, as the package computes it. + """ + + exact: float + model_exact_h: float + model: float + + @property + def facet(self) -> float: + """Return what the model omits at the interface: exact minus model.""" + return self.exact - self.model_exact_h + + +class StepIndexFacet: + """The facet of one azimuthal order: fibre modes, admittances, matching. + + Parameters + ---------- + order : int + Azimuthal order n of :math:`e^{in\\phi}`. + radius : float + Core radius a, m. + indices : tuple of float + Input medium, core and cladding indices. + omega : float + Angular frequency, rad/s. + outer : float + Conducting cylinder's radius R, m. + count : int + TM and TE basis fields each. + """ + + def __init__( + self, + order: int, + radius: float, + indices: tuple[float, float, float], + omega: float, + outer: float, + count: int, + ) -> None: + n_in, n_co, n_cl = indices + self.radius, self.indices, self.omega = radius, indices, omega + self.k0 = omega / C + basis = self.basis = _DiscBasis(order, outer, count) + size = 2 * count + # Panels short enough for the fastest basis oscillation, broken at a. + panel = np.pi / (1.5 * max(float(basis.k.max()), 1.0 / radius)) + core_r, core_w, _ = ring_rule((0.0, radius), panel, n_theta=1) + clad_r, clad_w, _ = ring_rule((radius, outer), panel, n_theta=1) + core_g, core_d = basis.grams(core_r, core_w) + clad_g, clad_d = basis.grams(clad_r, clad_w) + self.orthonormality = float(np.max(np.abs(core_g + clad_g - np.eye(size)))) + self.cladding_gram = clad_g + te = basis.is_te + p = omega * EPS0 * (n_co**2 * core_g + n_cl**2 * clad_g) - np.diag( + np.where(te, basis.k**2, 0.0) + ) / (omega * MU0) + a = omega * MU0 * np.eye(size) - (core_d / n_co**2 + clad_d / n_cl**2) / ( + omega * EPS0 + ) + beta_sq, vectors = np.linalg.eig(a @ p) + beta = np.sqrt(beta_sq.astype(complex)) + # The forward branch: propagating towards +z, or decaying there. A real + # beta^2 carries a rounding-level imaginary part from the eigensolver, whose + # sign must not pick the branch. + propagating = np.abs(np.imag(beta)) <= 1e-9 * np.abs(beta) + beta = np.where( + propagating, + np.abs(np.real(beta)) + 1j * np.abs(np.imag(beta)), + np.where(np.imag(beta) < 0, -beta, beta), + ) + self.beta, self.vectors, self.p = beta, vectors, p + self.y_fibre = p @ vectors @ np.diag(1 / beta) @ np.linalg.inv(vectors) + beta_in = np.sqrt((self.k0**2 * n_in**2 - basis.k**2).astype(complex)) + beta_in = np.where(np.imag(beta_in) < 0, -beta_in, beta_in) + self.y_input = np.diag( + np.where(te, beta_in / (omega * MU0), omega * EPS0 * n_in**2 / beta_in) + ) + + def guided(self) -> np.ndarray: + """Return the guided modes' indices, highest effective index first.""" + n_cl = self.indices[2] + neff = np.real(self.beta) / self.k0 + bound = (np.abs(np.imag(self.beta)) < 1e-9 * self.k0) & (neff > n_cl) + found = np.flatnonzero(bound) + return found[np.argsort(-neff[found])] + + def unit_mode(self, index: int) -> tuple[np.ndarray, np.ndarray]: + """Return a mode's u and v coefficients normalised to unit power.""" + u = self.vectors[:, index] + power = 0.5 * np.real(u.conj() @ self.p @ u / self.beta[index]) + u = u / np.sqrt(power) + return u, self.p @ u / self.beta[index] + + def amplitudes( + self, coefficients: np.ndarray, index: int + ) -> tuple[complex, complex, complex, tuple[float, float, float]]: + """Return the exact, model (exact H) and model (closure) amplitudes. + + Also the incident, reflected and transmitted powers, W, whose balance + checks the matching. + """ + u, v = self.unit_mode(index) + admittance = self.indices[0] * Y_VACUUM + transmitted = 2 * np.linalg.solve( + self.y_input + self.y_fibre, self.y_input @ coefficients + ) + exact = 0.25 * ( + v.conj() @ transmitted + u.conj() @ (self.y_fibre @ transmitted) + ) + model_exact_h = 0.25 * ( + v.conj() @ coefficients + u.conj() @ (self.y_input @ coefficients) + ) + model = 0.25 * ( + v.conj() @ coefficients + admittance * (u.conj() @ coefficients) + ) + reflected = transmitted - coefficients + powers = ( + 0.5 * float(np.real(coefficients.conj() @ self.y_input @ coefficients)), + 0.5 * float(np.real(reflected.conj() @ self.y_input @ reflected)), + 0.5 * float(np.real(transmitted.conj() @ self.y_fibre @ transmitted)), + ) + return complex(exact), complex(model_exact_h), complex(model), powers + + +@dataclass(frozen=True) +class Beam: + """An x-polarised Gaussian beam at the facet, in units of the core radius. + + Attributes + ---------- + waist : float + 1/e field radius over a. + shift : tuple of float + Displacement (x, y) over a. + tilt : float + Transverse wavenumber along x over :math:`k_0\\,\\mathrm{NA}`. + """ + + waist: float + shift: tuple[float, float] = (0.0, 0.0) + tilt: float = 0.0 + + def field( + self, x: np.ndarray, y: np.ndarray, radius: float, kappa_unit: float + ) -> np.ndarray: + """Return E_x (optics convention) at points, for a core radius and k0 NA.""" + dx, dy = self.shift + envelope = np.exp( + -((x - dx * radius) ** 2 + (y - dy * radius) ** 2) + / (self.waist * radius) ** 2 + ) + return envelope * np.exp(1j * self.tilt * kappa_unit * x) + + +def mode_shares( # noqa: PLR0913 - the fibre, the beam and the discretisation + beam: Beam, + radius: float, + indices: tuple[float, float, float], + omega: float, + orders: tuple[int, ...] = (1, -1), + which: int = 0, + *, + outer: float = 8.0, + count: int = 100, +) -> FacetShares: + """Return one guided family's shares of a beam, summed over the given orders. + + ``which`` picks the guided mode of each order by descending effective index + (0 is HE11 in orders 1 and -1); the ``n`` and ``-n`` orders together are the + two parities of one mode. ``outer`` is R/a. + """ + n_in, n_co, n_cl = indices + radii, weights, theta = ring_rule((0.0, radius, outer * radius), radius / 4) + r, t = radii[:, None], theta[None, :] + x, y = r * np.cos(t), r * np.sin(t) + ex = beam.field(x, y, radius, omega / C * np.sqrt(n_co**2 - n_cl**2)) + incident = ( + (0.5 * n_in * Y_VACUUM * np.sum((radii * weights)[:, None] * np.abs(ex) ** 2)) + * 2 + * np.pi + / theta.size + ) + totals = np.zeros(3) + for order in orders: + facet = StepIndexFacet(order, radius, indices, omega, outer * radius, count) + coefficients = facet.basis.project( + radii, weights, theta, ex * np.cos(t), -ex * np.sin(t) + ) + amplitudes = facet.amplitudes(coefficients, int(facet.guided()[which]))[:3] + totals += np.abs(np.asarray(amplitudes)) ** 2 / incident + return FacetShares(*(float(one) for one in totals)) + + +def hybrid_eigenvalue(order: int, v: float, n_co: float, n_cl: float) -> float: + """Return the HE_{n1} core parameter U of the exact vector eigenvalue equation. + + Snyder and Love, *Optical Waveguide Theory* (1983), eq. 12-11 with + :math:`W = \\sqrt{V^2 - U^2}`; the HE_{11} root is the smallest positive one, + below the first zero of J_0 for order 1. + """ + ratio = n_cl**2 / n_co**2 + + def residual(u: float) -> float: + w = np.sqrt(v**2 - u**2) + j = jvp(order, u) / (u * jv(order, u)) + k = kvp(order, w) / (w * kv(order, w)) + return float( + (j + k) * (j + ratio * k) + - order**2 * (1 / u**2 + 1 / w**2) * (1 / u**2 + ratio / w**2) + ) + + grid = np.linspace(1e-3, min(v, 2.404825557695773) - 1e-9, 4001) + values = np.array([residual(u) for u in grid]) + change = int(np.flatnonzero(np.sign(values[:-1]) != np.sign(values[1:]))[0]) + return float(brentq(residual, grid[change], grid[change + 1])) From 5b453004d59527bac095a64b1b44a280c5031ae5 Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 11:30:54 +0100 Subject: [PATCH 41/50] Docs: the vector model's step-index facet envelope; SEP 31 decision A1 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019qNYqtCKh4iTKj7544yARb --- CHANGELOG.md | 6 ++ docs/dev/seps/sep-0031-simulation-coupling.md | 99 +++++++++++++++++++ docs/guide/waveguides/general-modal.md | 8 +- docs/guide/workflows/coupling.md | 28 +++++- 4 files changed, 137 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1da9cd476..99bd1aa0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -104,6 +104,12 @@ The first public release. It contains: than against the scalar profile itself; bins outside an envelope's range, including a fibre's V across the band, are counted in full; the modal overlap quadrature enters the budget as `2 delta + delta^2`. + `VECTOR_STEP_INDEX_ENVELOPE` bounds `vector_paraxial` into a step-index guide + against an independent full-vector mode-matching solution of the entrance facet + (bound 1.4e-3 at contrast 0.05, V 1.5 to 8, scaled by the squared contrast); its + item is counted and a guide outside its range is refused by name. A step-index + port carries `InputPort.normalised_frequency`; a graded-index guide keeps an + uncounted item without a value. - **Media and responses.** - Gases, gas mixtures, glasses and metals from the [optmat](https://github.com/LupoLab/optmat) material library. diff --git a/docs/dev/seps/sep-0031-simulation-coupling.md b/docs/dev/seps/sep-0031-simulation-coupling.md index 773494b1f..b3f8c66e9 100644 --- a/docs/dev/seps/sep-0031-simulation-coupling.md +++ b/docs/dev/seps/sep-0031-simulation-coupling.md @@ -1939,6 +1939,7 @@ plain defects among them are fixed above or were superseded): `processing.mixtures` and `processing.planes` rebuild the plane measure there). 10. *(Phase 3 Q1)* A recorded provider envelope for `vector_paraxial`: recommended before Final, against an independent mode-matching calculation for `StepIndexProvider`. + *Resolved 2026-10-24* (maintainer decision A1 below): `VECTOR_STEP_INDEX_ENVELOPE`. 11. *(Phase 3 Q2, Q3)* The cone rule for small-core direct overlaps and a Gaussian → LP01 envelope: maintainer decision; the cone rule is conservative there. 12. *(Phase 3 Q4)* The general guide's target angular item at every occupied bin: yes if @@ -2108,6 +2109,104 @@ Its output was regenerated with `poe docs-examples plot_04_graded_index_multimod (angular 1.25e-2, contrast 9.5e-3, closure 4.6e-3, passivity 1.1e-3); entrance shares LP01 0.933, LP02 0.0625. The figures changed only in their last digits. +### Maintainer decisions — 2026-10-24 + +- A1 — *A recorded pair envelope for `vector_paraxial` into a `StepIndexProvider` + guide* (Phase 5 open question 10, Phase 3 open question 1; resolved). The maintainer + decided the vector model needs a recorded envelope before Final, against an + independent mode-matching calculation. Delivered on `sep31/vector-envelope`: + `VECTOR_STEP_INDEX_ENVELOPE` (`step-index-vector-facet-mode-matching-2026-10`) in + `coupling/envelopes.py`; `InputPort.normalised_frequency` (V of a step-index guide + at the entrance, per bin, filled by `translate.coupling.step_index_frequency`); + `_vector_envelope_item` in `coupler.py`; the oracle `tests/facet_mode_matching.py`. +- A1 — *Reference.* A full-vector mode-matching solution of the planar entrance + facet: incident and reflected fields in the input medium, the fibre's forward guided + and radiation fields, tangential E and H continuous. Written with + $\mathbf u = \mathbf E_t$, $\mathbf v = -\hat z\times\mathbf H_t$, Maxwell's + equations give $\beta\mathbf u = A\mathbf v$, $\beta\mathbf v = P\mathbf u$ + ($A = \omega\mu_0 + (\omega\varepsilon_0)^{-1}\nabla_t\varepsilon^{-1} + \nabla_t\cdot$, $P = \omega\varepsilon_0\varepsilon - (\omega\mu_0)^{-1} + \hat z\times\nabla_t\,\mathrm{curl}$), the forward admittance is + $Y = P(AP)^{-1/2}$, and continuity gives the transmitted field + $2(Y_0 + Y_f)^{-1}Y_0\mathbf u_i$ exactly; its guided amplitude is its flux pairing + with the unit-power mode. Each azimuthal order is solved separately in a perfectly + conducting cylinder of 8 (12 for the refinement) core radii, on the cylinder's own + orthonormal TE/TM Bessel fields, with Galerkin matrices on Gauss–Legendre panels + broken at the core. The model's amplitude differs from the exact one by + $-\tfrac14\langle\mathbf u_i, D(Y_0 + Y_f)^{-1}D\mathbf e_m\rangle$, + $D = Y_f - Y_0$: second order in the index step. +- A1 — *Independence and validation.* `StepIndexMode.electromagnetic_field` is **not** + independent of the provider: both evaluate the same analytic fields + (`modes/_stepindex_vector._fields` and `_power`), the provider at its nodes. The + reference therefore solves its own modes and uses nothing of the package's + step-index code. Checks: its HE11 effective index converges (as 1/N, the smooth + basis meeting the jump in $E_r$) to Snyder and Love's exact vector eigenvalue + equation solved directly, which the package's solver matches to 1e-12; its HE11 + radial profile matches `electromagnetic_field` to 1e-2 at N = 100; a uniform guide + of another index gives every basis field its modal Fresnel transmission to 1e-9 + (a known solution); incident power equals reflected plus transmitted to 1e-10; at + weak guidance the facet term equals the local Fresnel estimate + $-(n_{\rm cl}-n_{\rm co})^2Y_{\rm vac}/(8n_{\rm co})$ times the beam's cladding + overlap to 2–4 % (contrast 0.003–0.01; 10 % at 0.03), the size of the next order. +- A1 — *Measurements.* The facet term (the exact share minus the model's with the same + incident H) over x-polarised Gaussian beams of waist 0.5a–2a, displaced to 0.5a, + tilted to 0.3 NA, contrast 0.001–0.05 and V 1.5–8 for HE11; at V 3–8 for every + guided channel (HE12, EH11, HE21, HE31, HE41, TE01, TM01, ...), contrast 0.01 and + 0.05. It is + negative (the model over-launches) and proportional to the squared contrast to + within 7 % across the range: HE11 $0.26$–$0.28\Delta^2$ at V = 1.5 falling to $0.02\Delta^2$ + at V = 6–8; higher channels at most $0.19\Delta^2$ (HE12 at V = 4, near cutoff; + $0.05\Delta^2$ at V = 8). + Largest: $6.9\times10^{-4}$ at contrast 0.05, V = 1.5, a 2a waist. Refining the + basis (100 → 200 fields) and the cylinder (8a → 12a) changes it by $1.1\times10^{-5}$. + Against `couple` itself (Cartesian planes, one beam per bin, the guide tabulated at + 17 knots with a 48 × 48 node rule): at contrasts 0.003–0.01 the launched share + equals the reference's own evaluation of the model to $\le 1.6\times10^{-5}$ + (2e-5–5e-5 at V = 1.5 bins near the band edge, where the cylinder must be 12a and + the tables are coarsest), the incident beam's closure term (exact H against + $Y_0\hat z\times\mathbf E_t$, up to $2.6\times10^{-4}$) is within the angular item + (0.11), and the facet term within the envelope item. At contrast 0.05 the launch + matches to $3\times10^{-4}$ at V 1.5–2.6 (coarse planes whose numerical item already + counts $10^{-4}$) and to $10^{-4}$ at V 3.5–6, the size of the reference's own + refinement change there; the total difference from the exact share stays within + the envelope item plus the angular item everywhere. At contrast 0.05 the closure + item alone (0.045 at V = 5.4, 0.098 at V = 2.4) needs a large `model_error`. +- A1 — *Recorded.* Range contrast 0–0.05 (`contrast_squared` 0–2.5e-3), V 1.5–8, + any bound channel of the provider, a beam in the core's index; bound + $1.4\times10^{-3}$ (twice the largest, numerical included), numerical + $2\times10^{-5}$; the allowance scales with the incident-weighted squared contrast + over $0.05^2$ (`scaled_by="contrast_squared"`, the existing linear `allowance` + applied to the square): $5.6\times10^{-5}$ at contrast 0.01. Scaling is justified by + the measured $\Delta^2$ law and the identity above; a linear scaling would overstate + a 0.01 guide fivefold. The envelope bounds only the facet's reflection and backward + fields; the incident H closure stays the angular item's and the guide's tabulation + the numerical items'. +- A1 — *Admission now.* `vector_paraxial` into a step-index guide counts the envelope + item (allowance plus the incident share of bins whose V or contrast leave the range); + weighted V or contrast outside the range is refused by name ("not qualified for this + step-index guide: v = … lies outside the envelope …"). The contrast screen still + applies; the envelope does not replace it (the closure item limits the same guides). + A beam in another medium than the core (air into silica: the reference puts the model + 6 % high, a 3 % bulk Fresnel loss) is refused through the source-metric item, as + §4.3 requires a separate planar interface. The strand of the incident tests + (V = 5.1, contrast 0.0069) and its 5 µm sibling (V = 6.4) are inside the range. +- A1 — *Graded index.* Not covered: a `GradedIndexProvider` port carries no V and keeps + the uncounted `envelope` item without a value, its note saying only a step-index + guide has one; its facet omission is bounded only by the contrast screen and the + closure, as before. The graded-index worked example and `benchmarks/guides.py` are + unchanged. Refusing graded guides was rejected: the local-Fresnel identity says the + omission is second order in the local step and the screen caps that step, so + refusing would remove a working, screened path for no measured defect; a + graded-index reference (the same solver with a layered core) is the follow-up. +- A1 — *Limitations.* x-polarised Gaussian beams only (no supplied incident H, no + measured beams); constant indices (the envelope depends on V and contrast, which + the port evaluates per bin from the regions' laws); a reference whose fields + converge only as 1/N, so its absolute shares carry ~1e-5 and only the facet + difference is resolved finely; the test sweep samples corners, the full sweep was + run once and is recorded here. The `couple` comparison at V = 1.5 bins shows a + ~2e-5 implementation difference attributed to the guide's tabulation, not to the + facet. + ## Part III — History - 2026-10-22: Drafted after auditing existing launch, projection, spatial flux, diff --git a/docs/guide/waveguides/general-modal.md b/docs/guide/waveguides/general-modal.md index 68d85be6f..c4431ff88 100644 --- a/docs/guide/waveguides/general-modal.md +++ b/docs/guide/waveguides/general-modal.md @@ -151,7 +151,13 @@ small: the coupling's admission budget (`model_error`) bounds the closure's erro against the provider's own $\mathbf H$, the weak-guidance contrast of the guide's regions, the angular content of the beam and of the channels, and the metric defect of $F$ against the run's declared metric. A strongly guiding core, such as a silica strand -in air, fails the contrast screen and is refused by name. It is *not* $Q$, the record's +in air, fails the contrast screen and is refused by name. The pairing also omits the +light the entrance facet reflects; for a step-index guide that omission is bounded by a +recorded envelope against an independent mode-matching solution of the facet, counted +in the budget and refused outside its range (contrast to 0.05, V from 1.5 to 8; see +[the coupling chapter](../workflows/coupling.md#the-admission-budget-and-the-report)). +A graded-index guide has no recorded envelope; its report says so and leaves the item +uncounted. It is *not* $Q$, the record's other map ([modal projection](modal-projection.md#the-synthesis-and-projection-maps)), which contracts a *polarisation* for a **source** term. For a field you sample yourself, {py:func}`~solprop.translate.modal_tables.project_transverse_field` applies the diff --git a/docs/guide/workflows/coupling.md b/docs/guide/workflows/coupling.md index c20834e36..60ee7d33f 100644 --- a/docs/guide/workflows/coupling.md +++ b/docs/guide/workflows/coupling.md @@ -250,7 +250,7 @@ for the vector model), every item with its scope -- this field, the whole map, o model -- the source plane's exterior, and the source's SEP 34 path certificate, separately and with its scope. A zero field is a valid zero launch whose `efficiency` is `None`. The recorded envelopes ({py:mod}`solprop.coupling.envelopes`) bound what the -scalar model omits, measured by the test suite against independent physics: +approximate models omit, measured by the test suite against independent physics: - *Capillary.* The scalar profile $J_0(u_mr/a)$ against Marcatili's leaky core field $J_0(u_m(1 - i\delta)r/a)$, $\delta = |\nu_n|c/(\omega a)$ with $\nu_n$ the wall @@ -266,8 +266,29 @@ scalar model omits, measured by the test suite against independent physics: radius ratios 0.7--1.43. V is checked at the profile wavelength and at every occupied bin, since it grows with frequency. +- *Vector model into a step-index guide.* The flux pairing with the incident field + against an independent full-vector mode-matching solution of the planar entrance + facet: the incident and reflected fields in the input medium, the fibre's forward + guided and radiation fields, tangential $\mathbf E$ and $\mathbf H$ continuous, + solved per azimuthal order in a large conducting cylinder on its own TE/TM basis. + The exact amplitude differs from the model's by + $-\tfrac14\langle\mathbf u_i, D(Y_0 + Y_f)^{-1}D\,\mathbf e_m\rangle$ with + $D = Y_f - Y_0$ the difference of the fibre's and the medium's admittance operators, + so the omission is second order in the index step; at weak guidance it is the local + Fresnel estimate $-(n_{\rm cl} - n_{\rm co})^2/(8n_{\rm co})$ times the beam's + cladding overlap with the mode, which the solution matches to a few per cent. Over + Gaussian beams of waist $a$ to $2a$, displaced to $0.5a$ and tilted to 0.3 NA, into + $HE_{11}$ at V from 1.5 to 8 and the higher channels at V = 4, the largest omitted + share at contrast 0.05 is $6.9\times10^{-4}$ ($HE_{11}$ at V = 1.5); the bound is + $1.4\times10^{-3}$, scaled by the squared weighted contrast over $0.05^2$: about + $5.6\times10^{-5}$ at contrast 0.01. The beam is in the core's index: a beam in air + meets a bulk step first (a separate planar interface), and the model refuses it + through its source metric. The incident beam's own $\mathbf H$ closure is the + angular item's, not the envelope's. A graded-index guide has no recorded envelope; + its item has no value and is not counted. + A pair outside its envelope's range is refused, not extrapolated; bins of a covered pair -whose $\delta$ or V leave the range carry an unbounded discrepancy, so their share of +whose $\delta$, V or contrast leave the range carry an unbounded discrepancy, so their share of the incident energy is added to the item in full. ### A Gaussian beam into a capillary @@ -429,7 +450,8 @@ coupling=CouplingModel("vector_paraxial", model_error=...))`, which couples the through `couple` and records the report as `configuration["launch"]` ([general modal propagation](../waveguides/general-modal.md)). A strongly guiding core fails the contrast screen and is refused; the closure is qualified against the -provider's own H. +provider's own H, and for a step-index guide the facet reflection the model omits is +counted through its recorded envelope (above). ## Transporting a plane between planes From 7f596c67a859fe1c0c0ac1eb38efc9c81084e3ac Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 12:13:04 +0100 Subject: [PATCH 42/50] Coupling: bound the vector facet envelope over every field in the cone The vector step-index facet envelope sampled x-polarised Gaussians in an 8a cylinder and missed guided modes just above cutoff and wide beams, so its largest recorded term (6.9e-4) was understated about twofold. It is now measured as the beam envelope is: the largest eigenvalue modulus, per azimuthal order, of the summed exact-minus-model capture forms inside the widest admitted cone, in a 30a cylinder with each order's guided count held to the exact cutoffs. Largest resolved 1.371e-3 at V 3.905; modes near cutoff approach the normal-incidence Fresnel ceiling 1.39e-3, and the bound is twice that, 3.0e-3 at contrast 0.05. Also corrects the beam envelope's facet aside (0.28 Delta^2), the scope of its V 1.5 checks, and the fibre-pair order claim. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019qNYqtCKh4iTKj7544yARb --- CHANGELOG.md | 8 +- docs/dev/seps/sep-0031-simulation-coupling.md | 112 +++++++++---- docs/guide/workflows/coupling.md | 27 ++- src/solprop/coupling/envelopes.py | 51 +++--- tests/coupling/test_envelopes.py | 158 ++++++++++++------ tests/facet_mode_matching.py | 42 ++++- 6 files changed, 274 insertions(+), 124 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 727de7f1f..ed2818327 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -115,9 +115,11 @@ The first public release. It contains: and reports both. the budget as `2 delta + delta^2`. `VECTOR_STEP_INDEX_ENVELOPE` bounds `vector_paraxial` into a step-index guide - against an independent full-vector mode-matching solution of the entrance facet - (bound 1.4e-3 at contrast 0.05, V 1.5 to 8, scaled by the squared contrast); its - item is counted and a guide outside its range is refused by name. A step-index + against an independent full-vector mode-matching solution of the entrance facet, + maximised over every incident field in the admitted cone (bound 3.0e-3 at contrast + 0.05, twice the Fresnel ceiling a mode near cutoff approaches, V 1.5 to 8, scaled by + the squared contrast); its item is counted and a guide outside its range is refused + by name. A step-index port carries `InputPort.normalised_frequency`; a graded-index guide keeps an uncounted item without a value. - **Media and responses.** diff --git a/docs/dev/seps/sep-0031-simulation-coupling.md b/docs/dev/seps/sep-0031-simulation-coupling.md index 88ea53d43..5b815eedf 100644 --- a/docs/dev/seps/sep-0031-simulation-coupling.md +++ b/docs/dev/seps/sep-0031-simulation-coupling.md @@ -2166,10 +2166,14 @@ LP01 0.933, LP02 0.0625. The figures changed only in their last digits. energy discrepancy over incident energy for **every** field in the cone, whatever its shape, alignment and polarisation; Gaussian beams sample it. *Measured* (core 1.45, 1064 nm): $0.467\Delta$ at V 1.5, $0.413\Delta$ at 1.6, $0.356\Delta$ at 1.75, - $0.288\Delta$ at 2.2, $0.284\Delta$ at 2.4, for $\Delta$ from $5\times10^{-4}$ to - $10^{-2}$ (the ratio moves by 0.3 % over that range), unchanged to $10^{-14}$ for core + $0.288\Delta$ at 2.2, $0.284\Delta$ at 2.4. At V 1.5 the ratio moves by 0.3 % for + $\Delta$ from $5\times10^{-4}$ to $10^{-2}$ and is unchanged to $10^{-14}$ for core indices 1.33–2.0 and wavelengths 0.8–1.55 µm (the overlaps depend on V and $\Delta$ - alone); largest $4.67\times10^{-3}$ at $\Delta = 0.01$, V 1.5. It is dominated by the + alone); those two checks were run at V 1.5 only. Largest $4.67\times10^{-3}$ at + $\Delta = 0.01$, V 1.5. Towards V 2.4 the in-cone figure is conservative: the cone's + edge truncates the widening HE11 spectrum, and most of the gap between $0.284\Delta$ + and the $0.205\Delta$ found in review by a real-space evaluation without a cone is + that truncation, not the vector correction. It is dominated by the first-order departure of the HE11 transverse field from LP01 (a field orthogonal to LP01 but overlapping that departure is the worst case), not by the plane-wave H: the impedance terms enter at second order. Gaussian beams — waists 0.5a–2.6a, displaced to @@ -2178,8 +2182,9 @@ LP01 0.933, LP02 0.0625. The figures changed only in their last digits. projects onto that departure), and every one lies below the field bound. Smaller cones only lower it ($4.07\times10^{-3}$ at $\epsilon_m = 0.05$). *Numerical:* doubling every rule changes the maximum by $2\times10^{-13}$; recorded $10^{-12}$. - *Bound and scaling:* the discrepancy is first order in $\Delta$ (unlike the - fibre-pair envelope's second-order $3\times10^{-4}$), so the bound scales with the + *Bound and scaling:* the discrepancy is first order in $\Delta$ (the fibre-pair + envelope's $3\times10^{-4}$ is a constant bound whose order in $\Delta$ was not + measured), so the bound scales with the contrast as the capillary one does with $\delta$ (`scaled_by="contrast"`): $10^{-2}$ at contrast 0.01, twice the largest per unit contrast ($0.934\Delta$) rounded up, so the allowance is $\Delta$ itself, about $5\times10^{-3}$ for a 0.005 step; a @@ -2189,8 +2194,9 @@ LP01 0.933, LP02 0.0625. The figures changed only in their last digits. fibre pair, and bins outside add their incident share to the item; a contrast or V outside is refused by name. The envelope bounds the transverse-field physics only: the facet reflection off the core–cladding step, which the forward pairing omits, is - second order, $((n_{\rm core}-n_{\rm clad})/2n)^2 \le (\Delta/2)^2 = 2.5\times10^{-5}$ - of the energy at the range's top, inside the bound's margin; the obliquity of the + second order: over every field in the widest cone the A1 mode-matching oracle gives + at most $0.28\Delta^2 = 2.8\times10^{-5}$ of the energy at the range's top (V 1.5, + falling to $0.14\Delta^2$ at V 2.4), inside the bound's margin; the obliquity of the beam's axial flux against $M_0$ is the angular item's allocation, and a beam in another medium is refused by the source-metric item. As for the fibre pair, the profile is the run's frozen one, so the bound is measured at the profile wavelength. @@ -2237,7 +2243,8 @@ LP01 0.933, LP02 0.0625. The figures changed only in their last digits. $Y = P(AP)^{-1/2}$, and continuity gives the transmitted field $2(Y_0 + Y_f)^{-1}Y_0\mathbf u_i$ exactly; its guided amplitude is its flux pairing with the unit-power mode. Each azimuthal order is solved separately in a perfectly - conducting cylinder of 8 (12 for the refinement) core radii, on the cylinder's own + conducting cylinder of 30 core radii with 300 TE and 300 TM fields (45a and 60a, at + the same fields per radius, for the refinement), on the cylinder's own orthonormal TE/TM Bessel fields, with Galerkin matrices on Gauss–Legendre panels broken at the core. The model's amplitude differs from the exact one by $-\tfrac14\langle\mathbf u_i, D(Y_0 + Y_f)^{-1}D\mathbf e_m\rangle$, @@ -2255,17 +2262,44 @@ LP01 0.933, LP02 0.0625. The figures changed only in their last digits. weak guidance the facet term equals the local Fresnel estimate $-(n_{\rm cl}-n_{\rm co})^2Y_{\rm vac}/(8n_{\rm co})$ times the beam's cladding overlap to 2–4 % (contrast 0.003–0.01; 10 % at 0.03), the size of the next order. -- A1 — *Measurements.* The facet term (the exact share minus the model's with the same - incident H) over x-polarised Gaussian beams of waist 0.5a–2a, displaced to 0.5a, - tilted to 0.3 NA, contrast 0.001–0.05 and V 1.5–8 for HE11; at V 3–8 for every - guided channel (HE12, EH11, HE21, HE31, HE41, TE01, TM01, ...), contrast 0.01 and - 0.05. It is - negative (the model over-launches) and proportional to the squared contrast to - within 7 % across the range: HE11 $0.26$–$0.28\Delta^2$ at V = 1.5 falling to $0.02\Delta^2$ - at V = 6–8; higher channels at most $0.19\Delta^2$ (HE12 at V = 4, near cutoff; - $0.05\Delta^2$ at V = 8). - Largest: $6.9\times10^{-4}$ at contrast 0.05, V = 1.5, a 2a waist. Refining the - basis (100 → 200 fields) and the cylinder (8a → 12a) changes it by $1.1\times10^{-5}$. +- A1 — *Measurements.* *Corrected after review:* the first record sampled + x-polarised Gaussian beams of waist 1a–2a (with an 8a cylinder) and recorded + $6.9\times10^{-4}$; it missed guided modes just above cutoff and beams much wider + than the core, and understated the term by about a factor of two. It is now measured + as B1 is. The facet term (the exact share minus the model's with the same incident + H) of each guided mode is the difference of two rank-one quadratic forms of the + incident field; summed over an azimuthal order's guided modes, in $M_0$, restricted + to the cylinder fields whose transverse wavenumber lies inside the widest admitted + cone ($1-\cos\theta = 1/8$, $\sin\theta = 0.484$), its largest eigenvalue modulus + bounds the summed facet share of **every** incident field in the cone, whatever its + shape and polarisation; orders do not mix, so the largest over orders bounds every + field. Swept at contrast 0.05 over 63 V points in 1.5–8: a coarse grid and points + 0.01, 0.03, 0.1 and 0.3 above each of the 14 distinct exact cutoffs, with each + order's guided count held to the package's exact vector cutoffs. It rises towards + every cutoff, where a newborn mode's power moves into the cladding: largest + $1.371\times10^{-3} = 0.548\Delta^2$ at V 3.905 (order one: HE11, EH11 and HE12, + 0.07 above HE12's cutoff), $1.367\times10^{-3}$ at V 7.06 (above HE13's), + $1.24\times10^{-3}$ 0.003 above TE01/TM01's, $1.17\times10^{-3}$ above HE21's, and + $7.4\times10^{-4}$ at V 1.5 (HE11 alone), falling to $5\times10^{-4}$ between + cutoffs at V 5–8. A mode carried wholly by the cladding meets a planar step from the + core index, where the model's pairing gives $1/(1-R)$ and the exact transmission + $1-R$; the omitted share then approaches the normal-incidence Fresnel ceiling + $R(2-R)/(1-R) = 1.388\times10^{-3}$ ($\approx\Delta^2/2$; R the core–cladding + reflectance), and no resolved case exceeds it (the largest is 0.99 of it). It is + second order in the index step: at V 3.905, $0.50\Delta^2$, $0.50\Delta^2$, + $0.52\Delta^2$ and $0.55\Delta^2$ at contrasts 0.0025, 0.01, 0.025 and 0.05. + Wide beams sample it: a centred x-polarised Gaussian of waist 10a at V 3.92, + summed over the three guided modes of orders ±1, loses $1.11\times10^{-3}$ + (waists 8a and 12a at V 3.905 and 3.93 give $0.79$–$1.22\times10^{-3}$). + *Convergence:* widening the cylinder from 30a to 45a and 60a at fixed fields per + radius changes the resolved values by at most $1.2\times10^{-5}$ (0.003 above + TE01/TM01's cutoff; $5\times10^{-6}$ at V 3.905). Within 0.04–0.07 above the HE12 + and HE13 cutoffs no cylinder up to 60a holds the newborn mode (its field spreads as + $K_0(Wr/a)$ with W vanishing), and the oracle finds one guided mode too few there; + those points (V 3.84–3.90, 7.03–7.05) are excluded from the measured maximum and + are bounded by the ceiling instead. 0.0013 below HE32's cutoff (V 7.0456) the + reference finds a spurious bound order-3 mode ($b = 1.8\times10^{-4}$) that + disappears when its fields per radius are doubled: its eigenvalues' 1/N error. Against `couple` itself (Cartesian planes, one beam per bin, the guide tabulated at 17 knots with a 48 × 48 node rule): at contrasts 0.003–0.01 the launched share equals the reference's own evaluation of the model to $\le 1.6\times10^{-5}$ @@ -2280,14 +2314,20 @@ LP01 0.933, LP02 0.0625. The figures changed only in their last digits. item alone (0.045 at V = 5.4, 0.098 at V = 2.4) needs a large `model_error`. - A1 — *Recorded.* Range contrast 0–0.05 (`contrast_squared` 0–2.5e-3), V 1.5–8, any bound channel of the provider, a beam in the core's index; bound - $1.4\times10^{-3}$ (twice the largest, numerical included), numerical - $2\times10^{-5}$; the allowance scales with the incident-weighted squared contrast - over $0.05^2$ (`scaled_by="contrast_squared"`, the existing linear `allowance` - applied to the square): $5.6\times10^{-5}$ at contrast 0.01. Scaling is justified by - the measured $\Delta^2$ law and the identity above; a linear scaling would overstate - a 0.01 guide fivefold. The envelope bounds only the facet's reflection and backward - fields; the incident H closure stays the angular item's and the guide's tabulation - the numerical items'. + $3.0\times10^{-3}$, twice the Fresnel ceiling ($2.78\times10^{-3}$) plus the + numerical error, rounded up, and so 2.2 times the largest resolved value; numerical + $2\times10^{-5}$. Taking the ceiling rather than the measured maximum covers the + unresolved bands just above the HE1m cutoffs, where the value keeps rising towards + it, without refusing them. The allowance scales with the incident-weighted squared + contrast over $0.05^2$ (`scaled_by="contrast_squared"`, the existing linear + `allowance` applied to the square), $1.2\Delta^2$: $1.2\times10^{-4}$ at contrast + 0.01. Scaling is justified by the measured $\Delta^2$ law, whose coefficient is + largest at the range's top, and the identity above; a linear scaling would overstate + a 0.01 guide fivefold. The bound is on the energy launched into all guided modes + together, not on each channel's share (a channel's own discrepancy is not separately + bounded). The envelope bounds only the facet's reflection and backward fields; the + incident H closure stays the angular item's and the guide's tabulation the numerical + items'. Nothing that was admitted is now refused: the range is unchanged. - A1 — *Admission now.* `vector_paraxial` into a step-index guide counts the envelope item (allowance plus the incident share of bins whose V or contrast leave the range); weighted V or contrast outside the range is refused by name ("not qualified for this @@ -2305,14 +2345,18 @@ LP01 0.933, LP02 0.0625. The figures changed only in their last digits. omission is second order in the local step and the screen caps that step, so refusing would remove a working, screened path for no measured defect; a graded-index reference (the same solver with a layered core) is the follow-up. -- A1 — *Limitations.* x-polarised Gaussian beams only (no supplied incident H, no - measured beams); constant indices (the envelope depends on V and contrast, which - the port evaluates per bin from the regions' laws); a reference whose fields +- A1 — *Limitations.* The field bound covers any incident shape and polarisation + inside the widest cone (no supplied incident H or measured beam is needed); what lies + outside the cone is the angular item's. Constant indices (the envelope depends on V + and contrast, which the port evaluates per bin from the regions' laws). The bound + rests on the Fresnel ceiling in the bands just above the HE12 and HE13 cutoffs that + no practical cylinder resolves: the resolved values approach the ceiling from below + there, but that it is never exceeded is argued, not proved. The reference's fields converge only as 1/N, so its absolute shares carry ~1e-5 and only the facet - difference is resolved finely; the test sweep samples corners, the full sweep was - run once and is recorded here. The `couple` comparison at V = 1.5 bins shows a - ~2e-5 implementation difference attributed to the guide's tabulation, not to the - facet. + difference is resolved finely; the test recomputes the worst point, V 1.5, a + refinement, a half contrast and one wide beam, and the full sweep was run once and + is recorded here. The `couple` comparison at V = 1.5 bins shows a ~2e-5 + implementation difference attributed to the guide's tabulation, not to the facet. ## Part III — History diff --git a/docs/guide/workflows/coupling.md b/docs/guide/workflows/coupling.md index aebd3dcd3..96cfaae4e 100644 --- a/docs/guide/workflows/coupling.md +++ b/docs/guide/workflows/coupling.md @@ -277,7 +277,9 @@ approximate models omit, measured by the test suite against independent physics: index. The bound is not sampled over beams but maximised over every incident field inside the widest admitted cone (both captures are rank-two quadratic forms, so the largest eigenvalue of their difference bounds them all): $0.467\Delta$ at V 1.5, - falling to $0.284\Delta$ at V 2.4, independent of the core index and the wavelength. + falling to $0.284\Delta$ at V 2.4 (conservative there, where the cone's edge truncates + the widening $HE_{11}$ spectrum); at V 1.5 it is linear in $\Delta$ and independent of + the core index and the wavelength. Gaussian beams of waist 0.5a--2.6a, displaced to 0.5a and tilted to half the acceptance angle reach $3.3\times10^{-3}$ at $\Delta = 0.01$ and V 1.5, a centred beam of waist 0.8a. The bound is $10^{-2}$ at contrast 0.01, scaled by the fibre's @@ -295,14 +297,21 @@ approximate models omit, measured by the test suite against independent physics: $D = Y_f - Y_0$ the difference of the fibre's and the medium's admittance operators, so the omission is second order in the index step; at weak guidance it is the local Fresnel estimate $-(n_{\rm cl} - n_{\rm co})^2/(8n_{\rm co})$ times the beam's - cladding overlap with the mode, which the solution matches to a few per cent. Over - Gaussian beams of waist $a$ to $2a$, displaced to $0.5a$ and tilted to 0.3 NA, into - $HE_{11}$ at V from 1.5 to 8 and the higher channels at V = 4, the largest omitted - share at contrast 0.05 is $6.9\times10^{-4}$ ($HE_{11}$ at V = 1.5); the bound is - $1.4\times10^{-3}$, scaled by the squared weighted contrast over $0.05^2$: about - $5.6\times10^{-5}$ at contrast 0.01. The beam is in the core's index: a beam in air - meets a bulk step first (a separate planar interface), and the model refuses it - through its source metric. The incident beam's own $\mathbf H$ closure is the + cladding overlap with the mode, which the solution matches to a few per cent. Summed + over an azimuthal order's guided modes the omission is a quadratic form of the + incident field, so, as for the beam into a fibre, the bound is maximised over every + field inside the widest admitted cone, of any shape and polarisation. At contrast + 0.05 it rises towards every cutoff, where a newborn mode's power moves into the + cladding: the largest resolved value is $1.37\times10^{-3}$ (V = 3.905, just above + the $HE_{12}$ cutoff), against $7.4\times10^{-4}$ at V = 1.5. A mode wholly in the + cladding approaches the normal-incidence Fresnel ceiling $R(2-R)/(1-R) \approx + \Delta^2/2 = 1.39\times10^{-3}$, R the core--cladding reflectance; the bound is + twice that, $3.0\times10^{-3}$, which also covers the narrow bands just above the + $HE_{1m}$ cutoffs that the reference cannot resolve. It bounds the energy launched + into all guided modes together and is scaled by the squared weighted contrast over + $0.05^2$: about $1.2\times10^{-4}$ at contrast 0.01. The beam is in the core's + index: a beam in air meets a bulk step first (a separate planar interface), and the + model refuses it through its source metric. The incident beam's own $\mathbf H$ closure is the angular item's, not the envelope's. A graded-index guide has no recorded envelope; its item has no value and is not counted. diff --git a/src/solprop/coupling/envelopes.py b/src/solprop/coupling/envelopes.py index 039f24589..a8292dff0 100644 --- a/src/solprop/coupling/envelopes.py +++ b/src/solprop/coupling/envelopes.py @@ -30,8 +30,8 @@ - :data:`VECTOR_STEP_INDEX_ENVELOPE`: the vector model's flux recovery into a :class:`~solprop.modes.guide.StepIndexProvider` guide against an independent full-vector mode-matching solution of the planar entrance facet, the reflection - and backward fields the model omits. Its bound scales with the square of the - guide's contrast. + and backward fields the model omits, maximised over every incident field inside the + widest admitted cone. Its bound scales with the square of the guide's contrast. """ from collections.abc import Mapping @@ -207,8 +207,10 @@ def allowance(self, parameters: Mapping[str, float]) -> float: "discrepancy of captured energy over incident energy, maximised over every " "field in that cone (the largest eigenvalue modulus of the difference of the " "two rank-two capture forms), is 0.467 Delta at V 1.5, falling to 0.284 " - "Delta at V 2.4, for Delta 5e-4 to 1e-2, independent of the core index and " - "the wavelength: largest 4.67e-3 at Delta 0.01 and V 1.5. Gaussian beams " + "Delta at V 2.4 (conservative there: the cone's edge truncates the widening " + "HE11 spectrum); at V 1.5 it is linear in Delta from 5e-4 to 1e-2 and " + "independent of the core index and the wavelength: largest 4.67e-3 at " + "Delta 0.01 and V 1.5. Gaussian beams " "(waists 0.5a to 2.6a, displaced to 0.5a and tilted to half the acceptance " "angle along x and y, x-polarised) reach 3.3e-3. The bound is twice the " "largest per unit contrast, rounded up, so the allowance is scaled by " @@ -231,22 +233,32 @@ def allowance(self, parameters: Mapping[str, float]) -> float: "v": (1.5, 8.0), "contrast_squared": (0.0, 2.5e-3), }, - bound=1.4e-3, + bound=3.0e-3, numerical=2e-5, reference=( "independent full-vector mode matching of the planar entrance facet " "(incident and reflected fields in the core-index input medium, the " "fibre's forward guided and radiation fields, tangential E and H " - "continuous), solved per azimuthal order in a conducting cylinder of 8 " - "core radii on its own TE/TM Bessel basis, against the model's flux " - "pairing with the same incident H: x-polarised Gaussian beams of waist " - "1a to 2a, displaced to 0.5a and tilted to 0.3 NA, into HE11 at V 1.5 to " - "8 and HE12, HE21, TE01 and TM01 at V 4; contrast 0.05 (the range's top), " - "input medium of the core's index. Largest omitted share 6.9e-4 (HE11, " - "V 1.5, a 2a waist); bound twice that; refining the basis and widening " - "the cylinder changes it by 1.1e-5. It is second order in the index step " - "(halving the contrast quarters it, and at weak guidance it is the local " - "Fresnel estimate to a few per cent), so the allowance is scaled by the " + "continuous), solved per azimuthal order in a conducting cylinder of 30 " + "core radii on its own 300 TE and 300 TM Bessel fields, against the " + "model's flux pairing with the same incident H. The omitted share summed " + "over an order's guided modes is a Hermitian form of the incident field: " + "its largest eigenvalue modulus inside the widest admitted cone " + "(1 - cos theta = 1/8) bounds every field of any shape and polarisation, " + "and orders do not mix. Swept at contrast 0.05 (the range's top) over V " + "1.5 to 8 with points 0.01 to 0.3 above every exact cutoff, each order's " + "guided count held to the cutoffs: largest 1.371e-3 at V 3.905 (order " + "one, HE12 just above its cutoff), 1.24e-3 just above the TE01/TM01 " + "cutoff, 7.4e-4 at V 1.5. A guided mode wholly in the cladding approaches " + "the normal-incidence Fresnel ceiling R(2 - R)/(1 - R) = 1.39e-3 (about " + "Delta**2/2), which no resolved case exceeds; within 0.04 to 0.07 above " + "the HE12 and HE13 cutoffs no cylinder up to 60a holds the newborn mode, " + "so that band is bounded by the ceiling, not resolved. Bound twice the " + "ceiling, numerical " + "included; widening the cylinder to 45a and 60a changes the resolved " + "values by at most 1.2e-5. It is second order in the index step " + "(halving the contrast quarters it; 0.50 to 0.55 Delta**2 at the worst " + "point for contrasts 0.0025 to 0.05), so the allowance is scaled by the " "squared contrast over 0.05**2" ), scaled_by="contrast_squared", @@ -254,7 +266,8 @@ def allowance(self, parameters: Mapping[str, float]) -> float: """The vector model's step-index facet envelope; ``contrast`` is the guide's ``|n^2 - n_ref^2|/(2 n_ref^2)``, ``v`` its normalised frequency at the entrance and ``contrast_squared`` the square of the contrast, each weighted by the incident energy -over the occupied bins. It bounds the reflection and backward fields at the facet, -not the incident beam's H closure (the angular item) or the guide's tabulation; it -covers a beam in the core's index only, since a bulk index change is a separate -planar interface (SEP 31, section 4.3).""" +over the occupied bins. It bounds the energy launched into all guided modes together, +not each channel's share, for any incident field inside the admitted cone. It covers +the reflection and backward fields at the facet, not the incident beam's H closure +(the angular item) or the guide's tabulation, and a beam in the core's index only, +since a bulk index change is a separate planar interface (SEP 31, section 4.3).""" diff --git a/tests/coupling/test_envelopes.py b/tests/coupling/test_envelopes.py index 6bedb902e..2464910d5 100644 --- a/tests/coupling/test_envelopes.py +++ b/tests/coupling/test_envelopes.py @@ -21,7 +21,10 @@ - the beam-into-fibre envelope: an incident field's scalar LP01 overlap against its full-vector flux coupling into the exact HE11 pair, the field's H from its own plane-wave spectrum, maximised over every field inside the widest admitted cone - and sampled by Gaussian beams. + and sampled by Gaussian beams; +- the vector model's step-index facet envelope: the flux pairing with the incident + field against an independent full-vector mode-matching solution of the entrance + facet (``facet_mode_matching``), maximised over every field inside that cone. The numerical error of each reference is estimated by refining its quadrature. """ @@ -63,6 +66,7 @@ from solprop.grid import EnvelopeGrid, FieldGrid, FreeGrid, RadialGrid from solprop.modes import MarcatiliMode, StepIndexMode from solprop.modes.guide import ModalGuide +from solprop.modes.stepindex import _cutoff from solprop.spatial import SpatialMedium RADIUS = 75e-6 @@ -734,9 +738,10 @@ def test_the_beam_fibre_envelope_bounds_every_field_in_the_cone(): """The scalar LP01 capture against the vector HE11 pair, maximised over fields. The largest discrepancy over every field inside the widest admitted cone is - 0.467 Delta at V 1.5 and falls with V (0.284 Delta at 2.4); it is linear in - Delta from 5e-4 to 1e-2 and does not depend on the core index or the - wavelength (the overlaps depend on V and Delta alone). The recorded bound is + 0.467 Delta at V 1.5 and falls with V (0.284 Delta at 2.4, where the cone's + edge truncating the HE11 spectrum makes it conservative); at V 1.5 it is + linear in Delta from 5e-4 to 1e-2 (the overlaps depend on V and Delta + alone, so not on the core index or the wavelength). The recorded bound is at least twice it at every contrast, through the allowance's linear scaling; the reference's numerical error is estimated by refining every rule. """ @@ -1029,67 +1034,112 @@ def test_the_facet_correction_is_the_local_fresnel_estimate(contrast, v): assert rigorous == pytest.approx(estimate, rel=0.05) -FACET_CASES = [ - # (V, beam, orders, which): HE11 over the V range, then higher channels. - *( - (v, beam, (1, -1), 0) - for v in (1.5, 2.4, 4.0, 8.0) - for beam in (Beam(1.0), Beam(2.0), Beam(1.5, (0.5, 0.0), 0.3)) - ), - (4.0, Beam(2.0), (1, -1), 2), - (4.0, Beam(2.0, (0.0, 0.0), 0.3), (2, -2), 0), - (4.0, Beam(2.0, (0.0, 0.5)), (0,), 0), - (4.0, Beam(2.0, (0.0, 0.0), 0.3), (0,), 1), -] -"""The envelope's sampled cases: V, beam, azimuthal orders and guided index. +def guided_count(order, v, contrast): + """Return the guided modes of one azimuthal order from the exact cutoffs. -HE11 at both ends of the V range and between, centred, wide and displaced and -tilted; then at V = 4 the HE12 (third mode of order one, the largest higher-mode -discrepancy found), HE21 (order two), TE01 (a y-displaced x-polarised beam is the -one that excites it) and TM01.""" + The package's exact vector cutoffs (TE0m and TM0m for order 0, HE and EH + otherwise) at the facet fibre's squared index ratio 1 - 2 contrast: the + count the reference's own eigenproblem must find. + """ + ratio = 1.0 - 2.0 * contrast + kinds = ("TE", "TM") if order == 0 else ("HE", "EH") + return sum( + sum( + 1 + for _ in itertools.takewhile( + lambda m, kind=kind: _cutoff(kind, order, m, ratio) < v, + itertools.count(1), + ) + ) + for kind in kinds + ) -def worst_facet(contrast, **discretisation): - """Return the largest |facet| share over the cases at a contrast, and its case.""" - worst, where = 0.0, None - for v, beam, orders, which in FACET_CASES: - radius, indices = facet_fibre(contrast, v) - shares = mode_shares( - beam, radius, indices, FACET_OMEGA, orders, which, **discretisation - ) - if abs(shares.facet) > worst: - worst, where = abs(shares.facet), (v, beam, orders, which) - return worst, where +def facet_worst_field(contrast, v, order, outer=30.0, count=300): + """Return one order's largest omitted facet share over every field in the cone. + + A 30a cylinder and 300 TE and 300 TM fields resolve the guided modes of the + recorded sweep away from the narrow bands just above the LP0m-like cutoffs + (an 8a cylinder misses HE12 at V = 3.93); the reference's guided count is + held to the exact cutoffs' first. + """ + radius, indices = facet_fibre(contrast, v) + facet = StepIndexFacet(order, radius, indices, FACET_OMEGA, outer * radius, count) + assert facet.guided().size == guided_count(order, v, contrast) + return facet.worst_field(math.sin(WIDEST_CONE)) + + +def fresnel_ceiling(contrast): + """Return the omitted share of a field wholly in the cladding at normal incidence. + + A mode carried entirely by the cladding meets a planar step from the core + index: the model's pairing over-launches it by 1/(1 - R) and the exact + transmission gives 1 - R, R the normal-incidence Fresnel reflectance, so the + omitted share is R(2 - R)/(1 - R), about Delta^2/2. Modes approaching cutoff + approach it. + """ + ratio = math.sqrt(1.0 - 2.0 * contrast) + reflectance = ((1.0 - ratio) / (1.0 + ratio)) ** 2 + return reflectance * (2.0 - reflectance) / (1.0 - reflectance) + + +FACET_LARGEST = 1.371e-3 +"""The largest omitted facet share of the recorded sweep at contrast 0.05: every +field in the cone, order one (HE11, EH11 and HE12 guided) at V = 3.905.""" @pytest.mark.medium def test_the_vector_step_index_envelope_bounds_the_omitted_facet(): - """The vector model against the full facet solution, over the envelope's range. - - At the top contrast, 0.05, the largest omitted share is 6.9e-4 (HE11 at - V = 1.5, a wide beam: the most power in the cladding, where the index steps); - the bound is twice it. Halving the contrast quarters it: the discrepancy is - second order in the step, so the allowance scales with the squared contrast. - Refining the basis and widening the cylinder estimates the numerical error. + """The vector model against the full facet solution, over every field in the cone. + + The facet term is the exact guided share minus the model's with the same + incident H. Summed over one azimuthal order's guided modes it is a Hermitian + form of the incident field, so its largest eigenvalue modulus inside the + widest admitted cone bounds every incident field, of any shape and + polarisation (orders do not mix). At contrast 0.05 the recorded sweep over V + 1.5 to 8, with points just above every cutoff, is largest at V = 3.905 + (1.371e-3, HE12 just resolved above its cutoff) and is 7.4e-4 at V = 1.5 + (HE11 alone); a guided mode wholly in the cladding approaches the + normal-incidence Fresnel ceiling, 1.39e-3, which no resolved case exceeds. + The bound is twice that ceiling, numerical error included. Widening the + cylinder estimates the numerical error, and halving the contrast quarters the + term: the allowance scales with the squared contrast. """ envelope = VECTOR_STEP_INDEX_ENVELOPE top = envelope.ranges["contrast"][1] - worst, where = worst_facet(top) - assert where is not None - assert worst == pytest.approx(6.9e-4, rel=0.05) - v, beam, orders, which = where - radius, indices = facet_fibre(top, v) - fine = mode_shares( - beam, radius, indices, FACET_OMEGA, orders, which, outer=12.0, count=200 - ) - assert abs(abs(fine.facet) - worst) <= envelope.numerical - assert 2 * worst + envelope.numerical <= envelope.bound - radius, indices = facet_fibre(0.5 * top, v) - half = mode_shares(beam, radius, indices, FACET_OMEGA, orders, which) - assert abs(half.facet) <= 0.3 * worst - assert envelope.allowance({"contrast_squared": (0.5 * top) ** 2}) >= 2 * abs( - half.facet + ceiling = fresnel_ceiling(top) + worst = facet_worst_field(top, 3.905, 1) + assert worst == pytest.approx(FACET_LARGEST, rel=0.01) + assert worst <= ceiling + single = facet_worst_field(top, 1.5, 1) + assert single == pytest.approx(7.38e-4, rel=0.01) + wider = facet_worst_field(top, 3.905, 1, outer=45.0, count=450) + assert abs(wider - worst) <= envelope.numerical + assert wider <= ceiling + assert 2 * ceiling + envelope.numerical <= envelope.bound + half = facet_worst_field(0.5 * top, 3.905, 1) + assert half / worst == pytest.approx(0.25, rel=0.1) + assert envelope.allowance({"contrast_squared": (0.5 * top) ** 2}) >= 2 * half + + +@pytest.mark.medium +def test_a_wide_beam_near_cutoff_lies_within_the_facet_envelope(): + """A centred beam much wider than the core, just above HE12's cutoff. + + An x-polarised Gaussian of waist 10a at V = 3.92 and contrast 0.05, in a 30a + cylinder: summed over the three guided modes of orders 1 and -1, the model + over-launches by 1.11e-3 (most of the beam lies in the cladding, where + the index steps), below the largest over every field and half the bound. + """ + top = VECTOR_STEP_INDEX_ENVELOPE.ranges["contrast"][1] + radius, indices = facet_fibre(top, 3.92) + shares = mode_shares( + Beam(10.0), radius, indices, FACET_OMEGA, which=None, outer=30.0, count=300 ) + assert shares.facet < 0.0 + assert abs(shares.facet) == pytest.approx(1.11e-3, rel=0.02) + assert abs(shares.facet) <= FACET_LARGEST + assert 2 * abs(shares.facet) <= VECTOR_STEP_INDEX_ENVELOPE.bound def facet_port(contrast, v_centre): diff --git a/tests/facet_mode_matching.py b/tests/facet_mode_matching.py index bd13a3216..b37ffd7c4 100644 --- a/tests/facet_mode_matching.py +++ b/tests/facet_mode_matching.py @@ -314,6 +314,35 @@ def amplitudes( ) return complex(exact), complex(model_exact_h), complex(model), powers + def worst_field(self, sine: float) -> float: + r"""Return the largest |omitted facet share| over every field in a cone. + + Each guided mode's exact amplitude is a linear form of the incident + coefficients, :math:`\tfrac14(\mathbf v_m^\dagger + \mathbf u_m^\dagger + Y_f)T` with :math:`T = 2(Y_0 + Y_f)^{-1}Y_0`, and the model's with the + same incident H is :math:`\tfrac14(\mathbf v_m^\dagger + \mathbf + u_m^\dagger Y_0)`. Summed over the order's guided modes, the difference of + the two rank-one capture forms, over the incident measure M0 + (:math:`\tfrac12 n_{\rm in}Y_{\rm vac}|\mathbf c|^2` in the orthonormal + basis), restricted to the basis fields whose transverse wavenumber lies + inside the cone ``sine`` (of the polar angle in the input medium), is a + Hermitian form: its largest eigenvalue modulus bounds the summed facet + share of every incident field of this order in the cone, whatever its + shape or polarisation. Orders do not mix, so the largest over orders + bounds every field. + """ + transfer = 2 * np.linalg.solve(self.y_input + self.y_fibre, self.y_input) + live = self.basis.k < sine * self.k0 * self.indices[0] + measure = 0.5 * self.indices[0] * Y_VACUUM + form = np.zeros((int(np.sum(live)),) * 2, dtype=complex) + for index in self.guided(): + u, v = self.unit_mode(int(index)) + exact = (0.25 * (v.conj() + u.conj() @ self.y_fibre) @ transfer)[live] + model = (0.25 * (v.conj() + u.conj() @ self.y_input))[live] + form += np.outer(exact.conj(), exact) - np.outer(model.conj(), model) + values = np.linalg.eigvalsh(0.5 * (form + form.conj().T) / measure) + return float(np.max(np.abs(values))) if values.size else 0.0 + @dataclass(frozen=True) class Beam: @@ -351,7 +380,7 @@ def mode_shares( # noqa: PLR0913 - the fibre, the beam and the discretisation indices: tuple[float, float, float], omega: float, orders: tuple[int, ...] = (1, -1), - which: int = 0, + which: int | None = 0, *, outer: float = 8.0, count: int = 100, @@ -359,8 +388,9 @@ def mode_shares( # noqa: PLR0913 - the fibre, the beam and the discretisation """Return one guided family's shares of a beam, summed over the given orders. ``which`` picks the guided mode of each order by descending effective index - (0 is HE11 in orders 1 and -1); the ``n`` and ``-n`` orders together are the - two parities of one mode. ``outer`` is R/a. + (0 is HE11 in orders 1 and -1), or None for every guided mode of the orders; + the ``n`` and ``-n`` orders together are the two parities of one mode. + ``outer`` is R/a. """ n_in, n_co, n_cl = indices radii, weights, theta = ring_rule((0.0, radius, outer * radius), radius / 4) @@ -379,8 +409,10 @@ def mode_shares( # noqa: PLR0913 - the fibre, the beam and the discretisation coefficients = facet.basis.project( radii, weights, theta, ex * np.cos(t), -ex * np.sin(t) ) - amplitudes = facet.amplitudes(coefficients, int(facet.guided()[which]))[:3] - totals += np.abs(np.asarray(amplitudes)) ** 2 / incident + guided = facet.guided() + for index in guided if which is None else guided[which : which + 1]: + amplitudes = facet.amplitudes(coefficients, int(index))[:3] + totals += np.abs(np.asarray(amplitudes)) ** 2 / incident return FacetShares(*(float(one) for one in totals)) From 4b096fd61fb4dbead8f1d4a7caf1e1d7339428b8 Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 15:29:49 +0100 Subject: [PATCH 43/50] Coupling: form the general guide's cone phase matrix in blocks The target angular item built the whole cone by node phase matrix at once; a fine node table and a wide cone made it 6.3 GiB on the Windows runner. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019qNYqtCKh4iTKj7544yARb --- src/solprop/coupling/coupler.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/solprop/coupling/coupler.py b/src/solprop/coupling/coupler.py index 70a3366c6..5c89406f1 100644 --- a/src/solprop/coupling/coupler.py +++ b/src/solprop/coupling/coupler.py @@ -127,6 +127,12 @@ band edges, the peak bin and two between them bound it. """ +_PHASE_BLOCK = 1 << 22 +"""Entries of the cone by node phase matrix formed at once (64 MiB of complex). + +A fine node table and a wide cone make the whole matrix several GiB. +""" + _SCALE = 0.5 * EPS0 * C """``eps0 c / 2``: joules per weighted ``|E|^2`` sum of a field in V/m.""" @@ -2872,11 +2878,20 @@ def _target_angular_vector( kx = np.outer(kappa_r, np.cos(angles)).ravel() ky = np.outer(kappa_r, np.sin(angles)).ravel() cone_w = np.repeat(area_k, 16) / (2.0 * np.pi) ** 2 - phase = np.exp(-1j * (np.outer(kx, nodes[:, 0]) + np.outer(ky, nodes[:, 1]))) transverse = electric[:, :2, :, k] gram = np.einsum("q,qcm,qcn->mn", weights, transverse.conj(), transverse) - spectra = np.einsum("kq,q,qcm->kcm", phase, weights, transverse) - cone = np.einsum("k,kcm,kcn->mn", cone_w, spectra.conj(), spectra) + weighted = weights[:, None, None] * transverse + cone = np.zeros_like(gram) + # Cone points in blocks: the cone x node phase matrix is never whole. + step = max(1, _PHASE_BLOCK // nodes.shape[0]) + for start in range(0, kx.size, step): + block = slice(start, start + step) + phase = np.exp( + -1j + * (np.outer(kx[block], nodes[:, 0]) + np.outer(ky[block], nodes[:, 1])) + ) + spectra = np.einsum("kq,qcm->kcm", phase, weighted) + cone += np.einsum("k,kcm,kcn->mn", cone_w[block], spectra.conj(), spectra) tail = gram - cone defect = (epsilon / 8.0) * gram + (1.0 - epsilon / 8.0) * tail worst = max(worst, _whitened_extremes(defect, gram)[1]) From a62e0355fe3ce294fb1f9cb1d7ddbc750c2171fd Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 13:04:42 +0100 Subject: [PATCH 44/50] SEP 31: Final after phase 5; the remaining coupling work moves to the roadmap The closing section records what the SEP delivered, the end-of-SEP CI evidence on pull request #9 and the fate of every phase 5 open question, and lists what moves to the roadmap: regrid's band loss in joules, field planes in the output schema, the general guide's angular item at every bin, a public per-bin spatial energy spectrum, a graded-index facet envelope, modified-noise planes, the envelopes beyond their measured range, other exit interfaces for Outcouple, vector coupling into a moving general guide and two slow envelope tests. The SEP is archived, the pages it produced name it, and architecture records the processing to translate.flux edge as deliberate. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019qNYqtCKh4iTKj7544yARb --- CHANGELOG.md | 10 +- docs/api/transport.md | 3 + docs/dev/accuracy.md | 2 +- docs/dev/architecture.md | 8 +- docs/dev/backends.md | 2 +- docs/dev/conventions.md | 2 +- docs/dev/roadmap.md | 109 ++++++++++++---- docs/dev/seps/archive/index.md | 1 + .../sep-0031-simulation-coupling.md | 116 ++++++++++++++---- docs/dev/seps/archive/sep-0034-medium-flux.md | 4 +- docs/dev/seps/index.md | 3 +- docs/guide/results/provenance.md | 2 +- docs/guide/waveguides/general-modal.md | 5 +- docs/guide/waveguides/solid-fibres.md | 3 + docs/guide/workflows/coupling.md | 9 +- docs/guide/workflows/luna.md | 2 +- .../plot_08_radial_capillary_coupling.py | 2 + 17 files changed, 210 insertions(+), 73 deletions(-) rename docs/dev/seps/{ => archive}/sep-0031-simulation-coupling.md (96%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 183df2a7c..1066cf66b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,7 +41,7 @@ The first public release. It contains: `beam_spectrum` is the axial-flux spectrum. Kernel schema 35 (`flux`) and output schema 23 (`flux_model`, `flux_plan`). - **Incident beams into general modal guides** - ([SEP 31](https://solprop.readthedocs.io/en/latest/dev/seps/sep-0031-simulation-coupling.html), + ([SEP 31](https://solprop.readthedocs.io/en/latest/dev/seps/archive/sep-0031-simulation-coupling.html), phases 1 and 3). `from_waveguide(provider=..., incident=SpatialPulse(...), coupling=CouplingModel("vector_paraxial", model_error=...))` and `prop_waveguide` launch the share of an incident beam that the guide's channels capture. The beam's @@ -52,7 +52,7 @@ The first public release. It contains: 3 % uncaptured warning, `LAUNCH_UNCAPTURED_WARNING` and `IncidentLaunch` are removed; a strongly guiding core is refused by the vector model's contrast screen. - **Plane exchange between simulations** - ([SEP 31](https://solprop.readthedocs.io/en/latest/dev/seps/sep-0031-simulation-coupling.html), + ([SEP 31](https://solprop.readthedocs.io/en/latest/dev/seps/archive/sep-0031-simulation-coupling.html), phase 2). `solprop.coupling` holds `FieldPlane`, a coherent field at one plane with its reference, geometry, metric, basis identity and provenance; `regrid`, the direct finite-time Fourier remap between temporal grids with its loss report and no @@ -62,7 +62,7 @@ The first public release. It contains: launch bound to it, recording `configuration["coupling"]` and drawing no new noise. Output schema 24 records each run's plane-reference record (`RunMetadata.reference`). - **Coupling models** - ([SEP 31](https://solprop.readthedocs.io/en/latest/dev/seps/sep-0031-simulation-coupling.html), + ([SEP 31](https://solprop.readthedocs.io/en/latest/dev/seps/archive/sep-0031-simulation-coupling.html), phase 3). `couple(plane, port, model=CouplingModel(kind, model_error=...))` is the one path from a field plane to a launch bound to a port, returning the launch and an immutable `CouplingReport`. `same_basis` copies an identical basis, applies a declared @@ -81,7 +81,7 @@ The first public release. It contains: field on a spatial grid under a declared interface model. Ports state the models they admit. The phase-2 `same_basis` function is removed in favour of `couple`. - **Transport between planes** - ([SEP 31](https://solprop.readthedocs.io/en/latest/dev/seps/sep-0031-simulation-coupling.html), + ([SEP 31](https://solprop.readthedocs.io/en/latest/dev/seps/archive/sep-0031-simulation-coupling.html), phase 4). `coupling.transport` carries a radial or Cartesian `FieldPlane` through an ordered sequence of linear elements -- `FreeSpace` (exact angular-spectrum propagation, with an explicit `"angular"` or `"fresnel"` output plane), `ThinLens`, @@ -93,7 +93,7 @@ The first public release. It contains: unless their removal is declared, and a same-grid field filling the window's outer band is refused beyond `FreeSpace(edge_tolerance=...)` (default 1e-4). Refusals raise `CouplingError`. - **Coupled workflows** - ([SEP 31](https://solprop.readthedocs.io/en/latest/dev/seps/sep-0031-simulation-coupling.html), + ([SEP 31](https://solprop.readthedocs.io/en/latest/dev/seps/archive/sep-0031-simulation-coupling.html), phase 5). The eight compositions of the coupling chapter -- capillary into capillary, a nonlinear gas gap into a capillary, fibre into fibre, Gaussian, analytic and Cartesian beams into a guide, a guide's far field, and SPM, compression and relay diff --git a/docs/api/transport.md b/docs/api/transport.md index 7c2007e8e..7b5e2db9f 100644 --- a/docs/api/transport.md +++ b/docs/api/transport.md @@ -14,3 +14,6 @@ page. Both names are importable from {mod}`solprop.coupling`. .. autoclass:: solprop.coupling.transport.TransportReport :members: ``` + +Transport between planes was proposed and carried out by +[SEP 31](../dev/seps/archive/sep-0031-simulation-coupling.md). diff --git a/docs/dev/accuracy.md b/docs/dev/accuracy.md index 3efc81e4a..21fd42e9d 100644 --- a/docs/dev/accuracy.md +++ b/docs/dev/accuracy.md @@ -745,7 +745,7 @@ that comparison, not a general estimate for a changing basis. ## 12. Coupling between simulations -A coupling ([SEP 31](seps/sep-0031-simulation-coupling.md), +A coupling ([SEP 31](seps/archive/sep-0031-simulation-coupling.md), [the manual's chapter](../guide/workflows/coupling.md)) joins two runs that each have their own accuracy envelope; the coupling adds its own, and the three are kept apart rather than folded into one number. diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md index 94b83a2df..c7d660184 100644 --- a/docs/dev/architecture.md +++ b/docs/dev/architecture.md @@ -136,7 +136,7 @@ kernel layout carries the transverse columns in its mode axis (see §12). | **T** translation | turns P objects into validated kernel-ready arrays and the `KernelProblem` contract | P, numpy, scipy | may be expensive once per problem; results cached and validated | | **B** backends | state buffers, transform plans, kernels, the inner z-loop, statistics kernels | numpy, numba, pyfftw; the native package in `native/` (Kokkos, Kokkos-FFT and nanobind); from P only the types and constants the seam names — never a physics object | all hot-path rules of [backends.md](backends.md); in-place and `out=` allowed here only | | **D** driver | `Simulation`, run loop over save planes, batch runners, `prop_capillary`/`prop_gnlse`, output handlers, statistics specs | P, T, B (through the seam only), h5py | none | -| **X** processing / plotting | analysis of outputs, standard plots, design tools | numpy, scipy, matplotlib, D (outputs), T (reference tables and their identities, for `processing.modal` alone) — never B | none; may call backend statistics kernels through the D layer when a computation belongs on the device | +| **X** processing / plotting | analysis of outputs, standard plots, design tools | numpy, scipy, matplotlib, D (outputs), T (reference tables and their identities, for `processing.modal`; the recorded plane measure, for `processing.mixtures` and `processing.planes`) — never B | none; may call backend statistics kernels through the D layer when a computation belongs on the device | **Transverse rules are built in P.** `modes/quadrature.py` constructs its rules numerically -- Laurie's Kronrod recursion, and the field-scale rules of @@ -146,7 +146,7 @@ problem, and caches them. It is the one numerical construction kept in P: it yie nodes and weights for T to fold into the transform, and no compiled code, per-stage evaluation or hot loop enters P with it. -**The one X → T edge.** `solprop.processing.modal` imports +**The X → T edges.** `solprop.processing.modal` imports `solprop.translate.modal_tables.ModalTransformTables` and `solprop.translate.general.guide_identity`, and that is inherent to the output schema rather than incidental. A general modal file records what its E/H reference **is** -- @@ -159,7 +159,9 @@ same way. `processing.mixtures` and `processing.planes` rebuild a spatial plane' measure from the record with `translate.flux.flux_from_record` and `translate.spatial.axial_admittance` (SEP 34), and `processing.planes` reuses `processing.modal`'s checked tables and evaluates a general basis Gram with -`translate.coupled.metric_at`. No other module of **X** imports **T**. +`translate.coupled.metric_at`. These edges are deliberate (SEP 31): the measure a file +records is rebuilt by the code that built it for the run, not by a second copy in **X**. +No other module of **X** imports **T**. **Coupling crosses the layers without breaking them** (SEP 31). The field plane, the input port, the remap and `couple` are **P**; the driver's `Simulation.input_port()` diff --git a/docs/dev/backends.md b/docs/dev/backends.md index 9d8f995b7..9ff927dbd 100644 --- a/docs/dev/backends.md +++ b/docs/dev/backends.md @@ -1694,7 +1694,7 @@ views sized from the problem. A fill whose populations follow independent densit paths takes the z table, which every CPU backend runs (on `native` the CPU build only); a shared pressure path does not. Device builds are not qualified on these runs. -**Coupling between runs** ([SEP 31](seps/sep-0031-simulation-coupling.md)) never crosses +**Coupling between runs** ([SEP 31](seps/archive/sep-0031-simulation-coupling.md)) never crosses the seam: a field plane is read from a run's stored output and its record, coupled and transported in NumPy and SciPy on the host, and installed in the receiving run as its initial state through `with_input`. A source and a receiver may therefore run on diff --git a/docs/dev/conventions.md b/docs/dev/conventions.md index a80009ea9..c23a59693 100644 --- a/docs/dev/conventions.md +++ b/docs/dev/conventions.md @@ -234,7 +234,7 @@ admitted only when every propagating column meets the adiabatic certificate: `delta* = expm1(Lambda tanh Lambda)` within `AdiabaticAccuracy` (default 1e-4 each; `solprop.adiabatic`). -**Field planes and coupling** ([SEP 31](seps/sep-0031-simulation-coupling.md)). A +**Field planes and coupling** ([SEP 31](seps/archive/sep-0031-simulation-coupling.md)). A `FieldPlane` holds a stored spectrum exactly as the output does: the optics-convention raw DFT of each column, referenced to the window centre, in √W (ordinary and general modal coordinates), V m (the Hankel spectrum of a radial field) or V/m (the DFT of a diff --git a/docs/dev/roadmap.md b/docs/dev/roadmap.md index 3ce3b9d5e..91218f811 100644 --- a/docs/dev/roadmap.md +++ b/docs/dev/roadmap.md @@ -32,18 +32,6 @@ being implemented. routes, bound response and other geometries remain follow-up work; the first route is a single-mode capillary with node quadrature. -- [SEP 31 — Coupling simulations and transporting ultrafast fields](seps/sep-0031-simulation-coupling.md): - reference-plane fields, coherent spatial synthesis/projection, ordered linear - transport and validated relaunch using SEP 34's shared medium flux; temporal - grid conversion; forward guide coupling; and a focused radial Kerr-to-capillary - tutorial. Clean breaking changes replace inconsistent energy semantics, with - no legacy normalisation or old-file migration. General junction scattering, - high-NA vector propagation and resolved prism/grating layouts remain open below. - Its accepted design specifies concrete overlap/thin-screen models and a detuned - 37.5 μm tutorial; the incident-energy defect is its first implementation phase, - after SEP 34's full public activation. Qualified provider-pair envelopes may - replace conservative weak-guidance screens within the total model budget. - In progress: implementation has started on SEP 34's branch. ## Draft enhancement proposals @@ -299,14 +287,14 @@ multi-pass cells by composing repeated bulk propagation with focusing mirrors, dispersive optical elements and the chosen cell geometry. A cell-level workflow would need to carry the spatial and temporal field from pass to pass, account for mirror dispersion and loss, and expose the evolution of the beam, spectrum and compressible -pulse duration. [SEP 31](seps/sep-0031-simulation-coupling.md) proposes field +pulse duration. [SEP 31](seps/archive/sep-0031-simulation-coupling.md) added field handoffs, focusing, reflection and spectral optics on top of the existing free-space -routes. Cell geometry, assembly and validation against independent propagation or -experimental data remain open. +routes ([coupling simulations](../guide/workflows/coupling.md)). Cell geometry, assembly +and validation against independent propagation or experimental data remain open. **Coupling beyond the forward model.** -[SEP 31](seps/sep-0031-simulation-coupling.md) covers forward overlaps, scalar -homogeneous gaps and spectral compressors. A general junction calculation with +[SEP 31](seps/archive/sep-0031-simulation-coupling.md) delivered forward overlaps, +scalar homogeneous gaps and spectral compressors. A general junction calculation with reflected, radiated and evanescent fields, high-NA vector free-space transport, gradient transport outside SEP 34's certificate, varying absorption, resolved prism/grating angular dispersion and spatial chirp, and feedback between stages @@ -315,6 +303,47 @@ remain separate extensions. certified forward amplitude correction, including admitted weak steps; it does not add general gradient scattering or a complex-index path certificate. +**A graded-index facet envelope for the vector coupling model.** `vector_paraxial` into a +step-index guide counts a recorded envelope for the facet reflection and backward fields +it omits (`VECTOR_STEP_INDEX_ENVELOPE`, against an independent full-vector mode-matching +solution of the facet). A graded-index guide has no such envelope: it is admitted with an +uncounted envelope item, its facet omission bounded only by the weak-guidance contrast +screen and the H closure. A graded-index reference — the same mode-matching solver with +a layered core — would give it a recorded bound +([SEP 31](seps/archive/sep-0031-simulation-coupling.md), decision A1; [coupling simulations](../guide/workflows/coupling.md)). + +**The coupling envelopes beyond their measured range.** Each recorded pair envelope holds +only where it was measured ([SEP 31](seps/archive/sep-0031-simulation-coupling.md), Part II, "Closing"): +- the capillary envelope stops at the leaky parameter $\delta = 10^{-2}$, so a narrow + capillary in the infrared is refused; extending it needs a re-measured bound, and is + worth doing only when such a pair is needed; +- the beam-into-fibre envelope covers LP01 alone (V 1.5–2.4, contrast up to 0.01); near + V 2.4 its in-cone figure is dominated by the cone's truncation of the widening HE11 + spectrum rather than by the vector correction, and could be sharpened; +- the vector facet envelope rests on the analytic Fresnel ceiling in the narrow bands + just above the HE12 and HE13 cutoffs, which no practical reference cylinder resolves; +- the fibre-pair `STEP_INDEX_ENVELOPE` is a constant bound whose order in the index + contrast was not measured; re-measuring it would let it scale as the others do; +- every envelope is measured at the profile wavelength against the run's frozen + profile, so a fibre whose profile moves strongly across the band is represented only + as the run represents it. + +**The general guide's angular item at every occupied bin.** `couple` into a general +modal guide evaluates the target's angular admission at no more than five bins of the +band. Evaluating it at every occupied bin closes the gap, if its cost stays within a +coupling's; the cost is to be measured first ([SEP 31](seps/archive/sep-0031-simulation-coupling.md), phase 3). + +**Other exit interfaces for `Outcouple`.** `Outcouple` synthesises a guide's exit field +under the matched interface only. A declared planar-interface model at the exit (the +end face of a fibre into air, for example) would make the facet's reflection and +refraction explicit, as `PlanarInterface` does between two media in transport +([SEP 31](seps/archive/sep-0031-simulation-coupling.md), phase 3). + +**Vector coupling into a moving general guide.** The input port of a general guide whose +geometry moves along z (a sampled path) carries no E/H reference at the entrance, so it +admits only `same_basis`; `vector_paraxial` needs the entrance section's fields from the +path ([SEP 31](seps/archive/sep-0031-simulation-coupling.md), phase 3). + **Elliptical hollow capillary modes.** Extend hollow-capillary modelling to elliptical bores specified by their two semi-axes, with mode profiles, dispersion, wall loss and nonlinear overlaps that resolve the geometry and polarisation. This needs a mode model @@ -934,7 +963,7 @@ distance, which the radial tutorial computes by hand. row of a mode set, so a Jones vector spread over the two members of a degenerate pair needs a hand-built input; a vector capillary run through the keyword interfaces is also not yet validated end to end ([polarisation](../guide/setup/polarisation.md)). -[SEP 31](seps/sep-0031-simulation-coupling.md) proposes explicit incident-field +[SEP 31](seps/archive/sep-0031-simulation-coupling.md) added explicit incident-field polarisation and coherent projection across target channels. The keyword-interface convenience and its complete vector-capillary qualification remain open. @@ -943,21 +972,44 @@ paired gradient pressures is expressed as an integer index with the profile buil builder ([scans](../guide/workflows/scans.md)). Structured axis values would need explicit serialisation and provenance in the scan file. -**Further offline elements.** [SEP 31](seps/sep-0031-simulation-coupling.md) -proposes focusing, apertures and ordered field transport beside the existing -material, mirror, grating and prism transfer functions -([compression](../guide/workflows/compression.md)). Its aperture model shares -`spatial.aperture_profile` and qualifies agreement with in-run aperture statistics -where their plane, flux and paraxial assumptions agree +**Further offline elements.** [SEP 31](seps/archive/sep-0031-simulation-coupling.md) +added focusing, apertures and ordered field transport (`coupling.transport`) beside the +existing material, mirror, grating and prism transfer functions +([compression](../guide/workflows/compression.md)). Its aperture shares +`spatial.aperture_profile` and its weighting with the in-run aperture statistics, so the +two agree where their plane, flux and paraxial assumptions do ([SEP 23](seps/archive/sep-0023-far-field-aperture-spectra.md)). Offline propagation through a z-varying waveguide mode as an element remains open. +**Field planes in the output schema.** Two changes wait for the next output-schema +change: `field_plane` on the general modal route's accessor whitelist (the whitelist +feeds the recorded schema), and the plane's frame recorded in `FieldPlane`, so that +`transport` no longer needs a `frame=` argument for a plane written in a frame other +than its medium's ([SEP 31](seps/archive/sep-0031-simulation-coupling.md), phases 2 and 4). + +**Regrid's band loss in physical energy.** `regrid` reports the share of a plane lost +outside the new temporal band in the record's norm. Reporting it in physical energy as +well, through the per-bin admittance the transport core already evaluates, would give the +loss in joules beside the share ([SEP 31](seps/archive/sep-0031-simulation-coupling.md), phase 2). + +**A public per-bin spatial energy spectrum.** The transport elements use a private +`_energy_spectrum` (`coupling/elements.py`); a public `spatial.spatial_energy_spectrum` per frequency bin would +replace it. In the same change, `spatial.aperture_profile`'s docstring should say that its +result is a field transmission under the default `"amplitude"` weighting and a power +weight under `"quadrature"` ([SEP 31](seps/archive/sep-0031-simulation-coupling.md), phase 4). + +**Field planes from modified-noise runs.** `processing.field_plane` refuses every plane of +a run with `noise="modified"`, whose saved field leaves the one-photon background to the +auxiliary `noise_phase`. Reconstructing that phase into the plane, following the +documented saved-field meaning, would lift the refusal +([SEP 31](seps/archive/sep-0031-simulation-coupling.md), decision A2; [coupling simulations](../guide/workflows/coupling.md)). + **Far-field detection beyond SEP 23.** The far-field statistics ([statistics](../guide/results/statistics.md), *Far-field apertures and angular regions*) leave the following in-run extensions for later, each as new names or fields that leave the six statistics' shapes unchanged ([SEP 23](seps/archive/sep-0023-far-field-aperture-spectra.md)). -[SEP 31](seps/sep-0031-simulation-coupling.md) adds coherent offline observation +[SEP 31](seps/archive/sep-0031-simulation-coupling.md) added coherent offline observation planes, apertures and polarisation optics; its partial coverage is noted below. - *Polarisation-resolved detection*: an analyser Jones vector on `ApertureSet`, or @@ -972,7 +1024,7 @@ planes, apertures and polarisation optics; its partial coverage is noted below. two-dimensional slit (a line grid's aperture already is one). - *Radial and planar-hybrid geometry*: refused today, having no Cartesian transverse wavevector; a centred aperture on a radial grid would weight the Hankel - spectrum's rings. SEP 31 covers radial offline transport and apertures; + spectrum's rings. SEP 31 added radial offline transport and apertures; extensions to these in-run statistics remain open. ## Release preparation @@ -1092,3 +1144,8 @@ backend is built. The pool sizes should be clamped to `numba.config.NUMBA_NUM_TH Likewise the field retention of [SEP 22](seps/archive/sep-0022-memory-lean-spatial-execution.md) (`fields=`, `field_planes=`) is set on `SimulationSettings` or through `run_scan`, not as a keyword of the `prop_*` functions. Offer both there together. + +**Two slow coupling-envelope tests in the medium tier.** Two of the recorded-envelope +oracles in `tests/coupling/test_envelopes.py` take about 30–50 s each in the medium tier. +If CI time becomes a constraint, they can move to the slow tier, with a cheaper check of +the same envelope left in the medium tier ([SEP 31](seps/archive/sep-0031-simulation-coupling.md)). diff --git a/docs/dev/seps/archive/index.md b/docs/dev/seps/archive/index.md index ecb898ca7..35fb478f2 100644 --- a/docs/dev/seps/archive/index.md +++ b/docs/dev/seps/archive/index.md @@ -26,5 +26,6 @@ sep-0027-held-plasma-current sep-0028-free-space-evaluation sep-0029-compiled-backend-distribution sep-0030-standard-benchmark-suite +sep-0031-simulation-coupling sep-0034-medium-flux ``` diff --git a/docs/dev/seps/sep-0031-simulation-coupling.md b/docs/dev/seps/archive/sep-0031-simulation-coupling.md similarity index 96% rename from docs/dev/seps/sep-0031-simulation-coupling.md rename to docs/dev/seps/archive/sep-0031-simulation-coupling.md index 44582f432..86cd0eb92 100644 --- a/docs/dev/seps/sep-0031-simulation-coupling.md +++ b/docs/dev/seps/archive/sep-0031-simulation-coupling.md @@ -6,17 +6,17 @@ | **Title** | Coupling simulations and transporting ultrafast fields | | **Author** | John Travers | | **Drafting agent** | GPT-6 (draft and 2026-10-23 review response) | -| **Status** | In progress | +| **Status** | Final | | **Review status** | Reviewed | | **Reviewers** | John Travers (human); Opus 5.5 (AI) | | **Type** | Feature | | **Created** | 2026-10-22 | -| **Resolution** | Accepted 2026-10-23 by John Travers after the S1–S4 responses recorded in Part III. SEP 34's public activation precedes this proposal's incident-launch change. | -| **Requires** | [SEP 22](archive/sep-0022-memory-lean-spatial-execution.md): plane storage and input replacement; [SEP 23](archive/sep-0023-far-field-aperture-spectra.md): aperture definitions; [SEP 25](archive/sep-0025-rounding-level-reproducibility.md): numerical qualification; [SEP 34](archive/sep-0034-medium-flux.md): shared medium flux and radial adiabatic transport | +| **Resolution** | Accepted 2026-10-23 by John Travers after the S1–S4 responses recorded in Part III. SEP 34's public activation precedes this proposal's incident-launch change. Final 2026-10-24 by John Travers after phase 5 and the maintainer decisions of 2026-10-24, with the remaining follow-up work moved to the roadmap (Part II, "Closing"). | +| **Requires** | [SEP 22](sep-0022-memory-lean-spatial-execution.md): plane storage and input replacement; [SEP 23](sep-0023-far-field-aperture-spectra.md): aperture definitions; [SEP 25](sep-0025-rounding-level-reproducibility.md): numerical qualification; [SEP 34](sep-0034-medium-flux.md): shared medium flux and radial adiabatic transport | | **Supersedes** | — | | **Superseded-by** | — | | **Implementation** | John Travers with Opus 5.5 (AI); on `feature/sep-0031-simulation-coupling` | -| **Manual pages** | — | +| **Manual pages** | [Coupling simulations](../../../guide/workflows/coupling.md); [General modal propagation](../../../guide/waveguides/general-modal.md) (incident launch); [Solid-core fibres: the general modal route](../../../guide/waveguides/solid-fibres.md); [Supplying a waveguide](../../../guide/waveguides/supplied-guides.md); [Transport between planes](../../../api/transport.md) and the `solprop.coupling` and `processing.field_plane` entries of the [API index](../../../api/index.md); the tutorial [nonlinear beam preparation and coupling into a capillary](../../../auto_tutorials/free_space/plot_08_radial_capillary_coupling.rst); the worked example [spatiotemporal propagation in a graded-index multimode fibre](../../../auto_examples/waveguides/plot_04_graded_index_multimode.rst) | ## Part I — Proposal @@ -28,7 +28,7 @@ the next simulation. These four blocks cover coupling between hollow or solid guides, incident Gaussian and arbitrary fields, nonlinear beam preparation before a capillary, outcoupling to an observation plane, and compression between stages. The incident field retains its absolute scale: light that is not captured is not -restored by normalisation. The separate [SEP 34](archive/sep-0034-medium-flux.md) supplies +restored by normalisation. The separate [SEP 34](sep-0034-medium-flux.md) supplies the shared medium-flux contract; coupling evaluates that measure on the host at each reference plane. Qualification separates physical attenuation, uncaptured forward power, representation error and model limitations, and compares @@ -85,11 +85,11 @@ shims or old-file migration. A changed schema may refuse an older file clearly. ### 3. Context The relevant implementation is already divided across the layers of -[architecture](../architecture.md): +[architecture](../../architecture.md): - `solprop.driver.simulation.Simulation.with_input` accepts an array on an existing grid, in its existing basis, and shares translated tables. The - [interior-launch workflow](../../guide/results/interior-launch.md) deliberately + [interior-launch workflow](../../../guide/results/interior-launch.md) deliberately retains the parent's frame. It does not establish a transfer between unrelated guides or temporal grids. - `solprop.pulses.SpatialPulse` builds Gaussian/super-Gaussian beams and can launch @@ -102,18 +102,18 @@ The relevant implementation is already divided across the layers of reconstruction residual in a quadrature norm. It is not a radiated-power calculation. `Simulation`'s `_projected_launch` subsequently rescales the projected shape to the pulse's full requested energy, even when capture is poor. - The [general-modal manual](../../guide/waveguides/general-modal.md) documents this. + The [general-modal manual](../../../guide/waveguides/general-modal.md) documents this. - `solprop.processing.modal` reconstructs E/H only against checked reference identities. Ordinary modal states instead use the true-power boundary $b=\sqrt{\operatorname{Re}n_{\rm eff}}a$. General-guide coordinates can carry a dense flux metric. A universal unweighted sum of squared amplitudes is wrong. - `solprop.optics` already supplies `TransferFunction`, `TaylorPhase`, `Material`, `ChirpedMirror`, `GratingPair`, `PrismPair` and `Chain`. - [Compression](../../guide/workflows/compression.md) and its tests cover their + [Compression](../../../guide/workflows/compression.md) and its tests cover their signs, loss conventions and parameter searches. This SEP reuses those models. - `solprop.spatial` and the radial/Cartesian routes propagate physical electric fields, but their energy, intensity and fluence currently use vacuum admittance. - [Free-space conventions](../../guide/free-space/model.md) explicitly identify + [Free-space conventions](../../../guide/free-space/model.md) explicitly identify that limitation. Existing far-field statistics are selected diagnostics, not a coherent output field at an arbitrary receiving plane. @@ -122,17 +122,17 @@ semantics, optics/compressor models, spatial transforms and same-problem input replacement. They do not qualify the cross-route compositions proposed here. This assessment is a source/test audit, not a new test-suite result. -[SEP 34](archive/sep-0034-medium-flux.md) owns spatial launch/noise/statistics consistency +[SEP 34](sep-0034-medium-flux.md) owns spatial launch/noise/statistics consistency and the adiabatic correction for the existing radial gas-gradient route. This SEP -and [SEP 5](sep-0005-solid-ionisation.md) consume its common measure; neither adds -another normalisation policy. [SEP 8](sep-0008-quadratic-crystal-optics.md) retains +and [SEP 5](../sep-0005-solid-ionisation.md) consume its common measure; neither adds +another normalisation policy. [SEP 8](../sep-0008-quadratic-crystal-optics.md) retains its full-vector crystal E/H contract and interface solver. -[SEP 9](sep-0009-propagation-statistics.md) measures fields within a propagation +[SEP 9](../sep-0009-propagation-statistics.md) measures fields within a propagation route; this SEP supplies transported fields without taking on its additional measurement catalogue. -[SEP 21](sep-0021-absorbing-boundaries.md) owns wider production time windows and +[SEP 21](../sep-0021-absorbing-boundaries.md) owns wider production time windows and escaped-field models; remapping a saved plane cannot recover already absorbed light. -[SEP 29](archive/sep-0029-compiled-backend-distribution.md) has moved the compiled backend +[SEP 29](sep-0029-compiled-backend-distribution.md) has moved the compiled backend into this repository as `native`, distributed as `solprop-native`. Qualification uses its CPU build. This draft's historical review retains the names it reviewed. @@ -140,7 +140,7 @@ uses its CPU build. This draft's historical review retains the names it reviewed #### 4.1 One physical plane, several representations -Use SI and the optics convention of [conventions](../conventions.md): positive +Use SI and the optics convention of [conventions](../../conventions.md): positive propagation phase $\exp(+i\beta L)$ and delay $\exp(+i\omega\tau)$. A plane has an oriented local transverse basis, a forward normal and a physical origin. Its temporal reference is independent of a simulation's local z origin. Reflection @@ -172,7 +172,7 @@ together and leaves the physical field unchanged. #### 4.2 Shared plane measure -[SEP 34](archive/sep-0034-medium-flux.md) derives and implements the scalar axial-admittance +[SEP 34](sep-0034-medium-flux.md) derives and implements the scalar axial-admittance measure, its analytic-signal endpoints, physical launch/noise scaling, statistics and radial adiabatic transport. For a plane in an isotropic medium it supplies @@ -755,8 +755,8 @@ Use it as the reference and a supported small-problem implementation; qualify accelerations against the same discrete map. Cache plans by geometry, grid, material, basis and reference identity, not by a mutable object's address. Factored $Y_z$ data follow SEP 34 and the spatial memory bounds of -[SEP 22](archive/sep-0022-memory-lean-spatial-execution.md) and -[SEP 28](archive/sep-0028-free-space-evaluation.md); +[SEP 22](sep-0022-memory-lean-spatial-execution.md) and +[SEP 28](sep-0028-free-space-evaluation.md); do not add a permanent transverse-by-frequency array when the root can be evaluated from existing separable coefficients in a bounded workspace. @@ -787,7 +787,7 @@ fallback is hidden inside a device statistic. ### 7. Verification and validation -Use [testing](../testing.md), [accuracy](../accuracy.md) and SEP 25. A matched +Use [testing](../../testing.md), [accuracy](../../accuracy.md) and SEP 25. A matched discrete operation uses a rounding budget based on reduction length, transform error and conditioning. A changed grid, modal truncation or propagation uses independent refinement and an accuracy envelope. Do not demand bitwise agreement @@ -1128,9 +1128,9 @@ No new material constant or experimental validation is claimed. Channel launches and `with_input` arrays are unchanged. - The advanced benchmark's graded-index case (`benchmarks/guides.py`) launches an incident 12 µm beam, so its launched energy is now the captured share. -- Manual: [general modal propagation](../../guide/waveguides/general-modal.md), - [solid fibres](../../guide/waveguides/solid-fibres.md) (a runnable snippet), - [supplied guides](../../guide/waveguides/supplied-guides.md), a comment in the +- Manual: [general modal propagation](../../../guide/waveguides/general-modal.md), + [solid fibres](../../../guide/waveguides/solid-fibres.md) (a runnable snippet), + [supplied guides](../../../guide/waveguides/supplied-guides.md), a comment in the graded-index tutorial, and the changelog. **Deviations and why.** @@ -2358,6 +2358,68 @@ LP01 0.933, LP02 0.0625. The figures changed only in their last digits. is recorded here. The `couple` comparison at V = 1.5 bins shows a ~2e-5 implementation difference attributed to the guide's tabulation, not to the facet. +### Closing — 2026-10-24 + +The maintainer set this SEP Final on 2026-10-24, after phase 5 and the decisions above. +Phases 1–5 are merged on `feature/sep-0031-simulation-coupling` and reported in this +part. Delivered overall: the incident-energy correction (a beam's energy met over its +whole plane in the core medium, the uncaptured share lost rather than restored); plane +exchange (`FieldPlane`, `processing.field_plane`, `regrid`, `InputPort` and the +plane-reference record of output schema 24); coupling (`couple` under `same_basis`, +`scalar_paraxial` and `vector_paraxial`, with its `CouplingReport`, its energy budget and +the recorded capillary, fibre-pair, beam-into-fibre and vector facet envelopes, and +`Outcouple`); the transport core (`transport` through the linear elements, with its +sequential ledger in joules); and the eight compositions of §2 as integrated tests, the +focused capillary-coupling tutorial and the graded-index worked example rerun on the +incident launch. Coupling and transport are NumPy/SciPy code outside the propagation +kernels; the compositions' tests run their source and receiving simulations on +`numpy_ref`, `numba_cpu` and the native CPU build. + +**End-of-SEP evidence.** On pull request #9 every CI job passed on 12abb59: the test +lanes with coverage at 100 %, the slow lanes on Linux and macOS, Windows, the lowest +supported dependencies and the native suite. The run on this commit was pending when it +was written. + +**The phase 5 open questions.** Questions 1 (superseded by `couple` and the +`CouplingReport`), 2 (the on-axis index kept as a graded core's reference), 3 (the +graded-index worked example, rerun above), 4 (basis identity by digest, kept), 8 +(`same_basis` outside the band, still refused; `regrid(crop_band=True)` crops) and 13 +(`Outcouple` kept a separate synthesis step) are resolved as recommended. Questions 6 +(A2), 10 (A1), 11 (B1) and 16 (A3) are resolved by the maintainer decisions above. +Question 9, the `processing` → `translate.flux` edge, is recorded as deliberate in +[architecture](../../architecture.md) §3. Questions 5, 7, 12, 14, 15 and 17 are the +roadmap items below. + +Moved to the [roadmap](../../roadmap.md): +- **regrid's band loss in physical energy** (per-bin admittance), beside the + record-norm share (question 5); +- **field planes in the output schema**: `field_plane` on the general modal accessor + whitelist and the frame recorded in `FieldPlane`, so `transport` needs no `frame=`, + both with the next output-schema change (questions 7 and 15); +- **the general guide's angular item at every occupied bin**, now at most five, after + measuring its cost (question 12); +- **a public per-bin spatial energy spectrum** replacing the transport elements' private + `_energy_spectrum`, with the `aperture_profile` docstring's amplitude and quadrature + weighting, when `spatial.py` is next open (question 14); +- **a graded-index facet envelope** for `vector_paraxial`: the same mode-matching solver + with a layered core; graded guides are admitted now with an uncounted envelope item + (A1); +- **field planes from modified-noise runs**: reconstructing the auxiliary `noise_phase` + would lift `field_plane`'s refusal (A2); +- **the coupling envelopes beyond their measured range**: the capillary envelope beyond + $\delta = 10^{-2}$, only if narrow infrared capillaries need it (question 17); the + beam-into-fibre envelope's LP01-only range (V 1.5–2.4, contrast to 0.01) and its + cone-edge truncation near V 2.4; the vector facet envelope's unresolved bands just above + the HE12 and HE13 cutoffs, which rest on the analytic ceiling; the order in Δ of the + fibre-pair `STEP_INDEX_ENVELOPE`, to re-measure; and the measurement of every envelope at + the profile wavelength against the frozen profile; +- **other exit interfaces for `Outcouple`**, which offers only the matched interface (a + declared planar-interface model); +- **vector coupling into a moving general guide**, whose sampled-path port carries no + E/H and so admits only `same_basis`; +- **two slow medium-tier envelope tests** (about 30–50 s each), for the slow tier if CI + time becomes a constraint. + ## Part III — History - 2026-10-22: Drafted after auditing existing launch, projection, spatial flux, @@ -2393,6 +2455,8 @@ LP01 0.933, LP02 0.0625. The figures changed only in their last digits. implementation branch; it reaches main through its own pull request after SEP 34 has merged. - 2026-10-24: Phase 2, plane exchange, implemented; report in Part II. +- 2026-10-24: Final. Phases 1–5 merged and reported, the maintainer decisions recorded; + the remaining work moved to the roadmap (Part II, "Closing"). Archived. ### AI review — 2026-10-23 @@ -2662,7 +2726,7 @@ the record of what was reviewed; its completion does not imply acceptance of thi revision. John selected the detuned tutorial and complex-admittance treatment while the revision was being prepared. -- **R1 — Accepted.** [SEP 34](archive/sep-0034-medium-flux.md) now owns spatial energy, +- **R1 — Accepted.** [SEP 34](sep-0034-medium-flux.md) now owns spatial energy, launch, noise, statistics, adiabatic radial transport and their CPU-backend qualification. SEP 31 consumes that contract. SEP 34's implementation may be developed in private backend slices, but the public switch is atomic across @@ -2746,7 +2810,7 @@ required implementation evidence. ### AI re-review of the revision — 2026-10-23 **Assessment.** The revision resolves R1–R10. The split into -[SEP 34](archive/sep-0034-medium-flux.md) is clean, and §4.3's three named coupling models +[SEP 34](sep-0034-medium-flux.md) is clean, and §4.3's three named coupling models with an explicit `model_error` budget are concrete enough to approve. §4.6's thin-screen bound is correct: $0\le U_0-U_Y\le d(E)$ holds because $Y_z/Y_0=\cos\theta$ on propagating columns, and the bound on the loss difference follows from the two diff --git a/docs/dev/seps/archive/sep-0034-medium-flux.md b/docs/dev/seps/archive/sep-0034-medium-flux.md index c0299feef..ff15f11c4 100644 --- a/docs/dev/seps/archive/sep-0034-medium-flux.md +++ b/docs/dev/seps/archive/sep-0034-medium-flux.md @@ -32,7 +32,7 @@ paths. Qualify NumPy, Numba CPU, native CPU and bounded CUDA/HIP cases before activating the changed public semantics together. This is a clean breaking change with no legacy normalisation or old-file migration. It is the shared prerequisite for coupling in -[SEP 31](../sep-0031-simulation-coupling.md) and solid carriers in +[SEP 31](sep-0031-simulation-coupling.md) and solid carriers in [SEP 5](../sep-0005-solid-ionisation.md), not an implementation of either feature. ### 2. Motivation and scope @@ -95,7 +95,7 @@ diagonal operator, but omits the changing-admittance amplitude term. This is a code/design audit, not a qualification result. [SEP 5](../sep-0005-solid-ionisation.md) originally sketched scalar flux. -[SEP 31](../sep-0031-simulation-coupling.md)'s review separated this prerequisite +[SEP 31](sep-0031-simulation-coupling.md)'s review separated this prerequisite from coupling and explicitly chose physical launch energy and preservation of radial gradients. [SEP 8](../sep-0008-quadratic-crystal-optics.md)'s anisotropic vector E/H metric remains distinct; [SEP 9](../sep-0009-propagation-statistics.md) diff --git a/docs/dev/seps/index.md b/docs/dev/seps/index.md index 4789b4eed..458615ebf 100644 --- a/docs/dev/seps/index.md +++ b/docs/dev/seps/index.md @@ -35,7 +35,6 @@ Proposals that are active, in draft, accepted, in progress or deferred. | [11](sep-0011-finite-density-gas-index.md) | Finite-density gas index and mixture optics | Draft | Change | | [17](sep-0017-gas-two-photon-ionisation.md) | Broadband two-photon continuum response beside strong-field ionisation | Accepted | Feature | | [21](sep-0021-absorbing-boundaries.md) | Temporal boundaries: wider windows, absorption and the escaped field | Draft | Change | -| [31](sep-0031-simulation-coupling.md) | Coupling simulations and transporting ultrafast fields | In progress | Feature | | [32](sep-0032-raman-maxwell-bloch.md) | Raman Maxwell–Bloch dynamics | Draft | Feature | | [33](sep-0033-single-precision-mlx.md) | Single-precision propagation, and an MLX backend for Apple GPUs | Draft | Feature | @@ -70,6 +69,7 @@ Proposals that are final, rejected, withdrawn or superseded; the | [28](archive/sep-0028-free-space-evaluation.md) | Free-space evaluation at the memory bound | Final | Change | | [29](archive/sep-0029-compiled-backend-distribution.md) | The native backend in solprop, distributed as binaries | Final | Change | | [30](archive/sep-0030-standard-benchmark-suite.md) | Standard benchmark suite | Final | Process | +| [31](archive/sep-0031-simulation-coupling.md) | Coupling simulations and transporting ultrafast fields | Final | Feature | | [34](archive/sep-0034-medium-flux.md) | Medium flux for free-space propagation | Final | Change | ```{toctree} @@ -88,7 +88,6 @@ sep-0010-low-rank-raman sep-0011-finite-density-gas-index sep-0017-gas-two-photon-ionisation sep-0021-absorbing-boundaries -sep-0031-simulation-coupling sep-0032-raman-maxwell-bloch sep-0033-single-precision-mlx template diff --git a/docs/guide/results/provenance.md b/docs/guide/results/provenance.md index 705649993..1edcaa34f 100644 --- a/docs/guide/results/provenance.md +++ b/docs/guide/results/provenance.md @@ -74,7 +74,7 @@ A saved result needs the grid, field convention, material models, response choic checkout when there is one, and when the run started. * - ``reference`` - The **plane-reference record** (output schema 24, - `SEP 31 <../../dev/seps/sep-0031-simulation-coupling.html>`_): the carrier, the + `SEP 31 <../../dev/seps/archive/sep-0031-simulation-coupling.html>`_): the carrier, the frame integral of the terms the linear operator removed (exact Chebyshev antiderivatives of the frame's ``beta_0`` and ``beta_1`` tables, with the frequency origin of the linear term), the reference origin of the run's path, the parent diff --git a/docs/guide/waveguides/general-modal.md b/docs/guide/waveguides/general-modal.md index c4431ff88..f9a3b942f 100644 --- a/docs/guide/waveguides/general-modal.md +++ b/docs/guide/waveguides/general-modal.md @@ -117,7 +117,7 @@ The launched energy is the pulse's, to the last bit. `incident=` takes a {class}`~solprop.pulses.SpatialPulse` — a Gaussian or super-Gaussian beam carrying its own temporal pulse — and launches what the guide's channels capture of it, coupled under a declared model -([SEP 31](../../dev/seps/sep-0031-simulation-coupling.md); +([SEP 31](../../dev/seps/archive/sep-0031-simulation-coupling.md); [coupling simulations](../workflows/coupling.md)): ```text @@ -516,3 +516,6 @@ the declared one or more, as above. And on a constant guide the node rule is fro the entrance geometry (a moving solid guide's rule moves with its interfaces, section by section): the rule's certified tail bound and its sampled Gram belong to that geometry and to no other. + +The incident-beam launch (`incident=` under `coupling=`) and its coupling budget were +proposed and carried out by [SEP 31](../../dev/seps/archive/sep-0031-simulation-coupling.md). diff --git a/docs/guide/waveguides/solid-fibres.md b/docs/guide/waveguides/solid-fibres.md index a1e63221d..f2383f22b 100644 --- a/docs/guide/waveguides/solid-fibres.md +++ b/docs/guide/waveguides/solid-fibres.md @@ -386,3 +386,6 @@ The ones a user meets first: `physical_energy` and `analytic_flux` ([reading the output](../results/reading-output.md)); * on a moving guide, a noise model, a lockstep batch, a CF4 flow or a fixed schedule that straddles a kink (above). + +The incident-beam launch into a fibre and its coupling budget were proposed and carried +out by [SEP 31](../../dev/seps/archive/sep-0031-simulation-coupling.md). diff --git a/docs/guide/workflows/coupling.md b/docs/guide/workflows/coupling.md index 96cfaae4e..f7af5cf35 100644 --- a/docs/guide/workflows/coupling.md +++ b/docs/guide/workflows/coupling.md @@ -5,7 +5,7 @@ a fibre, a gas cell. Carrying only its spectrum or duration loses its wavefront, modal coherence and the frequency dependence of its transverse profile; carrying a bare array leaves its basis, units, frame and energy meaning implicit. {py:mod}`solprop.coupling` connects simulations through a **coherent field at a -reference plane** instead ([SEP 31](../../dev/seps/sep-0031-simulation-coupling.md)). +reference plane** instead ([SEP 31](../../dev/seps/archive/sep-0031-simulation-coupling.md)). This page covers reading a saved plane as a field plane, moving it between temporal grids, and coupling it into another simulation: a relaunch into the same basis, a beam or a guide's exit into a capillary or another fibre, a guide's exit field @@ -195,7 +195,7 @@ run keeps its ionisation tables and their ranges, as every derived run does. ## Coupling between different representations A different guide, radius or medium is not a relaunch. Three quantities must then be -kept apart ([SEP 31](../../dev/seps/sep-0031-simulation-coupling.md), section 4.3): the +kept apart ([SEP 31](../../dev/seps/archive/sep-0031-simulation-coupling.md), section 4.3): the Hermitian E/H flux pairing $F$ of a modal basis, the Gram $G = S^\dagger MS$ of a scalar least-squares fit in a common measure $M$, and a representation's native power metric $Q$ -- the identity for ordinary true-power coefficients, a dense Gram for a declared @@ -654,7 +654,7 @@ certificate (ratio $2.5\times10^{-4}$ against the budget of $10^{-3}$). ## Composing simulations The blocks above compose into the calculations -[SEP 31](../../dev/seps/sep-0031-simulation-coupling.md) set out to support. Each is an +[SEP 31](../../dev/seps/archive/sep-0031-simulation-coupling.md) set out to support. Each is an ordinary chain of runs and planes; nothing schedules them, and each run keeps its own step control. The test suite runs every one of them as a short integrated case (`tests/coupling/test_workflows.py`), asserting its energy ledger. @@ -731,3 +731,6 @@ relayed, ledger = transport( second = couple(relayed, port, model=CouplingModel("scalar_paraxial", model_error=0.05)) print(ledger.specified_loss, ledger.removed, second.report.efficiency) ``` + +Field planes, coupling models and their budgets, and transport between planes were +proposed and carried out by [SEP 31](../../dev/seps/archive/sep-0031-simulation-coupling.md). diff --git a/docs/guide/workflows/luna.md b/docs/guide/workflows/luna.md index 829c1f432..8fba07a65 100644 --- a/docs/guide/workflows/luna.md +++ b/docs/guide/workflows/luna.md @@ -225,7 +225,7 @@ and the launch is the projected amplitudes as they are. The energy the modes do capture is not restored by renormalising to the requested energy (nor is it in Luna), and the coupling report states the incident, captured and uncaptured energies. A field taken from another run is coupled at its own physical scale ([coupling simulations](coupling.md), -[SEP 31](../../dev/seps/sep-0031-simulation-coupling.md)). +[SEP 31](../../dev/seps/archive/sep-0031-simulation-coupling.md)). ## More accurate numerics, the same physics diff --git a/docs/tutorials/free_space/plot_08_radial_capillary_coupling.py b/docs/tutorials/free_space/plot_08_radial_capillary_coupling.py index 279e2e372..e68e1e9fc 100644 --- a/docs/tutorials/free_space/plot_08_radial_capillary_coupling.py +++ b/docs/tutorials/free_space/plot_08_radial_capillary_coupling.py @@ -507,3 +507,5 @@ # :doc:`/guide/workflows/coupling` describes planes, ports, coupling models and # their budgets. :doc:`/guide/free-space/model` and # :doc:`/guide/waveguides/capillaries` describe the two propagations. +# The design is +# :doc:`SEP 31 `. From f7856589a794477441b1a4f18b96ed6be5ddda2d Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 12:43:17 +0100 Subject: [PATCH 45/50] optmat 0.9.0: resolve a rate's family without building every model optmat 0.9.0 reads only the data a model needs. _resolve tells an atom from a molecule, and lists a family's model names, with optmat's ionisation_rate_coverage and molecular_rate_coverage instead of building both rate registries; only the model asked for is built. The capillary configuration's structure hash moves with optmat's helium rate: its reasons text was corrected in 0.9.0 (no number changed). Co-Authored-By: Claude Opus 5.5 --- docs/dev/dependencies.md | 2 +- pyproject.toml | 2 +- src/solprop/nonlinear/optmat_rates.py | 10 ++++++---- tests/driver/test_tdse_entry.py | 2 +- uv.lock | 8 ++++---- 5 files changed, 13 insertions(+), 11 deletions(-) diff --git a/docs/dev/dependencies.md b/docs/dev/dependencies.md index 3d0fc1879..346ce8f07 100644 --- a/docs/dev/dependencies.md +++ b/docs/dev/dependencies.md @@ -24,7 +24,7 @@ need no exception for them. | h5py ≥ 3.15 | HDF5 output | first release with 3.14 wheels on all three platforms | | numba ≥ 0.67 | CPU kernels (ADR-002, ADR-023) | the first release that supports NumPy 2.5 (0.63 is the first with Python 3.14, but caps NumPy below 2.4, and 0.64–0.66 below 2.5); the internals `solprop._numba_cache` uses were checked down to 0.63; `cache=True` except for parallel and dispatcher-capturing closures, and SEP 25's floating-point licence (contraction by default, reassociation per kernel, nothing else) ([backends.md §3.2](backends.md)) | | pyfftw ≥ 0.15.1 | FFTW plans executed in place (ADR-023) | wisdom handled explicitly; 0.15.1 has the 3.14 wheels (none for Windows on Arm) | -| optmat[gas] ≥ 0.8.0 | built-in material models: gas dispersion, Kerr, equation of state and transport; solids; gas and silica Raman responses; gas ionisation rates ([SEP 3](seps/archive/sep-0003-optmat-materials.md)) | the same maintainers' curated-data package; never imported at `import solprop`, since its registries take about a second to import; 0.8.0 is its first public release | +| optmat[gas] ≥ 0.9.0 | built-in material models: gas dispersion, Kerr, equation of state and transport; solids; gas and silica Raman responses; gas ionisation rates ([SEP 3](seps/archive/sep-0003-optmat-materials.md)) | the same maintainers' curated-data package; never imported at `import solprop`, since its registries take about a second to import; 0.9.0 reads only the data a model needs and says which gases each rate model covers | | threadpoolctl ≥ 3.1 | inspect and control the backend-owned BLAS pool | pool provenance and limits are recorded per run | | jaxtyping ≥ 0.3.11 | array annotations, evaluated at import time in every module | pure Python; the checker (beartype) is dev-only; under earlier releases the test session's import hook wraps the compiled kernels, which numba then cannot compile or cache | | tqdm ≥ 4.67 | the progress bar (`solprop.log.BarReporter`) | pure Python; imported when the first bar is drawn, never at `import solprop`; `ipywidgets` is optional for the notebook widget and is not a dependency | diff --git a/pyproject.toml b/pyproject.toml index 72d3aa101..6b7bd8f56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,7 @@ dependencies = [ "pyfftw>=0.15.1", # Every built-in material model (SEP 3): gases with their equation of state and # transport, solids, Raman responses and ionisation rates. - "optmat[gas]>=0.8.0", + "optmat[gas]>=0.9.0", # The BLAS pool is one of the four the numba_cpu backend owns (backends.md 3.4: # a factor of nine on one step from the pool configuration alone), and no BLAS # exposes a runtime thread count except through the library walk this does. diff --git a/src/solprop/nonlinear/optmat_rates.py b/src/solprop/nonlinear/optmat_rates.py index a05c95fb8..2facb8739 100644 --- a/src/solprop/nonlinear/optmat_rates.py +++ b/src/solprop/nonlinear/optmat_rates.py @@ -210,17 +210,19 @@ def _resolve(name: str, wavelength: float, form: str) -> object: """ from optmat.exceptions import OptmatError # noqa: PLC0415 - optmat is lazy from optmat.gas.ionisation_rates import ( # noqa: PLC0415 - optmat is lazy + ionisation_rate_coverage, ionisation_rate_model, - ionisation_rate_models, ) from optmat.gas.molecular_ionisation_rates import ( # noqa: PLC0415 - as above + molecular_rate_coverage, molecular_rate_model, - molecular_rate_models, ) registry = "default" if form == _RECOMMENDED else form - atomic = ionisation_rate_models() - molecular = molecular_rate_models() + # Each family's model names and the gases they cover, read without building any + # model: only the one asked for is built. + atomic = ionisation_rate_coverage() + molecular = molecular_rate_coverage() atoms = {gas.value for gas in atomic["default"]} molecules = {gas.value for gas in molecular["default"]} if name in atoms: diff --git a/tests/driver/test_tdse_entry.py b/tests/driver/test_tdse_entry.py index 5f59836c6..218b30497 100644 --- a/tests/driver/test_tdse_entry.py +++ b/tests/driver/test_tdse_entry.py @@ -222,7 +222,7 @@ def test_tdse_output_defaults_and_their_explicit_overrides(): # included, which another platform's libm and FFT move. Its structure is still hashed # exactly; its numbers are stored beside this test (recorded from the state the # former digest certified) and compared within a bound (tests/record_numbers.py). -_UNCHANGED = "aad63c25bc485771c6e6cf1cceb7868f877a5de6a7fd5dc07c741b68cdb50683" +_UNCHANGED = "91adfdb4ac0f6bfdfd9946026092c3f08b7565bdc033fb929b0f2da07813c523" def test_without_tdse_a_representative_call_is_unchanged(): diff --git a/uv.lock b/uv.lock index 535744f30..e81895de8 100644 --- a/uv.lock +++ b/uv.lock @@ -971,16 +971,16 @@ wheels = [ [[package]] name = "optmat" -version = "0.8.0" +version = "0.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jaxtyping" }, { name = "numpy" }, { name = "scipy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/79/f8/3ccb6002d576390da7de9ed164c17843cc3d0e17d5ac5a028ed656d5a821/optmat-0.8.0.tar.gz", hash = "sha256:213e9eaefbfa917e8bb0542c6bad661e2e0af988d8dd1acb55d751c0848c799a", size = 17522428, upload-time = "2026-10-16T16:58:09.237Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/69/af434a4d97daad30ac5c3e1b84b1145de6b58acdd1b2517c8d9fcc032205/optmat-0.9.0.tar.gz", hash = "sha256:dc0f67e9bba8b45f75d107b24827dcfba1915fa407955a0eba4d3fd2bab8627b", size = 17617180, upload-time = "2026-10-24T11:15:18.977Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/34/1e780588d947a4c9e24ea8a8987087659404e2228b9f7468bfd3c1d51e43/optmat-0.8.0-py3-none-any.whl", hash = "sha256:47e687f1966c27ddf02b9370bc10ef06e90ab58511ddc145c5c74f0e54a2b9b4", size = 17646650, upload-time = "2026-10-16T16:58:05.254Z" }, + { url = "https://files.pythonhosted.org/packages/b3/83/6590f9bb96770d3f2de65895b67a1010985149c6ba040888664ca571bb12/optmat-0.9.0-py3-none-any.whl", hash = "sha256:869abd38277e0a08cb506469d5cc2fdc0b84710c5faa98fd059b85d84b4faf2f", size = 17804765, upload-time = "2026-10-24T11:15:14.776Z" }, ] [package.optional-dependencies] @@ -1615,7 +1615,7 @@ requires-dist = [ { name = "matplotlib", specifier = ">=3.10.7" }, { name = "numba", specifier = ">=0.67" }, { name = "numpy", specifier = ">=2.5" }, - { name = "optmat", extras = ["gas"], specifier = ">=0.8.0" }, + { name = "optmat", extras = ["gas"], specifier = ">=0.9.0" }, { name = "pyfftw", specifier = ">=0.15.1" }, { name = "scipy", specifier = ">=1.18" }, { name = "solprop-native", marker = "extra == 'native'", editable = "native" }, From 11aba7e8c36ef39bb28603cb61302c395e43aacf Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 14:19:32 +0100 Subject: [PATCH 46/50] Import scipy.signal and pyplot only where they are used scipy.signal (half a second to import) served one hilbert call and the spectrogram's gate, and matplotlib.pyplot (its backends) only the functions that draw; both now load there, so importing solprop.driver or solprop.plotting no longer pays for them. matplotlib's types stay imported, so the runtime type checks still see Axes and Figure. Co-Authored-By: Claude Opus 5.5 --- src/solprop/fourier.py | 6 ++++-- src/solprop/plotting/propagation.py | 5 ++++- src/solprop/plotting/spectrograms.py | 16 ++++++++++++---- src/solprop/plotting/style.py | 7 ++++++- 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/solprop/fourier.py b/src/solprop/fourier.py index affcdcd82..c5beba970 100644 --- a/src/solprop/fourier.py +++ b/src/solprop/fourier.py @@ -37,7 +37,6 @@ import numpy as np from jaxtyping import Complex, Float, Inexact, Num from scipy import fft as _fft -from scipy import signal as _signal from solprop.exceptions import GridError @@ -343,9 +342,12 @@ def analytic_signal( >>> bool(np.allclose(envelope, np.exp(2j * np.pi * 2 * t))) True """ + # scipy.signal takes half a second to import: only this function needs it. + from scipy.signal import hilbert # noqa: PLC0415 + values = np.asarray(x) _check_time_dtype(values, "field") - return _signal.hilbert(values, axis=-1) + return hilbert(values, axis=-1) def power_amplitude( diff --git a/src/solprop/plotting/propagation.py b/src/solprop/plotting/propagation.py index 1dfdee609..4e0878849 100644 --- a/src/solprop/plotting/propagation.py +++ b/src/solprop/plotting/propagation.py @@ -17,7 +17,6 @@ from collections.abc import Sequence from typing import Literal -import matplotlib.pyplot as plt import numpy as np from jaxtyping import Float from matplotlib.axes import Axes @@ -159,6 +158,8 @@ def prop_2d( # noqa: PLR0913 - the two axes, the two ranges and the colour scal factor, label = spectral_display(axis) figures = [] for index, name in enumerate(labels): + import matplotlib.pyplot as plt # noqa: PLC0415 - pyplot loads on first plot + figure, (spectral, temporal) = plt.subplots(1, 2, figsize=(12, 4)) _spectral_map( spectral, @@ -471,6 +472,8 @@ def _axis(ax: Axes | None, figsize: tuple[float, float]) -> Axes: """ if ax is not None: return ax + import matplotlib.pyplot as plt # noqa: PLC0415 - pyplot loads on first plot + return plt.subplots(figsize=figsize)[1] diff --git a/src/solprop/plotting/spectrograms.py b/src/solprop/plotting/spectrograms.py index 17ac70f22..b4dd94bf5 100644 --- a/src/solprop/plotting/spectrograms.py +++ b/src/solprop/plotting/spectrograms.py @@ -10,14 +10,13 @@ silently taking the first. """ -import matplotlib.pyplot as plt +from typing import TYPE_CHECKING + import numpy as np from jaxtyping import Float from matplotlib.axes import Axes from matplotlib.colors import Colormap from matplotlib.figure import Figure -from scipy.signal import ShortTimeFFT -from scipy.signal.windows import gaussian from solprop.constants import C from solprop.exceptions import ModeError @@ -33,6 +32,9 @@ from solprop.processing.axes import ModeSelection, SpectralAxis from solprop.processing.fields import get_field, nearest_plane +if TYPE_CHECKING: + from scipy.signal import ShortTimeFFT + __all__ = ["DEFAULT_GATE_POSITIONS", "spectrogram"] DEFAULT_GATE_POSITIONS = 256 @@ -138,6 +140,8 @@ def spectrogram( # noqa: PLR0913 - a plane, a mode, two axes and the colour sca frequency = transform.f positive = frequency > 0.0 values, ordinate = _ordinate(axis, frequency[positive], intensity[positive]) + import matplotlib.pyplot as plt # noqa: PLC0415 - pyplot loads on first plot + ax = plt.subplots(figsize=(8.0, 5.0))[1] if ax is None else ax if log: floor = float(np.max(values)) * 10.0 ** ((db_min - 10.0) / 10.0) @@ -155,7 +159,7 @@ def spectrogram( # noqa: PLR0913 - a plane, a mode, two axes and the colour sca return figure -def _transform(dt: float, width: float, span: float, positions: int) -> ShortTimeFFT: +def _transform(dt: float, width: float, span: float, positions: int) -> "ShortTimeFFT": # noqa: UP037 - the runtime type hook resolves this at import """Return the short-time transform of a Gaussian gate of the given width. Parameters @@ -179,6 +183,10 @@ def _transform(dt: float, width: float, span: float, positions: int) -> ShortTim solprop.exceptions.ModeError Never; the signature is stable for callers that catch it. """ + # scipy.signal takes half a second to import: only a spectrogram needs it. + from scipy.signal import ShortTimeFFT # noqa: PLC0415 + from scipy.signal.windows import gaussian # noqa: PLC0415 + sigma = width * _FWHM_TO_SIGMA half = max(int(np.ceil(_GATE_SIGMAS * sigma / dt)), 1) window = gaussian(2 * half + 1, sigma / dt) diff --git a/src/solprop/plotting/style.py b/src/solprop/plotting/style.py index 789a89c99..2a9fa8b4d 100644 --- a/src/solprop/plotting/style.py +++ b/src/solprop/plotting/style.py @@ -18,7 +18,6 @@ import math from typing import Any, Literal -import matplotlib.pyplot as plt import numpy as np from jaxtyping import Float from matplotlib.axes import Axes @@ -106,6 +105,8 @@ def cmap_white( >>> [float(channel) for channel in cmap_white()(0.0)] [1.0, 1.0, 1.0, 1.0] """ + import matplotlib.pyplot as plt # noqa: PLC0415 - pyplot loads on first plot + sampled = plt.get_cmap(name)(np.linspace(0.0, 1.0, stops)) sampled[0] = (1.0, 1.0, 1.0, 1.0) return LinearSegmentedColormap.from_list(f"{name}_white", sampled, N=levels) @@ -150,6 +151,8 @@ def cmap_colours( msg = f"at least one colour is needed, got {count}" raise ProcessingError(msg) positions = np.linspace(low, high, count) + import matplotlib.pyplot as plt # noqa: PLC0415 - pyplot loads on first plot + return [tuple(colour) for colour in plt.get_cmap(name)(positions)] @@ -201,6 +204,8 @@ def subplot_grid( rows = math.ceil(count / columns) if portrait: rows, columns = columns, rows + import matplotlib.pyplot as plt # noqa: PLC0415 - pyplot loads on first plot + figure, grid = plt.subplots( rows, columns, figsize=(columns * width, rows * height), squeeze=False ) From 9364fd091eaf9029a10f9b9d9b654ca68ccc36f4 Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 14:26:55 +0100 Subject: [PATCH 47/50] Evaluate set-up Chebyshev tables with NumPy until numba is loaded The first z-table evaluation compiled (or loaded) the numba Clenshaw kernel, and so imported numba, which a run on the native backend needs for nothing else. Until numba is loaded, clenshaw now evaluates with NumPy's chebval, the same values bit for bit; once it is, the compiled kernel is used as before. A test checks the two paths agree exactly. Co-Authored-By: Claude Opus 5.5 --- src/solprop/translate/chebyshev.py | 7 ++++++- tests/translate/test_chebyshev.py | 23 +++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/solprop/translate/chebyshev.py b/src/solprop/translate/chebyshev.py index 5e015071e..2fd7f1406 100644 --- a/src/solprop/translate/chebyshev.py +++ b/src/solprop/translate/chebyshev.py @@ -21,6 +21,7 @@ import itertools import logging import math +import sys from collections.abc import Callable, Sequence from dataclasses import dataclass from typing import Final @@ -155,7 +156,9 @@ def clenshaw( """Evaluate one interval's Chebyshev series at one point, as NumPy's ``chebval``. The same values bit for bit, compiled: a degree-64 series of 18 values costs under a - microsecond against NumPy's 36 µs. + microsecond against NumPy's 36 µs. Until numba is loaded NumPy evaluates instead: + a run on the native backend needs numba for nothing else, and importing it costs + more than a set-up's thousand NumPy evaluations. Parameters ---------- @@ -177,6 +180,8 @@ def clenshaw( >>> float(clenshaw(0.5, np.array([1.0, 2.0, 3.0]))) 0.5 """ + if "numba" not in sys.modules: + return np.asarray(chebyshev_basis.chebval(float(x), coefficients)) flat = np.ascontiguousarray(coefficients).reshape(coefficients.shape[0], -1) value = np.empty(flat.shape[1], dtype=coefficients.dtype) _clenshaw()(float(x), flat, value) diff --git a/tests/translate/test_chebyshev.py b/tests/translate/test_chebyshev.py index 811e6d263..bbcf693e5 100644 --- a/tests/translate/test_chebyshev.py +++ b/tests/translate/test_chebyshev.py @@ -4,6 +4,7 @@ import logging import math +import sys import numpy as np import pytest @@ -931,3 +932,25 @@ def test_a_degree_one_series_is_one_product_and_one_sum(compiled): out = np.empty(2, dtype=coefficients.dtype) columns(0.5, coefficients, out) np.testing.assert_array_equal(out, coefficients[0] + 0.5 * coefficients[1]) + + +@pytest.mark.parametrize("shape", [(), (18,), (3, 2)]) +@pytest.mark.parametrize("complex_valued", [False, True]) +def test_clenshaw_before_numba_loads_gives_the_compiled_values( + shape, complex_valued, monkeypatch +): + # Until numba is imported, clenshaw evaluates with NumPy's chebval (a run on the + # native backend never imports numba); the values must be the compiled ones. + pytest.importorskip("numba") + rng = np.random.default_rng(7) + coefficients = rng.standard_normal((33, *shape)) + if complex_valued: + coefficients = coefficients + 1j * rng.standard_normal(coefficients.shape) + points = rng.uniform(-1.0, 1.0, 16) + compiled = [chebyshev.clenshaw(x, coefficients) for x in points] + monkeypatch.delitem(sys.modules, "numba") + uncompiled = [chebyshev.clenshaw(x, coefficients) for x in points] + for one, other in zip(compiled, uncompiled, strict=True): + assert one.shape == other.shape == shape + assert one.dtype == other.dtype + np.testing.assert_array_equal(one, other) From 53ea2ce584c063cd997966442bed1184fac3e9c9 Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 14:34:05 +0100 Subject: [PATCH 48/50] Record the shorter start-up Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1066cf66b..21b06da62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -155,6 +155,11 @@ The first public release. It contains: ([SEP 29](https://solprop.readthedocs.io/en/latest/dev/seps/archive/sep-0029-compiled-backend-distribution.html)). - Each backend is tested against the reference implementation within derived error bounds. + - A short start-up: a run on the native backend imports neither Numba nor + `scipy.signal`, plotting imports `matplotlib.pyplot` only when it draws, and an + optmat rate builds only the model it uses (optmat 0.9.0, which reads only the data a + model needs). A five-centimetre capillary run with ionisation is under way in about + 3 s on an Apple M5 Max, from 5.5 s. - **The native backend.** - `NativeBackend` runs one trial step per native call, with states, views and FFT plans resident in the execution space; `NativeBatchBackend` runs fixed-schedule From 62e9a472c348d10f10e78accf4d663b9c9d236c2 Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 15:41:58 +0100 Subject: [PATCH 49/50] optmat 0.9.1: ionisation parameters read one gas's fit optmat 0.9.1 builds only the fitted model an ionisation rate's polarizability needs. The capillary configuration's structure hash moves with the optmat version in its citations (no other change). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- docs/dev/dependencies.md | 2 +- pyproject.toml | 2 +- tests/driver/test_tdse_entry.py | 2 +- uv.lock | 8 ++++---- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21b06da62..cb10aec04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -157,7 +157,7 @@ The first public release. It contains: bounds. - A short start-up: a run on the native backend imports neither Numba nor `scipy.signal`, plotting imports `matplotlib.pyplot` only when it draws, and an - optmat rate builds only the model it uses (optmat 0.9.0, which reads only the data a + optmat rate builds only the model it uses (optmat 0.9.1, which reads only the data a model needs). A five-centimetre capillary run with ionisation is under way in about 3 s on an Apple M5 Max, from 5.5 s. - **The native backend.** diff --git a/docs/dev/dependencies.md b/docs/dev/dependencies.md index 346ce8f07..ba7cd183e 100644 --- a/docs/dev/dependencies.md +++ b/docs/dev/dependencies.md @@ -24,7 +24,7 @@ need no exception for them. | h5py ≥ 3.15 | HDF5 output | first release with 3.14 wheels on all three platforms | | numba ≥ 0.67 | CPU kernels (ADR-002, ADR-023) | the first release that supports NumPy 2.5 (0.63 is the first with Python 3.14, but caps NumPy below 2.4, and 0.64–0.66 below 2.5); the internals `solprop._numba_cache` uses were checked down to 0.63; `cache=True` except for parallel and dispatcher-capturing closures, and SEP 25's floating-point licence (contraction by default, reassociation per kernel, nothing else) ([backends.md §3.2](backends.md)) | | pyfftw ≥ 0.15.1 | FFTW plans executed in place (ADR-023) | wisdom handled explicitly; 0.15.1 has the 3.14 wheels (none for Windows on Arm) | -| optmat[gas] ≥ 0.9.0 | built-in material models: gas dispersion, Kerr, equation of state and transport; solids; gas and silica Raman responses; gas ionisation rates ([SEP 3](seps/archive/sep-0003-optmat-materials.md)) | the same maintainers' curated-data package; never imported at `import solprop`, since its registries take about a second to import; 0.9.0 reads only the data a model needs and says which gases each rate model covers | +| optmat[gas] ≥ 0.9.1 | built-in material models: gas dispersion, Kerr, equation of state and transport; solids; gas and silica Raman responses; gas ionisation rates ([SEP 3](seps/archive/sep-0003-optmat-materials.md)) | the same maintainers' curated-data package; never imported at `import solprop`, since its registries take about a second to import; 0.9 reads only the data a model needs and says which gases each rate model covers | | threadpoolctl ≥ 3.1 | inspect and control the backend-owned BLAS pool | pool provenance and limits are recorded per run | | jaxtyping ≥ 0.3.11 | array annotations, evaluated at import time in every module | pure Python; the checker (beartype) is dev-only; under earlier releases the test session's import hook wraps the compiled kernels, which numba then cannot compile or cache | | tqdm ≥ 4.67 | the progress bar (`solprop.log.BarReporter`) | pure Python; imported when the first bar is drawn, never at `import solprop`; `ipywidgets` is optional for the notebook widget and is not a dependency | diff --git a/pyproject.toml b/pyproject.toml index 6b7bd8f56..d38931f99 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,7 @@ dependencies = [ "pyfftw>=0.15.1", # Every built-in material model (SEP 3): gases with their equation of state and # transport, solids, Raman responses and ionisation rates. - "optmat[gas]>=0.9.0", + "optmat[gas]>=0.9.1", # The BLAS pool is one of the four the numba_cpu backend owns (backends.md 3.4: # a factor of nine on one step from the pool configuration alone), and no BLAS # exposes a runtime thread count except through the library walk this does. diff --git a/tests/driver/test_tdse_entry.py b/tests/driver/test_tdse_entry.py index 218b30497..f428cd693 100644 --- a/tests/driver/test_tdse_entry.py +++ b/tests/driver/test_tdse_entry.py @@ -222,7 +222,7 @@ def test_tdse_output_defaults_and_their_explicit_overrides(): # included, which another platform's libm and FFT move. Its structure is still hashed # exactly; its numbers are stored beside this test (recorded from the state the # former digest certified) and compared within a bound (tests/record_numbers.py). -_UNCHANGED = "91adfdb4ac0f6bfdfd9946026092c3f08b7565bdc033fb929b0f2da07813c523" +_UNCHANGED = "fe3cea40a780789cb5b8a8d3da0d4cb1abf5aea0654ad6da91c80a2f82f6d9b8" def test_without_tdse_a_representative_call_is_unchanged(): diff --git a/uv.lock b/uv.lock index e81895de8..98d2e24b3 100644 --- a/uv.lock +++ b/uv.lock @@ -971,16 +971,16 @@ wheels = [ [[package]] name = "optmat" -version = "0.9.0" +version = "0.9.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jaxtyping" }, { name = "numpy" }, { name = "scipy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b9/69/af434a4d97daad30ac5c3e1b84b1145de6b58acdd1b2517c8d9fcc032205/optmat-0.9.0.tar.gz", hash = "sha256:dc0f67e9bba8b45f75d107b24827dcfba1915fa407955a0eba4d3fd2bab8627b", size = 17617180, upload-time = "2026-10-24T11:15:18.977Z" } +sdist = { url = "https://files.pythonhosted.org/packages/78/2d/0aa0d6d145fe88795faed2e86dc6b506c5d477742a93355cc173a6af5522/optmat-0.9.1.tar.gz", hash = "sha256:2d562f577586101397d710d3777b364aa1d9bb1fd5f3365298cab1e0c6f46159", size = 17617286, upload-time = "2026-10-24T14:36:41.381Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/83/6590f9bb96770d3f2de65895b67a1010985149c6ba040888664ca571bb12/optmat-0.9.0-py3-none-any.whl", hash = "sha256:869abd38277e0a08cb506469d5cc2fdc0b84710c5faa98fd059b85d84b4faf2f", size = 17804765, upload-time = "2026-10-24T11:15:14.776Z" }, + { url = "https://files.pythonhosted.org/packages/2e/53/08fe14dbbb426c5054b36bfa7d91ffcfae08e54343ac6cf3b35a64821339/optmat-0.9.1-py3-none-any.whl", hash = "sha256:bae638a6c7f47ba345937f3c63053bc502ce7db5ece4cacee22869e877990825", size = 17804758, upload-time = "2026-10-24T14:36:37.533Z" }, ] [package.optional-dependencies] @@ -1615,7 +1615,7 @@ requires-dist = [ { name = "matplotlib", specifier = ">=3.10.7" }, { name = "numba", specifier = ">=0.67" }, { name = "numpy", specifier = ">=2.5" }, - { name = "optmat", extras = ["gas"], specifier = ">=0.9.0" }, + { name = "optmat", extras = ["gas"], specifier = ">=0.9.1" }, { name = "pyfftw", specifier = ">=0.15.1" }, { name = "scipy", specifier = ">=1.18" }, { name = "solprop-native", marker = "extra == 'native'", editable = "native" }, From 78e57a55d2d29a098415fc4220524024d31bb5d7 Mon Sep 17 00:00:00 2001 From: John Travers Date: Sat, 24 Oct 2026 15:57:41 +0100 Subject: [PATCH 50/50] Import air's equation of state from optmat.gas.eos, which exports it optmat ships a py.typed marker since 0.8.1, so basedpyright holds imports to optmat's __all__: optmat.gas.air uses air_equation_of_state but does not export it. Co-Authored-By: Claude Opus 5.5 --- tests/physdata/test_optmat_gases.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/physdata/test_optmat_gases.py b/tests/physdata/test_optmat_gases.py index 1b084ffa7..34c42ee93 100644 --- a/tests/physdata/test_optmat_gases.py +++ b/tests/physdata/test_optmat_gases.py @@ -14,8 +14,8 @@ from former_gas_laws import AR_BORZSONYI, XE_BORZSONYI from optmat.conditions import State from optmat.exceptions import StateError -from optmat.gas.air import air_equation_of_state, air_model -from optmat.gas.eos import equation_of_state +from optmat.gas.air import air_model +from optmat.gas.eos import air_equation_of_state, equation_of_state from optmat.gas.models.registry import default_model, model from solprop.constants import EPS0, K_B, C