solprop architecture#
This is the current architecture of solprop, the reference that changes to the design are checked against. Companion documents: conventions.md (units, signs, shapes), stepper.md (the solver), backends.md (execution), kernel-contract.md (the seam), decisions.md (why) and roadmap.md (what is next). What this page describes in prose, structure.md draws: six figures of the layers, the data flow, the contract, one stage, the backends and the output side.
1. What solprop is#
solprop models ultrafast nonlinear pulse propagation: the unidirectional pulse propagation equation (UPPE) for the full electric field or for a complex envelope, in a single spatial mode, in many modes of a waveguide, and in free space (radially symmetric and full 3-D). It began as a Python rewrite of Luna.jl: it keeps Luna’s scope and its most valuable design choices, and is designed around the following (the user-facing differences are listed in Luna and solprop):
exponential treatment of the represented diagonal linear flow, with adaptive estimates for the integrated source (stepper.md);
absorbing boundaries defined as rates, with explicit and split-step treatments;
a specified one-photon input-noise model normalised for dispersive media;
field and envelope propagation with explicit supported combinations and limitations;
polarisation as an ordinary array axis, at no cost when unused;
a hot loop designed for threads and GPUs from the start, behind one seam;
lean memory: bounded buffers, node chunking, no materialised stages;
an in-process batch mode for many small or several large problems;
a clean, fast-loading Python package that plays well with NumPy, SciPy, matplotlib and h5py.
Priorities, in order: accuracy and reliability; then as much CPU performance as can be
had; then GPU performance (ADR-020). Double precision by default: realistic modelling always
carries noise, so the stepper’s job is efficiency at sufficient accuracy, not extended
precision. Single precision is an explicit per-run choice, precision="single", under
SEP 33’s contract (ADR-054): the state, stages, transforms and responses in binary32,
the controller, host scalars and propagator exponents in binary64, every narrowed table
with its own power-of-two exponent and the state at an exact scale. numpy_ref’s
single-precision path is its oracle; numba_cpu and native compute in binary64 only.
2. The model#
In the frequency domain the propagation equation for every mode m (and polarisation
component p) is
∂z Ẽ_m(ω, z) = D_m(ω, z) Ẽ_m(ω, z) + N_m[Ẽ](ω, z)
D_m(ω, z) = −i(β_m(ω, z) − β₀(z) − β₁(z)(ω − ω₀)) − α_m(ω, z)/2 − α_abs(ω)/2is the linear operator: dispersion and loss of the mode in a co-moving frame, plus the spectral absorbing collar as a rate. It is diagonal in ω, which is the one property the whole solver design rests on:D(ω, z₁)andD(ω, z₂)commute, soexp(∫_{z₁}^{z₂} D dz')is the linear flow, exactly. Which frame terms apply depends on the grid: an envelope grid removesβ₀unless a third-harmonic term is present; a field grid keeps it, because a constant phase on the half-spectrum of a real field rotates its carrier-envelope phase, and measures theβ₁term from zero frequency (docs/guide/numerics/linear-operator.md, “Which terms the frame removes”).N_mis the nonlinear term: the projection onto modemof the nonlinear polarisation evaluated in the time domain at points of the fibre cross-section (Kerr, Raman, plasma, and later χ⁽²⁾ and spectral-domain responses such as two-photon absorption), multiplied by the UPPE prefactor−iω/(2ε₀c Re n_eff(ω))(field) or its envelope equivalent, with the temporal absorbing collar as a rate. Only the real part of the index enters the prefactor: the loss belongs toD, and carrying it here would give the nonlinear term a spurious gain component.
Two grids: a field grid (real E(t), half-spectrum state, r2c/c2r transforms, oversampled
fine grid for the cubic response) and an envelope grid (complex A(t) about ω₀,
full-spectrum state, c2c transforms, optional oversampling for third-harmonic terms). Both are
first class, with parity tests for shared physics on narrowband pulses. Supported
combinations are explicit: plasma supports scalar and Cartesian vector fields on
both grids, using a separately derived cycle-averaged envelope model. Explicitly
declared tensor Raman supports both grids on NumPy, Numba CPU and native;
unlabelled custom kernels and the default silica model remain scalar-only.
Envelope plasma with THG is refused. The native CPU/CUDA/HIP paths
have their own qualification. The running guide
and backends.md record the qualified scope.
Optional atomic response (SEP 12).
A scalar helium TDSEGasResponse owns
the gas nonlinearity. Its immutable specification becomes a TDSEDescriptor;
the driver lazily owns native plans and stable radial chunks and passes a narrow
host binding explicitly to NumPy. The optical march uses Adams. Atomic device
execution is independent of the optical backend. Numba and the native backend refuse this
response by name. The kernel schema includes separate polarisation and current
factors: current enters with -1/(2 epsilon_0 c Re(n_eff)), without division by
frequency. The provider’s raw discrete shadows are subtracted before density
and calibration. Native retention subtracts only the first-order current and
applies its explicitly declared positive scale to the whole nonlinear current;
its polarisation contribution is zero. Its source is always gated and edge-free
(SEP 26): the backend multiplies the already subtracted remainder by its gate,
grids.t_window_fine or the atomic window’s, before source projection/FFT, and
subtracts the gate’s edge term F[g' S]/(i omega) on the active bins. The raw provider result, atomic
drive and separate in-stage field absorber are unchanged. This is a recorded
finite-window approximation, not a model of absorption, walk-off or phase mismatch.
Cubic-replacement policies also subtract the third-order current and supply their
separately specified polarisation; their sources are never gated.
The TDSE binding uses the shared Adams hooks and the step and plane policies of the
optical march. Every descriptor has one layout, solprop.tdse-descriptor.v5, which binds
the closure, model, window and grid collar geometry into its identity. The saved output
records the absolute launch, the TDSE identity, the outcome and any terminal checkpoint
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). The
current versions are kernel schema 36 and saved-output schema 25
(kernel contract).
haCC helium (SEP 20). The same route carries a second atomic model, the
two-electron haCC atom (TDSEGasResponse.model="hacc" with a HaccBasis), at a
fixed current scale of one. Its descriptor may carry an atomic window
whose gate replaces the grid collar as the source gate; the binding slices and
gates the drive to the window and pads the currents back to the full lattice, so
the seam’s arrays keep their shapes. The weak-field consistency check lives in the
driver layer (solprop.driver.tdse_consistency), runs before the first propagation
call on the run’s open binding, and is cached on disk; its policy never enters an
identity. An optional raw response archive (output/tdse_archive.py) records every provider evaluation in a separate append-only HDF5 file: the
driver opens its session before any atomic work, the binding writes each
evaluation’s input before its first provider call and each raw result before
validation, and the reference backend reports each Adams trial’s outcome to the
binding from its step record. It changes no numerical step and adds no call; the
optical file records the archive’s run UUID and relative path.
Plan lifetime, allocation admission and attempted-call budgets belong to the driver/binding; modal synthesis, P/J projection and spectral assembly belong to the reference backend. See the TDSE manual.
Geometry: mode-averaged propagation is the single-node case of multimode propagation (the effective-area normalisation replaces the quadrature); the GNLSE is the mode-averaged envelope equation with a dispersion-specified mode (β from Taylor coefficients or a tabulated β(ω), γ or n₂ + A_eff, Raman fraction), so it is an ordinary problem rather than a hack. Free-space propagation (radial and 3-D) uses the same equation with a transverse spectral axis in place of the mode index; nothing in the layering below prevents it, and the kernel layout carries the transverse columns in its mode axis (see §12).
3. Layers and their rules#
layer |
contents |
may import |
performance rules |
|---|---|---|---|
P physics / convenience |
grids, materials, modes, profiles, pulses, noise, linear-operator assembly, response definitions, ionisation and Raman models, boundaries, normalisation, tools |
numpy, scipy, standard library, other P modules |
none: clarity first; never in a hot loop |
T translation |
turns P objects into validated kernel-ready arrays and the |
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 |
all hot-path rules of backends.md; in-place and |
D driver |
|
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 |
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
SEP 19 by the Lanczos
process and the Golub–Welsch eigenvalue method, certified on construction – once per
problem, and caches them. It is the one numerical construction kept in P: it yields
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 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 –
the table identity, the guide identity – and not the reference’s tens of megabytes,
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. 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
handed at construction, the constants, and the Raman drive form;
X never imports B directly. Every public module declares __all__. No utils modules:
organise by domain concept. Hand-rolled numerics live in T (translate.chebyshev) and B
only; everything else uses NumPy/SciPy/h5py/matplotlib. A value type in P may carry what T
designed and apply it with NumPy alone: the plasma column’s
nonlinear.ionisation.RunningIntegral holds taps that translate.plasma_integration
fits and certifies, and filters and sums with them
(SEP 18).
4. Package layout#
The public package is organised by domain.
structure.md figure 1 lists the modules individually and is checked
against src/solprop by tests/test_structure_figures.py.
Location under |
Responsibility |
Layer |
|---|---|---|
|
Lazy public imports, package logging, exception hierarchy and SI constants |
package / P |
|
Temporal and spatial grids (radial, the Cartesian plane, and |
P |
|
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 simulations through a coherent field at a reference plane (SEP 31): the field plane with its reference, geometry, metric, basis identity and provenance ( |
P |
|
Species, mixtures, equations of state, transport properties (viscosity, sound speed, mean free path), optical material data, metals, nonlinear and Raman data |
P |
|
Mode protocols and derived quantities; capillary, antiresonant, tabulated, step-index, rectangular and planar (slab, uniform in y, per unit width) modes, with |
P |
|
Linear assembly, absorbing rates, response definitions, ionisation, Raman, normalisation and the GNLSE mapping |
P |
|
Validated |
T |
|
Registry and protocols, shared step and batch drivers, scalar bodies, spatial and population statistics |
B |
|
Reference and compiled CPU execution |
B |
|
Tableaux, controllers, schedules, progress reporters and statistics specifications |
D |
|
Simulation and convenience interfaces, scan/batch orchestration, Local/Pool/Queue/Slurm execution and scan CLI |
D |
|
Memory and HDF5 outputs, provenance/resume, scan storage and artifacts |
D |
|
Field, spectrum, time, coherence, compression, mixture and scan analysis; a saved plane read back as a field plane ( |
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
on the versioned seam; solprop does not depend on its native build.
driver/scaffold.py is an independent fixed-step IP-RK4 reference used by tests and
its tutorial; it is not the production stepper. It propagates a GnlseProblem
directly, without a contract, a backend or an output handler, which is what makes it a
cross-check of the reference backend. processing/windows.py is a candidate
module, drawn dashed in figure 1, to be written only for an agreed use case.
_display.py is private: the formatter behind every class’s printed block, imported by
any layer and importing none. _numba_cache.py is private in the same way: numba’s
njit with a disk cache that concurrent processes cannot cross-wire, through which
every kernel in T, D and B compiles (backends.md §3.2).
The layout does not mirror Luna’s module tree: what NonlinearRHS.jl does spans
response normalisation, translation and backend passes; Maths.jl mostly becomes SciPy
calls; the cumulative-operator stepper takes the place of RK45.jl; scans use the
driver and executor layers above.
5. Data flow of one run#
user script / prop_capillary(...) (D)
│ builds physics objects: Grid, Modes, ZProfiles, Responses, Pulses, Noise,
│ Boundaries, StepperSettings, Output
▼
Simulation(...) (D)
│ .translate() → KernelProblem (validated arrays, schema_version) (T)
│ .backend = backends.select(name, problem) (B)
▼
run loop over save planes and stats periods (D)
│ backend.set_initial(field) host → device once
│ for z_save in planes: backend.propagate_to(z_save) shared step loop
│ obs = backend.observe() small host arrays
│ stats = backend.stats(spec) one fused pass
│ output.save(z_save, obs, stats)
▼
MemoryOutput / HDF5Output ──► processing / plotting (X)
The inner loop (many adaptive or scheduled steps between two save planes) is the
shared Python StepDriver. NumPy evaluates reference passes, Numba launches compiled
kernels and library transforms, and the native backend executes each trial through one native
call. The outer driver visits save planes. Device arrays never cross the seam;
observe() returns host arrays of the field at the plane
and the step history; stats() returns a small dictionary of host scalars/vectors.
The outer loop is solprop.driver.run.run_problem, which takes a translated problem, a
backend that already holds its initial state, and an output handler. Nothing in it is
specific to a Simulation; it is the piece a batch runner or a scan calls. What the
driver does not do is as much a part of the design as what it does:
It never conjugates a spectrum. The backend works in the FFT convention, the output layer converts on the way to disk, and those are the only two places a sign changes.
It never touches the state between planes. Everything between two save planes happens inside the backend’s march.
It never computes physics. The mapping from
gammato a susceptibility issolprop.nonlinear.gnlse, the statistics aresolprop.stats, the storage issolprop.output; the driver is the wiring.
A general modal guide adds a chain of host-side records in front of the
KernelProblem (vector fields and projection):
ModalProvider -> DenseModalTable -> ModalTransformTables -> KernelProblem
E, H, beta at tracked knots in S and Q on the run's the three
nodes, one frequency, gated coarse grid, the dense general-modal
(omega, z) on its sampled E/H and the node rule leaves and the
Gram diagonal operator
6. The physics layer#
6.1 Grids (grid.py, fourier.py)#
FieldGrid and EnvelopeGrid implement one protocol: time axis t (n_t points, spacing
δt), coarse frequency axis omega (n_omega: n_t/2+1 for the field grid, n_t for the
envelope grid in FFT order), the oversampled fine axes t_fine/omega_fine (factor ffac:
6 for a cubic field response with third-harmonic content, 4 for |E|²E, 1 or 2 for
envelopes), the in-band mask (physical frequencies inside the wavelength limits), the
spectral and temporal collar profiles, and the reference frequency ω₀. Sizes are powers of
two or 2-3-5-smooth on request. The invariants between coarse and fine grids (Luna
Grid.jl:107–108, 244–250) are asserted in the constructors and tested. fourier.py owns the
sign convention (conventions.md), the r2c/c2c helpers, the analytic-signal
and oversampling operations used outside the hot loop, and the crop/pad scaling factors that
the translation layer hands to kernels.
6.2 Modes (modes/) and profiles (profiles.py)#
The Mode protocol: neff(omega, z) (complex), dneff_domega(omega, z) (analytic where the
model allows; a validated finite-difference fallback otherwise, ADR-006), field(points, z)
(transverse field components at points), dimlimits(z), aeff(z), and the traits
z_constant, scale_invariant, azimuthal_order. Derived quantities (β, α, β₁, β₂, ZDW,
loss length) are protocol-level functions. MarcatiliMode (hollow capillary, full and reduced
models, loss as a Boolean, scale factor or callable of ω) shares gas-fill machinery
with the tabulated and rectangular hollow modes; the core index is a
ZProfile-aware object so gradients and tapers are ordinary cases. n_eff − 1 is computed
directly, never as (1 + x) − 1.
ZProfile (profiles.py) represents a scalar function of z — gas density, core radius,
pre-ionisation fraction — with its breakpoints (stage boundaries are hard kinks), analytic
derivative where available, and evaluation on arrays. It also declares its jumps
(a subset of the breakpoints) and encloses its variation over any interval as a
PathVariation: pieces with one-sided limits, value bounds and a variation bound,
exact on pieces certified monotone. An arbitrary callable must declare its turning
points to have one; sampling is not an enclosure. The adiabatic certificate
(adiabatic.py, SEP 34 §4.5) builds on it. Pressure gradients use the steady-flow
profile p(z) = sqrt(p₀² + (z/L)(p₁² − p₀²)); fills must not start or end at exactly zero
pressure (a vacuum-terminated fill caps every scheme at order ~3, stepper.md
§10; the interface substitutes a small floor and logs it). A steady-flow fill of a
hollow fibre (gasflow.py) follows the bore and chokes at its vacuum end (ADR-032).
modes/quadrature.py builds the fixed transverse rules (Gauss–Legendre radial with Kronrod
extension for the embedded estimate; periodic trapezoid in θ, exact for trigonometric
polynomials of degree ≤ n_θ − 1, with the 4·h_max + 1 warning; Cartesian Gauss for
rectangular modes; a one-dimensional Gauss–Kronrod rule in x with unit y weight for
slab modes; for one Marcatili HE₁₁ mode, Gauss and Gauss–Radau rules in the field scale
\(\eta = J_0(u_{01}r/a)\) that carry radial area weights) and the
synthesis/projection matrices with weights folded in (Luna TransModalFixed §3.3–3.4;
ADR-010). radial_rule selects the family; a one-node rule is an ordinary modal
transform, and only mode_averaged_transform makes Transform.kind == "mode_averaged".
6.3 Materials (physdata/)#
solprop keeps no material database of its own (SEP 3).
Every built-in material is optmat’s, adapted at the boundary: a gas’s polarizability
(OptmatSusceptibility), equation of state and transport (OptmatGas) and electronic
Kerr model at the carrier (OptmatKerr), its first ionisation energy, and the solids’
complex indices (OptmatGlass) and fused silica’s nonlinear bundle (SilicaBundle),
each with its exact derivative, its qualified intervals, its status and an identity that
the run’s material manifest records. A MaterialSelection names optmat’s models, and
the solid factories’ model= names a solid’s. What physdata/ keeps is solprop’s physics around the data
— linear density scaling, the mixture rule (mixtures.py), the χ⁽³⁾/n₂ conversions — and
the types a caller’s own data take: the gas formula types (sellmeier.py), a custom
Species with TabulatedSusceptibility or IdealGas, SellmeierGlass,
TabulatedGlass and CompositeGlass, and the molecular Raman constants of
raman_types.py. Air’s molar mass is read from CoolProp through pyfluids, the one
direct use left (see dependencies.md).
6.4 Pulses, noise, optics#
pulses.py builds initial fields on either grid (Gaussian, sech, custom shape, measured
data, CW), scaled to energy or peak power, with Taylor spectral phase; noise.py draws
shot-noise realisations for the input model and the modified model (one photon per mode per
frequency bin, amplitudes per conventions.md, masked to the in-band
region, seeded with np.random.default_rng); optics.py applies linear optical
elements (Taylor phase, bulk material, chirped mirrors, grating and prism pairs, and
chains of them) as spectral transfer functions to a field outside the propagation.
6.5 Linear operator assembly (linear.py) and boundaries (boundaries.py)#
linear.py evaluates D_m(ω, z) from the modes and the frame (β₀(z), β₁(z) from analytic
derivatives at ω₀) and folds in the spectral absorber rate; it knows nothing about Chebyshev
tables. For Marcatili modes with ZProfile core indices it exposes the three-scalar structure
(a(z), ρ(z), β₁(z)) that lets the translation layer sample D cheaply at many z (the idea behind
Luna’s MarcatiliLinop). boundaries.py defines the collar geometry (spectral edges beyond
the wavelength limits; temporal collar as a fraction of the window, guaranteed non-degenerate)
and converts profiles to rates with a reference length ℓ: α = −2 log(W)/ℓ for the
spectral collar and the bounded 12.6·(1 − W)/ℓ for the temporal one (ADR-007).
6.6 Responses (nonlinear/)#
There is one definition per response, not one per layout or per device: a frozen
dataclass that carries its coefficients, declares four traits (ResponseTraits,
ADR-011) and provides a NumPy evaluate that is the reference implementation — the
oracle every backend kernel is tested against, written for clarity rather than speed.
trait |
values |
Kerr |
dispersive Kerr |
plasma |
envelope plasma |
Raman |
silica tensor Raman |
|---|---|---|---|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
both |
both |
|
|
both |
|
|
|
|
|
|
|
|
|
|
names of the scalar paths it consumes |
|
|
|
|
|
none |
A response bound to a mixture component names its paths with the label, density:N2
(labelled mixture responses).
The translation layer reads the traits and compiles the declared set into one fused
time-domain traversal per column. Batched variants do not exist as types: batching is the
kernel layout. Adding a response means adding a definition, a scalar body and a test.
Envelope plasma uses the same traits but exposes sources rather than one evaluate
polarisation: its temporal population scan produces a Drude drive and a directional loss
current, which keep their own frequency factors through projection and the temporal
transforms (envelope plasma sources).
ionisation.py holds the rate protocol (IonisationRate, EvaluableRate), its
optical-cycle conventions, the energy binding of the plasma loss, the rate ceiling
MAX_RATE (clipping the rate so a diverged step gives no NaN) and the Keldysh and
barrier-suppression diagnostics, and no rate formula; optmat_rates.py adapts optmat’s
rate models to the protocol (OptmatRate); raman.py the response models
(damped oscillator, rotational/vibrational models of a supplied species, silica’s
sampled bundle shape and tensor response); normalisation.py the UPPE and
envelope prefactors and the effective-area normalisation for mode-averaged runs; gnlse.py
the mapping from (γ or n₂/A_eff, β coefficients, f_R, shock) to a mode-averaged envelope
problem. optmat_raman.py adapts optmat’s gas Raman responses onto the same terms:
one isotropic and one rank-two row per damping group, its decay law optmat’s own, and a
mixture’s one response driven by the summed density of its labels.
nonlinear/envelope_plasma.py defines an independent Cartesian-cycle oracle for
envelope plasma. Translation compiles instantaneous-rate tables, validated angular
rules and separate Drude/loss frequency factors. Backends recompute each channel’s
cycle moments and temporal population for every RHS input, then project and
transform both current streams before adding their equivalent polarisations to
the ordinary source. The envelope plasma manual
derives the directional loss current, source signs and averaging limits; the
kernel contract
defines the numeric data crossing into a backend.
RamanResponse stores immutable RamanTensorTerm declarations. Translation
preserves each term’s density and decay law, then supplies numeric trace and
traceless weights. The local Cartesian response retains delayed off-diagonal
coherence. Scalar and isotropic-only vector responses use one real history;
rank-two vector responses use three histories and two effective kernel spectra.
The Raman manual derives the contraction and its scalar and
circular limits. Circular launch still uses the existing real modal basis.
7. The translation layer (translate/)#
The translation layer produces the KernelProblem (kernel-contract.md):
plain arrays with fixed dtypes, shapes and axis names, validated (finite, contiguous, sizes
consistent), immutable, versioned. What it computes, once per problem:
product |
from |
representation |
|---|---|---|
Φ table |
|
piecewise Chebyshev in z, coefficients |
unitary phase for noise |
Im Φ |
same table, imaginary part |
quadrature |
|
nodes, weights, coarse weights, synthesis and projection matrices; taper scaling rule |
response spec |
|
compiled ordering of the fused pass, coefficients, ionisation-rate spline tables (log-spaced, converged, clip limits), Raman kernels on the doubled grid in the frequency domain, normalisation prefactors |
masks and rates |
|
in-band mask, spectral rate (already in D), temporal rate, hard band mask, crop/pad scaling; analytic fine-grid collar derivative and steady-current certificate for field plasma |
initial state and noise realisation |
|
canonical layout |
stepper settings |
|
tableau, tolerances, controller parameters, schedule, save planes, caps |
statistics requests |
|
which statistics, which windows |
spatial plane measure |
|
the |
batch parameters |
|
per-member scalars or per-member table indices |
Validation is not optional: a backend calls problem.validate() and refuses anything
inconsistent. Tables are cached on the Simulation and rebuilt only when a physics object
changes.
8. The solver, in one page#
Details and evidence are in stepper.md. The anatomy of one stage i of the
interaction-picture DOPRI5 step from anchor z_n, for a multimode field-grid problem, as the
backend executes it (each numbered line is one pass over its data; the FFT counts are the
cost metric):
stage combine + to lab frame + noise + pad:
u = exp(ΔΦ_i)·(y + h Σ_j a_ij k_j) + N(z_n + c_i h), written zero-padded into the fine-grid spectral buffer — one fused pass over(n_modes, n_pol, n_omega).batched inverse FFT (fine grid),
n_modes·n_polcolumns.synthesis GEMM onto a chunk of quadrature nodes:
(nodes·n_pol) × n_modesbyn_modes × n_to.fused response pass per column:
|E|²→ ionisation rate (table) → two plasma running integrals (exposure and free current) → physical currentJ. Kerr and Raman accumulate separate non-plasma polarisation. Raman adds one or three real histories per physical node, transformed in a doubled-grid batch. The column writes three complete streams: collared non-plasma polarisationgP,gJandg′J.projection GEMM for each stream back onto modes, accumulated over chunks.
batched forward FFT (fine grid) for each stream, followed by
DFT[gP] + DFT[gJ]/(iω) − DFT[g′J]/(iω)²on active bins. Inactive bins are exactly zero and no division is evaluated there.crop + prefactor + spectral window + band mask + back to the interaction frame + accumulate:
k_i = exp(−ΔΦ_i)·p(ω)·W(ω)·crop(source),acc_sol += b_i k_i,acc_err += e_i k_i— one fused pass; on the last stage the weak error norm is reduced in the same pass (a two-stage reduction, its order free within its rounding bound).
Without field plasma there is one projected source stream. The held-current source adds two temporal transforms to that modal route. Radial propagation instead transforms and combines the three streams per node, crops, then projects once in frequency. Cartesian and line propagation combine before the transverse FFT; planar propagation projects in x before the temporal transforms and combines before the y transform. These orders and their buffer budgets are part of SEP 27.
These are passes over data, not launches.
Per step: six new nonlinear evaluations with FSAL (seven without it), with the modal
batched transforms above plus the doubled-grid pairs for Raman, 6 exp(ΔΦ_i)
evaluations (or 5 with FSAL and memoisation), one controller decision (lagged, with rejection allowed by default;
reject_free is explicit), one accept pass (y ← exp(ΔΦ)·(y + h·acc_sol) — the stages live in the interaction variable anchored at the step start, so the whole sum is carried by one exponential, and the embedded difference takes the same factor — rebase the FSAL stage, apply
the temporal absorber per ADR-025 when active), one hard-mask multiply. Steps land on save
planes and hard kinks (ADR-009). Where the linear flow couples channels (§16), each
exp(±ΔΦ_i) multiply becomes the action of a small per-bin matrix on the mode axis, and a
frozen-residual section adds its explicit residual to the stage source
(stepper.md §2).
Memory budget (field-sized unit F = n_batch·n_modes·n_pol·n_omega·16 B): state y,
stages k₁…k₆, two accumulators, up to five propagator factors (tabulated) or none
(recomputed) — 9–14 F by design, more when both propagator signs are tabulated
(recompute halves that share); fine-grid spectral
and modal time-domain buffers — 2–3 F·ffac;
node-chunk buffers (chunk, n_pol, n_to) for E and P, one or three doubled
real Raman histories per node and their spectra/convolved values — bounded by
the chunk size, not by the node count. Field plasma also charges its current and
product streams, projected streams, transforms and provider plans. Planar node
admission reserves at least one complete x row. Effective kernels and provider workspace
are counted separately in the backend ledger. Examples:
case |
F |
solver registers |
node buffers (chunk 16) |
Luna’s node buffers (all nodes) |
|---|---|---|---|---|
RDW VUV: n_t 4096, 4 modes |
0.13 MB |
~2 MB |
~3 MB |
~8 MB |
CtC: n_t 32768, 6 modes, Raman |
1.6 MB |
~20 MB |
~45 MB |
~210 MB |
The low-storage march (SEP 24,
ADR-050) is RK4 in the midpoint interaction picture, replay-only and accepted in place:
each slope is folded into a running sum as it is produced, so a step holds the state, an
accumulator and a stage register beside the evaluation’s scratch, plus the half-step flow
exp(hD/2) when tabulated — four or five F where the Runge–Kutta march’s ledgers hold
13 to 16. It makes four source evaluations a step and keeps no estimate.
A backend exposes a memory_ledger() naming every buffer and its size; the ledger is tested.
The table shows the design’s scale, not a per-configuration allocation promise: read the
constructed backend’s ledger for its actual buffers.
9. Backends, in one page#
Details in backends.md. The seam is at the level of operations on the
propagated state, never at the array-namespace level
(solprop.backends.protocol.Backend; signatures abbreviated):
class Backend(Protocol):
name: str
max_batch: int
def set_initial(self, field, noise, *, z: float = 0.0) -> None: ...
def set_progress(self, reporter) -> None: ... # an observer, never an input
def propagate_to(self, z: float) -> StepSummary: ... # shared step loop
def observe(self, *, field: bool = True) -> Observation: ... # host arrays only
def stats(self, spec) -> dict[str, np.ndarray]: ... # one fused pass
def metadata(self) -> dict[str, object]: ... # what the execution was
def restore_call_ledgers(self, logical, atomic=None) -> None: ... # after a resume
def available_device_memory(self) -> int | None: ...
def memory_ledger(self) -> dict[str, int]: ...
def close(self) -> None: ...
numpy_ref: the readable oracle. Plain NumPy +scipy.fft, no fusion, no in-place tricks, one function per pass. Every other backend is validated against it.numba_cpu: fused stage kernels (@njit(cache=True, nogil=True)throughsolprop._numba_cache.njit), pyfftw plans executed in place on preallocated aligned buffers (FFTW_MEASURE+ wisdom on a quiet machine), node-parallel kernels and controlled FFTW/BLAS threads, with two-stage reductions and the shared Python step loop. Its floating-point licence is SEP 25’s: contraction by default, strict opt-outs for the protected helpers (stable increments, small-argument functions, defects, compensated sums), reassociation only on the loops of a kernel that asks for it, and no other fast-math flag (backends.md §3.2), applied by thenjitwrapper. Fixed fibre batches have a compiled accept update over members; other numerical work remains member-owned. An all-compiled march is a deferred experiment, subject to a measured dispatch-cost case.native(thesolprop-nativedistribution,native/): C++20/Kokkos kernels and persistent Kokkos-FFT plans behind nanobind. It runs mode-averaged and multimode scalar/two-component problems, Kerr, plasma and declared tensor Raman, weighted control, embedded quadrature and backend-owned statistics with one native call per trial, and on its CPU build also the general modal route and the coupled flow. Raman uses doubled-grid plans, actual-width tails and memoisation by decay rates and mixture amplitude densities. KokkosKernels supplies the portable GEMM seam; measured local builds may dispatch eligible whole transforms to Accelerate, OpenBLAS or cuBLAS. The CUDA build uses time-parallel response teams, deterministic plasma scans and device reductions and is qualified on data-centre NVIDIA GPUs; the HIP build is qualified on AMD Instinct GPUs, with the HIP sanitizer run still outstanding. Fixed-schedule fibre batching is implemented throughselect_batch. The private state is exposed to the shared driver only through its protected state type and the_observed_fieldhost-copy hook.JAX: evaluated in a measured comparison (ADR-002); not a planned backend unless the data reverse the decision.
backends.md §9 records which route each backend admits and where each is qualified, and §11 its current limits.
10. Output and statistics#
MemoryOutput (everything in RAM: fast, small problems, batch scans) and HDF5Output
(streaming to disk, chunked per saved plane, optional compression, cache/resume) implement one
save protocol and one metadata schema (output/schema.py): grid, modes, simulation type,
the full configuration that built the Simulation (serialised physics objects), package and
dependency versions, git provenance of the running code when available, the noise seed and
realisation, the stepper settings and the step schedule actually taken (z, h, error norm,
rejections, wall time), the statistics, and the rows a periodic subset of them recorded
between planes. Fields are stored in the canonical layout
(n_z, n_modes, n_pol, n_omega) (batch: leading n_batch), in the optics sign convention
with the attribute spectral_convention = "optics" (ADR-017: the output layer conjugates on
save and on resume); processing accessors squeeze trivial axes and accept convention="fft".
A TDSE run may also write its raw provider evaluations to a separate archive
(output/tdse_archive.py, schema solprop.tdse-archive.v1), which the optical record
references and never needs (the archive page).
output/snapshots.py coordinates optional immutable disk snapshots at the existing
accepted-step commit boundary. It persists retained march state and execution facts,
suspends the optical and optional archive handles while their advisory writer locks
remain held, copies and verifies the pair, publishes a manifest last, then reopens
the same writers. Backend and atomic resources are untouched; no additional observe,
landing plane or response evaluation occurs. The store bounds completed and incomplete
generations and leaves transport/retirement to an external controller. The manifest
has its own version, outside the scientific configuration and kernel schema. See the
manual for admission,
restoration, ownership and interruption rules.
Statistics (energy per mode and window, peak power and intensity, FWHM, electron density and
peak ionisation rate, density and pressure, core radius, ZDW, the quadrature error estimate,
ω₀) belong to saved planes (ADR-012). They are computed by the backend in one fused pass with
two-stage reductions and returned as small host arrays; an optional periodic mode
computes a subset every N accepted steps for step-level diagnostics.
The periodic mode is the shared step loop’s, not a backend’s: after every N-th accepted
step the march calls the backend’s own stats with the subset and appends one row, which
observe drains into Observation.step_stats at each plane, so there is one
implementation of every statistic (backends.md §1, “The
statistics channel between planes”). The catalogue’s between_planes trait says which
statistics may be in a subset, and StatsSpec refuses the rest by name — a root search or
a mode-profile rebuild per accepted step is what cost Luna a fifth of its step. The native backend
reduces and packs the statistics without observing the complete field. The rows are stored
beside the step history and in the same ragged shape.
11. Scans and batch mode#
Scans run above the backend seam. Scan gives each point a stable global
index, parameters and seed. run_scan selects an executor; Local is the API default,
Pool uses spawned processes for independent CPU runs, and Queue/Slurm provide
durable distributed execution. A controller owns the collected HDF5 scan file.
Fixed-schedule fibre batches use BatchBackend and select_batch.
A batch adopts independently translated and loaded member backends, each with its
own tables, state and noise; it advances them under one compatible explicit schedule.
The accepted update is fused on compiled backends, while response and transform work
remains member-owned. observe_members preserves each member’s history and statistics.
Admission uses memory ledgers and a free-device-memory query with headroom.
Every backend has exact member-versus-singleton identity checks under the same schedule. That does not imply a throughput improvement on every workload: whether a batch beats independent runs depends on the problem size and the device. Adaptive lockstep is not implemented. A group-level failure fails every member of that group; recovering the surviving members is an open question (roadmap).
Independent simulations use spawned processes, normally one scientific thread per worker, rather than threads, because thread pools, FFTW plans and the OpenMP runtime are process-global (ADR-013). Each member may use its own adaptive schedule outside a fixed batch.
12. Parallelism and the GPU model#
Where parallelism lives at each scale. Construction-time pool choices use measured size rules with explicit overrides (backends.md §3.4); independent process counts belong to the scan executor:
problem |
inside one step |
across steps |
across problems |
|---|---|---|---|
small single |
serial kernels and small transform pools by measured size rules |
sequential |
spawned processes |
large single |
|
sequential |
processes |
compatible fibre problems on GPU |
member-owned stage work; fused accepted updates |
fixed lockstep |
backend batch |
large on GPU |
one thread per element |
sequential |
one problem per device |
The plasma cumulative integral is sequential in value but not necessarily in execution. On the CPU it is the inner loop of the fused pass. The CUDA path uses one team per node: rate evaluation is time-parallel and a fixed deterministic scan tree forms exposure, fraction and currents before the response finish. Response teams are 256 below 8,192 time samples and 512 at and above it; deterministic reduction blocks are 512. Fixed batches are implemented; their measured crossover depends on workload (backends.md). Public selection prefers the installed native backend, otherwise Numba CPU (ADR-029); the installed native build determines its execution space.
13. Memory discipline#
Every buffer is named, preallocated at backend construction and listed in
memory_ledger().The Runge–Kutta march holds every stage row its tableau and dense output read, and running accumulators for the solution and the error estimate; the low-storage march folds each slope into its registers as it is produced (ADR-009 as amended by ADR-050).
Space-domain work runs over node chunks; the chunk size is chosen from a memory budget (default: the whole node set when it fits a per-problem budget, else the largest chunk that does).
HDF5Outputnever holds more than the current plane, in datasets preallocated for the planned planes with ann_savedcount;MemoryOutputis allocated for the planned planes, grows by doubling past them, and is charged by the memory admission.A run keeps its fields on every plane, the named planes or none (
FieldRetention, SEP 22): a plane without its field is not copied, converted or stored, the stores allocate field storage for the kept planes only, and the admission charges those alone (itsoutputgroup). The backend is asked for the field only on the kept planes:StepDriver.observe(field=False)transfers nothing from a device, scans nothing for finiteness, skips the collar diagnostic (counted in the execution record) and forms no noise-phase volume, so a field-free plane costs its statistics alone. Under the low-storage march a field-free plane holds one state volume so that a run stopped in flight can resume (held_plane, in the ledger and as its own admission group; on the native backend in the memory space).The Cartesian and planar collar diagnostic reconstructs the physical field by block of frequency, at most a node chunk’s worth of samples at a time, never the whole volume.
A
SimulationRunnerkeeps one backend’s buffers and plans across many inputs, and is admitted once;StepDriver.set_initialrestores everything a march reads, so a reused backend repeats a fresh one’s run. An input array of more than 2²⁰ elements is recorded by its SHA-256 digest, not inline, so the run record stays small.Φ tables are shared where possible; the constant-D fast path stores one array.
Fine-grid buffers use the smallest
ffacthe declared responses need.
14. Logging and progress#
logging.getLogger(__name__) per module; the package installs a NullHandler;
solprop.configure_logging(level, file) for scripts. Debug logs are plentiful in P, T and D
(translation summaries, table sizes, schedule statistics, memory ledger) and guarded with
logger.isEnabledFor(logging.DEBUG) when formatting costs anything. Nothing logs inside
compiled kernels or per-stage code. Progress is reported by the driver through one
ProgressReporter protocol (solprop/log.py): started once, step from inside the step
loop on every attempted step, plane per save plane and finished at the end. The default
LogReporter turns those into three kinds of INFO line – a periodic one on a wall-clock
period (progress_period, 10 s, 0 to disable) carrying position, counts, current step,
elapsed and an ETA; one per plane with the step count and mean h; and a closing one with the
achieved-against-requested tolerance ratio and how far off the run’s own ETAs were. The
period is wall time, not a step count, so the log volume of a run does not depend on how fast
its steps are. Where a person is watching – standard error a terminal, or a Jupyter
kernel – the default is instead a BarReporter: a LogReporter whose
periodic event redraws a tqdm bar carrying the same content and the same measured ETA,
with the plane line demoted to DEBUG; redirected or captured output always gets the
lines. A reporter that implements interrupted (InterruptibleReporter) is told when
the loop raises, before the error propagates, so a bar can give the terminal back.
15. Extension points#
Modes: implement the
Modeprotocol and declare the profile geometry, frequency support, derivatives and z dependence. Capillary, antiresonant, tabulated, step-index and rectangular implementations provide current examples. A full-vector E/H source implements the separateModalProviderprotocol instead, declaring its material regions, its domains and a serializable identity; the bound step-index and radial graded-index providers are the current examples. A cross-section ispolar,cartesianorslab; a new one needs aquadrature_rulebranch,taper_scale’s scaling law andon_axis_rule’s node. A mode uniform in one transverse direction declarestransverse_measure = "per_unit_width", which the run records and processing and plotting follow for units; the kernel never sees it. A grid uniform in y (LineGrid) is the same measure, recorded from the free geometry’s single y row. A spatial kind whose columns are not its nodes ("slab") says so throughSpatialGeometry.n_columns; its transform carries the x half of the map insynthesisandprojectionand the y half as FFT axes.Materials and responses: species and mixtures carry optical and nonlinear data; the translation layer compiles supported Kerr, scalar/tensor Raman and field/envelope plasma terms. A new tensor or spectral response needs a written design with its physical derivation, grid/backend scope and contract changes, and independent tests.
Spatial propagation: radial and Cartesian transforms already share the seam. Additional geometry or varying Cartesian media are separate extensions to qualify against the existing spatial contracts.
Execution: scans separate point identity and output collection from the executor. New execution policies must preserve that identity and be measured on complete jobs.
The roadmap records selected work and optional extensions.
16. Coupled linear flows, and non-goals#
The linear operator need not be diagonal in the modes. Where channels couple
linearly — a capillary’s coherent wall, the taper coupling of a moving multimode guide
(the connection of a basis that moves along z), a declared unitary basis of a split
block on the general modal route — the problem carries the optional
linear.coupled_flow leaf (translate/coupled.py) beside the diagonal Φ table. Its
generator does not commute with itself at different z, so the flow is built per
section of the operator table’s breakpoints, in one of three ways:
"constant_schur": a constant block per section, whose exponential is formed from its Schur decomposition and is exact over any step inside the section;"frozen_residual": the block frozen per section inside the exponential, and the rest of the generator — its z variation and the connection — as an explicit residual that the Runge–Kutta source carries, so the embedded pair sees its error;"cf4": a fourth-order commutator-free exponential of the whole generator at Gauss nodes of each stage interval, whose flow error the embedded pair does not see (stepper.md §2); admitted onnumpy_refonly and never selected by default.
The coupled flow runs on numpy_ref, numba_cpu and the native CPU build, which form
the per-bin flow actions in their own kernels and agree with the reference to a derived
rounding bound; a certificate records each flow’s conditioning. A multimode capillary
taper carries the taper coupling by default; taper_coupling=False is the explicit
opt-out to the adiabatic route
(the taper coupling). The general
modal route and its moving guides are described in
general modal propagation.
Non-goals. Extended precision; adaptive spatial quadrature; Julia interoperability. Queue and Slurm executors are implemented; more general distributed task graphs are outside the scan model.