witwin-maxwell 0.3.0__py3-none-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- witwin/maxwell/__init__.py +164 -0
- witwin/maxwell/adapters/__init__.py +1 -0
- witwin/maxwell/adapters/gds.py +203 -0
- witwin/maxwell/adapters/tidy3d.py +1669 -0
- witwin/maxwell/adjoint_inputs.py +70 -0
- witwin/maxwell/compiler/__init__.py +11 -0
- witwin/maxwell/compiler/materials.py +1355 -0
- witwin/maxwell/compiler/monitors.py +164 -0
- witwin/maxwell/compiler/sources.py +222 -0
- witwin/maxwell/fdfd/__init__.py +3 -0
- witwin/maxwell/fdfd/adjoint/__init__.py +3 -0
- witwin/maxwell/fdfd/adjoint/bridge.py +180 -0
- witwin/maxwell/fdfd/ams.py +300 -0
- witwin/maxwell/fdfd/krylov.py +280 -0
- witwin/maxwell/fdfd/plotting.py +110 -0
- witwin/maxwell/fdfd/postprocess.py +33 -0
- witwin/maxwell/fdfd/solver.py +1057 -0
- witwin/maxwell/fdtd/__init__.py +4 -0
- witwin/maxwell/fdtd/adjoint/__init__.py +21 -0
- witwin/maxwell/fdtd/adjoint/bridge.py +746 -0
- witwin/maxwell/fdtd/adjoint/capabilities.py +29 -0
- witwin/maxwell/fdtd/adjoint/core.py +3614 -0
- witwin/maxwell/fdtd/adjoint/dispatch.py +684 -0
- witwin/maxwell/fdtd/adjoint/native.py +2561 -0
- witwin/maxwell/fdtd/adjoint/profiler.py +195 -0
- witwin/maxwell/fdtd/adjoint/reverse_common.py +207 -0
- witwin/maxwell/fdtd/adjoint/seeds.py +524 -0
- witwin/maxwell/fdtd/boundary/__init__.py +37 -0
- witwin/maxwell/fdtd/boundary/bloch.py +90 -0
- witwin/maxwell/fdtd/boundary/common.py +41 -0
- witwin/maxwell/fdtd/boundary/cpml.py +527 -0
- witwin/maxwell/fdtd/boundary/runtime.py +209 -0
- witwin/maxwell/fdtd/checkpoint.py +227 -0
- witwin/maxwell/fdtd/coords.py +32 -0
- witwin/maxwell/fdtd/cuda/__init__.py +11 -0
- witwin/maxwell/fdtd/cuda/backend.py +3532 -0
- witwin/maxwell/fdtd/cuda/build.py +292 -0
- witwin/maxwell/fdtd/cuda/extension.cpp +2053 -0
- witwin/maxwell/fdtd/cuda/kernels/adjoint.cu +5290 -0
- witwin/maxwell/fdtd/cuda/kernels/aniso.cu +1704 -0
- witwin/maxwell/fdtd/cuda/kernels/boundary.cu +424 -0
- witwin/maxwell/fdtd/cuda/kernels/common.cu +74 -0
- witwin/maxwell/fdtd/cuda/kernels/common.cuh +82 -0
- witwin/maxwell/fdtd/cuda/kernels/dispersive.cu +782 -0
- witwin/maxwell/fdtd/cuda/kernels/electric.cu +3613 -0
- witwin/maxwell/fdtd/cuda/kernels/magnetic.cu +781 -0
- witwin/maxwell/fdtd/cuda/kernels/observers.cu +347 -0
- witwin/maxwell/fdtd/cuda/kernels/projection.cu +211 -0
- witwin/maxwell/fdtd/cuda/kernels/sources.cu +1482 -0
- witwin/maxwell/fdtd/cuda/kernels/spectral.cu +297 -0
- witwin/maxwell/fdtd/cuda/launch.h +23 -0
- witwin/maxwell/fdtd/cuda/prebuilt/witwin_maxwell_fdtd_cuda.pyd +0 -0
- witwin/maxwell/fdtd/cuda/runtime/__init__.py +6 -0
- witwin/maxwell/fdtd/cuda/runtime/dispatcher.py +6 -0
- witwin/maxwell/fdtd/cuda/runtime/graph.py +33 -0
- witwin/maxwell/fdtd/cuda/tensors.h +35 -0
- witwin/maxwell/fdtd/dispersion.py +48 -0
- witwin/maxwell/fdtd/evaluation.py +290 -0
- witwin/maxwell/fdtd/excitation/__init__.py +27 -0
- witwin/maxwell/fdtd/excitation/injection.py +1102 -0
- witwin/maxwell/fdtd/excitation/modes.py +1675 -0
- witwin/maxwell/fdtd/excitation/spatial.py +469 -0
- witwin/maxwell/fdtd/excitation/temporal.py +510 -0
- witwin/maxwell/fdtd/excitation/tfsf_apply.py +264 -0
- witwin/maxwell/fdtd/excitation/tfsf_common.py +421 -0
- witwin/maxwell/fdtd/excitation/tfsf_specs.py +490 -0
- witwin/maxwell/fdtd/excitation/tfsf_state.py +778 -0
- witwin/maxwell/fdtd/io.py +31 -0
- witwin/maxwell/fdtd/material_pullback.py +371 -0
- witwin/maxwell/fdtd/meshing/__init__.py +3 -0
- witwin/maxwell/fdtd/meshing/autogrid.py +335 -0
- witwin/maxwell/fdtd/observers.py +1453 -0
- witwin/maxwell/fdtd/plotting.py +121 -0
- witwin/maxwell/fdtd/postprocess.py +205 -0
- witwin/maxwell/fdtd/runtime/__init__.py +119 -0
- witwin/maxwell/fdtd/runtime/initialization.py +236 -0
- witwin/maxwell/fdtd/runtime/materials.py +1563 -0
- witwin/maxwell/fdtd/runtime/module_cache.py +31 -0
- witwin/maxwell/fdtd/runtime/spectral.py +581 -0
- witwin/maxwell/fdtd/runtime/stepping.py +1987 -0
- witwin/maxwell/fdtd/solver.py +502 -0
- witwin/maxwell/geometry/__init__.py +5 -0
- witwin/maxwell/geometry/polyslab.py +299 -0
- witwin/maxwell/materials.py +3 -0
- witwin/maxwell/media.py +1467 -0
- witwin/maxwell/monitors.py +743 -0
- witwin/maxwell/ports.py +113 -0
- witwin/maxwell/postprocess/RCS.py +139 -0
- witwin/maxwell/postprocess/__init__.py +42 -0
- witwin/maxwell/postprocess/diffraction.py +219 -0
- witwin/maxwell/postprocess/directivity.py +152 -0
- witwin/maxwell/postprocess/emission.py +67 -0
- witwin/maxwell/postprocess/modal.py +324 -0
- witwin/maxwell/postprocess/nfft.py +266 -0
- witwin/maxwell/postprocess/scattering_parameters.py +358 -0
- witwin/maxwell/postprocess/stratton_chu.py +1193 -0
- witwin/maxwell/result.py +967 -0
- witwin/maxwell/scene.py +1338 -0
- witwin/maxwell/simulation.py +659 -0
- witwin/maxwell/sources.py +917 -0
- witwin/maxwell/visualization/__init__.py +28 -0
- witwin/maxwell/visualization/interactive.py +484 -0
- witwin/maxwell/visualization/plots.py +568 -0
- witwin_maxwell-0.3.0.dist-info/METADATA +184 -0
- witwin_maxwell-0.3.0.dist-info/RECORD +107 -0
- witwin_maxwell-0.3.0.dist-info/WHEEL +4 -0
- witwin_maxwell-0.3.0.dist-info/licenses/LICENSE +13 -0
|
@@ -0,0 +1,917 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, replace
|
|
4
|
+
import math
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
SOURCE_TIME_KIND_CW = 0
|
|
10
|
+
SOURCE_TIME_KIND_GAUSSIAN_PULSE = 1
|
|
11
|
+
SOURCE_TIME_KIND_RICKER_WAVELET = 2
|
|
12
|
+
SOURCE_TIME_KIND_CUSTOM = 3
|
|
13
|
+
POINT_DIPOLE_REFERENCE_WIDTH = 0.02
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _require_length3(name, value) -> tuple[float, float, float]:
|
|
17
|
+
if len(value) != 3:
|
|
18
|
+
raise ValueError(f"{name} must contain exactly three values.")
|
|
19
|
+
return tuple(float(component) for component in value)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _normalize_vector(name, value) -> tuple[float, float, float]:
|
|
23
|
+
vector = _require_length3(name, value)
|
|
24
|
+
norm = math.sqrt(sum(component * component for component in vector))
|
|
25
|
+
if norm <= 0.0:
|
|
26
|
+
raise ValueError(f"{name} must be non-zero.")
|
|
27
|
+
return tuple(component / norm for component in vector)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _require_beam_waist_pair(value) -> tuple[float, float]:
|
|
31
|
+
if len(value) != 2:
|
|
32
|
+
raise ValueError("beam_waist must contain exactly two values (w0_u, w0_v).")
|
|
33
|
+
waist = tuple(float(component) for component in value)
|
|
34
|
+
if any(component <= 0.0 for component in waist):
|
|
35
|
+
raise ValueError("beam_waist components must be > 0.")
|
|
36
|
+
return waist
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _normalize_axis(axis) -> str | None:
|
|
40
|
+
if axis is None:
|
|
41
|
+
return None
|
|
42
|
+
normalized = str(axis).lower()
|
|
43
|
+
if normalized not in {"x", "y", "z"}:
|
|
44
|
+
raise ValueError("injection_axis must be 'x', 'y', 'z', or None.")
|
|
45
|
+
return normalized
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _normalize_point_dipole_profile(value) -> str:
|
|
49
|
+
normalized = str(value).lower()
|
|
50
|
+
if normalized not in {"gaussian", "ideal"}:
|
|
51
|
+
raise ValueError("profile must be 'gaussian' or 'ideal'.")
|
|
52
|
+
return normalized
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _normalize_mode_direction(value) -> str:
|
|
56
|
+
normalized = str(value).strip()
|
|
57
|
+
if normalized not in {"+", "-"}:
|
|
58
|
+
raise ValueError("direction must be '+' or '-'.")
|
|
59
|
+
return normalized
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _require_mode_source_size(value) -> tuple[float, float, float]:
|
|
63
|
+
size = _require_length3("size", value)
|
|
64
|
+
if any(component < 0.0 for component in size):
|
|
65
|
+
raise ValueError("size components must be >= 0.")
|
|
66
|
+
return size
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _resolve_mode_source_normal_axis(size) -> str:
|
|
70
|
+
zero_axes = [axis for axis, component in zip("xyz", size) if abs(float(component)) <= 1e-12]
|
|
71
|
+
if len(zero_axes) != 1:
|
|
72
|
+
raise ValueError("ModeSource size must contain exactly one zero component to define the source plane.")
|
|
73
|
+
return zero_axes[0]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _resolve_mode_source_polarization_axis(normal_axis: str, size, value) -> str:
|
|
77
|
+
tangential_axes = tuple(axis for axis in "xyz" if axis != normal_axis)
|
|
78
|
+
if value is None or (isinstance(value, str) and str(value).lower() == "auto"):
|
|
79
|
+
tangential_sizes = {axis: float(size["xyz".index(axis)]) for axis in tangential_axes}
|
|
80
|
+
return max(tangential_axes, key=lambda axis: (tangential_sizes[axis], axis))
|
|
81
|
+
|
|
82
|
+
if isinstance(value, str):
|
|
83
|
+
token = value.lower()
|
|
84
|
+
if token in {"ex", "ey", "ez"}:
|
|
85
|
+
axis = token[-1]
|
|
86
|
+
elif token in {"x", "y", "z"}:
|
|
87
|
+
axis = token
|
|
88
|
+
else:
|
|
89
|
+
raise ValueError("polarization must be 'auto', Ex/Ey/Ez, x/y/z, or an axis-aligned vector.")
|
|
90
|
+
else:
|
|
91
|
+
vector = polarization_vector(value)
|
|
92
|
+
active = [axis for axis, component in zip("xyz", vector) if abs(float(component)) > 1e-9]
|
|
93
|
+
if len(active) != 1:
|
|
94
|
+
raise ValueError("ModeSource polarization vector must be axis-aligned.")
|
|
95
|
+
axis = active[0]
|
|
96
|
+
|
|
97
|
+
if axis == normal_axis:
|
|
98
|
+
raise ValueError("ModeSource polarization must be tangential to the source plane.")
|
|
99
|
+
return axis
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _normalize_bend(bend_radius, bend_axis, normal_axis: str) -> tuple[float | None, str | None]:
|
|
103
|
+
"""Validate the cylindrical-bend parameters of a mode plane.
|
|
104
|
+
|
|
105
|
+
A bent (curved-waveguide) port keeps its cross-section plane axis-aligned but
|
|
106
|
+
solves the guided mode with the Heiblum-Harris conformal transform, which maps
|
|
107
|
+
a bend of signed radius ``bend_radius`` into an equivalent straight guide whose
|
|
108
|
+
index is graded along the in-plane radial direction. ``bend_axis`` is the
|
|
109
|
+
cylinder (rotation) axis: it must be one of the two axes tangential to the mode
|
|
110
|
+
plane, and the remaining tangential axis becomes the radial direction. Returns
|
|
111
|
+
``(None, None)`` for a straight port and ``(radius, axis)`` for a bent one.
|
|
112
|
+
"""
|
|
113
|
+
if bend_radius is None:
|
|
114
|
+
if bend_axis is not None:
|
|
115
|
+
raise ValueError("bend_axis requires bend_radius to be set.")
|
|
116
|
+
return None, None
|
|
117
|
+
radius = float(bend_radius)
|
|
118
|
+
if radius == 0.0 or not math.isfinite(radius):
|
|
119
|
+
raise ValueError("bend_radius must be a finite non-zero length (a straight port omits it).")
|
|
120
|
+
if bend_axis is None:
|
|
121
|
+
raise ValueError("bend_radius requires bend_axis, the cylinder (rotation) axis of the bend.")
|
|
122
|
+
axis = str(bend_axis).lower()
|
|
123
|
+
if axis not in {"x", "y", "z"}:
|
|
124
|
+
raise ValueError("bend_axis must be 'x', 'y', or 'z'.")
|
|
125
|
+
if axis == normal_axis:
|
|
126
|
+
raise ValueError(
|
|
127
|
+
"bend_axis must be tangential to the mode plane (perpendicular to the propagation axis), "
|
|
128
|
+
f"but it equals the propagation axis {normal_axis!r}."
|
|
129
|
+
)
|
|
130
|
+
return radius, axis
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def polarization_vector(value) -> tuple[float, float, float]:
|
|
134
|
+
if isinstance(value, str):
|
|
135
|
+
token = value.lower()
|
|
136
|
+
mapping = {
|
|
137
|
+
"ex": (1.0, 0.0, 0.0),
|
|
138
|
+
"ey": (0.0, 1.0, 0.0),
|
|
139
|
+
"ez": (0.0, 0.0, 1.0),
|
|
140
|
+
}
|
|
141
|
+
if token not in mapping:
|
|
142
|
+
raise ValueError("polarization must be Ex, Ey, Ez, or a length-3 vector.")
|
|
143
|
+
return mapping[token]
|
|
144
|
+
return _normalize_vector("polarization", value)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _validate_transverse_polarization(
|
|
148
|
+
direction,
|
|
149
|
+
polarization,
|
|
150
|
+
*,
|
|
151
|
+
direction_name="direction",
|
|
152
|
+
) -> tuple[float, float, float]:
|
|
153
|
+
direction = _normalize_vector(direction_name, direction)
|
|
154
|
+
polarization = polarization_vector(polarization)
|
|
155
|
+
dot = sum(a * b for a, b in zip(direction, polarization))
|
|
156
|
+
if abs(dot) > 1e-6:
|
|
157
|
+
raise ValueError("polarization must be orthogonal to direction.")
|
|
158
|
+
return direction, polarization
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _validate_frequency(name, value):
|
|
162
|
+
frequency = float(value)
|
|
163
|
+
if frequency <= 0.0:
|
|
164
|
+
raise ValueError(f"{name} must be > 0.")
|
|
165
|
+
return frequency
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _validate_amplitude(value):
|
|
169
|
+
return float(value)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass(frozen=True)
|
|
173
|
+
class CW:
|
|
174
|
+
frequency: float
|
|
175
|
+
amplitude: float = 1.0
|
|
176
|
+
phase: float = 0.0
|
|
177
|
+
kind: str = "cw"
|
|
178
|
+
|
|
179
|
+
def __post_init__(self):
|
|
180
|
+
object.__setattr__(self, "frequency", _validate_frequency("frequency", self.frequency))
|
|
181
|
+
object.__setattr__(self, "amplitude", _validate_amplitude(self.amplitude))
|
|
182
|
+
object.__setattr__(self, "phase", float(self.phase))
|
|
183
|
+
object.__setattr__(self, "kind", "cw")
|
|
184
|
+
|
|
185
|
+
def evaluate(self, t: float) -> float:
|
|
186
|
+
return self.amplitude * math.cos(2.0 * math.pi * self.frequency * float(t) + self.phase)
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def characteristic_frequency(self) -> float:
|
|
190
|
+
return self.frequency
|
|
191
|
+
|
|
192
|
+
@property
|
|
193
|
+
def delay(self) -> float:
|
|
194
|
+
return 0.0
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def settling_time(self) -> float:
|
|
198
|
+
return 0.0
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass(frozen=True)
|
|
202
|
+
class GaussianPulse:
|
|
203
|
+
frequency: float
|
|
204
|
+
fwidth: float
|
|
205
|
+
amplitude: float = 1.0
|
|
206
|
+
phase: float = 0.0
|
|
207
|
+
delay: float | None = None
|
|
208
|
+
kind: str = "gaussian_pulse"
|
|
209
|
+
|
|
210
|
+
def __post_init__(self):
|
|
211
|
+
frequency = _validate_frequency("frequency", self.frequency)
|
|
212
|
+
fwidth = _validate_frequency("fwidth", self.fwidth)
|
|
213
|
+
sigma_t = 1.0 / (2.0 * math.pi * fwidth)
|
|
214
|
+
delay = 6.0 * sigma_t if self.delay is None else float(self.delay)
|
|
215
|
+
object.__setattr__(self, "frequency", frequency)
|
|
216
|
+
object.__setattr__(self, "fwidth", fwidth)
|
|
217
|
+
object.__setattr__(self, "amplitude", _validate_amplitude(self.amplitude))
|
|
218
|
+
object.__setattr__(self, "phase", float(self.phase))
|
|
219
|
+
object.__setattr__(self, "delay", delay)
|
|
220
|
+
object.__setattr__(self, "kind", "gaussian_pulse")
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def sigma_t(self) -> float:
|
|
224
|
+
return 1.0 / (2.0 * math.pi * self.fwidth)
|
|
225
|
+
|
|
226
|
+
def evaluate(self, t: float) -> float:
|
|
227
|
+
tau = float(t) - float(self.delay)
|
|
228
|
+
envelope = math.exp(-0.5 * (tau / self.sigma_t) ** 2)
|
|
229
|
+
return self.amplitude * envelope * math.cos(2.0 * math.pi * self.frequency * tau + self.phase)
|
|
230
|
+
|
|
231
|
+
@property
|
|
232
|
+
def characteristic_frequency(self) -> float:
|
|
233
|
+
return max(self.frequency, self.frequency + 3.0 * self.fwidth)
|
|
234
|
+
|
|
235
|
+
@property
|
|
236
|
+
def settling_time(self) -> float:
|
|
237
|
+
return float(self.delay) + 6.0 * self.sigma_t
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@dataclass(frozen=True)
|
|
241
|
+
class RickerWavelet:
|
|
242
|
+
frequency: float
|
|
243
|
+
amplitude: float = 1.0
|
|
244
|
+
delay: float | None = None
|
|
245
|
+
kind: str = "ricker_wavelet"
|
|
246
|
+
|
|
247
|
+
def __post_init__(self):
|
|
248
|
+
frequency = _validate_frequency("frequency", self.frequency)
|
|
249
|
+
delay = 4.0 / (math.pi * frequency) if self.delay is None else float(self.delay)
|
|
250
|
+
object.__setattr__(self, "frequency", frequency)
|
|
251
|
+
object.__setattr__(self, "amplitude", _validate_amplitude(self.amplitude))
|
|
252
|
+
object.__setattr__(self, "delay", delay)
|
|
253
|
+
object.__setattr__(self, "kind", "ricker_wavelet")
|
|
254
|
+
|
|
255
|
+
def evaluate(self, t: float) -> float:
|
|
256
|
+
tau = float(t) - float(self.delay)
|
|
257
|
+
alpha = math.pi * self.frequency * tau
|
|
258
|
+
alpha_sq = alpha * alpha
|
|
259
|
+
return self.amplitude * (1.0 - 2.0 * alpha_sq) * math.exp(-alpha_sq)
|
|
260
|
+
|
|
261
|
+
@property
|
|
262
|
+
def phase(self) -> float:
|
|
263
|
+
return 0.0
|
|
264
|
+
|
|
265
|
+
@property
|
|
266
|
+
def fwidth(self) -> float:
|
|
267
|
+
return 0.0
|
|
268
|
+
|
|
269
|
+
@property
|
|
270
|
+
def characteristic_frequency(self) -> float:
|
|
271
|
+
return 3.0 * self.frequency
|
|
272
|
+
|
|
273
|
+
@property
|
|
274
|
+
def settling_time(self) -> float:
|
|
275
|
+
return float(self.delay) + 4.0 / max(self.frequency, 1e-30)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _table_characteristic_frequency(times: np.ndarray, amplitudes: np.ndarray) -> float:
|
|
279
|
+
spacing = np.diff(times)
|
|
280
|
+
mean_dt = float(np.mean(spacing))
|
|
281
|
+
if mean_dt <= 0.0:
|
|
282
|
+
return 0.0
|
|
283
|
+
spectrum = np.abs(np.fft.rfft(amplitudes))
|
|
284
|
+
freqs = np.fft.rfftfreq(times.size, d=mean_dt)
|
|
285
|
+
if spectrum.size <= 1:
|
|
286
|
+
return 0.0
|
|
287
|
+
peak_index = int(np.argmax(spectrum[1:]) + 1)
|
|
288
|
+
return float(freqs[peak_index])
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@dataclass(frozen=True)
|
|
292
|
+
class CustomSourceTime:
|
|
293
|
+
"""Arbitrary temporal waveform.
|
|
294
|
+
|
|
295
|
+
Construct either from a sampled table ``CustomSourceTime(times, amplitudes)``
|
|
296
|
+
or from a callable ``CustomSourceTime(fn)``. The signal is evaluated on the
|
|
297
|
+
solver time grid ``n * dt`` through the Python scalar injection path, so the
|
|
298
|
+
native CUDA time-shifted kernel is not involved.
|
|
299
|
+
"""
|
|
300
|
+
|
|
301
|
+
times: tuple[float, ...] | None = None
|
|
302
|
+
amplitudes: tuple[float, ...] | None = None
|
|
303
|
+
amplitude: float = 1.0
|
|
304
|
+
kind: str = "custom"
|
|
305
|
+
|
|
306
|
+
def __init__(self, times=None, amplitudes=None, *, amplitude=1.0, characteristic_frequency=None):
|
|
307
|
+
fn = None
|
|
308
|
+
table_times = None
|
|
309
|
+
table_amplitudes = None
|
|
310
|
+
if callable(times):
|
|
311
|
+
if amplitudes is not None:
|
|
312
|
+
raise ValueError("CustomSourceTime(fn) does not accept a second positional argument.")
|
|
313
|
+
if characteristic_frequency is None:
|
|
314
|
+
raise ValueError("CustomSourceTime(fn) requires characteristic_frequency.")
|
|
315
|
+
fn = times
|
|
316
|
+
char_freq = _validate_frequency("characteristic_frequency", characteristic_frequency)
|
|
317
|
+
settling_time = 0.0
|
|
318
|
+
delay = 0.0
|
|
319
|
+
else:
|
|
320
|
+
if times is None or amplitudes is None:
|
|
321
|
+
raise ValueError("CustomSourceTime requires either fn or (times, amplitudes).")
|
|
322
|
+
table_times = np.asarray(times, dtype=np.float64)
|
|
323
|
+
table_amplitudes = np.asarray(amplitudes, dtype=np.float64)
|
|
324
|
+
if table_times.ndim != 1 or table_amplitudes.ndim != 1:
|
|
325
|
+
raise ValueError("times and amplitudes must be one-dimensional.")
|
|
326
|
+
if table_times.shape != table_amplitudes.shape:
|
|
327
|
+
raise ValueError("times and amplitudes must have the same length.")
|
|
328
|
+
if table_times.size < 2:
|
|
329
|
+
raise ValueError("times and amplitudes must contain at least two samples.")
|
|
330
|
+
order = np.argsort(table_times)
|
|
331
|
+
table_times = np.ascontiguousarray(table_times[order])
|
|
332
|
+
table_amplitudes = np.ascontiguousarray(table_amplitudes[order])
|
|
333
|
+
if characteristic_frequency is None:
|
|
334
|
+
char_freq = _table_characteristic_frequency(table_times, table_amplitudes)
|
|
335
|
+
else:
|
|
336
|
+
char_freq = float(characteristic_frequency)
|
|
337
|
+
delay = float(table_times[0])
|
|
338
|
+
settling_time = float(table_times[-1])
|
|
339
|
+
|
|
340
|
+
object.__setattr__(self, "times", None if table_times is None else tuple(float(v) for v in table_times))
|
|
341
|
+
object.__setattr__(
|
|
342
|
+
self, "amplitudes", None if table_amplitudes is None else tuple(float(v) for v in table_amplitudes)
|
|
343
|
+
)
|
|
344
|
+
object.__setattr__(self, "amplitude", _validate_amplitude(amplitude))
|
|
345
|
+
object.__setattr__(self, "kind", "custom")
|
|
346
|
+
object.__setattr__(self, "_fn", fn)
|
|
347
|
+
object.__setattr__(self, "_table_times", table_times)
|
|
348
|
+
object.__setattr__(self, "_table_amplitudes", table_amplitudes)
|
|
349
|
+
object.__setattr__(self, "_characteristic_frequency", char_freq)
|
|
350
|
+
object.__setattr__(self, "_delay", delay)
|
|
351
|
+
object.__setattr__(self, "_settling_time", settling_time)
|
|
352
|
+
|
|
353
|
+
@property
|
|
354
|
+
def fn(self):
|
|
355
|
+
return self._fn
|
|
356
|
+
|
|
357
|
+
def evaluate(self, t: float) -> float:
|
|
358
|
+
if self._fn is not None:
|
|
359
|
+
return float(self.amplitude) * float(self._fn(float(t)))
|
|
360
|
+
value = np.interp(float(t), self._table_times, self._table_amplitudes, left=0.0, right=0.0)
|
|
361
|
+
return float(self.amplitude) * float(value)
|
|
362
|
+
|
|
363
|
+
@property
|
|
364
|
+
def frequency(self) -> float:
|
|
365
|
+
return float(self._characteristic_frequency)
|
|
366
|
+
|
|
367
|
+
@property
|
|
368
|
+
def characteristic_frequency(self) -> float:
|
|
369
|
+
return float(self._characteristic_frequency)
|
|
370
|
+
|
|
371
|
+
@property
|
|
372
|
+
def phase(self) -> float:
|
|
373
|
+
return 0.0
|
|
374
|
+
|
|
375
|
+
@property
|
|
376
|
+
def fwidth(self) -> float:
|
|
377
|
+
return 0.0
|
|
378
|
+
|
|
379
|
+
@property
|
|
380
|
+
def delay(self) -> float:
|
|
381
|
+
return float(self._delay)
|
|
382
|
+
|
|
383
|
+
@property
|
|
384
|
+
def settling_time(self) -> float:
|
|
385
|
+
return float(self._settling_time)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
SourceTime = CW | GaussianPulse | RickerWavelet | CustomSourceTime
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
@dataclass(frozen=True)
|
|
392
|
+
class TFSF:
|
|
393
|
+
bounds: tuple[tuple[float, float], tuple[float, float], tuple[float, float]] | None
|
|
394
|
+
mode: str = "box"
|
|
395
|
+
axis: str | None = None
|
|
396
|
+
axis_bounds: tuple[float, float] | None = None
|
|
397
|
+
kind: str = "tfsf"
|
|
398
|
+
|
|
399
|
+
def __init__(self, bounds):
|
|
400
|
+
if len(bounds) != 3:
|
|
401
|
+
raise ValueError("bounds must contain x, y, z ranges.")
|
|
402
|
+
normalized = []
|
|
403
|
+
for axis_bounds in bounds:
|
|
404
|
+
if len(axis_bounds) != 2:
|
|
405
|
+
raise ValueError("Each TFSF axis bound must contain exactly two values.")
|
|
406
|
+
start, end = float(axis_bounds[0]), float(axis_bounds[1])
|
|
407
|
+
if end <= start:
|
|
408
|
+
raise ValueError("Each TFSF axis bound must have end > start.")
|
|
409
|
+
normalized.append((start, end))
|
|
410
|
+
object.__setattr__(self, "bounds", tuple(normalized))
|
|
411
|
+
object.__setattr__(self, "mode", "box")
|
|
412
|
+
object.__setattr__(self, "axis", None)
|
|
413
|
+
object.__setattr__(self, "axis_bounds", None)
|
|
414
|
+
object.__setattr__(self, "kind", "tfsf")
|
|
415
|
+
|
|
416
|
+
@classmethod
|
|
417
|
+
def slab(cls, *, axis, bounds) -> "TFSF":
|
|
418
|
+
axis_name = str(axis).lower()
|
|
419
|
+
if axis_name not in {"x", "y", "z"}:
|
|
420
|
+
raise ValueError("TFSF slab axis must be 'x', 'y', or 'z'.")
|
|
421
|
+
if len(bounds) != 2:
|
|
422
|
+
raise ValueError("TFSF slab bounds must contain exactly two values.")
|
|
423
|
+
start, end = float(bounds[0]), float(bounds[1])
|
|
424
|
+
if end <= start:
|
|
425
|
+
raise ValueError("TFSF slab bounds must have end > start.")
|
|
426
|
+
|
|
427
|
+
source = cls.__new__(cls)
|
|
428
|
+
object.__setattr__(source, "bounds", None)
|
|
429
|
+
object.__setattr__(source, "mode", "slab")
|
|
430
|
+
object.__setattr__(source, "axis", axis_name)
|
|
431
|
+
object.__setattr__(source, "axis_bounds", (start, end))
|
|
432
|
+
object.__setattr__(source, "kind", "tfsf")
|
|
433
|
+
return source
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
Injection = str | TFSF
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def resolve_source_time(source_time: SourceTime | None, *, default_frequency: float) -> SourceTime:
|
|
440
|
+
if source_time is None:
|
|
441
|
+
return CW(frequency=default_frequency)
|
|
442
|
+
if not isinstance(source_time, (CW, GaussianPulse, RickerWavelet, CustomSourceTime)):
|
|
443
|
+
raise TypeError("source_time must be CW, GaussianPulse, RickerWavelet, CustomSourceTime, or None.")
|
|
444
|
+
return source_time
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def compile_source_time(source_time: SourceTime | None, *, default_frequency: float) -> dict[str, object]:
|
|
448
|
+
resolved = resolve_source_time(source_time, default_frequency=default_frequency)
|
|
449
|
+
if isinstance(resolved, CW):
|
|
450
|
+
return {
|
|
451
|
+
"kind": "cw",
|
|
452
|
+
"kind_code": SOURCE_TIME_KIND_CW,
|
|
453
|
+
"frequency": float(resolved.frequency),
|
|
454
|
+
"fwidth": 0.0,
|
|
455
|
+
"amplitude": float(resolved.amplitude),
|
|
456
|
+
"phase": float(resolved.phase),
|
|
457
|
+
"delay": 0.0,
|
|
458
|
+
"characteristic_frequency": float(resolved.characteristic_frequency),
|
|
459
|
+
"settling_time": 0.0,
|
|
460
|
+
}
|
|
461
|
+
if isinstance(resolved, GaussianPulse):
|
|
462
|
+
return {
|
|
463
|
+
"kind": "gaussian_pulse",
|
|
464
|
+
"kind_code": SOURCE_TIME_KIND_GAUSSIAN_PULSE,
|
|
465
|
+
"frequency": float(resolved.frequency),
|
|
466
|
+
"fwidth": float(resolved.fwidth),
|
|
467
|
+
"amplitude": float(resolved.amplitude),
|
|
468
|
+
"phase": float(resolved.phase),
|
|
469
|
+
"delay": float(resolved.delay),
|
|
470
|
+
"characteristic_frequency": float(resolved.characteristic_frequency),
|
|
471
|
+
"settling_time": float(resolved.settling_time),
|
|
472
|
+
}
|
|
473
|
+
if isinstance(resolved, CustomSourceTime):
|
|
474
|
+
return {
|
|
475
|
+
"kind": "custom",
|
|
476
|
+
"kind_code": SOURCE_TIME_KIND_CUSTOM,
|
|
477
|
+
"frequency": float(resolved.characteristic_frequency),
|
|
478
|
+
"fwidth": 0.0,
|
|
479
|
+
"amplitude": float(resolved.amplitude),
|
|
480
|
+
"phase": 0.0,
|
|
481
|
+
"delay": float(resolved.delay),
|
|
482
|
+
"characteristic_frequency": float(resolved.characteristic_frequency),
|
|
483
|
+
"settling_time": float(resolved.settling_time),
|
|
484
|
+
"fn": resolved.fn,
|
|
485
|
+
"times": None if resolved.times is None else resolved.times,
|
|
486
|
+
"amplitudes": None if resolved.amplitudes is None else resolved.amplitudes,
|
|
487
|
+
}
|
|
488
|
+
return {
|
|
489
|
+
"kind": "ricker_wavelet",
|
|
490
|
+
"kind_code": SOURCE_TIME_KIND_RICKER_WAVELET,
|
|
491
|
+
"frequency": float(resolved.frequency),
|
|
492
|
+
"fwidth": 0.0,
|
|
493
|
+
"amplitude": float(resolved.amplitude),
|
|
494
|
+
"phase": 0.0,
|
|
495
|
+
"delay": float(resolved.delay),
|
|
496
|
+
"characteristic_frequency": float(resolved.characteristic_frequency),
|
|
497
|
+
"settling_time": float(resolved.settling_time),
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def evaluate_source_time(source_time: SourceTime | dict[str, float | int | str], t: float) -> float:
|
|
502
|
+
if isinstance(source_time, dict):
|
|
503
|
+
kind = str(source_time["kind"])
|
|
504
|
+
frequency = float(source_time["frequency"])
|
|
505
|
+
amplitude = float(source_time["amplitude"])
|
|
506
|
+
phase = float(source_time.get("phase", 0.0))
|
|
507
|
+
delay = float(source_time.get("delay", 0.0))
|
|
508
|
+
if kind == "custom":
|
|
509
|
+
fn = source_time.get("fn")
|
|
510
|
+
if fn is not None:
|
|
511
|
+
return amplitude * float(fn(float(t)))
|
|
512
|
+
table_times = source_time["times"]
|
|
513
|
+
table_amplitudes = source_time["amplitudes"]
|
|
514
|
+
value = np.interp(float(t), table_times, table_amplitudes, left=0.0, right=0.0)
|
|
515
|
+
return amplitude * float(value)
|
|
516
|
+
if kind == "cw":
|
|
517
|
+
return amplitude * math.cos(2.0 * math.pi * frequency * float(t) + phase)
|
|
518
|
+
if kind == "gaussian_pulse":
|
|
519
|
+
fwidth = float(source_time["fwidth"])
|
|
520
|
+
sigma_t = 1.0 / (2.0 * math.pi * fwidth)
|
|
521
|
+
tau = float(t) - delay
|
|
522
|
+
envelope = math.exp(-0.5 * (tau / sigma_t) ** 2)
|
|
523
|
+
return amplitude * envelope * math.cos(2.0 * math.pi * frequency * tau + phase)
|
|
524
|
+
alpha = math.pi * frequency * (float(t) - delay)
|
|
525
|
+
alpha_sq = alpha * alpha
|
|
526
|
+
return amplitude * (1.0 - 2.0 * alpha_sq) * math.exp(-alpha_sq)
|
|
527
|
+
return float(source_time.evaluate(float(t)))
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def evaluate_source_time_normalization(
|
|
531
|
+
source_time: SourceTime | dict[str, float | int | str], t: float
|
|
532
|
+
) -> float:
|
|
533
|
+
"""Evaluate only the arbitrary waveform envelope used for spectral normalization.
|
|
534
|
+
|
|
535
|
+
User amplitude and phase are physical source controls and must remain in frequency-domain
|
|
536
|
+
results. This matches Tidy3D's normalization contract while still removing pulse width,
|
|
537
|
+
delay, and other waveform-shape factors.
|
|
538
|
+
"""
|
|
539
|
+
if isinstance(source_time, dict):
|
|
540
|
+
normalized = dict(source_time)
|
|
541
|
+
normalized["amplitude"] = 1.0
|
|
542
|
+
normalized["phase"] = 0.0
|
|
543
|
+
return evaluate_source_time(normalized, t)
|
|
544
|
+
if isinstance(source_time, CustomSourceTime):
|
|
545
|
+
if source_time.fn is not None:
|
|
546
|
+
return float(source_time.fn(float(t)))
|
|
547
|
+
return float(np.interp(float(t), source_time.times, source_time.amplitudes, left=0.0, right=0.0))
|
|
548
|
+
updates = {"amplitude": 1.0}
|
|
549
|
+
if hasattr(source_time, "phase"):
|
|
550
|
+
updates["phase"] = 0.0
|
|
551
|
+
normalized = replace(source_time, **updates)
|
|
552
|
+
return float(normalized.evaluate(float(t)))
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
@dataclass(frozen=True)
|
|
556
|
+
class PointDipole:
|
|
557
|
+
position: tuple[float, float, float]
|
|
558
|
+
polarization: tuple[float, float, float]
|
|
559
|
+
width: float = POINT_DIPOLE_REFERENCE_WIDTH
|
|
560
|
+
profile: str = "gaussian"
|
|
561
|
+
source_time: SourceTime | None = None
|
|
562
|
+
name: str | None = None
|
|
563
|
+
kind: str = "point_dipole"
|
|
564
|
+
|
|
565
|
+
def __init__(
|
|
566
|
+
self,
|
|
567
|
+
position,
|
|
568
|
+
polarization="Ez",
|
|
569
|
+
width=POINT_DIPOLE_REFERENCE_WIDTH,
|
|
570
|
+
profile="gaussian",
|
|
571
|
+
source_time=None,
|
|
572
|
+
name=None,
|
|
573
|
+
):
|
|
574
|
+
object.__setattr__(self, "position", _require_length3("position", position))
|
|
575
|
+
object.__setattr__(self, "polarization", polarization_vector(polarization))
|
|
576
|
+
object.__setattr__(self, "width", float(width))
|
|
577
|
+
object.__setattr__(self, "profile", _normalize_point_dipole_profile(profile))
|
|
578
|
+
object.__setattr__(self, "source_time", source_time)
|
|
579
|
+
object.__setattr__(self, "name", name)
|
|
580
|
+
object.__setattr__(self, "kind", "point_dipole")
|
|
581
|
+
if self.width <= 0.0:
|
|
582
|
+
raise ValueError("width must be > 0.")
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
@dataclass(frozen=True)
|
|
586
|
+
class PlaneWave:
|
|
587
|
+
direction: tuple[float, float, float]
|
|
588
|
+
polarization: tuple[float, float, float]
|
|
589
|
+
source_time: SourceTime | None = None
|
|
590
|
+
injection: Injection = "soft"
|
|
591
|
+
injection_axis: str | None = None
|
|
592
|
+
name: str | None = None
|
|
593
|
+
kind: str = "plane_wave"
|
|
594
|
+
|
|
595
|
+
def __init__(
|
|
596
|
+
self,
|
|
597
|
+
direction=(0.0, 0.0, 1.0),
|
|
598
|
+
polarization=(1.0, 0.0, 0.0),
|
|
599
|
+
source_time=None,
|
|
600
|
+
injection="soft",
|
|
601
|
+
injection_axis=None,
|
|
602
|
+
name=None,
|
|
603
|
+
):
|
|
604
|
+
direction, polarization = _validate_transverse_polarization(direction, polarization)
|
|
605
|
+
object.__setattr__(self, "direction", direction)
|
|
606
|
+
object.__setattr__(self, "polarization", polarization)
|
|
607
|
+
object.__setattr__(self, "source_time", source_time)
|
|
608
|
+
if isinstance(injection, TFSF):
|
|
609
|
+
normalized_injection = injection
|
|
610
|
+
else:
|
|
611
|
+
normalized_injection = str(injection).lower()
|
|
612
|
+
object.__setattr__(self, "injection_axis", _normalize_axis(injection_axis))
|
|
613
|
+
object.__setattr__(self, "injection", normalized_injection)
|
|
614
|
+
object.__setattr__(self, "name", name)
|
|
615
|
+
object.__setattr__(self, "kind", "plane_wave")
|
|
616
|
+
if not isinstance(self.injection, TFSF) and self.injection != "soft":
|
|
617
|
+
raise ValueError("PlaneWave injection must be 'soft' or TFSF(...).")
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
@dataclass(frozen=True)
|
|
621
|
+
class GaussianBeam:
|
|
622
|
+
direction: tuple[float, float, float]
|
|
623
|
+
polarization: tuple[float, float, float]
|
|
624
|
+
beam_waist: float
|
|
625
|
+
focus: tuple[float, float, float]
|
|
626
|
+
source_time: SourceTime | None = None
|
|
627
|
+
injection: Injection = "soft"
|
|
628
|
+
injection_axis: str | None = None
|
|
629
|
+
name: str | None = None
|
|
630
|
+
kind: str = "gaussian_beam"
|
|
631
|
+
|
|
632
|
+
def __init__(
|
|
633
|
+
self,
|
|
634
|
+
direction=(0.0, 0.0, 1.0),
|
|
635
|
+
polarization=(1.0, 0.0, 0.0),
|
|
636
|
+
beam_waist=0.5,
|
|
637
|
+
focus=(0.0, 0.0, 0.0),
|
|
638
|
+
source_time=None,
|
|
639
|
+
injection="soft",
|
|
640
|
+
injection_axis=None,
|
|
641
|
+
name=None,
|
|
642
|
+
):
|
|
643
|
+
direction, polarization = _validate_transverse_polarization(direction, polarization)
|
|
644
|
+
object.__setattr__(self, "direction", direction)
|
|
645
|
+
object.__setattr__(self, "polarization", polarization)
|
|
646
|
+
object.__setattr__(self, "beam_waist", float(beam_waist))
|
|
647
|
+
object.__setattr__(self, "focus", _require_length3("focus", focus))
|
|
648
|
+
object.__setattr__(self, "source_time", source_time)
|
|
649
|
+
if isinstance(injection, TFSF):
|
|
650
|
+
normalized_injection = injection
|
|
651
|
+
else:
|
|
652
|
+
normalized_injection = str(injection).lower()
|
|
653
|
+
object.__setattr__(self, "injection_axis", _normalize_axis(injection_axis))
|
|
654
|
+
object.__setattr__(self, "injection", normalized_injection)
|
|
655
|
+
object.__setattr__(self, "name", name)
|
|
656
|
+
object.__setattr__(self, "kind", "gaussian_beam")
|
|
657
|
+
if self.beam_waist <= 0.0:
|
|
658
|
+
raise ValueError("beam_waist must be > 0.")
|
|
659
|
+
if not isinstance(self.injection, TFSF) and self.injection != "soft":
|
|
660
|
+
raise ValueError("GaussianBeam injection must be 'soft' or TFSF(...).")
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
@dataclass(frozen=True)
|
|
664
|
+
class AstigmaticGaussianBeam:
|
|
665
|
+
direction: tuple[float, float, float]
|
|
666
|
+
polarization: tuple[float, float, float]
|
|
667
|
+
beam_waist: tuple[float, float]
|
|
668
|
+
focus: tuple[float, float, float]
|
|
669
|
+
focus_u: float
|
|
670
|
+
focus_v: float
|
|
671
|
+
source_time: SourceTime | None = None
|
|
672
|
+
injection: Injection = "soft"
|
|
673
|
+
injection_axis: str | None = None
|
|
674
|
+
name: str | None = None
|
|
675
|
+
kind: str = "astigmatic_gaussian_beam"
|
|
676
|
+
|
|
677
|
+
def __init__(
|
|
678
|
+
self,
|
|
679
|
+
direction=(0.0, 0.0, 1.0),
|
|
680
|
+
polarization=(1.0, 0.0, 0.0),
|
|
681
|
+
beam_waist=(0.5, 0.5),
|
|
682
|
+
focus=(0.0, 0.0, 0.0),
|
|
683
|
+
focus_u=0.0,
|
|
684
|
+
focus_v=0.0,
|
|
685
|
+
source_time=None,
|
|
686
|
+
injection="soft",
|
|
687
|
+
injection_axis=None,
|
|
688
|
+
name=None,
|
|
689
|
+
):
|
|
690
|
+
direction, polarization = _validate_transverse_polarization(direction, polarization)
|
|
691
|
+
object.__setattr__(self, "direction", direction)
|
|
692
|
+
object.__setattr__(self, "polarization", polarization)
|
|
693
|
+
object.__setattr__(self, "beam_waist", _require_beam_waist_pair(beam_waist))
|
|
694
|
+
object.__setattr__(self, "focus", _require_length3("focus", focus))
|
|
695
|
+
object.__setattr__(self, "focus_u", float(focus_u))
|
|
696
|
+
object.__setattr__(self, "focus_v", float(focus_v))
|
|
697
|
+
object.__setattr__(self, "source_time", source_time)
|
|
698
|
+
if isinstance(injection, TFSF):
|
|
699
|
+
normalized_injection = injection
|
|
700
|
+
else:
|
|
701
|
+
normalized_injection = str(injection).lower()
|
|
702
|
+
object.__setattr__(self, "injection_axis", _normalize_axis(injection_axis))
|
|
703
|
+
object.__setattr__(self, "injection", normalized_injection)
|
|
704
|
+
object.__setattr__(self, "name", name)
|
|
705
|
+
object.__setattr__(self, "kind", "astigmatic_gaussian_beam")
|
|
706
|
+
if not isinstance(self.injection, TFSF) and self.injection != "soft":
|
|
707
|
+
raise ValueError("AstigmaticGaussianBeam injection must be 'soft' or TFSF(...).")
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
_FIELD_DATASET_COMPONENTS = ("Ex", "Ey", "Ez", "Hx", "Hy", "Hz")
|
|
711
|
+
_CURRENT_DATASET_COMPONENTS = ("Jx", "Jy", "Jz", "Mx", "My", "Mz")
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _normalize_dataset(coords, components, *, allowed, dataset_name):
|
|
715
|
+
if len(coords) != 3:
|
|
716
|
+
raise ValueError(f"{dataset_name} coords must contain three (x, y, z) sample arrays.")
|
|
717
|
+
axes = []
|
|
718
|
+
for axis_name, values in zip("xyz", coords):
|
|
719
|
+
axis = np.asarray(values, dtype=np.float64).reshape(-1)
|
|
720
|
+
if axis.size < 1:
|
|
721
|
+
raise ValueError(f"{dataset_name} {axis_name}-coordinates must be non-empty.")
|
|
722
|
+
if axis.size >= 2 and np.any(np.diff(axis) <= 0.0):
|
|
723
|
+
raise ValueError(f"{dataset_name} {axis_name}-coordinates must be strictly increasing.")
|
|
724
|
+
axes.append(axis)
|
|
725
|
+
grid_shape = tuple(int(axis.size) for axis in axes)
|
|
726
|
+
|
|
727
|
+
if not isinstance(components, dict) or not components:
|
|
728
|
+
raise ValueError(f"{dataset_name} requires a non-empty component mapping.")
|
|
729
|
+
normalized = {}
|
|
730
|
+
for key, values in components.items():
|
|
731
|
+
if key not in allowed:
|
|
732
|
+
raise ValueError(f"{dataset_name} component {key!r} must be one of {allowed}.")
|
|
733
|
+
data = np.asarray(values, dtype=np.float64)
|
|
734
|
+
if data.shape != grid_shape:
|
|
735
|
+
raise ValueError(
|
|
736
|
+
f"{dataset_name} component {key!r} has shape {data.shape}; expected coords grid {grid_shape}."
|
|
737
|
+
)
|
|
738
|
+
normalized[key] = np.ascontiguousarray(data)
|
|
739
|
+
coord_tuple = tuple(tuple(float(v) for v in axis) for axis in axes)
|
|
740
|
+
return coord_tuple, normalized
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
@dataclass(frozen=True)
|
|
744
|
+
class FieldDataset:
|
|
745
|
+
"""Tangential E/H field distribution sampled on a rectilinear region.
|
|
746
|
+
|
|
747
|
+
``coords`` holds three strictly increasing 1D coordinate arrays (x, y, z).
|
|
748
|
+
``components`` maps field names (``Ex``..``Hz``) to arrays of shape
|
|
749
|
+
``(len(x), len(y), len(z))``. CustomFieldSource requires exactly one axis to
|
|
750
|
+
be a single sample so the region defines a plane with a well-defined normal.
|
|
751
|
+
"""
|
|
752
|
+
|
|
753
|
+
coords: tuple[tuple[float, ...], tuple[float, ...], tuple[float, ...]]
|
|
754
|
+
components: dict
|
|
755
|
+
|
|
756
|
+
def __init__(self, coords, components):
|
|
757
|
+
coord_tuple, normalized = _normalize_dataset(
|
|
758
|
+
coords, components, allowed=_FIELD_DATASET_COMPONENTS, dataset_name="FieldDataset"
|
|
759
|
+
)
|
|
760
|
+
object.__setattr__(self, "coords", coord_tuple)
|
|
761
|
+
object.__setattr__(self, "components", normalized)
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
@dataclass(frozen=True)
|
|
765
|
+
class CurrentDataset:
|
|
766
|
+
"""Electric/magnetic current distribution sampled on a rectilinear region.
|
|
767
|
+
|
|
768
|
+
``coords`` holds three strictly increasing 1D coordinate arrays (x, y, z).
|
|
769
|
+
``components`` maps current names (``Jx``..``Mz``) to arrays of shape
|
|
770
|
+
``(len(x), len(y), len(z))``.
|
|
771
|
+
"""
|
|
772
|
+
|
|
773
|
+
coords: tuple[tuple[float, ...], tuple[float, ...], tuple[float, ...]]
|
|
774
|
+
components: dict
|
|
775
|
+
|
|
776
|
+
def __init__(self, coords, components):
|
|
777
|
+
coord_tuple, normalized = _normalize_dataset(
|
|
778
|
+
coords, components, allowed=_CURRENT_DATASET_COMPONENTS, dataset_name="CurrentDataset"
|
|
779
|
+
)
|
|
780
|
+
object.__setattr__(self, "coords", coord_tuple)
|
|
781
|
+
object.__setattr__(self, "components", normalized)
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
@dataclass(frozen=True)
|
|
785
|
+
class UniformCurrentSource:
|
|
786
|
+
"""Uniform additive electric current filling an axis-aligned box region."""
|
|
787
|
+
|
|
788
|
+
size: tuple[float, float, float]
|
|
789
|
+
polarization: tuple[float, float, float]
|
|
790
|
+
center: tuple[float, float, float]
|
|
791
|
+
source_time: SourceTime | None
|
|
792
|
+
name: str | None
|
|
793
|
+
kind: str = "uniform_current"
|
|
794
|
+
|
|
795
|
+
def __init__(
|
|
796
|
+
self,
|
|
797
|
+
size,
|
|
798
|
+
polarization="Ez",
|
|
799
|
+
source_time=None,
|
|
800
|
+
center=(0.0, 0.0, 0.0),
|
|
801
|
+
name=None,
|
|
802
|
+
):
|
|
803
|
+
resolved_size = _require_length3("size", size)
|
|
804
|
+
if any(component < 0.0 for component in resolved_size):
|
|
805
|
+
raise ValueError("size components must be >= 0.")
|
|
806
|
+
object.__setattr__(self, "size", resolved_size)
|
|
807
|
+
object.__setattr__(self, "polarization", polarization_vector(polarization))
|
|
808
|
+
object.__setattr__(self, "center", _require_length3("center", center))
|
|
809
|
+
object.__setattr__(self, "source_time", source_time)
|
|
810
|
+
object.__setattr__(self, "name", name)
|
|
811
|
+
object.__setattr__(self, "kind", "uniform_current")
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
@dataclass(frozen=True)
|
|
815
|
+
class CustomCurrentSource:
|
|
816
|
+
"""Arbitrary volume electric (J) and magnetic (M) current distribution."""
|
|
817
|
+
|
|
818
|
+
current_dataset: CurrentDataset
|
|
819
|
+
source_time: SourceTime | None
|
|
820
|
+
name: str | None
|
|
821
|
+
kind: str = "custom_current"
|
|
822
|
+
|
|
823
|
+
def __init__(self, current_dataset, source_time=None, name=None):
|
|
824
|
+
if not isinstance(current_dataset, CurrentDataset):
|
|
825
|
+
raise TypeError("current_dataset must be a CurrentDataset instance.")
|
|
826
|
+
object.__setattr__(self, "current_dataset", current_dataset)
|
|
827
|
+
object.__setattr__(self, "source_time", source_time)
|
|
828
|
+
object.__setattr__(self, "name", name)
|
|
829
|
+
object.__setattr__(self, "kind", "custom_current")
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
@dataclass(frozen=True)
|
|
833
|
+
class CustomFieldSource:
|
|
834
|
+
"""Tangential E/H distribution on a plane injected as equivalent currents.
|
|
835
|
+
|
|
836
|
+
The provided tangential fields are converted into equivalent surface currents
|
|
837
|
+
``J = n x H`` and ``M = -n x E`` on the plane (with normal ``n`` along the
|
|
838
|
+
single-sample dataset axis, pointing toward +normal) and injected additively.
|
|
839
|
+
"""
|
|
840
|
+
|
|
841
|
+
field_dataset: FieldDataset
|
|
842
|
+
source_time: SourceTime | None
|
|
843
|
+
name: str | None
|
|
844
|
+
kind: str = "custom_field"
|
|
845
|
+
|
|
846
|
+
def __init__(self, field_dataset, source_time=None, name=None):
|
|
847
|
+
if not isinstance(field_dataset, FieldDataset):
|
|
848
|
+
raise TypeError("field_dataset must be a FieldDataset instance.")
|
|
849
|
+
normal_axes = [axis for axis, values in zip("xyz", field_dataset.coords) if len(values) == 1]
|
|
850
|
+
if len(normal_axes) != 1:
|
|
851
|
+
raise ValueError(
|
|
852
|
+
"CustomFieldSource requires exactly one single-sample dataset axis to define the plane normal."
|
|
853
|
+
)
|
|
854
|
+
object.__setattr__(self, "field_dataset", field_dataset)
|
|
855
|
+
object.__setattr__(self, "source_time", source_time)
|
|
856
|
+
object.__setattr__(self, "name", name)
|
|
857
|
+
object.__setattr__(self, "kind", "custom_field")
|
|
858
|
+
|
|
859
|
+
@property
|
|
860
|
+
def normal_axis(self) -> str:
|
|
861
|
+
for axis, values in zip("xyz", self.field_dataset.coords):
|
|
862
|
+
if len(values) == 1:
|
|
863
|
+
return axis
|
|
864
|
+
raise ValueError("CustomFieldSource dataset does not define a plane normal.")
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
@dataclass(frozen=True)
|
|
868
|
+
class ModeSource:
|
|
869
|
+
position: tuple[float, float, float]
|
|
870
|
+
size: tuple[float, float, float]
|
|
871
|
+
mode_index: int
|
|
872
|
+
direction: str
|
|
873
|
+
polarization: tuple[float, float, float]
|
|
874
|
+
source_time: SourceTime | None = None
|
|
875
|
+
injection: Injection = "soft"
|
|
876
|
+
name: str | None = None
|
|
877
|
+
normal_axis: str = "z"
|
|
878
|
+
polarization_axis: str = "x"
|
|
879
|
+
bend_radius: float | None = None
|
|
880
|
+
bend_axis: str | None = None
|
|
881
|
+
kind: str = "mode_source"
|
|
882
|
+
|
|
883
|
+
def __init__(
|
|
884
|
+
self,
|
|
885
|
+
position=(0.0, 0.0, 0.0),
|
|
886
|
+
size=(1.0, 1.0, 0.0),
|
|
887
|
+
mode_index=0,
|
|
888
|
+
direction="+",
|
|
889
|
+
polarization="auto",
|
|
890
|
+
source_time=None,
|
|
891
|
+
injection="soft",
|
|
892
|
+
name=None,
|
|
893
|
+
bend_radius=None,
|
|
894
|
+
bend_axis=None,
|
|
895
|
+
):
|
|
896
|
+
resolved_position = _require_length3("position", position)
|
|
897
|
+
resolved_size = _require_mode_source_size(size)
|
|
898
|
+
normal_axis = _resolve_mode_source_normal_axis(resolved_size)
|
|
899
|
+
polarization_axis = _resolve_mode_source_polarization_axis(normal_axis, resolved_size, polarization)
|
|
900
|
+
resolved_bend_radius, resolved_bend_axis = _normalize_bend(bend_radius, bend_axis, normal_axis)
|
|
901
|
+
object.__setattr__(self, "position", resolved_position)
|
|
902
|
+
object.__setattr__(self, "size", resolved_size)
|
|
903
|
+
object.__setattr__(self, "mode_index", int(mode_index))
|
|
904
|
+
object.__setattr__(self, "direction", _normalize_mode_direction(direction))
|
|
905
|
+
object.__setattr__(self, "polarization", polarization_vector(f"E{polarization_axis}"))
|
|
906
|
+
object.__setattr__(self, "source_time", source_time)
|
|
907
|
+
object.__setattr__(self, "injection", str(injection).lower())
|
|
908
|
+
object.__setattr__(self, "name", name)
|
|
909
|
+
object.__setattr__(self, "normal_axis", normal_axis)
|
|
910
|
+
object.__setattr__(self, "polarization_axis", polarization_axis)
|
|
911
|
+
object.__setattr__(self, "bend_radius", resolved_bend_radius)
|
|
912
|
+
object.__setattr__(self, "bend_axis", resolved_bend_axis)
|
|
913
|
+
object.__setattr__(self, "kind", "mode_source")
|
|
914
|
+
if self.mode_index < 0:
|
|
915
|
+
raise ValueError("mode_index must be >= 0.")
|
|
916
|
+
if self.injection != "soft":
|
|
917
|
+
raise ValueError("ModeSource injection must currently be 'soft'.")
|