Repository navigation
Conversation
- 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>
There was a problem hiding this comment.
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
Boundariesmodule 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, andprop_gnlsewith 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.
|
|
||
| The absorber reference length in metres. `ℓ` wins if given; otherwise `zmax/N`. | ||
| """ | ||
| reflength(grid, N, ℓ) = isnothing(ℓ) ? grid.zmax/N : float(ℓ) |
There was a problem hiding this comment.
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] | |||
There was a problem hiding this comment.
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 theprop_capillary/prop_gnlsedocstrings had nothing to resolve to —rungained 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.
|
|
||
| # 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, |
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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.
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>
|
Pushed six commits addressing the review. Replies on the individual threads; summary and the two open points here.
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 windowsMy suggestion is to merge this first and add them in #416, for three reasons. Nothing regresses by waiting:
Two things worth deciding before mergeThe walk-off diagnostic is too noisy. It fires during Two behaviour changes reviewers should see stated. Validation
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 One unrelated thing found on the way, which I have not touched: an interrupted queue scan leaves entries marked "in progress", |
`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>
|
Fixed the walk-off diagnostic in c4d099d, since it was going to be a nuisance as written. The problem: 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. Behaviour on the two cases that matter:
|
Rate-based absorbing boundaries
The problem
Luna's absorbing boundaries were applied by multiplying the solution by a fixed profile
Wafter every accepted step. The cumulative absorption was therefore
W^N, whereNiswhatever number of steps the adaptive controller happened to take — a number derived from
rtol. Two consequences:rtoldid not approach a limit, itincreased the absorption.
W^Nis a brick wall atthe 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
Lattenuates by
exp(-αL)however that path is subdivided. A newBoundariesmodule turnsthe existing apodisation profiles into rates,
α = -log(W)/ℓ, and the two rates are appliedby whichever mechanism can do it exactly:
α(ω)is diagonal in ω, so it is folded into the linear operator. The interaction-picturepropagator then applies it exactly, over whatever sub-interval the stepper chooses, at no
extra cost and consistently with dense output.
α(t)is applied as anexp(-α Δz)split-step factor. Those factors telescope, so thetotal is again a rate.
The hard band limit outside
grid.sidxstays a per-step multiply — it is a 0/1 mask, so itis idempotent and never had the problem.
Two smaller fixes ride along: the temporal collar is guaranteed non-degenerate (for a
trangethat lands on a power of two,grid.twinis identically 1 except for a singlesample, i.e. those grids had no temporal absorber at all), and
Luna.runwarns if theabsorber 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_capillaryandprop_gnlse:boundary:rate:rate,:legacy(reproduces the old behaviour bit-identically) or:noneboundary_N20boundary_lengthℓin metres, overridingboundary_Ntcollar0.05The 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,:legacybit-identity), two end-to-end cases:
rtolfrom 1e-6 to 1e-9moves the answer by 1e-6 under
:rateand 2e-4 under:legacy.the absorber is inert until the pulse arrives, then removes it, and what
:nonekeepsinstead is wraparound aliased into the front of the window. At fixed
rtolwith 3.6× thestep count, the surviving DUV energy moves 0.9% under
:rateand 33% under:legacy.Plots from that second example:
Green shows aliasing, both temporal filters attenuate properly:
The temporal filter attenuation removes the waling off RDW smoothly:
Cost: none. Like for like,
:ratetakes 1488 solver steps against:legacy's 1504, atthe same wall time. The
max_dz ≤ ℓcap that:rateimposes never binds in practice — theerror controller is already taking far smaller steps.