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 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