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