witwin-maxwell 0.3.0__py3-none-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- witwin/maxwell/__init__.py +164 -0
- witwin/maxwell/adapters/__init__.py +1 -0
- witwin/maxwell/adapters/gds.py +203 -0
- witwin/maxwell/adapters/tidy3d.py +1669 -0
- witwin/maxwell/adjoint_inputs.py +70 -0
- witwin/maxwell/compiler/__init__.py +11 -0
- witwin/maxwell/compiler/materials.py +1355 -0
- witwin/maxwell/compiler/monitors.py +164 -0
- witwin/maxwell/compiler/sources.py +222 -0
- witwin/maxwell/fdfd/__init__.py +3 -0
- witwin/maxwell/fdfd/adjoint/__init__.py +3 -0
- witwin/maxwell/fdfd/adjoint/bridge.py +180 -0
- witwin/maxwell/fdfd/ams.py +300 -0
- witwin/maxwell/fdfd/krylov.py +280 -0
- witwin/maxwell/fdfd/plotting.py +110 -0
- witwin/maxwell/fdfd/postprocess.py +33 -0
- witwin/maxwell/fdfd/solver.py +1057 -0
- witwin/maxwell/fdtd/__init__.py +4 -0
- witwin/maxwell/fdtd/adjoint/__init__.py +21 -0
- witwin/maxwell/fdtd/adjoint/bridge.py +746 -0
- witwin/maxwell/fdtd/adjoint/capabilities.py +29 -0
- witwin/maxwell/fdtd/adjoint/core.py +3614 -0
- witwin/maxwell/fdtd/adjoint/dispatch.py +684 -0
- witwin/maxwell/fdtd/adjoint/native.py +2561 -0
- witwin/maxwell/fdtd/adjoint/profiler.py +195 -0
- witwin/maxwell/fdtd/adjoint/reverse_common.py +207 -0
- witwin/maxwell/fdtd/adjoint/seeds.py +524 -0
- witwin/maxwell/fdtd/boundary/__init__.py +37 -0
- witwin/maxwell/fdtd/boundary/bloch.py +90 -0
- witwin/maxwell/fdtd/boundary/common.py +41 -0
- witwin/maxwell/fdtd/boundary/cpml.py +527 -0
- witwin/maxwell/fdtd/boundary/runtime.py +209 -0
- witwin/maxwell/fdtd/checkpoint.py +227 -0
- witwin/maxwell/fdtd/coords.py +32 -0
- witwin/maxwell/fdtd/cuda/__init__.py +11 -0
- witwin/maxwell/fdtd/cuda/backend.py +3532 -0
- witwin/maxwell/fdtd/cuda/build.py +292 -0
- witwin/maxwell/fdtd/cuda/extension.cpp +2053 -0
- witwin/maxwell/fdtd/cuda/kernels/adjoint.cu +5290 -0
- witwin/maxwell/fdtd/cuda/kernels/aniso.cu +1704 -0
- witwin/maxwell/fdtd/cuda/kernels/boundary.cu +424 -0
- witwin/maxwell/fdtd/cuda/kernels/common.cu +74 -0
- witwin/maxwell/fdtd/cuda/kernels/common.cuh +82 -0
- witwin/maxwell/fdtd/cuda/kernels/dispersive.cu +782 -0
- witwin/maxwell/fdtd/cuda/kernels/electric.cu +3613 -0
- witwin/maxwell/fdtd/cuda/kernels/magnetic.cu +781 -0
- witwin/maxwell/fdtd/cuda/kernels/observers.cu +347 -0
- witwin/maxwell/fdtd/cuda/kernels/projection.cu +211 -0
- witwin/maxwell/fdtd/cuda/kernels/sources.cu +1482 -0
- witwin/maxwell/fdtd/cuda/kernels/spectral.cu +297 -0
- witwin/maxwell/fdtd/cuda/launch.h +23 -0
- witwin/maxwell/fdtd/cuda/prebuilt/witwin_maxwell_fdtd_cuda.pyd +0 -0
- witwin/maxwell/fdtd/cuda/runtime/__init__.py +6 -0
- witwin/maxwell/fdtd/cuda/runtime/dispatcher.py +6 -0
- witwin/maxwell/fdtd/cuda/runtime/graph.py +33 -0
- witwin/maxwell/fdtd/cuda/tensors.h +35 -0
- witwin/maxwell/fdtd/dispersion.py +48 -0
- witwin/maxwell/fdtd/evaluation.py +290 -0
- witwin/maxwell/fdtd/excitation/__init__.py +27 -0
- witwin/maxwell/fdtd/excitation/injection.py +1102 -0
- witwin/maxwell/fdtd/excitation/modes.py +1675 -0
- witwin/maxwell/fdtd/excitation/spatial.py +469 -0
- witwin/maxwell/fdtd/excitation/temporal.py +510 -0
- witwin/maxwell/fdtd/excitation/tfsf_apply.py +264 -0
- witwin/maxwell/fdtd/excitation/tfsf_common.py +421 -0
- witwin/maxwell/fdtd/excitation/tfsf_specs.py +490 -0
- witwin/maxwell/fdtd/excitation/tfsf_state.py +778 -0
- witwin/maxwell/fdtd/io.py +31 -0
- witwin/maxwell/fdtd/material_pullback.py +371 -0
- witwin/maxwell/fdtd/meshing/__init__.py +3 -0
- witwin/maxwell/fdtd/meshing/autogrid.py +335 -0
- witwin/maxwell/fdtd/observers.py +1453 -0
- witwin/maxwell/fdtd/plotting.py +121 -0
- witwin/maxwell/fdtd/postprocess.py +205 -0
- witwin/maxwell/fdtd/runtime/__init__.py +119 -0
- witwin/maxwell/fdtd/runtime/initialization.py +236 -0
- witwin/maxwell/fdtd/runtime/materials.py +1563 -0
- witwin/maxwell/fdtd/runtime/module_cache.py +31 -0
- witwin/maxwell/fdtd/runtime/spectral.py +581 -0
- witwin/maxwell/fdtd/runtime/stepping.py +1987 -0
- witwin/maxwell/fdtd/solver.py +502 -0
- witwin/maxwell/geometry/__init__.py +5 -0
- witwin/maxwell/geometry/polyslab.py +299 -0
- witwin/maxwell/materials.py +3 -0
- witwin/maxwell/media.py +1467 -0
- witwin/maxwell/monitors.py +743 -0
- witwin/maxwell/ports.py +113 -0
- witwin/maxwell/postprocess/RCS.py +139 -0
- witwin/maxwell/postprocess/__init__.py +42 -0
- witwin/maxwell/postprocess/diffraction.py +219 -0
- witwin/maxwell/postprocess/directivity.py +152 -0
- witwin/maxwell/postprocess/emission.py +67 -0
- witwin/maxwell/postprocess/modal.py +324 -0
- witwin/maxwell/postprocess/nfft.py +266 -0
- witwin/maxwell/postprocess/scattering_parameters.py +358 -0
- witwin/maxwell/postprocess/stratton_chu.py +1193 -0
- witwin/maxwell/result.py +967 -0
- witwin/maxwell/scene.py +1338 -0
- witwin/maxwell/simulation.py +659 -0
- witwin/maxwell/sources.py +917 -0
- witwin/maxwell/visualization/__init__.py +28 -0
- witwin/maxwell/visualization/interactive.py +484 -0
- witwin/maxwell/visualization/plots.py +568 -0
- witwin_maxwell-0.3.0.dist-info/METADATA +184 -0
- witwin_maxwell-0.3.0.dist-info/RECORD +107 -0
- witwin_maxwell-0.3.0.dist-info/WHEEL +4 -0
- witwin_maxwell-0.3.0.dist-info/licenses/LICENSE +13 -0
witwin/maxwell/media.py
ADDED
|
@@ -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
|
+
)
|