General modal propagation#

This page is for anyone propagating in a solid or graded-index guide whose modes cannot be treated as scalar: a small or high-contrast core, several vector channels, a declared circular basis, or a guide whose geometry moves along z. It sets out the model the general modal route solves, how to run it, what it measures about its own accuracy, and what it refuses. How the route relates to every other transverse decomposition solprop has is set out, with a figure each, in spatial decompositions; the step-by-step workflow, with snippets that run as written, is solid-core fibres.

A waveguide mode is not a scalar. A bound hybrid mode of a solid or a hollow fibre has \(E_x\), \(E_y\) and \(E_z\), and its nonlinear polarisation mixes all three; the transverse scalar profile the rest of solprop propagates against is the weakly guiding limit of that, which is excellent for a large hollow core and progressively worse as the index contrast rises and the core shrinks. The general modal route propagates the full three-component field instead: a provider solves \(\mathbf E\) and \(\mathbf H\) at quadrature nodes across the cross-section, the run synthesises the physical vector field there, evaluates the material’s own response at each node, and projects the result back onto the channels with the reciprocity relation that defines them.

It is reached through the waveguide entry points, with one extra argument: from_waveguide() and prop_waveguide() take a provider= in place of a gas fill. What the route supports, and what it refuses rather than approximates, is set out in the last two sections of this page; read them before planning a run. In short: the route runs on a field grid, for a guide of constant geometry or a solid guide whose geometry moves along z, on numpy_ref, numba_cpu and the native backend (its CPU build and its CUDA and HIP device builds alike); a coupled flow runs on all of them too, except that a flow with a CF4 section runs on numpy_ref alone. A general modal run writes, reads back and resumes an HDF5 file (output schema 7), a scan point writes one, and a batch of launches on one guide runs in lockstep. A declared basis over a block that is only nearly degenerate propagates as a coupled matrix flow, and a guide whose geometry moves carries the connection of its moving basis, which couples its channels. Declared, refused, recorded is the whole route on one screen.

Running one#

Three objects, in order: the material regions, the guide, the run. An executed version with four vector channels, and the rule for the band the guide must span, is a step-index fibre, with its vector modes.

import numpy as np

from solprop.driver import prop_waveguide
from solprop.grid import FieldGrid
from solprop.modes import (
    IndexMaterial,
    MaterialKerr,
    MaterialRegion,
    ModalGuide,
    StepIndexMode,
)

length = 1e-3
grid = FieldGrid.from_limits(length, 835e-9, (790e-9, 890e-9), 200e-15)

# 1. The geometry, and the materials that own the responses. A region with no
#    MaterialKerr is linear, which is how a cladding is said to be linear.
core_law, cladding_law = IndexMaterial(1.45), IndexMaterial(1.44)
mode = StepIndexMode(4e-6, core=1.45, cladding=1.44, profile_wavelength=835e-9)
regions = (
    MaterialRegion(
        "core", 0.0, mode.core_radius, core_law, (MaterialKerr(n2=2.6e-20),)
    ),
    MaterialRegion("cladding", mode.core_radius, None, cladding_law),
)
strand = ModalGuide.from_step_index(
    [mode], core=core_law, cladding=cladding_law, regions=regions
).field_provider

# 2. The guide: the provider, solved on its own node rule at its own knots, over
#    the run's whole ACTIVE band -- twice the declared one, see below.
active = grid.omega[grid.active_mask]
guide = ModalGuide.from_provider(
    strand,
    np.linspace(0.99 * active[0], 1.01 * active[-1], 13),
    nodes=strand.node_rule(0.99 * float(active[0]), n_r=24),
)

# 3. The run. The guide supplies its own channels, so `modes` is empty.
output = prop_waveguide(
    (),
    length,
    provider=guide,
    grid=grid,
    lambda0=835e-9,
    fwhm=40e-15,
    energy=4e-11,
    n_save=2,
    backend="numpy_ref",
)

The run warns once, with a GeneralModalBasisWarning: thirteen knots leave the interpolated basis \(1.1\times10^{-4}\) from orthonormal near 1.73 µm, at the long-wavelength edge of the active band where the launched pulse puts no light. The warning, and why it is a warning rather than a refusal, are explained under normalisation; more knots remove it.

provider= takes a guide, not a provider. Despite the keyword’s name, what it wants is a ModalGuide — a field provider together with its material regions, its ordered channels and the ModalSampling that records what they were solved on — and a bare ModalProvider carries none of those.

channel= chooses which channel the pulse enters, by index or by display label ("HE1,1:even"); the default is the first. The launch is the pulse’s amplitude placed in that channel unchanged: a provider normalises every channel to unit longitudinal Poynting flux, so a channel amplitude already is a true-power amplitude in \(\sqrt{\rm W}\) 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.

incident= launches an incident beam instead. Where channel= names one channel, incident= takes a 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; coupling simulations):

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 (incident()) — never over the guide’s quadrature nodes, which cover only the guide. The beam becomes a radial FieldPlane that resolves it (from_pulse() with the core’s index), and 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). 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), which contracts a polarisation for a source term. For a field you sample yourself, 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 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 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 a guide owns its materials, its responses and its transverse rule, and a keyword silently ignored would let you believe a fill or a resolution had been applied. observable_rtol and observable_atol are accepted as None, which means the solver’s own rtol and atol: the route’s basis is orthonormal in true power, so the controller’s weighted norm on the amplitudes already is a norm on the physical channel powers.

For a worked example that runs and plots, see Propagate a vector fibre mode, and check it against the GNLSE; for a graded-index guide, its self-imaging beat and a launch declared as a field, see Self-imaging in a graded-index guide, and a launch declared as a field; for a declared basis of a split pair, propagated as a coupled flow and checked against the provider’s own basis, see A declared basis of a split pair: two bases, one answer. The guide to guided propagation sets out the workflow step by step, and the graded-index worked example runs a multimode fibre at full size.

A guide must be solved over at least twice the band a run declares. Grid.active_mask runs from half the lowest declared frequency to the window’s highest, so the FieldGrid above, declared over 790–890 nm, has an active band of 724.9–1774.0 nm — a factor 2.45 in frequency — and the guide’s table must cover all of it, at the low end of which a weakly bound mode reaches furthest into the cladding and a truncating node rule discards most of it. Outside the table’s own knots the stored reference is an explicit zero, and a run that reaches there is refused with both bands in the message rather than propagated against zeros. An EnvelopeGrid of the same window has a slightly wider active band, so a guide meant to serve a comparison against prop_gnlse() has to cover the union of the two.

The collar, and not the route, sets the step on a short grid. from_waveguide builds the grid’s default temporal collar, applied as a rate inside every stage (absorbing boundaries). Its rate is set by the length the grid was declared for and by the time window, and on a short run that rate, not the nonlinearity, is what the step controller reacts to. On the millimetre above the general route takes 42 accepted steps with the collar and exactly 10 without, and prop_capillary on the same grid does the same thing: this is not the general route’s doing, it only matters more here because a general modal step costs about a hundred fused ones. Two settings change it: absorber_scheme="split_step" applies the same rates once per step, outside the stage, and reaches the same answer in the collar-free step count; absorber=None in the settings removes the absorber, for a lossless check. absorber=None passed to from_waveguide itself means “build the default collar”, so replace the settings before the first translate() or run() instead:

import dataclasses

from solprop.driver import Simulation

simulation = Simulation.from_waveguide(
    (), length, provider=guide, grid=grid, lambda0=835e-9, fwhm=40e-15, energy=4e-11
)
simulation.settings = dataclasses.replace(
    simulation.settings, absorber_scheme="split_step"
)

prop_gnlse builds no absorber at all, so a like-for-like comparison with it needs one of the two.

Noise, statistics and what comes back#

Because the basis is orthonormal in true power, one photon per channel per bin already is an energy-normalised amplitude: the shot-noise draw of every other route (Shot noise) is carried as it stands, with nothing multiplying it by an index, and a seed= reproduces the draw exactly. In a declared basis the draw is made once, in the orthonormal true-power reference basis, with the same seed and the same stream, and pushed through \(U^{\mathsf H}\) (transformed_noise()): the total photon number in each bin is exactly what it was and the per-channel numbers are redistributed, which is the point — a fixed-modulus random-phase draw is not invariant in distribution under unitary mixing, so drawing again in the declared basis would be a different stochastic model. A basis with unequal channel norms is neither of those and is refused by name.

The route’s statistics are physical_energy and analytic_flux, and they are its defaults. Both are scalars: a coordinate energy and a coordinate power are not additive on a basis that is not orthonormal, so the names that promise one are refused here rather than redefined. physical_energy is the same-frequency product of the state with the basis’s flux metric, integrated over frequency; analytic_flux is the peak over time of that same same-frequency flux, \(\max_t \mathbf a^{\mathsf H}W\mathbf a\). On an identity metric — every guide in the provider’s own coordinates — that is \(\max_t\sum_m|a_m(t)|^2\) and their arithmetic is exactly sum(energy(...)) and peak_power_total, and their docstrings say so.

A third name, spectral_flux, is the same quantity resolved in frequency: the spectral density \(\mathrm dU/\mathrm d\omega\) of physical_energy, in J/(rad/s), summed over the channels after the whitening exactly as physical_energy is. It is admitted but not a default — it is n_omega wide per plane where the other five statistics are scalars — so ask for it in statistics=; d_omega times its sum is the physical_energy the same plane records.

analytic_flux is not the cross-section flux of the reconstructed fields. That one carries the guide’s cross-frequency flux kernel as well, and the two peaks differ by at most \(\sum_{ij}|B_i||B_j||K_{ij}-1|\) — measured at 1.3e-5 of the peak inside a bound of 3.4e-5 for one \(\mathrm{HE}_{11}\) channel of a 1.5 µm strand and a 20 fs pulse, while the time integrals agree exactly. For the flux itself ask solprop.processing.modal.reconstructed_flux(), which rebuilds E and H and returns the flux as a trace in time; see statistics.

energy, peak_power, peak_power_total, energy_window, peak_power_window and peak_intensity are therefore refused by name, each with the replacement to use, at all four places a statistic can be asked for: the run loop’s statistics admission, the backends’ stats, the accessors of solprop.processing.modal and the scalar-route energy() and peak_power(), which read only the stored record. omega0, fwhm_t and fwhm_t_outer are admitted, and they take the metric where the identity no longer holds: they are functionals of the same physical same-frequency flux \(\mathbf a^{\mathsf H} W\mathbf a\) that analytic_flux peaks, so a run does not report the width of a quantity whose peak it does not report. zdw takes no metric: it is a property of the channels’ propagation constants and of no basis at all, and a unitary transform of an exactly degenerate block leaves every one of them where it was; beside a split block, which has no per-channel propagation constant, it is withheld (above). The backend evaluates \(W\) at each plane and hands it over; see statistics. quadrature_error is refused by name too, rather than recorded as the nan it would be: a provider’s node rule has no embedded coarse rule to difference, and a nan at every plane would read as “no alarm was raised” when what is true is that a general modal run has no automatic transverse-resolution alarm — the rule’s tail bound and its sampled Gram are checked once, when the guide is built, and nothing watches the resolution during the run.

Output schema 7 carries this basis. A general modal run writes an HDF5 file, reads it back and resumes it: the record persists the channel labels row by row, the provider, the sampling the reference is rebuilt from, the materials and their declared Kerr responses, the table and metric identities with the measured Gram defect, the shared co-moving frame, the run’s active band, the basis the noise was drawn in, and the list of accessors the basis admits. All of that is inside the resume key, so a run continued against a changed guide, table, material or channel order is refused by the field that moved, and a file written before schema 7 is never resumed into one. A declared change of basis is in the key four times over — the metric’s kind moves to dense, both gauges gain +circular(i,j), the channels carry handedness instead of parity, and the sampling carries the declaration with its matrix — so a file written in one basis cannot resume in another. The sampling’s entry appears only where a basis is declared, so a run in the provider’s own coordinates carries no such entry; the two gauges are always compared, and they refuse a resume in the other direction. to_file() and a scan point write the same file.

A batch member may be a general modal run, under a narrower admission rule than the one for fibre runs (Parameter scans) and alongside it: the members of a general-guide group share the physical guide, the table, the gauge, the materials and one explicit fixed schedule, and differ in their launch and their noise seed and in nothing else. What separates two guides is modal_tables.identity — its three declared identities and its extents — in the grouping signature, beside the metric’s kind and gauge and the compiled responses’ model and policy; two strands of different radius share every layout entry of the signature and are told apart by what they physically are. Because a descriptor is only as good as what it names, a group assembled by hand is refused by name at the driver and again at the backend: general-guide batch requires identical guide/table/gauge identity. The source pass is unchanged by batching: the lockstep runner hands each member its own singleton row, and a batched run is what its members are alone, to rounding. A fused batch axis — one problem of shape (n_batch, ...) carrying modal tables — is refused by the reference backend, because the stage evaluates one member’s polarisation.

The file stores channel amplitudes, not fields. solprop.processing.modal is the way back — given the run’s own tables, rebuilt from the guide the record names, it synthesises \(\mathbf E\) in V/m and \(\mathbf H\) in A/m at the guide’s nodes and forms reconstructed_flux in time, instantaneous_flux of the real fields with its sum-frequency term, and coordinate_intensity, the flux density each channel would carry alone, which is not additive and is never clipped; plane_nodes returns the nodes and area weights those values live on, which on a moving guide are each plane’s own. A reconstruction needs the guide beside the file: the arrays are not in it, and guide= is a required keyword of all six reconstruction accessors, so the sampling and the regions are always checked too. See output.

Declared, refused, recorded#

This is the whole route in one place, each line pointing at the section that explains it.

What you declare.

declaration

where

explained in

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, 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

the cubic’s form

kerr_form="real" (the default, third harmonic included) or "analytic"

the response at a node

the launch

channel= (an index or a label) or incident= (a SpatialPulse coupled under coupling=, of which the captured share enters)

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, a split block

the statistics

physical_energy and analytic_flux (the defaults), spectral_flux by name

noise, statistics and what comes back

What is refused, and why. Every refusal names itself, at the entry point and again at the translation layer and in the backend.

  • Every gas keyword beside a provider: the guide owns its materials, its responses and its transverse rule, and a keyword silently ignored would read as applied.

  • A CF4 section on any backend but numpy_ref: it has no execution path there (its Gauss-node weights are evaluated inside the reference backend), and nothing falls back.

  • A guide that does not span the active band, or whose sampled or interpolated Gram is further from orthonormal than gram_atol: the run would propagate against zeros, or against a basis it declares orthonormal and is not.

  • An n2 with no at= whose conversion to \(\chi^{(3)}\) moves by more than one per cent across the band, and the real cubic on a grid with no room for its harmonic.

  • A nonunitary basis, or one acting outside a declared block; and beside a split block, a noise model (its transport through a coupled flow is not measured), a lockstep batch member (it applies neither half of the flow) and a block whose exponent exceeds MAX_GENERAL_FLOW_EXPONENT.

  • Beside a guide whose geometry moves: a snapshot position other than the entrance, a run longer than the declared path, a noise model, a lockstep batch, a fixed schedule that straddles a kink, and a CF4 flow or no flow at all beside the moving maps (a guide whose geometry moves along z).

  • The scalar-route power names, quadrature_error and — beside a split block — zdw: a statistic the basis does not define is refused with its replacement where it has one, rather than redefined.

  • observable_rtol and observable_atol as numbers, which nothing would enforce.

How to read the record. output.metadata.modal is the run’s schema-7 modal identity, and every entry of it is in the key a resume is checked against. basis says which kind of channels the state carries; guide names the provider, the sampling it was solved on and — only where one was declared — the change of basis with its matrix; tables and metric carry the table and metric identities with the measured Gram departures; channels lists the labels row by row; accessors lists the statistics and reconstructions the basis admits, which is where a withheld zdw is visibly absent; and flow appears only where the run carries a coupled flow, with the entries tabulated under reading the record.

The same leaf elsewhere. The coupled flow a split block becomes is the linear.coupled_flow leaf the capillary’s coherent wall also becomes, and the execution is shared: one Schur matrix-function action per stage, with the certificate checked against the measured action at every step. The wall is documented in three parts across two chapters: the coherent wall at constant geometry; a guide that varies along z, where the capillary’s own connection enters as an explicit residual beside frozen sections; and the vector boundary response, which couples the two radial families of one azimuthal order. Each has its gallery example: The coherent wall of a capillary, against the incoherent one, Mode conversion and loss in a taper with a coherent wall and The vector wall: two impedances, two families, one conversion.

What is implemented, and what is not#

Implemented, and executed by every backend — numpy_ref, numba_cpu, and native in its CPU build and its CUDA and HIP device builds — with the one exception of a CF4 section, which runs on numpy_ref alone:

  • Simulation.from_waveguide(..., provider=guide) and prop_waveguide(..., provider=guide), with the launch, the noise draw, the statistics above, in-memory results and an HDF5 file that reads back and resumes (schema 7);

  • the three-component nonlinear source, chunked over nodes with its own memory ledger;

  • the instantaneous cubic in two declared forms, kerr_form="real" (the default, third harmonic included) and "analytic", and the silica tensor Raman model as a region’s declared delayed response;

  • a region’s gas fill under the run’s electronic policy, the dispersive cubic of SEP 6 on every backend;

  • a declared unitary change of basis, with its circular-pair constructor, its dense metric, its relabelled channels, its carried noise draw and its place in the resume key — of an exactly degenerate block, which stays diagonal, and of a split one, which becomes the linear.coupled_flow leaf described above;

  • the connection of a basis that moves along z, measured from a provider with the flux self-terms kept (modal_connection()), and the connection a declared moving change of coordinates costs (basis_connection());

  • a moving solid guide: the provider re-solved along its declared radius path, its sectioned synthesis and projection maps, and the frozen-residual coupled flow that carries its connection (a guide whose geometry moves along z);

  • a graded-index guide as readily as a step-index one: any provider that solves its own E and H, with GradedIndexProvider(core_responses=…) declaring what its core responds with;

  • a launch into a named channel or a declared transverse field, and a batch of launches on one guide.

The route admits one combination and no other: a field grid; an identity metric whose certified defect is inside the route’s budget, or the dense metric of a declared unitary change of basis of one; a constant geometry (one Chebyshev interval of degree zero along z) or a moving solid guide’s sectioned maps beside the frozen-residual flow that carries their connection; a state with n_pol = 1; the constant unit power weight; and each region’s declared responses — the instantaneous cubic in either form, and the delayed silica tensor Raman model beside it — over the reference’s own three components. Everything else is refused by name, at the entry point, again at the translation layer and again in the backend. Every backend admits through one list, the reference’s, run under its own name, and nothing falls back: a leaf without a kernel is refused by its dotted name, and backend="auto", which selects native where it is installed, runs a general guide and a coupled flow there. numba_cpu executes the route through an execution path of its own — its fused pipeline is written for one real transverse matrix in the time domain, and this route’s synthesis is a complex frequency-dependent map of three components — in exactly the combinations numpy_ref admits, and agrees with it to rounding (a few 1e-16 after ten steps); native agrees with numpy_ref to a derived rounding bound. See backends and the native backend.

Some refusals are permanent and not a gap to be filled. peak_intensity, like energy and peak_power, is refused because a general basis does not define it, and solprop never redefines an established name on a basis it does not describe: the replacement is analytic_flux, or reconstructed E and H with an explicit detector definition. The same holds for every scalar-route power name above.

Not implemented, and refused rather than approximated:

  • quadrature_error, refused by name rather than recorded as a nan, because a provider’s node rule carries no embedded coarse rule to difference. Unlike the power names this one is a gap: a second rule, or a tail estimate, would give the alarm something to report;

  • the reference arrays in the file. Output schema 7 records the guide’s identity and not its tens of megabytes of stored E and H, so a reconstruction is made by rebuilding the tables from the guide — tens of milliseconds for the route, about 0.7 s if the guide itself must still be solved — and every accessor checks them against the identity the file carries, path by path, with the guide, which they require;

  • observable_rtol and observable_atol as numbers. None is accepted and means the solver’s own rtol and atol, which on this route is a true statement; a different number is refused by name, because the separate error allocation it asks for — over the table’s frequency interpolation, the transverse rule and the step — is not implemented, and accepting a number nothing enforces would be a silent claim about accuracy;

  • a backward or evanescent declared launch. incident= (above) 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 with_input();

  • a nonorthogonal basis: modal_metric of kind "diagonal", which is a power-orthogonal basis with unequal channel norms and which nothing here builds, and any "dense" metric that is not the unitary change of basis of a certified orthonormal reference described above. A nonunitary transform leaves a skewed or leaky basis whose flow is a matrix exponential and whose noise transport nothing has measured, and it is refused at three layers by name;

  • a guide sampled over a support narrower than the run’s active band. An opt-in fallback — the field zero-filled outside the support, the run warning by name when energy reaches its edge — would suit guides whose every solve is expensive, such as finite-element tables, but it is not built: the linear operator asks every channel for a propagation constant across the whole active band, through the one site every route in the package shares. A guide that does not span the active band is therefore refused, with both bands in the message;

  • a CF4 section on the compiled backends. numpy_ref evaluates a CF4 step’s Gauss-node weights itself, and numba_cpu and native have no path for them.

Two approximations are worth naming because a reader would otherwise take them as exact. Outside the guide’s sampled band the stored reference is an explicit zero rather than an extrapolated profile — which is why the table must span the whole active band, twice the declared one or more, as above. And on a constant guide the node rule is frozen at 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.