diff --git a/CHANGELOG.md b/CHANGELOG.md index d74574696..cb10aec04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,88 @@ 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/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 + 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, `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/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 + 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`). +- **Coupling models** + ([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 + 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`. +- **Transport between planes** + ([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`, + `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, 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/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 + 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`. `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. + 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, + 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.** - Gases, gas mixtures, glasses and metals from the [optmat](https://github.com/LupoLab/optmat) material library. @@ -73,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.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.** - `NativeBackend` runs one trial step per native call, with states, views and FFT plans resident in the execution space; `NativeBatchBackend` runs fixed-schedule diff --git a/benchmarks/guides.py b/benchmarks/guides.py index 7e1fc9e02..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 ( @@ -37,6 +38,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 +224,24 @@ 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, coupled under the vector paraxial model. + 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, + coupling=CouplingModel("vector_paraxial", model_error=0.2), 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/docs/api/index.md b/docs/api/index.md index 23aacca36..239c8d40d 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` (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`; `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 | @@ -73,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..7b5e2db9f --- /dev/null +++ b/docs/api/transport.md @@ -0,0 +1,19 @@ +# 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: +``` + +Transport between planes was proposed and carried out by +[SEP 31](../dev/seps/archive/sep-0031-simulation-coupling.md). 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 f32bdde10..c95dc578b 100644 Binary files a/docs/auto_examples/waveguides/images/sphx_glr_plot_04_graded_index_multimode_001.png and b/docs/auto_examples/waveguides/images/sphx_glr_plot_04_graded_index_multimode_001.png differ 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 698f87ed8..aafa7c957 100644 Binary files a/docs/auto_examples/waveguides/images/sphx_glr_plot_04_graded_index_multimode_002.png and b/docs/auto_examples/waveguides/images/sphx_glr_plot_04_graded_index_multimode_002.png differ 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 3973c05b9..ee6ec5685 100644 Binary files a/docs/auto_examples/waveguides/images/sphx_glr_plot_04_graded_index_multimode_003.png and b/docs/auto_examples/waveguides/images/sphx_glr_plot_04_graded_index_multimode_003.png differ diff --git a/docs/auto_examples/waveguides/images/sphx_glr_plot_04_graded_index_multimode_004.png b/docs/auto_examples/waveguides/images/sphx_glr_plot_04_graded_index_multimode_004.png index e4967d943..db9bafa1f 100644 Binary files a/docs/auto_examples/waveguides/images/sphx_glr_plot_04_graded_index_multimode_004.png and b/docs/auto_examples/waveguides/images/sphx_glr_plot_04_graded_index_multimode_004.png differ 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 cde347b32..97888e1dd 100644 Binary files a/docs/auto_examples/waveguides/images/sphx_glr_plot_04_graded_index_multimode_005.png and b/docs/auto_examples/waveguides/images/sphx_glr_plot_04_graded_index_multimode_005.png differ 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 8f7863794..5e5be3874 100644 Binary files a/docs/auto_examples/waveguides/images/thumb/sphx_glr_plot_04_graded_index_multimode_thumb.png and b/docs/auto_examples/waveguides/images/thumb/sphx_glr_plot_04_graded_index_multimode_thumb.png differ diff --git a/docs/auto_examples/waveguides/plot_04_graded_index_multimode.codeobj.json b/docs/auto_examples/waveguides/plot_04_graded_index_multimode.codeobj.json index 6ee4f3d63..ea4981dda 100644 --- a/docs/auto_examples/waveguides/plot_04_graded_index_multimode.codeobj.json +++ b/docs/auto_examples/waveguides/plot_04_graded_index_multimode.codeobj.json @@ -15,6 +15,36 @@ "name": "C" } ], + "CouplingModel": [ + { + "is_class": true, + "is_explicit": false, + "module": "solprop.coupling.models", + "module_short": "solprop.coupling", + "name": "CouplingModel" + }, + { + "is_class": true, + "is_explicit": false, + "module": "solprop.coupling", + "module_short": "solprop.coupling", + "name": "CouplingModel" + }, + { + "is_class": true, + "is_explicit": false, + "module": "solprop", + "module_short": "solprop", + "name": "CouplingModel" + }, + { + "is_class": false, + "is_explicit": false, + "module": "solprop.coupling", + "module_short": "solprop.coupling", + "name": "CouplingModel" + } + ], "DELTA": [ { "is_class": false, @@ -84,69 +114,55 @@ "name": "from_limits" } ], - "GeneralModalBasisWarning": [ + "GaussianPulse": [ { "is_class": true, "is_explicit": false, - "module": "solprop.exceptions", - "module_short": "solprop.exceptions", - "name": "GeneralModalBasisWarning" + "module": "solprop.pulses", + "module_short": "solprop.pulses", + "name": "GaussianPulse" }, { "is_class": true, "is_explicit": false, "module": "solprop", "module_short": "solprop", - "name": "GeneralModalBasisWarning" + "name": "GaussianPulse" }, { "is_class": true, "is_explicit": false, - "module": "solprop.exceptions", - "module_short": "solprop.exceptions", - "name": "SolpropWarning" + "module": "solprop.pulses", + "module_short": "solprop.pulses", + "name": "ShapedPulse" }, { "is_class": true, "is_explicit": false, "module": "solprop", "module_short": "solprop", - "name": "SolpropWarning" - }, - { - "is_class": true, - "is_explicit": false, - "module": "builtins", - "module_short": "builtins", - "name": "UserWarning" - }, - { - "is_class": true, - "is_explicit": false, - "module": "builtins", - "module_short": "builtins", - "name": "Warning" + "name": "ShapedPulse" }, { "is_class": true, "is_explicit": false, - "module": "builtins", - "module_short": "builtins", - "name": "Exception" + "module": "solprop._display", + "module_short": "solprop._display", + "name": "Displayed" }, { "is_class": true, "is_explicit": false, - "module": "builtins", - "module_short": "builtins", - "name": "BaseException" + "module": "solprop", + "module_short": "solprop", + "name": "Displayed" }, { "is_class": false, "is_explicit": false, - "module": "solprop.exceptions", - "module_short": "solprop.exceptions", - "name": "GeneralModalBasisWarning" + "module": "solprop.pulses", + "module_short": "solprop.pulses", + "name": "GaussianPulse" } ], "GradedIndexProvider": [ @@ -377,6 +393,43 @@ "name": "ScaledPermittivity" } ], + "SpatialPulse": [ + { + "is_class": true, + "is_explicit": false, + "module": "solprop.pulses", + "module_short": "solprop.pulses", + "name": "SpatialPulse" + }, + { + "is_class": true, + "is_explicit": false, + "module": "solprop", + "module_short": "solprop", + "name": "SpatialPulse" + }, + { + "is_class": true, + "is_explicit": false, + "module": "solprop._display", + "module_short": "solprop._display", + "name": "Displayed" + }, + { + "is_class": true, + "is_explicit": false, + "module": "solprop", + "module_short": "solprop", + "name": "Displayed" + }, + { + "is_class": false, + "is_explicit": false, + "module": "solprop.pulses", + "module_short": "solprop.pulses", + "name": "SpatialPulse" + } + ], "WAIST": [ { "is_class": false, @@ -904,9 +957,16 @@ { "is_class": false, "is_explicit": false, - "module": "numpy", - "module_short": "numpy", - "name": "ndarray" + "module": "solprop.pulses", + "module_short": "solprop.pulses", + "name": "SpatialPulse" + }, + { + "is_class": false, + "is_explicit": false, + "module": "solprop", + "module_short": "solprop", + "name": "SpatialPulse" } ], "beta": [ @@ -927,6 +987,15 @@ "name": "float" } ], + "captured": [ + { + "is_class": false, + "is_explicit": false, + "module": "builtins", + "module_short": "builtins", + "name": "NoneType" + } + ], "centre": [ { "is_class": false, @@ -945,6 +1014,29 @@ "name": "int" } ], + "coupling": [ + { + "is_class": false, + "is_explicit": false, + "module": "solprop.coupling.models", + "module_short": "solprop.coupling", + "name": "CouplingModel" + }, + { + "is_class": false, + "is_explicit": false, + "module": "solprop.coupling", + "module_short": "solprop.coupling", + "name": "CouplingModel" + }, + { + "is_class": false, + "is_explicit": false, + "module": "solprop", + "module_short": "solprop", + "name": "CouplingModel" + } + ], "dataclass": [ { "is_class": false, @@ -979,13 +1071,22 @@ "name": "ndarray" } ], - "energy": [ + "entrance": [ { "is_class": false, "is_explicit": false, "module": "numpy", "module_short": "numpy", - "name": "float64" + "name": "ndarray" + } + ], + "entrance.sum": [ + { + "is_class": false, + "is_explicit": false, + "module": "builtins", + "module_short": "builtins", + "name": "builtin_function_or_method" } ], "field": [ @@ -1538,6 +1639,232 @@ "name": "list" } ], + "launch": [ + { + "is_class": false, + "is_explicit": false, + "module": "solprop.coupling.coupler", + "module_short": "solprop.coupling", + "name": "CouplingReport" + }, + { + "is_class": false, + "is_explicit": false, + "module": "solprop.coupling", + "module_short": "solprop.coupling", + "name": "CouplingReport" + }, + { + "is_class": false, + "is_explicit": false, + "module": "solprop", + "module_short": "solprop", + "name": "CouplingReport" + } + ], + "launch.budget": [ + { + "is_class": false, + "is_explicit": false, + "module": "solprop.coupling.coupler", + "module_short": "solprop.coupling", + "name": "CouplingReport.budget" + }, + { + "is_class": false, + "is_explicit": false, + "module": "solprop.coupling", + "module_short": "solprop.coupling", + "name": "CouplingReport.budget" + }, + { + "is_class": false, + "is_explicit": false, + "module": "solprop", + "module_short": "solprop", + "name": "CouplingReport.budget" + }, + { + "is_class": false, + "is_explicit": false, + "module": "solprop._display", + "module_short": "solprop._display", + "name": "Displayed.budget" + }, + { + "is_class": false, + "is_explicit": false, + "module": "solprop", + "module_short": "solprop", + "name": "Displayed.budget" + } + ], + "launch.captured_energy": [ + { + "is_class": false, + "is_explicit": false, + "module": "solprop.coupling.coupler", + "module_short": "solprop.coupling", + "name": "CouplingReport.captured_energy" + }, + { + "is_class": false, + "is_explicit": false, + "module": "solprop.coupling", + "module_short": "solprop.coupling", + "name": "CouplingReport.captured_energy" + }, + { + "is_class": false, + "is_explicit": false, + "module": "solprop", + "module_short": "solprop", + "name": "CouplingReport.captured_energy" + }, + { + "is_class": false, + "is_explicit": false, + "module": "solprop._display", + "module_short": "solprop._display", + "name": "Displayed.captured_energy" + }, + { + "is_class": false, + "is_explicit": false, + "module": "solprop", + "module_short": "solprop", + "name": "Displayed.captured_energy" + } + ], + "launch.captured_reason": [ + { + "is_class": false, + "is_explicit": false, + "module": "builtins", + "module_short": "builtins", + "name": "str" + } + ], + "launch.efficiency": [ + { + "is_class": false, + "is_explicit": false, + "module": "solprop.coupling.coupler", + "module_short": "solprop.coupling", + "name": "CouplingReport.efficiency" + }, + { + "is_class": false, + "is_explicit": false, + "module": "solprop.coupling", + "module_short": "solprop.coupling", + "name": "CouplingReport.efficiency" + }, + { + "is_class": false, + "is_explicit": false, + "module": "solprop", + "module_short": "solprop", + "name": "CouplingReport.efficiency" + }, + { + "is_class": false, + "is_explicit": false, + "module": "solprop._display", + "module_short": "solprop._display", + "name": "Displayed.efficiency" + }, + { + "is_class": false, + "is_explicit": false, + "module": "solprop", + "module_short": "solprop", + "name": "Displayed.efficiency" + } + ], + "launch.incident_energy": [ + { + "is_class": false, + "is_explicit": false, + "module": "solprop.coupling.coupler", + "module_short": "solprop.coupling", + "name": "CouplingReport.incident_energy" + }, + { + "is_class": false, + "is_explicit": false, + "module": "solprop.coupling", + "module_short": "solprop.coupling", + "name": "CouplingReport.incident_energy" + }, + { + "is_class": false, + "is_explicit": false, + "module": "solprop", + "module_short": "solprop", + "name": "CouplingReport.incident_energy" + }, + { + "is_class": false, + "is_explicit": false, + "module": "solprop._display", + "module_short": "solprop._display", + "name": "Displayed.incident_energy" + }, + { + "is_class": false, + "is_explicit": false, + "module": "solprop", + "module_short": "solprop", + "name": "Displayed.incident_energy" + } + ], + "launch.launched_energy": [ + { + "is_class": false, + "is_explicit": false, + "module": "solprop.coupling.coupler", + "module_short": "solprop.coupling", + "name": "CouplingReport.launched_energy" + }, + { + "is_class": false, + "is_explicit": false, + "module": "solprop.coupling", + "module_short": "solprop.coupling", + "name": "CouplingReport.launched_energy" + }, + { + "is_class": false, + "is_explicit": false, + "module": "solprop", + "module_short": "solprop", + "name": "CouplingReport.launched_energy" + }, + { + "is_class": false, + "is_explicit": false, + "module": "solprop._display", + "module_short": "solprop._display", + "name": "Displayed.launched_energy" + }, + { + "is_class": false, + "is_explicit": false, + "module": "solprop", + "module_short": "solprop", + "name": "Displayed.launched_energy" + } + ], + "launch.model_error": [ + { + "is_class": false, + "is_explicit": false, + "module": "builtins", + "module_short": "builtins", + "name": "float" + } + ], "line": [ { "is_class": false, @@ -1718,22 +2045,6 @@ "name": "diff" } ], - "np.exp": [ - { - "is_class": false, - "is_explicit": false, - "module": "numpy", - "module_short": "numpy", - "name": "ufunc" - }, - { - "is_class": false, - "is_explicit": false, - "module": "numpy", - "module_short": "numpy", - "name": "exp" - } - ], "np.geomspace": [ { "is_class": false, @@ -1862,22 +2173,6 @@ "name": "real" } ], - "np.repeat": [ - { - "is_class": false, - "is_explicit": false, - "module": "numpy", - "module_short": "numpy", - "name": "_ArrayFunctionDispatcher" - }, - { - "is_class": false, - "is_explicit": false, - "module": "numpy", - "module_short": "numpy", - "name": "repeat" - } - ], "np.sqrt": [ { "is_class": false, @@ -1910,22 +2205,6 @@ "name": "sum" } ], - "np.zeros": [ - { - "is_class": false, - "is_explicit": false, - "module": "builtins", - "module_short": "builtins", - "name": "builtin_function_or_method" - }, - { - "is_class": false, - "is_explicit": false, - "module": "numpy", - "module_short": "numpy", - "name": "zeros" - } - ], "omega0": [ { "is_class": false, @@ -2021,63 +2300,6 @@ "name": "str" } ], - "project_transverse_field": [ - { - "is_class": false, - "is_explicit": false, - "module": "builtins", - "module_short": "builtins", - "name": "function" - }, - { - "is_class": false, - "is_explicit": false, - "module": "solprop.translate.modal_tables", - "module_short": "solprop.translate.modal_tables", - "name": "project_transverse_field" - } - ], - "projection": [ - { - "is_class": false, - "is_explicit": false, - "module": "solprop.translate.modal_tables", - "module_short": "solprop.translate.modal_tables", - "name": "ProjectedLaunch" - }, - { - "is_class": false, - "is_explicit": false, - "module": "solprop.translate", - "module_short": "solprop.translate", - "name": "ProjectedLaunch" - }, - { - "is_class": false, - "is_explicit": false, - "module": "solprop", - "module_short": "solprop", - "name": "ProjectedLaunch" - } - ], - "projection.amplitudes": [ - { - "is_class": false, - "is_explicit": false, - "module": "numpy", - "module_short": "numpy", - "name": "ndarray" - } - ], - "projection.residual_share": [ - { - "is_class": false, - "is_explicit": false, - "module": "builtins", - "module_short": "builtins", - "name": "float" - } - ], "prop_2d": [ { "is_class": false, @@ -2204,76 +2426,95 @@ "name": "reconstructed_flux" } ], - "route": [ + "run": [ { "is_class": false, "is_explicit": false, - "module": "solprop.translate.general", - "module_short": "solprop.translate.general", - "name": "GeneralModalRoute" + "module": "solprop.output.memory", + "module_short": "solprop.output", + "name": "MemoryOutput" }, { "is_class": false, "is_explicit": false, - "module": "solprop.translate", - "module_short": "solprop.translate", - "name": "GeneralModalRoute" + "module": "solprop.output", + "module_short": "solprop.output", + "name": "MemoryOutput" }, { "is_class": false, "is_explicit": false, "module": "solprop", "module_short": "solprop", - "name": "GeneralModalRoute" + "name": "MemoryOutput" } ], - "route.tables": [ + "run.metadata.backend.name": [ { "is_class": false, "is_explicit": false, - "module": "solprop.translate.modal_tables", - "module_short": "solprop.translate.modal_tables", - "name": "ModalTransformTables" + "module": "solprop.output.memory", + "module_short": "solprop.output", + "name": "MemoryOutput.metadata" }, { "is_class": false, "is_explicit": false, - "module": "solprop.translate", - "module_short": "solprop.translate", - "name": "ModalTransformTables" + "module": "solprop.output", + "module_short": "solprop.output", + "name": "MemoryOutput.metadata" }, { "is_class": false, "is_explicit": false, "module": "solprop", "module_short": "solprop", - "name": "ModalTransformTables" - } - ], - "run": [ + "name": "MemoryOutput.metadata" + }, { "is_class": false, "is_explicit": false, - "module": "solprop.output.memory", + "module": "solprop.output.schema", "module_short": "solprop.output", - "name": "MemoryOutput" + "name": "PlaneStore.metadata" }, { "is_class": false, "is_explicit": false, "module": "solprop.output", "module_short": "solprop.output", - "name": "MemoryOutput" + "name": "PlaneStore.metadata" }, { "is_class": false, "is_explicit": false, "module": "solprop", "module_short": "solprop", - "name": "MemoryOutput" + "name": "PlaneStore.metadata" + }, + { + "is_class": false, + "is_explicit": false, + "module": "solprop._display", + "module_short": "solprop._display", + "name": "Displayed.metadata" + }, + { + "is_class": false, + "is_explicit": false, + "module": "solprop", + "module_short": "solprop", + "name": "Displayed.metadata" + }, + { + "is_class": false, + "is_explicit": false, + "module": "abc", + "module_short": "abc", + "name": "ABC.metadata" } ], - "run.metadata.backend.name": [ + "run.metadata.backend.settings": [ { "is_class": false, "is_explicit": false, @@ -2338,7 +2579,7 @@ "name": "ABC.metadata" } ], - "run.metadata.backend.settings": [ + "run.metadata.configuration": [ { "is_class": false, "is_explicit": false, @@ -2421,24 +2662,6 @@ "name": "builtin_function_or_method" } ], - "shares": [ - { - "is_class": false, - "is_explicit": false, - "module": "numpy", - "module_short": "numpy", - "name": "ndarray" - } - ], - "shares.sum": [ - { - "is_class": false, - "is_explicit": false, - "module": "builtins", - "module_short": "builtins", - "name": "builtin_function_or_method" - } - ], "shift": [ { "is_class": false, @@ -2516,6 +2739,15 @@ "name": "reconstructed_flux" } ], + "solprop.pulses.SpatialPulse": [ + { + "is_class": false, + "is_explicit": true, + "module": "solprop.pulses", + "module_short": "solprop.pulses", + "name": "SpatialPulse" + } + ], "spacing": [ { "is_class": false, @@ -2568,38 +2800,6 @@ "name": "perf_counter" } ], - "warnings.catch_warnings": [ - { - "is_class": true, - "is_explicit": false, - "module": "_py_warnings", - "module_short": "_py_warnings", - "name": "catch_warnings" - }, - { - "is_class": false, - "is_explicit": false, - "module": "warnings", - "module_short": "warnings", - "name": "catch_warnings" - } - ], - "warnings.simplefilter": [ - { - "is_class": false, - "is_explicit": false, - "module": "builtins", - "module_short": "builtins", - "name": "function" - }, - { - "is_class": false, - "is_explicit": false, - "module": "warnings", - "module_short": "warnings", - "name": "simplefilter" - } - ], "wavelength": [ { "is_class": false, diff --git a/docs/auto_examples/waveguides/plot_04_graded_index_multimode.ipynb b/docs/auto_examples/waveguides/plot_04_graded_index_multimode.ipynb index 325e5789c..36a506906 100644 --- a/docs/auto_examples/waveguides/plot_04_graded_index_multimode.ipynb +++ b/docs/auto_examples/waveguides/plot_04_graded_index_multimode.ipynb @@ -4,7 +4,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "\n# Spatiotemporal propagation in a graded-index multimode fibre\n\nA parabolic graded-index fibre provides a useful setting for studying\nperiodic transverse motion and nonlinear spectral broadening. Its mode groups are almost\nequally spaced in\npropagation constant, so a beam launched into several of them **self-images**: the\ntransverse pattern contracts and expands with a period of a fraction of a\nmillimetre, and the intensity on the axis oscillates with it. At high power that\nperiodic oscillation is a longitudinal grating written into the Kerr effect, and it\nphase-matches **geometric parametric instability** (GPI): sidebands far from the pump,\nwith the leading-order, weak-nonlinearity estimate for frequency offsets\n$\\Omega_h = \\sqrt{2\\pi h/(z_p\\beta_2)}$, where\n$z_p$ is the self-imaging period and $\\beta_2$ the group-velocity\ndispersion (Krupa *et al.*, Phys. Rev. Lett. **116**, 183901 (2016),\n[doi:10.1103/PhysRevLett.116.183901](https://doi.org/10.1103/PhysRevLett.116.183901); Wright *et al.*,\nNat. Photon. **9**, 306 (2015)).\n\nThis example propagates that system on the **general modal route**\n(:doc:`/guide/waveguides/general-modal`): a\n:class:`~solprop.modes.graded_index.GradedIndexProvider` solves the fibre's own\nfields at every frequency across the band, the run synthesises the physical field at\nthe guide's quadrature nodes, evaluates the silica core's Kerr response there --\nincluding its third harmonic -- and projects it back onto the channels. Nothing is\nreduced to an effective area or a table of overlap integrals.\n\nThe fibre is a 100 um core (50 um radius), parabolic, index contrast\n$\\Delta = 0.0095$ (a numerical aperture of 0.2), on dispersive fused silica.\nThe pump is a 300 fs pulse at 1064 nm with 500 kW peak power -- well below the\ncritical power for self-focusing, about 4 MW -- in a centred Gaussian beam of 12 um\nwaist, declared as a **transverse field** and projected onto the fibre's channels.\n\n**What to look for.** The on-axis intensity oscillates at the self-imaging period\nfrom the entrance; the spectrum grows sidebands near the GPI frequencies predicted\nfrom that period and the silica dispersion; the energy the launch put in each\nchannel is redistributed by the nonlinearity.\n\n**What is approximate, and what was chosen.** A centred beam excites only the\nradially symmetric modes, so the guide is the weak-guidance ``LP_0m`` family (the\nprovider's scalar approximation, whose contrast limit this fibre is inside); four\nof them, because the guide must be solved over the run's whole *active* band, which\nreaches 3.6 um, and a fifth ``LP_0m`` channel is not bound there. The projection\nreports the\nfraction of the requested transverse profile\noutside those four channels. Propagation then uses only the captured profile,\nrenormalised to the declared pulse energy; the uncaptured spatial content is\nnot propagated. This is a truncated model, not a mode-count convergence study. The\nquadrature\nuses two angular nodes, exact for fields with no azimuthal dependence. Raman\nscattering is left out, to keep the run to minutes: a silica core would declare\n:class:`~solprop.nonlinear.raman.SilicaTensorResponse` beside its Kerr term. The\nself-imaging period is set by the finite-element solve of the parabolic core, whose\npropagation constants converge at second order in the element count.\n" + "\n# Spatiotemporal propagation in a graded-index multimode fibre\n\nA parabolic graded-index fibre provides a useful setting for studying\nperiodic transverse motion and nonlinear spectral broadening. Its mode groups are almost\nequally spaced in\npropagation constant, so a beam launched into several of them **self-images**: the\ntransverse pattern contracts and expands with a period of a fraction of a\nmillimetre, and the intensity on the axis oscillates with it. At high power that\nperiodic oscillation is a longitudinal grating written into the Kerr effect, and it\nphase-matches **geometric parametric instability** (GPI): sidebands far from the pump,\nwith the leading-order, weak-nonlinearity estimate for frequency offsets\n$\\Omega_h = \\sqrt{2\\pi h/(z_p\\beta_2)}$, where\n$z_p$ is the self-imaging period and $\\beta_2$ the group-velocity\ndispersion (Krupa *et al.*, Phys. Rev. Lett. **116**, 183901 (2016),\n[doi:10.1103/PhysRevLett.116.183901](https://doi.org/10.1103/PhysRevLett.116.183901); Wright *et al.*,\nNat. Photon. **9**, 306 (2015)).\n\nThis example propagates that system on the **general modal route**\n(:doc:`/guide/waveguides/general-modal`): a\n:class:`~solprop.modes.graded_index.GradedIndexProvider` solves the fibre's own\nfields at every frequency across the band, the run synthesises the physical field at\nthe guide's quadrature nodes, evaluates the silica core's Kerr response there --\nincluding its third harmonic -- and projects it back onto the channels. Nothing is\nreduced to an effective area or a table of overlap integrals.\n\nThe fibre is a 100 um core (50 um radius), parabolic, index contrast\n$\\Delta = 0.0095$ (a numerical aperture of 0.2), on dispersive fused silica.\nThe pump is a 300 fs pulse at 1064 nm with 500 kW peak power -- well below the\ncritical power for self-focusing, about 4 MW -- carried by a centred Gaussian beam of\n12 um waist, declared as an **incident beam** and coupled into the fibre's channels\nunder the vector paraxial model (:doc:`/guide/workflows/coupling`).\n\n**What to look for.** The on-axis intensity oscillates at the self-imaging period\nfrom the entrance; the spectrum grows sidebands near the GPI frequencies predicted\nfrom that period and the silica dispersion; the energy the launch put in each\nchannel is redistributed by the nonlinearity.\n\n**What is approximate, and what was chosen.** A centred beam excites only the\nradially symmetric modes, so the guide is the weak-guidance ``LP_0m`` family (the\nprovider's scalar approximation, whose contrast limit this fibre is inside); four\nof them, because the guide must be solved over the run's whole *active* band, which\nreaches 3.6 um, and a fifth ``LP_0m`` channel is not bound there. The beam's\npeak power and energy are those of the whole incident beam; the run launches only\nthe share of it the four channels capture -- light they miss would be lost at the\nentrance, not restored -- and the coupling report gives both energies. A 12 um beam\nis close to the fibre's fundamental mode (about 9 um), so the four ``LP_0m``\nchannels take all but a few parts in $10^5$ of it. This is a truncated\nmodel, not a mode-count convergence study. The quadrature\nuses two angular nodes, exact for fields with no azimuthal dependence. Raman\nscattering is left out, to keep the run to minutes: a silica core would declare\n:class:`~solprop.nonlinear.raman.SilicaTensorResponse` beside its Kerr term. The\nself-imaging period is set by the finite-element solve of the parabolic core, whose\npropagation constants converge at second order in the element count.\n" ] }, { @@ -22,14 +22,14 @@ }, "outputs": [], "source": [ - "import time\nimport warnings\nfrom dataclasses import dataclass\n\nimport matplotlib.pyplot as plt\nimport numpy as np\n\nfrom solprop.constants import C\nfrom solprop.driver import prop_waveguide\nfrom solprop.exceptions import GeneralModalBasisWarning\nfrom solprop.grid import FieldGrid\nfrom solprop.modes import (\n GradedIndexProvider,\n IndexMaterial,\n MaterialKerr,\n ModalGuide,\n PowerLawPermittivity,\n)\nfrom solprop.physdata import fused_silica\nfrom solprop.plotting import prop_2d\nfrom solprop.processing import get_spectral_density\nfrom solprop.processing.modal import physical_energy, reconstructed_flux\nfrom solprop.translate.general import general_modal_route\nfrom solprop.translate.modal_tables import project_transverse_field\n\nRADIUS = 50e-6\n\"\"\"Core radius, m.\"\"\"\n\nDELTA = 0.0095\n\"\"\"Index contrast: eps(r) = eps_axis (1 - 2 Delta (r/a)^2) in the core.\"\"\"\n\nWAVELENGTH = 1.064e-6\n\"\"\"Pump wavelength, m.\"\"\"\n\nLENGTH = 0.1\n\"\"\"Fibre length, m.\"\"\"\n\nWAIST = 12e-6\n\"\"\"1/e field radius of the launched Gaussian beam, m.\"\"\"\n\nPEAK_POWER = 5e5\n\"\"\"Launched peak power, W.\"\"\"\n\nFWHM = 300e-15\n\"\"\"Launched duration, s.\"\"\"\n\nstarted = time.perf_counter()\nsilica = IndexMaterial(fused_silica(model=\"malitson_1965/sellmeier\"))\n\n\n@dataclass(frozen=True)\nclass ScaledPermittivity:\n \"\"\"A permittivity law multiplied by a constant factor.\"\"\"\n\n law: IndexMaterial\n factor: float\n\n def permittivity(self, omega):\n return self.factor * self.law.permittivity(omega)\n\n @property\n def omega_domain(self):\n return self.law.omega_domain\n\n @property\n def identity(self):\n return {\"law\": dict(self.law.identity), \"factor\": self.factor}\n\n\nprovider = GradedIndexProvider(\n permittivity=PowerLawPermittivity(silica, DELTA),\n exterior=ScaledPermittivity(silica, 1.0 - 2.0 * DELTA),\n radius=RADIUS,\n family=\"LP\",\n count=4,\n elements=64,\n extent=1.6,\n core_responses=(MaterialKerr(n2=2.7e-20, at=WAVELENGTH),),\n)\nprint(provider)" + "import time\nfrom dataclasses import dataclass\n\nimport matplotlib.pyplot as plt\nimport numpy as np\n\nfrom solprop.constants import C\nfrom solprop.coupling import CouplingModel\nfrom solprop.driver import prop_waveguide\nfrom solprop.grid import FieldGrid\nfrom solprop.modes import (\n GradedIndexProvider,\n IndexMaterial,\n MaterialKerr,\n ModalGuide,\n PowerLawPermittivity,\n)\nfrom solprop.physdata import fused_silica\nfrom solprop.plotting import prop_2d\nfrom solprop.processing import get_spectral_density\nfrom solprop.processing.modal import physical_energy, reconstructed_flux\nfrom solprop.pulses import GaussianPulse, SpatialPulse\nfrom solprop.translate.general import general_modal_route\n\nRADIUS = 50e-6\n\"\"\"Core radius, m.\"\"\"\n\nDELTA = 0.0095\n\"\"\"Index contrast: eps(r) = eps_axis (1 - 2 Delta (r/a)^2) in the core.\"\"\"\n\nWAVELENGTH = 1.064e-6\n\"\"\"Pump wavelength, m.\"\"\"\n\nLENGTH = 0.1\n\"\"\"Fibre length, m.\"\"\"\n\nWAIST = 12e-6\n\"\"\"1/e field (1/e^2 intensity) radius of the incident Gaussian beam, m.\"\"\"\n\nPEAK_POWER = 5e5\n\"\"\"Peak power of the whole incident beam, W.\"\"\"\n\nFWHM = 300e-15\n\"\"\"Incident duration, s.\"\"\"\n\nstarted = time.perf_counter()\nsilica = IndexMaterial(fused_silica(model=\"malitson_1965/sellmeier\"))\n\n\n@dataclass(frozen=True)\nclass ScaledPermittivity:\n \"\"\"A permittivity law multiplied by a constant factor.\"\"\"\n\n law: IndexMaterial\n factor: float\n\n def permittivity(self, omega):\n return self.factor * self.law.permittivity(omega)\n\n @property\n def omega_domain(self):\n return self.law.omega_domain\n\n @property\n def identity(self):\n return {\"law\": dict(self.law.identity), \"factor\": self.factor}\n\n\nprovider = GradedIndexProvider(\n permittivity=PowerLawPermittivity(silica, DELTA),\n exterior=ScaledPermittivity(silica, 1.0 - 2.0 * DELTA),\n radius=RADIUS,\n family=\"LP\",\n count=4,\n elements=64,\n extent=1.6,\n core_responses=(MaterialKerr(n2=2.7e-20, at=WAVELENGTH),),\n)\nprint(provider)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "The grid spans 450 nm to 1.8 um, which holds both GPI sidebands, and 1.2 ps. The\nguide is solved at 97 frequencies across the **active** band -- from half the\ngrid's lowest declared frequency, 3.6 um, to its highest -- on a node rule of two\nGauss points per radial element and two angular nodes. The route gates the\ninterpolated basis's departure from orthonormality where the launched pulse is,\nand warns (it is silenced here, and quoted) that at the very longest active\nwavelengths, 3.5 um, where this run puts no light, it is 3e-4.\n\n" + "The grid spans 450 nm to 1.8 um, which holds both GPI sidebands, and 1.2 ps. The\nguide is solved at 97 frequencies across the **active** band -- from half the\ngrid's lowest declared frequency, 3.6 um, to its highest -- on a node rule of two\nGauss points per radial element and two angular nodes. The route gates the\ninterpolated basis's departure from orthonormality where the launched pulse is,\nand would warn if it exceeded its budget anywhere else in the active band; this\nguide's sampling passes without a warning.\n\n" ] }, { @@ -58,14 +58,14 @@ }, "outputs": [], "source": [ - "with warnings.catch_warnings():\n warnings.simplefilter(\"ignore\", GeneralModalBasisWarning)\n route = general_modal_route(\n guide, grid, z_max=LENGTH, omega_reference=float(grid.omega0)\n )\ncentre = int(np.argmin(np.abs(grid.omega - grid.omega0)))\nneff = np.array(\n [\n mode.neff(grid.omega[centre : centre + 1])[0].real\n for mode in guide.mode_collection.modes\n ]\n)\nbeta = neff * float(grid.omega[centre]) / C\nspacing = -np.diff(beta)\nideal = 2.0 * np.sqrt(2.0 * DELTA) / RADIUS\nz_p = 2.0 * np.pi / float(np.mean(spacing))\nlabels = [label.display_label for label in guide.mode_collection.channel_labels]\nprint(\n \"effective indices:\",\n \", \".join(f\"{name} {value:.7f}\" for name, value in zip(labels, neff, strict=True)),\n)\nprint(f\"spacings {np.array2string(spacing, precision=1)} 1/m; ideal {ideal:.1f} 1/m\")\nprint(\n f\"self-imaging period z_p = {z_p * 1e3:.4f} mm \"\n f\"(ideal {np.pi * RADIUS / np.sqrt(2 * DELTA) * 1e3:.4f} mm)\"\n)" + "centre = int(np.argmin(np.abs(grid.omega - grid.omega0)))\nneff = np.array(\n [\n mode.neff(grid.omega[centre : centre + 1])[0].real\n for mode in guide.mode_collection.modes\n ]\n)\nbeta = neff * float(grid.omega[centre]) / C\nspacing = -np.diff(beta)\nideal = 2.0 * np.sqrt(2.0 * DELTA) / RADIUS\nz_p = 2.0 * np.pi / float(np.mean(spacing))\nlabels = [label.display_label for label in guide.mode_collection.channel_labels]\nprint(\n \"effective indices:\",\n \", \".join(f\"{name} {value:.7f}\" for name, value in zip(labels, neff, strict=True)),\n)\nprint(f\"spacings {np.array2string(spacing, precision=1)} 1/m; ideal {ideal:.1f} 1/m\")\nprint(\n f\"self-imaging period z_p = {z_p * 1e3:.4f} mm \"\n f\"(ideal {np.pi * RADIUS / np.sqrt(2 * DELTA) * 1e3:.4f} mm)\"\n)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## The launch\nA Gaussian beam at the guide's nodes, the same at every frequency. The route\nprojects it through the flux pairing onto the four channels and reports the\nshare of it they do not capture: the higher ``LP_0m`` groups a 12 um beam also\nexcites, which this guide does not carry. The energy is not reduced by it -- the\nrun launches the declared energy into what the channels capture.\n\n" + "## The run\n``prop_waveguide(..., provider=guide, incident=beam, coupling=...)``. The beam is\na :class:`~solprop.pulses.SpatialPulse`: a Gaussian of the 12 um waist, centred\non the axis and polarised along x, carrying the 300 fs pulse; its energy is the\nwhole beam's, over its own plane. The run couples it into the four channels under\nthe vector paraxial model -- the flux pairing of the beam's field, with the forward\nparaxial closure for its magnetic field, against the channels' own E/H at the\nnodes -- whose admission budget, ``model_error``, bounds that closure's error,\nthe guide's contrast and the angular content of the beam and channels. The\nrecovered amplitudes are launched unscaled: the launched energy is the share of\nthe incident energy the channels capture, and anything they miss is lost at the\nentrance. ``model_error=0.1`` admits this guide: the weak-guidance contrast,\n0.0095, must stay below an eighth of the budget, and the counted terms (printed\nbelow) sum to about 0.02.\n\nShot noise is added to the input (``noise=\"input\"``, one photon per bin and\nchannel, with a fixed seed), because GPI grows from noise at frequencies the pump\ndoes not contain. ``dz_max`` is a tenth of the self-imaging period: the source\noscillates at that period, and the adaptive stepper would otherwise try steps that\nstraddle it. The default cubic is the real one, so the third harmonic is carried\ntoo.\n\n" ] }, { @@ -76,14 +76,14 @@ }, "outputs": [], "source": [ - "radius = np.hypot(nodes.xy[:, 0], nodes.xy[:, 1])\nbeam = np.zeros((nodes.xy.shape[0], 3))\nbeam[:, 0] = np.exp(-((radius / WAIST) ** 2))\nprojection = project_transverse_field(\n route.tables, np.repeat(beam[:, :, None], grid.n_omega, axis=2)\n)\nshares = np.abs(projection.amplitudes[:, centre]) ** 2\nshares /= shares.sum()\nprint(\n \"launched shares:\",\n \", \".join(\n f\"{name} {share:.3f}\" for name, share in zip(labels, shares, strict=True)\n ),\n)\nprint(f\"share of the beam the four channels miss: {projection.residual_share:.3f}\")" + "beam = SpatialPulse(\n GaussianPulse(\n wavelength=WAVELENGTH,\n fwhm=FWHM,\n energy=PEAK_POWER * FWHM * np.sqrt(np.pi / (4.0 * np.log(2.0))),\n ),\n WAIST,\n)\ncoupling = CouplingModel(\"vector_paraxial\", model_error=0.1)\n\n\ndef propagate(length, n_save):\n \"\"\"Propagate the incident beam through `length` of the fibre.\"\"\"\n return prop_waveguide(\n (),\n length,\n provider=guide,\n grid=grid,\n incident=beam,\n coupling=coupling,\n lambda0=WAVELENGTH,\n noise=\"input\",\n seed=41,\n dz_max=z_p / 10.0,\n n_save=n_save,\n progress=\"none\",\n )\n\n\nrun = propagate(LENGTH, 201)\nprint(run)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## The run\n``prop_waveguide(..., provider=guide, transverse=beam)``. Shot noise is added to\nthe input (``noise=\"input\"``, one photon per bin and channel, with a fixed seed),\nbecause GPI grows from noise at frequencies the pump does not contain. ``dz_max``\nis a tenth of the self-imaging period: the source oscillates at that period, and\nthe adaptive stepper would otherwise try steps that straddle it. The default\ncubic is the real one, so the third harmonic is carried too.\n\n" + "## The launch account\nThe run records the coupling's report as ``configuration[\"launch\"]``: the\nincident energy over the beam's whole plane, the energy the channels launch, the\ncaptured energy where the model defines one, and the admission budget against\n``model_error``. The vector model names no captured or uncaptured energy -- the\nflux pairing and the incident measure are not one positive metric -- so its\nlaunched share is the measure of what the four channels take: here all but a\nfew parts in $10^5$, mostly in ``LP_01``. The launched energy is what the\nrun's ``physical_energy`` reads at the entrance.\n\n" ] }, { @@ -94,7 +94,7 @@ }, "outputs": [], "source": [ - "energy = PEAK_POWER * FWHM * np.sqrt(np.pi / (4.0 * np.log(2.0)))\n\n\ndef propagate(length, n_save):\n \"\"\"Propagate the declared beam through `length` of the fibre.\"\"\"\n with warnings.catch_warnings():\n warnings.simplefilter(\"ignore\", GeneralModalBasisWarning)\n return prop_waveguide(\n (),\n length,\n provider=guide,\n grid=grid,\n transverse=beam,\n lambda0=WAVELENGTH,\n fwhm=FWHM,\n energy=energy,\n noise=\"input\",\n seed=41,\n dz_max=z_p / 10.0,\n n_save=n_save,\n progress=\"none\",\n )\n\n\nrun = propagate(LENGTH, 201)\nprint(run)" + "launch = run.metadata.configuration[\"launch\"]\nprint(launch)\ncaptured = launch.captured_energy\nprint(\n f\"incident {launch.incident_energy * 1e9:.2f} nJ, \"\n f\"launched {launch.launched_energy * 1e9:.2f} nJ \"\n f\"({launch.efficiency:.5f} of the incident energy), captured \"\n + (\n f\"{captured * 1e9:.2f} nJ\"\n if captured is not None\n else f\"not defined ({launch.captured_reason})\"\n )\n)\nprint(f\"admission budget {launch.budget:.3e} of model_error {launch.model_error:g}\")\nentrance = np.sum(np.abs(np.asarray(run[\"field\"])[0, :, 0, :]) ** 2, axis=-1)\nprint(\n \"launched shares:\",\n \", \".join(\n f\"{name} {share:.3f}\"\n for name, share in zip(labels, entrance / entrance.sum(), strict=True)\n ),\n)" ] }, { @@ -112,7 +112,7 @@ }, "outputs": [], "source": [ - "imaging = propagate(4.0 * z_p, 81)\nwith warnings.catch_warnings():\n warnings.simplefilter(\"ignore\", GeneralModalBasisWarning)\n imaging_route = general_modal_route(\n guide, grid, z_max=4.0 * z_p, omega_reference=float(grid.omega0)\n )\naxis_nodes = radius <= np.min(radius) * 1.0001\nz_imaging = np.asarray(imaging[\"z\"])\non_axis = np.array(\n [\n np.max(\n np.mean(\n reconstructed_flux(\n imaging, imaging_route.tables, plane=i, density=True, guide=guide\n )[axis_nodes],\n axis=0,\n )\n )\n for i in range(z_imaging.size)\n ]\n)\nfig, ax = plt.subplots(figsize=(6.4, 3.4))\nax.plot(z_imaging * 1e3, on_axis * 1e-4 * 1e-12, \"o-\", markersize=3)\nfor period in range(1, 5):\n ax.axvline(period * z_p * 1e3, color=\"0.5\", linestyle=\"--\", linewidth=0.8)\nax.set_xlabel(\"z (mm)\")\nax.set_ylabel(\"peak flux density on axis (TW/cm^2)\")\nfig.tight_layout()" + "imaging = propagate(4.0 * z_p, 81)\nimaging_route = general_modal_route(\n guide, grid, z_max=4.0 * z_p, omega_reference=float(grid.omega0)\n)\nradius = np.hypot(nodes.xy[:, 0], nodes.xy[:, 1])\naxis_nodes = radius <= np.min(radius) * 1.0001\nz_imaging = np.asarray(imaging[\"z\"])\non_axis = np.array(\n [\n np.max(\n np.mean(\n reconstructed_flux(\n imaging, imaging_route.tables, plane=i, density=True, guide=guide\n )[axis_nodes],\n axis=0,\n )\n )\n for i in range(z_imaging.size)\n ]\n)\nfig, ax = plt.subplots(figsize=(6.4, 3.4))\nax.plot(z_imaging * 1e3, on_axis * 1e-4 * 1e-12, \"o-\", markersize=3)\nfor period in range(1, 5):\n ax.axvline(period * z_p * 1e3, color=\"0.5\", linestyle=\"--\", linewidth=0.8)\nax.set_xlabel(\"z (mm)\")\nax.set_ylabel(\"peak flux density on axis (TW/cm^2)\")\nfig.tight_layout()" ] }, { diff --git a/docs/auto_examples/waveguides/plot_04_graded_index_multimode.py b/docs/auto_examples/waveguides/plot_04_graded_index_multimode.py index 721a085ba..a1bef0d13 100644 --- a/docs/auto_examples/waveguides/plot_04_graded_index_multimode.py +++ b/docs/auto_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( diff --git a/docs/auto_examples/waveguides/plot_04_graded_index_multimode.py.md5 b/docs/auto_examples/waveguides/plot_04_graded_index_multimode.py.md5 index fb7b78a00..5fa7991e8 100644 --- a/docs/auto_examples/waveguides/plot_04_graded_index_multimode.py.md5 +++ b/docs/auto_examples/waveguides/plot_04_graded_index_multimode.py.md5 @@ -1 +1 @@ -2d90d78df23ca25c973484ebe75fdc06 \ No newline at end of file +1d697b706a39d9fa6ae394d891f0686c \ No newline at end of file diff --git a/docs/auto_examples/waveguides/plot_04_graded_index_multimode.rst b/docs/auto_examples/waveguides/plot_04_graded_index_multimode.rst index 7bb39f67e..fd4b86f47 100644 --- a/docs/auto_examples/waveguides/plot_04_graded_index_multimode.rst +++ b/docs/auto_examples/waveguides/plot_04_graded_index_multimode.rst @@ -48,8 +48,9 @@ reduced to an effective area or a table of overlap integrals. 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 @@ -60,20 +61,20 @@ channel is redistributed by the nonlinearity. 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 self-imaging period is set by the finite-element solve of the parabolic core, whose propagation constants converge at second order in the element count. -.. GENERATED FROM PYTHON SOURCE LINES 63-72 +.. GENERATED FROM PYTHON SOURCE LINES 64-73 The fibre --------- @@ -85,21 +86,20 @@ a small permittivity law of our own -- anything with ``permittivity``, (:class:`~solprop.modes.material.PermittivityLaw`). The core declares its Kerr response by :math:`n_2`, at the wavelength it was measured. -.. GENERATED FROM PYTHON SOURCE LINES 72-154 +.. GENERATED FROM PYTHON SOURCE LINES 73-154 .. code-block:: Python 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, @@ -112,8 +112,8 @@ response by :math:`n_2`, at the wavelength it was measured. 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.""" @@ -128,13 +128,13 @@ response by :math:`n_2`, at the wavelength it was measured. """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")) @@ -198,8 +198,8 @@ guide is solved at 97 frequencies across the **active** band -- from half the 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. .. GENERATED FROM PYTHON SOURCE LINES 162-174 @@ -242,16 +242,11 @@ The channels and the self-imaging period. For an ideal infinite parabola the 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. -.. GENERATED FROM PYTHON SOURCE LINES 179-207 +.. GENERATED FROM PYTHON SOURCE LINES 179-202 .. code-block:: Python - 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( [ @@ -289,93 +284,127 @@ the finite, dispersive fibre are close to that and not equal to it. -.. GENERATED FROM PYTHON SOURCE LINES 208-215 - -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. +.. GENERATED FROM PYTHON SOURCE LINES 203-225 -.. GENERATED FROM PYTHON SOURCE LINES 215-232 +The run +------- +``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. + +.. GENERATED FROM PYTHON SOURCE LINES 225-258 .. code-block:: Python - 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) + beam = SpatialPulse( + GaussianPulse( + wavelength=WAVELENGTH, + fwhm=FWHM, + energy=PEAK_POWER * FWHM * np.sqrt(np.pi / (4.0 * np.log(2.0))), ), + WAIST, ) - print(f"share of the beam the four channels miss: {projection.residual_share:.3f}") + coupling = CouplingModel("vector_paraxial", model_error=0.1) + def propagate(length, n_save): + """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) -.. rst-class:: sphx-glr-script-out - .. code-block:: none - launched shares: LP0,1 0.933, LP0,2 0.062, LP0,3 0.004, LP0,4 0.000 - share of the beam the four channels miss: 0.346 +.. rst-class:: sphx-glr-script-out + .. code-block:: none -.. GENERATED FROM PYTHON SOURCE LINES 233-241 + MemoryOutput + state: closed + planes: 201, 0 m to 100 mm + field per plane: 4 x 1 x 2049 (modes x components x omega) + statistics: analytic_flux, fwhm_t, fwhm_t_outer, omega0, physical_energy + between planes: none + run: RunMetadata(multimode, field grid of 4096, 4 modes, native, 2026-1... -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. -.. GENERATED FROM PYTHON SOURCE LINES 241-269 -.. code-block:: Python +.. GENERATED FROM PYTHON SOURCE LINES 259-269 - energy = PEAK_POWER * FWHM * np.sqrt(np.pi / (4.0 * np.log(2.0))) +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. +.. GENERATED FROM PYTHON SOURCE LINES 269-293 - 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", - ) +.. code-block:: Python - run = propagate(LENGTH, 201) - print(run) + 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) + ), + ) @@ -385,18 +414,31 @@ cubic is the real one, so the third harmonic is carried too. .. code-block:: none - MemoryOutput - state: closed - planes: 201, 0 m to 100 mm - field per plane: 4 x 1 x 2049 (modes x components x omega) - statistics: analytic_flux, fwhm_t, fwhm_t_outer, omega0, physical_energy - between planes: none - run: RunMetadata(multimode, field grid of 4096, 4 modes, native, 2026-1... + CouplingReport: a field coupled into an input port + model: vector_paraxial + incident: 159.7 nJ + launched: 159.7 nJ + launched share: 1.0000 of the incident energy + uncaptured: not defined + budget: 1.832e-02 of model_error 0.1 + angular: 1.250e-02 (field) incident field: cone-rule allocation (1 - cos the... + numerical: 0.000e+00 (field) source domain/resolution 0.0e+00, field beyond th... + source metric: 9.473e-05 (field) common measure M0 against the plane's native metric + target metric: 2.517e-08 (map) F at the port's nodes against the native Q + contrast: 9.500e-03 (model) weak-guidance screen Delta over the guide's regions + closure: 4.617e-03 (map) H_t = Y0 z x E_t against provider H, field error 2.... + angular: 0.000e+00 (map) retained target subspace: cone-rule allocation (1 -... + envelope: n/a (model) no recorded pair envelope: omitted facet reflection is ... + passivity: 1.107e-03 (field) apparent gain within the model's defects, reporte... + exterior: zero + incident 159.67 nJ, launched 159.67 nJ (0.99998 of the incident energy), captured not defined (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)) + admission budget 1.832e-02 of model_error 0.1 + launched shares: LP0,1 0.933, LP0,2 0.062, LP0,3 0.004, LP0,4 0.000 -.. GENERATED FROM PYTHON SOURCE LINES 270-282 +.. GENERATED FROM PYTHON SOURCE LINES 294-306 Self-imaging ------------ @@ -411,17 +453,16 @@ which is why the guide and its tables are passed beside the output. The dashed lines mark the period the channels' spacings predict. The launch is mostly ``LP_01`` with a few per cent of ``LP_02``, so what oscillates is their beat. -.. GENERATED FROM PYTHON SOURCE LINES 282-312 +.. GENERATED FROM PYTHON SOURCE LINES 306-335 .. code-block:: Python 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( @@ -457,7 +498,7 @@ lines mark the period the channels' spacings predict. The launch is mostly -.. GENERATED FROM PYTHON SOURCE LINES 313-320 +.. GENERATED FROM PYTHON SOURCE LINES 336-343 The spectrum ------------ @@ -467,7 +508,7 @@ narrow one near 785 nm from about 4 cm, and a broader one near 1.45 um from abou 5.5 cm. The pulse spreads in time as it goes, because self-phase modulation in normal dispersion broadens it. -.. GENERATED FROM PYTHON SOURCE LINES 320-333 +.. GENERATED FROM PYTHON SOURCE LINES 343-356 .. code-block:: Python @@ -496,7 +537,7 @@ normal dispersion broadens it. -.. GENERATED FROM PYTHON SOURCE LINES 334-344 +.. GENERATED FROM PYTHON SOURCE LINES 357-367 The spectral energy density summed over the channels, at the entrance and every 2.5 cm, on a logarithmic scale, with the first GPI sideband pair predicted from @@ -509,7 +550,7 @@ band is *not* at the predicted Stokes position (1.58 um), nor at the energy-conserving partner of the anti-Stokes band (1.64 um); the leading-order formula does not explain it, and this page leaves it as an open observation. -.. GENERATED FROM PYTHON SOURCE LINES 344-385 +.. GENERATED FROM PYTHON SOURCE LINES 367-408 .. code-block:: Python @@ -573,7 +614,7 @@ formula does not explain it, and this page leaves it as an open observation. -.. GENERATED FROM PYTHON SOURCE LINES 386-394 +.. GENERATED FROM PYTHON SOURCE LINES 409-417 The channels ------------ @@ -584,7 +625,7 @@ orthonormal in true power, so a channel's energy is the frequency sum of its own small, fast ripple is the Kerr exchange between the channels at the beat period; the slow drift is what the nonlinearity moves over centimetres. -.. GENERATED FROM PYTHON SOURCE LINES 394-417 +.. GENERATED FROM PYTHON SOURCE LINES 417-440 .. code-block:: Python @@ -625,20 +666,20 @@ the slow drift is what the nonlinearity moves over centimetres. .. code-block:: none physical energy: 159.67 nJ in, 159.68 nJ out - entrance: LP0,1 9.33e-01, LP0,2 6.24e-02, LP0,3 4.12e-03, LP0,4 2.74e-04 - exit : LP0,1 9.28e-01, LP0,2 6.94e-02, LP0,3 2.45e-03, LP0,4 1.10e-04 + entrance: LP0,1 9.33e-01, LP0,2 6.25e-02, LP0,3 4.13e-03, LP0,4 2.75e-04 + exit : LP0,1 9.28e-01, LP0,2 6.95e-02, LP0,3 2.46e-03, LP0,4 1.10e-04 -.. GENERATED FROM PYTHON SOURCE LINES 418-422 +.. GENERATED FROM PYTHON SOURCE LINES 441-445 Which channel carries which band: the exit spectrum of each channel. The pump stays mostly in ``LP_01``; the bands the parametric process built are shared between the channels, because the process that made them is the beat between the channels. -.. GENERATED FROM PYTHON SOURCE LINES 422-437 +.. GENERATED FROM PYTHON SOURCE LINES 445-460 .. code-block:: Python @@ -669,13 +710,13 @@ the channels. -.. GENERATED FROM PYTHON SOURCE LINES 438-441 +.. GENERATED FROM PYTHON SOURCE LINES 461-464 Runtime and reproducibility --------------------------- A ten-centimetre and a 4.5 mm run of four channels on the general modal route. -.. GENERATED FROM PYTHON SOURCE LINES 441-448 +.. GENERATED FROM PYTHON SOURCE LINES 464-471 .. code-block:: Python @@ -694,12 +735,12 @@ A ten-centimetre and a 4.5 mm run of four channels on the general modal route. .. code-block:: none - 3036 s; backend native, 1 threads + 623 s; backend native, 18 threads -.. GENERATED FROM PYTHON SOURCE LINES 449-455 +.. GENERATED FROM PYTHON SOURCE LINES 472-478 Related reading --------------- @@ -711,7 +752,7 @@ For a paced propagation lesson, return to .. rst-class:: sphx-glr-timing - **Total running time of the script:** (50 minutes 36.299 seconds) + **Total running time of the script:** (10 minutes 22.839 seconds) .. _sphx_glr_download_auto_examples_waveguides_plot_04_graded_index_multimode.py: diff --git a/docs/auto_examples/waveguides/plot_04_graded_index_multimode.zip b/docs/auto_examples/waveguides/plot_04_graded_index_multimode.zip index 04fad172c..02cdbaeb0 100644 Binary files a/docs/auto_examples/waveguides/plot_04_graded_index_multimode.zip and b/docs/auto_examples/waveguides/plot_04_graded_index_multimode.zip differ diff --git a/docs/dev/accuracy.md b/docs/dev/accuracy.md index b3f2e0ba6..21fd42e9d 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/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. + +- **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/architecture.md b/docs/dev/architecture.md index 8bf995e73..c7d660184 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 @@ -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** -- @@ -154,7 +154,24 @@ 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`. 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()` +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 @@ -178,16 +195,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 (`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`), 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 | -| `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/backends.md b/docs/dev/backends.md index 89b17187e..9ff927dbd 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/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 +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 aab5d51f4..c23a59693 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/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 +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/dependencies.md b/docs/dev/dependencies.md index 3d0fc1879..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.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.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/docs/dev/figures/build.py b/docs/dev/figures/build.py index 29d80a12e..7485a4a5f 100644 --- a/docs/dev/figures/build.py +++ b/docs/dev/figures/build.py @@ -597,6 +597,20 @@ class Layer(NamedTuple): Module("polarisation.py"), Module("optics.py"), Module("tools.py"), + Module( + "coupling/", + ( + "plane", + "port", + "remap", + "models", + "coupler", + "outcouple", + "envelopes", + "transport", + "elements", + ), + ), Module( "physdata/", ( @@ -669,6 +683,7 @@ class Layer(NamedTuple): "planar", "spatial", "flux", + "coupling", "responses", "ionisation", "plasma_integration", @@ -792,6 +807,7 @@ class Layer(NamedTuple): "compression", "mixtures", "modal", + "planes", "scans", ), ), diff --git a/docs/dev/figures/layers.svg b/docs/dev/figures/layers.svg index 2a4f2693e..bc0e3ac8b 100644 --- a/docs/dev/figures/layers.svg +++ b/docs/dev/figures/layers.svg @@ -1,6 +1,6 @@ + width="1200" height="1472.0" viewBox="0 0 1200 1472.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,163 +115,168 @@ text { font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; fill: linear.py boundaries.py - + polarisation.py - + optics.py - + tools.py - -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 · 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 · 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 + +coupling/ +plane · port · remap · models +coupler · outcouple · envelopes +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/kernel-contract.md b/docs/dev/kernel-contract.md index a7b1d7600..462b74c93 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/roadmap.md b/docs/dev/roadmap.md index ac8be8738..91218f811 100644 --- a/docs/dev/roadmap.md +++ b/docs/dev/roadmap.md @@ -32,17 +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. ## Draft enhancement proposals @@ -298,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 @@ -314,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 @@ -933,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. @@ -942,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 @@ -971,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 @@ -1091,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/archive/sep-0031-simulation-coupling.md b/docs/dev/seps/archive/sep-0031-simulation-coupling.md new file mode 100644 index 000000000..86cd0eb92 --- /dev/null +++ b/docs/dev/seps/archive/sep-0031-simulation-coupling.md @@ -0,0 +1,2929 @@ +# SEP 31 — Coupling simulations and transporting ultrafast fields + +| | | +|---|---| +| **SEP** | 31 | +| **Title** | Coupling simulations and transporting ultrafast fields | +| **Author** | John Travers | +| **Drafting agent** | GPT-6 (draft and 2026-10-23 review response) | +| **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. 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** | [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 + +### 1. Abstract + +Connect solprop simulations through a coherent field at a reference plane, spatial +synthesis and projection, linear optical transport, and a validated launch into +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](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 +short complete workflows on all three CPU backends. A required focused tutorial +compares linear and mildly nonlinear radial focusing before coupling into a +capillary. This is a draft design, not an announcement of implemented interfaces. + +### 2. Motivation and scope + +A simulated pulse is often the input to another optical component. Carrying only +its spectrum or duration loses wavefront, modal coherence, polarisation and the +frequency dependence of its spatial profile. Carrying a bare array preserves more +information but leaves its basis, units, frame, grid and energy meaning implicit. +The intended users need to assemble experiments without rebuilding those +conversions in each script. + +| Requested calculation | Composition proposed here | +|---|---| +| Capillary output into another capillary | Extract a modal plane; apply a direct overlap or outcouple, transport and recouple; launch the captured amplitudes | +| Nonlinear radial beam into a capillary | Run the gas gap with existing Kerr/plasma/THG responses; extract its entrance-plane field; project onto the capillary channels | +| Solid-core fibre into another fibre, or different hollow guides | Evaluate the source and target profiles at their actual end/entrance planes, with an explicit forward interface approximation and spectral overlap | +| Gaussian beam into a guide | Construct a normalised incident field, including curvature and polarisation, and use the same projection | +| Arbitrary analytic field into a guide | Sample the declared field and incident domain, establish its power measure, and project | +| Arbitrary Cartesian free-space beam into a guide | Reconstruct the complex field, remap to the projection rule and project without imposing radial symmetry | +| Capillary output at a distant free-space location | Synthesize the exit field, apply the declared outcoupling model and propagate to an observation plane | +| SPM, compression and another capillary stage | Compose ordinary simulations with spectral optics, focusing/apertures and coupling operations | + +**Included:** existing transverse mode families with actual field profiles; +ordinary and general-modal outputs; scalar or transverse-polarisation radial and +Cartesian fields; Gaussian and nonseparable analytic/sampled inputs; alignment; +controlled temporal remapping; spectral compressors; thin focusing elements, +apertures and declared mirror/polarisation operations; homogeneous isotropic +linear gaps; physical incident-energy accounting; CPU qualification and provenance. +The uniform-in-y line route uses J/m and W/m throughout. It cannot be coupled to +a finite-energy two-dimensional beam without an explicitly supplied y profile. +The hybrid planar guide can participate through its actual modal/FFT synthesis; +it must not be mistaken for a grid of electric-field samples. + +**Excluded:** a general electromagnetic junction solver with backward, radiation +and evanescent channels; feedback between stages or a cavity fixed-point solver; +new mode solvers; high-NA vector free-space propagation; anisotropic crystal +transport; resolved angular dispersion or spatial chirp inside prism/grating +compressors; nonlinear thin elements; automatic optical layout optimisation; +adaptive spatial meshes; and a new distributed workflow scheduler. The radial +adiabatic amplitude correction belongs to SEP 34, not to the host transport API. +Nonlinear gaps use the existing propagation routes. Field-grid Kerr/plasma/THG +is a qualification case; it is not the teaching tutorial's model. + +The release has no backward-compatibility requirement for this change. Replace +inconsistent interfaces and defaults directly, update their callers, tests and +documentation, and add no legacy normalisation, deprecated aliases, compatibility +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): + +- `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 + 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 + them before their waist; `PlanarPulse` and `modes.slab.gaussian_projection` + provide a specialised planar projection. `DataPulse` interpolates a measured + temporal spectrum and scales it to a requested energy; that scaling is unsuitable + for transferring an already normalised simulation output. +- `solprop.translate.modal_tables.project_transverse_field` uses the general + guide's coordinate dual. Its `ProjectedLaunch.residual` is an electric-field + 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. +- `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 + 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 + that limitation. Existing far-field statistics are selected diagnostics, not a + coherent output field at an arbitrary receiving plane. + +Existing tests cover modal synthesis/recovery, the current projected-launch energy +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](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 +its full-vector crystal E/H contract and interface solver. +[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 +escaped-field models; remapping a saved plane cannot recover already absorbed light. +[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. + +### 4. Theory + +#### 4.1 One physical plane, several representations + +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 +changes local coordinates and polarisation consistently, not by an unexplained +conjugation of the field. + +Let $\mathcal S_A(\omega)$ synthesize a source representation into a physical +field and $\mathcal P_B(\omega)$ recover the target coordinates. A declared +linear transport $\mathcal L$ gives + +$$ + C(\omega)=\mathcal P_B(\omega)\mathcal L(\omega)\mathcal S_A(\omega), + \qquad \mathbf b_B=C\mathbf b_A. +$$ + +This factorisation is a composition rule, not a claim that every combination of +ports and interfaces is physically valid. Each adapter declares its field model, +support, normalisation and approximation. Identity transfers keep their native +representation. A common quadrature can evaluate a direct overlap without storing +the intermediate field at every temporal frequency. + +For an ordinary guide, synthesize the physical field with the existing +vacuum-normalised basis after dividing saved $b_m$ by +$\sqrt{\operatorname{Re}n_{{\rm eff},m}}$ at the selected plane. For a general +guide, use its E/H tables and metric at that plane. Use the source exit radius, +material and tracked gauge, not their entrance values. The target uses its own +entrance reference. A change of modal gauge changes the amplitudes and the maps +together and leaves the physical field unchanged. + +#### 4.2 Shared plane measure + +[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 + +$$ + Y_z(\boldsymbol\kappa,\omega;z) + =\varepsilon_0c^2\frac{\operatorname{Re}k_z}{\omega},\qquad + U=\frac12\int\operatorname{Re}(\mathbf E_a\cdot\mathbf G_a^*)\,dA\,dt, + \quad \widetilde{\mathbf G}_a + =\mathcal Y_c\widetilde{\mathbf E}_a. +$$ + +Tildes here denote the transverse/frequency spectrum: apply admittance there, +then reconstruct G in space and time. Here $\operatorname{Re}\mathcal Y_c=Y_z$. +Local flux uses the full complex +admittance of the retained forward component, whereas total +energy uses its real part; replacing one by the other before reconstruction +would lose absorbing-medium interference terms. + +The host plane evaluator and propagation statistics use the same definition and +resolved material at the selected z. Coupling does not require an extra kernel +statistic or a second flux implementation. Physical V/m remains the propagated +spatial state; ordinary and general modal ports retain their derived native +metrics. A planar hybrid port uses its modal/FFT metric, not a bulk index factor. + +A saved plane from a radial path admitted by SEP 34 §4.5's per-column +total-variation certificate is a valid source. Its local admittance, jump side +where needed, path certificate and accumulated frame/amplitude references must +be available. Transport through that path remains an ordinary radial simulation; +the `FreeSpace` element below is homogeneous. Refuse a failed/missing certificate, +an ambiguous jump side or an unsupported absorbing path, not every gradient. +Record the certificate's scope: it bounds linear-background forward coordinates, +not the complete nonlinear simulation or an omitted reflected field. The scalar +metric is not a replacement for the full-vector Maxwell treatment of high-NA or +crystal fields. + +On supported optical bands, `energy=` is the physical energy crossing the +declared plane, not a vacuum-equivalent field norm. For a lossless paraxial plane +wave its flux is $n_0\varepsilon_0c|E_a|^2/2$, using the phase index. SEP 34 explains +the distinction from energy density and the conversion of an external meter +reading through an explicit interface. + +#### 4.3 Projection, power and an interface are different objects + +For a lossless forward vector basis, define the Hermitian flux pairing + +$$ + \langle\psi_m,\psi_n\rangle_F= + \frac14\int(\mathbf e_n\times\mathbf h_m^* + +\mathbf e_m^*\times\mathbf h_n)\cdot\hat{\mathbf z}\,dA, + \qquad F_{mn}=\langle\psi_m,\psi_n\rangle_F. +$$ + +The coordinates of a represented field satisfy $\mathbf a=F^{-1}\mathbf B$, +where $B_m=\langle\psi_m,(\mathbf E,\mathbf H)\rangle_F$. The recovered +power is $\mathbf a^\dagger F\mathbf a$. The package's existing E-only shortcut +doubles one half of B for a forward superposition in its admitted lossless basis. +Its exact recovery on that subspace does **not** prove that an unrelated incident +beam has the target guide's magnetic field. Use incident H when available; an +E-only input must declare its forward closure and validity. Do not reuse a +nonlinear source-projection matrix to recover field coordinates. + +For scalar fields in a common positive power space, let S contain target basis +columns and M the quadrature/power measure. Weighted least squares gives + +$$ + G=S^\dagger M S,\qquad + \mathbf a=G^{-1}S^\dagger M\mathbf f,\qquad + \|\mathbf f\|_M^2=\mathbf a^\dagger G\mathbf a+ + \|\mathbf f-S\mathbf a\|_M^2. +$$ + +The last equality follows because the residual is M-orthogonal to S. Only in +such a common positive space does it identify a nonnegative uncaptured share. +The existing electric-field residual and an arbitrary E/H flux projection do +not automatically satisfy this identity. A basis outside a common admitted power +space needs its declared interface approximation, not an assertion of orthogonal +energy loss. + +Use F for the E/H flux matrix, G for the scalar least-squares Gram matrix, +and Q for a native representation's spectral power metric. They coincide only +when the synthesis and common measure establish that identity. In particular, +ordinary saved true-power coefficients have Q equal to the identity; a general +guide may have a dense Q. A profile overlap must not silently replace Q by G. + +**Admitted models.** Make the following choices explicit in `CouplingModel`: + +1. `same_basis`: exact coordinate recovery in a checked common modal reference, + including a declared invertible basis/gauge change. Provider E/H and F recover + coordinates; this is not an interface calculation. +2. `scalar_paraxial`: ordinary scalar capillary modes, incident + Gaussian/analytic/sampled scalar or transverse-Jones beams, and scalar + weak-guidance solid modes with physical profiles. Use the common local measure + $M_0=Y_0(\omega)dA/2$, with $Y_0=n_{\rm ref}\varepsilon_0c$, on the whole + incident plane; solve G and convert the resulting coordinates to the target's + native Q. Different capillary radii and weakly guiding fibre cross-sections + are admitted subject to the bounds below. The capillary core gas fixes + $n_{\rm ref}$; its wall loss is propagated by the guide model, not inferred + as facet transmission. Its scalar boundary approximation is reported. +3. `vector_paraxial`: provider E/H for lossless bound general modes, in the same + weak-guidance/paraxial regime and a common transverse reference. Recover with + F using both incident E and H. An E-only incident beam explicitly uses + $\mathbf H_t=Y_0\hat{\mathbf z}\times\mathbf E_t$; this closure is qualified + against plane-wave Maxwell fields and provider H. Longitudinal field + reconstruction requires the provider or a separately declared closure; + an $O(\theta^2)$ axial-power error is not an $O(\theta^2)$ bound on every + omitted longitudinal field component. + +All other physical junctions are refused: strong index contrast, unresolved facet +near fields, cutoff, unqualified lossy/nonorthogonal duals, arbitrary discontinuities +and a request for reflected or radiation fields. A bulk entrance/exit index +change uses a separate qualified planar interface before projection. Do not +substitute modal effective indices into its Fresnel coefficients. +Reference [1] explains why coordinate recovery alone is not boundary matching. + +**Admission rule.** `CouplingModel` takes a required positive `model_error` +budget $\epsilon_m<1$; it is a dimensionless energy/axial-flux approximation +target, separate from numerical accuracy. No universal index contrast or angle +is called exact. For the two approximate models: + +- choose $\theta_*$ by $1-\cos\theta_*=\epsilon_m/8$ and bound angular tails + with §4.6's energy-weighted rule, for source and retained target subspace; +- require $\Delta=\max|n^2-n_{\rm ref}^2|/(2n_{\rm ref}^2)\le\epsilon_m/8$ + over the occupied band and dielectric regions of a weak-guidance solid + provider; this criterion does not apply to a capillary wall as if it were a + weakly guiding dielectric; +- bound the relative metric defect between G and target Q, and between the + common source measure and its native Q, by $\epsilon_m/8$ each, using + conditioned quadratic forms on the retained subspaces; +- for `vector_paraxial`, bound the transverse H-closure error in the same + normalised operator norm by $\epsilon_m/8$; supplied incident H is retained. + The bound concerns every admitted input in that port, not one lucky pulse. + +The Δ screen is a conservative sufficient screen, not an immutable minimum +budget for every solid-fibre pair. A qualified provider-pair benchmark envelope +may replace **that screen** if it bounds the omitted vector/facet effects over +the declared wavelength, geometry, alignment and mode span to the requested +budget. Record its identity, range and independently bounded numerical error; +do not extrapolate it. Angular support, metric/closure checks, passivity and +the total budget still apply. Identical checked bases use `same_basis` directly +and need no weak-guidance screen for coordinate recovery. + +These are conservative screening allocations, not a theorem that small contrast +alone bounds facet scattering. Each provider/model pair must additionally have +an independent plane-wave or mode-matching benchmark envelope for omitted +reflection and facet error. The sum of those errors, angular/metric/closure +defects and resolved tails must fit $\epsilon_m$. Unqualified combinations +remain refused. The independent benchmarks and budgets are deliverables, not +permission to choose a different model during implementation. + +For a proposed passive finite map C between native metrics $Q_A,Q_B$, check + +$$ + C^\dagger Q_BC\preceq Q_A +$$ + +within the separately recorded numerical and model bounds, using a conditioned +whitened-map singular-value or quadratic-form certificate. Small apparent gain +within a model bound is reported as a model defect, never as negative absorption; +outside the bound refine or refuse. Do not rescale C or clip efficiency to one. +This certificate is necessary, not sufficient, evidence of a physical interface. +Positive uncaptured power is claimed only in the common positive metric where +the orthogonal-residual identity holds. + +Integrate incident power over its declared incident domain, including light +outside the target aperture. Integrating only over the target core would erase +geometrical clipping. For an analytic field, establish a tail bound or convergence +under domain enlargement. For measured/sampled data, record the finite-domain +assumption; unobserved tails are unknown, not measured losses. Separate errors in +the quadrature, the transverse window and the retained modal span. + +#### 4.4 Analytic overlap references + +For normalised scalar Gaussians at a common flat-wavefront plane and admittance, +$u_j=\sqrt{2/(\pi w_j^2)}\exp(-r^2/w_j^2)$, direct completion of the square +gives the captured power for relative displacement d, + +$$ + \eta=\left(\frac{2w_1w_2}{w_1^2+w_2^2}\right)^2 + \exp\left[-\frac{2d^2}{w_1^2+w_2^2}\right]. +$$ + +For centred beams with transverse phase difference +$\exp(i\Delta\boldsymbol\kappa\cdot\mathbf r)$, multiply the aligned result +by $\exp[-|\Delta\boldsymbol\kappa|^2w_1^2w_2^2/ +(2(w_1^2+w_2^2))]$. A curvature difference replaces the real Gaussian inverse +width in the integral with its complex counterpart. These formulas are restricted +to the common paraxial scalar measure; they do not replace the medium/interface +calculation. Direct two-dimensional integration is an independent test of the +implemented alignment signs and amplitude. + +For a scalar capillary fundamental, let u be the first positive zero of $J_0$. +Inside radius a, use the normalised profile +$v=J_0(ur/a)/(\sqrt{\pi}a|J_1(u)|)$ and zero outside. The Bessel integral +$\int_0^a rJ_0(ur/a)^2dr=a^2J_1(u)^2/2$ fixes that normalisation. Gaussian +capture is the square of $2\pi\int_0^a r v^*u_1dr$, with the incident Gaussian +normalised over the whole plane. Evaluate this integral independently and sweep +waist, curvature and mode count; do not paste an asserted universal optimum +coupling percentage into a test. Vector/profile corrections are assessed against +the actual `Mode` fields, not absorbed into this scalar reference. + +#### 4.5 Reference phase, arrival time and temporal grids + +A saved spectrum is in the run's moving frame. If its linear operator removed +$R(\omega,z)=\beta_0(z)+\beta_1(z)(\omega-\omega_{\rm ref})$, the optics +spectrum in the common reference acquires $\exp[+i\int R\,dz]$. Use exactly +the terms removed by that route: on a field grid R is $\beta_1\omega$; an +envelope with or without THG has the separately documented constant term. Track +the accumulated affine phase as a phase offset and time offset, so a long path +need not numerically translate a femtosecond pulse out of its window. A subsequent +stage subtracts its chosen reference once. No per-mode recentering is permitted. + +Differentiate three conversions: the sign convention, the raw DFT's time origin, +and raw-DFT amplitude scaling. Let $s_k$ be an optics-convention raw spectrum +centred at t=0. Its normalised analytic spectral density amplitude is + +$$ + f_k=\frac{\Delta t}{\sqrt{2\pi}}q_ks_k, +$$ + +where $q_k=1$ for an envelope and $q_k=2$ on interior positive field bins +(1 at endpoints). Then $\Delta\omega\sum f_k^\dagger Q_kf_k$ is the +energy on the modal representation; apply the spatial metric analogously. +Identical physical frequencies can be copied after this scaling and reference +conversion, then divided by the target scale. Copying raw DFT amplitudes between +different N and $\Delta t$ is not this operation. + +For noncoincident lattices, use a complex linear Fourier remap of the source +finite record, with its actual time origin and envelope carrier. A direct +evaluation of its finite-time Fourier sum is the reference; zero padding, +Fourier resampling and chunked matrix evaluation may accelerate the same declared +map. Treat complex spectral zeros naturally, without interpolating an undefined +unwrapped phase. A larger time window extends a localised record only under an +explicitly checked edge/tail assumption. A shorter window or smaller band is an +explicit lossy operation with a report. No requested energy rescales the answer. + +Conversion does not recover clipped, aliased, absorbed or never recorded content. +Refuse a target that cannot represent occupied positive frequencies, a materially +nonlocalised time record without a specified truncation, or a representation +outside the receiving route's physics. Field/envelope parity is checked only for +a common admitted physical model. In particular, envelope plasma with THG and +general-modal envelope propagation remain unsupported by this SEP. + +#### 4.6 Transport between planes + +At each absolute temporal frequency, homogeneous propagation is + +$$ + \widetilde E(\boldsymbol\kappa,\omega;L) + =\exp[i k_z(\boldsymbol\kappa,\omega)L] + \widetilde E(\boldsymbol\kappa,\omega;0). +$$ + +Radial symmetry uses the order-zero Hankel representation; Cartesian propagation +uses the transverse Fourier representation with its own spatial origin and +scaling. Refuse significant unsupported evanescent launch content rather than +using a propagation kernel's numerical evanescent cap as an interface model. +Sampling an exact exponential poorly is still inaccurate. Bound the relevant +phase sampling and periodic wrap, qualify domain enlargement, and report any +intentional angular band removal. Reference [2] motivates these sampling checks; +its filtering is not permission to hide removed field energy. + +Evaluate an independent output grid through the continuous/discrete reference +transform, with a fast uniform-grid path where valid. Chunked direct Fourier or +Bessel evaluation is the correctness reference for a scaled observation plane. +Do not allocate a transverse-by-transverse-by-frequency propagation tensor. + +For a paraxial observation at L in a transparent uniform medium, the Fraunhofer +limit of the Fresnel integral is + +$$ + E(\mathbf r,\omega;L)\simeq + \frac{k e^{ikL}}{2\pi iL}e^{ik|\mathbf r|^2/(2L)} + \int E(\mathbf r',\omega;0) + e^{-i(k/L)\mathbf r\cdot\mathbf r'}dA'. +$$ + +Here the integral uses an explicitly negative-sign spatial transform, independently +of the positive-sign temporal optics convention. A sufficient far-field condition +over source radius R is $kR^2/(2L)\ll1$ for the occupied band; compare with the +finite-distance propagator and refine the output window and sampling. Preserve +the complex phase and the frequency-dependent angular-to-position map. A +far-field spectrum integrated through an aperture is not a substitute for this +field when another coherent element follows it. + +A thin lens has paraxial factor +$\exp[-ik(\omega)r^2/(2f(\omega))]$ in the local plane. A focusing mirror +uses its declared focal power with its spectral reflection/Jones response. +An amplitude aperture multiplies by A, with intensity transmission $|A|^2$; +reuse `spatial.aperture_profile` with an explicit conversion from its intensity +weight. The operation's plane must say whether it is a near-field, image or +far-field plane. Rotations and reflections carry the coordinate handedness and +Jones axes consistently. + +**Thin-screen model and bound.** Use the local paraxial electric-field screen +at each frequency; it is exact only in the local reference measure +$U_0(E)=\int Y_0|\widetilde E|^2\,d\boldsymbol\kappa\,d\omega/2$ with the +declared transform weights. For transparent media $Y_z/Y_0=\cos\theta$. +For a selected angular cone $\theta\le\theta_*$, + +$$ + 0\le U_0(E)-U_Y(E) + \le (1-\cos\theta_*)U_{0,\rm cone}(E)+U_{0,\rm tail}(E) + \equiv d(E). +$$ + +Evaluate both input and output, including the spectrum generated by a lens or +aperture. Given the required `model_error` $\epsilon_m$, choose +$1-\cos\theta_*=\epsilon_m/8$, and admit only if +$[d(E_{\rm in})+d(E_{\rm out})]/U_Y(E_{\rm in})$, plus numerical/domain error, +fits $\epsilon_m$. Zero input is an exact separate case. The tail is evaluated, +not silently discarded. An unresolved hard-edge tail or significant +nonpropagating content requires explicit removal with its own report or refusal. +For a reusable map, certify the whole declared source subspace; for a single +field, label the certificate as applying only to that field. + +For a phase screen $U_0$ is invariant; the inequality bounds its axial-flux +defect. For a contractive amplitude/Jones screen, local loss +$L_0=U_0(E_{\rm in})-U_0(E_{\rm out})$ is nonnegative, and the difference +between L0 and the measured axial-energy decrement is bounded by the same sum +of d terms. Report that difference as approximation error, not absorption. +This gives a concrete admission rule; $|A|\le1$ alone is not enough in the +nonlocal Y metric. + +For a mirror, specify incident/outgoing propagation normals and a shared +s direction perpendicular to their plane; at normal incidence the caller's +declared s axis resolves the degeneracy. Set +$\hat{\mathbf p}_{\rm in/out}=\hat{\mathbf s}\times\hat{\mathbf k}_{\rm in/out}$. +The right-handed local frames and the supplied complex p/s reflection +coefficients define the Jones map. Convert the field between these frames +explicitly, without conjugation; qualify normal-incidence and double-reflection +signs. Coefficients calibrated in another p/s convention must be converted. +The outgoing beam is forward relative to its own plane normal. + +For a normal-incidence, homogeneous, nonmagnetic, lossless planar interface, +$t_E=2n_1/(n_1+n_2)$ and $r_E=(n_1-n_2)/(n_1+n_2)$ follow from tangential E/H +continuity. Power transmission is $T=(n_2/n_1)|t_E|^2$, giving $R+T=1$. +This is a useful declared interface model and analytic test. It is not obtained +by inserting two modal effective indices into a bulk Fresnel formula. More +general angular/polarisation coefficients require their own declared planar +geometry and flux test; arbitrary facets remain outside scope. Never apply a +Fresnel factor twice through both the element and the launch adapter. + +Existing `Material`, mirrors and compressor pairs act through $H(\omega)$. +Their amplitude and phase affect every spatial sample coherently. `Material` +removes a reference group delay; mirror data can lack constant phase and delay; +grating/prism models remove affine phase and omit some efficiencies. Adapt those +facts explicitly into the plane's reference/calibration record. Require supplied +efficiency when a lossy prediction is requested from a phase-only model. +Negative material lengths or inverse mirror phases are reconstruction operations, +not passive physical elements. Keep their purpose explicit. + +#### 4.7 Accounting across a chain + +For each operation record input/output energy on the admitted support, its metric, +specified material/reflection loss, aperture/model interpretation, basis loss +where defined, intentional spectral/window removal, and unresolved numerical +or model error. Known losses add sequentially in joules; multiplying broadband +scalar efficiencies is generally wrong when an operation changes the spectrum. +Carry SEP 34's source certificate as a separate, scoped report item. Where +$\delta$ bounds the relative forward-field error in the same positive metric, +$2\delta+\delta^2$ bounds the corresponding relative energy discrepancy; +use that conversion and the admitted map norm before adding it to an +energy-valued `model_error` budget. Do not add a field error directly to an +energy-loss fraction. For successive field-error maps compose their bounds +conservatively, rather than assuming independent errors cancel. A nonlinear +source's background-path certificate alone supplies no end-to-end field-error +bound; include its separate propagation qualification and report what is unknown. +Retain spectral reports so wavelength-dependent coupling remains visible. + +For multiple coherent input channels, even a diagonal target metric does not +permit projecting intensities: the interference is in C times the complex field. +For a dense target metric, individual diagonal channel contributions are not +independent physical powers. Report the total quadratic form and any meaningful +block shares; do not invent positive per-channel efficiencies by dropping cross +terms. A field entirely outside a retained subspace may give a valid zero launch; +it does not trigger a normalisation division by zero. + +Carry the realised incoming field and its provenance once. The existing modified +noise scheme has auxiliary bookkeeping; reconstruct according to its documented +saved-field meaning, not by adding every stored noise array. A new stage does not +automatically draw another one-photon floor. Transporting a classical realisation +through loss does not model vacuum injected by unused quantum ports; quantum +loss-channel completion and ensemble claims are outside this SEP. + +### 5. Proposed interface + +#### 5.1 Plane, launch and report records + +Use domain modules, with no generic utility or workflow framework. The proposed +public surface is: + +| Name | Responsibility | +|---|---| +| `FieldPlane` | Immutable physical-plane record with native coefficients/samples, references, geometry, medium, metric and provenance | +| `FieldPlane.from_output(output, plane=..., source=...)` | Read one stored plane and validate/rebuild its spatial and frame reference | +| `FieldPlane.from_analytic(grid, geometry, field, medium=..., reference=...)` | Sample an optics-convention physical analytic field with declared support and units | +| `FieldPlane.from_samples(...)` | Bind existing physical arrays to explicit axes, units, domain, medium and reference | +| `pulses.SpatialPulse` | The one Gaussian/super-Gaussian beam builder, extended to bind an incident plane and its physical energy | +| `FieldPlane.from_pulse(pulse, grid, geometry, medium=..., reference=...)` | Build a plane from `SpatialPulse`; no second Gaussian implementation | +| `regrid(plane, grid, ...)` | Controlled temporal representation/window/band conversion with its report | +| `transport(plane, elements, ...)` | Apply an ordered sequence of spatial and spectral elements; return the resulting plane and report | +| `couple(plane, target, ...)` | Project into an explicit target port; return `CouplingResult` | +| `CouplingResult` | Target-bound `launch` and a structured `report`, including provenance and numerical/model certificates | +| `driver.Simulation.input_port()` | Return the receiving grid, basis, medium, entrance reference and admitted coupling models | +| `driver.Simulation.with_input(result.launch)` | Validate and install the already normalised target launch | +| `coupling.CouplingModel` | Select the §4.3 model and explicit approximation budget | +| `coupling.Outcouple` | Synthesize the guide exit under a declared forward interface model | +| `coupling.FreeSpace` | Homogeneous angular-spectrum/Hankel transport with an output-plane sampling specification | +| `coupling.ThinLens` | The qualified paraxial phase screen and its chromatic focal length | +| `coupling.Aperture` / `coupling.TransmissionMask` | Intensity-defined aperture converted to amplitude / directly supplied complex field transmission | +| `coupling.PlanarInterface` | A stated bulk Fresnel model in its admitted geometry | +| `coupling.PolarisationTransform` / `coupling.Mirror` | Qualified Jones map / spectral reflection and incoming/outgoing frame map | +| `coupling.SpectralTransfer` | Adapt a transfer function or compressor result, retaining affine-phase and efficiency metadata | + +`FieldPlane`, `CouplingResult`, `regrid`, `transport` and `couple` are public +members of `solprop.coupling`; the table prefixes other modules explicitly. +`GaussianBeam` is not introduced. `SpatialPulse` remains the single beam builder, +and `FieldPlane.from_pulse` is its incident-field record boundary. The builder +and analytic/sampled constructors share one normalisation/projection path. +Replace ambiguous `propz` energy-at-waist semantics: requested energy is fixed +at the declared launch plane after geometric preparation. A waist reference +specifies shape/curvature; attenuation before that plane is a separate transport +operation. No deprecated alias or alternate restored-energy launch survives. + +The plane records its native representation and units, temporal grid and absolute +frequency lattice, power metric, transverse coordinates or modal reference, +polarisation axes, oriented physical plane and resolved medium. Its temporal +record carries the time origin, carrier frequency/phase reference, accumulated +frame phase and delay, and which affine phase calibrations are known. Source +identity, saved-plane index and preparation history supply provenance. Owned +read-only arrays prevent a caller from mutating a validated plane through an +alias; frozen attributes alone are insufficient. + +`source` supplies the original simulation or an equivalent verified reference +when an output does not embed its full modal tables. An extraction default may +select the final saved plane; an explicit z must resolve to a stored plane within +the declared coordinate tolerance, not silently choose a nearby physical plane. +Identity and integral frame metadata must be sufficient for later extraction. + +An analytic callback accepts physical x/y coordinates and absolute angular +frequency, returning components in declared SI spectral units; provide a separate +time-sampled constructor for an analytic time-domain field. Fix their exact +array signatures against the standard axis vocabulary during Phase 2. Native +modal coefficients keep `(n_modes, n_pol, n_omega)`, sampled local fields use +`(n_nodes, n_components, n_omega)`, and general vector modes keep the existing +distinction between `n_pol=1` and three physical components. No scalar squeezing +occurs in these records. + +`energy=` on an incident pulse means the physical energy crossing the declared +launch plane in its medium, before target coupling. A meter reading in air is +converted through an explicit window/interface and its transmission. Direct +modal amplitudes already describe light inside the guide. + +The named physical inputs are SI lengths, seconds, rad/s and radians; gas pressure +uses the existing bar-valued user interface. Target geometry and medium are +explicit. A finite-domain input says whether it declares the field to be zero +outside its samples or leaves the exterior unknown. Report unknown tails rather +than normalising an arbitrary crop as a complete measured beam. + +Alignment inputs specify lateral displacement in metres, paraxial tilt in radians +and polarisation rotation in the declared local axes. Their signs follow the +physical synthesis map. A radial operation requires cylindrical symmetry: +displacement, tilt, asymmetric masks and other symmetry-breaking operations +require an explicit Cartesian representation and its sampling qualification. +Vector profiles with angular structure are not silently reduced to order-zero +Hankel fields. + +All spatial incident-field entry points converge on this implementation. Replace +the current `transverse=` shape-plus-restored-energy path; either accept the +incident-field record directly or route that spelling through its incident-energy +semantics. There is no retained alternative that silently restores missed energy. +Direct modal amplitudes remain an explicit internal launch, not an incident beam. +The phase-1 fix must supply the incident integration domain and power measure at +the existing profile boundary. Merely removing the final rescaling is insufficient +if the profile was already normalised over the receiving guide's aperture. + +The target-bound launch includes a grid, basis, metric and entrance-position +identity. `with_input` refuses mismatch rather than interpreting coincident array +shapes as compatibility. It records the coupling provenance automatically and +adds no new noise unless the caller explicitly supplies it. A different initial +field may require a new ionisation table/rate reference: respect the existing +`with_input` shared-table limits and refuse an out-of-domain target rather than +silently rebinding physics. + +#### 5.2 Composition sketches + +The following is a proposed API sketch, not executable documentation: + +```text +exit_plane = FieldPlane.from_output(first_run, plane=-1, source=first_simulation) +transported = transport( + exit_plane, + [Outcouple(external_medium), FreeSpace(distance), + SpectralTransfer(compressor.transfer), ThinLens(focal_length), + Aperture(radius), FreeSpace(distance_to_entrance)], + geometry=receiving_plane_geometry, +) +coupled = couple(transported.plane, second_simulation.input_port(), + model=CouplingModel("scalar_paraxial", model_error=budget)) +second_run = second_simulation.with_input(coupled.launch).run() +print(coupled.report) +``` + +`Outcouple` synthesises the guide's field and applies an explicitly selected +interface approximation; it is not an implicit unit-efficiency Fresnel solver. +For direct guide-to-guide transfer the same `couple` operation evaluates the +overlap without a transverse propagation grid. `SpectralTransfer` adapts existing +`optics` objects and compression results. Preserve `optics.Chain` as the +frequency-only multiplication. `transport` accepts a `Chain` directly as one +frequency-only block, and accepts a chain as an item inside an ordered spatial +sequence. The report records the block's factors and its position; only scalar +frequency multipliers within that block commute. It never reorders a block past +focusing, clipping, propagation, projection or a noncommuting Jones operation. + +The radial-input workflow replaces `exit_plane` with the output of an ordinary +radial run and couples at the capillary entrance. Gaussian, analytic and sampled +fields replace only the source constructor. To obtain a far-field observation, +stop after transport to the specified output plane; do not project onto a guide. +A nonlinear gap is a spatial simulation between two such operations. + +#### 5.3 Refusals and outputs + +Refuse paths failing SEP 34's §4.5 certificate or outside its scope, +ambiguous jump sides, missing mode profiles or references, +inconsistent units/measures, ill-conditioned power metrics, occupied unsupported +bands/cutoff, unqualified lossy modal duals, unresolved windows, forbidden +field/envelope response combinations and invalid symmetry reductions. Identity +and zero transfers preserve exact structural contracts. + +Return immutable structured diagnostics, with finite meaningful energies and +explicit unavailable quantities/reasons, rather than invented efficiencies. +When incident energy is zero, efficiency is undefined but the zero field remains +valid. A numerical tolerance is never used to hide a finite model defect. +Source and target hashes, selected planes, operation order, reference changes, +material identities, model choices and remapping/loss reports enter configuration +provenance. Arrays may be recorded by the existing digest mechanism. The copied +launch must be sufficient to run the next stage without reopening the source. + +Scans compose these pure preparations in their existing builders. This SEP +does not add a scheduler, a multi-stage resume protocol or device arrays to the +public seam. Resume continues one simulation; coupling creates a new initial-value +problem with its own run identity. + +### 6. Implementation strategies + +#### 6.1 Layers and shared definitions + +Recommend a physics-layer `solprop.coupling` domain for plane specifications, +reference records and optical models. Put quadrature, conditioned projection, +remapping plans and validated numerical maps under `solprop.translate`; saved-plane +extraction belongs in processing and target installation in the driver. Share the +existing general-modal reference/identity machinery without making physics import +processing or a backend. Update the architecture's package table and tested +structure figure when these modules are implemented. + +The offline application is a host operation using NumPy/SciPy, independent of the +backend that created the source or runs the target. There is no reason to add a +new propagation backend for it. SEP 34 owns the hot spatial flux/statistics/noise +support that crosses the seam. There, T passes immutable arrays/factored +admittance data; B does not construct a material, mode or optical element. + +Reuse aperture definitions, mode profiles, Hankel scalings, Fourier boundaries, +material selection and spectral optics. Replace the duplicated launch accounting +at its source. Do not build a second general-modal synthesis reference beside +the checked one. No dependency is added: NumPy/SciPy provide quadrature, linear +algebra, interpolation and transforms. + +#### 6.2 Cost, memory and certification + +An overlap plan costs approximately $O(N_\omega N_q M_A M_B)$ in a direct +evaluation and stores $O(N_\omega M_A M_B)$ coefficients if materialised. A +single-field projection can stream with $O(N_qM_B)$ work per frequency. Use node +and frequency chunks when maps or full fields would dominate memory. Record +the actual choice and bytes; do not conceal a large reconstruction behind a +small output record. + +FFT/Hankel transport uses existing transforms on a common grid; independent-plane +direct evaluation costs $O(N_\omega N_{\rm in}N_{\rm out})$ and is chunked. +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](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. + +Certificates account separately for quadrature, basis conditioning, transverse +and temporal truncation, transform sampling and model approximation. Strict +admission must be expressed in physical/numerical error targets, not an arbitrary +universal percentage of missed light. Uncaptured physical power may be large and +perfectly valid; numerical uncertainty must be small enough for the requested +conclusion. + +#### 6.3 Backends and schemas + +The coupling/transport maps are host-side NumPy/SciPy operations. SEP 34 owns the +cross-backend flux changes, including an atomic public activation after +`numpy_ref`, `numba_cpu` and `native` CPU qualification; this SEP does not make a +working compiled spatial route temporarily refuse its existing inputs. + +Plane reference, launch and driver changes bump the kernel schema only when they +alter its protected contract; update literal tests and native consumers in the +same change. Persisted plane reference and lineage changes bump the output schema. +Use current-schema read/write/resume tests. No old-file migration is required. + +Test outputs and relaunches from all three CPU backends, including mixed +source/receiver pairs. The numerical host map is shared, not reimplemented three +times. GPU acceleration of offline maps is not required; native GPU propagation +and its flux kernels follow SEP 34's separate device qualification. No host +fallback is hidden inside a device statistic. + +### 7. Verification and validation + +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 +between independent backends. Copies, hashes, mode labels, explicit no-ops and +hard zeros retain exact structural tests. + +| Case | Comparison and acceptance evidence | +|---|---| +| Shared plane flux | Consume SEP 34's host evaluator; agree with its in-run observations at uniform and admitted radial-gradient planes, using analytic-signal rather than real-field endpoint integration | +| Incident launch and noise | Requested physical energy before coupling and captured energy after coupling; carry SEP 34's already normalised noise without a second draw | +| Identity and gauge | Same-guide recovery, rotated degenerate bases and coherent two-mode interference; physical E/H and energy invariant under a simultaneous basis/amplitude transformation | +| Scalar overlap | Gaussian waist/displacement/tilt/curvature formulas and independent Gaussian–Bessel quadrature; deliberate poor and zero capture, with no restoration of energy | +| Cross-guide coupling | Different radii, mode sets and supported solid guides; direct overlap versus synthesis/project route; passive-map certificate and explicit failures outside the admitted interface model | +| Modal truncation | Retain successively larger nested mode sets; distinguish quadrature convergence from captured-subspace convergence and finite-domain effects | +| Spatial transfer | Radial versus Cartesian for a symmetric beam; displaced/tilted input on Cartesian; refusal of symmetry destruction on a radial grid | +| Linear transport | Gaussian diffraction and focus, independent Fresnel/Bessel evaluation, aperture diffraction, thin-element order, reflection/Jones signs, and finite-distance convergence to the Fraunhofer limit | +| Spectral optics | Reuse existing element references; explicit $\lvert H\rvert^2$ attenuation, phase-only invariance in its admitted metric, no double Fresnel factor and an efficiency report that changes with spectral reshaping | +| Temporal conversion | Equal-bin scaling, unequal windows/sample spacings, delays, CEP, carrier changes, spectral zeros and separated bands; field/envelope round trips only on common supported physics | +| Refusals | Missing saved fields/references or required path certificate, exceeded gradient budget, ambiguous jump side, nonconstant absorbing path, unsupported measure/endpoint/cutoff, invalid provider dual, unresolved time/space support and route/backend mismatch | +| Provenance | New-schema memory/HDF5 round trips, configuration identity changes, target-bound input mismatch, and no duplicate noise draw | + +Use at least three refinement levels when asserting an order. Vary domain size, +node resolution, modal span and temporal sampling independently. A three-dimensional +array with more samples is not evidence of a larger physical domain. Compare +fields after applying the declared reference-frame conversion, not after fitting +away an unexplained phase or delay. + +The short integrated qualification set must include all eight workflows of §2, +including a radial field-grid Kerr/plasma/THG entrance calculation followed by +capillary propagation, and capillary SPM followed by a specified compressor and +a second capillary. The plasma/THG case checks the transferred generated bands, +not only the fundamental's total energy. Tests use about ten propagation steps +or fibres short enough to finish in seconds. Refine the physical approximation +and propagation separately from the coupling calculation. + +The tutorial below is an additional gallery deliverable. Its convergence evidence +must resolve the absolute change in incident-energy-normalised fundamental share +$s_{11}=U_{11}/U_{\rm incident}$ and first higher radial-mode share $s_{12}$. +For each claimed nonzero change, the combined independently estimated numerical +uncertainty of the two runs must be at most one tenth of its magnitude. This +factor is a reporting criterion, not a physical constant. Report total capture +separately; its change need not be the largest effect. Do not assume any sign. +Measure single/multimode overlaps, radial/Cartesian extraction, regridding and +scaled-plane transport with actual input/output sizes and memory. SEP 34 owns the +short-window benchmark ledger for hot flux changes. Never extrapolate a timing +into a full experimental run. + +### 8. Documentation + +Add a manual chapter `docs/guide/workflows/coupling.md` describing the four blocks, +their physical measures and approximations, source/target reference planes, +energy reports and all eight compositions. Include examples for a same-basis +handoff, a changed capillary radius, solid-fibre overlap, analytic and Cartesian +inputs, outcoupling and compression between stages. Cross-link input pulses, +general-modal launch, free-space setup, output access, polarisation and compression. +Update the API reference and public docstrings with units, shapes and refusals. + +Update conventions, architecture/structure, kernel/output contracts, numerical +accuracy and backend capability pages with implementation. Record the changed +incident-energy and spatial-flux semantics in `CHANGELOG.md`, with a SEP link, +and the deliberate difference on the Luna comparison page. No changelog entry +claims functionality merely because this draft was added. + +#### 8.1 Required focused tutorial + +Add **Nonlinear beam preparation and coupling into a capillary** as +`docs/tutorials/free_space/plot_08_radial_capillary_coupling.py` (use the next free +gallery number if that name is taken at implementation). It is a focused tutorial, +executes on normal docs builds, and is linked from the new coupling chapter and +the capillary/free-space guides. The manual explains how to convert a meter +reading through an entrance interface. Generate its output through Sphinx; do not +hand-edit the rendered gallery. + +Use a scalar linearly polarised Gaussian on an envelope grid in uniform argon. +The model contains Kerr self-focusing and self-phase modulation, with plasma, +THG and Raman omitted explicitly. The starting specification is: + +| Quantity | Value | +|---|---| +| Vacuum wavelength | 800 nm | +| Intensity FWHM | 30 fs | +| Argon pressure | 3 bar, uniform in gap and capillary | +| Temperature | The declared material reference temperature, recorded in the example | +| Gas gap before entrance | 20 mm | +| Linear-focus 1/e² intensity radius | 37.5 μm, at the capillary entrance; w/a = 0.5 | +| Capillary radius | 75 μm | +| Capillary segment | 5 mm | +| Initial strength target | Linear-Gaussian on-axis nonlinear phase $B_{\rm lin}=0.3$ rad | + +Determine energy from the physical material functions, not a copied nonlinear +coefficient. Let $n_0$ be the real gas index at the pump, $k_0=2\pi/\lambda_0$ +the vacuum wavenumber and $P_0$ the peak axial power. In the narrowband paraxial +sizing model, + +$$ + z_R=\frac{\pi n_0w_0^2}{\lambda_0},\quad + I_0(z)=\frac{2P_0}{\pi w_0^2[1+(z/z_R)^2]},\quad + n_2=\frac{3\chi^{(3)}}{4\varepsilon_0cn_0^2}. +$$ + +Integrating from $z=-L$ to the waist at zero gives + +$$ + B_{\rm lin}=\frac{2k_0n_2P_0z_R}{\pi w_0^2}\arctan(L/z_R),\quad + U=P_0\tau_{\rm FWHM}\frac{\sqrt\pi}{2\sqrt{\ln2}}. +$$ + +These equations follow directly from the Gaussian beam/pulse definitions and the +package's scalar Kerr normalisation. A sizing probe with the current material +functions at the revised waist gives about 12.1 μJ, 0.379 GW and 0.124 of the +physical critical-power estimate (the probe is recorded in the review response). +These are provisional analytic estimates, not a propagated or converged result. +Recompute with SEP 34's physical launch and critical-power conventions and +recorded optmat material identity. The earlier 50 μm, 14.1 μJ sizing is +superseded, not a regression target. + +The detuning is deliberate: the review identified a near-stationary fundamental +overlap at the old waist. At w/a = 0.5, scalar Gaussian–Bessel quadrature puts +about 0.906 of the incident energy in the fundamental and 0.092 in the next +radial mode. These numbers size the lesson only; the propagated broadband +complex field and converged mode set determine its results. Mild Kerr evolution +must be demonstrated to change the named modal shares above their error bounds; +neither a lens estimate nor the static overlap probe proves that outcome. + +Run two calculations from the identical physically focused incident field: +linear propagation through the gap, and propagation with Kerr enabled. At the +entrance, retain each full complex radial field and couple into the same capillary +mode set. Launch each captured result through the 5 mm capillary with identical +Kerr-only capillary physics, so the difference is the prepared incident field. +Do not replace either entrance field by a fitted Gaussian or its power spectrum. + +Name the linear modal beating in the 5 mm segment. With +$\beta_m\simeq nk_0-u_m^2/(2nk_0a^2)$, the fundamental/next radial-mode +beat length is + +$$ + 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} + \simeq22.5\ {\rm mm}. +$$ + +Here $u_{11},u_{12}$ are the first two J0 zeros, and the package labels the +family HE_1m. Thus 5 mm is about 0.22 beat periods: the output contains partial +linear mode beating, not merely modal filtering or a new nonlinear effect. +Evaluate the actual dispersive beta and differential wall loss across the +retained band when implementing the plots; the carrier estimate is explanatory. +The identical capillary physics in the two cases controls for this evolution. + +Plot beam radius and on-axis intensity through the gap; entrance fluence and +wavefront change; entrance temporal and spectral changes; incident/captured energy +and modal shares; and the short capillary outputs. Specify the slice or spectral +component used for wavefront phase, masking phase where amplitude is unresolved. +Use physically meaningful modal/block shares under the target metric. + +Before fixing the tutorial's grids and mode count, independently refine radial +domain, radial resolution, temporal window/band/sampling and retained modes. +Require visible but mild nonlinear changes and no unresolved collapse, collar +contamination or spatial aliasing. Check the magnitude of omitted ionisation +with the existing rate model as an offline diagnostic; if the Kerr-only +assumption is not defensible, reduce the strength target and document the reason +rather than silently adding plasma. The starting numbers are design coordinates, +not a promise that every omitted process is negligible. + +Assess omitted ionisation at the revised, higher on-axis intensity; the review's +assessment of the old waist is not a qualification of the new one. + +Keep the final tutorial deterministic and sized for a normal docs build. Full +nonlinear propagation is permitted for this focused gallery when needed to show +the effect, but its unit/parity regressions remain short. Report the runtime and +the numerical evidence with the implementation phase. This teaching calculation +is separate from the mandatory field-grid plasma/THG transfer test. + +### 9. Compatibility and impact + +The maintainer explicitly chooses a clean breaking change before release. +There are no legacy files to migrate for this work. Introduce no old-normalisation +switch, compatibility alias or deprecation period. Remove superseded launch +accounting, update callers and fixtures, and reject unsupported old schemas with +a clear version error rather than interpreting old data with new units. + +The observable differences are intentional: a spatial launch's requested energy +uses axial flux; incident energy is reduced by actual coupling; modal capture is +frequency-dependent and coherent; and transformed fields carry explicit phase +and basis references. Already supplied physical V/m arrays are not arbitrarily +rescaled merely because their reported energy changes. Current-schema resume +retains strict identities. Frame/reference metadata and flux records require +versioned output/seam changes when implemented, not in this documentation patch. + +SEP 34 owns corrected spatial energy, noise and observables and preserves admitted +radial gradient simulations through the adiabatic amplitude correction. +Coupling consumes its local plane measure; uniform-only restrictions apply to +the homogeneous `FreeSpace` element, not to all source simulations. The hybrid +planar and general-vector metrics keep their own derived meanings. + +SEP 5 consumes SEP 34 for solid carriers; SEP 8 retains vector crystal +fields/interfaces; SEP 9 retains its in-guide measurements. Their dependencies +and the roadmap follow that division. Nonadiabatic scattering, wider detector +catalogues and multi-pass-cell assembly remain explicit future work. Spatially +resolved compressor layouts remain outside this SEP. + +### 10. Alternatives considered + +| Alternative | Assessment | +|---|---| +| Bare output arrays between simulations | Convenient for an identical known basis; loses the reference, measure and remapping contract needed across routes | +| A separate helper for every source/target pair | Duplicates normalisation and Fourier boundaries; use synthesis, transport and projection adapters | +| Always materialise a Cartesian field | General but wasteful for direct modal overlaps and radial symmetry; retain native representations | +| Use `DataPulse` or restore launch energy after projection | Hides clipping and imperfect capture; preserve incident scale | +| Infer a Gaussian from radius/energy | Discards wavefront and space-frequency correlations produced by nonlinear propagation | +| Treat electric-field residual as lost power | Valid only for a derived common positive metric; separate the diagnostics | +| Multiply by one refractive index | Misses broadband and angular weighting and does not fix current work | +| Full junction scattering immediately | Needs reflected/radiation/evanescent bases and new solvers; outside the selected forward scope | +| Exact vector free-space optics immediately | Important at high NA, but a new propagation model; keep the current scalar/transverse-field route explicit | +| A fixed-window FFT for every distance | Can wrap or undersample the transfer; admit by sampling evidence and support independent output planes | +| Redesign the prism/grating models | Existing spectral models suffice for compensated ideal layouts; resolved spatial compressors are separate work | +| A multi-stage execution framework | Adds scheduling/resume complexity before the physical operations are qualified; compose ordinary simulations | +| Preserve vacuum-normalised APIs and old files | Explicitly rejected for this unreleased change; update the public interface cleanly | + +### 11. Phases + +No numerical implementation begins until acceptance. Implement SEP 34 first. +Its full public flux activation precedes SEP 31's incident-launch change, +as the maintainer chose on 2026-10-23, so there is no interval with corrected +modal launches but inconsistent spatial observations. SEP 34's private host +reference alone is not permission to merge SEP 31 phase 1 early. Each phase +records delivered paths, deviations, evidence, unresolved questions and handoff +in Part II. + +| Phase | Deliverables | Required evidence | +|---|---|---| +| **1 — Incident-energy correction** | Fix the present profile-launch defect with the shared physical measure and existing profile inputs, before introducing `FieldPlane`; preserve captured amplitudes rather than restoring requested energy | Poor and zero capture, full incident-domain normalisation, coherent mode shares and unchanged direct inside-guide amplitudes; all CPU receivers | +| **2 — Plane exchange** | `FieldPlane`, source/target references, `input_port`, same-basis relaunch and temporal remapping | Identity, delay/CEP/carrier and equal/unequal-grid tests; current-schema persistence; all CPU source/receiver pairs | +| **3 — Coupling** | Unify the phase-1 implementation behind analytic/sampled/`SpatialPulse` inputs, the concrete §4.3 models, overlaps, target-bound launches and reports | Gaussian/Bessel references, independently qualified provider/interface envelopes, passive-map certificates, and separate domain/quadrature/modal refinement | +| **4 — Transport core** | Homogeneous propagation, outcoupling, finite and far observation planes, bounded thin screens, explicit reflection/Jones frames, and existing spectral optics | Independent diffraction and bulk-interface references, angular/flux-defect checks, ordering, explicit efficiencies and affine-phase provenance | +| **5 — Integrated qualification and tutorial** | Eight bounded compositions from §2, the detuned radial tutorial, manual and capability matrix | Short field-grid Kerr/plasma/THG transfer, SPM → compressor → capillary, named modal-share uncertainty, CPU parity and final gates | + +**Final means this bounded core is complete.** Retain all eight compositions: +they exercise distinct seams (guide identity, spatial input, analytic/sampled +constructors, output-plane scaling and spectral phase). The required far field +is scalar and paraxial with its stated error bound; mirrors are local-frame +Jones/spectral maps, not coating or geometric-optics solvers; compressor blocks +reuse existing spectral transfer functions. No general junction scattering, +high-NA vector optics, resolved compressor layout, new in-run detector catalogue +or multi-pass-cell scheduler is required for Final. Each phase is useful before +Final; that status does not claim the wider optical-laboratory scope. + +During each slice run fast checks and directly affected modules. At the end run +the full gate, slow lanes and coverage once, or use their CI evidence, as +`AGENTS.md` specifies. Run the native gate if protected native consumers change. +After a failure rerun the failing modules. Tests/timings use short propagations; +the focused gallery has the standing full-problem exception. + +For this documentation revision run diff whitespace checks, codespell on changed +files, `tests/test_seps.py` and the warnings-as-errors Sphinx build. Phase 5, not +the draft, supplies the runnable tutorial. + +### 12. Open questions + +The physical model choices below are proposed explicitly for review, not delegated +to implementation. Qualification may narrow admission; a different model needs +a recorded proposal revision. + +| Question | Disposition | +|---|---| +| **Q1 — Cross-model projection** | Proposed resolution: the three models and error-budget admission in §4.3. Phase 3 must qualify each provider pair; no unqualified pair is admitted merely because a projection can be computed. | +| **Q2 — Thin elements and reflection** | Proposed resolution: §4.6's local paraxial screen, input/output angular-flux defect bound and explicit p/s frames. Refinement cannot substitute for a missing model bound. | +| **Q3 — Numerical admission budgets** | Phase 2/3 implements the required model budget and justified numerical/conditioning/tail enclosures under §4.3. Qualified pair envelopes may replace the Δ screen, never the total error bound. No coupling-efficiency floor is introduced. | +| **Q4 — Plane persistence** | Phase 2 fixes and tests the minimal standalone reference record; missing information is refused. | +| **Q5 — Tutorial evidence** | The maintainer chose 37.5 μm on 2026-10-23. Phase 5 fixes grids/mode count, qualifies the Kerr-only regime and resolves changes in s11 and s12 to §7's criterion. | +| **Q6 — Deferred physics** | General junction scattering, high-NA vector optics, varying absorption and resolved compressors remain roadmap work. Certified real-index radial forward amplitude transport belongs to SEP 34. | +| **Q7 — Native backend location** | Closed: `native/` in this repository, distributed as `solprop-native`; changes use the same branch and native gate. | + +Review completion recorded in the header describes the historical review below. +These revisions and SEP 34 still require acceptance; neither is marked Accepted. + +### 13. References + +1. G. K. Svendsen, M. W. Haakestad and J. Skaar, “Reciprocity and the scattering + matrix of waveguide modes,” *Physical Review A* **87**, 013838 (2013). + [doi:10.1103/PhysRevA.87.013838](https://doi.org/10.1103/PhysRevA.87.013838); + [author manuscript](https://arxiv.org/abs/1301.2458). +2. K. Matsushima and T. Shimobaba, “Band-limited angular spectrum method for + numerical simulation of free-space propagation in far and near fields,” + *Optics Express* **17**, 19662–19673 (2009). + [doi:10.1364/OE.17.019662](https://doi.org/10.1364/OE.17.019662); + [author repository](https://kansai-u.repo.nii.ac.jp/records/11127). +3. E. B. Treacy, “Optical pulse compression with diffraction gratings,” + *IEEE Journal of Quantum Electronics* **5**, 454–458 (1969). + [doi:10.1109/JQE.1969.1076303](https://doi.org/10.1109/JQE.1969.1076303). +4. L. Arissian and J.-C. Diels, “Carrier to envelope and dispersion control in a + cavity with prism pairs,” *Physical Review A* **75**, 013814 (2007). + [doi:10.1103/PhysRevA.75.013814](https://doi.org/10.1103/PhysRevA.75.013814). + +**Source audit.** The package's conventions, spatial operators/functionals, +ordinary/general-modal synthesis and projection, input replacement and optics +implementations were inspected. The author manuscript of [1] was inspected for +its reciprocity and junction scope. Publisher/repository records establish [2]'s +sampling problem and bibliographic identity; its full algorithm is not transcribed +or independently qualified here. [3] and [4] are the existing compressor models' +primary references; [4]'s publisher abstract/identity was checked, but neither +compressor derivation was newly revalidated from its full primary text in this +draft. Reuse their existing implementation and tests; verify any new external +formula in its implementation phase. The Gaussian integrals, scalar flux/work +identity and tutorial sizing are derived above rather than copied coefficients. +Material values continue to come from optmat's resolved models and sources. +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, 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 + 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. + +### 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 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 + 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. + +### 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. *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, + 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 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). +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 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 + `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. + +### 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 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 +`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 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 θ`; + 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(...))`. + +### 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). **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. +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`. + *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. + *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 + 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. + **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? + +**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` — 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. + +### 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. +- **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. 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); 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 + 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$ (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 + 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: 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. + 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). +- 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 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$, + $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.* *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}$ + (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 + $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 + 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.* 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 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. + +### 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, + output and offline-optics interfaces. Selected forward coupling, spectral + compressors, current free-space field models, controlled temporal remapping + and uniform-medium flux. Required clean breaking changes with no legacy + support and a mild-Kerr radial-input capillary tutorial. Identified cross-model + projection and thin-screen flux as explicit pre-acceptance questions. +- 2026-10-23: Reviewed by Opus 5.5 (AI), with John Travers's human review recorded at + his request. Part I is unchanged. The findings (R1–R10) and the maintainer's decisions + on R1, R2 and R5 follow. Reviewed records completion of review, not acceptance; + status remains Draft. +- 2026-10-23: Revised Part I in response to R1–R10, preserving the original review + below. Split medium flux into SEP 34, proposed bounded interface models, + moved the incident-energy defect to phase 1 and updated native-backend scope. + John selected the 37.5 μm tutorial waist and full complex admittance for local + flux traces. The response below records the resolutions and qualifications; + status remains Draft. +- 2026-10-23: AI re-review of the revised Part I by Opus 5.5 (S1–S4), recorded + after the response. No P1 finding remains; Part I unchanged by the reviewer. +- 2026-10-23: Maintainer decision on S2 recorded: phase 1 follows SEP 34's + activation. +- 2026-10-23: Revised Part I for S1–S4 and the settled SEP 34 contracts. Added + scoped path-certificate accounting, benchmark replacement of the weak-guidance + screen and the corrected tutorial beat-length derivation. Retained SEP 34's + full activation before the incident-energy fix. The response below records + the rationale; no implementation or new propagation qualification is claimed. +- 2026-10-23: Accepted by John Travers after the review responses, with an + 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. +- 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 + +**Assessment.** The direction is right and the physics is careful. Coupling through +a coherent field at a reference plane, with absolute incident scale and a refusal to +restore uncaptured light, fixes a real defect: `_projected_launch` today relaunches +the full declared energy however badly the channels capture the profile, as its own +docstring says. The derivations checked below are correct. The proposal is not yet +acceptable as written, for three reasons. It bundles a cross-backend change of +energy semantics with a host-only feature (R1). It removes a working capability, +radial gas-gradient runs, where a closed-form correction is available (R2). And its +two pre-acceptance gates, Q1 and Q2, ask for approval without proposing an answer +(R3). The tutorial's operating point may not show the effect it is meant to teach +(R4). + +**Maintainer decisions during review.** John asked for the flux question to be +explained, then decided: + +- **R1:** split the medium-flux prerequisite into its own SEP. +- **R2:** add the adiabatic amplitude term so that gradient runs keep working. +- **R5:** `energy=` means the physical energy (time-integrated axial Poynting + flux) in the launch medium, because that is what an energy meter measures. He + asked "What is the actual correct definition we should use for the flux"; + the answer is recorded under R5. + +**Review basis.** Read all of Part I against SEP 0, the architecture, conventions, +the free-space model page and the current implementation. Checked: +`Simulation.with_input`, `_projected_launch`, `project_transverse_field`, the vacuum +½ε₀c factors throughout `solprop.spatial`, `fourier.energy_omega`'s endpoint +convention, `spatial.aperture_profile` and the `optics.Chain` docstring. Every +factual claim in §3 matched the code. Independently re-derived: the scalar flux and +its discrete Parseval form (§4.2), the least-squares capture identity (§4.3), the +Gaussian displacement and tilt overlaps, the J₀ normalisation (§4.4), the +spectral-density scaling (§4.5), the Fresnel/Fraunhofer kernel, thin-lens sign and +normal-incidence Fresnel coefficients (§4.6), and the tutorial's n₂, B-integral and +energy formulas (§8.1). The tutorial sizing reproduces: n₂ ≈ 3×10⁻²³ m²/W at 3 bar +gives P₀ ≈ 0.46 GW against the stated 0.443 GW. Peak intensity is about +1.1×10¹³ W/cm², where argon ionisation is negligible, so the Kerr-only model is +defensible. All four references resolve, via Crossref, to the stated authors, +titles, volumes and pages. Ran one numerical probe (R4). This is a design review; +no implementation was qualified. + +Priorities: **P1** must be resolved in Part I before acceptance; **P2** needs a +concrete revision or recorded decision; **P3** is editorial. The drafting agent +should answer each point in a `Response to review` subsection, following SEP 0. + +#### R1 — P1: Split the medium-flux prerequisite into its own SEP + +**Sections:** 1, 3, 4.2, 6.3, 9, 11 (phases 1a–1c). +**Maintainer decision (2026-10-23):** split. + +SEP 31 contains two different kinds of work. Phases 1a–1c change the energy +semantics of every free-space route. Today `spatial_energy`, the intensity and +fluence functionals, `SpatialPulse(energy=…)`, `spatial_noise` and the in-run +statistics all use the vacuum ½ε₀c factor. Phases 1a–1c replace it with the Y_z +weighting. The statistics live in backend kernels, so the change needs work on all +three backends, and it changes the physical results of solid-medium runs by about +the refractive index. SEP 5 also depends on it. Phases 2–5 are host-only +NumPy/SciPy operations between runs, and they need a consistent measure only at the +hand-over plane. That measure can be evaluated offline from a saved field without a +kernel change. + +The bundle also creates a merge hazard. §11 calls the phases independently +mergeable, and §6.3 has incomplete backends "refuse the new contract by name" with +no vacuum fallback. Between phase 1a and phase 1c, main would refuse ordinary +spatial runs on the default compiled backend. + +**Requested revision:** move §4.2 (including the R2 term), the launch, noise and +statistics consistency work, and phases 1a–1c into a separate SEP, "medium flux for +free space". Within that SEP, the host-side launch and noise normalisation and the +kernel statistics land together, or in an order that never leaves a backend +refusing a currently working run. SEP 31 then lists that SEP under **Requires**. +Its coupling and transport evaluate the plane measure on the host through the same +shared definition. Update SEP 5's dependency and the roadmap entries that currently +attribute the flux correction to SEP 31. + +#### R2 — P1: Keep radial gas-gradient runs, with the adiabatic amplitude term + +**Sections:** 4.2 (*Uniform media only*), 6.3, 9, 12 (Q6). +**Maintainer decision (2026-10-23):** add the term; it belongs in the new flux SEP (R1). + +The radial route accepts a gas pressure `ZProfile`, for gas cells and jets (free-space +model, *Exact longitudinal operator*); Cartesian and line already refuse varying +media. As drafted, every operation needing the new flux contract refuses a +nonuniform medium, and no vacuum fallback remains. Energy-normalised launches and +energy statistics in those runs would therefore stop working. That is a regression +of a working capability, and it is avoidable. + +For the scalar forward model in a medium varying slowly on the wavelength scale, +conservation of $Y_z|\hat E|^2$ per transverse/frequency column requires + +$$ + \partial_z\hat E=\Bigl(ik_z-\tfrac12\partial_z\ln Y_z\Bigr)\hat E + \;\Rightarrow\; + \hat E(z_1)=\sqrt{\frac{Y_z(z_0)}{Y_z(z_1)}}\, + e^{i\int_{z_0}^{z_1}k_z\,dz}\,\hat E(z_0). +$$ + +The amplitude factor is closed-form and diagonal. It joins the integrated linear +exponent that the gradient route already evaluates, so it costs nothing per step. +Paraxially it reduces to $\sqrt{n(z_0)/n(z_1)}$. It is the adiabatic (WKB) forward +approximation and neglects reflection from the gradient. For gases, Δn ≈ 10⁻³ over +millimetres, so that reflection is many orders below any tolerance. Check whether +the nonlinear source prefactor's local $k_z$ stays consistent with this choice of +propagated variable. As drafted, it uses the local value. + +**Requested revision:** derive the term in the new SEP. Apply it on all three +backends. Qualify it as follows: a lossless gradient conserves the new energy to the +rounding budget, a uniform medium is unchanged, and a sharpening gradient tends +toward the transmitted share of an abrupt interface minus its reflection. Drop the +gradient refusal from §4.2, §9 and Q6. The remaining refusals cover only cases the +WKB model does not admit, such as abrupt steps that need an interface element. + +#### R3 — P1: Q1 and Q2 need proposed answers, not open approval + +**Sections:** 4.3, 4.6, 12 (Q1, Q2). + +Both questions are marked "before acceptance", but Part I proposes no model for the +maintainers to approve. Accepting the SEP now would accept an open design, and +leaving the choice to implementation is exactly what §12's preamble forbids. + +**Requested revision:** state the admitted cases concretely. For example, for Q1: + +1. A scalar forward overlap in the common uniform-medium paraxial measure for: + Gaussian or radial free-space fields into the ordinary capillary, capillary into + capillary of a different radius, and weakly guiding solid fibres below a stated + index contrast. +2. Provider E/H for general vector modes, with an E-only incident field closed by + $\mathbf H=Y\hat z\times\mathbf E$ and a stated $O(\theta^2,\Delta n)$ error. +3. Everything else refused. + +For Q2: the paraxial thin mask and lens in the local measure, with a defect bound +computed from the flux fraction beyond a stated angle where $Y_z$ departs from its +paraxial value by more than the tolerance, and refusal above it. Give the numbers or +the rule that fixes them. Q3 can then stay an implementation-phase budget. + +#### R4 — P2: The tutorial sits at the stationary point of capture + +**Sections:** 7, 8.1, 12 (Q5). + +The linear-focus spot of 50 μm in a 75 μm capillary gives w/a = 0.667. Gaussian +capture into the EH₁₁ mode peaks at w/a ≈ 0.645 (η ≈ 0.981), so capture there is +nearly stationary in spot size. A direct quadrature of the §4.4 scalar overlap +gives: + +| w/a | η₁₁ | η₁₂ | +|---|---|---| +| 0.645 | 0.9807 | 0.0052 | +| 0.647 (−3 %) | 0.9807 | 0.0048 | +| 0.667 (tutorial) | 0.9792 | 0.0020 | +| 0.687 (+3 %) | 0.9756 | 0.0004 | +| 0.500 | 0.9062 | 0.0923 | +| 0.515 (+3 %) | 0.9220 | 0.0764 | + +At P/P_cr ≈ 0.15, the Kerr lens changes the entrance spot by a few per cent. Around +the tutorial's point that moves total EH₁₁ capture by only 10⁻³ to 10⁻⁴. That is +comparable with the numerical uncertainty that §7 requires the effect to exceed. The +EH₁₂ share, in contrast, changes by a factor of several, and at w/a = 0.5 the +fundamental's capture moves by about 1.6 % per 3 %. + +**Requested revision:** choose deliberately. One option is to make the stationarity +the lesson: total capture is insensitive, while higher-mode content, wavefront and +spectrum change. The other is to detune the focus so that capture is first-order +sensitive. Name the quantity the convergence evidence must resolve in §7 and Q5, +rather than "the nonlinear change in modal capture". + +#### R5 — P2: State what `energy=` physically means + +**Sections:** 1, 4.2, 5.1, 9. +**Maintainer decision (2026-10-23):** physical energy in the launch medium. + +John's question was the principled one. A user knows a beam's energy from a meter +and wants to model that energy. The answer should be in Part I, not left implicit in +the Y_z formula: + +- Energy is the time-integrated axial Poynting flux through a plane. That is + conserved, and it is what a meter measures. +- For a paraxial plane wave in a nonmagnetic medium, $S_z=\tfrac12 n\varepsilon_0c|E|^2$ + with the **phase** index (Re k/ω), not the group index. The energy density + $\tfrac12\varepsilon_0nn_g|E|^2$ times $v_g=c/n_g$ gives the same result. +- The vacuum ½ε₀c|E|² equals the energy only for n = 1. It overstates the field by + √n in glass, and by about 0.04 % (0.08 % in energy) in a few-bar gas. The modal routes already use + true power, so the vacuum measure is the inconsistent one. +- A reading taken in air before an entrance window becomes the in-medium energy + through a declared interface element (Fresnel or AR transmission), never + implicitly. + +**Requested revision:** add a short paragraph with these points to the new flux SEP +(R1), and a sentence to §5.1 here that `energy=` and a `GaussianBeam`'s energy mean +the physical energy at the declared launch plane in its medium. Add a note to §8 that +the manual explains the mapping from a meter reading. + +#### R6 — P2: Fix the incident-energy defect early and on its own + +**Sections:** 3, 5.1, 11. + +The fix to `_projected_launch`, which stops it restoring missed energy, is a current +correctness defect. It does not depend on `FieldPlane`, regridding or transport. In +the phase table it sits inside phase 3, behind phase 2's plane-exchange machinery. + +**Requested revision:** make the unified incident-energy launch (§5.1, *All spatial +incident-field entry points converge*) the first deliverable after the flux SEP, with +its own evidence: deliberate poor and zero capture, and no restored energy. Then +order the remaining phases. + +#### R7 — P2: Interface gaps in §5 + +**Sections:** 5.1, 5.2. + +1. `GaussianBeam` duplicates the Gaussian of `SpatialPulse`, while §5.1 says all + spatial incident-field entry points converge. Say whether `GaussianBeam` + replaces `SpatialPulse`'s Gaussian, wraps it, or is its incident-field record. A + clean breaking change is allowed, so pick one. +2. The sketch in §5.2 uses `Simulation.input_port()`, `Outcouple`, `FreeSpace`, + `SpectralTransfer`, `ThinLens` and `Aperture`. None of them is in the §5.1 table. + List them with their modules and responsibilities, since the element vocabulary + is the public surface users will write against. +3. Say whether `transport` accepts an existing `optics.Chain` directly (the frequency + block) and how it reports that the chain's order is not significant while the + spatial sequence's order is. + +#### R8 — P2: The native-backend text is out of date + +**Sections:** 3, 6.3, 11 (phase 1c), 12 (Q7). + +SEP 29's first phase has already moved the compiled backend into this repository as +`native`; the free-space line-route text calls it that. The conditional "if SEP 29 +has moved Kokkos…" is now settled, and the phase name "Kokkos CPU flux" uses the old +name. + +**Requested revision:** use `native` (its CPU build), drop the conditional, and close +Q7. If R1 is taken, this moves with phases 1a–1c into the new SEP. + +#### R9 — P2: The "complete" criterion is broad + +**Sections:** 7, 11 (phase 5), 12 (Q6). + +The integrated qualification requires all eight workflows of §2. Those include the +radial field-grid Kerr/plasma/THG transfer and an SPM → compressor → second-capillary +chain. They are good targets. But Final status then depends on the far field, +Jones/reflection frames and thin elements as well as the coupling core. + +**Requested revision:** consider letting the SEP reach Final on the coupling and +transport core, with a clear list of what Final requires. Alternatively, state why +every workflow is needed for the first useful release. Either way, keep the field-grid +plasma/THG transfer, which is the case most likely to expose band and reference +errors. + +#### R10 — P3: Editorial + +- §4.2: the discrete rule uses the analytic-signal convention, which keeps the DC and + Nyquist bins unscaled, as `fourier.energy_omega` does. Say that §7's "direct + time/space integration" means $\tfrac12\int|E_a|^2$. A real-field $\int E^2$ + differs in those two bins. +- §4.3, §4.5: W denotes the flux pairing matrix, the least-squares Gram matrix and + the per-frequency metric $W_k$. Distinguish them, or say that they coincide when + M is the flux measure. +- §6.2 depends on SEP 28's memory bounds. Link SEP 28 there, or list it under + **Requires**. +- §4.4: keep the sentence forbidding an asserted universal coupling optimum. The + probe in R4 confirms that the value depends on the profile and the mode set. + +### Response to review — 2026-10-23 + +The revised Part I addresses all ten findings. The original review above remains +the record of what was reviewed; its completion does not imply acceptance of this +revision. John selected the detuned tutorial and complex-admittance treatment +while the revision was being prepared. + +- **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 + NumPy, Numba CPU and native CPU: no intermediate merge changes the default + convention while a supported CPU backend refuses existing runs. SEPs 5, 8 + and 9 and the roadmap now point to the appropriate owner. +- **R2 — Accepted with a correction to validation.** SEP 34 derives the + `−½ ∂z ln Y` amplitude term, its endpoint form and its relationship to the + existing radial linear operator. It preserves qualified smooth radial gas + gradients. The proposed sharp-gradient Fresnel test cannot be a convergence + target for this forward-only model: for n1 = 1 and n2 = 1.5 it predicts T = 1, + while Fresnel gives T = 0.96 and R = 0.04. Even a gas-sized step to n2 = 1.0008 + has nonzero R ≈ 1.60 × 10⁻⁷. Instead, compare against an independently refined + wave solution in the adiabatic range and refuse profiles outside the admitted + error budget. Exact endpoint cancellation for represented weights is not a + guarantee about interpolation or model error. No additional FFT is needed + for the propagation correction, but derivatives, endpoint evaluation and + local-flux reconstruction still have costs that must be measured. +- **R3 — Resolved by concrete proposals.** Section 4.3 names same-basis, + scalar-paraxial and vector-paraxial models, their profile requirements and + quantitative admission budgets. It requires provider/interface qualification + beyond small-angle or weak-guidance screens; passivity alone cannot establish + junction accuracy. Section 4.6 bounds the thin-screen discrepancy between + local paraxial power and axial flux, including spectral tails, and specifies + mirror coordinate frames. Unqualified pairs and exceeded budgets refuse + explicitly. These are proposed acceptance contracts, not claims that the + implementations have already met them. +- **R4 — Accepted; maintainer choice recorded.** The tutorial now uses 37.5 μm + (w/a = 0.5) and B = 0.3 rad. It must resolve changes in incident-normalised + fundamental and first higher radial-mode shares, each to a combined numerical + uncertainty no larger than one tenth of the claimed change. Independent + Gaussian/Bessel quadrature gives fundamental/second-mode shares about + 0.9062/0.0923 at w/a = 0.5 and 0.9220/0.0764 at 0.515. This supports detuning + as a useful teaching choice, but does not predict the propagated nonlinear + change. The review's comparison of a tiny change with numerical uncertainty + remains a hypothesis until the propagation and refinement study is run. +- **R5 — Accepted and extended.** Incident energy is the physical energy crossing + the specified plane in its medium. An external meter reading requires explicit + interface transmission; phase index determines transparent plane-wave flux, + while group index appears in stored energy density. A further issue arose + during revision: in absorbing media the real admittance fixes integrated + energy, but its imaginary part contributes to local broadband interference + terms. John agreed to retain the full complex admittance for local traces and + its real part for total energy and noise. SEP 34 derives both contracts. +- **R6 — Accepted.** Immediately after SEP 34, phase 1 fixes existing profile + launches before introducing `FieldPlane`. The fix preserves the captured + fraction using an explicit incident domain and measure; it does not merely + delete the last rescaling after an aperture-normalised input. Phase 3 folds + the same implementation into the unified coupling interface. +- **R7 — Accepted.** `SpatialPulse` remains the sole Gaussian/spatial pulse + builder and `FieldPlane.from_pulse` consumes it. Section 5 lists modules, + constructors, `Simulation.input_port` and the minimum transport elements. + Existing frequency-only `Chain` values are accepted as one ordered transport + block; their internal scalar multiplication does not reorder spatial optics. +- **R8 — Accepted.** The proposal uses the in-repository `native/` backend and + its CPU build. Q7 is closed, and SEP 34 owns its flux implementation and parity. +- **R9 — Bounded scope retained and justified.** Section 11 makes Final depend on + the eight named compositions within the restricted paraxial, forward and + spectral-optics models. Each checks a distinct exchange or reference seam, + so none is left implicit. General junction scattering, high-NA vector optics + and spatially resolved compressors remain deferred. The short field-grid + Kerr/plasma/THG transfer and capillary → compressor → capillary qualification + remain mandatory. +- **R10 — Accepted.** SEP 34 states the analytic-signal DC/Nyquist convention and + distinguishes its time-domain energy reference from a real-field endpoint + identity. SEP 31 uses F for the E/H pairing matrix, G for the scalar + least-squares Gram matrix and Q for the native power metric. Section 6.2 links + SEP 28's memory contract. The warning against a universal coupling optimum + remains in §4.4. + +**Sizing evidence, not propagation qualification.** A fresh analytic calculation +with the current optmat 0.8.0 argon functions at 3 bar and 293.15 K gives +n0 ≈ 1.00077206, n2 ≈ 3.09311 × 10⁻²³ m²/W and zR ≈ 5.5266 mm for the +37.5 μm waist. The B = 0.3 estimate gives U ≈ 12.1136 μJ and P ≈ 0.379331 GW, +or approximately 0.1238 of the critical-power estimate converted to physical +power. The Gaussian sizing and independent scalar overlaps were recalculated; +no nonlinear propagation, omitted-response assessment, grid convergence or +backend qualification is claimed by this documentation revision. Those remain +required implementation evidence. + +### AI re-review of the revision — 2026-10-23 + +**Assessment.** The revision resolves R1–R10. The split into +[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 +one-sided brackets. `SpatialPulse` remaining the only beam builder settles the +duplication. **No P1 finding remains.** SEP 31 can go forward for acceptance once +SEP 34, which it requires, is settled. The points below are refinements. + +**Review basis.** Read the revised Part I in full, the response to review and the +diff against the reviewed text. Recomputed the revised tutorial sizing with the +stated optmat values: z_R = 5.527 mm, P₀ ≈ 0.380 GW and U ≈ 12.1 μJ, matching the +draft. Peak on-axis intensity rises to about 1.7×10¹³ W/cm², which is still far +below significant argon ionisation over 30 fs; §8.1 rightly requires it to be +reassessed. Checked the R2 correction with an independent transfer-matrix solution +of $E''+q^2E=0$ (recorded in the SEP 34 review). + +**On the R2 correction.** The response is right, and the original request was +wrong. A forward-only adiabatic model transmits all the flux at a step. For +n = 1 → 1.5 the exact solution gives R = 0.0400, while the model gives T = 1. A +sharpening profile can only show the model leaving its admitted range; it cannot +converge to Fresnel transmission. The SEP 34 review (A1) adds a closed-form bound +that makes this precise. + +#### S1 — P2: Use SEP 34's closed-form certificate for gradient sources + +**Sections:** 4.2, 5.3, 7 (Refusals). + +§4.2 and §5.3 refuse "a nonadiabatic gradient", which leaves the criterion to +SEP 34. The SEP 34 review (A1) recommends a closed-form, per-column certificate +in place of profile-family qualification. If it is adopted, refer to that rule +here rather than restating it. The coupling report then quotes the source plane's +certified forward-field error alongside the other model errors in the +`model_error` sum. + +#### S2 — P2: Phase 1 may need only SEP 34's host reference + +**Sections:** 11 (phase 1), 6.3. +**Maintainer decision (2026-10-23):** keep the present ordering. The +incident-energy fix lands after SEP 34's public activation, so there is no window +of mixed conventions. State that reason in §11. + +As written, the incident-energy correction waits for SEP 34's full three-backend +activation. The defect it fixes is gross: a badly captured profile is relaunched at +the full requested energy, an error of order one. It also lives on the modal launch +path, not in a spatial kernel. The only physical input it needs is the incident +measure at the entrance plane, and that is SEP 34's host reference definition. In +a gas-filled capillary, the medium measure differs from the vacuum one by about +10⁻³. + +**Recommendation, for maintainer decision:** either allow phase 1 to merge after +SEP 34's phase 1 (shared host reference), with that ≈10⁻³ inconsistency against +still-vacuum spatial observations documented until activation; or keep the +present ordering and state the reason, which is no window of mixed conventions. +Either is defensible. The SEP should say which was chosen. + +#### S3 — P3: The weak-guidance screen is strict for ordinary fibres + +**Section:** 4.3 (*Admission rule*). + +With $\Delta\le\epsilon_m/8$, a standard step-index single-mode fibre (Δ ≈ 0.36 %) +needs $\epsilon_m\gtrsim2.9\,\%$. A 1 % model budget therefore refuses +fibre-to-fibre coupling between such fibres, even identical ones, where the scalar +overlap is exact by symmetry. The text already calls the allocation conservative +and requires independent benchmarks. Say whether those benchmark envelopes may +replace the Δ screen for a qualified provider pair. Otherwise the screen, not the +physics, sets the smallest usable budget. + +#### S4 — P3: Name the mode beating in the tutorial capillary + +**Section:** 8.1. + +For a = 75 μm at 800 nm in near-unity-index gas, the EH₁₁–EH₁₂ beat length +$2\pi/\Delta\beta=8\pi a^2/[(u_{12}^2-u_{11}^2)\lambda]$ is about 22.5 mm. The +5 mm segment is roughly a quarter beat, and EH₁₂'s extra loss over 5 mm is +negligible. At w/a = 0.5, with about 9 % in EH₁₂, the output profiles therefore +show partial beating rather than modal filtering. That is fine, but the tutorial +text should say so, so that a reader does not mistake the beat phase for a +nonlinear effect. Comparing linear-prepared and Kerr-prepared launches over the +same segment already controls for it. + +### Response to re-review — 2026-10-23 + +The original re-review above is retained. The following resolutions are now +in Part I; the proposal remains `Draft` pending formal maintainer acceptance. + +- **S1 — Accepted with error units and scope made explicit.** Sections 4.2, + 5.3 and 7 refer to SEP 34's per-column real-index path certificate, including + jump sides and refusal of nonconstant absorbing paths. Section 4.7 carries + that certificate separately in the report. A relative field error δ implies + an energy discrepancy bounded by 2δ + δ² in the same positive metric; this + conversion and the map norm are required before adding it to an energy + budget. The background-path certificate alone is not a bound on a nonlinear + source simulation, which retains its separate qualification. Local flux uses + SEP 34's retained forward-component closure. +- **S2 — Maintainer decision retained.** Section 11 explicitly requires SEP 34 + to be implemented and publicly activated first, including its required + backend qualification. This avoids mixed launch and observation conventions. + Its private host reference alone does not enable an earlier SEP 31 phase 1 + merge. +- **S3 — Accepted.** Section 4.3 allows an independently qualified provider-pair + envelope to replace the conservative Δ screen over its declared range. + It does not replace the angular/metric checks, passivity or total model-error + budget. Identical checked bases already use `same_basis`, avoiding an + artificial weak-guidance floor for coordinate recovery. +- **S4 — Accepted with a formula correction.** Section 8.1 derives + $L_{\rm beat}=8\pi^2 n a^2/[(u_{12}^2-u_{11}^2)\lambda_0]$ from the scalar + capillary beta expansion. The re-review's numerical 22.5 mm estimate is + correct, but its printed formula is missing one factor of π. With the stated + carrier index and J0 zeros, the static estimate is 22.5045 mm, so 5 mm is + about 0.222 periods. The tutorial explains partial beating, uses the package's + HE_1m labels and requires evaluation of actual dispersive beta and wall loss + when the plots are implemented. + +All rereview points have proposal-level answers. Grid/mode convergence, +interface qualification, the retained short field-grid Kerr/plasma/THG transfer, +the compressor workflow and the tutorial's nonlinear signal-to-error margin +remain mandatory implementation evidence, not unresolved design choices. 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 83fc3c844..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 | Accepted | 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/dev/seps/sep-0031-simulation-coupling.md b/docs/dev/seps/sep-0031-simulation-coupling.md deleted file mode 100644 index 662734f81..000000000 --- a/docs/dev/seps/sep-0031-simulation-coupling.md +++ /dev/null @@ -1,1600 +0,0 @@ -# SEP 31 — Coupling simulations and transporting ultrafast fields - -| | | -|---|---| -| **SEP** | 31 | -| **Title** | Coupling simulations and transporting ultrafast fields | -| **Author** | John Travers | -| **Drafting agent** | GPT-6 (draft and 2026-10-23 review response) | -| **Status** | Accepted | -| **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 | -| **Supersedes** | — | -| **Superseded-by** | — | -| **Implementation** | — | -| **Manual pages** | — | - -## Part I — Proposal - -### 1. Abstract - -Connect solprop simulations through a coherent field at a reference plane, spatial -synthesis and projection, linear optical transport, and a validated launch into -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 -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 -short complete workflows on all three CPU backends. A required focused tutorial -compares linear and mildly nonlinear radial focusing before coupling into a -capillary. This is a draft design, not an announcement of implemented interfaces. - -### 2. Motivation and scope - -A simulated pulse is often the input to another optical component. Carrying only -its spectrum or duration loses wavefront, modal coherence, polarisation and the -frequency dependence of its spatial profile. Carrying a bare array preserves more -information but leaves its basis, units, frame, grid and energy meaning implicit. -The intended users need to assemble experiments without rebuilding those -conversions in each script. - -| Requested calculation | Composition proposed here | -|---|---| -| Capillary output into another capillary | Extract a modal plane; apply a direct overlap or outcouple, transport and recouple; launch the captured amplitudes | -| Nonlinear radial beam into a capillary | Run the gas gap with existing Kerr/plasma/THG responses; extract its entrance-plane field; project onto the capillary channels | -| Solid-core fibre into another fibre, or different hollow guides | Evaluate the source and target profiles at their actual end/entrance planes, with an explicit forward interface approximation and spectral overlap | -| Gaussian beam into a guide | Construct a normalised incident field, including curvature and polarisation, and use the same projection | -| Arbitrary analytic field into a guide | Sample the declared field and incident domain, establish its power measure, and project | -| Arbitrary Cartesian free-space beam into a guide | Reconstruct the complex field, remap to the projection rule and project without imposing radial symmetry | -| Capillary output at a distant free-space location | Synthesize the exit field, apply the declared outcoupling model and propagate to an observation plane | -| SPM, compression and another capillary stage | Compose ordinary simulations with spectral optics, focusing/apertures and coupling operations | - -**Included:** existing transverse mode families with actual field profiles; -ordinary and general-modal outputs; scalar or transverse-polarisation radial and -Cartesian fields; Gaussian and nonseparable analytic/sampled inputs; alignment; -controlled temporal remapping; spectral compressors; thin focusing elements, -apertures and declared mirror/polarisation operations; homogeneous isotropic -linear gaps; physical incident-energy accounting; CPU qualification and provenance. -The uniform-in-y line route uses J/m and W/m throughout. It cannot be coupled to -a finite-energy two-dimensional beam without an explicitly supplied y profile. -The hybrid planar guide can participate through its actual modal/FFT synthesis; -it must not be mistaken for a grid of electric-field samples. - -**Excluded:** a general electromagnetic junction solver with backward, radiation -and evanescent channels; feedback between stages or a cavity fixed-point solver; -new mode solvers; high-NA vector free-space propagation; anisotropic crystal -transport; resolved angular dispersion or spatial chirp inside prism/grating -compressors; nonlinear thin elements; automatic optical layout optimisation; -adaptive spatial meshes; and a new distributed workflow scheduler. The radial -adiabatic amplitude correction belongs to SEP 34, not to the host transport API. -Nonlinear gaps use the existing propagation routes. Field-grid Kerr/plasma/THG -is a qualification case; it is not the teaching tutorial's model. - -The release has no backward-compatibility requirement for this change. Replace -inconsistent interfaces and defaults directly, update their callers, tests and -documentation, and add no legacy normalisation, deprecated aliases, compatibility -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): - -- `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 - 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 - them before their waist; `PlanarPulse` and `modes.slab.gaussian_projection` - provide a specialised planar projection. `DataPulse` interpolates a measured - temporal spectrum and scales it to a requested energy; that scaling is unsuitable - for transferring an already normalised simulation output. -- `solprop.translate.modal_tables.project_transverse_field` uses the general - guide's coordinate dual. Its `ProjectedLaunch.residual` is an electric-field - 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. -- `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 - 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 - that limitation. Existing far-field statistics are selected diagnostics, not a - coherent output field at an arbitrary receiving plane. - -Existing tests cover modal synthesis/recovery, the current projected-launch energy -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 -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 -its full-vector crystal E/H contract and interface solver. -[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 -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 -into this repository as `native`, distributed as `solprop-native`. Qualification -uses its CPU build. This draft's historical review retains the names it reviewed. - -### 4. Theory - -#### 4.1 One physical plane, several representations - -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 -changes local coordinates and polarisation consistently, not by an unexplained -conjugation of the field. - -Let $\mathcal S_A(\omega)$ synthesize a source representation into a physical -field and $\mathcal P_B(\omega)$ recover the target coordinates. A declared -linear transport $\mathcal L$ gives - -$$ - C(\omega)=\mathcal P_B(\omega)\mathcal L(\omega)\mathcal S_A(\omega), - \qquad \mathbf b_B=C\mathbf b_A. -$$ - -This factorisation is a composition rule, not a claim that every combination of -ports and interfaces is physically valid. Each adapter declares its field model, -support, normalisation and approximation. Identity transfers keep their native -representation. A common quadrature can evaluate a direct overlap without storing -the intermediate field at every temporal frequency. - -For an ordinary guide, synthesize the physical field with the existing -vacuum-normalised basis after dividing saved $b_m$ by -$\sqrt{\operatorname{Re}n_{{\rm eff},m}}$ at the selected plane. For a general -guide, use its E/H tables and metric at that plane. Use the source exit radius, -material and tracked gauge, not their entrance values. The target uses its own -entrance reference. A change of modal gauge changes the amplitudes and the maps -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 -measure, its analytic-signal endpoints, physical launch/noise scaling, statistics -and radial adiabatic transport. For a plane in an isotropic medium it supplies - -$$ - Y_z(\boldsymbol\kappa,\omega;z) - =\varepsilon_0c^2\frac{\operatorname{Re}k_z}{\omega},\qquad - U=\frac12\int\operatorname{Re}(\mathbf E_a\cdot\mathbf G_a^*)\,dA\,dt, - \quad \widetilde{\mathbf G}_a - =\mathcal Y_c\widetilde{\mathbf E}_a. -$$ - -Tildes here denote the transverse/frequency spectrum: apply admittance there, -then reconstruct G in space and time. Here $\operatorname{Re}\mathcal Y_c=Y_z$. -Local flux uses the full complex -admittance of the retained forward component, whereas total -energy uses its real part; replacing one by the other before reconstruction -would lose absorbing-medium interference terms. - -The host plane evaluator and propagation statistics use the same definition and -resolved material at the selected z. Coupling does not require an extra kernel -statistic or a second flux implementation. Physical V/m remains the propagated -spatial state; ordinary and general modal ports retain their derived native -metrics. A planar hybrid port uses its modal/FFT metric, not a bulk index factor. - -A saved plane from a radial path admitted by SEP 34 §4.5's per-column -total-variation certificate is a valid source. Its local admittance, jump side -where needed, path certificate and accumulated frame/amplitude references must -be available. Transport through that path remains an ordinary radial simulation; -the `FreeSpace` element below is homogeneous. Refuse a failed/missing certificate, -an ambiguous jump side or an unsupported absorbing path, not every gradient. -Record the certificate's scope: it bounds linear-background forward coordinates, -not the complete nonlinear simulation or an omitted reflected field. The scalar -metric is not a replacement for the full-vector Maxwell treatment of high-NA or -crystal fields. - -On supported optical bands, `energy=` is the physical energy crossing the -declared plane, not a vacuum-equivalent field norm. For a lossless paraxial plane -wave its flux is $n_0\varepsilon_0c|E_a|^2/2$, using the phase index. SEP 34 explains -the distinction from energy density and the conversion of an external meter -reading through an explicit interface. - -#### 4.3 Projection, power and an interface are different objects - -For a lossless forward vector basis, define the Hermitian flux pairing - -$$ - \langle\psi_m,\psi_n\rangle_F= - \frac14\int(\mathbf e_n\times\mathbf h_m^* - +\mathbf e_m^*\times\mathbf h_n)\cdot\hat{\mathbf z}\,dA, - \qquad F_{mn}=\langle\psi_m,\psi_n\rangle_F. -$$ - -The coordinates of a represented field satisfy $\mathbf a=F^{-1}\mathbf B$, -where $B_m=\langle\psi_m,(\mathbf E,\mathbf H)\rangle_F$. The recovered -power is $\mathbf a^\dagger F\mathbf a$. The package's existing E-only shortcut -doubles one half of B for a forward superposition in its admitted lossless basis. -Its exact recovery on that subspace does **not** prove that an unrelated incident -beam has the target guide's magnetic field. Use incident H when available; an -E-only input must declare its forward closure and validity. Do not reuse a -nonlinear source-projection matrix to recover field coordinates. - -For scalar fields in a common positive power space, let S contain target basis -columns and M the quadrature/power measure. Weighted least squares gives - -$$ - G=S^\dagger M S,\qquad - \mathbf a=G^{-1}S^\dagger M\mathbf f,\qquad - \|\mathbf f\|_M^2=\mathbf a^\dagger G\mathbf a+ - \|\mathbf f-S\mathbf a\|_M^2. -$$ - -The last equality follows because the residual is M-orthogonal to S. Only in -such a common positive space does it identify a nonnegative uncaptured share. -The existing electric-field residual and an arbitrary E/H flux projection do -not automatically satisfy this identity. A basis outside a common admitted power -space needs its declared interface approximation, not an assertion of orthogonal -energy loss. - -Use F for the E/H flux matrix, G for the scalar least-squares Gram matrix, -and Q for a native representation's spectral power metric. They coincide only -when the synthesis and common measure establish that identity. In particular, -ordinary saved true-power coefficients have Q equal to the identity; a general -guide may have a dense Q. A profile overlap must not silently replace Q by G. - -**Admitted models.** Make the following choices explicit in `CouplingModel`: - -1. `same_basis`: exact coordinate recovery in a checked common modal reference, - including a declared invertible basis/gauge change. Provider E/H and F recover - coordinates; this is not an interface calculation. -2. `scalar_paraxial`: ordinary scalar capillary modes, incident - Gaussian/analytic/sampled scalar or transverse-Jones beams, and scalar - weak-guidance solid modes with physical profiles. Use the common local measure - $M_0=Y_0(\omega)dA/2$, with $Y_0=n_{\rm ref}\varepsilon_0c$, on the whole - incident plane; solve G and convert the resulting coordinates to the target's - native Q. Different capillary radii and weakly guiding fibre cross-sections - are admitted subject to the bounds below. The capillary core gas fixes - $n_{\rm ref}$; its wall loss is propagated by the guide model, not inferred - as facet transmission. Its scalar boundary approximation is reported. -3. `vector_paraxial`: provider E/H for lossless bound general modes, in the same - weak-guidance/paraxial regime and a common transverse reference. Recover with - F using both incident E and H. An E-only incident beam explicitly uses - $\mathbf H_t=Y_0\hat{\mathbf z}\times\mathbf E_t$; this closure is qualified - against plane-wave Maxwell fields and provider H. Longitudinal field - reconstruction requires the provider or a separately declared closure; - an $O(\theta^2)$ axial-power error is not an $O(\theta^2)$ bound on every - omitted longitudinal field component. - -All other physical junctions are refused: strong index contrast, unresolved facet -near fields, cutoff, unqualified lossy/nonorthogonal duals, arbitrary discontinuities -and a request for reflected or radiation fields. A bulk entrance/exit index -change uses a separate qualified planar interface before projection. Do not -substitute modal effective indices into its Fresnel coefficients. -Reference [1] explains why coordinate recovery alone is not boundary matching. - -**Admission rule.** `CouplingModel` takes a required positive `model_error` -budget $\epsilon_m<1$; it is a dimensionless energy/axial-flux approximation -target, separate from numerical accuracy. No universal index contrast or angle -is called exact. For the two approximate models: - -- choose $\theta_*$ by $1-\cos\theta_*=\epsilon_m/8$ and bound angular tails - with §4.6's energy-weighted rule, for source and retained target subspace; -- require $\Delta=\max|n^2-n_{\rm ref}^2|/(2n_{\rm ref}^2)\le\epsilon_m/8$ - over the occupied band and dielectric regions of a weak-guidance solid - provider; this criterion does not apply to a capillary wall as if it were a - weakly guiding dielectric; -- bound the relative metric defect between G and target Q, and between the - common source measure and its native Q, by $\epsilon_m/8$ each, using - conditioned quadratic forms on the retained subspaces; -- for `vector_paraxial`, bound the transverse H-closure error in the same - normalised operator norm by $\epsilon_m/8$; supplied incident H is retained. - The bound concerns every admitted input in that port, not one lucky pulse. - -The Δ screen is a conservative sufficient screen, not an immutable minimum -budget for every solid-fibre pair. A qualified provider-pair benchmark envelope -may replace **that screen** if it bounds the omitted vector/facet effects over -the declared wavelength, geometry, alignment and mode span to the requested -budget. Record its identity, range and independently bounded numerical error; -do not extrapolate it. Angular support, metric/closure checks, passivity and -the total budget still apply. Identical checked bases use `same_basis` directly -and need no weak-guidance screen for coordinate recovery. - -These are conservative screening allocations, not a theorem that small contrast -alone bounds facet scattering. Each provider/model pair must additionally have -an independent plane-wave or mode-matching benchmark envelope for omitted -reflection and facet error. The sum of those errors, angular/metric/closure -defects and resolved tails must fit $\epsilon_m$. Unqualified combinations -remain refused. The independent benchmarks and budgets are deliverables, not -permission to choose a different model during implementation. - -For a proposed passive finite map C between native metrics $Q_A,Q_B$, check - -$$ - C^\dagger Q_BC\preceq Q_A -$$ - -within the separately recorded numerical and model bounds, using a conditioned -whitened-map singular-value or quadratic-form certificate. Small apparent gain -within a model bound is reported as a model defect, never as negative absorption; -outside the bound refine or refuse. Do not rescale C or clip efficiency to one. -This certificate is necessary, not sufficient, evidence of a physical interface. -Positive uncaptured power is claimed only in the common positive metric where -the orthogonal-residual identity holds. - -Integrate incident power over its declared incident domain, including light -outside the target aperture. Integrating only over the target core would erase -geometrical clipping. For an analytic field, establish a tail bound or convergence -under domain enlargement. For measured/sampled data, record the finite-domain -assumption; unobserved tails are unknown, not measured losses. Separate errors in -the quadrature, the transverse window and the retained modal span. - -#### 4.4 Analytic overlap references - -For normalised scalar Gaussians at a common flat-wavefront plane and admittance, -$u_j=\sqrt{2/(\pi w_j^2)}\exp(-r^2/w_j^2)$, direct completion of the square -gives the captured power for relative displacement d, - -$$ - \eta=\left(\frac{2w_1w_2}{w_1^2+w_2^2}\right)^2 - \exp\left[-\frac{2d^2}{w_1^2+w_2^2}\right]. -$$ - -For centred beams with transverse phase difference -$\exp(i\Delta\boldsymbol\kappa\cdot\mathbf r)$, multiply the aligned result -by $\exp[-|\Delta\boldsymbol\kappa|^2w_1^2w_2^2/ -(2(w_1^2+w_2^2))]$. A curvature difference replaces the real Gaussian inverse -width in the integral with its complex counterpart. These formulas are restricted -to the common paraxial scalar measure; they do not replace the medium/interface -calculation. Direct two-dimensional integration is an independent test of the -implemented alignment signs and amplitude. - -For a scalar capillary fundamental, let u be the first positive zero of $J_0$. -Inside radius a, use the normalised profile -$v=J_0(ur/a)/(\sqrt{\pi}a|J_1(u)|)$ and zero outside. The Bessel integral -$\int_0^a rJ_0(ur/a)^2dr=a^2J_1(u)^2/2$ fixes that normalisation. Gaussian -capture is the square of $2\pi\int_0^a r v^*u_1dr$, with the incident Gaussian -normalised over the whole plane. Evaluate this integral independently and sweep -waist, curvature and mode count; do not paste an asserted universal optimum -coupling percentage into a test. Vector/profile corrections are assessed against -the actual `Mode` fields, not absorbed into this scalar reference. - -#### 4.5 Reference phase, arrival time and temporal grids - -A saved spectrum is in the run's moving frame. If its linear operator removed -$R(\omega,z)=\beta_0(z)+\beta_1(z)(\omega-\omega_{\rm ref})$, the optics -spectrum in the common reference acquires $\exp[+i\int R\,dz]$. Use exactly -the terms removed by that route: on a field grid R is $\beta_1\omega$; an -envelope with or without THG has the separately documented constant term. Track -the accumulated affine phase as a phase offset and time offset, so a long path -need not numerically translate a femtosecond pulse out of its window. A subsequent -stage subtracts its chosen reference once. No per-mode recentering is permitted. - -Differentiate three conversions: the sign convention, the raw DFT's time origin, -and raw-DFT amplitude scaling. Let $s_k$ be an optics-convention raw spectrum -centred at t=0. Its normalised analytic spectral density amplitude is - -$$ - f_k=\frac{\Delta t}{\sqrt{2\pi}}q_ks_k, -$$ - -where $q_k=1$ for an envelope and $q_k=2$ on interior positive field bins -(1 at endpoints). Then $\Delta\omega\sum f_k^\dagger Q_kf_k$ is the -energy on the modal representation; apply the spatial metric analogously. -Identical physical frequencies can be copied after this scaling and reference -conversion, then divided by the target scale. Copying raw DFT amplitudes between -different N and $\Delta t$ is not this operation. - -For noncoincident lattices, use a complex linear Fourier remap of the source -finite record, with its actual time origin and envelope carrier. A direct -evaluation of its finite-time Fourier sum is the reference; zero padding, -Fourier resampling and chunked matrix evaluation may accelerate the same declared -map. Treat complex spectral zeros naturally, without interpolating an undefined -unwrapped phase. A larger time window extends a localised record only under an -explicitly checked edge/tail assumption. A shorter window or smaller band is an -explicit lossy operation with a report. No requested energy rescales the answer. - -Conversion does not recover clipped, aliased, absorbed or never recorded content. -Refuse a target that cannot represent occupied positive frequencies, a materially -nonlocalised time record without a specified truncation, or a representation -outside the receiving route's physics. Field/envelope parity is checked only for -a common admitted physical model. In particular, envelope plasma with THG and -general-modal envelope propagation remain unsupported by this SEP. - -#### 4.6 Transport between planes - -At each absolute temporal frequency, homogeneous propagation is - -$$ - \widetilde E(\boldsymbol\kappa,\omega;L) - =\exp[i k_z(\boldsymbol\kappa,\omega)L] - \widetilde E(\boldsymbol\kappa,\omega;0). -$$ - -Radial symmetry uses the order-zero Hankel representation; Cartesian propagation -uses the transverse Fourier representation with its own spatial origin and -scaling. Refuse significant unsupported evanescent launch content rather than -using a propagation kernel's numerical evanescent cap as an interface model. -Sampling an exact exponential poorly is still inaccurate. Bound the relevant -phase sampling and periodic wrap, qualify domain enlargement, and report any -intentional angular band removal. Reference [2] motivates these sampling checks; -its filtering is not permission to hide removed field energy. - -Evaluate an independent output grid through the continuous/discrete reference -transform, with a fast uniform-grid path where valid. Chunked direct Fourier or -Bessel evaluation is the correctness reference for a scaled observation plane. -Do not allocate a transverse-by-transverse-by-frequency propagation tensor. - -For a paraxial observation at L in a transparent uniform medium, the Fraunhofer -limit of the Fresnel integral is - -$$ - E(\mathbf r,\omega;L)\simeq - \frac{k e^{ikL}}{2\pi iL}e^{ik|\mathbf r|^2/(2L)} - \int E(\mathbf r',\omega;0) - e^{-i(k/L)\mathbf r\cdot\mathbf r'}dA'. -$$ - -Here the integral uses an explicitly negative-sign spatial transform, independently -of the positive-sign temporal optics convention. A sufficient far-field condition -over source radius R is $kR^2/(2L)\ll1$ for the occupied band; compare with the -finite-distance propagator and refine the output window and sampling. Preserve -the complex phase and the frequency-dependent angular-to-position map. A -far-field spectrum integrated through an aperture is not a substitute for this -field when another coherent element follows it. - -A thin lens has paraxial factor -$\exp[-ik(\omega)r^2/(2f(\omega))]$ in the local plane. A focusing mirror -uses its declared focal power with its spectral reflection/Jones response. -An amplitude aperture multiplies by A, with intensity transmission $|A|^2$; -reuse `spatial.aperture_profile` with an explicit conversion from its intensity -weight. The operation's plane must say whether it is a near-field, image or -far-field plane. Rotations and reflections carry the coordinate handedness and -Jones axes consistently. - -**Thin-screen model and bound.** Use the local paraxial electric-field screen -at each frequency; it is exact only in the local reference measure -$U_0(E)=\int Y_0|\widetilde E|^2\,d\boldsymbol\kappa\,d\omega/2$ with the -declared transform weights. For transparent media $Y_z/Y_0=\cos\theta$. -For a selected angular cone $\theta\le\theta_*$, - -$$ - 0\le U_0(E)-U_Y(E) - \le (1-\cos\theta_*)U_{0,\rm cone}(E)+U_{0,\rm tail}(E) - \equiv d(E). -$$ - -Evaluate both input and output, including the spectrum generated by a lens or -aperture. Given the required `model_error` $\epsilon_m$, choose -$1-\cos\theta_*=\epsilon_m/8$, and admit only if -$[d(E_{\rm in})+d(E_{\rm out})]/U_Y(E_{\rm in})$, plus numerical/domain error, -fits $\epsilon_m$. Zero input is an exact separate case. The tail is evaluated, -not silently discarded. An unresolved hard-edge tail or significant -nonpropagating content requires explicit removal with its own report or refusal. -For a reusable map, certify the whole declared source subspace; for a single -field, label the certificate as applying only to that field. - -For a phase screen $U_0$ is invariant; the inequality bounds its axial-flux -defect. For a contractive amplitude/Jones screen, local loss -$L_0=U_0(E_{\rm in})-U_0(E_{\rm out})$ is nonnegative, and the difference -between L0 and the measured axial-energy decrement is bounded by the same sum -of d terms. Report that difference as approximation error, not absorption. -This gives a concrete admission rule; $|A|\le1$ alone is not enough in the -nonlocal Y metric. - -For a mirror, specify incident/outgoing propagation normals and a shared -s direction perpendicular to their plane; at normal incidence the caller's -declared s axis resolves the degeneracy. Set -$\hat{\mathbf p}_{\rm in/out}=\hat{\mathbf s}\times\hat{\mathbf k}_{\rm in/out}$. -The right-handed local frames and the supplied complex p/s reflection -coefficients define the Jones map. Convert the field between these frames -explicitly, without conjugation; qualify normal-incidence and double-reflection -signs. Coefficients calibrated in another p/s convention must be converted. -The outgoing beam is forward relative to its own plane normal. - -For a normal-incidence, homogeneous, nonmagnetic, lossless planar interface, -$t_E=2n_1/(n_1+n_2)$ and $r_E=(n_1-n_2)/(n_1+n_2)$ follow from tangential E/H -continuity. Power transmission is $T=(n_2/n_1)|t_E|^2$, giving $R+T=1$. -This is a useful declared interface model and analytic test. It is not obtained -by inserting two modal effective indices into a bulk Fresnel formula. More -general angular/polarisation coefficients require their own declared planar -geometry and flux test; arbitrary facets remain outside scope. Never apply a -Fresnel factor twice through both the element and the launch adapter. - -Existing `Material`, mirrors and compressor pairs act through $H(\omega)$. -Their amplitude and phase affect every spatial sample coherently. `Material` -removes a reference group delay; mirror data can lack constant phase and delay; -grating/prism models remove affine phase and omit some efficiencies. Adapt those -facts explicitly into the plane's reference/calibration record. Require supplied -efficiency when a lossy prediction is requested from a phase-only model. -Negative material lengths or inverse mirror phases are reconstruction operations, -not passive physical elements. Keep their purpose explicit. - -#### 4.7 Accounting across a chain - -For each operation record input/output energy on the admitted support, its metric, -specified material/reflection loss, aperture/model interpretation, basis loss -where defined, intentional spectral/window removal, and unresolved numerical -or model error. Known losses add sequentially in joules; multiplying broadband -scalar efficiencies is generally wrong when an operation changes the spectrum. -Carry SEP 34's source certificate as a separate, scoped report item. Where -$\delta$ bounds the relative forward-field error in the same positive metric, -$2\delta+\delta^2$ bounds the corresponding relative energy discrepancy; -use that conversion and the admitted map norm before adding it to an -energy-valued `model_error` budget. Do not add a field error directly to an -energy-loss fraction. For successive field-error maps compose their bounds -conservatively, rather than assuming independent errors cancel. A nonlinear -source's background-path certificate alone supplies no end-to-end field-error -bound; include its separate propagation qualification and report what is unknown. -Retain spectral reports so wavelength-dependent coupling remains visible. - -For multiple coherent input channels, even a diagonal target metric does not -permit projecting intensities: the interference is in C times the complex field. -For a dense target metric, individual diagonal channel contributions are not -independent physical powers. Report the total quadratic form and any meaningful -block shares; do not invent positive per-channel efficiencies by dropping cross -terms. A field entirely outside a retained subspace may give a valid zero launch; -it does not trigger a normalisation division by zero. - -Carry the realised incoming field and its provenance once. The existing modified -noise scheme has auxiliary bookkeeping; reconstruct according to its documented -saved-field meaning, not by adding every stored noise array. A new stage does not -automatically draw another one-photon floor. Transporting a classical realisation -through loss does not model vacuum injected by unused quantum ports; quantum -loss-channel completion and ensemble claims are outside this SEP. - -### 5. Proposed interface - -#### 5.1 Plane, launch and report records - -Use domain modules, with no generic utility or workflow framework. The proposed -public surface is: - -| Name | Responsibility | -|---|---| -| `FieldPlane` | Immutable physical-plane record with native coefficients/samples, references, geometry, medium, metric and provenance | -| `FieldPlane.from_output(output, plane=..., source=...)` | Read one stored plane and validate/rebuild its spatial and frame reference | -| `FieldPlane.from_analytic(grid, geometry, field, medium=..., reference=...)` | Sample an optics-convention physical analytic field with declared support and units | -| `FieldPlane.from_samples(...)` | Bind existing physical arrays to explicit axes, units, domain, medium and reference | -| `pulses.SpatialPulse` | The one Gaussian/super-Gaussian beam builder, extended to bind an incident plane and its physical energy | -| `FieldPlane.from_pulse(pulse, grid, geometry, medium=..., reference=...)` | Build a plane from `SpatialPulse`; no second Gaussian implementation | -| `regrid(plane, grid, ...)` | Controlled temporal representation/window/band conversion with its report | -| `transport(plane, elements, ...)` | Apply an ordered sequence of spatial and spectral elements; return the resulting plane and report | -| `couple(plane, target, ...)` | Project into an explicit target port; return `CouplingResult` | -| `CouplingResult` | Target-bound `launch` and a structured `report`, including provenance and numerical/model certificates | -| `driver.Simulation.input_port()` | Return the receiving grid, basis, medium, entrance reference and admitted coupling models | -| `driver.Simulation.with_input(result.launch)` | Validate and install the already normalised target launch | -| `coupling.CouplingModel` | Select the §4.3 model and explicit approximation budget | -| `coupling.Outcouple` | Synthesize the guide exit under a declared forward interface model | -| `coupling.FreeSpace` | Homogeneous angular-spectrum/Hankel transport with an output-plane sampling specification | -| `coupling.ThinLens` | The qualified paraxial phase screen and its chromatic focal length | -| `coupling.Aperture` / `coupling.TransmissionMask` | Intensity-defined aperture converted to amplitude / directly supplied complex field transmission | -| `coupling.PlanarInterface` | A stated bulk Fresnel model in its admitted geometry | -| `coupling.PolarisationTransform` / `coupling.Mirror` | Qualified Jones map / spectral reflection and incoming/outgoing frame map | -| `coupling.SpectralTransfer` | Adapt a transfer function or compressor result, retaining affine-phase and efficiency metadata | - -`FieldPlane`, `CouplingResult`, `regrid`, `transport` and `couple` are public -members of `solprop.coupling`; the table prefixes other modules explicitly. -`GaussianBeam` is not introduced. `SpatialPulse` remains the single beam builder, -and `FieldPlane.from_pulse` is its incident-field record boundary. The builder -and analytic/sampled constructors share one normalisation/projection path. -Replace ambiguous `propz` energy-at-waist semantics: requested energy is fixed -at the declared launch plane after geometric preparation. A waist reference -specifies shape/curvature; attenuation before that plane is a separate transport -operation. No deprecated alias or alternate restored-energy launch survives. - -The plane records its native representation and units, temporal grid and absolute -frequency lattice, power metric, transverse coordinates or modal reference, -polarisation axes, oriented physical plane and resolved medium. Its temporal -record carries the time origin, carrier frequency/phase reference, accumulated -frame phase and delay, and which affine phase calibrations are known. Source -identity, saved-plane index and preparation history supply provenance. Owned -read-only arrays prevent a caller from mutating a validated plane through an -alias; frozen attributes alone are insufficient. - -`source` supplies the original simulation or an equivalent verified reference -when an output does not embed its full modal tables. An extraction default may -select the final saved plane; an explicit z must resolve to a stored plane within -the declared coordinate tolerance, not silently choose a nearby physical plane. -Identity and integral frame metadata must be sufficient for later extraction. - -An analytic callback accepts physical x/y coordinates and absolute angular -frequency, returning components in declared SI spectral units; provide a separate -time-sampled constructor for an analytic time-domain field. Fix their exact -array signatures against the standard axis vocabulary during Phase 2. Native -modal coefficients keep `(n_modes, n_pol, n_omega)`, sampled local fields use -`(n_nodes, n_components, n_omega)`, and general vector modes keep the existing -distinction between `n_pol=1` and three physical components. No scalar squeezing -occurs in these records. - -`energy=` on an incident pulse means the physical energy crossing the declared -launch plane in its medium, before target coupling. A meter reading in air is -converted through an explicit window/interface and its transmission. Direct -modal amplitudes already describe light inside the guide. - -The named physical inputs are SI lengths, seconds, rad/s and radians; gas pressure -uses the existing bar-valued user interface. Target geometry and medium are -explicit. A finite-domain input says whether it declares the field to be zero -outside its samples or leaves the exterior unknown. Report unknown tails rather -than normalising an arbitrary crop as a complete measured beam. - -Alignment inputs specify lateral displacement in metres, paraxial tilt in radians -and polarisation rotation in the declared local axes. Their signs follow the -physical synthesis map. A radial operation requires cylindrical symmetry: -displacement, tilt, asymmetric masks and other symmetry-breaking operations -require an explicit Cartesian representation and its sampling qualification. -Vector profiles with angular structure are not silently reduced to order-zero -Hankel fields. - -All spatial incident-field entry points converge on this implementation. Replace -the current `transverse=` shape-plus-restored-energy path; either accept the -incident-field record directly or route that spelling through its incident-energy -semantics. There is no retained alternative that silently restores missed energy. -Direct modal amplitudes remain an explicit internal launch, not an incident beam. -The phase-1 fix must supply the incident integration domain and power measure at -the existing profile boundary. Merely removing the final rescaling is insufficient -if the profile was already normalised over the receiving guide's aperture. - -The target-bound launch includes a grid, basis, metric and entrance-position -identity. `with_input` refuses mismatch rather than interpreting coincident array -shapes as compatibility. It records the coupling provenance automatically and -adds no new noise unless the caller explicitly supplies it. A different initial -field may require a new ionisation table/rate reference: respect the existing -`with_input` shared-table limits and refuse an out-of-domain target rather than -silently rebinding physics. - -#### 5.2 Composition sketches - -The following is a proposed API sketch, not executable documentation: - -```text -exit_plane = FieldPlane.from_output(first_run, plane=-1, source=first_simulation) -transported = transport( - exit_plane, - [Outcouple(external_medium), FreeSpace(distance), - SpectralTransfer(compressor.transfer), ThinLens(focal_length), - Aperture(radius), FreeSpace(distance_to_entrance)], - geometry=receiving_plane_geometry, -) -coupled = couple(transported.plane, second_simulation.input_port(), - model=CouplingModel("scalar_paraxial", model_error=budget)) -second_run = second_simulation.with_input(coupled.launch).run() -print(coupled.report) -``` - -`Outcouple` synthesises the guide's field and applies an explicitly selected -interface approximation; it is not an implicit unit-efficiency Fresnel solver. -For direct guide-to-guide transfer the same `couple` operation evaluates the -overlap without a transverse propagation grid. `SpectralTransfer` adapts existing -`optics` objects and compression results. Preserve `optics.Chain` as the -frequency-only multiplication. `transport` accepts a `Chain` directly as one -frequency-only block, and accepts a chain as an item inside an ordered spatial -sequence. The report records the block's factors and its position; only scalar -frequency multipliers within that block commute. It never reorders a block past -focusing, clipping, propagation, projection or a noncommuting Jones operation. - -The radial-input workflow replaces `exit_plane` with the output of an ordinary -radial run and couples at the capillary entrance. Gaussian, analytic and sampled -fields replace only the source constructor. To obtain a far-field observation, -stop after transport to the specified output plane; do not project onto a guide. -A nonlinear gap is a spatial simulation between two such operations. - -#### 5.3 Refusals and outputs - -Refuse paths failing SEP 34's §4.5 certificate or outside its scope, -ambiguous jump sides, missing mode profiles or references, -inconsistent units/measures, ill-conditioned power metrics, occupied unsupported -bands/cutoff, unqualified lossy modal duals, unresolved windows, forbidden -field/envelope response combinations and invalid symmetry reductions. Identity -and zero transfers preserve exact structural contracts. - -Return immutable structured diagnostics, with finite meaningful energies and -explicit unavailable quantities/reasons, rather than invented efficiencies. -When incident energy is zero, efficiency is undefined but the zero field remains -valid. A numerical tolerance is never used to hide a finite model defect. -Source and target hashes, selected planes, operation order, reference changes, -material identities, model choices and remapping/loss reports enter configuration -provenance. Arrays may be recorded by the existing digest mechanism. The copied -launch must be sufficient to run the next stage without reopening the source. - -Scans compose these pure preparations in their existing builders. This SEP -does not add a scheduler, a multi-stage resume protocol or device arrays to the -public seam. Resume continues one simulation; coupling creates a new initial-value -problem with its own run identity. - -### 6. Implementation strategies - -#### 6.1 Layers and shared definitions - -Recommend a physics-layer `solprop.coupling` domain for plane specifications, -reference records and optical models. Put quadrature, conditioned projection, -remapping plans and validated numerical maps under `solprop.translate`; saved-plane -extraction belongs in processing and target installation in the driver. Share the -existing general-modal reference/identity machinery without making physics import -processing or a backend. Update the architecture's package table and tested -structure figure when these modules are implemented. - -The offline application is a host operation using NumPy/SciPy, independent of the -backend that created the source or runs the target. There is no reason to add a -new propagation backend for it. SEP 34 owns the hot spatial flux/statistics/noise -support that crosses the seam. There, T passes immutable arrays/factored -admittance data; B does not construct a material, mode or optical element. - -Reuse aperture definitions, mode profiles, Hankel scalings, Fourier boundaries, -material selection and spectral optics. Replace the duplicated launch accounting -at its source. Do not build a second general-modal synthesis reference beside -the checked one. No dependency is added: NumPy/SciPy provide quadrature, linear -algebra, interpolation and transforms. - -#### 6.2 Cost, memory and certification - -An overlap plan costs approximately $O(N_\omega N_q M_A M_B)$ in a direct -evaluation and stores $O(N_\omega M_A M_B)$ coefficients if materialised. A -single-field projection can stream with $O(N_qM_B)$ work per frequency. Use node -and frequency chunks when maps or full fields would dominate memory. Record -the actual choice and bytes; do not conceal a large reconstruction behind a -small output record. - -FFT/Hankel transport uses existing transforms on a common grid; independent-plane -direct evaluation costs $O(N_\omega N_{\rm in}N_{\rm out})$ and is chunked. -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); -do not add a permanent transverse-by-frequency array when the root can be -evaluated from existing separable coefficients in a bounded workspace. - -Certificates account separately for quadrature, basis conditioning, transverse -and temporal truncation, transform sampling and model approximation. Strict -admission must be expressed in physical/numerical error targets, not an arbitrary -universal percentage of missed light. Uncaptured physical power may be large and -perfectly valid; numerical uncertainty must be small enough for the requested -conclusion. - -#### 6.3 Backends and schemas - -The coupling/transport maps are host-side NumPy/SciPy operations. SEP 34 owns the -cross-backend flux changes, including an atomic public activation after -`numpy_ref`, `numba_cpu` and `native` CPU qualification; this SEP does not make a -working compiled spatial route temporarily refuse its existing inputs. - -Plane reference, launch and driver changes bump the kernel schema only when they -alter its protected contract; update literal tests and native consumers in the -same change. Persisted plane reference and lineage changes bump the output schema. -Use current-schema read/write/resume tests. No old-file migration is required. - -Test outputs and relaunches from all three CPU backends, including mixed -source/receiver pairs. The numerical host map is shared, not reimplemented three -times. GPU acceleration of offline maps is not required; native GPU propagation -and its flux kernels follow SEP 34's separate device qualification. No host -fallback is hidden inside a device statistic. - -### 7. Verification and validation - -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 -between independent backends. Copies, hashes, mode labels, explicit no-ops and -hard zeros retain exact structural tests. - -| Case | Comparison and acceptance evidence | -|---|---| -| Shared plane flux | Consume SEP 34's host evaluator; agree with its in-run observations at uniform and admitted radial-gradient planes, using analytic-signal rather than real-field endpoint integration | -| Incident launch and noise | Requested physical energy before coupling and captured energy after coupling; carry SEP 34's already normalised noise without a second draw | -| Identity and gauge | Same-guide recovery, rotated degenerate bases and coherent two-mode interference; physical E/H and energy invariant under a simultaneous basis/amplitude transformation | -| Scalar overlap | Gaussian waist/displacement/tilt/curvature formulas and independent Gaussian–Bessel quadrature; deliberate poor and zero capture, with no restoration of energy | -| Cross-guide coupling | Different radii, mode sets and supported solid guides; direct overlap versus synthesis/project route; passive-map certificate and explicit failures outside the admitted interface model | -| Modal truncation | Retain successively larger nested mode sets; distinguish quadrature convergence from captured-subspace convergence and finite-domain effects | -| Spatial transfer | Radial versus Cartesian for a symmetric beam; displaced/tilted input on Cartesian; refusal of symmetry destruction on a radial grid | -| Linear transport | Gaussian diffraction and focus, independent Fresnel/Bessel evaluation, aperture diffraction, thin-element order, reflection/Jones signs, and finite-distance convergence to the Fraunhofer limit | -| Spectral optics | Reuse existing element references; explicit $\lvert H\rvert^2$ attenuation, phase-only invariance in its admitted metric, no double Fresnel factor and an efficiency report that changes with spectral reshaping | -| Temporal conversion | Equal-bin scaling, unequal windows/sample spacings, delays, CEP, carrier changes, spectral zeros and separated bands; field/envelope round trips only on common supported physics | -| Refusals | Missing saved fields/references or required path certificate, exceeded gradient budget, ambiguous jump side, nonconstant absorbing path, unsupported measure/endpoint/cutoff, invalid provider dual, unresolved time/space support and route/backend mismatch | -| Provenance | New-schema memory/HDF5 round trips, configuration identity changes, target-bound input mismatch, and no duplicate noise draw | - -Use at least three refinement levels when asserting an order. Vary domain size, -node resolution, modal span and temporal sampling independently. A three-dimensional -array with more samples is not evidence of a larger physical domain. Compare -fields after applying the declared reference-frame conversion, not after fitting -away an unexplained phase or delay. - -The short integrated qualification set must include all eight workflows of §2, -including a radial field-grid Kerr/plasma/THG entrance calculation followed by -capillary propagation, and capillary SPM followed by a specified compressor and -a second capillary. The plasma/THG case checks the transferred generated bands, -not only the fundamental's total energy. Tests use about ten propagation steps -or fibres short enough to finish in seconds. Refine the physical approximation -and propagation separately from the coupling calculation. - -The tutorial below is an additional gallery deliverable. Its convergence evidence -must resolve the absolute change in incident-energy-normalised fundamental share -$s_{11}=U_{11}/U_{\rm incident}$ and first higher radial-mode share $s_{12}$. -For each claimed nonzero change, the combined independently estimated numerical -uncertainty of the two runs must be at most one tenth of its magnitude. This -factor is a reporting criterion, not a physical constant. Report total capture -separately; its change need not be the largest effect. Do not assume any sign. -Measure single/multimode overlaps, radial/Cartesian extraction, regridding and -scaled-plane transport with actual input/output sizes and memory. SEP 34 owns the -short-window benchmark ledger for hot flux changes. Never extrapolate a timing -into a full experimental run. - -### 8. Documentation - -Add a manual chapter `docs/guide/workflows/coupling.md` describing the four blocks, -their physical measures and approximations, source/target reference planes, -energy reports and all eight compositions. Include examples for a same-basis -handoff, a changed capillary radius, solid-fibre overlap, analytic and Cartesian -inputs, outcoupling and compression between stages. Cross-link input pulses, -general-modal launch, free-space setup, output access, polarisation and compression. -Update the API reference and public docstrings with units, shapes and refusals. - -Update conventions, architecture/structure, kernel/output contracts, numerical -accuracy and backend capability pages with implementation. Record the changed -incident-energy and spatial-flux semantics in `CHANGELOG.md`, with a SEP link, -and the deliberate difference on the Luna comparison page. No changelog entry -claims functionality merely because this draft was added. - -#### 8.1 Required focused tutorial - -Add **Nonlinear beam preparation and coupling into a capillary** as -`docs/tutorials/free_space/plot_08_radial_capillary_coupling.py` (use the next free -gallery number if that name is taken at implementation). It is a focused tutorial, -executes on normal docs builds, and is linked from the new coupling chapter and -the capillary/free-space guides. The manual explains how to convert a meter -reading through an entrance interface. Generate its output through Sphinx; do not -hand-edit the rendered gallery. - -Use a scalar linearly polarised Gaussian on an envelope grid in uniform argon. -The model contains Kerr self-focusing and self-phase modulation, with plasma, -THG and Raman omitted explicitly. The starting specification is: - -| Quantity | Value | -|---|---| -| Vacuum wavelength | 800 nm | -| Intensity FWHM | 30 fs | -| Argon pressure | 3 bar, uniform in gap and capillary | -| Temperature | The declared material reference temperature, recorded in the example | -| Gas gap before entrance | 20 mm | -| Linear-focus 1/e² intensity radius | 37.5 μm, at the capillary entrance; w/a = 0.5 | -| Capillary radius | 75 μm | -| Capillary segment | 5 mm | -| Initial strength target | Linear-Gaussian on-axis nonlinear phase $B_{\rm lin}=0.3$ rad | - -Determine energy from the physical material functions, not a copied nonlinear -coefficient. Let $n_0$ be the real gas index at the pump, $k_0=2\pi/\lambda_0$ -the vacuum wavenumber and $P_0$ the peak axial power. In the narrowband paraxial -sizing model, - -$$ - z_R=\frac{\pi n_0w_0^2}{\lambda_0},\quad - I_0(z)=\frac{2P_0}{\pi w_0^2[1+(z/z_R)^2]},\quad - n_2=\frac{3\chi^{(3)}}{4\varepsilon_0cn_0^2}. -$$ - -Integrating from $z=-L$ to the waist at zero gives - -$$ - B_{\rm lin}=\frac{2k_0n_2P_0z_R}{\pi w_0^2}\arctan(L/z_R),\quad - U=P_0\tau_{\rm FWHM}\frac{\sqrt\pi}{2\sqrt{\ln2}}. -$$ - -These equations follow directly from the Gaussian beam/pulse definitions and the -package's scalar Kerr normalisation. A sizing probe with the current material -functions at the revised waist gives about 12.1 μJ, 0.379 GW and 0.124 of the -physical critical-power estimate (the probe is recorded in the review response). -These are provisional analytic estimates, not a propagated or converged result. -Recompute with SEP 34's physical launch and critical-power conventions and -recorded optmat material identity. The earlier 50 μm, 14.1 μJ sizing is -superseded, not a regression target. - -The detuning is deliberate: the review identified a near-stationary fundamental -overlap at the old waist. At w/a = 0.5, scalar Gaussian–Bessel quadrature puts -about 0.906 of the incident energy in the fundamental and 0.092 in the next -radial mode. These numbers size the lesson only; the propagated broadband -complex field and converged mode set determine its results. Mild Kerr evolution -must be demonstrated to change the named modal shares above their error bounds; -neither a lens estimate nor the static overlap probe proves that outcome. - -Run two calculations from the identical physically focused incident field: -linear propagation through the gap, and propagation with Kerr enabled. At the -entrance, retain each full complex radial field and couple into the same capillary -mode set. Launch each captured result through the 5 mm capillary with identical -Kerr-only capillary physics, so the difference is the prepared incident field. -Do not replace either entrance field by a fitted Gaussian or its power spectrum. - -Name the linear modal beating in the 5 mm segment. With -$\beta_m\simeq nk_0-u_m^2/(2nk_0a^2)$, the fundamental/next radial-mode -beat length is - -$$ - 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} - \simeq22.5\ {\rm mm}. -$$ - -Here $u_{11},u_{12}$ are the first two J0 zeros, and the package labels the -family HE_1m. Thus 5 mm is about 0.22 beat periods: the output contains partial -linear mode beating, not merely modal filtering or a new nonlinear effect. -Evaluate the actual dispersive beta and differential wall loss across the -retained band when implementing the plots; the carrier estimate is explanatory. -The identical capillary physics in the two cases controls for this evolution. - -Plot beam radius and on-axis intensity through the gap; entrance fluence and -wavefront change; entrance temporal and spectral changes; incident/captured energy -and modal shares; and the short capillary outputs. Specify the slice or spectral -component used for wavefront phase, masking phase where amplitude is unresolved. -Use physically meaningful modal/block shares under the target metric. - -Before fixing the tutorial's grids and mode count, independently refine radial -domain, radial resolution, temporal window/band/sampling and retained modes. -Require visible but mild nonlinear changes and no unresolved collapse, collar -contamination or spatial aliasing. Check the magnitude of omitted ionisation -with the existing rate model as an offline diagnostic; if the Kerr-only -assumption is not defensible, reduce the strength target and document the reason -rather than silently adding plasma. The starting numbers are design coordinates, -not a promise that every omitted process is negligible. - -Assess omitted ionisation at the revised, higher on-axis intensity; the review's -assessment of the old waist is not a qualification of the new one. - -Keep the final tutorial deterministic and sized for a normal docs build. Full -nonlinear propagation is permitted for this focused gallery when needed to show -the effect, but its unit/parity regressions remain short. Report the runtime and -the numerical evidence with the implementation phase. This teaching calculation -is separate from the mandatory field-grid plasma/THG transfer test. - -### 9. Compatibility and impact - -The maintainer explicitly chooses a clean breaking change before release. -There are no legacy files to migrate for this work. Introduce no old-normalisation -switch, compatibility alias or deprecation period. Remove superseded launch -accounting, update callers and fixtures, and reject unsupported old schemas with -a clear version error rather than interpreting old data with new units. - -The observable differences are intentional: a spatial launch's requested energy -uses axial flux; incident energy is reduced by actual coupling; modal capture is -frequency-dependent and coherent; and transformed fields carry explicit phase -and basis references. Already supplied physical V/m arrays are not arbitrarily -rescaled merely because their reported energy changes. Current-schema resume -retains strict identities. Frame/reference metadata and flux records require -versioned output/seam changes when implemented, not in this documentation patch. - -SEP 34 owns corrected spatial energy, noise and observables and preserves admitted -radial gradient simulations through the adiabatic amplitude correction. -Coupling consumes its local plane measure; uniform-only restrictions apply to -the homogeneous `FreeSpace` element, not to all source simulations. The hybrid -planar and general-vector metrics keep their own derived meanings. - -SEP 5 consumes SEP 34 for solid carriers; SEP 8 retains vector crystal -fields/interfaces; SEP 9 retains its in-guide measurements. Their dependencies -and the roadmap follow that division. Nonadiabatic scattering, wider detector -catalogues and multi-pass-cell assembly remain explicit future work. Spatially -resolved compressor layouts remain outside this SEP. - -### 10. Alternatives considered - -| Alternative | Assessment | -|---|---| -| Bare output arrays between simulations | Convenient for an identical known basis; loses the reference, measure and remapping contract needed across routes | -| A separate helper for every source/target pair | Duplicates normalisation and Fourier boundaries; use synthesis, transport and projection adapters | -| Always materialise a Cartesian field | General but wasteful for direct modal overlaps and radial symmetry; retain native representations | -| Use `DataPulse` or restore launch energy after projection | Hides clipping and imperfect capture; preserve incident scale | -| Infer a Gaussian from radius/energy | Discards wavefront and space-frequency correlations produced by nonlinear propagation | -| Treat electric-field residual as lost power | Valid only for a derived common positive metric; separate the diagnostics | -| Multiply by one refractive index | Misses broadband and angular weighting and does not fix current work | -| Full junction scattering immediately | Needs reflected/radiation/evanescent bases and new solvers; outside the selected forward scope | -| Exact vector free-space optics immediately | Important at high NA, but a new propagation model; keep the current scalar/transverse-field route explicit | -| A fixed-window FFT for every distance | Can wrap or undersample the transfer; admit by sampling evidence and support independent output planes | -| Redesign the prism/grating models | Existing spectral models suffice for compensated ideal layouts; resolved spatial compressors are separate work | -| A multi-stage execution framework | Adds scheduling/resume complexity before the physical operations are qualified; compose ordinary simulations | -| Preserve vacuum-normalised APIs and old files | Explicitly rejected for this unreleased change; update the public interface cleanly | - -### 11. Phases - -No numerical implementation begins until acceptance. Implement SEP 34 first. -Its full public flux activation precedes SEP 31's incident-launch change, -as the maintainer chose on 2026-10-23, so there is no interval with corrected -modal launches but inconsistent spatial observations. SEP 34's private host -reference alone is not permission to merge SEP 31 phase 1 early. Each phase -records delivered paths, deviations, evidence, unresolved questions and handoff -in Part II. - -| Phase | Deliverables | Required evidence | -|---|---|---| -| **1 — Incident-energy correction** | Fix the present profile-launch defect with the shared physical measure and existing profile inputs, before introducing `FieldPlane`; preserve captured amplitudes rather than restoring requested energy | Poor and zero capture, full incident-domain normalisation, coherent mode shares and unchanged direct inside-guide amplitudes; all CPU receivers | -| **2 — Plane exchange** | `FieldPlane`, source/target references, `input_port`, same-basis relaunch and temporal remapping | Identity, delay/CEP/carrier and equal/unequal-grid tests; current-schema persistence; all CPU source/receiver pairs | -| **3 — Coupling** | Unify the phase-1 implementation behind analytic/sampled/`SpatialPulse` inputs, the concrete §4.3 models, overlaps, target-bound launches and reports | Gaussian/Bessel references, independently qualified provider/interface envelopes, passive-map certificates, and separate domain/quadrature/modal refinement | -| **4 — Transport core** | Homogeneous propagation, outcoupling, finite and far observation planes, bounded thin screens, explicit reflection/Jones frames, and existing spectral optics | Independent diffraction and bulk-interface references, angular/flux-defect checks, ordering, explicit efficiencies and affine-phase provenance | -| **5 — Integrated qualification and tutorial** | Eight bounded compositions from §2, the detuned radial tutorial, manual and capability matrix | Short field-grid Kerr/plasma/THG transfer, SPM → compressor → capillary, named modal-share uncertainty, CPU parity and final gates | - -**Final means this bounded core is complete.** Retain all eight compositions: -they exercise distinct seams (guide identity, spatial input, analytic/sampled -constructors, output-plane scaling and spectral phase). The required far field -is scalar and paraxial with its stated error bound; mirrors are local-frame -Jones/spectral maps, not coating or geometric-optics solvers; compressor blocks -reuse existing spectral transfer functions. No general junction scattering, -high-NA vector optics, resolved compressor layout, new in-run detector catalogue -or multi-pass-cell scheduler is required for Final. Each phase is useful before -Final; that status does not claim the wider optical-laboratory scope. - -During each slice run fast checks and directly affected modules. At the end run -the full gate, slow lanes and coverage once, or use their CI evidence, as -`AGENTS.md` specifies. Run the native gate if protected native consumers change. -After a failure rerun the failing modules. Tests/timings use short propagations; -the focused gallery has the standing full-problem exception. - -For this documentation revision run diff whitespace checks, codespell on changed -files, `tests/test_seps.py` and the warnings-as-errors Sphinx build. Phase 5, not -the draft, supplies the runnable tutorial. - -### 12. Open questions - -The physical model choices below are proposed explicitly for review, not delegated -to implementation. Qualification may narrow admission; a different model needs -a recorded proposal revision. - -| Question | Disposition | -|---|---| -| **Q1 — Cross-model projection** | Proposed resolution: the three models and error-budget admission in §4.3. Phase 3 must qualify each provider pair; no unqualified pair is admitted merely because a projection can be computed. | -| **Q2 — Thin elements and reflection** | Proposed resolution: §4.6's local paraxial screen, input/output angular-flux defect bound and explicit p/s frames. Refinement cannot substitute for a missing model bound. | -| **Q3 — Numerical admission budgets** | Phase 2/3 implements the required model budget and justified numerical/conditioning/tail enclosures under §4.3. Qualified pair envelopes may replace the Δ screen, never the total error bound. No coupling-efficiency floor is introduced. | -| **Q4 — Plane persistence** | Phase 2 fixes and tests the minimal standalone reference record; missing information is refused. | -| **Q5 — Tutorial evidence** | The maintainer chose 37.5 μm on 2026-10-23. Phase 5 fixes grids/mode count, qualifies the Kerr-only regime and resolves changes in s11 and s12 to §7's criterion. | -| **Q6 — Deferred physics** | General junction scattering, high-NA vector optics, varying absorption and resolved compressors remain roadmap work. Certified real-index radial forward amplitude transport belongs to SEP 34. | -| **Q7 — Native backend location** | Closed: `native/` in this repository, distributed as `solprop-native`; changes use the same branch and native gate. | - -Review completion recorded in the header describes the historical review below. -These revisions and SEP 34 still require acceptance; neither is marked Accepted. - -### 13. References - -1. G. K. Svendsen, M. W. Haakestad and J. Skaar, “Reciprocity and the scattering - matrix of waveguide modes,” *Physical Review A* **87**, 013838 (2013). - [doi:10.1103/PhysRevA.87.013838](https://doi.org/10.1103/PhysRevA.87.013838); - [author manuscript](https://arxiv.org/abs/1301.2458). -2. K. Matsushima and T. Shimobaba, “Band-limited angular spectrum method for - numerical simulation of free-space propagation in far and near fields,” - *Optics Express* **17**, 19662–19673 (2009). - [doi:10.1364/OE.17.019662](https://doi.org/10.1364/OE.17.019662); - [author repository](https://kansai-u.repo.nii.ac.jp/records/11127). -3. E. B. Treacy, “Optical pulse compression with diffraction gratings,” - *IEEE Journal of Quantum Electronics* **5**, 454–458 (1969). - [doi:10.1109/JQE.1969.1076303](https://doi.org/10.1109/JQE.1969.1076303). -4. L. Arissian and J.-C. Diels, “Carrier to envelope and dispersion control in a - cavity with prism pairs,” *Physical Review A* **75**, 013814 (2007). - [doi:10.1103/PhysRevA.75.013814](https://doi.org/10.1103/PhysRevA.75.013814). - -**Source audit.** The package's conventions, spatial operators/functionals, -ordinary/general-modal synthesis and projection, input replacement and optics -implementations were inspected. The author manuscript of [1] was inspected for -its reciprocity and junction scope. Publisher/repository records establish [2]'s -sampling problem and bibliographic identity; its full algorithm is not transcribed -or independently qualified here. [3] and [4] are the existing compressor models' -primary references; [4]'s publisher abstract/identity was checked, but neither -compressor derivation was newly revalidated from its full primary text in this -draft. Reuse their existing implementation and tests; verify any new external -formula in its implementation phase. The Gaussian integrals, scalar flux/work -identity and tutorial sizing are derived above rather than copied coefficients. -Material values continue to come from optmat's resolved models and sources. -No new material constant or experimental validation is claimed. - -## Part II — Implementation record - -## Part III — History - -- 2026-10-22: Drafted after auditing existing launch, projection, spatial flux, - output and offline-optics interfaces. Selected forward coupling, spectral - compressors, current free-space field models, controlled temporal remapping - and uniform-medium flux. Required clean breaking changes with no legacy - support and a mild-Kerr radial-input capillary tutorial. Identified cross-model - projection and thin-screen flux as explicit pre-acceptance questions. -- 2026-10-23: Reviewed by Opus 5.5 (AI), with John Travers's human review recorded at - his request. Part I is unchanged. The findings (R1–R10) and the maintainer's decisions - on R1, R2 and R5 follow. Reviewed records completion of review, not acceptance; - status remains Draft. -- 2026-10-23: Revised Part I in response to R1–R10, preserving the original review - below. Split medium flux into SEP 34, proposed bounded interface models, - moved the incident-energy defect to phase 1 and updated native-backend scope. - John selected the 37.5 μm tutorial waist and full complex admittance for local - flux traces. The response below records the resolutions and qualifications; - status remains Draft. -- 2026-10-23: AI re-review of the revised Part I by Opus 5.5 (S1–S4), recorded - after the response. No P1 finding remains; Part I unchanged by the reviewer. -- 2026-10-23: Maintainer decision on S2 recorded: phase 1 follows SEP 34's - activation. -- 2026-10-23: Revised Part I for S1–S4 and the settled SEP 34 contracts. Added - scoped path-certificate accounting, benchmark replacement of the weak-guidance - screen and the corrected tutorial beat-length derivation. Retained SEP 34's - full activation before the incident-energy fix. The response below records - the rationale; no implementation or new propagation qualification is claimed. -- 2026-10-23: Accepted by John Travers after the review responses, with an - 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. - -### AI review — 2026-10-23 - -**Assessment.** The direction is right and the physics is careful. Coupling through -a coherent field at a reference plane, with absolute incident scale and a refusal to -restore uncaptured light, fixes a real defect: `_projected_launch` today relaunches -the full declared energy however badly the channels capture the profile, as its own -docstring says. The derivations checked below are correct. The proposal is not yet -acceptable as written, for three reasons. It bundles a cross-backend change of -energy semantics with a host-only feature (R1). It removes a working capability, -radial gas-gradient runs, where a closed-form correction is available (R2). And its -two pre-acceptance gates, Q1 and Q2, ask for approval without proposing an answer -(R3). The tutorial's operating point may not show the effect it is meant to teach -(R4). - -**Maintainer decisions during review.** John asked for the flux question to be -explained, then decided: - -- **R1:** split the medium-flux prerequisite into its own SEP. -- **R2:** add the adiabatic amplitude term so that gradient runs keep working. -- **R5:** `energy=` means the physical energy (time-integrated axial Poynting - flux) in the launch medium, because that is what an energy meter measures. He - asked "What is the actual correct definition we should use for the flux"; - the answer is recorded under R5. - -**Review basis.** Read all of Part I against SEP 0, the architecture, conventions, -the free-space model page and the current implementation. Checked: -`Simulation.with_input`, `_projected_launch`, `project_transverse_field`, the vacuum -½ε₀c factors throughout `solprop.spatial`, `fourier.energy_omega`'s endpoint -convention, `spatial.aperture_profile` and the `optics.Chain` docstring. Every -factual claim in §3 matched the code. Independently re-derived: the scalar flux and -its discrete Parseval form (§4.2), the least-squares capture identity (§4.3), the -Gaussian displacement and tilt overlaps, the J₀ normalisation (§4.4), the -spectral-density scaling (§4.5), the Fresnel/Fraunhofer kernel, thin-lens sign and -normal-incidence Fresnel coefficients (§4.6), and the tutorial's n₂, B-integral and -energy formulas (§8.1). The tutorial sizing reproduces: n₂ ≈ 3×10⁻²³ m²/W at 3 bar -gives P₀ ≈ 0.46 GW against the stated 0.443 GW. Peak intensity is about -1.1×10¹³ W/cm², where argon ionisation is negligible, so the Kerr-only model is -defensible. All four references resolve, via Crossref, to the stated authors, -titles, volumes and pages. Ran one numerical probe (R4). This is a design review; -no implementation was qualified. - -Priorities: **P1** must be resolved in Part I before acceptance; **P2** needs a -concrete revision or recorded decision; **P3** is editorial. The drafting agent -should answer each point in a `Response to review` subsection, following SEP 0. - -#### R1 — P1: Split the medium-flux prerequisite into its own SEP - -**Sections:** 1, 3, 4.2, 6.3, 9, 11 (phases 1a–1c). -**Maintainer decision (2026-10-23):** split. - -SEP 31 contains two different kinds of work. Phases 1a–1c change the energy -semantics of every free-space route. Today `spatial_energy`, the intensity and -fluence functionals, `SpatialPulse(energy=…)`, `spatial_noise` and the in-run -statistics all use the vacuum ½ε₀c factor. Phases 1a–1c replace it with the Y_z -weighting. The statistics live in backend kernels, so the change needs work on all -three backends, and it changes the physical results of solid-medium runs by about -the refractive index. SEP 5 also depends on it. Phases 2–5 are host-only -NumPy/SciPy operations between runs, and they need a consistent measure only at the -hand-over plane. That measure can be evaluated offline from a saved field without a -kernel change. - -The bundle also creates a merge hazard. §11 calls the phases independently -mergeable, and §6.3 has incomplete backends "refuse the new contract by name" with -no vacuum fallback. Between phase 1a and phase 1c, main would refuse ordinary -spatial runs on the default compiled backend. - -**Requested revision:** move §4.2 (including the R2 term), the launch, noise and -statistics consistency work, and phases 1a–1c into a separate SEP, "medium flux for -free space". Within that SEP, the host-side launch and noise normalisation and the -kernel statistics land together, or in an order that never leaves a backend -refusing a currently working run. SEP 31 then lists that SEP under **Requires**. -Its coupling and transport evaluate the plane measure on the host through the same -shared definition. Update SEP 5's dependency and the roadmap entries that currently -attribute the flux correction to SEP 31. - -#### R2 — P1: Keep radial gas-gradient runs, with the adiabatic amplitude term - -**Sections:** 4.2 (*Uniform media only*), 6.3, 9, 12 (Q6). -**Maintainer decision (2026-10-23):** add the term; it belongs in the new flux SEP (R1). - -The radial route accepts a gas pressure `ZProfile`, for gas cells and jets (free-space -model, *Exact longitudinal operator*); Cartesian and line already refuse varying -media. As drafted, every operation needing the new flux contract refuses a -nonuniform medium, and no vacuum fallback remains. Energy-normalised launches and -energy statistics in those runs would therefore stop working. That is a regression -of a working capability, and it is avoidable. - -For the scalar forward model in a medium varying slowly on the wavelength scale, -conservation of $Y_z|\hat E|^2$ per transverse/frequency column requires - -$$ - \partial_z\hat E=\Bigl(ik_z-\tfrac12\partial_z\ln Y_z\Bigr)\hat E - \;\Rightarrow\; - \hat E(z_1)=\sqrt{\frac{Y_z(z_0)}{Y_z(z_1)}}\, - e^{i\int_{z_0}^{z_1}k_z\,dz}\,\hat E(z_0). -$$ - -The amplitude factor is closed-form and diagonal. It joins the integrated linear -exponent that the gradient route already evaluates, so it costs nothing per step. -Paraxially it reduces to $\sqrt{n(z_0)/n(z_1)}$. It is the adiabatic (WKB) forward -approximation and neglects reflection from the gradient. For gases, Δn ≈ 10⁻³ over -millimetres, so that reflection is many orders below any tolerance. Check whether -the nonlinear source prefactor's local $k_z$ stays consistent with this choice of -propagated variable. As drafted, it uses the local value. - -**Requested revision:** derive the term in the new SEP. Apply it on all three -backends. Qualify it as follows: a lossless gradient conserves the new energy to the -rounding budget, a uniform medium is unchanged, and a sharpening gradient tends -toward the transmitted share of an abrupt interface minus its reflection. Drop the -gradient refusal from §4.2, §9 and Q6. The remaining refusals cover only cases the -WKB model does not admit, such as abrupt steps that need an interface element. - -#### R3 — P1: Q1 and Q2 need proposed answers, not open approval - -**Sections:** 4.3, 4.6, 12 (Q1, Q2). - -Both questions are marked "before acceptance", but Part I proposes no model for the -maintainers to approve. Accepting the SEP now would accept an open design, and -leaving the choice to implementation is exactly what §12's preamble forbids. - -**Requested revision:** state the admitted cases concretely. For example, for Q1: - -1. A scalar forward overlap in the common uniform-medium paraxial measure for: - Gaussian or radial free-space fields into the ordinary capillary, capillary into - capillary of a different radius, and weakly guiding solid fibres below a stated - index contrast. -2. Provider E/H for general vector modes, with an E-only incident field closed by - $\mathbf H=Y\hat z\times\mathbf E$ and a stated $O(\theta^2,\Delta n)$ error. -3. Everything else refused. - -For Q2: the paraxial thin mask and lens in the local measure, with a defect bound -computed from the flux fraction beyond a stated angle where $Y_z$ departs from its -paraxial value by more than the tolerance, and refusal above it. Give the numbers or -the rule that fixes them. Q3 can then stay an implementation-phase budget. - -#### R4 — P2: The tutorial sits at the stationary point of capture - -**Sections:** 7, 8.1, 12 (Q5). - -The linear-focus spot of 50 μm in a 75 μm capillary gives w/a = 0.667. Gaussian -capture into the EH₁₁ mode peaks at w/a ≈ 0.645 (η ≈ 0.981), so capture there is -nearly stationary in spot size. A direct quadrature of the §4.4 scalar overlap -gives: - -| w/a | η₁₁ | η₁₂ | -|---|---|---| -| 0.645 | 0.9807 | 0.0052 | -| 0.647 (−3 %) | 0.9807 | 0.0048 | -| 0.667 (tutorial) | 0.9792 | 0.0020 | -| 0.687 (+3 %) | 0.9756 | 0.0004 | -| 0.500 | 0.9062 | 0.0923 | -| 0.515 (+3 %) | 0.9220 | 0.0764 | - -At P/P_cr ≈ 0.15, the Kerr lens changes the entrance spot by a few per cent. Around -the tutorial's point that moves total EH₁₁ capture by only 10⁻³ to 10⁻⁴. That is -comparable with the numerical uncertainty that §7 requires the effect to exceed. The -EH₁₂ share, in contrast, changes by a factor of several, and at w/a = 0.5 the -fundamental's capture moves by about 1.6 % per 3 %. - -**Requested revision:** choose deliberately. One option is to make the stationarity -the lesson: total capture is insensitive, while higher-mode content, wavefront and -spectrum change. The other is to detune the focus so that capture is first-order -sensitive. Name the quantity the convergence evidence must resolve in §7 and Q5, -rather than "the nonlinear change in modal capture". - -#### R5 — P2: State what `energy=` physically means - -**Sections:** 1, 4.2, 5.1, 9. -**Maintainer decision (2026-10-23):** physical energy in the launch medium. - -John's question was the principled one. A user knows a beam's energy from a meter -and wants to model that energy. The answer should be in Part I, not left implicit in -the Y_z formula: - -- Energy is the time-integrated axial Poynting flux through a plane. That is - conserved, and it is what a meter measures. -- For a paraxial plane wave in a nonmagnetic medium, $S_z=\tfrac12 n\varepsilon_0c|E|^2$ - with the **phase** index (Re k/ω), not the group index. The energy density - $\tfrac12\varepsilon_0nn_g|E|^2$ times $v_g=c/n_g$ gives the same result. -- The vacuum ½ε₀c|E|² equals the energy only for n = 1. It overstates the field by - √n in glass, and by about 0.04 % (0.08 % in energy) in a few-bar gas. The modal routes already use - true power, so the vacuum measure is the inconsistent one. -- A reading taken in air before an entrance window becomes the in-medium energy - through a declared interface element (Fresnel or AR transmission), never - implicitly. - -**Requested revision:** add a short paragraph with these points to the new flux SEP -(R1), and a sentence to §5.1 here that `energy=` and a `GaussianBeam`'s energy mean -the physical energy at the declared launch plane in its medium. Add a note to §8 that -the manual explains the mapping from a meter reading. - -#### R6 — P2: Fix the incident-energy defect early and on its own - -**Sections:** 3, 5.1, 11. - -The fix to `_projected_launch`, which stops it restoring missed energy, is a current -correctness defect. It does not depend on `FieldPlane`, regridding or transport. In -the phase table it sits inside phase 3, behind phase 2's plane-exchange machinery. - -**Requested revision:** make the unified incident-energy launch (§5.1, *All spatial -incident-field entry points converge*) the first deliverable after the flux SEP, with -its own evidence: deliberate poor and zero capture, and no restored energy. Then -order the remaining phases. - -#### R7 — P2: Interface gaps in §5 - -**Sections:** 5.1, 5.2. - -1. `GaussianBeam` duplicates the Gaussian of `SpatialPulse`, while §5.1 says all - spatial incident-field entry points converge. Say whether `GaussianBeam` - replaces `SpatialPulse`'s Gaussian, wraps it, or is its incident-field record. A - clean breaking change is allowed, so pick one. -2. The sketch in §5.2 uses `Simulation.input_port()`, `Outcouple`, `FreeSpace`, - `SpectralTransfer`, `ThinLens` and `Aperture`. None of them is in the §5.1 table. - List them with their modules and responsibilities, since the element vocabulary - is the public surface users will write against. -3. Say whether `transport` accepts an existing `optics.Chain` directly (the frequency - block) and how it reports that the chain's order is not significant while the - spatial sequence's order is. - -#### R8 — P2: The native-backend text is out of date - -**Sections:** 3, 6.3, 11 (phase 1c), 12 (Q7). - -SEP 29's first phase has already moved the compiled backend into this repository as -`native`; the free-space line-route text calls it that. The conditional "if SEP 29 -has moved Kokkos…" is now settled, and the phase name "Kokkos CPU flux" uses the old -name. - -**Requested revision:** use `native` (its CPU build), drop the conditional, and close -Q7. If R1 is taken, this moves with phases 1a–1c into the new SEP. - -#### R9 — P2: The "complete" criterion is broad - -**Sections:** 7, 11 (phase 5), 12 (Q6). - -The integrated qualification requires all eight workflows of §2. Those include the -radial field-grid Kerr/plasma/THG transfer and an SPM → compressor → second-capillary -chain. They are good targets. But Final status then depends on the far field, -Jones/reflection frames and thin elements as well as the coupling core. - -**Requested revision:** consider letting the SEP reach Final on the coupling and -transport core, with a clear list of what Final requires. Alternatively, state why -every workflow is needed for the first useful release. Either way, keep the field-grid -plasma/THG transfer, which is the case most likely to expose band and reference -errors. - -#### R10 — P3: Editorial - -- §4.2: the discrete rule uses the analytic-signal convention, which keeps the DC and - Nyquist bins unscaled, as `fourier.energy_omega` does. Say that §7's "direct - time/space integration" means $\tfrac12\int|E_a|^2$. A real-field $\int E^2$ - differs in those two bins. -- §4.3, §4.5: W denotes the flux pairing matrix, the least-squares Gram matrix and - the per-frequency metric $W_k$. Distinguish them, or say that they coincide when - M is the flux measure. -- §6.2 depends on SEP 28's memory bounds. Link SEP 28 there, or list it under - **Requires**. -- §4.4: keep the sentence forbidding an asserted universal coupling optimum. The - probe in R4 confirms that the value depends on the profile and the mode set. - -### Response to review — 2026-10-23 - -The revised Part I addresses all ten findings. The original review above remains -the record of what was reviewed; its completion does not imply acceptance of this -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, - 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 - NumPy, Numba CPU and native CPU: no intermediate merge changes the default - convention while a supported CPU backend refuses existing runs. SEPs 5, 8 - and 9 and the roadmap now point to the appropriate owner. -- **R2 — Accepted with a correction to validation.** SEP 34 derives the - `−½ ∂z ln Y` amplitude term, its endpoint form and its relationship to the - existing radial linear operator. It preserves qualified smooth radial gas - gradients. The proposed sharp-gradient Fresnel test cannot be a convergence - target for this forward-only model: for n1 = 1 and n2 = 1.5 it predicts T = 1, - while Fresnel gives T = 0.96 and R = 0.04. Even a gas-sized step to n2 = 1.0008 - has nonzero R ≈ 1.60 × 10⁻⁷. Instead, compare against an independently refined - wave solution in the adiabatic range and refuse profiles outside the admitted - error budget. Exact endpoint cancellation for represented weights is not a - guarantee about interpolation or model error. No additional FFT is needed - for the propagation correction, but derivatives, endpoint evaluation and - local-flux reconstruction still have costs that must be measured. -- **R3 — Resolved by concrete proposals.** Section 4.3 names same-basis, - scalar-paraxial and vector-paraxial models, their profile requirements and - quantitative admission budgets. It requires provider/interface qualification - beyond small-angle or weak-guidance screens; passivity alone cannot establish - junction accuracy. Section 4.6 bounds the thin-screen discrepancy between - local paraxial power and axial flux, including spectral tails, and specifies - mirror coordinate frames. Unqualified pairs and exceeded budgets refuse - explicitly. These are proposed acceptance contracts, not claims that the - implementations have already met them. -- **R4 — Accepted; maintainer choice recorded.** The tutorial now uses 37.5 μm - (w/a = 0.5) and B = 0.3 rad. It must resolve changes in incident-normalised - fundamental and first higher radial-mode shares, each to a combined numerical - uncertainty no larger than one tenth of the claimed change. Independent - Gaussian/Bessel quadrature gives fundamental/second-mode shares about - 0.9062/0.0923 at w/a = 0.5 and 0.9220/0.0764 at 0.515. This supports detuning - as a useful teaching choice, but does not predict the propagated nonlinear - change. The review's comparison of a tiny change with numerical uncertainty - remains a hypothesis until the propagation and refinement study is run. -- **R5 — Accepted and extended.** Incident energy is the physical energy crossing - the specified plane in its medium. An external meter reading requires explicit - interface transmission; phase index determines transparent plane-wave flux, - while group index appears in stored energy density. A further issue arose - during revision: in absorbing media the real admittance fixes integrated - energy, but its imaginary part contributes to local broadband interference - terms. John agreed to retain the full complex admittance for local traces and - its real part for total energy and noise. SEP 34 derives both contracts. -- **R6 — Accepted.** Immediately after SEP 34, phase 1 fixes existing profile - launches before introducing `FieldPlane`. The fix preserves the captured - fraction using an explicit incident domain and measure; it does not merely - delete the last rescaling after an aperture-normalised input. Phase 3 folds - the same implementation into the unified coupling interface. -- **R7 — Accepted.** `SpatialPulse` remains the sole Gaussian/spatial pulse - builder and `FieldPlane.from_pulse` consumes it. Section 5 lists modules, - constructors, `Simulation.input_port` and the minimum transport elements. - Existing frequency-only `Chain` values are accepted as one ordered transport - block; their internal scalar multiplication does not reorder spatial optics. -- **R8 — Accepted.** The proposal uses the in-repository `native/` backend and - its CPU build. Q7 is closed, and SEP 34 owns its flux implementation and parity. -- **R9 — Bounded scope retained and justified.** Section 11 makes Final depend on - the eight named compositions within the restricted paraxial, forward and - spectral-optics models. Each checks a distinct exchange or reference seam, - so none is left implicit. General junction scattering, high-NA vector optics - and spatially resolved compressors remain deferred. The short field-grid - Kerr/plasma/THG transfer and capillary → compressor → capillary qualification - remain mandatory. -- **R10 — Accepted.** SEP 34 states the analytic-signal DC/Nyquist convention and - distinguishes its time-domain energy reference from a real-field endpoint - identity. SEP 31 uses F for the E/H pairing matrix, G for the scalar - least-squares Gram matrix and Q for the native power metric. Section 6.2 links - SEP 28's memory contract. The warning against a universal coupling optimum - remains in §4.4. - -**Sizing evidence, not propagation qualification.** A fresh analytic calculation -with the current optmat 0.8.0 argon functions at 3 bar and 293.15 K gives -n0 ≈ 1.00077206, n2 ≈ 3.09311 × 10⁻²³ m²/W and zR ≈ 5.5266 mm for the -37.5 μm waist. The B = 0.3 estimate gives U ≈ 12.1136 μJ and P ≈ 0.379331 GW, -or approximately 0.1238 of the critical-power estimate converted to physical -power. The Gaussian sizing and independent scalar overlaps were recalculated; -no nonlinear propagation, omitted-response assessment, grid convergence or -backend qualification is claimed by this documentation revision. Those remain -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 -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 -one-sided brackets. `SpatialPulse` remaining the only beam builder settles the -duplication. **No P1 finding remains.** SEP 31 can go forward for acceptance once -SEP 34, which it requires, is settled. The points below are refinements. - -**Review basis.** Read the revised Part I in full, the response to review and the -diff against the reviewed text. Recomputed the revised tutorial sizing with the -stated optmat values: z_R = 5.527 mm, P₀ ≈ 0.380 GW and U ≈ 12.1 μJ, matching the -draft. Peak on-axis intensity rises to about 1.7×10¹³ W/cm², which is still far -below significant argon ionisation over 30 fs; §8.1 rightly requires it to be -reassessed. Checked the R2 correction with an independent transfer-matrix solution -of $E''+q^2E=0$ (recorded in the SEP 34 review). - -**On the R2 correction.** The response is right, and the original request was -wrong. A forward-only adiabatic model transmits all the flux at a step. For -n = 1 → 1.5 the exact solution gives R = 0.0400, while the model gives T = 1. A -sharpening profile can only show the model leaving its admitted range; it cannot -converge to Fresnel transmission. The SEP 34 review (A1) adds a closed-form bound -that makes this precise. - -#### S1 — P2: Use SEP 34's closed-form certificate for gradient sources - -**Sections:** 4.2, 5.3, 7 (Refusals). - -§4.2 and §5.3 refuse "a nonadiabatic gradient", which leaves the criterion to -SEP 34. The SEP 34 review (A1) recommends a closed-form, per-column certificate -in place of profile-family qualification. If it is adopted, refer to that rule -here rather than restating it. The coupling report then quotes the source plane's -certified forward-field error alongside the other model errors in the -`model_error` sum. - -#### S2 — P2: Phase 1 may need only SEP 34's host reference - -**Sections:** 11 (phase 1), 6.3. -**Maintainer decision (2026-10-23):** keep the present ordering. The -incident-energy fix lands after SEP 34's public activation, so there is no window -of mixed conventions. State that reason in §11. - -As written, the incident-energy correction waits for SEP 34's full three-backend -activation. The defect it fixes is gross: a badly captured profile is relaunched at -the full requested energy, an error of order one. It also lives on the modal launch -path, not in a spatial kernel. The only physical input it needs is the incident -measure at the entrance plane, and that is SEP 34's host reference definition. In -a gas-filled capillary, the medium measure differs from the vacuum one by about -10⁻³. - -**Recommendation, for maintainer decision:** either allow phase 1 to merge after -SEP 34's phase 1 (shared host reference), with that ≈10⁻³ inconsistency against -still-vacuum spatial observations documented until activation; or keep the -present ordering and state the reason, which is no window of mixed conventions. -Either is defensible. The SEP should say which was chosen. - -#### S3 — P3: The weak-guidance screen is strict for ordinary fibres - -**Section:** 4.3 (*Admission rule*). - -With $\Delta\le\epsilon_m/8$, a standard step-index single-mode fibre (Δ ≈ 0.36 %) -needs $\epsilon_m\gtrsim2.9\,\%$. A 1 % model budget therefore refuses -fibre-to-fibre coupling between such fibres, even identical ones, where the scalar -overlap is exact by symmetry. The text already calls the allocation conservative -and requires independent benchmarks. Say whether those benchmark envelopes may -replace the Δ screen for a qualified provider pair. Otherwise the screen, not the -physics, sets the smallest usable budget. - -#### S4 — P3: Name the mode beating in the tutorial capillary - -**Section:** 8.1. - -For a = 75 μm at 800 nm in near-unity-index gas, the EH₁₁–EH₁₂ beat length -$2\pi/\Delta\beta=8\pi a^2/[(u_{12}^2-u_{11}^2)\lambda]$ is about 22.5 mm. The -5 mm segment is roughly a quarter beat, and EH₁₂'s extra loss over 5 mm is -negligible. At w/a = 0.5, with about 9 % in EH₁₂, the output profiles therefore -show partial beating rather than modal filtering. That is fine, but the tutorial -text should say so, so that a reader does not mistake the beat phase for a -nonlinear effect. Comparing linear-prepared and Kerr-prepared launches over the -same segment already controls for it. - -### Response to re-review — 2026-10-23 - -The original re-review above is retained. The following resolutions are now -in Part I; the proposal remains `Draft` pending formal maintainer acceptance. - -- **S1 — Accepted with error units and scope made explicit.** Sections 4.2, - 5.3 and 7 refer to SEP 34's per-column real-index path certificate, including - jump sides and refusal of nonconstant absorbing paths. Section 4.7 carries - that certificate separately in the report. A relative field error δ implies - an energy discrepancy bounded by 2δ + δ² in the same positive metric; this - conversion and the map norm are required before adding it to an energy - budget. The background-path certificate alone is not a bound on a nonlinear - source simulation, which retains its separate qualification. Local flux uses - SEP 34's retained forward-component closure. -- **S2 — Maintainer decision retained.** Section 11 explicitly requires SEP 34 - to be implemented and publicly activated first, including its required - backend qualification. This avoids mixed launch and observation conventions. - Its private host reference alone does not enable an earlier SEP 31 phase 1 - merge. -- **S3 — Accepted.** Section 4.3 allows an independently qualified provider-pair - envelope to replace the conservative Δ screen over its declared range. - It does not replace the angular/metric checks, passivity or total model-error - budget. Identical checked bases already use `same_basis`, avoiding an - artificial weak-guidance floor for coordinate recovery. -- **S4 — Accepted with a formula correction.** Section 8.1 derives - $L_{\rm beat}=8\pi^2 n a^2/[(u_{12}^2-u_{11}^2)\lambda_0]$ from the scalar - capillary beta expansion. The re-review's numerical 22.5 mm estimate is - correct, but its printed formula is missing one factor of π. With the stated - carrier index and J0 zeros, the static estimate is 22.5045 mm, so 5 mm is - about 0.222 periods. The tutorial explains partial beating, uses the package's - HE_1m labels and requires evaluation of actual dispersive beta and wall loss - when the plots are implemented. - -All rereview points have proposal-level answers. Grid/mode convergence, -interface qualification, the retained short field-grid Kerr/plasma/THG transfer, -the compressor workflow and the tutorial's nonlinear signal-to-error margin -remain mandatory implementation evidence, not unresolved design choices. diff --git a/docs/dev/structure.md b/docs/dev/structure.md index 425c4d9b5..982084bc2 100644 --- a/docs/dev/structure.md +++ b/docs/dev/structure.md @@ -32,11 +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. 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/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( 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/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/results/interior-launch.md b/docs/guide/results/interior-launch.md index 859d7f677..6cb0a50b8 100644 --- a/docs/guide/results/interior-launch.md +++ b/docs/guide/results/interior-launch.md @@ -39,6 +39,13 @@ 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: `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 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..1edcaa34f 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/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 + 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/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/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/waveguides/general-modal.md b/docs/guide/waveguides/general-modal.md index 83af85427..f9a3b942f 100644 --- a/docs/guide/waveguides/general-modal.md +++ b/docs/guide/waveguides/general-modal.md @@ -113,59 +113,78 @@ 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 -$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. - -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 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 -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". +**`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, coupled under a declared model +([SEP 31](../../dev/seps/archive/sep-0031-simulation-coupling.md); +[coupling simulations](../workflows/coupling.md)): + +```text +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 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 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. 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 +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 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 +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 +361,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` 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) | @@ -469,8 +488,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) 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 {meth}`~solprop.driver.simulation.Simulation.with_input`; @@ -497,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 e66073400..f2383f22b 100644 --- a/docs/guide/waveguides/solid-fibres.md +++ b/docs/guide/waveguides/solid-fibres.md @@ -247,49 +247,44 @@ 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 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.translate.general import general_modal_route -from solprop.translate.modal_tables import project_transverse_field +from solprop.coupling import CouplingModel +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, + coupling=CouplingModel("vector_paraxial", model_error=0.2), 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"launched {launch.efficiency:.3f} of the beam within {launch.budget:.3f}") ``` -`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 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 @@ -391,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/waveguides/supplied-guides.md b/docs/guide/waveguides/supplied-guides.md index 11088935c..3888dff91 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=` 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/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 new file mode 100644 index 000000000..f7af5cf35 --- /dev/null +++ b/docs/guide/workflows/coupling.md @@ -0,0 +1,736 @@ +# 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/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 +synthesised on a spatial grid, and transport of a spatial plane through free space, +lenses, apertures, mirrors, interfaces and spectral optics. + +## 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, 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 + +{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, 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 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, +) +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 +``` + +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. + +`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. + +## 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/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 +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 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: +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 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 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 +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`) bound what the +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 + 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 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 (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 + 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. + +- *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. 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. + +A pair outside its envelope's range is refused, not extrapolated; bins of a covered pair +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 + +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. 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 + +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. +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 + + +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) + +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 + +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, and for a step-index guide the facet reflection the model omits is +counted through its recorded envelope (above). + +## Transporting a plane between planes + +{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 +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=`. 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 + 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}$). + +## Composing simulations + +The blocks above compose into the calculations +[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. + +| 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) +``` + +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/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/docs/guide/workflows/luna.md b/docs/guide/workflows/luna.md index 38d9be6de..8fba07a65 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/archive/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. 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`. 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..e68e1e9fc --- /dev/null +++ b/docs/tutorials/free_space/plot_08_radial_capillary_coupling.py @@ -0,0 +1,511 @@ +# 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. +# The design is +# :doc:`SEP 31 `. 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] diff --git a/pyproject.toml b/pyproject.toml index 72d3aa101..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.8.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/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..9f5945d46 --- /dev/null +++ b/src/solprop/coupling/__init__.py @@ -0,0 +1,135 @@ +# 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:`~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.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, +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`. +The manual page is ``docs/guide/workflows/coupling.md``. +""" + +from solprop.coupling.coupler import ( + AdmissionItem, + CouplingReport, + CouplingResult, + couple, +) +from solprop.coupling.elements import ( + Alignment, + Aperture, + FreeSpace, + Mirror, + PlanarInterface, + PolarisationTransform, + SpectralCalibration, + SpectralTransfer, + ThinLens, + TransmissionMask, +) +from solprop.coupling.envelopes import ( + CAPILLARY_ENVELOPE, + STEP_INDEX_BEAM_ENVELOPE, + STEP_INDEX_ENVELOPE, + VECTOR_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, + MetricKind, + 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, +) +from solprop.coupling.remap import DEFAULT_TOLERANCE, RegridReport, regrid +from solprop.coupling.transport import TransportReport, transport + +__all__ = [ + "CAPILLARY_ENVELOPE", + "DEFAULT_TOLERANCE", + "ENTRANCE_TOLERANCE", + "METRIC_TOLERANCE", + "PLANE_KINDS", + "STEP_INDEX_BEAM_ENVELOPE", + "STEP_INDEX_ENVELOPE", + "VECTOR_STEP_INDEX_ENVELOPE", + "AdmissionItem", + "Alignment", + "AnalyticRecord", + "Aperture", + "BasisChange", + "Calibration", + "CouplingLaunch", + "CouplingModel", + "CouplingReport", + "CouplingResult", + "Exterior", + "FieldPlane", + "FreeSpace", + "InputPort", + "MetricKind", + "Mirror", + "NodalField", + "Outcouple", + "OutcoupleResult", + "PairEnvelope", + "PlanarInterface", + "PlaneBasis", + "PlaneGeometry", + "PlaneKind", + "PlaneMetric", + "PlaneProvenance", + "PlaneReference", + "PolarisationTransform", + "RegridReport", + "ScalarModeBasis", + "SpectralCalibration", + "SpectralTransfer", + "ThinLens", + "TransmissionMask", + "TransportReport", + "basis_identity", + "couple", + "lattice_identity", + "modal_basis_identity", + "reference_at", + "regrid", + "space_record", + "transport", +] diff --git a/src/solprop/coupling/coupler.py b/src/solprop/coupling/coupler.py new file mode 100644 index 000000000..5c89406f1 --- /dev/null +++ b/src/solprop/coupling/coupler.py @@ -0,0 +1,2906 @@ +# 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. 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; +- ``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 allowance (scaled by the pair's leaky + parameter for a capillary, by the fibre's contrast for a beam into a step-index + fibre, 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`. + +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. 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.adiabatic import AdiabaticAdmission +from solprop.constants import EPS0, C +from solprop.coupling.envelopes import ( + CAPILLARY_ENVELOPE, + STEP_INDEX_BEAM_ENVELOPE, + STEP_INDEX_ENVELOPE, + VECTOR_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", + "ItemScope", + "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. +""" + +_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.""" + +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 : 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 + 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: AdiabaticAdmission | 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))) + + @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)) + 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 + + +@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 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 + 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() + recovery: tuple[AdmissionItem, ...] = () + 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, recovery = _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), *recovery), + ) + + +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 +) -> 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, 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 + ---------- + 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 + ------- + tuple + The target samples, shape ``port.shape``, and the ``target metric`` and + ``source metric`` items. + + 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) + 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) + q_matrices = _metric_matrices(port.metric, port.shape[0], port.grid.n_omega) + defect = 0.0 + for k in occupied: + low, high = _whitened_extremes(flux[k], q_matrices[k]) + defect = max(defect, abs(low - 1.0), abs(high - 1.0)) + # 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 > allowed: + msg = ( + f"the recovered energy differs from the plane's by " + 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) + 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( + 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"): + 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)) + 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: + envelope_parameters["uncovered"] = _uncovered_v_share( + {"v_target": profiles}, + STEP_INDEX_BEAM_ENVELOPE, + plane.grid.omega, + incident, + ) + 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 a port's scalar 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, :]) + ) + 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 + + +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, + ) + ) + # 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", + 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. + 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 _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, + 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: cone-rule allocation (1 - cos theta*) = " + "model_error/8 of the cone, not a measured defect, plus the 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 _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( + 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 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 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 + ---------- + 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 + Incident energy per bin, J. + + Returns + ------- + float + Dimensionless. + """ + beyond = np.zeros(omega.shape, bool) + 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 = envelope.ranges[name] + beyond |= (incident > 0.0) & ((v < low) | (v > high)) + return _weighted(beyond.astype(float), incident) + + +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 (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 spatial plane or a modal plane + with its profiles. + 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 == "step_index": + envelope = ( + STEP_INDEX_ENVELOPE if plane.kind == "modal" else STEP_INDEX_BEAM_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 + # 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) / ( + 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}"), + ) + uncovered = parameters.get("uncovered", 0.0) + 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.allowance(parameters) + uncovered, + None, + True, + "model", + envelope.identity + + ( + f", plus the {uncovered:.1e} incident share outside its range" + if uncovered > 0.0 + else "" + ), + ), + ] + + +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) + incident = _bin_weights(port) * 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), + _vector_envelope_item(port, incident), + ] + items.append(_gain_item(gain, items, "field")) + return samples, _report( + plane, + port, + model, + index=index, + incident=incident, + 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_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, + 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: cone-rule allocation (1 - cos theta*) = model_error/8 " + "of the cone, not a measured defect, plus the 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: cone-rule allocation (1 - cos theta*) = model_error/8 " + "of the cone, not a measured defect, plus the 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 + transverse = electric[:, :2, :, k] + gram = np.einsum("q,qcm,qcn->mn", weights, transverse.conj(), transverse) + 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]) + return AdmissionItem( + "angular", + worst, + None, + True, + "map", + "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/elements.py b/src/solprop/coupling/elements.py new file mode 100644 index 000000000..9d421d354 --- /dev/null +++ b/src/solprop/coupling/elements.py @@ -0,0 +1,3244 @@ +# 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. + +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, + +.. 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 CouplingError +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; :func:`solprop.coupling.transport` builds it + from a :class:`~solprop.coupling.plane.FieldPlane` (the internal conjugate of + 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 + ---------- + 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 + ------ + CouplingError + If the representation, the spectrum's shape, finiteness or band, or the + frame is invalid, or 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 CouplingError(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 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 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 CouplingError(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 + ------ + CouplingError + 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 CouplingError(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 + ------ + 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 CouplingError(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 + ------ + 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 CouplingError(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 + ------ + 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 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 CouplingError(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 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)) + 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 + ------ + 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): + 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 CouplingError(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 CouplingError(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 + ------ + CouplingError + 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 CouplingError(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 + ------ + CouplingError + 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 CouplingError(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 + ------ + CouplingError + 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 CouplingError(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 built on the SEP 23 edge profile, with its weighting declared. + + :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 + ---------- + 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. + 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 + model_error: float + 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. + + Parameters + ---------- + state : SpatialState + The input plane. + + Returns + ------- + ElementResult + The output plane and its certified report. + + Raises + ------ + 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 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) 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}, {self.weighting})" + ), + model_error=self.model_error, + phase_only=False, + notes=( + "power weight W applied to the field as sqrt(W)" + if self.weighting == "quadrature" + else "field transmission W; power goes as W**2", + ), + ) + + +@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 + ------ + 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] + 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 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 CouplingError(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 + ------ + 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 CouplingError(msg) + if isinstance(state.space, RadialGrid): + if any(values): + msg = ( + "displacement and tilt break cylindrical symmetry: use a " + "Cartesian FreeGrid" + ) + raise CouplingError(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 CouplingError(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 + ------ + 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 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 CouplingError(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 + ------ + CouplingError + 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 CouplingError(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 + ------ + 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 CouplingError(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 + ------ + CouplingError + 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 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 CouplingError(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 + ------ + 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 CouplingError(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 + ------ + 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. + """ + 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 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])): + 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 CouplingError(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 + ------ + CouplingError + 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 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 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) + 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 + ------ + CouplingError + 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 CouplingError(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 + ------ + 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. + """ + 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 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 CouplingError(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 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 CouplingError(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. 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. 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. 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: + + - ``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 :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 + 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, + 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 + medium: SpatialMedium + output: RadialGrid | FreeGrid | None = None + 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 + edge_tolerance: float = 1e-4 + beyond_window: Literal["refuse", "remove"] = "refuse" + + def _validate(self, state: SpatialState) -> None: + """Check the element against the plane. + + Parameters + ---------- + state : SpatialState + The input plane. + + Raises + ------ + 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) 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 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 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 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 CouplingError(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 and ``phase_removed`` in rad. + + Raises + ------ + 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. + """ + 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, window_removed, diagnostics = self._propagate( + kept, flux, delay + ) + removed += window_removed + notes: tuple[str, ...] = () + result = propagated + if self.output is None: + self._check_edge(diagnostics["edge_share"]) + else: + 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 evaluate this plane " + 'with output_method="fresnel", which reaches any window' + ) + 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)", + measured_in, + measured_out, + specified_loss=absorbed, + removed=removed, + 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 + ), + ) + 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]: + """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 + ------ + CouplingError + 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 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 + 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, 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, 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) + 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 - 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, :] + 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) + diagnostics = { + "wrap_share": share, + "max_ray_shift_ratio": wrap.max_ratio, + "edge_share": _edge_share(propagated), + "window_removed": window_removed, + } + return propagated, absorbed, window_removed, 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 + ------ + CouplingError + 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 CouplingError(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 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 + ) + 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 + self.frame_phase)) + 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, + "phase_removed": self.frame_phase, + }, + 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, + ) -> 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 + (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. + + 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 + 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]))) + return ratio > 1 + + 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) diff --git a/src/solprop/coupling/envelopes.py b/src/solprop/coupling/envelopes.py new file mode 100644 index 000000000..a8292dff0 --- /dev/null +++ b/src/solprop/coupling/envelopes.py @@ -0,0 +1,273 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +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 +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 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 + (: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. +- :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, 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 +from dataclasses import dataclass +from types import MappingProxyType + +__all__ = [ + "CAPILLARY_ENVELOPE", + "STEP_INDEX_BEAM_ENVELOPE", + "STEP_INDEX_ENVELOPE", + "VECTOR_STEP_INDEX_ENVELOPE", + "PairEnvelope", +] + + +@dataclass(frozen=True) +class PairEnvelope: + """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', 'step_index_vector'} + The profile family of the pair's guided members: the target, and a modal + source (an incident beam has none); 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, 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. + 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 + family: str + ranges: Mapping[str, tuple[float, float]] + bound: float + numerical: float + reference: str + scaled_by: str | None = None + + 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 + + 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-leaky-marcatili-2026-10", + family="capillary", + ranges={"modes": (1.0, 8.0), "radius_ratio": (0.5, 2.0), "leaky": (0.0, 1e-2)}, + bound=6e-3, + numerical=1e-10, + reference=( + "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, ``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( + 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).""" + + +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 (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 " + "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.""" + +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=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 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", +) +"""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 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/src/solprop/coupling/models.py b/src/solprop/coupling/models.py new file mode 100644 index 000000000..e1ffc93f7 --- /dev/null +++ b/src/solprop/coupling/models.py @@ -0,0 +1,941 @@ +# 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.constants import C +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,)``. + 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. + + 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 + wall_term: 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) + # 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) + object.__setattr__(self, "effective_index", 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) + 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)" + ) + + 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. + + 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 39 e-foldings, ``log(1e17)``. + """ + 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)) + ) + 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", + labels, + radius, + np.array([mode.u_nm for mode in modes]), + np.zeros(len(modes)), + jones, + index, + core, + wall_term=wall, + 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..65de8c2b0 --- /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.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. +""" + +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.coupler 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 new file mode 100644 index 000000000..ddbefc057 --- /dev/null +++ b/src/solprop/coupling/plane.py @@ -0,0 +1,1847 @@ +# 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.planes.field_plane`, and a receiving +simulation is described by :class:`solprop.coupling.port.InputPort`. +""" + +import dataclasses +import hashlib +import json +import math +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass, field +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 +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", + "AnalyticField", + "AnalyticRecord", + "Calibration", + "Exterior", + "FieldPlane", + "MetricKind", + "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, + ) + + @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"]: + """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), + } + + +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. + + 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. + + 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 : 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. + 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 + ------ + 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: AdiabaticAdmission | 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.""" + 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) + 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) + + 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( # noqa: PLR0913 - the beam, its grids and the plane's medium + cls, + pulse: object, + grid: TimeGrid, + geometry: PlaneGeometry, + *, + space: RadialGrid | FreeGrid, + medium: SpatialMedium | None = None, + index: Float[np.ndarray, " n_omega"] | None = None, + reference: PlaneReference | None = None, + ) -> "FieldPlane": # noqa: UP037 - beartype evaluates the class body + r"""Build a spatial plane from the package's beam builder. + + 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, 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 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. + + Returns + ------- + FieldPlane + A radial or Cartesian plane with its admittance metric. + + Raises + ------ + CouplingError + 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) + incident = getattr(pulse, "incident", None) + if not (callable(build) and callable(incident)): + msg = "from_pulse takes a SpatialPulse" + raise CouplingError(msg) + 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, + ) + + @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, + columns, + grid, + "V m" if basis.kind == "radial" else "V/m", + basis, + PlaneReference() if reference is None else reference, + geometry, + metric, + exterior, + medium, + provenance=PlaneProvenance("analytic", history=(repr(field),)), + analytic=AnalyticRecord(support, checks[0], checks[1]), + ) + + @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, + profiles: ScalarModeBasis | 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. + profiles : solprop.coupling.models.ScalarModeBasis or None, optional + The scalar profiles of an ordinary modal basis's columns. + + 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, + profiles=profiles, + ) + + +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) + + +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 new file mode 100644 index 000000000..a72dc5e00 --- /dev/null +++ b/src/solprop/coupling/port.py @@ -0,0 +1,412 @@ +# 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.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.Simulation.with_input` refuses it for any other port, +whatever the array shapes. :func:`solprop.coupling.coupler.couple` computes one. +""" + +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.models import ScalarModeBasis +from solprop.coupling.plane import ( + PlaneBasis, + PlaneGeometry, + PlaneKind, + PlaneMetric, + PlaneReference, + lattice_identity, +) +from solprop.exceptions import CouplingError +from solprop.grid import TimeGrid + +__all__ = [ + "ENTRANCE_TOLERANCE", + "METRIC_TOLERANCE", + "CouplingLaunch", + "InputPort", + "check_same_medium", + "occupied_outside", +] + +ENTRANCE_TOLERANCE = 1e-12 +"""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. +""" + +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: + r"""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 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). + 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 + ------ + 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",) + 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.""" + 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) + 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), + ("normalised_frequency", np.float64), + ): + 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.plane.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 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 + 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..a71567856 --- /dev/null +++ b/src/solprop/coupling/remap.py @@ -0,0 +1,555 @@ +# 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.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 +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.plane.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.plane.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/coupling/transport.py b/src/solprop/coupling/transport.py new file mode 100644 index 000000000..671010289 --- /dev/null +++ b/src/solprop/coupling/transport.py @@ -0,0 +1,517 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +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. +""" + +import dataclasses +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, + ElementReport, + FreeSpace, + Mirror, + SpatialState, + SpectralTransfer, +) +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 FluxSide, SpatialFlux, SpatialMedium, spatial_energy + +__all__ = ["TransportReport", "transport"] + +_AXIS_TOLERANCE = 1e-12 +"""Agreement required between a declared output axis and the transported one.""" + + +@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. + 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 : 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, ...] + energy_in: float + energy_out: float + specified_loss: float + removed: float + approximation_error: float + numerical_error: float + reference_in: PlaneReference | None = None + reference_out: PlaneReference | None = None + frame: str = "" + source_certificate: AdiabaticAdmission | None = None + + @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) + + +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 + ------ + CouplingError + 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 CouplingError(msg) + + +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 + ---------- + 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 + ------- + SpatialState + Its internal-convention spectrum on its grids, medium and local frame. + + Raises + ------ + 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) + + +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): + 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 + 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), + 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:`~solprop.coupling.transport.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 result, report diff --git a/src/solprop/driver/interface.py b/src/solprop/driver/interface.py index 3cde4b8c6..15fc42b68 100644 --- a/src/solprop/driver/interface.py +++ b/src/solprop/driver/interface.py @@ -24,9 +24,10 @@ from typing import Literal import numpy as np -from jaxtyping import Float, Inexact +from jaxtyping import Float from solprop.boundaries import Absorber +from solprop.coupling.models import CouplingModel from solprop.driver.simulation import ( DEFAULT_ATOL, DEFAULT_RTOL, @@ -1163,9 +1164,8 @@ 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, + coupling: CouplingModel | None = None, kerr_form: KerrForm = "real", observable_rtol: float | None = None, observable_atol: float | None = None, @@ -1258,12 +1258,17 @@ 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 + incident : solprop.pulses.SpatialPulse or None, optional + 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 is refused beside `channel`. + `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,7 +1482,8 @@ def prop_waveguide( # noqa: PLR0913 - the interface's keyword surface pressure, provider=provider, channel=channel, - transverse=transverse, + 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 48fc7ae9b..32261655e 100644 --- a/src/solprop/driver/simulation.py +++ b/src/solprop/driver/simulation.py @@ -39,7 +39,7 @@ 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 @@ -56,6 +56,19 @@ ) from solprop.boundaries import absorber as collar from solprop.constants import C +from solprop.coupling import ( + CouplingLaunch, + CouplingModel, + FieldPlane, + InputPort, + PlaneBasis, + PlaneGeometry, + PlaneReference, + basis_identity, + couple, + modal_basis_identity, + space_record, +) from solprop.driver import tdse_consistency from solprop.driver.material_output import ( material_manifest, @@ -77,14 +90,13 @@ ) from solprop.exceptions import ( AdiabaticTaperWarning, + CouplingError, DriverError, GasFlowWarning, - GeneralModalBasisWarning, GrazingIncidenceWarning, MaterialRangeWarning, MemoryBudgetError, MemoryBudgetWarning, - ModeError, SampledPeakWarning, SolpropWarning, TDSEArchiveError, @@ -235,10 +247,11 @@ from solprop.stepper.tableau import DOPRI5 from solprop.translate.coupled import ( CF4_GROWTH_LIMIT, - metric_at, 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, @@ -254,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, ) @@ -279,7 +288,6 @@ "DEFAULT_ATOL", "DEFAULT_N_SAVE", "DEFAULT_RTOL", - "LAUNCH_UNCAPTURED_WARNING", "LOOSE_ADAMS_RTOL", "LOW_STORAGE_BACKENDS", "ORTHONORMALITY_TOLERANCE", @@ -298,14 +306,6 @@ 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. -""" LOOSE_ADAMS_RTOL = 5e-2 """The ``rtol`` from which an adaptive Adams run is warned on. @@ -877,6 +877,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. @@ -918,6 +919,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 ------- @@ -1042,6 +1047,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=( @@ -1065,6 +1071,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.plane.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. @@ -1562,9 +1681,82 @@ 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 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.coupler.couple`) 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 and the models it admits. + + 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, + modes=tuple(self.modes) if route == "modal" else (), + guide=self.guide if isinstance(self.guide, ModalGuide) else None, + ) + 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, @@ -1583,13 +1775,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.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 + 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 @@ -1611,8 +1810,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 ----- @@ -1623,6 +1823,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 @@ -1635,7 +1850,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, @@ -1706,6 +1921,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. @@ -1715,6 +1931,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 ------- @@ -1727,6 +1945,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 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: @@ -1734,7 +1956,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; " @@ -1742,6 +1966,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( @@ -1978,6 +2204,7 @@ def metadata(self) -> RunMetadata: modal=self._modal_identity(), materials=self.material_manifest(), quadrature=self.settings.quadrature, + frame=self.frame, ) return self._metadata @@ -3680,9 +3907,8 @@ 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, + coupling: CouplingModel | None = None, kerr_form: KerrForm = "real", observable_rtol: float | None = None, observable_atol: float | None = None, @@ -3798,22 +4024,33 @@ 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 + 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 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.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 + 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 coupling's + :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 + (``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 `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 @@ -4049,7 +4286,8 @@ def from_waveguide( # noqa: PLR0913 - the interface's keyword surface pressure_floor=pressure_floor, vacuum_end=vacuum_end, channel=channel, - transverse=transverse, + incident=incident, + coupling=coupling, kerr_form=kerr_form, observable_rtol=observable_rtol, observable_atol=observable_atol, @@ -4099,7 +4337,8 @@ def from_waveguide( # noqa: PLR0913 - the interface's keyword surface for name, value in ( ("memory_budget", memory_budget), ("channel", channel), - ("transverse", transverse), + ("incident", incident), + ("coupling", coupling), ("kerr_form", None if kerr_form == "real" else kerr_form), ("observable_rtol", observable_rtol), ("observable_atol", observable_atol), @@ -4178,9 +4417,8 @@ 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, + coupling: CouplingModel | None, kerr_form: KerrForm, observable_rtol: float | None, observable_atol: float | None, @@ -4230,9 +4468,12 @@ 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 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. @@ -4319,7 +4560,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 +4572,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 +4583,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 +4609,22 @@ 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, - route.tables, - transverse, - # 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. - 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( controller=PIController(rtol=rtol, atol=atol), save_planes=planes, @@ -4403,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, [], @@ -4416,12 +4654,15 @@ def _from_general( # noqa: PLR0913 - it refuses from_waveguide's whole surface "length": length, "provider": dict(provider.identity), }, - "pulse": pulse, + **({} 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 @@ -8472,152 +8713,242 @@ def _in_channel( return state -def _projected_launch( - built: Complex[np.ndarray, "n_modes_in n_pol n_omega"], - tables: ModalTransformTables, - transverse: Inexact[np.ndarray, "n_nodes n_components n_omega"] - | Inexact[np.ndarray, "n_nodes n_components"], - 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. +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 ---------- - built : numpy.ndarray - The pulse on the run's grid, shape ``(1, n_pol, n_omega)``. - 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. - 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. + 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 ------- - numpy.ndarray - The launched state, shape ``(n_modes, n_pol, n_omega)``, in the square - root of watts. + solprop.pulses.GaussianPulse or SechPulse or PulseSum or SpatialPulse + The input. 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 `incident` is given beside `channel` or a pulse keyword, is a planar + beam, or is launched away from its waist. """ - if built.shape[0] != 1: + 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 = ( - 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" + "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) - 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): + 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"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}" + 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) - try: - launched = project_transverse_field(tables, profile * built[0, 0]) - 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 - if metric is None - else np.real( - np.einsum( - "mw,mnw,nw->mw", - np.conj(launched.amplitudes), - metric, - launched.amplitudes, - ) + 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" ) - ) - total = float(np.sum(weighted)) - if not np.isfinite(total) or total <= 0.0: + raise DriverError(msg) + if incident.propz != 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" + 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) - # 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 + 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 + + +_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, + 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.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 + :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. + model : solprop.coupling.models.CouplingModel + The coupling model. + + Returns + ------- + Simulation + The simulation launching the coupled amplitudes, sharing the translation. + + Raises + ------ + DriverError + If the beam cannot be built or the coupling refuses it. + """ + port = simulation.input_port() + index = ( + np.zeros(simulation.grid.n_omega) + if port.reference_index is None + else (np.asarray(port.reference_index)) ) - 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 + try: + 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 + report = result.report logger.info( - "general modal launch projected onto %d channel(s): they capture %.1f %% of " - "the declared transverse field (%.1f %% not captured)", - state.shape[0], - 100.0 * max(0.0, 1.0 - missed), - 100.0 * missed, + "incident beam coupled into %d channel(s) under %s: %.4g J incident, " + "%.4g J launched", + port.shape[0], + model.kind, + report.incident_energy, + report.launched_energy, + ) + return simulation.with_input( + result.launch, + noise=simulation.noise, + configuration={"pulse": pulse, "launch": report}, ) - 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 def _in_lowest_mode( 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/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/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/src/solprop/output/schema.py b/src/solprop/output/schema.py index 0fd3274d0..7eed9971e 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.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"]``. + 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/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 ) 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..9fd1237e0 --- /dev/null +++ b/src/solprop/processing/planes.py @@ -0,0 +1,571 @@ +# 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.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 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.adiabatic import AdiabaticAdmission +from solprop.constants import EPS0, C +from solprop.coupling import ( + FieldPlane, + NodalField, + PlaneBasis, + PlaneGeometry, + PlaneKind, + PlaneMetric, + PlaneProvenance, + ScalarModeBasis, + basis_identity, + reference_at, +) +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 +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 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. +""" + +_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, AdiabaticAdmission | 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 the record rebuilds, 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 = 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: + 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) + # 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, + weights=space.spectral_weights, + scale=0.5 * EPS0 * C, + flux=flux, + ) + return metric, medium, plan.admission + + +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, + 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.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 + 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 + :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, and so is a run with the modified noise model, + whose auxiliary noise phase is not reconstructed into the plane. + + 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; 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 + ------- + 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 used + the modified noise model, 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 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) + 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 + 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, + grid, + "sqrt(W)", + basis, + reference, + geometry, + metric, + "zero", + nodal=nodal, + provenance=provenance, + profiles=profiles, + ) diff --git a/src/solprop/pulses.py b/src/solprop/pulses.py index 7b5529a13..ba50be326 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( # is finite. 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/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/src/solprop/translate/coupling.py b/src/solprop/translate/coupling.py new file mode 100644 index 000000000..b01a4f5f9 --- /dev/null +++ b/src/solprop/translate/coupling.py @@ -0,0 +1,384 @@ +# 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.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, 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 +from jaxtyping import Float + +from solprop.constants import EPS0, C +from solprop.coupling import ( + InputPort, + PlaneBasis, + PlaneGeometry, + PlaneKind, + PlaneMetric, + PlaneReference, + ScalarModeBasis, + reference_at, +) +from solprop.exceptions import CouplingError, TranslationError +from solprop.grid import TimeGrid +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 +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", + "guide_contrast", + "input_port", + "port_metric", + "step_index_frequency", +] + + +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.plane.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, + 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 + 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. + 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 and the models it admits. + """ + 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 + models: tuple[str, ...] = ("same_basis",) + 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 + 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]) + 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: + 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, + grid, + basis, + shape, + PlaneGeometry(z, side), + entrance, + port_metric(problem, grid, z), + index, + nodes, + weights, + electric, + magnetic, + models, + profiles, + contrast, + frequency, + ) + + +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 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. + + 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/__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/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_couple.py b/tests/coupling/test_couple.py new file mode 100644 index 000000000..4e528f7e5 --- /dev/null +++ b/tests/coupling/test_couple.py @@ -0,0 +1,1213 @@ +# 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 ( + STEP_INDEX_BEAM_ENVELOPE, + STEP_INDEX_ENVELOPE, + BasisChange, + CouplingModel, + FieldPlane, + InputPort, + Outcouple, + PlaneBasis, + PlaneGeometry, + PlaneMetric, + PlaneReference, + ScalarModeBasis, + basis_identity, + couple, +) +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 +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 +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 + + +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 + + +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), + ) + + +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 + + +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 beyond its envelope, 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 + # 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) + 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 + + +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) + + +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_elements.py b/tests/coupling/test_elements.py new file mode 100644 index 000000000..39023351f --- /dev/null +++ b/tests/coupling/test_elements.py @@ -0,0 +1,517 @@ +# 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 tests.coupling.sequence_cases import apply_sequence + +from solprop.constants import C +from solprop.coupling.elements import ( + Alignment, + Aperture, + FreeSpace, + Mirror, + PlanarInterface, + PolarisationTransform, + SpatialState, + SpectralCalibration, + SpectralTransfer, + ThinLens, + TransmissionMask, +) +from solprop.exceptions import CouplingError +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(CouplingError, match="perpendicular"): + Mirror(**CONDUCTOR, incident=(0, 0, 1), outgoing=(0, 0, -1), s_axis=(0, 1, 1)) + 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(CouplingError, match="incident direction"): + Mirror( + **CONDUCTOR, incident=(1, 0, 0), outgoing=(0, 0, 1), s_axis=(0, 1, 0) + ).apply(state) + 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(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(CouplingError, match="singular values"): + PolarisationTransform(np.array([[1.0, 1.0], [0.0, 1.0]])).apply(state) + with pytest.raises(CouplingError, match="2x2"): + PolarisationTransform(np.eye(3)).apply(state) + cartesian = FreeGrid(1e-3, 16, 1e-3, 16) + with pytest.raises(CouplingError, 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(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)) + + +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(CouplingError, match="applied once"): + interface.apply(result.state) + with pytest.raises(CouplingError, match="PlanarInterface"): + FreeSpace(0.1, air).apply(result.state) + FreeSpace(0.1, glass).apply(result.state) + with pytest.raises(CouplingError, 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(CouplingError, match="explicit efficiency"): + SpectralTransfer(gdd, predict_loss=True).apply(state) + with pytest.raises(CouplingError, match="twice"): + SpectralTransfer(Material("SiO2", 1e-3, loss=True), efficiency=0.9).apply(state) + with pytest.raises(CouplingError, match=r"\[0, 1\]"): + SpectralTransfer(gdd, efficiency=1.2).apply(state) + 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 + ).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(CouplingError, match="model_error"): + ThinLens(0.5, model_error=1e-7).apply(state) + with pytest.raises(CouplingError, match=r"\(0, 1\)"): + ThinLens(0.5, model_error=0.0).apply(state) + with pytest.raises(CouplingError, match="Nyquist"): + ThinLens(5e-3, model_error=0.5).apply(state) + with pytest.raises(CouplingError, match="nonzero focal"): + ThinLens(0.0, model_error=1e-3).apply(state) + with pytest.raises(CouplingError, match="passive"): + TransmissionMask(np.full(space.n_r, 1.1), model_error=1e-3).apply(state) + 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(CouplingError, match="n_nodes"): + TransmissionMask(np.ones(3), model_error=1e-3).apply(state) + 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) + ) + + +def test_radial_grids_refuse_symmetry_breaking(beam: BeamFactory): + space = RadialGrid(4e-3, 64) + state = beam(space, 1e-3) + with pytest.raises(CouplingError, 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(CouplingError, match="off-axis"): + Aperture(1e-3, model_error=1e-2, centre=(1e-4, 0.0)).apply(state) + with pytest.raises(CouplingError, match="cylindrical symmetry"): + Alignment(displacement=(1e-4, 0.0)).apply(state) + 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)" + + +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(CouplingError, match="tilt"): + Alignment(tilt=(1e-3, 0.0)).apply(state) + 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(CouplingError, match="line grid"): + SpatialState(grid, LineGrid(1e-3, 16), zero, air) + 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(CouplingError, match="outside the active band"): + SpatialState(grid, space, outside, air) + taper = SpatialMedium("Ar", Linear(1.0, 2.0, 0.1)) + with pytest.raises(CouplingError, match="homogeneous"): + SpatialState(grid, space, zero, taper) + 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(CouplingError, match="neither"): + apply_sequence(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(CouplingError, match="finite on every band"): + ThinLens(lambda omega: np.full(omega.shape, np.nan), model_error=1e-3).apply( + state + ) + 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(CouplingError, match="finite"): + PolarisationTransform(np.full((2, 2), np.inf)).apply(state) + record = SpectralCalibration(None, None, None) + with pytest.raises(CouplingError, 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(CouplingError, match="known optics element"): + SpectralTransfer(Chain((lambda omega: np.ones(omega.shape, complex),))).apply( + state + ) + 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(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) + 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(CouplingError, match="no forward flux"): + ThinLens(1.0, model_error=1e-3).apply(state) diff --git a/tests/coupling/test_envelopes.py b/tests/coupling/test_envelopes.py new file mode 100644 index 000000000..2464910d5 --- /dev/null +++ b/tests/coupling/test_envelopes.py @@ -0,0 +1,1253 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +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 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`); +- 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 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. +""" + +import itertools +import math +from typing import cast + +import numpy as np +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, MU0, C +from solprop.coupling import ( + CAPILLARY_ENVELOPE, + STEP_INDEX_BEAM_ENVELOPE, + STEP_INDEX_ENVELOPE, + VECTOR_STEP_INDEX_ENVELOPE, + CouplingModel, + FieldPlane, + PlaneBasis, + PlaneGeometry, + ScalarModeBasis, + couple, +) +from solprop.coupling.models import radial_rule +from solprop.driver import Simulation +from solprop.exceptions import CouplingError +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 +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_scalar_capillary_overlap_matches_marcatili_fields_by_quadrature( + capillary_port, waist, shift, tilt +): + """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``, + 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 + 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 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 = 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_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 + 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)) + <= 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 + ) + + +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 + + +# ------------------------------------------------- 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, 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. + """ + 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 + + +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) + + +def guided_count(order, v, contrast): + """Return the guided modes of one azimuthal order from the exact cutoffs. + + 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 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 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] + 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): + """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_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_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 new file mode 100644 index 000000000..ae04a03f5 --- /dev/null +++ b/tests/coupling/test_plane.py @@ -0,0 +1,347 @@ +# 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, + 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 +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", {})) + + +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 new file mode 100644 index 000000000..7e5bb75d8 --- /dev/null +++ b/tests/coupling/test_port.py @@ -0,0 +1,203 @@ +# 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 of ``couple`` (SEP 31).""" + +import dataclasses + +import numpy as np +import pytest + +from solprop.coupling import ( + CouplingLaunch, + CouplingModel, + FieldPlane, + InputPort, + PlaneBasis, + PlaneGeometry, + PlaneMetric, + PlaneReference, + ScalarModeBasis, + basis_identity, + couple, +) +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 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 = { + "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", {} + ) + + +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 new file mode 100644 index 000000000..06901c2fb --- /dev/null +++ b/tests/coupling/test_remap.py @@ -0,0 +1,314 @@ +# 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 + + +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/coupling/test_transport.py b/tests/coupling/test_transport.py new file mode 100644 index 000000000..67685c16b --- /dev/null +++ b/tests/coupling/test_transport.py @@ -0,0 +1,649 @@ +# 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 tests.coupling.sequence_cases import apply_sequence + +from solprop import fourier +from solprop.constants import C +from solprop.coupling.elements import ( + Alignment, + Aperture, + FreeSpace, + SpatialState, + ThinLens, +) +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 +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["wrap_share"] <= 1e-6 + + +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", 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, + # 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 = apply_sequence( + 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 = apply_sequence( + 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 = 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)) + 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 = 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( + _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 = apply_sequence( + 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") + # The aperture's diffraction reaches the window over 0.2 m; its removal is + # 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) + gdd = TaylorPhase((0.0, 0.0, 200e-30), 800e-9) + tod = TaylorPhase((0.0, 0.0, 0.0, 3000e-45), 800e-9) + 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, + 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 = apply_sequence( + 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(CouplingError, match="nonpropagating"): + FreeSpace(1e-6, air).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 + assert report.energy_out == pytest.approx( + report.energy_in - report.removed, rel=1e-12 + ) + + +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) + wide = RadialGrid(5e-3, 64) + # 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. + # 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(CouplingError, match="positive distance"): + FreeSpace(-1.0, air, output=wide, output_method="fresnel").apply(state) + with pytest.raises(CouplingError, match="same kind"): + FreeSpace(0.1, air, output=FreeGrid(1e-3, 8, 1e-3, 8)).apply(state) + with pytest.raises(CouplingError, match="PlanarInterface"): + FreeSpace(0.1, SpatialMedium(1.5)).apply(state) + with pytest.raises(CouplingError, 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 = apply_sequence(zero, []) + assert identity.report.energy_in == identity.report.energy_out == 0.0 + 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(CouplingError, match="neither an element"): + apply_sequence(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 = 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( + _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 = apply_sequence( + radial, [lens, FreeSpace(1.0, air, output=far_r, output_method="fresnel")] + ) + 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; + # 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 = 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) + ) + + +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) + # 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) + # 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 + 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): + 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 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, []) diff --git a/tests/coupling/test_vector_coupling.py b/tests/coupling/test_vector_coupling.py new file mode 100644 index 000000000..2ad2dad16 --- /dev/null +++ b/tests/coupling/test_vector_coupling.py @@ -0,0 +1,428 @@ +# 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 +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, + 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, 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. ``circular`` declares + the circular partners of a degenerate pair as the basis. + """ + return general_guide( + descriptors=descriptors, + blocks=blocks, + radius=4e-6, + grid=grid, + n_r=n_r, + knots=9, + indices=(1.45, 1.44), + circular=circular, + ) + + +@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. 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) + 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 + ) + 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) + 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) + + +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/coupling/test_workflows.py b/tests/coupling/test_workflows.py new file mode 100644 index 000000000..14e34786d --- /dev/null +++ b/tests/coupling/test_workflows.py @@ -0,0 +1,828 @@ +# 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 ( + STEP_INDEX_BEAM_ENVELOPE, + 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.""" + # 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 + # 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 + + +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 + + +@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"] 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..f7e089ac6 --- /dev/null +++ b/tests/driver/test_coupling_relaunch.py @@ -0,0 +1,207 @@ +# 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 CouplingModel, couple +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 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 +): + 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 0abbaa77e..df16e9692 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 @@ -53,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 ( @@ -66,8 +65,8 @@ from solprop.driver.scan import Scan, run_scan from solprop.driver.simulation import ( _GAS_KEYWORD_DEFAULTS, + _core_index, _in_channel, - _projected_launch, _refuse_gas_keywords, _shot_noise, ) @@ -89,7 +88,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 @@ -491,147 +490,258 @@ 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, + ) + + +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 coupling an incident beam.""" + return Simulation.from_waveguide( + (), + GENERAL_Z_END, + provider=guide, + grid=grid, + lambda0=PULSE["lambda0"], + incident=incident, + coupling=overrides.pop("coupling", INCIDENT_MODEL), + **(RUN | overrides), ) +def account(simulation) -> CouplingReport: + """Return the coupling report a simulation recorded.""" + report = simulation.configuration["launch"] + assert isinstance(report, CouplingReport) + return report + + +@INCIDENT_FILTER @pytest.mark.parametrize("kind", ["identity", "dense"]) -def test_the_projected_launch_carries_the_pulses_energy_and_the_declared_shape( - pair_guide, circular_pair_guide, grid, kind +def test_an_incident_launch_keeps_what_the_channels_capture_under_the_runs_metric( + incident_pair, incident_circular, 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 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 - 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, - ) + guide = incident_pair if kind == "identity" else incident_circular + simulation = launched(guide, grid, incident_beam(3e-6)) + report = account(simulation) + # 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)), - 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.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, ) + 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 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) -def test_a_real_transverse_profile_is_the_contract_and_launches_as_its_complex_copy( - pair_guide, grid +@INCIDENT_FILTER +def test_an_incident_launch_shares_a_coherent_beam_between_two_channels( + incident_pair, grid ): - """A real profile is accepted under the hook and means what it says. + """A beam polarised between the parity channels fills both, coherently. - 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. + 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 launched is the + x-polarised beam's: what is recovered is the field, not a sum of separately + projected intensities. """ - 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]) - ) + 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).launched_energy, + account(straight).launched_energy, + rtol=1e-6, ) - 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) -def test_the_two_launches_are_refused_together_and_an_empty_one_by_name( - pair_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 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(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.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 "launched share" in str(report) + + +@INCIDENT_FILTER +def test_the_incident_energy_does_not_depend_on_the_guides_aperture( + incident_guide, grid ): - """Two ways to say what is launched, and a profile no channel sees.""" + """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 + 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=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.launched_energy != wide_account.launched_energy + + +@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"): - 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), - ) - # 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`. - bare = dataclasses.replace(tables) - object.__setattr__(bare, "coordinate_dual", None) - 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), - ) + launched(incident_pair, 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(incident_pair, grid, beam, **{keyword: value}) + temporal = beam.temporal + with pytest.raises(DriverError, match="PlanarPulse"): + launched(incident_pair, grid, PlanarPulse(temporal, 1e-6, waist_x=1e-6)) + with pytest.raises(DriverError, match="propz=0"): + 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="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 = 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"): + _core_index(shifted, grid) class _LossyLaw: @@ -682,50 +792,88 @@ 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 -@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( + incident_guide, grid +): + """A beam much wider than the core enters with the little the guide captures. + + 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. """ - 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(40e-6) output = cast( "MemoryOutput", prop_waveguide( (), GENERAL_Z_END, - provider=pair_guide, + provider=incident_guide, grid=grid, - transverse=profile, + lambda0=PULSE["lambda0"], + incident=beam, + coupling=INCIDENT_MODEL, progress="none", - **(PULSE | RUN), + **RUN, ), ) + 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) - 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 report'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.launched_energy, + rtol=1e-12, atol=0.0, ) + assert output.metadata is not None + recorded = output.metadata.configuration["launch"] + 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( + 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 launched energy. + """ + if backend == "native": + pytest.importorskip("solprop_native") + beam = incident_beam(3e-6) + step = 5e-7 + simulation = launched( + incident_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(incident_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).launched_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): @@ -1014,7 +1162,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] @@ -2439,36 +2587,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/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_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/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_entry.py b/tests/driver/test_tdse_entry.py index 5f59836c6..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 = "aad63c25bc485771c6e6cf1cceb7868f877a5de6a7fd5dc07c741b68cdb50683" +_UNCHANGED = "fe3cea40a780789cb5b8a8d3da0d4cb1abf5aea0654ad6da91c80a2f82f6d9b8" def test_without_tdse_a_representative_call_is_unchanged(): 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/facet_mode_matching.py b/tests/facet_mode_matching.py new file mode 100644 index 000000000..b37ffd7c4 --- /dev/null +++ b/tests/facet_mode_matching.py @@ -0,0 +1,440 @@ +# 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 + + 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: + """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 | None = 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), 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) + 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) + ) + 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)) + + +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])) 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 b99afe18f..e399b38f9 100644 --- a/tests/output/test_schema.py +++ b/tests/output/test_schema.py @@ -930,7 +930,7 @@ 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 def test_spatial_metadata_of_schema_23_names_its_flux_model(envelope_grid, stepper): 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/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 diff --git a/tests/processing/test_mixtures.py b/tests/processing/test_mixtures.py index 0275ed8d2..d3cbf6a6b 100644 --- a/tests/processing/test_mixtures.py +++ b/tests/processing/test_mixtures.py @@ -196,7 +196,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..8ef3a21a9 --- /dev/null +++ b/tests/processing/test_planes.py @@ -0,0 +1,440 @@ +# 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.adiabatic import AdiabaticAdmission +from solprop.coupling import basis_identity, reference_at +from solprop.driver import Simulation, SimulationSettings +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, +) +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 +from solprop.translate.coupling import input_port + +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_rebuilt_path_certificate(tmp_path): + simulation = spatial( + RadialGrid(6e-4, 32), PressureGradient.two_point(2e-3, 1.0, 2.0) + ) + 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") +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)) + ) + + +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_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() + 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_display.py b/tests/test_display.py index 8bd074e0b..9fc54f407 100644 --- a/tests/test_display.py +++ b/tests/test_display.py @@ -16,6 +16,7 @@ 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.exceptions import MaterialRangeWarning, ModeError, OutputError @@ -218,6 +219,24 @@ 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), + # 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, # A moving guide's path record: synthetic numbers of the shape a sampled path @@ -506,6 +525,7 @@ def examples(tmp_path_factory): "ProviderChannelMode", "ModalTransformTables", "ProjectedLaunch", + "CouplingReport", "GeneralModalRoute", "ModalMetricTables", "CoupledFlowTables", @@ -681,7 +701,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() ) @@ -898,6 +918,7 @@ def rounding(block: str) -> str: "ProviderChannelMode", "ModalTransformTables", "ProjectedLaunch", + "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 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"}, diff --git a/tests/test_spatial_flux.py b/tests/test_spatial_flux.py index 7e05b4ad6..5b4c11644 100644 --- a/tests/test_spatial_flux.py +++ b/tests/test_spatial_flux.py @@ -243,6 +243,108 @@ 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_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_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) 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) diff --git a/uv.lock b/uv.lock index 535744f30..98d2e24b3 100644 --- a/uv.lock +++ b/uv.lock @@ -971,16 +971,16 @@ wheels = [ [[package]] name = "optmat" -version = "0.8.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/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/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/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/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.8.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" },