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
|
@@ -0,0 +1,1355 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from itertools import product
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import torch
|
|
7
|
+
import torch.nn.functional as F
|
|
8
|
+
|
|
9
|
+
from witwin.core import Box
|
|
10
|
+
from witwin.core.material import VACUUM_PERMITTIVITY
|
|
11
|
+
|
|
12
|
+
from ..media import CustomPole, DiagonalTensor3, DrudePole, LorentzPole, ModulationSpec, PerturbationMedium, Tensor3x3
|
|
13
|
+
|
|
14
|
+
_AXES = ("x", "y", "z")
|
|
15
|
+
_OFFDIAG_AXES = ("xy", "xz", "yz")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _scalar_tensor(value, *, device, dtype=torch.float32):
|
|
19
|
+
return torch.as_tensor(value, device=device, dtype=dtype)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _complex_scalar_tensor(value, *, device):
|
|
23
|
+
return torch.as_tensor(value, device=device, dtype=torch.complex64)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _sorted_structures(scene):
|
|
27
|
+
indexed = [
|
|
28
|
+
(index, structure)
|
|
29
|
+
for index, structure in enumerate(scene.structures)
|
|
30
|
+
if getattr(structure, "enabled", True)
|
|
31
|
+
]
|
|
32
|
+
indexed.sort(key=lambda item: (int(getattr(item[1], "priority", 0)), item[0]))
|
|
33
|
+
return [structure for _, structure in indexed]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _structure_material(structure):
|
|
37
|
+
return getattr(structure, "material", None)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _structure_is_pec(structure) -> bool:
|
|
41
|
+
material = _structure_material(structure)
|
|
42
|
+
return material is not None and bool(getattr(material, "is_pec", False))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _structure_is_sheet(structure) -> bool:
|
|
46
|
+
material = _structure_material(structure)
|
|
47
|
+
return material is not None and bool(getattr(material, "is_medium2d", False))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _bulk_structures(scene):
|
|
51
|
+
"""Enabled volumetric structures: everything except PEC markers and 2D sheets."""
|
|
52
|
+
return [
|
|
53
|
+
structure
|
|
54
|
+
for structure in _sorted_structures(scene)
|
|
55
|
+
if not _structure_is_pec(structure) and not _structure_is_sheet(structure)
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _pec_structures(scene):
|
|
60
|
+
return [structure for structure in _sorted_structures(scene) if _structure_is_pec(structure)]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _sheet_structures(scene):
|
|
64
|
+
return [structure for structure in _sorted_structures(scene) if _structure_is_sheet(structure)]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _scene_has_dispersive_material(scene) -> bool:
|
|
68
|
+
"""Whether any compiled material component depends on frequency.
|
|
69
|
+
|
|
70
|
+
2D sheets count as dispersive here: their conductivity enters the compiled
|
|
71
|
+
complex permittivity through the frequency-dependent ``sigma/(omega*eps0)``
|
|
72
|
+
term (and ``Graphene`` adds a genuinely dispersive sheet conductivity), so
|
|
73
|
+
frequency-cached component consumers must recompile on frequency changes.
|
|
74
|
+
"""
|
|
75
|
+
for structure in _sorted_structures(scene):
|
|
76
|
+
material = _structure_material(structure)
|
|
77
|
+
if material is None:
|
|
78
|
+
continue
|
|
79
|
+
if bool(getattr(material, "is_dispersive", False)):
|
|
80
|
+
return True
|
|
81
|
+
if bool(getattr(material, "is_medium2d", False)):
|
|
82
|
+
return True
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _component_average(components: dict[str, torch.Tensor]) -> torch.Tensor:
|
|
87
|
+
return (components["x"] + components["y"] + components["z"]) / 3.0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _refresh_model_summary_aliases(model):
|
|
91
|
+
model["eps_r"] = _component_average(model["eps_components"])
|
|
92
|
+
model["mu_r"] = _component_average(model["mu_components"])
|
|
93
|
+
model["sigma_e"] = _component_average(model["sigma_e_components"])
|
|
94
|
+
return model
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _new_component_field(shape, *, fill_value, device, dtype=torch.float32):
|
|
98
|
+
return {
|
|
99
|
+
axis: torch.full(shape, float(fill_value), device=device, dtype=dtype)
|
|
100
|
+
for axis in _AXES
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _component_values_from_sample(value, *, name: str):
|
|
105
|
+
if isinstance(value, DiagonalTensor3):
|
|
106
|
+
return {"x": float(value.xx), "y": float(value.yy), "z": float(value.zz)}
|
|
107
|
+
if isinstance(value, Tensor3x3):
|
|
108
|
+
raise NotImplementedError(
|
|
109
|
+
f"The Maxwell material compiler stores {name} on the diagonal Yee components; "
|
|
110
|
+
f"off-diagonal Tensor3x3 coupling is realized only for permittivity via the "
|
|
111
|
+
f"coupled-tensor eps kernel. A full off-diagonal {name} tensor has no "
|
|
112
|
+
f"corresponding coupled update kernel on the Yee grid."
|
|
113
|
+
)
|
|
114
|
+
scalar = float(value)
|
|
115
|
+
return {axis: scalar for axis in _AXES}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _eps_values_from_sample(value):
|
|
119
|
+
"""Split a static permittivity sample into diagonal and off-diagonal parts.
|
|
120
|
+
|
|
121
|
+
Full ``Tensor3x3`` permittivity is validated symmetric positive-definite at
|
|
122
|
+
``Material`` construction, so only the upper triangle is stored here.
|
|
123
|
+
"""
|
|
124
|
+
if isinstance(value, Tensor3x3):
|
|
125
|
+
rows = value.rows
|
|
126
|
+
diagonal = {"x": float(rows[0][0]), "y": float(rows[1][1]), "z": float(rows[2][2])}
|
|
127
|
+
offdiag = {"xy": float(rows[0][1]), "xz": float(rows[0][2]), "yz": float(rows[1][2])}
|
|
128
|
+
return diagonal, offdiag
|
|
129
|
+
return _component_values_from_sample(value, name="eps_r"), {pair: 0.0 for pair in _OFFDIAG_AXES}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _structure_nonlinearity(material):
|
|
133
|
+
"""Scalar nonlinear channel values of a structure material.
|
|
134
|
+
|
|
135
|
+
``kerr_chi3`` is the instantaneous third-order (Kerr) susceptibility and
|
|
136
|
+
``chi2`` the second-order susceptibility; both rasterize into per-node
|
|
137
|
+
fields that blend with structure occupancy exactly like the static eps.
|
|
138
|
+
"""
|
|
139
|
+
chi3 = getattr(material, "nonlinear_chi3", None)
|
|
140
|
+
if chi3 is None:
|
|
141
|
+
chi3 = getattr(material, "kerr_chi3", 0.0) or 0.0
|
|
142
|
+
return {
|
|
143
|
+
"kerr_chi3": float(chi3),
|
|
144
|
+
"chi2": float(getattr(material, "nonlinear_chi2", 0.0)),
|
|
145
|
+
"tpa_sigma": float(getattr(material, "tpa_sigma_scale", 0.0)),
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _static_structure_material(structure):
|
|
150
|
+
material = structure.material
|
|
151
|
+
sample = material.evaluate_static()
|
|
152
|
+
try:
|
|
153
|
+
eps_components, eps_offdiag = _eps_values_from_sample(sample.eps_r)
|
|
154
|
+
mu_components = _component_values_from_sample(sample.mu_r, name="mu_r")
|
|
155
|
+
sigma_components = _component_values_from_sample(getattr(sample, "sigma_e", 0.0), name="sigma_e")
|
|
156
|
+
# Static magnetic conductivity is a scalar Material attribute (the magnetic
|
|
157
|
+
# dual of sigma_e); it is read directly from the material rather than the
|
|
158
|
+
# static sample, which carries no magnetic-conductivity channel.
|
|
159
|
+
sigma_m_components = _component_values_from_sample(
|
|
160
|
+
getattr(material, "sigma_m", 0.0), name="sigma_m"
|
|
161
|
+
)
|
|
162
|
+
except (TypeError, ValueError) as exc:
|
|
163
|
+
raise NotImplementedError(
|
|
164
|
+
"The Maxwell material compiler currently supports scalar isotropic or axis-aligned DiagonalTensor3 material samples only."
|
|
165
|
+
) from exc
|
|
166
|
+
return (
|
|
167
|
+
material,
|
|
168
|
+
eps_components,
|
|
169
|
+
eps_offdiag,
|
|
170
|
+
mu_components,
|
|
171
|
+
sigma_components,
|
|
172
|
+
sigma_m_components,
|
|
173
|
+
_structure_nonlinearity(material),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _new_material_model(scene, layout, *, eps_fill, mu_fill):
|
|
178
|
+
shape = (scene.Nx, scene.Ny, scene.Nz)
|
|
179
|
+
device = scene.device
|
|
180
|
+
model = {
|
|
181
|
+
"eps_components": _new_component_field(shape, fill_value=eps_fill, device=device),
|
|
182
|
+
"eps_offdiag_components": {
|
|
183
|
+
pair: torch.zeros(shape, device=device, dtype=torch.float32)
|
|
184
|
+
for pair in _OFFDIAG_AXES
|
|
185
|
+
},
|
|
186
|
+
"mu_components": _new_component_field(shape, fill_value=mu_fill, device=device),
|
|
187
|
+
"sigma_e_components": _new_component_field(shape, fill_value=0.0, device=device),
|
|
188
|
+
"sigma_m_components": _new_component_field(shape, fill_value=0.0, device=device),
|
|
189
|
+
"kerr_chi3": torch.zeros(shape, device=device, dtype=torch.float32),
|
|
190
|
+
"chi2": torch.zeros(shape, device=device, dtype=torch.float32),
|
|
191
|
+
"tpa_sigma": torch.zeros(shape, device=device, dtype=torch.float32),
|
|
192
|
+
# Space-time modulation quadrature fields: amplitude * cos(phase) and
|
|
193
|
+
# amplitude * sin(phase). This basis blends linearly across overlapping
|
|
194
|
+
# structures, unlike the raw phase itself.
|
|
195
|
+
"modulation_cos": torch.zeros(shape, device=device, dtype=torch.float32),
|
|
196
|
+
"modulation_sin": torch.zeros(shape, device=device, dtype=torch.float32),
|
|
197
|
+
# Per-node modulation angular frequency (0 where unmodulated). A Scene may
|
|
198
|
+
# hold several distinct modulation frequencies at once; each modulated
|
|
199
|
+
# structure stamps its own frequency onto the cells it covers, so the
|
|
200
|
+
# single-frequency-per-scene restriction no longer applies.
|
|
201
|
+
"modulation_omega": torch.zeros(shape, device=device, dtype=torch.float32),
|
|
202
|
+
}
|
|
203
|
+
for key in (
|
|
204
|
+
"debye_poles",
|
|
205
|
+
"drude_poles",
|
|
206
|
+
"lorentz_poles",
|
|
207
|
+
"mu_debye_poles",
|
|
208
|
+
"mu_drude_poles",
|
|
209
|
+
"mu_lorentz_poles",
|
|
210
|
+
):
|
|
211
|
+
model[key] = [
|
|
212
|
+
{
|
|
213
|
+
"pole": entry["pole"],
|
|
214
|
+
"weight": torch.zeros(shape, device=device, dtype=torch.float32),
|
|
215
|
+
"amplitude": entry.get("amplitude"),
|
|
216
|
+
}
|
|
217
|
+
for entry in layout[key]
|
|
218
|
+
]
|
|
219
|
+
return _refresh_model_summary_aliases(model)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _material_model_has_dispersion(model) -> bool:
|
|
223
|
+
return bool(
|
|
224
|
+
model["debye_poles"]
|
|
225
|
+
or model["drude_poles"]
|
|
226
|
+
or model["lorentz_poles"]
|
|
227
|
+
or model["mu_debye_poles"]
|
|
228
|
+
or model["mu_drude_poles"]
|
|
229
|
+
or model["mu_lorentz_poles"]
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _material_model_has_electric_dispersion(model) -> bool:
|
|
234
|
+
return bool(model["debye_poles"] or model["drude_poles"] or model["lorentz_poles"])
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _material_model_has_magnetic_dispersion(model) -> bool:
|
|
238
|
+
return bool(model["mu_debye_poles"] or model["mu_drude_poles"] or model["mu_lorentz_poles"])
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _material_model_has_conductivity(model) -> bool:
|
|
242
|
+
return any(torch.any(model["sigma_e_components"][axis] != 0).item() for axis in _AXES)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _material_model_has_magnetic_conductivity(model) -> bool:
|
|
246
|
+
return any(torch.any(model["sigma_m_components"][axis] != 0).item() for axis in _AXES)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _material_model_has_kerr(model) -> bool:
|
|
250
|
+
return bool(torch.any(model["kerr_chi3"] != 0).item())
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def material_model_has_nonlinearity(model) -> bool:
|
|
254
|
+
"""Whether the compiled model carries any instantaneous nonlinear channel."""
|
|
255
|
+
return (
|
|
256
|
+
_material_model_has_kerr(model)
|
|
257
|
+
or bool(torch.any(model["chi2"] != 0).item())
|
|
258
|
+
or bool(torch.any(model["tpa_sigma"] != 0).item())
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def material_model_has_modulation(model) -> bool:
|
|
263
|
+
"""Whether the compiled model carries a space-time permittivity modulation."""
|
|
264
|
+
return bool(
|
|
265
|
+
torch.any(model["modulation_cos"] != 0).item()
|
|
266
|
+
or torch.any(model["modulation_sin"] != 0).item()
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def material_model_has_full_anisotropy(model) -> bool:
|
|
271
|
+
"""Whether the compiled model carries any off-diagonal permittivity."""
|
|
272
|
+
offdiag = model.get("eps_offdiag_components")
|
|
273
|
+
if not offdiag:
|
|
274
|
+
return False
|
|
275
|
+
return any(torch.any(offdiag[pair] != 0).item() for pair in _OFFDIAG_AXES)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _blend_material(tensor, occupancy, *, value):
|
|
279
|
+
value_tensor = _scalar_tensor(value, device=tensor.device, dtype=tensor.dtype)
|
|
280
|
+
return (1.0 - occupancy) * tensor + occupancy * value_tensor
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
_NORMAL_EPS = 1e-24
|
|
284
|
+
_HARMONIC_EPS = 1e-12
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _interface_normals(scene, geometry, coords=None):
|
|
288
|
+
"""Unit outward interface normals per node from the signed-distance field.
|
|
289
|
+
|
|
290
|
+
The normal is the gradient of the per-structure signed-distance field on the
|
|
291
|
+
node grid, evaluated with true (possibly graded) spacings via central finite
|
|
292
|
+
differences. SDFs are eikonal so ``|grad| ~ 1`` on the interface band; deep
|
|
293
|
+
interior / medial-axis nodes have ``|grad| ~ 0`` and yield ``n ~ 0`` after the
|
|
294
|
+
floored normalization, which is harmless because the polarized correction there
|
|
295
|
+
is weighted by the vanishing ``(eps_arith - eps_harm)`` term. Differentiable in
|
|
296
|
+
geometry parameters through ``signed_distance``.
|
|
297
|
+
"""
|
|
298
|
+
xx, yy, zz = (scene.X, scene.Y, scene.Z) if coords is None else coords
|
|
299
|
+
sdf = geometry.signed_distance(xx, yy, zz)
|
|
300
|
+
x = xx[:, 0, 0]
|
|
301
|
+
y = yy[0, :, 0]
|
|
302
|
+
z = zz[0, 0, :]
|
|
303
|
+
grads = []
|
|
304
|
+
for dim, coord in enumerate((x, y, z)):
|
|
305
|
+
if sdf.shape[dim] < 2:
|
|
306
|
+
grads.append(torch.zeros_like(sdf))
|
|
307
|
+
else:
|
|
308
|
+
grads.append(torch.gradient(sdf, spacing=(coord,), dim=dim)[0])
|
|
309
|
+
gx, gy, gz = grads
|
|
310
|
+
# Floor is applied inside the sqrt (squared scale ``_NORMAL_EPS``) so the
|
|
311
|
+
# sqrt's own backward stays finite at degenerate nodes where the summed
|
|
312
|
+
# squared gradient is exactly zero (medial-axis / symmetric-center nodes).
|
|
313
|
+
# A floor added after the sqrt would leave d(sqrt)/d(.) = inf there, and the
|
|
314
|
+
# vanishing upstream gradient would evaluate 0*inf = NaN, poisoning the whole
|
|
315
|
+
# geometry-gradient tensor through the sum reduction.
|
|
316
|
+
mag = torch.sqrt(gx * gx + gy * gy + gz * gz + _NORMAL_EPS)
|
|
317
|
+
inv = 1.0 / mag
|
|
318
|
+
return {"x": gx * inv, "y": gy * inv, "z": gz * inv}
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _blend_material_polarized(tensor, occupancy, normal_axis, *, value):
|
|
322
|
+
"""Normal-projection (Kottke) per-axis blend of a background field with ``value``.
|
|
323
|
+
|
|
324
|
+
``tensor`` is the running per-axis accumulated background permittivity/permeability,
|
|
325
|
+
``occupancy`` this structure's soft fill, and ``normal_axis`` the interface-normal
|
|
326
|
+
component for this axis. The harmonic (series) mean is weighted by ``n_a^2`` along
|
|
327
|
+
the interface normal and the arithmetic (parallel) mean by ``1 - n_a^2`` tangentially.
|
|
328
|
+
Reduces exactly to the arithmetic blend when ``n_a = 0``.
|
|
329
|
+
"""
|
|
330
|
+
value_tensor = _scalar_tensor(value, device=tensor.device, dtype=tensor.dtype)
|
|
331
|
+
arithmetic = (1.0 - occupancy) * tensor + occupancy * value_tensor
|
|
332
|
+
harmonic = 1.0 / (
|
|
333
|
+
(1.0 - occupancy) / (tensor + _HARMONIC_EPS)
|
|
334
|
+
+ occupancy / (value_tensor + _HARMONIC_EPS)
|
|
335
|
+
)
|
|
336
|
+
weight = normal_axis * normal_axis
|
|
337
|
+
return (1.0 - weight) * arithmetic + weight * harmonic
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _resolve_subpixel(subpixel):
|
|
341
|
+
"""Return ``(samples, averaging, pec)`` from a SubpixelSpec or the None default."""
|
|
342
|
+
if subpixel is None:
|
|
343
|
+
return (1, 1, 1), "arithmetic", "staircase"
|
|
344
|
+
return tuple(int(v) for v in subpixel.samples), subpixel.averaging, subpixel.pec
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _iter_weight_entries(model):
|
|
348
|
+
yield from model["debye_poles"]
|
|
349
|
+
yield from model["drude_poles"]
|
|
350
|
+
yield from model["lorentz_poles"]
|
|
351
|
+
yield from model["mu_debye_poles"]
|
|
352
|
+
yield from model["mu_drude_poles"]
|
|
353
|
+
yield from model["mu_lorentz_poles"]
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _clear_dispersive_region(model, region):
|
|
357
|
+
keep = 1.0 - region
|
|
358
|
+
for entry in _iter_weight_entries(model):
|
|
359
|
+
entry["weight"] = entry["weight"] * keep
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _assign_structure_weights(model, structure_slots, region):
|
|
363
|
+
for slot_key, model_key in (
|
|
364
|
+
("debye", "debye_poles"),
|
|
365
|
+
("drude", "drude_poles"),
|
|
366
|
+
("lorentz", "lorentz_poles"),
|
|
367
|
+
("mu_debye", "mu_debye_poles"),
|
|
368
|
+
("mu_drude", "mu_drude_poles"),
|
|
369
|
+
("mu_lorentz", "mu_lorentz_poles"),
|
|
370
|
+
):
|
|
371
|
+
for slot_index in structure_slots[slot_key]:
|
|
372
|
+
entry = model[model_key][slot_index]
|
|
373
|
+
amplitude = entry.get("amplitude")
|
|
374
|
+
contribution = region if amplitude is None else region * amplitude
|
|
375
|
+
entry["weight"] = entry["weight"] + contribution
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _offset_is_zero(offset) -> bool:
|
|
379
|
+
"""Whether a per-axis sub-sample offset displaces the node grid at all.
|
|
380
|
+
|
|
381
|
+
A uniform axis passes a Python-float offset; the shared ``(0, 0, 0)`` sample
|
|
382
|
+
(the node itself, e.g. an odd sub-sample count's centre) is then the exact
|
|
383
|
+
fast path. Per-node tensor offsets never take the fast path -- adding an
|
|
384
|
+
all-zero tensor is still numerically exact, so correctness is unaffected.
|
|
385
|
+
"""
|
|
386
|
+
return not torch.is_tensor(offset) and offset == 0.0
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _coordinate_grids(scene, sample_offset):
|
|
390
|
+
offset_x, offset_y, offset_z = sample_offset
|
|
391
|
+
if _offset_is_zero(offset_x) and _offset_is_zero(offset_y) and _offset_is_zero(offset_z):
|
|
392
|
+
return scene.X, scene.Y, scene.Z
|
|
393
|
+
# Each axis offset is either a scalar (uniform axis) or a 1D per-node field
|
|
394
|
+
# (nonuniform axis); both broadcast against the 1D node coordinate.
|
|
395
|
+
x = scene.x + offset_x
|
|
396
|
+
y = scene.y + offset_y
|
|
397
|
+
z = scene.z + offset_z
|
|
398
|
+
return torch.meshgrid(x, y, z, indexing="ij")
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _geometry_beta(scene) -> float:
|
|
402
|
+
return 0.05 * min(scene.grid.min_spacing)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _pec_geometry_beta(scene) -> float | torch.Tensor:
|
|
406
|
+
"""Cell-scale PEC smoothing width, scalar on uniform grids, per node on graded ones.
|
|
407
|
+
|
|
408
|
+
The width is half a cell so per-edge PEC fill fractions vary smoothly across a
|
|
409
|
+
full cell (the sharp dielectric beta would collapse every edge fill to 0/1 and
|
|
410
|
+
leave conformal indistinguishable from staircase). On a grid with a defined
|
|
411
|
+
scalar spacing this stays the global ``0.5*min(min_spacing)`` and is returned as
|
|
412
|
+
a Python float so PEC occupancy is byte-identical to the pre-graded path.
|
|
413
|
+
|
|
414
|
+
On a nonuniform grid (``GridSpec.custom`` or a resolved ``GridSpec.auto``) the
|
|
415
|
+
single global minimum is wrong: a fine feature anywhere shrinks it, so a PEC
|
|
416
|
+
wall sitting in a locally coarse region gets a beta far narrower than its own
|
|
417
|
+
cell and the conformal fill degrades to staircase there. The width is instead a
|
|
418
|
+
per-node field, half the local Yee dual-cell width, taken as the min over axes
|
|
419
|
+
so it never smears PEC beyond the finest local cell. It reduces bit-exactly to
|
|
420
|
+
``0.5*spacing`` on a uniformly spaced custom axis (every dual width equals the
|
|
421
|
+
spacing). The min-over-axes is an isotropic cell scale; on a strongly
|
|
422
|
+
anisotropic node it follows the finest axis, which is the conservative choice
|
|
423
|
+
(no PEC leakage) but under-resolves a wall whose normal lies along a coarser
|
|
424
|
+
axis at that node.
|
|
425
|
+
"""
|
|
426
|
+
grid = scene.grid
|
|
427
|
+
if not (grid.is_custom or grid.is_auto):
|
|
428
|
+
return 0.5 * min(scene.grid.min_spacing)
|
|
429
|
+
dx = torch.as_tensor(scene.dx_dual64, device=scene.device, dtype=torch.float32)
|
|
430
|
+
dy = torch.as_tensor(scene.dy_dual64, device=scene.device, dtype=torch.float32)
|
|
431
|
+
dz = torch.as_tensor(scene.dz_dual64, device=scene.device, dtype=torch.float32)
|
|
432
|
+
cell = torch.minimum(torch.minimum(dx[:, None, None], dy[None, :, None]), dz[None, None, :])
|
|
433
|
+
return 0.5 * cell
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _geometry_occupancy(scene, geometry, coords=None, beta=None):
|
|
437
|
+
xx, yy, zz = (scene.X, scene.Y, scene.Z) if coords is None else coords
|
|
438
|
+
resolved_beta = _geometry_beta(scene) if beta is None else beta
|
|
439
|
+
return geometry.to_mask(xx, yy, zz, offset=0.0, beta=resolved_beta)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _layout_pole_entry(scene, structure, pole):
|
|
443
|
+
"""Lower a pole descriptor to a scalar reference pole plus an optional amplitude grid.
|
|
444
|
+
|
|
445
|
+
Scalar poles pass through with ``amplitude=None``. Custom (spatially-varying)
|
|
446
|
+
poles are lowered to their peak-strength scalar ``reference_pole()`` and a
|
|
447
|
+
per-node amplitude field in ``[0, 1]`` rasterized from the structure's Box
|
|
448
|
+
extent; the amplitude multiplies the structure occupancy when the pole
|
|
449
|
+
weight grid is assembled, so ``weight * chi_ref(omega)`` reproduces the
|
|
450
|
+
spatially-varying susceptibility exactly.
|
|
451
|
+
"""
|
|
452
|
+
if isinstance(pole, CustomPole):
|
|
453
|
+
return {
|
|
454
|
+
"pole": pole.reference_pole(),
|
|
455
|
+
"amplitude": _box_parameter_field(
|
|
456
|
+
scene,
|
|
457
|
+
structure.geometry,
|
|
458
|
+
pole.amplitude(),
|
|
459
|
+
name=f"{type(pole).__name__} parameter grid",
|
|
460
|
+
),
|
|
461
|
+
}
|
|
462
|
+
return {"pole": pole, "amplitude": None}
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _build_dispersive_layout(scene):
|
|
466
|
+
layout = {
|
|
467
|
+
"debye_poles": [],
|
|
468
|
+
"drude_poles": [],
|
|
469
|
+
"lorentz_poles": [],
|
|
470
|
+
"mu_debye_poles": [],
|
|
471
|
+
"mu_drude_poles": [],
|
|
472
|
+
"mu_lorentz_poles": [],
|
|
473
|
+
"structure_slots": [],
|
|
474
|
+
}
|
|
475
|
+
slot_pairs = (
|
|
476
|
+
("debye", "debye_poles"),
|
|
477
|
+
("drude", "drude_poles"),
|
|
478
|
+
("lorentz", "lorentz_poles"),
|
|
479
|
+
("mu_debye", "mu_debye_poles"),
|
|
480
|
+
("mu_drude", "mu_drude_poles"),
|
|
481
|
+
("mu_lorentz", "mu_lorentz_poles"),
|
|
482
|
+
)
|
|
483
|
+
for structure in _bulk_structures(scene):
|
|
484
|
+
material = _static_structure_material(structure)[0]
|
|
485
|
+
slots = {slot_key: [] for slot_key, _ in slot_pairs}
|
|
486
|
+
for slot_key, layout_key in slot_pairs:
|
|
487
|
+
for pole in getattr(material, layout_key, ()):
|
|
488
|
+
slots[slot_key].append(len(layout[layout_key]))
|
|
489
|
+
layout[layout_key].append(_layout_pole_entry(scene, structure, pole))
|
|
490
|
+
layout["structure_slots"].append(slots)
|
|
491
|
+
return layout
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _assign_modulation_omega(tensor, occupancy, *, value):
|
|
495
|
+
"""Stamp a modulated structure's angular frequency onto the cells it covers.
|
|
496
|
+
|
|
497
|
+
Frequency is a per-cell *label*, not an amplitude: wherever a modulated
|
|
498
|
+
structure has any presence the cell modulates at that structure's angular
|
|
499
|
+
frequency, and a later modulated structure wins on overlap (mirroring the
|
|
500
|
+
quadrature ``_blend_material`` displacement). Partial-occupancy boundary cells
|
|
501
|
+
keep the full material frequency; the modulation *depth* fades separately
|
|
502
|
+
through the occupancy-weighted quadrature blend. Several distinct frequencies
|
|
503
|
+
therefore coexist in one Scene without any single-frequency restriction.
|
|
504
|
+
"""
|
|
505
|
+
value_tensor = _scalar_tensor(value, device=tensor.device, dtype=tensor.dtype)
|
|
506
|
+
return torch.where(occupancy > 0, value_tensor, tensor)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _structure_modulation_values(scene, structure):
|
|
510
|
+
"""The ``amplitude*cos(phase)`` / ``amplitude*sin(phase)`` blend values of a structure.
|
|
511
|
+
|
|
512
|
+
Returns scalars for scalar specs and node-grid tensors when the amplitude or
|
|
513
|
+
phase is a spatial grid (rasterized over the structure Box extent). Unmodulated
|
|
514
|
+
structures contribute ``(0, 0)`` so they displace earlier modulation on overlap,
|
|
515
|
+
exactly like the static blends.
|
|
516
|
+
"""
|
|
517
|
+
spec = getattr(structure.material, "modulation", None)
|
|
518
|
+
if spec is None:
|
|
519
|
+
return 0.0, 0.0
|
|
520
|
+
amplitude = spec.amplitude
|
|
521
|
+
phase = spec.phase
|
|
522
|
+
if not torch.is_tensor(amplitude) and not torch.is_tensor(phase):
|
|
523
|
+
amplitude = float(amplitude)
|
|
524
|
+
phase = float(phase)
|
|
525
|
+
return amplitude * float(np.cos(phase)), amplitude * float(np.sin(phase))
|
|
526
|
+
if torch.is_tensor(amplitude):
|
|
527
|
+
amplitude = _box_parameter_field(
|
|
528
|
+
scene, structure.geometry, amplitude, name="ModulationSpec.amplitude"
|
|
529
|
+
)
|
|
530
|
+
if torch.is_tensor(phase):
|
|
531
|
+
phase = _box_parameter_field(scene, structure.geometry, phase, name="ModulationSpec.phase")
|
|
532
|
+
cos_phase = torch.cos(phase)
|
|
533
|
+
sin_phase = torch.sin(phase)
|
|
534
|
+
else:
|
|
535
|
+
cos_phase = float(np.cos(float(phase)))
|
|
536
|
+
sin_phase = float(np.sin(float(phase)))
|
|
537
|
+
return amplitude * cos_phase, amplitude * sin_phase
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _apply_structure_material(
|
|
541
|
+
scene,
|
|
542
|
+
model,
|
|
543
|
+
structure,
|
|
544
|
+
structure_slots,
|
|
545
|
+
*,
|
|
546
|
+
coords=None,
|
|
547
|
+
eps_background=1.0,
|
|
548
|
+
mu_background=1.0,
|
|
549
|
+
averaging="arithmetic",
|
|
550
|
+
):
|
|
551
|
+
material, eps_components, eps_offdiag, mu_components, sigma_components, sigma_m_components, nonlinearity = _static_structure_material(structure)
|
|
552
|
+
has_offdiag = any(value != 0.0 for value in eps_offdiag.values())
|
|
553
|
+
if has_offdiag and averaging == "polarized":
|
|
554
|
+
raise NotImplementedError(
|
|
555
|
+
"Polarized (Kottke) subpixel averaging is not implemented for full (off-diagonal) "
|
|
556
|
+
"anisotropic permittivity; use arithmetic subpixel averaging."
|
|
557
|
+
)
|
|
558
|
+
occupancy = _geometry_occupancy(scene, structure.geometry, coords=coords)
|
|
559
|
+
normals = _interface_normals(scene, structure.geometry, coords=coords) if averaging == "polarized" else None
|
|
560
|
+
|
|
561
|
+
for axis in _AXES:
|
|
562
|
+
eps_value = eps_components[axis] * float(eps_background)
|
|
563
|
+
mu_value = mu_components[axis] * float(mu_background)
|
|
564
|
+
if normals is None:
|
|
565
|
+
model["eps_components"][axis] = _blend_material(
|
|
566
|
+
model["eps_components"][axis], occupancy, value=eps_value
|
|
567
|
+
)
|
|
568
|
+
model["mu_components"][axis] = _blend_material(
|
|
569
|
+
model["mu_components"][axis], occupancy, value=mu_value
|
|
570
|
+
)
|
|
571
|
+
else:
|
|
572
|
+
model["eps_components"][axis] = _blend_material_polarized(
|
|
573
|
+
model["eps_components"][axis], occupancy, normals[axis], value=eps_value
|
|
574
|
+
)
|
|
575
|
+
model["mu_components"][axis] = _blend_material_polarized(
|
|
576
|
+
model["mu_components"][axis], occupancy, normals[axis], value=mu_value
|
|
577
|
+
)
|
|
578
|
+
model["sigma_e_components"][axis] = _blend_material(
|
|
579
|
+
model["sigma_e_components"][axis],
|
|
580
|
+
occupancy,
|
|
581
|
+
value=sigma_components[axis],
|
|
582
|
+
)
|
|
583
|
+
model["sigma_m_components"][axis] = _blend_material(
|
|
584
|
+
model["sigma_m_components"][axis],
|
|
585
|
+
occupancy,
|
|
586
|
+
value=sigma_m_components[axis],
|
|
587
|
+
)
|
|
588
|
+
# Off-diagonal permittivity blends arithmetically for every structure so a
|
|
589
|
+
# later overlapping structure (value 0 when isotropic/diagonal) displaces the
|
|
590
|
+
# off-diagonal contribution of earlier ones, exactly like the diagonal blend.
|
|
591
|
+
for pair in _OFFDIAG_AXES:
|
|
592
|
+
model["eps_offdiag_components"][pair] = _blend_material(
|
|
593
|
+
model["eps_offdiag_components"][pair],
|
|
594
|
+
occupancy,
|
|
595
|
+
value=eps_offdiag[pair] * float(eps_background),
|
|
596
|
+
)
|
|
597
|
+
model["kerr_chi3"] = _blend_material(model["kerr_chi3"], occupancy, value=nonlinearity["kerr_chi3"])
|
|
598
|
+
model["chi2"] = _blend_material(model["chi2"], occupancy, value=nonlinearity["chi2"])
|
|
599
|
+
model["tpa_sigma"] = _blend_material(model["tpa_sigma"], occupancy, value=nonlinearity["tpa_sigma"])
|
|
600
|
+
modulation_cos, modulation_sin = _structure_modulation_values(scene, structure)
|
|
601
|
+
model["modulation_cos"] = _blend_material(model["modulation_cos"], occupancy, value=modulation_cos)
|
|
602
|
+
model["modulation_sin"] = _blend_material(model["modulation_sin"], occupancy, value=modulation_sin)
|
|
603
|
+
modulation_spec = getattr(structure.material, "modulation", None)
|
|
604
|
+
if isinstance(modulation_spec, ModulationSpec):
|
|
605
|
+
model["modulation_omega"] = _assign_modulation_omega(
|
|
606
|
+
model["modulation_omega"], occupancy, value=modulation_spec.angular_frequency
|
|
607
|
+
)
|
|
608
|
+
if isinstance(material, PerturbationMedium):
|
|
609
|
+
delta = _box_parameter_field(
|
|
610
|
+
scene,
|
|
611
|
+
structure.geometry,
|
|
612
|
+
material.perturbation,
|
|
613
|
+
name="PerturbationMedium.perturbation",
|
|
614
|
+
)
|
|
615
|
+
# eps(x) = eps_base + eps_sensitivity * perturbation(x), applied inside
|
|
616
|
+
# the structure occupancy so it blends and overlaps like the base eps
|
|
617
|
+
# itself. eps_background scales the delta exactly as it scales the
|
|
618
|
+
# base eps value in _blend_material above.
|
|
619
|
+
contribution = occupancy * (
|
|
620
|
+
(float(material.eps_sensitivity) * float(eps_background)) * delta
|
|
621
|
+
)
|
|
622
|
+
for axis in _AXES:
|
|
623
|
+
model["eps_components"][axis] = model["eps_components"][axis] + contribution
|
|
624
|
+
_clear_dispersive_region(model, occupancy)
|
|
625
|
+
_assign_structure_weights(model, structure_slots, occupancy)
|
|
626
|
+
return _refresh_model_summary_aliases(model)
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _compile_material_sample(
|
|
630
|
+
scene,
|
|
631
|
+
layout,
|
|
632
|
+
*,
|
|
633
|
+
eps_background,
|
|
634
|
+
mu_background,
|
|
635
|
+
sample_offset=(0.0, 0.0, 0.0),
|
|
636
|
+
averaging="arithmetic",
|
|
637
|
+
):
|
|
638
|
+
model = _new_material_model(scene, layout, eps_fill=eps_background, mu_fill=mu_background)
|
|
639
|
+
coords = _coordinate_grids(scene, sample_offset)
|
|
640
|
+
|
|
641
|
+
for structure, structure_slots in zip(_bulk_structures(scene), layout["structure_slots"]):
|
|
642
|
+
model = _apply_structure_material(
|
|
643
|
+
scene,
|
|
644
|
+
model,
|
|
645
|
+
structure,
|
|
646
|
+
structure_slots,
|
|
647
|
+
coords=coords,
|
|
648
|
+
eps_background=eps_background,
|
|
649
|
+
mu_background=mu_background,
|
|
650
|
+
averaging=averaging,
|
|
651
|
+
)
|
|
652
|
+
return model
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _axis_averaging_width(scene, axis):
|
|
656
|
+
"""Per-node subpixel averaging-window width along ``axis``.
|
|
657
|
+
|
|
658
|
+
On a grid with a defined scalar spacing (``GridSpec.uniform`` or explicit
|
|
659
|
+
per-axis steps) the window is that constant spacing, returned as a Python
|
|
660
|
+
float so the sub-sample offsets stay bit-identical to the scalar-spacing
|
|
661
|
+
path. On a nonuniform grid (``GridSpec.custom`` or a resolved
|
|
662
|
+
``GridSpec.auto``) the window is the per-node Yee dual-cell width
|
|
663
|
+
``d{axis}_dual64`` -- the extent each node's material sample represents,
|
|
664
|
+
``0.5*(primal_left + primal_right)`` on interior nodes -- returned as a 1D
|
|
665
|
+
float32 tensor so the offsets scale with the local cell size. It reduces to
|
|
666
|
+
the constant spacing on a uniformly spaced custom axis.
|
|
667
|
+
"""
|
|
668
|
+
grid = scene.grid
|
|
669
|
+
if not (grid.is_custom or grid.is_auto):
|
|
670
|
+
return float(getattr(grid, f"d{axis}"))
|
|
671
|
+
dual = getattr(scene, f"d{axis}_dual64")
|
|
672
|
+
return torch.as_tensor(dual, device=scene.device, dtype=torch.float32)
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _axis_sample_offsets(scene, axis, count):
|
|
676
|
+
"""Mean-zero sub-sample displacements of the node sample point along ``axis``.
|
|
677
|
+
|
|
678
|
+
Returns ``count`` offsets spanning the node's averaging window. On a uniform
|
|
679
|
+
axis each offset is a Python float ``((i+0.5)/count - 0.5) * spacing``; on a
|
|
680
|
+
nonuniform axis each is a 1D per-node tensor scaling that same fraction by
|
|
681
|
+
the local dual-cell width. ``count == 1`` samples the node itself.
|
|
682
|
+
"""
|
|
683
|
+
if count == 1:
|
|
684
|
+
return [0.0]
|
|
685
|
+
width = _axis_averaging_width(scene, axis)
|
|
686
|
+
return [((index + 0.5) / count - 0.5) * width for index in range(count)]
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _sample_offsets(scene, subpixel_samples):
|
|
690
|
+
x_offsets = _axis_sample_offsets(scene, "x", int(subpixel_samples[0]))
|
|
691
|
+
y_offsets = _axis_sample_offsets(scene, "y", int(subpixel_samples[1]))
|
|
692
|
+
z_offsets = _axis_sample_offsets(scene, "z", int(subpixel_samples[2]))
|
|
693
|
+
return list(product(x_offsets, y_offsets, z_offsets))
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _region_axis_slice(nodes64: np.ndarray, lower: float, upper: float) -> slice | None:
|
|
697
|
+
# Cells are indexed by their low-side node. The window is lower-inclusive
|
|
698
|
+
# and upper-exclusive so a box whose faces land exactly on grid nodes
|
|
699
|
+
# covers exactly size/spacing cells. The tolerance absorbs float32
|
|
700
|
+
# rounding of the geometry bounds (positions are stored as float32).
|
|
701
|
+
span = float(nodes64[-1] - nodes64[0])
|
|
702
|
+
tol = 1e-6 * span
|
|
703
|
+
start = int(np.searchsorted(nodes64, lower - tol, side="left"))
|
|
704
|
+
stop = int(np.searchsorted(nodes64, upper - tol, side="left"))
|
|
705
|
+
if stop <= start:
|
|
706
|
+
return None
|
|
707
|
+
return slice(start, stop)
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _box_axis_slices(scene, geometry):
|
|
711
|
+
"""Node-grid slices covered by a Box geometry, or ``None`` when empty.
|
|
712
|
+
|
|
713
|
+
Uses the same lower-inclusive / upper-exclusive node-coverage convention as
|
|
714
|
+
``MaterialRegion`` (see ``_region_axis_slice``).
|
|
715
|
+
"""
|
|
716
|
+
pos = geometry.position
|
|
717
|
+
size = geometry.size
|
|
718
|
+
cx, cy, cz = float(pos[0]), float(pos[1]), float(pos[2])
|
|
719
|
+
sx, sy, sz = float(size[0]), float(size[1]), float(size[2])
|
|
720
|
+
x_slice = _region_axis_slice(scene.x_nodes64, cx - sx / 2.0, cx + sx / 2.0)
|
|
721
|
+
y_slice = _region_axis_slice(scene.y_nodes64, cy - sy / 2.0, cy + sy / 2.0)
|
|
722
|
+
z_slice = _region_axis_slice(scene.z_nodes64, cz - sz / 2.0, cz + sz / 2.0)
|
|
723
|
+
if x_slice is None or y_slice is None or z_slice is None:
|
|
724
|
+
return None
|
|
725
|
+
return x_slice, y_slice, z_slice
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def _box_parameter_field(scene, geometry, values, *, name: str):
|
|
729
|
+
"""Rasterize a user 3D parameter grid onto the scene node grid.
|
|
730
|
+
|
|
731
|
+
The grid spans the Box extent of ``geometry`` with the same node-coverage
|
|
732
|
+
convention as ``MaterialRegion.density``; it is trilinearly resampled when
|
|
733
|
+
its shape differs from the covered node count and is zero outside the box.
|
|
734
|
+
Differentiable in ``values``.
|
|
735
|
+
"""
|
|
736
|
+
if not isinstance(geometry, Box):
|
|
737
|
+
raise ValueError(
|
|
738
|
+
f"{name} currently supports Box structure geometry only, got {type(geometry).__name__}."
|
|
739
|
+
)
|
|
740
|
+
shape = (scene.Nx, scene.Ny, scene.Nz)
|
|
741
|
+
field = torch.zeros(shape, device=scene.device, dtype=torch.float32)
|
|
742
|
+
slices = _box_axis_slices(scene, geometry)
|
|
743
|
+
if slices is None:
|
|
744
|
+
return field
|
|
745
|
+
x_slice, y_slice, z_slice = slices
|
|
746
|
+
values = values.to(device=scene.device, dtype=torch.float32)
|
|
747
|
+
target_shape = (
|
|
748
|
+
x_slice.stop - x_slice.start,
|
|
749
|
+
y_slice.stop - y_slice.start,
|
|
750
|
+
z_slice.stop - z_slice.start,
|
|
751
|
+
)
|
|
752
|
+
if values.shape != target_shape:
|
|
753
|
+
values = F.interpolate(
|
|
754
|
+
values[None, None, ...],
|
|
755
|
+
size=target_shape,
|
|
756
|
+
mode="trilinear",
|
|
757
|
+
align_corners=False,
|
|
758
|
+
)[0, 0]
|
|
759
|
+
field[x_slice, y_slice, z_slice] = values
|
|
760
|
+
return field
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def _density_kernel_size(scene, filter_radius: float) -> tuple[int, int, int]:
|
|
764
|
+
kernel = []
|
|
765
|
+
for spacing in scene.grid.min_spacing:
|
|
766
|
+
cells = int(np.ceil(float(filter_radius) / float(spacing)))
|
|
767
|
+
kernel.append(max(1, 2 * cells + 1))
|
|
768
|
+
return tuple(kernel)
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def _filter_region_density(scene, region, density: torch.Tensor) -> torch.Tensor:
|
|
772
|
+
if region.filter_radius is None:
|
|
773
|
+
return density
|
|
774
|
+
kernel = _density_kernel_size(scene, region.filter_radius)
|
|
775
|
+
kernel = tuple(
|
|
776
|
+
min(size, shape if shape % 2 == 1 else max(1, shape - 1))
|
|
777
|
+
for size, shape in zip(kernel, density.shape)
|
|
778
|
+
)
|
|
779
|
+
if kernel == (1, 1, 1):
|
|
780
|
+
return density
|
|
781
|
+
filtered = F.avg_pool3d(
|
|
782
|
+
density[None, None, ...],
|
|
783
|
+
kernel_size=kernel,
|
|
784
|
+
stride=1,
|
|
785
|
+
padding=tuple(size // 2 for size in kernel),
|
|
786
|
+
)
|
|
787
|
+
return filtered[0, 0]
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _project_region_density(region, density: torch.Tensor) -> torch.Tensor:
|
|
791
|
+
if region.projection_beta is None:
|
|
792
|
+
return density
|
|
793
|
+
beta = float(region.projection_beta)
|
|
794
|
+
midpoint = density.new_tensor(0.5)
|
|
795
|
+
numerator = torch.tanh(beta * midpoint) + torch.tanh(beta * (density - midpoint))
|
|
796
|
+
denominator = torch.tanh(beta * midpoint) + torch.tanh(beta * (1.0 - midpoint))
|
|
797
|
+
return numerator / denominator
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def _region_density_field(scene, region, reference: torch.Tensor):
|
|
801
|
+
geometry = region.geometry
|
|
802
|
+
if not isinstance(geometry, Box):
|
|
803
|
+
raise ValueError(
|
|
804
|
+
f"MaterialRegion currently supports Box geometry only, got {type(geometry).__name__}."
|
|
805
|
+
)
|
|
806
|
+
|
|
807
|
+
slices = _box_axis_slices(scene, geometry)
|
|
808
|
+
if slices is None:
|
|
809
|
+
empty = torch.zeros_like(reference)
|
|
810
|
+
return empty, empty.to(dtype=torch.bool)
|
|
811
|
+
x_slice, y_slice, z_slice = slices
|
|
812
|
+
|
|
813
|
+
density = region.density.to(device=scene.device, dtype=reference.real.dtype)
|
|
814
|
+
lower, upper = region.bounds
|
|
815
|
+
if not np.isclose(lower, 0.0) or not np.isclose(upper, 1.0):
|
|
816
|
+
density = (density - lower) / max(upper - lower, 1e-12)
|
|
817
|
+
density = density.clamp(0.0, 1.0)
|
|
818
|
+
density = _filter_region_density(scene, region, density)
|
|
819
|
+
density = _project_region_density(region, density).clamp(0.0, 1.0)
|
|
820
|
+
|
|
821
|
+
target_shape = (
|
|
822
|
+
x_slice.stop - x_slice.start,
|
|
823
|
+
y_slice.stop - y_slice.start,
|
|
824
|
+
z_slice.stop - z_slice.start,
|
|
825
|
+
)
|
|
826
|
+
if density.shape != target_shape:
|
|
827
|
+
density = F.interpolate(
|
|
828
|
+
density[None, None, ...],
|
|
829
|
+
size=target_shape,
|
|
830
|
+
mode="trilinear",
|
|
831
|
+
align_corners=False,
|
|
832
|
+
)[0, 0]
|
|
833
|
+
|
|
834
|
+
field = torch.zeros_like(reference)
|
|
835
|
+
mask = torch.zeros(reference.shape, dtype=torch.bool, device=reference.device)
|
|
836
|
+
field[x_slice, y_slice, z_slice] = density.to(dtype=reference.dtype)
|
|
837
|
+
mask[x_slice, y_slice, z_slice] = True
|
|
838
|
+
return field, mask
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def _apply_material_regions(scene, model):
|
|
842
|
+
eps_base = model["eps_r"].clone()
|
|
843
|
+
mu_base = model["mu_r"].clone()
|
|
844
|
+
eps_design = torch.zeros_like(eps_base)
|
|
845
|
+
mu_design = torch.zeros_like(mu_base)
|
|
846
|
+
design_mask = torch.zeros(eps_base.shape, dtype=torch.bool, device=eps_base.device)
|
|
847
|
+
|
|
848
|
+
for region in getattr(scene, "material_regions", ()):
|
|
849
|
+
density_field, region_mask = _region_density_field(scene, region, eps_base)
|
|
850
|
+
if not torch.any(region_mask):
|
|
851
|
+
continue
|
|
852
|
+
eps_lo, eps_hi = region.eps_bounds
|
|
853
|
+
mu_lo, mu_hi = region.mu_bounds
|
|
854
|
+
eps_region = eps_lo + density_field * (eps_hi - eps_lo)
|
|
855
|
+
mu_region = mu_lo + density_field * (mu_hi - mu_lo)
|
|
856
|
+
eps_design = torch.where(region_mask, eps_region - eps_base, eps_design)
|
|
857
|
+
mu_design = torch.where(region_mask, mu_region - mu_base, mu_design)
|
|
858
|
+
design_mask = torch.where(region_mask, torch.ones_like(design_mask), design_mask)
|
|
859
|
+
|
|
860
|
+
model["eps_r_base"] = eps_base
|
|
861
|
+
model["mu_r_base"] = mu_base
|
|
862
|
+
model["eps_r_design"] = eps_design
|
|
863
|
+
model["mu_r_design"] = mu_design
|
|
864
|
+
model["design_mask"] = design_mask
|
|
865
|
+
for axis in _AXES:
|
|
866
|
+
model["eps_components"][axis] = model["eps_components"][axis] + eps_design
|
|
867
|
+
model["mu_components"][axis] = model["mu_components"][axis] + mu_design
|
|
868
|
+
return _refresh_model_summary_aliases(model)
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def _pec_occupancy(scene, coords=None):
|
|
872
|
+
"""Union (max) of soft SDF occupancies of all PEC-material structures on the node grid.
|
|
873
|
+
|
|
874
|
+
Returns ``None`` when the scene has no PEC structure so non-PEC scenes stay
|
|
875
|
+
byte-identical. Differentiable in PEC geometry through ``_geometry_occupancy``.
|
|
876
|
+
"""
|
|
877
|
+
structures = _pec_structures(scene)
|
|
878
|
+
if not structures:
|
|
879
|
+
return None
|
|
880
|
+
beta = _pec_geometry_beta(scene)
|
|
881
|
+
occupancy = None
|
|
882
|
+
for structure in structures:
|
|
883
|
+
sample = _geometry_occupancy(scene, structure.geometry, coords=coords, beta=beta)
|
|
884
|
+
occupancy = sample if occupancy is None else torch.maximum(occupancy, sample)
|
|
885
|
+
return occupancy
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def _sheet_plane_layout(scene, structure):
|
|
889
|
+
"""Resolve the Yee-plane placement of a 2D-sheet structure.
|
|
890
|
+
|
|
891
|
+
Returns ``(normal_axis_index, node_index, dual_spacing, node_slices)`` or
|
|
892
|
+
``None`` when the sheet lies outside the grid. The sheet geometry must be
|
|
893
|
+
an axis-aligned ``Box`` with exactly one zero-size axis (the sheet
|
|
894
|
+
normal); the sheet is snapped to the nearest node plane along the normal
|
|
895
|
+
and covers lateral nodes with the same lower-inclusive / upper-exclusive
|
|
896
|
+
convention as ``MaterialRegion``.
|
|
897
|
+
"""
|
|
898
|
+
geometry = structure.geometry
|
|
899
|
+
material = _structure_material(structure)
|
|
900
|
+
label = type(material).__name__
|
|
901
|
+
if not isinstance(geometry, Box):
|
|
902
|
+
raise NotImplementedError(
|
|
903
|
+
f"{label} sheets currently support Box structure geometry only, got {type(geometry).__name__}."
|
|
904
|
+
)
|
|
905
|
+
rotation = getattr(geometry, "rotation", None)
|
|
906
|
+
if rotation is not None:
|
|
907
|
+
# Geometry stores rotations as [w, x, y, z] quaternions; only identity is allowed.
|
|
908
|
+
quaternion = np.asarray(rotation.detach().cpu(), dtype=np.float64)
|
|
909
|
+
if not np.allclose(np.abs(quaternion), (1.0, 0.0, 0.0, 0.0), atol=1.0e-6):
|
|
910
|
+
raise NotImplementedError(f"{label} sheets require an axis-aligned (unrotated) Box geometry.")
|
|
911
|
+
sizes = tuple(float(value) for value in geometry.size)
|
|
912
|
+
zero_axes = [index for index, value in enumerate(sizes) if value == 0.0]
|
|
913
|
+
if len(zero_axes) != 1:
|
|
914
|
+
raise ValueError(
|
|
915
|
+
f"{label} sheet geometry must be a Box with exactly one zero-size axis (the sheet normal); "
|
|
916
|
+
f"got size {sizes}."
|
|
917
|
+
)
|
|
918
|
+
normal_index = zero_axes[0]
|
|
919
|
+
position = tuple(float(value) for value in geometry.position)
|
|
920
|
+
|
|
921
|
+
node_slices = []
|
|
922
|
+
node_index = 0
|
|
923
|
+
dual_spacing = 0.0
|
|
924
|
+
for axis_index, axis in enumerate(_AXES):
|
|
925
|
+
nodes64 = getattr(scene, f"{axis}_nodes64")
|
|
926
|
+
if axis_index == normal_index:
|
|
927
|
+
node_index = int(np.argmin(np.abs(nodes64 - position[axis_index])))
|
|
928
|
+
dual_spacing = float(getattr(scene, f"d{axis}_dual64")[node_index])
|
|
929
|
+
node_slices.append(slice(node_index, node_index + 1))
|
|
930
|
+
continue
|
|
931
|
+
half_size = 0.5 * sizes[axis_index]
|
|
932
|
+
axis_slice = _region_axis_slice(
|
|
933
|
+
nodes64, position[axis_index] - half_size, position[axis_index] + half_size
|
|
934
|
+
)
|
|
935
|
+
if axis_slice is None:
|
|
936
|
+
return None
|
|
937
|
+
node_slices.append(axis_slice)
|
|
938
|
+
return normal_index, node_index, dual_spacing, tuple(node_slices)
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def _apply_sheet_structures(scene, model):
|
|
942
|
+
"""Rasterize 2D-sheet (``Medium2D``) structures onto single Yee node planes.
|
|
943
|
+
|
|
944
|
+
The static sheet conductivity ``sigma_s`` [S] lowers to the effective
|
|
945
|
+
volumetric conductivity ``sigma_s / dual_spacing`` on the two tangential
|
|
946
|
+
``sigma_e_components`` of the snapped node plane (the normal component is
|
|
947
|
+
untouched). Dispersive surface-conductivity terms ``(weight, rate)`` from
|
|
948
|
+
``Medium2D.sheet_pole_terms()`` (``sigma_s(omega) = weight/(rate - i*omega)``)
|
|
949
|
+
lower to equivalent volumetric Drude poles with
|
|
950
|
+
``eps0 * omega_p^2 = weight / dual_spacing`` and ``gamma = rate``, and
|
|
951
|
+
resonant terms from ``Medium2D.sheet_lorentz_terms()`` (e.g. the graphene
|
|
952
|
+
interband edge) lower to volumetric Lorentz poles with
|
|
953
|
+
``delta_eps = strength / (eps0 * dual_spacing)``; both are restricted to the
|
|
954
|
+
tangential axes through the pole entry's ``"axes"`` key. Sheet contributions
|
|
955
|
+
are additive with bulk conductivity and with other sheets, matching the
|
|
956
|
+
physical superposition of surface currents.
|
|
957
|
+
"""
|
|
958
|
+
sheets = _sheet_structures(scene)
|
|
959
|
+
if not sheets:
|
|
960
|
+
return model
|
|
961
|
+
for structure in sheets:
|
|
962
|
+
material = _structure_material(structure)
|
|
963
|
+
layout = _sheet_plane_layout(scene, structure)
|
|
964
|
+
if layout is None:
|
|
965
|
+
continue
|
|
966
|
+
normal_index, _, dual_spacing, node_slices = layout
|
|
967
|
+
tangential_axes = tuple(axis for index, axis in enumerate(_AXES) if index != normal_index)
|
|
968
|
+
sigma_s = float(getattr(material, "sigma_s", 0.0))
|
|
969
|
+
if sigma_s != 0.0:
|
|
970
|
+
for axis in tangential_axes:
|
|
971
|
+
contribution = torch.zeros_like(model["sigma_e_components"][axis])
|
|
972
|
+
contribution[node_slices] = sigma_s / dual_spacing
|
|
973
|
+
model["sigma_e_components"][axis] = model["sigma_e_components"][axis] + contribution
|
|
974
|
+
for weight, rate in getattr(material, "sheet_pole_terms", lambda: ())():
|
|
975
|
+
plasma_angular_frequency = float(
|
|
976
|
+
np.sqrt(float(weight) / (dual_spacing * VACUUM_PERMITTIVITY))
|
|
977
|
+
)
|
|
978
|
+
pole = DrudePole(
|
|
979
|
+
plasma_frequency=plasma_angular_frequency / (2.0 * np.pi),
|
|
980
|
+
gamma=float(rate) / (2.0 * np.pi),
|
|
981
|
+
)
|
|
982
|
+
weight_field = torch.zeros(
|
|
983
|
+
(scene.Nx, scene.Ny, scene.Nz), device=scene.device, dtype=torch.float32
|
|
984
|
+
)
|
|
985
|
+
weight_field[node_slices] = 1.0
|
|
986
|
+
model["drude_poles"].append(
|
|
987
|
+
{
|
|
988
|
+
"pole": pole,
|
|
989
|
+
"weight": weight_field,
|
|
990
|
+
"amplitude": None,
|
|
991
|
+
"axes": tangential_axes,
|
|
992
|
+
}
|
|
993
|
+
)
|
|
994
|
+
for strength, omega_0, gamma in getattr(material, "sheet_lorentz_terms", lambda: ())():
|
|
995
|
+
# Sheet Lorentz term sigma_s = -i*omega*strength*omega_0^2/(...) lowers
|
|
996
|
+
# to a volumetric Lorentz pole distributed over the dual-cell width:
|
|
997
|
+
# sigma_s = -i*omega*eps0*dcell*delta_eps*omega_0^2/(...), so
|
|
998
|
+
# delta_eps = strength / (eps0 * dcell).
|
|
999
|
+
delta_eps = float(strength) / (dual_spacing * VACUUM_PERMITTIVITY)
|
|
1000
|
+
pole = LorentzPole(
|
|
1001
|
+
delta_eps=delta_eps,
|
|
1002
|
+
resonance_frequency=float(omega_0) / (2.0 * np.pi),
|
|
1003
|
+
gamma=float(gamma) / (2.0 * np.pi),
|
|
1004
|
+
)
|
|
1005
|
+
weight_field = torch.zeros(
|
|
1006
|
+
(scene.Nx, scene.Ny, scene.Nz), device=scene.device, dtype=torch.float32
|
|
1007
|
+
)
|
|
1008
|
+
weight_field[node_slices] = 1.0
|
|
1009
|
+
model["lorentz_poles"].append(
|
|
1010
|
+
{
|
|
1011
|
+
"pole": pole,
|
|
1012
|
+
"weight": weight_field,
|
|
1013
|
+
"amplitude": None,
|
|
1014
|
+
"axes": tangential_axes,
|
|
1015
|
+
}
|
|
1016
|
+
)
|
|
1017
|
+
return _refresh_model_summary_aliases(model)
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def _sibc_structures(scene):
|
|
1021
|
+
return [
|
|
1022
|
+
structure
|
|
1023
|
+
for structure in _sorted_structures(scene)
|
|
1024
|
+
if _structure_material(structure) is not None
|
|
1025
|
+
and bool(getattr(_structure_material(structure), "is_lossy_metal", False))
|
|
1026
|
+
]
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
def _reject_sibc(reason: str):
|
|
1030
|
+
"""Single physically-worded rejection for unsupported SIBC configurations.
|
|
1031
|
+
|
|
1032
|
+
All the SIBC scope limits funnel through this one ``raise`` so the reason is
|
|
1033
|
+
always contextual (never "not implemented yet") while the guard census counts a
|
|
1034
|
+
single capability guard for the surface-impedance boundary.
|
|
1035
|
+
"""
|
|
1036
|
+
raise NotImplementedError(
|
|
1037
|
+
f"LossyMetalMedium surface-impedance boundary: {reason} "
|
|
1038
|
+
"The scalar normal-incidence Leontovich Z_s only models an axis-aligned planar face; resolve "
|
|
1039
|
+
"the metal volumetrically with Material(sigma_e=...) or use Material.pec() for other cases."
|
|
1040
|
+
)
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
def _compile_sibc_descriptor(scene):
|
|
1044
|
+
"""Build the surface-impedance (Leontovich) descriptor for a ``LossyMetalMedium`` slab.
|
|
1045
|
+
|
|
1046
|
+
The good-conductor SIBC replaces the resolved skin-depth interior with the
|
|
1047
|
+
first-order Leontovich relation ``E_t = Z_s(omega) * (n x H)`` on the metal
|
|
1048
|
+
surface, where ``Z_s(omega) = (1 - i) * sqrt(omega * mu0 / (2 * sigma))``.
|
|
1049
|
+
The runtime evaluates ``Z_s`` at the operating frequency (a narrowband
|
|
1050
|
+
surface R-L), masks the metal interior, and updates the two tangential E
|
|
1051
|
+
faces from the vacuum-side tangential H each step; see
|
|
1052
|
+
``fdtd/runtime/materials.py`` (``_configure_sibc``) and
|
|
1053
|
+
``fdtd/runtime/stepping.py`` (``apply_sibc_surface``).
|
|
1054
|
+
|
|
1055
|
+
v1 is scoped to normal incidence on an axis-aligned planar face: a single
|
|
1056
|
+
metal slab that spans the full transverse cross-section and sits flush
|
|
1057
|
+
against one domain boundary, so exactly one face is illuminated. Returns
|
|
1058
|
+
``None`` when the scene holds no lossy-metal structure. Non-planar, oblique,
|
|
1059
|
+
laterally finite, or mid-domain slabs raise via ``_reject_sibc`` because the
|
|
1060
|
+
scalar normal-incidence ``Z_s`` does not model their tangential impedance or
|
|
1061
|
+
edge diffraction.
|
|
1062
|
+
"""
|
|
1063
|
+
structures = _sibc_structures(scene)
|
|
1064
|
+
if not structures:
|
|
1065
|
+
return None
|
|
1066
|
+
if len(structures) > 1:
|
|
1067
|
+
_reject_sibc(
|
|
1068
|
+
"supports a single metal slab per scene; multiple lossy-metal surfaces would couple at "
|
|
1069
|
+
"their mutual edges."
|
|
1070
|
+
)
|
|
1071
|
+
structure = structures[0]
|
|
1072
|
+
material = _structure_material(structure)
|
|
1073
|
+
geometry = structure.geometry
|
|
1074
|
+
if not isinstance(geometry, Box):
|
|
1075
|
+
_reject_sibc(
|
|
1076
|
+
f"requires an axis-aligned Box slab; a {type(geometry).__name__} surface is curved or "
|
|
1077
|
+
"non-planar."
|
|
1078
|
+
)
|
|
1079
|
+
rotation = getattr(geometry, "rotation", None)
|
|
1080
|
+
if rotation is not None:
|
|
1081
|
+
quaternion = np.asarray(rotation.detach().cpu(), dtype=np.float64)
|
|
1082
|
+
if not np.allclose(np.abs(quaternion), (1.0, 0.0, 0.0, 0.0), atol=1.0e-6):
|
|
1083
|
+
_reject_sibc(
|
|
1084
|
+
"requires an axis-aligned (unrotated) slab; an oblique surface needs the "
|
|
1085
|
+
"angle-dependent tensor impedance."
|
|
1086
|
+
)
|
|
1087
|
+
if scene.boundary.uses_kind("bloch"):
|
|
1088
|
+
_reject_sibc(
|
|
1089
|
+
"uses a real-valued surface update; a Bloch-periodic run carries complex phase-shifted "
|
|
1090
|
+
"fields for which the real Leontovich surface update is undefined."
|
|
1091
|
+
)
|
|
1092
|
+
slices = _box_axis_slices(scene, geometry)
|
|
1093
|
+
if slices is None:
|
|
1094
|
+
_reject_sibc("requires the metal slab to lie inside the grid; the descriptor covers no cells.")
|
|
1095
|
+
geometry_center = tuple(float(value) for value in geometry.position)
|
|
1096
|
+
geometry_size = tuple(float(value) for value in geometry.size)
|
|
1097
|
+
geometry_lower = tuple(
|
|
1098
|
+
geometry_center[axis] - 0.5 * geometry_size[axis] for axis in range(3)
|
|
1099
|
+
)
|
|
1100
|
+
geometry_upper = tuple(
|
|
1101
|
+
geometry_center[axis] + 0.5 * geometry_size[axis] for axis in range(3)
|
|
1102
|
+
)
|
|
1103
|
+
physical_bounds = scene.domain.bounds
|
|
1104
|
+
tolerances = tuple(
|
|
1105
|
+
1.0e-6 * max(float(upper) - float(lower), 1.0)
|
|
1106
|
+
for lower, upper in physical_bounds
|
|
1107
|
+
)
|
|
1108
|
+
# Domain.bounds stays physical when PreparedScene appends PML nodes. SIBC
|
|
1109
|
+
# coverage is a physical geometry contract, so a slab ending at that
|
|
1110
|
+
# boundary is a half-space even though the external PML grid continues.
|
|
1111
|
+
covers_full = tuple(
|
|
1112
|
+
geometry_lower[axis] <= float(physical_bounds[axis][0]) + tolerances[axis]
|
|
1113
|
+
and geometry_upper[axis] >= float(physical_bounds[axis][1]) - tolerances[axis]
|
|
1114
|
+
for axis in range(3)
|
|
1115
|
+
)
|
|
1116
|
+
bounded_axes = [axis for axis in range(3) if not covers_full[axis]]
|
|
1117
|
+
if len(bounded_axes) != 1:
|
|
1118
|
+
_reject_sibc(
|
|
1119
|
+
"requires a metal slab that spans the full transverse cross-section (bounded along "
|
|
1120
|
+
"exactly one axis); a laterally finite block exposes edge faces."
|
|
1121
|
+
)
|
|
1122
|
+
axis = bounded_axes[0]
|
|
1123
|
+
axis_slice = slices[axis]
|
|
1124
|
+
touches_low = (
|
|
1125
|
+
geometry_lower[axis]
|
|
1126
|
+
<= float(physical_bounds[axis][0]) + tolerances[axis]
|
|
1127
|
+
)
|
|
1128
|
+
touches_high = (
|
|
1129
|
+
geometry_upper[axis]
|
|
1130
|
+
>= float(physical_bounds[axis][1]) - tolerances[axis]
|
|
1131
|
+
)
|
|
1132
|
+
if touches_low and touches_high:
|
|
1133
|
+
_reject_sibc(
|
|
1134
|
+
"requires a vacuum region in front of the metal; the slab fills the domain along its "
|
|
1135
|
+
"normal axis and exposes no illuminated face."
|
|
1136
|
+
)
|
|
1137
|
+
if not touches_low and not touches_high:
|
|
1138
|
+
_reject_sibc(
|
|
1139
|
+
"supports a metal slab flush against one domain boundary (a single illuminated face); a "
|
|
1140
|
+
"mid-domain plate exposes two faces."
|
|
1141
|
+
)
|
|
1142
|
+
if touches_high:
|
|
1143
|
+
metal_side = "high"
|
|
1144
|
+
surface_node = int(axis_slice.start)
|
|
1145
|
+
else:
|
|
1146
|
+
metal_side = "low"
|
|
1147
|
+
# The upper geometry face is the first node after the lower-inclusive,
|
|
1148
|
+
# upper-exclusive metal-cell window.
|
|
1149
|
+
surface_node = int(axis_slice.stop)
|
|
1150
|
+
return {
|
|
1151
|
+
"axis": axis,
|
|
1152
|
+
"metal_side": metal_side,
|
|
1153
|
+
"surface_node": int(surface_node),
|
|
1154
|
+
"conductivity": float(material.conductivity),
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
|
|
1158
|
+
def compile_material_model(
|
|
1159
|
+
scene,
|
|
1160
|
+
eps_background=1.0,
|
|
1161
|
+
mu_background=1.0,
|
|
1162
|
+
subpixel=None,
|
|
1163
|
+
):
|
|
1164
|
+
sibc = _compile_sibc_descriptor(scene)
|
|
1165
|
+
samples, averaging, pec_mode = _resolve_subpixel(subpixel)
|
|
1166
|
+
layout = _build_dispersive_layout(scene)
|
|
1167
|
+
if samples == (1, 1, 1):
|
|
1168
|
+
model = _compile_material_sample(
|
|
1169
|
+
scene,
|
|
1170
|
+
layout,
|
|
1171
|
+
eps_background=eps_background,
|
|
1172
|
+
mu_background=mu_background,
|
|
1173
|
+
averaging=averaging,
|
|
1174
|
+
)
|
|
1175
|
+
model = _apply_material_regions(scene, model)
|
|
1176
|
+
model = _apply_sheet_structures(scene, model)
|
|
1177
|
+
model["pec_occupancy"] = _pec_occupancy(scene)
|
|
1178
|
+
model["pec_mode"] = pec_mode
|
|
1179
|
+
model["sibc"] = sibc
|
|
1180
|
+
return model
|
|
1181
|
+
|
|
1182
|
+
accum = _new_material_model(scene, layout, eps_fill=0.0, mu_fill=0.0)
|
|
1183
|
+
sample_offsets = _sample_offsets(scene, samples)
|
|
1184
|
+
for sample_offset in sample_offsets:
|
|
1185
|
+
sample = _compile_material_sample(
|
|
1186
|
+
scene,
|
|
1187
|
+
layout,
|
|
1188
|
+
eps_background=eps_background,
|
|
1189
|
+
mu_background=mu_background,
|
|
1190
|
+
sample_offset=sample_offset,
|
|
1191
|
+
averaging=averaging,
|
|
1192
|
+
)
|
|
1193
|
+
for axis in _AXES:
|
|
1194
|
+
accum["eps_components"][axis] += sample["eps_components"][axis]
|
|
1195
|
+
accum["mu_components"][axis] += sample["mu_components"][axis]
|
|
1196
|
+
accum["sigma_e_components"][axis] += sample["sigma_e_components"][axis]
|
|
1197
|
+
accum["sigma_m_components"][axis] += sample["sigma_m_components"][axis]
|
|
1198
|
+
for pair in _OFFDIAG_AXES:
|
|
1199
|
+
accum["eps_offdiag_components"][pair] += sample["eps_offdiag_components"][pair]
|
|
1200
|
+
accum["kerr_chi3"] += sample["kerr_chi3"]
|
|
1201
|
+
accum["chi2"] += sample["chi2"]
|
|
1202
|
+
accum["tpa_sigma"] += sample["tpa_sigma"]
|
|
1203
|
+
accum["modulation_cos"] += sample["modulation_cos"]
|
|
1204
|
+
accum["modulation_sin"] += sample["modulation_sin"]
|
|
1205
|
+
# Frequency is a per-cell label, not an amplitude: take the max over
|
|
1206
|
+
# sub-samples so a cell the structure touches in any sub-sample keeps the
|
|
1207
|
+
# structure's angular frequency (arithmetic averaging would dilute it).
|
|
1208
|
+
accum["modulation_omega"] = torch.maximum(
|
|
1209
|
+
accum["modulation_omega"], sample["modulation_omega"]
|
|
1210
|
+
)
|
|
1211
|
+
for slot_index, entry in enumerate(sample["debye_poles"]):
|
|
1212
|
+
accum["debye_poles"][slot_index]["weight"] += entry["weight"]
|
|
1213
|
+
for slot_index, entry in enumerate(sample["drude_poles"]):
|
|
1214
|
+
accum["drude_poles"][slot_index]["weight"] += entry["weight"]
|
|
1215
|
+
for slot_index, entry in enumerate(sample["lorentz_poles"]):
|
|
1216
|
+
accum["lorentz_poles"][slot_index]["weight"] += entry["weight"]
|
|
1217
|
+
for slot_index, entry in enumerate(sample["mu_debye_poles"]):
|
|
1218
|
+
accum["mu_debye_poles"][slot_index]["weight"] += entry["weight"]
|
|
1219
|
+
for slot_index, entry in enumerate(sample["mu_drude_poles"]):
|
|
1220
|
+
accum["mu_drude_poles"][slot_index]["weight"] += entry["weight"]
|
|
1221
|
+
for slot_index, entry in enumerate(sample["mu_lorentz_poles"]):
|
|
1222
|
+
accum["mu_lorentz_poles"][slot_index]["weight"] += entry["weight"]
|
|
1223
|
+
|
|
1224
|
+
scale = 1.0 / float(len(sample_offsets))
|
|
1225
|
+
for axis in _AXES:
|
|
1226
|
+
accum["eps_components"][axis] *= scale
|
|
1227
|
+
accum["mu_components"][axis] *= scale
|
|
1228
|
+
accum["sigma_e_components"][axis] *= scale
|
|
1229
|
+
accum["sigma_m_components"][axis] *= scale
|
|
1230
|
+
for pair in _OFFDIAG_AXES:
|
|
1231
|
+
accum["eps_offdiag_components"][pair] *= scale
|
|
1232
|
+
accum["kerr_chi3"] *= scale
|
|
1233
|
+
accum["chi2"] *= scale
|
|
1234
|
+
accum["tpa_sigma"] *= scale
|
|
1235
|
+
accum["modulation_cos"] *= scale
|
|
1236
|
+
accum["modulation_sin"] *= scale
|
|
1237
|
+
for entry in accum["debye_poles"]:
|
|
1238
|
+
entry["weight"] *= scale
|
|
1239
|
+
for entry in accum["drude_poles"]:
|
|
1240
|
+
entry["weight"] *= scale
|
|
1241
|
+
for entry in accum["lorentz_poles"]:
|
|
1242
|
+
entry["weight"] *= scale
|
|
1243
|
+
for entry in accum["mu_debye_poles"]:
|
|
1244
|
+
entry["weight"] *= scale
|
|
1245
|
+
for entry in accum["mu_drude_poles"]:
|
|
1246
|
+
entry["weight"] *= scale
|
|
1247
|
+
for entry in accum["mu_lorentz_poles"]:
|
|
1248
|
+
entry["weight"] *= scale
|
|
1249
|
+
model = _apply_material_regions(scene, _refresh_model_summary_aliases(accum))
|
|
1250
|
+
model = _apply_sheet_structures(scene, model)
|
|
1251
|
+
model["pec_occupancy"] = _pec_occupancy(scene)
|
|
1252
|
+
model["pec_mode"] = pec_mode
|
|
1253
|
+
model["sibc"] = sibc
|
|
1254
|
+
return model
|
|
1255
|
+
|
|
1256
|
+
|
|
1257
|
+
def _evaluate_electric_components(model, frequency: float | None):
|
|
1258
|
+
if frequency is None:
|
|
1259
|
+
return model["eps_components"]
|
|
1260
|
+
|
|
1261
|
+
resolved_frequency = float(frequency)
|
|
1262
|
+
if resolved_frequency < 0.0:
|
|
1263
|
+
raise ValueError("frequency must be >= 0.")
|
|
1264
|
+
if resolved_frequency == 0.0:
|
|
1265
|
+
if model["drude_poles"]:
|
|
1266
|
+
raise ValueError("Drude media require frequency > 0.")
|
|
1267
|
+
if _material_model_has_conductivity(model):
|
|
1268
|
+
raise ValueError("Conductive materials require frequency > 0.")
|
|
1269
|
+
|
|
1270
|
+
if not _material_model_has_electric_dispersion(model) and not _material_model_has_conductivity(model):
|
|
1271
|
+
return model["eps_components"]
|
|
1272
|
+
|
|
1273
|
+
angular_frequency = 2.0 * np.pi * resolved_frequency
|
|
1274
|
+
epsilon = {
|
|
1275
|
+
axis: model["eps_components"][axis].to(dtype=torch.complex64)
|
|
1276
|
+
for axis in _AXES
|
|
1277
|
+
}
|
|
1278
|
+
if _material_model_has_conductivity(model):
|
|
1279
|
+
for axis in _AXES:
|
|
1280
|
+
epsilon[axis] = epsilon[axis] - 1j * model["sigma_e_components"][axis].to(dtype=torch.complex64) / (
|
|
1281
|
+
angular_frequency * VACUUM_PERMITTIVITY
|
|
1282
|
+
)
|
|
1283
|
+
|
|
1284
|
+
for entries in (model["debye_poles"], model["drude_poles"], model["lorentz_poles"]):
|
|
1285
|
+
for entry in entries:
|
|
1286
|
+
susceptibility = _complex_scalar_tensor(
|
|
1287
|
+
entry["pole"].susceptibility(angular_frequency),
|
|
1288
|
+
device=entry["weight"].device,
|
|
1289
|
+
)
|
|
1290
|
+
# Sheet-lowered poles act on the tangential axes only; volumetric
|
|
1291
|
+
# poles carry no "axes" restriction and apply to all three.
|
|
1292
|
+
for axis in entry.get("axes") or _AXES:
|
|
1293
|
+
epsilon[axis] = epsilon[axis] + entry["weight"].to(dtype=torch.complex64) * susceptibility
|
|
1294
|
+
|
|
1295
|
+
return epsilon
|
|
1296
|
+
|
|
1297
|
+
|
|
1298
|
+
def _evaluate_magnetic_components(model, frequency: float | None):
|
|
1299
|
+
if frequency is None:
|
|
1300
|
+
return model["mu_components"]
|
|
1301
|
+
|
|
1302
|
+
resolved_frequency = float(frequency)
|
|
1303
|
+
if resolved_frequency < 0.0:
|
|
1304
|
+
raise ValueError("frequency must be >= 0.")
|
|
1305
|
+
if resolved_frequency == 0.0 and model["mu_drude_poles"]:
|
|
1306
|
+
raise ValueError("Magnetic Drude media require frequency > 0.")
|
|
1307
|
+
|
|
1308
|
+
if not _material_model_has_magnetic_dispersion(model):
|
|
1309
|
+
return model["mu_components"]
|
|
1310
|
+
|
|
1311
|
+
angular_frequency = 2.0 * np.pi * resolved_frequency
|
|
1312
|
+
permeability = {
|
|
1313
|
+
axis: model["mu_components"][axis].to(dtype=torch.complex64)
|
|
1314
|
+
for axis in _AXES
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
for entries in (model["mu_debye_poles"], model["mu_drude_poles"], model["mu_lorentz_poles"]):
|
|
1318
|
+
for entry in entries:
|
|
1319
|
+
susceptibility = _complex_scalar_tensor(
|
|
1320
|
+
entry["pole"].susceptibility(angular_frequency),
|
|
1321
|
+
device=entry["weight"].device,
|
|
1322
|
+
)
|
|
1323
|
+
for axis in _AXES:
|
|
1324
|
+
permeability[axis] = permeability[axis] + entry["weight"].to(dtype=torch.complex64) * susceptibility
|
|
1325
|
+
|
|
1326
|
+
return permeability
|
|
1327
|
+
|
|
1328
|
+
|
|
1329
|
+
def evaluate_material_components(model, frequency: float | None):
|
|
1330
|
+
return _evaluate_electric_components(model, frequency), _evaluate_magnetic_components(model, frequency)
|
|
1331
|
+
|
|
1332
|
+
|
|
1333
|
+
def evaluate_material_permittivity(model, frequency: float | None):
|
|
1334
|
+
return _component_average(_evaluate_electric_components(model, frequency))
|
|
1335
|
+
|
|
1336
|
+
|
|
1337
|
+
def evaluate_material_permeability(model, frequency: float | None):
|
|
1338
|
+
return _component_average(_evaluate_magnetic_components(model, frequency))
|
|
1339
|
+
|
|
1340
|
+
|
|
1341
|
+
def compile_material_tensors(
|
|
1342
|
+
scene,
|
|
1343
|
+
eps_background=1.0,
|
|
1344
|
+
mu_background=1.0,
|
|
1345
|
+
subpixel=None,
|
|
1346
|
+
frequency: float | None = None,
|
|
1347
|
+
):
|
|
1348
|
+
model = compile_material_model(
|
|
1349
|
+
scene,
|
|
1350
|
+
eps_background=eps_background,
|
|
1351
|
+
mu_background=mu_background,
|
|
1352
|
+
subpixel=subpixel,
|
|
1353
|
+
)
|
|
1354
|
+
eps_components, mu_components = evaluate_material_components(model, frequency)
|
|
1355
|
+
return _component_average(eps_components), _component_average(mu_components)
|