Compare commits

..

10 commits

Author SHA1 Message Date
5ca4f27f22 add links to pypi and github 2024-03-30 18:06:41 -07:00
35ec8b4325 bump minmum python to 3.11 2024-03-30 18:06:31 -07:00
35b5a8c5b9 move some functions 2024-03-18 11:24:49 -07:00
9ef62b92d5 nn -> nC 2024-03-18 11:00:50 -07:00
711ce119d0 fix npy->numpy 2024-03-18 11:00:00 -07:00
6937bbf455 use slices 2024-03-18 10:59:00 -07:00
ce3e47daa9 comment out some eme notes 2024-03-18 10:54:51 -07:00
99461dc129 alternate connect_s 2024-03-18 10:54:36 -07:00
0ad78271ca more wip 2024-03-14 22:38:17 -07:00
2c5c40c1e7 wip junk 2024-03-14 22:17:09 -07:00
127 changed files with 4628 additions and 11363 deletions

4
.gitignore vendored
View file

@ -54,10 +54,6 @@ coverage.xml
# documentation # documentation
doc/ doc/
site/
_doc_mathimg/
doc.md
doc.htex
# PyBuilder # PyBuilder
target/ target/

148
README.md
View file

@ -56,21 +56,6 @@ linear systems, ideally with double precision.
Install from PyPI with pip: Install from PyPI with pip:
```bash ```bash
pip3 install meanas
```
Optional extras:
- `meanas[test]`: pytest and coverage
- `meanas[docs]`: MkDocs-based documentation toolchain
- `meanas[examples]`: optional runtime dependencies used by the tracked examples
- `meanas[dev]`: the union of `test`, `docs`, and `examples`, plus local lint/docs-publish helpers
Examples:
```bash
pip3 install 'meanas[test]'
pip3 install 'meanas[docs]'
pip3 install 'meanas[examples]'
pip3 install 'meanas[dev]' pip3 install 'meanas[dev]'
``` ```
@ -95,13 +80,9 @@ source my_venv/bin/activate
# Install in-place (-e, editable) from ./meanas, including development dependencies ([dev]) # Install in-place (-e, editable) from ./meanas, including development dependencies ([dev])
pip3 install --user -e './meanas[dev]' pip3 install --user -e './meanas[dev]'
# Fast local iteration: excludes slower 3D/integration/example-smoke checks # Run tests
cd meanas cd meanas
python3 -m pytest -q -m "not complete" python3 -m pytest -rsxX | tee test_results.txt
# Complete pre-commit confidence run: includes the slower integration tests and
# tracked example smoke tests
python3 -m pytest -q | tee test_results.txt
``` ```
#### See also: #### See also:
@ -113,125 +94,6 @@ python3 -m pytest -q | tee test_results.txt
## Use ## Use
`meanas` is a collection of finite-difference electromagnetics tools: See `examples/` for some simple examples; you may need additional
packages such as [gridlock](https://mpxd.net/code/jan/gridlock)
- `meanas.fdfd`: frequency-domain wave equations, sparse operators, SCPML, and to run the examples.
iterative solves for driven problems.
- `meanas.fdfd.waveguide_2d` / `meanas.fdfd.waveguide_3d`: waveguide mode
solvers, mode-source construction, and overlap windows for port-based
excitation and analysis.
- `meanas.fdtd`: Yee-step updates, CPML boundaries, flux/energy accounting, and
on-the-fly phasor extraction for comparing time-domain runs against FDFD.
- `meanas.fdmath`: low-level finite-difference operators, vectorization helpers,
and derivations shared by the FDTD and FDFD layers.
For most users, the tracked examples under `examples/` are the right entry
point. The library API is primarily a toolbox; the module docstrings and API
pages are there to document the mathematical conventions and derivations behind
those tools.
## Documentation
API and workflow docs are generated from the package docstrings with
[MkDocs](https://www.mkdocs.org/), [Material for MkDocs](https://squidfunk.github.io/mkdocs-material/),
and [mkdocstrings](https://mkdocstrings.github.io/).
Install the docs toolchain with:
```bash
pip3 install -e './meanas[docs]'
```
Then build the docs site with:
```bash
./make_docs.sh
```
This produces:
- a normal multi-page site under `site/`
- a combined printable single-page HTML site under `site/print_page/`
- an optional fully inlined `site/standalone.html` when `htmlark` is available
The docs build uses a local MathJax bundle vendored under `docs/assets/`, so
the rendered HTML does not rely on external services for equation rendering.
The tracked examples under `examples/` are the intended entry points for users:
- `examples/fdtd.py`: broadband FDTD pulse excitation, phasor extraction, and a
residual check against the matching FDFD operator.
- `examples/waveguide.py`: waveguide mode solving, unidirectional mode-source
construction, overlap readout, and FDTD/FDFD comparison on a guided structure.
- `examples/waveguide_real.py`: real-valued continuous-wave FDTD on a straight
guide, with late-time monitor slices, guided-core windows, and mode-weighted
errors compared directly against real fields reconstructed from the matching
FDFD solution, plus a guided-mode / orthogonal-residual split.
- `examples/eme.py`: straight-interface mode matching / EME, including port
mode solving, interface scattering, and modal field visualization.
- `examples/eme_bend.py`: straight-to-bent waveguide mode matching with
cylindrical bend modes, interface scattering, and a cascaded bend-network
example built with `scikit-rf`.
- `examples/fdfd.py`: direct frequency-domain waveguide excitation and overlap /
Poynting analysis without a time-domain run.
Several examples rely on optional packages such as
[gridlock](https://mpxd.net/code/jan/gridlock).
### Frequency-domain waveguide workflow
For a structure with a constant cross-section in one direction:
1. Build `dxes` and the diagonal `epsilon` / `mu` distributions on the Yee grid.
2. Solve the port mode with `meanas.fdfd.waveguide_3d.solve_mode(...)`.
3. Build a unidirectional source with `compute_source(...)`.
4. Build a matching overlap window with `compute_overlap_e(...)`.
5. Solve the full FDFD problem and project the result onto the overlap window or
evaluate plane flux with `meanas.fdfd.functional.poynting_e_cross_h(...)`.
### Time-domain phasor workflow
For a broadband or continuous-wave FDTD run:
1. Advance the fields with `meanas.fdtd.maxwell_e/maxwell_h` or
`updates_with_cpml(...)`.
2. Inject electric current using the same sign convention used throughout the
examples and library: `E -= dt * J / epsilon`.
3. Accumulate the desired phasor with `accumulate_phasor(...)` or the Yee-aware
wrappers `accumulate_phasor_e/h/j(...)`.
4. Build the matching FDFD operator on the stretched `dxes` if CPML/SCPML is
part of the simulation, and compare the extracted phasor to the FDFD field or
residual.
This is the primary FDTD/FDFD equivalence workflow. The phasor extraction step
filters the time-domain run down to the guided `+\omega` content that FDFD
solves for directly, so it is the cleanest apples-to-apples comparison.
### Real-field reconstruction workflow
For a continuous-wave real-valued FDTD run:
1. Build the analytic source phasor for the structure, for example with
`waveguide_3d.compute_source(...)`.
2. Run the real-valued FDTD simulation using the real part of that source.
3. Solve the matching FDFD problem from the analytic source phasor on the
stretched `dxes`.
4. Reconstruct late real `E/H/J` snapshots with
`reconstruct_real_e/h/j(...)` and compare those directly against the
real-valued FDTD fields, ideally on a monitor window or mode-weighted norm
centered on the guided field rather than on the full transverse plane. When
needed, split the monitor field into guided-mode and orthogonal residual
pieces to see whether the remaining mismatch is actually in the mode or in
weak nonguided tails.
This is a stricter diagnostic, not the primary equivalence benchmark. A raw
monitor slice contains both the guided field and the remaining orthogonal
content on that plane,
$$ E_{\text{monitor}} = E_{\text{guided}} + E_{\text{residual}} , $$
so its full-plane instantaneous error is naturally noisier than the extracted
phasor comparison even when the underlying guided `+\omega` content matches
well.
`examples/waveguide_real.py` is the reference implementation of this workflow.

View file

@ -1,3 +0,0 @@
# eigensolvers
::: meanas.eigensolvers

View file

@ -1,15 +0,0 @@
# fdfd
::: meanas.fdfd
## Core operator layers
::: meanas.fdfd.functional
::: meanas.fdfd.operators
::: meanas.fdfd.solvers
::: meanas.fdfd.scpml
::: meanas.fdfd.farfield

View file

@ -1,13 +0,0 @@
# fdmath
::: meanas.fdmath
## Functional and sparse operators
::: meanas.fdmath.functional
::: meanas.fdmath.operators
::: meanas.fdmath.vectorization
::: meanas.fdmath.types

View file

@ -1,15 +0,0 @@
# fdtd
::: meanas.fdtd
## Core update and analysis helpers
::: meanas.fdtd.base
::: meanas.fdtd.pml
::: meanas.fdtd.boundaries
::: meanas.fdtd.energy
::: meanas.fdtd.phasor

View file

@ -1,14 +0,0 @@
# API Overview
The package is documented directly from its docstrings. The most useful entry
points are:
- [meanas](meanas.md): top-level package overview
- [eigensolvers](eigensolvers.md): generic eigenvalue utilities used by the mode solvers
- [fdfd](fdfd.md): frequency-domain operators, sources, PML, solvers, and far-field transforms
- [waveguides](waveguides.md): straight, cylindrical, and 3D waveguide mode helpers
- [fdtd](fdtd.md): timestepping, CPML, energy/flux helpers, and phasor extraction
- [fdmath](fdmath.md): shared discrete operators, vectorization helpers, and derivation background
The waveguide and FDTD pages are the best places to start if you want the
mathematical derivations rather than just the callable reference.

View file

@ -1,3 +0,0 @@
# meanas
::: meanas

View file

@ -1,7 +0,0 @@
# waveguides
::: meanas.fdfd.waveguide_2d
::: meanas.fdfd.waveguide_3d
::: meanas.fdfd.waveguide_cyl

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,38 +0,0 @@
{
"etag": "7ab8013dfc2c395304109fe189c1772ef87b366a",
"files": {
"core.js": 212262,
"loader.js": 16370,
"startup.js": 22870,
"input/tex-full.js": 270711,
"input/asciimath.js": 107690,
"input/mml.js": 13601,
"input/mml/entities.js": 32232,
"output/chtml.js": 211041,
"output/chtml/fonts/tex.js": 104359,
"output/chtml/fonts/woff-v2/MathJax_Zero.woff": 1368,
"output/chtml/fonts/woff-v2/MathJax_Vector-Regular.woff": 1136,
"output/chtml/fonts/woff-v2/MathJax_Vector-Bold.woff": 1116,
"output/chtml/fonts/woff-v2/MathJax_Typewriter-Regular.woff": 17604,
"output/chtml/fonts/woff-v2/MathJax_Size4-Regular.woff": 5148,
"output/chtml/fonts/woff-v2/MathJax_Size3-Regular.woff": 3244,
"output/chtml/fonts/woff-v2/MathJax_Size2-Regular.woff": 5464,
"output/chtml/fonts/woff-v2/MathJax_Size1-Regular.woff": 5792,
"output/chtml/fonts/woff-v2/MathJax_Script-Regular.woff": 11852,
"output/chtml/fonts/woff-v2/MathJax_SansSerif-Regular.woff": 12660,
"output/chtml/fonts/woff-v2/MathJax_SansSerif-Italic.woff": 14628,
"output/chtml/fonts/woff-v2/MathJax_SansSerif-Bold.woff": 15944,
"output/chtml/fonts/woff-v2/MathJax_Math-Regular.woff": 19288,
"output/chtml/fonts/woff-v2/MathJax_Math-Italic.woff": 19360,
"output/chtml/fonts/woff-v2/MathJax_Math-BoldItalic.woff": 19776,
"output/chtml/fonts/woff-v2/MathJax_Main-Regular.woff": 34160,
"output/chtml/fonts/woff-v2/MathJax_Main-Italic.woff": 20832,
"output/chtml/fonts/woff-v2/MathJax_Main-Bold.woff": 34464,
"output/chtml/fonts/woff-v2/MathJax_Fraktur-Regular.woff": 21480,
"output/chtml/fonts/woff-v2/MathJax_Fraktur-Bold.woff": 22340,
"output/chtml/fonts/woff-v2/MathJax_Calligraphic-Regular.woff": 9600,
"output/chtml/fonts/woff-v2/MathJax_Calligraphic-Bold.woff": 9908,
"output/chtml/fonts/woff-v2/MathJax_AMS-Regular.woff": 40808
},
"version": "3.1.4"
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,61 +0,0 @@
# meanas
`meanas` is a Python package for finite-difference electromagnetic simulation.
It combines:
- `meanas.fdfd` for frequency-domain operators, sources, waveguide modes, and SCPML
- `meanas.fdtd` for Yee-grid timestepping, CPML, energy/flux accounting, and phasor extraction
- `meanas.fdmath` for the shared discrete operators and derivations underneath both solvers
This documentation is built directly from the package docstrings. The API pages
are the source of truth for the mathematical derivations and calling
conventions.
## Examples and API Map
For most users, the tracked examples under `examples/` are the right entry
point. They show the intended combinations of tools for solving complete
problems.
Relevant starting examples:
- `examples/fdtd.py` for broadband pulse excitation and phasor extraction
- `examples/waveguide.py` for guided phasor-domain FDTD/FDFD comparison
- `examples/waveguide_real.py` for real-valued continuous-wave FDTD compared
against real fields reconstructed from an FDFD solution, including guided-core,
mode-weighted, and guided-mode / residual comparisons
- `examples/eme.py` for straight-interface mode matching / EME and modal
scattering between two nearby waveguide cross-sections
- `examples/eme_bend.py` for straight-to-bent mode matching with cylindrical
bend modes and a cascaded bend-network example
- `examples/fdfd.py` for direct frequency-domain waveguide excitation
For solver equivalence, prefer the phasor-based examples first. They compare
the extracted `+\omega` content of the FDTD run directly against the FDFD
solution and are the main accuracy benchmarks in the test suite.
`examples/waveguide_real.py` answers a different, stricter question: how well a
late raw real snapshot matches `Re(E_\omega e^{i\omega t})` on a monitor plane.
That diagnostic is useful, but it also includes orthogonal residual structure
that the phasor comparison intentionally filters out.
The API pages are better read as a toolbox map and derivation reference:
- Use the [FDTD API](api/fdtd.md) for time-domain stepping, CPML, phasor
extraction, and real-field reconstruction from FDFD phasors.
- Use the [FDFD API](api/fdfd.md) for driven frequency-domain solves and sparse
operator algebra.
- Use the [Waveguide API](api/waveguides.md) for mode solving, port sources,
and overlap windows.
- Use the [fdmath API](api/fdmath.md) for the lower-level finite-difference
operators and the shared discrete derivations underneath both solvers.
## Build outputs
The docs build generates two HTML views from the same source:
- a normal multi-page site
- a print-oriented combined page under `site/print_page/`
If `htmlark` is installed, `./make_docs.sh` also writes a fully inlined
`site/standalone.html`.

View file

@ -1,19 +0,0 @@
window.MathJax = {
loader: {
load: ["input/tex-full", "output/chtml"]
},
tex: {
processEscapes: true,
processEnvironments: true
},
options: {
ignoreHtmlClass: ".*|",
processHtmlClass: "arithmatex"
}
};
document$.subscribe(() => {
MathJax.typesetPromise?.(
document.querySelectorAll(".arithmatex")
).catch((error) => console.error(error));
});

View file

@ -1,64 +0,0 @@
.md-typeset .arithmatex {
overflow-x: auto;
}
.md-typeset .doc-contents {
overflow-wrap: anywhere;
}
.md-typeset h1 code,
.md-typeset h2 code,
.md-typeset h3 code {
word-break: break-word;
}
[data-md-color-scheme="slate"] {
--md-default-bg-color: #000000;
--md-default-bg-color--light: #050505;
--md-default-bg-color--lighter: #0a0a0a;
--md-default-bg-color--lightest: #111111;
--md-default-fg-color: #e8eef7;
--md-default-fg-color--light: #b3bfd1;
--md-default-fg-color--lighter: #7f8ba0;
--md-default-fg-color--lightest: #5d6880;
--md-code-bg-color: #050505;
--md-code-fg-color: #e4edf8;
--md-accent-fg-color: #7dd3fc;
}
[data-md-color-scheme="slate"] .md-header,
[data-md-color-scheme="slate"] .md-tabs {
background: #000000;
}
[data-md-color-scheme="slate"] .md-main,
[data-md-color-scheme="slate"] .md-main__inner,
[data-md-color-scheme="slate"] .md-content,
[data-md-color-scheme="slate"] .md-content__inner,
[data-md-color-scheme="slate"] .md-sidebar,
[data-md-color-scheme="slate"] .md-sidebar__scrollwrap {
background: #000000;
}
[data-md-color-scheme="slate"] .md-typeset pre > code,
[data-md-color-scheme="slate"] .md-typeset code {
border: 1px solid rgba(125, 211, 252, 0.14);
}
[data-md-color-scheme="slate"] .md-typeset table:not([class]) {
background: #050505;
}
[data-md-color-scheme="slate"] .md-typeset table:not([class]) th {
background: rgba(125, 211, 252, 0.08);
}
[data-md-color-scheme="slate"] .md-typeset .admonition,
[data-md-color-scheme="slate"] .md-typeset details {
background: #050505;
border-color: rgba(125, 211, 252, 0.2);
}
[data-md-color-scheme="slate"] .md-typeset .arithmatex {
padding: 0.1rem 0;
}

View file

@ -1,221 +0,0 @@
"""
Mode-matching / EME example for a straight rib-waveguide interface.
This example shows the intended user-facing workflow for `meanas.fdfd.eme` on a
simple straight interface:
1. build two nearby waveguide cross-sections on a Yee grid,
2. solve a small set of guided modes on each side,
3. normalize those modes into E/H port fields,
4. assemble the interface scattering matrix with `meanas.fdfd.eme.get_s(...)`,
5. inspect the dominant modal coupling numerically and visually.
"""
from __future__ import annotations
import importlib
from typing import TYPE_CHECKING
import numpy
from numpy import pi
import gridlock
from gridlock import Extent
from meanas.fdfd import eme, waveguide_2d
from meanas.fdmath import unvec
if TYPE_CHECKING:
from types import ModuleType
WL = 1310.0
DX = 40.0
WIDTH = 400.0
THF = 161.0
THP = 77.0
EPS_SI = 3.51 ** 2
EPS_OX = 1.453 ** 2
MODE_NUMBERS = numpy.array([0])
def require_optional(name: str, package_name: str | None = None) -> ModuleType:
package_name = package_name or name
try:
return importlib.import_module(name)
except ImportError as exc: # pragma: no cover - user environment guard
raise SystemExit(
f"This example requires the optional package '{package_name}'. "
"Install example dependencies with `pip install -e './meanas[examples]'`.",
) from exc
def build_geometry(
*,
dx: float = DX,
width: float = WIDTH,
thf: float = THF,
thp: float = THP,
eps_si: float = EPS_SI,
eps_ox: float = EPS_OX,
) -> tuple[gridlock.Grid, numpy.ndarray, list[list[numpy.ndarray]], float]:
x0 = (width / 2) % dx
omega = 2 * pi / WL
grid = gridlock.Grid(
[
numpy.arange(-800, 800 + dx, dx),
numpy.arange(-400, 400 + dx, dx),
numpy.arange(-2 * dx, 2 * dx + dx, dx),
],
periodic=True,
)
epsilon = grid.allocate(eps_ox)
grid.draw_cuboid(
epsilon,
foreground=eps_si,
x=Extent(center=x0, span=width + 1200),
y=Extent(min=0, max=thf),
z=Extent(min=-1e6, max=0),
)
grid.draw_cuboid(
epsilon,
foreground=eps_ox,
x=Extent(max=-width / 2, span=300),
y=Extent(min=thp, max=1e6),
z=Extent(min=-1e6, max=0),
)
grid.draw_cuboid(
epsilon,
foreground=eps_ox,
x=Extent(min=width / 2, span=300),
y=Extent(min=thp, max=1e6),
z=Extent(min=-1e6, max=0),
)
grid.draw_cuboid(
epsilon,
foreground=eps_si,
x=Extent(max=-(width / 2 + 600), span=240),
y=Extent(min=0, max=thf),
z=Extent(min=0, max=1e6),
)
grid.draw_cuboid(
epsilon,
foreground=eps_si,
x=Extent(max=width / 2 + 600, span=240),
y=Extent(min=0, max=thf),
z=Extent(min=0, max=1e6),
)
dxes = [grid.dxyz, grid.autoshifted_dxyz()]
dxes_2d = [[d[0], d[1]] for d in dxes]
return grid, epsilon, dxes_2d, omega
def solve_cross_section_modes(
epsilon_slice: numpy.ndarray,
*,
omega: float,
dxes_2d: list[list[numpy.ndarray]],
mode_numbers: numpy.ndarray = MODE_NUMBERS,
) -> tuple[list[tuple[numpy.ndarray, numpy.ndarray]], numpy.ndarray]:
e_xys, wavenumbers = waveguide_2d.solve_modes(
epsilon=epsilon_slice.ravel(),
omega=omega,
dxes=dxes_2d,
mode_numbers=mode_numbers,
)
eh_fields = [
waveguide_2d.normalized_fields_e(
e_xy,
wavenumber=wavenumber,
dxes=dxes_2d,
omega=omega,
epsilon=epsilon_slice.ravel(),
)
for e_xy, wavenumber in zip(e_xys, wavenumbers, strict=True)
]
return eh_fields, wavenumbers
def print_summary(ss: numpy.ndarray, wavenumbers_left: numpy.ndarray, wavenumbers_right: numpy.ndarray, omega: float) -> None:
n_left = len(wavenumbers_left)
left_neff = numpy.real(wavenumbers_left / omega)
right_neff = numpy.real(wavenumbers_right / omega)
print('left effective indices:', ', '.join(f'{value:.5f}' for value in left_neff[:4]))
print('right effective indices:', ', '.join(f'{value:.5f}' for value in right_neff[:4]))
reflection = abs(ss[0, 0]) ** 2
transmission = abs(ss[n_left, 0]) ** 2
total_output = numpy.sum(abs(ss[:, 0]) ** 2)
print(f'fundamental left-incident reflection |S_00|^2 = {reflection:.6f}')
print(f'fundamental left-to-right transmission |S_{n_left},0|^2 = {transmission:.6f}')
print(f'fundamental left-incident total output power = {total_output:.6f}')
strongest = numpy.argsort(abs(ss[n_left:, 0]) ** 2)[::-1][:3]
print('dominant transmitted right-side modes for left mode 0:')
for mode_index in strongest:
print(f' mode {mode_index}: |S|^2 = {abs(ss[n_left + mode_index, 0]) ** 2:.6f}')
def plot_results(
*,
pyplot: ModuleType,
ss: numpy.ndarray,
left_mode: tuple[numpy.ndarray, numpy.ndarray],
right_mode: tuple[numpy.ndarray, numpy.ndarray],
shape_2d: tuple[int, int],
) -> None:
fig_s, ax_s = pyplot.subplots()
image = ax_s.imshow(abs(ss) ** 2, origin='lower', cmap='magma')
fig_s.colorbar(image, ax=ax_s)
ax_s.set_title('Interface scattering magnitude |S|^2')
ax_s.set_xlabel('Incident mode index')
ax_s.set_ylabel('Outgoing mode index')
e_left = unvec(left_mode[0], shape_2d)
e_right = unvec(right_mode[0], shape_2d)
left_intensity = numpy.sum(abs(e_left) ** 2, axis=0).T
right_intensity = numpy.sum(abs(e_right) ** 2, axis=0).T
fig_modes, axes = pyplot.subplots(1, 2, figsize=(10, 4))
left_plot = axes[0].imshow(left_intensity, origin='lower', cmap='viridis')
fig_modes.colorbar(left_plot, ax=axes[0])
axes[0].set_title('Left fundamental mode |E|^2')
right_plot = axes[1].imshow(right_intensity, origin='lower', cmap='viridis')
fig_modes.colorbar(right_plot, ax=axes[1])
axes[1].set_title('Right fundamental mode |E|^2')
if pyplot.get_backend().lower().endswith('agg'):
pyplot.close(fig_s)
pyplot.close(fig_modes)
else:
pyplot.show()
def main() -> None:
pyplot = require_optional('matplotlib.pyplot', package_name='matplotlib')
grid, epsilon, dxes_2d, omega = build_geometry()
left_slice = epsilon[:, :, :, 1]
right_slice = epsilon[:, :, :, -2]
left_modes, wavenumbers_left = solve_cross_section_modes(left_slice, omega=omega, dxes_2d=dxes_2d)
right_modes, wavenumbers_right = solve_cross_section_modes(right_slice, omega=omega, dxes_2d=dxes_2d)
ss = eme.get_s(left_modes, wavenumbers_left, right_modes, wavenumbers_right, dxes=dxes_2d)
print_summary(ss, wavenumbers_left, wavenumbers_right, omega)
plot_results(
pyplot=pyplot,
ss=ss,
left_mode=left_modes[0],
right_mode=right_modes[0],
shape_2d=grid.shape[:2],
)
if __name__ == '__main__':
main()

View file

@ -1,314 +0,0 @@
"""
Mode-matching / EME example for a straight-to-bent waveguide interface.
This example demonstrates a cylindrical-waveguide EME workflow:
1. build a rib-waveguide cross-section,
2. solve straight port modes with `waveguide_2d`,
3. solve bend modes with `waveguide_cyl`,
4. assemble the straight-to-bend interface scattering matrix with
`meanas.fdfd.eme.get_s(...)`,
5. optionally cascade a straight section, bend section, and interface pair into
a compact multiport network using `scikit-rf`.
"""
from __future__ import annotations
import importlib
from typing import TYPE_CHECKING
import numpy
from numpy import pi
from scipy import sparse
import gridlock
from gridlock import Extent
from meanas.fdfd import eme, waveguide_2d, waveguide_cyl
from meanas.fdmath import unvec
if TYPE_CHECKING:
from types import ModuleType
WL = 1310.0
DX = 40.0
RADIUS = 6e3
WIDTH = 400.0
THF = 161.0
THP = 77.0
EPS_SI = 3.51 ** 2
EPS_OX = 1.453 ** 2
MODE_NUMBERS = numpy.array([0])
STRAIGHT_SECTION_LENGTH = 12e3
BEND_ANGLE = pi / 2
def require_optional(name: str, package_name: str | None = None) -> ModuleType:
package_name = package_name or name
try:
return importlib.import_module(name)
except ImportError as exc: # pragma: no cover - user environment guard
raise SystemExit(
f"This example requires the optional package '{package_name}'. "
"Install example dependencies with `pip install -e './meanas[examples]'`.",
) from exc
def build_geometry(
*,
dx: float = DX,
width: float = WIDTH,
thf: float = THF,
thp: float = THP,
eps_si: float = EPS_SI,
eps_ox: float = EPS_OX,
) -> tuple[gridlock.Grid, numpy.ndarray, list[list[numpy.ndarray]], float]:
x0 = (width / 2) % dx
omega = 2 * pi / WL
grid = gridlock.Grid(
[
numpy.arange(-800, 800 + dx, dx),
numpy.arange(-400, 400 + dx, dx),
numpy.arange(-2 * dx, 2 * dx + dx, dx),
],
periodic=True,
)
epsilon = grid.allocate(eps_ox)
grid.draw_cuboid(
epsilon,
foreground=eps_si,
x=Extent(center=x0, span=width + 1200),
y=Extent(min=0, max=thf),
z=Extent(min=-1e6, max=0),
)
grid.draw_cuboid(
epsilon,
foreground=eps_ox,
x=Extent(max=-width / 2, span=300),
y=Extent(min=thp, max=1e6),
z=Extent(min=-1e6, center=0),
)
grid.draw_cuboid(
epsilon,
foreground=eps_ox,
x=Extent(min=width / 2, span=300),
y=Extent(min=thp, max=1e6),
z=Extent(min=-1e6, center=0),
)
dxes = [grid.dxyz, grid.autoshifted_dxyz()]
dxes_2d = [[d[0], d[1]] for d in dxes]
return grid, epsilon, dxes_2d, omega
def solve_straight_modes(
epsilon_slice: numpy.ndarray,
*,
omega: float,
dxes_2d: list[list[numpy.ndarray]],
mode_numbers: numpy.ndarray = MODE_NUMBERS,
) -> tuple[list[tuple[numpy.ndarray, numpy.ndarray]], numpy.ndarray]:
e_xys, wavenumbers = waveguide_2d.solve_modes(
epsilon=epsilon_slice.ravel(),
omega=omega,
dxes=dxes_2d,
mode_numbers=mode_numbers,
)
eh_fields = [
waveguide_2d.normalized_fields_e(
e_xy,
wavenumber=wavenumber,
dxes=dxes_2d,
omega=omega,
epsilon=epsilon_slice.ravel(),
)
for e_xy, wavenumber in zip(e_xys, wavenumbers, strict=True)
]
return eh_fields, wavenumbers
def solve_bend_modes(
epsilon_slice: numpy.ndarray,
*,
omega: float,
dxes_2d: list[list[numpy.ndarray]],
rmin: float,
mode_numbers: numpy.ndarray = MODE_NUMBERS,
) -> tuple[list[tuple[numpy.ndarray, numpy.ndarray]], numpy.ndarray, numpy.ndarray]:
e_xys, angular_wavenumbers = waveguide_cyl.solve_modes(
epsilon=epsilon_slice.ravel(),
omega=omega,
dxes=dxes_2d,
mode_numbers=mode_numbers,
rmin=rmin,
)
linear_wavenumbers = waveguide_cyl.linear_wavenumbers(
e_xys=e_xys,
angular_wavenumbers=angular_wavenumbers,
rmin=rmin,
epsilon=epsilon_slice.ravel(),
dxes=dxes_2d,
)
eh_fields = [
waveguide_cyl.normalized_fields_e(
e_xy,
angular_wavenumber=angular_wavenumber,
dxes=dxes_2d,
omega=omega,
epsilon=epsilon_slice.ravel(),
rmin=rmin,
)
for e_xy, angular_wavenumber in zip(e_xys, angular_wavenumbers, strict=True)
]
return eh_fields, linear_wavenumbers, angular_wavenumbers
def build_cascaded_network(
skrf: ModuleType,
*,
interface_s: numpy.ndarray,
straight_wavenumbers: numpy.ndarray,
bend_angular_wavenumbers: numpy.ndarray,
n_modes: int,
) -> object:
net_sb = skrf.Network(f=[1 / WL], s=interface_s[numpy.newaxis, ...])
net_bs = net_sb.copy()
net_bs.renumber(numpy.arange(2 * n_modes), numpy.roll(numpy.arange(2 * n_modes), n_modes))
straight_phase = sparse.diags_array(numpy.exp(-1j * straight_wavenumbers[:n_modes] * STRAIGHT_SECTION_LENGTH))
bend_phase = sparse.diags_array(numpy.exp(-1j * bend_angular_wavenumbers[:n_modes] * BEND_ANGLE))
zero = numpy.zeros((n_modes, n_modes), dtype=complex)
straight_s = numpy.block([[zero, straight_phase.toarray()], [straight_phase.toarray(), zero]])
bend_s = numpy.block([[zero, bend_phase.toarray()], [bend_phase.toarray(), zero]])
net_straight = skrf.Network(f=[1 / WL], s=straight_s[numpy.newaxis, ...])
net_bend = skrf.Network(f=[1 / WL], s=bend_s[numpy.newaxis, ...])
return skrf.network.cascade_list([net_straight, net_sb, net_bend, net_bs, net_straight])
def print_summary(
interface_s: numpy.ndarray,
cascaded_s: numpy.ndarray,
straight_wavenumbers: numpy.ndarray,
bend_linear_wavenumbers: numpy.ndarray,
bend_angular_wavenumbers: numpy.ndarray,
omega: float,
n_modes: int,
) -> None:
straight_neff = numpy.real(straight_wavenumbers / omega)
bend_neff = numpy.real(bend_linear_wavenumbers / omega)
print('straight effective indices:', ', '.join(f'{value:.5f}' for value in straight_neff[:4]))
print('bend effective indices :', ', '.join(f'{value:.5f}' for value in bend_neff[:4]))
print('bend angular wavenumbers :', ', '.join(f'{value:.5e}' for value in bend_angular_wavenumbers[:4]))
interface_transmission = abs(interface_s[n_modes, 0]) ** 2
interface_reflection = abs(interface_s[0, 0]) ** 2
print(f'interface fundamental transmission |S_{n_modes},0|^2 = {interface_transmission:.6f}')
print(f'interface fundamental reflection |S_00|^2 = {interface_reflection:.6f}')
total_cascaded_output = numpy.sum(abs(cascaded_s[:, 0]) ** 2)
bend_through = abs(cascaded_s[n_modes, 0]) ** 2
bend_reflection = abs(cascaded_s[0, 0]) ** 2
print(f'cascaded bend through power |S_{n_modes},0|^2 = {bend_through:.6f}')
print(f'cascaded bend reflection |S_00|^2 = {bend_reflection:.6f}')
print(f'cascaded left-incident total output power = {total_cascaded_output:.6f}')
def plot_results(
*,
pyplot: ModuleType,
interface_s: numpy.ndarray,
cascaded_s: numpy.ndarray,
straight_mode: tuple[numpy.ndarray, numpy.ndarray],
bend_mode: tuple[numpy.ndarray, numpy.ndarray],
shape_2d: tuple[int, int],
) -> None:
fig_s, axes = pyplot.subplots(1, 2, figsize=(12, 4))
interface_plot = axes[0].imshow(abs(interface_s) ** 2, origin='lower', cmap='magma')
fig_s.colorbar(interface_plot, ax=axes[0])
axes[0].set_title('Straight-to-bend interface |S|^2')
axes[0].set_xlabel('Incident mode index')
axes[0].set_ylabel('Outgoing mode index')
cascaded_plot = axes[1].imshow(abs(cascaded_s) ** 2, origin='lower', cmap='magma')
fig_s.colorbar(cascaded_plot, ax=axes[1])
axes[1].set_title('Cascaded bend network |S|^2')
axes[1].set_xlabel('Incident mode index')
axes[1].set_ylabel('Outgoing mode index')
straight_e = unvec(straight_mode[0], shape_2d)
bend_e = unvec(bend_mode[0], shape_2d)
straight_intensity = numpy.sum(abs(straight_e) ** 2, axis=0).T
bend_intensity = numpy.sum(abs(bend_e) ** 2, axis=0).T
fig_modes, axes_modes = pyplot.subplots(1, 2, figsize=(10, 4))
straight_plot = axes_modes[0].imshow(straight_intensity, origin='lower', cmap='viridis')
fig_modes.colorbar(straight_plot, ax=axes_modes[0])
axes_modes[0].set_title('Straight fundamental mode |E|^2')
bend_plot = axes_modes[1].imshow(bend_intensity, origin='lower', cmap='viridis')
fig_modes.colorbar(bend_plot, ax=axes_modes[1])
axes_modes[1].set_title('Bent fundamental mode |E|^2')
if pyplot.get_backend().lower().endswith('agg'):
pyplot.close(fig_s)
pyplot.close(fig_modes)
else:
pyplot.show()
def main() -> None:
pyplot = require_optional('matplotlib.pyplot', package_name='matplotlib')
skrf = require_optional('skrf', package_name='scikit-rf')
grid, epsilon, dxes_2d, omega = build_geometry()
epsilon_slice = epsilon[:, :, :, 2]
rmin = RADIUS + grid.xyz[0].min()
straight_modes, straight_wavenumbers = solve_straight_modes(epsilon_slice, omega=omega, dxes_2d=dxes_2d)
bend_modes, bend_linear_wavenumbers, bend_angular_wavenumbers = solve_bend_modes(
epsilon_slice,
omega=omega,
dxes_2d=dxes_2d,
rmin=rmin,
)
interface_s = eme.get_s(
straight_modes,
straight_wavenumbers,
bend_modes,
bend_linear_wavenumbers,
dxes=dxes_2d,
)
cascaded = build_cascaded_network(
skrf,
interface_s=interface_s,
straight_wavenumbers=straight_wavenumbers,
bend_angular_wavenumbers=bend_angular_wavenumbers,
n_modes=len(MODE_NUMBERS),
)
cascaded_s = cascaded.s[0]
print_summary(
interface_s,
cascaded_s,
straight_wavenumbers,
bend_linear_wavenumbers,
bend_angular_wavenumbers,
omega,
len(MODE_NUMBERS),
)
plot_results(
pyplot=pyplot,
interface_s=interface_s,
cascaded_s=cascaded_s,
straight_mode=straight_modes[0],
bend_mode=bend_modes[0],
shape_2d=grid.shape[:2],
)
if __name__ == '__main__':
main()

View file

@ -1,13 +1,10 @@
import importlib import importlib
import logging
import numpy import numpy
from numpy.linalg import norm from numpy.linalg import norm
from matplotlib import pyplot, colors
import logging
import meanas import meanas
from meanas import fdtd from meanas import fdtd
from meanas.fdmath import vec, unvec, fdfield_t from meanas.fdmath import vec, unvec
from meanas.fdfd import waveguide_3d, functional, scpml, operators from meanas.fdfd import waveguide_3d, functional, scpml, operators
from meanas.fdfd.solvers import generic as generic_solver from meanas.fdfd.solvers import generic as generic_solver
@ -15,6 +12,7 @@ import gridlock
from matplotlib import pyplot from matplotlib import pyplot
import logging
logging.basicConfig(level=logging.DEBUG) logging.basicConfig(level=logging.DEBUG)
logging.getLogger('matplotlib').setLevel(logging.WARNING) logging.getLogger('matplotlib').setLevel(logging.WARNING)
@ -22,6 +20,82 @@ logging.getLogger('matplotlib').setLevel(logging.WARNING)
__author__ = 'Jan Petykiewicz' __author__ = 'Jan Petykiewicz'
def test0(solver=generic_solver):
dx = 50 # discretization (nm/cell)
pml_thickness = 10 # (number of cells)
wl = 1550 # Excitation wavelength
omega = 2 * numpy.pi / wl
# Device design parameters
radii = (1, 0.6)
th = 220
center = [0, 0, 0]
# refractive indices
n_ring = numpy.sqrt(12.6) # ~Si
n_air = 4.0 # air
# Half-dimensions of the simulation grid
xyz_max = numpy.array([1.2, 1.2, 0.3]) * 1000 + pml_thickness * dx
# Coordinates of the edges of the cells.
half_edge_coords = [numpy.arange(dx/2, m + dx, step=dx) for m in xyz_max]
edge_coords = [numpy.hstack((-h[::-1], h)) for h in half_edge_coords]
# #### Create the grid, mask, and draw the device ####
grid = gridlock.Grid(edge_coords)
epsilon = grid.allocate(n_air**2, dtype=numpy.float32)
grid.draw_cylinder(epsilon,
surface_normal=2,
center=center,
radius=max(radii),
thickness=th,
eps=n_ring**2,
num_points=24)
grid.draw_cylinder(epsilon,
surface_normal=2,
center=center,
radius=min(radii),
thickness=th*1.1,
eps=n_air ** 2,
num_points=24)
dxes = [grid.dxyz, grid.autoshifted_dxyz()]
for a in (0, 1, 2):
for p in (-1, 1):
dxes = meanas.fdfd.scpml.stretch_with_scpml(dxes, axis=a, polarity=p, omega=omega,
thickness=pml_thickness)
J = [numpy.zeros_like(epsilon[0], dtype=complex) for _ in range(3)]
J[1][15, grid.shape[1]//2, grid.shape[2]//2] = 1
'''
Solve!
'''
sim_args = {
'omega': omega,
'dxes': dxes,
'epsilon': vec(epsilon),
}
x = solver(J=vec(J), **sim_args)
A = operators.e_full(omega, dxes, vec(epsilon)).tocsr()
b = -1j * omega * vec(J)
print('Norm of the residual is ', norm(A @ x - b))
E = unvec(x, grid.shape)
'''
Plot results
'''
pyplot.figure()
pyplot.pcolor(numpy.real(E[1][:, :, grid.shape[2]//2]), cmap='seismic')
pyplot.axis('equal')
pyplot.show()
def test1(solver=generic_solver): def test1(solver=generic_solver):
dx = 40 # discretization (nm/cell) dx = 40 # discretization (nm/cell)
pml_thickness = 10 # (number of cells) pml_thickness = 10 # (number of cells)
@ -48,7 +122,7 @@ def test1(solver=generic_solver):
# #### Create the grid and draw the device #### # #### Create the grid and draw the device ####
grid = gridlock.Grid(edge_coords) grid = gridlock.Grid(edge_coords)
epsilon = grid.allocate(n_air**2, dtype=numpy.float32) epsilon = grid.allocate(n_air**2, dtype=numpy.float32)
grid.draw_cuboid(epsilon, x=dict(center=0, span=8e3), y=dict(center=0, span=w), z=dict(center=0, span=th), foreground=n_wg**2) grid.draw_cuboid(epsilon, center=center, dimensions=[8e3, w, th], eps=n_wg**2)
dxes = [grid.dxyz, grid.autoshifted_dxyz()] dxes = [grid.dxyz, grid.autoshifted_dxyz()]
for a in (0, 1, 2): for a in (0, 1, 2):
@ -82,14 +156,22 @@ def test1(solver=generic_solver):
# grid.draw_cuboid(pmcg, center=[700, 0, 0], dimensions=[80, 1e8, 1e8], eps=1) # grid.draw_cuboid(pmcg, center=[700, 0, 0], dimensions=[80, 1e8, 1e8], eps=1)
# grid.visualize_isosurface(pmcg) # grid.visualize_isosurface(pmcg)
grid.visualize_slice(J.imag, plane=dict(y=6*dx), which_shifts=1, pcolormesh_args=dict(norm=colors.CenteredNorm(), cmap='bwr')) def pcolor(v) -> None:
fig, ax = pyplot.subplots() vmax = numpy.max(numpy.abs(v))
ax.pcolormesh((numpy.abs(J).sum(axis=2).sum(axis=0) > 0).astype(float).T, cmap='hot') pyplot.pcolor(v, cmap='seismic', vmin=-vmax, vmax=vmax)
pyplot.axis('equal')
pyplot.colorbar()
ss = (1, slice(None), J.shape[2]//2+6, slice(None))
# pyplot.figure()
# pcolor(J3[ss].T.imag)
# pyplot.figure()
# pcolor((numpy.abs(J3).sum(axis=2).sum(axis=0) > 0).astype(float).T)
pyplot.show(block=True) pyplot.show(block=True)
# '''
# Solve! Solve!
# '''
sim_args = { sim_args = {
'omega': omega, 'omega': omega,
'dxes': dxes, 'dxes': dxes,
@ -106,18 +188,20 @@ def test1(solver=generic_solver):
E = unvec(x, grid.shape) E = unvec(x, grid.shape)
# '''
# Plot results Plot results
# '''
center = grid.pos2ind([0, 0, 0], None).astype(int) center = grid.pos2ind([0, 0, 0], None).astype(int)
fig, axes = pyplot.subplots(2, 2) pyplot.figure()
grid.visualize_slice(E.real, plane=dict(x=0), which_shifts=1, ax=axes[0, 0], finalize=False, pcolormesh_args=dict(norm=colors.CenteredNorm(), cmap='bwr')) pyplot.subplot(2, 2, 1)
grid.visualize_slice(E.real, plane=dict(z=0), which_shifts=1, ax=axes[0, 1], finalize=False, pcolormesh_args=dict(norm=colors.CenteredNorm(), cmap='bwr')) pcolor(numpy.real(E[1][center[0], :, :]).T)
# pcolor(axes[0, 0], numpy.real(E[1][center[0], :, :]).T) pyplot.subplot(2, 2, 2)
# pcolor(axes[0, 1], numpy.real(E[1][:, :, center[2]]).T) pyplot.plot(numpy.log10(numpy.abs(E[1][:, center[1], center[2]]) + 1e-10))
axes[1, 0].plot(numpy.log10(numpy.abs(E[1][:, center[1], center[2]]) + 1e-10)) pyplot.grid(alpha=0.6)
axes[1, 0].grid(alpha=0.6) pyplot.ylabel('log10 of field')
axes[1, 0].set_ylabel('log10 of field') pyplot.subplot(2, 2, 3)
pcolor(numpy.real(E[1][:, :, center[2]]).T)
pyplot.subplot(2, 2, 4)
def poyntings(E): def poyntings(E):
H = functional.e2h(omega, dxes)(E) H = functional.e2h(omega, dxes)(E)
@ -131,28 +215,24 @@ def test1(solver=generic_solver):
return s0, s1, s2 return s0, s1, s2
s0x, s1x, s2x = poyntings(E) s0x, s1x, s2x = poyntings(E)
ax = axes[1, 1] pyplot.plot(s0x[0].sum(axis=2).sum(axis=1), label='s0', marker='.')
ax.plot(s0x[0].sum(axis=2).sum(axis=1), label='s0', marker='.') pyplot.plot(s1x[0].sum(axis=2).sum(axis=1), label='s1', marker='.')
ax.plot(s1x[0].sum(axis=2).sum(axis=1), label='s1', marker='.') pyplot.plot(s2x[0].sum(axis=2).sum(axis=1), label='s2', marker='.')
ax.plot(s2x[0].sum(axis=2).sum(axis=1), label='s2', marker='.') pyplot.plot(E[1][:, center[1], center[2]].real.T, label='Ey', marker='x')
ax.plot(E[1][:, center[1], center[2]].real.T, label='Ey', marker='x') pyplot.grid(alpha=0.6)
ax.grid(alpha=0.6) pyplot.legend()
ax.legend() pyplot.show()
p_in = (-E * J.conj()).sum() / 2 * (dx * dx * dx)
print(f'{p_in=}')
q = [] q = []
for i in range(-5, 30): for i in range(-5, 30):
e_ovl_rolled = numpy.roll(e_overlap, i, axis=1) e_ovl_rolled = numpy.roll(e_overlap, i, axis=1)
q += [numpy.abs(vec(E).conj() @ vec(e_ovl_rolled))] q += [numpy.abs(vec(E) @ vec(e_ovl_rolled).conj())]
fig, ax = pyplot.subplots() pyplot.figure()
ax.plot(q, marker='.') pyplot.plot(q, marker='.')
ax.grid(alpha=0.6) pyplot.grid(alpha=0.6)
ax.set_title('Overlap with mode') pyplot.title('Overlap with mode')
print('Average overlap with mode:', sum(q[8:32])/len(q[8:32])) pyplot.show()
print('Average overlap with mode:', sum(q)/len(q))
pyplot.show(block=True)
def module_available(name): def module_available(name):
@ -160,6 +240,9 @@ def module_available(name):
if __name__ == '__main__': if __name__ == '__main__':
#test0()
# test1()
if module_available('opencl_fdfd'): if module_available('opencl_fdfd'):
from opencl_fdfd import cg_solver as opencl_solver from opencl_fdfd import cg_solver as opencl_solver
test1(opencl_solver) test1(opencl_solver)
@ -170,4 +253,3 @@ if __name__ == '__main__':
# test1(magma_solver) # test1(magma_solver)
else: else:
test1() test1()

View file

@ -1,103 +0,0 @@
import numpy
from numpy.linalg import norm
from matplotlib import pyplot, colors
import logging
import meanas
from meanas import fdtd
from meanas.fdmath import vec, unvec
from meanas.fdfd import waveguide_3d, functional, scpml, operators
from meanas.fdfd.solvers import generic as generic_solver
import gridlock
logging.basicConfig(level=logging.DEBUG)
logging.getLogger('matplotlib').setLevel(logging.WARNING)
__author__ = 'Jan Petykiewicz'
def pcolor(ax, v) -> None:
mappable = ax.pcolor(v, cmap='seismic', norm=colors.CenteredNorm())
ax.axis('equal')
ax.get_figure().colorbar(mappable)
def test0(solver=generic_solver):
dx = 50 # discretization (nm/cell)
pml_thickness = 10 # (number of cells)
wl = 1550 # Excitation wavelength
omega = 2 * numpy.pi / wl
# Device design parameters
radii = (1, 0.6)
th = 220
center = [0, 0, 0]
# refractive indices
n_ring = numpy.sqrt(12.6) # ~Si
n_air = 4.0 # air
# Half-dimensions of the simulation grid
xyz_max = numpy.array([1.2, 1.2, 0.3]) * 1000 + pml_thickness * dx
# Coordinates of the edges of the cells.
half_edge_coords = [numpy.arange(dx/2, m + dx, step=dx) for m in xyz_max]
edge_coords = [numpy.hstack((-h[::-1], h)) for h in half_edge_coords]
# #### Create the grid, mask, and draw the device ####
grid = gridlock.Grid(edge_coords)
epsilon = grid.allocate(n_air**2, dtype=numpy.float32)
grid.draw_cylinder(
epsilon,
h = dict(axis='z', center=center[2], span=th),
radius = max(radii),
center2d = center[:2],
foreground = n_ring ** 2,
num_points = 24,
)
grid.draw_cylinder(
epsilon,
h = dict(axis='z', center=center[2], span=th * 1.1),
radius = min(radii),
center2d = center[:2],
foreground = n_air ** 2,
num_points = 24,
)
dxes = [grid.dxyz, grid.autoshifted_dxyz()]
for a in (0, 1, 2):
for p in (-1, 1):
dxes = meanas.fdfd.scpml.stretch_with_scpml(dxes, axis=a, polarity=p, omega=omega,
thickness=pml_thickness)
J = [numpy.zeros_like(epsilon[0], dtype=complex) for _ in range(3)]
J[1][15, grid.shape[1]//2, grid.shape[2]//2] = 1
#
# Solve!
#
sim_args = dict(
omega = omega,
dxes = dxes,
epsilon = vec(epsilon),
)
x = solver(J=vec(J), **sim_args)
A = operators.e_full(omega, dxes, vec(epsilon)).tocsr()
b = -1j * omega * vec(J)
print('Norm of the residual is ', norm(A @ x - b) / norm(b))
E = unvec(x, grid.shape)
#
# Plot results
#
grid.visualize_slice(E.real, plane=dict(z=0), which_shifts=1, pcolormesh_args=dict(norm=colors.CenteredNorm(), cmap='bwr'))
if __name__ == '__main__':
test0()

View file

@ -1,30 +1,18 @@
""" """
Example code for a broadband FDTD run with phasor extraction. Example code for running an OpenCL FDTD simulation
This script shows the intended low-level workflow for: See main() for simulation setup.
1. building a Yee-grid simulation with CPML on all faces,
2. driving it with an electric-current pulse,
3. extracting a single-frequency phasor on the fly, and
4. checking that phasor against the matching stretched-grid FDFD operator.
""" """
import sys import sys
import time import time
import copy
import numpy import numpy
import h5py import h5py
from numpy.linalg import norm
from meanas import fdtd from meanas import fdtd
from meanas.fdtd import cpml_params, updates_with_cpml from meanas.fdtd import cpml_params, updates_with_cpml
from meanas.fdtd.misc import gaussian_packet from masque import Pattern, shapes
from meanas.fdfd.operators import e_full
from meanas.fdfd.scpml import stretch_with_scpml
from meanas.fdmath import vec
from masque import Pattern, Circle, Polygon
import gridlock import gridlock
import pcgen import pcgen
@ -53,50 +41,50 @@ def perturbed_l3(a: float, radius: float, **kwargs) -> Pattern:
`masque.Pattern` object containing the L3 design `masque.Pattern` object containing the L3 design
""" """
default_args = { default_args = {'hole_dose': 1,
'hole_layer': 0, 'trench_dose': 1,
'trench_layer': 1, 'hole_layer': 0,
'shifts_a': (0.15, 0, 0.075), 'trench_layer': 1,
'shifts_r': (1.0, 1.0, 1.0), 'shifts_a': (0.15, 0, 0.075),
'xy_size': (10, 10), 'shifts_r': (1.0, 1.0, 1.0),
'perturbed_radius': 1.1, 'xy_size': (10, 10),
'trench_width': 1.2e3, 'perturbed_radius': 1.1,
} 'trench_width': 1.2e3,
}
kwargs = {**default_args, **kwargs} kwargs = {**default_args, **kwargs}
xyr = pcgen.l3_shift_perturbed_defect( xyr = pcgen.l3_shift_perturbed_defect(mirror_dims=kwargs['xy_size'],
mirror_dims=kwargs['xy_size'], perturbed_radius=kwargs['perturbed_radius'],
perturbed_radius=kwargs['perturbed_radius'], shifts_a=kwargs['shifts_a'],
shifts_a=kwargs['shifts_a'], shifts_r=kwargs['shifts_r'])
shifts_r=kwargs['shifts_r'],
)
xyr *= a xyr *= a
xyr[:, 2] *= radius xyr[:, 2] *= radius
pat = Pattern() pat = Pattern()
#pat.name = f'L3p-a{a:g}r{radius:g}rp{kwargs["perturbed_radius"]:g}' pat.name = f'L3p-a{a:g}r{radius:g}rp{kwargs["perturbed_radius"]:g}'
pat.shapes[(kwargs['hole_layer'], 0)] += [ pat.shapes += [shapes.Circle(radius=r, offset=(x, y),
Circle(radius=r, offset=(x, y)) dose=kwargs['hole_dose'],
for x, y, r in xyr] layer=kwargs['hole_layer'])
for x, y, r in xyr]
maxes = numpy.max(numpy.fabs(xyr), axis=0) maxes = numpy.max(numpy.fabs(xyr), axis=0)
pat.shapes[(kwargs['trench_layer'], 0)] += [ pat.shapes += [shapes.Polygon.rectangle(
Polygon.rectangle( lx=(2 * maxes[0]), ly=kwargs['trench_width'],
lx=(2 * maxes[0]), ly=kwargs['trench_width'], offset=(0, s * (maxes[1] + a + kwargs['trench_width'] / 2)),
offset=(0, s * (maxes[1] + a + kwargs['trench_width'] / 2)) dose=kwargs['trench_dose'], layer=kwargs['trench_layer'])
) for s in (-1, 1)]
for s in (-1, 1)]
return pat return pat
def main() -> None: def main():
dtype = numpy.float32 dtype = numpy.float32
max_t = 3600 # number of timesteps max_t = 8000 # number of timesteps
dx = 40 # discretization (nm/cell) dx = 40 # discretization (nm/cell)
pml_thickness = 8 # (number of cells) pml_thickness = 8 # (number of cells)
wl = 1550 # Excitation wavelength and fwhm wl = 1550 # Excitation wavelength and fwhm
dwl = 200
# Device design parameters # Device design parameters
xy_size = numpy.array([10, 10]) xy_size = numpy.array([10, 10])
@ -119,97 +107,68 @@ def main() -> None:
# #### Create the grid, mask, and draw the device #### # #### Create the grid, mask, and draw the device ####
grid = gridlock.Grid(edge_coords) grid = gridlock.Grid(edge_coords)
epsilon = grid.allocate(n_air ** 2, dtype=dtype) epsilon = grid.allocate(n_air**2, dtype=dtype)
grid.draw_slab( grid.draw_slab(epsilon,
epsilon, surface_normal=2,
slab = dict(axis='z', center=0, span=th), center=[0, 0, 0],
foreground = n_slab ** 2, thickness=th,
) eps=n_slab**2)
mask = perturbed_l3(a, r) mask = perturbed_l3(a, r)
grid.draw_polygons(
epsilon,
slab = dict(axis='z', center=0, span=2 * th),
foreground = n_air ** 2,
offset2d = (0, 0),
polygons = mask.as_polygons(library=None),
)
print(f'{grid.shape=}') grid.draw_polygons(epsilon,
surface_normal=2,
center=[0, 0, 0],
thickness=2 * th,
eps=n_air**2,
polygons=mask.as_polygons())
dt = dx * 0.99 / numpy.sqrt(3) print(grid.shape)
ee = numpy.zeros_like(epsilon, dtype=complex)
hh = numpy.zeros_like(epsilon, dtype=complex) dt = .99/numpy.sqrt(3)
e = [numpy.zeros_like(epsilon[0], dtype=dtype) for _ in range(3)]
h = [numpy.zeros_like(epsilon[0], dtype=dtype) for _ in range(3)]
dxes = [grid.dxyz, grid.autoshifted_dxyz()] dxes = [grid.dxyz, grid.autoshifted_dxyz()]
# PMLs in every direction # PMLs in every direction
pml_params = [ pml_params = [[cpml_params(axis=dd, polarity=pp, dt=dt,
[cpml_params(axis=dd, polarity=pp, dt=dt, thickness=pml_thickness, epsilon_eff=n_air ** 2) thickness=pml_thickness, epsilon_eff=1.0**2)
for pp in (-1, +1)] for pp in (-1, +1)]
for dd in range(3)] for dd in range(3)]
update_E, update_H = updates_with_cpml(cpml_params=pml_params, dt=dt, dxes=dxes, epsilon=epsilon, dtype=complex) update_E, update_H = updates_with_cpml(cpml_params=pml_params, dt=dt,
dxes=dxes, epsilon=epsilon)
# sample_interval = numpy.floor(1 / (2 * 1 / wl * dt)).astype(int) # Source parameters and function
# print(f'Save time interval would be {sample_interval} * dt = {sample_interval * dt:3g}') w = 2 * numpy.pi * dx / wl
fwhm = dwl * w * w / (2 * numpy.pi * dx)
alpha = (fwhm ** 2) / 8 * numpy.log(2)
delay = 7/numpy.sqrt(2 * alpha)
def field_source(i):
# Build the pulse directly at the current half-steps and normalize that t0 = i * dt - delay
# scalar waveform so its extracted temporal phasor is exactly 1 at omega. return numpy.sin(w * t0) * numpy.exp(-alpha * t0**2)
source_phasor, _delay = gaussian_packet(wl=wl, dwl=100, dt=dt, turn_on=1e-5)
aa, cc, ss = source_phasor(numpy.arange(max_t) + 0.5)
source_waveform = aa * (cc + 1j * ss)
omega = 2 * numpy.pi / wl
pulse_scale = fdtd.temporal_phasor_scale(source_waveform, omega, dt, offset_steps=0.5)[0]
j_source = numpy.zeros_like(epsilon, dtype=complex)
j_source[1, *(grid.shape // 2)] = epsilon[1, *(grid.shape // 2)]
jph = numpy.zeros((1, *epsilon.shape), dtype=complex)
eph = numpy.zeros((1, *epsilon.shape), dtype=complex)
hph = numpy.zeros((1, *epsilon.shape), dtype=complex)
# #### Run a bunch of iterations #### # #### Run a bunch of iterations ####
output_file = h5py.File('simulation_output.h5', 'w') output_file = h5py.File('simulation_output.h5', 'w')
start = time.perf_counter() start = time.perf_counter()
for tt in range(max_t): for t in range(max_t):
update_E(ee, hh, epsilon) update_E(e, h, epsilon)
# Electric-current injection uses E -= dt * J / epsilon, which is the e[1][tuple(grid.shape//2)] += field_source(t)
# same sign convention used by the matching FDFD right-hand side. update_H(e, h)
j_step = pulse_scale * source_waveform[tt] * j_source
ee -= dt * j_step / epsilon
update_H(ee, hh)
avg_rate = (tt + 1) / (time.perf_counter() - start) print('iteration {}: average {} iterations per sec'.format(t, (t+1)/(time.perf_counter()-start)))
sys.stdout.flush() sys.stdout.flush()
if tt % 200 == 0: if t % 20 == 0:
print(f'iteration {tt}: average {avg_rate} iterations per sec') r = sum([(f * f * e).sum() for f, e in zip(e, epsilon)])
E_energy_sum = (ee.conj() * ee * epsilon).sum().real print('E sum', r)
print(f'{E_energy_sum=}')
# Save field slices # Save field slices
if (tt % 20 == 0 and (max_t - tt <= 1000 or tt <= 2000)) or tt == max_t - 1: if (t % 20 == 0 and (max_t - t <= 1000 or t <= 2000)) or t == max_t-1:
print(f'saving E-field at iteration {tt}') print('saving E-field')
output_file[f'/E_t{tt}'] = ee[:, :, :, ee.shape[3] // 2] for j, f in enumerate(e):
output_file['/E{}_t{}'.format('xyz'[j], t)] = f[:, :, round(f.shape[2]/2)]
fdtd.accumulate_phasor_j(jph, omega, dt, j_step, tt)
fdtd.accumulate_phasor_e(eph, omega, dt, ee, tt + 1)
fdtd.accumulate_phasor_h(hph, omega, dt, hh, tt + 1)
Eph = eph[0]
Jph = jph[0]
b = -1j * omega * Jph
dxes_fdfd = copy.deepcopy(dxes)
for pp in (-1, +1):
for dd in range(3):
stretch_with_scpml(dxes_fdfd, axis=dd, polarity=pp, omega=omega, epsilon_effective=n_air ** 2, thickness=pml_thickness)
# Compare the extracted phasor to the FDFD operator on the stretched grid,
# not the unstretched Yee spacings used by the raw time-domain update.
A = e_full(omega=omega, dxes=dxes_fdfd, epsilon=epsilon)
residual = norm(A @ vec(Eph) - vec(b)) / norm(vec(b))
print(f'FDFD residual is {residual}')
if __name__ == '__main__': if __name__ == '__main__':
main() main()

View file

@ -3,7 +3,7 @@ import numpy
from numpy.linalg import norm from numpy.linalg import norm
from meanas.fdmath import vec, unvec from meanas.fdmath import vec, unvec
from meanas.fdfd import waveguide_cyl, functional, scpml from meanas.fdfd import waveguide_mode, functional, scpml
from meanas.fdfd.solvers import generic as generic_solver from meanas.fdfd.solvers import generic as generic_solver
import gridlock import gridlock
@ -37,34 +37,29 @@ def test1(solver=generic_solver):
xyz_max = numpy.array([800, y_max, z_max]) + (pml_thickness + 2) * dx xyz_max = numpy.array([800, y_max, z_max]) + (pml_thickness + 2) * dx
# Coordinates of the edges of the cells. # Coordinates of the edges of the cells.
half_edge_coords = [numpy.arange(dx / 2, m + dx / 2, step=dx) for m in xyz_max] half_edge_coords = [numpy.arange(dx/2, m + dx/2, step=dx) for m in xyz_max]
edge_coords = [numpy.hstack((-h[::-1], h)) for h in half_edge_coords] edge_coords = [numpy.hstack((-h[::-1], h)) for h in half_edge_coords]
edge_coords[0] = numpy.array([-dx, dx]) edge_coords[0] = numpy.array([-dx, dx])
# #### Create the grid and draw the device #### # #### Create the grid and draw the device ####
grid = gridlock.Grid(edge_coords) grid = gridlock.Grid(edge_coords)
epsilon = grid.allocate(n_air**2, dtype=numpy.float32) epsilon = grid.allocate(n_air**2, dtype=numpy.float32)
grid.draw_cuboid(epsilon, center=center, dimensions=[8e3, w, th], foreground=n_wg**2) grid.draw_cuboid(epsilon, center=center, dimensions=[8e3, w, th], eps=n_wg**2)
dxes = [grid.dxyz, grid.autoshifted_dxyz()] dxes = [grid.dxyz, grid.autoshifted_dxyz()]
for a in (1, 2): for a in (1, 2):
for p in (-1, 1): for p in (-1, 1):
dxes = scpml.stretch_with_scpml( dxes = scmpl.stretch_with_scpml(dxes, omega=omega, axis=a, polarity=p,
dxes, thickness=pml_thickness)
omega=omega,
axis=a,
polarity=p,
thickness=pml_thickness,
)
wg_args = { wg_args = {
'omega': omega, 'omega': omega,
'dxes': [(d[1], d[2]) for d in dxes], 'dxes': [(d[1], d[2]) for d in dxes],
'epsilon': vec(epsilon.transpose([0, 2, 3, 1])), 'epsilon': vec(g.transpose([1, 2, 0]) for g in epsilon),
'r0': r0, 'r0': r0,
} }
wg_results = waveguide_cyl.solve_mode(mode_number=0, **wg_args) wg_results = waveguide_mode.solve_waveguide_mode_cylindrical(mode_number=0, **wg_args)
E = wg_results['E'] E = wg_results['E']
@ -75,17 +70,20 @@ def test1(solver=generic_solver):
''' '''
Plot results Plot results
''' '''
def pcolor(fig, ax, v, title): def pcolor(v):
vmax = numpy.max(numpy.abs(v)) vmax = numpy.max(numpy.abs(v))
mappable = ax.pcolormesh(v.T, cmap='seismic', vmin=-vmax, vmax=vmax) pyplot.pcolor(v.T, cmap='seismic', vmin=-vmax, vmax=vmax)
ax.set_aspect('equal', adjustable='box') pyplot.axis('equal')
ax.set_title(title) pyplot.colorbar()
ax.figure.colorbar(mappable)
fig, axes = pyplot.subplots(2, 2) pyplot.figure()
pcolor(fig, axes[0][0], numpy.real(E[0]), 'Ex') pyplot.subplot(2, 2, 1)
pcolor(fig, axes[0][1], numpy.real(E[1]), 'Ey') pcolor(numpy.real(E[0][:, :]))
pcolor(fig, axes[1][0], numpy.real(E[2]), 'Ez') pyplot.subplot(2, 2, 2)
pcolor(numpy.real(E[1][:, :]))
pyplot.subplot(2, 2, 3)
pcolor(numpy.real(E[2][:, :]))
pyplot.subplot(2, 2, 4)
pyplot.show() pyplot.show()

View file

@ -1,342 +0,0 @@
"""
Example code for guided-wave FDFD and FDTD comparison.
This example is the reference workflow for:
1. solving a waveguide port mode,
2. turning that mode into a one-sided source and overlap window,
3. comparing a direct FDFD solve against a time-domain phasor extracted from FDTD.
"""
from typing import Callable
import logging
import time
import copy
import numpy
import h5py
from numpy.linalg import norm
import gridlock
import meanas
from meanas import fdtd, fdfd
from meanas.fdtd import cpml_params, updates_with_cpml
from meanas.fdtd.misc import gaussian_packet
from meanas.fdmath import vec, unvec, vcfdfield_t, cfdfield_t, fdfield_t, dx_lists_t
from meanas.fdfd import waveguide_3d, functional, scpml, operators
from meanas.fdfd.solvers import generic as generic_solver
from meanas.fdfd.operators import e_full
from meanas.fdfd.scpml import stretch_with_scpml
logging.basicConfig(level=logging.DEBUG)
for pp in ('matplotlib', 'PIL'):
logging.getLogger(pp).setLevel(logging.WARNING)
logger = logging.getLogger(__name__)
def pcolor(vv, fig=None, ax=None) -> None:
if fig is None:
assert ax is None
fig, ax = pyplot.subplots()
mb = ax.pcolor(vv, cmap='seismic', norm=colors.CenteredNorm())
fig.colorbar(mb)
ax.set_aspect('equal')
def draw_grid(
*,
dx: float,
pml_thickness: int,
n_wg: float = 3.476, # Si index @ 1550
n_cladding: float = 1.00, # Air index
wg_w: float = 400,
wg_th: float = 200,
) -> tuple[gridlock.Grid, fdfield_t]:
""" Create the grid and draw the device """
# Half-dimensions of the simulation grid
xyz_max = numpy.array([800, 900, 600]) + (pml_thickness + 2) * dx
# Coordinates of the edges of the cells.
half_edge_coords = [numpy.arange(dx / 2, m + dx / 2, step=dx) for m in xyz_max]
edge_coords = [numpy.hstack((-h[::-1], h)) for h in half_edge_coords]
grid = gridlock.Grid(edge_coords)
epsilon = grid.allocate(n_cladding**2, dtype=numpy.float32)
grid.draw_cuboid(
epsilon,
x = dict(center=0, span=8e3),
y = dict(center=0, span=wg_w),
z = dict(center=0, span=wg_th),
foreground = n_wg ** 2,
)
return grid, epsilon
def get_waveguide_mode(
*,
grid: gridlock.Grid,
dxes: dx_lists_t,
omega: float,
epsilon: fdfield_t,
) -> tuple[vcfdfield_t, vcfdfield_t]:
"""Create a mode source and overlap window for one forward-going port."""
dims = numpy.array([[-10, -20, -15],
[-10, 20, 15]]) * [[numpy.median(numpy.real(dx)) for dx in dxes[0]]]
ind_dims = (grid.pos2ind(dims[0], which_shifts=None).astype(int),
grid.pos2ind(dims[1], which_shifts=None).astype(int))
wg_args = dict(
slices = [slice(i, f+1) for i, f in zip(*ind_dims)],
dxes = dxes,
axis = 0,
polarity = +1,
)
wg_results = waveguide_3d.solve_mode(mode_number=0, omega=omega, epsilon=epsilon, **wg_args)
J = waveguide_3d.compute_source(E=wg_results['E'], wavenumber=wg_results['wavenumber'],
omega=omega, epsilon=epsilon, **wg_args)
# compute_overlap_e() returns the normalized upstream overlap window used to
# project another field onto this same guided mode.
e_overlap = waveguide_3d.compute_overlap_e(E=wg_results['E'], wavenumber=wg_results['wavenumber'], **wg_args)
return J, e_overlap
def main(
*,
solver: Callable = generic_solver,
dx: float = 40, # discretization (nm / cell)
pml_thickness: int = 10, # (number of cells)
wl: float = 1550, # Excitation wavelength
wg_w: float = 600, # Waveguide width
wg_th: float = 220, # Waveguide thickness
):
omega = 2 * numpy.pi / wl
grid, epsilon = draw_grid(dx=dx, pml_thickness=pml_thickness)
# Add SCPML stretching to the FDFD grid; this matches the CPML-backed FDTD
# run below so the two solvers see the same absorbing boundary profile.
dxes = [grid.dxyz, grid.autoshifted_dxyz()]
for a in (0, 1, 2):
for p in (-1, 1):
dxes = scpml.stretch_with_scpml(dxes, omega=omega, axis=a, polarity=p, thickness=pml_thickness)
J, e_overlap = get_waveguide_mode(grid=grid, dxes=dxes, omega=omega, epsilon=epsilon)
pecg = numpy.zeros_like(epsilon)
# pecg.draw_cuboid(pecg, center=[700, 0, 0], dimensions=[80, 1e8, 1e8], eps=1)
# pecg.visualize_isosurface(pecg)
pmcg = numpy.zeros_like(epsilon)
# grid.draw_cuboid(pmcg, center=[700, 0, 0], dimensions=[80, 1e8, 1e8], eps=1)
# grid.visualize_isosurface(pmcg)
# ss = (1, slice(None), J.shape[2]//2+6, slice(None))
# pcolor(J3[ss].T.imag)
# pcolor((numpy.abs(J3).sum(axis=(0, 2)) > 0).astype(float).T)
# pyplot.show(block=True)
E_fdfd = fdfd_solve(
omega = omega,
dxes = dxes,
epsilon = epsilon,
J = J,
pec = pecg,
pmc = pmcg,
)
#
# Plot results
#
center = grid.pos2ind([0, 0, 0], None).astype(int)
fig, axes = pyplot.subplots(2, 2)
pcolor(numpy.real(E[1][center[0], :, :]).T, fig=fig, ax=axes[0, 0])
axes[0, 1].plot(numpy.log10(numpy.abs(E[1][:, center[1], center[2]]) + 1e-10))
axes[0, 1].grid(alpha=0.6)
axes[0, 1].set_ylabel('log10 of field')
pcolor(numpy.real(E[1][:, :, center[2]]).T, fig=fig, ax=axes[1, 0])
def poyntings(E):
H = functional.e2h(omega, dxes)(E)
poynting = fdtd.poynting(e=E, h=H.conj(), dxes=dxes)
cross1 = operators.poynting_e_cross(vec(E), dxes) @ vec(H).conj()
cross2 = operators.poynting_h_cross(vec(H), dxes) @ vec(E).conj() * -1
s1 = 0.5 * unvec(numpy.real(cross1), grid.shape)
s2 = 0.5 * unvec(numpy.real(cross2), grid.shape)
s0 = 0.5 * poynting.real
# s2 = poynting.imag
return s0, s1, s2
s0x, s1x, s2x = poyntings(E)
axes[1, 1].plot(s0x[0].sum(axis=2).sum(axis=1), label='s0', marker='.')
axes[1, 1].plot(s1x[0].sum(axis=2).sum(axis=1), label='s1', marker='.')
axes[1, 1].plot(s2x[0].sum(axis=2).sum(axis=1), label='s2', marker='.')
axes[1, 1].plot(E[1][:, center[1], center[2]].real.T, label='Ey', marker='x')
axes[1, 1].grid(alpha=0.6)
axes[1, 1].legend()
q = []
for i in range(-5, 30):
e_ovl_rolled = numpy.roll(e_overlap, i, axis=1)
q += [numpy.abs(vec(E) @ vec(e_ovl_rolled).conj())]
fig, ax = pyplot.subplots()
ax.plot(q, marker='.')
ax.grid(alpha=0.6)
ax.set_title('Overlap with mode')
logger.info('Average overlap with mode:', sum(q[8:32]) / len(q[8:32]))
pyplot.show(block=True)
def fdfd_solve(
*,
omega: float,
dxes = dx_lists_t,
epsilon: fdfield_t,
J: cfdfield_t,
pec: fdfield_t,
pmc: fdfield_t,
) -> cfdfield_t:
""" Construct and run the solve """
sim_args = dict(
omega = omega,
dxes = dxes,
epsilon = vec(epsilon),
pec = vec(pecg),
pmc = vec(pmcg),
)
x = solver(J=vec(J), **sim_args)
b = -1j * omega * vec(J)
A = operators.e_full(**sim_args).tocsr()
logger.info('Norm of the residual is ', norm(A @ x - b) / norm(b))
E = unvec(x, epsilon.shape[1:])
return E
def main2():
dtype = numpy.float32
max_t = 3600 # number of timesteps
dx = 40 # discretization (nm/cell)
pml_thickness = 8 # (number of cells)
wl = 1550 # Excitation wavelength and fwhm
dwl = 100
# Device design parameters
xy_size = numpy.array([10, 10])
a = 430
r = 0.285
th = 170
# refractive indices
n_slab = 3.408 # InGaAsP(80, 50) @ 1550nm
n_cladding = 1.0 # air
# Half-dimensions of the simulation grid
xy_max = (xy_size + 1) * a * [1, numpy.sqrt(3)/2]
z_max = 1.6 * a
xyz_max = numpy.hstack((xy_max, z_max)) + pml_thickness * dx
# Coordinates of the edges of the cells. The fdtd package can only do square grids at the moment.
half_edge_coords = [numpy.arange(dx/2, m + dx, step=dx) for m in xyz_max]
edge_coords = [numpy.hstack((-h[::-1], h)) for h in half_edge_coords]
# #### Create the grid, mask, and draw the device ####
grid = gridlock.Grid(edge_coords)
epsilon = grid.allocate(n_cladding ** 2, dtype=dtype)
grid.draw_slab(
epsilon,
slab = dict(axis='z', center=0, span=th),
foreground = n_slab ** 2,
)
print(f'{grid.shape=}')
dt = dx * 0.99 / numpy.sqrt(3)
ee = numpy.zeros_like(epsilon, dtype=complex)
hh = numpy.zeros_like(epsilon, dtype=complex)
dxes = [grid.dxyz, grid.autoshifted_dxyz()]
# PMLs in every direction
pml_params = [
[cpml_params(axis=dd, polarity=pp, dt=dt, thickness=pml_thickness, epsilon_eff=n_cladding ** 2)
for pp in (-1, +1)]
for dd in range(3)]
update_E, update_H = updates_with_cpml(cpml_params=pml_params, dt=dt, dxes=dxes, epsilon=epsilon, dtype=complex)
# sample_interval = numpy.floor(1 / (2 * 1 / wl * dt)).astype(int)
# print(f'Save time interval would be {sample_interval} * dt = {sample_interval * dt:3g}')
# Sample the pulse at the current half-steps and normalize that scalar
# waveform so the extracted temporal phasor is exactly 1 at omega.
source_phasor, _delay = gaussian_packet(wl=wl, dwl=100, dt=dt, turn_on=1e-5)
aa, cc, ss = source_phasor(numpy.arange(max_t) + 0.5)
source_waveform = aa * (cc + 1j * ss)
omega = 2 * numpy.pi / wl
pulse_scale = fdtd.temporal_phasor_scale(source_waveform, omega, dt, offset_steps=0.5)[0]
j_source = numpy.zeros_like(epsilon, dtype=complex)
j_source[1, *(grid.shape // 2)] = epsilon[1, *(grid.shape // 2)]
jph = numpy.zeros((1, *epsilon.shape), dtype=complex)
eph = numpy.zeros((1, *epsilon.shape), dtype=complex)
hph = numpy.zeros((1, *epsilon.shape), dtype=complex)
# #### Run a bunch of iterations ####
output_file = h5py.File('simulation_output.h5', 'w')
start = time.perf_counter()
for tt in range(max_t):
update_E(ee, hh, epsilon)
# Electric-current injection uses E -= dt * J / epsilon, which is the
# sign convention matched by the FDFD source term -1j * omega * J.
j_step = pulse_scale * source_waveform[tt] * j_source
ee -= dt * j_step / epsilon
update_H(ee, hh)
avg_rate = (tt + 1) / (time.perf_counter() - start)
if tt % 200 == 0:
print(f'iteration {tt}: average {avg_rate} iterations per sec')
E_energy_sum = (ee.conj() * ee * epsilon).sum().real
print(f'{E_energy_sum=}')
# Save field slices
if (tt % 20 == 0 and (max_t - tt <= 1000 or tt <= 2000)) or tt == max_t - 1:
print(f'saving E-field at iteration {tt}')
output_file[f'/E_t{tt}'] = ee[:, :, :, ee.shape[3] // 2]
fdtd.accumulate_phasor_j(jph, omega, dt, j_step, tt)
fdtd.accumulate_phasor_e(eph, omega, dt, ee, tt + 1)
fdtd.accumulate_phasor_h(hph, omega, dt, hh, tt + 1)
Eph = eph[0]
Jph = jph[0]
b = -1j * omega * Jph
dxes_fdfd = copy.deepcopy(dxes)
for pp in (-1, +1):
for dd in range(3):
stretch_with_scpml(dxes_fdfd, axis=dd, polarity=pp, omega=omega, epsilon_effective=n_cladding ** 2, thickness=pml_thickness)
# Residuals must be checked on the stretched FDFD grid, because the FDTD run
# already includes those same absorbing layers through CPML.
A = e_full(omega=omega, dxes=dxes_fdfd, epsilon=epsilon)
residual = norm(A @ vec(Eph) - vec(b)) / norm(vec(b))
print(f'FDFD residual is {residual}')
if __name__ == '__main__':
main()

View file

@ -1,219 +0,0 @@
"""
Real-valued straight-waveguide FDTD/FDFD comparison.
This example shows the user-facing "compare real FDTD against reconstructed real
FDFD" workflow:
1. build a straight waveguide on a uniform Yee grid,
2. drive it with a real-valued continuous-wave mode source,
3. solve the matching FDFD problem from the analytic source phasor, and
4. compare late real monitor slices against `fdtd.reconstruct_real_e/h(...)`.
Unlike the phasor-based examples, this script does not use extracted phasors as
the main output. It is a stricter diagnostic: the comparison target is the raw
real field itself, with full-plane, mode-weighted, guided-mode, and orthogonal-
residual errors reported. Strong phasor agreement can coexist with visibly
larger raw-snapshot error because the latter includes weak nonguided tails on
the monitor plane.
"""
import numpy
from meanas import fdfd, fdtd
from meanas.fdfd import functional, scpml, waveguide_3d
from meanas.fdmath import vec, unvec
DT = 0.25
PERIOD_STEPS = 64
OMEGA = 2 * numpy.pi / (PERIOD_STEPS * DT)
CPML_THICKNESS = 3
SHAPE = (3, 37, 13, 13)
SOURCE_SLICES = (slice(5, 6), slice(None), slice(None))
MONITOR_SLICES = (slice(30, 31), slice(None), slice(None))
WARMUP_PERIODS = 16
SOURCE_PHASE = 0.4
CORE_SLICES = (slice(None), slice(None), slice(4, 9), slice(4, 9))
def build_uniform_dxes(shape: tuple[int, int, int, int]) -> list[list[numpy.ndarray]]:
return [[numpy.ones(shape[axis + 1]) for axis in range(3)] for _ in range(2)]
def build_epsilon(shape: tuple[int, int, int, int]) -> numpy.ndarray:
epsilon = numpy.ones(shape, dtype=float)
y0 = (shape[2] - 3) // 2
z0 = (shape[3] - 3) // 2
epsilon[:, :, y0:y0 + 3, z0:z0 + 3] = 12.0
return epsilon
def build_stretched_dxes(base_dxes: list[list[numpy.ndarray]]) -> list[list[numpy.ndarray]]:
stretched_dxes = [[dx.copy() for dx in group] for group in base_dxes]
for axis in (0, 1, 2):
for polarity in (-1, 1):
stretched_dxes = scpml.stretch_with_scpml(
stretched_dxes,
axis=axis,
polarity=polarity,
omega=OMEGA,
epsilon_effective=1.0,
thickness=CPML_THICKNESS,
)
return stretched_dxes
def build_cpml_params() -> list[list[dict[str, numpy.ndarray | float]]]:
return [
[fdtd.cpml_params(axis=axis, polarity=polarity, dt=DT, thickness=CPML_THICKNESS, epsilon_eff=1.0)
for polarity in (-1, 1)]
for axis in range(3)
]
def weighted_rel_err(observed: numpy.ndarray, reference: numpy.ndarray, weight: numpy.ndarray) -> float:
return numpy.linalg.norm((observed - reference) * weight) / numpy.linalg.norm(reference * weight)
def project_onto_mode(observed: numpy.ndarray, mode: numpy.ndarray) -> tuple[complex, numpy.ndarray, numpy.ndarray]:
coefficient = numpy.vdot(mode, observed) / numpy.vdot(mode, mode)
guided = coefficient * mode
residual = observed - guided
return coefficient, guided, residual
def main() -> None:
epsilon = build_epsilon(SHAPE)
base_dxes = build_uniform_dxes(SHAPE)
stretched_dxes = build_stretched_dxes(base_dxes)
source_mode = waveguide_3d.solve_mode(
0,
omega=OMEGA,
dxes=base_dxes,
axis=0,
polarity=1,
slices=SOURCE_SLICES,
epsilon=epsilon,
)
j_mode = waveguide_3d.compute_source(
E=source_mode['E'],
wavenumber=source_mode['wavenumber'],
omega=OMEGA,
dxes=base_dxes,
axis=0,
polarity=1,
slices=SOURCE_SLICES,
epsilon=epsilon,
)
# A small global phase aligns the real-valued source with the late-cycle
# raw-snapshot diagnostic. The underlying phasor problem is unchanged.
j_mode *= numpy.exp(1j * SOURCE_PHASE)
monitor_mode = waveguide_3d.solve_mode(
0,
omega=OMEGA,
dxes=base_dxes,
axis=0,
polarity=1,
slices=MONITOR_SLICES,
epsilon=epsilon,
)
e_weight = numpy.abs(monitor_mode['E'][:, MONITOR_SLICES[0], :, :])
h_weight = numpy.abs(monitor_mode['H'][:, MONITOR_SLICES[0], :, :])
e_mode = monitor_mode['E'][:, MONITOR_SLICES[0], :, :]
h_mode = monitor_mode['H'][:, MONITOR_SLICES[0], :, :]
e_fdfd = unvec(
fdfd.solvers.generic(
J=vec(j_mode),
omega=OMEGA,
dxes=stretched_dxes,
epsilon=vec(epsilon),
matrix_solver_opts={'atol': 1e-10, 'rtol': 1e-7},
),
SHAPE[1:],
)
h_fdfd = functional.e2h(OMEGA, stretched_dxes)(e_fdfd)
update_e, update_h = fdtd.updates_with_cpml(
cpml_params=build_cpml_params(),
dt=DT,
dxes=base_dxes,
epsilon=epsilon,
)
e_field = numpy.zeros_like(epsilon)
h_field = numpy.zeros_like(epsilon)
total_steps = (WARMUP_PERIODS + 1) * PERIOD_STEPS
e_errors: list[float] = []
h_errors: list[float] = []
e_core_errors: list[float] = []
h_core_errors: list[float] = []
e_weighted_errors: list[float] = []
h_weighted_errors: list[float] = []
e_guided_errors: list[float] = []
h_guided_errors: list[float] = []
e_residual_errors: list[float] = []
h_residual_errors: list[float] = []
for step in range(total_steps):
update_e(e_field, h_field, epsilon)
# Real-valued FDTD uses the real part of the analytic mode source.
t_half = (step + 0.5) * DT
j_real = (j_mode.real * numpy.cos(OMEGA * t_half) - j_mode.imag * numpy.sin(OMEGA * t_half)).real
e_field -= DT * j_real / epsilon
update_h(e_field, h_field)
if step >= total_steps - PERIOD_STEPS // 4:
reconstructed_e = fdtd.reconstruct_real_e(
e_fdfd[:, MONITOR_SLICES[0], :, :],
OMEGA,
DT,
step + 1,
)
reconstructed_h = fdtd.reconstruct_real_h(
h_fdfd[:, MONITOR_SLICES[0], :, :],
OMEGA,
DT,
step + 1,
)
e_monitor = e_field[:, MONITOR_SLICES[0], :, :]
h_monitor = h_field[:, MONITOR_SLICES[0], :, :]
e_errors.append(numpy.linalg.norm(e_monitor - reconstructed_e) / numpy.linalg.norm(reconstructed_e))
h_errors.append(numpy.linalg.norm(h_monitor - reconstructed_h) / numpy.linalg.norm(reconstructed_h))
e_core_errors.append(
numpy.linalg.norm(e_monitor[CORE_SLICES] - reconstructed_e[CORE_SLICES])
/ numpy.linalg.norm(reconstructed_e[CORE_SLICES]),
)
h_core_errors.append(
numpy.linalg.norm(h_monitor[CORE_SLICES] - reconstructed_h[CORE_SLICES])
/ numpy.linalg.norm(reconstructed_h[CORE_SLICES]),
)
e_weighted_errors.append(weighted_rel_err(e_monitor, reconstructed_e, e_weight))
h_weighted_errors.append(weighted_rel_err(h_monitor, reconstructed_h, h_weight))
e_guided_coeff, _, e_residual = project_onto_mode(e_monitor, e_mode)
e_guided_coeff_ref, _, e_residual_ref = project_onto_mode(reconstructed_e, e_mode)
h_guided_coeff, _, h_residual = project_onto_mode(h_monitor, h_mode)
h_guided_coeff_ref, _, h_residual_ref = project_onto_mode(reconstructed_h, h_mode)
e_guided_errors.append(abs(e_guided_coeff - e_guided_coeff_ref) / abs(e_guided_coeff_ref))
h_guided_errors.append(abs(h_guided_coeff - h_guided_coeff_ref) / abs(h_guided_coeff_ref))
e_residual_errors.append(numpy.linalg.norm(e_residual - e_residual_ref) / numpy.linalg.norm(e_residual_ref))
h_residual_errors.append(numpy.linalg.norm(h_residual - h_residual_ref) / numpy.linalg.norm(h_residual_ref))
print(f'late-cycle monitor E errors: min={min(e_errors):.4f} max={max(e_errors):.4f}')
print(f'late-cycle monitor H errors: min={min(h_errors):.4f} max={max(h_errors):.4f}')
print(f'late-cycle core-window E errors: min={min(e_core_errors):.4f} max={max(e_core_errors):.4f}')
print(f'late-cycle core-window H errors: min={min(h_core_errors):.4f} max={max(h_core_errors):.4f}')
print(f'late-cycle mode-weighted E errors: min={min(e_weighted_errors):.4f} max={max(e_weighted_errors):.4f}')
print(f'late-cycle mode-weighted H errors: min={min(h_weighted_errors):.4f} max={max(h_weighted_errors):.4f}')
print(f'late-cycle guided-mode E coefficient errors: min={min(e_guided_errors):.4f} max={max(e_guided_errors):.4f}')
print(f'late-cycle guided-mode H coefficient errors: min={min(h_guided_errors):.4f} max={max(h_guided_errors):.4f}')
print(f'late-cycle orthogonal-residual E errors: min={min(e_residual_errors):.4f} max={max(e_residual_errors):.4f}')
print(f'late-cycle orthogonal-residual H errors: min={min(h_residual_errors):.4f} max={max(h_residual_errors):.4f}')
if __name__ == '__main__':
main()

View file

@ -2,20 +2,18 @@
set -Eeuo pipefail set -Eeuo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd ~/projects/meanas
cd "$ROOT"
DOCS_TMP="$(mktemp -d)" # Approach 1: pdf to html?
cleanup() { #pdoc3 --pdf --force --template-dir pdoc_templates -o doc . | \
rm -rf "$DOCS_TMP" # pandoc --metadata=title:"meanas" --toc --toc-depth=4 --from=markdown+abbreviations --to=html --output=doc.html --gladtex -s -
}
trap cleanup EXIT
python3 "$ROOT/scripts/prepare_docs_sources.py" "$ROOT/meanas" "$DOCS_TMP" # Approach 2: pdf to html with gladtex
rm -rf _doc_mathimg
pdoc --pdf --force --template-dir pdoc_templates -o doc . > doc.md
pandoc --metadata=title:"meanas" --from=markdown+abbreviations --to=html --output=doc.htex --gladtex -s --css pdoc_templates/pdoc.css doc.md
gladtex -a -n -d _doc_mathimg -c white doc.htex
MKDOCSTRINGS_PYTHON_PATH="$DOCS_TMP" mkdocs build --clean # Approach 3: html with gladtex
#pdoc3 --html --force --template-dir pdoc_templates -o doc .
PRINT_PAGE='site/print_page/index.html' #find doc -iname '*.html' -exec gladtex -a -n -d _mathimg -c white {} \;
if [[ -f "$PRINT_PAGE" ]] && command -v htmlark >/dev/null 2>&1; then
htmlark "$PRINT_PAGE" -o site/standalone.html
fi

View file

@ -1,19 +1,18 @@
""" """
Electromagnetic simulation tools Electromagnetic simulation tools
See the tracked examples for end-to-end workflows, and `help(meanas)` for the See the readme or `import meanas; help(meanas)` for more info.
toolbox overview and API derivations.
""" """
import pathlib import pathlib
__version__ = '0.12' __version__ = '0.8'
__author__ = 'Jan Petykiewicz' __author__ = 'Jan Petykiewicz'
try: try:
readme_path = pathlib.Path(__file__).parent / 'README.md' with open(pathlib.Path(__file__).parent / 'README.md', 'r') as f:
with readme_path.open('r') as f:
__doc__ = f.read() __doc__ = f.read()
except Exception: except Exception:
pass pass

View file

@ -1,12 +1,12 @@
""" """
Solvers for eigenvalue / eigenvector problems Solvers for eigenvalue / eigenvector problems
""" """
from collections.abc import Callable from typing import Callable
import numpy import numpy
from numpy.typing import NDArray, ArrayLike from numpy.typing import NDArray, ArrayLike
from numpy.linalg import norm from numpy.linalg import norm
from scipy import sparse from scipy import sparse # type: ignore
import scipy.sparse.linalg as spalg import scipy.sparse.linalg as spalg # type: ignore
def power_iteration( def power_iteration(
@ -25,9 +25,8 @@ def power_iteration(
Returns: Returns:
(Largest-magnitude eigenvalue, Corresponding eigenvector estimate) (Largest-magnitude eigenvalue, Corresponding eigenvector estimate)
""" """
rng = numpy.random.default_rng()
if guess_vector is None: if guess_vector is None:
v = rng.random(operator.shape[0]) + 1j * rng.random(operator.shape[0]) v = numpy.random.rand(operator.shape[0]) + 1j * numpy.random.rand(operator.shape[0])
else: else:
v = guess_vector v = guess_vector
@ -64,10 +63,10 @@ def rayleigh_quotient_iteration(
(eigenvalues, eigenvectors) (eigenvalues, eigenvectors)
""" """
try: try:
(operator - sparse.eye_array(operator.shape[0])) (operator - sparse.eye(operator.shape[0]))
def shift(eigval: float) -> sparse.sparray: def shift(eigval: float) -> sparse:
return eigval * sparse.eye_array(operator.shape[0]) return eigval * sparse.eye(operator.shape[0])
if solver is None: if solver is None:
solver = spalg.spsolve solver = spalg.spsolve
@ -130,12 +129,12 @@ def signed_eigensolve(
# Try to combine, use general LinearOperator if we fail # Try to combine, use general LinearOperator if we fail
try: try:
shifted_operator = operator + shift * sparse.eye_array(operator.shape[0]) shifted_operator = operator + shift * sparse.eye(operator.shape[0])
except TypeError: except TypeError:
shifted_operator = operator + spalg.LinearOperator(shape=operator.shape, shifted_operator = operator + spalg.LinearOperator(shape=operator.shape,
matvec=lambda v: shift * v) matvec=lambda v: shift * v)
shifted_eigenvalues, eigenvectors = spalg.eigs(shifted_operator, which='LM', k=how_many, ncv=2 * how_many + 50) shifted_eigenvalues, eigenvectors = spalg.eigs(shifted_operator, which='LM', k=how_many, ncv=50)
eigenvalues = shifted_eigenvalues - shift eigenvalues = shifted_eigenvalues - shift
k = eigenvalues.argsort() k = eigenvalues.argsort()

View file

@ -1,4 +1,4 @@
r""" """
Tools for finite difference frequency-domain (FDFD) simulations and calculations. Tools for finite difference frequency-domain (FDFD) simulations and calculations.
These mostly involve picking a single frequency, then setting up and solving a These mostly involve picking a single frequency, then setting up and solving a
@ -9,12 +9,9 @@ Submodules:
- `operators`, `functional`: General FDFD problem setup. - `operators`, `functional`: General FDFD problem setup.
- `solvers`: Solver interface and reference implementation. - `solvers`: Solver interface and reference implementation.
- `scpml`: Stretched-coordinate perfectly matched layer (SCPML) boundary conditions. - `scpml`: Stretched-coordinate perfectly matched layer (scpml) boundary conditions
- `waveguide_2d`: Operators and mode-solver for waveguides with constant cross-section. - `waveguide_2d`: Operators and mode-solver for waveguides with constant cross-section.
- `waveguide_3d`: Functions for transforming `waveguide_2d` results into 3D, - `waveguide_3d`: Functions for transforming `waveguide_2d` results into 3D.
including mode-source and overlap-window construction.
- `farfield`, `bloch`, `eme`: specialized helper modules for near/far transforms,
Bloch-periodic problems, and eigenmode expansion.
================================================================ ================================================================
@ -22,80 +19,77 @@ Submodules:
From the "Frequency domain" section of `meanas.fdmath`, we have From the "Frequency domain" section of `meanas.fdmath`, we have
$$ $$
\begin{aligned} \\begin{aligned}
\tilde{E}_{l, \vec{r}} &= \tilde{E}_{\vec{r}} e^{-\imath \omega l \Delta_t} \\ \\tilde{E}_{l, \\vec{r}} &= \\tilde{E}_{\\vec{r}} e^{-\\imath \\omega l \\Delta_t} \\\\
\tilde{H}_{l - \frac{1}{2}, \vec{r} + \frac{1}{2}} &= \tilde{H}_{\vec{r} + \frac{1}{2}} e^{-\imath \omega (l - \frac{1}{2}) \Delta_t} \\ \\tilde{H}_{l - \\frac{1}{2}, \\vec{r} + \\frac{1}{2}} &= \\tilde{H}_{\\vec{r} + \\frac{1}{2}} e^{-\\imath \\omega (l - \\frac{1}{2}) \\Delta_t} \\\\
\tilde{J}_{l, \vec{r}} &= \tilde{J}_{\vec{r}} e^{-\imath \omega (l - \frac{1}{2}) \Delta_t} \\ \\tilde{J}_{l, \\vec{r}} &= \\tilde{J}_{\\vec{r}} e^{-\\imath \\omega (l - \\frac{1}{2}) \\Delta_t} \\\\
\tilde{M}_{l - \frac{1}{2}, \vec{r} + \frac{1}{2}} &= \tilde{M}_{\vec{r} + \frac{1}{2}} e^{-\imath \omega l \Delta_t} \\ \\tilde{M}_{l - \\frac{1}{2}, \\vec{r} + \\frac{1}{2}} &= \\tilde{M}_{\\vec{r} + \\frac{1}{2}} e^{-\\imath \\omega l \\Delta_t} \\\\
\hat{\nabla} \times (\mu^{-1}_{\vec{r} + \frac{1}{2}} \cdot \tilde{\nabla} \times \tilde{E}_{\vec{r}}) \\hat{\\nabla} \\times (\\mu^{-1}_{\\vec{r} + \\frac{1}{2}} \\cdot \\tilde{\\nabla} \\times \\tilde{E}_{\\vec{r}})
-\Omega^2 \epsilon_{\vec{r}} \cdot \tilde{E}_{\vec{r}} &= -\imath \Omega \tilde{J}_{\vec{r}} e^{\imath \omega \Delta_t / 2} \\ -\\Omega^2 \\epsilon_{\\vec{r}} \\cdot \\tilde{E}_{\\vec{r}} &= -\\imath \\Omega \\tilde{J}_{\\vec{r}} e^{\\imath \\omega \\Delta_t / 2} \\\\
\Omega &= 2 \sin(\omega \Delta_t / 2) / \Delta_t \\Omega &= 2 \\sin(\\omega \\Delta_t / 2) / \\Delta_t
\end{aligned} \\end{aligned}
$$ $$
resulting in resulting in
$$ $$
\begin{aligned} \\begin{aligned}
\tilde{\partial}_t &\Rightarrow -\imath \Omega e^{-\imath \omega \Delta_t / 2}\\ \\tilde{\\partial}_t &\\Rightarrow -\\imath \\Omega e^{-\\imath \\omega \\Delta_t / 2}\\\\
\hat{\partial}_t &\Rightarrow -\imath \Omega e^{ \imath \omega \Delta_t / 2}\\ \\hat{\\partial}_t &\\Rightarrow -\\imath \\Omega e^{ \\imath \\omega \\Delta_t / 2}\\\\
\end{aligned} \\end{aligned}
$$ $$
Maxwell's equations are then Maxwell's equations are then
$$ $$
\begin{aligned} \\begin{aligned}
\tilde{\nabla} \times \tilde{E}_{\vec{r}} &= \\tilde{\\nabla} \\times \\tilde{E}_{\\vec{r}} &=
\imath \Omega e^{-\imath \omega \Delta_t / 2} \hat{B}_{\vec{r} + \frac{1}{2}} \\imath \\Omega e^{-\\imath \\omega \\Delta_t / 2} \\hat{B}_{\\vec{r} + \\frac{1}{2}}
- \hat{M}_{\vec{r} + \frac{1}{2}} \\ - \\hat{M}_{\\vec{r} + \\frac{1}{2}} \\\\
\hat{\nabla} \times \hat{H}_{\vec{r} + \frac{1}{2}} &= \\hat{\\nabla} \\times \\hat{H}_{\\vec{r} + \\frac{1}{2}} &=
-\imath \Omega e^{ \imath \omega \Delta_t / 2} \tilde{D}_{\vec{r}} -\\imath \\Omega e^{ \\imath \\omega \\Delta_t / 2} \\tilde{D}_{\\vec{r}}
+ \tilde{J}_{\vec{r}} \\ + \\tilde{J}_{\\vec{r}} \\\\
\tilde{\nabla} \cdot \hat{B}_{\vec{r} + \frac{1}{2}} &= 0 \\ \\tilde{\\nabla} \\cdot \\hat{B}_{\\vec{r} + \\frac{1}{2}} &= 0 \\\\
\hat{\nabla} \cdot \tilde{D}_{\vec{r}} &= \rho_{\vec{r}} \\hat{\\nabla} \\cdot \\tilde{D}_{\\vec{r}} &= \\rho_{\\vec{r}}
\end{aligned} \\end{aligned}
$$ $$
With $\Delta_t \to 0$, this simplifies to With $\\Delta_t \\to 0$, this simplifies to
$$ $$
\begin{aligned} \\begin{aligned}
\tilde{E}_{l, \vec{r}} &\to \tilde{E}_{\vec{r}} \\ \\tilde{E}_{l, \\vec{r}} &\\to \\tilde{E}_{\\vec{r}} \\\\
\tilde{H}_{l - \frac{1}{2}, \vec{r} + \frac{1}{2}} &\to \tilde{H}_{\vec{r} + \frac{1}{2}} \\ \\tilde{H}_{l - \\frac{1}{2}, \\vec{r} + \\frac{1}{2}} &\\to \\tilde{H}_{\\vec{r} + \\frac{1}{2}} \\\\
\tilde{J}_{l, \vec{r}} &\to \tilde{J}_{\vec{r}} \\ \\tilde{J}_{l, \\vec{r}} &\\to \\tilde{J}_{\\vec{r}} \\\\
\tilde{M}_{l - \frac{1}{2}, \vec{r} + \frac{1}{2}} &\to \tilde{M}_{\vec{r} + \frac{1}{2}} \\ \\tilde{M}_{l - \\frac{1}{2}, \\vec{r} + \\frac{1}{2}} &\\to \\tilde{M}_{\\vec{r} + \\frac{1}{2}} \\\\
\Omega &\to \omega \\ \\Omega &\\to \\omega \\\\
\tilde{\partial}_t &\to -\imath \omega \\ \\tilde{\\partial}_t &\\to -\\imath \\omega \\\\
\hat{\partial}_t &\to -\imath \omega \\ \\hat{\\partial}_t &\\to -\\imath \\omega \\\\
\end{aligned} \\end{aligned}
$$ $$
and then and then
$$ $$
\begin{aligned} \\begin{aligned}
\tilde{\nabla} \times \tilde{E}_{\vec{r}} &= \\tilde{\\nabla} \\times \\tilde{E}_{\\vec{r}} &=
\imath \omega \hat{B}_{\vec{r} + \frac{1}{2}} \\imath \\omega \\hat{B}_{\\vec{r} + \\frac{1}{2}}
- \hat{M}_{\vec{r} + \frac{1}{2}} \\ - \\hat{M}_{\\vec{r} + \\frac{1}{2}} \\\\
\hat{\nabla} \times \hat{H}_{\vec{r} + \frac{1}{2}} &= \\hat{\\nabla} \\times \\hat{H}_{\\vec{r} + \\frac{1}{2}} &=
-\imath \omega \tilde{D}_{\vec{r}} -\\imath \\omega \\tilde{D}_{\\vec{r}}
+ \tilde{J}_{\vec{r}} \\ + \\tilde{J}_{\\vec{r}} \\\\
\end{aligned} \\end{aligned}
$$ $$
$$ $$
\hat{\nabla} \times (\mu^{-1}_{\vec{r} + \frac{1}{2}} \cdot \tilde{\nabla} \times \tilde{E}_{\vec{r}}) \\hat{\\nabla} \\times (\\mu^{-1}_{\\vec{r} + \\frac{1}{2}} \\cdot \\tilde{\\nabla} \\times \\tilde{E}_{\\vec{r}})
-\omega^2 \epsilon_{\vec{r}} \cdot \tilde{E}_{\vec{r}} = -\imath \omega \tilde{J}_{\vec{r}} \\ -\\omega^2 \\epsilon_{\\vec{r}} \\cdot \\tilde{E}_{\\vec{r}} = -\\imath \\omega \\tilde{J}_{\\vec{r}} \\\\
$$ $$
# TODO FDFD?
# TODO PML
""" """
from . import ( from . import solvers, operators, functional, scpml, waveguide_2d, waveguide_3d
solvers as solvers,
operators as operators,
functional as functional,
scpml as scpml,
waveguide_2d as waveguide_2d,
waveguide_3d as waveguide_3d,
)
# from . import farfield, bloch TODO # from . import farfield, bloch TODO

View file

@ -94,19 +94,18 @@ This module contains functions for generating and solving the
""" """
from typing import Any, cast from typing import Callable, Any, cast, Sequence
from collections.abc import Callable, Sequence
import logging import logging
import numpy import numpy
from numpy import pi, real, trace from numpy import pi, real, trace
from numpy.fft import fftfreq from numpy.fft import fftfreq
from numpy.typing import NDArray, ArrayLike from numpy.typing import NDArray, ArrayLike
import scipy import scipy # type: ignore
import scipy.optimize import scipy.optimize # type: ignore
from scipy.linalg import norm from scipy.linalg import norm # type: ignore
import scipy.sparse.linalg as spalg import scipy.sparse.linalg as spalg # type: ignore
from ..fdmath import fdfield, cfdfield, cfdfield_t from ..fdmath import fdfield_t, cfdfield_t
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -115,6 +114,7 @@ logger = logging.getLogger(__name__)
try: try:
import pyfftw.interfaces.numpy_fft # type: ignore import pyfftw.interfaces.numpy_fft # type: ignore
import pyfftw.interfaces # type: ignore import pyfftw.interfaces # type: ignore
import multiprocessing
logger.info('Using pyfftw') logger.info('Using pyfftw')
pyfftw.interfaces.cache.enable() pyfftw.interfaces.cache.enable()
@ -136,14 +136,6 @@ except ImportError:
logger.info('Using numpy fft') logger.info('Using numpy fft')
def _assemble_hmn_vector(
h_m: NDArray[numpy.complex128],
h_n: NDArray[numpy.complex128],
) -> NDArray[numpy.complex128]:
stacked = numpy.concatenate((numpy.ravel(h_m), numpy.ravel(h_n)))
return stacked[:, None]
def generate_kmn( def generate_kmn(
k0: ArrayLike, k0: ArrayLike,
G_matrix: ArrayLike, G_matrix: ArrayLike,
@ -163,7 +155,7 @@ def generate_kmn(
All are given in the xyz basis (e.g. `|k|[0,0,0] = norm(G_matrix @ k0)`). All are given in the xyz basis (e.g. `|k|[0,0,0] = norm(G_matrix @ k0)`).
""" """
k0 = numpy.array(k0) k0 = numpy.array(k0)
G_matrix = numpy.asarray(G_matrix) G_matrix = numpy.array(G_matrix, copy=False)
Gi_grids = numpy.array(numpy.meshgrid(*(fftfreq(n, 1 / n) for n in shape[:3]), indexing='ij')) Gi_grids = numpy.array(numpy.meshgrid(*(fftfreq(n, 1 / n) for n in shape[:3]), indexing='ij'))
Gi = numpy.moveaxis(Gi_grids, 0, -1) Gi = numpy.moveaxis(Gi_grids, 0, -1)
@ -191,8 +183,8 @@ def generate_kmn(
def maxwell_operator( def maxwell_operator(
k0: ArrayLike, k0: ArrayLike,
G_matrix: ArrayLike, G_matrix: ArrayLike,
epsilon: fdfield, epsilon: fdfield_t,
mu: fdfield | None = None mu: fdfield_t | None = None
) -> Callable[[NDArray[numpy.complex128]], NDArray[numpy.complex128]]: ) -> Callable[[NDArray[numpy.complex128]], NDArray[numpy.complex128]]:
""" """
Generate the Maxwell operator Generate the Maxwell operator
@ -240,13 +232,13 @@ def maxwell_operator(
Raveled conv(1/mu_k, ik x conv(1/eps_k, ik x h_mn)), returned Raveled conv(1/mu_k, ik x conv(1/eps_k, ik x h_mn)), returned
and overwritten in-place of `h`. and overwritten in-place of `h`.
""" """
hin_m, hin_n = (hi.reshape(shape) for hi in numpy.split(h, 2)) hin_m, hin_n = [hi.reshape(shape) for hi in numpy.split(h, 2)]
#{d,e,h}_xyz fields are complex 3-fields in (1/x, 1/y, 1/z) basis #{d,e,h}_xyz fields are complex 3-fields in (1/x, 1/y, 1/z) basis
# cross product and transform into xyz basis # cross product and transform into xyz basis
d_xyz = (n * hin_m d_xyz = (n * hin_m
- m * hin_n) * k_mag - m * hin_n) * k_mag # noqa: E128
# divide by epsilon # divide by epsilon
temp = ifftn(d_xyz, axes=range(3)) # reuses d_xyz if using pyfftw temp = ifftn(d_xyz, axes=range(3)) # reuses d_xyz if using pyfftw
@ -261,8 +253,8 @@ def maxwell_operator(
h_m, h_n = b_m, b_n h_m, h_n = b_m, b_n
else: else:
# transform from mn to xyz # transform from mn to xyz
b_xyz = (m * b_m b_xyz = (m * b_m[:, :, :, None]
+ n * b_n) # noqa + n * b_n[:, :, :, None]) # noqa: E128
# divide by mu # divide by mu
temp = ifftn(b_xyz, axes=range(3)) temp = ifftn(b_xyz, axes=range(3))
@ -273,7 +265,10 @@ def maxwell_operator(
h_m = numpy.sum(h_xyz * m, axis=3) h_m = numpy.sum(h_xyz * m, axis=3)
h_n = numpy.sum(h_xyz * n, axis=3) h_n = numpy.sum(h_xyz * n, axis=3)
return _assemble_hmn_vector(h_m, h_n) h.shape = (h.size,)
h = numpy.concatenate((h_m.ravel(), h_n.ravel()), axis=None, out=h) # ravel and merge
h.shape = (h.size, 1)
return h
return operator return operator
@ -281,7 +276,7 @@ def maxwell_operator(
def hmn_2_exyz( def hmn_2_exyz(
k0: ArrayLike, k0: ArrayLike,
G_matrix: ArrayLike, G_matrix: ArrayLike,
epsilon: fdfield, epsilon: fdfield_t,
) -> Callable[[NDArray[numpy.complex128]], cfdfield_t]: ) -> Callable[[NDArray[numpy.complex128]], cfdfield_t]:
""" """
Generate an operator which converts a vectorized spatial-frequency-space Generate an operator which converts a vectorized spatial-frequency-space
@ -308,13 +303,12 @@ def hmn_2_exyz(
k_mag, m, n = generate_kmn(k0, G_matrix, shape) k_mag, m, n = generate_kmn(k0, G_matrix, shape)
def operator(h: NDArray[numpy.complex128]) -> cfdfield_t: def operator(h: NDArray[numpy.complex128]) -> cfdfield_t:
hin_m, hin_n = (hi.reshape(shape) for hi in numpy.split(h, 2)) hin_m, hin_n = [hi.reshape(shape) for hi in numpy.split(h, 2)]
d_xyz = (n * hin_m d_xyz = (n * hin_m
- m * hin_n) * k_mag - m * hin_n) * k_mag # noqa: E128
# divide by epsilon # divide by epsilon
exyz = numpy.moveaxis(ifftn(d_xyz, axes=range(3)) / epsilon, 3, 0) return numpy.array([ei for ei in numpy.moveaxis(ifftn(d_xyz, axes=range(3)) / epsilon, 3, 0)]) # TODO avoid copy
return cfdfield_t(exyz)
return operator return operator
@ -322,7 +316,7 @@ def hmn_2_exyz(
def hmn_2_hxyz( def hmn_2_hxyz(
k0: ArrayLike, k0: ArrayLike,
G_matrix: ArrayLike, G_matrix: ArrayLike,
epsilon: fdfield, epsilon: fdfield_t
) -> Callable[[NDArray[numpy.complex128]], cfdfield_t]: ) -> Callable[[NDArray[numpy.complex128]], cfdfield_t]:
""" """
Generate an operator which converts a vectorized spatial-frequency-space Generate an operator which converts a vectorized spatial-frequency-space
@ -347,10 +341,10 @@ def hmn_2_hxyz(
_k_mag, m, n = generate_kmn(k0, G_matrix, shape) _k_mag, m, n = generate_kmn(k0, G_matrix, shape)
def operator(h: NDArray[numpy.complex128]) -> cfdfield_t: def operator(h: NDArray[numpy.complex128]) -> cfdfield_t:
hin_m, hin_n = (hi.reshape(shape) for hi in numpy.split(h, 2)) hin_m, hin_n = [hi.reshape(shape) for hi in numpy.split(h, 2)]
h_xyz = (m * hin_m h_xyz = (m * hin_m
+ n * hin_n) + n * hin_n) # noqa: E128
return cfdfield_t(numpy.array([ifftn(hi) for hi in numpy.moveaxis(h_xyz, 3, 0)])) return numpy.array([ifftn(hi) for hi in numpy.moveaxis(h_xyz, 3, 0)])
return operator return operator
@ -358,8 +352,8 @@ def hmn_2_hxyz(
def inverse_maxwell_operator_approx( def inverse_maxwell_operator_approx(
k0: ArrayLike, k0: ArrayLike,
G_matrix: ArrayLike, G_matrix: ArrayLike,
epsilon: fdfield, epsilon: fdfield_t,
mu: fdfield | None = None, mu: fdfield_t | None = None,
) -> Callable[[NDArray[numpy.complex128]], NDArray[numpy.complex128]]: ) -> Callable[[NDArray[numpy.complex128]], NDArray[numpy.complex128]]:
""" """
Generate an approximate inverse of the Maxwell operator, Generate an approximate inverse of the Maxwell operator,
@ -400,7 +394,7 @@ def inverse_maxwell_operator_approx(
Returns: Returns:
Raveled ik x conv(eps_k, ik x conv(mu_k, h_mn)) Raveled ik x conv(eps_k, ik x conv(mu_k, h_mn))
""" """
hin_m, hin_n = (hi.reshape(shape) for hi in numpy.split(h, 2)) hin_m, hin_n = [hi.reshape(shape) for hi in numpy.split(h, 2)]
#{d,e,h}_xyz fields are complex 3-fields in (1/x, 1/y, 1/z) basis #{d,e,h}_xyz fields are complex 3-fields in (1/x, 1/y, 1/z) basis
@ -408,8 +402,8 @@ def inverse_maxwell_operator_approx(
b_m, b_n = hin_m, hin_n b_m, b_n = hin_m, hin_n
else: else:
# transform from mn to xyz # transform from mn to xyz
h_xyz = (m * hin_m h_xyz = (m * hin_m[:, :, :, None]
+ n * hin_n) # noqa + n * hin_n[:, :, :, None]) # noqa: E128
# multiply by mu # multiply by mu
temp = ifftn(h_xyz, axes=range(3)) temp = ifftn(h_xyz, axes=range(3))
@ -417,12 +411,12 @@ def inverse_maxwell_operator_approx(
b_xyz = fftn(temp, axes=range(3)) b_xyz = fftn(temp, axes=range(3))
# transform back to mn # transform back to mn
b_m = numpy.sum(b_xyz * m, axis=3, keepdims=True) b_m = numpy.sum(b_xyz * m, axis=3)
b_n = numpy.sum(b_xyz * n, axis=3, keepdims=True) b_n = numpy.sum(b_xyz * n, axis=3)
# cross product and transform into xyz basis # cross product and transform into xyz basis
e_xyz = (n * b_m e_xyz = (n * b_m
- m * b_n) / k_mag - m * b_n) / k_mag # noqa: E128
# multiply by epsilon # multiply by epsilon
temp = ifftn(e_xyz, axes=range(3)) temp = ifftn(e_xyz, axes=range(3))
@ -433,7 +427,10 @@ def inverse_maxwell_operator_approx(
h_m = numpy.sum(d_xyz * n, axis=3, keepdims=True) / +k_mag h_m = numpy.sum(d_xyz * n, axis=3, keepdims=True) / +k_mag
h_n = numpy.sum(d_xyz * m, axis=3, keepdims=True) / -k_mag h_n = numpy.sum(d_xyz * m, axis=3, keepdims=True) / -k_mag
return _assemble_hmn_vector(h_m, h_n) h.shape = (h.size,)
h = numpy.concatenate((h_m, h_n), axis=None, out=h)
h.shape = (h.size, 1)
return h
return operator return operator
@ -443,15 +440,15 @@ def find_k(
tolerance: float, tolerance: float,
direction: ArrayLike, direction: ArrayLike,
G_matrix: ArrayLike, G_matrix: ArrayLike,
epsilon: fdfield, epsilon: fdfield_t,
mu: fdfield | None = None, mu: fdfield_t | None = None,
band: int = 0, band: int = 0,
k_bounds: tuple[float, float] = (0, 0.5), k_bounds: tuple[float, float] = (0, 0.5),
k_guess: float | None = None, k_guess: float | None = None,
solve_callback: Callable[..., None] | None = None, solve_callback: Callable[..., None] | None = None,
iter_callback: Callable[..., None] | None = None, iter_callback: Callable[..., None] | None = None,
v0: NDArray[numpy.complex128] | None = None, v0: NDArray[numpy.complex128] | None = None,
) -> tuple[NDArray[numpy.float64], float, NDArray[numpy.complex128], NDArray[numpy.complex128]]: ) -> tuple[float, float, NDArray[numpy.complex128], NDArray[numpy.complex128]]:
""" """
Search for a bloch vector that has a given frequency. Search for a bloch vector that has a given frequency.
@ -474,7 +471,7 @@ def find_k(
`(k, actual_frequency, eigenvalues, eigenvectors)` `(k, actual_frequency, eigenvalues, eigenvectors)`
The found k-vector and its frequency, along with all eigenvalues and eigenvectors. The found k-vector and its frequency, along with all eigenvalues and eigenvectors.
""" """
direction = numpy.array(direction) / norm(direction) # type: ignore[operator] direction = numpy.array(direction) / norm(direction)
k_bounds = tuple(sorted(k_bounds)) # type: ignore # we know the length already... k_bounds = tuple(sorted(k_bounds)) # type: ignore # we know the length already...
assert len(k_bounds) == 2 assert len(k_bounds) == 2
@ -496,23 +493,23 @@ def find_k(
res = scipy.optimize.minimize_scalar( res = scipy.optimize.minimize_scalar(
lambda x: abs(get_f(x, band) - frequency), lambda x: abs(get_f(x, band) - frequency),
method='bounded', k_guess,
method='Bounded',
bounds=k_bounds, bounds=k_bounds,
options={'xatol': abs(tolerance)}, options={'xatol': abs(tolerance)},
) )
assert n is not None assert n is not None
assert v is not None assert v is not None
actual_frequency = get_f(float(res.x), band) return float(res.x * direction), float(res.fun + frequency), n, v
return direction * float(res.x), float(actual_frequency), n, v # type: ignore[operator,return-value]
def eigsolve( def eigsolve(
num_modes: int, num_modes: int,
k0: ArrayLike, k0: ArrayLike,
G_matrix: ArrayLike, G_matrix: ArrayLike,
epsilon: fdfield, epsilon: fdfield_t,
mu: fdfield | None = None, mu: fdfield_t | None = None,
tolerance: float = 1e-7, tolerance: float = 1e-7,
max_iters: int = 10000, max_iters: int = 10000,
reset_iters: int = 100, reset_iters: int = 100,
@ -541,7 +538,7 @@ def eigsolve(
`(eigenvalues, eigenvectors)` where `eigenvalues[i]` corresponds to the `(eigenvalues, eigenvectors)` where `eigenvalues[i]` corresponds to the
vector `eigenvectors[i, :]` vector `eigenvectors[i, :]`
""" """
k0 = numpy.asarray(k0) k0 = numpy.array(k0, copy=False)
h_size = 2 * epsilon[0].size h_size = 2 * epsilon[0].size
@ -564,12 +561,11 @@ def eigsolve(
prev_theta = 0.5 prev_theta = 0.5
D = numpy.zeros(shape=y_shape, dtype=complex) D = numpy.zeros(shape=y_shape, dtype=complex)
rng = numpy.random.default_rng()
Z: NDArray[numpy.complex128] Z: NDArray[numpy.complex128]
if y0 is None: if y0 is None:
Z = rng.random(y_shape) + 1j * rng.random(y_shape) Z = numpy.random.rand(*y_shape) + 1j * numpy.random.rand(*y_shape)
else: else:
Z = numpy.asarray(y0).T Z = numpy.array(y0, copy=False).T
while True: while True:
Z *= num_modes / norm(Z) Z *= num_modes / norm(Z)
@ -577,7 +573,7 @@ def eigsolve(
try: try:
U = numpy.linalg.inv(ZtZ) U = numpy.linalg.inv(ZtZ)
except numpy.linalg.LinAlgError: except numpy.linalg.LinAlgError:
Z = rng.random(y_shape) + 1j * rng.random(y_shape) Z = numpy.random.rand(*y_shape) + 1j * numpy.random.rand(*y_shape)
continue continue
trace_U = real(trace(U)) trace_U = real(trace(U))
@ -650,16 +646,17 @@ def eigsolve(
Qi_memo: list[float | None] = [None, None] Qi_memo: list[float | None] = [None, None]
def Qi_func(theta: float, Qi_memo=Qi_memo, ZtZ=ZtZ, DtD=DtD, symZtD=symZtD) -> float: # noqa: ANN001 def Qi_func(theta: float) -> float:
nonlocal Qi_memo
if Qi_memo[0] == theta: if Qi_memo[0] == theta:
return cast('float', Qi_memo[1]) return cast(float, Qi_memo[1])
c = numpy.cos(theta) c = numpy.cos(theta)
s = numpy.sin(theta) s = numpy.sin(theta)
Q = c * c * ZtZ + s * s * DtD + 2 * s * c * symZtD Q = c * c * ZtZ + s * s * DtD + 2 * s * c * symZtD
try: try:
Qi = numpy.linalg.inv(Q) Qi = numpy.linalg.inv(Q)
except numpy.linalg.LinAlgError as err: except numpy.linalg.LinAlgError:
logger.info('taylor Qi') logger.info('taylor Qi')
# if c or s small, taylor expand # if c or s small, taylor expand
if c < 1e-4 * s and c != 0: if c < 1e-4 * s and c != 0:
@ -669,12 +666,12 @@ def eigsolve(
ZtZi = numpy.linalg.inv(ZtZ) ZtZi = numpy.linalg.inv(ZtZ)
Qi = ZtZi / (c * c) - 2 * s / (c * c * c) * (ZtZi @ (ZtZi @ symZtD).conj().T) Qi = ZtZi / (c * c) - 2 * s / (c * c * c) * (ZtZi @ (ZtZi @ symZtD).conj().T)
else: else:
raise Exception('Inexplicable singularity in trace_func') from err raise Exception('Inexplicable singularity in trace_func')
Qi_memo[0] = theta Qi_memo[0] = theta
Qi_memo[1] = cast('float', Qi) Qi_memo[1] = cast(float, Qi)
return cast('float', Qi) return cast(float, Qi)
def trace_func(theta: float, ZtAZ=ZtAZ, DtAD=DtAD, symZtAD=symZtAD) -> float: # noqa: ANN001 def trace_func(theta: float) -> float:
c = numpy.cos(theta) c = numpy.cos(theta)
s = numpy.sin(theta) s = numpy.sin(theta)
Qi = Qi_func(theta) Qi = Qi_func(theta)
@ -683,24 +680,15 @@ def eigsolve(
return numpy.abs(trace) return numpy.abs(trace)
if False: if False:
def trace_deriv( def trace_deriv(theta):
theta: float,
sgn: int = sgn,
ZtAZ=ZtAZ, # noqa: ANN001
DtAD=DtAD, # noqa: ANN001
symZtD=symZtD, # noqa: ANN001
symZtAD=symZtAD, # noqa: ANN001
ZtZ=ZtZ, # noqa: ANN001
DtD=DtD, # noqa: ANN001
) -> float:
Qi = Qi_func(theta) Qi = Qi_func(theta)
c2 = numpy.cos(2 * theta) c2 = numpy.cos(2 * theta)
s2 = numpy.sin(2 * theta) s2 = numpy.sin(2 * theta)
F = -0.5 * s2 * (ZtAZ - DtAD) + c2 * symZtAD F = -0.5*s2 * (ZtAZ - DtAD) + c2 * symZtAD
trace_deriv = _rtrace_AtB(Qi, F) trace_deriv = _rtrace_AtB(Qi, F)
G = Qi @ F.conj().T @ Qi.conj().T G = Qi @ F.conj().T @ Qi.conj().T
H = -0.5 * s2 * (ZtZ - DtD) + c2 * symZtD H = -0.5*s2 * (ZtZ - DtD) + c2 * symZtD
trace_deriv -= _rtrace_AtB(G, H) trace_deriv -= _rtrace_AtB(G, H)
trace_deriv *= 2 trace_deriv *= 2
@ -708,12 +696,12 @@ def eigsolve(
U_sZtD = U @ symZtD U_sZtD = U @ symZtD
dE = 2.0 * (_rtrace_AtB(U, symZtAD) dE = 2.0 * (_rtrace_AtB(U, symZtAD) -
- _rtrace_AtB(ZtAZU, U_sZtD)) _rtrace_AtB(ZtAZU, U_sZtD))
d2E = 2 * (_rtrace_AtB(U, DtAD) d2E = 2 * (_rtrace_AtB(U, DtAD) -
- _rtrace_AtB(ZtAZU, U @ (DtD - 4 * symZtD @ U_sZtD)) _rtrace_AtB(ZtAZU, U @ (DtD - 4 * symZtD @ U_sZtD)) -
- 4 * _rtrace_AtB(U, symZtAD @ U_sZtD)) 4 * _rtrace_AtB(U, symZtAD @ U_sZtD))
# Newton-Raphson to find a root of the first derivative: # Newton-Raphson to find a root of the first derivative:
theta = -dE / d2E theta = -dE / d2E
@ -734,12 +722,7 @@ def eigsolve(
amax=pi, amax=pi,
) )
result = scipy.optimize.minimize_scalar( result = scipy.optimize.minimize_scalar(trace_func, bounds=(0, pi), tol=tolerance)
trace_func,
method='bounded',
bounds=(0, pi),
options={'xatol': tolerance},
)
new_E = result.fun new_E = result.fun
theta = result.x theta = result.x
@ -768,7 +751,7 @@ def eigsolve(
v = eigvecs[:, i] v = eigvecs[:, i]
n = eigvals[i] n = eigvals[i]
v /= norm(v) v /= norm(v)
Av = numpy.asarray(scipy_op @ v.copy()).reshape(-1) Av = (scipy_op @ v.copy())[:, 0]
eigness = norm(Av - (v.conj() @ Av) * v) eigness = norm(Av - (v.conj() @ Av) * v)
f = numpy.sqrt(-numpy.real(n)) f = numpy.sqrt(-numpy.real(n))
df = numpy.sqrt(-numpy.real(n) + eigness) df = numpy.sqrt(-numpy.real(n) + eigness)
@ -798,7 +781,7 @@ def linmin(x_guess, f0, df0, x_max, f_tol=0.1, df_tol=min(tolerance, 1e-6), x_to
x_min, x_max, isave, dsave) x_min, x_max, isave, dsave)
for i in range(int(1e6)): for i in range(int(1e6)):
if task != 'F': if task != 'F':
logging.info(f'search converged in {i} iterations') logging.info('search converged in {} iterations'.format(i))
break break
fx = f(x, dfx) fx = f(x, dfx)
x, fx, dfx, task = minpack2.dsrch(x, fx, dfx, f_tol, df_tol, x_tol, task, x, fx, dfx, task = minpack2.dsrch(x, fx, dfx, f_tol, df_tol, x_tol, task,
@ -816,62 +799,3 @@ def _rtrace_AtB(
def _symmetrize(A: NDArray[numpy.complex128]) -> NDArray[numpy.complex128]: def _symmetrize(A: NDArray[numpy.complex128]) -> NDArray[numpy.complex128]:
return (A + A.conj().T) * 0.5 return (A + A.conj().T) * 0.5
def inner_product(
eL: cfdfield,
hL: cfdfield,
eR: cfdfield,
hR: cfdfield,
) -> complex:
# assumes x-axis propagation
assert numpy.array_equal(eR.shape, hR.shape)
assert numpy.array_equal(eL.shape, hL.shape)
assert numpy.array_equal(eR.shape, eL.shape)
# Cross product, times 2 since it's <p | n>, then divide by 4. # TODO might want to abs() this?
norm2R = (eR[1] * hR[2] - eR[2] * hR[1]).sum() / 2
norm2L = (eL[1] * hL[2] - eL[2] * hL[1]).sum() / 2
# eRxhR_x = numpy.cross(eR.reshape(3, -1), hR.reshape(3, -1), axis=0).reshape(eR.shape)[0] / normR
# logger.info(f'power {eRxhR_x.sum() / 2})
eR_norm = eR / numpy.sqrt(abs(norm2R))
hR_norm = hR / numpy.sqrt(abs(norm2R))
eL_norm = eL / numpy.sqrt(abs(norm2L))
hL_norm = hL / numpy.sqrt(abs(norm2L))
# (eR x hL)[0] and (eL x hR)[0]
eRxhL_x = eR_norm[1] * hL_norm[2] - eR_norm[2] * hL_norm[1]
eLxhR_x = eL_norm[1] * hR_norm[2] - eL_norm[2] * hR_norm[1]
#return 1j * (eRxhL_x - eLxhR_x).sum() / numpy.sqrt(norm2R * norm2L)
#return (eRxhL_x.sum() - eLxhR_x.sum()) / numpy.sqrt(norm2R * norm2L)
return eLxhR_x.sum() - eRxhL_x.sum()
def trq(
eI: cfdfield,
hI: cfdfield,
eO: cfdfield,
hO: cfdfield,
) -> tuple[complex, complex]:
pp = inner_product(eO, hO, eI, hI)
pn = inner_product(eO, hO, eI, -hI)
np = inner_product(eO, -hO, eI, hI)
nn = inner_product(eO, -hO, eI, -hI)
assert numpy.allclose(pp, -nn, atol=1e-12, rtol=1e-12)
assert numpy.allclose(pn, -np, atol=1e-12, rtol=1e-12)
logger.info(f'''
{pp=:4g} {pn=:4g}
{nn=:4g} {np=:4g}
{nn * pp / pn=:4g} {-np=:4g}
''')
r = -pp / pn # -<Pp|Bp>/<Pn/Bp> = -(-pp) / (-pn)
t = (np - nn * pp / pn) / 4
return t, r

View file

@ -1,190 +0,0 @@
"""
Low-level mode-matching helpers for waveguide / EME workflows.
These helpers operate on already-solved and already-normalized port fields.
They do not build geometries or solve modes themselves; downstream users are
expected to supply compatible `(E, H)` modal field pairs from
`waveguide_2d`, `waveguide_3d`, or `waveguide_cyl`.
The returned matrices follow the usual port ordering:
- `get_tr(...)` returns `(T, R)` for left-incident modes.
- `get_abcd(...)` returns the 2-port block transfer matrix built from the two
directional `T/R` solves.
- `get_s(...)` returns the full block scattering matrix
`[[R12, T12], [T21, R21]]`.
This module is intentionally a thin library layer rather than an integrated
simulation suite. It provides the overlap algebra that downstream users can
compose into larger workflows.
"""
from collections.abc import Sequence
import numpy
from numpy.typing import NDArray
from scipy import sparse
from ..fdmath import dx_lists2_t, vcfdfield2
from .waveguide_2d import inner_product
type wavenumber_seq = Sequence[complex] | NDArray[numpy.complexfloating] | NDArray[numpy.floating]
def _validate_port_modes(
name: str,
ehs: Sequence[Sequence[vcfdfield2]],
wavenumbers: wavenumber_seq,
) -> tuple[tuple[int, ...], tuple[int, ...]]:
if len(ehs) != len(wavenumbers):
raise ValueError(f'{name} mode list and wavenumber list must have the same length')
if not ehs:
raise ValueError(f'{name} must contain at least one mode')
e_shape: tuple[int, ...] | None = None
h_shape: tuple[int, ...] | None = None
for index, mode in enumerate(ehs):
if len(mode) != 2:
raise ValueError(f'{name}[{index}] must be a 2-tuple of (E, H) modal fields')
e_field, h_field = mode
mode_e_shape = numpy.shape(e_field)
mode_h_shape = numpy.shape(h_field)
if mode_e_shape != mode_h_shape:
raise ValueError(f'{name}[{index}] has mismatched E/H field shapes')
if e_shape is None:
e_shape = mode_e_shape
h_shape = mode_h_shape
elif mode_e_shape != e_shape or mode_h_shape != h_shape:
raise ValueError(f'{name} modal fields must all share the same shape')
assert e_shape is not None
assert h_shape is not None
return e_shape, h_shape
def get_tr(
ehLs: Sequence[Sequence[vcfdfield2]],
wavenumbers_L: wavenumber_seq,
ehRs: Sequence[Sequence[vcfdfield2]],
wavenumbers_R: wavenumber_seq,
dxes: dx_lists2_t,
) -> tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]:
"""
Compute left-incident transmission and reflection matrices.
Args:
ehLs: Left-port modes as `(E, H)` field pairs.
wavenumbers_L: Propagation constants for `ehLs`.
ehRs: Right-port modes as `(E, H)` field pairs.
wavenumbers_R: Propagation constants for `ehRs`.
dxes: Two-dimensional Yee-cell edge lengths for the shared port plane.
Returns:
`(T12, R12)` where columns index left-incident modes and rows index
outgoing right-going / left-going modes respectively.
Raises:
ValueError: If the port mode lists are empty, malformed, or defined on
incompatible field shapes.
"""
left_e_shape, left_h_shape = _validate_port_modes('ehLs', ehLs, wavenumbers_L)
right_e_shape, right_h_shape = _validate_port_modes('ehRs', ehRs, wavenumbers_R)
if left_e_shape != right_e_shape or left_h_shape != right_h_shape:
raise ValueError('left and right modal fields must share the same E/H shapes')
nL = len(wavenumbers_L)
nR = len(wavenumbers_R)
A12 = numpy.zeros((nL, nR), dtype=complex)
A21 = numpy.zeros((nL, nR), dtype=complex)
B11 = numpy.zeros((nL,), dtype=complex)
for ll in range(nL):
eL, hL = ehLs[ll]
B11[ll] = inner_product(eL, hL, dxes=dxes, conj_h=False)
for rr in range(nR):
eR, hR = ehRs[rr]
A12[ll, rr] = inner_product(eL, hR, dxes=dxes, conj_h=False) # TODO optimize loop?
A21[ll, rr] = inner_product(eR, hL, dxes=dxes, conj_h=False)
# tt0 = 2 * numpy.linalg.pinv(A21 + numpy.conj(A12))
tt0, _resid, _rank, _sing = numpy.linalg.lstsq(A21 + A12, numpy.diag(2 * B11), rcond=None)
U, st, V = numpy.linalg.svd(tt0)
gain = st > 1
st[gain] = 1 / st[gain]
tt = U @ numpy.diag(st) @ V
# rr = 0.5 * (A21 - numpy.conj(A12)) @ tt
rr = numpy.diag(0.5 / B11) @ (A21 - A12) @ tt
return tt, rr
def get_abcd(
ehLs: Sequence[Sequence[vcfdfield2]],
wavenumbers_L: wavenumber_seq,
ehRs: Sequence[Sequence[vcfdfield2]],
wavenumbers_R: wavenumber_seq,
**kwargs,
) -> sparse.sparray:
"""
Build the 2-port block transfer matrix for an interface.
The blocks are assembled from the forward and reverse `get_tr(...)`
solutions using the standard
`[[A, B], [C, D]] = [[T12 - R21 T21^-1 R12, R21 T21^-1], [-T21^-1 R12, T21^-1]]`
convention.
"""
t12, r12 = get_tr(ehLs, wavenumbers_L, ehRs, wavenumbers_R, **kwargs)
t21, r21 = get_tr(ehRs, wavenumbers_R, ehLs, wavenumbers_L, **kwargs)
t21i = numpy.linalg.pinv(t21)
A = t12 - r21 @ t21i @ r12
B = r21 @ t21i
C = -t21i @ r12
D = t21i
return sparse.block_array(
[
[sparse.csr_array(A), sparse.csr_array(B)],
[sparse.csr_array(C), sparse.csr_array(D)],
],
format='csr',
)
def get_s(
ehLs: Sequence[Sequence[vcfdfield2]],
wavenumbers_L: wavenumber_seq,
ehRs: Sequence[Sequence[vcfdfield2]],
wavenumbers_R: wavenumber_seq,
force_nogain: bool = False,
force_reciprocal: bool = False,
**kwargs,
) -> NDArray[numpy.complex128]:
"""
Build the full block scattering matrix for a two-sided interface.
The returned matrix is ordered as `[[R12, T12], [T21, R21]]`, where the
first block-row/column corresponds to the left port and the second to the
right port.
Args:
force_nogain: If `True`, clamp singular values of the assembled
scattering matrix to at most one.
force_reciprocal: If `True`, symmetrize the assembled matrix as
`0.5 * (S + S.T)`.
"""
t12, r12 = get_tr(ehLs, wavenumbers_L, ehRs, wavenumbers_R, **kwargs)
t21, r21 = get_tr(ehRs, wavenumbers_R, ehLs, wavenumbers_L, **kwargs)
ss = numpy.block([[r12, t12],
[t21, r21]])
if force_nogain:
# force S @ S.H diagonal
U, sing, Vh = numpy.linalg.svd(ss)
ss = U @ numpy.diag(numpy.minimum(sing, 1.0)) @ Vh
if force_reciprocal:
ss = 0.5 * (ss + ss.T)
return ss

View file

@ -1,24 +1,20 @@
""" """
Functions for performing near-to-farfield transformation (and the reverse). Functions for performing near-to-farfield transformation (and the reverse).
""" """
from typing import Any, cast from typing import Any, Sequence, cast
from collections.abc import Sequence
import numpy import numpy
from numpy.fft import fft2, fftshift, fftfreq, ifft2, ifftshift from numpy.fft import fft2, fftshift, fftfreq, ifft2, ifftshift
from numpy import pi from numpy import pi
from numpy.typing import NDArray
from numpy import complexfloating
type farfield_slice = NDArray[complexfloating] from ..fdmath import cfdfield_t
type transverse_slice_pair = Sequence[farfield_slice]
def near_to_farfield( def near_to_farfield(
E_near: transverse_slice_pair, E_near: cfdfield_t,
H_near: transverse_slice_pair, H_near: cfdfield_t,
dx: float, dx: float,
dy: float, dy: float,
padded_size: Sequence[int] | int | None = None padded_size: list[int] | int | None = None
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """
Compute the farfield, i.e. the distribution of the fields after propagation Compute the farfield, i.e. the distribution of the fields after propagation
@ -59,14 +55,14 @@ def near_to_farfield(
raise Exception('H_near must be a length-2 list of ndarrays') raise Exception('H_near must be a length-2 list of ndarrays')
s = E_near[0].shape s = E_near[0].shape
if not all(s == f.shape for f in [*E_near, *H_near]): if not all(s == f.shape for f in E_near + H_near):
raise Exception('All fields must be the same shape!') raise Exception('All fields must be the same shape!')
if padded_size is None: if padded_size is None:
padded_size = (2**numpy.ceil(numpy.log2(s))).astype(int) padded_size = (2**numpy.ceil(numpy.log2(s))).astype(int)
if not hasattr(padded_size, '__len__'): if not hasattr(padded_size, '__len__'):
padded_size = (padded_size, padded_size) # type: ignore # checked if sequence padded_size = (padded_size, padded_size) # type: ignore # checked if sequence
padded_shape = cast('Sequence[int]', padded_size) padded_shape = cast(Sequence[int], padded_size)
En_fft = [fftshift(fft2(fftshift(Eni), s=padded_shape)) for Eni in E_near] En_fft = [fftshift(fft2(fftshift(Eni), s=padded_shape)) for Eni in E_near]
Hn_fft = [fftshift(fft2(fftshift(Hni), s=padded_shape)) for Hni in H_near] Hn_fft = [fftshift(fft2(fftshift(Hni), s=padded_shape)) for Hni in H_near]
@ -79,22 +75,25 @@ def near_to_farfield(
kx, ky = numpy.meshgrid(kxx, kyy, indexing='ij') kx, ky = numpy.meshgrid(kxx, kyy, indexing='ij')
kxy2 = kx * kx + ky * ky kxy2 = kx * kx + ky * ky
kxy = numpy.sqrt(kxy2) kxy = numpy.sqrt(kxy2)
kz = numpy.sqrt(numpy.maximum(0, k * k - kxy2)) kz = numpy.sqrt(k * k - kxy2)
sin_th = numpy.divide(ky, kxy, out=numpy.zeros_like(ky), where=kxy != 0) sin_th = ky / kxy
cos_th = numpy.divide(kx, kxy, out=numpy.ones_like(kx), where=kxy != 0) cos_th = kx / kxy
cos_phi = kz / k cos_phi = kz / k
sin_th[numpy.logical_and(kx == 0, ky == 0)] = 0
cos_th[numpy.logical_and(kx == 0, ky == 0)] = 1
# Normalized vector potentials N, L # Normalized vector potentials N, L
N = [-Hn_fft[1] * cos_phi * cos_th + Hn_fft[0] * cos_phi * sin_th, N = [-Hn_fft[1] * cos_phi * cos_th + Hn_fft[0] * cos_phi * sin_th,
Hn_fft[1] * sin_th + Hn_fft[0] * cos_th] # noqa Hn_fft[1] * sin_th + Hn_fft[0] * cos_th] # noqa: E127
L = [ En_fft[1] * cos_phi * cos_th - En_fft[0] * cos_phi * sin_th, L = [ En_fft[1] * cos_phi * cos_th - En_fft[0] * cos_phi * sin_th,
-En_fft[1] * sin_th - En_fft[0] * cos_th] # noqa -En_fft[1] * sin_th - En_fft[0] * cos_th] # noqa: E128
E_far = [-L[1] - N[0], E_far = [-L[1] - N[0],
L[0] - N[1]] # noqa L[0] - N[1]] # noqa: E127
H_far = [-E_far[1], H_far = [-E_far[1],
E_far[0]] # noqa E_far[0]] # noqa: E127
theta = numpy.arctan2(ky, kx) theta = numpy.arctan2(ky, kx)
phi = numpy.arccos(cos_phi) phi = numpy.arccos(cos_phi)
@ -112,8 +111,8 @@ def near_to_farfield(
outputs = { outputs = {
'E': E_far, 'E': E_far,
'H': H_far, 'H': H_far,
'dkx': float(kxx[1] - kxx[0]), 'dkx': kx[1] - kx[0],
'dky': float(kyy[1] - kyy[0]), 'dky': ky[1] - ky[0],
'kx': kx, 'kx': kx,
'ky': ky, 'ky': ky,
'theta': theta, 'theta': theta,
@ -124,11 +123,11 @@ def near_to_farfield(
def far_to_nearfield( def far_to_nearfield(
E_far: transverse_slice_pair, E_far: cfdfield_t,
H_far: transverse_slice_pair, H_far: cfdfield_t,
dkx: float, dkx: float,
dky: float, dky: float,
padded_size: Sequence[int] | int | None = None padded_size: list[int] | int | None = None
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """
Compute the farfield, i.e. the distribution of the fields after propagation Compute the farfield, i.e. the distribution of the fields after propagation
@ -165,29 +164,32 @@ def far_to_nearfield(
raise Exception('H_far must be a length-2 list of ndarrays') raise Exception('H_far must be a length-2 list of ndarrays')
s = E_far[0].shape s = E_far[0].shape
if not all(s == f.shape for f in [*E_far, *H_far]): if not all(s == f.shape for f in E_far + H_far):
raise Exception('All fields must be the same shape!') raise Exception('All fields must be the same shape!')
if padded_size is None: if padded_size is None:
padded_size = (2 ** numpy.ceil(numpy.log2(s))).astype(int) padded_size = (2 ** numpy.ceil(numpy.log2(s))).astype(int)
if not hasattr(padded_size, '__len__'): if not hasattr(padded_size, '__len__'):
padded_size = (padded_size, padded_size) # type: ignore # checked if sequence padded_size = (padded_size, padded_size) # type: ignore # checked if sequence
padded_shape = cast('Sequence[int]', padded_size) padded_shape = cast(Sequence[int], padded_size)
k = 2 * pi k = 2 * pi
kxs = dkx * fftshift(fftfreq(s[0], d=1 / s[0])) kxs = fftshift(fftfreq(s[0], 1 / (s[0] * dkx)))
kys = dky * fftshift(fftfreq(s[1], d=1 / s[1])) kys = fftshift(fftfreq(s[0], 1 / (s[1] * dky)))
kx, ky = numpy.meshgrid(kxs, kys, indexing='ij') kx, ky = numpy.meshgrid(kxs, kys, indexing='ij')
kxy2 = kx * kx + ky * ky kxy2 = kx * kx + ky * ky
kxy = numpy.sqrt(kxy2) kxy = numpy.sqrt(kxy2)
kz = numpy.sqrt(numpy.maximum(0, k * k - kxy2)) kz = numpy.sqrt(k * k - kxy2)
sin_th = numpy.divide(ky, kxy, out=numpy.zeros_like(ky), where=kxy != 0) sin_th = ky / kxy
cos_th = numpy.divide(kx, kxy, out=numpy.ones_like(kx), where=kxy != 0) cos_th = kx / kxy
cos_phi = kz / k cos_phi = kz / k
sin_th[numpy.logical_and(kx == 0, ky == 0)] = 0
cos_th[numpy.logical_and(kx == 0, ky == 0)] = 1
theta = numpy.arctan2(ky, kx) theta = numpy.arctan2(ky, kx)
phi = numpy.arccos(cos_phi) phi = numpy.arccos(cos_phi)
theta[numpy.logical_and(kx == 0, ky == 0)] = 0 theta[numpy.logical_and(kx == 0, ky == 0)] = 0
@ -203,45 +205,25 @@ def far_to_nearfield(
# Normalized vector potentials N, L # Normalized vector potentials N, L
L = [0.5 * E_far[1], L = [0.5 * E_far[1],
-0.5 * E_far[0]] # noqa -0.5 * E_far[0]] # noqa: E128
N = [L[1], N = [L[1],
-L[0]] # noqa -L[0]] # noqa: E128
En_fft = [ En_fft = [-( L[0] * sin_th + L[1] * cos_phi * cos_th) / cos_phi,
numpy.divide( -(-L[0] * cos_th + L[1] * cos_phi * sin_th) / cos_phi]
-(L[0] * sin_th + L[1] * cos_phi * cos_th),
cos_phi,
out=numpy.zeros_like(L[0]),
where=cos_phi != 0,
),
numpy.divide(
-(-L[0] * cos_th + L[1] * cos_phi * sin_th),
cos_phi,
out=numpy.zeros_like(L[0]),
where=cos_phi != 0,
),
]
Hn_fft = [ Hn_fft = [( N[0] * sin_th + N[1] * cos_phi * cos_th) / cos_phi,
numpy.divide( (-N[0] * cos_th + N[1] * cos_phi * sin_th) / cos_phi]
N[0] * sin_th + N[1] * cos_phi * cos_th,
cos_phi, for i in range(2):
out=numpy.zeros_like(N[0]), En_fft[i][cos_phi == 0] = 0
where=cos_phi != 0, Hn_fft[i][cos_phi == 0] = 0
),
numpy.divide(
-N[0] * cos_th + N[1] * cos_phi * sin_th,
cos_phi,
out=numpy.zeros_like(N[0]),
where=cos_phi != 0,
),
]
E_near = [ifftshift(ifft2(ifftshift(Ei), s=padded_shape)) for Ei in En_fft] E_near = [ifftshift(ifft2(ifftshift(Ei), s=padded_shape)) for Ei in En_fft]
H_near = [ifftshift(ifft2(ifftshift(Hi), s=padded_shape)) for Hi in Hn_fft] H_near = [ifftshift(ifft2(ifftshift(Hi), s=padded_shape)) for Hi in Hn_fft]
dx = 2 * pi / (s[0] * dkx) dx = 2 * pi / (s[0] * dkx)
dy = 2 * pi / (s[1] * dky) dy = 2 * pi / (s[0] * dky)
outputs = { outputs = {
'E': E_near, 'E': E_near,
@ -251,3 +233,4 @@ def far_to_nearfield(
} }
return outputs return outputs

View file

@ -5,10 +5,10 @@ Functional versions of many FDFD operators. These can be useful for performing
The functions generated here expect `cfdfield_t` inputs with shape (3, X, Y, Z), The functions generated here expect `cfdfield_t` inputs with shape (3, X, Y, Z),
e.g. E = [E_x, E_y, E_z] where each (complex) component has shape (X, Y, Z) e.g. E = [E_x, E_y, E_z] where each (complex) component has shape (X, Y, Z)
""" """
from collections.abc import Callable from typing import Callable
import numpy import numpy
from ..fdmath import dx_lists_t, cfdfield_t, fdfield, cfdfield, cfdfield_updater_t from ..fdmath import dx_lists_t, fdfield_t, cfdfield_t, cfdfield_updater_t
from ..fdmath.functional import curl_forward, curl_back from ..fdmath.functional import curl_forward, curl_back
@ -18,8 +18,8 @@ __author__ = 'Jan Petykiewicz'
def e_full( def e_full(
omega: complex, omega: complex,
dxes: dx_lists_t, dxes: dx_lists_t,
epsilon: fdfield, epsilon: fdfield_t,
mu: fdfield | None = None, mu: fdfield_t | None = None,
) -> cfdfield_updater_t: ) -> cfdfield_updater_t:
""" """
Wave operator for use with E-field. See `operators.e_full` for details. Wave operator for use with E-field. See `operators.e_full` for details.
@ -37,25 +37,26 @@ def e_full(
ch = curl_back(dxes[1]) ch = curl_back(dxes[1])
ce = curl_forward(dxes[0]) ce = curl_forward(dxes[0])
def op_1(e: cfdfield) -> cfdfield_t: def op_1(e: cfdfield_t) -> cfdfield_t:
curls = ch(ce(e)) curls = ch(ce(e))
return cfdfield_t(curls - omega ** 2 * epsilon * e) return curls - omega ** 2 * epsilon * e
def op_mu(e: cfdfield_t) -> cfdfield_t: def op_mu(e: cfdfield_t) -> cfdfield_t:
curls = ch(ce(e) / mu) # type: ignore # mu = None ok because we don't return the function curls = ch(mu * ce(e)) # type: ignore # mu = None ok because we don't return the function
return cfdfield_t(curls - omega ** 2 * epsilon * e) return curls - omega ** 2 * epsilon * e
if mu is None: if mu is None:
return op_1 return op_1
return op_mu else:
return op_mu
def eh_full( def eh_full(
omega: complex, omega: complex,
dxes: dx_lists_t, dxes: dx_lists_t,
epsilon: fdfield, epsilon: fdfield_t,
mu: fdfield | None = None, mu: fdfield_t | None = None,
) -> Callable[[cfdfield, cfdfield], tuple[cfdfield_t, cfdfield_t]]: ) -> Callable[[cfdfield_t, cfdfield_t], tuple[cfdfield_t, cfdfield_t]]:
""" """
Wave operator for full (both E and H) field representation. Wave operator for full (both E and H) field representation.
See `operators.eh_full`. See `operators.eh_full`.
@ -73,23 +74,24 @@ def eh_full(
ch = curl_back(dxes[1]) ch = curl_back(dxes[1])
ce = curl_forward(dxes[0]) ce = curl_forward(dxes[0])
def op_1(e: cfdfield, h: cfdfield) -> tuple[cfdfield_t, cfdfield_t]: def op_1(e: cfdfield_t, h: cfdfield_t) -> tuple[cfdfield_t, cfdfield_t]:
return (cfdfield_t(ch(h) - 1j * omega * epsilon * e), return (ch(h) - 1j * omega * epsilon * e,
cfdfield_t(ce(e) + 1j * omega * h)) ce(e) + 1j * omega * h)
def op_mu(e: cfdfield, h: cfdfield) -> tuple[cfdfield_t, cfdfield_t]: def op_mu(e: cfdfield_t, h: cfdfield_t) -> tuple[cfdfield_t, cfdfield_t]:
return (cfdfield_t(ch(h) - 1j * omega * epsilon * e), return (ch(h) - 1j * omega * epsilon * e,
cfdfield_t(ce(e) + 1j * omega * mu * h)) # type: ignore # mu=None ok ce(e) + 1j * omega * mu * h) # type: ignore # mu=None ok
if mu is None: if mu is None:
return op_1 return op_1
return op_mu else:
return op_mu
def e2h( def e2h(
omega: complex, omega: complex,
dxes: dx_lists_t, dxes: dx_lists_t,
mu: fdfield | None = None, mu: fdfield_t | None = None,
) -> cfdfield_updater_t: ) -> cfdfield_updater_t:
""" """
Utility operator for converting the `E` field into the `H` field. Utility operator for converting the `E` field into the `H` field.
@ -106,21 +108,22 @@ def e2h(
""" """
ce = curl_forward(dxes[0]) ce = curl_forward(dxes[0])
def e2h_1_1(e: cfdfield) -> cfdfield_t: def e2h_1_1(e: cfdfield_t) -> cfdfield_t:
return cfdfield_t(ce(e) / (-1j * omega)) return ce(e) / (-1j * omega)
def e2h_mu(e: cfdfield) -> cfdfield_t: def e2h_mu(e: cfdfield_t) -> cfdfield_t:
return cfdfield_t(ce(e) / (-1j * omega * mu)) # type: ignore # mu=None ok return ce(e) / (-1j * omega * mu) # type: ignore # mu=None ok
if mu is None: if mu is None:
return e2h_1_1 return e2h_1_1
return e2h_mu else:
return e2h_mu
def m2j( def m2j(
omega: complex, omega: complex,
dxes: dx_lists_t, dxes: dx_lists_t,
mu: fdfield | None = None, mu: fdfield_t | None = None,
) -> cfdfield_updater_t: ) -> cfdfield_updater_t:
""" """
Utility operator for converting magnetic current `M` distribution Utility operator for converting magnetic current `M` distribution
@ -139,42 +142,30 @@ def m2j(
ch = curl_back(dxes[1]) ch = curl_back(dxes[1])
def m2j_mu(m: cfdfield_t) -> cfdfield_t: def m2j_mu(m: cfdfield_t) -> cfdfield_t:
J = ch(m / mu) / (1j * omega) # type: ignore # mu=None ok J = ch(m / mu) / (-1j * omega) # type: ignore # mu=None ok
return cfdfield_t(J) return J
def m2j_1(m: cfdfield_t) -> cfdfield_t: def m2j_1(m: cfdfield_t) -> cfdfield_t:
J = ch(m) / (1j * omega) J = ch(m) / (-1j * omega)
return cfdfield_t(J) return J
if mu is None: if mu is None:
return m2j_1 return m2j_1
return m2j_mu else:
return m2j_mu
def e_tfsf_source( def e_tfsf_source(
TF_region: fdfield, TF_region: fdfield_t,
omega: complex, omega: complex,
dxes: dx_lists_t, dxes: dx_lists_t,
epsilon: fdfield, epsilon: fdfield_t,
mu: fdfield | None = None, mu: fdfield_t | None = None,
) -> cfdfield_updater_t: ) -> cfdfield_updater_t:
r""" """
Operator that turns an E-field distribution into a total-field/scattered-field Operator that turns an E-field distribution into a total-field/scattered-field
(TFSF) source. (TFSF) source.
If `A` is the full wave operator from `e_full(...)` and `Q` is the diagonal
mask selecting the total-field region, then the TFSF source is the commutator
$$
\frac{A Q - Q A}{-i \omega} E.
$$
This vanishes in the interior of the total-field and scattered-field regions
and is supported only at their shared boundary, where the mask discontinuity
makes `A` and `Q` fail to commute. The returned current is therefore the
distributed source needed to inject the desired total field without also
forcing the scattered-field region.
Args: Args:
TF_region: mask which is set to 1 in the total-field region, and 0 elsewhere TF_region: mask which is set to 1 in the total-field region, and 0 elsewhere
(i.e. in the scattered-field region). (i.e. in the scattered-field region).
@ -188,25 +179,20 @@ def e_tfsf_source(
Function `f` which takes an E field and returns a current distribution, Function `f` which takes an E field and returns a current distribution,
`f(E)` -> `J` `f(E)` -> `J`
""" """
# TODO documentation
A = e_full(omega, dxes, epsilon, mu) A = e_full(omega, dxes, epsilon, mu)
def op(e: cfdfield) -> cfdfield_t: def op(e: cfdfield_t) -> cfdfield_t:
neg_iwj = A(TF_region * e) - TF_region * A(e) neg_iwj = A(TF_region * e) - TF_region * A(e)
return cfdfield_t(neg_iwj / (-1j * omega)) return neg_iwj / (-1j * omega)
return op return op
def poynting_e_cross_h(dxes: dx_lists_t) -> Callable[[cfdfield, cfdfield], cfdfield_t]: def poynting_e_cross_h(dxes: dx_lists_t) -> Callable[[cfdfield_t, cfdfield_t], cfdfield_t]:
r""" """
Generates a function that takes the single-frequency `E` and `H` fields Generates a function that takes the single-frequency `E` and `H` fields
and calculates the cross product `E` x `H` = $E \times H$ as required and calculates the cross product `E` x `H` = $E \\times H$ as required
for the Poynting vector, $S = E \times H$. for the Poynting vector, $S = E \\times H$
On the Yee grid, the electric and magnetic components are not stored at the
same locations. This helper therefore applies the same one-cell electric-field
shifts used by the sparse `operators.poynting_e_cross(...)` construction so
that the discrete cross product matches the face-centered energy flux used in
`meanas.fdtd.energy.poynting(...)`.
Note: Note:
This function also shifts the input `E` field by one cell as required This function also shifts the input `E` field by one cell as required
@ -214,7 +200,7 @@ def poynting_e_cross_h(dxes: dx_lists_t) -> Callable[[cfdfield, cfdfield], cfdfi
Note: Note:
If `E` and `H` are peak amplitudes as assumed elsewhere in this code, If `E` and `H` are peak amplitudes as assumed elsewhere in this code,
the time-average of the poynting vector is `<S> = Re(S)/2 = Re(E x H*) / 2`. the time-average of the poynting vector is `<S> = Re(S)/2 = Re(E x H) / 2`.
The factor of `1/2` can be omitted if root-mean-square quantities are used The factor of `1/2` can be omitted if root-mean-square quantities are used
instead. instead.
@ -222,10 +208,9 @@ def poynting_e_cross_h(dxes: dx_lists_t) -> Callable[[cfdfield, cfdfield], cfdfi
dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types` dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types`
Returns: Returns:
Function `f` that returns the staggered-grid cross product `E \times H`. Function `f` that returns E x H as required for the poynting vector.
For time-average power, call it as `f(E, H.conj())` and take `Re(...) / 2`.
""" """
def exh(e: cfdfield, h: cfdfield) -> cfdfield_t: def exh(e: cfdfield_t, h: cfdfield_t) -> cfdfield_t:
s = numpy.empty_like(e) s = numpy.empty_like(e)
ex = e[0] * dxes[0][0][:, None, None] ex = e[0] * dxes[0][0][:, None, None]
ey = e[1] * dxes[0][1][None, :, None] ey = e[1] * dxes[0][1][None, :, None]
@ -236,5 +221,5 @@ def poynting_e_cross_h(dxes: dx_lists_t) -> Callable[[cfdfield, cfdfield], cfdfi
s[0] = numpy.roll(ey, -1, axis=0) * hz - numpy.roll(ez, -1, axis=0) * hy s[0] = numpy.roll(ey, -1, axis=0) * hz - numpy.roll(ez, -1, axis=0) * hy
s[1] = numpy.roll(ez, -1, axis=1) * hx - numpy.roll(ex, -1, axis=1) * hz s[1] = numpy.roll(ez, -1, axis=1) * hx - numpy.roll(ex, -1, axis=1) * hz
s[2] = numpy.roll(ex, -1, axis=2) * hy - numpy.roll(ey, -1, axis=2) * hx s[2] = numpy.roll(ex, -1, axis=2) * hy - numpy.roll(ey, -1, axis=2) * hx
return cfdfield_t(s) return s
return exh return exh

View file

@ -1,7 +1,7 @@
""" """
Sparse matrix operators for use with electromagnetic wave equations. Sparse matrix operators for use with electromagnetic wave equations.
These functions return sparse-matrix (`scipy.sparse.sparray`) representations of These functions return sparse-matrix (`scipy.sparse.spmatrix`) representations of
a variety of operators, intended for use with E and H fields vectorized using the a variety of operators, intended for use with E and H fields vectorized using the
`meanas.fdmath.vectorization.vec()` and `meanas.fdmath.vectorization.unvec()` functions. `meanas.fdmath.vectorization.vec()` and `meanas.fdmath.vectorization.unvec()` functions.
@ -28,9 +28,9 @@ The following operators are included:
""" """
import numpy import numpy
from scipy import sparse import scipy.sparse as sparse # type: ignore
from ..fdmath import vec, dx_lists_t, vfdfield, vcfdfield from ..fdmath import vec, dx_lists_t, vfdfield_t, vcfdfield_t
from ..fdmath.operators import shift_with_mirror, shift_circ, curl_forward, curl_back from ..fdmath.operators import shift_with_mirror, shift_circ, curl_forward, curl_back
@ -40,19 +40,19 @@ __author__ = 'Jan Petykiewicz'
def e_full( def e_full(
omega: complex, omega: complex,
dxes: dx_lists_t, dxes: dx_lists_t,
epsilon: vfdfield | vcfdfield, epsilon: vfdfield_t,
mu: vfdfield | None = None, mu: vfdfield_t | None = None,
pec: vfdfield | None = None, pec: vfdfield_t | None = None,
pmc: vfdfield | None = None, pmc: vfdfield_t | None = None,
) -> sparse.sparray: ) -> sparse.spmatrix:
r""" """
Wave operator Wave operator
$$ \nabla \times (\frac{1}{\mu} \nabla \times) - \Omega^2 \epsilon $$ $$ \\nabla \\times (\\frac{1}{\\mu} \\nabla \\times) - \\Omega^2 \\epsilon $$
del x (1/mu * del x) - omega**2 * epsilon del x (1/mu * del x) - omega**2 * epsilon
for use with the E-field, with wave equation for use with the E-field, with wave equation
$$ (\nabla \times (\frac{1}{\mu} \nabla \times) - \Omega^2 \epsilon) E = -\imath \omega J $$ $$ (\\nabla \\times (\\frac{1}{\\mu} \\nabla \\times) - \\Omega^2 \\epsilon) E = -\\imath \\omega J $$
(del x (1/mu * del x) - omega**2 * epsilon) E = -i * omega * J (del x (1/mu * del x) - omega**2 * epsilon) E = -i * omega * J
@ -64,11 +64,11 @@ def e_full(
epsilon: Vectorized dielectric constant epsilon: Vectorized dielectric constant
mu: Vectorized magnetic permeability (default 1 everywhere). mu: Vectorized magnetic permeability (default 1 everywhere).
pec: Vectorized mask specifying PEC cells. Any cells where `pec != 0` are interpreted pec: Vectorized mask specifying PEC cells. Any cells where `pec != 0` are interpreted
as containing a perfect electrical conductor (PEC). as containing a perfect electrical conductor (PEC).
The PEC is applied per-field-component (i.e. `pec.size == epsilon.size`) The PEC is applied per-field-component (i.e. `pec.size == epsilon.size`)
pmc: Vectorized mask specifying PMC cells. Any cells where `pmc != 0` are interpreted pmc: Vectorized mask specifying PMC cells. Any cells where `pmc != 0` are interpreted
as containing a perfect magnetic conductor (PMC). as containing a perfect magnetic conductor (PMC).
The PMC is applied per-field-component (i.e. `pmc.size == epsilon.size`) The PMC is applied per-field-component (i.e. `pmc.size == epsilon.size`)
Returns: Returns:
Sparse matrix containing the wave operator. Sparse matrix containing the wave operator.
@ -77,20 +77,20 @@ def e_full(
ce = curl_forward(dxes[0]) ce = curl_forward(dxes[0])
if pec is None: if pec is None:
pe = sparse.eye_array(epsilon.size) pe = sparse.eye(epsilon.size)
else: else:
pe = sparse.diags_array(numpy.where(pec, 0, 1)) # Set pe to (not PEC) pe = sparse.diags(numpy.where(pec, 0, 1)) # Set pe to (not PEC)
if pmc is None: if pmc is None:
pm = sparse.eye_array(epsilon.size) pm = sparse.eye(epsilon.size)
else: else:
pm = sparse.diags_array(numpy.where(pmc, 0, 1)) # set pm to (not PMC) pm = sparse.diags(numpy.where(pmc, 0, 1)) # set pm to (not PMC)
e = sparse.diags_array(epsilon) e = sparse.diags(epsilon)
if mu is None: if mu is None:
m_div = sparse.eye_array(epsilon.size) m_div = sparse.eye(epsilon.size)
else: else:
m_div = sparse.diags_array(1 / mu) m_div = sparse.diags(1 / mu)
op = pe @ (ch @ pm @ m_div @ ce - omega**2 * e) @ pe op = pe @ (ch @ pm @ m_div @ ce - omega**2 * e) @ pe
return op return op
@ -98,7 +98,7 @@ def e_full(
def e_full_preconditioners( def e_full_preconditioners(
dxes: dx_lists_t, dxes: dx_lists_t,
) -> tuple[sparse.sparray, sparse.sparray]: ) -> tuple[sparse.spmatrix, sparse.spmatrix]:
""" """
Left and right preconditioners `(Pl, Pr)` for symmetrizing the `e_full` wave operator. Left and right preconditioners `(Pl, Pr)` for symmetrizing the `e_full` wave operator.
@ -118,27 +118,27 @@ def e_full_preconditioners(
dxes[1][0][:, None, None] * dxes[1][1][None, :, None] * dxes[0][2][None, None, :]] dxes[1][0][:, None, None] * dxes[1][1][None, :, None] * dxes[0][2][None, None, :]]
p_vector = numpy.sqrt(vec(p_squared)) p_vector = numpy.sqrt(vec(p_squared))
P_left = sparse.diags_array(p_vector) P_left = sparse.diags(p_vector)
P_right = sparse.diags_array(1 / p_vector) P_right = sparse.diags(1 / p_vector)
return P_left, P_right return P_left, P_right
def h_full( def h_full(
omega: complex, omega: complex,
dxes: dx_lists_t, dxes: dx_lists_t,
epsilon: vfdfield, epsilon: vfdfield_t,
mu: vfdfield | None = None, mu: vfdfield_t | None = None,
pec: vfdfield | None = None, pec: vfdfield_t | None = None,
pmc: vfdfield | None = None, pmc: vfdfield_t | None = None,
) -> sparse.sparray: ) -> sparse.spmatrix:
r""" """
Wave operator Wave operator
$$ \nabla \times (\frac{1}{\epsilon} \nabla \times) - \omega^2 \mu $$ $$ \\nabla \\times (\\frac{1}{\\epsilon} \\nabla \\times) - \\omega^2 \\mu $$
del x (1/epsilon * del x) - omega**2 * mu del x (1/epsilon * del x) - omega**2 * mu
for use with the H-field, with wave equation for use with the H-field, with wave equation
$$ (\nabla \times (\frac{1}{\epsilon} \nabla \times) - \omega^2 \mu) E = \imath \omega M $$ $$ (\\nabla \\times (\\frac{1}{\\epsilon} \\nabla \\times) - \\omega^2 \\mu) E = \\imath \\omega M $$
(del x (1/epsilon * del x) - omega**2 * mu) E = i * omega * M (del x (1/epsilon * del x) - omega**2 * mu) E = i * omega * M
@ -148,11 +148,11 @@ def h_full(
epsilon: Vectorized dielectric constant epsilon: Vectorized dielectric constant
mu: Vectorized magnetic permeability (default 1 everywhere) mu: Vectorized magnetic permeability (default 1 everywhere)
pec: Vectorized mask specifying PEC cells. Any cells where `pec != 0` are interpreted pec: Vectorized mask specifying PEC cells. Any cells where `pec != 0` are interpreted
as containing a perfect electrical conductor (PEC). as containing a perfect electrical conductor (PEC).
The PEC is applied per-field-component (i.e. `pec.size == epsilon.size`) The PEC is applied per-field-component (i.e. `pec.size == epsilon.size`)
pmc: Vectorized mask specifying PMC cells. Any cells where `pmc != 0` are interpreted pmc: Vectorized mask specifying PMC cells. Any cells where `pmc != 0` are interpreted
as containing a perfect magnetic conductor (PMC). as containing a perfect magnetic conductor (PMC).
The PMC is applied per-field-component (i.e. `pmc.size == epsilon.size`) The PMC is applied per-field-component (i.e. `pmc.size == epsilon.size`)
Returns: Returns:
Sparse matrix containing the wave operator. Sparse matrix containing the wave operator.
@ -161,20 +161,20 @@ def h_full(
ce = curl_forward(dxes[0]) ce = curl_forward(dxes[0])
if pec is None: if pec is None:
pe = sparse.eye_array(epsilon.size) pe = sparse.eye(epsilon.size)
else: else:
pe = sparse.diags_array(numpy.where(pec, 0, 1)) # set pe to (not PEC) pe = sparse.diags(numpy.where(pec, 0, 1)) # set pe to (not PEC)
if pmc is None: if pmc is None:
pm = sparse.eye_array(epsilon.size) pm = sparse.eye(epsilon.size)
else: else:
pm = sparse.diags_array(numpy.where(pmc, 0, 1)) # Set pe to (not PMC) pm = sparse.diags(numpy.where(pmc, 0, 1)) # Set pe to (not PMC)
e_div = sparse.diags_array(1 / epsilon) e_div = sparse.diags(1 / epsilon)
if mu is None: if mu is None:
m = sparse.eye_array(epsilon.size) m = sparse.eye(epsilon.size)
else: else:
m = sparse.diags_array(mu) m = sparse.diags(mu)
A = pm @ (ce @ pe @ e_div @ ch - omega**2 * m) @ pm A = pm @ (ce @ pe @ e_div @ ch - omega**2 * m) @ pm
return A return A
@ -183,33 +183,33 @@ def h_full(
def eh_full( def eh_full(
omega: complex, omega: complex,
dxes: dx_lists_t, dxes: dx_lists_t,
epsilon: vfdfield, epsilon: vfdfield_t,
mu: vfdfield | None = None, mu: vfdfield_t | None = None,
pec: vfdfield | None = None, pec: vfdfield_t | None = None,
pmc: vfdfield | None = None, pmc: vfdfield_t | None = None,
) -> sparse.sparray: ) -> sparse.spmatrix:
r""" """
Wave operator for `[E, H]` field representation. This operator implements Maxwell's Wave operator for `[E, H]` field representation. This operator implements Maxwell's
equations without cancelling out either E or H. The operator is equations without cancelling out either E or H. The operator is
$$ \begin{bmatrix} $$ \\begin{bmatrix}
-\imath \omega \epsilon & \nabla \times \\ -\\imath \\omega \\epsilon & \\nabla \\times \\\\
\nabla \times & \imath \omega \mu \\nabla \\times & \\imath \\omega \\mu
\end{bmatrix} $$ \\end{bmatrix} $$
[[-i * omega * epsilon, del x ], [[-i * omega * epsilon, del x ],
[del x, i * omega * mu]] [del x, i * omega * mu]]
for use with a field vector of the form `cat(vec(E), vec(H))`: for use with a field vector of the form `cat(vec(E), vec(H))`:
$$ \begin{bmatrix} $$ \\begin{bmatrix}
-\imath \omega \epsilon & \nabla \times \\ -\\imath \\omega \\epsilon & \\nabla \\times \\\\
\nabla \times & \imath \omega \mu \\nabla \\times & \\imath \\omega \\mu
\end{bmatrix} \\end{bmatrix}
\begin{bmatrix} E \\ \\begin{bmatrix} E \\\\
H H
\end{bmatrix} \\end{bmatrix}
= \begin{bmatrix} J \\ = \\begin{bmatrix} J \\\\
-M -M
\end{bmatrix} $$ \\end{bmatrix} $$
Args: Args:
omega: Angular frequency of the simulation omega: Angular frequency of the simulation
@ -217,47 +217,45 @@ def eh_full(
epsilon: Vectorized dielectric constant epsilon: Vectorized dielectric constant
mu: Vectorized magnetic permeability (default 1 everywhere) mu: Vectorized magnetic permeability (default 1 everywhere)
pec: Vectorized mask specifying PEC cells. Any cells where `pec != 0` are interpreted pec: Vectorized mask specifying PEC cells. Any cells where `pec != 0` are interpreted
as containing a perfect electrical conductor (PEC). as containing a perfect electrical conductor (PEC).
The PEC is applied per-field-component (i.e. `pec.size == epsilon.size`) The PEC is applied per-field-component (i.e. `pec.size == epsilon.size`)
pmc: Vectorized mask specifying PMC cells. Any cells where `pmc != 0` are interpreted pmc: Vectorized mask specifying PMC cells. Any cells where `pmc != 0` are interpreted
as containing a perfect magnetic conductor (PMC). as containing a perfect magnetic conductor (PMC).
The PMC is applied per-field-component (i.e. `pmc.size == epsilon.size`) The PMC is applied per-field-component (i.e. `pmc.size == epsilon.size`)
Returns: Returns:
Sparse matrix containing the wave operator. Sparse matrix containing the wave operator.
""" """
if pec is None: if pec is None:
pe = sparse.eye_array(epsilon.size) pe = sparse.eye(epsilon.size)
else: else:
pe = sparse.diags_array(numpy.where(pec, 0, 1)) # set pe to (not PEC) pe = sparse.diags(numpy.where(pec, 0, 1)) # set pe to (not PEC)
if pmc is None: if pmc is None:
pm = sparse.eye_array(epsilon.size) pm = sparse.eye(epsilon.size)
else: else:
pm = sparse.diags_array(numpy.where(pmc, 0, 1)) # set pm to (not PMC) pm = sparse.diags(numpy.where(pmc, 0, 1)) # set pm to (not PMC)
iwe = pe @ (1j * omega * sparse.diags(epsilon)) @ pe iwe = pe @ (1j * omega * sparse.diags(epsilon)) @ pe
if mu is None: iwm = 1j * omega
iwm = 1j * omega * sparse.eye(epsilon.size) if mu is not None:
else: iwm *= sparse.diags(mu)
iwm = 1j * omega * sparse.diags(mu)
iwm = pm @ iwm @ pm iwm = pm @ iwm @ pm
A1 = pe @ curl_back(dxes[1]) @ pm A1 = pe @ curl_back(dxes[1]) @ pm
A2 = pm @ curl_forward(dxes[0]) @ pe A2 = pm @ curl_forward(dxes[0]) @ pe
A = sparse.block_array([[-iwe, A1], A = sparse.bmat([[-iwe, A1],
[A2, iwm]]) [A2, iwm]])
return A return A
def e2h( def e2h(
omega: complex, omega: complex,
dxes: dx_lists_t, dxes: dx_lists_t,
mu: vfdfield | None = None, mu: vfdfield_t | None = None,
pmc: vfdfield | None = None, pmc: vfdfield_t | None = None,
) -> sparse.sparray: ) -> sparse.spmatrix:
""" """
Utility operator for converting the E field into the H field. Utility operator for converting the E field into the H field.
For use with `e_full()` -- assumes that there is no magnetic current M. For use with `e_full()` -- assumes that there is no magnetic current M.
@ -267,8 +265,8 @@ def e2h(
dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types` dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types`
mu: Vectorized magnetic permeability (default 1 everywhere) mu: Vectorized magnetic permeability (default 1 everywhere)
pmc: Vectorized mask specifying PMC cells. Any cells where `pmc != 0` are interpreted pmc: Vectorized mask specifying PMC cells. Any cells where `pmc != 0` are interpreted
as containing a perfect magnetic conductor (PMC). as containing a perfect magnetic conductor (PMC).
The PMC is applied per-field-component (i.e. `pmc.size == epsilon.size`) The PMC is applied per-field-component (i.e. `pmc.size == epsilon.size`)
Returns: Returns:
Sparse matrix for converting E to H. Sparse matrix for converting E to H.
@ -276,10 +274,10 @@ def e2h(
op = curl_forward(dxes[0]) / (-1j * omega) op = curl_forward(dxes[0]) / (-1j * omega)
if mu is not None: if mu is not None:
op = sparse.diags_array(1 / mu) @ op op = sparse.diags(1 / mu) @ op
if pmc is not None: if pmc is not None:
op = sparse.diags_array(numpy.where(pmc, 0, 1)) @ op op = sparse.diags(numpy.where(pmc, 0, 1)) @ op
return op return op
@ -287,8 +285,8 @@ def e2h(
def m2j( def m2j(
omega: complex, omega: complex,
dxes: dx_lists_t, dxes: dx_lists_t,
mu: vfdfield | None = None, mu: vfdfield_t | None = None,
) -> sparse.sparray: ) -> sparse.spmatrix:
""" """
Operator for converting a magnetic current M into an electric current J. Operator for converting a magnetic current M into an electric current J.
For use with eg. `e_full()`. For use with eg. `e_full()`.
@ -304,108 +302,78 @@ def m2j(
op = curl_back(dxes[1]) / (1j * omega) op = curl_back(dxes[1]) / (1j * omega)
if mu is not None: if mu is not None:
op = op @ sparse.diags_array(1 / mu) op = op @ sparse.diags(1 / mu)
return op return op
def poynting_e_cross(e: vcfdfield, dxes: dx_lists_t) -> sparse.sparray: def poynting_e_cross(e: vcfdfield_t, dxes: dx_lists_t) -> sparse.spmatrix:
r""" """
Operator for computing the staggered-grid `(E \times)` part of the Poynting vector. Operator for computing the Poynting vector, containing the
(E x) portion of the Poynting vector.
On the Yee grid the E and H components live on different edges, so the
electric field must be shifted by one cell in the appropriate direction
before forming the discrete cross product. This sparse operator encodes that
shifted cross product directly and is the matrix equivalent of
`functional.poynting_e_cross_h(...)`.
Args: Args:
e: Vectorized E-field for the ExH cross product e: Vectorized E-field for the ExH cross product
dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types` dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types`
Returns: Returns:
Sparse matrix containing the `(E \times)` part of the staggered Poynting Sparse matrix containing (E x) portion of Poynting cross product.
cross product.
""" """
shape = [len(dx) for dx in dxes[0]] shape = [len(dx) for dx in dxes[0]]
fx, fy, fz = (shift_circ(i, shape, 1) for i in range(3)) fx, fy, fz = [shift_circ(i, shape, 1) for i in range(3)]
dxag = [dx.ravel(order='C') for dx in numpy.meshgrid(*dxes[0], indexing='ij')] dxag = [dx.ravel(order='C') for dx in numpy.meshgrid(*dxes[0], indexing='ij')]
dxbg = [dx.ravel(order='C') for dx in numpy.meshgrid(*dxes[1], indexing='ij')] Ex, Ey, Ez = [ei * da for ei, da in zip(numpy.split(e, 3), dxag)]
Ex, Ey, Ez = (ei * da for ei, da in zip(numpy.split(e, 3), dxag, strict=True))
block_diags = [[ None, fx @ -Ez, fx @ Ey], block_diags = [[ None, fx @ -Ez, fx @ Ey],
[ fy @ Ez, None, fy @ -Ex], [ fy @ Ez, None, fy @ -Ex],
[ fz @ -Ey, fz @ Ex, None]] [ fz @ -Ey, fz @ Ex, None]]
block_matrix = sparse.block_array([[sparse.diags_array(x) if x is not None else None for x in row] block_matrix = sparse.bmat([[sparse.diags(x) if x is not None else None for x in row]
for row in block_diags]) for row in block_diags])
P = block_matrix @ sparse.diags_array(numpy.concatenate(dxbg)) P = block_matrix @ sparse.diags(numpy.concatenate(dxag))
return P return P
def poynting_h_cross(h: vcfdfield, dxes: dx_lists_t) -> sparse.sparray: def poynting_h_cross(h: vcfdfield_t, dxes: dx_lists_t) -> sparse.spmatrix:
r""" """
Operator for computing the staggered-grid `(H \times)` part of the Poynting vector. Operator for computing the Poynting vector, containing the (H x) portion of the Poynting vector.
Together with `poynting_e_cross(...)`, this provides the matrix form of the
Yee-grid cross product used in the flux helpers. The two are related by the
usual antisymmetry of the cross product,
$$
H \times E = -(E \times H),
$$
once the same staggered field placement is used on both sides.
Args: Args:
h: Vectorized H-field for the HxE cross product h: Vectorized H-field for the HxE cross product
dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types` dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types`
Returns: Returns:
Sparse matrix containing the `(H \times)` part of the staggered Poynting Sparse matrix containing (H x) portion of Poynting cross product.
cross product.
""" """
shape = [len(dx) for dx in dxes[0]] shape = [len(dx) for dx in dxes[0]]
fx, fy, fz = (shift_circ(i, shape, 1) for i in range(3)) fx, fy, fz = [shift_circ(i, shape, 1) for i in range(3)]
dxag = [dx.ravel(order='C') for dx in numpy.meshgrid(*dxes[0], indexing='ij')] dxag = [dx.ravel(order='C') for dx in numpy.meshgrid(*dxes[0], indexing='ij')]
dxbg = [dx.ravel(order='C') for dx in numpy.meshgrid(*dxes[1], indexing='ij')] dxbg = [dx.ravel(order='C') for dx in numpy.meshgrid(*dxes[1], indexing='ij')]
Hx, Hy, Hz = (sparse.diags_array(hi * db) for hi, db in zip(numpy.split(h, 3), dxbg, strict=True)) Hx, Hy, Hz = [sparse.diags(hi * db) for hi, db in zip(numpy.split(h, 3), dxbg)]
P = (sparse.block_array( P = (sparse.bmat(
[[ None, -Hz @ fx, Hy @ fx], [[ None, -Hz @ fx, Hy @ fx],
[ Hz @ fy, None, -Hx @ fy], [ Hz @ fy, None, -Hx @ fy],
[-Hy @ fz, Hx @ fz, None]]) [-Hy @ fz, Hx @ fz, None]])
@ sparse.diags_array(numpy.concatenate(dxag))) @ sparse.diags(numpy.concatenate(dxag)))
return P return P
def e_tfsf_source( def e_tfsf_source(
TF_region: vfdfield, TF_region: vfdfield_t,
omega: complex, omega: complex,
dxes: dx_lists_t, dxes: dx_lists_t,
epsilon: vfdfield, epsilon: vfdfield_t,
mu: vfdfield | None = None, mu: vfdfield_t | None = None,
) -> sparse.sparray: ) -> sparse.spmatrix:
r""" """
Operator that turns a desired E-field distribution into a Operator that turns a desired E-field distribution into a
total-field/scattered-field (TFSF) source. total-field/scattered-field (TFSF) source.
Let `A` be the full wave operator from `e_full(...)`, and let TODO: Reference Rumpf paper
`Q = \mathrm{diag}(TF_region)` be the projector onto the total-field region.
Then the TFSF current operator is the commutator
$$
\frac{A Q - Q A}{-i \omega}.
$$
Inside regions where `Q` is locally constant, `A` and `Q` commute and the
source vanishes. Only cells at the TF/SF boundary contribute nonzero current,
which is exactly the desired distributed source for injecting the chosen
field into the total-field region without directly forcing the
scattered-field region.
Args: Args:
TF_region: Mask, which is set to 1 inside the total-field region and 0 in the TF_region: Mask, which is set to 1 inside the total-field region and 0 in the
@ -417,31 +385,27 @@ def e_tfsf_source(
Returns: Returns:
Sparse matrix that turns an E-field into a current (J) distribution. Sparse matrix that turns an E-field into a current (J) distribution.
""" """
# TODO documentation
A = e_full(omega, dxes, epsilon, mu) A = e_full(omega, dxes, epsilon, mu)
Q = sparse.diags_array(TF_region) Q = sparse.diags(TF_region)
return (A @ Q - Q @ A) / (-1j * omega) return (A @ Q - Q @ A) / (-1j * omega)
def e_boundary_source( def e_boundary_source(
mask: vfdfield, mask: vfdfield_t,
omega: complex, omega: complex,
dxes: dx_lists_t, dxes: dx_lists_t,
epsilon: vfdfield, epsilon: vfdfield_t,
mu: vfdfield | None = None, mu: vfdfield_t | None = None,
periodic_mask_edges: bool = False, periodic_mask_edges: bool = False,
) -> sparse.sparray: ) -> sparse.spmatrix:
r""" """
Operator that turns an E-field distrubtion into a current (J) distribution Operator that turns an E-field distrubtion into a current (J) distribution
along the edges (external and internal) of the provided mask. This is just an along the edges (external and internal) of the provided mask. This is just an
`e_tfsf_source()` with an additional masking step. `e_tfsf_source()` with an additional masking step.
Equivalently, this helper first constructs the TFSF commutator source for the
full mask and then zeroes out all cells except the mask boundary. The
boundary is defined as the set of cells whose mask value changes under a
one-cell shift in any Cartesian direction. With `periodic_mask_edges=False`
the shifts mirror at the domain boundary; with `True` they wrap periodically.
Args: Args:
mask: The current distribution is generated at the edges of the mask, mask: The current distribution is generated at the edges of the mask,
i.e. any points where shifting the mask by one cell in any direction i.e. any points where shifting the mask by one cell in any direction
@ -459,10 +423,10 @@ def e_boundary_source(
shape = [len(dxe) for dxe in dxes[0]] shape = [len(dxe) for dxe in dxes[0]]
jmask = numpy.zeros_like(mask, dtype=bool) jmask = numpy.zeros_like(mask, dtype=bool)
def shift_rot(axis: int, polarity: int) -> sparse.sparray: def shift_rot(axis: int, polarity: int) -> sparse.spmatrix:
return shift_circ(axis=axis, shape=shape, shift_distance=polarity) return shift_circ(axis=axis, shape=shape, shift_distance=polarity)
def shift_mir(axis: int, polarity: int) -> sparse.sparray: def shift_mir(axis: int, polarity: int) -> sparse.spmatrix:
return shift_with_mirror(axis=axis, shape=shape, shift_distance=polarity) return shift_with_mirror(axis=axis, shape=shape, shift_distance=polarity)
shift = shift_rot if periodic_mask_edges else shift_mir shift = shift_rot if periodic_mask_edges else shift_mir
@ -471,7 +435,7 @@ def e_boundary_source(
if shape[axis] == 1: if shape[axis] == 1:
continue continue
for polarity in (-1, +1): for polarity in (-1, +1):
r = shift(axis, polarity) - sparse.eye_array(numpy.prod(shape)) # shifted minus original r = shift(axis, polarity) - sparse.eye(numpy.prod(shape)) # shifted minus original
r3 = sparse.block_diag((r, r, r)) r3 = sparse.block_diag((r, r, r))
jmask = numpy.logical_or(jmask, numpy.abs(r3 @ mask)) jmask = numpy.logical_or(jmask, numpy.abs(r3 @ mask))
@ -482,4 +446,5 @@ def e_boundary_source(
# (numpy.roll(mask, -1, axis=2) != mask) | # (numpy.roll(mask, -1, axis=2) != mask) |
# (numpy.roll(mask, +1, axis=2) != mask)) # (numpy.roll(mask, +1, axis=2) != mask))
return sparse.diags_array(jmask.astype(int)) @ full return sparse.diags(jmask.astype(int)) @ full

View file

@ -2,7 +2,7 @@
Functions for creating stretched coordinate perfectly matched layer (PML) absorbers. Functions for creating stretched coordinate perfectly matched layer (PML) absorbers.
""" """
from collections.abc import Sequence, Callable from typing import Sequence, Callable
import numpy import numpy
from numpy.typing import NDArray from numpy.typing import NDArray
@ -128,11 +128,6 @@ def stretch_with_scpml(
dx_ai = dxes[0][axis].astype(complex) dx_ai = dxes[0][axis].astype(complex)
dx_bi = dxes[1][axis].astype(complex) dx_bi = dxes[1][axis].astype(complex)
if thickness == 0:
dxes[0][axis] = dx_ai
dxes[1][axis] = dx_bi
return dxes
pos = numpy.hstack((0, dx_ai.cumsum())) pos = numpy.hstack((0, dx_ai.cumsum()))
pos_a = (pos[:-1] + pos[1:]) / 2 pos_a = (pos[:-1] + pos[1:]) / 2
pos_b = pos[:-1] pos_b = pos[:-1]
@ -158,7 +153,10 @@ def stretch_with_scpml(
def l_d(x: NDArray[numpy.float64]) -> NDArray[numpy.float64]: def l_d(x: NDArray[numpy.float64]) -> NDArray[numpy.float64]:
return (x - bound) / (pos[-1] - bound) return (x - bound) / (pos[-1] - bound)
slc = slice(-thickness, None) if thickness == 0:
slc = slice(None)
else:
slc = slice(-thickness, None)
dx_ai[slc] *= 1 + 1j * s_function(l_d(pos_a[slc])) / d / s_correction dx_ai[slc] *= 1 + 1j * s_function(l_d(pos_a[slc])) / d / s_correction
dx_bi[slc] *= 1 + 1j * s_function(l_d(pos_b[slc])) / d / s_correction dx_bi[slc] *= 1 + 1j * s_function(l_d(pos_b[slc])) / d / s_correction

View file

@ -2,16 +2,15 @@
Solvers and solver interface for FDFD problems. Solvers and solver interface for FDFD problems.
""" """
from typing import Any from typing import Callable, Dict, Any, Optional
from collections.abc import Callable
import logging import logging
import numpy import numpy
from numpy.typing import ArrayLike, NDArray from numpy.typing import ArrayLike, NDArray
from numpy.linalg import norm from numpy.linalg import norm
import scipy.sparse.linalg import scipy.sparse.linalg # type: ignore
from ..fdmath import dx_lists_t, vfdfield, vcfdfield, vcfdfield_t from ..fdmath import dx_lists_t, vfdfield_t, vcfdfield_t
from . import operators from . import operators
@ -19,7 +18,7 @@ logger = logging.getLogger(__name__)
def _scipy_qmr( def _scipy_qmr(
A: scipy.sparse.csr_array, A: scipy.sparse.csr_matrix,
b: ArrayLike, b: ArrayLike,
**kwargs: Any, **kwargs: Any,
) -> NDArray[numpy.float64]: ) -> NDArray[numpy.float64]:
@ -35,32 +34,30 @@ def _scipy_qmr(
Guess for solution (returned even if didn't converge) Guess for solution (returned even if didn't converge)
""" """
# '''
#Report on our progress Report on our progress
# '''
ii = 0 ii = 0
def log_residual(xk: ArrayLike) -> None: def log_residual(xk: ArrayLike) -> None:
nonlocal ii nonlocal ii
ii += 1 ii += 1
if ii % 100 == 0: if ii % 100 == 0:
cur_norm = norm(A @ xk - b) / norm(b) logger.info('Solver residual at iteration {} : {}'.format(ii, norm(A @ xk - b)))
logger.info(f'Solver residual at iteration {ii} : {cur_norm}')
if 'callback' in kwargs: if 'callback' in kwargs:
callback = kwargs['callback']
def augmented_callback(xk: ArrayLike) -> None: def augmented_callback(xk: ArrayLike) -> None:
log_residual(xk) log_residual(xk)
callback(xk) kwargs['callback'](xk)
kwargs['callback'] = augmented_callback kwargs['callback'] = augmented_callback
else: else:
kwargs['callback'] = log_residual kwargs['callback'] = log_residual
# '''
# Run the actual solve Run the actual solve
# '''
x, _ = scipy.sparse.linalg.qmr(A, b, **kwargs) x, _ = scipy.sparse.linalg.qmr(A, b, **kwargs)
return x return x
@ -68,16 +65,14 @@ def _scipy_qmr(
def generic( def generic(
omega: complex, omega: complex,
dxes: dx_lists_t, dxes: dx_lists_t,
J: vcfdfield, J: vcfdfield_t,
epsilon: vfdfield, epsilon: vfdfield_t,
mu: vfdfield | None = None, mu: Optional[vfdfield_t] = None,
*, pec: Optional[vfdfield_t] = None,
pec: vfdfield | None = None, pmc: Optional[vfdfield_t] = None,
pmc: vfdfield | None = None,
adjoint: bool = False, adjoint: bool = False,
matrix_solver: Callable[..., ArrayLike] = _scipy_qmr, matrix_solver: Callable[..., ArrayLike] = _scipy_qmr,
matrix_solver_opts: dict[str, Any] | None = None, matrix_solver_opts: Optional[Dict[str, Any]] = None,
E_guess: vcfdfield | None = None,
) -> vcfdfield_t: ) -> vcfdfield_t:
""" """
Conjugate gradient FDFD solver using CSR sparse matrices. Conjugate gradient FDFD solver using CSR sparse matrices.
@ -97,15 +92,13 @@ def generic(
(at H-field locations; non-zero value indicates PMC is present) (at H-field locations; non-zero value indicates PMC is present)
adjoint: If true, solves the adjoint problem. adjoint: If true, solves the adjoint problem.
matrix_solver: Called as `matrix_solver(A, b, **matrix_solver_opts) -> x`, matrix_solver: Called as `matrix_solver(A, b, **matrix_solver_opts) -> x`,
where `A`: `scipy.sparse.csr_array`; where `A`: `scipy.sparse.csr_matrix`;
`b`: `ArrayLike`; `b`: `ArrayLike`;
`x`: `ArrayLike`; `x`: `ArrayLike`;
Default is a wrapped version of `scipy.sparse.linalg.qmr()` Default is a wrapped version of `scipy.sparse.linalg.qmr()`
which doesn't return convergence info and logs the residual which doesn't return convergence info and logs the residual
every 100 iterations. every 100 iterations.
matrix_solver_opts: Passed as kwargs to `matrix_solver(...)` matrix_solver_opts: Passed as kwargs to `matrix_solver(...)`
E_guess: Guess at the solution E-field. `matrix_solver` must accept an
`x0` argument with the same purpose.
Returns: Returns:
E-field which solves the system. E-field which solves the system.
@ -120,24 +113,17 @@ def generic(
Pl, Pr = operators.e_full_preconditioners(dxes) Pl, Pr = operators.e_full_preconditioners(dxes)
if adjoint: if adjoint:
A = (Pl @ A0 @ Pr).T.conjugate() A = (Pl @ A0 @ Pr).H
b = Pr.T.conjugate() @ b0 b = Pr.H @ b0
else: else:
A = Pl @ A0 @ Pr A = Pl @ A0 @ Pr
b = Pl @ b0 b = Pl @ b0
if E_guess is not None:
if adjoint:
x0 = Pr.T.conjugate() @ E_guess
else:
x0 = Pl @ E_guess
matrix_solver_opts['x0'] = x0
x = matrix_solver(A.tocsr(), b, **matrix_solver_opts) x = matrix_solver(A.tocsr(), b, **matrix_solver_opts)
if adjoint: if adjoint:
x0 = Pl.T.conjugate() @ x x0 = Pl.H @ x
else: else:
x0 = Pr @ x x0 = Pr @ x
return vcfdfield_t(x0) return x0

File diff suppressed because it is too large Load diff

View file

@ -3,40 +3,15 @@ Tools for working with waveguide modes in 3D domains.
This module relies heavily on `waveguide_2d` and mostly just transforms This module relies heavily on `waveguide_2d` and mostly just transforms
its parameters into 2D equivalents and expands the results back into 3D. its parameters into 2D equivalents and expands the results back into 3D.
The intended workflow is:
1. Select a single-cell slice normal to the propagation axis.
2. Solve the corresponding 2D mode problem with `solve_mode(...)`.
3. Turn that mode into a one-sided source with `compute_source(...)`.
4. Build an overlap window with `compute_overlap_e(...)` for port readout.
`polarity` is part of the public convention throughout this module:
- `+1` means forward propagation toward increasing index along `axis`
- `-1` means backward propagation toward decreasing index along `axis`
That same convention controls which side of the selected slice is used for the
overlap window and how the expanded field is phased.
""" """
from typing import Any, TypedDict, cast from typing import Sequence, Any
import warnings
from collections.abc import Sequence
import numpy import numpy
from numpy.typing import NDArray from numpy.typing import NDArray
from numpy import complexfloating
from ..fdmath import vec, unvec, dx_lists_t, cfdfield_t, fdfield, cfdfield from ..fdmath import vec, unvec, dx_lists_t, fdfield_t, cfdfield_t
from . import operators, waveguide_2d from . import operators, waveguide_2d
class Waveguide3DMode(TypedDict):
wavenumber: complex
wavenumber_2d: complex
H: NDArray[complexfloating]
E: NDArray[complexfloating]
def solve_mode( def solve_mode(
mode_number: int, mode_number: int,
omega: complex, omega: complex,
@ -44,12 +19,12 @@ def solve_mode(
axis: int, axis: int,
polarity: int, polarity: int,
slices: Sequence[slice], slices: Sequence[slice],
epsilon: fdfield, epsilon: fdfield_t,
mu: fdfield | None = None, mu: fdfield_t | None = None,
) -> Waveguide3DMode: ) -> dict[str, complex | NDArray[numpy.float_]]:
r""" """
Given a 3D grid, selects a slice from the grid and attempts to Given a 3D grid, selects a slice from the grid and attempts to
solve for an eigenmode propagating through that slice. solve for an eigenmode propagating through that slice.
Args: Args:
mode_number: Number of the mode, 0-indexed mode_number: Number of the mode, 0-indexed
@ -58,31 +33,27 @@ def solve_mode(
axis: Propagation axis (0=x, 1=y, 2=z) axis: Propagation axis (0=x, 1=y, 2=z)
polarity: Propagation direction (+1 for +ve, -1 for -ve) polarity: Propagation direction (+1 for +ve, -1 for -ve)
slices: `epsilon[tuple(slices)]` is used to select the portion of the grid to use slices: `epsilon[tuple(slices)]` is used to select the portion of the grid to use
as the waveguide cross-section. `slices[axis]` must select exactly one item. as the waveguide cross-section. `slices[axis]` should select only one item.
epsilon: Dielectric constant epsilon: Dielectric constant
mu: Magnetic permeability (default 1 everywhere) mu: Magnetic permeability (default 1 everywhere)
Returns: Returns:
Dictionary containing: ```
{
- `E`: full-grid electric field for the solved mode 'E': list[NDArray[numpy.float_]],
- `H`: full-grid magnetic field for the solved mode 'H': list[NDArray[numpy.float_]],
- `wavenumber`: propagation constant corrected for the discretized 'wavenumber': complex,
propagation axis }
- `wavenumber_2d`: propagation constant of the reduced 2D eigenproblem ```
Notes:
The returned fields are normalized through the `waveguide_2d`
normalization convention before being expanded back to 3D.
""" """
if mu is None: if mu is None:
mu = numpy.ones_like(epsilon) mu = numpy.ones_like(epsilon)
slices = tuple(slices) slices = tuple(slices)
# '''
# Solve the 2D problem in the specified plane Solve the 2D problem in the specified plane
# '''
# Define rotation to set z as propagation direction # Define rotation to set z as propagation direction
order = numpy.roll(range(3), 2 - axis) order = numpy.roll(range(3), 2 - axis)
reverse_order = numpy.roll(range(3), axis - 2) reverse_order = numpy.roll(range(3), axis - 2)
@ -100,10 +71,9 @@ def solve_mode(
} }
e_xy, wavenumber_2d = waveguide_2d.solve_mode(mode_number, **args_2d) e_xy, wavenumber_2d = waveguide_2d.solve_mode(mode_number, **args_2d)
# '''
# Apply corrections and expand to 3D Apply corrections and expand to 3D
# '''
# Correct wavenumber to account for numerical dispersion. # Correct wavenumber to account for numerical dispersion.
wavenumber = 2 / dx_prop * numpy.arcsin(wavenumber_2d * dx_prop / 2) wavenumber = 2 / dx_prop * numpy.arcsin(wavenumber_2d * dx_prop / 2)
@ -122,12 +92,11 @@ def solve_mode(
# Expand E, H to full epsilon space we were given # Expand E, H to full epsilon space we were given
E = numpy.zeros_like(epsilon, dtype=complex) E = numpy.zeros_like(epsilon, dtype=complex)
H = numpy.zeros_like(epsilon, dtype=complex) H = numpy.zeros_like(epsilon, dtype=complex)
for aa, oo in enumerate(reverse_order): for a, o in enumerate(reverse_order):
iii = cast('tuple[slice | int]', (aa, *slices)) E[(a, *slices)] = e[o][:, :, None].transpose(reverse_order)
E[iii] = e[oo][:, :, None].transpose(reverse_order) H[(a, *slices)] = h[o][:, :, None].transpose(reverse_order)
H[iii] = h[oo][:, :, None].transpose(reverse_order)
results: Waveguide3DMode = { results = {
'wavenumber': wavenumber, 'wavenumber': wavenumber,
'wavenumber_2d': wavenumber_2d, 'wavenumber_2d': wavenumber_2d,
'H': H, 'H': H,
@ -137,15 +106,15 @@ def solve_mode(
def compute_source( def compute_source(
E: cfdfield, E: cfdfield_t,
wavenumber: complex, wavenumber: complex,
omega: complex, omega: complex,
dxes: dx_lists_t, dxes: dx_lists_t,
axis: int, axis: int,
polarity: int, polarity: int,
slices: Sequence[slice], slices: Sequence[slice],
epsilon: fdfield, epsilon: fdfield_t,
mu: fdfield | None = None, mu: fdfield_t | None = None,
) -> cfdfield_t: ) -> cfdfield_t:
""" """
Given an eigenmode obtained by `solve_mode`, returns the current source distribution Given an eigenmode obtained by `solve_mode`, returns the current source distribution
@ -163,14 +132,7 @@ def compute_source(
mu: Magnetic permeability (default 1 everywhere) mu: Magnetic permeability (default 1 everywhere)
Returns: Returns:
`J` distribution for a one-sided electric-current source. J distribution for the unidirectional source
Notes:
The source is built from the expanded mode field and a boundary-source
operator. The resulting current is intended to be injected with the
same sign convention used elsewhere in the package:
`E -= dt * J / epsilon`
""" """
E_expanded = expand_e(E=E, dxes=dxes, wavenumber=wavenumber, axis=axis, E_expanded = expand_e(E=E, dxes=dxes, wavenumber=wavenumber, axis=axis,
polarity=polarity, slices=slices) polarity=polarity, slices=slices)
@ -186,113 +148,66 @@ def compute_source(
masked_e2j = operators.e_boundary_source(mask=vec(mask), omega=omega, dxes=dxes, epsilon=vec(epsilon), mu=vec(mu)) masked_e2j = operators.e_boundary_source(mask=vec(mask), omega=omega, dxes=dxes, epsilon=vec(epsilon), mu=vec(mu))
J = unvec(masked_e2j @ vec(E_expanded), E.shape[1:]) J = unvec(masked_e2j @ vec(E_expanded), E.shape[1:])
return cfdfield_t(J) return J
def compute_overlap_e( def compute_overlap_e(
E: cfdfield, E: cfdfield_t,
wavenumber: complex, wavenumber: complex,
dxes: dx_lists_t, dxes: dx_lists_t,
axis: int, axis: int,
polarity: int, polarity: int,
slices: Sequence[slice], slices: Sequence[slice],
) -> cfdfield_t: ) -> cfdfield_t: # TODO DOCS
r""" """
Build an overlap field for projecting another 3D electric field onto a mode. Given an eigenmode obtained by `solve_mode`, calculates an overlap_e for the
mode orthogonality relation Integrate(((E x H_mode) + (E_mode x H)) dot dn)
The returned field is intended for the discrete overlap expression [assumes reflection symmetry].
$$
\sum \mathrm{overlap\_e} \; E_\mathrm{other}^*
$$
where the sum is over the full Yee-grid field storage.
The construction uses a two-cell window immediately upstream of the selected
slice:
- for `polarity=+1`, the two cells just before `slices[axis].start`
- for `polarity=-1`, the two cells just after `slices[axis].stop`
The window is clipped to the simulation domain if necessary. A clipped but
non-empty window raises `RuntimeWarning`; an empty window raises
`ValueError`.
The derivation below assumes reflection symmetry and the standard waveguide
overlap relation involving
$$
\int ((E \times H_\mathrm{mode}) + (E_\mathrm{mode} \times H)) \cdot dn.
$$
E x H_mode + E_mode x H
-> Ex Hmy - EyHmx + Emx Hy - Emy Hx (Z-prop)
Ex we/B Emx + Ex i/B dy Hmz - Ey (-we/B Emy) - Ey i/B dx Hmz
we/B (Ex Emx + Ey Emy) + i/B (Ex dy Hmz - Ey dx Hmz)
we/B (Ex Emx + Ey Emy) + i/B (Ex dy (dx Emy - dy Emx) - Ey dx (dx Emy - dy Emx))
we/B (Ex Emx + Ey Emy) + i/B (Ex dy dx Emy - Ex dy dy Emx - Ey dx dx Emy - Ey dx dy Emx)
Ex j/wu (-jB Emx - dx Emz) - Ey j/wu (dy Emz + jB Emy)
B/wu (Ex Emx + Ey Emy) - j/wu (Ex dx Emz + Ey dy Emz)
TODO: add reference
Args: Args:
E: E-field of the mode E: E-field of the mode
H: H-field of the mode (advanced by half of a Yee cell from E)
wavenumber: Wavenumber of the mode wavenumber: Wavenumber of the mode
omega: Angular frequency of the simulation
dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types` dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types`
axis: Propagation axis (0=x, 1=y, 2=z) axis: Propagation axis (0=x, 1=y, 2=z)
polarity: Propagation direction (+1 for +ve, -1 for -ve) polarity: Propagation direction (+1 for +ve, -1 for -ve)
slices: `epsilon[tuple(slices)]` is used to select the portion of the grid to use slices: `epsilon[tuple(slices)]` is used to select the portion of the grid to use
as the waveguide cross-section. slices[axis] should select only one item. as the waveguide cross-section. slices[axis] should select only one item.
mu: Magnetic permeability (default 1 everywhere)
Returns: Returns:
`overlap_e` normalized so that `numpy.sum(overlap_e * E.conj()) == 1` overlap_e such that `numpy.sum(overlap_e * other_e.conj())` computes the overlap integral
over the retained overlap window.
""" """
slices = tuple(slices) slices = tuple(slices)
Ee = expand_e(E=E, wavenumber=wavenumber, dxes=dxes, Ee = expand_e(E=E, wavenumber=wavenumber, dxes=dxes,
axis=axis, polarity=polarity, slices=slices) axis=axis, polarity=polarity, slices=slices)
axis_size = E.shape[axis + 1] start, stop = sorted((slices[axis].start, slices[axis].start - 2 * polarity))
if polarity > 0:
start = slices[axis].start - 2
stop = slices[axis].start
else:
start = slices[axis].stop
stop = slices[axis].stop + 2
clipped_start = max(0, start)
clipped_stop = min(axis_size, stop)
if clipped_start >= clipped_stop:
raise ValueError('Requested overlap window lies outside the domain')
if clipped_start != start or clipped_stop != stop:
warnings.warn('Requested overlap window was clipped to fit within the domain', RuntimeWarning, stacklevel=2)
slices2_l = list(slices) slices2_l = list(slices)
slices2_l[axis] = slice(clipped_start, clipped_stop) slices2_l[axis] = slice(start, stop)
slices2 = (slice(None), *slices2_l) slices2 = (slice(None), *slices2_l)
Etgt = numpy.zeros_like(Ee) Etgt = numpy.zeros_like(Ee)
Etgt[slices2] = Ee[slices2] Etgt[slices2] = Ee[slices2]
# Note: We normalize so that (Etgt @ E.conj()) == 1, so (Etgt @ Etgt.conj) != 1 Etgt /= (Etgt.conj() * Etgt).sum()
norm = (Etgt.conj() * Etgt).sum() return Etgt
if norm == 0:
raise ValueError('Requested overlap window contains no overlap field support')
Etgt = Etgt / norm
return cfdfield_t(Etgt)
def expand_e( def expand_e(
E: cfdfield, E: cfdfield_t,
wavenumber: complex, wavenumber: complex,
dxes: dx_lists_t, dxes: dx_lists_t,
axis: int, axis: int,
polarity: int, polarity: int,
slices: Sequence[slice], slices: Sequence[slice],
) -> cfdfield_t: ) -> cfdfield_t:
r""" """
Given an eigenmode obtained by `solve_mode`, expands the E-field from the 2D Given an eigenmode obtained by `solve_mode`, expands the E-field from the 2D
slice where the mode was calculated to the entire domain (along the propagation slice where the mode was calculated to the entire domain (along the propagation
axis). This assumes the epsilon cross-section remains constant throughout the axis). This assumes the epsilon cross-section remains constant throughout the
@ -310,16 +225,6 @@ def expand_e(
Returns: Returns:
`E`, with the original field expanded along the specified `axis`. `E`, with the original field expanded along the specified `axis`.
Notes:
This helper assumes that the waveguide cross-section remains constant
along the propagation axis and applies the phase factor
$$
e^{-i \, \mathrm{polarity} \, wavenumber \, \Delta z}
$$
to each copied slice.
""" """
slices = tuple(slices) slices = tuple(slices)
@ -340,4 +245,4 @@ def expand_e(
slices_in = (slice(None), *slices) slices_in = (slice(None), *slices)
E_expanded[slices_exp] = phase_E * numpy.array(E)[slices_in] E_expanded[slices_exp] = phase_E * numpy.array(E)[slices_in]
return cfdfield_t(E_expanded) return E_expanded

View file

@ -1,181 +1,46 @@
r""" """
Operators and helper functions for cylindrical waveguides with unchanging cross-section. Operators and helper functions for cylindrical waveguides with unchanging cross-section.
Waveguide operator is derived according to 10.1364/OL.33.001848. WORK IN PROGRESS, CURRENTLY BROKEN
As in `waveguide_2d`, the propagation dependence is separated from the As the z-dependence is known, all the functions in this file assume a 2D grid
transverse solve. Here the propagation coordinate is the bend angle `\theta`, (i.e. `dxes = [[[dr_e_0, dx_e_1, ...], [dy_e_0, ...]], [[dr_h_0, ...], [dy_h_0, ...]]]`).
and the fields are assumed to have the form
$$
\vec{E}(r, y, \theta), \vec{H}(r, y, \theta) \propto e^{-\imath m \theta},
$$
where `m` is the angular wavenumber returned by `solve_mode(s)`. It is often
convenient to introduce the corresponding linear wavenumber
$$
\beta = \frac{m}{r_{\min}},
$$
so that the cylindrical problem resembles the straight-waveguide problem with
additional metric factors.
Those metric factors live on the staggered radial Yee grids. If the left edge of
the computational window is at `r = r_{\min}`, define the electric-grid and
magnetic-grid radial sample locations by
$$
\begin{aligned}
r_a(n) &= r_{\min} + \sum_{j \le n} \Delta r_{e, j}, \\
r_b\!\left(n + \tfrac{1}{2}\right) &= r_{\min} + \tfrac{1}{2}\Delta r_{e, n}
+ \sum_{j < n} \Delta r_{h, j},
\end{aligned}
$$
and from them the diagonal metric matrices
$$
\begin{aligned}
T_a &= \operatorname{diag}(r_a / r_{\min}), \\
T_b &= \operatorname{diag}(r_b / r_{\min}).
\end{aligned}
$$
With the same forward/backward derivative notation used in `waveguide_2d`, the
coordinate-transformed discrete curl equations used here are
$$
\begin{aligned}
-\imath \omega \mu_{rr} H_r &= \tilde{\partial}_y E_z + \imath \beta T_a^{-1} E_y, \\
-\imath \omega \mu_{yy} H_y &= -\imath \beta T_b^{-1} E_r
- T_b^{-1} \tilde{\partial}_r (T_a E_z), \\
-\imath \omega \mu_{zz} H_z &= \tilde{\partial}_r E_y - \tilde{\partial}_y E_r, \\
\imath \beta H_y &= -\imath \omega T_b \epsilon_{rr} E_r - T_b \hat{\partial}_y H_z, \\
\imath \beta H_r &= \imath \omega T_a \epsilon_{yy} E_y
- T_b T_a^{-1} \hat{\partial}_r (T_b H_z), \\
\imath \omega E_z &= T_a \epsilon_{zz}^{-1}
\left(\hat{\partial}_r H_y - \hat{\partial}_y H_r\right).
\end{aligned}
$$
The first three equations are the cylindrical analogue of the straight-guide
relations for `H_r`, `H_y`, and `H_z`. The next two are the metric-weighted
versions of the straight-guide identities for `\imath \beta H_y` and
`\imath \beta H_r`, and the last equation plays the same role as the
longitudinal `E_z` reconstruction in `waveguide_2d`.
Following the same elimination steps as in `waveguide_2d`, apply
`\imath \beta \tilde{\partial}_r` and `\imath \beta \tilde{\partial}_y` to the
equation for `E_z`, substitute for `\imath \beta H_r` and `\imath \beta H_y`,
and then eliminate `H_z` with
$$
H_z = \frac{1}{-\imath \omega \mu_{zz}}
\left(\tilde{\partial}_r E_y - \tilde{\partial}_y E_r\right).
$$
This yields the transverse electric eigenproblem implemented by
`cylindrical_operator(...)`:
$$
\beta^2
\begin{bmatrix} E_r \\ E_y \end{bmatrix}
=
\left(
\omega^2
\begin{bmatrix}
T_b^2 \mu_{yy} \epsilon_{xx} & 0 \\
0 & T_a^2 \mu_{xx} \epsilon_{yy}
\end{bmatrix}
+
\begin{bmatrix}
-T_b \mu_{yy} \hat{\partial}_y \\
T_a \mu_{xx} \hat{\partial}_x
\end{bmatrix}
T_b \mu_{zz}^{-1}
\begin{bmatrix}
-\tilde{\partial}_y & \tilde{\partial}_x
\end{bmatrix}
+
\begin{bmatrix}
\tilde{\partial}_x \\
\tilde{\partial}_y
\end{bmatrix}
T_a \epsilon_{zz}^{-1}
\begin{bmatrix}
\hat{\partial}_x T_b \epsilon_{xx} &
\hat{\partial}_y T_a \epsilon_{yy}
\end{bmatrix}
\right)
\begin{bmatrix} E_r \\ E_y \end{bmatrix}.
$$
Since `\beta = m / r_{\min}`, the solver implemented in this file returns the
angular wavenumber `m`, while the operator itself is most naturally written in
terms of the linear quantity `\beta`. The helpers below reconstruct the full
field components from the solved transverse eigenvector and then normalize the
mode to unit forward power with the same discrete longitudinal Poynting inner
product used by `waveguide_2d`.
As in the straight-waveguide case, all functions here assume a 2D grid:
`dxes = [[[dr_e_0, dr_e_1, ...], [dy_e_0, ...]], [[dr_h_0, ...], [dy_h_0, ...]]]`.
""" """
from typing import Any, cast # TODO update module docs
from collections.abc import Sequence
import logging
import numpy import numpy
from numpy.typing import NDArray, ArrayLike import scipy.sparse as sparse # type: ignore
from scipy import sparse
from ..fdmath import vec, unvec, dx_lists2_t, vcfdslice_t, vfdslice, vcfdslice, vcfdfield2 from ..fdmath import vec, unvec, dx_lists_t, fdfield_t, vfdfield_t, cfdfield_t
from ..fdmath.operators import deriv_forward, deriv_back from ..fdmath.operators import deriv_forward, deriv_back
from ..eigensolvers import signed_eigensolve, rayleigh_quotient_iteration from ..eigensolvers import signed_eigensolve, rayleigh_quotient_iteration
from . import waveguide_2d
logger = logging.getLogger(__name__)
def cylindrical_operator( def cylindrical_operator(
omega: float, omega: complex,
dxes: dx_lists2_t, dxes: dx_lists_t,
epsilon: vfdslice, epsilon: vfdfield_t,
rmin: float, r0: float,
) -> sparse.sparray: ) -> sparse.spmatrix:
r""" """
Cylindrical coordinate waveguide operator of the form Cylindrical coordinate waveguide operator of the form
$$ TODO
(\omega^2 \begin{bmatrix} T_b T_b \mu_{yy} \epsilon_{xx} & 0 \\
0 & T_a T_a \mu_{xx} \epsilon_{yy} \end{bmatrix} +
\begin{bmatrix} -T_b \mu_{yy} \hat{\partial}_y \\
T_a \mu_{xx} \hat{\partial}_x \end{bmatrix} T_b \mu_{zz}^{-1}
\begin{bmatrix} -\tilde{\partial}_y & \tilde{\partial}_x \end{bmatrix} +
\begin{bmatrix} \tilde{\partial}_x \\
\tilde{\partial}_y \end{bmatrix} T_a \epsilon_{zz}^{-1}
\begin{bmatrix} \hat{\partial}_x T_b \epsilon_{xx} & \hat{\partial}_y T_a \epsilon_{yy} \end{bmatrix})
\begin{bmatrix} E_r \\
E_y \end{bmatrix}
$$
for use with a field vector of the form `[E_r, E_y]`. for use with a field vector of the form `[E_r, E_y]`.
This operator can be used to form an eigenvalue problem of the form This operator can be used to form an eigenvalue problem of the form
A @ [E_r, E_y] = beta**2 * [E_r, E_y] A @ [E_r, E_y] = wavenumber**2 * [E_r, E_y]
which can then be solved for the eigenmodes of the system which can then be solved for the eigenmodes of the system
(an `exp(-i * angular_wavenumber * theta)` theta-dependence is assumed for (an `exp(-i * wavenumber * theta)` theta-dependence is assumed for the fields).
the fields, with `beta = angular_wavenumber / rmin`).
(NOTE: See module docs and 10.1364/OL.33.001848)
Args: Args:
omega: The angular frequency of the system omega: The angular frequency of the system
dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types` (2D) dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types` (2D)
epsilon: Vectorized dielectric constant grid epsilon: Vectorized dielectric constant grid
rmin: Radius at the left edge of the simulation domain (at minimum 'x') r0: Radius of curvature for the simulation. This should be the minimum value of
r within the simulation domain.
Returns: Returns:
Sparse matrix representation of the operator Sparse matrix representation of the operator
@ -184,409 +49,95 @@ def cylindrical_operator(
Dfx, Dfy = deriv_forward(dxes[0]) Dfx, Dfy = deriv_forward(dxes[0])
Dbx, Dby = deriv_back(dxes[1]) Dbx, Dby = deriv_back(dxes[1])
Ta, Tb = dxes2T(dxes=dxes, rmin=rmin) rx = r0 + numpy.cumsum(dxes[0][0])
ry = r0 + dxes[0][0] / 2.0 + numpy.cumsum(dxes[1][0])
tx = rx / r0
ty = ry / r0
Tx = sparse.diags(vec(tx[:, None].repeat(dxes[0][1].size, axis=1)))
Ty = sparse.diags(vec(ty[:, None].repeat(dxes[1][1].size, axis=1)))
eps_parts = numpy.split(epsilon, 3) eps_parts = numpy.split(epsilon, 3)
eps_x = sparse.diags_array(eps_parts[0]) eps_x = sparse.diags(eps_parts[0])
eps_y = sparse.diags_array(eps_parts[1]) eps_y = sparse.diags(eps_parts[1])
eps_z_inv = sparse.diags_array(1 / eps_parts[2]) eps_z_inv = sparse.diags(1 / eps_parts[2])
pa = sparse.vstack((Dfx, Dfy)) @ Tx @ eps_z_inv @ sparse.hstack((Dbx, Dby))
pb = sparse.vstack((Dfx, Dfy)) @ Tx @ eps_z_inv @ sparse.hstack((Dby, Dbx))
a0 = Ty @ eps_x + omega**-2 * Dby @ Ty @ Dfy
a1 = Tx @ eps_y + omega**-2 * Dbx @ Ty @ Dfx
b0 = Dbx @ Ty @ Dfy
b1 = Dby @ Ty @ Dfx
omega2 = omega * omega
diag = sparse.block_diag diag = sparse.block_diag
sq0 = omega2 * diag((Tb @ Tb @ eps_x, omega2 = omega * omega
Ta @ Ta @ eps_y))
lin0 = sparse.vstack((-Tb @ Dby, Ta @ Dbx)) @ Tb @ sparse.hstack((-Dfy, Dfx)) op = (omega2 * diag((Tx, Ty)) + pa) @ diag((a0, a1)) + \
lin1 = sparse.vstack((Dfx, Dfy)) @ Ta @ eps_z_inv @ sparse.hstack((Dbx @ Tb @ eps_x, - (sparse.bmat(((None, Ty), (Tx, None))) + pb / omega2) @ diag((b0, b1))
Dby @ Ta @ eps_y))
op = sq0 + lin0 + lin1
return op return op
def solve_modes(
mode_numbers: Sequence[int],
omega: float,
dxes: dx_lists2_t,
epsilon: vfdslice,
rmin: float,
mode_margin: int = 2,
) -> tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]:
"""
Given a 2d (r, y) slice of epsilon, attempts to solve for the eigenmode
of the bent waveguide with the specified mode number.
Args:
mode_numbers: Mode numbers to solve, 0-indexed.
omega: Angular frequency of the simulation
dxes: Grid parameters [dx_e, dx_h] as described in meanas.fdmath.types.
The first coordinate is assumed to be r, the second is y.
epsilon: Dielectric constant
rmin: Radius of curvature for the simulation. This should be the minimum value of
r within the simulation domain.
Returns:
e_xys: NDArray of vfdfield_t specifying fields. First dimension is mode number.
angular_wavenumbers: list of wavenumbers in 1/rad units.
"""
#
# Solve for the largest-magnitude eigenvalue of the real operator
#
dxes_real = [[numpy.real(dx) for dx in dxi] for dxi in dxes]
A_r = cylindrical_operator(numpy.real(omega), dxes_real, numpy.real(epsilon), rmin=rmin)
eigvals, eigvecs = signed_eigensolve(A_r, max(mode_numbers) + mode_margin)
keep_inds = -(numpy.array(mode_numbers) + 1)
e_xys = eigvecs[:, keep_inds].T
eigvals = eigvals[keep_inds]
#
# Now solve for the eigenvector of the full operator, using the real operator's
# eigenvector as an initial guess for Rayleigh quotient iteration.
#
A = cylindrical_operator(omega, dxes, epsilon, rmin=rmin)
for nn in range(len(mode_numbers)):
eigvals[nn], e_xys[nn, :] = rayleigh_quotient_iteration(A, e_xys[nn, :])
# Calculate the wave-vector (force the real part to be positive)
wavenumbers = numpy.sqrt(eigvals)
wavenumbers *= numpy.sign(numpy.real(wavenumbers))
# Wavenumbers assume the mode is at rmin, which is unlikely
# Instead, return the wavenumber in inverse radians
angular_wavenumbers = wavenumbers * cast('complex', rmin)
order = angular_wavenumbers.argsort()[::-1]
e_xys = e_xys[order]
angular_wavenumbers = angular_wavenumbers[order]
return e_xys, angular_wavenumbers
def solve_mode( def solve_mode(
mode_number: int, mode_number: int,
*args: Any, omega: complex,
**kwargs: Any, dxes: dx_lists_t,
) -> tuple[vcfdfield2, complex]: epsilon: vfdfield_t,
r0: float,
) -> dict[str, complex | cfdfield_t]:
""" """
Wrapper around `solve_modes()` that solves for a single mode. TODO: fixup
Given a 2d (r, y) slice of epsilon, attempts to solve for the eigenmode
of the bent waveguide with the specified mode number.
Args: Args:
mode_number: 0-indexed mode number to solve for mode_number: Number of the mode, 0-indexed
*args: passed to `solve_modes()` omega: Angular frequency of the simulation
**kwargs: passed to `solve_modes()` dxes: Grid parameters [dx_e, dx_h] as described in meanas.fdmath.types.
The first coordinate is assumed to be r, the second is y.
epsilon: Dielectric constant
r0: Radius of curvature for the simulation. This should be the minimum value of
r within the simulation domain.
Returns: Returns:
(e_xy, angular_wavenumber) ```
{
'E': list[NDArray[numpy.complex_]],
'H': list[NDArray[numpy.complex_]],
'wavenumber': complex,
}
```
""" """
kwargs['mode_numbers'] = [mode_number]
e_xys, angular_wavenumbers = solve_modes(*args, **kwargs)
return e_xys[0], angular_wavenumbers[0]
'''
Solve for the largest-magnitude eigenvalue of the real operator
'''
dxes_real = [[numpy.real(dx) for dx in dxi] for dxi in dxes]
def linear_wavenumbers( A_r = cylindrical_operator(numpy.real(omega), dxes_real, numpy.real(epsilon), r0)
e_xys: Sequence[vcfdfield2] | NDArray[numpy.complex128], eigvals, eigvecs = signed_eigensolve(A_r, mode_number + 3)
angular_wavenumbers: ArrayLike, e_xy = eigvecs[:, -(mode_number + 1)]
epsilon: vfdslice,
dxes: dx_lists2_t,
rmin: float,
) -> NDArray[numpy.complex128]:
"""
Calculate linear wavenumbers (1/distance) based on angular wavenumbers (1/rad)
and the mode's energy distribution.
Args: '''
e_xys: Vectorized mode fields with shape (num_modes, 2 * x *y) Now solve for the eigenvector of the full operator, using the real operator's
angular_wavenumbers: Wavenumbers assuming fields have theta-dependence of eigenvector as an initial guess for Rayleigh quotient iteration.
`exp(-i * angular_wavenumber * theta)`. They should satisfy '''
`operator_e() @ e_xy == (angular_wavenumber / rmin) ** 2 * e_xy` A = cylindrical_operator(omega, dxes, epsilon, r0)
epsilon: Vectorized dielectric constant grid with shape (3, x, y) eigval, e_xy = rayleigh_quotient_iteration(A, e_xy)
dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types` (2D)
rmin: Radius at the left edge of the simulation domain (at minimum 'x')
Returns: # Calculate the wave-vector (force the real part to be positive)
NDArray containing the calculated linear (1/distance) wavenumbers wavenumber = numpy.sqrt(eigval)
""" wavenumber *= numpy.sign(numpy.real(wavenumber))
angular_wavenumbers = numpy.asarray(angular_wavenumbers)
mode_radii = numpy.empty_like(angular_wavenumbers, dtype=float)
shape2d = (len(dxes[0][0]), len(dxes[0][1])) # TODO: Perform correction on wavenumber to account for numerical dispersion.
epsilon2d = unvec(epsilon, shape2d)[:2]
grid_radii = rmin + numpy.cumsum(dxes[0][0])
for ii in range(angular_wavenumbers.size):
efield = unvec(e_xys[ii], shape2d, 2)
energy = numpy.real((efield * efield.conj()) * epsilon2d)
energy_vs_x = energy.sum(axis=(0, 2))
mode_radii[ii] = (grid_radii * energy_vs_x).sum() / energy_vs_x.sum()
logger.info(f'{mode_radii=}') shape = [d.size for d in dxes[0]]
lin_wavenumbers = angular_wavenumbers / mode_radii e_xy = numpy.hstack((e_xy, numpy.zeros(shape[0] * shape[1])))
return lin_wavenumbers fields = {
'wavenumber': wavenumber,
'E': unvec(e_xy, shape),
# 'E': unvec(e, shape),
# 'H': unvec(h, shape),
}
return fields
def exy2h(
angular_wavenumber: complex,
omega: float,
dxes: dx_lists2_t,
rmin: float,
epsilon: vfdslice,
mu: vfdslice | None = None
) -> sparse.sparray:
"""
Operator which transforms the vector `e_xy` containing the vectorized E_r and E_y fields,
into a vectorized H containing all three H components
Args:
angular_wavenumber: Wavenumber assuming fields have theta-dependence of
`exp(-i * angular_wavenumber * theta)`. It should satisfy
`operator_e() @ e_xy == (angular_wavenumber / rmin) ** 2 * e_xy`
omega: The angular frequency of the system
dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types` (2D)
rmin: Radius at the left edge of the simulation domain (at minimum 'x')
epsilon: Vectorized dielectric constant grid
mu: Vectorized magnetic permeability grid (default 1 everywhere)
Returns:
Sparse matrix representing the operator.
"""
e2hop = e2h(angular_wavenumber=angular_wavenumber, omega=omega, dxes=dxes, rmin=rmin, mu=mu)
return e2hop @ exy2e(angular_wavenumber=angular_wavenumber, omega=omega, dxes=dxes, rmin=rmin, epsilon=epsilon)
def exy2e(
angular_wavenumber: complex,
omega: float,
dxes: dx_lists2_t,
rmin: float,
epsilon: vfdslice,
) -> sparse.sparray:
"""
Operator which transforms the vector `e_xy` containing the vectorized E_r and E_y fields,
into a vectorized E containing all three E components
Unlike the straight waveguide case, the H_z components do not cancel and must be calculated
from E_r and E_y in order to then calculate E_z.
Args:
angular_wavenumber: Wavenumber assuming fields have theta-dependence of
`exp(-i * angular_wavenumber * theta)`. It should satisfy
`operator_e() @ e_xy == (angular_wavenumber / rmin) ** 2 * e_xy`
omega: The angular frequency of the system
dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types` (2D)
rmin: Radius at the left edge of the simulation domain (at minimum 'x')
epsilon: Vectorized dielectric constant grid
Returns:
Sparse matrix representing the operator.
"""
Dfx, Dfy = deriv_forward(dxes[0])
Dbx, Dby = deriv_back(dxes[1])
wavenumber = angular_wavenumber / rmin
Ta, Tb = dxes2T(dxes=dxes, rmin=rmin)
Tai = sparse.diags_array(1 / Ta.diagonal())
#Tbi = sparse.diags_array(1 / Tb.diagonal())
epsilon_parts = numpy.split(epsilon, 3)
epsilon_x, epsilon_y = (sparse.diags_array(epsi) for epsi in epsilon_parts[:2])
epsilon_z_inv = sparse.diags_array(1 / epsilon_parts[2])
n_pts = dxes[0][0].size * dxes[0][1].size
zeros = sparse.coo_array((n_pts, n_pts))
mu_z = numpy.ones(n_pts)
mu_z_inv = sparse.diags_array(1 / mu_z)
exy2hz = 1 / (-1j * omega) * mu_z_inv @ sparse.hstack((Dfy, -Dfx))
hxy2ez = 1 / (1j * omega) * epsilon_z_inv @ sparse.hstack((Dby, -Dbx))
exy2hy = Tb / (1j * wavenumber) @ (-1j * omega * sparse.hstack((epsilon_x, zeros)) - Dby @ exy2hz)
exy2hx = Tb / (1j * wavenumber) @ ( 1j * omega * sparse.hstack((zeros, epsilon_y)) - Tai @ Dbx @ Tb @ exy2hz)
exy2ez = hxy2ez @ sparse.vstack((exy2hx, exy2hy))
op = sparse.vstack((sparse.eye_array(2 * n_pts),
exy2ez))
return op
def e2h(
angular_wavenumber: complex,
omega: float,
dxes: dx_lists2_t,
rmin: float,
mu: vfdslice | None = None
) -> sparse.sparray:
r"""
Returns an operator which, when applied to a vectorized E eigenfield, produces
the vectorized H eigenfield.
This operator is created directly from the initial coordinate-transformed equations:
$$
\begin{aligned}
-\imath \omega \mu_{rr} H_r &= \tilde{\partial}_y E_z + \imath \beta T_a^{-1} E_y, \\
-\imath \omega \mu_{yy} H_y &= -\imath \beta T_b^{-1} E_r
- T_b^{-1} \tilde{\partial}_r (T_a E_z), \\
-\imath \omega \mu_{zz} H_z &= \tilde{\partial}_r E_y - \tilde{\partial}_y E_r,
\end{aligned}
$$
Args:
angular_wavenumber: Wavenumber assuming fields have theta-dependence of
`exp(-i * angular_wavenumber * theta)`. It should satisfy
`operator_e() @ e_xy == (angular_wavenumber / rmin) ** 2 * e_xy`
omega: The angular frequency of the system
dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types` (2D)
rmin: Radius at the left edge of the simulation domain (at minimum 'x')
mu: Vectorized magnetic permeability grid (default 1 everywhere)
Returns:
Sparse matrix representation of the operator.
"""
Dfx, Dfy = deriv_forward(dxes[0])
Ta, Tb = dxes2T(dxes=dxes, rmin=rmin)
Tai = sparse.diags_array(1 / Ta.diagonal())
Tbi = sparse.diags_array(1 / Tb.diagonal())
jB = 1j * angular_wavenumber / rmin
op = sparse.block_array([[ None, -jB * Tai, -Dfy],
[jB * Tbi, None, Tbi @ Dfx @ Ta],
[ Dfy, -Dfx, None]]) / (-1j * omega)
if mu is not None:
op = sparse.diags_array(1 / mu) @ op
return op
def dxes2T(
dxes: dx_lists2_t,
rmin: float,
) -> tuple[NDArray[numpy.float64], NDArray[numpy.float64]]:
r"""
Construct the cylindrical metric matrices $T_a$ and $T_b$.
`T_a` is sampled on the E-grid radial locations, while `T_b` is sampled on
the staggered H-grid radial locations. These are the diagonal matrices that
convert the straight-waveguide algebra into its cylindrical counterpart.
Args:
dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types` (2D)
rmin: Radius at the left edge of the simulation domain (at minimum 'x')
Returns:
Sparse diagonal matrices `(T_a, T_b)`.
"""
ra = rmin + numpy.cumsum(dxes[0][0]) # Radius at Ey points
rb = rmin + dxes[0][0] / 2.0 + numpy.cumsum(dxes[1][0]) # Radius at Ex points
ta = ra / rmin
tb = rb / rmin
Ta = sparse.diags_array(vec(ta[:, None].repeat(dxes[0][1].size, axis=1)))
Tb = sparse.diags_array(vec(tb[:, None].repeat(dxes[1][1].size, axis=1)))
return Ta, Tb
def normalized_fields_e(
e_xy: vcfdfield2,
angular_wavenumber: complex,
omega: float,
dxes: dx_lists2_t,
rmin: float,
epsilon: vfdslice,
mu: vfdslice | None = None,
prop_phase: float = 0,
) -> tuple[vcfdslice_t, vcfdslice_t]:
r"""
Given a vector `e_xy` containing the vectorized E_r and E_y fields,
returns normalized, vectorized E and H fields for the system.
Args:
e_xy: Vector containing E_r and E_y fields
angular_wavenumber: Wavenumber assuming fields have theta-dependence of
`exp(-i * angular_wavenumber * theta)`. It should satisfy
`operator_e() @ e_xy == (angular_wavenumber / rmin) ** 2 * e_xy`
omega: The angular frequency of the system
dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types` (2D)
rmin: Radius at the left edge of the simulation domain (at minimum 'x')
epsilon: Vectorized dielectric constant grid
mu: Vectorized magnetic permeability grid (default 1 everywhere)
prop_phase: Phase shift `(dz * corrected_wavenumber)` over 1 cell in propagation direction.
Default 0 (continuous propagation direction, i.e. dz->0).
Returns:
`(e, h)`, where each field is vectorized, normalized,
and contains all three vector components.
Notes:
The normalization step is delegated to `_normalized_fields(...)`, which
enforces unit forward power under the discrete inner product
$$
\frac{1}{2}\int (E_r H_y^* - E_y H_r^*) \, dr \, dy.
$$
The angular wavenumber `m` is first converted into the full three-component
fields, then the overall complex phase and sign are fixed so the result is
reproducible for symmetric modes.
"""
e = exy2e(angular_wavenumber=angular_wavenumber, omega=omega, dxes=dxes, rmin=rmin, epsilon=epsilon) @ e_xy
h = exy2h(angular_wavenumber=angular_wavenumber, omega=omega, dxes=dxes, rmin=rmin, epsilon=epsilon, mu=mu) @ e_xy
e_norm, h_norm = _normalized_fields(
e=e, h=h, dxes=dxes, epsilon=epsilon, prop_phase=prop_phase,
)
return e_norm, h_norm
def _normalized_fields(
e: vcfdslice,
h: vcfdslice,
dxes: dx_lists2_t,
epsilon: vfdslice,
prop_phase: float = 0,
) -> tuple[vcfdslice_t, vcfdslice_t]:
r"""
Normalize a cylindrical waveguide mode to unit forward power.
The cylindrical helpers reuse the straight-waveguide inner product after the
field reconstruction step. The extra metric factors have already been folded
into the reconstructed `e`/`h` fields through `dxes2T(...)` and the
cylindrical `exy2e(...)` / `exy2h(...)` operators, so the same discrete
longitudinal Poynting integral can be used here.
The normalization procedure is:
1. Flip the magnetic field sign so the reconstructed `(e, h)` pair follows
the same forward-power convention as `waveguide_2d`.
2. Compute the time-averaged forward power with
`waveguide_2d.inner_product(..., conj_h=True)`.
3. Scale by `1 / sqrt(S_z)` so the resulting mode has unit forward power.
4. Remove the arbitrary complex phase and apply a quadrant-sum sign heuristic
so symmetric modes choose a stable representative.
`prop_phase` has the same meaning as in `waveguide_2d`: it compensates for
the half-cell longitudinal staggering between the E and H fields when the
propagation direction is itself discretized.
"""
h *= -1
shape = [s.size for s in dxes[0]]
# Find time-averaged Sz and normalize to it
# H phase is adjusted by a half-cell forward shift for Yee cell, and 1-cell reverse shift for Poynting
Sz_tavg = waveguide_2d.inner_product(e, h, dxes=dxes, prop_phase=prop_phase, conj_h=True).real # Note, using linear poynting vector
assert Sz_tavg > 0, f'Found a mode propagating in the wrong direction! {Sz_tavg=}'
energy = numpy.real(epsilon * e.conj() * e)
norm_amplitude = 1 / numpy.sqrt(Sz_tavg)
norm_angle = -numpy.angle(e[energy.argmax()]) # Will randomly add a negative sign when mode is symmetric
# Try to break symmetry to assign a consistent sign [experimental]
E_weighted = unvec(e * energy * numpy.exp(1j * norm_angle), shape)
sign = numpy.sign(E_weighted[:,
:max(shape[0] // 2, 1),
:max(shape[1] // 2, 1)].real.sum())
assert sign != 0
norm_factor = sign * norm_amplitude * numpy.exp(1j * norm_angle)
e *= norm_factor
h *= norm_factor
return vcfdslice_t(e), vcfdslice_t(h)

View file

@ -1,4 +1,4 @@
r""" """
Basic discrete calculus for finite difference (fd) simulations. Basic discrete calculus for finite difference (fd) simulations.
@ -43,11 +43,11 @@ Scalar derivatives and cell shifts
---------------------------------- ----------------------------------
Define the discrete forward derivative as Define the discrete forward derivative as
$$ [\tilde{\partial}_x f]_{m + \frac{1}{2}} = \frac{1}{\Delta_{x, m}} (f_{m + 1} - f_m) $$ $$ [\\tilde{\\partial}_x f]_{m + \\frac{1}{2}} = \\frac{1}{\\Delta_{x, m}} (f_{m + 1} - f_m) $$
where $f$ is a function defined at discrete locations on the x-axis (labeled using $m$). where $f$ is a function defined at discrete locations on the x-axis (labeled using $m$).
The value at $m$ occupies a length $\Delta_{x, m}$ along the x-axis. Note that $m$ The value at $m$ occupies a length $\\Delta_{x, m}$ along the x-axis. Note that $m$
is an index along the x-axis, _not_ necessarily an x-coordinate, since each length is an index along the x-axis, _not_ necessarily an x-coordinate, since each length
$\Delta_{x, m}, \Delta_{x, m+1}, ...$ is independently chosen. $\\Delta_{x, m}, \\Delta_{x, m+1}, ...$ is independently chosen.
If we treat `f` as a 1D array of values, with the `i`-th value `f[i]` taking up a length `dx[i]` If we treat `f` as a 1D array of values, with the `i`-th value `f[i]` taking up a length `dx[i]`
along the x-axis, the forward derivative is along the x-axis, the forward derivative is
@ -56,13 +56,13 @@ along the x-axis, the forward derivative is
Likewise, discrete reverse derivative is Likewise, discrete reverse derivative is
$$ [\hat{\partial}_x f ]_{m - \frac{1}{2}} = \frac{1}{\Delta_{x, m}} (f_{m} - f_{m - 1}) $$ $$ [\\hat{\\partial}_x f ]_{m - \\frac{1}{2}} = \\frac{1}{\\Delta_{x, m}} (f_{m} - f_{m - 1}) $$
or or
deriv_back(f)[i] = (f[i] - f[i - 1]) / dx[i] deriv_back(f)[i] = (f[i] - f[i - 1]) / dx[i]
The derivatives' values are shifted by a half-cell relative to the original function, and The derivatives' values are shifted by a half-cell relative to the original function, and
will have different cell widths if all the `dx[i]` ( $\Delta_{x, m}$ ) are not will have different cell widths if all the `dx[i]` ( $\\Delta_{x, m}$ ) are not
identical: identical:
[figure: derivatives and cell sizes] [figure: derivatives and cell sizes]
@ -88,19 +88,19 @@ identical:
In the above figure, In the above figure,
`f0 =` $f_0$, `f1 =` $f_1$ `f0 =` $f_0$, `f1 =` $f_1$
`Df0 =` $[\tilde{\partial}f]_{0 + \frac{1}{2}}$ `Df0 =` $[\\tilde{\\partial}f]_{0 + \\frac{1}{2}}$
`Df1 =` $[\tilde{\partial}f]_{1 + \frac{1}{2}}$ `Df1 =` $[\\tilde{\\partial}f]_{1 + \\frac{1}{2}}$
`df0 =` $[\hat{\partial}f]_{0 - \frac{1}{2}}$ `df0 =` $[\\hat{\\partial}f]_{0 - \\frac{1}{2}}$
etc. etc.
The fractional subscript $m + \frac{1}{2}$ is used to indicate values defined The fractional subscript $m + \\frac{1}{2}$ is used to indicate values defined
at shifted locations relative to the original $m$, with corresponding lengths at shifted locations relative to the original $m$, with corresponding lengths
$$ \Delta_{x, m + \frac{1}{2}} = \frac{1}{2} * (\Delta_{x, m} + \Delta_{x, m + 1}) $$ $$ \\Delta_{x, m + \\frac{1}{2}} = \\frac{1}{2} * (\\Delta_{x, m} + \\Delta_{x, m + 1}) $$
Just as $m$ is not itself an x-coordinate, neither is $m + \frac{1}{2}$; Just as $m$ is not itself an x-coordinate, neither is $m + \\frac{1}{2}$;
carefully note the positions of the various cells in the above figure vs their labels. carefully note the positions of the various cells in the above figure vs their labels.
If the positions labeled with $m$ are considered the "base" or "original" grid, If the positions labeled with $m$ are considered the "base" or "original" grid,
the positions labeled with $m + \frac{1}{2}$ are said to lie on a "dual" or the positions labeled with $m + \\frac{1}{2}$ are said to lie on a "dual" or
"derived" grid. "derived" grid.
For the remainder of the `Discrete calculus` section, all figures will show For the remainder of the `Discrete calculus` section, all figures will show
@ -113,12 +113,12 @@ Gradients and fore-vectors
-------------------------- --------------------------
Expanding to three dimensions, we can define two gradients Expanding to three dimensions, we can define two gradients
$$ [\tilde{\nabla} f]_{m,n,p} = \vec{x} [\tilde{\partial}_x f]_{m + \frac{1}{2},n,p} + $$ [\\tilde{\\nabla} f]_{m,n,p} = \\vec{x} [\\tilde{\\partial}_x f]_{m + \\frac{1}{2},n,p} +
\vec{y} [\tilde{\partial}_y f]_{m,n + \frac{1}{2},p} + \\vec{y} [\\tilde{\\partial}_y f]_{m,n + \\frac{1}{2},p} +
\vec{z} [\tilde{\partial}_z f]_{m,n,p + \frac{1}{2}} $$ \\vec{z} [\\tilde{\\partial}_z f]_{m,n,p + \\frac{1}{2}} $$
$$ [\hat{\nabla} f]_{m,n,p} = \vec{x} [\hat{\partial}_x f]_{m + \frac{1}{2},n,p} + $$ [\\hat{\\nabla} f]_{m,n,p} = \\vec{x} [\\hat{\\partial}_x f]_{m + \\frac{1}{2},n,p} +
\vec{y} [\hat{\partial}_y f]_{m,n + \frac{1}{2},p} + \\vec{y} [\\hat{\\partial}_y f]_{m,n + \\frac{1}{2},p} +
\vec{z} [\hat{\partial}_z f]_{m,n,p + \frac{1}{2}} $$ \\vec{z} [\\hat{\\partial}_z f]_{m,n,p + \\frac{1}{2}} $$
or or
@ -144,12 +144,12 @@ y in y, and z in z.
We call the resulting object a "fore-vector" or "back-vector", depending We call the resulting object a "fore-vector" or "back-vector", depending
on the direction of the shift. We write it as on the direction of the shift. We write it as
$$ \tilde{g}_{m,n,p} = \vec{x} g^x_{m + \frac{1}{2},n,p} + $$ \\tilde{g}_{m,n,p} = \\vec{x} g^x_{m + \\frac{1}{2},n,p} +
\vec{y} g^y_{m,n + \frac{1}{2},p} + \\vec{y} g^y_{m,n + \\frac{1}{2},p} +
\vec{z} g^z_{m,n,p + \frac{1}{2}} $$ \\vec{z} g^z_{m,n,p + \\frac{1}{2}} $$
$$ \hat{g}_{m,n,p} = \vec{x} g^x_{m - \frac{1}{2},n,p} + $$ \\hat{g}_{m,n,p} = \\vec{x} g^x_{m - \\frac{1}{2},n,p} +
\vec{y} g^y_{m,n - \frac{1}{2},p} + \\vec{y} g^y_{m,n - \\frac{1}{2},p} +
\vec{z} g^z_{m,n,p - \frac{1}{2}} $$ \\vec{z} g^z_{m,n,p - \\frac{1}{2}} $$
[figure: gradient / fore-vector] [figure: gradient / fore-vector]
@ -172,15 +172,15 @@ Divergences
There are also two divergences, There are also two divergences,
$$ d_{n,m,p} = [\tilde{\nabla} \cdot \hat{g}]_{n,m,p} $$ d_{n,m,p} = [\\tilde{\\nabla} \\cdot \\hat{g}]_{n,m,p}
= [\tilde{\partial}_x g^x]_{m,n,p} + = [\\tilde{\\partial}_x g^x]_{m,n,p} +
[\tilde{\partial}_y g^y]_{m,n,p} + [\\tilde{\\partial}_y g^y]_{m,n,p} +
[\tilde{\partial}_z g^z]_{m,n,p} $$ [\\tilde{\\partial}_z g^z]_{m,n,p} $$
$$ d_{n,m,p} = [\hat{\nabla} \cdot \tilde{g}]_{n,m,p} $$ d_{n,m,p} = [\\hat{\\nabla} \\cdot \\tilde{g}]_{n,m,p}
= [\hat{\partial}_x g^x]_{m,n,p} + = [\\hat{\\partial}_x g^x]_{m,n,p} +
[\hat{\partial}_y g^y]_{m,n,p} + [\\hat{\\partial}_y g^y]_{m,n,p} +
[\hat{\partial}_z g^z]_{m,n,p} $$ [\\hat{\\partial}_z g^z]_{m,n,p} $$
or or
@ -203,7 +203,7 @@ where `g = [gx, gy, gz]` is a fore- or back-vector field.
Since we applied the forward divergence to the back-vector (and vice-versa), the resulting scalar value Since we applied the forward divergence to the back-vector (and vice-versa), the resulting scalar value
is defined at the back-vector's (fore-vector's) location $(m,n,p)$ and not at the locations of its components is defined at the back-vector's (fore-vector's) location $(m,n,p)$ and not at the locations of its components
$(m \pm \frac{1}{2},n,p)$ etc. $(m \\pm \\frac{1}{2},n,p)$ etc.
[figure: divergence] [figure: divergence]
^^ ^^
@ -227,23 +227,23 @@ Curls
The two curls are then The two curls are then
$$ \begin{aligned} $$ \\begin{aligned}
\hat{h}_{m + \frac{1}{2}, n + \frac{1}{2}, p + \frac{1}{2}} &= \\ \\hat{h}_{m + \\frac{1}{2}, n + \\frac{1}{2}, p + \\frac{1}{2}} &= \\\\
[\tilde{\nabla} \times \tilde{g}]_{m + \frac{1}{2}, n + \frac{1}{2}, p + \frac{1}{2}} &= [\\tilde{\\nabla} \\times \\tilde{g}]_{m + \\frac{1}{2}, n + \\frac{1}{2}, p + \\frac{1}{2}} &=
\vec{x} (\tilde{\partial}_y g^z_{m,n,p + \frac{1}{2}} - \tilde{\partial}_z g^y_{m,n + \frac{1}{2},p}) \\ \\vec{x} (\\tilde{\\partial}_y g^z_{m,n,p + \\frac{1}{2}} - \\tilde{\\partial}_z g^y_{m,n + \\frac{1}{2},p}) \\\\
&+ \vec{y} (\tilde{\partial}_z g^x_{m + \frac{1}{2},n,p} - \tilde{\partial}_x g^z_{m,n,p + \frac{1}{2}}) \\ &+ \\vec{y} (\\tilde{\\partial}_z g^x_{m + \\frac{1}{2},n,p} - \\tilde{\\partial}_x g^z_{m,n,p + \\frac{1}{2}}) \\\\
&+ \vec{z} (\tilde{\partial}_x g^y_{m,n + \frac{1}{2},p} - \tilde{\partial}_y g^z_{m + \frac{1}{2},n,p}) &+ \\vec{z} (\\tilde{\\partial}_x g^y_{m,n + \\frac{1}{2},p} - \\tilde{\\partial}_y g^z_{m + \\frac{1}{2},n,p})
\end{aligned} $$ \\end{aligned} $$
and and
$$ \tilde{h}_{m - \frac{1}{2}, n - \frac{1}{2}, p - \frac{1}{2}} = $$ \\tilde{h}_{m - \\frac{1}{2}, n - \\frac{1}{2}, p - \\frac{1}{2}} =
[\hat{\nabla} \times \hat{g}]_{m - \frac{1}{2}, n - \frac{1}{2}, p - \frac{1}{2}} $$ [\\hat{\\nabla} \\times \\hat{g}]_{m - \\frac{1}{2}, n - \\frac{1}{2}, p - \\frac{1}{2}} $$
where $\hat{g}$ and $\tilde{g}$ are located at $(m,n,p)$ where $\\hat{g}$ and $\\tilde{g}$ are located at $(m,n,p)$
with components at $(m \pm \frac{1}{2},n,p)$ etc., with components at $(m \\pm \\frac{1}{2},n,p)$ etc.,
while $\hat{h}$ and $\tilde{h}$ are located at $(m \pm \frac{1}{2}, n \pm \frac{1}{2}, p \pm \frac{1}{2})$ while $\\hat{h}$ and $\\tilde{h}$ are located at $(m \\pm \\frac{1}{2}, n \\pm \\frac{1}{2}, p \\pm \\frac{1}{2})$
with components at $(m, n \pm \frac{1}{2}, p \pm \frac{1}{2})$ etc. with components at $(m, n \\pm \\frac{1}{2}, p \\pm \\frac{1}{2})$ etc.
[code: curls] [code: curls]
@ -287,27 +287,27 @@ Maxwell's Equations
If we discretize both space (m,n,p) and time (l), Maxwell's equations become If we discretize both space (m,n,p) and time (l), Maxwell's equations become
$$ \begin{aligned} $$ \\begin{aligned}
\tilde{\nabla} \times \tilde{E}_{l,\vec{r}} &= -\tilde{\partial}_t \hat{B}_{l-\frac{1}{2}, \vec{r} + \frac{1}{2}} \\tilde{\\nabla} \\times \\tilde{E}_{l,\\vec{r}} &= -\\tilde{\\partial}_t \\hat{B}_{l-\\frac{1}{2}, \\vec{r} + \\frac{1}{2}}
- \hat{M}_{l, \vec{r} + \frac{1}{2}} \\ - \\hat{M}_{l, \\vec{r} + \\frac{1}{2}} \\\\
\hat{\nabla} \times \hat{H}_{l-\frac{1}{2},\vec{r} + \frac{1}{2}} &= \hat{\partial}_t \tilde{D}_{l, \vec{r}} \\hat{\\nabla} \\times \\hat{H}_{l-\\frac{1}{2},\\vec{r} + \\frac{1}{2}} &= \\hat{\\partial}_t \\tilde{D}_{l, \\vec{r}}
+ \tilde{J}_{l-\frac{1}{2},\vec{r}} \\ + \\tilde{J}_{l-\\frac{1}{2},\\vec{r}} \\\\
\tilde{\nabla} \cdot \hat{B}_{l-\frac{1}{2}, \vec{r} + \frac{1}{2}} &= 0 \\ \\tilde{\\nabla} \\cdot \\hat{B}_{l-\\frac{1}{2}, \\vec{r} + \\frac{1}{2}} &= 0 \\\\
\hat{\nabla} \cdot \tilde{D}_{l,\vec{r}} &= \rho_{l,\vec{r}} \\hat{\\nabla} \\cdot \\tilde{D}_{l,\\vec{r}} &= \\rho_{l,\\vec{r}}
\end{aligned} $$ \\end{aligned} $$
with with
$$ \begin{aligned} $$ \\begin{aligned}
\hat{B}_{\vec{r}} &= \mu_{\vec{r} + \frac{1}{2}} \cdot \hat{H}_{\vec{r} + \frac{1}{2}} \\ \\hat{B}_{\\vec{r}} &= \\mu_{\\vec{r} + \\frac{1}{2}} \\cdot \\hat{H}_{\\vec{r} + \\frac{1}{2}} \\\\
\tilde{D}_{\vec{r}} &= \epsilon_{\vec{r}} \cdot \tilde{E}_{\vec{r}} \\tilde{D}_{\\vec{r}} &= \\epsilon_{\\vec{r}} \\cdot \\tilde{E}_{\\vec{r}}
\end{aligned} $$ \\end{aligned} $$
where the spatial subscripts are abbreviated as $\vec{r} = (m, n, p)$ and where the spatial subscripts are abbreviated as $\\vec{r} = (m, n, p)$ and
$\vec{r} + \frac{1}{2} = (m + \frac{1}{2}, n + \frac{1}{2}, p + \frac{1}{2})$, $\\vec{r} + \\frac{1}{2} = (m + \\frac{1}{2}, n + \\frac{1}{2}, p + \\frac{1}{2})$,
$\tilde{E}$ and $\hat{H}$ are the electric and magnetic fields, $\\tilde{E}$ and $\\hat{H}$ are the electric and magnetic fields,
$\tilde{J}$ and $\hat{M}$ are the electric and magnetic current distributions, $\\tilde{J}$ and $\\hat{M}$ are the electric and magnetic current distributions,
and $\epsilon$ and $\mu$ are the dielectric permittivity and magnetic permeability. and $\\epsilon$ and $\\mu$ are the dielectric permittivity and magnetic permeability.
The above is Yee's algorithm, written in a form analogous to Maxwell's equations. The above is Yee's algorithm, written in a form analogous to Maxwell's equations.
The time derivatives can be expanded to form the update equations: The time derivatives can be expanded to form the update equations:
@ -369,34 +369,34 @@ Each component forms its own grid, offset from the others:
The divergence equations can be derived by taking the divergence of the curl equations The divergence equations can be derived by taking the divergence of the curl equations
and combining them with charge continuity, and combining them with charge continuity,
$$ \hat{\nabla} \cdot \tilde{J} + \hat{\partial}_t \rho = 0 $$ $$ \\hat{\\nabla} \\cdot \\tilde{J} + \\hat{\\partial}_t \\rho = 0 $$
implying that the discrete Maxwell's equations do not produce spurious charges. implying that the discrete Maxwell's equations do not produce spurious charges.
Wave equation Wave equation
------------- -------------
Taking the backward curl of the $\tilde{\nabla} \times \tilde{E}$ equation and Taking the backward curl of the $\\tilde{\\nabla} \\times \\tilde{E}$ equation and
replacing the resulting $\hat{\nabla} \times \hat{H}$ term using its respective equation, replacing the resulting $\\hat{\\nabla} \\times \\hat{H}$ term using its respective equation,
and setting $\hat{M}$ to zero, we can form the discrete wave equation: and setting $\\hat{M}$ to zero, we can form the discrete wave equation:
$$ $$
\begin{aligned} \\begin{aligned}
\tilde{\nabla} \times \tilde{E}_{l,\vec{r}} &= \\tilde{\\nabla} \\times \\tilde{E}_{l,\\vec{r}} &=
-\tilde{\partial}_t \hat{B}_{l-\frac{1}{2}, \vec{r} + \frac{1}{2}} -\\tilde{\\partial}_t \\hat{B}_{l-\\frac{1}{2}, \\vec{r} + \\frac{1}{2}}
- \hat{M}_{l-1, \vec{r} + \frac{1}{2}} \\ - \\hat{M}_{l-1, \\vec{r} + \\frac{1}{2}} \\\\
\mu^{-1}_{\vec{r} + \frac{1}{2}} \cdot \tilde{\nabla} \times \tilde{E}_{l,\vec{r}} &= \\mu^{-1}_{\\vec{r} + \\frac{1}{2}} \\cdot \\tilde{\\nabla} \\times \\tilde{E}_{l,\\vec{r}} &=
-\tilde{\partial}_t \hat{H}_{l-\frac{1}{2}, \vec{r} + \frac{1}{2}} \\ -\\tilde{\\partial}_t \\hat{H}_{l-\\frac{1}{2}, \\vec{r} + \\frac{1}{2}} \\\\
\hat{\nabla} \times (\mu^{-1}_{\vec{r} + \frac{1}{2}} \cdot \tilde{\nabla} \times \tilde{E}_{l,\vec{r}}) &= \\hat{\\nabla} \\times (\\mu^{-1}_{\\vec{r} + \\frac{1}{2}} \\cdot \\tilde{\\nabla} \\times \\tilde{E}_{l,\\vec{r}}) &=
\hat{\nabla} \times (-\tilde{\partial}_t \hat{H}_{l-\frac{1}{2}, \vec{r} + \frac{1}{2}}) \\ \\hat{\\nabla} \\times (-\\tilde{\\partial}_t \\hat{H}_{l-\\frac{1}{2}, \\vec{r} + \\frac{1}{2}}) \\\\
\hat{\nabla} \times (\mu^{-1}_{\vec{r} + \frac{1}{2}} \cdot \tilde{\nabla} \times \tilde{E}_{l,\vec{r}}) &= \\hat{\\nabla} \\times (\\mu^{-1}_{\\vec{r} + \\frac{1}{2}} \\cdot \\tilde{\\nabla} \\times \\tilde{E}_{l,\\vec{r}}) &=
-\tilde{\partial}_t \hat{\nabla} \times \hat{H}_{l-\frac{1}{2}, \vec{r} + \frac{1}{2}} \\ -\\tilde{\\partial}_t \\hat{\\nabla} \\times \\hat{H}_{l-\\frac{1}{2}, \\vec{r} + \\frac{1}{2}} \\\\
\hat{\nabla} \times (\mu^{-1}_{\vec{r} + \frac{1}{2}} \cdot \tilde{\nabla} \times \tilde{E}_{l,\vec{r}}) &= \\hat{\\nabla} \\times (\\mu^{-1}_{\\vec{r} + \\frac{1}{2}} \\cdot \\tilde{\\nabla} \\times \\tilde{E}_{l,\\vec{r}}) &=
-\tilde{\partial}_t \hat{\partial}_t \epsilon_{\vec{r}} \tilde{E}_{l, \vec{r}} + \hat{\partial}_t \tilde{J}_{l-\frac{1}{2},\vec{r}} \\ -\\tilde{\\partial}_t \\hat{\\partial}_t \\epsilon_{\\vec{r}} \\tilde{E}_{l, \\vec{r}} + \\hat{\\partial}_t \\tilde{J}_{l-\\frac{1}{2},\\vec{r}} \\\\
\hat{\nabla} \times (\mu^{-1}_{\vec{r} + \frac{1}{2}} \cdot \tilde{\nabla} \times \tilde{E}_{l,\vec{r}}) \\hat{\\nabla} \\times (\\mu^{-1}_{\\vec{r} + \\frac{1}{2}} \\cdot \\tilde{\\nabla} \\times \\tilde{E}_{l,\\vec{r}})
+ \tilde{\partial}_t \hat{\partial}_t \epsilon_{\vec{r}} \cdot \tilde{E}_{l, \vec{r}} + \\tilde{\\partial}_t \\hat{\\partial}_t \\epsilon_{\\vec{r}} \\cdot \\tilde{E}_{l, \\vec{r}}
&= \tilde{\partial}_t \tilde{J}_{l - \frac{1}{2}, \vec{r}} &= \\tilde{\\partial}_t \\tilde{J}_{l - \\frac{1}{2}, \\vec{r}}
\end{aligned} \\end{aligned}
$$ $$
@ -406,27 +406,27 @@ Frequency domain
We can substitute in a time-harmonic fields We can substitute in a time-harmonic fields
$$ $$
\begin{aligned} \\begin{aligned}
\tilde{E}_{l, \vec{r}} &= \tilde{E}_{\vec{r}} e^{-\imath \omega l \Delta_t} \\ \\tilde{E}_{l, \\vec{r}} &= \\tilde{E}_{\\vec{r}} e^{-\\imath \\omega l \\Delta_t} \\\\
\tilde{J}_{l, \vec{r}} &= \tilde{J}_{\vec{r}} e^{-\imath \omega (l - \frac{1}{2}) \Delta_t} \\tilde{J}_{l, \\vec{r}} &= \\tilde{J}_{\\vec{r}} e^{-\\imath \\omega (l - \\frac{1}{2}) \\Delta_t}
\end{aligned} \\end{aligned}
$$ $$
resulting in resulting in
$$ $$
\begin{aligned} \\begin{aligned}
\tilde{\partial}_t &\Rightarrow (e^{ \imath \omega \Delta_t} - 1) / \Delta_t = \frac{-2 \imath}{\Delta_t} \sin(\omega \Delta_t / 2) e^{-\imath \omega \Delta_t / 2} = -\imath \Omega e^{-\imath \omega \Delta_t / 2}\\ \\tilde{\\partial}_t &\\Rightarrow (e^{ \\imath \\omega \\Delta_t} - 1) / \\Delta_t = \\frac{-2 \\imath}{\\Delta_t} \\sin(\\omega \\Delta_t / 2) e^{-\\imath \\omega \\Delta_t / 2} = -\\imath \\Omega e^{-\\imath \\omega \\Delta_t / 2}\\\\
\hat{\partial}_t &\Rightarrow (1 - e^{-\imath \omega \Delta_t}) / \Delta_t = \frac{-2 \imath}{\Delta_t} \sin(\omega \Delta_t / 2) e^{ \imath \omega \Delta_t / 2} = -\imath \Omega e^{ \imath \omega \Delta_t / 2}\\ \\hat{\\partial}_t &\\Rightarrow (1 - e^{-\\imath \\omega \\Delta_t}) / \\Delta_t = \\frac{-2 \\imath}{\\Delta_t} \\sin(\\omega \\Delta_t / 2) e^{ \\imath \\omega \\Delta_t / 2} = -\\imath \\Omega e^{ \\imath \\omega \\Delta_t / 2}\\\\
\Omega &= 2 \sin(\omega \Delta_t / 2) / \Delta_t \\Omega &= 2 \\sin(\\omega \\Delta_t / 2) / \\Delta_t
\end{aligned} \\end{aligned}
$$ $$
This gives the frequency-domain wave equation, This gives the frequency-domain wave equation,
$$ $$
\hat{\nabla} \times (\mu^{-1}_{\vec{r} + \frac{1}{2}} \cdot \tilde{\nabla} \times \tilde{E}_{\vec{r}}) \\hat{\\nabla} \\times (\\mu^{-1}_{\\vec{r} + \\frac{1}{2}} \\cdot \\tilde{\\nabla} \\times \\tilde{E}_{\\vec{r}})
-\Omega^2 \epsilon_{\vec{r}} \cdot \tilde{E}_{\vec{r}} = -\imath \Omega \tilde{J}_{\vec{r}} e^{\imath \omega \Delta_t / 2} \\ -\\Omega^2 \\epsilon_{\\vec{r}} \\cdot \\tilde{E}_{\\vec{r}} = -\\imath \\Omega \\tilde{J}_{\\vec{r}} e^{\\imath \\omega \\Delta_t / 2} \\\\
$$ $$
@ -436,48 +436,48 @@ Plane waves and Dispersion relation
With uniform material distribution and no sources With uniform material distribution and no sources
$$ $$
\begin{aligned} \\begin{aligned}
\mu_{\vec{r} + \frac{1}{2}} &= \mu \\ \\mu_{\\vec{r} + \\frac{1}{2}} &= \\mu \\\\
\epsilon_{\vec{r}} &= \epsilon \\ \\epsilon_{\\vec{r}} &= \\epsilon \\\\
\tilde{J}_{\vec{r}} &= 0 \\ \\tilde{J}_{\\vec{r}} &= 0 \\\\
\end{aligned} \\end{aligned}
$$ $$
the frequency domain wave equation simplifies to the frequency domain wave equation simplifies to
$$ \hat{\nabla} \times \tilde{\nabla} \times \tilde{E}_{\vec{r}} - \Omega^2 \epsilon \mu \tilde{E}_{\vec{r}} = 0 $$ $$ \\hat{\\nabla} \\times \\tilde{\\nabla} \\times \\tilde{E}_{\\vec{r}} - \\Omega^2 \\epsilon \\mu \\tilde{E}_{\\vec{r}} = 0 $$
Since $\hat{\nabla} \cdot \tilde{E}_{\vec{r}} = 0$, we can simplify Since $\\hat{\\nabla} \\cdot \\tilde{E}_{\\vec{r}} = 0$, we can simplify
$$ $$
\begin{aligned} \\begin{aligned}
\hat{\nabla} \times \tilde{\nabla} \times \tilde{E}_{\vec{r}} \\hat{\\nabla} \\times \\tilde{\\nabla} \\times \\tilde{E}_{\\vec{r}}
&= \tilde{\nabla}(\hat{\nabla} \cdot \tilde{E}_{\vec{r}}) - \hat{\nabla} \cdot \tilde{\nabla} \tilde{E}_{\vec{r}} \\ &= \\tilde{\\nabla}(\\hat{\\nabla} \\cdot \\tilde{E}_{\\vec{r}}) - \\hat{\\nabla} \\cdot \\tilde{\\nabla} \\tilde{E}_{\\vec{r}} \\\\
&= - \hat{\nabla} \cdot \tilde{\nabla} \tilde{E}_{\vec{r}} \\ &= - \\hat{\\nabla} \\cdot \\tilde{\\nabla} \\tilde{E}_{\\vec{r}} \\\\
&= - \tilde{\nabla}^2 \tilde{E}_{\vec{r}} &= - \\tilde{\\nabla}^2 \\tilde{E}_{\\vec{r}}
\end{aligned} \\end{aligned}
$$ $$
and we get and we get
$$ \tilde{\nabla}^2 \tilde{E}_{\vec{r}} + \Omega^2 \epsilon \mu \tilde{E}_{\vec{r}} = 0 $$ $$ \\tilde{\\nabla}^2 \\tilde{E}_{\\vec{r}} + \\Omega^2 \\epsilon \\mu \\tilde{E}_{\\vec{r}} = 0 $$
We can convert this to three scalar-wave equations of the form We can convert this to three scalar-wave equations of the form
$$ (\tilde{\nabla}^2 + K^2) \phi_{\vec{r}} = 0 $$ $$ (\\tilde{\\nabla}^2 + K^2) \\phi_{\\vec{r}} = 0 $$
with $K^2 = \Omega^2 \mu \epsilon$. Now we let with $K^2 = \\Omega^2 \\mu \\epsilon$. Now we let
$$ \phi_{\vec{r}} = A e^{\imath (k_x m \Delta_x + k_y n \Delta_y + k_z p \Delta_z)} $$ $$ \\phi_{\\vec{r}} = A e^{\\imath (k_x m \\Delta_x + k_y n \\Delta_y + k_z p \\Delta_z)} $$
resulting in resulting in
$$ $$
\begin{aligned} \\begin{aligned}
\tilde{\partial}_x &\Rightarrow (e^{ \imath k_x \Delta_x} - 1) / \Delta_t = \frac{-2 \imath}{\Delta_x} \sin(k_x \Delta_x / 2) e^{ \imath k_x \Delta_x / 2} = \imath K_x e^{ \imath k_x \Delta_x / 2}\\ \\tilde{\\partial}_x &\\Rightarrow (e^{ \\imath k_x \\Delta_x} - 1) / \\Delta_t = \\frac{-2 \\imath}{\\Delta_x} \\sin(k_x \\Delta_x / 2) e^{ \\imath k_x \\Delta_x / 2} = \\imath K_x e^{ \\imath k_x \\Delta_x / 2}\\\\
\hat{\partial}_x &\Rightarrow (1 - e^{-\imath k_x \Delta_x}) / \Delta_t = \frac{-2 \imath}{\Delta_x} \sin(k_x \Delta_x / 2) e^{-\imath k_x \Delta_x / 2} = \imath K_x e^{-\imath k_x \Delta_x / 2}\\ \\hat{\\partial}_x &\\Rightarrow (1 - e^{-\\imath k_x \\Delta_x}) / \\Delta_t = \\frac{-2 \\imath}{\\Delta_x} \\sin(k_x \\Delta_x / 2) e^{-\\imath k_x \\Delta_x / 2} = \\imath K_x e^{-\\imath k_x \\Delta_x / 2}\\\\
K_x &= 2 \sin(k_x \Delta_x / 2) / \Delta_x \\ K_x &= 2 \\sin(k_x \\Delta_x / 2) / \\Delta_x \\\\
\end{aligned} \\end{aligned}
$$ $$
with similar expressions for the y and z dimnsions (and $K_y, K_z$). with similar expressions for the y and z dimnsions (and $K_y, K_z$).
@ -485,20 +485,20 @@ with similar expressions for the y and z dimnsions (and $K_y, K_z$).
This implies This implies
$$ $$
\tilde{\nabla}^2 = -(K_x^2 + K_y^2 + K_z^2) \phi_{\vec{r}} \\ \\tilde{\\nabla}^2 = -(K_x^2 + K_y^2 + K_z^2) \\phi_{\\vec{r}} \\\\
K_x^2 + K_y^2 + K_z^2 = \Omega^2 \mu \epsilon = \Omega^2 / c^2 K_x^2 + K_y^2 + K_z^2 = \\Omega^2 \\mu \\epsilon = \\Omega^2 / c^2
$$ $$
where $c = \sqrt{\mu \epsilon}$. where $c = \\sqrt{\\mu \\epsilon}$.
Assuming real $(k_x, k_y, k_z), \omega$ will be real only if Assuming real $(k_x, k_y, k_z), \\omega$ will be real only if
$$ c^2 \Delta_t^2 = \frac{\Delta_t^2}{\mu \epsilon} < 1/(\frac{1}{\Delta_x^2} + \frac{1}{\Delta_y^2} + \frac{1}{\Delta_z^2}) $$ $$ c^2 \\Delta_t^2 = \\frac{\\Delta_t^2}{\\mu \\epsilon} < 1/(\\frac{1}{\\Delta_x^2} + \\frac{1}{\\Delta_y^2} + \\frac{1}{\\Delta_z^2}) $$
If $\Delta_x = \Delta_y = \Delta_z$, this simplifies to $c \Delta_t < \Delta_x / \sqrt{3}$. If $\\Delta_x = \\Delta_y = \\Delta_z$, this simplifies to $c \\Delta_t < \\Delta_x / \\sqrt{3}$.
This last form can be interpreted as enforcing causality; the distance that light This last form can be interpreted as enforcing causality; the distance that light
travels in one timestep (i.e., $c \Delta_t$) must be less than the diagonal travels in one timestep (i.e., $c \\Delta_t$) must be less than the diagonal
of the smallest cell ( $\Delta_x / \sqrt{3}$ when on a uniform cubic grid). of the smallest cell ( $\\Delta_x / \\sqrt{3}$ when on a uniform cubic grid).
Grid description Grid description
@ -515,8 +515,8 @@ to make the illustration simpler; we need at least two cells in the x dimension
demonstrate how nonuniform `dx` affects the various components. demonstrate how nonuniform `dx` affects the various components.
Place the E fore-vectors at integer indices $r = (m, n, p)$ and the H back-vectors Place the E fore-vectors at integer indices $r = (m, n, p)$ and the H back-vectors
at fractional indices $r + \frac{1}{2} = (m + \frac{1}{2}, n + \frac{1}{2}, at fractional indices $r + \\frac{1}{2} = (m + \\frac{1}{2}, n + \\frac{1}{2},
p + \frac{1}{2})$. Remember that these are indices and not coordinates; they can p + \\frac{1}{2})$. Remember that these are indices and not coordinates; they can
correspond to arbitrary (monotonically increasing) coordinates depending on the cell widths. correspond to arbitrary (monotonically increasing) coordinates depending on the cell widths.
Draw lines to denote the planes on which the H components and back-vectors are defined. Draw lines to denote the planes on which the H components and back-vectors are defined.
@ -718,14 +718,14 @@ composed of the three diagonal tensor components:
or or
$$ $$
\epsilon = \begin{bmatrix} \epsilon_{xx} & 0 & 0 \\ \\epsilon = \\begin{bmatrix} \\epsilon_{xx} & 0 & 0 \\\\
0 & \epsilon_{yy} & 0 \\ 0 & \\epsilon_{yy} & 0 \\\\
0 & 0 & \epsilon_{zz} \end{bmatrix} 0 & 0 & \\epsilon_{zz} \\end{bmatrix}
$$ $$
$$ $$
\mu = \begin{bmatrix} \mu_{xx} & 0 & 0 \\ \\mu = \\begin{bmatrix} \\mu_{xx} & 0 & 0 \\\\
0 & \mu_{yy} & 0 \\ 0 & \\mu_{yy} & 0 \\\\
0 & 0 & \mu_{zz} \end{bmatrix} 0 & 0 & \\mu_{zz} \\end{bmatrix}
$$ $$
where the off-diagonal terms (e.g. `epsilon_xy`) are assumed to be zero. where the off-diagonal terms (e.g. `epsilon_xy`) are assumed to be zero.
@ -741,46 +741,8 @@ the true values can be multiplied back in after the simulation is complete if no
normalized results are needed. normalized results are needed.
""" """
from .types import ( from .types import fdfield_t, vfdfield_t, cfdfield_t, vcfdfield_t, dx_lists_t, dx_lists_mut
fdfield_t as fdfield_t, from .types import fdfield_updater_t, cfdfield_updater_t
vfdfield_t as vfdfield_t, from .vectorization import vec, unvec
cfdfield_t as cfdfield_t, from . import operators, functional, types, vectorization
vcfdfield_t as vcfdfield_t,
fdfield2_t as fdfield2_t,
vfdfield2_t as vfdfield2_t,
cfdfield2_t as cfdfield2_t,
vcfdfield2_t as vcfdfield2_t,
fdfield as fdfield,
vfdfield as vfdfield,
cfdfield as cfdfield,
vcfdfield as vcfdfield,
fdfield2 as fdfield2,
vfdfield2 as vfdfield2,
cfdfield2 as cfdfield2,
vcfdfield2 as vcfdfield2,
fdslice_t as fdslice_t,
vfdslice_t as vfdslice_t,
cfdslice_t as cfdslice_t,
vcfdslice_t as vcfdslice_t,
fdslice as fdslice,
vfdslice as vfdslice,
cfdslice as cfdslice,
vcfdslice as vcfdslice,
dx_lists_t as dx_lists_t,
dx_lists2_t as dx_lists2_t,
dx_lists_mut as dx_lists_mut,
dx_lists2_mut as dx_lists2_mut,
fdfield_updater_t as fdfield_updater_t,
cfdfield_updater_t as cfdfield_updater_t,
)
from .vectorization import (
vec as vec,
unvec as unvec,
)
from . import (
operators as operators,
functional as functional,
types as types,
vectorization as vectorization,
)

View file

@ -3,18 +3,16 @@ Math functions for finite difference simulations
Basic discrete calculus etc. Basic discrete calculus etc.
""" """
from typing import TypeVar from typing import Sequence, Callable
from collections.abc import Sequence, Callable
import numpy import numpy
from numpy.typing import NDArray from numpy.typing import NDArray
from numpy import floating, complexfloating
from .types import fdfield, fdfield_updater_t from .types import fdfield_t, fdfield_updater_t
def deriv_forward( def deriv_forward(
dx_e: Sequence[NDArray[floating | complexfloating]] | None = None, dx_e: Sequence[NDArray[numpy.float_]] | None = None,
) -> tuple[fdfield_updater_t, fdfield_updater_t, fdfield_updater_t]: ) -> tuple[fdfield_updater_t, fdfield_updater_t, fdfield_updater_t]:
""" """
Utility operators for taking discretized derivatives (backward variant). Utility operators for taking discretized derivatives (backward variant).
@ -38,7 +36,7 @@ def deriv_forward(
def deriv_back( def deriv_back(
dx_h: Sequence[NDArray[floating | complexfloating]] | None = None, dx_h: Sequence[NDArray[numpy.float_]] | None = None,
) -> tuple[fdfield_updater_t, fdfield_updater_t, fdfield_updater_t]: ) -> tuple[fdfield_updater_t, fdfield_updater_t, fdfield_updater_t]:
""" """
Utility operators for taking discretized derivatives (forward variant). Utility operators for taking discretized derivatives (forward variant).
@ -61,13 +59,10 @@ def deriv_back(
return derivs return derivs
TT = TypeVar('TT', bound='NDArray[floating | complexfloating]')
def curl_forward( def curl_forward(
dx_e: Sequence[NDArray[floating | complexfloating]] | None = None, dx_e: Sequence[NDArray[numpy.float_]] | None = None,
) -> Callable[[TT], TT]: ) -> fdfield_updater_t:
r""" """
Curl operator for use with the E field. Curl operator for use with the E field.
Args: Args:
@ -76,11 +71,11 @@ def curl_forward(
Returns: Returns:
Function `f` for taking the discrete forward curl of a field, Function `f` for taking the discrete forward curl of a field,
`f(E)` -> curlE $= \nabla_f \times E$ `f(E)` -> curlE $= \\nabla_f \\times E$
""" """
Dx, Dy, Dz = deriv_forward(dx_e) Dx, Dy, Dz = deriv_forward(dx_e)
def ce_fun(e: TT) -> TT: def ce_fun(e: fdfield_t) -> fdfield_t:
output = numpy.empty_like(e) output = numpy.empty_like(e)
output[0] = Dy(e[2]) output[0] = Dy(e[2])
output[1] = Dz(e[0]) output[1] = Dz(e[0])
@ -94,9 +89,9 @@ def curl_forward(
def curl_back( def curl_back(
dx_h: Sequence[NDArray[floating | complexfloating]] | None = None, dx_h: Sequence[NDArray[numpy.float_]] | None = None,
) -> Callable[[TT], TT]: ) -> fdfield_updater_t:
r""" """
Create a function which takes the backward curl of a field. Create a function which takes the backward curl of a field.
Args: Args:
@ -105,11 +100,11 @@ def curl_back(
Returns: Returns:
Function `f` for taking the discrete backward curl of a field, Function `f` for taking the discrete backward curl of a field,
`f(H)` -> curlH $= \nabla_b \times H$ `f(H)` -> curlH $= \\nabla_b \\times H$
""" """
Dx, Dy, Dz = deriv_back(dx_h) Dx, Dy, Dz = deriv_back(dx_h)
def ch_fun(h: TT) -> TT: def ch_fun(h: fdfield_t) -> fdfield_t:
output = numpy.empty_like(h) output = numpy.empty_like(h)
output[0] = Dy(h[2]) output[0] = Dy(h[2])
output[1] = Dz(h[0]) output[1] = Dz(h[0])
@ -123,11 +118,11 @@ def curl_back(
def curl_forward_parts( def curl_forward_parts(
dx_e: Sequence[NDArray[floating | complexfloating]] | None = None, dx_e: Sequence[NDArray[numpy.float_]] | None = None,
) -> Callable: ) -> Callable:
Dx, Dy, Dz = deriv_forward(dx_e) Dx, Dy, Dz = deriv_forward(dx_e)
def mkparts_fwd(e: fdfield) -> tuple[tuple[fdfield, fdfield], ...]: def mkparts_fwd(e: fdfield_t) -> tuple[tuple[fdfield_t, fdfield_t], ...]:
return ((-Dz(e[1]), Dy(e[2])), return ((-Dz(e[1]), Dy(e[2])),
( Dz(e[0]), -Dx(e[2])), ( Dz(e[0]), -Dx(e[2])),
(-Dy(e[0]), Dx(e[1]))) (-Dy(e[0]), Dx(e[1])))
@ -136,11 +131,11 @@ def curl_forward_parts(
def curl_back_parts( def curl_back_parts(
dx_h: Sequence[NDArray[floating | complexfloating]] | None = None, dx_h: Sequence[NDArray[numpy.float_]] | None = None,
) -> Callable: ) -> Callable:
Dx, Dy, Dz = deriv_back(dx_h) Dx, Dy, Dz = deriv_back(dx_h)
def mkparts_back(h: fdfield) -> tuple[tuple[fdfield, fdfield], ...]: def mkparts_back(h: fdfield_t) -> tuple[tuple[fdfield_t, fdfield_t], ...]:
return ((-Dz(h[1]), Dy(h[2])), return ((-Dz(h[1]), Dy(h[2])),
( Dz(h[0]), -Dx(h[2])), ( Dz(h[0]), -Dx(h[2])),
(-Dy(h[0]), Dx(h[1]))) (-Dy(h[0]), Dx(h[1])))

View file

@ -3,20 +3,19 @@ Matrix operators for finite difference simulations
Basic discrete calculus etc. Basic discrete calculus etc.
""" """
from collections.abc import Sequence from typing import Sequence
import numpy import numpy
from numpy.typing import NDArray from numpy.typing import NDArray
from numpy import floating, complexfloating import scipy.sparse as sparse # type: ignore
from scipy import sparse
from .types import vfdfield from .types import vfdfield_t
def shift_circ( def shift_circ(
axis: int, axis: int,
shape: Sequence[int], shape: Sequence[int],
shift_distance: int = 1, shift_distance: int = 1,
) -> sparse.sparray: ) -> sparse.spmatrix:
""" """
Utility operator for performing a circular shift along a specified axis by a Utility operator for performing a circular shift along a specified axis by a
specified number of elements. specified number of elements.
@ -30,12 +29,12 @@ def shift_circ(
Sparse matrix for performing the circular shift. Sparse matrix for performing the circular shift.
""" """
if len(shape) not in (2, 3): if len(shape) not in (2, 3):
raise Exception(f'Invalid shape: {shape}') raise Exception('Invalid shape: {}'.format(shape))
if axis not in range(len(shape)): if axis not in range(len(shape)):
raise Exception(f'Invalid direction: {axis}, shape is {shape}') raise Exception('Invalid direction: {}, shape is {}'.format(axis, shape))
shifts = [abs(shift_distance) if a == axis else 0 for a in range(len(shape))] shifts = [abs(shift_distance) if a == axis else 0 for a in range(3)]
shifted_diags = [(numpy.arange(n) + s) % n for n, s in zip(shape, shifts, strict=True)] shifted_diags = [(numpy.arange(n) + s) % n for n, s in zip(shape, shifts)]
ijk = numpy.meshgrid(*shifted_diags, indexing='ij') ijk = numpy.meshgrid(*shifted_diags, indexing='ij')
n = numpy.prod(shape) n = numpy.prod(shape)
@ -44,7 +43,7 @@ def shift_circ(
vij = (numpy.ones(n), (i_ind, j_ind.ravel(order='C'))) vij = (numpy.ones(n), (i_ind, j_ind.ravel(order='C')))
d = sparse.csr_array(vij, shape=(n, n)) d = sparse.csr_matrix(vij, shape=(n, n))
if shift_distance < 0: if shift_distance < 0:
d = d.T d = d.T
@ -56,7 +55,7 @@ def shift_with_mirror(
axis: int, axis: int,
shape: Sequence[int], shape: Sequence[int],
shift_distance: int = 1, shift_distance: int = 1,
) -> sparse.sparray: ) -> sparse.spmatrix:
""" """
Utility operator for performing an n-element shift along a specified axis, with mirror Utility operator for performing an n-element shift along a specified axis, with mirror
boundary conditions applied to the cells beyond the receding edge. boundary conditions applied to the cells beyond the receding edge.
@ -70,11 +69,12 @@ def shift_with_mirror(
Sparse matrix for performing the shift-with-mirror. Sparse matrix for performing the shift-with-mirror.
""" """
if len(shape) not in (2, 3): if len(shape) not in (2, 3):
raise Exception(f'Invalid shape: {shape}') raise Exception('Invalid shape: {}'.format(shape))
if axis not in range(len(shape)): if axis not in range(len(shape)):
raise Exception(f'Invalid direction: {axis}, shape is {shape}') raise Exception('Invalid direction: {}, shape is {}'.format(axis, shape))
if shift_distance >= shape[axis]: if shift_distance >= shape[axis]:
raise Exception(f'Shift ({shift_distance}) is too large for axis {axis} of size {shape[axis]}') raise Exception('Shift ({}) is too large for axis {} of size {}'.format(
shift_distance, axis, shape[axis]))
def mirrored_range(n: int, s: int) -> NDArray[numpy.int_]: def mirrored_range(n: int, s: int) -> NDArray[numpy.int_]:
v = numpy.arange(n) + s v = numpy.arange(n) + s
@ -82,8 +82,8 @@ def shift_with_mirror(
v = numpy.where(v < 0, - 1 - v, v) v = numpy.where(v < 0, - 1 - v, v)
return v return v
shifts = [shift_distance if a == axis else 0 for a in range(len(shape))] shifts = [shift_distance if a == axis else 0 for a in range(3)]
shifted_diags = [mirrored_range(n, s) for n, s in zip(shape, shifts, strict=True)] shifted_diags = [mirrored_range(n, s) for n, s in zip(shape, shifts)]
ijk = numpy.meshgrid(*shifted_diags, indexing='ij') ijk = numpy.meshgrid(*shifted_diags, indexing='ij')
n = numpy.prod(shape) n = numpy.prod(shape)
@ -92,13 +92,13 @@ def shift_with_mirror(
vij = (numpy.ones(n), (i_ind, j_ind.ravel(order='C'))) vij = (numpy.ones(n), (i_ind, j_ind.ravel(order='C')))
d = sparse.csr_array(vij, shape=(n, n)) d = sparse.csr_matrix(vij, shape=(n, n))
return d return d
def deriv_forward( def deriv_forward(
dx_e: Sequence[NDArray[floating | complexfloating]], dx_e: Sequence[NDArray[numpy.float_]],
) -> list[sparse.sparray]: ) -> list[sparse.spmatrix]:
""" """
Utility operators for taking discretized derivatives (forward variant). Utility operators for taking discretized derivatives (forward variant).
@ -114,18 +114,18 @@ def deriv_forward(
dx_e_expanded = numpy.meshgrid(*dx_e, indexing='ij') dx_e_expanded = numpy.meshgrid(*dx_e, indexing='ij')
def deriv(axis: int) -> sparse.sparray: def deriv(axis: int) -> sparse.spmatrix:
return shift_circ(axis, shape, 1) - sparse.eye_array(n) return shift_circ(axis, shape, 1) - sparse.eye(n)
Ds = [sparse.diags_array(+1 / dx.ravel(order='C')) @ deriv(a) Ds = [sparse.diags(+1 / dx.ravel(order='C')) @ deriv(a)
for a, dx in enumerate(dx_e_expanded)] for a, dx in enumerate(dx_e_expanded)]
return Ds return Ds
def deriv_back( def deriv_back(
dx_h: Sequence[NDArray[floating | complexfloating]], dx_h: Sequence[NDArray[numpy.float_]],
) -> list[sparse.sparray]: ) -> list[sparse.spmatrix]:
""" """
Utility operators for taking discretized derivatives (backward variant). Utility operators for taking discretized derivatives (backward variant).
@ -141,37 +141,36 @@ def deriv_back(
dx_h_expanded = numpy.meshgrid(*dx_h, indexing='ij') dx_h_expanded = numpy.meshgrid(*dx_h, indexing='ij')
def deriv(axis: int) -> sparse.sparray: def deriv(axis: int) -> sparse.spmatrix:
return shift_circ(axis, shape, -1) - sparse.eye_array(n) return shift_circ(axis, shape, -1) - sparse.eye(n)
Ds = [sparse.diags_array(-1 / dx.ravel(order='C')) @ deriv(a) Ds = [sparse.diags(-1 / dx.ravel(order='C')) @ deriv(a)
for a, dx in enumerate(dx_h_expanded)] for a, dx in enumerate(dx_h_expanded)]
return Ds return Ds
def cross( def cross(
B: Sequence[sparse.sparray], B: Sequence[sparse.spmatrix],
) -> sparse.sparray: ) -> sparse.spmatrix:
""" """
Cross product operator Cross product operator
Args: Args:
B: List `[Bx, By, Bz]` of sparse matrices corresponding to the x, y, z B: List `[Bx, By, Bz]` of sparse matrices corresponding to the x, y, z
portions of the operator on the left side of the cross product. portions of the operator on the left side of the cross product.
Returns: Returns:
Sparse matrix corresponding to (B x), where x is the cross product. Sparse matrix corresponding to (B x), where x is the cross product.
""" """
n = B[0].shape[0] n = B[0].shape[0]
zero = sparse.csr_array((n, n)) zero = sparse.csr_matrix((n, n))
return sparse.block_array([ return sparse.bmat([[zero, -B[2], B[1]],
[zero, -B[2], B[1]], [B[2], zero, -B[0]],
[B[2], zero, -B[0]], [-B[1], B[0], zero]])
[-B[1], B[0], zero]])
def vec_cross(b: vfdfield) -> sparse.sparray: def vec_cross(b: vfdfield_t) -> sparse.spmatrix:
""" """
Vector cross product operator Vector cross product operator
@ -183,11 +182,11 @@ def vec_cross(b: vfdfield) -> sparse.sparray:
Sparse matrix corresponding to (b x), where x is the cross product. Sparse matrix corresponding to (b x), where x is the cross product.
""" """
B = [sparse.diags_array(c) for c in numpy.split(b, 3)] B = [sparse.diags(c) for c in numpy.split(b, 3)]
return cross(B) return cross(B)
def avg_forward(axis: int, shape: Sequence[int]) -> sparse.sparray: def avg_forward(axis: int, shape: Sequence[int]) -> sparse.spmatrix:
""" """
Forward average operator `(x4 = (x4 + x5) / 2)` Forward average operator `(x4 = (x4 + x5) / 2)`
@ -199,13 +198,13 @@ def avg_forward(axis: int, shape: Sequence[int]) -> sparse.sparray:
Sparse matrix for forward average operation. Sparse matrix for forward average operation.
""" """
if len(shape) not in (2, 3): if len(shape) not in (2, 3):
raise Exception(f'Invalid shape: {shape}') raise Exception('Invalid shape: {}'.format(shape))
n = numpy.prod(shape) n = numpy.prod(shape)
return 0.5 * (sparse.eye_array(n) + shift_circ(axis, shape)) return 0.5 * (sparse.eye(n) + shift_circ(axis, shape))
def avg_back(axis: int, shape: Sequence[int]) -> sparse.sparray: def avg_back(axis: int, shape: Sequence[int]) -> sparse.spmatrix:
""" """
Backward average operator `(x4 = (x4 + x3) / 2)` Backward average operator `(x4 = (x4 + x3) / 2)`
@ -220,8 +219,8 @@ def avg_back(axis: int, shape: Sequence[int]) -> sparse.sparray:
def curl_forward( def curl_forward(
dx_e: Sequence[NDArray[floating | complexfloating]], dx_e: Sequence[NDArray[numpy.float_]],
) -> sparse.sparray: ) -> sparse.spmatrix:
""" """
Curl operator for use with the E field. Curl operator for use with the E field.
@ -236,8 +235,8 @@ def curl_forward(
def curl_back( def curl_back(
dx_h: Sequence[NDArray[floating | complexfloating]], dx_h: Sequence[NDArray[numpy.float_]],
) -> sparse.sparray: ) -> sparse.spmatrix:
""" """
Curl operator for use with the H field. Curl operator for use with the H field.

View file

@ -1,65 +1,26 @@
""" """
Types shared across multiple submodules Types shared across multiple submodules
""" """
from typing import NewType from typing import Sequence, Callable, MutableSequence
from collections.abc import Sequence, Callable, MutableSequence import numpy
from numpy.typing import NDArray from numpy.typing import NDArray
from numpy import floating, complexfloating
# Field types # Field types
fdfield_t = NewType('fdfield_t', NDArray[floating]) fdfield_t = NDArray[numpy.float_]
type fdfield = fdfield_t | NDArray[floating]
"""Vector field with shape (3, X, Y, Z) (e.g. `[E_x, E_y, E_z]`)""" """Vector field with shape (3, X, Y, Z) (e.g. `[E_x, E_y, E_z]`)"""
vfdfield_t = NewType('vfdfield_t', NDArray[floating]) vfdfield_t = NDArray[numpy.float_]
type vfdfield = vfdfield_t | NDArray[floating]
"""Linearized vector field (single vector of length 3*X*Y*Z)""" """Linearized vector field (single vector of length 3*X*Y*Z)"""
cfdfield_t = NewType('cfdfield_t', NDArray[complexfloating]) cfdfield_t = NDArray[numpy.complex_]
type cfdfield = cfdfield_t | NDArray[complexfloating]
"""Complex vector field with shape (3, X, Y, Z) (e.g. `[E_x, E_y, E_z]`)""" """Complex vector field with shape (3, X, Y, Z) (e.g. `[E_x, E_y, E_z]`)"""
vcfdfield_t = NewType('vcfdfield_t', NDArray[complexfloating]) vcfdfield_t = NDArray[numpy.complex_]
type vcfdfield = vcfdfield_t | NDArray[complexfloating]
"""Linearized complex vector field (single vector of length 3*X*Y*Z)""" """Linearized complex vector field (single vector of length 3*X*Y*Z)"""
fdslice_t = NewType('fdslice_t', NDArray[floating]) dx_lists_t = Sequence[Sequence[NDArray[numpy.float_]]]
type fdslice = fdslice_t | NDArray[floating]
"""Vector field slice with shape (3, X, Y) (e.g. `[E_x, E_y, E_z]` at a single Z position)"""
vfdslice_t = NewType('vfdslice_t', NDArray[floating])
type vfdslice = vfdslice_t | NDArray[floating]
"""Linearized vector field slice (single vector of length 3*X*Y)"""
cfdslice_t = NewType('cfdslice_t', NDArray[complexfloating])
type cfdslice = cfdslice_t | NDArray[complexfloating]
"""Complex vector field slice with shape (3, X, Y) (e.g. `[E_x, E_y, E_z]` at a single Z position)"""
vcfdslice_t = NewType('vcfdslice_t', NDArray[complexfloating])
type vcfdslice = vcfdslice_t | NDArray[complexfloating]
"""Linearized complex vector field slice (single vector of length 3*X*Y)"""
fdfield2_t = NewType('fdfield2_t', NDArray[floating])
type fdfield2 = fdfield2_t | NDArray[floating]
"""2D Vector field with shape (2, X, Y) (e.g. `[E_x, E_y]`)"""
vfdfield2_t = NewType('vfdfield2_t', NDArray[floating])
type vfdfield2 = vfdfield2_t | NDArray[floating]
"""2D Linearized vector field (single vector of length 2*X*Y)"""
cfdfield2_t = NewType('cfdfield2_t', NDArray[complexfloating])
type cfdfield2 = cfdfield2_t | NDArray[complexfloating]
"""2D Complex vector field with shape (2, X, Y) (e.g. `[E_x, E_y]`)"""
vcfdfield2_t = NewType('vcfdfield2_t', NDArray[complexfloating])
type vcfdfield2 = vcfdfield2_t | NDArray[complexfloating]
"""2D Linearized complex vector field (single vector of length 2*X*Y)"""
dx_lists_t = Sequence[Sequence[NDArray[floating | complexfloating]]]
""" """
'dxes' datastructure which contains grid cell width information in the following format: 'dxes' datastructure which contains grid cell width information in the following format:
@ -70,26 +31,12 @@ dx_lists_t = Sequence[Sequence[NDArray[floating | complexfloating]]]
and `dy_h[0]` is the y-width of the `y=0` cells, as used when calculating dH/dy, etc. and `dy_h[0]` is the y-width of the `y=0` cells, as used when calculating dH/dy, etc.
""" """
dx_lists2_t = Sequence[Sequence[NDArray[floating | complexfloating]]] dx_lists_mut = MutableSequence[MutableSequence[NDArray[numpy.float_]]]
"""
2D 'dxes' datastructure which contains grid cell width information in the following format:
[[[dx_e[0], dx_e[1], ...], [dy_e[0], ...]],
[[dx_h[0], dx_h[1], ...], [dy_h[0], ...]]]
where `dx_e[0]` is the x-width of the `x=0` cells, as used when calculating dE/dx,
and `dy_h[0]` is the y-width of the `y=0` cells, as used when calculating dH/dy, etc.
"""
dx_lists_mut = MutableSequence[MutableSequence[NDArray[floating | complexfloating]]]
"""Mutable version of `dx_lists_t`""" """Mutable version of `dx_lists_t`"""
dx_lists2_mut = MutableSequence[MutableSequence[NDArray[floating | complexfloating]]]
"""Mutable version of `dx_lists2_t`"""
fdfield_updater_t = Callable[..., fdfield_t]
"""Convenience type for functions which take and return an fdfield_t"""
fdfield_updater_t = Callable[..., fdfield] cfdfield_updater_t = Callable[..., cfdfield_t]
"""Convenience type for functions which take and return a real `fdfield`""" """Convenience type for functions which take and return an cfdfield_t"""
cfdfield_updater_t = Callable[..., cfdfield]
"""Convenience type for functions which take and return a complex `cfdfield`"""

View file

@ -4,122 +4,75 @@ and a 1D array representation of that field `[f_x0, f_x1, f_x2,... f_y0,... f_z0
Vectorized versions of the field use row-major (ie., C-style) ordering. Vectorized versions of the field use row-major (ie., C-style) ordering.
""" """
from typing import overload from typing import overload, Sequence
from collections.abc import Sequence
import numpy import numpy
from numpy.typing import ArrayLike, NDArray from numpy.typing import ArrayLike
from .types import ( from .types import fdfield_t, vfdfield_t, cfdfield_t, vcfdfield_t
fdfield_t, vfdfield_t, cfdfield_t, vcfdfield_t,
fdslice_t, vfdslice_t, cfdslice_t, vcfdslice_t,
fdfield2_t, vfdfield2_t, cfdfield2_t, vcfdfield2_t,
)
@overload @overload
def vec(f: None) -> None: def vec(f: None) -> None:
pass # pragma: no cover pass
@overload @overload
def vec(f: fdfield_t) -> vfdfield_t: def vec(f: fdfield_t) -> vfdfield_t:
pass # pragma: no cover pass
@overload @overload
def vec(f: cfdfield_t) -> vcfdfield_t: def vec(f: cfdfield_t) -> vcfdfield_t:
pass # pragma: no cover pass
@overload @overload
def vec(f: fdfield2_t) -> vfdfield2_t: def vec(f: ArrayLike) -> vfdfield_t | vcfdfield_t:
pass # pragma: no cover pass
@overload def vec(f: fdfield_t | cfdfield_t | ArrayLike | None) -> vfdfield_t | vcfdfield_t | None:
def vec(f: cfdfield2_t) -> vcfdfield2_t:
pass # pragma: no cover
@overload
def vec(f: fdslice_t) -> vfdslice_t:
pass # pragma: no cover
@overload
def vec(f: cfdslice_t) -> vcfdslice_t:
pass # pragma: no cover
@overload
def vec(f: ArrayLike) -> NDArray:
pass # pragma: no cover
def vec(
f: fdfield_t | cfdfield_t | fdfield2_t | cfdfield2_t | fdslice_t | cfdslice_t | ArrayLike | None,
) -> vfdfield_t | vcfdfield_t | vfdfield2_t | vcfdfield2_t | vfdslice_t | vcfdslice_t | NDArray | None:
""" """
Create a 1D ndarray from a vector field which spans a 1-3D region. Create a 1D ndarray from a 3D vector field which spans a 1-3D region.
Returns `None` if called with `f=None`. Returns `None` if called with `f=None`.
Args: Args:
f: A vector field, e.g. `[f_x, f_y, f_z]` where each `f_` component is a 1- to f: A vector field, `[f_x, f_y, f_z]` where each `f_` component is a 1- to
3-D ndarray (`f_*` should all be the same size). Doesn't fail with `f=None`. 3-D ndarray (`f_*` should all be the same size). Doesn't fail with `f=None`.
Returns: Returns:
1D ndarray containing the linearized field (or `None`) 1D ndarray containing the linearized field (or `None`)
""" """
if f is None: if f is None:
return None return None
return numpy.ravel(f, order='C') # type: ignore return numpy.ravel(f, order='C')
@overload @overload
def unvec(v: None, shape: Sequence[int], nvdim: int = 3) -> None: def unvec(v: None, shape: Sequence[int]) -> None:
pass # pragma: no cover
@overload
def unvec(v: vfdfield_t, shape: Sequence[int], nvdim: int = 3) -> fdfield_t:
pass # pragma: no cover
@overload
def unvec(v: vcfdfield_t, shape: Sequence[int], nvdim: int = 3) -> cfdfield_t:
pass # pragma: no cover
@overload
def unvec(v: vfdfield2_t, shape: Sequence[int], nvdim: int = 3) -> fdfield2_t:
pass pass
@overload @overload
def unvec(v: vcfdfield2_t, shape: Sequence[int], nvdim: int = 3) -> cfdfield2_t: def unvec(v: vfdfield_t, shape: Sequence[int]) -> fdfield_t:
pass pass
@overload @overload
def unvec(v: vfdslice_t, shape: Sequence[int], nvdim: int = 3) -> fdslice_t: def unvec(v: vcfdfield_t, shape: Sequence[int]) -> cfdfield_t:
pass pass
@overload def unvec(v: vfdfield_t | vcfdfield_t | None, shape: Sequence[int]) -> fdfield_t | cfdfield_t | None:
def unvec(v: vcfdslice_t, shape: Sequence[int], nvdim: int = 3) -> cfdslice_t:
pass
@overload
def unvec(v: ArrayLike, shape: Sequence[int], nvdim: int = 3) -> NDArray:
pass
def unvec(
v: vfdfield_t | vcfdfield_t | vfdfield2_t | vcfdfield2_t | vfdslice_t | vcfdslice_t | ArrayLike | None,
shape: Sequence[int],
nvdim: int = 3,
) -> fdfield_t | cfdfield_t | fdfield2_t | cfdfield2_t | fdslice_t | cfdslice_t | NDArray | None:
""" """
Perform the inverse of vec(): take a 1D ndarray and output an `nvdim`-component field Perform the inverse of vec(): take a 1D ndarray and output a 3D field
of form e.g. `[f_x, f_y, f_z]` (`nvdim=3`) where each of `f_*` is a len(shape)-dimensional of form `[f_x, f_y, f_z]` where each of `f_*` is a len(shape)-dimensional
ndarray. ndarray.
Returns `None` if called with `v=None`. Returns `None` if called with `v=None`.
Args: Args:
v: 1D ndarray representing a vector field of shape shape (or None) v: 1D ndarray representing a 3D vector field of shape shape (or None)
shape: shape of the vector field shape: shape of the vector field
nvdim: Number of components in each vector
Returns: Returns:
`[f_x, f_y, f_z]` where each `f_` is a `len(shape)` dimensional ndarray (or `None`) `[f_x, f_y, f_z]` where each `f_` is a `len(shape)` dimensional ndarray (or `None`)
""" """
if v is None: if v is None:
return None return None
return v.reshape((nvdim, *shape), order='C') # type: ignore return v.reshape((3, *shape), order='C')

View file

@ -1,4 +1,4 @@
r""" """
Utilities for running finite-difference time-domain (FDTD) simulations Utilities for running finite-difference time-domain (FDTD) simulations
See the discussion of `Maxwell's Equations` in `meanas.fdmath` for basic See the discussion of `Maxwell's Equations` in `meanas.fdmath` for basic
@ -11,9 +11,9 @@ Timestep
From the discussion of "Plane waves and the Dispersion relation" in `meanas.fdmath`, From the discussion of "Plane waves and the Dispersion relation" in `meanas.fdmath`,
we have we have
$$ c^2 \Delta_t^2 = \frac{\Delta_t^2}{\mu \epsilon} < 1/(\frac{1}{\Delta_x^2} + \frac{1}{\Delta_y^2} + \frac{1}{\Delta_z^2}) $$ $$ c^2 \\Delta_t^2 = \\frac{\\Delta_t^2}{\\mu \\epsilon} < 1/(\\frac{1}{\\Delta_x^2} + \\frac{1}{\\Delta_y^2} + \\frac{1}{\\Delta_z^2}) $$
or, if $\Delta_x = \Delta_y = \Delta_z$, then $c \Delta_t < \frac{\Delta_x}{\sqrt{3}}$. or, if $\\Delta_x = \\Delta_y = \\Delta_z$, then $c \\Delta_t < \\frac{\\Delta_x}{\\sqrt{3}}$.
Based on this, we can set Based on this, we can set
@ -27,81 +27,81 @@ Poynting Vector and Energy Conservation
Let Let
$$ \begin{aligned} $$ \\begin{aligned}
\tilde{S}_{l, l', \vec{r}} &=& &\tilde{E}_{l, \vec{r}} \otimes \hat{H}_{l', \vec{r} + \frac{1}{2}} \\ \\tilde{S}_{l, l', \\vec{r}} &=& &\\tilde{E}_{l, \\vec{r}} \\otimes \\hat{H}_{l', \\vec{r} + \\frac{1}{2}} \\\\
&=& &\vec{x} (\tilde{E}^y_{l,m+1,n,p} \hat{H}^z_{l',\vec{r} + \frac{1}{2}} - \tilde{E}^z_{l,m+1,n,p} \hat{H}^y_{l', \vec{r} + \frac{1}{2}}) \\ &=& &\\vec{x} (\\tilde{E}^y_{l,m+1,n,p} \\hat{H}^z_{l',\\vec{r} + \\frac{1}{2}} - \\tilde{E}^z_{l,m+1,n,p} \\hat{H}^y_{l', \\vec{r} + \\frac{1}{2}}) \\\\
& &+ &\vec{y} (\tilde{E}^z_{l,m,n+1,p} \hat{H}^x_{l',\vec{r} + \frac{1}{2}} - \tilde{E}^x_{l,m,n+1,p} \hat{H}^z_{l', \vec{r} + \frac{1}{2}}) \\ & &+ &\\vec{y} (\\tilde{E}^z_{l,m,n+1,p} \\hat{H}^x_{l',\\vec{r} + \\frac{1}{2}} - \\tilde{E}^x_{l,m,n+1,p} \\hat{H}^z_{l', \\vec{r} + \\frac{1}{2}}) \\\\
& &+ &\vec{z} (\tilde{E}^x_{l,m,n,p+1} \hat{H}^y_{l',\vec{r} + \frac{1}{2}} - \tilde{E}^y_{l,m,n,p+1} \hat{H}^z_{l', \vec{r} + \frac{1}{2}}) & &+ &\\vec{z} (\\tilde{E}^x_{l,m,n,p+1} \\hat{H}^y_{l',\\vec{r} + \\frac{1}{2}} - \\tilde{E}^y_{l,m,n,p+1} \\hat{H}^z_{l', \\vec{r} + \\frac{1}{2}})
\end{aligned} \\end{aligned}
$$ $$
where $\vec{r} = (m, n, p)$ and $\otimes$ is a modified cross product where $\\vec{r} = (m, n, p)$ and $\\otimes$ is a modified cross product
in which the $\tilde{E}$ terms are shifted as indicated. in which the $\\tilde{E}$ terms are shifted as indicated.
By taking the divergence and rearranging terms, we can show that By taking the divergence and rearranging terms, we can show that
$$ $$
\begin{aligned} \\begin{aligned}
\hat{\nabla} \cdot \tilde{S}_{l, l', \vec{r}} \\hat{\\nabla} \\cdot \\tilde{S}_{l, l', \\vec{r}}
&= \hat{\nabla} \cdot (\tilde{E}_{l, \vec{r}} \otimes \hat{H}_{l', \vec{r} + \frac{1}{2}}) \\ &= \\hat{\\nabla} \\cdot (\\tilde{E}_{l, \\vec{r}} \\otimes \\hat{H}_{l', \\vec{r} + \\frac{1}{2}}) \\\\
&= \hat{H}_{l', \vec{r} + \frac{1}{2}} \cdot \tilde{\nabla} \times \tilde{E}_{l, \vec{r}} - &= \\hat{H}_{l', \\vec{r} + \\frac{1}{2}} \\cdot \\tilde{\\nabla} \\times \\tilde{E}_{l, \\vec{r}} -
\tilde{E}_{l, \vec{r}} \cdot \hat{\nabla} \times \hat{H}_{l', \vec{r} + \frac{1}{2}} \\ \\tilde{E}_{l, \\vec{r}} \\cdot \\hat{\\nabla} \\times \\hat{H}_{l', \\vec{r} + \\frac{1}{2}} \\\\
&= \hat{H}_{l', \vec{r} + \frac{1}{2}} \cdot &= \\hat{H}_{l', \\vec{r} + \\frac{1}{2}} \\cdot
(-\tilde{\partial}_t \mu_{\vec{r} + \frac{1}{2}} \hat{H}_{l - \frac{1}{2}, \vec{r} + \frac{1}{2}} - (-\\tilde{\\partial}_t \\mu_{\\vec{r} + \\frac{1}{2}} \\hat{H}_{l - \\frac{1}{2}, \\vec{r} + \\frac{1}{2}} -
\hat{M}_{l, \vec{r} + \frac{1}{2}}) - \\hat{M}_{l, \\vec{r} + \\frac{1}{2}}) -
\tilde{E}_{l, \vec{r}} \cdot (\hat{\partial}_t \tilde{\epsilon}_{\vec{r}} \tilde{E}_{l'+\frac{1}{2}, \vec{r}} + \\tilde{E}_{l, \\vec{r}} \\cdot (\\hat{\\partial}_t \\tilde{\\epsilon}_{\\vec{r}} \\tilde{E}_{l'+\\frac{1}{2}, \\vec{r}} +
\tilde{J}_{l', \vec{r}}) \\ \\tilde{J}_{l', \\vec{r}}) \\\\
&= \hat{H}_{l'} \cdot (-\mu / \Delta_t)(\hat{H}_{l + \frac{1}{2}} - \hat{H}_{l - \frac{1}{2}}) - &= \\hat{H}_{l'} \\cdot (-\\mu / \\Delta_t)(\\hat{H}_{l + \\frac{1}{2}} - \\hat{H}_{l - \\frac{1}{2}}) -
\tilde{E}_l \cdot (\epsilon / \Delta_t )(\tilde{E}_{l'+\frac{1}{2}} - \tilde{E}_{l'-\frac{1}{2}}) \\tilde{E}_l \\cdot (\\epsilon / \\Delta_t )(\\tilde{E}_{l'+\\frac{1}{2}} - \\tilde{E}_{l'-\\frac{1}{2}})
- \hat{H}_{l'} \cdot \hat{M}_{l} - \tilde{E}_l \cdot \tilde{J}_{l'} \\ - \\hat{H}_{l'} \\cdot \\hat{M}_{l} - \\tilde{E}_l \\cdot \\tilde{J}_{l'} \\\\
\end{aligned} \\end{aligned}
$$ $$
where in the last line the spatial subscripts have been dropped to emphasize where in the last line the spatial subscripts have been dropped to emphasize
the time subscripts $l, l'$, i.e. the time subscripts $l, l'$, i.e.
$$ $$
\begin{aligned} \\begin{aligned}
\tilde{E}_l &= \tilde{E}_{l, \vec{r}} \\ \\tilde{E}_l &= \\tilde{E}_{l, \\vec{r}} \\\\
\hat{H}_l &= \tilde{H}_{l, \vec{r} + \frac{1}{2}} \\ \\hat{H}_l &= \\tilde{H}_{l, \\vec{r} + \\frac{1}{2}} \\\\
\tilde{\epsilon} &= \tilde{\epsilon}_{\vec{r}} \\ \\tilde{\\epsilon} &= \\tilde{\\epsilon}_{\\vec{r}} \\\\
\end{aligned} \\end{aligned}
$$ $$
etc. etc.
For $l' = l + \frac{1}{2}$ we get For $l' = l + \\frac{1}{2}$ we get
$$ $$
\begin{aligned} \\begin{aligned}
\hat{\nabla} \cdot \tilde{S}_{l, l + \frac{1}{2}} \\hat{\\nabla} \\cdot \\tilde{S}_{l, l + \\frac{1}{2}}
&= \hat{H}_{l + \frac{1}{2}} \cdot &= \\hat{H}_{l + \\frac{1}{2}} \\cdot
(-\mu / \Delta_t)(\hat{H}_{l + \frac{1}{2}} - \hat{H}_{l - \frac{1}{2}}) - (-\\mu / \\Delta_t)(\\hat{H}_{l + \\frac{1}{2}} - \\hat{H}_{l - \\frac{1}{2}}) -
\tilde{E}_l \cdot (\epsilon / \Delta_t)(\tilde{E}_{l+1} - \tilde{E}_l) \\tilde{E}_l \\cdot (\\epsilon / \\Delta_t)(\\tilde{E}_{l+1} - \\tilde{E}_l)
- \hat{H}_{l'} \cdot \hat{M}_l - \tilde{E}_l \cdot \tilde{J}_{l + \frac{1}{2}} \\ - \\hat{H}_{l'} \\cdot \\hat{M}_l - \\tilde{E}_l \\cdot \\tilde{J}_{l + \\frac{1}{2}} \\\\
&= (-\mu / \Delta_t)(\hat{H}^2_{l + \frac{1}{2}} - \hat{H}_{l + \frac{1}{2}} \cdot \hat{H}_{l - \frac{1}{2}}) - &= (-\\mu / \\Delta_t)(\\hat{H}^2_{l + \\frac{1}{2}} - \\hat{H}_{l + \\frac{1}{2}} \\cdot \\hat{H}_{l - \\frac{1}{2}}) -
(\epsilon / \Delta_t)(\tilde{E}_{l+1} \cdot \tilde{E}_l - \tilde{E}^2_l) (\\epsilon / \\Delta_t)(\\tilde{E}_{l+1} \\cdot \\tilde{E}_l - \\tilde{E}^2_l)
- \hat{H}_{l'} \cdot \hat{M}_l - \tilde{E}_l \cdot \tilde{J}_{l + \frac{1}{2}} \\ - \\hat{H}_{l'} \\cdot \\hat{M}_l - \\tilde{E}_l \\cdot \\tilde{J}_{l + \\frac{1}{2}} \\\\
&= -(\mu \hat{H}^2_{l + \frac{1}{2}} &= -(\\mu \\hat{H}^2_{l + \\frac{1}{2}}
+\epsilon \tilde{E}_{l+1} \cdot \tilde{E}_l) / \Delta_t \\ +\\epsilon \\tilde{E}_{l+1} \\cdot \\tilde{E}_l) / \\Delta_t \\ \\
+(\mu \hat{H}_{l + \frac{1}{2}} \cdot \hat{H}_{l - \frac{1}{2}} +(\\mu \\hat{H}_{l + \\frac{1}{2}} \\cdot \\hat{H}_{l - \\frac{1}{2}}
+\epsilon \tilde{E}^2_l) / \Delta_t \\ +\\epsilon \\tilde{E}^2_l) / \\Delta_t \\ \\
- \hat{H}_{l+\frac{1}{2}} \cdot \hat{M}_l \\ - \\hat{H}_{l+\\frac{1}{2}} \\cdot \\hat{M}_l \\ \\
- \tilde{E}_l \cdot \tilde{J}_{l+\frac{1}{2}} \\ - \\tilde{E}_l \\cdot \\tilde{J}_{l+\\frac{1}{2}} \\\\
\end{aligned} \\end{aligned}
$$ $$
and for $l' = l - \frac{1}{2}$, and for $l' = l - \\frac{1}{2}$,
$$ $$
\begin{aligned} \\begin{aligned}
\hat{\nabla} \cdot \tilde{S}_{l, l - \frac{1}{2}} \\hat{\\nabla} \\cdot \\tilde{S}_{l, l - \\frac{1}{2}}
&= (\mu \hat{H}^2_{l - \frac{1}{2}} &= (\\mu \\hat{H}^2_{l - \\frac{1}{2}}
+\epsilon \tilde{E}_{l-1} \cdot \tilde{E}_l) / \Delta_t \\ +\\epsilon \\tilde{E}_{l-1} \\cdot \\tilde{E}_l) / \\Delta_t \\ \\
-(\mu \hat{H}_{l + \frac{1}{2}} \cdot \hat{H}_{l - \frac{1}{2}} -(\\mu \\hat{H}_{l + \\frac{1}{2}} \\cdot \\hat{H}_{l - \\frac{1}{2}}
+\epsilon \tilde{E}^2_l) / \Delta_t \\ +\\epsilon \\tilde{E}^2_l) / \\Delta_t \\ \\
- \hat{H}_{l-\frac{1}{2}} \cdot \hat{M}_l \\ - \\hat{H}_{l-\\frac{1}{2}} \\cdot \\hat{M}_l \\ \\
- \tilde{E}_l \cdot \tilde{J}_{l-\frac{1}{2}} \\ - \\tilde{E}_l \\cdot \\tilde{J}_{l-\\frac{1}{2}} \\\\
\end{aligned} \\end{aligned}
$$ $$
These two results form the discrete time-domain analogue to Poynting's theorem. These two results form the discrete time-domain analogue to Poynting's theorem.
@ -109,25 +109,25 @@ They hint at the expressions for the energy, which can be calculated at the same
time-index as either the E or H field: time-index as either the E or H field:
$$ $$
\begin{aligned} \\begin{aligned}
U_l &= \epsilon \tilde{E}^2_l + \mu \hat{H}_{l + \frac{1}{2}} \cdot \hat{H}_{l - \frac{1}{2}} \\ U_l &= \\epsilon \\tilde{E}^2_l + \\mu \\hat{H}_{l + \\frac{1}{2}} \\cdot \\hat{H}_{l - \\frac{1}{2}} \\\\
U_{l + \frac{1}{2}} &= \epsilon \tilde{E}_l \cdot \tilde{E}_{l + 1} + \mu \hat{H}^2_{l + \frac{1}{2}} \\ U_{l + \\frac{1}{2}} &= \\epsilon \\tilde{E}_l \\cdot \\tilde{E}_{l + 1} + \\mu \\hat{H}^2_{l + \\frac{1}{2}} \\\\
\end{aligned} \\end{aligned}
$$ $$
Rewriting the Poynting theorem in terms of the energy expressions, Rewriting the Poynting theorem in terms of the energy expressions,
$$ $$
\begin{aligned} \\begin{aligned}
(U_{l+\frac{1}{2}} - U_l) / \Delta_t (U_{l+\\frac{1}{2}} - U_l) / \\Delta_t
&= -\hat{\nabla} \cdot \tilde{S}_{l, l + \frac{1}{2}} \\ &= -\\hat{\\nabla} \\cdot \\tilde{S}_{l, l + \\frac{1}{2}} \\ \\
- \hat{H}_{l+\frac{1}{2}} \cdot \hat{M}_l \\ - \\hat{H}_{l+\\frac{1}{2}} \\cdot \\hat{M}_l \\ \\
- \tilde{E}_l \cdot \tilde{J}_{l+\frac{1}{2}} \\ - \\tilde{E}_l \\cdot \\tilde{J}_{l+\\frac{1}{2}} \\\\
(U_l - U_{l-\frac{1}{2}}) / \Delta_t (U_l - U_{l-\\frac{1}{2}}) / \\Delta_t
&= -\hat{\nabla} \cdot \tilde{S}_{l, l - \frac{1}{2}} \\ &= -\\hat{\\nabla} \\cdot \\tilde{S}_{l, l - \\frac{1}{2}} \\ \\
- \hat{H}_{l-\frac{1}{2}} \cdot \hat{M}_l \\ - \\hat{H}_{l-\\frac{1}{2}} \\cdot \\hat{M}_l \\ \\
- \tilde{E}_l \cdot \tilde{J}_{l-\frac{1}{2}} \\ - \\tilde{E}_l \\cdot \\tilde{J}_{l-\\frac{1}{2}} \\\\
\end{aligned} \\end{aligned}
$$ $$
This result is exact and should practically hold to within numerical precision. No time- This result is exact and should practically hold to within numerical precision. No time-
@ -144,131 +144,23 @@ It is often useful to excite the simulation with an arbitrary broadband pulse an
extract the frequency-domain response by performing an on-the-fly Fourier transform extract the frequency-domain response by performing an on-the-fly Fourier transform
of the time-domain fields. of the time-domain fields.
`accumulate_phasor` in `meanas.fdtd.phasor` performs the phase accumulation for one
or more target frequencies, while leaving source normalization and simulation-loop
policy to the caller. `temporal_phasor(...)` and `temporal_phasor_scale(...)`
apply the same Fourier sum to a scalar waveform, which is useful when a pulsed
source envelope must be normalized before being applied to a point source or
mode source. `real_injection_scale(...)` is the corresponding helper for the
common real-valued injection pattern `numpy.real(scale * waveform)`. Convenience
wrappers `accumulate_phasor_e`, `accumulate_phasor_h`, and `accumulate_phasor_j`
apply the usual Yee time offsets. `reconstruct_real(...)` and the corresponding
`reconstruct_real_e/h/j(...)` wrappers perform the inverse operation, converting
phasors back into real-valued field snapshots at explicit Yee-aligned times.
For scalar `omega`, the reconstruction helpers accept either a plain field
phasor or the batched `(1, *sample_shape)` form used by the accumulator helpers.
The helpers accumulate
$$ \Delta_t \sum_l w_l e^{-i \omega t_l} f_l $$
with caller-provided sample times and weights. In this codebase the matching
electric-current convention is typically `E -= dt * J / epsilon` in FDTD and
`-i \omega J` on the right-hand side of the FDFD wave equation.
For FDTD/FDFD equivalence, this phasor path is the primary comparison workflow.
It isolates the guided `+\omega` response that the frequency-domain solver
targets directly, regardless of whether the underlying FDTD run used real- or
complex-valued fields.
For exact pulsed FDTD/FDFD equivalence it is often simplest to keep the
injected source, fields, and CPML auxiliary state complex-valued. The
`real_injection_scale(...)` helper is instead for the more ordinary one-run
real-valued source path, where the intended positive-frequency waveform is
injected through `numpy.real(scale * waveform)` and any remaining negative-
frequency leakage is controlled by the pulse bandwidth and run length.
`reconstruct_real(...)` is for a different question: given a phasor, what late
real-valued field snapshot should it produce? That raw-snapshot comparison is
stricter and noisier because a monitor plane generally contains both the guided
field and the remaining orthogonal content,
$$ E_{\text{monitor}} = E_{\text{guided}} + E_{\text{residual}} . $$
Phasor/modal comparisons mostly validate the guided `+\omega` term. Raw
real-field comparisons expose both terms at once, so they should be treated as
secondary diagnostics rather than the main solver-equivalence benchmark.
The Ricker wavelet (normalized second derivative of a Gaussian) is commonly used for the pulse The Ricker wavelet (normalized second derivative of a Gaussian) is commonly used for the pulse
shape. It can be written shape. It can be written
$$ f_r(t) = (1 - \frac{1}{2} (\omega (t - \tau))^2) e^{-(\frac{\omega (t - \tau)}{2})^2} $$ $$ f_r(t) = (1 - \\frac{1}{2} (\\omega (t - \\tau))^2) e^{-(\\frac{\\omega (t - \\tau)}{2})^2} $$
with $\tau > \frac{2 * \pi}{\omega}$ as a minimum delay to avoid a discontinuity at with $\\tau > \\frac{2 * \\pi}{\\omega}$ as a minimum delay to avoid a discontinuity at
t=0 (assuming the source is off for t<0 this gives $\sim 10^{-3}$ error at t=0). t=0 (assuming the source is off for t<0 this gives $\\sim 10^{-3}$ error at t=0).
Boundary conditions Boundary conditions
=================== ===================
# TODO notes about boundaries / PMLs
`meanas.fdtd` exposes two boundary-related building blocks:
- `conducting_boundary(...)` for simple perfect-electric-conductor style field
clamping at one face of the domain.
- `cpml_params(...)` and `updates_with_cpml(...)` for convolutional perfectly
matched layers (CPMLs) attached to one or more faces of the Yee grid.
`updates_with_cpml(...)` accepts a three-by-two table of CPML parameter blocks:
```
cpml_params[axis][polarity_index]
```
where `axis` is `0`, `1`, or `2` and `polarity_index` corresponds to `(-1, +1)`.
Passing `None` for one entry disables CPML on that face while leaving the other
directions unchanged. This is how mixed boundary setups such as "absorbing in x,
periodic in y/z" are expressed.
When comparing an FDTD run against an FDFD solve, use the same stretched
coordinate system in both places:
1. Build the FDTD update with the desired CPML parameters.
2. Stretch the FDFD `dxes` with the matching SCPML transform.
3. Compare the extracted phasor against the FDFD field or residual on those
stretched `dxes`.
The electric-current sign convention used throughout the examples and tests is
$$
E \leftarrow E - \Delta_t J / \epsilon
$$
which matches the FDFD right-hand side
$$
-i \omega J.
$$
""" """
from .base import ( from .base import maxwell_e, maxwell_h
maxwell_e as maxwell_e, from .pml import cpml_params, updates_with_cpml
maxwell_h as maxwell_h, from .energy import (poynting, poynting_divergence, energy_hstep, energy_estep,
) delta_energy_h2e, delta_energy_j)
from .pml import ( from .boundaries import conducting_boundary
cpml_params as cpml_params,
updates_with_cpml as updates_with_cpml,
)
from .energy import (
poynting as poynting,
poynting_divergence as poynting_divergence,
energy_hstep as energy_hstep,
energy_estep as energy_estep,
delta_energy_h2e as delta_energy_h2e,
delta_energy_j as delta_energy_j,
)
from .boundaries import (
conducting_boundary as conducting_boundary,
)
from .phasor import (
accumulate_phasor as accumulate_phasor,
accumulate_phasor_e as accumulate_phasor_e,
accumulate_phasor_h as accumulate_phasor_h,
accumulate_phasor_j as accumulate_phasor_j,
real_injection_scale as real_injection_scale,
reconstruct_real as reconstruct_real,
reconstruct_real_e as reconstruct_real_e,
reconstruct_real_h as reconstruct_real_h,
reconstruct_real_j as reconstruct_real_j,
temporal_phasor as temporal_phasor,
temporal_phasor_scale as temporal_phasor_scale,
)

View file

@ -3,7 +3,7 @@ Basic FDTD field updates
""" """
from ..fdmath import dx_lists_t, fdfield, fdfield_updater_t from ..fdmath import dx_lists_t, fdfield_t, fdfield_updater_t
from ..fdmath.functional import curl_forward, curl_back from ..fdmath.functional import curl_forward, curl_back
@ -47,7 +47,7 @@ def maxwell_e(
else: else:
curl_h_fun = curl_back() curl_h_fun = curl_back()
def me_fun(e: fdfield, h: fdfield, epsilon: fdfield | float) -> fdfield: def me_fun(e: fdfield_t, h: fdfield_t, epsilon: fdfield_t | float) -> fdfield_t:
""" """
Update the E-field. Update the E-field.
@ -103,7 +103,7 @@ def maxwell_h(
else: else:
curl_e_fun = curl_forward() curl_e_fun = curl_forward()
def mh_fun(e: fdfield, h: fdfield, mu: fdfield | float | None = None) -> fdfield: def mh_fun(e: fdfield_t, h: fdfield_t, mu: fdfield_t | float | None = None) -> fdfield_t:
""" """
Update the H-field. Update the H-field.

View file

@ -6,7 +6,7 @@ Boundary conditions
from typing import Any from typing import Any
from ..fdmath import fdfield, fdfield_updater_t from ..fdmath import fdfield_t, fdfield_updater_t
def conducting_boundary( def conducting_boundary(
@ -15,32 +15,26 @@ def conducting_boundary(
) -> tuple[fdfield_updater_t, fdfield_updater_t]: ) -> tuple[fdfield_updater_t, fdfield_updater_t]:
dirs = [0, 1, 2] dirs = [0, 1, 2]
if direction not in dirs: if direction not in dirs:
raise ValueError(f'Invalid direction: {direction}') raise Exception('Invalid direction: {}'.format(direction))
dirs.remove(direction) dirs.remove(direction)
u, v = dirs u, v = dirs
boundary_slice: list[Any]
shifted1_slice: list[Any]
shifted2_slice: list[Any]
if polarity < 0: if polarity < 0:
boundary_slice = [slice(None)] * 3 boundary_slice = [slice(None)] * 3 # type: list[Any]
shifted1_slice = [slice(None)] * 3 shifted1_slice = [slice(None)] * 3 # type: list[Any]
boundary_slice[direction] = 0 boundary_slice[direction] = 0
shifted1_slice[direction] = 1 shifted1_slice[direction] = 1
boundary = tuple(boundary_slice)
shifted1 = tuple(shifted1_slice)
def en(e: fdfield) -> fdfield: def en(e: fdfield_t) -> fdfield_t:
e[direction][boundary] = 0 e[direction][boundary_slice] = 0
e[u][boundary] = e[u][shifted1] e[u][boundary_slice] = e[u][shifted1_slice]
e[v][boundary] = e[v][shifted1] e[v][boundary_slice] = e[v][shifted1_slice]
return e return e
def hn(h: fdfield) -> fdfield: def hn(h: fdfield_t) -> fdfield_t:
h[direction][boundary] = h[direction][shifted1] h[direction][boundary_slice] = h[direction][shifted1_slice]
h[u][boundary] = 0 h[u][boundary_slice] = 0
h[v][boundary] = 0 h[v][boundary_slice] = 0
return h return h
return en, hn return en, hn
@ -48,29 +42,26 @@ def conducting_boundary(
if polarity > 0: if polarity > 0:
boundary_slice = [slice(None)] * 3 boundary_slice = [slice(None)] * 3
shifted1_slice = [slice(None)] * 3 shifted1_slice = [slice(None)] * 3
shifted2_slice = [slice(None)] * 3 shifted2_slice = [slice(None)] * 3 # type: list[Any]
boundary_slice[direction] = -1 boundary_slice[direction] = -1
shifted1_slice[direction] = -2 shifted1_slice[direction] = -2
shifted2_slice[direction] = -3 shifted2_slice[direction] = -3
boundary = tuple(boundary_slice)
shifted1 = tuple(shifted1_slice)
shifted2 = tuple(shifted2_slice)
def ep(e: fdfield) -> fdfield: def ep(e: fdfield_t) -> fdfield_t:
e[direction][boundary] = -e[direction][shifted2] e[direction][boundary_slice] = -e[direction][shifted2_slice]
e[direction][shifted1] = 0 e[direction][shifted1_slice] = 0
e[u][boundary] = e[u][shifted1] e[u][boundary_slice] = e[u][shifted1_slice]
e[v][boundary] = e[v][shifted1] e[v][boundary_slice] = e[v][shifted1_slice]
return e return e
def hp(h: fdfield) -> fdfield: def hp(h: fdfield_t) -> fdfield_t:
h[direction][boundary] = h[direction][shifted1] h[direction][boundary_slice] = h[direction][shifted1_slice]
h[u][boundary] = -h[u][shifted2] h[u][boundary_slice] = -h[u][shifted2_slice]
h[u][shifted1] = 0 h[u][shifted1_slice] = 0
h[v][boundary] = -h[v][shifted2] h[v][boundary_slice] = -h[v][shifted2_slice]
h[v][shifted1] = 0 h[v][shifted1_slice] = 0
return h return h
return ep, hp return ep, hp
raise ValueError(f'Bad polarity: {polarity}') raise Exception('Bad polarity: {}'.format(polarity))

View file

@ -1,32 +1,18 @@
import numpy import numpy
from ..fdmath import dx_lists_t, fdfield_t, fdfield from ..fdmath import dx_lists_t, fdfield_t
from ..fdmath.functional import deriv_back from ..fdmath.functional import deriv_back
""" # TODO documentation
Energy- and flux-accounting helpers for Yee-grid FDTD fields.
These functions complement the derivation in `meanas.fdtd`:
- `poynting(...)` and `poynting_divergence(...)` evaluate the discrete flux terms
from the exact time-domain Poynting identity.
- `energy_hstep(...)` / `energy_estep(...)` evaluate the two staggered energy
expressions.
- `delta_energy_*` helpers evaluate the corresponding energy changes between
adjacent half-steps.
The helpers are intended for diagnostics, regression tests, and consistency
checks between source work, field energy, and flux through cell faces.
"""
def poynting( def poynting(
e: fdfield, e: fdfield_t,
h: fdfield, h: fdfield_t,
dxes: dx_lists_t | None = None, dxes: dx_lists_t | None = None,
) -> fdfield_t: ) -> fdfield_t:
r""" """
Calculate the poynting vector `S` ($S$). Calculate the poynting vector `S` ($S$).
This is the energy transfer rate (amount of energy `U` per `dt` transferred This is the energy transfer rate (amount of energy `U` per `dt` transferred
@ -57,18 +43,17 @@ def poynting(
(see `meanas.tests.test_fdtd.test_poynting_planes`) (see `meanas.tests.test_fdtd.test_poynting_planes`)
The full relationship is The full relationship is
$$ $$
\begin{aligned} \\begin{aligned}
(U_{l+\frac{1}{2}} - U_l) / \Delta_t (U_{l+\\frac{1}{2}} - U_l) / \\Delta_t
&= -\hat{\nabla} \cdot \tilde{S}_{l, l + \frac{1}{2}} \\ &= -\\hat{\\nabla} \\cdot \\tilde{S}_{l, l + \\frac{1}{2}} \\ \\
- \hat{H}_{l+\frac{1}{2}} \cdot \hat{M}_l \\ - \\hat{H}_{l+\\frac{1}{2}} \\cdot \\hat{M}_l \\ \\
- \tilde{E}_l \cdot \tilde{J}_{l+\frac{1}{2}} \\ - \\tilde{E}_l \\cdot \\tilde{J}_{l+\\frac{1}{2}} \\\\
(U_l - U_{l-\frac{1}{2}}) / \Delta_t (U_l - U_{l-\\frac{1}{2}}) / \\Delta_t
&= -\hat{\nabla} \cdot \tilde{S}_{l, l - \frac{1}{2}} \\ &= -\\hat{\\nabla} \\cdot \\tilde{S}_{l, l - \\frac{1}{2}} \\ \\
- \hat{H}_{l-\frac{1}{2}} \cdot \hat{M}_l \\ - \\hat{H}_{l-\\frac{1}{2}} \\cdot \\hat{M}_l \\ \\
- \tilde{E}_l \cdot \tilde{J}_{l-\frac{1}{2}} \\ - \\tilde{E}_l \\cdot \\tilde{J}_{l-\\frac{1}{2}} \\\\
\end{aligned} \\end{aligned}
$$ $$
These equalities are exact and should practically hold to within numerical precision. These equalities are exact and should practically hold to within numerical precision.
@ -99,14 +84,14 @@ def poynting(
s[0] = numpy.roll(ey, -1, axis=0) * hz - numpy.roll(ez, -1, axis=0) * hy s[0] = numpy.roll(ey, -1, axis=0) * hz - numpy.roll(ez, -1, axis=0) * hy
s[1] = numpy.roll(ez, -1, axis=1) * hx - numpy.roll(ex, -1, axis=1) * hz s[1] = numpy.roll(ez, -1, axis=1) * hx - numpy.roll(ex, -1, axis=1) * hz
s[2] = numpy.roll(ex, -1, axis=2) * hy - numpy.roll(ey, -1, axis=2) * hx s[2] = numpy.roll(ex, -1, axis=2) * hy - numpy.roll(ey, -1, axis=2) * hx
return fdfield_t(s) return s
def poynting_divergence( def poynting_divergence(
s: fdfield | None = None, s: fdfield_t | None = None,
*, *,
e: fdfield | None = None, e: fdfield_t | None = None,
h: fdfield | None = None, h: fdfield_t | None = None,
dxes: dx_lists_t | None = None, dxes: dx_lists_t | None = None,
) -> fdfield_t: ) -> fdfield_t:
""" """
@ -137,15 +122,15 @@ def poynting_divergence(
Dx, Dy, Dz = deriv_back() Dx, Dy, Dz = deriv_back()
ds = Dx(s[0]) + Dy(s[1]) + Dz(s[2]) ds = Dx(s[0]) + Dy(s[1]) + Dz(s[2])
return fdfield_t(ds) return ds
def energy_hstep( def energy_hstep(
e0: fdfield, e0: fdfield_t,
h1: fdfield, h1: fdfield_t,
e2: fdfield, e2: fdfield_t,
epsilon: fdfield | None = None, epsilon: fdfield_t | None = None,
mu: fdfield | None = None, mu: fdfield_t | None = None,
dxes: dx_lists_t | None = None, dxes: dx_lists_t | None = None,
) -> fdfield_t: ) -> fdfield_t:
""" """
@ -165,15 +150,15 @@ def energy_hstep(
Energy, at the time of the H-field `h1`. Energy, at the time of the H-field `h1`.
""" """
u = dxmul(e0 * e2, h1 * h1, epsilon, mu, dxes) u = dxmul(e0 * e2, h1 * h1, epsilon, mu, dxes)
return fdfield_t(u) return u
def energy_estep( def energy_estep(
h0: fdfield, h0: fdfield_t,
e1: fdfield, e1: fdfield_t,
h2: fdfield, h2: fdfield_t,
epsilon: fdfield | None = None, epsilon: fdfield_t | None = None,
mu: fdfield | None = None, mu: fdfield_t | None = None,
dxes: dx_lists_t | None = None, dxes: dx_lists_t | None = None,
) -> fdfield_t: ) -> fdfield_t:
""" """
@ -193,17 +178,17 @@ def energy_estep(
Energy, at the time of the E-field `e1`. Energy, at the time of the E-field `e1`.
""" """
u = dxmul(e1 * e1, h0 * h2, epsilon, mu, dxes) u = dxmul(e1 * e1, h0 * h2, epsilon, mu, dxes)
return fdfield_t(u) return u
def delta_energy_h2e( def delta_energy_h2e(
dt: float, dt: float,
e0: fdfield, e0: fdfield_t,
h1: fdfield, h1: fdfield_t,
e2: fdfield, e2: fdfield_t,
h3: fdfield, h3: fdfield_t,
epsilon: fdfield | None = None, epsilon: fdfield_t | None = None,
mu: fdfield | None = None, mu: fdfield_t | None = None,
dxes: dx_lists_t | None = None, dxes: dx_lists_t | None = None,
) -> fdfield_t: ) -> fdfield_t:
""" """
@ -226,17 +211,17 @@ def delta_energy_h2e(
de = e2 * (e2 - e0) / dt de = e2 * (e2 - e0) / dt
dh = h1 * (h3 - h1) / dt dh = h1 * (h3 - h1) / dt
du = dxmul(de, dh, epsilon, mu, dxes) du = dxmul(de, dh, epsilon, mu, dxes)
return fdfield_t(du) return du
def delta_energy_e2h( def delta_energy_e2h(
dt: float, dt: float,
h0: fdfield, h0: fdfield_t,
e1: fdfield, e1: fdfield_t,
h2: fdfield, h2: fdfield_t,
e3: fdfield, e3: fdfield_t,
epsilon: fdfield | None = None, epsilon: fdfield_t | None = None,
mu: fdfield | None = None, mu: fdfield_t | None = None,
dxes: dx_lists_t | None = None, dxes: dx_lists_t | None = None,
) -> fdfield_t: ) -> fdfield_t:
""" """
@ -259,31 +244,21 @@ def delta_energy_e2h(
de = e1 * (e3 - e1) / dt de = e1 * (e3 - e1) / dt
dh = h2 * (h2 - h0) / dt dh = h2 * (h2 - h0) / dt
du = dxmul(de, dh, epsilon, mu, dxes) du = dxmul(de, dh, epsilon, mu, dxes)
return fdfield_t(du) return du
def delta_energy_j( def delta_energy_j(
j0: fdfield, j0: fdfield_t,
e1: fdfield, e1: fdfield_t,
dxes: dx_lists_t | None = None, dxes: dx_lists_t | None = None,
) -> fdfield_t: ) -> fdfield_t:
r""" """
Calculate the electric-current work term $J \cdot E$ on the Yee grid. Calculate
This is the source contribution that appears beside the flux divergence in Note that each value of $J$ contributes to the energy twice (i.e. once per field update)
the discrete Poynting identities documented in `meanas.fdtd`. despite only causing the value of $E$ to change once (same for $M$ and $H$).
Note that each value of `J` contributes twice in a full Yee cycle (once per
half-step energy balance) even though it directly changes `E` only once.
Args:
j0: Electric-current density sampled at the same half-step as the
current work term.
e1: Electric field sampled at the matching integer timestep.
dxes: Grid description; defaults to unit spacing.
Returns:
Per-cell source-work contribution with the scalar field shape.
""" """
if dxes is None: if dxes is None:
dxes = tuple(tuple(numpy.ones(1) for _ in range(3)) for _ in range(2)) dxes = tuple(tuple(numpy.ones(1) for _ in range(3)) for _ in range(2))
@ -292,30 +267,16 @@ def delta_energy_j(
* dxes[0][0][:, None, None] * dxes[0][0][:, None, None]
* dxes[0][1][None, :, None] * dxes[0][1][None, :, None]
* dxes[0][2][None, None, :]) * dxes[0][2][None, None, :])
return fdfield_t(du) return du
def dxmul( def dxmul(
ee: fdfield, ee: fdfield_t,
hh: fdfield, hh: fdfield_t,
epsilon: fdfield | float | None = None, epsilon: fdfield_t | float | None = None,
mu: fdfield | float | None = None, mu: fdfield_t | float | None = None,
dxes: dx_lists_t | None = None, dxes: dx_lists_t | None = None,
) -> fdfield_t: ) -> fdfield_t:
"""
Multiply E- and H-like field products by material weights and cell volumes.
Args:
ee: Three-component electric-field product, such as `e0 * e2`.
hh: Three-component magnetic-field product, such as `h1 * h1`.
epsilon: Electric material weight; defaults to `1`.
mu: Magnetic material weight; defaults to `1`.
dxes: Grid description; defaults to unit spacing.
Returns:
Scalar field containing the weighted electric plus magnetic contribution
for each Yee cell.
"""
if epsilon is None: if epsilon is None:
epsilon = 1 epsilon = 1
if mu is None: if mu is None:
@ -331,4 +292,4 @@ def dxmul(
* dxes[1][0][:, None, None] * dxes[1][0][:, None, None]
* dxes[1][1][None, :, None] * dxes[1][1][None, :, None]
* dxes[1][2][None, None, :]) * dxes[1][2][None, None, :])
return fdfield_t(result) return result

View file

@ -1,180 +0,0 @@
from collections.abc import Callable
import logging
from typing import cast
import numpy
from numpy.typing import NDArray, ArrayLike
from numpy import pi
logger = logging.getLogger(__name__)
type pulse_scalar_t = float | NDArray[numpy.floating]
pulse_fn_t = Callable[[ArrayLike], tuple[pulse_scalar_t, pulse_scalar_t, pulse_scalar_t]]
def _scalar_or_array(values: NDArray[numpy.floating]) -> pulse_scalar_t:
if values.ndim == 0:
return float(values)
return cast('NDArray[numpy.floating]', values)
def gaussian_packet(
wl: float,
dwl: float,
dt: float,
turn_on: float = 1e-10,
one_sided: bool = False,
) -> tuple[pulse_fn_t, float]:
"""
Gaussian pulse (or gaussian ramp) for FDTD excitation
exp(-a*t*t) ==> exp(-omega * omega / (4 * a)) [fourier, ignoring leading const.]
FWHM_time is 2 * sqrt(2 * log(2)) * sqrt(2 / a)
FWHM_omega is 2 * sqrt(2 * log(2)) * sqrt(2 * a) = 4 * sqrt(log(2) * a)
Args:
wl: wavelength
dwl: Gaussian's FWHM in wavelength space
dt: Timestep
turn_on: Max allowable amplitude at t=0
one_sided: If `True`, source amplitude never decreases after reaching max
Returns:
Source function: src(timestep) -> (envelope[tt], cos[... * tt], sin[... * tt])
Delay: number of initial timesteps for which envelope[tt] will be 0
"""
logger.warning('meanas.fdtd.misc functions are still very WIP!') # TODO
# dt * dw = 4 * ln(2)
omega = 2 * pi / wl
freq = 1 / wl
fwhm_omega = dwl * omega * omega / (2 * pi) # dwl -> d_omega (approx)
alpha = (fwhm_omega * fwhm_omega) * numpy.log(2) / 8
delay = numpy.sqrt(-numpy.log(turn_on) / alpha)
delay = numpy.ceil(delay * freq) / freq # force delay to integer number of periods to maintain phase
logger.info(f'src_time {2 * delay / dt}')
def source_phasor(ii: ArrayLike) -> tuple[pulse_scalar_t, pulse_scalar_t, pulse_scalar_t]:
ii_array = numpy.asarray(ii, dtype=float)
t0 = ii_array * dt - delay
envelope = numpy.sqrt(numpy.sqrt(2 * alpha / pi)) * numpy.exp(-alpha * t0 * t0)
if one_sided:
envelope = numpy.where(t0 > 0, 1.0, envelope)
cc = numpy.cos(omega * t0)
ss = numpy.sin(omega * t0)
return _scalar_or_array(envelope), _scalar_or_array(cc), _scalar_or_array(ss)
# nrm = numpy.exp(-omega * omega / alpha) / 2
return source_phasor, delay
def ricker_pulse(
wl: float,
dt: float,
turn_on: float = 1e-10,
) -> tuple[pulse_fn_t, float]:
"""
Ricker wavelet (second derivative of a gaussian pulse)
t0 = ii * dt - delay
R = w_peak * t0 / 2
f(t) = (1 - 2 * (pi * f_peak * t0) ** 2) * exp(-(pi * f_peak * t0)**2
= (1 - (w_peak * t0)**2 / 2 exp(-(w_peak * t0 / 2) **2)
= (1 - 2 * R * R) * exp(-R * R)
# NOTE: don't use cosine/sine for J, just for phasor readout
Args:
wl: wavelength
dt: Timestep
turn_on: Max allowable amplitude at t=0
Returns:
Source function: src(timestep) -> (envelope[tt], cos[... * tt], sin[... * tt])
Delay: number of initial timesteps for which envelope[tt] will be 0
"""
logger.warning('meanas.fdtd.misc functions are still very WIP!') # TODO
omega = 2 * pi / wl
freq = 1 / wl
from scipy.optimize import root_scalar
delay_results = root_scalar(
lambda tt: (1 - omega * omega * tt * tt / 2) * numpy.exp(-omega * omega * tt * tt / 4) - turn_on,
x0=0,
x1=-2 / omega,
)
delay = delay_results.root
delay = numpy.ceil(delay * freq) / freq # force delay to integer number of periods to maintain phase
def source_phasor(ii: ArrayLike) -> tuple[pulse_scalar_t, pulse_scalar_t, pulse_scalar_t]:
ii_array = numpy.asarray(ii, dtype=float)
t0 = ii_array * dt - delay
rr = omega * t0 / 2
ff = (1 - 2 * rr * rr) * numpy.exp(-rr * rr)
cc = numpy.cos(omega * t0)
ss = numpy.sin(omega * t0)
return _scalar_or_array(ff), _scalar_or_array(cc), _scalar_or_array(ss)
return source_phasor, delay
def gaussian_beam(
xyz: list[NDArray],
center: ArrayLike,
waist_radius: float,
wl: float,
tilt: float = 0,
) -> NDArray[numpy.complex128]:
"""
Gaussian beam
(solution to paraxial Helmholtz equation)
Default (no tilt) corresponds to a beam propagating in the -z direction.
Args:
xyz: List of [[x0, x1, ...], [y0, ...], [z0, ...]] positions specifying grid
locations at which the field will be sampled.
center: [x, y, z] location of beam waist
waist_radius: Beam radius at the waist
wl: Wavelength
tilt: Rotation around y axis. Default (0) has beam propagating in -z direction.
"""
logger.warning('meanas.fdtd.misc functions are still very WIP!') # TODO
w0 = waist_radius
grids = numpy.asarray(numpy.meshgrid(*xyz, indexing='ij'))
grids -= numpy.asarray(center)[:, None, None, None]
rot = numpy.array([
[ numpy.cos(tilt), 0, numpy.sin(tilt)],
[ 0, 1, 0],
[-numpy.sin(tilt), 0, numpy.cos(tilt)],
])
xx, yy, zz = numpy.einsum('ij,jxyz->ixyz', rot, grids)
r2 = xx * xx + yy * yy
z2 = zz * zz
zr = pi * w0 * w0 / wl
zr2 = zr * zr
wz2 = w0 * w0 * (1 + z2 / zr2)
wz = numpy.sqrt(wz2) # == fwhm(z) / sqrt(2 * ln(2))
kk = 2 * pi / wl
inv_Rz = numpy.divide(zz, z2 + zr2, out=numpy.zeros_like(zz), where=(z2 + zr2) != 0)
gouy = numpy.arctan(zz / zr)
gaussian = w0 / wz * numpy.exp(-r2 / wz2) * numpy.exp(1j * (kk * zz + kk * r2 * inv_Rz / 2 - gouy))
row = gaussian[:, :, gaussian.shape[2] // 2]
norm = numpy.linalg.norm(row)
return gaussian / norm

View file

@ -1,319 +0,0 @@
"""
Helpers for extracting single- or multi-frequency phasors from FDTD samples.
These helpers are intentionally low-level: callers own the accumulator arrays and
the sampling policy. The accumulated quantity is
dt * sum(weight * exp(-1j * omega * t_step) * sample_step)
where `t_step = (step + offset_steps) * dt`.
The usual Yee offsets are:
- `accumulate_phasor_e(..., step=l)` for `E_l`
- `accumulate_phasor_h(..., step=l)` for `H_{l + 1/2}`
- `accumulate_phasor_j(..., step=l)` for `J_{l + 1/2}`
`temporal_phasor(...)` and `temporal_phasor_scale(...)` apply the same Fourier
sum to a 1D scalar waveform. They are useful for normalizing a pulsed source
before that scalar waveform is applied to a point source or spatial mode source.
`real_injection_scale(...)` is a companion helper for the common real-valued
injection pattern `numpy.real(scale * waveform)`, where `waveform` is the
analytic positive-frequency signal and the injected real current should still
produce a desired phasor response.
`reconstruct_real(...)` and its `E/H/J` wrappers perform the inverse operation:
they turn one or more phasors back into real-valued field snapshots at explicit
Yee-aligned sample times. For a scalar target frequency they accept either a
plain field phasor or the batched `(1, *sample_shape)` form used elsewhere in
this module.
These helpers do not choose warmup/accumulation windows or pulse-envelope
normalization. They also do not impose a current sign convention. In this
codebase, electric-current injection normally appears as `E -= dt * J / epsilon`,
which matches the FDFD right-hand side `-1j * omega * J`.
"""
from collections.abc import Sequence
import numpy
from numpy.typing import ArrayLike, NDArray
def _normalize_omegas(
omegas: float | complex | Sequence[float | complex] | NDArray,
) -> NDArray[numpy.complexfloating]:
omega_array = numpy.atleast_1d(numpy.asarray(omegas, dtype=complex))
if omega_array.ndim != 1 or omega_array.size == 0:
raise ValueError('omegas must be a scalar or non-empty 1D sequence')
return omega_array
def _normalize_weight(
weight: ArrayLike,
omega_shape: tuple[int, ...],
) -> NDArray[numpy.complexfloating]:
weight_array = numpy.asarray(weight, dtype=complex)
if weight_array.ndim == 0:
return numpy.full(omega_shape, weight_array, dtype=complex)
if weight_array.shape == omega_shape:
return weight_array
raise ValueError(f'weight must be scalar or have shape {omega_shape}, got {weight_array.shape}')
def _normalize_temporal_samples(
samples: ArrayLike,
) -> NDArray[numpy.complexfloating]:
sample_array = numpy.asarray(samples, dtype=complex)
if sample_array.ndim != 1 or sample_array.size == 0:
raise ValueError('samples must be a non-empty 1D sequence')
return sample_array
def _validate_reconstruction_inputs(
phasors: ArrayLike,
omegas: float | complex | Sequence[float | complex] | NDArray,
dt: float,
) -> tuple[NDArray[numpy.complexfloating], NDArray[numpy.complexfloating], bool]:
if dt <= 0:
raise ValueError('dt must be positive')
omega_array = _normalize_omegas(omegas)
phasor_array = numpy.asarray(phasors, dtype=complex)
expected_leading = omega_array.size
if phasor_array.ndim == 0:
raise ValueError(
f'phasors must have shape ({expected_leading}, *sample_shape) or sample_shape for scalar omega, got {phasor_array.shape}',
)
added_axis = False
if expected_leading == 1 and (phasor_array.ndim == 1 or phasor_array.shape[0] != expected_leading):
phasor_array = phasor_array[numpy.newaxis, ...]
added_axis = True
elif phasor_array.shape[0] != expected_leading:
raise ValueError(
f'phasors must have shape ({expected_leading}, *sample_shape) or sample_shape for scalar omega, got {phasor_array.shape}',
)
return omega_array, phasor_array, added_axis
def accumulate_phasor(
accumulator: NDArray[numpy.complexfloating],
omegas: float | complex | Sequence[float | complex] | NDArray,
dt: float,
sample: ArrayLike,
step: int,
*,
offset_steps: float = 0.0,
weight: ArrayLike = 1.0,
) -> NDArray[numpy.complexfloating]:
"""
Add one time-domain sample into a phasor accumulator.
The added quantity is
dt * weight * exp(-1j * omega * t_step) * sample
where `t_step = (step + offset_steps) * dt`.
Note:
This helper already multiplies by `dt`. If the caller's normalization
factor was derived from a discrete sum that already includes `dt`, pass
`weight / dt` here.
"""
if dt <= 0:
raise ValueError('dt must be positive')
omega_array = _normalize_omegas(omegas)
sample_array = numpy.asarray(sample)
expected_shape = (omega_array.size, *sample_array.shape)
if accumulator.shape != expected_shape:
raise ValueError(f'accumulator must have shape {expected_shape}, got {accumulator.shape}')
weight_array = _normalize_weight(weight, omega_array.shape)
time = (step + offset_steps) * dt
phase = numpy.exp(-1j * omega_array * time)
scaled = dt * (weight_array * phase).reshape((-1,) + (1,) * sample_array.ndim)
accumulator += scaled * sample_array
return accumulator
def temporal_phasor(
samples: ArrayLike,
omegas: float | complex | Sequence[float | complex] | NDArray,
dt: float,
*,
start_step: int = 0,
offset_steps: float = 0.0,
) -> NDArray[numpy.complexfloating]:
"""
Fourier-project a 1D temporal waveform onto one or more angular frequencies.
The returned quantity is
dt * sum(exp(-1j * omega * t_step) * samples[step_index])
where `t_step = (start_step + step_index + offset_steps) * dt`.
"""
if dt <= 0:
raise ValueError('dt must be positive')
omega_array = _normalize_omegas(omegas)
sample_array = _normalize_temporal_samples(samples)
steps = start_step + numpy.arange(sample_array.size, dtype=float) + offset_steps
phase = numpy.exp(-1j * omega_array[:, None] * (steps[None, :] * dt))
return dt * (phase @ sample_array)
def temporal_phasor_scale(
samples: ArrayLike,
omegas: float | complex | Sequence[float | complex] | NDArray,
dt: float,
*,
start_step: int = 0,
offset_steps: float = 0.0,
target: ArrayLike = 1.0,
) -> NDArray[numpy.complexfloating]:
"""
Return the scalar multiplier that gives a desired temporal phasor response.
The returned scale satisfies
temporal_phasor(scale * samples, omegas, dt, ...) == target
for each target frequency. The result keeps a leading frequency axis even
when `omegas` is scalar.
"""
response = temporal_phasor(samples, omegas, dt, start_step=start_step, offset_steps=offset_steps)
target_array = _normalize_weight(target, response.shape)
if numpy.any(numpy.abs(response) <= numpy.finfo(float).eps):
raise ValueError('cannot normalize a waveform with zero temporal phasor response')
return target_array / response
def real_injection_scale(
samples: ArrayLike,
omegas: float | complex | Sequence[float | complex] | NDArray,
dt: float,
*,
start_step: int = 0,
offset_steps: float = 0.0,
target: ArrayLike = 1.0,
) -> NDArray[numpy.complexfloating]:
"""
Return the scale for a real-valued injection built from an analytic waveform.
If the time-domain source is applied as
numpy.real(scale * samples[step])
then the desired positive-frequency phasor is obtained by compensating for
the 1/2 factor between the real-valued source and its analytic component:
scale = 2 * target / temporal_phasor(samples, ...)
This helper normalizes only the intended positive-frequency component. Any
residual negative-frequency leakage is controlled by the waveform design and
the accumulation window.
"""
response = temporal_phasor(samples, omegas, dt, start_step=start_step, offset_steps=offset_steps)
target_array = _normalize_weight(target, response.shape)
if numpy.any(numpy.abs(response) <= numpy.finfo(float).eps):
raise ValueError('cannot normalize a waveform with zero temporal phasor response')
return 2 * target_array / response
def reconstruct_real(
phasors: ArrayLike,
omegas: float | complex | Sequence[float | complex] | NDArray,
dt: float,
step: int,
*,
offset_steps: float = 0.0,
) -> NDArray[numpy.floating]:
"""
Reconstruct a real-valued field snapshot from one or more phasors.
The returned quantity is
real(phasor * exp(1j * omega * t_step))
where `t_step = (step + offset_steps) * dt`.
For multi-frequency inputs, the leading frequency axis is preserved. For a
scalar `omega`, callers may pass either `(1, *sample_shape)` or
`sample_shape`; the return shape matches that choice.
"""
omega_array, phasor_array, added_axis = _validate_reconstruction_inputs(phasors, omegas, dt)
time = (step + offset_steps) * dt
phase = numpy.exp(1j * omega_array * time).reshape((-1,) + (1,) * (phasor_array.ndim - 1))
reconstructed = numpy.real(phasor_array * phase)
if added_axis:
return reconstructed[0]
return reconstructed
def accumulate_phasor_e(
accumulator: NDArray[numpy.complexfloating],
omegas: float | complex | Sequence[float | complex] | NDArray,
dt: float,
sample: ArrayLike,
step: int,
*,
weight: ArrayLike = 1.0,
) -> NDArray[numpy.complexfloating]:
"""Accumulate an E-field sample taken at integer timestep `step`."""
return accumulate_phasor(accumulator, omegas, dt, sample, step, offset_steps=0.0, weight=weight)
def accumulate_phasor_h(
accumulator: NDArray[numpy.complexfloating],
omegas: float | complex | Sequence[float | complex] | NDArray,
dt: float,
sample: ArrayLike,
step: int,
*,
weight: ArrayLike = 1.0,
) -> NDArray[numpy.complexfloating]:
"""Accumulate an H-field sample corresponding to `H_{step + 1/2}`."""
return accumulate_phasor(accumulator, omegas, dt, sample, step, offset_steps=0.5, weight=weight)
def accumulate_phasor_j(
accumulator: NDArray[numpy.complexfloating],
omegas: float | complex | Sequence[float | complex] | NDArray,
dt: float,
sample: ArrayLike,
step: int,
*,
weight: ArrayLike = 1.0,
) -> NDArray[numpy.complexfloating]:
"""Accumulate a current sample corresponding to `J_{step + 1/2}`."""
return accumulate_phasor(accumulator, omegas, dt, sample, step, offset_steps=0.5, weight=weight)
def reconstruct_real_e(
phasors: ArrayLike,
omegas: float | complex | Sequence[float | complex] | NDArray,
dt: float,
step: int,
) -> NDArray[numpy.floating]:
"""Reconstruct a real E-field snapshot taken at integer timestep `step`."""
return reconstruct_real(phasors, omegas, dt, step, offset_steps=0.0)
def reconstruct_real_h(
phasors: ArrayLike,
omegas: float | complex | Sequence[float | complex] | NDArray,
dt: float,
step: int,
) -> NDArray[numpy.floating]:
"""Reconstruct a real H-field snapshot corresponding to `H_{step + 1/2}`."""
return reconstruct_real(phasors, omegas, dt, step, offset_steps=0.5)
def reconstruct_real_j(
phasors: ArrayLike,
omegas: float | complex | Sequence[float | complex] | NDArray,
dt: float,
step: int,
) -> NDArray[numpy.floating]:
"""Reconstruct a real current snapshot corresponding to `J_{step + 1/2}`."""
return reconstruct_real(phasors, omegas, dt, step, offset_steps=0.5)

View file

@ -1,29 +1,18 @@
""" """
Convolutional perfectly matched layer (CPML) support for FDTD updates. PML implementations
The helpers in this module construct per-face CPML parameters and then wrap the #TODO discussion of PMLs
standard Yee updates with the additional auxiliary `psi` fields needed by the #TODO cpml documentation
CPML recurrence.
The intended call pattern is:
1. Build a `cpml_params[axis][polarity_index]` table with `cpml_params(...)`.
2. Pass that table into `updates_with_cpml(...)` together with `dt`, `dxes`, and
`epsilon`.
3. Advance the returned `update_E` / `update_H` closures in the simulation loop.
Each face can be enabled or disabled independently by replacing one table entry
with `None`.
""" """
# TODO retest pmls! # TODO retest pmls!
from typing import Any from typing import Callable, Sequence, Any
from collections.abc import Callable, Sequence
from copy import deepcopy from copy import deepcopy
import numpy import numpy
from numpy.typing import NDArray, DTypeLike from numpy.typing import NDArray, DTypeLike
from ..fdmath import fdfield, dx_lists_t from ..fdmath import fdfield_t, dx_lists_t
from ..fdmath.functional import deriv_forward, deriv_back from ..fdmath.functional import deriv_forward, deriv_back
@ -42,41 +31,18 @@ def cpml_params(
ma: float = 1, ma: float = 1,
cfs_alpha: float = 0, cfs_alpha: float = 0,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""
Construct the parameter block for one CPML face.
Args:
axis: Which Cartesian axis the CPML is normal to (`0`, `1`, or `2`).
polarity: Which face along that axis (`-1` for the low-index face,
`+1` for the high-index face).
dt: Timestep used by the Yee update.
thickness: Number of Yee cells occupied by the CPML region.
ln_R_per_layer: Logarithmic attenuation target per layer.
epsilon_eff: Effective permittivity used when choosing the CPML scaling.
mu_eff: Effective permeability used when choosing the CPML scaling.
m: Polynomial grading exponent for `sigma` and `kappa`.
ma: Polynomial grading exponent for the complex-frequency shift `alpha`.
cfs_alpha: Maximum complex-frequency shift parameter.
Returns:
Dictionary with:
- `param_e`: `(p0, p1, p2)` arrays for the E update
- `param_h`: `(p0, p1, p2)` arrays for the H update
- `region`: slice tuple selecting the CPML cells on that face
"""
if axis not in range(3): if axis not in range(3):
raise ValueError(f'Invalid axis: {axis}') raise Exception('Invalid axis: {}'.format(axis))
if polarity not in (-1, 1): if polarity not in (-1, 1):
raise ValueError(f'Invalid polarity: {polarity}') raise Exception('Invalid polarity: {}'.format(polarity))
if thickness <= 2: if thickness <= 2:
raise ValueError('It would be wise to have a pml with 4+ cells of thickness') raise Exception('It would be wise to have a pml with 4+ cells of thickness')
if epsilon_eff <= 0: if epsilon_eff <= 0:
raise ValueError('epsilon_eff must be positive') raise Exception('epsilon_eff must be positive')
sigma_max = -ln_R_per_layer / 2 * (m + 1) sigma_max = -ln_R_per_layer / 2 * (m + 1)
kappa_max = numpy.sqrt(epsilon_eff * mu_eff) kappa_max = numpy.sqrt(epsilon_eff * mu_eff)
@ -90,6 +56,8 @@ def cpml_params(
xh -= 0.5 xh -= 0.5
xe = xe[::-1] xe = xe[::-1]
xh = xh[::-1] xh = xh[::-1]
else:
raise Exception('Bad polarity!')
expand_slice_l: list[Any] = [None, None, None] expand_slice_l: list[Any] = [None, None, None]
expand_slice_l[axis] = slice(None) expand_slice_l[axis] = slice(None)
@ -113,6 +81,8 @@ def cpml_params(
region_list[axis] = slice(None, thickness) region_list[axis] = slice(None, thickness)
elif polarity > 0: elif polarity > 0:
region_list[axis] = slice(-thickness, None) region_list[axis] = slice(-thickness, None)
else:
raise Exception('Bad polarity!')
region = tuple(region_list) region = tuple(region_list)
return { return {
@ -126,31 +96,11 @@ def updates_with_cpml(
cpml_params: Sequence[Sequence[dict[str, Any] | None]], cpml_params: Sequence[Sequence[dict[str, Any] | None]],
dt: float, dt: float,
dxes: dx_lists_t, dxes: dx_lists_t,
epsilon: fdfield, epsilon: fdfield_t,
*, *,
dtype: DTypeLike = numpy.float32, dtype: DTypeLike = numpy.float32,
) -> tuple[Callable[..., None], Callable[..., None]]: ) -> tuple[Callable[[fdfield_t, fdfield_t, fdfield_t], None],
""" Callable[[fdfield_t, fdfield_t, fdfield_t], None]]:
Build Yee-step update closures augmented with CPML terms.
Args:
cpml_params: Three-by-two sequence indexed as `[axis][polarity_index]`.
Entries are the dictionaries returned by `cpml_params(...)`; use
`None` to disable CPML on one face.
dt: Timestep.
dxes: Yee-grid spacing lists `[dx_e, dx_h]`.
epsilon: Electric material distribution used by the E update.
dtype: Storage dtype for the auxiliary CPML state arrays.
Returns:
`(update_E, update_H)` closures with the same call shape as the basic
Yee updates:
- `update_E(e, h, epsilon)`
- `update_H(e, h, mu)`
The closures retain the CPML auxiliary state internally.
"""
Dfx, Dfy, Dfz = deriv_forward(dxes[1]) Dfx, Dfy, Dfz = deriv_forward(dxes[1])
Dbx, Dby, Dbz = deriv_back(dxes[1]) Dbx, Dby, Dbz = deriv_back(dxes[1])
@ -161,7 +111,7 @@ def updates_with_cpml(
params_H: list[list[tuple[Any, Any, Any, Any]]] = deepcopy(params_E) params_H: list[list[tuple[Any, Any, Any, Any]]] = deepcopy(params_E)
for axis in range(3): for axis in range(3):
for pp, _polarity in enumerate((-1, 1)): for pp, polarity in enumerate((-1, 1)):
cpml_param = cpml_params[axis][pp] cpml_param = cpml_params[axis][pp]
if cpml_param is None: if cpml_param is None:
psi_E[axis][pp] = (None, None) psi_E[axis][pp] = (None, None)
@ -186,9 +136,9 @@ def updates_with_cpml(
pH = numpy.empty_like(epsilon, dtype=dtype) pH = numpy.empty_like(epsilon, dtype=dtype)
def update_E( def update_E(
e: fdfield, e: fdfield_t,
h: fdfield, h: fdfield_t,
epsilon: fdfield, epsilon: fdfield_t,
) -> None: ) -> None:
dyHx = Dby(h[0]) dyHx = Dby(h[0])
dzHx = Dbz(h[0]) dzHx = Dbz(h[0])
@ -232,9 +182,9 @@ def updates_with_cpml(
e[2] += dt / epsilon[2] * (dxHy - dyHx + pE[2]) e[2] += dt / epsilon[2] * (dxHy - dyHx + pE[2])
def update_H( def update_H(
e: fdfield, e: fdfield_t,
h: fdfield, h: fdfield_t,
mu: fdfield | tuple[int, int, int] = (1, 1, 1), mu: fdfield_t = numpy.ones(3),
) -> None: ) -> None:
dyEx = Dfy(e[0]) dyEx = Dfy(e[0])
dzEx = Dfz(e[0]) dzEx = Dfz(e[0])

View file

@ -1,37 +0,0 @@
import numpy
SHAPE = (2, 2, 2)
G_MATRIX = numpy.eye(3)
K0_GENERAL = numpy.array([0.1, 0.2, 0.3], dtype=float)
K0_AXIAL = numpy.array([0.0, 0.0, 0.25], dtype=float)
K0_X = numpy.array([0.1, 0.0, 0.0], dtype=float)
EPSILON = numpy.ones((3, *SHAPE), dtype=float)
MU = numpy.stack([
numpy.linspace(2.0, 2.7, numpy.prod(SHAPE)).reshape(SHAPE),
numpy.linspace(2.1, 2.8, numpy.prod(SHAPE)).reshape(SHAPE),
numpy.linspace(2.2, 2.9, numpy.prod(SHAPE)).reshape(SHAPE),
])
H_SIZE = 2 * numpy.prod(SHAPE)
H_MN = (numpy.arange(H_SIZE) + 0.25j).astype(complex)
ZERO_H_MN = numpy.zeros_like(H_MN)
Y0 = (numpy.arange(H_SIZE, dtype=float) + 1j * numpy.linspace(0.1, 0.9, H_SIZE))[None, :]
Y0_TWO_MODE = numpy.vstack(
[
numpy.arange(H_SIZE, dtype=float) + 1j * numpy.linspace(0.1, 0.9, H_SIZE),
numpy.linspace(2.0, 3.5, H_SIZE) - 0.5j * numpy.arange(H_SIZE, dtype=float),
],
)
def build_overlap_fixture() -> tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray, numpy.ndarray]:
e_in = numpy.zeros((3, *SHAPE), dtype=complex)
h_in = numpy.zeros_like(e_in)
e_out = numpy.zeros_like(e_in)
h_out = numpy.zeros_like(e_in)
e_in[1] = 1.0
h_in[2] = 2.0
e_out[1] = 3.0
h_out[2] = 4.0
return e_in, h_in, e_out, h_out

View file

@ -1,49 +0,0 @@
import numpy
from ..fdmath import vec, unvec
OMEGA = 1 / 1500
SHAPE = (2, 3, 2)
DXES = [
[numpy.array([1.0, 1.5]), numpy.array([0.75, 1.25, 1.5]), numpy.array([1.2, 0.8])],
[numpy.array([0.9, 1.4]), numpy.array([0.8, 1.1, 1.4]), numpy.array([1.0, 0.7])],
]
EPSILON = numpy.stack([
numpy.linspace(1.0, 2.2, numpy.prod(SHAPE)).reshape(SHAPE),
numpy.linspace(1.1, 2.3, numpy.prod(SHAPE)).reshape(SHAPE),
numpy.linspace(1.2, 2.4, numpy.prod(SHAPE)).reshape(SHAPE),
])
MU = numpy.stack([
numpy.linspace(2.0, 3.2, numpy.prod(SHAPE)).reshape(SHAPE),
numpy.linspace(2.1, 3.3, numpy.prod(SHAPE)).reshape(SHAPE),
numpy.linspace(2.2, 3.4, numpy.prod(SHAPE)).reshape(SHAPE),
])
E_FIELD = (numpy.arange(3 * numpy.prod(SHAPE)).reshape((3, *SHAPE)) + 0.5j).astype(complex)
H_FIELD = (numpy.arange(3 * numpy.prod(SHAPE)).reshape((3, *SHAPE)) * 0.25 - 0.75j).astype(complex)
PEC = numpy.zeros((3, *SHAPE), dtype=float)
PEC[1, 0, 1, 0] = 1.0
PMC = numpy.zeros((3, *SHAPE), dtype=float)
PMC[2, 1, 2, 1] = 1.0
TF_REGION = numpy.zeros((3, *SHAPE), dtype=float)
TF_REGION[:, 0, 1, 0] = 1.0
BOUNDARY_SHAPE = (3, 4, 3)
BOUNDARY_DXES = [
[numpy.array([1.0, 1.5, 0.8]), numpy.array([0.75, 1.25, 1.5, 0.9]), numpy.array([1.2, 0.8, 1.1])],
[numpy.array([0.9, 1.4, 1.0]), numpy.array([0.8, 1.1, 1.4, 1.0]), numpy.array([1.0, 0.7, 1.3])],
]
BOUNDARY_EPSILON = numpy.stack([
numpy.linspace(1.0, 2.2, numpy.prod(BOUNDARY_SHAPE)).reshape(BOUNDARY_SHAPE),
numpy.linspace(1.1, 2.3, numpy.prod(BOUNDARY_SHAPE)).reshape(BOUNDARY_SHAPE),
numpy.linspace(1.2, 2.4, numpy.prod(BOUNDARY_SHAPE)).reshape(BOUNDARY_SHAPE),
])
BOUNDARY_FIELD = (numpy.arange(3 * numpy.prod(BOUNDARY_SHAPE)).reshape((3, *BOUNDARY_SHAPE)) + 0.5j).astype(complex)
def apply_fdfd_matrix(op, field: numpy.ndarray, shape: tuple[int, ...] = SHAPE) -> numpy.ndarray:
return unvec(op @ vec(field), shape)

View file

@ -1,56 +0,0 @@
import dataclasses
import numpy
from scipy import sparse
import scipy.sparse.linalg as spalg
from ._test_builders import unit_dxes
@dataclasses.dataclass(frozen=True)
class DiagonalEigenCase:
operator: sparse.csr_matrix
linear_operator: spalg.LinearOperator
guess_default: numpy.ndarray
guess_sparse: numpy.ndarray
@dataclasses.dataclass(frozen=True)
class SolverPlumbingCase:
omega: float
a0: sparse.csr_matrix
pl: sparse.csr_matrix
pr: sparse.csr_matrix
j: numpy.ndarray
guess: numpy.ndarray
solver_result: numpy.ndarray
dxes: tuple[tuple[numpy.ndarray, ...], tuple[numpy.ndarray, ...]]
epsilon: numpy.ndarray
def diagonal_eigen_case() -> DiagonalEigenCase:
operator = sparse.diags([5.0, 3.0, 1.0, -2.0]).tocsr()
linear_operator = spalg.LinearOperator(
shape=operator.shape,
dtype=complex,
matvec=lambda vv: operator @ vv,
)
return DiagonalEigenCase(
operator=operator,
linear_operator=linear_operator,
guess_default=numpy.array([0.0, 1.0, 1e-6, 0.0], dtype=complex),
guess_sparse=numpy.array([1.0, 0.1, 0.0, 0.0], dtype=complex),
)
def solver_plumbing_case() -> SolverPlumbingCase:
return SolverPlumbingCase(
omega=2.0,
a0=sparse.csr_matrix(numpy.array([[1.0 + 2.0j, 2.0], [3.0 - 1.0j, 4.0]])),
pl=sparse.csr_matrix(numpy.array([[2.0, 0.0], [0.0, 3.0j]])),
pr=sparse.csr_matrix(numpy.array([[0.5, 0.0], [0.0, -2.0j]])),
j=numpy.array([1.0 + 0.5j, -2.0]),
guess=numpy.array([0.25 - 0.75j, 1.5 + 0.5j]),
solver_result=numpy.array([3.0 - 1.0j, -4.0 + 2.0j]),
dxes=unit_dxes((1, 1, 1)),
epsilon=numpy.ones(2),
)

View file

@ -1,22 +0,0 @@
import numpy
def real_ramp(shape: tuple[int, ...], *, scale: float = 1.0, offset: float = 0.0) -> numpy.ndarray:
return numpy.arange(numpy.prod(shape), dtype=float).reshape(shape, order='C') * scale + offset
def complex_ramp(
shape: tuple[int, ...],
*,
scale: float = 1.0,
offset: float = 0.0,
imag_scale: float = 0.0,
imag_offset: float = 0.0,
) -> numpy.ndarray:
real = real_ramp(shape, scale=scale, offset=offset)
imag = real_ramp(shape, scale=imag_scale, offset=imag_offset)
return (real + 1j * imag).astype(complex)
def unit_dxes(shape: tuple[int, ...]) -> tuple[tuple[numpy.ndarray, ...], tuple[numpy.ndarray, ...]]:
return tuple(tuple(numpy.ones(length) for length in shape) for _ in range(2)) # type: ignore[return-value]

View file

@ -3,13 +3,12 @@
Test fixtures Test fixtures
""" """
# ruff: noqa: ARG001 from typing import Iterable, Any
from typing import Any
import numpy import numpy
from numpy.typing import NDArray from numpy.typing import NDArray
import pytest # type: ignore import pytest # type: ignore
from .utils import make_prng from .utils import PRNG
FixtureRequest = Any FixtureRequest = Any
@ -21,18 +20,18 @@ FixtureRequest = Any
(5, 5, 5), (5, 5, 5),
# (7, 7, 7), # (7, 7, 7),
]) ])
def shape(request: FixtureRequest) -> tuple[int, ...]: def shape(request: FixtureRequest) -> Iterable[tuple[int, ...]]:
return (3, *request.param) yield (3, *request.param)
@pytest.fixture(scope='module', params=[1.0, 1.5]) @pytest.fixture(scope='module', params=[1.0, 1.5])
def epsilon_bg(request: FixtureRequest) -> float: def epsilon_bg(request: FixtureRequest) -> Iterable[float]:
return request.param yield request.param
@pytest.fixture(scope='module', params=[1.0, 2.5]) @pytest.fixture(scope='module', params=[1.0, 2.5])
def epsilon_fg(request: FixtureRequest) -> float: def epsilon_fg(request: FixtureRequest) -> Iterable[float]:
return request.param yield request.param
@pytest.fixture(scope='module', params=['center', '000', 'random']) @pytest.fixture(scope='module', params=['center', '000', 'random'])
@ -41,8 +40,7 @@ def epsilon(
shape: tuple[int, ...], shape: tuple[int, ...],
epsilon_bg: float, epsilon_bg: float,
epsilon_fg: float, epsilon_fg: float,
) -> NDArray[numpy.float64]: ) -> Iterable[NDArray[numpy.float64]]:
prng = make_prng()
is3d = (numpy.array(shape) == 1).sum() == 0 is3d = (numpy.array(shape) == 1).sum() == 0
if is3d: if is3d:
if request.param == '000': if request.param == '000':
@ -58,23 +56,21 @@ def epsilon(
elif request.param == '000': elif request.param == '000':
epsilon[:, 0, 0, 0] = epsilon_fg epsilon[:, 0, 0, 0] = epsilon_fg
elif request.param == 'random': elif request.param == 'random':
epsilon[:] = prng.uniform( epsilon[:] = PRNG.uniform(low=min(epsilon_bg, epsilon_fg),
low=min(epsilon_bg, epsilon_fg), high=max(epsilon_bg, epsilon_fg),
high=max(epsilon_bg, epsilon_fg), size=shape)
size=shape,
)
return epsilon yield epsilon
@pytest.fixture(scope='module', params=[1.0]) # 1.5 @pytest.fixture(scope='module', params=[1.0]) # 1.5
def j_mag(request: FixtureRequest) -> float: def j_mag(request: FixtureRequest) -> Iterable[float]:
return request.param yield request.param
@pytest.fixture(scope='module', params=[1.0, 1.5]) @pytest.fixture(scope='module', params=[1.0, 1.5])
def dx(request: FixtureRequest) -> float: def dx(request: FixtureRequest) -> Iterable[float]:
return request.param yield request.param
@pytest.fixture(scope='module', params=['uniform', 'centerbig']) @pytest.fixture(scope='module', params=['uniform', 'centerbig'])
@ -82,8 +78,7 @@ def dxes(
request: FixtureRequest, request: FixtureRequest,
shape: tuple[int, ...], shape: tuple[int, ...],
dx: float, dx: float,
) -> list[list[NDArray[numpy.float64]]]: ) -> Iterable[list[list[NDArray[numpy.float64]]]]:
prng = make_prng()
if request.param == 'uniform': if request.param == 'uniform':
dxes = [[numpy.full(s, dx) for s in shape[1:]] for _ in range(2)] dxes = [[numpy.full(s, dx) for s in shape[1:]] for _ in range(2)]
elif request.param == 'centerbig': elif request.param == 'centerbig':
@ -92,7 +87,8 @@ def dxes(
for ax in (0, 1, 2): for ax in (0, 1, 2):
dxes[eh][ax][dxes[eh][ax].size // 2] *= 1.1 dxes[eh][ax][dxes[eh][ax].size // 2] *= 1.1
elif request.param == 'random': elif request.param == 'random':
dxe = [prng.uniform(low=1.0 * dx, high=1.1 * dx, size=s) for s in shape[1:]] dxe = [PRNG.uniform(low=1.0 * dx, high=1.1 * dx, size=s) for s in shape[1:]]
dxh = [(d + numpy.roll(d, -1)) / 2 for d in dxe] dxh = [(d + numpy.roll(d, -1)) / 2 for d in dxe]
dxes = [dxe, dxh] dxes = [dxe, dxh]
return dxes yield dxes

View file

@ -1,73 +0,0 @@
import numpy
from numpy.linalg import norm
from ..fdfd import bloch
from ._bloch_case import EPSILON, G_MATRIX, H_MN, K0_AXIAL, K0_GENERAL, MU, SHAPE, ZERO_H_MN
from .utils import assert_close
def test_generate_kmn_general_case_returns_orthonormal_basis() -> None:
k_mag, m_vecs, n_vecs = bloch.generate_kmn(K0_GENERAL, G_MATRIX, SHAPE)
assert k_mag.shape == SHAPE + (1,)
assert m_vecs.shape == SHAPE + (3,)
assert n_vecs.shape == SHAPE + (3,)
assert numpy.isfinite(k_mag).all()
assert numpy.isfinite(m_vecs).all()
assert numpy.isfinite(n_vecs).all()
assert_close(norm(m_vecs.reshape(-1, 3), axis=1), 1.0)
assert_close(norm(n_vecs.reshape(-1, 3), axis=1), 1.0)
assert_close(numpy.sum(m_vecs * n_vecs, axis=3), 0.0, atol=1e-12)
def test_generate_kmn_z_aligned_uses_default_transverse_basis() -> None:
k_mag, m_vecs, n_vecs = bloch.generate_kmn(K0_AXIAL, G_MATRIX, (1, 1, 1))
assert numpy.isfinite(k_mag).all()
assert_close(m_vecs[0, 0, 0], [0.0, 1.0, 0.0])
assert_close(numpy.sum(m_vecs * n_vecs, axis=3), 0.0, atol=1e-12)
assert_close(norm(n_vecs.reshape(-1, 3), axis=1), 1.0)
def test_maxwell_operator_returns_finite_column_vector_without_mu() -> None:
operator = bloch.maxwell_operator(K0_GENERAL, G_MATRIX, EPSILON)
result = operator(H_MN.copy())
zero_result = operator(ZERO_H_MN.copy())
assert result.shape == (2 * numpy.prod(SHAPE), 1)
assert numpy.isfinite(result).all()
assert_close(zero_result, 0.0)
def test_maxwell_operator_returns_finite_column_vector_with_mu() -> None:
operator = bloch.maxwell_operator(K0_GENERAL, G_MATRIX, EPSILON, MU)
result = operator(H_MN.copy())
zero_result = operator(ZERO_H_MN.copy())
assert result.shape == (2 * numpy.prod(SHAPE), 1)
assert numpy.isfinite(result).all()
assert_close(zero_result, 0.0)
def test_inverse_maxwell_operator_returns_finite_column_vector_for_both_mu_branches() -> None:
for mu in (None, MU):
operator = bloch.inverse_maxwell_operator_approx(K0_GENERAL, G_MATRIX, EPSILON, mu)
result = operator(H_MN.copy())
zero_result = operator(ZERO_H_MN.copy())
assert result.shape == (2 * numpy.prod(SHAPE), 1)
assert numpy.isfinite(result).all()
assert_close(zero_result, 0.0)
def test_bloch_field_converters_return_finite_fields() -> None:
e_field = bloch.hmn_2_exyz(K0_GENERAL, G_MATRIX, EPSILON)(H_MN.copy())
h_field = bloch.hmn_2_hxyz(K0_GENERAL, G_MATRIX, EPSILON)(H_MN.copy())
assert e_field.shape == (3, *SHAPE)
assert h_field.shape == (3, *SHAPE)
assert numpy.isfinite(e_field).all()
assert numpy.isfinite(h_field).all()

View file

@ -1,315 +0,0 @@
import numpy
import pytest
from numpy.testing import assert_allclose
from types import SimpleNamespace
from ..fdfd import bloch
from ._bloch_case import EPSILON, G_MATRIX, H_SIZE, K0_X, Y0, Y0_TWO_MODE, build_overlap_fixture
from .utils import assert_close
def test_rtrace_atb_matches_real_frobenius_inner_product() -> None:
a_mat = numpy.array([[1.0 + 2.0j, 3.0 - 1.0j], [2.0j, 4.0]], dtype=complex)
b_mat = numpy.array([[5.0 - 1.0j, 1.0 + 1.0j], [2.0, 3.0j]], dtype=complex)
expected = numpy.real(numpy.sum(a_mat.conj() * b_mat))
assert bloch._rtrace_AtB(a_mat, b_mat) == expected
def test_symmetrize_returns_hermitian_average() -> None:
matrix = numpy.array([[1.0 + 2.0j, 3.0 - 1.0j], [2.0j, 4.0]], dtype=complex)
result = bloch._symmetrize(matrix)
assert_close(result, 0.5 * (matrix + matrix.conj().T))
assert_close(result, result.conj().T)
def test_inner_product_is_nonmutating_and_obeys_sign_symmetry() -> None:
e_in, h_in, e_out, h_out = build_overlap_fixture()
originals = (e_in.copy(), h_in.copy(), e_out.copy(), h_out.copy())
pp = bloch.inner_product(e_out, h_out, e_in, h_in)
pn = bloch.inner_product(e_out, h_out, e_in, -h_in)
np_term = bloch.inner_product(e_out, -h_out, e_in, h_in)
nn = bloch.inner_product(e_out, -h_out, e_in, -h_in)
assert_close(pp, 0.8164965809277263 + 0.0j)
assert_close(pp, -nn, atol=1e-12, rtol=1e-12)
assert_close(pn, -np_term, atol=1e-12, rtol=1e-12)
assert numpy.array_equal(e_in, originals[0])
assert numpy.array_equal(h_in, originals[1])
assert numpy.array_equal(e_out, originals[2])
assert numpy.array_equal(h_out, originals[3])
def test_trq_returns_expected_transmission_and_reflection() -> None:
e_in, h_in, e_out, h_out = build_overlap_fixture()
transmission, reflection = bloch.trq(e_in, h_in, e_out, h_out)
assert_close(transmission, 0.9797958971132713 + 0.0j, atol=1e-12, rtol=1e-12)
assert_close(reflection, 0.2 + 0.0j, atol=1e-12, rtol=1e-12)
def test_eigsolve_returns_finite_modes_with_small_residual() -> None:
callback_count = 0
def callback() -> None:
nonlocal callback_count
callback_count += 1
eigvals, eigvecs = bloch.eigsolve(
1,
K0_X,
G_MATRIX,
EPSILON,
tolerance=1e-6,
max_iters=50,
y0=Y0,
callback=callback,
)
operator = bloch.maxwell_operator(K0_X, G_MATRIX, EPSILON)
eigvec = eigvecs[0] / numpy.linalg.norm(eigvecs[0])
residual = numpy.linalg.norm(operator(eigvec).reshape(-1) - eigvals[0] * eigvec) / numpy.linalg.norm(eigvec)
assert eigvals.shape == (1,)
assert eigvecs.shape == (1, H_SIZE)
assert numpy.isfinite(eigvals).all()
assert numpy.isfinite(eigvecs).all()
assert residual < 1e-5
assert callback_count > 0
def test_eigsolve_without_initial_guess_returns_finite_modes() -> None:
eigvals, eigvecs = bloch.eigsolve(
1,
K0_X,
G_MATRIX,
EPSILON,
tolerance=1e-6,
max_iters=20,
y0=None,
)
operator = bloch.maxwell_operator(K0_X, G_MATRIX, EPSILON)
eigvec = eigvecs[0] / numpy.linalg.norm(eigvecs[0])
residual = numpy.linalg.norm(operator(eigvec).reshape(-1) - eigvals[0] * eigvec) / numpy.linalg.norm(eigvec)
assert eigvals.shape == (1,)
assert eigvecs.shape == (1, H_SIZE)
assert numpy.isfinite(eigvals).all()
assert numpy.isfinite(eigvecs).all()
assert residual < 1e-5
def test_eigsolve_recovers_from_singular_initial_subspace(monkeypatch: pytest.MonkeyPatch) -> None:
class FakeRng:
def __init__(self) -> None:
self.calls = 0
def random(self, shape: tuple[int, ...]) -> numpy.ndarray:
self.calls += 1
return numpy.arange(numpy.prod(shape), dtype=float).reshape(shape) + self.calls
fake_rng = FakeRng()
singular_y0 = numpy.vstack([Y0_TWO_MODE[0], Y0_TWO_MODE[0]])
monkeypatch.setattr(bloch.numpy.random, 'default_rng', lambda: fake_rng)
eigvals, eigvecs = bloch.eigsolve(
2,
K0_X,
G_MATRIX,
EPSILON,
tolerance=1e-6,
max_iters=20,
y0=singular_y0,
)
assert fake_rng.calls == 2
assert eigvals.shape == (2,)
assert eigvecs.shape == (2, H_SIZE)
assert numpy.isfinite(eigvals).all()
assert numpy.isfinite(eigvecs).all()
def test_eigsolve_reconditions_large_trace_initial_subspace(monkeypatch: pytest.MonkeyPatch) -> None:
original_inv = bloch.numpy.linalg.inv
original_sqrtm = bloch.scipy.linalg.sqrtm
sqrtm_calls = 0
inv_calls = 0
def inv_with_large_first_trace(matrix: numpy.ndarray) -> numpy.ndarray:
nonlocal inv_calls
inv_calls += 1
if inv_calls == 1:
return numpy.eye(matrix.shape[0], dtype=complex) * 1e9
return original_inv(matrix)
def sqrtm_wrapper(matrix: numpy.ndarray) -> numpy.ndarray:
nonlocal sqrtm_calls
sqrtm_calls += 1
return original_sqrtm(matrix)
monkeypatch.setattr(bloch.numpy.linalg, 'inv', inv_with_large_first_trace)
monkeypatch.setattr(bloch.scipy.linalg, 'sqrtm', sqrtm_wrapper)
eigvals, eigvecs = bloch.eigsolve(
2,
K0_X,
G_MATRIX,
EPSILON,
tolerance=1e-6,
max_iters=20,
y0=Y0_TWO_MODE,
)
assert sqrtm_calls >= 2
assert eigvals.shape == (2,)
assert eigvecs.shape == (2, H_SIZE)
assert numpy.isfinite(eigvals).all()
assert numpy.isfinite(eigvecs).all()
def test_eigsolve_qi_memoization_reuses_cached_theta(monkeypatch: pytest.MonkeyPatch) -> None:
def fake_minimize_scalar(func, method: str, bounds: tuple[float, float], options: dict[str, float]) -> SimpleNamespace:
theta = 0.3
first = func(theta)
second = func(theta)
assert_allclose(second, first)
return SimpleNamespace(fun=second, x=theta)
monkeypatch.setattr(bloch.scipy.optimize, 'minimize_scalar', fake_minimize_scalar)
eigvals, eigvecs = bloch.eigsolve(
1,
K0_X,
G_MATRIX,
EPSILON,
tolerance=1e-6,
max_iters=1,
y0=Y0,
)
assert eigvals.shape == (1,)
assert eigvecs.shape == (1, H_SIZE)
assert numpy.isfinite(eigvals).all()
assert numpy.isfinite(eigvecs).all()
@pytest.mark.parametrize('theta', [numpy.pi / 2 - 1e-8, 1e-8])
def test_eigsolve_qi_taylor_expansions_return_finite_modes(monkeypatch: pytest.MonkeyPatch, theta: float) -> None:
original_inv = bloch.numpy.linalg.inv
inv_calls = 0
def inv_raise_once_for_q(matrix: numpy.ndarray) -> numpy.ndarray:
nonlocal inv_calls
inv_calls += 1
if inv_calls == 3:
raise numpy.linalg.LinAlgError('forced singular Q')
return original_inv(matrix)
def fake_minimize_scalar(func, method: str, bounds: tuple[float, float], options: dict[str, float]) -> SimpleNamespace:
value = func(theta)
return SimpleNamespace(fun=value, x=theta)
monkeypatch.setattr(bloch.numpy.linalg, 'inv', inv_raise_once_for_q)
monkeypatch.setattr(bloch.scipy.optimize, 'minimize_scalar', fake_minimize_scalar)
eigvals, eigvecs = bloch.eigsolve(
1,
K0_X,
G_MATRIX,
EPSILON,
tolerance=1e-6,
max_iters=1,
y0=Y0,
)
assert eigvals.shape == (1,)
assert eigvecs.shape == (1, H_SIZE)
assert numpy.isfinite(eigvals).all()
assert numpy.isfinite(eigvecs).all()
def test_eigsolve_qi_inexplicable_singularity_raises(monkeypatch: pytest.MonkeyPatch) -> None:
original_inv = bloch.numpy.linalg.inv
inv_calls = 0
def inv_raise_once_for_q(matrix: numpy.ndarray) -> numpy.ndarray:
nonlocal inv_calls
inv_calls += 1
if inv_calls == 3:
raise numpy.linalg.LinAlgError('forced singular Q')
return original_inv(matrix)
def fake_minimize_scalar(func, method: str, bounds: tuple[float, float], options: dict[str, float]) -> SimpleNamespace:
func(numpy.pi / 4)
raise AssertionError('unreachable after trace_func exception')
monkeypatch.setattr(bloch.numpy.linalg, 'inv', inv_raise_once_for_q)
monkeypatch.setattr(bloch.scipy.optimize, 'minimize_scalar', fake_minimize_scalar)
with pytest.raises(Exception, match='Inexplicable singularity in trace_func'):
bloch.eigsolve(
1,
K0_X,
G_MATRIX,
EPSILON,
tolerance=1e-6,
max_iters=1,
y0=Y0,
)
def test_find_k_returns_vector_frequency_and_callbacks() -> None:
target_eigvals, _target_eigvecs = bloch.eigsolve(
1,
K0_X,
G_MATRIX,
EPSILON,
tolerance=1e-6,
max_iters=50,
y0=Y0,
)
target_frequency = float(numpy.sqrt(abs(numpy.real(target_eigvals[0]))))
solve_calls = 0
iter_calls = 0
def solve_callback(k_mag: float, eigvals: numpy.ndarray, eigvecs: numpy.ndarray, frequency: float) -> None:
nonlocal solve_calls
solve_calls += 1
assert eigvals.shape == (1,)
assert eigvecs.shape == (1, H_SIZE)
assert isinstance(k_mag, float)
assert isinstance(frequency, float)
def iter_callback() -> None:
nonlocal iter_calls
iter_calls += 1
found_k, found_frequency, eigvals, eigvecs = bloch.find_k(
target_frequency,
1e-4,
[1, 0, 0],
G_MATRIX,
EPSILON,
band=0,
k_bounds=(0.05, 0.15),
v0=Y0,
solve_callback=solve_callback,
iter_callback=iter_callback,
)
assert found_k.shape == (3,)
assert numpy.isfinite(found_k).all()
assert_close(numpy.cross(found_k, [1.0, 0.0, 0.0]), 0.0, atol=1e-12, rtol=1e-12)
assert_close(found_k, K0_X, atol=1e-4, rtol=1e-4)
assert abs(found_frequency - target_frequency) <= 1e-4
assert eigvals.shape == (1,)
assert eigvecs.shape == (1, H_SIZE)
assert numpy.isfinite(eigvals).all()
assert numpy.isfinite(eigvecs).all()
assert solve_calls > 0
assert iter_calls > 0

View file

@ -1,101 +0,0 @@
import numpy
from numpy.linalg import norm
import pytest
from ._solver_cases import diagonal_eigen_case
from .utils import assert_close
from ..eigensolvers import power_iteration, rayleigh_quotient_iteration, signed_eigensolve
def test_rayleigh_quotient_iteration_with_linear_operator() -> None:
case = diagonal_eigen_case()
def dense_solver(
shifted_operator,
rhs: numpy.ndarray,
) -> numpy.ndarray:
basis = numpy.eye(case.operator.shape[0], dtype=complex)
columns = [shifted_operator.matvec(basis[:, ii]) for ii in range(case.operator.shape[0])]
dense_matrix = numpy.column_stack(columns)
return numpy.linalg.lstsq(dense_matrix, rhs, rcond=None)[0]
eigval, eigvec = rayleigh_quotient_iteration(
case.linear_operator,
case.guess_default,
iterations=8,
solver=dense_solver,
)
residual = norm(case.operator @ eigvec - eigval * eigvec)
assert abs(eigval - 3.0) < 1e-12
assert residual < 1e-12
def test_signed_eigensolve_negative_returns_largest_negative_mode() -> None:
case = diagonal_eigen_case()
eigvals, eigvecs = signed_eigensolve(case.operator, how_many=1, negative=True)
assert eigvals.shape == (1,)
assert eigvecs.shape == (4, 1)
assert abs(eigvals[0] + 2.0) < 1e-12
assert abs(eigvecs[3, 0]) > 0.99
def test_rayleigh_quotient_iteration_uses_default_linear_operator_solver() -> None:
case = diagonal_eigen_case()
eigval, eigvec = rayleigh_quotient_iteration(
case.linear_operator,
case.guess_default,
iterations=8,
)
residual = norm(case.operator @ eigvec - eigval * eigvec)
assert abs(eigval - 3.0) < 1e-12
assert residual < 1e-12
def test_signed_eigensolve_linear_operator_fallback_returns_dominant_positive_mode() -> None:
case = diagonal_eigen_case()
eigvals, eigvecs = signed_eigensolve(case.linear_operator, how_many=1)
assert eigvals.shape == (1,)
assert eigvecs.shape == (4, 1)
assert_close(eigvals[0], 5.0, atol=1e-12, rtol=1e-12)
assert abs(eigvecs[0, 0]) > 0.99
def test_power_iteration_finds_dominant_mode() -> None:
case = diagonal_eigen_case()
eigval, eigvec = power_iteration(case.operator, guess_vector=numpy.ones(4, dtype=complex), iterations=20)
assert eigval == pytest.approx(5.0, rel=1e-6)
assert abs(eigvec[0]) > abs(eigvec[1])
def test_rayleigh_quotient_iteration_refines_known_sparse_mode() -> None:
case = diagonal_eigen_case()
def solver(matrix, rhs: numpy.ndarray) -> numpy.ndarray:
return numpy.linalg.lstsq(matrix.toarray(), rhs, rcond=None)[0]
eigval, eigvec = rayleigh_quotient_iteration(
case.operator,
case.guess_sparse,
iterations=8,
solver=solver,
)
residual = numpy.linalg.norm(case.operator @ eigvec - eigval * eigvec)
assert eigval == pytest.approx(3.0, rel=1e-6)
assert residual < 1e-8
def test_signed_eigensolve_returns_largest_positive_modes() -> None:
case = diagonal_eigen_case()
eigvals, eigvecs = signed_eigensolve(case.operator, how_many=2)
assert_close(eigvals, [3.0, 5.0], atol=1e-6)
assert eigvecs.shape == (4, 2)

View file

@ -1,491 +0,0 @@
from typing import cast
import numpy
import pytest
from scipy import sparse
from ..fdmath import vec
from ..fdfd import eme, waveguide_2d, waveguide_cyl
from ._test_builders import complex_ramp, unit_dxes
from .utils import assert_close
SHAPE = (3, 2, 2)
DXES = unit_dxes((2, 2))
WAVENUMBERS_L = numpy.array([1.0, 0.8])
WAVENUMBERS_R = numpy.array([0.9, 0.7])
OMEGA = 1 / 1500
REAL_DXES = unit_dxes((5, 5))
def _mode(scale: float) -> tuple[numpy.ndarray, numpy.ndarray]:
e_field = complex_ramp(SHAPE, offset=1.0 + scale)
h_field = complex_ramp(SHAPE, scale=0.2, offset=2.0, imag_offset=0.05 * scale)
return vec(e_field), vec(h_field)
def _mode_sets() -> tuple[list[tuple[numpy.ndarray, numpy.ndarray]], list[tuple[numpy.ndarray, numpy.ndarray]]]:
left_modes = [_mode(0.0), _mode(0.7)]
right_modes = [_mode(1.4), _mode(2.1)]
return left_modes, right_modes
def _gain_only_tr(*args, **kwargs) -> tuple[numpy.ndarray, numpy.ndarray]:
return numpy.array([[2.0, 0.0], [0.0, 0.5]]), numpy.zeros((2, 2))
def _gain_and_reflection_tr(*args, **kwargs) -> tuple[numpy.ndarray, numpy.ndarray]:
return numpy.array([[2.0, 0.0], [0.0, 0.5]]), numpy.array([[0.0, 1.0], [2.0, 0.0]])
def _nonsymmetric_tr(left_marker: object):
def fake_get_tr(_eh_left, wavenumbers_left, _eh_right, _wavenumbers_right, **kwargs):
if wavenumbers_left is left_marker:
return (
numpy.array([[1.0, 2.0], [0.5, 1.0]]),
numpy.array([[0.0, 1.0], [2.0, 0.0]]),
)
return (
numpy.array([[1.0, -1.0], [0.0, 1.0]]),
numpy.array([[0.0, 0.5], [1.5, 0.0]]),
)
return fake_get_tr
def _dummy_modes() -> tuple[list[tuple[numpy.ndarray, numpy.ndarray]], numpy.ndarray]:
return [_mode(0.0), _mode(0.7)], numpy.array([1.0, 0.5])
def test_get_tr_returns_finite_bounded_transfer_matrices() -> None:
left_modes, right_modes = _mode_sets()
transmission, reflection = eme.get_tr(
left_modes,
WAVENUMBERS_L,
right_modes,
WAVENUMBERS_R,
dxes=DXES,
)
singular_values = numpy.linalg.svd(transmission, compute_uv=False)
assert transmission.shape == (2, 2)
assert reflection.shape == (2, 2)
assert numpy.isfinite(transmission).all()
assert numpy.isfinite(reflection).all()
assert (singular_values <= 1.0 + 1e-12).all()
def test_get_abcd_matches_explicit_block_formula() -> None:
left_modes, right_modes = _mode_sets()
t12, r12 = eme.get_tr(left_modes, WAVENUMBERS_L, right_modes, WAVENUMBERS_R, dxes=DXES)
t21, r21 = eme.get_tr(right_modes, WAVENUMBERS_R, left_modes, WAVENUMBERS_L, dxes=DXES)
t21_inv = numpy.linalg.pinv(t21)
expected = numpy.block([
[t12 - r21 @ t21_inv @ r12, r21 @ t21_inv],
[-t21_inv @ r12, t21_inv],
])
abcd = eme.get_abcd(left_modes, WAVENUMBERS_L, right_modes, WAVENUMBERS_R, dxes=DXES)
assert sparse.issparse(abcd)
assert abcd.shape == (4, 4)
assert_close(abcd.toarray(), expected)
def test_get_s_plain_matches_block_assembly_from_get_tr() -> None:
left_modes, right_modes = _mode_sets()
t12, r12 = eme.get_tr(left_modes, WAVENUMBERS_L, right_modes, WAVENUMBERS_R, dxes=DXES)
t21, r21 = eme.get_tr(right_modes, WAVENUMBERS_R, left_modes, WAVENUMBERS_L, dxes=DXES)
expected = numpy.block([[r12, t12], [t21, r21]])
ss = eme.get_s(left_modes, WAVENUMBERS_L, right_modes, WAVENUMBERS_R, dxes=DXES)
assert ss.shape == (4, 4)
assert numpy.isfinite(ss).all()
assert_close(ss, expected)
def test_get_s_force_nogain_caps_singular_values(monkeypatch) -> None:
monkeypatch.setattr(eme, 'get_tr', _gain_only_tr)
modes, wavenumbers = _dummy_modes()
plain_s = eme.get_s(modes, wavenumbers, modes, wavenumbers)
clipped_s = eme.get_s(modes, wavenumbers, modes, wavenumbers, force_nogain=True)
plain_singular_values = numpy.linalg.svd(plain_s, compute_uv=False)
clipped_singular_values = numpy.linalg.svd(clipped_s, compute_uv=False)
assert plain_singular_values.max() > 1.0
assert (clipped_singular_values <= 1.0 + 1e-12).all()
assert numpy.isfinite(clipped_s).all()
def test_get_s_force_reciprocal_symmetrizes_output(monkeypatch) -> None:
left = numpy.array([1.0, 0.5])
right = numpy.array([0.9, 0.4])
modes, _wavenumbers = _dummy_modes()
monkeypatch.setattr(eme, 'get_tr', _nonsymmetric_tr(left))
ss = eme.get_s(modes, left, modes, right, force_reciprocal=True)
assert_close(ss, ss.T)
def test_get_s_force_nogain_and_reciprocal_returns_finite_output(monkeypatch) -> None:
monkeypatch.setattr(eme, 'get_tr', _gain_and_reflection_tr)
modes, wavenumbers = _dummy_modes()
ss = eme.get_s(modes, wavenumbers, modes, wavenumbers, force_nogain=True, force_reciprocal=True)
assert ss.shape == (4, 4)
assert numpy.isfinite(ss).all()
assert_close(ss, ss.T)
assert (numpy.linalg.svd(ss, compute_uv=False) <= 1.0 + 1e-12).all()
def test_get_tr_rejects_length_mismatches() -> None:
left_modes, right_modes = _mode_sets()
with pytest.raises(ValueError, match='same length'):
eme.get_tr(left_modes[:1], WAVENUMBERS_L, right_modes, WAVENUMBERS_R, dxes=DXES)
def test_get_tr_rejects_malformed_mode_tuples() -> None:
bad_modes = cast(list[tuple[numpy.ndarray, numpy.ndarray]], [(numpy.ones(4, dtype=complex),)])
with pytest.raises(ValueError, match='2-tuple'):
eme.get_tr(bad_modes, [1.0], bad_modes, [1.0], dxes=DXES)
def test_get_tr_rejects_incompatible_field_shapes() -> None:
left_modes = [(numpy.ones(4, dtype=complex), numpy.ones(4, dtype=complex))]
right_modes = [(numpy.ones(6, dtype=complex), numpy.ones(6, dtype=complex))]
with pytest.raises(ValueError, match='same E/H shapes'):
eme.get_tr(left_modes, [1.0], right_modes, [1.0], dxes=DXES)
def _build_real_epsilon() -> numpy.ndarray:
epsilon = numpy.ones((3, 5, 5), dtype=float)
epsilon[:, 2, 1] = 2.0
return vec(epsilon)
def _build_straight_mode() -> tuple[tuple[numpy.ndarray, numpy.ndarray], complex, numpy.ndarray]:
epsilon = _build_real_epsilon()
e_xy, wavenumber = waveguide_2d.solve_mode(
0,
omega=OMEGA,
dxes=REAL_DXES,
epsilon=epsilon,
)
e_field, h_field = waveguide_2d.normalized_fields_e(
e_xy,
wavenumber=wavenumber,
omega=OMEGA,
dxes=REAL_DXES,
epsilon=epsilon,
)
return (e_field, h_field), wavenumber, epsilon
def _build_bend_mode() -> tuple[tuple[numpy.ndarray, numpy.ndarray], complex]:
epsilon = vec(numpy.ones((3, 5, 5), dtype=float))
rmin = 10.0
e_xy, angular_wavenumber = waveguide_cyl.solve_mode(
0,
omega=OMEGA,
dxes=REAL_DXES,
epsilon=epsilon,
rmin=rmin,
)
linear_wavenumber = waveguide_cyl.linear_wavenumbers(
[e_xy],
[angular_wavenumber],
epsilon=epsilon,
dxes=REAL_DXES,
rmin=rmin,
)[0]
e_field, h_field = waveguide_cyl.normalized_fields_e(
e_xy,
angular_wavenumber=angular_wavenumber,
omega=OMEGA,
dxes=REAL_DXES,
epsilon=epsilon,
rmin=rmin,
)
return (e_field, h_field), linear_wavenumber
def _build_uniform_mode(index: float) -> tuple[tuple[numpy.ndarray, numpy.ndarray], complex]:
area = 25.0
e_field = numpy.zeros((3, 5, 5), dtype=complex)
h_field = numpy.zeros((3, 5, 5), dtype=complex)
e_field[0] = numpy.sqrt(2.0 / (index * area))
h_field[1] = numpy.sqrt(2.0 * index / area)
return (vec(e_field), vec(h_field)), complex(index * OMEGA)
def _interface_s(n_left: float, n_right: float) -> numpy.ndarray:
left_mode, left_beta = _build_uniform_mode(n_left)
right_mode, right_beta = _build_uniform_mode(n_right)
return eme.get_s([left_mode], [left_beta], [right_mode], [right_beta], dxes=REAL_DXES)
def _interface_abcd(n_left: float, n_right: float) -> numpy.ndarray:
left_mode, left_beta = _build_uniform_mode(n_left)
right_mode, right_beta = _build_uniform_mode(n_right)
return eme.get_abcd([left_mode], [left_beta], [right_mode], [right_beta], dxes=REAL_DXES).toarray()
def _expected_interface_s(n_left: float, n_right: float) -> numpy.ndarray:
reflection = (n_left - n_right) / (n_left + n_right)
transmission = 2 * numpy.sqrt(n_left * n_right) / (n_left + n_right)
return numpy.array(
[
[reflection, transmission],
[transmission, -reflection],
],
dtype=complex,
)
def _propagation_abcd(beta: complex, length: float) -> numpy.ndarray:
phase = numpy.exp(-1j * beta * length)
return numpy.array(
[
[phase, 0.0],
[0.0, phase ** -1],
],
dtype=complex,
)
def _abcd_to_s(abcd: numpy.ndarray) -> numpy.ndarray:
aa = abcd[0, 0]
bb = abcd[0, 1]
cc = abcd[1, 0]
dd = abcd[1, 1]
t21 = 1.0 / dd
r21 = bb / dd
r12 = -cc / dd
t12 = aa - bb * cc / dd
return numpy.array(
[
[r12, t12],
[t21, r21],
],
dtype=complex,
)
def _expected_bragg_reflector_s(n_low: float, n_high: float, periods: int) -> numpy.ndarray:
ratio = n_high / n_low
reflection = (1 - ratio ** (2 * periods)) / (1 + ratio ** (2 * periods))
transmission = ((-1) ** periods) * 2 * ratio ** periods / (1 + ratio ** (2 * periods))
return numpy.array(
[
[reflection, transmission],
[transmission, -reflection],
],
dtype=complex,
)
def test_get_s_is_near_identity_for_identical_solved_straight_modes() -> None:
mode, wavenumber, _epsilon = _build_straight_mode()
ss = eme.get_s([mode], [wavenumber], [mode], [wavenumber], dxes=REAL_DXES)
assert ss.shape == (2, 2)
assert numpy.isfinite(ss).all()
assert abs(ss[0, 0]) < 1e-6
assert abs(ss[1, 1]) < 1e-6
assert abs(abs(ss[0, 1]) - 1.0) < 1e-6
assert abs(abs(ss[1, 0]) - 1.0) < 1e-6
assert numpy.linalg.svd(ss, compute_uv=False).max() <= 1.0 + 1e-10
def test_get_s_returns_finite_passive_output_for_small_straight_to_bend_fixture() -> None:
straight_mode, straight_wavenumber, _epsilon = _build_straight_mode()
bend_mode, bend_wavenumber = _build_bend_mode()
ss = eme.get_s([straight_mode], [straight_wavenumber], [bend_mode], [bend_wavenumber], dxes=REAL_DXES)
assert ss.shape == (2, 2)
assert numpy.isfinite(ss).all()
assert numpy.linalg.svd(ss, compute_uv=False).max() <= 1.0 + 1e-10
def test_get_s_matches_analytic_fresnel_interface_for_uniform_one_mode_ports() -> None:
"""
For power-normalized one-mode ports at normal incidence, the interface matrix is
r12 = (n_left - n_right) / (n_left + n_right)
r21 = -r12
t12 = t21 = 2 * sqrt(n_left * n_right) / (n_left + n_right)
so
S = [[r12, t12], [t21, r21]].
"""
ss = _interface_s(1.0, 2.0)
expected = _expected_interface_s(1.0, 2.0)
assert ss.shape == (2, 2)
assert numpy.isfinite(ss).all()
assert_close(ss, expected, atol=1e-6, rtol=1e-6)
assert numpy.linalg.svd(ss, compute_uv=False).max() <= 1.0 + 1e-10
def test_quarter_wave_matching_layer_is_nearly_reflectionless_at_design_frequency() -> None:
"""
A single quarter-wave matching layer with
n1 = sqrt(n0 * n2), beta1 * L = pi / 2
cancels the two interface reflections at the design wavelength, so the
normal-incidence stack should satisfy `r = 0` and `|t| = 1`.
"""
n0 = 1.0
n1 = numpy.sqrt(2.0)
n2 = 2.0
interface_01 = _interface_abcd(n0, n1)
interface_12 = _interface_abcd(n1, n2)
_mode_1, beta_1 = _build_uniform_mode(float(n1))
quarter_wave_length = numpy.pi / (2 * numpy.real(beta_1))
stack_abcd = interface_01 @ _propagation_abcd(beta_1, quarter_wave_length) @ interface_12
ss = _abcd_to_s(stack_abcd)
assert ss.shape == (2, 2)
assert numpy.isfinite(ss).all()
assert abs(ss[0, 0]) < 1e-12
assert abs(ss[1, 1]) < 1e-12
assert abs(abs(ss[0, 1]) - 1.0) < 1e-12
assert abs(abs(ss[1, 0]) - 1.0) < 1e-12
assert numpy.linalg.svd(ss, compute_uv=False).max() <= 1.0 + 1e-10
def test_quarter_wave_ar_layer_reduces_reflection_relative_to_abrupt_interface() -> None:
"""
Compare the abrupt interface `n0 -> n2` against the quarter-wave matching-layer
stack `n0 -> sqrt(n0 n2) -> n2` at the same design wavelength.
For the canonical `n0 = 1`, `n2 = 2` case, the abrupt interface has
|r_abrupt| = |(n0 - n2) / (n0 + n2)| = 1 / 3,
while the quarter-wave matching layer should cancel the interface reflections
so that `|r_ar|` is essentially zero and `|t_ar|` is correspondingly larger.
"""
n0 = 1.0
n2 = 2.0
abrupt = _interface_s(n0, n2)
n1 = numpy.sqrt(n0 * n2)
interface_01 = _interface_abcd(n0, n1)
interface_12 = _interface_abcd(n1, n2)
_mode_1, beta_1 = _build_uniform_mode(float(n1))
quarter_wave_length = numpy.pi / (2 * numpy.real(beta_1))
ar_stack = _abcd_to_s(interface_01 @ _propagation_abcd(beta_1, quarter_wave_length) @ interface_12)
abrupt_reflection = abs(abrupt[0, 0])
abrupt_transmission = abs(abrupt[1, 0])
ar_reflection = abs(ar_stack[0, 0])
ar_transmission = abs(ar_stack[1, 0])
assert numpy.linalg.svd(abrupt, compute_uv=False).max() <= 1.0 + 1e-10
assert numpy.linalg.svd(ar_stack, compute_uv=False).max() <= 1.0 + 1e-10
assert ar_reflection < abrupt_reflection
assert ar_transmission > abrupt_transmission
assert ar_reflection < 1e-12
assert abs(abrupt_reflection - (1.0 / 3.0)) < 1e-12
def test_half_wave_uniform_slab_restores_unit_transmission_between_matched_media() -> None:
"""
For matched exterior media `n0 = n2`, a half-wave slab with
beta1 * L = pi
contributes only a global phase, so the stack returns to `r = 0` and
`|t| = 1` at the design wavelength.
"""
n0 = 1.0
n1 = 2.0
interface_01 = _interface_abcd(n0, n1)
interface_10 = _interface_abcd(n1, n0)
_mode_1, beta_1 = _build_uniform_mode(n1)
half_wave_length = numpy.pi / numpy.real(beta_1)
stack_abcd = interface_01 @ _propagation_abcd(beta_1, half_wave_length) @ interface_10
ss = _abcd_to_s(stack_abcd)
assert ss.shape == (2, 2)
assert numpy.isfinite(ss).all()
assert abs(ss[0, 0]) < 1e-12
assert abs(ss[1, 1]) < 1e-12
assert abs(abs(ss[0, 1]) - 1.0) < 1e-12
assert abs(abs(ss[1, 0]) - 1.0) < 1e-12
assert numpy.linalg.svd(ss, compute_uv=False).max() <= 1.0 + 1e-10
def test_strong_uniform_index_mismatch_behaves_like_near_termination() -> None:
"""
In the large-index-ratio limit, the same Fresnel formulas approach a hard-wall
reflector:
|r| -> 1, |t| -> 0 as n_right / n_left -> infinity.
"""
ss = _interface_s(1.0, 100.0)
expected = _expected_interface_s(1.0, 100.0)
assert ss.shape == (2, 2)
assert numpy.isfinite(ss).all()
assert_close(ss, expected, atol=1e-6, rtol=1e-6)
assert abs(ss[0, 0]) > 0.9
assert abs(ss[1, 0]) < 0.25
assert numpy.linalg.svd(ss, compute_uv=False).max() <= 1.0 + 1e-10
def test_quarter_wave_bragg_reflector_matches_closed_form_stopband_response() -> None:
"""
For `N` quarter-wave high/low periods at the Bragg wavelength with identical
low-index incident and exit media (`n0 = ns = n_low`),
M_pair = diag(-(n_low / n_high), -(n_high / n_low))
M_stack = M_pair ** N
which yields the closed-form scattering amplitudes
r = (1 - (n_high / n_low) ** (2N)) / (1 + (n_high / n_low) ** (2N))
t = 2 * (n_high / n_low) ** N / (1 + (n_high / n_low) ** (2N)).
The reflector should therefore sit deep in the stopband with `|r|` near 1 and
`|t|` correspondingly small.
"""
n_low = 1.0
n_high = 2.0
periods = 5
interface_lh = _interface_abcd(n_low, n_high)
interface_hl = _interface_abcd(n_high, n_low)
_mode_h, beta_h = _build_uniform_mode(n_high)
_mode_l, beta_l = _build_uniform_mode(n_low)
quarter_wave_high = numpy.pi / (2 * numpy.real(beta_h))
quarter_wave_low = numpy.pi / (2 * numpy.real(beta_l))
stack_abcd = numpy.eye(2, dtype=complex)
for _ in range(periods):
stack_abcd = stack_abcd @ interface_lh @ _propagation_abcd(beta_h, quarter_wave_high)
stack_abcd = stack_abcd @ interface_hl @ _propagation_abcd(beta_l, quarter_wave_low)
ss = _abcd_to_s(stack_abcd)
expected = _expected_bragg_reflector_s(n_low, n_high, periods)
assert ss.shape == (2, 2)
assert numpy.isfinite(ss).all()
assert_close(ss, expected, atol=1e-12, rtol=1e-12)
assert abs(ss[0, 0]) > 0.99
assert abs(ss[1, 0]) < 0.1
assert numpy.linalg.svd(ss, compute_uv=False).max() <= 1.0 + 1e-10

View file

@ -1,47 +0,0 @@
from pathlib import Path
import os
import subprocess
import sys
import pytest
pytestmark = pytest.mark.complete
REPO_ROOT = Path(__file__).resolve().parents[2]
def _run_example(example_name: str, tmp_path: Path) -> subprocess.CompletedProcess[str]:
env = os.environ.copy()
env['MPLBACKEND'] = 'Agg'
env['MPLCONFIGDIR'] = str(tmp_path / f'mpl-{example_name}')
return subprocess.run(
[sys.executable, str(REPO_ROOT / 'examples' / example_name)],
cwd=REPO_ROOT,
env=env,
text=True,
capture_output=True,
check=False,
timeout=60,
)
def test_eme_example_smoke_runs(tmp_path: Path) -> None:
pytest.importorskip('matplotlib')
result = _run_example('eme.py', tmp_path)
assert result.returncode == 0, result.stdout + result.stderr
assert 'left effective indices:' in result.stdout
assert 'fundamental left-to-right transmission' in result.stdout
def test_eme_bend_example_smoke_runs(tmp_path: Path) -> None:
pytest.importorskip('matplotlib')
pytest.importorskip('skrf')
result = _run_example('eme_bend.py', tmp_path)
assert result.returncode == 0, result.stdout + result.stderr
assert 'straight effective indices:' in result.stdout
assert 'cascaded bend through power' in result.stdout

View file

@ -1,4 +1,4 @@
# ruff: noqa: ARG001 from typing import Iterable
import dataclasses import dataclasses
import pytest # type: ignore import pytest # type: ignore
import numpy import numpy
@ -6,7 +6,7 @@ from numpy.typing import NDArray
#from numpy.testing import assert_allclose, assert_array_equal #from numpy.testing import assert_allclose, assert_array_equal
from .. import fdfd from .. import fdfd
from ..fdmath import vec, unvec, vcfdfield, vfdfield, dx_lists_t from ..fdmath import vec, unvec
from .utils import assert_close # , assert_fields_close from .utils import assert_close # , assert_fields_close
from .conftest import FixtureRequest from .conftest import FixtureRequest
@ -61,24 +61,24 @@ def test_poynting_planes(sim: 'FDResult') -> None:
# Also see conftest.py # Also see conftest.py
@pytest.fixture(params=[1 / 1500]) @pytest.fixture(params=[1 / 1500])
def omega(request: FixtureRequest) -> float: def omega(request: FixtureRequest) -> Iterable[float]:
return request.param yield request.param
@pytest.fixture(params=[None]) @pytest.fixture(params=[None])
def pec(request: FixtureRequest) -> NDArray[numpy.float64] | None: def pec(request: FixtureRequest) -> Iterable[NDArray[numpy.float64] | None]:
return request.param yield request.param
@pytest.fixture(params=[None]) @pytest.fixture(params=[None])
def pmc(request: FixtureRequest) -> NDArray[numpy.float64] | None: def pmc(request: FixtureRequest) -> Iterable[NDArray[numpy.float64] | None]:
return request.param yield request.param
#@pytest.fixture(scope='module', #@pytest.fixture(scope='module',
# params=[(25, 5, 5)]) # params=[(25, 5, 5)])
#def shape(request: FixtureRequest): #def shape(request):
# return (3, *request.param) # yield (3, *request.param)
@pytest.fixture(params=['diag']) # 'center' @pytest.fixture(params=['diag']) # 'center'
@ -86,7 +86,7 @@ def j_distribution(
request: FixtureRequest, request: FixtureRequest,
shape: tuple[int, ...], shape: tuple[int, ...],
j_mag: float, j_mag: float,
) -> NDArray[numpy.float64]: ) -> Iterable[NDArray[numpy.float64]]:
j = numpy.zeros(shape, dtype=complex) j = numpy.zeros(shape, dtype=complex)
center_mask = numpy.zeros(shape, dtype=bool) center_mask = numpy.zeros(shape, dtype=bool)
center_mask[:, shape[1] // 2, shape[2] // 2, shape[3] // 2] = True center_mask[:, shape[1] // 2, shape[2] // 2, shape[3] // 2] = True
@ -96,22 +96,22 @@ def j_distribution(
elif request.param == 'diag': elif request.param == 'diag':
j[numpy.roll(center_mask, [1, 1, 1], axis=(1, 2, 3))] = (1 + 1j) * j_mag j[numpy.roll(center_mask, [1, 1, 1], axis=(1, 2, 3))] = (1 + 1j) * j_mag
j[numpy.roll(center_mask, [-1, -1, -1], axis=(1, 2, 3))] = (1 - 1j) * j_mag j[numpy.roll(center_mask, [-1, -1, -1], axis=(1, 2, 3))] = (1 - 1j) * j_mag
return j yield j
@dataclasses.dataclass() @dataclasses.dataclass()
class FDResult: class FDResult:
shape: tuple[int, ...] shape: tuple[int, ...]
dxes: dx_lists_t dxes: list[list[NDArray[numpy.float64]]]
epsilon: vfdfield epsilon: NDArray[numpy.float64]
omega: complex omega: complex
j: vcfdfield j: NDArray[numpy.complex128]
e: vcfdfield e: NDArray[numpy.complex128]
pmc: vfdfield | None pmc: NDArray[numpy.float64] | None
pec: vfdfield | None pec: NDArray[numpy.float64] | None
@pytest.fixture @pytest.fixture()
def sim( def sim(
request: FixtureRequest, request: FixtureRequest,
shape: tuple[int, ...], shape: tuple[int, ...],
@ -141,11 +141,11 @@ def sim(
j_vec = vec(j_distribution) j_vec = vec(j_distribution)
eps_vec = vec(epsilon) eps_vec = vec(epsilon)
e_vec = fdfd.solvers.generic( e_vec = fdfd.solvers.generic(
J = j_vec, J=j_vec,
omega = omega, omega=omega,
dxes = dxes, dxes=dxes,
epsilon = eps_vec, epsilon=eps_vec,
matrix_solver_opts = dict(atol=1e-15, rtol=1e-11), matrix_solver_opts={'atol': 1e-15, 'tol': 1e-11},
) )
e = unvec(e_vec, shape[1:]) e = unvec(e_vec, shape[1:])

View file

@ -1,213 +0,0 @@
import numpy
from ..fdmath import vec, unvec
from ..fdmath import functional as fd_functional
from ..fdfd import operators, scpml
from ._fdfd_case import (
BOUNDARY_DXES,
BOUNDARY_EPSILON,
BOUNDARY_FIELD,
BOUNDARY_SHAPE,
DXES,
EPSILON,
E_FIELD,
MU,
H_FIELD,
OMEGA,
PEC,
PMC,
SHAPE,
apply_fdfd_matrix,
)
from .utils import assert_close, assert_fields_close
def _dense_e_full(mu: numpy.ndarray | None) -> numpy.ndarray:
ce = fd_functional.curl_forward(DXES[0])
ch = fd_functional.curl_back(DXES[1])
pe = numpy.where(PEC, 0.0, 1.0)
pm = numpy.where(PMC, 0.0, 1.0)
masked_e = pe * E_FIELD
curl_term = ce(masked_e)
if mu is not None:
curl_term = curl_term / mu
curl_term = pm * curl_term
curl_term = ch(curl_term)
return pe * (curl_term - OMEGA**2 * EPSILON * masked_e)
def _dense_h_full(mu: numpy.ndarray | None) -> numpy.ndarray:
ce = fd_functional.curl_forward(DXES[0])
ch = fd_functional.curl_back(DXES[1])
pe = numpy.where(PEC, 0.0, 1.0)
pm = numpy.where(PMC, 0.0, 1.0)
magnetic = numpy.ones_like(EPSILON) if mu is None else mu
masked_h = pm * H_FIELD
curl_term = ch(masked_h)
curl_term = pe * (curl_term / EPSILON)
curl_term = ce(curl_term)
return pm * (curl_term - OMEGA**2 * magnetic * masked_h)
def _normalized_distance(u: numpy.ndarray, size: int, thickness: int) -> numpy.ndarray:
return ((thickness - u).clip(0) + (u - (size - thickness)).clip(0)) / thickness
def test_h_full_matches_dense_reference_with_and_without_mu() -> None:
for mu in (None, MU):
matrix_result = apply_fdfd_matrix(
operators.h_full(OMEGA, DXES, vec(EPSILON), None if mu is None else vec(mu), vec(PEC), vec(PMC)),
H_FIELD,
)
dense_result = _dense_h_full(mu)
assert_fields_close(matrix_result, dense_result, atol=1e-10, rtol=1e-10)
def test_e_full_matches_dense_reference_with_masks() -> None:
for mu in (None, MU):
matrix_result = apply_fdfd_matrix(
operators.e_full(OMEGA, DXES, vec(EPSILON), None if mu is None else vec(mu), vec(PEC), vec(PMC)),
E_FIELD,
)
dense_result = _dense_e_full(mu)
assert_fields_close(matrix_result, dense_result, atol=1e-10, rtol=1e-10)
def test_h_full_without_masks_matches_dense_reference() -> None:
ce = fd_functional.curl_forward(DXES[0])
ch = fd_functional.curl_back(DXES[1])
dense_result = ce(ch(H_FIELD) / EPSILON) - OMEGA**2 * MU * H_FIELD
matrix_result = apply_fdfd_matrix(
operators.h_full(OMEGA, DXES, vec(EPSILON), vec(MU)),
H_FIELD,
)
assert_fields_close(matrix_result, dense_result, atol=1e-10, rtol=1e-10)
def test_eh_full_matches_manual_block_operator_with_masks() -> None:
pe = numpy.where(PEC, 0.0, 1.0)
pm = numpy.where(PMC, 0.0, 1.0)
ce = fd_functional.curl_forward(DXES[0])
ch = fd_functional.curl_back(DXES[1])
matrix_result = operators.eh_full(OMEGA, DXES, vec(EPSILON), vec(MU), vec(PEC), vec(PMC)) @ numpy.concatenate(
[vec(E_FIELD), vec(H_FIELD)],
)
matrix_e, matrix_h = (unvec(part, SHAPE) for part in numpy.split(matrix_result, 2))
dense_e = pe * ch(pm * H_FIELD) - pe * (1j * OMEGA * EPSILON * (pe * E_FIELD))
dense_h = pm * ce(pe * E_FIELD) + pm * (1j * OMEGA * MU * (pm * H_FIELD))
assert_fields_close(matrix_e, dense_e, atol=1e-10, rtol=1e-10)
assert_fields_close(matrix_h, dense_h, atol=1e-10, rtol=1e-10)
def test_e2h_pmc_mask_matches_masked_unmasked_result() -> None:
pmc_complement = numpy.where(PMC, 0.0, 1.0)
unmasked = apply_fdfd_matrix(operators.e2h(OMEGA, DXES, vec(MU)), E_FIELD)
masked = apply_fdfd_matrix(operators.e2h(OMEGA, DXES, vec(MU), vec(PMC)), E_FIELD)
assert_fields_close(masked, pmc_complement * unmasked, atol=1e-10, rtol=1e-10)
def test_poynting_h_cross_matches_negative_e_cross_relation() -> None:
h_cross_e = apply_fdfd_matrix(operators.poynting_h_cross(vec(H_FIELD), DXES), E_FIELD)
e_cross_h = apply_fdfd_matrix(operators.poynting_e_cross(vec(E_FIELD), DXES), H_FIELD)
assert_fields_close(h_cross_e, -e_cross_h, atol=1e-10, rtol=1e-10)
def test_e_boundary_source_interior_mask_is_independent_of_periodic_edges() -> None:
mask = numpy.zeros((3, *BOUNDARY_SHAPE), dtype=float)
mask[:, 1, 1, 1] = 1.0
periodic = operators.e_boundary_source(vec(mask), OMEGA, BOUNDARY_DXES, vec(BOUNDARY_EPSILON), periodic_mask_edges=True)
mirrored = operators.e_boundary_source(vec(mask), OMEGA, BOUNDARY_DXES, vec(BOUNDARY_EPSILON), periodic_mask_edges=False)
assert_close(periodic.toarray(), mirrored.toarray())
def test_e_boundary_source_periodic_edges_add_opposite_face_response() -> None:
mask = numpy.zeros((3, *BOUNDARY_SHAPE), dtype=float)
mask[:, 0, 1, 1] = 1.0
periodic = operators.e_boundary_source(vec(mask), OMEGA, BOUNDARY_DXES, vec(BOUNDARY_EPSILON), periodic_mask_edges=True)
mirrored = operators.e_boundary_source(vec(mask), OMEGA, BOUNDARY_DXES, vec(BOUNDARY_EPSILON), periodic_mask_edges=False)
diff = unvec((periodic - mirrored) @ vec(BOUNDARY_FIELD), BOUNDARY_SHAPE)
assert numpy.isfinite(diff).all()
assert_close(diff[:, 1:-1, :, :], 0.0)
assert numpy.linalg.norm(diff[:, -1, :, :]) > 0
def test_prepare_s_function_matches_closed_form_polynomial() -> None:
ln_r = -12.0
order = 3.0
distances = numpy.array([0.0, 0.25, 0.5, 1.0])
s_function = scpml.prepare_s_function(ln_R=ln_r, m=order)
expected = (order + 1) * ln_r / 2 * distances**order
assert_close(s_function(distances), expected)
def test_uniform_grid_scpml_matches_expected_stretch_profile() -> None:
s_function = scpml.prepare_s_function(ln_R=-12.0, m=3.0)
dxes = scpml.uniform_grid_scpml((6, 4, 3), (2, 0, 1), omega=2.0, epsilon_effective=4.0, s_function=s_function)
correction = numpy.sqrt(4.0) * 2.0
for axis, size, thickness in ((0, 6, 2), (2, 3, 1)):
grid = numpy.arange(size, dtype=float)
expected_a = 1 + 1j * s_function(_normalized_distance(grid, size, thickness)) / correction
expected_b = 1 + 1j * s_function(_normalized_distance(grid + 0.5, size, thickness)) / correction
assert_close(dxes[0][axis], expected_a)
assert_close(dxes[1][axis], expected_b)
assert_close(dxes[0][1], 1.0)
assert_close(dxes[1][1], 1.0)
assert numpy.isfinite(dxes[0][0]).all()
assert numpy.isfinite(dxes[1][0]).all()
def test_uniform_grid_scpml_default_s_function_matches_explicit_default() -> None:
implicit = scpml.uniform_grid_scpml((6, 4, 3), (2, 0, 1), omega=2.0)
explicit = scpml.uniform_grid_scpml((6, 4, 3), (2, 0, 1), omega=2.0, s_function=scpml.prepare_s_function())
for implicit_group, explicit_group in zip(implicit, explicit, strict=True):
for implicit_axis, explicit_axis in zip(implicit_group, explicit_group, strict=True):
assert_close(implicit_axis, explicit_axis)
def test_stretch_with_scpml_only_modifies_requested_front_edge() -> None:
s_function = scpml.prepare_s_function(ln_R=-12.0, m=3.0)
base = [[numpy.ones(6), numpy.ones(4), numpy.ones(3)] for _ in range(2)]
stretched = scpml.stretch_with_scpml(base, axis=0, polarity=1, omega=2.0, epsilon_effective=4.0, thickness=2, s_function=s_function)
assert_close(stretched[0][0][2:], 1.0)
assert_close(stretched[1][0][2:], 1.0)
assert_close(stretched[0][0][-2:], 1.0)
assert_close(stretched[1][0][-2:], 1.0)
assert numpy.linalg.norm(stretched[0][0][:2] - 1.0) > 0
assert numpy.linalg.norm(stretched[1][0][:2] - 1.0) > 0
def test_stretch_with_scpml_only_modifies_requested_back_edge() -> None:
s_function = scpml.prepare_s_function(ln_R=-12.0, m=3.0)
base = [[numpy.ones(6), numpy.ones(4), numpy.ones(3)] for _ in range(2)]
stretched = scpml.stretch_with_scpml(base, axis=0, polarity=-1, omega=2.0, epsilon_effective=4.0, thickness=2, s_function=s_function)
assert_close(stretched[0][0][:4], 1.0)
assert_close(stretched[1][0][:4], 1.0)
assert numpy.linalg.norm(stretched[0][0][-2:] - 1.0) > 0
assert numpy.linalg.norm(stretched[1][0][-2:] - 1.0) > 0
def test_stretch_with_scpml_thickness_zero_is_noop() -> None:
s_function = scpml.prepare_s_function(ln_R=-12.0, m=3.0)
base = [[numpy.ones(6), numpy.ones(4), numpy.ones(3)] for _ in range(2)]
stretched = scpml.stretch_with_scpml(base, axis=0, polarity=-1, omega=2.0, epsilon_effective=4.0, thickness=0, s_function=s_function)
for grid_group in stretched:
for axis_grid in grid_group:
assert_close(axis_grid, 1.0)

View file

@ -1,100 +0,0 @@
import numpy
import pytest
from ..fdfd import farfield
NEAR_SHAPE = (2, 3)
E_NEAR = [numpy.zeros(NEAR_SHAPE, dtype=complex), numpy.zeros(NEAR_SHAPE, dtype=complex)]
H_NEAR = [numpy.zeros(NEAR_SHAPE, dtype=complex), numpy.zeros(NEAR_SHAPE, dtype=complex)]
def test_near_to_farfield_rejects_wrong_length_inputs() -> None:
with pytest.raises(Exception, match='E_near must be a length-2 list'):
farfield.near_to_farfield(E_NEAR[:1], H_NEAR, dx=0.2, dy=0.3)
with pytest.raises(Exception, match='H_near must be a length-2 list'):
farfield.near_to_farfield(E_NEAR, H_NEAR[:1], dx=0.2, dy=0.3)
def test_near_to_farfield_rejects_mismatched_shapes() -> None:
bad_h_near = [H_NEAR[0], numpy.zeros((2, 4), dtype=complex)]
with pytest.raises(Exception, match='All fields must be the same shape'):
farfield.near_to_farfield(E_NEAR, bad_h_near, dx=0.2, dy=0.3)
def test_near_to_farfield_uses_default_and_scalar_padding_shapes() -> None:
default_result = farfield.near_to_farfield(E_NEAR, H_NEAR, dx=0.2, dy=0.3)
scalar_result = farfield.near_to_farfield(E_NEAR, H_NEAR, dx=0.2, dy=0.3, padded_size=8)
assert default_result['E'][0].shape == (2, 4)
assert default_result['H'][0].shape == (2, 4)
assert scalar_result['E'][0].shape == (8, 8)
assert scalar_result['H'][0].shape == (8, 8)
def test_far_to_nearfield_rejects_wrong_length_inputs() -> None:
ff = farfield.near_to_farfield(E_NEAR, H_NEAR, dx=0.2, dy=0.3, padded_size=8)
with pytest.raises(Exception, match='E_far must be a length-2 list'):
farfield.far_to_nearfield(ff['E'][:1], ff['H'], ff['dkx'], ff['dky'])
with pytest.raises(Exception, match='H_far must be a length-2 list'):
farfield.far_to_nearfield(ff['E'], ff['H'][:1], ff['dkx'], ff['dky'])
def test_far_to_nearfield_rejects_mismatched_shapes() -> None:
ff = farfield.near_to_farfield(E_NEAR, H_NEAR, dx=0.2, dy=0.3, padded_size=8)
bad_h_far = [ff['H'][0], numpy.zeros((8, 4), dtype=complex)]
with pytest.raises(Exception, match='All fields must be the same shape'):
farfield.far_to_nearfield(ff['E'], bad_h_far, ff['dkx'], ff['dky'])
def test_far_to_nearfield_uses_default_and_scalar_padding_shapes() -> None:
ff = farfield.near_to_farfield(E_NEAR, H_NEAR, dx=0.2, dy=0.3, padded_size=8)
default_result = farfield.far_to_nearfield(
[field.copy() for field in ff['E']],
[field.copy() for field in ff['H']],
ff['dkx'],
ff['dky'],
)
scalar_result = farfield.far_to_nearfield(
[field.copy() for field in ff['E']],
[field.copy() for field in ff['H']],
ff['dkx'],
ff['dky'],
padded_size=4,
)
assert default_result['E'][0].shape == (8, 8)
assert default_result['H'][0].shape == (8, 8)
assert scalar_result['E'][0].shape == (4, 4)
assert scalar_result['H'][0].shape == (4, 4)
def test_farfield_roundtrip_supports_rectangular_arrays() -> None:
e_near = [numpy.zeros((4, 8), dtype=complex), numpy.zeros((4, 8), dtype=complex)]
h_near = [numpy.zeros((4, 8), dtype=complex), numpy.zeros((4, 8), dtype=complex)]
e_near[0][1, 3] = 1.0 + 0.25j
h_near[1][2, 5] = -0.5j
ff = farfield.near_to_farfield(e_near, h_near, dx=0.2, dy=0.3, padded_size=(4, 8))
restored = farfield.far_to_nearfield(
[field.copy() for field in ff['E']],
[field.copy() for field in ff['H']],
ff['dkx'],
ff['dky'],
padded_size=(4, 8),
)
assert isinstance(ff['dkx'], float)
assert isinstance(ff['dky'], float)
assert ff['E'][0].shape == (4, 8)
assert restored['E'][0].shape == (4, 8)
assert restored['H'][0].shape == (4, 8)
assert restored['dx'] == pytest.approx(0.2)
assert restored['dy'] == pytest.approx(0.3)
assert numpy.isfinite(restored['E'][0]).all()
assert numpy.isfinite(restored['H'][0]).all()

View file

@ -1,122 +0,0 @@
import numpy
from ..fdmath import unvec, vec
from ..fdfd import functional, operators
from ._fdfd_case import DXES, EPSILON, E_FIELD, H_FIELD, MU, OMEGA, SHAPE, TF_REGION, apply_fdfd_matrix
from .utils import assert_fields_close
ATOL = 1e-9
RTOL = 1e-9
def assert_fields_match(actual: numpy.ndarray, expected: numpy.ndarray) -> None:
assert_fields_close(actual, expected, atol=ATOL, rtol=RTOL)
def test_e_full_matches_sparse_operator_without_mu() -> None:
matrix_result = apply_fdfd_matrix(
operators.e_full(OMEGA, DXES, vec(EPSILON)),
E_FIELD,
)
functional_result = functional.e_full(OMEGA, DXES, EPSILON)(E_FIELD)
assert_fields_match(functional_result, matrix_result)
def test_e_full_matches_sparse_operator_with_mu() -> None:
matrix_result = apply_fdfd_matrix(
operators.e_full(OMEGA, DXES, vec(EPSILON), vec(MU)),
E_FIELD,
)
functional_result = functional.e_full(OMEGA, DXES, EPSILON, MU)(E_FIELD)
assert_fields_match(functional_result, matrix_result)
def test_eh_full_matches_sparse_operator_with_mu() -> None:
matrix_result = operators.eh_full(OMEGA, DXES, vec(EPSILON), vec(MU)) @ numpy.concatenate([vec(E_FIELD), vec(H_FIELD)])
matrix_e, matrix_h = (unvec(part, SHAPE) for part in numpy.split(matrix_result, 2))
functional_e, functional_h = functional.eh_full(OMEGA, DXES, EPSILON, MU)(E_FIELD, H_FIELD)
assert_fields_match(functional_e, matrix_e)
assert_fields_match(functional_h, matrix_h)
def test_eh_full_matches_sparse_operator_without_mu() -> None:
matrix_result = operators.eh_full(OMEGA, DXES, vec(EPSILON)) @ numpy.concatenate([vec(E_FIELD), vec(H_FIELD)])
matrix_e, matrix_h = (unvec(part, SHAPE) for part in numpy.split(matrix_result, 2))
functional_e, functional_h = functional.eh_full(OMEGA, DXES, EPSILON)(E_FIELD, H_FIELD)
assert_fields_match(functional_e, matrix_e)
assert_fields_match(functional_h, matrix_h)
def test_e2h_matches_sparse_operator_with_mu() -> None:
matrix_result = apply_fdfd_matrix(
operators.e2h(OMEGA, DXES, vec(MU)),
E_FIELD,
)
functional_result = functional.e2h(OMEGA, DXES, MU)(E_FIELD)
assert_fields_match(functional_result, matrix_result)
def test_e2h_matches_sparse_operator_without_mu() -> None:
matrix_result = apply_fdfd_matrix(
operators.e2h(OMEGA, DXES),
E_FIELD,
)
functional_result = functional.e2h(OMEGA, DXES)(E_FIELD)
assert_fields_match(functional_result, matrix_result)
def test_m2j_matches_sparse_operator_without_mu() -> None:
matrix_result = apply_fdfd_matrix(
operators.m2j(OMEGA, DXES),
H_FIELD,
)
functional_result = functional.m2j(OMEGA, DXES)(H_FIELD)
assert_fields_match(functional_result, matrix_result)
def test_m2j_matches_sparse_operator_with_mu() -> None:
matrix_result = apply_fdfd_matrix(
operators.m2j(OMEGA, DXES, vec(MU)),
H_FIELD,
)
functional_result = functional.m2j(OMEGA, DXES, MU)(H_FIELD)
assert_fields_match(functional_result, matrix_result)
def test_e_tfsf_source_matches_sparse_operator_without_mu() -> None:
matrix_result = apply_fdfd_matrix(
operators.e_tfsf_source(vec(TF_REGION), OMEGA, DXES, vec(EPSILON)),
E_FIELD,
)
functional_result = functional.e_tfsf_source(TF_REGION, OMEGA, DXES, EPSILON)(E_FIELD)
assert_fields_match(functional_result, matrix_result)
def test_e_tfsf_source_matches_sparse_operator_with_mu() -> None:
matrix_result = apply_fdfd_matrix(
operators.e_tfsf_source(vec(TF_REGION), OMEGA, DXES, vec(EPSILON), vec(MU)),
E_FIELD,
)
functional_result = functional.e_tfsf_source(TF_REGION, OMEGA, DXES, EPSILON, MU)(E_FIELD)
assert_fields_match(functional_result, matrix_result)
def test_poynting_e_cross_h_matches_sparse_operator() -> None:
matrix_result = apply_fdfd_matrix(
operators.poynting_e_cross(vec(E_FIELD), DXES),
H_FIELD,
)
functional_result = functional.poynting_e_cross_h(DXES)(E_FIELD, H_FIELD)
assert_fields_match(functional_result, matrix_result)

View file

@ -1,11 +1,11 @@
# ruff: noqa: ARG001 from typing import Iterable
import pytest # type: ignore import pytest # type: ignore
import numpy import numpy
from numpy.typing import NDArray from numpy.typing import NDArray
from numpy.testing import assert_allclose from numpy.testing import assert_allclose
from .. import fdfd from .. import fdfd
from ..fdmath import vec, unvec, dx_lists_mut, vfdfield, cfdfield_t from ..fdmath import vec, unvec, dx_lists_mut
#from .utils import assert_close, assert_fields_close #from .utils import assert_close, assert_fields_close
from .test_fdfd import FDResult from .test_fdfd import FDResult
from .conftest import FixtureRequest from .conftest import FixtureRequest
@ -44,51 +44,49 @@ def test_pml(sim: FDResult, src_polarity: int) -> None:
# Also see conftest.py # Also see conftest.py
@pytest.fixture(params=[1 / 1500]) @pytest.fixture(params=[1 / 1500])
def omega(request: FixtureRequest) -> float: def omega(request: FixtureRequest) -> Iterable[float]:
return request.param yield request.param
@pytest.fixture(params=[None]) @pytest.fixture(params=[None])
def pec(request: FixtureRequest) -> NDArray[numpy.float64] | None: def pec(request: FixtureRequest) -> Iterable[NDArray[numpy.float64] | None]:
return request.param yield request.param
@pytest.fixture(params=[None]) @pytest.fixture(params=[None])
def pmc(request: FixtureRequest) -> NDArray[numpy.float64] | None: def pmc(request: FixtureRequest) -> Iterable[NDArray[numpy.float64] | None]:
return request.param yield request.param
@pytest.fixture(params=[(30, 1, 1), @pytest.fixture(params=[(30, 1, 1),
(1, 30, 1), (1, 30, 1),
(1, 1, 30)]) (1, 1, 30)])
def shape(request: FixtureRequest) -> tuple[int, int, int]: def shape(request: FixtureRequest) -> Iterable[tuple[int, ...]]:
return (3, *request.param) yield (3, *request.param)
@pytest.fixture(params=[+1, -1]) @pytest.fixture(params=[+1, -1])
def src_polarity(request: FixtureRequest) -> int: def src_polarity(request: FixtureRequest) -> Iterable[int]:
return request.param yield request.param
@pytest.fixture @pytest.fixture()
def j_distribution( def j_distribution(
request: FixtureRequest, request: FixtureRequest,
shape: tuple[int, ...], shape: tuple[int, ...],
epsilon: vfdfield, epsilon: NDArray[numpy.float64],
dxes: dx_lists_mut, dxes: dx_lists_mut,
omega: float, omega: float,
src_polarity: int, src_polarity: int,
) -> cfdfield_t: ) -> Iterable[NDArray[numpy.complex128]]:
j = numpy.zeros(shape, dtype=complex) j = numpy.zeros(shape, dtype=complex)
dim = numpy.where(numpy.array(shape[1:]) > 1)[0][0] # Propagation axis dim = numpy.where(numpy.array(shape[1:]) > 1)[0][0] # Propagation axis
other_dims = [0, 1, 2] other_dims = [0, 1, 2]
other_dims.remove(dim) other_dims.remove(dim)
dx_prop = ( dx_prop = (dxes[0][dim][shape[dim + 1] // 2]
dxes[0][dim][shape[dim + 1] // 2] + dxes[1][dim][shape[dim + 1] // 2]) / 2 # noqa: E128 # TODO is this right for nonuniform dxes?
+ dxes[1][dim][shape[dim + 1] // 2]
) / 2 # TODO is this right for nonuniform dxes?
# Mask only contains components orthogonal to propagation direction # Mask only contains components orthogonal to propagation direction
center_mask = numpy.zeros(shape, dtype=bool) center_mask = numpy.zeros(shape, dtype=bool)
@ -108,18 +106,18 @@ def j_distribution(
j = fdfd.waveguide_3d.compute_source(E=e, wavenumber=wavenumber_corrected, omega=omega, dxes=dxes, j = fdfd.waveguide_3d.compute_source(E=e, wavenumber=wavenumber_corrected, omega=omega, dxes=dxes,
axis=dim, polarity=src_polarity, slices=slices, epsilon=epsilon) axis=dim, polarity=src_polarity, slices=slices, epsilon=epsilon)
return j yield j
@pytest.fixture @pytest.fixture()
def epsilon( def epsilon(
request: FixtureRequest, request: FixtureRequest,
shape: tuple[int, ...], shape: tuple[int, ...],
epsilon_bg: float, epsilon_bg: float,
epsilon_fg: float, epsilon_fg: float,
) -> NDArray[numpy.float64]: ) -> Iterable[NDArray[numpy.float64]]:
epsilon = numpy.full(shape, epsilon_fg, dtype=float) epsilon = numpy.full(shape, epsilon_fg, dtype=float)
return epsilon yield epsilon
@pytest.fixture(params=['uniform']) @pytest.fixture(params=['uniform'])
@ -129,7 +127,7 @@ def dxes(
dx: float, dx: float,
omega: float, omega: float,
epsilon_fg: float, epsilon_fg: float,
) -> list[list[NDArray[numpy.float64]]]: ) -> Iterable[list[list[NDArray[numpy.float64]]]]:
if request.param == 'uniform': if request.param == 'uniform':
dxes = [[numpy.full(s, dx) for s in shape[1:]] for _ in range(2)] dxes = [[numpy.full(s, dx) for s in shape[1:]] for _ in range(2)]
dim = numpy.where(numpy.array(shape[1:]) > 1)[0][0] # Propagation axis dim = numpy.where(numpy.array(shape[1:]) > 1)[0][0] # Propagation axis
@ -143,10 +141,10 @@ def dxes(
epsilon_effective=epsilon_fg, epsilon_effective=epsilon_fg,
thickness=10, thickness=10,
) )
return dxes yield dxes
@pytest.fixture @pytest.fixture()
def sim( def sim(
request: FixtureRequest, request: FixtureRequest,
shape: tuple[int, ...], shape: tuple[int, ...],
@ -164,7 +162,7 @@ def sim(
omega=omega, omega=omega,
dxes=dxes, dxes=dxes,
epsilon=eps_vec, epsilon=eps_vec,
matrix_solver_opts={'atol': 1e-15, 'rtol': 1e-11}, matrix_solver_opts={'atol': 1e-15, 'tol': 1e-11},
) )
e = unvec(e_vec, shape[1:]) e = unvec(e_vec, shape[1:])

View file

@ -1,128 +0,0 @@
from typing import cast
import numpy
from ..fdfd import solvers
from ._solver_cases import solver_plumbing_case
from .utils import assert_close
def test_scipy_qmr_wraps_user_callback_without_recursion(monkeypatch) -> None:
seen: list[tuple[float, ...]] = []
def fake_qmr(a, b: numpy.ndarray, **kwargs):
kwargs['callback'](numpy.array([1.0, 2.0]))
return numpy.array([3.0, 4.0]), 0
monkeypatch.setattr(solvers.scipy.sparse.linalg, 'qmr', fake_qmr)
result = solvers._scipy_qmr(
solver_plumbing_case().a0,
numpy.array([1.0, 0.0]),
callback=lambda xk: seen.append(tuple(xk)),
)
assert_close(result, [3.0, 4.0])
assert seen == [(1.0, 2.0)]
def test_scipy_qmr_installs_logging_callback_when_missing(monkeypatch) -> None:
callback_seen: list[numpy.ndarray] = []
def fake_qmr(a, b: numpy.ndarray, **kwargs):
callback = kwargs['callback']
callback(numpy.array([5.0, 6.0]))
callback_seen.append(b.copy())
return numpy.array([7.0, 8.0]), 0
monkeypatch.setattr(solvers.scipy.sparse.linalg, 'qmr', fake_qmr)
result = solvers._scipy_qmr(solver_plumbing_case().a0, numpy.array([1.0, 0.0]))
assert_close(result, [7.0, 8.0])
assert len(callback_seen) == 1
def test_generic_forward_preconditions_system_and_guess(monkeypatch) -> None:
case = solver_plumbing_case()
captured: dict[str, numpy.ndarray | float | object] = {}
monkeypatch.setattr(solvers.operators, 'e_full', lambda *args, **kwargs: case.a0)
monkeypatch.setattr(solvers.operators, 'e_full_preconditioners', lambda dxes: (case.pl, case.pr))
def fake_solver(a, b: numpy.ndarray, **kwargs):
captured['a'] = a
captured['b'] = b
captured['x0'] = kwargs['x0']
captured['atol'] = kwargs['atol']
return case.solver_result
result = solvers.generic(
omega=case.omega,
dxes=case.dxes,
J=case.j,
epsilon=case.epsilon,
matrix_solver=fake_solver,
matrix_solver_opts={'atol': 1e-12},
E_guess=case.guess,
)
assert_close(cast(object, captured['a']).toarray(), (case.pl @ case.a0 @ case.pr).toarray()) # type: ignore[attr-defined]
assert_close(cast(numpy.ndarray, captured['b']), case.pl @ (-1j * case.omega * case.j))
assert_close(cast(numpy.ndarray, captured['x0']), case.pl @ case.guess)
assert captured['atol'] == 1e-12
assert_close(result, case.pr @ case.solver_result)
def test_generic_adjoint_preconditions_system_and_guess(monkeypatch) -> None:
case = solver_plumbing_case()
captured: dict[str, numpy.ndarray | float | object] = {}
monkeypatch.setattr(solvers.operators, 'e_full', lambda *args, **kwargs: case.a0)
monkeypatch.setattr(solvers.operators, 'e_full_preconditioners', lambda dxes: (case.pl, case.pr))
def fake_solver(a, b: numpy.ndarray, **kwargs):
captured['a'] = a
captured['b'] = b
captured['x0'] = kwargs['x0']
captured['rtol'] = kwargs['rtol']
return case.solver_result
result = solvers.generic(
omega=case.omega,
dxes=case.dxes,
J=case.j,
epsilon=case.epsilon,
matrix_solver=fake_solver,
matrix_solver_opts={'rtol': 1e-9},
E_guess=case.guess,
adjoint=True,
)
expected_matrix = (case.pl @ case.a0 @ case.pr).T.conjugate()
assert_close(cast(object, captured['a']).toarray(), expected_matrix.toarray()) # type: ignore[attr-defined]
assert_close(cast(numpy.ndarray, captured['b']), case.pr.T.conjugate() @ (-1j * case.omega * case.j))
assert_close(cast(numpy.ndarray, captured['x0']), case.pr.T.conjugate() @ case.guess)
assert captured['rtol'] == 1e-9
assert_close(result, case.pl.T.conjugate() @ case.solver_result)
def test_generic_without_guess_does_not_inject_x0(monkeypatch) -> None:
case = solver_plumbing_case()
captured: dict[str, object] = {}
monkeypatch.setattr(solvers.operators, 'e_full', lambda *args, **kwargs: case.a0)
monkeypatch.setattr(solvers.operators, 'e_full_preconditioners', lambda dxes: (case.pl, case.pr))
def fake_solver(a, b: numpy.ndarray, **kwargs):
captured['kwargs'] = kwargs
return numpy.array([1.0, -1.0])
result = solvers.generic(
omega=1.0,
dxes=case.dxes,
J=numpy.array([2.0, 3.0]),
epsilon=case.epsilon,
matrix_solver=fake_solver,
)
assert 'x0' not in cast(dict[str, object], captured['kwargs'])
assert_close(result, case.pr @ numpy.array([1.0, -1.0]))

View file

@ -1,60 +0,0 @@
import numpy
from ..fdmath import functional as fd_functional
from ..fdmath import operators as fd_operators
from ..fdmath import vec, unvec
from .utils import assert_close, assert_fields_close
SHAPE = (2, 3, 2)
DX_E = [numpy.array([1.0, 1.5]), numpy.array([0.75, 1.25, 1.5]), numpy.array([1.2, 0.8])]
DX_H = [numpy.array([0.9, 1.4]), numpy.array([0.8, 1.1, 1.4]), numpy.array([1.0, 0.7])]
SCALAR_FIELD = (
numpy.arange(numpy.prod(SHAPE)).reshape(SHAPE)
+ 0.1j * numpy.arange(numpy.prod(SHAPE)).reshape(SHAPE)
).astype(complex)
VECTOR_FIELD = (numpy.arange(3 * numpy.prod(SHAPE)).reshape((3, *SHAPE)) + 0.25j).astype(complex)
def test_deriv_forward_without_dx_matches_numpy_roll() -> None:
for axis, deriv in enumerate(fd_functional.deriv_forward()):
expected = numpy.roll(SCALAR_FIELD, -1, axis=axis) - SCALAR_FIELD
assert_close(deriv(SCALAR_FIELD), expected)
def test_deriv_back_without_dx_matches_numpy_roll() -> None:
for axis, deriv in enumerate(fd_functional.deriv_back()):
expected = SCALAR_FIELD - numpy.roll(SCALAR_FIELD, 1, axis=axis)
assert_close(deriv(SCALAR_FIELD), expected)
def test_curl_parts_sum_to_full_curl() -> None:
curl_forward = fd_functional.curl_forward(DX_E)(VECTOR_FIELD)
curl_back = fd_functional.curl_back(DX_H)(VECTOR_FIELD)
forward_parts = fd_functional.curl_forward_parts(DX_E)(VECTOR_FIELD)
back_parts = fd_functional.curl_back_parts(DX_H)(VECTOR_FIELD)
for axis in range(3):
assert_close(forward_parts[axis][0] + forward_parts[axis][1], curl_forward[axis])
assert_close(back_parts[axis][0] + back_parts[axis][1], curl_back[axis])
def test_derivatives_match_sparse_operators_on_nonuniform_grid() -> None:
for axis, deriv in enumerate(fd_functional.deriv_forward(DX_E)):
matrix_result = (fd_operators.deriv_forward(DX_E)[axis] @ SCALAR_FIELD.ravel(order='C')).reshape(SHAPE, order='C')
assert_close(deriv(SCALAR_FIELD), matrix_result, atol=1e-12, rtol=1e-12)
for axis, deriv in enumerate(fd_functional.deriv_back(DX_H)):
matrix_result = (fd_operators.deriv_back(DX_H)[axis] @ SCALAR_FIELD.ravel(order='C')).reshape(SHAPE, order='C')
assert_close(deriv(SCALAR_FIELD), matrix_result, atol=1e-12, rtol=1e-12)
def test_curls_match_sparse_operators_on_nonuniform_grid() -> None:
curl_forward = fd_functional.curl_forward(DX_E)(VECTOR_FIELD)
curl_back = fd_functional.curl_back(DX_H)(VECTOR_FIELD)
matrix_forward = unvec(fd_operators.curl_forward(DX_E) @ vec(VECTOR_FIELD), SHAPE)
matrix_back = unvec(fd_operators.curl_back(DX_H) @ vec(VECTOR_FIELD), SHAPE)
assert_fields_close(curl_forward, matrix_forward, atol=1e-12, rtol=1e-12)
assert_fields_close(curl_back, matrix_back, atol=1e-12, rtol=1e-12)

View file

@ -1,90 +0,0 @@
import numpy
import pytest
from ..fdmath import operators, unvec, vec
from ._test_builders import real_ramp
from .utils import assert_close
SHAPE = (2, 3, 2)
SCALAR_FIELD = real_ramp(SHAPE)
VECTOR_LEFT = real_ramp((3, *SHAPE), offset=0.5)
VECTOR_RIGHT = real_ramp((3, *SHAPE), scale=1 / 3, offset=2.0)
def _apply_scalar_matrix(op: operators.sparse.spmatrix) -> numpy.ndarray:
return (op @ SCALAR_FIELD.ravel(order='C')).reshape(SHAPE, order='C')
def _mirrored_indices(size: int, shift_distance: int) -> numpy.ndarray:
indices = numpy.arange(size) + shift_distance
indices = numpy.where(indices >= size, 2 * size - indices - 1, indices)
indices = numpy.where(indices < 0, -1 - indices, indices)
return indices
@pytest.mark.parametrize(('axis', 'shift_distance'), [(0, 1), (1, -1), (2, 1)])
def test_shift_circ_matches_numpy_roll(axis: int, shift_distance: int) -> None:
matrix_result = _apply_scalar_matrix(operators.shift_circ(axis, SHAPE, shift_distance))
expected = numpy.roll(SCALAR_FIELD, -shift_distance, axis=axis)
assert_close(matrix_result, expected)
@pytest.mark.parametrize(('axis', 'shift_distance'), [(0, 1), (1, -1), (2, 1)])
def test_shift_with_mirror_matches_explicit_mirrored_indices(axis: int, shift_distance: int) -> None:
matrix_result = _apply_scalar_matrix(operators.shift_with_mirror(axis, SHAPE, shift_distance))
indices = [numpy.arange(length) for length in SHAPE]
indices[axis] = _mirrored_indices(SHAPE[axis], shift_distance)
expected = SCALAR_FIELD[numpy.ix_(*indices)]
assert_close(matrix_result, expected)
@pytest.mark.parametrize(
('args', 'message'),
[
((0, (2,), 1), 'Invalid shape'),
((3, SHAPE, 1), 'Invalid direction'),
],
)
def test_shift_circ_rejects_invalid_arguments(args: tuple[int, tuple[int, ...], int], message: str) -> None:
with pytest.raises(Exception, match=message):
operators.shift_circ(*args)
@pytest.mark.parametrize(
('args', 'message'),
[
((0, (2,), 1), 'Invalid shape'),
((3, SHAPE, 1), 'Invalid direction'),
((0, SHAPE, SHAPE[0]), 'too large'),
],
)
def test_shift_with_mirror_rejects_invalid_arguments(args: tuple[int, tuple[int, ...], int], message: str) -> None:
with pytest.raises(Exception, match=message):
operators.shift_with_mirror(*args)
def test_vec_cross_matches_pointwise_cross_product() -> None:
matrix_result = unvec(operators.vec_cross(vec(VECTOR_LEFT)) @ vec(VECTOR_RIGHT), SHAPE)
expected = numpy.empty_like(VECTOR_LEFT)
expected[0] = VECTOR_LEFT[1] * VECTOR_RIGHT[2] - VECTOR_LEFT[2] * VECTOR_RIGHT[1]
expected[1] = VECTOR_LEFT[2] * VECTOR_RIGHT[0] - VECTOR_LEFT[0] * VECTOR_RIGHT[2]
expected[2] = VECTOR_LEFT[0] * VECTOR_RIGHT[1] - VECTOR_LEFT[1] * VECTOR_RIGHT[0]
assert_close(matrix_result, expected)
def test_avg_forward_matches_half_sum_with_forward_neighbor() -> None:
matrix_result = _apply_scalar_matrix(operators.avg_forward(1, SHAPE))
expected = 0.5 * (SCALAR_FIELD + numpy.roll(SCALAR_FIELD, -1, axis=1))
assert_close(matrix_result, expected)
def test_avg_back_matches_half_sum_with_backward_neighbor() -> None:
matrix_result = _apply_scalar_matrix(operators.avg_back(1, SHAPE))
expected = 0.5 * (SCALAR_FIELD + numpy.roll(SCALAR_FIELD, 1, axis=1))
assert_close(matrix_result, expected)
def test_avg_forward_rejects_invalid_shape() -> None:
with pytest.raises(Exception, match='Invalid shape'):
operators.avg_forward(0, (2,))

View file

@ -1,46 +0,0 @@
import numpy
from ..fdmath import unvec, vec
from ._test_builders import complex_ramp, real_ramp
from .utils import assert_close
SHAPE = (2, 3, 2)
FIELD = real_ramp((3, *SHAPE))
COMPLEX_FIELD = complex_ramp((3, *SHAPE), imag_scale=0.5)
def test_vec_and_unvec_return_none_for_none_input() -> None:
assert vec(None) is None
assert unvec(None, SHAPE) is None
def test_real_field_round_trip_preserves_shape_and_values() -> None:
vector = vec(FIELD)
assert vector is not None
restored = unvec(vector, SHAPE)
assert restored is not None
assert restored.shape == (3, *SHAPE)
assert_close(restored, FIELD)
def test_complex_field_round_trip_preserves_shape_and_values() -> None:
vector = vec(COMPLEX_FIELD)
assert vector is not None
restored = unvec(vector, SHAPE)
assert restored is not None
assert restored.shape == (3, *SHAPE)
assert_close(restored, COMPLEX_FIELD)
def test_unvec_with_two_components_round_trips_vector() -> None:
vector = numpy.arange(2 * numpy.prod(SHAPE), dtype=float)
field = unvec(vector, SHAPE, nvdim=2)
assert field is not None
assert field.shape == (2, *SHAPE)
assert_close(vec(field), vector)
def test_vec_accepts_arraylike_input() -> None:
arraylike = [[[1, 2], [3, 4]], [[5, 6], [7, 8]]]
assert_close(vec(arraylike), numpy.ravel(arraylike, order='C'))

View file

@ -1,5 +1,4 @@
# ruff: noqa: ARG001 from typing import Iterable, Any
from typing import Any
import dataclasses import dataclasses
import pytest # type: ignore import pytest # type: ignore
import numpy import numpy
@ -7,7 +6,7 @@ from numpy.typing import NDArray
#from numpy.testing import assert_allclose, assert_array_equal #from numpy.testing import assert_allclose, assert_array_equal
from .. import fdtd from .. import fdtd
from .utils import assert_close, assert_fields_close, make_prng from .utils import assert_close, assert_fields_close, PRNG
from .conftest import FixtureRequest from .conftest import FixtureRequest
@ -102,7 +101,7 @@ def test_poynting_divergence(sim: 'TDResult') -> None:
def test_poynting_planes(sim: 'TDResult') -> None: def test_poynting_planes(sim: 'TDResult') -> None:
mask = (sim.js[0] != 0).any(axis=0) mask = (sim.js[0] != 0).any(axis=0)
if mask.sum() > 1: if mask.sum() > 1:
pytest.skip(f'test_poynting_planes can only test single point sources, got {mask.sum()}') pytest.skip('test_poynting_planes can only test single point sources, got {}'.format(mask.sum()))
args: dict[str, Any] = { args: dict[str, Any] = {
'dxes': sim.dxes, 'dxes': sim.dxes,
@ -151,8 +150,8 @@ def test_poynting_planes(sim: 'TDResult') -> None:
@pytest.fixture(params=[0.3]) @pytest.fixture(params=[0.3])
def dt(request: FixtureRequest) -> float: def dt(request: FixtureRequest) -> Iterable[float]:
return request.param yield request.param
@dataclasses.dataclass() @dataclasses.dataclass()
@ -169,8 +168,8 @@ class TDResult:
@pytest.fixture(params=[(0, 4, 8)]) # (0,) @pytest.fixture(params=[(0, 4, 8)]) # (0,)
def j_steps(request: FixtureRequest) -> tuple[int, ...]: def j_steps(request: FixtureRequest) -> Iterable[tuple[int, ...]]:
return request.param yield request.param
@pytest.fixture(params=['center', 'random']) @pytest.fixture(params=['center', 'random'])
@ -178,19 +177,18 @@ def j_distribution(
request: FixtureRequest, request: FixtureRequest,
shape: tuple[int, ...], shape: tuple[int, ...],
j_mag: float, j_mag: float,
) -> NDArray[numpy.float64]: ) -> Iterable[NDArray[numpy.float64]]:
prng = make_prng()
j = numpy.zeros(shape) j = numpy.zeros(shape)
if request.param == 'center': if request.param == 'center':
j[:, shape[1] // 2, shape[2] // 2, shape[3] // 2] = j_mag j[:, shape[1] // 2, shape[2] // 2, shape[3] // 2] = j_mag
elif request.param == '000': elif request.param == '000':
j[:, 0, 0, 0] = j_mag j[:, 0, 0, 0] = j_mag
elif request.param == 'random': elif request.param == 'random':
j[:] = prng.uniform(low=-j_mag, high=j_mag, size=shape) j[:] = PRNG.uniform(low=-j_mag, high=j_mag, size=shape)
return j yield j
@pytest.fixture @pytest.fixture()
def sim( def sim(
request: FixtureRequest, request: FixtureRequest,
shape: tuple[int, ...], shape: tuple[int, ...],
@ -201,8 +199,9 @@ def sim(
j_steps: tuple[int, ...], j_steps: tuple[int, ...],
) -> TDResult: ) -> TDResult:
is3d = (numpy.array(shape) == 1).sum() == 0 is3d = (numpy.array(shape) == 1).sum() == 0
if is3d and dt != 0.3: if is3d:
pytest.skip('Skipping dt != 0.3 because test is 3D (for speed)') if dt != 0.3:
pytest.skip('Skipping dt != 0.3 because test is 3D (for speed)')
sim = TDResult( sim = TDResult(
shape=shape, shape=shape,

View file

@ -1,43 +0,0 @@
from ..fdmath import functional as fd_functional
from ..fdtd import base
from ._test_builders import real_ramp
from .utils import assert_close
DT = 0.25
SHAPE = (3, 2, 2, 2)
E_FIELD = real_ramp(SHAPE, scale=1 / 5)
H_FIELD = real_ramp(SHAPE, scale=1 / 7, offset=1 / 7)
EPSILON = 1.5 + E_FIELD / 10.0
MU_FIELD = 2.0 + H_FIELD / 8.0
MU_SCALAR = 3.0
def test_maxwell_e_without_dxes_matches_unit_spacing_update() -> None:
updater = base.maxwell_e(dt=DT)
expected = E_FIELD + DT * fd_functional.curl_back()(H_FIELD) / EPSILON
updated = updater(E_FIELD.copy(), H_FIELD.copy(), EPSILON)
assert_close(updated, expected)
def test_maxwell_h_without_dxes_and_without_mu_matches_unit_spacing_update() -> None:
updater = base.maxwell_h(dt=DT)
expected = H_FIELD - DT * fd_functional.curl_forward()(E_FIELD)
updated = updater(E_FIELD.copy(), H_FIELD.copy())
assert_close(updated, expected)
def test_maxwell_h_without_dxes_accepts_scalar_and_field_mu() -> None:
updater = base.maxwell_h(dt=DT)
updated_scalar = updater(E_FIELD.copy(), H_FIELD.copy(), MU_SCALAR)
expected_scalar = H_FIELD - DT * fd_functional.curl_forward()(E_FIELD) / MU_SCALAR
assert_close(updated_scalar, expected_scalar)
updated_field = updater(E_FIELD.copy(), H_FIELD.copy(), MU_FIELD)
expected_field = H_FIELD - DT * fd_functional.curl_forward()(E_FIELD) / MU_FIELD
assert_close(updated_field, expected_field)

Some files were not shown because too many files have changed in this diff Show more