witwin-maxwell 0.3.0__py3-none-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. witwin/maxwell/__init__.py +164 -0
  2. witwin/maxwell/adapters/__init__.py +1 -0
  3. witwin/maxwell/adapters/gds.py +203 -0
  4. witwin/maxwell/adapters/tidy3d.py +1669 -0
  5. witwin/maxwell/adjoint_inputs.py +70 -0
  6. witwin/maxwell/compiler/__init__.py +11 -0
  7. witwin/maxwell/compiler/materials.py +1355 -0
  8. witwin/maxwell/compiler/monitors.py +164 -0
  9. witwin/maxwell/compiler/sources.py +222 -0
  10. witwin/maxwell/fdfd/__init__.py +3 -0
  11. witwin/maxwell/fdfd/adjoint/__init__.py +3 -0
  12. witwin/maxwell/fdfd/adjoint/bridge.py +180 -0
  13. witwin/maxwell/fdfd/ams.py +300 -0
  14. witwin/maxwell/fdfd/krylov.py +280 -0
  15. witwin/maxwell/fdfd/plotting.py +110 -0
  16. witwin/maxwell/fdfd/postprocess.py +33 -0
  17. witwin/maxwell/fdfd/solver.py +1057 -0
  18. witwin/maxwell/fdtd/__init__.py +4 -0
  19. witwin/maxwell/fdtd/adjoint/__init__.py +21 -0
  20. witwin/maxwell/fdtd/adjoint/bridge.py +746 -0
  21. witwin/maxwell/fdtd/adjoint/capabilities.py +29 -0
  22. witwin/maxwell/fdtd/adjoint/core.py +3614 -0
  23. witwin/maxwell/fdtd/adjoint/dispatch.py +684 -0
  24. witwin/maxwell/fdtd/adjoint/native.py +2561 -0
  25. witwin/maxwell/fdtd/adjoint/profiler.py +195 -0
  26. witwin/maxwell/fdtd/adjoint/reverse_common.py +207 -0
  27. witwin/maxwell/fdtd/adjoint/seeds.py +524 -0
  28. witwin/maxwell/fdtd/boundary/__init__.py +37 -0
  29. witwin/maxwell/fdtd/boundary/bloch.py +90 -0
  30. witwin/maxwell/fdtd/boundary/common.py +41 -0
  31. witwin/maxwell/fdtd/boundary/cpml.py +527 -0
  32. witwin/maxwell/fdtd/boundary/runtime.py +209 -0
  33. witwin/maxwell/fdtd/checkpoint.py +227 -0
  34. witwin/maxwell/fdtd/coords.py +32 -0
  35. witwin/maxwell/fdtd/cuda/__init__.py +11 -0
  36. witwin/maxwell/fdtd/cuda/backend.py +3532 -0
  37. witwin/maxwell/fdtd/cuda/build.py +292 -0
  38. witwin/maxwell/fdtd/cuda/extension.cpp +2053 -0
  39. witwin/maxwell/fdtd/cuda/kernels/adjoint.cu +5290 -0
  40. witwin/maxwell/fdtd/cuda/kernels/aniso.cu +1704 -0
  41. witwin/maxwell/fdtd/cuda/kernels/boundary.cu +424 -0
  42. witwin/maxwell/fdtd/cuda/kernels/common.cu +74 -0
  43. witwin/maxwell/fdtd/cuda/kernels/common.cuh +82 -0
  44. witwin/maxwell/fdtd/cuda/kernels/dispersive.cu +782 -0
  45. witwin/maxwell/fdtd/cuda/kernels/electric.cu +3613 -0
  46. witwin/maxwell/fdtd/cuda/kernels/magnetic.cu +781 -0
  47. witwin/maxwell/fdtd/cuda/kernels/observers.cu +347 -0
  48. witwin/maxwell/fdtd/cuda/kernels/projection.cu +211 -0
  49. witwin/maxwell/fdtd/cuda/kernels/sources.cu +1482 -0
  50. witwin/maxwell/fdtd/cuda/kernels/spectral.cu +297 -0
  51. witwin/maxwell/fdtd/cuda/launch.h +23 -0
  52. witwin/maxwell/fdtd/cuda/prebuilt/witwin_maxwell_fdtd_cuda.pyd +0 -0
  53. witwin/maxwell/fdtd/cuda/runtime/__init__.py +6 -0
  54. witwin/maxwell/fdtd/cuda/runtime/dispatcher.py +6 -0
  55. witwin/maxwell/fdtd/cuda/runtime/graph.py +33 -0
  56. witwin/maxwell/fdtd/cuda/tensors.h +35 -0
  57. witwin/maxwell/fdtd/dispersion.py +48 -0
  58. witwin/maxwell/fdtd/evaluation.py +290 -0
  59. witwin/maxwell/fdtd/excitation/__init__.py +27 -0
  60. witwin/maxwell/fdtd/excitation/injection.py +1102 -0
  61. witwin/maxwell/fdtd/excitation/modes.py +1675 -0
  62. witwin/maxwell/fdtd/excitation/spatial.py +469 -0
  63. witwin/maxwell/fdtd/excitation/temporal.py +510 -0
  64. witwin/maxwell/fdtd/excitation/tfsf_apply.py +264 -0
  65. witwin/maxwell/fdtd/excitation/tfsf_common.py +421 -0
  66. witwin/maxwell/fdtd/excitation/tfsf_specs.py +490 -0
  67. witwin/maxwell/fdtd/excitation/tfsf_state.py +778 -0
  68. witwin/maxwell/fdtd/io.py +31 -0
  69. witwin/maxwell/fdtd/material_pullback.py +371 -0
  70. witwin/maxwell/fdtd/meshing/__init__.py +3 -0
  71. witwin/maxwell/fdtd/meshing/autogrid.py +335 -0
  72. witwin/maxwell/fdtd/observers.py +1453 -0
  73. witwin/maxwell/fdtd/plotting.py +121 -0
  74. witwin/maxwell/fdtd/postprocess.py +205 -0
  75. witwin/maxwell/fdtd/runtime/__init__.py +119 -0
  76. witwin/maxwell/fdtd/runtime/initialization.py +236 -0
  77. witwin/maxwell/fdtd/runtime/materials.py +1563 -0
  78. witwin/maxwell/fdtd/runtime/module_cache.py +31 -0
  79. witwin/maxwell/fdtd/runtime/spectral.py +581 -0
  80. witwin/maxwell/fdtd/runtime/stepping.py +1987 -0
  81. witwin/maxwell/fdtd/solver.py +502 -0
  82. witwin/maxwell/geometry/__init__.py +5 -0
  83. witwin/maxwell/geometry/polyslab.py +299 -0
  84. witwin/maxwell/materials.py +3 -0
  85. witwin/maxwell/media.py +1467 -0
  86. witwin/maxwell/monitors.py +743 -0
  87. witwin/maxwell/ports.py +113 -0
  88. witwin/maxwell/postprocess/RCS.py +139 -0
  89. witwin/maxwell/postprocess/__init__.py +42 -0
  90. witwin/maxwell/postprocess/diffraction.py +219 -0
  91. witwin/maxwell/postprocess/directivity.py +152 -0
  92. witwin/maxwell/postprocess/emission.py +67 -0
  93. witwin/maxwell/postprocess/modal.py +324 -0
  94. witwin/maxwell/postprocess/nfft.py +266 -0
  95. witwin/maxwell/postprocess/scattering_parameters.py +358 -0
  96. witwin/maxwell/postprocess/stratton_chu.py +1193 -0
  97. witwin/maxwell/result.py +967 -0
  98. witwin/maxwell/scene.py +1338 -0
  99. witwin/maxwell/simulation.py +659 -0
  100. witwin/maxwell/sources.py +917 -0
  101. witwin/maxwell/visualization/__init__.py +28 -0
  102. witwin/maxwell/visualization/interactive.py +484 -0
  103. witwin/maxwell/visualization/plots.py +568 -0
  104. witwin_maxwell-0.3.0.dist-info/METADATA +184 -0
  105. witwin_maxwell-0.3.0.dist-info/RECORD +107 -0
  106. witwin_maxwell-0.3.0.dist-info/WHEEL +4 -0
  107. witwin_maxwell-0.3.0.dist-info/licenses/LICENSE +13 -0
@@ -0,0 +1,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'.")