Is my time window big enough?#

The time window is periodic: light that walks past one edge comes back at the other. Every run therefore carries a soft absorbing collar just outside the window you ask for (trange), and a boundary monitor that watches the outer 5 % of the window at each edge, the guards, and the energy the collar removes. This tutorial uses both to size a window, on a short fibre where a Raman soliton decelerates towards the window’s late edge.

The fibre: 40 cm of a solid-core fibre at 835 nm (beta2 = -11.83 ps^2/km, a little third-order dispersion, gamma = 0.11 /W/m), pumped by a 50 fs sech pulse of soliton order 1.7 with Raman on. The pulse settles into a soliton, which shifts to the red and slows down, and sheds dispersive radiation near 700 nm, which travels slower still. The runs below switch the input shot noise off (noise=None), so that windows of different sizes see the same input; with noise, each grid draws its own realisation.

The boundaries guide describes the collar, the monitor and their settings.

A window that is too small#

One function builds the fibre on a requested window trange, optionally with the pulse launched at t0 and with the monitor’s thresholds changed.

import time
import warnings

import matplotlib.pyplot as plt
import numpy as np

from solprop.driver import Simulation
from solprop.exceptions import BoundaryError, BoundaryWarning
from solprop.monitor import DEFAULT_BOUNDARY_CHECK, BoundaryCheck
from solprop.processing import get_power
from solprop.pulses import SechPulse

LENGTH = 0.4
BETAS = [0.0, 0.0, -11.83e-27, 8.1e-41]
WIDTH = 28e-15
"""sech half-width, s (50 fs full width at half maximum)."""
PEAK = 3 * abs(BETAS[2]) / (0.11 * WIDTH**2)
"""Peak power, W: three times the fundamental soliton's, a soliton order of 1.7."""


def fibre(trange, *, t0=0.0, check=DEFAULT_BOUNDARY_CHECK):
    """Return the fibre on a requested window `trange`, s."""
    return Simulation.from_gnlse(
        0.11,
        LENGTH,
        BETAS,
        wavelength=835e-9,
        wavelength_limits=(600e-9, 1600e-9),
        trange=trange,
        raman=True,
        n_save=21,
        noise=None,
        pulses=[SechPulse(wavelength=835e-9, width=WIDTH, peak_power=PEAK, t0=t0)],
        boundary_check=check,
    )

On a 0.5 ps window the soliton reaches the late edge before the end of the fibre. The monitor warns once the field in a guard passes its budget (a field error of 1e-3, so a millionth of the energy), and stops the run at ten times the budget:

try:
    fibre(0.5e-12).run(progress="none")
except BoundaryError as error:
    print(f"stopped: {error}")
BoundaryWarning: at z = 1.442264e-01 m the square root of the time window's guard occupancy is 1.01e-03, above the 1e-03 budget: light is reaching the window's edge; widen trange or move the launch
stopped: at z = 3.641578e-01 m the field puts 1.02e-04 of its energy in the time window's 0.05 guards, whose square root 1.01e-02 is above 10 times the 1e-03 budget: light is reaching the window's edge and will wrap around or be absorbed; widen trange, move the launch, or relax the check (settings.boundary_check)

Reading what the monitor saw#

A relaxed check lets the run finish so that its record can be read. The record sits in the run’s execution record, with a row for every saved plane: the collar’s occupancy there, each guard’s largest occupancy since the previous plane, and the fraction of the launch energy the collar has removed so far. (The occupancies are counted above the shot-noise floor, which these runs do not carry.)

relaxed = BoundaryCheck(budget=1.0, stop_factor=1.0)


def watched(trange, **keywords):
    """Run the fibre with a relaxed check; return the run, its record and wall time."""
    simulation = fibre(trange, check=relaxed, **keywords)
    started = time.perf_counter()
    with warnings.catch_warnings():
        warnings.simplefilter("ignore", BoundaryWarning)
        run = simulation.run(progress="none")
    wall = time.perf_counter() - started
    return run, run.outcome.execution["boundary_monitor"], wall, simulation.grid


small, small_record, _, _ = watched(0.5e-12)
modest, modest_record, _, _ = watched(1e-12)

fig, (guards, removed) = plt.subplots(1, 2, figsize=(9, 3.4), layout="constrained")
for record, label in ((small_record, "0.5 ps"), (modest_record, "1 ps")):
    rows = record["planes"]
    z = 100 * np.asarray(rows["z"])
    guards.semilogy(
        z, np.maximum(rows["guard_high"], 1e-16), label=f"{label}, late guard"
    )
    removed.semilogy(z, np.maximum(rows["removed_fraction"], 1e-16), label=label)
guards.axhline(1e-6, color="0.5", ls=":", label="budget (field error 1e-3)")
guards.set(xlabel="z (cm)", ylabel="guard occupancy", ylim=(1e-14, 1))
removed.set(xlabel="z (cm)", ylabel="fraction removed by the collar", ylim=(1e-8, 1))
guards.legend(fontsize=8)
removed.legend(fontsize=8)
plt.show()
plot 07 time window

On 0.5 ps the soliton fills the late guard and the collar takes most of the energy. On 1 ps the guards stay far below the budget, yet the collar still removes about a quarter of a per cent of the energy: light that is not the soliton walks out. The monitor reports that at the end of the run as a warning, since its square root is above the budget; whether it matters depends on what you measure.

print(
    f"1 ps window: guards peaked at {modest_record['guard_high_max']:.1e}, "
    f"the collar removed {modest_record['removed_fraction']:.1e} of the launch energy"
)
1 ps window: guards peaked at 1.3e-07, the collar removed 2.5e-03 of the launch energy

What is walking out#

A wide window answers that. On 8 ps nothing reaches the collar, and the exit power shows the soliton at about +0.34 ps and a weak tail far later: the dispersive radiation shed at fission, near 700 nm, which a 1 ps window cannot hold.

wide, wide_record, _, _ = watched(8e-12)
t, power = get_power(wide, plane=-1)
total = power.sum()
print(f"8 ps window: the collar removed {wide_record['removed_fraction']:.1e}")
for edge in (0.25e-12, 0.5e-12, 1e-12):
    print(
        f"energy later than {1e12 * edge:.2f} ps: {power[t > edge].sum() / total:.1e}, "
        f"earlier than {-1e12 * edge:.2f} ps: {power[t < -edge].sum() / total:.1e}"
    )

fig, ax = plt.subplots(figsize=(7, 3.4), layout="constrained")
ax.semilogy(1e12 * t, power / power.max(), lw=0.8, color="k")
for edge, colour in ((0.25e-12, "C3"), (0.5e-12, "C1"), (1.5e-12, "C2")):
    ax.axvspan(-edge, edge, color=colour, alpha=0.08)
    ax.axvline(edge, color=colour, lw=0.8, label=f"{2e12 * edge:g} ps request")
ax.set(xlabel="time (ps)", ylabel="power (normalised)", xlim=(-1, 3), ylim=(1e-12, 2))
ax.legend(fontsize=8)
plt.show()
plot 07 time window
8 ps window: the collar removed 8.7e-15
energy later than 0.25 ps: 9.1e-01, earlier than -0.25 ps: 3.1e-05
energy later than 0.50 ps: 2.8e-03, earlier than -0.50 ps: 5.3e-16
energy later than 1.00 ps: 2.5e-03, earlier than -1.00 ps: 1.1e-23

Cost against what is kept#

Widening the window costs transform points and time. The fibre loses energy to the Raman response itself, so what the window costs is read against the wide run’s exit energy. The soliton is the same on every window that holds it; the radiation needs 3 ps.

def kept(run):
    """Return the exit energy over the launch energy."""
    energy = np.asarray(run["stats/energy"]).reshape(len(run["z"]), -1).sum(axis=1)
    return energy[-1] / energy[0]


reference = kept(wide)
print(" window   points   wall (s)   removed   exit energy against 8 ps")
for trange in (0.5e-12, 1e-12, 2e-12, 3e-12, 4e-12):
    run, record, wall, grid = watched(trange)
    print(
        f"{1e12 * trange:5.1f} ps  {grid.n_t:6d}   {wall:8.2f}   "
        f"{record['removed_fraction']:.1e}   {kept(run) / reference - 1:+.1e}"
    )
window   points   wall (s)   removed   exit energy against 8 ps
 0.5 ps     384       0.12   8.4e-01   -8.7e-01
 1.0 ps     768       0.16   2.5e-03   -2.6e-03
 2.0 ps    1536       0.35   1.6e-03   -1.6e-03
 3.0 ps    2048       0.36   1.5e-07   -1.6e-07
 4.0 ps    3072       0.52   3.5e-12   -3.6e-12

Placing the launch#

The soliton walks one way, to later times, so launching the pulse early moves it away from the late edge. On 0.75 ps, a launch 0.2 ps early brings the soliton’s exit from near the edge back towards the middle. The guard and the collar barely notice: what they report is the radiation, which walks far further than the soliton and crosses the late guard whatever the launch. Placing the launch helps when the light you measure is the only light that walks; here only a wider window keeps it all.

for t0 in (0.0, -0.2e-12):
    run, record, _, _ = watched(0.75e-12, t0=t0)
    t, power = get_power(run, plane=-1)
    guard, removed = record["guard_high_max"], record["removed_fraction"]
    print(
        f"0.75 ps, launched at {1e12 * t0:+.2f} ps: soliton at "
        f"{1e12 * t[power.argmax()]:+.2f} ps, late guard {guard:.1e}, "
        f"collar removed {removed:.1e}"
    )
0.75 ps, launched at +0.00 ps: soliton at +0.34 ps, late guard 2.6e-05, collar removed 3.4e-03
0.75 ps, launched at -0.20 ps: soliton at +0.14 ps, late guard 2.0e-05, collar removed 2.7e-03

In short. A monitor that stays quiet means no light reached the guards; a removed fraction near zero means none left through the collar either. When either is not, run the same case on a wider window, see what walks out, and decide whether it is part of what you measure. boundary_check relaxes or switches off the check, absorber=False the collar.

Total running time of the script: (0 minutes 3.565 seconds)