Coherent capillary walls#
Use a coherent wall when relative modal phases affect leakage and coupling at a capillary boundary. Begin with the practical capillary workflow, then use the equations and validity ratios here to assess the approximation. Ordinary independent modal loss is described in Capillary modes and dispersion.
The coherent wall#
Marcatili’s loss is incoherent: each \(\mathrm{HE}_{1m}\) channel attenuates on its own and the wall never moves power between them. That is right whenever the channels are far apart in propagation constant compared with the wall’s coupling, and wrong whenever they are not — two channels that graze the same lossy boundary exchange amplitude with a definite phase, and an aperture or a taper that fills several of them at once can see that exchange before it sees the attenuation.
prop_capillary(..., modes=N, wall=CoherentWall()) propagates the coherent wall
instead. Use it when several \(\mathrm{HE}_{1m}\) modes are excited together — a launch
that is not matched to \(\mathrm{HE}_{11}\), an aperture, a taper — and the exchange
between them matters; a run in which only \(\mathrm{HE}_{11}\) matters gains nothing from
it. The block condenses the outgoing Robin boundary condition of
the exact radial problem onto the \(N\) retained channels. That boundary condition, and the
observation that an incoherent modal loss misjudges the loss of beams that pull away from the
wall, are Andreasen and Kolesik’s semitransparent wall
(Phys. Rev. E 87, 053303 (2013), Eqs. 10–15;
J. Lightwave Technol. 31, 2999 (2013)), which
they apply on a finite-difference grid; the condensation onto modes is solprop’s own. It makes the wall one
rank-one term that couples every channel to every other:
with \(h_m = -\sqrt2\,\operatorname{sgn}J_1(u_m)\,u_m\) — the radial derivative of the \(m\)-th orthonormal basis function \(\phi_m(x)=\sqrt2\,J_0(u_mx)/|J_1(u_m)|\) at the wall — and \(g(a) = i/[2ka^2(2N - ik_{\rm eff}a)]\), with \(N\) the number of retained channels. The carrier \(k = n_{\rm core}\omega/c\) is the in-core wavenumber the paraxial reduction divides by; the impedance \(k_{\rm eff} = k_0/v_n\) is the vacuum wavenumber \(k_0 = \omega/c\) over the same cladding term \(v_n = (\varepsilon+1)/(2\sqrt{\varepsilon-1})\) the ordinary Marcatili index already uses, which is the combination whose \(N = 1\) limit reproduces it.
It reproduces Marcatili’s loss to a bound you can write down. With \(t = v_n/(k_0a)\), the condensed diagonal’s imaginary part is \(-2u_m^2t/(1 + 4N^2t^2)\) against Marcatili’s \(-2u_m^2t\), and the paraxial reduction divides by \(2k\) where the exact square root divides by \(2\,\mathrm{Re}\,\beta\), so each channel’s field-loss rate departs from \((\omega/c)\,\mathrm{Im}\,n_{\rm eff}\) by
relative. For a 125 µm bore at 800 nm with one channel that is \(-1.2036\times10^{-5}\), and that is what is measured. The first term is the square of the quasi-static ratio \(2N/|ak_{\rm eff}|\) the block records as a validity limit, which is the reason raising \(N\) cannot by itself improve the condensation: the boundary trace it discards grows with the basis.
The subsections below give the block’s requirements and validity limits, its effect on a straight bore, how it is integrated and recorded, and its extension to a taper or a pressure gradient. Separate chapters describe taper coupling without a wall and the vector wall, which gives TE and TM fields their own impedances. The capillary chapter of the guide sets out when and how to use it.
What it needs, and what it refuses#
The condensation is onto the complete set of \(J_0\) radial orders, so the block needs the
contiguous set \(\mathrm{HE}_{11}\) to \(\mathrm{HE}_{1N}\) — modes=N on the capillary route
builds exactly that — sharing one polarisation angle, one capillary model and one
declared guide. A sparse set, a mixed family, two polarisations, a
noise model and a fused batch are refused by name, each at the layer that owns the
refusal. All three backends execute the block — numpy_ref, numba_cpu and native, on
the CPU and on the qualified GPU builds (the native backend) —
except that method="cf4" runs on numpy_ref alone. The vector boundary response
chapter covers the additional families and is declared instead of it rather than
beside it.
Two settings are refused rather than quietly overridden, because the block is what they
describe. The wall is the loss model: it replaces each channel’s Marcatili
attenuation, so loss= must be True — False, a scale or a measured curve beside a
wall would otherwise vanish without a word, and is refused by name instead. And the
condensation inverts the full model’s transverse eigenvalue, so model="reduced" is
refused too. A tube lattice is refused for the same kind of reason: an antiresonant
guide’s loss is its tubes’ resonances, not a Robin condition at one radius, so
lattice= and wall= together are named rather than given the bare capillary’s wall.
Validity ratios. Three dimensionless ratios, taken for the highest retained channel, say how far the block is from its assumptions:
\(u_N/(ka)\), how far that channel is from paraxial;
\(u_N/(k_0a)\), the grazing ratio the Marcatili expansion is asymptotic in;
\(2N/|ak_{\rm eff}|\), the quasi-static ratio above.
They are computed over the scored band — the run’s grid.inband_mask, the requested
wavelength limits the step’s error norm measures — and recorded on the run’s identity.
Above validity_limit (default 0.25) the
block is refused, and above a tenth of it the construction warns, naming the ratio. The
operator itself is built over the wider active band (grid.active_mask), whose
spectral collar reaches half the lowest requested frequency; the collar is absorbed and
not scored, so its bins, where every ratio would be about twice its in-band value,
neither refuse nor warn. A grid whose requested limits hold no bin takes the ratios over
the whole active band instead. Bins outside the active band are never evaluated: the
formula divides by \(\omega\), and the generator is left exactly zero there.
On a straight bore#
Here is the whole of it, on ten steps of a 50 µm bore, against the same run with the wall left off. Both launch into \(\mathrm{HE}_{11}\) alone; what appears in the other two channels is the wall’s doing, because a diagonal loss cannot move power between channels at all and the only other path — the Kerr overlap — is eleven orders below it.
import warnings
import matplotlib.pyplot as plt
import numpy as np
from solprop.boundaries import absorber
from solprop.driver import prop_capillary
from solprop.grid import FieldGrid
from solprop.modes import CoherentWall
from solprop.stepper import FixedSchedule
length, steps = 2e-4, 10
grid = FieldGrid.from_limits(length, 800e-9, (400e-9, 1.6e-6), 300e-15)
schedule = FixedSchedule(np.full(steps, length / steps))
def run(wall):
# The band runs past helium's and silica's fitted ranges and a fifth of a
# millimetre of this bore is a marginal geometry on purpose, so both say so.
with warnings.catch_warnings():
warnings.simplefilter("ignore")
return prop_capillary(
50e-6, length, "He", 1.0, lambda0=800e-9, grid=grid,
# A collar weak enough that twenty-micrometre steps are stable: an
# absorber is an explicit term in the nonlinear scheme.
absorber=absorber(grid, boundary_length=1e3),
fwhm=30e-15, energy=1e-9, modes=3, n_save=6,
backend="numpy_ref", wall=wall, schedule=schedule,
dz_max=length / steps, dz_init=length / steps,
plasma=False, raman=False,
)
walled, plain = run(CoherentWall()), run(None)
print(walled.metadata.modal["flow"]["quasi_static"])
fig, ax = plt.subplots(figsize=(6.4, 3.8))
for handler, style, label in ((plain, ":", "wall=None"), (walled, "-", "CoherentWall()")):
planes = handler.planes()
z = np.array([plane.z for plane in planes]) * 1e6
energy = np.array(
[np.asarray(plane.stats["energy"]).reshape(3, -1)[:, 0] for plane in planes]
)
for channel in range(3):
ax.semilogy(
z, energy[:, channel] / energy[0, 0], style, color=f"C{channel}",
label=f"HE_1{channel + 1}, {label}",
)
ax.set_xlabel("z (um)")
ax.set_ylabel("fraction of the launched energy")
ax.set_ylim(1e-22, 3.0)
ax.legend(frameon=False, fontsize="small", ncol=2)
fig.tight_layout()
The coherent wall moves power between channels; the incoherent one cannot. Ten fixed steps of 20 micrometres of a 50 micrometre helium-filled bore, launched into HE_11 alone, with the fraction of the launched energy found in each channel at each save plane. The uncoupled route leaves 1e-18 and 1e-21 in the upper channels – the capillary’s Kerr overlap, and nothing else.#
How it is propagated#
The block is a coupled flow, so it cannot ride the cumulative diagonal operator the
rest of the package uses. The run is split instead: the shared scalar part of the flow —
the first channel’s own operator, in the one co-moving frame — stays in the cumulative
\(\Phi\), where it is integrated analytically and commutes with everything, and the modal
part becomes the linear.coupled_flow leaf of the kernel contract. Nothing may be split
between them that does not commute, and a channel-dependent diagonal does not, which is
why \(\Phi\) is built over \(N\) copies of one channel and carries no mode dependence at all.
The per-channel true-power weight is unaffected: it is a row of its own, and a saved
amplitude still carries each channel’s \(\sqrt{\mathrm{Re}\,n_{\rm eff}}\).
What the split leaves on the flow’s diagonal is \(k-\beta_{11}\), the block’s in-core
carrier \(k = n_{\rm core}\,\omega/c\) less the first channel’s propagation constant. The
two wavenumbers agree to five or six digits, so their difference would carry the
absolute rounding of \(k\), about \(5\times10^{-9}\) 1/m and up to \(5\times10^{-10}\) of the
difference itself; it is formed instead from the two refractivities,
\(\big((n_{\rm core}-1)-(n_{\rm eff}-1)\big)\,\omega/c\)
(core_index_minus_one(),
neff_minus_one()), which is exact to
\(10^{-14}\) of itself. On a guide that varies along z this is what lets each section’s
expansion resolve to its \(10^{-12}\) target
(SECTION_TOL): forced to degree 64, the qualification
tapers’ generators floor at \(4\times10^{-14}\), so a section still unresolved at the
highest degree is refused, never accepted with a larger error.
The reference backend integrates the section with a Schur matrix-function action,
\(U = Z\exp(\Delta z\,T)Z^{\rm H}\), once per stage fraction, and takes the inverse the
interaction picture needs as the matrix function of the negated exponent rather than as a
solve. Because the geometry is constant there is no explicit residual at all, and the
certificate the leaf carries — the Schur reconstruction residual and unitary defect
amplified by the departure from normality, and a bound
\(\exp(\Delta z_{\max}(\mu_{\max}-\mu_{\min}))\) on \(\|U\|\,\|U^{-1}\|\) — is checked
against the measured action at every step rather than trusted. A step whose
\(\|U\|\,\|U^{-1}\|\) exceeds condition_limit
(default \(10^4\), about nine e-foldings of attenuation of the lossiest retained channel in
one step) is refused, because the inverse action carries the interaction-picture source
back and an unbounded inverse would amplify it. Over ten steps the coupled route costs
about 1.4 times the uncoupled one for a single channel and about 1.8 times for eight.
wall_square_root_metric() builds the square-root model
\(c' = iBc\) and its candidate flux metric \(W = (B+B^{\rm H})/2k\) as a diagnostic. Its
identity \(d(c^{\rm H}Wc)/dz = -c^{\rm H}(M-M^{\rm H})c/2ik\) is exact algebra for a
constant real positive carrier and that is the whole of its domain: it does not establish
positivity of \(W\), and what a run propagates is the paraxial generator above, not the
square root.
Reading the record#
A run carrying a wall writes a flow entry on its modal
identity, so what the block was is recoverable from the file alone:
output.metadata.modal["flow"] is a mapping of the section method, the two identity
strings, the core radius, the three validity ratios and the three certificate rows
reduced to their extremes, every one of them a scalar or a string so that it can be part
of the key a resume is checked against. Four entries describe the sectioning itself:
entry |
what it says |
|---|---|
|
how many frozen sections the run was cut into — the operator table’s own intervals times |
|
the Chebyshev degree along z the generator resolved at, plus one. |
|
whether the flow carries the HE1m connection \(C\), which is |
|
\(0.9/(K\,|U||U^{-1}|)\) in m, the smallest over every section and every frequency the error norm scores (the residual is applied nowhere else), which is what a declared step is warned against. Infinite for a flow with an exactly zero residual |
The geometry the record names — the radius and the three ratios — is the worst the run reaches, scanned over every section edge and midpoint, not the entrance’s, so a resume across a changed taper is refused by the field that moved.
Printing the record shows the same thing on one flow row, which reads diagonal for
every run that carries no block and, for one that does,
mean impedance coherent wall, constant_schur, worst ratio 4.491e-02, conditioning 1.007e+00,
or, for the taper below, mean impedance coherent wall, frozen_residual, frozen_residual, frozen_residual, frozen_residual, worst ratio 5.613e-02, conditioning 1.013e+00.
The run’s capability is that of any modal run — a coupled flow through a basis does
not change the basis — so every ordinary modal accessor is admitted on such a file.
The worked examples run the wall over metres: a
pressure-gradient compressor, a
tapered capillary and
the vector wall.
The coherent wall of a capillary, against the incoherent one runs this section end to end: the loss
limit against its derived departure over a band of wavelengths, and the conversion
against the incoherent route. The same linear.coupled_flow leaf is what a declared
basis of a split block becomes on the general modal route
(general modal propagation).
A guide that varies along z#
A taper or a pressure gradient is carried too; a taper without a wall carries
the same connection (the taper coupling without a wall). The run is cut into
sections — the linear operator’s own intervals, which already land on
every declared corner of the radius and the fill, subdivided
sections times — and inside each one the
complete generator is frozen at the midpoint and integrated by the Schur
matrix-function action a constant bore uses (above). What the
freezing leaves out is put back exactly, as an explicit term in the Runge-Kutta source:
The connection. The last piece of \(R_s\) is the connection \(C\). The basis is attached to the core radius, so a taper moves it, and writing the field in the local basis at every \(z\) costs
with \(s_m = \operatorname{sgn}J_1(u_m)\), which is
\(\langle\psi_m|\partial_z\psi_n\rangle\) for
\(\psi_n = \sqrt2J_0(u_nr/a)/(a|J_1(u_n)|)\) and is checked that way, by differentiating
the profile the run actually declared rather than by trusting the formula. It is real
and antisymmetric in the true-power amplitude \(b_n = \sqrt{w_n}\,a_n\), where coupled
local-mode theory puts it, so the kernel stores \(\sqrt{w_n/w_m}\,C_{mn}\) for its own
amplitude and on its own it moves no power out of the retained set at all; what
it does is convert the launch between the channels, and the channels it converts
into are the lossier ones. That, and not a larger attenuation at a smaller radius, is
what a non-adiabatic taper costs: a 50 to 40 µm transition over a fifth of a millimetre
leaves 3.8 % of the energy in \(\mathrm{HE}_{12}\) and 3.3 % in \(\mathrm{HE}_{13}\), and
loses 1.9e-3 of it where the adiabatic route (taper_coupling=False), which cannot
convert at all, loses 6.4e-4.
Why the wall block is not re-weighted. The wall block’s own off-diagonal is not stored with the \(\sqrt{w_n/w_m}\) weights, and the difference is physics rather than convention: the condensed \(M = k^2I + L_N/a^2\) is the matrix of a reciprocal operator in a real orthonormal basis, so it and every function of it — the exact one-way generator \(i(\sqrt{M}-k)\) included, whatever the loss — are complex symmetric in the field amplitude, and the diagonal \(W^{\pm1/2}\) would break that symmetry. The re-weighting would change the off-diagonal by \((w_m-w_n)/(2w)\) of itself, 0.17 1/m of 148 at 3.2 µm on a 50 µm bore, which is 2.4 times smaller than the paraxial reduction’s own symmetric departure from the exact square root: below the model’s approximation, and in the wrong symmetry class.
Freezing is exact. Because \(R_s\) is evaluated exactly, freezing is a rearrangement
of the same equation and not an approximation: one frozen block per interval and four of
them give answers
1.85e-10 apart on that case. What sections buys is a smaller explicit term, and the
flow records the bound on it, together with the advisory step
\(0.9/(K\,\|U\|\|U^{-1}\|)\), with \(K\) the bound on the norm of the explicit term,
\(\|U\|\|U^{-1}\|\) the conditioning of the frozen section’s propagator, and 0.9 a margin
below the Runge-Kutta scheme’s stability radius on the imaginary axis. A declared
dz_init, dz_max or fixed schedule above that limit is warned about by name, with
the term, the section, the frequency and the numbers, and nothing is changed behind your
back. The limit is advisory, but a fixed schedule several times above it on a long
taper can go unstable, so heed the warning.
The residual acts only where the error norm looks. It is applied on the bins the
adaptive error norm scores, the requested wavelength band (inband_mask), and not in
the spectral collar around it, where the wall block is still evaluated but the
controller never looks. The residual can be largest there — 7.2e3 1/m at 5.85 µm at the
end of a 20 cm section of a 100 to 60 µm taper, against 3.8e3 in band — and at the steps
the in-band error allows, |R| dz of about 4, the embedded Runge-Kutta pair would be
unstable on it with nothing to see the growth (it would show as a spurious HE_14
transient near the taper’s corner). In the collar the frozen flow alone carries the
state, exact at any step: each section’s midpoint block, without the variation across
the section and without the taper connection. There freezing is an approximation, but
one no result depends on. The flow is diagonal in frequency and the controller and the
hard mask act per bin, so an in-band bin reads the collar only through the nonlinear
source and the temporal absorber, and the in-band answer moves by at most the product of
that coupling, the dropped term and the collar’s share of the field: below 1e-18 of the
in-band field on the tapers tested, with a measured effect of 6e-20.
Every channel keeps its own normalisation. The cumulative \(\Phi\) must be a multiple of the identity in the channel axis, so the term that renormalises a vacuum amplitude along a varying index, \(-\tfrac12\ln[w(z)/w(0)]\) with \(w = \operatorname{Re} n_\text{eff}\), is the lowest channel’s there. The rest of each channel’s own is carried in the flow’s diagonal, beside the block:
real, zero for \(\mathrm{HE}_{11}\) and zero wherever the guide does not vary, with the
slope \(w_n' = \operatorname{Re}\,dn_\text{eff}/dz\) taken in closed form through the
radius and the fill (dneff_dz()), never
by a difference. It is part of the generator, so the frozen block, the explicit residual
and CF4 all carry it. On the 50 to 40 µm taper below, leaving it out — giving every
channel the lowest channel’s normalisation — moves \(\mathrm{HE}_{12}\) and
\(\mathrm{HE}_{13}\) relative to \(\mathrm{HE}_{11}\) by \(9\times10^{-6}\) and
\(3\times10^{-5}\) at 800 nm and by \(1.5\times10^{-4}\) and \(5.5\times10^{-4}\) at the
edge of the active band; with it they agree with an independent solve in the true-power
amplitude, which has no normalisation term at all, to the step error: \(1.5\times10^{-8}\)
at ten steps, falling at fifth order. The passivity guard of the varying route allows
each channel its own term, and nothing more.
What the profile and the schedule must do. The profile must have an analytic
derivative — a FromCallable without a slope is refused,
because a difference of samples is noise and not a slope — and every channel must
declare the
same radius and density profiles, compared by their structural identity rather than by
their value at the entrance, so that two tapers agreeing only at \(z = 0\) are named
instead of silently propagating the first one.
A fixed schedule must land on every corner of the profile exactly. The sections
are placed on those corners, and the section a stage reads is matched to a tolerance of
\(10^{-10}\) of the run; a schedule edge that comes near a corner without landing on it
anchors its step on the closing side and then integrates past the corner against a
frozen block its own stages do not read, which costs the run its order. Such a
schedule is refused by name; FixedSchedule.uniform lands on every corner, and
adaptive stepping needs no rule at all, because the driver lands on the hard kinks
itself.
A taper, measured. Here is a taper with the wall, on the same ten steps as the
straight bore, against the adiabatic route (taper_coupling=False, no wall). The launch
is into \(\mathrm{HE}_{11}\) alone in both, so everything in the upper two channels is the
connection’s doing: the uncoupled route leaves \(10^{-18}\) there
and a walled straight bore \(10^{-6}\).
import warnings
import matplotlib.pyplot as plt
import numpy as np
from solprop.boundaries import absorber
from solprop.driver import prop_capillary
from solprop.grid import FieldGrid
from solprop.modes import CoherentWall
from solprop.profiles import PiecewiseLinear
from solprop.stepper import FixedSchedule
length, steps = 2e-4, 10
# Straight, transition, straight, at a radius and length ten fixed steps resolve.
taper = PiecewiseLinear.from_stages(
(0.0, 6e-5, 1.4e-4, 2e-4), [50e-6, 50e-6, 40e-6, 40e-6]
)
grid = FieldGrid.from_limits(length, 800e-9, (400e-9, 1.6e-6), 300e-15)
schedule = FixedSchedule(np.full(steps, length / steps))
def run(wall):
# The band runs past helium's and silica's fitted ranges and this is a
# marginal geometry on purpose, so both say so. The comparison drawn is the
# adiabatic route, the declared opt-out taper_coupling=False.
with warnings.catch_warnings():
warnings.simplefilter("ignore")
return prop_capillary(
taper, length, "He", 1.0, lambda0=800e-9, grid=grid,
# A collar weak enough that twenty-micrometre steps are stable: an
# absorber is an explicit term in the nonlinear scheme.
absorber=absorber(grid, boundary_length=1e3),
fwhm=30e-15, energy=1e-9, modes=3, n_save=6,
backend="numpy_ref", wall=wall, schedule=schedule,
dz_max=length / steps, dz_init=length / steps,
plasma=False, raman=False, taper_coupling=wall is not None,
)
walled, plain = run(CoherentWall()), run(None)
record = walled.metadata.modal["flow"]
print(record["n_sections"], record["n_deg"], record["connection"])
fig, ax = plt.subplots(figsize=(6.4, 3.8))
for handler, style, label in ((plain, ":", "adiabatic"), (walled, "-", "CoherentWall()")):
planes = handler.planes()
z = np.array([plane.z for plane in planes]) * 1e6
energy = np.array(
[np.asarray(plane.stats["energy"]).reshape(3, -1)[:, 0] for plane in planes]
)
for channel in range(3):
ax.semilogy(
z, energy[:, channel] / energy[0, 0], style, color=f"C{channel}",
label=f"HE_1{channel + 1}, {label}",
)
ax.set_xlabel("z (um)")
ax.set_ylabel("fraction of the launched energy")
ax.set_ylim(1e-22, 3.0)
ax.legend(frameon=False, fontsize="small", ncol=2)
fig.tight_layout()
A taper converts between channels, and the conversion is what it costs. Ten fixed steps of 20 micrometres of a helium-filled bore running 50 micrometres, tapering to 40 over 80 micrometres and holding, launched into HE_11 alone, with the fraction of the launched energy in each channel at each save plane. The walled taper loses 1.9e-3 of the launched energy where the uncoupled one loses 6.4e-4, and the difference is the 7 % converted into the lossier orders.#
Mode conversion and loss in a taper with a coherent wall runs this taper beside the two straight bores it joins, reads the sectioning off the record, and shows that four frozen sections per interval and one give the same answer to \(2\times10^{-10}\).
Integrating the varying section by CF4. CoherentWall(method="cf4") integrates each
section’s whole generator by the fourth-order commutator-free exponential instead of
freezing it: no explicit residual, so no advisory step, but a flow error the step
controller cannot see and a run that is at most fourth order. It is admitted only where
its internal factors provably cannot exceed \(\exp(h\,m_{\rm hi})\) — the largest norm
the exact flow could have over a step \(h\), with \(m_{\rm hi}\) the largest eigenvalue of
the generator’s Hermitian part — by more than growth_limit (default 2); a rejected
section is refused
by name, or integrated by the frozen split where fallback="frozen_residual" says so.
That guard bounds growth, not accuracy: a lossless section passes it at any step, and
only the step-doubling defect estimate, on which validate warns against rtol, says
whether a step is small enough. On every taper measured a CF4 step costs 3.3 times a
frozen one and is the more accurate only for steps beyond the frozen split’s advisory
limit, so the default frozen split is the better choice at equal cost. CF4 is refused
on a guide that does not vary, whose one section the constant-Schur action already
integrates exactly; beside impedance="te_tm" it is admitted, because it integrates the
stored expansion and reads nothing of the block that built it — measured fourth order,
3.996 and 3.999, on the vector taper below.