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.
Files changed (107) hide show
  1. witwin/maxwell/__init__.py +164 -0
  2. witwin/maxwell/adapters/__init__.py +1 -0
  3. witwin/maxwell/adapters/gds.py +203 -0
  4. witwin/maxwell/adapters/tidy3d.py +1669 -0
  5. witwin/maxwell/adjoint_inputs.py +70 -0
  6. witwin/maxwell/compiler/__init__.py +11 -0
  7. witwin/maxwell/compiler/materials.py +1355 -0
  8. witwin/maxwell/compiler/monitors.py +164 -0
  9. witwin/maxwell/compiler/sources.py +222 -0
  10. witwin/maxwell/fdfd/__init__.py +3 -0
  11. witwin/maxwell/fdfd/adjoint/__init__.py +3 -0
  12. witwin/maxwell/fdfd/adjoint/bridge.py +180 -0
  13. witwin/maxwell/fdfd/ams.py +300 -0
  14. witwin/maxwell/fdfd/krylov.py +280 -0
  15. witwin/maxwell/fdfd/plotting.py +110 -0
  16. witwin/maxwell/fdfd/postprocess.py +33 -0
  17. witwin/maxwell/fdfd/solver.py +1057 -0
  18. witwin/maxwell/fdtd/__init__.py +4 -0
  19. witwin/maxwell/fdtd/adjoint/__init__.py +21 -0
  20. witwin/maxwell/fdtd/adjoint/bridge.py +746 -0
  21. witwin/maxwell/fdtd/adjoint/capabilities.py +29 -0
  22. witwin/maxwell/fdtd/adjoint/core.py +3614 -0
  23. witwin/maxwell/fdtd/adjoint/dispatch.py +684 -0
  24. witwin/maxwell/fdtd/adjoint/native.py +2561 -0
  25. witwin/maxwell/fdtd/adjoint/profiler.py +195 -0
  26. witwin/maxwell/fdtd/adjoint/reverse_common.py +207 -0
  27. witwin/maxwell/fdtd/adjoint/seeds.py +524 -0
  28. witwin/maxwell/fdtd/boundary/__init__.py +37 -0
  29. witwin/maxwell/fdtd/boundary/bloch.py +90 -0
  30. witwin/maxwell/fdtd/boundary/common.py +41 -0
  31. witwin/maxwell/fdtd/boundary/cpml.py +527 -0
  32. witwin/maxwell/fdtd/boundary/runtime.py +209 -0
  33. witwin/maxwell/fdtd/checkpoint.py +227 -0
  34. witwin/maxwell/fdtd/coords.py +32 -0
  35. witwin/maxwell/fdtd/cuda/__init__.py +11 -0
  36. witwin/maxwell/fdtd/cuda/backend.py +3532 -0
  37. witwin/maxwell/fdtd/cuda/build.py +292 -0
  38. witwin/maxwell/fdtd/cuda/extension.cpp +2053 -0
  39. witwin/maxwell/fdtd/cuda/kernels/adjoint.cu +5290 -0
  40. witwin/maxwell/fdtd/cuda/kernels/aniso.cu +1704 -0
  41. witwin/maxwell/fdtd/cuda/kernels/boundary.cu +424 -0
  42. witwin/maxwell/fdtd/cuda/kernels/common.cu +74 -0
  43. witwin/maxwell/fdtd/cuda/kernels/common.cuh +82 -0
  44. witwin/maxwell/fdtd/cuda/kernels/dispersive.cu +782 -0
  45. witwin/maxwell/fdtd/cuda/kernels/electric.cu +3613 -0
  46. witwin/maxwell/fdtd/cuda/kernels/magnetic.cu +781 -0
  47. witwin/maxwell/fdtd/cuda/kernels/observers.cu +347 -0
  48. witwin/maxwell/fdtd/cuda/kernels/projection.cu +211 -0
  49. witwin/maxwell/fdtd/cuda/kernels/sources.cu +1482 -0
  50. witwin/maxwell/fdtd/cuda/kernels/spectral.cu +297 -0
  51. witwin/maxwell/fdtd/cuda/launch.h +23 -0
  52. witwin/maxwell/fdtd/cuda/prebuilt/witwin_maxwell_fdtd_cuda.pyd +0 -0
  53. witwin/maxwell/fdtd/cuda/runtime/__init__.py +6 -0
  54. witwin/maxwell/fdtd/cuda/runtime/dispatcher.py +6 -0
  55. witwin/maxwell/fdtd/cuda/runtime/graph.py +33 -0
  56. witwin/maxwell/fdtd/cuda/tensors.h +35 -0
  57. witwin/maxwell/fdtd/dispersion.py +48 -0
  58. witwin/maxwell/fdtd/evaluation.py +290 -0
  59. witwin/maxwell/fdtd/excitation/__init__.py +27 -0
  60. witwin/maxwell/fdtd/excitation/injection.py +1102 -0
  61. witwin/maxwell/fdtd/excitation/modes.py +1675 -0
  62. witwin/maxwell/fdtd/excitation/spatial.py +469 -0
  63. witwin/maxwell/fdtd/excitation/temporal.py +510 -0
  64. witwin/maxwell/fdtd/excitation/tfsf_apply.py +264 -0
  65. witwin/maxwell/fdtd/excitation/tfsf_common.py +421 -0
  66. witwin/maxwell/fdtd/excitation/tfsf_specs.py +490 -0
  67. witwin/maxwell/fdtd/excitation/tfsf_state.py +778 -0
  68. witwin/maxwell/fdtd/io.py +31 -0
  69. witwin/maxwell/fdtd/material_pullback.py +371 -0
  70. witwin/maxwell/fdtd/meshing/__init__.py +3 -0
  71. witwin/maxwell/fdtd/meshing/autogrid.py +335 -0
  72. witwin/maxwell/fdtd/observers.py +1453 -0
  73. witwin/maxwell/fdtd/plotting.py +121 -0
  74. witwin/maxwell/fdtd/postprocess.py +205 -0
  75. witwin/maxwell/fdtd/runtime/__init__.py +119 -0
  76. witwin/maxwell/fdtd/runtime/initialization.py +236 -0
  77. witwin/maxwell/fdtd/runtime/materials.py +1563 -0
  78. witwin/maxwell/fdtd/runtime/module_cache.py +31 -0
  79. witwin/maxwell/fdtd/runtime/spectral.py +581 -0
  80. witwin/maxwell/fdtd/runtime/stepping.py +1987 -0
  81. witwin/maxwell/fdtd/solver.py +502 -0
  82. witwin/maxwell/geometry/__init__.py +5 -0
  83. witwin/maxwell/geometry/polyslab.py +299 -0
  84. witwin/maxwell/materials.py +3 -0
  85. witwin/maxwell/media.py +1467 -0
  86. witwin/maxwell/monitors.py +743 -0
  87. witwin/maxwell/ports.py +113 -0
  88. witwin/maxwell/postprocess/RCS.py +139 -0
  89. witwin/maxwell/postprocess/__init__.py +42 -0
  90. witwin/maxwell/postprocess/diffraction.py +219 -0
  91. witwin/maxwell/postprocess/directivity.py +152 -0
  92. witwin/maxwell/postprocess/emission.py +67 -0
  93. witwin/maxwell/postprocess/modal.py +324 -0
  94. witwin/maxwell/postprocess/nfft.py +266 -0
  95. witwin/maxwell/postprocess/scattering_parameters.py +358 -0
  96. witwin/maxwell/postprocess/stratton_chu.py +1193 -0
  97. witwin/maxwell/result.py +967 -0
  98. witwin/maxwell/scene.py +1338 -0
  99. witwin/maxwell/simulation.py +659 -0
  100. witwin/maxwell/sources.py +917 -0
  101. witwin/maxwell/visualization/__init__.py +28 -0
  102. witwin/maxwell/visualization/interactive.py +484 -0
  103. witwin/maxwell/visualization/plots.py +568 -0
  104. witwin_maxwell-0.3.0.dist-info/METADATA +184 -0
  105. witwin_maxwell-0.3.0.dist-info/RECORD +107 -0
  106. witwin_maxwell-0.3.0.dist-info/WHEEL +4 -0
  107. witwin_maxwell-0.3.0.dist-info/licenses/LICENSE +13 -0
@@ -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)