Compare commits

..

113 commits

Author SHA1 Message Date
Forgejo Actions
bf99f35f9b bump version to v0.12 2026-04-22 21:11:05 -07:00
Forgejo Actions
39291a8314 [eme] add analytic tests 2026-04-22 21:10:18 -07:00
Forgejo Actions
061c3f2e90 [_normalized_fields] remove unused args 2026-04-22 21:09:59 -07:00
Forgejo Actions
35fc67faa3 [compute_overlap_e] remove omega arg (unused) 2026-04-22 21:08:12 -07:00
Forgejo Actions
a1568a6f16 ignore some lint 2026-04-21 21:20:34 -07:00
Forgejo Actions
c6c9159b13 type hints and lint 2026-04-21 21:13:34 -07:00
Forgejo Actions
eec3fc28a7 [docs] update colors 2026-04-21 19:51:57 -07:00
Forgejo Actions
010da1ccf5 [tests] add some slow tests 2026-04-21 19:40:49 -07:00
Forgejo Actions
1a2c6ab524 [EME] add more docs and tests 2026-04-21 19:40:32 -07:00
Forgejo Actions
8b67696d7f bump version to v0.11 2026-04-20 11:03:18 -07:00
Forgejo Actions
f8ad0250d1 [eme / examples] add EME examples 2026-04-20 10:15:25 -07:00
Forgejo Actions
9a0c693848 [docs] docs dark mode 2026-04-19 20:22:36 -07:00
Forgejo Actions
bb920b8e33 [tests / fdtd.pml] add pml test 2026-04-19 17:30:04 -07:00
Forgejo Actions
ff278e6fa1 [docs] more docs cleanup 2026-04-19 16:57:22 -07:00
Forgejo Actions
dc92d4a79d [docs] clean up latex leaking into docs site 2026-04-19 16:40:05 -07:00
Forgejo Actions
40efe7a450 [docs] clarify FDFD-to-FDTD field reconstruction 2026-04-19 16:15:06 -07:00
Forgejo Actions
e50637dc1c [waveguide_real / phasor] more work towards real-FDTD to FDFD equivalence 2026-04-19 15:47:00 -07:00
Forgejo Actions
f7aa21a42a [examples] add waveguide_real 2026-04-19 14:43:49 -07:00
Forgejo Actions
3e4aee1197 [fdtd.phasor] add phasor-to-real helpers 2026-04-19 14:43:37 -07:00
Forgejo Actions
4b8a462df7 [phasor] add real-valued scaling 2026-04-19 12:34:28 -07:00
Forgejo Actions
c0b41752e1 [phasor] add temporal_phasor and temporal_phasor_scale 2026-04-19 10:57:10 -07:00
Forgejo Actions
318c43d62d [docs] high level doc updates 2026-04-19 00:50:22 -07:00
Forgejo Actions
6f29dd89a8 Update dependency groups 2026-04-19 00:41:07 -07:00
bedb338ac9 [docs] add push_with_docs script 2026-04-19 00:19:57 -07:00
a82eb5858a [docs] switch generated docs to MkDocs 2026-04-18 23:55:40 -07:00
5e95d66a7e [docs] expand API and derivation docs 2026-04-18 23:55:40 -07:00
0568e1ba50 [tests] add a waveguide scattering test 2026-04-18 23:55:40 -07:00
d4c1082ca9 [tests] FDFD/FDTD equivalence test 2026-04-18 23:55:40 -07:00
d99ef96c96 [fdtd.phasor] add accumulate_phasor* 2026-04-18 23:55:40 -07:00
8cdcd08ba0 [tests] refactor tests 2026-04-18 23:55:40 -07:00
267d161769 [tests] more test coverage 2026-04-18 23:55:40 -07:00
0afe2297b0 [fdfd.operators] fix eh_full for non-None mu 2026-04-18 23:55:40 -07:00
0ff23542ac [tests] more tests 2026-04-18 23:55:40 -07:00
9ac24892d6 [tests] test more 2D waveguide results 2026-04-18 23:55:40 -07:00
f3d13e1486 [fdfd.eme] do a better job of enforcing no gain 2026-04-18 23:55:40 -07:00
e6756742be [bloch] add some more tests and clean up solves 2026-04-18 23:55:40 -07:00
87bb3af3f9 [fdfd] minor fixes and more tests 2026-04-18 23:55:40 -07:00
07b16ad86a [bloch] fixup some vectorization and add tests 2026-04-18 23:55:40 -07:00
f35b334100 [fdfd.waveguide_3d] improve handling of out-of-bounds overlap_e windows 2026-04-18 23:55:40 -07:00
593098bf8f [fdfd.functional] fix handling of mu in e_full and m2j sign 2026-04-18 23:55:40 -07:00
38a5c1a9aa [tests] add some more tests around numerical self-consistency 2026-04-18 23:49:53 -07:00
bc55baf4a6 [tests] add coverage and test options 2026-04-18 23:49:53 -07:00
7eea919f94 [fdtd.boundaries] use tuples for indexing 2026-04-18 23:49:53 -07:00
74bebea837 [fdfd.farfield] fix kys calculation and some near-0 behavior 2026-04-18 23:49:53 -07:00
8d49901b58 [fdtd.misc] fix some packets/pulses 2026-04-18 23:49:53 -07:00
9d419aa3ea [fdtd.misc.gaussian_beam] avoid some nans at w0 near 0 2026-04-18 23:05:08 -07:00
4913211883 [fdfd.eme] fix abcd array construction 2026-04-18 23:05:07 -07:00
f5af0fef55 [waveguide_3d] fixup and doc update 2026-04-18 23:04:44 -07:00
7e8ff23356 misc example updates 2026-04-18 23:02:10 -07:00
jan
be647658d3 [eigensolvers] Increase number of lanczos vectors (ncv) based on number of requested eigenvalues 2025-12-10 23:07:28 -08:00
jan
c46bed8298 update optional deps 2025-12-10 21:15:38 -08:00
jan
fb3bef23bf [examples/fdfd] split fdfd example into two files 2025-12-10 21:14:34 -08:00
jan
d4f1008c5c [fdfd.waveguide*] comment updates 2025-12-10 19:45:26 -08:00
jan
b486fa325b Rework field types, use sparse arrays instead of matrices, rework eme arg naming, improve type annotations and linter cleanup 2025-12-10 02:14:20 -08:00
jan
b7ad5dea2b [fdfd.bloch] drop unnecessary noqas 2025-12-10 02:05:24 -08:00
jan
684b891e0f [waveguide_3d] clean up docstrings 2025-12-09 22:56:16 -08:00
jan
4a80ca8b12 [waveguide_cyl] silence some debug prints 2025-12-09 22:55:52 -08:00
e3169b9e20 bump version to v0.10 2025-04-16 22:20:16 -07:00
35ecbad15e remove old lint 2025-04-16 22:19:21 -07:00
43e01a814d examples will use new gridlock 2025-04-16 22:19:14 -07:00
9eb0e28bcb [meanas.fdtd.misc] add basic pulse and beam shapes 2025-03-12 23:40:00 -07:00
c858b20d47 Bump numpy dependency to >=2.0 2025-03-12 23:19:20 -07:00
777ecbc024 [fdfd.solvers.generic] add option to pass a guess solution 2025-02-05 00:13:46 -08:00
c4f8749941 [fdfd.solvers.generic] report residual scaled to b 2025-02-05 00:09:25 -08:00
cd5cc9eb83 [fdfd.eme] Add basic (WIP) eignmode expansion functionality 2025-01-28 22:07:19 -08:00
99e8d32eb1 [waveguide_cyl] frequency should be real 2025-01-28 22:06:32 -08:00
1cb0cb2e4f [fdfd.waveguide_cyl] Improve documentation and add auxiliary functions (e.g. exy2exyz) 2025-01-28 21:59:59 -08:00
234e8d7ac3 delete h version of operator in comment 2025-01-28 19:55:09 -08:00
83f4d87ad8 [fdfd.waveguide*] misc fixes 2025-01-28 19:54:48 -08:00
1987ee473a improve type annotations 2025-01-28 19:54:13 -08:00
4afc6cf62e cleanup latex 2025-01-14 22:34:52 -08:00
53d5812b4a [waveguide_2d] Remove \gamma from docs in favor of just using \beta 2025-01-14 22:34:35 -08:00
651e255704 add derivation for exy2e() 2025-01-14 22:15:18 -08:00
71c2bbfada Add linear_wavenumbers() for calculating 1/distance wavenumbers 2025-01-14 22:02:43 -08:00
6a56921c12 Return angular wavenumbers, and remove r0 arg (leaving only rmin) 2025-01-14 22:02:19 -08:00
006833acf2 add logger 2025-01-14 22:01:29 -08:00
155f30068f add inner_product() and use it for energy calculation 2025-01-14 22:01:10 -08:00
7987dc796f mode numbers may be any sequence 2025-01-14 22:00:21 -08:00
829007c672 Only keep the real part of the energy 2025-01-14 22:00:08 -08:00
659566750f update for new gridlock syntax 2025-01-14 21:59:46 -08:00
76701f593c Check overlap only on forward-propagating part of mode 2025-01-14 21:59:37 -08:00
4e3a163522 indentation & style 2025-01-14 21:59:12 -08:00
50f92e1cc8 [vectorization] add nvdim arg allowing unvec() on 2D fields 2025-01-14 21:58:46 -08:00
b3c2fd391b [waveguide_2d] Return modes sorted by wavenumber (descending) 2025-01-14 21:57:54 -08:00
c543868c0b check for sign=0 case 2025-01-14 21:51:32 -08:00
e54735d9c6 Fix cylindrical waveguide module
- Properly account for rmin vs r0
- Change return values to match waveguide_2d
- Change operator definition to look more like waveguide_2d

remaining TODO:
- Fix docs
- Further consolidate operators vs waveguide_2d
- Figure out E/H field conversions
2025-01-07 00:10:15 -08:00
4f2433320d fix zip(strict=True) for 2D problems 2025-01-07 00:05:19 -08:00
47415a0beb Return list-of-vectors from waveguide mode solve 2025-01-07 00:04:53 -08:00
e459b5e61f clean up comments and some types 2025-01-07 00:04:01 -08:00
36431cd0e4 enable numpy 2.0 and recent scipy 2024-07-29 02:25:16 -07:00
739e96df3d avoid a copy 2024-07-29 00:34:17 -07:00
63e7cb949f explicitly specify closed variables 2024-07-29 00:33:58 -07:00
c53a3c4d84 unused var 2024-07-29 00:33:43 -07:00
5dd9994e76 improve some type annotations 2024-07-29 00:32:52 -07:00
1021768e30 simplify indentation 2024-07-29 00:32:20 -07:00
95e923d7b7 improve error handling 2024-07-29 00:32:03 -07:00
3f8802cb5f use strict zip 2024-07-29 00:31:44 -07:00
43bb0ba379 use generators where applicable 2024-07-29 00:31:16 -07:00
e19968bb9f linter-related test updates 2024-07-29 00:30:00 -07:00
43f038d761 modernize type annotations 2024-07-29 00:29:39 -07:00
d5fca741d1 remove type:ignore from scipy imports (done at pyproject.toml level) 2024-07-29 00:27:59 -07:00
ca94ad1b25 use path.open() 2024-07-29 00:23:08 -07:00
10f26c12b4 add ruff and mypy configs 2024-07-29 00:22:54 -07:00
ee51c7db49 improve type annotations 2024-07-28 23:23:47 -07:00
36bea6a593 drop unused import 2024-07-28 23:23:21 -07:00
b16b35d84a use new numpy.random.Generator approach 2024-07-28 23:23:11 -07:00
6f3ae5a64f explicitly re-export some names 2024-07-28 23:22:21 -07:00
99c22d572f bump numpy version 2024-07-28 23:21:59 -07:00
2f00baf0c6 fixup cylindrical wg example 2024-07-18 19:31:17 -07:00
2712d96f2a add notes on references 2024-07-18 19:31:17 -07:00
dc3e733e7f flake8 fixes 2024-07-18 19:31:17 -07:00
95e3f71b40 use f-strings in place of .format() 2024-07-18 19:31:17 -07:00
639f88bba8 add sensitivity calculation 2024-07-18 19:31:17 -07:00
122 changed files with 10831 additions and 3110 deletions

4
.gitignore vendored
View file

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

148
README.md
View file

@ -56,6 +56,21 @@ 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]'
``` ```
@ -80,9 +95,13 @@ 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]'
# Run tests # Fast local iteration: excludes slower 3D/integration/example-smoke checks
cd meanas cd meanas
python3 -m pytest -rsxX | tee test_results.txt python3 -m pytest -q -m "not complete"
# 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:
@ -94,6 +113,125 @@ python3 -m pytest -rsxX | tee test_results.txt
## Use ## Use
See `examples/` for some simple examples; you may need additional `meanas` is a collection of finite-difference electromagnetics tools:
packages such as [gridlock](https://mpxd.net/code/jan/gridlock)
to run the examples. - `meanas.fdfd`: frequency-domain wave equations, sparse operators, SCPML, and
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.

3
docs/api/eigensolvers.md Normal file
View file

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

15
docs/api/fdfd.md Normal file
View file

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

13
docs/api/fdmath.md Normal file
View file

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

15
docs/api/fdtd.md Normal file
View file

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

14
docs/api/index.md Normal file
View file

@ -0,0 +1,14 @@
# 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.

3
docs/api/meanas.md Normal file
View file

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

7
docs/api/waveguides.md Normal file
View file

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

1
docs/assets/vendor/mathjax/core.js vendored Normal file

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

1
docs/assets/vendor/mathjax/loader.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,38 @@
{
"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

1
docs/assets/vendor/mathjax/startup.js vendored Normal file

File diff suppressed because one or more lines are too long

61
docs/index.md Normal file
View file

@ -0,0 +1,61 @@
# 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

@ -0,0 +1,19 @@
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

@ -0,0 +1,64 @@
.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;
}

221
examples/eme.py Normal file
View file

@ -0,0 +1,221 @@
"""
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()

314
examples/eme_bend.py Normal file
View file

@ -0,0 +1,314 @@
"""
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()

103
examples/fdfd0.py Normal file
View file

@ -0,0 +1,103 @@
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,10 +1,13 @@
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 from meanas.fdmath import vec, unvec, fdfield_t
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
@ -12,7 +15,6 @@ 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)
@ -20,82 +22,6 @@ 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)
@ -122,7 +48,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, center=center, dimensions=[8e3, w, th], eps=n_wg**2) 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)
dxes = [grid.dxyz, grid.autoshifted_dxyz()] dxes = [grid.dxyz, grid.autoshifted_dxyz()]
for a in (0, 1, 2): for a in (0, 1, 2):
@ -156,22 +82,14 @@ 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)
def pcolor(v) -> None: grid.visualize_slice(J.imag, plane=dict(y=6*dx), which_shifts=1, pcolormesh_args=dict(norm=colors.CenteredNorm(), cmap='bwr'))
vmax = numpy.max(numpy.abs(v)) fig, ax = pyplot.subplots()
pyplot.pcolor(v, cmap='seismic', vmin=-vmax, vmax=vmax) ax.pcolormesh((numpy.abs(J).sum(axis=2).sum(axis=0) > 0).astype(float).T, cmap='hot')
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,
@ -188,20 +106,18 @@ 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)
pyplot.figure() fig, axes = pyplot.subplots(2, 2)
pyplot.subplot(2, 2, 1) 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'))
pcolor(numpy.real(E[1][center[0], :, :]).T) 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'))
pyplot.subplot(2, 2, 2) # pcolor(axes[0, 0], numpy.real(E[1][center[0], :, :]).T)
pyplot.plot(numpy.log10(numpy.abs(E[1][:, center[1], center[2]]) + 1e-10)) # pcolor(axes[0, 1], numpy.real(E[1][:, :, center[2]]).T)
pyplot.grid(alpha=0.6) axes[1, 0].plot(numpy.log10(numpy.abs(E[1][:, center[1], center[2]]) + 1e-10))
pyplot.ylabel('log10 of field') axes[1, 0].grid(alpha=0.6)
pyplot.subplot(2, 2, 3) axes[1, 0].set_ylabel('log10 of field')
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)
@ -215,24 +131,28 @@ def test1(solver=generic_solver):
return s0, s1, s2 return s0, s1, s2
s0x, s1x, s2x = poyntings(E) s0x, s1x, s2x = poyntings(E)
pyplot.plot(s0x[0].sum(axis=2).sum(axis=1), label='s0', marker='.') ax = axes[1, 1]
pyplot.plot(s1x[0].sum(axis=2).sum(axis=1), label='s1', marker='.') ax.plot(s0x[0].sum(axis=2).sum(axis=1), label='s0', marker='.')
pyplot.plot(s2x[0].sum(axis=2).sum(axis=1), label='s2', marker='.') ax.plot(s1x[0].sum(axis=2).sum(axis=1), label='s1', marker='.')
pyplot.plot(E[1][:, center[1], center[2]].real.T, label='Ey', marker='x') ax.plot(s2x[0].sum(axis=2).sum(axis=1), label='s2', marker='.')
pyplot.grid(alpha=0.6) ax.plot(E[1][:, center[1], center[2]].real.T, label='Ey', marker='x')
pyplot.legend() ax.grid(alpha=0.6)
pyplot.show() ax.legend()
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) @ vec(e_ovl_rolled).conj())] q += [numpy.abs(vec(E).conj() @ vec(e_ovl_rolled))]
pyplot.figure() fig, ax = pyplot.subplots()
pyplot.plot(q, marker='.') ax.plot(q, marker='.')
pyplot.grid(alpha=0.6) ax.grid(alpha=0.6)
pyplot.title('Overlap with mode') ax.set_title('Overlap with mode')
pyplot.show() print('Average overlap with mode:', sum(q[8:32])/len(q[8:32]))
print('Average overlap with mode:', sum(q)/len(q))
pyplot.show(block=True)
def module_available(name): def module_available(name):
@ -240,9 +160,6 @@ 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)
@ -253,3 +170,4 @@ if __name__ == '__main__':
# test1(magma_solver) # test1(magma_solver)
else: else:
test1() test1()

View file

@ -1,18 +1,30 @@
""" """
Example code for running an OpenCL FDTD simulation Example code for a broadband FDTD run with phasor extraction.
See main() for simulation setup. This script shows the intended low-level workflow for:
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 masque import Pattern, shapes from meanas.fdtd.misc import gaussian_packet
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
@ -41,8 +53,7 @@ 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 = {'hole_dose': 1, default_args = {
'trench_dose': 1,
'hole_layer': 0, 'hole_layer': 0,
'trench_layer': 1, 'trench_layer': 1,
'shifts_a': (0.15, 0, 0.075), 'shifts_a': (0.15, 0, 0.075),
@ -53,38 +64,39 @@ def perturbed_l3(a: float, radius: float, **kwargs) -> Pattern:
} }
kwargs = {**default_args, **kwargs} kwargs = {**default_args, **kwargs}
xyr = pcgen.l3_shift_perturbed_defect(mirror_dims=kwargs['xy_size'], xyr = pcgen.l3_shift_perturbed_defect(
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 += [shapes.Circle(radius=r, offset=(x, y), pat.shapes[(kwargs['hole_layer'], 0)] += [
dose=kwargs['hole_dose'], Circle(radius=r, offset=(x, y))
layer=kwargs['hole_layer'])
for x, y, r in xyr] for x, y, r in xyr]
maxes = numpy.max(numpy.fabs(xyr), axis=0) maxes = numpy.max(numpy.fabs(xyr), axis=0)
pat.shapes += [shapes.Polygon.rectangle( pat.shapes[(kwargs['trench_layer'], 0)] += [
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(): def main() -> None:
dtype = numpy.float32 dtype = numpy.float32
max_t = 8000 # number of timesteps max_t = 3600 # 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])
@ -107,69 +119,97 @@ def main():
# #### 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(epsilon, grid.draw_slab(
surface_normal=2, epsilon,
center=[0, 0, 0], slab = dict(axis='z', center=0, span=th),
thickness=th, foreground = n_slab ** 2,
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),
)
grid.draw_polygons(epsilon, print(f'{grid.shape=}')
surface_normal=2,
center=[0, 0, 0],
thickness=2 * th,
eps=n_air**2,
polygons=mask.as_polygons())
print(grid.shape) dt = dx * 0.99 / numpy.sqrt(3)
ee = numpy.zeros_like(epsilon, dtype=complex)
dt = .99/numpy.sqrt(3) hh = numpy.zeros_like(epsilon, dtype=complex)
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 = [[cpml_params(axis=dd, polarity=pp, dt=dt, pml_params = [
thickness=pml_thickness, epsilon_eff=1.0**2) [cpml_params(axis=dd, polarity=pp, dt=dt, thickness=pml_thickness, epsilon_eff=n_air ** 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, update_E, update_H = updates_with_cpml(cpml_params=pml_params, dt=dt, dxes=dxes, epsilon=epsilon, dtype=complex)
dxes=dxes, epsilon=epsilon)
# Source parameters and function # sample_interval = numpy.floor(1 / (2 * 1 / wl * dt)).astype(int)
w = 2 * numpy.pi * dx / wl # print(f'Save time interval would be {sample_interval} * dt = {sample_interval * dt:3g}')
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):
t0 = i * dt - delay # Build the pulse directly at the current half-steps and normalize that
return numpy.sin(w * t0) * numpy.exp(-alpha * t0**2) # scalar waveform so its 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 #### # #### 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 t in range(max_t): for tt in range(max_t):
update_E(e, h, epsilon) update_E(ee, hh, epsilon)
e[1][tuple(grid.shape//2)] += field_source(t) # Electric-current injection uses E -= dt * J / epsilon, which is the
update_H(e, h) # same sign convention used by the matching FDFD right-hand side.
j_step = pulse_scale * source_waveform[tt] * j_source
ee -= dt * j_step / epsilon
update_H(ee, hh)
avg_rate = (t + 1)/(time.perf_counter() - start)) avg_rate = (tt + 1) / (time.perf_counter() - start)
print(f'iteration {t}: average {avg_rate} iterations per sec')
sys.stdout.flush() sys.stdout.flush()
if t % 20 == 0: if tt % 200 == 0:
r = sum([(f * f * e).sum() for f, e in zip(e, epsilon)]) print(f'iteration {tt}: average {avg_rate} iterations per sec')
print('E sum', r) E_energy_sum = (ee.conj() * ee * epsilon).sum().real
print(f'{E_energy_sum=}')
# Save field slices # Save field slices
if (t % 20 == 0 and (max_t - t <= 1000 or t <= 2000)) or t == max_t-1: if (tt % 20 == 0 and (max_t - tt <= 1000 or tt <= 2000)) or tt == max_t - 1:
print('saving E-field') print(f'saving E-field at iteration {tt}')
for j, f in enumerate(e): output_file[f'/E_t{tt}'] = ee[:, :, :, ee.shape[3] // 2]
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()

342
examples/waveguide.py Normal file
View file

@ -0,0 +1,342 @@
"""
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()

219
examples/waveguide_real.py Normal file
View file

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

View file

@ -1,18 +1,19 @@
""" """
Electromagnetic simulation tools Electromagnetic simulation tools
See the readme or `import meanas; help(meanas)` for more info. See the tracked examples for end-to-end workflows, and `help(meanas)` for the
toolbox overview and API derivations.
""" """
import pathlib import pathlib
__version__ = '0.9' __version__ = '0.12'
__author__ = 'Jan Petykiewicz' __author__ = 'Jan Petykiewicz'
try: try:
with open(pathlib.Path(__file__).parent / 'README.md', 'r') as f: readme_path = pathlib.Path(__file__).parent / 'README.md'
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 typing import Callable from collections.abc 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 # type: ignore from scipy import sparse
import scipy.sparse.linalg as spalg # type: ignore import scipy.sparse.linalg as spalg
def power_iteration( def power_iteration(
@ -25,8 +25,9 @@ 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 = numpy.random.rand(operator.shape[0]) + 1j * numpy.random.rand(operator.shape[0]) v = rng.random(operator.shape[0]) + 1j * rng.random(operator.shape[0])
else: else:
v = guess_vector v = guess_vector
@ -63,10 +64,10 @@ def rayleigh_quotient_iteration(
(eigenvalues, eigenvectors) (eigenvalues, eigenvectors)
""" """
try: try:
(operator - sparse.eye(operator.shape[0])) (operator - sparse.eye_array(operator.shape[0]))
def shift(eigval: float) -> sparse: def shift(eigval: float) -> sparse.sparray:
return eigval * sparse.eye(operator.shape[0]) return eigval * sparse.eye_array(operator.shape[0])
if solver is None: if solver is None:
solver = spalg.spsolve solver = spalg.spsolve
@ -129,12 +130,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(operator.shape[0]) shifted_operator = operator + shift * sparse.eye_array(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=50) shifted_eigenvalues, eigenvectors = spalg.eigs(shifted_operator, which='LM', k=how_many, ncv=2 * how_many + 50)
eigenvalues = shifted_eigenvalues - shift eigenvalues = shifted_eigenvalues - shift
k = eigenvalues.argsort() k = eigenvalues.argsort()

View file

@ -9,9 +9,12 @@ 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.
================================================================ ================================================================
@ -86,10 +89,13 @@ $$
-\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 solvers, operators, functional, scpml, waveguide_2d, waveguide_3d from . import (
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,18 +94,19 @@ This module contains functions for generating and solving the
""" """
from typing import Callable, Any, cast, Sequence from typing import Any, cast
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 # type: ignore import scipy
import scipy.optimize # type: ignore import scipy.optimize
from scipy.linalg import norm # type: ignore from scipy.linalg import norm
import scipy.sparse.linalg as spalg # type: ignore import scipy.sparse.linalg as spalg
from ..fdmath import fdfield_t, cfdfield_t from ..fdmath import fdfield, cfdfield, cfdfield_t
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -114,7 +115,6 @@ 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,6 +136,14 @@ 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,
@ -155,7 +163,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.array(G_matrix, copy=False) G_matrix = numpy.asarray(G_matrix)
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)
@ -183,8 +191,8 @@ def generate_kmn(
def maxwell_operator( def maxwell_operator(
k0: ArrayLike, k0: ArrayLike,
G_matrix: ArrayLike, G_matrix: ArrayLike,
epsilon: fdfield_t, epsilon: fdfield,
mu: fdfield_t | None = None mu: fdfield | None = None
) -> Callable[[NDArray[numpy.complex128]], NDArray[numpy.complex128]]: ) -> Callable[[NDArray[numpy.complex128]], NDArray[numpy.complex128]]:
""" """
Generate the Maxwell operator Generate the Maxwell operator
@ -232,13 +240,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 # noqa: E128 - m * hin_n) * k_mag
# 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
@ -253,8 +261,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[:, :, :, None] b_xyz = (m * b_m
+ n * b_n[:, :, :, None]) # noqa: E128 + n * b_n) # noqa
# divide by mu # divide by mu
temp = ifftn(b_xyz, axes=range(3)) temp = ifftn(b_xyz, axes=range(3))
@ -265,10 +273,7 @@ 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)
h.shape = (h.size,) return _assemble_hmn_vector(h_m, h_n)
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
@ -276,7 +281,7 @@ def maxwell_operator(
def hmn_2_exyz( def hmn_2_exyz(
k0: ArrayLike, k0: ArrayLike,
G_matrix: ArrayLike, G_matrix: ArrayLike,
epsilon: fdfield_t, epsilon: fdfield,
) -> 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
@ -303,12 +308,13 @@ 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 # noqa: E128 - m * hin_n) * k_mag
# divide by epsilon # divide by epsilon
return numpy.array([ei for ei in numpy.moveaxis(ifftn(d_xyz, axes=range(3)) / epsilon, 3, 0)]) # TODO avoid copy exyz = numpy.moveaxis(ifftn(d_xyz, axes=range(3)) / epsilon, 3, 0)
return cfdfield_t(exyz)
return operator return operator
@ -316,7 +322,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_t epsilon: fdfield,
) -> 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
@ -341,10 +347,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) # noqa: E128 + n * hin_n)
return numpy.array([ifftn(hi) for hi in numpy.moveaxis(h_xyz, 3, 0)]) return cfdfield_t(numpy.array([ifftn(hi) for hi in numpy.moveaxis(h_xyz, 3, 0)]))
return operator return operator
@ -352,8 +358,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_t, epsilon: fdfield,
mu: fdfield_t | None = None, mu: fdfield | 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,
@ -394,7 +400,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
@ -402,8 +408,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[:, :, :, None] h_xyz = (m * hin_m
+ n * hin_n[:, :, :, None]) # noqa: E128 + n * hin_n) # noqa
# multiply by mu # multiply by mu
temp = ifftn(h_xyz, axes=range(3)) temp = ifftn(h_xyz, axes=range(3))
@ -411,12 +417,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) b_m = numpy.sum(b_xyz * m, axis=3, keepdims=True)
b_n = numpy.sum(b_xyz * n, axis=3) b_n = numpy.sum(b_xyz * n, axis=3, keepdims=True)
# 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 # noqa: E128 - m * b_n) / k_mag
# multiply by epsilon # multiply by epsilon
temp = ifftn(e_xyz, axes=range(3)) temp = ifftn(e_xyz, axes=range(3))
@ -427,10 +433,7 @@ 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
h.shape = (h.size,) return _assemble_hmn_vector(h_m, h_n)
h = numpy.concatenate((h_m, h_n), axis=None, out=h)
h.shape = (h.size, 1)
return h
return operator return operator
@ -440,15 +443,15 @@ def find_k(
tolerance: float, tolerance: float,
direction: ArrayLike, direction: ArrayLike,
G_matrix: ArrayLike, G_matrix: ArrayLike,
epsilon: fdfield_t, epsilon: fdfield,
mu: fdfield_t | None = None, mu: fdfield | 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[float, float, NDArray[numpy.complex128], NDArray[numpy.complex128]]: ) -> tuple[NDArray[numpy.float64], 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.
@ -471,7 +474,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) direction = numpy.array(direction) / norm(direction) # type: ignore[operator]
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
@ -493,23 +496,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),
k_guess, method='bounded',
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
return float(res.x * direction), float(res.fun + frequency), n, v actual_frequency = get_f(float(res.x), band)
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_t, epsilon: fdfield,
mu: fdfield_t | None = None, mu: fdfield | 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,
@ -538,7 +541,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.array(k0, copy=False) k0 = numpy.asarray(k0)
h_size = 2 * epsilon[0].size h_size = 2 * epsilon[0].size
@ -561,11 +564,12 @@ 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 = numpy.random.rand(*y_shape) + 1j * numpy.random.rand(*y_shape) Z = rng.random(y_shape) + 1j * rng.random(y_shape)
else: else:
Z = numpy.array(y0, copy=False).T Z = numpy.asarray(y0).T
while True: while True:
Z *= num_modes / norm(Z) Z *= num_modes / norm(Z)
@ -573,7 +577,7 @@ def eigsolve(
try: try:
U = numpy.linalg.inv(ZtZ) U = numpy.linalg.inv(ZtZ)
except numpy.linalg.LinAlgError: except numpy.linalg.LinAlgError:
Z = numpy.random.rand(*y_shape) + 1j * numpy.random.rand(*y_shape) Z = rng.random(y_shape) + 1j * rng.random(y_shape)
continue continue
trace_U = real(trace(U)) trace_U = real(trace(U))
@ -646,17 +650,16 @@ def eigsolve(
Qi_memo: list[float | None] = [None, None] Qi_memo: list[float | None] = [None, None]
def Qi_func(theta: float) -> float: def Qi_func(theta: float, Qi_memo=Qi_memo, ZtZ=ZtZ, DtD=DtD, symZtD=symZtD) -> float: # noqa: ANN001
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: except numpy.linalg.LinAlgError as err:
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:
@ -666,12 +669,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') raise Exception('Inexplicable singularity in trace_func') from err
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) -> float: def trace_func(theta: float, ZtAZ=ZtAZ, DtAD=DtAD, symZtAD=symZtAD) -> float: # noqa: ANN001
c = numpy.cos(theta) c = numpy.cos(theta)
s = numpy.sin(theta) s = numpy.sin(theta)
Qi = Qi_func(theta) Qi = Qi_func(theta)
@ -680,7 +683,16 @@ def eigsolve(
return numpy.abs(trace) return numpy.abs(trace)
if False: if False:
def trace_deriv(theta): def trace_deriv(
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)
@ -722,7 +734,12 @@ def eigsolve(
amax=pi, amax=pi,
) )
result = scipy.optimize.minimize_scalar(trace_func, bounds=(0, pi), tol=tolerance) result = scipy.optimize.minimize_scalar(
trace_func,
method='bounded',
bounds=(0, pi),
options={'xatol': tolerance},
)
new_E = result.fun new_E = result.fun
theta = result.x theta = result.x
@ -751,7 +768,7 @@ def eigsolve(
v = eigvecs[:, i] v = eigvecs[:, i]
n = eigvals[i] n = eigvals[i]
v /= norm(v) v /= norm(v)
Av = (scipy_op @ v.copy())[:, 0] Av = numpy.asarray(scipy_op @ v.copy()).reshape(-1)
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)
@ -799,3 +816,62 @@ 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

190
meanas/fdfd/eme.py Normal file
View file

@ -0,0 +1,190 @@
"""
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,20 +1,24 @@
""" """
Functions for performing near-to-farfield transformation (and the reverse). Functions for performing near-to-farfield transformation (and the reverse).
""" """
from typing import Any, Sequence, cast from typing import Any, 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
from ..fdmath import cfdfield_t type farfield_slice = NDArray[complexfloating]
type transverse_slice_pair = Sequence[farfield_slice]
def near_to_farfield( def near_to_farfield(
E_near: cfdfield_t, E_near: transverse_slice_pair,
H_near: cfdfield_t, H_near: transverse_slice_pair,
dx: float, dx: float,
dy: float, dy: float,
padded_size: list[int] | int | None = None padded_size: Sequence[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
@ -55,14 +59,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]
@ -75,25 +79,22 @@ 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(k * k - kxy2) kz = numpy.sqrt(numpy.maximum(0, k * k - kxy2))
sin_th = ky / kxy sin_th = numpy.divide(ky, kxy, out=numpy.zeros_like(ky), where=kxy != 0)
cos_th = kx / kxy cos_th = numpy.divide(kx, kxy, out=numpy.ones_like(kx), where=kxy != 0)
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: E127 Hn_fft[1] * sin_th + Hn_fft[0] * cos_th] # noqa
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: E128 -En_fft[1] * sin_th - En_fft[0] * cos_th] # noqa
E_far = [-L[1] - N[0], E_far = [-L[1] - N[0],
L[0] - N[1]] # noqa: E127 L[0] - N[1]] # noqa
H_far = [-E_far[1], H_far = [-E_far[1],
E_far[0]] # noqa: E127 E_far[0]] # noqa
theta = numpy.arctan2(ky, kx) theta = numpy.arctan2(ky, kx)
phi = numpy.arccos(cos_phi) phi = numpy.arccos(cos_phi)
@ -111,8 +112,8 @@ def near_to_farfield(
outputs = { outputs = {
'E': E_far, 'E': E_far,
'H': H_far, 'H': H_far,
'dkx': kx[1] - kx[0], 'dkx': float(kxx[1] - kxx[0]),
'dky': ky[1] - ky[0], 'dky': float(kyy[1] - kyy[0]),
'kx': kx, 'kx': kx,
'ky': ky, 'ky': ky,
'theta': theta, 'theta': theta,
@ -123,11 +124,11 @@ def near_to_farfield(
def far_to_nearfield( def far_to_nearfield(
E_far: cfdfield_t, E_far: transverse_slice_pair,
H_far: cfdfield_t, H_far: transverse_slice_pair,
dkx: float, dkx: float,
dky: float, dky: float,
padded_size: list[int] | int | None = None padded_size: Sequence[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
@ -164,32 +165,29 @@ 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 = fftshift(fftfreq(s[0], 1 / (s[0] * dkx))) kxs = dkx * fftshift(fftfreq(s[0], d=1 / s[0]))
kys = fftshift(fftfreq(s[0], 1 / (s[1] * dky))) kys = dky * fftshift(fftfreq(s[1], d=1 / s[1]))
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(k * k - kxy2) kz = numpy.sqrt(numpy.maximum(0, k * k - kxy2))
sin_th = ky / kxy sin_th = numpy.divide(ky, kxy, out=numpy.zeros_like(ky), where=kxy != 0)
cos_th = kx / kxy cos_th = numpy.divide(kx, kxy, out=numpy.ones_like(kx), where=kxy != 0)
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
@ -205,25 +203,45 @@ 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: E128 -0.5 * E_far[0]] # noqa
N = [L[1], N = [L[1],
-L[0]] # noqa: E128 -L[0]] # noqa
En_fft = [-( L[0] * sin_th + L[1] * cos_phi * cos_th) / cos_phi, En_fft = [
-(-L[0] * cos_th + L[1] * cos_phi * sin_th) / cos_phi] numpy.divide(
-(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 = [( N[0] * sin_th + N[1] * cos_phi * cos_th) / cos_phi, Hn_fft = [
(-N[0] * cos_th + N[1] * cos_phi * sin_th) / cos_phi] numpy.divide(
N[0] * sin_th + N[1] * cos_phi * cos_th,
for i in range(2): cos_phi,
En_fft[i][cos_phi == 0] = 0 out=numpy.zeros_like(N[0]),
Hn_fft[i][cos_phi == 0] = 0 where=cos_phi != 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[0] * dky) dy = 2 * pi / (s[1] * dky)
outputs = { outputs = {
'E': E_near, 'E': E_near,
@ -233,4 +251,3 @@ 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 typing import Callable from collections.abc import Callable
import numpy import numpy
from ..fdmath import dx_lists_t, fdfield_t, cfdfield_t, cfdfield_updater_t from ..fdmath import dx_lists_t, cfdfield_t, fdfield, cfdfield, 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_t, epsilon: fdfield,
mu: fdfield_t | None = None, mu: fdfield | 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,26 +37,25 @@ 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_t) -> cfdfield_t: def op_1(e: cfdfield) -> cfdfield_t:
curls = ch(ce(e)) curls = ch(ce(e))
return curls - omega ** 2 * epsilon * e return cfdfield_t(curls - omega ** 2 * epsilon * e)
def op_mu(e: cfdfield_t) -> cfdfield_t: def op_mu(e: cfdfield_t) -> cfdfield_t:
curls = ch(mu * ce(e)) # type: ignore # mu = None ok because we don't return the function curls = ch(ce(e) / mu) # type: ignore # mu = None ok because we don't return the function
return curls - omega ** 2 * epsilon * e return cfdfield_t(curls - omega ** 2 * epsilon * e)
if mu is None: if mu is None:
return op_1 return op_1
else:
return op_mu return op_mu
def eh_full( def eh_full(
omega: complex, omega: complex,
dxes: dx_lists_t, dxes: dx_lists_t,
epsilon: fdfield_t, epsilon: fdfield,
mu: fdfield_t | None = None, mu: fdfield | None = None,
) -> Callable[[cfdfield_t, cfdfield_t], tuple[cfdfield_t, cfdfield_t]]: ) -> Callable[[cfdfield, cfdfield], 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`.
@ -74,24 +73,23 @@ 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_t, h: cfdfield_t) -> tuple[cfdfield_t, cfdfield_t]: def op_1(e: cfdfield, h: cfdfield) -> tuple[cfdfield_t, cfdfield_t]:
return (ch(h) - 1j * omega * epsilon * e, return (cfdfield_t(ch(h) - 1j * omega * epsilon * e),
ce(e) + 1j * omega * h) cfdfield_t(ce(e) + 1j * omega * h))
def op_mu(e: cfdfield_t, h: cfdfield_t) -> tuple[cfdfield_t, cfdfield_t]: def op_mu(e: cfdfield, h: cfdfield) -> tuple[cfdfield_t, cfdfield_t]:
return (ch(h) - 1j * omega * epsilon * e, return (cfdfield_t(ch(h) - 1j * omega * epsilon * e),
ce(e) + 1j * omega * mu * h) # type: ignore # mu=None ok cfdfield_t(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
else:
return op_mu return op_mu
def e2h( def e2h(
omega: complex, omega: complex,
dxes: dx_lists_t, dxes: dx_lists_t,
mu: fdfield_t | None = None, mu: fdfield | 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.
@ -108,22 +106,21 @@ def e2h(
""" """
ce = curl_forward(dxes[0]) ce = curl_forward(dxes[0])
def e2h_1_1(e: cfdfield_t) -> cfdfield_t: def e2h_1_1(e: cfdfield) -> cfdfield_t:
return ce(e) / (-1j * omega) return cfdfield_t(ce(e) / (-1j * omega))
def e2h_mu(e: cfdfield_t) -> cfdfield_t: def e2h_mu(e: cfdfield) -> cfdfield_t:
return ce(e) / (-1j * omega * mu) # type: ignore # mu=None ok return cfdfield_t(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
else:
return e2h_mu return e2h_mu
def m2j( def m2j(
omega: complex, omega: complex,
dxes: dx_lists_t, dxes: dx_lists_t,
mu: fdfield_t | None = None, mu: fdfield | None = None,
) -> cfdfield_updater_t: ) -> cfdfield_updater_t:
""" """
Utility operator for converting magnetic current `M` distribution Utility operator for converting magnetic current `M` distribution
@ -142,30 +139,42 @@ 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 J return cfdfield_t(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 J return cfdfield_t(J)
if mu is None: if mu is None:
return m2j_1 return m2j_1
else:
return m2j_mu return m2j_mu
def e_tfsf_source( def e_tfsf_source(
TF_region: fdfield_t, TF_region: fdfield,
omega: complex, omega: complex,
dxes: dx_lists_t, dxes: dx_lists_t,
epsilon: fdfield_t, epsilon: fdfield,
mu: fdfield_t | None = None, mu: fdfield | 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).
@ -179,20 +188,25 @@ 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_t) -> cfdfield_t: def op(e: cfdfield) -> cfdfield_t:
neg_iwj = A(TF_region * e) - TF_region * A(e) neg_iwj = A(TF_region * e) - TF_region * A(e)
return neg_iwj / (-1j * omega) return cfdfield_t(neg_iwj / (-1j * omega))
return op return op
def poynting_e_cross_h(dxes: dx_lists_t) -> Callable[[cfdfield_t, cfdfield_t], cfdfield_t]: def poynting_e_cross_h(dxes: dx_lists_t) -> Callable[[cfdfield, cfdfield], cfdfield_t]:
r""" 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
@ -208,9 +222,10 @@ def poynting_e_cross_h(dxes: dx_lists_t) -> Callable[[cfdfield_t, cfdfield_t], c
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 E x H as required for the poynting vector. Function `f` that returns the staggered-grid cross product `E \times H`.
For time-average power, call it as `f(E, H.conj())` and take `Re(...) / 2`.
""" """
def exh(e: cfdfield_t, h: cfdfield_t) -> cfdfield_t: def exh(e: cfdfield, h: cfdfield) -> 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]
@ -221,5 +236,5 @@ def poynting_e_cross_h(dxes: dx_lists_t) -> Callable[[cfdfield_t, cfdfield_t], c
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 s return cfdfield_t(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.spmatrix`) representations of These functions return sparse-matrix (`scipy.sparse.sparray`) 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
import scipy.sparse as sparse # type: ignore from scipy import sparse
from ..fdmath import vec, dx_lists_t, vfdfield_t, vcfdfield_t from ..fdmath import vec, dx_lists_t, vfdfield, vcfdfield
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,11 +40,11 @@ __author__ = 'Jan Petykiewicz'
def e_full( def e_full(
omega: complex, omega: complex,
dxes: dx_lists_t, dxes: dx_lists_t,
epsilon: vfdfield_t, epsilon: vfdfield | vcfdfield,
mu: vfdfield_t | None = None, mu: vfdfield | None = None,
pec: vfdfield_t | None = None, pec: vfdfield | None = None,
pmc: vfdfield_t | None = None, pmc: vfdfield | None = None,
) -> sparse.spmatrix: ) -> sparse.sparray:
r""" 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 $$
@ -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(epsilon.size) pe = sparse.eye_array(epsilon.size)
else: else:
pe = sparse.diags(numpy.where(pec, 0, 1)) # Set pe to (not PEC) pe = sparse.diags_array(numpy.where(pec, 0, 1)) # Set pe to (not PEC)
if pmc is None: if pmc is None:
pm = sparse.eye(epsilon.size) pm = sparse.eye_array(epsilon.size)
else: else:
pm = sparse.diags(numpy.where(pmc, 0, 1)) # set pm to (not PMC) pm = sparse.diags_array(numpy.where(pmc, 0, 1)) # set pm to (not PMC)
e = sparse.diags(epsilon) e = sparse.diags_array(epsilon)
if mu is None: if mu is None:
m_div = sparse.eye(epsilon.size) m_div = sparse.eye_array(epsilon.size)
else: else:
m_div = sparse.diags(1 / mu) m_div = sparse.diags_array(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.spmatrix, sparse.spmatrix]: ) -> tuple[sparse.sparray, sparse.sparray]:
""" """
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,19 +118,19 @@ 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(p_vector) P_left = sparse.diags_array(p_vector)
P_right = sparse.diags(1 / p_vector) P_right = sparse.diags_array(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_t, epsilon: vfdfield,
mu: vfdfield_t | None = None, mu: vfdfield | None = None,
pec: vfdfield_t | None = None, pec: vfdfield | None = None,
pmc: vfdfield_t | None = None, pmc: vfdfield | None = None,
) -> sparse.spmatrix: ) -> sparse.sparray:
r""" 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 $$
@ -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(epsilon.size) pe = sparse.eye_array(epsilon.size)
else: else:
pe = sparse.diags(numpy.where(pec, 0, 1)) # set pe to (not PEC) pe = sparse.diags_array(numpy.where(pec, 0, 1)) # set pe to (not PEC)
if pmc is None: if pmc is None:
pm = sparse.eye(epsilon.size) pm = sparse.eye_array(epsilon.size)
else: else:
pm = sparse.diags(numpy.where(pmc, 0, 1)) # Set pe to (not PMC) pm = sparse.diags_array(numpy.where(pmc, 0, 1)) # Set pe to (not PMC)
e_div = sparse.diags(1 / epsilon) e_div = sparse.diags_array(1 / epsilon)
if mu is None: if mu is None:
m = sparse.eye(epsilon.size) m = sparse.eye_array(epsilon.size)
else: else:
m = sparse.diags(mu) m = sparse.diags_array(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,11 +183,11 @@ def h_full(
def eh_full( def eh_full(
omega: complex, omega: complex,
dxes: dx_lists_t, dxes: dx_lists_t,
epsilon: vfdfield_t, epsilon: vfdfield,
mu: vfdfield_t | None = None, mu: vfdfield | None = None,
pec: vfdfield_t | None = None, pec: vfdfield | None = None,
pmc: vfdfield_t | None = None, pmc: vfdfield | None = None,
) -> sparse.spmatrix: ) -> sparse.sparray:
r""" 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
@ -227,25 +227,27 @@ def eh_full(
Sparse matrix containing the wave operator. Sparse matrix containing the wave operator.
""" """
if pec is None: if pec is None:
pe = sparse.eye(epsilon.size) pe = sparse.eye_array(epsilon.size)
else: else:
pe = sparse.diags(numpy.where(pec, 0, 1)) # set pe to (not PEC) pe = sparse.diags_array(numpy.where(pec, 0, 1)) # set pe to (not PEC)
if pmc is None: if pmc is None:
pm = sparse.eye(epsilon.size) pm = sparse.eye_array(epsilon.size)
else: else:
pm = sparse.diags(numpy.where(pmc, 0, 1)) # set pm to (not PMC) pm = sparse.diags_array(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
iwm = 1j * omega if mu is None:
if mu is not None: iwm = 1j * omega * sparse.eye(epsilon.size)
iwm *= sparse.diags(mu) else:
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.bmat([[-iwe, A1], A = sparse.block_array([[-iwe, A1],
[A2, iwm]]) [A2, iwm]])
return A return A
@ -253,9 +255,9 @@ def eh_full(
def e2h( def e2h(
omega: complex, omega: complex,
dxes: dx_lists_t, dxes: dx_lists_t,
mu: vfdfield_t | None = None, mu: vfdfield | None = None,
pmc: vfdfield_t | None = None, pmc: vfdfield | None = None,
) -> sparse.spmatrix: ) -> sparse.sparray:
""" """
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.
@ -274,10 +276,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(1 / mu) @ op op = sparse.diags_array(1 / mu) @ op
if pmc is not None: if pmc is not None:
op = sparse.diags(numpy.where(pmc, 0, 1)) @ op op = sparse.diags_array(numpy.where(pmc, 0, 1)) @ op
return op return op
@ -285,8 +287,8 @@ def e2h(
def m2j( def m2j(
omega: complex, omega: complex,
dxes: dx_lists_t, dxes: dx_lists_t,
mu: vfdfield_t | None = None, mu: vfdfield | None = None,
) -> sparse.spmatrix: ) -> sparse.sparray:
""" """
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()`.
@ -302,79 +304,108 @@ 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(1 / mu) op = op @ sparse.diags_array(1 / mu)
return op return op
def poynting_e_cross(e: vcfdfield_t, dxes: dx_lists_t) -> sparse.spmatrix: def poynting_e_cross(e: vcfdfield, dxes: dx_lists_t) -> sparse.sparray:
""" r"""
Operator for computing the Poynting vector, containing the Operator for computing the staggered-grid `(E \times)` part of the Poynting vector.
(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 (E x) portion of Poynting cross product. Sparse matrix containing the `(E \times)` part of the staggered Poynting
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')]
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.bmat([[sparse.diags(x) if x is not None else None for x in row] block_matrix = sparse.block_array([[sparse.diags_array(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(numpy.concatenate(dxbg)) P = block_matrix @ sparse.diags_array(numpy.concatenate(dxbg))
return P return P
def poynting_h_cross(h: vcfdfield_t, dxes: dx_lists_t) -> sparse.spmatrix: def poynting_h_cross(h: vcfdfield, dxes: dx_lists_t) -> sparse.sparray:
""" r"""
Operator for computing the Poynting vector, containing the (H x) portion of the Poynting vector. Operator for computing the staggered-grid `(H \times)` part 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 (H x) portion of Poynting cross product. Sparse matrix containing the `(H \times)` part of the staggered Poynting
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(hi * db) for hi, db in zip(numpy.split(h, 3), dxbg)] Hx, Hy, Hz = (sparse.diags_array(hi * db) for hi, db in zip(numpy.split(h, 3), dxbg, strict=True))
P = (sparse.bmat( P = (sparse.block_array(
[[ 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(numpy.concatenate(dxag))) @ sparse.diags_array(numpy.concatenate(dxag)))
return P return P
def e_tfsf_source( def e_tfsf_source(
TF_region: vfdfield_t, TF_region: vfdfield,
omega: complex, omega: complex,
dxes: dx_lists_t, dxes: dx_lists_t,
epsilon: vfdfield_t, epsilon: vfdfield,
mu: vfdfield_t | None = None, mu: vfdfield | None = None,
) -> sparse.spmatrix: ) -> sparse.sparray:
""" 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.
TODO: Reference Rumpf paper Let `A` be the full wave operator from `e_full(...)`, and let
`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
@ -386,27 +417,31 @@ 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(TF_region) Q = sparse.diags_array(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_t, mask: vfdfield,
omega: complex, omega: complex,
dxes: dx_lists_t, dxes: dx_lists_t,
epsilon: vfdfield_t, epsilon: vfdfield,
mu: vfdfield_t | None = None, mu: vfdfield | None = None,
periodic_mask_edges: bool = False, periodic_mask_edges: bool = False,
) -> sparse.spmatrix: ) -> sparse.sparray:
""" 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
@ -424,10 +459,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.spmatrix: def shift_rot(axis: int, polarity: int) -> sparse.sparray:
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.spmatrix: def shift_mir(axis: int, polarity: int) -> sparse.sparray:
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
@ -436,7 +471,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(numpy.prod(shape)) # shifted minus original r = shift(axis, polarity) - sparse.eye_array(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))
@ -447,5 +482,4 @@ 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(jmask.astype(int)) @ full return sparse.diags_array(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 typing import Sequence, Callable from collections.abc import Sequence, Callable
import numpy import numpy
from numpy.typing import NDArray from numpy.typing import NDArray
@ -128,6 +128,11 @@ 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]
@ -153,9 +158,6 @@ 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)
if thickness == 0:
slc = slice(None)
else:
slc = slice(-thickness, None) 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

View file

@ -2,15 +2,16 @@
Solvers and solver interface for FDFD problems. Solvers and solver interface for FDFD problems.
""" """
from typing import Callable, Dict, Any, Optional from typing import Any
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 # type: ignore import scipy.sparse.linalg
from ..fdmath import dx_lists_t, vfdfield_t, vcfdfield_t from ..fdmath import dx_lists_t, vfdfield, vcfdfield, vcfdfield_t
from . import operators from . import operators
@ -18,7 +19,7 @@ logger = logging.getLogger(__name__)
def _scipy_qmr( def _scipy_qmr(
A: scipy.sparse.csr_matrix, A: scipy.sparse.csr_array,
b: ArrayLike, b: ArrayLike,
**kwargs: Any, **kwargs: Any,
) -> NDArray[numpy.float64]: ) -> NDArray[numpy.float64]:
@ -34,31 +35,32 @@ 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) cur_norm = norm(A @ xk - b) / norm(b)
logger.info(f'Solver residual at iteration {ii} : {cur_norm}') 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)
kwargs['callback'](xk) 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
@ -66,14 +68,16 @@ def _scipy_qmr(
def generic( def generic(
omega: complex, omega: complex,
dxes: dx_lists_t, dxes: dx_lists_t,
J: vcfdfield_t, J: vcfdfield,
epsilon: vfdfield_t, epsilon: vfdfield,
mu: Optional[vfdfield_t] = None, mu: vfdfield | None = None,
pec: Optional[vfdfield_t] = None, *,
pmc: Optional[vfdfield_t] = None, pec: vfdfield | None = 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: Optional[Dict[str, Any]] = None, matrix_solver_opts: dict[str, Any] | None = 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.
@ -93,13 +97,15 @@ 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_matrix`; where `A`: `scipy.sparse.csr_array`;
`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.
@ -114,17 +120,24 @@ def generic(
Pl, Pr = operators.e_full_preconditioners(dxes) Pl, Pr = operators.e_full_preconditioners(dxes)
if adjoint: if adjoint:
A = (Pl @ A0 @ Pr).H A = (Pl @ A0 @ Pr).T.conjugate()
b = Pr.H @ b0 b = Pr.T.conjugate() @ 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.H @ x x0 = Pl.T.conjugate() @ x
else: else:
x0 = Pr @ x x0 = Pr @ x
return x0 return vcfdfield_t(x0)

View file

@ -18,8 +18,8 @@ $$
\begin{aligned} \begin{aligned}
\nabla \times \vec{E}(x, y, z) &= -\imath \omega \mu \vec{H} \\ \nabla \times \vec{E}(x, y, z) &= -\imath \omega \mu \vec{H} \\
\nabla \times \vec{H}(x, y, z) &= \imath \omega \epsilon \vec{E} \\ \nabla \times \vec{H}(x, y, z) &= \imath \omega \epsilon \vec{E} \\
\vec{E}(x,y,z) &= (\vec{E}_t(x, y) + E_z(x, y)\vec{z}) e^{-\gamma z} \\ \vec{E}(x,y,z) &= (\vec{E}_t(x, y) + E_z(x, y)\vec{z}) e^{-\imath \beta z} \\
\vec{H}(x,y,z) &= (\vec{H}_t(x, y) + H_z(x, y)\vec{z}) e^{-\gamma z} \\ \vec{H}(x,y,z) &= (\vec{H}_t(x, y) + H_z(x, y)\vec{z}) e^{-\imath \beta z} \\
\end{aligned} \end{aligned}
$$ $$
@ -40,56 +40,57 @@ Substituting in our expressions for $\vec{E}$, $\vec{H}$ and discretizing:
$$ $$
\begin{aligned} \begin{aligned}
-\imath \omega \mu_{xx} H_x &= \tilde{\partial}_y E_z + \gamma E_y \\ -\imath \omega \mu_{xx} H_x &= \tilde{\partial}_y E_z + \imath \beta E_y \\
-\imath \omega \mu_{yy} H_y &= -\gamma E_x - \tilde{\partial}_x E_z \\ -\imath \omega \mu_{yy} H_y &= -\imath \beta E_x - \tilde{\partial}_x E_z \\
-\imath \omega \mu_{zz} H_z &= \tilde{\partial}_x E_y - \tilde{\partial}_y E_x \\ -\imath \omega \mu_{zz} H_z &= \tilde{\partial}_x E_y - \tilde{\partial}_y E_x \\
\imath \omega \epsilon_{xx} E_x &= \hat{\partial}_y H_z + \gamma H_y \\ \imath \omega \epsilon_{xx} E_x &= \hat{\partial}_y H_z + \imath \beta H_y \\
\imath \omega \epsilon_{yy} E_y &= -\gamma H_x - \hat{\partial}_x H_z \\ \imath \omega \epsilon_{yy} E_y &= -\imath \beta H_x - \hat{\partial}_x H_z \\
\imath \omega \epsilon_{zz} E_z &= \hat{\partial}_x H_y - \hat{\partial}_y H_x \\ \imath \omega \epsilon_{zz} E_z &= \hat{\partial}_x H_y - \hat{\partial}_y H_x \\
\end{aligned} \end{aligned}
$$ $$
Rewrite the last three equations as Rewrite the last three equations as
$$ $$
\begin{aligned} \begin{aligned}
\gamma H_y &= \imath \omega \epsilon_{xx} E_x - \hat{\partial}_y H_z \\ \imath \beta H_y &= \imath \omega \epsilon_{xx} E_x - \hat{\partial}_y H_z \\
\gamma H_x &= -\imath \omega \epsilon_{yy} E_y - \hat{\partial}_x H_z \\ \imath \beta H_x &= -\imath \omega \epsilon_{yy} E_y - \hat{\partial}_x H_z \\
\imath \omega E_z &= \frac{1}{\epsilon_{zz}} \hat{\partial}_x H_y - \frac{1}{\epsilon_{zz}} \hat{\partial}_y H_x \\ \imath \omega E_z &= \frac{1}{\epsilon_{zz}} \hat{\partial}_x H_y - \frac{1}{\epsilon_{zz}} \hat{\partial}_y H_x \\
\end{aligned} \end{aligned}
$$ $$
Now apply $\gamma \tilde{\partial}_x$ to the last equation, Now apply $\imath \beta \tilde{\partial}_x$ to the last equation,
then substitute in for $\gamma H_x$ and $\gamma H_y$: then substitute in for $\imath \beta H_x$ and $\imath \beta H_y$:
$$ $$
\begin{aligned} \begin{aligned}
\gamma \tilde{\partial}_x \imath \omega E_z &= \gamma \tilde{\partial}_x \frac{1}{\epsilon_{zz}} \hat{\partial}_x H_y \imath \beta \tilde{\partial}_x \imath \omega E_z &= \imath \beta \tilde{\partial}_x \frac{1}{\epsilon_{zz}} \hat{\partial}_x H_y
- \gamma \tilde{\partial}_x \frac{1}{\epsilon_{zz}} \hat{\partial}_y H_x \\ - \imath \beta \tilde{\partial}_x \frac{1}{\epsilon_{zz}} \hat{\partial}_y H_x \\
&= \tilde{\partial}_x \frac{1}{\epsilon_{zz}} \hat{\partial}_x ( \imath \omega \epsilon_{xx} E_x - \hat{\partial}_y H_z) &= \tilde{\partial}_x \frac{1}{\epsilon_{zz}} \hat{\partial}_x ( \imath \omega \epsilon_{xx} E_x - \hat{\partial}_y H_z)
- \tilde{\partial}_x \frac{1}{\epsilon_{zz}} \hat{\partial}_y (-\imath \omega \epsilon_{yy} E_y - \hat{\partial}_x H_z) \\ - \tilde{\partial}_x \frac{1}{\epsilon_{zz}} \hat{\partial}_y (-\imath \omega \epsilon_{yy} E_y - \hat{\partial}_x H_z) \\
&= \tilde{\partial}_x \frac{1}{\epsilon_{zz}} \hat{\partial}_x ( \imath \omega \epsilon_{xx} E_x) &= \tilde{\partial}_x \frac{1}{\epsilon_{zz}} \hat{\partial}_x ( \imath \omega \epsilon_{xx} E_x)
- \tilde{\partial}_x \frac{1}{\epsilon_{zz}} \hat{\partial}_y (-\imath \omega \epsilon_{yy} E_y) \\ - \tilde{\partial}_x \frac{1}{\epsilon_{zz}} \hat{\partial}_y (-\imath \omega \epsilon_{yy} E_y) \\
\gamma \tilde{\partial}_x E_z &= \tilde{\partial}_x \frac{1}{\epsilon_{zz}} \hat{\partial}_x (\epsilon_{xx} E_x) \imath \beta \tilde{\partial}_x E_z &= \tilde{\partial}_x \frac{1}{\epsilon_{zz}} \hat{\partial}_x (\epsilon_{xx} E_x)
+ \tilde{\partial}_x \frac{1}{\epsilon_{zz}} \hat{\partial}_y (\epsilon_{yy} E_y) \\ + \tilde{\partial}_x \frac{1}{\epsilon_{zz}} \hat{\partial}_y (\epsilon_{yy} E_y) \\
\end{aligned} \end{aligned}
$$ $$
With a similar approach (but using $\gamma \tilde{\partial}_y$ instead), we can get With a similar approach (but using $\imath \beta \tilde{\partial}_y$ instead), we can get
$$ $$
\begin{aligned} \begin{aligned}
\gamma \tilde{\partial}_y E_z &= \tilde{\partial}_y \frac{1}{\epsilon_{zz}} \hat{\partial}_x (\epsilon_{xx} E_x) \imath \beta \tilde{\partial}_y E_z &= \tilde{\partial}_y \frac{1}{\epsilon_{zz}} \hat{\partial}_x (\epsilon_{xx} E_x)
+ \tilde{\partial}_y \frac{1}{\epsilon_{zz}} \hat{\partial}_y (\epsilon_{yy} E_y) \\ + \tilde{\partial}_y \frac{1}{\epsilon_{zz}} \hat{\partial}_y (\epsilon_{yy} E_y) \\
\end{aligned} \end{aligned}
$$ $$
We can combine this equation for $\gamma \tilde{\partial}_y E_z$ with We can combine this equation for $\imath \beta \tilde{\partial}_y E_z$ with
the unused $\imath \omega \mu_{xx} H_x$ and $\imath \omega \mu_{yy} H_y$ equations to get the unused $\imath \omega \mu_{xx} H_x$ and $\imath \omega \mu_{yy} H_y$ equations to get
$$ $$
\begin{aligned} \begin{aligned}
-\imath \omega \mu_{xx} \gamma H_x &= \gamma^2 E_y + \gamma \tilde{\partial}_y E_z \\ -\imath \omega \mu_{xx} \imath \beta H_x &= -\beta^2 E_y + \imath \beta \tilde{\partial}_y E_z \\
-\imath \omega \mu_{xx} \gamma H_x &= \gamma^2 E_y + \tilde{\partial}_y ( -\imath \omega \mu_{xx} \imath \beta H_x &= -\beta^2 E_y + \tilde{\partial}_y (
\frac{1}{\epsilon_{zz}} \hat{\partial}_x (\epsilon_{xx} E_x) \frac{1}{\epsilon_{zz}} \hat{\partial}_x (\epsilon_{xx} E_x)
+ \frac{1}{\epsilon_{zz}} \hat{\partial}_y (\epsilon_{yy} E_y) + \frac{1}{\epsilon_{zz}} \hat{\partial}_y (\epsilon_{yy} E_y)
)\\ )\\
@ -100,22 +101,21 @@ and
$$ $$
\begin{aligned} \begin{aligned}
-\imath \omega \mu_{yy} \gamma H_y &= -\gamma^2 E_x - \gamma \tilde{\partial}_x E_z \\ -\imath \omega \mu_{yy} \imath \beta H_y &= \beta^2 E_x - \imath \beta \tilde{\partial}_x E_z \\
-\imath \omega \mu_{yy} \gamma H_y &= -\gamma^2 E_x - \tilde{\partial}_x ( -\imath \omega \mu_{yy} \imath \beta H_y &= \beta^2 E_x - \tilde{\partial}_x (
\frac{1}{\epsilon_{zz}} \hat{\partial}_x (\epsilon_{xx} E_x) \frac{1}{\epsilon_{zz}} \hat{\partial}_x (\epsilon_{xx} E_x)
+ \frac{1}{\epsilon_{zz}} \hat{\partial}_y (\epsilon_{yy} E_y) + \frac{1}{\epsilon_{zz}} \hat{\partial}_y (\epsilon_{yy} E_y)
)\\ )\\
\end{aligned} \end{aligned}
$$ $$
However, based on our rewritten equation for $\gamma H_x$ and the so-far unused However, based on our rewritten equation for $\imath \beta H_x$ and the so-far unused
equation for $\imath \omega \mu_{zz} H_z$ we can also write equation for $\imath \omega \mu_{zz} H_z$ we can also write
$$ $$
\begin{aligned} \begin{aligned}
-\imath \omega \mu_{xx} (\gamma H_x) &= -\imath \omega \mu_{xx} (-\imath \omega \epsilon_{yy} E_y - \hat{\partial}_x H_z) \\ -\imath \omega \mu_{xx} (\imath \beta H_x) &= -\imath \omega \mu_{xx} (-\imath \omega \epsilon_{yy} E_y - \hat{\partial}_x H_z) \\
&= -\omega^2 \mu_{xx} \epsilon_{yy} E_y &= -\omega^2 \mu_{xx} \epsilon_{yy} E_y + \imath \omega \mu_{xx} \hat{\partial}_x (
+\imath \omega \mu_{xx} \hat{\partial}_x (
\frac{1}{-\imath \omega \mu_{zz}} (\tilde{\partial}_x E_y - \tilde{\partial}_y E_x)) \\ \frac{1}{-\imath \omega \mu_{zz}} (\tilde{\partial}_x E_y - \tilde{\partial}_y E_x)) \\
&= -\omega^2 \mu_{xx} \epsilon_{yy} E_y &= -\omega^2 \mu_{xx} \epsilon_{yy} E_y
-\mu_{xx} \hat{\partial}_x \frac{1}{\mu_{zz}} (\tilde{\partial}_x E_y - \tilde{\partial}_y E_x) \\ -\mu_{xx} \hat{\partial}_x \frac{1}{\mu_{zz}} (\tilde{\partial}_x E_y - \tilde{\partial}_y E_x) \\
@ -126,7 +126,7 @@ and, similarly,
$$ $$
\begin{aligned} \begin{aligned}
-\imath \omega \mu_{yy} (\gamma H_y) &= \omega^2 \mu_{yy} \epsilon_{xx} E_x -\imath \omega \mu_{yy} (\imath \beta H_y) &= \omega^2 \mu_{yy} \epsilon_{xx} E_x
+\mu_{yy} \hat{\partial}_y \frac{1}{\mu_{zz}} (\tilde{\partial}_x E_y - \tilde{\partial}_y E_x) \\ +\mu_{yy} \hat{\partial}_y \frac{1}{\mu_{zz}} (\tilde{\partial}_x E_y - \tilde{\partial}_y E_x) \\
\end{aligned} \end{aligned}
$$ $$
@ -135,12 +135,12 @@ By combining both pairs of expressions, we get
$$ $$
\begin{aligned} \begin{aligned}
-\gamma^2 E_x - \tilde{\partial}_x ( \beta^2 E_x - \tilde{\partial}_x (
\frac{1}{\epsilon_{zz}} \hat{\partial}_x (\epsilon_{xx} E_x) \frac{1}{\epsilon_{zz}} \hat{\partial}_x (\epsilon_{xx} E_x)
+ \frac{1}{\epsilon_{zz}} \hat{\partial}_y (\epsilon_{yy} E_y) + \frac{1}{\epsilon_{zz}} \hat{\partial}_y (\epsilon_{yy} E_y)
) &= \omega^2 \mu_{yy} \epsilon_{xx} E_x ) &= \omega^2 \mu_{yy} \epsilon_{xx} E_x
+\mu_{yy} \hat{\partial}_y \frac{1}{\mu_{zz}} (\tilde{\partial}_x E_y - \tilde{\partial}_y E_x) \\ +\mu_{yy} \hat{\partial}_y \frac{1}{\mu_{zz}} (\tilde{\partial}_x E_y - \tilde{\partial}_y E_x) \\
\gamma^2 E_y + \tilde{\partial}_y ( -\beta^2 E_y + \tilde{\partial}_y (
\frac{1}{\epsilon_{zz}} \hat{\partial}_x (\epsilon_{xx} E_x) \frac{1}{\epsilon_{zz}} \hat{\partial}_x (\epsilon_{xx} E_x)
+ \frac{1}{\epsilon_{zz}} \hat{\partial}_y (\epsilon_{yy} E_y) + \frac{1}{\epsilon_{zz}} \hat{\partial}_y (\epsilon_{yy} E_y)
) &= -\omega^2 \mu_{xx} \epsilon_{yy} E_y ) &= -\omega^2 \mu_{xx} \epsilon_{yy} E_y
@ -165,27 +165,25 @@ $$
E_y \end{bmatrix} E_y \end{bmatrix}
$$ $$
where $\gamma = \imath\beta$. In the literature, $\beta$ is usually used to denote In the literature, $\beta$ is usually used to denote the lossless/real part of the propagation constant,
the lossless/real part of the propagation constant, but in `meanas` it is allowed to but in `meanas` it is allowed to be complex.
be complex.
An equivalent eigenvalue problem can be formed using the $H_x$ and $H_y$ fields, if those are more convenient. An equivalent eigenvalue problem can be formed using the $H_x$ and $H_y$ fields, if those are more convenient.
Note that $E_z$ was never discretized, so $\gamma$ and $\beta$ will need adjustment Note that $E_z$ was never discretized, so $\beta$ will need adjustment to account for numerical dispersion
to account for numerical dispersion if the result is introduced into a space with a discretized z-axis. if the result is introduced into a space with a discretized z-axis.
""" """
# TODO update module docs
from typing import Any from typing import Any
from collections.abc import Sequence
import numpy import numpy
from numpy.typing import NDArray, ArrayLike from numpy.typing import NDArray
from numpy.linalg import norm from numpy.linalg import norm
import scipy.sparse as sparse # type: ignore from scipy import sparse
from ..fdmath.operators import deriv_forward, deriv_back, cross from ..fdmath.operators import deriv_forward, deriv_back, cross
from ..fdmath import vec, unvec, dx_lists_t, vfdfield_t, vcfdfield_t from ..fdmath import vec, unvec, dx_lists2_t, vcfdfield2_t, vcfdslice_t, vcfdfield2, vfdslice, vcfdslice
from ..eigensolvers import signed_eigensolve, rayleigh_quotient_iteration from ..eigensolvers import signed_eigensolve, rayleigh_quotient_iteration
@ -194,10 +192,10 @@ __author__ = 'Jan Petykiewicz'
def operator_e( def operator_e(
omega: complex, omega: complex,
dxes: dx_lists_t, dxes: dx_lists2_t,
epsilon: vfdfield_t, epsilon: vfdslice,
mu: vfdfield_t | None = None, mu: vfdslice | None = None,
) -> sparse.spmatrix: ) -> sparse.sparray:
r""" r"""
Waveguide operator of the form Waveguide operator of the form
@ -246,12 +244,12 @@ def operator_e(
Dbx, Dby = deriv_back(dxes[1]) Dbx, Dby = deriv_back(dxes[1])
eps_parts = numpy.split(epsilon, 3) eps_parts = numpy.split(epsilon, 3)
eps_xy = sparse.diags(numpy.hstack((eps_parts[0], eps_parts[1]))) eps_xy = sparse.diags_array(numpy.hstack((eps_parts[0], eps_parts[1])))
eps_z_inv = sparse.diags(1 / eps_parts[2]) eps_z_inv = sparse.diags_array(1 / eps_parts[2])
mu_parts = numpy.split(mu, 3) mu_parts = numpy.split(mu, 3)
mu_yx = sparse.diags(numpy.hstack((mu_parts[1], mu_parts[0]))) mu_yx = sparse.diags_array(numpy.hstack((mu_parts[1], mu_parts[0])))
mu_z_inv = sparse.diags(1 / mu_parts[2]) mu_z_inv = sparse.diags_array(1 / mu_parts[2])
op = ( op = (
omega * omega * mu_yx @ eps_xy omega * omega * mu_yx @ eps_xy
@ -263,10 +261,10 @@ def operator_e(
def operator_h( def operator_h(
omega: complex, omega: complex,
dxes: dx_lists_t, dxes: dx_lists2_t,
epsilon: vfdfield_t, epsilon: vfdslice,
mu: vfdfield_t | None = None, mu: vfdslice | None = None,
) -> sparse.spmatrix: ) -> sparse.sparray:
r""" r"""
Waveguide operator of the form Waveguide operator of the form
@ -315,12 +313,12 @@ def operator_h(
Dbx, Dby = deriv_back(dxes[1]) Dbx, Dby = deriv_back(dxes[1])
eps_parts = numpy.split(epsilon, 3) eps_parts = numpy.split(epsilon, 3)
eps_yx = sparse.diags(numpy.hstack((eps_parts[1], eps_parts[0]))) eps_yx = sparse.diags_array(numpy.hstack((eps_parts[1], eps_parts[0])))
eps_z_inv = sparse.diags(1 / eps_parts[2]) eps_z_inv = sparse.diags_array(1 / eps_parts[2])
mu_parts = numpy.split(mu, 3) mu_parts = numpy.split(mu, 3)
mu_xy = sparse.diags(numpy.hstack((mu_parts[0], mu_parts[1]))) mu_xy = sparse.diags_array(numpy.hstack((mu_parts[0], mu_parts[1])))
mu_z_inv = sparse.diags(1 / mu_parts[2]) mu_z_inv = sparse.diags_array(1 / mu_parts[2])
op = ( op = (
omega * omega * eps_yx @ mu_xy omega * omega * eps_yx @ mu_xy
@ -331,15 +329,15 @@ def operator_h(
def normalized_fields_e( def normalized_fields_e(
e_xy: ArrayLike, e_xy: vcfdfield2,
wavenumber: complex, wavenumber: complex,
omega: complex, omega: complex,
dxes: dx_lists_t, dxes: dx_lists2_t,
epsilon: vfdfield_t, epsilon: vfdslice,
mu: vfdfield_t | None = None, mu: vfdslice | None = None,
prop_phase: float = 0, prop_phase: float = 0,
) -> tuple[vcfdfield_t, vcfdfield_t]: ) -> tuple[vcfdslice_t, vcfdslice_t]:
""" r"""
Given a vector `e_xy` containing the vectorized E_x and E_y fields, Given a vector `e_xy` containing the vectorized E_x and E_y fields,
returns normalized, vectorized E and H fields for the system. returns normalized, vectorized E and H fields for the system.
@ -357,24 +355,40 @@ def normalized_fields_e(
Returns: Returns:
`(e, h)`, where each field is vectorized, normalized, `(e, h)`, where each field is vectorized, normalized,
and contains all three vector components. and contains all three vector components.
Notes:
`e_xy` is only the transverse electric eigenvector. This helper first
reconstructs the full three-component `E` and `H` fields with `exy2e(...)`
and `exy2h(...)`, then normalizes them to unit forward power using
`_normalized_fields(...)`.
The normalization target is
$$
\Re\left[\mathrm{inner\_product}(e, h, \mathrm{conj\_h}=True)\right] = 1,
$$
so the returned fields represent a unit-power forward mode under the
discrete Yee-grid Poynting inner product.
""" """
e = exy2e(wavenumber=wavenumber, dxes=dxes, epsilon=epsilon) @ e_xy e = exy2e(wavenumber=wavenumber, dxes=dxes, epsilon=epsilon) @ e_xy
h = exy2h(wavenumber=wavenumber, omega=omega, dxes=dxes, epsilon=epsilon, mu=mu) @ e_xy h = exy2h(wavenumber=wavenumber, omega=omega, dxes=dxes, epsilon=epsilon, mu=mu) @ e_xy
e_norm, h_norm = _normalized_fields(e=e, h=h, omega=omega, dxes=dxes, epsilon=epsilon, e_norm, h_norm = _normalized_fields(
mu=mu, prop_phase=prop_phase) e=e, h=h, dxes=dxes, epsilon=epsilon, prop_phase=prop_phase,
)
return e_norm, h_norm return e_norm, h_norm
def normalized_fields_h( def normalized_fields_h(
h_xy: ArrayLike, h_xy: vcfdfield2,
wavenumber: complex, wavenumber: complex,
omega: complex, omega: complex,
dxes: dx_lists_t, dxes: dx_lists2_t,
epsilon: vfdfield_t, epsilon: vfdslice,
mu: vfdfield_t | None = None, mu: vfdslice | None = None,
prop_phase: float = 0, prop_phase: float = 0,
) -> tuple[vcfdfield_t, vcfdfield_t]: ) -> tuple[vcfdslice_t, vcfdslice_t]:
""" r"""
Given a vector `h_xy` containing the vectorized H_x and H_y fields, Given a vector `h_xy` containing the vectorized H_x and H_y fields,
returns normalized, vectorized E and H fields for the system. returns normalized, vectorized E and H fields for the system.
@ -392,39 +406,55 @@ def normalized_fields_h(
Returns: Returns:
`(e, h)`, where each field is vectorized, normalized, `(e, h)`, where each field is vectorized, normalized,
and contains all three vector components. and contains all three vector components.
Notes:
This is the `H_x/H_y` analogue of `normalized_fields_e(...)`. The final
normalized mode should describe the same physical solution, but because
the overall complex phase and sign are chosen heuristically,
`normalized_fields_e(...)` and `normalized_fields_h(...)` need not return
identical representatives for nearly symmetric modes.
""" """
e = hxy2e(wavenumber=wavenumber, omega=omega, dxes=dxes, epsilon=epsilon, mu=mu) @ h_xy e = hxy2e(wavenumber=wavenumber, omega=omega, dxes=dxes, epsilon=epsilon, mu=mu) @ h_xy
h = hxy2h(wavenumber=wavenumber, dxes=dxes, mu=mu) @ h_xy h = hxy2h(wavenumber=wavenumber, dxes=dxes, mu=mu) @ h_xy
e_norm, h_norm = _normalized_fields(e=e, h=h, omega=omega, dxes=dxes, epsilon=epsilon, e_norm, h_norm = _normalized_fields(
mu=mu, prop_phase=prop_phase) e=e, h=h, dxes=dxes, epsilon=epsilon, prop_phase=prop_phase,
)
return e_norm, h_norm return e_norm, h_norm
def _normalized_fields( def _normalized_fields(
e: vcfdfield_t, e: vcfdslice,
h: vcfdfield_t, h: vcfdslice,
omega: complex, dxes: dx_lists2_t,
dxes: dx_lists_t, epsilon: vfdslice,
epsilon: vfdfield_t,
mu: vfdfield_t | None = None,
prop_phase: float = 0, prop_phase: float = 0,
) -> tuple[vcfdfield_t, vcfdfield_t]: ) -> tuple[vcfdslice_t, vcfdslice_t]:
# TODO documentation r"""
shape = [s.size for s in dxes[0]] Normalize a reconstructed waveguide mode to unit forward power.
dxes_real = [[numpy.real(d) for d in numpy.meshgrid(*dxes[v], indexing='ij')] for v in (0, 1)]
E = unvec(e, shape) The eigenproblem solved by `solve_mode(s)` determines only the mode shape and
H = unvec(h, shape) propagation constant. The overall complex amplitude and sign are still free.
This helper fixes those remaining degrees of freedom in two steps:
1. Compute the discrete longitudinal Poynting flux with
`inner_product(e, h, conj_h=True)`, including the half-cell longitudinal
phase adjustment controlled by `prop_phase`.
2. Multiply both fields by a scalar chosen so that the real forward power is
`1`, then choose a reproducible phase/sign representative by making a
dominant-energy sample real and using a weighted quadrant sum to break
mirror-symmetry ties.
The sign heuristic is intentionally pragmatic rather than fundamental: it is
only there to make downstream tests and source/overlap construction choose a
consistent representative when the physical mode is symmetric.
"""
shape = [s.size for s in dxes[0]]
# Find time-averaged Sz and normalize to it # 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 = inner_product(e, h, dxes=dxes, prop_phase=prop_phase, conj_h=True).real
phase = numpy.exp(-1j * -prop_phase / 2)
Sz_a = E[0] * numpy.conj(H[1] * phase) * dxes_real[0][1] * dxes_real[1][0]
Sz_b = E[1] * numpy.conj(H[0] * phase) * dxes_real[0][0] * dxes_real[1][1]
Sz_tavg = numpy.real(Sz_a.sum() - Sz_b.sum()) * 0.5 # 0.5 since E, H are assumed to be peak (not RMS) amplitudes
assert Sz_tavg > 0, f'Found a mode propagating in the wrong direction! {Sz_tavg=}' assert Sz_tavg > 0, f'Found a mode propagating in the wrong direction! {Sz_tavg=}'
energy = epsilon * e.conj() * e energy = numpy.real(epsilon * e.conj() * e)
norm_amplitude = 1 / numpy.sqrt(Sz_tavg) norm_amplitude = 1 / numpy.sqrt(Sz_tavg)
norm_angle = -numpy.angle(e[energy.argmax()]) # Will randomly add a negative sign when mode is symmetric norm_angle = -numpy.angle(e[energy.argmax()]) # Will randomly add a negative sign when mode is symmetric
@ -434,22 +464,23 @@ def _normalized_fields(
sign = numpy.sign(E_weighted[:, sign = numpy.sign(E_weighted[:,
:max(shape[0] // 2, 1), :max(shape[0] // 2, 1),
:max(shape[1] // 2, 1)].real.sum()) :max(shape[1] // 2, 1)].real.sum())
assert sign != 0
norm_factor = sign * norm_amplitude * numpy.exp(1j * norm_angle) norm_factor = sign * norm_amplitude * numpy.exp(1j * norm_angle)
e *= norm_factor e *= norm_factor
h *= norm_factor h *= norm_factor
return e, h return vcfdslice_t(e), vcfdslice_t(h)
def exy2h( def exy2h(
wavenumber: complex, wavenumber: complex,
omega: complex, omega: complex,
dxes: dx_lists_t, dxes: dx_lists2_t,
epsilon: vfdfield_t, epsilon: vfdslice,
mu: vfdfield_t | None = None mu: vfdslice | None = None
) -> sparse.spmatrix: ) -> sparse.sparray:
""" """
Operator which transforms the vector `e_xy` containing the vectorized E_x and E_y fields, Operator which transforms the vector `e_xy` containing the vectorized E_x and E_y fields,
into a vectorized H containing all three H components into a vectorized H containing all three H components
@ -472,10 +503,10 @@ def exy2h(
def hxy2e( def hxy2e(
wavenumber: complex, wavenumber: complex,
omega: complex, omega: complex,
dxes: dx_lists_t, dxes: dx_lists2_t,
epsilon: vfdfield_t, epsilon: vfdslice,
mu: vfdfield_t | None = None mu: vfdslice | None = None
) -> sparse.spmatrix: ) -> sparse.sparray:
""" """
Operator which transforms the vector `h_xy` containing the vectorized H_x and H_y fields, Operator which transforms the vector `h_xy` containing the vectorized H_x and H_y fields,
into a vectorized E containing all three E components into a vectorized E containing all three E components
@ -497,9 +528,9 @@ def hxy2e(
def hxy2h( def hxy2h(
wavenumber: complex, wavenumber: complex,
dxes: dx_lists_t, dxes: dx_lists2_t,
mu: vfdfield_t | None = None mu: vfdslice | None = None
) -> sparse.spmatrix: ) -> sparse.sparray:
""" """
Operator which transforms the vector `h_xy` containing the vectorized H_x and H_y fields, Operator which transforms the vector `h_xy` containing the vectorized H_x and H_y fields,
into a vectorized H containing all three H components into a vectorized H containing all three H components
@ -518,26 +549,53 @@ def hxy2h(
if mu is not None: if mu is not None:
mu_parts = numpy.split(mu, 3) mu_parts = numpy.split(mu, 3)
mu_xy = sparse.diags(numpy.hstack((mu_parts[0], mu_parts[1]))) mu_xy = sparse.diags_array(numpy.hstack((mu_parts[0], mu_parts[1])))
mu_z_inv = sparse.diags(1 / mu_parts[2]) mu_z_inv = sparse.diags_array(1 / mu_parts[2])
hxy2hz = mu_z_inv @ hxy2hz @ mu_xy hxy2hz = mu_z_inv @ hxy2hz @ mu_xy
n_pts = dxes[1][0].size * dxes[1][1].size n_pts = dxes[1][0].size * dxes[1][1].size
op = sparse.vstack((sparse.eye(2 * n_pts), op = sparse.vstack((sparse.eye_array(2 * n_pts),
hxy2hz)) hxy2hz))
return op return op
def exy2e( def exy2e(
wavenumber: complex, wavenumber: complex,
dxes: dx_lists_t, dxes: dx_lists2_t,
epsilon: vfdfield_t, epsilon: vfdslice,
) -> sparse.spmatrix: ) -> sparse.sparray:
""" r"""
Operator which transforms the vector `e_xy` containing the vectorized E_x and E_y fields, Operator which transforms the vector `e_xy` containing the vectorized E_x and E_y fields,
into a vectorized E containing all three E components into a vectorized E containing all three E components
From the operator derivation (see module docs), we have
$$
\imath \omega \epsilon_{zz} E_z = \hat{\partial}_x H_y - \hat{\partial}_y H_x \\
$$
as well as the intermediate equations
$$
\begin{aligned}
\imath \beta H_y &= \imath \omega \epsilon_{xx} E_x - \hat{\partial}_y H_z \\
\imath \beta H_x &= -\imath \omega \epsilon_{yy} E_y - \hat{\partial}_x H_z \\
\end{aligned}
$$
Combining these, we get
$$
\begin{aligned}
E_z &= \frac{1}{- \omega \beta \epsilon_{zz}} ((
\hat{\partial}_y \hat{\partial}_x H_z
-\hat{\partial}_x \hat{\partial}_y H_z)
+ \imath \omega (\hat{\partial}_x \epsilon_{xx} E_x + \hat{\partial}_y \epsilon{yy} E_y))
&= \frac{1}{\imath \beta \epsilon_{zz}} (\hat{\partial}_x \epsilon_{xx} E_x + \hat{\partial}_y \epsilon{yy} E_y)
\end{aligned}
$$
Args: Args:
wavenumber: Wavenumber assuming fields have z-dependence of `exp(-i * wavenumber * z)` wavenumber: Wavenumber assuming fields have z-dependence of `exp(-i * wavenumber * z)`
It should satisfy `operator_e() @ e_xy == wavenumber**2 * e_xy` It should satisfy `operator_e() @ e_xy == wavenumber**2 * e_xy`
@ -552,13 +610,13 @@ def exy2e(
if epsilon is not None: if epsilon is not None:
epsilon_parts = numpy.split(epsilon, 3) epsilon_parts = numpy.split(epsilon, 3)
epsilon_xy = sparse.diags(numpy.hstack((epsilon_parts[0], epsilon_parts[1]))) epsilon_xy = sparse.diags_array(numpy.hstack((epsilon_parts[0], epsilon_parts[1])))
epsilon_z_inv = sparse.diags(1 / epsilon_parts[2]) epsilon_z_inv = sparse.diags_array(1 / epsilon_parts[2])
exy2ez = epsilon_z_inv @ exy2ez @ epsilon_xy exy2ez = epsilon_z_inv @ exy2ez @ epsilon_xy
n_pts = dxes[0][0].size * dxes[0][1].size n_pts = dxes[0][0].size * dxes[0][1].size
op = sparse.vstack((sparse.eye(2 * n_pts), op = sparse.vstack((sparse.eye_array(2 * n_pts),
exy2ez)) exy2ez))
return op return op
@ -566,12 +624,12 @@ def exy2e(
def e2h( def e2h(
wavenumber: complex, wavenumber: complex,
omega: complex, omega: complex,
dxes: dx_lists_t, dxes: dx_lists2_t,
mu: vfdfield_t | None = None mu: vfdslice | None = None
) -> sparse.spmatrix: ) -> sparse.sparray:
""" """
Returns an operator which, when applied to a vectorized E eigenfield, produces Returns an operator which, when applied to a vectorized E eigenfield, produces
the vectorized H eigenfield. the vectorized H eigenfield slice.
Args: Args:
wavenumber: Wavenumber assuming fields have z-dependence of `exp(-i * wavenumber * z)` wavenumber: Wavenumber assuming fields have z-dependence of `exp(-i * wavenumber * z)`
@ -584,19 +642,19 @@ def e2h(
""" """
op = curl_e(wavenumber, dxes) / (-1j * omega) op = curl_e(wavenumber, dxes) / (-1j * omega)
if mu is not None: if mu is not None:
op = sparse.diags(1 / mu) @ op op = sparse.diags_array(1 / mu) @ op
return op return op
def h2e( def h2e(
wavenumber: complex, wavenumber: complex,
omega: complex, omega: complex,
dxes: dx_lists_t, dxes: dx_lists2_t,
epsilon: vfdfield_t epsilon: vfdslice,
) -> sparse.spmatrix: ) -> sparse.sparray:
""" """
Returns an operator which, when applied to a vectorized H eigenfield, produces Returns an operator which, when applied to a vectorized H eigenfield, produces
the vectorized E eigenfield. the vectorized E eigenfield slice.
Args: Args:
wavenumber: Wavenumber assuming fields have z-dependence of `exp(-i * wavenumber * z)` wavenumber: Wavenumber assuming fields have z-dependence of `exp(-i * wavenumber * z)`
@ -607,13 +665,13 @@ def h2e(
Returns: Returns:
Sparse matrix representation of the operator. Sparse matrix representation of the operator.
""" """
op = sparse.diags(1 / (1j * omega * epsilon)) @ curl_h(wavenumber, dxes) op = sparse.diags_array(1 / (1j * omega * epsilon)) @ curl_h(wavenumber, dxes)
return op return op
def curl_e(wavenumber: complex, dxes: dx_lists_t) -> sparse.spmatrix: def curl_e(wavenumber: complex, dxes: dx_lists2_t) -> sparse.sparray:
""" """
Discretized curl operator for use with the waveguide E field. Discretized curl operator for use with the waveguide E field slice.
Args: Args:
wavenumber: Wavenumber assuming fields have z-dependence of `exp(-i * wavenumber * z)` wavenumber: Wavenumber assuming fields have z-dependence of `exp(-i * wavenumber * z)`
@ -622,18 +680,18 @@ def curl_e(wavenumber: complex, dxes: dx_lists_t) -> sparse.spmatrix:
Returns: Returns:
Sparse matrix representation of the operator. Sparse matrix representation of the operator.
""" """
n = 1 nn = 1
for d in dxes[0]: for dd in dxes[0]:
n *= len(d) nn *= len(dd)
Bz = -1j * wavenumber * sparse.eye(n) Bz = -1j * wavenumber * sparse.eye_array(nn)
Dfx, Dfy = deriv_forward(dxes[0]) Dfx, Dfy = deriv_forward(dxes[0])
return cross([Dfx, Dfy, Bz]) return cross([Dfx, Dfy, Bz])
def curl_h(wavenumber: complex, dxes: dx_lists_t) -> sparse.spmatrix: def curl_h(wavenumber: complex, dxes: dx_lists2_t) -> sparse.sparray:
""" """
Discretized curl operator for use with the waveguide H field. Discretized curl operator for use with the waveguide H field slice.
Args: Args:
wavenumber: Wavenumber assuming fields have z-dependence of `exp(-i * wavenumber * z)` wavenumber: Wavenumber assuming fields have z-dependence of `exp(-i * wavenumber * z)`
@ -642,22 +700,22 @@ def curl_h(wavenumber: complex, dxes: dx_lists_t) -> sparse.spmatrix:
Returns: Returns:
Sparse matrix representation of the operator. Sparse matrix representation of the operator.
""" """
n = 1 nn = 1
for d in dxes[1]: for dd in dxes[1]:
n *= len(d) nn *= len(dd)
Bz = -1j * wavenumber * sparse.eye(n) Bz = -1j * wavenumber * sparse.eye_array(nn)
Dbx, Dby = deriv_back(dxes[1]) Dbx, Dby = deriv_back(dxes[1])
return cross([Dbx, Dby, Bz]) return cross([Dbx, Dby, Bz])
def h_err( def h_err(
h: vcfdfield_t, h: vcfdslice,
wavenumber: complex, wavenumber: complex,
omega: complex, omega: complex,
dxes: dx_lists_t, dxes: dx_lists2_t,
epsilon: vfdfield_t, epsilon: vfdslice,
mu: vfdfield_t | None = None mu: vfdslice | None = None
) -> float: ) -> float:
""" """
Calculates the relative error in the H field Calculates the relative error in the H field
@ -676,7 +734,7 @@ def h_err(
ce = curl_e(wavenumber, dxes) ce = curl_e(wavenumber, dxes)
ch = curl_h(wavenumber, dxes) ch = curl_h(wavenumber, dxes)
eps_inv = sparse.diags(1 / epsilon) eps_inv = sparse.diags_array(1 / epsilon)
if mu is None: if mu is None:
op = ce @ eps_inv @ ch @ h - omega ** 2 * h op = ce @ eps_inv @ ch @ h - omega ** 2 * h
@ -687,12 +745,12 @@ def h_err(
def e_err( def e_err(
e: vcfdfield_t, e: vcfdslice,
wavenumber: complex, wavenumber: complex,
omega: complex, omega: complex,
dxes: dx_lists_t, dxes: dx_lists2_t,
epsilon: vfdfield_t, epsilon: vfdslice,
mu: vfdfield_t | None = None, mu: vfdslice | None = None,
) -> float: ) -> float:
""" """
Calculates the relative error in the E field Calculates the relative error in the E field
@ -714,21 +772,21 @@ def e_err(
if mu is None: if mu is None:
op = ch @ ce @ e - omega ** 2 * (epsilon * e) op = ch @ ce @ e - omega ** 2 * (epsilon * e)
else: else:
mu_inv = sparse.diags(1 / mu) mu_inv = sparse.diags_array(1 / mu)
op = ch @ mu_inv @ ce @ e - omega ** 2 * (epsilon * e) op = ch @ mu_inv @ ce @ e - omega ** 2 * (epsilon * e)
return float(norm(op) / norm(e)) return float(norm(op) / norm(e))
def sensitivity( def sensitivity(
e_norm: vcfdfield_t, e_norm: vcfdslice,
h_norm: vcfdfield_t, h_norm: vcfdslice,
wavenumber: complex, wavenumber: complex,
omega: complex, omega: complex,
dxes: dx_lists_t, dxes: dx_lists2_t,
epsilon: vfdfield_t, epsilon: vfdslice,
mu: vfdfield_t | None = None, mu: vfdslice | None = None,
) -> vcfdfield_t: ) -> vcfdslice_t:
r""" r"""
Given a waveguide structure (`dxes`, `epsilon`, `mu`) and mode fields Given a waveguide structure (`dxes`, `epsilon`, `mu`) and mode fields
(`e_norm`, `h_norm`, `wavenumber`, `omega`), calculates the sensitivity of the wavenumber (`e_norm`, `h_norm`, `wavenumber`, `omega`), calculates the sensitivity of the wavenumber
@ -802,11 +860,11 @@ def sensitivity(
Dbx, Dby = deriv_back(dxes[1]) Dbx, Dby = deriv_back(dxes[1])
eps_x, eps_y, eps_z = numpy.split(epsilon, 3) eps_x, eps_y, eps_z = numpy.split(epsilon, 3)
eps_xy = sparse.diags(numpy.hstack((eps_x, eps_y))) eps_xy = sparse.diags_array(numpy.hstack((eps_x, eps_y)))
eps_z_inv = sparse.diags(1 / eps_z) eps_z_inv = sparse.diags_array(1 / eps_z)
mu_x, mu_y, _mu_z = numpy.split(mu, 3) mu_x, mu_y, _mu_z = numpy.split(mu, 3)
mu_yx = sparse.diags(numpy.hstack((mu_y, mu_x))) mu_yx = sparse.diags_array(numpy.hstack((mu_y, mu_x)))
da_exxhyy = vec(dxes[1][0][:, None] * dxes[0][1][None, :]) da_exxhyy = vec(dxes[1][0][:, None] * dxes[0][1][None, :])
da_eyyhxx = vec(dxes[1][1][None, :] * dxes[0][0][:, None]) da_eyyhxx = vec(dxes[1][1][None, :] * dxes[0][0][:, None])
@ -820,15 +878,15 @@ def sensitivity(
norm = hv_yx_conj @ ev_xy norm = hv_yx_conj @ ev_xy
sens_tot = numpy.concatenate([sens_xy1 + sens_xy2, sens_z]) / (2 * wavenumber * norm) sens_tot = numpy.concatenate([sens_xy1 + sens_xy2, sens_z]) / (2 * wavenumber * norm)
return sens_tot return vcfdslice_t(sens_tot)
def solve_modes( def solve_modes(
mode_numbers: list[int], mode_numbers: Sequence[int],
omega: complex, omega: complex,
dxes: dx_lists_t, dxes: dx_lists2_t,
epsilon: vfdfield_t, epsilon: vfdslice,
mu: vfdfield_t | None = None, mu: vfdslice | None = None,
mode_margin: int = 2, mode_margin: int = 2,
) -> tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]: ) -> tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]:
""" """
@ -845,32 +903,38 @@ def solve_modes(
ability to find the correct mode. Default 2. ability to find the correct mode. Default 2.
Returns: Returns:
e_xys: list of vfdfield_t specifying fields e_xys: NDArray of vfdfield_t specifying fields. First dimension is mode number.
wavenumbers: list of wavenumbers wavenumbers: list of wavenumbers
""" """
''' #
Solve for the largest-magnitude eigenvalue of the real operator # Solve for the largest-magnitude eigenvalue of the real operator
''' #
dxes_real = [[numpy.real(dx) for dx in dxi] for dxi in dxes] dxes_real = [[numpy.real(dx) for dx in dxi] for dxi in dxes]
mu_real = None if mu is None else numpy.real(mu) mu_real = None if mu is None else numpy.real(mu)
A_r = operator_e(numpy.real(omega), dxes_real, numpy.real(epsilon), mu_real) A_r = operator_e(numpy.real(omega), dxes_real, numpy.real(epsilon), mu_real)
eigvals, eigvecs = signed_eigensolve(A_r, max(mode_numbers) + mode_margin) eigvals, eigvecs = signed_eigensolve(A_r, max(mode_numbers) + mode_margin)
e_xys = eigvecs[:, -(numpy.array(mode_numbers) + 1)] 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 # Now solve for the eigenvector of the full operator, using the real operator's
eigenvector as an initial guess for Rayleigh quotient iteration. # eigenvector as an initial guess for Rayleigh quotient iteration.
''' #
A = operator_e(omega, dxes, epsilon, mu) A = operator_e(omega, dxes, epsilon, mu)
for nn in range(len(mode_numbers)): for nn in range(len(mode_numbers)):
eigvals[nn], e_xys[:, nn] = rayleigh_quotient_iteration(A, e_xys[:, nn]) eigvals[nn], e_xys[nn, :] = rayleigh_quotient_iteration(A, e_xys[nn, :])
# Calculate the wave-vector (force the real part to be positive) # Calculate the wave-vector (force the real part to be positive)
wavenumbers = numpy.sqrt(eigvals) wavenumbers = numpy.sqrt(eigvals)
wavenumbers *= numpy.sign(numpy.real(wavenumbers)) wavenumbers *= numpy.sign(numpy.real(wavenumbers))
order = wavenumbers.argsort()[::-1]
e_xys = e_xys[order]
wavenumbers = wavenumbers[order]
return e_xys, wavenumbers return e_xys, wavenumbers
@ -878,7 +942,7 @@ def solve_mode(
mode_number: int, mode_number: int,
*args: Any, *args: Any,
**kwargs: Any, **kwargs: Any,
) -> tuple[vcfdfield_t, complex]: ) -> tuple[vcfdfield2_t, complex]:
""" """
Wrapper around `solve_modes()` that solves for a single mode. Wrapper around `solve_modes()` that solves for a single mode.
@ -892,4 +956,66 @@ def solve_mode(
""" """
kwargs['mode_numbers'] = [mode_number] kwargs['mode_numbers'] = [mode_number]
e_xys, wavenumbers = solve_modes(*args, **kwargs) e_xys, wavenumbers = solve_modes(*args, **kwargs)
return e_xys[:, 0], wavenumbers[0] return vcfdfield2_t(e_xys[0]), wavenumbers[0]
def inner_product(
e1: vcfdfield2,
h2: vcfdfield2,
dxes: dx_lists2_t,
prop_phase: float = 0,
conj_h: bool = False,
trapezoid: bool = False,
) -> complex:
r"""
Compute the discrete waveguide overlap / Poynting inner product.
This is the 2D transverse integral corresponding to the time-averaged
longitudinal Poynting flux,
$$
\frac{1}{2}\int (E_x H_y - E_y H_x) \, dx \, dy
$$
with the Yee-grid staggering and optional propagation-phase adjustment used
by the waveguide helpers in this module.
Args:
e1: Vectorized electric field, typically from `exy2e(...)` or
`normalized_fields_e(...)`.
h2: Vectorized magnetic field, typically from `hxy2h(...)`,
`exy2h(...)`, or one of the normalization helpers.
dxes: Two-dimensional Yee-grid spacing lists `[dx_e, dx_h]`.
prop_phase: Phase advance over one propagation cell. This is used to
shift the H field into the same longitudinal reference plane as the
E field.
conj_h: Whether to conjugate `h2` before forming the overlap. Use
`True` for the usual time-averaged power normalization.
trapezoid: Whether to use trapezoidal quadrature instead of the default
rectangular Yee-cell sum.
Returns:
Complex overlap / longitudinal power integral.
"""
shape = [s.size for s in dxes[0]]
# H phase is adjusted by a half-cell forward shift for Yee cell, and 1-cell reverse shift for Poynting
phase = numpy.exp(-1j * -prop_phase / 2)
E1 = unvec(e1, shape)
H2 = unvec(h2, shape) * phase
if conj_h:
H2 = numpy.conj(H2)
# Find time-averaged Sz and normalize to it
dxes_real = [[numpy.real(dxyz) for dxyz in dxeh] for dxeh in dxes]
if trapezoid:
Sz_a = numpy.trapezoid(numpy.trapezoid(E1[0] * H2[1], numpy.cumsum(dxes_real[0][1])), numpy.cumsum(dxes_real[1][0]))
Sz_b = numpy.trapezoid(numpy.trapezoid(E1[1] * H2[0], numpy.cumsum(dxes_real[0][0])), numpy.cumsum(dxes_real[1][1]))
else:
Sz_a = E1[0] * H2[1] * dxes_real[1][0][:, None] * dxes_real[0][1][None, :]
Sz_b = E1[1] * H2[0] * dxes_real[0][0][:, None] * dxes_real[1][1][None, :]
Sz = 0.5 * (Sz_a.sum() - Sz_b.sum())
return Sz

View file

@ -3,15 +3,40 @@ 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 Sequence, Any from typing import Any, TypedDict, cast
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, fdfield_t, cfdfield_t from ..fdmath import vec, unvec, dx_lists_t, cfdfield_t, fdfield, cfdfield
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,
@ -19,10 +44,10 @@ def solve_mode(
axis: int, axis: int,
polarity: int, polarity: int,
slices: Sequence[slice], slices: Sequence[slice],
epsilon: fdfield_t, epsilon: fdfield,
mu: fdfield_t | None = None, mu: fdfield | None = None,
) -> dict[str, complex | NDArray[numpy.float_]]: ) -> Waveguide3DMode:
""" 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.
@ -33,27 +58,31 @@ 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]` should select only one item. as the waveguide cross-section. `slices[axis]` must select exactly 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': list[NDArray[numpy.float_]], - `E`: full-grid electric field for the solved mode
'H': list[NDArray[numpy.float_]], - `H`: full-grid magnetic field for the solved mode
'wavenumber': complex, - `wavenumber`: propagation constant corrected for the discretized
} 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)
@ -71,9 +100,10 @@ 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)
@ -92,11 +122,12 @@ 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 a, o in enumerate(reverse_order): for aa, oo in enumerate(reverse_order):
E[(a, *slices)] = e[o][:, :, None].transpose(reverse_order) iii = cast('tuple[slice | int]', (aa, *slices))
H[(a, *slices)] = h[o][:, :, None].transpose(reverse_order) E[iii] = e[oo][:, :, None].transpose(reverse_order)
H[iii] = h[oo][:, :, None].transpose(reverse_order)
results = { results: Waveguide3DMode = {
'wavenumber': wavenumber, 'wavenumber': wavenumber,
'wavenumber_2d': wavenumber_2d, 'wavenumber_2d': wavenumber_2d,
'H': H, 'H': H,
@ -106,15 +137,15 @@ def solve_mode(
def compute_source( def compute_source(
E: cfdfield_t, E: cfdfield,
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_t, epsilon: fdfield,
mu: fdfield_t | None = None, mu: fdfield | 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
@ -132,7 +163,14 @@ def compute_source(
mu: Magnetic permeability (default 1 everywhere) mu: Magnetic permeability (default 1 everywhere)
Returns: Returns:
J distribution for the unidirectional source `J` distribution for a one-sided electric-current 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)
@ -148,66 +186,113 @@ 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 J return cfdfield_t(J)
def compute_overlap_e( def compute_overlap_e(
E: cfdfield_t, E: cfdfield,
wavenumber: complex,
dxes: dx_lists_t,
axis: int,
polarity: int,
slices: Sequence[slice],
) -> cfdfield_t: # TODO DOCS
"""
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)
[assumes reflection symmetry].
TODO: add reference
Args:
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
omega: Angular frequency of the simulation
dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types`
axis: Propagation axis (0=x, 1=y, 2=z)
polarity: Propagation direction (+1 for +ve, -1 for -ve)
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.
mu: Magnetic permeability (default 1 everywhere)
Returns:
overlap_e such that `numpy.sum(overlap_e * other_e.conj())` computes the overlap integral
"""
slices = tuple(slices)
Ee = expand_e(E=E, wavenumber=wavenumber, dxes=dxes,
axis=axis, polarity=polarity, slices=slices)
start, stop = sorted((slices[axis].start, slices[axis].start - 2 * polarity))
slices2_l = list(slices)
slices2_l[axis] = slice(start, stop)
slices2 = (slice(None), *slices2_l)
Etgt = numpy.zeros_like(Ee)
Etgt[slices2] = Ee[slices2]
Etgt /= (Etgt.conj() * Etgt).sum()
return Etgt
def expand_e(
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"""
Build an overlap field for projecting another 3D electric field onto a mode.
The returned field is intended for the discrete overlap expression
$$
\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)
Args:
E: E-field of the mode
wavenumber: Wavenumber of the mode
dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types`
axis: Propagation axis (0=x, 1=y, 2=z)
polarity: Propagation direction (+1 for +ve, -1 for -ve)
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.
Returns:
`overlap_e` normalized so that `numpy.sum(overlap_e * E.conj()) == 1`
over the retained overlap window.
""" """
slices = tuple(slices)
Ee = expand_e(E=E, wavenumber=wavenumber, dxes=dxes,
axis=axis, polarity=polarity, slices=slices)
axis_size = E.shape[axis + 1]
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[axis] = slice(clipped_start, clipped_stop)
slices2 = (slice(None), *slices2_l)
Etgt = numpy.zeros_like(Ee)
Etgt[slices2] = Ee[slices2]
# Note: We normalize so that (Etgt @ E.conj()) == 1, so (Etgt @ Etgt.conj) != 1
norm = (Etgt.conj() * Etgt).sum()
if norm == 0:
raise ValueError('Requested overlap window contains no overlap field support')
Etgt = Etgt / norm
return cfdfield_t(Etgt)
def expand_e(
E: cfdfield,
wavenumber: complex,
dxes: dx_lists_t,
axis: int,
polarity: int,
slices: Sequence[slice],
) -> 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
@ -225,6 +310,16 @@ 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)
@ -245,4 +340,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 E_expanded return cfdfield_t(E_expanded)

View file

@ -1,49 +1,181 @@
""" r"""
Operators and helper functions for cylindrical waveguides with unchanging cross-section. Operators and helper functions for cylindrical waveguides with unchanging cross-section.
WORK IN PROGRESS, CURRENTLY BROKEN Waveguide operator is derived according to 10.1364/OL.33.001848.
As the z-dependence is known, all the functions in this file assume a 2D grid As in `waveguide_2d`, the propagation dependence is separated from the
(i.e. `dxes = [[[dr_e_0, dx_e_1, ...], [dy_e_0, ...]], [[dr_h_0, ...], [dy_h_0, ...]]]`). transverse solve. Here the propagation coordinate is the bend angle `\theta`,
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, ...]]]`.
""" """
# TODO update module docs from typing import Any, cast
from collections.abc import Sequence
import logging
import numpy import numpy
import scipy.sparse as sparse # type: ignore from numpy.typing import NDArray, ArrayLike
from scipy import sparse
from ..fdmath import vec, unvec, dx_lists_t, fdfield_t, vfdfield_t, cfdfield_t from ..fdmath import vec, unvec, dx_lists2_t, vcfdslice_t, vfdslice, vcfdslice, vcfdfield2
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: complex, omega: float,
dxes: dx_lists_t, dxes: dx_lists2_t,
epsilon: vfdfield_t, epsilon: vfdslice,
r0: float, rmin: float,
) -> sparse.spmatrix: ) -> sparse.sparray:
""" r"""
Cylindrical coordinate waveguide operator of the form Cylindrical coordinate waveguide operator of the form
(NOTE: See 10.1364/OL.33.001848) $$
TODO: consider 10.1364/OE.20.021583 (\omega^2 \begin{bmatrix} T_b T_b \mu_{yy} \epsilon_{xx} & 0 \\
0 & T_a T_a \mu_{xx} \epsilon_{yy} \end{bmatrix} +
TODO \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] = wavenumber**2 * [E_r, E_y] A @ [E_r, E_y] = beta**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 * wavenumber * theta)` theta-dependence is assumed for the fields). (an `exp(-i * angular_wavenumber * theta)` theta-dependence is assumed for
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
r0: Radius of curvature for the simulation. This should be the minimum value of rmin: Radius at the left edge of the simulation domain (at minimum 'x')
r within the simulation domain.
Returns: Returns:
Sparse matrix representation of the operator Sparse matrix representation of the operator
@ -52,97 +184,409 @@ 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])
rx = r0 + numpy.cumsum(dxes[0][0]) Ta, Tb = dxes2T(dxes=dxes, rmin=rmin)
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(eps_parts[0]) eps_x = sparse.diags_array(eps_parts[0])
eps_y = sparse.diags(eps_parts[1]) eps_y = sparse.diags_array(eps_parts[1])
eps_z_inv = sparse.diags(1 / eps_parts[2]) eps_z_inv = sparse.diags_array(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
diag = sparse.block_diag
omega2 = omega * omega omega2 = omega * omega
diag = sparse.block_diag
op = ( sq0 = omega2 * diag((Tb @ Tb @ eps_x,
(omega2 * diag((Tx, Ty)) + pa) @ diag((a0, a1)) Ta @ Ta @ eps_y))
- (sparse.bmat(((None, Ty), (Tx, None))) + pb / omega2) @ diag((b0, b1)) lin0 = sparse.vstack((-Tb @ Dby, Ta @ Dbx)) @ Tb @ sparse.hstack((-Dfy, Dfx))
) lin1 = sparse.vstack((Dfx, Dfy)) @ Ta @ eps_z_inv @ sparse.hstack((Dbx @ Tb @ eps_x,
Dby @ Ta @ eps_y))
op = sq0 + lin0 + lin1
return op return op
def solve_mode( def solve_modes(
mode_number: int, mode_numbers: Sequence[int],
omega: complex, omega: float,
dxes: dx_lists_t, dxes: dx_lists2_t,
epsilon: vfdfield_t, epsilon: vfdslice,
r0: float, rmin: float,
) -> dict[str, complex | cfdfield_t]: mode_margin: int = 2,
) -> tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]:
""" """
TODO: fixup
Given a 2d (r, y) slice of epsilon, attempts to solve for the eigenmode Given a 2d (r, y) slice of epsilon, attempts to solve for the eigenmode
of the bent waveguide with the specified mode number. of the bent waveguide with the specified mode number.
Args: Args:
mode_number: Number of the mode, 0-indexed mode_numbers: Mode numbers to solve, 0-indexed.
omega: Angular frequency of the simulation 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.
The first coordinate is assumed to be r, the second is y. The first coordinate is assumed to be r, the second is y.
epsilon: Dielectric constant epsilon: Dielectric constant
r0: Radius of curvature for the simulation. This should be the minimum value of rmin: Radius of curvature for the simulation. This should be the minimum value of
r within the simulation domain. r within the simulation domain.
Returns: Returns:
``` e_xys: NDArray of vfdfield_t specifying fields. First dimension is mode number.
{ angular_wavenumbers: list of wavenumbers in 1/rad units.
'E': list[NDArray[numpy.complex_]],
'H': list[NDArray[numpy.complex_]],
'wavenumber': complex,
}
```
""" """
''' #
Solve for the largest-magnitude eigenvalue of the real operator # Solve for the largest-magnitude eigenvalue of the real operator
''' #
dxes_real = [[numpy.real(dx) for dx in dxi] for dxi in dxes] 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), r0) A_r = cylindrical_operator(numpy.real(omega), dxes_real, numpy.real(epsilon), rmin=rmin)
eigvals, eigvecs = signed_eigensolve(A_r, mode_number + 3) eigvals, eigvecs = signed_eigensolve(A_r, max(mode_numbers) + mode_margin)
e_xy = eigvecs[:, -(mode_number + 1)] 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 # Now solve for the eigenvector of the full operator, using the real operator's
eigenvector as an initial guess for Rayleigh quotient iteration. # eigenvector as an initial guess for Rayleigh quotient iteration.
''' #
A = cylindrical_operator(omega, dxes, epsilon, r0) A = cylindrical_operator(omega, dxes, epsilon, rmin=rmin)
eigval, e_xy = rayleigh_quotient_iteration(A, e_xy) 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) # Calculate the wave-vector (force the real part to be positive)
wavenumber = numpy.sqrt(eigval) wavenumbers = numpy.sqrt(eigvals)
wavenumber *= numpy.sign(numpy.real(wavenumber)) wavenumbers *= numpy.sign(numpy.real(wavenumbers))
# TODO: Perform correction on wavenumber to account for numerical dispersion. # Wavenumbers assume the mode is at rmin, which is unlikely
# Instead, return the wavenumber in inverse radians
angular_wavenumbers = wavenumbers * cast('complex', rmin)
shape = [d.size for d in dxes[0]] order = angular_wavenumbers.argsort()[::-1]
e_xy = numpy.hstack((e_xy, numpy.zeros(shape[0] * shape[1]))) e_xys = e_xys[order]
fields = { angular_wavenumbers = angular_wavenumbers[order]
'wavenumber': wavenumber,
'E': unvec(e_xy, shape),
# 'E': unvec(e, shape),
# 'H': unvec(h, shape),
}
return fields return e_xys, angular_wavenumbers
def solve_mode(
mode_number: int,
*args: Any,
**kwargs: Any,
) -> tuple[vcfdfield2, complex]:
"""
Wrapper around `solve_modes()` that solves for a single mode.
Args:
mode_number: 0-indexed mode number to solve for
*args: passed to `solve_modes()`
**kwargs: passed to `solve_modes()`
Returns:
(e_xy, angular_wavenumber)
"""
kwargs['mode_numbers'] = [mode_number]
e_xys, angular_wavenumbers = solve_modes(*args, **kwargs)
return e_xys[0], angular_wavenumbers[0]
def linear_wavenumbers(
e_xys: Sequence[vcfdfield2] | NDArray[numpy.complex128],
angular_wavenumbers: ArrayLike,
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)
angular_wavenumbers: Wavenumbers assuming fields have theta-dependence of
`exp(-i * angular_wavenumber * theta)`. They should satisfy
`operator_e() @ e_xy == (angular_wavenumber / rmin) ** 2 * e_xy`
epsilon: Vectorized dielectric constant grid with shape (3, x, y)
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:
NDArray containing the calculated linear (1/distance) wavenumbers
"""
angular_wavenumbers = numpy.asarray(angular_wavenumbers)
mode_radii = numpy.empty_like(angular_wavenumbers, dtype=float)
shape2d = (len(dxes[0][0]), len(dxes[0][1]))
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=}')
lin_wavenumbers = angular_wavenumbers / mode_radii
return lin_wavenumbers
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

@ -741,8 +741,46 @@ 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 fdfield_t, vfdfield_t, cfdfield_t, vcfdfield_t, dx_lists_t, dx_lists_mut from .types import (
from .types import fdfield_updater_t, cfdfield_updater_t fdfield_t as fdfield_t,
from .vectorization import vec, unvec vfdfield_t as vfdfield_t,
from . import operators, functional, types, vectorization cfdfield_t as cfdfield_t,
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,16 +3,18 @@ Math functions for finite difference simulations
Basic discrete calculus etc. Basic discrete calculus etc.
""" """
from typing import Sequence, Callable from typing import TypeVar
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_t, fdfield_updater_t from .types import fdfield, fdfield_updater_t
def deriv_forward( def deriv_forward(
dx_e: Sequence[NDArray[numpy.float_]] | None = None, dx_e: Sequence[NDArray[floating | complexfloating]] | 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).
@ -36,7 +38,7 @@ def deriv_forward(
def deriv_back( def deriv_back(
dx_h: Sequence[NDArray[numpy.float_]] | None = None, dx_h: Sequence[NDArray[floating | complexfloating]] | 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).
@ -59,9 +61,12 @@ def deriv_back(
return derivs return derivs
TT = TypeVar('TT', bound='NDArray[floating | complexfloating]')
def curl_forward( def curl_forward(
dx_e: Sequence[NDArray[numpy.float_]] | None = None, dx_e: Sequence[NDArray[floating | complexfloating]] | None = None,
) -> fdfield_updater_t: ) -> Callable[[TT], TT]:
r""" r"""
Curl operator for use with the E field. Curl operator for use with the E field.
@ -75,7 +80,7 @@ def curl_forward(
""" """
Dx, Dy, Dz = deriv_forward(dx_e) Dx, Dy, Dz = deriv_forward(dx_e)
def ce_fun(e: fdfield_t) -> fdfield_t: def ce_fun(e: TT) -> TT:
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])
@ -89,8 +94,8 @@ def curl_forward(
def curl_back( def curl_back(
dx_h: Sequence[NDArray[numpy.float_]] | None = None, dx_h: Sequence[NDArray[floating | complexfloating]] | None = None,
) -> fdfield_updater_t: ) -> Callable[[TT], TT]:
r""" r"""
Create a function which takes the backward curl of a field. Create a function which takes the backward curl of a field.
@ -104,7 +109,7 @@ def curl_back(
""" """
Dx, Dy, Dz = deriv_back(dx_h) Dx, Dy, Dz = deriv_back(dx_h)
def ch_fun(h: fdfield_t) -> fdfield_t: def ch_fun(h: TT) -> TT:
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])
@ -118,11 +123,11 @@ def curl_back(
def curl_forward_parts( def curl_forward_parts(
dx_e: Sequence[NDArray[numpy.float_]] | None = None, dx_e: Sequence[NDArray[floating | complexfloating]] | None = None,
) -> Callable: ) -> Callable:
Dx, Dy, Dz = deriv_forward(dx_e) Dx, Dy, Dz = deriv_forward(dx_e)
def mkparts_fwd(e: fdfield_t) -> tuple[tuple[fdfield_t, fdfield_t], ...]: def mkparts_fwd(e: fdfield) -> tuple[tuple[fdfield, fdfield], ...]:
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])))
@ -131,11 +136,11 @@ def curl_forward_parts(
def curl_back_parts( def curl_back_parts(
dx_h: Sequence[NDArray[numpy.float_]] | None = None, dx_h: Sequence[NDArray[floating | complexfloating]] | None = None,
) -> Callable: ) -> Callable:
Dx, Dy, Dz = deriv_back(dx_h) Dx, Dy, Dz = deriv_back(dx_h)
def mkparts_back(h: fdfield_t) -> tuple[tuple[fdfield_t, fdfield_t], ...]: def mkparts_back(h: fdfield) -> tuple[tuple[fdfield, fdfield], ...]:
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,19 +3,20 @@ Matrix operators for finite difference simulations
Basic discrete calculus etc. Basic discrete calculus etc.
""" """
from typing import Sequence from collections.abc import Sequence
import numpy import numpy
from numpy.typing import NDArray from numpy.typing import NDArray
import scipy.sparse as sparse # type: ignore from numpy import floating, complexfloating
from scipy import sparse
from .types import vfdfield_t from .types import vfdfield
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.spmatrix: ) -> sparse.sparray:
""" """
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.
@ -33,8 +34,8 @@ def shift_circ(
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(f'Invalid direction: {axis}, shape is {shape}')
shifts = [abs(shift_distance) if a == axis else 0 for a in range(3)] shifts = [abs(shift_distance) if a == axis else 0 for a in range(len(shape))]
shifted_diags = [(numpy.arange(n) + s) % n for n, s in zip(shape, shifts)] shifted_diags = [(numpy.arange(n) + s) % n for n, s in zip(shape, shifts, strict=True)]
ijk = numpy.meshgrid(*shifted_diags, indexing='ij') ijk = numpy.meshgrid(*shifted_diags, indexing='ij')
n = numpy.prod(shape) n = numpy.prod(shape)
@ -43,7 +44,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_matrix(vij, shape=(n, n)) d = sparse.csr_array(vij, shape=(n, n))
if shift_distance < 0: if shift_distance < 0:
d = d.T d = d.T
@ -55,7 +56,7 @@ def shift_with_mirror(
axis: int, axis: int,
shape: Sequence[int], shape: Sequence[int],
shift_distance: int = 1, shift_distance: int = 1,
) -> sparse.spmatrix: ) -> sparse.sparray:
""" """
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.
@ -81,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(3)] shifts = [shift_distance if a == axis else 0 for a in range(len(shape))]
shifted_diags = [mirrored_range(n, s) for n, s in zip(shape, shifts)] shifted_diags = [mirrored_range(n, s) for n, s in zip(shape, shifts, strict=True)]
ijk = numpy.meshgrid(*shifted_diags, indexing='ij') ijk = numpy.meshgrid(*shifted_diags, indexing='ij')
n = numpy.prod(shape) n = numpy.prod(shape)
@ -91,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_matrix(vij, shape=(n, n)) d = sparse.csr_array(vij, shape=(n, n))
return d return d
def deriv_forward( def deriv_forward(
dx_e: Sequence[NDArray[numpy.float_]], dx_e: Sequence[NDArray[floating | complexfloating]],
) -> list[sparse.spmatrix]: ) -> list[sparse.sparray]:
""" """
Utility operators for taking discretized derivatives (forward variant). Utility operators for taking discretized derivatives (forward variant).
@ -113,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.spmatrix: def deriv(axis: int) -> sparse.sparray:
return shift_circ(axis, shape, 1) - sparse.eye(n) return shift_circ(axis, shape, 1) - sparse.eye_array(n)
Ds = [sparse.diags(+1 / dx.ravel(order='C')) @ deriv(a) Ds = [sparse.diags_array(+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[numpy.float_]], dx_h: Sequence[NDArray[floating | complexfloating]],
) -> list[sparse.spmatrix]: ) -> list[sparse.sparray]:
""" """
Utility operators for taking discretized derivatives (backward variant). Utility operators for taking discretized derivatives (backward variant).
@ -140,18 +141,18 @@ 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.spmatrix: def deriv(axis: int) -> sparse.sparray:
return shift_circ(axis, shape, -1) - sparse.eye(n) return shift_circ(axis, shape, -1) - sparse.eye_array(n)
Ds = [sparse.diags(-1 / dx.ravel(order='C')) @ deriv(a) Ds = [sparse.diags_array(-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.spmatrix], B: Sequence[sparse.sparray],
) -> sparse.spmatrix: ) -> sparse.sparray:
""" """
Cross product operator Cross product operator
@ -163,13 +164,14 @@ def cross(
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_matrix((n, n)) zero = sparse.csr_array((n, n))
return sparse.bmat([[zero, -B[2], B[1]], return sparse.block_array([
[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_t) -> sparse.spmatrix: def vec_cross(b: vfdfield) -> sparse.sparray:
""" """
Vector cross product operator Vector cross product operator
@ -181,11 +183,11 @@ def vec_cross(b: vfdfield_t) -> sparse.spmatrix:
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(c) for c in numpy.split(b, 3)] B = [sparse.diags_array(c) for c in numpy.split(b, 3)]
return cross(B) return cross(B)
def avg_forward(axis: int, shape: Sequence[int]) -> sparse.spmatrix: def avg_forward(axis: int, shape: Sequence[int]) -> sparse.sparray:
""" """
Forward average operator `(x4 = (x4 + x5) / 2)` Forward average operator `(x4 = (x4 + x5) / 2)`
@ -200,10 +202,10 @@ def avg_forward(axis: int, shape: Sequence[int]) -> sparse.spmatrix:
raise Exception(f'Invalid shape: {shape}') raise Exception(f'Invalid shape: {shape}')
n = numpy.prod(shape) n = numpy.prod(shape)
return 0.5 * (sparse.eye(n) + shift_circ(axis, shape)) return 0.5 * (sparse.eye_array(n) + shift_circ(axis, shape))
def avg_back(axis: int, shape: Sequence[int]) -> sparse.spmatrix: def avg_back(axis: int, shape: Sequence[int]) -> sparse.sparray:
""" """
Backward average operator `(x4 = (x4 + x3) / 2)` Backward average operator `(x4 = (x4 + x3) / 2)`
@ -218,8 +220,8 @@ def avg_back(axis: int, shape: Sequence[int]) -> sparse.spmatrix:
def curl_forward( def curl_forward(
dx_e: Sequence[NDArray[numpy.float_]], dx_e: Sequence[NDArray[floating | complexfloating]],
) -> sparse.spmatrix: ) -> sparse.sparray:
""" """
Curl operator for use with the E field. Curl operator for use with the E field.
@ -234,8 +236,8 @@ def curl_forward(
def curl_back( def curl_back(
dx_h: Sequence[NDArray[numpy.float_]], dx_h: Sequence[NDArray[floating | complexfloating]],
) -> sparse.spmatrix: ) -> sparse.sparray:
""" """
Curl operator for use with the H field. Curl operator for use with the H field.

View file

@ -1,26 +1,65 @@
""" """
Types shared across multiple submodules Types shared across multiple submodules
""" """
from typing import Sequence, Callable, MutableSequence from typing import NewType
import numpy from collections.abc import Sequence, Callable, MutableSequence
from numpy.typing import NDArray from numpy.typing import NDArray
from numpy import floating, complexfloating
# Field types # Field types
fdfield_t = NDArray[numpy.float_] fdfield_t = NewType('fdfield_t', NDArray[floating])
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 = NDArray[numpy.float_] vfdfield_t = NewType('vfdfield_t', NDArray[floating])
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 = NDArray[numpy.complex_] cfdfield_t = NewType('cfdfield_t', NDArray[complexfloating])
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 = NDArray[numpy.complex_] vcfdfield_t = NewType('vcfdfield_t', NDArray[complexfloating])
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)"""
dx_lists_t = Sequence[Sequence[NDArray[numpy.float_]]] fdslice_t = NewType('fdslice_t', NDArray[floating])
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:
@ -31,12 +70,26 @@ dx_lists_t = Sequence[Sequence[NDArray[numpy.float_]]]
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_lists_mut = MutableSequence[MutableSequence[NDArray[numpy.float_]]] dx_lists2_t = Sequence[Sequence[NDArray[floating | complexfloating]]]
"""
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"""
cfdfield_updater_t = Callable[..., cfdfield_t] fdfield_updater_t = Callable[..., fdfield]
"""Convenience type for functions which take and return an cfdfield_t""" """Convenience type for functions which take and return a real `fdfield`"""
cfdfield_updater_t = Callable[..., cfdfield]
"""Convenience type for functions which take and return a complex `cfdfield`"""

View file

@ -4,37 +4,60 @@ 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, Sequence from typing import overload
from collections.abc import Sequence
import numpy import numpy
from numpy.typing import ArrayLike from numpy.typing import ArrayLike, NDArray
from .types import fdfield_t, vfdfield_t, cfdfield_t, vcfdfield_t from .types import (
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 pass # pragma: no cover
@overload @overload
def vec(f: fdfield_t) -> vfdfield_t: def vec(f: fdfield_t) -> vfdfield_t:
pass pass # pragma: no cover
@overload @overload
def vec(f: cfdfield_t) -> vcfdfield_t: def vec(f: cfdfield_t) -> vcfdfield_t:
pass pass # pragma: no cover
@overload @overload
def vec(f: ArrayLike) -> vfdfield_t | vcfdfield_t: def vec(f: fdfield2_t) -> vfdfield2_t:
pass pass # pragma: no cover
def vec(f: fdfield_t | cfdfield_t | ArrayLike | None) -> vfdfield_t | vcfdfield_t | None: @overload
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 3D vector field which spans a 1-3D region. Create a 1D ndarray from a 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, `[f_x, f_y, f_z]` where each `f_` component is a 1- to f: A vector field, e.g. `[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:
@ -42,37 +65,61 @@ def vec(f: fdfield_t | cfdfield_t | ArrayLike | None) -> vfdfield_t | vcfdfield_
""" """
if f is None: if f is None:
return None return None
return numpy.ravel(f, order='C') return numpy.ravel(f, order='C') # type: ignore
@overload @overload
def unvec(v: None, shape: Sequence[int]) -> None: def unvec(v: None, shape: Sequence[int], nvdim: int = 3) -> 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: vfdfield_t, shape: Sequence[int]) -> fdfield_t: def unvec(v: vcfdfield2_t, shape: Sequence[int], nvdim: int = 3) -> cfdfield2_t:
pass pass
@overload @overload
def unvec(v: vcfdfield_t, shape: Sequence[int]) -> cfdfield_t: def unvec(v: vfdslice_t, shape: Sequence[int], nvdim: int = 3) -> fdslice_t:
pass pass
def unvec(v: vfdfield_t | vcfdfield_t | None, shape: Sequence[int]) -> fdfield_t | cfdfield_t | None: @overload
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 a 3D field Perform the inverse of vec(): take a 1D ndarray and output an `nvdim`-component field
of form `[f_x, f_y, f_z]` where each of `f_*` is a len(shape)-dimensional of form e.g. `[f_x, f_y, f_z]` (`nvdim=3`) 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 3D vector field of shape shape (or None) v: 1D ndarray representing a 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((3, *shape), order='C') return v.reshape((nvdim, *shape), order='C') # type: ignore

View file

@ -144,6 +144,50 @@ 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
@ -156,11 +200,75 @@ 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 maxwell_e, maxwell_h from .base import (
from .pml import cpml_params, updates_with_cpml maxwell_e as maxwell_e,
from .energy import (poynting, poynting_divergence, energy_hstep, energy_estep, maxwell_h as maxwell_h,
delta_energy_h2e, delta_energy_j) )
from .boundaries import conducting_boundary from .pml import (
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_t, fdfield_updater_t from ..fdmath import dx_lists_t, fdfield, 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_t, h: fdfield_t, epsilon: fdfield_t | float) -> fdfield_t: def me_fun(e: fdfield, h: fdfield, epsilon: fdfield | float) -> fdfield:
""" """
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_t, h: fdfield_t, mu: fdfield_t | float | None = None) -> fdfield_t: def mh_fun(e: fdfield, h: fdfield, mu: fdfield | float | None = None) -> fdfield:
""" """
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_t, fdfield_updater_t from ..fdmath import fdfield, fdfield_updater_t
def conducting_boundary( def conducting_boundary(
@ -15,7 +15,7 @@ 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 Exception(f'Invalid direction: {direction}') raise ValueError(f'Invalid direction: {direction}')
dirs.remove(direction) dirs.remove(direction)
u, v = dirs u, v = dirs
@ -28,17 +28,19 @@ def conducting_boundary(
shifted1_slice = [slice(None)] * 3 shifted1_slice = [slice(None)] * 3
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_t) -> fdfield_t: def en(e: fdfield) -> fdfield:
e[direction][boundary_slice] = 0 e[direction][boundary] = 0
e[u][boundary_slice] = e[u][shifted1_slice] e[u][boundary] = e[u][shifted1]
e[v][boundary_slice] = e[v][shifted1_slice] e[v][boundary] = e[v][shifted1]
return e return e
def hn(h: fdfield_t) -> fdfield_t: def hn(h: fdfield) -> fdfield:
h[direction][boundary_slice] = h[direction][shifted1_slice] h[direction][boundary] = h[direction][shifted1]
h[u][boundary_slice] = 0 h[u][boundary] = 0
h[v][boundary_slice] = 0 h[v][boundary] = 0
return h return h
return en, hn return en, hn
@ -50,22 +52,25 @@ def conducting_boundary(
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_t) -> fdfield_t: def ep(e: fdfield) -> fdfield:
e[direction][boundary_slice] = -e[direction][shifted2_slice] e[direction][boundary] = -e[direction][shifted2]
e[direction][shifted1_slice] = 0 e[direction][shifted1] = 0
e[u][boundary_slice] = e[u][shifted1_slice] e[u][boundary] = e[u][shifted1]
e[v][boundary_slice] = e[v][shifted1_slice] e[v][boundary] = e[v][shifted1]
return e return e
def hp(h: fdfield_t) -> fdfield_t: def hp(h: fdfield) -> fdfield:
h[direction][boundary_slice] = h[direction][shifted1_slice] h[direction][boundary] = h[direction][shifted1]
h[u][boundary_slice] = -h[u][shifted2_slice] h[u][boundary] = -h[u][shifted2]
h[u][shifted1_slice] = 0 h[u][shifted1] = 0
h[v][boundary_slice] = -h[v][shifted2_slice] h[v][boundary] = -h[v][shifted2]
h[v][shifted1_slice] = 0 h[v][shifted1] = 0
return h return h
return ep, hp return ep, hp
raise Exception(f'Bad polarity: {polarity}') raise ValueError(f'Bad polarity: {polarity}')

View file

@ -1,15 +1,29 @@
import numpy import numpy
from ..fdmath import dx_lists_t, fdfield_t from ..fdmath import dx_lists_t, fdfield_t, fdfield
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_t, e: fdfield,
h: fdfield_t, h: fdfield,
dxes: dx_lists_t | None = None, dxes: dx_lists_t | None = None,
) -> fdfield_t: ) -> fdfield_t:
r""" r"""
@ -43,6 +57,7 @@ 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
@ -84,14 +99,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 s return fdfield_t(s)
def poynting_divergence( def poynting_divergence(
s: fdfield_t | None = None, s: fdfield | None = None,
*, *,
e: fdfield_t | None = None, e: fdfield | None = None,
h: fdfield_t | None = None, h: fdfield | None = None,
dxes: dx_lists_t | None = None, dxes: dx_lists_t | None = None,
) -> fdfield_t: ) -> fdfield_t:
""" """
@ -122,15 +137,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 ds return fdfield_t(ds)
def energy_hstep( def energy_hstep(
e0: fdfield_t, e0: fdfield,
h1: fdfield_t, h1: fdfield,
e2: fdfield_t, e2: fdfield,
epsilon: fdfield_t | None = None, epsilon: fdfield | None = None,
mu: fdfield_t | None = None, mu: fdfield | None = None,
dxes: dx_lists_t | None = None, dxes: dx_lists_t | None = None,
) -> fdfield_t: ) -> fdfield_t:
""" """
@ -150,15 +165,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 u return fdfield_t(u)
def energy_estep( def energy_estep(
h0: fdfield_t, h0: fdfield,
e1: fdfield_t, e1: fdfield,
h2: fdfield_t, h2: fdfield,
epsilon: fdfield_t | None = None, epsilon: fdfield | None = None,
mu: fdfield_t | None = None, mu: fdfield | None = None,
dxes: dx_lists_t | None = None, dxes: dx_lists_t | None = None,
) -> fdfield_t: ) -> fdfield_t:
""" """
@ -178,17 +193,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 u return fdfield_t(u)
def delta_energy_h2e( def delta_energy_h2e(
dt: float, dt: float,
e0: fdfield_t, e0: fdfield,
h1: fdfield_t, h1: fdfield,
e2: fdfield_t, e2: fdfield,
h3: fdfield_t, h3: fdfield,
epsilon: fdfield_t | None = None, epsilon: fdfield | None = None,
mu: fdfield_t | None = None, mu: fdfield | None = None,
dxes: dx_lists_t | None = None, dxes: dx_lists_t | None = None,
) -> fdfield_t: ) -> fdfield_t:
""" """
@ -211,17 +226,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 du return fdfield_t(du)
def delta_energy_e2h( def delta_energy_e2h(
dt: float, dt: float,
h0: fdfield_t, h0: fdfield,
e1: fdfield_t, e1: fdfield,
h2: fdfield_t, h2: fdfield,
e3: fdfield_t, e3: fdfield,
epsilon: fdfield_t | None = None, epsilon: fdfield | None = None,
mu: fdfield_t | None = None, mu: fdfield | None = None,
dxes: dx_lists_t | None = None, dxes: dx_lists_t | None = None,
) -> fdfield_t: ) -> fdfield_t:
""" """
@ -244,21 +259,31 @@ 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 du return fdfield_t(du)
def delta_energy_j( def delta_energy_j(
j0: fdfield_t, j0: fdfield,
e1: fdfield_t, e1: fdfield,
dxes: dx_lists_t | None = None, dxes: dx_lists_t | None = None,
) -> fdfield_t: ) -> fdfield_t:
""" r"""
Calculate Calculate the electric-current work term $J \cdot E$ on the Yee grid.
Note that each value of $J$ contributes to the energy twice (i.e. once per field update) This is the source contribution that appears beside the flux divergence in
despite only causing the value of $E$ to change once (same for $M$ and $H$). the discrete Poynting identities documented in `meanas.fdtd`.
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))
@ -267,16 +292,30 @@ 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 du return fdfield_t(du)
def dxmul( def dxmul(
ee: fdfield_t, ee: fdfield,
hh: fdfield_t, hh: fdfield,
epsilon: fdfield_t | float | None = None, epsilon: fdfield | float | None = None,
mu: fdfield_t | float | None = None, mu: fdfield | 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:
@ -292,4 +331,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 result return fdfield_t(result)

180
meanas/fdtd/misc.py Normal file
View file

@ -0,0 +1,180 @@
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

319
meanas/fdtd/phasor.py Normal file
View file

@ -0,0 +1,319 @@
"""
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,18 +1,29 @@
""" """
PML implementations Convolutional perfectly matched layer (CPML) support for FDTD updates.
#TODO discussion of PMLs The helpers in this module construct per-face CPML parameters and then wrap the
#TODO cpml documentation standard Yee updates with the additional auxiliary `psi` fields needed by the
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 Callable, Sequence, Any from typing import 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_t, dx_lists_t from ..fdmath import fdfield, dx_lists_t
from ..fdmath.functional import deriv_forward, deriv_back from ..fdmath.functional import deriv_forward, deriv_back
@ -31,18 +42,41 @@ 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 Exception(f'Invalid axis: {axis}') raise ValueError(f'Invalid axis: {axis}')
if polarity not in (-1, 1): if polarity not in (-1, 1):
raise Exception(f'Invalid polarity: {polarity}') raise ValueError(f'Invalid polarity: {polarity}')
if thickness <= 2: if thickness <= 2:
raise Exception('It would be wise to have a pml with 4+ cells of thickness') raise ValueError('It would be wise to have a pml with 4+ cells of thickness')
if epsilon_eff <= 0: if epsilon_eff <= 0:
raise Exception('epsilon_eff must be positive') raise ValueError('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)
@ -56,8 +90,6 @@ 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)
@ -81,8 +113,6 @@ 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 {
@ -96,11 +126,31 @@ 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_t, epsilon: fdfield,
*, *,
dtype: DTypeLike = numpy.float32, dtype: DTypeLike = numpy.float32,
) -> tuple[Callable[[fdfield_t, fdfield_t, fdfield_t], None], ) -> tuple[Callable[..., None], Callable[..., 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])
@ -111,7 +161,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)
@ -136,9 +186,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_t, e: fdfield,
h: fdfield_t, h: fdfield,
epsilon: fdfield_t, epsilon: fdfield,
) -> None: ) -> None:
dyHx = Dby(h[0]) dyHx = Dby(h[0])
dzHx = Dbz(h[0]) dzHx = Dbz(h[0])
@ -182,9 +232,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_t, e: fdfield,
h: fdfield_t, h: fdfield,
mu: fdfield_t = numpy.ones(3), mu: fdfield | tuple[int, int, int] = (1, 1, 1),
) -> None: ) -> None:
dyEx = Dfy(e[0]) dyEx = Dfy(e[0])
dzEx = Dfz(e[0]) dzEx = Dfz(e[0])

View file

@ -0,0 +1,37 @@
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

49
meanas/test/_fdfd_case.py Normal file
View file

@ -0,0 +1,49 @@
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

@ -0,0 +1,56 @@
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

@ -0,0 +1,22 @@
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,12 +3,13 @@
Test fixtures Test fixtures
""" """
from typing import Iterable, Any # ruff: noqa: ARG001
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 PRNG from .utils import make_prng
FixtureRequest = Any FixtureRequest = Any
@ -20,18 +21,18 @@ FixtureRequest = Any
(5, 5, 5), (5, 5, 5),
# (7, 7, 7), # (7, 7, 7),
]) ])
def shape(request: FixtureRequest) -> Iterable[tuple[int, ...]]: def shape(request: FixtureRequest) -> tuple[int, ...]:
yield (3, *request.param) return (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) -> Iterable[float]: def epsilon_bg(request: FixtureRequest) -> float:
yield request.param return 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) -> Iterable[float]: def epsilon_fg(request: FixtureRequest) -> float:
yield request.param return request.param
@pytest.fixture(scope='module', params=['center', '000', 'random']) @pytest.fixture(scope='module', params=['center', '000', 'random'])
@ -40,7 +41,8 @@ def epsilon(
shape: tuple[int, ...], shape: tuple[int, ...],
epsilon_bg: float, epsilon_bg: float,
epsilon_fg: float, epsilon_fg: float,
) -> Iterable[NDArray[numpy.float64]]: ) -> 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':
@ -56,21 +58,23 @@ 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(low=min(epsilon_bg, epsilon_fg), epsilon[:] = prng.uniform(
low=min(epsilon_bg, epsilon_fg),
high=max(epsilon_bg, epsilon_fg), high=max(epsilon_bg, epsilon_fg),
size=shape) size=shape,
)
yield epsilon return epsilon
@pytest.fixture(scope='module', params=[1.0]) # 1.5 @pytest.fixture(scope='module', params=[1.0]) # 1.5
def j_mag(request: FixtureRequest) -> Iterable[float]: def j_mag(request: FixtureRequest) -> float:
yield request.param return request.param
@pytest.fixture(scope='module', params=[1.0, 1.5]) @pytest.fixture(scope='module', params=[1.0, 1.5])
def dx(request: FixtureRequest) -> Iterable[float]: def dx(request: FixtureRequest) -> float:
yield request.param return request.param
@pytest.fixture(scope='module', params=['uniform', 'centerbig']) @pytest.fixture(scope='module', params=['uniform', 'centerbig'])
@ -78,7 +82,8 @@ def dxes(
request: FixtureRequest, request: FixtureRequest,
shape: tuple[int, ...], shape: tuple[int, ...],
dx: float, dx: float,
) -> Iterable[list[list[NDArray[numpy.float64]]]]: ) -> 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':
@ -87,8 +92,7 @@ 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]
yield dxes return dxes

View file

@ -0,0 +1,73 @@
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

@ -0,0 +1,315 @@
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

@ -0,0 +1,101 @@
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

@ -0,0 +1,491 @@
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

@ -0,0 +1,47 @@
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 @@
from typing import Iterable # ruff: noqa: ARG001
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 from ..fdmath import vec, unvec, vcfdfield, vfdfield, dx_lists_t
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) -> Iterable[float]: def omega(request: FixtureRequest) -> float:
yield request.param return request.param
@pytest.fixture(params=[None]) @pytest.fixture(params=[None])
def pec(request: FixtureRequest) -> Iterable[NDArray[numpy.float64] | None]: def pec(request: FixtureRequest) -> NDArray[numpy.float64] | None:
yield request.param return request.param
@pytest.fixture(params=[None]) @pytest.fixture(params=[None])
def pmc(request: FixtureRequest) -> Iterable[NDArray[numpy.float64] | None]: def pmc(request: FixtureRequest) -> NDArray[numpy.float64] | None:
yield request.param return request.param
#@pytest.fixture(scope='module', #@pytest.fixture(scope='module',
# params=[(25, 5, 5)]) # params=[(25, 5, 5)])
#def shape(request): #def shape(request: FixtureRequest):
# yield (3, *request.param) # return (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,
) -> Iterable[NDArray[numpy.float64]]: ) -> 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
yield j return j
@dataclasses.dataclass() @dataclasses.dataclass()
class FDResult: class FDResult:
shape: tuple[int, ...] shape: tuple[int, ...]
dxes: list[list[NDArray[numpy.float64]]] dxes: dx_lists_t
epsilon: NDArray[numpy.float64] epsilon: vfdfield
omega: complex omega: complex
j: NDArray[numpy.complex128] j: vcfdfield
e: NDArray[numpy.complex128] e: vcfdfield
pmc: NDArray[numpy.float64] | None pmc: vfdfield | None
pec: NDArray[numpy.float64] | None pec: vfdfield | 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={'atol': 1e-15, 'tol': 1e-11}, matrix_solver_opts = dict(atol=1e-15, rtol=1e-11),
) )
e = unvec(e_vec, shape[1:]) e = unvec(e_vec, shape[1:])

View file

@ -0,0 +1,213 @@
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

@ -0,0 +1,100 @@
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

@ -0,0 +1,122 @@
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 @@
from typing import Iterable # ruff: noqa: ARG001
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 from ..fdmath import vec, unvec, dx_lists_mut, vfdfield, cfdfield_t
#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,49 +44,51 @@ 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) -> Iterable[float]: def omega(request: FixtureRequest) -> float:
yield request.param return request.param
@pytest.fixture(params=[None]) @pytest.fixture(params=[None])
def pec(request: FixtureRequest) -> Iterable[NDArray[numpy.float64] | None]: def pec(request: FixtureRequest) -> NDArray[numpy.float64] | None:
yield request.param return request.param
@pytest.fixture(params=[None]) @pytest.fixture(params=[None])
def pmc(request: FixtureRequest) -> Iterable[NDArray[numpy.float64] | None]: def pmc(request: FixtureRequest) -> NDArray[numpy.float64] | None:
yield request.param return 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) -> Iterable[tuple[int, ...]]: def shape(request: FixtureRequest) -> tuple[int, int, int]:
yield (3, *request.param) return (3, *request.param)
@pytest.fixture(params=[+1, -1]) @pytest.fixture(params=[+1, -1])
def src_polarity(request: FixtureRequest) -> Iterable[int]: def src_polarity(request: FixtureRequest) -> int:
yield request.param return request.param
@pytest.fixture() @pytest.fixture
def j_distribution( def j_distribution(
request: FixtureRequest, request: FixtureRequest,
shape: tuple[int, ...], shape: tuple[int, ...],
epsilon: NDArray[numpy.float64], epsilon: vfdfield,
dxes: dx_lists_mut, dxes: dx_lists_mut,
omega: float, omega: float,
src_polarity: int, src_polarity: int,
) -> Iterable[NDArray[numpy.complex128]]: ) -> cfdfield_t:
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 = (dxes[0][dim][shape[dim + 1] // 2] dx_prop = (
+ dxes[1][dim][shape[dim + 1] // 2]) / 2 # noqa: E128 # TODO is this right for nonuniform dxes? dxes[0][dim][shape[dim + 1] // 2]
+ 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)
@ -106,18 +108,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)
yield j return 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,
) -> Iterable[NDArray[numpy.float64]]: ) -> NDArray[numpy.float64]:
epsilon = numpy.full(shape, epsilon_fg, dtype=float) epsilon = numpy.full(shape, epsilon_fg, dtype=float)
yield epsilon return epsilon
@pytest.fixture(params=['uniform']) @pytest.fixture(params=['uniform'])
@ -127,7 +129,7 @@ def dxes(
dx: float, dx: float,
omega: float, omega: float,
epsilon_fg: float, epsilon_fg: float,
) -> Iterable[list[list[NDArray[numpy.float64]]]]: ) -> 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
@ -141,10 +143,10 @@ def dxes(
epsilon_effective=epsilon_fg, epsilon_effective=epsilon_fg,
thickness=10, thickness=10,
) )
yield dxes return dxes
@pytest.fixture() @pytest.fixture
def sim( def sim(
request: FixtureRequest, request: FixtureRequest,
shape: tuple[int, ...], shape: tuple[int, ...],
@ -162,7 +164,7 @@ def sim(
omega=omega, omega=omega,
dxes=dxes, dxes=dxes,
epsilon=eps_vec, epsilon=eps_vec,
matrix_solver_opts={'atol': 1e-15, 'tol': 1e-11}, matrix_solver_opts={'atol': 1e-15, 'rtol': 1e-11},
) )
e = unvec(e_vec, shape[1:]) e = unvec(e_vec, shape[1:])

View file

@ -0,0 +1,128 @@
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

@ -0,0 +1,60 @@
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

@ -0,0 +1,90 @@
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

@ -0,0 +1,46 @@
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,4 +1,5 @@
from typing import Iterable, Any # ruff: noqa: ARG001
from typing import Any
import dataclasses import dataclasses
import pytest # type: ignore import pytest # type: ignore
import numpy import numpy
@ -6,7 +7,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, PRNG from .utils import assert_close, assert_fields_close, make_prng
from .conftest import FixtureRequest from .conftest import FixtureRequest
@ -150,8 +151,8 @@ def test_poynting_planes(sim: 'TDResult') -> None:
@pytest.fixture(params=[0.3]) @pytest.fixture(params=[0.3])
def dt(request: FixtureRequest) -> Iterable[float]: def dt(request: FixtureRequest) -> float:
yield request.param return request.param
@dataclasses.dataclass() @dataclasses.dataclass()
@ -168,8 +169,8 @@ class TDResult:
@pytest.fixture(params=[(0, 4, 8)]) # (0,) @pytest.fixture(params=[(0, 4, 8)]) # (0,)
def j_steps(request: FixtureRequest) -> Iterable[tuple[int, ...]]: def j_steps(request: FixtureRequest) -> tuple[int, ...]:
yield request.param return request.param
@pytest.fixture(params=['center', 'random']) @pytest.fixture(params=['center', 'random'])
@ -177,18 +178,19 @@ def j_distribution(
request: FixtureRequest, request: FixtureRequest,
shape: tuple[int, ...], shape: tuple[int, ...],
j_mag: float, j_mag: float,
) -> Iterable[NDArray[numpy.float64]]: ) -> 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)
yield j return j
@pytest.fixture() @pytest.fixture
def sim( def sim(
request: FixtureRequest, request: FixtureRequest,
shape: tuple[int, ...], shape: tuple[int, ...],
@ -199,8 +201,7 @@ 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: if is3d and dt != 0.3:
if dt != 0.3:
pytest.skip('Skipping dt != 0.3 because test is 3D (for speed)') pytest.skip('Skipping dt != 0.3 because test is 3D (for speed)')
sim = TDResult( sim = TDResult(

View file

@ -0,0 +1,43 @@
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)

View file

@ -0,0 +1,62 @@
import numpy
import pytest
from numpy.testing import assert_allclose
from ..fdtd.boundaries import conducting_boundary
def _axis_index(axis: int, index: int) -> tuple[slice | int, ...]:
coords: list[slice | int] = [slice(None), slice(None), slice(None)]
coords[axis] = index
return tuple(coords)
@pytest.mark.parametrize('direction', [0, 1, 2])
@pytest.mark.parametrize('polarity', [-1, 1])
def test_conducting_boundary_updates_expected_faces(direction: int, polarity: int) -> None:
e = numpy.arange(3 * 4 * 4 * 4, dtype=float).reshape(3, 4, 4, 4)
h = e.copy()
e0 = e.copy()
h0 = h.copy()
update_e, update_h = conducting_boundary(direction, polarity)
update_e(e)
update_h(h)
dirs = [0, 1, 2]
dirs.remove(direction)
u, v = dirs
if polarity < 0:
boundary = _axis_index(direction, 0)
shifted1 = _axis_index(direction, 1)
assert_allclose(e[direction][boundary], 0)
assert_allclose(e[u][boundary], e0[u][shifted1])
assert_allclose(e[v][boundary], e0[v][shifted1])
assert_allclose(h[direction][boundary], h0[direction][shifted1])
assert_allclose(h[u][boundary], 0)
assert_allclose(h[v][boundary], 0)
else:
boundary = _axis_index(direction, -1)
shifted1 = _axis_index(direction, -2)
shifted2 = _axis_index(direction, -3)
assert_allclose(e[direction][boundary], -e0[direction][shifted2])
assert_allclose(e[direction][shifted1], 0)
assert_allclose(e[u][boundary], e0[u][shifted1])
assert_allclose(e[v][boundary], e0[v][shifted1])
assert_allclose(h[direction][boundary], h0[direction][shifted1])
assert_allclose(h[u][boundary], -h0[u][shifted2])
assert_allclose(h[u][shifted1], 0)
assert_allclose(h[v][boundary], -h0[v][shifted2])
assert_allclose(h[v][shifted1], 0)
@pytest.mark.parametrize(
('direction', 'polarity'),
[(-1, 1), (3, 1), (0, 0)],
)
def test_conducting_boundary_rejects_invalid_arguments(direction: int, polarity: int) -> None:
with pytest.raises(ValueError, match='Invalid direction|Bad polarity'):
conducting_boundary(direction, polarity)

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