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,1467 @@
1
+ from __future__ import annotations
2
+
3
+ import warnings
4
+ from dataclasses import dataclass
5
+ from typing import Iterable
6
+
7
+ import numpy as np
8
+ import torch
9
+
10
+ from witwin.core import (
11
+ FrequencyMaterialSample,
12
+ Material as CoreMaterial,
13
+ MaterialCapabilities,
14
+ StaticMaterialSample,
15
+ )
16
+ from witwin.core.material import VACUUM_PERMITTIVITY
17
+
18
+ _SPEED_OF_LIGHT = 299_792_458.0
19
+
20
+
21
+ def _coerce_frequency(value: float, *, name: str) -> float:
22
+ frequency = float(value)
23
+ if frequency <= 0.0:
24
+ raise ValueError(f"{name} must be > 0.")
25
+ return frequency
26
+
27
+
28
+ def _coerce_nonnegative(value: float, *, name: str) -> float:
29
+ number = float(value)
30
+ if number < 0.0:
31
+ raise ValueError(f"{name} must be >= 0.")
32
+ return number
33
+
34
+
35
+ def _coerce_positive(value: float, *, name: str) -> float:
36
+ number = float(value)
37
+ if number <= 0.0:
38
+ raise ValueError(f"{name} must be > 0.")
39
+ return number
40
+
41
+
42
+ def _normalize_poles(value, pole_types, *, name: str):
43
+ if not isinstance(pole_types, tuple):
44
+ pole_types = (pole_types,)
45
+ label = " or ".join(pole_type.__name__ for pole_type in pole_types)
46
+ if value is None:
47
+ return ()
48
+ if isinstance(value, pole_types):
49
+ return (value,)
50
+ if isinstance(value, Iterable) and not isinstance(value, (str, bytes)):
51
+ normalized = tuple(value)
52
+ if any(not isinstance(item, pole_types) for item in normalized):
53
+ raise TypeError(f"{name} entries must be {label} instances.")
54
+ return normalized
55
+ raise TypeError(f"{name} must be a {label} or an iterable of them.")
56
+
57
+
58
+ def _normalize_tensor_rows(value, *, name: str):
59
+ rows = tuple(tuple(float(component) for component in row) for row in value)
60
+ if len(rows) != 3 or any(len(row) != 3 for row in rows):
61
+ raise ValueError(f"{name} must be a 3x3 tensor.")
62
+ return rows
63
+
64
+
65
+ def _coerce_real_scalar(value, *, name: str) -> float:
66
+ number = float(value)
67
+ if not np.isfinite(number):
68
+ raise ValueError(f"{name} must be finite.")
69
+ return number
70
+
71
+
72
+ _TENSOR_SYMMETRY_RTOL = 1.0e-6
73
+
74
+
75
+ def _validate_symmetric_positive_definite(tensor: "Tensor3x3", *, name: str) -> None:
76
+ """Reject full 3x3 tensors that are not symmetric positive-definite.
77
+
78
+ A lossless anisotropic permittivity must be an SPD tensor; asymmetric or
79
+ indefinite tensors would make the FDTD update unconditionally unstable, so
80
+ they fail loudly at construction time.
81
+ """
82
+ matrix = np.asarray(tensor.rows, dtype=np.float64)
83
+ scale = max(float(np.abs(matrix).max()), 1.0)
84
+ if not np.allclose(matrix, matrix.T, rtol=0.0, atol=_TENSOR_SYMMETRY_RTOL * scale):
85
+ raise ValueError(f"{name} must be a symmetric 3x3 tensor.")
86
+ eigenvalues = np.linalg.eigvalsh(0.5 * (matrix + matrix.T))
87
+ if float(eigenvalues.min()) <= 0.0:
88
+ raise ValueError(
89
+ f"{name} must be positive-definite; got eigenvalues {tuple(float(v) for v in eigenvalues)}."
90
+ )
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class DebyePole:
95
+ delta_eps: float
96
+ tau: float
97
+
98
+ def __post_init__(self):
99
+ object.__setattr__(self, "delta_eps", _coerce_nonnegative(self.delta_eps, name="delta_eps"))
100
+ object.__setattr__(self, "tau", _coerce_positive(self.tau, name="tau"))
101
+
102
+ def susceptibility(self, angular_frequency: float) -> complex:
103
+ return self.delta_eps / (1.0 - 1j * float(angular_frequency) * self.tau)
104
+
105
+ def susceptibility_at_freq(self, frequency: float) -> complex:
106
+ return self.susceptibility(2.0 * np.pi * _coerce_frequency(frequency, name="frequency"))
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class DrudePole:
111
+ plasma_frequency: float
112
+ gamma: float
113
+
114
+ def __post_init__(self):
115
+ object.__setattr__(self, "plasma_frequency", _coerce_frequency(self.plasma_frequency, name="plasma_frequency"))
116
+ object.__setattr__(self, "gamma", _coerce_nonnegative(self.gamma, name="gamma"))
117
+
118
+ @property
119
+ def plasma_angular_frequency(self) -> float:
120
+ return 2.0 * np.pi * self.plasma_frequency
121
+
122
+ @property
123
+ def gamma_angular_frequency(self) -> float:
124
+ return 2.0 * np.pi * self.gamma
125
+
126
+ def susceptibility(self, angular_frequency: float) -> complex:
127
+ angular_frequency = float(angular_frequency)
128
+ if angular_frequency <= 0.0:
129
+ raise ValueError("Drude susceptibility is undefined at zero frequency.")
130
+ omega_p = self.plasma_angular_frequency
131
+ gamma = self.gamma_angular_frequency
132
+ return -(omega_p * omega_p) / (
133
+ angular_frequency * angular_frequency + 1j * gamma * angular_frequency
134
+ )
135
+
136
+ def susceptibility_at_freq(self, frequency: float) -> complex:
137
+ return self.susceptibility(2.0 * np.pi * _coerce_frequency(frequency, name="frequency"))
138
+
139
+
140
+ @dataclass(frozen=True)
141
+ class LorentzPole:
142
+ delta_eps: float
143
+ resonance_frequency: float
144
+ gamma: float
145
+ allow_gain: bool = False
146
+
147
+ def __post_init__(self):
148
+ object.__setattr__(self, "allow_gain", bool(self.allow_gain))
149
+ # A negative oscillator strength (delta_eps < 0) inverts the resonance and
150
+ # models linear gain instead of absorption. This is only permitted behind an
151
+ # explicit opt-in so an accidental sign flip still fails validation loudly.
152
+ delta_eps = _coerce_real_scalar(self.delta_eps, name="delta_eps")
153
+ if delta_eps < 0.0 and not self.allow_gain:
154
+ raise ValueError(
155
+ "LorentzPole delta_eps < 0 encodes a linear-gain (negative oscillator "
156
+ "strength) resonance; pass allow_gain=True to opt in explicitly."
157
+ )
158
+ object.__setattr__(self, "delta_eps", delta_eps)
159
+ object.__setattr__(self, "resonance_frequency", _coerce_frequency(self.resonance_frequency, name="resonance_frequency"))
160
+ object.__setattr__(self, "gamma", _coerce_nonnegative(self.gamma, name="gamma"))
161
+ if delta_eps < 0.0:
162
+ warnings.warn(
163
+ "LorentzPole with negative delta_eps models linear gain; gain media can "
164
+ "violate the usual FDTD Courant/stability margins and may cause the "
165
+ "time-domain solution to diverge. Verify run stability and keep the "
166
+ "single-pass gain small.",
167
+ stacklevel=2,
168
+ )
169
+
170
+ @property
171
+ def resonance_angular_frequency(self) -> float:
172
+ return 2.0 * np.pi * self.resonance_frequency
173
+
174
+ @property
175
+ def gamma_angular_frequency(self) -> float:
176
+ return 2.0 * np.pi * self.gamma
177
+
178
+ def susceptibility(self, angular_frequency: float) -> complex:
179
+ angular_frequency = float(angular_frequency)
180
+ omega_0 = self.resonance_angular_frequency
181
+ gamma = self.gamma_angular_frequency
182
+ return self.delta_eps * omega_0 * omega_0 / (
183
+ omega_0 * omega_0 - angular_frequency * angular_frequency - 1j * gamma * angular_frequency
184
+ )
185
+
186
+ def susceptibility_at_freq(self, frequency: float) -> complex:
187
+ return self.susceptibility(2.0 * np.pi * _coerce_frequency(frequency, name="frequency"))
188
+
189
+
190
+ def _coerce_parameter_grid(value, *, name: str) -> torch.Tensor:
191
+ if not torch.is_tensor(value):
192
+ raise TypeError(f"{name} must be a torch.Tensor.")
193
+ if value.ndim != 3:
194
+ raise ValueError(f"{name} must be a 3D tensor, got ndim={value.ndim}.")
195
+ with torch.no_grad():
196
+ if not torch.isfinite(value).all():
197
+ raise ValueError(f"{name} must be finite everywhere.")
198
+ if torch.any(value < 0):
199
+ raise ValueError(f"{name} must be >= 0 everywhere.")
200
+ if float(value.max()) <= 0.0:
201
+ raise ValueError(f"{name} must contain at least one positive value.")
202
+ return value
203
+
204
+
205
+ class CustomPole:
206
+ """Marker base for spatially-varying (per-cell) dispersive pole descriptors.
207
+
208
+ A custom pole carries a 3D parameter grid instead of a scalar oscillator
209
+ strength. The grid spans the axis-aligned extent of the ``Box`` geometry of
210
+ the ``Structure`` the material is attached to, using the same node-coverage
211
+ convention as ``MaterialRegion.density``: box faces select grid nodes
212
+ lower-inclusive / upper-exclusive, and the grid is trilinearly resampled
213
+ when its shape differs from the covered node count. The rasterized grid is
214
+ composed multiplicatively with the structure's soft geometry occupancy, so
215
+ it blends and overlaps with other structures exactly like a scalar pole.
216
+
217
+ Only the oscillator-strength parameter may vary per cell (``delta_eps`` for
218
+ Debye/Lorentz, ``plasma_frequency`` for Drude); the rate parameters
219
+ (``tau``, ``gamma``, ``resonance_frequency``) stay spatially uniform so the
220
+ ADE recursion constants remain scalar and the existing native CUDA
221
+ dispersive kernels are reused unchanged.
222
+ """
223
+
224
+
225
+ @dataclass(frozen=True, eq=False)
226
+ class CustomDebyePole(CustomPole):
227
+ """Debye pole with per-cell oscillator strength ``delta_eps(x) >= 0``."""
228
+
229
+ delta_eps: torch.Tensor
230
+ tau: float
231
+
232
+ def __post_init__(self):
233
+ object.__setattr__(self, "delta_eps", _coerce_parameter_grid(self.delta_eps, name="delta_eps"))
234
+ object.__setattr__(self, "tau", _coerce_positive(self.tau, name="tau"))
235
+
236
+ @property
237
+ def peak_delta_eps(self) -> float:
238
+ return float(self.delta_eps.detach().max())
239
+
240
+ def reference_pole(self) -> DebyePole:
241
+ """Scalar Debye pole at the peak oscillator strength."""
242
+ return DebyePole(delta_eps=self.peak_delta_eps, tau=self.tau)
243
+
244
+ def amplitude(self) -> torch.Tensor:
245
+ """Per-cell strength normalized by the peak, in [0, 1]."""
246
+ return self.delta_eps / self.peak_delta_eps
247
+
248
+ def susceptibility(self, angular_frequency: float) -> torch.Tensor:
249
+ return self.delta_eps / (1.0 - 1j * float(angular_frequency) * self.tau)
250
+
251
+ def susceptibility_at_freq(self, frequency: float) -> torch.Tensor:
252
+ return self.susceptibility(2.0 * np.pi * _coerce_frequency(frequency, name="frequency"))
253
+
254
+
255
+ @dataclass(frozen=True, eq=False)
256
+ class CustomDrudePole(CustomPole):
257
+ """Drude pole with per-cell plasma frequency ``plasma_frequency(x) >= 0`` [Hz]."""
258
+
259
+ plasma_frequency: torch.Tensor
260
+ gamma: float
261
+
262
+ def __post_init__(self):
263
+ object.__setattr__(
264
+ self,
265
+ "plasma_frequency",
266
+ _coerce_parameter_grid(self.plasma_frequency, name="plasma_frequency"),
267
+ )
268
+ object.__setattr__(self, "gamma", _coerce_nonnegative(self.gamma, name="gamma"))
269
+
270
+ @property
271
+ def peak_plasma_frequency(self) -> float:
272
+ return float(self.plasma_frequency.detach().max())
273
+
274
+ def reference_pole(self) -> DrudePole:
275
+ """Scalar Drude pole at the peak plasma frequency."""
276
+ return DrudePole(plasma_frequency=self.peak_plasma_frequency, gamma=self.gamma)
277
+
278
+ def amplitude(self) -> torch.Tensor:
279
+ """Per-cell ``(f_p / f_p_peak)^2`` weight in [0, 1] (chi scales with f_p^2)."""
280
+ normalized = self.plasma_frequency / self.peak_plasma_frequency
281
+ return normalized * normalized
282
+
283
+ def susceptibility(self, angular_frequency: float) -> torch.Tensor:
284
+ angular_frequency = float(angular_frequency)
285
+ if angular_frequency <= 0.0:
286
+ raise ValueError("Drude susceptibility is undefined at zero frequency.")
287
+ omega_p = 2.0 * np.pi * self.plasma_frequency
288
+ gamma = 2.0 * np.pi * self.gamma
289
+ return -(omega_p * omega_p) / (
290
+ angular_frequency * angular_frequency + 1j * gamma * angular_frequency
291
+ )
292
+
293
+ def susceptibility_at_freq(self, frequency: float) -> torch.Tensor:
294
+ return self.susceptibility(2.0 * np.pi * _coerce_frequency(frequency, name="frequency"))
295
+
296
+
297
+ @dataclass(frozen=True, eq=False)
298
+ class CustomLorentzPole(CustomPole):
299
+ """Lorentz pole with per-cell oscillator strength ``delta_eps(x) >= 0``."""
300
+
301
+ delta_eps: torch.Tensor
302
+ resonance_frequency: float
303
+ gamma: float
304
+
305
+ def __post_init__(self):
306
+ object.__setattr__(self, "delta_eps", _coerce_parameter_grid(self.delta_eps, name="delta_eps"))
307
+ object.__setattr__(
308
+ self,
309
+ "resonance_frequency",
310
+ _coerce_frequency(self.resonance_frequency, name="resonance_frequency"),
311
+ )
312
+ object.__setattr__(self, "gamma", _coerce_nonnegative(self.gamma, name="gamma"))
313
+
314
+ @property
315
+ def peak_delta_eps(self) -> float:
316
+ return float(self.delta_eps.detach().max())
317
+
318
+ def reference_pole(self) -> LorentzPole:
319
+ """Scalar Lorentz pole at the peak oscillator strength."""
320
+ return LorentzPole(
321
+ delta_eps=self.peak_delta_eps,
322
+ resonance_frequency=self.resonance_frequency,
323
+ gamma=self.gamma,
324
+ )
325
+
326
+ def amplitude(self) -> torch.Tensor:
327
+ """Per-cell strength normalized by the peak, in [0, 1]."""
328
+ return self.delta_eps / self.peak_delta_eps
329
+
330
+ def susceptibility(self, angular_frequency: float) -> torch.Tensor:
331
+ angular_frequency = float(angular_frequency)
332
+ omega_0 = 2.0 * np.pi * self.resonance_frequency
333
+ gamma = 2.0 * np.pi * self.gamma
334
+ return self.delta_eps * omega_0 * omega_0 / (
335
+ omega_0 * omega_0 - angular_frequency * angular_frequency - 1j * gamma * angular_frequency
336
+ )
337
+
338
+ def susceptibility_at_freq(self, frequency: float) -> torch.Tensor:
339
+ return self.susceptibility(2.0 * np.pi * _coerce_frequency(frequency, name="frequency"))
340
+
341
+
342
+ @dataclass(frozen=True)
343
+ class NonlinearSusceptibility:
344
+ """Instantaneous nonlinear susceptibility descriptor.
345
+
346
+ ``chi2`` [m/V] adds the second-order polarization ``P_i = eps0 * chi2 * E_i^2``
347
+ per field component (diagonal scalar model), which drives second-harmonic
348
+ generation and optical rectification. ``chi3`` [m^2/V^2] adds the isotropic
349
+ third-order polarization ``P_i = eps0 * chi3 * |E|^2 * E_i`` and lowers to the
350
+ same runtime channel as ``Material(kerr_chi3=...)`` (the two are strictly
351
+ equivalent and additive). Both are scalars; tensorial susceptibilities are
352
+ not supported. Composes with a ``Material`` through the ``nonlinearity=``
353
+ argument.
354
+ """
355
+
356
+ chi2: float = 0.0
357
+ chi3: float = 0.0
358
+
359
+ def __post_init__(self):
360
+ object.__setattr__(self, "chi2", _coerce_real_scalar(self.chi2, name="chi2"))
361
+ object.__setattr__(self, "chi3", _coerce_real_scalar(self.chi3, name="chi3"))
362
+ if float(self.chi2) == 0.0 and float(self.chi3) == 0.0:
363
+ raise ValueError("NonlinearSusceptibility requires a nonzero chi2 or chi3.")
364
+
365
+
366
+ @dataclass(frozen=True)
367
+ class TwoPhotonAbsorption:
368
+ """Two-photon absorption (TPA): intensity-dependent nonlinear loss.
369
+
370
+ ``beta`` [m/W] is the TPA coefficient of ``dI/dz = -beta * I^2``. At runtime
371
+ the loss enters the update as a field-dependent conductivity
372
+ ``sigma_NL(x, t) = sigma_scale * |E(x, t)|^2`` folded into the semi-implicit
373
+ lossy decay term, with ``sigma_scale = (4/3) * beta * (n0 * eps0 * c0)^2``.
374
+ The 4/3 factor makes the cycle-averaged dissipation of a CW field
375
+ (``<E^4> = 3/8 |E0|^4``) reproduce ``alpha = beta * I`` with
376
+ ``I = 0.5 * n0 * eps0 * c0 * |E0|^2``. ``n0`` is the linear refractive index
377
+ used in the intensity conversion and defaults to ``sqrt(eps_r)`` of the host
378
+ material. Composes with a ``Material`` through the ``nonlinearity=`` argument.
379
+ """
380
+
381
+ beta: float
382
+ n0: float | None = None
383
+
384
+ def __post_init__(self):
385
+ object.__setattr__(self, "beta", _coerce_positive(self.beta, name="beta"))
386
+ if self.n0 is not None:
387
+ object.__setattr__(self, "n0", _coerce_positive(self.n0, name="n0"))
388
+
389
+ def sigma_scale(self, base_index: float) -> float:
390
+ """The ``sigma_NL / |E|^2`` coefficient [S*m/V^2] for a host index."""
391
+ index = float(base_index) if self.n0 is None else float(self.n0)
392
+ return (4.0 / 3.0) * self.beta * (index * VACUUM_PERMITTIVITY * _SPEED_OF_LIGHT) ** 2
393
+
394
+
395
+ _NONLINEAR_SPEC_TYPES = (NonlinearSusceptibility, TwoPhotonAbsorption)
396
+
397
+ # Harmonic modulation depth cap. The FDTD time step is sized against the static
398
+ # permittivity, and a modulated cell momentarily speeds the wave up by
399
+ # 1/sqrt(1 - amplitude); capping the amplitude at 0.5 keeps that inflation below
400
+ # sqrt(2), well inside the 2x headroom of the default Courant factor.
401
+ _MODULATION_AMPLITUDE_LIMIT = 0.5
402
+
403
+
404
+ def _coerce_modulation_amplitude(value):
405
+ if torch.is_tensor(value):
406
+ if value.ndim != 3:
407
+ raise ValueError(f"ModulationSpec amplitude must be a 3D tensor, got ndim={value.ndim}.")
408
+ with torch.no_grad():
409
+ if not torch.isfinite(value).all():
410
+ raise ValueError("ModulationSpec amplitude must be finite everywhere.")
411
+ if torch.any(value < 0):
412
+ raise ValueError("ModulationSpec amplitude must be >= 0 everywhere.")
413
+ if float(value.max()) <= 0.0:
414
+ raise ValueError("ModulationSpec amplitude must contain at least one positive value.")
415
+ if float(value.max()) >= _MODULATION_AMPLITUDE_LIMIT:
416
+ raise ValueError(
417
+ f"ModulationSpec amplitude must be < {_MODULATION_AMPLITUDE_LIMIT} everywhere to keep "
418
+ "the time-varying permittivity positive and Courant-stable."
419
+ )
420
+ return value
421
+ amplitude = float(value)
422
+ if not np.isfinite(amplitude) or amplitude <= 0.0:
423
+ raise ValueError("ModulationSpec amplitude must be > 0.")
424
+ if amplitude >= _MODULATION_AMPLITUDE_LIMIT:
425
+ raise ValueError(
426
+ f"ModulationSpec amplitude must be < {_MODULATION_AMPLITUDE_LIMIT} to keep the "
427
+ "time-varying permittivity positive and Courant-stable."
428
+ )
429
+ return amplitude
430
+
431
+
432
+ def _coerce_modulation_phase(value):
433
+ if torch.is_tensor(value):
434
+ if value.ndim != 3:
435
+ raise ValueError(f"ModulationSpec phase must be a 3D tensor, got ndim={value.ndim}.")
436
+ with torch.no_grad():
437
+ if not torch.isfinite(value).all():
438
+ raise ValueError("ModulationSpec phase must be finite everywhere.")
439
+ return value
440
+ return _coerce_real_scalar(value, name="phase")
441
+
442
+
443
+ @dataclass(frozen=True, eq=False)
444
+ class ModulationSpec:
445
+ """Harmonic space-time permittivity modulation.
446
+
447
+ Attached to a ``Material`` through ``Material(modulation=...)`` it makes the
448
+ static permittivity time-varying:
449
+
450
+ ``eps(x, t) = eps_static(x) * (1 + amplitude(x) * cos(2*pi*frequency*t + phase(x)))``
451
+
452
+ ``frequency`` [Hz] is the modulation frequency (a single frequency per
453
+ scene). ``amplitude`` is the dimensionless modulation depth, a scalar or a
454
+ 3D grid in ``[0, 0.5)`` spanning the Box extent of the structure the
455
+ material is attached to (same node-coverage and trilinear-resampling
456
+ convention as ``MaterialRegion.density``). ``phase`` [rad] is a scalar or a
457
+ 3D grid with the same spatial convention, enabling traveling-wave
458
+ modulation profiles. The runtime applies the modulation inside a dedicated
459
+ E-update kernel from precompiled per-cell quadrature fields, so no
460
+ coefficient tensors are rebuilt per step.
461
+ """
462
+
463
+ frequency: float
464
+ amplitude: float | torch.Tensor
465
+ phase: float | torch.Tensor = 0.0
466
+
467
+ def __post_init__(self):
468
+ object.__setattr__(self, "frequency", _coerce_frequency(self.frequency, name="frequency"))
469
+ object.__setattr__(self, "amplitude", _coerce_modulation_amplitude(self.amplitude))
470
+ object.__setattr__(self, "phase", _coerce_modulation_phase(self.phase))
471
+
472
+ @property
473
+ def angular_frequency(self) -> float:
474
+ return 2.0 * np.pi * self.frequency
475
+
476
+
477
+ @dataclass(frozen=True)
478
+ class DiagonalTensor3:
479
+ xx: float
480
+ yy: float
481
+ zz: float
482
+
483
+ def __post_init__(self):
484
+ object.__setattr__(self, "xx", float(self.xx))
485
+ object.__setattr__(self, "yy", float(self.yy))
486
+ object.__setattr__(self, "zz", float(self.zz))
487
+
488
+ def as_tuple(self) -> tuple[float, float, float]:
489
+ return (self.xx, self.yy, self.zz)
490
+
491
+
492
+ @dataclass(frozen=True)
493
+ class Tensor3x3:
494
+ rows: tuple[tuple[float, float, float], tuple[float, float, float], tuple[float, float, float]]
495
+
496
+ def __init__(self, rows):
497
+ object.__setattr__(self, "rows", _normalize_tensor_rows(rows, name="rows"))
498
+
499
+
500
+ def _shift_permittivity_real(value, susceptibility: complex):
501
+ """Add the real part of a homogeneous susceptibility to a permittivity sample.
502
+
503
+ A ``DiagonalTensor3`` stays a real diagonal tensor with each axis shifted by
504
+ ``Re(chi)``; a scalar background shifts to ``eps_inf + Re(chi)``. The
505
+ frequency-domain anisotropic permittivity sample is real because
506
+ ``DiagonalTensor3`` carries no imaginary channel, and the only consumer of the
507
+ sample (AutoGrid meshing) needs the real refractive index; the dispersive loss
508
+ is recovered from the compiled scene material model when the imaginary part is
509
+ required.
510
+ """
511
+ shift = float(susceptibility.real)
512
+ if isinstance(value, DiagonalTensor3):
513
+ return DiagonalTensor3(value.xx + shift, value.yy + shift, value.zz + shift)
514
+ return float(value) + shift
515
+
516
+
517
+ @dataclass(frozen=True, init=False)
518
+ class Material(CoreMaterial):
519
+ debye_poles: tuple[DebyePole, ...]
520
+ drude_poles: tuple[DrudePole, ...]
521
+ lorentz_poles: tuple[LorentzPole, ...]
522
+ mu_debye_poles: tuple[DebyePole, ...]
523
+ mu_drude_poles: tuple[DrudePole, ...]
524
+ mu_lorentz_poles: tuple[LorentzPole, ...]
525
+ epsilon_tensor: DiagonalTensor3 | Tensor3x3 | None
526
+ mu_tensor: DiagonalTensor3 | Tensor3x3 | None
527
+ sigma_e_tensor: DiagonalTensor3 | Tensor3x3 | None
528
+ sigma_m: float
529
+ orientation: tuple[tuple[float, float, float], tuple[float, float, float], tuple[float, float, float]] | None
530
+ kerr_chi3: float | None
531
+ nonlinearity: tuple
532
+ modulation: ModulationSpec | None
533
+ pec: bool
534
+
535
+ def __init__(
536
+ self,
537
+ eps_r: float = 1.0,
538
+ mu_r: float = 1.0,
539
+ sigma_e: float = 0.0,
540
+ name: str | None = None,
541
+ *,
542
+ sigma_m: float = 0.0,
543
+ debye_poles=(),
544
+ drude_poles=(),
545
+ lorentz_poles=(),
546
+ mu_debye_poles=(),
547
+ mu_drude_poles=(),
548
+ mu_lorentz_poles=(),
549
+ epsilon_tensor: DiagonalTensor3 | Tensor3x3 | None = None,
550
+ mu_tensor: DiagonalTensor3 | Tensor3x3 | None = None,
551
+ sigma_e_tensor: DiagonalTensor3 | Tensor3x3 | None = None,
552
+ orientation=None,
553
+ kerr_chi3: float | None = None,
554
+ nonlinearity=None,
555
+ modulation: ModulationSpec | None = None,
556
+ pec: bool = False,
557
+ ):
558
+ super().__init__(eps_r=eps_r, mu_r=mu_r, sigma_e=sigma_e, name=name)
559
+ object.__setattr__(self, "pec", bool(pec))
560
+ object.__setattr__(self, "debye_poles", _normalize_poles(debye_poles, (DebyePole, CustomDebyePole), name="debye_poles"))
561
+ object.__setattr__(self, "drude_poles", _normalize_poles(drude_poles, (DrudePole, CustomDrudePole), name="drude_poles"))
562
+ object.__setattr__(self, "lorentz_poles", _normalize_poles(lorentz_poles, (LorentzPole, CustomLorentzPole), name="lorentz_poles"))
563
+ object.__setattr__(self, "mu_debye_poles", _normalize_poles(mu_debye_poles, (DebyePole, CustomDebyePole), name="mu_debye_poles"))
564
+ object.__setattr__(self, "mu_drude_poles", _normalize_poles(mu_drude_poles, (DrudePole, CustomDrudePole), name="mu_drude_poles"))
565
+ object.__setattr__(self, "mu_lorentz_poles", _normalize_poles(mu_lorentz_poles, (LorentzPole, CustomLorentzPole), name="mu_lorentz_poles"))
566
+ object.__setattr__(self, "epsilon_tensor", epsilon_tensor)
567
+ object.__setattr__(self, "mu_tensor", mu_tensor)
568
+ object.__setattr__(self, "sigma_e_tensor", sigma_e_tensor)
569
+ # Static magnetic conductivity [Ohm/m]: the magnetic dual of sigma_e. It
570
+ # adds a magnetic conduction current sigma_m * H to Faraday's law, folded
571
+ # semi-implicitly into the H-update Da/Db coefficients exactly as sigma_e
572
+ # folds into the E-update Ca/Cb coefficients.
573
+ object.__setattr__(self, "sigma_m", _coerce_nonnegative(sigma_m, name="sigma_m"))
574
+ object.__setattr__(self, "orientation", None if orientation is None else _normalize_tensor_rows(orientation, name="orientation"))
575
+ object.__setattr__(self, "kerr_chi3", None if kerr_chi3 is None else _coerce_real_scalar(kerr_chi3, name="kerr_chi3"))
576
+ object.__setattr__(self, "nonlinearity", _normalize_poles(nonlinearity, _NONLINEAR_SPEC_TYPES, name="nonlinearity"))
577
+ if modulation is not None and not isinstance(modulation, ModulationSpec):
578
+ raise TypeError("Material.modulation must be a ModulationSpec instance.")
579
+ object.__setattr__(self, "modulation", modulation)
580
+
581
+ if self.orientation is not None:
582
+ raise NotImplementedError(
583
+ "Material.orientation (a separate crystal-frame rotation matrix) is not consumed: the "
584
+ "tensor material path assembles per-edge Yee coefficients directly from the lab-frame "
585
+ "permittivity/permeability/conductivity tensor, so a standalone rotation would need the "
586
+ "tensor rotated into the lab frame before compilation. Pass the already-rotated tensor as "
587
+ "Tensor3x3 rows (or an axis-aligned DiagonalTensor3) instead of an orientation matrix."
588
+ )
589
+ for tensor_name, tensor_value in (
590
+ ("mu_tensor", self.mu_tensor),
591
+ ("sigma_e_tensor", self.sigma_e_tensor),
592
+ ):
593
+ if isinstance(tensor_value, Tensor3x3):
594
+ side = "H-field" if tensor_name == "mu_tensor" else "conduction-current"
595
+ raise NotImplementedError(
596
+ f"Material.{tensor_name} accepts a DiagonalTensor3 (axis-aligned) tensor only. A full "
597
+ f"off-diagonal Tensor3x3 would require a coupled per-edge 3x3 inverse on the {side} "
598
+ "update, which only the electric permittivity update forms; supply an axis-aligned "
599
+ "DiagonalTensor3, or move the off-diagonal coupling to epsilon_tensor."
600
+ )
601
+ if isinstance(self.epsilon_tensor, Tensor3x3):
602
+ _validate_symmetric_positive_definite(self.epsilon_tensor, name="epsilon_tensor")
603
+ # A full (off-diagonal) Tensor3x3 permittivity combined with (diagonal)
604
+ # electric conductivity is supported: the FDTD update folds the loss through
605
+ # the exact semi-implicit tensor inverse B = dt * (eps_inf + dt/2 * diag(sigma))^-1
606
+ # applied to both curl(H) and the conduction current sigma . E, which
607
+ # diagonalizes in the crystal principal frame and reproduces the per-axis
608
+ # complex index of a lossy anisotropic crystal (see build_full_anisotropy).
609
+
610
+ # A full (off-diagonal) Tensor3x3 permittivity combined with electric poles is
611
+ # supported: the poles enter isotropically (chi(omega) * I), so the frequency
612
+ # response is eps_inf_tensor + chi(omega) * I and the FDTD update applies the
613
+ # single instantaneous tensor inverse eps_inf^-1 to both curl(H) and the ADE
614
+ # polarization current. This diagonalizes exactly in the crystal principal
615
+ # frame, reproducing n_o(omega) and n_e(omega) for a rotated birefringent
616
+ # dispersive crystal.
617
+ if self.is_nonlinear and self.is_anisotropic:
618
+ raise NotImplementedError(
619
+ "A nonlinear Material cannot carry an anisotropic permittivity tensor: the instantaneous "
620
+ "Kerr/chi2/TPA channel updates the permittivity as a per-component field-dependent scalar, "
621
+ "whereas an anisotropic tensor applies a coupled per-edge tensor inverse. Composing them "
622
+ "would need a per-step re-inversion of a field-amplitude-dependent 3x3 tensor, which the "
623
+ "scalar nonlinear coefficient update does not form. Use an isotropic (scalar) permittivity "
624
+ "with the nonlinearity, or drop the tensor."
625
+ )
626
+
627
+ if self.modulation is not None:
628
+ # A time-modulated Material may now carry dispersive poles (electric or
629
+ # magnetic) and the instantaneous nonlinear channels (Kerr / chi2 / TPA):
630
+ # the modulation scales the eps_inf background while the ADE polarization
631
+ # current and the field-dependent coefficients are folded through the same
632
+ # per-step modulation factor (electro-optic-modulator physics). Two edges
633
+ # remain physically out of reach for the single instantaneous-tensor path:
634
+ if self.is_anisotropic:
635
+ raise NotImplementedError(
636
+ "A time-modulated Material cannot carry anisotropic tensors: modulating the "
637
+ "crystal-frame permittivity tensor needs a per-step re-inversion of the coupled "
638
+ "3x3 tensor, which the diagonalized effective-permittivity update does not provide."
639
+ )
640
+ if float(self.sigma_e) != 0.0:
641
+ raise NotImplementedError(
642
+ "A time-modulated Material cannot carry static electric conductivity: the "
643
+ "semi-implicit loss fold 0.5*sigma*dt/eps must see the modulated eps_inf*m(t), "
644
+ "which the static decay coefficient does not track. Model loss with a Drude/Debye "
645
+ "pole (dispersive path) instead."
646
+ )
647
+
648
+ if self.pec:
649
+ if (
650
+ self.is_dispersive
651
+ or self.is_anisotropic
652
+ or self.is_nonlinear
653
+ or self.modulation is not None
654
+ or float(self.eps_r) != 1.0
655
+ or float(self.mu_r) != 1.0
656
+ or float(self.sigma_e) != 0.0
657
+ or float(self.sigma_m) != 0.0
658
+ ):
659
+ raise ValueError(
660
+ "A PEC Material must not carry dispersion, anisotropy, Kerr, modulation, or "
661
+ "non-default eps/mu/sigma; its permittivity is not a finite number."
662
+ )
663
+
664
+ @property
665
+ def is_dispersive(self) -> bool:
666
+ return bool(
667
+ self.debye_poles
668
+ or self.drude_poles
669
+ or self.lorentz_poles
670
+ or self.mu_debye_poles
671
+ or self.mu_drude_poles
672
+ or self.mu_lorentz_poles
673
+ )
674
+
675
+ @property
676
+ def is_electric_dispersive(self) -> bool:
677
+ return bool(self.debye_poles or self.drude_poles or self.lorentz_poles)
678
+
679
+ @property
680
+ def is_magnetic_dispersive(self) -> bool:
681
+ return bool(self.mu_debye_poles or self.mu_drude_poles or self.mu_lorentz_poles)
682
+
683
+ @property
684
+ def has_custom_electric_poles(self) -> bool:
685
+ return any(
686
+ isinstance(pole, CustomPole)
687
+ for pole in (*self.debye_poles, *self.drude_poles, *self.lorentz_poles)
688
+ )
689
+
690
+ @property
691
+ def has_custom_magnetic_poles(self) -> bool:
692
+ return any(
693
+ isinstance(pole, CustomPole)
694
+ for pole in (*self.mu_debye_poles, *self.mu_drude_poles, *self.mu_lorentz_poles)
695
+ )
696
+
697
+ @property
698
+ def has_custom_poles(self) -> bool:
699
+ return self.has_custom_electric_poles or self.has_custom_magnetic_poles
700
+
701
+ @property
702
+ def is_anisotropic(self) -> bool:
703
+ return any(value is not None for value in (self.epsilon_tensor, self.mu_tensor, self.sigma_e_tensor))
704
+
705
+ @property
706
+ def has_full_epsilon_tensor(self) -> bool:
707
+ """Whether the permittivity is a full (potentially off-diagonal) 3x3 tensor."""
708
+ return isinstance(self.epsilon_tensor, Tensor3x3)
709
+
710
+ @property
711
+ def nonlinear_chi2(self) -> float:
712
+ """Total instantaneous second-order susceptibility [m/V]."""
713
+ return float(
714
+ sum(spec.chi2 for spec in self.nonlinearity if isinstance(spec, NonlinearSusceptibility))
715
+ )
716
+
717
+ @property
718
+ def nonlinear_chi3(self) -> float:
719
+ """Total instantaneous third-order (Kerr) susceptibility [m^2/V^2]."""
720
+ return float(self.kerr_chi3 or 0.0) + float(
721
+ sum(spec.chi3 for spec in self.nonlinearity if isinstance(spec, NonlinearSusceptibility))
722
+ )
723
+
724
+ @property
725
+ def tpa_sigma_scale(self) -> float:
726
+ """Total two-photon-absorption ``sigma_NL / |E|^2`` coefficient [S*m/V^2]."""
727
+ base_index = float(np.sqrt(float(self.eps_r)))
728
+ return float(
729
+ sum(
730
+ spec.sigma_scale(base_index)
731
+ for spec in self.nonlinearity
732
+ if isinstance(spec, TwoPhotonAbsorption)
733
+ )
734
+ )
735
+
736
+ @property
737
+ def is_nonlinear(self) -> bool:
738
+ return self.nonlinear_chi2 != 0.0 or self.nonlinear_chi3 != 0.0 or self.tpa_sigma_scale != 0.0
739
+
740
+ @property
741
+ def is_modulated(self) -> bool:
742
+ return self.modulation is not None
743
+
744
+ @property
745
+ def is_pec(self) -> bool:
746
+ return self.pec
747
+
748
+ @classmethod
749
+ def pec(cls, name: str | None = None) -> "Material":
750
+ """Construct a perfect-electric-conductor marker material."""
751
+ return cls(pec=True, name=name)
752
+
753
+ def capabilities(self) -> MaterialCapabilities:
754
+ base = super().capabilities()
755
+ return MaterialCapabilities(
756
+ conductive=base.conductive or self.sigma_e_tensor is not None,
757
+ magnetic=(
758
+ base.magnetic
759
+ or self.mu_tensor is not None
760
+ or self.is_magnetic_dispersive
761
+ or float(self.sigma_m) != 0.0
762
+ ),
763
+ anisotropic=self.is_anisotropic,
764
+ dispersive=self.is_dispersive,
765
+ )
766
+
767
+ def evaluate_static(self) -> StaticMaterialSample:
768
+ return StaticMaterialSample(
769
+ eps_r=self.epsilon_tensor if self.epsilon_tensor is not None else self.eps_r,
770
+ mu_r=self.mu_tensor if self.mu_tensor is not None else self.mu_r,
771
+ sigma_e=self.sigma_e_tensor if self.sigma_e_tensor is not None else self.sigma_e,
772
+ )
773
+
774
+ def _electric_pole_susceptibility(self, frequency: float) -> complex:
775
+ """Summed homogeneous electric-pole susceptibility ``chi_e(f)``.
776
+
777
+ Defined only for scalar (non-custom) poles; spatially-varying custom poles
778
+ are rejected before this is reached.
779
+ """
780
+ total = 0.0 + 0.0j
781
+ for pole in (*self.debye_poles, *self.drude_poles, *self.lorentz_poles):
782
+ total += pole.susceptibility_at_freq(frequency)
783
+ return complex(total)
784
+
785
+ def _magnetic_pole_susceptibility(self, frequency: float) -> complex:
786
+ """Summed homogeneous magnetic-pole susceptibility ``chi_m(f)``."""
787
+ total = 0.0 + 0.0j
788
+ for pole in (*self.mu_debye_poles, *self.mu_drude_poles, *self.mu_lorentz_poles):
789
+ total += pole.susceptibility_at_freq(frequency)
790
+ return complex(total)
791
+
792
+ def evaluate_at_frequency(self, frequency: float) -> FrequencyMaterialSample:
793
+ if self.is_nonlinear:
794
+ raise NotImplementedError("Nonlinear Material frequency evaluation is not defined without a field amplitude.")
795
+ if self.is_dispersive and (self.has_full_epsilon_tensor or self.has_custom_poles):
796
+ raise NotImplementedError(
797
+ "Frequency evaluation of a dispersive Material is only defined for an axis-aligned "
798
+ "DiagonalTensor3 permittivity with homogeneous poles: a fully anisotropic Tensor3x3 "
799
+ "permittivity needs a coupled tensor ADE, and spatially-varying custom poles have no "
800
+ "homogeneous sample. Evaluate the compiled scene material model "
801
+ "(Scene.compile_relative_materials) instead."
802
+ )
803
+ if self.is_anisotropic:
804
+ # Diagonal (axis-aligned) anisotropy composes with homogeneous
805
+ # dispersion by shifting each per-axis background permittivity by the
806
+ # isotropic pole susceptibility; the sample stays a real DiagonalTensor3.
807
+ static = self.evaluate_static()
808
+ eps_r = static.eps_r
809
+ mu_r = static.mu_r
810
+ if self.is_electric_dispersive:
811
+ eps_r = _shift_permittivity_real(eps_r, self._electric_pole_susceptibility(frequency))
812
+ if self.is_magnetic_dispersive:
813
+ mu_r = _shift_permittivity_real(mu_r, self._magnetic_pole_susceptibility(frequency))
814
+ return FrequencyMaterialSample(
815
+ eps_r=eps_r,
816
+ mu_r=mu_r,
817
+ sigma_e=static.sigma_e,
818
+ )
819
+ return FrequencyMaterialSample(
820
+ eps_r=self.relative_permittivity(frequency),
821
+ mu_r=self.relative_permeability(frequency),
822
+ sigma_e=self.sigma_e,
823
+ )
824
+
825
+ def relative_permittivity(self, frequency: float) -> complex:
826
+ if self.is_anisotropic:
827
+ raise NotImplementedError("relative_permittivity() currently supports isotropic Material only.")
828
+ if self.is_nonlinear:
829
+ raise NotImplementedError("relative_permittivity() is not defined for nonlinear Material without a field amplitude.")
830
+ if self.has_custom_electric_poles:
831
+ raise NotImplementedError(
832
+ "relative_permittivity() is not defined for spatially-varying custom dispersive poles; "
833
+ "evaluate the compiled scene material model (Scene.compile_relative_materials) instead."
834
+ )
835
+ frequency = _coerce_frequency(frequency, name="frequency")
836
+ angular_frequency = 2.0 * np.pi * frequency
837
+ epsilon = complex(self.eps_r, self.sigma_e / (angular_frequency * VACUUM_PERMITTIVITY))
838
+ for pole in self.debye_poles:
839
+ epsilon += pole.susceptibility_at_freq(frequency)
840
+ for pole in self.drude_poles:
841
+ epsilon += pole.susceptibility_at_freq(frequency)
842
+ for pole in self.lorentz_poles:
843
+ epsilon += pole.susceptibility_at_freq(frequency)
844
+ return epsilon
845
+
846
+ def relative_permeability(self, frequency: float) -> complex:
847
+ if self.is_anisotropic:
848
+ raise NotImplementedError("relative_permeability() currently supports isotropic Material only.")
849
+ if self.is_nonlinear:
850
+ raise NotImplementedError("relative_permeability() is not defined for nonlinear Material without a field amplitude.")
851
+ if self.has_custom_magnetic_poles:
852
+ raise NotImplementedError(
853
+ "relative_permeability() is not defined for spatially-varying custom dispersive poles; "
854
+ "evaluate the compiled scene material model (Scene.compile_relative_materials) instead."
855
+ )
856
+ frequency = _coerce_frequency(frequency, name="frequency")
857
+ permeability = complex(self.mu_r, 0.0)
858
+ for pole in self.mu_debye_poles:
859
+ permeability += pole.susceptibility_at_freq(frequency)
860
+ for pole in self.mu_drude_poles:
861
+ permeability += pole.susceptibility_at_freq(frequency)
862
+ for pole in self.mu_lorentz_poles:
863
+ permeability += pole.susceptibility_at_freq(frequency)
864
+ return permeability
865
+
866
+ @classmethod
867
+ def debye(
868
+ cls,
869
+ *,
870
+ eps_inf: float = 1.0,
871
+ delta_eps: float,
872
+ tau: float,
873
+ mu_r: float = 1.0,
874
+ sigma_e: float = 0.0,
875
+ name: str | None = None,
876
+ ) -> "Material":
877
+ return cls(
878
+ eps_r=eps_inf,
879
+ mu_r=mu_r,
880
+ sigma_e=sigma_e,
881
+ name=name,
882
+ debye_poles=(DebyePole(delta_eps=delta_eps, tau=tau),),
883
+ )
884
+
885
+ @classmethod
886
+ def drude(
887
+ cls,
888
+ *,
889
+ eps_inf: float = 1.0,
890
+ plasma_frequency: float,
891
+ gamma: float,
892
+ mu_r: float = 1.0,
893
+ sigma_e: float = 0.0,
894
+ name: str | None = None,
895
+ ) -> "Material":
896
+ return cls(
897
+ eps_r=eps_inf,
898
+ mu_r=mu_r,
899
+ sigma_e=sigma_e,
900
+ name=name,
901
+ drude_poles=(DrudePole(plasma_frequency=plasma_frequency, gamma=gamma),),
902
+ )
903
+
904
+ @classmethod
905
+ def lorentz(
906
+ cls,
907
+ *,
908
+ eps_inf: float = 1.0,
909
+ delta_eps: float,
910
+ resonance_frequency: float,
911
+ gamma: float,
912
+ mu_r: float = 1.0,
913
+ sigma_e: float = 0.0,
914
+ name: str | None = None,
915
+ allow_gain: bool = False,
916
+ ) -> "Material":
917
+ return cls(
918
+ eps_r=eps_inf,
919
+ mu_r=mu_r,
920
+ sigma_e=sigma_e,
921
+ name=name,
922
+ lorentz_poles=(
923
+ LorentzPole(
924
+ delta_eps=delta_eps,
925
+ resonance_frequency=resonance_frequency,
926
+ gamma=gamma,
927
+ allow_gain=allow_gain,
928
+ ),
929
+ ),
930
+ )
931
+
932
+ @classmethod
933
+ def sellmeier(
934
+ cls,
935
+ *,
936
+ b_coefficients,
937
+ c_coefficients,
938
+ eps_inf: float = 1.0,
939
+ mu_r: float = 1.0,
940
+ sigma_e: float = 0.0,
941
+ name: str | None = None,
942
+ ) -> "Material":
943
+ """Construct a lossless Sellmeier dielectric.
944
+
945
+ The Sellmeier dispersion ``n(lambda)^2 = eps_inf + sum_i B_i * lambda^2 /
946
+ (lambda^2 - C_i)`` is a sum of lossless Lorentz oscillators. Each ``(B_i, C_i)``
947
+ term maps exactly to a zero-damping ``LorentzPole`` with ``delta_eps = B_i`` and
948
+ resonance ``omega0_i = 2*pi*c / sqrt(C_i)`` (i.e. ``resonance_frequency =
949
+ c / sqrt(C_i)``), because ``omega0_i^2 / (omega0_i^2 - omega^2) =
950
+ lambda^2 / (lambda^2 - C_i)``.
951
+
952
+ ``c_coefficients`` carry units of length squared and MUST be expressed in SI
953
+ ``meters^2``, consistent with the rest of the simulation. Coefficient tables
954
+ quoted in ``micron^2`` (for example the BK7 Schott coefficients) must be scaled
955
+ by ``1e-12`` before being passed in. Each ``B_i`` must be non-negative and each
956
+ ``C_i`` strictly positive.
957
+ """
958
+ b_values = tuple(float(b) for b in b_coefficients)
959
+ c_values = tuple(float(c) for c in c_coefficients)
960
+ if len(b_values) != len(c_values):
961
+ raise ValueError("Sellmeier b_coefficients and c_coefficients must have equal length.")
962
+ if not b_values:
963
+ raise ValueError("Sellmeier material requires at least one (B, C) coefficient pair.")
964
+ poles = []
965
+ for b_value, c_value in zip(b_values, c_values):
966
+ if c_value <= 0.0:
967
+ raise ValueError("Sellmeier c_coefficients must be > 0 (squared resonance wavelength in meters^2).")
968
+ resonance_frequency = _SPEED_OF_LIGHT / np.sqrt(c_value)
969
+ poles.append(
970
+ LorentzPole(
971
+ delta_eps=b_value,
972
+ resonance_frequency=resonance_frequency,
973
+ gamma=0.0,
974
+ )
975
+ )
976
+ return cls(
977
+ eps_r=eps_inf,
978
+ mu_r=mu_r,
979
+ sigma_e=sigma_e,
980
+ name=name,
981
+ lorentz_poles=tuple(poles),
982
+ )
983
+
984
+
985
+ class Medium2D(Material):
986
+ """Zero-thickness conductive sheet (2D material).
987
+
988
+ ``Medium2D`` models an infinitesimally thin conductive layer through its
989
+ sheet conductivity ``sigma_s`` [S] (siemens; a surface quantity, not the
990
+ volumetric S/m). The sheet carries the surface current
991
+ ``J_s = sigma_s * E_t`` in the two tangential field components and leaves
992
+ the normal component untouched.
993
+
994
+ A ``Medium2D`` must be attached to a ``Structure`` whose geometry is an
995
+ axis-aligned ``Box`` with exactly one zero-size axis (the sheet normal).
996
+ The compiler snaps the sheet to the nearest node plane along the normal
997
+ axis and lowers ``sigma_s`` to the effective volumetric conductivity
998
+ ``sigma_s / dcell`` on that single Yee layer, where ``dcell`` is the local
999
+ dual-cell width at the plane: integrating the discrete Ampere law across
1000
+ one dual cell shows a sheet current distributed over that cell is exactly
1001
+ equivalent to this volumetric conductivity. Sheet contributions are
1002
+ additive with bulk conductivity and with other overlapping sheets.
1003
+ """
1004
+
1005
+ def __init__(self, *, sigma_s: float = 0.0, name: str | None = None):
1006
+ super().__init__(eps_r=1.0, mu_r=1.0, sigma_e=0.0, name=name)
1007
+ object.__setattr__(self, "sigma_s", _coerce_nonnegative(sigma_s, name="sigma_s"))
1008
+
1009
+ @property
1010
+ def is_medium2d(self) -> bool:
1011
+ return True
1012
+
1013
+ def sheet_pole_terms(self) -> tuple[tuple[float, float], ...]:
1014
+ """Dispersive surface-conductivity terms ``(weight, rate)``.
1015
+
1016
+ Each term contributes ``weight / (rate - i*omega)`` [S] to the sheet
1017
+ conductivity (``weight`` in S/s, ``rate`` in 1/s), i.e. a Drude-like
1018
+ relaxation of the surface current ``dJ_s/dt + rate * J_s = weight * E_t``.
1019
+ The static base class carries none; frequency-dependent sheets such as
1020
+ ``Graphene`` override this.
1021
+ """
1022
+ return ()
1023
+
1024
+ def sheet_lorentz_terms(self) -> tuple[tuple[float, float, float], ...]:
1025
+ """Resonant surface-conductivity terms ``(strength, omega_0, gamma)``.
1026
+
1027
+ Each term contributes
1028
+ ``-i*omega * strength * omega_0^2 / (omega_0^2 - omega^2 - i*gamma*omega)``
1029
+ [S] to the sheet conductivity (``strength`` in S*s, ``omega_0`` and
1030
+ ``gamma`` in rad/s), i.e. the sheet lowering of one volumetric Lorentz
1031
+ pole. A Drude term (``sheet_pole_terms``) can only reproduce an inductive
1032
+ surface reactance (``Im(sigma) > 0``); a resonant absorption edge whose
1033
+ below-edge reactance is capacitive (``Im(sigma) < 0``), such as the
1034
+ graphene interband transition, needs a Lorentz term. The static base
1035
+ class carries none.
1036
+ """
1037
+ return ()
1038
+
1039
+ def sheet_conductivity(self, angular_frequency: float) -> complex:
1040
+ """Complex sheet conductivity ``sigma_s(omega)`` [S] (e^{-i*omega*t} convention)."""
1041
+ omega = float(angular_frequency)
1042
+ sigma = complex(self.sigma_s)
1043
+ for weight, rate in self.sheet_pole_terms():
1044
+ sigma += weight / complex(rate, -omega)
1045
+ for strength, omega_0, gamma in self.sheet_lorentz_terms():
1046
+ sigma += -1j * omega * strength * omega_0 * omega_0 / (
1047
+ omega_0 * omega_0 - omega * omega - 1j * gamma * omega
1048
+ )
1049
+ return sigma
1050
+
1051
+ def sheet_conductivity_at_freq(self, frequency: float) -> complex:
1052
+ return self.sheet_conductivity(2.0 * np.pi * _coerce_frequency(frequency, name="frequency"))
1053
+
1054
+
1055
+ _ELEMENTARY_CHARGE = 1.602176634e-19 # [C]
1056
+ _REDUCED_PLANCK = 1.054571817e-34 # [J*s]
1057
+ _BOLTZMANN = 1.380649e-23 # [J/K]
1058
+
1059
+ _GRAPHENE_INTERBAND_POLES = 4
1060
+
1061
+
1062
+ def _kubo_interband_sigma(angular_frequency: float, mu_c_ev: float, tau: float, temperature: float) -> complex:
1063
+ """Analytic Kubo interband sheet conductivity ``sigma_inter(omega)`` [S].
1064
+
1065
+ T > 0 principal-value form in the e^{-i*omega*t} convention:
1066
+
1067
+ ``sigma_inter = i*e^2*Omega/(pi*hbar^2) * INT_0^inf [f(-xi) - f(xi)]
1068
+ / (Omega^2 - (2*xi/hbar)^2) dxi``
1069
+
1070
+ where ``Omega = omega + i/tau`` is the collision-broadened complex
1071
+ frequency and ``f(xi) = 1/(exp((xi - mu_c)/(kB*T)) + 1)`` is the Fermi-Dirac
1072
+ occupation. The finite scattering rate ``1/tau`` moves the integrand pole
1073
+ off the real ``xi`` axis, so the "principal value" is a convergent integral.
1074
+
1075
+ The integrand has a sharp resonant peak of width ``hbar/(2*tau)`` at
1076
+ ``xi = hbar*omega/2`` and a Fermi step of width ``kB*T`` at ``xi = mu_c``,
1077
+ and its ``1/xi^2`` tail decays too slowly to truncate. It is therefore split
1078
+ at ``xi = split`` (chosen above both features, where ``f(-xi) - f(xi) = 1``
1079
+ to machine precision): the near part is integrated adaptively with the peak
1080
+ and step passed as subdivision hints, and the far part is added in closed
1081
+ form, ``INT_split^inf dxi / (Omega^2 - 4*xi^2/hbar^2) = (hbar/(4*Omega)) *
1082
+ ln((2*split - hbar*Omega)/(2*split + hbar*Omega))``. This converges to the
1083
+ exact T = 0 log form as ``T -> 0``.
1084
+ """
1085
+ from scipy.integrate import quad
1086
+
1087
+ mu = mu_c_ev * _ELEMENTARY_CHARGE
1088
+ thermal_energy = _BOLTZMANN * temperature
1089
+ omega_complex = angular_frequency + 1j / tau
1090
+ prefactor = 1j * _ELEMENTARY_CHARGE * _ELEMENTARY_CHARGE * omega_complex / (
1091
+ np.pi * _REDUCED_PLANCK * _REDUCED_PLANCK
1092
+ )
1093
+
1094
+ def fermi(energy: float) -> float:
1095
+ return 1.0 / (np.exp(np.clip((energy - mu) / thermal_energy, -500.0, 500.0)) + 1.0)
1096
+
1097
+ def integrand(energy: float) -> complex:
1098
+ numerator = fermi(-energy) - fermi(energy)
1099
+ denom = omega_complex * omega_complex - 4.0 * (energy / _REDUCED_PLANCK) ** 2
1100
+ return numerator / denom
1101
+
1102
+ split = mu + 40.0 * thermal_energy + 6.0 * _REDUCED_PLANCK * abs(angular_frequency)
1103
+ peak = _REDUCED_PLANCK * angular_frequency / 2.0
1104
+ peak_width = _REDUCED_PLANCK / (2.0 * tau)
1105
+ hints = (peak - 2.0 * peak_width, peak, peak + 2.0 * peak_width,
1106
+ mu - 4.0 * thermal_energy, mu, mu + 4.0 * thermal_energy)
1107
+ points = sorted(p for p in hints if 0.0 < p < split) or None
1108
+ real_part, _ = quad(lambda e: integrand(e).real, 0.0, split, limit=2000, points=points)
1109
+ imag_part, _ = quad(lambda e: integrand(e).imag, 0.0, split, limit=2000, points=points)
1110
+ tail = (_REDUCED_PLANCK / (4.0 * omega_complex)) * np.log(
1111
+ (2.0 * split - _REDUCED_PLANCK * omega_complex) / (2.0 * split + _REDUCED_PLANCK * omega_complex)
1112
+ )
1113
+ return prefactor * (real_part + 1j * imag_part + tail)
1114
+
1115
+
1116
+ def _fit_graphene_interband_lorentz(
1117
+ mu_c_ev: float, tau: float, temperature: float, intraband_weight: float
1118
+ ) -> tuple[tuple[float, float, float], ...]:
1119
+ """Fit the Kubo interband sheet conductivity with Lorentz sheet terms.
1120
+
1121
+ Returns ``(strength, omega_0, gamma)`` terms (see ``sheet_lorentz_terms``)
1122
+ that reproduce the interband conductivity below the band edge
1123
+ ``hbar*omega = 2*|mu_c|``. Below the edge the interband response is a
1124
+ capacitive reactance (``Im(sigma) < 0``) that a Drude pole cannot represent,
1125
+ but a Lorentz oscillator seated near the edge can. Strengths are constrained
1126
+ non-negative so every term lowers to a passive (``delta_eps >= 0``) pole and
1127
+ the time-domain ADE stays stable.
1128
+
1129
+ The fit is a deterministic non-negative least-squares seed on a fixed
1130
+ ``(omega_0, gamma)`` grid, refined by a bounded Levenberg-Marquardt step,
1131
+ weighted by ``1/|sigma_total|`` so the relative error of the *total*
1132
+ (intraband + interband) conductivity is minimized across the band.
1133
+ """
1134
+ from scipy.optimize import least_squares, nnls
1135
+
1136
+ omega_gap = 2.0 * mu_c_ev * _ELEMENTARY_CHARGE / _REDUCED_PLANCK
1137
+ n_samples = 140
1138
+ omega = np.linspace(0.02 * omega_gap, 0.99 * omega_gap, n_samples)
1139
+ intra = intraband_weight / (1.0 / tau - 1j * omega)
1140
+ inter = np.array([_kubo_interband_sigma(w, mu_c_ev, tau, temperature) for w in omega])
1141
+ total = intra + inter
1142
+ fit_weight = 1.0 / np.maximum(np.abs(total), 0.1 * np.abs(total).max())
1143
+
1144
+ def basis(w, omega_0, gamma):
1145
+ return -1j * w * omega_0 * omega_0 / (omega_0 * omega_0 - w * w - 1j * gamma * w)
1146
+
1147
+ omega_0_grid = np.linspace(0.5, 2.0, 25) * omega_gap
1148
+ gamma_grid = np.array([0.05, 0.1, 0.2, 0.35, 0.6, 1.0]) * omega_gap
1149
+ candidates = [(o, g) for o in omega_0_grid for g in gamma_grid]
1150
+ design = np.zeros((2 * n_samples, len(candidates)))
1151
+ scales = np.zeros(len(candidates))
1152
+ for k, (o, g) in enumerate(candidates):
1153
+ column = basis(omega, o, g) * fit_weight
1154
+ stacked = np.concatenate([column.real, column.imag])
1155
+ scales[k] = np.linalg.norm(stacked)
1156
+ design[:, k] = stacked / scales[k]
1157
+ target = np.concatenate([inter.real * fit_weight, inter.imag * fit_weight])
1158
+ seed_solution, _ = nnls(design, target, maxiter=20000)
1159
+ seed_strengths = seed_solution / scales
1160
+ seeds = sorted(
1161
+ (
1162
+ (seed_strengths[k], candidates[k][0], candidates[k][1])
1163
+ for k in range(len(candidates))
1164
+ if seed_strengths[k] > 0.0
1165
+ ),
1166
+ key=lambda term: -term[0],
1167
+ )[:_GRAPHENE_INTERBAND_POLES]
1168
+ while len(seeds) < _GRAPHENE_INTERBAND_POLES:
1169
+ seeds.append((1e-24, 1.3 * omega_gap, 0.3 * omega_gap))
1170
+
1171
+ initial, lower, upper = [], [], []
1172
+ for strength, omega_0, gamma in seeds:
1173
+ initial += [np.log10(max(strength, 1e-30)), omega_0 / omega_gap, np.log10(max(gamma / omega_gap, 0.03))]
1174
+ lower += [-33.0, 0.3, -1.6]
1175
+ upper += [-13.0, 2.5, 0.6]
1176
+
1177
+ def residual(params):
1178
+ model = np.zeros(n_samples, dtype=complex)
1179
+ for k in range(_GRAPHENE_INTERBAND_POLES):
1180
+ model += 10 ** params[3 * k] * basis(
1181
+ omega, params[3 * k + 1] * omega_gap, 10 ** params[3 * k + 2] * omega_gap
1182
+ )
1183
+ residual_complex = (model - inter) * fit_weight
1184
+ return np.concatenate([residual_complex.real, residual_complex.imag])
1185
+
1186
+ solution = least_squares(
1187
+ residual,
1188
+ np.array(initial),
1189
+ bounds=(np.array(lower), np.array(upper)),
1190
+ method="trf",
1191
+ max_nfev=8000,
1192
+ xtol=1e-14,
1193
+ ftol=1e-14,
1194
+ gtol=1e-14,
1195
+ )
1196
+ terms = tuple(
1197
+ (
1198
+ float(10 ** solution.x[3 * k]),
1199
+ float(solution.x[3 * k + 1] * omega_gap),
1200
+ float(10 ** solution.x[3 * k + 2] * omega_gap),
1201
+ )
1202
+ for k in range(_GRAPHENE_INTERBAND_POLES)
1203
+ )
1204
+
1205
+ fitted = intra + sum(strength * basis(omega, omega_0, gamma) for strength, omega_0, gamma in terms)
1206
+ l2_error = float(np.linalg.norm(fitted - total) / np.linalg.norm(total))
1207
+ if l2_error > 0.03:
1208
+ raise ValueError(
1209
+ f"Graphene interband Lorentz fit reached only {l2_error * 100:.1f}% band error "
1210
+ f"(> 3%) for chemical_potential={mu_c_ev} eV, scattering_time={tau} s, "
1211
+ f"temperature={temperature} K; this operating point lies outside the validated "
1212
+ f"interband range (the fit assumes a well-defined edge with |mu_c| >~ kB*T)."
1213
+ )
1214
+ return terms
1215
+
1216
+
1217
+ class Graphene(Medium2D):
1218
+ """Graphene sheet with the Kubo surface conductivity.
1219
+
1220
+ The intraband (Drude-like) term of the Kubo conductivity is
1221
+
1222
+ ``sigma_intra(omega) = A / (Gamma - i*omega)`` [S]
1223
+
1224
+ with the Drude weight (e^{-i*omega*t} convention)
1225
+
1226
+ ``A = (e^2 * kB * T / (pi * hbar^2)) * (mu_c/(kB*T) + 2*ln(1 + exp(-mu_c/(kB*T))))``
1227
+
1228
+ and the scattering rate ``Gamma = 1 / scattering_time``. ``chemical_potential``
1229
+ is given in eV, ``temperature`` in K, and ``scattering_time`` (the intraband
1230
+ relaxation time tau) in seconds. The DC sheet conductivity is
1231
+ ``A * scattering_time``.
1232
+
1233
+ At runtime the sheet lowers to a surface-current ADE on the snapped Yee
1234
+ layer: the relaxation ``dJ_s/dt + Gamma*J_s = A*E_t`` distributed over the
1235
+ local dual-cell width ``dcell`` is exactly a Drude pole with
1236
+ ``eps0*omega_p^2 = A/dcell``, so the existing native Drude current kernels
1237
+ advance the sheet current with no new per-step work beyond one pole.
1238
+
1239
+ With ``include_interband=True`` the interband term of the Kubo conductivity
1240
+ (the T > 0 principal-value form, ``_kubo_interband_sigma``) is added as a
1241
+ small set of Lorentz sheet terms fitted at construction. The interband
1242
+ transition is a reactive absorption edge at ``hbar*omega = 2*|mu_c|`` whose
1243
+ below-edge conductivity is capacitive (``Im(sigma) < 0``); this does not
1244
+ lower onto a Drude pole (always inductive) but does lower onto Lorentz
1245
+ poles, which the existing native Lorentz current kernels advance. The fitted
1246
+ conductivity matches the analytic Kubo model to within a few percent across
1247
+ the optical band up to the edge. The intraband-only model (the default)
1248
+ stays a single Drude pole and dominates for ``|mu_c| >> hbar*omega``.
1249
+ """
1250
+
1251
+ def __init__(
1252
+ self,
1253
+ *,
1254
+ chemical_potential: float,
1255
+ scattering_time: float,
1256
+ temperature: float = 300.0,
1257
+ include_interband: bool = False,
1258
+ name: str | None = None,
1259
+ ):
1260
+ super().__init__(sigma_s=0.0, name=name)
1261
+ object.__setattr__(
1262
+ self,
1263
+ "chemical_potential",
1264
+ _coerce_nonnegative(chemical_potential, name="chemical_potential"),
1265
+ )
1266
+ object.__setattr__(
1267
+ self, "scattering_time", _coerce_positive(scattering_time, name="scattering_time")
1268
+ )
1269
+ object.__setattr__(self, "temperature", _coerce_positive(temperature, name="temperature"))
1270
+ object.__setattr__(self, "include_interband", bool(include_interband))
1271
+ interband_terms: tuple[tuple[float, float, float], ...] = ()
1272
+ if include_interband:
1273
+ interband_terms = _fit_graphene_interband_lorentz(
1274
+ float(self.chemical_potential),
1275
+ float(self.scattering_time),
1276
+ float(self.temperature),
1277
+ self.intraband_drude_weight,
1278
+ )
1279
+ object.__setattr__(self, "_interband_lorentz_terms", interband_terms)
1280
+
1281
+ @property
1282
+ def intraband_drude_weight(self) -> float:
1283
+ """The Kubo intraband Drude weight ``A`` [S/s]."""
1284
+ thermal_energy = _BOLTZMANN * self.temperature
1285
+ mu_over_kt = self.chemical_potential * _ELEMENTARY_CHARGE / thermal_energy
1286
+ bracket = mu_over_kt + 2.0 * float(np.log1p(np.exp(-mu_over_kt)))
1287
+ prefactor = (
1288
+ _ELEMENTARY_CHARGE * _ELEMENTARY_CHARGE * thermal_energy
1289
+ / (np.pi * _REDUCED_PLANCK * _REDUCED_PLANCK)
1290
+ )
1291
+ return float(prefactor * bracket)
1292
+
1293
+ @property
1294
+ def scattering_rate(self) -> float:
1295
+ """Intraband relaxation rate ``Gamma = 1/tau`` [1/s]."""
1296
+ return 1.0 / self.scattering_time
1297
+
1298
+ @property
1299
+ def characteristic_frequency(self) -> float:
1300
+ """Material rate [Hz] folded into the FDTD auto-dt bound.
1301
+
1302
+ The intraband relaxation rate and, when interband is enabled, the
1303
+ highest interband Lorentz resonance and linewidth all set the fastest
1304
+ material timescale the time step must resolve.
1305
+ """
1306
+ characteristic = self.scattering_rate / (2.0 * np.pi)
1307
+ for _strength, omega_0, gamma in self._interband_lorentz_terms:
1308
+ characteristic = max(characteristic, omega_0 / (2.0 * np.pi), gamma / (2.0 * np.pi))
1309
+ return characteristic
1310
+
1311
+ def sheet_pole_terms(self) -> tuple[tuple[float, float], ...]:
1312
+ return ((self.intraband_drude_weight, self.scattering_rate),)
1313
+
1314
+ def sheet_lorentz_terms(self) -> tuple[tuple[float, float, float], ...]:
1315
+ return self._interband_lorentz_terms
1316
+
1317
+
1318
+ _VACUUM_PERMEABILITY = 4.0e-7 * np.pi # [H/m]
1319
+
1320
+
1321
+ class LossyMetalMedium(Material):
1322
+ """Good-conductor metal intended for a surface-impedance boundary condition (SIBC).
1323
+
1324
+ ``conductivity`` [S/m] is the bulk metal conductivity. The intended runtime
1325
+ treatment replaces the resolved metal interior with the first-order Leontovich
1326
+ boundary condition ``E_t = Z_s(omega) * (n x H)`` on the metal surface, where
1327
+ (with the ``e^{-i*omega*t}`` convention)
1328
+
1329
+ ``Z_s(omega) = (1 - i) / (conductivity * skin_depth(omega))
1330
+ = (1 - i) * sqrt(omega * mu0 / (2 * conductivity))``
1331
+
1332
+ so the skin-depth interior never needs to be meshed.
1333
+
1334
+ The v1 runtime is scoped to normal incidence on a single axis-aligned planar
1335
+ face: the metal must be a ``Box`` slab that spans the full transverse
1336
+ cross-section and sits flush against one domain boundary. The surface
1337
+ impedance is realized as a narrowband series R-L evaluated at the operating
1338
+ frequency (``Z_s(omega0) = R + i*omega0*L_s`` with ``L_s = R/omega0``), so it
1339
+ reproduces the exact Leontovich value at the source frequency; the metal
1340
+ interior is masked and the two tangential E faces are updated each step from
1341
+ the vacuum-side tangential H (see ``compiler/materials.py`` and
1342
+ ``fdtd/runtime/materials.py``). Laterally finite blocks, oblique/curved
1343
+ surfaces, mid-domain slabs, and Bloch runs raise ``NotImplementedError`` with
1344
+ a physical reason; resolve those metals volumetrically with
1345
+ ``Material(sigma_e=...)`` (or ``Material.pec()`` for a lossless shortcut). The
1346
+ analytic helpers ``surface_impedance`` / ``surface_impedance_at_freq`` /
1347
+ ``skin_depth`` are exposed for validation and design work.
1348
+ """
1349
+
1350
+ def __init__(self, *, conductivity: float, name: str | None = None):
1351
+ super().__init__(eps_r=1.0, mu_r=1.0, sigma_e=0.0, name=name)
1352
+ object.__setattr__(
1353
+ self, "conductivity", _coerce_positive(conductivity, name="conductivity")
1354
+ )
1355
+
1356
+ @property
1357
+ def is_lossy_metal(self) -> bool:
1358
+ return True
1359
+
1360
+ def skin_depth(self, frequency: float) -> float:
1361
+ """Skin depth ``sqrt(2 / (omega * mu0 * sigma))`` [m] at ``frequency`` [Hz]."""
1362
+ omega = 2.0 * np.pi * _coerce_frequency(frequency, name="frequency")
1363
+ return float(np.sqrt(2.0 / (omega * _VACUUM_PERMEABILITY * self.conductivity)))
1364
+
1365
+ def surface_impedance(self, angular_frequency: float) -> complex:
1366
+ """Leontovich surface impedance ``Z_s(omega)`` [ohm] (e^{-i*omega*t} convention)."""
1367
+ omega = float(angular_frequency)
1368
+ if omega <= 0.0:
1369
+ raise ValueError("surface_impedance requires angular_frequency > 0.")
1370
+ magnitude = np.sqrt(omega * _VACUUM_PERMEABILITY / (2.0 * self.conductivity))
1371
+ return complex(magnitude, -magnitude)
1372
+
1373
+ def surface_impedance_at_freq(self, frequency: float) -> complex:
1374
+ return self.surface_impedance(2.0 * np.pi * _coerce_frequency(frequency, name="frequency"))
1375
+
1376
+
1377
+ class PerturbationMedium(Material):
1378
+ """A base material whose permittivity is shifted by an external perturbation field.
1379
+
1380
+ ``eps(x) = eps_base + eps_sensitivity * perturbation(x)`` where
1381
+ ``perturbation`` is a 3D torch tensor (for example a temperature or
1382
+ carrier-density change from an external multiphysics solve). The
1383
+ perturbation grid maps onto the ``Box`` extent of the structure the
1384
+ material is attached to with the same lower-inclusive / upper-exclusive
1385
+ node coverage and trilinear resampling as ``MaterialRegion.density``, is
1386
+ composed multiplicatively with the structure's soft geometry occupancy,
1387
+ and is applied at compile time before ADE templating, so dispersive base
1388
+ materials see the shifted ``eps_inf``.
1389
+
1390
+ The map is PyTorch-native and differentiable: a ``perturbation`` tensor
1391
+ with ``requires_grad=True`` is discovered as a trainable material input by
1392
+ the gradient bridges, so simulation outputs can be differentiated with
1393
+ respect to the perturbation field.
1394
+ """
1395
+
1396
+ def __init__(
1397
+ self,
1398
+ base: Material,
1399
+ *,
1400
+ perturbation: torch.Tensor,
1401
+ eps_sensitivity: float = 1.0,
1402
+ name: str | None = None,
1403
+ ):
1404
+ if not isinstance(base, CoreMaterial):
1405
+ raise TypeError("PerturbationMedium base must be a Material.")
1406
+ if bool(getattr(base, "is_pec", False)):
1407
+ raise ValueError("PerturbationMedium cannot wrap a PEC base material.")
1408
+ if not torch.is_tensor(perturbation):
1409
+ raise TypeError("PerturbationMedium perturbation must be a torch.Tensor.")
1410
+ if perturbation.ndim != 3:
1411
+ raise ValueError(
1412
+ f"PerturbationMedium perturbation must be a 3D tensor, got ndim={perturbation.ndim}."
1413
+ )
1414
+ with torch.no_grad():
1415
+ if not torch.isfinite(perturbation).all():
1416
+ raise ValueError("PerturbationMedium perturbation must be finite everywhere.")
1417
+ sensitivity = _coerce_real_scalar(eps_sensitivity, name="eps_sensitivity")
1418
+
1419
+ super().__init__(
1420
+ eps_r=base.eps_r,
1421
+ mu_r=base.mu_r,
1422
+ sigma_e=base.sigma_e,
1423
+ name=name if name is not None else base.name,
1424
+ debye_poles=getattr(base, "debye_poles", ()),
1425
+ drude_poles=getattr(base, "drude_poles", ()),
1426
+ lorentz_poles=getattr(base, "lorentz_poles", ()),
1427
+ mu_debye_poles=getattr(base, "mu_debye_poles", ()),
1428
+ mu_drude_poles=getattr(base, "mu_drude_poles", ()),
1429
+ mu_lorentz_poles=getattr(base, "mu_lorentz_poles", ()),
1430
+ epsilon_tensor=getattr(base, "epsilon_tensor", None),
1431
+ mu_tensor=getattr(base, "mu_tensor", None),
1432
+ sigma_e_tensor=getattr(base, "sigma_e_tensor", None),
1433
+ kerr_chi3=getattr(base, "kerr_chi3", None),
1434
+ nonlinearity=getattr(base, "nonlinearity", ()),
1435
+ modulation=getattr(base, "modulation", None),
1436
+ )
1437
+ object.__setattr__(self, "perturbation", perturbation)
1438
+ object.__setattr__(self, "eps_sensitivity", sensitivity)
1439
+
1440
+ if isinstance(self.epsilon_tensor, Tensor3x3):
1441
+ raise NotImplementedError(
1442
+ "PerturbationMedium applies its scalar sensitivity field as an isotropic delta_eps to a "
1443
+ "scalar or axis-aligned (DiagonalTensor3) base permittivity; a full off-diagonal Tensor3x3 "
1444
+ "base has no single principal-axis eps to perturb, so the perturbation direction in the "
1445
+ "coupled tensor is ambiguous. Use a scalar or DiagonalTensor3 base permittivity."
1446
+ )
1447
+ base_eps = self.epsilon_tensor if self.epsilon_tensor is not None else self.eps_r
1448
+ eps_floor = min(base_eps.as_tuple()) if isinstance(base_eps, DiagonalTensor3) else float(base_eps)
1449
+ with torch.no_grad():
1450
+ delta_floor = float((sensitivity * perturbation).min())
1451
+ if eps_floor + delta_floor <= 0.0:
1452
+ raise ValueError(
1453
+ "PerturbationMedium would produce a non-positive permittivity: base eps "
1454
+ f"{eps_floor} + minimum perturbation contribution {delta_floor} <= 0."
1455
+ )
1456
+
1457
+ def evaluate_at_frequency(self, frequency: float) -> FrequencyMaterialSample:
1458
+ raise NotImplementedError(
1459
+ "PerturbationMedium frequency evaluation is spatially varying; evaluate the "
1460
+ "compiled scene material model (Scene.compile_relative_materials) instead."
1461
+ )
1462
+
1463
+ def relative_permittivity(self, frequency: float) -> complex:
1464
+ raise NotImplementedError(
1465
+ "relative_permittivity() is not defined for PerturbationMedium; evaluate the "
1466
+ "compiled scene material model (Scene.compile_relative_materials) instead."
1467
+ )