Skip to content

Apodization windows - #438

Open
jtravs wants to merge 13 commits into
LupoLab:masterfrom
jtravs:apodization-window
Open

jtravs wants to merge 13 commits into
LupoLab:masterfrom
jtravs:apodization-window

Conversation

@jtravs

@jtravs jtravs commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Rate-based absorbing boundaries

The problem

Luna's absorbing boundaries were applied by multiplying the solution by a fixed profile W
after every accepted step. The cumulative absorption was therefore W^N, where N is
whatever number of steps the adaptive controller happened to take — a number derived from
rtol. Two consequences:

  • The answer depended on the tolerance. Tightening rtol did not approach a limit, it
    increased the absorption.
  • The graded taper was not a taper. At realistic step counts W^N is a brick wall at
    the edge of the flat region, which eroded genuine spectral wings and truncated pulses that
    drifted into the temporal collar.

The change

An absorbing boundary is now an absorption rate per unit distance, so traversing L
attenuates by exp(-αL) however that path is subdivided. A new Boundaries module turns
the existing apodisation profiles into rates, α = -log(W)/ℓ, and the two rates are applied
by whichever mechanism can do it exactly:

  • α(ω) is diagonal in ω, so it is folded into the linear operator. The interaction-picture
    propagator then applies it exactly, over whatever sub-interval the stepper chooses, at no
    extra cost and consistently with dense output.
  • α(t) is applied as an exp(-α Δz) split-step factor. Those factors telescope, so the
    total is again a rate.

The hard band limit outside grid.sidx stays a per-step multiply — it is a 0/1 mask, so it
is idempotent and never had the problem.

Two smaller fixes ride along: the temporal collar is guaranteed non-degenerate (for a
trange that lands on a power of two, grid.twin is identically 1 except for a single
sample, i.e. those grids had no temporal absorber at all), and Luna.run warns if the
absorber is too weak for the group-delay walk-off the simulation can produce.

What changes for users

Results will differ from previous versions wherever energy actually reached the edge of a
window
— soliton self-compression, MI, a dispersive wave walking out of the time window.
They should now be converged with respect to rtol, which they were not before.

New keyword arguments on Luna.run, prop_capillary and prop_gnlse:

argument default meaning
boundary :rate :rate, :legacy (reproduces the old behaviour bit-identically) or :none
boundary_N 20 absorber strength: applications of the profile over the whole propagation
boundary_length — reference length ℓ in metres, overriding boundary_N
tcollar 0.05 minimum temporal collar width, as a fraction of the time window

The chosen mode is recorded in the output, so saved runs stay reproducible.

Validation

test/test_boundaries.jl — 82 assertions in 8 testsets, ~12 s. Beyond the unit-level checks
(rate arithmetic, collar geometry, addloss, exactness of the propagator, :legacy
bit-identity), two end-to-end cases:

  • Spectral, a strongly self-broadening capillary run: changing rtol from 1e-6 to 1e-9
    moves the answer by 1e-6 under :rate and 2e-4 under :legacy.
  • Temporal walk-off, a 230 nm dispersive wave leaving a 171 fs time window at ~100 fs/m:
    the absorber is inert until the pulse arrives, then removes it, and what :none keeps
    instead is wraparound aliased into the front of the window. At fixed rtol with 3.6× the
    step count, the surviving DUV energy moves 0.9% under :rate and 33% under :legacy.

Plots from that second example:
Green shows aliasing, both temporal filters attenuate properly:

image

The temporal filter attenuation removes the waling off RDW smoothly:

image

Cost: none. Like for like, :rate takes 1488 solver steps against :legacy's 1504, at
the same wall time. The max_dz ≤ ℓ cap that :rate imposes never binds in practice — the
error controller is already taking far smaller steps.

jtravs and others added 3 commits August 15, 2026 23:27
- Introduced a new Boundaries module to handle absorbing boundaries with rate semantics, improving the accuracy of energy absorption during propagation.
- Updated the Interface module to include new boundary options in prop_capillary and prop_gnlse functions, allowing users to specify boundary behavior.
- Enhanced the run function in Luna to apply absorbing boundaries based on user-defined parameters, ensuring consistent absorption regardless of solver step count.
- Implemented tests for the new boundary functionality, verifying the correctness of absorption rates and ensuring that results are independent of the number of solver steps.
- Added documentation for the Boundaries module to explain the rationale and usage of the new absorbing boundary features.
Nothing in test_boundaries.jl exercised the absorber in time: the only
temporal testset runs with a zero linear operator, so the field never
moves and never reaches the collar.

Add a "temporal walk-off" testset built on the case where this bites in
practice -- a deep-UV resonant dispersive wave in a deliberately small
time window, walking away from the pump at ~100 fs/m and leaving the
171 fs window halfway along the fibre. It asserts that the absorber is
inert until the pulse arrives, that it then removes it, that what
:none keeps instead is wraparound at the front of the window, and that
the absorbed fraction is step-count independent for :rate but not for
:legacy (0.9% vs 33% for the same rtol and 3.6x the steps).

Uses plasma=false, which halves the cost and drops the PPT-cache
dependency without changing the walk-off physics. 9 assertions, 3.3 s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 19, 2026 15:10

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates Luna’s absorbing boundaries to use rate semantics (absorption per unit propagation distance) so boundary attenuation converges with respect to solver tolerance (rtol) and no longer depends on the adaptive step count.

Changes:

  • Introduces a new Boundaries module that converts historical apodization windows into absorption rates and applies them via the linear operator (spectral) and split-step factors (temporal).
  • Extends Luna.run, prop_capillary, and prop_gnlse with new boundary-related keyword arguments (boundary, boundary_N, boundary_length, tcollar) and records the chosen boundary mode in outputs.
  • Adds documentation and a comprehensive new test suite validating rate semantics, legacy bit-identity, and tolerance/step-count independence.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
test/test_boundaries.jl Adds unit + end-to-end tests covering rate semantics, legacy reproduction, and walk-off behavior.
test/runtests.jl Registers the new Boundaries testset in the test runner.
src/Luna.jl Adds Boundaries integration into run, including new boundary keywords, logging, and application mechanisms.
src/Interface.jl Plumbs boundary keyword arguments through prop_capillary* and prop_gnlse* and records them via saveargs.
src/Boundaries.jl New module implementing rate conversion, loss injection into linops, hard mask handling, and walk-off diagnostics.
docs/src/modules/Boundaries.md Adds autodocs page for the new Boundaries module.
docs/src/model/model.md Documents absorbing boundary rationale, semantics, and the legacy behavior change.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/Boundaries.jl Outdated

The absorber reference length in metres. `ℓ` wins if given; otherwise `zmax/N`.
"""
reflength(grid, N, ℓ) = isnothing(ℓ) ? grid.zmax/N : float(ℓ)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, and it is worse than described. Fixed in 377a0be.

boundary_length=0 gives -log(1)/0 in the interior, so 2636 of 4097 points on a typical grid come back NaN and the rest Inf. A negative length is nastier still: clamp(x, 0.0, -600.0) returns -600.0 for every element, so the absorber becomes a gain and applies over the whole grid rather than just the collar. boundary_N=0 gives an infinite reference length, i.e. silently no absorber at all.

reflength now requires both to be finite and positive, which covers every call site. boundary=:none remains the way to switch the absorbers off.

@@ -0,0 +1,4 @@
# Boundaries.jl
```@autodocs
Modules = [Boundaries]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and it was already breaking the build — the Documentation check on this PR was red. Fixed in d507c3c by adding Boundaries to the export list, which is what every other module with a docs page relies on.

There were four more errors in the same build, all introduced here:

  • [`Luna.run`](@ref) in the model docs and in the prop_capillary/prop_gnlse docstrings had nothing to resolve to — run gained a docstring but no page included it. There is now a page for the top-level module.
  • [`Grid`](@ref Grid) and [`NonlinearRHS`](@ref NonlinearRHS) pointed at modules with no module-level docstring, so there was no binding to link to. Both now have a short one.

Docs build verified locally: no errors, and the only remaining warning (an unescaped $HOME in an Ionisation docstring) predates this PR.

Comment thread test/test_boundaries.jl

# the chosen mode is recorded, so a saved run can be reproduced
out = Logging.with_logger(Logging.NullLogger()) do
prop_capillary(125e-6, 0.05, :He, 1.0; λ0=800e-9, energy=1e-6, τfwhm=10e-15,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right that the file is not self-contained, though it does not throw under runtests.jl — test_ionisation.jl and test_scans.jl are included earlier and both do using Luna, which puts the name in Main before this file runs. It only fails when the file is run on its own, which is exactly how I hit it. It was also the only test file calling prop_capillary without using Luna.

Added in 377a0be.

@chrisbrahms chrisbrahms left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While we're at it, we should also implement the spatial apodisation for free-space simulations (real-space/k-space). That may then need to be overhauled in #416 (the linops change shape), so alternatively we merge this first and I add it to that PR.

Comment thread src/Boundaries.jl Resolved
Comment thread src/Boundaries.jl Resolved
Comment thread src/Boundaries.jl Resolved
Comment thread src/Luna.jl Resolved
Comment thread src/Luna.jl Resolved
Comment thread src/Luna.jl Resolved
Comment thread src/Luna.jl Resolved
Comment thread src/Luna.jl Resolved
jtravs and others added 6 commits August 30, 2026 20:15
The docs build fails on this branch (master is green). Five distinct
errors, all introduced here:

- `Modules = [Boundaries]` in the new autodocs page could not be
  evaluated: `Boundaries` was missing from Luna's export list, so it
  does not resolve in `Main` where Documenter evaluates the block. Every
  other module with a docs page is exported.
- `[`Luna.run`](@ref)`, added to the model docs and to the
  `prop_capillary`/`prop_gnlse` docstrings, had no page to resolve to --
  `Luna.run` gained a docstring but nothing included it. Add a page for
  the top-level module which documents it.
- `[`Grid`](@ref Grid)` and `[`NonlinearRHS`](@ref NonlinearRHS)` in the
  model docs pointed at modules with no module-level docstring, so there
  was no binding to link to. Give both a short one.

Cross-references inside a module docstring resolve with `CurrentModule`
set to that module, so the two new docstrings refer to
`@ref Luna.Boundaries` rather than the bare name.

Verified by building the docs locally: no errors, and the only remaining
warning (an unescaped $HOME in an `Ionisation` docstring) is pre-existing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`reflength` took `boundary_N` and `boundary_length` on trust. Neither is
merely useless when non-positive, both are silently destructive:

- `boundary_length=0` makes `rate` evaluate -log(1)/0 in the interior, so
  the profile comes back NaN wherever the window is flat -- 2636 of 4097
  points on a typical grid -- and Inf everywhere else.
- `boundary_length<0` inverts the clamp bounds. `clamp(x, 0.0, -600.0)`
  returns -600.0 for every element, so the absorber becomes a gain, and
  applies over the whole grid rather than just the collar.
- `boundary_N=0` gives an infinite reference length, i.e. no absorber at
  all, with no indication that the setting did nothing.

Validate both in `reflength`, which every call site goes through.
`boundary=:none` remains the way to switch the absorbers off.

Also add `using Luna` to test_boundaries.jl, which called `prop_capillary`
unqualified while importing only submodules. It passes under runtests.jl
only because test_ionisation.jl and test_scans.jl happen to be included
first and do bring the name into scope; run on its own the file threw
UndefVarError. It is the only test file calling prop_capillary without
`using Luna`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Luna's convention is that alpha is a *power* attenuation coefficient and
a linear operator carries -alpha/2 -- see `Modes.alpha` feeding
`LinearOps.make_const_linop`. `Boundaries` used the same symbol for a
field-amplitude rate and subtracted it from the linop whole, so the two
meanings collided in the one place they meet.

Redefine the coefficient as a power one, alpha = -2 log(W)/l, and have
`addloss` subtract alpha/2. The relation to the historical profiles is
unchanged and still stated in terms of the field, since that is what they
multiplied: over one reference length the field is attenuated by exactly
W, i.e. exp(-alpha*l/2) == W. `boundary_N` and `boundary_length` keep
their meaning.

The temporal factor in `Luna.run` and the walk-off diagnostic both act on
the field, so both gain the same factor of 2, and `MAX_alpha_l` becomes
60 so the floor on the profile stays exp(-30).

This is a pure change of convention: every factor of 2 introduced is
cancelled by one already there, and the scalings are exact in binary, so
the result is bit-identical. Checked on the walk-off case -- the final
field hashes to a26bdbac before and after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Boundaries.ωmask` existed to mop up after one transform. `TransModeAvg`
normalises before windowing and `norm_mode_average` skips everything
outside `grid.sidx` -- it has to, because β is only filled in-band and the
normalisation divides by it -- so the out-of-band part of `nl` was left as
the raw, unnormalised transform of the polarisation. The linear operator
is zero there, so nothing removed it and every step of `run` had to
multiply the field by a 0/1 mask.

No other transform has this problem: `TransModal` windows before it
normalises, and the radial and free-space norms return 1.0 out of band, so
in all three cases `ωwin` (exactly zero outside `sidx`) already zeroes it.

So fix it where it happens: zero `nl` out of band in `norm_mode_average`
and `norm_mode_average_gnlse`, which also lets `TransModeAvg` window the
whole array like the others rather than looping with a `continue`. With
`nl` zero out of band and the linop zero there, nothing can put anything
back, so `ωmask` and both per-step multiplies go, replaced by a single
band limit on the field at the start of `run`.

`:legacy` is excluded from that initial band limit so it stays a
bit-identical reproduction of the historical scheme; its own per-step
`ωwin` multiply does the same job from the first step.

Removing the mask also removes its non-commutation with the temporal
factor, so the residual splitting error in the temporal absorber is now
only against the spectral absorber on the propagator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Setting up the boundaries was an `if` expression whose *value* was the
stepfun but which also reassigned `max_dz`, `init_dz` and `linop` in the
enclosing scope, so `run` had to be read for side effects as well as for
its result.

Move it into `Boundaries.setup`, which returns
`(; stepfun, linop, max_dz, init_dz, l)` -- the three it changes are
handed back rather than mutated from inside a branch, and why each one
changes is documented on `setup` instead of in a comment halfway down
`run`.

The stepfuns become functors: `RateAbsorber`, `LegacyAbsorber`,
`NoAbsorber`. That gives `setup` one return type per mode, names the state
each carries instead of capturing it, and means the spatial windows a
free-space simulation wants become another struct rather than another
branch inside `run`. `linop_array` and the setup log line move across with
them, since nothing else uses either.

The reference length is now only computed for `:rate`, so a stray
`boundary_N` no longer gets validated in a mode which never looks at it.

Pure restructuring: the walk-off case hashes to e019a5fd before and after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things, all wording.

The fifteen-line comment block on MAX_alpha_l explained why `run` caps
`max_dz` -- that belongs where someone hitting the "Reducing max_dz" log
line will find it, not hidden above a constant. It becomes a "Step size"
section of the module docstring, which is what the Boundaries docs page
renders, and the constant keeps a two-line docstring saying what it caps.

Plainer language where the jargon was doing no work: "first-order Lie
splitting, but the commutator is supported only inside the collar" now
says the two parts do not commute, the error is first order in the step
size, and it is confined to the collar.

"never converged" was too strong, and a reviewer would rightly pick at it.
The old scheme does have a limit as rtol tightens -- it is just that the
limit is a hard truncation at the edge of the flat region rather than the
taper the profile describes. Say that instead, in both the module
docstring and the model docs.

The model docs also still described the coefficient as a field rate; bring
them in line with the power convention and its factor of 2, and note the
max_dz cap, which is user-visible.

Docs build clean, tests pass, walk-off case still hashes to e019a5fd.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jtravs

jtravs commented Aug 30, 2026

Copy link
Copy Markdown
Contributor Author

Pushed six commits addressing the review. Replies on the individual threads; summary and the two open points here.

d507c3c Fix the documentation build
377a0be Reject a non-positive absorber reference length
dcac64e Make the absorber alpha a power coefficient, as elsewhere in Luna
9f42280 Band-limit the nonlinear polarisation in TransModeAvg, not the field
80195a2 Make the absorbing boundaries functors behind one setup call
539b04d Move the step-size rationale into the docs, and plainer prose

The Documentation check was already red before these — five errors, all introduced by this PR, of which Copilot spotted one. That should be green now; verified by building the docs locally.

On the spatial windows

My suggestion is to merge this first and add them in #416, for three reasons. Nothing regresses by waiting: Grid.FreeGrid builds an xywin taper today and nothing in the codebase uses it, so free-space has no spatial absorbing boundary at all right now — this is a new boundary rather than one that stops working. #416 changes the linop shapes, so building against the current ones means building it twice. And this PR is already large with a scope that ends cleanly at omega and t.

80195a2 should make it cheap on your side either way: the stepfuns are now RateAbsorber/LegacyAbsorber/NoAbsorber structs behind Boundaries.setup, so a spatial absorber is another struct rather than another branch in run. Worth flagging that the same two-mechanism split applies — a k-space window is diagonal in k and can ride the propagator like alpha_omega, while a real-space x/y window cannot and needs the split-step treatment like alpha_t. addloss already broadcasts over (Nomega, Nky, Nkx).

Two things worth deciding before merge

The walk-off diagnostic is too noisy. It fires during test_gnlse.jl, and not spuriously — with lambda limits out to 8 um, band-edge components walk 525 ps (and 24 ns in the second test) over fibres with 4 ps and 13 ps windows. But there is only noise out there. It takes the 95th percentile of group delay across the whole band, so on any broadband grid it reports the dispersive edge rather than where the energy actually is, and as written it will train people to ignore it. Options: weight by the spectrum (setup has the field), or only warn if the component actually reaches the boundary within zmax. I have left it alone since it was not part of the review.

Two behaviour changes reviewers should see stated. :none no longer applies a per-step hard mask — after the TransModeAvg fix there is nothing to mask, so it is now genuinely nothing beyond one band limit at the start. And :legacy is deliberately excluded from that initial band limit, or it stops being a bit-identical reproduction of the historical scheme; the existing test caught this when I first applied it unconditionally. Separately, mode-averaged output now moves in the last digits (1.1e-8 relative, three orders below rtol) because out-of-band goes from ~1e-30 dust to exactly zero and weaknorm sums over every element.

Validation

test/test_boundaries.jl is now 92 assertions. Full suite passes: 4427 tests, with the pre-existing 12 @test_broken in PhysData.

For the two changes claimed to be behaviour-neutral I checked rather than asserted: a real DUV walk-off run hashes identically before and after dcac64e and before and after 80195a2.

One unrelated thing found on the way, which I have not touched: an interrupted queue scan leaves entries marked "in progress", all(qdata .> 1) is then false so the queue file is never removed, and every subsequent run exits having done nothing, silently, until someone deletes it by hand. Bad for cluster jobs killed by a time limit, and CI never sees it because that testset is skipped on GitHub Actions. Probably its own issue.

@jtravs
jtravs marked this pull request as draft August 30, 2026 19:27
`walkoff_check` predicted, from the linear operator, whether a component
could cross the collar faster than the absorber could kill it. It fired
during test_gnlse.jl, and not spuriously: with a band running to 8 um the
edge components walk 525 ps over a fibre with a 4 ps window. But there is
nothing out there except shot noise.

That is the flaw. The check took the 95th percentile of group delay across
the whole simulation band, which on any broadband grid is the dispersive
edge rather than where the light is, so it warned on ordinary runs. A
warning that is usually wrong is one people stop reading. It could not
easily be made energy-aware either: at setup time the only spectrum
available is the input pulse, and the light that walks off is generated
during propagation.

So measure instead. `RateAbsorber` already multiplies the collar every
step; doing that in an explicit loop rather than a whole-array broadcast
gives the energy it removes for free, and does less work than before since
only the collar can change. It warns once, when the running total passes a
thousandth of the pulse.

This fires when something real is being absorbed and stays quiet
otherwise: on the DUV walk-off case it warns at z = 0.61 m having removed
0.11%, well before the wave's centre reaches the flat edge at ~1.0 m, and
the same fibre with a wide time window is silent. test_gnlse.jl is now
silent too.

`linop_array` goes with it, having had no other caller, and `setup` no
longer needs the field.

Bit-identical: the walk-off case still hashes to e019a5fd.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jtravs

jtravs commented Aug 30, 2026

Copy link
Copy Markdown
Contributor Author

Fixed the walk-off diagnostic in c4d099d, since it was going to be a nuisance as written.

The problem: walkoff_check predicted from the linear operator whether a component could cross the collar faster than the absorber could kill it. It fired during test_gnlse.jl, and not spuriously — with a band running to 8 um the edge components walk 525 ps over a fibre with a 4 ps window. But there is nothing out there except shot noise. It took the 95th percentile of group delay across the whole simulation band, which on any broadband grid is the dispersive edge rather than where the light actually is.

I could not see a way to make the prediction energy-aware: at setup time the only spectrum available is the input pulse, and the light that walks off is generated during propagation.

So it now measures instead. RateAbsorber already multiplies the collar every step, so doing that in an explicit loop rather than a whole-array broadcast yields the energy removed for free — and does slightly less work than before, since only the collar can change. It warns once, when the running total passes a thousandth of the pulse.

Behaviour on the two cases that matter:

  • DUV walking off a 171 fs window: warns at z = 0.61 m having removed 0.11%, which is well before the wave centre reaches the flat edge at ~1.0 m. So it is still an early warning, not a post-mortem.
  • Same fibre, 1 ps window, nothing reaching the boundary: silent.
  • test_gnlse.jl: silent.

linop_array went with it (no other caller) and setup no longer needs the field. Bit-identical — the walk-off case still hashes to e019a5fd. Tests cover both directions: warns exactly once on the 2 m run, silent on a 0.3 m run of the same fibre that stops before the wave gets there.

test_boundaries.jl is now 95 assertions. Also checked multimode, radial, full_freespace and polarisation, since the loop indexes the leading axis and had to stay correct for modal and transverse field shapes.

@jtravs
jtravs marked this pull request as ready for review August 31, 2026 08:39

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants