solprop.boundaries#

Absorbing boundaries as absorption rates per unit distance.

Both axes of the grid are finite and periodic, so energy that reaches the edge of the frequency or the time window does not leave the simulation – it wraps around, and the answer is wrong. The collars of solprop.grid mark where the propagation is absorbed instead of modelled; this module turns them into rates.

Multiplying the solution by the collar profile W once per accepted step would make the total absorption W^n for however many steps the adaptive controller happened to take. The answer would then depend on rtol: refining the tolerance sixteen-fold erodes the spectral wings fifty-fold, and the limit it approaches is not the taper the profile describes but a brick wall at the edge of the flat region. As a rate it is exact instead, because traversing a distance attenuates by a factor that depends only on the distance. The spectral collar becomes

\[\alpha(\omega) = -\frac{2 \log W(\omega)}{\ell},\]

the rate that reproduces one multiplication by the window over the reference length, and the temporal collar becomes the bounded profile

\[\alpha(t) = \frac{A\,(1 - W(t))}{\ell}, \qquad A = 12.6,\]

whose integral over the collar is what the capped logarithm gave and whose largest value is a fifth of the cap (TEMPORAL_ALPHA_LENGTH).

alpha is a power coefficient, as everywhere else in solprop: power falls as exp(-alpha z), the field as exp(-alpha z / 2), and the linear operator carries -alpha/2 (docs/dev/conventions.md section 2). The factor of two in the spectral definition is what makes exp(-alpha ell / 2) = W – one reference length applies the historical profile to the field exactly once, which is how the profile was used. The reference length is ell = length / boundary_N by default: apply the window boundary_N times over the whole propagation.

The two rates are applied by different mechanisms, each chosen because it is exact (see docs/dev/decisions.md):

  • alpha(omega) is diagonal in omega, so it is folded into the linear operator before the cumulative operator is integrated. The propagator then applies it exactly over whatever sub-interval the stepper takes, at no cost.

  • alpha(t) is diagonal in t and cannot ride the propagator. It is applied as the term -alpha(t) E(t) / 2 inside every stage evaluation (nonlinear_absorber_term()), the default because the alternative – the split-step factor exp(-alpha(t) h / 2) after each accepted step (split_step_factor()), exact for the absorber alone because the factors telescope – was measured to be a first-order splitting of the whole. Both are here.

  • The hard band limit outside the active mask stays a 0/1 mask (apply_band_mask()), which is idempotent and never had the problem.

Two lengths set the collars’ strengths (SEP 22 section 4.6). reference_length sets the spectral rate and the ceiling every spectral rate is validated against; collar_length, reference_length unless given, sets the in-stage temporal collar, 12.6 (1 - W(t)) / ell_c, and its ceiling; a free-space run’s transverse boundary is designed by SpatialAbsorber (SEP 40). A custom spectral rate (custom_absorber()) can then be as strong as a measurement needs without moving the explicit in-stage collars or their stable step, and a temporal collar sized from a run’s walk-off (walkoff of absorber()) leaves the spectral rate where it was.

The manual page is docs/guide/numerics/boundaries.md.

Module Attributes

DEFAULT_BOUNDARY_N

Default absorber strength: applications of the collar profile over the propagation.

MAX_ALPHA_LENGTH

Cap on the spectral alpha * ell, and the ceiling every rate is validated against.

TEMPORAL_ALPHA_LENGTH

Depth of the temporal absorber at the edge of the time window, as alpha * ell.

MIN_COLLAR_SAMPLES

Grid samples a collar edge must span before it counts as a taper.

MIN_COLLAR_ATTENUATION

Field attenuation, in nepers, a walking packet must accumulate crossing the collar.

SPLIT_CROSSING

c of the split collar's step limit h <= c w / max |d tau / dz|: no step carries light more than half the temporal collar's width (split_crossing_step()).

SPLIT_OCCUPIED_SHARE

The share of the energy the split collar's crossing limit may set aside: the fastest-walking light holding at most this much does not bound the step (SplitCrossing).

SPLIT_RATE_BUCKETS

Walk-off rate buckets of SplitCrossing, logarithmic from SPLIT_RATE_TOP down nine decades: each bucket's rate is its upper edge, at most \(10^{9/128}\), 18 %, above the rates it holds, and the open first bucket's is the fastest rate the bins reach, so the limit is never longer than the bins' own would give.

SPLIT_RATE_TOP

The fastest bucket's lower edge, s/m: a group index 30 from the frame's, beyond any propagating light.

STABILITY_RADIUS

How far DOPRI5's stability region reaches along the negative real axis.

Functions

absorber(grid, *[, boundary_N, ...])

Build the absorbing boundaries of a grid.

apply_band_mask(spectrum, band_mask)

Zero a spectrum outside the active band.

apply_split_step(field, temporal_rate, step)

Apply the temporal absorber to a time-domain field over one accepted step.

collar_rate(window, reference_length)

Convert a collar profile into a power absorption coefficient by its logarithm.

custom_absorber(grid, spectral_rate, *[, collar])

Add a user's spectral power rate to a collar, keeping its in-stage collars.

no_absorber(grid, *[, boundary_N, ...])

Build boundaries with both soft collars off and the hard band mask kept.

nonlinear_absorber_term(field, temporal_rate)

Return the temporal absorber as a term to add inside the nonlinear evaluation.

split_bucket_rates([fastest])

Return each SplitCrossing bucket's rate, its upper edge, s/m.

split_crossing_step(rate, width[, factor])

Return the longest step the split collar holds a walking pulse to, m.

split_rate_buckets(rates)

Return the SplitCrossing bucket of each walk-off rate.

split_step_factor(temporal_rate, step)

Return the factor the temporal absorber multiplies the field by over one step.

stable_step(temporal_rate)

Return the longest step an explicit method can take with an in-stage collar.

temporal_collar_rate(axis, collar, ...)

Convert the temporal collar into a bounded power absorption coefficient.

warn_walkoff(grid, group_delay_span, *[, ...])

Warn if the absorber is too weak for the walk-off the run can produce.

Classes

Absorber(spectral_rate, temporal_rate, ...)

The absorbing boundaries of one grid, as rates per unit distance.

SplitCrossing(buckets, width[, ...])

The split temporal collar's crossing limit, read from where the light is.

class solprop.boundaries.Absorber(spectral_rate, temporal_rate, temporal_rate_fine, band_mask, reference_length, collar_length=None, temporal=True, user_rate=None)[source]#

Bases: object

The absorbing boundaries of one grid, as rates per unit distance.

Attributes:
spectral_ratenumpy.ndarray

Total power absorption coefficient on the coarse frequency axis, 1/m, shape (n_omega,): the collar’s and any user’s rate (user_rate), the one rate the linear operator folds in as -alpha/2. The collar’s part is exactly zero in the flat region and outside the active mask, where the linear operator is zero anyway.

temporal_ratenumpy.ndarray

Power absorption coefficient on the coarse time axis, 1/m, shape (n_t,).

temporal_rate_finenumpy.ndarray

The same on the fine time axis, 1/m, shape (n_to,), for a response that is evaluated there.

band_masknumpy.ndarray

The hard band limit, shape (n_mask,) matching spectral_rate: true where the physics lives. Applied as a 0/1 multiply, which is idempotent.

reference_lengthfloat

The length ell in metres over which the collar profile is applied once to the field. It sets the total spectral rate’s ceiling, 60 / ell.

collar_lengthfloat or None

The length ell_c in metres that sets the in-stage temporal collar’s strength, whose largest rate is 12.6 / ell_c; it also sets the temporal rates’ ceiling, 60 / ell_c. A free-space run’s real-space collar is designed separately (SpatialAbsorber, SEP 40). None, the default, means reference_length, as for every absorber before SEP 22; a value equal to reference_length is stored as None, so that the record carries it only where the two differ. Left out of the run record at None.

temporalbool

Whether the collar has a temporal part. False for a spectral-only collar (absorber() with temporal=False), whose temporal rates are zero arrays; it is recorded only when False. Any collar whose temporal rates are all zero, however it was built, is translated to no temporal absorber (solprop.translate.masks.temporal_rates()).

user_ratenumpy.ndarray or None

The user’s share of spectral_rate, 1/m, shape (n_omega,), with 0 <= user_rate <= spectral_rate: what custom_absorber() added, which absorbed_power reports apart from the collar. It moves no bit of the operator, which reads the total. None, the default, is no user’s rate, and is left out of the run record, so a run without one keeps its record and its hash; set, it is recorded, and two splits of one total hash apart.

Raises:
GridError

If a length is not positive and finite, the mask and the spectral rate disagree in shape, a rate is negative, not finite, or above its ceiling, a collar declared spectral-only carries a temporal rate, or the user’s share is not finite, misshaped, negative or above the total.

Examples

>>> from solprop.boundaries import absorber
>>> from solprop.grid import FieldGrid
>>> grid = FieldGrid.from_limits(1.5, 800e-9, (90e-9, 4e-6), 400e-15)
>>> boundary = absorber(grid)
>>> boundary.reference_length
0.075
>>> float(boundary.spectral_rate[grid.inband_mask].max())
0.0
band_mask: Bool[ndarray, 'n_mask']#
collar_length: float | None = None#
property in_stage_length: float[source]#

Return the length that sets the in-stage collars’ strength.

Returns:
float

collar_length, or reference_length when it is None, m.

Examples

>>> from solprop.boundaries import absorber
>>> from solprop.grid import FieldGrid
>>> grid = FieldGrid.from_limits(1.5, 800e-9, (90e-9, 4e-6), 400e-15)
>>> absorber(grid).in_stage_length
0.075
reference_length: float#
spectral_rate: Float[ndarray, 'n_omega']#
temporal: bool = True#
temporal_rate: Float[ndarray, 'n_t']#
temporal_rate_fine: Float[ndarray, 'n_to']#
user_rate: Float[ndarray, 'n_user_rate'] | None = None#
solprop.boundaries.DEFAULT_BOUNDARY_N: Final = 20#

Default absorber strength: applications of the collar profile over the propagation.

It sets the reference length ell = length / boundary_N, so the strength is a physical distance rather than a number of steps.

solprop.boundaries.MAX_ALPHA_LENGTH: Final = 60.0#

Cap on the spectral alpha * ell, and the ceiling every rate is validated against.

The spectral collar reaches exactly zero at the band edge and -2 log 0 is an infinity, which would make the operator inf * 0 somewhere and hence a NaN. At 60 the field is attenuated by exp(-30), about 1e-13, over one reference length – far below anything a propagation can represent, so the cap costs nothing and keeps every rate finite. The temporal rate is bounded by construction at TEMPORAL_ALPHA_LENGTH, well inside this ceiling: were it the same capped logarithm, then under the in-stage absorber term the cap would set the stability limit and the step count.

solprop.boundaries.MIN_COLLAR_ATTENUATION: Final = 6.907755278982137#

Field attenuation, in nepers, a walking packet must accumulate crossing the collar.

log(1e3): the field falls by a thousand and the power by sixty decibels. Below that warn_walkoff() says the absorber is too weak for the walk-off the run can produce.

solprop.boundaries.MIN_COLLAR_SAMPLES: Final = 4#

Grid samples a collar edge must span before it counts as a taper.

With fewer the discrete profile steps from one to zero rather than tapering, the rate jumps to its cap in a single sample, and the boundary is the brick wall the rate form exists to avoid. A temporal collar built naively is exactly this degenerate for a trange that lands on a power of two; solprop’s grids guarantee a minimum width, and this is the guard that says so.

solprop.boundaries.SPLIT_CROSSING: Final = 0.5#

c of the split collar’s step limit h <= c w / max |d tau / dz|: no step carries light more than half the temporal collar’s width (split_crossing_step()).

The "split_step" scheme damps the collar once per accepted step, at the step’s end, so light that walks through the collar within one step wraps round the periodic window into the interior undamped, and on a linear stretch the error estimate sees nothing. At half a width a crossing is damped at two step ends at least; the same factor as SEP 40’s adaptive transverse clamp (solprop.spatial_absorber.CROSSING_ADAPTIVE).

solprop.boundaries.SPLIT_OCCUPIED_SHARE: Final = 1e-06#

The share of the energy the split collar’s crossing limit may set aside: the fastest-walking light holding at most this much does not bound the step (SplitCrossing). Were all of it to wrap round the window undamped, the field error would be its square root, the boundary monitor’s default budget.

solprop.boundaries.SPLIT_RATE_BUCKETS: Final = 128#

Walk-off rate buckets of SplitCrossing, logarithmic from SPLIT_RATE_TOP down nine decades: each bucket’s rate is its upper edge, at most \(10^{9/128}\), 18 %, above the rates it holds, and the open first bucket’s is the fastest rate the bins reach, so the limit is never longer than the bins’ own would give.

solprop.boundaries.SPLIT_RATE_TOP: Final = 1e-07#

The fastest bucket’s lower edge, s/m: a group index 30 from the frame’s, beyond any propagating light. The fastest bucket is open above it and is counted at the fastest rate a run’s bins reach (SplitCrossing.fastest).

solprop.boundaries.STABILITY_RADIUS: Final = 3.3#

How far DOPRI5’s stability region reaches along the negative real axis.

The textbook value for both order-5(4) pairs this package carries, and what stable_step() turns into a step limit for the in-stage absorber. Measured on the 1 mm capillary of the driver tests, whose collar reached 1.2e6 m^-1 under the capped logarithm temporal profile: a fixed schedule diverges at h alpha / 2 = 3.75 and completes at 3.13, which brackets it.

class solprop.boundaries.SpatialAbsorber(kappa=None, angle=None, discard_above=None, attenuation=8.517193191416238, safety_fraction=0.1, real_space=True)#

Bases: object

The transverse boundary a free-space run asks for (SEP 40 section 5.2).

A specification, resolved at translation against the grid, the medium and the launch into a SpatialAbsorberDesign. By default (real_space=True) it asks for a real-space collar in the room the grid has outside its requested size (collar_fraction), as well as the safety ramp; real_space=False asks for the ramp alone, which is how a run with no spatial_absorber is resolved.

Attributes:
kappafloat or None

The protected band’s edge, |kappa| <= kappa, rad/m.

anglefloat or None

The protected band as an angle in vacuum behind a planar exit face, sin(theta) = c kappa / omega (SEP 23’s convention), rad, in (0, pi/2): a band that scales with frequency. Neither kappa nor angle takes the band from the launch, c_b sqrt(2) kappa_rms per axis (LAUNCH_BAND_FACTOR).

discard_abovefloat or None

A wavenumber above which the user declares no physical light, rad/m: the safety ramp then starts there. Never lowered by default.

attenuationfloat

Gamma_min, the collar’s returned attenuation at the band’s edge, field nepers. Default log(5e3).

safety_fractionfloat

The ramp’s width as a fraction of each axis’s largest wavenumber, in (0, 1). Default 0.1.

real_spacebool

Whether a real-space collar is asked for. Default True.

Raises:
GridError

If both kappa and angle are given, a value is out of range, or a band is declared with no collar to protect it.

Examples

>>> from solprop.spatial_absorber import SpatialAbsorber
>>> SpatialAbsorber(angle=25e-3).angle
0.025
>>> SpatialAbsorber(real_space=False, safety_fraction=0.2).real_space
False
angle: float | None = None#
attenuation: float = 8.517193191416238#
property band_source: Literal['kappa', 'angle', 'launch']#

Return where the protected band comes from, in order of precedence.

Returns:
str

"kappa", "angle" or "launch".

discard_above: float | None = None#
kappa: float | None = None#
real_space: bool = True#
safety_fraction: float = 0.1#
class solprop.boundaries.SpatialAbsorberDesign(specification, ramp_rate, ramp_strength, kappa_s, collar_rate=None, alpha0=0.0, width=(), kappa_p=(), kappa_lo=0.0, crossing_step=inf, unguarded_above=inf, check=<factory>, notes=<factory>)#

Bases: object

A transverse boundary resolved at translation (SEP 40 section 5.2).

Immutable, and part of the translated problem: derived runs share it (solprop.driver.simulation.Simulation.with_input()), and the design check that belongs to a launch is rerun for a new one. Every derived value is recorded in the run’s output beside the specification, not hashed with it.

Attributes:
specificationSpatialAbsorber

What the run asked for.

ramp_ratenumpy.ndarray

The safety ramp’s power rate on the spectral columns, 1/m.

ramp_strengthfloat

Its peak, 1/m, the larger of the axes’.

kappa_stuple of float

Where the ramp starts, per axis (one radially), rad/m.

collar_ratenumpy.ndarray or None

The real-space collar’s power rate on the nodes, 1/m; None without one.

alpha0float

The collar’s peak rate, 1/m; zero without one.

widthtuple of float

The collar’s width per face’s axis, m; empty without one.

kappa_ptuple of float

The protected band’s edge per axis at the reddest requested frequency, rad/m; empty without a collar.

kappa_lofloat

The reflection threshold of the strongest face, rad/m; zero without a collar.

crossing_stepfloat

The adaptive crossing limit, m; infinite without a collar.

unguarded_abovefloat

The wavenumber above which content could in principle cross within a clamped step, (0.75 / c_x) kappa_p, rad/m; infinite without a collar.

checkdict

The design check’s record (SEP 40 section 4.4): its status, the launch’s return, the bare wall’s at the same size, the frequency nodes and its wall time; empty until it has run, and without a collar.

notesdict

Further derived values for the record: the intermediate band’s report, the reflected QDHT columns, the launch’s share in the ramp’s band.

alpha0: float = 0.0#
check: dict[str, object]#
collar_rate: Float[ndarray, 'n_nodes'] | None = None#
crossing_step: float = inf#
property has_collar: bool#

Return whether the design carries a real-space collar.

Returns:
bool

True when collar_rate is set.

kappa_lo: float = 0.0#
kappa_p: tuple[float, ...] = ()#
kappa_s: tuple[float, ...]#
notes: dict[str, object]#
ramp_rate: Float[ndarray, 'n_columns']#
ramp_strength: float#
record()#

Return the design’s derived values for the run’s record.

Returns:
dict

JSON-ready scalars and tuples: the ramp’s start per axis and strength, and with a collar its peak rate, widths, band edges, threshold, crossing step and unguarded band, then the design check’s record and the notes. The rates on the nodes and columns are in the operator’s own record.

specification: SpatialAbsorber#
unguarded_above: float = inf#
width: tuple[float, ...] = ()#
class solprop.boundaries.SplitCrossing(buckets, width, launch_limit=inf, fastest=1e-07, factor=0.5, share=1e-06)[source]#

Bases: object

The split temporal collar’s crossing limit, read from where the light is.

Under the "split_step" scheme a step whose walk-off exceeds the collar’s width can carry light through it and round the periodic window undamped. Each (mode, frequency) bin walks at |d(beta - beta_frame)/d omega|, held here as its bucket (split_rate_buckets()); a state’s occupancy is its energy per bucket, and the limit factor * width / rate takes the rate of the fastest bucket left once the fastest holding at most share of the energy are set aside. Translation reads it from the launch (launch_limit), which holds dz_max and a fixed schedule; the boundary monitor reads it again from the state at every check, and the steps after it are capped there.

Attributes:
bucketsnumpy.ndarray

Each bin’s bucket, uint8, shape (n_modes, n_omega): the fastest of its batch members, polarisations and positions along the guide; evanescent and inactive bins in the last.

widthfloat

The collar’s width at its narrower edge, s.

launch_limitfloat

The limit the launch’s occupancy gives, m.

fastestfloat

The fastest rate a bin reaches, s/m: the open first bucket’s rate when above SPLIT_RATE_TOP, so that no bin is counted slower than it walks.

factorfloat

c, SPLIT_CROSSING.

sharefloat

SPLIT_OCCUPIED_SHARE.

Examples

>>> import numpy as np
>>> from solprop.boundaries import SplitCrossing, split_rate_buckets
>>> buckets = split_rate_buckets(np.array([[1e-12, 1e-10]]))
>>> crossing = SplitCrossing(buckets, 1e-13, launch_limit=0.05)
>>> occupancy = crossing.occupancy(np.array([[[[1.0, 1e-4]]]], dtype=complex))
>>> # The fast bin holds 1e-8 of the energy and is set aside: the slow bin's limit,
>>> # 50 mm at 1e-12 s/m, a little shorter at its bucket's edge.
>>> round(crossing.limit(occupancy) * 1e3, 1)
49.1
buckets: UInt8[ndarray, 'n_modes n_omega']#
factor: float = 0.5#
fastest: float = 1e-07#
launch_limit: float = inf#
limit(occupancy)[source]#

Return the crossing limit of an occupancy, m.

Parameters:
occupancynumpy.ndarray

Energy per bucket (occupancy()).

Returns:
float

factor * width / rate at the fastest bucket left once the fastest holding at most share of the energy are set aside; inf for no light.

occupancy(field_spectrum, columns=None)[source]#

Return a state’s energy per bucket, on the host: the reference reduction.

Parameters:
field_spectrumnumpy.ndarray

A state, canonical layout.

columnsnumpy.ndarray or None, optional

The modes’ quadrature weights, None for unit weights.

Returns:
numpy.ndarray

Shape (SPLIT_RATE_BUCKETS,), in the state’s units of energy.

share: float = 1e-06#
width: float#
solprop.boundaries.TEMPORAL_ALPHA_LENGTH: Final = 12.6#

Depth of the temporal absorber at the edge of the time window, as alpha * ell.

The temporal rate is TEMPORAL_ALPHA_LENGTH * (1 - W(t)) / ell, the complement of the collar window (temporal_collar_rate()). At 12.6 its integral over the collar is what -2 log W capped at MAX_ALPHA_LENGTH gave – 6.3 per collar width per reference length, both edges, to a few per cent on the grids solprop builds – so the walk-off protection and the meaning of boundary_N are unchanged, while the largest rate is a fifth of the capped logarithm’s: the stability limit of the in-stage absorber term (stable_step()) is 4.8 times longer, and the error estimate does not see a rate of 60 / ell on the collar’s last samples, which would cost the reduced M2 case 40 % of its steps. The capped logarithm puts 73 % of each edge’s attenuation in its outer 14 %; this profile spreads it over the edge, so a case whose field reaches the collar is absorbed earlier and answers differently. At the very edge the field falls by exp(-6.3), 1.8e-3, per reference length.

solprop.boundaries.absorber(grid, *, boundary_N=20, boundary_length=None, temporal=True, walkoff=None)[source]#

Build the absorbing boundaries of a grid.

Parameters:
gridTimeGrid

The grid, for its collars, its masks and its propagation length.

boundary_Nfloat, optional

Absorber strength, as the number of times the collar profile is applied to the field over the whole propagation; the reference length is length / boundary_N. Beside walkoff it sets the spectral collar alone.

boundary_lengthfloat or None, optional

Reference length in metres, overriding boundary_N. Beside walkoff it sets the spectral collar alone.

temporalbool, optional

False builds a spectral-only collar (SEP 22): its temporal rates are zero arrays on both time axes, the band mask and the spectral rate are as before, and the record says temporal=False. It is translated to no temporal absorber, so the kernels compile no temporal pass under either absorber scheme. The grid is unchanged.

walkofffloat or None, optional

The steady group-delay drift, in seconds and positive, that the temporal collar must stop: a packet drifting by this much over grid.length loses MIN_COLLAR_ATTENUATION nepers of field crossing the collar, counted over both edges, which is the condition warn_walkoff() checks. It sets collar_length to 6.3 w L / (Gamma walkoff) for edges of width w, a length L and that attenuation Gamma, and changes nothing else; None, the default, leaves the temporal strength to boundary_N.

It is measured over this grid’s own length. The collar’s rate depends only on the speed walkoff / grid.length, so a slice of a run gets its parent’s temporal collar when given the drift of that slice: a quarter of the length, a quarter of the walk-off. Handing a slice its parent’s whole drift asks for a collar four times stronger.

It does not conflict with boundary_N or boundary_length, so neither is refused beside it. They keep reference_length, the spectral rate and its ceiling, and walkoff sets the temporal rates and their ceiling; a slice that keeps its parent’s strength in both passes boundary_length=parent.reference_length and its own drift.

Refused beside temporal=False, which has no temporal collar to size. The window does not bound it: the loss needs only the collar’s width, and a drift longer than the window is absorbed on its first crossing. A large value costs steps instead, since an explicit method’s step is limited to about 0.48 w / v, by stable_step() under the in-stage scheme. A free-space run’s transverse collar is designed separately and does not read this length.

Returns:
Absorber

The spectral and temporal rates, the hard band mask and the reference length.

Raises:
GridError

If the reference length is not positive and finite, either collar is too narrow on its axis to be a taper, or walkoff is not positive and finite or is given with temporal=False.

Examples

>>> import numpy as np
>>> from solprop.boundaries import absorber
>>> from solprop.grid import FieldGrid
>>> grid = FieldGrid.from_limits(1.5, 800e-9, (90e-9, 4e-6), 400e-15)
>>> boundary = absorber(grid, boundary_N=20)
>>> boundary.reference_length
0.075
>>> field_left = np.exp(-boundary.spectral_rate * boundary.reference_length / 2)
>>> active = grid.active_mask
>>> bool(np.allclose(field_left[active], grid.omega_window[active]))
True
>>> spectral_only = absorber(grid, temporal=False)
>>> float(spectral_only.temporal_rate.max()), spectral_only.temporal
(0.0, False)
>>> walking = absorber(grid, walkoff=1e-12)
>>> bool(np.array_equal(walking.spectral_rate, boundary.spectral_rate))
True
>>> round(walking.in_stage_length / boundary.in_stage_length, 3)
1.023
solprop.boundaries.apply_band_mask(spectrum, band_mask)[source]#

Zero a spectrum outside the active band.

The hard part of the boundary, kept as a 0/1 multiply rather than made a rate: it is idempotent, so applying it once per accepted step never accumulates, and it is the definition of the band the equation is solved on rather than an absorber.

Parameters:
spectrumnumpy.ndarray

Spectral amplitudes, shape (..., n_omega).

band_masknumpy.ndarray

True where the physics lives, shape (n_omega,).

Returns:
numpy.ndarray

The masked spectrum, with the shape of spectrum.

Examples

>>> import numpy as np
>>> from solprop.boundaries import apply_band_mask
>>> mask = np.array([True, False, True])
>>> apply_band_mask(np.array([1.0, 2.0, 3.0]), mask).tolist()
[1.0, 0.0, 3.0]
solprop.boundaries.apply_split_step(field, temporal_rate, step)[source]#

Apply the temporal absorber to a time-domain field over one accepted step.

The reference implementation of the split-step absorber scheme, the alternative to the default in-stage term of nonlinear_absorber_term(), and what that term is measured against.

Parameters:
fieldnumpy.ndarray

Time-domain field, shape (..., n_t); real on a field grid, complex on an envelope grid.

temporal_ratenumpy.ndarray

Power absorption coefficient on the time axis, 1/m, shape (n_t,).

stepfloat

Distance travelled in metres.

Returns:
numpy.ndarray

The attenuated field, with the shape of field.

Examples

>>> import numpy as np
>>> from solprop.boundaries import apply_split_step, collar_rate
>>> alpha = collar_rate(np.array([1.0, 0.5]), 0.1)
>>> np.round(apply_split_step(np.ones((1, 2)), alpha, 0.1), 6).tolist()
[[1.0, 0.5]]
solprop.boundaries.collar_rate(window, reference_length)[source]#

Convert a collar profile into a power absorption coefficient by its logarithm.

The spectral collar’s rate; the temporal collar takes the bounded profile of temporal_collar_rate() instead. alpha = -2 log(W) / ell, so that propagating through ell attenuates the field by exactly W: the collar profiles were multiplied into the field, and this is the rate that reproduces one such multiplication over the reference length. Exactly zero where W == 1, and capped at MAX_ALPHA_LENGTH / ell where the profile reaches zero.

Parameters:
windownumpy.ndarray

Collar profile in [0, 1], one in the interior and falling to zero at the boundary, shape (n,).

reference_lengthfloat

The length in metres over which the profile is applied once; positive, finite.

Returns:
numpy.ndarray

Power absorption coefficient in 1/m, shape (n,), non-negative and finite.

Raises:
GridError

If the reference length is not positive and finite, or the profile leaves [0, 1].

Examples

>>> import numpy as np
>>> from solprop.boundaries import collar_rate
>>> alpha = collar_rate(np.array([1.0, 0.5, 0.0]), 0.1)
>>> np.round(alpha, 6).tolist()
[0.0, 13.862944, 600.0]
>>> float(np.exp(-alpha[1] * 0.1 / 2))
0.5
solprop.boundaries.custom_absorber(grid, spectral_rate, *, collar=None)[source]#

Add a user’s spectral power rate to a collar, keeping its in-stage collars.

The spectral rate is diagonal in frequency and folded into the linear operator, where it is exact and free whatever its strength (ADR-007); the in-stage collars are explicit and their strength bounds a replayed step (SEP 22 section 4.6). The user’s rate is therefore added to the collar’s spectral rate, kept apart as user_rate for the statistic that reports it, and the collar’s temporal rates, band mask and in-stage strength are kept as they are: its collar_length is the collar’s own in_stage_length. Its reference_length stays the collar’s when the summed rate fits under the collar’s ceiling 60 / reference_length, and otherwise becomes 60 over the sum’s largest value, the longest length that admits it. The collar length is recorded only where it differs from the reference length, so a zero user rate returns a collar equal to the one given, field for field.

Parameters:
gridTimeGrid

The grid the collar is for, for its frequency axis.

spectral_ratefloat, numpy.ndarray or callable

The user’s power absorption coefficient, 1/m: a scalar, an array of shape (n_omega,) on the grid’s coarse frequency axis, or a callable of the angular frequency omega in rad/s (the grid’s own axis, an envelope grid’s in FFT order) returning that array. Finite and non-negative; it is zeroed outside the collar’s band mask, where the operator is zero.

collarAbsorber or None, optional

The collar it is added to, taken by value; None is absorber() of the grid with its defaults. Any collar is accepted as it is, a spectral-only one (absorber(grid, temporal=False)) among them.

Returns:
Absorber

The collar with the summed spectral rate, and the user’s share of it in user_rate, added to any share collar already holds.

Raises:
GridError

If the rate is not finite and non-negative, or not of the frequency axis’s shape.

Examples

>>> import numpy as np
>>> from solprop.boundaries import absorber, custom_absorber
>>> from solprop.grid import FieldGrid
>>> grid = FieldGrid.from_limits(1.5, 800e-9, (90e-9, 4e-6), 400e-15)
>>> collar = absorber(grid)
>>> same = custom_absorber(grid, 0.0, collar=collar)
>>> same.reference_length == collar.reference_length, same.collar_length
(True, None)
>>> strong = custom_absorber(grid, 1e5, collar=collar)
>>> strong.reference_length < collar.reference_length
True
>>> strong.in_stage_length == collar.in_stage_length
True
>>> bool(np.array_equal(strong.temporal_rate, collar.temporal_rate))
True
solprop.boundaries.no_absorber(grid, *, boundary_N=20, boundary_length=None)[source]#

Build boundaries with both soft collars off and the hard band mask kept.

What absorber=False on the keyword interfaces means (SEP 21 section 5): the temporal and spectral rates are zero, so no light is absorbed at either edge of the window, and the hard band mask still keeps the field inside the representable band. The reference length is kept for the record; a free-space run’s transverse boundary is designed separately (SpatialAbsorber) and does not read it.

Parameters:
gridTimeGrid

The grid, for its masks and its propagation length.

boundary_Nfloat, optional

As for absorber(); it sets only the reference length here.

boundary_lengthfloat or None, optional

Reference length in metres, overriding boundary_N.

Returns:
Absorber

Zero spectral and temporal rates, the band mask and the reference length, recorded as temporal=False.

Examples

>>> from solprop.boundaries import no_absorber
>>> from solprop.grid import FieldGrid
>>> grid = FieldGrid.from_limits(1.5, 800e-9, (90e-9, 4e-6), 400e-15)
>>> boundary = no_absorber(grid)
>>> float(boundary.spectral_rate.max()), float(boundary.temporal_rate.max())
(0.0, 0.0)
>>> bool(boundary.band_mask.any())
True
solprop.boundaries.nonlinear_absorber_term(field, temporal_rate)[source]#

Return the temporal absorber as a term to add inside the nonlinear evaluation.

-alpha(t) E(t) / 2: the derivative form of the same rate, and the reference implementation of the default in-stage absorber scheme. Integrating it exactly over a step would give apply_split_step(); a Runge-Kutta stage integrates it to the order of the method, so the two agree to first order in the step and differ at second, which is what the comparison of the two schemes measures.

Parameters:
fieldnumpy.ndarray

Time-domain field, shape (..., n_t).

temporal_ratenumpy.ndarray

Power absorption coefficient on the time axis, 1/m, shape (n_t,).

Returns:
numpy.ndarray

The contribution to dE/dz in the time domain, with the shape of field.

Examples

>>> import numpy as np
>>> from solprop.boundaries import collar_rate, nonlinear_absorber_term
>>> alpha = collar_rate(np.array([0.8, 0.5]), 0.1)
>>> np.round(nonlinear_absorber_term(np.ones(2), alpha), 6).tolist()
[-2.231436, -6.931472]
solprop.boundaries.split_bucket_rates(fastest=1e-07)[source]#

Return each SplitCrossing bucket’s rate, its upper edge, s/m.

Parameters:
fastestfloat, optional

The fastest rate the bins reach, s/m: the open first bucket’s rate when it exceeds SPLIT_RATE_TOP.

Returns:
numpy.ndarray

Shape (SPLIT_RATE_BUCKETS,), the fastest first.

Examples

>>> from solprop.boundaries import split_bucket_rates
>>> rates = split_bucket_rates()
>>> float(rates[0]), len(rates)
(1e-07, 128)
solprop.boundaries.split_crossing_step(rate, width, factor=0.5)[source]#

Return the longest step the split collar holds a walking pulse to, m.

factor * width / rate: a component whose group delay walks rate seconds per metre against the frame moves factor of the collar’s width in one step at most, so it cannot cross the collar between two of the split’s applications. Translation holds an adaptive run’s dz_max to it and refuses a fixed schedule past it, under the "split_step" scheme; the in-stage scheme absorbs inside every stage and needs no such limit.

Parameters:
ratefloat

The fastest walk-off against the frame, s/m, nonnegative: the rate SplitCrossing.limit() reads from an occupancy.

widthfloat

The temporal collar’s width at its narrower edge, s.

factorfloat, optional

c, the share of the width one step may walk; SPLIT_CROSSING.

Returns:
float

The step, m; inf when nothing walks.

Examples

>>> from solprop.boundaries import split_crossing_step
>>> round(split_crossing_step(1.7e-12, 212e-15), 4)
0.0624
>>> split_crossing_step(0.0, 212e-15)
inf
solprop.boundaries.split_rate_buckets(rates)[source]#

Return the SplitCrossing bucket of each walk-off rate.

Bucket k holds the rates up to SPLIT_RATE_TOP * 10**(-9 k / 128), the fastest first; a rate of zero, or below the last edge, is in the last bucket, and one above SPLIT_RATE_TOP in the first, which is open-ended.

Parameters:
ratesnumpy.ndarray

Walk-off rates against the frame, s/m, nonnegative.

Returns:
numpy.ndarray

The buckets, uint8, the shape of rates.

Examples

>>> import numpy as np
>>> from solprop.boundaries import split_rate_buckets
>>> split_rate_buckets(np.array([1.0, 1e-7, 5e-8, 0.0])).tolist()
[0, 0, 4, 127]
solprop.boundaries.split_step_factor(temporal_rate, step)[source]#

Return the factor the temporal absorber multiplies the field by over one step.

exp(-alpha(t) h / 2): the field, not the power, because that is what the state holds, and alpha is a power coefficient. Successive factors telescope exactly to exp(-alpha(t) L / 2) however the solver subdivides the propagation, which is the whole point of rate semantics – and the property the tests assert.

The factor of one half is required: exp(-alpha(t) h) could not be right alongside the -alpha(t) E(t) / 2 of the alternative scheme, or alongside alpha = -2 log W / ell: with it, one reference length would apply the collar profile to the field twice, and the two schemes would differ at first order in the step rather than agreeing there.

Parameters:
temporal_ratenumpy.ndarray

Power absorption coefficient on the time axis, 1/m, shape (n_t,).

stepfloat

Distance travelled in metres, non-negative.

Returns:
numpy.ndarray

Multiplicative factor in (0, 1], shape (n_t,).

Raises:
GridError

If the step is negative or not finite.

Examples

>>> import numpy as np
>>> from solprop.boundaries import collar_rate, split_step_factor
>>> alpha = collar_rate(np.array([1.0, 0.5]), 0.1)
>>> np.round(split_step_factor(alpha, 0.1), 6).tolist()
[1.0, 0.5]
solprop.boundaries.stable_step(temporal_rate)[source]#

Return the longest step an explicit method can take with an in-stage collar.

The "nonlinear" absorber scheme adds -alpha(t) E(t) / 2 to the derivative, so the collar’s largest rate becomes an eigenvalue of the linearised problem and an explicit Runge-Kutta method must keep h alpha / 2 inside its stability region. The split factor of split_step_factor() integrates the same term exactly and is unconditionally stable, which is the one thing it is better at.

DOPRI5’s and Tsit5’s stability region reaches STABILITY_RADIUS along the negative real axis, so the limit is 2 R / max(alpha). Measured on the 1 mm capillary of the driver tests, whose collar reached 1.2e6 m^-1 under the capped logarithm then in force: a fixed schedule diverges at h alpha / 2 = 3.75 and completes at 3.13, which brackets the textbook 3.3. With the bounded profile the largest rate is TEMPORAL_ALPHA_LENGTH / ell, so the limit is 0.52 ell.

The rate reaches its ceiling only at the very edge of the window, where the field is by design negligible, so an adaptive run is not usually held at this limit – the error norm is scaled by the state and an enormous derivative on an empty bin scores nothing. What the limit binds is a replayed schedule, and a floor dz_min set above it.

Parameters:
temporal_ratenumpy.ndarray

The collar’s power absorption coefficient on the time axis, 1/m, shape (n_t,).

Returns:
float

The longest stable step in m, or inf when the collar is identically zero.

Examples

>>> import numpy as np
>>> from solprop.boundaries import stable_step
>>> round(stable_step(np.array([0.0, 6.6, 3.3])), 6)
1.0
>>> stable_step(np.zeros(4))
inf
solprop.boundaries.temporal_collar_rate(axis, collar, reference_length)[source]#

Convert the temporal collar into a bounded power absorption coefficient.

alpha(t) = TEMPORAL_ALPHA_LENGTH * (1 - W(t)) / ell: exactly zero over the flat region, rising with every derivative continuous as the Planck window falls, and reaching TEMPORAL_ALPHA_LENGTH / ell at the edge of the window. The logarithm of collar_rate() is the right rate for the spectral collar, which rides the linear operator for free; for the temporal collar it would be a stiff term in every stage of the in-stage absorber, its cap would set the stability limit and the step count, and it puts nearly three quarters of the attenuation in the outer seventh of each edge. This profile carries the same integrated attenuation with a largest rate a fifth of the cap.

Parameters:
axisnumpy.ndarray

Time axis to evaluate the rate on, s, shape (n,).

collarsolprop.grid.Collar

The temporal collar geometry.

reference_lengthfloat

The length ell in metres; positive, finite.

Returns:
numpy.ndarray

Power absorption coefficient in 1/m, shape (n,), in [0, TEMPORAL_ALPHA_LENGTH / ell].

Raises:
GridError

If the reference length is not positive and finite.

Examples

>>> import numpy as np
>>> from solprop.boundaries import TEMPORAL_ALPHA_LENGTH, temporal_collar_rate
>>> from solprop.grid import Collar
>>> collar = Collar(-2.0, -1.0, 1.0, 2.0)
>>> rate = temporal_collar_rate(np.array([-2.0, 0.0, 2.0]), collar, 0.1)
>>> rate.tolist() == [TEMPORAL_ALPHA_LENGTH / 0.1, 0.0, TEMPORAL_ALPHA_LENGTH / 0.1]
True
solprop.boundaries.warn_walkoff(grid, group_delay_span, *, boundary_N=20, boundary_length=None)[source]#

Warn if the absorber is too weak for the walk-off the run can produce.

A packet whose group delay drifts by group_delay_span over the whole propagation crosses the temporal collar at group_delay_span / length seconds per metre, so it spends dt / v metres at each sample and accumulates a field attenuation of (length / group_delay_span) * integral(alpha) / 2 nepers on the way through. Below MIN_COLLAR_ATTENUATION the packet reaches the edge of the window still alive, wraps around, and aliases onto the rest of the field.

The span is the caller’s, deliberately. A prediction from the group delay across the whole simulation band would, on a grid running to four microns, be dominated by band edges carrying nothing but shot noise, and so would fire on almost every run. A caller who passes the span of the band it cares about does not have that problem (docs/dev/stepper.md section 6).

Parameters:
gridTimeGrid

The grid, for its temporal collar and its propagation length.

group_delay_spanfloat

Spread of group delay accumulated over the propagation, in seconds. Zero or negative means nothing walks anywhere and nothing is checked.

boundary_Nfloat, optional

Absorber strength, as in absorber().

boundary_lengthfloat or None, optional

Reference length in metres, overriding boundary_N.

Raises:
GridError

If the span is not finite, or the reference length is invalid.

Warns:
SolpropWarning

If a packet drifting by that span would survive the collar.

Examples

>>> import warnings
>>> from solprop.boundaries import warn_walkoff
>>> from solprop.grid import FieldGrid
>>> grid = FieldGrid.from_limits(1.5, 800e-9, (90e-9, 4e-6), 400e-15)
>>> with warnings.catch_warnings():
...     warnings.simplefilter("error")
...     warn_walkoff(grid, 50e-15)