witwin-maxwell 0.3.0__py3-none-win_amd64.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.
- witwin/maxwell/__init__.py +164 -0
- witwin/maxwell/adapters/__init__.py +1 -0
- witwin/maxwell/adapters/gds.py +203 -0
- witwin/maxwell/adapters/tidy3d.py +1669 -0
- witwin/maxwell/adjoint_inputs.py +70 -0
- witwin/maxwell/compiler/__init__.py +11 -0
- witwin/maxwell/compiler/materials.py +1355 -0
- witwin/maxwell/compiler/monitors.py +164 -0
- witwin/maxwell/compiler/sources.py +222 -0
- witwin/maxwell/fdfd/__init__.py +3 -0
- witwin/maxwell/fdfd/adjoint/__init__.py +3 -0
- witwin/maxwell/fdfd/adjoint/bridge.py +180 -0
- witwin/maxwell/fdfd/ams.py +300 -0
- witwin/maxwell/fdfd/krylov.py +280 -0
- witwin/maxwell/fdfd/plotting.py +110 -0
- witwin/maxwell/fdfd/postprocess.py +33 -0
- witwin/maxwell/fdfd/solver.py +1057 -0
- witwin/maxwell/fdtd/__init__.py +4 -0
- witwin/maxwell/fdtd/adjoint/__init__.py +21 -0
- witwin/maxwell/fdtd/adjoint/bridge.py +746 -0
- witwin/maxwell/fdtd/adjoint/capabilities.py +29 -0
- witwin/maxwell/fdtd/adjoint/core.py +3614 -0
- witwin/maxwell/fdtd/adjoint/dispatch.py +684 -0
- witwin/maxwell/fdtd/adjoint/native.py +2561 -0
- witwin/maxwell/fdtd/adjoint/profiler.py +195 -0
- witwin/maxwell/fdtd/adjoint/reverse_common.py +207 -0
- witwin/maxwell/fdtd/adjoint/seeds.py +524 -0
- witwin/maxwell/fdtd/boundary/__init__.py +37 -0
- witwin/maxwell/fdtd/boundary/bloch.py +90 -0
- witwin/maxwell/fdtd/boundary/common.py +41 -0
- witwin/maxwell/fdtd/boundary/cpml.py +527 -0
- witwin/maxwell/fdtd/boundary/runtime.py +209 -0
- witwin/maxwell/fdtd/checkpoint.py +227 -0
- witwin/maxwell/fdtd/coords.py +32 -0
- witwin/maxwell/fdtd/cuda/__init__.py +11 -0
- witwin/maxwell/fdtd/cuda/backend.py +3532 -0
- witwin/maxwell/fdtd/cuda/build.py +292 -0
- witwin/maxwell/fdtd/cuda/extension.cpp +2053 -0
- witwin/maxwell/fdtd/cuda/kernels/adjoint.cu +5290 -0
- witwin/maxwell/fdtd/cuda/kernels/aniso.cu +1704 -0
- witwin/maxwell/fdtd/cuda/kernels/boundary.cu +424 -0
- witwin/maxwell/fdtd/cuda/kernels/common.cu +74 -0
- witwin/maxwell/fdtd/cuda/kernels/common.cuh +82 -0
- witwin/maxwell/fdtd/cuda/kernels/dispersive.cu +782 -0
- witwin/maxwell/fdtd/cuda/kernels/electric.cu +3613 -0
- witwin/maxwell/fdtd/cuda/kernels/magnetic.cu +781 -0
- witwin/maxwell/fdtd/cuda/kernels/observers.cu +347 -0
- witwin/maxwell/fdtd/cuda/kernels/projection.cu +211 -0
- witwin/maxwell/fdtd/cuda/kernels/sources.cu +1482 -0
- witwin/maxwell/fdtd/cuda/kernels/spectral.cu +297 -0
- witwin/maxwell/fdtd/cuda/launch.h +23 -0
- witwin/maxwell/fdtd/cuda/prebuilt/witwin_maxwell_fdtd_cuda.pyd +0 -0
- witwin/maxwell/fdtd/cuda/runtime/__init__.py +6 -0
- witwin/maxwell/fdtd/cuda/runtime/dispatcher.py +6 -0
- witwin/maxwell/fdtd/cuda/runtime/graph.py +33 -0
- witwin/maxwell/fdtd/cuda/tensors.h +35 -0
- witwin/maxwell/fdtd/dispersion.py +48 -0
- witwin/maxwell/fdtd/evaluation.py +290 -0
- witwin/maxwell/fdtd/excitation/__init__.py +27 -0
- witwin/maxwell/fdtd/excitation/injection.py +1102 -0
- witwin/maxwell/fdtd/excitation/modes.py +1675 -0
- witwin/maxwell/fdtd/excitation/spatial.py +469 -0
- witwin/maxwell/fdtd/excitation/temporal.py +510 -0
- witwin/maxwell/fdtd/excitation/tfsf_apply.py +264 -0
- witwin/maxwell/fdtd/excitation/tfsf_common.py +421 -0
- witwin/maxwell/fdtd/excitation/tfsf_specs.py +490 -0
- witwin/maxwell/fdtd/excitation/tfsf_state.py +778 -0
- witwin/maxwell/fdtd/io.py +31 -0
- witwin/maxwell/fdtd/material_pullback.py +371 -0
- witwin/maxwell/fdtd/meshing/__init__.py +3 -0
- witwin/maxwell/fdtd/meshing/autogrid.py +335 -0
- witwin/maxwell/fdtd/observers.py +1453 -0
- witwin/maxwell/fdtd/plotting.py +121 -0
- witwin/maxwell/fdtd/postprocess.py +205 -0
- witwin/maxwell/fdtd/runtime/__init__.py +119 -0
- witwin/maxwell/fdtd/runtime/initialization.py +236 -0
- witwin/maxwell/fdtd/runtime/materials.py +1563 -0
- witwin/maxwell/fdtd/runtime/module_cache.py +31 -0
- witwin/maxwell/fdtd/runtime/spectral.py +581 -0
- witwin/maxwell/fdtd/runtime/stepping.py +1987 -0
- witwin/maxwell/fdtd/solver.py +502 -0
- witwin/maxwell/geometry/__init__.py +5 -0
- witwin/maxwell/geometry/polyslab.py +299 -0
- witwin/maxwell/materials.py +3 -0
- witwin/maxwell/media.py +1467 -0
- witwin/maxwell/monitors.py +743 -0
- witwin/maxwell/ports.py +113 -0
- witwin/maxwell/postprocess/RCS.py +139 -0
- witwin/maxwell/postprocess/__init__.py +42 -0
- witwin/maxwell/postprocess/diffraction.py +219 -0
- witwin/maxwell/postprocess/directivity.py +152 -0
- witwin/maxwell/postprocess/emission.py +67 -0
- witwin/maxwell/postprocess/modal.py +324 -0
- witwin/maxwell/postprocess/nfft.py +266 -0
- witwin/maxwell/postprocess/scattering_parameters.py +358 -0
- witwin/maxwell/postprocess/stratton_chu.py +1193 -0
- witwin/maxwell/result.py +967 -0
- witwin/maxwell/scene.py +1338 -0
- witwin/maxwell/simulation.py +659 -0
- witwin/maxwell/sources.py +917 -0
- witwin/maxwell/visualization/__init__.py +28 -0
- witwin/maxwell/visualization/interactive.py +484 -0
- witwin/maxwell/visualization/plots.py +568 -0
- witwin_maxwell-0.3.0.dist-info/METADATA +184 -0
- witwin_maxwell-0.3.0.dist-info/RECORD +107 -0
- witwin_maxwell-0.3.0.dist-info/WHEEL +4 -0
- witwin_maxwell-0.3.0.dist-info/licenses/LICENSE +13 -0
witwin/maxwell/scene.py
ADDED
|
@@ -0,0 +1,1338 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any, Literal, TypeAlias
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import torch
|
|
9
|
+
|
|
10
|
+
from witwin.core import Structure
|
|
11
|
+
from .ports import ModePort
|
|
12
|
+
from .compiler.materials import (
|
|
13
|
+
compile_material_model,
|
|
14
|
+
evaluate_material_components,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
BoundaryKind: TypeAlias = Literal["none", "pml", "periodic", "bloch", "pec", "pmc", "mur"]
|
|
19
|
+
BoundaryAxisOverride: TypeAlias = BoundaryKind | tuple[BoundaryKind, BoundaryKind]
|
|
20
|
+
BlochWavevector: TypeAlias = tuple[float, float, float] | Literal["auto"]
|
|
21
|
+
|
|
22
|
+
_ResolvedBoundaryKind: TypeAlias = BoundaryKind | Literal["mixed"]
|
|
23
|
+
_BOUNDARY_AXES = ("x", "y", "z")
|
|
24
|
+
_BOUNDARY_AXIS_TO_INDEX = {axis: index for index, axis in enumerate(_BOUNDARY_AXES)}
|
|
25
|
+
_BOUNDARY_SIDE_TO_INDEX = {"low": 0, "high": 1}
|
|
26
|
+
_VALID_BOUNDARY_KINDS = {"none", "pml", "periodic", "bloch", "pec", "pmc", "mur"}
|
|
27
|
+
_PAIR_ONLY_BOUNDARY_KINDS = {"periodic", "bloch"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _normalize_boundary_kind(value, *, allow_mixed: bool = False) -> str:
|
|
31
|
+
kind = str(value).lower()
|
|
32
|
+
if kind == "mixed" and allow_mixed:
|
|
33
|
+
return kind
|
|
34
|
+
if kind not in _VALID_BOUNDARY_KINDS:
|
|
35
|
+
raise ValueError(
|
|
36
|
+
"Boundary kind must be one of 'none', 'pml', 'periodic', 'bloch', 'pec', 'pmc', or 'mur'."
|
|
37
|
+
)
|
|
38
|
+
return kind
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _normalize_boundary_axis_override(name: str, value) -> tuple[str, str] | None:
|
|
42
|
+
if value is None:
|
|
43
|
+
return None
|
|
44
|
+
if isinstance(value, str):
|
|
45
|
+
kind = _normalize_boundary_kind(value)
|
|
46
|
+
return (kind, kind)
|
|
47
|
+
if len(value) != 2:
|
|
48
|
+
raise ValueError(f"{name} must be a boundary kind or a (low, high) pair.")
|
|
49
|
+
return tuple(_normalize_boundary_kind(component) for component in value)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _normalize_boundary_face_override(name: str, value) -> str | None:
|
|
53
|
+
if value is None:
|
|
54
|
+
return None
|
|
55
|
+
return _normalize_boundary_kind(value)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _summarize_boundary_kind(face_kinds: tuple[tuple[str, str], tuple[str, str], tuple[str, str]]) -> _ResolvedBoundaryKind:
|
|
59
|
+
first_kind = face_kinds[0][0]
|
|
60
|
+
if all(low_kind == first_kind and high_kind == first_kind for low_kind, high_kind in face_kinds):
|
|
61
|
+
return first_kind
|
|
62
|
+
return "mixed"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class Domain:
|
|
67
|
+
bounds: tuple[tuple[float, float], tuple[float, float], tuple[float, float]]
|
|
68
|
+
|
|
69
|
+
def __init__(self, bounds):
|
|
70
|
+
if len(bounds) != 3:
|
|
71
|
+
raise ValueError("bounds must contain x, y, z ranges.")
|
|
72
|
+
normalized = []
|
|
73
|
+
for axis_bounds in bounds:
|
|
74
|
+
if len(axis_bounds) != 2:
|
|
75
|
+
raise ValueError("Each axis bound must contain exactly two values.")
|
|
76
|
+
start, end = float(axis_bounds[0]), float(axis_bounds[1])
|
|
77
|
+
if end <= start:
|
|
78
|
+
raise ValueError("Each axis bound must have end > start.")
|
|
79
|
+
normalized.append((start, end))
|
|
80
|
+
object.__setattr__(self, "bounds", tuple(normalized))
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def from_domain_range(cls, domain_range):
|
|
84
|
+
if len(domain_range) != 6:
|
|
85
|
+
raise ValueError("domain_range must contain six values.")
|
|
86
|
+
return cls(
|
|
87
|
+
bounds=(
|
|
88
|
+
(domain_range[0], domain_range[1]),
|
|
89
|
+
(domain_range[2], domain_range[3]),
|
|
90
|
+
(domain_range[4], domain_range[5]),
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def domain_range(self):
|
|
96
|
+
return (
|
|
97
|
+
self.bounds[0][0],
|
|
98
|
+
self.bounds[0][1],
|
|
99
|
+
self.bounds[1][0],
|
|
100
|
+
self.bounds[1][1],
|
|
101
|
+
self.bounds[2][0],
|
|
102
|
+
self.bounds[2][1],
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True)
|
|
107
|
+
class MeshOverrideStructure:
|
|
108
|
+
"""Maximum mesh step enforced inside a geometry's axis-aligned bounding box."""
|
|
109
|
+
|
|
110
|
+
geometry: Any
|
|
111
|
+
dl: tuple[float, float, float]
|
|
112
|
+
|
|
113
|
+
def __init__(self, geometry, dl):
|
|
114
|
+
if geometry is None:
|
|
115
|
+
raise ValueError("MeshOverrideStructure requires geometry=.")
|
|
116
|
+
if isinstance(dl, (int, float)):
|
|
117
|
+
values = (float(dl),) * 3
|
|
118
|
+
else:
|
|
119
|
+
values = tuple(float(value) for value in dl)
|
|
120
|
+
if len(values) != 3:
|
|
121
|
+
raise ValueError("MeshOverrideStructure dl must be a scalar or (dx, dy, dz).")
|
|
122
|
+
if any(value <= 0.0 for value in values):
|
|
123
|
+
raise ValueError("MeshOverrideStructure dl values must be > 0.")
|
|
124
|
+
object.__setattr__(self, "geometry", geometry)
|
|
125
|
+
object.__setattr__(self, "dl", values)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True)
|
|
129
|
+
class LayerRefinementSpec:
|
|
130
|
+
"""Minimum cell count across thin structure-bounded intervals.
|
|
131
|
+
|
|
132
|
+
``axes=None`` covers all axes; otherwise only the listed axes are refined.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
min_cells: int = 2
|
|
136
|
+
axes: tuple[str, ...] | None = None
|
|
137
|
+
|
|
138
|
+
def __post_init__(self):
|
|
139
|
+
min_cells = int(self.min_cells)
|
|
140
|
+
if min_cells < 1:
|
|
141
|
+
raise ValueError("LayerRefinementSpec.min_cells must be >= 1.")
|
|
142
|
+
object.__setattr__(self, "min_cells", min_cells)
|
|
143
|
+
if self.axes is not None:
|
|
144
|
+
axes = tuple(str(axis).lower() for axis in self.axes)
|
|
145
|
+
if any(axis not in _BOUNDARY_AXIS_TO_INDEX for axis in axes):
|
|
146
|
+
raise ValueError("LayerRefinementSpec.axes entries must be 'x', 'y', or 'z'.")
|
|
147
|
+
object.__setattr__(self, "axes", axes)
|
|
148
|
+
|
|
149
|
+
def covers(self, axis: str) -> bool:
|
|
150
|
+
return self.axes is None or str(axis).lower() in self.axes
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _validate_custom_axis_coords(values, axis: str) -> np.ndarray:
|
|
154
|
+
if isinstance(values, torch.Tensor):
|
|
155
|
+
values = values.detach().cpu().numpy()
|
|
156
|
+
coords = np.array(np.asarray(values, dtype=np.float64), copy=True)
|
|
157
|
+
if coords.ndim != 1:
|
|
158
|
+
raise ValueError(f"GridSpec.custom {axis}_coords must be a 1D array of node coordinates.")
|
|
159
|
+
if coords.size < 2:
|
|
160
|
+
raise ValueError(f"GridSpec.custom {axis}_coords must contain at least two node coordinates.")
|
|
161
|
+
if not np.all(np.isfinite(coords)):
|
|
162
|
+
raise ValueError(f"GridSpec.custom {axis}_coords must contain only finite values.")
|
|
163
|
+
if not np.all(np.diff(coords) > 0.0):
|
|
164
|
+
raise ValueError(f"GridSpec.custom {axis}_coords must be strictly increasing.")
|
|
165
|
+
coords.setflags(write=False)
|
|
166
|
+
return coords
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass(frozen=True, eq=False)
|
|
170
|
+
class GridSpec:
|
|
171
|
+
dx: float | None
|
|
172
|
+
dy: float | None
|
|
173
|
+
dz: float | None
|
|
174
|
+
x_coords: np.ndarray | None = None
|
|
175
|
+
y_coords: np.ndarray | None = None
|
|
176
|
+
z_coords: np.ndarray | None = None
|
|
177
|
+
min_steps_per_wavelength: float | None = None
|
|
178
|
+
wavelength: float | None = None
|
|
179
|
+
max_ratio: float | None = None
|
|
180
|
+
override_structures: tuple[MeshOverrideStructure, ...] = ()
|
|
181
|
+
layer_refinement: LayerRefinementSpec | None = None
|
|
182
|
+
|
|
183
|
+
@classmethod
|
|
184
|
+
def uniform(cls, dl: float) -> "GridSpec":
|
|
185
|
+
step = float(dl)
|
|
186
|
+
return cls(step, step, step)
|
|
187
|
+
|
|
188
|
+
@classmethod
|
|
189
|
+
def anisotropic(cls, dx: float, dy: float, dz: float) -> "GridSpec":
|
|
190
|
+
return cls(float(dx), float(dy), float(dz))
|
|
191
|
+
|
|
192
|
+
@classmethod
|
|
193
|
+
def custom(cls, x_coords, y_coords, z_coords) -> "GridSpec":
|
|
194
|
+
return cls(
|
|
195
|
+
None,
|
|
196
|
+
None,
|
|
197
|
+
None,
|
|
198
|
+
x_coords=_validate_custom_axis_coords(x_coords, "x"),
|
|
199
|
+
y_coords=_validate_custom_axis_coords(y_coords, "y"),
|
|
200
|
+
z_coords=_validate_custom_axis_coords(z_coords, "z"),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
@classmethod
|
|
204
|
+
def auto(
|
|
205
|
+
cls,
|
|
206
|
+
min_steps_per_wavelength: float = 10,
|
|
207
|
+
wavelength: float | None = None,
|
|
208
|
+
max_ratio: float = 1.4,
|
|
209
|
+
override_structures=(),
|
|
210
|
+
layer_refinement: LayerRefinementSpec | None = None,
|
|
211
|
+
) -> "GridSpec":
|
|
212
|
+
steps = float(min_steps_per_wavelength)
|
|
213
|
+
if steps <= 0.0:
|
|
214
|
+
raise ValueError("GridSpec.auto min_steps_per_wavelength must be > 0.")
|
|
215
|
+
if wavelength is not None:
|
|
216
|
+
wavelength = float(wavelength)
|
|
217
|
+
if wavelength <= 0.0:
|
|
218
|
+
raise ValueError("GridSpec.auto wavelength must be > 0 when provided.")
|
|
219
|
+
ratio = float(max_ratio)
|
|
220
|
+
if ratio <= 1.0:
|
|
221
|
+
raise ValueError("GridSpec.auto max_ratio must be > 1.")
|
|
222
|
+
overrides = tuple(override_structures)
|
|
223
|
+
for override in overrides:
|
|
224
|
+
if not isinstance(override, MeshOverrideStructure):
|
|
225
|
+
raise TypeError(
|
|
226
|
+
"GridSpec.auto override_structures must contain MeshOverrideStructure entries."
|
|
227
|
+
)
|
|
228
|
+
if layer_refinement is not None and not isinstance(layer_refinement, LayerRefinementSpec):
|
|
229
|
+
raise TypeError("GridSpec.auto layer_refinement must be a LayerRefinementSpec.")
|
|
230
|
+
return cls(
|
|
231
|
+
None,
|
|
232
|
+
None,
|
|
233
|
+
None,
|
|
234
|
+
min_steps_per_wavelength=steps,
|
|
235
|
+
wavelength=wavelength,
|
|
236
|
+
max_ratio=ratio,
|
|
237
|
+
override_structures=overrides,
|
|
238
|
+
layer_refinement=layer_refinement,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def is_custom(self) -> bool:
|
|
243
|
+
return self.x_coords is not None or self.y_coords is not None or self.z_coords is not None
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def is_auto(self) -> bool:
|
|
247
|
+
return self.min_steps_per_wavelength is not None
|
|
248
|
+
|
|
249
|
+
def _require_scalar_spacing(self):
|
|
250
|
+
if self.is_custom:
|
|
251
|
+
raise ValueError("GridSpec.custom has no scalar spacing; use axis_coords()/min_spacing.")
|
|
252
|
+
if self.is_auto:
|
|
253
|
+
raise ValueError(
|
|
254
|
+
"GridSpec.auto has no scalar spacing; it resolves to node coordinates at prepare time."
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
@property
|
|
258
|
+
def spacing(self) -> tuple[float, float, float]:
|
|
259
|
+
self._require_scalar_spacing()
|
|
260
|
+
return (self.dx, self.dy, self.dz)
|
|
261
|
+
|
|
262
|
+
@property
|
|
263
|
+
def is_uniform(self) -> bool:
|
|
264
|
+
self._require_scalar_spacing()
|
|
265
|
+
return bool(np.isclose(self.dx, self.dy) and np.isclose(self.dy, self.dz))
|
|
266
|
+
|
|
267
|
+
@property
|
|
268
|
+
def min_spacing(self) -> tuple[float, float, float]:
|
|
269
|
+
if self.is_auto:
|
|
270
|
+
raise ValueError(
|
|
271
|
+
"GridSpec.auto has no spacing before prepare; it resolves to node coordinates at prepare time."
|
|
272
|
+
)
|
|
273
|
+
if self.is_custom:
|
|
274
|
+
return (
|
|
275
|
+
float(np.diff(self.x_coords).min()),
|
|
276
|
+
float(np.diff(self.y_coords).min()),
|
|
277
|
+
float(np.diff(self.z_coords).min()),
|
|
278
|
+
)
|
|
279
|
+
return (self.dx, self.dy, self.dz)
|
|
280
|
+
|
|
281
|
+
def axis_coords(self, axis: str) -> np.ndarray | None:
|
|
282
|
+
axis_name = str(axis).lower()
|
|
283
|
+
if axis_name not in _BOUNDARY_AXIS_TO_INDEX:
|
|
284
|
+
raise ValueError("axis must be 'x', 'y', or 'z'.")
|
|
285
|
+
return (self.x_coords, self.y_coords, self.z_coords)[_BOUNDARY_AXIS_TO_INDEX[axis_name]]
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@dataclass(frozen=True)
|
|
289
|
+
class BoundarySpec:
|
|
290
|
+
kind: BoundaryKind | Literal["mixed"] = "none"
|
|
291
|
+
num_layers: int = 0
|
|
292
|
+
strength: float = 0.0
|
|
293
|
+
bloch_wavevector: BlochWavevector = (0.0, 0.0, 0.0)
|
|
294
|
+
x: BoundaryAxisOverride | None = None
|
|
295
|
+
y: BoundaryAxisOverride | None = None
|
|
296
|
+
z: BoundaryAxisOverride | None = None
|
|
297
|
+
x_low: BoundaryKind | None = None
|
|
298
|
+
x_high: BoundaryKind | None = None
|
|
299
|
+
y_low: BoundaryKind | None = None
|
|
300
|
+
y_high: BoundaryKind | None = None
|
|
301
|
+
z_low: BoundaryKind | None = None
|
|
302
|
+
z_high: BoundaryKind | None = None
|
|
303
|
+
_face_kinds: tuple[tuple[BoundaryKind, BoundaryKind], tuple[BoundaryKind, BoundaryKind], tuple[BoundaryKind, BoundaryKind]] = field(
|
|
304
|
+
init=False,
|
|
305
|
+
repr=False,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
def __post_init__(self):
|
|
309
|
+
kind = _normalize_boundary_kind(self.kind)
|
|
310
|
+
|
|
311
|
+
if self.num_layers < 0:
|
|
312
|
+
raise ValueError("BoundarySpec.num_layers must be >= 0.")
|
|
313
|
+
if self.strength < 0:
|
|
314
|
+
raise ValueError("BoundarySpec.strength must be >= 0.")
|
|
315
|
+
|
|
316
|
+
if isinstance(self.bloch_wavevector, str):
|
|
317
|
+
if self.bloch_wavevector != "auto":
|
|
318
|
+
raise ValueError("BoundarySpec.bloch_wavevector must be a length-3 tuple or 'auto'.")
|
|
319
|
+
bloch_wavevector: BlochWavevector = "auto"
|
|
320
|
+
else:
|
|
321
|
+
bloch_wavevector = tuple(float(value) for value in self.bloch_wavevector)
|
|
322
|
+
if len(bloch_wavevector) != 3:
|
|
323
|
+
raise ValueError("BoundarySpec.bloch_wavevector must contain exactly three values.")
|
|
324
|
+
|
|
325
|
+
axis_overrides = {
|
|
326
|
+
"x": _normalize_boundary_axis_override("x", self.x),
|
|
327
|
+
"y": _normalize_boundary_axis_override("y", self.y),
|
|
328
|
+
"z": _normalize_boundary_axis_override("z", self.z),
|
|
329
|
+
}
|
|
330
|
+
face_overrides = {
|
|
331
|
+
("x", "low"): _normalize_boundary_face_override("x_low", self.x_low),
|
|
332
|
+
("x", "high"): _normalize_boundary_face_override("x_high", self.x_high),
|
|
333
|
+
("y", "low"): _normalize_boundary_face_override("y_low", self.y_low),
|
|
334
|
+
("y", "high"): _normalize_boundary_face_override("y_high", self.y_high),
|
|
335
|
+
("z", "low"): _normalize_boundary_face_override("z_low", self.z_low),
|
|
336
|
+
("z", "high"): _normalize_boundary_face_override("z_high", self.z_high),
|
|
337
|
+
}
|
|
338
|
+
face_kinds = {axis: [kind, kind] for axis in _BOUNDARY_AXES}
|
|
339
|
+
for axis, axis_override in axis_overrides.items():
|
|
340
|
+
if axis_override is None:
|
|
341
|
+
continue
|
|
342
|
+
face_kinds[axis][0] = axis_override[0]
|
|
343
|
+
face_kinds[axis][1] = axis_override[1]
|
|
344
|
+
for (axis, side), face_override in face_overrides.items():
|
|
345
|
+
if face_override is None:
|
|
346
|
+
continue
|
|
347
|
+
face_kinds[axis][_BOUNDARY_SIDE_TO_INDEX[side]] = face_override
|
|
348
|
+
|
|
349
|
+
normalized_face_kinds = tuple(
|
|
350
|
+
(face_kinds[axis][0], face_kinds[axis][1])
|
|
351
|
+
for axis in _BOUNDARY_AXES
|
|
352
|
+
)
|
|
353
|
+
resolved_kind = _summarize_boundary_kind(normalized_face_kinds)
|
|
354
|
+
|
|
355
|
+
uses_pml = any(
|
|
356
|
+
face_kind == "pml"
|
|
357
|
+
for axis_face_kinds in normalized_face_kinds
|
|
358
|
+
for face_kind in axis_face_kinds
|
|
359
|
+
)
|
|
360
|
+
uses_bloch = any(
|
|
361
|
+
face_kind == "bloch"
|
|
362
|
+
for axis_face_kinds in normalized_face_kinds
|
|
363
|
+
for face_kind in axis_face_kinds
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
if uses_pml and self.num_layers <= 0:
|
|
367
|
+
raise ValueError("PML boundaries require num_layers > 0.")
|
|
368
|
+
if bloch_wavevector == "auto" and not uses_bloch:
|
|
369
|
+
raise ValueError("BoundarySpec.bloch_wavevector='auto' requires at least one Bloch axis.")
|
|
370
|
+
if uses_bloch:
|
|
371
|
+
for axis, axis_index in _BOUNDARY_AXIS_TO_INDEX.items():
|
|
372
|
+
axis_face_kinds = normalized_face_kinds[axis_index]
|
|
373
|
+
if "bloch" not in axis_face_kinds:
|
|
374
|
+
continue
|
|
375
|
+
if axis_face_kinds[0] != "bloch" or axis_face_kinds[1] != "bloch":
|
|
376
|
+
raise ValueError(f"{axis}-axis Bloch boundaries must be configured on both faces.")
|
|
377
|
+
if (
|
|
378
|
+
bloch_wavevector != "auto"
|
|
379
|
+
and resolved_kind != "bloch"
|
|
380
|
+
and np.isclose(bloch_wavevector[axis_index], 0.0)
|
|
381
|
+
):
|
|
382
|
+
raise ValueError(
|
|
383
|
+
f"{axis}-axis Bloch boundaries require a non-zero bloch_wavevector component."
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
for axis, axis_face_kinds in zip(_BOUNDARY_AXES, normalized_face_kinds):
|
|
387
|
+
axis_face_set = set(axis_face_kinds)
|
|
388
|
+
if _PAIR_ONLY_BOUNDARY_KINDS.intersection(axis_face_set) and axis_face_kinds[0] != axis_face_kinds[1]:
|
|
389
|
+
raise ValueError(
|
|
390
|
+
f"{axis}-axis periodic and Bloch boundaries must use the same kind on both faces."
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
if not uses_bloch:
|
|
394
|
+
bloch_wavevector = (0.0, 0.0, 0.0)
|
|
395
|
+
if not uses_pml:
|
|
396
|
+
object.__setattr__(self, "num_layers", 0)
|
|
397
|
+
object.__setattr__(self, "strength", 0.0)
|
|
398
|
+
|
|
399
|
+
object.__setattr__(self, "kind", resolved_kind)
|
|
400
|
+
object.__setattr__(self, "bloch_wavevector", bloch_wavevector)
|
|
401
|
+
object.__setattr__(self, "x", axis_overrides["x"])
|
|
402
|
+
object.__setattr__(self, "y", axis_overrides["y"])
|
|
403
|
+
object.__setattr__(self, "z", axis_overrides["z"])
|
|
404
|
+
object.__setattr__(self, "x_low", face_overrides[("x", "low")])
|
|
405
|
+
object.__setattr__(self, "x_high", face_overrides[("x", "high")])
|
|
406
|
+
object.__setattr__(self, "y_low", face_overrides[("y", "low")])
|
|
407
|
+
object.__setattr__(self, "y_high", face_overrides[("y", "high")])
|
|
408
|
+
object.__setattr__(self, "z_low", face_overrides[("z", "low")])
|
|
409
|
+
object.__setattr__(self, "z_high", face_overrides[("z", "high")])
|
|
410
|
+
object.__setattr__(self, "_face_kinds", normalized_face_kinds)
|
|
411
|
+
|
|
412
|
+
@classmethod
|
|
413
|
+
def pml(cls, num_layers: int = 15, strength: float = 1e6) -> "BoundarySpec":
|
|
414
|
+
return cls(kind="pml", num_layers=int(num_layers), strength=float(strength))
|
|
415
|
+
|
|
416
|
+
@classmethod
|
|
417
|
+
def none(cls) -> "BoundarySpec":
|
|
418
|
+
return cls(kind="none", num_layers=0, strength=0.0)
|
|
419
|
+
|
|
420
|
+
@classmethod
|
|
421
|
+
def periodic(cls) -> "BoundarySpec":
|
|
422
|
+
return cls(kind="periodic")
|
|
423
|
+
|
|
424
|
+
@classmethod
|
|
425
|
+
def bloch(cls, wavevector) -> "BoundarySpec":
|
|
426
|
+
return cls(kind="bloch", bloch_wavevector=wavevector)
|
|
427
|
+
|
|
428
|
+
@classmethod
|
|
429
|
+
def pec(cls) -> "BoundarySpec":
|
|
430
|
+
return cls(kind="pec")
|
|
431
|
+
|
|
432
|
+
@classmethod
|
|
433
|
+
def pmc(cls) -> "BoundarySpec":
|
|
434
|
+
return cls(kind="pmc")
|
|
435
|
+
|
|
436
|
+
@classmethod
|
|
437
|
+
def mur(cls) -> "BoundarySpec":
|
|
438
|
+
return cls(kind="mur")
|
|
439
|
+
|
|
440
|
+
@classmethod
|
|
441
|
+
def faces(
|
|
442
|
+
cls,
|
|
443
|
+
*,
|
|
444
|
+
default: BoundaryKind = "none",
|
|
445
|
+
num_layers: int = 0,
|
|
446
|
+
strength: float = 0.0,
|
|
447
|
+
bloch_wavevector: BlochWavevector = (0.0, 0.0, 0.0),
|
|
448
|
+
x: BoundaryAxisOverride | None = None,
|
|
449
|
+
y: BoundaryAxisOverride | None = None,
|
|
450
|
+
z: BoundaryAxisOverride | None = None,
|
|
451
|
+
x_low: BoundaryKind | None = None,
|
|
452
|
+
x_high: BoundaryKind | None = None,
|
|
453
|
+
y_low: BoundaryKind | None = None,
|
|
454
|
+
y_high: BoundaryKind | None = None,
|
|
455
|
+
z_low: BoundaryKind | None = None,
|
|
456
|
+
z_high: BoundaryKind | None = None,
|
|
457
|
+
) -> "BoundarySpec":
|
|
458
|
+
return cls(
|
|
459
|
+
kind=default,
|
|
460
|
+
num_layers=int(num_layers),
|
|
461
|
+
strength=float(strength),
|
|
462
|
+
bloch_wavevector=bloch_wavevector,
|
|
463
|
+
x=x,
|
|
464
|
+
y=y,
|
|
465
|
+
z=z,
|
|
466
|
+
x_low=x_low,
|
|
467
|
+
x_high=x_high,
|
|
468
|
+
y_low=y_low,
|
|
469
|
+
y_high=y_high,
|
|
470
|
+
z_low=z_low,
|
|
471
|
+
z_high=z_high,
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
@property
|
|
475
|
+
def face_kinds(
|
|
476
|
+
self,
|
|
477
|
+
) -> tuple[tuple[BoundaryKind, BoundaryKind], tuple[BoundaryKind, BoundaryKind], tuple[BoundaryKind, BoundaryKind]]:
|
|
478
|
+
return self._face_kinds
|
|
479
|
+
|
|
480
|
+
def axis_face_kinds(self, axis: str) -> tuple[BoundaryKind, BoundaryKind]:
|
|
481
|
+
axis_name = str(axis).lower()
|
|
482
|
+
if axis_name not in _BOUNDARY_AXIS_TO_INDEX:
|
|
483
|
+
raise ValueError("axis must be 'x', 'y', or 'z'.")
|
|
484
|
+
return self._face_kinds[_BOUNDARY_AXIS_TO_INDEX[axis_name]]
|
|
485
|
+
|
|
486
|
+
def axis_kind(self, axis: str) -> _ResolvedBoundaryKind:
|
|
487
|
+
low_kind, high_kind = self.axis_face_kinds(axis)
|
|
488
|
+
if low_kind == high_kind:
|
|
489
|
+
return low_kind
|
|
490
|
+
return "mixed"
|
|
491
|
+
|
|
492
|
+
def face_kind(self, axis: str, side: str) -> BoundaryKind:
|
|
493
|
+
axis_name = str(axis).lower()
|
|
494
|
+
side_name = str(side).lower()
|
|
495
|
+
if axis_name not in _BOUNDARY_AXIS_TO_INDEX:
|
|
496
|
+
raise ValueError("axis must be 'x', 'y', or 'z'.")
|
|
497
|
+
if side_name not in _BOUNDARY_SIDE_TO_INDEX:
|
|
498
|
+
raise ValueError("side must be 'low' or 'high'.")
|
|
499
|
+
return self._face_kinds[_BOUNDARY_AXIS_TO_INDEX[axis_name]][_BOUNDARY_SIDE_TO_INDEX[side_name]]
|
|
500
|
+
|
|
501
|
+
def uses_kind(self, kind: BoundaryKind) -> bool:
|
|
502
|
+
normalized_kind = _normalize_boundary_kind(kind)
|
|
503
|
+
return any(
|
|
504
|
+
face_kind == normalized_kind
|
|
505
|
+
for axis_face_kinds in self._face_kinds
|
|
506
|
+
for face_kind in axis_face_kinds
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
def pml_layers_for_face(self, axis: str, side: str) -> int:
|
|
510
|
+
return int(self.num_layers) if self.face_kind(axis, side) == "pml" else 0
|
|
511
|
+
|
|
512
|
+
def bloch_phase_factors(
|
|
513
|
+
self,
|
|
514
|
+
domain_range,
|
|
515
|
+
) -> tuple[complex, complex, complex]:
|
|
516
|
+
if self.bloch_wavevector == "auto":
|
|
517
|
+
raise ValueError(
|
|
518
|
+
"Automatic Bloch phase factors require Simulation.prepare() to resolve the incident wavevector."
|
|
519
|
+
)
|
|
520
|
+
lengths = (
|
|
521
|
+
float(domain_range[1] - domain_range[0]),
|
|
522
|
+
float(domain_range[3] - domain_range[2]),
|
|
523
|
+
float(domain_range[5] - domain_range[4]),
|
|
524
|
+
)
|
|
525
|
+
phases = []
|
|
526
|
+
for axis, length, wave_number in zip(_BOUNDARY_AXES, lengths, self.bloch_wavevector):
|
|
527
|
+
if self.axis_kind(axis) == "bloch":
|
|
528
|
+
phases.append(np.exp(1j * wave_number * length))
|
|
529
|
+
else:
|
|
530
|
+
phases.append(1.0 + 0.0j)
|
|
531
|
+
return tuple(phases)
|
|
532
|
+
|
|
533
|
+
def with_faces(
|
|
534
|
+
self,
|
|
535
|
+
*,
|
|
536
|
+
x: BoundaryAxisOverride | None = None,
|
|
537
|
+
y: BoundaryAxisOverride | None = None,
|
|
538
|
+
z: BoundaryAxisOverride | None = None,
|
|
539
|
+
x_low: BoundaryKind | None = None,
|
|
540
|
+
x_high: BoundaryKind | None = None,
|
|
541
|
+
y_low: BoundaryKind | None = None,
|
|
542
|
+
y_high: BoundaryKind | None = None,
|
|
543
|
+
z_low: BoundaryKind | None = None,
|
|
544
|
+
z_high: BoundaryKind | None = None,
|
|
545
|
+
) -> "BoundarySpec":
|
|
546
|
+
return type(self).faces(
|
|
547
|
+
default="none",
|
|
548
|
+
num_layers=self.num_layers,
|
|
549
|
+
strength=self.strength,
|
|
550
|
+
bloch_wavevector=self.bloch_wavevector,
|
|
551
|
+
x=self.axis_face_kinds("x") if x is None else x,
|
|
552
|
+
y=self.axis_face_kinds("y") if y is None else y,
|
|
553
|
+
z=self.axis_face_kinds("z") if z is None else z,
|
|
554
|
+
x_low=x_low,
|
|
555
|
+
x_high=x_high,
|
|
556
|
+
y_low=y_low,
|
|
557
|
+
y_high=y_high,
|
|
558
|
+
z_low=z_low,
|
|
559
|
+
z_high=z_high,
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
@dataclass(frozen=True)
|
|
563
|
+
class MaterialRegion:
|
|
564
|
+
name: str
|
|
565
|
+
geometry: Any
|
|
566
|
+
density: torch.Tensor
|
|
567
|
+
basis: str = "density"
|
|
568
|
+
bounds: tuple[float, float] = (0.0, 1.0)
|
|
569
|
+
eps_bounds: tuple[float, float] = (1.0, 1.0)
|
|
570
|
+
mu_bounds: tuple[float, float] = (1.0, 1.0)
|
|
571
|
+
filter_radius: float | None = None
|
|
572
|
+
projection_beta: float | None = None
|
|
573
|
+
symmetry: str | None = None
|
|
574
|
+
|
|
575
|
+
def __post_init__(self):
|
|
576
|
+
if str(self.basis).lower() != "density":
|
|
577
|
+
raise ValueError("MaterialRegion currently supports basis='density' only.")
|
|
578
|
+
if not isinstance(self.density, torch.Tensor):
|
|
579
|
+
raise TypeError("MaterialRegion.density must be a torch.Tensor.")
|
|
580
|
+
if self.density.ndim != 3:
|
|
581
|
+
raise ValueError("MaterialRegion.density must have shape (Nx, Ny, Nz).")
|
|
582
|
+
|
|
583
|
+
density_bounds = (float(self.bounds[0]), float(self.bounds[1]))
|
|
584
|
+
if density_bounds[1] <= density_bounds[0]:
|
|
585
|
+
raise ValueError("MaterialRegion.bounds must satisfy upper > lower.")
|
|
586
|
+
eps_bounds = (float(self.eps_bounds[0]), float(self.eps_bounds[1]))
|
|
587
|
+
mu_bounds = (float(self.mu_bounds[0]), float(self.mu_bounds[1]))
|
|
588
|
+
if self.filter_radius is not None and float(self.filter_radius) <= 0.0:
|
|
589
|
+
raise ValueError("MaterialRegion.filter_radius must be > 0 when provided.")
|
|
590
|
+
if self.projection_beta is not None and float(self.projection_beta) <= 0.0:
|
|
591
|
+
raise ValueError("MaterialRegion.projection_beta must be > 0 when provided.")
|
|
592
|
+
|
|
593
|
+
object.__setattr__(self, "basis", "density")
|
|
594
|
+
object.__setattr__(self, "bounds", density_bounds)
|
|
595
|
+
object.__setattr__(self, "eps_bounds", eps_bounds)
|
|
596
|
+
object.__setattr__(self, "mu_bounds", mu_bounds)
|
|
597
|
+
object.__setattr__(
|
|
598
|
+
self,
|
|
599
|
+
"filter_radius",
|
|
600
|
+
None if self.filter_radius is None else float(self.filter_radius),
|
|
601
|
+
)
|
|
602
|
+
object.__setattr__(
|
|
603
|
+
self,
|
|
604
|
+
"projection_beta",
|
|
605
|
+
None if self.projection_beta is None else float(self.projection_beta),
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
class SceneModule(torch.nn.Module):
|
|
610
|
+
def to_scene(self) -> "Scene":
|
|
611
|
+
raise NotImplementedError("SceneModule subclasses must implement to_scene().")
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
_USE_SCENE_SUBPIXEL_SAMPLES = object()
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _normalize_subpixel_samples(value) -> tuple[int, int, int]:
|
|
618
|
+
if isinstance(value, bool):
|
|
619
|
+
raise TypeError("subpixel_samples must be an int or a length-3 iterable, not bool.")
|
|
620
|
+
if isinstance(value, int):
|
|
621
|
+
if value < 1:
|
|
622
|
+
raise ValueError("subpixel_samples must be >= 1.")
|
|
623
|
+
return (value, value, value)
|
|
624
|
+
if len(value) != 3:
|
|
625
|
+
raise ValueError("subpixel_samples must be an int or a length-3 iterable.")
|
|
626
|
+
normalized = tuple(int(v) for v in value)
|
|
627
|
+
if any(v < 1 for v in normalized):
|
|
628
|
+
raise ValueError("subpixel_samples values must be >= 1.")
|
|
629
|
+
return normalized
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
_SUBPIXEL_AVERAGING = {"arithmetic", "polarized"}
|
|
633
|
+
_SUBPIXEL_PEC = {"staircase", "conformal"}
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
@dataclass(frozen=True)
|
|
637
|
+
class SubpixelSpec:
|
|
638
|
+
"""Subpixel material-averaging policy.
|
|
639
|
+
|
|
640
|
+
``averaging`` selects the per-Yee-component permittivity/permeability blend:
|
|
641
|
+
``"arithmetic"`` (scalar volume-fraction mixing, the default) or ``"polarized"``
|
|
642
|
+
(Kottke normal-projection mixing that uses the harmonic mean along the interface
|
|
643
|
+
normal and the arithmetic mean tangentially). ``pec`` selects how PEC-material
|
|
644
|
+
edges are handled by FDTD: ``"staircase"`` (hard edge) or ``"conformal"``
|
|
645
|
+
(fractional partial-fill edge suppression). The defaults reproduce the legacy
|
|
646
|
+
arithmetic / staircase behaviour bitwise.
|
|
647
|
+
"""
|
|
648
|
+
|
|
649
|
+
samples: tuple[int, int, int] = (1, 1, 1)
|
|
650
|
+
averaging: str = "arithmetic"
|
|
651
|
+
pec: str = "staircase"
|
|
652
|
+
|
|
653
|
+
def __post_init__(self):
|
|
654
|
+
object.__setattr__(self, "samples", _normalize_subpixel_samples(self.samples))
|
|
655
|
+
averaging = str(self.averaging).lower()
|
|
656
|
+
if averaging not in _SUBPIXEL_AVERAGING:
|
|
657
|
+
raise ValueError("SubpixelSpec.averaging must be 'arithmetic' or 'polarized'.")
|
|
658
|
+
pec = str(self.pec).lower()
|
|
659
|
+
if pec not in _SUBPIXEL_PEC:
|
|
660
|
+
raise ValueError("SubpixelSpec.pec must be 'staircase' or 'conformal'.")
|
|
661
|
+
object.__setattr__(self, "averaging", averaging)
|
|
662
|
+
object.__setattr__(self, "pec", pec)
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _coerce_subpixel(value) -> SubpixelSpec:
|
|
666
|
+
if isinstance(value, SubpixelSpec):
|
|
667
|
+
return value
|
|
668
|
+
return SubpixelSpec(samples=_normalize_subpixel_samples(value))
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _normalize_symmetry_entry(axis_value) -> tuple[str, str] | None:
|
|
672
|
+
"""Normalize a single axis symmetry declaration to ``(mode, face)`` or ``None``.
|
|
673
|
+
|
|
674
|
+
Accepted forms per axis:
|
|
675
|
+
- ``None``: no symmetry on this axis.
|
|
676
|
+
- ``"PEC"`` / ``"PMC"``: image plane on the domain low face (default).
|
|
677
|
+
- ``("PEC", "low")`` / ``("PMC", "high")``: explicit symmetry-plane face.
|
|
678
|
+
|
|
679
|
+
``face`` selects which domain face carries the symmetry plane, i.e. which half
|
|
680
|
+
of the full domain was folded away. ``"low"`` keeps ``[plane, high]`` and mirrors
|
|
681
|
+
downward; ``"high"`` keeps ``[low, plane]`` and mirrors upward.
|
|
682
|
+
"""
|
|
683
|
+
if axis_value is None:
|
|
684
|
+
return None
|
|
685
|
+
face = "low"
|
|
686
|
+
mode = axis_value
|
|
687
|
+
if isinstance(axis_value, (tuple, list)):
|
|
688
|
+
if len(axis_value) != 2:
|
|
689
|
+
raise ValueError("symmetry entry tuples must be (mode, face).")
|
|
690
|
+
mode, face = axis_value
|
|
691
|
+
face = str(face).lower()
|
|
692
|
+
if face not in {"low", "high"}:
|
|
693
|
+
raise ValueError("symmetry face must be 'low' or 'high'.")
|
|
694
|
+
label = str(mode).upper()
|
|
695
|
+
if label not in {"PEC", "PMC"}:
|
|
696
|
+
raise ValueError("symmetry entries must be None, 'PEC', 'PMC', or (mode, face).")
|
|
697
|
+
return (label, face)
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _normalize_symmetry(value) -> tuple[tuple[str, str] | None, ...]:
|
|
701
|
+
if value is None:
|
|
702
|
+
return (None, None, None)
|
|
703
|
+
if len(value) != 3:
|
|
704
|
+
raise ValueError("symmetry must contain exactly three axis entries.")
|
|
705
|
+
return tuple(_normalize_symmetry_entry(axis_value) for axis_value in value)
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _to_like_tensor(value, *, reference: torch.Tensor) -> torch.Tensor:
|
|
709
|
+
return torch.as_tensor(value, device=reference.device, dtype=reference.dtype)
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def _component_summary(components: dict[str, torch.Tensor]) -> torch.Tensor:
|
|
713
|
+
return (components["x"] + components["y"] + components["z"]) / 3.0
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _clone_component_map(components: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]:
|
|
717
|
+
return {axis: tensor.clone() for axis, tensor in components.items()}
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def _resolve_scene_device(device) -> str:
|
|
721
|
+
requested = "cuda" if device is None else device
|
|
722
|
+
resolved = torch.device(requested)
|
|
723
|
+
if resolved.type == "cuda" and not torch.cuda.is_available():
|
|
724
|
+
raise RuntimeError(
|
|
725
|
+
"Scene requires CUDA by default, but torch.cuda.is_available() is False. "
|
|
726
|
+
"Pass device='cpu' for scene-only or CPU postprocessing workflows."
|
|
727
|
+
)
|
|
728
|
+
return str(resolved)
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def _domain_range_from_bounds(
|
|
732
|
+
bounds: tuple[tuple[float, float], tuple[float, float], tuple[float, float]],
|
|
733
|
+
) -> tuple[float, float, float, float, float, float]:
|
|
734
|
+
return (
|
|
735
|
+
bounds[0][0],
|
|
736
|
+
bounds[0][1],
|
|
737
|
+
bounds[1][0],
|
|
738
|
+
bounds[1][1],
|
|
739
|
+
bounds[2][0],
|
|
740
|
+
bounds[2][1],
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
class Scene:
|
|
745
|
+
"""Declarative 3D scene definition for simulation backends."""
|
|
746
|
+
|
|
747
|
+
def __init__(
|
|
748
|
+
self,
|
|
749
|
+
domain: Domain | None = None,
|
|
750
|
+
grid: GridSpec | None = None,
|
|
751
|
+
boundary: BoundarySpec | None = None,
|
|
752
|
+
*,
|
|
753
|
+
structures=None,
|
|
754
|
+
sources=None,
|
|
755
|
+
monitors=None,
|
|
756
|
+
ports=None,
|
|
757
|
+
material_regions=None,
|
|
758
|
+
metadata=None,
|
|
759
|
+
device="cuda",
|
|
760
|
+
verbose=False,
|
|
761
|
+
lazy_meshgrid=True,
|
|
762
|
+
subpixel_samples=1,
|
|
763
|
+
symmetry=None,
|
|
764
|
+
):
|
|
765
|
+
resolved_device = _resolve_scene_device(device)
|
|
766
|
+
self.structures = list(structures or [])
|
|
767
|
+
if any(not isinstance(structure, Structure) for structure in self.structures):
|
|
768
|
+
raise TypeError("Maxwell Scene structures must be witwin.core.Structure instances.")
|
|
769
|
+
self.sources = list(sources or [])
|
|
770
|
+
self.monitors = list(monitors or [])
|
|
771
|
+
self.metadata = dict(metadata or {})
|
|
772
|
+
self.device = resolved_device
|
|
773
|
+
self.verbose = bool(verbose)
|
|
774
|
+
|
|
775
|
+
if domain is None:
|
|
776
|
+
domain = Domain.from_domain_range((-1.0, 1.0, -1.0, 1.0, -1.0, 1.0))
|
|
777
|
+
if grid is None:
|
|
778
|
+
grid = GridSpec.uniform(0.05)
|
|
779
|
+
if boundary is None:
|
|
780
|
+
boundary = BoundarySpec.none()
|
|
781
|
+
|
|
782
|
+
self.domain = domain
|
|
783
|
+
self.grid = grid
|
|
784
|
+
self.boundary = boundary
|
|
785
|
+
self.subpixel = _coerce_subpixel(subpixel_samples)
|
|
786
|
+
self.symmetry = _normalize_symmetry(symmetry)
|
|
787
|
+
self.material_regions = list(material_regions or [])
|
|
788
|
+
self.ports = list(ports or [])
|
|
789
|
+
self.lazy_meshgrid = bool(lazy_meshgrid)
|
|
790
|
+
|
|
791
|
+
@property
|
|
792
|
+
def subpixel_samples(self) -> tuple[int, int, int]:
|
|
793
|
+
return self.subpixel.samples
|
|
794
|
+
|
|
795
|
+
def _scalar_grid_step(self, value: float | None) -> float:
|
|
796
|
+
if self.grid.is_custom or self.grid.is_auto:
|
|
797
|
+
raise ValueError(
|
|
798
|
+
"scalar spacing undefined on a nonuniform grid; use scene.x/y/z, x_half, or grid.min_spacing."
|
|
799
|
+
)
|
|
800
|
+
return float(value)
|
|
801
|
+
|
|
802
|
+
@property
|
|
803
|
+
def dx(self) -> float:
|
|
804
|
+
return self._scalar_grid_step(self.grid.dx)
|
|
805
|
+
|
|
806
|
+
@property
|
|
807
|
+
def dy(self) -> float:
|
|
808
|
+
return self._scalar_grid_step(self.grid.dy)
|
|
809
|
+
|
|
810
|
+
@property
|
|
811
|
+
def dz(self) -> float:
|
|
812
|
+
return self._scalar_grid_step(self.grid.dz)
|
|
813
|
+
|
|
814
|
+
@property
|
|
815
|
+
def grid_spacing(self) -> tuple[float, float, float]:
|
|
816
|
+
return self.grid.spacing
|
|
817
|
+
|
|
818
|
+
@property
|
|
819
|
+
def domain_size(self) -> float:
|
|
820
|
+
bounds = self.domain.bounds
|
|
821
|
+
return max(
|
|
822
|
+
float(bounds[0][1] - bounds[0][0]),
|
|
823
|
+
float(bounds[1][1] - bounds[1][0]),
|
|
824
|
+
float(bounds[2][1] - bounds[2][0]),
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
@property
|
|
828
|
+
def boundary_type(self):
|
|
829
|
+
labels = {
|
|
830
|
+
"none": "None",
|
|
831
|
+
"pml": "PML",
|
|
832
|
+
"periodic": "Periodic",
|
|
833
|
+
"bloch": "Bloch",
|
|
834
|
+
"pec": "PEC",
|
|
835
|
+
"pmc": "PMC",
|
|
836
|
+
"mixed": "Mixed",
|
|
837
|
+
}
|
|
838
|
+
return labels[self.boundary.kind]
|
|
839
|
+
|
|
840
|
+
@property
|
|
841
|
+
def bc_type(self):
|
|
842
|
+
return self.boundary_type
|
|
843
|
+
|
|
844
|
+
@property
|
|
845
|
+
def pml_thickness(self):
|
|
846
|
+
return int(self.boundary.num_layers) if self.boundary.uses_kind("pml") else 0
|
|
847
|
+
|
|
848
|
+
@property
|
|
849
|
+
def pml_strength(self):
|
|
850
|
+
return float(self.boundary.strength) if self.boundary.uses_kind("pml") else 0.0
|
|
851
|
+
|
|
852
|
+
@property
|
|
853
|
+
def bloch_wavevector(self):
|
|
854
|
+
if not self.boundary.uses_kind("bloch"):
|
|
855
|
+
return (0.0, 0.0, 0.0)
|
|
856
|
+
if self.boundary.bloch_wavevector == "auto":
|
|
857
|
+
raise ValueError(
|
|
858
|
+
"Automatic Bloch wavevectors require Simulation.prepare() to resolve the incident wavevector."
|
|
859
|
+
)
|
|
860
|
+
return self.boundary.bloch_wavevector
|
|
861
|
+
|
|
862
|
+
def boundary_face_kind(self, axis: str, side: str) -> BoundaryKind:
|
|
863
|
+
return self.boundary.face_kind(axis, side)
|
|
864
|
+
|
|
865
|
+
def pml_thickness_for_face(self, axis: str, side: str) -> int:
|
|
866
|
+
axis_index = {"x": 0, "y": 1, "z": 2}[axis]
|
|
867
|
+
symmetry = self.symmetry[axis_index]
|
|
868
|
+
if symmetry is not None and symmetry[1] == side:
|
|
869
|
+
return 0
|
|
870
|
+
return self.boundary.pml_layers_for_face(axis, side)
|
|
871
|
+
|
|
872
|
+
@property
|
|
873
|
+
def bloch_phase_factors(self):
|
|
874
|
+
domain_range = _domain_range_from_bounds(self.domain.bounds)
|
|
875
|
+
return self.boundary.bloch_phase_factors(domain_range)
|
|
876
|
+
|
|
877
|
+
@property
|
|
878
|
+
def has_symmetry(self) -> bool:
|
|
879
|
+
return any(mode is not None for mode in self.symmetry)
|
|
880
|
+
|
|
881
|
+
def add_structure(self, structure: Structure):
|
|
882
|
+
if not isinstance(structure, Structure):
|
|
883
|
+
raise TypeError("Maxwell Scene structures must be witwin.core.Structure instances.")
|
|
884
|
+
self.structures.append(structure)
|
|
885
|
+
return self
|
|
886
|
+
|
|
887
|
+
def add_source(self, source):
|
|
888
|
+
self.sources.append(source)
|
|
889
|
+
return self
|
|
890
|
+
|
|
891
|
+
def add_monitor(self, monitor):
|
|
892
|
+
self.monitors.append(monitor)
|
|
893
|
+
return self
|
|
894
|
+
|
|
895
|
+
def add_port(self, port: ModePort):
|
|
896
|
+
self.ports.append(port)
|
|
897
|
+
return self
|
|
898
|
+
|
|
899
|
+
def add_material_region(self, material_region: MaterialRegion):
|
|
900
|
+
self.material_regions.append(material_region)
|
|
901
|
+
return self
|
|
902
|
+
|
|
903
|
+
def clone(self, **overrides):
|
|
904
|
+
params = {
|
|
905
|
+
"domain": self.domain,
|
|
906
|
+
"grid": self.grid,
|
|
907
|
+
"boundary": self.boundary,
|
|
908
|
+
"structures": list(self.structures),
|
|
909
|
+
"sources": list(self.sources),
|
|
910
|
+
"monitors": list(self.monitors),
|
|
911
|
+
"ports": list(self.ports),
|
|
912
|
+
"material_regions": list(self.material_regions),
|
|
913
|
+
"metadata": dict(self.metadata),
|
|
914
|
+
"device": self.device,
|
|
915
|
+
"verbose": self.verbose,
|
|
916
|
+
"lazy_meshgrid": self.lazy_meshgrid,
|
|
917
|
+
"subpixel_samples": self.subpixel,
|
|
918
|
+
"symmetry": self.symmetry,
|
|
919
|
+
}
|
|
920
|
+
params.update(overrides)
|
|
921
|
+
return Scene(**params)
|
|
922
|
+
|
|
923
|
+
def resolved_sources(self):
|
|
924
|
+
resolved = list(self.sources)
|
|
925
|
+
for port in self.ports:
|
|
926
|
+
source = port.to_mode_source()
|
|
927
|
+
if source is not None:
|
|
928
|
+
resolved.append(source)
|
|
929
|
+
return resolved
|
|
930
|
+
|
|
931
|
+
def resolved_monitors(self):
|
|
932
|
+
resolved = []
|
|
933
|
+
for monitor in self.monitors:
|
|
934
|
+
if hasattr(monitor, "resolved_monitors"):
|
|
935
|
+
resolved.extend(monitor.resolved_monitors())
|
|
936
|
+
else:
|
|
937
|
+
resolved.append(monitor)
|
|
938
|
+
for port in self.ports:
|
|
939
|
+
resolved.append(port.to_mode_monitor())
|
|
940
|
+
return resolved
|
|
941
|
+
|
|
942
|
+
def to_tidy3d(self, *, frequencies=None, run_time=None, **kwargs):
|
|
943
|
+
"""Convert this scene to a ``tidy3d.Simulation``.
|
|
944
|
+
|
|
945
|
+
Parameters
|
|
946
|
+
----------
|
|
947
|
+
frequencies : float or sequence of float, optional
|
|
948
|
+
Monitoring / source frequencies in Hz.
|
|
949
|
+
run_time : float, optional
|
|
950
|
+
Simulation run time in seconds. Estimated from domain size if omitted.
|
|
951
|
+
**kwargs
|
|
952
|
+
Extra keyword arguments forwarded to ``tidy3d.Simulation``.
|
|
953
|
+
|
|
954
|
+
Returns
|
|
955
|
+
-------
|
|
956
|
+
tidy3d.Simulation
|
|
957
|
+
"""
|
|
958
|
+
from .adapters.tidy3d import scene_to_tidy3d
|
|
959
|
+
return scene_to_tidy3d(self, frequencies=frequencies, run_time=run_time, **kwargs)
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def _axis_nodes64(
|
|
963
|
+
grid: GridSpec,
|
|
964
|
+
axis: str,
|
|
965
|
+
axis_lo: float,
|
|
966
|
+
axis_hi: float,
|
|
967
|
+
*,
|
|
968
|
+
anchor_high: bool = False,
|
|
969
|
+
) -> np.ndarray:
|
|
970
|
+
coords = grid.axis_coords(axis)
|
|
971
|
+
if coords is None:
|
|
972
|
+
step = float(getattr(grid, f"d{axis}"))
|
|
973
|
+
count = max(0, int(math.ceil((float(axis_hi) - float(axis_lo)) / step)))
|
|
974
|
+
if anchor_high:
|
|
975
|
+
return np.linspace(float(axis_lo), float(axis_hi), count + 1, endpoint=True, dtype=np.float64)[1:]
|
|
976
|
+
return np.linspace(float(axis_lo), float(axis_hi), count, endpoint=False, dtype=np.float64)
|
|
977
|
+
span = float(axis_hi) - float(axis_lo)
|
|
978
|
+
tolerance = 1e-9 * span
|
|
979
|
+
if abs(float(coords[0]) - float(axis_lo)) > tolerance or abs(float(coords[-1]) - float(axis_hi)) > tolerance:
|
|
980
|
+
raise ValueError(
|
|
981
|
+
f"GridSpec.custom {axis}_coords must span the domain exactly along axis '{axis}'; "
|
|
982
|
+
f"build Domain from ({float(coords[0]):g}, {float(coords[-1]):g})."
|
|
983
|
+
)
|
|
984
|
+
return coords
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
def _axis_dual_spacing64(primal: np.ndarray, boundary_kind: str) -> np.ndarray:
|
|
988
|
+
dual = np.empty(primal.size + 1, dtype=np.float64)
|
|
989
|
+
dual[1:-1] = 0.5 * (primal[:-1] + primal[1:])
|
|
990
|
+
if boundary_kind in ("periodic", "bloch"):
|
|
991
|
+
# Distance from the wrap image of the last half-point to the first half-point.
|
|
992
|
+
wrap = 0.5 * (primal[0] + primal[-1])
|
|
993
|
+
dual[0] = wrap
|
|
994
|
+
dual[-1] = wrap
|
|
995
|
+
else:
|
|
996
|
+
# PMC mirror distance across the boundary node; placeholder for PEC/none/PML.
|
|
997
|
+
dual[0] = primal[0]
|
|
998
|
+
dual[-1] = primal[-1]
|
|
999
|
+
return dual
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def _build_axis_grid64(
|
|
1003
|
+
grid: GridSpec,
|
|
1004
|
+
axis: str,
|
|
1005
|
+
axis_lo: float,
|
|
1006
|
+
axis_hi: float,
|
|
1007
|
+
boundary_kind: str,
|
|
1008
|
+
pml_layers: tuple[int, int] = (0, 0),
|
|
1009
|
+
*,
|
|
1010
|
+
anchor_high: bool = False,
|
|
1011
|
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
|
1012
|
+
nodes = _axis_nodes64(grid, axis, axis_lo, axis_hi, anchor_high=anchor_high)
|
|
1013
|
+
low_layers, high_layers = (int(value) for value in pml_layers)
|
|
1014
|
+
if low_layers or high_layers:
|
|
1015
|
+
if nodes.size < 2:
|
|
1016
|
+
raise ValueError(f"Cannot extend the {axis}-axis PML from fewer than two physical nodes.")
|
|
1017
|
+
low_step = float(nodes[1] - nodes[0])
|
|
1018
|
+
high_step = float(nodes[-1] - nodes[-2])
|
|
1019
|
+
low_nodes = nodes[0] - low_step * np.arange(low_layers, 0, -1, dtype=np.float64)
|
|
1020
|
+
high_nodes = nodes[-1] + high_step * np.arange(1, high_layers + 1, dtype=np.float64)
|
|
1021
|
+
nodes = np.concatenate((low_nodes, nodes, high_nodes))
|
|
1022
|
+
primal = np.diff(nodes)
|
|
1023
|
+
half = nodes[:-1] + 0.5 * primal
|
|
1024
|
+
dual = _axis_dual_spacing64(primal, boundary_kind)
|
|
1025
|
+
return nodes, primal, half, dual
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
class PreparedScene(Scene):
|
|
1029
|
+
"""Internal solver-ready scene with compiled grid state."""
|
|
1030
|
+
|
|
1031
|
+
def __init__(self, scene: Scene, *, lazy_meshgrid: bool | None = None):
|
|
1032
|
+
resolved_lazy_meshgrid = scene.lazy_meshgrid if lazy_meshgrid is None else bool(lazy_meshgrid)
|
|
1033
|
+
super().__init__(
|
|
1034
|
+
domain=scene.domain,
|
|
1035
|
+
grid=scene.grid,
|
|
1036
|
+
boundary=scene.boundary,
|
|
1037
|
+
structures=list(scene.structures),
|
|
1038
|
+
sources=list(scene.sources),
|
|
1039
|
+
monitors=list(scene.monitors),
|
|
1040
|
+
ports=list(scene.ports),
|
|
1041
|
+
material_regions=list(scene.material_regions),
|
|
1042
|
+
metadata=dict(scene.metadata),
|
|
1043
|
+
device=scene.device,
|
|
1044
|
+
verbose=scene.verbose,
|
|
1045
|
+
lazy_meshgrid=resolved_lazy_meshgrid,
|
|
1046
|
+
subpixel_samples=scene.subpixel,
|
|
1047
|
+
symmetry=scene.symmetry,
|
|
1048
|
+
)
|
|
1049
|
+
self._public_scene = scene
|
|
1050
|
+
self.physical_domain_range = _domain_range_from_bounds(self.domain.bounds)
|
|
1051
|
+
|
|
1052
|
+
if self.grid.is_auto:
|
|
1053
|
+
from .fdtd.meshing import resolve_auto_grid
|
|
1054
|
+
|
|
1055
|
+
self.grid = GridSpec.custom(*resolve_auto_grid(self))
|
|
1056
|
+
|
|
1057
|
+
x_start, x_end, y_start, y_end, z_start, z_end = self.physical_domain_range
|
|
1058
|
+
high_symmetry_axes = {
|
|
1059
|
+
axis
|
|
1060
|
+
for axis, entry in zip(("x", "y", "z"), self.symmetry)
|
|
1061
|
+
if entry is not None and entry[1] == "high"
|
|
1062
|
+
}
|
|
1063
|
+
self.x_nodes64, self.dx_primal64, self.x_half64, self.dx_dual64 = _build_axis_grid64(
|
|
1064
|
+
self.grid,
|
|
1065
|
+
"x",
|
|
1066
|
+
x_start,
|
|
1067
|
+
x_end,
|
|
1068
|
+
self.boundary.axis_kind("x"),
|
|
1069
|
+
(self.pml_thickness_for_face("x", "low"), self.pml_thickness_for_face("x", "high")),
|
|
1070
|
+
anchor_high="x" in high_symmetry_axes,
|
|
1071
|
+
)
|
|
1072
|
+
self.y_nodes64, self.dy_primal64, self.y_half64, self.dy_dual64 = _build_axis_grid64(
|
|
1073
|
+
self.grid,
|
|
1074
|
+
"y",
|
|
1075
|
+
y_start,
|
|
1076
|
+
y_end,
|
|
1077
|
+
self.boundary.axis_kind("y"),
|
|
1078
|
+
(self.pml_thickness_for_face("y", "low"), self.pml_thickness_for_face("y", "high")),
|
|
1079
|
+
anchor_high="y" in high_symmetry_axes,
|
|
1080
|
+
)
|
|
1081
|
+
self.z_nodes64, self.dz_primal64, self.z_half64, self.dz_dual64 = _build_axis_grid64(
|
|
1082
|
+
self.grid,
|
|
1083
|
+
"z",
|
|
1084
|
+
z_start,
|
|
1085
|
+
z_end,
|
|
1086
|
+
self.boundary.axis_kind("z"),
|
|
1087
|
+
(self.pml_thickness_for_face("z", "low"), self.pml_thickness_for_face("z", "high")),
|
|
1088
|
+
anchor_high="z" in high_symmetry_axes,
|
|
1089
|
+
)
|
|
1090
|
+
computational_bounds = []
|
|
1091
|
+
for axis_index, (axis, nodes, primal) in enumerate(
|
|
1092
|
+
(
|
|
1093
|
+
("x", self.x_nodes64, self.dx_primal64),
|
|
1094
|
+
("y", self.y_nodes64, self.dy_primal64),
|
|
1095
|
+
("z", self.z_nodes64, self.dz_primal64),
|
|
1096
|
+
)
|
|
1097
|
+
):
|
|
1098
|
+
low_layers = self.pml_thickness_for_face(axis, "low")
|
|
1099
|
+
high_layers = self.pml_thickness_for_face(axis, "high")
|
|
1100
|
+
physical_lo, physical_hi = self.domain.bounds[axis_index]
|
|
1101
|
+
low_step = float(primal[low_layers]) if low_layers < primal.size else float(primal[0])
|
|
1102
|
+
high_index = primal.size - high_layers - 1
|
|
1103
|
+
high_step = float(primal[max(high_index, 0)])
|
|
1104
|
+
computational_bounds.append(
|
|
1105
|
+
(
|
|
1106
|
+
float(physical_lo) - low_layers * low_step,
|
|
1107
|
+
float(physical_hi) + high_layers * high_step,
|
|
1108
|
+
)
|
|
1109
|
+
)
|
|
1110
|
+
self.domain_range = _domain_range_from_bounds(computational_bounds)
|
|
1111
|
+
self.x = torch.tensor(self.x_nodes64, dtype=torch.float32, device=self.device)
|
|
1112
|
+
self.y = torch.tensor(self.y_nodes64, dtype=torch.float32, device=self.device)
|
|
1113
|
+
self.z = torch.tensor(self.z_nodes64, dtype=torch.float32, device=self.device)
|
|
1114
|
+
self.x_half = torch.tensor(self.x_half64, dtype=torch.float32, device=self.device)
|
|
1115
|
+
self.y_half = torch.tensor(self.y_half64, dtype=torch.float32, device=self.device)
|
|
1116
|
+
self.z_half = torch.tensor(self.z_half64, dtype=torch.float32, device=self.device)
|
|
1117
|
+
|
|
1118
|
+
self._xx = None
|
|
1119
|
+
self._yy = None
|
|
1120
|
+
self._zz = None
|
|
1121
|
+
self._material_model_cache = {}
|
|
1122
|
+
self._permittivity_components = None
|
|
1123
|
+
self._permeability_components = None
|
|
1124
|
+
self._permittivity = None
|
|
1125
|
+
self._permeability = None
|
|
1126
|
+
if not self.lazy_meshgrid:
|
|
1127
|
+
self._ensure_meshgrid()
|
|
1128
|
+
|
|
1129
|
+
self.Nx = len(self.x)
|
|
1130
|
+
self.Ny = len(self.y)
|
|
1131
|
+
self.Nz = len(self.z)
|
|
1132
|
+
self.N_total = self.Nx * self.Ny * self.Nz
|
|
1133
|
+
self.N_total_nodes = self.N_total
|
|
1134
|
+
self.Nx_ex, self.Ny_ex, self.Nz_ex = self.Nx - 1, self.Ny, self.Nz
|
|
1135
|
+
self.Nx_ey, self.Ny_ey, self.Nz_ey = self.Nx, self.Ny - 1, self.Nz
|
|
1136
|
+
self.Nx_ez, self.Ny_ez, self.Nz_ez = self.Nx, self.Ny, self.Nz - 1
|
|
1137
|
+
self.N_ex = self.Nx_ex * self.Ny_ex * self.Nz_ex
|
|
1138
|
+
self.N_ey = self.Nx_ey * self.Ny_ey * self.Nz_ey
|
|
1139
|
+
self.N_ez = self.Nx_ez * self.Ny_ez * self.Nz_ez
|
|
1140
|
+
self.N_vector_total = self.N_ex + self.N_ey + self.N_ez
|
|
1141
|
+
|
|
1142
|
+
def _ensure_meshgrid(self):
|
|
1143
|
+
if self._xx is None or self._yy is None or self._zz is None:
|
|
1144
|
+
self._xx, self._yy, self._zz = torch.meshgrid(self.x, self.y, self.z, indexing="ij")
|
|
1145
|
+
|
|
1146
|
+
def _invalidate_material_cache(self):
|
|
1147
|
+
self._material_model_cache = {}
|
|
1148
|
+
self._permittivity_components = None
|
|
1149
|
+
self._permeability_components = None
|
|
1150
|
+
self._permittivity = None
|
|
1151
|
+
self._permeability = None
|
|
1152
|
+
|
|
1153
|
+
def _has_dynamic_materials(self) -> bool:
|
|
1154
|
+
if self.material_regions:
|
|
1155
|
+
return True
|
|
1156
|
+
for structure in self.structures:
|
|
1157
|
+
material = getattr(structure, "material", None)
|
|
1158
|
+
perturbation = getattr(material, "perturbation", None)
|
|
1159
|
+
if isinstance(perturbation, torch.Tensor) and perturbation.requires_grad:
|
|
1160
|
+
return True
|
|
1161
|
+
return False
|
|
1162
|
+
|
|
1163
|
+
def _has_trainable_geometry(self) -> bool:
|
|
1164
|
+
for structure in self.structures:
|
|
1165
|
+
geometry = getattr(structure, "geometry", None)
|
|
1166
|
+
if geometry is None:
|
|
1167
|
+
continue
|
|
1168
|
+
for value in vars(geometry).values():
|
|
1169
|
+
if isinstance(value, torch.Tensor) and value.requires_grad:
|
|
1170
|
+
return True
|
|
1171
|
+
return False
|
|
1172
|
+
|
|
1173
|
+
@property
|
|
1174
|
+
def xx(self):
|
|
1175
|
+
self._ensure_meshgrid()
|
|
1176
|
+
return self._xx
|
|
1177
|
+
|
|
1178
|
+
@property
|
|
1179
|
+
def yy(self):
|
|
1180
|
+
self._ensure_meshgrid()
|
|
1181
|
+
return self._yy
|
|
1182
|
+
|
|
1183
|
+
@property
|
|
1184
|
+
def zz(self):
|
|
1185
|
+
self._ensure_meshgrid()
|
|
1186
|
+
return self._zz
|
|
1187
|
+
|
|
1188
|
+
@property
|
|
1189
|
+
def X(self):
|
|
1190
|
+
return self.xx
|
|
1191
|
+
|
|
1192
|
+
@property
|
|
1193
|
+
def Y(self):
|
|
1194
|
+
return self.yy
|
|
1195
|
+
|
|
1196
|
+
@property
|
|
1197
|
+
def Z(self):
|
|
1198
|
+
return self.zz
|
|
1199
|
+
|
|
1200
|
+
def release_meshgrid(self):
|
|
1201
|
+
self._xx = None
|
|
1202
|
+
self._yy = None
|
|
1203
|
+
self._zz = None
|
|
1204
|
+
|
|
1205
|
+
def add_structure(self, structure: Structure):
|
|
1206
|
+
super().add_structure(structure)
|
|
1207
|
+
self._invalidate_material_cache()
|
|
1208
|
+
return self
|
|
1209
|
+
|
|
1210
|
+
def add_material_region(self, material_region: MaterialRegion):
|
|
1211
|
+
super().add_material_region(material_region)
|
|
1212
|
+
self._invalidate_material_cache()
|
|
1213
|
+
return self
|
|
1214
|
+
|
|
1215
|
+
def compile_materials(self, subpixel_samples=_USE_SCENE_SUBPIXEL_SAMPLES):
|
|
1216
|
+
spec = (
|
|
1217
|
+
self.subpixel
|
|
1218
|
+
if subpixel_samples is _USE_SCENE_SUBPIXEL_SAMPLES
|
|
1219
|
+
else _coerce_subpixel(subpixel_samples)
|
|
1220
|
+
)
|
|
1221
|
+
key = spec
|
|
1222
|
+
if self._has_dynamic_materials() or self._has_trainable_geometry():
|
|
1223
|
+
model = compile_material_model(
|
|
1224
|
+
self,
|
|
1225
|
+
eps_background=1.0,
|
|
1226
|
+
mu_background=1.0,
|
|
1227
|
+
subpixel=spec,
|
|
1228
|
+
)
|
|
1229
|
+
self.release_meshgrid()
|
|
1230
|
+
return model
|
|
1231
|
+
|
|
1232
|
+
cached = self._material_model_cache.get(key)
|
|
1233
|
+
if cached is None:
|
|
1234
|
+
cached = compile_material_model(
|
|
1235
|
+
self,
|
|
1236
|
+
eps_background=1.0,
|
|
1237
|
+
mu_background=1.0,
|
|
1238
|
+
subpixel=spec,
|
|
1239
|
+
)
|
|
1240
|
+
self._material_model_cache[key] = cached
|
|
1241
|
+
self.release_meshgrid()
|
|
1242
|
+
return cached
|
|
1243
|
+
|
|
1244
|
+
def compile_relative_materials(
|
|
1245
|
+
self,
|
|
1246
|
+
subpixel_samples=_USE_SCENE_SUBPIXEL_SAMPLES,
|
|
1247
|
+
frequency=None,
|
|
1248
|
+
):
|
|
1249
|
+
eps_components, mu_components = self.compile_material_components(
|
|
1250
|
+
subpixel_samples=subpixel_samples,
|
|
1251
|
+
frequency=frequency,
|
|
1252
|
+
)
|
|
1253
|
+
return _component_summary(eps_components), _component_summary(mu_components)
|
|
1254
|
+
|
|
1255
|
+
def compile_material_components(
|
|
1256
|
+
self,
|
|
1257
|
+
subpixel_samples=_USE_SCENE_SUBPIXEL_SAMPLES,
|
|
1258
|
+
frequency=None,
|
|
1259
|
+
):
|
|
1260
|
+
model = self.compile_materials(subpixel_samples=subpixel_samples)
|
|
1261
|
+
return evaluate_material_components(model, frequency)
|
|
1262
|
+
|
|
1263
|
+
def compile_material_tensors(
|
|
1264
|
+
self,
|
|
1265
|
+
eps_background=1.0,
|
|
1266
|
+
mu_background=1.0,
|
|
1267
|
+
subpixel_samples=_USE_SCENE_SUBPIXEL_SAMPLES,
|
|
1268
|
+
frequency=None,
|
|
1269
|
+
):
|
|
1270
|
+
eps_r, mu_r = self.compile_relative_materials(
|
|
1271
|
+
subpixel_samples=subpixel_samples,
|
|
1272
|
+
frequency=frequency,
|
|
1273
|
+
)
|
|
1274
|
+
eps_background_tensor = _to_like_tensor(eps_background, reference=eps_r)
|
|
1275
|
+
mu_background_tensor = _to_like_tensor(mu_background, reference=mu_r)
|
|
1276
|
+
if torch.equal(eps_background_tensor, torch.ones_like(eps_background_tensor)) and torch.equal(
|
|
1277
|
+
mu_background_tensor,
|
|
1278
|
+
torch.ones_like(mu_background_tensor),
|
|
1279
|
+
):
|
|
1280
|
+
return eps_r.clone(), mu_r.clone()
|
|
1281
|
+
return eps_r * eps_background_tensor, mu_r * mu_background_tensor
|
|
1282
|
+
|
|
1283
|
+
def refresh_material_grids(self, subpixel_samples=_USE_SCENE_SUBPIXEL_SAMPLES):
|
|
1284
|
+
eps_components, mu_components = self.compile_material_components(
|
|
1285
|
+
subpixel_samples=subpixel_samples,
|
|
1286
|
+
)
|
|
1287
|
+
self._permittivity_components = _clone_component_map(eps_components)
|
|
1288
|
+
self._permeability_components = _clone_component_map(mu_components)
|
|
1289
|
+
self._permittivity = _component_summary(self._permittivity_components)
|
|
1290
|
+
self._permeability = _component_summary(self._permeability_components)
|
|
1291
|
+
return self._permittivity, self._permeability
|
|
1292
|
+
|
|
1293
|
+
def _ensure_material_grids(self):
|
|
1294
|
+
if self._permittivity is None or self._permeability is None:
|
|
1295
|
+
self.refresh_material_grids()
|
|
1296
|
+
|
|
1297
|
+
@property
|
|
1298
|
+
def permittivity_components(self):
|
|
1299
|
+
self._ensure_material_grids()
|
|
1300
|
+
return self._permittivity_components
|
|
1301
|
+
|
|
1302
|
+
@property
|
|
1303
|
+
def permeability_components(self):
|
|
1304
|
+
self._ensure_material_grids()
|
|
1305
|
+
return self._permeability_components
|
|
1306
|
+
|
|
1307
|
+
@property
|
|
1308
|
+
def permittivity(self):
|
|
1309
|
+
self._ensure_material_grids()
|
|
1310
|
+
return self._permittivity
|
|
1311
|
+
|
|
1312
|
+
@property
|
|
1313
|
+
def permeability(self):
|
|
1314
|
+
self._ensure_material_grids()
|
|
1315
|
+
return self._permeability
|
|
1316
|
+
|
|
1317
|
+
def get_cross_section(self, axis="z", position=0.0, field=None):
|
|
1318
|
+
field = self.permittivity if field is None else field
|
|
1319
|
+
if axis == "z":
|
|
1320
|
+
idx = torch.argmin(torch.abs(self.z - position))
|
|
1321
|
+
return field[:, :, idx]
|
|
1322
|
+
if axis == "y":
|
|
1323
|
+
idx = torch.argmin(torch.abs(self.y - position))
|
|
1324
|
+
return field[:, idx, :]
|
|
1325
|
+
if axis == "x":
|
|
1326
|
+
idx = torch.argmin(torch.abs(self.x - position))
|
|
1327
|
+
return field[idx, :, :]
|
|
1328
|
+
raise ValueError("axis must be 'x', 'y', or 'z'")
|
|
1329
|
+
|
|
1330
|
+
|
|
1331
|
+
def prepare_scene(scene: Scene | PreparedScene, *, lazy_meshgrid: bool | None = None) -> PreparedScene:
|
|
1332
|
+
if isinstance(scene, PreparedScene):
|
|
1333
|
+
if lazy_meshgrid is None or scene.lazy_meshgrid == bool(lazy_meshgrid):
|
|
1334
|
+
return scene
|
|
1335
|
+
return PreparedScene(scene._public_scene, lazy_meshgrid=lazy_meshgrid)
|
|
1336
|
+
if not isinstance(scene, Scene):
|
|
1337
|
+
raise TypeError("prepare_scene() expects a maxwell.Scene instance.")
|
|
1338
|
+
return PreparedScene(scene, lazy_meshgrid=lazy_meshgrid)
|