Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ca4f27f22 | |||
| 35ec8b4325 | |||
| 35b5a8c5b9 | |||
| 9ef62b92d5 | |||
| 711ce119d0 | |||
| 6937bbf455 | |||
| ce3e47daa9 | |||
| 99461dc129 | |||
| 0ad78271ca | |||
| 2c5c40c1e7 |
132 changed files with 4803 additions and 17036 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -54,10 +54,6 @@ coverage.xml
|
||||||
|
|
||||||
# documentation
|
# documentation
|
||||||
doc/
|
doc/
|
||||||
site/
|
|
||||||
_doc_mathimg/
|
|
||||||
doc.md
|
|
||||||
doc.htex
|
|
||||||
|
|
||||||
# PyBuilder
|
# PyBuilder
|
||||||
target/
|
target/
|
||||||
|
|
|
||||||
158
README.md
158
README.md
|
|
@ -49,28 +49,13 @@ linear systems, ideally with double precision.
|
||||||
|
|
||||||
**Requirements:**
|
**Requirements:**
|
||||||
|
|
||||||
* python >=3.12
|
* python >=3.11
|
||||||
* numpy
|
* numpy
|
||||||
* scipy
|
* scipy
|
||||||
|
|
||||||
|
|
||||||
Install from PyPI with pip:
|
Install from PyPI with pip:
|
||||||
```bash
|
```bash
|
||||||
pip3 install meanas
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional extras:
|
|
||||||
|
|
||||||
- `meanas[test]`: pytest and coverage
|
|
||||||
- `meanas[docs]`: MkDocs-based documentation toolchain
|
|
||||||
- `meanas[examples]`: optional runtime dependencies used by the tracked examples
|
|
||||||
- `meanas[dev]`: the union of `test`, `docs`, and `examples`, plus local lint/docs-publish helpers
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
```bash
|
|
||||||
pip3 install 'meanas[test]'
|
|
||||||
pip3 install 'meanas[docs]'
|
|
||||||
pip3 install 'meanas[examples]'
|
|
||||||
pip3 install 'meanas[dev]'
|
pip3 install 'meanas[dev]'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -95,13 +80,9 @@ source my_venv/bin/activate
|
||||||
# Install in-place (-e, editable) from ./meanas, including development dependencies ([dev])
|
# Install in-place (-e, editable) from ./meanas, including development dependencies ([dev])
|
||||||
pip3 install --user -e './meanas[dev]'
|
pip3 install --user -e './meanas[dev]'
|
||||||
|
|
||||||
# Fast local iteration: excludes slower 3D/integration/example-smoke checks
|
# Run tests
|
||||||
cd meanas
|
cd meanas
|
||||||
python3 -m pytest -q -m "not complete"
|
python3 -m pytest -rsxX | tee test_results.txt
|
||||||
|
|
||||||
# Complete pre-commit confidence run: includes the slower integration tests and
|
|
||||||
# tracked example smoke tests
|
|
||||||
python3 -m pytest -q | tee test_results.txt
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### See also:
|
#### See also:
|
||||||
|
|
@ -113,133 +94,6 @@ python3 -m pytest -q | tee test_results.txt
|
||||||
|
|
||||||
## Use
|
## Use
|
||||||
|
|
||||||
`meanas` is a collection of finite-difference electromagnetics tools:
|
See `examples/` for some simple examples; you may need additional
|
||||||
|
packages such as [gridlock](https://mpxd.net/code/jan/gridlock)
|
||||||
- `meanas.fdfd`: frequency-domain wave equations, sparse operators, SCPML, and
|
to run the examples.
|
||||||
iterative solves for driven problems.
|
|
||||||
- `meanas.fdfd.waveguide_2d` / `meanas.fdfd.waveguide_3d`: waveguide mode
|
|
||||||
solvers, including targeted complex bases from the default half-sized
|
|
||||||
beta-squared or explicit signed-beta formulations, transpose-left and
|
|
||||||
Lorentz-dual projection fields on a shared field-array axis, separately
|
|
||||||
returned PML-mode diagnostics, overlap-based tracking,
|
|
||||||
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/eme_taper_cmt.py`: sampled-interface EME with stable S cascading for a
|
|
||||||
continuously varying rib-waveguide taper.
|
|
||||||
- `examples/eme_taper_pml_modes.py`: targeted complex/PML mode solving,
|
|
||||||
tracking, and construction of EME taper sections.
|
|
||||||
- `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.
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
# eigensolvers
|
|
||||||
|
|
||||||
::: meanas.eigensolvers
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
# fdfd
|
|
||||||
|
|
||||||
::: meanas.fdfd
|
|
||||||
|
|
||||||
## Core operator layers
|
|
||||||
|
|
||||||
::: meanas.fdfd.functional
|
|
||||||
|
|
||||||
::: meanas.fdfd.operators
|
|
||||||
|
|
||||||
::: meanas.fdfd.solvers
|
|
||||||
|
|
||||||
::: meanas.fdfd.scpml
|
|
||||||
|
|
||||||
::: meanas.fdfd.farfield
|
|
||||||
|
|
||||||
## SCPML conventions
|
|
||||||
|
|
||||||
`stretch_with_scpml` now uses `-1` for the low-index face and `+1` for the
|
|
||||||
high-index face, matching CPML and propagation conventions. It returns copied
|
|
||||||
widths by default; pass `copy=False` to request mutation explicitly.
|
|
||||||
|
|
||||||
Both SCPML builders accept `kappa_function(depth)` for real stretching and
|
|
||||||
`max_imag_ratio` to limit the final dimensionless `abs(Im(s))/Re(s)`. Start a
|
|
||||||
mode-solver convergence study with `max_imag_ratio=1.0`: overly strong imaginary
|
|
||||||
stretching can produce above-light-line PML artifacts. The cap is not a general
|
|
||||||
spectral guarantee, and weaker absorption can increase boundary reflections.
|
|
||||||
Check domain size, PML thickness, strength, mode localization, and residuals.
|
|
||||||
The general scattering profile remains uncapped by default.
|
|
||||||
|
|
||||||
`uniform_grid_scpml` builds unit widths by applying `stretch_with_scpml` to
|
|
||||||
both faces of each enabled axis. Both builders divide imaginary grading by
|
|
||||||
physical layer thickness and sample electric widths at cell centers and
|
|
||||||
magnetic widths at cell edges. This corrects the uniform builder's previous
|
|
||||||
thickness-independent strength and reversed E/H sample offsets; retune existing
|
|
||||||
uniform-builder profiles using domain/PML convergence checks.
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
# fdmath
|
|
||||||
|
|
||||||
::: meanas.fdmath
|
|
||||||
|
|
||||||
## Functional and sparse operators
|
|
||||||
|
|
||||||
::: meanas.fdmath.functional
|
|
||||||
|
|
||||||
::: meanas.fdmath.operators
|
|
||||||
|
|
||||||
::: meanas.fdmath.vectorization
|
|
||||||
|
|
||||||
::: meanas.fdmath.types
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
# fdtd
|
|
||||||
|
|
||||||
::: meanas.fdtd
|
|
||||||
|
|
||||||
## Core update and analysis helpers
|
|
||||||
|
|
||||||
::: meanas.fdtd.base
|
|
||||||
|
|
||||||
::: meanas.fdtd.pml
|
|
||||||
|
|
||||||
::: meanas.fdtd.boundaries
|
|
||||||
|
|
||||||
::: meanas.fdtd.energy
|
|
||||||
|
|
||||||
::: meanas.fdtd.phasor
|
|
||||||
|
|
||||||
CPML derivatives use electric widths for forward differences and magnetic
|
|
||||||
widths for backward differences. `cpml_params(..., dx=1.0)` specifies the uniform
|
|
||||||
physical cell width normal to a face; arbitrary nonuniform PML grading is not
|
|
||||||
implemented by this constructor. Scale the timestep consistently with the grid.
|
|
||||||
|
|
||||||
`ln_R_per_layer` must be finite and strictly negative. Zero attenuation raises
|
|
||||||
`ValueError`; disable individual CPML faces with `None` in the parameter table
|
|
||||||
passed to `updates_with_cpml`.
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
# API Overview
|
|
||||||
|
|
||||||
The package is documented directly from its docstrings. The most useful entry
|
|
||||||
points are:
|
|
||||||
|
|
||||||
- [meanas](meanas.md): top-level package overview
|
|
||||||
- [eigensolvers](eigensolvers.md): generic eigenvalue utilities used by the mode solvers
|
|
||||||
- [fdfd](fdfd.md): frequency-domain operators, sources, PML, solvers, and far-field transforms
|
|
||||||
- [waveguides](waveguides.md): straight, cylindrical, and 3D waveguide mode helpers
|
|
||||||
- [fdtd](fdtd.md): timestepping, CPML, energy/flux helpers, and phasor extraction
|
|
||||||
- [fdmath](fdmath.md): shared discrete operators, vectorization helpers, and derivation background
|
|
||||||
|
|
||||||
The waveguide and FDTD pages are the best places to start if you want the
|
|
||||||
mathematical derivations rather than just the callable reference.
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
# meanas
|
|
||||||
|
|
||||||
::: meanas
|
|
||||||
|
|
@ -1,153 +0,0 @@
|
||||||
# waveguides
|
|
||||||
|
|
||||||
::: meanas.fdfd.waveguide_2d
|
|
||||||
|
|
||||||
::: meanas.fdfd.waveguide_3d
|
|
||||||
|
|
||||||
::: meanas.fdfd.waveguide_cyl
|
|
||||||
|
|
||||||
## Overlap measures and normalization
|
|
||||||
|
|
||||||
`waveguide_2d.inner_product(..., measure="physical", conj_h=True)` uses real
|
|
||||||
Yee widths for physical Poynting power. `measure="stretched"` keeps complex
|
|
||||||
widths for coordinate reaction. `lorentz_metric` and `lorentz_overlap` default
|
|
||||||
to the stretched measure. Use the same grid and measure when constructing and
|
|
||||||
using dual fields. A real overlap grid still defines a consistent constructed
|
|
||||||
dual basis, even when the eigenproblem uses a separate PML grid.
|
|
||||||
|
|
||||||
`normalized_fields_e/h(..., normalization="lorentz")` normalizes unconjugated
|
|
||||||
self-reaction to one. This fixes phase up to a sign and is distinct from unit
|
|
||||||
forward power, which remains the default. Zero-power or self-orthogonal modes
|
|
||||||
raise `ValueError`; open self-orthogonal modes require paired primal/dual
|
|
||||||
normalization through `ModeBasis`.
|
|
||||||
|
|
||||||
## Targeted and reproducible mode solving
|
|
||||||
|
|
||||||
For modes near an effective index guess, use `solve_modes_near` with
|
|
||||||
`target_wavenumbers=[omega * n_guess]`. It targets the electric beta-squared
|
|
||||||
operator at `(omega * n_guess)**2`; `solve_modes_near_beta` instead targets the
|
|
||||||
first-order signed-beta operator. Both use the LU shift-invert backend and
|
|
||||||
support `eigensolver_opts={"v0": starting_vector}`. Factorization cost and
|
|
||||||
memory depend on the grid, so this is not universally faster than indexed
|
|
||||||
solving. `solve_modes` keeps its global mode-number semantics and now accepts
|
|
||||||
`rng` or `v0` for reproducible initialization.
|
|
||||||
|
|
||||||
`waveguide_3d.mode_from_2d` accepts a supplied transverse electric eigenvector
|
|
||||||
and reuses field reconstruction, normalization, numerical-dispersion
|
|
||||||
correction, Yee staggering, rotation, and full-grid embedding without solving
|
|
||||||
an eigenproblem.
|
|
||||||
|
|
||||||
## Interface and sampled-taper scattering
|
|
||||||
|
|
||||||
All scattering matrices use outgoing rows and incoming columns, ordered
|
|
||||||
`[left, right]`:
|
|
||||||
|
|
||||||
```text
|
|
||||||
[b_left ] [R_left T_right_to_left] [a_left ]
|
|
||||||
[b_right] = [T_left_to_right R_right] [a_right]
|
|
||||||
```
|
|
||||||
|
|
||||||
`get_s` and `get_abcd` require explicit `dxes`. Mixed Petrov-Galerkin testing is
|
|
||||||
the default: left electric duals test H continuity, and right magnetic duals
|
|
||||||
test E continuity. Both incidence directions are solved together with full
|
|
||||||
projection matrices. Mixed scattering supports unequal mode counts.
|
|
||||||
`formulation="galerkin"` selects equal-count left testing of both equations.
|
|
||||||
Neither formulation silently clips transmission. The optional `force_nogain`
|
|
||||||
and `force_reciprocal` flags are numerical projections, not general physical
|
|
||||||
tests for arbitrary open or evanescent modal coordinates.
|
|
||||||
|
|
||||||
`get_taper_s` replaces finite-difference CMT with half propagation in the left
|
|
||||||
basis, a midpoint interface, and half propagation in the right basis for each
|
|
||||||
sampled interval. Redheffer composition avoids growing passive backward
|
|
||||||
transfer branches. Refine section sampling and modal/PML bases to establish
|
|
||||||
convergence. The ineffective `rtol`, `atol`, and `max_step` arguments have been
|
|
||||||
removed. The existing `eme_taper_cmt.py` example filename is retained.
|
|
||||||
|
|
||||||
```python
|
|
||||||
prepared = eme.prepare_taper(sections, dxes)
|
|
||||||
s_original = eme.evaluate_taper_s(prepared)
|
|
||||||
s_longer = eme.evaluate_taper_s(prepared, z_coords=prepared.z_coords * 1.2)
|
|
||||||
s_changed_beta = eme.evaluate_taper_s(
|
|
||||||
prepared, wavenumber_matrices=beta_matrices,
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
`PreparedTaper` owns read-only copies of phase-aligned interface data. Beta
|
|
||||||
matrix overrides have shape `(section, mode, mode)` in the original supplied
|
|
||||||
section bases; recorded basis transforms are applied automatically. Explicit
|
|
||||||
backward branches require backward beta overrides when forward beta is changed.
|
|
||||||
Evaluation performs no field overlaps or eigensolves. Length sweeps reuse the
|
|
||||||
sampled model; wavelength sweeps with reused fields are explicitly frozen-basis
|
|
||||||
approximations. Reprepare when fields, duals, grids, or formulations change.
|
|
||||||
|
|
||||||
`cascade_s(left, right, internal_modes=...)` connects spatially ordered S
|
|
||||||
matrices, including unequal external ports. Public `abcd_to_s` and `s_to_abcd`
|
|
||||||
use `[f_right, b_right] = ABCD @ [f_left, b_left]` and require equal port counts.
|
|
||||||
`get_taper_abcd` converts the stable S result; zero transmission or an
|
|
||||||
unrepresentable transfer matrix raises `LinAlgError`. Prefer S for long lossy
|
|
||||||
devices. Singular interface and cascade systems also raise contextual errors
|
|
||||||
rather than silently substituting pseudoinverses.
|
|
||||||
|
|
||||||
::: meanas.fdfd.eme
|
|
||||||
|
|
||||||
## Orientation bias under modal truncation
|
|
||||||
|
|
||||||
Mixed testing is a projected approximation: H continuity is tested on the left
|
|
||||||
and E continuity on the right. Swapping the ports changes the testing spaces
|
|
||||||
when the retained modal subspaces are incomplete. This effect survives checks
|
|
||||||
of reciprocity and unitarity.
|
|
||||||
|
|
||||||
A reproducible Maxwell counterexample is a uniform isotropic medium with two
|
|
||||||
degenerate linear polarizations. Keep one polarization on each port, rotated
|
|
||||||
so their overlap is `a=0.8`. There is no material interface: true reflection is
|
|
||||||
zero and transmission into the retained receiver is `a`. The other polarization
|
|
||||||
carries the remaining transmitted power, `1-a**2=0.36`.
|
|
||||||
|
|
||||||
| Calculation | R_left | R_right | T_left_to_right | T_right_to_left |
|
|
||||||
| --- | ---: | ---: | ---: | ---: |
|
|
||||||
| Exact full basis, then restrict ports | 0 | 0 | 0.8 | 0.8 |
|
|
||||||
| Truncated mixed | 0.219512 | -0.219512 | 0.975610 | 0.975610 |
|
|
||||||
| Swap ports, mixed, then reorder | -0.219512 | 0.219512 | 0.975610 | 0.975610 |
|
|
||||||
| Truncated left Galerkin | 0 | 0 | 1.25 | 0.8 |
|
|
||||||
| Euclidean E/H least squares | 0 | 0 | 0.8 | 0.8 |
|
|
||||||
|
|
||||||
An independent driven 3D FDFD solve with PML agrees with the exact reference
|
|
||||||
from either incidence direction: transmission is 0.8 within `1e-12`, with
|
|
||||||
reflection below `2e-8` in the test fixture. Keeping both polarizations makes
|
|
||||||
mixed testing match the exact complete scattering matrix and restores
|
|
||||||
port-relabeling covariance.
|
|
||||||
|
|
||||||
For the single retained polarization, mixed testing yields
|
|
||||||
`r=(1-a**2)/(1+a**2)` and `t=2*a/(1+a**2)`. The spurious reflection is quadratic
|
|
||||||
in a small angle between adjacent retained polarizations. However, longitudinal
|
|
||||||
refinement alone is insufficient: rotating a single retained vector through a
|
|
||||||
uniform degenerate medium makes the sampled chain approach artificial following
|
|
||||||
of that vector (`|T|` approaches 1), while the physical endpoint overlap remains
|
|
||||||
0.8. Keeping the complete degenerate block recovers the physical result at every
|
|
||||||
sampling density tested.
|
|
||||||
|
|
||||||
Two tempting corrections are insufficient. Averaging the two port orientations
|
|
||||||
cancels reflection in this example but leaves transmission at 0.975610. Plain
|
|
||||||
Euclidean E/H least squares gives the correct uniform-medium result, but is not
|
|
||||||
a general replacement. A second fixture uses exact degenerate modes of diagonal
|
|
||||||
positive epsilon/mu tensors, with electric scaling `(1,2)` on the left and
|
|
||||||
`(1,3)` on the right and a 30-degree polarization rotation on both ports. After
|
|
||||||
single-mode truncation, least squares has unequal directional transmissions
|
|
||||||
(approximately 0.850839 and 1.092163) and a maximum singular value of 1.104947.
|
|
||||||
The complete Fresnel solution is reciprocal and lossless.
|
|
||||||
|
|
||||||
The mixed default remains unchanged. Retain complete coupled/degenerate blocks
|
|
||||||
when the geometry can mix them, check modal-basis convergence as well as section
|
|
||||||
sampling, and compare against independent full-field results. Neither orientation
|
|
||||||
averaging nor gain/reciprocity postprocessing recovers omitted-channel physics.
|
|
||||||
Orientation discrepancy is a useful diagnostic, not a bound on transmission
|
|
||||||
error. These examples use real power-orthonormal propagating modes; for general
|
|
||||||
complex modes, the reciprocity constraint involves the modal orthogonality
|
|
||||||
matrix, as derived by [Svendsen, Haakestad, and Skaar](https://arxiv.org/abs/1301.2458).
|
|
||||||
|
|
||||||
Reproduce the analytic, full-basis, candidate-formulation, taper-refinement, and
|
|
||||||
driven-FDFD checks with:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
OPENBLAS_NUM_THREADS=1 python -m pytest meanas/test/test_eme_orientation.py -q
|
|
||||||
```
|
|
||||||
1
docs/assets/vendor/mathjax/core.js
vendored
1
docs/assets/vendor/mathjax/core.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
docs/assets/vendor/mathjax/input/mml.js
vendored
1
docs/assets/vendor/mathjax/input/mml.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
34
docs/assets/vendor/mathjax/input/tex-full.js
vendored
34
docs/assets/vendor/mathjax/input/tex-full.js
vendored
File diff suppressed because one or more lines are too long
1
docs/assets/vendor/mathjax/loader.js
vendored
1
docs/assets/vendor/mathjax/loader.js
vendored
File diff suppressed because one or more lines are too long
38
docs/assets/vendor/mathjax/manifest.json
vendored
38
docs/assets/vendor/mathjax/manifest.json
vendored
|
|
@ -1,38 +0,0 @@
|
||||||
{
|
|
||||||
"etag": "7ab8013dfc2c395304109fe189c1772ef87b366a",
|
|
||||||
"files": {
|
|
||||||
"core.js": 212262,
|
|
||||||
"loader.js": 16370,
|
|
||||||
"startup.js": 22870,
|
|
||||||
"input/tex-full.js": 270711,
|
|
||||||
"input/asciimath.js": 107690,
|
|
||||||
"input/mml.js": 13601,
|
|
||||||
"input/mml/entities.js": 32232,
|
|
||||||
"output/chtml.js": 211041,
|
|
||||||
"output/chtml/fonts/tex.js": 104359,
|
|
||||||
"output/chtml/fonts/woff-v2/MathJax_Zero.woff": 1368,
|
|
||||||
"output/chtml/fonts/woff-v2/MathJax_Vector-Regular.woff": 1136,
|
|
||||||
"output/chtml/fonts/woff-v2/MathJax_Vector-Bold.woff": 1116,
|
|
||||||
"output/chtml/fonts/woff-v2/MathJax_Typewriter-Regular.woff": 17604,
|
|
||||||
"output/chtml/fonts/woff-v2/MathJax_Size4-Regular.woff": 5148,
|
|
||||||
"output/chtml/fonts/woff-v2/MathJax_Size3-Regular.woff": 3244,
|
|
||||||
"output/chtml/fonts/woff-v2/MathJax_Size2-Regular.woff": 5464,
|
|
||||||
"output/chtml/fonts/woff-v2/MathJax_Size1-Regular.woff": 5792,
|
|
||||||
"output/chtml/fonts/woff-v2/MathJax_Script-Regular.woff": 11852,
|
|
||||||
"output/chtml/fonts/woff-v2/MathJax_SansSerif-Regular.woff": 12660,
|
|
||||||
"output/chtml/fonts/woff-v2/MathJax_SansSerif-Italic.woff": 14628,
|
|
||||||
"output/chtml/fonts/woff-v2/MathJax_SansSerif-Bold.woff": 15944,
|
|
||||||
"output/chtml/fonts/woff-v2/MathJax_Math-Regular.woff": 19288,
|
|
||||||
"output/chtml/fonts/woff-v2/MathJax_Math-Italic.woff": 19360,
|
|
||||||
"output/chtml/fonts/woff-v2/MathJax_Math-BoldItalic.woff": 19776,
|
|
||||||
"output/chtml/fonts/woff-v2/MathJax_Main-Regular.woff": 34160,
|
|
||||||
"output/chtml/fonts/woff-v2/MathJax_Main-Italic.woff": 20832,
|
|
||||||
"output/chtml/fonts/woff-v2/MathJax_Main-Bold.woff": 34464,
|
|
||||||
"output/chtml/fonts/woff-v2/MathJax_Fraktur-Regular.woff": 21480,
|
|
||||||
"output/chtml/fonts/woff-v2/MathJax_Fraktur-Bold.woff": 22340,
|
|
||||||
"output/chtml/fonts/woff-v2/MathJax_Calligraphic-Regular.woff": 9600,
|
|
||||||
"output/chtml/fonts/woff-v2/MathJax_Calligraphic-Bold.woff": 9908,
|
|
||||||
"output/chtml/fonts/woff-v2/MathJax_AMS-Regular.woff": 40808
|
|
||||||
},
|
|
||||||
"version": "3.1.4"
|
|
||||||
}
|
|
||||||
1
docs/assets/vendor/mathjax/output/chtml.js
vendored
1
docs/assets/vendor/mathjax/output/chtml.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
1
docs/assets/vendor/mathjax/startup.js
vendored
1
docs/assets/vendor/mathjax/startup.js
vendored
File diff suppressed because one or more lines are too long
|
|
@ -1,63 +0,0 @@
|
||||||
# meanas
|
|
||||||
|
|
||||||
`meanas` is a Python package for finite-difference electromagnetic simulation.
|
|
||||||
It combines:
|
|
||||||
|
|
||||||
- `meanas.fdfd` for frequency-domain operators, sources, waveguide modes, and SCPML
|
|
||||||
- `meanas.fdtd` for Yee-grid timestepping, CPML, energy/flux accounting, and phasor extraction
|
|
||||||
- `meanas.fdmath` for the shared discrete operators and derivations underneath both solvers
|
|
||||||
|
|
||||||
This documentation is built directly from the package docstrings. The API pages
|
|
||||||
are the source of truth for the mathematical derivations and calling
|
|
||||||
conventions.
|
|
||||||
|
|
||||||
## Examples and API Map
|
|
||||||
|
|
||||||
For most users, the tracked examples under `examples/` are the right entry
|
|
||||||
point. They show the intended combinations of tools for solving complete
|
|
||||||
problems.
|
|
||||||
|
|
||||||
Relevant starting examples:
|
|
||||||
|
|
||||||
- `examples/fdtd.py` for broadband pulse excitation and phasor extraction
|
|
||||||
- `examples/waveguide.py` for guided phasor-domain FDTD/FDFD comparison
|
|
||||||
- `examples/waveguide_real.py` for real-valued continuous-wave FDTD compared
|
|
||||||
against real fields reconstructed from an FDFD solution, including guided-core,
|
|
||||||
mode-weighted, and guided-mode / residual comparisons
|
|
||||||
- `examples/eme.py` for straight-interface mode matching / EME and modal
|
|
||||||
scattering between two nearby waveguide cross-sections
|
|
||||||
- `examples/eme_bend.py` for straight-to-bent mode matching with cylindrical
|
|
||||||
bend modes and a cascaded bend-network example
|
|
||||||
- `examples/eme_taper_cmt.py` for sampled-interface EME through continuously
|
|
||||||
varying taper cross-sections
|
|
||||||
- `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`.
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
window.MathJax = {
|
|
||||||
loader: {
|
|
||||||
load: ["input/tex-full", "output/chtml"]
|
|
||||||
},
|
|
||||||
tex: {
|
|
||||||
processEscapes: true,
|
|
||||||
processEnvironments: true
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
ignoreHtmlClass: ".*|",
|
|
||||||
processHtmlClass: "arithmatex"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document$.subscribe(() => {
|
|
||||||
MathJax.typesetPromise?.(
|
|
||||||
document.querySelectorAll(".arithmatex")
|
|
||||||
).catch((error) => console.error(error));
|
|
||||||
});
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
.md-typeset .arithmatex {
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.md-typeset .doc-contents {
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
.md-typeset h1 code,
|
|
||||||
.md-typeset h2 code,
|
|
||||||
.md-typeset h3 code {
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-md-color-scheme="slate"] {
|
|
||||||
--md-default-bg-color: #000000;
|
|
||||||
--md-default-bg-color--light: #050505;
|
|
||||||
--md-default-bg-color--lighter: #0a0a0a;
|
|
||||||
--md-default-bg-color--lightest: #111111;
|
|
||||||
--md-default-fg-color: #e8eef7;
|
|
||||||
--md-default-fg-color--light: #b3bfd1;
|
|
||||||
--md-default-fg-color--lighter: #7f8ba0;
|
|
||||||
--md-default-fg-color--lightest: #5d6880;
|
|
||||||
--md-code-bg-color: #050505;
|
|
||||||
--md-code-fg-color: #e4edf8;
|
|
||||||
--md-accent-fg-color: #7dd3fc;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-md-color-scheme="slate"] .md-header,
|
|
||||||
[data-md-color-scheme="slate"] .md-tabs {
|
|
||||||
background: #000000;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-md-color-scheme="slate"] .md-main,
|
|
||||||
[data-md-color-scheme="slate"] .md-main__inner,
|
|
||||||
[data-md-color-scheme="slate"] .md-content,
|
|
||||||
[data-md-color-scheme="slate"] .md-content__inner,
|
|
||||||
[data-md-color-scheme="slate"] .md-sidebar,
|
|
||||||
[data-md-color-scheme="slate"] .md-sidebar__scrollwrap {
|
|
||||||
background: #000000;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-md-color-scheme="slate"] .md-typeset pre > code,
|
|
||||||
[data-md-color-scheme="slate"] .md-typeset code {
|
|
||||||
border: 1px solid rgba(125, 211, 252, 0.14);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-md-color-scheme="slate"] .md-typeset table:not([class]) {
|
|
||||||
background: #050505;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-md-color-scheme="slate"] .md-typeset table:not([class]) th {
|
|
||||||
background: rgba(125, 211, 252, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-md-color-scheme="slate"] .md-typeset .admonition,
|
|
||||||
[data-md-color-scheme="slate"] .md-typeset details {
|
|
||||||
background: #050505;
|
|
||||||
border-color: rgba(125, 211, 252, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-md-color-scheme="slate"] .md-typeset .arithmatex {
|
|
||||||
padding: 0.1rem 0;
|
|
||||||
}
|
|
||||||
221
examples/eme.py
221
examples/eme.py
|
|
@ -1,221 +0,0 @@
|
||||||
"""
|
|
||||||
Mode-matching / EME example for a straight rib-waveguide interface.
|
|
||||||
|
|
||||||
This example shows the intended user-facing workflow for `meanas.fdfd.eme` on a
|
|
||||||
simple straight interface:
|
|
||||||
|
|
||||||
1. build two nearby waveguide cross-sections on a Yee grid,
|
|
||||||
2. solve a small set of guided modes on each side,
|
|
||||||
3. normalize those modes into E/H port fields,
|
|
||||||
4. assemble the interface scattering matrix with `meanas.fdfd.eme.get_s(...)`,
|
|
||||||
5. inspect the dominant modal coupling numerically and visually.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import importlib
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
import numpy
|
|
||||||
from numpy import pi
|
|
||||||
|
|
||||||
import gridlock
|
|
||||||
from gridlock import Extent
|
|
||||||
|
|
||||||
from meanas.fdfd import eme, waveguide_2d
|
|
||||||
from meanas.fdmath import unvec
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from types import ModuleType
|
|
||||||
|
|
||||||
|
|
||||||
WL = 1310.0
|
|
||||||
DX = 40.0
|
|
||||||
WIDTH = 400.0
|
|
||||||
THF = 161.0
|
|
||||||
THP = 77.0
|
|
||||||
EPS_SI = 3.51 ** 2
|
|
||||||
EPS_OX = 1.453 ** 2
|
|
||||||
MODE_NUMBERS = numpy.array([0])
|
|
||||||
|
|
||||||
|
|
||||||
def require_optional(name: str, package_name: str | None = None) -> ModuleType:
|
|
||||||
package_name = package_name or name
|
|
||||||
try:
|
|
||||||
return importlib.import_module(name)
|
|
||||||
except ImportError as exc: # pragma: no cover - user environment guard
|
|
||||||
raise SystemExit(
|
|
||||||
f"This example requires the optional package '{package_name}'. "
|
|
||||||
"Install example dependencies with `pip install -e './meanas[examples]'`.",
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
|
|
||||||
def build_geometry(
|
|
||||||
*,
|
|
||||||
dx: float = DX,
|
|
||||||
width: float = WIDTH,
|
|
||||||
thf: float = THF,
|
|
||||||
thp: float = THP,
|
|
||||||
eps_si: float = EPS_SI,
|
|
||||||
eps_ox: float = EPS_OX,
|
|
||||||
) -> tuple[gridlock.Grid, numpy.ndarray, list[list[numpy.ndarray]], float]:
|
|
||||||
x0 = (width / 2) % dx
|
|
||||||
omega = 2 * pi / WL
|
|
||||||
|
|
||||||
grid = gridlock.Grid(
|
|
||||||
[
|
|
||||||
numpy.arange(-800, 800 + dx, dx),
|
|
||||||
numpy.arange(-400, 400 + dx, dx),
|
|
||||||
numpy.arange(-2 * dx, 2 * dx + dx, dx),
|
|
||||||
],
|
|
||||||
periodic=True,
|
|
||||||
)
|
|
||||||
epsilon = grid.allocate(eps_ox)
|
|
||||||
|
|
||||||
grid.draw_cuboid(
|
|
||||||
epsilon,
|
|
||||||
foreground=eps_si,
|
|
||||||
x=Extent(center=x0, span=width + 1200),
|
|
||||||
y=Extent(min=0, max=thf),
|
|
||||||
z=Extent(min=-1e6, max=0),
|
|
||||||
)
|
|
||||||
grid.draw_cuboid(
|
|
||||||
epsilon,
|
|
||||||
foreground=eps_ox,
|
|
||||||
x=Extent(max=-width / 2, span=300),
|
|
||||||
y=Extent(min=thp, max=1e6),
|
|
||||||
z=Extent(min=-1e6, max=0),
|
|
||||||
)
|
|
||||||
grid.draw_cuboid(
|
|
||||||
epsilon,
|
|
||||||
foreground=eps_ox,
|
|
||||||
x=Extent(min=width / 2, span=300),
|
|
||||||
y=Extent(min=thp, max=1e6),
|
|
||||||
z=Extent(min=-1e6, max=0),
|
|
||||||
)
|
|
||||||
|
|
||||||
grid.draw_cuboid(
|
|
||||||
epsilon,
|
|
||||||
foreground=eps_si,
|
|
||||||
x=Extent(max=-(width / 2 + 600), span=240),
|
|
||||||
y=Extent(min=0, max=thf),
|
|
||||||
z=Extent(min=0, max=1e6),
|
|
||||||
)
|
|
||||||
grid.draw_cuboid(
|
|
||||||
epsilon,
|
|
||||||
foreground=eps_si,
|
|
||||||
x=Extent(max=width / 2 + 600, span=240),
|
|
||||||
y=Extent(min=0, max=thf),
|
|
||||||
z=Extent(min=0, max=1e6),
|
|
||||||
)
|
|
||||||
|
|
||||||
dxes = [grid.dxyz, grid.autoshifted_dxyz()]
|
|
||||||
dxes_2d = [[d[0], d[1]] for d in dxes]
|
|
||||||
return grid, epsilon, dxes_2d, omega
|
|
||||||
|
|
||||||
|
|
||||||
def solve_cross_section_modes(
|
|
||||||
epsilon_slice: numpy.ndarray,
|
|
||||||
*,
|
|
||||||
omega: float,
|
|
||||||
dxes_2d: list[list[numpy.ndarray]],
|
|
||||||
mode_numbers: numpy.ndarray = MODE_NUMBERS,
|
|
||||||
) -> tuple[list[tuple[numpy.ndarray, numpy.ndarray]], numpy.ndarray]:
|
|
||||||
e_xys, wavenumbers = waveguide_2d.solve_modes(
|
|
||||||
epsilon=epsilon_slice.ravel(),
|
|
||||||
omega=omega,
|
|
||||||
dxes=dxes_2d,
|
|
||||||
mode_numbers=mode_numbers,
|
|
||||||
)
|
|
||||||
eh_fields = [
|
|
||||||
waveguide_2d.normalized_fields_e(
|
|
||||||
e_xy,
|
|
||||||
wavenumber=wavenumber,
|
|
||||||
dxes=dxes_2d,
|
|
||||||
omega=omega,
|
|
||||||
epsilon=epsilon_slice.ravel(),
|
|
||||||
)
|
|
||||||
for e_xy, wavenumber in zip(e_xys, wavenumbers, strict=True)
|
|
||||||
]
|
|
||||||
return eh_fields, wavenumbers
|
|
||||||
|
|
||||||
|
|
||||||
def print_summary(ss: numpy.ndarray, wavenumbers_left: numpy.ndarray, wavenumbers_right: numpy.ndarray, omega: float) -> None:
|
|
||||||
n_left = len(wavenumbers_left)
|
|
||||||
left_neff = numpy.real(wavenumbers_left / omega)
|
|
||||||
right_neff = numpy.real(wavenumbers_right / omega)
|
|
||||||
|
|
||||||
print('left effective indices:', ', '.join(f'{value:.5f}' for value in left_neff[:4]))
|
|
||||||
print('right effective indices:', ', '.join(f'{value:.5f}' for value in right_neff[:4]))
|
|
||||||
|
|
||||||
reflection = abs(ss[0, 0]) ** 2
|
|
||||||
transmission = abs(ss[n_left, 0]) ** 2
|
|
||||||
total_output = numpy.sum(abs(ss[:, 0]) ** 2)
|
|
||||||
print(f'fundamental left-incident reflection |S_00|^2 = {reflection:.6f}')
|
|
||||||
print(f'fundamental left-to-right transmission |S_{n_left},0|^2 = {transmission:.6f}')
|
|
||||||
print(f'fundamental left-incident total output power = {total_output:.6f}')
|
|
||||||
|
|
||||||
strongest = numpy.argsort(abs(ss[n_left:, 0]) ** 2)[::-1][:3]
|
|
||||||
print('dominant transmitted right-side modes for left mode 0:')
|
|
||||||
for mode_index in strongest:
|
|
||||||
print(f' mode {mode_index}: |S|^2 = {abs(ss[n_left + mode_index, 0]) ** 2:.6f}')
|
|
||||||
|
|
||||||
|
|
||||||
def plot_results(
|
|
||||||
*,
|
|
||||||
pyplot: ModuleType,
|
|
||||||
ss: numpy.ndarray,
|
|
||||||
left_mode: tuple[numpy.ndarray, numpy.ndarray],
|
|
||||||
right_mode: tuple[numpy.ndarray, numpy.ndarray],
|
|
||||||
shape_2d: tuple[int, int],
|
|
||||||
) -> None:
|
|
||||||
fig_s, ax_s = pyplot.subplots()
|
|
||||||
image = ax_s.imshow(abs(ss) ** 2, origin='lower', cmap='magma')
|
|
||||||
fig_s.colorbar(image, ax=ax_s)
|
|
||||||
ax_s.set_title('Interface scattering magnitude |S|^2')
|
|
||||||
ax_s.set_xlabel('Incident mode index')
|
|
||||||
ax_s.set_ylabel('Outgoing mode index')
|
|
||||||
|
|
||||||
e_left = unvec(left_mode[0], shape_2d)
|
|
||||||
e_right = unvec(right_mode[0], shape_2d)
|
|
||||||
left_intensity = numpy.sum(abs(e_left) ** 2, axis=0).T
|
|
||||||
right_intensity = numpy.sum(abs(e_right) ** 2, axis=0).T
|
|
||||||
|
|
||||||
fig_modes, axes = pyplot.subplots(1, 2, figsize=(10, 4))
|
|
||||||
left_plot = axes[0].imshow(left_intensity, origin='lower', cmap='viridis')
|
|
||||||
fig_modes.colorbar(left_plot, ax=axes[0])
|
|
||||||
axes[0].set_title('Left fundamental mode |E|^2')
|
|
||||||
right_plot = axes[1].imshow(right_intensity, origin='lower', cmap='viridis')
|
|
||||||
fig_modes.colorbar(right_plot, ax=axes[1])
|
|
||||||
axes[1].set_title('Right fundamental mode |E|^2')
|
|
||||||
if pyplot.get_backend().lower().endswith('agg'):
|
|
||||||
pyplot.close(fig_s)
|
|
||||||
pyplot.close(fig_modes)
|
|
||||||
else:
|
|
||||||
pyplot.show()
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
pyplot = require_optional('matplotlib.pyplot', package_name='matplotlib')
|
|
||||||
|
|
||||||
grid, epsilon, dxes_2d, omega = build_geometry()
|
|
||||||
left_slice = epsilon[:, :, :, 1]
|
|
||||||
right_slice = epsilon[:, :, :, -2]
|
|
||||||
|
|
||||||
left_modes, wavenumbers_left = solve_cross_section_modes(left_slice, omega=omega, dxes_2d=dxes_2d)
|
|
||||||
right_modes, wavenumbers_right = solve_cross_section_modes(right_slice, omega=omega, dxes_2d=dxes_2d)
|
|
||||||
|
|
||||||
ss = eme.get_s(left_modes, wavenumbers_left, right_modes, wavenumbers_right, dxes=dxes_2d)
|
|
||||||
|
|
||||||
print_summary(ss, wavenumbers_left, wavenumbers_right, omega)
|
|
||||||
plot_results(
|
|
||||||
pyplot=pyplot,
|
|
||||||
ss=ss,
|
|
||||||
left_mode=left_modes[0],
|
|
||||||
right_mode=right_modes[0],
|
|
||||||
shape_2d=grid.shape[:2],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
|
|
@ -1,314 +0,0 @@
|
||||||
"""
|
|
||||||
Mode-matching / EME example for a straight-to-bent waveguide interface.
|
|
||||||
|
|
||||||
This example demonstrates a cylindrical-waveguide EME workflow:
|
|
||||||
|
|
||||||
1. build a rib-waveguide cross-section,
|
|
||||||
2. solve straight port modes with `waveguide_2d`,
|
|
||||||
3. solve bend modes with `waveguide_cyl`,
|
|
||||||
4. assemble the straight-to-bend interface scattering matrix with
|
|
||||||
`meanas.fdfd.eme.get_s(...)`,
|
|
||||||
5. optionally cascade a straight section, bend section, and interface pair into
|
|
||||||
a compact multiport network using `scikit-rf`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import importlib
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
import numpy
|
|
||||||
from numpy import pi
|
|
||||||
from scipy import sparse
|
|
||||||
|
|
||||||
import gridlock
|
|
||||||
from gridlock import Extent
|
|
||||||
|
|
||||||
from meanas.fdfd import eme, waveguide_2d, waveguide_cyl
|
|
||||||
from meanas.fdmath import unvec
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from types import ModuleType
|
|
||||||
|
|
||||||
|
|
||||||
WL = 1310.0
|
|
||||||
DX = 40.0
|
|
||||||
RADIUS = 6e3
|
|
||||||
WIDTH = 400.0
|
|
||||||
THF = 161.0
|
|
||||||
THP = 77.0
|
|
||||||
EPS_SI = 3.51 ** 2
|
|
||||||
EPS_OX = 1.453 ** 2
|
|
||||||
MODE_NUMBERS = numpy.array([0])
|
|
||||||
STRAIGHT_SECTION_LENGTH = 12e3
|
|
||||||
BEND_ANGLE = pi / 2
|
|
||||||
|
|
||||||
|
|
||||||
def require_optional(name: str, package_name: str | None = None) -> ModuleType:
|
|
||||||
package_name = package_name or name
|
|
||||||
try:
|
|
||||||
return importlib.import_module(name)
|
|
||||||
except ImportError as exc: # pragma: no cover - user environment guard
|
|
||||||
raise SystemExit(
|
|
||||||
f"This example requires the optional package '{package_name}'. "
|
|
||||||
"Install example dependencies with `pip install -e './meanas[examples]'`.",
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
|
|
||||||
def build_geometry(
|
|
||||||
*,
|
|
||||||
dx: float = DX,
|
|
||||||
width: float = WIDTH,
|
|
||||||
thf: float = THF,
|
|
||||||
thp: float = THP,
|
|
||||||
eps_si: float = EPS_SI,
|
|
||||||
eps_ox: float = EPS_OX,
|
|
||||||
) -> tuple[gridlock.Grid, numpy.ndarray, list[list[numpy.ndarray]], float]:
|
|
||||||
x0 = (width / 2) % dx
|
|
||||||
omega = 2 * pi / WL
|
|
||||||
|
|
||||||
grid = gridlock.Grid(
|
|
||||||
[
|
|
||||||
numpy.arange(-800, 800 + dx, dx),
|
|
||||||
numpy.arange(-400, 400 + dx, dx),
|
|
||||||
numpy.arange(-2 * dx, 2 * dx + dx, dx),
|
|
||||||
],
|
|
||||||
periodic=True,
|
|
||||||
)
|
|
||||||
epsilon = grid.allocate(eps_ox)
|
|
||||||
|
|
||||||
grid.draw_cuboid(
|
|
||||||
epsilon,
|
|
||||||
foreground=eps_si,
|
|
||||||
x=Extent(center=x0, span=width + 1200),
|
|
||||||
y=Extent(min=0, max=thf),
|
|
||||||
z=Extent(min=-1e6, max=0),
|
|
||||||
)
|
|
||||||
grid.draw_cuboid(
|
|
||||||
epsilon,
|
|
||||||
foreground=eps_ox,
|
|
||||||
x=Extent(max=-width / 2, span=300),
|
|
||||||
y=Extent(min=thp, max=1e6),
|
|
||||||
z=Extent(min=-1e6, center=0),
|
|
||||||
)
|
|
||||||
grid.draw_cuboid(
|
|
||||||
epsilon,
|
|
||||||
foreground=eps_ox,
|
|
||||||
x=Extent(min=width / 2, span=300),
|
|
||||||
y=Extent(min=thp, max=1e6),
|
|
||||||
z=Extent(min=-1e6, center=0),
|
|
||||||
)
|
|
||||||
|
|
||||||
dxes = [grid.dxyz, grid.autoshifted_dxyz()]
|
|
||||||
dxes_2d = [[d[0], d[1]] for d in dxes]
|
|
||||||
return grid, epsilon, dxes_2d, omega
|
|
||||||
|
|
||||||
|
|
||||||
def solve_straight_modes(
|
|
||||||
epsilon_slice: numpy.ndarray,
|
|
||||||
*,
|
|
||||||
omega: float,
|
|
||||||
dxes_2d: list[list[numpy.ndarray]],
|
|
||||||
mode_numbers: numpy.ndarray = MODE_NUMBERS,
|
|
||||||
) -> tuple[list[tuple[numpy.ndarray, numpy.ndarray]], numpy.ndarray]:
|
|
||||||
e_xys, wavenumbers = waveguide_2d.solve_modes(
|
|
||||||
epsilon=epsilon_slice.ravel(),
|
|
||||||
omega=omega,
|
|
||||||
dxes=dxes_2d,
|
|
||||||
mode_numbers=mode_numbers,
|
|
||||||
)
|
|
||||||
eh_fields = [
|
|
||||||
waveguide_2d.normalized_fields_e(
|
|
||||||
e_xy,
|
|
||||||
wavenumber=wavenumber,
|
|
||||||
dxes=dxes_2d,
|
|
||||||
omega=omega,
|
|
||||||
epsilon=epsilon_slice.ravel(),
|
|
||||||
)
|
|
||||||
for e_xy, wavenumber in zip(e_xys, wavenumbers, strict=True)
|
|
||||||
]
|
|
||||||
return eh_fields, wavenumbers
|
|
||||||
|
|
||||||
|
|
||||||
def solve_bend_modes(
|
|
||||||
epsilon_slice: numpy.ndarray,
|
|
||||||
*,
|
|
||||||
omega: float,
|
|
||||||
dxes_2d: list[list[numpy.ndarray]],
|
|
||||||
rmin: float,
|
|
||||||
mode_numbers: numpy.ndarray = MODE_NUMBERS,
|
|
||||||
) -> tuple[list[tuple[numpy.ndarray, numpy.ndarray]], numpy.ndarray, numpy.ndarray]:
|
|
||||||
e_xys, angular_wavenumbers = waveguide_cyl.solve_modes(
|
|
||||||
epsilon=epsilon_slice.ravel(),
|
|
||||||
omega=omega,
|
|
||||||
dxes=dxes_2d,
|
|
||||||
mode_numbers=mode_numbers,
|
|
||||||
rmin=rmin,
|
|
||||||
)
|
|
||||||
linear_wavenumbers = waveguide_cyl.linear_wavenumbers(
|
|
||||||
e_xys=e_xys,
|
|
||||||
angular_wavenumbers=angular_wavenumbers,
|
|
||||||
rmin=rmin,
|
|
||||||
epsilon=epsilon_slice.ravel(),
|
|
||||||
dxes=dxes_2d,
|
|
||||||
)
|
|
||||||
eh_fields = [
|
|
||||||
waveguide_cyl.normalized_fields_e(
|
|
||||||
e_xy,
|
|
||||||
angular_wavenumber=angular_wavenumber,
|
|
||||||
dxes=dxes_2d,
|
|
||||||
omega=omega,
|
|
||||||
epsilon=epsilon_slice.ravel(),
|
|
||||||
rmin=rmin,
|
|
||||||
)
|
|
||||||
for e_xy, angular_wavenumber in zip(e_xys, angular_wavenumbers, strict=True)
|
|
||||||
]
|
|
||||||
return eh_fields, linear_wavenumbers, angular_wavenumbers
|
|
||||||
|
|
||||||
|
|
||||||
def build_cascaded_network(
|
|
||||||
skrf: ModuleType,
|
|
||||||
*,
|
|
||||||
interface_s: numpy.ndarray,
|
|
||||||
straight_wavenumbers: numpy.ndarray,
|
|
||||||
bend_angular_wavenumbers: numpy.ndarray,
|
|
||||||
n_modes: int,
|
|
||||||
) -> object:
|
|
||||||
net_sb = skrf.Network(f=[1 / WL], s=interface_s[numpy.newaxis, ...])
|
|
||||||
net_bs = net_sb.copy()
|
|
||||||
net_bs.renumber(numpy.arange(2 * n_modes), numpy.roll(numpy.arange(2 * n_modes), n_modes))
|
|
||||||
|
|
||||||
straight_phase = sparse.diags_array(numpy.exp(-1j * straight_wavenumbers[:n_modes] * STRAIGHT_SECTION_LENGTH))
|
|
||||||
bend_phase = sparse.diags_array(numpy.exp(-1j * bend_angular_wavenumbers[:n_modes] * BEND_ANGLE))
|
|
||||||
zero = numpy.zeros((n_modes, n_modes), dtype=complex)
|
|
||||||
|
|
||||||
straight_s = numpy.block([[zero, straight_phase.toarray()], [straight_phase.toarray(), zero]])
|
|
||||||
bend_s = numpy.block([[zero, bend_phase.toarray()], [bend_phase.toarray(), zero]])
|
|
||||||
net_straight = skrf.Network(f=[1 / WL], s=straight_s[numpy.newaxis, ...])
|
|
||||||
net_bend = skrf.Network(f=[1 / WL], s=bend_s[numpy.newaxis, ...])
|
|
||||||
|
|
||||||
return skrf.network.cascade_list([net_straight, net_sb, net_bend, net_bs, net_straight])
|
|
||||||
|
|
||||||
|
|
||||||
def print_summary(
|
|
||||||
interface_s: numpy.ndarray,
|
|
||||||
cascaded_s: numpy.ndarray,
|
|
||||||
straight_wavenumbers: numpy.ndarray,
|
|
||||||
bend_linear_wavenumbers: numpy.ndarray,
|
|
||||||
bend_angular_wavenumbers: numpy.ndarray,
|
|
||||||
omega: float,
|
|
||||||
n_modes: int,
|
|
||||||
) -> None:
|
|
||||||
straight_neff = numpy.real(straight_wavenumbers / omega)
|
|
||||||
bend_neff = numpy.real(bend_linear_wavenumbers / omega)
|
|
||||||
print('straight effective indices:', ', '.join(f'{value:.5f}' for value in straight_neff[:4]))
|
|
||||||
print('bend effective indices :', ', '.join(f'{value:.5f}' for value in bend_neff[:4]))
|
|
||||||
print('bend angular wavenumbers :', ', '.join(f'{value:.5e}' for value in bend_angular_wavenumbers[:4]))
|
|
||||||
|
|
||||||
interface_transmission = abs(interface_s[n_modes, 0]) ** 2
|
|
||||||
interface_reflection = abs(interface_s[0, 0]) ** 2
|
|
||||||
print(f'interface fundamental transmission |S_{n_modes},0|^2 = {interface_transmission:.6f}')
|
|
||||||
print(f'interface fundamental reflection |S_00|^2 = {interface_reflection:.6f}')
|
|
||||||
|
|
||||||
total_cascaded_output = numpy.sum(abs(cascaded_s[:, 0]) ** 2)
|
|
||||||
bend_through = abs(cascaded_s[n_modes, 0]) ** 2
|
|
||||||
bend_reflection = abs(cascaded_s[0, 0]) ** 2
|
|
||||||
print(f'cascaded bend through power |S_{n_modes},0|^2 = {bend_through:.6f}')
|
|
||||||
print(f'cascaded bend reflection |S_00|^2 = {bend_reflection:.6f}')
|
|
||||||
print(f'cascaded left-incident total output power = {total_cascaded_output:.6f}')
|
|
||||||
|
|
||||||
|
|
||||||
def plot_results(
|
|
||||||
*,
|
|
||||||
pyplot: ModuleType,
|
|
||||||
interface_s: numpy.ndarray,
|
|
||||||
cascaded_s: numpy.ndarray,
|
|
||||||
straight_mode: tuple[numpy.ndarray, numpy.ndarray],
|
|
||||||
bend_mode: tuple[numpy.ndarray, numpy.ndarray],
|
|
||||||
shape_2d: tuple[int, int],
|
|
||||||
) -> None:
|
|
||||||
fig_s, axes = pyplot.subplots(1, 2, figsize=(12, 4))
|
|
||||||
interface_plot = axes[0].imshow(abs(interface_s) ** 2, origin='lower', cmap='magma')
|
|
||||||
fig_s.colorbar(interface_plot, ax=axes[0])
|
|
||||||
axes[0].set_title('Straight-to-bend interface |S|^2')
|
|
||||||
axes[0].set_xlabel('Incident mode index')
|
|
||||||
axes[0].set_ylabel('Outgoing mode index')
|
|
||||||
|
|
||||||
cascaded_plot = axes[1].imshow(abs(cascaded_s) ** 2, origin='lower', cmap='magma')
|
|
||||||
fig_s.colorbar(cascaded_plot, ax=axes[1])
|
|
||||||
axes[1].set_title('Cascaded bend network |S|^2')
|
|
||||||
axes[1].set_xlabel('Incident mode index')
|
|
||||||
axes[1].set_ylabel('Outgoing mode index')
|
|
||||||
|
|
||||||
straight_e = unvec(straight_mode[0], shape_2d)
|
|
||||||
bend_e = unvec(bend_mode[0], shape_2d)
|
|
||||||
straight_intensity = numpy.sum(abs(straight_e) ** 2, axis=0).T
|
|
||||||
bend_intensity = numpy.sum(abs(bend_e) ** 2, axis=0).T
|
|
||||||
|
|
||||||
fig_modes, axes_modes = pyplot.subplots(1, 2, figsize=(10, 4))
|
|
||||||
straight_plot = axes_modes[0].imshow(straight_intensity, origin='lower', cmap='viridis')
|
|
||||||
fig_modes.colorbar(straight_plot, ax=axes_modes[0])
|
|
||||||
axes_modes[0].set_title('Straight fundamental mode |E|^2')
|
|
||||||
bend_plot = axes_modes[1].imshow(bend_intensity, origin='lower', cmap='viridis')
|
|
||||||
fig_modes.colorbar(bend_plot, ax=axes_modes[1])
|
|
||||||
axes_modes[1].set_title('Bent fundamental mode |E|^2')
|
|
||||||
if pyplot.get_backend().lower().endswith('agg'):
|
|
||||||
pyplot.close(fig_s)
|
|
||||||
pyplot.close(fig_modes)
|
|
||||||
else:
|
|
||||||
pyplot.show()
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
pyplot = require_optional('matplotlib.pyplot', package_name='matplotlib')
|
|
||||||
skrf = require_optional('skrf', package_name='scikit-rf')
|
|
||||||
|
|
||||||
grid, epsilon, dxes_2d, omega = build_geometry()
|
|
||||||
epsilon_slice = epsilon[:, :, :, 1]
|
|
||||||
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()
|
|
||||||
|
|
@ -1,135 +0,0 @@
|
||||||
"""
|
|
||||||
Sampled-interface EME example for a continuously varying rib-waveguide taper.
|
|
||||||
|
|
||||||
This example keeps geometry construction outside `meanas.fdfd.eme`: it samples a
|
|
||||||
width taper at several cross-sections, solves and normalizes each local mode with
|
|
||||||
`waveguide_2d`, then asks `eme.get_taper_s(...)` for the bidirectional taper
|
|
||||||
scattering matrix.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import numpy
|
|
||||||
from numpy import pi
|
|
||||||
|
|
||||||
from meanas.fdmath import vec
|
|
||||||
from meanas.fdfd import eme, waveguide_2d
|
|
||||||
|
|
||||||
|
|
||||||
WL = 1310.0
|
|
||||||
DX = 80.0
|
|
||||||
TAPER_LENGTH = 4e3
|
|
||||||
WIDTH_LEFT = 280.0
|
|
||||||
WIDTH_RIGHT = 520.0
|
|
||||||
THF = 160.0
|
|
||||||
THP = 80.0
|
|
||||||
EPS_SI = 3.51 ** 2
|
|
||||||
EPS_OX = 1.453 ** 2
|
|
||||||
MODE_NUMBERS = numpy.array([0])
|
|
||||||
N_SECTIONS = 7
|
|
||||||
|
|
||||||
|
|
||||||
def build_dxes(shape: tuple[int, int], dx: float = DX) -> list[list[numpy.ndarray]]:
|
|
||||||
nx, ny = shape
|
|
||||||
return [
|
|
||||||
[numpy.full(nx, dx), numpy.full(ny, dx)],
|
|
||||||
[numpy.full(nx, dx), numpy.full(ny, dx)],
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def build_cross_section(
|
|
||||||
*,
|
|
||||||
width: float,
|
|
||||||
x: numpy.ndarray,
|
|
||||||
y: numpy.ndarray,
|
|
||||||
eps_si: float = EPS_SI,
|
|
||||||
eps_ox: float = EPS_OX,
|
|
||||||
thf: float = THF,
|
|
||||||
thp: float = THP,
|
|
||||||
) -> numpy.ndarray:
|
|
||||||
epsilon = numpy.full((3, x.size, y.size), eps_ox, dtype=float)
|
|
||||||
xx = x[:, None]
|
|
||||||
yy = y[None, :]
|
|
||||||
slab = (yy >= 0) & (yy <= thp)
|
|
||||||
rib = (abs(xx) <= width / 2) & (yy >= 0) & (yy <= thf)
|
|
||||||
epsilon[:, slab.repeat(x.size, axis=0)] = eps_si
|
|
||||||
epsilon[:, rib] = eps_si
|
|
||||||
return epsilon
|
|
||||||
|
|
||||||
|
|
||||||
def solve_cross_section_modes(
|
|
||||||
epsilon: 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]:
|
|
||||||
epsilon_vec = vec(epsilon)
|
|
||||||
e_xys, wavenumbers = waveguide_2d.solve_modes(
|
|
||||||
epsilon=epsilon_vec,
|
|
||||||
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_vec,
|
|
||||||
)
|
|
||||||
for e_xy, wavenumber in zip(e_xys, wavenumbers, strict=True)
|
|
||||||
]
|
|
||||||
return eh_fields, wavenumbers
|
|
||||||
|
|
||||||
|
|
||||||
def solve_taper_sections() -> tuple[list[eme.ModeSection], list[float], float, list[list[numpy.ndarray]]]:
|
|
||||||
omega = 2 * pi / WL
|
|
||||||
x = numpy.arange(-480, 480 + DX, DX)
|
|
||||||
y = numpy.arange(-240, 400 + DX, DX)
|
|
||||||
dxes_2d = build_dxes((x.size, y.size))
|
|
||||||
z_samples = numpy.linspace(0, TAPER_LENGTH, N_SECTIONS)
|
|
||||||
widths = numpy.linspace(WIDTH_LEFT, WIDTH_RIGHT, N_SECTIONS)
|
|
||||||
|
|
||||||
sections = []
|
|
||||||
neffs = []
|
|
||||||
for z_coord, width in zip(z_samples, widths, strict=True):
|
|
||||||
epsilon = build_cross_section(width=float(width), x=x, y=y)
|
|
||||||
modes, wavenumbers = solve_cross_section_modes(epsilon, omega=omega, dxes_2d=dxes_2d)
|
|
||||||
sections.append(eme.ModeSection(float(z_coord), modes, wavenumbers))
|
|
||||||
neffs.append(float(numpy.real(wavenumbers[0] / omega)))
|
|
||||||
|
|
||||||
return sections, neffs, omega, dxes_2d
|
|
||||||
|
|
||||||
|
|
||||||
def print_summary(
|
|
||||||
taper_s: numpy.ndarray,
|
|
||||||
abrupt_s: numpy.ndarray,
|
|
||||||
neffs: list[float],
|
|
||||||
) -> None:
|
|
||||||
n_modes = len(MODE_NUMBERS)
|
|
||||||
print('sampled taper effective indices:', ', '.join(f'{value:.5f}' for value in neffs))
|
|
||||||
print(f'abrupt endpoint reflection |S_00|^2 = {abs(abrupt_s[0, 0]) ** 2:.6f}')
|
|
||||||
print(f'abrupt endpoint transmission |S_{n_modes},0|^2 = {abs(abrupt_s[n_modes, 0]) ** 2:.6f}')
|
|
||||||
print(f'taper reflection |S_00|^2 = {abs(taper_s[0, 0]) ** 2:.6f}')
|
|
||||||
print(f'taper transmission |S_{n_modes},0|^2 = {abs(taper_s[n_modes, 0]) ** 2:.6f}')
|
|
||||||
print(f'taper total output power = {numpy.sum(abs(taper_s[:, 0]) ** 2):.6f}')
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
sections, neffs, _omega, dxes_2d = solve_taper_sections()
|
|
||||||
prepared = eme.prepare_taper(sections, dxes=dxes_2d)
|
|
||||||
taper_s = eme.evaluate_taper_s(prepared)
|
|
||||||
abrupt_s = eme.get_s(
|
|
||||||
sections[0].modes,
|
|
||||||
sections[0].wavenumbers,
|
|
||||||
sections[-1].modes,
|
|
||||||
sections[-1].wavenumbers,
|
|
||||||
dxes=dxes_2d,
|
|
||||||
)
|
|
||||||
print_summary(taper_s, abrupt_s, neffs)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
"""Build tracked complex/PML modal bases for a slowly varying taper."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import numpy
|
|
||||||
|
|
||||||
from meanas.fdmath import vec
|
|
||||||
from meanas.fdfd import eme, scpml, waveguide_2d
|
|
||||||
|
|
||||||
|
|
||||||
WAVELENGTH = 1.55
|
|
||||||
OMEGA = 2 * numpy.pi / WAVELENGTH
|
|
||||||
SHAPE = (17, 17)
|
|
||||||
SPACING = 0.25
|
|
||||||
PML_CELLS = 3
|
|
||||||
EPS_CLADDING = 1.45 ** 2
|
|
||||||
EPS_CORE = 2.0 ** 2
|
|
||||||
|
|
||||||
|
|
||||||
def build_grids() -> tuple[list[list[numpy.ndarray]], list[list[numpy.ndarray]]]:
|
|
||||||
"""Build matching physical and complex-stretched transverse Yee grids.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The real overlap grid followed by the PML-stretched solve grid.
|
|
||||||
"""
|
|
||||||
physical = [[numpy.full(size, SPACING) for size in SHAPE] for _ in range(2)]
|
|
||||||
stretched = [[values.copy() for values in family] for family in physical]
|
|
||||||
for axis in (0, 1):
|
|
||||||
for polarity in (-1, +1):
|
|
||||||
stretched = scpml.stretch_with_scpml(
|
|
||||||
stretched,
|
|
||||||
axis=axis,
|
|
||||||
polarity=polarity,
|
|
||||||
omega=OMEGA,
|
|
||||||
epsilon_effective=EPS_CLADDING,
|
|
||||||
thickness=PML_CELLS,
|
|
||||||
max_imag_ratio=1.0,
|
|
||||||
)
|
|
||||||
return physical, stretched
|
|
||||||
|
|
||||||
|
|
||||||
def build_epsilon(edge_fill: float) -> numpy.ndarray:
|
|
||||||
"""Construct and vectorize one taper cross-section permittivity profile.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
edge_fill: Fractional core fill used for the two cells beside the
|
|
||||||
central three-cell-wide core.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Vectorized three-component relative permittivity field.
|
|
||||||
"""
|
|
||||||
epsilon = numpy.full((3, *SHAPE), EPS_CLADDING)
|
|
||||||
center_x, center_y = (size // 2 for size in SHAPE)
|
|
||||||
epsilon[:, center_x - 1:center_x + 2, center_y] = EPS_CORE
|
|
||||||
mixed_epsilon = EPS_CLADDING + edge_fill * (EPS_CORE - EPS_CLADDING)
|
|
||||||
epsilon[:, center_x - 1:center_x + 2, center_y - 1] = mixed_epsilon
|
|
||||||
epsilon[:, center_x - 1:center_x + 2, center_y + 1] = mixed_epsilon
|
|
||||||
return vec(epsilon)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
"""Solve, track, and summarize the sampled complex modal bases."""
|
|
||||||
physical_dxes, solve_dxes = build_grids()
|
|
||||||
z_coords = numpy.array([0.0, 5.0, 10.0])
|
|
||||||
epsilon_samples = [build_epsilon(edge_fill) for edge_fill in (0.30, 0.35, 0.40)]
|
|
||||||
solved = [
|
|
||||||
waveguide_2d.solve_modes_near(
|
|
||||||
OMEGA,
|
|
||||||
solve_dxes,
|
|
||||||
epsilon,
|
|
||||||
target_wavenumbers=[1.45 * OMEGA],
|
|
||||||
modes_per_target=3,
|
|
||||||
candidate_margin=3,
|
|
||||||
overlap_dxes=physical_dxes,
|
|
||||||
)
|
|
||||||
for epsilon in epsilon_samples
|
|
||||||
]
|
|
||||||
bases = [basis for basis, _diagnostics in solved]
|
|
||||||
tracked = waveguide_2d.track_mode_path(bases, overlap_dxes=physical_dxes)
|
|
||||||
tracked_diagnostics = [
|
|
||||||
waveguide_2d.compute_mode_diagnostics(
|
|
||||||
basis,
|
|
||||||
OMEGA,
|
|
||||||
solve_dxes,
|
|
||||||
epsilon,
|
|
||||||
target_wavenumbers=[1.45 * OMEGA],
|
|
||||||
overlap_dxes=physical_dxes,
|
|
||||||
)
|
|
||||||
for basis, epsilon in zip(tracked, epsilon_samples, strict=True)
|
|
||||||
]
|
|
||||||
sections = eme.mode_sections_from_bases(z_coords, tracked)
|
|
||||||
prepared = eme.prepare_taper(sections, physical_dxes)
|
|
||||||
# Fields and interface overlaps are reused: these are frozen-basis length
|
|
||||||
# sweeps. Wavelength-dependent beta overrides would also freeze the fields.
|
|
||||||
length_sweep = [eme.evaluate_taper_s(prepared, z_coords=z_coords * scale)
|
|
||||||
for scale in (0.8, 1.0, 1.2)]
|
|
||||||
|
|
||||||
for z_coord, basis, diagnostics in zip(z_coords, tracked, tracked_diagnostics, strict=True):
|
|
||||||
print(
|
|
||||||
f'z={z_coord:5.1f}: '
|
|
||||||
f'beta={basis.wavenumbers[0]:.6g}, '
|
|
||||||
f'PML participation={diagnostics.pml_participation[0]:.3f}, '
|
|
||||||
f'right residual={diagnostics.right_residuals[0]:.2e}',
|
|
||||||
)
|
|
||||||
print(f'constructed {len(sections)} tracked EME sections with {len(sections[0].modes)} modes each')
|
|
||||||
print('frozen-basis length sweep |T_00|:', [float(abs(ss[len(tracked[0]), 0])) for ss in length_sweep])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
|
|
@ -1,13 +1,10 @@
|
||||||
import importlib
|
import importlib
|
||||||
import logging
|
|
||||||
import numpy
|
import numpy
|
||||||
from numpy.linalg import norm
|
from numpy.linalg import norm
|
||||||
from matplotlib import pyplot, colors
|
|
||||||
import logging
|
|
||||||
|
|
||||||
import meanas
|
import meanas
|
||||||
from meanas import fdtd
|
from meanas import fdtd
|
||||||
from meanas.fdmath import vec, unvec, fdfield_t
|
from meanas.fdmath import vec, unvec
|
||||||
from meanas.fdfd import waveguide_3d, functional, scpml, operators
|
from meanas.fdfd import waveguide_3d, functional, scpml, operators
|
||||||
from meanas.fdfd.solvers import generic as generic_solver
|
from meanas.fdfd.solvers import generic as generic_solver
|
||||||
|
|
||||||
|
|
@ -15,6 +12,7 @@ import gridlock
|
||||||
|
|
||||||
from matplotlib import pyplot
|
from matplotlib import pyplot
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
logging.basicConfig(level=logging.DEBUG)
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
logging.getLogger('matplotlib').setLevel(logging.WARNING)
|
logging.getLogger('matplotlib').setLevel(logging.WARNING)
|
||||||
|
|
@ -22,6 +20,82 @@ logging.getLogger('matplotlib').setLevel(logging.WARNING)
|
||||||
__author__ = 'Jan Petykiewicz'
|
__author__ = 'Jan Petykiewicz'
|
||||||
|
|
||||||
|
|
||||||
|
def test0(solver=generic_solver):
|
||||||
|
dx = 50 # discretization (nm/cell)
|
||||||
|
pml_thickness = 10 # (number of cells)
|
||||||
|
|
||||||
|
wl = 1550 # Excitation wavelength
|
||||||
|
omega = 2 * numpy.pi / wl
|
||||||
|
|
||||||
|
# Device design parameters
|
||||||
|
radii = (1, 0.6)
|
||||||
|
th = 220
|
||||||
|
center = [0, 0, 0]
|
||||||
|
|
||||||
|
# refractive indices
|
||||||
|
n_ring = numpy.sqrt(12.6) # ~Si
|
||||||
|
n_air = 4.0 # air
|
||||||
|
|
||||||
|
# Half-dimensions of the simulation grid
|
||||||
|
xyz_max = numpy.array([1.2, 1.2, 0.3]) * 1000 + pml_thickness * dx
|
||||||
|
|
||||||
|
# Coordinates of the edges of the cells.
|
||||||
|
half_edge_coords = [numpy.arange(dx/2, m + dx, step=dx) for m in xyz_max]
|
||||||
|
edge_coords = [numpy.hstack((-h[::-1], h)) for h in half_edge_coords]
|
||||||
|
|
||||||
|
# #### Create the grid, mask, and draw the device ####
|
||||||
|
grid = gridlock.Grid(edge_coords)
|
||||||
|
epsilon = grid.allocate(n_air**2, dtype=numpy.float32)
|
||||||
|
grid.draw_cylinder(epsilon,
|
||||||
|
surface_normal=2,
|
||||||
|
center=center,
|
||||||
|
radius=max(radii),
|
||||||
|
thickness=th,
|
||||||
|
eps=n_ring**2,
|
||||||
|
num_points=24)
|
||||||
|
grid.draw_cylinder(epsilon,
|
||||||
|
surface_normal=2,
|
||||||
|
center=center,
|
||||||
|
radius=min(radii),
|
||||||
|
thickness=th*1.1,
|
||||||
|
eps=n_air ** 2,
|
||||||
|
num_points=24)
|
||||||
|
|
||||||
|
dxes = [grid.dxyz, grid.autoshifted_dxyz()]
|
||||||
|
for a in (0, 1, 2):
|
||||||
|
for p in (-1, 1):
|
||||||
|
dxes = meanas.fdfd.scpml.stretch_with_scpml(dxes, axis=a, polarity=p, omega=omega,
|
||||||
|
thickness=pml_thickness)
|
||||||
|
|
||||||
|
J = [numpy.zeros_like(epsilon[0], dtype=complex) for _ in range(3)]
|
||||||
|
J[1][15, grid.shape[1]//2, grid.shape[2]//2] = 1
|
||||||
|
|
||||||
|
|
||||||
|
'''
|
||||||
|
Solve!
|
||||||
|
'''
|
||||||
|
sim_args = {
|
||||||
|
'omega': omega,
|
||||||
|
'dxes': dxes,
|
||||||
|
'epsilon': vec(epsilon),
|
||||||
|
}
|
||||||
|
x = solver(J=vec(J), **sim_args)
|
||||||
|
|
||||||
|
A = operators.e_full(omega, dxes, vec(epsilon)).tocsr()
|
||||||
|
b = -1j * omega * vec(J)
|
||||||
|
print('Norm of the residual is ', norm(A @ x - b))
|
||||||
|
|
||||||
|
E = unvec(x, grid.shape)
|
||||||
|
|
||||||
|
'''
|
||||||
|
Plot results
|
||||||
|
'''
|
||||||
|
pyplot.figure()
|
||||||
|
pyplot.pcolor(numpy.real(E[1][:, :, grid.shape[2]//2]), cmap='seismic')
|
||||||
|
pyplot.axis('equal')
|
||||||
|
pyplot.show()
|
||||||
|
|
||||||
|
|
||||||
def test1(solver=generic_solver):
|
def test1(solver=generic_solver):
|
||||||
dx = 40 # discretization (nm/cell)
|
dx = 40 # discretization (nm/cell)
|
||||||
pml_thickness = 10 # (number of cells)
|
pml_thickness = 10 # (number of cells)
|
||||||
|
|
@ -48,7 +122,7 @@ def test1(solver=generic_solver):
|
||||||
# #### Create the grid and draw the device ####
|
# #### Create the grid and draw the device ####
|
||||||
grid = gridlock.Grid(edge_coords)
|
grid = gridlock.Grid(edge_coords)
|
||||||
epsilon = grid.allocate(n_air**2, dtype=numpy.float32)
|
epsilon = grid.allocate(n_air**2, dtype=numpy.float32)
|
||||||
grid.draw_cuboid(epsilon, x=dict(center=0, span=8e3), y=dict(center=0, span=w), z=dict(center=0, span=th), foreground=n_wg**2)
|
grid.draw_cuboid(epsilon, center=center, dimensions=[8e3, w, th], eps=n_wg**2)
|
||||||
|
|
||||||
dxes = [grid.dxyz, grid.autoshifted_dxyz()]
|
dxes = [grid.dxyz, grid.autoshifted_dxyz()]
|
||||||
for a in (0, 1, 2):
|
for a in (0, 1, 2):
|
||||||
|
|
@ -82,14 +156,22 @@ def test1(solver=generic_solver):
|
||||||
# grid.draw_cuboid(pmcg, center=[700, 0, 0], dimensions=[80, 1e8, 1e8], eps=1)
|
# grid.draw_cuboid(pmcg, center=[700, 0, 0], dimensions=[80, 1e8, 1e8], eps=1)
|
||||||
# grid.visualize_isosurface(pmcg)
|
# grid.visualize_isosurface(pmcg)
|
||||||
|
|
||||||
grid.visualize_slice(J.imag, plane=dict(y=6*dx), which_shifts=1, pcolormesh_args=dict(norm=colors.CenteredNorm(), cmap='bwr'))
|
def pcolor(v) -> None:
|
||||||
fig, ax = pyplot.subplots()
|
vmax = numpy.max(numpy.abs(v))
|
||||||
ax.pcolormesh((numpy.abs(J).sum(axis=2).sum(axis=0) > 0).astype(float).T, cmap='hot')
|
pyplot.pcolor(v, cmap='seismic', vmin=-vmax, vmax=vmax)
|
||||||
|
pyplot.axis('equal')
|
||||||
|
pyplot.colorbar()
|
||||||
|
|
||||||
|
ss = (1, slice(None), J.shape[2]//2+6, slice(None))
|
||||||
|
# pyplot.figure()
|
||||||
|
# pcolor(J3[ss].T.imag)
|
||||||
|
# pyplot.figure()
|
||||||
|
# pcolor((numpy.abs(J3).sum(axis=2).sum(axis=0) > 0).astype(float).T)
|
||||||
pyplot.show(block=True)
|
pyplot.show(block=True)
|
||||||
|
|
||||||
#
|
'''
|
||||||
# Solve!
|
Solve!
|
||||||
#
|
'''
|
||||||
sim_args = {
|
sim_args = {
|
||||||
'omega': omega,
|
'omega': omega,
|
||||||
'dxes': dxes,
|
'dxes': dxes,
|
||||||
|
|
@ -106,18 +188,20 @@ def test1(solver=generic_solver):
|
||||||
|
|
||||||
E = unvec(x, grid.shape)
|
E = unvec(x, grid.shape)
|
||||||
|
|
||||||
#
|
'''
|
||||||
# Plot results
|
Plot results
|
||||||
#
|
'''
|
||||||
center = grid.pos2ind([0, 0, 0], None).astype(int)
|
center = grid.pos2ind([0, 0, 0], None).astype(int)
|
||||||
fig, axes = pyplot.subplots(2, 2)
|
pyplot.figure()
|
||||||
grid.visualize_slice(E.real, plane=dict(x=0), which_shifts=1, ax=axes[0, 0], finalize=False, pcolormesh_args=dict(norm=colors.CenteredNorm(), cmap='bwr'))
|
pyplot.subplot(2, 2, 1)
|
||||||
grid.visualize_slice(E.real, plane=dict(z=0), which_shifts=1, ax=axes[0, 1], finalize=False, pcolormesh_args=dict(norm=colors.CenteredNorm(), cmap='bwr'))
|
pcolor(numpy.real(E[1][center[0], :, :]).T)
|
||||||
# pcolor(axes[0, 0], numpy.real(E[1][center[0], :, :]).T)
|
pyplot.subplot(2, 2, 2)
|
||||||
# pcolor(axes[0, 1], numpy.real(E[1][:, :, center[2]]).T)
|
pyplot.plot(numpy.log10(numpy.abs(E[1][:, center[1], center[2]]) + 1e-10))
|
||||||
axes[1, 0].plot(numpy.log10(numpy.abs(E[1][:, center[1], center[2]]) + 1e-10))
|
pyplot.grid(alpha=0.6)
|
||||||
axes[1, 0].grid(alpha=0.6)
|
pyplot.ylabel('log10 of field')
|
||||||
axes[1, 0].set_ylabel('log10 of field')
|
pyplot.subplot(2, 2, 3)
|
||||||
|
pcolor(numpy.real(E[1][:, :, center[2]]).T)
|
||||||
|
pyplot.subplot(2, 2, 4)
|
||||||
|
|
||||||
def poyntings(E):
|
def poyntings(E):
|
||||||
H = functional.e2h(omega, dxes)(E)
|
H = functional.e2h(omega, dxes)(E)
|
||||||
|
|
@ -131,28 +215,24 @@ def test1(solver=generic_solver):
|
||||||
return s0, s1, s2
|
return s0, s1, s2
|
||||||
|
|
||||||
s0x, s1x, s2x = poyntings(E)
|
s0x, s1x, s2x = poyntings(E)
|
||||||
ax = axes[1, 1]
|
pyplot.plot(s0x[0].sum(axis=2).sum(axis=1), label='s0', marker='.')
|
||||||
ax.plot(s0x[0].sum(axis=2).sum(axis=1), label='s0', marker='.')
|
pyplot.plot(s1x[0].sum(axis=2).sum(axis=1), label='s1', marker='.')
|
||||||
ax.plot(s1x[0].sum(axis=2).sum(axis=1), label='s1', marker='.')
|
pyplot.plot(s2x[0].sum(axis=2).sum(axis=1), label='s2', marker='.')
|
||||||
ax.plot(s2x[0].sum(axis=2).sum(axis=1), label='s2', marker='.')
|
pyplot.plot(E[1][:, center[1], center[2]].real.T, label='Ey', marker='x')
|
||||||
ax.plot(E[1][:, center[1], center[2]].real.T, label='Ey', marker='x')
|
pyplot.grid(alpha=0.6)
|
||||||
ax.grid(alpha=0.6)
|
pyplot.legend()
|
||||||
ax.legend()
|
pyplot.show()
|
||||||
|
|
||||||
p_in = (-E * J.conj()).sum() / 2 * (dx * dx * dx)
|
|
||||||
print(f'{p_in=}')
|
|
||||||
|
|
||||||
q = []
|
q = []
|
||||||
for i in range(-5, 30):
|
for i in range(-5, 30):
|
||||||
e_ovl_rolled = numpy.roll(e_overlap, i, axis=1)
|
e_ovl_rolled = numpy.roll(e_overlap, i, axis=1)
|
||||||
q += [numpy.abs(vec(E).conj() @ vec(e_ovl_rolled))]
|
q += [numpy.abs(vec(E) @ vec(e_ovl_rolled).conj())]
|
||||||
fig, ax = pyplot.subplots()
|
pyplot.figure()
|
||||||
ax.plot(q, marker='.')
|
pyplot.plot(q, marker='.')
|
||||||
ax.grid(alpha=0.6)
|
pyplot.grid(alpha=0.6)
|
||||||
ax.set_title('Overlap with mode')
|
pyplot.title('Overlap with mode')
|
||||||
print('Average overlap with mode:', sum(q[8:32])/len(q[8:32]))
|
pyplot.show()
|
||||||
|
print('Average overlap with mode:', sum(q)/len(q))
|
||||||
pyplot.show(block=True)
|
|
||||||
|
|
||||||
|
|
||||||
def module_available(name):
|
def module_available(name):
|
||||||
|
|
@ -160,6 +240,9 @@ def module_available(name):
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
#test0()
|
||||||
|
# test1()
|
||||||
|
|
||||||
if module_available('opencl_fdfd'):
|
if module_available('opencl_fdfd'):
|
||||||
from opencl_fdfd import cg_solver as opencl_solver
|
from opencl_fdfd import cg_solver as opencl_solver
|
||||||
test1(opencl_solver)
|
test1(opencl_solver)
|
||||||
|
|
@ -170,4 +253,3 @@ if __name__ == '__main__':
|
||||||
# test1(magma_solver)
|
# test1(magma_solver)
|
||||||
else:
|
else:
|
||||||
test1()
|
test1()
|
||||||
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
import numpy
|
|
||||||
from numpy.linalg import norm
|
|
||||||
from matplotlib import pyplot, colors
|
|
||||||
import logging
|
|
||||||
|
|
||||||
import meanas
|
|
||||||
from meanas import fdtd
|
|
||||||
from meanas.fdmath import vec, unvec
|
|
||||||
from meanas.fdfd import waveguide_3d, functional, scpml, operators
|
|
||||||
from meanas.fdfd.solvers import generic as generic_solver
|
|
||||||
|
|
||||||
import gridlock
|
|
||||||
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.DEBUG)
|
|
||||||
logging.getLogger('matplotlib').setLevel(logging.WARNING)
|
|
||||||
|
|
||||||
__author__ = 'Jan Petykiewicz'
|
|
||||||
|
|
||||||
|
|
||||||
def pcolor(ax, v) -> None:
|
|
||||||
mappable = ax.pcolor(v, cmap='seismic', norm=colors.CenteredNorm())
|
|
||||||
ax.axis('equal')
|
|
||||||
ax.get_figure().colorbar(mappable)
|
|
||||||
|
|
||||||
|
|
||||||
def test0(solver=generic_solver):
|
|
||||||
dx = 50 # discretization (nm/cell)
|
|
||||||
pml_thickness = 10 # (number of cells)
|
|
||||||
|
|
||||||
wl = 1550 # Excitation wavelength
|
|
||||||
omega = 2 * numpy.pi / wl
|
|
||||||
|
|
||||||
# Device design parameters
|
|
||||||
radii = (1, 0.6)
|
|
||||||
th = 220
|
|
||||||
center = [0, 0, 0]
|
|
||||||
|
|
||||||
# refractive indices
|
|
||||||
n_ring = numpy.sqrt(12.6) # ~Si
|
|
||||||
n_air = 4.0 # air
|
|
||||||
|
|
||||||
# Half-dimensions of the simulation grid
|
|
||||||
xyz_max = numpy.array([1.2, 1.2, 0.3]) * 1000 + pml_thickness * dx
|
|
||||||
|
|
||||||
# Coordinates of the edges of the cells.
|
|
||||||
half_edge_coords = [numpy.arange(dx/2, m + dx, step=dx) for m in xyz_max]
|
|
||||||
edge_coords = [numpy.hstack((-h[::-1], h)) for h in half_edge_coords]
|
|
||||||
|
|
||||||
# #### Create the grid, mask, and draw the device ####
|
|
||||||
grid = gridlock.Grid(edge_coords)
|
|
||||||
epsilon = grid.allocate(n_air**2, dtype=numpy.float32)
|
|
||||||
grid.draw_cylinder(
|
|
||||||
epsilon,
|
|
||||||
h = dict(axis='z', center=center[2], span=th),
|
|
||||||
radius = max(radii),
|
|
||||||
center2d = center[:2],
|
|
||||||
foreground = n_ring ** 2,
|
|
||||||
num_points = 24,
|
|
||||||
)
|
|
||||||
grid.draw_cylinder(
|
|
||||||
epsilon,
|
|
||||||
h = dict(axis='z', center=center[2], span=th * 1.1),
|
|
||||||
radius = min(radii),
|
|
||||||
center2d = center[:2],
|
|
||||||
foreground = n_air ** 2,
|
|
||||||
num_points = 24,
|
|
||||||
)
|
|
||||||
|
|
||||||
dxes = [grid.dxyz, grid.autoshifted_dxyz()]
|
|
||||||
for a in (0, 1, 2):
|
|
||||||
for p in (-1, 1):
|
|
||||||
dxes = meanas.fdfd.scpml.stretch_with_scpml(dxes, axis=a, polarity=p, omega=omega,
|
|
||||||
thickness=pml_thickness)
|
|
||||||
|
|
||||||
J = [numpy.zeros_like(epsilon[0], dtype=complex) for _ in range(3)]
|
|
||||||
J[1][15, grid.shape[1]//2, grid.shape[2]//2] = 1
|
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# Solve!
|
|
||||||
#
|
|
||||||
sim_args = dict(
|
|
||||||
omega = omega,
|
|
||||||
dxes = dxes,
|
|
||||||
epsilon = vec(epsilon),
|
|
||||||
)
|
|
||||||
x = solver(J=vec(J), **sim_args)
|
|
||||||
|
|
||||||
A = operators.e_full(omega, dxes, vec(epsilon)).tocsr()
|
|
||||||
b = -1j * omega * vec(J)
|
|
||||||
print('Norm of the residual is ', norm(A @ x - b) / norm(b))
|
|
||||||
|
|
||||||
E = unvec(x, grid.shape)
|
|
||||||
|
|
||||||
#
|
|
||||||
# Plot results
|
|
||||||
#
|
|
||||||
grid.visualize_slice(E.real, plane=dict(z=0), which_shifts=1, pcolormesh_args=dict(norm=colors.CenteredNorm(), cmap='bwr'))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
test0()
|
|
||||||
187
examples/fdtd.py
187
examples/fdtd.py
|
|
@ -1,30 +1,18 @@
|
||||||
"""
|
"""
|
||||||
Example code for a broadband FDTD run with phasor extraction.
|
Example code for running an OpenCL FDTD simulation
|
||||||
|
|
||||||
This script shows the intended low-level workflow for:
|
See main() for simulation setup.
|
||||||
|
|
||||||
1. building a Yee-grid simulation with CPML on all faces,
|
|
||||||
2. driving it with an electric-current pulse,
|
|
||||||
3. extracting a single-frequency phasor on the fly, and
|
|
||||||
4. checking that phasor against the matching stretched-grid FDFD operator.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import copy
|
|
||||||
|
|
||||||
import numpy
|
import numpy
|
||||||
import h5py
|
import h5py
|
||||||
from numpy.linalg import norm
|
|
||||||
|
|
||||||
from meanas import fdtd
|
from meanas import fdtd
|
||||||
from meanas.fdtd import cpml_params, updates_with_cpml
|
from meanas.fdtd import cpml_params, updates_with_cpml
|
||||||
from meanas.fdtd.misc import gaussian_packet
|
from masque import Pattern, shapes
|
||||||
|
|
||||||
from meanas.fdfd.operators import e_full
|
|
||||||
from meanas.fdfd.scpml import stretch_with_scpml
|
|
||||||
from meanas.fdmath import vec
|
|
||||||
from masque import Pattern, Circle, Polygon
|
|
||||||
import gridlock
|
import gridlock
|
||||||
import pcgen
|
import pcgen
|
||||||
|
|
||||||
|
|
@ -53,50 +41,50 @@ def perturbed_l3(a: float, radius: float, **kwargs) -> Pattern:
|
||||||
`masque.Pattern` object containing the L3 design
|
`masque.Pattern` object containing the L3 design
|
||||||
"""
|
"""
|
||||||
|
|
||||||
default_args = {
|
default_args = {'hole_dose': 1,
|
||||||
'hole_layer': 0,
|
'trench_dose': 1,
|
||||||
'trench_layer': 1,
|
'hole_layer': 0,
|
||||||
'shifts_a': (0.15, 0, 0.075),
|
'trench_layer': 1,
|
||||||
'shifts_r': (1.0, 1.0, 1.0),
|
'shifts_a': (0.15, 0, 0.075),
|
||||||
'xy_size': (10, 10),
|
'shifts_r': (1.0, 1.0, 1.0),
|
||||||
'perturbed_radius': 1.1,
|
'xy_size': (10, 10),
|
||||||
'trench_width': 1.2e3,
|
'perturbed_radius': 1.1,
|
||||||
}
|
'trench_width': 1.2e3,
|
||||||
|
}
|
||||||
kwargs = {**default_args, **kwargs}
|
kwargs = {**default_args, **kwargs}
|
||||||
|
|
||||||
xyr = pcgen.l3_shift_perturbed_defect(
|
xyr = pcgen.l3_shift_perturbed_defect(mirror_dims=kwargs['xy_size'],
|
||||||
mirror_dims=kwargs['xy_size'],
|
perturbed_radius=kwargs['perturbed_radius'],
|
||||||
perturbed_radius=kwargs['perturbed_radius'],
|
shifts_a=kwargs['shifts_a'],
|
||||||
shifts_a=kwargs['shifts_a'],
|
shifts_r=kwargs['shifts_r'])
|
||||||
shifts_r=kwargs['shifts_r'],
|
|
||||||
)
|
|
||||||
xyr *= a
|
xyr *= a
|
||||||
xyr[:, 2] *= radius
|
xyr[:, 2] *= radius
|
||||||
|
|
||||||
pat = Pattern()
|
pat = Pattern()
|
||||||
#pat.name = f'L3p-a{a:g}r{radius:g}rp{kwargs["perturbed_radius"]:g}'
|
pat.name = f'L3p-a{a:g}r{radius:g}rp{kwargs["perturbed_radius"]:g}'
|
||||||
pat.shapes[(kwargs['hole_layer'], 0)] += [
|
pat.shapes += [shapes.Circle(radius=r, offset=(x, y),
|
||||||
Circle(radius=r, offset=(x, y))
|
dose=kwargs['hole_dose'],
|
||||||
for x, y, r in xyr]
|
layer=kwargs['hole_layer'])
|
||||||
|
for x, y, r in xyr]
|
||||||
|
|
||||||
maxes = numpy.max(numpy.fabs(xyr), axis=0)
|
maxes = numpy.max(numpy.fabs(xyr), axis=0)
|
||||||
pat.shapes[(kwargs['trench_layer'], 0)] += [
|
pat.shapes += [shapes.Polygon.rectangle(
|
||||||
Polygon.rectangle(
|
lx=(2 * maxes[0]), ly=kwargs['trench_width'],
|
||||||
lx=(2 * maxes[0]), ly=kwargs['trench_width'],
|
offset=(0, s * (maxes[1] + a + kwargs['trench_width'] / 2)),
|
||||||
offset=(0, s * (maxes[1] + a + kwargs['trench_width'] / 2))
|
dose=kwargs['trench_dose'], layer=kwargs['trench_layer'])
|
||||||
)
|
for s in (-1, 1)]
|
||||||
for s in (-1, 1)]
|
|
||||||
return pat
|
return pat
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main():
|
||||||
dtype = numpy.float32
|
dtype = numpy.float32
|
||||||
max_t = 3600 # number of timesteps
|
max_t = 8000 # number of timesteps
|
||||||
|
|
||||||
dx = 40 # discretization (nm/cell)
|
dx = 40 # discretization (nm/cell)
|
||||||
pml_thickness = 8 # (number of cells)
|
pml_thickness = 8 # (number of cells)
|
||||||
|
|
||||||
wl = 1550 # Excitation wavelength and fwhm
|
wl = 1550 # Excitation wavelength and fwhm
|
||||||
|
dwl = 200
|
||||||
|
|
||||||
# Device design parameters
|
# Device design parameters
|
||||||
xy_size = numpy.array([10, 10])
|
xy_size = numpy.array([10, 10])
|
||||||
|
|
@ -119,97 +107,68 @@ def main() -> None:
|
||||||
|
|
||||||
# #### Create the grid, mask, and draw the device ####
|
# #### Create the grid, mask, and draw the device ####
|
||||||
grid = gridlock.Grid(edge_coords)
|
grid = gridlock.Grid(edge_coords)
|
||||||
epsilon = grid.allocate(n_air ** 2, dtype=dtype)
|
epsilon = grid.allocate(n_air**2, dtype=dtype)
|
||||||
grid.draw_slab(
|
grid.draw_slab(epsilon,
|
||||||
epsilon,
|
surface_normal=2,
|
||||||
slab = dict(axis='z', center=0, span=th),
|
center=[0, 0, 0],
|
||||||
foreground = n_slab ** 2,
|
thickness=th,
|
||||||
)
|
eps=n_slab**2)
|
||||||
|
|
||||||
mask = perturbed_l3(a, r)
|
mask = perturbed_l3(a, r)
|
||||||
grid.draw_polygons(
|
|
||||||
epsilon,
|
|
||||||
slab = dict(axis='z', center=0, span=2 * th),
|
|
||||||
foreground = n_air ** 2,
|
|
||||||
offset2d = (0, 0),
|
|
||||||
polygons = mask.as_polygons(library=None),
|
|
||||||
)
|
|
||||||
|
|
||||||
print(f'{grid.shape=}')
|
grid.draw_polygons(epsilon,
|
||||||
|
surface_normal=2,
|
||||||
|
center=[0, 0, 0],
|
||||||
|
thickness=2 * th,
|
||||||
|
eps=n_air**2,
|
||||||
|
polygons=mask.as_polygons())
|
||||||
|
|
||||||
dt = dx * 0.99 / numpy.sqrt(3)
|
print(grid.shape)
|
||||||
ee = numpy.zeros_like(epsilon, dtype=complex)
|
|
||||||
hh = numpy.zeros_like(epsilon, dtype=complex)
|
dt = .99/numpy.sqrt(3)
|
||||||
|
e = [numpy.zeros_like(epsilon[0], dtype=dtype) for _ in range(3)]
|
||||||
|
h = [numpy.zeros_like(epsilon[0], dtype=dtype) for _ in range(3)]
|
||||||
|
|
||||||
dxes = [grid.dxyz, grid.autoshifted_dxyz()]
|
dxes = [grid.dxyz, grid.autoshifted_dxyz()]
|
||||||
|
|
||||||
# PMLs in every direction
|
# PMLs in every direction
|
||||||
pml_params = [
|
pml_params = [[cpml_params(axis=dd, polarity=pp, dt=dt,
|
||||||
[cpml_params(axis=dd, polarity=pp, dt=dt, dx=dx, thickness=pml_thickness, epsilon_eff=n_air ** 2)
|
thickness=pml_thickness, epsilon_eff=1.0**2)
|
||||||
for pp in (-1, +1)]
|
for pp in (-1, +1)]
|
||||||
for dd in range(3)]
|
for dd in range(3)]
|
||||||
update_E, update_H = updates_with_cpml(cpml_params=pml_params, dt=dt, dxes=dxes, epsilon=epsilon, dtype=complex)
|
update_E, update_H = updates_with_cpml(cpml_params=pml_params, dt=dt,
|
||||||
|
dxes=dxes, epsilon=epsilon)
|
||||||
|
|
||||||
# sample_interval = numpy.floor(1 / (2 * 1 / wl * dt)).astype(int)
|
# Source parameters and function
|
||||||
# print(f'Save time interval would be {sample_interval} * dt = {sample_interval * dt:3g}')
|
w = 2 * numpy.pi * dx / wl
|
||||||
|
fwhm = dwl * w * w / (2 * numpy.pi * dx)
|
||||||
|
alpha = (fwhm ** 2) / 8 * numpy.log(2)
|
||||||
|
delay = 7/numpy.sqrt(2 * alpha)
|
||||||
|
|
||||||
|
def field_source(i):
|
||||||
# Build the pulse directly at the current half-steps and normalize that
|
t0 = i * dt - delay
|
||||||
# scalar waveform so its extracted temporal phasor is exactly 1 at omega.
|
return numpy.sin(w * t0) * numpy.exp(-alpha * t0**2)
|
||||||
source_phasor, _delay = gaussian_packet(wl=wl, dwl=100, dt=dt, turn_on=1e-5)
|
|
||||||
aa, cc, ss = source_phasor(numpy.arange(max_t) + 0.5)
|
|
||||||
source_waveform = aa * (cc + 1j * ss)
|
|
||||||
omega = 2 * numpy.pi / wl
|
|
||||||
pulse_scale = fdtd.temporal_phasor_scale(source_waveform, omega, dt, offset_steps=0.5)[0]
|
|
||||||
|
|
||||||
j_source = numpy.zeros_like(epsilon, dtype=complex)
|
|
||||||
j_source[1, *(grid.shape // 2)] = epsilon[1, *(grid.shape // 2)]
|
|
||||||
jph = numpy.zeros((1, *epsilon.shape), dtype=complex)
|
|
||||||
eph = numpy.zeros((1, *epsilon.shape), dtype=complex)
|
|
||||||
hph = numpy.zeros((1, *epsilon.shape), dtype=complex)
|
|
||||||
|
|
||||||
# #### Run a bunch of iterations ####
|
# #### Run a bunch of iterations ####
|
||||||
output_file = h5py.File('simulation_output.h5', 'w')
|
output_file = h5py.File('simulation_output.h5', 'w')
|
||||||
start = time.perf_counter()
|
start = time.perf_counter()
|
||||||
for tt in range(max_t):
|
for t in range(max_t):
|
||||||
update_E(ee, hh, epsilon)
|
update_E(e, h, epsilon)
|
||||||
|
|
||||||
# Electric-current injection uses E -= dt * J / epsilon, which is the
|
e[1][tuple(grid.shape//2)] += field_source(t)
|
||||||
# same sign convention used by the matching FDFD right-hand side.
|
update_H(e, h)
|
||||||
j_step = pulse_scale * source_waveform[tt] * j_source
|
|
||||||
ee -= dt * j_step / epsilon
|
|
||||||
update_H(ee, hh)
|
|
||||||
|
|
||||||
avg_rate = (tt + 1) / (time.perf_counter() - start)
|
print('iteration {}: average {} iterations per sec'.format(t, (t+1)/(time.perf_counter()-start)))
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
|
|
||||||
if tt % 200 == 0:
|
if t % 20 == 0:
|
||||||
print(f'iteration {tt}: average {avg_rate} iterations per sec')
|
r = sum([(f * f * e).sum() for f, e in zip(e, epsilon)])
|
||||||
E_energy_sum = (ee.conj() * ee * epsilon).sum().real
|
print('E sum', r)
|
||||||
print(f'{E_energy_sum=}')
|
|
||||||
|
|
||||||
# Save field slices
|
# Save field slices
|
||||||
if (tt % 20 == 0 and (max_t - tt <= 1000 or tt <= 2000)) or tt == max_t - 1:
|
if (t % 20 == 0 and (max_t - t <= 1000 or t <= 2000)) or t == max_t-1:
|
||||||
print(f'saving E-field at iteration {tt}')
|
print('saving E-field')
|
||||||
output_file[f'/E_t{tt}'] = ee[:, :, :, ee.shape[3] // 2]
|
for j, f in enumerate(e):
|
||||||
|
output_file['/E{}_t{}'.format('xyz'[j], t)] = f[:, :, round(f.shape[2]/2)]
|
||||||
fdtd.accumulate_phasor_j(jph, omega, dt, j_step, tt)
|
|
||||||
fdtd.accumulate_phasor_e(eph, omega, dt, ee, tt + 1)
|
|
||||||
fdtd.accumulate_phasor_h(hph, omega, dt, hh, tt + 1)
|
|
||||||
|
|
||||||
Eph = eph[0]
|
|
||||||
Jph = jph[0]
|
|
||||||
b = -1j * omega * Jph
|
|
||||||
dxes_fdfd = copy.deepcopy(dxes)
|
|
||||||
for pp in (-1, +1):
|
|
||||||
for dd in range(3):
|
|
||||||
dxes_fdfd = stretch_with_scpml(dxes_fdfd, axis=dd, polarity=pp, omega=omega, epsilon_effective=n_air ** 2, thickness=pml_thickness)
|
|
||||||
# Compare the extracted phasor to the FDFD operator on the stretched grid,
|
|
||||||
# not the unstretched Yee spacings used by the raw time-domain update.
|
|
||||||
A = e_full(omega=omega, dxes=dxes_fdfd, epsilon=epsilon)
|
|
||||||
residual = norm(A @ vec(Eph) - vec(b)) / norm(vec(b))
|
|
||||||
print(f'FDFD residual is {residual}')
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
main()
|
main()
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import numpy
|
||||||
from numpy.linalg import norm
|
from numpy.linalg import norm
|
||||||
|
|
||||||
from meanas.fdmath import vec, unvec
|
from meanas.fdmath import vec, unvec
|
||||||
from meanas.fdfd import waveguide_cyl, functional, scpml
|
from meanas.fdfd import waveguide_mode, functional, scpml
|
||||||
from meanas.fdfd.solvers import generic as generic_solver
|
from meanas.fdfd.solvers import generic as generic_solver
|
||||||
|
|
||||||
import gridlock
|
import gridlock
|
||||||
|
|
@ -37,34 +37,29 @@ def test1(solver=generic_solver):
|
||||||
xyz_max = numpy.array([800, y_max, z_max]) + (pml_thickness + 2) * dx
|
xyz_max = numpy.array([800, y_max, z_max]) + (pml_thickness + 2) * dx
|
||||||
|
|
||||||
# Coordinates of the edges of the cells.
|
# Coordinates of the edges of the cells.
|
||||||
half_edge_coords = [numpy.arange(dx / 2, m + dx / 2, step=dx) for m in xyz_max]
|
half_edge_coords = [numpy.arange(dx/2, m + dx/2, step=dx) for m in xyz_max]
|
||||||
edge_coords = [numpy.hstack((-h[::-1], h)) for h in half_edge_coords]
|
edge_coords = [numpy.hstack((-h[::-1], h)) for h in half_edge_coords]
|
||||||
edge_coords[0] = numpy.array([-dx, dx])
|
edge_coords[0] = numpy.array([-dx, dx])
|
||||||
|
|
||||||
# #### Create the grid and draw the device ####
|
# #### Create the grid and draw the device ####
|
||||||
grid = gridlock.Grid(edge_coords)
|
grid = gridlock.Grid(edge_coords)
|
||||||
epsilon = grid.allocate(n_air**2, dtype=numpy.float32)
|
epsilon = grid.allocate(n_air**2, dtype=numpy.float32)
|
||||||
grid.draw_cuboid(epsilon, center=center, dimensions=[8e3, w, th], foreground=n_wg**2)
|
grid.draw_cuboid(epsilon, center=center, dimensions=[8e3, w, th], eps=n_wg**2)
|
||||||
|
|
||||||
dxes = [grid.dxyz, grid.autoshifted_dxyz()]
|
dxes = [grid.dxyz, grid.autoshifted_dxyz()]
|
||||||
for a in (1, 2):
|
for a in (1, 2):
|
||||||
for p in (-1, 1):
|
for p in (-1, 1):
|
||||||
dxes = scpml.stretch_with_scpml(
|
dxes = scmpl.stretch_with_scpml(dxes, omega=omega, axis=a, polarity=p,
|
||||||
dxes,
|
thickness=pml_thickness)
|
||||||
omega=omega,
|
|
||||||
axis=a,
|
|
||||||
polarity=p,
|
|
||||||
thickness=pml_thickness,
|
|
||||||
)
|
|
||||||
|
|
||||||
wg_args = {
|
wg_args = {
|
||||||
'omega': omega,
|
'omega': omega,
|
||||||
'dxes': [(d[1], d[2]) for d in dxes],
|
'dxes': [(d[1], d[2]) for d in dxes],
|
||||||
'epsilon': vec(epsilon.transpose([0, 2, 3, 1])),
|
'epsilon': vec(g.transpose([1, 2, 0]) for g in epsilon),
|
||||||
'r0': r0,
|
'r0': r0,
|
||||||
}
|
}
|
||||||
|
|
||||||
wg_results = waveguide_cyl.solve_mode(mode_number=0, **wg_args)
|
wg_results = waveguide_mode.solve_waveguide_mode_cylindrical(mode_number=0, **wg_args)
|
||||||
|
|
||||||
E = wg_results['E']
|
E = wg_results['E']
|
||||||
|
|
||||||
|
|
@ -75,17 +70,20 @@ def test1(solver=generic_solver):
|
||||||
'''
|
'''
|
||||||
Plot results
|
Plot results
|
||||||
'''
|
'''
|
||||||
def pcolor(fig, ax, v, title):
|
def pcolor(v):
|
||||||
vmax = numpy.max(numpy.abs(v))
|
vmax = numpy.max(numpy.abs(v))
|
||||||
mappable = ax.pcolormesh(v.T, cmap='seismic', vmin=-vmax, vmax=vmax)
|
pyplot.pcolor(v.T, cmap='seismic', vmin=-vmax, vmax=vmax)
|
||||||
ax.set_aspect('equal', adjustable='box')
|
pyplot.axis('equal')
|
||||||
ax.set_title(title)
|
pyplot.colorbar()
|
||||||
ax.figure.colorbar(mappable)
|
|
||||||
|
|
||||||
fig, axes = pyplot.subplots(2, 2)
|
pyplot.figure()
|
||||||
pcolor(fig, axes[0][0], numpy.real(E[0]), 'Ex')
|
pyplot.subplot(2, 2, 1)
|
||||||
pcolor(fig, axes[0][1], numpy.real(E[1]), 'Ey')
|
pcolor(numpy.real(E[0][:, :]))
|
||||||
pcolor(fig, axes[1][0], numpy.real(E[2]), 'Ez')
|
pyplot.subplot(2, 2, 2)
|
||||||
|
pcolor(numpy.real(E[1][:, :]))
|
||||||
|
pyplot.subplot(2, 2, 3)
|
||||||
|
pcolor(numpy.real(E[2][:, :]))
|
||||||
|
pyplot.subplot(2, 2, 4)
|
||||||
pyplot.show()
|
pyplot.show()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,342 +0,0 @@
|
||||||
"""
|
|
||||||
Example code for guided-wave FDFD and FDTD comparison.
|
|
||||||
|
|
||||||
This example is the reference workflow for:
|
|
||||||
|
|
||||||
1. solving a waveguide port mode,
|
|
||||||
2. turning that mode into a one-sided source and overlap window,
|
|
||||||
3. comparing a direct FDFD solve against a time-domain phasor extracted from FDTD.
|
|
||||||
"""
|
|
||||||
from typing import Callable
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
import copy
|
|
||||||
|
|
||||||
import numpy
|
|
||||||
import h5py
|
|
||||||
from numpy.linalg import norm
|
|
||||||
|
|
||||||
import gridlock
|
|
||||||
import meanas
|
|
||||||
from meanas import fdtd, fdfd
|
|
||||||
from meanas.fdtd import cpml_params, updates_with_cpml
|
|
||||||
from meanas.fdtd.misc import gaussian_packet
|
|
||||||
|
|
||||||
from meanas.fdmath import vec, unvec, vcfdfield_t, cfdfield_t, fdfield_t, dx_lists_t
|
|
||||||
from meanas.fdfd import waveguide_3d, functional, scpml, operators
|
|
||||||
from meanas.fdfd.solvers import generic as generic_solver
|
|
||||||
from meanas.fdfd.operators import e_full
|
|
||||||
from meanas.fdfd.scpml import stretch_with_scpml
|
|
||||||
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.DEBUG)
|
|
||||||
for pp in ('matplotlib', 'PIL'):
|
|
||||||
logging.getLogger(pp).setLevel(logging.WARNING)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def pcolor(vv, fig=None, ax=None) -> None:
|
|
||||||
if fig is None:
|
|
||||||
assert ax is None
|
|
||||||
fig, ax = pyplot.subplots()
|
|
||||||
mb = ax.pcolor(vv, cmap='seismic', norm=colors.CenteredNorm())
|
|
||||||
fig.colorbar(mb)
|
|
||||||
ax.set_aspect('equal')
|
|
||||||
|
|
||||||
|
|
||||||
def draw_grid(
|
|
||||||
*,
|
|
||||||
dx: float,
|
|
||||||
pml_thickness: int,
|
|
||||||
n_wg: float = 3.476, # Si index @ 1550
|
|
||||||
n_cladding: float = 1.00, # Air index
|
|
||||||
wg_w: float = 400,
|
|
||||||
wg_th: float = 200,
|
|
||||||
) -> tuple[gridlock.Grid, fdfield_t]:
|
|
||||||
""" Create the grid and draw the device """
|
|
||||||
# Half-dimensions of the simulation grid
|
|
||||||
xyz_max = numpy.array([800, 900, 600]) + (pml_thickness + 2) * dx
|
|
||||||
|
|
||||||
# Coordinates of the edges of the cells.
|
|
||||||
half_edge_coords = [numpy.arange(dx / 2, m + dx / 2, step=dx) for m in xyz_max]
|
|
||||||
edge_coords = [numpy.hstack((-h[::-1], h)) for h in half_edge_coords]
|
|
||||||
|
|
||||||
grid = gridlock.Grid(edge_coords)
|
|
||||||
epsilon = grid.allocate(n_cladding**2, dtype=numpy.float32)
|
|
||||||
grid.draw_cuboid(
|
|
||||||
epsilon,
|
|
||||||
x = dict(center=0, span=8e3),
|
|
||||||
y = dict(center=0, span=wg_w),
|
|
||||||
z = dict(center=0, span=wg_th),
|
|
||||||
foreground = n_wg ** 2,
|
|
||||||
)
|
|
||||||
|
|
||||||
return grid, epsilon
|
|
||||||
|
|
||||||
|
|
||||||
def get_waveguide_mode(
|
|
||||||
*,
|
|
||||||
grid: gridlock.Grid,
|
|
||||||
dxes: dx_lists_t,
|
|
||||||
omega: float,
|
|
||||||
epsilon: fdfield_t,
|
|
||||||
) -> tuple[vcfdfield_t, vcfdfield_t]:
|
|
||||||
"""Create a mode source and overlap window for one forward-going port."""
|
|
||||||
dims = numpy.array([[-10, -20, -15],
|
|
||||||
[-10, 20, 15]]) * [[numpy.median(numpy.real(dx)) for dx in dxes[0]]]
|
|
||||||
ind_dims = (grid.pos2ind(dims[0], which_shifts=None).astype(int),
|
|
||||||
grid.pos2ind(dims[1], which_shifts=None).astype(int))
|
|
||||||
wg_args = dict(
|
|
||||||
slices = [slice(i, f+1) for i, f in zip(*ind_dims)],
|
|
||||||
dxes = dxes,
|
|
||||||
axis = 0,
|
|
||||||
polarity = +1,
|
|
||||||
)
|
|
||||||
|
|
||||||
wg_results = waveguide_3d.solve_mode(mode_number=0, omega=omega, epsilon=epsilon, **wg_args)
|
|
||||||
J = waveguide_3d.compute_source(E=wg_results['E'], wavenumber=wg_results['wavenumber'],
|
|
||||||
omega=omega, epsilon=epsilon, **wg_args)
|
|
||||||
|
|
||||||
# compute_overlap_e() returns the normalized upstream overlap window used to
|
|
||||||
# project another field onto this same guided mode.
|
|
||||||
e_overlap = waveguide_3d.compute_overlap_e(E=wg_results['E'], wavenumber=wg_results['wavenumber'], **wg_args)
|
|
||||||
return J, e_overlap
|
|
||||||
|
|
||||||
|
|
||||||
def main(
|
|
||||||
*,
|
|
||||||
solver: Callable = generic_solver,
|
|
||||||
dx: float = 40, # discretization (nm / cell)
|
|
||||||
pml_thickness: int = 10, # (number of cells)
|
|
||||||
wl: float = 1550, # Excitation wavelength
|
|
||||||
wg_w: float = 600, # Waveguide width
|
|
||||||
wg_th: float = 220, # Waveguide thickness
|
|
||||||
):
|
|
||||||
omega = 2 * numpy.pi / wl
|
|
||||||
|
|
||||||
grid, epsilon = draw_grid(dx=dx, pml_thickness=pml_thickness)
|
|
||||||
|
|
||||||
# Add SCPML stretching to the FDFD grid; this matches the CPML-backed FDTD
|
|
||||||
# run below so the two solvers see the same absorbing boundary profile.
|
|
||||||
dxes = [grid.dxyz, grid.autoshifted_dxyz()]
|
|
||||||
for a in (0, 1, 2):
|
|
||||||
for p in (-1, 1):
|
|
||||||
dxes = scpml.stretch_with_scpml(dxes, omega=omega, axis=a, polarity=p, thickness=pml_thickness)
|
|
||||||
|
|
||||||
|
|
||||||
J, e_overlap = get_waveguide_mode(grid=grid, dxes=dxes, omega=omega, epsilon=epsilon)
|
|
||||||
|
|
||||||
|
|
||||||
pecg = numpy.zeros_like(epsilon)
|
|
||||||
# pecg.draw_cuboid(pecg, center=[700, 0, 0], dimensions=[80, 1e8, 1e8], eps=1)
|
|
||||||
# pecg.visualize_isosurface(pecg)
|
|
||||||
|
|
||||||
pmcg = numpy.zeros_like(epsilon)
|
|
||||||
# grid.draw_cuboid(pmcg, center=[700, 0, 0], dimensions=[80, 1e8, 1e8], eps=1)
|
|
||||||
# grid.visualize_isosurface(pmcg)
|
|
||||||
|
|
||||||
|
|
||||||
# ss = (1, slice(None), J.shape[2]//2+6, slice(None))
|
|
||||||
# pcolor(J3[ss].T.imag)
|
|
||||||
# pcolor((numpy.abs(J3).sum(axis=(0, 2)) > 0).astype(float).T)
|
|
||||||
# pyplot.show(block=True)
|
|
||||||
|
|
||||||
E_fdfd = fdfd_solve(
|
|
||||||
omega = omega,
|
|
||||||
dxes = dxes,
|
|
||||||
epsilon = epsilon,
|
|
||||||
J = J,
|
|
||||||
pec = pecg,
|
|
||||||
pmc = pmcg,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# Plot results
|
|
||||||
#
|
|
||||||
center = grid.pos2ind([0, 0, 0], None).astype(int)
|
|
||||||
fig, axes = pyplot.subplots(2, 2)
|
|
||||||
pcolor(numpy.real(E[1][center[0], :, :]).T, fig=fig, ax=axes[0, 0])
|
|
||||||
axes[0, 1].plot(numpy.log10(numpy.abs(E[1][:, center[1], center[2]]) + 1e-10))
|
|
||||||
axes[0, 1].grid(alpha=0.6)
|
|
||||||
axes[0, 1].set_ylabel('log10 of field')
|
|
||||||
pcolor(numpy.real(E[1][:, :, center[2]]).T, fig=fig, ax=axes[1, 0])
|
|
||||||
|
|
||||||
def poyntings(E):
|
|
||||||
H = functional.e2h(omega, dxes)(E)
|
|
||||||
poynting = fdtd.poynting(e=E, h=H.conj(), dxes=dxes)
|
|
||||||
cross1 = operators.poynting_e_cross(vec(E), dxes) @ vec(H).conj()
|
|
||||||
cross2 = operators.poynting_h_cross(vec(H), dxes) @ vec(E).conj() * -1
|
|
||||||
s1 = 0.5 * unvec(numpy.real(cross1), grid.shape)
|
|
||||||
s2 = 0.5 * unvec(numpy.real(cross2), grid.shape)
|
|
||||||
s0 = 0.5 * poynting.real
|
|
||||||
# s2 = poynting.imag
|
|
||||||
return s0, s1, s2
|
|
||||||
|
|
||||||
s0x, s1x, s2x = poyntings(E)
|
|
||||||
axes[1, 1].plot(s0x[0].sum(axis=2).sum(axis=1), label='s0', marker='.')
|
|
||||||
axes[1, 1].plot(s1x[0].sum(axis=2).sum(axis=1), label='s1', marker='.')
|
|
||||||
axes[1, 1].plot(s2x[0].sum(axis=2).sum(axis=1), label='s2', marker='.')
|
|
||||||
axes[1, 1].plot(E[1][:, center[1], center[2]].real.T, label='Ey', marker='x')
|
|
||||||
axes[1, 1].grid(alpha=0.6)
|
|
||||||
axes[1, 1].legend()
|
|
||||||
|
|
||||||
q = []
|
|
||||||
for i in range(-5, 30):
|
|
||||||
e_ovl_rolled = numpy.roll(e_overlap, i, axis=1)
|
|
||||||
q += [numpy.abs(vec(E) @ vec(e_ovl_rolled).conj())]
|
|
||||||
fig, ax = pyplot.subplots()
|
|
||||||
ax.plot(q, marker='.')
|
|
||||||
ax.grid(alpha=0.6)
|
|
||||||
ax.set_title('Overlap with mode')
|
|
||||||
|
|
||||||
logger.info('Average overlap with mode:', sum(q[8:32]) / len(q[8:32]))
|
|
||||||
|
|
||||||
pyplot.show(block=True)
|
|
||||||
|
|
||||||
|
|
||||||
def fdfd_solve(
|
|
||||||
*,
|
|
||||||
omega: float,
|
|
||||||
dxes = dx_lists_t,
|
|
||||||
epsilon: fdfield_t,
|
|
||||||
J: cfdfield_t,
|
|
||||||
pec: fdfield_t,
|
|
||||||
pmc: fdfield_t,
|
|
||||||
) -> cfdfield_t:
|
|
||||||
""" Construct and run the solve """
|
|
||||||
sim_args = dict(
|
|
||||||
omega = omega,
|
|
||||||
dxes = dxes,
|
|
||||||
epsilon = vec(epsilon),
|
|
||||||
pec = vec(pecg),
|
|
||||||
pmc = vec(pmcg),
|
|
||||||
)
|
|
||||||
|
|
||||||
x = solver(J=vec(J), **sim_args)
|
|
||||||
|
|
||||||
b = -1j * omega * vec(J)
|
|
||||||
A = operators.e_full(**sim_args).tocsr()
|
|
||||||
logger.info('Norm of the residual is ', norm(A @ x - b) / norm(b))
|
|
||||||
|
|
||||||
E = unvec(x, epsilon.shape[1:])
|
|
||||||
return E
|
|
||||||
|
|
||||||
|
|
||||||
def main2():
|
|
||||||
dtype = numpy.float32
|
|
||||||
max_t = 3600 # number of timesteps
|
|
||||||
|
|
||||||
dx = 40 # discretization (nm/cell)
|
|
||||||
pml_thickness = 8 # (number of cells)
|
|
||||||
|
|
||||||
wl = 1550 # Excitation wavelength and fwhm
|
|
||||||
dwl = 100
|
|
||||||
|
|
||||||
# Device design parameters
|
|
||||||
xy_size = numpy.array([10, 10])
|
|
||||||
a = 430
|
|
||||||
r = 0.285
|
|
||||||
th = 170
|
|
||||||
|
|
||||||
# refractive indices
|
|
||||||
n_slab = 3.408 # InGaAsP(80, 50) @ 1550nm
|
|
||||||
n_cladding = 1.0 # air
|
|
||||||
|
|
||||||
# Half-dimensions of the simulation grid
|
|
||||||
xy_max = (xy_size + 1) * a * [1, numpy.sqrt(3)/2]
|
|
||||||
z_max = 1.6 * a
|
|
||||||
xyz_max = numpy.hstack((xy_max, z_max)) + pml_thickness * dx
|
|
||||||
|
|
||||||
# Coordinates of the edges of the cells. The fdtd package can only do square grids at the moment.
|
|
||||||
half_edge_coords = [numpy.arange(dx/2, m + dx, step=dx) for m in xyz_max]
|
|
||||||
edge_coords = [numpy.hstack((-h[::-1], h)) for h in half_edge_coords]
|
|
||||||
|
|
||||||
# #### Create the grid, mask, and draw the device ####
|
|
||||||
grid = gridlock.Grid(edge_coords)
|
|
||||||
epsilon = grid.allocate(n_cladding ** 2, dtype=dtype)
|
|
||||||
grid.draw_slab(
|
|
||||||
epsilon,
|
|
||||||
slab = dict(axis='z', center=0, span=th),
|
|
||||||
foreground = n_slab ** 2,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
print(f'{grid.shape=}')
|
|
||||||
|
|
||||||
dt = dx * 0.99 / numpy.sqrt(3)
|
|
||||||
ee = numpy.zeros_like(epsilon, dtype=complex)
|
|
||||||
hh = numpy.zeros_like(epsilon, dtype=complex)
|
|
||||||
|
|
||||||
dxes = [grid.dxyz, grid.autoshifted_dxyz()]
|
|
||||||
|
|
||||||
# PMLs in every direction
|
|
||||||
pml_params = [
|
|
||||||
[cpml_params(axis=dd, polarity=pp, dt=dt, dx=dx, 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):
|
|
||||||
dxes_fdfd = 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()
|
|
||||||
|
|
@ -1,219 +0,0 @@
|
||||||
"""
|
|
||||||
Real-valued straight-waveguide FDTD/FDFD comparison.
|
|
||||||
|
|
||||||
This example shows the user-facing "compare real FDTD against reconstructed real
|
|
||||||
FDFD" workflow:
|
|
||||||
|
|
||||||
1. build a straight waveguide on a uniform Yee grid,
|
|
||||||
2. drive it with a real-valued continuous-wave mode source,
|
|
||||||
3. solve the matching FDFD problem from the analytic source phasor, and
|
|
||||||
4. compare late real monitor slices against `fdtd.reconstruct_real_e/h(...)`.
|
|
||||||
|
|
||||||
Unlike the phasor-based examples, this script does not use extracted phasors as
|
|
||||||
the main output. It is a stricter diagnostic: the comparison target is the raw
|
|
||||||
real field itself, with full-plane, mode-weighted, guided-mode, and orthogonal-
|
|
||||||
residual errors reported. Strong phasor agreement can coexist with visibly
|
|
||||||
larger raw-snapshot error because the latter includes weak nonguided tails on
|
|
||||||
the monitor plane.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import numpy
|
|
||||||
|
|
||||||
from meanas import fdfd, fdtd
|
|
||||||
from meanas.fdfd import functional, scpml, waveguide_3d
|
|
||||||
from meanas.fdmath import vec, unvec
|
|
||||||
|
|
||||||
|
|
||||||
DT = 0.25
|
|
||||||
PERIOD_STEPS = 64
|
|
||||||
OMEGA = 2 * numpy.pi / (PERIOD_STEPS * DT)
|
|
||||||
CPML_THICKNESS = 3
|
|
||||||
SHAPE = (3, 37, 13, 13)
|
|
||||||
SOURCE_SLICES = (slice(5, 6), slice(None), slice(None))
|
|
||||||
MONITOR_SLICES = (slice(30, 31), slice(None), slice(None))
|
|
||||||
WARMUP_PERIODS = 16
|
|
||||||
SOURCE_PHASE = 0.4
|
|
||||||
CORE_SLICES = (slice(None), slice(None), slice(4, 9), slice(4, 9))
|
|
||||||
|
|
||||||
|
|
||||||
def build_uniform_dxes(shape: tuple[int, int, int, int]) -> list[list[numpy.ndarray]]:
|
|
||||||
return [[numpy.ones(shape[axis + 1]) for axis in range(3)] for _ in range(2)]
|
|
||||||
|
|
||||||
|
|
||||||
def build_epsilon(shape: tuple[int, int, int, int]) -> numpy.ndarray:
|
|
||||||
epsilon = numpy.ones(shape, dtype=float)
|
|
||||||
y0 = (shape[2] - 3) // 2
|
|
||||||
z0 = (shape[3] - 3) // 2
|
|
||||||
epsilon[:, :, y0:y0 + 3, z0:z0 + 3] = 12.0
|
|
||||||
return epsilon
|
|
||||||
|
|
||||||
|
|
||||||
def build_stretched_dxes(base_dxes: list[list[numpy.ndarray]]) -> list[list[numpy.ndarray]]:
|
|
||||||
stretched_dxes = [[dx.copy() for dx in group] for group in base_dxes]
|
|
||||||
for axis in (0, 1, 2):
|
|
||||||
for polarity in (-1, 1):
|
|
||||||
stretched_dxes = scpml.stretch_with_scpml(
|
|
||||||
stretched_dxes,
|
|
||||||
axis=axis,
|
|
||||||
polarity=polarity,
|
|
||||||
omega=OMEGA,
|
|
||||||
epsilon_effective=1.0,
|
|
||||||
thickness=CPML_THICKNESS,
|
|
||||||
)
|
|
||||||
return stretched_dxes
|
|
||||||
|
|
||||||
|
|
||||||
def build_cpml_params() -> list[list[dict[str, numpy.ndarray | float]]]:
|
|
||||||
return [
|
|
||||||
[fdtd.cpml_params(axis=axis, polarity=polarity, dt=DT, thickness=CPML_THICKNESS, epsilon_eff=1.0)
|
|
||||||
for polarity in (-1, 1)]
|
|
||||||
for axis in range(3)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def weighted_rel_err(observed: numpy.ndarray, reference: numpy.ndarray, weight: numpy.ndarray) -> float:
|
|
||||||
return numpy.linalg.norm((observed - reference) * weight) / numpy.linalg.norm(reference * weight)
|
|
||||||
|
|
||||||
|
|
||||||
def project_onto_mode(observed: numpy.ndarray, mode: numpy.ndarray) -> tuple[complex, numpy.ndarray, numpy.ndarray]:
|
|
||||||
coefficient = numpy.vdot(mode, observed) / numpy.vdot(mode, mode)
|
|
||||||
guided = coefficient * mode
|
|
||||||
residual = observed - guided
|
|
||||||
return coefficient, guided, residual
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
epsilon = build_epsilon(SHAPE)
|
|
||||||
base_dxes = build_uniform_dxes(SHAPE)
|
|
||||||
stretched_dxes = build_stretched_dxes(base_dxes)
|
|
||||||
|
|
||||||
source_mode = waveguide_3d.solve_mode(
|
|
||||||
0,
|
|
||||||
omega=OMEGA,
|
|
||||||
dxes=base_dxes,
|
|
||||||
axis=0,
|
|
||||||
polarity=1,
|
|
||||||
slices=SOURCE_SLICES,
|
|
||||||
epsilon=epsilon,
|
|
||||||
)
|
|
||||||
j_mode = waveguide_3d.compute_source(
|
|
||||||
E=source_mode['E'],
|
|
||||||
wavenumber=source_mode['wavenumber'],
|
|
||||||
omega=OMEGA,
|
|
||||||
dxes=base_dxes,
|
|
||||||
axis=0,
|
|
||||||
polarity=1,
|
|
||||||
slices=SOURCE_SLICES,
|
|
||||||
epsilon=epsilon,
|
|
||||||
)
|
|
||||||
# A small global phase aligns the real-valued source with the late-cycle
|
|
||||||
# raw-snapshot diagnostic. The underlying phasor problem is unchanged.
|
|
||||||
j_mode *= numpy.exp(1j * SOURCE_PHASE)
|
|
||||||
monitor_mode = waveguide_3d.solve_mode(
|
|
||||||
0,
|
|
||||||
omega=OMEGA,
|
|
||||||
dxes=base_dxes,
|
|
||||||
axis=0,
|
|
||||||
polarity=1,
|
|
||||||
slices=MONITOR_SLICES,
|
|
||||||
epsilon=epsilon,
|
|
||||||
)
|
|
||||||
e_weight = numpy.abs(monitor_mode['E'][:, MONITOR_SLICES[0], :, :])
|
|
||||||
h_weight = numpy.abs(monitor_mode['H'][:, MONITOR_SLICES[0], :, :])
|
|
||||||
e_mode = monitor_mode['E'][:, MONITOR_SLICES[0], :, :]
|
|
||||||
h_mode = monitor_mode['H'][:, MONITOR_SLICES[0], :, :]
|
|
||||||
|
|
||||||
e_fdfd = unvec(
|
|
||||||
fdfd.solvers.generic(
|
|
||||||
J=vec(j_mode),
|
|
||||||
omega=OMEGA,
|
|
||||||
dxes=stretched_dxes,
|
|
||||||
epsilon=vec(epsilon),
|
|
||||||
matrix_solver_opts={'atol': 1e-10, 'rtol': 1e-7},
|
|
||||||
),
|
|
||||||
SHAPE[1:],
|
|
||||||
)
|
|
||||||
h_fdfd = functional.e2h(OMEGA, stretched_dxes)(e_fdfd)
|
|
||||||
|
|
||||||
update_e, update_h = fdtd.updates_with_cpml(
|
|
||||||
cpml_params=build_cpml_params(),
|
|
||||||
dt=DT,
|
|
||||||
dxes=base_dxes,
|
|
||||||
epsilon=epsilon,
|
|
||||||
)
|
|
||||||
|
|
||||||
e_field = numpy.zeros_like(epsilon)
|
|
||||||
h_field = numpy.zeros_like(epsilon)
|
|
||||||
total_steps = (WARMUP_PERIODS + 1) * PERIOD_STEPS
|
|
||||||
e_errors: list[float] = []
|
|
||||||
h_errors: list[float] = []
|
|
||||||
e_core_errors: list[float] = []
|
|
||||||
h_core_errors: list[float] = []
|
|
||||||
e_weighted_errors: list[float] = []
|
|
||||||
h_weighted_errors: list[float] = []
|
|
||||||
e_guided_errors: list[float] = []
|
|
||||||
h_guided_errors: list[float] = []
|
|
||||||
e_residual_errors: list[float] = []
|
|
||||||
h_residual_errors: list[float] = []
|
|
||||||
|
|
||||||
for step in range(total_steps):
|
|
||||||
update_e(e_field, h_field, epsilon)
|
|
||||||
|
|
||||||
# Real-valued FDTD uses the real part of the analytic mode source.
|
|
||||||
t_half = (step + 0.5) * DT
|
|
||||||
j_real = (j_mode.real * numpy.cos(OMEGA * t_half) - j_mode.imag * numpy.sin(OMEGA * t_half)).real
|
|
||||||
e_field -= DT * j_real / epsilon
|
|
||||||
|
|
||||||
update_h(e_field, h_field)
|
|
||||||
|
|
||||||
if step >= total_steps - PERIOD_STEPS // 4:
|
|
||||||
reconstructed_e = fdtd.reconstruct_real_e(
|
|
||||||
e_fdfd[:, MONITOR_SLICES[0], :, :],
|
|
||||||
OMEGA,
|
|
||||||
DT,
|
|
||||||
step + 1,
|
|
||||||
)
|
|
||||||
reconstructed_h = fdtd.reconstruct_real_h(
|
|
||||||
h_fdfd[:, MONITOR_SLICES[0], :, :],
|
|
||||||
OMEGA,
|
|
||||||
DT,
|
|
||||||
step + 1,
|
|
||||||
)
|
|
||||||
|
|
||||||
e_monitor = e_field[:, MONITOR_SLICES[0], :, :]
|
|
||||||
h_monitor = h_field[:, MONITOR_SLICES[0], :, :]
|
|
||||||
e_errors.append(numpy.linalg.norm(e_monitor - reconstructed_e) / numpy.linalg.norm(reconstructed_e))
|
|
||||||
h_errors.append(numpy.linalg.norm(h_monitor - reconstructed_h) / numpy.linalg.norm(reconstructed_h))
|
|
||||||
e_core_errors.append(
|
|
||||||
numpy.linalg.norm(e_monitor[CORE_SLICES] - reconstructed_e[CORE_SLICES])
|
|
||||||
/ numpy.linalg.norm(reconstructed_e[CORE_SLICES]),
|
|
||||||
)
|
|
||||||
h_core_errors.append(
|
|
||||||
numpy.linalg.norm(h_monitor[CORE_SLICES] - reconstructed_h[CORE_SLICES])
|
|
||||||
/ numpy.linalg.norm(reconstructed_h[CORE_SLICES]),
|
|
||||||
)
|
|
||||||
e_weighted_errors.append(weighted_rel_err(e_monitor, reconstructed_e, e_weight))
|
|
||||||
h_weighted_errors.append(weighted_rel_err(h_monitor, reconstructed_h, h_weight))
|
|
||||||
e_guided_coeff, _, e_residual = project_onto_mode(e_monitor, e_mode)
|
|
||||||
e_guided_coeff_ref, _, e_residual_ref = project_onto_mode(reconstructed_e, e_mode)
|
|
||||||
h_guided_coeff, _, h_residual = project_onto_mode(h_monitor, h_mode)
|
|
||||||
h_guided_coeff_ref, _, h_residual_ref = project_onto_mode(reconstructed_h, h_mode)
|
|
||||||
e_guided_errors.append(abs(e_guided_coeff - e_guided_coeff_ref) / abs(e_guided_coeff_ref))
|
|
||||||
h_guided_errors.append(abs(h_guided_coeff - h_guided_coeff_ref) / abs(h_guided_coeff_ref))
|
|
||||||
e_residual_errors.append(numpy.linalg.norm(e_residual - e_residual_ref) / numpy.linalg.norm(e_residual_ref))
|
|
||||||
h_residual_errors.append(numpy.linalg.norm(h_residual - h_residual_ref) / numpy.linalg.norm(h_residual_ref))
|
|
||||||
|
|
||||||
print(f'late-cycle monitor E errors: min={min(e_errors):.4f} max={max(e_errors):.4f}')
|
|
||||||
print(f'late-cycle monitor H errors: min={min(h_errors):.4f} max={max(h_errors):.4f}')
|
|
||||||
print(f'late-cycle core-window E errors: min={min(e_core_errors):.4f} max={max(e_core_errors):.4f}')
|
|
||||||
print(f'late-cycle core-window H errors: min={min(h_core_errors):.4f} max={max(h_core_errors):.4f}')
|
|
||||||
print(f'late-cycle mode-weighted E errors: min={min(e_weighted_errors):.4f} max={max(e_weighted_errors):.4f}')
|
|
||||||
print(f'late-cycle mode-weighted H errors: min={min(h_weighted_errors):.4f} max={max(h_weighted_errors):.4f}')
|
|
||||||
print(f'late-cycle guided-mode E coefficient errors: min={min(e_guided_errors):.4f} max={max(e_guided_errors):.4f}')
|
|
||||||
print(f'late-cycle guided-mode H coefficient errors: min={min(h_guided_errors):.4f} max={max(h_guided_errors):.4f}')
|
|
||||||
print(f'late-cycle orthogonal-residual E errors: min={min(e_residual_errors):.4f} max={max(e_residual_errors):.4f}')
|
|
||||||
print(f'late-cycle orthogonal-residual H errors: min={min(h_residual_errors):.4f} max={max(h_residual_errors):.4f}')
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
26
make_docs.sh
26
make_docs.sh
|
|
@ -2,20 +2,18 @@
|
||||||
|
|
||||||
set -Eeuo pipefail
|
set -Eeuo pipefail
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
cd ~/projects/meanas
|
||||||
cd "$ROOT"
|
|
||||||
|
|
||||||
DOCS_TMP="$(mktemp -d)"
|
# Approach 1: pdf to html?
|
||||||
cleanup() {
|
#pdoc3 --pdf --force --template-dir pdoc_templates -o doc . | \
|
||||||
rm -rf "$DOCS_TMP"
|
# pandoc --metadata=title:"meanas" --toc --toc-depth=4 --from=markdown+abbreviations --to=html --output=doc.html --gladtex -s -
|
||||||
}
|
|
||||||
trap cleanup EXIT
|
|
||||||
|
|
||||||
python3 "$ROOT/scripts/prepare_docs_sources.py" "$ROOT/meanas" "$DOCS_TMP"
|
# Approach 2: pdf to html with gladtex
|
||||||
|
rm -rf _doc_mathimg
|
||||||
|
pdoc --pdf --force --template-dir pdoc_templates -o doc . > doc.md
|
||||||
|
pandoc --metadata=title:"meanas" --from=markdown+abbreviations --to=html --output=doc.htex --gladtex -s --css pdoc_templates/pdoc.css doc.md
|
||||||
|
gladtex -a -n -d _doc_mathimg -c white doc.htex
|
||||||
|
|
||||||
MKDOCSTRINGS_PYTHON_PATH="$DOCS_TMP" mkdocs build --clean
|
# Approach 3: html with gladtex
|
||||||
|
#pdoc3 --html --force --template-dir pdoc_templates -o doc .
|
||||||
PRINT_PAGE='site/print_page/index.html'
|
#find doc -iname '*.html' -exec gladtex -a -n -d _mathimg -c white {} \;
|
||||||
if [[ -f "$PRINT_PAGE" ]] && command -v htmlark >/dev/null 2>&1; then
|
|
||||||
htmlark "$PRINT_PAGE" -o site/standalone.html
|
|
||||||
fi
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,18 @@
|
||||||
"""
|
"""
|
||||||
Electromagnetic simulation tools
|
Electromagnetic simulation tools
|
||||||
|
|
||||||
See the tracked examples for end-to-end workflows, and `help(meanas)` for the
|
See the readme or `import meanas; help(meanas)` for more info.
|
||||||
toolbox overview and API derivations.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pathlib
|
import pathlib
|
||||||
|
|
||||||
__version__ = '0.12'
|
__version__ = '0.8'
|
||||||
__author__ = 'Jan Petykiewicz'
|
__author__ = 'Jan Petykiewicz'
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
readme_path = pathlib.Path(__file__).parent / 'README.md'
|
with open(pathlib.Path(__file__).parent / 'README.md', 'r') as f:
|
||||||
with readme_path.open('r') as f:
|
|
||||||
__doc__ = f.read()
|
__doc__ = f.read()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,154 +1,18 @@
|
||||||
"""
|
"""
|
||||||
Solvers for eigenvalue / eigenvector problems
|
Solvers for eigenvalue / eigenvector problems
|
||||||
"""
|
"""
|
||||||
from collections.abc import Callable
|
from typing import Callable
|
||||||
import dataclasses
|
|
||||||
import numpy
|
import numpy
|
||||||
from numpy.typing import NDArray, ArrayLike
|
from numpy.typing import NDArray, ArrayLike
|
||||||
from numpy.linalg import norm
|
from numpy.linalg import norm
|
||||||
from scipy import sparse
|
from scipy import sparse # type: ignore
|
||||||
import scipy.sparse.linalg as spalg
|
import scipy.sparse.linalg as spalg # type: ignore
|
||||||
from scipy.optimize import linear_sum_assignment
|
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass(frozen=True, eq=False)
|
|
||||||
class TargetedEigensolution:
|
|
||||||
"""Paired right and transpose-left eigenvectors near a spectral target.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
eigenvalues: Eigenvalues associated with the columns of both vector
|
|
||||||
matrices.
|
|
||||||
right_eigenvectors: Right eigenvectors stored column-wise.
|
|
||||||
left_eigenvectors: Bilinear-left eigenvectors stored column-wise. These
|
|
||||||
satisfy a transpose, rather than Hermitian-transpose, eigenproblem.
|
|
||||||
"""
|
|
||||||
|
|
||||||
eigenvalues: NDArray[numpy.complex128]
|
|
||||||
right_eigenvectors: NDArray[numpy.complex128] = dataclasses.field(repr=False)
|
|
||||||
left_eigenvectors: NDArray[numpy.complex128] = dataclasses.field(repr=False)
|
|
||||||
|
|
||||||
|
|
||||||
type TargetedEigensolver = Callable[..., TargetedEigensolution]
|
|
||||||
|
|
||||||
|
|
||||||
def scipy_eigs_near(
|
|
||||||
operator: sparse.sparray,
|
|
||||||
target: complex,
|
|
||||||
how_many: int,
|
|
||||||
*,
|
|
||||||
tolerance: float = 1e-10,
|
|
||||||
max_iterations: int | None = None,
|
|
||||||
ncv: int | None = None,
|
|
||||||
pairing_rtol: float = 1e-7,
|
|
||||||
v0: ArrayLike | None = None,
|
|
||||||
) -> TargetedEigensolution:
|
|
||||||
"""
|
|
||||||
Solve for paired right and bilinear-left eigenvectors near ``target``.
|
|
||||||
|
|
||||||
One sparse LU factorization of ``operator - target * I`` is reused for the
|
|
||||||
right solve and for the transpose-left solve. The left vectors therefore
|
|
||||||
satisfy ``left.T @ operator = eigenvalue * left.T``; this deliberately uses
|
|
||||||
a transpose rather than a Hermitian transpose so the result is suitable for
|
|
||||||
unconjugated Lorentz-reciprocity projections.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
operator: Square sparse operator to diagonalize.
|
|
||||||
target: Shift-invert spectral target.
|
|
||||||
how_many: Number of eigenpairs to return.
|
|
||||||
tolerance: ARPACK convergence tolerance for both solves.
|
|
||||||
max_iterations: Optional ARPACK iteration limit.
|
|
||||||
ncv: Optional number of Arnoldi vectors. A size-aware default is used
|
|
||||||
when omitted.
|
|
||||||
pairing_rtol: Relative tolerance for matching right and transpose-left
|
|
||||||
eigenvalues.
|
|
||||||
v0: Optional finite starting vector shared by both solves.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Paired eigenvalues and column-wise right and transpose-left vectors.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If the dimensions, counts, tolerances, or starting vector
|
|
||||||
are invalid.
|
|
||||||
RuntimeError: If the independently solved spectra cannot be paired.
|
|
||||||
"""
|
|
||||||
if how_many < 1:
|
|
||||||
raise ValueError('how_many must be positive')
|
|
||||||
if not numpy.isfinite(tolerance) or tolerance <= 0:
|
|
||||||
raise ValueError('tolerance must be finite and positive')
|
|
||||||
if not numpy.isfinite(pairing_rtol) or pairing_rtol <= 0:
|
|
||||||
raise ValueError('pairing_rtol must be finite and positive')
|
|
||||||
|
|
||||||
operator = sparse.csc_array(operator, dtype=complex)
|
|
||||||
size = operator.shape[0]
|
|
||||||
if operator.shape != (size, size):
|
|
||||||
raise ValueError('operator must be square')
|
|
||||||
if how_many >= size - 1:
|
|
||||||
raise ValueError('how_many must be smaller than operator dimension minus one')
|
|
||||||
|
|
||||||
shifted = operator - target * sparse.eye_array(size, format='csc', dtype=complex)
|
|
||||||
factor = spalg.splu(shifted)
|
|
||||||
dtype = numpy.dtype(numpy.complex128)
|
|
||||||
inverse = spalg.LinearOperator(
|
|
||||||
operator.shape,
|
|
||||||
matvec=lambda vector: factor.solve(vector, trans='N'),
|
|
||||||
dtype=dtype,
|
|
||||||
)
|
|
||||||
inverse_transpose = spalg.LinearOperator(
|
|
||||||
operator.shape,
|
|
||||||
matvec=lambda vector: factor.solve(vector, trans='T'),
|
|
||||||
dtype=dtype,
|
|
||||||
)
|
|
||||||
if ncv is None:
|
|
||||||
ncv = min(size, max(2 * how_many + 1, 4 * how_many))
|
|
||||||
start = None if v0 is None else numpy.asarray(v0, dtype=complex)
|
|
||||||
if start is not None and (start.shape != (size,) or not numpy.isfinite(start).all()):
|
|
||||||
raise ValueError('v0 must be a finite vector matching the operator dimension')
|
|
||||||
|
|
||||||
eigenvalues, right = spalg.eigs(
|
|
||||||
operator,
|
|
||||||
k=how_many,
|
|
||||||
sigma=target,
|
|
||||||
which='LM',
|
|
||||||
OPinv=inverse,
|
|
||||||
tol=tolerance,
|
|
||||||
maxiter=max_iterations,
|
|
||||||
ncv=ncv,
|
|
||||||
v0=start,
|
|
||||||
)
|
|
||||||
left_values, left = spalg.eigs(
|
|
||||||
operator.T,
|
|
||||||
k=how_many,
|
|
||||||
sigma=target,
|
|
||||||
which='LM',
|
|
||||||
OPinv=inverse_transpose,
|
|
||||||
tol=tolerance,
|
|
||||||
maxiter=max_iterations,
|
|
||||||
ncv=ncv,
|
|
||||||
v0=start,
|
|
||||||
)
|
|
||||||
|
|
||||||
rows, columns = linear_sum_assignment(numpy.abs(eigenvalues[:, None] - left_values[None, :]))
|
|
||||||
paired_columns = columns[numpy.argsort(rows)]
|
|
||||||
left_values = left_values[paired_columns]
|
|
||||||
left = left[:, paired_columns]
|
|
||||||
pairing_error = numpy.abs(eigenvalues - left_values)
|
|
||||||
pairing_scale = numpy.maximum(1.0, numpy.maximum(numpy.abs(eigenvalues), numpy.abs(left_values)))
|
|
||||||
if numpy.any(pairing_error > pairing_rtol * pairing_scale):
|
|
||||||
raise RuntimeError('could not pair right and transpose-left eigenvalues')
|
|
||||||
|
|
||||||
return TargetedEigensolution(
|
|
||||||
eigenvalues=numpy.asarray(eigenvalues, dtype=complex),
|
|
||||||
right_eigenvectors=numpy.asarray(right, dtype=complex),
|
|
||||||
left_eigenvectors=numpy.asarray(left, dtype=complex),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def power_iteration(
|
def power_iteration(
|
||||||
operator: sparse.spmatrix,
|
operator: sparse.spmatrix,
|
||||||
guess_vector: NDArray[numpy.complex128] | None = None,
|
guess_vector: NDArray[numpy.complex128] | None = None,
|
||||||
iterations: int = 20,
|
iterations: int = 20,
|
||||||
*,
|
|
||||||
rng: numpy.random.Generator | int | None = None,
|
|
||||||
) -> tuple[complex, NDArray[numpy.complex128]]:
|
) -> tuple[complex, NDArray[numpy.complex128]]:
|
||||||
"""
|
"""
|
||||||
Use power iteration to estimate the dominant eigenvector of a matrix.
|
Use power iteration to estimate the dominant eigenvector of a matrix.
|
||||||
|
|
@ -157,18 +21,14 @@ def power_iteration(
|
||||||
operator: Matrix to analyze.
|
operator: Matrix to analyze.
|
||||||
guess_vector: Starting point for the eigenvector. Default is a randomly chosen vector.
|
guess_vector: Starting point for the eigenvector. Default is a randomly chosen vector.
|
||||||
iterations: Number of iterations to perform. Default 20.
|
iterations: Number of iterations to perform. Default 20.
|
||||||
rng: Generator or seed used only when ``guess_vector`` is omitted.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(Largest-magnitude eigenvalue, Corresponding eigenvector estimate)
|
(Largest-magnitude eigenvalue, Corresponding eigenvector estimate)
|
||||||
"""
|
"""
|
||||||
if guess_vector is None:
|
if guess_vector is None:
|
||||||
rng = numpy.random.default_rng(rng)
|
v = numpy.random.rand(operator.shape[0]) + 1j * numpy.random.rand(operator.shape[0])
|
||||||
v = rng.random(operator.shape[0]) + 1j * rng.random(operator.shape[0])
|
|
||||||
else:
|
else:
|
||||||
v = numpy.array(guess_vector, dtype=complex, copy=True)
|
v = guess_vector
|
||||||
if v.shape != (operator.shape[0],) or not numpy.isfinite(v).all() or not numpy.any(v):
|
|
||||||
raise ValueError('guess_vector must be finite, nonzero, and match the operator dimension')
|
|
||||||
|
|
||||||
for _ in range(iterations):
|
for _ in range(iterations):
|
||||||
v = operator @ v
|
v = operator @ v
|
||||||
|
|
@ -203,17 +63,15 @@ def rayleigh_quotient_iteration(
|
||||||
(eigenvalues, eigenvectors)
|
(eigenvalues, eigenvectors)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
(operator - sparse.eye_array(operator.shape[0]))
|
(operator - sparse.eye(operator.shape[0]))
|
||||||
|
|
||||||
def shift(eigval: float) -> sparse.sparray:
|
def shift(eigval: float) -> sparse:
|
||||||
"""Construct a sparse scalar identity shift."""
|
return eigval * sparse.eye(operator.shape[0])
|
||||||
return eigval * sparse.eye_array(operator.shape[0])
|
|
||||||
|
|
||||||
if solver is None:
|
if solver is None:
|
||||||
solver = spalg.spsolve
|
solver = spalg.spsolve
|
||||||
except TypeError:
|
except TypeError:
|
||||||
def shift(eigval: float) -> spalg.LinearOperator:
|
def shift(eigval: float) -> spalg.LinearOperator:
|
||||||
"""Construct a matrix-free scalar identity shift."""
|
|
||||||
return spalg.LinearOperator(
|
return spalg.LinearOperator(
|
||||||
shape=operator.shape,
|
shape=operator.shape,
|
||||||
dtype=operator.dtype,
|
dtype=operator.dtype,
|
||||||
|
|
@ -221,7 +79,6 @@ def rayleigh_quotient_iteration(
|
||||||
)
|
)
|
||||||
if solver is None:
|
if solver is None:
|
||||||
def solver(A: spalg.LinearOperator, b: ArrayLike) -> NDArray[numpy.complex128]:
|
def solver(A: spalg.LinearOperator, b: ArrayLike) -> NDArray[numpy.complex128]:
|
||||||
"""Solve a matrix-free shifted system with BiCGSTAB."""
|
|
||||||
return spalg.bicgstab(A, b)[0]
|
return spalg.bicgstab(A, b)[0]
|
||||||
assert solver is not None
|
assert solver is not None
|
||||||
|
|
||||||
|
|
@ -242,9 +99,6 @@ def signed_eigensolve(
|
||||||
operator: sparse.spmatrix | spalg.LinearOperator,
|
operator: sparse.spmatrix | spalg.LinearOperator,
|
||||||
how_many: int,
|
how_many: int,
|
||||||
negative: bool = False,
|
negative: bool = False,
|
||||||
*,
|
|
||||||
rng: numpy.random.Generator | int | None = None,
|
|
||||||
v0: ArrayLike | None = None,
|
|
||||||
) -> tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]:
|
) -> tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]:
|
||||||
"""
|
"""
|
||||||
Find the largest-magnitude positive-only (or negative-only) eigenvalues and
|
Find the largest-magnitude positive-only (or negative-only) eigenvalues and
|
||||||
|
|
@ -255,24 +109,13 @@ def signed_eigensolve(
|
||||||
how_many: How many eigenvalues to find.
|
how_many: How many eigenvalues to find.
|
||||||
negative: Whether to find negative-only eigenvalues.
|
negative: Whether to find negative-only eigenvalues.
|
||||||
Default False (positive only).
|
Default False (positive only).
|
||||||
rng: Optional generator or seed for reproducible initialization.
|
|
||||||
v0: Finite nonzero starting vector, taking precedence over ``rng``.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(sorted list of eigenvalues, 2D ndarray of corresponding eigenvectors)
|
(sorted list of eigenvalues, 2D ndarray of corresponding eigenvectors)
|
||||||
`eigenvectors[:, k]` corresponds to the k-th eigenvalue
|
`eigenvectors[:, k]` corresponds to the k-th eigenvalue
|
||||||
"""
|
"""
|
||||||
# Use power iteration to estimate the dominant eigenvector
|
# Use power iteration to estimate the dominant eigenvector
|
||||||
if v0 is None:
|
lm_eigval, _ = power_iteration(operator)
|
||||||
generator = numpy.random.default_rng(rng)
|
|
||||||
start = generator.random(operator.shape[0]) + 1j * generator.random(operator.shape[0])
|
|
||||||
else:
|
|
||||||
start = numpy.array(v0, dtype=complex, copy=True)
|
|
||||||
lm_eigval, _ = power_iteration(operator, guess_vector=start)
|
|
||||||
if not numpy.issubdtype(operator.dtype, numpy.complexfloating):
|
|
||||||
start = start.real
|
|
||||||
if not numpy.any(start):
|
|
||||||
raise ValueError('v0 must have a nonzero real part for a real operator')
|
|
||||||
|
|
||||||
'''
|
'''
|
||||||
Shift by the absolute value of the largest eigenvalue, then find a few of the
|
Shift by the absolute value of the largest eigenvalue, then find a few of the
|
||||||
|
|
@ -286,16 +129,14 @@ def signed_eigensolve(
|
||||||
|
|
||||||
# Try to combine, use general LinearOperator if we fail
|
# Try to combine, use general LinearOperator if we fail
|
||||||
try:
|
try:
|
||||||
shifted_operator = operator + shift * sparse.eye_array(operator.shape[0])
|
shifted_operator = operator + shift * sparse.eye(operator.shape[0])
|
||||||
except TypeError:
|
except TypeError:
|
||||||
shifted_operator = operator + spalg.LinearOperator(shape=operator.shape,
|
shifted_operator = operator + spalg.LinearOperator(shape=operator.shape,
|
||||||
matvec=lambda v: shift * v)
|
matvec=lambda v: shift * v)
|
||||||
|
|
||||||
shifted_eigenvalues, eigenvectors = spalg.eigs(
|
shifted_eigenvalues, eigenvectors = spalg.eigs(shifted_operator, which='LM', k=how_many, ncv=50)
|
||||||
shifted_operator, which='LM', k=how_many,
|
|
||||||
ncv=min(operator.shape[0], 2 * how_many + 50), v0=start,
|
|
||||||
)
|
|
||||||
eigenvalues = shifted_eigenvalues - shift
|
eigenvalues = shifted_eigenvalues - shift
|
||||||
|
|
||||||
k = eigenvalues.argsort()
|
k = eigenvalues.argsort()
|
||||||
return eigenvalues[k], eigenvectors[:, k]
|
return eigenvalues[k], eigenvectors[:, k]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
r"""
|
"""
|
||||||
Tools for finite difference frequency-domain (FDFD) simulations and calculations.
|
Tools for finite difference frequency-domain (FDFD) simulations and calculations.
|
||||||
|
|
||||||
These mostly involve picking a single frequency, then setting up and solving a
|
These mostly involve picking a single frequency, then setting up and solving a
|
||||||
|
|
@ -9,12 +9,9 @@ Submodules:
|
||||||
|
|
||||||
- `operators`, `functional`: General FDFD problem setup.
|
- `operators`, `functional`: General FDFD problem setup.
|
||||||
- `solvers`: Solver interface and reference implementation.
|
- `solvers`: Solver interface and reference implementation.
|
||||||
- `scpml`: Stretched-coordinate perfectly matched layer (SCPML) boundary conditions.
|
- `scpml`: Stretched-coordinate perfectly matched layer (scpml) boundary conditions
|
||||||
- `waveguide_2d`: Operators and mode-solver for waveguides with constant cross-section.
|
- `waveguide_2d`: Operators and mode-solver for waveguides with constant cross-section.
|
||||||
- `waveguide_3d`: Functions for transforming `waveguide_2d` results into 3D,
|
- `waveguide_3d`: Functions for transforming `waveguide_2d` results into 3D.
|
||||||
including mode-source and overlap-window construction.
|
|
||||||
- `farfield`, `bloch`, `eme`: specialized helper modules for near/far transforms,
|
|
||||||
Bloch-periodic problems, and eigenmode expansion.
|
|
||||||
|
|
||||||
|
|
||||||
================================================================
|
================================================================
|
||||||
|
|
@ -22,80 +19,77 @@ Submodules:
|
||||||
From the "Frequency domain" section of `meanas.fdmath`, we have
|
From the "Frequency domain" section of `meanas.fdmath`, we have
|
||||||
|
|
||||||
$$
|
$$
|
||||||
\begin{aligned}
|
\\begin{aligned}
|
||||||
\tilde{E}_{l, \vec{r}} &= \tilde{E}_{\vec{r}} e^{-\imath \omega l \Delta_t} \\
|
\\tilde{E}_{l, \\vec{r}} &= \\tilde{E}_{\\vec{r}} e^{-\\imath \\omega l \\Delta_t} \\\\
|
||||||
\tilde{H}_{l - \frac{1}{2}, \vec{r} + \frac{1}{2}} &= \tilde{H}_{\vec{r} + \frac{1}{2}} e^{-\imath \omega (l - \frac{1}{2}) \Delta_t} \\
|
\\tilde{H}_{l - \\frac{1}{2}, \\vec{r} + \\frac{1}{2}} &= \\tilde{H}_{\\vec{r} + \\frac{1}{2}} e^{-\\imath \\omega (l - \\frac{1}{2}) \\Delta_t} \\\\
|
||||||
\tilde{J}_{l, \vec{r}} &= \tilde{J}_{\vec{r}} e^{-\imath \omega (l - \frac{1}{2}) \Delta_t} \\
|
\\tilde{J}_{l, \\vec{r}} &= \\tilde{J}_{\\vec{r}} e^{-\\imath \\omega (l - \\frac{1}{2}) \\Delta_t} \\\\
|
||||||
\tilde{M}_{l - \frac{1}{2}, \vec{r} + \frac{1}{2}} &= \tilde{M}_{\vec{r} + \frac{1}{2}} e^{-\imath \omega l \Delta_t} \\
|
\\tilde{M}_{l - \\frac{1}{2}, \\vec{r} + \\frac{1}{2}} &= \\tilde{M}_{\\vec{r} + \\frac{1}{2}} e^{-\\imath \\omega l \\Delta_t} \\\\
|
||||||
\hat{\nabla} \times (\mu^{-1}_{\vec{r} + \frac{1}{2}} \cdot \tilde{\nabla} \times \tilde{E}_{\vec{r}})
|
\\hat{\\nabla} \\times (\\mu^{-1}_{\\vec{r} + \\frac{1}{2}} \\cdot \\tilde{\\nabla} \\times \\tilde{E}_{\\vec{r}})
|
||||||
-\Omega^2 \epsilon_{\vec{r}} \cdot \tilde{E}_{\vec{r}} &= -\imath \Omega \tilde{J}_{\vec{r}} e^{\imath \omega \Delta_t / 2} \\
|
-\\Omega^2 \\epsilon_{\\vec{r}} \\cdot \\tilde{E}_{\\vec{r}} &= -\\imath \\Omega \\tilde{J}_{\\vec{r}} e^{\\imath \\omega \\Delta_t / 2} \\\\
|
||||||
\Omega &= 2 \sin(\omega \Delta_t / 2) / \Delta_t
|
\\Omega &= 2 \\sin(\\omega \\Delta_t / 2) / \\Delta_t
|
||||||
\end{aligned}
|
\\end{aligned}
|
||||||
$$
|
$$
|
||||||
|
|
||||||
resulting in
|
resulting in
|
||||||
|
|
||||||
$$
|
$$
|
||||||
\begin{aligned}
|
\\begin{aligned}
|
||||||
\tilde{\partial}_t &\Rightarrow -\imath \Omega e^{-\imath \omega \Delta_t / 2}\\
|
\\tilde{\\partial}_t &\\Rightarrow -\\imath \\Omega e^{-\\imath \\omega \\Delta_t / 2}\\\\
|
||||||
\hat{\partial}_t &\Rightarrow -\imath \Omega e^{ \imath \omega \Delta_t / 2}\\
|
\\hat{\\partial}_t &\\Rightarrow -\\imath \\Omega e^{ \\imath \\omega \\Delta_t / 2}\\\\
|
||||||
\end{aligned}
|
\\end{aligned}
|
||||||
$$
|
$$
|
||||||
|
|
||||||
Maxwell's equations are then
|
Maxwell's equations are then
|
||||||
|
|
||||||
$$
|
$$
|
||||||
\begin{aligned}
|
\\begin{aligned}
|
||||||
\tilde{\nabla} \times \tilde{E}_{\vec{r}} &=
|
\\tilde{\\nabla} \\times \\tilde{E}_{\\vec{r}} &=
|
||||||
\imath \Omega e^{-\imath \omega \Delta_t / 2} \hat{B}_{\vec{r} + \frac{1}{2}}
|
\\imath \\Omega e^{-\\imath \\omega \\Delta_t / 2} \\hat{B}_{\\vec{r} + \\frac{1}{2}}
|
||||||
- \hat{M}_{\vec{r} + \frac{1}{2}} \\
|
- \\hat{M}_{\\vec{r} + \\frac{1}{2}} \\\\
|
||||||
\hat{\nabla} \times \hat{H}_{\vec{r} + \frac{1}{2}} &=
|
\\hat{\\nabla} \\times \\hat{H}_{\\vec{r} + \\frac{1}{2}} &=
|
||||||
-\imath \Omega e^{ \imath \omega \Delta_t / 2} \tilde{D}_{\vec{r}}
|
-\\imath \\Omega e^{ \\imath \\omega \\Delta_t / 2} \\tilde{D}_{\\vec{r}}
|
||||||
+ \tilde{J}_{\vec{r}} \\
|
+ \\tilde{J}_{\\vec{r}} \\\\
|
||||||
\tilde{\nabla} \cdot \hat{B}_{\vec{r} + \frac{1}{2}} &= 0 \\
|
\\tilde{\\nabla} \\cdot \\hat{B}_{\\vec{r} + \\frac{1}{2}} &= 0 \\\\
|
||||||
\hat{\nabla} \cdot \tilde{D}_{\vec{r}} &= \rho_{\vec{r}}
|
\\hat{\\nabla} \\cdot \\tilde{D}_{\\vec{r}} &= \\rho_{\\vec{r}}
|
||||||
\end{aligned}
|
\\end{aligned}
|
||||||
$$
|
$$
|
||||||
|
|
||||||
With $\Delta_t \to 0$, this simplifies to
|
With $\\Delta_t \\to 0$, this simplifies to
|
||||||
|
|
||||||
$$
|
$$
|
||||||
\begin{aligned}
|
\\begin{aligned}
|
||||||
\tilde{E}_{l, \vec{r}} &\to \tilde{E}_{\vec{r}} \\
|
\\tilde{E}_{l, \\vec{r}} &\\to \\tilde{E}_{\\vec{r}} \\\\
|
||||||
\tilde{H}_{l - \frac{1}{2}, \vec{r} + \frac{1}{2}} &\to \tilde{H}_{\vec{r} + \frac{1}{2}} \\
|
\\tilde{H}_{l - \\frac{1}{2}, \\vec{r} + \\frac{1}{2}} &\\to \\tilde{H}_{\\vec{r} + \\frac{1}{2}} \\\\
|
||||||
\tilde{J}_{l, \vec{r}} &\to \tilde{J}_{\vec{r}} \\
|
\\tilde{J}_{l, \\vec{r}} &\\to \\tilde{J}_{\\vec{r}} \\\\
|
||||||
\tilde{M}_{l - \frac{1}{2}, \vec{r} + \frac{1}{2}} &\to \tilde{M}_{\vec{r} + \frac{1}{2}} \\
|
\\tilde{M}_{l - \\frac{1}{2}, \\vec{r} + \\frac{1}{2}} &\\to \\tilde{M}_{\\vec{r} + \\frac{1}{2}} \\\\
|
||||||
\Omega &\to \omega \\
|
\\Omega &\\to \\omega \\\\
|
||||||
\tilde{\partial}_t &\to -\imath \omega \\
|
\\tilde{\\partial}_t &\\to -\\imath \\omega \\\\
|
||||||
\hat{\partial}_t &\to -\imath \omega \\
|
\\hat{\\partial}_t &\\to -\\imath \\omega \\\\
|
||||||
\end{aligned}
|
\\end{aligned}
|
||||||
$$
|
$$
|
||||||
|
|
||||||
and then
|
and then
|
||||||
|
|
||||||
$$
|
$$
|
||||||
\begin{aligned}
|
\\begin{aligned}
|
||||||
\tilde{\nabla} \times \tilde{E}_{\vec{r}} &=
|
\\tilde{\\nabla} \\times \\tilde{E}_{\\vec{r}} &=
|
||||||
\imath \omega \hat{B}_{\vec{r} + \frac{1}{2}}
|
\\imath \\omega \\hat{B}_{\\vec{r} + \\frac{1}{2}}
|
||||||
- \hat{M}_{\vec{r} + \frac{1}{2}} \\
|
- \\hat{M}_{\\vec{r} + \\frac{1}{2}} \\\\
|
||||||
\hat{\nabla} \times \hat{H}_{\vec{r} + \frac{1}{2}} &=
|
\\hat{\\nabla} \\times \\hat{H}_{\\vec{r} + \\frac{1}{2}} &=
|
||||||
-\imath \omega \tilde{D}_{\vec{r}}
|
-\\imath \\omega \\tilde{D}_{\\vec{r}}
|
||||||
+ \tilde{J}_{\vec{r}} \\
|
+ \\tilde{J}_{\\vec{r}} \\\\
|
||||||
\end{aligned}
|
\\end{aligned}
|
||||||
$$
|
$$
|
||||||
|
|
||||||
$$
|
$$
|
||||||
\hat{\nabla} \times (\mu^{-1}_{\vec{r} + \frac{1}{2}} \cdot \tilde{\nabla} \times \tilde{E}_{\vec{r}})
|
\\hat{\\nabla} \\times (\\mu^{-1}_{\\vec{r} + \\frac{1}{2}} \\cdot \\tilde{\\nabla} \\times \\tilde{E}_{\\vec{r}})
|
||||||
-\omega^2 \epsilon_{\vec{r}} \cdot \tilde{E}_{\vec{r}} = -\imath \omega \tilde{J}_{\vec{r}} \\
|
-\\omega^2 \\epsilon_{\\vec{r}} \\cdot \\tilde{E}_{\\vec{r}} = -\\imath \\omega \\tilde{J}_{\\vec{r}} \\\\
|
||||||
$$
|
$$
|
||||||
|
|
||||||
|
# TODO FDFD?
|
||||||
|
# TODO PML
|
||||||
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from . import (
|
from . import solvers, operators, functional, scpml, waveguide_2d, waveguide_3d
|
||||||
solvers as solvers,
|
|
||||||
operators as operators,
|
|
||||||
functional as functional,
|
|
||||||
scpml as scpml,
|
|
||||||
waveguide_2d as waveguide_2d,
|
|
||||||
waveguide_3d as waveguide_3d,
|
|
||||||
)
|
|
||||||
# from . import farfield, bloch TODO
|
# from . import farfield, bloch TODO
|
||||||
|
|
|
||||||
|
|
@ -94,19 +94,18 @@ This module contains functions for generating and solving the
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Any, cast
|
from typing import Callable, Any, cast, Sequence
|
||||||
from collections.abc import Callable, Sequence
|
|
||||||
import logging
|
import logging
|
||||||
import numpy
|
import numpy
|
||||||
from numpy import pi, real, trace
|
from numpy import pi, real, trace
|
||||||
from numpy.fft import fftfreq
|
from numpy.fft import fftfreq
|
||||||
from numpy.typing import NDArray, ArrayLike
|
from numpy.typing import NDArray, ArrayLike
|
||||||
import scipy
|
import scipy # type: ignore
|
||||||
import scipy.optimize
|
import scipy.optimize # type: ignore
|
||||||
from scipy.linalg import norm
|
from scipy.linalg import norm # type: ignore
|
||||||
import scipy.sparse.linalg as spalg
|
import scipy.sparse.linalg as spalg # type: ignore
|
||||||
|
|
||||||
from ..fdmath import fdfield, cfdfield, cfdfield_t
|
from ..fdmath import fdfield_t, cfdfield_t
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -115,6 +114,7 @@ logger = logging.getLogger(__name__)
|
||||||
try:
|
try:
|
||||||
import pyfftw.interfaces.numpy_fft # type: ignore
|
import pyfftw.interfaces.numpy_fft # type: ignore
|
||||||
import pyfftw.interfaces # type: ignore
|
import pyfftw.interfaces # type: ignore
|
||||||
|
import multiprocessing
|
||||||
logger.info('Using pyfftw')
|
logger.info('Using pyfftw')
|
||||||
|
|
||||||
pyfftw.interfaces.cache.enable()
|
pyfftw.interfaces.cache.enable()
|
||||||
|
|
@ -136,14 +136,6 @@ except ImportError:
|
||||||
logger.info('Using numpy fft')
|
logger.info('Using numpy fft')
|
||||||
|
|
||||||
|
|
||||||
def _assemble_hmn_vector(
|
|
||||||
h_m: NDArray[numpy.complex128],
|
|
||||||
h_n: NDArray[numpy.complex128],
|
|
||||||
) -> NDArray[numpy.complex128]:
|
|
||||||
stacked = numpy.concatenate((numpy.ravel(h_m), numpy.ravel(h_n)))
|
|
||||||
return stacked[:, None]
|
|
||||||
|
|
||||||
|
|
||||||
def generate_kmn(
|
def generate_kmn(
|
||||||
k0: ArrayLike,
|
k0: ArrayLike,
|
||||||
G_matrix: ArrayLike,
|
G_matrix: ArrayLike,
|
||||||
|
|
@ -163,7 +155,7 @@ def generate_kmn(
|
||||||
All are given in the xyz basis (e.g. `|k|[0,0,0] = norm(G_matrix @ k0)`).
|
All are given in the xyz basis (e.g. `|k|[0,0,0] = norm(G_matrix @ k0)`).
|
||||||
"""
|
"""
|
||||||
k0 = numpy.array(k0)
|
k0 = numpy.array(k0)
|
||||||
G_matrix = numpy.asarray(G_matrix)
|
G_matrix = numpy.array(G_matrix, copy=False)
|
||||||
|
|
||||||
Gi_grids = numpy.array(numpy.meshgrid(*(fftfreq(n, 1 / n) for n in shape[:3]), indexing='ij'))
|
Gi_grids = numpy.array(numpy.meshgrid(*(fftfreq(n, 1 / n) for n in shape[:3]), indexing='ij'))
|
||||||
Gi = numpy.moveaxis(Gi_grids, 0, -1)
|
Gi = numpy.moveaxis(Gi_grids, 0, -1)
|
||||||
|
|
@ -191,8 +183,8 @@ def generate_kmn(
|
||||||
def maxwell_operator(
|
def maxwell_operator(
|
||||||
k0: ArrayLike,
|
k0: ArrayLike,
|
||||||
G_matrix: ArrayLike,
|
G_matrix: ArrayLike,
|
||||||
epsilon: fdfield,
|
epsilon: fdfield_t,
|
||||||
mu: fdfield | None = None
|
mu: fdfield_t | None = None
|
||||||
) -> Callable[[NDArray[numpy.complex128]], NDArray[numpy.complex128]]:
|
) -> Callable[[NDArray[numpy.complex128]], NDArray[numpy.complex128]]:
|
||||||
"""
|
"""
|
||||||
Generate the Maxwell operator
|
Generate the Maxwell operator
|
||||||
|
|
@ -240,13 +232,13 @@ def maxwell_operator(
|
||||||
Raveled conv(1/mu_k, ik x conv(1/eps_k, ik x h_mn)), returned
|
Raveled conv(1/mu_k, ik x conv(1/eps_k, ik x h_mn)), returned
|
||||||
and overwritten in-place of `h`.
|
and overwritten in-place of `h`.
|
||||||
"""
|
"""
|
||||||
hin_m, hin_n = (hi.reshape(shape) for hi in numpy.split(h, 2))
|
hin_m, hin_n = [hi.reshape(shape) for hi in numpy.split(h, 2)]
|
||||||
|
|
||||||
#{d,e,h}_xyz fields are complex 3-fields in (1/x, 1/y, 1/z) basis
|
#{d,e,h}_xyz fields are complex 3-fields in (1/x, 1/y, 1/z) basis
|
||||||
|
|
||||||
# cross product and transform into xyz basis
|
# cross product and transform into xyz basis
|
||||||
d_xyz = (n * hin_m
|
d_xyz = (n * hin_m
|
||||||
- m * hin_n) * k_mag
|
- m * hin_n) * k_mag # noqa: E128
|
||||||
|
|
||||||
# divide by epsilon
|
# divide by epsilon
|
||||||
temp = ifftn(d_xyz, axes=range(3)) # reuses d_xyz if using pyfftw
|
temp = ifftn(d_xyz, axes=range(3)) # reuses d_xyz if using pyfftw
|
||||||
|
|
@ -261,8 +253,8 @@ def maxwell_operator(
|
||||||
h_m, h_n = b_m, b_n
|
h_m, h_n = b_m, b_n
|
||||||
else:
|
else:
|
||||||
# transform from mn to xyz
|
# transform from mn to xyz
|
||||||
b_xyz = (m * b_m
|
b_xyz = (m * b_m[:, :, :, None]
|
||||||
+ n * b_n) # noqa
|
+ n * b_n[:, :, :, None]) # noqa: E128
|
||||||
|
|
||||||
# divide by mu
|
# divide by mu
|
||||||
temp = ifftn(b_xyz, axes=range(3))
|
temp = ifftn(b_xyz, axes=range(3))
|
||||||
|
|
@ -273,7 +265,10 @@ def maxwell_operator(
|
||||||
h_m = numpy.sum(h_xyz * m, axis=3)
|
h_m = numpy.sum(h_xyz * m, axis=3)
|
||||||
h_n = numpy.sum(h_xyz * n, axis=3)
|
h_n = numpy.sum(h_xyz * n, axis=3)
|
||||||
|
|
||||||
return _assemble_hmn_vector(h_m, h_n)
|
h.shape = (h.size,)
|
||||||
|
h = numpy.concatenate((h_m.ravel(), h_n.ravel()), axis=None, out=h) # ravel and merge
|
||||||
|
h.shape = (h.size, 1)
|
||||||
|
return h
|
||||||
|
|
||||||
return operator
|
return operator
|
||||||
|
|
||||||
|
|
@ -281,7 +276,7 @@ def maxwell_operator(
|
||||||
def hmn_2_exyz(
|
def hmn_2_exyz(
|
||||||
k0: ArrayLike,
|
k0: ArrayLike,
|
||||||
G_matrix: ArrayLike,
|
G_matrix: ArrayLike,
|
||||||
epsilon: fdfield,
|
epsilon: fdfield_t,
|
||||||
) -> Callable[[NDArray[numpy.complex128]], cfdfield_t]:
|
) -> Callable[[NDArray[numpy.complex128]], cfdfield_t]:
|
||||||
"""
|
"""
|
||||||
Generate an operator which converts a vectorized spatial-frequency-space
|
Generate an operator which converts a vectorized spatial-frequency-space
|
||||||
|
|
@ -308,13 +303,12 @@ def hmn_2_exyz(
|
||||||
k_mag, m, n = generate_kmn(k0, G_matrix, shape)
|
k_mag, m, n = generate_kmn(k0, G_matrix, shape)
|
||||||
|
|
||||||
def operator(h: NDArray[numpy.complex128]) -> cfdfield_t:
|
def operator(h: NDArray[numpy.complex128]) -> cfdfield_t:
|
||||||
hin_m, hin_n = (hi.reshape(shape) for hi in numpy.split(h, 2))
|
hin_m, hin_n = [hi.reshape(shape) for hi in numpy.split(h, 2)]
|
||||||
d_xyz = (n * hin_m
|
d_xyz = (n * hin_m
|
||||||
- m * hin_n) * k_mag
|
- m * hin_n) * k_mag # noqa: E128
|
||||||
|
|
||||||
# divide by epsilon
|
# divide by epsilon
|
||||||
exyz = numpy.moveaxis(ifftn(d_xyz, axes=range(3)) / epsilon, 3, 0)
|
return numpy.array([ei for ei in numpy.moveaxis(ifftn(d_xyz, axes=range(3)) / epsilon, 3, 0)]) # TODO avoid copy
|
||||||
return cfdfield_t(exyz)
|
|
||||||
|
|
||||||
return operator
|
return operator
|
||||||
|
|
||||||
|
|
@ -322,7 +316,7 @@ def hmn_2_exyz(
|
||||||
def hmn_2_hxyz(
|
def hmn_2_hxyz(
|
||||||
k0: ArrayLike,
|
k0: ArrayLike,
|
||||||
G_matrix: ArrayLike,
|
G_matrix: ArrayLike,
|
||||||
epsilon: fdfield,
|
epsilon: fdfield_t
|
||||||
) -> Callable[[NDArray[numpy.complex128]], cfdfield_t]:
|
) -> Callable[[NDArray[numpy.complex128]], cfdfield_t]:
|
||||||
"""
|
"""
|
||||||
Generate an operator which converts a vectorized spatial-frequency-space
|
Generate an operator which converts a vectorized spatial-frequency-space
|
||||||
|
|
@ -347,10 +341,10 @@ def hmn_2_hxyz(
|
||||||
_k_mag, m, n = generate_kmn(k0, G_matrix, shape)
|
_k_mag, m, n = generate_kmn(k0, G_matrix, shape)
|
||||||
|
|
||||||
def operator(h: NDArray[numpy.complex128]) -> cfdfield_t:
|
def operator(h: NDArray[numpy.complex128]) -> cfdfield_t:
|
||||||
hin_m, hin_n = (hi.reshape(shape) for hi in numpy.split(h, 2))
|
hin_m, hin_n = [hi.reshape(shape) for hi in numpy.split(h, 2)]
|
||||||
h_xyz = (m * hin_m
|
h_xyz = (m * hin_m
|
||||||
+ n * hin_n)
|
+ n * hin_n) # noqa: E128
|
||||||
return cfdfield_t(numpy.array([ifftn(hi) for hi in numpy.moveaxis(h_xyz, 3, 0)]))
|
return numpy.array([ifftn(hi) for hi in numpy.moveaxis(h_xyz, 3, 0)])
|
||||||
|
|
||||||
return operator
|
return operator
|
||||||
|
|
||||||
|
|
@ -358,8 +352,8 @@ def hmn_2_hxyz(
|
||||||
def inverse_maxwell_operator_approx(
|
def inverse_maxwell_operator_approx(
|
||||||
k0: ArrayLike,
|
k0: ArrayLike,
|
||||||
G_matrix: ArrayLike,
|
G_matrix: ArrayLike,
|
||||||
epsilon: fdfield,
|
epsilon: fdfield_t,
|
||||||
mu: fdfield | None = None,
|
mu: fdfield_t | None = None,
|
||||||
) -> Callable[[NDArray[numpy.complex128]], NDArray[numpy.complex128]]:
|
) -> Callable[[NDArray[numpy.complex128]], NDArray[numpy.complex128]]:
|
||||||
"""
|
"""
|
||||||
Generate an approximate inverse of the Maxwell operator,
|
Generate an approximate inverse of the Maxwell operator,
|
||||||
|
|
@ -400,7 +394,7 @@ def inverse_maxwell_operator_approx(
|
||||||
Returns:
|
Returns:
|
||||||
Raveled ik x conv(eps_k, ik x conv(mu_k, h_mn))
|
Raveled ik x conv(eps_k, ik x conv(mu_k, h_mn))
|
||||||
"""
|
"""
|
||||||
hin_m, hin_n = (hi.reshape(shape) for hi in numpy.split(h, 2))
|
hin_m, hin_n = [hi.reshape(shape) for hi in numpy.split(h, 2)]
|
||||||
|
|
||||||
#{d,e,h}_xyz fields are complex 3-fields in (1/x, 1/y, 1/z) basis
|
#{d,e,h}_xyz fields are complex 3-fields in (1/x, 1/y, 1/z) basis
|
||||||
|
|
||||||
|
|
@ -408,8 +402,8 @@ def inverse_maxwell_operator_approx(
|
||||||
b_m, b_n = hin_m, hin_n
|
b_m, b_n = hin_m, hin_n
|
||||||
else:
|
else:
|
||||||
# transform from mn to xyz
|
# transform from mn to xyz
|
||||||
h_xyz = (m * hin_m
|
h_xyz = (m * hin_m[:, :, :, None]
|
||||||
+ n * hin_n) # noqa
|
+ n * hin_n[:, :, :, None]) # noqa: E128
|
||||||
|
|
||||||
# multiply by mu
|
# multiply by mu
|
||||||
temp = ifftn(h_xyz, axes=range(3))
|
temp = ifftn(h_xyz, axes=range(3))
|
||||||
|
|
@ -417,12 +411,12 @@ def inverse_maxwell_operator_approx(
|
||||||
b_xyz = fftn(temp, axes=range(3))
|
b_xyz = fftn(temp, axes=range(3))
|
||||||
|
|
||||||
# transform back to mn
|
# transform back to mn
|
||||||
b_m = numpy.sum(b_xyz * m, axis=3, keepdims=True)
|
b_m = numpy.sum(b_xyz * m, axis=3)
|
||||||
b_n = numpy.sum(b_xyz * n, axis=3, keepdims=True)
|
b_n = numpy.sum(b_xyz * n, axis=3)
|
||||||
|
|
||||||
# cross product and transform into xyz basis
|
# cross product and transform into xyz basis
|
||||||
e_xyz = (n * b_m
|
e_xyz = (n * b_m
|
||||||
- m * b_n) / k_mag
|
- m * b_n) / k_mag # noqa: E128
|
||||||
|
|
||||||
# multiply by epsilon
|
# multiply by epsilon
|
||||||
temp = ifftn(e_xyz, axes=range(3))
|
temp = ifftn(e_xyz, axes=range(3))
|
||||||
|
|
@ -433,7 +427,10 @@ def inverse_maxwell_operator_approx(
|
||||||
h_m = numpy.sum(d_xyz * n, axis=3, keepdims=True) / +k_mag
|
h_m = numpy.sum(d_xyz * n, axis=3, keepdims=True) / +k_mag
|
||||||
h_n = numpy.sum(d_xyz * m, axis=3, keepdims=True) / -k_mag
|
h_n = numpy.sum(d_xyz * m, axis=3, keepdims=True) / -k_mag
|
||||||
|
|
||||||
return _assemble_hmn_vector(h_m, h_n)
|
h.shape = (h.size,)
|
||||||
|
h = numpy.concatenate((h_m, h_n), axis=None, out=h)
|
||||||
|
h.shape = (h.size, 1)
|
||||||
|
return h
|
||||||
|
|
||||||
return operator
|
return operator
|
||||||
|
|
||||||
|
|
@ -443,15 +440,15 @@ def find_k(
|
||||||
tolerance: float,
|
tolerance: float,
|
||||||
direction: ArrayLike,
|
direction: ArrayLike,
|
||||||
G_matrix: ArrayLike,
|
G_matrix: ArrayLike,
|
||||||
epsilon: fdfield,
|
epsilon: fdfield_t,
|
||||||
mu: fdfield | None = None,
|
mu: fdfield_t | None = None,
|
||||||
band: int = 0,
|
band: int = 0,
|
||||||
k_bounds: tuple[float, float] = (0, 0.5),
|
k_bounds: tuple[float, float] = (0, 0.5),
|
||||||
k_guess: float | None = None,
|
k_guess: float | None = None,
|
||||||
solve_callback: Callable[..., None] | None = None,
|
solve_callback: Callable[..., None] | None = None,
|
||||||
iter_callback: Callable[..., None] | None = None,
|
iter_callback: Callable[..., None] | None = None,
|
||||||
v0: NDArray[numpy.complex128] | None = None,
|
v0: NDArray[numpy.complex128] | None = None,
|
||||||
) -> tuple[NDArray[numpy.float64], float, NDArray[numpy.complex128], NDArray[numpy.complex128]]:
|
) -> tuple[float, float, NDArray[numpy.complex128], NDArray[numpy.complex128]]:
|
||||||
"""
|
"""
|
||||||
Search for a bloch vector that has a given frequency.
|
Search for a bloch vector that has a given frequency.
|
||||||
|
|
||||||
|
|
@ -474,7 +471,7 @@ def find_k(
|
||||||
`(k, actual_frequency, eigenvalues, eigenvectors)`
|
`(k, actual_frequency, eigenvalues, eigenvectors)`
|
||||||
The found k-vector and its frequency, along with all eigenvalues and eigenvectors.
|
The found k-vector and its frequency, along with all eigenvalues and eigenvectors.
|
||||||
"""
|
"""
|
||||||
direction = numpy.array(direction) / norm(direction) # type: ignore[operator]
|
direction = numpy.array(direction) / norm(direction)
|
||||||
|
|
||||||
k_bounds = tuple(sorted(k_bounds)) # type: ignore # we know the length already...
|
k_bounds = tuple(sorted(k_bounds)) # type: ignore # we know the length already...
|
||||||
assert len(k_bounds) == 2
|
assert len(k_bounds) == 2
|
||||||
|
|
@ -496,23 +493,23 @@ def find_k(
|
||||||
|
|
||||||
res = scipy.optimize.minimize_scalar(
|
res = scipy.optimize.minimize_scalar(
|
||||||
lambda x: abs(get_f(x, band) - frequency),
|
lambda x: abs(get_f(x, band) - frequency),
|
||||||
method='bounded',
|
k_guess,
|
||||||
|
method='Bounded',
|
||||||
bounds=k_bounds,
|
bounds=k_bounds,
|
||||||
options={'xatol': abs(tolerance)},
|
options={'xatol': abs(tolerance)},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert n is not None
|
assert n is not None
|
||||||
assert v is not None
|
assert v is not None
|
||||||
actual_frequency = get_f(float(res.x), band)
|
return float(res.x * direction), float(res.fun + frequency), n, v
|
||||||
return direction * float(res.x), float(actual_frequency), n, v # type: ignore[operator,return-value]
|
|
||||||
|
|
||||||
|
|
||||||
def eigsolve(
|
def eigsolve(
|
||||||
num_modes: int,
|
num_modes: int,
|
||||||
k0: ArrayLike,
|
k0: ArrayLike,
|
||||||
G_matrix: ArrayLike,
|
G_matrix: ArrayLike,
|
||||||
epsilon: fdfield,
|
epsilon: fdfield_t,
|
||||||
mu: fdfield | None = None,
|
mu: fdfield_t | None = None,
|
||||||
tolerance: float = 1e-7,
|
tolerance: float = 1e-7,
|
||||||
max_iters: int = 10000,
|
max_iters: int = 10000,
|
||||||
reset_iters: int = 100,
|
reset_iters: int = 100,
|
||||||
|
|
@ -541,7 +538,7 @@ def eigsolve(
|
||||||
`(eigenvalues, eigenvectors)` where `eigenvalues[i]` corresponds to the
|
`(eigenvalues, eigenvectors)` where `eigenvalues[i]` corresponds to the
|
||||||
vector `eigenvectors[i, :]`
|
vector `eigenvectors[i, :]`
|
||||||
"""
|
"""
|
||||||
k0 = numpy.asarray(k0)
|
k0 = numpy.array(k0, copy=False)
|
||||||
|
|
||||||
h_size = 2 * epsilon[0].size
|
h_size = 2 * epsilon[0].size
|
||||||
|
|
||||||
|
|
@ -564,12 +561,11 @@ def eigsolve(
|
||||||
prev_theta = 0.5
|
prev_theta = 0.5
|
||||||
D = numpy.zeros(shape=y_shape, dtype=complex)
|
D = numpy.zeros(shape=y_shape, dtype=complex)
|
||||||
|
|
||||||
rng = numpy.random.default_rng()
|
|
||||||
Z: NDArray[numpy.complex128]
|
Z: NDArray[numpy.complex128]
|
||||||
if y0 is None:
|
if y0 is None:
|
||||||
Z = rng.random(y_shape) + 1j * rng.random(y_shape)
|
Z = numpy.random.rand(*y_shape) + 1j * numpy.random.rand(*y_shape)
|
||||||
else:
|
else:
|
||||||
Z = numpy.asarray(y0).T
|
Z = numpy.array(y0, copy=False).T
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
Z *= num_modes / norm(Z)
|
Z *= num_modes / norm(Z)
|
||||||
|
|
@ -577,7 +573,7 @@ def eigsolve(
|
||||||
try:
|
try:
|
||||||
U = numpy.linalg.inv(ZtZ)
|
U = numpy.linalg.inv(ZtZ)
|
||||||
except numpy.linalg.LinAlgError:
|
except numpy.linalg.LinAlgError:
|
||||||
Z = rng.random(y_shape) + 1j * rng.random(y_shape)
|
Z = numpy.random.rand(*y_shape) + 1j * numpy.random.rand(*y_shape)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
trace_U = real(trace(U))
|
trace_U = real(trace(U))
|
||||||
|
|
@ -650,16 +646,17 @@ def eigsolve(
|
||||||
|
|
||||||
Qi_memo: list[float | None] = [None, None]
|
Qi_memo: list[float | None] = [None, None]
|
||||||
|
|
||||||
def Qi_func(theta: float, Qi_memo=Qi_memo, ZtZ=ZtZ, DtD=DtD, symZtD=symZtD) -> float: # noqa: ANN001
|
def Qi_func(theta: float) -> float:
|
||||||
|
nonlocal Qi_memo
|
||||||
if Qi_memo[0] == theta:
|
if Qi_memo[0] == theta:
|
||||||
return cast('float', Qi_memo[1])
|
return cast(float, Qi_memo[1])
|
||||||
|
|
||||||
c = numpy.cos(theta)
|
c = numpy.cos(theta)
|
||||||
s = numpy.sin(theta)
|
s = numpy.sin(theta)
|
||||||
Q = c * c * ZtZ + s * s * DtD + 2 * s * c * symZtD
|
Q = c * c * ZtZ + s * s * DtD + 2 * s * c * symZtD
|
||||||
try:
|
try:
|
||||||
Qi = numpy.linalg.inv(Q)
|
Qi = numpy.linalg.inv(Q)
|
||||||
except numpy.linalg.LinAlgError as err:
|
except numpy.linalg.LinAlgError:
|
||||||
logger.info('taylor Qi')
|
logger.info('taylor Qi')
|
||||||
# if c or s small, taylor expand
|
# if c or s small, taylor expand
|
||||||
if c < 1e-4 * s and c != 0:
|
if c < 1e-4 * s and c != 0:
|
||||||
|
|
@ -669,12 +666,12 @@ def eigsolve(
|
||||||
ZtZi = numpy.linalg.inv(ZtZ)
|
ZtZi = numpy.linalg.inv(ZtZ)
|
||||||
Qi = ZtZi / (c * c) - 2 * s / (c * c * c) * (ZtZi @ (ZtZi @ symZtD).conj().T)
|
Qi = ZtZi / (c * c) - 2 * s / (c * c * c) * (ZtZi @ (ZtZi @ symZtD).conj().T)
|
||||||
else:
|
else:
|
||||||
raise Exception('Inexplicable singularity in trace_func') from err
|
raise Exception('Inexplicable singularity in trace_func')
|
||||||
Qi_memo[0] = theta
|
Qi_memo[0] = theta
|
||||||
Qi_memo[1] = cast('float', Qi)
|
Qi_memo[1] = cast(float, Qi)
|
||||||
return cast('float', Qi)
|
return cast(float, Qi)
|
||||||
|
|
||||||
def trace_func(theta: float, ZtAZ=ZtAZ, DtAD=DtAD, symZtAD=symZtAD) -> float: # noqa: ANN001
|
def trace_func(theta: float) -> float:
|
||||||
c = numpy.cos(theta)
|
c = numpy.cos(theta)
|
||||||
s = numpy.sin(theta)
|
s = numpy.sin(theta)
|
||||||
Qi = Qi_func(theta)
|
Qi = Qi_func(theta)
|
||||||
|
|
@ -683,24 +680,15 @@ def eigsolve(
|
||||||
return numpy.abs(trace)
|
return numpy.abs(trace)
|
||||||
|
|
||||||
if False:
|
if False:
|
||||||
def trace_deriv(
|
def trace_deriv(theta):
|
||||||
theta: float,
|
|
||||||
sgn: int = sgn,
|
|
||||||
ZtAZ=ZtAZ, # noqa: ANN001
|
|
||||||
DtAD=DtAD, # noqa: ANN001
|
|
||||||
symZtD=symZtD, # noqa: ANN001
|
|
||||||
symZtAD=symZtAD, # noqa: ANN001
|
|
||||||
ZtZ=ZtZ, # noqa: ANN001
|
|
||||||
DtD=DtD, # noqa: ANN001
|
|
||||||
) -> float:
|
|
||||||
Qi = Qi_func(theta)
|
Qi = Qi_func(theta)
|
||||||
c2 = numpy.cos(2 * theta)
|
c2 = numpy.cos(2 * theta)
|
||||||
s2 = numpy.sin(2 * theta)
|
s2 = numpy.sin(2 * theta)
|
||||||
F = -0.5 * s2 * (ZtAZ - DtAD) + c2 * symZtAD
|
F = -0.5*s2 * (ZtAZ - DtAD) + c2 * symZtAD
|
||||||
trace_deriv = _rtrace_AtB(Qi, F)
|
trace_deriv = _rtrace_AtB(Qi, F)
|
||||||
|
|
||||||
G = Qi @ F.conj().T @ Qi.conj().T
|
G = Qi @ F.conj().T @ Qi.conj().T
|
||||||
H = -0.5 * s2 * (ZtZ - DtD) + c2 * symZtD
|
H = -0.5*s2 * (ZtZ - DtD) + c2 * symZtD
|
||||||
trace_deriv -= _rtrace_AtB(G, H)
|
trace_deriv -= _rtrace_AtB(G, H)
|
||||||
|
|
||||||
trace_deriv *= 2
|
trace_deriv *= 2
|
||||||
|
|
@ -708,12 +696,12 @@ def eigsolve(
|
||||||
|
|
||||||
U_sZtD = U @ symZtD
|
U_sZtD = U @ symZtD
|
||||||
|
|
||||||
dE = 2.0 * (_rtrace_AtB(U, symZtAD)
|
dE = 2.0 * (_rtrace_AtB(U, symZtAD) -
|
||||||
- _rtrace_AtB(ZtAZU, U_sZtD))
|
_rtrace_AtB(ZtAZU, U_sZtD))
|
||||||
|
|
||||||
d2E = 2 * (_rtrace_AtB(U, DtAD)
|
d2E = 2 * (_rtrace_AtB(U, DtAD) -
|
||||||
- _rtrace_AtB(ZtAZU, U @ (DtD - 4 * symZtD @ U_sZtD))
|
_rtrace_AtB(ZtAZU, U @ (DtD - 4 * symZtD @ U_sZtD)) -
|
||||||
- 4 * _rtrace_AtB(U, symZtAD @ U_sZtD))
|
4 * _rtrace_AtB(U, symZtAD @ U_sZtD))
|
||||||
|
|
||||||
# Newton-Raphson to find a root of the first derivative:
|
# Newton-Raphson to find a root of the first derivative:
|
||||||
theta = -dE / d2E
|
theta = -dE / d2E
|
||||||
|
|
@ -734,12 +722,7 @@ def eigsolve(
|
||||||
amax=pi,
|
amax=pi,
|
||||||
)
|
)
|
||||||
|
|
||||||
result = scipy.optimize.minimize_scalar(
|
result = scipy.optimize.minimize_scalar(trace_func, bounds=(0, pi), tol=tolerance)
|
||||||
trace_func,
|
|
||||||
method='bounded',
|
|
||||||
bounds=(0, pi),
|
|
||||||
options={'xatol': tolerance},
|
|
||||||
)
|
|
||||||
new_E = result.fun
|
new_E = result.fun
|
||||||
theta = result.x
|
theta = result.x
|
||||||
|
|
||||||
|
|
@ -768,7 +751,7 @@ def eigsolve(
|
||||||
v = eigvecs[:, i]
|
v = eigvecs[:, i]
|
||||||
n = eigvals[i]
|
n = eigvals[i]
|
||||||
v /= norm(v)
|
v /= norm(v)
|
||||||
Av = numpy.asarray(scipy_op @ v.copy()).reshape(-1)
|
Av = (scipy_op @ v.copy())[:, 0]
|
||||||
eigness = norm(Av - (v.conj() @ Av) * v)
|
eigness = norm(Av - (v.conj() @ Av) * v)
|
||||||
f = numpy.sqrt(-numpy.real(n))
|
f = numpy.sqrt(-numpy.real(n))
|
||||||
df = numpy.sqrt(-numpy.real(n) + eigness)
|
df = numpy.sqrt(-numpy.real(n) + eigness)
|
||||||
|
|
@ -798,7 +781,7 @@ def linmin(x_guess, f0, df0, x_max, f_tol=0.1, df_tol=min(tolerance, 1e-6), x_to
|
||||||
x_min, x_max, isave, dsave)
|
x_min, x_max, isave, dsave)
|
||||||
for i in range(int(1e6)):
|
for i in range(int(1e6)):
|
||||||
if task != 'F':
|
if task != 'F':
|
||||||
logging.info(f'search converged in {i} iterations')
|
logging.info('search converged in {} iterations'.format(i))
|
||||||
break
|
break
|
||||||
fx = f(x, dfx)
|
fx = f(x, dfx)
|
||||||
x, fx, dfx, task = minpack2.dsrch(x, fx, dfx, f_tol, df_tol, x_tol, task,
|
x, fx, dfx, task = minpack2.dsrch(x, fx, dfx, f_tol, df_tol, x_tol, task,
|
||||||
|
|
@ -816,62 +799,3 @@ def _rtrace_AtB(
|
||||||
def _symmetrize(A: NDArray[numpy.complex128]) -> NDArray[numpy.complex128]:
|
def _symmetrize(A: NDArray[numpy.complex128]) -> NDArray[numpy.complex128]:
|
||||||
return (A + A.conj().T) * 0.5
|
return (A + A.conj().T) * 0.5
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def inner_product(
|
|
||||||
eL: cfdfield,
|
|
||||||
hL: cfdfield,
|
|
||||||
eR: cfdfield,
|
|
||||||
hR: cfdfield,
|
|
||||||
) -> complex:
|
|
||||||
# assumes x-axis propagation
|
|
||||||
|
|
||||||
assert numpy.array_equal(eR.shape, hR.shape)
|
|
||||||
assert numpy.array_equal(eL.shape, hL.shape)
|
|
||||||
assert numpy.array_equal(eR.shape, eL.shape)
|
|
||||||
|
|
||||||
# Cross product, times 2 since it's <p | n>, then divide by 4. # TODO might want to abs() this?
|
|
||||||
norm2R = (eR[1] * hR[2] - eR[2] * hR[1]).sum() / 2
|
|
||||||
norm2L = (eL[1] * hL[2] - eL[2] * hL[1]).sum() / 2
|
|
||||||
|
|
||||||
# eRxhR_x = numpy.cross(eR.reshape(3, -1), hR.reshape(3, -1), axis=0).reshape(eR.shape)[0] / normR
|
|
||||||
# logger.info(f'power {eRxhR_x.sum() / 2})
|
|
||||||
|
|
||||||
eR_norm = eR / numpy.sqrt(abs(norm2R))
|
|
||||||
hR_norm = hR / numpy.sqrt(abs(norm2R))
|
|
||||||
eL_norm = eL / numpy.sqrt(abs(norm2L))
|
|
||||||
hL_norm = hL / numpy.sqrt(abs(norm2L))
|
|
||||||
|
|
||||||
# (eR x hL)[0] and (eL x hR)[0]
|
|
||||||
eRxhL_x = eR_norm[1] * hL_norm[2] - eR_norm[2] * hL_norm[1]
|
|
||||||
eLxhR_x = eL_norm[1] * hR_norm[2] - eL_norm[2] * hR_norm[1]
|
|
||||||
|
|
||||||
#return 1j * (eRxhL_x - eLxhR_x).sum() / numpy.sqrt(norm2R * norm2L)
|
|
||||||
#return (eRxhL_x.sum() - eLxhR_x.sum()) / numpy.sqrt(norm2R * norm2L)
|
|
||||||
return eLxhR_x.sum() - eRxhL_x.sum()
|
|
||||||
|
|
||||||
|
|
||||||
def trq(
|
|
||||||
eI: cfdfield,
|
|
||||||
hI: cfdfield,
|
|
||||||
eO: cfdfield,
|
|
||||||
hO: cfdfield,
|
|
||||||
) -> tuple[complex, complex]:
|
|
||||||
pp = inner_product(eO, hO, eI, hI)
|
|
||||||
pn = inner_product(eO, hO, eI, -hI)
|
|
||||||
np = inner_product(eO, -hO, eI, hI)
|
|
||||||
nn = inner_product(eO, -hO, eI, -hI)
|
|
||||||
|
|
||||||
assert numpy.allclose(pp, -nn, atol=1e-12, rtol=1e-12)
|
|
||||||
assert numpy.allclose(pn, -np, atol=1e-12, rtol=1e-12)
|
|
||||||
|
|
||||||
logger.info(f'''
|
|
||||||
{pp=:4g} {pn=:4g}
|
|
||||||
{nn=:4g} {np=:4g}
|
|
||||||
{nn * pp / pn=:4g} {-np=:4g}
|
|
||||||
''')
|
|
||||||
|
|
||||||
r = -pp / pn # -<Pp|Bp>/<Pn/Bp> = -(-pp) / (-pn)
|
|
||||||
t = (np - nn * pp / pn) / 4
|
|
||||||
|
|
||||||
return t, r
|
|
||||||
|
|
|
||||||
|
|
@ -1,895 +0,0 @@
|
||||||
"""
|
|
||||||
Low-level mode-matching helpers for waveguide / EME workflows.
|
|
||||||
|
|
||||||
These helpers operate on already-solved and already-normalized port fields.
|
|
||||||
They do not build geometries or solve modes themselves; downstream users are
|
|
||||||
expected to supply compatible `(E, H)` modal field pairs from
|
|
||||||
`waveguide_2d`, `waveguide_3d`, or `waveguide_cyl`.
|
|
||||||
|
|
||||||
The returned matrices follow the usual port ordering:
|
|
||||||
|
|
||||||
- `get_tr(...)` returns `(T, R)` for left-incident modes.
|
|
||||||
- `get_abcd(...)` returns the 2-port block transfer matrix built from the
|
|
||||||
interface continuity solve.
|
|
||||||
- `get_s(...)` returns the full block scattering matrix
|
|
||||||
`[[R_L, T_R_to_L], [T_L_to_R, R_R]]`.
|
|
||||||
- `get_taper_abcd(...)` and `get_taper_s(...)` build the same transfer /
|
|
||||||
scattering objects for sampled tapers using midpoint interfaces and stable
|
|
||||||
Redheffer cascading.
|
|
||||||
|
|
||||||
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
|
|
||||||
from typing import Literal
|
|
||||||
import dataclasses
|
|
||||||
import operator
|
|
||||||
import numpy
|
|
||||||
from numpy.typing import NDArray
|
|
||||||
from scipy import linalg
|
|
||||||
from scipy import sparse
|
|
||||||
|
|
||||||
from ..fdmath import dx_lists2_t, vcfdfield2
|
|
||||||
from .waveguide_2d import ModeBasis, inner_product, lorentz_overlap
|
|
||||||
|
|
||||||
type wavenumber_seq = Sequence[complex] | NDArray[numpy.complexfloating] | NDArray[numpy.floating]
|
|
||||||
type mode_seq = Sequence[Sequence[vcfdfield2]]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass(frozen=True, eq=False)
|
|
||||||
class ModeSection:
|
|
||||||
"""
|
|
||||||
Local modal basis at one longitudinal sample of a continuously varying guide.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
z: Longitudinal coordinate of this section.
|
|
||||||
modes: Forward modes as `(E, H)` field pairs.
|
|
||||||
wavenumbers: Forward propagation constants for `modes`.
|
|
||||||
backward_modes: Optional explicit backward modes. If omitted, backward
|
|
||||||
modes are synthesized as `(E, -H)`.
|
|
||||||
backward_wavenumbers: Optional propagation constants for
|
|
||||||
`backward_modes`. If omitted, they are synthesized as `-wavenumbers`.
|
|
||||||
dual_modes: Optional forward dual / adjoint projection modes. If
|
|
||||||
omitted, `modes` are used as their own projection basis.
|
|
||||||
dual_backward_modes: Optional backward dual / adjoint projection modes.
|
|
||||||
If omitted, they are synthesized from `dual_modes` when available,
|
|
||||||
otherwise from `backward_modes`.
|
|
||||||
wavenumber_matrix: Optional dense beta operator in the supplied forward
|
|
||||||
basis. If omitted, ``diag(wavenumbers)`` is used.
|
|
||||||
backward_wavenumber_matrix: Optional dense beta operator for explicit
|
|
||||||
backward modes. Synthesized reciprocal branches use ``-wavenumber_matrix``.
|
|
||||||
"""
|
|
||||||
|
|
||||||
z: float
|
|
||||||
modes: mode_seq = dataclasses.field(repr=False)
|
|
||||||
wavenumbers: wavenumber_seq
|
|
||||||
backward_modes: mode_seq | None = dataclasses.field(default=None, repr=False)
|
|
||||||
backward_wavenumbers: wavenumber_seq | None = None
|
|
||||||
dual_modes: mode_seq | None = dataclasses.field(default=None, repr=False)
|
|
||||||
dual_backward_modes: mode_seq | None = dataclasses.field(default=None, repr=False)
|
|
||||||
wavenumber_matrix: NDArray[numpy.complex128] | None = None
|
|
||||||
backward_wavenumber_matrix: NDArray[numpy.complex128] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_port_modes(
|
|
||||||
name: str,
|
|
||||||
ehs: mode_seq,
|
|
||||||
wavenumbers: wavenumber_seq,
|
|
||||||
) -> tuple[tuple[int, ...], tuple[int, ...]]:
|
|
||||||
"""Validate a nonempty modal list and return its common E/H shapes."""
|
|
||||||
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 _validate_dual_modes(
|
|
||||||
name: str,
|
|
||||||
dual_ehs: mode_seq | None,
|
|
||||||
reference_shape: tuple[int, ...],
|
|
||||||
wavenumbers: wavenumber_seq,
|
|
||||||
) -> mode_seq | None:
|
|
||||||
"""Validate optional dual modes against a primal modal field shape."""
|
|
||||||
if dual_ehs is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
dual_e_shape, dual_h_shape = _validate_port_modes(name, dual_ehs, wavenumbers)
|
|
||||||
if dual_e_shape != reference_shape or dual_h_shape != reference_shape:
|
|
||||||
raise ValueError(f'{name} modal fields must share the same E/H shapes as the corresponding modes')
|
|
||||||
return dual_ehs
|
|
||||||
|
|
||||||
|
|
||||||
def _as_wavenumber_array(
|
|
||||||
name: str,
|
|
||||||
wavenumbers: wavenumber_seq,
|
|
||||||
) -> NDArray[numpy.complex128]:
|
|
||||||
"""Convert finite propagation constants to a one-dimensional array."""
|
|
||||||
array = numpy.asarray(wavenumbers, dtype=complex)
|
|
||||||
if array.ndim != 1:
|
|
||||||
raise ValueError(f'{name} must be a one-dimensional sequence')
|
|
||||||
if not numpy.isfinite(array).all():
|
|
||||||
raise ValueError(f'{name} must contain only finite values')
|
|
||||||
return array
|
|
||||||
|
|
||||||
|
|
||||||
def _as_wavenumber_matrix(
|
|
||||||
name: str,
|
|
||||||
matrix: NDArray[numpy.complex128] | None,
|
|
||||||
wavenumbers: NDArray[numpy.complex128],
|
|
||||||
) -> NDArray[numpy.complex128]:
|
|
||||||
"""Return a validated propagation matrix or the diagonal spectrum."""
|
|
||||||
if matrix is None:
|
|
||||||
return numpy.diag(wavenumbers)
|
|
||||||
array = numpy.asarray(matrix, dtype=complex)
|
|
||||||
if array.shape != (wavenumbers.size, wavenumbers.size):
|
|
||||||
raise ValueError(f'{name} must have one row and column per mode')
|
|
||||||
if not numpy.isfinite(array).all():
|
|
||||||
raise ValueError(f'{name} must contain only finite values')
|
|
||||||
return array
|
|
||||||
|
|
||||||
|
|
||||||
def _as_mode_arrays(
|
|
||||||
ehs: mode_seq,
|
|
||||||
) -> list[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]]:
|
|
||||||
"""Copy a modal sequence into complex NumPy array views."""
|
|
||||||
return [
|
|
||||||
(numpy.array(e_field, dtype=complex, copy=True), numpy.array(h_field, dtype=complex, copy=True))
|
|
||||||
for e_field, h_field in ehs
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _phase_align_modes(
|
|
||||||
previous: Sequence[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]],
|
|
||||||
current: Sequence[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]],
|
|
||||||
dxes: dx_lists2_t,
|
|
||||||
previous_dual: Sequence[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]] | None = None,
|
|
||||||
*,
|
|
||||||
measure: Literal['physical', 'stretched'] = 'stretched',
|
|
||||||
) -> tuple[list[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]], NDArray[numpy.complex128]]:
|
|
||||||
"""Align current modal phases and return their diagonal basis transform."""
|
|
||||||
aligned = []
|
|
||||||
scales = []
|
|
||||||
test_modes = previous if previous_dual is None else previous_dual
|
|
||||||
for index, (previous_mode, current_mode, test_mode) in enumerate(zip(previous, current, test_modes, strict=True)):
|
|
||||||
overlap = lorentz_overlap(test_mode, current_mode, dxes, measure=measure)
|
|
||||||
self_overlap = lorentz_overlap(test_mode, previous_mode, dxes, measure=measure)
|
|
||||||
if overlap == 0:
|
|
||||||
raise ValueError(f'cannot phase-track mode {index}: adjacent section overlap is zero')
|
|
||||||
if self_overlap == 0:
|
|
||||||
raise ValueError(f'cannot phase-track mode {index}: mode dual-overlap is zero')
|
|
||||||
phase = (overlap / abs(overlap)) / (self_overlap / abs(self_overlap))
|
|
||||||
e_field, h_field = current_mode
|
|
||||||
scale = 1 / phase
|
|
||||||
scales.append(scale)
|
|
||||||
aligned.append((e_field * scale, h_field * scale))
|
|
||||||
return aligned, numpy.diag(scales)
|
|
||||||
|
|
||||||
|
|
||||||
def _section_branches(
|
|
||||||
section: ModeSection,
|
|
||||||
index: int,
|
|
||||||
expected_count: int | None,
|
|
||||||
expected_shape: tuple[int, ...] | None,
|
|
||||||
) -> tuple[
|
|
||||||
float,
|
|
||||||
list[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]],
|
|
||||||
NDArray[numpy.complex128],
|
|
||||||
list[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]],
|
|
||||||
NDArray[numpy.complex128],
|
|
||||||
list[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]],
|
|
||||||
list[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]],
|
|
||||||
NDArray[numpy.complex128],
|
|
||||||
NDArray[numpy.complex128],
|
|
||||||
tuple[int, ...],
|
|
||||||
]:
|
|
||||||
"""Validate one section and construct its forward and backward branches."""
|
|
||||||
z_coord = float(section.z)
|
|
||||||
if not numpy.isfinite(z_coord):
|
|
||||||
raise ValueError(f'sections[{index}].z must be finite')
|
|
||||||
|
|
||||||
shape, _h_shape = _validate_port_modes(f'sections[{index}].modes', section.modes, section.wavenumbers)
|
|
||||||
wavenumbers = _as_wavenumber_array(f'sections[{index}].wavenumbers', section.wavenumbers)
|
|
||||||
wavenumber_matrix = _as_wavenumber_matrix(f'sections[{index}].wavenumber_matrix', section.wavenumber_matrix, wavenumbers)
|
|
||||||
if expected_count is not None and len(wavenumbers) != expected_count:
|
|
||||||
raise ValueError('all taper sections must contain the same number of modes')
|
|
||||||
if expected_shape is not None and shape != expected_shape:
|
|
||||||
raise ValueError('all taper section modal fields must share the same E/H shapes')
|
|
||||||
|
|
||||||
if (section.backward_modes is None) != (section.backward_wavenumbers is None):
|
|
||||||
raise ValueError('backward_modes and backward_wavenumbers must be supplied together')
|
|
||||||
|
|
||||||
forward_modes = _as_mode_arrays(section.modes)
|
|
||||||
if section.backward_modes is None:
|
|
||||||
backward_modes = [(e_field.copy(), -h_field.copy()) for e_field, h_field in forward_modes]
|
|
||||||
backward_wavenumbers = -wavenumbers
|
|
||||||
if section.backward_wavenumber_matrix is not None:
|
|
||||||
raise ValueError('backward_wavenumber_matrix requires explicit backward modes and wavenumbers')
|
|
||||||
backward_wavenumber_matrix = -wavenumber_matrix
|
|
||||||
else:
|
|
||||||
backward_shape, _backward_h_shape = _validate_port_modes(
|
|
||||||
f'sections[{index}].backward_modes',
|
|
||||||
section.backward_modes,
|
|
||||||
section.backward_wavenumbers,
|
|
||||||
)
|
|
||||||
if backward_shape != shape:
|
|
||||||
raise ValueError('forward and backward modal fields must share the same E/H shapes')
|
|
||||||
backward_wavenumbers = _as_wavenumber_array(
|
|
||||||
f'sections[{index}].backward_wavenumbers',
|
|
||||||
section.backward_wavenumbers,
|
|
||||||
)
|
|
||||||
backward_modes = _as_mode_arrays(section.backward_modes)
|
|
||||||
backward_wavenumber_matrix = _as_wavenumber_matrix(
|
|
||||||
f'sections[{index}].backward_wavenumber_matrix',
|
|
||||||
section.backward_wavenumber_matrix,
|
|
||||||
backward_wavenumbers,
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(backward_wavenumbers) != len(wavenumbers):
|
|
||||||
raise ValueError('forward and backward mode counts must match')
|
|
||||||
|
|
||||||
if section.dual_modes is None:
|
|
||||||
dual_modes = forward_modes
|
|
||||||
else:
|
|
||||||
dual_shape, _dual_h_shape = _validate_port_modes(
|
|
||||||
f'sections[{index}].dual_modes',
|
|
||||||
section.dual_modes,
|
|
||||||
section.wavenumbers,
|
|
||||||
)
|
|
||||||
if dual_shape != shape:
|
|
||||||
raise ValueError('modal fields and dual modal fields must share the same E/H shapes')
|
|
||||||
dual_modes = _as_mode_arrays(section.dual_modes)
|
|
||||||
|
|
||||||
if section.dual_backward_modes is None:
|
|
||||||
if section.dual_modes is None and section.backward_modes is not None:
|
|
||||||
dual_backward_modes = backward_modes
|
|
||||||
else:
|
|
||||||
dual_backward_modes = [(e_field.copy(), -h_field.copy()) for e_field, h_field in dual_modes]
|
|
||||||
else:
|
|
||||||
dual_backward_shape, _dual_backward_h_shape = _validate_port_modes(
|
|
||||||
f'sections[{index}].dual_backward_modes',
|
|
||||||
section.dual_backward_modes,
|
|
||||||
section.backward_wavenumbers if section.backward_wavenumbers is not None else backward_wavenumbers,
|
|
||||||
)
|
|
||||||
if dual_backward_shape != shape:
|
|
||||||
raise ValueError('backward modal fields and dual backward modal fields must share the same E/H shapes')
|
|
||||||
dual_backward_modes = _as_mode_arrays(section.dual_backward_modes)
|
|
||||||
|
|
||||||
if len(dual_modes) != len(forward_modes) or len(dual_backward_modes) != len(backward_modes):
|
|
||||||
raise ValueError('dual mode counts must match the corresponding modal basis counts')
|
|
||||||
|
|
||||||
return (
|
|
||||||
z_coord, forward_modes, wavenumbers, backward_modes, backward_wavenumbers,
|
|
||||||
dual_modes, dual_backward_modes, wavenumber_matrix, backward_wavenumber_matrix, shape,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_taper_sections(
|
|
||||||
sections: Sequence[ModeSection],
|
|
||||||
dxes: dx_lists2_t,
|
|
||||||
*,
|
|
||||||
measure: Literal['physical', 'stretched'] = 'stretched',
|
|
||||||
) -> tuple[
|
|
||||||
NDArray[numpy.float64],
|
|
||||||
list[list[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]]],
|
|
||||||
list[list[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]]],
|
|
||||||
list[NDArray[numpy.complex128]],
|
|
||||||
int,
|
|
||||||
list[NDArray[numpy.complex128]],
|
|
||||||
]:
|
|
||||||
"""Validate, phase-align, and assemble all taper branch data."""
|
|
||||||
if len(sections) < 2:
|
|
||||||
raise ValueError('at least two taper sections are required')
|
|
||||||
|
|
||||||
z_coords: list[float] = []
|
|
||||||
branch_modes: list[list[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]]] = []
|
|
||||||
branch_dual_modes: list[list[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]]] = []
|
|
||||||
branch_wavenumbers: list[NDArray[numpy.complex128]] = []
|
|
||||||
expected_count: int | None = None
|
|
||||||
expected_shape: tuple[int, ...] | None = None
|
|
||||||
|
|
||||||
for index, section in enumerate(sections):
|
|
||||||
z_coord, forward_modes, forward_wavenumbers, backward_modes, backward_wavenumbers, dual_modes, dual_backward_modes, forward_matrix, backward_matrix, shape = _section_branches(
|
|
||||||
section,
|
|
||||||
index,
|
|
||||||
expected_count,
|
|
||||||
expected_shape,
|
|
||||||
)
|
|
||||||
if expected_count is None:
|
|
||||||
expected_count = len(forward_wavenumbers)
|
|
||||||
expected_shape = shape
|
|
||||||
z_coords.append(z_coord)
|
|
||||||
branch_modes.append([*forward_modes, *backward_modes])
|
|
||||||
branch_dual_modes.append([*dual_modes, *dual_backward_modes])
|
|
||||||
branch_wavenumbers.append(linalg.block_diag(forward_matrix, backward_matrix))
|
|
||||||
|
|
||||||
z_array = numpy.asarray(z_coords, dtype=float)
|
|
||||||
if not (numpy.diff(z_array) > 0).all():
|
|
||||||
raise ValueError('taper section z coordinates must be strictly increasing')
|
|
||||||
|
|
||||||
transforms = [numpy.eye(2 * expected_count, dtype=complex) for _ in sections]
|
|
||||||
for index in range(1, len(branch_modes)):
|
|
||||||
branch_modes[index], transform = _phase_align_modes(
|
|
||||||
branch_modes[index - 1], branch_modes[index], dxes, branch_dual_modes[index - 1], measure=measure,
|
|
||||||
)
|
|
||||||
transforms[index] = transform
|
|
||||||
inverse = numpy.linalg.inv(transform)
|
|
||||||
branch_dual_modes[index] = [
|
|
||||||
(e_field * inverse[row, row], h_field * inverse[row, row])
|
|
||||||
for row, (e_field, h_field) in enumerate(branch_dual_modes[index])
|
|
||||||
]
|
|
||||||
branch_wavenumbers[index] = inverse @ branch_wavenumbers[index] @ transform
|
|
||||||
|
|
||||||
assert expected_count is not None
|
|
||||||
return z_array, branch_modes, branch_dual_modes, branch_wavenumbers, expected_count, transforms
|
|
||||||
|
|
||||||
|
|
||||||
def _interface_s(
|
|
||||||
left: mode_seq,
|
|
||||||
right: mode_seq,
|
|
||||||
left_dual: mode_seq,
|
|
||||||
right_dual: mode_seq,
|
|
||||||
dxes: dx_lists2_t,
|
|
||||||
*,
|
|
||||||
formulation: Literal['mixed', 'galerkin'] = 'mixed',
|
|
||||||
measure: Literal['physical', 'stretched'] = 'stretched',
|
|
||||||
) -> NDArray[numpy.complex128]:
|
|
||||||
"""Solve projected tangential continuity for both incidences together.
|
|
||||||
|
|
||||||
Branches are ordered [forward, backward]. Mixed testing uses left electric
|
|
||||||
duals on H continuity and right magnetic duals on E continuity. In contrast,
|
|
||||||
Galerkin tests both equations against the left dual basis.
|
|
||||||
"""
|
|
||||||
nl, nr = len(left) // 2, len(right) // 2
|
|
||||||
if formulation not in ('mixed', 'galerkin'):
|
|
||||||
raise ValueError("formulation must be 'mixed' or 'galerkin'")
|
|
||||||
if formulation == 'galerkin' and nl != nr:
|
|
||||||
raise ValueError('Galerkin interfaces require equal port mode counts')
|
|
||||||
electric_tests = left_dual[:nl]
|
|
||||||
magnetic_tests = right_dual[:nr] if formulation == 'mixed' else left_dual[:nl]
|
|
||||||
|
|
||||||
def h_projection(modes: mode_seq) -> NDArray[numpy.complex128]:
|
|
||||||
return numpy.asarray([
|
|
||||||
[inner_product(test[0], mode[1], dxes, measure=measure) for mode in modes]
|
|
||||||
for test in electric_tests
|
|
||||||
])
|
|
||||||
|
|
||||||
def e_projection(modes: mode_seq) -> NDArray[numpy.complex128]:
|
|
||||||
return numpy.asarray([
|
|
||||||
[inner_product(mode[0], test[1], dxes, measure=measure) for mode in modes]
|
|
||||||
for test in magnetic_tests
|
|
||||||
])
|
|
||||||
|
|
||||||
hl, hr = h_projection(left), h_projection(right)
|
|
||||||
el, er = e_projection(left), e_projection(right)
|
|
||||||
outgoing = numpy.block([[hl[:, nl:], -hr[:, :nr]],
|
|
||||||
[el[:, nl:], -er[:, :nr]]])
|
|
||||||
incoming = numpy.block([[-hl[:, :nl], hr[:, nr:]],
|
|
||||||
[-el[:, :nl], er[:, nr:]]])
|
|
||||||
return _solve(outgoing, incoming, 'projected interface continuity')
|
|
||||||
|
|
||||||
|
|
||||||
def _solve(matrix: NDArray, rhs: NDArray, context: str) -> NDArray[numpy.complex128]:
|
|
||||||
"""Solve without rank truncation or hidden regularization."""
|
|
||||||
if not numpy.isfinite(matrix).all() or not numpy.isfinite(rhs).all():
|
|
||||||
raise ValueError(f'{context} requires finite matrices')
|
|
||||||
try:
|
|
||||||
result = numpy.linalg.solve(matrix, rhs)
|
|
||||||
except numpy.linalg.LinAlgError as error:
|
|
||||||
raise numpy.linalg.LinAlgError(f'{context}: singular system') from error
|
|
||||||
if not numpy.isfinite(result).all():
|
|
||||||
raise numpy.linalg.LinAlgError(f'{context}: result is not representable')
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _two_port_matrix(matrix: NDArray | sparse.sparray, n_modes: int | None) -> tuple[NDArray, int]:
|
|
||||||
array = numpy.asarray(matrix.toarray() if sparse.issparse(matrix) else matrix, dtype=complex)
|
|
||||||
if array.ndim != 2 or array.shape[0] != array.shape[1] or array.shape[0] % 2:
|
|
||||||
raise ValueError('transfer conversion requires a square matrix with equal port mode counts')
|
|
||||||
n = array.shape[0] // 2
|
|
||||||
if n == 0 or (n_modes is not None and n_modes != n):
|
|
||||||
raise ValueError('n_modes must match the nonempty equal-sized matrix blocks')
|
|
||||||
if not numpy.isfinite(array).all():
|
|
||||||
raise ValueError('matrix must contain only finite values')
|
|
||||||
return array, n
|
|
||||||
|
|
||||||
|
|
||||||
def abcd_to_s(abcd: NDArray | sparse.sparray, n_modes: int | None = None) -> NDArray[numpy.complex128]:
|
|
||||||
"""Convert [f_R, b_R] = ABCD @ [f_L, b_L] to standard port scattering.
|
|
||||||
|
|
||||||
Requires equal port counts and an invertible D block. Singular systems
|
|
||||||
raise ``LinAlgError`` rather than silently using a pseudoinverse.
|
|
||||||
"""
|
|
||||||
matrix, n = _two_port_matrix(abcd, n_modes)
|
|
||||||
a, b, c, d = matrix[:n, :n], matrix[:n, n:], matrix[n:, :n], matrix[n:, n:]
|
|
||||||
solved = _solve(d, numpy.concatenate((c, numpy.eye(n)), axis=1), 'ABCD to S conversion')
|
|
||||||
dc, di = solved[:, :n], solved[:, n:]
|
|
||||||
result = numpy.block([[-dc, di], [a - b @ dc, b @ di]])
|
|
||||||
if not numpy.isfinite(result).all():
|
|
||||||
raise numpy.linalg.LinAlgError('ABCD to S conversion: result is not representable')
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def s_to_abcd(ss: NDArray, n_modes: int | None = None) -> NDArray[numpy.complex128]:
|
|
||||||
"""Convert standard port scattering to [f_R, b_R] = ABCD @ [f_L, b_L].
|
|
||||||
|
|
||||||
Requires equal port counts and invertible right-to-left transmission.
|
|
||||||
Strongly attenuating devices can have finite S but unrepresentable ABCD.
|
|
||||||
"""
|
|
||||||
matrix, n = _two_port_matrix(ss, n_modes)
|
|
||||||
rl, trl, tlr, rr = matrix[:n, :n], matrix[:n, n:], matrix[n:, :n], matrix[n:, n:]
|
|
||||||
solved = _solve(trl, numpy.concatenate((rl, numpy.eye(n)), axis=1), 'S to ABCD conversion')
|
|
||||||
tr, ti = solved[:, :n], solved[:, n:]
|
|
||||||
result = numpy.block([[tlr - rr @ tr, rr @ ti], [-tr, ti]])
|
|
||||||
if not numpy.isfinite(result).all():
|
|
||||||
raise numpy.linalg.LinAlgError('S to ABCD conversion: result is not representable')
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def cascade_s(left: NDArray, right: NDArray, *, internal_modes: int) -> NDArray[numpy.complex128]:
|
|
||||||
"""Cascade spatially ordered two-port S matrices using the Redheffer product.
|
|
||||||
|
|
||||||
``internal_modes`` is the common right port of ``left`` and left port of
|
|
||||||
``right``. External port counts may differ. Matrices use outgoing rows and
|
|
||||||
incoming columns in [left, right] order. No transfer matrix is constructed.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
k = operator.index(internal_modes)
|
|
||||||
except TypeError as error:
|
|
||||||
raise ValueError('internal_modes must be a positive integer') from error
|
|
||||||
if isinstance(internal_modes, bool | numpy.bool_) or k <= 0:
|
|
||||||
raise ValueError('internal_modes must be a positive integer')
|
|
||||||
left, right = numpy.asarray(left, dtype=complex), numpy.asarray(right, dtype=complex)
|
|
||||||
for matrix in (left, right):
|
|
||||||
if matrix.ndim != 2 or matrix.shape[0] != matrix.shape[1] or matrix.shape[0] <= k:
|
|
||||||
raise ValueError('each S matrix must be square with a nonempty external port')
|
|
||||||
if not numpy.isfinite(matrix).all():
|
|
||||||
raise ValueError('S matrices must contain only finite values')
|
|
||||||
n = left.shape[0] - k
|
|
||||||
ral, tarl, talr, rar = left[:n, :n], left[:n, n:], left[n:, :n], left[n:, n:]
|
|
||||||
rbl, tbrl, tblr, rbr = right[:k, :k], right[:k, k:], right[k:, :k], right[k:, k:]
|
|
||||||
rhs = numpy.concatenate((talr, rar @ tbrl), axis=1)
|
|
||||||
solved = _solve(numpy.eye(k) - rar @ rbl, rhs, 'Redheffer internal feedback')
|
|
||||||
x, y = solved[:, :n], solved[:, n:]
|
|
||||||
result = numpy.block([[ral + tarl @ rbl @ x, tarl @ (tbrl + rbl @ y)],
|
|
||||||
[tblr @ x, rbr + tblr @ y]])
|
|
||||||
if not numpy.isfinite(result).all():
|
|
||||||
raise numpy.linalg.LinAlgError('Redheffer composition: result is not representable')
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _postprocess_s(ss: NDArray, force_nogain: bool, force_reciprocal: bool) -> NDArray[numpy.complex128]:
|
|
||||||
if force_nogain:
|
|
||||||
u, singular, vh = numpy.linalg.svd(ss)
|
|
||||||
ss = (u * numpy.minimum(singular, 1)) @ vh
|
|
||||||
if force_reciprocal:
|
|
||||||
ss = 0.5 * (ss + ss.T)
|
|
||||||
return ss
|
|
||||||
|
|
||||||
|
|
||||||
def get_s(
|
|
||||||
ehLs: mode_seq,
|
|
||||||
wavenumbers_L: wavenumber_seq,
|
|
||||||
ehRs: mode_seq,
|
|
||||||
wavenumbers_R: wavenumber_seq,
|
|
||||||
dxes: dx_lists2_t,
|
|
||||||
*,
|
|
||||||
formulation: Literal['mixed', 'galerkin'] = 'mixed',
|
|
||||||
measure: Literal['physical', 'stretched'] = 'stretched',
|
|
||||||
force_nogain: bool = False,
|
|
||||||
force_reciprocal: bool = False,
|
|
||||||
dual_ehLs: mode_seq | None = None,
|
|
||||||
dual_ehRs: mode_seq | None = None,
|
|
||||||
) -> NDArray[numpy.complex128]:
|
|
||||||
"""Solve an interface in standard [left, right] scattering order.
|
|
||||||
|
|
||||||
``S = [[R_L, T_R_to_L], [T_L_to_R, R_R]]`` maps incoming to outgoing
|
|
||||||
amplitudes. Mixed testing is the default and supports unequal port counts.
|
|
||||||
Galerkin testing requires equal counts. Both use full projection matrices.
|
|
||||||
|
|
||||||
``dxes`` and ``measure`` must match the supplied modal duals; omitted duals
|
|
||||||
use the primal fields. Wavenumbers label the modes and are not used in the
|
|
||||||
interface continuity equations. Forward modes imply backward (E, -H).
|
|
||||||
|
|
||||||
Mixed testing assigns different test families to the two ports. Incomplete
|
|
||||||
modal subspaces can therefore give different approximations after a port
|
|
||||||
exchange, even when each result is reciprocal and unitary. Establish basis
|
|
||||||
convergence, especially within coupled or degenerate blocks; do not infer
|
|
||||||
physical accuracy from these matrix constraints alone.
|
|
||||||
|
|
||||||
``force_nogain`` caps Euclidean singular values; ``force_reciprocal``
|
|
||||||
symmetrizes S. These optional numerical projections are not general power
|
|
||||||
or reciprocity tests for open, evanescent, or non-power-normalized bases.
|
|
||||||
No such projection is applied by default.
|
|
||||||
"""
|
|
||||||
shape, _ = _validate_port_modes('ehLs', ehLs, wavenumbers_L)
|
|
||||||
right_shape, _ = _validate_port_modes('ehRs', ehRs, wavenumbers_R)
|
|
||||||
if shape != right_shape:
|
|
||||||
raise ValueError('left and right modal fields must share the same E/H shapes')
|
|
||||||
_as_wavenumber_array('wavenumbers_L', wavenumbers_L)
|
|
||||||
_as_wavenumber_array('wavenumbers_R', wavenumbers_R)
|
|
||||||
dl = _validate_dual_modes('dual_ehLs', dual_ehLs, shape, wavenumbers_L)
|
|
||||||
dr = _validate_dual_modes('dual_ehRs', dual_ehRs, shape, wavenumbers_R)
|
|
||||||
left, right = _as_mode_arrays(ehLs), _as_mode_arrays(ehRs)
|
|
||||||
left_dual = left if dl is None else _as_mode_arrays(dl)
|
|
||||||
right_dual = right if dr is None else _as_mode_arrays(dr)
|
|
||||||
ss = _interface_s(
|
|
||||||
[*left, *((e, -h) for e, h in left)],
|
|
||||||
[*right, *((e, -h) for e, h in right)],
|
|
||||||
left_dual, right_dual, dxes, formulation=formulation, measure=measure,
|
|
||||||
)
|
|
||||||
return _postprocess_s(ss, force_nogain, force_reciprocal)
|
|
||||||
|
|
||||||
|
|
||||||
def get_tr(
|
|
||||||
ehLs: mode_seq,
|
|
||||||
wavenumbers_L: wavenumber_seq,
|
|
||||||
ehRs: mode_seq,
|
|
||||||
wavenumbers_R: wavenumber_seq,
|
|
||||||
dxes: dx_lists2_t,
|
|
||||||
*,
|
|
||||||
dual_ehLs: mode_seq | None = None,
|
|
||||||
dual_ehRs: mode_seq | None = None,
|
|
||||||
formulation: Literal['mixed', 'galerkin'] = 'mixed',
|
|
||||||
measure: Literal['physical', 'stretched'] = 'stretched',
|
|
||||||
) -> tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]:
|
|
||||||
"""Return (T_L_to_R, R_L) from the shared two-sided continuity solve.
|
|
||||||
|
|
||||||
Unlike the old implementation, transmission singular values are never
|
|
||||||
modified. Galerkin truncation can therefore expose apparent gain; mixed
|
|
||||||
testing is the default. Dual modes and the overlap measure follow get_s.
|
|
||||||
"""
|
|
||||||
ss = get_s(ehLs, wavenumbers_L, ehRs, wavenumbers_R, dxes,
|
|
||||||
dual_ehLs=dual_ehLs, dual_ehRs=dual_ehRs,
|
|
||||||
formulation=formulation, measure=measure)
|
|
||||||
n = len(ehLs)
|
|
||||||
return ss[n:, :n], ss[:n, :n]
|
|
||||||
|
|
||||||
|
|
||||||
def get_abcd(
|
|
||||||
ehLs: mode_seq,
|
|
||||||
wavenumbers_L: wavenumber_seq,
|
|
||||||
ehRs: mode_seq,
|
|
||||||
wavenumbers_R: wavenumber_seq,
|
|
||||||
dxes: dx_lists2_t,
|
|
||||||
*,
|
|
||||||
dual_ehLs: mode_seq | None = None,
|
|
||||||
dual_ehRs: mode_seq | None = None,
|
|
||||||
formulation: Literal['mixed', 'galerkin'] = 'mixed',
|
|
||||||
measure: Literal['physical', 'stretched'] = 'stretched',
|
|
||||||
) -> sparse.sparray:
|
|
||||||
"""Return [f_R, b_R] = ABCD @ [f_L, b_L] for an equal-count interface.
|
|
||||||
|
|
||||||
Prefer get_s for unequal ports or strongly attenuating devices.
|
|
||||||
"""
|
|
||||||
if len(ehLs) != len(ehRs):
|
|
||||||
raise ValueError('transfer conversion requires equal port mode counts')
|
|
||||||
ss = get_s(ehLs, wavenumbers_L, ehRs, wavenumbers_R, dxes,
|
|
||||||
dual_ehLs=dual_ehLs, dual_ehRs=dual_ehRs,
|
|
||||||
formulation=formulation, measure=measure)
|
|
||||||
return sparse.csr_array(s_to_abcd(ss))
|
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass(frozen=True, eq=False)
|
|
||||||
class PreparedTaper:
|
|
||||||
"""Read-only field-dependent data for repeated frozen-basis evaluation.
|
|
||||||
|
|
||||||
Arrays are copied and made read-only, including when constructed directly.
|
|
||||||
Beta matrices are stored in aligned bases. Evaluation overrides are in the
|
|
||||||
original section bases and receive the recorded similarity transforms.
|
|
||||||
Changing modal fields, grids, duals, or formulation requires preparation
|
|
||||||
again. Reusing these fields across wavelengths is an approximation.
|
|
||||||
"""
|
|
||||||
|
|
||||||
z_coords: NDArray[numpy.float64]
|
|
||||||
interfaces: NDArray[numpy.complex128] = dataclasses.field(repr=False)
|
|
||||||
wavenumber_matrices: NDArray[numpy.complex128] = dataclasses.field(repr=False)
|
|
||||||
backward_wavenumber_matrices: NDArray[numpy.complex128] = dataclasses.field(repr=False)
|
|
||||||
basis_transforms: NDArray[numpy.complex128] = dataclasses.field(repr=False)
|
|
||||||
reciprocal_branches: tuple[bool, ...]
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
for field in dataclasses.fields(self):
|
|
||||||
if field.name == 'reciprocal_branches':
|
|
||||||
object.__setattr__(self, field.name, tuple(self.reciprocal_branches))
|
|
||||||
continue
|
|
||||||
value = numpy.array(getattr(self, field.name), dtype=float if field.name == 'z_coords' else complex, copy=True)
|
|
||||||
if not numpy.isfinite(value).all():
|
|
||||||
raise ValueError(f'{field.name} must contain only finite values')
|
|
||||||
value.flags.writeable = False
|
|
||||||
object.__setattr__(self, field.name, value)
|
|
||||||
z, beta = self.z_coords, self.wavenumber_matrices
|
|
||||||
if z.ndim != 1 or z.size < 2 or not (numpy.diff(z) > 0).all():
|
|
||||||
raise ValueError('z_coords must contain at least two strictly increasing finite coordinates')
|
|
||||||
if beta.ndim != 3 or beta.shape[0] != z.size or beta.shape[1] != beta.shape[2] or beta.shape[1] == 0:
|
|
||||||
raise ValueError('wavenumber_matrices must have shape (section, mode, mode)')
|
|
||||||
n = beta.shape[1]
|
|
||||||
if self.backward_wavenumber_matrices.shape != beta.shape:
|
|
||||||
raise ValueError('backward matrix shape must match forward matrices')
|
|
||||||
if self.interfaces.shape != (z.size - 1, 2 * n, 2 * n):
|
|
||||||
raise ValueError('interface shape must match section and mode counts')
|
|
||||||
if self.basis_transforms.shape != (z.size, 2 * n, 2 * n):
|
|
||||||
raise ValueError('basis transform shape must match section and mode counts')
|
|
||||||
if len(self.reciprocal_branches) != z.size:
|
|
||||||
raise ValueError('reciprocal branch flags must match section count')
|
|
||||||
|
|
||||||
@property
|
|
||||||
def n_modes(self) -> int:
|
|
||||||
"""Number of modes in either external port."""
|
|
||||||
return self.wavenumber_matrices.shape[1]
|
|
||||||
|
|
||||||
|
|
||||||
def prepare_taper(
|
|
||||||
sections: Sequence[ModeSection],
|
|
||||||
dxes: dx_lists2_t,
|
|
||||||
*,
|
|
||||||
formulation: Literal['mixed', 'galerkin'] = 'mixed',
|
|
||||||
measure: Literal['physical', 'stretched'] = 'stretched',
|
|
||||||
) -> PreparedTaper:
|
|
||||||
"""Validate, align, and cache midpoint interfaces for sampled sections.
|
|
||||||
|
|
||||||
Section mode counts must agree. Fields and duals must already use a
|
|
||||||
consistent normalization and measure. No eigensolver is invoked.
|
|
||||||
"""
|
|
||||||
z, modes, duals, beta, n, transforms = _validate_taper_sections(sections, dxes, measure=measure)
|
|
||||||
interfaces = [
|
|
||||||
_interface_s(modes[i], modes[i + 1], duals[i], duals[i + 1], dxes,
|
|
||||||
formulation=formulation, measure=measure)
|
|
||||||
for i in range(len(sections) - 1)
|
|
||||||
]
|
|
||||||
return PreparedTaper(
|
|
||||||
z, numpy.asarray(interfaces), numpy.asarray([b[:n, :n] for b in beta]),
|
|
||||||
numpy.asarray([b[n:, n:] for b in beta]), numpy.asarray(transforms),
|
|
||||||
tuple(section.backward_modes is None for section in sections),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _propagation_s(forward: NDArray, backward: NDArray, length: float) -> NDArray[numpy.complex128]:
|
|
||||||
n = forward.shape[0]
|
|
||||||
zero = numpy.zeros((n, n), dtype=complex)
|
|
||||||
# Backward propagation is evaluated toward decreasing z, avoiding the
|
|
||||||
# exponentially growing transfer branch of passive leaky modes.
|
|
||||||
result = numpy.block([[zero, linalg.expm(1j * backward * length)],
|
|
||||||
[linalg.expm(-1j * forward * length), zero]])
|
|
||||||
if not numpy.isfinite(result).all():
|
|
||||||
raise numpy.linalg.LinAlgError('propagation S matrix is not representable')
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def evaluate_taper_s(
|
|
||||||
prepared: PreparedTaper,
|
|
||||||
*,
|
|
||||||
z_coords: Sequence[float] | None = None,
|
|
||||||
wavenumber_matrices: NDArray | None = None,
|
|
||||||
backward_wavenumber_matrices: NDArray | None = None,
|
|
||||||
force_nogain: bool = False,
|
|
||||||
force_reciprocal: bool = False,
|
|
||||||
) -> NDArray[numpy.complex128]:
|
|
||||||
"""Evaluate cached interfaces with optional lengths and beta overrides.
|
|
||||||
|
|
||||||
Overrides have shape (section, mode, mode) in the original section bases.
|
|
||||||
Forward overrides synthesize negative backward beta only for reciprocal
|
|
||||||
branches; explicit branches require backward overrides as well. This call
|
|
||||||
performs no field overlaps or mode solves. It is suitable for frozen-basis
|
|
||||||
wavelength approximations and exact length sweeps within the sampled model.
|
|
||||||
"""
|
|
||||||
z = prepared.z_coords if z_coords is None else numpy.asarray(z_coords, dtype=float)
|
|
||||||
if z.shape != prepared.z_coords.shape or not numpy.isfinite(z).all() or not (numpy.diff(z) > 0).all():
|
|
||||||
raise ValueError('z_coords must match section count and be finite and strictly increasing')
|
|
||||||
n = prepared.n_modes
|
|
||||||
|
|
||||||
def override(value: NDArray | None, default: NDArray, start: int) -> NDArray:
|
|
||||||
if value is None:
|
|
||||||
return default
|
|
||||||
value = numpy.asarray(value, dtype=complex)
|
|
||||||
if value.shape != default.shape or not numpy.isfinite(value).all():
|
|
||||||
raise ValueError('beta overrides must be finite with shape (section, mode, mode)')
|
|
||||||
transforms = prepared.basis_transforms[:, start:start + n, start:start + n]
|
|
||||||
return numpy.asarray([_solve(t, b @ t, 'beta basis transform') for t, b in zip(transforms, value, strict=True)])
|
|
||||||
|
|
||||||
forward = override(wavenumber_matrices, prepared.wavenumber_matrices, 0)
|
|
||||||
if wavenumber_matrices is not None and backward_wavenumber_matrices is None:
|
|
||||||
if not all(prepared.reciprocal_branches):
|
|
||||||
raise ValueError('forward overrides require backward overrides for explicit backward branches')
|
|
||||||
# Forward and backward phase tracking may choose different gauges.
|
|
||||||
backward_wavenumber_matrices = -numpy.asarray(wavenumber_matrices)
|
|
||||||
backward = override(backward_wavenumber_matrices, prepared.backward_wavenumber_matrices, n)
|
|
||||||
total = _propagation_s(forward[0], backward[0], 0)
|
|
||||||
for index, length in enumerate(numpy.diff(z)):
|
|
||||||
for ss in (
|
|
||||||
_propagation_s(forward[index], backward[index], length / 2),
|
|
||||||
prepared.interfaces[index],
|
|
||||||
_propagation_s(forward[index + 1], backward[index + 1], length / 2),
|
|
||||||
):
|
|
||||||
total = cascade_s(total, ss, internal_modes=n)
|
|
||||||
return _postprocess_s(total, force_nogain, force_reciprocal)
|
|
||||||
|
|
||||||
|
|
||||||
def get_taper_s(
|
|
||||||
sections: Sequence[ModeSection],
|
|
||||||
dxes: dx_lists2_t,
|
|
||||||
*,
|
|
||||||
formulation: Literal['mixed', 'galerkin'] = 'mixed',
|
|
||||||
measure: Literal['physical', 'stretched'] = 'stretched',
|
|
||||||
force_nogain: bool = False,
|
|
||||||
force_reciprocal: bool = False,
|
|
||||||
) -> NDArray[numpy.complex128]:
|
|
||||||
"""Sampled-taper S matrix using midpoint interfaces and Redheffer cascading.
|
|
||||||
|
|
||||||
Each interval propagates half its length in either adjacent basis. Accuracy
|
|
||||||
is controlled by section sampling and basis/PML convergence, not ODE
|
|
||||||
tolerances. Standard outgoing/incoming [left, right] ordering is used.
|
|
||||||
Longitudinal refinement alone cannot repair omitted coupled/degenerate
|
|
||||||
modes or remove the orientation bias of incomplete interface projections.
|
|
||||||
"""
|
|
||||||
prepared = prepare_taper(sections, dxes, formulation=formulation, measure=measure)
|
|
||||||
return evaluate_taper_s(prepared, force_nogain=force_nogain, force_reciprocal=force_reciprocal)
|
|
||||||
|
|
||||||
|
|
||||||
def get_taper_abcd(
|
|
||||||
sections: Sequence[ModeSection],
|
|
||||||
dxes: dx_lists2_t,
|
|
||||||
*,
|
|
||||||
formulation: Literal['mixed', 'galerkin'] = 'mixed',
|
|
||||||
measure: Literal['physical', 'stretched'] = 'stretched',
|
|
||||||
) -> sparse.sparray:
|
|
||||||
"""Convert sampled-taper S to ABCD, where representable.
|
|
||||||
|
|
||||||
Prefer get_taper_s for long lossy devices. Zero transmission or an
|
|
||||||
unrepresentable transfer matrix raises LinAlgError. The removed rtol, atol,
|
|
||||||
and max_step arguments never refined the former sampled CMT model.
|
|
||||||
"""
|
|
||||||
return sparse.csr_array(s_to_abcd(get_taper_s(sections, dxes, formulation=formulation, measure=measure)))
|
|
||||||
|
|
||||||
|
|
||||||
def mode_sections_from_bases(
|
|
||||||
z_coords: Sequence[float],
|
|
||||||
bases: Sequence[ModeBasis],
|
|
||||||
*,
|
|
||||||
mode_indices: Sequence[int] | None = None,
|
|
||||||
backward_bases: Sequence[ModeBasis] | None = None,
|
|
||||||
coupling_rtol: float = 1e-10,
|
|
||||||
) -> tuple[ModeSection, ...]:
|
|
||||||
"""Convert tracked complex-mode bases into EME taper sections.
|
|
||||||
|
|
||||||
The caller is responsible for tracking the bases first and for explicitly
|
|
||||||
power-normalizing any port modes whose scattering amplitudes will be
|
|
||||||
interpreted as power waves. When ``backward_bases`` is omitted, the normal
|
|
||||||
reciprocal ``(E, -H, -beta)`` branches are synthesized by ``ModeSection``.
|
|
||||||
A selected mode subset must be invariant under each basis's wavenumber
|
|
||||||
matrix; ``coupling_rtol`` controls the relative numerical coupling threshold.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
z_coords: Strictly increasing longitudinal coordinates for the bases.
|
|
||||||
bases: Equal-sized, already tracked forward modal bases.
|
|
||||||
mode_indices: Optional ordered subset of modal basis indices. Omit to
|
|
||||||
retain every mode.
|
|
||||||
backward_bases: Optional explicitly solved backward bases. When
|
|
||||||
omitted, reciprocal backward branches are synthesized later.
|
|
||||||
coupling_rtol: Relative threshold below which propagation-matrix
|
|
||||||
coupling across the selected subset is treated as numerical zero.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Immutable sequence of EME mode sections carrying primal, dual, and
|
|
||||||
full propagation-matrix data.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If sequence lengths, mode indices, tolerances, or selected
|
|
||||||
propagation blocks are incompatible.
|
|
||||||
"""
|
|
||||||
if len(z_coords) != len(bases):
|
|
||||||
raise ValueError('z_coords and bases must have the same length')
|
|
||||||
if len(bases) < 2:
|
|
||||||
raise ValueError('at least two bases are required')
|
|
||||||
if backward_bases is not None and len(backward_bases) != len(bases):
|
|
||||||
raise ValueError('backward_bases must have the same length as bases')
|
|
||||||
if not numpy.isfinite(coupling_rtol) or coupling_rtol <= 0:
|
|
||||||
raise ValueError('coupling_rtol must be finite and positive')
|
|
||||||
|
|
||||||
if mode_indices is None:
|
|
||||||
indices = numpy.arange(len(bases[0]), dtype=int)
|
|
||||||
else:
|
|
||||||
raw_indices = numpy.asarray(mode_indices)
|
|
||||||
if raw_indices.ndim != 1 or raw_indices.size == 0:
|
|
||||||
raise ValueError('mode_indices must be a nonempty one-dimensional sequence')
|
|
||||||
if any(isinstance(index, bool | numpy.bool_) for index in mode_indices):
|
|
||||||
raise ValueError('mode_indices entries must be integers')
|
|
||||||
try:
|
|
||||||
indices = numpy.asarray([operator.index(index) for index in mode_indices], dtype=int)
|
|
||||||
except TypeError as error:
|
|
||||||
raise ValueError('mode_indices entries must be integers') from error
|
|
||||||
if indices.ndim != 1 or indices.size == 0:
|
|
||||||
raise ValueError('mode_indices must be a nonempty one-dimensional sequence')
|
|
||||||
if numpy.any(indices < 0):
|
|
||||||
raise ValueError('mode_indices must be nonnegative')
|
|
||||||
if numpy.unique(indices).size != indices.size:
|
|
||||||
raise ValueError('mode_indices must not contain duplicates')
|
|
||||||
|
|
||||||
def validate_invariant_subset(matrix: NDArray[numpy.complex128], size: int, section_name: str) -> None:
|
|
||||||
"""Reject a subset coupled to excluded coordinates by its beta matrix."""
|
|
||||||
excluded = numpy.setdiff1d(numpy.arange(size), indices, assume_unique=False)
|
|
||||||
if excluded.size == 0:
|
|
||||||
return
|
|
||||||
scale = max(1.0, float(numpy.linalg.norm(matrix)))
|
|
||||||
coupling = max(
|
|
||||||
float(numpy.linalg.norm(matrix[numpy.ix_(indices, excluded)])),
|
|
||||||
float(numpy.linalg.norm(matrix[numpy.ix_(excluded, indices)])),
|
|
||||||
)
|
|
||||||
if coupling > coupling_rtol * scale:
|
|
||||||
raise ValueError(
|
|
||||||
f'mode_indices cut through a coupled wavenumber block in {section_name}; '
|
|
||||||
'select the complete coupled block',
|
|
||||||
)
|
|
||||||
|
|
||||||
sections: list[ModeSection] = []
|
|
||||||
for section_index, (z_coord, basis) in enumerate(zip(z_coords, bases, strict=True)):
|
|
||||||
if numpy.any(indices >= len(basis)):
|
|
||||||
raise ValueError(f'mode_indices are out of range for bases[{section_index}]')
|
|
||||||
validate_invariant_subset(basis.wavenumber_matrix, len(basis), f'bases[{section_index}]')
|
|
||||||
modes = [
|
|
||||||
(basis.electric_fields[index, 0], basis.magnetic_fields[index, 0])
|
|
||||||
for index in indices
|
|
||||||
]
|
|
||||||
dual_modes = [
|
|
||||||
(basis.electric_fields[index, 1], basis.magnetic_fields[index, 1])
|
|
||||||
for index in indices
|
|
||||||
]
|
|
||||||
wavenumbers = basis.wavenumbers[indices]
|
|
||||||
wavenumber_matrix = basis.wavenumber_matrix[numpy.ix_(indices, indices)]
|
|
||||||
if backward_bases is None:
|
|
||||||
sections.append(ModeSection(
|
|
||||||
z=float(z_coord),
|
|
||||||
modes=modes,
|
|
||||||
wavenumbers=wavenumbers,
|
|
||||||
dual_modes=dual_modes,
|
|
||||||
wavenumber_matrix=wavenumber_matrix,
|
|
||||||
))
|
|
||||||
continue
|
|
||||||
|
|
||||||
backward_basis = backward_bases[section_index]
|
|
||||||
if numpy.any(indices >= len(backward_basis)):
|
|
||||||
raise ValueError(f'mode_indices are out of range for backward_bases[{section_index}]')
|
|
||||||
validate_invariant_subset(
|
|
||||||
backward_basis.wavenumber_matrix,
|
|
||||||
len(backward_basis),
|
|
||||||
f'backward_bases[{section_index}]',
|
|
||||||
)
|
|
||||||
sections.append(ModeSection(
|
|
||||||
z=float(z_coord),
|
|
||||||
modes=modes,
|
|
||||||
wavenumbers=wavenumbers,
|
|
||||||
backward_modes=[
|
|
||||||
(backward_basis.electric_fields[index, 0], backward_basis.magnetic_fields[index, 0])
|
|
||||||
for index in indices
|
|
||||||
],
|
|
||||||
backward_wavenumbers=backward_basis.wavenumbers[indices],
|
|
||||||
dual_modes=dual_modes,
|
|
||||||
dual_backward_modes=[
|
|
||||||
(backward_basis.electric_fields[index, 1], backward_basis.magnetic_fields[index, 1])
|
|
||||||
for index in indices
|
|
||||||
],
|
|
||||||
wavenumber_matrix=wavenumber_matrix,
|
|
||||||
backward_wavenumber_matrix=backward_basis.wavenumber_matrix[numpy.ix_(indices, indices)],
|
|
||||||
))
|
|
||||||
return tuple(sections)
|
|
||||||
|
|
@ -1,24 +1,20 @@
|
||||||
"""
|
"""
|
||||||
Functions for performing near-to-farfield transformation (and the reverse).
|
Functions for performing near-to-farfield transformation (and the reverse).
|
||||||
"""
|
"""
|
||||||
from typing import Any, cast
|
from typing import Any, Sequence, cast
|
||||||
from collections.abc import Sequence
|
|
||||||
import numpy
|
import numpy
|
||||||
from numpy.fft import fft2, fftshift, fftfreq, ifft2, ifftshift
|
from numpy.fft import fft2, fftshift, fftfreq, ifft2, ifftshift
|
||||||
from numpy import pi
|
from numpy import pi
|
||||||
from numpy.typing import NDArray
|
|
||||||
from numpy import complexfloating
|
|
||||||
|
|
||||||
type farfield_slice = NDArray[complexfloating]
|
from ..fdmath import cfdfield_t
|
||||||
type transverse_slice_pair = Sequence[farfield_slice]
|
|
||||||
|
|
||||||
|
|
||||||
def near_to_farfield(
|
def near_to_farfield(
|
||||||
E_near: transverse_slice_pair,
|
E_near: cfdfield_t,
|
||||||
H_near: transverse_slice_pair,
|
H_near: cfdfield_t,
|
||||||
dx: float,
|
dx: float,
|
||||||
dy: float,
|
dy: float,
|
||||||
padded_size: Sequence[int] | int | None = None
|
padded_size: list[int] | int | None = None
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Compute the farfield, i.e. the distribution of the fields after propagation
|
Compute the farfield, i.e. the distribution of the fields after propagation
|
||||||
|
|
@ -59,14 +55,14 @@ def near_to_farfield(
|
||||||
raise Exception('H_near must be a length-2 list of ndarrays')
|
raise Exception('H_near must be a length-2 list of ndarrays')
|
||||||
|
|
||||||
s = E_near[0].shape
|
s = E_near[0].shape
|
||||||
if not all(s == f.shape for f in [*E_near, *H_near]):
|
if not all(s == f.shape for f in E_near + H_near):
|
||||||
raise Exception('All fields must be the same shape!')
|
raise Exception('All fields must be the same shape!')
|
||||||
|
|
||||||
if padded_size is None:
|
if padded_size is None:
|
||||||
padded_size = (2**numpy.ceil(numpy.log2(s))).astype(int)
|
padded_size = (2**numpy.ceil(numpy.log2(s))).astype(int)
|
||||||
if not hasattr(padded_size, '__len__'):
|
if not hasattr(padded_size, '__len__'):
|
||||||
padded_size = (padded_size, padded_size) # type: ignore # checked if sequence
|
padded_size = (padded_size, padded_size) # type: ignore # checked if sequence
|
||||||
padded_shape = cast('Sequence[int]', padded_size)
|
padded_shape = cast(Sequence[int], padded_size)
|
||||||
|
|
||||||
En_fft = [fftshift(fft2(fftshift(Eni), s=padded_shape)) for Eni in E_near]
|
En_fft = [fftshift(fft2(fftshift(Eni), s=padded_shape)) for Eni in E_near]
|
||||||
Hn_fft = [fftshift(fft2(fftshift(Hni), s=padded_shape)) for Hni in H_near]
|
Hn_fft = [fftshift(fft2(fftshift(Hni), s=padded_shape)) for Hni in H_near]
|
||||||
|
|
@ -79,22 +75,25 @@ def near_to_farfield(
|
||||||
kx, ky = numpy.meshgrid(kxx, kyy, indexing='ij')
|
kx, ky = numpy.meshgrid(kxx, kyy, indexing='ij')
|
||||||
kxy2 = kx * kx + ky * ky
|
kxy2 = kx * kx + ky * ky
|
||||||
kxy = numpy.sqrt(kxy2)
|
kxy = numpy.sqrt(kxy2)
|
||||||
kz = numpy.sqrt(numpy.maximum(0, k * k - kxy2))
|
kz = numpy.sqrt(k * k - kxy2)
|
||||||
|
|
||||||
sin_th = numpy.divide(ky, kxy, out=numpy.zeros_like(ky), where=kxy != 0)
|
sin_th = ky / kxy
|
||||||
cos_th = numpy.divide(kx, kxy, out=numpy.ones_like(kx), where=kxy != 0)
|
cos_th = kx / kxy
|
||||||
cos_phi = kz / k
|
cos_phi = kz / k
|
||||||
|
|
||||||
|
sin_th[numpy.logical_and(kx == 0, ky == 0)] = 0
|
||||||
|
cos_th[numpy.logical_and(kx == 0, ky == 0)] = 1
|
||||||
|
|
||||||
# Normalized vector potentials N, L
|
# Normalized vector potentials N, L
|
||||||
N = [-Hn_fft[1] * cos_phi * cos_th + Hn_fft[0] * cos_phi * sin_th,
|
N = [-Hn_fft[1] * cos_phi * cos_th + Hn_fft[0] * cos_phi * sin_th,
|
||||||
Hn_fft[1] * sin_th + Hn_fft[0] * cos_th] # noqa
|
Hn_fft[1] * sin_th + Hn_fft[0] * cos_th] # noqa: E127
|
||||||
L = [ En_fft[1] * cos_phi * cos_th - En_fft[0] * cos_phi * sin_th,
|
L = [ En_fft[1] * cos_phi * cos_th - En_fft[0] * cos_phi * sin_th,
|
||||||
-En_fft[1] * sin_th - En_fft[0] * cos_th] # noqa
|
-En_fft[1] * sin_th - En_fft[0] * cos_th] # noqa: E128
|
||||||
|
|
||||||
E_far = [-L[1] - N[0],
|
E_far = [-L[1] - N[0],
|
||||||
L[0] - N[1]] # noqa
|
L[0] - N[1]] # noqa: E127
|
||||||
H_far = [-E_far[1],
|
H_far = [-E_far[1],
|
||||||
E_far[0]] # noqa
|
E_far[0]] # noqa: E127
|
||||||
|
|
||||||
theta = numpy.arctan2(ky, kx)
|
theta = numpy.arctan2(ky, kx)
|
||||||
phi = numpy.arccos(cos_phi)
|
phi = numpy.arccos(cos_phi)
|
||||||
|
|
@ -112,8 +111,8 @@ def near_to_farfield(
|
||||||
outputs = {
|
outputs = {
|
||||||
'E': E_far,
|
'E': E_far,
|
||||||
'H': H_far,
|
'H': H_far,
|
||||||
'dkx': float(kxx[1] - kxx[0]),
|
'dkx': kx[1] - kx[0],
|
||||||
'dky': float(kyy[1] - kyy[0]),
|
'dky': ky[1] - ky[0],
|
||||||
'kx': kx,
|
'kx': kx,
|
||||||
'ky': ky,
|
'ky': ky,
|
||||||
'theta': theta,
|
'theta': theta,
|
||||||
|
|
@ -124,11 +123,11 @@ def near_to_farfield(
|
||||||
|
|
||||||
|
|
||||||
def far_to_nearfield(
|
def far_to_nearfield(
|
||||||
E_far: transverse_slice_pair,
|
E_far: cfdfield_t,
|
||||||
H_far: transverse_slice_pair,
|
H_far: cfdfield_t,
|
||||||
dkx: float,
|
dkx: float,
|
||||||
dky: float,
|
dky: float,
|
||||||
padded_size: Sequence[int] | int | None = None
|
padded_size: list[int] | int | None = None
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Compute the farfield, i.e. the distribution of the fields after propagation
|
Compute the farfield, i.e. the distribution of the fields after propagation
|
||||||
|
|
@ -165,29 +164,32 @@ def far_to_nearfield(
|
||||||
raise Exception('H_far must be a length-2 list of ndarrays')
|
raise Exception('H_far must be a length-2 list of ndarrays')
|
||||||
|
|
||||||
s = E_far[0].shape
|
s = E_far[0].shape
|
||||||
if not all(s == f.shape for f in [*E_far, *H_far]):
|
if not all(s == f.shape for f in E_far + H_far):
|
||||||
raise Exception('All fields must be the same shape!')
|
raise Exception('All fields must be the same shape!')
|
||||||
|
|
||||||
if padded_size is None:
|
if padded_size is None:
|
||||||
padded_size = (2 ** numpy.ceil(numpy.log2(s))).astype(int)
|
padded_size = (2 ** numpy.ceil(numpy.log2(s))).astype(int)
|
||||||
if not hasattr(padded_size, '__len__'):
|
if not hasattr(padded_size, '__len__'):
|
||||||
padded_size = (padded_size, padded_size) # type: ignore # checked if sequence
|
padded_size = (padded_size, padded_size) # type: ignore # checked if sequence
|
||||||
padded_shape = cast('Sequence[int]', padded_size)
|
padded_shape = cast(Sequence[int], padded_size)
|
||||||
|
|
||||||
k = 2 * pi
|
k = 2 * pi
|
||||||
kxs = dkx * fftshift(fftfreq(s[0], d=1 / s[0]))
|
kxs = fftshift(fftfreq(s[0], 1 / (s[0] * dkx)))
|
||||||
kys = dky * fftshift(fftfreq(s[1], d=1 / s[1]))
|
kys = fftshift(fftfreq(s[0], 1 / (s[1] * dky)))
|
||||||
|
|
||||||
kx, ky = numpy.meshgrid(kxs, kys, indexing='ij')
|
kx, ky = numpy.meshgrid(kxs, kys, indexing='ij')
|
||||||
kxy2 = kx * kx + ky * ky
|
kxy2 = kx * kx + ky * ky
|
||||||
kxy = numpy.sqrt(kxy2)
|
kxy = numpy.sqrt(kxy2)
|
||||||
|
|
||||||
kz = numpy.sqrt(numpy.maximum(0, k * k - kxy2))
|
kz = numpy.sqrt(k * k - kxy2)
|
||||||
|
|
||||||
sin_th = numpy.divide(ky, kxy, out=numpy.zeros_like(ky), where=kxy != 0)
|
sin_th = ky / kxy
|
||||||
cos_th = numpy.divide(kx, kxy, out=numpy.ones_like(kx), where=kxy != 0)
|
cos_th = kx / kxy
|
||||||
cos_phi = kz / k
|
cos_phi = kz / k
|
||||||
|
|
||||||
|
sin_th[numpy.logical_and(kx == 0, ky == 0)] = 0
|
||||||
|
cos_th[numpy.logical_and(kx == 0, ky == 0)] = 1
|
||||||
|
|
||||||
theta = numpy.arctan2(ky, kx)
|
theta = numpy.arctan2(ky, kx)
|
||||||
phi = numpy.arccos(cos_phi)
|
phi = numpy.arccos(cos_phi)
|
||||||
theta[numpy.logical_and(kx == 0, ky == 0)] = 0
|
theta[numpy.logical_and(kx == 0, ky == 0)] = 0
|
||||||
|
|
@ -203,45 +205,25 @@ def far_to_nearfield(
|
||||||
|
|
||||||
# Normalized vector potentials N, L
|
# Normalized vector potentials N, L
|
||||||
L = [0.5 * E_far[1],
|
L = [0.5 * E_far[1],
|
||||||
-0.5 * E_far[0]] # noqa
|
-0.5 * E_far[0]] # noqa: E128
|
||||||
N = [L[1],
|
N = [L[1],
|
||||||
-L[0]] # noqa
|
-L[0]] # noqa: E128
|
||||||
|
|
||||||
En_fft = [
|
En_fft = [-( L[0] * sin_th + L[1] * cos_phi * cos_th) / cos_phi,
|
||||||
numpy.divide(
|
-(-L[0] * cos_th + L[1] * cos_phi * sin_th) / cos_phi]
|
||||||
-(L[0] * sin_th + L[1] * cos_phi * cos_th),
|
|
||||||
cos_phi,
|
|
||||||
out=numpy.zeros_like(L[0]),
|
|
||||||
where=cos_phi != 0,
|
|
||||||
),
|
|
||||||
numpy.divide(
|
|
||||||
-(-L[0] * cos_th + L[1] * cos_phi * sin_th),
|
|
||||||
cos_phi,
|
|
||||||
out=numpy.zeros_like(L[0]),
|
|
||||||
where=cos_phi != 0,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
Hn_fft = [
|
Hn_fft = [( N[0] * sin_th + N[1] * cos_phi * cos_th) / cos_phi,
|
||||||
numpy.divide(
|
(-N[0] * cos_th + N[1] * cos_phi * sin_th) / cos_phi]
|
||||||
N[0] * sin_th + N[1] * cos_phi * cos_th,
|
|
||||||
cos_phi,
|
for i in range(2):
|
||||||
out=numpy.zeros_like(N[0]),
|
En_fft[i][cos_phi == 0] = 0
|
||||||
where=cos_phi != 0,
|
Hn_fft[i][cos_phi == 0] = 0
|
||||||
),
|
|
||||||
numpy.divide(
|
|
||||||
-N[0] * cos_th + N[1] * cos_phi * sin_th,
|
|
||||||
cos_phi,
|
|
||||||
out=numpy.zeros_like(N[0]),
|
|
||||||
where=cos_phi != 0,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
E_near = [ifftshift(ifft2(ifftshift(Ei), s=padded_shape)) for Ei in En_fft]
|
E_near = [ifftshift(ifft2(ifftshift(Ei), s=padded_shape)) for Ei in En_fft]
|
||||||
H_near = [ifftshift(ifft2(ifftshift(Hi), s=padded_shape)) for Hi in Hn_fft]
|
H_near = [ifftshift(ifft2(ifftshift(Hi), s=padded_shape)) for Hi in Hn_fft]
|
||||||
|
|
||||||
dx = 2 * pi / (s[0] * dkx)
|
dx = 2 * pi / (s[0] * dkx)
|
||||||
dy = 2 * pi / (s[1] * dky)
|
dy = 2 * pi / (s[0] * dky)
|
||||||
|
|
||||||
outputs = {
|
outputs = {
|
||||||
'E': E_near,
|
'E': E_near,
|
||||||
|
|
@ -251,3 +233,4 @@ def far_to_nearfield(
|
||||||
}
|
}
|
||||||
|
|
||||||
return outputs
|
return outputs
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,10 @@ Functional versions of many FDFD operators. These can be useful for performing
|
||||||
The functions generated here expect `cfdfield_t` inputs with shape (3, X, Y, Z),
|
The functions generated here expect `cfdfield_t` inputs with shape (3, X, Y, Z),
|
||||||
e.g. E = [E_x, E_y, E_z] where each (complex) component has shape (X, Y, Z)
|
e.g. E = [E_x, E_y, E_z] where each (complex) component has shape (X, Y, Z)
|
||||||
"""
|
"""
|
||||||
from collections.abc import Callable
|
from typing import Callable
|
||||||
import numpy
|
import numpy
|
||||||
|
|
||||||
from ..fdmath import dx_lists_t, cfdfield_t, fdfield, cfdfield, cfdfield_updater_t
|
from ..fdmath import dx_lists_t, fdfield_t, cfdfield_t, cfdfield_updater_t
|
||||||
from ..fdmath.functional import curl_forward, curl_back
|
from ..fdmath.functional import curl_forward, curl_back
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -18,8 +18,8 @@ __author__ = 'Jan Petykiewicz'
|
||||||
def e_full(
|
def e_full(
|
||||||
omega: complex,
|
omega: complex,
|
||||||
dxes: dx_lists_t,
|
dxes: dx_lists_t,
|
||||||
epsilon: fdfield,
|
epsilon: fdfield_t,
|
||||||
mu: fdfield | None = None,
|
mu: fdfield_t | None = None,
|
||||||
) -> cfdfield_updater_t:
|
) -> cfdfield_updater_t:
|
||||||
"""
|
"""
|
||||||
Wave operator for use with E-field. See `operators.e_full` for details.
|
Wave operator for use with E-field. See `operators.e_full` for details.
|
||||||
|
|
@ -37,25 +37,26 @@ def e_full(
|
||||||
ch = curl_back(dxes[1])
|
ch = curl_back(dxes[1])
|
||||||
ce = curl_forward(dxes[0])
|
ce = curl_forward(dxes[0])
|
||||||
|
|
||||||
def op_1(e: cfdfield) -> cfdfield_t:
|
def op_1(e: cfdfield_t) -> cfdfield_t:
|
||||||
curls = ch(ce(e))
|
curls = ch(ce(e))
|
||||||
return cfdfield_t(curls - omega ** 2 * epsilon * e)
|
return curls - omega ** 2 * epsilon * e
|
||||||
|
|
||||||
def op_mu(e: cfdfield_t) -> cfdfield_t:
|
def op_mu(e: cfdfield_t) -> cfdfield_t:
|
||||||
curls = ch(ce(e) / mu) # type: ignore # mu = None ok because we don't return the function
|
curls = ch(mu * ce(e)) # type: ignore # mu = None ok because we don't return the function
|
||||||
return cfdfield_t(curls - omega ** 2 * epsilon * e)
|
return curls - omega ** 2 * epsilon * e
|
||||||
|
|
||||||
if mu is None:
|
if mu is None:
|
||||||
return op_1
|
return op_1
|
||||||
return op_mu
|
else:
|
||||||
|
return op_mu
|
||||||
|
|
||||||
|
|
||||||
def eh_full(
|
def eh_full(
|
||||||
omega: complex,
|
omega: complex,
|
||||||
dxes: dx_lists_t,
|
dxes: dx_lists_t,
|
||||||
epsilon: fdfield,
|
epsilon: fdfield_t,
|
||||||
mu: fdfield | None = None,
|
mu: fdfield_t | None = None,
|
||||||
) -> Callable[[cfdfield, cfdfield], tuple[cfdfield_t, cfdfield_t]]:
|
) -> Callable[[cfdfield_t, cfdfield_t], tuple[cfdfield_t, cfdfield_t]]:
|
||||||
"""
|
"""
|
||||||
Wave operator for full (both E and H) field representation.
|
Wave operator for full (both E and H) field representation.
|
||||||
See `operators.eh_full`.
|
See `operators.eh_full`.
|
||||||
|
|
@ -73,23 +74,24 @@ def eh_full(
|
||||||
ch = curl_back(dxes[1])
|
ch = curl_back(dxes[1])
|
||||||
ce = curl_forward(dxes[0])
|
ce = curl_forward(dxes[0])
|
||||||
|
|
||||||
def op_1(e: cfdfield, h: cfdfield) -> tuple[cfdfield_t, cfdfield_t]:
|
def op_1(e: cfdfield_t, h: cfdfield_t) -> tuple[cfdfield_t, cfdfield_t]:
|
||||||
return (cfdfield_t(ch(h) - 1j * omega * epsilon * e),
|
return (ch(h) - 1j * omega * epsilon * e,
|
||||||
cfdfield_t(ce(e) + 1j * omega * h))
|
ce(e) + 1j * omega * h)
|
||||||
|
|
||||||
def op_mu(e: cfdfield, h: cfdfield) -> tuple[cfdfield_t, cfdfield_t]:
|
def op_mu(e: cfdfield_t, h: cfdfield_t) -> tuple[cfdfield_t, cfdfield_t]:
|
||||||
return (cfdfield_t(ch(h) - 1j * omega * epsilon * e),
|
return (ch(h) - 1j * omega * epsilon * e,
|
||||||
cfdfield_t(ce(e) + 1j * omega * mu * h)) # type: ignore # mu=None ok
|
ce(e) + 1j * omega * mu * h) # type: ignore # mu=None ok
|
||||||
|
|
||||||
if mu is None:
|
if mu is None:
|
||||||
return op_1
|
return op_1
|
||||||
return op_mu
|
else:
|
||||||
|
return op_mu
|
||||||
|
|
||||||
|
|
||||||
def e2h(
|
def e2h(
|
||||||
omega: complex,
|
omega: complex,
|
||||||
dxes: dx_lists_t,
|
dxes: dx_lists_t,
|
||||||
mu: fdfield | None = None,
|
mu: fdfield_t | None = None,
|
||||||
) -> cfdfield_updater_t:
|
) -> cfdfield_updater_t:
|
||||||
"""
|
"""
|
||||||
Utility operator for converting the `E` field into the `H` field.
|
Utility operator for converting the `E` field into the `H` field.
|
||||||
|
|
@ -106,21 +108,22 @@ def e2h(
|
||||||
"""
|
"""
|
||||||
ce = curl_forward(dxes[0])
|
ce = curl_forward(dxes[0])
|
||||||
|
|
||||||
def e2h_1_1(e: cfdfield) -> cfdfield_t:
|
def e2h_1_1(e: cfdfield_t) -> cfdfield_t:
|
||||||
return cfdfield_t(ce(e) / (-1j * omega))
|
return ce(e) / (-1j * omega)
|
||||||
|
|
||||||
def e2h_mu(e: cfdfield) -> cfdfield_t:
|
def e2h_mu(e: cfdfield_t) -> cfdfield_t:
|
||||||
return cfdfield_t(ce(e) / (-1j * omega * mu)) # type: ignore # mu=None ok
|
return ce(e) / (-1j * omega * mu) # type: ignore # mu=None ok
|
||||||
|
|
||||||
if mu is None:
|
if mu is None:
|
||||||
return e2h_1_1
|
return e2h_1_1
|
||||||
return e2h_mu
|
else:
|
||||||
|
return e2h_mu
|
||||||
|
|
||||||
|
|
||||||
def m2j(
|
def m2j(
|
||||||
omega: complex,
|
omega: complex,
|
||||||
dxes: dx_lists_t,
|
dxes: dx_lists_t,
|
||||||
mu: fdfield | None = None,
|
mu: fdfield_t | None = None,
|
||||||
) -> cfdfield_updater_t:
|
) -> cfdfield_updater_t:
|
||||||
"""
|
"""
|
||||||
Utility operator for converting magnetic current `M` distribution
|
Utility operator for converting magnetic current `M` distribution
|
||||||
|
|
@ -139,42 +142,30 @@ def m2j(
|
||||||
ch = curl_back(dxes[1])
|
ch = curl_back(dxes[1])
|
||||||
|
|
||||||
def m2j_mu(m: cfdfield_t) -> cfdfield_t:
|
def m2j_mu(m: cfdfield_t) -> cfdfield_t:
|
||||||
J = ch(m / mu) / (1j * omega) # type: ignore # mu=None ok
|
J = ch(m / mu) / (-1j * omega) # type: ignore # mu=None ok
|
||||||
return cfdfield_t(J)
|
return J
|
||||||
|
|
||||||
def m2j_1(m: cfdfield_t) -> cfdfield_t:
|
def m2j_1(m: cfdfield_t) -> cfdfield_t:
|
||||||
J = ch(m) / (1j * omega)
|
J = ch(m) / (-1j * omega)
|
||||||
return cfdfield_t(J)
|
return J
|
||||||
|
|
||||||
if mu is None:
|
if mu is None:
|
||||||
return m2j_1
|
return m2j_1
|
||||||
return m2j_mu
|
else:
|
||||||
|
return m2j_mu
|
||||||
|
|
||||||
|
|
||||||
def e_tfsf_source(
|
def e_tfsf_source(
|
||||||
TF_region: fdfield,
|
TF_region: fdfield_t,
|
||||||
omega: complex,
|
omega: complex,
|
||||||
dxes: dx_lists_t,
|
dxes: dx_lists_t,
|
||||||
epsilon: fdfield,
|
epsilon: fdfield_t,
|
||||||
mu: fdfield | None = None,
|
mu: fdfield_t | None = None,
|
||||||
) -> cfdfield_updater_t:
|
) -> cfdfield_updater_t:
|
||||||
r"""
|
"""
|
||||||
Operator that turns an E-field distribution into a total-field/scattered-field
|
Operator that turns an E-field distribution into a total-field/scattered-field
|
||||||
(TFSF) source.
|
(TFSF) source.
|
||||||
|
|
||||||
If `A` is the full wave operator from `e_full(...)` and `Q` is the diagonal
|
|
||||||
mask selecting the total-field region, then the TFSF source is the commutator
|
|
||||||
|
|
||||||
$$
|
|
||||||
\frac{A Q - Q A}{-i \omega} E.
|
|
||||||
$$
|
|
||||||
|
|
||||||
This vanishes in the interior of the total-field and scattered-field regions
|
|
||||||
and is supported only at their shared boundary, where the mask discontinuity
|
|
||||||
makes `A` and `Q` fail to commute. The returned current is therefore the
|
|
||||||
distributed source needed to inject the desired total field without also
|
|
||||||
forcing the scattered-field region.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
TF_region: mask which is set to 1 in the total-field region, and 0 elsewhere
|
TF_region: mask which is set to 1 in the total-field region, and 0 elsewhere
|
||||||
(i.e. in the scattered-field region).
|
(i.e. in the scattered-field region).
|
||||||
|
|
@ -188,25 +179,20 @@ def e_tfsf_source(
|
||||||
Function `f` which takes an E field and returns a current distribution,
|
Function `f` which takes an E field and returns a current distribution,
|
||||||
`f(E)` -> `J`
|
`f(E)` -> `J`
|
||||||
"""
|
"""
|
||||||
|
# TODO documentation
|
||||||
A = e_full(omega, dxes, epsilon, mu)
|
A = e_full(omega, dxes, epsilon, mu)
|
||||||
|
|
||||||
def op(e: cfdfield) -> cfdfield_t:
|
def op(e: cfdfield_t) -> cfdfield_t:
|
||||||
neg_iwj = A(TF_region * e) - TF_region * A(e)
|
neg_iwj = A(TF_region * e) - TF_region * A(e)
|
||||||
return cfdfield_t(neg_iwj / (-1j * omega))
|
return neg_iwj / (-1j * omega)
|
||||||
return op
|
return op
|
||||||
|
|
||||||
|
|
||||||
def poynting_e_cross_h(dxes: dx_lists_t) -> Callable[[cfdfield, cfdfield], cfdfield_t]:
|
def poynting_e_cross_h(dxes: dx_lists_t) -> Callable[[cfdfield_t, cfdfield_t], cfdfield_t]:
|
||||||
r"""
|
"""
|
||||||
Generates a function that takes the single-frequency `E` and `H` fields
|
Generates a function that takes the single-frequency `E` and `H` fields
|
||||||
and calculates the cross product `E` x `H` = $E \times H$ as required
|
and calculates the cross product `E` x `H` = $E \\times H$ as required
|
||||||
for the Poynting vector, $S = E \times H$.
|
for the Poynting vector, $S = E \\times H$
|
||||||
|
|
||||||
On the Yee grid, the electric and magnetic components are not stored at the
|
|
||||||
same locations. This helper therefore applies the same one-cell electric-field
|
|
||||||
shifts used by the sparse `operators.poynting_e_cross(...)` construction so
|
|
||||||
that the discrete cross product matches the face-centered energy flux used in
|
|
||||||
`meanas.fdtd.energy.poynting(...)`.
|
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
This function also shifts the input `E` field by one cell as required
|
This function also shifts the input `E` field by one cell as required
|
||||||
|
|
@ -214,7 +200,7 @@ def poynting_e_cross_h(dxes: dx_lists_t) -> Callable[[cfdfield, cfdfield], cfdfi
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
If `E` and `H` are peak amplitudes as assumed elsewhere in this code,
|
If `E` and `H` are peak amplitudes as assumed elsewhere in this code,
|
||||||
the time-average of the poynting vector is `<S> = Re(S)/2 = Re(E x H*) / 2`.
|
the time-average of the poynting vector is `<S> = Re(S)/2 = Re(E x H) / 2`.
|
||||||
The factor of `1/2` can be omitted if root-mean-square quantities are used
|
The factor of `1/2` can be omitted if root-mean-square quantities are used
|
||||||
instead.
|
instead.
|
||||||
|
|
||||||
|
|
@ -222,10 +208,9 @@ def poynting_e_cross_h(dxes: dx_lists_t) -> Callable[[cfdfield, cfdfield], cfdfi
|
||||||
dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types`
|
dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types`
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Function `f` that returns the staggered-grid cross product `E \times H`.
|
Function `f` that returns E x H as required for the poynting vector.
|
||||||
For time-average power, call it as `f(E, H.conj())` and take `Re(...) / 2`.
|
|
||||||
"""
|
"""
|
||||||
def exh(e: cfdfield, h: cfdfield) -> cfdfield_t:
|
def exh(e: cfdfield_t, h: cfdfield_t) -> cfdfield_t:
|
||||||
s = numpy.empty_like(e)
|
s = numpy.empty_like(e)
|
||||||
ex = e[0] * dxes[0][0][:, None, None]
|
ex = e[0] * dxes[0][0][:, None, None]
|
||||||
ey = e[1] * dxes[0][1][None, :, None]
|
ey = e[1] * dxes[0][1][None, :, None]
|
||||||
|
|
@ -236,5 +221,5 @@ def poynting_e_cross_h(dxes: dx_lists_t) -> Callable[[cfdfield, cfdfield], cfdfi
|
||||||
s[0] = numpy.roll(ey, -1, axis=0) * hz - numpy.roll(ez, -1, axis=0) * hy
|
s[0] = numpy.roll(ey, -1, axis=0) * hz - numpy.roll(ez, -1, axis=0) * hy
|
||||||
s[1] = numpy.roll(ez, -1, axis=1) * hx - numpy.roll(ex, -1, axis=1) * hz
|
s[1] = numpy.roll(ez, -1, axis=1) * hx - numpy.roll(ex, -1, axis=1) * hz
|
||||||
s[2] = numpy.roll(ex, -1, axis=2) * hy - numpy.roll(ey, -1, axis=2) * hx
|
s[2] = numpy.roll(ex, -1, axis=2) * hy - numpy.roll(ey, -1, axis=2) * hx
|
||||||
return cfdfield_t(s)
|
return s
|
||||||
return exh
|
return exh
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""
|
"""
|
||||||
Sparse matrix operators for use with electromagnetic wave equations.
|
Sparse matrix operators for use with electromagnetic wave equations.
|
||||||
|
|
||||||
These functions return sparse-matrix (`scipy.sparse.sparray`) representations of
|
These functions return sparse-matrix (`scipy.sparse.spmatrix`) representations of
|
||||||
a variety of operators, intended for use with E and H fields vectorized using the
|
a variety of operators, intended for use with E and H fields vectorized using the
|
||||||
`meanas.fdmath.vectorization.vec()` and `meanas.fdmath.vectorization.unvec()` functions.
|
`meanas.fdmath.vectorization.vec()` and `meanas.fdmath.vectorization.unvec()` functions.
|
||||||
|
|
||||||
|
|
@ -28,9 +28,9 @@ The following operators are included:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import numpy
|
import numpy
|
||||||
from scipy import sparse
|
import scipy.sparse as sparse # type: ignore
|
||||||
|
|
||||||
from ..fdmath import vec, dx_lists_t, vfdfield, vcfdfield
|
from ..fdmath import vec, dx_lists_t, vfdfield_t, vcfdfield_t
|
||||||
from ..fdmath.operators import shift_with_mirror, shift_circ, curl_forward, curl_back
|
from ..fdmath.operators import shift_with_mirror, shift_circ, curl_forward, curl_back
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -40,19 +40,19 @@ __author__ = 'Jan Petykiewicz'
|
||||||
def e_full(
|
def e_full(
|
||||||
omega: complex,
|
omega: complex,
|
||||||
dxes: dx_lists_t,
|
dxes: dx_lists_t,
|
||||||
epsilon: vfdfield | vcfdfield,
|
epsilon: vfdfield_t,
|
||||||
mu: vfdfield | None = None,
|
mu: vfdfield_t | None = None,
|
||||||
pec: vfdfield | None = None,
|
pec: vfdfield_t | None = None,
|
||||||
pmc: vfdfield | None = None,
|
pmc: vfdfield_t | None = None,
|
||||||
) -> sparse.sparray:
|
) -> sparse.spmatrix:
|
||||||
r"""
|
"""
|
||||||
Wave operator
|
Wave operator
|
||||||
$$ \nabla \times (\frac{1}{\mu} \nabla \times) - \Omega^2 \epsilon $$
|
$$ \\nabla \\times (\\frac{1}{\\mu} \\nabla \\times) - \\Omega^2 \\epsilon $$
|
||||||
|
|
||||||
del x (1/mu * del x) - omega**2 * epsilon
|
del x (1/mu * del x) - omega**2 * epsilon
|
||||||
|
|
||||||
for use with the E-field, with wave equation
|
for use with the E-field, with wave equation
|
||||||
$$ (\nabla \times (\frac{1}{\mu} \nabla \times) - \Omega^2 \epsilon) E = -\imath \omega J $$
|
$$ (\\nabla \\times (\\frac{1}{\\mu} \\nabla \\times) - \\Omega^2 \\epsilon) E = -\\imath \\omega J $$
|
||||||
|
|
||||||
(del x (1/mu * del x) - omega**2 * epsilon) E = -i * omega * J
|
(del x (1/mu * del x) - omega**2 * epsilon) E = -i * omega * J
|
||||||
|
|
||||||
|
|
@ -64,11 +64,11 @@ def e_full(
|
||||||
epsilon: Vectorized dielectric constant
|
epsilon: Vectorized dielectric constant
|
||||||
mu: Vectorized magnetic permeability (default 1 everywhere).
|
mu: Vectorized magnetic permeability (default 1 everywhere).
|
||||||
pec: Vectorized mask specifying PEC cells. Any cells where `pec != 0` are interpreted
|
pec: Vectorized mask specifying PEC cells. Any cells where `pec != 0` are interpreted
|
||||||
as containing a perfect electrical conductor (PEC).
|
as containing a perfect electrical conductor (PEC).
|
||||||
The PEC is applied per-field-component (i.e. `pec.size == epsilon.size`)
|
The PEC is applied per-field-component (i.e. `pec.size == epsilon.size`)
|
||||||
pmc: Vectorized mask specifying PMC cells. Any cells where `pmc != 0` are interpreted
|
pmc: Vectorized mask specifying PMC cells. Any cells where `pmc != 0` are interpreted
|
||||||
as containing a perfect magnetic conductor (PMC).
|
as containing a perfect magnetic conductor (PMC).
|
||||||
The PMC is applied per-field-component (i.e. `pmc.size == epsilon.size`)
|
The PMC is applied per-field-component (i.e. `pmc.size == epsilon.size`)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Sparse matrix containing the wave operator.
|
Sparse matrix containing the wave operator.
|
||||||
|
|
@ -77,20 +77,20 @@ def e_full(
|
||||||
ce = curl_forward(dxes[0])
|
ce = curl_forward(dxes[0])
|
||||||
|
|
||||||
if pec is None:
|
if pec is None:
|
||||||
pe = sparse.eye_array(epsilon.size)
|
pe = sparse.eye(epsilon.size)
|
||||||
else:
|
else:
|
||||||
pe = sparse.diags_array(numpy.where(pec, 0, 1)) # Set pe to (not PEC)
|
pe = sparse.diags(numpy.where(pec, 0, 1)) # Set pe to (not PEC)
|
||||||
|
|
||||||
if pmc is None:
|
if pmc is None:
|
||||||
pm = sparse.eye_array(epsilon.size)
|
pm = sparse.eye(epsilon.size)
|
||||||
else:
|
else:
|
||||||
pm = sparse.diags_array(numpy.where(pmc, 0, 1)) # set pm to (not PMC)
|
pm = sparse.diags(numpy.where(pmc, 0, 1)) # set pm to (not PMC)
|
||||||
|
|
||||||
e = sparse.diags_array(epsilon)
|
e = sparse.diags(epsilon)
|
||||||
if mu is None:
|
if mu is None:
|
||||||
m_div = sparse.eye_array(epsilon.size)
|
m_div = sparse.eye(epsilon.size)
|
||||||
else:
|
else:
|
||||||
m_div = sparse.diags_array(1 / mu)
|
m_div = sparse.diags(1 / mu)
|
||||||
|
|
||||||
op = pe @ (ch @ pm @ m_div @ ce - omega**2 * e) @ pe
|
op = pe @ (ch @ pm @ m_div @ ce - omega**2 * e) @ pe
|
||||||
return op
|
return op
|
||||||
|
|
@ -98,7 +98,7 @@ def e_full(
|
||||||
|
|
||||||
def e_full_preconditioners(
|
def e_full_preconditioners(
|
||||||
dxes: dx_lists_t,
|
dxes: dx_lists_t,
|
||||||
) -> tuple[sparse.sparray, sparse.sparray]:
|
) -> tuple[sparse.spmatrix, sparse.spmatrix]:
|
||||||
"""
|
"""
|
||||||
Left and right preconditioners `(Pl, Pr)` for symmetrizing the `e_full` wave operator.
|
Left and right preconditioners `(Pl, Pr)` for symmetrizing the `e_full` wave operator.
|
||||||
|
|
||||||
|
|
@ -118,27 +118,27 @@ def e_full_preconditioners(
|
||||||
dxes[1][0][:, None, None] * dxes[1][1][None, :, None] * dxes[0][2][None, None, :]]
|
dxes[1][0][:, None, None] * dxes[1][1][None, :, None] * dxes[0][2][None, None, :]]
|
||||||
|
|
||||||
p_vector = numpy.sqrt(vec(p_squared))
|
p_vector = numpy.sqrt(vec(p_squared))
|
||||||
P_left = sparse.diags_array(p_vector)
|
P_left = sparse.diags(p_vector)
|
||||||
P_right = sparse.diags_array(1 / p_vector)
|
P_right = sparse.diags(1 / p_vector)
|
||||||
return P_left, P_right
|
return P_left, P_right
|
||||||
|
|
||||||
|
|
||||||
def h_full(
|
def h_full(
|
||||||
omega: complex,
|
omega: complex,
|
||||||
dxes: dx_lists_t,
|
dxes: dx_lists_t,
|
||||||
epsilon: vfdfield,
|
epsilon: vfdfield_t,
|
||||||
mu: vfdfield | None = None,
|
mu: vfdfield_t | None = None,
|
||||||
pec: vfdfield | None = None,
|
pec: vfdfield_t | None = None,
|
||||||
pmc: vfdfield | None = None,
|
pmc: vfdfield_t | None = None,
|
||||||
) -> sparse.sparray:
|
) -> sparse.spmatrix:
|
||||||
r"""
|
"""
|
||||||
Wave operator
|
Wave operator
|
||||||
$$ \nabla \times (\frac{1}{\epsilon} \nabla \times) - \omega^2 \mu $$
|
$$ \\nabla \\times (\\frac{1}{\\epsilon} \\nabla \\times) - \\omega^2 \\mu $$
|
||||||
|
|
||||||
del x (1/epsilon * del x) - omega**2 * mu
|
del x (1/epsilon * del x) - omega**2 * mu
|
||||||
|
|
||||||
for use with the H-field, with wave equation
|
for use with the H-field, with wave equation
|
||||||
$$ (\nabla \times (\frac{1}{\epsilon} \nabla \times) - \omega^2 \mu) E = \imath \omega M $$
|
$$ (\\nabla \\times (\\frac{1}{\\epsilon} \\nabla \\times) - \\omega^2 \\mu) E = \\imath \\omega M $$
|
||||||
|
|
||||||
(del x (1/epsilon * del x) - omega**2 * mu) E = i * omega * M
|
(del x (1/epsilon * del x) - omega**2 * mu) E = i * omega * M
|
||||||
|
|
||||||
|
|
@ -148,11 +148,11 @@ def h_full(
|
||||||
epsilon: Vectorized dielectric constant
|
epsilon: Vectorized dielectric constant
|
||||||
mu: Vectorized magnetic permeability (default 1 everywhere)
|
mu: Vectorized magnetic permeability (default 1 everywhere)
|
||||||
pec: Vectorized mask specifying PEC cells. Any cells where `pec != 0` are interpreted
|
pec: Vectorized mask specifying PEC cells. Any cells where `pec != 0` are interpreted
|
||||||
as containing a perfect electrical conductor (PEC).
|
as containing a perfect electrical conductor (PEC).
|
||||||
The PEC is applied per-field-component (i.e. `pec.size == epsilon.size`)
|
The PEC is applied per-field-component (i.e. `pec.size == epsilon.size`)
|
||||||
pmc: Vectorized mask specifying PMC cells. Any cells where `pmc != 0` are interpreted
|
pmc: Vectorized mask specifying PMC cells. Any cells where `pmc != 0` are interpreted
|
||||||
as containing a perfect magnetic conductor (PMC).
|
as containing a perfect magnetic conductor (PMC).
|
||||||
The PMC is applied per-field-component (i.e. `pmc.size == epsilon.size`)
|
The PMC is applied per-field-component (i.e. `pmc.size == epsilon.size`)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Sparse matrix containing the wave operator.
|
Sparse matrix containing the wave operator.
|
||||||
|
|
@ -161,20 +161,20 @@ def h_full(
|
||||||
ce = curl_forward(dxes[0])
|
ce = curl_forward(dxes[0])
|
||||||
|
|
||||||
if pec is None:
|
if pec is None:
|
||||||
pe = sparse.eye_array(epsilon.size)
|
pe = sparse.eye(epsilon.size)
|
||||||
else:
|
else:
|
||||||
pe = sparse.diags_array(numpy.where(pec, 0, 1)) # set pe to (not PEC)
|
pe = sparse.diags(numpy.where(pec, 0, 1)) # set pe to (not PEC)
|
||||||
|
|
||||||
if pmc is None:
|
if pmc is None:
|
||||||
pm = sparse.eye_array(epsilon.size)
|
pm = sparse.eye(epsilon.size)
|
||||||
else:
|
else:
|
||||||
pm = sparse.diags_array(numpy.where(pmc, 0, 1)) # Set pe to (not PMC)
|
pm = sparse.diags(numpy.where(pmc, 0, 1)) # Set pe to (not PMC)
|
||||||
|
|
||||||
e_div = sparse.diags_array(1 / epsilon)
|
e_div = sparse.diags(1 / epsilon)
|
||||||
if mu is None:
|
if mu is None:
|
||||||
m = sparse.eye_array(epsilon.size)
|
m = sparse.eye(epsilon.size)
|
||||||
else:
|
else:
|
||||||
m = sparse.diags_array(mu)
|
m = sparse.diags(mu)
|
||||||
|
|
||||||
A = pm @ (ce @ pe @ e_div @ ch - omega**2 * m) @ pm
|
A = pm @ (ce @ pe @ e_div @ ch - omega**2 * m) @ pm
|
||||||
return A
|
return A
|
||||||
|
|
@ -183,33 +183,33 @@ def h_full(
|
||||||
def eh_full(
|
def eh_full(
|
||||||
omega: complex,
|
omega: complex,
|
||||||
dxes: dx_lists_t,
|
dxes: dx_lists_t,
|
||||||
epsilon: vfdfield,
|
epsilon: vfdfield_t,
|
||||||
mu: vfdfield | None = None,
|
mu: vfdfield_t | None = None,
|
||||||
pec: vfdfield | None = None,
|
pec: vfdfield_t | None = None,
|
||||||
pmc: vfdfield | None = None,
|
pmc: vfdfield_t | None = None,
|
||||||
) -> sparse.sparray:
|
) -> sparse.spmatrix:
|
||||||
r"""
|
"""
|
||||||
Wave operator for `[E, H]` field representation. This operator implements Maxwell's
|
Wave operator for `[E, H]` field representation. This operator implements Maxwell's
|
||||||
equations without cancelling out either E or H. The operator is
|
equations without cancelling out either E or H. The operator is
|
||||||
$$ \begin{bmatrix}
|
$$ \\begin{bmatrix}
|
||||||
-\imath \omega \epsilon & \nabla \times \\
|
-\\imath \\omega \\epsilon & \\nabla \\times \\\\
|
||||||
\nabla \times & \imath \omega \mu
|
\\nabla \\times & \\imath \\omega \\mu
|
||||||
\end{bmatrix} $$
|
\\end{bmatrix} $$
|
||||||
|
|
||||||
[[-i * omega * epsilon, del x ],
|
[[-i * omega * epsilon, del x ],
|
||||||
[del x, i * omega * mu]]
|
[del x, i * omega * mu]]
|
||||||
|
|
||||||
for use with a field vector of the form `cat(vec(E), vec(H))`:
|
for use with a field vector of the form `cat(vec(E), vec(H))`:
|
||||||
$$ \begin{bmatrix}
|
$$ \\begin{bmatrix}
|
||||||
-\imath \omega \epsilon & \nabla \times \\
|
-\\imath \\omega \\epsilon & \\nabla \\times \\\\
|
||||||
\nabla \times & \imath \omega \mu
|
\\nabla \\times & \\imath \\omega \\mu
|
||||||
\end{bmatrix}
|
\\end{bmatrix}
|
||||||
\begin{bmatrix} E \\
|
\\begin{bmatrix} E \\\\
|
||||||
H
|
H
|
||||||
\end{bmatrix}
|
\\end{bmatrix}
|
||||||
= \begin{bmatrix} J \\
|
= \\begin{bmatrix} J \\\\
|
||||||
-M
|
-M
|
||||||
\end{bmatrix} $$
|
\\end{bmatrix} $$
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
omega: Angular frequency of the simulation
|
omega: Angular frequency of the simulation
|
||||||
|
|
@ -217,47 +217,45 @@ def eh_full(
|
||||||
epsilon: Vectorized dielectric constant
|
epsilon: Vectorized dielectric constant
|
||||||
mu: Vectorized magnetic permeability (default 1 everywhere)
|
mu: Vectorized magnetic permeability (default 1 everywhere)
|
||||||
pec: Vectorized mask specifying PEC cells. Any cells where `pec != 0` are interpreted
|
pec: Vectorized mask specifying PEC cells. Any cells where `pec != 0` are interpreted
|
||||||
as containing a perfect electrical conductor (PEC).
|
as containing a perfect electrical conductor (PEC).
|
||||||
The PEC is applied per-field-component (i.e. `pec.size == epsilon.size`)
|
The PEC is applied per-field-component (i.e. `pec.size == epsilon.size`)
|
||||||
pmc: Vectorized mask specifying PMC cells. Any cells where `pmc != 0` are interpreted
|
pmc: Vectorized mask specifying PMC cells. Any cells where `pmc != 0` are interpreted
|
||||||
as containing a perfect magnetic conductor (PMC).
|
as containing a perfect magnetic conductor (PMC).
|
||||||
The PMC is applied per-field-component (i.e. `pmc.size == epsilon.size`)
|
The PMC is applied per-field-component (i.e. `pmc.size == epsilon.size`)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Sparse matrix containing the wave operator.
|
Sparse matrix containing the wave operator.
|
||||||
"""
|
"""
|
||||||
if pec is None:
|
if pec is None:
|
||||||
pe = sparse.eye_array(epsilon.size)
|
pe = sparse.eye(epsilon.size)
|
||||||
else:
|
else:
|
||||||
pe = sparse.diags_array(numpy.where(pec, 0, 1)) # set pe to (not PEC)
|
pe = sparse.diags(numpy.where(pec, 0, 1)) # set pe to (not PEC)
|
||||||
|
|
||||||
if pmc is None:
|
if pmc is None:
|
||||||
pm = sparse.eye_array(epsilon.size)
|
pm = sparse.eye(epsilon.size)
|
||||||
else:
|
else:
|
||||||
pm = sparse.diags_array(numpy.where(pmc, 0, 1)) # set pm to (not PMC)
|
pm = sparse.diags(numpy.where(pmc, 0, 1)) # set pm to (not PMC)
|
||||||
|
|
||||||
iwe = pe @ (1j * omega * sparse.diags(epsilon)) @ pe
|
iwe = pe @ (1j * omega * sparse.diags(epsilon)) @ pe
|
||||||
if mu is None:
|
iwm = 1j * omega
|
||||||
iwm = 1j * omega * sparse.eye(epsilon.size)
|
if mu is not None:
|
||||||
else:
|
iwm *= sparse.diags(mu)
|
||||||
iwm = 1j * omega * sparse.diags(mu)
|
|
||||||
|
|
||||||
iwm = pm @ iwm @ pm
|
iwm = pm @ iwm @ pm
|
||||||
|
|
||||||
A1 = pe @ curl_back(dxes[1]) @ pm
|
A1 = pe @ curl_back(dxes[1]) @ pm
|
||||||
A2 = pm @ curl_forward(dxes[0]) @ pe
|
A2 = pm @ curl_forward(dxes[0]) @ pe
|
||||||
|
|
||||||
A = sparse.block_array([[-iwe, A1],
|
A = sparse.bmat([[-iwe, A1],
|
||||||
[A2, iwm]])
|
[A2, iwm]])
|
||||||
return A
|
return A
|
||||||
|
|
||||||
|
|
||||||
def e2h(
|
def e2h(
|
||||||
omega: complex,
|
omega: complex,
|
||||||
dxes: dx_lists_t,
|
dxes: dx_lists_t,
|
||||||
mu: vfdfield | None = None,
|
mu: vfdfield_t | None = None,
|
||||||
pmc: vfdfield | None = None,
|
pmc: vfdfield_t | None = None,
|
||||||
) -> sparse.sparray:
|
) -> sparse.spmatrix:
|
||||||
"""
|
"""
|
||||||
Utility operator for converting the E field into the H field.
|
Utility operator for converting the E field into the H field.
|
||||||
For use with `e_full()` -- assumes that there is no magnetic current M.
|
For use with `e_full()` -- assumes that there is no magnetic current M.
|
||||||
|
|
@ -267,8 +265,8 @@ def e2h(
|
||||||
dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types`
|
dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types`
|
||||||
mu: Vectorized magnetic permeability (default 1 everywhere)
|
mu: Vectorized magnetic permeability (default 1 everywhere)
|
||||||
pmc: Vectorized mask specifying PMC cells. Any cells where `pmc != 0` are interpreted
|
pmc: Vectorized mask specifying PMC cells. Any cells where `pmc != 0` are interpreted
|
||||||
as containing a perfect magnetic conductor (PMC).
|
as containing a perfect magnetic conductor (PMC).
|
||||||
The PMC is applied per-field-component (i.e. `pmc.size == epsilon.size`)
|
The PMC is applied per-field-component (i.e. `pmc.size == epsilon.size`)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Sparse matrix for converting E to H.
|
Sparse matrix for converting E to H.
|
||||||
|
|
@ -276,10 +274,10 @@ def e2h(
|
||||||
op = curl_forward(dxes[0]) / (-1j * omega)
|
op = curl_forward(dxes[0]) / (-1j * omega)
|
||||||
|
|
||||||
if mu is not None:
|
if mu is not None:
|
||||||
op = sparse.diags_array(1 / mu) @ op
|
op = sparse.diags(1 / mu) @ op
|
||||||
|
|
||||||
if pmc is not None:
|
if pmc is not None:
|
||||||
op = sparse.diags_array(numpy.where(pmc, 0, 1)) @ op
|
op = sparse.diags(numpy.where(pmc, 0, 1)) @ op
|
||||||
|
|
||||||
return op
|
return op
|
||||||
|
|
||||||
|
|
@ -287,8 +285,8 @@ def e2h(
|
||||||
def m2j(
|
def m2j(
|
||||||
omega: complex,
|
omega: complex,
|
||||||
dxes: dx_lists_t,
|
dxes: dx_lists_t,
|
||||||
mu: vfdfield | None = None,
|
mu: vfdfield_t | None = None,
|
||||||
) -> sparse.sparray:
|
) -> sparse.spmatrix:
|
||||||
"""
|
"""
|
||||||
Operator for converting a magnetic current M into an electric current J.
|
Operator for converting a magnetic current M into an electric current J.
|
||||||
For use with eg. `e_full()`.
|
For use with eg. `e_full()`.
|
||||||
|
|
@ -304,108 +302,78 @@ def m2j(
|
||||||
op = curl_back(dxes[1]) / (1j * omega)
|
op = curl_back(dxes[1]) / (1j * omega)
|
||||||
|
|
||||||
if mu is not None:
|
if mu is not None:
|
||||||
op = op @ sparse.diags_array(1 / mu)
|
op = op @ sparse.diags(1 / mu)
|
||||||
|
|
||||||
return op
|
return op
|
||||||
|
|
||||||
|
|
||||||
def poynting_e_cross(e: vcfdfield, dxes: dx_lists_t) -> sparse.sparray:
|
def poynting_e_cross(e: vcfdfield_t, dxes: dx_lists_t) -> sparse.spmatrix:
|
||||||
r"""
|
"""
|
||||||
Operator for computing the staggered-grid `(E \times)` part of the Poynting vector.
|
Operator for computing the Poynting vector, containing the
|
||||||
|
(E x) portion of the Poynting vector.
|
||||||
On the Yee grid the E and H components live on different edges, so the
|
|
||||||
electric field must be shifted by one cell in the appropriate direction
|
|
||||||
before forming the discrete cross product. This sparse operator encodes that
|
|
||||||
shifted cross product directly and is the matrix equivalent of
|
|
||||||
`functional.poynting_e_cross_h(...)`.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
e: Vectorized E-field for the ExH cross product
|
e: Vectorized E-field for the ExH cross product
|
||||||
dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types`
|
dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types`
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Sparse matrix containing the `(E \times)` part of the staggered Poynting
|
Sparse matrix containing (E x) portion of Poynting cross product.
|
||||||
cross product.
|
|
||||||
"""
|
"""
|
||||||
shape = [len(dx) for dx in dxes[0]]
|
shape = [len(dx) for dx in dxes[0]]
|
||||||
|
|
||||||
fx, fy, fz = (shift_circ(i, shape, 1) for i in range(3))
|
fx, fy, fz = [shift_circ(i, shape, 1) for i in range(3)]
|
||||||
|
|
||||||
dxag = [dx.ravel(order='C') for dx in numpy.meshgrid(*dxes[0], indexing='ij')]
|
dxag = [dx.ravel(order='C') for dx in numpy.meshgrid(*dxes[0], indexing='ij')]
|
||||||
dxbg = [dx.ravel(order='C') for dx in numpy.meshgrid(*dxes[1], indexing='ij')]
|
Ex, Ey, Ez = [ei * da for ei, da in zip(numpy.split(e, 3), dxag)]
|
||||||
Ex, Ey, Ez = (ei * da for ei, da in zip(numpy.split(e, 3), dxag, strict=True))
|
|
||||||
|
|
||||||
block_diags = [[ None, fx @ -Ez, fx @ Ey],
|
block_diags = [[ None, fx @ -Ez, fx @ Ey],
|
||||||
[ fy @ Ez, None, fy @ -Ex],
|
[ fy @ Ez, None, fy @ -Ex],
|
||||||
[ fz @ -Ey, fz @ Ex, None]]
|
[ fz @ -Ey, fz @ Ex, None]]
|
||||||
block_matrix = sparse.block_array([[sparse.diags_array(x) if x is not None else None for x in row]
|
block_matrix = sparse.bmat([[sparse.diags(x) if x is not None else None for x in row]
|
||||||
for row in block_diags])
|
for row in block_diags])
|
||||||
P = block_matrix @ sparse.diags_array(numpy.concatenate(dxbg))
|
P = block_matrix @ sparse.diags(numpy.concatenate(dxag))
|
||||||
return P
|
return P
|
||||||
|
|
||||||
|
|
||||||
def poynting_h_cross(h: vcfdfield, dxes: dx_lists_t) -> sparse.sparray:
|
def poynting_h_cross(h: vcfdfield_t, dxes: dx_lists_t) -> sparse.spmatrix:
|
||||||
r"""
|
"""
|
||||||
Operator for computing the staggered-grid `(H \times)` part of the Poynting vector.
|
Operator for computing the Poynting vector, containing the (H x) portion of the Poynting vector.
|
||||||
|
|
||||||
Together with `poynting_e_cross(...)`, this provides the matrix form of the
|
|
||||||
Yee-grid cross product used in the flux helpers. The two are related by the
|
|
||||||
usual antisymmetry of the cross product,
|
|
||||||
|
|
||||||
$$
|
|
||||||
H \times E = -(E \times H),
|
|
||||||
$$
|
|
||||||
|
|
||||||
once the same staggered field placement is used on both sides.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
h: Vectorized H-field for the HxE cross product
|
h: Vectorized H-field for the HxE cross product
|
||||||
dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types`
|
dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types`
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Sparse matrix containing the `(H \times)` part of the staggered Poynting
|
Sparse matrix containing (H x) portion of Poynting cross product.
|
||||||
cross product.
|
|
||||||
"""
|
"""
|
||||||
shape = [len(dx) for dx in dxes[0]]
|
shape = [len(dx) for dx in dxes[0]]
|
||||||
|
|
||||||
fx, fy, fz = (shift_circ(i, shape, 1) for i in range(3))
|
fx, fy, fz = [shift_circ(i, shape, 1) for i in range(3)]
|
||||||
|
|
||||||
dxag = [dx.ravel(order='C') for dx in numpy.meshgrid(*dxes[0], indexing='ij')]
|
dxag = [dx.ravel(order='C') for dx in numpy.meshgrid(*dxes[0], indexing='ij')]
|
||||||
dxbg = [dx.ravel(order='C') for dx in numpy.meshgrid(*dxes[1], indexing='ij')]
|
dxbg = [dx.ravel(order='C') for dx in numpy.meshgrid(*dxes[1], indexing='ij')]
|
||||||
Hx, Hy, Hz = (sparse.diags_array(hi * db) for hi, db in zip(numpy.split(h, 3), dxbg, strict=True))
|
Hx, Hy, Hz = [sparse.diags(hi * db) for hi, db in zip(numpy.split(h, 3), dxbg)]
|
||||||
|
|
||||||
P = (sparse.block_array(
|
P = (sparse.bmat(
|
||||||
[[ None, -Hz @ fx, Hy @ fx],
|
[[ None, -Hz @ fx, Hy @ fx],
|
||||||
[ Hz @ fy, None, -Hx @ fy],
|
[ Hz @ fy, None, -Hx @ fy],
|
||||||
[-Hy @ fz, Hx @ fz, None]])
|
[-Hy @ fz, Hx @ fz, None]])
|
||||||
@ sparse.diags_array(numpy.concatenate(dxag)))
|
@ sparse.diags(numpy.concatenate(dxag)))
|
||||||
return P
|
return P
|
||||||
|
|
||||||
|
|
||||||
def e_tfsf_source(
|
def e_tfsf_source(
|
||||||
TF_region: vfdfield,
|
TF_region: vfdfield_t,
|
||||||
omega: complex,
|
omega: complex,
|
||||||
dxes: dx_lists_t,
|
dxes: dx_lists_t,
|
||||||
epsilon: vfdfield,
|
epsilon: vfdfield_t,
|
||||||
mu: vfdfield | None = None,
|
mu: vfdfield_t | None = None,
|
||||||
) -> sparse.sparray:
|
) -> sparse.spmatrix:
|
||||||
r"""
|
"""
|
||||||
Operator that turns a desired E-field distribution into a
|
Operator that turns a desired E-field distribution into a
|
||||||
total-field/scattered-field (TFSF) source.
|
total-field/scattered-field (TFSF) source.
|
||||||
|
|
||||||
Let `A` be the full wave operator from `e_full(...)`, and let
|
TODO: Reference Rumpf paper
|
||||||
`Q = \mathrm{diag}(TF_region)` be the projector onto the total-field region.
|
|
||||||
Then the TFSF current operator is the commutator
|
|
||||||
|
|
||||||
$$
|
|
||||||
\frac{A Q - Q A}{-i \omega}.
|
|
||||||
$$
|
|
||||||
|
|
||||||
Inside regions where `Q` is locally constant, `A` and `Q` commute and the
|
|
||||||
source vanishes. Only cells at the TF/SF boundary contribute nonzero current,
|
|
||||||
which is exactly the desired distributed source for injecting the chosen
|
|
||||||
field into the total-field region without directly forcing the
|
|
||||||
scattered-field region.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
TF_region: Mask, which is set to 1 inside the total-field region and 0 in the
|
TF_region: Mask, which is set to 1 inside the total-field region and 0 in the
|
||||||
|
|
@ -417,31 +385,27 @@ def e_tfsf_source(
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Sparse matrix that turns an E-field into a current (J) distribution.
|
Sparse matrix that turns an E-field into a current (J) distribution.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
# TODO documentation
|
||||||
A = e_full(omega, dxes, epsilon, mu)
|
A = e_full(omega, dxes, epsilon, mu)
|
||||||
Q = sparse.diags_array(TF_region)
|
Q = sparse.diags(TF_region)
|
||||||
return (A @ Q - Q @ A) / (-1j * omega)
|
return (A @ Q - Q @ A) / (-1j * omega)
|
||||||
|
|
||||||
|
|
||||||
def e_boundary_source(
|
def e_boundary_source(
|
||||||
mask: vfdfield,
|
mask: vfdfield_t,
|
||||||
omega: complex,
|
omega: complex,
|
||||||
dxes: dx_lists_t,
|
dxes: dx_lists_t,
|
||||||
epsilon: vfdfield,
|
epsilon: vfdfield_t,
|
||||||
mu: vfdfield | None = None,
|
mu: vfdfield_t | None = None,
|
||||||
periodic_mask_edges: bool = False,
|
periodic_mask_edges: bool = False,
|
||||||
) -> sparse.sparray:
|
) -> sparse.spmatrix:
|
||||||
r"""
|
"""
|
||||||
Operator that turns an E-field distrubtion into a current (J) distribution
|
Operator that turns an E-field distrubtion into a current (J) distribution
|
||||||
along the edges (external and internal) of the provided mask. This is just an
|
along the edges (external and internal) of the provided mask. This is just an
|
||||||
`e_tfsf_source()` with an additional masking step.
|
`e_tfsf_source()` with an additional masking step.
|
||||||
|
|
||||||
Equivalently, this helper first constructs the TFSF commutator source for the
|
|
||||||
full mask and then zeroes out all cells except the mask boundary. The
|
|
||||||
boundary is defined as the set of cells whose mask value changes under a
|
|
||||||
one-cell shift in any Cartesian direction. With `periodic_mask_edges=False`
|
|
||||||
the shifts mirror at the domain boundary; with `True` they wrap periodically.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
mask: The current distribution is generated at the edges of the mask,
|
mask: The current distribution is generated at the edges of the mask,
|
||||||
i.e. any points where shifting the mask by one cell in any direction
|
i.e. any points where shifting the mask by one cell in any direction
|
||||||
|
|
@ -459,10 +423,10 @@ def e_boundary_source(
|
||||||
shape = [len(dxe) for dxe in dxes[0]]
|
shape = [len(dxe) for dxe in dxes[0]]
|
||||||
jmask = numpy.zeros_like(mask, dtype=bool)
|
jmask = numpy.zeros_like(mask, dtype=bool)
|
||||||
|
|
||||||
def shift_rot(axis: int, polarity: int) -> sparse.sparray:
|
def shift_rot(axis: int, polarity: int) -> sparse.spmatrix:
|
||||||
return shift_circ(axis=axis, shape=shape, shift_distance=polarity)
|
return shift_circ(axis=axis, shape=shape, shift_distance=polarity)
|
||||||
|
|
||||||
def shift_mir(axis: int, polarity: int) -> sparse.sparray:
|
def shift_mir(axis: int, polarity: int) -> sparse.spmatrix:
|
||||||
return shift_with_mirror(axis=axis, shape=shape, shift_distance=polarity)
|
return shift_with_mirror(axis=axis, shape=shape, shift_distance=polarity)
|
||||||
|
|
||||||
shift = shift_rot if periodic_mask_edges else shift_mir
|
shift = shift_rot if periodic_mask_edges else shift_mir
|
||||||
|
|
@ -471,7 +435,7 @@ def e_boundary_source(
|
||||||
if shape[axis] == 1:
|
if shape[axis] == 1:
|
||||||
continue
|
continue
|
||||||
for polarity in (-1, +1):
|
for polarity in (-1, +1):
|
||||||
r = shift(axis, polarity) - sparse.eye_array(numpy.prod(shape)) # shifted minus original
|
r = shift(axis, polarity) - sparse.eye(numpy.prod(shape)) # shifted minus original
|
||||||
r3 = sparse.block_diag((r, r, r))
|
r3 = sparse.block_diag((r, r, r))
|
||||||
jmask = numpy.logical_or(jmask, numpy.abs(r3 @ mask))
|
jmask = numpy.logical_or(jmask, numpy.abs(r3 @ mask))
|
||||||
|
|
||||||
|
|
@ -482,4 +446,5 @@ def e_boundary_source(
|
||||||
# (numpy.roll(mask, -1, axis=2) != mask) |
|
# (numpy.roll(mask, -1, axis=2) != mask) |
|
||||||
# (numpy.roll(mask, +1, axis=2) != mask))
|
# (numpy.roll(mask, +1, axis=2) != mask))
|
||||||
|
|
||||||
return sparse.diags_array(jmask.astype(int)) @ full
|
return sparse.diags(jmask.astype(int)) @ full
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
Functions for creating stretched coordinate perfectly matched layer (PML) absorbers.
|
Functions for creating stretched coordinate perfectly matched layer (PML) absorbers.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from collections.abc import Sequence, Callable
|
from typing import Sequence, Callable
|
||||||
|
|
||||||
import numpy
|
import numpy
|
||||||
from numpy.typing import NDArray
|
from numpy.typing import NDArray
|
||||||
|
|
@ -38,148 +38,130 @@ def prepare_s_function(
|
||||||
return s_factor
|
return s_factor
|
||||||
|
|
||||||
|
|
||||||
def _stretch_factors(
|
|
||||||
distance: NDArray,
|
|
||||||
correction: float,
|
|
||||||
s_function: s_function_t,
|
|
||||||
kappa_function: s_function_t | None,
|
|
||||||
max_imag_ratio: float | None,
|
|
||||||
) -> NDArray[numpy.complex128]:
|
|
||||||
"""Evaluate the final dimensionless coordinate stretch."""
|
|
||||||
kappa = numpy.asarray(1.0 if kappa_function is None else kappa_function(distance))
|
|
||||||
sigma = numpy.asarray(s_function(distance)) / correction
|
|
||||||
if numpy.iscomplexobj(kappa) or not numpy.isfinite(kappa).all() or (kappa < 1).any():
|
|
||||||
raise ValueError('kappa_function must return finite real stretches at least one')
|
|
||||||
if numpy.iscomplexobj(sigma) or not numpy.isfinite(sigma).all():
|
|
||||||
raise ValueError('s_function must return finite real values')
|
|
||||||
if max_imag_ratio is not None:
|
|
||||||
sigma = numpy.clip(sigma, -max_imag_ratio * kappa, max_imag_ratio * kappa)
|
|
||||||
return kappa + 1j * sigma
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_profile(omega: float, epsilon_effective: float, max_imag_ratio: float | None) -> None:
|
|
||||||
if not numpy.isfinite(omega) or not numpy.isreal(omega) or omega <= 0:
|
|
||||||
raise ValueError('omega must be finite, real, and positive')
|
|
||||||
if not numpy.isfinite(epsilon_effective) or epsilon_effective <= 0:
|
|
||||||
raise ValueError('epsilon_effective must be finite and positive')
|
|
||||||
if max_imag_ratio is not None and (not numpy.isfinite(max_imag_ratio) or max_imag_ratio <= 0):
|
|
||||||
raise ValueError('max_imag_ratio must be finite and positive')
|
|
||||||
|
|
||||||
|
|
||||||
def uniform_grid_scpml(
|
def uniform_grid_scpml(
|
||||||
shape: Sequence[int],
|
shape: Sequence[int],
|
||||||
thicknesses: Sequence[int],
|
thicknesses: Sequence[int],
|
||||||
omega: float,
|
omega: float,
|
||||||
epsilon_effective: float = 1.0,
|
epsilon_effective: float = 1.0,
|
||||||
s_function: s_function_t | None = None,
|
s_function: s_function_t | None = None,
|
||||||
*,
|
) -> list[list[NDArray[numpy.float64]]]:
|
||||||
kappa_function: s_function_t | None = None,
|
"""
|
||||||
max_imag_ratio: float | None = None,
|
Create dx arrays for a uniform grid with a cell width of 1 and a pml.
|
||||||
) -> list[list[NDArray]]:
|
|
||||||
"""Create unit-cell Yee widths with SCPML on both faces of each axis.
|
|
||||||
|
|
||||||
``shape`` and ``thicknesses`` contain three axis sizes and per-face PML
|
If you want something more fine-grained, check out `stretch_with_scpml(...)`.
|
||||||
thicknesses; zero disables an axis. ``s_function`` specifies the imaginary
|
|
||||||
grading, defaulting to prepare_s_function(). ``kappa_function`` optionally
|
|
||||||
returns dimensionless real stretch values >= 1 at normalized depths.
|
|
||||||
Each face is built by stretch_with_scpml, including its physical-thickness
|
|
||||||
scaling and electric-center / magnetic-edge sampling convention.
|
|
||||||
|
|
||||||
``max_imag_ratio`` optionally bounds abs(Im(s))/Re(s) after frequency
|
Args:
|
||||||
scaling. A value of 1 is a useful mode-solver starting point, not a proof
|
shape: Shape of the grid, including the PMLs (which are 2*thicknesses thick)
|
||||||
against spectral artifacts. Converge domain size, thickness, and strength;
|
thicknesses: `[th_x, th_y, th_z]`
|
||||||
reducing absorption can increase boundary reflections. The general
|
Thickness of the PML in each direction.
|
||||||
scattering profile remains uncapped by default.
|
Both polarities are added.
|
||||||
|
Each th_ of pml is applied twice, once on each edge of the grid along the given axis.
|
||||||
|
`th_*` may be zero, in which case no pml is added.
|
||||||
|
omega: Angular frequency for the simulation
|
||||||
|
epsilon_effective: Effective epsilon of the PML. Match this to the material
|
||||||
|
at the edge of your grid.
|
||||||
|
Default 1.
|
||||||
|
s_function: created by `prepare_s_function(...)`, allowing customization of pml parameters.
|
||||||
|
Default uses `prepare_s_function()` with no parameters.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Complex cell widths (dx_lists_mut) as discussed in `meanas.fdmath.types`.
|
||||||
"""
|
"""
|
||||||
_validate_profile(omega, epsilon_effective, max_imag_ratio)
|
|
||||||
shape, thicknesses = tuple(shape), tuple(thicknesses)
|
|
||||||
if len(shape) != 3 or len(thicknesses) != 3:
|
|
||||||
raise ValueError('shape and thicknesses must contain three entries')
|
|
||||||
if any(not isinstance(n, int | numpy.integer) or isinstance(n, bool) or n <= 0 for n in shape):
|
|
||||||
raise ValueError('shape entries must be positive integers')
|
|
||||||
if any(not isinstance(t, int | numpy.integer) or isinstance(t, bool) or t < 0 or 2 * t > n
|
|
||||||
for n, t in zip(shape, thicknesses, strict=True)):
|
|
||||||
raise ValueError('PML thicknesses must be nonnegative integers fitting both faces')
|
|
||||||
if s_function is None:
|
if s_function is None:
|
||||||
s_function = prepare_s_function()
|
s_function = prepare_s_function()
|
||||||
result = [[numpy.ones(n) for n in shape] for _ in range(2)]
|
|
||||||
for axis, thickness in enumerate(thicknesses):
|
shape = tuple(shape)
|
||||||
if not thickness:
|
thicknesses = tuple(thicknesses)
|
||||||
continue
|
|
||||||
for polarity in (-1, 1):
|
# Normalized distance to nearest boundary
|
||||||
stretch_with_scpml(
|
def ll(u: NDArray[numpy.float64], n: int, t: int) -> NDArray[numpy.float64]:
|
||||||
result, axis, polarity, omega, epsilon_effective, thickness,
|
return ((t - u).clip(0) + (u - (n - t)).clip(0)) / t
|
||||||
s_function, kappa_function=kappa_function,
|
|
||||||
max_imag_ratio=max_imag_ratio, copy=False,
|
dx_a = [numpy.array(numpy.inf)] * 3
|
||||||
)
|
dx_b = [numpy.array(numpy.inf)] * 3
|
||||||
return result
|
|
||||||
|
# divide by this to adjust for epsilon_effective and omega
|
||||||
|
s_correction = numpy.sqrt(epsilon_effective) * numpy.real(omega)
|
||||||
|
|
||||||
|
for k, th in enumerate(thicknesses):
|
||||||
|
s = shape[k]
|
||||||
|
if th > 0:
|
||||||
|
sr = numpy.arange(s, dtype=numpy.float64)
|
||||||
|
dx_a[k] = 1 + 1j * s_function(ll(sr, s, th)) / s_correction
|
||||||
|
dx_b[k] = 1 + 1j * s_function(ll(sr + 0.5, s, th)) / s_correction
|
||||||
|
else:
|
||||||
|
dx_a[k] = numpy.ones((s,))
|
||||||
|
dx_b[k] = numpy.ones((s,))
|
||||||
|
return [dx_a, dx_b]
|
||||||
|
|
||||||
|
|
||||||
def stretch_with_scpml(
|
def stretch_with_scpml(
|
||||||
dxes: list[list[NDArray]],
|
dxes: list[list[NDArray[numpy.float64]]],
|
||||||
axis: int,
|
axis: int,
|
||||||
polarity: int,
|
polarity: int,
|
||||||
omega: float,
|
omega: float,
|
||||||
epsilon_effective: float = 1.0,
|
epsilon_effective: float = 1.0,
|
||||||
thickness: int = 10,
|
thickness: int = 10,
|
||||||
s_function: s_function_t | None = None,
|
s_function: s_function_t | None = None,
|
||||||
*,
|
) -> list[list[NDArray[numpy.float64]]]:
|
||||||
kappa_function: s_function_t | None = None,
|
"""
|
||||||
max_imag_ratio: float | None = None,
|
Stretch dxes to contain a stretched-coordinate PML (SCPML) in one direction along one axis.
|
||||||
copy: bool = True,
|
|
||||||
) -> list[list[NDArray]]:
|
|
||||||
"""Add SCPML to one face, returning independent widths by default.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
dxes: Yee widths [dx_e, dx_h]; two or three spatial axes are supported.
|
dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types`
|
||||||
axis: Axis normal to the absorbing face.
|
axis: axis to stretch (0=x, 1=y, 2=z)
|
||||||
polarity: -1 selects the low-index face; +1 selects the high-index face.
|
polarity: direction to stretch (-1 for -ve, +1 for +ve)
|
||||||
omega: Positive real angular frequency.
|
omega: Angular frequency for the simulation
|
||||||
epsilon_effective: Positive effective permittivity at the boundary.
|
epsilon_effective: Effective epsilon of the PML. Match this to the material at the
|
||||||
thickness: Number of cells on this face; zero is a no-op.
|
edge of your grid. Default 1.
|
||||||
s_function: Imaginary grading at normalized depths, from prepare_s_function.
|
thickness: number of cells to use for pml (default 10)
|
||||||
kappa_function: Optional dimensionless real stretch profile, >= 1.
|
s_function: Created by `prepare_s_function(...)`, allowing customization
|
||||||
max_imag_ratio: Optional final abs(Im(s))/Re(s) bound. A value of 1 is
|
of pml parameters. Default uses `prepare_s_function()` with no parameters.
|
||||||
a mode-solver starting configuration; check PML/domain convergence.
|
|
||||||
copy: Return copied widths by default. False explicitly mutates dxes.
|
|
||||||
|
|
||||||
Coordinates and physical PML thickness are obtained from the real input
|
Returns:
|
||||||
electric widths. Multiple nonoverlapping faces can be added successively.
|
Complex cell widths (dx_lists_mut) as discussed in `meanas.fdmath.types`.
|
||||||
The imaginary sign follows the exp(+i omega t) convention used by meanas.
|
Multiple calls to this function may be necessary if multiple absorpbing boundaries are needed.
|
||||||
"""
|
"""
|
||||||
_validate_profile(omega, epsilon_effective, max_imag_ratio)
|
|
||||||
if len(dxes) != 2 or len(dxes[0]) != len(dxes[1]) or axis not in range(len(dxes[0])):
|
|
||||||
raise ValueError('axis must select a spatial axis in both Yee width families')
|
|
||||||
if polarity not in (-1, 1):
|
|
||||||
raise ValueError('polarity must be -1 or +1')
|
|
||||||
if not isinstance(thickness, int | numpy.integer) or isinstance(thickness, bool):
|
|
||||||
raise TypeError('thickness must be an integer')
|
|
||||||
n = len(dxes[0][axis])
|
|
||||||
if thickness < 0 or thickness > n or len(dxes[1][axis]) != n:
|
|
||||||
raise ValueError('thickness must fit the matching Yee width arrays')
|
|
||||||
widths = [[values.copy() for values in family] for family in dxes] if copy else dxes
|
|
||||||
if thickness == 0:
|
|
||||||
return widths
|
|
||||||
if s_function is None:
|
if s_function is None:
|
||||||
s_function = prepare_s_function()
|
s_function = prepare_s_function()
|
||||||
da, db = (numpy.array(family[axis], dtype=complex, copy=True) for family in widths)
|
|
||||||
if not numpy.isfinite(da).all() or not numpy.isfinite(db).all() or (da.real <= 0).any() or (db.real <= 0).any():
|
dx_ai = dxes[0][axis].astype(complex)
|
||||||
raise ValueError('cell widths must be finite with positive real parts')
|
dx_bi = dxes[1][axis].astype(complex)
|
||||||
position = numpy.concatenate(([0.0], da.real.cumsum()))
|
|
||||||
electric = (position[:-1] + position[1:]) / 2
|
pos = numpy.hstack((0, dx_ai.cumsum()))
|
||||||
magnetic = position[:-1]
|
pos_a = (pos[:-1] + pos[1:]) / 2
|
||||||
if polarity == -1:
|
pos_b = pos[:-1]
|
||||||
selected = slice(0, thickness)
|
|
||||||
bound = position[thickness]
|
# divide by this to adjust for epsilon_effective and omega
|
||||||
length = bound - position[0]
|
s_correction = numpy.sqrt(epsilon_effective) * numpy.real(omega)
|
||||||
depths = [(bound - coordinates[selected]) / length for coordinates in (electric, magnetic)]
|
|
||||||
|
if polarity > 0:
|
||||||
|
# front pml
|
||||||
|
bound = pos[thickness]
|
||||||
|
d = bound - pos[0]
|
||||||
|
|
||||||
|
def l_d(x: NDArray[numpy.float64]) -> NDArray[numpy.float64]:
|
||||||
|
return (bound - x) / (bound - pos[0])
|
||||||
|
|
||||||
|
slc = slice(thickness)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
selected = slice(-thickness, None)
|
# back pml
|
||||||
bound = position[-thickness - 1]
|
bound = pos[-thickness - 1]
|
||||||
length = position[-1] - bound
|
d = pos[-1] - bound
|
||||||
depths = [(coordinates[selected] - bound) / length for coordinates in (electric, magnetic)]
|
|
||||||
correction = length * numpy.sqrt(epsilon_effective) * numpy.real(omega)
|
def l_d(x: NDArray[numpy.float64]) -> NDArray[numpy.float64]:
|
||||||
da[selected] *= _stretch_factors(depths[0], correction, s_function, kappa_function, max_imag_ratio)
|
return (x - bound) / (pos[-1] - bound)
|
||||||
db[selected] *= _stretch_factors(depths[1], correction, s_function, kappa_function, max_imag_ratio)
|
|
||||||
widths[0][axis], widths[1][axis] = da, db
|
if thickness == 0:
|
||||||
return widths
|
slc = slice(None)
|
||||||
|
else:
|
||||||
|
slc = slice(-thickness, None)
|
||||||
|
|
||||||
|
dx_ai[slc] *= 1 + 1j * s_function(l_d(pos_a[slc])) / d / s_correction
|
||||||
|
dx_bi[slc] *= 1 + 1j * s_function(l_d(pos_b[slc])) / d / s_correction
|
||||||
|
|
||||||
|
dxes[0][axis] = dx_ai
|
||||||
|
dxes[1][axis] = dx_bi
|
||||||
|
|
||||||
|
return dxes
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,15 @@
|
||||||
Solvers and solver interface for FDFD problems.
|
Solvers and solver interface for FDFD problems.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Any
|
from typing import Callable, Dict, Any, Optional
|
||||||
from collections.abc import Callable
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
import numpy
|
import numpy
|
||||||
from numpy.typing import ArrayLike, NDArray
|
from numpy.typing import ArrayLike, NDArray
|
||||||
from numpy.linalg import norm
|
from numpy.linalg import norm
|
||||||
import scipy.sparse.linalg
|
import scipy.sparse.linalg # type: ignore
|
||||||
|
|
||||||
from ..fdmath import dx_lists_t, vfdfield, vcfdfield, vcfdfield_t
|
from ..fdmath import dx_lists_t, vfdfield_t, vcfdfield_t
|
||||||
from . import operators
|
from . import operators
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -19,7 +18,7 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _scipy_qmr(
|
def _scipy_qmr(
|
||||||
A: scipy.sparse.csr_array,
|
A: scipy.sparse.csr_matrix,
|
||||||
b: ArrayLike,
|
b: ArrayLike,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> NDArray[numpy.float64]:
|
) -> NDArray[numpy.float64]:
|
||||||
|
|
@ -35,32 +34,30 @@ def _scipy_qmr(
|
||||||
Guess for solution (returned even if didn't converge)
|
Guess for solution (returned even if didn't converge)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
#
|
'''
|
||||||
#Report on our progress
|
Report on our progress
|
||||||
#
|
'''
|
||||||
ii = 0
|
ii = 0
|
||||||
|
|
||||||
def log_residual(xk: ArrayLike) -> None:
|
def log_residual(xk: ArrayLike) -> None:
|
||||||
nonlocal ii
|
nonlocal ii
|
||||||
ii += 1
|
ii += 1
|
||||||
if ii % 100 == 0:
|
if ii % 100 == 0:
|
||||||
cur_norm = norm(A @ xk - b) / norm(b)
|
logger.info('Solver residual at iteration {} : {}'.format(ii, norm(A @ xk - b)))
|
||||||
logger.info(f'Solver residual at iteration {ii} : {cur_norm}')
|
|
||||||
|
|
||||||
if 'callback' in kwargs:
|
if 'callback' in kwargs:
|
||||||
callback = kwargs['callback']
|
|
||||||
|
|
||||||
def augmented_callback(xk: ArrayLike) -> None:
|
def augmented_callback(xk: ArrayLike) -> None:
|
||||||
log_residual(xk)
|
log_residual(xk)
|
||||||
callback(xk)
|
kwargs['callback'](xk)
|
||||||
|
|
||||||
kwargs['callback'] = augmented_callback
|
kwargs['callback'] = augmented_callback
|
||||||
else:
|
else:
|
||||||
kwargs['callback'] = log_residual
|
kwargs['callback'] = log_residual
|
||||||
|
|
||||||
#
|
'''
|
||||||
# Run the actual solve
|
Run the actual solve
|
||||||
#
|
'''
|
||||||
|
|
||||||
x, _ = scipy.sparse.linalg.qmr(A, b, **kwargs)
|
x, _ = scipy.sparse.linalg.qmr(A, b, **kwargs)
|
||||||
return x
|
return x
|
||||||
|
|
||||||
|
|
@ -68,16 +65,14 @@ def _scipy_qmr(
|
||||||
def generic(
|
def generic(
|
||||||
omega: complex,
|
omega: complex,
|
||||||
dxes: dx_lists_t,
|
dxes: dx_lists_t,
|
||||||
J: vcfdfield,
|
J: vcfdfield_t,
|
||||||
epsilon: vfdfield,
|
epsilon: vfdfield_t,
|
||||||
mu: vfdfield | None = None,
|
mu: Optional[vfdfield_t] = None,
|
||||||
*,
|
pec: Optional[vfdfield_t] = None,
|
||||||
pec: vfdfield | None = None,
|
pmc: Optional[vfdfield_t] = None,
|
||||||
pmc: vfdfield | None = None,
|
|
||||||
adjoint: bool = False,
|
adjoint: bool = False,
|
||||||
matrix_solver: Callable[..., ArrayLike] = _scipy_qmr,
|
matrix_solver: Callable[..., ArrayLike] = _scipy_qmr,
|
||||||
matrix_solver_opts: dict[str, Any] | None = None,
|
matrix_solver_opts: Optional[Dict[str, Any]] = None,
|
||||||
E_guess: vcfdfield | None = None,
|
|
||||||
) -> vcfdfield_t:
|
) -> vcfdfield_t:
|
||||||
"""
|
"""
|
||||||
Conjugate gradient FDFD solver using CSR sparse matrices.
|
Conjugate gradient FDFD solver using CSR sparse matrices.
|
||||||
|
|
@ -97,15 +92,13 @@ def generic(
|
||||||
(at H-field locations; non-zero value indicates PMC is present)
|
(at H-field locations; non-zero value indicates PMC is present)
|
||||||
adjoint: If true, solves the adjoint problem.
|
adjoint: If true, solves the adjoint problem.
|
||||||
matrix_solver: Called as `matrix_solver(A, b, **matrix_solver_opts) -> x`,
|
matrix_solver: Called as `matrix_solver(A, b, **matrix_solver_opts) -> x`,
|
||||||
where `A`: `scipy.sparse.csr_array`;
|
where `A`: `scipy.sparse.csr_matrix`;
|
||||||
`b`: `ArrayLike`;
|
`b`: `ArrayLike`;
|
||||||
`x`: `ArrayLike`;
|
`x`: `ArrayLike`;
|
||||||
Default is a wrapped version of `scipy.sparse.linalg.qmr()`
|
Default is a wrapped version of `scipy.sparse.linalg.qmr()`
|
||||||
which doesn't return convergence info and logs the residual
|
which doesn't return convergence info and logs the residual
|
||||||
every 100 iterations.
|
every 100 iterations.
|
||||||
matrix_solver_opts: Passed as kwargs to `matrix_solver(...)`
|
matrix_solver_opts: Passed as kwargs to `matrix_solver(...)`
|
||||||
E_guess: Guess at the solution E-field. `matrix_solver` must accept an
|
|
||||||
`x0` argument with the same purpose.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
E-field which solves the system.
|
E-field which solves the system.
|
||||||
|
|
@ -120,24 +113,17 @@ def generic(
|
||||||
Pl, Pr = operators.e_full_preconditioners(dxes)
|
Pl, Pr = operators.e_full_preconditioners(dxes)
|
||||||
|
|
||||||
if adjoint:
|
if adjoint:
|
||||||
A = (Pl @ A0 @ Pr).T.conjugate()
|
A = (Pl @ A0 @ Pr).H
|
||||||
b = Pr.T.conjugate() @ b0
|
b = Pr.H @ b0
|
||||||
else:
|
else:
|
||||||
A = Pl @ A0 @ Pr
|
A = Pl @ A0 @ Pr
|
||||||
b = Pl @ b0
|
b = Pl @ b0
|
||||||
|
|
||||||
if E_guess is not None:
|
|
||||||
if adjoint:
|
|
||||||
x0 = Pr.T.conjugate() @ E_guess
|
|
||||||
else:
|
|
||||||
x0 = Pl @ E_guess
|
|
||||||
matrix_solver_opts['x0'] = x0
|
|
||||||
|
|
||||||
x = matrix_solver(A.tocsr(), b, **matrix_solver_opts)
|
x = matrix_solver(A.tocsr(), b, **matrix_solver_opts)
|
||||||
|
|
||||||
if adjoint:
|
if adjoint:
|
||||||
x0 = Pl.T.conjugate() @ x
|
x0 = Pl.H @ x
|
||||||
else:
|
else:
|
||||||
x0 = Pr @ x
|
x0 = Pr @ x
|
||||||
|
|
||||||
return vcfdfield_t(x0)
|
return x0
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -3,41 +3,13 @@ Tools for working with waveguide modes in 3D domains.
|
||||||
|
|
||||||
This module relies heavily on `waveguide_2d` and mostly just transforms
|
This module relies heavily on `waveguide_2d` and mostly just transforms
|
||||||
its parameters into 2D equivalents and expands the results back into 3D.
|
its parameters into 2D equivalents and expands the results back into 3D.
|
||||||
|
|
||||||
The intended workflow is:
|
|
||||||
|
|
||||||
1. Select a single-cell slice normal to the propagation axis.
|
|
||||||
2. Solve the corresponding 2D mode problem with `solve_mode(...)`.
|
|
||||||
3. Turn that mode into a one-sided source with `compute_source(...)`.
|
|
||||||
4. Build an overlap window with `compute_overlap_e(...)` for port readout.
|
|
||||||
5. For two-port discontinuities, `get_s_fdfd(...)` automates the source,
|
|
||||||
solve, and monitor-plane modal decomposition loop.
|
|
||||||
|
|
||||||
`polarity` is part of the public convention throughout this module:
|
|
||||||
|
|
||||||
- `+1` means forward propagation toward increasing index along `axis`
|
|
||||||
- `-1` means backward propagation toward decreasing index along `axis`
|
|
||||||
|
|
||||||
That same convention controls which side of the selected slice is used for the
|
|
||||||
overlap window and how the expanded field is phased.
|
|
||||||
"""
|
"""
|
||||||
from typing import Any, TypedDict, cast, Literal
|
from typing import Sequence, Any
|
||||||
import warnings
|
|
||||||
import operator
|
|
||||||
from collections.abc import Callable, Sequence
|
|
||||||
import numpy
|
import numpy
|
||||||
from numpy.typing import NDArray
|
from numpy.typing import NDArray
|
||||||
from numpy import complexfloating, floating
|
|
||||||
|
|
||||||
from ..fdmath import vec, unvec, dx_lists2_t, dx_lists_t, cfdfield_t, fdfield, cfdfield
|
from ..fdmath import vec, unvec, dx_lists_t, fdfield_t, cfdfield_t
|
||||||
from . import functional, operators, scpml, solvers, waveguide_2d
|
from . import operators, waveguide_2d
|
||||||
|
|
||||||
|
|
||||||
class Waveguide3DMode(TypedDict):
|
|
||||||
wavenumber: complex
|
|
||||||
wavenumber_2d: complex
|
|
||||||
H: NDArray[complexfloating]
|
|
||||||
E: NDArray[complexfloating]
|
|
||||||
|
|
||||||
|
|
||||||
def solve_mode(
|
def solve_mode(
|
||||||
|
|
@ -47,12 +19,12 @@ def solve_mode(
|
||||||
axis: int,
|
axis: int,
|
||||||
polarity: int,
|
polarity: int,
|
||||||
slices: Sequence[slice],
|
slices: Sequence[slice],
|
||||||
epsilon: fdfield,
|
epsilon: fdfield_t,
|
||||||
mu: fdfield | None = None,
|
mu: fdfield_t | None = None,
|
||||||
) -> Waveguide3DMode:
|
) -> dict[str, complex | NDArray[numpy.float_]]:
|
||||||
r"""
|
"""
|
||||||
Given a 3D grid, selects a slice from the grid and attempts to
|
Given a 3D grid, selects a slice from the grid and attempts to
|
||||||
solve for an eigenmode propagating through that slice.
|
solve for an eigenmode propagating through that slice.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
mode_number: Number of the mode, 0-indexed
|
mode_number: Number of the mode, 0-indexed
|
||||||
|
|
@ -61,103 +33,52 @@ def solve_mode(
|
||||||
axis: Propagation axis (0=x, 1=y, 2=z)
|
axis: Propagation axis (0=x, 1=y, 2=z)
|
||||||
polarity: Propagation direction (+1 for +ve, -1 for -ve)
|
polarity: Propagation direction (+1 for +ve, -1 for -ve)
|
||||||
slices: `epsilon[tuple(slices)]` is used to select the portion of the grid to use
|
slices: `epsilon[tuple(slices)]` is used to select the portion of the grid to use
|
||||||
as the waveguide cross-section. `slices[axis]` must select exactly one item.
|
as the waveguide cross-section. `slices[axis]` should select only one item.
|
||||||
epsilon: Dielectric constant
|
epsilon: Dielectric constant
|
||||||
mu: Magnetic permeability (default 1 everywhere)
|
mu: Magnetic permeability (default 1 everywhere)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary containing:
|
```
|
||||||
|
{
|
||||||
- `E`: full-grid electric field for the solved mode
|
'E': list[NDArray[numpy.float_]],
|
||||||
- `H`: full-grid magnetic field for the solved mode
|
'H': list[NDArray[numpy.float_]],
|
||||||
- `wavenumber`: propagation constant corrected for the discretized
|
'wavenumber': complex,
|
||||||
propagation axis
|
}
|
||||||
- `wavenumber_2d`: propagation constant of the reduced 2D eigenproblem
|
```
|
||||||
|
|
||||||
Notes:
|
|
||||||
The returned fields are normalized through the `waveguide_2d`
|
|
||||||
normalization convention before being expanded back to 3D.
|
|
||||||
"""
|
"""
|
||||||
args_2d, _order, _reverse, _dx_prop = _mode_cross_section(omega, dxes, axis, slices, epsilon, mu)
|
|
||||||
e_xy, wavenumber_2d = waveguide_2d.solve_mode(mode_number, **args_2d)
|
|
||||||
return mode_from_2d(e_xy, wavenumber_2d, omega=omega, dxes=dxes, axis=axis,
|
|
||||||
polarity=polarity, slices=slices, epsilon=epsilon, mu=mu)
|
|
||||||
|
|
||||||
|
|
||||||
def _mode_cross_section(
|
|
||||||
omega: complex,
|
|
||||||
dxes: dx_lists_t,
|
|
||||||
axis: int,
|
|
||||||
slices: Sequence[slice],
|
|
||||||
epsilon: fdfield,
|
|
||||||
mu: fdfield | None,
|
|
||||||
) -> tuple[dict[str, Any], NDArray, NDArray, complex]:
|
|
||||||
if axis not in range(3):
|
|
||||||
raise ValueError('axis must be 0, 1, or 2')
|
|
||||||
if numpy.ndim(epsilon) != 4 or epsilon.shape[0] != 3:
|
|
||||||
raise ValueError('epsilon must have shape (3, nx, ny, nz)')
|
|
||||||
slices = tuple(slices)
|
|
||||||
if len(slices) != 3 or any(not isinstance(part, slice) for part in slices):
|
|
||||||
raise ValueError('slices must contain three slices')
|
|
||||||
sizes = [len(range(size)[part]) for size, part in zip(epsilon.shape[1:], slices, strict=True)]
|
|
||||||
if sizes[axis] != 1 or any(size == 0 for size in sizes):
|
|
||||||
raise ValueError('slices must select one propagation cell and a nonempty cross-section')
|
|
||||||
if mu is None:
|
if mu is None:
|
||||||
mu = numpy.ones_like(epsilon)
|
mu = numpy.ones_like(epsilon)
|
||||||
elif mu.shape != epsilon.shape:
|
|
||||||
raise ValueError('mu must match epsilon')
|
slices = tuple(slices)
|
||||||
|
|
||||||
|
'''
|
||||||
|
Solve the 2D problem in the specified plane
|
||||||
|
'''
|
||||||
|
# Define rotation to set z as propagation direction
|
||||||
order = numpy.roll(range(3), 2 - axis)
|
order = numpy.roll(range(3), 2 - axis)
|
||||||
reverse = numpy.roll(range(3), axis - 2)
|
reverse_order = numpy.roll(range(3), axis - 2)
|
||||||
dx_prop = 0.5 * sum(dx[axis][slices[axis]].sum() for dx in dxes)
|
|
||||||
if not numpy.isfinite(dx_prop) or dx_prop == 0:
|
# Find dx in propagation direction
|
||||||
raise ValueError('propagation cell width must be finite and nonzero')
|
dxab_forward = numpy.array([dx[order[2]][slices[order[2]]] for dx in dxes])
|
||||||
args: dict[str, Any] = {
|
dx_prop = 0.5 * dxab_forward.sum()
|
||||||
|
|
||||||
|
# Reduce to 2D and solve the 2D problem
|
||||||
|
args_2d: dict[str, Any] = {
|
||||||
'omega': omega,
|
'omega': omega,
|
||||||
'dxes': [[dx[i][slices[i]] for i in order[:2]] for dx in dxes],
|
'dxes': [[dx[i][slices[i]] for i in order[:2]] for dx in dxes],
|
||||||
'epsilon': vec([epsilon[i][slices].transpose(order) for i in order]),
|
'epsilon': vec([epsilon[i][slices].transpose(order) for i in order]),
|
||||||
'mu': vec([mu[i][slices].transpose(order) for i in order]),
|
'mu': vec([mu[i][slices].transpose(order) for i in order]),
|
||||||
}
|
}
|
||||||
return args, order, reverse, dx_prop
|
e_xy, wavenumber_2d = waveguide_2d.solve_mode(mode_number, **args_2d)
|
||||||
|
|
||||||
|
'''
|
||||||
def mode_from_2d(
|
Apply corrections and expand to 3D
|
||||||
e_xy: NDArray,
|
'''
|
||||||
wavenumber_2d: complex,
|
|
||||||
*,
|
|
||||||
omega: complex,
|
|
||||||
dxes: dx_lists_t,
|
|
||||||
axis: int,
|
|
||||||
polarity: int,
|
|
||||||
slices: Sequence[slice],
|
|
||||||
epsilon: fdfield,
|
|
||||||
mu: fdfield | None = None,
|
|
||||||
normalization: Literal['power', 'lorentz'] = 'power',
|
|
||||||
) -> Waveguide3DMode:
|
|
||||||
"""Convert a supplied 2D electric eigenmode to a staggered full-grid mode.
|
|
||||||
|
|
||||||
``e_xy`` contains vectorized Ex/Ey in the rotated plane (the order used by
|
|
||||||
solve_mode). It must solve the 2D problem extracted from ``epsilon`` and
|
|
||||||
``dxes`` at ``slices``. No eigensolver is called. The helper reconstructs E/H,
|
|
||||||
normalizes them, applies propagation-grid dispersion and half-cell shifts,
|
|
||||||
rotates components, and embeds them in the full epsilon-shaped grid.
|
|
||||||
|
|
||||||
``polarity`` is +1 toward increasing indices and -1 toward decreasing
|
|
||||||
indices. ``normalization`` follows waveguide_2d.normalized_fields_e.
|
|
||||||
"""
|
|
||||||
if polarity not in (-1, 1):
|
|
||||||
raise ValueError('polarity must be -1 or +1')
|
|
||||||
args_2d, _order, reverse_order, dx_prop = _mode_cross_section(omega, dxes, axis, slices, epsilon, mu)
|
|
||||||
slices = tuple(slices)
|
|
||||||
expected = 2 * numpy.prod([len(values) for values in args_2d['dxes'][0]])
|
|
||||||
e_xy = numpy.asarray(e_xy)
|
|
||||||
if e_xy.shape != (expected,) or not numpy.isfinite(e_xy).all() or not numpy.isfinite(wavenumber_2d):
|
|
||||||
raise ValueError('the 2D eigenvector and wavenumber must be finite and match the cross-section')
|
|
||||||
wavenumber_2d = complex(wavenumber_2d)
|
|
||||||
# Correct wavenumber to account for numerical dispersion.
|
# Correct wavenumber to account for numerical dispersion.
|
||||||
wavenumber = 2 / dx_prop * numpy.arcsin(wavenumber_2d * dx_prop / 2)
|
wavenumber = 2 / dx_prop * numpy.arcsin(wavenumber_2d * dx_prop / 2)
|
||||||
|
|
||||||
shape = [d.size for d in args_2d['dxes'][0]]
|
shape = [d.size for d in args_2d['dxes'][0]]
|
||||||
ve, vh = waveguide_2d.normalized_fields_e(e_xy, wavenumber=wavenumber_2d, prop_phase=dx_prop * wavenumber, normalization=normalization, **args_2d)
|
ve, vh = waveguide_2d.normalized_fields_e(e_xy, wavenumber=wavenumber_2d, prop_phase=dx_prop * wavenumber, **args_2d)
|
||||||
e = unvec(ve, shape)
|
e = unvec(ve, shape)
|
||||||
h = unvec(vh, shape)
|
h = unvec(vh, shape)
|
||||||
|
|
||||||
|
|
@ -171,12 +92,11 @@ def mode_from_2d(
|
||||||
# Expand E, H to full epsilon space we were given
|
# Expand E, H to full epsilon space we were given
|
||||||
E = numpy.zeros_like(epsilon, dtype=complex)
|
E = numpy.zeros_like(epsilon, dtype=complex)
|
||||||
H = numpy.zeros_like(epsilon, dtype=complex)
|
H = numpy.zeros_like(epsilon, dtype=complex)
|
||||||
for aa, oo in enumerate(reverse_order):
|
for a, o in enumerate(reverse_order):
|
||||||
iii = cast('tuple[slice | int]', (aa, *slices))
|
E[(a, *slices)] = e[o][:, :, None].transpose(reverse_order)
|
||||||
E[iii] = e[oo][:, :, None].transpose(reverse_order)
|
H[(a, *slices)] = h[o][:, :, None].transpose(reverse_order)
|
||||||
H[iii] = h[oo][:, :, None].transpose(reverse_order)
|
|
||||||
|
|
||||||
results: Waveguide3DMode = {
|
results = {
|
||||||
'wavenumber': wavenumber,
|
'wavenumber': wavenumber,
|
||||||
'wavenumber_2d': wavenumber_2d,
|
'wavenumber_2d': wavenumber_2d,
|
||||||
'H': H,
|
'H': H,
|
||||||
|
|
@ -186,15 +106,15 @@ def mode_from_2d(
|
||||||
|
|
||||||
|
|
||||||
def compute_source(
|
def compute_source(
|
||||||
E: cfdfield,
|
E: cfdfield_t,
|
||||||
wavenumber: complex,
|
wavenumber: complex,
|
||||||
omega: complex,
|
omega: complex,
|
||||||
dxes: dx_lists_t,
|
dxes: dx_lists_t,
|
||||||
axis: int,
|
axis: int,
|
||||||
polarity: int,
|
polarity: int,
|
||||||
slices: Sequence[slice],
|
slices: Sequence[slice],
|
||||||
epsilon: fdfield,
|
epsilon: fdfield_t,
|
||||||
mu: fdfield | None = None,
|
mu: fdfield_t | None = None,
|
||||||
) -> cfdfield_t:
|
) -> cfdfield_t:
|
||||||
"""
|
"""
|
||||||
Given an eigenmode obtained by `solve_mode`, returns the current source distribution
|
Given an eigenmode obtained by `solve_mode`, returns the current source distribution
|
||||||
|
|
@ -209,19 +129,10 @@ def compute_source(
|
||||||
polarity: Propagation direction (+1 for +ve, -1 for -ve)
|
polarity: Propagation direction (+1 for +ve, -1 for -ve)
|
||||||
slices: `epsilon[tuple(slices)]` is used to select the portion of the grid to use
|
slices: `epsilon[tuple(slices)]` is used to select the portion of the grid to use
|
||||||
as the waveguide cross-section. `slices[axis]` should select only one item.
|
as the waveguide cross-section. `slices[axis]` should select only one item.
|
||||||
epsilon: Dielectric distribution on the same full grid as E and dxes.
|
|
||||||
Used by the boundary-source operator.
|
|
||||||
mu: Magnetic permeability (default 1 everywhere)
|
mu: Magnetic permeability (default 1 everywhere)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
`J` distribution for a one-sided electric-current source.
|
J distribution for the unidirectional source
|
||||||
|
|
||||||
Notes:
|
|
||||||
The source is built from the expanded mode field and a boundary-source
|
|
||||||
operator. The resulting current is intended to be injected with the
|
|
||||||
same sign convention used elsewhere in the package:
|
|
||||||
|
|
||||||
`E -= dt * J / epsilon`
|
|
||||||
"""
|
"""
|
||||||
E_expanded = expand_e(E=E, dxes=dxes, wavenumber=wavenumber, axis=axis,
|
E_expanded = expand_e(E=E, dxes=dxes, wavenumber=wavenumber, axis=axis,
|
||||||
polarity=polarity, slices=slices)
|
polarity=polarity, slices=slices)
|
||||||
|
|
@ -237,113 +148,66 @@ def compute_source(
|
||||||
|
|
||||||
masked_e2j = operators.e_boundary_source(mask=vec(mask), omega=omega, dxes=dxes, epsilon=vec(epsilon), mu=vec(mu))
|
masked_e2j = operators.e_boundary_source(mask=vec(mask), omega=omega, dxes=dxes, epsilon=vec(epsilon), mu=vec(mu))
|
||||||
J = unvec(masked_e2j @ vec(E_expanded), E.shape[1:])
|
J = unvec(masked_e2j @ vec(E_expanded), E.shape[1:])
|
||||||
return cfdfield_t(J)
|
return J
|
||||||
|
|
||||||
|
|
||||||
def compute_overlap_e(
|
def compute_overlap_e(
|
||||||
E: cfdfield,
|
E: cfdfield_t,
|
||||||
wavenumber: complex,
|
wavenumber: complex,
|
||||||
dxes: dx_lists_t,
|
dxes: dx_lists_t,
|
||||||
axis: int,
|
axis: int,
|
||||||
polarity: int,
|
polarity: int,
|
||||||
slices: Sequence[slice],
|
slices: Sequence[slice],
|
||||||
) -> cfdfield_t:
|
) -> cfdfield_t: # TODO DOCS
|
||||||
r"""
|
"""
|
||||||
Build an overlap field for projecting another 3D electric field onto a mode.
|
Given an eigenmode obtained by `solve_mode`, calculates an overlap_e for the
|
||||||
|
mode orthogonality relation Integrate(((E x H_mode) + (E_mode x H)) dot dn)
|
||||||
The returned field is intended for the discrete overlap expression
|
[assumes reflection symmetry].
|
||||||
|
|
||||||
$$
|
|
||||||
\sum \mathrm{overlap\_e} \; E_\mathrm{other}^*
|
|
||||||
$$
|
|
||||||
|
|
||||||
where the sum is over the full Yee-grid field storage.
|
|
||||||
|
|
||||||
The construction uses a two-cell window immediately upstream of the selected
|
|
||||||
slice:
|
|
||||||
|
|
||||||
- for `polarity=+1`, the two cells just before `slices[axis].start`
|
|
||||||
- for `polarity=-1`, the two cells just after `slices[axis].stop`
|
|
||||||
|
|
||||||
The window is clipped to the simulation domain if necessary. A clipped but
|
|
||||||
non-empty window raises `RuntimeWarning`; an empty window raises
|
|
||||||
`ValueError`.
|
|
||||||
|
|
||||||
The derivation below assumes reflection symmetry and the standard waveguide
|
|
||||||
overlap relation involving
|
|
||||||
|
|
||||||
$$
|
|
||||||
\int ((E \times H_\mathrm{mode}) + (E_\mathrm{mode} \times H)) \cdot dn.
|
|
||||||
$$
|
|
||||||
|
|
||||||
E x H_mode + E_mode x H
|
|
||||||
-> Ex Hmy - EyHmx + Emx Hy - Emy Hx (Z-prop)
|
|
||||||
Ex we/B Emx + Ex i/B dy Hmz - Ey (-we/B Emy) - Ey i/B dx Hmz
|
|
||||||
we/B (Ex Emx + Ey Emy) + i/B (Ex dy Hmz - Ey dx Hmz)
|
|
||||||
we/B (Ex Emx + Ey Emy) + i/B (Ex dy (dx Emy - dy Emx) - Ey dx (dx Emy - dy Emx))
|
|
||||||
we/B (Ex Emx + Ey Emy) + i/B (Ex dy dx Emy - Ex dy dy Emx - Ey dx dx Emy - Ey dx dy Emx)
|
|
||||||
|
|
||||||
Ex j/wu (-jB Emx - dx Emz) - Ey j/wu (dy Emz + jB Emy)
|
|
||||||
B/wu (Ex Emx + Ey Emy) - j/wu (Ex dx Emz + Ey dy Emz)
|
|
||||||
|
|
||||||
|
TODO: add reference
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
E: E-field of the mode
|
E: E-field of the mode
|
||||||
|
H: H-field of the mode (advanced by half of a Yee cell from E)
|
||||||
wavenumber: Wavenumber of the mode
|
wavenumber: Wavenumber of the mode
|
||||||
|
omega: Angular frequency of the simulation
|
||||||
dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types`
|
dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types`
|
||||||
axis: Propagation axis (0=x, 1=y, 2=z)
|
axis: Propagation axis (0=x, 1=y, 2=z)
|
||||||
polarity: Propagation direction (+1 for +ve, -1 for -ve)
|
polarity: Propagation direction (+1 for +ve, -1 for -ve)
|
||||||
slices: `epsilon[tuple(slices)]` is used to select the portion of the grid to use
|
slices: `epsilon[tuple(slices)]` is used to select the portion of the grid to use
|
||||||
as the waveguide cross-section. slices[axis] should select only one item.
|
as the waveguide cross-section. slices[axis] should select only one item.
|
||||||
|
mu: Magnetic permeability (default 1 everywhere)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
`overlap_e` normalized so that `numpy.sum(overlap_e * E.conj()) == 1`
|
overlap_e such that `numpy.sum(overlap_e * other_e.conj())` computes the overlap integral
|
||||||
over the retained overlap window.
|
|
||||||
"""
|
"""
|
||||||
slices = tuple(slices)
|
slices = tuple(slices)
|
||||||
|
|
||||||
Ee = expand_e(E=E, wavenumber=wavenumber, dxes=dxes,
|
Ee = expand_e(E=E, wavenumber=wavenumber, dxes=dxes,
|
||||||
axis=axis, polarity=polarity, slices=slices)
|
axis=axis, polarity=polarity, slices=slices)
|
||||||
|
|
||||||
axis_size = E.shape[axis + 1]
|
start, stop = sorted((slices[axis].start, slices[axis].start - 2 * polarity))
|
||||||
if polarity > 0:
|
|
||||||
start = slices[axis].start - 2
|
|
||||||
stop = slices[axis].start
|
|
||||||
else:
|
|
||||||
start = slices[axis].stop
|
|
||||||
stop = slices[axis].stop + 2
|
|
||||||
|
|
||||||
clipped_start = max(0, start)
|
|
||||||
clipped_stop = min(axis_size, stop)
|
|
||||||
if clipped_start >= clipped_stop:
|
|
||||||
raise ValueError('Requested overlap window lies outside the domain')
|
|
||||||
if clipped_start != start or clipped_stop != stop:
|
|
||||||
warnings.warn('Requested overlap window was clipped to fit within the domain', RuntimeWarning, stacklevel=2)
|
|
||||||
|
|
||||||
slices2_l = list(slices)
|
slices2_l = list(slices)
|
||||||
slices2_l[axis] = slice(clipped_start, clipped_stop)
|
slices2_l[axis] = slice(start, stop)
|
||||||
slices2 = (slice(None), *slices2_l)
|
slices2 = (slice(None), *slices2_l)
|
||||||
|
|
||||||
Etgt = numpy.zeros_like(Ee)
|
Etgt = numpy.zeros_like(Ee)
|
||||||
Etgt[slices2] = Ee[slices2]
|
Etgt[slices2] = Ee[slices2]
|
||||||
|
|
||||||
# Note: We normalize so that (Etgt @ E.conj()) == 1, so (Etgt @ Etgt.conj) != 1
|
Etgt /= (Etgt.conj() * Etgt).sum()
|
||||||
norm = (Etgt.conj() * Etgt).sum()
|
return Etgt
|
||||||
if norm == 0:
|
|
||||||
raise ValueError('Requested overlap window contains no overlap field support')
|
|
||||||
Etgt = Etgt / norm
|
|
||||||
return cfdfield_t(Etgt)
|
|
||||||
|
|
||||||
|
|
||||||
def expand_e(
|
def expand_e(
|
||||||
E: cfdfield,
|
E: cfdfield_t,
|
||||||
wavenumber: complex,
|
wavenumber: complex,
|
||||||
dxes: dx_lists_t,
|
dxes: dx_lists_t,
|
||||||
axis: int,
|
axis: int,
|
||||||
polarity: int,
|
polarity: int,
|
||||||
slices: Sequence[slice],
|
slices: Sequence[slice],
|
||||||
) -> cfdfield_t:
|
) -> cfdfield_t:
|
||||||
r"""
|
"""
|
||||||
Given an eigenmode obtained by `solve_mode`, expands the E-field from the 2D
|
Given an eigenmode obtained by `solve_mode`, expands the E-field from the 2D
|
||||||
slice where the mode was calculated to the entire domain (along the propagation
|
slice where the mode was calculated to the entire domain (along the propagation
|
||||||
axis). This assumes the epsilon cross-section remains constant throughout the
|
axis). This assumes the epsilon cross-section remains constant throughout the
|
||||||
|
|
@ -361,16 +225,6 @@ def expand_e(
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
`E`, with the original field expanded along the specified `axis`.
|
`E`, with the original field expanded along the specified `axis`.
|
||||||
|
|
||||||
Notes:
|
|
||||||
This helper assumes that the waveguide cross-section remains constant
|
|
||||||
along the propagation axis and applies the phase factor
|
|
||||||
|
|
||||||
$$
|
|
||||||
e^{-i \, \mathrm{polarity} \, wavenumber \, \Delta z}
|
|
||||||
$$
|
|
||||||
|
|
||||||
to each copied slice.
|
|
||||||
"""
|
"""
|
||||||
slices = tuple(slices)
|
slices = tuple(slices)
|
||||||
|
|
||||||
|
|
@ -391,553 +245,4 @@ def expand_e(
|
||||||
slices_in = (slice(None), *slices)
|
slices_in = (slice(None), *slices)
|
||||||
|
|
||||||
E_expanded[slices_exp] = phase_E * numpy.array(E)[slices_in]
|
E_expanded[slices_exp] = phase_E * numpy.array(E)[slices_in]
|
||||||
return cfdfield_t(E_expanded)
|
return E_expanded
|
||||||
|
|
||||||
|
|
||||||
def _slice_start(name: str, slices: Sequence[slice], axis: int, shape: Sequence[int]) -> int:
|
|
||||||
if len(slices) != 3:
|
|
||||||
raise ValueError(f'{name} must contain exactly three slices')
|
|
||||||
axis_slice = slices[axis]
|
|
||||||
if axis_slice.start is None or axis_slice.stop is None:
|
|
||||||
raise ValueError(f'{name}[axis] must have explicit start and stop')
|
|
||||||
if axis_slice.step not in (None, 1):
|
|
||||||
raise ValueError(f'{name}[axis] must not use a step')
|
|
||||||
if axis_slice.stop - axis_slice.start != 1:
|
|
||||||
raise ValueError(f'{name}[axis] must select exactly one cell')
|
|
||||||
if axis_slice.start < 0 or axis_slice.stop > shape[axis]:
|
|
||||||
raise ValueError(f'{name}[axis] lies outside the simulation domain')
|
|
||||||
return axis_slice.start
|
|
||||||
|
|
||||||
|
|
||||||
def _as_slice_tuple(name: str, slices: Sequence[slice], axis: int, shape: Sequence[int]) -> tuple[slice, slice, slice]:
|
|
||||||
_slice_start(name, slices, axis, shape)
|
|
||||||
return (slices[0], slices[1], slices[2])
|
|
||||||
|
|
||||||
|
|
||||||
def _nonbool_index(value: object) -> int:
|
|
||||||
if isinstance(value, bool | numpy.bool_):
|
|
||||||
raise TypeError
|
|
||||||
return operator.index(value)
|
|
||||||
|
|
||||||
|
|
||||||
def _as_mode_numbers(name: str, mode_numbers: Sequence[int]) -> tuple[int, ...]:
|
|
||||||
modes: list[int] = []
|
|
||||||
for mode in mode_numbers:
|
|
||||||
try:
|
|
||||||
mode_i = _nonbool_index(mode)
|
|
||||||
except TypeError as err:
|
|
||||||
raise ValueError(f'{name} entries must be integers') from err
|
|
||||||
if mode_i < 0:
|
|
||||||
raise ValueError('mode numbers must be non-negative')
|
|
||||||
modes.append(mode_i)
|
|
||||||
if not modes:
|
|
||||||
raise ValueError('each FDFD port must contain at least one mode number')
|
|
||||||
return tuple(modes)
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_fdfd_port_specs(
|
|
||||||
left_mode_numbers: Sequence[int],
|
|
||||||
right_mode_numbers: Sequence[int],
|
|
||||||
left_source_slice: Sequence[slice],
|
|
||||||
left_monitor_slice: Sequence[slice],
|
|
||||||
right_source_slice: Sequence[slice],
|
|
||||||
right_monitor_slice: Sequence[slice],
|
|
||||||
axis: int,
|
|
||||||
shape: Sequence[int],
|
|
||||||
) -> tuple[
|
|
||||||
tuple[int, ...],
|
|
||||||
tuple[int, ...],
|
|
||||||
tuple[slice, slice, slice],
|
|
||||||
tuple[slice, slice, slice],
|
|
||||||
tuple[slice, slice, slice],
|
|
||||||
tuple[slice, slice, slice],
|
|
||||||
]:
|
|
||||||
if axis not in (0, 1, 2):
|
|
||||||
raise ValueError('axis must be 0, 1, or 2')
|
|
||||||
|
|
||||||
left_modes_i = _as_mode_numbers('left_mode_numbers', left_mode_numbers)
|
|
||||||
right_modes_i = _as_mode_numbers('right_mode_numbers', right_mode_numbers)
|
|
||||||
|
|
||||||
left_source = _as_slice_tuple('left_source_slice', left_source_slice, axis, shape)
|
|
||||||
left_monitor = _as_slice_tuple('left_monitor_slice', left_monitor_slice, axis, shape)
|
|
||||||
right_source = _as_slice_tuple('right_source_slice', right_source_slice, axis, shape)
|
|
||||||
right_monitor = _as_slice_tuple('right_monitor_slice', right_monitor_slice, axis, shape)
|
|
||||||
|
|
||||||
left_source_i = _slice_start('left_source_slice', left_source, axis, shape)
|
|
||||||
left_monitor_i = _slice_start('left_monitor_slice', left_monitor, axis, shape)
|
|
||||||
right_monitor_i = _slice_start('right_monitor_slice', right_monitor, axis, shape)
|
|
||||||
right_source_i = _slice_start('right_source_slice', right_source, axis, shape)
|
|
||||||
if not (left_source_i < left_monitor_i < right_monitor_i < right_source_i):
|
|
||||||
raise ValueError('port slices must be ordered as left source, left monitor, right monitor, right source')
|
|
||||||
|
|
||||||
return left_modes_i, right_modes_i, left_source, left_monitor, right_source, right_monitor
|
|
||||||
|
|
||||||
|
|
||||||
def _port_decomposition(
|
|
||||||
incoming_modes: Sequence[Waveguide3DMode],
|
|
||||||
outgoing_modes: Sequence[Waveguide3DMode],
|
|
||||||
*,
|
|
||||||
dxes: dx_lists_t,
|
|
||||||
axis: int,
|
|
||||||
incoming_polarity: int,
|
|
||||||
outgoing_polarity: int,
|
|
||||||
slices: Sequence[slice],
|
|
||||||
) -> tuple[NDArray[numpy.complex128], tuple[cfdfield_t, ...], int]:
|
|
||||||
mode_count = len(incoming_modes)
|
|
||||||
if len(outgoing_modes) != mode_count:
|
|
||||||
raise ValueError('incoming and outgoing monitor bases must contain the same number of modes')
|
|
||||||
overlaps = (
|
|
||||||
*(
|
|
||||||
compute_overlap_e(
|
|
||||||
E=mode['E'],
|
|
||||||
wavenumber=mode['wavenumber'],
|
|
||||||
dxes=dxes,
|
|
||||||
axis=axis,
|
|
||||||
polarity=incoming_polarity,
|
|
||||||
slices=slices,
|
|
||||||
)
|
|
||||||
for mode in incoming_modes
|
|
||||||
),
|
|
||||||
*(
|
|
||||||
compute_overlap_e(
|
|
||||||
E=mode['E'],
|
|
||||||
wavenumber=mode['wavenumber'],
|
|
||||||
dxes=dxes,
|
|
||||||
axis=axis,
|
|
||||||
polarity=outgoing_polarity,
|
|
||||||
slices=slices,
|
|
||||||
)
|
|
||||||
for mode in outgoing_modes
|
|
||||||
),
|
|
||||||
)
|
|
||||||
basis_fields = (
|
|
||||||
*(
|
|
||||||
expand_e(
|
|
||||||
E=mode['E'],
|
|
||||||
wavenumber=mode['wavenumber'],
|
|
||||||
dxes=dxes,
|
|
||||||
axis=axis,
|
|
||||||
polarity=incoming_polarity,
|
|
||||||
slices=slices,
|
|
||||||
)
|
|
||||||
for mode in incoming_modes
|
|
||||||
),
|
|
||||||
*(
|
|
||||||
expand_e(
|
|
||||||
E=mode['E'],
|
|
||||||
wavenumber=mode['wavenumber'],
|
|
||||||
dxes=dxes,
|
|
||||||
axis=axis,
|
|
||||||
polarity=outgoing_polarity,
|
|
||||||
slices=slices,
|
|
||||||
)
|
|
||||||
for mode in outgoing_modes
|
|
||||||
),
|
|
||||||
)
|
|
||||||
matrix = numpy.empty((2 * mode_count, 2 * mode_count), dtype=complex)
|
|
||||||
for row, overlap in enumerate(overlaps):
|
|
||||||
overlap_vec_conj = vec(overlap).conj()
|
|
||||||
for col, basis_field in enumerate(basis_fields):
|
|
||||||
matrix[row, col] = vec(basis_field) @ overlap_vec_conj
|
|
||||||
return matrix, overlaps, mode_count
|
|
||||||
|
|
||||||
|
|
||||||
def _decompose_port_field(
|
|
||||||
decomposition: tuple[NDArray[numpy.complex128], tuple[cfdfield_t, ...], int],
|
|
||||||
field: cfdfield_t,
|
|
||||||
) -> tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]:
|
|
||||||
matrix, overlaps, mode_count = decomposition
|
|
||||||
field_vec = vec(field)
|
|
||||||
measured = numpy.asarray([field_vec @ vec(overlap).conj() for overlap in overlaps])
|
|
||||||
coeffs, _resid, _rank, _sing = numpy.linalg.lstsq(matrix, measured, rcond=None)
|
|
||||||
return coeffs[:mode_count], coeffs[mode_count:]
|
|
||||||
|
|
||||||
|
|
||||||
def _copy_cross_section(
|
|
||||||
target: NDArray,
|
|
||||||
cross_section: NDArray,
|
|
||||||
axis: int,
|
|
||||||
index: int,
|
|
||||||
) -> None:
|
|
||||||
slices: list[slice | int] = [slice(None), slice(None), slice(None), slice(None)]
|
|
||||||
slices[axis + 1] = index
|
|
||||||
target[tuple(slices)] = cross_section
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_cross_section(name: str, field: NDArray) -> NDArray:
|
|
||||||
array = numpy.asarray(field)
|
|
||||||
if array.ndim != 3 or array.shape[0] != 3:
|
|
||||||
raise ValueError(f'{name} must have shape (3, n0, n1)')
|
|
||||||
if not numpy.isfinite(array).all():
|
|
||||||
raise ValueError(f'{name} must contain only finite values')
|
|
||||||
return array
|
|
||||||
|
|
||||||
|
|
||||||
def _as_axis_pair(name: str, value: int | Sequence[int]) -> tuple[int, int]:
|
|
||||||
if isinstance(value, int):
|
|
||||||
pair = (value, value)
|
|
||||||
else:
|
|
||||||
pair = tuple(operator.index(v) for v in value)
|
|
||||||
if len(pair) != 2:
|
|
||||||
raise ValueError(f'{name} must be an integer or a two-entry sequence')
|
|
||||||
if min(pair) < 0:
|
|
||||||
raise ValueError(f'{name} entries must be non-negative')
|
|
||||||
return pair
|
|
||||||
|
|
||||||
|
|
||||||
def _pad_cross_section(field: NDArray, padding_cells: tuple[int, int]) -> NDArray:
|
|
||||||
if padding_cells == (0, 0):
|
|
||||||
return field
|
|
||||||
return numpy.pad(
|
|
||||||
field,
|
|
||||||
(
|
|
||||||
(0, 0),
|
|
||||||
(padding_cells[0], padding_cells[0]),
|
|
||||||
(padding_cells[1], padding_cells[1]),
|
|
||||||
),
|
|
||||||
mode='edge',
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _pad_dxes_2d(dxes_2d: dx_lists2_t, padding_cells: tuple[int, int]) -> list[list[NDArray[floating | complexfloating]]]:
|
|
||||||
padded: list[list[NDArray[floating | complexfloating]]] = [[numpy.empty(0), numpy.empty(0)] for _ in range(2)]
|
|
||||||
for eh in range(2):
|
|
||||||
for axis_2d in range(2):
|
|
||||||
values = numpy.asarray(dxes_2d[eh][axis_2d])
|
|
||||||
pad = padding_cells[axis_2d]
|
|
||||||
if pad == 0:
|
|
||||||
padded[eh][axis_2d] = values.copy()
|
|
||||||
else:
|
|
||||||
padded[eh][axis_2d] = numpy.pad(values, (pad, pad), mode='edge')
|
|
||||||
return padded
|
|
||||||
|
|
||||||
|
|
||||||
def get_s_fdfd(
|
|
||||||
omega: complex,
|
|
||||||
dxes: dx_lists_t,
|
|
||||||
epsilon: fdfield,
|
|
||||||
*,
|
|
||||||
left_mode_numbers: Sequence[int],
|
|
||||||
left_source_slice: Sequence[slice],
|
|
||||||
left_monitor_slice: Sequence[slice],
|
|
||||||
right_source_slice: Sequence[slice],
|
|
||||||
right_monitor_slice: Sequence[slice],
|
|
||||||
right_mode_numbers: Sequence[int] | None = None,
|
|
||||||
axis: int = 0,
|
|
||||||
solve_dxes: dx_lists_t | None = None,
|
|
||||||
mu: fdfield | None = None,
|
|
||||||
matrix_solver: Callable[..., Any] | None = None,
|
|
||||||
matrix_solver_opts: dict[str, Any] | None = None,
|
|
||||||
source_scales: Sequence[complex] | None = None,
|
|
||||||
incident_amplitude_atol: float = 1e-12,
|
|
||||||
return_diagnostics: bool = False,
|
|
||||||
) -> NDArray[numpy.complex128] | tuple[NDArray[numpy.complex128], dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Extract a two-port guided-mode scattering matrix from local 3D FDFD solves.
|
|
||||||
|
|
||||||
The returned S-matrix is ordered as `[[R_L, T_R_to_L], [T_L_to_R, R_R]]`, with
|
|
||||||
reference planes at the supplied monitor slices. Each column is normalized
|
|
||||||
by the incident modal amplitude measured on the corresponding input monitor.
|
|
||||||
"""
|
|
||||||
if incident_amplitude_atol <= 0:
|
|
||||||
raise ValueError('incident_amplitude_atol must be positive')
|
|
||||||
|
|
||||||
epsilon = numpy.asarray(epsilon)
|
|
||||||
if epsilon.ndim != 4 or epsilon.shape[0] != 3:
|
|
||||||
raise ValueError('epsilon must have shape (3, nx, ny, nz)')
|
|
||||||
if mu is not None:
|
|
||||||
mu = numpy.asarray(mu)
|
|
||||||
if mu.shape != epsilon.shape:
|
|
||||||
raise ValueError('mu must have the same shape as epsilon')
|
|
||||||
|
|
||||||
if right_mode_numbers is None:
|
|
||||||
right_mode_numbers = left_mode_numbers
|
|
||||||
|
|
||||||
shape = epsilon.shape[1:]
|
|
||||||
left_modes_i, right_modes_i, left_source, left_monitor, right_source, right_monitor = _validate_fdfd_port_specs(
|
|
||||||
left_mode_numbers,
|
|
||||||
right_mode_numbers,
|
|
||||||
left_source_slice,
|
|
||||||
left_monitor_slice,
|
|
||||||
right_source_slice,
|
|
||||||
right_monitor_slice,
|
|
||||||
axis,
|
|
||||||
shape,
|
|
||||||
)
|
|
||||||
n_left = len(left_modes_i)
|
|
||||||
n_right = len(right_modes_i)
|
|
||||||
n_total = n_left + n_right
|
|
||||||
|
|
||||||
if solve_dxes is None:
|
|
||||||
solve_dxes = dxes
|
|
||||||
if source_scales is None:
|
|
||||||
source_scale_array = numpy.ones((n_total,), dtype=complex)
|
|
||||||
else:
|
|
||||||
source_scale_array = numpy.asarray(source_scales, dtype=complex)
|
|
||||||
if source_scale_array.shape != (n_total,):
|
|
||||||
raise ValueError('source_scales must have one entry per incident mode')
|
|
||||||
|
|
||||||
def solve_modes(mode_numbers: Sequence[int], polarity: int, slices: Sequence[slice]) -> tuple[Waveguide3DMode, ...]:
|
|
||||||
return tuple(
|
|
||||||
solve_mode(mode_number, omega=omega, dxes=dxes, axis=axis, polarity=polarity, slices=slices, epsilon=epsilon, mu=mu)
|
|
||||||
for mode_number in mode_numbers
|
|
||||||
)
|
|
||||||
|
|
||||||
left_source_modes = solve_modes(left_modes_i, +1, left_source)
|
|
||||||
right_source_modes = solve_modes(right_modes_i, -1, right_source)
|
|
||||||
|
|
||||||
left_in_modes = solve_modes(left_modes_i, +1, left_monitor)
|
|
||||||
left_out_modes = solve_modes(left_modes_i, -1, left_monitor)
|
|
||||||
right_in_modes = solve_modes(right_modes_i, -1, right_monitor)
|
|
||||||
right_out_modes = solve_modes(right_modes_i, +1, right_monitor)
|
|
||||||
left_decomposition = _port_decomposition(
|
|
||||||
left_in_modes,
|
|
||||||
left_out_modes,
|
|
||||||
dxes=dxes,
|
|
||||||
axis=axis,
|
|
||||||
incoming_polarity=+1,
|
|
||||||
outgoing_polarity=-1,
|
|
||||||
slices=left_monitor,
|
|
||||||
)
|
|
||||||
right_decomposition = _port_decomposition(
|
|
||||||
right_in_modes,
|
|
||||||
right_out_modes,
|
|
||||||
dxes=dxes,
|
|
||||||
axis=axis,
|
|
||||||
incoming_polarity=-1,
|
|
||||||
outgoing_polarity=+1,
|
|
||||||
slices=right_monitor,
|
|
||||||
)
|
|
||||||
|
|
||||||
s = numpy.zeros((n_total, n_total), dtype=complex)
|
|
||||||
raw_outgoing = numpy.zeros_like(s)
|
|
||||||
incident_amplitudes = numpy.zeros((n_total,), dtype=complex)
|
|
||||||
electric_fields: list[cfdfield_t] = []
|
|
||||||
magnetic_fields: list[cfdfield_t] = []
|
|
||||||
|
|
||||||
excitation_modes: list[tuple[int, int, Sequence[slice], Waveguide3DMode]] = [
|
|
||||||
*((col, +1, left_source, left_source_modes[col]) for col in range(n_left)),
|
|
||||||
*((n_left + col, -1, right_source, right_source_modes[col]) for col in range(n_right)),
|
|
||||||
]
|
|
||||||
|
|
||||||
for column, polarity, source_slice, source_mode in excitation_modes:
|
|
||||||
current = compute_source(
|
|
||||||
E=source_mode['E'],
|
|
||||||
wavenumber=source_mode['wavenumber'],
|
|
||||||
omega=omega,
|
|
||||||
dxes=dxes,
|
|
||||||
axis=axis,
|
|
||||||
polarity=polarity,
|
|
||||||
slices=source_slice,
|
|
||||||
epsilon=epsilon,
|
|
||||||
mu=mu,
|
|
||||||
)
|
|
||||||
current = cfdfield_t(source_scale_array[column] * current)
|
|
||||||
solve_kwargs: dict[str, Any] = {
|
|
||||||
'omega': omega,
|
|
||||||
'dxes': solve_dxes,
|
|
||||||
'J': vec(current),
|
|
||||||
'epsilon': vec(epsilon),
|
|
||||||
'mu': vec(mu),
|
|
||||||
'matrix_solver_opts': {} if matrix_solver_opts is None else dict(matrix_solver_opts),
|
|
||||||
}
|
|
||||||
if matrix_solver is not None:
|
|
||||||
solve_kwargs['matrix_solver'] = matrix_solver
|
|
||||||
e_field = unvec(solvers.generic(**solve_kwargs), shape)
|
|
||||||
left_in, left_out = _decompose_port_field(left_decomposition, e_field)
|
|
||||||
right_in, right_out = _decompose_port_field(right_decomposition, e_field)
|
|
||||||
if column < n_left:
|
|
||||||
incident = left_in[column]
|
|
||||||
else:
|
|
||||||
incident = right_in[column - n_left]
|
|
||||||
if abs(incident) <= incident_amplitude_atol:
|
|
||||||
raise ValueError(f'incident modal amplitude for column {column} is zero')
|
|
||||||
|
|
||||||
raw_column = numpy.concatenate((left_out, right_out))
|
|
||||||
|
|
||||||
incident_amplitudes[column] = incident
|
|
||||||
raw_outgoing[:, column] = raw_column
|
|
||||||
s[:, column] = raw_column / incident
|
|
||||||
if return_diagnostics:
|
|
||||||
electric_fields.append(cfdfield_t(e_field))
|
|
||||||
magnetic_fields.append(cfdfield_t(functional.e2h(omega, solve_dxes, mu=mu)(e_field)))
|
|
||||||
|
|
||||||
if not return_diagnostics:
|
|
||||||
return s
|
|
||||||
|
|
||||||
guided_powers = numpy.sum(numpy.abs(s) ** 2, axis=0).real
|
|
||||||
diagnostics: dict[str, Any] = {
|
|
||||||
'axis': axis,
|
|
||||||
'left_mode_numbers': left_modes_i,
|
|
||||||
'right_mode_numbers': right_modes_i,
|
|
||||||
'left_source_slice': left_source,
|
|
||||||
'left_monitor_slice': left_monitor,
|
|
||||||
'right_source_slice': right_source,
|
|
||||||
'right_monitor_slice': right_monitor,
|
|
||||||
'left_wavenumbers': numpy.asarray([mode['wavenumber'] for mode in left_out_modes]),
|
|
||||||
'right_wavenumbers': numpy.asarray([mode['wavenumber'] for mode in right_out_modes]),
|
|
||||||
'source_scales': source_scale_array,
|
|
||||||
'electric_fields': tuple(electric_fields),
|
|
||||||
'magnetic_fields': tuple(magnetic_fields),
|
|
||||||
'incident_amplitudes': incident_amplitudes,
|
|
||||||
'raw_outgoing_amplitudes': raw_outgoing,
|
|
||||||
'guided_powers': guided_powers,
|
|
||||||
'unresolved_powers': 1.0 - guided_powers,
|
|
||||||
'left_modes': left_out_modes,
|
|
||||||
'right_modes': right_out_modes,
|
|
||||||
}
|
|
||||||
return s, diagnostics
|
|
||||||
|
|
||||||
|
|
||||||
def get_s_fdfd_from_2d_ports(
|
|
||||||
left_epsilon: NDArray,
|
|
||||||
right_epsilon: NDArray,
|
|
||||||
*,
|
|
||||||
omega: complex,
|
|
||||||
dxes_2d: dx_lists2_t,
|
|
||||||
mode_numbers: Sequence[int],
|
|
||||||
right_mode_numbers: Sequence[int] | None = None,
|
|
||||||
axis: int = 0,
|
|
||||||
left_mu: NDArray | None = None,
|
|
||||||
right_mu: NDArray | None = None,
|
|
||||||
prop_dx: float | None = None,
|
|
||||||
pml_cells: int = 8,
|
|
||||||
pml_buffer_cells: int = 2,
|
|
||||||
source_monitor_cells: int = 5,
|
|
||||||
monitor_interface_cells: int = 6,
|
|
||||||
transverse_padding_cells: int | Sequence[int] = 0,
|
|
||||||
transverse_pml_cells: int | Sequence[int] = 0,
|
|
||||||
pml_epsilon_effective: float = 1.0,
|
|
||||||
matrix_solver: Callable[..., Any] | None = None,
|
|
||||||
matrix_solver_opts: dict[str, Any] | None = None,
|
|
||||||
source_scales: Sequence[complex] | None = None,
|
|
||||||
return_diagnostics: bool = False,
|
|
||||||
) -> NDArray[numpy.complex128] | tuple[NDArray[numpy.complex128], dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Extrude two 2D port cross-sections into a short 3D FDFD interface problem.
|
|
||||||
"""
|
|
||||||
if axis not in (0, 1, 2):
|
|
||||||
raise ValueError('axis must be 0, 1, or 2')
|
|
||||||
if min(pml_cells, pml_buffer_cells, source_monitor_cells, monitor_interface_cells) < 1:
|
|
||||||
raise ValueError('PML, buffer, source/monitor, and monitor/interface cell counts must be positive')
|
|
||||||
|
|
||||||
padding_cells = _as_axis_pair('transverse_padding_cells', transverse_padding_cells)
|
|
||||||
transverse_pml = _as_axis_pair('transverse_pml_cells', transverse_pml_cells)
|
|
||||||
|
|
||||||
left_epsilon = _pad_cross_section(_validate_cross_section('left_epsilon', left_epsilon), padding_cells)
|
|
||||||
right_epsilon = _pad_cross_section(_validate_cross_section('right_epsilon', right_epsilon), padding_cells)
|
|
||||||
if left_epsilon.shape != right_epsilon.shape:
|
|
||||||
raise ValueError('left_epsilon and right_epsilon must have the same shape')
|
|
||||||
if left_mu is not None:
|
|
||||||
left_mu = _pad_cross_section(_validate_cross_section('left_mu', left_mu), padding_cells)
|
|
||||||
if left_mu.shape != left_epsilon.shape:
|
|
||||||
raise ValueError('left_mu must match left_epsilon')
|
|
||||||
if right_mu is not None:
|
|
||||||
right_mu = _pad_cross_section(_validate_cross_section('right_mu', right_mu), padding_cells)
|
|
||||||
if right_mu.shape != right_epsilon.shape:
|
|
||||||
raise ValueError('right_mu must match right_epsilon')
|
|
||||||
if (left_mu is None) != (right_mu is None):
|
|
||||||
raise ValueError('left_mu and right_mu must be supplied together')
|
|
||||||
|
|
||||||
if right_mode_numbers is None:
|
|
||||||
right_mode_numbers = mode_numbers
|
|
||||||
dxes_2d_padded = _pad_dxes_2d(dxes_2d, padding_cells)
|
|
||||||
transverse_shape = left_epsilon.shape[1:]
|
|
||||||
transverse_axes = tuple(ax for ax in (0, 1, 2) if ax != axis)
|
|
||||||
if prop_dx is None:
|
|
||||||
prop_dx = float(numpy.median(numpy.real(numpy.concatenate([numpy.asarray(d) for d in dxes_2d_padded[0]]))))
|
|
||||||
if prop_dx <= 0:
|
|
||||||
raise ValueError('prop_dx must be positive')
|
|
||||||
|
|
||||||
left_source_i = pml_cells + pml_buffer_cells
|
|
||||||
left_monitor_i = left_source_i + source_monitor_cells
|
|
||||||
interface_i = left_monitor_i + monitor_interface_cells
|
|
||||||
right_monitor_i = interface_i + monitor_interface_cells
|
|
||||||
right_source_i = right_monitor_i + source_monitor_cells
|
|
||||||
axis_size = right_source_i + pml_buffer_cells + pml_cells + 1
|
|
||||||
|
|
||||||
shape3 = [0, 0, 0]
|
|
||||||
shape3[axis] = axis_size
|
|
||||||
shape3[transverse_axes[0]] = transverse_shape[0]
|
|
||||||
shape3[transverse_axes[1]] = transverse_shape[1]
|
|
||||||
|
|
||||||
epsilon = numpy.empty((3, *shape3), dtype=numpy.result_type(left_epsilon, right_epsilon))
|
|
||||||
mu = None if left_mu is None else numpy.empty((3, *shape3), dtype=numpy.result_type(left_mu, right_mu))
|
|
||||||
for index in range(axis_size):
|
|
||||||
cross_epsilon = left_epsilon if index < interface_i else right_epsilon
|
|
||||||
_copy_cross_section(epsilon, cross_epsilon, axis, index)
|
|
||||||
if mu is not None:
|
|
||||||
assert left_mu is not None
|
|
||||||
assert right_mu is not None
|
|
||||||
cross_mu = left_mu if index < interface_i else right_mu
|
|
||||||
_copy_cross_section(mu, cross_mu, axis, index)
|
|
||||||
|
|
||||||
dxes: list[list[NDArray[floating | complexfloating]]] = [[numpy.empty(0), numpy.empty(0), numpy.empty(0)] for _ in range(2)]
|
|
||||||
for eh in range(2):
|
|
||||||
dxes[eh][axis] = numpy.full(axis_size, prop_dx, dtype=float)
|
|
||||||
dxes[eh][transverse_axes[0]] = dxes_2d_padded[eh][0].copy()
|
|
||||||
dxes[eh][transverse_axes[1]] = dxes_2d_padded[eh][1].copy()
|
|
||||||
|
|
||||||
solve_dxes: dx_lists_t = [[d.copy() for d in dx] for dx in dxes]
|
|
||||||
for stretch_axis, thickness in ((axis, pml_cells), (transverse_axes[0], transverse_pml[0]), (transverse_axes[1], transverse_pml[1])):
|
|
||||||
if thickness == 0:
|
|
||||||
continue
|
|
||||||
solve_dxes = scpml.stretch_with_scpml(
|
|
||||||
solve_dxes,
|
|
||||||
axis=stretch_axis,
|
|
||||||
polarity=+1,
|
|
||||||
omega=float(numpy.real(omega)),
|
|
||||||
epsilon_effective=pml_epsilon_effective,
|
|
||||||
thickness=thickness,
|
|
||||||
)
|
|
||||||
solve_dxes = scpml.stretch_with_scpml(
|
|
||||||
solve_dxes,
|
|
||||||
axis=stretch_axis,
|
|
||||||
polarity=-1,
|
|
||||||
omega=float(numpy.real(omega)),
|
|
||||||
epsilon_effective=pml_epsilon_effective,
|
|
||||||
thickness=thickness,
|
|
||||||
)
|
|
||||||
|
|
||||||
def port_slice(index: int) -> tuple[slice, slice, slice]:
|
|
||||||
slices_l = [slice(None), slice(None), slice(None)]
|
|
||||||
slices_l[axis] = slice(index, index + 1)
|
|
||||||
return (slices_l[0], slices_l[1], slices_l[2])
|
|
||||||
|
|
||||||
result = get_s_fdfd(
|
|
||||||
omega=omega,
|
|
||||||
dxes=dxes,
|
|
||||||
solve_dxes=solve_dxes,
|
|
||||||
epsilon=epsilon,
|
|
||||||
mu=mu,
|
|
||||||
axis=axis,
|
|
||||||
left_mode_numbers=mode_numbers,
|
|
||||||
left_source_slice=port_slice(left_source_i),
|
|
||||||
left_monitor_slice=port_slice(left_monitor_i),
|
|
||||||
right_mode_numbers=right_mode_numbers,
|
|
||||||
right_source_slice=port_slice(right_source_i),
|
|
||||||
right_monitor_slice=port_slice(right_monitor_i),
|
|
||||||
matrix_solver=matrix_solver,
|
|
||||||
matrix_solver_opts=matrix_solver_opts,
|
|
||||||
source_scales=source_scales,
|
|
||||||
return_diagnostics=return_diagnostics,
|
|
||||||
)
|
|
||||||
if not return_diagnostics:
|
|
||||||
return result
|
|
||||||
|
|
||||||
s, diagnostics = result
|
|
||||||
diagnostics.update({
|
|
||||||
'interface_index': interface_i,
|
|
||||||
'pml_cells': pml_cells,
|
|
||||||
'pml_buffer_cells': pml_buffer_cells,
|
|
||||||
'source_monitor_cells': source_monitor_cells,
|
|
||||||
'monitor_interface_cells': monitor_interface_cells,
|
|
||||||
'transverse_padding_cells': padding_cells,
|
|
||||||
'transverse_pml_cells': transverse_pml,
|
|
||||||
'prop_dx': prop_dx,
|
|
||||||
'epsilon': epsilon,
|
|
||||||
'mu': mu,
|
|
||||||
'dxes': dxes,
|
|
||||||
'solve_dxes': solve_dxes,
|
|
||||||
})
|
|
||||||
return s, diagnostics
|
|
||||||
|
|
|
||||||
|
|
@ -1,603 +1,143 @@
|
||||||
r"""
|
"""
|
||||||
Operators and helper functions for cylindrical waveguides with unchanging cross-section.
|
Operators and helper functions for cylindrical waveguides with unchanging cross-section.
|
||||||
|
|
||||||
Waveguide operator is derived according to 10.1364/OL.33.001848.
|
WORK IN PROGRESS, CURRENTLY BROKEN
|
||||||
|
|
||||||
As in `waveguide_2d`, the propagation dependence is separated from the
|
As the z-dependence is known, all the functions in this file assume a 2D grid
|
||||||
transverse solve. Here the propagation coordinate is the bend angle `\theta`,
|
(i.e. `dxes = [[[dr_e_0, dx_e_1, ...], [dy_e_0, ...]], [[dr_h_0, ...], [dy_h_0, ...]]]`).
|
||||||
and the fields are assumed to have the form
|
|
||||||
|
|
||||||
$$
|
|
||||||
\vec{E}(r, y, \theta), \vec{H}(r, y, \theta) \propto e^{-\imath m \theta},
|
|
||||||
$$
|
|
||||||
|
|
||||||
where `m` is the angular wavenumber returned by `solve_mode(s)`. It is often
|
|
||||||
convenient to introduce the corresponding linear wavenumber
|
|
||||||
|
|
||||||
$$
|
|
||||||
\beta = \frac{m}{r_{\min}},
|
|
||||||
$$
|
|
||||||
|
|
||||||
so that the cylindrical problem resembles the straight-waveguide problem with
|
|
||||||
additional metric factors.
|
|
||||||
|
|
||||||
Those metric factors live on the staggered radial Yee grids. If the left edge of
|
|
||||||
the computational window is at `r = r_{\min}`, define the electric-grid and
|
|
||||||
magnetic-grid radial sample locations by
|
|
||||||
|
|
||||||
$$
|
|
||||||
\begin{aligned}
|
|
||||||
r_a(n) &= r_{\min} + \sum_{j \le n} \Delta r_{e, j}, \\
|
|
||||||
r_b\!\left(n + \tfrac{1}{2}\right) &= r_{\min} + \tfrac{1}{2}\Delta r_{e, n}
|
|
||||||
+ \sum_{j < n} \Delta r_{h, j},
|
|
||||||
\end{aligned}
|
|
||||||
$$
|
|
||||||
|
|
||||||
and from them the diagonal metric matrices
|
|
||||||
|
|
||||||
$$
|
|
||||||
\begin{aligned}
|
|
||||||
T_a &= \operatorname{diag}(r_a / r_{\min}), \\
|
|
||||||
T_b &= \operatorname{diag}(r_b / r_{\min}).
|
|
||||||
\end{aligned}
|
|
||||||
$$
|
|
||||||
|
|
||||||
With the same forward/backward derivative notation used in `waveguide_2d`, the
|
|
||||||
implementation treats the transverse electric eigenproblem as the canonical
|
|
||||||
cylindrical discretization. It reduces to `waveguide_2d.operator_e(...)` in the
|
|
||||||
large-radius limit `T_a, T_b \to I`. The 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}.
|
|
||||||
$$
|
|
||||||
|
|
||||||
The full fields reconstructed by `exy2e(...)` and `e2h(...)` use the matching
|
|
||||||
large-radius-compatible identities
|
|
||||||
|
|
||||||
$$
|
|
||||||
E_z =
|
|
||||||
\frac{1}{\imath \beta} T_a \epsilon_{zz}^{-1}
|
|
||||||
\begin{bmatrix}
|
|
||||||
\hat{\partial}_r T_b \epsilon_{rr} &
|
|
||||||
\hat{\partial}_y T_a \epsilon_{yy}
|
|
||||||
\end{bmatrix}
|
|
||||||
\begin{bmatrix} E_r \\ E_y \end{bmatrix},
|
|
||||||
$$
|
|
||||||
|
|
||||||
and
|
|
||||||
|
|
||||||
$$
|
|
||||||
\begin{bmatrix} H_r \\ H_y \\ H_z \end{bmatrix}
|
|
||||||
=
|
|
||||||
\frac{1}{-\imath \omega}\mu^{-1}
|
|
||||||
\begin{bmatrix}
|
|
||||||
0 & \imath\beta T_a^{-1} & \tilde{\partial}_y \\
|
|
||||||
-\imath\beta T_b^{-1} & 0 & -T_b^{-1}\tilde{\partial}_r T_a \\
|
|
||||||
-\tilde{\partial}_y & \tilde{\partial}_r & 0
|
|
||||||
\end{bmatrix}
|
|
||||||
\begin{bmatrix} E_r \\ E_y \\ E_z \end{bmatrix}.
|
|
||||||
$$
|
|
||||||
|
|
||||||
Since `\beta = m / r_{\min}`, the solver implemented in this file returns the
|
|
||||||
angular wavenumber `m`, while the operator itself is most naturally written in
|
|
||||||
terms of the linear quantity `\beta`. The helpers below reconstruct the full
|
|
||||||
field components from the solved transverse eigenvector and then normalize the
|
|
||||||
mode to unit forward power with the same discrete longitudinal Poynting inner
|
|
||||||
product used by `waveguide_2d`.
|
|
||||||
|
|
||||||
As in the straight-waveguide case, all functions here assume a 2D grid:
|
|
||||||
|
|
||||||
`dxes = [[[dr_e_0, dr_e_1, ...], [dy_e_0, ...]], [[dr_h_0, ...], [dy_h_0, ...]]]`.
|
|
||||||
"""
|
"""
|
||||||
from typing import Any, cast
|
# TODO update module docs
|
||||||
from collections.abc import Sequence
|
|
||||||
import logging
|
|
||||||
|
|
||||||
import numpy
|
import numpy
|
||||||
from numpy.typing import NDArray, ArrayLike
|
import scipy.sparse as sparse # type: ignore
|
||||||
from scipy import sparse
|
|
||||||
|
|
||||||
from ..fdmath import vec, unvec, dx_lists2_t, vcfdslice_t, vfdslice, vcfdslice, vcfdfield2
|
from ..fdmath import vec, unvec, dx_lists_t, fdfield_t, vfdfield_t, cfdfield_t
|
||||||
from ..fdmath.operators import deriv_forward, deriv_back
|
from ..fdmath.operators import deriv_forward, deriv_back
|
||||||
from ..eigensolvers import signed_eigensolve, rayleigh_quotient_iteration
|
from ..eigensolvers import signed_eigensolve, rayleigh_quotient_iteration
|
||||||
from . import waveguide_2d
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def cylindrical_operator(
|
def cylindrical_operator(
|
||||||
omega: float,
|
omega: complex,
|
||||||
dxes: dx_lists2_t,
|
dxes: dx_lists_t,
|
||||||
epsilon: vfdslice,
|
epsilon: vfdfield_t,
|
||||||
rmin: float,
|
r0: float,
|
||||||
mu: vfdslice | None = None,
|
) -> sparse.spmatrix:
|
||||||
) -> sparse.sparray:
|
"""
|
||||||
r"""
|
|
||||||
Cylindrical coordinate waveguide operator of the form
|
Cylindrical coordinate waveguide operator of the form
|
||||||
|
|
||||||
$$
|
TODO
|
||||||
(\omega^2 \begin{bmatrix} T_b T_b \mu_{yy} \epsilon_{xx} & 0 \\
|
|
||||||
0 & T_a T_a \mu_{xx} \epsilon_{yy} \end{bmatrix} +
|
|
||||||
\begin{bmatrix} -T_b \mu_{yy} \hat{\partial}_y \\
|
|
||||||
T_a \mu_{xx} \hat{\partial}_x \end{bmatrix} T_b \mu_{zz}^{-1}
|
|
||||||
\begin{bmatrix} -\tilde{\partial}_y & \tilde{\partial}_x \end{bmatrix} +
|
|
||||||
\begin{bmatrix} \tilde{\partial}_x \\
|
|
||||||
\tilde{\partial}_y \end{bmatrix} T_a \epsilon_{zz}^{-1}
|
|
||||||
\begin{bmatrix} \hat{\partial}_x T_b \epsilon_{xx} & \hat{\partial}_y T_a \epsilon_{yy} \end{bmatrix})
|
|
||||||
\begin{bmatrix} E_r \\
|
|
||||||
E_y \end{bmatrix}
|
|
||||||
$$
|
|
||||||
|
|
||||||
for use with a field vector of the form `[E_r, E_y]`.
|
for use with a field vector of the form `[E_r, E_y]`.
|
||||||
|
|
||||||
This operator can be used to form an eigenvalue problem of the form
|
This operator can be used to form an eigenvalue problem of the form
|
||||||
A @ [E_r, E_y] = beta**2 * [E_r, E_y]
|
A @ [E_r, E_y] = wavenumber**2 * [E_r, E_y]
|
||||||
|
|
||||||
which can then be solved for the eigenmodes of the system
|
which can then be solved for the eigenmodes of the system
|
||||||
(an `exp(-i * angular_wavenumber * theta)` theta-dependence is assumed for
|
(an `exp(-i * wavenumber * theta)` theta-dependence is assumed for the fields).
|
||||||
the fields, with `beta = angular_wavenumber / rmin`).
|
|
||||||
|
|
||||||
(NOTE: See module docs and 10.1364/OL.33.001848)
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
omega: The angular frequency of the system
|
omega: The angular frequency of the system
|
||||||
dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types` (2D)
|
dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types` (2D)
|
||||||
epsilon: Vectorized dielectric constant grid
|
epsilon: Vectorized dielectric constant grid
|
||||||
rmin: Radius at the left edge of the simulation domain (at minimum 'x')
|
r0: Radius of curvature for the simulation. This should be the minimum value of
|
||||||
mu: Vectorized magnetic permeability grid (default 1 everywhere)
|
r within the simulation domain.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Sparse matrix representation of the operator
|
Sparse matrix representation of the operator
|
||||||
"""
|
"""
|
||||||
if mu is None:
|
|
||||||
mu = numpy.ones_like(epsilon)
|
|
||||||
|
|
||||||
Dfx, Dfy = deriv_forward(dxes[0])
|
Dfx, Dfy = deriv_forward(dxes[0])
|
||||||
Dbx, Dby = deriv_back(dxes[1])
|
Dbx, Dby = deriv_back(dxes[1])
|
||||||
|
|
||||||
Ta, Tb = dxes2T(dxes=dxes, rmin=rmin)
|
rx = r0 + numpy.cumsum(dxes[0][0])
|
||||||
|
ry = r0 + dxes[0][0] / 2.0 + numpy.cumsum(dxes[1][0])
|
||||||
|
tx = rx / r0
|
||||||
|
ty = ry / r0
|
||||||
|
|
||||||
|
Tx = sparse.diags(vec(tx[:, None].repeat(dxes[0][1].size, axis=1)))
|
||||||
|
Ty = sparse.diags(vec(ty[:, None].repeat(dxes[1][1].size, axis=1)))
|
||||||
|
|
||||||
eps_parts = numpy.split(epsilon, 3)
|
eps_parts = numpy.split(epsilon, 3)
|
||||||
eps_x = sparse.diags_array(eps_parts[0])
|
eps_x = sparse.diags(eps_parts[0])
|
||||||
eps_y = sparse.diags_array(eps_parts[1])
|
eps_y = sparse.diags(eps_parts[1])
|
||||||
eps_z_inv = sparse.diags_array(1 / eps_parts[2])
|
eps_z_inv = sparse.diags(1 / eps_parts[2])
|
||||||
|
|
||||||
mu_parts = numpy.split(mu, 3)
|
pa = sparse.vstack((Dfx, Dfy)) @ Tx @ eps_z_inv @ sparse.hstack((Dbx, Dby))
|
||||||
mu_y = sparse.diags_array(mu_parts[1])
|
pb = sparse.vstack((Dfx, Dfy)) @ Tx @ eps_z_inv @ sparse.hstack((Dby, Dbx))
|
||||||
mu_x = sparse.diags_array(mu_parts[0])
|
a0 = Ty @ eps_x + omega**-2 * Dby @ Ty @ Dfy
|
||||||
mu_z_inv = sparse.diags_array(1 / mu_parts[2])
|
a1 = Tx @ eps_y + omega**-2 * Dbx @ Ty @ Dfx
|
||||||
|
b0 = Dbx @ Ty @ Dfy
|
||||||
|
b1 = Dby @ Ty @ Dfx
|
||||||
|
|
||||||
omega2 = omega * omega
|
|
||||||
diag = sparse.block_diag
|
diag = sparse.block_diag
|
||||||
|
|
||||||
sq0 = omega2 * diag((Tb @ Tb @ mu_y @ eps_x,
|
omega2 = omega * omega
|
||||||
Ta @ Ta @ mu_x @ eps_y))
|
|
||||||
lin0 = sparse.vstack((-Tb @ mu_y @ Dby, Ta @ mu_x @ Dbx)) @ Tb @ mu_z_inv @ sparse.hstack((-Dfy, Dfx))
|
op = (omega2 * diag((Tx, Ty)) + pa) @ diag((a0, a1)) + \
|
||||||
lin1 = sparse.vstack((Dfx, Dfy)) @ Ta @ eps_z_inv @ sparse.hstack((Dbx @ Tb @ eps_x,
|
- (sparse.bmat(((None, Ty), (Tx, None))) + pb / omega2) @ diag((b0, b1))
|
||||||
Dby @ Ta @ eps_y))
|
|
||||||
op = sq0 + lin0 + lin1
|
|
||||||
return op
|
return op
|
||||||
|
|
||||||
|
|
||||||
def solve_modes(
|
|
||||||
mode_numbers: Sequence[int],
|
|
||||||
omega: float,
|
|
||||||
dxes: dx_lists2_t,
|
|
||||||
epsilon: vfdslice,
|
|
||||||
rmin: float,
|
|
||||||
mu: vfdslice | None = None,
|
|
||||||
mode_margin: int = 2,
|
|
||||||
) -> tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]:
|
|
||||||
"""
|
|
||||||
Given a 2d (r, y) slice of epsilon, attempts to solve for the eigenmode
|
|
||||||
of the bent waveguide with the specified mode number.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
mode_numbers: Mode numbers to solve, 0-indexed.
|
|
||||||
omega: Angular frequency of the simulation
|
|
||||||
dxes: Grid parameters [dx_e, dx_h] as described in meanas.fdmath.types.
|
|
||||||
The first coordinate is assumed to be r, the second is y.
|
|
||||||
epsilon: Dielectric constant
|
|
||||||
rmin: Radius of curvature for the simulation. This should be the minimum value of
|
|
||||||
r within the simulation domain.
|
|
||||||
mu: Magnetic permeability (default 1 everywhere)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
e_xys: NDArray of vfdfield_t specifying fields. First dimension is mode number.
|
|
||||||
angular_wavenumbers: list of wavenumbers in 1/rad units.
|
|
||||||
"""
|
|
||||||
|
|
||||||
#
|
|
||||||
# Solve for the largest-magnitude eigenvalue of the real operator
|
|
||||||
#
|
|
||||||
dxes_real = [[numpy.real(dx) for dx in dxi] for dxi in dxes]
|
|
||||||
mu_real = None if mu is None else numpy.real(mu)
|
|
||||||
|
|
||||||
A_r = cylindrical_operator(numpy.real(omega), dxes_real, numpy.real(epsilon), rmin=rmin, mu=mu_real)
|
|
||||||
eigvals, eigvecs = signed_eigensolve(A_r, max(mode_numbers) + mode_margin)
|
|
||||||
keep_inds = -(numpy.array(mode_numbers) + 1)
|
|
||||||
e_xys = eigvecs[:, keep_inds].T
|
|
||||||
eigvals = eigvals[keep_inds]
|
|
||||||
|
|
||||||
#
|
|
||||||
# Now solve for the eigenvector of the full operator, using the real operator's
|
|
||||||
# eigenvector as an initial guess for Rayleigh quotient iteration.
|
|
||||||
#
|
|
||||||
A = cylindrical_operator(omega, dxes, epsilon, rmin=rmin, mu=mu)
|
|
||||||
for nn in range(len(mode_numbers)):
|
|
||||||
eigvals[nn], e_xys[nn, :] = rayleigh_quotient_iteration(A, e_xys[nn, :])
|
|
||||||
|
|
||||||
# Calculate the wave-vector (force the real part to be positive)
|
|
||||||
wavenumbers = numpy.sqrt(eigvals)
|
|
||||||
wavenumbers *= numpy.sign(numpy.real(wavenumbers))
|
|
||||||
|
|
||||||
# Wavenumbers assume the mode is at rmin, which is unlikely
|
|
||||||
# Instead, return the wavenumber in inverse radians
|
|
||||||
angular_wavenumbers = wavenumbers * cast('complex', rmin)
|
|
||||||
|
|
||||||
order = angular_wavenumbers.argsort()[::-1]
|
|
||||||
e_xys = e_xys[order]
|
|
||||||
angular_wavenumbers = angular_wavenumbers[order]
|
|
||||||
|
|
||||||
return e_xys, angular_wavenumbers
|
|
||||||
|
|
||||||
|
|
||||||
def solve_mode(
|
def solve_mode(
|
||||||
mode_number: int,
|
mode_number: int,
|
||||||
*args: Any,
|
omega: complex,
|
||||||
**kwargs: Any,
|
dxes: dx_lists_t,
|
||||||
) -> tuple[vcfdfield2, complex]:
|
epsilon: vfdfield_t,
|
||||||
|
r0: float,
|
||||||
|
) -> dict[str, complex | cfdfield_t]:
|
||||||
"""
|
"""
|
||||||
Wrapper around `solve_modes()` that solves for a single mode.
|
TODO: fixup
|
||||||
|
Given a 2d (r, y) slice of epsilon, attempts to solve for the eigenmode
|
||||||
|
of the bent waveguide with the specified mode number.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
mode_number: 0-indexed mode number to solve for
|
mode_number: Number of the mode, 0-indexed
|
||||||
*args: passed to `solve_modes()`
|
omega: Angular frequency of the simulation
|
||||||
**kwargs: passed to `solve_modes()`
|
dxes: Grid parameters [dx_e, dx_h] as described in meanas.fdmath.types.
|
||||||
|
The first coordinate is assumed to be r, the second is y.
|
||||||
|
epsilon: Dielectric constant
|
||||||
|
r0: Radius of curvature for the simulation. This should be the minimum value of
|
||||||
|
r within the simulation domain.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(e_xy, angular_wavenumber)
|
```
|
||||||
|
{
|
||||||
|
'E': list[NDArray[numpy.complex_]],
|
||||||
|
'H': list[NDArray[numpy.complex_]],
|
||||||
|
'wavenumber': complex,
|
||||||
|
}
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
kwargs['mode_numbers'] = [mode_number]
|
|
||||||
e_xys, angular_wavenumbers = solve_modes(*args, **kwargs)
|
|
||||||
return e_xys[0], angular_wavenumbers[0]
|
|
||||||
|
|
||||||
|
'''
|
||||||
|
Solve for the largest-magnitude eigenvalue of the real operator
|
||||||
|
'''
|
||||||
|
dxes_real = [[numpy.real(dx) for dx in dxi] for dxi in dxes]
|
||||||
|
|
||||||
def linear_wavenumbers(
|
A_r = cylindrical_operator(numpy.real(omega), dxes_real, numpy.real(epsilon), r0)
|
||||||
e_xys: Sequence[vcfdfield2] | NDArray[numpy.complex128],
|
eigvals, eigvecs = signed_eigensolve(A_r, mode_number + 3)
|
||||||
angular_wavenumbers: ArrayLike,
|
e_xy = eigvecs[:, -(mode_number + 1)]
|
||||||
epsilon: vfdslice,
|
|
||||||
dxes: dx_lists2_t,
|
|
||||||
rmin: float,
|
|
||||||
) -> NDArray[numpy.complex128]:
|
|
||||||
"""
|
|
||||||
Calculate linear wavenumbers (1/distance) based on angular wavenumbers (1/rad)
|
|
||||||
and the mode's energy distribution.
|
|
||||||
|
|
||||||
Args:
|
'''
|
||||||
e_xys: Vectorized mode fields with shape (num_modes, 2 * x *y)
|
Now solve for the eigenvector of the full operator, using the real operator's
|
||||||
angular_wavenumbers: Wavenumbers assuming fields have theta-dependence of
|
eigenvector as an initial guess for Rayleigh quotient iteration.
|
||||||
`exp(-i * angular_wavenumber * theta)`. They should satisfy
|
'''
|
||||||
`operator_e() @ e_xy == (angular_wavenumber / rmin) ** 2 * e_xy`
|
A = cylindrical_operator(omega, dxes, epsilon, r0)
|
||||||
epsilon: Vectorized dielectric constant grid with shape (3, x, y)
|
eigval, e_xy = rayleigh_quotient_iteration(A, e_xy)
|
||||||
dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types` (2D)
|
|
||||||
rmin: Radius at the left edge of the simulation domain (at minimum 'x')
|
|
||||||
|
|
||||||
Returns:
|
# Calculate the wave-vector (force the real part to be positive)
|
||||||
NDArray containing the calculated linear (1/distance) wavenumbers
|
wavenumber = numpy.sqrt(eigval)
|
||||||
"""
|
wavenumber *= numpy.sign(numpy.real(wavenumber))
|
||||||
angular_wavenumbers = numpy.asarray(angular_wavenumbers)
|
|
||||||
mode_radii = numpy.empty_like(angular_wavenumbers, dtype=float)
|
|
||||||
|
|
||||||
shape2d = (len(dxes[0][0]), len(dxes[0][1]))
|
# TODO: Perform correction on wavenumber to account for numerical dispersion.
|
||||||
epsilon2d = unvec(epsilon, shape2d)[:2]
|
|
||||||
ra = rmin + numpy.cumsum(dxes[0][0])
|
|
||||||
rb = rmin + dxes[0][0] / 2.0 + numpy.concatenate((
|
|
||||||
numpy.zeros(1, dtype=dxes[1][0].dtype),
|
|
||||||
numpy.cumsum(dxes[1][0][:-1]),
|
|
||||||
))
|
|
||||||
for ii in range(angular_wavenumbers.size):
|
|
||||||
efield = unvec(e_xys[ii], shape2d, 2)
|
|
||||||
energy = numpy.real((efield * efield.conj()) * epsilon2d)
|
|
||||||
er_energy_vs_r = energy[0].sum(axis=1)
|
|
||||||
ey_energy_vs_r = energy[1].sum(axis=1)
|
|
||||||
energy_vs_r = er_energy_vs_r + ey_energy_vs_r
|
|
||||||
mode_radii[ii] = (
|
|
||||||
(rb * er_energy_vs_r).sum() + (ra * ey_energy_vs_r).sum()
|
|
||||||
) / energy_vs_r.sum()
|
|
||||||
|
|
||||||
logger.info(f'{mode_radii=}')
|
shape = [d.size for d in dxes[0]]
|
||||||
lin_wavenumbers = angular_wavenumbers / mode_radii
|
e_xy = numpy.hstack((e_xy, numpy.zeros(shape[0] * shape[1])))
|
||||||
return lin_wavenumbers
|
fields = {
|
||||||
|
'wavenumber': wavenumber,
|
||||||
|
'E': unvec(e_xy, shape),
|
||||||
|
# 'E': unvec(e, shape),
|
||||||
|
# 'H': unvec(h, shape),
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields
|
||||||
def exy2h(
|
|
||||||
angular_wavenumber: complex,
|
|
||||||
omega: float,
|
|
||||||
dxes: dx_lists2_t,
|
|
||||||
rmin: float,
|
|
||||||
epsilon: vfdslice,
|
|
||||||
mu: vfdslice | None = None
|
|
||||||
) -> sparse.sparray:
|
|
||||||
"""
|
|
||||||
Operator which transforms the vector `e_xy` containing the vectorized E_r and E_y fields,
|
|
||||||
into a vectorized H containing all three H components
|
|
||||||
|
|
||||||
Args:
|
|
||||||
angular_wavenumber: Wavenumber assuming fields have theta-dependence of
|
|
||||||
`exp(-i * angular_wavenumber * theta)`. It should satisfy
|
|
||||||
`operator_e() @ e_xy == (angular_wavenumber / rmin) ** 2 * e_xy`
|
|
||||||
omega: The angular frequency of the system
|
|
||||||
dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types` (2D)
|
|
||||||
rmin: Radius at the left edge of the simulation domain (at minimum 'x')
|
|
||||||
epsilon: Vectorized dielectric constant grid
|
|
||||||
mu: Vectorized magnetic permeability grid (default 1 everywhere)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Sparse matrix representing the operator.
|
|
||||||
"""
|
|
||||||
e2hop = e2h(angular_wavenumber=angular_wavenumber, omega=omega, dxes=dxes, rmin=rmin, mu=mu)
|
|
||||||
return e2hop @ exy2e(angular_wavenumber=angular_wavenumber, dxes=dxes, rmin=rmin, epsilon=epsilon)
|
|
||||||
|
|
||||||
|
|
||||||
def exy2e(
|
|
||||||
angular_wavenumber: complex,
|
|
||||||
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`
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
Dbx, Dby = deriv_back(dxes[1])
|
|
||||||
wavenumber = angular_wavenumber / rmin
|
|
||||||
|
|
||||||
Ta, Tb = dxes2T(dxes=dxes, rmin=rmin)
|
|
||||||
|
|
||||||
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
|
|
||||||
exy2ez = (
|
|
||||||
Ta @ epsilon_z_inv
|
|
||||||
@ sparse.hstack((Dbx @ Tb @ epsilon_x,
|
|
||||||
Dby @ Ta @ epsilon_y))
|
|
||||||
/ (1j * wavenumber)
|
|
||||||
)
|
|
||||||
|
|
||||||
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.concatenate((
|
|
||||||
numpy.zeros(1, dtype=dxes[1][0].dtype),
|
|
||||||
numpy.cumsum(dxes[1][0][:-1]),
|
|
||||||
))
|
|
||||||
) # Radius at Er 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, 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. Compute the time-averaged forward power with
|
|
||||||
`waveguide_2d.inner_product(..., conj_h=True)`.
|
|
||||||
2. Scale by `1 / sqrt(S_z)` so the resulting mode has unit forward power.
|
|
||||||
3. 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.
|
|
||||||
"""
|
|
||||||
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.
|
Basic discrete calculus for finite difference (fd) simulations.
|
||||||
|
|
||||||
|
|
@ -43,11 +43,11 @@ Scalar derivatives and cell shifts
|
||||||
----------------------------------
|
----------------------------------
|
||||||
|
|
||||||
Define the discrete forward derivative as
|
Define the discrete forward derivative as
|
||||||
$$ [\tilde{\partial}_x f]_{m + \frac{1}{2}} = \frac{1}{\Delta_{x, m}} (f_{m + 1} - f_m) $$
|
$$ [\\tilde{\\partial}_x f]_{m + \\frac{1}{2}} = \\frac{1}{\\Delta_{x, m}} (f_{m + 1} - f_m) $$
|
||||||
where $f$ is a function defined at discrete locations on the x-axis (labeled using $m$).
|
where $f$ is a function defined at discrete locations on the x-axis (labeled using $m$).
|
||||||
The value at $m$ occupies a length $\Delta_{x, m}$ along the x-axis. Note that $m$
|
The value at $m$ occupies a length $\\Delta_{x, m}$ along the x-axis. Note that $m$
|
||||||
is an index along the x-axis, _not_ necessarily an x-coordinate, since each length
|
is an index along the x-axis, _not_ necessarily an x-coordinate, since each length
|
||||||
$\Delta_{x, m}, \Delta_{x, m+1}, ...$ is independently chosen.
|
$\\Delta_{x, m}, \\Delta_{x, m+1}, ...$ is independently chosen.
|
||||||
|
|
||||||
If we treat `f` as a 1D array of values, with the `i`-th value `f[i]` taking up a length `dx[i]`
|
If we treat `f` as a 1D array of values, with the `i`-th value `f[i]` taking up a length `dx[i]`
|
||||||
along the x-axis, the forward derivative is
|
along the x-axis, the forward derivative is
|
||||||
|
|
@ -56,13 +56,13 @@ along the x-axis, the forward derivative is
|
||||||
|
|
||||||
|
|
||||||
Likewise, discrete reverse derivative is
|
Likewise, discrete reverse derivative is
|
||||||
$$ [\hat{\partial}_x f ]_{m - \frac{1}{2}} = \frac{1}{\Delta_{x, m}} (f_{m} - f_{m - 1}) $$
|
$$ [\\hat{\\partial}_x f ]_{m - \\frac{1}{2}} = \\frac{1}{\\Delta_{x, m}} (f_{m} - f_{m - 1}) $$
|
||||||
or
|
or
|
||||||
|
|
||||||
deriv_back(f)[i] = (f[i] - f[i - 1]) / dx[i]
|
deriv_back(f)[i] = (f[i] - f[i - 1]) / dx[i]
|
||||||
|
|
||||||
The derivatives' values are shifted by a half-cell relative to the original function, and
|
The derivatives' values are shifted by a half-cell relative to the original function, and
|
||||||
will have different cell widths if all the `dx[i]` ( $\Delta_{x, m}$ ) are not
|
will have different cell widths if all the `dx[i]` ( $\\Delta_{x, m}$ ) are not
|
||||||
identical:
|
identical:
|
||||||
|
|
||||||
[figure: derivatives and cell sizes]
|
[figure: derivatives and cell sizes]
|
||||||
|
|
@ -88,19 +88,19 @@ identical:
|
||||||
|
|
||||||
In the above figure,
|
In the above figure,
|
||||||
`f0 =` $f_0$, `f1 =` $f_1$
|
`f0 =` $f_0$, `f1 =` $f_1$
|
||||||
`Df0 =` $[\tilde{\partial}f]_{0 + \frac{1}{2}}$
|
`Df0 =` $[\\tilde{\\partial}f]_{0 + \\frac{1}{2}}$
|
||||||
`Df1 =` $[\tilde{\partial}f]_{1 + \frac{1}{2}}$
|
`Df1 =` $[\\tilde{\\partial}f]_{1 + \\frac{1}{2}}$
|
||||||
`df0 =` $[\hat{\partial}f]_{0 - \frac{1}{2}}$
|
`df0 =` $[\\hat{\\partial}f]_{0 - \\frac{1}{2}}$
|
||||||
etc.
|
etc.
|
||||||
|
|
||||||
The fractional subscript $m + \frac{1}{2}$ is used to indicate values defined
|
The fractional subscript $m + \\frac{1}{2}$ is used to indicate values defined
|
||||||
at shifted locations relative to the original $m$, with corresponding lengths
|
at shifted locations relative to the original $m$, with corresponding lengths
|
||||||
$$ \Delta_{x, m + \frac{1}{2}} = \frac{1}{2} * (\Delta_{x, m} + \Delta_{x, m + 1}) $$
|
$$ \\Delta_{x, m + \\frac{1}{2}} = \\frac{1}{2} * (\\Delta_{x, m} + \\Delta_{x, m + 1}) $$
|
||||||
|
|
||||||
Just as $m$ is not itself an x-coordinate, neither is $m + \frac{1}{2}$;
|
Just as $m$ is not itself an x-coordinate, neither is $m + \\frac{1}{2}$;
|
||||||
carefully note the positions of the various cells in the above figure vs their labels.
|
carefully note the positions of the various cells in the above figure vs their labels.
|
||||||
If the positions labeled with $m$ are considered the "base" or "original" grid,
|
If the positions labeled with $m$ are considered the "base" or "original" grid,
|
||||||
the positions labeled with $m + \frac{1}{2}$ are said to lie on a "dual" or
|
the positions labeled with $m + \\frac{1}{2}$ are said to lie on a "dual" or
|
||||||
"derived" grid.
|
"derived" grid.
|
||||||
|
|
||||||
For the remainder of the `Discrete calculus` section, all figures will show
|
For the remainder of the `Discrete calculus` section, all figures will show
|
||||||
|
|
@ -113,12 +113,12 @@ Gradients and fore-vectors
|
||||||
--------------------------
|
--------------------------
|
||||||
|
|
||||||
Expanding to three dimensions, we can define two gradients
|
Expanding to three dimensions, we can define two gradients
|
||||||
$$ [\tilde{\nabla} f]_{m,n,p} = \vec{x} [\tilde{\partial}_x f]_{m + \frac{1}{2},n,p} +
|
$$ [\\tilde{\\nabla} f]_{m,n,p} = \\vec{x} [\\tilde{\\partial}_x f]_{m + \\frac{1}{2},n,p} +
|
||||||
\vec{y} [\tilde{\partial}_y f]_{m,n + \frac{1}{2},p} +
|
\\vec{y} [\\tilde{\\partial}_y f]_{m,n + \\frac{1}{2},p} +
|
||||||
\vec{z} [\tilde{\partial}_z f]_{m,n,p + \frac{1}{2}} $$
|
\\vec{z} [\\tilde{\\partial}_z f]_{m,n,p + \\frac{1}{2}} $$
|
||||||
$$ [\hat{\nabla} f]_{m,n,p} = \vec{x} [\hat{\partial}_x f]_{m + \frac{1}{2},n,p} +
|
$$ [\\hat{\\nabla} f]_{m,n,p} = \\vec{x} [\\hat{\\partial}_x f]_{m + \\frac{1}{2},n,p} +
|
||||||
\vec{y} [\hat{\partial}_y f]_{m,n + \frac{1}{2},p} +
|
\\vec{y} [\\hat{\\partial}_y f]_{m,n + \\frac{1}{2},p} +
|
||||||
\vec{z} [\hat{\partial}_z f]_{m,n,p + \frac{1}{2}} $$
|
\\vec{z} [\\hat{\\partial}_z f]_{m,n,p + \\frac{1}{2}} $$
|
||||||
|
|
||||||
or
|
or
|
||||||
|
|
||||||
|
|
@ -144,12 +144,12 @@ y in y, and z in z.
|
||||||
|
|
||||||
We call the resulting object a "fore-vector" or "back-vector", depending
|
We call the resulting object a "fore-vector" or "back-vector", depending
|
||||||
on the direction of the shift. We write it as
|
on the direction of the shift. We write it as
|
||||||
$$ \tilde{g}_{m,n,p} = \vec{x} g^x_{m + \frac{1}{2},n,p} +
|
$$ \\tilde{g}_{m,n,p} = \\vec{x} g^x_{m + \\frac{1}{2},n,p} +
|
||||||
\vec{y} g^y_{m,n + \frac{1}{2},p} +
|
\\vec{y} g^y_{m,n + \\frac{1}{2},p} +
|
||||||
\vec{z} g^z_{m,n,p + \frac{1}{2}} $$
|
\\vec{z} g^z_{m,n,p + \\frac{1}{2}} $$
|
||||||
$$ \hat{g}_{m,n,p} = \vec{x} g^x_{m - \frac{1}{2},n,p} +
|
$$ \\hat{g}_{m,n,p} = \\vec{x} g^x_{m - \\frac{1}{2},n,p} +
|
||||||
\vec{y} g^y_{m,n - \frac{1}{2},p} +
|
\\vec{y} g^y_{m,n - \\frac{1}{2},p} +
|
||||||
\vec{z} g^z_{m,n,p - \frac{1}{2}} $$
|
\\vec{z} g^z_{m,n,p - \\frac{1}{2}} $$
|
||||||
|
|
||||||
|
|
||||||
[figure: gradient / fore-vector]
|
[figure: gradient / fore-vector]
|
||||||
|
|
@ -172,15 +172,15 @@ Divergences
|
||||||
|
|
||||||
There are also two divergences,
|
There are also two divergences,
|
||||||
|
|
||||||
$$ d_{n,m,p} = [\tilde{\nabla} \cdot \hat{g}]_{n,m,p}
|
$$ d_{n,m,p} = [\\tilde{\\nabla} \\cdot \\hat{g}]_{n,m,p}
|
||||||
= [\tilde{\partial}_x g^x]_{m,n,p} +
|
= [\\tilde{\\partial}_x g^x]_{m,n,p} +
|
||||||
[\tilde{\partial}_y g^y]_{m,n,p} +
|
[\\tilde{\\partial}_y g^y]_{m,n,p} +
|
||||||
[\tilde{\partial}_z g^z]_{m,n,p} $$
|
[\\tilde{\\partial}_z g^z]_{m,n,p} $$
|
||||||
|
|
||||||
$$ d_{n,m,p} = [\hat{\nabla} \cdot \tilde{g}]_{n,m,p}
|
$$ d_{n,m,p} = [\\hat{\\nabla} \\cdot \\tilde{g}]_{n,m,p}
|
||||||
= [\hat{\partial}_x g^x]_{m,n,p} +
|
= [\\hat{\\partial}_x g^x]_{m,n,p} +
|
||||||
[\hat{\partial}_y g^y]_{m,n,p} +
|
[\\hat{\\partial}_y g^y]_{m,n,p} +
|
||||||
[\hat{\partial}_z g^z]_{m,n,p} $$
|
[\\hat{\\partial}_z g^z]_{m,n,p} $$
|
||||||
|
|
||||||
or
|
or
|
||||||
|
|
||||||
|
|
@ -203,7 +203,7 @@ where `g = [gx, gy, gz]` is a fore- or back-vector field.
|
||||||
|
|
||||||
Since we applied the forward divergence to the back-vector (and vice-versa), the resulting scalar value
|
Since we applied the forward divergence to the back-vector (and vice-versa), the resulting scalar value
|
||||||
is defined at the back-vector's (fore-vector's) location $(m,n,p)$ and not at the locations of its components
|
is defined at the back-vector's (fore-vector's) location $(m,n,p)$ and not at the locations of its components
|
||||||
$(m \pm \frac{1}{2},n,p)$ etc.
|
$(m \\pm \\frac{1}{2},n,p)$ etc.
|
||||||
|
|
||||||
[figure: divergence]
|
[figure: divergence]
|
||||||
^^
|
^^
|
||||||
|
|
@ -227,23 +227,23 @@ Curls
|
||||||
|
|
||||||
The two curls are then
|
The two curls are then
|
||||||
|
|
||||||
$$ \begin{aligned}
|
$$ \\begin{aligned}
|
||||||
\hat{h}_{m + \frac{1}{2}, n + \frac{1}{2}, p + \frac{1}{2}} &= \\
|
\\hat{h}_{m + \\frac{1}{2}, n + \\frac{1}{2}, p + \\frac{1}{2}} &= \\\\
|
||||||
[\tilde{\nabla} \times \tilde{g}]_{m + \frac{1}{2}, n + \frac{1}{2}, p + \frac{1}{2}} &=
|
[\\tilde{\\nabla} \\times \\tilde{g}]_{m + \\frac{1}{2}, n + \\frac{1}{2}, p + \\frac{1}{2}} &=
|
||||||
\vec{x} (\tilde{\partial}_y g^z_{m,n,p + \frac{1}{2}} - \tilde{\partial}_z g^y_{m,n + \frac{1}{2},p}) \\
|
\\vec{x} (\\tilde{\\partial}_y g^z_{m,n,p + \\frac{1}{2}} - \\tilde{\\partial}_z g^y_{m,n + \\frac{1}{2},p}) \\\\
|
||||||
&+ \vec{y} (\tilde{\partial}_z g^x_{m + \frac{1}{2},n,p} - \tilde{\partial}_x g^z_{m,n,p + \frac{1}{2}}) \\
|
&+ \\vec{y} (\\tilde{\\partial}_z g^x_{m + \\frac{1}{2},n,p} - \\tilde{\\partial}_x g^z_{m,n,p + \\frac{1}{2}}) \\\\
|
||||||
&+ \vec{z} (\tilde{\partial}_x g^y_{m,n + \frac{1}{2},p} - \tilde{\partial}_y g^z_{m + \frac{1}{2},n,p})
|
&+ \\vec{z} (\\tilde{\\partial}_x g^y_{m,n + \\frac{1}{2},p} - \\tilde{\\partial}_y g^z_{m + \\frac{1}{2},n,p})
|
||||||
\end{aligned} $$
|
\\end{aligned} $$
|
||||||
|
|
||||||
and
|
and
|
||||||
|
|
||||||
$$ \tilde{h}_{m - \frac{1}{2}, n - \frac{1}{2}, p - \frac{1}{2}} =
|
$$ \\tilde{h}_{m - \\frac{1}{2}, n - \\frac{1}{2}, p - \\frac{1}{2}} =
|
||||||
[\hat{\nabla} \times \hat{g}]_{m - \frac{1}{2}, n - \frac{1}{2}, p - \frac{1}{2}} $$
|
[\\hat{\\nabla} \\times \\hat{g}]_{m - \\frac{1}{2}, n - \\frac{1}{2}, p - \\frac{1}{2}} $$
|
||||||
|
|
||||||
where $\hat{g}$ and $\tilde{g}$ are located at $(m,n,p)$
|
where $\\hat{g}$ and $\\tilde{g}$ are located at $(m,n,p)$
|
||||||
with components at $(m \pm \frac{1}{2},n,p)$ etc.,
|
with components at $(m \\pm \\frac{1}{2},n,p)$ etc.,
|
||||||
while $\hat{h}$ and $\tilde{h}$ are located at $(m \pm \frac{1}{2}, n \pm \frac{1}{2}, p \pm \frac{1}{2})$
|
while $\\hat{h}$ and $\\tilde{h}$ are located at $(m \\pm \\frac{1}{2}, n \\pm \\frac{1}{2}, p \\pm \\frac{1}{2})$
|
||||||
with components at $(m, n \pm \frac{1}{2}, p \pm \frac{1}{2})$ etc.
|
with components at $(m, n \\pm \\frac{1}{2}, p \\pm \\frac{1}{2})$ etc.
|
||||||
|
|
||||||
|
|
||||||
[code: curls]
|
[code: curls]
|
||||||
|
|
@ -287,27 +287,27 @@ Maxwell's Equations
|
||||||
|
|
||||||
If we discretize both space (m,n,p) and time (l), Maxwell's equations become
|
If we discretize both space (m,n,p) and time (l), Maxwell's equations become
|
||||||
|
|
||||||
$$ \begin{aligned}
|
$$ \\begin{aligned}
|
||||||
\tilde{\nabla} \times \tilde{E}_{l,\vec{r}} &= -\tilde{\partial}_t \hat{B}_{l-\frac{1}{2}, \vec{r} + \frac{1}{2}}
|
\\tilde{\\nabla} \\times \\tilde{E}_{l,\\vec{r}} &= -\\tilde{\\partial}_t \\hat{B}_{l-\\frac{1}{2}, \\vec{r} + \\frac{1}{2}}
|
||||||
- \hat{M}_{l, \vec{r} + \frac{1}{2}} \\
|
- \\hat{M}_{l, \\vec{r} + \\frac{1}{2}} \\\\
|
||||||
\hat{\nabla} \times \hat{H}_{l-\frac{1}{2},\vec{r} + \frac{1}{2}} &= \hat{\partial}_t \tilde{D}_{l, \vec{r}}
|
\\hat{\\nabla} \\times \\hat{H}_{l-\\frac{1}{2},\\vec{r} + \\frac{1}{2}} &= \\hat{\\partial}_t \\tilde{D}_{l, \\vec{r}}
|
||||||
+ \tilde{J}_{l-\frac{1}{2},\vec{r}} \\
|
+ \\tilde{J}_{l-\\frac{1}{2},\\vec{r}} \\\\
|
||||||
\tilde{\nabla} \cdot \hat{B}_{l-\frac{1}{2}, \vec{r} + \frac{1}{2}} &= 0 \\
|
\\tilde{\\nabla} \\cdot \\hat{B}_{l-\\frac{1}{2}, \\vec{r} + \\frac{1}{2}} &= 0 \\\\
|
||||||
\hat{\nabla} \cdot \tilde{D}_{l,\vec{r}} &= \rho_{l,\vec{r}}
|
\\hat{\\nabla} \\cdot \\tilde{D}_{l,\\vec{r}} &= \\rho_{l,\\vec{r}}
|
||||||
\end{aligned} $$
|
\\end{aligned} $$
|
||||||
|
|
||||||
with
|
with
|
||||||
|
|
||||||
$$ \begin{aligned}
|
$$ \\begin{aligned}
|
||||||
\hat{B}_{\vec{r}} &= \mu_{\vec{r} + \frac{1}{2}} \cdot \hat{H}_{\vec{r} + \frac{1}{2}} \\
|
\\hat{B}_{\\vec{r}} &= \\mu_{\\vec{r} + \\frac{1}{2}} \\cdot \\hat{H}_{\\vec{r} + \\frac{1}{2}} \\\\
|
||||||
\tilde{D}_{\vec{r}} &= \epsilon_{\vec{r}} \cdot \tilde{E}_{\vec{r}}
|
\\tilde{D}_{\\vec{r}} &= \\epsilon_{\\vec{r}} \\cdot \\tilde{E}_{\\vec{r}}
|
||||||
\end{aligned} $$
|
\\end{aligned} $$
|
||||||
|
|
||||||
where the spatial subscripts are abbreviated as $\vec{r} = (m, n, p)$ and
|
where the spatial subscripts are abbreviated as $\\vec{r} = (m, n, p)$ and
|
||||||
$\vec{r} + \frac{1}{2} = (m + \frac{1}{2}, n + \frac{1}{2}, p + \frac{1}{2})$,
|
$\\vec{r} + \\frac{1}{2} = (m + \\frac{1}{2}, n + \\frac{1}{2}, p + \\frac{1}{2})$,
|
||||||
$\tilde{E}$ and $\hat{H}$ are the electric and magnetic fields,
|
$\\tilde{E}$ and $\\hat{H}$ are the electric and magnetic fields,
|
||||||
$\tilde{J}$ and $\hat{M}$ are the electric and magnetic current distributions,
|
$\\tilde{J}$ and $\\hat{M}$ are the electric and magnetic current distributions,
|
||||||
and $\epsilon$ and $\mu$ are the dielectric permittivity and magnetic permeability.
|
and $\\epsilon$ and $\\mu$ are the dielectric permittivity and magnetic permeability.
|
||||||
|
|
||||||
The above is Yee's algorithm, written in a form analogous to Maxwell's equations.
|
The above is Yee's algorithm, written in a form analogous to Maxwell's equations.
|
||||||
The time derivatives can be expanded to form the update equations:
|
The time derivatives can be expanded to form the update equations:
|
||||||
|
|
@ -369,34 +369,34 @@ Each component forms its own grid, offset from the others:
|
||||||
|
|
||||||
The divergence equations can be derived by taking the divergence of the curl equations
|
The divergence equations can be derived by taking the divergence of the curl equations
|
||||||
and combining them with charge continuity,
|
and combining them with charge continuity,
|
||||||
$$ \hat{\nabla} \cdot \tilde{J} + \hat{\partial}_t \rho = 0 $$
|
$$ \\hat{\\nabla} \\cdot \\tilde{J} + \\hat{\\partial}_t \\rho = 0 $$
|
||||||
implying that the discrete Maxwell's equations do not produce spurious charges.
|
implying that the discrete Maxwell's equations do not produce spurious charges.
|
||||||
|
|
||||||
|
|
||||||
Wave equation
|
Wave equation
|
||||||
-------------
|
-------------
|
||||||
|
|
||||||
Taking the backward curl of the $\tilde{\nabla} \times \tilde{E}$ equation and
|
Taking the backward curl of the $\\tilde{\\nabla} \\times \\tilde{E}$ equation and
|
||||||
replacing the resulting $\hat{\nabla} \times \hat{H}$ term using its respective equation,
|
replacing the resulting $\\hat{\\nabla} \\times \\hat{H}$ term using its respective equation,
|
||||||
and setting $\hat{M}$ to zero, we can form the discrete wave equation:
|
and setting $\\hat{M}$ to zero, we can form the discrete wave equation:
|
||||||
|
|
||||||
$$
|
$$
|
||||||
\begin{aligned}
|
\\begin{aligned}
|
||||||
\tilde{\nabla} \times \tilde{E}_{l,\vec{r}} &=
|
\\tilde{\\nabla} \\times \\tilde{E}_{l,\\vec{r}} &=
|
||||||
-\tilde{\partial}_t \hat{B}_{l-\frac{1}{2}, \vec{r} + \frac{1}{2}}
|
-\\tilde{\\partial}_t \\hat{B}_{l-\\frac{1}{2}, \\vec{r} + \\frac{1}{2}}
|
||||||
- \hat{M}_{l-1, \vec{r} + \frac{1}{2}} \\
|
- \\hat{M}_{l-1, \\vec{r} + \\frac{1}{2}} \\\\
|
||||||
\mu^{-1}_{\vec{r} + \frac{1}{2}} \cdot \tilde{\nabla} \times \tilde{E}_{l,\vec{r}} &=
|
\\mu^{-1}_{\\vec{r} + \\frac{1}{2}} \\cdot \\tilde{\\nabla} \\times \\tilde{E}_{l,\\vec{r}} &=
|
||||||
-\tilde{\partial}_t \hat{H}_{l-\frac{1}{2}, \vec{r} + \frac{1}{2}} \\
|
-\\tilde{\\partial}_t \\hat{H}_{l-\\frac{1}{2}, \\vec{r} + \\frac{1}{2}} \\\\
|
||||||
\hat{\nabla} \times (\mu^{-1}_{\vec{r} + \frac{1}{2}} \cdot \tilde{\nabla} \times \tilde{E}_{l,\vec{r}}) &=
|
\\hat{\\nabla} \\times (\\mu^{-1}_{\\vec{r} + \\frac{1}{2}} \\cdot \\tilde{\\nabla} \\times \\tilde{E}_{l,\\vec{r}}) &=
|
||||||
\hat{\nabla} \times (-\tilde{\partial}_t \hat{H}_{l-\frac{1}{2}, \vec{r} + \frac{1}{2}}) \\
|
\\hat{\\nabla} \\times (-\\tilde{\\partial}_t \\hat{H}_{l-\\frac{1}{2}, \\vec{r} + \\frac{1}{2}}) \\\\
|
||||||
\hat{\nabla} \times (\mu^{-1}_{\vec{r} + \frac{1}{2}} \cdot \tilde{\nabla} \times \tilde{E}_{l,\vec{r}}) &=
|
\\hat{\\nabla} \\times (\\mu^{-1}_{\\vec{r} + \\frac{1}{2}} \\cdot \\tilde{\\nabla} \\times \\tilde{E}_{l,\\vec{r}}) &=
|
||||||
-\tilde{\partial}_t \hat{\nabla} \times \hat{H}_{l-\frac{1}{2}, \vec{r} + \frac{1}{2}} \\
|
-\\tilde{\\partial}_t \\hat{\\nabla} \\times \\hat{H}_{l-\\frac{1}{2}, \\vec{r} + \\frac{1}{2}} \\\\
|
||||||
\hat{\nabla} \times (\mu^{-1}_{\vec{r} + \frac{1}{2}} \cdot \tilde{\nabla} \times \tilde{E}_{l,\vec{r}}) &=
|
\\hat{\\nabla} \\times (\\mu^{-1}_{\\vec{r} + \\frac{1}{2}} \\cdot \\tilde{\\nabla} \\times \\tilde{E}_{l,\\vec{r}}) &=
|
||||||
-\tilde{\partial}_t \hat{\partial}_t \epsilon_{\vec{r}} \tilde{E}_{l, \vec{r}} + \hat{\partial}_t \tilde{J}_{l-\frac{1}{2},\vec{r}} \\
|
-\\tilde{\\partial}_t \\hat{\\partial}_t \\epsilon_{\\vec{r}} \\tilde{E}_{l, \\vec{r}} + \\hat{\\partial}_t \\tilde{J}_{l-\\frac{1}{2},\\vec{r}} \\\\
|
||||||
\hat{\nabla} \times (\mu^{-1}_{\vec{r} + \frac{1}{2}} \cdot \tilde{\nabla} \times \tilde{E}_{l,\vec{r}})
|
\\hat{\\nabla} \\times (\\mu^{-1}_{\\vec{r} + \\frac{1}{2}} \\cdot \\tilde{\\nabla} \\times \\tilde{E}_{l,\\vec{r}})
|
||||||
+ \tilde{\partial}_t \hat{\partial}_t \epsilon_{\vec{r}} \cdot \tilde{E}_{l, \vec{r}}
|
+ \\tilde{\\partial}_t \\hat{\\partial}_t \\epsilon_{\\vec{r}} \\cdot \\tilde{E}_{l, \\vec{r}}
|
||||||
&= \tilde{\partial}_t \tilde{J}_{l - \frac{1}{2}, \vec{r}}
|
&= \\tilde{\\partial}_t \\tilde{J}_{l - \\frac{1}{2}, \\vec{r}}
|
||||||
\end{aligned}
|
\\end{aligned}
|
||||||
$$
|
$$
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -406,27 +406,27 @@ Frequency domain
|
||||||
We can substitute in a time-harmonic fields
|
We can substitute in a time-harmonic fields
|
||||||
|
|
||||||
$$
|
$$
|
||||||
\begin{aligned}
|
\\begin{aligned}
|
||||||
\tilde{E}_{l, \vec{r}} &= \tilde{E}_{\vec{r}} e^{-\imath \omega l \Delta_t} \\
|
\\tilde{E}_{l, \\vec{r}} &= \\tilde{E}_{\\vec{r}} e^{-\\imath \\omega l \\Delta_t} \\\\
|
||||||
\tilde{J}_{l, \vec{r}} &= \tilde{J}_{\vec{r}} e^{-\imath \omega (l - \frac{1}{2}) \Delta_t}
|
\\tilde{J}_{l, \\vec{r}} &= \\tilde{J}_{\\vec{r}} e^{-\\imath \\omega (l - \\frac{1}{2}) \\Delta_t}
|
||||||
\end{aligned}
|
\\end{aligned}
|
||||||
$$
|
$$
|
||||||
|
|
||||||
resulting in
|
resulting in
|
||||||
|
|
||||||
$$
|
$$
|
||||||
\begin{aligned}
|
\\begin{aligned}
|
||||||
\tilde{\partial}_t &\Rightarrow (e^{ \imath \omega \Delta_t} - 1) / \Delta_t = \frac{-2 \imath}{\Delta_t} \sin(\omega \Delta_t / 2) e^{-\imath \omega \Delta_t / 2} = -\imath \Omega e^{-\imath \omega \Delta_t / 2}\\
|
\\tilde{\\partial}_t &\\Rightarrow (e^{ \\imath \\omega \\Delta_t} - 1) / \\Delta_t = \\frac{-2 \\imath}{\\Delta_t} \\sin(\\omega \\Delta_t / 2) e^{-\\imath \\omega \\Delta_t / 2} = -\\imath \\Omega e^{-\\imath \\omega \\Delta_t / 2}\\\\
|
||||||
\hat{\partial}_t &\Rightarrow (1 - e^{-\imath \omega \Delta_t}) / \Delta_t = \frac{-2 \imath}{\Delta_t} \sin(\omega \Delta_t / 2) e^{ \imath \omega \Delta_t / 2} = -\imath \Omega e^{ \imath \omega \Delta_t / 2}\\
|
\\hat{\\partial}_t &\\Rightarrow (1 - e^{-\\imath \\omega \\Delta_t}) / \\Delta_t = \\frac{-2 \\imath}{\\Delta_t} \\sin(\\omega \\Delta_t / 2) e^{ \\imath \\omega \\Delta_t / 2} = -\\imath \\Omega e^{ \\imath \\omega \\Delta_t / 2}\\\\
|
||||||
\Omega &= 2 \sin(\omega \Delta_t / 2) / \Delta_t
|
\\Omega &= 2 \\sin(\\omega \\Delta_t / 2) / \\Delta_t
|
||||||
\end{aligned}
|
\\end{aligned}
|
||||||
$$
|
$$
|
||||||
|
|
||||||
This gives the frequency-domain wave equation,
|
This gives the frequency-domain wave equation,
|
||||||
|
|
||||||
$$
|
$$
|
||||||
\hat{\nabla} \times (\mu^{-1}_{\vec{r} + \frac{1}{2}} \cdot \tilde{\nabla} \times \tilde{E}_{\vec{r}})
|
\\hat{\\nabla} \\times (\\mu^{-1}_{\\vec{r} + \\frac{1}{2}} \\cdot \\tilde{\\nabla} \\times \\tilde{E}_{\\vec{r}})
|
||||||
-\Omega^2 \epsilon_{\vec{r}} \cdot \tilde{E}_{\vec{r}} = -\imath \Omega \tilde{J}_{\vec{r}} e^{\imath \omega \Delta_t / 2} \\
|
-\\Omega^2 \\epsilon_{\\vec{r}} \\cdot \\tilde{E}_{\\vec{r}} = -\\imath \\Omega \\tilde{J}_{\\vec{r}} e^{\\imath \\omega \\Delta_t / 2} \\\\
|
||||||
$$
|
$$
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -436,48 +436,48 @@ Plane waves and Dispersion relation
|
||||||
With uniform material distribution and no sources
|
With uniform material distribution and no sources
|
||||||
|
|
||||||
$$
|
$$
|
||||||
\begin{aligned}
|
\\begin{aligned}
|
||||||
\mu_{\vec{r} + \frac{1}{2}} &= \mu \\
|
\\mu_{\\vec{r} + \\frac{1}{2}} &= \\mu \\\\
|
||||||
\epsilon_{\vec{r}} &= \epsilon \\
|
\\epsilon_{\\vec{r}} &= \\epsilon \\\\
|
||||||
\tilde{J}_{\vec{r}} &= 0 \\
|
\\tilde{J}_{\\vec{r}} &= 0 \\\\
|
||||||
\end{aligned}
|
\\end{aligned}
|
||||||
$$
|
$$
|
||||||
|
|
||||||
the frequency domain wave equation simplifies to
|
the frequency domain wave equation simplifies to
|
||||||
|
|
||||||
$$ \hat{\nabla} \times \tilde{\nabla} \times \tilde{E}_{\vec{r}} - \Omega^2 \epsilon \mu \tilde{E}_{\vec{r}} = 0 $$
|
$$ \\hat{\\nabla} \\times \\tilde{\\nabla} \\times \\tilde{E}_{\\vec{r}} - \\Omega^2 \\epsilon \\mu \\tilde{E}_{\\vec{r}} = 0 $$
|
||||||
|
|
||||||
Since $\hat{\nabla} \cdot \tilde{E}_{\vec{r}} = 0$, we can simplify
|
Since $\\hat{\\nabla} \\cdot \\tilde{E}_{\\vec{r}} = 0$, we can simplify
|
||||||
|
|
||||||
$$
|
$$
|
||||||
\begin{aligned}
|
\\begin{aligned}
|
||||||
\hat{\nabla} \times \tilde{\nabla} \times \tilde{E}_{\vec{r}}
|
\\hat{\\nabla} \\times \\tilde{\\nabla} \\times \\tilde{E}_{\\vec{r}}
|
||||||
&= \tilde{\nabla}(\hat{\nabla} \cdot \tilde{E}_{\vec{r}}) - \hat{\nabla} \cdot \tilde{\nabla} \tilde{E}_{\vec{r}} \\
|
&= \\tilde{\\nabla}(\\hat{\\nabla} \\cdot \\tilde{E}_{\\vec{r}}) - \\hat{\\nabla} \\cdot \\tilde{\\nabla} \\tilde{E}_{\\vec{r}} \\\\
|
||||||
&= - \hat{\nabla} \cdot \tilde{\nabla} \tilde{E}_{\vec{r}} \\
|
&= - \\hat{\\nabla} \\cdot \\tilde{\\nabla} \\tilde{E}_{\\vec{r}} \\\\
|
||||||
&= - \tilde{\nabla}^2 \tilde{E}_{\vec{r}}
|
&= - \\tilde{\\nabla}^2 \\tilde{E}_{\\vec{r}}
|
||||||
\end{aligned}
|
\\end{aligned}
|
||||||
$$
|
$$
|
||||||
|
|
||||||
and we get
|
and we get
|
||||||
|
|
||||||
$$ \tilde{\nabla}^2 \tilde{E}_{\vec{r}} + \Omega^2 \epsilon \mu \tilde{E}_{\vec{r}} = 0 $$
|
$$ \\tilde{\\nabla}^2 \\tilde{E}_{\\vec{r}} + \\Omega^2 \\epsilon \\mu \\tilde{E}_{\\vec{r}} = 0 $$
|
||||||
|
|
||||||
We can convert this to three scalar-wave equations of the form
|
We can convert this to three scalar-wave equations of the form
|
||||||
|
|
||||||
$$ (\tilde{\nabla}^2 + K^2) \phi_{\vec{r}} = 0 $$
|
$$ (\\tilde{\\nabla}^2 + K^2) \\phi_{\\vec{r}} = 0 $$
|
||||||
|
|
||||||
with $K^2 = \Omega^2 \mu \epsilon$. Now we let
|
with $K^2 = \\Omega^2 \\mu \\epsilon$. Now we let
|
||||||
|
|
||||||
$$ \phi_{\vec{r}} = A e^{\imath (k_x m \Delta_x + k_y n \Delta_y + k_z p \Delta_z)} $$
|
$$ \\phi_{\\vec{r}} = A e^{\\imath (k_x m \\Delta_x + k_y n \\Delta_y + k_z p \\Delta_z)} $$
|
||||||
|
|
||||||
resulting in
|
resulting in
|
||||||
|
|
||||||
$$
|
$$
|
||||||
\begin{aligned}
|
\\begin{aligned}
|
||||||
\tilde{\partial}_x &\Rightarrow (e^{ \imath k_x \Delta_x} - 1) / \Delta_t = \frac{-2 \imath}{\Delta_x} \sin(k_x \Delta_x / 2) e^{ \imath k_x \Delta_x / 2} = \imath K_x e^{ \imath k_x \Delta_x / 2}\\
|
\\tilde{\\partial}_x &\\Rightarrow (e^{ \\imath k_x \\Delta_x} - 1) / \\Delta_t = \\frac{-2 \\imath}{\\Delta_x} \\sin(k_x \\Delta_x / 2) e^{ \\imath k_x \\Delta_x / 2} = \\imath K_x e^{ \\imath k_x \\Delta_x / 2}\\\\
|
||||||
\hat{\partial}_x &\Rightarrow (1 - e^{-\imath k_x \Delta_x}) / \Delta_t = \frac{-2 \imath}{\Delta_x} \sin(k_x \Delta_x / 2) e^{-\imath k_x \Delta_x / 2} = \imath K_x e^{-\imath k_x \Delta_x / 2}\\
|
\\hat{\\partial}_x &\\Rightarrow (1 - e^{-\\imath k_x \\Delta_x}) / \\Delta_t = \\frac{-2 \\imath}{\\Delta_x} \\sin(k_x \\Delta_x / 2) e^{-\\imath k_x \\Delta_x / 2} = \\imath K_x e^{-\\imath k_x \\Delta_x / 2}\\\\
|
||||||
K_x &= 2 \sin(k_x \Delta_x / 2) / \Delta_x \\
|
K_x &= 2 \\sin(k_x \\Delta_x / 2) / \\Delta_x \\\\
|
||||||
\end{aligned}
|
\\end{aligned}
|
||||||
$$
|
$$
|
||||||
|
|
||||||
with similar expressions for the y and z dimnsions (and $K_y, K_z$).
|
with similar expressions for the y and z dimnsions (and $K_y, K_z$).
|
||||||
|
|
@ -485,20 +485,20 @@ with similar expressions for the y and z dimnsions (and $K_y, K_z$).
|
||||||
This implies
|
This implies
|
||||||
|
|
||||||
$$
|
$$
|
||||||
\tilde{\nabla}^2 = -(K_x^2 + K_y^2 + K_z^2) \phi_{\vec{r}} \\
|
\\tilde{\\nabla}^2 = -(K_x^2 + K_y^2 + K_z^2) \\phi_{\\vec{r}} \\\\
|
||||||
K_x^2 + K_y^2 + K_z^2 = \Omega^2 \mu \epsilon = \Omega^2 / c^2
|
K_x^2 + K_y^2 + K_z^2 = \\Omega^2 \\mu \\epsilon = \\Omega^2 / c^2
|
||||||
$$
|
$$
|
||||||
|
|
||||||
where $c = \sqrt{\mu \epsilon}$.
|
where $c = \\sqrt{\\mu \\epsilon}$.
|
||||||
|
|
||||||
Assuming real $(k_x, k_y, k_z), \omega$ will be real only if
|
Assuming real $(k_x, k_y, k_z), \\omega$ will be real only if
|
||||||
|
|
||||||
$$ c^2 \Delta_t^2 = \frac{\Delta_t^2}{\mu \epsilon} < 1/(\frac{1}{\Delta_x^2} + \frac{1}{\Delta_y^2} + \frac{1}{\Delta_z^2}) $$
|
$$ c^2 \\Delta_t^2 = \\frac{\\Delta_t^2}{\\mu \\epsilon} < 1/(\\frac{1}{\\Delta_x^2} + \\frac{1}{\\Delta_y^2} + \\frac{1}{\\Delta_z^2}) $$
|
||||||
|
|
||||||
If $\Delta_x = \Delta_y = \Delta_z$, this simplifies to $c \Delta_t < \Delta_x / \sqrt{3}$.
|
If $\\Delta_x = \\Delta_y = \\Delta_z$, this simplifies to $c \\Delta_t < \\Delta_x / \\sqrt{3}$.
|
||||||
This last form can be interpreted as enforcing causality; the distance that light
|
This last form can be interpreted as enforcing causality; the distance that light
|
||||||
travels in one timestep (i.e., $c \Delta_t$) must be less than the diagonal
|
travels in one timestep (i.e., $c \\Delta_t$) must be less than the diagonal
|
||||||
of the smallest cell ( $\Delta_x / \sqrt{3}$ when on a uniform cubic grid).
|
of the smallest cell ( $\\Delta_x / \\sqrt{3}$ when on a uniform cubic grid).
|
||||||
|
|
||||||
|
|
||||||
Grid description
|
Grid description
|
||||||
|
|
@ -515,8 +515,8 @@ to make the illustration simpler; we need at least two cells in the x dimension
|
||||||
demonstrate how nonuniform `dx` affects the various components.
|
demonstrate how nonuniform `dx` affects the various components.
|
||||||
|
|
||||||
Place the E fore-vectors at integer indices $r = (m, n, p)$ and the H back-vectors
|
Place the E fore-vectors at integer indices $r = (m, n, p)$ and the H back-vectors
|
||||||
at fractional indices $r + \frac{1}{2} = (m + \frac{1}{2}, n + \frac{1}{2},
|
at fractional indices $r + \\frac{1}{2} = (m + \\frac{1}{2}, n + \\frac{1}{2},
|
||||||
p + \frac{1}{2})$. Remember that these are indices and not coordinates; they can
|
p + \\frac{1}{2})$. Remember that these are indices and not coordinates; they can
|
||||||
correspond to arbitrary (monotonically increasing) coordinates depending on the cell widths.
|
correspond to arbitrary (monotonically increasing) coordinates depending on the cell widths.
|
||||||
|
|
||||||
Draw lines to denote the planes on which the H components and back-vectors are defined.
|
Draw lines to denote the planes on which the H components and back-vectors are defined.
|
||||||
|
|
@ -718,14 +718,14 @@ composed of the three diagonal tensor components:
|
||||||
or
|
or
|
||||||
|
|
||||||
$$
|
$$
|
||||||
\epsilon = \begin{bmatrix} \epsilon_{xx} & 0 & 0 \\
|
\\epsilon = \\begin{bmatrix} \\epsilon_{xx} & 0 & 0 \\\\
|
||||||
0 & \epsilon_{yy} & 0 \\
|
0 & \\epsilon_{yy} & 0 \\\\
|
||||||
0 & 0 & \epsilon_{zz} \end{bmatrix}
|
0 & 0 & \\epsilon_{zz} \\end{bmatrix}
|
||||||
$$
|
$$
|
||||||
$$
|
$$
|
||||||
\mu = \begin{bmatrix} \mu_{xx} & 0 & 0 \\
|
\\mu = \\begin{bmatrix} \\mu_{xx} & 0 & 0 \\\\
|
||||||
0 & \mu_{yy} & 0 \\
|
0 & \\mu_{yy} & 0 \\\\
|
||||||
0 & 0 & \mu_{zz} \end{bmatrix}
|
0 & 0 & \\mu_{zz} \\end{bmatrix}
|
||||||
$$
|
$$
|
||||||
|
|
||||||
where the off-diagonal terms (e.g. `epsilon_xy`) are assumed to be zero.
|
where the off-diagonal terms (e.g. `epsilon_xy`) are assumed to be zero.
|
||||||
|
|
@ -741,46 +741,8 @@ the true values can be multiplied back in after the simulation is complete if no
|
||||||
normalized results are needed.
|
normalized results are needed.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .types import (
|
from .types import fdfield_t, vfdfield_t, cfdfield_t, vcfdfield_t, dx_lists_t, dx_lists_mut
|
||||||
fdfield_t as fdfield_t,
|
from .types import fdfield_updater_t, cfdfield_updater_t
|
||||||
vfdfield_t as vfdfield_t,
|
from .vectorization import vec, unvec
|
||||||
cfdfield_t as cfdfield_t,
|
from . import operators, functional, types, vectorization
|
||||||
vcfdfield_t as vcfdfield_t,
|
|
||||||
fdfield2_t as fdfield2_t,
|
|
||||||
vfdfield2_t as vfdfield2_t,
|
|
||||||
cfdfield2_t as cfdfield2_t,
|
|
||||||
vcfdfield2_t as vcfdfield2_t,
|
|
||||||
fdfield as fdfield,
|
|
||||||
vfdfield as vfdfield,
|
|
||||||
cfdfield as cfdfield,
|
|
||||||
vcfdfield as vcfdfield,
|
|
||||||
fdfield2 as fdfield2,
|
|
||||||
vfdfield2 as vfdfield2,
|
|
||||||
cfdfield2 as cfdfield2,
|
|
||||||
vcfdfield2 as vcfdfield2,
|
|
||||||
fdslice_t as fdslice_t,
|
|
||||||
vfdslice_t as vfdslice_t,
|
|
||||||
cfdslice_t as cfdslice_t,
|
|
||||||
vcfdslice_t as vcfdslice_t,
|
|
||||||
fdslice as fdslice,
|
|
||||||
vfdslice as vfdslice,
|
|
||||||
cfdslice as cfdslice,
|
|
||||||
vcfdslice as vcfdslice,
|
|
||||||
dx_lists_t as dx_lists_t,
|
|
||||||
dx_lists2_t as dx_lists2_t,
|
|
||||||
dx_lists_mut as dx_lists_mut,
|
|
||||||
dx_lists2_mut as dx_lists2_mut,
|
|
||||||
fdfield_updater_t as fdfield_updater_t,
|
|
||||||
cfdfield_updater_t as cfdfield_updater_t,
|
|
||||||
)
|
|
||||||
from .vectorization import (
|
|
||||||
vec as vec,
|
|
||||||
unvec as unvec,
|
|
||||||
)
|
|
||||||
from . import (
|
|
||||||
operators as operators,
|
|
||||||
functional as functional,
|
|
||||||
types as types,
|
|
||||||
vectorization as vectorization,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,80 +3,66 @@ Math functions for finite difference simulations
|
||||||
|
|
||||||
Basic discrete calculus etc.
|
Basic discrete calculus etc.
|
||||||
"""
|
"""
|
||||||
from typing import TypeVar
|
from typing import Sequence, Callable
|
||||||
from collections.abc import Sequence, Callable
|
|
||||||
|
|
||||||
import numpy
|
import numpy
|
||||||
from numpy.typing import NDArray
|
from numpy.typing import NDArray
|
||||||
from numpy import floating, complexfloating
|
|
||||||
|
|
||||||
from .types import fdfield, fdfield_updater_t
|
from .types import fdfield_t, fdfield_updater_t
|
||||||
|
|
||||||
|
|
||||||
def _derivative(axis: int, forward: bool, widths: NDArray | None) -> fdfield_updater_t:
|
|
||||||
"""Periodic difference using views, with one output allocation at most."""
|
|
||||||
def derivative(f: NDArray, *, out: NDArray | None = None) -> NDArray:
|
|
||||||
f = numpy.asarray(f)
|
|
||||||
if f.ndim != 3 or f.shape[axis] == 0:
|
|
||||||
raise ValueError('derivatives require a nonempty three-dimensional axis')
|
|
||||||
if widths is not None and (numpy.ndim(widths) != 1 or len(widths) != f.shape[axis]):
|
|
||||||
raise ValueError('cell widths must match the differentiated axis')
|
|
||||||
dtype = f.dtype if widths is None else numpy.true_divide.resolve_dtypes((f.dtype, numpy.asarray(widths).dtype, None))[2]
|
|
||||||
if out is None:
|
|
||||||
output = numpy.empty(f.shape, dtype=dtype)
|
|
||||||
else:
|
|
||||||
output = numpy.asarray(out)
|
|
||||||
if output.shape != f.shape:
|
|
||||||
raise ValueError('out must match the field shape')
|
|
||||||
if numpy.shares_memory(f, output) or (widths is not None and numpy.shares_memory(widths, output)):
|
|
||||||
raise ValueError('out must not alias the field or widths')
|
|
||||||
if not output.flags.writeable or not numpy.can_cast(dtype, output.dtype, casting='same_kind'):
|
|
||||||
raise ValueError('out must be writable with a compatible dtype')
|
|
||||||
low, high = [slice(None)] * 3, [slice(None)] * 3
|
|
||||||
low[axis], high[axis] = slice(None, -1), slice(1, None)
|
|
||||||
first, last = [slice(None)] * 3, [slice(None)] * 3
|
|
||||||
first[axis], last[axis] = slice(0, 1), slice(-1, None)
|
|
||||||
destination = low if forward else high
|
|
||||||
numpy.subtract(f[tuple(high)], f[tuple(low)], out=output[tuple(destination)])
|
|
||||||
destination = last if forward else first
|
|
||||||
numpy.subtract(f[tuple(first)], f[tuple(last)], out=output[tuple(destination)])
|
|
||||||
if widths is not None:
|
|
||||||
expand = [1, 1, 1]
|
|
||||||
expand[axis] = len(widths)
|
|
||||||
numpy.divide(output, numpy.asarray(widths).reshape(expand), out=output)
|
|
||||||
return output
|
|
||||||
return derivative
|
|
||||||
|
|
||||||
|
|
||||||
def deriv_forward(
|
def deriv_forward(
|
||||||
dx_e: Sequence[NDArray[floating | complexfloating]] | None = None,
|
dx_e: Sequence[NDArray[numpy.float_]] | None = None,
|
||||||
) -> tuple[fdfield_updater_t, fdfield_updater_t, fdfield_updater_t]:
|
) -> tuple[fdfield_updater_t, fdfield_updater_t, fdfield_updater_t]:
|
||||||
"""Forward periodic derivatives divided by electric Yee widths.
|
|
||||||
|
|
||||||
Each callable accepts ``derivative(field, *, out=None)``. An output buffer
|
|
||||||
must match the field shape and must not alias the field or width arrays.
|
|
||||||
Slice-based subtraction avoids a full-size roll temporary.
|
|
||||||
"""
|
"""
|
||||||
return tuple(_derivative(axis, True, None if dx_e is None else dx_e[axis]) for axis in range(3))
|
Utility operators for taking discretized derivatives (backward variant).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dx_e: Lists of cell sizes for all axes
|
||||||
|
`[[dx_0, dx_1, ...], [dy_0, dy_1, ...], ...]`.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of functions for taking forward derivatives along each axis.
|
||||||
|
"""
|
||||||
|
if dx_e is not None:
|
||||||
|
derivs = (lambda f: (numpy.roll(f, -1, axis=0) - f) / dx_e[0][:, None, None],
|
||||||
|
lambda f: (numpy.roll(f, -1, axis=1) - f) / dx_e[1][None, :, None],
|
||||||
|
lambda f: (numpy.roll(f, -1, axis=2) - f) / dx_e[2][None, None, :])
|
||||||
|
else:
|
||||||
|
derivs = (lambda f: numpy.roll(f, -1, axis=0) - f,
|
||||||
|
lambda f: numpy.roll(f, -1, axis=1) - f,
|
||||||
|
lambda f: numpy.roll(f, -1, axis=2) - f)
|
||||||
|
return derivs
|
||||||
|
|
||||||
|
|
||||||
def deriv_back(
|
def deriv_back(
|
||||||
dx_h: Sequence[NDArray[floating | complexfloating]] | None = None,
|
dx_h: Sequence[NDArray[numpy.float_]] | None = None,
|
||||||
) -> tuple[fdfield_updater_t, fdfield_updater_t, fdfield_updater_t]:
|
) -> tuple[fdfield_updater_t, fdfield_updater_t, fdfield_updater_t]:
|
||||||
"""Backward periodic derivatives divided by magnetic Yee widths.
|
|
||||||
|
|
||||||
Callables accept the same non-aliasing ``out`` buffer as deriv_forward.
|
|
||||||
"""
|
"""
|
||||||
return tuple(_derivative(axis, False, None if dx_h is None else dx_h[axis]) for axis in range(3))
|
Utility operators for taking discretized derivatives (forward variant).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dx_h: Lists of cell sizes for all axes
|
||||||
|
`[[dx_0, dx_1, ...], [dy_0, dy_1, ...], ...]`.
|
||||||
|
|
||||||
TT = TypeVar('TT', bound='NDArray[floating | complexfloating]')
|
Returns:
|
||||||
|
List of functions for taking forward derivatives along each axis.
|
||||||
|
"""
|
||||||
|
if dx_h is not None:
|
||||||
|
derivs = (lambda f: (f - numpy.roll(f, 1, axis=0)) / dx_h[0][:, None, None],
|
||||||
|
lambda f: (f - numpy.roll(f, 1, axis=1)) / dx_h[1][None, :, None],
|
||||||
|
lambda f: (f - numpy.roll(f, 1, axis=2)) / dx_h[2][None, None, :])
|
||||||
|
else:
|
||||||
|
derivs = (lambda f: f - numpy.roll(f, 1, axis=0),
|
||||||
|
lambda f: f - numpy.roll(f, 1, axis=1),
|
||||||
|
lambda f: f - numpy.roll(f, 1, axis=2))
|
||||||
|
return derivs
|
||||||
|
|
||||||
|
|
||||||
def curl_forward(
|
def curl_forward(
|
||||||
dx_e: Sequence[NDArray[floating | complexfloating]] | None = None,
|
dx_e: Sequence[NDArray[numpy.float_]] | None = None,
|
||||||
) -> Callable[[TT], TT]:
|
) -> fdfield_updater_t:
|
||||||
r"""
|
"""
|
||||||
Curl operator for use with the E field.
|
Curl operator for use with the E field.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -85,11 +71,11 @@ def curl_forward(
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Function `f` for taking the discrete forward curl of a field,
|
Function `f` for taking the discrete forward curl of a field,
|
||||||
`f(E)` -> curlE $= \nabla_f \times E$
|
`f(E)` -> curlE $= \\nabla_f \\times E$
|
||||||
"""
|
"""
|
||||||
Dx, Dy, Dz = deriv_forward(dx_e)
|
Dx, Dy, Dz = deriv_forward(dx_e)
|
||||||
|
|
||||||
def ce_fun(e: TT) -> TT:
|
def ce_fun(e: fdfield_t) -> fdfield_t:
|
||||||
output = numpy.empty_like(e)
|
output = numpy.empty_like(e)
|
||||||
output[0] = Dy(e[2])
|
output[0] = Dy(e[2])
|
||||||
output[1] = Dz(e[0])
|
output[1] = Dz(e[0])
|
||||||
|
|
@ -103,9 +89,9 @@ def curl_forward(
|
||||||
|
|
||||||
|
|
||||||
def curl_back(
|
def curl_back(
|
||||||
dx_h: Sequence[NDArray[floating | complexfloating]] | None = None,
|
dx_h: Sequence[NDArray[numpy.float_]] | None = None,
|
||||||
) -> Callable[[TT], TT]:
|
) -> fdfield_updater_t:
|
||||||
r"""
|
"""
|
||||||
Create a function which takes the backward curl of a field.
|
Create a function which takes the backward curl of a field.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -114,11 +100,11 @@ def curl_back(
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Function `f` for taking the discrete backward curl of a field,
|
Function `f` for taking the discrete backward curl of a field,
|
||||||
`f(H)` -> curlH $= \nabla_b \times H$
|
`f(H)` -> curlH $= \\nabla_b \\times H$
|
||||||
"""
|
"""
|
||||||
Dx, Dy, Dz = deriv_back(dx_h)
|
Dx, Dy, Dz = deriv_back(dx_h)
|
||||||
|
|
||||||
def ch_fun(h: TT) -> TT:
|
def ch_fun(h: fdfield_t) -> fdfield_t:
|
||||||
output = numpy.empty_like(h)
|
output = numpy.empty_like(h)
|
||||||
output[0] = Dy(h[2])
|
output[0] = Dy(h[2])
|
||||||
output[1] = Dz(h[0])
|
output[1] = Dz(h[0])
|
||||||
|
|
@ -132,11 +118,11 @@ def curl_back(
|
||||||
|
|
||||||
|
|
||||||
def curl_forward_parts(
|
def curl_forward_parts(
|
||||||
dx_e: Sequence[NDArray[floating | complexfloating]] | None = None,
|
dx_e: Sequence[NDArray[numpy.float_]] | None = None,
|
||||||
) -> Callable:
|
) -> Callable:
|
||||||
Dx, Dy, Dz = deriv_forward(dx_e)
|
Dx, Dy, Dz = deriv_forward(dx_e)
|
||||||
|
|
||||||
def mkparts_fwd(e: fdfield) -> tuple[tuple[fdfield, fdfield], ...]:
|
def mkparts_fwd(e: fdfield_t) -> tuple[tuple[fdfield_t, fdfield_t], ...]:
|
||||||
return ((-Dz(e[1]), Dy(e[2])),
|
return ((-Dz(e[1]), Dy(e[2])),
|
||||||
( Dz(e[0]), -Dx(e[2])),
|
( Dz(e[0]), -Dx(e[2])),
|
||||||
(-Dy(e[0]), Dx(e[1])))
|
(-Dy(e[0]), Dx(e[1])))
|
||||||
|
|
@ -145,11 +131,11 @@ def curl_forward_parts(
|
||||||
|
|
||||||
|
|
||||||
def curl_back_parts(
|
def curl_back_parts(
|
||||||
dx_h: Sequence[NDArray[floating | complexfloating]] | None = None,
|
dx_h: Sequence[NDArray[numpy.float_]] | None = None,
|
||||||
) -> Callable:
|
) -> Callable:
|
||||||
Dx, Dy, Dz = deriv_back(dx_h)
|
Dx, Dy, Dz = deriv_back(dx_h)
|
||||||
|
|
||||||
def mkparts_back(h: fdfield) -> tuple[tuple[fdfield, fdfield], ...]:
|
def mkparts_back(h: fdfield_t) -> tuple[tuple[fdfield_t, fdfield_t], ...]:
|
||||||
return ((-Dz(h[1]), Dy(h[2])),
|
return ((-Dz(h[1]), Dy(h[2])),
|
||||||
( Dz(h[0]), -Dx(h[2])),
|
( Dz(h[0]), -Dx(h[2])),
|
||||||
(-Dy(h[0]), Dx(h[1])))
|
(-Dy(h[0]), Dx(h[1])))
|
||||||
|
|
|
||||||
|
|
@ -3,20 +3,19 @@ Matrix operators for finite difference simulations
|
||||||
|
|
||||||
Basic discrete calculus etc.
|
Basic discrete calculus etc.
|
||||||
"""
|
"""
|
||||||
from collections.abc import Sequence
|
from typing import Sequence
|
||||||
import numpy
|
import numpy
|
||||||
from numpy.typing import NDArray
|
from numpy.typing import NDArray
|
||||||
from numpy import floating, complexfloating
|
import scipy.sparse as sparse # type: ignore
|
||||||
from scipy import sparse
|
|
||||||
|
|
||||||
from .types import vfdfield
|
from .types import vfdfield_t
|
||||||
|
|
||||||
|
|
||||||
def shift_circ(
|
def shift_circ(
|
||||||
axis: int,
|
axis: int,
|
||||||
shape: Sequence[int],
|
shape: Sequence[int],
|
||||||
shift_distance: int = 1,
|
shift_distance: int = 1,
|
||||||
) -> sparse.sparray:
|
) -> sparse.spmatrix:
|
||||||
"""
|
"""
|
||||||
Utility operator for performing a circular shift along a specified axis by a
|
Utility operator for performing a circular shift along a specified axis by a
|
||||||
specified number of elements.
|
specified number of elements.
|
||||||
|
|
@ -30,12 +29,12 @@ def shift_circ(
|
||||||
Sparse matrix for performing the circular shift.
|
Sparse matrix for performing the circular shift.
|
||||||
"""
|
"""
|
||||||
if len(shape) not in (2, 3):
|
if len(shape) not in (2, 3):
|
||||||
raise Exception(f'Invalid shape: {shape}')
|
raise Exception('Invalid shape: {}'.format(shape))
|
||||||
if axis not in range(len(shape)):
|
if axis not in range(len(shape)):
|
||||||
raise Exception(f'Invalid direction: {axis}, shape is {shape}')
|
raise Exception('Invalid direction: {}, shape is {}'.format(axis, shape))
|
||||||
|
|
||||||
shifts = [abs(shift_distance) if a == axis else 0 for a in range(len(shape))]
|
shifts = [abs(shift_distance) if a == axis else 0 for a in range(3)]
|
||||||
shifted_diags = [(numpy.arange(n) + s) % n for n, s in zip(shape, shifts, strict=True)]
|
shifted_diags = [(numpy.arange(n) + s) % n for n, s in zip(shape, shifts)]
|
||||||
ijk = numpy.meshgrid(*shifted_diags, indexing='ij')
|
ijk = numpy.meshgrid(*shifted_diags, indexing='ij')
|
||||||
|
|
||||||
n = numpy.prod(shape)
|
n = numpy.prod(shape)
|
||||||
|
|
@ -44,7 +43,7 @@ def shift_circ(
|
||||||
|
|
||||||
vij = (numpy.ones(n), (i_ind, j_ind.ravel(order='C')))
|
vij = (numpy.ones(n), (i_ind, j_ind.ravel(order='C')))
|
||||||
|
|
||||||
d = sparse.csr_array(vij, shape=(n, n))
|
d = sparse.csr_matrix(vij, shape=(n, n))
|
||||||
|
|
||||||
if shift_distance < 0:
|
if shift_distance < 0:
|
||||||
d = d.T
|
d = d.T
|
||||||
|
|
@ -56,7 +55,7 @@ def shift_with_mirror(
|
||||||
axis: int,
|
axis: int,
|
||||||
shape: Sequence[int],
|
shape: Sequence[int],
|
||||||
shift_distance: int = 1,
|
shift_distance: int = 1,
|
||||||
) -> sparse.sparray:
|
) -> sparse.spmatrix:
|
||||||
"""
|
"""
|
||||||
Utility operator for performing an n-element shift along a specified axis, with mirror
|
Utility operator for performing an n-element shift along a specified axis, with mirror
|
||||||
boundary conditions applied to the cells beyond the receding edge.
|
boundary conditions applied to the cells beyond the receding edge.
|
||||||
|
|
@ -70,11 +69,12 @@ def shift_with_mirror(
|
||||||
Sparse matrix for performing the shift-with-mirror.
|
Sparse matrix for performing the shift-with-mirror.
|
||||||
"""
|
"""
|
||||||
if len(shape) not in (2, 3):
|
if len(shape) not in (2, 3):
|
||||||
raise Exception(f'Invalid shape: {shape}')
|
raise Exception('Invalid shape: {}'.format(shape))
|
||||||
if axis not in range(len(shape)):
|
if axis not in range(len(shape)):
|
||||||
raise Exception(f'Invalid direction: {axis}, shape is {shape}')
|
raise Exception('Invalid direction: {}, shape is {}'.format(axis, shape))
|
||||||
if shift_distance >= shape[axis]:
|
if shift_distance >= shape[axis]:
|
||||||
raise Exception(f'Shift ({shift_distance}) is too large for axis {axis} of size {shape[axis]}')
|
raise Exception('Shift ({}) is too large for axis {} of size {}'.format(
|
||||||
|
shift_distance, axis, shape[axis]))
|
||||||
|
|
||||||
def mirrored_range(n: int, s: int) -> NDArray[numpy.int_]:
|
def mirrored_range(n: int, s: int) -> NDArray[numpy.int_]:
|
||||||
v = numpy.arange(n) + s
|
v = numpy.arange(n) + s
|
||||||
|
|
@ -82,8 +82,8 @@ def shift_with_mirror(
|
||||||
v = numpy.where(v < 0, - 1 - v, v)
|
v = numpy.where(v < 0, - 1 - v, v)
|
||||||
return v
|
return v
|
||||||
|
|
||||||
shifts = [shift_distance if a == axis else 0 for a in range(len(shape))]
|
shifts = [shift_distance if a == axis else 0 for a in range(3)]
|
||||||
shifted_diags = [mirrored_range(n, s) for n, s in zip(shape, shifts, strict=True)]
|
shifted_diags = [mirrored_range(n, s) for n, s in zip(shape, shifts)]
|
||||||
ijk = numpy.meshgrid(*shifted_diags, indexing='ij')
|
ijk = numpy.meshgrid(*shifted_diags, indexing='ij')
|
||||||
|
|
||||||
n = numpy.prod(shape)
|
n = numpy.prod(shape)
|
||||||
|
|
@ -92,13 +92,13 @@ def shift_with_mirror(
|
||||||
|
|
||||||
vij = (numpy.ones(n), (i_ind, j_ind.ravel(order='C')))
|
vij = (numpy.ones(n), (i_ind, j_ind.ravel(order='C')))
|
||||||
|
|
||||||
d = sparse.csr_array(vij, shape=(n, n))
|
d = sparse.csr_matrix(vij, shape=(n, n))
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
def deriv_forward(
|
def deriv_forward(
|
||||||
dx_e: Sequence[NDArray[floating | complexfloating]],
|
dx_e: Sequence[NDArray[numpy.float_]],
|
||||||
) -> list[sparse.sparray]:
|
) -> list[sparse.spmatrix]:
|
||||||
"""
|
"""
|
||||||
Utility operators for taking discretized derivatives (forward variant).
|
Utility operators for taking discretized derivatives (forward variant).
|
||||||
|
|
||||||
|
|
@ -114,18 +114,18 @@ def deriv_forward(
|
||||||
|
|
||||||
dx_e_expanded = numpy.meshgrid(*dx_e, indexing='ij')
|
dx_e_expanded = numpy.meshgrid(*dx_e, indexing='ij')
|
||||||
|
|
||||||
def deriv(axis: int) -> sparse.sparray:
|
def deriv(axis: int) -> sparse.spmatrix:
|
||||||
return shift_circ(axis, shape, 1) - sparse.eye_array(n)
|
return shift_circ(axis, shape, 1) - sparse.eye(n)
|
||||||
|
|
||||||
Ds = [sparse.diags_array(+1 / dx.ravel(order='C')) @ deriv(a)
|
Ds = [sparse.diags(+1 / dx.ravel(order='C')) @ deriv(a)
|
||||||
for a, dx in enumerate(dx_e_expanded)]
|
for a, dx in enumerate(dx_e_expanded)]
|
||||||
|
|
||||||
return Ds
|
return Ds
|
||||||
|
|
||||||
|
|
||||||
def deriv_back(
|
def deriv_back(
|
||||||
dx_h: Sequence[NDArray[floating | complexfloating]],
|
dx_h: Sequence[NDArray[numpy.float_]],
|
||||||
) -> list[sparse.sparray]:
|
) -> list[sparse.spmatrix]:
|
||||||
"""
|
"""
|
||||||
Utility operators for taking discretized derivatives (backward variant).
|
Utility operators for taking discretized derivatives (backward variant).
|
||||||
|
|
||||||
|
|
@ -141,37 +141,36 @@ def deriv_back(
|
||||||
|
|
||||||
dx_h_expanded = numpy.meshgrid(*dx_h, indexing='ij')
|
dx_h_expanded = numpy.meshgrid(*dx_h, indexing='ij')
|
||||||
|
|
||||||
def deriv(axis: int) -> sparse.sparray:
|
def deriv(axis: int) -> sparse.spmatrix:
|
||||||
return shift_circ(axis, shape, -1) - sparse.eye_array(n)
|
return shift_circ(axis, shape, -1) - sparse.eye(n)
|
||||||
|
|
||||||
Ds = [sparse.diags_array(-1 / dx.ravel(order='C')) @ deriv(a)
|
Ds = [sparse.diags(-1 / dx.ravel(order='C')) @ deriv(a)
|
||||||
for a, dx in enumerate(dx_h_expanded)]
|
for a, dx in enumerate(dx_h_expanded)]
|
||||||
|
|
||||||
return Ds
|
return Ds
|
||||||
|
|
||||||
|
|
||||||
def cross(
|
def cross(
|
||||||
B: Sequence[sparse.sparray],
|
B: Sequence[sparse.spmatrix],
|
||||||
) -> sparse.sparray:
|
) -> sparse.spmatrix:
|
||||||
"""
|
"""
|
||||||
Cross product operator
|
Cross product operator
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
B: List `[Bx, By, Bz]` of sparse matrices corresponding to the x, y, z
|
B: List `[Bx, By, Bz]` of sparse matrices corresponding to the x, y, z
|
||||||
portions of the operator on the left side of the cross product.
|
portions of the operator on the left side of the cross product.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Sparse matrix corresponding to (B x), where x is the cross product.
|
Sparse matrix corresponding to (B x), where x is the cross product.
|
||||||
"""
|
"""
|
||||||
n = B[0].shape[0]
|
n = B[0].shape[0]
|
||||||
zero = sparse.csr_array((n, n))
|
zero = sparse.csr_matrix((n, n))
|
||||||
return sparse.block_array([
|
return sparse.bmat([[zero, -B[2], B[1]],
|
||||||
[zero, -B[2], B[1]],
|
[B[2], zero, -B[0]],
|
||||||
[B[2], zero, -B[0]],
|
[-B[1], B[0], zero]])
|
||||||
[-B[1], B[0], zero]])
|
|
||||||
|
|
||||||
|
|
||||||
def vec_cross(b: vfdfield) -> sparse.sparray:
|
def vec_cross(b: vfdfield_t) -> sparse.spmatrix:
|
||||||
"""
|
"""
|
||||||
Vector cross product operator
|
Vector cross product operator
|
||||||
|
|
||||||
|
|
@ -183,11 +182,11 @@ def vec_cross(b: vfdfield) -> sparse.sparray:
|
||||||
Sparse matrix corresponding to (b x), where x is the cross product.
|
Sparse matrix corresponding to (b x), where x is the cross product.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
B = [sparse.diags_array(c) for c in numpy.split(b, 3)]
|
B = [sparse.diags(c) for c in numpy.split(b, 3)]
|
||||||
return cross(B)
|
return cross(B)
|
||||||
|
|
||||||
|
|
||||||
def avg_forward(axis: int, shape: Sequence[int]) -> sparse.sparray:
|
def avg_forward(axis: int, shape: Sequence[int]) -> sparse.spmatrix:
|
||||||
"""
|
"""
|
||||||
Forward average operator `(x4 = (x4 + x5) / 2)`
|
Forward average operator `(x4 = (x4 + x5) / 2)`
|
||||||
|
|
||||||
|
|
@ -199,13 +198,13 @@ def avg_forward(axis: int, shape: Sequence[int]) -> sparse.sparray:
|
||||||
Sparse matrix for forward average operation.
|
Sparse matrix for forward average operation.
|
||||||
"""
|
"""
|
||||||
if len(shape) not in (2, 3):
|
if len(shape) not in (2, 3):
|
||||||
raise Exception(f'Invalid shape: {shape}')
|
raise Exception('Invalid shape: {}'.format(shape))
|
||||||
|
|
||||||
n = numpy.prod(shape)
|
n = numpy.prod(shape)
|
||||||
return 0.5 * (sparse.eye_array(n) + shift_circ(axis, shape))
|
return 0.5 * (sparse.eye(n) + shift_circ(axis, shape))
|
||||||
|
|
||||||
|
|
||||||
def avg_back(axis: int, shape: Sequence[int]) -> sparse.sparray:
|
def avg_back(axis: int, shape: Sequence[int]) -> sparse.spmatrix:
|
||||||
"""
|
"""
|
||||||
Backward average operator `(x4 = (x4 + x3) / 2)`
|
Backward average operator `(x4 = (x4 + x3) / 2)`
|
||||||
|
|
||||||
|
|
@ -220,8 +219,8 @@ def avg_back(axis: int, shape: Sequence[int]) -> sparse.sparray:
|
||||||
|
|
||||||
|
|
||||||
def curl_forward(
|
def curl_forward(
|
||||||
dx_e: Sequence[NDArray[floating | complexfloating]],
|
dx_e: Sequence[NDArray[numpy.float_]],
|
||||||
) -> sparse.sparray:
|
) -> sparse.spmatrix:
|
||||||
"""
|
"""
|
||||||
Curl operator for use with the E field.
|
Curl operator for use with the E field.
|
||||||
|
|
||||||
|
|
@ -236,8 +235,8 @@ def curl_forward(
|
||||||
|
|
||||||
|
|
||||||
def curl_back(
|
def curl_back(
|
||||||
dx_h: Sequence[NDArray[floating | complexfloating]],
|
dx_h: Sequence[NDArray[numpy.float_]],
|
||||||
) -> sparse.sparray:
|
) -> sparse.spmatrix:
|
||||||
"""
|
"""
|
||||||
Curl operator for use with the H field.
|
Curl operator for use with the H field.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,65 +1,26 @@
|
||||||
"""
|
"""
|
||||||
Types shared across multiple submodules
|
Types shared across multiple submodules
|
||||||
"""
|
"""
|
||||||
from typing import NewType
|
from typing import Sequence, Callable, MutableSequence
|
||||||
from collections.abc import Sequence, Callable, MutableSequence
|
import numpy
|
||||||
from numpy.typing import NDArray
|
from numpy.typing import NDArray
|
||||||
from numpy import floating, complexfloating
|
|
||||||
|
|
||||||
|
|
||||||
# Field types
|
# Field types
|
||||||
fdfield_t = NewType('fdfield_t', NDArray[floating])
|
fdfield_t = NDArray[numpy.float_]
|
||||||
type fdfield = fdfield_t | NDArray[floating]
|
|
||||||
"""Vector field with shape (3, X, Y, Z) (e.g. `[E_x, E_y, E_z]`)"""
|
"""Vector field with shape (3, X, Y, Z) (e.g. `[E_x, E_y, E_z]`)"""
|
||||||
|
|
||||||
vfdfield_t = NewType('vfdfield_t', NDArray[floating])
|
vfdfield_t = NDArray[numpy.float_]
|
||||||
type vfdfield = vfdfield_t | NDArray[floating]
|
|
||||||
"""Linearized vector field (single vector of length 3*X*Y*Z)"""
|
"""Linearized vector field (single vector of length 3*X*Y*Z)"""
|
||||||
|
|
||||||
cfdfield_t = NewType('cfdfield_t', NDArray[complexfloating])
|
cfdfield_t = NDArray[numpy.complex_]
|
||||||
type cfdfield = cfdfield_t | NDArray[complexfloating]
|
|
||||||
"""Complex vector field with shape (3, X, Y, Z) (e.g. `[E_x, E_y, E_z]`)"""
|
"""Complex vector field with shape (3, X, Y, Z) (e.g. `[E_x, E_y, E_z]`)"""
|
||||||
|
|
||||||
vcfdfield_t = NewType('vcfdfield_t', NDArray[complexfloating])
|
vcfdfield_t = NDArray[numpy.complex_]
|
||||||
type vcfdfield = vcfdfield_t | NDArray[complexfloating]
|
|
||||||
"""Linearized complex vector field (single vector of length 3*X*Y*Z)"""
|
"""Linearized complex vector field (single vector of length 3*X*Y*Z)"""
|
||||||
|
|
||||||
|
|
||||||
fdslice_t = NewType('fdslice_t', NDArray[floating])
|
dx_lists_t = Sequence[Sequence[NDArray[numpy.float_]]]
|
||||||
type fdslice = fdslice_t | NDArray[floating]
|
|
||||||
"""Vector field slice with shape (3, X, Y) (e.g. `[E_x, E_y, E_z]` at a single Z position)"""
|
|
||||||
|
|
||||||
vfdslice_t = NewType('vfdslice_t', NDArray[floating])
|
|
||||||
type vfdslice = vfdslice_t | NDArray[floating]
|
|
||||||
"""Linearized vector field slice (single vector of length 3*X*Y)"""
|
|
||||||
|
|
||||||
cfdslice_t = NewType('cfdslice_t', NDArray[complexfloating])
|
|
||||||
type cfdslice = cfdslice_t | NDArray[complexfloating]
|
|
||||||
"""Complex vector field slice with shape (3, X, Y) (e.g. `[E_x, E_y, E_z]` at a single Z position)"""
|
|
||||||
|
|
||||||
vcfdslice_t = NewType('vcfdslice_t', NDArray[complexfloating])
|
|
||||||
type vcfdslice = vcfdslice_t | NDArray[complexfloating]
|
|
||||||
"""Linearized complex vector field slice (single vector of length 3*X*Y)"""
|
|
||||||
|
|
||||||
|
|
||||||
fdfield2_t = NewType('fdfield2_t', NDArray[floating])
|
|
||||||
type fdfield2 = fdfield2_t | NDArray[floating]
|
|
||||||
"""2D Vector field with shape (2, X, Y) (e.g. `[E_x, E_y]`)"""
|
|
||||||
|
|
||||||
vfdfield2_t = NewType('vfdfield2_t', NDArray[floating])
|
|
||||||
type vfdfield2 = vfdfield2_t | NDArray[floating]
|
|
||||||
"""2D Linearized vector field (single vector of length 2*X*Y)"""
|
|
||||||
|
|
||||||
cfdfield2_t = NewType('cfdfield2_t', NDArray[complexfloating])
|
|
||||||
type cfdfield2 = cfdfield2_t | NDArray[complexfloating]
|
|
||||||
"""2D Complex vector field with shape (2, X, Y) (e.g. `[E_x, E_y]`)"""
|
|
||||||
|
|
||||||
vcfdfield2_t = NewType('vcfdfield2_t', NDArray[complexfloating])
|
|
||||||
type vcfdfield2 = vcfdfield2_t | NDArray[complexfloating]
|
|
||||||
"""2D Linearized complex vector field (single vector of length 2*X*Y)"""
|
|
||||||
|
|
||||||
|
|
||||||
dx_lists_t = Sequence[Sequence[NDArray[floating | complexfloating]]]
|
|
||||||
"""
|
"""
|
||||||
'dxes' datastructure which contains grid cell width information in the following format:
|
'dxes' datastructure which contains grid cell width information in the following format:
|
||||||
|
|
||||||
|
|
@ -70,26 +31,12 @@ dx_lists_t = Sequence[Sequence[NDArray[floating | complexfloating]]]
|
||||||
and `dy_h[0]` is the y-width of the `y=0` cells, as used when calculating dH/dy, etc.
|
and `dy_h[0]` is the y-width of the `y=0` cells, as used when calculating dH/dy, etc.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
dx_lists2_t = Sequence[Sequence[NDArray[floating | complexfloating]]]
|
dx_lists_mut = MutableSequence[MutableSequence[NDArray[numpy.float_]]]
|
||||||
"""
|
|
||||||
2D 'dxes' datastructure which contains grid cell width information in the following format:
|
|
||||||
|
|
||||||
[[[dx_e[0], dx_e[1], ...], [dy_e[0], ...]],
|
|
||||||
[[dx_h[0], dx_h[1], ...], [dy_h[0], ...]]]
|
|
||||||
|
|
||||||
where `dx_e[0]` is the x-width of the `x=0` cells, as used when calculating dE/dx,
|
|
||||||
and `dy_h[0]` is the y-width of the `y=0` cells, as used when calculating dH/dy, etc.
|
|
||||||
"""
|
|
||||||
|
|
||||||
dx_lists_mut = MutableSequence[MutableSequence[NDArray[floating | complexfloating]]]
|
|
||||||
"""Mutable version of `dx_lists_t`"""
|
"""Mutable version of `dx_lists_t`"""
|
||||||
|
|
||||||
dx_lists2_mut = MutableSequence[MutableSequence[NDArray[floating | complexfloating]]]
|
|
||||||
"""Mutable version of `dx_lists2_t`"""
|
|
||||||
|
|
||||||
|
fdfield_updater_t = Callable[..., fdfield_t]
|
||||||
|
"""Convenience type for functions which take and return an fdfield_t"""
|
||||||
|
|
||||||
fdfield_updater_t = Callable[..., fdfield]
|
cfdfield_updater_t = Callable[..., cfdfield_t]
|
||||||
"""Convenience type for functions which take and return a real `fdfield`"""
|
"""Convenience type for functions which take and return an cfdfield_t"""
|
||||||
|
|
||||||
cfdfield_updater_t = Callable[..., cfdfield]
|
|
||||||
"""Convenience type for functions which take and return a complex `cfdfield`"""
|
|
||||||
|
|
|
||||||
|
|
@ -4,122 +4,75 @@ and a 1D array representation of that field `[f_x0, f_x1, f_x2,... f_y0,... f_z0
|
||||||
Vectorized versions of the field use row-major (ie., C-style) ordering.
|
Vectorized versions of the field use row-major (ie., C-style) ordering.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import overload
|
from typing import overload, Sequence
|
||||||
from collections.abc import Sequence
|
|
||||||
import numpy
|
import numpy
|
||||||
from numpy.typing import ArrayLike, NDArray
|
from numpy.typing import ArrayLike
|
||||||
|
|
||||||
from .types import (
|
from .types import fdfield_t, vfdfield_t, cfdfield_t, vcfdfield_t
|
||||||
fdfield_t, vfdfield_t, cfdfield_t, vcfdfield_t,
|
|
||||||
fdslice_t, vfdslice_t, cfdslice_t, vcfdslice_t,
|
|
||||||
fdfield2_t, vfdfield2_t, cfdfield2_t, vcfdfield2_t,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
def vec(f: None) -> None:
|
def vec(f: None) -> None:
|
||||||
pass # pragma: no cover
|
pass
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
def vec(f: fdfield_t) -> vfdfield_t:
|
def vec(f: fdfield_t) -> vfdfield_t:
|
||||||
pass # pragma: no cover
|
pass
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
def vec(f: cfdfield_t) -> vcfdfield_t:
|
def vec(f: cfdfield_t) -> vcfdfield_t:
|
||||||
pass # pragma: no cover
|
pass
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
def vec(f: fdfield2_t) -> vfdfield2_t:
|
def vec(f: ArrayLike) -> vfdfield_t | vcfdfield_t:
|
||||||
pass # pragma: no cover
|
pass
|
||||||
|
|
||||||
@overload
|
def vec(f: fdfield_t | cfdfield_t | ArrayLike | None) -> vfdfield_t | vcfdfield_t | None:
|
||||||
def vec(f: cfdfield2_t) -> vcfdfield2_t:
|
|
||||||
pass # pragma: no cover
|
|
||||||
|
|
||||||
@overload
|
|
||||||
def vec(f: fdslice_t) -> vfdslice_t:
|
|
||||||
pass # pragma: no cover
|
|
||||||
|
|
||||||
@overload
|
|
||||||
def vec(f: cfdslice_t) -> vcfdslice_t:
|
|
||||||
pass # pragma: no cover
|
|
||||||
|
|
||||||
@overload
|
|
||||||
def vec(f: ArrayLike) -> NDArray:
|
|
||||||
pass # pragma: no cover
|
|
||||||
|
|
||||||
def vec(
|
|
||||||
f: fdfield_t | cfdfield_t | fdfield2_t | cfdfield2_t | fdslice_t | cfdslice_t | ArrayLike | None,
|
|
||||||
) -> vfdfield_t | vcfdfield_t | vfdfield2_t | vcfdfield2_t | vfdslice_t | vcfdslice_t | NDArray | None:
|
|
||||||
"""
|
"""
|
||||||
Create a 1D ndarray from a vector field which spans a 1-3D region.
|
Create a 1D ndarray from a 3D vector field which spans a 1-3D region.
|
||||||
|
|
||||||
Returns `None` if called with `f=None`.
|
Returns `None` if called with `f=None`.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
f: A vector field, e.g. `[f_x, f_y, f_z]` where each `f_` component is a 1- to
|
f: A vector field, `[f_x, f_y, f_z]` where each `f_` component is a 1- to
|
||||||
3-D ndarray (`f_*` should all be the same size). Doesn't fail with `f=None`.
|
3-D ndarray (`f_*` should all be the same size). Doesn't fail with `f=None`.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
1D ndarray containing the linearized field (or `None`)
|
1D ndarray containing the linearized field (or `None`)
|
||||||
"""
|
"""
|
||||||
if f is None:
|
if f is None:
|
||||||
return None
|
return None
|
||||||
return numpy.ravel(f, order='C') # type: ignore
|
return numpy.ravel(f, order='C')
|
||||||
|
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
def unvec(v: None, shape: Sequence[int], nvdim: int = 3) -> None:
|
def unvec(v: None, shape: Sequence[int]) -> None:
|
||||||
pass # pragma: no cover
|
|
||||||
|
|
||||||
@overload
|
|
||||||
def unvec(v: vfdfield_t, shape: Sequence[int], nvdim: int = 3) -> fdfield_t:
|
|
||||||
pass # pragma: no cover
|
|
||||||
|
|
||||||
@overload
|
|
||||||
def unvec(v: vcfdfield_t, shape: Sequence[int], nvdim: int = 3) -> cfdfield_t:
|
|
||||||
pass # pragma: no cover
|
|
||||||
|
|
||||||
@overload
|
|
||||||
def unvec(v: vfdfield2_t, shape: Sequence[int], nvdim: int = 3) -> fdfield2_t:
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
def unvec(v: vcfdfield2_t, shape: Sequence[int], nvdim: int = 3) -> cfdfield2_t:
|
def unvec(v: vfdfield_t, shape: Sequence[int]) -> fdfield_t:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
def unvec(v: vfdslice_t, shape: Sequence[int], nvdim: int = 3) -> fdslice_t:
|
def unvec(v: vcfdfield_t, shape: Sequence[int]) -> cfdfield_t:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@overload
|
def unvec(v: vfdfield_t | vcfdfield_t | None, shape: Sequence[int]) -> fdfield_t | cfdfield_t | None:
|
||||||
def unvec(v: vcfdslice_t, shape: Sequence[int], nvdim: int = 3) -> cfdslice_t:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@overload
|
|
||||||
def unvec(v: ArrayLike, shape: Sequence[int], nvdim: int = 3) -> NDArray:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def unvec(
|
|
||||||
v: vfdfield_t | vcfdfield_t | vfdfield2_t | vcfdfield2_t | vfdslice_t | vcfdslice_t | ArrayLike | None,
|
|
||||||
shape: Sequence[int],
|
|
||||||
nvdim: int = 3,
|
|
||||||
) -> fdfield_t | cfdfield_t | fdfield2_t | cfdfield2_t | fdslice_t | cfdslice_t | NDArray | None:
|
|
||||||
"""
|
"""
|
||||||
Perform the inverse of vec(): take a 1D ndarray and output an `nvdim`-component field
|
Perform the inverse of vec(): take a 1D ndarray and output a 3D field
|
||||||
of form e.g. `[f_x, f_y, f_z]` (`nvdim=3`) where each of `f_*` is a len(shape)-dimensional
|
of form `[f_x, f_y, f_z]` where each of `f_*` is a len(shape)-dimensional
|
||||||
ndarray.
|
ndarray.
|
||||||
|
|
||||||
Returns `None` if called with `v=None`.
|
Returns `None` if called with `v=None`.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
v: 1D ndarray representing a vector field of shape shape (or None)
|
v: 1D ndarray representing a 3D vector field of shape shape (or None)
|
||||||
shape: shape of the vector field
|
shape: shape of the vector field
|
||||||
nvdim: Number of components in each vector
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
`[f_x, f_y, f_z]` where each `f_` is a `len(shape)` dimensional ndarray (or `None`)
|
`[f_x, f_y, f_z]` where each `f_` is a `len(shape)` dimensional ndarray (or `None`)
|
||||||
"""
|
"""
|
||||||
if v is None:
|
if v is None:
|
||||||
return None
|
return None
|
||||||
return v.reshape((nvdim, *shape), order='C') # type: ignore
|
return v.reshape((3, *shape), order='C')
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
r"""
|
"""
|
||||||
Utilities for running finite-difference time-domain (FDTD) simulations
|
Utilities for running finite-difference time-domain (FDTD) simulations
|
||||||
|
|
||||||
See the discussion of `Maxwell's Equations` in `meanas.fdmath` for basic
|
See the discussion of `Maxwell's Equations` in `meanas.fdmath` for basic
|
||||||
|
|
@ -11,9 +11,9 @@ Timestep
|
||||||
From the discussion of "Plane waves and the Dispersion relation" in `meanas.fdmath`,
|
From the discussion of "Plane waves and the Dispersion relation" in `meanas.fdmath`,
|
||||||
we have
|
we have
|
||||||
|
|
||||||
$$ c^2 \Delta_t^2 = \frac{\Delta_t^2}{\mu \epsilon} < 1/(\frac{1}{\Delta_x^2} + \frac{1}{\Delta_y^2} + \frac{1}{\Delta_z^2}) $$
|
$$ c^2 \\Delta_t^2 = \\frac{\\Delta_t^2}{\\mu \\epsilon} < 1/(\\frac{1}{\\Delta_x^2} + \\frac{1}{\\Delta_y^2} + \\frac{1}{\\Delta_z^2}) $$
|
||||||
|
|
||||||
or, if $\Delta_x = \Delta_y = \Delta_z$, then $c \Delta_t < \frac{\Delta_x}{\sqrt{3}}$.
|
or, if $\\Delta_x = \\Delta_y = \\Delta_z$, then $c \\Delta_t < \\frac{\\Delta_x}{\\sqrt{3}}$.
|
||||||
|
|
||||||
Based on this, we can set
|
Based on this, we can set
|
||||||
|
|
||||||
|
|
@ -27,81 +27,81 @@ Poynting Vector and Energy Conservation
|
||||||
|
|
||||||
Let
|
Let
|
||||||
|
|
||||||
$$ \begin{aligned}
|
$$ \\begin{aligned}
|
||||||
\tilde{S}_{l, l', \vec{r}} &=& &\tilde{E}_{l, \vec{r}} \otimes \hat{H}_{l', \vec{r} + \frac{1}{2}} \\
|
\\tilde{S}_{l, l', \\vec{r}} &=& &\\tilde{E}_{l, \\vec{r}} \\otimes \\hat{H}_{l', \\vec{r} + \\frac{1}{2}} \\\\
|
||||||
&=& &\vec{x} (\tilde{E}^y_{l,m+1,n,p} \hat{H}^z_{l',\vec{r} + \frac{1}{2}} - \tilde{E}^z_{l,m+1,n,p} \hat{H}^y_{l', \vec{r} + \frac{1}{2}}) \\
|
&=& &\\vec{x} (\\tilde{E}^y_{l,m+1,n,p} \\hat{H}^z_{l',\\vec{r} + \\frac{1}{2}} - \\tilde{E}^z_{l,m+1,n,p} \\hat{H}^y_{l', \\vec{r} + \\frac{1}{2}}) \\\\
|
||||||
& &+ &\vec{y} (\tilde{E}^z_{l,m,n+1,p} \hat{H}^x_{l',\vec{r} + \frac{1}{2}} - \tilde{E}^x_{l,m,n+1,p} \hat{H}^z_{l', \vec{r} + \frac{1}{2}}) \\
|
& &+ &\\vec{y} (\\tilde{E}^z_{l,m,n+1,p} \\hat{H}^x_{l',\\vec{r} + \\frac{1}{2}} - \\tilde{E}^x_{l,m,n+1,p} \\hat{H}^z_{l', \\vec{r} + \\frac{1}{2}}) \\\\
|
||||||
& &+ &\vec{z} (\tilde{E}^x_{l,m,n,p+1} \hat{H}^y_{l',\vec{r} + \frac{1}{2}} - \tilde{E}^y_{l,m,n,p+1} \hat{H}^z_{l', \vec{r} + \frac{1}{2}})
|
& &+ &\\vec{z} (\\tilde{E}^x_{l,m,n,p+1} \\hat{H}^y_{l',\\vec{r} + \\frac{1}{2}} - \\tilde{E}^y_{l,m,n,p+1} \\hat{H}^z_{l', \\vec{r} + \\frac{1}{2}})
|
||||||
\end{aligned}
|
\\end{aligned}
|
||||||
$$
|
$$
|
||||||
|
|
||||||
where $\vec{r} = (m, n, p)$ and $\otimes$ is a modified cross product
|
where $\\vec{r} = (m, n, p)$ and $\\otimes$ is a modified cross product
|
||||||
in which the $\tilde{E}$ terms are shifted as indicated.
|
in which the $\\tilde{E}$ terms are shifted as indicated.
|
||||||
|
|
||||||
By taking the divergence and rearranging terms, we can show that
|
By taking the divergence and rearranging terms, we can show that
|
||||||
|
|
||||||
$$
|
$$
|
||||||
\begin{aligned}
|
\\begin{aligned}
|
||||||
\hat{\nabla} \cdot \tilde{S}_{l, l', \vec{r}}
|
\\hat{\\nabla} \\cdot \\tilde{S}_{l, l', \\vec{r}}
|
||||||
&= \hat{\nabla} \cdot (\tilde{E}_{l, \vec{r}} \otimes \hat{H}_{l', \vec{r} + \frac{1}{2}}) \\
|
&= \\hat{\\nabla} \\cdot (\\tilde{E}_{l, \\vec{r}} \\otimes \\hat{H}_{l', \\vec{r} + \\frac{1}{2}}) \\\\
|
||||||
&= \hat{H}_{l', \vec{r} + \frac{1}{2}} \cdot \tilde{\nabla} \times \tilde{E}_{l, \vec{r}} -
|
&= \\hat{H}_{l', \\vec{r} + \\frac{1}{2}} \\cdot \\tilde{\\nabla} \\times \\tilde{E}_{l, \\vec{r}} -
|
||||||
\tilde{E}_{l, \vec{r}} \cdot \hat{\nabla} \times \hat{H}_{l', \vec{r} + \frac{1}{2}} \\
|
\\tilde{E}_{l, \\vec{r}} \\cdot \\hat{\\nabla} \\times \\hat{H}_{l', \\vec{r} + \\frac{1}{2}} \\\\
|
||||||
&= \hat{H}_{l', \vec{r} + \frac{1}{2}} \cdot
|
&= \\hat{H}_{l', \\vec{r} + \\frac{1}{2}} \\cdot
|
||||||
(-\tilde{\partial}_t \mu_{\vec{r} + \frac{1}{2}} \hat{H}_{l - \frac{1}{2}, \vec{r} + \frac{1}{2}} -
|
(-\\tilde{\\partial}_t \\mu_{\\vec{r} + \\frac{1}{2}} \\hat{H}_{l - \\frac{1}{2}, \\vec{r} + \\frac{1}{2}} -
|
||||||
\hat{M}_{l, \vec{r} + \frac{1}{2}}) -
|
\\hat{M}_{l, \\vec{r} + \\frac{1}{2}}) -
|
||||||
\tilde{E}_{l, \vec{r}} \cdot (\hat{\partial}_t \tilde{\epsilon}_{\vec{r}} \tilde{E}_{l'+\frac{1}{2}, \vec{r}} +
|
\\tilde{E}_{l, \\vec{r}} \\cdot (\\hat{\\partial}_t \\tilde{\\epsilon}_{\\vec{r}} \\tilde{E}_{l'+\\frac{1}{2}, \\vec{r}} +
|
||||||
\tilde{J}_{l', \vec{r}}) \\
|
\\tilde{J}_{l', \\vec{r}}) \\\\
|
||||||
&= \hat{H}_{l'} \cdot (-\mu / \Delta_t)(\hat{H}_{l + \frac{1}{2}} - \hat{H}_{l - \frac{1}{2}}) -
|
&= \\hat{H}_{l'} \\cdot (-\\mu / \\Delta_t)(\\hat{H}_{l + \\frac{1}{2}} - \\hat{H}_{l - \\frac{1}{2}}) -
|
||||||
\tilde{E}_l \cdot (\epsilon / \Delta_t )(\tilde{E}_{l'+\frac{1}{2}} - \tilde{E}_{l'-\frac{1}{2}})
|
\\tilde{E}_l \\cdot (\\epsilon / \\Delta_t )(\\tilde{E}_{l'+\\frac{1}{2}} - \\tilde{E}_{l'-\\frac{1}{2}})
|
||||||
- \hat{H}_{l'} \cdot \hat{M}_{l} - \tilde{E}_l \cdot \tilde{J}_{l'} \\
|
- \\hat{H}_{l'} \\cdot \\hat{M}_{l} - \\tilde{E}_l \\cdot \\tilde{J}_{l'} \\\\
|
||||||
\end{aligned}
|
\\end{aligned}
|
||||||
$$
|
$$
|
||||||
|
|
||||||
where in the last line the spatial subscripts have been dropped to emphasize
|
where in the last line the spatial subscripts have been dropped to emphasize
|
||||||
the time subscripts $l, l'$, i.e.
|
the time subscripts $l, l'$, i.e.
|
||||||
|
|
||||||
$$
|
$$
|
||||||
\begin{aligned}
|
\\begin{aligned}
|
||||||
\tilde{E}_l &= \tilde{E}_{l, \vec{r}} \\
|
\\tilde{E}_l &= \\tilde{E}_{l, \\vec{r}} \\\\
|
||||||
\hat{H}_l &= \tilde{H}_{l, \vec{r} + \frac{1}{2}} \\
|
\\hat{H}_l &= \\tilde{H}_{l, \\vec{r} + \\frac{1}{2}} \\\\
|
||||||
\tilde{\epsilon} &= \tilde{\epsilon}_{\vec{r}} \\
|
\\tilde{\\epsilon} &= \\tilde{\\epsilon}_{\\vec{r}} \\\\
|
||||||
\end{aligned}
|
\\end{aligned}
|
||||||
$$
|
$$
|
||||||
|
|
||||||
etc.
|
etc.
|
||||||
For $l' = l + \frac{1}{2}$ we get
|
For $l' = l + \\frac{1}{2}$ we get
|
||||||
|
|
||||||
$$
|
$$
|
||||||
\begin{aligned}
|
\\begin{aligned}
|
||||||
\hat{\nabla} \cdot \tilde{S}_{l, l + \frac{1}{2}}
|
\\hat{\\nabla} \\cdot \\tilde{S}_{l, l + \\frac{1}{2}}
|
||||||
&= \hat{H}_{l + \frac{1}{2}} \cdot
|
&= \\hat{H}_{l + \\frac{1}{2}} \\cdot
|
||||||
(-\mu / \Delta_t)(\hat{H}_{l + \frac{1}{2}} - \hat{H}_{l - \frac{1}{2}}) -
|
(-\\mu / \\Delta_t)(\\hat{H}_{l + \\frac{1}{2}} - \\hat{H}_{l - \\frac{1}{2}}) -
|
||||||
\tilde{E}_l \cdot (\epsilon / \Delta_t)(\tilde{E}_{l+1} - \tilde{E}_l)
|
\\tilde{E}_l \\cdot (\\epsilon / \\Delta_t)(\\tilde{E}_{l+1} - \\tilde{E}_l)
|
||||||
- \hat{H}_{l'} \cdot \hat{M}_l - \tilde{E}_l \cdot \tilde{J}_{l + \frac{1}{2}} \\
|
- \\hat{H}_{l'} \\cdot \\hat{M}_l - \\tilde{E}_l \\cdot \\tilde{J}_{l + \\frac{1}{2}} \\\\
|
||||||
&= (-\mu / \Delta_t)(\hat{H}^2_{l + \frac{1}{2}} - \hat{H}_{l + \frac{1}{2}} \cdot \hat{H}_{l - \frac{1}{2}}) -
|
&= (-\\mu / \\Delta_t)(\\hat{H}^2_{l + \\frac{1}{2}} - \\hat{H}_{l + \\frac{1}{2}} \\cdot \\hat{H}_{l - \\frac{1}{2}}) -
|
||||||
(\epsilon / \Delta_t)(\tilde{E}_{l+1} \cdot \tilde{E}_l - \tilde{E}^2_l)
|
(\\epsilon / \\Delta_t)(\\tilde{E}_{l+1} \\cdot \\tilde{E}_l - \\tilde{E}^2_l)
|
||||||
- \hat{H}_{l'} \cdot \hat{M}_l - \tilde{E}_l \cdot \tilde{J}_{l + \frac{1}{2}} \\
|
- \\hat{H}_{l'} \\cdot \\hat{M}_l - \\tilde{E}_l \\cdot \\tilde{J}_{l + \\frac{1}{2}} \\\\
|
||||||
&= -(\mu \hat{H}^2_{l + \frac{1}{2}}
|
&= -(\\mu \\hat{H}^2_{l + \\frac{1}{2}}
|
||||||
+\epsilon \tilde{E}_{l+1} \cdot \tilde{E}_l) / \Delta_t \\
|
+\\epsilon \\tilde{E}_{l+1} \\cdot \\tilde{E}_l) / \\Delta_t \\ \\
|
||||||
+(\mu \hat{H}_{l + \frac{1}{2}} \cdot \hat{H}_{l - \frac{1}{2}}
|
+(\\mu \\hat{H}_{l + \\frac{1}{2}} \\cdot \\hat{H}_{l - \\frac{1}{2}}
|
||||||
+\epsilon \tilde{E}^2_l) / \Delta_t \\
|
+\\epsilon \\tilde{E}^2_l) / \\Delta_t \\ \\
|
||||||
- \hat{H}_{l+\frac{1}{2}} \cdot \hat{M}_l \\
|
- \\hat{H}_{l+\\frac{1}{2}} \\cdot \\hat{M}_l \\ \\
|
||||||
- \tilde{E}_l \cdot \tilde{J}_{l+\frac{1}{2}} \\
|
- \\tilde{E}_l \\cdot \\tilde{J}_{l+\\frac{1}{2}} \\\\
|
||||||
\end{aligned}
|
\\end{aligned}
|
||||||
$$
|
$$
|
||||||
|
|
||||||
and for $l' = l - \frac{1}{2}$,
|
and for $l' = l - \\frac{1}{2}$,
|
||||||
|
|
||||||
$$
|
$$
|
||||||
\begin{aligned}
|
\\begin{aligned}
|
||||||
\hat{\nabla} \cdot \tilde{S}_{l, l - \frac{1}{2}}
|
\\hat{\\nabla} \\cdot \\tilde{S}_{l, l - \\frac{1}{2}}
|
||||||
&= (\mu \hat{H}^2_{l - \frac{1}{2}}
|
&= (\\mu \\hat{H}^2_{l - \\frac{1}{2}}
|
||||||
+\epsilon \tilde{E}_{l-1} \cdot \tilde{E}_l) / \Delta_t \\
|
+\\epsilon \\tilde{E}_{l-1} \\cdot \\tilde{E}_l) / \\Delta_t \\ \\
|
||||||
-(\mu \hat{H}_{l + \frac{1}{2}} \cdot \hat{H}_{l - \frac{1}{2}}
|
-(\\mu \\hat{H}_{l + \\frac{1}{2}} \\cdot \\hat{H}_{l - \\frac{1}{2}}
|
||||||
+\epsilon \tilde{E}^2_l) / \Delta_t \\
|
+\\epsilon \\tilde{E}^2_l) / \\Delta_t \\ \\
|
||||||
- \hat{H}_{l-\frac{1}{2}} \cdot \hat{M}_l \\
|
- \\hat{H}_{l-\\frac{1}{2}} \\cdot \\hat{M}_l \\ \\
|
||||||
- \tilde{E}_l \cdot \tilde{J}_{l-\frac{1}{2}} \\
|
- \\tilde{E}_l \\cdot \\tilde{J}_{l-\\frac{1}{2}} \\\\
|
||||||
\end{aligned}
|
\\end{aligned}
|
||||||
$$
|
$$
|
||||||
|
|
||||||
These two results form the discrete time-domain analogue to Poynting's theorem.
|
These two results form the discrete time-domain analogue to Poynting's theorem.
|
||||||
|
|
@ -109,25 +109,25 @@ They hint at the expressions for the energy, which can be calculated at the same
|
||||||
time-index as either the E or H field:
|
time-index as either the E or H field:
|
||||||
|
|
||||||
$$
|
$$
|
||||||
\begin{aligned}
|
\\begin{aligned}
|
||||||
U_l &= \epsilon \tilde{E}^2_l + \mu \hat{H}_{l + \frac{1}{2}} \cdot \hat{H}_{l - \frac{1}{2}} \\
|
U_l &= \\epsilon \\tilde{E}^2_l + \\mu \\hat{H}_{l + \\frac{1}{2}} \\cdot \\hat{H}_{l - \\frac{1}{2}} \\\\
|
||||||
U_{l + \frac{1}{2}} &= \epsilon \tilde{E}_l \cdot \tilde{E}_{l + 1} + \mu \hat{H}^2_{l + \frac{1}{2}} \\
|
U_{l + \\frac{1}{2}} &= \\epsilon \\tilde{E}_l \\cdot \\tilde{E}_{l + 1} + \\mu \\hat{H}^2_{l + \\frac{1}{2}} \\\\
|
||||||
\end{aligned}
|
\\end{aligned}
|
||||||
$$
|
$$
|
||||||
|
|
||||||
Rewriting the Poynting theorem in terms of the energy expressions,
|
Rewriting the Poynting theorem in terms of the energy expressions,
|
||||||
|
|
||||||
$$
|
$$
|
||||||
\begin{aligned}
|
\\begin{aligned}
|
||||||
(U_{l+\frac{1}{2}} - U_l) / \Delta_t
|
(U_{l+\\frac{1}{2}} - U_l) / \\Delta_t
|
||||||
&= -\hat{\nabla} \cdot \tilde{S}_{l, l + \frac{1}{2}} \\
|
&= -\\hat{\\nabla} \\cdot \\tilde{S}_{l, l + \\frac{1}{2}} \\ \\
|
||||||
- \hat{H}_{l+\frac{1}{2}} \cdot \hat{M}_l \\
|
- \\hat{H}_{l+\\frac{1}{2}} \\cdot \\hat{M}_l \\ \\
|
||||||
- \tilde{E}_l \cdot \tilde{J}_{l+\frac{1}{2}} \\
|
- \\tilde{E}_l \\cdot \\tilde{J}_{l+\\frac{1}{2}} \\\\
|
||||||
(U_l - U_{l-\frac{1}{2}}) / \Delta_t
|
(U_l - U_{l-\\frac{1}{2}}) / \\Delta_t
|
||||||
&= -\hat{\nabla} \cdot \tilde{S}_{l, l - \frac{1}{2}} \\
|
&= -\\hat{\\nabla} \\cdot \\tilde{S}_{l, l - \\frac{1}{2}} \\ \\
|
||||||
- \hat{H}_{l-\frac{1}{2}} \cdot \hat{M}_l \\
|
- \\hat{H}_{l-\\frac{1}{2}} \\cdot \\hat{M}_l \\ \\
|
||||||
- \tilde{E}_l \cdot \tilde{J}_{l-\frac{1}{2}} \\
|
- \\tilde{E}_l \\cdot \\tilde{J}_{l-\\frac{1}{2}} \\\\
|
||||||
\end{aligned}
|
\\end{aligned}
|
||||||
$$
|
$$
|
||||||
|
|
||||||
This result is exact and should practically hold to within numerical precision. No time-
|
This result is exact and should practically hold to within numerical precision. No time-
|
||||||
|
|
@ -144,131 +144,23 @@ It is often useful to excite the simulation with an arbitrary broadband pulse an
|
||||||
extract the frequency-domain response by performing an on-the-fly Fourier transform
|
extract the frequency-domain response by performing an on-the-fly Fourier transform
|
||||||
of the time-domain fields.
|
of the time-domain fields.
|
||||||
|
|
||||||
`accumulate_phasor` in `meanas.fdtd.phasor` performs the phase accumulation for one
|
|
||||||
or more target frequencies, while leaving source normalization and simulation-loop
|
|
||||||
policy to the caller. `temporal_phasor(...)` and `temporal_phasor_scale(...)`
|
|
||||||
apply the same Fourier sum to a scalar waveform, which is useful when a pulsed
|
|
||||||
source envelope must be normalized before being applied to a point source or
|
|
||||||
mode source. `real_injection_scale(...)` is the corresponding helper for the
|
|
||||||
common real-valued injection pattern `numpy.real(scale * waveform)`. Convenience
|
|
||||||
wrappers `accumulate_phasor_e`, `accumulate_phasor_h`, and `accumulate_phasor_j`
|
|
||||||
apply the usual Yee time offsets. `reconstruct_real(...)` and the corresponding
|
|
||||||
`reconstruct_real_e/h/j(...)` wrappers perform the inverse operation, converting
|
|
||||||
phasors back into real-valued field snapshots at explicit Yee-aligned times.
|
|
||||||
For scalar `omega`, the reconstruction helpers accept either a plain field
|
|
||||||
phasor or the batched `(1, *sample_shape)` form used by the accumulator helpers.
|
|
||||||
The helpers accumulate
|
|
||||||
|
|
||||||
$$ \Delta_t \sum_l w_l e^{-i \omega t_l} f_l $$
|
|
||||||
|
|
||||||
with caller-provided sample times and weights. In this codebase the matching
|
|
||||||
electric-current convention is typically `E -= dt * J / epsilon` in FDTD and
|
|
||||||
`-i \omega J` on the right-hand side of the FDFD wave equation.
|
|
||||||
|
|
||||||
For FDTD/FDFD equivalence, this phasor path is the primary comparison workflow.
|
|
||||||
It isolates the guided `+\omega` response that the frequency-domain solver
|
|
||||||
targets directly, regardless of whether the underlying FDTD run used real- or
|
|
||||||
complex-valued fields.
|
|
||||||
|
|
||||||
For exact pulsed FDTD/FDFD equivalence it is often simplest to keep the
|
|
||||||
injected source, fields, and CPML auxiliary state complex-valued. The
|
|
||||||
`real_injection_scale(...)` helper is instead for the more ordinary one-run
|
|
||||||
real-valued source path, where the intended positive-frequency waveform is
|
|
||||||
injected through `numpy.real(scale * waveform)` and any remaining negative-
|
|
||||||
frequency leakage is controlled by the pulse bandwidth and run length.
|
|
||||||
|
|
||||||
`reconstruct_real(...)` is for a different question: given a phasor, what late
|
|
||||||
real-valued field snapshot should it produce? That raw-snapshot comparison is
|
|
||||||
stricter and noisier because a monitor plane generally contains both the guided
|
|
||||||
field and the remaining orthogonal content,
|
|
||||||
|
|
||||||
$$ E_{\text{monitor}} = E_{\text{guided}} + E_{\text{residual}} . $$
|
|
||||||
|
|
||||||
Phasor/modal comparisons mostly validate the guided `+\omega` term. Raw
|
|
||||||
real-field comparisons expose both terms at once, so they should be treated as
|
|
||||||
secondary diagnostics rather than the main solver-equivalence benchmark.
|
|
||||||
|
|
||||||
The Ricker wavelet (normalized second derivative of a Gaussian) is commonly used for the pulse
|
The Ricker wavelet (normalized second derivative of a Gaussian) is commonly used for the pulse
|
||||||
shape. It can be written
|
shape. It can be written
|
||||||
|
|
||||||
$$ f_r(t) = (1 - \frac{1}{2} (\omega (t - \tau))^2) e^{-(\frac{\omega (t - \tau)}{2})^2} $$
|
$$ f_r(t) = (1 - \\frac{1}{2} (\\omega (t - \\tau))^2) e^{-(\\frac{\\omega (t - \\tau)}{2})^2} $$
|
||||||
|
|
||||||
with $\tau > \frac{2 * \pi}{\omega}$ as a minimum delay to avoid a discontinuity at
|
with $\\tau > \\frac{2 * \\pi}{\\omega}$ as a minimum delay to avoid a discontinuity at
|
||||||
t=0 (assuming the source is off for t<0 this gives $\sim 10^{-3}$ error at t=0).
|
t=0 (assuming the source is off for t<0 this gives $\\sim 10^{-3}$ error at t=0).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Boundary conditions
|
Boundary conditions
|
||||||
===================
|
===================
|
||||||
|
# TODO notes about boundaries / PMLs
|
||||||
`meanas.fdtd` exposes two boundary-related building blocks:
|
|
||||||
|
|
||||||
- `conducting_boundary(...)` for simple perfect-electric-conductor style field
|
|
||||||
clamping at one face of the domain.
|
|
||||||
- `cpml_params(...)` and `updates_with_cpml(...)` for convolutional perfectly
|
|
||||||
matched layers (CPMLs) attached to one or more faces of the Yee grid.
|
|
||||||
|
|
||||||
`updates_with_cpml(...)` accepts a three-by-two table of CPML parameter blocks:
|
|
||||||
|
|
||||||
```
|
|
||||||
cpml_params[axis][polarity_index]
|
|
||||||
```
|
|
||||||
|
|
||||||
where `axis` is `0`, `1`, or `2` and `polarity_index` corresponds to `(-1, +1)`.
|
|
||||||
Passing `None` for one entry disables CPML on that face while leaving the other
|
|
||||||
directions unchanged. This is how mixed boundary setups such as "absorbing in x,
|
|
||||||
periodic in y/z" are expressed.
|
|
||||||
|
|
||||||
When comparing an FDTD run against an FDFD solve, use the same stretched
|
|
||||||
coordinate system in both places:
|
|
||||||
|
|
||||||
1. Build the FDTD update with the desired CPML parameters.
|
|
||||||
2. Stretch the FDFD `dxes` with the matching SCPML transform.
|
|
||||||
3. Compare the extracted phasor against the FDFD field or residual on those
|
|
||||||
stretched `dxes`.
|
|
||||||
|
|
||||||
The electric-current sign convention used throughout the examples and tests is
|
|
||||||
|
|
||||||
$$
|
|
||||||
E \leftarrow E - \Delta_t J / \epsilon
|
|
||||||
$$
|
|
||||||
|
|
||||||
which matches the FDFD right-hand side
|
|
||||||
|
|
||||||
$$
|
|
||||||
-i \omega J.
|
|
||||||
$$
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .base import (
|
from .base import maxwell_e, maxwell_h
|
||||||
maxwell_e as maxwell_e,
|
from .pml import cpml_params, updates_with_cpml
|
||||||
maxwell_h as maxwell_h,
|
from .energy import (poynting, poynting_divergence, energy_hstep, energy_estep,
|
||||||
)
|
delta_energy_h2e, delta_energy_j)
|
||||||
from .pml import (
|
from .boundaries import conducting_boundary
|
||||||
cpml_params as cpml_params,
|
|
||||||
updates_with_cpml as updates_with_cpml,
|
|
||||||
)
|
|
||||||
from .energy import (
|
|
||||||
poynting as poynting,
|
|
||||||
poynting_divergence as poynting_divergence,
|
|
||||||
energy_hstep as energy_hstep,
|
|
||||||
energy_estep as energy_estep,
|
|
||||||
delta_energy_h2e as delta_energy_h2e,
|
|
||||||
delta_energy_j as delta_energy_j,
|
|
||||||
)
|
|
||||||
from .boundaries import (
|
|
||||||
conducting_boundary as conducting_boundary,
|
|
||||||
)
|
|
||||||
from .phasor import (
|
|
||||||
accumulate_phasor as accumulate_phasor,
|
|
||||||
accumulate_phasor_e as accumulate_phasor_e,
|
|
||||||
accumulate_phasor_h as accumulate_phasor_h,
|
|
||||||
accumulate_phasor_j as accumulate_phasor_j,
|
|
||||||
real_injection_scale as real_injection_scale,
|
|
||||||
reconstruct_real as reconstruct_real,
|
|
||||||
reconstruct_real_e as reconstruct_real_e,
|
|
||||||
reconstruct_real_h as reconstruct_real_h,
|
|
||||||
reconstruct_real_j as reconstruct_real_j,
|
|
||||||
temporal_phasor as temporal_phasor,
|
|
||||||
temporal_phasor_scale as temporal_phasor_scale,
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ Basic FDTD field updates
|
||||||
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from ..fdmath import dx_lists_t, fdfield, fdfield_updater_t
|
from ..fdmath import dx_lists_t, fdfield_t, fdfield_updater_t
|
||||||
from ..fdmath.functional import curl_forward, curl_back
|
from ..fdmath.functional import curl_forward, curl_back
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -47,7 +47,7 @@ def maxwell_e(
|
||||||
else:
|
else:
|
||||||
curl_h_fun = curl_back()
|
curl_h_fun = curl_back()
|
||||||
|
|
||||||
def me_fun(e: fdfield, h: fdfield, epsilon: fdfield | float) -> fdfield:
|
def me_fun(e: fdfield_t, h: fdfield_t, epsilon: fdfield_t | float) -> fdfield_t:
|
||||||
"""
|
"""
|
||||||
Update the E-field.
|
Update the E-field.
|
||||||
|
|
||||||
|
|
@ -103,7 +103,7 @@ def maxwell_h(
|
||||||
else:
|
else:
|
||||||
curl_e_fun = curl_forward()
|
curl_e_fun = curl_forward()
|
||||||
|
|
||||||
def mh_fun(e: fdfield, h: fdfield, mu: fdfield | float | None = None) -> fdfield:
|
def mh_fun(e: fdfield_t, h: fdfield_t, mu: fdfield_t | float | None = None) -> fdfield_t:
|
||||||
"""
|
"""
|
||||||
Update the H-field.
|
Update the H-field.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ Boundary conditions
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from ..fdmath import fdfield, fdfield_updater_t
|
from ..fdmath import fdfield_t, fdfield_updater_t
|
||||||
|
|
||||||
|
|
||||||
def conducting_boundary(
|
def conducting_boundary(
|
||||||
|
|
@ -15,32 +15,26 @@ def conducting_boundary(
|
||||||
) -> tuple[fdfield_updater_t, fdfield_updater_t]:
|
) -> tuple[fdfield_updater_t, fdfield_updater_t]:
|
||||||
dirs = [0, 1, 2]
|
dirs = [0, 1, 2]
|
||||||
if direction not in dirs:
|
if direction not in dirs:
|
||||||
raise ValueError(f'Invalid direction: {direction}')
|
raise Exception('Invalid direction: {}'.format(direction))
|
||||||
dirs.remove(direction)
|
dirs.remove(direction)
|
||||||
u, v = dirs
|
u, v = dirs
|
||||||
|
|
||||||
boundary_slice: list[Any]
|
|
||||||
shifted1_slice: list[Any]
|
|
||||||
shifted2_slice: list[Any]
|
|
||||||
|
|
||||||
if polarity < 0:
|
if polarity < 0:
|
||||||
boundary_slice = [slice(None)] * 3
|
boundary_slice = [slice(None)] * 3 # type: list[Any]
|
||||||
shifted1_slice = [slice(None)] * 3
|
shifted1_slice = [slice(None)] * 3 # type: list[Any]
|
||||||
boundary_slice[direction] = 0
|
boundary_slice[direction] = 0
|
||||||
shifted1_slice[direction] = 1
|
shifted1_slice[direction] = 1
|
||||||
boundary = tuple(boundary_slice)
|
|
||||||
shifted1 = tuple(shifted1_slice)
|
|
||||||
|
|
||||||
def en(e: fdfield) -> fdfield:
|
def en(e: fdfield_t) -> fdfield_t:
|
||||||
e[direction][boundary] = 0
|
e[direction][boundary_slice] = 0
|
||||||
e[u][boundary] = e[u][shifted1]
|
e[u][boundary_slice] = e[u][shifted1_slice]
|
||||||
e[v][boundary] = e[v][shifted1]
|
e[v][boundary_slice] = e[v][shifted1_slice]
|
||||||
return e
|
return e
|
||||||
|
|
||||||
def hn(h: fdfield) -> fdfield:
|
def hn(h: fdfield_t) -> fdfield_t:
|
||||||
h[direction][boundary] = h[direction][shifted1]
|
h[direction][boundary_slice] = h[direction][shifted1_slice]
|
||||||
h[u][boundary] = 0
|
h[u][boundary_slice] = 0
|
||||||
h[v][boundary] = 0
|
h[v][boundary_slice] = 0
|
||||||
return h
|
return h
|
||||||
|
|
||||||
return en, hn
|
return en, hn
|
||||||
|
|
@ -48,29 +42,26 @@ def conducting_boundary(
|
||||||
if polarity > 0:
|
if polarity > 0:
|
||||||
boundary_slice = [slice(None)] * 3
|
boundary_slice = [slice(None)] * 3
|
||||||
shifted1_slice = [slice(None)] * 3
|
shifted1_slice = [slice(None)] * 3
|
||||||
shifted2_slice = [slice(None)] * 3
|
shifted2_slice = [slice(None)] * 3 # type: list[Any]
|
||||||
boundary_slice[direction] = -1
|
boundary_slice[direction] = -1
|
||||||
shifted1_slice[direction] = -2
|
shifted1_slice[direction] = -2
|
||||||
shifted2_slice[direction] = -3
|
shifted2_slice[direction] = -3
|
||||||
boundary = tuple(boundary_slice)
|
|
||||||
shifted1 = tuple(shifted1_slice)
|
|
||||||
shifted2 = tuple(shifted2_slice)
|
|
||||||
|
|
||||||
def ep(e: fdfield) -> fdfield:
|
def ep(e: fdfield_t) -> fdfield_t:
|
||||||
e[direction][boundary] = -e[direction][shifted2]
|
e[direction][boundary_slice] = -e[direction][shifted2_slice]
|
||||||
e[direction][shifted1] = 0
|
e[direction][shifted1_slice] = 0
|
||||||
e[u][boundary] = e[u][shifted1]
|
e[u][boundary_slice] = e[u][shifted1_slice]
|
||||||
e[v][boundary] = e[v][shifted1]
|
e[v][boundary_slice] = e[v][shifted1_slice]
|
||||||
return e
|
return e
|
||||||
|
|
||||||
def hp(h: fdfield) -> fdfield:
|
def hp(h: fdfield_t) -> fdfield_t:
|
||||||
h[direction][boundary] = h[direction][shifted1]
|
h[direction][boundary_slice] = h[direction][shifted1_slice]
|
||||||
h[u][boundary] = -h[u][shifted2]
|
h[u][boundary_slice] = -h[u][shifted2_slice]
|
||||||
h[u][shifted1] = 0
|
h[u][shifted1_slice] = 0
|
||||||
h[v][boundary] = -h[v][shifted2]
|
h[v][boundary_slice] = -h[v][shifted2_slice]
|
||||||
h[v][shifted1] = 0
|
h[v][shifted1_slice] = 0
|
||||||
return h
|
return h
|
||||||
|
|
||||||
return ep, hp
|
return ep, hp
|
||||||
|
|
||||||
raise ValueError(f'Bad polarity: {polarity}')
|
raise Exception('Bad polarity: {}'.format(polarity))
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,18 @@
|
||||||
import numpy
|
import numpy
|
||||||
|
|
||||||
from ..fdmath import dx_lists_t, fdfield_t, fdfield
|
from ..fdmath import dx_lists_t, fdfield_t
|
||||||
from ..fdmath.functional import deriv_back
|
from ..fdmath.functional import deriv_back
|
||||||
|
|
||||||
|
|
||||||
"""
|
# TODO documentation
|
||||||
Energy- and flux-accounting helpers for Yee-grid FDTD fields.
|
|
||||||
|
|
||||||
These functions complement the derivation in `meanas.fdtd`:
|
|
||||||
|
|
||||||
- `poynting(...)` and `poynting_divergence(...)` evaluate the discrete flux terms
|
|
||||||
from the exact time-domain Poynting identity.
|
|
||||||
- `energy_hstep(...)` / `energy_estep(...)` evaluate the two staggered energy
|
|
||||||
expressions.
|
|
||||||
- `delta_energy_*` helpers evaluate the corresponding energy changes between
|
|
||||||
adjacent half-steps.
|
|
||||||
|
|
||||||
The helpers are intended for diagnostics, regression tests, and consistency
|
|
||||||
checks between source work, field energy, and flux through cell faces.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def poynting(
|
def poynting(
|
||||||
e: fdfield,
|
e: fdfield_t,
|
||||||
h: fdfield,
|
h: fdfield_t,
|
||||||
dxes: dx_lists_t | None = None,
|
dxes: dx_lists_t | None = None,
|
||||||
) -> fdfield_t:
|
) -> fdfield_t:
|
||||||
r"""
|
"""
|
||||||
Calculate the poynting vector `S` ($S$).
|
Calculate the poynting vector `S` ($S$).
|
||||||
|
|
||||||
This is the energy transfer rate (amount of energy `U` per `dt` transferred
|
This is the energy transfer rate (amount of energy `U` per `dt` transferred
|
||||||
|
|
@ -57,18 +43,17 @@ def poynting(
|
||||||
(see `meanas.tests.test_fdtd.test_poynting_planes`)
|
(see `meanas.tests.test_fdtd.test_poynting_planes`)
|
||||||
|
|
||||||
The full relationship is
|
The full relationship is
|
||||||
|
|
||||||
$$
|
$$
|
||||||
\begin{aligned}
|
\\begin{aligned}
|
||||||
(U_{l+\frac{1}{2}} - U_l) / \Delta_t
|
(U_{l+\\frac{1}{2}} - U_l) / \\Delta_t
|
||||||
&= -\hat{\nabla} \cdot \tilde{S}_{l, l + \frac{1}{2}} \\
|
&= -\\hat{\\nabla} \\cdot \\tilde{S}_{l, l + \\frac{1}{2}} \\ \\
|
||||||
- \hat{H}_{l+\frac{1}{2}} \cdot \hat{M}_l \\
|
- \\hat{H}_{l+\\frac{1}{2}} \\cdot \\hat{M}_l \\ \\
|
||||||
- \tilde{E}_l \cdot \tilde{J}_{l+\frac{1}{2}} \\
|
- \\tilde{E}_l \\cdot \\tilde{J}_{l+\\frac{1}{2}} \\\\
|
||||||
(U_l - U_{l-\frac{1}{2}}) / \Delta_t
|
(U_l - U_{l-\\frac{1}{2}}) / \\Delta_t
|
||||||
&= -\hat{\nabla} \cdot \tilde{S}_{l, l - \frac{1}{2}} \\
|
&= -\\hat{\\nabla} \\cdot \\tilde{S}_{l, l - \\frac{1}{2}} \\ \\
|
||||||
- \hat{H}_{l-\frac{1}{2}} \cdot \hat{M}_l \\
|
- \\hat{H}_{l-\\frac{1}{2}} \\cdot \\hat{M}_l \\ \\
|
||||||
- \tilde{E}_l \cdot \tilde{J}_{l-\frac{1}{2}} \\
|
- \\tilde{E}_l \\cdot \\tilde{J}_{l-\\frac{1}{2}} \\\\
|
||||||
\end{aligned}
|
\\end{aligned}
|
||||||
$$
|
$$
|
||||||
|
|
||||||
These equalities are exact and should practically hold to within numerical precision.
|
These equalities are exact and should practically hold to within numerical precision.
|
||||||
|
|
@ -99,14 +84,14 @@ def poynting(
|
||||||
s[0] = numpy.roll(ey, -1, axis=0) * hz - numpy.roll(ez, -1, axis=0) * hy
|
s[0] = numpy.roll(ey, -1, axis=0) * hz - numpy.roll(ez, -1, axis=0) * hy
|
||||||
s[1] = numpy.roll(ez, -1, axis=1) * hx - numpy.roll(ex, -1, axis=1) * hz
|
s[1] = numpy.roll(ez, -1, axis=1) * hx - numpy.roll(ex, -1, axis=1) * hz
|
||||||
s[2] = numpy.roll(ex, -1, axis=2) * hy - numpy.roll(ey, -1, axis=2) * hx
|
s[2] = numpy.roll(ex, -1, axis=2) * hy - numpy.roll(ey, -1, axis=2) * hx
|
||||||
return fdfield_t(s)
|
return s
|
||||||
|
|
||||||
|
|
||||||
def poynting_divergence(
|
def poynting_divergence(
|
||||||
s: fdfield | None = None,
|
s: fdfield_t | None = None,
|
||||||
*,
|
*,
|
||||||
e: fdfield | None = None,
|
e: fdfield_t | None = None,
|
||||||
h: fdfield | None = None,
|
h: fdfield_t | None = None,
|
||||||
dxes: dx_lists_t | None = None,
|
dxes: dx_lists_t | None = None,
|
||||||
) -> fdfield_t:
|
) -> fdfield_t:
|
||||||
"""
|
"""
|
||||||
|
|
@ -137,15 +122,15 @@ def poynting_divergence(
|
||||||
|
|
||||||
Dx, Dy, Dz = deriv_back()
|
Dx, Dy, Dz = deriv_back()
|
||||||
ds = Dx(s[0]) + Dy(s[1]) + Dz(s[2])
|
ds = Dx(s[0]) + Dy(s[1]) + Dz(s[2])
|
||||||
return fdfield_t(ds)
|
return ds
|
||||||
|
|
||||||
|
|
||||||
def energy_hstep(
|
def energy_hstep(
|
||||||
e0: fdfield,
|
e0: fdfield_t,
|
||||||
h1: fdfield,
|
h1: fdfield_t,
|
||||||
e2: fdfield,
|
e2: fdfield_t,
|
||||||
epsilon: fdfield | None = None,
|
epsilon: fdfield_t | None = None,
|
||||||
mu: fdfield | None = None,
|
mu: fdfield_t | None = None,
|
||||||
dxes: dx_lists_t | None = None,
|
dxes: dx_lists_t | None = None,
|
||||||
) -> fdfield_t:
|
) -> fdfield_t:
|
||||||
"""
|
"""
|
||||||
|
|
@ -165,15 +150,15 @@ def energy_hstep(
|
||||||
Energy, at the time of the H-field `h1`.
|
Energy, at the time of the H-field `h1`.
|
||||||
"""
|
"""
|
||||||
u = dxmul(e0 * e2, h1 * h1, epsilon, mu, dxes)
|
u = dxmul(e0 * e2, h1 * h1, epsilon, mu, dxes)
|
||||||
return fdfield_t(u)
|
return u
|
||||||
|
|
||||||
|
|
||||||
def energy_estep(
|
def energy_estep(
|
||||||
h0: fdfield,
|
h0: fdfield_t,
|
||||||
e1: fdfield,
|
e1: fdfield_t,
|
||||||
h2: fdfield,
|
h2: fdfield_t,
|
||||||
epsilon: fdfield | None = None,
|
epsilon: fdfield_t | None = None,
|
||||||
mu: fdfield | None = None,
|
mu: fdfield_t | None = None,
|
||||||
dxes: dx_lists_t | None = None,
|
dxes: dx_lists_t | None = None,
|
||||||
) -> fdfield_t:
|
) -> fdfield_t:
|
||||||
"""
|
"""
|
||||||
|
|
@ -193,17 +178,17 @@ def energy_estep(
|
||||||
Energy, at the time of the E-field `e1`.
|
Energy, at the time of the E-field `e1`.
|
||||||
"""
|
"""
|
||||||
u = dxmul(e1 * e1, h0 * h2, epsilon, mu, dxes)
|
u = dxmul(e1 * e1, h0 * h2, epsilon, mu, dxes)
|
||||||
return fdfield_t(u)
|
return u
|
||||||
|
|
||||||
|
|
||||||
def delta_energy_h2e(
|
def delta_energy_h2e(
|
||||||
dt: float,
|
dt: float,
|
||||||
e0: fdfield,
|
e0: fdfield_t,
|
||||||
h1: fdfield,
|
h1: fdfield_t,
|
||||||
e2: fdfield,
|
e2: fdfield_t,
|
||||||
h3: fdfield,
|
h3: fdfield_t,
|
||||||
epsilon: fdfield | None = None,
|
epsilon: fdfield_t | None = None,
|
||||||
mu: fdfield | None = None,
|
mu: fdfield_t | None = None,
|
||||||
dxes: dx_lists_t | None = None,
|
dxes: dx_lists_t | None = None,
|
||||||
) -> fdfield_t:
|
) -> fdfield_t:
|
||||||
"""
|
"""
|
||||||
|
|
@ -226,17 +211,17 @@ def delta_energy_h2e(
|
||||||
de = e2 * (e2 - e0) / dt
|
de = e2 * (e2 - e0) / dt
|
||||||
dh = h1 * (h3 - h1) / dt
|
dh = h1 * (h3 - h1) / dt
|
||||||
du = dxmul(de, dh, epsilon, mu, dxes)
|
du = dxmul(de, dh, epsilon, mu, dxes)
|
||||||
return fdfield_t(du)
|
return du
|
||||||
|
|
||||||
|
|
||||||
def delta_energy_e2h(
|
def delta_energy_e2h(
|
||||||
dt: float,
|
dt: float,
|
||||||
h0: fdfield,
|
h0: fdfield_t,
|
||||||
e1: fdfield,
|
e1: fdfield_t,
|
||||||
h2: fdfield,
|
h2: fdfield_t,
|
||||||
e3: fdfield,
|
e3: fdfield_t,
|
||||||
epsilon: fdfield | None = None,
|
epsilon: fdfield_t | None = None,
|
||||||
mu: fdfield | None = None,
|
mu: fdfield_t | None = None,
|
||||||
dxes: dx_lists_t | None = None,
|
dxes: dx_lists_t | None = None,
|
||||||
) -> fdfield_t:
|
) -> fdfield_t:
|
||||||
"""
|
"""
|
||||||
|
|
@ -259,31 +244,21 @@ def delta_energy_e2h(
|
||||||
de = e1 * (e3 - e1) / dt
|
de = e1 * (e3 - e1) / dt
|
||||||
dh = h2 * (h2 - h0) / dt
|
dh = h2 * (h2 - h0) / dt
|
||||||
du = dxmul(de, dh, epsilon, mu, dxes)
|
du = dxmul(de, dh, epsilon, mu, dxes)
|
||||||
return fdfield_t(du)
|
return du
|
||||||
|
|
||||||
|
|
||||||
def delta_energy_j(
|
def delta_energy_j(
|
||||||
j0: fdfield,
|
j0: fdfield_t,
|
||||||
e1: fdfield,
|
e1: fdfield_t,
|
||||||
dxes: dx_lists_t | None = None,
|
dxes: dx_lists_t | None = None,
|
||||||
) -> fdfield_t:
|
) -> fdfield_t:
|
||||||
r"""
|
"""
|
||||||
Calculate the electric-current work term $J \cdot E$ on the Yee grid.
|
Calculate
|
||||||
|
|
||||||
This is the source contribution that appears beside the flux divergence in
|
Note that each value of $J$ contributes to the energy twice (i.e. once per field update)
|
||||||
the discrete Poynting identities documented in `meanas.fdtd`.
|
despite only causing the value of $E$ to change once (same for $M$ and $H$).
|
||||||
|
|
||||||
Note that each value of `J` contributes twice in a full Yee cycle (once per
|
|
||||||
half-step energy balance) even though it directly changes `E` only once.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
j0: Electric-current density sampled at the same half-step as the
|
|
||||||
current work term.
|
|
||||||
e1: Electric field sampled at the matching integer timestep.
|
|
||||||
dxes: Grid description; defaults to unit spacing.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Per-cell source-work contribution with the scalar field shape.
|
|
||||||
"""
|
"""
|
||||||
if dxes is None:
|
if dxes is None:
|
||||||
dxes = tuple(tuple(numpy.ones(1) for _ in range(3)) for _ in range(2))
|
dxes = tuple(tuple(numpy.ones(1) for _ in range(3)) for _ in range(2))
|
||||||
|
|
@ -292,30 +267,16 @@ def delta_energy_j(
|
||||||
* dxes[0][0][:, None, None]
|
* dxes[0][0][:, None, None]
|
||||||
* dxes[0][1][None, :, None]
|
* dxes[0][1][None, :, None]
|
||||||
* dxes[0][2][None, None, :])
|
* dxes[0][2][None, None, :])
|
||||||
return fdfield_t(du)
|
return du
|
||||||
|
|
||||||
|
|
||||||
def dxmul(
|
def dxmul(
|
||||||
ee: fdfield,
|
ee: fdfield_t,
|
||||||
hh: fdfield,
|
hh: fdfield_t,
|
||||||
epsilon: fdfield | float | None = None,
|
epsilon: fdfield_t | float | None = None,
|
||||||
mu: fdfield | float | None = None,
|
mu: fdfield_t | float | None = None,
|
||||||
dxes: dx_lists_t | None = None,
|
dxes: dx_lists_t | None = None,
|
||||||
) -> fdfield_t:
|
) -> fdfield_t:
|
||||||
"""
|
|
||||||
Multiply E- and H-like field products by material weights and cell volumes.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
ee: Three-component electric-field product, such as `e0 * e2`.
|
|
||||||
hh: Three-component magnetic-field product, such as `h1 * h1`.
|
|
||||||
epsilon: Electric material weight; defaults to `1`.
|
|
||||||
mu: Magnetic material weight; defaults to `1`.
|
|
||||||
dxes: Grid description; defaults to unit spacing.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Scalar field containing the weighted electric plus magnetic contribution
|
|
||||||
for each Yee cell.
|
|
||||||
"""
|
|
||||||
if epsilon is None:
|
if epsilon is None:
|
||||||
epsilon = 1
|
epsilon = 1
|
||||||
if mu is None:
|
if mu is None:
|
||||||
|
|
@ -331,4 +292,4 @@ def dxmul(
|
||||||
* dxes[1][0][:, None, None]
|
* dxes[1][0][:, None, None]
|
||||||
* dxes[1][1][None, :, None]
|
* dxes[1][1][None, :, None]
|
||||||
* dxes[1][2][None, None, :])
|
* dxes[1][2][None, None, :])
|
||||||
return fdfield_t(result)
|
return result
|
||||||
|
|
|
||||||
|
|
@ -1,180 +0,0 @@
|
||||||
from collections.abc import Callable
|
|
||||||
import logging
|
|
||||||
from typing import cast
|
|
||||||
|
|
||||||
import numpy
|
|
||||||
from numpy.typing import NDArray, ArrayLike
|
|
||||||
from numpy import pi
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
type pulse_scalar_t = float | NDArray[numpy.floating]
|
|
||||||
pulse_fn_t = Callable[[ArrayLike], tuple[pulse_scalar_t, pulse_scalar_t, pulse_scalar_t]]
|
|
||||||
|
|
||||||
|
|
||||||
def _scalar_or_array(values: NDArray[numpy.floating]) -> pulse_scalar_t:
|
|
||||||
if values.ndim == 0:
|
|
||||||
return float(values)
|
|
||||||
return cast('NDArray[numpy.floating]', values)
|
|
||||||
|
|
||||||
|
|
||||||
def gaussian_packet(
|
|
||||||
wl: float,
|
|
||||||
dwl: float,
|
|
||||||
dt: float,
|
|
||||||
turn_on: float = 1e-10,
|
|
||||||
one_sided: bool = False,
|
|
||||||
) -> tuple[pulse_fn_t, float]:
|
|
||||||
"""
|
|
||||||
Gaussian pulse (or gaussian ramp) for FDTD excitation
|
|
||||||
|
|
||||||
exp(-a*t*t) ==> exp(-omega * omega / (4 * a)) [fourier, ignoring leading const.]
|
|
||||||
|
|
||||||
FWHM_time is 2 * sqrt(2 * log(2)) * sqrt(2 / a)
|
|
||||||
FWHM_omega is 2 * sqrt(2 * log(2)) * sqrt(2 * a) = 4 * sqrt(log(2) * a)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
wl: wavelength
|
|
||||||
dwl: Gaussian's FWHM in wavelength space
|
|
||||||
dt: Timestep
|
|
||||||
turn_on: Max allowable amplitude at t=0
|
|
||||||
one_sided: If `True`, source amplitude never decreases after reaching max
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Source function: src(timestep) -> (envelope[tt], cos[... * tt], sin[... * tt])
|
|
||||||
Delay: number of initial timesteps for which envelope[tt] will be 0
|
|
||||||
"""
|
|
||||||
logger.warning('meanas.fdtd.misc functions are still very WIP!') # TODO
|
|
||||||
# dt * dw = 4 * ln(2)
|
|
||||||
|
|
||||||
omega = 2 * pi / wl
|
|
||||||
freq = 1 / wl
|
|
||||||
fwhm_omega = dwl * omega * omega / (2 * pi) # dwl -> d_omega (approx)
|
|
||||||
alpha = (fwhm_omega * fwhm_omega) * numpy.log(2) / 8
|
|
||||||
delay = numpy.sqrt(-numpy.log(turn_on) / alpha)
|
|
||||||
delay = numpy.ceil(delay * freq) / freq # force delay to integer number of periods to maintain phase
|
|
||||||
logger.info(f'src_time {2 * delay / dt}')
|
|
||||||
|
|
||||||
def source_phasor(ii: ArrayLike) -> tuple[pulse_scalar_t, pulse_scalar_t, pulse_scalar_t]:
|
|
||||||
ii_array = numpy.asarray(ii, dtype=float)
|
|
||||||
t0 = ii_array * dt - delay
|
|
||||||
envelope = numpy.sqrt(numpy.sqrt(2 * alpha / pi)) * numpy.exp(-alpha * t0 * t0)
|
|
||||||
|
|
||||||
if one_sided:
|
|
||||||
envelope = numpy.where(t0 > 0, 1.0, envelope)
|
|
||||||
|
|
||||||
cc = numpy.cos(omega * t0)
|
|
||||||
ss = numpy.sin(omega * t0)
|
|
||||||
|
|
||||||
return _scalar_or_array(envelope), _scalar_or_array(cc), _scalar_or_array(ss)
|
|
||||||
|
|
||||||
# nrm = numpy.exp(-omega * omega / alpha) / 2
|
|
||||||
|
|
||||||
return source_phasor, delay
|
|
||||||
|
|
||||||
|
|
||||||
def ricker_pulse(
|
|
||||||
wl: float,
|
|
||||||
dt: float,
|
|
||||||
turn_on: float = 1e-10,
|
|
||||||
) -> tuple[pulse_fn_t, float]:
|
|
||||||
"""
|
|
||||||
Ricker wavelet (second derivative of a gaussian pulse)
|
|
||||||
|
|
||||||
t0 = ii * dt - delay
|
|
||||||
R = w_peak * t0 / 2
|
|
||||||
f(t) = (1 - 2 * (pi * f_peak * t0) ** 2) * exp(-(pi * f_peak * t0)**2
|
|
||||||
= (1 - (w_peak * t0)**2 / 2 exp(-(w_peak * t0 / 2) **2)
|
|
||||||
= (1 - 2 * R * R) * exp(-R * R)
|
|
||||||
|
|
||||||
# NOTE: don't use cosine/sine for J, just for phasor readout
|
|
||||||
|
|
||||||
Args:
|
|
||||||
wl: wavelength
|
|
||||||
dt: Timestep
|
|
||||||
turn_on: Max allowable amplitude at t=0
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Source function: src(timestep) -> (envelope[tt], cos[... * tt], sin[... * tt])
|
|
||||||
Delay: number of initial timesteps for which envelope[tt] will be 0
|
|
||||||
"""
|
|
||||||
logger.warning('meanas.fdtd.misc functions are still very WIP!') # TODO
|
|
||||||
omega = 2 * pi / wl
|
|
||||||
freq = 1 / wl
|
|
||||||
|
|
||||||
from scipy.optimize import root_scalar
|
|
||||||
delay_results = root_scalar(
|
|
||||||
lambda tt: (1 - omega * omega * tt * tt / 2) * numpy.exp(-omega * omega * tt * tt / 4) - turn_on,
|
|
||||||
x0=0,
|
|
||||||
x1=-2 / omega,
|
|
||||||
)
|
|
||||||
|
|
||||||
delay = delay_results.root
|
|
||||||
delay = numpy.ceil(delay * freq) / freq # force delay to integer number of periods to maintain phase
|
|
||||||
|
|
||||||
def source_phasor(ii: ArrayLike) -> tuple[pulse_scalar_t, pulse_scalar_t, pulse_scalar_t]:
|
|
||||||
ii_array = numpy.asarray(ii, dtype=float)
|
|
||||||
t0 = ii_array * dt - delay
|
|
||||||
rr = omega * t0 / 2
|
|
||||||
ff = (1 - 2 * rr * rr) * numpy.exp(-rr * rr)
|
|
||||||
|
|
||||||
cc = numpy.cos(omega * t0)
|
|
||||||
ss = numpy.sin(omega * t0)
|
|
||||||
|
|
||||||
return _scalar_or_array(ff), _scalar_or_array(cc), _scalar_or_array(ss)
|
|
||||||
|
|
||||||
return source_phasor, delay
|
|
||||||
|
|
||||||
|
|
||||||
def gaussian_beam(
|
|
||||||
xyz: list[NDArray],
|
|
||||||
center: ArrayLike,
|
|
||||||
waist_radius: float,
|
|
||||||
wl: float,
|
|
||||||
tilt: float = 0,
|
|
||||||
) -> NDArray[numpy.complex128]:
|
|
||||||
"""
|
|
||||||
Gaussian beam
|
|
||||||
(solution to paraxial Helmholtz equation)
|
|
||||||
|
|
||||||
Default (no tilt) corresponds to a beam propagating in the -z direction.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
xyz: List of [[x0, x1, ...], [y0, ...], [z0, ...]] positions specifying grid
|
|
||||||
locations at which the field will be sampled.
|
|
||||||
center: [x, y, z] location of beam waist
|
|
||||||
waist_radius: Beam radius at the waist
|
|
||||||
wl: Wavelength
|
|
||||||
tilt: Rotation around y axis. Default (0) has beam propagating in -z direction.
|
|
||||||
"""
|
|
||||||
logger.warning('meanas.fdtd.misc functions are still very WIP!') # TODO
|
|
||||||
w0 = waist_radius
|
|
||||||
grids = numpy.asarray(numpy.meshgrid(*xyz, indexing='ij'))
|
|
||||||
grids -= numpy.asarray(center)[:, None, None, None]
|
|
||||||
|
|
||||||
rot = numpy.array([
|
|
||||||
[ numpy.cos(tilt), 0, numpy.sin(tilt)],
|
|
||||||
[ 0, 1, 0],
|
|
||||||
[-numpy.sin(tilt), 0, numpy.cos(tilt)],
|
|
||||||
])
|
|
||||||
|
|
||||||
xx, yy, zz = numpy.einsum('ij,jxyz->ixyz', rot, grids)
|
|
||||||
r2 = xx * xx + yy * yy
|
|
||||||
z2 = zz * zz
|
|
||||||
|
|
||||||
zr = pi * w0 * w0 / wl
|
|
||||||
zr2 = zr * zr
|
|
||||||
wz2 = w0 * w0 * (1 + z2 / zr2)
|
|
||||||
wz = numpy.sqrt(wz2) # == fwhm(z) / sqrt(2 * ln(2))
|
|
||||||
|
|
||||||
kk = 2 * pi / wl
|
|
||||||
inv_Rz = numpy.divide(zz, z2 + zr2, out=numpy.zeros_like(zz), where=(z2 + zr2) != 0)
|
|
||||||
gouy = numpy.arctan(zz / zr)
|
|
||||||
|
|
||||||
gaussian = w0 / wz * numpy.exp(-r2 / wz2) * numpy.exp(1j * (kk * zz + kk * r2 * inv_Rz / 2 - gouy))
|
|
||||||
|
|
||||||
row = gaussian[:, :, gaussian.shape[2] // 2]
|
|
||||||
norm = numpy.linalg.norm(row)
|
|
||||||
return gaussian / norm
|
|
||||||
|
|
@ -1,319 +0,0 @@
|
||||||
"""
|
|
||||||
Helpers for extracting single- or multi-frequency phasors from FDTD samples.
|
|
||||||
|
|
||||||
These helpers are intentionally low-level: callers own the accumulator arrays and
|
|
||||||
the sampling policy. The accumulated quantity is
|
|
||||||
|
|
||||||
dt * sum(weight * exp(-1j * omega * t_step) * sample_step)
|
|
||||||
|
|
||||||
where `t_step = (step + offset_steps) * dt`.
|
|
||||||
|
|
||||||
The usual Yee offsets are:
|
|
||||||
|
|
||||||
- `accumulate_phasor_e(..., step=l)` for `E_l`
|
|
||||||
- `accumulate_phasor_h(..., step=l)` for `H_{l + 1/2}`
|
|
||||||
- `accumulate_phasor_j(..., step=l)` for `J_{l + 1/2}`
|
|
||||||
|
|
||||||
`temporal_phasor(...)` and `temporal_phasor_scale(...)` apply the same Fourier
|
|
||||||
sum to a 1D scalar waveform. They are useful for normalizing a pulsed source
|
|
||||||
before that scalar waveform is applied to a point source or spatial mode source.
|
|
||||||
`real_injection_scale(...)` is a companion helper for the common real-valued
|
|
||||||
injection pattern `numpy.real(scale * waveform)`, where `waveform` is the
|
|
||||||
analytic positive-frequency signal and the injected real current should still
|
|
||||||
produce a desired phasor response.
|
|
||||||
`reconstruct_real(...)` and its `E/H/J` wrappers perform the inverse operation:
|
|
||||||
they turn one or more phasors back into real-valued field snapshots at explicit
|
|
||||||
Yee-aligned sample times. For a scalar target frequency they accept either a
|
|
||||||
plain field phasor or the batched `(1, *sample_shape)` form used elsewhere in
|
|
||||||
this module.
|
|
||||||
|
|
||||||
These helpers do not choose warmup/accumulation windows or pulse-envelope
|
|
||||||
normalization. They also do not impose a current sign convention. In this
|
|
||||||
codebase, electric-current injection normally appears as `E -= dt * J / epsilon`,
|
|
||||||
which matches the FDFD right-hand side `-1j * omega * J`.
|
|
||||||
"""
|
|
||||||
from collections.abc import Sequence
|
|
||||||
|
|
||||||
import numpy
|
|
||||||
from numpy.typing import ArrayLike, NDArray
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_omegas(
|
|
||||||
omegas: float | complex | Sequence[float | complex] | NDArray,
|
|
||||||
) -> NDArray[numpy.complexfloating]:
|
|
||||||
omega_array = numpy.atleast_1d(numpy.asarray(omegas, dtype=complex))
|
|
||||||
if omega_array.ndim != 1 or omega_array.size == 0:
|
|
||||||
raise ValueError('omegas must be a scalar or non-empty 1D sequence')
|
|
||||||
return omega_array
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_weight(
|
|
||||||
weight: ArrayLike,
|
|
||||||
omega_shape: tuple[int, ...],
|
|
||||||
) -> NDArray[numpy.complexfloating]:
|
|
||||||
weight_array = numpy.asarray(weight, dtype=complex)
|
|
||||||
if weight_array.ndim == 0:
|
|
||||||
return numpy.full(omega_shape, weight_array, dtype=complex)
|
|
||||||
if weight_array.shape == omega_shape:
|
|
||||||
return weight_array
|
|
||||||
raise ValueError(f'weight must be scalar or have shape {omega_shape}, got {weight_array.shape}')
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_temporal_samples(
|
|
||||||
samples: ArrayLike,
|
|
||||||
) -> NDArray[numpy.complexfloating]:
|
|
||||||
sample_array = numpy.asarray(samples, dtype=complex)
|
|
||||||
if sample_array.ndim != 1 or sample_array.size == 0:
|
|
||||||
raise ValueError('samples must be a non-empty 1D sequence')
|
|
||||||
return sample_array
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_reconstruction_inputs(
|
|
||||||
phasors: ArrayLike,
|
|
||||||
omegas: float | complex | Sequence[float | complex] | NDArray,
|
|
||||||
dt: float,
|
|
||||||
) -> tuple[NDArray[numpy.complexfloating], NDArray[numpy.complexfloating], bool]:
|
|
||||||
if dt <= 0:
|
|
||||||
raise ValueError('dt must be positive')
|
|
||||||
|
|
||||||
omega_array = _normalize_omegas(omegas)
|
|
||||||
phasor_array = numpy.asarray(phasors, dtype=complex)
|
|
||||||
expected_leading = omega_array.size
|
|
||||||
if phasor_array.ndim == 0:
|
|
||||||
raise ValueError(
|
|
||||||
f'phasors must have shape ({expected_leading}, *sample_shape) or sample_shape for scalar omega, got {phasor_array.shape}',
|
|
||||||
)
|
|
||||||
added_axis = False
|
|
||||||
if expected_leading == 1 and (phasor_array.ndim == 1 or phasor_array.shape[0] != expected_leading):
|
|
||||||
phasor_array = phasor_array[numpy.newaxis, ...]
|
|
||||||
added_axis = True
|
|
||||||
elif phasor_array.shape[0] != expected_leading:
|
|
||||||
raise ValueError(
|
|
||||||
f'phasors must have shape ({expected_leading}, *sample_shape) or sample_shape for scalar omega, got {phasor_array.shape}',
|
|
||||||
)
|
|
||||||
return omega_array, phasor_array, added_axis
|
|
||||||
|
|
||||||
|
|
||||||
def accumulate_phasor(
|
|
||||||
accumulator: NDArray[numpy.complexfloating],
|
|
||||||
omegas: float | complex | Sequence[float | complex] | NDArray,
|
|
||||||
dt: float,
|
|
||||||
sample: ArrayLike,
|
|
||||||
step: int,
|
|
||||||
*,
|
|
||||||
offset_steps: float = 0.0,
|
|
||||||
weight: ArrayLike = 1.0,
|
|
||||||
) -> NDArray[numpy.complexfloating]:
|
|
||||||
"""
|
|
||||||
Add one time-domain sample into a phasor accumulator.
|
|
||||||
|
|
||||||
The added quantity is
|
|
||||||
|
|
||||||
dt * weight * exp(-1j * omega * t_step) * sample
|
|
||||||
|
|
||||||
where `t_step = (step + offset_steps) * dt`.
|
|
||||||
|
|
||||||
Note:
|
|
||||||
This helper already multiplies by `dt`. If the caller's normalization
|
|
||||||
factor was derived from a discrete sum that already includes `dt`, pass
|
|
||||||
`weight / dt` here.
|
|
||||||
"""
|
|
||||||
if dt <= 0:
|
|
||||||
raise ValueError('dt must be positive')
|
|
||||||
|
|
||||||
omega_array = _normalize_omegas(omegas)
|
|
||||||
sample_array = numpy.asarray(sample)
|
|
||||||
expected_shape = (omega_array.size, *sample_array.shape)
|
|
||||||
if accumulator.shape != expected_shape:
|
|
||||||
raise ValueError(f'accumulator must have shape {expected_shape}, got {accumulator.shape}')
|
|
||||||
|
|
||||||
weight_array = _normalize_weight(weight, omega_array.shape)
|
|
||||||
time = (step + offset_steps) * dt
|
|
||||||
phase = numpy.exp(-1j * omega_array * time)
|
|
||||||
scaled = dt * (weight_array * phase).reshape((-1,) + (1,) * sample_array.ndim)
|
|
||||||
accumulator += scaled * sample_array
|
|
||||||
return accumulator
|
|
||||||
|
|
||||||
|
|
||||||
def temporal_phasor(
|
|
||||||
samples: ArrayLike,
|
|
||||||
omegas: float | complex | Sequence[float | complex] | NDArray,
|
|
||||||
dt: float,
|
|
||||||
*,
|
|
||||||
start_step: int = 0,
|
|
||||||
offset_steps: float = 0.0,
|
|
||||||
) -> NDArray[numpy.complexfloating]:
|
|
||||||
"""
|
|
||||||
Fourier-project a 1D temporal waveform onto one or more angular frequencies.
|
|
||||||
|
|
||||||
The returned quantity is
|
|
||||||
|
|
||||||
dt * sum(exp(-1j * omega * t_step) * samples[step_index])
|
|
||||||
|
|
||||||
where `t_step = (start_step + step_index + offset_steps) * dt`.
|
|
||||||
"""
|
|
||||||
if dt <= 0:
|
|
||||||
raise ValueError('dt must be positive')
|
|
||||||
|
|
||||||
omega_array = _normalize_omegas(omegas)
|
|
||||||
sample_array = _normalize_temporal_samples(samples)
|
|
||||||
steps = start_step + numpy.arange(sample_array.size, dtype=float) + offset_steps
|
|
||||||
phase = numpy.exp(-1j * omega_array[:, None] * (steps[None, :] * dt))
|
|
||||||
return dt * (phase @ sample_array)
|
|
||||||
|
|
||||||
|
|
||||||
def temporal_phasor_scale(
|
|
||||||
samples: ArrayLike,
|
|
||||||
omegas: float | complex | Sequence[float | complex] | NDArray,
|
|
||||||
dt: float,
|
|
||||||
*,
|
|
||||||
start_step: int = 0,
|
|
||||||
offset_steps: float = 0.0,
|
|
||||||
target: ArrayLike = 1.0,
|
|
||||||
) -> NDArray[numpy.complexfloating]:
|
|
||||||
"""
|
|
||||||
Return the scalar multiplier that gives a desired temporal phasor response.
|
|
||||||
|
|
||||||
The returned scale satisfies
|
|
||||||
|
|
||||||
temporal_phasor(scale * samples, omegas, dt, ...) == target
|
|
||||||
|
|
||||||
for each target frequency. The result keeps a leading frequency axis even
|
|
||||||
when `omegas` is scalar.
|
|
||||||
"""
|
|
||||||
response = temporal_phasor(samples, omegas, dt, start_step=start_step, offset_steps=offset_steps)
|
|
||||||
target_array = _normalize_weight(target, response.shape)
|
|
||||||
if numpy.any(numpy.abs(response) <= numpy.finfo(float).eps):
|
|
||||||
raise ValueError('cannot normalize a waveform with zero temporal phasor response')
|
|
||||||
return target_array / response
|
|
||||||
|
|
||||||
|
|
||||||
def real_injection_scale(
|
|
||||||
samples: ArrayLike,
|
|
||||||
omegas: float | complex | Sequence[float | complex] | NDArray,
|
|
||||||
dt: float,
|
|
||||||
*,
|
|
||||||
start_step: int = 0,
|
|
||||||
offset_steps: float = 0.0,
|
|
||||||
target: ArrayLike = 1.0,
|
|
||||||
) -> NDArray[numpy.complexfloating]:
|
|
||||||
"""
|
|
||||||
Return the scale for a real-valued injection built from an analytic waveform.
|
|
||||||
|
|
||||||
If the time-domain source is applied as
|
|
||||||
|
|
||||||
numpy.real(scale * samples[step])
|
|
||||||
|
|
||||||
then the desired positive-frequency phasor is obtained by compensating for
|
|
||||||
the 1/2 factor between the real-valued source and its analytic component:
|
|
||||||
|
|
||||||
scale = 2 * target / temporal_phasor(samples, ...)
|
|
||||||
|
|
||||||
This helper normalizes only the intended positive-frequency component. Any
|
|
||||||
residual negative-frequency leakage is controlled by the waveform design and
|
|
||||||
the accumulation window.
|
|
||||||
"""
|
|
||||||
response = temporal_phasor(samples, omegas, dt, start_step=start_step, offset_steps=offset_steps)
|
|
||||||
target_array = _normalize_weight(target, response.shape)
|
|
||||||
if numpy.any(numpy.abs(response) <= numpy.finfo(float).eps):
|
|
||||||
raise ValueError('cannot normalize a waveform with zero temporal phasor response')
|
|
||||||
return 2 * target_array / response
|
|
||||||
|
|
||||||
|
|
||||||
def reconstruct_real(
|
|
||||||
phasors: ArrayLike,
|
|
||||||
omegas: float | complex | Sequence[float | complex] | NDArray,
|
|
||||||
dt: float,
|
|
||||||
step: int,
|
|
||||||
*,
|
|
||||||
offset_steps: float = 0.0,
|
|
||||||
) -> NDArray[numpy.floating]:
|
|
||||||
"""
|
|
||||||
Reconstruct a real-valued field snapshot from one or more phasors.
|
|
||||||
|
|
||||||
The returned quantity is
|
|
||||||
|
|
||||||
real(phasor * exp(1j * omega * t_step))
|
|
||||||
|
|
||||||
where `t_step = (step + offset_steps) * dt`.
|
|
||||||
|
|
||||||
For multi-frequency inputs, the leading frequency axis is preserved. For a
|
|
||||||
scalar `omega`, callers may pass either `(1, *sample_shape)` or
|
|
||||||
`sample_shape`; the return shape matches that choice.
|
|
||||||
"""
|
|
||||||
omega_array, phasor_array, added_axis = _validate_reconstruction_inputs(phasors, omegas, dt)
|
|
||||||
time = (step + offset_steps) * dt
|
|
||||||
phase = numpy.exp(1j * omega_array * time).reshape((-1,) + (1,) * (phasor_array.ndim - 1))
|
|
||||||
reconstructed = numpy.real(phasor_array * phase)
|
|
||||||
if added_axis:
|
|
||||||
return reconstructed[0]
|
|
||||||
return reconstructed
|
|
||||||
|
|
||||||
|
|
||||||
def accumulate_phasor_e(
|
|
||||||
accumulator: NDArray[numpy.complexfloating],
|
|
||||||
omegas: float | complex | Sequence[float | complex] | NDArray,
|
|
||||||
dt: float,
|
|
||||||
sample: ArrayLike,
|
|
||||||
step: int,
|
|
||||||
*,
|
|
||||||
weight: ArrayLike = 1.0,
|
|
||||||
) -> NDArray[numpy.complexfloating]:
|
|
||||||
"""Accumulate an E-field sample taken at integer timestep `step`."""
|
|
||||||
return accumulate_phasor(accumulator, omegas, dt, sample, step, offset_steps=0.0, weight=weight)
|
|
||||||
|
|
||||||
|
|
||||||
def accumulate_phasor_h(
|
|
||||||
accumulator: NDArray[numpy.complexfloating],
|
|
||||||
omegas: float | complex | Sequence[float | complex] | NDArray,
|
|
||||||
dt: float,
|
|
||||||
sample: ArrayLike,
|
|
||||||
step: int,
|
|
||||||
*,
|
|
||||||
weight: ArrayLike = 1.0,
|
|
||||||
) -> NDArray[numpy.complexfloating]:
|
|
||||||
"""Accumulate an H-field sample corresponding to `H_{step + 1/2}`."""
|
|
||||||
return accumulate_phasor(accumulator, omegas, dt, sample, step, offset_steps=0.5, weight=weight)
|
|
||||||
|
|
||||||
|
|
||||||
def accumulate_phasor_j(
|
|
||||||
accumulator: NDArray[numpy.complexfloating],
|
|
||||||
omegas: float | complex | Sequence[float | complex] | NDArray,
|
|
||||||
dt: float,
|
|
||||||
sample: ArrayLike,
|
|
||||||
step: int,
|
|
||||||
*,
|
|
||||||
weight: ArrayLike = 1.0,
|
|
||||||
) -> NDArray[numpy.complexfloating]:
|
|
||||||
"""Accumulate a current sample corresponding to `J_{step + 1/2}`."""
|
|
||||||
return accumulate_phasor(accumulator, omegas, dt, sample, step, offset_steps=0.5, weight=weight)
|
|
||||||
|
|
||||||
|
|
||||||
def reconstruct_real_e(
|
|
||||||
phasors: ArrayLike,
|
|
||||||
omegas: float | complex | Sequence[float | complex] | NDArray,
|
|
||||||
dt: float,
|
|
||||||
step: int,
|
|
||||||
) -> NDArray[numpy.floating]:
|
|
||||||
"""Reconstruct a real E-field snapshot taken at integer timestep `step`."""
|
|
||||||
return reconstruct_real(phasors, omegas, dt, step, offset_steps=0.0)
|
|
||||||
|
|
||||||
|
|
||||||
def reconstruct_real_h(
|
|
||||||
phasors: ArrayLike,
|
|
||||||
omegas: float | complex | Sequence[float | complex] | NDArray,
|
|
||||||
dt: float,
|
|
||||||
step: int,
|
|
||||||
) -> NDArray[numpy.floating]:
|
|
||||||
"""Reconstruct a real H-field snapshot corresponding to `H_{step + 1/2}`."""
|
|
||||||
return reconstruct_real(phasors, omegas, dt, step, offset_steps=0.5)
|
|
||||||
|
|
||||||
|
|
||||||
def reconstruct_real_j(
|
|
||||||
phasors: ArrayLike,
|
|
||||||
omegas: float | complex | Sequence[float | complex] | NDArray,
|
|
||||||
dt: float,
|
|
||||||
step: int,
|
|
||||||
) -> NDArray[numpy.floating]:
|
|
||||||
"""Reconstruct a real current snapshot corresponding to `J_{step + 1/2}`."""
|
|
||||||
return reconstruct_real(phasors, omegas, dt, step, offset_steps=0.5)
|
|
||||||
|
|
@ -1,29 +1,18 @@
|
||||||
"""
|
"""
|
||||||
Convolutional perfectly matched layer (CPML) support for FDTD updates.
|
PML implementations
|
||||||
|
|
||||||
The helpers in this module construct per-face CPML parameters and then wrap the
|
#TODO discussion of PMLs
|
||||||
standard Yee updates with the additional auxiliary `psi` fields needed by the
|
#TODO cpml documentation
|
||||||
CPML recurrence.
|
|
||||||
|
|
||||||
The intended call pattern is:
|
|
||||||
|
|
||||||
1. Build a `cpml_params[axis][polarity_index]` table with `cpml_params(...)`.
|
|
||||||
2. Pass that table into `updates_with_cpml(...)` together with `dt`, `dxes`, and
|
|
||||||
`epsilon`.
|
|
||||||
3. Advance the returned `update_E` / `update_H` closures in the simulation loop.
|
|
||||||
|
|
||||||
Each face can be enabled or disabled independently by replacing one table entry
|
|
||||||
with `None`.
|
|
||||||
"""
|
"""
|
||||||
# TODO retest pmls!
|
# TODO retest pmls!
|
||||||
|
|
||||||
from typing import Any
|
from typing import Callable, Sequence, Any
|
||||||
from collections.abc import Callable, Sequence
|
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
import numpy
|
import numpy
|
||||||
from numpy.typing import NDArray, DTypeLike
|
from numpy.typing import NDArray, DTypeLike
|
||||||
|
|
||||||
from ..fdmath import fdfield, dx_lists_t
|
from ..fdmath import fdfield_t, dx_lists_t
|
||||||
from ..fdmath.functional import deriv_forward, deriv_back
|
from ..fdmath.functional import deriv_forward, deriv_back
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -41,56 +30,21 @@ def cpml_params(
|
||||||
m: float = 3.5,
|
m: float = 3.5,
|
||||||
ma: float = 1,
|
ma: float = 1,
|
||||||
cfs_alpha: float = 0,
|
cfs_alpha: float = 0,
|
||||||
dx: float = 1.0,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""
|
|
||||||
Construct the parameter block for one CPML face.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
axis: Which Cartesian axis the CPML is normal to (`0`, `1`, or `2`).
|
|
||||||
polarity: Which face along that axis (`-1` for the low-index face,
|
|
||||||
`+1` for the high-index face).
|
|
||||||
dt: Timestep used by the Yee update.
|
|
||||||
thickness: Number of Yee cells occupied by the CPML region.
|
|
||||||
ln_R_per_layer: Finite, strictly negative logarithmic attenuation target
|
|
||||||
per layer. Zero attenuation is not supported; disable a face with
|
|
||||||
``None`` in the updates_with_cpml parameter table instead.
|
|
||||||
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.
|
|
||||||
dx: Physical cell width normal to this face, uniform within the CPML.
|
|
||||||
The default assumes unit cells. Arbitrary nonuniform grading is
|
|
||||||
not supported by this parameter constructor.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary with:
|
|
||||||
|
|
||||||
- `param_e`: `(p0, p1, p2)` arrays for the E update
|
|
||||||
- `param_h`: `(p0, p1, p2)` arrays for the H update
|
|
||||||
- `region`: slice tuple selecting the CPML cells on that face
|
|
||||||
"""
|
|
||||||
|
|
||||||
if axis not in range(3):
|
if axis not in range(3):
|
||||||
raise ValueError(f'Invalid axis: {axis}')
|
raise Exception('Invalid axis: {}'.format(axis))
|
||||||
|
|
||||||
if polarity not in (-1, 1):
|
if polarity not in (-1, 1):
|
||||||
raise ValueError(f'Invalid polarity: {polarity}')
|
raise Exception('Invalid polarity: {}'.format(polarity))
|
||||||
|
|
||||||
if thickness <= 2:
|
if thickness <= 2:
|
||||||
raise ValueError('It would be wise to have a pml with 4+ cells of thickness')
|
raise Exception('It would be wise to have a pml with 4+ cells of thickness')
|
||||||
|
|
||||||
if epsilon_eff <= 0:
|
if epsilon_eff <= 0:
|
||||||
raise ValueError('epsilon_eff must be positive')
|
raise Exception('epsilon_eff must be positive')
|
||||||
|
|
||||||
if not numpy.isfinite(ln_R_per_layer) or ln_R_per_layer >= 0:
|
sigma_max = -ln_R_per_layer / 2 * (m + 1)
|
||||||
raise ValueError('ln_R_per_layer must be finite and negative; use None to disable a CPML face')
|
|
||||||
|
|
||||||
if not numpy.isfinite(dx) or dx <= 0:
|
|
||||||
raise ValueError('dx must be finite and positive')
|
|
||||||
|
|
||||||
sigma_max = -ln_R_per_layer / (2 * dx) * (m + 1)
|
|
||||||
kappa_max = numpy.sqrt(epsilon_eff * mu_eff)
|
kappa_max = numpy.sqrt(epsilon_eff * mu_eff)
|
||||||
alpha_max = cfs_alpha
|
alpha_max = cfs_alpha
|
||||||
|
|
||||||
|
|
@ -102,6 +56,8 @@ def cpml_params(
|
||||||
xh -= 0.5
|
xh -= 0.5
|
||||||
xe = xe[::-1]
|
xe = xe[::-1]
|
||||||
xh = xh[::-1]
|
xh = xh[::-1]
|
||||||
|
else:
|
||||||
|
raise Exception('Bad polarity!')
|
||||||
|
|
||||||
expand_slice_l: list[Any] = [None, None, None]
|
expand_slice_l: list[Any] = [None, None, None]
|
||||||
expand_slice_l[axis] = slice(None)
|
expand_slice_l[axis] = slice(None)
|
||||||
|
|
@ -125,6 +81,8 @@ def cpml_params(
|
||||||
region_list[axis] = slice(None, thickness)
|
region_list[axis] = slice(None, thickness)
|
||||||
elif polarity > 0:
|
elif polarity > 0:
|
||||||
region_list[axis] = slice(-thickness, None)
|
region_list[axis] = slice(-thickness, None)
|
||||||
|
else:
|
||||||
|
raise Exception('Bad polarity!')
|
||||||
region = tuple(region_list)
|
region = tuple(region_list)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -138,33 +96,13 @@ def updates_with_cpml(
|
||||||
cpml_params: Sequence[Sequence[dict[str, Any] | None]],
|
cpml_params: Sequence[Sequence[dict[str, Any] | None]],
|
||||||
dt: float,
|
dt: float,
|
||||||
dxes: dx_lists_t,
|
dxes: dx_lists_t,
|
||||||
epsilon: fdfield,
|
epsilon: fdfield_t,
|
||||||
*,
|
*,
|
||||||
dtype: DTypeLike = numpy.float32,
|
dtype: DTypeLike = numpy.float32,
|
||||||
) -> tuple[Callable[..., None], Callable[..., None]]:
|
) -> tuple[Callable[[fdfield_t, fdfield_t, fdfield_t], None],
|
||||||
"""
|
Callable[[fdfield_t, fdfield_t, fdfield_t], None]]:
|
||||||
Build Yee-step update closures augmented with CPML terms.
|
|
||||||
|
|
||||||
Args:
|
Dfx, Dfy, Dfz = deriv_forward(dxes[1])
|
||||||
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[0])
|
|
||||||
Dbx, Dby, Dbz = deriv_back(dxes[1])
|
Dbx, Dby, Dbz = deriv_back(dxes[1])
|
||||||
|
|
||||||
psi_E: list[list[tuple[Any, Any]]] = [[(None, None) for _ in range(2)] for _ in range(3)]
|
psi_E: list[list[tuple[Any, Any]]] = [[(None, None) for _ in range(2)] for _ in range(3)]
|
||||||
|
|
@ -173,7 +111,7 @@ def updates_with_cpml(
|
||||||
params_H: list[list[tuple[Any, Any, Any, Any]]] = deepcopy(params_E)
|
params_H: list[list[tuple[Any, Any, Any, Any]]] = deepcopy(params_E)
|
||||||
|
|
||||||
for axis in range(3):
|
for axis in range(3):
|
||||||
for pp, _polarity in enumerate((-1, 1)):
|
for pp, polarity in enumerate((-1, 1)):
|
||||||
cpml_param = cpml_params[axis][pp]
|
cpml_param = cpml_params[axis][pp]
|
||||||
if cpml_param is None:
|
if cpml_param is None:
|
||||||
psi_E[axis][pp] = (None, None)
|
psi_E[axis][pp] = (None, None)
|
||||||
|
|
@ -198,9 +136,9 @@ def updates_with_cpml(
|
||||||
pH = numpy.empty_like(epsilon, dtype=dtype)
|
pH = numpy.empty_like(epsilon, dtype=dtype)
|
||||||
|
|
||||||
def update_E(
|
def update_E(
|
||||||
e: fdfield,
|
e: fdfield_t,
|
||||||
h: fdfield,
|
h: fdfield_t,
|
||||||
epsilon: fdfield,
|
epsilon: fdfield_t,
|
||||||
) -> None:
|
) -> None:
|
||||||
dyHx = Dby(h[0])
|
dyHx = Dby(h[0])
|
||||||
dzHx = Dbz(h[0])
|
dzHx = Dbz(h[0])
|
||||||
|
|
@ -244,9 +182,9 @@ def updates_with_cpml(
|
||||||
e[2] += dt / epsilon[2] * (dxHy - dyHx + pE[2])
|
e[2] += dt / epsilon[2] * (dxHy - dyHx + pE[2])
|
||||||
|
|
||||||
def update_H(
|
def update_H(
|
||||||
e: fdfield,
|
e: fdfield_t,
|
||||||
h: fdfield,
|
h: fdfield_t,
|
||||||
mu: fdfield | tuple[int, int, int] = (1, 1, 1),
|
mu: fdfield_t = numpy.ones(3),
|
||||||
) -> None:
|
) -> None:
|
||||||
dyEx = Dfy(e[0])
|
dyEx = Dfy(e[0])
|
||||||
dzEx = Dfz(e[0])
|
dzEx = Dfz(e[0])
|
||||||
|
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
import numpy
|
|
||||||
|
|
||||||
|
|
||||||
SHAPE = (2, 2, 2)
|
|
||||||
G_MATRIX = numpy.eye(3)
|
|
||||||
K0_GENERAL = numpy.array([0.1, 0.2, 0.3], dtype=float)
|
|
||||||
K0_AXIAL = numpy.array([0.0, 0.0, 0.25], dtype=float)
|
|
||||||
K0_X = numpy.array([0.1, 0.0, 0.0], dtype=float)
|
|
||||||
EPSILON = numpy.ones((3, *SHAPE), dtype=float)
|
|
||||||
MU = numpy.stack([
|
|
||||||
numpy.linspace(2.0, 2.7, numpy.prod(SHAPE)).reshape(SHAPE),
|
|
||||||
numpy.linspace(2.1, 2.8, numpy.prod(SHAPE)).reshape(SHAPE),
|
|
||||||
numpy.linspace(2.2, 2.9, numpy.prod(SHAPE)).reshape(SHAPE),
|
|
||||||
])
|
|
||||||
H_SIZE = 2 * numpy.prod(SHAPE)
|
|
||||||
H_MN = (numpy.arange(H_SIZE) + 0.25j).astype(complex)
|
|
||||||
ZERO_H_MN = numpy.zeros_like(H_MN)
|
|
||||||
Y0 = (numpy.arange(H_SIZE, dtype=float) + 1j * numpy.linspace(0.1, 0.9, H_SIZE))[None, :]
|
|
||||||
Y0_TWO_MODE = numpy.vstack(
|
|
||||||
[
|
|
||||||
numpy.arange(H_SIZE, dtype=float) + 1j * numpy.linspace(0.1, 0.9, H_SIZE),
|
|
||||||
numpy.linspace(2.0, 3.5, H_SIZE) - 0.5j * numpy.arange(H_SIZE, dtype=float),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def build_overlap_fixture() -> tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray, numpy.ndarray]:
|
|
||||||
e_in = numpy.zeros((3, *SHAPE), dtype=complex)
|
|
||||||
h_in = numpy.zeros_like(e_in)
|
|
||||||
e_out = numpy.zeros_like(e_in)
|
|
||||||
h_out = numpy.zeros_like(e_in)
|
|
||||||
|
|
||||||
e_in[1] = 1.0
|
|
||||||
h_in[2] = 2.0
|
|
||||||
e_out[1] = 3.0
|
|
||||||
h_out[2] = 4.0
|
|
||||||
return e_in, h_in, e_out, h_out
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
import numpy
|
|
||||||
|
|
||||||
from ..fdmath import vec, unvec
|
|
||||||
|
|
||||||
|
|
||||||
OMEGA = 1 / 1500
|
|
||||||
SHAPE = (2, 3, 2)
|
|
||||||
DXES = [
|
|
||||||
[numpy.array([1.0, 1.5]), numpy.array([0.75, 1.25, 1.5]), numpy.array([1.2, 0.8])],
|
|
||||||
[numpy.array([0.9, 1.4]), numpy.array([0.8, 1.1, 1.4]), numpy.array([1.0, 0.7])],
|
|
||||||
]
|
|
||||||
|
|
||||||
EPSILON = numpy.stack([
|
|
||||||
numpy.linspace(1.0, 2.2, numpy.prod(SHAPE)).reshape(SHAPE),
|
|
||||||
numpy.linspace(1.1, 2.3, numpy.prod(SHAPE)).reshape(SHAPE),
|
|
||||||
numpy.linspace(1.2, 2.4, numpy.prod(SHAPE)).reshape(SHAPE),
|
|
||||||
])
|
|
||||||
MU = numpy.stack([
|
|
||||||
numpy.linspace(2.0, 3.2, numpy.prod(SHAPE)).reshape(SHAPE),
|
|
||||||
numpy.linspace(2.1, 3.3, numpy.prod(SHAPE)).reshape(SHAPE),
|
|
||||||
numpy.linspace(2.2, 3.4, numpy.prod(SHAPE)).reshape(SHAPE),
|
|
||||||
])
|
|
||||||
|
|
||||||
E_FIELD = (numpy.arange(3 * numpy.prod(SHAPE)).reshape((3, *SHAPE)) + 0.5j).astype(complex)
|
|
||||||
H_FIELD = (numpy.arange(3 * numpy.prod(SHAPE)).reshape((3, *SHAPE)) * 0.25 - 0.75j).astype(complex)
|
|
||||||
|
|
||||||
PEC = numpy.zeros((3, *SHAPE), dtype=float)
|
|
||||||
PEC[1, 0, 1, 0] = 1.0
|
|
||||||
PMC = numpy.zeros((3, *SHAPE), dtype=float)
|
|
||||||
PMC[2, 1, 2, 1] = 1.0
|
|
||||||
|
|
||||||
TF_REGION = numpy.zeros((3, *SHAPE), dtype=float)
|
|
||||||
TF_REGION[:, 0, 1, 0] = 1.0
|
|
||||||
|
|
||||||
BOUNDARY_SHAPE = (3, 4, 3)
|
|
||||||
BOUNDARY_DXES = [
|
|
||||||
[numpy.array([1.0, 1.5, 0.8]), numpy.array([0.75, 1.25, 1.5, 0.9]), numpy.array([1.2, 0.8, 1.1])],
|
|
||||||
[numpy.array([0.9, 1.4, 1.0]), numpy.array([0.8, 1.1, 1.4, 1.0]), numpy.array([1.0, 0.7, 1.3])],
|
|
||||||
]
|
|
||||||
BOUNDARY_EPSILON = numpy.stack([
|
|
||||||
numpy.linspace(1.0, 2.2, numpy.prod(BOUNDARY_SHAPE)).reshape(BOUNDARY_SHAPE),
|
|
||||||
numpy.linspace(1.1, 2.3, numpy.prod(BOUNDARY_SHAPE)).reshape(BOUNDARY_SHAPE),
|
|
||||||
numpy.linspace(1.2, 2.4, numpy.prod(BOUNDARY_SHAPE)).reshape(BOUNDARY_SHAPE),
|
|
||||||
])
|
|
||||||
BOUNDARY_FIELD = (numpy.arange(3 * numpy.prod(BOUNDARY_SHAPE)).reshape((3, *BOUNDARY_SHAPE)) + 0.5j).astype(complex)
|
|
||||||
|
|
||||||
|
|
||||||
def apply_fdfd_matrix(op, field: numpy.ndarray, shape: tuple[int, ...] = SHAPE) -> numpy.ndarray:
|
|
||||||
return unvec(op @ vec(field), shape)
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
import dataclasses
|
|
||||||
import numpy
|
|
||||||
from scipy import sparse
|
|
||||||
import scipy.sparse.linalg as spalg
|
|
||||||
|
|
||||||
from ._test_builders import unit_dxes
|
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass(frozen=True)
|
|
||||||
class DiagonalEigenCase:
|
|
||||||
operator: sparse.csr_matrix
|
|
||||||
linear_operator: spalg.LinearOperator
|
|
||||||
guess_default: numpy.ndarray
|
|
||||||
guess_sparse: numpy.ndarray
|
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass(frozen=True)
|
|
||||||
class SolverPlumbingCase:
|
|
||||||
omega: float
|
|
||||||
a0: sparse.csr_matrix
|
|
||||||
pl: sparse.csr_matrix
|
|
||||||
pr: sparse.csr_matrix
|
|
||||||
j: numpy.ndarray
|
|
||||||
guess: numpy.ndarray
|
|
||||||
solver_result: numpy.ndarray
|
|
||||||
dxes: tuple[tuple[numpy.ndarray, ...], tuple[numpy.ndarray, ...]]
|
|
||||||
epsilon: numpy.ndarray
|
|
||||||
|
|
||||||
|
|
||||||
def diagonal_eigen_case() -> DiagonalEigenCase:
|
|
||||||
operator = sparse.diags([5.0, 3.0, 1.0, -2.0]).tocsr()
|
|
||||||
linear_operator = spalg.LinearOperator(
|
|
||||||
shape=operator.shape,
|
|
||||||
dtype=complex,
|
|
||||||
matvec=lambda vv: operator @ vv,
|
|
||||||
)
|
|
||||||
return DiagonalEigenCase(
|
|
||||||
operator=operator,
|
|
||||||
linear_operator=linear_operator,
|
|
||||||
guess_default=numpy.array([0.0, 1.0, 1e-6, 0.0], dtype=complex),
|
|
||||||
guess_sparse=numpy.array([1.0, 0.1, 0.0, 0.0], dtype=complex),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def solver_plumbing_case() -> SolverPlumbingCase:
|
|
||||||
return SolverPlumbingCase(
|
|
||||||
omega=2.0,
|
|
||||||
a0=sparse.csr_matrix(numpy.array([[1.0 + 2.0j, 2.0], [3.0 - 1.0j, 4.0]])),
|
|
||||||
pl=sparse.csr_matrix(numpy.array([[2.0, 0.0], [0.0, 3.0j]])),
|
|
||||||
pr=sparse.csr_matrix(numpy.array([[0.5, 0.0], [0.0, -2.0j]])),
|
|
||||||
j=numpy.array([1.0 + 0.5j, -2.0]),
|
|
||||||
guess=numpy.array([0.25 - 0.75j, 1.5 + 0.5j]),
|
|
||||||
solver_result=numpy.array([3.0 - 1.0j, -4.0 + 2.0j]),
|
|
||||||
dxes=unit_dxes((1, 1, 1)),
|
|
||||||
epsilon=numpy.ones(2),
|
|
||||||
)
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import numpy
|
|
||||||
|
|
||||||
|
|
||||||
def real_ramp(shape: tuple[int, ...], *, scale: float = 1.0, offset: float = 0.0) -> numpy.ndarray:
|
|
||||||
return numpy.arange(numpy.prod(shape), dtype=float).reshape(shape, order='C') * scale + offset
|
|
||||||
|
|
||||||
|
|
||||||
def complex_ramp(
|
|
||||||
shape: tuple[int, ...],
|
|
||||||
*,
|
|
||||||
scale: float = 1.0,
|
|
||||||
offset: float = 0.0,
|
|
||||||
imag_scale: float = 0.0,
|
|
||||||
imag_offset: float = 0.0,
|
|
||||||
) -> numpy.ndarray:
|
|
||||||
real = real_ramp(shape, scale=scale, offset=offset)
|
|
||||||
imag = real_ramp(shape, scale=imag_scale, offset=imag_offset)
|
|
||||||
return (real + 1j * imag).astype(complex)
|
|
||||||
|
|
||||||
|
|
||||||
def unit_dxes(shape: tuple[int, ...]) -> tuple[tuple[numpy.ndarray, ...], tuple[numpy.ndarray, ...]]:
|
|
||||||
return tuple(tuple(numpy.ones(length) for length in shape) for _ in range(2)) # type: ignore[return-value]
|
|
||||||
|
|
@ -3,13 +3,12 @@
|
||||||
Test fixtures
|
Test fixtures
|
||||||
|
|
||||||
"""
|
"""
|
||||||
# ruff: noqa: ARG001
|
from typing import Iterable, Any
|
||||||
from typing import Any
|
|
||||||
import numpy
|
import numpy
|
||||||
from numpy.typing import NDArray
|
from numpy.typing import NDArray
|
||||||
import pytest # type: ignore
|
import pytest # type: ignore
|
||||||
|
|
||||||
from .utils import make_prng
|
from .utils import PRNG
|
||||||
|
|
||||||
|
|
||||||
FixtureRequest = Any
|
FixtureRequest = Any
|
||||||
|
|
@ -21,18 +20,18 @@ FixtureRequest = Any
|
||||||
(5, 5, 5),
|
(5, 5, 5),
|
||||||
# (7, 7, 7),
|
# (7, 7, 7),
|
||||||
])
|
])
|
||||||
def shape(request: FixtureRequest) -> tuple[int, ...]:
|
def shape(request: FixtureRequest) -> Iterable[tuple[int, ...]]:
|
||||||
return (3, *request.param)
|
yield (3, *request.param)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope='module', params=[1.0, 1.5])
|
@pytest.fixture(scope='module', params=[1.0, 1.5])
|
||||||
def epsilon_bg(request: FixtureRequest) -> float:
|
def epsilon_bg(request: FixtureRequest) -> Iterable[float]:
|
||||||
return request.param
|
yield request.param
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope='module', params=[1.0, 2.5])
|
@pytest.fixture(scope='module', params=[1.0, 2.5])
|
||||||
def epsilon_fg(request: FixtureRequest) -> float:
|
def epsilon_fg(request: FixtureRequest) -> Iterable[float]:
|
||||||
return request.param
|
yield request.param
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope='module', params=['center', '000', 'random'])
|
@pytest.fixture(scope='module', params=['center', '000', 'random'])
|
||||||
|
|
@ -41,8 +40,7 @@ def epsilon(
|
||||||
shape: tuple[int, ...],
|
shape: tuple[int, ...],
|
||||||
epsilon_bg: float,
|
epsilon_bg: float,
|
||||||
epsilon_fg: float,
|
epsilon_fg: float,
|
||||||
) -> NDArray[numpy.float64]:
|
) -> Iterable[NDArray[numpy.float64]]:
|
||||||
prng = make_prng()
|
|
||||||
is3d = (numpy.array(shape) == 1).sum() == 0
|
is3d = (numpy.array(shape) == 1).sum() == 0
|
||||||
if is3d:
|
if is3d:
|
||||||
if request.param == '000':
|
if request.param == '000':
|
||||||
|
|
@ -58,23 +56,21 @@ def epsilon(
|
||||||
elif request.param == '000':
|
elif request.param == '000':
|
||||||
epsilon[:, 0, 0, 0] = epsilon_fg
|
epsilon[:, 0, 0, 0] = epsilon_fg
|
||||||
elif request.param == 'random':
|
elif request.param == 'random':
|
||||||
epsilon[:] = prng.uniform(
|
epsilon[:] = PRNG.uniform(low=min(epsilon_bg, epsilon_fg),
|
||||||
low=min(epsilon_bg, epsilon_fg),
|
high=max(epsilon_bg, epsilon_fg),
|
||||||
high=max(epsilon_bg, epsilon_fg),
|
size=shape)
|
||||||
size=shape,
|
|
||||||
)
|
|
||||||
|
|
||||||
return epsilon
|
yield epsilon
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope='module', params=[1.0]) # 1.5
|
@pytest.fixture(scope='module', params=[1.0]) # 1.5
|
||||||
def j_mag(request: FixtureRequest) -> float:
|
def j_mag(request: FixtureRequest) -> Iterable[float]:
|
||||||
return request.param
|
yield request.param
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope='module', params=[1.0, 1.5])
|
@pytest.fixture(scope='module', params=[1.0, 1.5])
|
||||||
def dx(request: FixtureRequest) -> float:
|
def dx(request: FixtureRequest) -> Iterable[float]:
|
||||||
return request.param
|
yield request.param
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope='module', params=['uniform', 'centerbig'])
|
@pytest.fixture(scope='module', params=['uniform', 'centerbig'])
|
||||||
|
|
@ -82,8 +78,7 @@ def dxes(
|
||||||
request: FixtureRequest,
|
request: FixtureRequest,
|
||||||
shape: tuple[int, ...],
|
shape: tuple[int, ...],
|
||||||
dx: float,
|
dx: float,
|
||||||
) -> list[list[NDArray[numpy.float64]]]:
|
) -> Iterable[list[list[NDArray[numpy.float64]]]]:
|
||||||
prng = make_prng()
|
|
||||||
if request.param == 'uniform':
|
if request.param == 'uniform':
|
||||||
dxes = [[numpy.full(s, dx) for s in shape[1:]] for _ in range(2)]
|
dxes = [[numpy.full(s, dx) for s in shape[1:]] for _ in range(2)]
|
||||||
elif request.param == 'centerbig':
|
elif request.param == 'centerbig':
|
||||||
|
|
@ -92,7 +87,8 @@ def dxes(
|
||||||
for ax in (0, 1, 2):
|
for ax in (0, 1, 2):
|
||||||
dxes[eh][ax][dxes[eh][ax].size // 2] *= 1.1
|
dxes[eh][ax][dxes[eh][ax].size // 2] *= 1.1
|
||||||
elif request.param == 'random':
|
elif request.param == 'random':
|
||||||
dxe = [prng.uniform(low=1.0 * dx, high=1.1 * dx, size=s) for s in shape[1:]]
|
dxe = [PRNG.uniform(low=1.0 * dx, high=1.1 * dx, size=s) for s in shape[1:]]
|
||||||
dxh = [(d + numpy.roll(d, -1)) / 2 for d in dxe]
|
dxh = [(d + numpy.roll(d, -1)) / 2 for d in dxe]
|
||||||
dxes = [dxe, dxh]
|
dxes = [dxe, dxh]
|
||||||
return dxes
|
yield dxes
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
import numpy
|
|
||||||
from numpy.linalg import norm
|
|
||||||
|
|
||||||
from ..fdfd import bloch
|
|
||||||
from ._bloch_case import EPSILON, G_MATRIX, H_MN, K0_AXIAL, K0_GENERAL, MU, SHAPE, ZERO_H_MN
|
|
||||||
from .utils import assert_close
|
|
||||||
|
|
||||||
def test_generate_kmn_general_case_returns_orthonormal_basis() -> None:
|
|
||||||
k_mag, m_vecs, n_vecs = bloch.generate_kmn(K0_GENERAL, G_MATRIX, SHAPE)
|
|
||||||
|
|
||||||
assert k_mag.shape == SHAPE + (1,)
|
|
||||||
assert m_vecs.shape == SHAPE + (3,)
|
|
||||||
assert n_vecs.shape == SHAPE + (3,)
|
|
||||||
assert numpy.isfinite(k_mag).all()
|
|
||||||
assert numpy.isfinite(m_vecs).all()
|
|
||||||
assert numpy.isfinite(n_vecs).all()
|
|
||||||
|
|
||||||
assert_close(norm(m_vecs.reshape(-1, 3), axis=1), 1.0)
|
|
||||||
assert_close(norm(n_vecs.reshape(-1, 3), axis=1), 1.0)
|
|
||||||
assert_close(numpy.sum(m_vecs * n_vecs, axis=3), 0.0, atol=1e-12)
|
|
||||||
|
|
||||||
|
|
||||||
def test_generate_kmn_z_aligned_uses_default_transverse_basis() -> None:
|
|
||||||
k_mag, m_vecs, n_vecs = bloch.generate_kmn(K0_AXIAL, G_MATRIX, (1, 1, 1))
|
|
||||||
|
|
||||||
assert numpy.isfinite(k_mag).all()
|
|
||||||
assert_close(m_vecs[0, 0, 0], [0.0, 1.0, 0.0])
|
|
||||||
assert_close(numpy.sum(m_vecs * n_vecs, axis=3), 0.0, atol=1e-12)
|
|
||||||
assert_close(norm(n_vecs.reshape(-1, 3), axis=1), 1.0)
|
|
||||||
|
|
||||||
|
|
||||||
def test_maxwell_operator_returns_finite_column_vector_without_mu() -> None:
|
|
||||||
operator = bloch.maxwell_operator(K0_GENERAL, G_MATRIX, EPSILON)
|
|
||||||
|
|
||||||
result = operator(H_MN.copy())
|
|
||||||
zero_result = operator(ZERO_H_MN.copy())
|
|
||||||
|
|
||||||
assert result.shape == (2 * numpy.prod(SHAPE), 1)
|
|
||||||
assert numpy.isfinite(result).all()
|
|
||||||
assert_close(zero_result, 0.0)
|
|
||||||
|
|
||||||
|
|
||||||
def test_maxwell_operator_returns_finite_column_vector_with_mu() -> None:
|
|
||||||
operator = bloch.maxwell_operator(K0_GENERAL, G_MATRIX, EPSILON, MU)
|
|
||||||
|
|
||||||
result = operator(H_MN.copy())
|
|
||||||
zero_result = operator(ZERO_H_MN.copy())
|
|
||||||
|
|
||||||
assert result.shape == (2 * numpy.prod(SHAPE), 1)
|
|
||||||
assert numpy.isfinite(result).all()
|
|
||||||
assert_close(zero_result, 0.0)
|
|
||||||
|
|
||||||
|
|
||||||
def test_inverse_maxwell_operator_returns_finite_column_vector_for_both_mu_branches() -> None:
|
|
||||||
for mu in (None, MU):
|
|
||||||
operator = bloch.inverse_maxwell_operator_approx(K0_GENERAL, G_MATRIX, EPSILON, mu)
|
|
||||||
|
|
||||||
result = operator(H_MN.copy())
|
|
||||||
zero_result = operator(ZERO_H_MN.copy())
|
|
||||||
|
|
||||||
assert result.shape == (2 * numpy.prod(SHAPE), 1)
|
|
||||||
assert numpy.isfinite(result).all()
|
|
||||||
assert_close(zero_result, 0.0)
|
|
||||||
|
|
||||||
|
|
||||||
def test_bloch_field_converters_return_finite_fields() -> None:
|
|
||||||
e_field = bloch.hmn_2_exyz(K0_GENERAL, G_MATRIX, EPSILON)(H_MN.copy())
|
|
||||||
h_field = bloch.hmn_2_hxyz(K0_GENERAL, G_MATRIX, EPSILON)(H_MN.copy())
|
|
||||||
|
|
||||||
assert e_field.shape == (3, *SHAPE)
|
|
||||||
assert h_field.shape == (3, *SHAPE)
|
|
||||||
assert numpy.isfinite(e_field).all()
|
|
||||||
assert numpy.isfinite(h_field).all()
|
|
||||||
|
|
@ -1,315 +0,0 @@
|
||||||
import numpy
|
|
||||||
import pytest
|
|
||||||
from numpy.testing import assert_allclose
|
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
from ..fdfd import bloch
|
|
||||||
from ._bloch_case import EPSILON, G_MATRIX, H_SIZE, K0_X, Y0, Y0_TWO_MODE, build_overlap_fixture
|
|
||||||
from .utils import assert_close
|
|
||||||
|
|
||||||
|
|
||||||
def test_rtrace_atb_matches_real_frobenius_inner_product() -> None:
|
|
||||||
a_mat = numpy.array([[1.0 + 2.0j, 3.0 - 1.0j], [2.0j, 4.0]], dtype=complex)
|
|
||||||
b_mat = numpy.array([[5.0 - 1.0j, 1.0 + 1.0j], [2.0, 3.0j]], dtype=complex)
|
|
||||||
expected = numpy.real(numpy.sum(a_mat.conj() * b_mat))
|
|
||||||
|
|
||||||
assert bloch._rtrace_AtB(a_mat, b_mat) == expected
|
|
||||||
|
|
||||||
|
|
||||||
def test_symmetrize_returns_hermitian_average() -> None:
|
|
||||||
matrix = numpy.array([[1.0 + 2.0j, 3.0 - 1.0j], [2.0j, 4.0]], dtype=complex)
|
|
||||||
result = bloch._symmetrize(matrix)
|
|
||||||
|
|
||||||
assert_close(result, 0.5 * (matrix + matrix.conj().T))
|
|
||||||
assert_close(result, result.conj().T)
|
|
||||||
|
|
||||||
|
|
||||||
def test_inner_product_is_nonmutating_and_obeys_sign_symmetry() -> None:
|
|
||||||
e_in, h_in, e_out, h_out = build_overlap_fixture()
|
|
||||||
originals = (e_in.copy(), h_in.copy(), e_out.copy(), h_out.copy())
|
|
||||||
|
|
||||||
pp = bloch.inner_product(e_out, h_out, e_in, h_in)
|
|
||||||
pn = bloch.inner_product(e_out, h_out, e_in, -h_in)
|
|
||||||
np_term = bloch.inner_product(e_out, -h_out, e_in, h_in)
|
|
||||||
nn = bloch.inner_product(e_out, -h_out, e_in, -h_in)
|
|
||||||
|
|
||||||
assert_close(pp, 0.8164965809277263 + 0.0j)
|
|
||||||
assert_close(pp, -nn, atol=1e-12, rtol=1e-12)
|
|
||||||
assert_close(pn, -np_term, atol=1e-12, rtol=1e-12)
|
|
||||||
assert numpy.array_equal(e_in, originals[0])
|
|
||||||
assert numpy.array_equal(h_in, originals[1])
|
|
||||||
assert numpy.array_equal(e_out, originals[2])
|
|
||||||
assert numpy.array_equal(h_out, originals[3])
|
|
||||||
|
|
||||||
|
|
||||||
def test_trq_returns_expected_transmission_and_reflection() -> None:
|
|
||||||
e_in, h_in, e_out, h_out = build_overlap_fixture()
|
|
||||||
|
|
||||||
transmission, reflection = bloch.trq(e_in, h_in, e_out, h_out)
|
|
||||||
|
|
||||||
assert_close(transmission, 0.9797958971132713 + 0.0j, atol=1e-12, rtol=1e-12)
|
|
||||||
assert_close(reflection, 0.2 + 0.0j, atol=1e-12, rtol=1e-12)
|
|
||||||
|
|
||||||
|
|
||||||
def test_eigsolve_returns_finite_modes_with_small_residual() -> None:
|
|
||||||
callback_count = 0
|
|
||||||
|
|
||||||
def callback() -> None:
|
|
||||||
nonlocal callback_count
|
|
||||||
callback_count += 1
|
|
||||||
|
|
||||||
eigvals, eigvecs = bloch.eigsolve(
|
|
||||||
1,
|
|
||||||
K0_X,
|
|
||||||
G_MATRIX,
|
|
||||||
EPSILON,
|
|
||||||
tolerance=1e-6,
|
|
||||||
max_iters=50,
|
|
||||||
y0=Y0,
|
|
||||||
callback=callback,
|
|
||||||
)
|
|
||||||
|
|
||||||
operator = bloch.maxwell_operator(K0_X, G_MATRIX, EPSILON)
|
|
||||||
eigvec = eigvecs[0] / numpy.linalg.norm(eigvecs[0])
|
|
||||||
residual = numpy.linalg.norm(operator(eigvec).reshape(-1) - eigvals[0] * eigvec) / numpy.linalg.norm(eigvec)
|
|
||||||
|
|
||||||
assert eigvals.shape == (1,)
|
|
||||||
assert eigvecs.shape == (1, H_SIZE)
|
|
||||||
assert numpy.isfinite(eigvals).all()
|
|
||||||
assert numpy.isfinite(eigvecs).all()
|
|
||||||
assert residual < 1e-5
|
|
||||||
assert callback_count > 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_eigsolve_without_initial_guess_returns_finite_modes() -> None:
|
|
||||||
eigvals, eigvecs = bloch.eigsolve(
|
|
||||||
1,
|
|
||||||
K0_X,
|
|
||||||
G_MATRIX,
|
|
||||||
EPSILON,
|
|
||||||
tolerance=1e-6,
|
|
||||||
max_iters=20,
|
|
||||||
y0=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
operator = bloch.maxwell_operator(K0_X, G_MATRIX, EPSILON)
|
|
||||||
eigvec = eigvecs[0] / numpy.linalg.norm(eigvecs[0])
|
|
||||||
residual = numpy.linalg.norm(operator(eigvec).reshape(-1) - eigvals[0] * eigvec) / numpy.linalg.norm(eigvec)
|
|
||||||
|
|
||||||
assert eigvals.shape == (1,)
|
|
||||||
assert eigvecs.shape == (1, H_SIZE)
|
|
||||||
assert numpy.isfinite(eigvals).all()
|
|
||||||
assert numpy.isfinite(eigvecs).all()
|
|
||||||
assert residual < 1e-5
|
|
||||||
|
|
||||||
|
|
||||||
def test_eigsolve_recovers_from_singular_initial_subspace(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
class FakeRng:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.calls = 0
|
|
||||||
|
|
||||||
def random(self, shape: tuple[int, ...]) -> numpy.ndarray:
|
|
||||||
self.calls += 1
|
|
||||||
return numpy.arange(numpy.prod(shape), dtype=float).reshape(shape) + self.calls
|
|
||||||
|
|
||||||
fake_rng = FakeRng()
|
|
||||||
singular_y0 = numpy.vstack([Y0_TWO_MODE[0], Y0_TWO_MODE[0]])
|
|
||||||
monkeypatch.setattr(bloch.numpy.random, 'default_rng', lambda: fake_rng)
|
|
||||||
|
|
||||||
eigvals, eigvecs = bloch.eigsolve(
|
|
||||||
2,
|
|
||||||
K0_X,
|
|
||||||
G_MATRIX,
|
|
||||||
EPSILON,
|
|
||||||
tolerance=1e-6,
|
|
||||||
max_iters=20,
|
|
||||||
y0=singular_y0,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert fake_rng.calls == 2
|
|
||||||
assert eigvals.shape == (2,)
|
|
||||||
assert eigvecs.shape == (2, H_SIZE)
|
|
||||||
assert numpy.isfinite(eigvals).all()
|
|
||||||
assert numpy.isfinite(eigvecs).all()
|
|
||||||
|
|
||||||
|
|
||||||
def test_eigsolve_reconditions_large_trace_initial_subspace(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
original_inv = bloch.numpy.linalg.inv
|
|
||||||
original_sqrtm = bloch.scipy.linalg.sqrtm
|
|
||||||
sqrtm_calls = 0
|
|
||||||
inv_calls = 0
|
|
||||||
|
|
||||||
def inv_with_large_first_trace(matrix: numpy.ndarray) -> numpy.ndarray:
|
|
||||||
nonlocal inv_calls
|
|
||||||
inv_calls += 1
|
|
||||||
if inv_calls == 1:
|
|
||||||
return numpy.eye(matrix.shape[0], dtype=complex) * 1e9
|
|
||||||
return original_inv(matrix)
|
|
||||||
|
|
||||||
def sqrtm_wrapper(matrix: numpy.ndarray) -> numpy.ndarray:
|
|
||||||
nonlocal sqrtm_calls
|
|
||||||
sqrtm_calls += 1
|
|
||||||
return original_sqrtm(matrix)
|
|
||||||
|
|
||||||
monkeypatch.setattr(bloch.numpy.linalg, 'inv', inv_with_large_first_trace)
|
|
||||||
monkeypatch.setattr(bloch.scipy.linalg, 'sqrtm', sqrtm_wrapper)
|
|
||||||
|
|
||||||
eigvals, eigvecs = bloch.eigsolve(
|
|
||||||
2,
|
|
||||||
K0_X,
|
|
||||||
G_MATRIX,
|
|
||||||
EPSILON,
|
|
||||||
tolerance=1e-6,
|
|
||||||
max_iters=20,
|
|
||||||
y0=Y0_TWO_MODE,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert sqrtm_calls >= 2
|
|
||||||
assert eigvals.shape == (2,)
|
|
||||||
assert eigvecs.shape == (2, H_SIZE)
|
|
||||||
assert numpy.isfinite(eigvals).all()
|
|
||||||
assert numpy.isfinite(eigvecs).all()
|
|
||||||
|
|
||||||
|
|
||||||
def test_eigsolve_qi_memoization_reuses_cached_theta(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
def fake_minimize_scalar(func, method: str, bounds: tuple[float, float], options: dict[str, float]) -> SimpleNamespace:
|
|
||||||
theta = 0.3
|
|
||||||
first = func(theta)
|
|
||||||
second = func(theta)
|
|
||||||
assert_allclose(second, first)
|
|
||||||
return SimpleNamespace(fun=second, x=theta)
|
|
||||||
|
|
||||||
monkeypatch.setattr(bloch.scipy.optimize, 'minimize_scalar', fake_minimize_scalar)
|
|
||||||
|
|
||||||
eigvals, eigvecs = bloch.eigsolve(
|
|
||||||
1,
|
|
||||||
K0_X,
|
|
||||||
G_MATRIX,
|
|
||||||
EPSILON,
|
|
||||||
tolerance=1e-6,
|
|
||||||
max_iters=1,
|
|
||||||
y0=Y0,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert eigvals.shape == (1,)
|
|
||||||
assert eigvecs.shape == (1, H_SIZE)
|
|
||||||
assert numpy.isfinite(eigvals).all()
|
|
||||||
assert numpy.isfinite(eigvecs).all()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('theta', [numpy.pi / 2 - 1e-8, 1e-8])
|
|
||||||
def test_eigsolve_qi_taylor_expansions_return_finite_modes(monkeypatch: pytest.MonkeyPatch, theta: float) -> None:
|
|
||||||
original_inv = bloch.numpy.linalg.inv
|
|
||||||
inv_calls = 0
|
|
||||||
|
|
||||||
def inv_raise_once_for_q(matrix: numpy.ndarray) -> numpy.ndarray:
|
|
||||||
nonlocal inv_calls
|
|
||||||
inv_calls += 1
|
|
||||||
if inv_calls == 3:
|
|
||||||
raise numpy.linalg.LinAlgError('forced singular Q')
|
|
||||||
return original_inv(matrix)
|
|
||||||
|
|
||||||
def fake_minimize_scalar(func, method: str, bounds: tuple[float, float], options: dict[str, float]) -> SimpleNamespace:
|
|
||||||
value = func(theta)
|
|
||||||
return SimpleNamespace(fun=value, x=theta)
|
|
||||||
|
|
||||||
monkeypatch.setattr(bloch.numpy.linalg, 'inv', inv_raise_once_for_q)
|
|
||||||
monkeypatch.setattr(bloch.scipy.optimize, 'minimize_scalar', fake_minimize_scalar)
|
|
||||||
|
|
||||||
eigvals, eigvecs = bloch.eigsolve(
|
|
||||||
1,
|
|
||||||
K0_X,
|
|
||||||
G_MATRIX,
|
|
||||||
EPSILON,
|
|
||||||
tolerance=1e-6,
|
|
||||||
max_iters=1,
|
|
||||||
y0=Y0,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert eigvals.shape == (1,)
|
|
||||||
assert eigvecs.shape == (1, H_SIZE)
|
|
||||||
assert numpy.isfinite(eigvals).all()
|
|
||||||
assert numpy.isfinite(eigvecs).all()
|
|
||||||
|
|
||||||
|
|
||||||
def test_eigsolve_qi_inexplicable_singularity_raises(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
original_inv = bloch.numpy.linalg.inv
|
|
||||||
inv_calls = 0
|
|
||||||
|
|
||||||
def inv_raise_once_for_q(matrix: numpy.ndarray) -> numpy.ndarray:
|
|
||||||
nonlocal inv_calls
|
|
||||||
inv_calls += 1
|
|
||||||
if inv_calls == 3:
|
|
||||||
raise numpy.linalg.LinAlgError('forced singular Q')
|
|
||||||
return original_inv(matrix)
|
|
||||||
|
|
||||||
def fake_minimize_scalar(func, method: str, bounds: tuple[float, float], options: dict[str, float]) -> SimpleNamespace:
|
|
||||||
func(numpy.pi / 4)
|
|
||||||
raise AssertionError('unreachable after trace_func exception')
|
|
||||||
|
|
||||||
monkeypatch.setattr(bloch.numpy.linalg, 'inv', inv_raise_once_for_q)
|
|
||||||
monkeypatch.setattr(bloch.scipy.optimize, 'minimize_scalar', fake_minimize_scalar)
|
|
||||||
|
|
||||||
with pytest.raises(Exception, match='Inexplicable singularity in trace_func'):
|
|
||||||
bloch.eigsolve(
|
|
||||||
1,
|
|
||||||
K0_X,
|
|
||||||
G_MATRIX,
|
|
||||||
EPSILON,
|
|
||||||
tolerance=1e-6,
|
|
||||||
max_iters=1,
|
|
||||||
y0=Y0,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_find_k_returns_vector_frequency_and_callbacks() -> None:
|
|
||||||
target_eigvals, _target_eigvecs = bloch.eigsolve(
|
|
||||||
1,
|
|
||||||
K0_X,
|
|
||||||
G_MATRIX,
|
|
||||||
EPSILON,
|
|
||||||
tolerance=1e-6,
|
|
||||||
max_iters=50,
|
|
||||||
y0=Y0,
|
|
||||||
)
|
|
||||||
target_frequency = float(numpy.sqrt(abs(numpy.real(target_eigvals[0]))))
|
|
||||||
|
|
||||||
solve_calls = 0
|
|
||||||
iter_calls = 0
|
|
||||||
|
|
||||||
def solve_callback(k_mag: float, eigvals: numpy.ndarray, eigvecs: numpy.ndarray, frequency: float) -> None:
|
|
||||||
nonlocal solve_calls
|
|
||||||
solve_calls += 1
|
|
||||||
assert eigvals.shape == (1,)
|
|
||||||
assert eigvecs.shape == (1, H_SIZE)
|
|
||||||
assert isinstance(k_mag, float)
|
|
||||||
assert isinstance(frequency, float)
|
|
||||||
|
|
||||||
def iter_callback() -> None:
|
|
||||||
nonlocal iter_calls
|
|
||||||
iter_calls += 1
|
|
||||||
|
|
||||||
found_k, found_frequency, eigvals, eigvecs = bloch.find_k(
|
|
||||||
target_frequency,
|
|
||||||
1e-4,
|
|
||||||
[1, 0, 0],
|
|
||||||
G_MATRIX,
|
|
||||||
EPSILON,
|
|
||||||
band=0,
|
|
||||||
k_bounds=(0.05, 0.15),
|
|
||||||
v0=Y0,
|
|
||||||
solve_callback=solve_callback,
|
|
||||||
iter_callback=iter_callback,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert found_k.shape == (3,)
|
|
||||||
assert numpy.isfinite(found_k).all()
|
|
||||||
assert_close(numpy.cross(found_k, [1.0, 0.0, 0.0]), 0.0, atol=1e-12, rtol=1e-12)
|
|
||||||
assert_close(found_k, K0_X, atol=1e-4, rtol=1e-4)
|
|
||||||
assert abs(found_frequency - target_frequency) <= 1e-4
|
|
||||||
assert eigvals.shape == (1,)
|
|
||||||
assert eigvecs.shape == (1, H_SIZE)
|
|
||||||
assert numpy.isfinite(eigvals).all()
|
|
||||||
assert numpy.isfinite(eigvecs).all()
|
|
||||||
assert solve_calls > 0
|
|
||||||
assert iter_calls > 0
|
|
||||||
|
|
@ -1,126 +0,0 @@
|
||||||
import numpy
|
|
||||||
from numpy.linalg import norm
|
|
||||||
import pytest
|
|
||||||
from scipy import sparse
|
|
||||||
|
|
||||||
from ._solver_cases import diagonal_eigen_case
|
|
||||||
from .utils import assert_close
|
|
||||||
from ..eigensolvers import power_iteration, rayleigh_quotient_iteration, scipy_eigs_near, 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)
|
|
||||||
|
|
||||||
|
|
||||||
def test_scipy_eigs_near_returns_paired_bilinear_left_vectors() -> None:
|
|
||||||
operator = sparse.csc_array(numpy.diag([1.0 + 0.1j, 2.0 - 0.2j, 4.0 + 0.3j, 7.0]))
|
|
||||||
|
|
||||||
solution = scipy_eigs_near(operator, target=3.1, how_many=2)
|
|
||||||
|
|
||||||
assert_close(numpy.sort_complex(solution.eigenvalues), numpy.sort_complex([2.0 - 0.2j, 4.0 + 0.3j]))
|
|
||||||
gram = solution.left_eigenvectors.T @ solution.right_eigenvectors
|
|
||||||
assert numpy.linalg.cond(gram) < 2
|
|
||||||
for index, eigenvalue in enumerate(solution.eigenvalues):
|
|
||||||
right = solution.right_eigenvectors[:, index]
|
|
||||||
left = solution.left_eigenvectors[:, index]
|
|
||||||
assert norm(operator @ right - eigenvalue * right) < 1e-10
|
|
||||||
assert norm(operator.T @ left - eigenvalue * left) < 1e-10
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('parameter', ['tolerance', 'pairing_rtol'])
|
|
||||||
@pytest.mark.parametrize('invalid', [numpy.nan, numpy.inf, -numpy.inf])
|
|
||||||
def test_scipy_eigs_near_rejects_nonfinite_tolerances(parameter: str, invalid: float) -> None:
|
|
||||||
operator = sparse.csc_array(numpy.diag([1.0, 2.0, 3.0, 4.0]))
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match=rf'{parameter} must be finite and positive'):
|
|
||||||
scipy_eigs_near(operator, target=2.1, how_many=1, **{parameter: invalid})
|
|
||||||
|
|
@ -1,868 +0,0 @@
|
||||||
from typing import cast
|
|
||||||
|
|
||||||
import numpy
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from ..fdmath import vec
|
|
||||||
from ..fdfd import eme, waveguide_2d, waveguide_3d, 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
|
|
||||||
FDFD_OMEGA = 2 * numpy.pi / 16
|
|
||||||
REAL_DXES = unit_dxes((5, 5))
|
|
||||||
FDFD_SHAPE_2D = (7, 7)
|
|
||||||
FDFD_DXES_2D = unit_dxes(FDFD_SHAPE_2D)
|
|
||||||
|
|
||||||
|
|
||||||
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 _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_coordinate_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()
|
|
||||||
# These arbitrary fields are not power-normalized: coefficients may exceed one.
|
|
||||||
assert singular_values.max() > 1.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_tr_accepts_scaled_dual_projection_modes() -> None:
|
|
||||||
left_modes, right_modes = _mode_sets()
|
|
||||||
dual_left_modes = [
|
|
||||||
(mode[0] * (0.5 + 0.25j), mode[1] * (0.5 + 0.25j))
|
|
||||||
for mode in left_modes
|
|
||||||
]
|
|
||||||
|
|
||||||
plain_t, plain_r = eme.get_tr(left_modes, WAVENUMBERS_L, right_modes, WAVENUMBERS_R, dxes=DXES)
|
|
||||||
dual_t, dual_r = eme.get_tr(
|
|
||||||
left_modes,
|
|
||||||
WAVENUMBERS_L,
|
|
||||||
right_modes,
|
|
||||||
WAVENUMBERS_R,
|
|
||||||
dxes=DXES,
|
|
||||||
dual_ehLs=dual_left_modes,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert_close(dual_t, plain_t)
|
|
||||||
assert_close(dual_r, plain_r, atol=1e-11)
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_abcd_maps_left_state_to_right_state() -> None:
|
|
||||||
left_modes, right_modes = _mode_sets()
|
|
||||||
ss = eme.get_s(left_modes, WAVENUMBERS_L, right_modes, WAVENUMBERS_R, DXES)
|
|
||||||
transfer = eme.get_abcd(left_modes, WAVENUMBERS_L, right_modes, WAVENUMBERS_R, DXES).toarray()
|
|
||||||
incoming = numpy.array([1.0, .3j, -.4j, .2])
|
|
||||||
outgoing = ss @ incoming
|
|
||||||
state_left = numpy.concatenate((incoming[:2], outgoing[:2]))
|
|
||||||
state_right = numpy.concatenate((outgoing[2:], incoming[2:]))
|
|
||||||
assert_close(transfer @ state_left, state_right, atol=1e-11)
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_s_left_incidence_matches_get_tr() -> None:
|
|
||||||
left_modes, right_modes = _mode_sets()
|
|
||||||
transmission, reflection = eme.get_tr(left_modes, WAVENUMBERS_L, right_modes, WAVENUMBERS_R, DXES)
|
|
||||||
ss = eme.get_s(left_modes, WAVENUMBERS_L, right_modes, WAVENUMBERS_R, DXES)
|
|
||||||
assert ss.shape == (4, 4)
|
|
||||||
assert_close(ss[:2, :2], reflection, atol=1e-11)
|
|
||||||
assert_close(ss[2:, :2], transmission, atol=1e-11)
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_s_force_nogain_caps_singular_values(monkeypatch) -> None:
|
|
||||||
raw = numpy.diag([2., .5, 1.2, .3])
|
|
||||||
monkeypatch.setattr(eme, '_interface_s', lambda *args, **kwargs: raw.copy())
|
|
||||||
modes, wavenumbers = _dummy_modes()
|
|
||||||
plain_s = eme.get_s(modes, wavenumbers, modes, wavenumbers, DXES)
|
|
||||||
clipped_s = eme.get_s(modes, wavenumbers, modes, wavenumbers, DXES, force_nogain=True)
|
|
||||||
assert numpy.linalg.svd(plain_s, compute_uv=False).max() > 1
|
|
||||||
assert_close(numpy.linalg.svd(clipped_s, compute_uv=False), [1., 1., .5, .3])
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_s_force_reciprocal_symmetrizes_output(monkeypatch) -> None:
|
|
||||||
raw = numpy.arange(16, dtype=float).reshape(4, 4)
|
|
||||||
monkeypatch.setattr(eme, '_interface_s', lambda *args, **kwargs: raw.copy())
|
|
||||||
modes, wavenumbers = _dummy_modes()
|
|
||||||
ss = eme.get_s(modes, wavenumbers, modes, wavenumbers, DXES, force_reciprocal=True)
|
|
||||||
assert_close(ss, .5 * (raw + raw.T))
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_s_force_nogain_and_reciprocal_returns_finite_output(monkeypatch) -> None:
|
|
||||||
raw = numpy.arange(16, dtype=float).reshape(4, 4)
|
|
||||||
monkeypatch.setattr(eme, '_interface_s', lambda *args, **kwargs: raw.copy())
|
|
||||||
modes, wavenumbers = _dummy_modes()
|
|
||||||
ss = eme.get_s(modes, wavenumbers, modes, wavenumbers, DXES, force_nogain=True, force_reciprocal=True)
|
|
||||||
assert_close(ss, ss.T, atol=1e-14)
|
|
||||||
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 test_get_tr_rejects_dual_mode_length_mismatches() -> None:
|
|
||||||
left_modes, right_modes = _mode_sets()
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match='same length'):
|
|
||||||
eme.get_tr(
|
|
||||||
left_modes,
|
|
||||||
WAVENUMBERS_L,
|
|
||||||
right_modes,
|
|
||||||
WAVENUMBERS_R,
|
|
||||||
dxes=DXES,
|
|
||||||
dual_ehLs=left_modes[:1],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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 test_get_taper_abcd_constant_section_is_phase_only() -> None:
|
|
||||||
mode, beta = _build_uniform_mode(1.5)
|
|
||||||
length = 11.0
|
|
||||||
|
|
||||||
abcd = eme.get_taper_abcd(
|
|
||||||
[
|
|
||||||
eme.ModeSection(0.0, [mode], [beta]),
|
|
||||||
eme.ModeSection(length, [mode], [beta]),
|
|
||||||
],
|
|
||||||
dxes=REAL_DXES,
|
|
||||||
).toarray()
|
|
||||||
|
|
||||||
assert_close(abcd, _propagation_abcd(beta, length), atol=1e-12, rtol=1e-12)
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_taper_s_constant_section_has_no_reflection() -> None:
|
|
||||||
mode, beta = _build_uniform_mode(1.5)
|
|
||||||
length = 11.0
|
|
||||||
phase = numpy.exp(-1j * beta * length)
|
|
||||||
|
|
||||||
ss = eme.get_taper_s(
|
|
||||||
[
|
|
||||||
eme.ModeSection(0.0, [mode], [beta]),
|
|
||||||
eme.ModeSection(length, [mode], [beta]),
|
|
||||||
],
|
|
||||||
dxes=REAL_DXES,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert_close(ss, numpy.array([[0.0, phase], [phase, 0.0]], dtype=complex), atol=1e-12, rtol=1e-12)
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_taper_abcd_is_invariant_to_adjacent_modal_phase() -> None:
|
|
||||||
mode, beta = _build_uniform_mode(1.5)
|
|
||||||
shifted_mode = (mode[0] * numpy.exp(0.73j), mode[1] * numpy.exp(0.73j))
|
|
||||||
length = 11.0
|
|
||||||
base_sections = [
|
|
||||||
eme.ModeSection(0.0, [mode], [beta]),
|
|
||||||
eme.ModeSection(length, [mode], [beta]),
|
|
||||||
]
|
|
||||||
shifted_sections = [
|
|
||||||
eme.ModeSection(0.0, [mode], [beta]),
|
|
||||||
eme.ModeSection(length, [shifted_mode], [beta]),
|
|
||||||
]
|
|
||||||
|
|
||||||
base = eme.get_taper_abcd(base_sections, dxes=REAL_DXES).toarray()
|
|
||||||
shifted = eme.get_taper_abcd(shifted_sections, dxes=REAL_DXES).toarray()
|
|
||||||
|
|
||||||
assert_close(shifted, base, atol=1e-12, rtol=1e-12)
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_taper_abcd_is_invariant_to_modal_phase_across_multiple_sections() -> None:
|
|
||||||
mode, beta = _build_uniform_mode(1.5)
|
|
||||||
mid_length = 5.0
|
|
||||||
length = 11.0
|
|
||||||
base_sections = [
|
|
||||||
eme.ModeSection(0.0, [mode], [beta]),
|
|
||||||
eme.ModeSection(mid_length, [mode], [beta]),
|
|
||||||
eme.ModeSection(length, [mode], [beta]),
|
|
||||||
]
|
|
||||||
shifted_sections = [
|
|
||||||
eme.ModeSection(0.0, [mode], [beta]),
|
|
||||||
eme.ModeSection(mid_length, [(mode[0] * numpy.exp(0.41j), mode[1] * numpy.exp(0.41j))], [beta]),
|
|
||||||
eme.ModeSection(length, [(mode[0] * numpy.exp(-0.28j), mode[1] * numpy.exp(-0.28j))], [beta]),
|
|
||||||
]
|
|
||||||
|
|
||||||
base = eme.get_taper_abcd(base_sections, dxes=REAL_DXES).toarray()
|
|
||||||
shifted = eme.get_taper_abcd(shifted_sections, dxes=REAL_DXES).toarray()
|
|
||||||
|
|
||||||
assert_close(shifted, base, atol=1e-12, rtol=1e-12)
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_taper_accepts_complex_leaky_wavenumber() -> None:
|
|
||||||
mode, beta = _build_uniform_mode(1.5)
|
|
||||||
leaky_beta = beta - 0.02j
|
|
||||||
length = 3.0
|
|
||||||
|
|
||||||
abcd = eme.get_taper_abcd(
|
|
||||||
[
|
|
||||||
eme.ModeSection(0.0, [mode], [leaky_beta]),
|
|
||||||
eme.ModeSection(length, [mode], [leaky_beta]),
|
|
||||||
],
|
|
||||||
dxes=REAL_DXES,
|
|
||||||
).toarray()
|
|
||||||
|
|
||||||
assert_close(abcd, _propagation_abcd(leaky_beta, length), atol=1e-12, rtol=1e-12)
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_taper_uses_supplied_dual_modes_for_phase_tracking() -> None:
|
|
||||||
mode, beta = _build_uniform_mode(1.5)
|
|
||||||
primal_scale = numpy.exp(0.42j)
|
|
||||||
dual_scale = 0.31 * numpy.exp(-0.77j)
|
|
||||||
dual_mode = (mode[0] * dual_scale, mode[1] * dual_scale)
|
|
||||||
shifted_mode = (mode[0] * primal_scale, mode[1] * primal_scale)
|
|
||||||
shifted_dual_mode = (dual_mode[0] * 2.3j, dual_mode[1] * 2.3j)
|
|
||||||
length = 11.0
|
|
||||||
|
|
||||||
base = eme.get_taper_abcd(
|
|
||||||
[
|
|
||||||
eme.ModeSection(0.0, [mode], [beta], dual_modes=[dual_mode]),
|
|
||||||
eme.ModeSection(length, [mode], [beta], dual_modes=[dual_mode]),
|
|
||||||
],
|
|
||||||
dxes=REAL_DXES,
|
|
||||||
).toarray()
|
|
||||||
shifted = eme.get_taper_abcd(
|
|
||||||
[
|
|
||||||
eme.ModeSection(0.0, [mode], [beta], dual_modes=[dual_mode]),
|
|
||||||
eme.ModeSection(length, [shifted_mode], [beta], dual_modes=[shifted_dual_mode]),
|
|
||||||
],
|
|
||||||
dxes=REAL_DXES,
|
|
||||||
).toarray()
|
|
||||||
|
|
||||||
assert_close(shifted, base, atol=1e-12, rtol=1e-12)
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_taper_rejects_nonmonotonic_sections() -> None:
|
|
||||||
mode, beta = _build_uniform_mode(1.5)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match='strictly increasing'):
|
|
||||||
eme.get_taper_abcd(
|
|
||||||
[
|
|
||||||
eme.ModeSection(0.0, [mode], [beta]),
|
|
||||||
eme.ModeSection(0.0, [mode], [beta]),
|
|
||||||
],
|
|
||||||
dxes=REAL_DXES,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_taper_rejects_mode_count_changes() -> None:
|
|
||||||
mode, beta = _build_uniform_mode(1.5)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match='same number of modes'):
|
|
||||||
eme.get_taper_abcd(
|
|
||||||
[
|
|
||||||
eme.ModeSection(0.0, [mode], [beta]),
|
|
||||||
eme.ModeSection(1.0, [mode, mode], [beta, beta]),
|
|
||||||
],
|
|
||||||
dxes=REAL_DXES,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_taper_rejects_dual_mode_count_changes() -> None:
|
|
||||||
mode, beta = _build_uniform_mode(1.5)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match='same length'):
|
|
||||||
eme.get_taper_abcd(
|
|
||||||
[
|
|
||||||
eme.ModeSection(0.0, [mode], [beta], dual_modes=[mode]),
|
|
||||||
eme.ModeSection(1.0, [mode], [beta], dual_modes=[mode, mode]),
|
|
||||||
],
|
|
||||||
dxes=REAL_DXES,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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, t21],
|
|
||||||
[t12, 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 _fdfd_cross_section(core_epsilon: float) -> numpy.ndarray:
|
|
||||||
epsilon = numpy.ones((3, *FDFD_SHAPE_2D), dtype=float)
|
|
||||||
epsilon[:, 2:5, 2:5] = core_epsilon
|
|
||||||
return epsilon
|
|
||||||
|
|
||||||
|
|
||||||
def _small_fdfd_kwargs() -> dict:
|
|
||||||
return {
|
|
||||||
'omega': FDFD_OMEGA,
|
|
||||||
'dxes_2d': FDFD_DXES_2D,
|
|
||||||
'mode_numbers': [0],
|
|
||||||
'pml_cells': 4,
|
|
||||||
'pml_buffer_cells': 2,
|
|
||||||
'source_monitor_cells': 5,
|
|
||||||
'monitor_interface_cells': 5,
|
|
||||||
'matrix_solver_opts': {'atol': 1e-10, 'rtol': 1e-8},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
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_get_s_with_dual_modes_matches_analytic_fresnel_interface() -> None:
|
|
||||||
left_mode, left_beta = _build_uniform_mode(1.0)
|
|
||||||
right_mode, right_beta = _build_uniform_mode(2.0)
|
|
||||||
left_dual = (left_mode[0] * (0.25 + 0.5j), left_mode[1] * (0.25 + 0.5j))
|
|
||||||
right_dual = (right_mode[0] * (-0.75 + 0.125j), right_mode[1] * (-0.75 + 0.125j))
|
|
||||||
|
|
||||||
ss = eme.get_s(
|
|
||||||
[left_mode],
|
|
||||||
[left_beta],
|
|
||||||
[right_mode],
|
|
||||||
[right_beta],
|
|
||||||
dxes=REAL_DXES,
|
|
||||||
dual_ehLs=[left_dual],
|
|
||||||
dual_ehRs=[right_dual],
|
|
||||||
)
|
|
||||||
expected = _expected_interface_s(1.0, 2.0)
|
|
||||||
|
|
||||||
assert_close(ss, expected, atol=1e-6, rtol=1e-6)
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_s_accepts_complex_leaky_wavenumbers_for_abrupt_interface() -> None:
|
|
||||||
mode, beta = _build_uniform_mode(1.5)
|
|
||||||
|
|
||||||
ss = eme.get_s([mode], [beta - 0.02j], [mode], [beta - 0.03j], dxes=REAL_DXES)
|
|
||||||
|
|
||||||
assert_close(ss, numpy.array([[0.0, 1.0], [1.0, 0.0]], dtype=complex), atol=1e-12, rtol=1e-12)
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_s_fdfd_from_2d_ports_identical_guides_are_nearly_transmissive() -> None:
|
|
||||||
epsilon = _fdfd_cross_section(2.25)
|
|
||||||
|
|
||||||
ss = waveguide_3d.get_s_fdfd_from_2d_ports(epsilon, epsilon, **_small_fdfd_kwargs())
|
|
||||||
|
|
||||||
assert ss.shape == (2, 2)
|
|
||||||
assert numpy.isfinite(ss).all()
|
|
||||||
assert abs(ss[0, 0]) < 0.03
|
|
||||||
assert abs(ss[1, 1]) < 0.04
|
|
||||||
assert abs(abs(ss[1, 0]) - 1.0) < 0.02
|
|
||||||
assert abs(abs(ss[0, 1]) - 1.0) < 0.03
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_s_fdfd_from_2d_ports_mismatch_returns_finite_diagnostics() -> None:
|
|
||||||
left = _fdfd_cross_section(2.25)
|
|
||||||
right = _fdfd_cross_section(1.5)
|
|
||||||
|
|
||||||
ss, diagnostics = waveguide_3d.get_s_fdfd_from_2d_ports(left, right, return_diagnostics=True, **_small_fdfd_kwargs())
|
|
||||||
|
|
||||||
assert ss.shape == (2, 2)
|
|
||||||
assert numpy.isfinite(ss).all()
|
|
||||||
assert len(diagnostics['electric_fields']) == 2
|
|
||||||
assert len(diagnostics['magnetic_fields']) == 2
|
|
||||||
assert diagnostics['electric_fields'][0].shape == (3, 33, *FDFD_SHAPE_2D)
|
|
||||||
assert numpy.isfinite(diagnostics['electric_fields'][0]).all()
|
|
||||||
assert numpy.isfinite(diagnostics['magnetic_fields'][0]).all()
|
|
||||||
assert numpy.isfinite(diagnostics['guided_powers']).all()
|
|
||||||
assert numpy.isfinite(diagnostics['unresolved_powers']).all()
|
|
||||||
assert numpy.isfinite(diagnostics['raw_outgoing_amplitudes']).all()
|
|
||||||
assert numpy.isfinite(diagnostics['incident_amplitudes']).all()
|
|
||||||
assert (numpy.abs(diagnostics['incident_amplitudes']) > 1e-3).all()
|
|
||||||
assert_close(diagnostics['unresolved_powers'], 1.0 - diagnostics['guided_powers'])
|
|
||||||
assert diagnostics['guided_powers'][0] < 1.05
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_s_fdfd_normalization_is_source_scale_invariant() -> None:
|
|
||||||
left = _fdfd_cross_section(2.25)
|
|
||||||
right = _fdfd_cross_section(1.5)
|
|
||||||
kwargs = _small_fdfd_kwargs()
|
|
||||||
|
|
||||||
base, base_diagnostics = waveguide_3d.get_s_fdfd_from_2d_ports(left, right, return_diagnostics=True, **kwargs)
|
|
||||||
scaled, scaled_diagnostics = waveguide_3d.get_s_fdfd_from_2d_ports(left, right, source_scales=[2.0, 0.3], return_diagnostics=True, **kwargs)
|
|
||||||
|
|
||||||
assert_close(numpy.abs(scaled), numpy.abs(base), atol=1e-6, rtol=1e-6)
|
|
||||||
assert abs(abs(scaled_diagnostics['incident_amplitudes'][0] / base_diagnostics['incident_amplitudes'][0]) - 2.0) < 1e-6
|
|
||||||
assert abs(abs(scaled_diagnostics['incident_amplitudes'][1] / base_diagnostics['incident_amplitudes'][1]) - 0.3) < 1e-6
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_s_fdfd_from_2d_ports_diagnostics_order_generated_slices() -> None:
|
|
||||||
epsilon = _fdfd_cross_section(2.25)
|
|
||||||
|
|
||||||
_ss, diagnostics = waveguide_3d.get_s_fdfd_from_2d_ports(epsilon, epsilon, return_diagnostics=True, **_small_fdfd_kwargs())
|
|
||||||
|
|
||||||
left_source = diagnostics['left_source_slice'][0].start
|
|
||||||
left_monitor = diagnostics['left_monitor_slice'][0].start
|
|
||||||
interface_index = diagnostics['interface_index']
|
|
||||||
right_monitor = diagnostics['right_monitor_slice'][0].start
|
|
||||||
right_source = diagnostics['right_source_slice'][0].start
|
|
||||||
|
|
||||||
assert left_source == 6
|
|
||||||
assert left_monitor == 11
|
|
||||||
assert interface_index == 16
|
|
||||||
assert right_monitor == 21
|
|
||||||
assert right_source == 26
|
|
||||||
assert left_source < left_monitor < interface_index < right_monitor < right_source
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_s_fdfd_from_2d_ports_transverse_padding_and_pml_updates_diagnostics() -> None:
|
|
||||||
epsilon = _fdfd_cross_section(2.25)
|
|
||||||
|
|
||||||
ss, diagnostics = waveguide_3d.get_s_fdfd_from_2d_ports(
|
|
||||||
epsilon,
|
|
||||||
epsilon,
|
|
||||||
transverse_padding_cells=(2, 3),
|
|
||||||
transverse_pml_cells=(1, 2),
|
|
||||||
return_diagnostics=True,
|
|
||||||
**_small_fdfd_kwargs(),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert ss.shape == (2, 2)
|
|
||||||
assert diagnostics['epsilon'].shape == (3, 33, 11, 13)
|
|
||||||
assert diagnostics['transverse_padding_cells'] == (2, 3)
|
|
||||||
assert diagnostics['transverse_pml_cells'] == (1, 2)
|
|
||||||
assert numpy.iscomplexobj(diagnostics['solve_dxes'][0][1])
|
|
||||||
assert numpy.iscomplexobj(diagnostics['solve_dxes'][0][2])
|
|
||||||
assert numpy.abs(numpy.imag(diagnostics['solve_dxes'][0][1][0])) > 0
|
|
||||||
assert numpy.abs(numpy.imag(diagnostics['solve_dxes'][0][2][0])) > 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_s_fdfd_from_2d_ports_supports_nondefault_axis() -> None:
|
|
||||||
epsilon = _fdfd_cross_section(2.25)
|
|
||||||
|
|
||||||
ss, diagnostics = waveguide_3d.get_s_fdfd_from_2d_ports(epsilon, epsilon, axis=1, return_diagnostics=True, **_small_fdfd_kwargs())
|
|
||||||
|
|
||||||
assert ss.shape == (2, 2)
|
|
||||||
assert diagnostics['epsilon'].shape == (3, FDFD_SHAPE_2D[0], 33, FDFD_SHAPE_2D[1])
|
|
||||||
assert diagnostics['left_source_slice'][1].start == 6
|
|
||||||
assert numpy.isfinite(ss).all()
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_s_fdfd_rejects_bad_port_slice_order() -> None:
|
|
||||||
epsilon = numpy.ones((3, 12, 5, 5), dtype=float)
|
|
||||||
dxes = [[numpy.ones(12), numpy.ones(5), numpy.ones(5)] for _ in range(2)]
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match='ordered'):
|
|
||||||
waveguide_3d.get_s_fdfd(
|
|
||||||
FDFD_OMEGA,
|
|
||||||
dxes,
|
|
||||||
epsilon,
|
|
||||||
left_mode_numbers=[0],
|
|
||||||
left_source_slice=(slice(5, 6), slice(None), slice(None)),
|
|
||||||
left_monitor_slice=(slice(4, 5), slice(None), slice(None)),
|
|
||||||
right_source_slice=(slice(10, 11), slice(None), slice(None)),
|
|
||||||
right_monitor_slice=(slice(8, 9), slice(None), slice(None)),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_s_fdfd_rejects_bad_source_scale_count_before_solving() -> None:
|
|
||||||
epsilon = numpy.ones((3, 12, 5, 5), dtype=float)
|
|
||||||
dxes = [[numpy.ones(12), numpy.ones(5), numpy.ones(5)] for _ in range(2)]
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match='source_scales'):
|
|
||||||
waveguide_3d.get_s_fdfd(
|
|
||||||
FDFD_OMEGA,
|
|
||||||
dxes,
|
|
||||||
epsilon,
|
|
||||||
left_mode_numbers=[0],
|
|
||||||
left_source_slice=(slice(2, 3), slice(None), slice(None)),
|
|
||||||
left_monitor_slice=(slice(4, 5), slice(None), slice(None)),
|
|
||||||
right_mode_numbers=[0, 1],
|
|
||||||
right_source_slice=(slice(10, 11), slice(None), slice(None)),
|
|
||||||
right_monitor_slice=(slice(8, 9), slice(None), slice(None)),
|
|
||||||
source_scales=[1.0, 1.0],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_s_fdfd_from_2d_ports_rejects_invalid_generated_geometry() -> None:
|
|
||||||
epsilon = _fdfd_cross_section(2.25)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match='must be positive'):
|
|
||||||
waveguide_3d.get_s_fdfd_from_2d_ports(epsilon, epsilon, **{**_small_fdfd_kwargs(), 'pml_cells': 0})
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_s_fdfd_rejects_noninteger_mode_numbers_before_solving() -> None:
|
|
||||||
epsilon = numpy.ones((3, 12, 5, 5), dtype=float)
|
|
||||||
dxes = [[numpy.ones(12), numpy.ones(5), numpy.ones(5)] for _ in range(2)]
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match='mode_numbers'):
|
|
||||||
waveguide_3d.get_s_fdfd(
|
|
||||||
FDFD_OMEGA,
|
|
||||||
dxes,
|
|
||||||
epsilon,
|
|
||||||
left_mode_numbers=[0.5], # type: ignore[list-item]
|
|
||||||
left_source_slice=(slice(2, 3), slice(None), slice(None)),
|
|
||||||
left_monitor_slice=(slice(4, 5), slice(None), slice(None)),
|
|
||||||
right_source_slice=(slice(10, 11), slice(None), slice(None)),
|
|
||||||
right_monitor_slice=(slice(8, 9), slice(None), slice(None)),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_s_fdfd_from_2d_ports_preserves_complex_generated_inputs(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
left = _fdfd_cross_section(2.25)
|
|
||||||
right = _fdfd_cross_section(1.5).astype(complex)
|
|
||||||
right += 0.1j
|
|
||||||
dxes_2d = [
|
|
||||||
[FDFD_DXES_2D[0][0].astype(complex) + 0.2j, FDFD_DXES_2D[0][1].astype(complex) + 0.3j],
|
|
||||||
[FDFD_DXES_2D[1][0].astype(complex) + 0.4j, FDFD_DXES_2D[1][1].astype(complex) + 0.5j],
|
|
||||||
]
|
|
||||||
|
|
||||||
def fake_get_s_fdfd(*args, **kwargs) -> tuple[numpy.ndarray, dict]:
|
|
||||||
return numpy.eye(2, dtype=complex), {}
|
|
||||||
|
|
||||||
monkeypatch.setattr(waveguide_3d, 'get_s_fdfd', fake_get_s_fdfd)
|
|
||||||
|
|
||||||
kwargs = {**_small_fdfd_kwargs(), 'dxes_2d': dxes_2d}
|
|
||||||
_ss, diagnostics = waveguide_3d.get_s_fdfd_from_2d_ports(left, right, return_diagnostics=True, **kwargs)
|
|
||||||
|
|
||||||
assert numpy.iscomplexobj(diagnostics['epsilon'])
|
|
||||||
assert diagnostics['epsilon'][0, diagnostics['interface_index'], 0, 0].imag == pytest.approx(0.1)
|
|
||||||
assert numpy.iscomplexobj(diagnostics['dxes'][0][1])
|
|
||||||
assert diagnostics['dxes'][0][1][0].imag == pytest.approx(0.2)
|
|
||||||
|
|
||||||
|
|
||||||
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_12 @ _propagation_abcd(beta_1, quarter_wave_length) @ interface_01
|
|
||||||
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_12 @ _propagation_abcd(beta_1, quarter_wave_length) @ interface_01)
|
|
||||||
|
|
||||||
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_10 @ _propagation_abcd(beta_1, half_wave_length) @ interface_01
|
|
||||||
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 = _propagation_abcd(beta_l, quarter_wave_low) @ interface_hl @ _propagation_abcd(beta_h, quarter_wave_high) @ interface_lh @ stack_abcd
|
|
||||||
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
|
|
||||||
|
|
@ -1,210 +0,0 @@
|
||||||
"""Characterize truncation bias against exact reciprocal Maxwell interfaces.
|
|
||||||
|
|
||||||
These tests intentionally document mixed/Galerkin projection limitations; they
|
|
||||||
are not passivity-enforcement tests. Both complete modal bases and an independent
|
|
||||||
driven FDFD solve establish the reference for the truncated polarization case.
|
|
||||||
"""
|
|
||||||
import numpy as np
|
|
||||||
import pytest
|
|
||||||
from numpy.testing import assert_allclose
|
|
||||||
from scipy.sparse.linalg import spsolve
|
|
||||||
|
|
||||||
from ..fdmath import vec, unvec
|
|
||||||
from ..fdfd import eme, scpml, solvers, waveguide_2d, waveguide_3d
|
|
||||||
|
|
||||||
|
|
||||||
DXES = [[np.ones(1), np.ones(1)] for _ in range(2)]
|
|
||||||
|
|
||||||
|
|
||||||
def _rotation(angle):
|
|
||||||
return np.array([[np.cos(angle), -np.sin(angle)], [np.sin(angle), np.cos(angle)]])
|
|
||||||
|
|
||||||
|
|
||||||
def _polarizations(angle, scales=(1., 1.)):
|
|
||||||
"""Two power-orthonormal signed-beta=1 modes on a one-cell transverse grid.
|
|
||||||
|
|
||||||
E_xy = sqrt(2) diag(scales) Q, and J^-1 H_xy = sqrt(2) diag(1/scales) Q.
|
|
||||||
They solve Maxwell with diagonal epsilon=(1/sx^2,1/sy^2,1) and
|
|
||||||
mu=(sy^2,sx^2,1) at omega=1, including nonparallel E/H traces.
|
|
||||||
"""
|
|
||||||
rotation = _rotation(angle)
|
|
||||||
electric = np.diag(scales) @ rotation
|
|
||||||
magnetic = np.diag(1 / np.asarray(scales)) @ rotation
|
|
||||||
return [
|
|
||||||
(np.sqrt(2) * np.array([electric[0, i], electric[1, i], 0], complex),
|
|
||||||
np.sqrt(2) * np.array([-magnetic[1, i], magnetic[0, i], 0], complex))
|
|
||||||
for i in range(2)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _scattering(left, right, formulation='mixed'):
|
|
||||||
return eme.get_s(left, np.ones(len(left)), right, np.ones(len(right)), DXES, formulation=formulation)
|
|
||||||
|
|
||||||
|
|
||||||
def _reorder_ports(matrix):
|
|
||||||
n = matrix.shape[0] // 2
|
|
||||||
order = [*range(n, 2*n), *range(n)]
|
|
||||||
return matrix[np.ix_(order, order)]
|
|
||||||
|
|
||||||
|
|
||||||
def _fresnel_reference(left_angle, right_angle, left_scales=(1., 1.), right_scales=(1., 1.)):
|
|
||||||
"""Independent exact two-polarization interface in the supplied port bases."""
|
|
||||||
yl, yr = 1 / np.asarray(left_scales)**2, 1 / np.asarray(right_scales)**2
|
|
||||||
reflection = np.diag((yl - yr) / (yl + yr))
|
|
||||||
transmission = np.diag(2 * np.sqrt(yl * yr) / (yl + yr))
|
|
||||||
ql, qr = _rotation(left_angle), _rotation(right_angle)
|
|
||||||
return np.block([[ql.T @ reflection @ ql, ql.T @ transmission @ qr],
|
|
||||||
[qr.T @ transmission @ ql, -qr.T @ reflection @ qr]])
|
|
||||||
|
|
||||||
|
|
||||||
def _trace_least_squares(left, right):
|
|
||||||
"""Research comparator: unweighted Euclidean tangential E/H least squares.
|
|
||||||
|
|
||||||
This is deliberately not a public formulation: correcting the isotropic
|
|
||||||
polarization example does not establish reciprocity or passivity generally.
|
|
||||||
"""
|
|
||||||
el = np.column_stack([e[:2] for e, _h in left])
|
|
||||||
hl = np.column_stack([h[:2] for _e, h in left])
|
|
||||||
er = np.column_stack([e[:2] for e, _h in right])
|
|
||||||
hr = np.column_stack([h[:2] for _e, h in right])
|
|
||||||
outgoing = np.block([[el, -er], [-hl, -hr]])
|
|
||||||
incoming = np.block([[-el, er], [-hl, -hr]])
|
|
||||||
return np.linalg.lstsq(outgoing, incoming, rcond=None)[0]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('overlap', [.5, .8, .95])
|
|
||||||
def test_truncated_mixed_polarizations_show_orientation_bias_despite_unitarity(overlap):
|
|
||||||
angle = np.arccos(overlap)
|
|
||||||
left, right = _polarizations(0)[:1], _polarizations(angle)[:1]
|
|
||||||
result = _scattering(left, right)
|
|
||||||
reversed_result = _reorder_ports(_scattering(right, left))
|
|
||||||
reflection = (1-overlap**2) / (1+overlap**2)
|
|
||||||
transmission = 2*overlap / (1+overlap**2)
|
|
||||||
assert_allclose(result, [[reflection, transmission], [transmission, -reflection]], atol=1e-14)
|
|
||||||
assert_allclose(reversed_result, [[-reflection, transmission], [transmission, reflection]], atol=1e-14)
|
|
||||||
assert_allclose(result.T @ result, np.eye(2), atol=1e-14)
|
|
||||||
assert_allclose(result, result.T, atol=1e-14)
|
|
||||||
assert_allclose(np.linalg.norm(result-reversed_result), 2*np.sqrt(2)*reflection, atol=1e-14)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(('left_scales', 'right_scales'), [((1., 1.), (1., 1.)), ((1., 2.), (1., 3.))])
|
|
||||||
def test_complete_maxwell_bases_recover_fresnel_and_port_relabeling(left_scales, right_scales):
|
|
||||||
left_angle, right_angle = np.pi/6, np.arccos(.8)
|
|
||||||
left, right = _polarizations(left_angle, left_scales), _polarizations(right_angle, right_scales)
|
|
||||||
# Establish that the synthetic fields are actual degenerate Maxwell modes,
|
|
||||||
# not arbitrary profiles or mixed vectors with different eigenvalues.
|
|
||||||
for modes, scales in ((left, left_scales), (right, right_scales)):
|
|
||||||
sx, sy = scales
|
|
||||||
epsilon, mu = np.array([1/sx**2, 1/sy**2, 1]), np.array([sy**2, sx**2, 1])
|
|
||||||
operator = waveguide_2d.operator_e(1, DXES, epsilon, mu)
|
|
||||||
for e, h in modes:
|
|
||||||
assert_allclose(operator @ e[:2], e[:2], atol=1e-14)
|
|
||||||
assert_allclose(waveguide_2d.exy2h(1, 1, DXES, epsilon, mu) @ e[:2], h, atol=1e-14)
|
|
||||||
gram = [[waveguide_2d.lorentz_overlap(a, b, DXES) for b in modes] for a in modes]
|
|
||||||
assert_allclose(gram, np.eye(2), atol=1e-14)
|
|
||||||
result = _scattering(left, right)
|
|
||||||
expected = _fresnel_reference(left_angle, right_angle, left_scales, right_scales)
|
|
||||||
assert_allclose(result, expected, atol=1e-14)
|
|
||||||
assert_allclose(_reorder_ports(_scattering(right, left)), expected, atol=1e-14)
|
|
||||||
assert_allclose(result.T @ result, np.eye(4), atol=1e-14)
|
|
||||||
assert_allclose(result, result.T, atol=1e-14)
|
|
||||||
|
|
||||||
|
|
||||||
def test_missing_polarization_accounts_for_retained_submatrix_power_deficit():
|
|
||||||
overlap = .8
|
|
||||||
full = _scattering(_polarizations(0), _polarizations(np.arccos(overlap)))
|
|
||||||
retained = full[np.ix_([0, 2], [0, 2])]
|
|
||||||
assert_allclose(retained, [[0, overlap], [overlap, 0]], atol=1e-14)
|
|
||||||
assert_allclose(abs(full[3, 0])**2, 1-overlap**2, atol=1e-14)
|
|
||||||
assert_allclose(np.sum(abs(full[:, 0])**2), 1, atol=1e-14)
|
|
||||||
assert_allclose(np.sum(abs(retained[:, 0])**2), overlap**2, atol=1e-14)
|
|
||||||
|
|
||||||
|
|
||||||
def test_posthoc_orientation_averaging_does_not_recover_physical_transmission():
|
|
||||||
overlap = .8
|
|
||||||
left, right = _polarizations(0)[:1], _polarizations(np.arccos(overlap))[:1]
|
|
||||||
averaged = .5 * (_scattering(left, right) + _reorder_ports(_scattering(right, left)))
|
|
||||||
assert_allclose(averaged[0, 0], 0, atol=1e-14)
|
|
||||||
assert_allclose(averaged[1, 1], 0, atol=1e-14)
|
|
||||||
assert abs(averaged[1, 0] - overlap) > .17
|
|
||||||
|
|
||||||
|
|
||||||
def test_galerkin_testing_does_not_resolve_truncated_polarization_error():
|
|
||||||
overlap = .8
|
|
||||||
left, right = _polarizations(0)[:1], _polarizations(np.arccos(overlap))[:1]
|
|
||||||
result = _scattering(left, right, 'galerkin')
|
|
||||||
assert_allclose(result, [[0, overlap], [1/overlap, 0]], atol=1e-14)
|
|
||||||
assert not np.allclose(result, result.T)
|
|
||||||
|
|
||||||
|
|
||||||
def test_trace_least_squares_recovers_uniform_reference_but_is_not_a_general_fix():
|
|
||||||
overlap = .8
|
|
||||||
left, right = _polarizations(0)[:1], _polarizations(np.arccos(overlap))[:1]
|
|
||||||
assert_allclose(_trace_least_squares(left, right), [[0, overlap], [overlap, 0]], atol=1e-14)
|
|
||||||
angle = np.pi/6
|
|
||||||
left, right = _polarizations(angle, (1., 2.)), _polarizations(angle, (1., 3.))
|
|
||||||
reference = _fresnel_reference(angle, angle, (1., 2.), (1., 3.))
|
|
||||||
assert_allclose(_trace_least_squares(left, right), reference, atol=1e-14)
|
|
||||||
truncated = _trace_least_squares(left[:1], right[:1])
|
|
||||||
# All traces come from the exact reciprocal, lossless Maxwell bases above.
|
|
||||||
# Euclidean residual minimization nevertheless violates both constraints
|
|
||||||
# after truncation. Port-relabeling covariance alone is not sufficient.
|
|
||||||
assert np.linalg.norm(truncated-truncated.T) > .3
|
|
||||||
assert np.linalg.svd(truncated, compute_uv=False).max() > 1.10
|
|
||||||
assert_allclose(_reorder_ports(_trace_least_squares(right[:1], left[:1])), truncated, atol=1e-14)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('side', [0, 1])
|
|
||||||
def test_driven_fdfd_confirms_uniform_polarization_reference(side):
|
|
||||||
omega, thickness = np.pi/8, 24
|
|
||||||
size = 2*thickness + 64
|
|
||||||
physical = [[np.ones(1), np.ones(1), np.ones(size)] for _ in range(2)]
|
|
||||||
epsilon = np.ones((3, 1, 1, size))
|
|
||||||
solve_widths = physical
|
|
||||||
for polarity in (-1, 1):
|
|
||||||
solve_widths = scpml.stretch_with_scpml(solve_widths, 2, polarity, omega, thickness=thickness)
|
|
||||||
overlap = .8
|
|
||||||
profiles = [np.array([1., 0.]), np.array([overlap, np.sqrt(1-overlap**2)])]
|
|
||||||
polarity = 1 if side == 0 else -1
|
|
||||||
source_index = thickness+4 if side == 0 else thickness+52
|
|
||||||
slices = (slice(None), slice(None), slice(source_index, source_index+1))
|
|
||||||
mode = waveguide_3d.mode_from_2d(profiles[side], omega, omega=omega, dxes=physical,
|
|
||||||
axis=2, polarity=polarity, slices=slices, epsilon=epsilon)
|
|
||||||
current = waveguide_3d.compute_source(mode['E'], mode['wavenumber'], omega, physical, 2, polarity, slices, epsilon)
|
|
||||||
field = unvec(solvers.generic(omega, solve_widths, vec(current), vec(epsilon), matrix_solver=spsolve), epsilon.shape[1:])
|
|
||||||
left_index, right_index = thickness+12, thickness+40
|
|
||||||
beta = 2*np.arcsin(omega/2)
|
|
||||||
phase = np.exp(-1j*beta)
|
|
||||||
|
|
||||||
def waves(index):
|
|
||||||
e0, e1 = field[:2, 0, 0, index], field[:2, 0, 0, index+1]
|
|
||||||
forward = (e1 - e0/phase) / (phase - 1/phase)
|
|
||||||
return forward, e0-forward
|
|
||||||
|
|
||||||
fl, bl = waves(left_index)
|
|
||||||
fr, br = waves(right_index)
|
|
||||||
incoming = np.dot(profiles[side], fl if side == 0 else br)
|
|
||||||
reflected = np.dot(profiles[side], bl if side == 0 else fr) / incoming
|
|
||||||
transmitted = np.dot(profiles[1-side], fr if side == 0 else bl) / incoming
|
|
||||||
transmitted *= np.exp(1j*beta*(right_index-left_index))
|
|
||||||
assert abs(reflected) < 5e-8
|
|
||||||
assert_allclose(transmitted, overlap, atol=1e-12)
|
|
||||||
|
|
||||||
|
|
||||||
def test_longitudinal_refinement_cannot_repair_an_omitted_degenerate_polarization():
|
|
||||||
angle = np.arccos(.8)
|
|
||||||
reflection_magnitudes = []
|
|
||||||
for count in (3, 9, 33):
|
|
||||||
complete = [eme.ModeSection(z, _polarizations(angle*z), [1., 1.]) for z in np.linspace(0, 1, count)]
|
|
||||||
truncated = [eme.ModeSection(section.z, section.modes[:1], [1.]) for section in complete]
|
|
||||||
full = eme.get_taper_s(complete, DXES)
|
|
||||||
expected = _fresnel_reference(0, angle).astype(complex) * np.exp(-1j)
|
|
||||||
assert_allclose(full, expected, atol=1e-13)
|
|
||||||
small = eme.get_taper_s(truncated, DXES)
|
|
||||||
reflection_magnitudes.append(abs(small[0, 0]))
|
|
||||||
# There is no material taper: the basis is merely rotated within a
|
|
||||||
# degenerate subspace. Restricting the exact result gives |T|=.8, while
|
|
||||||
# the truncated chain approaches artificial adiabatic following, |T|=1.
|
|
||||||
assert abs(small[1, 0]) > .99
|
|
||||||
assert reflection_magnitudes[-1] < reflection_magnitudes[0] / 10
|
|
||||||
assert_allclose(abs(expected[2, 0]), .8, atol=1e-14)
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
from pathlib import Path
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
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
|
|
||||||
|
|
||||||
straight_index_match = re.search(r'straight effective indices:\s*([0-9.]+)', result.stdout)
|
|
||||||
assert straight_index_match is not None, result.stdout
|
|
||||||
assert float(straight_index_match.group(1)) > 1.6
|
|
||||||
|
|
||||||
|
|
||||||
def test_eme_taper_cmt_example_smoke_runs(tmp_path: Path) -> None:
|
|
||||||
result = _run_example('eme_taper_cmt.py', tmp_path)
|
|
||||||
|
|
||||||
assert result.returncode == 0, result.stdout + result.stderr
|
|
||||||
assert 'sampled taper effective indices:' in result.stdout
|
|
||||||
assert 'taper transmission' in result.stdout
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# ruff: noqa: ARG001
|
from typing import Iterable
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import pytest # type: ignore
|
import pytest # type: ignore
|
||||||
import numpy
|
import numpy
|
||||||
|
|
@ -6,7 +6,7 @@ from numpy.typing import NDArray
|
||||||
#from numpy.testing import assert_allclose, assert_array_equal
|
#from numpy.testing import assert_allclose, assert_array_equal
|
||||||
|
|
||||||
from .. import fdfd
|
from .. import fdfd
|
||||||
from ..fdmath import vec, unvec, vcfdfield, vfdfield, dx_lists_t
|
from ..fdmath import vec, unvec
|
||||||
from .utils import assert_close # , assert_fields_close
|
from .utils import assert_close # , assert_fields_close
|
||||||
from .conftest import FixtureRequest
|
from .conftest import FixtureRequest
|
||||||
|
|
||||||
|
|
@ -61,24 +61,24 @@ def test_poynting_planes(sim: 'FDResult') -> None:
|
||||||
# Also see conftest.py
|
# Also see conftest.py
|
||||||
|
|
||||||
@pytest.fixture(params=[1 / 1500])
|
@pytest.fixture(params=[1 / 1500])
|
||||||
def omega(request: FixtureRequest) -> float:
|
def omega(request: FixtureRequest) -> Iterable[float]:
|
||||||
return request.param
|
yield request.param
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(params=[None])
|
@pytest.fixture(params=[None])
|
||||||
def pec(request: FixtureRequest) -> NDArray[numpy.float64] | None:
|
def pec(request: FixtureRequest) -> Iterable[NDArray[numpy.float64] | None]:
|
||||||
return request.param
|
yield request.param
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(params=[None])
|
@pytest.fixture(params=[None])
|
||||||
def pmc(request: FixtureRequest) -> NDArray[numpy.float64] | None:
|
def pmc(request: FixtureRequest) -> Iterable[NDArray[numpy.float64] | None]:
|
||||||
return request.param
|
yield request.param
|
||||||
|
|
||||||
|
|
||||||
#@pytest.fixture(scope='module',
|
#@pytest.fixture(scope='module',
|
||||||
# params=[(25, 5, 5)])
|
# params=[(25, 5, 5)])
|
||||||
#def shape(request: FixtureRequest):
|
#def shape(request):
|
||||||
# return (3, *request.param)
|
# yield (3, *request.param)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(params=['diag']) # 'center'
|
@pytest.fixture(params=['diag']) # 'center'
|
||||||
|
|
@ -86,7 +86,7 @@ def j_distribution(
|
||||||
request: FixtureRequest,
|
request: FixtureRequest,
|
||||||
shape: tuple[int, ...],
|
shape: tuple[int, ...],
|
||||||
j_mag: float,
|
j_mag: float,
|
||||||
) -> NDArray[numpy.float64]:
|
) -> Iterable[NDArray[numpy.float64]]:
|
||||||
j = numpy.zeros(shape, dtype=complex)
|
j = numpy.zeros(shape, dtype=complex)
|
||||||
center_mask = numpy.zeros(shape, dtype=bool)
|
center_mask = numpy.zeros(shape, dtype=bool)
|
||||||
center_mask[:, shape[1] // 2, shape[2] // 2, shape[3] // 2] = True
|
center_mask[:, shape[1] // 2, shape[2] // 2, shape[3] // 2] = True
|
||||||
|
|
@ -96,22 +96,22 @@ def j_distribution(
|
||||||
elif request.param == 'diag':
|
elif request.param == 'diag':
|
||||||
j[numpy.roll(center_mask, [1, 1, 1], axis=(1, 2, 3))] = (1 + 1j) * j_mag
|
j[numpy.roll(center_mask, [1, 1, 1], axis=(1, 2, 3))] = (1 + 1j) * j_mag
|
||||||
j[numpy.roll(center_mask, [-1, -1, -1], axis=(1, 2, 3))] = (1 - 1j) * j_mag
|
j[numpy.roll(center_mask, [-1, -1, -1], axis=(1, 2, 3))] = (1 - 1j) * j_mag
|
||||||
return j
|
yield j
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass()
|
@dataclasses.dataclass()
|
||||||
class FDResult:
|
class FDResult:
|
||||||
shape: tuple[int, ...]
|
shape: tuple[int, ...]
|
||||||
dxes: dx_lists_t
|
dxes: list[list[NDArray[numpy.float64]]]
|
||||||
epsilon: vfdfield
|
epsilon: NDArray[numpy.float64]
|
||||||
omega: complex
|
omega: complex
|
||||||
j: vcfdfield
|
j: NDArray[numpy.complex128]
|
||||||
e: vcfdfield
|
e: NDArray[numpy.complex128]
|
||||||
pmc: vfdfield | None
|
pmc: NDArray[numpy.float64] | None
|
||||||
pec: vfdfield | None
|
pec: NDArray[numpy.float64] | None
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture()
|
||||||
def sim(
|
def sim(
|
||||||
request: FixtureRequest,
|
request: FixtureRequest,
|
||||||
shape: tuple[int, ...],
|
shape: tuple[int, ...],
|
||||||
|
|
@ -141,11 +141,11 @@ def sim(
|
||||||
j_vec = vec(j_distribution)
|
j_vec = vec(j_distribution)
|
||||||
eps_vec = vec(epsilon)
|
eps_vec = vec(epsilon)
|
||||||
e_vec = fdfd.solvers.generic(
|
e_vec = fdfd.solvers.generic(
|
||||||
J = j_vec,
|
J=j_vec,
|
||||||
omega = omega,
|
omega=omega,
|
||||||
dxes = dxes,
|
dxes=dxes,
|
||||||
epsilon = eps_vec,
|
epsilon=eps_vec,
|
||||||
matrix_solver_opts = dict(atol=1e-15, rtol=1e-11),
|
matrix_solver_opts={'atol': 1e-15, 'tol': 1e-11},
|
||||||
)
|
)
|
||||||
e = unvec(e_vec, shape[1:])
|
e = unvec(e_vec, shape[1:])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,211 +0,0 @@
|
||||||
import numpy
|
|
||||||
|
|
||||||
from ..fdmath import vec, unvec
|
|
||||||
from ..fdmath import functional as fd_functional
|
|
||||||
from ..fdfd import operators, scpml
|
|
||||||
from ._fdfd_case import (
|
|
||||||
BOUNDARY_DXES,
|
|
||||||
BOUNDARY_EPSILON,
|
|
||||||
BOUNDARY_FIELD,
|
|
||||||
BOUNDARY_SHAPE,
|
|
||||||
DXES,
|
|
||||||
EPSILON,
|
|
||||||
E_FIELD,
|
|
||||||
MU,
|
|
||||||
H_FIELD,
|
|
||||||
OMEGA,
|
|
||||||
PEC,
|
|
||||||
PMC,
|
|
||||||
SHAPE,
|
|
||||||
apply_fdfd_matrix,
|
|
||||||
)
|
|
||||||
from .utils import assert_close, assert_fields_close
|
|
||||||
|
|
||||||
|
|
||||||
def _dense_e_full(mu: numpy.ndarray | None) -> numpy.ndarray:
|
|
||||||
ce = fd_functional.curl_forward(DXES[0])
|
|
||||||
ch = fd_functional.curl_back(DXES[1])
|
|
||||||
pe = numpy.where(PEC, 0.0, 1.0)
|
|
||||||
pm = numpy.where(PMC, 0.0, 1.0)
|
|
||||||
|
|
||||||
masked_e = pe * E_FIELD
|
|
||||||
curl_term = ce(masked_e)
|
|
||||||
if mu is not None:
|
|
||||||
curl_term = curl_term / mu
|
|
||||||
curl_term = pm * curl_term
|
|
||||||
curl_term = ch(curl_term)
|
|
||||||
return pe * (curl_term - OMEGA**2 * EPSILON * masked_e)
|
|
||||||
|
|
||||||
|
|
||||||
def _dense_h_full(mu: numpy.ndarray | None) -> numpy.ndarray:
|
|
||||||
ce = fd_functional.curl_forward(DXES[0])
|
|
||||||
ch = fd_functional.curl_back(DXES[1])
|
|
||||||
pe = numpy.where(PEC, 0.0, 1.0)
|
|
||||||
pm = numpy.where(PMC, 0.0, 1.0)
|
|
||||||
magnetic = numpy.ones_like(EPSILON) if mu is None else mu
|
|
||||||
|
|
||||||
masked_h = pm * H_FIELD
|
|
||||||
curl_term = ch(masked_h)
|
|
||||||
curl_term = pe * (curl_term / EPSILON)
|
|
||||||
curl_term = ce(curl_term)
|
|
||||||
return pm * (curl_term - OMEGA**2 * magnetic * masked_h)
|
|
||||||
|
|
||||||
|
|
||||||
def 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)
|
|
||||||
for axis, size, thickness in ((0, 6, 2), (2, 3, 1)):
|
|
||||||
correction = thickness * numpy.sqrt(4.0) * 2.0
|
|
||||||
for family, offset in enumerate((0.5, 0.0)):
|
|
||||||
positions = numpy.arange(size, dtype=float) + offset
|
|
||||||
expected = numpy.ones(size, dtype=complex)
|
|
||||||
low_depth = (thickness - positions[:thickness]) / thickness
|
|
||||||
high_depth = (positions[-thickness:] - (size - thickness)) / thickness
|
|
||||||
expected[:thickness] += 1j * s_function(low_depth) / correction
|
|
||||||
expected[-thickness:] += 1j * s_function(high_depth) / correction
|
|
||||||
assert_close(dxes[family][axis], expected)
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
import numpy
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from ..fdfd import farfield
|
|
||||||
|
|
||||||
|
|
||||||
NEAR_SHAPE = (2, 3)
|
|
||||||
E_NEAR = [numpy.zeros(NEAR_SHAPE, dtype=complex), numpy.zeros(NEAR_SHAPE, dtype=complex)]
|
|
||||||
H_NEAR = [numpy.zeros(NEAR_SHAPE, dtype=complex), numpy.zeros(NEAR_SHAPE, dtype=complex)]
|
|
||||||
|
|
||||||
|
|
||||||
def test_near_to_farfield_rejects_wrong_length_inputs() -> None:
|
|
||||||
with pytest.raises(Exception, match='E_near must be a length-2 list'):
|
|
||||||
farfield.near_to_farfield(E_NEAR[:1], H_NEAR, dx=0.2, dy=0.3)
|
|
||||||
|
|
||||||
with pytest.raises(Exception, match='H_near must be a length-2 list'):
|
|
||||||
farfield.near_to_farfield(E_NEAR, H_NEAR[:1], dx=0.2, dy=0.3)
|
|
||||||
|
|
||||||
|
|
||||||
def test_near_to_farfield_rejects_mismatched_shapes() -> None:
|
|
||||||
bad_h_near = [H_NEAR[0], numpy.zeros((2, 4), dtype=complex)]
|
|
||||||
|
|
||||||
with pytest.raises(Exception, match='All fields must be the same shape'):
|
|
||||||
farfield.near_to_farfield(E_NEAR, bad_h_near, dx=0.2, dy=0.3)
|
|
||||||
|
|
||||||
|
|
||||||
def test_near_to_farfield_uses_default_and_scalar_padding_shapes() -> None:
|
|
||||||
default_result = farfield.near_to_farfield(E_NEAR, H_NEAR, dx=0.2, dy=0.3)
|
|
||||||
scalar_result = farfield.near_to_farfield(E_NEAR, H_NEAR, dx=0.2, dy=0.3, padded_size=8)
|
|
||||||
|
|
||||||
assert default_result['E'][0].shape == (2, 4)
|
|
||||||
assert default_result['H'][0].shape == (2, 4)
|
|
||||||
assert scalar_result['E'][0].shape == (8, 8)
|
|
||||||
assert scalar_result['H'][0].shape == (8, 8)
|
|
||||||
|
|
||||||
|
|
||||||
def test_far_to_nearfield_rejects_wrong_length_inputs() -> None:
|
|
||||||
ff = farfield.near_to_farfield(E_NEAR, H_NEAR, dx=0.2, dy=0.3, padded_size=8)
|
|
||||||
|
|
||||||
with pytest.raises(Exception, match='E_far must be a length-2 list'):
|
|
||||||
farfield.far_to_nearfield(ff['E'][:1], ff['H'], ff['dkx'], ff['dky'])
|
|
||||||
|
|
||||||
with pytest.raises(Exception, match='H_far must be a length-2 list'):
|
|
||||||
farfield.far_to_nearfield(ff['E'], ff['H'][:1], ff['dkx'], ff['dky'])
|
|
||||||
|
|
||||||
|
|
||||||
def test_far_to_nearfield_rejects_mismatched_shapes() -> None:
|
|
||||||
ff = farfield.near_to_farfield(E_NEAR, H_NEAR, dx=0.2, dy=0.3, padded_size=8)
|
|
||||||
bad_h_far = [ff['H'][0], numpy.zeros((8, 4), dtype=complex)]
|
|
||||||
|
|
||||||
with pytest.raises(Exception, match='All fields must be the same shape'):
|
|
||||||
farfield.far_to_nearfield(ff['E'], bad_h_far, ff['dkx'], ff['dky'])
|
|
||||||
|
|
||||||
|
|
||||||
def test_far_to_nearfield_uses_default_and_scalar_padding_shapes() -> None:
|
|
||||||
ff = farfield.near_to_farfield(E_NEAR, H_NEAR, dx=0.2, dy=0.3, padded_size=8)
|
|
||||||
default_result = farfield.far_to_nearfield(
|
|
||||||
[field.copy() for field in ff['E']],
|
|
||||||
[field.copy() for field in ff['H']],
|
|
||||||
ff['dkx'],
|
|
||||||
ff['dky'],
|
|
||||||
)
|
|
||||||
scalar_result = farfield.far_to_nearfield(
|
|
||||||
[field.copy() for field in ff['E']],
|
|
||||||
[field.copy() for field in ff['H']],
|
|
||||||
ff['dkx'],
|
|
||||||
ff['dky'],
|
|
||||||
padded_size=4,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert default_result['E'][0].shape == (8, 8)
|
|
||||||
assert default_result['H'][0].shape == (8, 8)
|
|
||||||
assert scalar_result['E'][0].shape == (4, 4)
|
|
||||||
assert scalar_result['H'][0].shape == (4, 4)
|
|
||||||
|
|
||||||
|
|
||||||
def test_farfield_roundtrip_supports_rectangular_arrays() -> None:
|
|
||||||
e_near = [numpy.zeros((4, 8), dtype=complex), numpy.zeros((4, 8), dtype=complex)]
|
|
||||||
h_near = [numpy.zeros((4, 8), dtype=complex), numpy.zeros((4, 8), dtype=complex)]
|
|
||||||
e_near[0][1, 3] = 1.0 + 0.25j
|
|
||||||
h_near[1][2, 5] = -0.5j
|
|
||||||
|
|
||||||
ff = farfield.near_to_farfield(e_near, h_near, dx=0.2, dy=0.3, padded_size=(4, 8))
|
|
||||||
restored = farfield.far_to_nearfield(
|
|
||||||
[field.copy() for field in ff['E']],
|
|
||||||
[field.copy() for field in ff['H']],
|
|
||||||
ff['dkx'],
|
|
||||||
ff['dky'],
|
|
||||||
padded_size=(4, 8),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert isinstance(ff['dkx'], float)
|
|
||||||
assert isinstance(ff['dky'], float)
|
|
||||||
assert ff['E'][0].shape == (4, 8)
|
|
||||||
assert restored['E'][0].shape == (4, 8)
|
|
||||||
assert restored['H'][0].shape == (4, 8)
|
|
||||||
assert restored['dx'] == pytest.approx(0.2)
|
|
||||||
assert restored['dy'] == pytest.approx(0.3)
|
|
||||||
assert numpy.isfinite(restored['E'][0]).all()
|
|
||||||
assert numpy.isfinite(restored['H'][0]).all()
|
|
||||||
|
|
@ -1,122 +0,0 @@
|
||||||
import numpy
|
|
||||||
|
|
||||||
from ..fdmath import unvec, vec
|
|
||||||
from ..fdfd import functional, operators
|
|
||||||
from ._fdfd_case import DXES, EPSILON, E_FIELD, H_FIELD, MU, OMEGA, SHAPE, TF_REGION, apply_fdfd_matrix
|
|
||||||
from .utils import assert_fields_close
|
|
||||||
|
|
||||||
|
|
||||||
ATOL = 1e-9
|
|
||||||
RTOL = 1e-9
|
|
||||||
|
|
||||||
|
|
||||||
def assert_fields_match(actual: numpy.ndarray, expected: numpy.ndarray) -> None:
|
|
||||||
assert_fields_close(actual, expected, atol=ATOL, rtol=RTOL)
|
|
||||||
|
|
||||||
|
|
||||||
def test_e_full_matches_sparse_operator_without_mu() -> None:
|
|
||||||
matrix_result = apply_fdfd_matrix(
|
|
||||||
operators.e_full(OMEGA, DXES, vec(EPSILON)),
|
|
||||||
E_FIELD,
|
|
||||||
)
|
|
||||||
functional_result = functional.e_full(OMEGA, DXES, EPSILON)(E_FIELD)
|
|
||||||
|
|
||||||
assert_fields_match(functional_result, matrix_result)
|
|
||||||
|
|
||||||
|
|
||||||
def test_e_full_matches_sparse_operator_with_mu() -> None:
|
|
||||||
matrix_result = apply_fdfd_matrix(
|
|
||||||
operators.e_full(OMEGA, DXES, vec(EPSILON), vec(MU)),
|
|
||||||
E_FIELD,
|
|
||||||
)
|
|
||||||
functional_result = functional.e_full(OMEGA, DXES, EPSILON, MU)(E_FIELD)
|
|
||||||
|
|
||||||
assert_fields_match(functional_result, matrix_result)
|
|
||||||
|
|
||||||
|
|
||||||
def test_eh_full_matches_sparse_operator_with_mu() -> None:
|
|
||||||
matrix_result = operators.eh_full(OMEGA, DXES, vec(EPSILON), vec(MU)) @ numpy.concatenate([vec(E_FIELD), vec(H_FIELD)])
|
|
||||||
matrix_e, matrix_h = (unvec(part, SHAPE) for part in numpy.split(matrix_result, 2))
|
|
||||||
functional_e, functional_h = functional.eh_full(OMEGA, DXES, EPSILON, MU)(E_FIELD, H_FIELD)
|
|
||||||
|
|
||||||
assert_fields_match(functional_e, matrix_e)
|
|
||||||
assert_fields_match(functional_h, matrix_h)
|
|
||||||
|
|
||||||
|
|
||||||
def test_eh_full_matches_sparse_operator_without_mu() -> None:
|
|
||||||
matrix_result = operators.eh_full(OMEGA, DXES, vec(EPSILON)) @ numpy.concatenate([vec(E_FIELD), vec(H_FIELD)])
|
|
||||||
matrix_e, matrix_h = (unvec(part, SHAPE) for part in numpy.split(matrix_result, 2))
|
|
||||||
functional_e, functional_h = functional.eh_full(OMEGA, DXES, EPSILON)(E_FIELD, H_FIELD)
|
|
||||||
|
|
||||||
assert_fields_match(functional_e, matrix_e)
|
|
||||||
assert_fields_match(functional_h, matrix_h)
|
|
||||||
|
|
||||||
|
|
||||||
def test_e2h_matches_sparse_operator_with_mu() -> None:
|
|
||||||
matrix_result = apply_fdfd_matrix(
|
|
||||||
operators.e2h(OMEGA, DXES, vec(MU)),
|
|
||||||
E_FIELD,
|
|
||||||
)
|
|
||||||
functional_result = functional.e2h(OMEGA, DXES, MU)(E_FIELD)
|
|
||||||
|
|
||||||
assert_fields_match(functional_result, matrix_result)
|
|
||||||
|
|
||||||
|
|
||||||
def test_e2h_matches_sparse_operator_without_mu() -> None:
|
|
||||||
matrix_result = apply_fdfd_matrix(
|
|
||||||
operators.e2h(OMEGA, DXES),
|
|
||||||
E_FIELD,
|
|
||||||
)
|
|
||||||
functional_result = functional.e2h(OMEGA, DXES)(E_FIELD)
|
|
||||||
|
|
||||||
assert_fields_match(functional_result, matrix_result)
|
|
||||||
|
|
||||||
|
|
||||||
def test_m2j_matches_sparse_operator_without_mu() -> None:
|
|
||||||
matrix_result = apply_fdfd_matrix(
|
|
||||||
operators.m2j(OMEGA, DXES),
|
|
||||||
H_FIELD,
|
|
||||||
)
|
|
||||||
functional_result = functional.m2j(OMEGA, DXES)(H_FIELD)
|
|
||||||
|
|
||||||
assert_fields_match(functional_result, matrix_result)
|
|
||||||
|
|
||||||
|
|
||||||
def test_m2j_matches_sparse_operator_with_mu() -> None:
|
|
||||||
matrix_result = apply_fdfd_matrix(
|
|
||||||
operators.m2j(OMEGA, DXES, vec(MU)),
|
|
||||||
H_FIELD,
|
|
||||||
)
|
|
||||||
functional_result = functional.m2j(OMEGA, DXES, MU)(H_FIELD)
|
|
||||||
|
|
||||||
assert_fields_match(functional_result, matrix_result)
|
|
||||||
|
|
||||||
|
|
||||||
def test_e_tfsf_source_matches_sparse_operator_without_mu() -> None:
|
|
||||||
matrix_result = apply_fdfd_matrix(
|
|
||||||
operators.e_tfsf_source(vec(TF_REGION), OMEGA, DXES, vec(EPSILON)),
|
|
||||||
E_FIELD,
|
|
||||||
)
|
|
||||||
functional_result = functional.e_tfsf_source(TF_REGION, OMEGA, DXES, EPSILON)(E_FIELD)
|
|
||||||
|
|
||||||
assert_fields_match(functional_result, matrix_result)
|
|
||||||
|
|
||||||
|
|
||||||
def test_e_tfsf_source_matches_sparse_operator_with_mu() -> None:
|
|
||||||
matrix_result = apply_fdfd_matrix(
|
|
||||||
operators.e_tfsf_source(vec(TF_REGION), OMEGA, DXES, vec(EPSILON), vec(MU)),
|
|
||||||
E_FIELD,
|
|
||||||
)
|
|
||||||
functional_result = functional.e_tfsf_source(TF_REGION, OMEGA, DXES, EPSILON, MU)(E_FIELD)
|
|
||||||
|
|
||||||
assert_fields_match(functional_result, matrix_result)
|
|
||||||
|
|
||||||
|
|
||||||
def test_poynting_e_cross_h_matches_sparse_operator() -> None:
|
|
||||||
matrix_result = apply_fdfd_matrix(
|
|
||||||
operators.poynting_e_cross(vec(E_FIELD), DXES),
|
|
||||||
H_FIELD,
|
|
||||||
)
|
|
||||||
functional_result = functional.poynting_e_cross_h(DXES)(E_FIELD, H_FIELD)
|
|
||||||
|
|
||||||
assert_fields_match(functional_result, matrix_result)
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
# ruff: noqa: ARG001
|
from typing import Iterable
|
||||||
import pytest # type: ignore
|
import pytest # type: ignore
|
||||||
import numpy
|
import numpy
|
||||||
from numpy.typing import NDArray
|
from numpy.typing import NDArray
|
||||||
from numpy.testing import assert_allclose
|
from numpy.testing import assert_allclose
|
||||||
|
|
||||||
from .. import fdfd
|
from .. import fdfd
|
||||||
from ..fdmath import vec, unvec, dx_lists_mut, vfdfield, cfdfield_t
|
from ..fdmath import vec, unvec, dx_lists_mut
|
||||||
#from .utils import assert_close, assert_fields_close
|
#from .utils import assert_close, assert_fields_close
|
||||||
from .test_fdfd import FDResult
|
from .test_fdfd import FDResult
|
||||||
from .conftest import FixtureRequest
|
from .conftest import FixtureRequest
|
||||||
|
|
@ -44,51 +44,49 @@ def test_pml(sim: FDResult, src_polarity: int) -> None:
|
||||||
# Also see conftest.py
|
# Also see conftest.py
|
||||||
|
|
||||||
@pytest.fixture(params=[1 / 1500])
|
@pytest.fixture(params=[1 / 1500])
|
||||||
def omega(request: FixtureRequest) -> float:
|
def omega(request: FixtureRequest) -> Iterable[float]:
|
||||||
return request.param
|
yield request.param
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(params=[None])
|
@pytest.fixture(params=[None])
|
||||||
def pec(request: FixtureRequest) -> NDArray[numpy.float64] | None:
|
def pec(request: FixtureRequest) -> Iterable[NDArray[numpy.float64] | None]:
|
||||||
return request.param
|
yield request.param
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(params=[None])
|
@pytest.fixture(params=[None])
|
||||||
def pmc(request: FixtureRequest) -> NDArray[numpy.float64] | None:
|
def pmc(request: FixtureRequest) -> Iterable[NDArray[numpy.float64] | None]:
|
||||||
return request.param
|
yield request.param
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(params=[(30, 1, 1),
|
@pytest.fixture(params=[(30, 1, 1),
|
||||||
(1, 30, 1),
|
(1, 30, 1),
|
||||||
(1, 1, 30)])
|
(1, 1, 30)])
|
||||||
def shape(request: FixtureRequest) -> tuple[int, int, int]:
|
def shape(request: FixtureRequest) -> Iterable[tuple[int, ...]]:
|
||||||
return (3, *request.param)
|
yield (3, *request.param)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(params=[+1, -1])
|
@pytest.fixture(params=[+1, -1])
|
||||||
def src_polarity(request: FixtureRequest) -> int:
|
def src_polarity(request: FixtureRequest) -> Iterable[int]:
|
||||||
return request.param
|
yield request.param
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture()
|
||||||
def j_distribution(
|
def j_distribution(
|
||||||
request: FixtureRequest,
|
request: FixtureRequest,
|
||||||
shape: tuple[int, ...],
|
shape: tuple[int, ...],
|
||||||
epsilon: vfdfield,
|
epsilon: NDArray[numpy.float64],
|
||||||
dxes: dx_lists_mut,
|
dxes: dx_lists_mut,
|
||||||
omega: float,
|
omega: float,
|
||||||
src_polarity: int,
|
src_polarity: int,
|
||||||
) -> cfdfield_t:
|
) -> Iterable[NDArray[numpy.complex128]]:
|
||||||
j = numpy.zeros(shape, dtype=complex)
|
j = numpy.zeros(shape, dtype=complex)
|
||||||
|
|
||||||
dim = numpy.where(numpy.array(shape[1:]) > 1)[0][0] # Propagation axis
|
dim = numpy.where(numpy.array(shape[1:]) > 1)[0][0] # Propagation axis
|
||||||
other_dims = [0, 1, 2]
|
other_dims = [0, 1, 2]
|
||||||
other_dims.remove(dim)
|
other_dims.remove(dim)
|
||||||
|
|
||||||
dx_prop = (
|
dx_prop = (dxes[0][dim][shape[dim + 1] // 2]
|
||||||
dxes[0][dim][shape[dim + 1] // 2]
|
+ dxes[1][dim][shape[dim + 1] // 2]) / 2 # noqa: E128 # TODO is this right for nonuniform dxes?
|
||||||
+ dxes[1][dim][shape[dim + 1] // 2]
|
|
||||||
) / 2 # TODO is this right for nonuniform dxes?
|
|
||||||
|
|
||||||
# Mask only contains components orthogonal to propagation direction
|
# Mask only contains components orthogonal to propagation direction
|
||||||
center_mask = numpy.zeros(shape, dtype=bool)
|
center_mask = numpy.zeros(shape, dtype=bool)
|
||||||
|
|
@ -108,18 +106,18 @@ def j_distribution(
|
||||||
|
|
||||||
j = fdfd.waveguide_3d.compute_source(E=e, wavenumber=wavenumber_corrected, omega=omega, dxes=dxes,
|
j = fdfd.waveguide_3d.compute_source(E=e, wavenumber=wavenumber_corrected, omega=omega, dxes=dxes,
|
||||||
axis=dim, polarity=src_polarity, slices=slices, epsilon=epsilon)
|
axis=dim, polarity=src_polarity, slices=slices, epsilon=epsilon)
|
||||||
return j
|
yield j
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture()
|
||||||
def epsilon(
|
def epsilon(
|
||||||
request: FixtureRequest,
|
request: FixtureRequest,
|
||||||
shape: tuple[int, ...],
|
shape: tuple[int, ...],
|
||||||
epsilon_bg: float,
|
epsilon_bg: float,
|
||||||
epsilon_fg: float,
|
epsilon_fg: float,
|
||||||
) -> NDArray[numpy.float64]:
|
) -> Iterable[NDArray[numpy.float64]]:
|
||||||
epsilon = numpy.full(shape, epsilon_fg, dtype=float)
|
epsilon = numpy.full(shape, epsilon_fg, dtype=float)
|
||||||
return epsilon
|
yield epsilon
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(params=['uniform'])
|
@pytest.fixture(params=['uniform'])
|
||||||
|
|
@ -129,7 +127,7 @@ def dxes(
|
||||||
dx: float,
|
dx: float,
|
||||||
omega: float,
|
omega: float,
|
||||||
epsilon_fg: float,
|
epsilon_fg: float,
|
||||||
) -> list[list[NDArray[numpy.float64]]]:
|
) -> Iterable[list[list[NDArray[numpy.float64]]]]:
|
||||||
if request.param == 'uniform':
|
if request.param == 'uniform':
|
||||||
dxes = [[numpy.full(s, dx) for s in shape[1:]] for _ in range(2)]
|
dxes = [[numpy.full(s, dx) for s in shape[1:]] for _ in range(2)]
|
||||||
dim = numpy.where(numpy.array(shape[1:]) > 1)[0][0] # Propagation axis
|
dim = numpy.where(numpy.array(shape[1:]) > 1)[0][0] # Propagation axis
|
||||||
|
|
@ -143,10 +141,10 @@ def dxes(
|
||||||
epsilon_effective=epsilon_fg,
|
epsilon_effective=epsilon_fg,
|
||||||
thickness=10,
|
thickness=10,
|
||||||
)
|
)
|
||||||
return dxes
|
yield dxes
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture()
|
||||||
def sim(
|
def sim(
|
||||||
request: FixtureRequest,
|
request: FixtureRequest,
|
||||||
shape: tuple[int, ...],
|
shape: tuple[int, ...],
|
||||||
|
|
@ -164,7 +162,7 @@ def sim(
|
||||||
omega=omega,
|
omega=omega,
|
||||||
dxes=dxes,
|
dxes=dxes,
|
||||||
epsilon=eps_vec,
|
epsilon=eps_vec,
|
||||||
matrix_solver_opts={'atol': 1e-15, 'rtol': 1e-11},
|
matrix_solver_opts={'atol': 1e-15, 'tol': 1e-11},
|
||||||
)
|
)
|
||||||
e = unvec(e_vec, shape[1:])
|
e = unvec(e_vec, shape[1:])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
from typing import cast
|
|
||||||
|
|
||||||
import numpy
|
|
||||||
|
|
||||||
from ..fdfd import solvers
|
|
||||||
from ._solver_cases import solver_plumbing_case
|
|
||||||
from .utils import assert_close
|
|
||||||
|
|
||||||
|
|
||||||
def test_scipy_qmr_wraps_user_callback_without_recursion(monkeypatch) -> None:
|
|
||||||
seen: list[tuple[float, ...]] = []
|
|
||||||
|
|
||||||
def fake_qmr(a, b: numpy.ndarray, **kwargs):
|
|
||||||
kwargs['callback'](numpy.array([1.0, 2.0]))
|
|
||||||
return numpy.array([3.0, 4.0]), 0
|
|
||||||
|
|
||||||
monkeypatch.setattr(solvers.scipy.sparse.linalg, 'qmr', fake_qmr)
|
|
||||||
result = solvers._scipy_qmr(
|
|
||||||
solver_plumbing_case().a0,
|
|
||||||
numpy.array([1.0, 0.0]),
|
|
||||||
callback=lambda xk: seen.append(tuple(xk)),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert_close(result, [3.0, 4.0])
|
|
||||||
assert seen == [(1.0, 2.0)]
|
|
||||||
|
|
||||||
|
|
||||||
def test_scipy_qmr_installs_logging_callback_when_missing(monkeypatch) -> None:
|
|
||||||
callback_seen: list[numpy.ndarray] = []
|
|
||||||
|
|
||||||
def fake_qmr(a, b: numpy.ndarray, **kwargs):
|
|
||||||
callback = kwargs['callback']
|
|
||||||
callback(numpy.array([5.0, 6.0]))
|
|
||||||
callback_seen.append(b.copy())
|
|
||||||
return numpy.array([7.0, 8.0]), 0
|
|
||||||
|
|
||||||
monkeypatch.setattr(solvers.scipy.sparse.linalg, 'qmr', fake_qmr)
|
|
||||||
result = solvers._scipy_qmr(solver_plumbing_case().a0, numpy.array([1.0, 0.0]))
|
|
||||||
|
|
||||||
assert_close(result, [7.0, 8.0])
|
|
||||||
assert len(callback_seen) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_generic_forward_preconditions_system_and_guess(monkeypatch) -> None:
|
|
||||||
case = solver_plumbing_case()
|
|
||||||
captured: dict[str, numpy.ndarray | float | object] = {}
|
|
||||||
|
|
||||||
monkeypatch.setattr(solvers.operators, 'e_full', lambda *args, **kwargs: case.a0)
|
|
||||||
monkeypatch.setattr(solvers.operators, 'e_full_preconditioners', lambda dxes: (case.pl, case.pr))
|
|
||||||
|
|
||||||
def fake_solver(a, b: numpy.ndarray, **kwargs):
|
|
||||||
captured['a'] = a
|
|
||||||
captured['b'] = b
|
|
||||||
captured['x0'] = kwargs['x0']
|
|
||||||
captured['atol'] = kwargs['atol']
|
|
||||||
return case.solver_result
|
|
||||||
|
|
||||||
result = solvers.generic(
|
|
||||||
omega=case.omega,
|
|
||||||
dxes=case.dxes,
|
|
||||||
J=case.j,
|
|
||||||
epsilon=case.epsilon,
|
|
||||||
matrix_solver=fake_solver,
|
|
||||||
matrix_solver_opts={'atol': 1e-12},
|
|
||||||
E_guess=case.guess,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert_close(cast(object, captured['a']).toarray(), (case.pl @ case.a0 @ case.pr).toarray()) # type: ignore[attr-defined]
|
|
||||||
assert_close(cast(numpy.ndarray, captured['b']), case.pl @ (-1j * case.omega * case.j))
|
|
||||||
assert_close(cast(numpy.ndarray, captured['x0']), case.pl @ case.guess)
|
|
||||||
assert captured['atol'] == 1e-12
|
|
||||||
assert_close(result, case.pr @ case.solver_result)
|
|
||||||
|
|
||||||
|
|
||||||
def test_generic_adjoint_preconditions_system_and_guess(monkeypatch) -> None:
|
|
||||||
case = solver_plumbing_case()
|
|
||||||
captured: dict[str, numpy.ndarray | float | object] = {}
|
|
||||||
|
|
||||||
monkeypatch.setattr(solvers.operators, 'e_full', lambda *args, **kwargs: case.a0)
|
|
||||||
monkeypatch.setattr(solvers.operators, 'e_full_preconditioners', lambda dxes: (case.pl, case.pr))
|
|
||||||
|
|
||||||
def fake_solver(a, b: numpy.ndarray, **kwargs):
|
|
||||||
captured['a'] = a
|
|
||||||
captured['b'] = b
|
|
||||||
captured['x0'] = kwargs['x0']
|
|
||||||
captured['rtol'] = kwargs['rtol']
|
|
||||||
return case.solver_result
|
|
||||||
|
|
||||||
result = solvers.generic(
|
|
||||||
omega=case.omega,
|
|
||||||
dxes=case.dxes,
|
|
||||||
J=case.j,
|
|
||||||
epsilon=case.epsilon,
|
|
||||||
matrix_solver=fake_solver,
|
|
||||||
matrix_solver_opts={'rtol': 1e-9},
|
|
||||||
E_guess=case.guess,
|
|
||||||
adjoint=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
expected_matrix = (case.pl @ case.a0 @ case.pr).T.conjugate()
|
|
||||||
assert_close(cast(object, captured['a']).toarray(), expected_matrix.toarray()) # type: ignore[attr-defined]
|
|
||||||
assert_close(cast(numpy.ndarray, captured['b']), case.pr.T.conjugate() @ (-1j * case.omega * case.j))
|
|
||||||
assert_close(cast(numpy.ndarray, captured['x0']), case.pr.T.conjugate() @ case.guess)
|
|
||||||
assert captured['rtol'] == 1e-9
|
|
||||||
assert_close(result, case.pl.T.conjugate() @ case.solver_result)
|
|
||||||
|
|
||||||
|
|
||||||
def test_generic_without_guess_does_not_inject_x0(monkeypatch) -> None:
|
|
||||||
case = solver_plumbing_case()
|
|
||||||
captured: dict[str, object] = {}
|
|
||||||
|
|
||||||
monkeypatch.setattr(solvers.operators, 'e_full', lambda *args, **kwargs: case.a0)
|
|
||||||
monkeypatch.setattr(solvers.operators, 'e_full_preconditioners', lambda dxes: (case.pl, case.pr))
|
|
||||||
|
|
||||||
def fake_solver(a, b: numpy.ndarray, **kwargs):
|
|
||||||
captured['kwargs'] = kwargs
|
|
||||||
return numpy.array([1.0, -1.0])
|
|
||||||
|
|
||||||
result = solvers.generic(
|
|
||||||
omega=1.0,
|
|
||||||
dxes=case.dxes,
|
|
||||||
J=numpy.array([2.0, 3.0]),
|
|
||||||
epsilon=case.epsilon,
|
|
||||||
matrix_solver=fake_solver,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert 'x0' not in cast(dict[str, object], captured['kwargs'])
|
|
||||||
assert_close(result, case.pr @ numpy.array([1.0, -1.0]))
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
import numpy
|
|
||||||
|
|
||||||
from ..fdmath import functional as fd_functional
|
|
||||||
from ..fdmath import operators as fd_operators
|
|
||||||
from ..fdmath import vec, unvec
|
|
||||||
from .utils import assert_close, assert_fields_close
|
|
||||||
|
|
||||||
|
|
||||||
SHAPE = (2, 3, 2)
|
|
||||||
DX_E = [numpy.array([1.0, 1.5]), numpy.array([0.75, 1.25, 1.5]), numpy.array([1.2, 0.8])]
|
|
||||||
DX_H = [numpy.array([0.9, 1.4]), numpy.array([0.8, 1.1, 1.4]), numpy.array([1.0, 0.7])]
|
|
||||||
|
|
||||||
SCALAR_FIELD = (
|
|
||||||
numpy.arange(numpy.prod(SHAPE)).reshape(SHAPE)
|
|
||||||
+ 0.1j * numpy.arange(numpy.prod(SHAPE)).reshape(SHAPE)
|
|
||||||
).astype(complex)
|
|
||||||
VECTOR_FIELD = (numpy.arange(3 * numpy.prod(SHAPE)).reshape((3, *SHAPE)) + 0.25j).astype(complex)
|
|
||||||
|
|
||||||
|
|
||||||
def test_deriv_forward_without_dx_matches_numpy_roll() -> None:
|
|
||||||
for axis, deriv in enumerate(fd_functional.deriv_forward()):
|
|
||||||
expected = numpy.roll(SCALAR_FIELD, -1, axis=axis) - SCALAR_FIELD
|
|
||||||
assert_close(deriv(SCALAR_FIELD), expected)
|
|
||||||
|
|
||||||
|
|
||||||
def test_deriv_back_without_dx_matches_numpy_roll() -> None:
|
|
||||||
for axis, deriv in enumerate(fd_functional.deriv_back()):
|
|
||||||
expected = SCALAR_FIELD - numpy.roll(SCALAR_FIELD, 1, axis=axis)
|
|
||||||
assert_close(deriv(SCALAR_FIELD), expected)
|
|
||||||
|
|
||||||
|
|
||||||
def test_curl_parts_sum_to_full_curl() -> None:
|
|
||||||
curl_forward = fd_functional.curl_forward(DX_E)(VECTOR_FIELD)
|
|
||||||
curl_back = fd_functional.curl_back(DX_H)(VECTOR_FIELD)
|
|
||||||
forward_parts = fd_functional.curl_forward_parts(DX_E)(VECTOR_FIELD)
|
|
||||||
back_parts = fd_functional.curl_back_parts(DX_H)(VECTOR_FIELD)
|
|
||||||
|
|
||||||
for axis in range(3):
|
|
||||||
assert_close(forward_parts[axis][0] + forward_parts[axis][1], curl_forward[axis])
|
|
||||||
assert_close(back_parts[axis][0] + back_parts[axis][1], curl_back[axis])
|
|
||||||
|
|
||||||
|
|
||||||
def test_derivatives_match_sparse_operators_on_nonuniform_grid() -> None:
|
|
||||||
for axis, deriv in enumerate(fd_functional.deriv_forward(DX_E)):
|
|
||||||
matrix_result = (fd_operators.deriv_forward(DX_E)[axis] @ SCALAR_FIELD.ravel(order='C')).reshape(SHAPE, order='C')
|
|
||||||
assert_close(deriv(SCALAR_FIELD), matrix_result, atol=1e-12, rtol=1e-12)
|
|
||||||
|
|
||||||
for axis, deriv in enumerate(fd_functional.deriv_back(DX_H)):
|
|
||||||
matrix_result = (fd_operators.deriv_back(DX_H)[axis] @ SCALAR_FIELD.ravel(order='C')).reshape(SHAPE, order='C')
|
|
||||||
assert_close(deriv(SCALAR_FIELD), matrix_result, atol=1e-12, rtol=1e-12)
|
|
||||||
|
|
||||||
|
|
||||||
def test_curls_match_sparse_operators_on_nonuniform_grid() -> None:
|
|
||||||
curl_forward = fd_functional.curl_forward(DX_E)(VECTOR_FIELD)
|
|
||||||
curl_back = fd_functional.curl_back(DX_H)(VECTOR_FIELD)
|
|
||||||
matrix_forward = unvec(fd_operators.curl_forward(DX_E) @ vec(VECTOR_FIELD), SHAPE)
|
|
||||||
matrix_back = unvec(fd_operators.curl_back(DX_H) @ vec(VECTOR_FIELD), SHAPE)
|
|
||||||
|
|
||||||
assert_fields_close(curl_forward, matrix_forward, atol=1e-12, rtol=1e-12)
|
|
||||||
assert_fields_close(curl_back, matrix_back, atol=1e-12, rtol=1e-12)
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
import numpy
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from ..fdmath import operators, unvec, vec
|
|
||||||
from ._test_builders import real_ramp
|
|
||||||
from .utils import assert_close
|
|
||||||
|
|
||||||
|
|
||||||
SHAPE = (2, 3, 2)
|
|
||||||
SCALAR_FIELD = real_ramp(SHAPE)
|
|
||||||
VECTOR_LEFT = real_ramp((3, *SHAPE), offset=0.5)
|
|
||||||
VECTOR_RIGHT = real_ramp((3, *SHAPE), scale=1 / 3, offset=2.0)
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_scalar_matrix(op: operators.sparse.spmatrix) -> numpy.ndarray:
|
|
||||||
return (op @ SCALAR_FIELD.ravel(order='C')).reshape(SHAPE, order='C')
|
|
||||||
|
|
||||||
|
|
||||||
def _mirrored_indices(size: int, shift_distance: int) -> numpy.ndarray:
|
|
||||||
indices = numpy.arange(size) + shift_distance
|
|
||||||
indices = numpy.where(indices >= size, 2 * size - indices - 1, indices)
|
|
||||||
indices = numpy.where(indices < 0, -1 - indices, indices)
|
|
||||||
return indices
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(('axis', 'shift_distance'), [(0, 1), (1, -1), (2, 1)])
|
|
||||||
def test_shift_circ_matches_numpy_roll(axis: int, shift_distance: int) -> None:
|
|
||||||
matrix_result = _apply_scalar_matrix(operators.shift_circ(axis, SHAPE, shift_distance))
|
|
||||||
expected = numpy.roll(SCALAR_FIELD, -shift_distance, axis=axis)
|
|
||||||
assert_close(matrix_result, expected)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(('axis', 'shift_distance'), [(0, 1), (1, -1), (2, 1)])
|
|
||||||
def test_shift_with_mirror_matches_explicit_mirrored_indices(axis: int, shift_distance: int) -> None:
|
|
||||||
matrix_result = _apply_scalar_matrix(operators.shift_with_mirror(axis, SHAPE, shift_distance))
|
|
||||||
indices = [numpy.arange(length) for length in SHAPE]
|
|
||||||
indices[axis] = _mirrored_indices(SHAPE[axis], shift_distance)
|
|
||||||
expected = SCALAR_FIELD[numpy.ix_(*indices)]
|
|
||||||
assert_close(matrix_result, expected)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
('args', 'message'),
|
|
||||||
[
|
|
||||||
((0, (2,), 1), 'Invalid shape'),
|
|
||||||
((3, SHAPE, 1), 'Invalid direction'),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_shift_circ_rejects_invalid_arguments(args: tuple[int, tuple[int, ...], int], message: str) -> None:
|
|
||||||
with pytest.raises(Exception, match=message):
|
|
||||||
operators.shift_circ(*args)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
('args', 'message'),
|
|
||||||
[
|
|
||||||
((0, (2,), 1), 'Invalid shape'),
|
|
||||||
((3, SHAPE, 1), 'Invalid direction'),
|
|
||||||
((0, SHAPE, SHAPE[0]), 'too large'),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_shift_with_mirror_rejects_invalid_arguments(args: tuple[int, tuple[int, ...], int], message: str) -> None:
|
|
||||||
with pytest.raises(Exception, match=message):
|
|
||||||
operators.shift_with_mirror(*args)
|
|
||||||
|
|
||||||
|
|
||||||
def test_vec_cross_matches_pointwise_cross_product() -> None:
|
|
||||||
matrix_result = unvec(operators.vec_cross(vec(VECTOR_LEFT)) @ vec(VECTOR_RIGHT), SHAPE)
|
|
||||||
expected = numpy.empty_like(VECTOR_LEFT)
|
|
||||||
expected[0] = VECTOR_LEFT[1] * VECTOR_RIGHT[2] - VECTOR_LEFT[2] * VECTOR_RIGHT[1]
|
|
||||||
expected[1] = VECTOR_LEFT[2] * VECTOR_RIGHT[0] - VECTOR_LEFT[0] * VECTOR_RIGHT[2]
|
|
||||||
expected[2] = VECTOR_LEFT[0] * VECTOR_RIGHT[1] - VECTOR_LEFT[1] * VECTOR_RIGHT[0]
|
|
||||||
assert_close(matrix_result, expected)
|
|
||||||
|
|
||||||
|
|
||||||
def test_avg_forward_matches_half_sum_with_forward_neighbor() -> None:
|
|
||||||
matrix_result = _apply_scalar_matrix(operators.avg_forward(1, SHAPE))
|
|
||||||
expected = 0.5 * (SCALAR_FIELD + numpy.roll(SCALAR_FIELD, -1, axis=1))
|
|
||||||
assert_close(matrix_result, expected)
|
|
||||||
|
|
||||||
|
|
||||||
def test_avg_back_matches_half_sum_with_backward_neighbor() -> None:
|
|
||||||
matrix_result = _apply_scalar_matrix(operators.avg_back(1, SHAPE))
|
|
||||||
expected = 0.5 * (SCALAR_FIELD + numpy.roll(SCALAR_FIELD, 1, axis=1))
|
|
||||||
assert_close(matrix_result, expected)
|
|
||||||
|
|
||||||
|
|
||||||
def test_avg_forward_rejects_invalid_shape() -> None:
|
|
||||||
with pytest.raises(Exception, match='Invalid shape'):
|
|
||||||
operators.avg_forward(0, (2,))
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue