Compare commits
124 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf99f35f9b | ||
|
|
39291a8314 | ||
|
|
061c3f2e90 | ||
|
|
35fc67faa3 | ||
|
|
a1568a6f16 | ||
|
|
c6c9159b13 | ||
|
|
eec3fc28a7 | ||
|
|
010da1ccf5 | ||
|
|
1a2c6ab524 | ||
|
|
8b67696d7f | ||
|
|
f8ad0250d1 | ||
|
|
9a0c693848 | ||
|
|
bb920b8e33 | ||
|
|
ff278e6fa1 | ||
|
|
dc92d4a79d | ||
|
|
40efe7a450 | ||
|
|
e50637dc1c | ||
|
|
f7aa21a42a | ||
|
|
3e4aee1197 | ||
|
|
4b8a462df7 | ||
|
|
c0b41752e1 | ||
|
|
318c43d62d | ||
|
|
6f29dd89a8 | ||
| bedb338ac9 | |||
| a82eb5858a | |||
| 5e95d66a7e | |||
| 0568e1ba50 | |||
| d4c1082ca9 | |||
| d99ef96c96 | |||
| 8cdcd08ba0 | |||
| 267d161769 | |||
| 0afe2297b0 | |||
| 0ff23542ac | |||
| 9ac24892d6 | |||
| f3d13e1486 | |||
| e6756742be | |||
| 87bb3af3f9 | |||
| 07b16ad86a | |||
| f35b334100 | |||
| 593098bf8f | |||
| 38a5c1a9aa | |||
| bc55baf4a6 | |||
| 7eea919f94 | |||
| 74bebea837 | |||
| 8d49901b58 | |||
| 9d419aa3ea | |||
| 4913211883 | |||
| f5af0fef55 | |||
| 7e8ff23356 | |||
| be647658d3 | |||
| c46bed8298 | |||
| fb3bef23bf | |||
| d4f1008c5c | |||
| b486fa325b | |||
| b7ad5dea2b | |||
| 684b891e0f | |||
| 4a80ca8b12 | |||
| e3169b9e20 | |||
| 35ecbad15e | |||
| 43e01a814d | |||
| 9eb0e28bcb | |||
| c858b20d47 | |||
| 777ecbc024 | |||
| c4f8749941 | |||
| cd5cc9eb83 | |||
| 99e8d32eb1 | |||
| 1cb0cb2e4f | |||
| 234e8d7ac3 | |||
| 83f4d87ad8 | |||
| 1987ee473a | |||
| 4afc6cf62e | |||
| 53d5812b4a | |||
| 651e255704 | |||
| 71c2bbfada | |||
| 6a56921c12 | |||
| 006833acf2 | |||
| 155f30068f | |||
| 7987dc796f | |||
| 829007c672 | |||
| 659566750f | |||
| 76701f593c | |||
| 4e3a163522 | |||
| 50f92e1cc8 | |||
| b3c2fd391b | |||
| c543868c0b | |||
| e54735d9c6 | |||
| 4f2433320d | |||
| 47415a0beb | |||
| e459b5e61f | |||
| 36431cd0e4 | |||
| 739e96df3d | |||
| 63e7cb949f | |||
| c53a3c4d84 | |||
| 5dd9994e76 | |||
| 1021768e30 | |||
| 95e923d7b7 | |||
| 3f8802cb5f | |||
| 43bb0ba379 | |||
| e19968bb9f | |||
| 43f038d761 | |||
| d5fca741d1 | |||
| ca94ad1b25 | |||
| 10f26c12b4 | |||
| ee51c7db49 | |||
| 36bea6a593 | |||
| b16b35d84a | |||
| 6f3ae5a64f | |||
| 99c22d572f | |||
| 2f00baf0c6 | |||
| 2712d96f2a | |||
| dc3e733e7f | |||
| 95e3f71b40 | |||
| 639f88bba8 | |||
| ccfd4fbf04 | |||
| 77715da8b4 | |||
| 2d48858973 | |||
| 8c49710861 | |||
| 22565328ab | |||
| 4c8a07bf20 | |||
| b47dec0317 | |||
| 52d297bb31 | |||
| 7b4b2058bb | |||
| 950a5831ec | |||
| 91d89550a1 |
127 changed files with 11363 additions and 4628 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -54,6 +54,10 @@ coverage.xml
|
|||
|
||||
# documentation
|
||||
doc/
|
||||
site/
|
||||
_doc_mathimg/
|
||||
doc.md
|
||||
doc.htex
|
||||
|
||||
# PyBuilder
|
||||
target/
|
||||
|
|
|
|||
148
README.md
148
README.md
|
|
@ -56,6 +56,21 @@ linear systems, ideally with double precision.
|
|||
|
||||
Install from PyPI with pip:
|
||||
```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]'
|
||||
```
|
||||
|
||||
|
|
@ -80,9 +95,13 @@ source my_venv/bin/activate
|
|||
# Install in-place (-e, editable) from ./meanas, including development dependencies ([dev])
|
||||
pip3 install --user -e './meanas[dev]'
|
||||
|
||||
# Run tests
|
||||
# Fast local iteration: excludes slower 3D/integration/example-smoke checks
|
||||
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:
|
||||
|
|
@ -94,6 +113,125 @@ python3 -m pytest -rsxX | tee test_results.txt
|
|||
|
||||
## Use
|
||||
|
||||
See `examples/` for some simple examples; you may need additional
|
||||
packages such as [gridlock](https://mpxd.net/code/jan/gridlock)
|
||||
to run the examples.
|
||||
`meanas` is a collection of finite-difference electromagnetics tools:
|
||||
|
||||
- `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
3
docs/api/eigensolvers.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# eigensolvers
|
||||
|
||||
::: meanas.eigensolvers
|
||||
15
docs/api/fdfd.md
Normal file
15
docs/api/fdfd.md
Normal 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
13
docs/api/fdmath.md
Normal 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
15
docs/api/fdtd.md
Normal 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
14
docs/api/index.md
Normal 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
3
docs/api/meanas.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# meanas
|
||||
|
||||
::: meanas
|
||||
7
docs/api/waveguides.md
Normal file
7
docs/api/waveguides.md
Normal 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
1
docs/assets/vendor/mathjax/core.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
docs/assets/vendor/mathjax/input/asciimath.js
vendored
Normal file
1
docs/assets/vendor/mathjax/input/asciimath.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
docs/assets/vendor/mathjax/input/mml.js
vendored
Normal file
1
docs/assets/vendor/mathjax/input/mml.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
docs/assets/vendor/mathjax/input/mml/entities.js
vendored
Normal file
1
docs/assets/vendor/mathjax/input/mml/entities.js
vendored
Normal file
File diff suppressed because one or more lines are too long
34
docs/assets/vendor/mathjax/input/tex-full.js
vendored
Normal file
34
docs/assets/vendor/mathjax/input/tex-full.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
docs/assets/vendor/mathjax/loader.js
vendored
Normal file
1
docs/assets/vendor/mathjax/loader.js
vendored
Normal file
File diff suppressed because one or more lines are too long
38
docs/assets/vendor/mathjax/manifest.json
vendored
Normal file
38
docs/assets/vendor/mathjax/manifest.json
vendored
Normal 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"
|
||||
}
|
||||
1
docs/assets/vendor/mathjax/output/chtml.js
vendored
Normal file
1
docs/assets/vendor/mathjax/output/chtml.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
docs/assets/vendor/mathjax/output/chtml/fonts/tex.js
vendored
Normal file
1
docs/assets/vendor/mathjax/output/chtml/fonts/tex.js
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
docs/assets/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_AMS-Regular.woff
vendored
Normal file
BIN
docs/assets/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_AMS-Regular.woff
vendored
Normal file
Binary file not shown.
BIN
docs/assets/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Calligraphic-Bold.woff
vendored
Normal file
BIN
docs/assets/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Calligraphic-Bold.woff
vendored
Normal file
Binary file not shown.
BIN
docs/assets/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Calligraphic-Regular.woff
vendored
Normal file
BIN
docs/assets/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Calligraphic-Regular.woff
vendored
Normal file
Binary file not shown.
BIN
docs/assets/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Fraktur-Bold.woff
vendored
Normal file
BIN
docs/assets/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Fraktur-Bold.woff
vendored
Normal file
Binary file not shown.
BIN
docs/assets/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Fraktur-Regular.woff
vendored
Normal file
BIN
docs/assets/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Fraktur-Regular.woff
vendored
Normal file
Binary file not shown.
BIN
docs/assets/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Main-Bold.woff
vendored
Normal file
BIN
docs/assets/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Main-Bold.woff
vendored
Normal file
Binary file not shown.
BIN
docs/assets/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Main-Italic.woff
vendored
Normal file
BIN
docs/assets/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Main-Italic.woff
vendored
Normal file
Binary file not shown.
BIN
docs/assets/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Main-Regular.woff
vendored
Normal file
BIN
docs/assets/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Main-Regular.woff
vendored
Normal file
Binary file not shown.
BIN
docs/assets/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Math-BoldItalic.woff
vendored
Normal file
BIN
docs/assets/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Math-BoldItalic.woff
vendored
Normal file
Binary file not shown.
BIN
docs/assets/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Math-Italic.woff
vendored
Normal file
BIN
docs/assets/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Math-Italic.woff
vendored
Normal file
Binary file not shown.
BIN
docs/assets/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Math-Regular.woff
vendored
Normal file
BIN
docs/assets/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Math-Regular.woff
vendored
Normal file
Binary file not shown.
BIN
docs/assets/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_SansSerif-Bold.woff
vendored
Normal file
BIN
docs/assets/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_SansSerif-Bold.woff
vendored
Normal file
Binary file not shown.
BIN
docs/assets/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_SansSerif-Italic.woff
vendored
Normal file
BIN
docs/assets/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_SansSerif-Italic.woff
vendored
Normal file
Binary file not shown.
BIN
docs/assets/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_SansSerif-Regular.woff
vendored
Normal file
BIN
docs/assets/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_SansSerif-Regular.woff
vendored
Normal file
Binary file not shown.
BIN
docs/assets/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Script-Regular.woff
vendored
Normal file
BIN
docs/assets/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Script-Regular.woff
vendored
Normal file
Binary file not shown.
BIN
docs/assets/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Size1-Regular.woff
vendored
Normal file
BIN
docs/assets/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Size1-Regular.woff
vendored
Normal file
Binary file not shown.
BIN
docs/assets/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Size2-Regular.woff
vendored
Normal file
BIN
docs/assets/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Size2-Regular.woff
vendored
Normal file
Binary file not shown.
BIN
docs/assets/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Size3-Regular.woff
vendored
Normal file
BIN
docs/assets/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Size3-Regular.woff
vendored
Normal file
Binary file not shown.
BIN
docs/assets/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Size4-Regular.woff
vendored
Normal file
BIN
docs/assets/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Size4-Regular.woff
vendored
Normal file
Binary file not shown.
BIN
docs/assets/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Typewriter-Regular.woff
vendored
Normal file
BIN
docs/assets/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Typewriter-Regular.woff
vendored
Normal file
Binary file not shown.
BIN
docs/assets/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Vector-Bold.woff
vendored
Normal file
BIN
docs/assets/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Vector-Bold.woff
vendored
Normal file
Binary file not shown.
BIN
docs/assets/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Vector-Regular.woff
vendored
Normal file
BIN
docs/assets/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Vector-Regular.woff
vendored
Normal file
Binary file not shown.
BIN
docs/assets/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Zero.woff
vendored
Normal file
BIN
docs/assets/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Zero.woff
vendored
Normal file
Binary file not shown.
1
docs/assets/vendor/mathjax/startup.js
vendored
Normal file
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
61
docs/index.md
Normal 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`.
|
||||
19
docs/javascripts/mathjax.js
Normal file
19
docs/javascripts/mathjax.js
Normal 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));
|
||||
});
|
||||
64
docs/stylesheets/extra.css
Normal file
64
docs/stylesheets/extra.css
Normal 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
221
examples/eme.py
Normal 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
314
examples/eme_bend.py
Normal 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
103
examples/fdfd0.py
Normal 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()
|
||||
|
|
@ -1,10 +1,13 @@
|
|||
import importlib
|
||||
import logging
|
||||
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.fdmath import vec, unvec, fdfield_t
|
||||
from meanas.fdfd import waveguide_3d, functional, scpml, operators
|
||||
from meanas.fdfd.solvers import generic as generic_solver
|
||||
|
||||
|
|
@ -12,7 +15,6 @@ import gridlock
|
|||
|
||||
from matplotlib import pyplot
|
||||
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
logging.getLogger('matplotlib').setLevel(logging.WARNING)
|
||||
|
|
@ -20,82 +22,6 @@ logging.getLogger('matplotlib').setLevel(logging.WARNING)
|
|||
__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):
|
||||
dx = 40 # discretization (nm/cell)
|
||||
pml_thickness = 10 # (number of cells)
|
||||
|
|
@ -122,7 +48,7 @@ def test1(solver=generic_solver):
|
|||
# #### Create the grid and draw the device ####
|
||||
grid = gridlock.Grid(edge_coords)
|
||||
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()]
|
||||
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.visualize_isosurface(pmcg)
|
||||
|
||||
def pcolor(v) -> None:
|
||||
vmax = numpy.max(numpy.abs(v))
|
||||
pyplot.pcolor(v, cmap='seismic', vmin=-vmax, vmax=vmax)
|
||||
pyplot.axis('equal')
|
||||
pyplot.colorbar()
|
||||
|
||||
ss = (1, slice(None), J.shape[2]//2+6, slice(None))
|
||||
# pyplot.figure()
|
||||
# pcolor(J3[ss].T.imag)
|
||||
# pyplot.figure()
|
||||
# pcolor((numpy.abs(J3).sum(axis=2).sum(axis=0) > 0).astype(float).T)
|
||||
grid.visualize_slice(J.imag, plane=dict(y=6*dx), which_shifts=1, pcolormesh_args=dict(norm=colors.CenteredNorm(), cmap='bwr'))
|
||||
fig, ax = pyplot.subplots()
|
||||
ax.pcolormesh((numpy.abs(J).sum(axis=2).sum(axis=0) > 0).astype(float).T, cmap='hot')
|
||||
pyplot.show(block=True)
|
||||
|
||||
'''
|
||||
Solve!
|
||||
'''
|
||||
#
|
||||
# Solve!
|
||||
#
|
||||
sim_args = {
|
||||
'omega': omega,
|
||||
'dxes': dxes,
|
||||
|
|
@ -188,20 +106,18 @@ def test1(solver=generic_solver):
|
|||
|
||||
E = unvec(x, grid.shape)
|
||||
|
||||
'''
|
||||
Plot results
|
||||
'''
|
||||
#
|
||||
# Plot results
|
||||
#
|
||||
center = grid.pos2ind([0, 0, 0], None).astype(int)
|
||||
pyplot.figure()
|
||||
pyplot.subplot(2, 2, 1)
|
||||
pcolor(numpy.real(E[1][center[0], :, :]).T)
|
||||
pyplot.subplot(2, 2, 2)
|
||||
pyplot.plot(numpy.log10(numpy.abs(E[1][:, center[1], center[2]]) + 1e-10))
|
||||
pyplot.grid(alpha=0.6)
|
||||
pyplot.ylabel('log10 of field')
|
||||
pyplot.subplot(2, 2, 3)
|
||||
pcolor(numpy.real(E[1][:, :, center[2]]).T)
|
||||
pyplot.subplot(2, 2, 4)
|
||||
fig, axes = pyplot.subplots(2, 2)
|
||||
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'))
|
||||
grid.visualize_slice(E.real, plane=dict(z=0), which_shifts=1, ax=axes[0, 1], finalize=False, pcolormesh_args=dict(norm=colors.CenteredNorm(), cmap='bwr'))
|
||||
# pcolor(axes[0, 0], numpy.real(E[1][center[0], :, :]).T)
|
||||
# pcolor(axes[0, 1], numpy.real(E[1][:, :, center[2]]).T)
|
||||
axes[1, 0].plot(numpy.log10(numpy.abs(E[1][:, center[1], center[2]]) + 1e-10))
|
||||
axes[1, 0].grid(alpha=0.6)
|
||||
axes[1, 0].set_ylabel('log10 of field')
|
||||
|
||||
def poyntings(E):
|
||||
H = functional.e2h(omega, dxes)(E)
|
||||
|
|
@ -215,24 +131,28 @@ def test1(solver=generic_solver):
|
|||
return s0, s1, s2
|
||||
|
||||
s0x, s1x, s2x = poyntings(E)
|
||||
pyplot.plot(s0x[0].sum(axis=2).sum(axis=1), label='s0', marker='.')
|
||||
pyplot.plot(s1x[0].sum(axis=2).sum(axis=1), label='s1', marker='.')
|
||||
pyplot.plot(s2x[0].sum(axis=2).sum(axis=1), label='s2', marker='.')
|
||||
pyplot.plot(E[1][:, center[1], center[2]].real.T, label='Ey', marker='x')
|
||||
pyplot.grid(alpha=0.6)
|
||||
pyplot.legend()
|
||||
pyplot.show()
|
||||
ax = axes[1, 1]
|
||||
ax.plot(s0x[0].sum(axis=2).sum(axis=1), label='s0', marker='.')
|
||||
ax.plot(s1x[0].sum(axis=2).sum(axis=1), label='s1', marker='.')
|
||||
ax.plot(s2x[0].sum(axis=2).sum(axis=1), label='s2', marker='.')
|
||||
ax.plot(E[1][:, center[1], center[2]].real.T, label='Ey', marker='x')
|
||||
ax.grid(alpha=0.6)
|
||||
ax.legend()
|
||||
|
||||
p_in = (-E * J.conj()).sum() / 2 * (dx * dx * dx)
|
||||
print(f'{p_in=}')
|
||||
|
||||
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())]
|
||||
pyplot.figure()
|
||||
pyplot.plot(q, marker='.')
|
||||
pyplot.grid(alpha=0.6)
|
||||
pyplot.title('Overlap with mode')
|
||||
pyplot.show()
|
||||
print('Average overlap with mode:', sum(q)/len(q))
|
||||
q += [numpy.abs(vec(E).conj() @ vec(e_ovl_rolled))]
|
||||
fig, ax = pyplot.subplots()
|
||||
ax.plot(q, marker='.')
|
||||
ax.grid(alpha=0.6)
|
||||
ax.set_title('Overlap with mode')
|
||||
print('Average overlap with mode:', sum(q[8:32])/len(q[8:32]))
|
||||
|
||||
pyplot.show(block=True)
|
||||
|
||||
|
||||
def module_available(name):
|
||||
|
|
@ -240,9 +160,6 @@ def module_available(name):
|
|||
|
||||
|
||||
if __name__ == '__main__':
|
||||
#test0()
|
||||
# test1()
|
||||
|
||||
if module_available('opencl_fdfd'):
|
||||
from opencl_fdfd import cg_solver as opencl_solver
|
||||
test1(opencl_solver)
|
||||
|
|
@ -253,3 +170,4 @@ if __name__ == '__main__':
|
|||
# test1(magma_solver)
|
||||
else:
|
||||
test1()
|
||||
|
||||
157
examples/fdtd.py
157
examples/fdtd.py
|
|
@ -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 time
|
||||
import copy
|
||||
|
||||
import numpy
|
||||
import h5py
|
||||
from numpy.linalg import norm
|
||||
|
||||
from meanas import fdtd
|
||||
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 pcgen
|
||||
|
||||
|
|
@ -41,8 +53,7 @@ def perturbed_l3(a: float, radius: float, **kwargs) -> Pattern:
|
|||
`masque.Pattern` object containing the L3 design
|
||||
"""
|
||||
|
||||
default_args = {'hole_dose': 1,
|
||||
'trench_dose': 1,
|
||||
default_args = {
|
||||
'hole_layer': 0,
|
||||
'trench_layer': 1,
|
||||
'shifts_a': (0.15, 0, 0.075),
|
||||
|
|
@ -53,38 +64,39 @@ def perturbed_l3(a: float, radius: float, **kwargs) -> Pattern:
|
|||
}
|
||||
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'],
|
||||
shifts_a=kwargs['shifts_a'],
|
||||
shifts_r=kwargs['shifts_r'])
|
||||
shifts_r=kwargs['shifts_r'],
|
||||
)
|
||||
xyr *= a
|
||||
xyr[:, 2] *= radius
|
||||
|
||||
pat = Pattern()
|
||||
pat.name = f'L3p-a{a:g}r{radius:g}rp{kwargs["perturbed_radius"]:g}'
|
||||
pat.shapes += [shapes.Circle(radius=r, offset=(x, y),
|
||||
dose=kwargs['hole_dose'],
|
||||
layer=kwargs['hole_layer'])
|
||||
#pat.name = f'L3p-a{a:g}r{radius:g}rp{kwargs["perturbed_radius"]:g}'
|
||||
pat.shapes[(kwargs['hole_layer'], 0)] += [
|
||||
Circle(radius=r, offset=(x, y))
|
||||
for x, y, r in xyr]
|
||||
|
||||
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'],
|
||||
offset=(0, s * (maxes[1] + a + kwargs['trench_width'] / 2)),
|
||||
dose=kwargs['trench_dose'], layer=kwargs['trench_layer'])
|
||||
offset=(0, s * (maxes[1] + a + kwargs['trench_width'] / 2))
|
||||
)
|
||||
for s in (-1, 1)]
|
||||
return pat
|
||||
|
||||
|
||||
def main():
|
||||
def main() -> None:
|
||||
dtype = numpy.float32
|
||||
max_t = 8000 # number of timesteps
|
||||
max_t = 3600 # number of timesteps
|
||||
|
||||
dx = 40 # discretization (nm/cell)
|
||||
pml_thickness = 8 # (number of cells)
|
||||
|
||||
wl = 1550 # Excitation wavelength and fwhm
|
||||
dwl = 200
|
||||
|
||||
# Device design parameters
|
||||
xy_size = numpy.array([10, 10])
|
||||
|
|
@ -107,68 +119,97 @@ def main():
|
|||
|
||||
# #### Create the grid, mask, and draw the device ####
|
||||
grid = gridlock.Grid(edge_coords)
|
||||
epsilon = grid.allocate(n_air**2, dtype=dtype)
|
||||
grid.draw_slab(epsilon,
|
||||
surface_normal=2,
|
||||
center=[0, 0, 0],
|
||||
thickness=th,
|
||||
eps=n_slab**2)
|
||||
epsilon = grid.allocate(n_air ** 2, dtype=dtype)
|
||||
grid.draw_slab(
|
||||
epsilon,
|
||||
slab = dict(axis='z', center=0, span=th),
|
||||
foreground = n_slab ** 2,
|
||||
)
|
||||
|
||||
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,
|
||||
surface_normal=2,
|
||||
center=[0, 0, 0],
|
||||
thickness=2 * th,
|
||||
eps=n_air**2,
|
||||
polygons=mask.as_polygons())
|
||||
print(f'{grid.shape=}')
|
||||
|
||||
print(grid.shape)
|
||||
|
||||
dt = .99/numpy.sqrt(3)
|
||||
e = [numpy.zeros_like(epsilon[0], dtype=dtype) for _ in range(3)]
|
||||
h = [numpy.zeros_like(epsilon[0], dtype=dtype) for _ in range(3)]
|
||||
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=1.0**2)
|
||||
pml_params = [
|
||||
[cpml_params(axis=dd, polarity=pp, dt=dt, thickness=pml_thickness, epsilon_eff=n_air ** 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)
|
||||
update_E, update_H = updates_with_cpml(cpml_params=pml_params, dt=dt, dxes=dxes, epsilon=epsilon, dtype=complex)
|
||||
|
||||
# Source parameters and function
|
||||
w = 2 * numpy.pi * dx / wl
|
||||
fwhm = dwl * w * w / (2 * numpy.pi * dx)
|
||||
alpha = (fwhm ** 2) / 8 * numpy.log(2)
|
||||
delay = 7/numpy.sqrt(2 * alpha)
|
||||
# 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}')
|
||||
|
||||
def field_source(i):
|
||||
t0 = i * dt - delay
|
||||
return numpy.sin(w * t0) * numpy.exp(-alpha * t0**2)
|
||||
|
||||
# Build the pulse directly at the current half-steps and normalize that
|
||||
# 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 ####
|
||||
output_file = h5py.File('simulation_output.h5', 'w')
|
||||
start = time.perf_counter()
|
||||
for t in range(max_t):
|
||||
update_E(e, h, epsilon)
|
||||
for tt in range(max_t):
|
||||
update_E(ee, hh, epsilon)
|
||||
|
||||
e[1][tuple(grid.shape//2)] += field_source(t)
|
||||
update_H(e, h)
|
||||
# Electric-current injection uses E -= dt * J / epsilon, which is the
|
||||
# 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)
|
||||
|
||||
print('iteration {}: average {} iterations per sec'.format(t, (t+1)/(time.perf_counter()-start)))
|
||||
avg_rate = (tt + 1) / (time.perf_counter() - start)
|
||||
sys.stdout.flush()
|
||||
|
||||
if t % 20 == 0:
|
||||
r = sum([(f * f * e).sum() for f, e in zip(e, epsilon)])
|
||||
print('E sum', r)
|
||||
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 (t % 20 == 0 and (max_t - t <= 1000 or t <= 2000)) or t == max_t-1:
|
||||
print('saving E-field')
|
||||
for j, f in enumerate(e):
|
||||
output_file['/E{}_t{}'.format('xyz'[j], t)] = f[:, :, round(f.shape[2]/2)]
|
||||
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_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__':
|
||||
main()
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import numpy
|
|||
from numpy.linalg import norm
|
||||
|
||||
from meanas.fdmath import vec, unvec
|
||||
from meanas.fdfd import waveguide_mode, functional, scpml
|
||||
from meanas.fdfd import waveguide_cyl, functional, scpml
|
||||
from meanas.fdfd.solvers import generic as generic_solver
|
||||
|
||||
import gridlock
|
||||
|
|
@ -37,29 +37,34 @@ def test1(solver=generic_solver):
|
|||
xyz_max = numpy.array([800, y_max, z_max]) + (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]
|
||||
half_edge_coords = [numpy.arange(dx / 2, m + dx / 2, step=dx) for m in xyz_max]
|
||||
edge_coords = [numpy.hstack((-h[::-1], h)) for h in half_edge_coords]
|
||||
edge_coords[0] = numpy.array([-dx, dx])
|
||||
|
||||
# #### Create the grid and draw the device ####
|
||||
grid = gridlock.Grid(edge_coords)
|
||||
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, center=center, dimensions=[8e3, w, th], foreground=n_wg**2)
|
||||
|
||||
dxes = [grid.dxyz, grid.autoshifted_dxyz()]
|
||||
for a in (1, 2):
|
||||
for p in (-1, 1):
|
||||
dxes = scmpl.stretch_with_scpml(dxes, omega=omega, axis=a, polarity=p,
|
||||
thickness=pml_thickness)
|
||||
dxes = scpml.stretch_with_scpml(
|
||||
dxes,
|
||||
omega=omega,
|
||||
axis=a,
|
||||
polarity=p,
|
||||
thickness=pml_thickness,
|
||||
)
|
||||
|
||||
wg_args = {
|
||||
'omega': omega,
|
||||
'dxes': [(d[1], d[2]) for d in dxes],
|
||||
'epsilon': vec(g.transpose([1, 2, 0]) for g in epsilon),
|
||||
'epsilon': vec(epsilon.transpose([0, 2, 3, 1])),
|
||||
'r0': r0,
|
||||
}
|
||||
|
||||
wg_results = waveguide_mode.solve_waveguide_mode_cylindrical(mode_number=0, **wg_args)
|
||||
wg_results = waveguide_cyl.solve_mode(mode_number=0, **wg_args)
|
||||
|
||||
E = wg_results['E']
|
||||
|
||||
|
|
@ -70,20 +75,17 @@ def test1(solver=generic_solver):
|
|||
'''
|
||||
Plot results
|
||||
'''
|
||||
def pcolor(v):
|
||||
def pcolor(fig, ax, v, title):
|
||||
vmax = numpy.max(numpy.abs(v))
|
||||
pyplot.pcolor(v.T, cmap='seismic', vmin=-vmax, vmax=vmax)
|
||||
pyplot.axis('equal')
|
||||
pyplot.colorbar()
|
||||
mappable = ax.pcolormesh(v.T, cmap='seismic', vmin=-vmax, vmax=vmax)
|
||||
ax.set_aspect('equal', adjustable='box')
|
||||
ax.set_title(title)
|
||||
ax.figure.colorbar(mappable)
|
||||
|
||||
pyplot.figure()
|
||||
pyplot.subplot(2, 2, 1)
|
||||
pcolor(numpy.real(E[0][:, :]))
|
||||
pyplot.subplot(2, 2, 2)
|
||||
pcolor(numpy.real(E[1][:, :]))
|
||||
pyplot.subplot(2, 2, 3)
|
||||
pcolor(numpy.real(E[2][:, :]))
|
||||
pyplot.subplot(2, 2, 4)
|
||||
fig, axes = pyplot.subplots(2, 2)
|
||||
pcolor(fig, axes[0][0], numpy.real(E[0]), 'Ex')
|
||||
pcolor(fig, axes[0][1], numpy.real(E[1]), 'Ey')
|
||||
pcolor(fig, axes[1][0], numpy.real(E[2]), 'Ez')
|
||||
pyplot.show()
|
||||
|
||||
|
||||
|
|
|
|||
342
examples/waveguide.py
Normal file
342
examples/waveguide.py
Normal 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
219
examples/waveguide_real.py
Normal 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()
|
||||
26
make_docs.sh
26
make_docs.sh
|
|
@ -2,18 +2,20 @@
|
|||
|
||||
set -Eeuo pipefail
|
||||
|
||||
cd ~/projects/meanas
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
# Approach 1: pdf to html?
|
||||
#pdoc3 --pdf --force --template-dir pdoc_templates -o doc . | \
|
||||
# pandoc --metadata=title:"meanas" --toc --toc-depth=4 --from=markdown+abbreviations --to=html --output=doc.html --gladtex -s -
|
||||
DOCS_TMP="$(mktemp -d)"
|
||||
cleanup() {
|
||||
rm -rf "$DOCS_TMP"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Approach 2: pdf to html with gladtex
|
||||
rm -rf _doc_mathimg
|
||||
pdoc --pdf --force --template-dir pdoc_templates -o doc . > doc.md
|
||||
pandoc --metadata=title:"meanas" --from=markdown+abbreviations --to=html --output=doc.htex --gladtex -s --css pdoc_templates/pdoc.css doc.md
|
||||
gladtex -a -n -d _doc_mathimg -c white doc.htex
|
||||
python3 "$ROOT/scripts/prepare_docs_sources.py" "$ROOT/meanas" "$DOCS_TMP"
|
||||
|
||||
# Approach 3: html with gladtex
|
||||
#pdoc3 --html --force --template-dir pdoc_templates -o doc .
|
||||
#find doc -iname '*.html' -exec gladtex -a -n -d _mathimg -c white {} \;
|
||||
MKDOCSTRINGS_PYTHON_PATH="$DOCS_TMP" mkdocs build --clean
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,18 +1,19 @@
|
|||
"""
|
||||
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
|
||||
|
||||
__version__ = '0.8'
|
||||
__version__ = '0.12'
|
||||
__author__ = 'Jan Petykiewicz'
|
||||
|
||||
|
||||
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()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
"""
|
||||
Solvers for eigenvalue / eigenvector problems
|
||||
"""
|
||||
from typing import Callable
|
||||
from collections.abc import Callable
|
||||
import numpy
|
||||
from numpy.typing import NDArray, ArrayLike
|
||||
from numpy.linalg import norm
|
||||
from scipy import sparse # type: ignore
|
||||
import scipy.sparse.linalg as spalg # type: ignore
|
||||
from scipy import sparse
|
||||
import scipy.sparse.linalg as spalg
|
||||
|
||||
|
||||
def power_iteration(
|
||||
|
|
@ -25,8 +25,9 @@ def power_iteration(
|
|||
Returns:
|
||||
(Largest-magnitude eigenvalue, Corresponding eigenvector estimate)
|
||||
"""
|
||||
rng = numpy.random.default_rng()
|
||||
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:
|
||||
v = guess_vector
|
||||
|
||||
|
|
@ -63,10 +64,10 @@ def rayleigh_quotient_iteration(
|
|||
(eigenvalues, eigenvectors)
|
||||
"""
|
||||
try:
|
||||
(operator - sparse.eye(operator.shape[0]))
|
||||
(operator - sparse.eye_array(operator.shape[0]))
|
||||
|
||||
def shift(eigval: float) -> sparse:
|
||||
return eigval * sparse.eye(operator.shape[0])
|
||||
def shift(eigval: float) -> sparse.sparray:
|
||||
return eigval * sparse.eye_array(operator.shape[0])
|
||||
|
||||
if solver is None:
|
||||
solver = spalg.spsolve
|
||||
|
|
@ -129,12 +130,12 @@ def signed_eigensolve(
|
|||
|
||||
# Try to combine, use general LinearOperator if we fail
|
||||
try:
|
||||
shifted_operator = operator + shift * sparse.eye(operator.shape[0])
|
||||
shifted_operator = operator + shift * sparse.eye_array(operator.shape[0])
|
||||
except TypeError:
|
||||
shifted_operator = operator + spalg.LinearOperator(shape=operator.shape,
|
||||
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
|
||||
|
||||
k = eigenvalues.argsort()
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""
|
||||
r"""
|
||||
Tools for finite difference frequency-domain (FDFD) simulations and calculations.
|
||||
|
||||
These mostly involve picking a single frequency, then setting up and solving a
|
||||
|
|
@ -9,9 +9,12 @@ Submodules:
|
|||
|
||||
- `operators`, `functional`: General FDFD problem setup.
|
||||
- `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_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.
|
||||
|
||||
|
||||
================================================================
|
||||
|
|
@ -19,77 +22,80 @@ Submodules:
|
|||
From the "Frequency domain" section of `meanas.fdmath`, we have
|
||||
|
||||
$$
|
||||
\\begin{aligned}
|
||||
\\tilde{E}_{l, \\vec{r}} &= \\tilde{E}_{\\vec{r}} e^{-\\imath \\omega l \\Delta_t} \\\\
|
||||
\\tilde{H}_{l - \\frac{1}{2}, \\vec{r} + \\frac{1}{2}} &= \\tilde{H}_{\\vec{r} + \\frac{1}{2}} e^{-\\imath \\omega (l - \\frac{1}{2}) \\Delta_t} \\\\
|
||||
\\tilde{J}_{l, \\vec{r}} &= \\tilde{J}_{\\vec{r}} e^{-\\imath \\omega (l - \\frac{1}{2}) \\Delta_t} \\\\
|
||||
\\tilde{M}_{l - \\frac{1}{2}, \\vec{r} + \\frac{1}{2}} &= \\tilde{M}_{\\vec{r} + \\frac{1}{2}} e^{-\\imath \\omega l \\Delta_t} \\\\
|
||||
\\hat{\\nabla} \\times (\\mu^{-1}_{\\vec{r} + \\frac{1}{2}} \\cdot \\tilde{\\nabla} \\times \\tilde{E}_{\\vec{r}})
|
||||
-\\Omega^2 \\epsilon_{\\vec{r}} \\cdot \\tilde{E}_{\\vec{r}} &= -\\imath \\Omega \\tilde{J}_{\\vec{r}} e^{\\imath \\omega \\Delta_t / 2} \\\\
|
||||
\\Omega &= 2 \\sin(\\omega \\Delta_t / 2) / \\Delta_t
|
||||
\\end{aligned}
|
||||
\begin{aligned}
|
||||
\tilde{E}_{l, \vec{r}} &= \tilde{E}_{\vec{r}} e^{-\imath \omega l \Delta_t} \\
|
||||
\tilde{H}_{l - \frac{1}{2}, \vec{r} + \frac{1}{2}} &= \tilde{H}_{\vec{r} + \frac{1}{2}} e^{-\imath \omega (l - \frac{1}{2}) \Delta_t} \\
|
||||
\tilde{J}_{l, \vec{r}} &= \tilde{J}_{\vec{r}} e^{-\imath \omega (l - \frac{1}{2}) \Delta_t} \\
|
||||
\tilde{M}_{l - \frac{1}{2}, \vec{r} + \frac{1}{2}} &= \tilde{M}_{\vec{r} + \frac{1}{2}} e^{-\imath \omega l \Delta_t} \\
|
||||
\hat{\nabla} \times (\mu^{-1}_{\vec{r} + \frac{1}{2}} \cdot \tilde{\nabla} \times \tilde{E}_{\vec{r}})
|
||||
-\Omega^2 \epsilon_{\vec{r}} \cdot \tilde{E}_{\vec{r}} &= -\imath \Omega \tilde{J}_{\vec{r}} e^{\imath \omega \Delta_t / 2} \\
|
||||
\Omega &= 2 \sin(\omega \Delta_t / 2) / \Delta_t
|
||||
\end{aligned}
|
||||
$$
|
||||
|
||||
resulting in
|
||||
|
||||
$$
|
||||
\\begin{aligned}
|
||||
\\tilde{\\partial}_t &\\Rightarrow -\\imath \\Omega e^{-\\imath \\omega \\Delta_t / 2}\\\\
|
||||
\\hat{\\partial}_t &\\Rightarrow -\\imath \\Omega e^{ \\imath \\omega \\Delta_t / 2}\\\\
|
||||
\\end{aligned}
|
||||
\begin{aligned}
|
||||
\tilde{\partial}_t &\Rightarrow -\imath \Omega e^{-\imath \omega \Delta_t / 2}\\
|
||||
\hat{\partial}_t &\Rightarrow -\imath \Omega e^{ \imath \omega \Delta_t / 2}\\
|
||||
\end{aligned}
|
||||
$$
|
||||
|
||||
Maxwell's equations are then
|
||||
|
||||
$$
|
||||
\\begin{aligned}
|
||||
\\tilde{\\nabla} \\times \\tilde{E}_{\\vec{r}} &=
|
||||
\\imath \\Omega e^{-\\imath \\omega \\Delta_t / 2} \\hat{B}_{\\vec{r} + \\frac{1}{2}}
|
||||
- \\hat{M}_{\\vec{r} + \\frac{1}{2}} \\\\
|
||||
\\hat{\\nabla} \\times \\hat{H}_{\\vec{r} + \\frac{1}{2}} &=
|
||||
-\\imath \\Omega e^{ \\imath \\omega \\Delta_t / 2} \\tilde{D}_{\\vec{r}}
|
||||
+ \\tilde{J}_{\\vec{r}} \\\\
|
||||
\\tilde{\\nabla} \\cdot \\hat{B}_{\\vec{r} + \\frac{1}{2}} &= 0 \\\\
|
||||
\\hat{\\nabla} \\cdot \\tilde{D}_{\\vec{r}} &= \\rho_{\\vec{r}}
|
||||
\\end{aligned}
|
||||
\begin{aligned}
|
||||
\tilde{\nabla} \times \tilde{E}_{\vec{r}} &=
|
||||
\imath \Omega e^{-\imath \omega \Delta_t / 2} \hat{B}_{\vec{r} + \frac{1}{2}}
|
||||
- \hat{M}_{\vec{r} + \frac{1}{2}} \\
|
||||
\hat{\nabla} \times \hat{H}_{\vec{r} + \frac{1}{2}} &=
|
||||
-\imath \Omega e^{ \imath \omega \Delta_t / 2} \tilde{D}_{\vec{r}}
|
||||
+ \tilde{J}_{\vec{r}} \\
|
||||
\tilde{\nabla} \cdot \hat{B}_{\vec{r} + \frac{1}{2}} &= 0 \\
|
||||
\hat{\nabla} \cdot \tilde{D}_{\vec{r}} &= \rho_{\vec{r}}
|
||||
\end{aligned}
|
||||
$$
|
||||
|
||||
With $\\Delta_t \\to 0$, this simplifies to
|
||||
With $\Delta_t \to 0$, this simplifies to
|
||||
|
||||
$$
|
||||
\\begin{aligned}
|
||||
\\tilde{E}_{l, \\vec{r}} &\\to \\tilde{E}_{\\vec{r}} \\\\
|
||||
\\tilde{H}_{l - \\frac{1}{2}, \\vec{r} + \\frac{1}{2}} &\\to \\tilde{H}_{\\vec{r} + \\frac{1}{2}} \\\\
|
||||
\\tilde{J}_{l, \\vec{r}} &\\to \\tilde{J}_{\\vec{r}} \\\\
|
||||
\\tilde{M}_{l - \\frac{1}{2}, \\vec{r} + \\frac{1}{2}} &\\to \\tilde{M}_{\\vec{r} + \\frac{1}{2}} \\\\
|
||||
\\Omega &\\to \\omega \\\\
|
||||
\\tilde{\\partial}_t &\\to -\\imath \\omega \\\\
|
||||
\\hat{\\partial}_t &\\to -\\imath \\omega \\\\
|
||||
\\end{aligned}
|
||||
\begin{aligned}
|
||||
\tilde{E}_{l, \vec{r}} &\to \tilde{E}_{\vec{r}} \\
|
||||
\tilde{H}_{l - \frac{1}{2}, \vec{r} + \frac{1}{2}} &\to \tilde{H}_{\vec{r} + \frac{1}{2}} \\
|
||||
\tilde{J}_{l, \vec{r}} &\to \tilde{J}_{\vec{r}} \\
|
||||
\tilde{M}_{l - \frac{1}{2}, \vec{r} + \frac{1}{2}} &\to \tilde{M}_{\vec{r} + \frac{1}{2}} \\
|
||||
\Omega &\to \omega \\
|
||||
\tilde{\partial}_t &\to -\imath \omega \\
|
||||
\hat{\partial}_t &\to -\imath \omega \\
|
||||
\end{aligned}
|
||||
$$
|
||||
|
||||
and then
|
||||
|
||||
$$
|
||||
\\begin{aligned}
|
||||
\\tilde{\\nabla} \\times \\tilde{E}_{\\vec{r}} &=
|
||||
\\imath \\omega \\hat{B}_{\\vec{r} + \\frac{1}{2}}
|
||||
- \\hat{M}_{\\vec{r} + \\frac{1}{2}} \\\\
|
||||
\\hat{\\nabla} \\times \\hat{H}_{\\vec{r} + \\frac{1}{2}} &=
|
||||
-\\imath \\omega \\tilde{D}_{\\vec{r}}
|
||||
+ \\tilde{J}_{\\vec{r}} \\\\
|
||||
\\end{aligned}
|
||||
\begin{aligned}
|
||||
\tilde{\nabla} \times \tilde{E}_{\vec{r}} &=
|
||||
\imath \omega \hat{B}_{\vec{r} + \frac{1}{2}}
|
||||
- \hat{M}_{\vec{r} + \frac{1}{2}} \\
|
||||
\hat{\nabla} \times \hat{H}_{\vec{r} + \frac{1}{2}} &=
|
||||
-\imath \omega \tilde{D}_{\vec{r}}
|
||||
+ \tilde{J}_{\vec{r}} \\
|
||||
\end{aligned}
|
||||
$$
|
||||
|
||||
$$
|
||||
\\hat{\\nabla} \\times (\\mu^{-1}_{\\vec{r} + \\frac{1}{2}} \\cdot \\tilde{\\nabla} \\times \\tilde{E}_{\\vec{r}})
|
||||
-\\omega^2 \\epsilon_{\\vec{r}} \\cdot \\tilde{E}_{\\vec{r}} = -\\imath \\omega \\tilde{J}_{\\vec{r}} \\\\
|
||||
\hat{\nabla} \times (\mu^{-1}_{\vec{r} + \frac{1}{2}} \cdot \tilde{\nabla} \times \tilde{E}_{\vec{r}})
|
||||
-\omega^2 \epsilon_{\vec{r}} \cdot \tilde{E}_{\vec{r}} = -\imath \omega \tilde{J}_{\vec{r}} \\
|
||||
$$
|
||||
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -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 numpy
|
||||
from numpy import pi, real, trace
|
||||
from numpy.fft import fftfreq
|
||||
from numpy.typing import NDArray, ArrayLike
|
||||
import scipy # type: ignore
|
||||
import scipy.optimize # type: ignore
|
||||
from scipy.linalg import norm # type: ignore
|
||||
import scipy.sparse.linalg as spalg # type: ignore
|
||||
import scipy
|
||||
import scipy.optimize
|
||||
from scipy.linalg import norm
|
||||
import scipy.sparse.linalg as spalg
|
||||
|
||||
from ..fdmath import fdfield_t, cfdfield_t
|
||||
from ..fdmath import fdfield, cfdfield, cfdfield_t
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -114,7 +115,6 @@ logger = logging.getLogger(__name__)
|
|||
try:
|
||||
import pyfftw.interfaces.numpy_fft # type: ignore
|
||||
import pyfftw.interfaces # type: ignore
|
||||
import multiprocessing
|
||||
logger.info('Using pyfftw')
|
||||
|
||||
pyfftw.interfaces.cache.enable()
|
||||
|
|
@ -136,6 +136,14 @@ except ImportError:
|
|||
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(
|
||||
k0: 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)`).
|
||||
"""
|
||||
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 = numpy.moveaxis(Gi_grids, 0, -1)
|
||||
|
|
@ -183,8 +191,8 @@ def generate_kmn(
|
|||
def maxwell_operator(
|
||||
k0: ArrayLike,
|
||||
G_matrix: ArrayLike,
|
||||
epsilon: fdfield_t,
|
||||
mu: fdfield_t | None = None
|
||||
epsilon: fdfield,
|
||||
mu: fdfield | None = None
|
||||
) -> Callable[[NDArray[numpy.complex128]], NDArray[numpy.complex128]]:
|
||||
"""
|
||||
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
|
||||
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
|
||||
|
||||
# cross product and transform into xyz basis
|
||||
d_xyz = (n * hin_m
|
||||
- m * hin_n) * k_mag # noqa: E128
|
||||
- m * hin_n) * k_mag
|
||||
|
||||
# divide by epsilon
|
||||
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
|
||||
else:
|
||||
# transform from mn to xyz
|
||||
b_xyz = (m * b_m[:, :, :, None]
|
||||
+ n * b_n[:, :, :, None]) # noqa: E128
|
||||
b_xyz = (m * b_m
|
||||
+ n * b_n) # noqa
|
||||
|
||||
# divide by mu
|
||||
temp = ifftn(b_xyz, axes=range(3))
|
||||
|
|
@ -265,10 +273,7 @@ def maxwell_operator(
|
|||
h_m = numpy.sum(h_xyz * m, axis=3)
|
||||
h_n = numpy.sum(h_xyz * n, axis=3)
|
||||
|
||||
h.shape = (h.size,)
|
||||
h = numpy.concatenate((h_m.ravel(), h_n.ravel()), axis=None, out=h) # ravel and merge
|
||||
h.shape = (h.size, 1)
|
||||
return h
|
||||
return _assemble_hmn_vector(h_m, h_n)
|
||||
|
||||
return operator
|
||||
|
||||
|
|
@ -276,7 +281,7 @@ def maxwell_operator(
|
|||
def hmn_2_exyz(
|
||||
k0: ArrayLike,
|
||||
G_matrix: ArrayLike,
|
||||
epsilon: fdfield_t,
|
||||
epsilon: fdfield,
|
||||
) -> Callable[[NDArray[numpy.complex128]], cfdfield_t]:
|
||||
"""
|
||||
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)
|
||||
|
||||
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
|
||||
- m * hin_n) * k_mag # noqa: E128
|
||||
- m * hin_n) * k_mag
|
||||
|
||||
# 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
|
||||
|
||||
|
|
@ -316,7 +322,7 @@ def hmn_2_exyz(
|
|||
def hmn_2_hxyz(
|
||||
k0: ArrayLike,
|
||||
G_matrix: ArrayLike,
|
||||
epsilon: fdfield_t
|
||||
epsilon: fdfield,
|
||||
) -> Callable[[NDArray[numpy.complex128]], cfdfield_t]:
|
||||
"""
|
||||
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)
|
||||
|
||||
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
|
||||
+ n * hin_n) # noqa: E128
|
||||
return numpy.array([ifftn(hi) for hi in numpy.moveaxis(h_xyz, 3, 0)])
|
||||
+ n * hin_n)
|
||||
return cfdfield_t(numpy.array([ifftn(hi) for hi in numpy.moveaxis(h_xyz, 3, 0)]))
|
||||
|
||||
return operator
|
||||
|
||||
|
|
@ -352,8 +358,8 @@ def hmn_2_hxyz(
|
|||
def inverse_maxwell_operator_approx(
|
||||
k0: ArrayLike,
|
||||
G_matrix: ArrayLike,
|
||||
epsilon: fdfield_t,
|
||||
mu: fdfield_t | None = None,
|
||||
epsilon: fdfield,
|
||||
mu: fdfield | None = None,
|
||||
) -> Callable[[NDArray[numpy.complex128]], NDArray[numpy.complex128]]:
|
||||
"""
|
||||
Generate an approximate inverse of the Maxwell operator,
|
||||
|
|
@ -394,7 +400,7 @@ def inverse_maxwell_operator_approx(
|
|||
Returns:
|
||||
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
|
||||
|
||||
|
|
@ -402,8 +408,8 @@ def inverse_maxwell_operator_approx(
|
|||
b_m, b_n = hin_m, hin_n
|
||||
else:
|
||||
# transform from mn to xyz
|
||||
h_xyz = (m * hin_m[:, :, :, None]
|
||||
+ n * hin_n[:, :, :, None]) # noqa: E128
|
||||
h_xyz = (m * hin_m
|
||||
+ n * hin_n) # noqa
|
||||
|
||||
# multiply by mu
|
||||
temp = ifftn(h_xyz, axes=range(3))
|
||||
|
|
@ -411,12 +417,12 @@ def inverse_maxwell_operator_approx(
|
|||
b_xyz = fftn(temp, axes=range(3))
|
||||
|
||||
# transform back to mn
|
||||
b_m = numpy.sum(b_xyz * m, axis=3)
|
||||
b_n = numpy.sum(b_xyz * n, axis=3)
|
||||
b_m = numpy.sum(b_xyz * m, axis=3, keepdims=True)
|
||||
b_n = numpy.sum(b_xyz * n, axis=3, keepdims=True)
|
||||
|
||||
# cross product and transform into xyz basis
|
||||
e_xyz = (n * b_m
|
||||
- m * b_n) / k_mag # noqa: E128
|
||||
- m * b_n) / k_mag
|
||||
|
||||
# multiply by epsilon
|
||||
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_n = numpy.sum(d_xyz * m, axis=3, keepdims=True) / -k_mag
|
||||
|
||||
h.shape = (h.size,)
|
||||
h = numpy.concatenate((h_m, h_n), axis=None, out=h)
|
||||
h.shape = (h.size, 1)
|
||||
return h
|
||||
return _assemble_hmn_vector(h_m, h_n)
|
||||
|
||||
return operator
|
||||
|
||||
|
|
@ -440,15 +443,15 @@ def find_k(
|
|||
tolerance: float,
|
||||
direction: ArrayLike,
|
||||
G_matrix: ArrayLike,
|
||||
epsilon: fdfield_t,
|
||||
mu: fdfield_t | None = None,
|
||||
epsilon: fdfield,
|
||||
mu: fdfield | None = None,
|
||||
band: int = 0,
|
||||
k_bounds: tuple[float, float] = (0, 0.5),
|
||||
k_guess: float | None = None,
|
||||
solve_callback: Callable[..., None] | None = None,
|
||||
iter_callback: Callable[..., None] | 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.
|
||||
|
||||
|
|
@ -471,7 +474,7 @@ def find_k(
|
|||
`(k, actual_frequency, eigenvalues, 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...
|
||||
assert len(k_bounds) == 2
|
||||
|
|
@ -493,23 +496,23 @@ def find_k(
|
|||
|
||||
res = scipy.optimize.minimize_scalar(
|
||||
lambda x: abs(get_f(x, band) - frequency),
|
||||
k_guess,
|
||||
method='Bounded',
|
||||
method='bounded',
|
||||
bounds=k_bounds,
|
||||
options={'xatol': abs(tolerance)},
|
||||
)
|
||||
|
||||
assert n 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(
|
||||
num_modes: int,
|
||||
k0: ArrayLike,
|
||||
G_matrix: ArrayLike,
|
||||
epsilon: fdfield_t,
|
||||
mu: fdfield_t | None = None,
|
||||
epsilon: fdfield,
|
||||
mu: fdfield | None = None,
|
||||
tolerance: float = 1e-7,
|
||||
max_iters: int = 10000,
|
||||
reset_iters: int = 100,
|
||||
|
|
@ -538,7 +541,7 @@ def eigsolve(
|
|||
`(eigenvalues, eigenvectors)` where `eigenvalues[i]` corresponds to the
|
||||
vector `eigenvectors[i, :]`
|
||||
"""
|
||||
k0 = numpy.array(k0, copy=False)
|
||||
k0 = numpy.asarray(k0)
|
||||
|
||||
h_size = 2 * epsilon[0].size
|
||||
|
||||
|
|
@ -561,11 +564,12 @@ def eigsolve(
|
|||
prev_theta = 0.5
|
||||
D = numpy.zeros(shape=y_shape, dtype=complex)
|
||||
|
||||
rng = numpy.random.default_rng()
|
||||
Z: NDArray[numpy.complex128]
|
||||
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:
|
||||
Z = numpy.array(y0, copy=False).T
|
||||
Z = numpy.asarray(y0).T
|
||||
|
||||
while True:
|
||||
Z *= num_modes / norm(Z)
|
||||
|
|
@ -573,7 +577,7 @@ def eigsolve(
|
|||
try:
|
||||
U = numpy.linalg.inv(ZtZ)
|
||||
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
|
||||
|
||||
trace_U = real(trace(U))
|
||||
|
|
@ -646,17 +650,16 @@ def eigsolve(
|
|||
|
||||
Qi_memo: list[float | None] = [None, None]
|
||||
|
||||
def Qi_func(theta: float) -> float:
|
||||
nonlocal Qi_memo
|
||||
def Qi_func(theta: float, Qi_memo=Qi_memo, ZtZ=ZtZ, DtD=DtD, symZtD=symZtD) -> float: # noqa: ANN001
|
||||
if Qi_memo[0] == theta:
|
||||
return cast(float, Qi_memo[1])
|
||||
return cast('float', Qi_memo[1])
|
||||
|
||||
c = numpy.cos(theta)
|
||||
s = numpy.sin(theta)
|
||||
Q = c * c * ZtZ + s * s * DtD + 2 * s * c * symZtD
|
||||
try:
|
||||
Qi = numpy.linalg.inv(Q)
|
||||
except numpy.linalg.LinAlgError:
|
||||
except numpy.linalg.LinAlgError as err:
|
||||
logger.info('taylor Qi')
|
||||
# if c or s small, taylor expand
|
||||
if c < 1e-4 * s and c != 0:
|
||||
|
|
@ -666,12 +669,12 @@ def eigsolve(
|
|||
ZtZi = numpy.linalg.inv(ZtZ)
|
||||
Qi = ZtZi / (c * c) - 2 * s / (c * c * c) * (ZtZi @ (ZtZi @ symZtD).conj().T)
|
||||
else:
|
||||
raise Exception('Inexplicable singularity in trace_func')
|
||||
raise Exception('Inexplicable singularity in trace_func') from err
|
||||
Qi_memo[0] = theta
|
||||
Qi_memo[1] = cast(float, Qi)
|
||||
return cast(float, Qi)
|
||||
Qi_memo[1] = 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)
|
||||
s = numpy.sin(theta)
|
||||
Qi = Qi_func(theta)
|
||||
|
|
@ -680,15 +683,24 @@ def eigsolve(
|
|||
return numpy.abs(trace)
|
||||
|
||||
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)
|
||||
c2 = numpy.cos(2 * theta)
|
||||
s2 = numpy.sin(2 * theta)
|
||||
F = -0.5*s2 * (ZtAZ - DtAD) + c2 * symZtAD
|
||||
F = -0.5 * s2 * (ZtAZ - DtAD) + c2 * symZtAD
|
||||
trace_deriv = _rtrace_AtB(Qi, F)
|
||||
|
||||
G = Qi @ F.conj().T @ Qi.conj().T
|
||||
H = -0.5*s2 * (ZtZ - DtD) + c2 * symZtD
|
||||
H = -0.5 * s2 * (ZtZ - DtD) + c2 * symZtD
|
||||
trace_deriv -= _rtrace_AtB(G, H)
|
||||
|
||||
trace_deriv *= 2
|
||||
|
|
@ -696,12 +708,12 @@ def eigsolve(
|
|||
|
||||
U_sZtD = U @ symZtD
|
||||
|
||||
dE = 2.0 * (_rtrace_AtB(U, symZtAD) -
|
||||
_rtrace_AtB(ZtAZU, U_sZtD))
|
||||
dE = 2.0 * (_rtrace_AtB(U, symZtAD)
|
||||
- _rtrace_AtB(ZtAZU, U_sZtD))
|
||||
|
||||
d2E = 2 * (_rtrace_AtB(U, DtAD) -
|
||||
_rtrace_AtB(ZtAZU, U @ (DtD - 4 * symZtD @ U_sZtD)) -
|
||||
4 * _rtrace_AtB(U, symZtAD @ U_sZtD))
|
||||
d2E = 2 * (_rtrace_AtB(U, DtAD)
|
||||
- _rtrace_AtB(ZtAZU, U @ (DtD - 4 * symZtD @ U_sZtD))
|
||||
- 4 * _rtrace_AtB(U, symZtAD @ U_sZtD))
|
||||
|
||||
# Newton-Raphson to find a root of the first derivative:
|
||||
theta = -dE / d2E
|
||||
|
|
@ -722,7 +734,12 @@ def eigsolve(
|
|||
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
|
||||
theta = result.x
|
||||
|
||||
|
|
@ -751,7 +768,7 @@ def eigsolve(
|
|||
v = eigvecs[:, i]
|
||||
n = eigvals[i]
|
||||
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)
|
||||
f = numpy.sqrt(-numpy.real(n))
|
||||
df = numpy.sqrt(-numpy.real(n) + eigness)
|
||||
|
|
@ -781,7 +798,7 @@ def linmin(x_guess, f0, df0, x_max, f_tol=0.1, df_tol=min(tolerance, 1e-6), x_to
|
|||
x_min, x_max, isave, dsave)
|
||||
for i in range(int(1e6)):
|
||||
if task != 'F':
|
||||
logging.info('search converged in {} iterations'.format(i))
|
||||
logging.info(f'search converged in {i} iterations')
|
||||
break
|
||||
fx = f(x, dfx)
|
||||
x, fx, dfx, task = minpack2.dsrch(x, fx, dfx, f_tol, df_tol, x_tol, task,
|
||||
|
|
@ -799,3 +816,62 @@ def _rtrace_AtB(
|
|||
def _symmetrize(A: NDArray[numpy.complex128]) -> NDArray[numpy.complex128]:
|
||||
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
190
meanas/fdfd/eme.py
Normal 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
|
||||
|
|
@ -1,20 +1,24 @@
|
|||
"""
|
||||
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
|
||||
from numpy.fft import fft2, fftshift, fftfreq, ifft2, ifftshift
|
||||
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(
|
||||
E_near: cfdfield_t,
|
||||
H_near: cfdfield_t,
|
||||
E_near: transverse_slice_pair,
|
||||
H_near: transverse_slice_pair,
|
||||
dx: float,
|
||||
dy: float,
|
||||
padded_size: list[int] | int | None = None
|
||||
padded_size: Sequence[int] | int | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
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')
|
||||
|
||||
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!')
|
||||
|
||||
if padded_size is None:
|
||||
padded_size = (2**numpy.ceil(numpy.log2(s))).astype(int)
|
||||
if not hasattr(padded_size, '__len__'):
|
||||
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]
|
||||
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')
|
||||
kxy2 = kx * kx + ky * ky
|
||||
kxy = numpy.sqrt(kxy2)
|
||||
kz = numpy.sqrt(k * k - kxy2)
|
||||
kz = numpy.sqrt(numpy.maximum(0, k * k - kxy2))
|
||||
|
||||
sin_th = ky / kxy
|
||||
cos_th = kx / kxy
|
||||
sin_th = numpy.divide(ky, kxy, out=numpy.zeros_like(ky), where=kxy != 0)
|
||||
cos_th = numpy.divide(kx, kxy, out=numpy.ones_like(kx), where=kxy != 0)
|
||||
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
|
||||
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,
|
||||
-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],
|
||||
L[0] - N[1]] # noqa: E127
|
||||
L[0] - N[1]] # noqa
|
||||
H_far = [-E_far[1],
|
||||
E_far[0]] # noqa: E127
|
||||
E_far[0]] # noqa
|
||||
|
||||
theta = numpy.arctan2(ky, kx)
|
||||
phi = numpy.arccos(cos_phi)
|
||||
|
|
@ -111,8 +112,8 @@ def near_to_farfield(
|
|||
outputs = {
|
||||
'E': E_far,
|
||||
'H': H_far,
|
||||
'dkx': kx[1] - kx[0],
|
||||
'dky': ky[1] - ky[0],
|
||||
'dkx': float(kxx[1] - kxx[0]),
|
||||
'dky': float(kyy[1] - kyy[0]),
|
||||
'kx': kx,
|
||||
'ky': ky,
|
||||
'theta': theta,
|
||||
|
|
@ -123,11 +124,11 @@ def near_to_farfield(
|
|||
|
||||
|
||||
def far_to_nearfield(
|
||||
E_far: cfdfield_t,
|
||||
H_far: cfdfield_t,
|
||||
E_far: transverse_slice_pair,
|
||||
H_far: transverse_slice_pair,
|
||||
dkx: float,
|
||||
dky: float,
|
||||
padded_size: list[int] | int | None = None
|
||||
padded_size: Sequence[int] | int | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
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')
|
||||
|
||||
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!')
|
||||
|
||||
if padded_size is None:
|
||||
padded_size = (2 ** numpy.ceil(numpy.log2(s))).astype(int)
|
||||
if not hasattr(padded_size, '__len__'):
|
||||
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
|
||||
kxs = fftshift(fftfreq(s[0], 1 / (s[0] * dkx)))
|
||||
kys = fftshift(fftfreq(s[0], 1 / (s[1] * dky)))
|
||||
kxs = dkx * fftshift(fftfreq(s[0], d=1 / s[0]))
|
||||
kys = dky * fftshift(fftfreq(s[1], d=1 / s[1]))
|
||||
|
||||
kx, ky = numpy.meshgrid(kxs, kys, indexing='ij')
|
||||
kxy2 = kx * kx + ky * ky
|
||||
kxy = numpy.sqrt(kxy2)
|
||||
|
||||
kz = numpy.sqrt(k * k - kxy2)
|
||||
kz = numpy.sqrt(numpy.maximum(0, k * k - kxy2))
|
||||
|
||||
sin_th = ky / kxy
|
||||
cos_th = kx / kxy
|
||||
sin_th = numpy.divide(ky, kxy, out=numpy.zeros_like(ky), where=kxy != 0)
|
||||
cos_th = numpy.divide(kx, kxy, out=numpy.ones_like(kx), where=kxy != 0)
|
||||
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)
|
||||
phi = numpy.arccos(cos_phi)
|
||||
theta[numpy.logical_and(kx == 0, ky == 0)] = 0
|
||||
|
|
@ -205,25 +203,45 @@ def far_to_nearfield(
|
|||
|
||||
# Normalized vector potentials N, L
|
||||
L = [0.5 * E_far[1],
|
||||
-0.5 * E_far[0]] # noqa: E128
|
||||
-0.5 * E_far[0]] # noqa
|
||||
N = [L[1],
|
||||
-L[0]] # noqa: E128
|
||||
-L[0]] # noqa
|
||||
|
||||
En_fft = [-( L[0] * sin_th + L[1] * cos_phi * cos_th) / cos_phi,
|
||||
-(-L[0] * cos_th + L[1] * cos_phi * sin_th) / cos_phi]
|
||||
En_fft = [
|
||||
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,
|
||||
(-N[0] * cos_th + N[1] * cos_phi * sin_th) / cos_phi]
|
||||
|
||||
for i in range(2):
|
||||
En_fft[i][cos_phi == 0] = 0
|
||||
Hn_fft[i][cos_phi == 0] = 0
|
||||
Hn_fft = [
|
||||
numpy.divide(
|
||||
N[0] * sin_th + N[1] * cos_phi * cos_th,
|
||||
cos_phi,
|
||||
out=numpy.zeros_like(N[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]
|
||||
H_near = [ifftshift(ifft2(ifftshift(Hi), s=padded_shape)) for Hi in Hn_fft]
|
||||
|
||||
dx = 2 * pi / (s[0] * dkx)
|
||||
dy = 2 * pi / (s[0] * dky)
|
||||
dy = 2 * pi / (s[1] * dky)
|
||||
|
||||
outputs = {
|
||||
'E': E_near,
|
||||
|
|
@ -233,4 +251,3 @@ def far_to_nearfield(
|
|||
}
|
||||
|
||||
return outputs
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -18,8 +18,8 @@ __author__ = 'Jan Petykiewicz'
|
|||
def e_full(
|
||||
omega: complex,
|
||||
dxes: dx_lists_t,
|
||||
epsilon: fdfield_t,
|
||||
mu: fdfield_t | None = None,
|
||||
epsilon: fdfield,
|
||||
mu: fdfield | None = None,
|
||||
) -> cfdfield_updater_t:
|
||||
"""
|
||||
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])
|
||||
ce = curl_forward(dxes[0])
|
||||
|
||||
def op_1(e: cfdfield_t) -> cfdfield_t:
|
||||
def op_1(e: cfdfield) -> cfdfield_t:
|
||||
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:
|
||||
curls = ch(mu * ce(e)) # type: ignore # mu = None ok because we don't return the function
|
||||
return curls - omega ** 2 * epsilon * e
|
||||
curls = ch(ce(e) / mu) # type: ignore # mu = None ok because we don't return the function
|
||||
return cfdfield_t(curls - omega ** 2 * epsilon * e)
|
||||
|
||||
if mu is None:
|
||||
return op_1
|
||||
else:
|
||||
return op_mu
|
||||
|
||||
|
||||
def eh_full(
|
||||
omega: complex,
|
||||
dxes: dx_lists_t,
|
||||
epsilon: fdfield_t,
|
||||
mu: fdfield_t | None = None,
|
||||
) -> Callable[[cfdfield_t, cfdfield_t], tuple[cfdfield_t, cfdfield_t]]:
|
||||
epsilon: fdfield,
|
||||
mu: fdfield | None = None,
|
||||
) -> Callable[[cfdfield, cfdfield], tuple[cfdfield_t, cfdfield_t]]:
|
||||
"""
|
||||
Wave operator for full (both E and H) field representation.
|
||||
See `operators.eh_full`.
|
||||
|
|
@ -74,24 +73,23 @@ def eh_full(
|
|||
ch = curl_back(dxes[1])
|
||||
ce = curl_forward(dxes[0])
|
||||
|
||||
def op_1(e: cfdfield_t, h: cfdfield_t) -> tuple[cfdfield_t, cfdfield_t]:
|
||||
return (ch(h) - 1j * omega * epsilon * e,
|
||||
ce(e) + 1j * omega * h)
|
||||
def op_1(e: cfdfield, h: cfdfield) -> tuple[cfdfield_t, cfdfield_t]:
|
||||
return (cfdfield_t(ch(h) - 1j * omega * epsilon * e),
|
||||
cfdfield_t(ce(e) + 1j * omega * h))
|
||||
|
||||
def op_mu(e: cfdfield_t, h: cfdfield_t) -> tuple[cfdfield_t, cfdfield_t]:
|
||||
return (ch(h) - 1j * omega * epsilon * e,
|
||||
ce(e) + 1j * omega * mu * h) # type: ignore # mu=None ok
|
||||
def op_mu(e: cfdfield, h: cfdfield) -> tuple[cfdfield_t, cfdfield_t]:
|
||||
return (cfdfield_t(ch(h) - 1j * omega * epsilon * e),
|
||||
cfdfield_t(ce(e) + 1j * omega * mu * h)) # type: ignore # mu=None ok
|
||||
|
||||
if mu is None:
|
||||
return op_1
|
||||
else:
|
||||
return op_mu
|
||||
|
||||
|
||||
def e2h(
|
||||
omega: complex,
|
||||
dxes: dx_lists_t,
|
||||
mu: fdfield_t | None = None,
|
||||
mu: fdfield | None = None,
|
||||
) -> cfdfield_updater_t:
|
||||
"""
|
||||
Utility operator for converting the `E` field into the `H` field.
|
||||
|
|
@ -108,22 +106,21 @@ def e2h(
|
|||
"""
|
||||
ce = curl_forward(dxes[0])
|
||||
|
||||
def e2h_1_1(e: cfdfield_t) -> cfdfield_t:
|
||||
return ce(e) / (-1j * omega)
|
||||
def e2h_1_1(e: cfdfield) -> cfdfield_t:
|
||||
return cfdfield_t(ce(e) / (-1j * omega))
|
||||
|
||||
def e2h_mu(e: cfdfield_t) -> cfdfield_t:
|
||||
return ce(e) / (-1j * omega * mu) # type: ignore # mu=None ok
|
||||
def e2h_mu(e: cfdfield) -> cfdfield_t:
|
||||
return cfdfield_t(ce(e) / (-1j * omega * mu)) # type: ignore # mu=None ok
|
||||
|
||||
if mu is None:
|
||||
return e2h_1_1
|
||||
else:
|
||||
return e2h_mu
|
||||
|
||||
|
||||
def m2j(
|
||||
omega: complex,
|
||||
dxes: dx_lists_t,
|
||||
mu: fdfield_t | None = None,
|
||||
mu: fdfield | None = None,
|
||||
) -> cfdfield_updater_t:
|
||||
"""
|
||||
Utility operator for converting magnetic current `M` distribution
|
||||
|
|
@ -142,30 +139,42 @@ def m2j(
|
|||
ch = curl_back(dxes[1])
|
||||
|
||||
def m2j_mu(m: cfdfield_t) -> cfdfield_t:
|
||||
J = ch(m / mu) / (-1j * omega) # type: ignore # mu=None ok
|
||||
return J
|
||||
J = ch(m / mu) / (1j * omega) # type: ignore # mu=None ok
|
||||
return cfdfield_t(J)
|
||||
|
||||
def m2j_1(m: cfdfield_t) -> cfdfield_t:
|
||||
J = ch(m) / (-1j * omega)
|
||||
return J
|
||||
J = ch(m) / (1j * omega)
|
||||
return cfdfield_t(J)
|
||||
|
||||
if mu is None:
|
||||
return m2j_1
|
||||
else:
|
||||
return m2j_mu
|
||||
|
||||
|
||||
def e_tfsf_source(
|
||||
TF_region: fdfield_t,
|
||||
TF_region: fdfield,
|
||||
omega: complex,
|
||||
dxes: dx_lists_t,
|
||||
epsilon: fdfield_t,
|
||||
mu: fdfield_t | None = None,
|
||||
epsilon: fdfield,
|
||||
mu: fdfield | None = None,
|
||||
) -> cfdfield_updater_t:
|
||||
"""
|
||||
r"""
|
||||
Operator that turns an E-field distribution into a total-field/scattered-field
|
||||
(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:
|
||||
TF_region: mask which is set to 1 in the total-field region, and 0 elsewhere
|
||||
(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,
|
||||
`f(E)` -> `J`
|
||||
"""
|
||||
# TODO documentation
|
||||
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)
|
||||
return neg_iwj / (-1j * omega)
|
||||
return cfdfield_t(neg_iwj / (-1j * omega))
|
||||
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"""
|
||||
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
|
||||
for the Poynting vector, $S = E \\times H$
|
||||
and calculates the cross product `E` x `H` = $E \times H$ as required
|
||||
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:
|
||||
This function also shifts the input `E` field by one cell as required
|
||||
|
|
@ -200,7 +214,7 @@ def poynting_e_cross_h(dxes: dx_lists_t) -> Callable[[cfdfield_t, cfdfield_t], c
|
|||
|
||||
Note:
|
||||
If `E` and `H` are peak amplitudes as assumed elsewhere in this code,
|
||||
the time-average of the poynting vector is `<S> = Re(S)/2 = Re(E x H) / 2`.
|
||||
the time-average of the poynting vector is `<S> = Re(S)/2 = Re(E x H*) / 2`.
|
||||
The factor of `1/2` can be omitted if root-mean-square quantities are used
|
||||
instead.
|
||||
|
||||
|
|
@ -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`
|
||||
|
||||
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)
|
||||
ex = e[0] * dxes[0][0][:, 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[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
|
||||
return s
|
||||
return cfdfield_t(s)
|
||||
return exh
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""
|
||||
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
|
||||
`meanas.fdmath.vectorization.vec()` and `meanas.fdmath.vectorization.unvec()` functions.
|
||||
|
||||
|
|
@ -28,9 +28,9 @@ The following operators are included:
|
|||
"""
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -40,19 +40,19 @@ __author__ = 'Jan Petykiewicz'
|
|||
def e_full(
|
||||
omega: complex,
|
||||
dxes: dx_lists_t,
|
||||
epsilon: vfdfield_t,
|
||||
mu: vfdfield_t | None = None,
|
||||
pec: vfdfield_t | None = None,
|
||||
pmc: vfdfield_t | None = None,
|
||||
) -> sparse.spmatrix:
|
||||
"""
|
||||
epsilon: vfdfield | vcfdfield,
|
||||
mu: vfdfield | None = None,
|
||||
pec: vfdfield | None = None,
|
||||
pmc: vfdfield | None = None,
|
||||
) -> sparse.sparray:
|
||||
r"""
|
||||
Wave operator
|
||||
$$ \\nabla \\times (\\frac{1}{\\mu} \\nabla \\times) - \\Omega^2 \\epsilon $$
|
||||
$$ \nabla \times (\frac{1}{\mu} \nabla \times) - \Omega^2 \epsilon $$
|
||||
|
||||
del x (1/mu * del x) - omega**2 * epsilon
|
||||
|
||||
for use with the E-field, with wave equation
|
||||
$$ (\\nabla \\times (\\frac{1}{\\mu} \\nabla \\times) - \\Omega^2 \\epsilon) E = -\\imath \\omega J $$
|
||||
$$ (\nabla \times (\frac{1}{\mu} \nabla \times) - \Omega^2 \epsilon) E = -\imath \omega J $$
|
||||
|
||||
(del x (1/mu * del x) - omega**2 * epsilon) E = -i * omega * J
|
||||
|
||||
|
|
@ -77,20 +77,20 @@ def e_full(
|
|||
ce = curl_forward(dxes[0])
|
||||
|
||||
if pec is None:
|
||||
pe = sparse.eye(epsilon.size)
|
||||
pe = sparse.eye_array(epsilon.size)
|
||||
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:
|
||||
pm = sparse.eye(epsilon.size)
|
||||
pm = sparse.eye_array(epsilon.size)
|
||||
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:
|
||||
m_div = sparse.eye(epsilon.size)
|
||||
m_div = sparse.eye_array(epsilon.size)
|
||||
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
|
||||
return op
|
||||
|
|
@ -98,7 +98,7 @@ def e_full(
|
|||
|
||||
def e_full_preconditioners(
|
||||
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.
|
||||
|
||||
|
|
@ -118,27 +118,27 @@ def e_full_preconditioners(
|
|||
dxes[1][0][:, None, None] * dxes[1][1][None, :, None] * dxes[0][2][None, None, :]]
|
||||
|
||||
p_vector = numpy.sqrt(vec(p_squared))
|
||||
P_left = sparse.diags(p_vector)
|
||||
P_right = sparse.diags(1 / p_vector)
|
||||
P_left = sparse.diags_array(p_vector)
|
||||
P_right = sparse.diags_array(1 / p_vector)
|
||||
return P_left, P_right
|
||||
|
||||
|
||||
def h_full(
|
||||
omega: complex,
|
||||
dxes: dx_lists_t,
|
||||
epsilon: vfdfield_t,
|
||||
mu: vfdfield_t | None = None,
|
||||
pec: vfdfield_t | None = None,
|
||||
pmc: vfdfield_t | None = None,
|
||||
) -> sparse.spmatrix:
|
||||
"""
|
||||
epsilon: vfdfield,
|
||||
mu: vfdfield | None = None,
|
||||
pec: vfdfield | None = None,
|
||||
pmc: vfdfield | None = None,
|
||||
) -> sparse.sparray:
|
||||
r"""
|
||||
Wave operator
|
||||
$$ \\nabla \\times (\\frac{1}{\\epsilon} \\nabla \\times) - \\omega^2 \\mu $$
|
||||
$$ \nabla \times (\frac{1}{\epsilon} \nabla \times) - \omega^2 \mu $$
|
||||
|
||||
del x (1/epsilon * del x) - omega**2 * mu
|
||||
|
||||
for use with the H-field, with wave equation
|
||||
$$ (\\nabla \\times (\\frac{1}{\\epsilon} \\nabla \\times) - \\omega^2 \\mu) E = \\imath \\omega M $$
|
||||
$$ (\nabla \times (\frac{1}{\epsilon} \nabla \times) - \omega^2 \mu) E = \imath \omega M $$
|
||||
|
||||
(del x (1/epsilon * del x) - omega**2 * mu) E = i * omega * M
|
||||
|
||||
|
|
@ -161,20 +161,20 @@ def h_full(
|
|||
ce = curl_forward(dxes[0])
|
||||
|
||||
if pec is None:
|
||||
pe = sparse.eye(epsilon.size)
|
||||
pe = sparse.eye_array(epsilon.size)
|
||||
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:
|
||||
pm = sparse.eye(epsilon.size)
|
||||
pm = sparse.eye_array(epsilon.size)
|
||||
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:
|
||||
m = sparse.eye(epsilon.size)
|
||||
m = sparse.eye_array(epsilon.size)
|
||||
else:
|
||||
m = sparse.diags(mu)
|
||||
m = sparse.diags_array(mu)
|
||||
|
||||
A = pm @ (ce @ pe @ e_div @ ch - omega**2 * m) @ pm
|
||||
return A
|
||||
|
|
@ -183,33 +183,33 @@ def h_full(
|
|||
def eh_full(
|
||||
omega: complex,
|
||||
dxes: dx_lists_t,
|
||||
epsilon: vfdfield_t,
|
||||
mu: vfdfield_t | None = None,
|
||||
pec: vfdfield_t | None = None,
|
||||
pmc: vfdfield_t | None = None,
|
||||
) -> sparse.spmatrix:
|
||||
"""
|
||||
epsilon: vfdfield,
|
||||
mu: vfdfield | None = None,
|
||||
pec: vfdfield | None = None,
|
||||
pmc: vfdfield | None = None,
|
||||
) -> sparse.sparray:
|
||||
r"""
|
||||
Wave operator for `[E, H]` field representation. This operator implements Maxwell's
|
||||
equations without cancelling out either E or H. The operator is
|
||||
$$ \\begin{bmatrix}
|
||||
-\\imath \\omega \\epsilon & \\nabla \\times \\\\
|
||||
\\nabla \\times & \\imath \\omega \\mu
|
||||
\\end{bmatrix} $$
|
||||
$$ \begin{bmatrix}
|
||||
-\imath \omega \epsilon & \nabla \times \\
|
||||
\nabla \times & \imath \omega \mu
|
||||
\end{bmatrix} $$
|
||||
|
||||
[[-i * omega * epsilon, del x ],
|
||||
[del x, i * omega * mu]]
|
||||
|
||||
for use with a field vector of the form `cat(vec(E), vec(H))`:
|
||||
$$ \\begin{bmatrix}
|
||||
-\\imath \\omega \\epsilon & \\nabla \\times \\\\
|
||||
\\nabla \\times & \\imath \\omega \\mu
|
||||
\\end{bmatrix}
|
||||
\\begin{bmatrix} E \\\\
|
||||
$$ \begin{bmatrix}
|
||||
-\imath \omega \epsilon & \nabla \times \\
|
||||
\nabla \times & \imath \omega \mu
|
||||
\end{bmatrix}
|
||||
\begin{bmatrix} E \\
|
||||
H
|
||||
\\end{bmatrix}
|
||||
= \\begin{bmatrix} J \\\\
|
||||
\end{bmatrix}
|
||||
= \begin{bmatrix} J \\
|
||||
-M
|
||||
\\end{bmatrix} $$
|
||||
\end{bmatrix} $$
|
||||
|
||||
Args:
|
||||
omega: Angular frequency of the simulation
|
||||
|
|
@ -227,25 +227,27 @@ def eh_full(
|
|||
Sparse matrix containing the wave operator.
|
||||
"""
|
||||
if pec is None:
|
||||
pe = sparse.eye(epsilon.size)
|
||||
pe = sparse.eye_array(epsilon.size)
|
||||
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:
|
||||
pm = sparse.eye(epsilon.size)
|
||||
pm = sparse.eye_array(epsilon.size)
|
||||
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
|
||||
iwm = 1j * omega
|
||||
if mu is not None:
|
||||
iwm *= sparse.diags(mu)
|
||||
if mu is None:
|
||||
iwm = 1j * omega * sparse.eye(epsilon.size)
|
||||
else:
|
||||
iwm = 1j * omega * sparse.diags(mu)
|
||||
|
||||
iwm = pm @ iwm @ pm
|
||||
|
||||
A1 = pe @ curl_back(dxes[1]) @ pm
|
||||
A2 = pm @ curl_forward(dxes[0]) @ pe
|
||||
|
||||
A = sparse.bmat([[-iwe, A1],
|
||||
A = sparse.block_array([[-iwe, A1],
|
||||
[A2, iwm]])
|
||||
return A
|
||||
|
||||
|
|
@ -253,9 +255,9 @@ def eh_full(
|
|||
def e2h(
|
||||
omega: complex,
|
||||
dxes: dx_lists_t,
|
||||
mu: vfdfield_t | None = None,
|
||||
pmc: vfdfield_t | None = None,
|
||||
) -> sparse.spmatrix:
|
||||
mu: vfdfield | None = None,
|
||||
pmc: vfdfield | None = None,
|
||||
) -> sparse.sparray:
|
||||
"""
|
||||
Utility operator for converting the E field into the H field.
|
||||
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)
|
||||
|
||||
if mu is not None:
|
||||
op = sparse.diags(1 / mu) @ op
|
||||
op = sparse.diags_array(1 / mu) @ op
|
||||
|
||||
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
|
||||
|
||||
|
|
@ -285,8 +287,8 @@ def e2h(
|
|||
def m2j(
|
||||
omega: complex,
|
||||
dxes: dx_lists_t,
|
||||
mu: vfdfield_t | None = None,
|
||||
) -> sparse.spmatrix:
|
||||
mu: vfdfield | None = None,
|
||||
) -> sparse.sparray:
|
||||
"""
|
||||
Operator for converting a magnetic current M into an electric current J.
|
||||
For use with eg. `e_full()`.
|
||||
|
|
@ -302,78 +304,108 @@ def m2j(
|
|||
op = curl_back(dxes[1]) / (1j * omega)
|
||||
|
||||
if mu is not None:
|
||||
op = op @ sparse.diags(1 / mu)
|
||||
op = op @ sparse.diags_array(1 / mu)
|
||||
|
||||
return op
|
||||
|
||||
|
||||
def poynting_e_cross(e: vcfdfield_t, dxes: dx_lists_t) -> sparse.spmatrix:
|
||||
"""
|
||||
Operator for computing the Poynting vector, containing the
|
||||
(E x) portion of the Poynting vector.
|
||||
def poynting_e_cross(e: vcfdfield, dxes: dx_lists_t) -> sparse.sparray:
|
||||
r"""
|
||||
Operator for computing the staggered-grid `(E \times)` part 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:
|
||||
e: Vectorized E-field for the ExH cross product
|
||||
dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types`
|
||||
|
||||
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]]
|
||||
|
||||
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')]
|
||||
Ex, Ey, Ez = [ei * da for ei, da in zip(numpy.split(e, 3), dxag)]
|
||||
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, strict=True))
|
||||
|
||||
block_diags = [[ None, fx @ -Ez, fx @ Ey],
|
||||
[ fy @ Ez, None, fy @ -Ex],
|
||||
[ 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])
|
||||
P = block_matrix @ sparse.diags(numpy.concatenate(dxag))
|
||||
P = block_matrix @ sparse.diags_array(numpy.concatenate(dxbg))
|
||||
return P
|
||||
|
||||
|
||||
def poynting_h_cross(h: vcfdfield_t, dxes: dx_lists_t) -> sparse.spmatrix:
|
||||
"""
|
||||
Operator for computing the Poynting vector, containing the (H x) portion of the Poynting vector.
|
||||
def poynting_h_cross(h: vcfdfield, dxes: dx_lists_t) -> sparse.sparray:
|
||||
r"""
|
||||
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:
|
||||
h: Vectorized H-field for the HxE cross product
|
||||
dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types`
|
||||
|
||||
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]]
|
||||
|
||||
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')]
|
||||
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],
|
||||
[ Hz @ fy, None, -Hx @ fy],
|
||||
[-Hy @ fz, Hx @ fz, None]])
|
||||
@ sparse.diags(numpy.concatenate(dxag)))
|
||||
@ sparse.diags_array(numpy.concatenate(dxag)))
|
||||
return P
|
||||
|
||||
|
||||
def e_tfsf_source(
|
||||
TF_region: vfdfield_t,
|
||||
TF_region: vfdfield,
|
||||
omega: complex,
|
||||
dxes: dx_lists_t,
|
||||
epsilon: vfdfield_t,
|
||||
mu: vfdfield_t | None = None,
|
||||
) -> sparse.spmatrix:
|
||||
"""
|
||||
epsilon: vfdfield,
|
||||
mu: vfdfield | None = None,
|
||||
) -> sparse.sparray:
|
||||
r"""
|
||||
Operator that turns a desired E-field distribution into a
|
||||
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:
|
||||
TF_region: Mask, which is set to 1 inside the total-field region and 0 in the
|
||||
|
|
@ -385,27 +417,31 @@ def e_tfsf_source(
|
|||
|
||||
Returns:
|
||||
Sparse matrix that turns an E-field into a current (J) distribution.
|
||||
|
||||
"""
|
||||
# TODO documentation
|
||||
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)
|
||||
|
||||
|
||||
def e_boundary_source(
|
||||
mask: vfdfield_t,
|
||||
mask: vfdfield,
|
||||
omega: complex,
|
||||
dxes: dx_lists_t,
|
||||
epsilon: vfdfield_t,
|
||||
mu: vfdfield_t | None = None,
|
||||
epsilon: vfdfield,
|
||||
mu: vfdfield | None = None,
|
||||
periodic_mask_edges: bool = False,
|
||||
) -> sparse.spmatrix:
|
||||
"""
|
||||
) -> sparse.sparray:
|
||||
r"""
|
||||
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
|
||||
`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:
|
||||
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
|
||||
|
|
@ -423,10 +459,10 @@ def e_boundary_source(
|
|||
shape = [len(dxe) for dxe in dxes[0]]
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
shift = shift_rot if periodic_mask_edges else shift_mir
|
||||
|
|
@ -435,7 +471,7 @@ def e_boundary_source(
|
|||
if shape[axis] == 1:
|
||||
continue
|
||||
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))
|
||||
jmask = numpy.logical_or(jmask, numpy.abs(r3 @ mask))
|
||||
|
||||
|
|
@ -446,5 +482,4 @@ def e_boundary_source(
|
|||
# (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
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
Functions for creating stretched coordinate perfectly matched layer (PML) absorbers.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Callable
|
||||
from collections.abc import Sequence, Callable
|
||||
|
||||
import numpy
|
||||
from numpy.typing import NDArray
|
||||
|
|
@ -128,6 +128,11 @@ def stretch_with_scpml(
|
|||
dx_ai = dxes[0][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_a = (pos[:-1] + pos[1:]) / 2
|
||||
pos_b = pos[:-1]
|
||||
|
|
@ -153,9 +158,6 @@ def stretch_with_scpml(
|
|||
def l_d(x: NDArray[numpy.float64]) -> NDArray[numpy.float64]:
|
||||
return (x - bound) / (pos[-1] - bound)
|
||||
|
||||
if thickness == 0:
|
||||
slc = slice(None)
|
||||
else:
|
||||
slc = slice(-thickness, None)
|
||||
|
||||
dx_ai[slc] *= 1 + 1j * s_function(l_d(pos_a[slc])) / d / s_correction
|
||||
|
|
|
|||
|
|
@ -2,15 +2,16 @@
|
|||
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 numpy
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -18,7 +19,7 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
|
||||
def _scipy_qmr(
|
||||
A: scipy.sparse.csr_matrix,
|
||||
A: scipy.sparse.csr_array,
|
||||
b: ArrayLike,
|
||||
**kwargs: Any,
|
||||
) -> NDArray[numpy.float64]:
|
||||
|
|
@ -34,30 +35,32 @@ def _scipy_qmr(
|
|||
Guess for solution (returned even if didn't converge)
|
||||
"""
|
||||
|
||||
'''
|
||||
Report on our progress
|
||||
'''
|
||||
#
|
||||
#Report on our progress
|
||||
#
|
||||
ii = 0
|
||||
|
||||
def log_residual(xk: ArrayLike) -> None:
|
||||
nonlocal ii
|
||||
ii += 1
|
||||
if ii % 100 == 0:
|
||||
logger.info('Solver residual at iteration {} : {}'.format(ii, norm(A @ xk - b)))
|
||||
cur_norm = norm(A @ xk - b) / norm(b)
|
||||
logger.info(f'Solver residual at iteration {ii} : {cur_norm}')
|
||||
|
||||
if 'callback' in kwargs:
|
||||
callback = kwargs['callback']
|
||||
|
||||
def augmented_callback(xk: ArrayLike) -> None:
|
||||
log_residual(xk)
|
||||
kwargs['callback'](xk)
|
||||
callback(xk)
|
||||
|
||||
kwargs['callback'] = augmented_callback
|
||||
else:
|
||||
kwargs['callback'] = log_residual
|
||||
|
||||
'''
|
||||
Run the actual solve
|
||||
'''
|
||||
|
||||
#
|
||||
# Run the actual solve
|
||||
#
|
||||
x, _ = scipy.sparse.linalg.qmr(A, b, **kwargs)
|
||||
return x
|
||||
|
||||
|
|
@ -65,14 +68,16 @@ def _scipy_qmr(
|
|||
def generic(
|
||||
omega: complex,
|
||||
dxes: dx_lists_t,
|
||||
J: vcfdfield_t,
|
||||
epsilon: vfdfield_t,
|
||||
mu: Optional[vfdfield_t] = None,
|
||||
pec: Optional[vfdfield_t] = None,
|
||||
pmc: Optional[vfdfield_t] = None,
|
||||
J: vcfdfield,
|
||||
epsilon: vfdfield,
|
||||
mu: vfdfield | None = None,
|
||||
*,
|
||||
pec: vfdfield | None = None,
|
||||
pmc: vfdfield | None = None,
|
||||
adjoint: bool = False,
|
||||
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:
|
||||
"""
|
||||
Conjugate gradient FDFD solver using CSR sparse matrices.
|
||||
|
|
@ -92,13 +97,15 @@ def generic(
|
|||
(at H-field locations; non-zero value indicates PMC is present)
|
||||
adjoint: If true, solves the adjoint problem.
|
||||
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`;
|
||||
`x`: `ArrayLike`;
|
||||
Default is a wrapped version of `scipy.sparse.linalg.qmr()`
|
||||
which doesn't return convergence info and logs the residual
|
||||
every 100 iterations.
|
||||
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:
|
||||
E-field which solves the system.
|
||||
|
|
@ -113,17 +120,24 @@ def generic(
|
|||
Pl, Pr = operators.e_full_preconditioners(dxes)
|
||||
|
||||
if adjoint:
|
||||
A = (Pl @ A0 @ Pr).H
|
||||
b = Pr.H @ b0
|
||||
A = (Pl @ A0 @ Pr).T.conjugate()
|
||||
b = Pr.T.conjugate() @ b0
|
||||
else:
|
||||
A = Pl @ A0 @ Pr
|
||||
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)
|
||||
|
||||
if adjoint:
|
||||
x0 = Pl.H @ x
|
||||
x0 = Pl.T.conjugate() @ x
|
||||
else:
|
||||
x0 = Pr @ x
|
||||
|
||||
return x0
|
||||
return vcfdfield_t(x0)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -3,15 +3,40 @@ Tools for working with waveguide modes in 3D domains.
|
|||
|
||||
This module relies heavily on `waveguide_2d` and mostly just transforms
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
class Waveguide3DMode(TypedDict):
|
||||
wavenumber: complex
|
||||
wavenumber_2d: complex
|
||||
H: NDArray[complexfloating]
|
||||
E: NDArray[complexfloating]
|
||||
|
||||
|
||||
def solve_mode(
|
||||
mode_number: int,
|
||||
omega: complex,
|
||||
|
|
@ -19,10 +44,10 @@ def solve_mode(
|
|||
axis: int,
|
||||
polarity: int,
|
||||
slices: Sequence[slice],
|
||||
epsilon: fdfield_t,
|
||||
mu: fdfield_t | None = None,
|
||||
) -> dict[str, complex | NDArray[numpy.float_]]:
|
||||
"""
|
||||
epsilon: fdfield,
|
||||
mu: fdfield | None = None,
|
||||
) -> Waveguide3DMode:
|
||||
r"""
|
||||
Given a 3D grid, selects a slice from the grid and attempts to
|
||||
solve for an eigenmode propagating through that slice.
|
||||
|
||||
|
|
@ -33,27 +58,31 @@ def solve_mode(
|
|||
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.
|
||||
as the waveguide cross-section. `slices[axis]` must select exactly one item.
|
||||
epsilon: Dielectric constant
|
||||
mu: Magnetic permeability (default 1 everywhere)
|
||||
|
||||
Returns:
|
||||
```
|
||||
{
|
||||
'E': list[NDArray[numpy.float_]],
|
||||
'H': list[NDArray[numpy.float_]],
|
||||
'wavenumber': complex,
|
||||
}
|
||||
```
|
||||
Dictionary containing:
|
||||
|
||||
- `E`: full-grid electric field for the solved mode
|
||||
- `H`: full-grid magnetic field for the solved mode
|
||||
- `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:
|
||||
mu = numpy.ones_like(epsilon)
|
||||
|
||||
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
|
||||
order = numpy.roll(range(3), 2 - axis)
|
||||
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)
|
||||
|
||||
'''
|
||||
Apply corrections and expand to 3D
|
||||
'''
|
||||
#
|
||||
# Apply corrections and expand to 3D
|
||||
#
|
||||
|
||||
# Correct wavenumber to account for numerical dispersion.
|
||||
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
|
||||
E = numpy.zeros_like(epsilon, dtype=complex)
|
||||
H = numpy.zeros_like(epsilon, dtype=complex)
|
||||
for a, o in enumerate(reverse_order):
|
||||
E[(a, *slices)] = e[o][:, :, None].transpose(reverse_order)
|
||||
H[(a, *slices)] = h[o][:, :, None].transpose(reverse_order)
|
||||
for aa, oo in enumerate(reverse_order):
|
||||
iii = cast('tuple[slice | int]', (aa, *slices))
|
||||
E[iii] = e[oo][:, :, None].transpose(reverse_order)
|
||||
H[iii] = h[oo][:, :, None].transpose(reverse_order)
|
||||
|
||||
results = {
|
||||
results: Waveguide3DMode = {
|
||||
'wavenumber': wavenumber,
|
||||
'wavenumber_2d': wavenumber_2d,
|
||||
'H': H,
|
||||
|
|
@ -106,15 +137,15 @@ def solve_mode(
|
|||
|
||||
|
||||
def compute_source(
|
||||
E: cfdfield_t,
|
||||
E: cfdfield,
|
||||
wavenumber: complex,
|
||||
omega: complex,
|
||||
dxes: dx_lists_t,
|
||||
axis: int,
|
||||
polarity: int,
|
||||
slices: Sequence[slice],
|
||||
epsilon: fdfield_t,
|
||||
mu: fdfield_t | None = None,
|
||||
epsilon: fdfield,
|
||||
mu: fdfield | None = None,
|
||||
) -> cfdfield_t:
|
||||
"""
|
||||
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)
|
||||
|
||||
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,
|
||||
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))
|
||||
J = unvec(masked_e2j @ vec(E_expanded), E.shape[1:])
|
||||
return J
|
||||
return cfdfield_t(J)
|
||||
|
||||
|
||||
def compute_overlap_e(
|
||||
E: cfdfield_t,
|
||||
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,
|
||||
E: cfdfield,
|
||||
wavenumber: complex,
|
||||
dxes: dx_lists_t,
|
||||
axis: int,
|
||||
polarity: int,
|
||||
slices: Sequence[slice],
|
||||
) -> 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
|
||||
slice where the mode was calculated to the entire domain (along the propagation
|
||||
axis). This assumes the epsilon cross-section remains constant throughout the
|
||||
|
|
@ -225,6 +310,16 @@ def expand_e(
|
|||
|
||||
Returns:
|
||||
`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)
|
||||
|
||||
|
|
@ -245,4 +340,4 @@ def expand_e(
|
|||
slices_in = (slice(None), *slices)
|
||||
|
||||
E_expanded[slices_exp] = phase_E * numpy.array(E)[slices_in]
|
||||
return E_expanded
|
||||
return cfdfield_t(E_expanded)
|
||||
|
|
|
|||
|
|
@ -1,46 +1,181 @@
|
|||
"""
|
||||
r"""
|
||||
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
|
||||
(i.e. `dxes = [[[dr_e_0, dx_e_1, ...], [dy_e_0, ...]], [[dr_h_0, ...], [dy_h_0, ...]]]`).
|
||||
As in `waveguide_2d`, the propagation dependence is separated from the
|
||||
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 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 ..eigensolvers import signed_eigensolve, rayleigh_quotient_iteration
|
||||
from . import waveguide_2d
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def cylindrical_operator(
|
||||
omega: complex,
|
||||
dxes: dx_lists_t,
|
||||
epsilon: vfdfield_t,
|
||||
r0: float,
|
||||
) -> sparse.spmatrix:
|
||||
"""
|
||||
omega: float,
|
||||
dxes: dx_lists2_t,
|
||||
epsilon: vfdslice,
|
||||
rmin: float,
|
||||
) -> sparse.sparray:
|
||||
r"""
|
||||
Cylindrical coordinate waveguide operator of the form
|
||||
|
||||
TODO
|
||||
$$
|
||||
(\omega^2 \begin{bmatrix} T_b T_b \mu_{yy} \epsilon_{xx} & 0 \\
|
||||
0 & T_a T_a \mu_{xx} \epsilon_{yy} \end{bmatrix} +
|
||||
\begin{bmatrix} -T_b \mu_{yy} \hat{\partial}_y \\
|
||||
T_a \mu_{xx} \hat{\partial}_x \end{bmatrix} T_b \mu_{zz}^{-1}
|
||||
\begin{bmatrix} -\tilde{\partial}_y & \tilde{\partial}_x \end{bmatrix} +
|
||||
\begin{bmatrix} \tilde{\partial}_x \\
|
||||
\tilde{\partial}_y \end{bmatrix} T_a \epsilon_{zz}^{-1}
|
||||
\begin{bmatrix} \hat{\partial}_x T_b \epsilon_{xx} & \hat{\partial}_y T_a \epsilon_{yy} \end{bmatrix})
|
||||
\begin{bmatrix} E_r \\
|
||||
E_y \end{bmatrix}
|
||||
$$
|
||||
|
||||
for use with a field vector of the form `[E_r, E_y]`.
|
||||
|
||||
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
|
||||
(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:
|
||||
omega: The angular frequency of the system
|
||||
dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types` (2D)
|
||||
epsilon: Vectorized dielectric constant grid
|
||||
r0: Radius of curvature for the simulation. This should be the minimum value of
|
||||
r within the simulation domain.
|
||||
rmin: Radius at the left edge of the simulation domain (at minimum 'x')
|
||||
|
||||
Returns:
|
||||
Sparse matrix representation of the operator
|
||||
|
|
@ -49,95 +184,409 @@ def cylindrical_operator(
|
|||
Dfx, Dfy = deriv_forward(dxes[0])
|
||||
Dbx, Dby = deriv_back(dxes[1])
|
||||
|
||||
rx = r0 + numpy.cumsum(dxes[0][0])
|
||||
ry = r0 + dxes[0][0] / 2.0 + numpy.cumsum(dxes[1][0])
|
||||
tx = rx / r0
|
||||
ty = ry / r0
|
||||
|
||||
Tx = sparse.diags(vec(tx[:, None].repeat(dxes[0][1].size, axis=1)))
|
||||
Ty = sparse.diags(vec(ty[:, None].repeat(dxes[1][1].size, axis=1)))
|
||||
Ta, Tb = dxes2T(dxes=dxes, rmin=rmin)
|
||||
|
||||
eps_parts = numpy.split(epsilon, 3)
|
||||
eps_x = sparse.diags(eps_parts[0])
|
||||
eps_y = sparse.diags(eps_parts[1])
|
||||
eps_z_inv = sparse.diags(1 / eps_parts[2])
|
||||
|
||||
pa = sparse.vstack((Dfx, Dfy)) @ Tx @ eps_z_inv @ sparse.hstack((Dbx, Dby))
|
||||
pb = sparse.vstack((Dfx, Dfy)) @ Tx @ eps_z_inv @ sparse.hstack((Dby, Dbx))
|
||||
a0 = Ty @ eps_x + omega**-2 * Dby @ Ty @ Dfy
|
||||
a1 = Tx @ eps_y + omega**-2 * Dbx @ Ty @ Dfx
|
||||
b0 = Dbx @ Ty @ Dfy
|
||||
b1 = Dby @ Ty @ Dfx
|
||||
|
||||
diag = sparse.block_diag
|
||||
eps_x = sparse.diags_array(eps_parts[0])
|
||||
eps_y = sparse.diags_array(eps_parts[1])
|
||||
eps_z_inv = sparse.diags_array(1 / eps_parts[2])
|
||||
|
||||
omega2 = omega * omega
|
||||
diag = sparse.block_diag
|
||||
|
||||
op = (omega2 * diag((Tx, Ty)) + pa) @ diag((a0, a1)) + \
|
||||
- (sparse.bmat(((None, Ty), (Tx, None))) + pb / omega2) @ diag((b0, b1))
|
||||
sq0 = omega2 * diag((Tb @ Tb @ eps_x,
|
||||
Ta @ Ta @ eps_y))
|
||||
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
|
||||
|
||||
|
||||
def solve_mode(
|
||||
mode_number: int,
|
||||
omega: complex,
|
||||
dxes: dx_lists_t,
|
||||
epsilon: vfdfield_t,
|
||||
r0: float,
|
||||
) -> dict[str, complex | cfdfield_t]:
|
||||
def solve_modes(
|
||||
mode_numbers: Sequence[int],
|
||||
omega: float,
|
||||
dxes: dx_lists2_t,
|
||||
epsilon: vfdslice,
|
||||
rmin: float,
|
||||
mode_margin: int = 2,
|
||||
) -> tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]:
|
||||
"""
|
||||
TODO: fixup
|
||||
Given a 2d (r, y) slice of epsilon, attempts to solve for the eigenmode
|
||||
of the bent waveguide with the specified mode number.
|
||||
|
||||
Args:
|
||||
mode_number: Number of the mode, 0-indexed
|
||||
mode_numbers: Mode numbers to solve, 0-indexed.
|
||||
omega: Angular frequency of the simulation
|
||||
dxes: Grid parameters [dx_e, dx_h] as described in meanas.fdmath.types.
|
||||
The first coordinate is assumed to be r, the second is y.
|
||||
epsilon: Dielectric constant
|
||||
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.
|
||||
|
||||
Returns:
|
||||
```
|
||||
{
|
||||
'E': list[NDArray[numpy.complex_]],
|
||||
'H': list[NDArray[numpy.complex_]],
|
||||
'wavenumber': complex,
|
||||
}
|
||||
```
|
||||
e_xys: NDArray of vfdfield_t specifying fields. First dimension is mode number.
|
||||
angular_wavenumbers: list of wavenumbers in 1/rad units.
|
||||
"""
|
||||
|
||||
'''
|
||||
Solve for the largest-magnitude eigenvalue of the real operator
|
||||
'''
|
||||
#
|
||||
# Solve for the largest-magnitude eigenvalue of the real operator
|
||||
#
|
||||
dxes_real = [[numpy.real(dx) for dx in dxi] for dxi in dxes]
|
||||
|
||||
A_r = cylindrical_operator(numpy.real(omega), dxes_real, numpy.real(epsilon), r0)
|
||||
eigvals, eigvecs = signed_eigensolve(A_r, mode_number + 3)
|
||||
e_xy = eigvecs[:, -(mode_number + 1)]
|
||||
A_r = cylindrical_operator(numpy.real(omega), dxes_real, numpy.real(epsilon), rmin=rmin)
|
||||
eigvals, eigvecs = signed_eigensolve(A_r, max(mode_numbers) + mode_margin)
|
||||
keep_inds = -(numpy.array(mode_numbers) + 1)
|
||||
e_xys = eigvecs[:, keep_inds].T
|
||||
eigvals = eigvals[keep_inds]
|
||||
|
||||
'''
|
||||
Now solve for the eigenvector of the full operator, using the real operator's
|
||||
eigenvector as an initial guess for Rayleigh quotient iteration.
|
||||
'''
|
||||
A = cylindrical_operator(omega, dxes, epsilon, r0)
|
||||
eigval, e_xy = rayleigh_quotient_iteration(A, e_xy)
|
||||
#
|
||||
# Now solve for the eigenvector of the full operator, using the real operator's
|
||||
# eigenvector as an initial guess for Rayleigh quotient iteration.
|
||||
#
|
||||
A = cylindrical_operator(omega, dxes, epsilon, rmin=rmin)
|
||||
for nn in range(len(mode_numbers)):
|
||||
eigvals[nn], e_xys[nn, :] = rayleigh_quotient_iteration(A, e_xys[nn, :])
|
||||
|
||||
# Calculate the wave-vector (force the real part to be positive)
|
||||
wavenumber = numpy.sqrt(eigval)
|
||||
wavenumber *= numpy.sign(numpy.real(wavenumber))
|
||||
wavenumbers = numpy.sqrt(eigvals)
|
||||
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]]
|
||||
e_xy = numpy.hstack((e_xy, numpy.zeros(shape[0] * shape[1])))
|
||||
fields = {
|
||||
'wavenumber': wavenumber,
|
||||
'E': unvec(e_xy, shape),
|
||||
# 'E': unvec(e, shape),
|
||||
# 'H': unvec(h, shape),
|
||||
}
|
||||
order = angular_wavenumbers.argsort()[::-1]
|
||||
e_xys = e_xys[order]
|
||||
angular_wavenumbers = angular_wavenumbers[order]
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""
|
||||
r"""
|
||||
|
||||
Basic discrete calculus for finite difference (fd) simulations.
|
||||
|
||||
|
|
@ -43,11 +43,11 @@ Scalar derivatives and cell shifts
|
|||
----------------------------------
|
||||
|
||||
Define the discrete forward derivative as
|
||||
$$ [\\tilde{\\partial}_x f]_{m + \\frac{1}{2}} = \\frac{1}{\\Delta_{x, m}} (f_{m + 1} - f_m) $$
|
||||
$$ [\tilde{\partial}_x f]_{m + \frac{1}{2}} = \frac{1}{\Delta_{x, m}} (f_{m + 1} - f_m) $$
|
||||
where $f$ is a function defined at discrete locations on the x-axis (labeled using $m$).
|
||||
The value at $m$ occupies a length $\\Delta_{x, m}$ along the x-axis. Note that $m$
|
||||
The value at $m$ occupies a length $\Delta_{x, m}$ along the x-axis. Note that $m$
|
||||
is an index along the x-axis, _not_ necessarily an x-coordinate, since each length
|
||||
$\\Delta_{x, m}, \\Delta_{x, m+1}, ...$ is independently chosen.
|
||||
$\Delta_{x, m}, \Delta_{x, m+1}, ...$ is independently chosen.
|
||||
|
||||
If we treat `f` as a 1D array of values, with the `i`-th value `f[i]` taking up a length `dx[i]`
|
||||
along the x-axis, the forward derivative is
|
||||
|
|
@ -56,13 +56,13 @@ along the x-axis, the forward derivative is
|
|||
|
||||
|
||||
Likewise, discrete reverse derivative is
|
||||
$$ [\\hat{\\partial}_x f ]_{m - \\frac{1}{2}} = \\frac{1}{\\Delta_{x, m}} (f_{m} - f_{m - 1}) $$
|
||||
$$ [\hat{\partial}_x f ]_{m - \frac{1}{2}} = \frac{1}{\Delta_{x, m}} (f_{m} - f_{m - 1}) $$
|
||||
or
|
||||
|
||||
deriv_back(f)[i] = (f[i] - f[i - 1]) / dx[i]
|
||||
|
||||
The derivatives' values are shifted by a half-cell relative to the original function, and
|
||||
will have different cell widths if all the `dx[i]` ( $\\Delta_{x, m}$ ) are not
|
||||
will have different cell widths if all the `dx[i]` ( $\Delta_{x, m}$ ) are not
|
||||
identical:
|
||||
|
||||
[figure: derivatives and cell sizes]
|
||||
|
|
@ -88,19 +88,19 @@ identical:
|
|||
|
||||
In the above figure,
|
||||
`f0 =` $f_0$, `f1 =` $f_1$
|
||||
`Df0 =` $[\\tilde{\\partial}f]_{0 + \\frac{1}{2}}$
|
||||
`Df1 =` $[\\tilde{\\partial}f]_{1 + \\frac{1}{2}}$
|
||||
`df0 =` $[\\hat{\\partial}f]_{0 - \\frac{1}{2}}$
|
||||
`Df0 =` $[\tilde{\partial}f]_{0 + \frac{1}{2}}$
|
||||
`Df1 =` $[\tilde{\partial}f]_{1 + \frac{1}{2}}$
|
||||
`df0 =` $[\hat{\partial}f]_{0 - \frac{1}{2}}$
|
||||
etc.
|
||||
|
||||
The fractional subscript $m + \\frac{1}{2}$ is used to indicate values defined
|
||||
The fractional subscript $m + \frac{1}{2}$ is used to indicate values defined
|
||||
at shifted locations relative to the original $m$, with corresponding lengths
|
||||
$$ \\Delta_{x, m + \\frac{1}{2}} = \\frac{1}{2} * (\\Delta_{x, m} + \\Delta_{x, m + 1}) $$
|
||||
$$ \Delta_{x, m + \frac{1}{2}} = \frac{1}{2} * (\Delta_{x, m} + \Delta_{x, m + 1}) $$
|
||||
|
||||
Just as $m$ is not itself an x-coordinate, neither is $m + \\frac{1}{2}$;
|
||||
Just as $m$ is not itself an x-coordinate, neither is $m + \frac{1}{2}$;
|
||||
carefully note the positions of the various cells in the above figure vs their labels.
|
||||
If the positions labeled with $m$ are considered the "base" or "original" grid,
|
||||
the positions labeled with $m + \\frac{1}{2}$ are said to lie on a "dual" or
|
||||
the positions labeled with $m + \frac{1}{2}$ are said to lie on a "dual" or
|
||||
"derived" grid.
|
||||
|
||||
For the remainder of the `Discrete calculus` section, all figures will show
|
||||
|
|
@ -113,12 +113,12 @@ Gradients and fore-vectors
|
|||
--------------------------
|
||||
|
||||
Expanding to three dimensions, we can define two gradients
|
||||
$$ [\\tilde{\\nabla} f]_{m,n,p} = \\vec{x} [\\tilde{\\partial}_x f]_{m + \\frac{1}{2},n,p} +
|
||||
\\vec{y} [\\tilde{\\partial}_y f]_{m,n + \\frac{1}{2},p} +
|
||||
\\vec{z} [\\tilde{\\partial}_z f]_{m,n,p + \\frac{1}{2}} $$
|
||||
$$ [\\hat{\\nabla} f]_{m,n,p} = \\vec{x} [\\hat{\\partial}_x f]_{m + \\frac{1}{2},n,p} +
|
||||
\\vec{y} [\\hat{\\partial}_y f]_{m,n + \\frac{1}{2},p} +
|
||||
\\vec{z} [\\hat{\\partial}_z f]_{m,n,p + \\frac{1}{2}} $$
|
||||
$$ [\tilde{\nabla} f]_{m,n,p} = \vec{x} [\tilde{\partial}_x f]_{m + \frac{1}{2},n,p} +
|
||||
\vec{y} [\tilde{\partial}_y f]_{m,n + \frac{1}{2},p} +
|
||||
\vec{z} [\tilde{\partial}_z f]_{m,n,p + \frac{1}{2}} $$
|
||||
$$ [\hat{\nabla} f]_{m,n,p} = \vec{x} [\hat{\partial}_x f]_{m + \frac{1}{2},n,p} +
|
||||
\vec{y} [\hat{\partial}_y f]_{m,n + \frac{1}{2},p} +
|
||||
\vec{z} [\hat{\partial}_z f]_{m,n,p + \frac{1}{2}} $$
|
||||
|
||||
or
|
||||
|
||||
|
|
@ -144,12 +144,12 @@ y in y, and z in z.
|
|||
|
||||
We call the resulting object a "fore-vector" or "back-vector", depending
|
||||
on the direction of the shift. We write it as
|
||||
$$ \\tilde{g}_{m,n,p} = \\vec{x} g^x_{m + \\frac{1}{2},n,p} +
|
||||
\\vec{y} g^y_{m,n + \\frac{1}{2},p} +
|
||||
\\vec{z} g^z_{m,n,p + \\frac{1}{2}} $$
|
||||
$$ \\hat{g}_{m,n,p} = \\vec{x} g^x_{m - \\frac{1}{2},n,p} +
|
||||
\\vec{y} g^y_{m,n - \\frac{1}{2},p} +
|
||||
\\vec{z} g^z_{m,n,p - \\frac{1}{2}} $$
|
||||
$$ \tilde{g}_{m,n,p} = \vec{x} g^x_{m + \frac{1}{2},n,p} +
|
||||
\vec{y} g^y_{m,n + \frac{1}{2},p} +
|
||||
\vec{z} g^z_{m,n,p + \frac{1}{2}} $$
|
||||
$$ \hat{g}_{m,n,p} = \vec{x} g^x_{m - \frac{1}{2},n,p} +
|
||||
\vec{y} g^y_{m,n - \frac{1}{2},p} +
|
||||
\vec{z} g^z_{m,n,p - \frac{1}{2}} $$
|
||||
|
||||
|
||||
[figure: gradient / fore-vector]
|
||||
|
|
@ -172,15 +172,15 @@ Divergences
|
|||
|
||||
There are also two divergences,
|
||||
|
||||
$$ d_{n,m,p} = [\\tilde{\\nabla} \\cdot \\hat{g}]_{n,m,p}
|
||||
= [\\tilde{\\partial}_x g^x]_{m,n,p} +
|
||||
[\\tilde{\\partial}_y g^y]_{m,n,p} +
|
||||
[\\tilde{\\partial}_z g^z]_{m,n,p} $$
|
||||
$$ d_{n,m,p} = [\tilde{\nabla} \cdot \hat{g}]_{n,m,p}
|
||||
= [\tilde{\partial}_x g^x]_{m,n,p} +
|
||||
[\tilde{\partial}_y g^y]_{m,n,p} +
|
||||
[\tilde{\partial}_z g^z]_{m,n,p} $$
|
||||
|
||||
$$ d_{n,m,p} = [\\hat{\\nabla} \\cdot \\tilde{g}]_{n,m,p}
|
||||
= [\\hat{\\partial}_x g^x]_{m,n,p} +
|
||||
[\\hat{\\partial}_y g^y]_{m,n,p} +
|
||||
[\\hat{\\partial}_z g^z]_{m,n,p} $$
|
||||
$$ d_{n,m,p} = [\hat{\nabla} \cdot \tilde{g}]_{n,m,p}
|
||||
= [\hat{\partial}_x g^x]_{m,n,p} +
|
||||
[\hat{\partial}_y g^y]_{m,n,p} +
|
||||
[\hat{\partial}_z g^z]_{m,n,p} $$
|
||||
|
||||
or
|
||||
|
||||
|
|
@ -203,7 +203,7 @@ where `g = [gx, gy, gz]` is a fore- or back-vector field.
|
|||
|
||||
Since we applied the forward divergence to the back-vector (and vice-versa), the resulting scalar value
|
||||
is defined at the back-vector's (fore-vector's) location $(m,n,p)$ and not at the locations of its components
|
||||
$(m \\pm \\frac{1}{2},n,p)$ etc.
|
||||
$(m \pm \frac{1}{2},n,p)$ etc.
|
||||
|
||||
[figure: divergence]
|
||||
^^
|
||||
|
|
@ -227,23 +227,23 @@ Curls
|
|||
|
||||
The two curls are then
|
||||
|
||||
$$ \\begin{aligned}
|
||||
\\hat{h}_{m + \\frac{1}{2}, n + \\frac{1}{2}, p + \\frac{1}{2}} &= \\\\
|
||||
[\\tilde{\\nabla} \\times \\tilde{g}]_{m + \\frac{1}{2}, n + \\frac{1}{2}, p + \\frac{1}{2}} &=
|
||||
\\vec{x} (\\tilde{\\partial}_y g^z_{m,n,p + \\frac{1}{2}} - \\tilde{\\partial}_z g^y_{m,n + \\frac{1}{2},p}) \\\\
|
||||
&+ \\vec{y} (\\tilde{\\partial}_z g^x_{m + \\frac{1}{2},n,p} - \\tilde{\\partial}_x g^z_{m,n,p + \\frac{1}{2}}) \\\\
|
||||
&+ \\vec{z} (\\tilde{\\partial}_x g^y_{m,n + \\frac{1}{2},p} - \\tilde{\\partial}_y g^z_{m + \\frac{1}{2},n,p})
|
||||
\\end{aligned} $$
|
||||
$$ \begin{aligned}
|
||||
\hat{h}_{m + \frac{1}{2}, n + \frac{1}{2}, p + \frac{1}{2}} &= \\
|
||||
[\tilde{\nabla} \times \tilde{g}]_{m + \frac{1}{2}, n + \frac{1}{2}, p + \frac{1}{2}} &=
|
||||
\vec{x} (\tilde{\partial}_y g^z_{m,n,p + \frac{1}{2}} - \tilde{\partial}_z g^y_{m,n + \frac{1}{2},p}) \\
|
||||
&+ \vec{y} (\tilde{\partial}_z g^x_{m + \frac{1}{2},n,p} - \tilde{\partial}_x g^z_{m,n,p + \frac{1}{2}}) \\
|
||||
&+ \vec{z} (\tilde{\partial}_x g^y_{m,n + \frac{1}{2},p} - \tilde{\partial}_y g^z_{m + \frac{1}{2},n,p})
|
||||
\end{aligned} $$
|
||||
|
||||
and
|
||||
|
||||
$$ \\tilde{h}_{m - \\frac{1}{2}, n - \\frac{1}{2}, p - \\frac{1}{2}} =
|
||||
[\\hat{\\nabla} \\times \\hat{g}]_{m - \\frac{1}{2}, n - \\frac{1}{2}, p - \\frac{1}{2}} $$
|
||||
$$ \tilde{h}_{m - \frac{1}{2}, n - \frac{1}{2}, p - \frac{1}{2}} =
|
||||
[\hat{\nabla} \times \hat{g}]_{m - \frac{1}{2}, n - \frac{1}{2}, p - \frac{1}{2}} $$
|
||||
|
||||
where $\\hat{g}$ and $\\tilde{g}$ are located at $(m,n,p)$
|
||||
with components at $(m \\pm \\frac{1}{2},n,p)$ etc.,
|
||||
while $\\hat{h}$ and $\\tilde{h}$ are located at $(m \\pm \\frac{1}{2}, n \\pm \\frac{1}{2}, p \\pm \\frac{1}{2})$
|
||||
with components at $(m, n \\pm \\frac{1}{2}, p \\pm \\frac{1}{2})$ etc.
|
||||
where $\hat{g}$ and $\tilde{g}$ are located at $(m,n,p)$
|
||||
with components at $(m \pm \frac{1}{2},n,p)$ etc.,
|
||||
while $\hat{h}$ and $\tilde{h}$ are located at $(m \pm \frac{1}{2}, n \pm \frac{1}{2}, p \pm \frac{1}{2})$
|
||||
with components at $(m, n \pm \frac{1}{2}, p \pm \frac{1}{2})$ etc.
|
||||
|
||||
|
||||
[code: curls]
|
||||
|
|
@ -287,27 +287,27 @@ Maxwell's Equations
|
|||
|
||||
If we discretize both space (m,n,p) and time (l), Maxwell's equations become
|
||||
|
||||
$$ \\begin{aligned}
|
||||
\\tilde{\\nabla} \\times \\tilde{E}_{l,\\vec{r}} &= -\\tilde{\\partial}_t \\hat{B}_{l-\\frac{1}{2}, \\vec{r} + \\frac{1}{2}}
|
||||
- \\hat{M}_{l, \\vec{r} + \\frac{1}{2}} \\\\
|
||||
\\hat{\\nabla} \\times \\hat{H}_{l-\\frac{1}{2},\\vec{r} + \\frac{1}{2}} &= \\hat{\\partial}_t \\tilde{D}_{l, \\vec{r}}
|
||||
+ \\tilde{J}_{l-\\frac{1}{2},\\vec{r}} \\\\
|
||||
\\tilde{\\nabla} \\cdot \\hat{B}_{l-\\frac{1}{2}, \\vec{r} + \\frac{1}{2}} &= 0 \\\\
|
||||
\\hat{\\nabla} \\cdot \\tilde{D}_{l,\\vec{r}} &= \\rho_{l,\\vec{r}}
|
||||
\\end{aligned} $$
|
||||
$$ \begin{aligned}
|
||||
\tilde{\nabla} \times \tilde{E}_{l,\vec{r}} &= -\tilde{\partial}_t \hat{B}_{l-\frac{1}{2}, \vec{r} + \frac{1}{2}}
|
||||
- \hat{M}_{l, \vec{r} + \frac{1}{2}} \\
|
||||
\hat{\nabla} \times \hat{H}_{l-\frac{1}{2},\vec{r} + \frac{1}{2}} &= \hat{\partial}_t \tilde{D}_{l, \vec{r}}
|
||||
+ \tilde{J}_{l-\frac{1}{2},\vec{r}} \\
|
||||
\tilde{\nabla} \cdot \hat{B}_{l-\frac{1}{2}, \vec{r} + \frac{1}{2}} &= 0 \\
|
||||
\hat{\nabla} \cdot \tilde{D}_{l,\vec{r}} &= \rho_{l,\vec{r}}
|
||||
\end{aligned} $$
|
||||
|
||||
with
|
||||
|
||||
$$ \\begin{aligned}
|
||||
\\hat{B}_{\\vec{r}} &= \\mu_{\\vec{r} + \\frac{1}{2}} \\cdot \\hat{H}_{\\vec{r} + \\frac{1}{2}} \\\\
|
||||
\\tilde{D}_{\\vec{r}} &= \\epsilon_{\\vec{r}} \\cdot \\tilde{E}_{\\vec{r}}
|
||||
\\end{aligned} $$
|
||||
$$ \begin{aligned}
|
||||
\hat{B}_{\vec{r}} &= \mu_{\vec{r} + \frac{1}{2}} \cdot \hat{H}_{\vec{r} + \frac{1}{2}} \\
|
||||
\tilde{D}_{\vec{r}} &= \epsilon_{\vec{r}} \cdot \tilde{E}_{\vec{r}}
|
||||
\end{aligned} $$
|
||||
|
||||
where the spatial subscripts are abbreviated as $\\vec{r} = (m, n, p)$ and
|
||||
$\\vec{r} + \\frac{1}{2} = (m + \\frac{1}{2}, n + \\frac{1}{2}, p + \\frac{1}{2})$,
|
||||
$\\tilde{E}$ and $\\hat{H}$ are the electric and magnetic fields,
|
||||
$\\tilde{J}$ and $\\hat{M}$ are the electric and magnetic current distributions,
|
||||
and $\\epsilon$ and $\\mu$ are the dielectric permittivity and magnetic permeability.
|
||||
where the spatial subscripts are abbreviated as $\vec{r} = (m, n, p)$ and
|
||||
$\vec{r} + \frac{1}{2} = (m + \frac{1}{2}, n + \frac{1}{2}, p + \frac{1}{2})$,
|
||||
$\tilde{E}$ and $\hat{H}$ are the electric and magnetic fields,
|
||||
$\tilde{J}$ and $\hat{M}$ are the electric and magnetic current distributions,
|
||||
and $\epsilon$ and $\mu$ are the dielectric permittivity and magnetic permeability.
|
||||
|
||||
The above is Yee's algorithm, written in a form analogous to Maxwell's equations.
|
||||
The time derivatives can be expanded to form the update equations:
|
||||
|
|
@ -369,34 +369,34 @@ Each component forms its own grid, offset from the others:
|
|||
|
||||
The divergence equations can be derived by taking the divergence of the curl equations
|
||||
and combining them with charge continuity,
|
||||
$$ \\hat{\\nabla} \\cdot \\tilde{J} + \\hat{\\partial}_t \\rho = 0 $$
|
||||
$$ \hat{\nabla} \cdot \tilde{J} + \hat{\partial}_t \rho = 0 $$
|
||||
implying that the discrete Maxwell's equations do not produce spurious charges.
|
||||
|
||||
|
||||
Wave equation
|
||||
-------------
|
||||
|
||||
Taking the backward curl of the $\\tilde{\\nabla} \\times \\tilde{E}$ equation and
|
||||
replacing the resulting $\\hat{\\nabla} \\times \\hat{H}$ term using its respective equation,
|
||||
and setting $\\hat{M}$ to zero, we can form the discrete wave equation:
|
||||
Taking the backward curl of the $\tilde{\nabla} \times \tilde{E}$ equation and
|
||||
replacing the resulting $\hat{\nabla} \times \hat{H}$ term using its respective equation,
|
||||
and setting $\hat{M}$ to zero, we can form the discrete wave equation:
|
||||
|
||||
$$
|
||||
\\begin{aligned}
|
||||
\\tilde{\\nabla} \\times \\tilde{E}_{l,\\vec{r}} &=
|
||||
-\\tilde{\\partial}_t \\hat{B}_{l-\\frac{1}{2}, \\vec{r} + \\frac{1}{2}}
|
||||
- \\hat{M}_{l-1, \\vec{r} + \\frac{1}{2}} \\\\
|
||||
\\mu^{-1}_{\\vec{r} + \\frac{1}{2}} \\cdot \\tilde{\\nabla} \\times \\tilde{E}_{l,\\vec{r}} &=
|
||||
-\\tilde{\\partial}_t \\hat{H}_{l-\\frac{1}{2}, \\vec{r} + \\frac{1}{2}} \\\\
|
||||
\\hat{\\nabla} \\times (\\mu^{-1}_{\\vec{r} + \\frac{1}{2}} \\cdot \\tilde{\\nabla} \\times \\tilde{E}_{l,\\vec{r}}) &=
|
||||
\\hat{\\nabla} \\times (-\\tilde{\\partial}_t \\hat{H}_{l-\\frac{1}{2}, \\vec{r} + \\frac{1}{2}}) \\\\
|
||||
\\hat{\\nabla} \\times (\\mu^{-1}_{\\vec{r} + \\frac{1}{2}} \\cdot \\tilde{\\nabla} \\times \\tilde{E}_{l,\\vec{r}}) &=
|
||||
-\\tilde{\\partial}_t \\hat{\\nabla} \\times \\hat{H}_{l-\\frac{1}{2}, \\vec{r} + \\frac{1}{2}} \\\\
|
||||
\\hat{\\nabla} \\times (\\mu^{-1}_{\\vec{r} + \\frac{1}{2}} \\cdot \\tilde{\\nabla} \\times \\tilde{E}_{l,\\vec{r}}) &=
|
||||
-\\tilde{\\partial}_t \\hat{\\partial}_t \\epsilon_{\\vec{r}} \\tilde{E}_{l, \\vec{r}} + \\hat{\\partial}_t \\tilde{J}_{l-\\frac{1}{2},\\vec{r}} \\\\
|
||||
\\hat{\\nabla} \\times (\\mu^{-1}_{\\vec{r} + \\frac{1}{2}} \\cdot \\tilde{\\nabla} \\times \\tilde{E}_{l,\\vec{r}})
|
||||
+ \\tilde{\\partial}_t \\hat{\\partial}_t \\epsilon_{\\vec{r}} \\cdot \\tilde{E}_{l, \\vec{r}}
|
||||
&= \\tilde{\\partial}_t \\tilde{J}_{l - \\frac{1}{2}, \\vec{r}}
|
||||
\\end{aligned}
|
||||
\begin{aligned}
|
||||
\tilde{\nabla} \times \tilde{E}_{l,\vec{r}} &=
|
||||
-\tilde{\partial}_t \hat{B}_{l-\frac{1}{2}, \vec{r} + \frac{1}{2}}
|
||||
- \hat{M}_{l-1, \vec{r} + \frac{1}{2}} \\
|
||||
\mu^{-1}_{\vec{r} + \frac{1}{2}} \cdot \tilde{\nabla} \times \tilde{E}_{l,\vec{r}} &=
|
||||
-\tilde{\partial}_t \hat{H}_{l-\frac{1}{2}, \vec{r} + \frac{1}{2}} \\
|
||||
\hat{\nabla} \times (\mu^{-1}_{\vec{r} + \frac{1}{2}} \cdot \tilde{\nabla} \times \tilde{E}_{l,\vec{r}}) &=
|
||||
\hat{\nabla} \times (-\tilde{\partial}_t \hat{H}_{l-\frac{1}{2}, \vec{r} + \frac{1}{2}}) \\
|
||||
\hat{\nabla} \times (\mu^{-1}_{\vec{r} + \frac{1}{2}} \cdot \tilde{\nabla} \times \tilde{E}_{l,\vec{r}}) &=
|
||||
-\tilde{\partial}_t \hat{\nabla} \times \hat{H}_{l-\frac{1}{2}, \vec{r} + \frac{1}{2}} \\
|
||||
\hat{\nabla} \times (\mu^{-1}_{\vec{r} + \frac{1}{2}} \cdot \tilde{\nabla} \times \tilde{E}_{l,\vec{r}}) &=
|
||||
-\tilde{\partial}_t \hat{\partial}_t \epsilon_{\vec{r}} \tilde{E}_{l, \vec{r}} + \hat{\partial}_t \tilde{J}_{l-\frac{1}{2},\vec{r}} \\
|
||||
\hat{\nabla} \times (\mu^{-1}_{\vec{r} + \frac{1}{2}} \cdot \tilde{\nabla} \times \tilde{E}_{l,\vec{r}})
|
||||
+ \tilde{\partial}_t \hat{\partial}_t \epsilon_{\vec{r}} \cdot \tilde{E}_{l, \vec{r}}
|
||||
&= \tilde{\partial}_t \tilde{J}_{l - \frac{1}{2}, \vec{r}}
|
||||
\end{aligned}
|
||||
$$
|
||||
|
||||
|
||||
|
|
@ -406,27 +406,27 @@ Frequency domain
|
|||
We can substitute in a time-harmonic fields
|
||||
|
||||
$$
|
||||
\\begin{aligned}
|
||||
\\tilde{E}_{l, \\vec{r}} &= \\tilde{E}_{\\vec{r}} e^{-\\imath \\omega l \\Delta_t} \\\\
|
||||
\\tilde{J}_{l, \\vec{r}} &= \\tilde{J}_{\\vec{r}} e^{-\\imath \\omega (l - \\frac{1}{2}) \\Delta_t}
|
||||
\\end{aligned}
|
||||
\begin{aligned}
|
||||
\tilde{E}_{l, \vec{r}} &= \tilde{E}_{\vec{r}} e^{-\imath \omega l \Delta_t} \\
|
||||
\tilde{J}_{l, \vec{r}} &= \tilde{J}_{\vec{r}} e^{-\imath \omega (l - \frac{1}{2}) \Delta_t}
|
||||
\end{aligned}
|
||||
$$
|
||||
|
||||
resulting in
|
||||
|
||||
$$
|
||||
\\begin{aligned}
|
||||
\\tilde{\\partial}_t &\\Rightarrow (e^{ \\imath \\omega \\Delta_t} - 1) / \\Delta_t = \\frac{-2 \\imath}{\\Delta_t} \\sin(\\omega \\Delta_t / 2) e^{-\\imath \\omega \\Delta_t / 2} = -\\imath \\Omega e^{-\\imath \\omega \\Delta_t / 2}\\\\
|
||||
\\hat{\\partial}_t &\\Rightarrow (1 - e^{-\\imath \\omega \\Delta_t}) / \\Delta_t = \\frac{-2 \\imath}{\\Delta_t} \\sin(\\omega \\Delta_t / 2) e^{ \\imath \\omega \\Delta_t / 2} = -\\imath \\Omega e^{ \\imath \\omega \\Delta_t / 2}\\\\
|
||||
\\Omega &= 2 \\sin(\\omega \\Delta_t / 2) / \\Delta_t
|
||||
\\end{aligned}
|
||||
\begin{aligned}
|
||||
\tilde{\partial}_t &\Rightarrow (e^{ \imath \omega \Delta_t} - 1) / \Delta_t = \frac{-2 \imath}{\Delta_t} \sin(\omega \Delta_t / 2) e^{-\imath \omega \Delta_t / 2} = -\imath \Omega e^{-\imath \omega \Delta_t / 2}\\
|
||||
\hat{\partial}_t &\Rightarrow (1 - e^{-\imath \omega \Delta_t}) / \Delta_t = \frac{-2 \imath}{\Delta_t} \sin(\omega \Delta_t / 2) e^{ \imath \omega \Delta_t / 2} = -\imath \Omega e^{ \imath \omega \Delta_t / 2}\\
|
||||
\Omega &= 2 \sin(\omega \Delta_t / 2) / \Delta_t
|
||||
\end{aligned}
|
||||
$$
|
||||
|
||||
This gives the frequency-domain wave equation,
|
||||
|
||||
$$
|
||||
\\hat{\\nabla} \\times (\\mu^{-1}_{\\vec{r} + \\frac{1}{2}} \\cdot \\tilde{\\nabla} \\times \\tilde{E}_{\\vec{r}})
|
||||
-\\Omega^2 \\epsilon_{\\vec{r}} \\cdot \\tilde{E}_{\\vec{r}} = -\\imath \\Omega \\tilde{J}_{\\vec{r}} e^{\\imath \\omega \\Delta_t / 2} \\\\
|
||||
\hat{\nabla} \times (\mu^{-1}_{\vec{r} + \frac{1}{2}} \cdot \tilde{\nabla} \times \tilde{E}_{\vec{r}})
|
||||
-\Omega^2 \epsilon_{\vec{r}} \cdot \tilde{E}_{\vec{r}} = -\imath \Omega \tilde{J}_{\vec{r}} e^{\imath \omega \Delta_t / 2} \\
|
||||
$$
|
||||
|
||||
|
||||
|
|
@ -436,48 +436,48 @@ Plane waves and Dispersion relation
|
|||
With uniform material distribution and no sources
|
||||
|
||||
$$
|
||||
\\begin{aligned}
|
||||
\\mu_{\\vec{r} + \\frac{1}{2}} &= \\mu \\\\
|
||||
\\epsilon_{\\vec{r}} &= \\epsilon \\\\
|
||||
\\tilde{J}_{\\vec{r}} &= 0 \\\\
|
||||
\\end{aligned}
|
||||
\begin{aligned}
|
||||
\mu_{\vec{r} + \frac{1}{2}} &= \mu \\
|
||||
\epsilon_{\vec{r}} &= \epsilon \\
|
||||
\tilde{J}_{\vec{r}} &= 0 \\
|
||||
\end{aligned}
|
||||
$$
|
||||
|
||||
the frequency domain wave equation simplifies to
|
||||
|
||||
$$ \\hat{\\nabla} \\times \\tilde{\\nabla} \\times \\tilde{E}_{\\vec{r}} - \\Omega^2 \\epsilon \\mu \\tilde{E}_{\\vec{r}} = 0 $$
|
||||
$$ \hat{\nabla} \times \tilde{\nabla} \times \tilde{E}_{\vec{r}} - \Omega^2 \epsilon \mu \tilde{E}_{\vec{r}} = 0 $$
|
||||
|
||||
Since $\\hat{\\nabla} \\cdot \\tilde{E}_{\\vec{r}} = 0$, we can simplify
|
||||
Since $\hat{\nabla} \cdot \tilde{E}_{\vec{r}} = 0$, we can simplify
|
||||
|
||||
$$
|
||||
\\begin{aligned}
|
||||
\\hat{\\nabla} \\times \\tilde{\\nabla} \\times \\tilde{E}_{\\vec{r}}
|
||||
&= \\tilde{\\nabla}(\\hat{\\nabla} \\cdot \\tilde{E}_{\\vec{r}}) - \\hat{\\nabla} \\cdot \\tilde{\\nabla} \\tilde{E}_{\\vec{r}} \\\\
|
||||
&= - \\hat{\\nabla} \\cdot \\tilde{\\nabla} \\tilde{E}_{\\vec{r}} \\\\
|
||||
&= - \\tilde{\\nabla}^2 \\tilde{E}_{\\vec{r}}
|
||||
\\end{aligned}
|
||||
\begin{aligned}
|
||||
\hat{\nabla} \times \tilde{\nabla} \times \tilde{E}_{\vec{r}}
|
||||
&= \tilde{\nabla}(\hat{\nabla} \cdot \tilde{E}_{\vec{r}}) - \hat{\nabla} \cdot \tilde{\nabla} \tilde{E}_{\vec{r}} \\
|
||||
&= - \hat{\nabla} \cdot \tilde{\nabla} \tilde{E}_{\vec{r}} \\
|
||||
&= - \tilde{\nabla}^2 \tilde{E}_{\vec{r}}
|
||||
\end{aligned}
|
||||
$$
|
||||
|
||||
and we get
|
||||
|
||||
$$ \\tilde{\\nabla}^2 \\tilde{E}_{\\vec{r}} + \\Omega^2 \\epsilon \\mu \\tilde{E}_{\\vec{r}} = 0 $$
|
||||
$$ \tilde{\nabla}^2 \tilde{E}_{\vec{r}} + \Omega^2 \epsilon \mu \tilde{E}_{\vec{r}} = 0 $$
|
||||
|
||||
We can convert this to three scalar-wave equations of the form
|
||||
|
||||
$$ (\\tilde{\\nabla}^2 + K^2) \\phi_{\\vec{r}} = 0 $$
|
||||
$$ (\tilde{\nabla}^2 + K^2) \phi_{\vec{r}} = 0 $$
|
||||
|
||||
with $K^2 = \\Omega^2 \\mu \\epsilon$. Now we let
|
||||
with $K^2 = \Omega^2 \mu \epsilon$. Now we let
|
||||
|
||||
$$ \\phi_{\\vec{r}} = A e^{\\imath (k_x m \\Delta_x + k_y n \\Delta_y + k_z p \\Delta_z)} $$
|
||||
$$ \phi_{\vec{r}} = A e^{\imath (k_x m \Delta_x + k_y n \Delta_y + k_z p \Delta_z)} $$
|
||||
|
||||
resulting in
|
||||
|
||||
$$
|
||||
\\begin{aligned}
|
||||
\\tilde{\\partial}_x &\\Rightarrow (e^{ \\imath k_x \\Delta_x} - 1) / \\Delta_t = \\frac{-2 \\imath}{\\Delta_x} \\sin(k_x \\Delta_x / 2) e^{ \\imath k_x \\Delta_x / 2} = \\imath K_x e^{ \\imath k_x \\Delta_x / 2}\\\\
|
||||
\\hat{\\partial}_x &\\Rightarrow (1 - e^{-\\imath k_x \\Delta_x}) / \\Delta_t = \\frac{-2 \\imath}{\\Delta_x} \\sin(k_x \\Delta_x / 2) e^{-\\imath k_x \\Delta_x / 2} = \\imath K_x e^{-\\imath k_x \\Delta_x / 2}\\\\
|
||||
K_x &= 2 \\sin(k_x \\Delta_x / 2) / \\Delta_x \\\\
|
||||
\\end{aligned}
|
||||
\begin{aligned}
|
||||
\tilde{\partial}_x &\Rightarrow (e^{ \imath k_x \Delta_x} - 1) / \Delta_t = \frac{-2 \imath}{\Delta_x} \sin(k_x \Delta_x / 2) e^{ \imath k_x \Delta_x / 2} = \imath K_x e^{ \imath k_x \Delta_x / 2}\\
|
||||
\hat{\partial}_x &\Rightarrow (1 - e^{-\imath k_x \Delta_x}) / \Delta_t = \frac{-2 \imath}{\Delta_x} \sin(k_x \Delta_x / 2) e^{-\imath k_x \Delta_x / 2} = \imath K_x e^{-\imath k_x \Delta_x / 2}\\
|
||||
K_x &= 2 \sin(k_x \Delta_x / 2) / \Delta_x \\
|
||||
\end{aligned}
|
||||
$$
|
||||
|
||||
with similar expressions for the y and z dimnsions (and $K_y, K_z$).
|
||||
|
|
@ -485,20 +485,20 @@ with similar expressions for the y and z dimnsions (and $K_y, K_z$).
|
|||
This implies
|
||||
|
||||
$$
|
||||
\\tilde{\\nabla}^2 = -(K_x^2 + K_y^2 + K_z^2) \\phi_{\\vec{r}} \\\\
|
||||
K_x^2 + K_y^2 + K_z^2 = \\Omega^2 \\mu \\epsilon = \\Omega^2 / c^2
|
||||
\tilde{\nabla}^2 = -(K_x^2 + K_y^2 + K_z^2) \phi_{\vec{r}} \\
|
||||
K_x^2 + K_y^2 + K_z^2 = \Omega^2 \mu \epsilon = \Omega^2 / c^2
|
||||
$$
|
||||
|
||||
where $c = \\sqrt{\\mu \\epsilon}$.
|
||||
where $c = \sqrt{\mu \epsilon}$.
|
||||
|
||||
Assuming real $(k_x, k_y, k_z), \\omega$ will be real only if
|
||||
Assuming real $(k_x, k_y, k_z), \omega$ will be real only if
|
||||
|
||||
$$ c^2 \\Delta_t^2 = \\frac{\\Delta_t^2}{\\mu \\epsilon} < 1/(\\frac{1}{\\Delta_x^2} + \\frac{1}{\\Delta_y^2} + \\frac{1}{\\Delta_z^2}) $$
|
||||
$$ c^2 \Delta_t^2 = \frac{\Delta_t^2}{\mu \epsilon} < 1/(\frac{1}{\Delta_x^2} + \frac{1}{\Delta_y^2} + \frac{1}{\Delta_z^2}) $$
|
||||
|
||||
If $\\Delta_x = \\Delta_y = \\Delta_z$, this simplifies to $c \\Delta_t < \\Delta_x / \\sqrt{3}$.
|
||||
If $\Delta_x = \Delta_y = \Delta_z$, this simplifies to $c \Delta_t < \Delta_x / \sqrt{3}$.
|
||||
This last form can be interpreted as enforcing causality; the distance that light
|
||||
travels in one timestep (i.e., $c \\Delta_t$) must be less than the diagonal
|
||||
of the smallest cell ( $\\Delta_x / \\sqrt{3}$ when on a uniform cubic grid).
|
||||
travels in one timestep (i.e., $c \Delta_t$) must be less than the diagonal
|
||||
of the smallest cell ( $\Delta_x / \sqrt{3}$ when on a uniform cubic grid).
|
||||
|
||||
|
||||
Grid description
|
||||
|
|
@ -515,8 +515,8 @@ to make the illustration simpler; we need at least two cells in the x dimension
|
|||
demonstrate how nonuniform `dx` affects the various components.
|
||||
|
||||
Place the E fore-vectors at integer indices $r = (m, n, p)$ and the H back-vectors
|
||||
at fractional indices $r + \\frac{1}{2} = (m + \\frac{1}{2}, n + \\frac{1}{2},
|
||||
p + \\frac{1}{2})$. Remember that these are indices and not coordinates; they can
|
||||
at fractional indices $r + \frac{1}{2} = (m + \frac{1}{2}, n + \frac{1}{2},
|
||||
p + \frac{1}{2})$. Remember that these are indices and not coordinates; they can
|
||||
correspond to arbitrary (monotonically increasing) coordinates depending on the cell widths.
|
||||
|
||||
Draw lines to denote the planes on which the H components and back-vectors are defined.
|
||||
|
|
@ -718,14 +718,14 @@ composed of the three diagonal tensor components:
|
|||
or
|
||||
|
||||
$$
|
||||
\\epsilon = \\begin{bmatrix} \\epsilon_{xx} & 0 & 0 \\\\
|
||||
0 & \\epsilon_{yy} & 0 \\\\
|
||||
0 & 0 & \\epsilon_{zz} \\end{bmatrix}
|
||||
\epsilon = \begin{bmatrix} \epsilon_{xx} & 0 & 0 \\
|
||||
0 & \epsilon_{yy} & 0 \\
|
||||
0 & 0 & \epsilon_{zz} \end{bmatrix}
|
||||
$$
|
||||
$$
|
||||
\\mu = \\begin{bmatrix} \\mu_{xx} & 0 & 0 \\\\
|
||||
0 & \\mu_{yy} & 0 \\\\
|
||||
0 & 0 & \\mu_{zz} \\end{bmatrix}
|
||||
\mu = \begin{bmatrix} \mu_{xx} & 0 & 0 \\
|
||||
0 & \mu_{yy} & 0 \\
|
||||
0 & 0 & \mu_{zz} \end{bmatrix}
|
||||
$$
|
||||
|
||||
where the off-diagonal terms (e.g. `epsilon_xy`) are assumed to be zero.
|
||||
|
|
@ -741,8 +741,46 @@ the true values can be multiplied back in after the simulation is complete if no
|
|||
normalized results are needed.
|
||||
"""
|
||||
|
||||
from .types import fdfield_t, vfdfield_t, cfdfield_t, vcfdfield_t, dx_lists_t, dx_lists_mut
|
||||
from .types import fdfield_updater_t, cfdfield_updater_t
|
||||
from .vectorization import vec, unvec
|
||||
from . import operators, functional, types, vectorization
|
||||
from .types import (
|
||||
fdfield_t as fdfield_t,
|
||||
vfdfield_t as vfdfield_t,
|
||||
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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -3,16 +3,18 @@ Math functions for finite difference simulations
|
|||
|
||||
Basic discrete calculus etc.
|
||||
"""
|
||||
from typing import Sequence, Callable
|
||||
from typing import TypeVar
|
||||
from collections.abc import Sequence, Callable
|
||||
|
||||
import numpy
|
||||
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(
|
||||
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]:
|
||||
"""
|
||||
Utility operators for taking discretized derivatives (backward variant).
|
||||
|
|
@ -36,7 +38,7 @@ def deriv_forward(
|
|||
|
||||
|
||||
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]:
|
||||
"""
|
||||
Utility operators for taking discretized derivatives (forward variant).
|
||||
|
|
@ -59,10 +61,13 @@ def deriv_back(
|
|||
return derivs
|
||||
|
||||
|
||||
TT = TypeVar('TT', bound='NDArray[floating | complexfloating]')
|
||||
|
||||
|
||||
def curl_forward(
|
||||
dx_e: Sequence[NDArray[numpy.float_]] | None = None,
|
||||
) -> fdfield_updater_t:
|
||||
"""
|
||||
dx_e: Sequence[NDArray[floating | complexfloating]] | None = None,
|
||||
) -> Callable[[TT], TT]:
|
||||
r"""
|
||||
Curl operator for use with the E field.
|
||||
|
||||
Args:
|
||||
|
|
@ -71,11 +76,11 @@ def curl_forward(
|
|||
|
||||
Returns:
|
||||
Function `f` for taking the discrete forward curl of a field,
|
||||
`f(E)` -> curlE $= \\nabla_f \\times E$
|
||||
`f(E)` -> curlE $= \nabla_f \times E$
|
||||
"""
|
||||
Dx, Dy, Dz = deriv_forward(dx_e)
|
||||
|
||||
def ce_fun(e: fdfield_t) -> fdfield_t:
|
||||
def ce_fun(e: TT) -> TT:
|
||||
output = numpy.empty_like(e)
|
||||
output[0] = Dy(e[2])
|
||||
output[1] = Dz(e[0])
|
||||
|
|
@ -89,9 +94,9 @@ def curl_forward(
|
|||
|
||||
|
||||
def curl_back(
|
||||
dx_h: Sequence[NDArray[numpy.float_]] | None = None,
|
||||
) -> fdfield_updater_t:
|
||||
"""
|
||||
dx_h: Sequence[NDArray[floating | complexfloating]] | None = None,
|
||||
) -> Callable[[TT], TT]:
|
||||
r"""
|
||||
Create a function which takes the backward curl of a field.
|
||||
|
||||
Args:
|
||||
|
|
@ -100,11 +105,11 @@ def curl_back(
|
|||
|
||||
Returns:
|
||||
Function `f` for taking the discrete backward curl of a field,
|
||||
`f(H)` -> curlH $= \\nabla_b \\times H$
|
||||
`f(H)` -> curlH $= \nabla_b \times H$
|
||||
"""
|
||||
Dx, Dy, Dz = deriv_back(dx_h)
|
||||
|
||||
def ch_fun(h: fdfield_t) -> fdfield_t:
|
||||
def ch_fun(h: TT) -> TT:
|
||||
output = numpy.empty_like(h)
|
||||
output[0] = Dy(h[2])
|
||||
output[1] = Dz(h[0])
|
||||
|
|
@ -118,11 +123,11 @@ def curl_back(
|
|||
|
||||
|
||||
def curl_forward_parts(
|
||||
dx_e: Sequence[NDArray[numpy.float_]] | None = None,
|
||||
dx_e: Sequence[NDArray[floating | complexfloating]] | None = None,
|
||||
) -> Callable:
|
||||
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])),
|
||||
( Dz(e[0]), -Dx(e[2])),
|
||||
(-Dy(e[0]), Dx(e[1])))
|
||||
|
|
@ -131,11 +136,11 @@ def curl_forward_parts(
|
|||
|
||||
|
||||
def curl_back_parts(
|
||||
dx_h: Sequence[NDArray[numpy.float_]] | None = None,
|
||||
dx_h: Sequence[NDArray[floating | complexfloating]] | None = None,
|
||||
) -> Callable:
|
||||
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])),
|
||||
( Dz(h[0]), -Dx(h[2])),
|
||||
(-Dy(h[0]), Dx(h[1])))
|
||||
|
|
|
|||
|
|
@ -3,19 +3,20 @@ Matrix operators for finite difference simulations
|
|||
|
||||
Basic discrete calculus etc.
|
||||
"""
|
||||
from typing import Sequence
|
||||
from collections.abc import Sequence
|
||||
import numpy
|
||||
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(
|
||||
axis: int,
|
||||
shape: Sequence[int],
|
||||
shift_distance: int = 1,
|
||||
) -> sparse.spmatrix:
|
||||
) -> sparse.sparray:
|
||||
"""
|
||||
Utility operator for performing a circular shift along a specified axis by a
|
||||
specified number of elements.
|
||||
|
|
@ -29,12 +30,12 @@ def shift_circ(
|
|||
Sparse matrix for performing the circular shift.
|
||||
"""
|
||||
if len(shape) not in (2, 3):
|
||||
raise Exception('Invalid shape: {}'.format(shape))
|
||||
raise Exception(f'Invalid shape: {shape}')
|
||||
if axis not in range(len(shape)):
|
||||
raise Exception('Invalid direction: {}, shape is {}'.format(axis, shape))
|
||||
raise Exception(f'Invalid direction: {axis}, shape is {shape}')
|
||||
|
||||
shifts = [abs(shift_distance) if a == axis else 0 for a in range(3)]
|
||||
shifted_diags = [(numpy.arange(n) + s) % n for n, s in zip(shape, shifts)]
|
||||
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, strict=True)]
|
||||
ijk = numpy.meshgrid(*shifted_diags, indexing='ij')
|
||||
|
||||
n = numpy.prod(shape)
|
||||
|
|
@ -43,7 +44,7 @@ def shift_circ(
|
|||
|
||||
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:
|
||||
d = d.T
|
||||
|
|
@ -55,7 +56,7 @@ def shift_with_mirror(
|
|||
axis: int,
|
||||
shape: Sequence[int],
|
||||
shift_distance: int = 1,
|
||||
) -> sparse.spmatrix:
|
||||
) -> sparse.sparray:
|
||||
"""
|
||||
Utility operator for performing an n-element shift along a specified axis, with mirror
|
||||
boundary conditions applied to the cells beyond the receding edge.
|
||||
|
|
@ -69,12 +70,11 @@ def shift_with_mirror(
|
|||
Sparse matrix for performing the shift-with-mirror.
|
||||
"""
|
||||
if len(shape) not in (2, 3):
|
||||
raise Exception('Invalid shape: {}'.format(shape))
|
||||
raise Exception(f'Invalid shape: {shape}')
|
||||
if axis not in range(len(shape)):
|
||||
raise Exception('Invalid direction: {}, shape is {}'.format(axis, shape))
|
||||
raise Exception(f'Invalid direction: {axis}, shape is {shape}')
|
||||
if shift_distance >= shape[axis]:
|
||||
raise Exception('Shift ({}) is too large for axis {} of size {}'.format(
|
||||
shift_distance, axis, shape[axis]))
|
||||
raise Exception(f'Shift ({shift_distance}) is too large for axis {axis} of size {shape[axis]}')
|
||||
|
||||
def mirrored_range(n: int, s: int) -> NDArray[numpy.int_]:
|
||||
v = numpy.arange(n) + s
|
||||
|
|
@ -82,8 +82,8 @@ def shift_with_mirror(
|
|||
v = numpy.where(v < 0, - 1 - v, v)
|
||||
return v
|
||||
|
||||
shifts = [shift_distance if a == axis else 0 for a in range(3)]
|
||||
shifted_diags = [mirrored_range(n, s) for n, s in zip(shape, shifts)]
|
||||
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, strict=True)]
|
||||
ijk = numpy.meshgrid(*shifted_diags, indexing='ij')
|
||||
|
||||
n = numpy.prod(shape)
|
||||
|
|
@ -92,13 +92,13 @@ def shift_with_mirror(
|
|||
|
||||
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
|
||||
|
||||
|
||||
def deriv_forward(
|
||||
dx_e: Sequence[NDArray[numpy.float_]],
|
||||
) -> list[sparse.spmatrix]:
|
||||
dx_e: Sequence[NDArray[floating | complexfloating]],
|
||||
) -> list[sparse.sparray]:
|
||||
"""
|
||||
Utility operators for taking discretized derivatives (forward variant).
|
||||
|
||||
|
|
@ -114,18 +114,18 @@ def deriv_forward(
|
|||
|
||||
dx_e_expanded = numpy.meshgrid(*dx_e, indexing='ij')
|
||||
|
||||
def deriv(axis: int) -> sparse.spmatrix:
|
||||
return shift_circ(axis, shape, 1) - sparse.eye(n)
|
||||
def deriv(axis: int) -> sparse.sparray:
|
||||
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)]
|
||||
|
||||
return Ds
|
||||
|
||||
|
||||
def deriv_back(
|
||||
dx_h: Sequence[NDArray[numpy.float_]],
|
||||
) -> list[sparse.spmatrix]:
|
||||
dx_h: Sequence[NDArray[floating | complexfloating]],
|
||||
) -> list[sparse.sparray]:
|
||||
"""
|
||||
Utility operators for taking discretized derivatives (backward variant).
|
||||
|
||||
|
|
@ -141,18 +141,18 @@ def deriv_back(
|
|||
|
||||
dx_h_expanded = numpy.meshgrid(*dx_h, indexing='ij')
|
||||
|
||||
def deriv(axis: int) -> sparse.spmatrix:
|
||||
return shift_circ(axis, shape, -1) - sparse.eye(n)
|
||||
def deriv(axis: int) -> sparse.sparray:
|
||||
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)]
|
||||
|
||||
return Ds
|
||||
|
||||
|
||||
def cross(
|
||||
B: Sequence[sparse.spmatrix],
|
||||
) -> sparse.spmatrix:
|
||||
B: Sequence[sparse.sparray],
|
||||
) -> sparse.sparray:
|
||||
"""
|
||||
Cross product operator
|
||||
|
||||
|
|
@ -164,13 +164,14 @@ def cross(
|
|||
Sparse matrix corresponding to (B x), where x is the cross product.
|
||||
"""
|
||||
n = B[0].shape[0]
|
||||
zero = sparse.csr_matrix((n, n))
|
||||
return sparse.bmat([[zero, -B[2], B[1]],
|
||||
zero = sparse.csr_array((n, n))
|
||||
return sparse.block_array([
|
||||
[zero, -B[2], B[1]],
|
||||
[B[2], zero, -B[0]],
|
||||
[-B[1], B[0], zero]])
|
||||
|
||||
|
||||
def vec_cross(b: vfdfield_t) -> sparse.spmatrix:
|
||||
def vec_cross(b: vfdfield) -> sparse.sparray:
|
||||
"""
|
||||
Vector cross product operator
|
||||
|
||||
|
|
@ -182,11 +183,11 @@ def vec_cross(b: vfdfield_t) -> sparse.spmatrix:
|
|||
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)
|
||||
|
||||
|
||||
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)`
|
||||
|
||||
|
|
@ -198,13 +199,13 @@ def avg_forward(axis: int, shape: Sequence[int]) -> sparse.spmatrix:
|
|||
Sparse matrix for forward average operation.
|
||||
"""
|
||||
if len(shape) not in (2, 3):
|
||||
raise Exception('Invalid shape: {}'.format(shape))
|
||||
raise Exception(f'Invalid shape: {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)`
|
||||
|
||||
|
|
@ -219,8 +220,8 @@ def avg_back(axis: int, shape: Sequence[int]) -> sparse.spmatrix:
|
|||
|
||||
|
||||
def curl_forward(
|
||||
dx_e: Sequence[NDArray[numpy.float_]],
|
||||
) -> sparse.spmatrix:
|
||||
dx_e: Sequence[NDArray[floating | complexfloating]],
|
||||
) -> sparse.sparray:
|
||||
"""
|
||||
Curl operator for use with the E field.
|
||||
|
||||
|
|
@ -235,8 +236,8 @@ def curl_forward(
|
|||
|
||||
|
||||
def curl_back(
|
||||
dx_h: Sequence[NDArray[numpy.float_]],
|
||||
) -> sparse.spmatrix:
|
||||
dx_h: Sequence[NDArray[floating | complexfloating]],
|
||||
) -> sparse.sparray:
|
||||
"""
|
||||
Curl operator for use with the H field.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,26 +1,65 @@
|
|||
"""
|
||||
Types shared across multiple submodules
|
||||
"""
|
||||
from typing import Sequence, Callable, MutableSequence
|
||||
import numpy
|
||||
from typing import NewType
|
||||
from collections.abc import Sequence, Callable, MutableSequence
|
||||
from numpy.typing import NDArray
|
||||
from numpy import floating, complexfloating
|
||||
|
||||
|
||||
# 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]`)"""
|
||||
|
||||
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)"""
|
||||
|
||||
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]`)"""
|
||||
|
||||
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)"""
|
||||
|
||||
|
||||
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:
|
||||
|
||||
|
|
@ -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.
|
||||
"""
|
||||
|
||||
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`"""
|
||||
|
||||
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]
|
||||
"""Convenience type for functions which take and return an cfdfield_t"""
|
||||
fdfield_updater_t = Callable[..., fdfield]
|
||||
"""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`"""
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
"""
|
||||
|
||||
from typing import overload, Sequence
|
||||
from typing import overload
|
||||
from collections.abc import Sequence
|
||||
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
|
||||
def vec(f: None) -> None:
|
||||
pass
|
||||
pass # pragma: no cover
|
||||
|
||||
@overload
|
||||
def vec(f: fdfield_t) -> vfdfield_t:
|
||||
pass
|
||||
pass # pragma: no cover
|
||||
|
||||
@overload
|
||||
def vec(f: cfdfield_t) -> vcfdfield_t:
|
||||
pass
|
||||
pass # pragma: no cover
|
||||
|
||||
@overload
|
||||
def vec(f: ArrayLike) -> vfdfield_t | vcfdfield_t:
|
||||
pass
|
||||
def vec(f: fdfield2_t) -> vfdfield2_t:
|
||||
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`.
|
||||
|
||||
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`.
|
||||
|
||||
Returns:
|
||||
|
|
@ -42,37 +65,61 @@ def vec(f: fdfield_t | cfdfield_t | ArrayLike | None) -> vfdfield_t | vcfdfield_
|
|||
"""
|
||||
if f is None:
|
||||
return None
|
||||
return numpy.ravel(f, order='C')
|
||||
return numpy.ravel(f, order='C') # type: ignore
|
||||
|
||||
|
||||
@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
|
||||
|
||||
@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
|
||||
|
||||
@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
|
||||
|
||||
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
|
||||
of form `[f_x, f_y, f_z]` where each of `f_*` is a len(shape)-dimensional
|
||||
Perform the inverse of vec(): take a 1D ndarray and output an `nvdim`-component field
|
||||
of form e.g. `[f_x, f_y, f_z]` (`nvdim=3`) where each of `f_*` is a len(shape)-dimensional
|
||||
ndarray.
|
||||
|
||||
Returns `None` if called with `v=None`.
|
||||
|
||||
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
|
||||
nvdim: Number of components in each vector
|
||||
|
||||
Returns:
|
||||
`[f_x, f_y, f_z]` where each `f_` is a `len(shape)` dimensional ndarray (or `None`)
|
||||
"""
|
||||
if v is None:
|
||||
return None
|
||||
return v.reshape((3, *shape), order='C')
|
||||
|
||||
return v.reshape((nvdim, *shape), order='C') # type: ignore
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""
|
||||
r"""
|
||||
Utilities for running finite-difference time-domain (FDTD) simulations
|
||||
|
||||
See the discussion of `Maxwell's Equations` in `meanas.fdmath` for basic
|
||||
|
|
@ -11,9 +11,9 @@ Timestep
|
|||
From the discussion of "Plane waves and the Dispersion relation" in `meanas.fdmath`,
|
||||
we have
|
||||
|
||||
$$ c^2 \\Delta_t^2 = \\frac{\\Delta_t^2}{\\mu \\epsilon} < 1/(\\frac{1}{\\Delta_x^2} + \\frac{1}{\\Delta_y^2} + \\frac{1}{\\Delta_z^2}) $$
|
||||
$$ c^2 \Delta_t^2 = \frac{\Delta_t^2}{\mu \epsilon} < 1/(\frac{1}{\Delta_x^2} + \frac{1}{\Delta_y^2} + \frac{1}{\Delta_z^2}) $$
|
||||
|
||||
or, if $\\Delta_x = \\Delta_y = \\Delta_z$, then $c \\Delta_t < \\frac{\\Delta_x}{\\sqrt{3}}$.
|
||||
or, if $\Delta_x = \Delta_y = \Delta_z$, then $c \Delta_t < \frac{\Delta_x}{\sqrt{3}}$.
|
||||
|
||||
Based on this, we can set
|
||||
|
||||
|
|
@ -27,81 +27,81 @@ Poynting Vector and Energy Conservation
|
|||
|
||||
Let
|
||||
|
||||
$$ \\begin{aligned}
|
||||
\\tilde{S}_{l, l', \\vec{r}} &=& &\\tilde{E}_{l, \\vec{r}} \\otimes \\hat{H}_{l', \\vec{r} + \\frac{1}{2}} \\\\
|
||||
&=& &\\vec{x} (\\tilde{E}^y_{l,m+1,n,p} \\hat{H}^z_{l',\\vec{r} + \\frac{1}{2}} - \\tilde{E}^z_{l,m+1,n,p} \\hat{H}^y_{l', \\vec{r} + \\frac{1}{2}}) \\\\
|
||||
& &+ &\\vec{y} (\\tilde{E}^z_{l,m,n+1,p} \\hat{H}^x_{l',\\vec{r} + \\frac{1}{2}} - \\tilde{E}^x_{l,m,n+1,p} \\hat{H}^z_{l', \\vec{r} + \\frac{1}{2}}) \\\\
|
||||
& &+ &\\vec{z} (\\tilde{E}^x_{l,m,n,p+1} \\hat{H}^y_{l',\\vec{r} + \\frac{1}{2}} - \\tilde{E}^y_{l,m,n,p+1} \\hat{H}^z_{l', \\vec{r} + \\frac{1}{2}})
|
||||
\\end{aligned}
|
||||
$$ \begin{aligned}
|
||||
\tilde{S}_{l, l', \vec{r}} &=& &\tilde{E}_{l, \vec{r}} \otimes \hat{H}_{l', \vec{r} + \frac{1}{2}} \\
|
||||
&=& &\vec{x} (\tilde{E}^y_{l,m+1,n,p} \hat{H}^z_{l',\vec{r} + \frac{1}{2}} - \tilde{E}^z_{l,m+1,n,p} \hat{H}^y_{l', \vec{r} + \frac{1}{2}}) \\
|
||||
& &+ &\vec{y} (\tilde{E}^z_{l,m,n+1,p} \hat{H}^x_{l',\vec{r} + \frac{1}{2}} - \tilde{E}^x_{l,m,n+1,p} \hat{H}^z_{l', \vec{r} + \frac{1}{2}}) \\
|
||||
& &+ &\vec{z} (\tilde{E}^x_{l,m,n,p+1} \hat{H}^y_{l',\vec{r} + \frac{1}{2}} - \tilde{E}^y_{l,m,n,p+1} \hat{H}^z_{l', \vec{r} + \frac{1}{2}})
|
||||
\end{aligned}
|
||||
$$
|
||||
|
||||
where $\\vec{r} = (m, n, p)$ and $\\otimes$ is a modified cross product
|
||||
in which the $\\tilde{E}$ terms are shifted as indicated.
|
||||
where $\vec{r} = (m, n, p)$ and $\otimes$ is a modified cross product
|
||||
in which the $\tilde{E}$ terms are shifted as indicated.
|
||||
|
||||
By taking the divergence and rearranging terms, we can show that
|
||||
|
||||
$$
|
||||
\\begin{aligned}
|
||||
\\hat{\\nabla} \\cdot \\tilde{S}_{l, l', \\vec{r}}
|
||||
&= \\hat{\\nabla} \\cdot (\\tilde{E}_{l, \\vec{r}} \\otimes \\hat{H}_{l', \\vec{r} + \\frac{1}{2}}) \\\\
|
||||
&= \\hat{H}_{l', \\vec{r} + \\frac{1}{2}} \\cdot \\tilde{\\nabla} \\times \\tilde{E}_{l, \\vec{r}} -
|
||||
\\tilde{E}_{l, \\vec{r}} \\cdot \\hat{\\nabla} \\times \\hat{H}_{l', \\vec{r} + \\frac{1}{2}} \\\\
|
||||
&= \\hat{H}_{l', \\vec{r} + \\frac{1}{2}} \\cdot
|
||||
(-\\tilde{\\partial}_t \\mu_{\\vec{r} + \\frac{1}{2}} \\hat{H}_{l - \\frac{1}{2}, \\vec{r} + \\frac{1}{2}} -
|
||||
\\hat{M}_{l, \\vec{r} + \\frac{1}{2}}) -
|
||||
\\tilde{E}_{l, \\vec{r}} \\cdot (\\hat{\\partial}_t \\tilde{\\epsilon}_{\\vec{r}} \\tilde{E}_{l'+\\frac{1}{2}, \\vec{r}} +
|
||||
\\tilde{J}_{l', \\vec{r}}) \\\\
|
||||
&= \\hat{H}_{l'} \\cdot (-\\mu / \\Delta_t)(\\hat{H}_{l + \\frac{1}{2}} - \\hat{H}_{l - \\frac{1}{2}}) -
|
||||
\\tilde{E}_l \\cdot (\\epsilon / \\Delta_t )(\\tilde{E}_{l'+\\frac{1}{2}} - \\tilde{E}_{l'-\\frac{1}{2}})
|
||||
- \\hat{H}_{l'} \\cdot \\hat{M}_{l} - \\tilde{E}_l \\cdot \\tilde{J}_{l'} \\\\
|
||||
\\end{aligned}
|
||||
\begin{aligned}
|
||||
\hat{\nabla} \cdot \tilde{S}_{l, l', \vec{r}}
|
||||
&= \hat{\nabla} \cdot (\tilde{E}_{l, \vec{r}} \otimes \hat{H}_{l', \vec{r} + \frac{1}{2}}) \\
|
||||
&= \hat{H}_{l', \vec{r} + \frac{1}{2}} \cdot \tilde{\nabla} \times \tilde{E}_{l, \vec{r}} -
|
||||
\tilde{E}_{l, \vec{r}} \cdot \hat{\nabla} \times \hat{H}_{l', \vec{r} + \frac{1}{2}} \\
|
||||
&= \hat{H}_{l', \vec{r} + \frac{1}{2}} \cdot
|
||||
(-\tilde{\partial}_t \mu_{\vec{r} + \frac{1}{2}} \hat{H}_{l - \frac{1}{2}, \vec{r} + \frac{1}{2}} -
|
||||
\hat{M}_{l, \vec{r} + \frac{1}{2}}) -
|
||||
\tilde{E}_{l, \vec{r}} \cdot (\hat{\partial}_t \tilde{\epsilon}_{\vec{r}} \tilde{E}_{l'+\frac{1}{2}, \vec{r}} +
|
||||
\tilde{J}_{l', \vec{r}}) \\
|
||||
&= \hat{H}_{l'} \cdot (-\mu / \Delta_t)(\hat{H}_{l + \frac{1}{2}} - \hat{H}_{l - \frac{1}{2}}) -
|
||||
\tilde{E}_l \cdot (\epsilon / \Delta_t )(\tilde{E}_{l'+\frac{1}{2}} - \tilde{E}_{l'-\frac{1}{2}})
|
||||
- \hat{H}_{l'} \cdot \hat{M}_{l} - \tilde{E}_l \cdot \tilde{J}_{l'} \\
|
||||
\end{aligned}
|
||||
$$
|
||||
|
||||
where in the last line the spatial subscripts have been dropped to emphasize
|
||||
the time subscripts $l, l'$, i.e.
|
||||
|
||||
$$
|
||||
\\begin{aligned}
|
||||
\\tilde{E}_l &= \\tilde{E}_{l, \\vec{r}} \\\\
|
||||
\\hat{H}_l &= \\tilde{H}_{l, \\vec{r} + \\frac{1}{2}} \\\\
|
||||
\\tilde{\\epsilon} &= \\tilde{\\epsilon}_{\\vec{r}} \\\\
|
||||
\\end{aligned}
|
||||
\begin{aligned}
|
||||
\tilde{E}_l &= \tilde{E}_{l, \vec{r}} \\
|
||||
\hat{H}_l &= \tilde{H}_{l, \vec{r} + \frac{1}{2}} \\
|
||||
\tilde{\epsilon} &= \tilde{\epsilon}_{\vec{r}} \\
|
||||
\end{aligned}
|
||||
$$
|
||||
|
||||
etc.
|
||||
For $l' = l + \\frac{1}{2}$ we get
|
||||
For $l' = l + \frac{1}{2}$ we get
|
||||
|
||||
$$
|
||||
\\begin{aligned}
|
||||
\\hat{\\nabla} \\cdot \\tilde{S}_{l, l + \\frac{1}{2}}
|
||||
&= \\hat{H}_{l + \\frac{1}{2}} \\cdot
|
||||
(-\\mu / \\Delta_t)(\\hat{H}_{l + \\frac{1}{2}} - \\hat{H}_{l - \\frac{1}{2}}) -
|
||||
\\tilde{E}_l \\cdot (\\epsilon / \\Delta_t)(\\tilde{E}_{l+1} - \\tilde{E}_l)
|
||||
- \\hat{H}_{l'} \\cdot \\hat{M}_l - \\tilde{E}_l \\cdot \\tilde{J}_{l + \\frac{1}{2}} \\\\
|
||||
&= (-\\mu / \\Delta_t)(\\hat{H}^2_{l + \\frac{1}{2}} - \\hat{H}_{l + \\frac{1}{2}} \\cdot \\hat{H}_{l - \\frac{1}{2}}) -
|
||||
(\\epsilon / \\Delta_t)(\\tilde{E}_{l+1} \\cdot \\tilde{E}_l - \\tilde{E}^2_l)
|
||||
- \\hat{H}_{l'} \\cdot \\hat{M}_l - \\tilde{E}_l \\cdot \\tilde{J}_{l + \\frac{1}{2}} \\\\
|
||||
&= -(\\mu \\hat{H}^2_{l + \\frac{1}{2}}
|
||||
+\\epsilon \\tilde{E}_{l+1} \\cdot \\tilde{E}_l) / \\Delta_t \\ \\
|
||||
+(\\mu \\hat{H}_{l + \\frac{1}{2}} \\cdot \\hat{H}_{l - \\frac{1}{2}}
|
||||
+\\epsilon \\tilde{E}^2_l) / \\Delta_t \\ \\
|
||||
- \\hat{H}_{l+\\frac{1}{2}} \\cdot \\hat{M}_l \\ \\
|
||||
- \\tilde{E}_l \\cdot \\tilde{J}_{l+\\frac{1}{2}} \\\\
|
||||
\\end{aligned}
|
||||
\begin{aligned}
|
||||
\hat{\nabla} \cdot \tilde{S}_{l, l + \frac{1}{2}}
|
||||
&= \hat{H}_{l + \frac{1}{2}} \cdot
|
||||
(-\mu / \Delta_t)(\hat{H}_{l + \frac{1}{2}} - \hat{H}_{l - \frac{1}{2}}) -
|
||||
\tilde{E}_l \cdot (\epsilon / \Delta_t)(\tilde{E}_{l+1} - \tilde{E}_l)
|
||||
- \hat{H}_{l'} \cdot \hat{M}_l - \tilde{E}_l \cdot \tilde{J}_{l + \frac{1}{2}} \\
|
||||
&= (-\mu / \Delta_t)(\hat{H}^2_{l + \frac{1}{2}} - \hat{H}_{l + \frac{1}{2}} \cdot \hat{H}_{l - \frac{1}{2}}) -
|
||||
(\epsilon / \Delta_t)(\tilde{E}_{l+1} \cdot \tilde{E}_l - \tilde{E}^2_l)
|
||||
- \hat{H}_{l'} \cdot \hat{M}_l - \tilde{E}_l \cdot \tilde{J}_{l + \frac{1}{2}} \\
|
||||
&= -(\mu \hat{H}^2_{l + \frac{1}{2}}
|
||||
+\epsilon \tilde{E}_{l+1} \cdot \tilde{E}_l) / \Delta_t \\
|
||||
+(\mu \hat{H}_{l + \frac{1}{2}} \cdot \hat{H}_{l - \frac{1}{2}}
|
||||
+\epsilon \tilde{E}^2_l) / \Delta_t \\
|
||||
- \hat{H}_{l+\frac{1}{2}} \cdot \hat{M}_l \\
|
||||
- \tilde{E}_l \cdot \tilde{J}_{l+\frac{1}{2}} \\
|
||||
\end{aligned}
|
||||
$$
|
||||
|
||||
and for $l' = l - \\frac{1}{2}$,
|
||||
and for $l' = l - \frac{1}{2}$,
|
||||
|
||||
$$
|
||||
\\begin{aligned}
|
||||
\\hat{\\nabla} \\cdot \\tilde{S}_{l, l - \\frac{1}{2}}
|
||||
&= (\\mu \\hat{H}^2_{l - \\frac{1}{2}}
|
||||
+\\epsilon \\tilde{E}_{l-1} \\cdot \\tilde{E}_l) / \\Delta_t \\ \\
|
||||
-(\\mu \\hat{H}_{l + \\frac{1}{2}} \\cdot \\hat{H}_{l - \\frac{1}{2}}
|
||||
+\\epsilon \\tilde{E}^2_l) / \\Delta_t \\ \\
|
||||
- \\hat{H}_{l-\\frac{1}{2}} \\cdot \\hat{M}_l \\ \\
|
||||
- \\tilde{E}_l \\cdot \\tilde{J}_{l-\\frac{1}{2}} \\\\
|
||||
\\end{aligned}
|
||||
\begin{aligned}
|
||||
\hat{\nabla} \cdot \tilde{S}_{l, l - \frac{1}{2}}
|
||||
&= (\mu \hat{H}^2_{l - \frac{1}{2}}
|
||||
+\epsilon \tilde{E}_{l-1} \cdot \tilde{E}_l) / \Delta_t \\
|
||||
-(\mu \hat{H}_{l + \frac{1}{2}} \cdot \hat{H}_{l - \frac{1}{2}}
|
||||
+\epsilon \tilde{E}^2_l) / \Delta_t \\
|
||||
- \hat{H}_{l-\frac{1}{2}} \cdot \hat{M}_l \\
|
||||
- \tilde{E}_l \cdot \tilde{J}_{l-\frac{1}{2}} \\
|
||||
\end{aligned}
|
||||
$$
|
||||
|
||||
These two results form the discrete time-domain analogue to Poynting's theorem.
|
||||
|
|
@ -109,25 +109,25 @@ They hint at the expressions for the energy, which can be calculated at the same
|
|||
time-index as either the E or H field:
|
||||
|
||||
$$
|
||||
\\begin{aligned}
|
||||
U_l &= \\epsilon \\tilde{E}^2_l + \\mu \\hat{H}_{l + \\frac{1}{2}} \\cdot \\hat{H}_{l - \\frac{1}{2}} \\\\
|
||||
U_{l + \\frac{1}{2}} &= \\epsilon \\tilde{E}_l \\cdot \\tilde{E}_{l + 1} + \\mu \\hat{H}^2_{l + \\frac{1}{2}} \\\\
|
||||
\\end{aligned}
|
||||
\begin{aligned}
|
||||
U_l &= \epsilon \tilde{E}^2_l + \mu \hat{H}_{l + \frac{1}{2}} \cdot \hat{H}_{l - \frac{1}{2}} \\
|
||||
U_{l + \frac{1}{2}} &= \epsilon \tilde{E}_l \cdot \tilde{E}_{l + 1} + \mu \hat{H}^2_{l + \frac{1}{2}} \\
|
||||
\end{aligned}
|
||||
$$
|
||||
|
||||
Rewriting the Poynting theorem in terms of the energy expressions,
|
||||
|
||||
$$
|
||||
\\begin{aligned}
|
||||
(U_{l+\\frac{1}{2}} - U_l) / \\Delta_t
|
||||
&= -\\hat{\\nabla} \\cdot \\tilde{S}_{l, l + \\frac{1}{2}} \\ \\
|
||||
- \\hat{H}_{l+\\frac{1}{2}} \\cdot \\hat{M}_l \\ \\
|
||||
- \\tilde{E}_l \\cdot \\tilde{J}_{l+\\frac{1}{2}} \\\\
|
||||
(U_l - U_{l-\\frac{1}{2}}) / \\Delta_t
|
||||
&= -\\hat{\\nabla} \\cdot \\tilde{S}_{l, l - \\frac{1}{2}} \\ \\
|
||||
- \\hat{H}_{l-\\frac{1}{2}} \\cdot \\hat{M}_l \\ \\
|
||||
- \\tilde{E}_l \\cdot \\tilde{J}_{l-\\frac{1}{2}} \\\\
|
||||
\\end{aligned}
|
||||
\begin{aligned}
|
||||
(U_{l+\frac{1}{2}} - U_l) / \Delta_t
|
||||
&= -\hat{\nabla} \cdot \tilde{S}_{l, l + \frac{1}{2}} \\
|
||||
- \hat{H}_{l+\frac{1}{2}} \cdot \hat{M}_l \\
|
||||
- \tilde{E}_l \cdot \tilde{J}_{l+\frac{1}{2}} \\
|
||||
(U_l - U_{l-\frac{1}{2}}) / \Delta_t
|
||||
&= -\hat{\nabla} \cdot \tilde{S}_{l, l - \frac{1}{2}} \\
|
||||
- \hat{H}_{l-\frac{1}{2}} \cdot \hat{M}_l \\
|
||||
- \tilde{E}_l \cdot \tilde{J}_{l-\frac{1}{2}} \\
|
||||
\end{aligned}
|
||||
$$
|
||||
|
||||
This result is exact and should practically hold to within numerical precision. No time-
|
||||
|
|
@ -144,23 +144,131 @@ 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
|
||||
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
|
||||
shape. It can be written
|
||||
|
||||
$$ f_r(t) = (1 - \\frac{1}{2} (\\omega (t - \\tau))^2) e^{-(\\frac{\\omega (t - \\tau)}{2})^2} $$
|
||||
$$ f_r(t) = (1 - \frac{1}{2} (\omega (t - \tau))^2) e^{-(\frac{\omega (t - \tau)}{2})^2} $$
|
||||
|
||||
with $\\tau > \\frac{2 * \\pi}{\\omega}$ as a minimum delay to avoid a discontinuity at
|
||||
t=0 (assuming the source is off for t<0 this gives $\\sim 10^{-3}$ error at t=0).
|
||||
with $\tau > \frac{2 * \pi}{\omega}$ as a minimum delay to avoid a discontinuity at
|
||||
t=0 (assuming the source is off for t<0 this gives $\sim 10^{-3}$ error at t=0).
|
||||
|
||||
|
||||
|
||||
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 .pml import cpml_params, updates_with_cpml
|
||||
from .energy import (poynting, poynting_divergence, energy_hstep, energy_estep,
|
||||
delta_energy_h2e, delta_energy_j)
|
||||
from .boundaries import conducting_boundary
|
||||
from .base import (
|
||||
maxwell_e as maxwell_e,
|
||||
maxwell_h as maxwell_h,
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
@ -47,7 +47,7 @@ def maxwell_e(
|
|||
else:
|
||||
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.
|
||||
|
||||
|
|
@ -103,7 +103,7 @@ def maxwell_h(
|
|||
else:
|
||||
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ Boundary conditions
|
|||
|
||||
from typing import Any
|
||||
|
||||
from ..fdmath import fdfield_t, fdfield_updater_t
|
||||
from ..fdmath import fdfield, fdfield_updater_t
|
||||
|
||||
|
||||
def conducting_boundary(
|
||||
|
|
@ -15,26 +15,32 @@ def conducting_boundary(
|
|||
) -> tuple[fdfield_updater_t, fdfield_updater_t]:
|
||||
dirs = [0, 1, 2]
|
||||
if direction not in dirs:
|
||||
raise Exception('Invalid direction: {}'.format(direction))
|
||||
raise ValueError(f'Invalid direction: {direction}')
|
||||
dirs.remove(direction)
|
||||
u, v = dirs
|
||||
|
||||
boundary_slice: list[Any]
|
||||
shifted1_slice: list[Any]
|
||||
shifted2_slice: list[Any]
|
||||
|
||||
if polarity < 0:
|
||||
boundary_slice = [slice(None)] * 3 # type: list[Any]
|
||||
shifted1_slice = [slice(None)] * 3 # type: list[Any]
|
||||
boundary_slice = [slice(None)] * 3
|
||||
shifted1_slice = [slice(None)] * 3
|
||||
boundary_slice[direction] = 0
|
||||
shifted1_slice[direction] = 1
|
||||
boundary = tuple(boundary_slice)
|
||||
shifted1 = tuple(shifted1_slice)
|
||||
|
||||
def en(e: fdfield_t) -> fdfield_t:
|
||||
e[direction][boundary_slice] = 0
|
||||
e[u][boundary_slice] = e[u][shifted1_slice]
|
||||
e[v][boundary_slice] = e[v][shifted1_slice]
|
||||
def en(e: fdfield) -> fdfield:
|
||||
e[direction][boundary] = 0
|
||||
e[u][boundary] = e[u][shifted1]
|
||||
e[v][boundary] = e[v][shifted1]
|
||||
return e
|
||||
|
||||
def hn(h: fdfield_t) -> fdfield_t:
|
||||
h[direction][boundary_slice] = h[direction][shifted1_slice]
|
||||
h[u][boundary_slice] = 0
|
||||
h[v][boundary_slice] = 0
|
||||
def hn(h: fdfield) -> fdfield:
|
||||
h[direction][boundary] = h[direction][shifted1]
|
||||
h[u][boundary] = 0
|
||||
h[v][boundary] = 0
|
||||
return h
|
||||
|
||||
return en, hn
|
||||
|
|
@ -42,26 +48,29 @@ def conducting_boundary(
|
|||
if polarity > 0:
|
||||
boundary_slice = [slice(None)] * 3
|
||||
shifted1_slice = [slice(None)] * 3
|
||||
shifted2_slice = [slice(None)] * 3 # type: list[Any]
|
||||
shifted2_slice = [slice(None)] * 3
|
||||
boundary_slice[direction] = -1
|
||||
shifted1_slice[direction] = -2
|
||||
shifted2_slice[direction] = -3
|
||||
boundary = tuple(boundary_slice)
|
||||
shifted1 = tuple(shifted1_slice)
|
||||
shifted2 = tuple(shifted2_slice)
|
||||
|
||||
def ep(e: fdfield_t) -> fdfield_t:
|
||||
e[direction][boundary_slice] = -e[direction][shifted2_slice]
|
||||
e[direction][shifted1_slice] = 0
|
||||
e[u][boundary_slice] = e[u][shifted1_slice]
|
||||
e[v][boundary_slice] = e[v][shifted1_slice]
|
||||
def ep(e: fdfield) -> fdfield:
|
||||
e[direction][boundary] = -e[direction][shifted2]
|
||||
e[direction][shifted1] = 0
|
||||
e[u][boundary] = e[u][shifted1]
|
||||
e[v][boundary] = e[v][shifted1]
|
||||
return e
|
||||
|
||||
def hp(h: fdfield_t) -> fdfield_t:
|
||||
h[direction][boundary_slice] = h[direction][shifted1_slice]
|
||||
h[u][boundary_slice] = -h[u][shifted2_slice]
|
||||
h[u][shifted1_slice] = 0
|
||||
h[v][boundary_slice] = -h[v][shifted2_slice]
|
||||
h[v][shifted1_slice] = 0
|
||||
def hp(h: fdfield) -> fdfield:
|
||||
h[direction][boundary] = h[direction][shifted1]
|
||||
h[u][boundary] = -h[u][shifted2]
|
||||
h[u][shifted1] = 0
|
||||
h[v][boundary] = -h[v][shifted2]
|
||||
h[v][shifted1] = 0
|
||||
return h
|
||||
|
||||
return ep, hp
|
||||
|
||||
raise Exception('Bad polarity: {}'.format(polarity))
|
||||
raise ValueError(f'Bad polarity: {polarity}')
|
||||
|
|
|
|||
|
|
@ -1,18 +1,32 @@
|
|||
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
|
||||
|
||||
|
||||
# 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(
|
||||
e: fdfield_t,
|
||||
h: fdfield_t,
|
||||
e: fdfield,
|
||||
h: fdfield,
|
||||
dxes: dx_lists_t | None = None,
|
||||
) -> fdfield_t:
|
||||
"""
|
||||
r"""
|
||||
Calculate the poynting vector `S` ($S$).
|
||||
|
||||
This is the energy transfer rate (amount of energy `U` per `dt` transferred
|
||||
|
|
@ -43,17 +57,18 @@ def poynting(
|
|||
(see `meanas.tests.test_fdtd.test_poynting_planes`)
|
||||
|
||||
The full relationship is
|
||||
|
||||
$$
|
||||
\\begin{aligned}
|
||||
(U_{l+\\frac{1}{2}} - U_l) / \\Delta_t
|
||||
&= -\\hat{\\nabla} \\cdot \\tilde{S}_{l, l + \\frac{1}{2}} \\ \\
|
||||
- \\hat{H}_{l+\\frac{1}{2}} \\cdot \\hat{M}_l \\ \\
|
||||
- \\tilde{E}_l \\cdot \\tilde{J}_{l+\\frac{1}{2}} \\\\
|
||||
(U_l - U_{l-\\frac{1}{2}}) / \\Delta_t
|
||||
&= -\\hat{\\nabla} \\cdot \\tilde{S}_{l, l - \\frac{1}{2}} \\ \\
|
||||
- \\hat{H}_{l-\\frac{1}{2}} \\cdot \\hat{M}_l \\ \\
|
||||
- \\tilde{E}_l \\cdot \\tilde{J}_{l-\\frac{1}{2}} \\\\
|
||||
\\end{aligned}
|
||||
\begin{aligned}
|
||||
(U_{l+\frac{1}{2}} - U_l) / \Delta_t
|
||||
&= -\hat{\nabla} \cdot \tilde{S}_{l, l + \frac{1}{2}} \\
|
||||
- \hat{H}_{l+\frac{1}{2}} \cdot \hat{M}_l \\
|
||||
- \tilde{E}_l \cdot \tilde{J}_{l+\frac{1}{2}} \\
|
||||
(U_l - U_{l-\frac{1}{2}}) / \Delta_t
|
||||
&= -\hat{\nabla} \cdot \tilde{S}_{l, l - \frac{1}{2}} \\
|
||||
- \hat{H}_{l-\frac{1}{2}} \cdot \hat{M}_l \\
|
||||
- \tilde{E}_l \cdot \tilde{J}_{l-\frac{1}{2}} \\
|
||||
\end{aligned}
|
||||
$$
|
||||
|
||||
These equalities are exact and should practically hold to within numerical precision.
|
||||
|
|
@ -84,14 +99,14 @@ def poynting(
|
|||
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[2] = numpy.roll(ex, -1, axis=2) * hy - numpy.roll(ey, -1, axis=2) * hx
|
||||
return s
|
||||
return fdfield_t(s)
|
||||
|
||||
|
||||
def poynting_divergence(
|
||||
s: fdfield_t | None = None,
|
||||
s: fdfield | None = None,
|
||||
*,
|
||||
e: fdfield_t | None = None,
|
||||
h: fdfield_t | None = None,
|
||||
e: fdfield | None = None,
|
||||
h: fdfield | None = None,
|
||||
dxes: dx_lists_t | None = None,
|
||||
) -> fdfield_t:
|
||||
"""
|
||||
|
|
@ -122,15 +137,15 @@ def poynting_divergence(
|
|||
|
||||
Dx, Dy, Dz = deriv_back()
|
||||
ds = Dx(s[0]) + Dy(s[1]) + Dz(s[2])
|
||||
return ds
|
||||
return fdfield_t(ds)
|
||||
|
||||
|
||||
def energy_hstep(
|
||||
e0: fdfield_t,
|
||||
h1: fdfield_t,
|
||||
e2: fdfield_t,
|
||||
epsilon: fdfield_t | None = None,
|
||||
mu: fdfield_t | None = None,
|
||||
e0: fdfield,
|
||||
h1: fdfield,
|
||||
e2: fdfield,
|
||||
epsilon: fdfield | None = None,
|
||||
mu: fdfield | None = None,
|
||||
dxes: dx_lists_t | None = None,
|
||||
) -> fdfield_t:
|
||||
"""
|
||||
|
|
@ -150,15 +165,15 @@ def energy_hstep(
|
|||
Energy, at the time of the H-field `h1`.
|
||||
"""
|
||||
u = dxmul(e0 * e2, h1 * h1, epsilon, mu, dxes)
|
||||
return u
|
||||
return fdfield_t(u)
|
||||
|
||||
|
||||
def energy_estep(
|
||||
h0: fdfield_t,
|
||||
e1: fdfield_t,
|
||||
h2: fdfield_t,
|
||||
epsilon: fdfield_t | None = None,
|
||||
mu: fdfield_t | None = None,
|
||||
h0: fdfield,
|
||||
e1: fdfield,
|
||||
h2: fdfield,
|
||||
epsilon: fdfield | None = None,
|
||||
mu: fdfield | None = None,
|
||||
dxes: dx_lists_t | None = None,
|
||||
) -> fdfield_t:
|
||||
"""
|
||||
|
|
@ -178,17 +193,17 @@ def energy_estep(
|
|||
Energy, at the time of the E-field `e1`.
|
||||
"""
|
||||
u = dxmul(e1 * e1, h0 * h2, epsilon, mu, dxes)
|
||||
return u
|
||||
return fdfield_t(u)
|
||||
|
||||
|
||||
def delta_energy_h2e(
|
||||
dt: float,
|
||||
e0: fdfield_t,
|
||||
h1: fdfield_t,
|
||||
e2: fdfield_t,
|
||||
h3: fdfield_t,
|
||||
epsilon: fdfield_t | None = None,
|
||||
mu: fdfield_t | None = None,
|
||||
e0: fdfield,
|
||||
h1: fdfield,
|
||||
e2: fdfield,
|
||||
h3: fdfield,
|
||||
epsilon: fdfield | None = None,
|
||||
mu: fdfield | None = None,
|
||||
dxes: dx_lists_t | None = None,
|
||||
) -> fdfield_t:
|
||||
"""
|
||||
|
|
@ -211,17 +226,17 @@ def delta_energy_h2e(
|
|||
de = e2 * (e2 - e0) / dt
|
||||
dh = h1 * (h3 - h1) / dt
|
||||
du = dxmul(de, dh, epsilon, mu, dxes)
|
||||
return du
|
||||
return fdfield_t(du)
|
||||
|
||||
|
||||
def delta_energy_e2h(
|
||||
dt: float,
|
||||
h0: fdfield_t,
|
||||
e1: fdfield_t,
|
||||
h2: fdfield_t,
|
||||
e3: fdfield_t,
|
||||
epsilon: fdfield_t | None = None,
|
||||
mu: fdfield_t | None = None,
|
||||
h0: fdfield,
|
||||
e1: fdfield,
|
||||
h2: fdfield,
|
||||
e3: fdfield,
|
||||
epsilon: fdfield | None = None,
|
||||
mu: fdfield | None = None,
|
||||
dxes: dx_lists_t | None = None,
|
||||
) -> fdfield_t:
|
||||
"""
|
||||
|
|
@ -244,21 +259,31 @@ def delta_energy_e2h(
|
|||
de = e1 * (e3 - e1) / dt
|
||||
dh = h2 * (h2 - h0) / dt
|
||||
du = dxmul(de, dh, epsilon, mu, dxes)
|
||||
return du
|
||||
return fdfield_t(du)
|
||||
|
||||
|
||||
def delta_energy_j(
|
||||
j0: fdfield_t,
|
||||
e1: fdfield_t,
|
||||
j0: fdfield,
|
||||
e1: fdfield,
|
||||
dxes: dx_lists_t | None = None,
|
||||
) -> fdfield_t:
|
||||
"""
|
||||
Calculate
|
||||
r"""
|
||||
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)
|
||||
despite only causing the value of $E$ to change once (same for $M$ and $H$).
|
||||
This is the source contribution that appears beside the flux divergence in
|
||||
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:
|
||||
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][1][None, :, None]
|
||||
* dxes[0][2][None, None, :])
|
||||
return du
|
||||
return fdfield_t(du)
|
||||
|
||||
|
||||
def dxmul(
|
||||
ee: fdfield_t,
|
||||
hh: fdfield_t,
|
||||
epsilon: fdfield_t | float | None = None,
|
||||
mu: fdfield_t | float | None = None,
|
||||
ee: fdfield,
|
||||
hh: fdfield,
|
||||
epsilon: fdfield | float | None = None,
|
||||
mu: fdfield | float | None = None,
|
||||
dxes: dx_lists_t | None = None,
|
||||
) -> 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:
|
||||
epsilon = 1
|
||||
if mu is None:
|
||||
|
|
@ -292,4 +331,4 @@ def dxmul(
|
|||
* dxes[1][0][:, None, None]
|
||||
* dxes[1][1][None, :, None]
|
||||
* dxes[1][2][None, None, :])
|
||||
return result
|
||||
return fdfield_t(result)
|
||||
|
|
|
|||
180
meanas/fdtd/misc.py
Normal file
180
meanas/fdtd/misc.py
Normal 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
319
meanas/fdtd/phasor.py
Normal 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)
|
||||
|
|
@ -1,18 +1,29 @@
|
|||
"""
|
||||
PML implementations
|
||||
Convolutional perfectly matched layer (CPML) support for FDTD updates.
|
||||
|
||||
#TODO discussion of PMLs
|
||||
#TODO cpml documentation
|
||||
The helpers in this module construct per-face CPML parameters and then wrap the
|
||||
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!
|
||||
|
||||
from typing import Callable, Sequence, Any
|
||||
from typing import Any
|
||||
from collections.abc import Callable, Sequence
|
||||
from copy import deepcopy
|
||||
import numpy
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -31,18 +42,41 @@ def cpml_params(
|
|||
ma: float = 1,
|
||||
cfs_alpha: float = 0,
|
||||
) -> 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):
|
||||
raise Exception('Invalid axis: {}'.format(axis))
|
||||
raise ValueError(f'Invalid axis: {axis}')
|
||||
|
||||
if polarity not in (-1, 1):
|
||||
raise Exception('Invalid polarity: {}'.format(polarity))
|
||||
raise ValueError(f'Invalid polarity: {polarity}')
|
||||
|
||||
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:
|
||||
raise Exception('epsilon_eff must be positive')
|
||||
raise ValueError('epsilon_eff must be positive')
|
||||
|
||||
sigma_max = -ln_R_per_layer / 2 * (m + 1)
|
||||
kappa_max = numpy.sqrt(epsilon_eff * mu_eff)
|
||||
|
|
@ -56,8 +90,6 @@ def cpml_params(
|
|||
xh -= 0.5
|
||||
xe = xe[::-1]
|
||||
xh = xh[::-1]
|
||||
else:
|
||||
raise Exception('Bad polarity!')
|
||||
|
||||
expand_slice_l: list[Any] = [None, None, None]
|
||||
expand_slice_l[axis] = slice(None)
|
||||
|
|
@ -81,8 +113,6 @@ def cpml_params(
|
|||
region_list[axis] = slice(None, thickness)
|
||||
elif polarity > 0:
|
||||
region_list[axis] = slice(-thickness, None)
|
||||
else:
|
||||
raise Exception('Bad polarity!')
|
||||
region = tuple(region_list)
|
||||
|
||||
return {
|
||||
|
|
@ -96,11 +126,31 @@ def updates_with_cpml(
|
|||
cpml_params: Sequence[Sequence[dict[str, Any] | None]],
|
||||
dt: float,
|
||||
dxes: dx_lists_t,
|
||||
epsilon: fdfield_t,
|
||||
epsilon: fdfield,
|
||||
*,
|
||||
dtype: DTypeLike = numpy.float32,
|
||||
) -> tuple[Callable[[fdfield_t, fdfield_t, fdfield_t], None],
|
||||
Callable[[fdfield_t, fdfield_t, fdfield_t], None]]:
|
||||
) -> tuple[Callable[..., None], Callable[..., 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])
|
||||
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)
|
||||
|
||||
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]
|
||||
if cpml_param is None:
|
||||
psi_E[axis][pp] = (None, None)
|
||||
|
|
@ -136,9 +186,9 @@ def updates_with_cpml(
|
|||
pH = numpy.empty_like(epsilon, dtype=dtype)
|
||||
|
||||
def update_E(
|
||||
e: fdfield_t,
|
||||
h: fdfield_t,
|
||||
epsilon: fdfield_t,
|
||||
e: fdfield,
|
||||
h: fdfield,
|
||||
epsilon: fdfield,
|
||||
) -> None:
|
||||
dyHx = Dby(h[0])
|
||||
dzHx = Dbz(h[0])
|
||||
|
|
@ -182,9 +232,9 @@ def updates_with_cpml(
|
|||
e[2] += dt / epsilon[2] * (dxHy - dyHx + pE[2])
|
||||
|
||||
def update_H(
|
||||
e: fdfield_t,
|
||||
h: fdfield_t,
|
||||
mu: fdfield_t = numpy.ones(3),
|
||||
e: fdfield,
|
||||
h: fdfield,
|
||||
mu: fdfield | tuple[int, int, int] = (1, 1, 1),
|
||||
) -> None:
|
||||
dyEx = Dfy(e[0])
|
||||
dzEx = Dfz(e[0])
|
||||
|
|
|
|||
37
meanas/test/_bloch_case.py
Normal file
37
meanas/test/_bloch_case.py
Normal 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
49
meanas/test/_fdfd_case.py
Normal 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)
|
||||
56
meanas/test/_solver_cases.py
Normal file
56
meanas/test/_solver_cases.py
Normal 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),
|
||||
)
|
||||
22
meanas/test/_test_builders.py
Normal file
22
meanas/test/_test_builders.py
Normal 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]
|
||||
|
|
@ -3,12 +3,13 @@
|
|||
Test fixtures
|
||||
|
||||
"""
|
||||
from typing import Iterable, Any
|
||||
# ruff: noqa: ARG001
|
||||
from typing import Any
|
||||
import numpy
|
||||
from numpy.typing import NDArray
|
||||
import pytest # type: ignore
|
||||
|
||||
from .utils import PRNG
|
||||
from .utils import make_prng
|
||||
|
||||
|
||||
FixtureRequest = Any
|
||||
|
|
@ -20,18 +21,18 @@ FixtureRequest = Any
|
|||
(5, 5, 5),
|
||||
# (7, 7, 7),
|
||||
])
|
||||
def shape(request: FixtureRequest) -> Iterable[tuple[int, ...]]:
|
||||
yield (3, *request.param)
|
||||
def shape(request: FixtureRequest) -> tuple[int, ...]:
|
||||
return (3, *request.param)
|
||||
|
||||
|
||||
@pytest.fixture(scope='module', params=[1.0, 1.5])
|
||||
def epsilon_bg(request: FixtureRequest) -> Iterable[float]:
|
||||
yield request.param
|
||||
def epsilon_bg(request: FixtureRequest) -> float:
|
||||
return request.param
|
||||
|
||||
|
||||
@pytest.fixture(scope='module', params=[1.0, 2.5])
|
||||
def epsilon_fg(request: FixtureRequest) -> Iterable[float]:
|
||||
yield request.param
|
||||
def epsilon_fg(request: FixtureRequest) -> float:
|
||||
return request.param
|
||||
|
||||
|
||||
@pytest.fixture(scope='module', params=['center', '000', 'random'])
|
||||
|
|
@ -40,7 +41,8 @@ def epsilon(
|
|||
shape: tuple[int, ...],
|
||||
epsilon_bg: float,
|
||||
epsilon_fg: float,
|
||||
) -> Iterable[NDArray[numpy.float64]]:
|
||||
) -> NDArray[numpy.float64]:
|
||||
prng = make_prng()
|
||||
is3d = (numpy.array(shape) == 1).sum() == 0
|
||||
if is3d:
|
||||
if request.param == '000':
|
||||
|
|
@ -56,21 +58,23 @@ def epsilon(
|
|||
elif request.param == '000':
|
||||
epsilon[:, 0, 0, 0] = epsilon_fg
|
||||
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),
|
||||
size=shape)
|
||||
size=shape,
|
||||
)
|
||||
|
||||
yield epsilon
|
||||
return epsilon
|
||||
|
||||
|
||||
@pytest.fixture(scope='module', params=[1.0]) # 1.5
|
||||
def j_mag(request: FixtureRequest) -> Iterable[float]:
|
||||
yield request.param
|
||||
def j_mag(request: FixtureRequest) -> float:
|
||||
return request.param
|
||||
|
||||
|
||||
@pytest.fixture(scope='module', params=[1.0, 1.5])
|
||||
def dx(request: FixtureRequest) -> Iterable[float]:
|
||||
yield request.param
|
||||
def dx(request: FixtureRequest) -> float:
|
||||
return request.param
|
||||
|
||||
|
||||
@pytest.fixture(scope='module', params=['uniform', 'centerbig'])
|
||||
|
|
@ -78,7 +82,8 @@ def dxes(
|
|||
request: FixtureRequest,
|
||||
shape: tuple[int, ...],
|
||||
dx: float,
|
||||
) -> Iterable[list[list[NDArray[numpy.float64]]]]:
|
||||
) -> list[list[NDArray[numpy.float64]]]:
|
||||
prng = make_prng()
|
||||
if request.param == 'uniform':
|
||||
dxes = [[numpy.full(s, dx) for s in shape[1:]] for _ in range(2)]
|
||||
elif request.param == 'centerbig':
|
||||
|
|
@ -87,8 +92,7 @@ def dxes(
|
|||
for ax in (0, 1, 2):
|
||||
dxes[eh][ax][dxes[eh][ax].size // 2] *= 1.1
|
||||
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]
|
||||
dxes = [dxe, dxh]
|
||||
yield dxes
|
||||
|
||||
return dxes
|
||||
|
|
|
|||
73
meanas/test/test_bloch_foundations.py
Normal file
73
meanas/test/test_bloch_foundations.py
Normal 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()
|
||||
315
meanas/test/test_bloch_interactions.py
Normal file
315
meanas/test/test_bloch_interactions.py
Normal 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
|
||||
101
meanas/test/test_eigensolvers_numerics.py
Normal file
101
meanas/test/test_eigensolvers_numerics.py
Normal 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)
|
||||
491
meanas/test/test_eme_numerics.py
Normal file
491
meanas/test/test_eme_numerics.py
Normal 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
|
||||
47
meanas/test/test_examples_smoke.py
Normal file
47
meanas/test/test_examples_smoke.py
Normal 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
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
from typing import Iterable
|
||||
# ruff: noqa: ARG001
|
||||
import dataclasses
|
||||
import pytest # type: ignore
|
||||
import numpy
|
||||
|
|
@ -6,7 +6,7 @@ from numpy.typing import NDArray
|
|||
#from numpy.testing import assert_allclose, assert_array_equal
|
||||
|
||||
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 .conftest import FixtureRequest
|
||||
|
||||
|
|
@ -61,24 +61,24 @@ def test_poynting_planes(sim: 'FDResult') -> None:
|
|||
# Also see conftest.py
|
||||
|
||||
@pytest.fixture(params=[1 / 1500])
|
||||
def omega(request: FixtureRequest) -> Iterable[float]:
|
||||
yield request.param
|
||||
def omega(request: FixtureRequest) -> float:
|
||||
return request.param
|
||||
|
||||
|
||||
@pytest.fixture(params=[None])
|
||||
def pec(request: FixtureRequest) -> Iterable[NDArray[numpy.float64] | None]:
|
||||
yield request.param
|
||||
def pec(request: FixtureRequest) -> NDArray[numpy.float64] | None:
|
||||
return request.param
|
||||
|
||||
|
||||
@pytest.fixture(params=[None])
|
||||
def pmc(request: FixtureRequest) -> Iterable[NDArray[numpy.float64] | None]:
|
||||
yield request.param
|
||||
def pmc(request: FixtureRequest) -> NDArray[numpy.float64] | None:
|
||||
return request.param
|
||||
|
||||
|
||||
#@pytest.fixture(scope='module',
|
||||
# params=[(25, 5, 5)])
|
||||
#def shape(request):
|
||||
# yield (3, *request.param)
|
||||
#def shape(request: FixtureRequest):
|
||||
# return (3, *request.param)
|
||||
|
||||
|
||||
@pytest.fixture(params=['diag']) # 'center'
|
||||
|
|
@ -86,7 +86,7 @@ def j_distribution(
|
|||
request: FixtureRequest,
|
||||
shape: tuple[int, ...],
|
||||
j_mag: float,
|
||||
) -> Iterable[NDArray[numpy.float64]]:
|
||||
) -> NDArray[numpy.float64]:
|
||||
j = numpy.zeros(shape, dtype=complex)
|
||||
center_mask = numpy.zeros(shape, dtype=bool)
|
||||
center_mask[:, shape[1] // 2, shape[2] // 2, shape[3] // 2] = True
|
||||
|
|
@ -96,22 +96,22 @@ def j_distribution(
|
|||
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
|
||||
yield j
|
||||
return j
|
||||
|
||||
|
||||
@dataclasses.dataclass()
|
||||
class FDResult:
|
||||
shape: tuple[int, ...]
|
||||
dxes: list[list[NDArray[numpy.float64]]]
|
||||
epsilon: NDArray[numpy.float64]
|
||||
dxes: dx_lists_t
|
||||
epsilon: vfdfield
|
||||
omega: complex
|
||||
j: NDArray[numpy.complex128]
|
||||
e: NDArray[numpy.complex128]
|
||||
pmc: NDArray[numpy.float64] | None
|
||||
pec: NDArray[numpy.float64] | None
|
||||
j: vcfdfield
|
||||
e: vcfdfield
|
||||
pmc: vfdfield | None
|
||||
pec: vfdfield | None
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@pytest.fixture
|
||||
def sim(
|
||||
request: FixtureRequest,
|
||||
shape: tuple[int, ...],
|
||||
|
|
@ -141,11 +141,11 @@ def sim(
|
|||
j_vec = vec(j_distribution)
|
||||
eps_vec = vec(epsilon)
|
||||
e_vec = fdfd.solvers.generic(
|
||||
J=j_vec,
|
||||
omega=omega,
|
||||
dxes=dxes,
|
||||
epsilon=eps_vec,
|
||||
matrix_solver_opts={'atol': 1e-15, 'tol': 1e-11},
|
||||
J = j_vec,
|
||||
omega = omega,
|
||||
dxes = dxes,
|
||||
epsilon = eps_vec,
|
||||
matrix_solver_opts = dict(atol=1e-15, rtol=1e-11),
|
||||
)
|
||||
e = unvec(e_vec, shape[1:])
|
||||
|
||||
|
|
|
|||
213
meanas/test/test_fdfd_algebra_helpers.py
Normal file
213
meanas/test/test_fdfd_algebra_helpers.py
Normal 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)
|
||||
100
meanas/test/test_fdfd_farfield.py
Normal file
100
meanas/test/test_fdfd_farfield.py
Normal 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()
|
||||
122
meanas/test/test_fdfd_functional.py
Normal file
122
meanas/test/test_fdfd_functional.py
Normal 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)
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
from typing import Iterable
|
||||
# ruff: noqa: ARG001
|
||||
import pytest # type: ignore
|
||||
import numpy
|
||||
from numpy.typing import NDArray
|
||||
from numpy.testing import assert_allclose
|
||||
|
||||
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 .test_fdfd import FDResult
|
||||
from .conftest import FixtureRequest
|
||||
|
|
@ -44,49 +44,51 @@ def test_pml(sim: FDResult, src_polarity: int) -> None:
|
|||
# Also see conftest.py
|
||||
|
||||
@pytest.fixture(params=[1 / 1500])
|
||||
def omega(request: FixtureRequest) -> Iterable[float]:
|
||||
yield request.param
|
||||
def omega(request: FixtureRequest) -> float:
|
||||
return request.param
|
||||
|
||||
|
||||
@pytest.fixture(params=[None])
|
||||
def pec(request: FixtureRequest) -> Iterable[NDArray[numpy.float64] | None]:
|
||||
yield request.param
|
||||
def pec(request: FixtureRequest) -> NDArray[numpy.float64] | None:
|
||||
return request.param
|
||||
|
||||
|
||||
@pytest.fixture(params=[None])
|
||||
def pmc(request: FixtureRequest) -> Iterable[NDArray[numpy.float64] | None]:
|
||||
yield request.param
|
||||
def pmc(request: FixtureRequest) -> NDArray[numpy.float64] | None:
|
||||
return request.param
|
||||
|
||||
|
||||
@pytest.fixture(params=[(30, 1, 1),
|
||||
(1, 30, 1),
|
||||
(1, 1, 30)])
|
||||
def shape(request: FixtureRequest) -> Iterable[tuple[int, ...]]:
|
||||
yield (3, *request.param)
|
||||
def shape(request: FixtureRequest) -> tuple[int, int, int]:
|
||||
return (3, *request.param)
|
||||
|
||||
|
||||
@pytest.fixture(params=[+1, -1])
|
||||
def src_polarity(request: FixtureRequest) -> Iterable[int]:
|
||||
yield request.param
|
||||
def src_polarity(request: FixtureRequest) -> int:
|
||||
return request.param
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@pytest.fixture
|
||||
def j_distribution(
|
||||
request: FixtureRequest,
|
||||
shape: tuple[int, ...],
|
||||
epsilon: NDArray[numpy.float64],
|
||||
epsilon: vfdfield,
|
||||
dxes: dx_lists_mut,
|
||||
omega: float,
|
||||
src_polarity: int,
|
||||
) -> Iterable[NDArray[numpy.complex128]]:
|
||||
) -> cfdfield_t:
|
||||
j = numpy.zeros(shape, dtype=complex)
|
||||
|
||||
dim = numpy.where(numpy.array(shape[1:]) > 1)[0][0] # Propagation axis
|
||||
other_dims = [0, 1, 2]
|
||||
other_dims.remove(dim)
|
||||
|
||||
dx_prop = (dxes[0][dim][shape[dim + 1] // 2]
|
||||
+ dxes[1][dim][shape[dim + 1] // 2]) / 2 # noqa: E128 # TODO is this right for nonuniform dxes?
|
||||
dx_prop = (
|
||||
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
|
||||
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,
|
||||
axis=dim, polarity=src_polarity, slices=slices, epsilon=epsilon)
|
||||
yield j
|
||||
return j
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@pytest.fixture
|
||||
def epsilon(
|
||||
request: FixtureRequest,
|
||||
shape: tuple[int, ...],
|
||||
epsilon_bg: float,
|
||||
epsilon_fg: float,
|
||||
) -> Iterable[NDArray[numpy.float64]]:
|
||||
) -> NDArray[numpy.float64]:
|
||||
epsilon = numpy.full(shape, epsilon_fg, dtype=float)
|
||||
yield epsilon
|
||||
return epsilon
|
||||
|
||||
|
||||
@pytest.fixture(params=['uniform'])
|
||||
|
|
@ -127,7 +129,7 @@ def dxes(
|
|||
dx: float,
|
||||
omega: float,
|
||||
epsilon_fg: float,
|
||||
) -> Iterable[list[list[NDArray[numpy.float64]]]]:
|
||||
) -> list[list[NDArray[numpy.float64]]]:
|
||||
if request.param == 'uniform':
|
||||
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
|
||||
|
|
@ -141,10 +143,10 @@ def dxes(
|
|||
epsilon_effective=epsilon_fg,
|
||||
thickness=10,
|
||||
)
|
||||
yield dxes
|
||||
return dxes
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@pytest.fixture
|
||||
def sim(
|
||||
request: FixtureRequest,
|
||||
shape: tuple[int, ...],
|
||||
|
|
@ -162,7 +164,7 @@ def sim(
|
|||
omega=omega,
|
||||
dxes=dxes,
|
||||
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:])
|
||||
|
||||
|
|
|
|||
128
meanas/test/test_fdfd_solvers.py
Normal file
128
meanas/test/test_fdfd_solvers.py
Normal 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]))
|
||||
60
meanas/test/test_fdmath_functional.py
Normal file
60
meanas/test/test_fdmath_functional.py
Normal 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)
|
||||
90
meanas/test/test_fdmath_operators.py
Normal file
90
meanas/test/test_fdmath_operators.py
Normal 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,))
|
||||
46
meanas/test/test_fdmath_vectorization.py
Normal file
46
meanas/test/test_fdmath_vectorization.py
Normal 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'))
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
from typing import Iterable, Any
|
||||
# ruff: noqa: ARG001
|
||||
from typing import Any
|
||||
import dataclasses
|
||||
import pytest # type: ignore
|
||||
import numpy
|
||||
|
|
@ -6,7 +7,7 @@ from numpy.typing import NDArray
|
|||
#from numpy.testing import assert_allclose, assert_array_equal
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -101,7 +102,7 @@ def test_poynting_divergence(sim: 'TDResult') -> None:
|
|||
def test_poynting_planes(sim: 'TDResult') -> None:
|
||||
mask = (sim.js[0] != 0).any(axis=0)
|
||||
if mask.sum() > 1:
|
||||
pytest.skip('test_poynting_planes can only test single point sources, got {}'.format(mask.sum()))
|
||||
pytest.skip(f'test_poynting_planes can only test single point sources, got {mask.sum()}')
|
||||
|
||||
args: dict[str, Any] = {
|
||||
'dxes': sim.dxes,
|
||||
|
|
@ -150,8 +151,8 @@ def test_poynting_planes(sim: 'TDResult') -> None:
|
|||
|
||||
|
||||
@pytest.fixture(params=[0.3])
|
||||
def dt(request: FixtureRequest) -> Iterable[float]:
|
||||
yield request.param
|
||||
def dt(request: FixtureRequest) -> float:
|
||||
return request.param
|
||||
|
||||
|
||||
@dataclasses.dataclass()
|
||||
|
|
@ -168,8 +169,8 @@ class TDResult:
|
|||
|
||||
|
||||
@pytest.fixture(params=[(0, 4, 8)]) # (0,)
|
||||
def j_steps(request: FixtureRequest) -> Iterable[tuple[int, ...]]:
|
||||
yield request.param
|
||||
def j_steps(request: FixtureRequest) -> tuple[int, ...]:
|
||||
return request.param
|
||||
|
||||
|
||||
@pytest.fixture(params=['center', 'random'])
|
||||
|
|
@ -177,18 +178,19 @@ def j_distribution(
|
|||
request: FixtureRequest,
|
||||
shape: tuple[int, ...],
|
||||
j_mag: float,
|
||||
) -> Iterable[NDArray[numpy.float64]]:
|
||||
) -> NDArray[numpy.float64]:
|
||||
prng = make_prng()
|
||||
j = numpy.zeros(shape)
|
||||
if request.param == 'center':
|
||||
j[:, shape[1] // 2, shape[2] // 2, shape[3] // 2] = j_mag
|
||||
elif request.param == '000':
|
||||
j[:, 0, 0, 0] = j_mag
|
||||
elif request.param == 'random':
|
||||
j[:] = PRNG.uniform(low=-j_mag, high=j_mag, size=shape)
|
||||
yield j
|
||||
j[:] = prng.uniform(low=-j_mag, high=j_mag, size=shape)
|
||||
return j
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@pytest.fixture
|
||||
def sim(
|
||||
request: FixtureRequest,
|
||||
shape: tuple[int, ...],
|
||||
|
|
@ -199,8 +201,7 @@ def sim(
|
|||
j_steps: tuple[int, ...],
|
||||
) -> TDResult:
|
||||
is3d = (numpy.array(shape) == 1).sum() == 0
|
||||
if is3d:
|
||||
if dt != 0.3:
|
||||
if is3d and dt != 0.3:
|
||||
pytest.skip('Skipping dt != 0.3 because test is 3D (for speed)')
|
||||
|
||||
sim = TDResult(
|
||||
|
|
|
|||
43
meanas/test/test_fdtd_base.py
Normal file
43
meanas/test/test_fdtd_base.py
Normal 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)
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue