photonfdtd 0.12.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- photonfdtd/__init__.py +84 -0
- photonfdtd/adapters.py +190 -0
- photonfdtd/adi.py +337 -0
- photonfdtd/boundaries.py +97 -0
- photonfdtd/constants.py +8 -0
- photonfdtd/design.py +175 -0
- photonfdtd/eim.py +319 -0
- photonfdtd/eme.py +267 -0
- photonfdtd/geometry.py +154 -0
- photonfdtd/grid.py +111 -0
- photonfdtd/jaxbackend.py +773 -0
- photonfdtd/materials.py +268 -0
- photonfdtd/memory.py +165 -0
- photonfdtd/mode.py +387 -0
- photonfdtd/monitors.py +191 -0
- photonfdtd/outofcore.py +424 -0
- photonfdtd/reversible.py +248 -0
- photonfdtd/reversible_pml.py +287 -0
- photonfdtd/rustbackend.py +461 -0
- photonfdtd/simulation.py +1459 -0
- photonfdtd/smatrix.py +118 -0
- photonfdtd/smoothing.py +172 -0
- photonfdtd/sources.py +544 -0
- photonfdtd/storage.py +219 -0
- photonfdtd-0.12.0.dist-info/METADATA +254 -0
- photonfdtd-0.12.0.dist-info/RECORD +28 -0
- photonfdtd-0.12.0.dist-info/WHEEL +4 -0
- photonfdtd-0.12.0.dist-info/licenses/LICENSE +21 -0
photonfdtd/__init__.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""photonfdtd - local FDTD + waveguide mode solver for integrated photonics.
|
|
2
|
+
|
|
3
|
+
Capabilities:
|
|
4
|
+
|
|
5
|
+
- 1D / 2D / 3D Yee-grid FDTD with CPML absorbing boundaries
|
|
6
|
+
- Isotropic dielectric media, non-dispersive or dispersive (Lorentz / Drude /
|
|
7
|
+
Sellmeier poles stepped by the ADE method), with a cited material library
|
|
8
|
+
(SiO2, Si, Si3N4, LiNbO3, Au, Ag)
|
|
9
|
+
- Anisotropic subpixel (sub-cell) smoothing of material interfaces for
|
|
10
|
+
second-order accuracy at boundaries (``Simulation(subpixel=True)``)
|
|
11
|
+
- Axis-aligned Box and arbitrary-polygon PolySlab geometry primitives
|
|
12
|
+
- Point-dipole, distributed mode, and energy-normalised single-photon sources
|
|
13
|
+
- Field snapshot monitors and a flux monitor through a coordinate plane
|
|
14
|
+
- Full-vectorial (FDFD) waveguide mode solver
|
|
15
|
+
- :func:`from_gdsfactory` adapter that turns a gdsfactory Component into a
|
|
16
|
+
fully-built Simulation
|
|
17
|
+
|
|
18
|
+
A NumPy backend is used throughout. It is correct but not fast; for production
|
|
19
|
+
runs of large 3D problems a compiled or GPU backend is on the roadmap.
|
|
20
|
+
"""
|
|
21
|
+
from .constants import C_0, EPS_0, MU_0, ETA_0, Q_E
|
|
22
|
+
from .grid import Grid
|
|
23
|
+
from .materials import (
|
|
24
|
+
Medium, DispersiveMedium, Pole,
|
|
25
|
+
silica, silicon, silicon_nitride, lithium_niobate, gold, silver,
|
|
26
|
+
)
|
|
27
|
+
from . import materials
|
|
28
|
+
from .geometry import Box, PolySlab
|
|
29
|
+
from .sources import (
|
|
30
|
+
GaussianPulse, PointDipole, ModeSource, UniModeSource, SinglePhotonSource,
|
|
31
|
+
ChargedParticle, single_photon_field_amplitude, PLANCK_H,
|
|
32
|
+
)
|
|
33
|
+
from .monitors import FieldMonitor, FluxMonitor, DFTMonitor
|
|
34
|
+
from .storage import CompressedFieldSeries
|
|
35
|
+
from .simulation import Simulation
|
|
36
|
+
# Differentiable JAX entry point (import is lazy - jax is an optional dep;
|
|
37
|
+
# accessing the name only fails if jax is missing when actually called).
|
|
38
|
+
from .jaxbackend import value_and_grad_eps as jax_value_and_grad_eps
|
|
39
|
+
from .reversible import (
|
|
40
|
+
value_and_grad_eps_reversible as jax_value_and_grad_eps_reversible,
|
|
41
|
+
)
|
|
42
|
+
from .reversible_pml import (
|
|
43
|
+
value_and_grad_eps_reversible_pml as jax_value_and_grad_eps_reversible_pml,
|
|
44
|
+
)
|
|
45
|
+
from .mode import ModeSolver
|
|
46
|
+
from .smatrix import mode_amplitudes, port_fields, s_parameters
|
|
47
|
+
from .eim import (
|
|
48
|
+
Layer, SlabModes, slab_modes, effective_index, EffectiveIndex2D,
|
|
49
|
+
)
|
|
50
|
+
from .eme import Section, EMEResult, eme_2d, sections_from_eps
|
|
51
|
+
from .adi import ADISimulation2D, ADISource, ADIResult
|
|
52
|
+
from .design import (
|
|
53
|
+
EtchedCore, value_and_grad_density, conic_filter, tanh_projection,
|
|
54
|
+
)
|
|
55
|
+
from .memory import (
|
|
56
|
+
estimate_memory, recommend_mode, format_report, available_memory,
|
|
57
|
+
)
|
|
58
|
+
from . import adapters
|
|
59
|
+
from .adapters import from_gdsfactory
|
|
60
|
+
|
|
61
|
+
__all__ = [
|
|
62
|
+
"C_0", "EPS_0", "MU_0", "ETA_0", "Q_E", "PLANCK_H",
|
|
63
|
+
"Grid", "Medium", "DispersiveMedium", "Pole", "materials",
|
|
64
|
+
"silica", "silicon", "silicon_nitride", "lithium_niobate", "gold", "silver",
|
|
65
|
+
"Box", "PolySlab",
|
|
66
|
+
"GaussianPulse", "PointDipole", "ModeSource", "UniModeSource",
|
|
67
|
+
"SinglePhotonSource",
|
|
68
|
+
"ChargedParticle", "single_photon_field_amplitude",
|
|
69
|
+
"FieldMonitor", "FluxMonitor", "DFTMonitor",
|
|
70
|
+
"CompressedFieldSeries",
|
|
71
|
+
"Simulation", "ModeSolver", "jax_value_and_grad_eps",
|
|
72
|
+
"jax_value_and_grad_eps_reversible",
|
|
73
|
+
"jax_value_and_grad_eps_reversible_pml",
|
|
74
|
+
"mode_amplitudes", "port_fields", "s_parameters",
|
|
75
|
+
# 2.5-D effective-index reduction, eigenmode expansion, ADI-FDTD
|
|
76
|
+
"Layer", "SlabModes", "slab_modes", "effective_index", "EffectiveIndex2D",
|
|
77
|
+
"Section", "EMEResult", "eme_2d", "sections_from_eps",
|
|
78
|
+
"ADISimulation2D", "ADISource", "ADIResult",
|
|
79
|
+
"EtchedCore", "value_and_grad_density", "conic_filter", "tanh_projection",
|
|
80
|
+
"estimate_memory", "recommend_mode", "format_report", "available_memory",
|
|
81
|
+
"adapters", "from_gdsfactory",
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
__version__ = "0.12.0"
|
photonfdtd/adapters.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Adapters from third-party layout tools into a photonfdtd Simulation.
|
|
2
|
+
|
|
3
|
+
The single public function is :func:`from_gdsfactory`, which turns a
|
|
4
|
+
:class:`gdsfactory.Component` into a fully-built :class:`Simulation` (with
|
|
5
|
+
the grid sized to the layout bounding box plus a configurable padding,
|
|
6
|
+
polygons rasterised onto the Yee grid, and CPML on every transverse axis).
|
|
7
|
+
|
|
8
|
+
gdsfactory itself is an optional dependency: this module imports it lazily,
|
|
9
|
+
so a user who never touches the adapter will not pay an import cost.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
from typing import Dict, Iterable, Optional, Sequence, Tuple
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from .grid import Grid
|
|
16
|
+
from .materials import Medium
|
|
17
|
+
from .geometry import Box, PolySlab
|
|
18
|
+
from .simulation import Simulation
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
LayerSpec = int # gdsfactory layer index, e.g. 1 for WG core
|
|
22
|
+
LayerMap = Dict[LayerSpec, Tuple[Medium, Tuple[float, float]]]
|
|
23
|
+
BackgroundSlabs = Iterable[Tuple[Medium, Tuple[float, float]]]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _component_polygons_um(component) -> Dict[int, list]:
|
|
27
|
+
"""Return {layer_index: [Nx2 numpy arrays of (x, y) in microns, ...]}.
|
|
28
|
+
|
|
29
|
+
Works against gdsfactory >=9.x (kfactory-based) Components.
|
|
30
|
+
"""
|
|
31
|
+
raw = component.get_polygons()
|
|
32
|
+
dbu = float(component.kcl.dbu) # microns per database unit
|
|
33
|
+
out: Dict[int, list] = {}
|
|
34
|
+
for layer_key, plist in raw.items():
|
|
35
|
+
polys_um = []
|
|
36
|
+
for p in plist:
|
|
37
|
+
pts = np.array([(pt.x * dbu, pt.y * dbu) for pt in p.each_point_hull()],
|
|
38
|
+
dtype=float)
|
|
39
|
+
if pts.shape[0] >= 3:
|
|
40
|
+
polys_um.append(pts)
|
|
41
|
+
if polys_um:
|
|
42
|
+
out[layer_key] = polys_um
|
|
43
|
+
return out
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def from_gdsfactory(
|
|
47
|
+
component,
|
|
48
|
+
*,
|
|
49
|
+
layers: LayerMap,
|
|
50
|
+
background_slabs: BackgroundSlabs = (),
|
|
51
|
+
cell_size: float,
|
|
52
|
+
padding: Tuple[float, float, float] = (2e-6, 2e-6, 1e-6),
|
|
53
|
+
pml_layers: Tuple[int, int, int] = (12, 12, 0),
|
|
54
|
+
z_size: Optional[float] = None,
|
|
55
|
+
run_time: float = 1e-12,
|
|
56
|
+
extra_structures: Sequence = (),
|
|
57
|
+
courant: float = 0.99,
|
|
58
|
+
use_gpu: bool = False,
|
|
59
|
+
use_numba: bool = False,
|
|
60
|
+
precision="float64",
|
|
61
|
+
xy_bounds: Optional[Tuple[float, float, float, float]] = None,
|
|
62
|
+
) -> Simulation:
|
|
63
|
+
"""Build a 3D photonfdtd Simulation from a gdsfactory Component.
|
|
64
|
+
|
|
65
|
+
Parameters
|
|
66
|
+
----------
|
|
67
|
+
component : gdsfactory.Component
|
|
68
|
+
Layout source. Polygons are read via ``component.get_polygons()`` and
|
|
69
|
+
scaled from database units to microns via ``component.kcl.dbu``.
|
|
70
|
+
layers : dict
|
|
71
|
+
``{layer_index: (Medium, (z_min_m, z_max_m))}``. Each gdsfactory
|
|
72
|
+
layer is rasterised as a :class:`PolySlab` filled with ``Medium``
|
|
73
|
+
between ``z_min`` and ``z_max`` (both in metres). Layers not in this
|
|
74
|
+
dict are ignored.
|
|
75
|
+
background_slabs : iterable, optional
|
|
76
|
+
``[(Medium, (z_min_m, z_max_m)), ...]`` of unpatterned z-slabs that
|
|
77
|
+
span the whole xy-extent (e.g. buried oxide, substrate, top
|
|
78
|
+
cladding). Stamped before the layer polygons so the polygons win in
|
|
79
|
+
regions of overlap.
|
|
80
|
+
cell_size : float
|
|
81
|
+
Yee cell size in metres. The same value is used on every axis.
|
|
82
|
+
padding : (px, py, pz), in metres
|
|
83
|
+
Extra extent added to the component bounding box on each axis. The
|
|
84
|
+
z padding is symmetric around the z extent inferred from the layers.
|
|
85
|
+
pml_layers : (nx, ny, nz)
|
|
86
|
+
CPML thickness in cells per axis.
|
|
87
|
+
z_size : float, optional
|
|
88
|
+
Override the auto-computed z extent. If ``None``, z extent equals
|
|
89
|
+
``max_z - min_z + 2*padding[2]`` where ``min_z`` and ``max_z`` are
|
|
90
|
+
taken from ``layers`` and ``background_slabs``.
|
|
91
|
+
run_time : float
|
|
92
|
+
FDTD run time in seconds. Passed through to the Simulation.
|
|
93
|
+
extra_structures : sequence
|
|
94
|
+
Additional Box/PolySlab structures stamped after the layer polygons
|
|
95
|
+
(highest priority).
|
|
96
|
+
courant : float
|
|
97
|
+
Courant safety factor on the FDTD timestep.
|
|
98
|
+
|
|
99
|
+
Returns
|
|
100
|
+
-------
|
|
101
|
+
Simulation
|
|
102
|
+
Built but not yet run. Add sources and monitors before calling
|
|
103
|
+
``sim.run()``.
|
|
104
|
+
"""
|
|
105
|
+
polys_by_layer = _component_polygons_um(component)
|
|
106
|
+
|
|
107
|
+
# XY domain in microns -> centre and extent in metres. Normally the whole
|
|
108
|
+
# component bounding box (the kfactory-era DBox exposes .left/.right/
|
|
109
|
+
# .bottom/.top in microns); xy_bounds (x_lo, x_hi, y_lo, y_hi in metres)
|
|
110
|
+
# restricts it to a sub-region instead, so only that window is gridded.
|
|
111
|
+
# Polygons outside the grid simply don't rasterise, so the structure is
|
|
112
|
+
# clipped to the region automatically.
|
|
113
|
+
if xy_bounds is not None:
|
|
114
|
+
x_lo_um, x_hi_um = xy_bounds[0] * 1e6, xy_bounds[1] * 1e6
|
|
115
|
+
y_lo_um, y_hi_um = xy_bounds[2] * 1e6, xy_bounds[3] * 1e6
|
|
116
|
+
else:
|
|
117
|
+
bb = component.bbox()
|
|
118
|
+
x_lo_um, x_hi_um = float(bb.left), float(bb.right)
|
|
119
|
+
y_lo_um, y_hi_um = float(bb.bottom), float(bb.top)
|
|
120
|
+
x_center = 0.5 * (x_lo_um + x_hi_um) * 1e-6
|
|
121
|
+
y_center = 0.5 * (y_lo_um + y_hi_um) * 1e-6
|
|
122
|
+
x_extent = (x_hi_um - x_lo_um) * 1e-6 + 2.0 * padding[0]
|
|
123
|
+
y_extent = (y_hi_um - y_lo_um) * 1e-6 + 2.0 * padding[1]
|
|
124
|
+
|
|
125
|
+
# z extent
|
|
126
|
+
z_mins, z_maxs = [], []
|
|
127
|
+
for _med, (z0, z1) in layers.values():
|
|
128
|
+
z_mins.append(min(z0, z1)); z_maxs.append(max(z0, z1))
|
|
129
|
+
for _med, (z0, z1) in background_slabs:
|
|
130
|
+
z_mins.append(min(z0, z1)); z_maxs.append(max(z0, z1))
|
|
131
|
+
if z_mins:
|
|
132
|
+
z_lo, z_hi = min(z_mins), max(z_maxs)
|
|
133
|
+
z_extent_inner = z_hi - z_lo
|
|
134
|
+
z_center = 0.5 * (z_lo + z_hi)
|
|
135
|
+
else:
|
|
136
|
+
z_extent_inner = 0.0
|
|
137
|
+
z_center = 0.0
|
|
138
|
+
z_extent_full = z_extent_inner + 2.0 * padding[2] if z_size is None else float(z_size)
|
|
139
|
+
|
|
140
|
+
grid = Grid(
|
|
141
|
+
size=(x_extent, y_extent, z_extent_full),
|
|
142
|
+
cell_size=cell_size,
|
|
143
|
+
pml_layers=pml_layers,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Build structures: background slabs first, then per-layer polygons,
|
|
147
|
+
# then any user extras. The Simulation rasteriser respects insertion
|
|
148
|
+
# order (later overrides earlier).
|
|
149
|
+
structures = []
|
|
150
|
+
|
|
151
|
+
# Background slabs: stamped as Boxes spanning the entire xy domain. The
|
|
152
|
+
# photonfdtd Grid coordinates are centred at the origin, so we shift the
|
|
153
|
+
# slab centre by -z_center to land in grid space.
|
|
154
|
+
for med, (z0, z1) in background_slabs:
|
|
155
|
+
thickness = abs(z1 - z0)
|
|
156
|
+
c_z = 0.5 * (z0 + z1) - z_center
|
|
157
|
+
structures.append(Box(
|
|
158
|
+
center=(0.0, 0.0, c_z),
|
|
159
|
+
size=(1e9, 1e9, thickness), # effectively infinite in xy
|
|
160
|
+
medium=med,
|
|
161
|
+
))
|
|
162
|
+
|
|
163
|
+
# gdsfactory polygons: shift vertices to metres and recentre on the
|
|
164
|
+
# component bbox so the grid's origin lines up with the layout's.
|
|
165
|
+
for layer_idx, (med, (z0, z1)) in layers.items():
|
|
166
|
+
if layer_idx not in polys_by_layer:
|
|
167
|
+
continue
|
|
168
|
+
for poly_um in polys_by_layer[layer_idx]:
|
|
169
|
+
verts_m = poly_um * 1e-6
|
|
170
|
+
verts_m = [(float(x - x_center), float(y - y_center)) for x, y in verts_m]
|
|
171
|
+
structures.append(PolySlab(
|
|
172
|
+
vertices=tuple(verts_m),
|
|
173
|
+
z_bounds=(float(z0) - z_center, float(z1) - z_center),
|
|
174
|
+
medium=med,
|
|
175
|
+
))
|
|
176
|
+
|
|
177
|
+
for s in extra_structures:
|
|
178
|
+
structures.append(s)
|
|
179
|
+
|
|
180
|
+
return Simulation(
|
|
181
|
+
grid=grid,
|
|
182
|
+
structures=structures,
|
|
183
|
+
sources=[],
|
|
184
|
+
monitors=[],
|
|
185
|
+
run_time=run_time,
|
|
186
|
+
courant=courant,
|
|
187
|
+
use_gpu=use_gpu,
|
|
188
|
+
use_numba=use_numba,
|
|
189
|
+
precision=precision,
|
|
190
|
+
)
|
photonfdtd/adi.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""Unconditionally-stable ADI-FDTD (2-D), CFL-free time stepping.
|
|
2
|
+
|
|
3
|
+
Explicit Yee FDTD is bound by the Courant-Friedrichs-Lewy limit: the time step
|
|
4
|
+
cannot exceed ``~dx/(c*sqrt(D))``. On a photonic-circuit grid a single small
|
|
5
|
+
feature (a narrow gap, a thin sidewall) forces a small ``dx`` everywhere it
|
|
6
|
+
touches, and CFL then forces a small ``dt`` over the *whole* domain and every
|
|
7
|
+
timestep - even where the fields are smooth. The alternating-direction-implicit
|
|
8
|
+
FDTD (Namiki 1999; Zheng, Chen & Zhang 2000) removes the stability bound
|
|
9
|
+
entirely: it is **unconditionally stable**, so ``dt`` is set by accuracy alone.
|
|
10
|
+
Where CFL was the binding constraint, that is a 3-10x cut in the number of
|
|
11
|
+
steps.
|
|
12
|
+
|
|
13
|
+
The price is two implicit sub-steps per full step, each a set of independent
|
|
14
|
+
tridiagonal solves - one direction implicit, the other explicit, then swapped.
|
|
15
|
+
This module implements the scheme for the 2-D ``(Ez, Hx, Hy)`` polarisation
|
|
16
|
+
(the out-of-plane-E field of an ``xy`` grid - the polarisation the 2.5-D
|
|
17
|
+
effective-index reduction produces). The tridiagonal solves are the natural
|
|
18
|
+
unit of work for the Rust backend (each line is independent; see
|
|
19
|
+
``rust/src/adi.rs``), so ``backend="rust"`` parallelises them across lines.
|
|
20
|
+
|
|
21
|
+
Boundaries: ``"pec"`` (perfect electric conductor, ``Ez=0`` - a closed cavity,
|
|
22
|
+
used to demonstrate that the resonant frequency is ``dt``-independent) and
|
|
23
|
+
``"absorber"`` (a graded electric+magnetic conductivity layer, impedance
|
|
24
|
+
matched at normal incidence; a basic open boundary, not as reflectionless as
|
|
25
|
+
the explicit solver's CPML).
|
|
26
|
+
|
|
27
|
+
Scheme, per full step ``n -> n+1`` (``s = dt/2``, ``c = s^2/mu0``):
|
|
28
|
+
|
|
29
|
+
sub-step 1 (x implicit):
|
|
30
|
+
[eps - c d2/dx2] Ez* = eps Ez^n + s (dHy^n/dx - dHx^n/dy)
|
|
31
|
+
Hy* = Hy^n + (s/mu0) dEz*/dx , Hx* = Hx^n - (s/mu0) dEz^n/dy
|
|
32
|
+
sub-step 2 (y implicit):
|
|
33
|
+
[eps - c d2/dy2] Ez^{n+1} = eps Ez* + s (dHy*/dx - dHx*/dy)
|
|
34
|
+
Hy^{n+1} = Hy* + (s/mu0) dEz*/dx , Hx^{n+1} = Hx* - (s/mu0) dEz^{n+1}/dy
|
|
35
|
+
"""
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
from dataclasses import dataclass, field
|
|
39
|
+
from typing import Callable, Dict, List, Optional, Sequence, Tuple
|
|
40
|
+
|
|
41
|
+
import numpy as np
|
|
42
|
+
|
|
43
|
+
from .constants import C_0, EPS_0, MU_0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _thomas(diag, lower, upper, rhs):
|
|
47
|
+
"""Solve a batch of tridiagonal systems along axis 0 (Thomas algorithm).
|
|
48
|
+
|
|
49
|
+
``diag`` is ``(n, batch)``; ``lower``/``upper`` are the (constant) sub- and
|
|
50
|
+
super-diagonals as scalars; ``rhs`` is ``(n, batch)``. Returns ``(n, batch)``.
|
|
51
|
+
Modifies copies only.
|
|
52
|
+
"""
|
|
53
|
+
n = diag.shape[0]
|
|
54
|
+
cp = np.empty_like(diag)
|
|
55
|
+
dp = np.empty_like(rhs)
|
|
56
|
+
cp[0] = upper / diag[0]
|
|
57
|
+
dp[0] = rhs[0] / diag[0]
|
|
58
|
+
for i in range(1, n):
|
|
59
|
+
m = diag[i] - lower * cp[i - 1]
|
|
60
|
+
cp[i] = upper / m
|
|
61
|
+
dp[i] = (rhs[i] - lower * dp[i - 1]) / m
|
|
62
|
+
x = np.empty_like(rhs)
|
|
63
|
+
x[-1] = dp[-1]
|
|
64
|
+
for i in range(n - 2, -1, -1):
|
|
65
|
+
x[i] = dp[i] - cp[i] * x[i + 1]
|
|
66
|
+
return x
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class ADIResult:
|
|
71
|
+
times: np.ndarray
|
|
72
|
+
snapshots: Dict[str, np.ndarray] = field(default_factory=dict)
|
|
73
|
+
probes: Dict[str, np.ndarray] = field(default_factory=dict)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class ADISource:
|
|
78
|
+
"""Soft additive ``Ez`` point source at a grid index."""
|
|
79
|
+
i: int
|
|
80
|
+
j: int
|
|
81
|
+
waveform: Callable[[np.ndarray], np.ndarray]
|
|
82
|
+
amplitude: float = 1.0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class ADISimulation2D:
|
|
86
|
+
"""2-D unconditionally-stable ADI-FDTD for the ``(Ez, Hx, Hy)`` system.
|
|
87
|
+
|
|
88
|
+
Parameters
|
|
89
|
+
----------
|
|
90
|
+
eps_r : (nx, ny) array
|
|
91
|
+
Relative permittivity at the ``Ez`` nodes.
|
|
92
|
+
cell_size : float or (dx, dy)
|
|
93
|
+
Grid step (m).
|
|
94
|
+
dt : float, optional
|
|
95
|
+
Time step (s). May exceed the explicit CFL limit (that is the point).
|
|
96
|
+
Defaults to ``courant_factor`` times the CFL step.
|
|
97
|
+
courant_factor : float
|
|
98
|
+
If ``dt`` is not given, ``dt = courant_factor * dt_CFL``. Values > 1 are
|
|
99
|
+
the CFL-breaking regime (unconditionally stable here).
|
|
100
|
+
boundary : {"pec", "absorber"}
|
|
101
|
+
Domain truncation (see module docstring).
|
|
102
|
+
pml_cells : int
|
|
103
|
+
Absorber thickness in cells (``boundary="absorber"``).
|
|
104
|
+
backend : {"numpy", "rust"}
|
|
105
|
+
``"rust"`` runs the parallel tridiagonal sweeps in the compiled core.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
def __init__(
|
|
109
|
+
self,
|
|
110
|
+
eps_r: np.ndarray,
|
|
111
|
+
cell_size,
|
|
112
|
+
*,
|
|
113
|
+
dt: Optional[float] = None,
|
|
114
|
+
courant_factor: float = 1.0,
|
|
115
|
+
boundary: str = "pec",
|
|
116
|
+
pml_cells: int = 12,
|
|
117
|
+
sources: Sequence[ADISource] = (),
|
|
118
|
+
backend: str = "numpy",
|
|
119
|
+
precision: str = "float64",
|
|
120
|
+
) -> None:
|
|
121
|
+
self.eps_r = np.ascontiguousarray(eps_r, dtype=np.float64)
|
|
122
|
+
self.nx, self.ny = self.eps_r.shape
|
|
123
|
+
if isinstance(cell_size, (int, float)):
|
|
124
|
+
self.dx = self.dy = float(cell_size)
|
|
125
|
+
else:
|
|
126
|
+
self.dx, self.dy = float(cell_size[0]), float(cell_size[1])
|
|
127
|
+
if boundary not in ("pec", "absorber"):
|
|
128
|
+
raise ValueError("boundary must be 'pec' or 'absorber'")
|
|
129
|
+
self.boundary = boundary
|
|
130
|
+
self.pml_cells = int(pml_cells)
|
|
131
|
+
self.sources = list(sources)
|
|
132
|
+
if backend not in ("numpy", "rust"):
|
|
133
|
+
raise ValueError("backend must be 'numpy' or 'rust'")
|
|
134
|
+
self.backend = backend
|
|
135
|
+
self.npdt = np.float32 if precision == "float32" else np.float64
|
|
136
|
+
|
|
137
|
+
dt_cfl = 1.0 / (C_0 * np.sqrt(1.0 / self.dx ** 2 + 1.0 / self.dy ** 2))
|
|
138
|
+
self.dt = float(dt) if dt is not None else courant_factor * dt_cfl
|
|
139
|
+
self.dt_cfl = dt_cfl
|
|
140
|
+
self.courant_factor = self.dt / dt_cfl
|
|
141
|
+
|
|
142
|
+
# Electric / magnetic conductivity profiles (0 for PEC).
|
|
143
|
+
self.sigma_e, self.sigma_m = self._build_conductivity()
|
|
144
|
+
|
|
145
|
+
# ------------------------------------------------------------------ #
|
|
146
|
+
def _build_conductivity(self):
|
|
147
|
+
nx, ny = self.nx, self.ny
|
|
148
|
+
sig_e = np.zeros((nx, ny))
|
|
149
|
+
if self.boundary != "absorber":
|
|
150
|
+
return sig_e, sig_e.copy()
|
|
151
|
+
# Graded (polynomial) conductivity ramp in the boundary layers, matched
|
|
152
|
+
# magnetic conductivity sigma_m = sigma_e * mu0/eps0 for normal-incidence
|
|
153
|
+
# impedance matching. Not a CPML - a simple lossy layer.
|
|
154
|
+
m = 3
|
|
155
|
+
npml = self.pml_cells
|
|
156
|
+
eta = MU_0 / EPS_0
|
|
157
|
+
sig_max = 0.8 * (m + 1) / (np.sqrt(eta) * self.dx)
|
|
158
|
+
ramp = np.zeros(nx)
|
|
159
|
+
prof = sig_max * (np.arange(npml)[::-1] / max(npml - 1, 1)) ** m
|
|
160
|
+
ax = np.zeros(nx); ay = np.zeros(ny)
|
|
161
|
+
ax[:npml] = prof; ax[-npml:] = prof[::-1]
|
|
162
|
+
ay[:npml] = prof; ay[-npml:] = prof[::-1]
|
|
163
|
+
sig = np.maximum(ax[:, None], ay[None, :])
|
|
164
|
+
return sig, sig * (MU_0 / EPS_0)
|
|
165
|
+
|
|
166
|
+
# ------------------------------------------------------------------ #
|
|
167
|
+
def run(self, steps: int, *, snapshot_interval: int = 0,
|
|
168
|
+
probes: Sequence[Tuple[int, int]] = ()) -> ADIResult:
|
|
169
|
+
if self.backend == "rust":
|
|
170
|
+
return self._run_rust(steps, snapshot_interval, probes)
|
|
171
|
+
return self._run_numpy(steps, snapshot_interval, probes)
|
|
172
|
+
|
|
173
|
+
# ------------------------------------------------------------------ #
|
|
174
|
+
def _run_numpy(self, steps, snapshot_interval, probes) -> ADIResult:
|
|
175
|
+
nx, ny = self.nx, self.ny
|
|
176
|
+
dx, dy, dt = self.dx, self.dy, self.dt
|
|
177
|
+
eps = self.eps_r * EPS_0
|
|
178
|
+
s = dt / 2.0
|
|
179
|
+
c = s * s / MU_0
|
|
180
|
+
|
|
181
|
+
Ez = np.zeros((nx, ny))
|
|
182
|
+
Hx = np.zeros((nx, ny - 1)) # at (i, j+1/2)
|
|
183
|
+
Hy = np.zeros((nx - 1, ny)) # at (i+1/2, j)
|
|
184
|
+
|
|
185
|
+
# Tridiagonal off-diagonal (constant): -c/dx^2 (x sweep), -c/dy^2 (y).
|
|
186
|
+
offx = -c / dx ** 2
|
|
187
|
+
offy = -c / dy ** 2
|
|
188
|
+
# Interior diagonals include the eps term; PEC -> solve interior nodes.
|
|
189
|
+
snaps: List[np.ndarray] = []
|
|
190
|
+
snap_t: List[float] = []
|
|
191
|
+
probe_vals = {f"{i},{j}": [] for (i, j) in probes}
|
|
192
|
+
times = []
|
|
193
|
+
|
|
194
|
+
for n in range(steps):
|
|
195
|
+
# ---- sub-step 1: implicit in x ---- #
|
|
196
|
+
dHy_dx = np.zeros((nx, ny)); dHx_dy = np.zeros((nx, ny))
|
|
197
|
+
dHy_dx[1:-1, :] = (Hy[1:, :] - Hy[:-1, :])[:, :] / dx
|
|
198
|
+
dHx_dy[:, 1:-1] = (Hx[:, 1:] - Hx[:, :-1])[:, :] / dy
|
|
199
|
+
rhs = eps * Ez + s * (dHy_dx - dHx_dy)
|
|
200
|
+
Ez_star = self._solve_x(eps, offx, rhs, c, dx)
|
|
201
|
+
self._inject(Ez_star, (n + 0.5), eps, s) # half-step source
|
|
202
|
+
# explicit H at the half step
|
|
203
|
+
dEz_dx = (Ez_star[1:, :] - Ez_star[:-1, :]) / dx
|
|
204
|
+
dEz_dy_star = (Ez_star[:, 1:] - Ez_star[:, :-1]) / dy
|
|
205
|
+
Hy_star = self._advance_hy(Hy, dEz_dx, s)
|
|
206
|
+
Hx_star = self._advance_hx(Hx,
|
|
207
|
+
(Ez[:, 1:] - Ez[:, :-1]) / dy, s)
|
|
208
|
+
|
|
209
|
+
# ---- sub-step 2: implicit in y ---- #
|
|
210
|
+
dHy_dx2 = np.zeros((nx, ny)); dHx_dy2 = np.zeros((nx, ny))
|
|
211
|
+
dHy_dx2[1:-1, :] = (Hy_star[1:, :] - Hy_star[:-1, :]) / dx
|
|
212
|
+
dHx_dy2[:, 1:-1] = (Hx_star[:, 1:] - Hx_star[:, :-1]) / dy
|
|
213
|
+
rhs2 = eps * Ez_star + s * (dHy_dx2 - dHx_dy2)
|
|
214
|
+
Ez_new = self._solve_y(eps, offy, rhs2, c, dy)
|
|
215
|
+
self._inject(Ez_new, (n + 1.0), eps, s)
|
|
216
|
+
dEz_dx2 = (Ez_star[1:, :] - Ez_star[:-1, :]) / dx
|
|
217
|
+
dEz_dy_new = (Ez_new[:, 1:] - Ez_new[:, :-1]) / dy
|
|
218
|
+
Hy_new = self._advance_hy(Hy_star, dEz_dx2, s)
|
|
219
|
+
Hx_new = self._advance_hx(Hx_star, dEz_dy_new, s)
|
|
220
|
+
|
|
221
|
+
Ez, Hx, Hy = Ez_new, Hx_new, Hy_new
|
|
222
|
+
|
|
223
|
+
t = (n + 1) * dt
|
|
224
|
+
times.append(t)
|
|
225
|
+
if snapshot_interval and (n % snapshot_interval == 0):
|
|
226
|
+
snaps.append(Ez.copy()); snap_t.append(t)
|
|
227
|
+
for (i, j) in probes:
|
|
228
|
+
probe_vals[f"{i},{j}"].append(Ez[i, j])
|
|
229
|
+
|
|
230
|
+
res = ADIResult(times=np.asarray(times))
|
|
231
|
+
if snaps:
|
|
232
|
+
res.snapshots["Ez"] = np.asarray(snaps)
|
|
233
|
+
res.snapshots["t"] = np.asarray(snap_t)
|
|
234
|
+
res.probes = {k: np.asarray(v) for k, v in probe_vals.items()}
|
|
235
|
+
return res
|
|
236
|
+
|
|
237
|
+
# ---- per-substep helpers (also the reference for the Rust core) ---- #
|
|
238
|
+
def _solve_x(self, eps, off, rhs, c, dx):
|
|
239
|
+
"""Solve [eps - c d2/dx2] Ez = rhs for each row, PEC/absorber ends."""
|
|
240
|
+
nx, ny = self.nx, self.ny
|
|
241
|
+
diag = eps + 2.0 * c / dx ** 2 + self.sigma_e * (self.dt / 2.0)
|
|
242
|
+
# Interior system over i=1..nx-2 (Ez=0 on PEC ends; absorber handled by
|
|
243
|
+
# the sigma term keeping ends finite but we still pin the outermost).
|
|
244
|
+
d = diag[1:-1, :].copy()
|
|
245
|
+
r = rhs[1:-1, :].copy()
|
|
246
|
+
sol = _thomas(d, off, off, r)
|
|
247
|
+
out = np.zeros((nx, ny))
|
|
248
|
+
out[1:-1, :] = sol
|
|
249
|
+
return out
|
|
250
|
+
|
|
251
|
+
def _solve_y(self, eps, off, rhs, c, dy):
|
|
252
|
+
nx, ny = self.nx, self.ny
|
|
253
|
+
diag = eps + 2.0 * c / dy ** 2 + self.sigma_e * (self.dt / 2.0)
|
|
254
|
+
d = diag[:, 1:-1].T.copy()
|
|
255
|
+
r = rhs[:, 1:-1].T.copy()
|
|
256
|
+
sol = _thomas(d, off, off, r).T
|
|
257
|
+
out = np.zeros((nx, ny))
|
|
258
|
+
out[:, 1:-1] = sol
|
|
259
|
+
return out
|
|
260
|
+
|
|
261
|
+
def _advance_hy(self, Hy, dEz_dx, s):
|
|
262
|
+
if self.boundary == "absorber":
|
|
263
|
+
sm = 0.5 * (self.sigma_m[1:, :] + self.sigma_m[:-1, :])
|
|
264
|
+
a = 1.0 - sm * s / MU_0
|
|
265
|
+
b = 1.0 + sm * s / MU_0
|
|
266
|
+
return (a / b) * Hy + (s / MU_0 / b) * dEz_dx
|
|
267
|
+
return Hy + (s / MU_0) * dEz_dx
|
|
268
|
+
|
|
269
|
+
def _advance_hx(self, Hx, dEz_dy, s):
|
|
270
|
+
if self.boundary == "absorber":
|
|
271
|
+
sm = 0.5 * (self.sigma_m[:, 1:] + self.sigma_m[:, :-1])
|
|
272
|
+
a = 1.0 - sm * s / MU_0
|
|
273
|
+
b = 1.0 + sm * s / MU_0
|
|
274
|
+
return (a / b) * Hx - (s / MU_0 / b) * dEz_dy
|
|
275
|
+
return Hx - (s / MU_0) * dEz_dy
|
|
276
|
+
|
|
277
|
+
def _inject(self, Ez, step_frac, eps, s):
|
|
278
|
+
for src in self.sources:
|
|
279
|
+
val = src.amplitude * float(src.waveform(np.array([step_frac * self.dt]))[0])
|
|
280
|
+
Ez[src.i, src.j] += val * (self.dt / 2.0) / (eps[src.i, src.j])
|
|
281
|
+
|
|
282
|
+
# ------------------------------------------------------------------ #
|
|
283
|
+
def _run_rust(self, steps, snapshot_interval, probes) -> ADIResult:
|
|
284
|
+
from .rustbackend import _rust_module
|
|
285
|
+
rs = _rust_module()
|
|
286
|
+
if not hasattr(rs, "adi_step_range_f64"):
|
|
287
|
+
raise RuntimeError(
|
|
288
|
+
"the Rust extension was built without the ADI kernel; rebuild "
|
|
289
|
+
"with 'cargo build --release' in rust/ and reinstall the .so.")
|
|
290
|
+
return _run_adi_rust(self, rs, steps, snapshot_interval, probes)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _run_adi_rust(sim, rs, steps, snapshot_interval, probes) -> ADIResult:
|
|
294
|
+
"""Drive the Rust ADI stepper (mirrors ``_run_numpy``)."""
|
|
295
|
+
npdt = sim.npdt
|
|
296
|
+
step_fn = rs.adi_step_range_f64 if npdt == np.float64 else rs.adi_step_range_f32
|
|
297
|
+
nx, ny = sim.nx, sim.ny
|
|
298
|
+
Ez = np.zeros((nx, ny), dtype=npdt)
|
|
299
|
+
Hx = np.zeros((nx, ny - 1), dtype=npdt)
|
|
300
|
+
Hy = np.zeros((nx - 1, ny), dtype=npdt)
|
|
301
|
+
eps = np.ascontiguousarray(sim.eps_r * EPS_0, dtype=npdt)
|
|
302
|
+
sig_e = np.ascontiguousarray(sim.sigma_e, dtype=npdt)
|
|
303
|
+
sig_m = np.ascontiguousarray(sim.sigma_m, dtype=npdt)
|
|
304
|
+
|
|
305
|
+
# Precompute source waveform tables (Ez soft source, injected at both
|
|
306
|
+
# half steps as in the NumPy path).
|
|
307
|
+
n_src = max(len(sim.sources), 1)
|
|
308
|
+
src_ij = np.zeros((n_src, 2), dtype=np.int64)
|
|
309
|
+
# Two columns per step: the (n+0.5) and (n+1.0) sample.
|
|
310
|
+
src_vals = np.zeros((n_src, 2 * max(steps, 1)), dtype=npdt)
|
|
311
|
+
for s_i, src in enumerate(sim.sources):
|
|
312
|
+
src_ij[s_i] = (src.i, src.j)
|
|
313
|
+
fr = np.empty(2 * steps)
|
|
314
|
+
for n in range(steps):
|
|
315
|
+
fr[2 * n] = src.amplitude * float(src.waveform(np.array([(n + 0.5) * sim.dt]))[0])
|
|
316
|
+
fr[2 * n + 1] = src.amplitude * float(src.waveform(np.array([(n + 1.0) * sim.dt]))[0])
|
|
317
|
+
src_vals[s_i] = fr.astype(npdt)
|
|
318
|
+
|
|
319
|
+
rec = sorted(set(range(0, steps, snapshot_interval))) if snapshot_interval else []
|
|
320
|
+
probe_arr = np.asarray(probes, dtype=np.int64).reshape(-1, 2)
|
|
321
|
+
snaps = np.zeros((len(rec), nx, ny), dtype=npdt)
|
|
322
|
+
probe_out = np.zeros((max(len(probe_arr), 1), steps), dtype=npdt)
|
|
323
|
+
|
|
324
|
+
step_fn(Ez, Hx, Hy, eps, sig_e, sig_m,
|
|
325
|
+
sim.dx, sim.dy, sim.dt,
|
|
326
|
+
src_ij, src_vals, len(sim.sources),
|
|
327
|
+
np.asarray(rec, dtype=np.int64), snaps,
|
|
328
|
+
probe_arr, probe_out, steps,
|
|
329
|
+
1 if sim.boundary == "absorber" else 0)
|
|
330
|
+
|
|
331
|
+
res = ADIResult(times=(np.arange(steps) + 1) * sim.dt)
|
|
332
|
+
if len(rec):
|
|
333
|
+
res.snapshots["Ez"] = snaps
|
|
334
|
+
res.snapshots["t"] = (np.asarray(rec) + 1) * sim.dt
|
|
335
|
+
res.probes = {f"{i},{j}": probe_out[k]
|
|
336
|
+
for k, (i, j) in enumerate(probe_arr)}
|
|
337
|
+
return res
|