diff --git a/docs/_toc.yml b/docs/_toc.yml index 46c7c6cd..1e144a44 100644 --- a/docs/_toc.yml +++ b/docs/_toc.yml @@ -113,6 +113,8 @@ subtrees: - file: guide/fdtd/general-fdtd - file: guide/fdtd/model - file: guide/fdtd/time-domain + - file: guide/fdtd/waveguide-modes + - file: guide/fdtd/gaussian-beam - file: reference/index title: API reference subtrees: @@ -252,6 +254,10 @@ subtrees: - file: validation/fdtd/fd-public-numerics-refusals - file: validation/fdtd/fd-subpixel-occupancy - file: validation/fdtd/fd-two-debye-film + - file: validation/fdtd/fd-waveguide-modes + - file: validation/fdtd/fd-waveguide-through-pml + - file: validation/fdtd/fd-mode-source + - file: validation/fdtd/fd-gaussian-beam - file: validation/fdtd/fdtd-adjoint-validation - file: validation/fdtd/fdtd-bragg-cavity - file: validation/fdtd/fdtd-circle-gradients diff --git a/docs/design/unified-api.md b/docs/design/unified-api.md index 6a7f62a3..3b6c1c9a 100644 --- a/docs/design/unified-api.md +++ b/docs/design/unified-api.md @@ -368,7 +368,7 @@ claim them without changing the adapter and geometry accounting. The standard FDTD problem can request time records, fields, flux, diffraction, far fields, and final fields. Only implemented observables are public stable -exports. GaussianBeam, mode sources/monitors, decay termination, GPU support, +exports. Decay termination, GPU support, and nonuniform grids must not appear as supported merely because constructors or keywords have been reserved. @@ -1194,7 +1194,10 @@ not extend that scope. | `rcwa.operators` and `rcwa.experimental` (§5) | implemented, as submodules imported explicitly | `src/wavebit/rcwa/operators.py`, `src/wavebit/rcwa/experimental/` | | Detuning as explicit, recorded policy (§8) | implemented: `rcwa.Numerics(detuning=None)` is the default and solves the requested point | `src/wavebit/rcwa/numerics.py` | | `fdtd.Problem`, `Scene`, `Domain`, `Boundaries`, `Structure`, `PlaneWave`, `GaussianPulse`, `Outputs`, `Record`, `Numerics`, `UniformGrid`, `Duration`, `PML`, with the PML outside the physical domain (§6) | implemented; CPU and double precision only (§13) | `src/wavebit/fdtd/` | -| FDTD `GaussianBeam`, mode sources and monitors, decay termination, nonuniform grids, GPU (§6) | not implemented, and not exposed | -- | +| FDTD decay termination, nonuniform grids, GPU (§6) | not implemented, and not exposed | -- | +| FDTD discrete waveguide modes (`solve_modes`) and `ModeMonitor` (stage 7) | implemented | [validation](../validation/fdtd/fd-waveguide-modes.md) | +| FDTD `ModeSource`, a one-way mode launch (stage 7) | implemented | [validation](../validation/fdtd/fd-mode-source.md) | +| FDTD `GaussianBeam`, a focused beam launched one way (stage 7) | implemented | [validation](../validation/fdtd/fd-gaussian-beam.md) | | `fdtd.Numerics.geometry_ranges` (§7) | implemented | `src/wavebit/fdtd/_numerics.py` | | Host-only topology decisions rejected under an external transform (§7) | implemented: fixed-cell Cartesian Li rectangle sizes/centres use an array-valued edge-order guard under JIT/vmap. Other scalar region geometry can explicitly select `topology_check='host'` for checked CPU callbacks. The default device policy refuses it under `jax.jit` or `jax.vmap` (`prepared.host_topology`); an eager `jax.grad` tracer's primal is checked as a PyTorch tensor is | `src/wavebit/rcwa/_lifecycle.py` | | `outputs`, `validity`, `diagnostics`, `provenance` on every result; `DiffractionOrders` with `order(m, n)`, `FieldSamples`, `SpectralFieldSamples`, `RawDiffractionSpectrum`, `TimeSeries` (§8) | implemented | `src/wavebit/observations/` | diff --git a/docs/development/source-map.md b/docs/development/source-map.md index 2ea32094..f38fe4c9 100644 --- a/docs/development/source-map.md +++ b/docs/development/source-map.md @@ -86,6 +86,8 @@ research cases; inspect its scope before launching a long calculation. | Coherent sources and absolute power | [test_unified_rcwa_sources.py](https://github.com/fieldalgo/wavebit/blob/main/tests/api/test_unified_rcwa_sources.py) | SI source strength, finite observation regions and independently evaluated fields. | | FDTD domain, source/monitor or reuse | [test_lifecycle.py](https://github.com/fieldalgo/wavebit/blob/main/tests/fdtd/test_lifecycle.py) | PML outside the physical domain, unchanged laboratory coordinates, live source/material reuse and resolved duration guards. | | RCWA modal or reconstruction algebra | [test_smm_field_state.py](https://github.com/fieldalgo/wavebit/blob/main/tests/rcwa/test_smm_field_state.py), [test_lossy_media.py](https://github.com/fieldalgo/wavebit/blob/main/tests/rcwa/test_lossy_media.py) | Independent interior fields and interface responses rather than a regenerated solver golden. | +| FDTD waveguide modes | [test_fd_waveguide_modes.py](https://github.com/fieldalgo/wavebit/blob/main/tests/fdtd/test_fd_waveguide_modes.py) | Modes of the discrete Yee system against closed forms, exact slab dispersion and a published corner benchmark, and a solved mode advanced by the time stepper itself. | +| FDTD mode source | [test_fd_mode_source.py](https://github.com/fieldalgo/wavebit/blob/main/tests/fdtd/test_fd_mode_source.py) | A one-way mode sheet's injection and leakage against their exact grid predictions, absolute power, convergence, a butt joint against mode matching, backends, derivatives and refusals. | | FDTD time step or constitutive update | [test_fd_stage3_backends.py](https://github.com/fieldalgo/wavebit/blob/main/tests/fdtd/test_fd_stage3_backends.py), [test_fd_constitutive_absorber.py](https://github.com/fieldalgo/wavebit/blob/main/tests/fdtd/test_fd_constitutive_absorber.py) | Unchanged raw fields under observation-only changes, discrete energy/work identities and backend agreement. | | Cross-solver phase, port or diffraction normalization | [test_public_lifecycle_diffraction.py](https://github.com/fieldalgo/wavebit/blob/main/tests/cross_solver/test_public_lifecycle_diffraction.py) | Current public APIs, unequal structures and complex nonzero orders; equal total power alone can hide a phase error. | | Recorded results and source identity | [test_run_records.py](https://github.com/fieldalgo/wavebit/blob/main/tests/api/test_run_records.py) | Exact arrays/units, corrupt or incomplete archive refusal, clean input association and loading without the producing backend. | diff --git a/docs/guide/fdtd/gaussian-beam.md b/docs/guide/fdtd/gaussian-beam.md new file mode 100644 index 00000000..488f9052 --- /dev/null +++ b/docs/guide/fdtd/gaussian-beam.md @@ -0,0 +1,59 @@ +# Gaussian beams + +`fdtd.GaussianBeam` launches a focused Gaussian beam from a plane, one way. + +```python +from wavebit import Units, fdtd + +beam = fdtd.GaussianBeam('b', plane=fdtd.Plane(z=-4.), direction='+z', waist=1.5, + focus=(0., 0., 0.), polarization='y', + pulse=fdtd.GaussianPulse(wavelength=1., frequency_sigma_hz=2e13)) +``` + +## What it launches + +On the focal plane, the E component along `polarization` (a transverse axis) is +`exp(-r**2 / waist**2)` about `focus`, and the other transverse component is zero. +`waist` is the 1/e amplitude radius, as for `rcwa.GaussianBeam`. The beam is the propagating +part of that plane's angular spectrum. It is built from the grid's own plane waves in the +homogeneous medium that fills the source plane, so at the pulse's centre wavelength it is an +exact solution of the grid. `focus` is where the beam would focus if that medium filled +space. A beam that enters another medium focuses where refraction takes it. + +`amplitude` is in square-root watts (per metre of each invariant axis): a continuous wave of +amplitude `A` carries `A**2` watts. `phase` is in radians, and `delay_seconds` delays the +pulse. In a two-dimensional problem (one `InvariantAxis`) the beam is a sheet beam, focused +in the resolved transverse axis only. + +The plane is a one-sided total-field/scattered-field sheet, as for `ModeSource` (see +[Waveguide modes](waveguide-modes.md)). At the centre wavelength it launches exactly the +beam, one way. Away from it, each plane wave's profile and half-cell phase are the centre's, +and a little of the beam goes backward. The [validation record](../../validation/fdtd/fd-gaussian-beam.md) +measures that part against its exact prediction. + +## Accuracy on a finite grid + +The beam is exact for the grid, not for the continuum. The grid's plane waves travel at a +phase velocity that depends on their direction, so the phase a beam gathers through its focus +(its Gouy phase) differs a little from the continuum beam's. In the +[validation record](../../validation/fdtd/fd-gaussian-beam.md) (vacuum, wavelength 1 µm, +waist 1.5 µm) it differs by at most 2.2e-3 rad within 3 µm of the focus at a spacing of +0.05 µm (20 cells per wavelength), and by 5.4e-4 rad at 0.025 µm: the error falls at second +order. Converge the spacing for any result that depends on the phase along the beam. The +focal-plane waist, the power and the back-radiation match their predictions far more closely. + +## What it requires + +- The faces across the plane are `Open` (or `InvariantAxis`). A metal, mirror or periodic + face is refused (`source.boundary`): the beam would become the modes of that guide or + lattice. +- The medium on the plane is uniform (`source.medium`), lossless and non-dispersive, and the + same along the axis for a cell each way (the window rules of `solve_modes`). +- The cross-section holds the beam: on the outermost cells of the plane it must be at most + 1e-3 of its peak (`source.beam_size`). The sheet stops at the window, one cell inside an + `Open` face, and launches nothing beyond it. +- `waist` and `focus` are concrete numbers (`source.traced`): the beam is built on the host. + `amplitude`, `phase` and `delay_seconds`, and structures elsewhere, may carry traced values. + The pulse wavelength may not (`mode.traced`). +- Normal incidence only: the beam's axis is the plane's normal. A flux monitor within one + cell of the sheet is refused (`monitor.source_sheet`). diff --git a/docs/guide/fdtd/index.md b/docs/guide/fdtd/index.md index c6ed5f02..e19465b6 100644 --- a/docs/guide/fdtd/index.md +++ b/docs/guide/fdtd/index.md @@ -15,3 +15,5 @@ Start with the [current FDTD problem API](general-fdtd.md), [how the solver mode - [Current FDTD domains, sources and observations](general-fdtd.md) - [How the FDTD solver models a problem](model.md) - [A solve in the time domain](time-domain.md) +- [Waveguide modes of the discrete grid](waveguide-modes.md) +- [Gaussian beams](gaussian-beam.md) diff --git a/docs/guide/fdtd/model.md b/docs/guide/fdtd/model.md index c4dc65d1..443b83fb 100644 --- a/docs/guide/fdtd/model.md +++ b/docs/guide/fdtd/model.md @@ -207,7 +207,7 @@ The rule has limits, each enforced by `check`: | The smallest extent the body bounds must be at least 4 cells; 4 to 8 cells warns | across fewer cells, where the mixed cells fall decides the answer more than the body does | | Material: `Material` with a scalar permittivity and permeability; no per-axis values | the mixing rule is applied per component and assumes an isotropic body | | No `ConductiveMaterial` or `DispersiveMaterial`, except a flat film spanning the cell | lossy and dispersive mixing is validated only for those film scopes | -| Must not touch an `Open()` face unless an unturned, transversely uniform `Box` explicitly declares `Structure.continuation` | finite-body continuation is not inferred; see [smoothed substrates](substrate-gradients.md) | +| Must not touch an `Open()` face unless an unturned, transversely uniform `Box` explicitly declares `Structure.continuation` | finite-body continuation is not inferred; see [smoothed substrates](substrate-gradients.md). A cell-snapped waveguide core may continue instead (see [General-scene limits](#general-scene-limits)) | | Must not reach a `Periodic()` face, unless it runs the whole period reaching **more than** one cell past both faces | otherwise part of it would be lost at the seam | | Two smoothed surfaces may not pass through the same cell | the mixing rule describes two media and one surface per cell; separate them by a cell or write one body | | A smoothed surface may not cut a cell that a cell-snapped face also cuts | the cell would hold three media (see below) | @@ -329,7 +329,7 @@ type per axis or a `(low, high)` pair. | Face | Meaning | | --- | --- | -| `Open()` | outgoing: a PML outside the physical domain, matched to the uniform medium that reaches the face. Only uniform media may reach it — the background, a `SnappedSlab` across that axis, or an unturned `Box` that declares `Structure.continuation` and reaches more than one cell past both transverse faces; any other finite body touching it, or a slab or continued `Box` crossing an orthogonal `Open()` face, is refused (see [General-scene limits](#general-scene-limits)). | +| `Open()` | outgoing: a PML outside the physical domain, matched to the uniform medium that reaches the face. Only uniform media may reach it — the background, a `SnappedSlab` across that axis, an unturned `Box` that declares `Structure.continuation` and reaches more than one cell past both transverse faces, or a `SnappedBox` core that declares continuation through that face; any other finite body touching it, or a slab or continued `Box` crossing an orthogonal `Open()` face, is refused (see [General-scene limits](#general-scene-limits)). | | `Periodic()` | both faces wrap with no phase; normal incidence in a periodic cell. | | `Bloch(phase=φ)` or `Bloch(k=k)` | both faces wrap with a phase; the run becomes complex (twice the memory and work). `Bloch.from_source(source, units=...)` writes the faces an oblique `PlaneWave` needs. | | `PEC()` / `PMC()` | tangential E / tangential H vanishes on the wall. A structure reaching past one is clipped, with `GeometryClippedWarning`. | @@ -389,7 +389,8 @@ These messages do not relax the return tolerance or the measured support limits. ## Sources -`Problem.sources` accepts `PlaneWave`, `CurrentDipole` and `TFSF` declarations with +`Problem.sources` accepts `PlaneWave`, `CurrentDipole`, `TFSF`, `ModeSource` and +`GaussianBeam` declarations with unique, nonempty string names and a `GaussianPulse`. `check` and `Prepared.validate` report malformed declarations at `sources[i]`, invalid or duplicate names at `sources[i].name`, and invalid pulses at @@ -422,6 +423,8 @@ one matching the cell boundaries, is unsupported and produces a structured | `PlaneWave(name, plane=..., pulse=..., direction="-z", theta=0, phi=0, polarization="s")` | a one-way sheet at the node plane nearest `plane`, launching toward `direction`. `theta` is the polar angle from the propagation axis and `phi` the azimuth from the first transverse axis (x for a z wave); `"s"` has E perpendicular to the plane of incidence, `"p"` in it. The transverse faces must be `Periodic()` (normal incidence) or the `Bloch` faces from `Bloch.from_source`; the transverse wavevector is fixed at the pulse's centre wavelength, so an oblique broadband pulse is not constant-angle illumination. | | `CurrentDipole(name, center=..., pulse=..., component="Ey", kind="electric", current_moment=1)` | a point **current** (A·m), or with `kind="magnetic"` its magnetic equivalent (V·m), spread over a smooth stamp (`"bspline"`, the default, or `"tent"`, each with a half-width of two cells) that keeps the moment and its centroid exact wherever the centre sits. It is not a dipole moment in C·m. | | `TFSF(name, box=..., pulse=..., direction="-z", polarization="x")` | a plane wave inside a `SnappedBox` only (total field inside, scattered field outside), for a finite scatterer. Axis directions only; the two faces along its axis must be `Open()`. | +| `ModeSource(name, plane=..., pulse=..., direction="+z", mode=0, amplitude=1)` | a waveguide mode of `solve_modes`, launched one way from a sheet at the node plane nearest `plane`; see [Waveguide modes](waveguide-modes.md). | +| `GaussianBeam(name, plane=..., pulse=..., waist=..., focus=(0, 0, 0), polarization="x", direction="+z")` | a focused Gaussian beam launched one way from a sheet at the node plane nearest `plane`; see [Gaussian beams](gaussian-beam.md). | | `InitialField(E, H, amplitude=1)` | the field the run starts from: E at `t = 0` and H at `t = -dt/2`, given as arrays or as callables evaluated at each component's own Yee points. | `PlaneWave.plane` must be an `fdtd.Plane`, for example `fdtd.Plane(z=.5)`; @@ -709,18 +712,22 @@ coating of 0.105 µm instead, the same scene would warn `geometry.cell_snapped` other GPU backends and single precision are refused. The grid is uniform and cubic. - Per-cell sampled grids use `fdtd.native`; this general-scene formulation has no raster body. Constructive solid geometry (union, intersection, difference) and tilted extrusion axes are unsupported. -- No Gaussian beams, mode sources or mode monitors. +- Mode sources, mode monitors and the mode solver are described in + [Waveguide modes](waveguide-modes.md), and Gaussian beams in [Gaussian beams](gaussian-beam.md). - No smoothed lossy or dispersive bodies outside flat films. -- **A body narrower than the cross-section cannot continue into an absorbing layer.** Only - uniform media continue into an `Open()` face's PML: the background, a `SnappedSlab` across that - axis, and an unturned `Box` that declares `Structure.continuation` and reaches more than one cell - past both transverse faces (a substrate; see [smoothed substrates](substrate-gradients.md)). A - finite body that touches an `Open()` face is refused — a `Cylinder`, `Prism` or `Sphere`, or a - `Box` or `SnappedBox` core — and so is a smoothed body that ends so close to the face that its - smoothed edge reaches the layer. A waveguide whose core, round or rectangular, continues into - the absorber is therefore not supported yet, and no date is set. Ending the core before the - absorber, with its smoothed edge clear of the layer, avoids the refusal, but the end face then - reflects, so the scene solves a different problem. +- **Only a cell-snapped core continues into an absorbing layer.** Into an `Open()` face's PML + continue the background, a `SnappedSlab` across that axis (or, declaring + `Structure(continuation=...)`, through the faces of the axes it runs along), an unturned `Box` + that declares `Structure.continuation` and reaches more than one cell past both transverse + faces (a substrate; see [smoothed substrates](substrate-gradients.md)), and a `SnappedBox` + core that declares `Structure(continuation=('z-', 'z+'))` (any one axis) and reaches those + physical faces. The core is extended through the padding with its cross-section unchanged, so + every layer of the PML sees the same medium; its measured guided-mode reflection is in the + [validation record](../../validation/fdtd/fd-waveguide-through-pml.md). A smoothed core + narrower than the cross-section — a `Box`, `Cylinder`, `Prism` or `Sphere` — is still refused + at an `Open()` face, and so is a smoothed body that ends so close to the face that its + smoothed edge reaches the layer, or a continued core that touches an `Open()` face of another + axis. Custom static geometry implements `fdtd.Shape`; prepared reuse checks its declared instance state. Its cell support must stay clear of Open padding. See the [migration guide](../../migration/index.md) diff --git a/docs/guide/fdtd/waveguide-modes.md b/docs/guide/fdtd/waveguide-modes.md new file mode 100644 index 00000000..7f066ae6 --- /dev/null +++ b/docs/guide/fdtd/waveguide-modes.md @@ -0,0 +1,171 @@ +# Waveguide modes + +`fdtd.solve_modes` finds the guided modes of a cross-section **on the grid a run uses**. +It prepares the problem exactly as `fdtd.prepare` would, reads the permittivity and +permeability the time stepper multiplies by on a plane, and solves the eigenproblem of the +discrete update itself. A mode it returns is therefore a mode of the run, not of the +continuum: started in a run it stays itself to round-off, and its distance from the +continuum mode is the grid's discretization error. + +```python +from wavebit import Material, Units, fdtd + +problem = fdtd.Problem(units=Units(length='um'), scene=fdtd.Scene( + domain=fdtd.Domain(size=(3., 3., 0.4)), + boundaries=fdtd.Boundaries(x=fdtd.PEC(), y=fdtd.PEC(), z=fdtd.Periodic()), + structures=(fdtd.Structure('core', shape=fdtd.SnappedBox(size=(1., .5, 10.)), + material=Material(epsilon=4.)),))) +numerics = fdtd.Numerics(grid=fdtd.UniformGrid(spacing=.05), duration=fdtd.Duration(steps=1)) +modes = fdtd.solve_modes(problem, numerics=numerics, plane=fdtd.Plane(z=0.), + wavelength=1.0, count=2) +modes[0].effective_index # beta c / omega on the grid's own dispersion +modes[0].E['Ex'], modes[0].H['Hy'] # V/m and A/m on each component's Yee points +modes.axes, modes.coordinates['Ex'] # ('y', 'x') and the coordinates of those points +``` + +## What it takes + +- `problem`, `numerics`: the same declarations a run takes. Only the scene and the grid + matter; sources and monitors are ignored. The time step does matter: the leapfrog's + `Omega = (2/dt) sin(omega dt/2)` replaces `omega`. +- `plane`: an `fdtd.Plane` across the propagation axis. Its position snaps to the nearest + node plane, which must lie in the physical domain where the medium is uniform along the + axis (one layer either side is compared). Without transverse spans the window is the + physical cross-section; spans (`fdtd.Plane(z=0., x=(-1., 1.))`) snap to node planes and + close the window with a wall there. +- `wavelength`: the vacuum wavelength in `Problem.units`. +- `count`: how many modes, by decreasing effective index. Fewer confined modes than asked + is a refusal (`mode.count`) that says how many were found. +- `backend`: `'numpy'`, `'jax'` or `'torch'` for the returned arrays. The eigensolve runs + once on the host (SciPy) whatever the backend, so the three return the same bits. + +## What it returns + +`WaveguideModes` holds the modes (`modes[0]` is the largest index), the plane's `axis` and +snapped `position`, the `wavelength`, the two array `axes` in storage order (`('y', 'x')` +for a z plane) and the `coordinates` of every component. Each `WaveguideMode` carries + +- `effective_index`: `beta c / omega`, with `beta` from the grid's own dispersion + `q = (2/dx) sin(beta dx/2)`; `beta_per_meter` is `beta` in rad/m; +- `E` and `H`: complex arrays in V/m and A/m on the component's own Yee points. The + transverse components are real and the longitudinal ones imaginary. They are the phasors + of a run with `exp(-i omega t)`, each component at its own times and its own position: E + on the node plane, and the H components half a cell along the axis referred to that half + plane; +- `polarization`: the share of the transverse electric energy along each transverse axis + (`{'x': 0.98, 'y': 0.02}` for a mode polarized along x); +- `power_watts`: 1. The flux a `FluxMonitor` would measure for the mode -- the node-plane E + against the mean of the two H half planes -- is normalized to 1 W toward `+axis` (W/m with + one `InvariantAxis`, W/m² with two). + +The modes are orthogonal under the unconjugated cross product over the window, +`sum (E_u H_v - E_v H_u) dA = 0` for two modes with different indices, exactly on the grid. +A degenerate pair (a square guide's two polarizations) is returned already orthogonalized. + +## Transverse faces + +| Face | Treatment | +| --- | --- | +| `PEC`, `PMC` | The kernel's own wall, where the kernel puts it (a PMC wall lies half a cell off a low face). | +| `Periodic` | Periodic, when the window spans the axis. | +| `Open` | A wall one cell inside the physical domain's edge (the window's edge node is the wall, and a flux plane over the window must not read the absorbing layer). A mode is admitted only if its transverse E on the outermost cells is at most 1e-3 of its peak; a leaky mode, one near cutoff, or a mode of the truncated box is not. | +| `InvariantAxis` | The one-dimensional (slab) limit, for `k = 0`. | +| `Bloch`, `MirrorSymmetry`, `InvariantAxis` with `k != 0` | Refused (`mode.boundary`). | + +## What it refuses + +Each refusal is a structured error with its code: + +- `mode.material`: a conductive or dispersive medium in the window, or coupled smoothed + (constitutive) material or `discretization='stencil'` anywhere in the scene. Cell-snapped + bodies (`SnappedBox`, `SnappedSlab`) and unturned smoothed `Box` faces lower to a diagonal + medium and are admitted; a `Cylinder`, `Prism`, `Sphere` or turned `Box` couples components + and is not. +- `mode.nonuniform`: the medium changes along the axis at the plane. +- `mode.traced`: a JAX tracer or a Torch tensor that carries a gradient in a material, the + wavelength, or a ranged geometry. The mode solve does not differentiate; solve it from + concrete values. +- `mode.outside_domain`, `mode.plane`: a plane outside the physical domain, across an + `InvariantAxis`, or with an empty or outside span. +- `mode.count`, `mode.convergence`: fewer confined modes than requested; an eigenpair whose + residual exceeds 1e-8. + +## Measuring modes in a run: `ModeMonitor` + +```python +outputs = fdtd.Outputs(monitors=( + fdtd.ModeMonitor('out', plane=fdtd.Plane(z=3.), wavelengths=(1.5, 1.55), modes=2),)) +spectrum = fdtd.run(problem.replace(outputs=outputs), numerics=numerics)['out'] +spectrum.forward_energy, spectrum.backward_energy # (wavelengths, modes), J/Hz +spectrum.forward, spectrum.backward # complex amplitudes, sqrt(J/Hz) +spectrum.effective_index, spectrum.modes[0][0] # the modes it projected on +``` + +At each wavelength the monitor solves the plane's modes exactly as `solve_modes` would and +projects the run's Fourier-transformed fields onto them: the node-plane E and the mean of +the two H half planes, the pairing a `FluxMonitor` uses. Because the modes are exactly +orthogonal on the grid, the projection separates each mode and each direction without +crosstalk from the other modes, including the evanescent ones. `forward_energy` and +`backward_energy` are the two-sided pulse spectral energies carried toward the plane's `+` +and `-` normal, in the units of `FluxMonitor.spectral_energy`; for a field made only of the +projected modes, `sum(forward_energy - backward_energy)` over the modes is the flux. Divide by +the same quantity from a reference run (or, with a mode source, by its injected spectrum) to +get a transmission. + +The plane must cross a uniform section of the guide, with the window, faces and refusals of +`solve_modes`. A structure that reaches the plane must be concrete. Other structures may +carry traced values, so the energies differentiate through, say, a scatterer between source +and monitor (measured against central differences in the +[validation record](../../validation/fdtd/fd-waveguide-modes.md)). Across an `Open` face the +window stops one cell inside the physical edge, for the monitor and for `solve_modes` alike; +the monitor reads no absorbing cells. + +A guide that runs out through an `Open` face is written with a cell-snapped core that +continues through it: `fdtd.Structure('core', shape=fdtd.SnappedBox(size=(1., .5, 10.)), +material=..., continuation=('z-', 'z+'))`, or a `SnappedSlab` with the faces of the axes it +runs along. The guided mode then enters the absorbing layer without meeting an end face; its +measured reflection is in the [validation record](../../validation/fdtd/fd-waveguide-through-pml.md). +A smoothed core is refused at an `Open` face. + +## Launching a mode: `ModeSource` + +```python +source = fdtd.ModeSource('src', plane=fdtd.Plane(z=-2.), direction='+z', mode=0, + pulse=fdtd.GaussianPulse(wavelength=1.55, frequency_sigma_hz=1.5e13)) +monitor = fdtd.ModeMonitor('t', plane=fdtd.Plane(z=2.), wavelengths=(1.55,), source='src') +# ... run ... +t = result['t'] +t.forward_energy[:, 0] / t.injected_energy # the TE0 power transmission +``` + +`ModeSource` launches mode `mode` of `solve_modes` on `plane`, solved at the pulse's centre +wavelength, toward `direction` (`'+'` or `'-'` the plane's normal). The plane has the window, +faces and refusals of `solve_modes`. `amplitude` is in square-root watts (per metre of each +invariant axis): a continuous wave of amplitude `A` carries `A**2` watts. `phase` is in +radians, and `delay_seconds` delays the pulse. + +The plane is a one-sided total-field/scattered-field sheet whose incident field is the mode. +At the pulse's centre wavelength it launches exactly that mode, exactly one way. At other +wavelengths in the band it also launches a little of the mode the other way, because the +profile and the half-cell phase it uses are the centre's. Both parts are known exactly for +the grid: a `ModeMonitor` with `source=` reports them as `injected` (the source's way) and +`leakage` (the other way), as complex amplitudes of the source's mode on the source's plane, +with `injected_energy` and `leakage_energy`. Divide a monitor's `forward_energy` by +`injected_energy` for a transmission; nothing needs a reference run. + +Other modes of the guide are not launched at the centre wavelength. Away from it the profile +projects a little onto other modes of the same symmetry; a monitor that reads several modes +shows them. A monitor within one cell of the sheet is refused (`monitor.source_sheet`): the +field jumps there. A structure that reaches the source plane must be concrete. The source's +`amplitude`, `phase` and `delay_seconds`, and structures elsewhere, may carry traced values. +The pulse wavelength may not, because the mode is solved at it. + +## Accuracy + +The registered checks and their measured results are in the +[validation record](../../validation/fdtd/fd-waveguide-modes.md). In short: a uniform medium +gives its own index and the closed-form Yee dispersion to 1e-12; a hollow metal guide gives +the discrete closed form to about 1e-12; a symmetric slab converges to the exact TE and TM +dispersion at second order; the published dielectric-corner benchmark is reached at second +order; and a solved mode started in a run stays itself to round-off. The effective index on +a finite grid is the grid's, so converge the spacing as for any FDTD result. diff --git a/docs/locales/ko/pages/development/source-map.md b/docs/locales/ko/pages/development/source-map.md index 6d68e083..a01c004b 100644 --- a/docs/locales/ko/pages/development/source-map.md +++ b/docs/locales/ko/pages/development/source-map.md @@ -85,6 +85,8 @@ | 결맞는 소스와 절대 전력 | [test_unified_rcwa_sources.py](https://github.com/fieldalgo/wavebit/blob/main/tests/api/test_unified_rcwa_sources.py) | SI 소스 세기, 유한한 관측 영역과 독립적으로 평가한 장을 확인합니다. | | FDTD 영역, 소스·모니터 또는 재사용 | [test_lifecycle.py](https://github.com/fieldalgo/wavebit/blob/main/tests/fdtd/test_lifecycle.py) | 물리 영역 바깥 PML, 고정된 실험실 좌표, 소스·재료 값 재사용, 실제 실행 시간 제약을 확인합니다. | | RCWA 모드 또는 장 복원 연산 | [test_smm_field_state.py](https://github.com/fieldalgo/wavebit/blob/main/tests/rcwa/test_smm_field_state.py), [test_lossy_media.py](https://github.com/fieldalgo/wavebit/blob/main/tests/rcwa/test_lossy_media.py) | 솔버 golden을 다시 만드는 대신 독립적인 내부 장과 계면 응답으로 확인합니다. | +| FDTD 도파관 모드 | [test_fd_waveguide_modes.py](https://github.com/fieldalgo/wavebit/blob/main/tests/fdtd/test_fd_waveguide_modes.py) | 이산 Yee 계의 모드를 닫힌 식, 정확한 평판 분산, 출판된 모서리 기준값과 비교하고, 풀어낸 모드를 시간 전진 자체로 진행시켜 확인합니다. | +| FDTD 모드 광원 | [test_fd_mode_source.py](https://github.com/fieldalgo/wavebit/blob/main/tests/fdtd/test_fd_mode_source.py) | 단방향 모드 시트의 주입과 누설을 격자에서의 정확한 예측과 비교하고, 절대 전력, 수렴, 모드 정합과의 맞대기 접합, 백엔드, 미분, 거부를 확인합니다. | | FDTD 시간 적분 또는 구성식 갱신 | [test_fd_stage3_backends.py](https://github.com/fieldalgo/wavebit/blob/main/tests/fdtd/test_fd_stage3_backends.py), [test_fd_constitutive_absorber.py](https://github.com/fieldalgo/wavebit/blob/main/tests/fdtd/test_fd_constitutive_absorber.py) | 관측 처리만 바꿀 때 원시 장이 그대로인지, 이산 에너지·일 관계와 백엔드 결과가 일치하는지 검사합니다. | | 솔버 간 위상, 포트 또는 회절 정규화 | [test_public_lifecycle_diffraction.py](https://github.com/fieldalgo/wavebit/blob/main/tests/cross_solver/test_public_lifecycle_diffraction.py) | 현재 공개 API, 비대칭 구조와 복소수 비영차 회절을 확인합니다. 총 전력 일치만으로는 위상 오류가 드러나지 않을 수 있습니다. | | 결과 기록과 소스 식별 | [test_run_records.py](https://github.com/fieldalgo/wavebit/blob/main/tests/api/test_run_records.py) | 배열·단위의 정확한 보존, 손상되거나 미완료인 아카이브 거부, 명시적인 입력 연결, 계산에 쓴 백엔드 없이 읽기를 확인합니다. | diff --git a/docs/locales/ko/pages/guide/fdtd/gaussian-beam.md b/docs/locales/ko/pages/guide/fdtd/gaussian-beam.md new file mode 100644 index 00000000..38d18e50 --- /dev/null +++ b/docs/locales/ko/pages/guide/fdtd/gaussian-beam.md @@ -0,0 +1,53 @@ +# 가우시안 빔 + +`fdtd.GaussianBeam`은 면에서 집속 가우시안 빔을 한쪽으로 내보냅니다. + +```python +from wavebit import Units, fdtd + +beam = fdtd.GaussianBeam('b', plane=fdtd.Plane(z=-4.), direction='+z', waist=1.5, + focus=(0., 0., 0.), polarization='y', + pulse=fdtd.GaussianPulse(wavelength=1., frequency_sigma_hz=2e13)) +``` + +## 무엇을 내보내는가 + +초점면에서 `polarization`(가로 축) 방향의 E 성분은 `focus`를 중심으로 `exp(-r**2 / waist**2)`이고 +다른 가로 성분은 0입니다. `waist`는 `rcwa.GaussianBeam`과 같이 진폭이 1/e가 되는 반지름입니다. +빔은 그 면의 각 스펙트럼(장을 평면파들의 합으로 나타낸 것) 가운데 전파하는 부분입니다. 광원 면을 +채운 균일 매질에서 격자 자신의 평면파로 만들므로, 펄스 중심 파장에서 격자의 정확한 해입니다. +`focus`는 그 매질이 공간을 채웠다면 빔이 모일 곳입니다. 다른 매질로 들어가는 빔은 굴절이 +데려가는 곳에 모입니다. + +`amplitude`의 단위는 와트의 제곱근입니다(불변 축이 있으면 그 축 미터당). 진폭 `A`인 연속파는 +`A**2` 와트를 나릅니다. `phase`는 라디안이고 `delay_seconds`는 펄스를 늦춥니다. 2차원 +문제(`InvariantAxis` 하나)에서 빔은 판 모양 빔으로, 분해된 가로 축으로만 모입니다. + +면은 `ModeSource`와 같은 한쪽 전체장/산란장(TFSF) 시트입니다([도파관 모드](waveguide-modes.md) +참고). 중심 파장에서는 빔을 정확히 한쪽으로만 내보냅니다. 중심에서 벗어나면 평면파마다 단면과 +반 칸 위상이 중심의 것이어서 빔이 뒤로도 조금 나갑니다. [검증 기록](../../validation/fdtd/fd-gaussian-beam.md)이 +그 부분을 정확한 예측과 비교해 잽니다. + +## 유한 격자에서의 정확도 + +빔은 연속체가 아니라 격자에 대해 정확합니다. 격자의 평면파는 방향에 따라 위상 속도가 조금씩 +다르므로, 빔이 초점을 지나며 얻는 위상(구이 위상)이 연속체 빔과 조금 다릅니다. +[검증 기록](../../validation/fdtd/fd-gaussian-beam.md)(진공, 파장 1 µm, 허리 1.5 µm)에서 그 차이는 초점에서 +3 µm 안쪽에서 격자 간격 0.05 µm(파장당 20칸)일 때 최대 2.2e-3 rad이고, 0.025 µm일 때 5.4e-4 rad입니다. +오차는 2차로 줄어듭니다. 빔을 따라가는 위상에 기대는 결과는 격자 간격 수렴을 확인하세요. 초점면 허리, +전력, 뒤쪽 복사는 예측과 훨씬 더 가깝게 맞습니다. + +## 요구 조건 + +- 면을 가로지르는 경계면은 `Open`(또는 `InvariantAxis`)입니다. 금속, 거울, 주기 경계면은 + 거절됩니다(`source.boundary`). 빔이 그 도파관이나 격자의 모드가 되어 버리기 때문입니다. +- 면 위의 매질은 균일하고(`source.medium`) 손실과 분산이 없으며, 축을 따라 양쪽 한 칸씩 같아야 + 합니다(`solve_modes`의 창 규칙). +- 단면이 빔을 담아야 합니다. 면의 가장 바깥 칸에서 빔이 최고값의 1e-3 이하여야 + 합니다(`source.beam_size`). 시트는 창(`Open` 면에서 한 칸 안쪽)에서 멈추고 그 너머로는 아무것도 + 내보내지 않습니다. +- `waist`와 `focus`는 구체적인 수여야 합니다(`source.traced`). 빔은 호스트에서 만듭니다. + `amplitude`, `phase`, `delay_seconds`와 다른 곳의 구조는 추적된 값을 지닐 수 있습니다. 펄스 + 파장은 안 됩니다(`mode.traced`). +- 수직 입사만 됩니다. 빔의 축은 면의 법선입니다. 시트에서 한 칸 안쪽의 플럭스 측정기는 + 거절됩니다(`monitor.source_sheet`). diff --git a/docs/locales/ko/pages/guide/fdtd/model.md b/docs/locales/ko/pages/guide/fdtd/model.md index b4ca0962..90333823 100644 --- a/docs/locales/ko/pages/guide/fdtd/model.md +++ b/docs/locales/ko/pages/guide/fdtd/model.md @@ -198,7 +198,7 @@ Kottke, Farjadpour, Johnson(*Phys. Rev. E* **77**, 036611, 2008)의 비등방 | 바디가 경계 짓는 가장 작은 폭이 4셀 이상이어야 합니다. 4~8셀이면 경고 | 그보다 적은 셀에서는 섞인 셀이 어디에 떨어지느냐가 바디보다 답을 더 좌우합니다 | | 재료: 유전율과 투자율이 스칼라인 `Material`. 축별 값은 안 됩니다 | 섞는 규칙은 성분별로 적용되고 등방 바디를 가정합니다 | | `ConductiveMaterial`, `DispersiveMaterial`은 안 됩니다. 셀 전체에 걸친 평평한 필름만 예외 | 손실·분산 재료의 섞기는 그 필름 범위에서만 검증됐습니다 | -| `Open()` 면에 닿으면 안 됩니다. 회전하지 않고 가로 방향으로 균일한 `Box`가 `Structure.continuation`을 명시적으로 선언한 경우만 예외 | 유한한 바디의 연장은 추론하지 않습니다. [영문 스무딩 기판 가이드](substrate-gradients.md)를 참고하세요 | +| `Open()` 면에 닿으면 안 됩니다. 회전하지 않고 가로 방향으로 균일한 `Box`가 `Structure.continuation`을 명시적으로 선언한 경우만 예외 | 유한한 바디의 연장은 추론하지 않습니다. [영문 스무딩 기판 가이드](substrate-gradients.md)를 참고하세요. 셀에 맞춘 도파관 코어는 대신 이어질 수 있습니다([일반 장면 API의 범위](#일반-장면-api의-범위) 참고) | | `Periodic()` 면에 닿으면 안 됩니다. 단 주기 전체를 덮으면서 양쪽 면을 한 셀 **넘게** 지나가면 됩니다 | 그렇지 않으면 이음매에서 일부가 사라집니다 | | 두 스무딩 표면이 같은 셀을 지나가면 안 됩니다 | 섞는 규칙은 셀당 매질 둘, 표면 하나를 기술합니다. 한 셀 이상 떨어뜨리거나 한 바디로 쓰세요 | | 스무딩 표면이 셀 스냅 면이 자르는 셀을 함께 자르면 안 됩니다 | 그 셀에 매질이 셋 생깁니다(아래 참고) | @@ -309,7 +309,7 @@ stencil의 지원 범위는 그대로입니다. 진공 속 구 하나와 미분 | 면 | 의미 | | --- | --- | -| `Open()` | 바깥으로 나가는 면: 물리 도메인 바깥의 PML이 그 면에 닿는 균일 매질에 맞춰집니다. 균일 매질만 닿을 수 있습니다. 배경, 그 축을 가로지르는 `SnappedSlab`, 또는 `Structure.continuation`을 선언하고 가로 방향 양쪽 면을 한 셀 넘게 지나가는 회전하지 않은 `Box`입니다. 그 밖의 유한한 바디가 닿거나, 슬래브나 이어지는 `Box`가 직교하는 `Open()` 면을 가로지르면 거절됩니다([일반 장면 API의 범위](#일반-장면-api의-범위) 참고). | +| `Open()` | 바깥으로 나가는 면: 물리 도메인 바깥의 PML이 그 면에 닿는 균일 매질에 맞춰집니다. 균일 매질만 닿을 수 있습니다. 배경, 그 축을 가로지르는 `SnappedSlab`, `Structure.continuation`을 선언하고 가로 방향 양쪽 면을 한 셀 넘게 지나가는 회전하지 않은 `Box`, 또는 그 면을 지나 이어진다고 선언한 `SnappedBox` 코어입니다. 그 밖의 유한한 바디가 닿거나, 슬래브나 이어지는 `Box`가 직교하는 `Open()` 면을 가로지르면 거절됩니다([일반 장면 API의 범위](#일반-장면-api의-범위) 참고). | | `Periodic()` | 두 면이 위상 없이 이어집니다. 주기 셀에서의 수직 입사. | | `Bloch(phase=φ)` 또는 `Bloch(k=k)` | 두 면이 위상을 두고 이어집니다. 실행이 복소수가 되어 메모리와 계산이 두 배입니다. `Bloch.from_source(source, units=...)`가 비스듬한 `PlaneWave`에 필요한 면을 만들어 줍니다. | | `PEC()` / `PMC()` | 벽에서 접선 E / 접선 H가 0입니다. 벽 너머로 뻗은 구조물은 잘리고 `GeometryClippedWarning`이 납니다. | @@ -363,7 +363,7 @@ stencil의 지원 범위는 그대로입니다. 진공 속 구 하나와 미분 ## 소스 -`Problem.sources`에는 `PlaneWave`, `CurrentDipole`, `TFSF`를 넣습니다. 이름은 중복되지 않는 +`Problem.sources`에는 `PlaneWave`, `CurrentDipole`, `TFSF`, `ModeSource`, `GaussianBeam`을 넣습니다. 이름은 중복되지 않는 빈 문자열이 아닌 문자열이어야 하며, 펄스는 `GaussianPulse`여야 합니다. `check`와 `Prepared.validate`는 잘못된 선언을 `sources[i]`, 잘못되거나 중복된 이름을 `sources[i].name`, 잘못된 펄스 형식을 `sources..pulse` 경로에 보고합니다. @@ -393,6 +393,8 @@ cutoff`인 `k`에 대해 `f0 ± k·σ`에서 `cutoff`로 떨어집니다. 그 | `PlaneWave(name, plane=..., pulse=..., direction="-z", theta=0, phi=0, polarization="s")` | `plane`에 가장 가까운 노드 평면의 한 방향 시트로, `direction` 쪽으로만 내보냅니다. `theta`는 진행 축으로부터의 극각, `phi`는 첫 번째 가로 축(z 방향 파동이면 x)으로부터의 방위각입니다. `"s"`는 E가 입사면에 수직, `"p"`는 입사면 안입니다. 가로 면은 `Periodic()`(수직 입사)이거나 `Bloch.from_source`가 만든 `Bloch` 면이어야 합니다. 가로 파수 벡터는 펄스의 중심 파장에서 고정되므로, 비스듬한 광대역 펄스는 일정한 각도의 조명이 아닙니다. | | `CurrentDipole(name, center=..., pulse=..., component="Ey", kind="electric", current_moment=1)` | 점 **전류**(A·m), 또는 `kind="magnetic"`이면 그 자기 등가물(V·m)입니다. 매끄러운 스탬프(기본 `"bspline"` 또는 `"tent"`, 둘 다 반폭 두 셀)에 퍼뜨려, 중심이 어디에 있든 모멘트와 무게중심이 정확합니다. C·m 단위의 쌍극자 모멘트가 아닙니다. | | `TFSF(name, box=..., pulse=..., direction="-z", polarization="x")` | `SnappedBox` 안에만 있는 평면파(안은 전체장, 밖은 산란장)로, 유한한 산란체용입니다. 축 방향만 되고, 그 축의 두 면은 `Open()`이어야 합니다. | +| `ModeSource(name, plane=..., pulse=..., direction="+z", mode=0, amplitude=1)` | `plane`에 가장 가까운 노드 면의 시트에서 한쪽으로 내보내는 `solve_modes`의 도파관 모드입니다. [도파관 모드](waveguide-modes.md)를 참고하세요. | +| `GaussianBeam(name, plane=..., pulse=..., waist=..., focus=(0, 0, 0), polarization="x", direction="+z")` | `plane`에 가장 가까운 노드 면의 시트에서 한쪽으로 내보내는 집속 가우시안 빔입니다. [가우시안 빔](gaussian-beam.md)을 참고하세요. | | `InitialField(E, H, amplitude=1)` | 실행이 시작하는 장: `t = 0`의 E와 `t = -dt/2`의 H를 배열로, 또는 각 성분의 Yee 점에서 평가되는 호출 가능 객체로 줍니다. | `PlaneWave.plane`에는 `fdtd.Plane(z=.5)`처럼 `fdtd.Plane`을 넣어야 합니다. @@ -661,11 +663,12 @@ for w, r, t in zip(wavelengths, R, T): - CUDA는 [명시한 Torch 범위](../../validation/fdtd/general-cuda.md)로 제한되며, 다른 GPU 백엔드와 단정밀도는 거부합니다. 격자는 균일한 정육면체입니다. - 셀별 배열과 비균일 z 격자는 `fdtd.native`로 선언합니다. 일반 장면의 CSG 합집합·교집합·차집합과 기울어진 밀어내기 축은 지원하지 않습니다. -- 일반 장면의 가우시안 빔·모드 소스·모드 모니터는 아직 구현하지 않았습니다. +- 모드 광원, 모드 모니터, 모드 솔버는 [도파관 모드](waveguide-modes.md)에, 가우시안 빔은 + [가우시안 빔](gaussian-beam.md)에 설명되어 있습니다. - 자동 정지는 `fdtd.DecayStop`, 단계형 전도도 흡수층은 `fdtd.ConductiveAbsorber`로 제공합니다. [정지와 흡수층](stopping-and-absorbers.md)을 참고하세요. - `fdtd.Shape`는 사용자 정의 고정 도형을 받으며 prepared 재사용 시 선언 상태를 검사합니다. 도형의 셀 support는 Open padding에 닿지 않아야 합니다. - 손실·분산 스무딩 바디는 평평한 필름으로 제한됩니다. -- **단면보다 좁은 바디는 흡수층 안으로 이어질 수 없습니다.** `Open()` 면의 PML 안으로 이어지는 것은 균일 매질뿐입니다. 배경, 그 축을 가로지르는 `SnappedSlab`, 그리고 `Structure.continuation`을 선언하고 가로 방향 양쪽 면을 한 셀 넘게 지나가는 회전하지 않은 `Box`(기판. [영문 스무딩 기판 가이드](substrate-gradients.md) 참고)입니다. `Open()` 면에 닿는 유한한 바디(`Cylinder`, `Prism`, `Sphere`, 또는 `Box`나 `SnappedBox` 코어)는 거절되고, 면 가까이에서 끝나 스무딩된 가장자리가 흡수층에 닿는 스무딩 바디도 거절됩니다. 그래서 원형이든 사각형이든 코어가 흡수층 안으로 이어지는 도파관 장면은 아직 지원하지 않으며, 일정도 정해져 있지 않습니다. 코어를 흡수층 앞에서 끝내고 스무딩된 가장자리를 흡수층 밖에 두면 거절은 피하지만, 그 끝면이 반사하므로 다른 문제를 푸는 셈입니다. +- **흡수층 안으로 이어질 수 있는 코어는 셀에 맞춘 코어뿐입니다.** `Open()` 면의 PML 안으로 이어지는 것은 배경, 그 축을 가로지르는 `SnappedSlab`(또는 `Structure(continuation=...)`을 선언하면 슬래브가 뻗어 있는 축의 면으로), `Structure.continuation`을 선언하고 가로 방향 양쪽 면을 한 셀 넘게 지나가는 회전하지 않은 `Box`(기판. [영문 스무딩 기판 가이드](substrate-gradients.md) 참고), 그리고 `Structure(continuation=('z-', 'z+'))`(어느 한 축)를 선언하고 그 물리 면까지 닿는 `SnappedBox` 코어입니다. 코어는 단면을 그대로 유지한 채 패딩 안으로 연장되므로 PML의 모든 층이 같은 매질을 봅니다. 측정한 도파 모드 반사는 [검증 기록](../../validation/fdtd/fd-waveguide-through-pml.md)에 있습니다. 단면보다 좁은 스무딩 코어(`Box`, `Cylinder`, `Prism`, `Sphere`)는 여전히 `Open()` 면에서 거절되고, 면 가까이에서 끝나 스무딩된 가장자리가 흡수층에 닿는 스무딩 바디, 그리고 다른 축의 `Open()` 면에 닿는 연장 코어도 거절됩니다. ## 더 읽을거리 diff --git a/docs/locales/ko/pages/guide/fdtd/waveguide-modes.md b/docs/locales/ko/pages/guide/fdtd/waveguide-modes.md new file mode 100644 index 00000000..73d6da3c --- /dev/null +++ b/docs/locales/ko/pages/guide/fdtd/waveguide-modes.md @@ -0,0 +1,156 @@ +# 도파관 모드 + +`fdtd.solve_modes`는 **실행이 쓰는 그 격자 위에서** 단면의 도파 모드를 찾습니다. +`fdtd.prepare`와 똑같이 문제를 준비하고, 시간 전진이 면 위에서 곱하는 유전율과 +투자율을 읽어, 이산 갱신식 자체의 고유값 문제를 풉니다. 그래서 돌려준 모드는 +연속계가 아니라 실행의 모드입니다. 실행 안에서 출발시키면 반올림 수준까지 자기 +자신으로 남고, 연속계 모드와의 차이는 격자의 이산화 오차입니다. + +```python +from wavebit import Material, Units, fdtd + +problem = fdtd.Problem(units=Units(length='um'), scene=fdtd.Scene( + domain=fdtd.Domain(size=(3., 3., 0.4)), + boundaries=fdtd.Boundaries(x=fdtd.PEC(), y=fdtd.PEC(), z=fdtd.Periodic()), + structures=(fdtd.Structure('core', shape=fdtd.SnappedBox(size=(1., .5, 10.)), + material=Material(epsilon=4.)),))) +numerics = fdtd.Numerics(grid=fdtd.UniformGrid(spacing=.05), duration=fdtd.Duration(steps=1)) +modes = fdtd.solve_modes(problem, numerics=numerics, plane=fdtd.Plane(z=0.), + wavelength=1.0, count=2) +modes[0].effective_index # 격자 자체의 분산으로 계산한 beta c / omega +modes[0].E['Ex'], modes[0].H['Hy'] # 각 성분의 Yee 점에서 V/m, A/m +modes.axes, modes.coordinates['Ex'] # ('y', 'x')와 그 점들의 좌표 +``` + +## 입력 + +- `problem`, `numerics`: 실행과 같은 선언. 장면과 격자만 쓰고 광원·측정기는 무시합니다. + 시간 간격은 중요합니다. leapfrog의 `Omega = (2/dt) sin(omega dt/2)`가 `omega`를 + 대신합니다. +- `plane`: 전파 축에 수직인 `fdtd.Plane`. 위치는 가장 가까운 노드 면으로 맞춰지며, 그 + 면은 축을 따라 매질이 균일한 물리 영역 안에 있어야 합니다(양쪽 한 층씩 비교). + 가로 범위를 주지 않으면 창은 물리 단면 전체입니다. 범위(`fdtd.Plane(z=0., x=(-1., 1.))`)는 + 노드 면으로 맞춰지고 그 자리를 벽으로 닫습니다. +- `wavelength`: `Problem.units` 단위의 진공 파장. +- `count`: 유효 굴절률이 큰 순서로 몇 개의 모드를 받을지. 갇힌 모드가 요청보다 적으면 + 찾은 개수를 알려 주며 거부합니다(`mode.count`). +- `backend`: 돌려줄 배열의 종류 `'numpy'`, `'jax'`, `'torch'`. 고유값 계산은 백엔드와 + 상관없이 호스트(SciPy)에서 한 번 실행하므로 세 경우 모두 비트까지 같은 값을 돌려줍니다. + +## 출력 + +`WaveguideModes`는 모드들(`modes[0]`이 굴절률이 가장 큼), 면의 `axis`와 맞춰진 +`position`, `wavelength`, 저장 순서로 된 두 배열 축 `axes`(z 면이면 `('y', 'x')`), +각 성분의 `coordinates`를 담습니다. 각 `WaveguideMode`에는 다음이 있습니다. + +- `effective_index`: `beta c / omega`. `beta`는 격자 자체의 분산 + `q = (2/dx) sin(beta dx/2)`에서 구하며, `beta_per_meter`는 rad/m 단위의 `beta`입니다. +- `E`, `H`: 성분 자기 Yee 점 위의 V/m, A/m 복소 배열. 가로 성분은 실수, 세로 성분은 + 허수입니다. `exp(-i omega t)` 실행의 페이저이며, 각 성분은 자기 시각과 자기 위치 + 기준입니다. E는 노드 면, 축을 따라 반 칸 떨어진 H 성분은 그 반 칸 면 기준입니다. +- `polarization`: 가로 전기 에너지 중 각 가로 축 방향의 몫(x 방향 편광 모드면 + `{'x': 0.98, 'y': 0.02}`). +- `power_watts`: 1. `FluxMonitor`가 그 모드에 대해 잴 플럭스(노드 면의 E와 두 H 반 칸 + 면의 평균)를 `+axis` 방향 1 W로 정규화합니다(`InvariantAxis` 하나면 W/m, 둘이면 W/m²). + +모드들은 창 위에서 켤레 없는 교차곱으로 직교합니다. 굴절률이 다른 두 모드는 격자 +위에서 정확히 `sum (E_u H_v - E_v H_u) dA = 0`입니다. 겹친 쌍(정사각 도파관의 두 편광)은 +이미 직교화해서 돌려줍니다. + +## 가로 면 + +| 면 | 처리 | +| --- | --- | +| `PEC`, `PMC` | 커널 자체의 벽, 커널이 두는 자리(PMC 벽은 아래쪽 면에서 반 칸 바깥). | +| `Periodic` | 창이 축 전체를 덮으면 주기 조건. | +| `Open` | 물리 영역 가장자리에서 한 칸 안쪽의 벽(창의 가장자리 노드가 벽이며, 창 위의 플럭스 면은 흡수층을 읽으면 안 됨). 가장 바깥 칸에서의 가로 E가 최고값의 1e-3 이하인 모드만 받습니다. 누설 모드, 차단에 가까운 모드, 잘린 상자의 모드는 받지 않습니다. | +| `InvariantAxis` | `k = 0`일 때 1차원(평판) 극한. | +| `Bloch`, `MirrorSymmetry`, `k != 0`인 `InvariantAxis` | 거부(`mode.boundary`). | + +## 거부하는 것 + +각 거부는 코드를 지닌 구조화된 오류입니다. + +- `mode.material`: 창 안의 전도성·분산 매질, 또는 장면 어딘가의 결합 평활(constitutive) + 재료나 `discretization='stencil'`. 칸 맞춤 물체(`SnappedBox`, `SnappedSlab`)와 돌리지 + 않은 평활 `Box` 면은 대각 매질로 내려가므로 받습니다. `Cylinder`, `Prism`, `Sphere`, + 돌린 `Box`는 성분을 결합하므로 받지 않습니다. +- `mode.nonuniform`: 면에서 축을 따라 매질이 바뀜. +- `mode.traced`: 재료·파장·범위 지정 기하 안의 JAX tracer나 기울기를 지닌 Torch 텐서. + 모드 계산은 미분하지 않으니 구체값으로 푸세요. +- `mode.outside_domain`, `mode.plane`: 물리 영역 밖의 면, `InvariantAxis`를 가로지르는 면, + 비었거나 밖으로 나간 범위. +- `mode.count`, `mode.convergence`: 요청보다 적은 갇힌 모드, 잔차가 1e-8을 넘는 고유쌍. + +## 실행에서 모드 재기: `ModeMonitor` + +```python +outputs = fdtd.Outputs(monitors=( + fdtd.ModeMonitor('out', plane=fdtd.Plane(z=3.), wavelengths=(1.5, 1.55), modes=2),)) +spectrum = fdtd.run(problem.replace(outputs=outputs), numerics=numerics)['out'] +spectrum.forward_energy, spectrum.backward_energy # (파장, 모드), J/Hz +spectrum.forward, spectrum.backward # 복소 진폭, sqrt(J/Hz) +spectrum.effective_index, spectrum.modes[0][0] # 사영에 쓴 모드 +``` + +측정기는 각 파장에서 `solve_modes`와 똑같이 면의 모드를 풀고, 실행의 푸리에 변환 장을 그 +모드들에 사영합니다. 노드 면의 E와 두 H 반 칸 면의 평균을 짝짓는데, `FluxMonitor`와 같은 +방식입니다. 모드들이 격자 위에서 정확히 직교하므로, 사영은 다른 모드(소멸파 모드 포함)의 +섞임 없이 각 모드와 각 방향을 나눕니다. `forward_energy`와 `backward_energy`는 면의 `+`·`-` +법선 쪽으로 나르는 양측 펄스 스펙트럼 에너지이며 단위는 `FluxMonitor.spectral_energy`와 +같습니다. 사영한 모드들만으로 이루어진 장이라면 모드에 대한 +`sum(forward_energy - backward_energy)`가 플럭스입니다. 투과율은 기준 실행의 같은 양(모드 +광원이 있으면 그 주입 스펙트럼)으로 나누어 얻습니다. + +면은 도파관의 균일한 구간을 가로질러야 하며, 창·면·거부는 `solve_modes`와 같습니다. 면에 +닿는 구조는 구체값이어야 합니다. 다른 구조는 추적된 값을 지닐 수 있으므로, 예를 들어 광원과 +측정기 사이의 산란체를 통해 에너지를 미분할 수 있습니다(중앙 차분과의 비교는 +[검증 기록](../../validation/fdtd/fd-waveguide-modes.md)). `Open` 면을 가로지르면 창은 측정기와 +`solve_modes` 모두 물리 가장자리에서 한 칸 안쪽에서 멈추고, 측정기는 흡수층 칸을 읽지 않습니다. + +`Open` 면 밖으로 빠져나가는 도파관은 그 면을 지나 이어지는 셀 맞춤 코어로 씁니다: +`fdtd.Structure('core', shape=fdtd.SnappedBox(size=(1., .5, 10.)), material=..., +continuation=('z-', 'z+'))`, 또는 슬래브가 뻗어 있는 축의 면을 지정한 `SnappedSlab`입니다. 그러면 +도파 모드는 끝면을 만나지 않고 흡수층에 들어갑니다. 측정한 반사는 +[검증 기록](../../validation/fdtd/fd-waveguide-through-pml.md)에 있습니다. 스무딩 코어는 `Open` +면에서 거절됩니다. + +## 모드 내보내기: `ModeSource` + +```python +source = fdtd.ModeSource('src', plane=fdtd.Plane(z=-2.), direction='+z', mode=0, + pulse=fdtd.GaussianPulse(wavelength=1.55, frequency_sigma_hz=1.5e13)) +monitor = fdtd.ModeMonitor('t', plane=fdtd.Plane(z=2.), wavelengths=(1.55,), source='src') +# ... 실행 ... +t = result['t'] +t.forward_energy[:, 0] / t.injected_energy # TE0 전력 투과율 +``` + +`ModeSource`는 펄스 중심 파장에서 `plane` 위에 푼 `solve_modes`의 `mode`번 모드를 +`direction`(면 법선의 `'+'` 또는 `'-'`) 쪽으로 내보냅니다. 면의 창, 경계면, 거부는 +`solve_modes`와 같습니다. `amplitude`의 단위는 와트의 제곱근입니다(불변 축이 있으면 그 축 +미터당). 진폭 `A`인 연속파는 `A**2` 와트를 나릅니다. `phase`는 라디안이고 `delay_seconds`는 +펄스를 늦춥니다. + +면은 입사장이 모드인, 한쪽만 있는 전체장/산란장(TFSF) 시트입니다. 펄스 중심 파장에서는 정확히 +그 모드를, 정확히 한쪽으로만 내보냅니다. 대역의 다른 파장에서는 쓰는 단면과 반 칸 위상이 중심 +파장의 것이어서 반대쪽으로도 모드를 조금 내보냅니다. 두 부분 모두 격자에 대해 정확히 알려져 +있습니다. `source=`를 준 `ModeMonitor`가 이를 `injected`(광원 방향)와 `leakage`(반대 방향)로, +광원 면에서 광원 모드의 복소 진폭으로 보고하며 `injected_energy`와 `leakage_energy`도 줍니다. +측정기의 `forward_energy`를 `injected_energy`로 나누면 투과율이 됩니다. 기준 실행은 필요 +없습니다. + +중심 파장에서는 도파관의 다른 모드를 내보내지 않습니다. 중심에서 벗어나면 단면이 같은 대칭의 +다른 모드에 조금 투영되며, 여러 모드를 읽는 측정기에 나타납니다. 시트에서 한 칸 안쪽의 +측정기는 거부됩니다(`monitor.source_sheet`). 거기서는 장이 불연속입니다. 광원 면에 닿는 구조는 +구체값이어야 합니다. 광원의 `amplitude`, `phase`, `delay_seconds`와 다른 곳의 구조는 추적된 +값을 지닐 수 있습니다. 펄스 파장은 안 됩니다. 모드를 그 파장에서 풀기 때문입니다. + +## 정확도 + +등록한 검사와 측정 결과는 [검증 기록](../../validation/fdtd/fd-waveguide-modes.md)에 +있습니다. 요약하면, 균일 매질은 자기 굴절률과 Yee 분산의 닫힌 식을 1e-12까지 주고, +속이 빈 금속 도파관은 이산 닫힌 식을 1e-12 정도까지 줍니다. 대칭 평판은 정확한 TE·TM +분산에 2차로 수렴하고, 출판된 유전체 모서리 기준값에도 2차로 다가가며, 풀어낸 모드를 +실행에서 출발시키면 반올림 수준까지 자기 자신으로 남습니다. 유한 격자에서의 유효 +굴절률은 그 격자의 값이므로, 다른 FDTD 결과처럼 간격을 줄여 수렴을 확인하세요. diff --git a/docs/locales/ko/pages/validation/fdtd/fd-gaussian-beam.md b/docs/locales/ko/pages/validation/fdtd/fd-gaussian-beam.md new file mode 100644 index 00000000..3a5af1dd --- /dev/null +++ b/docs/locales/ko/pages/validation/fdtd/fd-gaussian-beam.md @@ -0,0 +1,181 @@ +# 집속 가우시안 빔 + +작성일: 2026-10-27 + +대상: `src/wavebit/fdtd/_beam_source.py`, `src/wavebit/fdtd/_sources.py`의 `GaussianBeam` 선언, +`tests/fdtd/test_fd_gaussian_beam.py`. + +`fdtd.GaussianBeam`은 면에서 집속 빔을 한쪽으로 내보냅니다. 모드 광원(`fd-mode-source.md`)의 +한쪽 시트를 쓰되 입사장이 빔입니다. 초점면에서 편광 방향의 가로 E를 자기 Yee 점에서 표본화한 +값이 초점을 중심으로 `exp(-r^2 / waist^2)`입니다(`waist`는 `rcwa.GaussianBeam`과 같이 진폭이 +1/e가 되는 반지름). 빔은 그 면의 각 스펙트럼(angular spectrum, 장을 평면파들의 합으로 나타낸 +것) 가운데 전파하는 부분이고, 시트가 있는 균일 매질에서 격자 자신의 이산 평면파로 만듭니다. + +그래서 펄스 중심 주파수에서 빔은 격자의 정확한 해이고, 시트는 빔을 한쪽으로만 내보냅니다. +중심에서 벗어나면 시트는 빔을 뒤로도 조금 내보내는데, 그 부분은 실제 파형 행으로부터 평면파마다 +정확히 계산됩니다. 각 스펙트럼 표현은 표준적인 것입니다(Goodman, *Introduction to Fourier +Optics*, 3장). 아래의 기준값(oracle)은 테스트 안에 직접 쓴 독립적인 연속체 계산입니다. + +## 등록한 합격 기준 (2026-10-27, 계산 전) + +**설정 B (2차원).** 진공, x는 ±6 µm에서 `Open`, y는 `InvariantAxis`, z는 ±5 µm에서 `Open`, +1 µm에 맞춘 24칸 `PML`. z = -4 µm 면의 `GaussianBeam('b')`, 방향 `+z`, 허리 1.5 µm, 초점은 +원점, 진폭 1(미터당 와트의 제곱근). 펄스는 중심 1 µm, `frequency_sigma_hz = 2e13`, +`cutoff = 1e-12`. 편광 `y`(TE: Ey, Hx, Hz)와 `x`(TM: Ex, Ez, Hy). 격자 간격 0.05와 +0.025 µm. 어떤 실행에서도 `UnsettledFieldWarning`과 `GrowingFieldWarning`이 나지 않아야 +합니다. + +* **G1, 허리.** 1 µm의 초점면에서 편광 방향 가로 성분의 `|E|^2`로 구한 `w = 2 sqrt()`가 + 허리와 맞아야 합니다: 두 편광, 두 간격에서 `|w / 1.5 µm - 1| <= 1e-6`. +* **G2, 구이 위상(Gouy phase, 초점을 지나며 빔이 평면파보다 더 얻는 위상).** TE, 축 위 + (x = 0 면의 `FieldMonitor`), 1 µm. 위상은 격자의 축 방향 평면파 기준 + `psi(z) = arg Ey(0, z) - beta_axis z`(`beta_axis`는 격자 분산에서)로 잡고 z = 0 값을 + 기준으로 합니다. 이를 같은 초점장의 정확한 연속체 각 스펙트럼의 같은 양 + `arg integral A(kx) exp(i kz z) dkx - k z`와 비교합니다. z가 [-3, 3] µm에서 가장 큰 차이가 + 0.05 µm에서 2e-3 rad 이하이고, 0.025 µm로 갈 때 관측 차수 1.8 이상으로 줄어야 합니다. + 근축 근사 `arctan(z / zR) / 2`도 함께 보고합니다. +* **G3, 초점 이동.** TE, 허리 0.6 µm, z > -1 µm에 유전율 2.25인 유전체 반공간(z를 + 가로지르며 z+ 면에 닿는 `SnappedSlab`). 시트는 z = -2.5 µm, 초점은 z = +0.5 µm(진공이라면 + 모일 곳)로 선언합니다. 유전체 안 축 위 세기 최댓점(가장 큰 다섯 표본을 지나는 포물선)을 + 경계면의 TE 프레넬 투과를 넣은 정확한 연속체 각 스펙트럼의 최댓점과 비교합니다. 차이는 + 0.05 µm에서 0.03 µm 이하, 0.025 µm에서 0.01 µm 이하이고 더 고운 간격에서 더 작아야 합니다. + 근축 초점(`-1 + 1.5 * 1.5 = 1.25` µm)도 함께 보고합니다. +* **G4, 전력.** 진폭 `A`인 연속파는 `A^2` 와트(여기서는 미터당)를 나르므로, z = +3 µm의 + `FluxMonitor`를 지나는 1 µm의 스펙트럼 에너지는 `2 A^2 |W(f0)|^2`입니다(`W`는 단위 파형의 + 연속 푸리에 변환). 두 편광, 두 간격에서 상대 2e-5 이내로 맞아야 합니다. 3차원에서도 같습니다: + x와 y는 ±3 µm에서 `Open`, z는 ±2.5 µm에서 `Open`, 12칸 `PML`, 허리 1 µm, 편광 `x`, 시트는 + z = -2 µm, 측정기는 z = +2 µm, 간격 0.05 µm. +* **G5, 뒤쪽 복사.** TE, 두 간격, 0.95, 1.0, 1.05 µm. 시트 뒤 z = -4.5 µm의 `FluxMonitor`를 + 지나는 뒤쪽 스펙트럼 에너지가 예측한 뒤쪽 방출과, 예측한 앞쪽 방출의 `1e-6` 이내로 맞아야 + 합니다. z = +3 µm의 앞쪽 스펙트럼 에너지는 예측한 앞쪽 방출과 상대 1e-5 이내로 맞아야 + 합니다. 1.0 µm에서 예측한 뒤쪽 방출은 앞쪽의 1e-12 이하이고, 0.95와 1.05 µm에서는 그 값을 + 보고합니다. +* **G6, 백엔드.** G1의 TE 실행(0.05 µm)을 NumPy, JAX, Torch에서 실행해 z = +3 µm의 플럭스와 + 축 위 장이 상대 1e-12 이내로 일치. +* **G7, 미분.** JAX와 Torch에서 TE, 0.05 µm로: 1 µm의 z = +3 µm 플럭스를 빔 `amplitude`로, + `Re Ey(0, z = 3 µm)`를 빔 `phase`와 `delay_seconds`로, 그리고 플럭스를 시트와 측정기 사이, + 축에서 벗어나고 시트 면에 닿지 않는 추적된 `SnappedBox` 산란체의 유전율로 미분한 값이 각각 + 중앙 차분(두 간격, Richardson 외삽)과 상대 1e-6 이내로 일치. +* **G8, 코드별 거부.** 면 법선을 따르지 않는 방향(`source.direction`), 법선 방향의 + 편광(`source.polarization`), 양수가 아닌 허리(`source.waist`), 점이 아닌 초점(`source.focus`), + 추적된 허리(`source.traced`), 추적된 펄스 파장(`mode.traced`), 면 위에서 균일하지 않은 + 매질(`source.medium`), 금속 가로 면(`source.boundary`), 단면이 담지 못하는 + 빔(`source.beam_size`), 시트에서 한 칸 안쪽의 측정기(`monitor.source_sheet`), 엔진 + 자리표시자 `fd.GaussianBeam`(공개 광원을 알려주는 `NotImplementedError`). + +G4 수정(3차원 경우를 처음 실행하기 전): 시트(초점에서 2 µm, 레일리 거리 3.14 µm)에서 빔 반지름은 +1.19 µm이므로 ±3 µm 단면의 가장 바깥 칸에서 최고값의 약 2e-3이 되어, 광원이 허용하는 1e-3을 +넘습니다(`source.beam_size`). 그래서 x와 y의 단면을 ±3.5 µm로 넓힙니다(그러면 거기서 최고값의 +2e-4). G4의 나머지는 등록한 그대로입니다. + +G4 3차원 경우의 두 번째 수정(역시 아무것도 계산하기 전): 허리 1 µm이면 광원이 이를 +거절했습니다(`source.beam_size`). 각 스펙트럼이 빛 원뿔 경계에서 최고값의 5e-5이고, 3차원에서는 +그 절단의 잔물결이 광원이 만드는 가장 큰 변환 격자(2^24점)의 가장자리에서도 최고값의 2.8e-7로 +광원의 한계 1e-7보다 큽니다. 그래서 허리를 설정 B와 같은 1.5 µm로 하고, x와 y 단면을 ±4.5 µm로 +넓혀 빔(시트에서 반지름 1.56 µm)이 가장 바깥 칸에서 최고값의 3e-4가 되게 합니다. 측정기는 +±4.4 µm에 걸칩니다. 한계는 그대로입니다. + +G3 수정(처음 실행하기 전): 허리 0.6 µm이면 초점장의 각 스펙트럼이 빔의 전파 부분이 잘리는 빛 원뿔 +경계(light line)에서도 최고값의 3%이고, 그 부분은 1/r로 길게 울립니다. 광원은 이런 빔을 +거절합니다(`source.beam_size`). 어떤 변환 격자를 만들어도 그 잔물결이 최고값의 1e-7보다 크기 +때문입니다. 그래서 G3의 허리를 1.0 µm로 바꿉니다(빛 원뿔 경계에서 스펙트럼이 최고값의 5e-5). +한계를 포함해 G3의 나머지는 등록한 그대로입니다. + +G7 수정(어떤 미분도 계산하기 전): `fd-mode-source.md`의 S7 수정과 같은 이유로(역방향 미분이 모든 스텝을 +저장하므로 설정 B 전체 실행은 여기 메모리에 들어가지 않음), 미분과 중앙 차분을 같은 잘린 실행에 대해 +구합니다: 9 µm × 3 µm 물리 영역(x, z `Open`, 12칸 `PML`), z = -1 µm의 시트, z = +1 µm의 플럭스와 +탐침, `cutoff = 1e-3`인 펄스, `Duration(steps=1000)`, (2, 0, 0) µm 중심의 유전율 2.0 산란체. 네 가지 +미분, 차분 간격, 1e-6 한계는 등록한 그대로입니다. + +## 결과 + +`tests/fdtd/test_fd_gaussian_beam.py`로 NumPy(이름을 적은 곳은 JAX와 Torch), 배정밀도에서 +측정했습니다. + +| 기준 | 한계 | 0.05 µm | 0.025 µm | 판정 | +| --- | --- | --- | --- | --- | +| G1 허리, TE / TM | `|w / waist - 1|` 1e-6 이하 | 1.07e-8 / 1.08e-8 | -7.2e-9 / -7.2e-9 | 통과 | +| G2 연속체와의 구이 위상 차, z가 [-3, 3] µm에서 최대 | 0.05에서 2e-3 rad | **2.20e-3** | 5.42e-4 | **실패** | +| G2 관측 차수 | 1.8 이상 | | 2.02 | 통과 | +| G3 유전체 안 초점 최댓점(연속체 1.37398 µm, 근축 1.25 µm) | 0.05에서 0.03, 0.025에서 0.01, 더 작음 | 1.34846 (0.0255) | 1.37084 (0.0031) | 통과 | +| G4 전력, TE / TM | 2e-5 | -1.5e-12 / -7.6e-13 | -9.0e-13 / -5.5e-13 | 통과 | +| G4 전력, 3차원 | 2e-5 | -3.6e-8 | | 통과 | +| G5 뒤쪽 에너지와 예측(앞쪽 대비) | 1e-6 | 6.2e-12 / 6.2e-14 / 5.0e-12 | 2.5e-12 / 4.1e-14 / 5.0e-12 | 통과 | +| G5 앞쪽 에너지와 예측 | 1e-5 | 1.3e-11 / 6.3e-13 / 5.0e-12 | 7.4e-12 / 3.8e-13 / 6.1e-12 | 통과 | +| G5 1.0 µm 예측 뒤쪽/앞쪽 | 1e-12 | 1.4e-13 | 8.5e-14 | 통과 | +| G5 0.95 / 1.05 µm 예측 뒤쪽/앞쪽 | 보고 | 2.6e-7 / 2.8e-7 | 2.5e-7 / 2.8e-7 | | +| G6 JAX / Torch와 NumPy, 플럭스와 축 | 1e-12 | 2.3e-16, 1.0e-15 / 1.1e-16, 1.2e-15 | | 통과 | +| G7 미분과 외삽한 중앙 차분(수정한 잘린 실행), JAX·Torch: 진폭 / 위상 / 지연 / 산란체 | 1e-6 | 1e-10 미만 / 1e-10 미만 / 2.4e-10 / 1.3e-10 | | 통과 | +| G8 코드별 거부 | 목록대로 | 모두 | | 통과 | + +**등록한 G2는 실패했습니다.** 0.05 µm에서 연속체와의 구이 위상 차가 최대 2.20e-3 rad로 등록한 +2e-3보다 큽니다. 등록한 두 번째 부분인 2차 수렴은 맞습니다(0.025 µm에서 5.42e-4). 차이는 격자의 +분산입니다. 실행의 빔은 격자에 대해 정확하고, 격자의 평면파는 방향에 따라 위상 속도가 다르지만 +연속체의 평면파는 그렇지 않습니다. 한계는 바꾸지 않았습니다. 테스트는 엄격한 예상 실패(strict +xfail)로 두고, 차수만 확인하는 통과 테스트를 곁에 둡니다. 비교하자면 같은 범위에서 정확한 빔의 +구이 위상은 근축 `arctan(z / zR) / 2`와 2.1e-3 rad 다릅니다. + +### 지원 판단 (2026-10-30) + +결과를 본 뒤에 기록하며, 위의 판정과는 따로 둡니다. 판정은 그대로입니다. 등록한 G2는 실패했고 +한계는 바꾸지 않습니다. `GaussianBeam`은 지원하며, 구이 위상 정확도를 숨기지 않고 밝힙니다. +이유는 다음과 같습니다. + +- 차이는 광원의 결함이 아니라 격자의 분산입니다. 내보낸 빔은 격자에 대해 정확하고, 허리·전력·뒤쪽 + 복사는 예측과 1e-8 이하로 맞습니다(G1, G4, G5). +- 오차는 2차로 줄어듭니다(관측 차수 2.02). 0.025 µm(파장당 40칸)에서는 등록한 한계 안입니다. +- 크기는 같은 범위에서 근축 공식 자체가 정확한 빔과 다른 정도(2.1e-3 rad)와 같은 수준입니다. +- 아래 비교에서 Meep과 FDTDX의 빔은 두 격자 모두에서 정확한 빔의 구이 위상과 더 많이 다르고(0.05 µm에서 + 3.6e-3, 3.7e-3 rad), Meep은 해상도를 올려도 줄지 않습니다. 격자 위의 빔에 기대할 수 있는 정도에 대한 + 맥락이며 판정이 아닙니다. + +안내서(`docs/guide/fdtd/gaussian-beam.md`)와 클래스 설명(docstring)에 측정한 오차를 적고, 빔을 따라 +가는 위상에 기대는 결과는 격자 간격 수렴을 확인하라고 안내합니다. 실행 시 경고는 더하지 않습니다. +이 격자들은 파장당 20칸과 40칸으로, `CoarseMeshWarning`이 이미 경고하는 8–12칸보다 곱습니다. 그리고 +모든 빔 실행에 붙는 알림은 그 실행에 대해 아무것도 알려 주지 않습니다. + +실행에 관한 두 가지는 한계를 바꾼 것이 아닙니다. G3의 반공간은 x 면을 가로지르므로 +`fd-waveguide-through-pml.md`의 슬래브 문인 `continuation=('x-', 'x+')`을 선언합니다. +`FluxMonitor`는 플럭스 면이 그래야 하듯 흡수층을 피해 x를 ±5.9 µm로 덮습니다. `FieldMonitor`는 +시트에서 한 칸 안쪽이어도 거부되지 않으므로(TFSF 상자처럼 거부는 플럭스 면에만 적용) G8은 +`FluxMonitor`로 그 거부를 확인합니다. + +설계 상수 하나를, 어떤 결과도 그것에 기대기 전에 한 번 바꿨습니다. 처음 썼을 때 광원은 덧댄 변환 +격자의 가장자리에서 빔이 최고값의 1e-13 미만이기를 요구했습니다. 첫 실행(G1 설정)에서 빛 원뿔 +경계의 잔물결이 5e-11로 나왔으므로 상수를 1e-7로 하고, 그 값을 만족할 때까지 덧대기를 두 배씩 +늘리게 했습니다(최대 2^24점). 위의 G3와 3차원 G4 수정은 이 규칙에 따른 거부입니다. + +등록 뒤에 빔이 가로 방향 초점에 중심을 두는지 확인하는 테스트를 더했습니다. 일부러 깨뜨린 +열세 가지를 모두 잡았습니다: 이산 패러데이 법칙의 각 부호, 코사인 행의 4분의 1 주기, 1 W 크기 +맞춤, 편광 대응, 축 방향과 가로 방향 초점, 가장자리·매질·경계면 규칙, 추적된 허리 거부, 뒤쪽 복사 +예측의 두 가지 오류. 매번 소스를 바이트 단위로 되돌렸습니다. 모드 광원처럼 H 행의 반 칸 군지연은 +중심 밖 뒤쪽 복사만 바꾸고, G5는 그 부분을 보고하며 정확한 예측이 그것을 따라가므로 지연을 묶는 +한계는 없습니다. + +## Meep·FDTDX와의 비교 + +2026-10-30에 위의 모든 판정 뒤에 더했으며, 어떤 판정도 바꾸지 않습니다. 도구, 버전, 실행 방식은 +`fd-mode-source.md`의 비교와 같고, 같은 저장값·기록·테스트가 이 표와 아래의 각 문장을 묶습니다. + +맞춘 것: Setup B(진공, 허리 1.5 µm, 파장 1 µm, TE), 같은 격자 간격과 시간 간격. Meep의 +`GaussianBeamSource`는 초점을 Wavebit과 같은 곳에 둡니다. FDTDX의 `GaussianPlaneSource`는 그럴 수 +없습니다. 잘린 가우시안에 평면파 E/H 쌍 하나를 곱하므로 초점이 늘 자기 면입니다. 그래서 FDTDX 행은 +초점을 광원 면에 둔 변형입니다. 폭은 그 면에서 2 µm 뒤에서 같은 초점면 장의 정확한 빔과 비교했고, +구이 위상은 0.5–3 µm에서 쟀습니다. 유리 속 초점 이동(G3)은 FDTDX로 설정할 수 없습니다. + +| 양 | Wavebit | Meep | FDTDX | +| --- | --- | --- | --- | +| 정확한 빔 대비 빔 폭, w / w_exact − 1 (0.05 / 0.025) | +1.1e-8 / -7.2e-9 | +8.6e-3 / +8.6e-3 | -4.2e-3 / -4.8e-3 | +| 정확한 빔과의 구이 위상 차 최대, rad (0.05 / 0.025) | 2.2e-3 / 5.4e-4 | 3.6e-3 / 4.8e-3 | 3.7e-3 / 2.3e-3 | +| 1.0 µm에서 뒤 / 앞 전력 (0.05 / 0.025) | 1.4e-13 / 8.5e-14 | 8.0e-4 / 1.1e-4 | 2.6e-5 / 2.6e-5 | +| 유리 속 초점 정점, abs(z − 1.37398 µm) (0.05 / 0.025) | 2.6e-2 / 3.1e-3 | 1.1e-1 / 4.9e-2 | 설정할 수 없음 | + +비교가 보여 주는 것: + +- Meep의 빔은 두 해상도에서 모두 0.86% 넓습니다. 차이는 격자가 아니라 빔의 정의입니다. 구이 위상 + 차는 해상도를 올려도 줄지 않고, 초점 정점은 정확한 값보다 짧습니다. 같은 이유인지는 확인하지 + 않았습니다. 뒤쪽 복사는 해상도에 따라 줄어듭니다. +- FDTDX의 폭과 뒤쪽 복사는 격자를 곱게 해도 줄지 않습니다. 점마다 곱한 평면파 쌍은 격자의 빔이 + 아닙니다. +- Wavebit의 구이 위상 차(G2 실패)는 두 격자 모두에서 두 도구보다 작고 2차로 줄어듭니다. 위의 지원 + 판단은 이것을 판정이 아니라 맥락으로 씁니다. diff --git a/docs/locales/ko/pages/validation/fdtd/fd-mode-source.md b/docs/locales/ko/pages/validation/fdtd/fd-mode-source.md new file mode 100644 index 00000000..5d115462 --- /dev/null +++ b/docs/locales/ko/pages/validation/fdtd/fd-mode-source.md @@ -0,0 +1,165 @@ +# 한쪽으로만 내보내는 도파관 모드 광원 + +작성일: 2026-10-27 + +대상: `src/wavebit/fdtd/_mode_source.py`, `src/wavebit/fdtd/_kernel/mode_sheet.py`, +`src/wavebit/fdtd/_sources.py`의 `ModeSource` 선언, `ModeMonitor`의 `source=` 옵션, +`tests/fdtd/test_fd_mode_source.py`. + +`fdtd.ModeSource`는 도파관을 가로지르는 면에서 `fdtd.solve_modes`의 모드를 한쪽으로 내보냅니다. +이 면은 한쪽만 있는 전체장/산란장(TFSF) 시트입니다. 커널의 평면파 구성에서 입사장을 모드로 +바꾼 것입니다. E 패턴은 시트의 노드 면에, H 패턴은 상류 쪽 반 칸 면에 있고, 각각 자기 +파형 행을 갖습니다. H 행은 E 행을 모드의 반 칸 위상 `beta dx / 2`만큼, 그리고 반 칸 군지연만큼 +앞당긴 것입니다. 그러면 펄스의 중심 주파수에서 두 행의 짝은 정확히 이산 모드가 되고, 시트는 +그 모드를 한쪽으로만 내보냅니다. 다른 주파수에서는 단면과 반 칸 위상이 중심 주파수의 것이므로 +시트는 반대쪽으로도 모드를 조금 내보냅니다. 이산 모드의 정확한 비켤레 직교성 덕분에 두 부분 +모두 실제 파형 행으로부터 닫힌 식으로 나옵니다(유도는 `_mode_source.py` 모듈 설명에 +있습니다). `source=`를 선언한 `ModeMonitor`가 이를 `injected`(주입)와 `leakage`(누설)로 +보고합니다. + +TFSF 구성은 표준적인 것입니다(Taflove와 Hagness, *Computational Electrodynamics*, 3판, +5장). 모드를 TFSF 면의 입사장으로 쓰고 H 면의 파형을 반 칸 앞당기는 것도 같은 생각을 따릅니다. +다른 솔버에서 가져온 코드는 없습니다. + +## 등록한 합격 기준 (2026-10-27, 계산 전) + +**설정 S.** 흡수층 통과 기록(`fd-waveguide-through-pml.md`) D1의 슬래브입니다. 유전율 2.085136의 +클래딩 안에 폭 1.5 µm, 유전율 4인 코어, y는 `InvariantAxis`, x는 ±4.5 µm에서 `Open`, z는 +±4 µm에서 `Open`, 코어는 양쪽 z 면을 지나 이어지는 `SnappedSlab`, 1.55 µm에 맞춘 24칸 `PML`. +z = -2 µm의 `ModeSource('src')`, 방향 `+z`, 모드 0(TE0), 진폭 1(미터당 와트의 제곱근), 펄스 +중심 1.55 µm, `frequency_sigma_hz = 1.5e13`, `cutoff = 1e-12`. `source='src'`, `modes=1`, +파장 1.45, 1.55, 1.65 µm인 `ModeMonitor`: z = +1 µm의 `p1`, z = +2 µm의 `p2`, z = -3 µm의 +`q`. 격자 간격 0.05와 0.025 µm. 각 실행은 `UnsettledFieldWarning`과 `GrowingFieldWarning`이 +나지 않을 때까지 계속합니다. 아래의 `P(d)`는 그 파장에서 모드 자신의 격자 `beta`(`solve_modes` +값)로 거리 `d`만큼 진행하는 인자이며, 실행의 푸리에 규약을 따릅니다. + +* **S1, 예측대로의 주입.** 모든 파장과 두 간격에서 + `|forward(p2) - injected * P(4 µm)| <= 1e-5 |injected|`. 거울상 실행(광원을 z = +2 µm에 + 방향 `-z`로, 측정기를 z = -2 µm에 두고 그 후방 진폭을 읽음)에서도 0.05 µm에서 같은 기준. +* **S2, 예측대로의 누설과 그 값.** 모든 파장과 두 간격에서 + `|backward(q) - (leakage * P(1 µm) + backward(p2) * P(5 µm))| <= 1e-6 |injected|`. + 1.55 µm에서 예측한 누설 전력 `|leakage / injected|^2`는 1e-12 이하(중심에서는 한쪽으로만 + 내보냄)이고, 1.45와 1.65 µm에서는 그 값을 보고합니다. +* **S3, 절대 전력.** 진폭 `A`인 연속파는 미터당 `A^2` 와트를 나르므로, 내보낸 모드의 순간 + 전력은 단위 파형 `w`에 대해 `2 A^2 w(t)^2`이고 양측 스펙트럼 에너지는 `2 A^2 |W(f)|^2`입니다. + `W`는 `w`의 연속 푸리에 변환입니다. 1.55 µm와 두 간격에서 `forward_energy(p2)`가 이 값과 + 상대 2e-5 이내로 맞아야 합니다. +* **S4, 해상도에 따른 수렴.** 1.55 µm에서 실행 자체의 유효 굴절률 + `n_run = arg(forward(p2) / forward(p1)) / (k0 * 1 µm)`(솔버 굴절률에 가장 가까운 가지)가 같은 + 간격의 솔버 격자 굴절률과 1e-9 이내로 맞고, 슬래브의 정확한 TE0 굴절률(1.9562181938, 모드 솔버 + 기록의 M3)에 대한 오차가 0.05에서 0.025 µm로 갈 때 관측 차수 1.8 이상으로 줄어야 합니다. +* **S5, 맞대기 접합과 모드 정합 비교.** 설정 S에서 코어를 두 `SnappedBox`로 바꿉니다. z < 0은 폭 + 1.5 µm(z- 면을 지나 이어짐), z > 0은 폭 1.0 µm(z+ 면을 지나 이어짐)이고, `p2`는 좁은 코어의 + TE0를 읽으며 `q`는 z = -1 µm로 옮깁니다. 1.55 µm에서 TE0 전력 투과 + `T = forward_energy(p2) / injected_energy`와 반사 `R = backward_energy(q) / injected_energy`를 + 같은 접합의 연속체 모드 정합 값과 비교합니다. 모드 정합은 테스트 안에 직접 씁니다. 완전 도체 + 상자 안 두 슬래브의 TE 모드를 1차원 연속 연산자의 미세 유한차분 풀이로 구하고, z = 0에서 + Ey와 Hx의 연속 조건으로 잇고, 모드 수와 상자 폭에 대해 수렴시킵니다. 기준: + `|T - T_mm|`가 0.05 µm에서 5e-3 이하, 0.025 µm에서 2e-3 이하이고 더 고운 간격에서 더 작음. + `|R - R_mm|`가 0.025 µm에서 `0.2 R_mm` 이하이고 0.05 µm보다 작음. +* **S6, 백엔드.** 0.05 µm의 설정 S를 NumPy, JAX, Torch에서 실행해 `injected`, `leakage`, + `forward(p2)`, `backward(q)`가 `|injected|` 대비 1e-12 이내로 일치. +* **S7, 미분.** JAX와 Torch에서 0.05 µm로: 1.55 µm의 `forward_energy(p2)`를 광원 `amplitude`로, + `Re forward(p2)`를 광원 `phase`와 `delay_seconds`로, 그리고 `forward_energy(p2)`를 광원과 + `p1` 사이에 있고 두 면 어디에도 닿지 않는 추적된 `Box` 산란체의 유전율로 미분한 값이 각각 + 중앙 차분(두 간격, Richardson 외삽)과 상대 1e-6 이내로 일치. +* **S8, 코드별 거부.** 면의 법선을 따르지 않는 방향(`source.direction`), 음수이거나 정수가 아닌 + 모드(`source.mode`), 찾은 갇힌 모드 수를 넘는 모드 번호(`mode.count`), 광원 면에 닿는 추적된 + 구조(`mode.traced`), 물리 영역 밖의 면(`source.outside_domain`), 시트에서 한 칸 안쪽의 + 측정기(`monitor.source_sheet`), `ModeSource`를 가리키지 않는 `source=`(`monitor.source`), + 추적된 펄스 파장(`mode.traced`), 엔진 자리표시자 `fd.ModeSource`(공개 광원을 알려주는 + `NotImplementedError`). + +S7 수정(어떤 미분도 계산하기 전): 등록한 대로(설정 S, 약 1만 2천 스텝)라면 역방향 미분이 모든 +스텝을 저장해 여기 JAX에서 스텝당 약 8 MB가 필요합니다. 전체 실행 시도 두 번은 끝나지 않고 멈췄고, +같은 설정의 15주기 시험 실행은 13 GB가 필요해 메모리 부족으로 종료되었습니다. 미분은 하나도 계산되지 +않았습니다. `Numerics(checkpoint_steps=)`는 Torch CUDA에서만 허용됩니다. 그래서 +모드 솔버 기록의 MM4b처럼, 미분과 중앙 차분을 같은 잘린 실행에 대해 구합니다: 같은 슬래브를 담은 +5 µm × 4 µm 물리 영역(x, z `Open`, 12칸 `PML`), z = -1 µm의 광원, 1.55 µm를 읽는 z = +0.5 µm의 +`p2`, `cutoff = 1e-3`·`frequency_sigma_hz = 2e13`인 설정 S의 펄스, `Duration(steps=1200)`, 그리고 +(1.2, 0, -0.25) µm 중심의 유전율 3.0 산란체. 네 가지 미분, 차분 간격, 1e-6 한계는 등록한 그대로입니다. + +## 결과 + +`tests/fdtd/test_fd_mode_source.py`로 NumPy(이름을 적은 곳은 JAX와 Torch), 배정밀도에서 +측정했습니다. 파장별 값은 1.45 / 1.55 / 1.65 µm 순서입니다. + +| 기준 | 한계 | 0.05 µm | 0.025 µm | 판정 | +| --- | --- | --- | --- | --- | +| S1 `forward(p2)`와 4 µm 진행한 주입 진폭 | `|injected|`의 1e-5 | 2.7e-11 / 1.3e-14 / 2.3e-11 | 1.1e-11 / 7.2e-13 / 1.2e-11 | 통과 | +| S1 거울상 광원(`-z`) | 1e-5 | 2.7e-11 / 1.3e-14 / 2.3e-11 | | 통과 | +| S2 `backward(q)`와 누설·투과 반사 | `|injected|`의 1e-6 | 7.7e-14 / 2.8e-15 / 1.5e-12 | 4.8e-14 / 6.5e-15 / 1.2e-12 | 통과 | +| S2 1.55 µm 예측 누설 전력 | 1e-12 | 6.8e-29 | 1.2e-29 | 통과 | +| S2 1.45 / 1.65 µm 예측 누설 전력 | 보고 | 1.4e-6 / 1.5e-6 | 1.3e-6 / 1.4e-6 | | +| S3 `forward_energy(p2)`와 `2 A^2 |W(f0)|^2` | 2e-5 | -1.2e-13 | -1.2e-13 | 통과 | +| S4 실행 굴절률과 솔버 격자 굴절률 | 1e-9 | 둘 다 1.969150425958 | 둘 다 1.959408135484 | 통과 | +| S4 정확한 TE0 굴절률에 대한 오차, 관측 차수 | 1.8 이상 | 1.29e-2 | 3.19e-3 | 통과(차수 2.02) | +| S5 TE0 투과, 모드 정합 `T_mm = 0.955057` | 0.05에서 5e-3, 0.025에서 2e-3, 더 작음 | 0.954677 (3.8e-4) | 0.954959 (9.8e-5) | 통과 | +| S5 TE0 반사, 모드 정합 `R_mm = 3.3287e-4` | 0.025에서 0.2 `R_mm`, 0.05보다 작음 | 3.5366e-4 (6.2 %) | 3.3777e-4 (1.5 %) | 통과 | +| S6 JAX / Torch와 NumPy | 1e-12 | 5.9e-16 / 1.3e-15(최대) | | 통과 | +| S7 미분과 외삽한 중앙 차분(수정한 잘린 실행), JAX·Torch: 진폭 / 위상 / 지연 / 산란체 | 1e-6 | 1e-10 미만 / 1e-10 미만 / 5e-11 / 1.1e-9 | | 통과 | +| S8 코드별 거부 | 목록대로 | 열한 가지 모두 | | 통과 | + +S5의 모드 정합 기준값은 쓰기 전에 자기 이산화에 대해 수렴시킵니다. 모드 수를 절반으로 줄이거나, +상자를 12에서 18 µm로 넓히고 모드를 늘려도 `T_mm`는 2e-4 미만, `R_mm`는 2 % 미만으로만 바뀝니다 +(테스트 자체의 검사). + +등록 뒤에 등록한 한계로 테스트를 더했습니다(한계는 바꾸지 않음). 일부러 깨뜨린 것 가운데 +두 가지를 등록한 테스트가 잡지 못했습니다. +- 방향에 맞춰 부호를 주지 않은 `-z` 광원의 H 패턴. 정확한 예측은 시트가 담은 것을 그대로 + 따르므로 S1만으로는 보이지 않습니다. +- Ex와 Hy가 공급하는 보정의 부호. TE0에는 둘 다 없습니다. + +그래서 거울상 광원도 중심 파장에서 S2의 한쪽 방향 한계(6.8e-29)와 S3의 전력 한계(-1.2e-13)로 +확인하고, TM0(모드 1) 방출도 S1·S2·S3 한계로 확인합니다(3.5e-11, 6.2e-29, -1.3e-13). 그러자 두 +깨뜨림과 Ex 보정의 부호 깨뜨림이 모두 실패했습니다. 모두 열여섯 가지 깨뜨림을 잡았습니다: H 행의 +반 칸 위상, 1 W 크기 맞춤, H 패턴의 방향 부호, 각 예측 식과 누설 부호, 각 보정의 부호, H 면의 위치, +커널의 행 오프셋, 측정기의 `source=` 검사, 시트 간격 검사, 방향 검사, 모드 창이 자기 광원으로 +다시 들어가는 것. 매번 소스를 바이트 단위로 되돌렸습니다. 한계가 없는 매개변수가 하나 있습니다. +H 행의 반 칸 군지연은 중심 밖 누설만 바꾸는데, S2는 그것을 제한하지 않고 보고하며 정확한 예측도 +그것을 따라갑니다. + +## Meep·FDTDX와의 비교 + +2026-10-30에 위의 모든 판정 뒤에 더했으며, 어떤 판정도 바꾸지 않습니다. 기대할 수 있는 정확도에 +대한 증거이지 합격 기준이 아닙니다. 같은 사례를 Meep 1.35.0(conda-forge의 pymeep, MPI 없음, +MPB 1.12.0)과 FDTDX 0.6.3(CPU의 JAX 0.12.0.1, 배정밀도)으로 돌렸습니다. 각 도구는 물리 단위로 적은 +기하와 파장에서 출발해 자기 프로세스에서 돌았고, Wavebit 객체는 어느 도구에도 닿지 않았습니다. +Meep은 GPL-2.0-or-later이며 별도 프로그램으로 실행했고 소스는 읽지 않았습니다. 값, 각 도구의 설정, +맞추지 못한 조건은 `tests/references/golden_stage7_cross_solver.json`과 그 기록에 고정했습니다. +`tests/references/test_stage7_cross_solver_golden.py`가 이 표와 아래의 각 문장을 그 값에 묶습니다. + +맞춘 것: Setup S와 S5 맞대기 접합, 같은 격자 간격(Meep 해상도 µm당 20, 40, 80), 같은 시간 간격(각 +도구에서 Courant 0.5), TE(Wavebit의 `Ey`는 Meep의 `Ez`, FDTDX의 `filter_pol="tm"`이며, FDTDX의 이름 +함정입니다). 각 도구는 자기 모드 해석기, 모드 광원, 모드 측정을 썼습니다. Meep은 `EigenModeSource`와 +`get_eigenmode_coefficients`, FDTDX는 `ModePlaneSource`와 `ModeOverlapDetector`입니다. Meep의 흡수층은 +기본 프로필의 `PML(1.2)`, FDTDX는 `extend_material_to_pml`을 쓴 기본 PML입니다. FDTDX의 단위 모드 +전력에는 면적 인자가 없으므로 비율만 비교합니다. Wavebit의 흡수층 행은 `fd-waveguide-through-pml.md`의 +D1(초기장 발사)이라 다른 둘과 같은 측정이 아닙니다. + +| 양 | Wavebit | Meep | FDTDX | +| --- | --- | --- | --- | +| 모드 해석기가 보고한 TE0 굴절률 − 정확한 값 (0.05 / 0.025 / 0.0125) | +1.3e-2 / +3.2e-3 / +7.9e-4 | -1.3e-3 / -5.6e-4 / -2.6e-4 | +1.8e-4 / +4.5e-5 / +1.1e-5 | +| 실행이 전파시킨 TE0 굴절률 − 정확한 값 (0.05 / 0.025) | +1.3e-2 / +3.2e-3 | +1.1e-2 / +2.6e-3 | +1.3e-2 / +3.2e-3 | +| 실행 굴절률 − Wavebit 실행 굴절률 (0.05 / 0.025) | 0 / 0 | -1.6e-3 / -5.9e-4 | -7.3e-9 / -7.7e-11 | +| 1.55 µm에서 뒤 / 앞 전력 (0.05 / 0.025) | 6.8e-29 / 1.2e-29 | 1.3e-3 / 9.2e-5 | 4.3e-7 / 6.4e-9 | +| 1.45 / 1.65 µm에서 뒤 / 앞 전력, 0.025 | 1.3e-6 / 1.4e-6 | 8.3e-5 / 1.0e-4 | 1.4e-6 / 1.4e-6 | +| 맞대기 접합, abs(T − T_mm) (0.05 / 0.025 / 0.0125) | 3.8e-4 / 9.8e-5 / - | 2.8e-3 / 1.0e-3 / 4.1e-4 | 5.0e-5 / 1.4e-5 / - | +| 맞대기 접합, abs(R − R_mm) / R_mm (0.05 / 0.025 / 0.0125) | 6.2e-2 / 1.5e-2 / - | 1.1e0 / 2.6e-1 / 7.2e-2 | 6.8e-1 / 2.3e-1 / - | +| 흡수층으로 들어간 코어: 되돌아온 TE0, 0.6 / 1.2 µm 층 (0.05) | 4.3e-8 / 3.5e-16 | 1.0e-4 / 1.0e-4 | 9.6e-5 / 9.6e-5 | +| 같은 것, 0.025 | - | 6.3e-6 / 6.3e-6 | - | + +비교가 보여 주는 것: + +- 세 실행은 하나의 이산계를 전파시킵니다. FDTDX의 실행 굴절률은 Wavebit과 7.3e-9, 7.7e-11 안에서 + 같고, Meep은 코어 면의 서브픽셀 평균 때문에 다릅니다. 모드 해석기가 보고하는 굴절률은 다른 + 문제입니다. Meep과 FDTDX는 연속체에 가깝고 Wavebit은 실행 자신의 값(S4)이므로, 정확한 값에서 가장 + 멀어 보입니다. +- 그 차이가 모드 발사의 한쪽 방향 정도를 정합니다. 중심 파장에서 한쪽으로만 나가는 것은 Wavebit + 발사뿐입니다. FDTDX의 누설은 격자 간격의 여섯제곱 가까이, Meep은 네제곱 가까이 줄어듭니다. 중심에서 + 벗어나면 FDTDX와 Wavebit은 비슷하게 샙니다. 둘 다 단면과 반 칸 위상을 중심에서 정하기 때문입니다. +- 모든 도구가 모드 정합 투과율로 수렴합니다. FDTDX가 가장 가깝고, 이유는 확인하지 않았습니다. 작은 + 반사율은 Wavebit이 가장 가깝습니다. Wavebit의 측정은 정확한 이산 직교성으로 방향을 나눕니다. +- 외부 도구의 흡수층 수치는 측정의 바닥입니다. 층을 두 배로 해도 1.5% 안에서만 바뀝니다. 층 자체의 + 반사라면 D1처럼 여러 자릿수가 줄어야 합니다. 그리고 Meep은 해상도에 따라 줄어듭니다. 층이 아니라 + 측정을 재는 것이며, 이 표로 흡수층의 순위를 매길 수 없습니다. diff --git a/docs/locales/ko/pages/validation/fdtd/fd-waveguide-modes.md b/docs/locales/ko/pages/validation/fdtd/fd-waveguide-modes.md new file mode 100644 index 00000000..a682ab7d --- /dev/null +++ b/docs/locales/ko/pages/validation/fdtd/fd-waveguide-modes.md @@ -0,0 +1,282 @@ +# 이산 Yee 계의 도파관 모드 + +작성: 2026-10-27 + +대상: `src/wavebit/fdtd/_modes.py`, `tests/fdtd/test_fd_waveguide_modes.py`. + +이 기록은 7단계 모드 해석기 `fdtd.solve_modes`를 다룹니다. 아래 수용 기준은 +해석기를 어떤 사례에도 실행하기 전에 등록했습니다. 결과를 본 뒤에 바꾸지 않으며, +통과하지 못한 기준은 이 기록에 실패로 남습니다. + +## 무엇을 푸는가 + +`fdtd.solve_modes`는 `fdtd.prepare`와 똑같이 문제를 준비하고, 시간 전진이 곱할 +갱신 계수를 읽습니다. 한 축 `w`에 수직인 면에서 각 Yee 성분의 대각 유전율과 +투자율(`eps_c = (dt/dx)/be_c`, `mu_c = (dt/dx)/bh_c`)을 가져와, `exp(i(beta w - omega t))` +꼴의 장에 대한 *이산* Maxwell 방정식의 고유값 문제를 풉니다. + +커널의 갱신식을 쓰는 Yee 격자에서 시간 조화 장은 연속 curl 방정식에서 두 가지를 +정확히 바꾼 식을 만족합니다. + +* 시간의 leapfrog에서 `omega -> Omega = (2/dt) sin(omega dt/2)`, +* 각 성분을 자기 Yee 위치 기준으로 놓으면, 진행 축의 엇갈린 차분에서 + `d/dw -> i q`, `q = (2/dx) sin(beta dx/2)`. + +가로 방향 미분은 각 면의 이웃 규칙을 쓰는 커널의 전진·후진 차분 그대로입니다. +세로 성분을 소거하면 가로 전기장 `e`에 대해 `q^2 e = Q P e`가 되고, 손실 없는 대각 +매질과 0·주기·벽 이웃에서는 `P`와 `Q`가 실수 대칭입니다. 해석기는 +`beta = (2/dx) asin(q dx/2)`와 물리 각주파수에서의 유효 굴절률 +`n_eff = beta c/omega`를 돌려줍니다. 연산자가 커널 자신의 것이므로 주입한 모드는 +연속계가 아니라 이산계의 모드입니다. 시간 전진 안에서의 잔차는 반올림 수준이고, +연속계 모드와의 차이는 격자의 이산화 오차입니다. + +`P`와 `Q`가 대칭이므로 전력 정규화와 모드 분해에 쓰는 정확한 이산 직교성이 +성립합니다. `q_m^2 != q_n^2`인 두 모드는 면 위에서 복소 켤레 없이 +`sum (E_u^m H_v^n - E_v^m H_u^n) = 0`입니다. 모드는 면을 지나는 시간 평균 전력이 +1 W(불변 축이 하나면 1 W/m)가 되도록 정규화합니다. + +## 등록한 수용 기준 (2026-10-27, 계산 전) + +M1, 균일 매질 대조. 가로 면은 `Periodic`, 매질 `epsilon = 2.25`, 간격 0.05 µm, +파장 1 µm, 2차원 단면과 `InvariantAxis` 하나인 경우 모두. 기본 모드는 +`q/Omega = 1.5`이고, `n_eff`가 Yee 분산의 닫힌 식 +`n_eff = (2/(dx k0)) asin(1.5 Omega dx/2)`과 상대 1e-12 안에서 같아야 합니다. + +M2, 속이 빈 금속 도파관. 두 가로 축 모두 `PEC`, a = 2 µm × b = 1 µm, +`epsilon = 2.25`로 채움, 파장 1 µm, 간격 0.05, 0.025, 0.0125 µm. 처음 다섯 모드가 +이산 닫힌 식 +`q^2 = Omega^2 eps - (2/dx)^2 [sin^2(m pi dx/(2a)) + sin^2(n pi dx/(2b))]` +(TE10, TE20, TE01, 겹친 TE11/TM11 쌍)과 `q^2`의 상대 1e-10 안에서 같아야 합니다. +연속계 `n_eff`에 대해, 이웃한 두 간격 사이의 관측 수렴 차수가 각 모드에서 +1.8 이상이어야 합니다. + +M3, 정확한 분산 관계와 비교하는 대칭 평판. 코어 두께 1.5 µm, `epsilon = 4` +(n = 2), 클래딩 `epsilon = 2.085136` (n = 1.444), 파장 1.55 µm, `InvariantAxis` +하나, 평판을 가로지르는 면은 평판 중심에서 4.5 µm 떨어진 `Open`(해석 창은 물리 +영역). 간격 0.05, 0.025, 0.0125 µm, 모두 코어 면과 정렬. TE0, TE1, TE2와 +TM0, TM1, TM2를 TE·TM 초월 방정식의 근과 비교합니다. + +* 두 번의 세분화와 여섯 모드 모두에서 관측 차수 1.8 이상, +* 0.0125 µm에서 여섯 모드 모두 `|n_eff - exact| <= 1e-3`, +* 가장 고운 두 간격의 2차 Richardson 외삽이 여섯 모드 모두 정확값과 1e-4 이내, +* 모든 간격에서 서로 다른 두 모드의 교차곱이 두 노름 곱의 1e-10 이하, 정규화 + 전력이 상대 1e-12 안에서 1. + +M4, 출판된 유전체 모서리 기준. G. R. Hadley, J. Lightwave Technol. 20, 1210 +(2002)의 "해석적으로 풀리는" 모서리 문제 네 개(명시 오차 1e-8 미만): 파장 1.5의 +단위 정사각형, 한 모서리의 정사각형 코어 `[0, 0.5]^2`, 지정한 변은 `PEC`, 나머지 +변은 `PMC`. 기준 유효 굴절률은 1.27627404(코어 2.25, 클래딩 1, 왼쪽·오른쪽 PEC), +2.65679692(8, 1, 왼쪽·오른쪽 PEC), 1.387926425(1, 2.25, 왼쪽·위 PEC), +2.761465320(1, 8, 왼쪽·위 PEC). 벽은 커널이 두는 자리(PMC 벽은 면에서 반 칸 떨어짐)에 +맞춰 정사각형의 변 위에 오게 하고, 코어는 평활 `Box`입니다. 간격은 1/40, 1/80, +1/160 부근의 세 값. 굴절률이 가장 큰 모드가 출판값에 다가가야 하며, 오차는 세 +간격에서 단조 감소하고 가장 고운 간격에서 1e-3 이하여야 합니다. 관측 차수는 +판정하지 않고 보고만 합니다. 유전체 모서리는 장의 특이점이고, 그것이 그 논문의 +주제입니다. + +M5, 이름을 대는 거부: 창 안의 전도성·분산 매질, 면에서 축을 따라 균일하지 않은 +매질, `Bloch`나 `MirrorSymmetry` 가로 면, `k`가 0이 아닌 `InvariantAxis`, 창 안의 +결합 평활(constitutive) 또는 확장 스텐실 재료, 추적되거나 기울기를 지닌 입력 +(고유값 계산은 호스트 계산이며 미분하지 않음), 물리 영역 밖의 면, 존재하는 것보다 +많은 갇힌 모드 요청. `Open` 면을 가로지르는 경우, 창의 가장 바깥 칸에서 가로 +전기장의 최대값이 최고값의 1e-3 이하인 모드만 받습니다. + +M6, 백엔드. `backend='numpy'`, `'jax'`, `'torch'`로 돌려준 배열은 비트까지 같아야 +합니다. 고유값 계산은 호스트에서 한 번 실행하고 결과를 변환합니다. + +## 추가 등록 (2026-10-27, 계산 전) + +처음 시험들의 변이 시험에서, M1–M4가 중요한 결함 두 가지를 보지 못한다는 것이 +드러났습니다. 잘못된 Yee 위치에서 읽은 유전율로 만든 모드, 그리고 `asin` 없이 +`q`에서 바로 읽은 전파 상수입니다. 두 결함 모두 대칭 평판에서는 2차 수렴을 +유지합니다. 직접 확인하는 방법은 풀어낸 모드가 시간 전진 자체의 고유모드인지 보는 +것이므로, 아래 기준을 처음 실행하기 전에 추가합니다. + +M7, 모드는 실행의 모드다. 3 µm × 3 µm `PEC` 상자 안의 1 µm × 0.5 µm +`epsilon = 4` `SnappedBox` 코어(간격 0.05 µm, 파장 1 µm)와, `Open` 대신 `PEC` 면을 +쓴 M3 평판(0.05 µm). 각각 전파 축은 `Bloch`이고 그 `k`는 여덟 칸에 걸친 모드의 +`beta`입니다. 실행은 모드(t = 0의 E, -dt/2의 H, 각 성분은 자기 Yee 점)에서 +시작해 100단계를 갑니다. 최종 E와 H의 모든 성분이 `exp(-i omega t)`로 진행시킨 +모드와, 그 장의 최대값의 1e-10 안에서 같아야 합니다. 각 도파관에서 굴절률이 가장 +큰 두 모드에 대해 확인합니다. + +M1과 M2는 `Omega`를 해석기 자신의 출력이 아니라 `fdtd.check`가 보고하는 시간 +간격에서 계산합니다. + +M7 사례 하나를 더, 그 이유를 보여 준 변이 실행 뒤·처음 실행 전에 등록합니다. +칸 맞춤 코어에서는 각 성분의 유전율 배열이 첨자마다 같으므로, 한 성분의 계수를 +다른 성분 대신 읽어도 아무것도 바뀌지 않습니다. 그래서 같은 M7 기준을 면이 칸 +사이에 있고 성분별 평균이 서로 다른 1.03 µm × 0.51 µm 평활 `Box` 코어에도 +적용합니다. + +## 결과 (2026-10-27) + +Linux CPU, Python 3.12, NumPy 2.5.4, SciPy 1.18.1에서 +`tests/fdtd/test_fd_waveguide_modes.py`로 측정했습니다. 등록한 기준은 모두 통과했습니다. + +| 기준 | 한계 | 측정 | +| --- | --- | --- | +| M1 `q/Omega`, 닫힌 식 대비 `n_eff`(2차원; 불변 축) | 1e-12 | 4.6e-15, 4.8e-15; 4.4e-16, 5.9e-16 | +| M2 이산 닫힌 식 대비 `q^2`, 다섯 모드, 세 간격 | 1e-10 | 7e-14, 7e-14, 1.1e-12(간격별 최악) | +| M2 연속계 대비 관측 차수 | >= 1.8 | 2.024–2.027, 이어서 2.006–2.007 | +| M3 관측 차수, 여섯 모드 | >= 1.8 | 2.014–2.019, 이어서 2.003–2.005 | +| M3 0.0125 µm에서 `|n_eff - exact|` | <= 1e-3 | 5.2e-4(TM2)–7.9e-4(TE0) | +| M3 Richardson 외삽 | <= 1e-4 | 1.6e-6–3.5e-6 | +| M3 직교성 / 전력 | 1e-10 / 1e-12 | 모든 간격에서 통과 | +| M4 Hadley 모서리, 세 간격의 오차 | 단조, <= 1e-3 | 아래 표 | +| M5 거부 | 코드별 | 모든 거부가 자기 코드로 발생 | +| M6 백엔드 | 비트 단위 | 같음 | +| M7 실행이 진행시킨 모드, 100단계 | 1e-10 | 최대 6.4e-14(평판), 2.1e-14(칸 맞춤 상자), 1.3e-14(평활 상자) | + +평판의 정확한 굴절률(TE0, TE1, TE2, TM0, TM1, TM2)은 1.9562181938, 1.8225308386, +1.5975660835, 1.9470205470, 1.7879894882, 1.5490399425입니다. 0.05, 0.025, +0.0125 µm에서 해석기 값은 각각 1.29e-2–8.4e-3, 3.2e-3–2.1e-3, 7.9e-4–5.2e-4만큼 +큽니다. 세 번째 TM 모드의 창 가장자리 가로 E 최대값은 최고값의 4e-4로, 받아들이는 +한계 1e-3 안이며 여기서 가장 덜 갇힌 모드입니다. + +Hadley 모서리의 부호 있는 오차, 1/40, 1/80, 1/160(왼쪽·위 PEC 경우는 시험에 적은 +대로 PMC 벽이 간격을 정하므로 그 값 부근): + +| 코어, 클래딩, PEC 변 | 출판값 | 오차 | 관측 차수 | +| --- | --- | --- | --- | +| 2.25, 1, 왼쪽/오른쪽 | 1.27627404 | +1.10e-3, +2.74e-4, +6.84e-5 | 2.00, 2.00 | +| 8, 1, 왼쪽/오른쪽 | 2.65679692 | +8.86e-3, +2.20e-3, +5.49e-4 | 2.01, 2.00 | +| 1, 2.25, 왼쪽/위 | 1.387926425 | +1.17e-3, +2.89e-4, +7.17e-5 | 2.02, 2.01 | +| 1, 8, 왼쪽/위 | 2.761465320 | +9.69e-3, +2.37e-3, +5.85e-4 | 2.03, 2.02 | + +평활 코어를 쓴 이 격자의 모서리에서 2차 수렴이 유지되었습니다. 이 간격들에서는 +유전체 모서리의 장 특이점이 드러나지 않았습니다. 세 간격에서의 관찰이며 더 고운 +격자에 대한 주장은 아닙니다. + +이것이 보이지 않는 것: 손실·분산·이방성·누설 도파관의 정확도(모두 거부)와, 어떤 +실행의 정확도(여전히 그 실행의 간격 수렴이 필요). 유효 굴절률은 정의상 격자의 +값입니다. + +변이 검사(아래 제품 변경을 각각 넣었을 때 해당 시험이 실패했고, 소스는 바이트 단위로 +복원): `Omega` 대신 `omega`(M1, M2), 아래쪽 벽의 가로 E 마스크 제거(M2), 전력의 코사인 +인자 제거(M3), PMC 위쪽 면 마스크 제거(M4), `eps_w` 대신 `eps_u` 읽기, `eps_u`와 +`eps_v` 교환, `beta = q`, `E_w` 부호 반전, `H` 대신 `Z0 H` 반환(M7), 각 거부 끄기(M5), +`backend='jax'`에 NumPy 배열 반환(M6). + +## ModeMonitor: 등록한 수용 기준 (2026-10-27, 계산 전) + +`fdtd.ModeMonitor(name, plane=..., wavelengths=..., modes=n)`은 면 위의 실행 DFT 장(노드 면의 +E와 두 H 반 칸 면의 평균, `FluxMonitor`와 같은 짝)을 각 파장에서 해석기가 돌려준 처음 +`n`개 모드에 사영합니다. 단위 커널 플럭스로 정규화한 모드 `(e, h)` +(`N = sum e . h dA = 2/cos(beta dx/2)`)에 대해 `X = sum (E_u h_v - E_v h_u) dA`, +`Y = sum (e_u H_v - e_v H_u) dA`로 두면, 직교성으로 앞뒤 방향 진폭 +`a = (X/N + Y/(N cos))/2`, `b = (X/N - Y/(N cos))/2`가 모드로 이루어진 장에서 정확히 +나옵니다. `|a|^2`와 `|b|^2`를 `FluxMonitor`가 플럭스를 바꾸는 방식대로 바꾼 값이 그 +모드가 앞·뒤로 나르는 양측 펄스 스펙트럼 에너지(J/Hz)입니다. + +MM1, 공개 경로를 통한 정확한 방향 분리. M7의 세 도파관(칸 맞춤 상자, 평판, 평활 상자)에서 +`k`가 `+beta`(또는 `-beta`)인 여덟 칸의 `Bloch` 축, `+z`(또는 `-z`)로 가는 모드 0 또는 +모드 1에서 시작한 200단계 실행, 그 모드의 파장에서 `modes=2`인 `ModeMonitor`. 반대 +방향과 다른 모드가 각각 쏘아 보낸 진폭의 1e-10 이하이고, 쏘아 보낸 모드의 스펙트럼 +에너지가 같은 면의 `FluxMonitor`와 상대 1e-10 안에서 같아야 합니다. + +MM2, 플럭스와 비교한 완전성. `epsilon = 2.25`로 채운 1 µm × 0.5 µm `PEC` 빈 도파관, +간격 0.05 µm, z 방향은 기본 PML의 `Open`, 짝·홀 모드를 모두 여기하도록 중심에서 벗어난 +`CurrentDipole`(`Ey`), 양쪽 3 µm에 측정기, 일곱 모드가 전파하는 파장 1.05 µm. 각 +측정기에서 일곱 모드의 앞 방향에서 뒤 방향을 뺀 스펙트럼 에너지 합이 같은 면의 +`FluxMonitor` 스펙트럼 에너지와 상대 1e-6 안에서 같아야 합니다. 실행은 감쇠 진단이 장이 +가라앉았다고 보고할 만큼 깁니다. + +MM3, 백엔드. NumPy, JAX, Torch가 MM2 진폭을 가장 큰 값의 상대 1e-12 안에서 같게 줍니다. + +MM4, 미분. MM2 도파관에서 쌍극자와 +z 측정기 사이에 `epsilon = 3`인 `SnappedBox` 블록(두 +측정 면에 닿지 않음)을 두고, 그 유전율에 대한 모드 0 앞 방향 스펙트럼 에너지의 미분을 +`jax.grad`와 Torch autograd로 구한 값이 간격 `1e-4 * epsilon`의 중앙 차분과 상대 1e-6 +안에서 같아야 합니다. + +MM5, 이름을 대는 거부: 축을 따라 매질이 바뀌는 측정 면(`mode.nonuniform`), 측정 면에 닿는 +추적되거나 기울기를 지닌 구조(`mode.traced`), 갇힌 것보다 많은 모드(`mode.count`), 양수가 +아닌 `modes`(`monitor.modes`), 물리 영역 밖의 면. + +### MM2 결과와 등록한 대체 사례 (2026-10-27) + +등록한 MM2는 **실패**했습니다. 전제 조건이 충족되지 않았습니다. 광원 150주기 뒤 감쇠 진단은 +측정 영역에 최고 에너지의 2.0e-3이 아직 남았다고 보고했고(기준 1e-5), 모드 합은 플럭스와 +4.5e-5만큼 달랐습니다(한계 1e-6). 더 긴 실행(300주기, 600주기)도 가라앉지 않았습니다. +원인은 물리적이며 구성에 있습니다. 닫힌 금속 도파관은 각 모드의 차단 주파수(군속도가 0)에 +있는 스펙트럼 성분을 가두므로, 스펙트럼이 차단 주파수들을 가로지르는 광대역 펄스는 +도파관을 떠나지 않습니다. + +가라앉지 않은 장이 불일치를 설명합니다. 실행 자체의 `FluxMonitor` 값이 손실 없는 도파관을 +따라 z = 1.5–3.5 µm 사이에서 3%(λ = 1.05 µm)에서 14%(0.9 µm)까지 달랐고, 잘 전파하는 각 +모드의 알짜 모드 에너지는 z를 따라 일정했으며 차단에 가까운 TE21 모드만 변했습니다. +감쇠하지 않은 실행의 푸리에 장은 두 이산 curl 방정식을 동시에 만족하는 해가 아닙니다. 끝 +보정(`src/wavebit/fdtd/_post.py::end_correction`)은 각 장의 자기 갱신식을 다른 장의 *보정하지 +않은* 변환에 대해 정확하게 만들므로, 보정한 쌍에는 마지막 장 수준의 잔여가 남습니다. +보정한 페이저 상자에서 상대 curl 잔차는 0.7%(Faraday), 5–7%(Ampère)였고, 같은 curl을 시간 +영역 장에 적용하면 4.7e-15로 닫힙니다. 이것은 감쇠하지 않은 실행에 대한 엔진의 문서화된 +동작(`UnsettledFieldWarning`)이며, 여기서는 기록만 하고 바꾸지 않습니다. + +MM2b, MM2 실패 뒤·실행 전에 등록: MM2 도파관과 측정기에, 이 격자에서 정확히 일곱 모드가 +전파하는 대역 안에 머무는 좁은 대역 펄스를 씁니다. 바로 위 모드와 마지막 아래 모드의 이산 +차단 파장은 1.0087 µm와 1.0644 µm입니다. 펄스 중심은 1.036 µm, 주파수 표준편차는 +1.3e12 Hz(두 대역 끝이 모두 표준편차의 여섯 배 넘게 떨어짐), `cutoff=1e-9`이고 측정기는 +1.036 µm를 읽습니다. 실행은 장이 가라앉았다고 보고해야 하며, 두 측정기에서 일곱 모드 합이 +플럭스와 상대 1e-6 안에서 같아야 합니다. + +### MM4 결과와 등록한 대체 기준 (2026-10-27) + +등록한 MM4는 **실패**했습니다. 800단계(펄스 앞부분이 +z 측정기를 지나기에 충분함, 역방향 +미분은 모든 단계를 보관함)를 실행했을 때 `jax.grad`는 유전율 단위당 8.794522e-20 J/Hz를 +주었고, 등록한 간격 `3e-4`의 중앙 차분은 9.6e-6만큼 컸습니다(한계 1e-6). 등록한 간격이 너무 +컸습니다. 같은 `jax.grad` 값과 다섯 간격의 중앙 차분 비교: + +| 간격 | 상대 차이 | +| --- | --- | +| 3e-3 | 9.57e-4 | +| 1e-3 | 1.06e-4 | +| 3e-4 | 9.57e-6 | +| 1e-4 | 1.06e-6 | +| 3e-5 | 1.26e-7 | + +차이는 간격의 제곱으로 줄어듭니다. 중앙 차분 자체의 절단 오차이며 AD 값으로 수렴합니다. +Torch는 이 진단 전에 계산하지 않았습니다. + +MM4b, 이 진단 뒤·실행 전에 등록(위의 JAX 단순 중앙 차분은 이미 보았고, 아래 조합은 계산하지 +않음): MM4 미분을 `jax.grad`와 Torch autograd로 구한 값이, `h = 3e-4`와 `h/2`의 중앙 차분을 +조합한 4차 식 `(4 D(h/2) - D(h))/3`과 상대 1e-7 안에서 같아야 합니다. + +MM6, 변이 실행으로 MM1이 면에서 유한 범위를 잘라내는 방식을 보지 못한다는 것(모든 MM1 창은 +단면 전체를 덮음)이 드러난 뒤·실행 전에 등록: x = ±4.5 µm에 `PEC` 면을 둔 MM1 평판을 MM1처럼 +쏘아 보내고(모드 0과 1, 두 방향), 면이 x = (-3, 3) µm만 덮는 `ModeMonitor`와 `FluxMonitor`로 +읽습니다. 그러면 창의 벽이 실행의 모드를 장이 작지만 0은 아닌 곳에서 자르므로 한계는 MM1보다 +느슨합니다. 반대 방향과 다른 모드가 각각 쏘아 보낸 진폭의 1e-8 이하이고, 쏘아 보낸 모드의 +스펙트럼 에너지가 같은 범위의 플럭스와 상대 1e-8 안에서 같아야 합니다. + +### ModeMonitor 결과 (2026-10-27) + +`tests/fdtd/test_fd_mode_monitor.py`로 측정했습니다(위와 같은 환경). + +| 기준 | 한계 | 측정 | 판정 | +| --- | --- | --- | --- | +| MM1 반대 방향 / 다른 모드 / 에너지 대 플럭스, 12가지 발사 | 1e-10 | 4.7e-15 / 6.8e-16 / 4.8e-16 (최악) | 통과 | +| MM2 광대역 펄스, 일곱 모드 합 대 플럭스 | 1e-6, 가라앉음 | 4.5e-5, 가라앉지 않음 | **실패** (예상 실패로 보존) | +| MM2b 좁은 대역 펄스, 일곱 모드 합 대 플럭스 | 1e-6, 가라앉음 | 두 측정기 모두 1.7e-10, 최고 에너지의 5.0e-15 남음 | 통과 | +| MM3 백엔드, MM2 진폭 | 1e-12 | 6.2e-16 (JAX), 3.4e-16 (Torch) | 통과 | +| MM4 간격 3e-4의 중앙 차분 대 AD | 1e-6 | 9.6e-6 | **실패** (예상 실패로 보존) | +| MM4b 4차 외삽 차분 대 AD | 1e-7 | 1.27e-8 (JAX와 Torch) | 통과 | +| MM5 거부 | 코드별 | `check`와 `run`에서 발생(모드 개수는 `run`에서) | 통과 | +| MM6 유한 범위: 반대 방향 / 다른 모드 / 에너지 대 플럭스 | 1e-8 | 3.1e-13 / 0 / 7.2e-13 | 통과 | + +MM2b에서 +z 측정기는 가장 느린 두 모드에서만 뒤 방향 에너지를 읽습니다. 앞 방향 합의 +2.8e-5(TE21)와 1.5e-6(TM21)입니다. 끝 PML이 가장 잘 흡수하지 못하는 차단 근처 모드를 +되돌려 보낸 것이며, 여기서는 판정하지 않고 측정값만 적습니다. + +변이 검사(각각 넣었을 때 시험이 실패했고 소스는 바이트 단위로 복원): 앞·뒤 조합 바꾸기, +H 겹침의 코사인 인자 제거, 두 H 반 칸 면의 평균 대신 한 면 읽기, 스펙트럼 배율 제거(MM1), +유한 범위 창의 자르기 위치 이동(MM6), 창의 조기 검사 제거(MM5), 창에 닿는 구조 대신 모든 +구조로 창 풀기(MM4b), 일곱 대신 여섯 모드 합산(MM2b). + +### Open 면을 가로지르는 창 (2026-10-27) + +`Open` 면을 가로지르는 `ModeMonitor`는 처음에 실행 중 거부되었습니다. 플럭스 면이 창의 +가장자리 노드를 읽었는데, 물리 가장자리에서는 그것이 흡수층의 첫 노드입니다. 이제 창은 +측정기와 `solve_modes` 모두 각 `Open` 쪽에서 물리 가장자리보다 한 칸 안쪽에서 멈추며, 회귀 +시험이 둘이 같은 모드를 쓰는지 확인합니다. 면이 `Open`인 M3 평판을 옮긴 벽으로 다시 +실행했고, 위에 적은 모든 자릿수가 그대로입니다(그곳의 장은 최고값의 1e-9 미만). + diff --git a/docs/locales/ko/pages/validation/fdtd/fd-waveguide-through-pml.md b/docs/locales/ko/pages/validation/fdtd/fd-waveguide-through-pml.md new file mode 100644 index 00000000..24ecfe6f --- /dev/null +++ b/docs/locales/ko/pages/validation/fdtd/fd-waveguide-through-pml.md @@ -0,0 +1,74 @@ +# 흡수층을 지나는 칸 맞춤 도파관 + +작성: 2026-10-27 + +대상: `src/wavebit/fdtd/_lower_public.py` (`_continue_snapped`와 평판 가로지르기 규칙), +`tests/fdtd/test_fd_waveguide_pml.py`. + +끝이 열린 도파관은 양 끝의 흡수층으로 코어가 이어져야 합니다. 이번 변경 전에는 공개 +낮춤(lowering)이 단면보다 좁은 물체가 그곳에 닿는 것을 거부했습니다. 엔진은 원래 층을 +곧게 지나는 구조를 받아 왔습니다. 그러면 층 안에 경계면이 없기 때문입니다. 공개 규칙이 더 +엄격했던 것입니다. + +여기서 여는 문은 좁습니다. `SnappedBox`는 해당 물리 면에 닿을 때 +`Structure(continuation=('z-', 'z+'))`(어느 한 축)를 선언할 수 있고, 그러면 단면을 바꾸지 +않은 채 그 면들의 패딩으로 늘어납니다. `SnappedSlab`은 자신이 따라 달리는 축들의 면을 지정할 +수 있습니다. 그러면 PML의 모든 층에서 코어의 칸이 같습니다. 매질은 흡수 축을 따라 균일하며, +층이 맞춰진 매질이 아닌 코어가 들어 있을 뿐입니다. 단면보다 좁은 평활 물체(`Box`, `Cylinder`, +`Prism` 코어)는 계속 거부합니다. 그것은 따로 연구되었고 받아들여지지 않은 별개의 문제이며, +여기서 바꾸는 것은 없습니다. 지정하지 않은 `Open` 면에 닿는 물체는 전과 같이 거부합니다. + +## 등록한 수용 기준 (2026-10-27, 계산 전) + +도파 모드의 반사는 코어 안의 광원과 먼 쪽 흡수층 사이에서 `ModeMonitor`로 재며, 읽는 +파장에서 모드 0의 뒤 방향 대 앞 방향 전력입니다. + +D1, 평판 도파관. 모드 해석기 기록의 M3 평판(코어 1.5 µm, `epsilon = 4`, 클래딩 +`epsilon = 2.085136`, `InvariantAxis` y), x는 ±4.5 µm에서 `Open`, z는 ±4 µm에서 `Open`이고 +코어는 두 z 면을 지나 이어짐, 간격 0.05 µm, 기본 `PML`(12칸), 코어 안 z = -2 µm의 `Ey` +`CurrentDipole`(펄스 중심 1.55 µm), z = +2 µm에서 1.55 µm를 읽는 `ModeMonitor`: + +* TE0 전력 반사 `|b0/a0|^2`가 12칸에서 1e-5 이하이고, 24칸에서 12칸보다 작음, +* 400주기 실행에서 `GrowingFieldWarning` 없음. + +D2, 직사각 코어. 진공 속 1 µm × 0.5 µm, `epsilon = 4`인 `SnappedBox` 코어, x와 y는 +`Open`(모드 꼬리가 가장자리에서 측정기의 갇힘 한계 1e-3 아래가 되도록 물리 단면 +3 µm × 2.5 µm), z는 ±2.5 µm에서 `Open`이고 코어는 두 z 면을 지나 이어짐, 간격 0.05 µm, +기본 `PML`, 코어 안 z = -1 µm의 `Ex` 쌍극자(펄스 중심 1 µm), z = +1 µm에서 1 µm를 읽는 +`ModeMonitor`: 기본 모드의 전력 반사가 12칸에서 1e-5 이하이고, 24칸에서 12칸보다 작음. + +D3, 코드(`fdtd.padding_continuation`)로 거부: 단면보다 좁고 continuation을 선언한 평활 +`Box` 코어, 다른 축의 `Open` 면에 닿는 이어진 `SnappedBox`, 패딩 없는 면을 지정한 +continuation, 지정한 면에 닿지 않는 이어진 `SnappedBox`, 자기 축의 면을 지정한 +`SnappedSlab`. + +D1 수정(처음 실행 전): `CurrentDipole`은 `InvariantAxis`에서 거부되므로(스탬프가 불변 축을 +따라 균일하지 않음) D1의 광원은 초기장으로 바꿉니다. E는 `solve_modes`가 돌려주는 1.55 µm의 +평판 TE0 모드에 `exp(-(z + 2)^2 / (2 (0.6 µm)^2)) cos(beta0 (z + 2))`(`beta0`는 그 모드의 전파 +상수)를 곱한 것이고 H는 0입니다. z = -2 µm에서 TE0를 양쪽으로 쏘아 보내며 스펙트럼 중심은 +1.55 µm 부근입니다. 나머지는 등록한 그대로입니다. + +## 결과 + +`tests/fdtd/test_fd_waveguide_pml.py`로 NumPy, 배정밀도에서 측정했습니다. + +| 기준 | 한계 | 12칸 | 24칸 | 판정 | +| --- | --- | --- | --- | --- | +| D1 슬래브 TE0 전력 반사 | 12칸에서 1e-5 이하, 24칸에서 더 작음 | 4.3e-8 | 3.5e-16 | 통과 | +| D1 400주기 동안 `GrowingFieldWarning` 없음 | 없음 | 없음 | 없음 | 통과 | +| D2 사각 코어 기본 모드 전력 반사 | 12칸에서 1e-5 이하, 24칸에서 더 작음 | 1.3e-6 | 3.5e-12 | 통과 | +| D3 코드별 거부 | `fdtd.padding_continuation` | 다섯 가지 모두 | | 통과 | + +실행에 관한 두 가지는 한계를 바꾼 것이 아닙니다. D1에는 PML 파장을 추론할 광원이 없으므로 +`PML`에 1.55 µm를 명시했고, 400주기는 같은 길이의 초 단위 시간으로 적었습니다. D2의 펄스 폭은 +등록하지 않았습니다. 처음 시도한 폭(`frequency_sigma_hz = 3e13`)은 어떤 스텝도 진행하기 전에 +해상도 검사에서 거부되었고(가장 짧은 파장이 코어 안에서 8칸 미만으로 표본화됨), 실행은 +`1.5e13`을 씁니다. D2의 코어는 1 µm에서 파장당 약 10칸으로 표본화되므로 `CoarseMeshWarning`이 +나며, 측정한 반사는 그 격자 위 모드의 반사입니다. + +등록 뒤에 거부·허용 테스트를 더했습니다(한계는 바꾸지 않음): 지정한 두 면 중 한쪽에만 닿는 +연장 코어(양쪽 각각), 두 축의 면을 지정한 continuation, 등록한 D1·D2 장면이 `check`와 +`prepare`를 통과하는지, 그리고 낮춘(lowered) 코어가 단면을 유지한 채 할당 영역 전체에 걸치는지. +새 규칙마다 일부러 깨뜨려 테스트가 실패하는 것을 확인했습니다. 한 축 규칙, 두 도달 검사 각각, +패딩 검사, 양쪽 면을 지나는 연장 각각, 슬래브 자기 축 규칙, 교차 규칙 예외를 없애고, 평활 +`Box`가 이어지도록 바꿨습니다. 그 뒤 소스를 바이트 단위로 되돌렸습니다. diff --git a/docs/reference/fdtd/index.md b/docs/reference/fdtd/index.md index 7b95069f..feefae4c 100644 --- a/docs/reference/fdtd/index.md +++ b/docs/reference/fdtd/index.md @@ -171,6 +171,33 @@ Grid, duration, absorber and stopping rule: choices that do not change the physi .. autoclass:: wavebit.fdtd.FarFieldSpectrum :members: ``` + +## Waveguide modes + +Modes of the discrete Yee system on a plane across a uniform cross-section; see the +[waveguide-mode guide](../../guide/fdtd/waveguide-modes.md). + +```{eval-rst} +.. autofunction:: wavebit.fdtd.solve_modes + +.. autoclass:: wavebit.fdtd.WaveguideModes + :members: + +.. autoclass:: wavebit.fdtd.WaveguideMode + :members: + +.. autoclass:: wavebit.fdtd.ModeMonitor + :members: + +.. autoclass:: wavebit.fdtd.ModeSpectrum + :members: + +.. autoclass:: wavebit.fdtd.ModeSource + :members: + +.. autoclass:: wavebit.fdtd.GaussianBeam + :members: +``` ## Native-grid and periodic-stack formulations See [native-grid units and observations](../../guide/fdtd/native-grids.md) and diff --git a/docs/validation/fdtd/fd-gaussian-beam.md b/docs/validation/fdtd/fd-gaussian-beam.md new file mode 100644 index 00000000..74eac730 --- /dev/null +++ b/docs/validation/fdtd/fd-gaussian-beam.md @@ -0,0 +1,207 @@ +# A focused Gaussian beam + +Written: 2026-10-27 + +Targets: `src/wavebit/fdtd/_beam_source.py`, the `GaussianBeam` declaration in +`src/wavebit/fdtd/_sources.py`, `tests/fdtd/test_fd_gaussian_beam.py`. + +`fdtd.GaussianBeam` launches a focused beam from a plane, one way. It uses the one-sided +sheet of the mode source (`fd-mode-source.md`), with a beam for its incident field. On the +focal plane, the polarized transverse E sampled at its own Yee points is +`exp(-r^2 / waist^2)` about the focus (`waist` is the 1/e amplitude radius, as for +`rcwa.GaussianBeam`). The beam is the propagating part of that plane's angular spectrum, +built from the grid's own discrete plane waves in the homogeneous medium at the sheet. + +At the pulse's centre frequency the beam is therefore an exact solution of the grid, and the +sheet launches it one way only. Away from the centre the sheet also launches a little of it +backward. That part is computed exactly, per plane wave, from the actual waveform rows. The +angular-spectrum representation is standard (Goodman, *Introduction to Fourier Optics*, +ch. 3). The oracles below are independent continuum computations written in the test. + +## Registered acceptance criteria (2026-10-27, before computing) + +**Setup B (two-dimensional).** Vacuum, x `Open` at ±6 µm, y `InvariantAxis`, z `Open` at +±5 µm, a 24-cell `PML` at 1 µm. `GaussianBeam('b')` on the plane z = -4 µm, direction `+z`, +waist 1.5 µm, focus at the origin, amplitude 1 (square-root watts per metre). Its pulse is +centred at 1 µm with `frequency_sigma_hz = 2e13` and `cutoff = 1e-12`. Polarization `y` +(TE: Ey, Hx, Hz) and `x` (TM: Ex, Ez, Hy). Spacings 0.05 and 0.025 µm. No +`UnsettledFieldWarning` or `GrowingFieldWarning` in any run. + +* **G1, waist.** On the focal plane at 1 µm, `w = 2 sqrt()` of the transverse + polarized component's `|E|^2` matches the waist: `|w / 1.5 µm - 1| <= 1e-6`, for both + polarizations and both spacings. +* **G2, Gouy phase.** TE, on the axis (a `FieldMonitor` on the plane x = 0), at 1 µm. The + phase is taken relative to the grid's axial plane wave, + `psi(z) = arg Ey(0, z) - beta_axis z` (with `beta_axis` from the grid's dispersion), and + referred to z = 0. It is compared with the same quantity of the exact continuum angular + spectrum of the same focal field, `arg integral A(kx) exp(i kz z) dkx - k z`. The largest + difference over z in [-3, 3] µm is at most 2e-3 rad at 0.05 µm and falls to 0.025 µm with + an observed order of at least 1.8. The paraxial `arctan(z / zR) / 2` is reported beside it. +* **G3, focal shift.** TE, waist 0.6 µm, a dielectric half-space `epsilon = 2.25` for + z > -1 µm (a `SnappedSlab` across z reaching the z+ face). The sheet is at z = -2.5 µm and the + focus is declared at z = +0.5 µm (where the beam would focus in vacuum). The on-axis + intensity peak inside the dielectric (a parabola through the five largest samples) is + compared with the peak of the exact continuum angular spectrum with the TE Fresnel + transmission at the interface. The difference is at most 0.03 µm at 0.05 µm and at most + 0.01 µm at 0.025 µm, smaller at the finer spacing. The paraxial focus + (`-1 + 1.5 * 1.5 = 1.25` µm) is reported beside it. +* **G4, power.** A continuous wave of amplitude `A` carries `A^2` watts (per metre here), so + the spectral energy through a `FluxMonitor` at z = +3 µm at 1 µm is `2 A^2 |W(f0)|^2`, `W` + the continuous Fourier transform of the unit waveform. It matches to 2e-5 (relative) for + both polarizations and both spacings. The same holds in three dimensions: x and y `Open` + at ±3 µm, z `Open` at ±2.5 µm, a 12-cell `PML`, waist 1 µm, polarization `x`, the sheet at + z = -2 µm, the monitor at z = +2 µm, spacing 0.05 µm. +* **G5, back-radiation.** TE, both spacings, at 0.95, 1.0 and 1.05 µm. The backward spectral + energy through a `FluxMonitor` at z = -4.5 µm (behind the sheet) matches the predicted + backward launch to `1e-6` of the predicted forward launch. The forward spectral energy at + z = +3 µm matches the predicted forward launch to 1e-5 (relative). The predicted backward + launch at 1.0 µm is at most 1e-12 of the forward; at 0.95 and 1.05 µm it is reported. +* **G6, backends.** The TE run of G1 at 0.05 µm on NumPy, JAX and Torch: the flux at + z = +3 µm and the on-axis field agree to 1e-12 (relative). +* **G7, derivatives.** On JAX and Torch, TE at 0.05 µm: the derivatives of the flux at + z = +3 µm at 1 µm with respect to the beam's `amplitude`, of `Re Ey(0, z = 3 µm)` with + respect to its `phase` and `delay_seconds`, and of the flux with respect to the + permittivity of a traced `SnappedBox` scatterer between the sheet and the monitor, off the + axis and clear of the sheet plane. Each matches a central difference (two steps, + Richardson-extrapolated) to 1e-6 relative. +* **G8, refusals by code.** A direction off the plane's normal (`source.direction`); a + polarization along the normal (`source.polarization`); a non-positive waist + (`source.waist`); a focus that is not a point (`source.focus`); a traced waist + (`source.traced`); a traced pulse wavelength (`mode.traced`); a medium that is not uniform + on the plane (`source.medium`); a metal transverse face (`source.boundary`); a beam that + the cross-section does not hold (`source.beam_size`); a monitor within a cell of the sheet + (`monitor.source_sheet`); the engine placeholder `fd.GaussianBeam` (`NotImplementedError` + naming the public source). + +Amendment to G4, before its three-dimensional case was first run: at the sheet (2 µm from the +focus, Rayleigh range 3.14 µm) the beam's radius is 1.19 µm, so on the outermost cells of a +±3 µm cross-section it is about 2e-3 of its peak, above the 1e-3 the source admits +(`source.beam_size`). That cross-section is widened to ±3.5 µm in x and y (the beam is then +2e-4 of its peak there). Everything else in G4 is as registered. + +Second amendment to G4's three-dimensional case, again before it computed anything: with a +1 µm waist the source refused it (`source.beam_size`). The angular spectrum is 5e-5 of its +peak at the light line, and in three dimensions the ripple of its cut stays at 2.8e-7 of the +peak at the edge of the largest transform grid the source builds (2^24 points), above its +1e-7. The waist is 1.5 µm, as in Setup B. The cross-section is then ±4.5 µm in x and y, so +the beam (radius 1.56 µm at the sheet) is 3e-4 of its peak on the outermost cells. The +monitor spans ±4.4 µm. The limit is unchanged. + +Amendment to G3, before it was first run: with a 0.6 µm waist the focal field's angular +spectrum is still 3% of its peak at the light line, where the beam's propagating part is cut, +and that part rings out as 1/r. The source refuses such a beam (`source.beam_size`): its +ripple stays above 1e-7 of the peak on any transform grid it builds. G3's waist is 1.0 µm +instead (the spectrum is 5e-5 of its peak at the light line). Everything else in G3, the +limits included, is as registered. + +Amendment to G7, before any derivative was computed: for the reason S7's amendment in +`fd-mode-source.md` states (reverse mode keeps every step; a full Setup B run does not fit +in memory here), the derivatives and central differences are of the same truncated run: a +9 µm by 3 µm physical domain (x and z `Open`, 12-cell `PML`), the sheet at z = -1 µm, the +flux and the probe at z = +1 µm, the pulse with `cutoff = 1e-3`, `Duration(steps=1000)`, and +the scatterer (permittivity 2.0) centred at (2, 0, 0) µm. The four derivatives, the steps and +the 1e-6 limit are as registered. + +## Results + +Measured with `tests/fdtd/test_fd_gaussian_beam.py` on NumPy (JAX and Torch where named), +double precision. + +| Criterion | Limit | 0.05 µm | 0.025 µm | Verdict | +| --- | --- | --- | --- | --- | +| G1 waist, TE / TM | `|w / waist - 1|` at most 1e-6 | 1.07e-8 / 1.08e-8 | -7.2e-9 / -7.2e-9 | passed | +| G2 Gouy phase against the continuum, largest over z in [-3, 3] µm | 2e-3 rad at 0.05 | **2.20e-3** | 5.42e-4 | **failed** | +| G2 observed order | at least 1.8 | | 2.02 | passed | +| G3 focal peak in the dielectric (continuum 1.37398 µm, paraxial 1.25 µm) | 0.03 at 0.05, 0.01 at 0.025, smaller | 1.34846 (0.0255) | 1.37084 (0.0031) | passed | +| G4 power, TE / TM | 2e-5 | -1.5e-12 / -7.6e-13 | -9.0e-13 / -5.5e-13 | passed | +| G4 power, three dimensions | 2e-5 | -3.6e-8 | | passed | +| G5 backward energy against the prediction, per forward | 1e-6 | 6.2e-12 / 6.2e-14 / 5.0e-12 | 2.5e-12 / 4.1e-14 / 5.0e-12 | passed | +| G5 forward energy against the prediction | 1e-5 | 1.3e-11 / 6.3e-13 / 5.0e-12 | 7.4e-12 / 3.8e-13 / 6.1e-12 | passed | +| G5 predicted backward per forward at 1.0 µm | 1e-12 | 1.4e-13 | 8.5e-14 | passed | +| G5 predicted backward per forward at 0.95 / 1.05 µm | reported | 2.6e-7 / 2.8e-7 | 2.5e-7 / 2.8e-7 | | +| G6 JAX / Torch against NumPy, flux and axis | 1e-12 | 2.3e-16, 1.0e-15 / 1.1e-16, 1.2e-15 | | passed | +| G7 derivatives against extrapolated central differences (amended truncated run), JAX and Torch: amplitude / phase / delay / scatterer | 1e-6 | < 1e-10 / < 1e-10 / 2.4e-10 / 1.3e-10 | | passed | +| G8 refusals by code | as listed | all raised | | passed | + +**G2 failed as registered.** The largest Gouy-phase difference from the continuum at +0.05 µm is 2.20e-3 rad, above the registered 2e-3. The error falls at second order +(5.42e-4 at 0.025 µm), the registered second part. The difference is the grid's dispersion: +the run's beam is exact for the grid, and the grid's plane waves have a phase velocity that +depends on their direction, which the continuum's do not. The limit is not changed. The test is kept as a strict expected +failure beside a passing test of its order. For scale, the exact beam's Gouy phase differs +from the paraxial `arctan(z / zR) / 2` by 2.1e-3 rad over the same range. + +### Support decision (2026-10-30) + +Recorded after the results and kept apart from the verdicts above, which stand: G2 failed as +registered and its limit is not changed. `GaussianBeam` is supported, with its Gouy-phase +accuracy stated rather than hidden. The reasons: + +- The difference is the grid's dispersion, not a fault of the source. The launched beam is + exact for the grid; its waist, power and back-radiation match their predictions to 1e-8 or + better (G1, G4, G5). +- It falls at second order (observed order 2.02), and at 0.025 µm (40 cells per wavelength) + it is within the registered limit. +- It is of the size by which the paraxial formula itself departs from the exact beam over the + same range (2.1e-3 rad). +- In the comparison below, Meep's and FDTDX's beams differ from the exact beam's Gouy phase + by more at both spacings (3.6e-3 and 3.7e-3 rad at 0.05 µm), and Meep's does not fall with + resolution. That is context for what a grid beam can be expected to do, not a verdict. + +The guide (`docs/guide/fdtd/gaussian-beam.md`) and the class docstring state the measured +error and ask for a spacing convergence of any result that depends on the phase along the +beam. No runtime warning is added. These grids sample the wavelength at 20 and 40 cells, +above the 8 to 12 cells where `CoarseMeshWarning` already warns, and a notice on every beam +run would say nothing about that run. + +Two details of the runs, neither a change to a limit. G3's half-space crosses the x faces, +so it declares `continuation=('x-', 'x+')`, the slab door of `fd-waveguide-through-pml.md`. +The `FluxMonitor`s span x in ±5.9 µm, clear of the absorbing layers, as a flux plane must. +A `FieldMonitor` is not refused within a cell of the sheet (the refusal covers flux planes, +as for a TFSF box), so G8 tests that refusal with a `FluxMonitor`. + +A design constant moved once, before any result depended on it. When first written, the +source required the beam to fall below 1e-13 of its peak at the edge of its padded transform +grid. The first run (G1's setup) found the light-line ripple at 5e-11, so the constant became +1e-7, with the padding doubled until the beam meets it (at most 2^24 points). The G3 and +three-dimensional G4 amendments above are refusals under that rule. + +Added after registration, as a further test: the beam is centred on a transverse focus. +Thirteen deliberate breaks of the source were caught: each sign of the discrete Faraday law, +the cosine rows' quarter period, the 1 W scaling, the polarization mapping, the axial and +the transverse focus, the edge, medium and face rules, the traced-waist refusal, and two +errors in the back-radiation prediction. Each time the source was restored byte for byte. +As for the mode source, the H rows' half-cell group delay changes only the off-centre +back-radiation. G5 reports that part and the exact prediction follows it, so no limit bounds +the delay. + +## Comparison with Meep and FDTDX + +Added 2026-10-30, after every verdict above, and it changes none of them. The tools, versions +and the way they ran are those of the comparison in `fd-mode-source.md`, and the same golden, +record and test hold this table and each statement below. + +What was matched: Setup B (vacuum, waist 1.5 µm, wavelength 1 µm, TE), the same spacings and +time step. Meep's `GaussianBeamSource` puts the focus where Wavebit's is. FDTDX's +`GaussianPlaneSource` cannot: it multiplies a truncated Gaussian by one plane-wave E/H pair, so +its focus is always its own plane. Its row is therefore a variant with the focus on the source +plane, its width measured 2 µm past that plane against the exact beam of the same focal field, +and its Gouy phase from 0.5 to 3 µm. The focal shift into glass (G3) cannot be set up in FDTDX. + +| Quantity | Wavebit | Meep | FDTDX | +| --- | --- | --- | --- | +| Beam width against the exact beam, w / w_exact - 1 (0.05 / 0.025) | +1.1e-8 / -7.2e-9 | +8.6e-3 / +8.6e-3 | -4.2e-3 / -4.8e-3 | +| Largest Gouy-phase difference from the exact beam, rad (0.05 / 0.025) | 2.2e-3 / 5.4e-4 | 3.6e-3 / 4.8e-3 | 3.7e-3 / 2.3e-3 | +| Backward / forward power at 1.0 um (0.05 / 0.025) | 1.4e-13 / 8.5e-14 | 8.0e-4 / 1.1e-4 | 2.6e-5 / 2.6e-5 | +| Focal peak in glass, abs(z - 1.37398 um) (0.05 / 0.025) | 2.6e-2 / 3.1e-3 | 1.1e-1 / 4.9e-2 | cannot be set up | + +What the comparison shows: + +- Meep's beam is 0.86 % wide at both resolutions, so the difference is its beam's definition + and not its grid. Its Gouy-phase difference does not fall with resolution, and its focal peak + falls short of the exact one; whether for the same reason has not been established. Its + back-radiation falls with resolution. +- FDTDX's width and back-radiation do not fall with the grid: a pointwise plane-wave pair is not + a beam of the grid. +- Wavebit's Gouy-phase difference, the G2 failure, is below both tools' at both spacings and + falls at second order. The support decision above uses this as context, not as a verdict. diff --git a/docs/validation/fdtd/fd-mode-source.md b/docs/validation/fdtd/fd-mode-source.md new file mode 100644 index 00000000..c56ccc4b --- /dev/null +++ b/docs/validation/fdtd/fd-mode-source.md @@ -0,0 +1,185 @@ +# A one-way waveguide mode source + +Written: 2026-10-27 + +Targets: `src/wavebit/fdtd/_mode_source.py`, `src/wavebit/fdtd/_kernel/mode_sheet.py`, the +`ModeSource` declaration in `src/wavebit/fdtd/_sources.py`, the `source=` option of +`ModeMonitor`, `tests/fdtd/test_fd_mode_source.py`. + +`fdtd.ModeSource` launches a mode of `fdtd.solve_modes` from a plane across a guide, one way. +The plane is a one-sided total-field/scattered-field (TFSF) sheet: the kernel's plane-wave +construction with the mode for its incident field. Its E pattern lives on the sheet's node +plane and its H pattern on the half plane upstream, each with its own waveform row. The H row +is the E row advanced by the mode's phase over half a cell, `beta dx / 2`, and by its group +delay over half a cell. At the pulse's centre frequency the pair is then exactly the discrete +mode, and the sheet launches it one way only. At other frequencies the profile and the +half-cell phase are the centre's, so the sheet also launches a little of the mode the other +way. The exact unconjugated orthogonality of the discrete modes gives both parts in closed form +from the actual waveform rows (the module docstring of `_mode_source.py` derives it). A +`ModeMonitor` declared with `source=` reports them as `injected` and `leakage`. + +The TFSF construction is the standard one (Taflove and Hagness, *Computational +Electrodynamics*, 3rd ed., ch. 5). A mode as the incident field of a TFSF plane, with the H +plane's waveform advanced by half a cell, follows the same idea; nothing here is copied from +another solver. + +## Registered acceptance criteria (2026-10-27, before computing) + +**Setup S.** The slab of D1 in the through-PML record (`fd-waveguide-through-pml.md`): a +1.5 µm core of `epsilon = 4` in cladding of `epsilon = 2.085136`, y `InvariantAxis`, x `Open` at +±4.5 µm, z `Open` at ±4 µm, the core a `SnappedSlab` continued through both z faces, a 24-cell +`PML` at 1.55 µm. `ModeSource('src')` at z = -2 µm, direction `+z`, mode 0 (TE0), amplitude 1 +(square-root watts per metre), pulse centred at 1.55 µm with `frequency_sigma_hz = 1.5e13` +and `cutoff = 1e-12`. `ModeMonitor`s with `source='src'`, `modes=1`, wavelengths 1.45, 1.55 +and 1.65 µm: `p1` at z = +1 µm, `p2` at z = +2 µm, `q` at z = -3 µm. Spacings 0.05 and 0.025 µm. +Each run lasts until no `UnsettledFieldWarning` and no `GrowingFieldWarning` is raised. +`P(d)` below is the mode's propagation factor over a distance `d` with its own grid `beta` +at that wavelength (from `solve_modes`), in the run's Fourier convention. + +* **S1, injection as predicted.** At every wavelength and both spacings, + `|forward(p2) - injected * P(4 µm)| <= 1e-5 |injected|`. The same with the mirrored run (the + source at z = +2 µm, direction `-z`, the monitor at z = -2 µm, its backward amplitude) at + 0.05 µm. +* **S2, leakage as predicted and stated.** At every wavelength and both spacings, + `|backward(q) - (leakage * P(1 µm) + backward(p2) * P(5 µm))| <= 1e-6 |injected|`. The + predicted leakage power `|leakage / injected|^2` at 1.55 µm is at most 1e-12 (the launch is + one-way at the centre); at 1.45 and 1.65 µm it is reported. +* **S3, absolute power.** A continuous wave of amplitude `A` carries `A^2` watts per metre, so + the launched mode's instantaneous power is `2 A^2 w(t)^2` for the unit waveform `w`, and its + two-sided spectral energy is `2 A^2 |W(f)|^2`, `W` the continuous Fourier transform of `w`. + At 1.55 µm and both spacings, `forward_energy(p2)` matches that to 2e-5 (relative). +* **S4, convergence with resolution.** At 1.55 µm the run's own effective index, + `n_run = arg(forward(p2) / forward(p1)) / (k0 * 1 µm)` on the branch nearest the solver's + index, matches the solver's grid index at the same spacing to 1e-9, and its error against the + exact TE0 index of the slab (1.9562181938, the mode-solver record's M3) falls from 0.05 to + 0.025 µm with an observed order of at least 1.8. +* **S5, butt joint against mode matching.** Setup S, but the core is two `SnappedBox`es: 1.5 µm + wide for z < 0 (continued through z-), 1.0 µm wide for z > 0 (continued through z+), with + `p2` reading the TE0 of the narrow core and `q` moved to z = -1 µm. The TE0 power + transmission `T = forward_energy(p2) / injected_energy` and reflection + `R = backward_energy(q) / injected_energy` at 1.55 µm, against continuum mode matching at + the same joint (written in the test: TE modes of both slabs in a perfectly conducting box, + from a fine finite-difference solve of the one-dimensional continuum operator, coupled + across z = 0 by continuity of Ey and Hx, converged in the number of modes and in the box + width): `|T - T_mm| <= 5e-3` at 0.05 µm and `<= 2e-3` at 0.025 µm, smaller at the finer + spacing; `|R - R_mm| <= 0.2 R_mm` at 0.025 µm, smaller than at 0.05 µm. +* **S6, backends.** Setup S at 0.05 µm on NumPy, JAX and Torch: `injected`, `leakage`, + `forward(p2)` and `backward(q)` agree to 1e-12 (relative to `|injected|`). +* **S7, derivatives.** On JAX and Torch, at 0.05 µm: the derivatives of + `forward_energy(p2)` at 1.55 µm with respect to the source's `amplitude`, of + `Re forward(p2)` with respect to its `phase` and its `delay_seconds`, and of + `forward_energy(p2)` with respect to the permittivity of a traced `Box` scatterer between + the source and `p1` that reaches neither plane, each match a central difference (two steps, + Richardson-extrapolated) to 1e-6 relative. +* **S8, refusals by code.** A direction not along the plane's normal (`source.direction`); a + negative or non-integer mode (`source.mode`); a mode index beyond the confined modes found + (`mode.count`); a traced structure reaching the source plane (`mode.traced`); a plane outside + the physical domain (`source.outside_domain`); a monitor within a cell of the sheet + (`monitor.source_sheet`); `source=` naming no `ModeSource` (`monitor.source`); a traced + pulse wavelength (`mode.traced`); the engine placeholder `fd.ModeSource` + (`NotImplementedError` naming the public source). + +Amendment to S7, before any derivative was computed: as registered (Setup S, about 12k +steps) the reverse-mode runs keep every step and need about 8 MB a step on JAX here. Two +full attempts stalled without returning, and a 15-period probe of the same setup needed 13 GB +and was killed for memory; no derivative was computed. `Numerics(checkpoint_steps=)` is +admitted only on Torch CUDA. As for MM4b in the mode-solver record, the derivatives and +the central differences are taken of the same truncated run instead: a 5 µm by 4 µm +physical domain (x and z `Open`, 12-cell `PML`) holding the same slab, the source at +z = -1 µm, `p2` at z = +0.5 µm reading 1.55 µm, the pulse of Setup S with `cutoff = 1e-3` +and `frequency_sigma_hz = 2e13`, `Duration(steps=1200)`, and the scatterer, of permittivity +3.0, centred at (1.2, 0, -0.25) µm. The four derivatives, the steps and the 1e-6 limit are as +registered. + +## Results + +Measured with `tests/fdtd/test_fd_mode_source.py` on NumPy (JAX and Torch where named), +double precision. Wavelengths are listed as 1.45 / 1.55 / 1.65 µm. + +| Criterion | Limit | 0.05 µm | 0.025 µm | Verdict | +| --- | --- | --- | --- | --- | +| S1 `forward(p2)` against the injected amplitude carried 4 µm | 1e-5 of `|injected|` | 2.7e-11 / 1.3e-14 / 2.3e-11 | 1.1e-11 / 7.2e-13 / 1.2e-11 | passed | +| S1 mirrored source (`-z`) | 1e-5 | 2.7e-11 / 1.3e-14 / 2.3e-11 | | passed | +| S2 `backward(q)` against the leakage and the transmitted reflection | 1e-6 of `|injected|` | 7.7e-14 / 2.8e-15 / 1.5e-12 | 4.8e-14 / 6.5e-15 / 1.2e-12 | passed | +| S2 predicted leakage power at 1.55 µm | 1e-12 | 6.8e-29 | 1.2e-29 | passed | +| S2 predicted leakage power at 1.45 / 1.65 µm | reported | 1.4e-6 / 1.5e-6 | 1.3e-6 / 1.4e-6 | | +| S3 `forward_energy(p2)` against `2 A^2 |W(f0)|^2` | 2e-5 | -1.2e-13 | -1.2e-13 | passed | +| S4 run index against the solver's grid index | 1e-9 | 1.969150425958 both | 1.959408135484 both | passed | +| S4 error against the exact TE0 index, observed order | at least 1.8 | 1.29e-2 | 3.19e-3 | passed (order 2.02) | +| S5 TE0 transmission, mode matching `T_mm = 0.955057` | 5e-3 at 0.05, 2e-3 at 0.025, smaller | 0.954677 (3.8e-4) | 0.954959 (9.8e-5) | passed | +| S5 TE0 reflection, mode matching `R_mm = 3.3287e-4` | 0.2 `R_mm` at 0.025, smaller than at 0.05 | 3.5366e-4 (6.2 %) | 3.3777e-4 (1.5 %) | passed | +| S6 JAX / Torch against NumPy | 1e-12 | 5.9e-16 / 1.3e-15 (worst) | | passed | +| S7 derivatives against extrapolated central differences (amended truncated run), JAX and Torch: amplitude / phase / delay / scatterer | 1e-6 | < 1e-10 / < 1e-10 / 5e-11 / 1.1e-9 | | passed | +| S8 refusals by code | as listed | all eleven raised | | passed | + +The mode-matching oracle in S5 is converged in its own discretization before it is used: +halving the number of modes, and widening the box from 12 to 18 µm with more modes, each +moves `T_mm` by less than 2e-4 and `R_mm` by less than 2 %, the test's own check. + +Added after registration, as further tests at registered limits (no limit changed). Two +deliberate breaks were not caught by the registered tests: +- a `-z` source whose H patterns were not signed for its direction. The exact prediction + follows whatever the sheet holds, so S1 alone cannot see it; +- the sign of the corrections fed by Ex and Hy. TE0 has neither component. + +The mirrored source is now also held to S2's one-way limit at the centre wavelength +(6.8e-29) and to S3's power limit (-1.2e-13). A TM0 launch (mode 1) is held to S1, S2 and S3 +(3.5e-11, 6.2e-29, -1.3e-13). Both breaks, and the sign of the Ex-fed correction, then +failed. In all, sixteen deliberate breaks of the source were caught: the H row's half-cell +phase, the 1 W scaling, the H patterns' direction sign, each prediction formula and the +leakage's sign, each correction's sign, the H plane's side, the kernel's row offsets, the +monitor's `source=` check, the sheet clearance, the direction check, and the mode window +re-entering its own source. Each time the source was restored byte for byte. One parameter is +bounded by no limit: the H row's half-cell group delay changes only the off-centre leakage, +which S2 reports rather than limits, and the exact prediction follows it. + +## Comparison with Meep and FDTDX + +Added 2026-10-30, after every verdict above, and it changes none of them: it is evidence about +the accuracy one can expect, not acceptance. The cases were run in Meep 1.35.0 (pymeep from +conda-forge, no MPI, with MPB 1.12.0) and in FDTDX 0.6.3 (JAX 0.12.0.1 on the CPU, double +precision). Each ran in its own process from the geometry and wavelengths written in physical +units; no Wavebit object reached either tool. Meep is GPL-2.0-or-later and was run as a +separate program; its source was not read. The values, each tool's settings and what could not +be matched are frozen in `tests/references/golden_stage7_cross_solver.json` and its record. +`tests/references/test_stage7_cross_solver_golden.py` holds this table, and each statement +below it, to those values. + +What was matched: Setup S and the S5 butt joint, the same spacings (Meep resolutions 20, 40 and +80 per µm), the same time step (Courant 0.5 in each tool) and TE (Wavebit's `Ey` is Meep's `Ez` +and FDTDX's `filter_pol="tm"`, a naming trap in FDTDX). Each tool used its own mode solver, +mode source and mode readout: Meep's `EigenModeSource` and `get_eigenmode_coefficients`, +FDTDX's `ModePlaneSource` and `ModeOverlapDetector`. Meep's absorber is `PML(1.2)` with its +default profile; FDTDX's is its default PML with `extend_material_to_pml`. Only ratios are +compared, since FDTDX's unit mode power carries no area factor. Wavebit's absorber row is D1 of +`fd-waveguide-through-pml.md` (an initial-field launch), not the same readout as the other two. + +| Quantity | Wavebit | Meep | FDTDX | +| --- | --- | --- | --- | +| TE0 index the mode solver reports, minus exact (0.05 / 0.025 / 0.0125) | +1.3e-2 / +3.2e-3 / +7.9e-4 | -1.3e-3 / -5.6e-4 / -2.6e-4 | +1.8e-4 / +4.5e-5 / +1.1e-5 | +| TE0 index the run propagates, minus exact (0.05 / 0.025) | +1.3e-2 / +3.2e-3 | +1.1e-2 / +2.6e-3 | +1.3e-2 / +3.2e-3 | +| Run index minus the Wavebit run index (0.05 / 0.025) | 0 / 0 | -1.6e-3 / -5.9e-4 | -7.3e-9 / -7.7e-11 | +| Backward / forward power at 1.55 um (0.05 / 0.025) | 6.8e-29 / 1.2e-29 | 1.3e-3 / 9.2e-5 | 4.3e-7 / 6.4e-9 | +| Backward / forward power at 1.45 / 1.65 um, 0.025 | 1.3e-6 / 1.4e-6 | 8.3e-5 / 1.0e-4 | 1.4e-6 / 1.4e-6 | +| Butt joint, abs(T - T_mm) (0.05 / 0.025 / 0.0125) | 3.8e-4 / 9.8e-5 / - | 2.8e-3 / 1.0e-3 / 4.1e-4 | 5.0e-5 / 1.4e-5 / - | +| Butt joint, abs(R - R_mm) / R_mm (0.05 / 0.025 / 0.0125) | 6.2e-2 / 1.5e-2 / - | 1.1e0 / 2.6e-1 / 7.2e-2 | 6.8e-1 / 2.3e-1 / - | +| Core into the absorber: reflected TE0, 0.6 / 1.2 um layer (0.05) | 4.3e-8 / 3.5e-16 | 1.0e-4 / 1.0e-4 | 9.6e-5 / 9.6e-5 | +| Same, 0.025 | - | 6.3e-6 / 6.3e-6 | - | + +What the comparison shows: + +- The three runs propagate one discrete system. FDTDX's run index equals Wavebit's to 7.3e-9 + and 7.7e-11; Meep's differs by its subpixel averaging of the core faces. The index each mode + solver reports is another matter: Meep's and FDTDX's are near the continuum, while Wavebit's + is the run's own (S4), which is why it looks furthest from the exact value. +- That difference decides how one-way a mode launch is. At the centre wavelength only the + Wavebit launch is one-way. FDTDX's leakage falls near the sixth power of the spacing and + Meep's near the fourth. Away from the centre FDTDX and Wavebit leak alike, since both fix the + profile and the half-cell phase at the centre. +- Every tool converges on the mode-matching transmission. FDTDX is closest; why has not been + established. The small reflection is closest in Wavebit, whose readout separates directions + by the exact discrete orthogonality. +- The external absorber numbers are a floor of the measurement. Doubling the layer changes them + by at most 1.5 %, where a layer's own reflection would fall by orders of magnitude (as D1's + does), and Meep's falls with resolution instead. They measure the readout, not the layer, and + this table cannot rank the absorbers. diff --git a/docs/validation/fdtd/fd-waveguide-modes.md b/docs/validation/fdtd/fd-waveguide-modes.md new file mode 100644 index 00000000..1ba4574f --- /dev/null +++ b/docs/validation/fdtd/fd-waveguide-modes.md @@ -0,0 +1,321 @@ +# Waveguide modes of the discrete Yee system + +Written: 2026-10-27 + +Targets: `src/wavebit/fdtd/_modes.py`, `tests/fdtd/test_fd_waveguide_modes.py`. + +This record covers the stage-7 mode solver, `fdtd.solve_modes`. The acceptance +criteria below were registered before the solver was run on any case. They are +not changed after the results are known; a criterion that fails stays failed in +this record. + +## What is solved + +`fdtd.solve_modes` prepares the problem exactly as `fdtd.prepare` does and reads +the update coefficients the time stepper will multiply by. On a plane across one +axis `w` it takes the diagonal permittivity and permeability of each Yee +component (`eps_c = (dt/dx)/be_c`, `mu_c = (dt/dx)/bh_c`) and solves the +eigenproblem of the *discrete* Maxwell equations for fields `exp(i(beta w - omega t))`. + +On the Yee lattice with the kernel's update, a time-harmonic field satisfies the +continuum curl equations with two exact replacements: + +* `omega -> Omega = (2/dt) sin(omega dt/2)`, from the leapfrog in time, and +* `d/dw -> i q` with `q = (2/dx) sin(beta dx/2)`, from the staggered difference + along the propagation axis, when each component is referred to its own Yee + position. + +The transverse derivatives stay the kernel's forward and backward differences +with the faces' own neighbour rules. Eliminating the longitudinal components +gives `q^2 e = Q P e` for the transverse electric field `e`, where `P` and `Q` +are real symmetric for lossless diagonal media and zero, periodic or wall +neighbours. The solver returns `beta = (2/dx) asin(q dx/2)` and the effective +index `n_eff = beta c/omega` at the physical angular frequency. Because the +operator is the kernel's own, an injected mode is a mode of the discrete system, +not of the continuum: its residual in the time stepper is round-off, while its +distance from the continuum mode is the grid's discretization error. + +Symmetry of `P` and `Q` gives the exact discrete orthogonality used for power +normalization and mode decomposition: for two modes with `q_m^2 != q_n^2`, +`sum (E_u^m H_v^n - E_v^m H_u^n) = 0` over the plane, without complex +conjugation. Modes are normalized to unit time-averaged power through the plane +(1 W, or 1 W/m with one invariant axis). + +## Registered acceptance criteria (2026-10-27, before computing) + +M1, homogeneous control. Transverse faces `Periodic`, medium `epsilon = 2.25`, +spacing 0.05 µm, wavelength 1 µm, with a 2-D cross-section and with one +`InvariantAxis`. The fundamental mode must have `q/Omega = 1.5` and `n_eff` +equal to the closed-form Yee dispersion +`n_eff = (2/(dx k0)) asin(1.5 Omega dx/2)`, both to a relative 1e-12. + +M2, hollow metal guide. `PEC` on both transverse axes, a = 2 µm by b = 1 µm, +filled with `epsilon = 2.25`, wavelength 1 µm, spacings 0.05, 0.025 and +0.0125 µm. The first five modes must match the discrete closed form +`q^2 = Omega^2 eps - (2/dx)^2 [sin^2(m pi dx/(2a)) + sin^2(n pi dx/(2b))]` +(TE10, TE20, TE01 and the degenerate TE11/TM11 pair) to a relative 1e-10 in +`q^2`. Against the continuum `n_eff`, the observed order between successive +spacings must be at least 1.8 for each of these modes. + +M3, symmetric slab against the exact dispersion relation. Core thickness 1.5 µm, +`epsilon = 4` (n = 2), cladding `epsilon = 2.085136` (n = 1.444), wavelength +1.55 µm, one `InvariantAxis` and `Open` faces across the slab at 4.5 µm from its +centre (the solver window is the physical domain). Spacings 0.05, 0.025 and +0.0125 µm, all aligned with the core faces. TE0, TE1, TE2 and TM0, TM1, TM2 +against the roots of the TE and TM transcendental equations: + +* observed order at least 1.8 for both refinements and all six modes; +* `|n_eff - exact| <= 1e-3` at 0.0125 µm for all six modes; +* the order-2 Richardson extrapolation of the two finest spacings within 1e-4 + of the exact value for all six modes; +* at every spacing, the cross-product of two distinct modes at most 1e-10 of + the product of their norms, and the normalized power 1 to a relative 1e-12. + +M4, published dielectric-corner benchmark. The four "analytically solvable" +corner problems of G. R. Hadley, J. Lightwave Technol. 20, 1210 (2002) +(stated error below 1e-8): a unit square at wavelength 1.5, a square core +`[0, 0.5]^2` in one corner, `PEC` on the listed sides and `PMC` on the others, +reference effective indices 1.27627404 (core 2.25, cladding 1, PEC left and +right), 2.65679692 (8, 1, PEC left and right), 1.387926425 (1, 2.25, PEC left +and top) and 2.761465320 (1, 8, PEC left and top). The walls are placed where +the kernel puts them (a PMC wall half a cell off the face) so that each wall +lies on the square's edge; the core is a smoothed `Box`. Three spacings near +1/40, 1/80 and 1/160. The largest-index mode must reach the published value +with an error that decreases monotonically over the three spacings and is at +most 1e-3 at the finest. The observed order is reported, not gated: a +dielectric corner is a field singularity, which is the subject of that paper. + +M5, refusals by name: a conductive or dispersive medium in the window; a medium +that is not uniform along the axis at the plane; a `Bloch` or `MirrorSymmetry` +transverse face; an `InvariantAxis` with nonzero `k`; coupled smoothed +(constitutive) or extended-stencil material in the window; a traced or +gradient-carrying input (the eigensolve is a host computation and does not +differentiate); a plane outside the physical domain; a request for more confined +modes than exist. Across an `Open` face a mode is admitted only when its largest +transverse electric field on the window's outermost cells is at most 1e-3 of +its peak. + +M6, backends. The arrays returned for `backend='numpy'`, `'jax'` and `'torch'` +are equal to the bit: the eigensolve runs once on the host and the result is +converted. + +## Registered addition (2026-10-27, before computing) + +Mutation testing of the first tests showed that M1-M4 cannot see two defects that +matter: a mode built with a permittivity sampled at the wrong Yee point, and a +propagation constant read from `q` without the `asin`. Both keep a symmetric +slab second order. The direct check is that a solved mode is an eigenmode of the +time stepper itself, so this criterion is added before it is first run: + +M7, the mode is a mode of the run. A 1 µm by 0.5 µm `SnappedBox` core of +`epsilon = 4` in a 3 µm by 3 µm `PEC` box (spacing 0.05 µm, wavelength 1 µm), and +the M3 slab with `PEC` instead of `Open` faces at 0.05 µm, each with a `Bloch` +propagation axis whose `k` is the mode's `beta` over eight cells. The run starts +from the mode (E at t = 0, H at -dt/2, each component at its own Yee point) and +takes 100 steps. Every component of the final E and H must equal the mode advanced +by `exp(-i omega t)` to within 1e-10 of the largest value of that field, for the +two largest-index modes of each guide. + +M1 and M2 compute `Omega` from the time step `fdtd.check` reports, not from the +solver's own output. + +A further M7 case, registered after the mutation run that showed why and before it +was first run: for a cell-snapped core each component's permittivity array is the +same index for index, so reading one component's coefficient in place of another's +changes nothing there. The same M7 criterion therefore also holds for a smoothed +`Box` core of 1.03 µm by 0.51 µm, whose faces lie between cells and whose +per-component averages differ. + +## Results (2026-10-27) + +Measured with `tests/fdtd/test_fd_waveguide_modes.py` on Linux CPU, Python 3.12, +NumPy 2.5.4, SciPy 1.18.1. Every registered criterion passed. + +| Criterion | Limit | Measured | +| --- | --- | --- | +| M1 `q/Omega`, `n_eff` vs closed form (2-D; invariant) | 1e-12 | 4.6e-15, 4.8e-15; 4.4e-16, 5.9e-16 | +| M2 `q^2` vs discrete closed form, five modes, three spacings | 1e-10 | 7e-14, 7e-14, 1.1e-12 (worst per spacing) | +| M2 observed order vs continuum | >= 1.8 | 2.024 to 2.027, then 2.006 to 2.007 | +| M3 observed order, six modes | >= 1.8 | 2.014 to 2.019, then 2.003 to 2.005 | +| M3 `|n_eff - exact|` at 0.0125 µm | <= 1e-3 | 5.2e-4 (TM2) to 7.9e-4 (TE0) | +| M3 Richardson extrapolation | <= 1e-4 | 1.6e-6 to 3.5e-6 | +| M3 orthogonality / power | 1e-10 / 1e-12 | passed at every spacing | +| M4 Hadley corners, error at the three spacings | monotone, <= 1e-3 | see below | +| M5 refusals | by code | each refusal raised with its code | +| M6 backends | bitwise | equal | +| M7 mode advanced by the run, 100 steps | 1e-10 | at most 6.4e-14 (slab), 2.1e-14 (snapped box), 1.3e-14 (smoothed box) | + +The exact slab indices (TE0, TE1, TE2, TM0, TM1, TM2) are 1.9562181938, +1.8225308386, 1.5975660835, 1.9470205470, 1.7879894882 and 1.5490399425. At 0.05, +0.025 and 0.0125 µm the solver is above each by 1.29e-2 to 8.4e-3, 3.2e-3 to 2.1e-3, +and 7.9e-4 to 5.2e-4. The third TM mode's largest transverse E on the window edge is +4e-4 of its peak, inside the 1e-3 admission limit; it is the least confined mode +here. + +Hadley's corners, signed error at 1/40, 1/80 and 1/160 (near those values for the +PEC-left/top cases, whose PMC walls set the spacing as described in the test): + +| Core, cladding, PEC sides | Published | Error | Observed order | +| --- | --- | --- | --- | +| 2.25, 1, left/right | 1.27627404 | +1.10e-3, +2.74e-4, +6.84e-5 | 2.00, 2.00 | +| 8, 1, left/right | 2.65679692 | +8.86e-3, +2.20e-3, +5.49e-4 | 2.01, 2.00 | +| 1, 2.25, left/top | 1.387926425 | +1.17e-3, +2.89e-4, +7.17e-5 | 2.02, 2.01 | +| 1, 8, left/top | 2.761465320 | +9.69e-3, +2.37e-3, +5.85e-4 | 2.03, 2.02 | + +Second order holds at these corners on this grid with a smoothed core: the field +singularity of a dielectric corner did not show at these spacings. This is an +observation at three spacings, not a statement about finer grids. + +What this does not establish: accuracy for lossy, dispersive, anisotropic or leaky +guides, all refused; and any run's accuracy, which still needs its own spacing +convergence. The effective index is the grid's, by construction. + +Mutation checks (each product change below was made, the named test failed, and the +source was restored byte for byte): leaving `Omega` as `omega` (M1, M2); dropping the +low-wall tangential-E mask (M2); dropping the cosine factor of the power (M3); dropping +the PMC high-face masks (M4); reading `eps_u` for `eps_w`, swapping `eps_u` and `eps_v`, +taking `beta = q`, flipping the sign of `E_w`, returning `Z0 H` for `H` (M7); disabling +each refusal (M5); and returning NumPy arrays for `backend='jax'` (M6). + +## ModeMonitor: registered acceptance criteria (2026-10-27, before computing) + +`fdtd.ModeMonitor(name, plane=..., wavelengths=..., modes=n)` projects the run's +DFT fields on its plane (the node-plane E and the mean of the two H half planes, as +a `FluxMonitor` pairs them) onto the first `n` modes the solver returns at each +wavelength. With `X = sum (E_u h_v - E_v h_u) dA` and `Y = sum (e_u H_v - e_v H_u) dA` +for a mode `(e, h)` normalized to unit kernel flux (`N = sum e . h dA = 2/cos(beta dx/2)`), +orthogonality gives the forward and backward amplitudes +`a = (X/N + Y/(N cos))/2` and `b = (X/N - Y/(N cos))/2` exactly for a field made of +modes. `|a|^2` and `|b|^2`, scaled as a `FluxMonitor` scales its flux, are the +two-sided pulse spectral energies (J/Hz) carried forward and backward by the mode. + +MM1, exact direction separation through the public path. In the three M7 guides +(snapped box, slab, smoothed box) with a `Bloch` axis of eight cells whose `k` is +`+beta` (or `-beta`), a run of 200 steps starting from mode 0 or mode 1 travelling +`+z` (or `-z`), and a `ModeMonitor` with `modes=2` at the mode's own wavelength: +the opposite direction and the other mode each at most 1e-10 of the launched +amplitude, and the launched mode's spectral energy equal to a `FluxMonitor`'s on +the same plane to a relative 1e-10. + +MM2, completeness against the flux. A hollow `PEC` guide 1 µm by 0.5 µm filled with +`epsilon = 2.25`, spacing 0.05 µm, `Open` along z with the default PML, a +`CurrentDipole` (`Ey`, off-centre so that it excites the guide's modes of both +parities) and monitors 3 µm on each side at wavelength 1.05 µm, where seven modes +propagate. For every monitor, the sum over the seven modes of forward minus +backward spectral energy equals the `FluxMonitor` spectral energy on the same plane +to a relative 1e-6. The run is long enough for its decay diagnostic to report the +fields settled. + +MM3, backends. NumPy, JAX and Torch give the same MM2 amplitudes to a relative +1e-12 of the largest. + +MM4, derivatives. In the MM2 guide with a `SnappedBox` block of `epsilon = 3` +between the dipole and the +z monitor (not reaching either monitor plane), the +derivative of mode 0's forward spectral energy with respect to that permittivity, +by `jax.grad` and by Torch autograd, agrees with the central difference at step +`1e-4 * epsilon` to a relative 1e-6. + +MM5, refusals by name: a monitor plane where the medium changes along the axis +(`mode.nonuniform`); a traced or gradient-carrying structure that reaches the +monitor plane (`mode.traced`); more modes than are confined (`mode.count`); a +non-positive `modes` (`monitor.modes`); a plane outside the physical domain. + +### MM2 outcome and a registered replacement (2026-10-27) + +MM2 as registered **failed**. Its precondition was not met: after 150 source periods +the decay diagnostic reported 2.0e-3 of the peak energy still in the monitor regions +(the line is 1e-5), and the modal sum differed from the flux by 4.5e-5 (limit 1e-6). +Longer runs (300 and 600 periods) still did not settle. The cause is physical and +lies in the configuration: a closed metal guide traps the spectral content at each +mode's cutoff, where the group velocity is zero, so a broadband pulse whose spectrum +crosses cutoffs never leaves the guide. + +The unsettled field explains the mismatch. The run's own `FluxMonitor` values differed +along the lossless guide by 3% (λ = 1.05 µm) to 14% (0.9 µm) between z = 1.5 and 3.5 µm, +while each well-propagating mode's net modal energy stayed constant along z and only +the near-cutoff TE21 mode's changed. The Fourier fields of a run that has not decayed +are not a solution of both discrete curl equations at once. The end correction +(`src/wavebit/fdtd/_post.py::end_correction`) makes each field's own update exact against the +other field's *uncorrected* transform, so the corrected pair leaves a residue at the +final field's level. A box of corrected phasors gave relative curl residuals of 0.7% +(Faraday) and 5-7% (Ampère); the same curls applied to the time-domain fields close to +4.7e-15. This is the engine's documented behavior for a run that has not decayed (its +`UnsettledFieldWarning`); it is recorded here, not changed. + +MM2b, registered after the MM2 failure and before it was run: the MM2 guide and +monitors, with a narrowband pulse kept inside the band where exactly seven modes +propagate on this grid. The discrete cutoffs of the next mode above and the last mode +below are at 1.0087 µm and 1.0644 µm. The pulse is centred at 1.036 µm, with a +frequency standard deviation of 1.3e12 Hz (both band edges more than six standard +deviations away) and `cutoff=1e-9`, and the monitors read 1.036 µm. The run must +report the fields settled, and the seven-mode sum must equal the flux to a relative +1e-6 at both monitors. + +### MM4 outcome and a registered replacement (2026-10-27) + +MM4 as registered **failed**: run for 800 steps (enough for the pulse's leading part to +cross the +z monitor; a reverse-mode derivative keeps every step), `jax.grad` gave +8.794522e-20 J/Hz per unit permittivity, and the central difference at the registered +step `3e-4` gave a value 9.6e-6 higher (limit 1e-6). The registered step was too coarse. +The central difference against the same `jax.grad` value at five steps: + +| Step | Relative difference | +| --- | --- | +| 3e-3 | 9.57e-4 | +| 1e-3 | 1.06e-4 | +| 3e-4 | 9.57e-6 | +| 1e-4 | 1.06e-6 | +| 3e-5 | 1.26e-7 | + +The difference falls as the square of the step: the central difference's own truncation +error, converging on the AD value. Torch was not evaluated before this diagnosis. + +MM4b, registered after this diagnosis and before it was run (JAX's plain central +differences above were already seen; the combination below was not computed): the MM4 +derivative, by `jax.grad` and by Torch autograd, against the fourth-order combination +`(4 D(h/2) - D(h))/3` of central differences at `h = 3e-4` and `h/2`, to a relative 1e-7. + +MM6, registered after a mutation run showed that MM1 cannot see how a finite span is +cut out of the plane (every MM1 window spans its whole cross-section), and before it +was run: the MM1 slab with its `PEC` faces at x = ±4.5 µm, launched as in MM1 (modes 0 +and 1, both directions), read by a `ModeMonitor` and a `FluxMonitor` whose planes span +only x = (-3, 3) µm. The window's walls then truncate the run's mode where its field is +small but not zero, so the limits are looser than MM1's: the opposite direction and the +other mode each at most 1e-8 of the launched amplitude, and the launched mode's +spectral energy equal to the flux on the same span to a relative 1e-8. + +### ModeMonitor results (2026-10-27) + +Measured with `tests/fdtd/test_fd_mode_monitor.py` (same environment as above). + +| Criterion | Limit | Measured | Verdict | +| --- | --- | --- | --- | +| MM1 opposite direction / other mode / energy vs flux, 12 launches | 1e-10 | 4.7e-15 / 6.8e-16 / 4.8e-16 (worst) | passed | +| MM2 seven-mode sum vs flux, broadband pulse | 1e-6, settled | 4.5e-5, not settled | **failed** (kept as an expected failure) | +| MM2b seven-mode sum vs flux, narrowband pulse | 1e-6, settled | 1.7e-10 at both monitors; 5.0e-15 of the peak energy left | passed | +| MM3 backends, MM2 amplitudes | 1e-12 | 6.2e-16 (JAX), 3.4e-16 (Torch) | passed | +| MM4 AD vs central difference at step 3e-4 | 1e-6 | 9.6e-6 | **failed** (kept as an expected failure) | +| MM4b AD vs fourth-order extrapolated difference | 1e-7 | 1.27e-8 (JAX and Torch) | passed | +| MM5 refusals | by code | raised by `check` and by `run` (the mode count at `run`) | passed | +| MM6 finite span: opposite direction / other mode / energy vs flux | 1e-8 | 3.1e-13 / 0 / 7.2e-13 | passed | + +In MM2b the +z monitor reads backward energy only in the two slowest modes, 2.8e-5 +(TE21) and 1.5e-6 (TM21) of the forward total. That is the end PML returning the +near-cutoff modes, which it absorbs worst. It is measured here, not judged. + +Mutation checks (each made, the test failed, the source restored byte for byte): +swapping the forward and backward combinations, dropping the cosine factor of the H +overlap, reading one H half plane instead of their mean, dropping the spectral scale +(MM1); shifting the window crop of a finite span (MM6); removing the early check of the +window (MM5); solving the window with every structure instead of those that reach it +(MM4b); summing six modes instead of seven (MM2b). + +### The window across an Open face (2026-10-27) + +A `ModeMonitor` across an `Open` face was first refused at run time. Its flux plane read +the window's edge node, which on the physical edge is the absorbing layer's first node. +The window now stops one cell inside the physical edge on each `Open` side, for the +monitor and for `solve_modes`, and a regression test holds the two to the same modes. +The M3 slab, whose faces are `Open`, was re-run with the moved walls: every quoted +digit above is unchanged (the field there is below 1e-9 of its peak). + diff --git a/docs/validation/fdtd/fd-waveguide-through-pml.md b/docs/validation/fdtd/fd-waveguide-through-pml.md new file mode 100644 index 00000000..4926ade5 --- /dev/null +++ b/docs/validation/fdtd/fd-waveguide-through-pml.md @@ -0,0 +1,86 @@ +# A cell-snapped waveguide through an absorbing layer + +Written: 2026-10-27 + +Targets: `src/wavebit/fdtd/_lower_public.py` (`_continue_snapped` and the slab crossing +rule), `tests/fdtd/test_fd_waveguide_pml.py`. + +An open-ended waveguide needs its core to run into the absorbing layer at each end. +Until this change the public lowering refused any body narrower than the cross-section +there. The engine has always accepted a structure that runs straight through a layer, +because no interface then lies inside it. The public rule was the stricter one. + +The door opened here is narrow. A `SnappedBox` may declare +`Structure(continuation=('z-', 'z+'))` (any one axis) when it reaches those physical +faces, and it is then extended through their padding with its cross-section unchanged. +A `SnappedSlab` may name the faces of the axes it runs along. Every cell of the core is +then the same in every layer of the PML: the medium is uniform along the absorbing axis, +with a core the layer is not graded for. A smoothed body narrower than the cross-section +(a `Box`, `Cylinder` or `Prism` core) stays refused. That separate question was studied +and not admitted, and nothing here changes it. A body that touches an `Open` face it +does not name is refused as before. + +## Registered acceptance criteria (2026-10-27, before computing) + +The guided mode's reflection is measured with `ModeMonitor`, between a source in the +core and the far layer, as the backward over forward power of mode 0 at the wavelength +read. + +D1, slab waveguide. The M3 slab of the mode-solver record (1.5 µm core, `epsilon = 4`, +cladding `epsilon = 2.085136`, `InvariantAxis` y), x `Open` at ±4.5 µm, z `Open` at +±4 µm with the core continued through both z faces, spacing 0.05 µm, the default `PML` (12 cells), +an `Ey` `CurrentDipole` in the core at z = -2 µm with a pulse centred at 1.55 µm, and a +`ModeMonitor` at z = +2 µm reading 1.55 µm: + +* the TE0 power reflection `|b0/a0|^2` at most 1e-5 with 12 cells, and smaller with 24 + cells than with 12; +* no `GrowingFieldWarning` in a run of 400 periods. + +D2, rectangular core. A 1 µm by 0.5 µm `SnappedBox` core of `epsilon = 4` in vacuum, +x and y `Open` (a 3 µm by 2.5 µm physical cross-section, so the mode's tail is below +the monitor's 1e-3 confinement limit at its edge), z `Open` at ±2.5 µm with the core +continued through both z faces, spacing 0.05 µm, the default `PML`, an `Ex` dipole in +the core at z = -1 µm with a pulse centred at 1 µm, and a `ModeMonitor` at z = +1 µm +reading 1 µm: the fundamental mode's power reflection at most 1e-5 with 12 cells, and +smaller with 24 cells than with 12. + +D3, refusals by code (`fdtd.padding_continuation`): a smoothed `Box` core narrower than +the cross-section declaring continuation; a continued `SnappedBox` that touches an +`Open` face of another axis; continuation naming a face without padding; a +continued `SnappedBox` that does not reach the named face; a `SnappedSlab` naming a +face of its own axis. + +Amendment to D1, before it was first run: a `CurrentDipole` is refused on an +`InvariantAxis` (its stamp is not uniform along the invariant axis), so D1's source is +instead an initial field. Its E is the slab's TE0 mode at 1.55 µm, as `solve_modes` +returns it, times `exp(-(z + 2)^2 / (2 (0.6 µm)^2)) cos(beta0 (z + 2))` with `beta0` that +mode's propagation constant, and its H is zero. It launches TE0 both ways from z = -2 µm +with a spectrum centred near 1.55 µm. Everything else in D1 is as registered. + +## Results + +Measured with `tests/fdtd/test_fd_waveguide_pml.py` on NumPy, double precision. + +| Criterion | Limit | 12 cells | 24 cells | Verdict | +| --- | --- | --- | --- | --- | +| D1 TE0 power reflection, slab | at most 1e-5 with 12 cells, smaller with 24 | 4.3e-8 | 3.5e-16 | passed | +| D1 no `GrowingFieldWarning` in 400 periods | none | none raised | none raised | passed | +| D2 fundamental-mode power reflection, rectangular core | at most 1e-5 with 12 cells, smaller with 24 | 1.3e-6 | 3.5e-12 | passed | +| D3 refusals by code | `fdtd.padding_continuation` | all five raised | | passed | + +Two details of the runs, neither a change to a limit. D1 has no source to infer the PML +wavelength from, so its `PML` names 1.55 µm, and its 400 periods are written as the +equivalent duration in seconds. D2's pulse width was not registered; the first width tried +(`frequency_sigma_hz = 3e13`) was refused by the resolution check before any step (its +shortest wavelength is sampled at fewer than 8 cells in the core), and the runs use +`1.5e13`. D2's core is sampled at about 10 cells per wavelength at 1 µm, so its runs carry the +`CoarseMeshWarning`; the reflection it measures is of the mode on that grid. + +Added after registration, as further refusal and admission tests (no limit changed): a +continued core reaching only one of the two named faces (each way), continuation naming +faces of two axes, the registered D1 and D2 scenes passing `check` and `prepare`, and the +lowered core spanning the allocated domain with its cross-section unchanged. Each new rule +was broken on purpose and the tests failed: dropping the one-axis rule, either reach check, +the padding check, the extension through either face, the slab's own-axis rule or the +crossing-rule exemption, and letting a smoothed `Box` continue; the source was then restored +byte for byte. diff --git a/docs/validation/fdtd/index.md b/docs/validation/fdtd/index.md index 4baf7613..f60eec72 100644 --- a/docs/validation/fdtd/index.md +++ b/docs/validation/fdtd/index.md @@ -37,6 +37,10 @@ usage see the [FDTD guide](../../guide/fdtd/general-fdtd.md) and the - [Sphere clipping across a JIT boundary](fd-sphere-clip-jit.md) - [Smoothed occupancy and the tilted-interface measurement](fd-subpixel-occupancy.md) - [C8: one film with two Debye poles](fd-two-debye-film.md) +- [Waveguide modes of the discrete Yee system (stage 7)](fd-waveguide-modes.md) +- [A cell-snapped waveguide through an absorbing layer (stage 7)](fd-waveguide-through-pml.md) +- [A one-way waveguide mode source (stage 7)](fd-mode-source.md) +- [A focused Gaussian beam (stage 7)](fd-gaussian-beam.md) - [Adjoint, discrete derivatives and physical accuracy](fdtd-adjoint-validation.md) - [Bragg cavity temporal and spatial convergence](fdtd-bragg-cavity.md) - [Circular FDTD shapes and diameter derivatives](fdtd-circle-gradients.md) diff --git a/src/wavebit/fdtd/__init__.py b/src/wavebit/fdtd/__init__.py index 1d1211a5..61f85736 100644 --- a/src/wavebit/fdtd/__init__.py +++ b/src/wavebit/fdtd/__init__.py @@ -3,24 +3,28 @@ from ._geometry import (Domain, Shape, SnappedSlab, SnappedBox, Plane, Sphere, Box, Cylinder, Prism, Structure) from ._boundaries import Open, Periodic, Bloch, PEC, PMC, MirrorSymmetry, InvariantAxis, Boundaries -from ._sources import GaussianPulse, PlaneWave, CurrentDipole, TFSF, InitialField +from ._sources import (GaussianPulse, PlaneWave, CurrentDipole, TFSF, ModeSource, GaussianBeam, + InitialField) from ._monitors import (TimeSeriesMonitor, FieldMonitor, FluxMonitor, DiffractionMonitor, - FarFieldMonitor, Outputs) + FarFieldMonitor, ModeMonitor, Outputs) from ._numerics import UniformGrid, Duration, PML, ConductiveAbsorber, DecayStop, Numerics from ._api import Prepared, check, prepare, run from ._result import Result, SpectralFlux, FarFieldSpectrum +from ._modes import solve_modes, WaveguideMode, WaveguideModes, ModeSpectrum from . import native, periodic __all__ = ['Problem', 'Scene', 'Domain', 'Shape', 'SnappedSlab', 'SnappedBox', 'Plane', 'Sphere', 'Box', 'Cylinder', 'Prism', 'Structure', 'Open', 'Periodic', 'Bloch', 'PEC', 'PMC', 'MirrorSymmetry', 'InvariantAxis', 'Boundaries', - 'GaussianPulse', 'PlaneWave', 'CurrentDipole', 'TFSF', 'InitialField', + 'GaussianPulse', 'PlaneWave', 'CurrentDipole', 'TFSF', 'ModeSource', 'GaussianBeam', + 'InitialField', 'TimeSeriesMonitor', 'FieldMonitor', 'FluxMonitor', 'DiffractionMonitor', - 'FarFieldMonitor', 'Outputs', + 'FarFieldMonitor', 'ModeMonitor', 'Outputs', 'UniformGrid', 'Duration', 'PML', 'ConductiveAbsorber', 'DecayStop', 'Numerics', 'Prepared', 'check', 'prepare', 'run', 'Result', 'SpectralFlux', 'FarFieldSpectrum', + 'solve_modes', 'WaveguideMode', 'WaveguideModes', 'ModeSpectrum', 'native', 'periodic'] #: Names this module no longer has, and what replaced them. Each is a tombstone, not an alias: diff --git a/src/wavebit/fdtd/_api.py b/src/wavebit/fdtd/_api.py index d83a400b..04e14ed4 100644 --- a/src/wavebit/fdtd/_api.py +++ b/src/wavebit/fdtd/_api.py @@ -401,6 +401,19 @@ def _issue(exc): return ValidationError(str(exc),code='fdtd.invalid',path='problem') +def _check_mode_monitors(problem,numerics): + """Each ModeMonitor's static refusals before any field advances (src/wavebit/fdtd/_modes.py). + + The window -- its faces, its uniformity along the axis, its admitted media -- is fixed by the + declaration; the per-wavelength mode solve runs when the result is adapted. + """ + from ._monitors import ModeMonitor + from ._modes import prepare_window, window_plane + for m in problem.outputs.monitors: + if isinstance(m,ModeMonitor): + prepare_window(problem,numerics,window_plane(problem,numerics,m.plane),m.wavelengths[0]) + + def _changed(signature,prepared): """The public path of the first prepared-structure entry that differs (``scene.domain``).""" for key,value in signature.items(): @@ -444,6 +457,7 @@ def check(problem,*,numerics,backend='numpy',device='cpu',precision='double'): _cuda_scope(problem,numerics,execution,scene=scene) plan,_=_planned(scene,schedule=schedule,compact_poles=_compact_poles(problem,execution)) _signature(problem,numerics) + _check_mode_monitors(problem,numerics) return _report(problem,numerics,execution,scene,plan,metadata) except (TypeError,ValueError,NotImplementedError) as exc: return CheckReport(issues=(_issue(exc).issue,)) @@ -521,6 +535,7 @@ def prepare(problem,*,numerics,backend='numpy',device='cpu',precision='double'): _cuda_scope(problem,numerics,execution,scene=scene) plan,_=_planned(scene,schedule=schedule,compact_poles=_compact_poles(problem,execution)) signature=_signature(problem,numerics) + _check_mode_monitors(problem,numerics) report=_report(problem,numerics,execution,scene,plan,metadata) except (TypeError,ValueError,NotImplementedError) as exc: raise _issue(exc) from exc diff --git a/src/wavebit/fdtd/_assemble.py b/src/wavebit/fdtd/_assemble.py index bfba8378..c92b3ba4 100644 --- a/src/wavebit/fdtd/_assemble.py +++ b/src/wavebit/fdtd/_assemble.py @@ -390,7 +390,7 @@ def monitor_results(scene, plan, state, rt, sources, params=None): layout = plane_layout(where, grid, m.plane.axis, m.plane.position, extents) flux = _post.plane_flux(rt, obs[m.name], layout, grid.dx) out[m.name] = FluxResult(name=m.name, wavelengths=m.wavelengths, flux=flux, - faces=None) + faces=None, accumulators=obs[m.name], layout=layout) elif isinstance(m, Field): points = field_points(m, grid) out[m.name] = FieldResult( diff --git a/src/wavebit/fdtd/_beam_source.py b/src/wavebit/fdtd/_beam_source.py new file mode 100644 index 00000000..cb7d4f31 --- /dev/null +++ b/src/wavebit/fdtd/_beam_source.py @@ -0,0 +1,408 @@ +"""A focused Gaussian beam: the public :class:`GaussianBeam` lowered to a one-sided sheet. + +The sheet is the mode source's (``src/wavebit/fdtd/_kernel/mode_sheet.py``) with a beam for its +incident field. The beam is built from the discrete plane waves of the Yee grid in the +homogeneous medium at the sheet, so at the pulse's centre frequency it is an exact solution of +the grid and the sheet launches it one way only. + +**The beam.** On the focal plane the polarized transverse component, sampled at its own Yee +points, is ``exp(-r^2 / waist^2)`` about the focus (``waist`` is the 1/e amplitude radius, as +for ``rcwa.GaussianBeam``); the other transverse component is zero. That plane's discrete +Fourier transform is taken over a zero-padded transverse grid wide enough that the beam is +negligible at its edges. Each transverse wavenumber ``k_t`` is one discrete plane wave: with +``q_a = (2/dx) sin(k_a dx/2)`` (the staggered difference of ``exp(i k_a a)``) and the leapfrog's +``Omega = (2/dt) sin(omega dt/2)``, it propagates along the normal with ``q_w`` from +``q_u^2 + q_v^2 + q_w^2 = Omega^2 eps mu``, i.e. ``beta = (2/dx) asin(q_w dx/2)``. Components +with no real ``beta`` (evanescent ones, or beyond the grid's band) are dropped: the beam is the +propagating part of the angular spectrum. The longitudinal E follows from the discrete Gauss +law ``q . E = 0`` and H from the discrete Faraday law ``H = q x E / (Omega mu)`` (phasors +``exp(-i omega t)``, each component at its own Yee point and, for H, its own half step). Each +plane wave then advances from the focal plane to the sheet's node plane (E) and to the half +plane upstream (H) by ``exp(i beta d)``. The angular-spectrum representation of a beam is +standard (Goodman, *Introduction to Fourier Optics*, ch. 3; Mandel and Wolf, *Optical +Coherence and Quantum Optics*, sec. 3.2); its discrete form here uses the grid's own dispersion. + +**The rows.** The incident patterns are complex. Each field is ``Re(P exp(-i psi))`` with +``psi = 2 pi f0 s + phase``: ``Re(P)`` on a cosine row and ``Im(P)`` on a sine row. The H rows +are read at ``(n + 1/2) dt`` with the envelope advanced by the axial group delay over half a +cell (the carrier's half-cell phase is in the H patterns). At the centre frequency the sheet's +two planes are the beam's; away from it each plane wave's half-cell phase and profile are the +centre's, and the sheet launches a little of the beam backward. :func:`launched_power` +computes both parts exactly, per plane wave, from the actual rows. + +**Power.** The patterns are scaled so that the kernel flux through the sheet's window at the +centre frequency is one unit; with the window's watts per unit flux, a continuous wave of +amplitude 1 carries 1 W (per metre of each invariant axis). +""" +import math + +import numpy as np + +from wavebit.errors import UnsupportedFeatureError, ValidationError + +__all__ = ['beam_sheet', 'beam_fields', 'launched_power'] + +#: The beam's transverse E on the window's outermost cells, relative to its peak, at most this +#: (the sheet stops at the window; what lies beyond is not launched). As for a mode's window. +EDGE = 1e-3 +#: The beam on the padded transform grid's edges, relative to its peak, at most this: what +#: wraps around the periodic transform is of that order. Dropping the evanescent part cuts the +#: spectrum at the light line, which leaves a ripple decaying only as 1/r (about 1e-10 of the +#: peak at 25 um for a waist of 1.5 wavelengths, more for a tighter one), so the padding grows +#: until the ripple is below this, up to ``TRANSFORM_POINTS``. +PADDED = 1e-7 +#: The padded transform grid's largest number of points; a beam that needs more is refused. +TRANSFORM_POINTS = 1 << 24 +UNIFORM = 1e-12 + + +def _local_axis(name, w, path): + axes = 'xyz' + if name not in axes or axes.index(name) == w: + raise ValidationError(f"GaussianBeam(polarization={name!r}): the transverse axis the E " + f"field points along, one of " + f"{[a for a in axes if axes.index(a) != w]}", + code='source.polarization', path=path) + a = axes.index(name) + return 'u' if a == (w + 1) % 3 else 'v' + + +def _concrete(value, path, what): + from ._modes import _traced + if _traced(value): + raise UnsupportedFeatureError(f'{what} is traced or carries a gradient; the beam is ' + f'built on the host from concrete values. The amplitude, ' + f'phase and delay may be traced.', code='source.traced', + path=path) + try: + return float(value) + except (TypeError, ValueError) as exc: + raise ValidationError(f'{what} must be a real number', code='source.value', + path=path) from exc + + +def _medium(window, path): + """The window's one relative permittivity and permeability, or a refusal.""" + values = [] + for store in (window.eps, window.mu): + arrays = np.concatenate([np.asarray(a).ravel() for a in store.values()]) + ref = float(arrays[0]) + if not np.allclose(arrays, ref, rtol=UNIFORM, atol=0.0): + raise UnsupportedFeatureError( + 'the medium on the beam plane is not uniform; a Gaussian beam is launched in a ' + 'homogeneous medium (the one that fills its plane). Move the plane into a ' + 'uniform region.', code='source.medium', path=path) + values.append(ref) + return values + + +def _sides(window, path): + for pair in window.sides: + for side in pair: + if side.kind == 'invariant' or (side.kind == 'wall' and side.truncates): + continue + raise UnsupportedFeatureError( + 'a Gaussian beam needs Open (or InvariantAxis) faces across its plane: it is a ' + 'beam in open space, and a metal, mirror or periodic face would turn it into ' + 'the modes of that guide or lattice.', code='source.boundary', path=path) + + +def beam_fields(window, wavelength, waist, focus, polarization, sign, planes, pad=4): + """The beam's complex phasors on the window, per plane, before normalization. + + ``planes`` maps a label to ``(field, w_position)``: ``field`` ``'E'`` or ``'H'``, the plane's + position along the normal. Returns ``{label: {'u': (Nu, Nv), 'v': (Nu, Nv)}}`` (the + transverse components at their Yee points on that plane), the axial ``beta`` and the + padded grid's edge ratio. + """ + from ._kernel.grid import OFFSETS + w, u, v = window.w, window.u, window.v + dx, dt = window.dx, window.dt + eps, mu = _medium(window, 'sources') + omega = 2.0 * math.pi / wavelength + Omega = 2.0 * math.sin(omega * dt / 2.0) / dt + (u0, u1), (v0, v1) = window.ranges[u], window.ranges[v] + Nu, Nv = u1 - u0, v1 - v0 + invariant = [s[0].kind == 'invariant' for s in window.sides] + sizes, starts = [], [] + for n, lo, inv in ((Nu, u0, invariant[0]), (Nv, v0, invariant[1])): + if inv: + sizes.append(1) + starts.append(lo) + continue + m = 1 << int(math.ceil(math.log2(pad * n))) + sizes.append(m) + starts.append(lo - (m - n) // 2) + Mu, Mv = sizes + ku = 2.0 * np.pi * np.fft.fftfreq(Mu, dx)[:, None] + kv = 2.0 * np.pi * np.fft.fftfreq(Mv, dx)[None, :] + qu = (2.0 / dx) * np.sin(ku * dx / 2.0) + qv = (2.0 / dx) * np.sin(kv * dx / 2.0) + qw2 = Omega ** 2 * eps * mu - qu ** 2 - qv ** 2 + keep = (qw2 > 0) & (np.sqrt(np.maximum(qw2, 0.0)) * dx / 2.0 < 1.0) + qw = np.where(keep, np.sqrt(np.maximum(qw2, 0.0)), 1.0) + beta = np.where(keep, (2.0 / dx) * np.arcsin(np.minimum(qw * dx / 2.0, 1.0)), 0.0) + qw, beta = sign * qw, sign * beta + + def positions(field, local): + c = {'u': u, 'v': v, 'w': w}[local] + off = OFFSETS[field][c] + cu = window.low[u] + (starts[0] + np.arange(Mu) + 0.5 * off[u]) * dx + cv = window.low[v] + (starts[1] + np.arange(Mv) + 0.5 * off[v]) * dx + return cu, cv, off + + # The focal plane: the polarized component sampled at its own points. + cu, cv, off_p = positions('E', polarization) + fu, fv, fw = focus[u], focus[v], focus[w] + du = (cu[:, None] - fu) if not invariant[0] else np.zeros((1, 1)) + dv = (cv[None, :] - fv) if not invariant[1] else np.zeros((1, 1)) + g = np.exp(-(du ** 2 + dv ** 2) / waist ** 2) + # Amplitudes referred to each component's own sampling: A_c(k) exp(i k (n + o_c/2) dx). + A = {} + G = np.fft.fft2(g) + shift_p = np.exp(-1j * (ku * off_p[u] + kv * off_p[v]) * dx / 2.0) + A['Eu' if polarization == 'u' else 'Ev'] = np.where(keep, G * shift_p, 0.0) + A['Ev' if polarization == 'u' else 'Eu'] = np.zeros_like(A['Eu' if polarization == 'u' else 'Ev']) + A['Ew'] = np.where(keep, -(qu * A['Eu'] + qv * A['Ev']) / qw, 0.0) + A['Hu'] = (qv * A['Ew'] - qw * A['Ev']) / (Omega * mu) + A['Hv'] = (qw * A['Eu'] - qu * A['Ew']) / (Omega * mu) + out = {} + edge = 0.0 + for label, (field, position) in planes.items(): + phase = np.exp(1j * beta * (position - fw)) + comps = {} + for local in ('u', 'v'): + name = field + local + _, _, off = positions(field, local) + spatial = np.exp(1j * (ku * off[u] + kv * off[v]) * dx / 2.0) + full = np.fft.ifft2(A[name] * phase * spatial) + if field == 'E': + peak = float(np.max(np.abs(full))) + if peak > 0: + rim = max(float(np.max(np.abs(full[[0, -1], :]))) if Mu > 1 else 0.0, + float(np.max(np.abs(full[:, [0, -1]]))) if Mv > 1 else 0.0) + edge = max(edge, rim / peak) + iu = slice(u0 - starts[0], u0 - starts[0] + Nu) + iv = slice(v0 - starts[1], v0 - starts[1] + Nv) + comps[local] = full[iu, iv] + out[label] = comps + axial = float((2.0 / dx) * math.asin(min(Omega * math.sqrt(eps * mu) * dx / 2.0, 1.0))) + return out, axial, edge + + +def _axial_group_velocity(window, wavelength): + eps, mu = _medium(window, 'sources') + dx, dt = window.dx, window.dt + + def beta(lam): + Om = 2.0 * math.sin(math.pi * dt / lam) / dt + return (2.0 / dx) * math.asin(Om * math.sqrt(eps * mu) * dx / 2.0) + h = 1e-4 + l1, l2 = wavelength * (1 - h), wavelength * (1 + h) + return (2 * math.pi / l1 - 2 * math.pi / l2) / (beta(l1) - beta(l2)) + + +def beam_sheet(source, problem, numerics, units): + """The engine sheet (a ``ModeSheet``) of public :class:`GaussianBeam` ``source``.""" + from ._modes import prepare_window, window_plane, _check_wavelength, _masks + from ._lower_public import _pulse + from ._mode_source import ModeSheet + from . import _geometry as geometry + path = 'sources.' + source.name + if not isinstance(source.plane, geometry.Plane): + raise ValidationError('expected Plane', code='source.plane', path=path + '.plane') + w = source.plane.axis + direction = source.direction + if direction not in ('+' + 'xyz'[w], '-' + 'xyz'[w]): + raise ValidationError(f"GaussianBeam(direction={direction!r}): '+{'xyz'[w]}' or " + f"'-{'xyz'[w]}', along the plane's normal", + code='source.direction', path=path + '.direction') + sign = 1 if direction[0] == '+' else -1 + polarization = _local_axis(source.polarization, w, path + '.polarization') + waist = _concrete(source.waist, path + '.waist', 'the waist') + if not (math.isfinite(waist) and waist > 0): + raise ValidationError('the waist is a positive length', code='source.waist', + path=path + '.waist', actual=source.waist) + if not isinstance(source.focus, tuple) or len(source.focus) != 3: + raise ValidationError('the focus is a point (x, y, z)', code='source.focus', + path=path + '.focus') + focus = tuple(_concrete(c, path + '.focus', 'the focus') for c in source.focus) + band = _pulse(source.pulse, units) + wavelength = _check_wavelength(source.pulse.wavelength, path + '.pulse.wavelength') + window = prepare_window(problem, numerics, window_plane(problem, numerics, source.plane), + wavelength) + _sides(window, path + '.plane') + eps, mu = _medium(window, path + '.plane') + dx = window.dx + w_node = window.low[w] + window.k * dx + h_plane = w_node - sign * dx / 2.0 + planes = {'E': ('E', w_node), 'H': ('H', h_plane), + 'H_other': ('H', w_node + sign * dx / 2.0)} + fields, axial, padded = beam_fields(window, wavelength, waist, focus, polarization, sign, + planes, pad=4) + pad = 4 + while padded > PADDED: + pad *= 2 + points = 1 + for n, s in zip(window.eps['u'].shape, window.sides): + points *= 1 if s[0].kind == 'invariant' else (1 << int(math.ceil(math.log2(pad * n)))) + if points > TRANSFORM_POINTS: + raise UnsupportedFeatureError( + f'the beam does not fit a transform grid of {TRANSFORM_POINTS} points (it still ' + f'reaches {padded:.2g} of its peak at the padded edge, above {PADDED:g}). A ' + f'beam focused this tightly has a large part of its angular spectrum at the ' + f'light line, and the propagating part rings far out; widen the waist.', + code='source.beam_size', path=path) + fields, axial, padded = beam_fields(window, wavelength, waist, focus, polarization, + sign, planes, pad=pad) + masks = _masks(*window.eps['u'].shape, window.sides) + E = {c: fields['E'][c] * masks['E' + c] for c in 'uv'} + Ht = {c: fields['H'][c] * masks['H' + c] for c in 'uv'} + peak = max(float(np.max(np.abs(E['u']))), float(np.max(np.abs(E['v'])))) + edge = _edge(E, window.sides) / peak + if edge > EDGE: + raise UnsupportedFeatureError( + f'the beam reaches {edge:.2g} of its peak on the outermost cells of the plane ' + f'(at most {EDGE:g}); the sheet stops at the window, so the cross-section must ' + f'hold the beam. Widen the domain, or move the plane nearer the focus.', + code='source.beam_size', path=path) + # Kernel flux at the centre frequency: node-plane E against the mean of the two H planes. + Hm = {c: 0.5 * (fields['H'][c] + fields['H_other'][c]) * masks['H' + c] for c in 'uv'} + flux = 0.5 * float(np.sum((E['u'] * np.conj(Hm['v']) - E['v'] * np.conj(Hm['u'])).real)) * dx * dx + if not sign * flux > 0: + raise ValidationError('the beam carries no power through its window', + code='source.beam_size', path=path) + scale = 1.0 / math.sqrt(abs(flux) * window.measure) + patterns = {} + for c in 'uv': + for part, f in (('re', np.real), ('im', np.imag)): + patterns[f'E{c}_{part}'] = f(E[c]) * scale + patterns[f'H{c}_{part}'] = f(Ht[c]) * scale + components = {f'E{c}': ((f'E{c}_re', 0), (f'E{c}_im', 1)) for c in 'uv'} + components.update({f'H{c}': ((f'H{c}_re', 2), (f'H{c}_im', 3)) for c in 'uv'}) + omega0 = 2.0 * math.pi / wavelength + delta = dx / (2.0 * _axial_group_velocity(window, wavelength)) + half = 0.5 * math.pi + row_offsets = ((half, 0.0), (0.0, 0.0), (half - omega0 * delta, -delta), + (-omega0 * delta, -delta)) + reference = dict(wavelength=wavelength, beta=axial, window=window, eps=eps, mu=mu, + waist=waist, focus=focus, polarization=polarization, scale=scale, + edge_ratio=edge, position=w_node, measure=window.measure, pad=pad) + u, v = window.u, window.v + return ModeSheet(name=source.name, band=band, amplitude=source.amplitude, + phase=source.phase, delay=units._time_to_internal(source.delay_seconds), + axis=w, k=window.k, sign=sign, + window=(window.ranges[u], window.ranges[v]), patterns=patterns, + row_offsets=row_offsets, reference=reference, components=components, + shifts=(0.0, 0.0, 0.5, 0.5), kind='GaussianBeam') + + +def _edge(E, sides): + rim = 0.0 + for c in 'uv': + a = np.abs(E[c]) + (ul, uh), (vl, vh) = sides + if ul.kind != 'invariant': + rim = max(rim, float(np.max(a[:2, :])), float(np.max(a[-2:, :]))) + if vl.kind != 'invariant': + rim = max(rim, float(np.max(a[:, :2])), float(np.max(a[:, -2:]))) + return rim + + +def launched_power(sheet, plan, rt, wavelengths): + """``(forward, backward)``, ``(nf,)``: the spectral energy the sheet launches each way. + + In the units a :class:`FluxMonitor` reports before its spectral scaling (kernel flux times + the DFT's squared magnitude). The sheet's incident planes, truncated to its window, are + decomposed on the padded transform grid into the grid's discrete plane waves; for each + transverse wavenumber and each of its two polarizations, the pair (node-plane E, upstream + H) is uniquely a forward plus a backward wave, and their powers add. + """ + from ._mode_source import waveform_spectra + from ._kernel.grid import OFFSETS + window = sheet.reference['window'] + eps, mu = sheet.reference['eps'], sheet.reference['mu'] + w, u, v = window.w, window.u, window.v + dx, dt = window.dx, window.dt + sign = sheet.sign + pad = sheet.reference['pad'] + spectra = [np.asarray(_np(g)) for g in waveform_spectra(sheet, plan, rt, wavelengths)] + (u0, u1), (v0, v1) = window.ranges[u], window.ranges[v] + Nu, Nv = u1 - u0, v1 - v0 + invariant = [s[0].kind == 'invariant' for s in window.sides] + Mu = 1 if invariant[0] else 1 << int(math.ceil(math.log2(pad * Nu))) + Mv = 1 if invariant[1] else 1 << int(math.ceil(math.log2(pad * Nv))) + ku = 2.0 * np.pi * np.fft.fftfreq(Mu, dx)[:, None] + kv = 2.0 * np.pi * np.fft.fftfreq(Mv, dx)[None, :] + qu = (2.0 / dx) * np.sin(ku * dx / 2.0) + qv = (2.0 / dx) * np.sin(kv * dx / 2.0) + p = sheet.patterns + + def spectrum(field, c, values): + padded = np.zeros((Mu, Mv), dtype=complex) + padded[:Nu, :Nv] = values + off = OFFSETS[field][{'u': u, 'v': v}[c]] + return np.fft.fft2(padded) * np.exp(-1j * (ku * off[u] + kv * off[v]) * dx / 2.0) + + forward, backward = [], [] + for i, lam in enumerate(wavelengths): + omega = 2.0 * math.pi / float(lam) + Omega = 2.0 * math.sin(omega * dt / 2.0) / dt + qw2 = Omega ** 2 * eps * mu - qu ** 2 - qv ** 2 + prop = (qw2 > 0) & (np.sqrt(np.maximum(qw2, 0.0)) * dx / 2.0 < 1.0) + qw = np.where(prop, np.sqrt(np.maximum(qw2, 0.0)), 1.0) + theta = np.where(prop, np.arcsin(np.minimum(qw * dx / 2.0, 1.0)), 0.0) + Gc_E, Gs_E, Gc_H, Gs_H = (s[i] for s in spectra) + E = {c: spectrum('E', c, p[f'E{c}_re'] * Gc_E + p[f'E{c}_im'] * Gs_E) for c in 'uv'} + H = {c: spectrum('H', c, p[f'H{c}_re'] * Gc_H + p[f'H{c}_im'] * Gs_H) for c in 'uv'} + # Admittance of a wave travelling +-w: H_t = Y E_t, from H = q x E / (Omega mu) with + # E_w = -(q_u E_u + q_v E_v) / q_w. + f_tot, b_tot = 0.0, 0.0 + Y = {} + for s_dir in (1.0, -1.0): + q = s_dir * qw + # H_u = (q_v E_w - q E_v)/(Om mu), H_v = (q E_u - q_u E_w)/(Om mu) + Y[s_dir] = (((-qv * qu / q), (-qv * qv / q - q)), + ((q + qu * qu / q), (qu * qv / q))) + # Upstream half plane: the travelling-with wave lags by theta there, the other leads. + up = np.exp(-1j * theta) + dn = np.exp(1j * theta) + a = {1.0: up, -1.0: dn} if sign > 0 else {1.0: dn, -1.0: up} + # Solve E = Ef + Eb, H = Y+ Ef a+ + Y- Eb a- per wavenumber (2x2 for (u, v)). + M = np.zeros(qu.shape + (4, 4), dtype=complex) + rhs = np.zeros(qu.shape + (4,), dtype=complex) + scale = 1.0 / (Omega * mu) + M[..., 0, 0] = M[..., 1, 1] = M[..., 0, 2] = M[..., 1, 3] = 1.0 + for j, comp in enumerate(('u', 'v')): + for col_base, s_dir in ((0, 1.0), (2, -1.0)): + for k_e in (0, 1): + M[..., 2 + j, col_base + k_e] = Y[s_dir][j][k_e] * scale * a[s_dir] + rhs[..., 0], rhs[..., 1] = E['u'], E['v'] + rhs[..., 2], rhs[..., 3] = H['u'], H['v'] + sol = np.zeros_like(rhs) + sol[prop] = np.linalg.solve(M[prop], rhs[prop][..., None])[..., 0] + cos = np.cos(theta) + for col_base, s_dir, acc in ((0, 1.0, 'f'), (2, -1.0, 'b')): + Eu_, Ev_ = sol[..., col_base], sol[..., col_base + 1] + Hu_ = (Y[s_dir][0][0] * Eu_ + Y[s_dir][0][1] * Ev_) * scale + Hv_ = (Y[s_dir][1][0] * Eu_ + Y[s_dir][1][1] * Ev_) * scale + # Parseval over the padded grid (paired components share their transverse points): + # the kernel flux 0.5 Re sum E_t x conj(H_mean) dx^2, H_mean = cos(theta) H at the + # node plane. + flux = (Eu_ * np.conj(Hv_) - Ev_ * np.conj(Hu_)).real * cos + power = 0.5 * float(np.sum(np.where(prop, flux, 0.0))) * dx * dx / (Mu * Mv) + if acc == 'f': + f_tot = power + else: + b_tot = -power + forward.append(f_tot if sign > 0 else b_tot) + backward.append(b_tot if sign > 0 else f_tot) + return np.asarray(forward), np.asarray(backward) + + +def _np(value): + if hasattr(value, 'detach'): + value = value.detach() + if hasattr(value, 'cpu'): + value = value.cpu() + return np.asarray(value) diff --git a/src/wavebit/fdtd/_geometry.py b/src/wavebit/fdtd/_geometry.py index e539d6ea..16e20dac 100644 --- a/src/wavebit/fdtd/_geometry.py +++ b/src/wavebit/fdtd/_geometry.py @@ -156,9 +156,13 @@ class Structure: """A named physical body and its shared material model. ``fdtd.Structure('film', shape=fdtd.SnappedSlab(z=(0., 0.2)), material=Material(epsilon=2.25))``. - ``continuation`` explicitly extends a fixed, unturned :class:`Box` through named Open - faces (for example ``('z-',)`` for a substrate). The Box must fill both transverse - directions; finite scatterers cannot continue into an absorber. + ``continuation`` explicitly extends a body through named Open faces. A fixed, unturned + :class:`Box` that fills both transverse directions continues as a substrate (``('z-',)``), + and those faces' PML is matched to it. A :class:`SnappedBox` waveguide core that reaches the + faces of one axis continues through them with its cross-section unchanged + (``('z-', 'z+')``), and a :class:`SnappedSlab` names the faces of the axes it runs along; their + PML stays matched to its own medium. No other finite body continues into an absorber: a + smoothed core narrower than the cross-section is refused. """ name: str shape: object diff --git a/src/wavebit/fdtd/_kernel/mode_sheet.py b/src/wavebit/fdtd/_kernel/mode_sheet.py new file mode 100644 index 00000000..2b0cde54 --- /dev/null +++ b/src/wavebit/fdtd/_kernel/mode_sheet.py @@ -0,0 +1,74 @@ +"""The kernel term of a waveguide mode source: a one-sided TFSF plane fed by a mode. + +A :class:`ModeSheetTerm` is the PlaneWave construction (``sources.py`` beside this one) with a +waveguide mode for its incident field. The stored field is ``F_scat + chi F_inc`` with ``chi`` +the indicator of the total-field side; wherever a curl stencil straddles the sheet it must read +its neighbour in its own region, and the correction is ``+- sgn F_inc`` of that neighbour +(the same table the ``sources.py`` docstring writes out for a TFSF box). For a sheet on the +node plane ``k0`` across ``w``, with ``u = w+1`` and ``v = w+2``: + + travelling +w (total field at w >= w_k0, the box's low face): + before_H, H half plane k0 - 1/2: cE_u += +E_inc_v(k0) cE_v += -E_inc_u(k0) + before_E, E node plane k0: cH_u += +H_inc_v(k0-1/2) cH_v += -H_inc_u(k0-1/2) + travelling -w (total field at w <= w_k0, the box's high face): + before_H, H half plane k0 + 1/2: cE_u += -E_inc_v(k0) cE_v += +E_inc_u(k0) + before_E, E node plane k0: cH_u += -H_inc_v(k0+1/2) cH_v += +H_inc_u(k0+1/2) + +The incident field is separable: a static transverse pattern (the mode's E_t on the node plane, +its H_t on the half plane upstream, from ``src/wavebit/fdtd/_modes.py``, already in the kernel's +units and signed for the direction) times one waveform row each. Every pattern lives in +``params['sources'][name]['pattern']``, shaped like its slab. + +**The rows.** E rows are read at ``n dt`` (the level ``curl E(n)`` uses), H rows at +``(n + 1/2) dt``. A mode source has one of each; a Gaussian beam's patterns are complex, and +each of its two fields has a cosine and a sine row (``_beam_source.py``). For the mode source: +the E row is read at ``n dt``, the H row at ``(n + 1/2) dt``. The H row is the E row's waveform with the carrier advanced by the mode's +phase over half a cell, ``beta dx/2``, and the envelope advanced by its group delay over half a +cell, ``dx / (2 v_g)``: the half plane is upstream of the node plane by half a cell. At the +pulse's centre frequency the pair is then the discrete mode itself, so the sheet launches it +one way only, exactly; elsewhere in the band the profile and the half-cell phase are the +centre's, and what the sheet injects is computed exactly by +:func:`wavebit.fdtd._modes.injected_amplitudes`. ``row_offsets`` carries the two rows' +``(phase, delay)`` offsets for :func:`wavebit.fdtd._kernel.sources.waveform_table`. +""" +import dataclasses + +__all__ = ['ModeSheetTerm'] + + +@dataclasses.dataclass(frozen=True) +class ModeSheetTerm: + """One sheet: correction slabs, each fed by a pattern times one of the term's waveform rows. + + ``corrections`` is ``((slot, slab, label, sign, row), ...)``: ``slot`` ``'H'`` adds into + ``cE`` before the H update, ``'E'`` into ``cH`` before the E update; ``label`` names the + pattern in ``params['sources'][name]['pattern']`` and ``row`` the term's own row (an index + into ``rows``). ``rows`` are the rows' time shifts in steps and ``row_offsets`` their + ``(phase, delay)`` offsets. + """ + + name: str + row: int + corrections: tuple + row_offsets: tuple + rows: tuple = (0.0, 0.5) + + def init_mem(self, plan, rt): + return {} + + def _add(self, out, slot, n, params, rt): + pattern = params['sources'][self.name]['pattern'] + for kind, slab, label, sign, row in self.corrections: + if kind == slot: + w = params['waveform'][self.row + row, n] + out = rt.slab_add(out, slab, (w * sign) * pattern[label]) + return out + + def before_H(self, cE, E, mem, n, params, rt): + return self._add(cE, 'H', n, params, rt) + + def before_E(self, cH, H, mem, n, params, rt): + return self._add(cH, 'E', n, params, rt) + + def advance(self, mem, E, H, n, params, rt): + return mem diff --git a/src/wavebit/fdtd/_kernel/sources.py b/src/wavebit/fdtd/_kernel/sources.py index 2215fe11..97b9295f 100644 --- a/src/wavebit/fdtd/_kernel/sources.py +++ b/src/wavebit/fdtd/_kernel/sources.py @@ -183,11 +183,19 @@ def waveform_table(plan, params, rt): n = rt.array(np.arange(plan.steps, dtype=np.float64), 'float64') rows = [] for term in plan.terms: - for shift in getattr(term, 'rows', ()): + # A row may carry a static (phase, delay) offset of its own: a mode sheet's H row is + # its E row half a cell upstream (``mode_sheet.py``). No other term has one, and theirs + # are computed exactly as before. + offsets = getattr(term, 'row_offsets', None) + for i, shift in enumerate(getattr(term, 'rows', ())): p = params['sources'][term.name] s = (n + shift) * plan.dt - p['t0'] - p['delay'] + phase = p['phase'] + if offsets is not None: + s = s - offsets[i][1] + phase = phase + offsets[i][0] envelope = rt.xp.exp(-0.5 * (s / p['tau']) ** 2) - w = envelope * rt.xp.sin((2.0 * math.pi) * p['f0'] * s + p['phase']) + w = envelope * rt.xp.sin((2.0 * math.pi) * p['f0'] * s + phase) rows.append(p['amplitude'] * w) if plan.dtype == 'complex128': one = rt.array(1.0 + 0.0j, 'complex128') diff --git a/src/wavebit/fdtd/_legacy_monitors.py b/src/wavebit/fdtd/_legacy_monitors.py index 68ddfbea..1c594edd 100644 --- a/src/wavebit/fdtd/_legacy_monitors.py +++ b/src/wavebit/fdtd/_legacy_monitors.py @@ -345,11 +345,12 @@ def plane(self): @_eager_under_torch_compile def ModeMonitor(*args, **kwargs): - """Refused by name: mode overlaps are stage 7.""" - raise NotImplementedError('fd.ModeMonitor (mode overlaps) belongs to stage 7, with ' - 'fd.ModeSource. Stage 3 has fdtd.TimeSeriesMonitor, ' - 'fdtd.FluxMonitor, fdtd.FieldMonitor, fdtd.FarFieldMonitor ' - 'and fdtd.DiffractionMonitor.') + """Refused by name: mode overlaps (stage 7) are on the public surface only.""" + raise NotImplementedError('fd.ModeMonitor (mode overlaps, stage 7) is not on the engine ' + 'surface: the public wavebit.fdtd.ModeMonitor computes them, from ' + 'the modes of wavebit.fdtd.solve_modes. Stage 3 has ' + 'fdtd.TimeSeriesMonitor, fdtd.FluxMonitor, fdtd.FieldMonitor, ' + 'fdtd.FarFieldMonitor and fdtd.DiffractionMonitor.') @spec(positional=('threshold',)) diff --git a/src/wavebit/fdtd/_legacy_results.py b/src/wavebit/fdtd/_legacy_results.py index 2e3cac2f..f42922ec 100644 --- a/src/wavebit/fdtd/_legacy_results.py +++ b/src/wavebit/fdtd/_legacy_results.py @@ -149,6 +149,8 @@ class FluxResult: wavelengths: tuple flux: object faces: object = None + accumulators: object = None # a plane's corrected DFT phasors (Eu, Ev, Hu, Hv) + layout: object = None # and where they sit (_prepare/monitors.py::PlaneLayout) @_eager_under_torch_compile diff --git a/src/wavebit/fdtd/_legacy_sources.py b/src/wavebit/fdtd/_legacy_sources.py index de27622d..7e41f2a5 100644 --- a/src/wavebit/fdtd/_legacy_sources.py +++ b/src/wavebit/fdtd/_legacy_sources.py @@ -619,13 +619,17 @@ def pol(self): @_eager_under_torch_compile def GaussianBeam(*args, **kwargs): - """Refused by name: a Gaussian beam source is stage 7.""" - raise NotImplementedError('fd.GaussianBeam belongs to stage 7 (beams and mode sources). ' - 'Stage 2 has fdtd.CurrentDipole, fd.PlaneWave and fd.TFSF.') + """Refused by name: a Gaussian beam (stage 7) is on the public surface only.""" + raise NotImplementedError('fd.GaussianBeam (stage 7, beams and mode sources) is not on the ' + 'engine surface: the public wavebit.fdtd.GaussianBeam launches a ' + 'focused beam. Stage 2 has fdtd.CurrentDipole, fd.PlaneWave and ' + 'fd.TFSF.') @_eager_under_torch_compile def ModeSource(*args, **kwargs): - """Refused by name: a waveguide mode source is stage 7.""" - raise NotImplementedError('fd.ModeSource belongs to stage 7 (beams and mode sources). ' - 'Stage 2 has fdtd.CurrentDipole, fd.PlaneWave and fd.TFSF.') + """Refused by name: a waveguide mode source (stage 7) is on the public surface only.""" + raise NotImplementedError('fd.ModeSource (stage 7, beams and mode sources) is not on the ' + 'engine surface: the public wavebit.fdtd.ModeSource launches a ' + 'mode of wavebit.fdtd.solve_modes. Stage 2 has ' + 'fdtd.CurrentDipole, fd.PlaneWave and fd.TFSF.') diff --git a/src/wavebit/fdtd/_lower_public.py b/src/wavebit/fdtd/_lower_public.py index 0044ac13..4812a8ba 100644 --- a/src/wavebit/fdtd/_lower_public.py +++ b/src/wavebit/fdtd/_lower_public.py @@ -87,6 +87,44 @@ def refuse(message): center=tuple((a+b)/2 for a,b in zip(low,high))) +def _continue_snapped(item, shape, pad, physical_low, physical_high, allocated_high, allocated_low, + dx): + """A cell-snapped body declared to run straight through the Open faces of one axis. + + A waveguide core: a ``SnappedBox`` that reaches the named physical faces and continues, + unchanged in its cross-section, through their absorbing layers. Its cells along that axis + are the same in every layer, so no interface lies inside the layer -- the condition the + engine's absorbing-layer rule asks (``_prepare/checks.py::check_absorbing_layers``). Each + named face's PML stays matched to its own medium (the background, unless + ``PML(reference_media=)`` names another): a core is not the medium the layer is graded for. + A smoothed body is not admitted here (a smoothed rod through a PML is refused; see + ``docs/guide/fdtd/waveguide-modes.md``), and neither is a body that touches an Open face of + another axis, which the caller's rule below still refuses. + """ + path = 'scene.structures.' + item.name + def refuse(message): + raise UnsupportedFeatureError(message, code='fdtd.padding_continuation', path=path) + axes = {face[0] for face in item.continuation} + if len(axes) != 1: + refuse('a continued SnappedBox runs through the Open faces of one axis only') + axis_name = next(iter(axes)) + axis = 'xyz'.index(axis_name) + low, high = list(shape.low), list(shape.high) + for face in item.continuation: + if not pad[face]: + refuse(f'continuation must name an Open face with absorbing padding; {face!r} has none') + if face[1] == '-': + if not low[axis] <= physical_low[axis] + 1e-9 * dx: + refuse(f'the continued SnappedBox must reach the physical face {face!r}') + low[axis] = allocated_low[axis] - 2 * dx + else: + if not high[axis] >= physical_high[axis] - 1e-9 * dx: + refuse(f'the continued SnappedBox must reach the physical face {face!r}') + high[axis] = allocated_high[axis] + 2 * dx + return shape.replace(size=tuple(b - a for a, b in zip(low, high)), + center=tuple((a + b) / 2 for a, b in zip(low, high))) + + def _pulse(value, units): if not isinstance(value, sources.GaussianPulse): raise ValidationError('expected GaussianPulse', code='source.pulse', path='sources.pulse') @@ -112,7 +150,13 @@ def _full_cell_plane(plane, path): code=_area(path)+'.plane_span', path=path, expected='no transverse spans', actual=spans) -def _source(source, units): +def _source(source, units, problem=None, numerics=None): + if isinstance(source, sources.ModeSource): + from ._mode_source import mode_sheet + return mode_sheet(source, problem, numerics, units) + if isinstance(source, sources.GaussianBeam): + from ._beam_source import beam_sheet + return beam_sheet(source, problem, numerics, units) if not isinstance(source, (sources.PlaneWave, sources.CurrentDipole, sources.TFSF)): raise UnsupportedFeatureError('unsupported source', code='source.type', path='sources', actual=type(source).__name__) kw = dict(name=source.name, band=_pulse(source.pulse, units), @@ -164,8 +208,21 @@ def _monitor_geometry(m, field, expected): _ENGINE_SIDE = {'reflected': 'reflect', 'transmitted': 'transmit'} -def _monitor(m, problem): +def _monitor(m, problem, numerics=None): """The engine monitor for public monitor ``m``, built explicitly (never by class name).""" + if isinstance(m, monitors.ModeMonitor): + # A plane flux over exactly the mode window; the projection onto the modes is made + # from its accumulators when the result is adapted (src/wavebit/fdtd/_modes.py). + if not isinstance(m.plane, geometry.Plane): + raise ValidationError('expected Plane', code='monitor.plane', path='outputs.'+m.name+'.plane') + from ._modes import window_plane + if m.source is not None and not any(isinstance(s, sources.ModeSource) and s.name == m.source + for s in problem.sources): + raise ValidationError(f'ModeMonitor(source={m.source!r}) names no ModeSource of the ' + f'problem', code='monitor.source', + path='outputs.'+m.name+'.source', actual=m.source) + return old_monitors.Flux(name=m.name, wavelengths=m.wavelengths, + plane=window_plane(problem, numerics, m.plane)) if isinstance(m, monitors.TimeSeriesMonitor): return old_monitors.Record(m.name, at=m.at, component=m.component) if isinstance(m, (monitors.FieldMonitor, monitors.FluxMonitor)): @@ -217,8 +274,10 @@ def validate_sources(problem): names = set() for index, source in enumerate(problem.sources): indexed = f'sources[{index}]' - if not isinstance(source, (sources.PlaneWave, sources.CurrentDipole, sources.TFSF)): - raise UnsupportedFeatureError('expected PlaneWave, CurrentDipole or TFSF', + if not isinstance(source, (sources.PlaneWave, sources.CurrentDipole, sources.TFSF, + sources.ModeSource, sources.GaussianBeam)): + raise UnsupportedFeatureError('expected PlaneWave, CurrentDipole, TFSF, ModeSource or ' + 'GaussianBeam', code='source.type', path=indexed, actual=type(source).__name__) if not isinstance(source.name, str) or not source.name: raise ValidationError('source name must be a nonempty string', code='source.name', path=indexed+'.name') @@ -312,7 +371,25 @@ def lower_problem(problem, numerics, *, plan=None): for item in scene.structures: material = lower_material(item.material, units=units) shape = _geometry(item.shape, numerics.geometry_ranges.get(item.name)) - if item.continuation: + if item.continuation and isinstance(shape, old_geometry.Region): + # A cell-snapped core through the faces of its own axis; the faces keep their match. + shape = _continue_snapped(item, shape, pad, physical_low, physical_high, + allocated_high, allocated_low, dx) + elif item.continuation and isinstance(shape, old_geometry.Slab): + # A slab runs the whole box on its other two axes already; naming their faces is + # the explicit consent the crossing check below asks for. Its own axis keeps the + # half-space rule. + if any(face[0] == shape.axis for face in item.continuation): + raise UnsupportedFeatureError( + 'a SnappedSlab continues through its own axis by reaching the face (a ' + 'half-space); continuation names faces of the two axes it runs along', + code='fdtd.padding_continuation', path='scene.structures.'+item.name) + for face in item.continuation: + if not pad[face]: + raise UnsupportedFeatureError( + f'continuation must name an Open face with absorbing padding; {face!r} has none', + code='fdtd.padding_continuation', path='scene.structures.'+item.name) + elif item.continuation: shape = _continue_box(item, shape, pad, physical_low, physical_high, allocated_low, allocated_high, dx) for face in item.continuation: @@ -328,9 +405,10 @@ def lower_problem(problem, numerics, *, plan=None): bounds[index] = allocated_low[axis] if index == 0 else allocated_high[axis] faces[face] = material shape = shape.replace(**{axis_name:tuple(bounds)}) - # A slab cutting an orthogonal open face is heterogeneous along that face. - if any(n for f,n in pad.items() if f[0] != axis_name): - raise UnsupportedFeatureError('a slab crossing an orthogonal Open face has no homogeneous PML continuation', code='fdtd.padding_continuation', path='scene.structures.'+item.name) + # A slab cutting an orthogonal open face is heterogeneous along that face, unless + # the structure declares that it continues through it (a slab waveguide). + if any(n for f,n in pad.items() if f[0] != axis_name and f not in item.continuation): + raise UnsupportedFeatureError('a slab crossing an orthogonal Open face has no homogeneous PML continuation; a slab waveguide declares continuation=(...) for the faces it runs through', code='fdtd.padding_continuation', path='scene.structures.'+item.name) elif any(pad.values()): # Supported bodies are fully contained in the physical region; their lowerer # subsequently applies the stricter Yee/interpolation halo clearance checks. @@ -414,7 +492,7 @@ def lower_problem(problem, numerics, *, plan=None): raise ValidationError('expected fdtd.InitialField',code='initial.type',path='initial') initial=old_sources.InitialField(E=problem.initial.E,H=_scaled_initial(problem.initial.H,Z0),amplitude=problem.initial.amplitude) # Validate physical monitor positions before adding the implementation padding. - outputs=tuple(_monitor(m,problem) for m in problem.outputs.monitors) + outputs=tuple(_monitor(m,problem,numerics) for m in problem.outputs.monitors) for m in outputs: _monitor_plane_bounds(m, physical_low, physical_high) if isinstance(m,old_monitors.Record): @@ -422,7 +500,13 @@ def lower_problem(problem, numerics, *, plan=None): raise ValidationError('monitor is outside the physical domain',code='monitor.outside_domain',path='outputs.'+m.name) else:old_scene._monitor_inside(m,physical_low,physical_high) for source in problem.sources: - if isinstance(source,sources.PlaneWave): + if isinstance(source,(sources.ModeSource,sources.GaussianBeam)): + if not isinstance(source.plane, geometry.Plane): + raise ValidationError('expected Plane', code='source.plane', path='sources.'+source.name+'.plane') + axis=source.plane.axis + if not physical_low[axis]<=source.plane.position<=physical_high[axis]: + raise ValidationError('source is outside the physical domain',code='source.outside_domain',path='sources.'+source.name+'.plane') + elif isinstance(source,sources.PlaneWave): if not isinstance(source.plane, geometry.Plane): raise ValidationError('expected Plane', code='source.plane', path='sources.'+source.name+'.plane') axis=source.plane.axis @@ -436,7 +520,7 @@ def lower_problem(problem, numerics, *, plan=None): raise ValidationError('source is outside the physical domain',code='source.outside_domain',path='sources.'+source.name+'.box') legacy=old_scene.Scene(domain=old_scene.Domain(size=size,center=center), boundaries=old_boundaries.Boundaries(**converted),resolution=old_scene.Resolution(dx=dx),run=run, - background=background,structures=tuple(structures),sources=tuple(_source(s,units) for s in problem.sources), + background=background,structures=tuple(structures),sources=tuple(_source(s,units,problem,numerics) for s in problem.sources), monitors=outputs,initial=initial) # The engine reads a junction rule only on the standing member of a flush # pair. In particular, a continued substrate and unrelated bodies retain diff --git a/src/wavebit/fdtd/_mode_source.py b/src/wavebit/fdtd/_mode_source.py new file mode 100644 index 00000000..81ab6c00 --- /dev/null +++ b/src/wavebit/fdtd/_mode_source.py @@ -0,0 +1,198 @@ +"""A waveguide mode source: the public :class:`ModeSource` lowered to a one-sided mode sheet. + +The sheet is the kernel's TFSF construction with a mode of the discrete system for its +incident field (``src/wavebit/fdtd/_kernel/mode_sheet.py``); the mode is solved on the source plane +at the pulse's centre wavelength by ``src/wavebit/fdtd/_modes.py``. At that wavelength the sheet +injects the mode one way only, exactly. At every other wavelength it injects exactly what +:func:`injected_amplitudes` computes, by the following argument. + +**What a TFSF plane injects.** The sheet's corrections read only the incident field's two +planes: the transverse E on the node plane ``k0`` and the transverse H on the half plane +upstream. Any such pair of transverse fields is uniquely a sum of the discrete modes travelling +both ways, ``E_t(k0) = sum (alpha_m + gamma_m) e_m`` and ``H_t = sum (alpha_m exp(-i theta_m) - +gamma_m exp(+i theta_m)) h_m`` (``theta = beta dx/2``; the half plane upstream of a ``+w`` +sheet). That sum is an exact source-free solution with the same two planes, so the sheet +radiates what it would for it: the total-field side holds the whole sum and the scattered side +nothing. That field brings ``gamma`` in from infinity on the total-field side; the causal, +outgoing field subtracts the backward part everywhere. Downstream there is ``alpha_m``, and +upstream ``-gamma_m``: the injection and the leakage. The modes' exact unconjugated +orthogonality gives both from two overlaps ``X = sum (E_u h_v - E_v h_u) dA`` and ``Y = sum (e_u +H_v - e_v H_u) dA``: + + alpha = (X exp(i theta) + Y) / (2 N cos theta), gamma = (X exp(-i theta) - Y) / (2 N cos theta), + +with ``N = sum e . h dA``. With the centre frequency's mode as the incident field, ``alpha`` is the +pulse and ``gamma`` zero, which is the exactness stated above. (For a ``-w`` sheet the half plane +is downstream in ``w``; the roles of ``alpha`` and ``gamma`` swap.) +""" +import math +from dataclasses import dataclass + +import numpy as np + +from wavebit.errors import UnsupportedFeatureError, ValidationError + +__all__ = ['ModeSheet', 'mode_sheet', 'injected_amplitudes'] + + +@dataclass(frozen=True, eq=False) +class ModeSheet: + """The engine's mode source: a lowered :class:`ModeSource`, in the engine's units. + + ``band`` and the waveform leaves (``amplitude``, ``phase``, ``delay``) are the engine + source's usual ones; ``k`` is the sheet's node plane along ``axis`` and ``sign`` the + direction. ``window`` holds the transverse cell ranges, ``patterns`` the four incident + patterns (``'Eu'``, ``'Ev'``, ``'Hu'``, ``'Hv'``, local ``(N_u, N_v)`` arrays, scaled so that a + unit waveform is a 1 W mode), ``row_offsets`` the waveform rows' offsets, and ``reference`` + what :func:`injected_amplitudes` needs. + """ + + name: str + band: object + amplitude: object + phase: object + delay: object + axis: int + k: int + sign: int + window: tuple + patterns: dict + row_offsets: tuple + reference: dict + #: Each incident component's ``((pattern label, row), ...)``; the rows' time shifts. + components: dict = None + shifts: tuple = (0.0, 0.5) + kind: str = 'ModeSource' + + def __post_init__(self): + if self.components is None: + object.__setattr__(self, 'components', {'Eu': (('Eu', 0),), 'Ev': (('Ev', 0),), + 'Hu': (('Hu', 1),), 'Hv': (('Hv', 1),)}) + + @property + def position(self): + return self.reference['position'] + + +def _group_velocity(window, wavelength, index): + """``d omega / d beta`` of mode ``index`` at ``wavelength``, by a central difference.""" + from ._modes import solve_window + h = 1e-4 + betas = [] + for lam in (wavelength * (1 - h), wavelength * (1 + h)): + betas.append(solve_window(window, lam, index + 1)[index][1]) + omegas = (2 * math.pi / (wavelength * (1 - h)), 2 * math.pi / (wavelength * (1 + h))) + return (omegas[0] - omegas[1]) / (betas[0] - betas[1]) + + +def mode_sheet(source, problem, numerics, units): + """The :class:`ModeSheet` of public ``source``: solve its mode and lay out the sheet.""" + from ._modes import prepare_window, solve_window, window_plane + from ._lower_public import _pulse + from . import _geometry as geometry + path = 'sources.' + source.name + if not isinstance(source.plane, geometry.Plane): + raise ValidationError('expected Plane', code='source.plane', path=path + '.plane') + axis = source.plane.axis + direction = source.direction + if direction not in ('+' + 'xyz'[axis], '-' + 'xyz'[axis]): + raise ValidationError(f"ModeSource(direction={direction!r}): '+{'xyz'[axis]}' or " + f"'-{'xyz'[axis]}', along the plane's normal", + code='source.direction', path=path + '.direction') + sign = 1 if direction[0] == '+' else -1 + band = _pulse(source.pulse, units) + from ._modes import _check_wavelength + wavelength = _check_wavelength(source.pulse.wavelength, path + '.pulse.wavelength') + plane = window_plane(problem, numerics, source.plane) + window = prepare_window(problem, numerics, plane, wavelength) + index = source.mode + modes = solve_window(window, wavelength, index + 1) + q, beta, f, residual, edge = modes[index] + v_g = _group_velocity(window, wavelength, index) + if not (math.isfinite(v_g) and v_g > 0): + raise UnsupportedFeatureError(f'mode {index} has no positive group velocity at the ' + f'centre wavelength; a mode source launches a forward mode', + code='source.mode', path=path + '.mode') + dx = window.dx + omega0 = 2 * math.pi / wavelength + delta = dx / (2 * v_g) + # The H row: the carrier advanced by beta dx/2, the envelope by the half cell's group delay. + row_offsets = ((0.0, 0.0), (beta * dx / 2 - omega0 * delta, -delta)) + scale = 1.0 / math.sqrt(window.measure) # unit flux -> 1 W of the mode + patterns = {'Eu': f['Eu'].real * scale, 'Ev': f['Ev'].real * scale, + 'Hu': sign * f['Hu'].real * scale, 'Hv': sign * f['Hv'].real * scale} + w, u, v = window.w, window.u, window.v + reference = dict(wavelength=wavelength, beta=beta, q=q, group_velocity=v_g, index=index, + residual=residual, edge_ratio=edge, measure=window.measure, + position=window.low[w] + window.k * dx, window=window) + return ModeSheet(name=source.name, band=band, amplitude=source.amplitude, + phase=source.phase, delay=units._time_to_internal(source.delay_seconds), + axis=w, k=window.k, sign=sign, + window=(window.ranges[u], window.ranges[v]), patterns=patterns, + row_offsets=row_offsets, reference=reference) + + +def waveform_spectra(sheet, plan, rt, wavelengths): + """One ``(nf,)`` array per waveform row (a mode sheet's ``(G_E, G_H)``): its DFT at its own times. + + Built as the kernel builds the rows (``waveform_table``), from the declaration's leaves, so a + traced amplitude, phase or delay stays traced. + """ + xp = rt.xp + band = sheet.band + tau = 1.0 / ((2.0 * math.pi) * band.df) + t0 = band.sigmas * tau + f0 = 1.0 / band.wavelength + n = np.arange(plan.steps, dtype=np.float64) + freqs = np.asarray([1.0 / w for w in wavelengths], dtype=np.float64) + out = [] + for shift, (dphi, ddelay) in zip(sheet.shifts, sheet.row_offsets): + t = (n + shift) * plan.dt + s = rt.array(t, 'float64') - t0 - rt.array(sheet.delay, 'float64') - ddelay + w = (rt.array(sheet.amplitude, 'float64') + * xp.exp(-0.5 * (s / tau) ** 2) + * xp.sin((2.0 * math.pi) * f0 * s + rt.array(sheet.phase, 'float64') + dphi)) + kernel = np.exp(1j * 2.0 * math.pi * freqs[:, None] * t[None, :]) * plan.dt + out.append(rt.array(kernel, 'complex128') @ (w * rt.array(1.0 + 0.0j, 'complex128'))) + return tuple(out) + + +def injected_amplitudes(sheet, plan, rt, wavelengths, index=None): + """``(injected, leakage)``, ``(nf,)``: the source mode's amplitude launched and leaked. + + In the units :class:`ModeMonitor` reports before its spectral scaling (the DFT amplitude of + the unit-flux mode in the kernel's units), at the sheet's own node plane: ``injected`` is + the amplitude of the mode travelling the source's way on its far side, ``leakage`` of the + same mode travelling the other way on its near side. Each wavelength's mode is solved on the + source's window; ``index`` is the mode (the source's own by default). + """ + from ._modes import solve_window + xp = rt.xp + window = sheet.reference['window'] + index = sheet.reference['index'] if index is None else index + dA = window.dx * window.dx + G_E, G_H = waveform_spectra(sheet, plan, rt, wavelengths)[:2] + p = sheet.patterns # the incident field per unit waveform + injected, leakage = [], [] + for i, lam in enumerate(wavelengths): + q, beta, f, residual, edge = solve_window(window, float(lam), index + 1)[index] + theta = beta * window.dx / 2.0 + two_n_cos = 4.0 # 2 N cos(theta), N = 2/cos(theta) + # The sheet's incident planes at this wavelength, against the mode at this wavelength. + X0 = float(np.sum(p['Eu'] * f['Hv'].real - p['Ev'] * f['Hu'].real) * dA) + Y0 = float(np.sum(f['Eu'].real * p['Hv'] - f['Ev'].real * p['Hu']) * dA) + X, Y = X0 * G_E[i], Y0 * G_H[i] + if sheet.sign > 0: + alpha = (X * np.exp(1j * theta) + Y) / two_n_cos + gamma = (X * np.exp(-1j * theta) - Y) / two_n_cos + injected.append(alpha) + leakage.append(-gamma) + else: + # The half plane is downstream in w: there the forward mode's H is exp(+i theta) h + # and the backward one's -exp(-i theta) h, which swaps the two solutions; the sheet + # launches the backward mode. + alpha = (X * np.exp(-1j * theta) + Y) / two_n_cos + gamma = (X * np.exp(1j * theta) - Y) / two_n_cos + injected.append(gamma) + leakage.append(-alpha) + return xp.stack(injected), xp.stack(leakage) diff --git a/src/wavebit/fdtd/_modes.py b/src/wavebit/fdtd/_modes.py new file mode 100644 index 00000000..99c0631b --- /dev/null +++ b/src/wavebit/fdtd/_modes.py @@ -0,0 +1,972 @@ +"""Waveguide modes of the discrete Yee system: ``fdtd.solve_modes``. + +A mode here is a solution of the **kernel's own** update equations, not of the continuum +Maxwell equations, so that a mode injected into a run or projected out of one is exact on the +grid. The derivation, the registered acceptance criteria and the measured results are in +``docs/validation/fdtd/fd-waveguide-modes.md``; the manual is ``docs/guide/fdtd/waveguide-modes.md``. + +**The discrete eigenproblem.** With ``c = eps0 = mu0 = 1`` and ``H`` stored as ``Z0 H`` +(``src/wavebit/fdtd/_kernel/step.py``), a time-harmonic run ``exp(-i omega t)`` satisfies, for phasors taken at +each component's own times (``src/wavebit/fdtd/_post.py``, eq. (*)), + + curl_d E = i Omega mu H, curl_d H = -i Omega eps E, Omega = (2/dt) sin(omega dt/2), + +with ``curl_d`` the grid's differences divided by ``dx``. On a plane across axis ``w`` take the +right-handed transverse pair ``u = w + 1``, ``v = w + 2`` (mod 3); the Yee offsets are invariant +under that cyclic relabelling. For fields ``exp(i beta w)`` with every component referred to +its own Yee position, the forward and the backward difference along ``w`` both become ``i q``, + + q = (2/dx) sin(beta dx/2), + +exactly. Eliminating ``E_w`` and ``H_w`` (each through its own diagonal coefficient and the +masks of the faces) leaves, for ``e = (E_u, E_v)`` and ``h = (H_v, -H_u)``, + + q h = P e, q e = Q h, q^2 e = Q P e, + P = Omega diag(eps_u, eps_v) - (1/Omega) C^T diag(m_Hw / mu_w) C, C = [-D_v, D_u], + Q = Omega diag(mu_v, mu_u) - (1/Omega) G diag(m_Ew / eps_w) G^T, G = [D_u; D_v], + +with ``D_a`` the forward difference along ``a`` (the backward one is ``-D_a^T`` for the zero +and periodic neighbour rules this module admits) and ``m`` the 0/1 wall masks. ``P`` and ``Q`` +are real and symmetric, which gives the exact orthogonality of two modes with different +``q^2`` under the unconjugated cross product ``e_m . h_n`` (the derivation is in the +validation record). That orthogonality is what normalizes a mode and what a mode +decomposition projects with. + +**Power.** :func:`wavebit.fdtd._post.plane_flux` pairs the node-plane ``E`` with the mean of the +two half planes of ``H``, which for a mode is ``cos(beta dx/2)`` times the ``H`` referred to the +node. A mode is normalized so that this flux -- the one a ``FluxMonitor`` measures -- is 1 W +(1 W/m with one ``InvariantAxis``, 1 W/m^2 with two). + +**What is refused** (by name, never approximated): a conductive or dispersive medium in the +window, a medium that changes along ``w`` at the plane, coupled smoothed (constitutive) or +extended-stencil material anywhere in the scene, a ``Bloch`` or ``MirrorSymmetry`` transverse +face, an ``InvariantAxis`` with nonzero ``k``, traced or gradient-carrying inputs (the +eigensolve is a host computation; it does not differentiate), and a mode that is not confined +inside a window edge that truncates the scene (an ``Open`` face, or a finite plane span). +""" +import math +from dataclasses import dataclass, field + +import numpy as np + +from wavebit._common.backend import eager_under_torch_compile +from wavebit._common.declarations import Declaration, spec, static_float +from wavebit._common.execution import frozen_mapping +from wavebit._common.pytrees import register_dataclass +from wavebit.errors import UnsupportedFeatureError, ValidationError +from wavebit.units import Z0 + +__all__ = ['solve_modes', 'WaveguideMode', 'WaveguideModes', 'ModeSpectrum'] + +#: A truncating window edge (an ``Open`` face or a finite plane span) admits a mode only when +#: its largest transverse E on the outermost cells is at most this fraction of its peak. +CONFINEMENT = 1e-3 + +#: Relative separation under which two eigenvalues ``q^2`` are one degenerate cluster. +DEGENERATE = 1e-8 + +#: Largest accepted relative eigen-residual ``|A e - q^2 e| / |q^2 e|``. +RESIDUAL = 1e-8 + +#: Relative difference under which two layers' coefficients count as the same medium. +UNIFORM = 1e-12 + +#: Problems with at most this many unknowns are solved densely. +DENSE_LIMIT = 1600 + +_AXES = 'xyz' +_COMPONENTS = ('Ex', 'Ey', 'Ez', 'Hx', 'Hy', 'Hz') + + +@spec +class WaveguideMode(Declaration): + """One mode of the discrete Yee system on a plane, normalized to unit power along ``+w``. + + ``effective_index`` is ``beta c / omega`` at the physical frequency, with ``beta`` the + propagation constant of the grid's own dispersion (``beta_per_meter`` in rad/m). ``E`` and + ``H`` map component names (``'Ex'`` ... ``'Hz'``) to complex arrays in V/m and A/m on that + component's own Yee points of the plane (see :class:`WaveguideModes` for the axes and the + coordinates). The transverse components are real and the longitudinal ones imaginary: + the phasors of a run ``exp(-i omega t)`` taken at each component's own times, with ``E`` + on the node plane and the ``H`` components that lie half a cell along the axis referred + to their own half plane. ``polarization`` gives the share of ``sum |E_t|^2`` along each + transverse axis; ``power_watts`` is 1 (per metre of each invariant axis). + """ + index: int + effective_index: float + beta_per_meter: float + E: object + H: object + polarization: object + power_watts: float + metadata: object = field(default_factory=dict) + + def __post_init__(self): + register_dataclass(type(self)) + for name in ('E', 'H', 'polarization', 'metadata'): + object.__setattr__(self, name, frozen_mapping(getattr(self, name))) + + +@spec +class WaveguideModes(Declaration): + """The modes :func:`solve_modes` found, by decreasing effective index. + + ``modes`` is the tuple of :class:`WaveguideMode`; ``modes[0]`` (or ``result[0]``) is the + largest index. ``axis`` is the plane's normal and ``position`` the node plane it snapped + to, in ``Problem.units``. ``axes`` names the two array axes of every field, in the order + the grid stores them (``('y', 'x')`` for a z plane); ``coordinates[component]`` gives + their coordinates. ``wavelength`` is the vacuum wavelength in ``Problem.units``. + """ + modes: tuple + axis: str + position: float + wavelength: float + axes: tuple + coordinates: object + metadata: object = field(default_factory=dict) + + def __post_init__(self): + register_dataclass(type(self)) + object.__setattr__(self, 'modes', tuple(self.modes)) + for name in ('coordinates', 'metadata'): + object.__setattr__(self, name, frozen_mapping(getattr(self, name))) + + def __getitem__(self, index): + return self.modes[index] + + def __len__(self): + return len(self.modes) + + def __iter__(self): + return iter(self.modes) + + +@spec +class ModeSpectrum(Declaration): + """What a :class:`ModeMonitor` measured: each mode's share of the run, both ways, per wavelength. + + Arrays are ``(wavelengths, modes)``. ``forward_energy`` and ``backward_energy`` are the + two-sided pulse spectral energies (``units``: J/Hz, or per metre of each invariant axis) + carried toward the plane's ``+`` and ``-`` normal by each mode, scaled exactly as a + :class:`FluxMonitor` scales its flux, so that for a field made only of these modes + ``sum(forward_energy - backward_energy)`` over the modes is the flux. ``forward`` and + ``backward`` are the complex amplitudes, in the square root of those units, of the modes as + :func:`solve_modes` returns them at that wavelength (``modes[i]`` holds them, with their + phase convention); ``|forward|**2`` is ``forward_energy``. ``effective_index`` is each + mode's index on the grid. + + With ``ModeMonitor(source=...)``, ``injected`` and ``leakage`` (``(wavelengths,)``, complex, + same units as ``forward``) are the amplitudes of that source's mode, on the source's plane, + that it launched its way and leaked the other way; ``injected_energy`` and + ``leakage_energy`` are their squared magnitudes. Without it they are ``None``. + """ + wavelengths: tuple + frequencies_hz: object + effective_index: object + forward: object + backward: object + forward_energy: object + backward_energy: object + units: str + modes: tuple = () + metadata: object = field(default_factory=dict) + injected: object = None + leakage: object = None + injected_energy: object = None + leakage_energy: object = None + + def __post_init__(self): + register_dataclass(type(self)) + object.__setattr__(self, 'modes', tuple(self.modes)) + object.__setattr__(self, 'metadata', frozen_mapping(self.metadata)) + + +# --------------------------------------------------------------------------------------------- +# The discrete operator +# --------------------------------------------------------------------------------------------- + +@dataclass(frozen=True) +class Side: + """A window edge's neighbour rule: ``'wall'`` (PEC, or a truncation), ``'pmc'``, + ``'periodic'`` or ``'invariant'``; ``truncates`` marks a wall that is not physical.""" + + kind: str + truncates: bool = False + + +def _difference(n, kind_low, kind_high, dx): + """The forward difference along one axis of ``n`` cells, divided by ``dx`` (sparse).""" + import scipy.sparse as sp + if kind_low == 'invariant': + return sp.csr_matrix((1, 1)) + D = sp.diags([-np.ones(n), np.ones(n - 1)], [0, 1], shape=(n, n), format='lil') + if kind_high == 'periodic': + D[n - 1, 0] += 1.0 + return D.tocsr() / dx + + +def _masks(Nu, Nv, sides): + """0/1 masks of the six local components ``(Eu, Ev, Ew, Hu, Hv, Hw)``, each ``(Nu, Nv)``. + + The kernel's face masks (``src/wavebit/fdtd/_kernel/faces.py``): a wall on a low face zeroes the tangential + E on its node layer and the normal H there; a PMC on a high face zeroes the tangential H on + its last half layer and the normal E there. Every other side has none. + """ + m = {name: np.ones((Nu, Nv)) for name in ('Eu', 'Ev', 'Ew', 'Hu', 'Hv', 'Hw')} + for a, (low, high) in enumerate(sides): + index = (slice(0, 1), slice(None)) if a == 0 else (slice(None), slice(0, 1)) + last = (slice(Nu - 1, Nu), slice(None)) if a == 0 else (slice(None), slice(Nv - 1, Nv)) + axis_name = 'uv'[a] + tangential = [c for c in 'uvw' if c != axis_name] + if low.kind == 'wall': + for c in tangential: + m['E' + c][index] = 0.0 + m['H' + axis_name][index] = 0.0 + if high.kind == 'pmc': + for c in tangential: + m['H' + c][last] = 0.0 + m['E' + axis_name][last] = 0.0 + return m + + +def discrete_modes(eps, mu, sides, dx, omega_d, count): + """The ``count`` largest-``q^2`` modes of the window, on the local ``(u, v)`` arrays. + + ``eps`` and ``mu`` are dicts ``{'u': (Nu, Nv), 'v': ..., 'w': ...}`` of each local + component's diagonal coefficient at its own Yee point; ``sides`` is + ``((u_low, u_high), (v_low, v_high))`` of :class:`Side`; ``omega_d`` is ``Omega``. Returns + ``(q2, fields, residuals)`` with ``fields[i]`` a dict of the six local components (complex + arrays, unnormalized but real-phased) and ``q2`` descending. Lengths are in the units of + ``dx``; ``H`` is ``Z0 H``. + """ + import scipy.sparse as sp + Nu, Nv = eps['u'].shape + (ul, uh), (vl, vh) = sides + du = _difference(Nu, ul.kind, uh.kind, dx) + dv = _difference(Nv, vl.kind, vh.kind, dx) + Iu, Iv = sp.identity(Nu, format='csr'), sp.identity(Nv, format='csr') + Du = sp.kron(du, Iv, format='csr') + Dv = sp.kron(Iu, dv, format='csr') + m = _masks(Nu, Nv, sides) + flat = {k: v.reshape(-1) for k, v in m.items()} + ev = lambda d: sp.diags(d.reshape(-1)) # noqa: E731 + C = sp.hstack([-Dv, Du], format='csr') + G = sp.vstack([Du, Dv], format='csr') + P = (omega_d * sp.block_diag([ev(eps['u']), ev(eps['v'])]) + - (1.0 / omega_d) * (C.T @ ev(flat['Hw'] / mu['w'].reshape(-1)) @ C)) + Q = (omega_d * sp.block_diag([ev(mu['v']), ev(mu['u'])]) + - (1.0 / omega_d) * (G @ ev(flat['Ew'] / eps['w'].reshape(-1)) @ G.T)) + keep_e = np.concatenate([flat['Eu'], flat['Ev']]) > 0 + keep_h = np.concatenate([flat['Hv'], flat['Hu']]) > 0 + Re = sp.identity(2 * Nu * Nv, format='csr')[np.flatnonzero(keep_e)] + Rh = sp.identity(2 * Nu * Nv, format='csr')[np.flatnonzero(keep_h)] + Pr = (Rh @ P @ Re.T).tocsr() + Qr = (Re @ Q @ Rh.T).tocsr() + A = (Qr @ Pr).tocsc() + n = A.shape[0] + top = omega_d ** 2 * max(float(np.max(eps[c] * mu[d])) + for c, d in (('u', 'v'), ('v', 'u'), ('w', 'w'))) + wanted = min(count + 6, n) + if n <= DENSE_LIMIT: + values, vectors = np.linalg.eig(A.toarray()) + else: + from scipy.sparse.linalg import eigs + sigma = top * (1.0 + 1e-3) + # A fixed start vector makes the result reproducible to the bit; a generic one (not, + # say, all ones, which a symmetric guide's odd modes are orthogonal to) reaches every + # mode. + start = np.random.default_rng(0).standard_normal(n) + values, vectors = eigs(A, k=min(wanted, n - 2), sigma=sigma, which='LM', v0=start) + order = np.argsort(-values.real) + values, vectors = values[order], vectors[:, order] + values, vectors = values[:wanted], vectors[:, :wanted] + # Real-phase each degenerate cluster and diagonalize the cross product inside it. + q2_out, e_out = [], [] + i = 0 + while i < len(values): + j = i + 1 + while j < len(values) and abs(values[j] - values[i]) <= DEGENERATE * abs(values[i]): + j += 1 + V = vectors[:, i:j] + k = j - i + basis = np.concatenate([V.real, V.imag], axis=1) + U, s, _ = np.linalg.svd(basis, full_matrices=False) + if s.size and s[min(k, s.size) - 1] <= 1e-8 * s[0]: + raise ValidationError('a degenerate cluster of modes did not span a real subspace', + code='mode.convergence', path='solve_modes') + Eb = U[:, :k] + # Propagating candidates (Re q^2 > 0) of a lossless window are real; a complex pair + # can appear only among evanescent ones (Re q^2 < 0), which no mode is taken from. + if np.any((values[i:j].real > 0) + & (np.abs(values[i:j].imag) > DEGENERATE * np.abs(values[i:j].real))): + raise ValidationError( + f'a propagating eigenvalue q^2 = {complex(values[i]):.6g} is complex; a lossless ' + f'cross-section has a real spectrum there, so the eigensolve did not resolve ' + f'this window', code='mode.convergence', path='solve_modes') + q2 = float(np.mean(values[i:j].real)) + if q2 > 0: + H = (Pr @ Eb) / math.sqrt(q2) + B = Eb.T @ H + B = 0.5 * (B + B.T) + _, W = np.linalg.eigh(B) + Eb = Eb @ W + for c in range(k): + q2_out.append(q2) + e_out.append(Eb[:, c]) + i = j + fields, residuals = [], [] + for q2, e in zip(q2_out, e_out): + r = np.linalg.norm(A @ e - q2 * e) / max(abs(q2) * np.linalg.norm(e), 1e-300) + residuals.append(float(r)) + if not q2 > 0: + fields.append(None) + continue + q = math.sqrt(q2) + h = (Pr @ e) / q + E = np.zeros(2 * Nu * Nv) + E[keep_e] = e + Hf = np.zeros(2 * Nu * Nv) + Hf[keep_h] = h + Eu, Ev = E[:Nu * Nv], E[Nu * Nv:] + Hv, Hu = Hf[:Nu * Nv], -Hf[Nu * Nv:] + Ew = flat['Ew'] * (G.T @ Hf) / (1j * omega_d * eps['w'].reshape(-1)) + Hw = flat['Hw'] * (C @ E) / (1j * omega_d * mu['w'].reshape(-1)) + shape = (Nu, Nv) + fields.append(dict(Eu=Eu.reshape(shape) + 0j, Ev=Ev.reshape(shape) + 0j, + Ew=Ew.reshape(shape), Hu=Hu.reshape(shape) + 0j, + Hv=Hv.reshape(shape) + 0j, Hw=Hw.reshape(shape))) + return q2_out, fields, residuals + + +def cross_product(f, g, weights): + """``sum (E_u^f H_v^g - E_v^f H_u^g) dA`` over the window: the unconjugated pairing.""" + return float(np.sum((f['Eu'] * g['Hv'] - f['Ev'] * g['Hu']) * weights).real) + + +# --------------------------------------------------------------------------------------------- +# The public door +# --------------------------------------------------------------------------------------------- + +def _refuse(message, code, path='solve_modes', cls=UnsupportedFeatureError): + raise cls(message, code=code, path=path) + + +def _traced(value): + """Whether ``value`` is a JAX tracer or a Torch tensor carrying a gradient.""" + if getattr(value, 'requires_grad', False): + return True + module = type(value).__module__ or '' + return module.startswith('jax') and 'Tracer' in type(value).__name__ + + +def _leaves(value): + if isinstance(value, (tuple, list)): + for v in value: + yield from _leaves(v) + elif value is not None: + yield value + + +def _traced_leaf(material): + """The name of the first traced or gradient-carrying field of ``material``, or ``None``.""" + from wavebit.materials import Material, ConductiveMaterial, DispersiveMaterial + names = () + if isinstance(material, Material): + names = ('epsilon', 'mu') + elif isinstance(material, ConductiveMaterial): + names = ('epsilon', 'mu', 'conductivity') + elif isinstance(material, DispersiveMaterial): + names = ('epsilon_inf', 'mu') + for name in names: + if any(_traced(leaf) for leaf in _leaves(getattr(material, name, None))): + return name + return None + + +def _side(face, truncated_span, path): + """The neighbour rule a window edge reads, from the public face declaration.""" + from . import _boundaries as boundaries + if truncated_span: + return Side('wall', truncates=True) + if isinstance(face, boundaries.Open): + return Side('wall', truncates=True) + if isinstance(face, boundaries.PEC): + return Side('wall') + if isinstance(face, boundaries.PMC): + return Side('pmc') + if isinstance(face, boundaries.Periodic): + return Side('periodic') + if isinstance(face, boundaries.InvariantAxis): + k = face.k + if _traced(k): + _refuse('an InvariantAxis k is traced; solve_modes needs a concrete k = 0', + 'mode.traced', path) + if float(k) != 0.0: + _refuse(f'an InvariantAxis with k = {float(k):g} makes the cross-section a Bloch ' + f'problem with a complex operator; solve_modes admits k = 0 only.', + 'mode.boundary', path) + return Side('invariant') + name = type(face).__name__ + _refuse(f'a {name} transverse face is not supported by solve_modes: its neighbour rule ' + f'breaks the symmetric discrete operator the solver and the mode orthogonality rely ' + f'on. PEC, PMC, Periodic, Open and InvariantAxis (k = 0) are.', 'mode.boundary', path) + + +def _component_slice(arr, c, w, k, ranges): + """``arr[c]`` on node or half layer ``k`` along ``w`` over ``ranges = {axis: (lo, hi)}``, + as a ``(N_u, N_v)`` array with ``u = w+1``, ``v = w+2``.""" + u, v = (w + 1) % 3, (w + 2) % 3 + full = np.asarray(arr) + idx = [c, slice(None), slice(None), slice(None)] + for a in range(3): + dim = 3 - a + n = full.shape[dim] + if a == w: + idx[dim] = slice(0, 1) if n == 1 else slice(k, k + 1) + else: + lo, hi = ranges[a] + idx[dim] = slice(None) if n == 1 else slice(lo, hi) + block = full[tuple(idx)] # (z, y, x), length 1 along w + # Move to (u, v, w) and drop w. + order = [2 - u, 2 - v, 2 - w] + block = np.transpose(block, order)[:, :, 0] + shape = tuple(ranges[a][1] - ranges[a][0] for a in (u, v)) + return np.broadcast_to(block, shape).astype(np.float64, copy=True) + + +@eager_under_torch_compile +def solve_modes(problem, *, numerics, plane, wavelength, count=1, backend='numpy'): + """The ``count`` largest-index modes of the discrete Yee system on ``plane``. + + ``fdtd.solve_modes(problem, numerics=numerics, plane=fdtd.Plane(z=0.), wavelength=1.55, + count=2)`` prepares ``problem`` as :func:`prepare` does, reads the diagonal permittivity and + permeability the time stepper will use on the plane, and returns + :class:`WaveguideModes`: each mode's effective index, its E and H on the Yee points, and a + normalization to 1 W along the plane's positive normal. ``wavelength`` is the vacuum + wavelength in ``Problem.units``. The plane spans the physical cross-section unless given + transverse spans, which truncate it with a wall. Across an ``Open`` face or a span, a mode + must be confined: its transverse E on the outermost cells at most 1e-3 of its peak. + ``backend`` chooses the array type of the returned fields; the eigensolve itself runs once + on the host with SciPy and does not differentiate: a traced or gradient-carrying value in a + structure that reaches the plane, or in the wavelength, is refused. Structures that do not + reach the plane play no part in the solve. + """ + from ._api import _issue + from ._spelling import public as _public_spelling + try: + with _public_spelling(): + return _solve(problem, numerics, plane, wavelength, count, backend) + except (TypeError, ValueError, NotImplementedError) as exc: + public = _issue(exc) + if public is exc: + raise + raise public from exc + + +def _check_count(count, path='count'): + if isinstance(count, bool) or not isinstance(count, (int, np.integer)) or count < 1: + raise ValidationError('count is a positive integer', code='mode.count', path=path, + actual=count) + return int(count) + + +def _check_wavelength(wavelength, path='wavelength'): + if _traced(wavelength): + _refuse('the wavelength is traced or carries a gradient; the mode solve is a host ' + 'eigensolve and does not differentiate', 'mode.traced', path) + try: + lam = static_float('solve_modes', 'wavelength', wavelength) + except (TypeError, ValueError) as exc: + raise ValidationError(str(exc), code='mode.wavelength', path=path) from exc + if not (math.isfinite(lam) and lam > 0): + raise ValidationError('the wavelength is a positive length', code='mode.wavelength', + path=path, actual=wavelength) + return lam + + +def _solve(problem, numerics, plane, wavelength, count, backend): + if backend not in ('numpy', 'jax', 'torch'): + raise ValidationError("backend is 'numpy', 'jax' or 'torch'", code='mode.backend', + path='backend', actual=backend) + count = _check_count(count) + lam = _check_wavelength(wavelength) + from . import _geometry as geometry + if not isinstance(plane, geometry.Plane): + raise ValidationError('expected fdtd.Plane', code='mode.plane', path='plane') + window = prepare_window(problem, numerics, window_plane(problem, numerics, plane), lam) + modes = solve_window(window, lam, count) + # A mode normalized to unit flux in the kernel's units carries ``measure`` watts + # (src/wavebit/fdtd/_result.py::_measure): rescale to 1 W (per metre of each invariant axis). + scale = 1.0 / math.sqrt(window.measure) + modes = [(q, beta, {k: v * scale for k, v in f.items()}, residual, edge) + for q, beta, f, residual, edge in modes] + return _package(problem, window, lam, modes, backend) + + +@dataclass(frozen=True) +class Window: + """Everything a mode solve on one plane needs, independent of the wavelength. + + ``eps``/``mu`` are the local ``u, v, w`` diagonal coefficients on the window (kernel units); + ``ranges[a]`` the window's cell range along each axis (``(k, k + 1)`` along ``w``); + ``sides`` the transverse neighbour rules; ``measure`` the watts per unit kernel flux. + """ + + w: int + u: int + v: int + k: int + ranges: dict + sides: tuple + eps: dict + mu: dict + dx: float + dt: float + low: tuple + measure: float + length_unit: float + + +def window_plane(problem, numerics, plane): + """``plane`` with explicit spans wherever the mode window is narrower than the grid axis. + + The window is the physical cross-section, or the plane's own spans. Along an axis with an + ``Open`` face it stops **one cell inside** the physical edge on that side: the window's + edge node is its wall, and a flux plane over the window reads that node, which on the + physical edge would be the absorbing layer's first (a flux plane may not read a layer). + :func:`solve_modes` and :class:`ModeMonitor` use the same window, so they solve the same + modes. + """ + from . import _boundaries as boundaries + from ._kernel.grid import cell_count + dx = static_float('Numerics', 'grid.spacing', numerics.grid.spacing) + w = plane.axis + spans = {} + for a in range(3): + if a == w: + continue + name = _AXES[a] + span = plane.extent(a) + faces = [problem.scene.boundaries.face(name + s) for s in '-+'] + if span is None and any(isinstance(f, boundaries.Open) for f in faces): + n = cell_count(problem.scene.domain.size[a], dx) + lo = problem.scene.domain.center[a] - n * dx / 2 + span = (lo + (dx if isinstance(faces[0], boundaries.Open) else 0.0), + lo + n * dx - (dx if isinstance(faces[1], boundaries.Open) else 0.0)) + if span is not None: + spans[name] = tuple(span) + if not spans: + return plane + return type(plane)(**{_AXES[w]: plane.position}, **spans) + + +def prepare_window(problem, numerics, plane, wavelength): + """The :class:`Window` of ``plane``: the solve's static part, refused where it must be. + + Only the structures whose support reaches the window (one node layer either side of the + plane) are kept for the coefficients: painting is in declaration order and a structure that + does not reach a cell does not change it, so the window's coefficients are the full + scene's. That keeps a traced structure elsewhere -- an optimized scatterer downstream -- + out of the host eigensolve, while one that reaches the plane is refused. + """ + from ._problem import Problem + from ._numerics import Numerics, Duration, PML + from . import _geometry as geometry, _monitors as monitors + from ._lower_public import lower_problem + from ._execution import _planned + from ._prepare.coefficients import build + from ._runtime import runtime + from ._kernel.grid import Grid + if not isinstance(problem, Problem) or not isinstance(numerics, Numerics): + raise ValidationError('expected fdtd.Problem and fdtd.Numerics', code='problem.type', + path='problem') + if not isinstance(plane, geometry.Plane): + raise ValidationError('expected fdtd.Plane', code='mode.plane', path='plane') + w = plane.axis + u, v = (w + 1) % 3, (w + 2) % 3 + bounds = problem.scene.boundaries + if type(bounds.face(_AXES[w] + '-')).__name__ == 'InvariantAxis': + raise ValidationError(f'the plane is across the InvariantAxis {_AXES[w]!r}; a mode ' + f'propagates along a resolved axis.', code='mode.plane', + path='plane') + leaf = _traced_leaf(problem.scene.background) + if leaf: + _refuse(f'scene.background.{leaf} is traced or carries a gradient; the mode solve is a ' + f'host eigensolve and does not differentiate.', 'mode.traced', + 'scene.background.' + leaf) + + # The full scene, lowered only for its grid and its structures' supports. Its sources and + # monitors are left out: a ModeSource is lowered by solving on its own window, and neither + # changes the grid, the time step or the coefficients. A run length that counts a source's + # periods is replaced by one step, and a PML that takes its wavelength from the sources is + # graded for the solve's wavelength (its profile lies outside the window). + from ._monitors import Outputs + absorber = numerics.absorber + if isinstance(absorber, PML) and absorber.wavelength is None: + absorber = absorber.replace(wavelength=wavelength) + full, metadata = lower_problem( + problem.replace(sources=(), outputs=Outputs()), + numerics.replace(duration=Duration(steps=1), absorber=absorber)) + grid = Grid.from_domain(full.domain.size, full.domain.center, full.resolution.dx) + pad = metadata['padding_cells'] + low = metadata['physical_domain']['low'] + size = metadata['physical_domain']['size'] + phys = {a: (int(pad[_AXES[a] + '-']), int(pad[_AXES[a] + '-']) + int(round(size[a] / grid.dx))) + for a in range(3)} + position = float(plane.position) + if not (low[w] - 1e-9 * grid.dx <= position <= low[w] + size[w] + 1e-9 * grid.dx): + raise ValidationError('the plane is outside the physical domain', + code='mode.outside_domain', path='plane', + expected=(low[w], low[w] + size[w]), actual=position) + k = int(round((position - grid.low[w]) / grid.dx)) + ranges, sides = {}, [] + for a in (u, v): + span = plane.extent(a) + lo, hi = phys[a] + truncated = (False, False) + if span is not None: + i0 = int(round((static_float('Plane', _AXES[a], span[0]) - grid.low[a]) / grid.dx)) + i1 = int(round((static_float('Plane', _AXES[a], span[1]) - grid.low[a]) / grid.dx)) + if i1 <= i0 or i0 < lo or i1 > hi: + raise ValidationError(f'the plane span along {_AXES[a]} snaps to nodes ' + f'{i0}..{i1}, outside the physical cross-section ' + f'{lo}..{hi} or empty', code='mode.plane', + path='plane.' + _AXES[a], actual=span) + truncated = (i0 > lo, i1 < hi) + lo, hi = i0, i1 + ranges[a] = (lo, hi) + pair = [_side(bounds.face(_AXES[a] + sign), cut, 'scene.boundaries.' + _AXES[a] + sign) + for sign, cut in zip('-+', truncated)] + if any(truncated) and any(s.kind == 'periodic' for s in pair): + # A span across part of a periodic axis is no longer periodic: both ends truncate. + pair = [Side('wall', truncates=True), Side('wall', truncates=True)] + sides.append(tuple(pair)) + layers = [kk for kk in (k - 1, k, k + 1) if 0 <= kk < grid.n[w]] + ranges[w] = (k, k + 1) + box = {u: ranges[u], v: ranges[v], w: (min(layers), max(layers) + 1)} + + kept = [] + for declared, lowered in zip(problem.scene.structures, full.structures): + support = lowered.shape.support(grid) + if not all(support[a][0] < box[a][1] and support[a][1] > box[a][0] for a in range(3)): + continue + path = 'scene.structures.' + declared.name + if declared.name in numerics.geometry_ranges: + _refuse(f'{path} reaches the plane and traces its geometry ' + f'(Numerics(geometry_ranges=...)); the mode solve is a host eigensolve and ' + f'does not differentiate.', 'mode.traced', path) + leaf = _traced_leaf(declared.material) + if leaf: + _refuse(f'{path}.material.{leaf} is traced or carries a gradient and the structure ' + f'reaches the plane; the mode solve is a host eigensolve and does not ' + f'differentiate. Structures that do not reach the plane may be traced.', + 'mode.traced', path + '.material.' + leaf) + if numerics.discretization == 'stencil' and type(declared.shape) is geometry.Sphere: + _refuse("a structure with discretization='stencil' reaches the plane; its extended " + 'operator is not a diagonal medium the mode solve models.', 'mode.material', + 'numerics.discretization') + kept.append(declared) + + # The coefficients of the window: the same grid, the kept structures only. + absorber = numerics.absorber + if isinstance(absorber, PML) and absorber.wavelength is None: + absorber = absorber.replace(wavelength=wavelength) + reduced = problem.replace(scene=problem.scene.replace(structures=tuple(kept)), sources=(), + outputs=monitors.Outputs(), initial=None) + settings = numerics.replace(geometry_ranges={}, stopping=None, checkpoint_steps=None, + duration=Duration(steps=1), absorber=absorber, memory_bound=None, + discretization='voxel') + try: + scene, _ = lower_problem(reduced, settings) + except ValidationError as exc: + if exc.code != 'numerics.junction': + raise + # The non-default junction policy's flush pair does not reach the plane. + scene, _ = lower_problem(reduced, settings.replace(junction='split')) + plan, _ = _planned(scene) + if plan.constitutive: + _refuse('a structure reaching the plane is coupled smoothed (constitutive) material, ' + 'whose off-diagonal cross coupling the mode solve does not model; cell-snapped ' + 'bodies and unturned smoothed Box faces, which lower to a diagonal medium, are ' + 'admitted.', 'mode.material') + if plan.grid != grid: + raise ValidationError('the mode window did not reproduce the run grid', + code='mode.convergence', path='solve_modes') + + coefficients = build(scene, plan, runtime('numpy')) + shape4 = (3, plan.nz, plan.ny, plan.nx) + arrays = {} + for name in ('ae', 'be', 'ah', 'bh'): + value = coefficients[name] + if not isinstance(value, (np.ndarray, float, int, np.floating)): + _refuse('a material coefficient on the plane is traced; the mode solve needs ' + 'concrete values', 'mode.traced', 'scene') + arrays[name] = np.broadcast_to(np.asarray(value, dtype=np.float64), shape4) + ratio = plan.dt / plan.dx + eps, mu = {}, {} + window = {u: ranges[u], v: ranges[v]} + for local, c in (('u', u), ('v', v), ('w', w)): + for name, store in (('be', eps), ('bh', mu)): + slices = [_component_slice(arrays[name], c, w, kk, {**window, w: (kk, kk + 1)}) + for kk in layers] + for other in slices[1:]: + # Relative 1e-12: a smoothed body's occupancy differs by round-off from layer to + # layer (measured 8e-17 on a Box along its own axis); a real change does not. + if not np.allclose(other, slices[0], rtol=UNIFORM, atol=0.0): + _refuse(f'the medium changes along {_AXES[w]} at the plane ' + f'({_AXES[w]} = {position:g}): a mode is a solution of a ' + f'cross-section that is uniform along its axis. Move the plane ' + f'into a uniform section.', 'mode.nonuniform', 'plane') + store[local] = ratio / slices[layers.index(k)] + for name in ('ae', 'ah'): + for kk in layers: + s = _component_slice(arrays[name], c, w, kk, {**window, w: (kk, kk + 1)}) + if not np.all(s == 1.0): + _refuse('the window holds a conductive or absorbing medium (a lossy ' + 'update coefficient); the mode solve admits lossless media only, ' + 'and a lossy or leaky mode is not a mode of this solver.', + 'mode.material', 'scene') + _refuse_dispersive(scene, grid, window, w, layers) + invariant = sum(1 for s in sides if s[0].kind == 'invariant') + L = problem.units.length_unit_meters + measure = L ** (2 - invariant) / (Z0 * grid.dx ** invariant) + return Window(w=w, u=u, v=v, k=k, ranges=ranges, sides=tuple(sides), eps=eps, mu=mu, + dx=grid.dx, dt=plan.dt, low=grid.low, measure=measure, length_unit=L) + + +def solve_window(window, wavelength, count): + """The ``count`` largest-index confined modes at ``wavelength``, in kernel units. + + Each is ``(q, beta, fields, residual, edge_ratio)``, the local fields normalized so that the + kernel's flux (:func:`wavebit.fdtd._post.plane_flux`: ``0.5 sum E_t x H_mean dx^2``, with + ``H_mean = cos(beta dx/2) H``) is 1 along ``+w``. + """ + dx = window.dx + omega = 2.0 * math.pi / wavelength + omega_d = 2.0 * math.sin(omega * window.dt / 2.0) / window.dt + q2s, fields, residuals = discrete_modes(window.eps, window.mu, window.sides, dx, omega_d, + count) + weights = np.full(window.eps['u'].shape, dx * dx) + modes = [] + for index, (q2, f, residual) in enumerate(zip(q2s, fields, residuals)): + if len(modes) == count: + break + if f is None or not (0 < q2 < (2.0 / dx) ** 2): + break + if residual > RESIDUAL: + _refuse(f'mode {index}: the eigen-residual {residual:.2g} exceeds {RESIDUAL:g}', + 'mode.convergence') + edge = _edge_ratio(f, window.sides) + if edge > CONFINEMENT: + break + q = math.sqrt(q2) + beta = (2.0 / dx) * math.asin(q * dx / 2.0) + cos = math.cos(beta * dx / 2.0) + flux = 0.5 * cross_product(f, f, weights) * cos + if not flux > 0: + _refuse(f'mode {index} carries its power against its phase velocity; the mode solve ' + f'admits forward modes only.', 'mode.convergence') + scale = 1.0 / math.sqrt(flux) + # Phase: the largest transverse E entry positive. + Et = np.concatenate([f['Eu'].real.ravel(), f['Ev'].real.ravel()]) + if Et[np.argmax(np.abs(Et))] < 0: + scale = -scale + modes.append((q, beta, {key: value * scale for key, value in f.items()}, residual, edge)) + if len(modes) < count: + _refuse(f'the mode solve found {len(modes)} confined mode(s) at wavelength ' + f'{wavelength:g}, fewer than count = {count}. A mode is admitted while its index ' + f'stays above the grid limit and, across an Open face or a plane span, its ' + f'transverse E on the outermost cells stays within {CONFINEMENT:g} of its peak; ' + f'a mode beyond that is leaky, near cutoff or a mode of the truncated box. Ask ' + f'for fewer modes or widen the cross-section.', 'mode.count', 'count') + return modes + + +def _edge_ratio(f, sides): + """Largest |E_t| on the outermost cells of each truncating edge over the peak |E_t|.""" + Et = np.maximum(np.abs(f['Eu']), np.abs(f['Ev'])) + peak = float(np.max(Et)) + if peak == 0: + return math.inf + worst = 0.0 + for a, (low, high) in enumerate(sides): + if low.truncates: + worst = max(worst, float(np.max(Et[0] if a == 0 else Et[:, 0]))) + if high.truncates: + worst = max(worst, float(np.max(Et[-1] if a == 0 else Et[:, -1]))) + return worst / peak + + +def _refuse_dispersive(scene, grid, window, w, layers): + """A dispersive body (or background) reaching the window is refused. + + A pole's memory is a separate term (``src/wavebit/fdtd/_kernel/poles.py``) and leaves the update + coefficients looking lossless, so it is found by material here; a conductivity is not a + term and shows in ``ae``, which :func:`_solve` reads instead. + """ + from wavebit.fdtd._legacy import Dispersive + from wavebit.materials._time_domain import as_material + background = as_material(getattr(scene, 'background', None), 'Scene(background=)') + if isinstance(background, Dispersive): + _refuse('the background is dispersive; solve_modes admits non-dispersive media only.', + 'mode.material', 'scene.background') + box = dict(window) + box[w] = (min(layers), max(layers) + 1) + for index, structure in enumerate(scene.structures): + material = as_material(structure.material, 'Structure(material=)') + if not isinstance(material, Dispersive): + continue + support = structure.shape.support(grid) + if all(support[a][0] < box[a][1] and support[a][1] > box[a][0] for a in range(3)): + _refuse(f'structures[{index}] is dispersive and reaches the plane; solve_modes ' + f'admits non-dispersive media only, and a dispersive mode is not a mode of ' + f'this solver.', + 'mode.material', 'scene.structures') + + +def _to_backend(array, backend): + if backend == 'numpy': + return array + if backend == 'jax': + import jax.numpy as jnp + return jnp.asarray(array) + import torch + return torch.from_numpy(np.ascontiguousarray(array)) + + +def _package(problem, window, lam, modes, backend): + from ._kernel.grid import OFFSETS + u, v, w, ranges, sides = window.u, window.v, window.w, window.ranges, window.sides + L = window.length_unit + omega = 2.0 * math.pi / lam + names = {0: 'x', 1: 'y', 2: 'z'} + local = {'u': u, 'v': v, 'w': w} + # Array axes in storage order (z, y, x) with w dropped. + stored = tuple(a for a in (2, 1, 0) if a != w) + axes = tuple(names[a] for a in stored) + coordinates = {} + for field_name in ('E', 'H'): + for c in range(3): + off = OFFSETS[field_name][c] + coordinates[field_name + names[c]] = { + names[a]: window.low[a] + (np.arange(*ranges[a]) + 0.5 * off[a]) * window.dx + for a in (u, v)} + out = [] + for index, (q, beta, f, residual, edge) in enumerate(modes): + E, H = {}, {} + for name, c in local.items(): + for field_name, store, scale in (('E', E, 1.0), ('H', H, 1.0 / Z0)): + arr = f[field_name + name] # (Nu, Nv) + data = arr if stored == (u, v) else arr.T # to the storage order + store[field_name + names[c]] = _to_backend(np.ascontiguousarray(data * scale), + backend) + Eu, Ev = np.abs(f['Eu']) ** 2, np.abs(f['Ev']) ** 2 + total = float(np.sum(Eu) + np.sum(Ev)) + polarization = {names[u]: float(np.sum(Eu)) / total, names[v]: float(np.sum(Ev)) / total} + out.append(WaveguideMode( + index=index, effective_index=beta / omega, beta_per_meter=beta / L, + E=E, H=H, polarization=polarization, power_watts=1.0, + metadata={'q': q, 'residual': residual, 'edge_ratio': edge})) + invariant = tuple(names[a] for a, s in zip((u, v), sides) if s[0].kind == 'invariant') + omega_d = 2.0 * math.sin(omega * window.dt / 2.0) / window.dt + return WaveguideModes( + modes=tuple(out), axis=names[w], position=window.low[w] + window.k * window.dx, + wavelength=lam, axes=axes, coordinates=coordinates, + metadata={'spacing': window.dx, 'time_step': window.dt, 'omega_discrete': omega_d, + 'node_index': window.k, 'window': {names[a]: ranges[a] for a in (u, v)}, + 'invariant_axes': invariant, + 'power_units': 'W' if not invariant else ('W/m' if len(invariant) == 1 else 'W/m^2'), + 'sides': {names[a]: (s[0].kind, s[1].kind) for a, s in zip((u, v), sides)}}) + + +# --------------------------------------------------------------------------------------------- +# ModeMonitor +# --------------------------------------------------------------------------------------------- + +def _local(acc, layout, window, label): + """The ``(nf, N_u, N_v)`` window part of one plane accumulator, in local ``(u, v)`` order. + + ``acc`` is ``(nf, nz', ny', nx')``. A finite span reads one node more than the window has + cells (its high wall), which a mode is zero on; it is dropped. + """ + import wavebit.fdtd._post as _post + w, u, v = window.w, window.u, window.v + value = _post._mean_planes(acc, w) if label.startswith('H') else acc + dims = [0] + [3 - a for a in (u, v, w)] # (nf, u, v, w) from (nf, z, y, x) + value = value.permute(*dims) if hasattr(value, 'permute') else _xp(value).transpose(value, dims) + Nu = window.ranges[u][1] - window.ranges[u][0] + Nv = window.ranges[v][1] - window.ranges[v][0] + return value[:, :Nu, :Nv, 0] + + +def _xp(value): + from wavebit._common.backend import array_namespace + return array_namespace(value) + + +def mode_spectrum(problem, numerics, monitor, value, plan, rt, spectral_factor, units_label, + length_units): + """The :class:`ModeSpectrum` of public ``monitor`` from its engine flux plane ``value``.""" + from wavebit.units import C0 + plane = window_plane(problem, numerics, monitor.plane) + window = prepare_window(problem, numerics, plane, monitor.wavelengths[0]) + if abs(window.dt - plan.dt) > 1e-14 * plan.dt or abs(window.dx - plan.dx) > 1e-14 * plan.dx: + raise ValidationError('the mode window did not reproduce the run time step', + code='mode.convergence', path='outputs.' + monitor.name) + acc, layout = value.accumulators, value.layout + if layout.P != window.w or layout.k != window.k: + raise ValidationError('the mode window and the flux plane disagree', + code='mode.convergence', path='outputs.' + monitor.name) + fields = {label: _local(acc[label], layout, window, label) for label in ('Eu', 'Ev', 'Hu', 'Hv')} + xp = rt.xp + dA = window.dx * window.dx + root = math.sqrt(spectral_factor) + forward, backward, indices, solved = [], [], [], [] + for i, lam in enumerate(monitor.wavelengths): + modes = solve_window(window, float(lam), monitor.modes) + a_row, b_row, n_row = [], [], [] + for q, beta, f, residual, edge in modes: + cos = math.cos(beta * window.dx / 2.0) + N = 2.0 / cos # 0.5 * N * cos = 1 (unit flux) + hv = rt.array(f['Hv'].real * dA, 'complex128') + hu = rt.array(f['Hu'].real * dA, 'complex128') + eu = rt.array(f['Eu'].real * dA, 'complex128') + ev = rt.array(f['Ev'].real * dA, 'complex128') + X = xp.sum(fields['Eu'][i] * hv - fields['Ev'][i] * hu) + Y = xp.sum(eu * fields['Hv'][i] - ev * fields['Hu'][i]) + a_row.append(0.5 * (X / N + Y / (N * cos)) * root) + b_row.append(0.5 * (X / N - Y / (N * cos)) * root) + n_row.append(beta / (2.0 * math.pi / float(lam))) + forward.append(xp.stack(a_row)) + backward.append(xp.stack(b_row)) + indices.append(n_row) + scale = 1.0 / math.sqrt(window.measure) # to the 1 W of solve_modes + solved.append(_package(problem, window, float(lam), + [(q, beta, {k: v * scale for k, v in f.items()}, r, e) + for q, beta, f, r, e in modes], 'numpy')) + forward, backward = xp.stack(forward), xp.stack(backward) + names = {0: 'x', 1: 'y', 2: 'z'} + launched = {} + if monitor.source is not None: + from ._mode_source import mode_sheet, injected_amplitudes + from ._sources import ModeSource + source = next(s for s in problem.sources + if isinstance(s, ModeSource) and s.name == monitor.source) + sheet = mode_sheet(source, problem, numerics, problem.units) + injected, leakage = injected_amplitudes(sheet, plan, rt, + [float(lam) for lam in monitor.wavelengths]) + launched = dict(injected=injected * root, leakage=leakage * root, + injected_energy=xp.abs(injected * root) ** 2, + leakage_energy=xp.abs(leakage * root) ** 2) + return ModeSpectrum( + wavelengths=tuple(monitor.wavelengths), + frequencies_hz=np.asarray([C0 / (w * problem.units.length_unit_meters) + for w in monitor.wavelengths]), + effective_index=np.asarray(indices), forward=forward, backward=backward, + forward_energy=xp.abs(forward) ** 2, backward_energy=xp.abs(backward) ** 2, + units=units_label, modes=tuple(solved), + metadata={'axis': names[window.w], 'position': window.low[window.w] + window.k * window.dx, + 'forward_direction': '+' + names[window.w], 'amplitude_units': + 'sqrt(' + units_label + ')', 'length_units': length_units, + 'pairing': 'node-plane E with the mean of the two H half planes, as FluxMonitor', + 'source': monitor.source}, **launched) diff --git a/src/wavebit/fdtd/_monitors.py b/src/wavebit/fdtd/_monitors.py index 3dad835d..0ed26f0d 100644 --- a/src/wavebit/fdtd/_monitors.py +++ b/src/wavebit/fdtd/_monitors.py @@ -4,14 +4,17 @@ monitors keep the raw pulse Fourier transform; see the reference for each result's units and normalization. """ +import numpy as np + from wavebit._common.backend import eager_under_torch_compile +from wavebit.errors import ValidationError from wavebit._common.declarations import order_pair, spec from . import _legacy_monitors from ._spelling import Spoken from ._structure import tuples __all__ = ['TimeSeriesMonitor', 'FieldMonitor', 'FluxMonitor', 'DiffractionMonitor', - 'FarFieldMonitor', 'Outputs'] + 'FarFieldMonitor', 'ModeMonitor', 'Outputs'] @eager_under_torch_compile @@ -133,6 +136,43 @@ def __post_init__(self): object.__setattr__(self, 'wavelengths', tuples(self.wavelengths)) +@spec(positional=('name',)) +class ModeMonitor: + """A monitor that projects the fields on a plane onto the waveguide modes there, at chosen wavelengths. + + ``fdtd.ModeMonitor('t', plane=fdtd.Plane(z=1.0), wavelengths=(1.55,), modes=2)`` is read back + as ``result['t']``, an :class:`fdtd.ModeSpectrum`: for each wavelength and each of the + ``modes`` largest-index modes :func:`solve_modes` finds on the plane, the amplitude and the + two-sided pulse spectral energy (J/Hz, as a :class:`FluxMonitor` reports it) travelling + toward the plane's ``+`` normal and toward its ``-`` normal. The plane is a mode plane: + across a uniform section of the guide, with the same transverse window and the same + refusals as :func:`solve_modes`. ``wavelengths`` are vacuum wavelengths in ``Problem.units``. + + ``source=`` names a :class:`ModeSource` of the problem: the spectrum then also carries what + that source launched its way (``injected``) and leaked the other way (``leakage``), at each + wavelength, as amplitudes of the source's own mode on the source's plane, in this monitor's + units. Divide ``forward_energy`` by ``injected_energy`` for a transmission. + """ + name: str + plane: object + wavelengths: tuple + modes: int = 1 + source: object = None + + def __post_init__(self): + object.__setattr__(self, 'wavelengths', tuples(self.wavelengths)) + if (isinstance(self.modes, bool) or not isinstance(self.modes, (int, np.integer)) + or self.modes < 1): + raise ValidationError('ModeMonitor(modes=...) is the number of modes, a positive ' + 'integer', code='monitor.modes', + path=f'outputs.{self.name}.modes', actual=self.modes) + object.__setattr__(self, 'modes', int(self.modes)) + if self.source is not None and not (isinstance(self.source, str) and self.source): + raise ValidationError('ModeMonitor(source=...) is the name of a ModeSource', + code='monitor.source', path=f'outputs.{self.name}.source', + actual=self.source) + + @spec class Outputs: """What a run returns: named monitors, and optionally the final E and H fields. diff --git a/src/wavebit/fdtd/_prepare/checks.py b/src/wavebit/fdtd/_prepare/checks.py index 7456288e..2c672335 100644 --- a/src/wavebit/fdtd/_prepare/checks.py +++ b/src/wavebit/fdtd/_prepare/checks.py @@ -1848,14 +1848,16 @@ def _public_layer_remedy(name): """ return (f'The body ends before the Open face; what reaches the layer is its smoothed edge, ' f'the cells over which its surface is averaged. Move its end farther from the face, ' - f'until fdtd.check no longer refuses it. Only a uniform medium can run through the ' - f'layer: a fixed, unturned fdtd.Box that reaches the face, whose fdtd.Structure ' - f'declares continuation=({name!r},), and that reaches more than one cell past both ' - f'transverse faces, which must not be Open (a substrate), or an fdtd.SnappedSlab({name[0]}=...) that reaches the face when no face ' - f'on another axis is Open. Nothing else can: not an fdtd.SnappedBox, a turned ' - f'fdtd.Box, an fdtd.Cylinder or fdtd.Prism, nor any body narrower than the ' - f'cross-section, such as a waveguide core. Running those into an absorbing layer is ' - f'not supported yet, and no date is set.') + f'until fdtd.check no longer refuses it. Only a medium uniform along {name[0]} can run ' + f'through the layer: a fixed, unturned fdtd.Box that reaches the face, whose ' + f'fdtd.Structure declares continuation=({name!r},), and that reaches more than one ' + f'cell past both transverse faces, which must not be Open (a substrate); an ' + f'fdtd.SnappedSlab({name[0]}=...) that reaches the face when no face on another axis is ' + f'Open; or a cell-snapped waveguide core -- an fdtd.SnappedBox that reaches the face ' + f'and declares continuation=({name!r}, ...) along {name[0]}, or an fdtd.SnappedSlab ' + f'naming the faces it runs through. Nothing else can: not a turned fdtd.Box, an ' + f'fdtd.Cylinder or fdtd.Prism, nor a smoothed body narrower than the cross-section. ' + f'Running those into an absorbing layer is not supported yet, and no date is set.') def _background(scene): diff --git a/src/wavebit/fdtd/_prepare/monitors.py b/src/wavebit/fdtd/_prepare/monitors.py index dc2cea99..c32db88e 100644 --- a/src/wavebit/fdtd/_prepare/monitors.py +++ b/src/wavebit/fdtd/_prepare/monitors.py @@ -485,6 +485,17 @@ def _clear_of_sources(where, scene, grid, lo, hi, closed, tfsf=True): f'{low_side} field, at {AXES[P]} = {position + grid.dx:g} or above the ' f'{high_side} field. The power the sheet injects is ' f'res.sources[{src.name!r}].intensity().') + elif type(src).__name__ == 'ModeSheet': + P, k = src.axis, src.k + # The sheet's E plane and the H half plane its corrections write, a cell each way. + if lo[P] - 1 <= k <= hi[P] + 1: + from wavebit.errors import ValidationError + raise ValidationError( + f'{where}: the {shape} (nodes {_nodes_text(lo, hi)}) is within a cell of the ' + f'sheet of {src.kind}({src.name!r}) at node {k} along {AXES[P]}, where the ' + f'field jumps from the total field on one side to the scattered field on the ' + f'other. Move it at least two cells off the sheet.', + code='monitor.source_sheet', path='outputs') elif isinstance(src, TFSF) and tfsf: tlo = [_node(grid, a, src.box.low[a]) for a in range(3)] thi = [_node(grid, a, src.box.high[a]) for a in range(3)] diff --git a/src/wavebit/fdtd/_prepare/sources.py b/src/wavebit/fdtd/_prepare/sources.py index 5ee2c9df..ac25208f 100644 --- a/src/wavebit/fdtd/_prepare/sources.py +++ b/src/wavebit/fdtd/_prepare/sources.py @@ -59,6 +59,7 @@ from wavebit.fdtd._legacy_sources import PlaneWave from wavebit.fdtd._legacy_sources import TFSF from wavebit.fdtd._legacy_sources import _is_complex +from wavebit.fdtd._mode_source import ModeSheet from wavebit.fdtd._kernel.grid import OFFSETS from wavebit.fdtd._kernel.masks import Slab from wavebit.fdtd._kernel.sources import ColumnTerm @@ -168,6 +169,8 @@ def source_terms(scene, plan): term = _plane_wave_term(scene, plan, src, row) elif isinstance(src, TFSF): term = _tfsf_term(scene, plan, src, row) + elif isinstance(src, ModeSheet): + term = _mode_sheet_term(scene, plan, src, row) else: # Scene already refused anything else raise TypeError(public_name(src)) terms.append(term) @@ -175,6 +178,30 @@ def source_terms(scene, plan): return tuple(terms) +def _mode_sheet_term(scene, plan, src, row): + """The kernel term of a mode or beam sheet (``src/wavebit/fdtd/_kernel/mode_sheet.py``).""" + from wavebit.fdtd._kernel.mode_sheet import ModeSheetTerm + w, u, v = src.axis, (src.axis + 1) % 3, (src.axis + 2) % 3 + (u0, u1), (v0, v1) = src.window + k = src.k + h_plane = (k - 1, k) if src.sign > 0 else (k, k + 1) + + def slab(c, along_w): + ranges = {u: (u0, u1), v: (v0, v1), w: along_w} + return Slab(c=c, z=ranges[2], y=ranges[1], x=ranges[0]) + s = src.sign + # Each incident component is a sum of patterns, each on one of the sheet's rows. + base = (('H', slab(u, h_plane), 'Ev', float(s)), ('H', slab(v, h_plane), 'Eu', float(-s)), + ('E', slab(u, (k, k + 1)), 'Hv', float(s)), ('E', slab(v, (k, k + 1)), 'Hu', float(-s))) + corrections = tuple((slot, sl, label, sign, r) for slot, sl, component, sign in base + for label, r in src.components[component]) + for _, sl, _, _ in base: + for a, (lo, hi) in enumerate((sl.x, sl.y, sl.z)): + _check_indices(f'{src.kind}({src.name!r})', a, lo, hi - 1, scene, plan) + return ModeSheetTerm(name=src.name, row=row, corrections=corrections, + row_offsets=src.row_offsets, rows=src.shifts) + + def dipole_indices(src, grid): """``((i0, i1), ...)`` per axis: the first and last index (inclusive) the stamp writes. @@ -832,6 +859,8 @@ def lower_sources(scene, plan, rt): if isinstance(term, DipoleTerm): center = tuple(rt.array(c, 'float64') for c in src.center) entry['stamp'] = stamp(term, center, plan.grid, rt) + elif isinstance(src, ModeSheet): + entry['pattern'] = _sheet_patterns(src, rt) else: entry['column'] = lower(column_scene(scene, src, plan), term.sub, rt) if term.driver is not None: @@ -843,6 +872,16 @@ def lower_sources(scene, plan, rt): return out +def _sheet_patterns(src, rt): + """A mode sheet's four incident patterns, each shaped like its correction slabs.""" + w, u, v = src.axis, (src.axis + 1) % 3, (src.axis + 2) % 3 + position = {u: 0, v: 1, w: 2} + order = [position[2], position[1], position[0]] # local (u, v, w) -> stored (z, y, x) + return {label: rt.array(np.transpose(np.asarray(array, dtype=np.float64)[:, :, None], order), + 'float64') + for label, array in src.patterns.items()} + + def _pattern(scene, plan, P, rt): """``exp(i k_par . r)`` per cell of a plane across ``P``, relative to cell ``(0, 0)``.""" grid = plan.grid diff --git a/src/wavebit/fdtd/_result.py b/src/wavebit/fdtd/_result.py index c1b3aa26..9418217c 100644 --- a/src/wavebit/fdtd/_result.py +++ b/src/wavebit/fdtd/_result.py @@ -11,6 +11,7 @@ from wavebit.observations.fields import _component_units from wavebit.units import C0, Z0 from . import _monitors as monitors, _boundaries as boundaries +from ._sources import ModeSource, GaussianBeam #: SpectralFlux fields that were renamed (fieldalgo/wavebit#57), and their replacements. @@ -193,6 +194,13 @@ def adapt_result(problem,numerics,execution,raw,metadata): spectral_energy=value.flux*spectral_factor,units=flux_units, faces=None if value.faces is None else {f:v*spectral_factor for f,v in value.faces.items()}, metadata=dict(transform,orientation='positive plane normal or outward closed surface',invariant_axes=invariant_axes)) + elif isinstance(m,monitors.ModeMonitor): + from ._modes import mode_spectrum + from ._runtime import runtime + rt=runtime(execution.backend,execution.device) + spectrum=mode_spectrum(problem,numerics,m,value,raw.plan,rt,spectral_factor,flux_units,units.length) + measured.extend((v,None) for v in (spectrum.forward,spectrum.backward)) + outputs[name]=spectrum elif isinstance(m,monitors.FarFieldMonitor): outputs[name]=FarFieldSpectrum(_raw=value,_factor=spectral_factor,wavelengths=value.wavelengths, units=flux_units+'/sr') @@ -286,6 +294,7 @@ def adapt_result(problem,numerics,execution,raw,metadata): provenance=dict(solver='fdtd',execution=execution,numerics=numerics,units=units, coordinate_frame=_COORDINATE_FRAME,time_convention='exp(-i*omega*t)', actual_steps=raw.plan.steps,time_step_seconds=raw.plan.dt*ts,source_normalization={s.name:('current moment in A*m' if s.kind=='electric' else 'equivalent magnetic current moment in V*m') - if hasattr(s,'current_moment') else 'electric field amplitude in V/m' for s in problem.sources},**metadata) + if hasattr(s,'current_moment') else ('beam or mode amplitude in sqrt(W) (per metre of each invariant axis)' + if isinstance(s,(ModeSource,GaussianBeam)) else 'electric field amplitude in V/m') for s in problem.sources},**metadata) return Result(outputs=outputs,validity=validity,diagnostics=diagnostics,provenance=provenance, source_references=source_references,final_fields=final) diff --git a/src/wavebit/fdtd/_scene.py b/src/wavebit/fdtd/_scene.py index 89d37c2e..0e98679c 100644 --- a/src/wavebit/fdtd/_scene.py +++ b/src/wavebit/fdtd/_scene.py @@ -272,11 +272,13 @@ def __post_init__(self): f'and must be unique; repeated: {dup}.') object.__setattr__(self, 'monitors', monitors) from wavebit.fdtd._legacy_sources import SOURCE_TYPES - if isinstance(self.sources, SOURCE_TYPES): + from wavebit.fdtd._mode_source import ModeSheet + if isinstance(self.sources, SOURCE_TYPES + (ModeSheet,)): object.__setattr__(self, 'sources', (self.sources,)) sources = tuple(self.sources) for src in sources: - if not isinstance(src, SOURCE_TYPES): + # A ModeSheet is the public ModeSource's lowering, never an engine declaration. + if not isinstance(src, SOURCE_TYPES + (ModeSheet,)): raise TypeError(f'Scene(sources=...): every entry is an fdtd.CurrentDipole, ' f'fd.PlaneWave or fd.TFSF; got {public_name(src)}.') names = [src.name for src in sources] diff --git a/src/wavebit/fdtd/_sources.py b/src/wavebit/fdtd/_sources.py index ea7847d6..e836be87 100644 --- a/src/wavebit/fdtd/_sources.py +++ b/src/wavebit/fdtd/_sources.py @@ -2,7 +2,8 @@ from wavebit._common.declarations import spec from ._structure import tuples -__all__ = ['GaussianPulse', 'PlaneWave', 'CurrentDipole', 'TFSF', 'InitialField'] +__all__ = ['GaussianPulse', 'PlaneWave', 'CurrentDipole', 'TFSF', 'ModeSource', 'GaussianBeam', + 'InitialField'] @spec @@ -99,3 +100,84 @@ class TFSF: amplitude: object = 1. phase: object = 0. delay_seconds: object = 0. + + +@spec(positional=('name',), renamed={'delay': 'delay_seconds'}) +class ModeSource: + """A waveguide mode launched one way from a plane across the guide. + + ``fdtd.ModeSource('src', plane=fdtd.Plane(z=-2.), direction='+z', mode=0, + pulse=fdtd.GaussianPulse(wavelength=1.55, frequency_sigma_hz=2e13))``. ``mode`` indexes the + modes :func:`solve_modes` finds on ``plane`` at the pulse's centre wavelength (0 is the + largest index); the plane, its window and its refusals are the solver's. ``direction`` is + ``'+'`` or ``'-'`` the plane's normal axis. ``amplitude`` is in square-root watts (per metre + of each invariant axis): a continuous wave of this amplitude carries ``amplitude**2`` watts. + ``phase`` is radians and ``delay_seconds`` delays the pulse. + + The plane is a one-sided total-field/scattered-field sheet whose incident field is the mode: + at the centre wavelength it launches exactly that mode, exactly one way; across the band it + launches what a :class:`ModeMonitor` with ``source=`` this source's name reports as + ``injected``, and leaks what it reports as ``leakage``, both computed exactly for the grid. + """ + name: str + plane: object + pulse: GaussianPulse + direction: str = '+z' + mode: int = 0 + amplitude: object = 1. + phase: object = 0. + delay_seconds: object = 0. + + def __post_init__(self): + import numpy as np + from wavebit.errors import ValidationError + if (isinstance(self.mode, bool) or not isinstance(self.mode, (int, np.integer)) + or self.mode < 0): + raise ValidationError('ModeSource(mode=...) is a mode index, an integer >= 0', + code='source.mode', path=f'sources.{self.name}.mode', + actual=self.mode) + object.__setattr__(self, 'mode', int(self.mode)) + + +@spec(positional=('name',), renamed={'delay': 'delay_seconds'}) +class GaussianBeam: + """A focused Gaussian beam launched one way from a plane. + + ``fdtd.GaussianBeam('b', plane=fdtd.Plane(z=-4.), direction='+z', waist=1.5, + focus=(0., 0., 0.), polarization='y', pulse=fdtd.GaussianPulse(wavelength=1., ...))``. On the + focal plane the E component along ``polarization`` (a transverse axis) is + ``exp(-r**2 / waist**2)`` about ``focus`` (``waist`` is the 1/e amplitude radius, as for + ``rcwa.GaussianBeam``); the other transverse component is zero there. The beam is the + propagating part of that plane's angular spectrum, built from the grid's own plane waves in + the homogeneous medium that fills the source plane, so ``focus`` is where it would focus if + that medium filled space. ``amplitude`` is in square-root watts (per metre of each invariant + axis): a continuous wave of this amplitude carries ``amplitude**2`` watts. ``phase`` is in + radians and ``delay_seconds`` delays the pulse. + + The plane is a one-sided total-field/scattered-field sheet. At the pulse's centre wavelength + it launches exactly the beam, one way; elsewhere in the band a little goes backward. The + faces across the plane are ``Open`` (or ``InvariantAxis``), and the cross-section must hold + the beam. ``waist`` and ``focus`` are concrete; ``amplitude``, ``phase`` and + ``delay_seconds`` may be traced. + + The beam is exact for the grid, not the continuum: the grid's plane waves travel at a + direction-dependent phase velocity, so its Gouy phase differs a little from the continuum + beam's. In the validation record (``docs/validation/fdtd/fd-gaussian-beam.md``, vacuum, + waist 1.5 wavelengths) that difference is at most 2.2e-3 rad within 3 wavelengths of the + focus at 20 cells per wavelength, and falls at second order. Converge the spacing for any + result that depends on the phase along the beam. + """ + name: str + plane: object + pulse: GaussianPulse + waist: object + focus: tuple = (0., 0., 0.) + polarization: str = 'x' + direction: str = '+z' + amplitude: object = 1. + phase: object = 0. + delay_seconds: object = 0. + + def __post_init__(self): + if isinstance(self.focus, list): + object.__setattr__(self, 'focus', tuple(self.focus)) diff --git a/src/wavebit/fdtd/_spelling.py b/src/wavebit/fdtd/_spelling.py index 511350b6..86fb8230 100644 --- a/src/wavebit/fdtd/_spelling.py +++ b/src/wavebit/fdtd/_spelling.py @@ -51,10 +51,7 @@ #: messages spell those publicly where they are raised. _SHARED = ('Plane', 'Box', 'Sphere', 'Cylinder', 'Prism', 'PlaneWave', 'TFSF', 'Bloch', 'Boundaries', 'Periodic', 'PEC', 'PMC', 'InitialField', 'Domain', 'Shape', 'Structure', - 'PML') - -#: Stage-7 placeholders have never implemented a calculation. -_UNIMPLEMENTED = ('ModeSource', 'ModeMonitor', 'GaussianBeam') + 'PML', 'ModeMonitor', 'ModeSource', 'GaussianBeam') PHRASES = ( # A static refusal ends with the engine's leaves; the public module has its own. @@ -121,7 +118,6 @@ # CSG is named as a spelling to come; it is in neither namespace yet. (_FD + r'(Union|Intersection|Difference)\b', r'\1'), (_FD + r'(' + '|'.join(_SHARED) + r')\b', r'fdtd.\1'), - (_FD + r'(' + '|'.join(_UNIMPLEMENTED) + r')\b', r'\1 (unimplemented)'), # The engine's calls without the prefix. A keyword the public spelling takes at the same # value keeps the value; one the engine holds in its own units (an end time, a bandwidth) # is spelled without the number; the compat namespace is retired, so every call is named diff --git a/src/wavebit/jobs.py b/src/wavebit/jobs.py index ecf38297..808957e9 100644 --- a/src/wavebit/jobs.py +++ b/src/wavebit/jobs.py @@ -32,9 +32,11 @@ 'wavebit.fdtd': ('Problem', 'Scene', 'Domain', 'Structure', 'SnappedSlab', 'Box', 'Sphere', 'Cylinder', 'Prism', 'Plane', 'SnappedBox', 'Boundaries', 'Periodic', 'Bloch', 'Open', 'PEC', 'PMC', 'MirrorSymmetry', 'InvariantAxis', 'PlaneWave', - 'CurrentDipole', 'TFSF', 'InitialField', 'GaussianPulse', 'Outputs', + 'CurrentDipole', 'TFSF', 'ModeSource', 'GaussianBeam', 'InitialField', + 'GaussianPulse', + 'Outputs', 'TimeSeriesMonitor', 'FieldMonitor', 'FluxMonitor', 'DiffractionMonitor', - 'FarFieldMonitor', 'Numerics', 'UniformGrid', 'Duration', 'PML', + 'FarFieldMonitor', 'ModeMonitor', 'Numerics', 'UniformGrid', 'Duration', 'PML', 'ConductiveAbsorber', 'DecayStop'), 'wavebit': ('Execution',), 'wavebit.fdtd.native': ('Problem', 'Medium', 'Oscillator', 'Current', 'InitialField', diff --git a/tests/api/test_public_api.py b/tests/api/test_public_api.py index f2411d47..0ee7fda3 100644 --- a/tests/api/test_public_api.py +++ b/tests/api/test_public_api.py @@ -35,12 +35,15 @@ 'refractive_index_from_eps', 'run'), 'wavebit.fdtd': ('Bloch', 'Boundaries', 'Box', 'ConductiveAbsorber', 'CurrentDipole', 'Cylinder', 'DecayStop', 'DiffractionMonitor', 'Domain', 'Duration', 'FarFieldMonitor', - 'FarFieldSpectrum', 'FieldMonitor', 'FluxMonitor', 'GaussianPulse', - 'InitialField', 'InvariantAxis', 'MirrorSymmetry', 'Numerics', 'Open', 'Outputs', + 'FarFieldSpectrum', 'FieldMonitor', 'FluxMonitor', 'GaussianBeam', 'GaussianPulse', + 'InitialField', 'InvariantAxis', 'MirrorSymmetry', 'ModeMonitor', 'ModeSource', + 'ModeSpectrum', + 'Numerics', 'Open', 'Outputs', 'PEC', 'PMC', 'PML', 'Periodic', 'Plane', 'PlaneWave', 'Prepared', 'Prism', 'Problem', 'Result', 'Scene', 'Shape', 'SnappedBox', 'SnappedSlab', 'SpectralFlux', 'Sphere', 'Structure', 'TFSF', 'TimeSeriesMonitor', 'UniformGrid', - 'check', 'native', 'periodic', 'prepare', 'run'), + 'WaveguideMode', 'WaveguideModes', 'check', 'native', 'periodic', 'prepare', + 'run', 'solve_modes'), 'wavebit.fdtd.diagnostics': ('AbsorberProximityWarning', 'BoundedVoxelWarning', 'CoarseMeshWarning', 'GeometryClippedWarning', 'GrowingFieldWarning', 'PMLStabilityWarning', 'RadiationApproximationWarning', diff --git a/tests/api/test_unified_jobs.py b/tests/api/test_unified_jobs.py index cf8d6380..f9f73161 100644 --- a/tests/api/test_unified_jobs.py +++ b/tests/api/test_unified_jobs.py @@ -189,7 +189,8 @@ def test_every_public_fdtd_declaration_has_a_tag_and_round_trips(): import dataclasses from wavebit import fdtd, jobs registry = jobs._registry() - results = {'Prepared', 'Result', 'SpectralFlux', 'FarFieldSpectrum'} + results = {'Prepared', 'Result', 'SpectralFlux', 'FarFieldSpectrum', 'WaveguideMode', + 'WaveguideModes', 'ModeSpectrum'} for name in fdtd.__all__: obj = getattr(fdtd, name) if isinstance(obj, type) and dataclasses.is_dataclass(obj) and name not in results: diff --git a/tests/fdtd/test_fd_gaussian_beam.py b/tests/fdtd/test_fd_gaussian_beam.py new file mode 100644 index 00000000..d8ab96cb --- /dev/null +++ b/tests/fdtd/test_fd_gaussian_beam.py @@ -0,0 +1,504 @@ +"""A focused Gaussian beam. + +Criteria G1-G8 of ``docs/validation/fdtd/fd-gaussian-beam.md``, registered before the beam was +first run; every limit here is quoted from there. The oracles are continuum angular spectra +computed here, independently of the grid. +""" +import math +import types +import warnings + +import numpy as np +import pytest + +from wavebit import Material, Units, fdtd +from wavebit.errors import WavebitError + +LAMBDAS = (0.95, 1.0, 1.05) +CENTRE = 1 +SIGMA_HZ = 2e13 +C0 = 299792458.0 +L = 1e-6 # Units(length='um') +PERIODS = 100 + + +def _quiet(): + warnings.simplefilter('ignore') + + +def _pulse(wavelength=1.0): + return fdtd.GaussianPulse(wavelength=wavelength, frequency_sigma_hz=SIGMA_HZ, cutoff=1e-12) + + +def _setup(dx, *, polarization='y', waist=1.5, focus=(0., 0., 0.), z_sheet=-4., half=False, + amplitude=1., phase=0., delay=0., scatterer=None, monitors=None, beam_kw=None, + boundaries=None, pulse=None, duration=None, periods=PERIODS): + """Setup B (two-dimensional), or G3's half-space when ``half``.""" + structures = () + if half: + structures += (fdtd.Structure('glass', shape=fdtd.SnappedSlab(z=(-1., 5.)), + material=Material(epsilon=2.25), + continuation=('x-', 'x+')),) + if scatterer is not None: + structures += (fdtd.Structure('block', shape=fdtd.SnappedBox(size=(.4, 1., .4), + center=(2., 0., 0.)), + material=Material(epsilon=scatterer)),) + beam = fdtd.GaussianBeam('b', plane=fdtd.Plane(z=z_sheet), pulse=pulse or _pulse(), + waist=waist, focus=focus, polarization=polarization, + direction='+z', amplitude=amplitude, phase=phase, + delay_seconds=delay, **(beam_kw or {})) + component = 'E' + polarization + if monitors is None: + span = (-5.9, 5.9) + monitors = (fdtd.FieldMonitor('focal', wavelengths=LAMBDAS, plane=fdtd.Plane(z=0., x=span), + components=(component,)), + fdtd.FieldMonitor('axis', wavelengths=LAMBDAS, plane=fdtd.Plane(x=0.), + components=('Ey',)), + fdtd.FluxMonitor('flux', plane=fdtd.Plane(z=3., x=span), wavelengths=LAMBDAS), + fdtd.FluxMonitor('back', plane=fdtd.Plane(z=-4.5, x=span), wavelengths=LAMBDAS)) + problem = fdtd.Problem( + units=Units(length='um'), + scene=fdtd.Scene(domain=fdtd.Domain(size=(12., dx, 10.)), + boundaries=boundaries or fdtd.Boundaries(x=fdtd.Open(), y=fdtd.InvariantAxis(), + z=fdtd.Open()), + structures=structures), + sources=(beam,), outputs=fdtd.Outputs(monitors=monitors)) + numerics = fdtd.Numerics(grid=fdtd.UniformGrid(spacing=dx), + duration=duration or fdtd.Duration(periods=periods, source='b'), + absorber=fdtd.PML(cells=24, wavelength=1.)) + return problem, numerics + + +def _run(problem, numerics, backend='numpy'): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always') + result = fdtd.run(problem, numerics=numerics, backend=backend) + names = [type(w.message).__name__ for w in caught] + assert 'UnsettledFieldWarning' not in names and 'GrowingFieldWarning' not in names, names + return result + + +def _np(value): + if hasattr(value, 'detach'): + value = value.detach() + return np.asarray(value) + + +class _Runs(dict): + """Setup B per (spacing, polarization), run once on first use.""" + + def __missing__(self, key): + dx, polarization = key + _quiet() + self[key] = _run(*_setup(dx, polarization=polarization)) + return self[key] + + +@pytest.fixture(scope='module') +def runs(): + return _Runs() + + +def _unit_spectrum(f, f0): + """``|W(f)|`` of ``w(t) = exp(-s^2/(2 tau^2)) sin(2 pi f0 s)``, both Gaussian sidebands.""" + tau = 1.0 / (2 * math.pi * SIGMA_HZ) + g = lambda x: math.exp(-0.5 * (2 * math.pi * x * tau) ** 2) # noqa: E731 + return tau * math.sqrt(2 * math.pi) / 2 * abs(g(f - f0) - g(f + f0)) + + +def _axis(result): + """``(z, Ey)`` on the axis at the centre wavelength.""" + a = result['axis'] + z = np.asarray(a.coordinates['Ey'][2]).ravel() + return z, _np(a.values['Ey'])[CENTRE].ravel() + + +def _beta_axis(result, dx, eps=1.0): + """The grid's axial wavenumber at the centre wavelength (kernel units, c = 1).""" + dt = float(result.provenance['time_step_seconds']) * C0 / L + Omega = 2.0 * math.sin(math.pi * dt / LAMBDAS[CENTRE]) / dt + return (2.0 / dx) * math.asin(Omega * math.sqrt(eps) * dx / 2.0) + + +def _angular(z, waist, distance_phase=None, n=4001): + """The continuum on-axis field ``(1/2 pi) integral A(kx) [..] dkx`` over propagating ``kx`` + (vacuum, wavelength 1), ``A`` the transform of ``exp(-x^2/waist^2)``.""" + k = 2 * math.pi / LAMBDAS[CENTRE] + kx = np.linspace(-k, k, n)[1:-1] + A = waist * math.sqrt(math.pi) * np.exp(-(kx * waist) ** 2 / 4) + kz = np.sqrt(k ** 2 - kx ** 2) + weights = np.full(kx.size, kx[1] - kx[0]) + if distance_phase is None: + phase = np.exp(1j * np.outer(z, kz)) + else: + phase = distance_phase(kx, kz, z) + return (phase @ (A * weights)) / (2 * math.pi) + + +# --------------------------------------------------------------------------------------------- +# G1: waist +# --------------------------------------------------------------------------------------------- + +@pytest.mark.slow +@pytest.mark.parametrize('polarization', ['y', 'x']) +@pytest.mark.parametrize('dx', [.05, .025]) +def test_g1_the_focal_plane_has_the_declared_waist(runs, dx, polarization): + f = runs[dx, polarization]['focal'] + c = 'E' + polarization + x = np.asarray(f.coordinates[c][0]).ravel() + intensity = np.abs(_np(f.values[c])[CENTRE].ravel()) ** 2 + w = 2 * math.sqrt(float(np.sum(x ** 2 * intensity) / np.sum(intensity))) + print(f'G1 dx={dx} {polarization}: w / waist - 1 = {w / 1.5 - 1:.3e}') + assert abs(w / 1.5 - 1) <= 1e-6, w + + +# --------------------------------------------------------------------------------------------- +# G2: Gouy phase +# --------------------------------------------------------------------------------------------- + +def _gouy_error(result, dx): + z, Ey = _axis(result) + keep = np.abs(z) <= 3.0 + 1e-9 + z, Ey = z[keep], Ey[keep] + psi = np.angle(Ey * np.exp(-1j * _beta_axis(result, dx) * z)) + psi = psi - psi[np.argmin(np.abs(z))] + k = 2 * math.pi / LAMBDAS[CENTRE] + exact = np.angle(_angular(z, 1.5) * np.exp(-1j * k * z)) + exact = exact - exact[np.argmin(np.abs(z))] + zR = math.pi * 1.5 ** 2 / LAMBDAS[CENTRE] + paraxial = -0.5 * np.arctan(z / zR) + return (float(np.max(np.abs(psi - exact))), float(np.max(np.abs(exact - paraxial))), + float(np.max(np.abs(exact)))) + + +def _gouy_errors(runs): + errors = {} + for dx in (.05, .025): + error, paraxial, size = _gouy_error(runs[dx, 'y'], dx) + errors[dx] = error + print(f'G2 dx={dx}: max |psi - psi_exact| = {error:.3e} rad (Gouy phase up to ' + f'{size:.3f} rad; exact vs paraxial {paraxial:.2e} rad)') + order = math.log2(errors[.05] / errors[.025]) + print('G2 observed order', order) + return errors, order + + +@pytest.mark.slow +@pytest.mark.xfail(strict=True, reason='registered G2 failed its limit at 0.05 um (2.2e-3 rad ' + 'against 2e-3); its convergence order passes. See the record.') +def test_g2_the_gouy_phase_converges_to_the_continuum(runs): + errors, order = _gouy_errors(runs) + assert errors[.05] <= 2e-3, errors + assert order >= 1.8, (errors, order) + + +@pytest.mark.slow +def test_g2_the_gouy_phase_error_falls_at_second_order(runs): + # The registered G2's second part, which passes on its own. + errors, order = _gouy_errors(runs) + assert order >= 1.8, (errors, order) + + +# --------------------------------------------------------------------------------------------- +# G3: focal shift through an interface +# --------------------------------------------------------------------------------------------- + +def _peak(z, intensity): + i = int(np.argmax(intensity)) + sl = slice(i - 2, i + 3) + a, b, _ = np.polyfit(z[sl], intensity[sl], 2) + return -b / (2 * a) + + +def _focal_oracle(z_i=-1., z_f=.5, n2=1.5, waist=1.): + k = 2 * math.pi / LAMBDAS[CENTRE] + z = np.linspace(z_i + .05, 4.5, 8901) + + def phase(kx, kz, zz): + kz2 = np.sqrt((n2 * k) ** 2 - kx ** 2) + t = 2 * kz / (kz + kz2) + return t * np.exp(1j * kz * (z_i - z_f)) * np.exp(1j * np.outer(zz - z_i, kz2)) + field = _angular(z, waist, distance_phase=phase) + return _peak(z, np.abs(field) ** 2) + + +@pytest.mark.slow +def test_g3_the_focus_shifts_into_a_dielectric_as_the_continuum_does(): + _quiet() + oracle = _focal_oracle() + errors = {} + for dx in (.05, .025): + problem, numerics = _setup(dx, waist=1., focus=(0., 0., .5), z_sheet=-2.5, half=True, + monitors=(fdtd.FieldMonitor('axis', wavelengths=LAMBDAS, + plane=fdtd.Plane(x=0.), + components=('Ey',)),)) + z, Ey = _axis(_run(problem, numerics)) + inside = (z > -1. + 3 * dx) & (z < 4.5) + peak = _peak(z[inside], np.abs(Ey[inside]) ** 2) + errors[dx] = abs(peak - oracle) + print(f'G3 dx={dx}: peak {peak:.5f} um, continuum {oracle:.5f} um, paraxial 1.25 um') + assert errors[.05] <= .03 and errors[.025] <= .01 and errors[.025] < errors[.05], errors + + +# --------------------------------------------------------------------------------------------- +# G4: power +# --------------------------------------------------------------------------------------------- + +@pytest.mark.slow +@pytest.mark.parametrize('polarization', ['y', 'x']) +@pytest.mark.parametrize('dx', [.05, .025]) +def test_g4_a_unit_amplitude_carries_one_watt_per_metre(runs, dx, polarization): + flux = runs[dx, polarization]['flux'] + f0 = float(_np(flux.frequencies_hz)[CENTRE]) + expected = 2 * _unit_spectrum(f0, f0) ** 2 + got = float(_np(flux.spectral_energy)[CENTRE]) + print(f'G4 dx={dx} {polarization}: energy / expected - 1 = {got / expected - 1:.3e}') + assert abs(got / expected - 1) <= 2e-5, (got, expected) + + +@pytest.mark.slow +def test_g4_a_unit_amplitude_carries_one_watt_in_three_dimensions(): + _quiet() + span = (-4.4, 4.4) + beam = fdtd.GaussianBeam('b', plane=fdtd.Plane(z=-2.), pulse=_pulse(), waist=1.5, + focus=(0., 0., 0.), polarization='x', direction='+z') + problem = fdtd.Problem( + units=Units(length='um'), + scene=fdtd.Scene(domain=fdtd.Domain(size=(9., 9., 5.)), boundaries=fdtd.Boundaries.open()), + sources=(beam,), outputs=fdtd.Outputs(monitors=( + fdtd.FluxMonitor('flux', plane=fdtd.Plane(z=2., x=span, y=span), wavelengths=(1.,)),))) + numerics = fdtd.Numerics(grid=fdtd.UniformGrid(spacing=.05), + duration=fdtd.Duration(periods=PERIODS, source='b'), + absorber=fdtd.PML(cells=12, wavelength=1.)) + flux = _run(problem, numerics)['flux'] + f0 = float(_np(flux.frequencies_hz)[0]) + expected = 2 * _unit_spectrum(f0, f0) ** 2 + got = float(_np(flux.spectral_energy)[0]) + print(f'G4 3-D: energy / expected - 1 = {got / expected - 1:.3e} ({flux.units})') + assert abs(got / expected - 1) <= 2e-5, (got, expected) + + +# --------------------------------------------------------------------------------------------- +# G5: back-radiation +# --------------------------------------------------------------------------------------------- + +def _predicted(problem, numerics, result, dx): + """The predicted (forward, backward) spectral energies in the FluxMonitor's units.""" + from wavebit.fdtd._beam_source import beam_sheet, launched_power + from wavebit.fdtd._runtime import runtime + from wavebit.units import Z0 + sheet = beam_sheet(problem.sources[0], problem, numerics, problem.units) + ts = L / C0 + plan = types.SimpleNamespace(steps=int(result.provenance['actual_steps']), + dt=float(result.provenance['time_step_seconds']) / ts) + forward, backward = launched_power(sheet, plan, runtime('numpy'), LAMBDAS) + measure = L / (Z0 * dx) # one invariant axis + factor = 2 * ts ** 2 * measure + return forward * factor, backward * factor + + +@pytest.mark.slow +@pytest.mark.parametrize('dx', [.05, .025]) +def test_g5_back_radiation_is_as_predicted(runs, dx): + result = runs[dx, 'y'] + forward, backward = _predicted(*_setup(dx), result, dx) + measured_back = -_np(result['back'].spectral_energy) + measured_forward = _np(result['flux'].spectral_energy) + back_error = np.abs(measured_back - backward) / forward + forward_error = np.abs(measured_forward - forward) / forward + print(f'G5 dx={dx}: predicted back / forward = {backward / forward}; |measured - predicted| ' + f'back / forward = {back_error}; forward = {forward_error}') + assert np.all(back_error <= 1e-6), back_error + assert np.all(forward_error <= 1e-5), forward_error + assert backward[CENTRE] / forward[CENTRE] <= 1e-12, backward / forward + + +# --------------------------------------------------------------------------------------------- +# G6: backends +# --------------------------------------------------------------------------------------------- + +def _readings(result): + return {'flux': _np(result['flux'].spectral_energy), 'axis': _axis(result)[1]} + + +@pytest.mark.slow +def test_g6_backends_agree(runs): + import jax + jax.config.update('jax_enable_x64', True) + _quiet() + reference = _readings(runs[.05, 'y']) + for backend in ('jax', 'torch'): + got = _readings(_run(*_setup(.05), backend=backend)) + for key, value in reference.items(): + error = float(np.max(np.abs(got[key] - value)) / np.max(np.abs(value))) + print(f'G6 {backend} {key}: {error:.2e}') + assert error <= 1e-12, (backend, key, error) + + +# --------------------------------------------------------------------------------------------- +# G7: derivatives +# --------------------------------------------------------------------------------------------- + +G7_POINT = {'amplitude': 1.3, 'phase': .4, 'delay': 2e-15, 'scatterer': 2.0} +G7_STEPS = {'amplitude': 1e-3, 'phase': 1e-3, 'delay': 1e-17, 'scatterer': 3e-4} + + +def _g7_outputs(point, backend): + """G7 as amended before computing (the record): a truncated run in a 9 um x 3 um domain, + so that reverse mode, which keeps every step, fits in memory. The derivatives and the + central differences are of this same function.""" + dx = .05 + structures = (fdtd.Structure('block', shape=fdtd.SnappedBox(size=(.4, 1., .4), center=(2., 0., 0.)), + material=Material(epsilon=point['scatterer'])),) + pulse = fdtd.GaussianPulse(wavelength=1.0, frequency_sigma_hz=SIGMA_HZ, cutoff=1e-3) + beam = fdtd.GaussianBeam('b', plane=fdtd.Plane(z=-1.), pulse=pulse, waist=1.5, focus=(0., 0., 0.), + polarization='y', direction='+z', amplitude=point['amplitude'], + phase=point['phase'], delay_seconds=point['delay']) + problem = fdtd.Problem( + units=Units(length='um'), + scene=fdtd.Scene(domain=fdtd.Domain(size=(9., dx, 3.)), + boundaries=fdtd.Boundaries(x=fdtd.Open(), y=fdtd.InvariantAxis(), z=fdtd.Open()), + structures=structures), + sources=(beam,), + outputs=fdtd.Outputs(monitors=( + fdtd.FluxMonitor('flux', plane=fdtd.Plane(z=1., x=(-4.4, 4.4)), wavelengths=(1.,)), + fdtd.FieldMonitor('probe', wavelengths=(1.,), plane=fdtd.Plane(z=1., x=(-.05, .05)), + components=('Ey',))))) + numerics = fdtd.Numerics(grid=fdtd.UniformGrid(spacing=dx), duration=fdtd.Duration(steps=1000), + absorber=fdtd.PML(cells=12, wavelength=1.)) + result = fdtd.run(problem, numerics=numerics, backend=backend) + probe = result['probe'] + x = np.asarray(probe.coordinates['Ey'][0]).ravel() + centre = int(np.argmin(np.abs(x))) + return result['flux'].spectral_energy[0], probe.values['Ey'][0].reshape(-1)[centre].real + + +def _central(name, which): + def at(step): + hi, lo = dict(G7_POINT), dict(G7_POINT) + hi[name] += step + lo[name] -= step + return (float(_g7_outputs(hi, 'numpy')[which]) + - float(_g7_outputs(lo, 'numpy')[which])) / (2 * step) + h = G7_STEPS[name] + return (4 * at(h / 2) - at(h)) / 3 + + +G7_CASES = (('amplitude', 0), ('phase', 1), ('delay', 1), ('scatterer', 0)) + + +@pytest.mark.slow +def test_g7_derivatives_match_central_differences(): + _quiet() + import jax + import torch + jax.config.update('jax_enable_x64', True) + for name, which in G7_CASES: + reference = _central(name, which) + + def f(x, backend, name=name, which=which): + point = dict(G7_POINT) + point[name] = x + return _g7_outputs(point, backend)[which] + by_jax = float(jax.grad(lambda x: f(x, 'jax'))(G7_POINT[name])) + x = torch.tensor(G7_POINT[name], dtype=torch.float64, requires_grad=True) + f(x, 'torch').backward() + by_torch = float(x.grad) + print(f'G7 {name}: jax {by_jax:.10e}, torch {by_torch:.10e}, central {reference:.10e}') + for got in (by_jax, by_torch): + assert abs(got - reference) <= 1e-6 * abs(reference), (name, got, reference) + + +# --------------------------------------------------------------------------------------------- +# G8: refusals +# --------------------------------------------------------------------------------------------- + +def _refused(code, problem, numerics, backend='numpy'): + _quiet() + report = fdtd.check(problem, numerics=numerics, backend=backend) + assert [issue.code for issue in report.issues] == [code], report.issues + with pytest.raises(WavebitError) as info: + fdtd.run(problem, numerics=numerics, backend=backend) + assert info.value.code == code, (info.value.code, str(info.value)) + + +def _with(**changes): + problem, numerics = _setup(.05, periods=1) + return problem.replace(sources=(problem.sources[0].replace(**changes),)), numerics + + +def test_g8_a_direction_off_the_normal_is_refused(): + _refused('source.direction', *_with(direction='+x')) + + +def test_g8_a_polarization_along_the_normal_is_refused(): + _refused('source.polarization', *_with(polarization='z')) + + +@pytest.mark.parametrize('waist', [0., -1.]) +def test_g8_a_waist_is_positive(waist): + _refused('source.waist', *_with(waist=waist)) + + +def test_g8_a_focus_is_a_point(): + _refused('source.focus', *_with(focus=(0., 0.))) + + +def test_g8_a_traced_waist_is_refused(): + import torch + waist = torch.tensor(1.5, dtype=torch.float64, requires_grad=True) + _refused('source.traced', *_with(waist=waist), backend='torch') + + +def test_g8_a_traced_pulse_wavelength_is_refused(): + import torch + lam = torch.tensor(1.0, dtype=torch.float64, requires_grad=True) + _refused('mode.traced', *_setup(.05, pulse=_pulse(lam), duration=fdtd.Duration(seconds=1e-15)), + backend='torch') + + +def test_g8_a_nonuniform_medium_on_the_plane_is_refused(): + problem, numerics = _setup(.05, periods=1) + scene = problem.scene.replace(structures=(fdtd.Structure( + 'rod', shape=fdtd.SnappedBox(size=(.5, 1., 1.), center=(1., 0., -4.)), + material=Material(epsilon=2.)),)) + _refused('source.medium', problem.replace(scene=scene), numerics) + + +def test_g8_a_metal_transverse_face_is_refused(): + boundaries = fdtd.Boundaries(x=fdtd.PEC(), y=fdtd.InvariantAxis(), z=fdtd.Open()) + _refused('source.boundary', *_setup(.05, periods=1, boundaries=boundaries, + monitors=(fdtd.FieldMonitor('axis', wavelengths=(1.,), + plane=fdtd.Plane(x=0.), + components=('Ey',)),))) + + +def test_g8_a_beam_the_cross_section_cannot_hold_is_refused(): + _refused('source.beam_size', *_with(waist=4.)) + + +def test_g8_a_monitor_on_the_sheet_is_refused(): + monitors = (fdtd.FluxMonitor('m', wavelengths=(1.,), plane=fdtd.Plane(z=-3.95, x=(-5., 5.))),) + _refused('monitor.source_sheet', *_setup(.05, periods=1, monitors=monitors)) + + +def test_g8_the_engine_placeholder_names_the_public_source(): + import wavebit.fdtd._legacy as fd + with pytest.raises(NotImplementedError, match='wavebit.fdtd.GaussianBeam'): + fd.GaussianBeam() + + +def test_the_beam_is_centred_on_a_transverse_focus(): + # Not a registered criterion: the sheet's E pattern is centred on the focus's x (here + # 1 um), and its axial phase does not depend on the transverse shift. + from wavebit.fdtd._beam_source import beam_sheet + _quiet() + problem, numerics = _setup(.05, periods=1) + centred = beam_sheet(problem.sources[0], problem, numerics, problem.units) + shifted_source = problem.sources[0].replace(focus=(1., 0., 0.)) + shifted = beam_sheet(shifted_source, problem.replace(sources=(shifted_source,)), numerics, + problem.units) + a = np.abs(centred.patterns['Ev_re'] + 1j * centred.patterns['Ev_im']).ravel() + b = np.abs(shifted.patterns['Ev_re'] + 1j * shifted.patterns['Ev_im']).ravel() + # (The window's wall node, the first and last entries, is zero in both.) + assert np.allclose(b[21:-1], a[1:-21], atol=1e-9 * a.max()), np.max(np.abs(b[21:-1] - a[1:-21])) diff --git a/tests/fdtd/test_fd_mode_monitor.py b/tests/fdtd/test_fd_mode_monitor.py new file mode 100644 index 00000000..a2aa7bac --- /dev/null +++ b/tests/fdtd/test_fd_mode_monitor.py @@ -0,0 +1,346 @@ +"""``fdtd.ModeMonitor``: modal amplitudes of a run's fields, against exact and conserved answers. + +The criteria are MM1-MM5 of ``docs/validation/fdtd/fd-waveguide-modes.md``, registered before +the monitor was first run; every limit here is quoted from there. +""" +import math +import warnings + +import numpy as np +import pytest + +from wavebit import Material, Units, fdtd +from wavebit.errors import WavebitError +from wavebit.units import C0 + + +def _quiet(): + warnings.simplefilter('ignore') + + +def _problem(domain, center, boundaries, structures=(), background=1.0, **kw): + return fdtd.Problem( + units=Units(length='um'), + scene=fdtd.Scene(domain=fdtd.Domain(size=domain, center=center), + background=Material(epsilon=background), boundaries=boundaries, + structures=tuple(structures)), **kw) + + +# --------------------------------------------------------------------------------------------- +# MM1: exact direction separation in Bloch guides +# --------------------------------------------------------------------------------------------- + +N1, N2 = 2.0, 1.444 + + +def _bloch_guide(kind, dx, k): + z = fdtd.Bloch(k=k) if k else fdtd.Periodic() + if kind in ('box', 'smoothed'): + shape = (fdtd.SnappedBox(size=(1., .5, 10.)) if kind == 'box' + else fdtd.Box(size=(1.03, .51, 10.))) + return _problem((3., 3., 8 * dx), (0., 0., 0.), + fdtd.Boundaries(x=fdtd.PEC(), y=fdtd.PEC(), z=z), + structures=(fdtd.Structure('core', shape=shape, + material=Material(epsilon=4.0)),)) + return _problem((9., dx, 8 * dx), (0., 0., 0.), + fdtd.Boundaries(x=fdtd.PEC(), y=fdtd.InvariantAxis(), z=z), + structures=(fdtd.Structure('core', shape=fdtd.SnappedSlab(x=(-.75, .75)), + material=Material(epsilon=N1 ** 2)),), + background=N2 ** 2) + + +def _numerics(dx, steps, wavelength): + return fdtd.Numerics(grid=fdtd.UniformGrid(spacing=dx), duration=fdtd.Duration(steps=steps), + absorber=fdtd.PML(wavelength=wavelength)) + + +def _launch(kind, lam, index, sign, span=None): + """A Bloch run started from mode ``index`` travelling ``sign`` z, read on z = 0. + + Returns ``(launched, other_way, other_mode_max, energy, flux)`` from a ``ModeMonitor`` and a + ``FluxMonitor`` whose planes take the transverse ``span`` (``{'x': (lo, hi)}``) if given. + """ + _quiet() + from wavebit.fdtd._kernel.grid import OFFSETS + dx, steps, nz = 0.05, 200, 8 + modes = fdtd.solve_modes(_bloch_guide(kind, dx, 0.0), numerics=_numerics(dx, 1, lam), + plane=fdtd.Plane(z=0.), wavelength=lam, count=2) + mode = modes[index] + beta = sign * mode.beta_per_meter * 1e-6 + problem = _bloch_guide(kind, dx, beta) + numerics = _numerics(dx, steps, lam) + dt = fdtd.check(problem, numerics=numerics).resolved['time_step_seconds'] * C0 / 1e-6 + omega = 2 * math.pi / lam + # A backward mode keeps E_t and H_z and flips H_t and E_z. + flip = {'E': (1, 1, sign), 'H': (sign, sign, 1)} + + def volume(field, values): + out = np.zeros((3, nz) + np.shape(values[field + 'x']), dtype=complex) + for c, axis in enumerate('xyz'): + z = -4 * dx + (np.arange(nz) + 0.5 * OFFSETS[field][c][2]) * dx + out[c] = flip[field][c] * np.asarray(values[field + axis])[None] \ + * np.exp(1j * beta * z)[:, None, None] + return out + initial = fdtd.InitialField(E=volume('E', mode.E), + H=volume('H', mode.H) * np.exp(1j * omega * dt / 2)) + plane = fdtd.Plane(z=0., **(span or {})) + outputs = fdtd.Outputs(monitors=( + fdtd.ModeMonitor('m', plane=plane, wavelengths=(lam,), modes=2), + fdtd.FluxMonitor('f', plane=plane, wavelengths=(lam,)))) + result = fdtd.run(problem.replace(initial=initial, outputs=outputs), numerics=numerics) + m, flux = result['m'], result['f'] + launched = (m.forward if sign > 0 else m.backward)[0, index] + other_way = (m.backward if sign > 0 else m.forward)[0, index] + other_mode = max(abs(m.forward[0, 1 - index]), abs(m.backward[0, 1 - index])) + energy = (m.forward_energy if sign > 0 else -m.backward_energy)[0, index] + return launched, other_way, other_mode, energy, flux.spectral_energy[0] + + +@pytest.mark.parametrize('kind,lam', [('box', 1.0), ('slab', 1.55), ('smoothed', 1.0)]) +@pytest.mark.parametrize('index', [0, 1]) +@pytest.mark.parametrize('sign', [1, -1]) +def test_mm1_a_launched_mode_is_read_back_in_its_own_direction_only(kind, lam, index, sign): + launched, other_way, other_mode, energy, flux = _launch(kind, lam, index, sign) + assert abs(launched) > 0 + assert abs(other_way) <= 1e-10 * abs(launched) + assert other_mode <= 1e-10 * abs(launched) + assert abs(energy - flux) <= 1e-10 * abs(flux) + + +@pytest.mark.parametrize('index', [0, 1]) +@pytest.mark.parametrize('sign', [1, -1]) +def test_mm6_a_finite_span_plane_reads_the_same_mode(index, sign): + launched, other_way, other_mode, energy, flux = _launch('slab', 1.55, index, sign, + span={'x': (-3., 3.)}) + assert abs(launched) > 0 + assert abs(other_way) <= 1e-8 * abs(launched) + assert other_mode <= 1e-8 * abs(launched) + assert abs(energy - flux) <= 1e-8 * abs(flux) + + +# --------------------------------------------------------------------------------------------- +# MM2-MM4: a dipole in a hollow metal guide +# --------------------------------------------------------------------------------------------- + +LAM2 = 1.05 +PULSE = fdtd.GaussianPulse(wavelength=LAM2, frequency_sigma_hz=2e13) + + +def _hollow(block=None, periods=150, monitors=True, pulse=None, wavelength=None, steps=None): + pulse = pulse or PULSE + wavelength = wavelength or LAM2 + structures = () + if block is not None: + structures = (fdtd.Structure('block', shape=fdtd.SnappedBox(size=(.4, .3, .5), + center=(.4, .25, 1.25)), + material=Material(epsilon=block)),) + outs = () + if monitors: + outs = tuple(fdtd.ModeMonitor('m' + s, plane=fdtd.Plane(z=z), wavelengths=(wavelength,), + modes=7) + for s, z in (('+', 3.), ('-', -3.))) + tuple( + fdtd.FluxMonitor('f' + s, plane=fdtd.Plane(z=z), wavelengths=(wavelength,)) + for s, z in (('+', 3.), ('-', -3.))) + else: + outs = (fdtd.ModeMonitor('m+', plane=fdtd.Plane(z=3.), wavelengths=(wavelength,), modes=1),) + problem = _problem((1., .5, 8.), (.5, .25, 0.), + fdtd.Boundaries(x=fdtd.PEC(), y=fdtd.PEC(), z=fdtd.Open()), + structures=structures, background=2.25, + sources=(fdtd.CurrentDipole('d', center=(.3, .2, 0.), component='Ey', + pulse=pulse),), + outputs=fdtd.Outputs(monitors=outs)) + numerics = fdtd.Numerics(grid=fdtd.UniformGrid(spacing=.05), + duration=(fdtd.Duration(steps=steps) if steps else + fdtd.Duration(periods=periods, source='d'))) + return problem, numerics + + +@pytest.fixture(scope='module') +def hollow_runs(): + _quiet() + import jax + jax.config.update('jax_enable_x64', True) + problem, numerics = _hollow() + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always') + runs = {b: fdtd.run(problem, numerics=numerics, backend=b) for b in ('numpy', 'jax', 'torch')} + return runs, [type(w.message).__name__ for w in caught] + + +@pytest.mark.xfail(strict=True, reason='registered MM2 failed: a broadband pulse in a closed metal ' + 'guide never settles (cutoff content is trapped); see MM2b and the record') +def test_mm2_seven_modes_carry_the_whole_flux(hollow_runs): + runs, caught = hollow_runs + assert 'UnsettledFieldWarning' not in caught, caught + _complete(runs['numpy']) + + +def _complete(result): + for side in '+-': + m, f = result['m' + side], result['f' + side] + modal = np.sum(np.asarray(m.forward_energy) - np.asarray(m.backward_energy), axis=1) + flux = np.asarray(f.spectral_energy) + assert np.all(np.abs(modal - flux) <= 1e-6 * np.abs(flux)), (modal, flux) + + +#: MM2b: a narrowband pulse inside the band where exactly seven modes propagate (discrete +#: cutoffs 1.0087 and 1.0644 um on this grid), so nothing is left at a cutoff. +LAM2B = 1.036 +PULSE2B = fdtd.GaussianPulse(wavelength=LAM2B, frequency_sigma_hz=1.3e12, cutoff=1e-9) + + +@pytest.mark.slow +def test_mm2b_seven_modes_carry_the_whole_flux_of_a_settled_run(): + _quiet() + problem, numerics = _hollow(periods=600, pulse=PULSE2B, wavelength=LAM2B) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always') + result = fdtd.run(problem, numerics=numerics) + names = [type(w.message).__name__ for w in caught] + assert 'UnsettledFieldWarning' not in names, names + _complete(result) + + +def test_mm3_backends_agree(hollow_runs): + runs, _ = hollow_runs + for side in '+-': + ref = runs['numpy']['m' + side] + scale = max(np.max(np.abs(np.asarray(ref.forward))), np.max(np.abs(np.asarray(ref.backward)))) + for b in ('jax', 'torch'): + got = runs[b]['m' + side] + for name in ('forward', 'backward'): + diff = np.max(np.abs(np.asarray(getattr(got, name)) - np.asarray(getattr(ref, name)))) + assert diff <= 1e-12 * scale, (b, name, diff / scale) + + +@pytest.mark.slow +@pytest.mark.xfail(strict=True, reason='registered MM4 failed: the central difference at the ' + 'registered step carries a truncation error of 9.6e-6 (> 1e-6); see MM4b') +def test_mm4_derivative_through_a_scatterer_matches_central_differences(): + _quiet() + import jax + import torch + jax.config.update('jax_enable_x64', True) + + def energy(eps, backend): + problem, numerics = _hollow(block=eps, steps=MM4_STEPS, monitors=False) + return fdtd.run(problem, numerics=numerics, backend=backend)['m+'].forward_energy[0, 0] + + eps0, h = 3.0, 3.0e-4 + central = (float(energy(eps0 + h, 'numpy')) - float(energy(eps0 - h, 'numpy'))) / (2 * h) + by_jax = float(jax.grad(lambda e: energy(e, 'jax'))(eps0)) + e = torch.tensor(eps0, dtype=torch.float64, requires_grad=True) + energy(e, 'torch').backward() + by_torch = float(e.grad) + for got in (by_jax, by_torch): + assert abs(got - central) <= 1e-6 * abs(central), (got, central) + + +def _mm4_energy(eps, backend): + problem, numerics = _hollow(block=eps, steps=MM4_STEPS, monitors=False) + return fdtd.run(problem, numerics=numerics, backend=backend)['m+'].forward_energy[0, 0] + + +@pytest.mark.slow +def test_mm4b_derivative_through_a_scatterer_matches_extrapolated_differences(): + _quiet() + import jax + import torch + jax.config.update('jax_enable_x64', True) + eps0, h = 3.0, 3.0e-4 + + def central(step): + return (float(_mm4_energy(eps0 + step, 'numpy')) + - float(_mm4_energy(eps0 - step, 'numpy'))) / (2 * step) + reference = (4 * central(h / 2) - central(h)) / 3 + by_jax = float(jax.grad(lambda e: _mm4_energy(e, 'jax'))(eps0)) + e = torch.tensor(eps0, dtype=torch.float64, requires_grad=True) + _mm4_energy(e, 'torch').backward() + by_torch = float(e.grad) + for got in (by_jax, by_torch): + assert abs(got - reference) <= 1e-7 * abs(reference), (got, reference) + + +#: The MM4 run length: the pulse's leading part has crossed the +z monitor. Reverse-mode +#: derivatives keep every step (about 3.5 MB a step on JAX here), so the run is kept short; the +#: derivative and the central difference are of the same truncated function. +MM4_STEPS = 800 + + +# --------------------------------------------------------------------------------------------- +# MM5: refusals +# --------------------------------------------------------------------------------------------- + +def _run_refused(code, problem, numerics, backend='numpy', at_check=True): + """Refused by ``run``, and by ``check`` before any step when the refusal is static.""" + _quiet() + report = fdtd.check(problem, numerics=numerics, backend=backend) + assert [issue.code for issue in report.issues] == ([code] if at_check else []), report.issues + with pytest.raises(WavebitError) as info: + fdtd.run(problem, numerics=numerics, backend=backend) + assert info.value.code == code, (info.value.code, str(info.value)) + + +def test_mm5_a_nonuniform_monitor_plane_is_refused(): + problem, numerics = _hollow(block=3.0, periods=1, monitors=False) + problem = problem.replace(outputs=fdtd.Outputs(monitors=( + fdtd.ModeMonitor('m', plane=fdtd.Plane(z=1.0), wavelengths=(LAM2,)),))) + _run_refused('mode.nonuniform', problem, numerics) + + +def test_mm5_a_traced_structure_on_the_plane_is_refused(): + import torch + eps = torch.tensor(3.0, dtype=torch.float64, requires_grad=True) + problem, numerics = _hollow(block=eps, periods=1, monitors=False) + problem = problem.replace(outputs=fdtd.Outputs(monitors=( + fdtd.ModeMonitor('m', plane=fdtd.Plane(z=1.25), wavelengths=(LAM2,)),))) + _run_refused('mode.traced', problem, numerics, backend='torch') + + +def test_mm5_more_modes_than_confined_are_refused(): + problem, numerics = _hollow(periods=1, monitors=False) + problem = problem.replace(outputs=fdtd.Outputs(monitors=( + fdtd.ModeMonitor('m', plane=fdtd.Plane(z=3.0), wavelengths=(LAM2,), modes=8),))) + # The count is a per-wavelength solve, made when the result is adapted. + _run_refused('mode.count', problem, numerics, at_check=False) + + +@pytest.mark.parametrize('modes', [0, -1, True, 1.5]) +def test_mm5_modes_is_a_positive_integer(modes): + with pytest.raises(WavebitError) as info: + fdtd.ModeMonitor('m', plane=fdtd.Plane(z=0.), wavelengths=(1.,), modes=modes) + assert info.value.code == 'monitor.modes' + + +def test_mm5_a_plane_outside_the_domain_is_refused(): + problem, numerics = _hollow(periods=1, monitors=False) + problem = problem.replace(outputs=fdtd.Outputs(monitors=( + fdtd.ModeMonitor('m', plane=fdtd.Plane(z=5.0), wavelengths=(LAM2,)),))) + _quiet() + with pytest.raises(WavebitError) as info: + fdtd.run(problem, numerics=numerics) + assert 'outside' in str(info.value) + + +# --------------------------------------------------------------------------------------------- +# Across an Open face +# --------------------------------------------------------------------------------------------- + +def test_a_monitor_across_open_faces_reads_the_window_solve_modes_solves(): + """The window stops one cell inside an Open face's physical edge, for both, so the monitor's + flux plane reads no absorbing cell and its modes are exactly the ones solve_modes returns.""" + _quiet() + problem = _problem((9., .05, .4), (0., 0., 0.), + fdtd.Boundaries(x=fdtd.Open(), y=fdtd.InvariantAxis(), z=fdtd.Periodic()), + structures=(fdtd.Structure('core', shape=fdtd.SnappedSlab(x=(-.75, .75)), + material=Material(epsilon=N1 ** 2)),), + background=N2 ** 2, + initial=fdtd.InitialField(E=(None, lambda x, y, z: np.exp(-(x / .5) ** 2), None)), + outputs=fdtd.Outputs(monitors=( + fdtd.ModeMonitor('m', plane=fdtd.Plane(z=.1), wavelengths=(1.55,), modes=2),))) + numerics = _numerics(.05, 20, 1.55) + spectrum = fdtd.run(problem, numerics=numerics)['m'] + solved = fdtd.solve_modes(problem, numerics=numerics, plane=fdtd.Plane(z=.1), wavelength=1.55, + count=2) + assert [m.effective_index for m in spectrum.modes[0]] == [m.effective_index for m in solved] + assert solved.metadata['window']['x'] == (13, 191) # physical cells [12, 192), one off each side diff --git a/tests/fdtd/test_fd_mode_source.py b/tests/fdtd/test_fd_mode_source.py new file mode 100644 index 00000000..4f8438ea --- /dev/null +++ b/tests/fdtd/test_fd_mode_source.py @@ -0,0 +1,468 @@ +"""A one-way waveguide mode source. + +Criteria S1-S8 of ``docs/validation/fdtd/fd-mode-source.md``, registered before the source was +first run; every limit here is quoted from there. +""" +import cmath +import math +import warnings + +import numpy as np +import pytest + +from wavebit import Material, Units, fdtd +from wavebit.errors import WavebitError + +EPS_CORE, EPS_CLAD = 4.0, 2.085136 +LAMBDAS = (1.45, 1.55, 1.65) +CENTRE = 1 # LAMBDAS[CENTRE] is the pulse's centre +SIGMA_HZ = 1.5e13 +N_EXACT_TE0 = 1.9562181938 # the mode-solver record's M3, TE0 at 1.55 um +PERIODS = 120 + + +def _quiet(): + warnings.simplefilter('ignore') + + +def _pulse(wavelength=1.55): + return fdtd.GaussianPulse(wavelength=wavelength, frequency_sigma_hz=SIGMA_HZ, cutoff=1e-12) + + +def _core(kind, eps=EPS_CORE): + if kind == 'slab': + return (fdtd.Structure('core', shape=fdtd.SnappedSlab(x=(-.75, .75)), + material=Material(epsilon=eps), continuation=('z-', 'z+')),) + # S5: a butt joint at z = 0, 1.5 um wide below and 1.0 um wide above. + return (fdtd.Structure('wide', shape=fdtd.SnappedBox(size=(1.5, 1., 4.), center=(0., 0., -2.)), + material=Material(epsilon=eps), continuation=('z-',)), + fdtd.Structure('narrow', shape=fdtd.SnappedBox(size=(1., 1., 4.), center=(0., 0., 2.)), + material=Material(epsilon=eps), continuation=('z+',))) + + +def _setup(dx, *, mirrored=False, kind='slab', amplitude=1., phase=0., delay=0., scatterer=None, + mode=0, monitor_modes=1, + core_eps=EPS_CORE, pulse=None, monitors=None, source_kw=None, periods=PERIODS, + duration=None): + """Setup S (or its mirror, or the S5 joint) at spacing ``dx``.""" + structures = _core(kind, core_eps) + if scatterer is not None: + # S7: a traced block in the cladding beside the core, between the source and p1. + structures += (fdtd.Structure('block', shape=fdtd.SnappedBox(size=(.4, 1., .4), + center=(1.2, 0., -.5)), + material=Material(epsilon=scatterer)),) + z_source, direction = (2., '-z') if mirrored else (-2., '+z') + source = fdtd.ModeSource('src', plane=fdtd.Plane(z=z_source), pulse=pulse or _pulse(), + direction=direction, mode=mode, amplitude=amplitude, phase=phase, + delay_seconds=delay, **(source_kw or {})) + if monitors is None: + if mirrored: + places = (('m', -2.),) + else: + places = (('p1', 1.), ('p2', 2.), ('q', -1. if kind == 'joint' else -3.)) + monitors = tuple(fdtd.ModeMonitor(name, plane=fdtd.Plane(z=z), wavelengths=LAMBDAS, + modes=monitor_modes, source='src') + for name, z in places) + problem = fdtd.Problem( + units=Units(length='um'), + scene=fdtd.Scene( + domain=fdtd.Domain(size=(9., dx, 8.)), background=Material(epsilon=EPS_CLAD), + boundaries=fdtd.Boundaries(x=fdtd.Open(), y=fdtd.InvariantAxis(), z=fdtd.Open()), + structures=structures), + sources=(source,), outputs=fdtd.Outputs(monitors=monitors)) + numerics = fdtd.Numerics(grid=fdtd.UniformGrid(spacing=dx), + duration=duration or fdtd.Duration(periods=periods, source='src'), + absorber=fdtd.PML(cells=24, wavelength=1.55)) + return problem, numerics + + +def _run(problem, numerics, backend='numpy'): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always') + result = fdtd.run(problem, numerics=numerics, backend=backend) + names = [type(w.message).__name__ for w in caught] + assert 'UnsettledFieldWarning' not in names and 'GrowingFieldWarning' not in names, names + return result + + +def _np(value): + if hasattr(value, 'detach'): + value = value.detach() + return np.asarray(value) + + +def _P(spectrum, distance, mode=0): + """The mode's grid propagation factor over ``distance`` um at each wavelength (the run's + Fourier convention is exp(+i omega t), so a wave advancing by d gains exp(+i beta d)).""" + beta = 2 * np.pi * _np(spectrum.effective_index)[:, mode] / np.asarray(LAMBDAS) + return np.exp(1j * beta * distance) + + +class _Straight(dict): + """Setup S per spacing, run once on first use (a test at one spacing runs only that one).""" + + def __missing__(self, dx): + _quiet() + self[dx] = _run(*_setup(dx)) + return self[dx] + + +@pytest.fixture(scope='module') +def straight(): + return _Straight() + + +# --------------------------------------------------------------------------------------------- +# S1, S2: what the sheet injects and leaks, as predicted +# --------------------------------------------------------------------------------------------- + +@pytest.mark.slow +@pytest.mark.parametrize('dx', [.05, .025]) +def test_s1_the_injected_mode_arrives_as_predicted(straight, dx): + p2 = straight[dx]['p2'] + injected = _np(p2.injected) + predicted = injected * _P(p2, 4.) + error = np.abs(_np(p2.forward)[:, 0] - predicted) / np.abs(injected) + print(f'S1 dx={dx}: |forward - predicted| / |injected| =', error) + assert np.all(error <= 1e-5), error + + +@pytest.mark.slow +def test_s1_the_mirrored_source_injects_as_predicted(): + _quiet() + m = _run(*_setup(.05, mirrored=True))['m'] + injected = _np(m.injected) + error = np.abs(_np(m.backward)[:, 0] - injected * _P(m, 4.)) / np.abs(injected) + print('S1 mirrored: |backward - predicted| / |injected| =', error) + assert np.all(error <= 1e-5), error + # Added after registration, at S2's and S3's limits: the prediction follows the sheet + # whatever it holds, so the mirrored source is also held to one-way launch and 1 W at the + # centre wavelength. + leakage = float(np.abs(_np(m.leakage)[CENTRE] / injected[CENTRE]) ** 2) + f0 = float(_np(m.frequencies_hz)[CENTRE]) + power = float(_np(m.backward_energy)[CENTRE, 0]) / (2 * _unit_spectrum(f0) ** 2) - 1 + print(f'S1 mirrored: leakage power {leakage:.2e}, backward energy / expected - 1 = {power:.2e}') + assert leakage <= 1e-12 and abs(power) <= 2e-5, (leakage, power) + + +@pytest.mark.slow +def test_s1_a_tm_mode_is_injected_as_predicted(): + # Added after registration, at S1's, S2's and S3's limits: TE0 has no Ex or Hy, so the + # sheet's other two corrections are exercised by TM0 (mode 1 here) alone. + _quiet() + result = _run(*_setup(.05, mode=1, monitor_modes=2)) + p2 = result['p2'] + injected = _np(p2.injected) + error = np.abs(_np(p2.forward)[:, 1] - injected * _P(p2, 4., mode=1)) / np.abs(injected) + leakage = float(np.abs(_np(p2.leakage)[CENTRE] / injected[CENTRE]) ** 2) + f0 = float(_np(p2.frequencies_hz)[CENTRE]) + power = float(_np(p2.forward_energy)[CENTRE, 1]) / (2 * _unit_spectrum(f0) ** 2) - 1 + print(f'S1 TM0: |forward - predicted| / |injected| = {error}; leakage power {leakage:.2e}; ' + f'forward energy / expected - 1 = {power:.2e}') + assert np.all(error <= 1e-5) and leakage <= 1e-12 and abs(power) <= 2e-5, (error, leakage, power) + + +@pytest.mark.slow +@pytest.mark.parametrize('dx', [.05, .025]) +def test_s2_the_leakage_is_as_predicted(straight, dx): + q, p2 = straight[dx]['q'], straight[dx]['p2'] + injected, leakage = _np(q.injected), _np(q.leakage) + predicted = leakage * _P(q, 1.) + _np(p2.backward)[:, 0] * _P(q, 5.) + error = np.abs(_np(q.backward)[:, 0] - predicted) / np.abs(injected) + power = np.abs(leakage / injected) ** 2 + print(f'S2 dx={dx}: |backward - predicted| / |injected| =', error, + '; leakage power / injected =', power) + assert np.all(error <= 1e-6), error + assert power[CENTRE] <= 1e-12, power + + +# --------------------------------------------------------------------------------------------- +# S3: absolute power +# --------------------------------------------------------------------------------------------- + +def _unit_spectrum(f): + """``|W(f)|`` of ``w(t) = exp(-s^2/(2 tau^2)) sin(2 pi f0 s)``, both Gaussian sidebands.""" + tau = 1.0 / (2 * math.pi * SIGMA_HZ) + f0 = 299792458.0 / 1.55e-6 + g = lambda x: math.exp(-0.5 * (2 * math.pi * x * tau) ** 2) + # W(f) = tau sqrt(2 pi) / (2i) [g(f - f0) - g(f + f0)] up to a phase of s's origin. + return tau * math.sqrt(2 * math.pi) / 2 * abs(g(f - f0) - g(f + f0)) + + +@pytest.mark.slow +@pytest.mark.parametrize('dx', [.05, .025]) +def test_s3_a_unit_amplitude_carries_one_watt_per_metre(straight, dx): + p2 = straight[dx]['p2'] + f0 = float(_np(p2.frequencies_hz)[CENTRE]) + expected = 2 * 1.0 ** 2 * _unit_spectrum(f0) ** 2 + got = float(_np(p2.forward_energy)[CENTRE, 0]) + print(f'S3 dx={dx}: forward_energy / expected - 1 =', got / expected - 1, p2.units) + assert abs(got / expected - 1) <= 2e-5, (got, expected) + + +# --------------------------------------------------------------------------------------------- +# S4: convergence with resolution +# --------------------------------------------------------------------------------------------- + +def _n_run(result): + p1, p2 = result['p1'], result['p2'] + k0 = 2 * math.pi / 1.55 + phase = cmath.phase(complex(_np(p2.forward)[CENTRE, 0] / _np(p1.forward)[CENTRE, 0])) + n_solver = float(_np(p2.effective_index)[CENTRE, 0]) + branch = round((n_solver * k0 * 1. - phase) / (2 * math.pi)) + return (phase + 2 * math.pi * branch) / k0, n_solver + + +@pytest.mark.slow +def test_s4_the_run_propagates_the_grid_mode_and_converges(straight): + errors = {} + for dx in (.05, .025): + n_run, n_solver = _n_run(straight[dx]) + print(f'S4 dx={dx}: n_run={n_run:.12f}, n_solver={n_solver:.12f}, ' + f'n_run - n_exact={n_run - N_EXACT_TE0:.3e}') + assert abs(n_run - n_solver) <= 1e-9, (n_run, n_solver) + errors[dx] = abs(n_run - N_EXACT_TE0) + order = math.log2(errors[.05] / errors[.025]) + print('S4 observed order', order) + assert order >= 1.8, (errors, order) + + +# --------------------------------------------------------------------------------------------- +# S5: butt joint against continuum mode matching +# --------------------------------------------------------------------------------------------- + +def _te_modes(half_width, W, h, k0, count): + """TE modes (Ey) of a symmetric slab in a perfectly conducting box ``|x| < W``: the + continuum operator ``d^2/dx^2 + k0^2 eps(x)`` by second-order finite differences on nodes + ``h`` apart (the core edges on nodes, eps averaged there), Ey = 0 at the walls. Returns + ``(beta^2, modes)`` with ``sum(mode^2) h = 1``, largest ``beta^2`` first.""" + from scipy.linalg import eigh_tridiagonal + n = int(round(2 * W / h)) - 1 + x = -W + h * np.arange(1, n + 1) + eps = np.where(np.abs(x) < half_width, EPS_CORE, EPS_CLAD) + eps[np.isclose(np.abs(x), half_width, atol=h * 1e-6)] = (EPS_CORE + EPS_CLAD) / 2 + diagonal = -2.0 / h ** 2 + k0 ** 2 * eps + off = np.full(n - 1, 1.0 / h ** 2) + values, vectors = eigh_tridiagonal(diagonal, off, select='i', select_range=(n - count, n - 1)) + order = np.argsort(-values) + return values[order], vectors[:, order] / math.sqrt(h) + + +def _mode_matching(W, h, count, wavelength=1.55): + """TE0 power transmission and reflection at the joint 1.5 um -> 1.0 um at z = 0. + + Fields go as exp(-i beta z) forward; an evanescent mode has beta = -i |beta|. Ey continuity + is tested with the right-hand modes and Hx continuity with the left-hand ones (both sets + orthonormal): ``c = O (e0 + b)`` and ``diag(bL) (e0 - b) = O^T diag(bR) c``.""" + k0 = 2 * math.pi / wavelength + b2L, eL = _te_modes(.75, W, h, k0, count) + b2R, eR = _te_modes(.5, W, h, k0, count) + beta = lambda b2: np.where(b2 > 0, np.sqrt(np.abs(b2)), -1j * np.sqrt(np.abs(b2))) + bL, bR = beta(b2L), beta(b2R) + O = eR.T @ eL * h + e0 = np.zeros(count) + e0[0] = 1.0 + M = O.T @ (bR[:, None] * O) + b = np.linalg.solve(np.diag(bL) + M, bL * e0 - M @ e0) + c = O @ (e0 + b) + return abs(c[0]) ** 2 * bR[0].real / bL[0].real, abs(b[0]) ** 2 + + +def _oracle(): + T, R = _mode_matching(W=12., h=.0025, count=800) + # Converged in the number of modes and in the box width at the scale of the criteria. + for other in (_mode_matching(W=12., h=.0025, count=400), _mode_matching(W=18., h=.0025, count=1200)): + assert abs(other[0] - T) <= 2e-4 and abs(other[1] - R) <= .02 * R, (T, R, other) + return T, R + + +@pytest.mark.slow +def test_s5_a_butt_joint_matches_mode_matching(): + _quiet() + T_mm, R_mm = _oracle() + errors = {} + for dx in (.05, .025): + result = _run(*_setup(dx, kind='joint')) + p2, q = result['p2'], result['q'] + T = float(_np(p2.forward_energy)[CENTRE, 0] / _np(p2.injected_energy)[CENTRE]) + R = float(_np(q.backward_energy)[CENTRE, 0] / _np(q.injected_energy)[CENTRE]) + errors[dx] = (abs(T - T_mm), abs(R - R_mm)) + print(f'S5 dx={dx}: T={T:.6f} (mode matching {T_mm:.6f}), R={R:.4e} (mode matching ' + f'{R_mm:.4e})') + assert errors[.05][0] <= 5e-3 and errors[.025][0] <= 2e-3, errors + assert errors[.025][0] < errors[.05][0], errors + assert errors[.025][1] <= .2 * R_mm and errors[.025][1] < errors[.05][1], errors + + +# --------------------------------------------------------------------------------------------- +# S6: backends +# --------------------------------------------------------------------------------------------- + +def _readings(result): + p2, q = result['p2'], result['q'] + return {'injected': _np(p2.injected), 'leakage': _np(p2.leakage), + 'forward': _np(p2.forward)[:, 0], 'backward': _np(q.backward)[:, 0]} + + +@pytest.mark.slow +def test_s6_backends_agree(straight): + import jax + jax.config.update('jax_enable_x64', True) + _quiet() + reference = _readings(straight[.05]) + scale = np.max(np.abs(reference['injected'])) + for backend in ('jax', 'torch'): + got = _readings(_run(*_setup(.05), backend=backend)) + for key, value in reference.items(): + error = float(np.max(np.abs(got[key] - value))) / scale + print(f'S6 {backend} {key}: {error:.2e}') + assert error <= 1e-12, (backend, key, error) + + +# --------------------------------------------------------------------------------------------- +# S7: derivatives +# --------------------------------------------------------------------------------------------- + +S7_POINT = {'amplitude': 1.3, 'phase': .4, 'delay': 2e-15, 'scatterer': 3.0} +S7_STEPS = {'amplitude': 1e-3, 'phase': 1e-3, 'delay': 1e-17, 'scatterer': 3e-4} + + +def _s7_outputs(point, backend): + """S7 as amended before computing (the record): a truncated run in a 5 um x 4 um domain, + so that reverse mode, which keeps every step, fits in memory. The derivatives and the + central differences are of this same function.""" + dx = .05 + structures = _core('slab') + (fdtd.Structure('block', shape=fdtd.SnappedBox(size=(.4, 1., .4), + center=(1.2, 0., -.25)), + material=Material(epsilon=point['scatterer'])),) + pulse = fdtd.GaussianPulse(wavelength=1.55, frequency_sigma_hz=2e13, cutoff=1e-3) + source = fdtd.ModeSource('src', plane=fdtd.Plane(z=-1.), pulse=pulse, direction='+z', mode=0, + amplitude=point['amplitude'], phase=point['phase'], + delay_seconds=point['delay']) + problem = fdtd.Problem( + units=Units(length='um'), + scene=fdtd.Scene(domain=fdtd.Domain(size=(5., dx, 4.)), background=Material(epsilon=EPS_CLAD), + boundaries=fdtd.Boundaries(x=fdtd.Open(), y=fdtd.InvariantAxis(), z=fdtd.Open()), + structures=structures), + sources=(source,), + outputs=fdtd.Outputs(monitors=(fdtd.ModeMonitor('p2', plane=fdtd.Plane(z=.5), wavelengths=(1.55,), + source='src'),))) + numerics = fdtd.Numerics(grid=fdtd.UniformGrid(spacing=dx), duration=fdtd.Duration(steps=1200), + absorber=fdtd.PML(cells=12, wavelength=1.55)) + p2 = fdtd.run(problem, numerics=numerics, backend=backend)['p2'] + return p2.forward_energy[0, 0], p2.forward[0, 0].real + + +def _central(name, which): + def at(step): + hi, lo = dict(S7_POINT), dict(S7_POINT) + hi[name] += step + lo[name] -= step + return (float(_s7_outputs(hi, 'numpy')[which]) + - float(_s7_outputs(lo, 'numpy')[which])) / (2 * step) + h = S7_STEPS[name] + return (4 * at(h / 2) - at(h)) / 3 + + +#: (parameter, output): 0 is forward_energy, 1 is Re forward. +S7_CASES = (('amplitude', 0), ('phase', 1), ('delay', 1), ('scatterer', 0)) + + +@pytest.mark.slow +def test_s7_derivatives_match_central_differences(): + _quiet() + import jax + import torch + jax.config.update('jax_enable_x64', True) + names = [name for name, _ in S7_CASES] + for name, which in S7_CASES: + reference = _central(name, which) + + def f(x, backend, name=name, which=which): + point = dict(S7_POINT) + point[name] = x + return _s7_outputs(point, backend)[which] + by_jax = float(jax.grad(lambda x: f(x, 'jax'))(S7_POINT[name])) + x = torch.tensor(S7_POINT[name], dtype=torch.float64, requires_grad=True) + f(x, 'torch').backward() + by_torch = float(x.grad) + print(f'S7 {name}: jax {by_jax:.10e}, torch {by_torch:.10e}, central {reference:.10e}') + for got in (by_jax, by_torch): + assert abs(got - reference) <= 1e-6 * abs(reference), (name, got, reference) + assert names == ['amplitude', 'phase', 'delay', 'scatterer'] + + +# --------------------------------------------------------------------------------------------- +# S8: refusals +# --------------------------------------------------------------------------------------------- + +def _refused(code, problem, numerics, backend='numpy'): + _quiet() + report = fdtd.check(problem, numerics=numerics, backend=backend) + assert [issue.code for issue in report.issues] == [code], report.issues + with pytest.raises(WavebitError) as info: + fdtd.run(problem, numerics=numerics, backend=backend) + assert info.value.code == code, (info.value.code, str(info.value)) + + +def test_s8_a_direction_off_the_normal_is_refused(): + _refused('source.direction', *_direction('+x')) + + +def _direction(direction): + problem, numerics = _setup(.05, periods=1) + source = problem.sources[0].replace(direction=direction) + return problem.replace(sources=(source,)), numerics + + +@pytest.mark.parametrize('mode', [-1, 1.5, True]) +def test_s8_a_mode_is_a_nonnegative_integer(mode): + with pytest.raises(WavebitError) as info: + fdtd.ModeSource('src', plane=fdtd.Plane(z=-2.), pulse=_pulse(), mode=mode) + assert info.value.code == 'source.mode' + + +def test_s8_a_mode_beyond_the_confined_ones_is_refused(): + problem, numerics = _setup(.05, periods=1) + problem = problem.replace(sources=(problem.sources[0].replace(mode=40),)) + _refused('mode.count', problem, numerics) + + +def test_s8_a_traced_structure_on_the_source_plane_is_refused(): + import torch + eps = torch.tensor(EPS_CORE, dtype=torch.float64, requires_grad=True) + _refused('mode.traced', *_setup(.05, periods=1, core_eps=eps), backend='torch') + + +def test_s8_a_plane_outside_the_domain_is_refused(): + problem, numerics = _setup(.05, periods=1) + problem = problem.replace(sources=(problem.sources[0].replace(plane=fdtd.Plane(z=5.)),)) + _refused('source.outside_domain', problem, numerics) + + +def test_s8_a_monitor_on_the_sheet_is_refused(): + monitors = (fdtd.ModeMonitor('m', plane=fdtd.Plane(z=-1.95), wavelengths=(1.55,)),) + _refused('monitor.source_sheet', *_setup(.05, periods=1, monitors=monitors)) + + +def test_s8_source_names_a_mode_source(): + monitors = (fdtd.ModeMonitor('m', plane=fdtd.Plane(z=1.), wavelengths=(1.55,), source='nope'),) + _refused('monitor.source', *_setup(.05, periods=1, monitors=monitors)) + with pytest.raises(WavebitError) as info: + fdtd.ModeMonitor('m', plane=fdtd.Plane(z=1.), wavelengths=(1.55,), source='') + assert info.value.code == 'monitor.source' + + +def test_s8_a_traced_pulse_wavelength_is_refused(): + import torch + lam = torch.tensor(1.55, dtype=torch.float64, requires_grad=True) + # A run length in periods of the source would read the wavelength first (and refuse it as a + # static input of preparation); in seconds, the mode source itself refuses it. + _refused('mode.traced', *_setup(.05, pulse=_pulse(lam), duration=fdtd.Duration(seconds=1e-15)), + backend='torch') + + +def test_s8_the_engine_placeholder_names_the_public_source(): + import wavebit.fdtd._legacy as fd + with pytest.raises(NotImplementedError, match='wavebit.fdtd.ModeSource'): + fd.ModeSource() diff --git a/tests/fdtd/test_fd_monitors_doors.py b/tests/fdtd/test_fd_monitors_doors.py index 6000c627..889a7b3b 100644 --- a/tests/fdtd/test_fd_monitors_doors.py +++ b/tests/fdtd/test_fd_monitors_doors.py @@ -160,8 +160,10 @@ def test_a_diffraction_monitor_names_its_plane_side_and_window(): def test_mode_monitors_and_decay_stopping_are_stage_7(): - with pytest.raises(NotImplementedError, match='stage 7'): + with pytest.raises(NotImplementedError, match='stage 7') as caught: fd.ModeMonitor('m') + # The engine surface does not have it; the message names the public one that does. + assert 'wavebit.fdtd.ModeMonitor' in str(caught.value) # Decay is now explicit, but both the ratio and physical window are required. with pytest.raises(TypeError, match="needs 'window'"): fd.Decay(1e-5) diff --git a/tests/fdtd/test_fd_waveguide_modes.py b/tests/fdtd/test_fd_waveguide_modes.py new file mode 100644 index 00000000..4dbbd195 --- /dev/null +++ b/tests/fdtd/test_fd_waveguide_modes.py @@ -0,0 +1,427 @@ +"""``fdtd.solve_modes``: modes of the discrete Yee system against independent answers. + +The criteria are the ones registered in ``docs/validation/fdtd/fd-waveguide-modes.md`` before +the solver was first run (M1-M6); every limit here is quoted from there. The oracles are +closed forms (the Yee dispersion of a uniform medium, the hollow metal guide on the grid), the +exact TE/TM dispersion relations of a symmetric slab, and the published dielectric-corner +benchmark of G. R. Hadley, J. Lightwave Technol. 20, 1210 (2002). +""" +import math +import warnings + +import numpy as np +import pytest +from scipy.optimize import brentq + +from wavebit import Material, Units, fdtd +from wavebit.errors import WavebitError +from wavebit.materials import ConductiveMaterial + + +def _quiet(): + warnings.simplefilter('ignore') + + +def _problem(domain, center, boundaries, structures=(), background=1.0): + return fdtd.Problem( + units=Units(length='um'), + scene=fdtd.Scene(domain=fdtd.Domain(size=domain, center=center), + background=Material(epsilon=background), boundaries=boundaries, + structures=tuple(structures))) + + +def _numerics(dx, wavelength=1.0): + return fdtd.Numerics(grid=fdtd.UniformGrid(spacing=dx), duration=fdtd.Duration(steps=1), + absorber=fdtd.PML(wavelength=wavelength)) + + +def _omega_d(problem, numerics, wavelength): + """``Omega = (2/dt) sin(omega dt/2)`` from the time step ``fdtd.check`` reports.""" + from wavebit.units import C0 + seconds = fdtd.check(problem, numerics=numerics).resolved['time_step_seconds'] + dt = seconds * C0 / problem.units.length_unit_meters + return 2.0 / dt * math.sin(math.pi / wavelength * dt) + + +# --------------------------------------------------------------------------------------------- +# M1: homogeneous control +# --------------------------------------------------------------------------------------------- + +@pytest.mark.parametrize('invariant', [False, True]) +def test_m1_homogeneous_medium_gives_its_index_and_the_yee_dispersion(invariant): + _quiet() + dx, lam = 0.05, 1.0 + y = fdtd.InvariantAxis() if invariant else fdtd.Periodic() + problem = _problem((0.6, dx if invariant else 0.4, 4 * dx), (0., 0., 0.), + fdtd.Boundaries(x=fdtd.Periodic(), y=y, z=fdtd.Periodic()), background=2.25) + modes = fdtd.solve_modes(problem, numerics=_numerics(dx), plane=fdtd.Plane(z=0.), + wavelength=lam, count=1) + Omega = _omega_d(problem, _numerics(dx), lam) + q = modes[0].metadata['q'] + assert abs(q / Omega - 1.5) <= 1e-12 * 1.5 + k0 = 2 * math.pi / lam + closed = (2.0 / (dx * k0)) * math.asin(1.5 * Omega * dx / 2.0) + assert abs(modes[0].effective_index - closed) <= 1e-12 * closed + + +# --------------------------------------------------------------------------------------------- +# M2: hollow metal guide +# --------------------------------------------------------------------------------------------- + +def _hollow(dx): + problem = _problem((2., 1., 4 * dx), (1., .5, 0.), + fdtd.Boundaries(x=fdtd.PEC(), y=fdtd.PEC(), z=fdtd.Periodic()), + background=2.25) + modes = fdtd.solve_modes(problem, numerics=_numerics(dx), plane=fdtd.Plane(z=0.), + wavelength=1.0, count=5) + return modes, _omega_d(problem, _numerics(dx), 1.0) + + +#: (m, n) of TE10, TE20, TE01, TE11 and TM11 in a 2 x 1 guide, sorted by decreasing index. +_HOLLOW = ((1, 0), (2, 0), (0, 1), (1, 1), (1, 1)) + + +def _hollow_q2(dx, Omega, a=2.0, b=1.0): + return sorted((Omega ** 2 * 2.25 - (2 / dx) ** 2 * (math.sin(m * math.pi * dx / (2 * a)) ** 2 + + math.sin(n * math.pi * dx / (2 * b)) ** 2) + for m, n in _HOLLOW), reverse=True) + + +def _hollow_continuum(lam=1.0, a=2.0, b=1.0): + return sorted((math.sqrt(2.25 - (lam / 2) ** 2 * ((m / a) ** 2 + (n / b) ** 2)) + for m, n in _HOLLOW), reverse=True) + + +def test_m2_hollow_metal_guide_matches_the_discrete_closed_form_and_converges(): + _quiet() + errors = [] + for dx in (0.05, 0.025, 0.0125): + modes, Omega = _hollow(dx) + q2 = [m.metadata['q'] ** 2 for m in modes] + exact = _hollow_q2(dx, Omega) + for got, want in zip(q2, exact): + assert abs(got - want) <= 1e-10 * abs(want) + errors.append([abs(m.effective_index - c) + for m, c in zip(modes, _hollow_continuum())]) + errors = np.array(errors) + order = np.log2(errors[:-1] / errors[1:]) + assert np.all(order >= 1.8), order + + +# --------------------------------------------------------------------------------------------- +# M3: symmetric slab against the exact dispersion relation +# --------------------------------------------------------------------------------------------- + +N1, N2, THICKNESS, LAM = 2.0, 1.444, 1.5, 1.55 + + +def _slab_exact(order, polarization): + """The exact effective index of mode ``order`` of the symmetric slab (TE or TM).""" + k0, a = 2 * math.pi / LAM, THICKNESS / 2 + r = 1.0 if polarization == 'TE' else (N1 / N2) ** 2 + + def f(n): + kappa = k0 * math.sqrt(N1 ** 2 - n ** 2) + gamma = k0 * math.sqrt(n ** 2 - N2 ** 2) + return 2 * kappa * a - order * math.pi - 2 * math.atan(r * gamma / kappa) + return brentq(f, N2 + 1e-12, N1 - 1e-12, xtol=1e-15, rtol=1e-15) + + +def _slab_problem(dx, width=9.): + return _problem((width, dx, 4 * dx), (0., 0., 0.), + fdtd.Boundaries(x=fdtd.Open(), y=fdtd.InvariantAxis(), z=fdtd.Periodic()), + structures=(fdtd.Structure('core', shape=fdtd.SnappedSlab(x=(-.75, .75)), + material=Material(epsilon=N1 ** 2)),), + background=N2 ** 2) + + +def _slab_modes(dx): + return fdtd.solve_modes(_slab_problem(dx), numerics=_numerics(dx, LAM), plane=fdtd.Plane(z=0.), + wavelength=LAM, count=6) + + +def _split(modes): + """TE (E along the invariant y) and TM modes, each by decreasing index.""" + te = [m for m in modes if m.polarization['y'] > 0.5] + tm = [m for m in modes if m.polarization['y'] <= 0.5] + return te, tm + + +def test_m3_slab_converges_to_the_exact_te_and_tm_dispersion(): + _quiet() + exact = [_slab_exact(m, 'TE') for m in range(3)] + [_slab_exact(m, 'TM') for m in range(3)] + errors, signed = [], [] + for dx in (0.05, 0.025, 0.0125): + modes = _slab_modes(dx) + te, tm = _split(modes) + assert len(te) == 3 and len(tm) == 3 + got = [m.effective_index for m in te + tm] + signed.append([g - e for g, e in zip(got, exact)]) + errors.append([abs(g - e) for g, e in zip(got, exact)]) + # Orthogonality and normalization at every spacing. + _check_orthonormal(modes, dx) + errors, signed = np.array(errors), np.array(signed) + order = np.log2(errors[:-1] / errors[1:]) + assert np.all(order >= 1.8), order + assert np.all(errors[-1] <= 1e-3), errors[-1] + richardson = (4 * (signed[-1] + np.array(exact)) - (signed[-2] + np.array(exact))) / 3 + assert np.all(np.abs(richardson - np.array(exact)) <= 1e-4), richardson - np.array(exact) + + +def _check_orthonormal(modes, dx): + """Cross product of distinct modes <= 1e-10 of their norms; flux power 1 to 1e-12. + + The plane is across z, so u = x and v = y; y is invariant, so the power is per metre of y + and each sample stands for dx of x. H is in A/m and E in V/m. + """ + L = 1e-6 + for i, a in enumerate(modes): + Ex, Ey = np.asarray(a.E['Ex']), np.asarray(a.E['Ey']) + Hx, Hy = np.asarray(a.H['Hx']), np.asarray(a.H['Hy']) + cos = math.cos(a.beta_per_meter * L * dx / 2) + power = 0.5 * np.sum(np.real(Ex * np.conj(Hy) - Ey * np.conj(Hx))) * dx * L * cos + assert abs(power - 1.0) <= 1e-12 + for b in modes[i + 1:]: + Hbx, Hby = np.asarray(b.H['Hx']), np.asarray(b.H['Hy']) + cross = np.sum(Ex * Hby - Ey * Hbx) + na = math.sqrt(np.sum(np.abs(Ex) ** 2 + np.abs(Ey) ** 2)) + nb = math.sqrt(np.sum(np.abs(Hbx) ** 2 + np.abs(Hby) ** 2)) + assert abs(cross) <= 1e-10 * na * nb + + +# --------------------------------------------------------------------------------------------- +# M4: Hadley's dielectric corners +# --------------------------------------------------------------------------------------------- + +#: (core eps, cladding eps, PEC sides, published n_eff): Hadley 2002, stated error < 1e-8. +_CORNERS = ((2.25, 1.0, ('left', 'right'), 1.27627404), + (8.0, 1.0, ('left', 'right'), 2.65679692), + (1.0, 2.25, ('left', 'top'), 1.387926425), + (1.0, 8.0, ('left', 'top'), 2.761465320)) + + +def _corner_modes(core, clad, pec, N): + """The unit square with its walls on the square's edges, at the spacing that puts them there. + + A PEC wall sits on its face; a PMC wall half a cell outside a low face and half a cell + inside a high one (``src/wavebit/fdtd/_kernel/faces.py``). With ``s = 1/2`` for a PMC side and 0 for PEC, the + low wall is at ``lo - s_low dx`` and the high one at ``lo + n dx - s_high dx``; putting them + at 0 and 1 gives ``lo = s_low dx`` and ``dx = 1 / (n + s_low - s_high)``. ``N`` cells along x + fix ``dx``; y then needs a whole number of cells, which both benchmark layouts give. + """ + edges = {'left': 'x-', 'right': 'x+', 'bottom': 'y-', 'top': 'y+'} + pmc = {face: side not in pec for side, face in edges.items()} + s = {face: (0.5 if is_pmc else 0.0) for face, is_pmc in pmc.items()} + dx = 1.0 / (N + s['x-'] - s['x+']) + ny = 1.0 / dx - s['y-'] + s['y+'] + assert abs(ny - round(ny)) < 1e-9 + ny = int(round(ny)) + low = (s['x-'] * dx, s['y-'] * dx) + size = (N * dx, ny * dx) + center = (low[0] + size[0] / 2, low[1] + size[1] / 2, 0.) + face = lambda f: fdtd.PMC() if pmc[f] else fdtd.PEC() # noqa: E731 + problem = _problem((size[0], size[1], 4 * dx), center, + fdtd.Boundaries(x=(face('x-'), face('x+')), y=(face('y-'), face('y+')), + z=fdtd.Periodic()), + structures=(fdtd.Structure('core', shape=fdtd.Box(size=(1.5, 1.5, 1.), + center=(-.25, -.25, 0.)), + material=Material(epsilon=core)),), + background=clad) + modes = fdtd.solve_modes(problem, numerics=_numerics(dx, 1.5), plane=fdtd.Plane(z=0.), + wavelength=1.5, count=1) + return modes, dx + + +@pytest.mark.parametrize('case', _CORNERS, ids=['2.25-1-LR', '8-1-LR', '1-2.25-LT', '1-8-LT']) +def test_m4_dielectric_corners_reach_hadleys_published_index(case): + _quiet() + core, clad, pec, reference = case + errors = [] + for N in (40, 80, 160): + modes, dx = _corner_modes(core, clad, pec, N) + errors.append(abs(modes[0].effective_index - reference)) + assert errors[0] > errors[1] > errors[2], errors + assert errors[2] <= 1e-3, errors + + +# --------------------------------------------------------------------------------------------- +# M5: refusals by name +# --------------------------------------------------------------------------------------------- + +def _guide(dx=0.05, *, core=None, boundaries=None, background=1.0, structures=None): + """A 1 x 0.5 core of epsilon 4 in a 3 x 3 PEC box, propagating along z.""" + core = core if core is not None else Material(epsilon=4.0) + if structures is None: + structures = (fdtd.Structure('core', shape=fdtd.SnappedBox(size=(1., .5, 10.)), + material=core),) + return _problem((3., 3., 0.4), (0., 0., 0.), + boundaries or fdtd.Boundaries(x=fdtd.PEC(), y=fdtd.PEC(), z=fdtd.Periodic()), + structures=structures, background=background) + + +def _solve(problem, dx=0.05, **kw): + kw.setdefault('plane', fdtd.Plane(z=0.)) + kw.setdefault('wavelength', 1.0) + return fdtd.solve_modes(problem, numerics=_numerics(dx), **kw) + + +def _refused(code, problem, **kw): + _quiet() + with pytest.raises(WavebitError) as info: + _solve(problem, **kw) + assert info.value.code == code, (info.value.code, str(info.value)) + return info.value + + +def test_m5_the_guide_itself_is_admitted(): + _quiet() + modes = _solve(_guide(), count=2) + assert len(modes) == 2 and modes[0].effective_index > modes[1].effective_index > 1.0 + + +def test_m5_conductive_or_dispersive_medium_in_the_window_is_refused(): + from wavebit.materials import DispersiveMaterial, Lorentz + _refused('mode.material', _guide(core=ConductiveMaterial(epsilon=4.0, conductivity=1e3))) + lorentz = DispersiveMaterial(epsilon_inf=2.0, poles=(Lorentz(strength=1.0, omega_0=3e15, gamma=1e13),)) + _refused('mode.material', _guide(core=lorentz)) + + +def test_m5_a_medium_that_changes_along_the_axis_is_refused(): + ends = (fdtd.Structure('core', shape=fdtd.SnappedBox(size=(1., .5, .2), center=(0., 0., .1)), + material=Material(epsilon=4.0)),) + error = _refused('mode.nonuniform', _guide(structures=ends)) + assert 'uniform' in str(error) + + +@pytest.mark.parametrize('face', [fdtd.Bloch(phase=0.3), fdtd.MirrorSymmetry(parity=1)], + ids=['Bloch', 'MirrorSymmetry']) +def test_m5_bloch_and_mirror_transverse_faces_are_refused(face): + boundaries = fdtd.Boundaries(x=face if isinstance(face, fdtd.Bloch) else (face, fdtd.PEC()), + y=fdtd.PEC(), z=fdtd.Periodic()) + _refused('mode.boundary', _guide(boundaries=boundaries)) + + +def test_m5_invariant_axis_with_a_wavevector_is_refused(): + boundaries = fdtd.Boundaries(x=fdtd.PEC(), y=fdtd.InvariantAxis(k=0.5), z=fdtd.Periodic()) + slab = (fdtd.Structure('core', shape=fdtd.SnappedSlab(x=(-.5, .5)), material=Material(epsilon=4.)),) + problem = _problem((3., .05, .4), (0., 0., 0.), boundaries, structures=slab) + _refused('mode.boundary', problem) + + +def test_m5_coupled_smoothed_material_is_refused(): + rod = (fdtd.Structure('core', shape=fdtd.Cylinder(radius=.4, height=10.), material=Material(epsilon=4.)),) + _refused('mode.material', _guide(structures=rod)) + + +def test_m5_traced_inputs_are_refused_by_name(): + import jax + import torch + jax.config.update('jax_enable_x64', True) + + def through_jax(eps): + return _solve(_guide(core=Material(epsilon=eps)))[0].effective_index + with pytest.raises(WavebitError) as info: + jax.grad(through_jax)(4.0) + assert info.value.code == 'mode.traced' + eps = torch.tensor(4.0, dtype=torch.float64, requires_grad=True) + _refused('mode.traced', _guide(core=Material(epsilon=eps))) + _refused('mode.traced', _guide(), wavelength=torch.tensor(1.0, dtype=torch.float64, requires_grad=True)) + + +def test_m5_plane_outside_or_across_an_invariant_axis_is_refused(): + _refused('mode.outside_domain', _guide(), plane=fdtd.Plane(z=0.5)) + boundaries = fdtd.Boundaries(x=fdtd.PEC(), y=fdtd.PEC(), z=fdtd.InvariantAxis()) + problem = _problem((3., 3., .05), (0., 0., 0.), boundaries) + _refused('mode.plane', problem) + + +def test_m5_more_confined_modes_than_exist_are_refused(): + # An Open box: only the guided modes are confined; the slab of M3 guides three of each. + error = _refused('mode.count', _slab_problem(0.05), plane=fdtd.Plane(z=0.), wavelength=LAM, + count=7) + assert 'found 6 confined mode(s)' in str(error) + + +def test_m5_a_mode_reaching_a_truncating_edge_is_not_admitted(): + # The same slab in a window too narrow for its third TM mode's tail. + problem = _slab_problem(0.05, width=3.0) + error = _refused('mode.count', problem, plane=fdtd.Plane(z=0.), wavelength=LAM, count=6) + assert 'outermost cells' in str(error) + + +# --------------------------------------------------------------------------------------------- +# M6: backends +# --------------------------------------------------------------------------------------------- + +def test_m6_backends_return_the_same_bits(): + import jax + jax.config.update('jax_enable_x64', True) + _quiet() + results = {b: _solve(_guide(), count=2, backend=b) for b in ('numpy', 'jax', 'torch')} + for b in ('jax', 'torch'): + assert type(results[b][0].E['Ex']).__module__.split('.')[0] in ('jax', 'jaxlib', 'torch') + for ref, got in zip(results['numpy'], results[b]): + assert got.effective_index == ref.effective_index + for name in ref.E: + np.testing.assert_array_equal(np.asarray(got.E[name]), ref.E[name]) + np.testing.assert_array_equal(np.asarray(got.H['H' + name[1]]), ref.H['H' + name[1]]) + + +# --------------------------------------------------------------------------------------------- +# M7: the mode is a mode of the run +# --------------------------------------------------------------------------------------------- + +def _bloch_guide(kind, dx, k): + """The M7 guides with a Bloch propagation axis of eight cells and wavevector ``k``.""" + z = fdtd.Bloch(k=k) if k else fdtd.Periodic() + if kind in ('box', 'smoothed'): + shape = (fdtd.SnappedBox(size=(1., .5, 10.)) if kind == 'box' + else fdtd.Box(size=(1.03, .51, 10.))) + return _problem((3., 3., 8 * dx), (0., 0., 0.), + fdtd.Boundaries(x=fdtd.PEC(), y=fdtd.PEC(), z=z), + structures=(fdtd.Structure('core', shape=shape, + material=Material(epsilon=4.0)),)) + return _problem((9., dx, 8 * dx), (0., 0., 0.), + fdtd.Boundaries(x=fdtd.PEC(), y=fdtd.InvariantAxis(), z=z), + structures=(fdtd.Structure('core', shape=fdtd.SnappedSlab(x=(-.75, .75)), + material=Material(epsilon=N1 ** 2)),), + background=N2 ** 2) + + +@pytest.mark.parametrize('kind,lam', [('box', 1.0), ('slab', LAM), ('smoothed', 1.0)]) +@pytest.mark.parametrize('index', [0, 1]) +def test_m7_a_solved_mode_is_an_eigenmode_of_the_time_stepper(kind, lam, index): + _quiet() + from wavebit.units import C0 + dx, steps = 0.05, 100 + modes = fdtd.solve_modes(_bloch_guide(kind, dx, 0.0), numerics=_numerics(dx, lam), + plane=fdtd.Plane(z=0.), wavelength=lam, count=2) + mode = modes[index] + beta = mode.beta_per_meter * 1e-6 # rad per um + problem = _bloch_guide(kind, dx, beta) + numerics = fdtd.Numerics(grid=fdtd.UniformGrid(spacing=dx), duration=fdtd.Duration(steps=steps), + absorber=fdtd.PML(wavelength=lam)) + report = fdtd.check(problem, numerics=numerics) + dt = report.resolved['time_step_seconds'] * C0 / 1e-6 # in um + omega = 2 * math.pi / lam + from wavebit.fdtd._kernel.grid import OFFSETS + nz = 8 + zlow = -4 * dx + grid_shape = (3, nz) + np.shape(mode.E['Ex']) + + def volume(field, values): + out = np.zeros(grid_shape, dtype=complex) + for c, axis in enumerate('xyz'): + z = zlow + (np.arange(nz) + 0.5 * OFFSETS[field][c][2]) * dx + out[c] = np.asarray(values[field + axis])[None] * np.exp(1j * beta * z)[:, None, None] + return out + E0, H0 = volume('E', mode.E), volume('H', mode.H) + initial = fdtd.InitialField(E=E0, H=H0 * np.exp(1j * omega * dt / 2)) + run = fdtd.run(problem.replace(initial=initial, outputs=fdtd.Outputs(final_fields=True)), + numerics=numerics) + final = run.final_fields.values + expected_E = E0 * np.exp(-1j * omega * steps * dt) + expected_H = H0 * np.exp(-1j * omega * (steps - 0.5) * dt) + for got, want in ((np.asarray(final['E']), expected_E), (np.asarray(final['H']), expected_H)): + scale = np.max(np.abs(want)) + assert np.max(np.abs(got - want)) <= 1e-10 * scale, np.max(np.abs(got - want)) / scale diff --git a/tests/fdtd/test_fd_waveguide_pml.py b/tests/fdtd/test_fd_waveguide_pml.py new file mode 100644 index 00000000..cf6eae08 --- /dev/null +++ b/tests/fdtd/test_fd_waveguide_pml.py @@ -0,0 +1,180 @@ +"""A cell-snapped waveguide core continued through an absorbing layer. + +Criteria D1-D3 of ``docs/validation/fdtd/fd-waveguide-through-pml.md``, registered before the +door was first run; every limit here is quoted from there. +""" +import warnings + +import numpy as np +import pytest + +from wavebit import Material, Units, fdtd +from wavebit.errors import WavebitError + + +def _quiet(): + warnings.simplefilter('ignore') + + +def _run(problem, numerics): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always') + result = fdtd.run(problem, numerics=numerics) + return result, [type(w.message).__name__ for w in caught] + + +def _reflection(result, name='m'): + m = result[name] + return float(np.asarray(m.backward_energy)[0, 0] / np.asarray(m.forward_energy)[0, 0]) + + +# --------------------------------------------------------------------------------------------- +# D1: slab waveguide +# --------------------------------------------------------------------------------------------- + +def _slab(cells, periods=400, initial=True): + """D1, launched by an initial field (a CurrentDipole is refused on an InvariantAxis): + the TE0 mode at 1.55 um times a Gaussian-windowed carrier along z, centred at z = -2.""" + problem = fdtd.Problem( + units=Units(length='um'), + scene=fdtd.Scene( + domain=fdtd.Domain(size=(9., .05, 8.)), background=Material(epsilon=2.085136), + boundaries=fdtd.Boundaries(x=fdtd.Open(), y=fdtd.InvariantAxis(), z=fdtd.Open()), + structures=(fdtd.Structure('core', shape=fdtd.SnappedSlab(x=(-.75, .75)), + material=Material(epsilon=4.), continuation=('z-', 'z+')),)), + outputs=fdtd.Outputs(monitors=( + fdtd.ModeMonitor('m', plane=fdtd.Plane(z=2.), wavelengths=(1.55,), modes=1),))) + numerics = fdtd.Numerics(grid=fdtd.UniformGrid(spacing=.05), + duration=fdtd.Duration(seconds=periods * 1.55e-6 / 299792458.0), + absorber=fdtd.PML(cells=cells, wavelength=1.55)) + if initial: + modes = fdtd.solve_modes(problem, numerics=numerics, plane=fdtd.Plane(z=2.), + wavelength=1.55, count=1) + beta = modes[0].beta_per_meter * 1e-6 + profile = np.asarray(modes[0].E['Ey'])[0].real # (nx,) on the window's Ey nodes + xs = np.asarray(modes.coordinates['Ey']['x']) + + def ey(x, y, z): + shape = np.interp(x, xs, profile, left=0.0, right=0.0) + return shape * np.exp(-(z + 2.) ** 2 / (2 * .6 ** 2)) * np.cos(beta * (z + 2.)) + problem = problem.replace(initial=fdtd.InitialField(E=(None, ey, None))) + return problem, numerics + + +@pytest.mark.slow +def test_d1_slab_core_through_the_pml_reflects_little_and_stays_stable(): + _quiet() + r12, caught = _run(*_slab(12)) + assert 'GrowingFieldWarning' not in caught, caught + r24, _ = _run(*_slab(24)) + reflection12, reflection24 = _reflection(r12), _reflection(r24) + assert reflection12 <= 1e-5, reflection12 + assert reflection24 < reflection12, (reflection24, reflection12) + + +# --------------------------------------------------------------------------------------------- +# D2: rectangular core +# --------------------------------------------------------------------------------------------- + +def _rectangle(cells, periods=60): + problem = fdtd.Problem( + units=Units(length='um'), + scene=fdtd.Scene( + domain=fdtd.Domain(size=(3., 2.5, 5.)), boundaries=fdtd.Boundaries.open(), + structures=(fdtd.Structure('core', shape=fdtd.SnappedBox(size=(1., .5, 10.)), + material=Material(epsilon=4.), continuation=('z-', 'z+')),)), + sources=(fdtd.CurrentDipole('d', center=(0., 0., -1.), component='Ex', + pulse=fdtd.GaussianPulse(wavelength=1., frequency_sigma_hz=1.5e13)),), + outputs=fdtd.Outputs(monitors=( + fdtd.ModeMonitor('m', plane=fdtd.Plane(z=1.), wavelengths=(1.,), modes=1),))) + numerics = fdtd.Numerics(grid=fdtd.UniformGrid(spacing=.05), + duration=fdtd.Duration(periods=periods, source='d'), + absorber=fdtd.PML(cells=cells)) + return problem, numerics + + +@pytest.mark.slow +def test_d2_rectangular_core_through_the_pml_reflects_little(): + _quiet() + r12, _ = _run(*_rectangle(12)) + r24, _ = _run(*_rectangle(24)) + reflection12, reflection24 = _reflection(r12), _reflection(r24) + assert reflection12 <= 1e-5, reflection12 + assert reflection24 < reflection12, (reflection24, reflection12) + + +# --------------------------------------------------------------------------------------------- +# D3: refusals +# --------------------------------------------------------------------------------------------- + +def _refused(structure, boundaries=None, match=None): + _quiet() + problem, numerics = _rectangle(12, periods=1) + scene = problem.scene.replace(structures=(structure,)) + if boundaries is not None: + scene = scene.replace(boundaries=boundaries) + with pytest.raises(WavebitError) as info: + fdtd.run(problem.replace(scene=scene), numerics=numerics) + assert info.value.code == 'fdtd.padding_continuation', (info.value.code, str(info.value)) + assert match is None or match in str(info.value), str(info.value) + + +def test_d3_a_smoothed_core_cannot_continue(): + _refused(fdtd.Structure('core', shape=fdtd.Box(size=(1., .5, 10.)), material=Material(epsilon=4.), + continuation=('z-', 'z+'))) + + +def test_d3_a_continued_core_touching_another_open_face_is_refused(): + _refused(fdtd.Structure('core', shape=fdtd.SnappedBox(size=(1., 4., 10.)), material=Material(epsilon=4.), + continuation=('z-', 'z+'))) + + +def test_d3_continuation_through_a_face_without_padding_is_refused(): + boundaries = fdtd.Boundaries(x=fdtd.Open(), y=fdtd.Open(), z=(fdtd.PEC(), fdtd.Open())) + _refused(fdtd.Structure('core', shape=fdtd.SnappedBox(size=(1., .5, 10.)), material=Material(epsilon=4.), + continuation=('z-', 'z+')), boundaries) + + +@pytest.mark.parametrize('center', [.5, -.75, .75]) +def test_d3_a_continued_core_must_reach_its_face(center): + # Reaching neither face, then only z- (it ends at z = 2.25 of 2.5), then only z+. + size = 3. if center == .5 else 4.5 + _refused(fdtd.Structure('core', shape=fdtd.SnappedBox(size=(1., .5, size), center=(0., 0., center)), + material=Material(epsilon=4.), continuation=('z-', 'z+'))) + + +def test_d3_a_continued_core_runs_along_one_axis(): + _refused(fdtd.Structure('core', shape=fdtd.SnappedBox(size=(1., .5, 10.)), material=Material(epsilon=4.), + continuation=('z-', 'x+')), match='one axis only') + + +def test_d3_a_continued_core_runs_through_the_padding(): + # The lowered core keeps its cross-section and spans the allocated domain along z. + from wavebit.fdtd._lower_public import lower_problem + _quiet() + scene, _ = lower_problem(*_rectangle(12, periods=1)) + core = scene.structures[0].shape + assert core.size[:2] == (1., .5) and core.center[2] == 0., core + assert core.size[2] > scene.domain.size[2], (core, scene.domain) + + +def test_d3_the_registered_scenes_are_admitted(): + # The scenes of D1 and D2 pass check and prepare (the engine's absorbing-layer rule + # included), so a refusal above is the rule it names and not a broken scene. + _quiet() + for problem, numerics in (_slab(12, periods=1, initial=False), _rectangle(12, periods=1)): + assert fdtd.check(problem, numerics=numerics).ok + fdtd.prepare(problem, numerics=numerics) + + +def test_d3_a_slab_does_not_continue_through_its_own_axis(): + boundaries = fdtd.Boundaries(x=fdtd.Open(), y=fdtd.InvariantAxis(), z=fdtd.Open()) + problem, numerics = _slab(12, periods=1) + scene = problem.scene.replace(structures=(fdtd.Structure( + 'core', shape=fdtd.SnappedSlab(x=(-.75, .75)), material=Material(epsilon=4.), + continuation=('x-', 'z+')),), boundaries=boundaries) + _quiet() + with pytest.raises(WavebitError) as info: + fdtd.run(problem.replace(scene=scene), numerics=numerics) + assert info.value.code == 'fdtd.padding_continuation' + assert 'its own axis' in str(info.value), str(info.value) diff --git a/tests/fdtd/test_public_source_admission.py b/tests/fdtd/test_public_source_admission.py index 3c927061..6892b564 100644 --- a/tests/fdtd/test_public_source_admission.py +++ b/tests/fdtd/test_public_source_admission.py @@ -52,7 +52,7 @@ def _refuse_at_all_doors(valid, bad, numerics, path, message): def test_unknown_source_is_refused_before_reading_its_name(source): valid = _problem() _refuse_at_all_doors(valid, valid.replace(sources=(source,)), _numerics('periods'), - 'sources[0]', 'expected PlaneWave, CurrentDipole or TFSF') + 'sources[0]', 'expected PlaneWave, CurrentDipole, TFSF, ModeSource or GaussianBeam') @pytest.mark.parametrize('name', [None, 1, [], '']) diff --git a/tests/references/README.md b/tests/references/README.md index 4037a020..2978f035 100644 --- a/tests/references/README.md +++ b/tests/references/README.md @@ -134,6 +134,7 @@ the committed numbers. [The star lift](../../docs/validation/fdtd/fd-subpixel-oc | **Meep** + **FDTDX**, **CPML and electric ADE** | four dispersive slab models at two resolutions, a low-loss mixed metal, and FDTDX two-pole updates with the oscillator-strength derivative | the same interpreters; frozen by `interface/freeze_grid_material_references.py` | `golden_native_material_external.npz`, `golden_low_loss_metal_meep.json`, `test_native_material_external.py`, `interface/validate_grid_dispersive.py`, `interface/validate_grid_fdtdx_ade.py` | | **FDTDX**, **complex Bloch boundaries** | three-axis phase advance, real and imaginary E/H, and the material derivative | FDTDX interpreter; `interface/freeze_grid_references.py --kind bloch` | `golden_native_bloch_external.npz`, `test_native_bloch_external.py`, `interface/validate_grid_fdtdx_bloch.py` | | **Meep**, **bottom-incident scene** | two-layer complex r/t with the source physically below the stack | Meep interpreter, public API only | `golden_scene_bottom_meep.json`, `test_scene_bottom_meep.py`, `interface/validate_scene_bottom_meep.py` | +| **Meep** + **FDTDX**, **stage-7 modes and beams** | the slab mode index (reported and propagated), the one-way mode launch, the butt joint against mode matching, a core run into the absorber, and the Gaussian beam's width, Gouy phase and back-radiation, beside Wavebit's own numbers. Evidence printed in `docs/validation/fdtd/fd-mode-source.md` and `fd-gaussian-beam.md`, not acceptance | separate Meep 1.35.0 and FDTDX 0.6.3 processes from inputs in physical units; public APIs only | `golden_stage7_cross_solver.json`, `test_stage7_cross_solver_golden.py` | One more oracle lives outside this directory: the Berreman 4x4 in `tests/rcwa/test_anisotropic_berreman.py` is an independent *formulation* — not RCWA at all — but our diff --git a/tests/references/golden_stage7_cross_solver.json b/tests/references/golden_stage7_cross_solver.json new file mode 100644 index 00000000..7af48c96 --- /dev/null +++ b/tests/references/golden_stage7_cross_solver.json @@ -0,0 +1,181 @@ +{ + "fdtdx__beam_back_per_forward": { + "0.025": [ + 2.066802187816107e-05, + 2.5501823352807373e-05, + 3.116773670854196e-05 + ], + "0.05": [ + 2.1362958125979126e-05, + 2.6288066992056938e-05, + 3.204478188836089e-05 + ] + }, + "fdtdx__beam_radius_2um_ratio_minus_1": { + "0.025": -0.004774746208880187, + "0.05": -0.004163909856992554 + }, + "fdtdx__gouy_max_error_rad": { + "0.025": 0.0023314843950566766, + "0.05": 0.0037491713456228593 + }, + "fdtdx__joint_R": { + "0.025": 0.0002569930761477128, + "0.05": 0.0001059522884784509 + }, + "fdtdx__joint_T": { + "0.025": 0.9550434419585734, + "0.05": 0.9550067930153955 + }, + "fdtdx__leakage_power": { + "0.025": [ + 1.3863869885434894e-06, + 6.399996218857093e-09, + 1.4203458484232527e-06 + ], + "0.05": [ + 2.5150756065528255e-06, + 4.334490399269849e-07, + 1.5546135472933902e-06 + ] + }, + "fdtdx__n_run": { + "0.025": 1.9594081354072241, + "0.05": 1.9691504186881243 + }, + "fdtdx__neff_te_reported_error": { + "0.0125": 1.1183007818882373e-05, + "0.025": 4.467500407612235e-05, + "0.05": 0.00017779527793360117 + }, + "fdtdx__pml_reflection": { + "0.05": { + "0.6": 9.572829175378516e-05, + "1.2": 9.61421112431095e-05 + } + }, + "meep__beam_back_per_forward": { + "0.025": [ + 0.00011209002701727198, + 0.00011077169359190604, + 0.00011088901080357368 + ], + "0.05": [ + 0.0008609222821765347, + 0.0008014129000163194, + 0.000753743485033127 + ] + }, + "meep__beam_waist_ratio_minus_1": { + "0.025": 0.008578189408063652, + "0.05": 0.008593152010550442 + }, + "meep__focal_peak_um": { + "0.025": 1.3247574567687046, + "0.05": 1.2605352165382067 + }, + "meep__gouy_max_error_rad": { + "0.025": 0.004788728716554724, + "0.05": 0.003570519559339791 + }, + "meep__joint_R": { + "0.0125": 0.00035689316423918285, + "0.025": 0.00042030082343012706, + "0.05": 0.0007124565783353928 + }, + "meep__joint_T": { + "0.0125": 0.9546488032695836, + "0.025": 0.9540452657276497, + "0.05": 0.9522827689953248 + }, + "meep__leakage_power": { + "0.025": [ + 8.321385337999448e-05, + 9.167656872299823e-05, + 0.00010333188489430836 + ], + "0.05": [ + 0.0013691926176472736, + 0.001287152009501932, + 0.001230068732281811 + ] + }, + "meep__n_run": { + "0.025": 1.9588165156880784, + "0.05": 1.9675127119252511 + }, + "meep__neff_te_reported_error": { + "0.0125": -0.0002579028413878248, + "0.025": -0.0005586698827828585, + "0.05": -0.0012822278789508257 + }, + "meep__pml_reflection": { + "0.025": { + "0.6": 6.259120643083086e-06, + "1.2": 6.275715110908659e-06 + }, + "0.05": { + "0.6": 0.00010053395929930794, + "1.2": 0.00010195793480058657 + } + }, + "wavebit__beam_back_per_forward": { + "0.025": [ + 2.5e-07, + 8.5e-14, + 2.8e-07 + ], + "0.05": [ + 2.6e-07, + 1.4e-13, + 2.8e-07 + ] + }, + "wavebit__beam_waist_ratio_minus_1": { + "0.025": -7.2e-09, + "0.05": 1.07e-08 + }, + "wavebit__focal_peak_um": { + "0.025": 1.37084, + "0.05": 1.34846 + }, + "wavebit__gouy_max_error_rad": { + "0.025": 0.000542, + "0.05": 0.0022 + }, + "wavebit__joint_R": { + "0.025": 0.00033777, + "0.05": 0.00035366 + }, + "wavebit__joint_T": { + "0.025": 0.954959, + "0.05": 0.954677 + }, + "wavebit__leakage_power": { + "0.025": [ + 1.3e-06, + 1.2e-29, + 1.4e-06 + ], + "0.05": [ + 1.4e-06, + 6.8e-29, + 1.5e-06 + ] + }, + "wavebit__n_run": { + "0.025": 1.959408135484, + "0.05": 1.969150425958 + }, + "wavebit__neff_te_reported_error": { + "0.0125": 0.0007948501310277578, + "0.025": 0.003189941701202237, + "0.05": 0.012932232175388414 + }, + "wavebit__pml_reflection_d1": { + "0.05": { + "0.6": 4.3e-08, + "1.2": 3.5e-16 + } + } +} diff --git a/tests/references/golden_stage7_cross_solver.reference.json b/tests/references/golden_stage7_cross_solver.reference.json new file mode 100644 index 00000000..ec079a0e --- /dev/null +++ b/tests/references/golden_stage7_cross_solver.reference.json @@ -0,0 +1,181 @@ +{ + "schema": "wavebit.reference-values/1", + "case": "golden_stage7_cross_solver", + "physics": "fdtd", + "comparison": "different-discretization", + "role": "stage-7 cross-solver comparison (mode solver, one-way mode launch, butt joint, absorber, Gaussian beam): evidence printed in fd-mode-source.md and fd-gaussian-beam.md, not acceptance", + "references": [ + { + "tool": "Meep", + "version": "1.35.0", + "build": "pymeep conda-forge nompi_py312h7a316d3_100 (no MPI), MPB 1.12.0, NumPy 2.5.4", + "modified": { + "value": false, + "description": "the conda-forge build, unmodified" + }, + "spdx": "GPL-2.0-or-later", + "source": "https://github.com/NanoComp/meep", + "install": { + "kind": "conda", + "command": "micromamba create -n meep -c conda-forge pymeep=1.35.0=nompi*" + }, + "settings": "public API only. Propagation along x, transverse y (Wavebit z -> x, x -> y); TE is Ez with ODD_Z symmetry; Courant 0.5; PML(1.2) with the default profile; EigenModeSource (MPB mode) and get_eigenmode_coefficients; GaussianBeamSource with the focus at the origin. Generator script sha256 817a5fbc9ac364e7858a8f26664a792b3c2b50dc5df53f1a5b8d7ee383d8b8de, shared case definitions e0f3fed65d0f56a388cfedbe2b23a432958e5246bf0d396552b7dc13720c6e62", + "discretization": "2-D cell, resolutions 20, 40 and 80 per um (0.05, 0.025, 0.0125 um), default subpixel averaging", + "arrays": [ + "meep__*" + ], + "execution": "separate-process", + "independence": { + "product_loaded_in_reference_process": false, + "reference_inputs_built_from_product_objects": false + } + }, + { + "tool": "FDTDX", + "version": "0.6.3", + "build": "pip wheel, JAX 0.12.0.1 on the CPU with float64, NumPy 2.4.6; its mode solver calls tidy3d compute_modes", + "modified": { + "value": false, + "description": "the released wheel, unmodified" + }, + "spdx": "MIT", + "source": "https://github.com/ymahlau/fdtdx", + "install": { + "kind": "pip", + "command": "python -m pip install fdtdx==0.6.3" + }, + "settings": "public API. Propagation along x, transverse y, the invariant axis two periodic cells; TE is filter_pol=\"tm\" (FDTDX names the mode by its first transverse E component); courant_factor 0.5; default PML (kappa 1) with extend_material_to_pml; ModePlaneSource and ModeOverlapDetector (conjugated overlap); GaussianPlaneSource, whose focus is its own plane, so the beam row is a variant with the focus on the source plane (width 2 um past it, Gouy phase from 0.5 to 3 um) and the focal case was not run. Generator script sha256 df86676088a50b8aa6f8234f3a19032f41c7d09c22943d59e52eed4dc75f2845", + "discretization": "uniform Yee grid 0.05, 0.025 and 0.0125 um (mode solver only at 0.0125), cell-snapped core", + "arrays": [ + "fdtdx__*" + ], + "execution": "separate-process", + "independence": { + "product_loaded_in_reference_process": false, + "reference_inputs_built_from_product_objects": false + } + }, + { + "tool": "wavebit", + "version": "0.0.4", + "build": "source checkout at the product commit below, NumPy backend, float64", + "modified": { + "value": false, + "description": "this repository" + }, + "spdx": "Apache-2.0 AND MIT", + "source": "https://github.com/fieldalgo/wavebit", + "install": { + "kind": "source", + "command": "python -m pip install -e ." + }, + "settings": "the validation tests themselves: tests/fdtd/test_fd_mode_source.py, test_fd_gaussian_beam.py, test_fd_waveguide_pml.py (D1, an initial-field launch, for the absorber row); values as fd-mode-source.md, fd-gaussian-beam.md and fd-waveguide-through-pml.md print them, and the solver index from fdtd.solve_modes on the slab", + "discretization": "uniform Yee grid 0.05, 0.025 and 0.0125 um, cell-snapped core", + "arrays": [ + "wavebit__*" + ], + "execution": "separate-process", + "independence": { + "product_loaded_in_reference_process": true, + "reference_inputs_built_from_product_objects": true + } + } + ], + "generator": { + "path": "private research repository", + "sha256": "0d714fa5cc0b89557854cf9a65befea1ff94563905c9193cb2e296b425771b40", + "command": "python freeze_public_golden.py golden_stage7_cross_solver.json (assembles the Meep and FDTDX run results and the Wavebit numbers the validation records print)", + "product_commit": "bdcd93da52d8f43fb71a15fb194380065be31ced", + "clean_tree": true + }, + "generated_utc": "2026-10-30T12:20:00Z", + "environment": { + "python": "3.12 (Meep environment), 3.13 (FDTDX and Wavebit environments)", + "numpy": "2.5.4 (Meep, Wavebit), 2.4.6 (FDTDX)", + "os": "Linux x86_64, CPU only" + }, + "cost": { + "max_rss_mb": { + "Meep": "up to 170", + "FDTDX": "up to 900" + }, + "wall_clock": "not recorded" + }, + "inputs": { + "cases": { + "slab": "core 1.5 um, eps 4 in cladding eps 2.085136, y invariant, x open at +-4.5 um, z open at +-4 um, core continued through both z faces (fd-mode-source.md Setup S)", + "straight": "TE0 launched +z from z = -2 um, pulse centred at 1.55 um, frequency_sigma_hz 1.5e13; mode readouts at z = +1, +2 (forward) and -3 um (backward), at 1.45, 1.55, 1.65 um", + "joint": "core 1.5 um for z < 0 and 1.0 um for z > 0 (fd-mode-source.md S5); T read at z = +2 um in the narrow core, R at z = -1 um, at 1.55 um", + "pml": "the slab core run into a 0.6 um and a 1.2 um absorbing layer; reflected TE0 power at 1.55 um", + "beam": "vacuum, waist 1.5 um (1/e amplitude radius), wavelength 1.0 um, TE, focus at the origin, source plane z = -4 um (fd-gaussian-beam.md Setup B); back-radiation at 0.95, 1.0, 1.05 um; Gouy phase on the axis relative to the axial wave, largest difference from the exact angular spectrum over z in [-3, 3] um", + "focal": "waist 1.0 um, glass eps 2.25 for z > -1 um, source z = -2.5 um, focus declared at z = +0.5 um (fd-gaussian-beam.md G3); on-axis intensity peak in the glass" + }, + "spacings_um": [ + 0.05, + 0.025, + 0.0125 + ], + "courant": 0.5, + "oracles": { + "te0_exact_index": 1.9562181937830228, + "joint_T_mode_matching": 0.955057091104045, + "joint_R_mode_matching": 0.0003328675845426776, + "focal_peak_exact_um": 1.3739765060772822, + "source": "exact slab dispersion relation; continuum mode matching and exact angular spectrum as written in tests/fdtd/test_fd_mode_source.py and tests/fdtd/test_fd_gaussian_beam.py" + }, + "keys": "tool__quantity; spacing keys in um (Meep resolution 20, 40, 80 per um); lists follow the three wavelengths of the case" + }, + "outcomes": { + "matched": "geometry, wavelengths, spacings, Courant number, polarization, readout planes; ratios only (FDTDX unit mode power has no area factor)", + "not_matched": "each tool's own mode solver, source and readout; Meep's subpixel averaging; FDTDX's Gaussian source focuses on its own plane (beam row is a variant, focal case not run); Wavebit's absorber row is D1, an initial-field launch, not the other tools' readout", + "reference_self_check": "Meep's and FDTDX's absorber values do not change when the layer is doubled, so they are a floor of their readout and are recorded as such", + "failed_or_discarded": "none discarded; every run that finished is recorded", + "not_run": "FDTDX focal-shift case (cannot be set up); FDTDX absorber case at 0.025 um; Wavebit butt joint at 0.0125 um" + }, + "payload": { + "file": "golden_stage7_cross_solver.json", + "format": "json", + "file_sha256": "bb3bd9afdca61867e9dac8cfb0e8278a49da819fbc40241f9ffddebbdb91888e", + "arrays": [ + "fdtdx__beam_back_per_forward", + "fdtdx__beam_radius_2um_ratio_minus_1", + "fdtdx__gouy_max_error_rad", + "fdtdx__joint_R", + "fdtdx__joint_T", + "fdtdx__leakage_power", + "fdtdx__n_run", + "fdtdx__neff_te_reported_error", + "fdtdx__pml_reflection", + "meep__beam_back_per_forward", + "meep__beam_waist_ratio_minus_1", + "meep__focal_peak_um", + "meep__gouy_max_error_rad", + "meep__joint_R", + "meep__joint_T", + "meep__leakage_power", + "meep__n_run", + "meep__neff_te_reported_error", + "meep__pml_reflection", + "wavebit__beam_back_per_forward", + "wavebit__beam_waist_ratio_minus_1", + "wavebit__focal_peak_um", + "wavebit__gouy_max_error_rad", + "wavebit__joint_R", + "wavebit__joint_T", + "wavebit__leakage_power", + "wavebit__n_run", + "wavebit__neff_te_reported_error", + "wavebit__pml_reflection_d1" + ] + }, + "values_sha256": null, + "content_sha256": "b89d851dc8c4cdc9915b4bba767c7bc07a29cb11cff24148aa051c547bb393a8", + "consumers": [ + "tests/references/test_stage7_cross_solver_golden.py" + ], + "provenance_status": "complete", + "regeneration": [], + "not_recorded": { + "values_sha256": "a nested JSON payload has no values digest (tests/references/_reference_records.py)" + } +} diff --git a/tests/references/test_stage7_cross_solver_golden.py b/tests/references/test_stage7_cross_solver_golden.py new file mode 100644 index 00000000..2d8ede8a --- /dev/null +++ b/tests/references/test_stage7_cross_solver_golden.py @@ -0,0 +1,213 @@ +"""Stage 7 against Meep and FDTDX, from a checked-in golden. + +``golden_stage7_cross_solver.json`` holds what Meep 1.35.0 and FDTDX 0.6.3 measured on the +stage-7 cases (slab mode index, one-way mode launch, butt joint, a core run into the absorbing +layer, and the Gaussian beam), beside the Wavebit numbers the validation records print. Its +record, ``golden_stage7_cross_solver.reference.json``, states how each tool was set up, what +could and could not be matched, and that each ran in its own process from inputs written in +physical units (no Wavebit object reached either tool). + +These values are **evidence, not acceptance**: no stage-7 verdict depends on them. This module +holds two things. First, the comparison tables in ``docs/validation/fdtd/fd-mode-source.md`` +and ``fd-gaussian-beam.md`` (English and Korean) print exactly these values, so a document +cannot drift from the numbers it quotes. Second, each sentence those sections draw from the +comparison is asserted against the values, so a later regeneration that changes a conclusion +fails here instead of leaving a stale sentence. + +Meep is GPL-2.0-or-later and was run as a separate program; its source was not read. This +module needs only the standard library. +""" + +import json +import re + +from tools.repository_paths import repository_path + +GOLDEN = 'tests/references/golden_stage7_cross_solver.json' +EXACT_TE0 = 1.9562181937830228 # fd-waveguide-modes.md, M3 +T_MM, R_MM = 0.955057091104045, 3.328675845426776e-4 # continuum mode matching (fd-mode-source.md S5) +FOCAL_EXACT = 1.3739765060772822 # exact angular spectrum (fd-gaussian-beam.md G3) +CENTRE = 1 # index of 1.55 um (guide) and 1.0 um (beam) + + +def _golden(): + return json.loads(repository_path(GOLDEN).read_text(encoding='utf-8')) + + +def sci(value, signed=False): + """Two significant figures with a short exponent: 1.3e-2, +1.3e-2, 6.8e-29.""" + text = f'{value:+.1e}' if signed else f'{value:.1e}' + mantissa, exponent = text.split('e') + return f'{mantissa}e{int(exponent)}' + + +def _cells(*values): + return ' / '.join(values) + + +def guide_rows(g): + """The rows of the mode-source comparison table: (label, Wavebit, Meep, FDTDX).""" + w, m, f = (lambda key: g['wavebit__' + key]), (lambda key: g['meep__' + key]), (lambda key: g['fdtdx__' + key]) + three, two = ('0.05', '0.025', '0.0125'), ('0.05', '0.025') + leak = lambda d: _cells(*(sci(d[k][CENTRE]) for k in two)) # noqa: E731 + side = lambda d, k: _cells(sci(d[k][0]), sci(d[k][2])) # noqa: E731 + return [ + ('TE0 index the mode solver reports, minus exact (0.05 / 0.025 / 0.0125)', + *(_cells(*(sci(t(k), signed=True) for k in three)) for t in ( + lambda k: w('neff_te_reported_error')[k], lambda k: m('neff_te_reported_error')[k], + lambda k: f('neff_te_reported_error')[k]))), + ('TE0 index the run propagates, minus exact (0.05 / 0.025)', + *(_cells(*(sci(d[k] - EXACT_TE0, signed=True) for k in two)) for d in ( + w('n_run'), m('n_run'), f('n_run')))), + ('Run index minus the Wavebit run index (0.05 / 0.025)', '0 / 0', + *(_cells(*(sci(d[k] - w('n_run')[k], signed=True) for k in two)) for d in (m('n_run'), f('n_run')))), + ('Backward / forward power at 1.55 um (0.05 / 0.025)', + leak(w('leakage_power')), leak(m('leakage_power')), leak(f('leakage_power'))), + ('Backward / forward power at 1.45 / 1.65 um, 0.025', + side(w('leakage_power'), '0.025'), side(m('leakage_power'), '0.025'), side(f('leakage_power'), '0.025')), + ('Butt joint, abs(T - T_mm) (0.05 / 0.025 / 0.0125)', + _cells(*(sci(abs(w('joint_T')[k] - T_MM)) for k in two), '-'), + _cells(*(sci(abs(m('joint_T')[k] - T_MM)) for k in three)), + _cells(*(sci(abs(f('joint_T')[k] - T_MM)) for k in two), '-')), + ('Butt joint, abs(R - R_mm) / R_mm (0.05 / 0.025 / 0.0125)', + _cells(*(sci(abs(w('joint_R')[k] - R_MM) / R_MM) for k in two), '-'), + _cells(*(sci(abs(m('joint_R')[k] - R_MM) / R_MM) for k in three)), + _cells(*(sci(abs(f('joint_R')[k] - R_MM) / R_MM) for k in two), '-')), + ('Core into the absorber: reflected TE0, 0.6 / 1.2 um layer (0.05)', + _cells(*(sci(w('pml_reflection_d1')['0.05'][um]) for um in ('0.6', '1.2'))), + _cells(*(sci(m('pml_reflection')['0.05'][um]) for um in ('0.6', '1.2'))), + _cells(*(sci(f('pml_reflection')['0.05'][um]) for um in ('0.6', '1.2')))), + ('Same, 0.025', '-', + _cells(*(sci(m('pml_reflection')['0.025'][um]) for um in ('0.6', '1.2'))), '-'), + ] + + +def beam_rows(g): + """The rows of the Gaussian-beam comparison table: (label, Wavebit, Meep, FDTDX).""" + w, m, f = (lambda key: g['wavebit__' + key]), (lambda key: g['meep__' + key]), (lambda key: g['fdtdx__' + key]) + two = ('0.05', '0.025') + pair = lambda d, fn=lambda v: v: _cells(*(sci(fn(d[k])) for k in two)) # noqa: E731 + return [ + ('Beam width against the exact beam, w / w_exact - 1 (0.05 / 0.025)', + _cells(*(sci(w('beam_waist_ratio_minus_1')[k], signed=True) for k in two)), + _cells(*(sci(m('beam_waist_ratio_minus_1')[k], signed=True) for k in two)), + _cells(*(sci(f('beam_radius_2um_ratio_minus_1')[k], signed=True) for k in two))), + ('Largest Gouy-phase difference from the exact beam, rad (0.05 / 0.025)', + pair(w('gouy_max_error_rad')), pair(m('gouy_max_error_rad')), pair(f('gouy_max_error_rad'))), + ('Backward / forward power at 1.0 um (0.05 / 0.025)', + pair(w('beam_back_per_forward'), lambda v: v[CENTRE]), pair(m('beam_back_per_forward'), lambda v: v[CENTRE]), + pair(f('beam_back_per_forward'), lambda v: v[CENTRE])), + ('Focal peak in glass, abs(z - 1.37398 um) (0.05 / 0.025)', + pair(w('focal_peak_um'), lambda v: abs(v - FOCAL_EXACT)), pair(m('focal_peak_um'), lambda v: abs(v - FOCAL_EXACT)), + 'cannot be set up'), + ] + + +def _table(path, heading): + """The first markdown table under ``heading`` in ``path``, as rows of stripped cells.""" + text = repository_path(path).read_text(encoding='utf-8') + start = text.index(heading) + rows = [] + for line in text[start:].splitlines()[1:]: + if line.startswith('#'): + break + if line.startswith('|') and not re.fullmatch(r'\|[\s|:-]+\|', line.strip()): + rows.append(tuple(cell.strip() for cell in line.strip().strip('|').split('|'))) + elif rows and not line.startswith('|'): + break + return rows[1:] # without the header + + +DOCS = { + 'guide': (guide_rows, 'docs/validation/fdtd/fd-mode-source.md', 'docs/locales/ko/pages/validation/fdtd/fd-mode-source.md'), + 'beam': (beam_rows, 'docs/validation/fdtd/fd-gaussian-beam.md', 'docs/locales/ko/pages/validation/fdtd/fd-gaussian-beam.md'), +} +HEADING = {'en': '## Comparison with Meep and FDTDX', 'ko': '## Meep·FDTDX와의 비교'} + + +def test_the_english_tables_print_the_golden(): + g = _golden() + for rows, en, _ in DOCS.values(): + assert _table(en, HEADING['en']) == rows(g), en + + +def test_the_korean_tables_print_the_same_values(): + g = _golden() + for rows, _, ko in DOCS.values(): + printed = [row[1:] for row in _table(ko, HEADING['ko'])] + expected = [row[1:] for row in rows(g)] + assert printed == [tuple('설정할 수 없음' if c == 'cannot be set up' else c for c in row) + for row in expected], ko + + +def test_the_three_runs_propagate_the_same_discrete_system(): + """'FDTDX's run index equals Wavebit's to 1e-8; Meep's differs by its subpixel averaging.'""" + g = _golden() + for k in ('0.05', '0.025'): + assert abs(g['fdtdx__n_run'][k] - g['wavebit__n_run'][k]) < 1e-8 + assert abs(g['meep__n_run'][k] - g['wavebit__n_run'][k]) > 1e-4 + + +def test_only_the_grid_mode_launch_is_one_way_at_the_centre(): + """'At the centre wavelength Meep's and FDTDX's mode sources leak; FDTDX's leak falls + near the sixth power of the spacing, Meep's near the fourth.'""" + g = _golden() + for k in ('0.05', '0.025'): + w = g['wavebit__leakage_power'][k][CENTRE] + assert w < 1e-20 < g['fdtdx__leakage_power'][k][CENTRE] < g['meep__leakage_power'][k][CENTRE] + fdtdx = g['fdtdx__leakage_power']['0.05'][CENTRE] / g['fdtdx__leakage_power']['0.025'][CENTRE] + meep = g['meep__leakage_power']['0.05'][CENTRE] / g['meep__leakage_power']['0.025'][CENTRE] + assert 2 ** 5.5 < fdtdx < 2 ** 6.5, fdtdx + assert 2 ** 3.5 < meep < 2 ** 4.5, meep + + +def test_every_tool_converges_on_the_mode_matching_transmission(): + g = _golden() + for tool, spacings in (('wavebit', ('0.05', '0.025')), ('meep', ('0.05', '0.025', '0.0125')), + ('fdtdx', ('0.05', '0.025'))): + errors = [abs(g[f'{tool}__joint_T'][k] - T_MM) for k in spacings] + assert errors == sorted(errors, reverse=True), (tool, errors) + + +def test_the_small_reflection_is_closest_in_wavebit(): + g = _golden() + rel = {tool: abs(g[f'{tool}__joint_R']['0.025'] - R_MM) / R_MM for tool in ('wavebit', 'meep', 'fdtdx')} + assert rel['wavebit'] < min(rel['meep'], rel['fdtdx']), rel + + +def test_the_external_absorber_numbers_are_a_measurement_floor(): + """'Doubling the layer changes Meep's and FDTDX's values by at most 1.5 %, so they measure + the readout, not the layer' (a layer's own reflection would fall by orders of magnitude, + as Wavebit's D1 does).""" + g = _golden() + for values in (g['meep__pml_reflection']['0.05'], g['meep__pml_reflection']['0.025'], + g['fdtdx__pml_reflection']['0.05']): + assert abs(values['1.2'] / values['0.6'] - 1) < 0.015, values + assert g['wavebit__pml_reflection_d1']['0.05']['1.2'] < 1e-6 * g['wavebit__pml_reflection_d1']['0.05']['0.6'] + + +def test_the_meep_beam_is_wider_at_every_resolution(): + """'Meep's waist is 0.86 % wide at both resolutions: its definition, not its grid.'""" + g = _golden() + ratios = g['meep__beam_waist_ratio_minus_1'] + assert all(8e-3 < v < 9e-3 for v in ratios.values()), ratios + assert abs(ratios['0.05'] - ratios['0.025']) < 1e-4 + + +def test_the_wavebit_gouy_phase_is_closest_and_converges(): + """The support decision in fd-gaussian-beam.md: the G2 difference is below both tools' + at both spacings and falls at second order; Meep's does not fall.""" + g = _golden() + for k in ('0.05', '0.025'): + assert g['wavebit__gouy_max_error_rad'][k] < min(g['meep__gouy_max_error_rad'][k], + g['fdtdx__gouy_max_error_rad'][k]) + w = g['wavebit__gouy_max_error_rad'] + assert 2 ** 1.8 < w['0.05'] / w['0.025'] < 2 ** 2.2 + assert g['meep__gouy_max_error_rad']['0.025'] >= g['meep__gouy_max_error_rad']['0.05'] + + +def test_the_fdtdx_back_radiation_does_not_fall_with_the_grid(): + g = _golden() + back = [g['fdtdx__beam_back_per_forward'][k][CENTRE] for k in ('0.05', '0.025')] + assert abs(back[1] / back[0] - 1) < 0.1, back + assert all(b > 1e3 * g['wavebit__beam_back_per_forward']['0.05'][CENTRE] for b in back)