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,1193 @@
1
+ from __future__ import annotations
2
+
3
+ import cmath
4
+ import math
5
+ from dataclasses import dataclass
6
+ from typing import Mapping
7
+
8
+ import torch
9
+
10
+ WINDOW_FACTOR = 15.0
11
+
12
+ _AXIS_TO_INDEX = {"x": 0, "y": 1, "z": 2}
13
+ _INDEX_TO_AXIS = ("x", "y", "z")
14
+
15
+
16
+ def _as_real_if_lossless(value: complex | float | int) -> complex | float:
17
+ """Collapse a (near-)real scalar to ``float`` so the vacuum/lossless path
18
+ keeps its original real dtype and only genuinely lossy backgrounds carry an
19
+ imaginary part into the propagator kernels."""
20
+ number = complex(value)
21
+ if abs(number.imag) <= 1e-12 * (abs(number.real) + 1.0):
22
+ return number.real
23
+ return number
24
+
25
+
26
+ def _normalize_background(
27
+ background_eps_r: complex | float | int,
28
+ background_mu_r: complex | float | int,
29
+ ) -> tuple[complex | float, complex | float]:
30
+ eps_r = _as_real_if_lossless(background_eps_r)
31
+ mu_r = _as_real_if_lossless(background_mu_r)
32
+ if isinstance(eps_r, float) and eps_r <= 0.0:
33
+ raise ValueError("background_eps_r must be positive for a lossless exterior.")
34
+ if isinstance(mu_r, float) and mu_r <= 0.0:
35
+ raise ValueError("background_mu_r must be positive for a lossless exterior.")
36
+ return eps_r, mu_r
37
+
38
+
39
+ def _background_wavenumber_and_impedance(
40
+ background_eps_r: complex | float,
41
+ background_mu_r: complex | float,
42
+ *,
43
+ omega: float,
44
+ c: float,
45
+ eta_vacuum: float,
46
+ ) -> tuple[complex | float, complex | float]:
47
+ """Return ``(k, eta)`` of the homogeneous exterior background relative to the
48
+ vacuum wavenumber ``omega/c`` and vacuum impedance ``eta_vacuum``."""
49
+ index = _as_real_if_lossless(cmath.sqrt(complex(background_eps_r) * complex(background_mu_r)))
50
+ impedance_ratio = _as_real_if_lossless(
51
+ cmath.sqrt(complex(background_mu_r) / complex(background_eps_r))
52
+ )
53
+ k = _as_real_if_lossless((omega / c) * complex(index))
54
+ eta = _as_real_if_lossless(eta_vacuum * complex(impedance_ratio))
55
+ return k, eta
56
+
57
+
58
+ def _resolve_currents_background(
59
+ surfaces,
60
+ background_eps_r: complex | float | None,
61
+ background_mu_r: complex | float | None,
62
+ ) -> tuple[complex | float, complex | float]:
63
+ """Resolve the exterior background for a propagator: an explicit override wins,
64
+ otherwise fall back to the medium carried by the equivalent currents."""
65
+ if background_eps_r is None:
66
+ background_eps_r = surfaces[0].background_eps_r
67
+ if background_mu_r is None:
68
+ background_mu_r = surfaces[0].background_mu_r
69
+ return _normalize_background(background_eps_r, background_mu_r)
70
+
71
+
72
+ def _complex_dtype_for(dtype: torch.dtype) -> torch.dtype:
73
+ return torch.complex64 if dtype in {torch.float16, torch.bfloat16, torch.float32} else torch.complex128
74
+
75
+
76
+ def _resolve_tensor_device(*values) -> torch.device:
77
+ for value in values:
78
+ if isinstance(value, torch.Tensor):
79
+ return value.device
80
+ return torch.device("cpu")
81
+
82
+
83
+ def _resolve_real_dtype(*values) -> torch.dtype:
84
+ for value in values:
85
+ if isinstance(value, torch.Tensor):
86
+ return value.real.dtype
87
+ return torch.float64
88
+
89
+
90
+ def _resolve_complex_dtype(*values) -> torch.dtype:
91
+ for value in values:
92
+ if isinstance(value, torch.Tensor):
93
+ return value.dtype if torch.is_complex(value) else _complex_dtype_for(value.dtype)
94
+ return torch.complex128
95
+
96
+
97
+ def _to_real_tensor(values, *, device: torch.device, dtype: torch.dtype) -> torch.Tensor:
98
+ if isinstance(values, torch.Tensor):
99
+ return values.to(device=device, dtype=dtype)
100
+ return torch.as_tensor(values, device=device, dtype=dtype)
101
+
102
+
103
+ def _to_complex_tensor(values, *, device: torch.device, dtype: torch.dtype) -> torch.Tensor:
104
+ if isinstance(values, torch.Tensor):
105
+ return values.to(device=device, dtype=dtype)
106
+ return torch.as_tensor(values, device=device, dtype=dtype)
107
+
108
+
109
+ def _as_1d_coords(values, name: str, *, device: torch.device, dtype: torch.dtype) -> torch.Tensor:
110
+ coords = _to_real_tensor(values, device=device, dtype=dtype)
111
+ if coords.ndim != 1:
112
+ raise ValueError(f"{name} must be a 1D array, got shape={tuple(coords.shape)}.")
113
+ if coords.numel() < 2:
114
+ raise ValueError(f"{name} must contain at least two points, got {coords.numel()}.")
115
+ return coords
116
+
117
+
118
+ def _trapz_weights_1d(points: torch.Tensor) -> torch.Tensor:
119
+ coords = points.to(dtype=points.real.dtype)
120
+ count = int(coords.numel())
121
+ if count <= 1:
122
+ return torch.ones((count,), device=coords.device, dtype=coords.dtype)
123
+ diffs = coords[1:] - coords[:-1]
124
+ weights = torch.empty((count,), device=coords.device, dtype=coords.dtype)
125
+ weights[0] = diffs[0] / 2.0
126
+ weights[-1] = diffs[-1] / 2.0
127
+ if count > 2:
128
+ weights[1:-1] = (diffs[:-1] + diffs[1:]) / 2.0
129
+ return weights
130
+
131
+
132
+ def gaussian_window_1d(
133
+ points,
134
+ bound_min: float,
135
+ bound_max: float,
136
+ window_frac: float,
137
+ ) -> torch.Tensor:
138
+ device = _resolve_tensor_device(points)
139
+ dtype = _resolve_real_dtype(points)
140
+ point_tensor = _to_real_tensor(points, device=device, dtype=dtype)
141
+ if window_frac <= 0.0:
142
+ return torch.ones_like(point_tensor)
143
+
144
+ span = float(bound_max) - float(bound_min)
145
+ if span <= 0.0:
146
+ raise ValueError("Window bounds must satisfy bound_max > bound_min.")
147
+
148
+ window_extent = float(window_frac) * span / 2.0
149
+ if window_extent <= 0.0:
150
+ return torch.ones_like(point_tensor)
151
+
152
+ inner_min = float(bound_min) + window_extent
153
+ inner_max = float(bound_max) - window_extent
154
+
155
+ weights = torch.ones_like(point_tensor)
156
+ left_mask = point_tensor < inner_min
157
+ if torch.any(left_mask):
158
+ u_left = (point_tensor[left_mask] - inner_min) / window_extent
159
+ weights[left_mask] = torch.exp(-0.5 * WINDOW_FACTOR * u_left.square())
160
+
161
+ right_mask = point_tensor > inner_max
162
+ if torch.any(right_mask):
163
+ u_right = (point_tensor[right_mask] - inner_max) / window_extent
164
+ weights[right_mask] = torch.exp(-0.5 * WINDOW_FACTOR * u_right.square())
165
+
166
+ return weights
167
+
168
+
169
+ def _normalize_axis(axis: str) -> str:
170
+ axis_name = str(axis).lower()
171
+ if axis_name not in _AXIS_TO_INDEX:
172
+ raise ValueError("axis must be one of 'x', 'y', or 'z'.")
173
+ return axis_name
174
+
175
+
176
+ def _normalize_window_size(window_size: tuple[float, float] | None) -> tuple[float, float]:
177
+ if window_size is None:
178
+ return 0.0, 0.0
179
+ if len(window_size) != 2:
180
+ raise ValueError("window_size must contain exactly two values.")
181
+ return float(window_size[0]), float(window_size[1])
182
+
183
+
184
+ def _normalize_tangential_bounds(
185
+ tangential_bounds: Mapping[str, tuple[float, float]] | None,
186
+ ) -> dict[str, tuple[float, float]] | None:
187
+ if tangential_bounds is None:
188
+ return None
189
+
190
+ normalized: dict[str, tuple[float, float]] = {}
191
+ for axis, bounds in tangential_bounds.items():
192
+ axis_name = _normalize_axis(axis)
193
+ if len(bounds) != 2:
194
+ raise ValueError(f"{axis_name}-bounds must contain exactly two values.")
195
+ lower = float(bounds[0])
196
+ upper = float(bounds[1])
197
+ if not upper > lower:
198
+ raise ValueError(f"{axis_name}-bounds must satisfy upper > lower.")
199
+ normalized[axis_name] = (lower, upper)
200
+ return normalized
201
+
202
+
203
+ def _normalize_normal_direction(normal_direction: str | int | float) -> float:
204
+ if isinstance(normal_direction, str):
205
+ if normal_direction == "+":
206
+ return 1.0
207
+ if normal_direction == "-":
208
+ return -1.0
209
+ raise ValueError("normal_direction must be '+' or '-'.")
210
+ normal = float(normal_direction)
211
+ if abs(normal) <= 1e-12:
212
+ raise ValueError("normal_direction must not be zero.")
213
+ return 1.0 if normal > 0.0 else -1.0
214
+
215
+
216
+ def _tangential_indices(axis: str) -> tuple[int, int]:
217
+ axis_index = _AXIS_TO_INDEX[axis]
218
+ tangential = tuple(index for index in range(3) if index != axis_index)
219
+ return tangential[0], tangential[1]
220
+
221
+
222
+ def _select_bound_indices(
223
+ coords: torch.Tensor,
224
+ *,
225
+ axis: str,
226
+ tangential_bounds: Mapping[str, tuple[float, float]] | None,
227
+ ) -> torch.Tensor | None:
228
+ if tangential_bounds is None or axis not in tangential_bounds:
229
+ return None
230
+
231
+ lower, upper = tangential_bounds[axis]
232
+ coord_tensor = coords.to(dtype=coords.real.dtype)
233
+ tolerance = 1e-12 * max(abs(lower), abs(upper), float(torch.max(torch.abs(coord_tensor)).item()), 1.0)
234
+ mask = (coord_tensor >= lower - tolerance) & (coord_tensor <= upper + tolerance)
235
+ indices = torch.nonzero(mask, as_tuple=False).reshape(-1)
236
+ if indices.numel() < 2:
237
+ raise ValueError(
238
+ f"{axis}-bounds [{lower}, {upper}] select fewer than two monitor samples."
239
+ )
240
+ if indices.numel() == coord_tensor.numel():
241
+ return None
242
+ return indices.to(device=coords.device)
243
+
244
+
245
+ def _as_plane_component(
246
+ values,
247
+ name: str,
248
+ shape: tuple[int, int],
249
+ *,
250
+ device: torch.device,
251
+ dtype: torch.dtype,
252
+ ) -> torch.Tensor:
253
+ array = _to_complex_tensor(values, device=device, dtype=dtype)
254
+ if tuple(array.shape) != shape:
255
+ raise ValueError(f"{name} must have shape {shape}, got {tuple(array.shape)}.")
256
+ return array
257
+
258
+
259
+ def _as_vector_field(
260
+ values,
261
+ name: str,
262
+ shape: tuple[int, int],
263
+ *,
264
+ device: torch.device,
265
+ dtype: torch.dtype,
266
+ ) -> torch.Tensor:
267
+ array = _to_complex_tensor(values, device=device, dtype=dtype)
268
+ if tuple(array.shape) == shape + (3,):
269
+ return array
270
+ if tuple(array.shape) == (3,) + shape:
271
+ array = torch.movedim(array, 0, -1)
272
+ if tuple(array.shape) != shape + (3,):
273
+ raise ValueError(
274
+ f"{name} must have shape {shape + (3,)} or {(3,) + shape}, got {tuple(array.shape)}."
275
+ )
276
+ return array
277
+
278
+
279
+ def _resolve_physical_constants(
280
+ *,
281
+ solver=None,
282
+ c: float | None = None,
283
+ eps0: float | None = None,
284
+ mu0: float | None = None,
285
+ ) -> tuple[float, float, float]:
286
+ if solver is not None:
287
+ c = getattr(solver, "c", getattr(solver, "c0", c))
288
+ eps0 = getattr(solver, "eps0", eps0)
289
+ mu0 = getattr(solver, "mu0", mu0)
290
+
291
+ missing = [
292
+ name
293
+ for name, value in (("c", c), ("eps0", eps0), ("mu0", mu0))
294
+ if value is None
295
+ ]
296
+ if missing:
297
+ missing_list = ", ".join(missing)
298
+ raise ValueError(
299
+ f"Missing physical constants: {missing_list}. "
300
+ "Pass solver=... or explicit c/eps0/mu0."
301
+ )
302
+
303
+ return float(c), float(eps0), float(mu0)
304
+
305
+
306
+ def _resolve_device(device: str | torch.device | None) -> torch.device:
307
+ if device is None:
308
+ if not torch.cuda.is_available():
309
+ raise RuntimeError(
310
+ "Stratton-Chu postprocessing defaults to CUDA, but torch.cuda.is_available() is False. "
311
+ "Pass device='cpu' explicitly to run on CPU."
312
+ )
313
+ return torch.device("cuda")
314
+ resolved = torch.device(device)
315
+ if resolved.type == "cuda" and not torch.cuda.is_available():
316
+ raise RuntimeError(
317
+ "Stratton-Chu postprocessing requested CUDA, but torch.cuda.is_available() is False."
318
+ )
319
+ return resolved
320
+
321
+
322
+ def build_plane_points(axis: str, position: float, u, v) -> torch.Tensor:
323
+ axis_name = _normalize_axis(axis)
324
+ device = _resolve_tensor_device(u, v)
325
+ dtype = _resolve_real_dtype(u, v)
326
+ u_coords = _as_1d_coords(u, "u", device=device, dtype=dtype)
327
+ v_coords = _as_1d_coords(v, "v", device=device, dtype=dtype)
328
+ shape = (u_coords.numel(), v_coords.numel())
329
+ points = torch.zeros(shape + (3,), device=device, dtype=dtype)
330
+ tangential_a, tangential_b = _tangential_indices(axis_name)
331
+ uu, vv = torch.meshgrid(u_coords, v_coords, indexing="ij")
332
+ points[..., _AXIS_TO_INDEX[axis_name]] = float(position)
333
+ points[..., tangential_a] = uu
334
+ points[..., tangential_b] = vv
335
+ return points
336
+
337
+
338
+ @dataclass(frozen=True)
339
+ class PlanarEquivalentCurrents:
340
+ axis: str
341
+ position: float
342
+ frequency: float
343
+ u: torch.Tensor
344
+ v: torch.Tensor
345
+ J: torch.Tensor
346
+ M: torch.Tensor
347
+ background_eps_r: complex | float = 1.0
348
+ background_mu_r: complex | float = 1.0
349
+
350
+ def __post_init__(self):
351
+ axis_name = _normalize_axis(self.axis)
352
+ device = _resolve_tensor_device(self.u, self.v, self.J, self.M)
353
+ real_dtype = _resolve_real_dtype(self.u, self.v, self.J, self.M)
354
+ complex_dtype = _resolve_complex_dtype(self.J, self.M)
355
+ u_coords = _as_1d_coords(self.u, "u", device=device, dtype=real_dtype)
356
+ v_coords = _as_1d_coords(self.v, "v", device=device, dtype=real_dtype)
357
+ if float(self.frequency) <= 0.0:
358
+ raise ValueError("frequency must be positive.")
359
+ shape = (u_coords.numel(), v_coords.numel())
360
+ j_field = _as_vector_field(self.J, "J", shape, device=device, dtype=complex_dtype)
361
+ m_field = _as_vector_field(self.M, "M", shape, device=device, dtype=complex_dtype)
362
+ background_eps_r, background_mu_r = _normalize_background(
363
+ self.background_eps_r, self.background_mu_r
364
+ )
365
+
366
+ object.__setattr__(self, "axis", axis_name)
367
+ object.__setattr__(self, "position", float(self.position))
368
+ object.__setattr__(self, "frequency", float(self.frequency))
369
+ object.__setattr__(self, "u", u_coords)
370
+ object.__setattr__(self, "v", v_coords)
371
+ object.__setattr__(self, "J", j_field)
372
+ object.__setattr__(self, "M", m_field)
373
+ object.__setattr__(self, "background_eps_r", background_eps_r)
374
+ object.__setattr__(self, "background_mu_r", background_mu_r)
375
+
376
+ @property
377
+ def device(self) -> torch.device:
378
+ return self.u.device
379
+
380
+ @property
381
+ def coord_dtype(self) -> torch.dtype:
382
+ return self.u.dtype
383
+
384
+ @property
385
+ def tangential_axes(self) -> tuple[str, str]:
386
+ tangential = _tangential_indices(self.axis)
387
+ return _INDEX_TO_AXIS[tangential[0]], _INDEX_TO_AXIS[tangential[1]]
388
+
389
+ @property
390
+ def shape(self) -> tuple[int, int]:
391
+ return int(self.u.numel()), int(self.v.numel())
392
+
393
+ def weights_2d(self) -> torch.Tensor:
394
+ return _trapz_weights_1d(self.u)[:, None] * _trapz_weights_1d(self.v)[None, :]
395
+
396
+ def plane_points(self) -> torch.Tensor:
397
+ return build_plane_points(self.axis, self.position, self.u, self.v)
398
+
399
+ def quadrature(self) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
400
+ """Flattened ``(points, J_weighted, M_weighted)`` for the radiation
401
+ integral: the plane's Yee-consistent trapezoid area weights are folded
402
+ into the electric/magnetic surface currents so every source-surface type
403
+ (planar patch or general curved surface) exposes the same interface."""
404
+ weights = self.weights_2d()[..., None].to(dtype=self.J.real.dtype)
405
+ points = self.plane_points().reshape(-1, 3)
406
+ j_weighted = (self.J * weights.to(dtype=self.J.dtype)).reshape(-1, 3)
407
+ m_weighted = (self.M * weights.to(dtype=self.M.dtype)).reshape(-1, 3)
408
+ return points, j_weighted, m_weighted
409
+
410
+ def cropped(
411
+ self,
412
+ tangential_bounds: Mapping[str, tuple[float, float]] | None,
413
+ ) -> "PlanarEquivalentCurrents":
414
+ normalized_bounds = _normalize_tangential_bounds(tangential_bounds)
415
+ if normalized_bounds is None:
416
+ return self
417
+
418
+ axis_u, axis_v = self.tangential_axes
419
+ u_indices = _select_bound_indices(self.u, axis=axis_u, tangential_bounds=normalized_bounds)
420
+ v_indices = _select_bound_indices(self.v, axis=axis_v, tangential_bounds=normalized_bounds)
421
+ if u_indices is None and v_indices is None:
422
+ return self
423
+
424
+ u_coords = self.u if u_indices is None else self.u.index_select(0, u_indices)
425
+ v_coords = self.v if v_indices is None else self.v.index_select(0, v_indices)
426
+ j_field = self.J
427
+ m_field = self.M
428
+ if u_indices is not None:
429
+ j_field = j_field.index_select(0, u_indices)
430
+ m_field = m_field.index_select(0, u_indices)
431
+ if v_indices is not None:
432
+ j_field = j_field.index_select(1, v_indices)
433
+ m_field = m_field.index_select(1, v_indices)
434
+
435
+ return PlanarEquivalentCurrents(
436
+ axis=self.axis,
437
+ position=self.position,
438
+ frequency=self.frequency,
439
+ u=u_coords,
440
+ v=v_coords,
441
+ J=j_field,
442
+ M=m_field,
443
+ background_eps_r=self.background_eps_r,
444
+ background_mu_r=self.background_mu_r,
445
+ )
446
+
447
+ def windowed(self, window_size: tuple[float, float] | None) -> "PlanarEquivalentCurrents":
448
+ frac_u, frac_v = _normalize_window_size(window_size)
449
+ if frac_u <= 0.0 and frac_v <= 0.0:
450
+ return self
451
+ window_u = gaussian_window_1d(self.u, float(self.u[0].item()), float(self.u[-1].item()), frac_u)
452
+ window_v = gaussian_window_1d(self.v, float(self.v[0].item()), float(self.v[-1].item()), frac_v)
453
+ window_2d = (window_u[:, None] * window_v[None, :])[..., None].to(dtype=self.J.real.dtype)
454
+ return PlanarEquivalentCurrents(
455
+ axis=self.axis,
456
+ position=self.position,
457
+ frequency=self.frequency,
458
+ u=self.u,
459
+ v=self.v,
460
+ J=self.J * window_2d.to(dtype=self.J.dtype),
461
+ M=self.M * window_2d.to(dtype=self.M.dtype),
462
+ background_eps_r=self.background_eps_r,
463
+ background_mu_r=self.background_mu_r,
464
+ )
465
+
466
+
467
+ @dataclass(frozen=True)
468
+ class EquivalentCurrentsSurface:
469
+ surfaces: tuple[PlanarEquivalentCurrents, ...]
470
+
471
+ def __init__(self, surfaces):
472
+ resolved_surfaces = tuple(surfaces)
473
+ if not resolved_surfaces:
474
+ raise ValueError("EquivalentCurrentsSurface requires at least one planar surface.")
475
+ for surface in resolved_surfaces:
476
+ if not isinstance(surface, PlanarEquivalentCurrents):
477
+ raise TypeError(
478
+ "EquivalentCurrentsSurface expects PlanarEquivalentCurrents entries, "
479
+ f"got {type(surface).__name__}."
480
+ )
481
+ reference_frequency = float(resolved_surfaces[0].frequency)
482
+ reference_eps = complex(resolved_surfaces[0].background_eps_r)
483
+ reference_mu = complex(resolved_surfaces[0].background_mu_r)
484
+ for surface in resolved_surfaces[1:]:
485
+ if not math.isclose(float(surface.frequency), reference_frequency, rel_tol=1e-9, abs_tol=1e-12):
486
+ raise ValueError("All equivalent-current surfaces must share the same frequency.")
487
+ if not cmath.isclose(
488
+ complex(surface.background_eps_r), reference_eps, rel_tol=1e-6, abs_tol=1e-9
489
+ ) or not cmath.isclose(
490
+ complex(surface.background_mu_r), reference_mu, rel_tol=1e-6, abs_tol=1e-9
491
+ ):
492
+ raise ValueError(
493
+ "All equivalent-current surfaces must share the same exterior background medium."
494
+ )
495
+ object.__setattr__(self, "surfaces", resolved_surfaces)
496
+
497
+ @property
498
+ def frequency(self) -> float:
499
+ return float(self.surfaces[0].frequency)
500
+
501
+ @property
502
+ def background_eps_r(self) -> complex | float:
503
+ return self.surfaces[0].background_eps_r
504
+
505
+ @property
506
+ def background_mu_r(self) -> complex | float:
507
+ return self.surfaces[0].background_mu_r
508
+
509
+ @property
510
+ def device(self) -> torch.device:
511
+ return self.surfaces[0].device
512
+
513
+ def cropped(
514
+ self,
515
+ tangential_bounds: Mapping[str, tuple[float, float]] | None,
516
+ ) -> "EquivalentCurrentsSurface":
517
+ return EquivalentCurrentsSurface(surface.cropped(tangential_bounds) for surface in self.surfaces)
518
+
519
+ def windowed(self, window_size: tuple[float, float] | None) -> "EquivalentCurrentsSurface":
520
+ return EquivalentCurrentsSurface(surface.windowed(window_size) for surface in self.surfaces)
521
+
522
+
523
+ @dataclass(frozen=True)
524
+ class SurfaceEquivalentCurrents:
525
+ """Equivalent electric/magnetic surface currents on an arbitrary closed
526
+ surface, sampled at explicit quadrature points with per-point area weights.
527
+
528
+ Unlike :class:`PlanarEquivalentCurrents`, the geometry is not restricted to
529
+ an axis-aligned plane: each quadrature point carries its own position and
530
+ the currents may point in any direction, so a genuinely curved Huygens
531
+ surface (a sphere, an ellipsoid, or a staircased/voxelized closed surface)
532
+ can be radiated by the same Stratton-Chu integral as a box. The surface
533
+ equivalence far field is independent of the enclosing surface's shape, so
534
+ the propagator kernels consume this the same way they consume planar faces.
535
+ """
536
+
537
+ frequency: float
538
+ points: torch.Tensor
539
+ weights: torch.Tensor
540
+ J: torch.Tensor
541
+ M: torch.Tensor
542
+ background_eps_r: complex | float = 1.0
543
+ background_mu_r: complex | float = 1.0
544
+
545
+ def __post_init__(self):
546
+ device = _resolve_tensor_device(self.points, self.weights, self.J, self.M)
547
+ real_dtype = _resolve_real_dtype(self.points, self.weights, self.J, self.M)
548
+ complex_dtype = _resolve_complex_dtype(self.J, self.M)
549
+ points = _to_real_tensor(self.points, device=device, dtype=real_dtype)
550
+ if points.ndim != 2 or points.shape[1] != 3:
551
+ raise ValueError(f"points must have shape (N, 3), got {tuple(points.shape)}.")
552
+ count = int(points.shape[0])
553
+ if count < 1:
554
+ raise ValueError("SurfaceEquivalentCurrents requires at least one quadrature point.")
555
+ weights = _to_real_tensor(self.weights, device=device, dtype=real_dtype).reshape(-1)
556
+ if weights.numel() != count:
557
+ raise ValueError(f"weights must have shape ({count},), got {tuple(weights.shape)}.")
558
+ if float(self.frequency) <= 0.0:
559
+ raise ValueError("frequency must be positive.")
560
+ j_field = _to_complex_tensor(self.J, device=device, dtype=complex_dtype)
561
+ m_field = _to_complex_tensor(self.M, device=device, dtype=complex_dtype)
562
+ for name, field in (("J", j_field), ("M", m_field)):
563
+ if tuple(field.shape) != (count, 3):
564
+ raise ValueError(f"{name} must have shape ({count}, 3), got {tuple(field.shape)}.")
565
+ background_eps_r, background_mu_r = _normalize_background(
566
+ self.background_eps_r, self.background_mu_r
567
+ )
568
+
569
+ object.__setattr__(self, "frequency", float(self.frequency))
570
+ object.__setattr__(self, "points", points)
571
+ object.__setattr__(self, "weights", weights)
572
+ object.__setattr__(self, "J", j_field)
573
+ object.__setattr__(self, "M", m_field)
574
+ object.__setattr__(self, "background_eps_r", background_eps_r)
575
+ object.__setattr__(self, "background_mu_r", background_mu_r)
576
+
577
+ @property
578
+ def device(self) -> torch.device:
579
+ return self.points.device
580
+
581
+ @property
582
+ def coord_dtype(self) -> torch.dtype:
583
+ return self.points.dtype
584
+
585
+ @property
586
+ def shape(self) -> tuple[int]:
587
+ return (int(self.points.shape[0]),)
588
+
589
+ def quadrature(self) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
590
+ """Flattened ``(points, J_weighted, M_weighted)`` with the per-point
591
+ surface-area weights folded into the currents, matching the interface of
592
+ :meth:`PlanarEquivalentCurrents.quadrature`."""
593
+ weights = self.weights[:, None]
594
+ j_weighted = self.J * weights.to(dtype=self.J.dtype)
595
+ m_weighted = self.M * weights.to(dtype=self.M.dtype)
596
+ return self.points, j_weighted, m_weighted
597
+
598
+
599
+ def equivalent_surface_currents_from_surface_samples(
600
+ *,
601
+ frequency: float,
602
+ points,
603
+ normals,
604
+ areas,
605
+ E,
606
+ H,
607
+ background_eps_r: complex | float = 1.0,
608
+ background_mu_r: complex | float = 1.0,
609
+ ) -> SurfaceEquivalentCurrents:
610
+ """Build equivalent surface currents on an arbitrary closed surface from
611
+ fields sampled at quadrature points.
612
+
613
+ The surface equivalence principle replaces the interior sources by
614
+ ``J = n x H`` and ``M = -n x E`` on any closed surface enclosing them, where
615
+ ``n`` is the outward unit normal. This constructor accepts a general point
616
+ cloud (``points``), the outward normals (``normals``, normalized here), the
617
+ per-point differential surface areas (``areas``), and the tangential-plus
618
+ fields (``E``, ``H``), so a curved Huygens surface can drive the same
619
+ Stratton-Chu / near-to-far-field transform as an axis-aligned box.
620
+ """
621
+ device = _resolve_tensor_device(points, normals, areas, E, H)
622
+ real_dtype = _resolve_real_dtype(points, normals, areas)
623
+ complex_dtype = _resolve_complex_dtype(E, H)
624
+
625
+ point_tensor = _to_real_tensor(points, device=device, dtype=real_dtype)
626
+ if point_tensor.ndim != 2 or point_tensor.shape[1] != 3:
627
+ raise ValueError(f"points must have shape (N, 3), got {tuple(point_tensor.shape)}.")
628
+ count = int(point_tensor.shape[0])
629
+
630
+ normal_tensor = _to_real_tensor(normals, device=device, dtype=real_dtype)
631
+ if tuple(normal_tensor.shape) != (count, 3):
632
+ raise ValueError(f"normals must have shape ({count}, 3), got {tuple(normal_tensor.shape)}.")
633
+ norm = torch.linalg.norm(normal_tensor, dim=-1, keepdim=True)
634
+ if torch.any(norm <= 0.0):
635
+ raise ValueError("normals must be non-zero vectors.")
636
+ unit_normal = (normal_tensor / norm).to(dtype=complex_dtype)
637
+
638
+ area_tensor = _to_real_tensor(areas, device=device, dtype=real_dtype).reshape(-1)
639
+ if area_tensor.numel() != count:
640
+ raise ValueError(f"areas must have shape ({count},), got {tuple(area_tensor.shape)}.")
641
+
642
+ e_field = _to_complex_tensor(E, device=device, dtype=complex_dtype)
643
+ h_field = _to_complex_tensor(H, device=device, dtype=complex_dtype)
644
+ for name, field in (("E", e_field), ("H", h_field)):
645
+ if tuple(field.shape) != (count, 3):
646
+ raise ValueError(f"{name} must have shape ({count}, 3), got {tuple(field.shape)}.")
647
+
648
+ j_field = torch.cross(unit_normal, h_field, dim=-1)
649
+ m_field = -torch.cross(unit_normal, e_field, dim=-1)
650
+ return SurfaceEquivalentCurrents(
651
+ frequency=frequency,
652
+ points=point_tensor,
653
+ weights=area_tensor,
654
+ J=j_field,
655
+ M=m_field,
656
+ background_eps_r=background_eps_r,
657
+ background_mu_r=background_mu_r,
658
+ )
659
+
660
+
661
+ def _normalize_currents_collection(
662
+ currents: PlanarEquivalentCurrents | EquivalentCurrentsSurface | SurfaceEquivalentCurrents,
663
+ ) -> tuple[PlanarEquivalentCurrents | SurfaceEquivalentCurrents, ...]:
664
+ if isinstance(currents, EquivalentCurrentsSurface):
665
+ return currents.surfaces
666
+ if isinstance(currents, (PlanarEquivalentCurrents, SurfaceEquivalentCurrents)):
667
+ return (currents,)
668
+ raise TypeError(
669
+ "currents must be PlanarEquivalentCurrents, EquivalentCurrentsSurface, or "
670
+ f"SurfaceEquivalentCurrents, got {type(currents).__name__}."
671
+ )
672
+
673
+
674
+ def _payload_monitor_frequencies(payload: Mapping[str, object]) -> tuple[float, ...]:
675
+ if "frequencies" in payload:
676
+ return tuple(float(freq) for freq in payload["frequencies"] if freq is not None)
677
+ if "frequency" in payload and payload["frequency"] is not None:
678
+ return (float(payload["frequency"]),)
679
+ return ()
680
+
681
+
682
+ def _coord_indices_from_bounds(coords: torch.Tensor, lower: float, upper: float) -> torch.Tensor:
683
+ coord_tensor = coords.to(dtype=coords.real.dtype)
684
+ tolerance = 1e-12 * max(
685
+ abs(lower),
686
+ abs(upper),
687
+ float(torch.max(torch.abs(coord_tensor)).item()),
688
+ 1.0,
689
+ )
690
+ mask = (coord_tensor >= lower - tolerance) & (coord_tensor <= upper + tolerance)
691
+ indices = torch.nonzero(mask, as_tuple=False).reshape(-1)
692
+ if indices.numel() < 2:
693
+ raise NotImplementedError(
694
+ "Closed-surface Huygens validation requires at least two exterior material samples on each tangential axis."
695
+ )
696
+ return indices.to(device=coords.device)
697
+
698
+
699
+ def _sample_exterior_patch(
700
+ material: torch.Tensor,
701
+ prepared_scene,
702
+ face_payload: Mapping[str, object],
703
+ ) -> torch.Tensor:
704
+ axis = _normalize_axis(face_payload["axis"])
705
+ axis_index = _AXIS_TO_INDEX[axis]
706
+ tangential = _tangential_indices(axis)
707
+ permuted = material.permute((axis_index, tangential[0], tangential[1]))
708
+
709
+ normal_direction = face_payload.get("normal_direction", "+")
710
+ sign = 1 if normal_direction == "+" else -1
711
+ normal_coords = getattr(prepared_scene, axis)
712
+ plane_position = float(face_payload["position"])
713
+ face_index = int(torch.argmin(torch.abs(normal_coords - plane_position)).item())
714
+ outside_index = face_index + sign
715
+ if outside_index < 0 or outside_index >= normal_coords.numel():
716
+ raise NotImplementedError(
717
+ "Closed-surface Huygens faces must have at least one material cell outside the surface."
718
+ )
719
+
720
+ tangential_bounds = face_payload.get("tangential_bounds")
721
+ if tangential_bounds is None:
722
+ raise NotImplementedError(
723
+ "Closed-surface Huygens faces must expose tangential_bounds metadata."
724
+ )
725
+
726
+ coord_a_name = _INDEX_TO_AXIS[tangential[0]]
727
+ coord_b_name = _INDEX_TO_AXIS[tangential[1]]
728
+ coord_a = getattr(prepared_scene, coord_a_name)
729
+ coord_b = getattr(prepared_scene, coord_b_name)
730
+ indices_a = _coord_indices_from_bounds(coord_a, *tangential_bounds[coord_a_name])
731
+ indices_b = _coord_indices_from_bounds(coord_b, *tangential_bounds[coord_b_name])
732
+
733
+ return permuted[outside_index].index_select(0, indices_a).index_select(1, indices_b)
734
+
735
+
736
+ def _validate_homogeneous_exterior(
737
+ result, monitor: Mapping[str, object], frequency: float
738
+ ) -> tuple[complex | float, complex | float] | None:
739
+ """Validate that the medium immediately outside a closed Huygens surface is a
740
+ single homogeneous background and return that background as relative
741
+ ``(eps_r, mu_r)`` so the exterior wavenumber/impedance can drive the
742
+ near-to-far-field transform. Returns ``None`` for non-closed-surface
743
+ monitors (their background defaults to vacuum)."""
744
+ if monitor.get("kind") != "closed_surface":
745
+ return None
746
+
747
+ prepared_scene = result.prepared_scene
748
+ eps_r, mu_r = prepared_scene.compile_relative_materials(frequency=frequency)
749
+ reference_eps = None
750
+ reference_mu = None
751
+
752
+ for face_payload in monitor.get("faces", {}).values():
753
+ eps_patch = _sample_exterior_patch(eps_r, prepared_scene, face_payload)
754
+ mu_patch = _sample_exterior_patch(mu_r, prepared_scene, face_payload)
755
+ eps_value = eps_patch.reshape(-1)[0]
756
+ mu_value = mu_patch.reshape(-1)[0]
757
+
758
+ if not torch.allclose(
759
+ eps_patch,
760
+ torch.full_like(eps_patch, eps_value),
761
+ rtol=1e-4,
762
+ atol=1e-4,
763
+ ) or not torch.allclose(
764
+ mu_patch,
765
+ torch.full_like(mu_patch, mu_value),
766
+ rtol=1e-4,
767
+ atol=1e-4,
768
+ ):
769
+ raise NotImplementedError(
770
+ "Closed-surface Huygens currently requires a homogeneous exterior medium immediately outside every face."
771
+ )
772
+
773
+ if reference_eps is None:
774
+ reference_eps = eps_value
775
+ reference_mu = mu_value
776
+ continue
777
+ if not torch.allclose(reference_eps.reshape(()), eps_value.reshape(()), rtol=1e-4, atol=1e-4) or not torch.allclose(
778
+ reference_mu.reshape(()),
779
+ mu_value.reshape(()),
780
+ rtol=1e-4,
781
+ atol=1e-4,
782
+ ):
783
+ raise NotImplementedError(
784
+ "Closed-surface Huygens currently requires a single homogeneous exterior medium across all faces; "
785
+ "layered or piecewise exteriors are not supported."
786
+ )
787
+
788
+ if reference_eps is None:
789
+ return None
790
+ return complex(reference_eps.item()), complex(reference_mu.item())
791
+
792
+
793
+ def _equivalent_surface_currents_from_payload(
794
+ monitor: Mapping[str, object],
795
+ *,
796
+ tangential_bounds: Mapping[str, tuple[float, float]] | None = None,
797
+ normal_direction: str | int | float | None = None,
798
+ window_size: tuple[float, float] | None = None,
799
+ background: tuple[complex | float, complex | float] = (1.0, 1.0),
800
+ ) -> PlanarEquivalentCurrents | EquivalentCurrentsSurface:
801
+ if monitor.get("kind") == "closed_surface":
802
+ surfaces = []
803
+ for face_payload in monitor.get("faces", {}).values():
804
+ surfaces.append(
805
+ _equivalent_surface_currents_from_payload(
806
+ face_payload,
807
+ tangential_bounds=tangential_bounds,
808
+ normal_direction=normal_direction,
809
+ window_size=window_size,
810
+ background=background,
811
+ )
812
+ )
813
+ return EquivalentCurrentsSurface(tuple(surfaces))
814
+
815
+ if monitor.get("kind") != "plane":
816
+ raise ValueError("Monitor payload must be a plane or closed-surface monitor.")
817
+
818
+ axis = _normalize_axis(monitor["axis"])
819
+ tangential_a, tangential_b = _tangential_indices(axis)
820
+ coord_name_a = _INDEX_TO_AXIS[tangential_a]
821
+ coord_name_b = _INDEX_TO_AXIS[tangential_b]
822
+ if coord_name_a not in monitor or coord_name_b not in monitor:
823
+ raise ValueError(
824
+ "Plane monitor payload does not expose aligned tangential coordinates."
825
+ )
826
+
827
+ resolved_frequency = float(monitor.get("frequency"))
828
+ resolved_normal_direction = monitor.get("normal_direction", "+") if normal_direction is None else normal_direction
829
+ return equivalent_surface_currents_from_fields(
830
+ axis=axis,
831
+ position=float(monitor["position"]),
832
+ frequency=resolved_frequency,
833
+ u=monitor[coord_name_a],
834
+ v=monitor[coord_name_b],
835
+ fields=monitor,
836
+ normal_direction=resolved_normal_direction,
837
+ window_size=None,
838
+ background_eps_r=background[0],
839
+ background_mu_r=background[1],
840
+ ).cropped(tangential_bounds).windowed(window_size)
841
+
842
+
843
+ def equivalent_surface_currents_from_fields(
844
+ *,
845
+ axis: str,
846
+ position: float,
847
+ frequency: float,
848
+ u,
849
+ v,
850
+ fields: Mapping[str, torch.Tensor],
851
+ normal_direction: str | int | float = "+",
852
+ window_size: tuple[float, float] | None = None,
853
+ background_eps_r: complex | float = 1.0,
854
+ background_mu_r: complex | float = 1.0,
855
+ ) -> PlanarEquivalentCurrents:
856
+ axis_name = _normalize_axis(axis)
857
+ normalized_fields = {str(name).upper(): values for name, values in fields.items()}
858
+ device = _resolve_tensor_device(u, v, *normalized_fields.values())
859
+ real_dtype = _resolve_real_dtype(u, v, *normalized_fields.values())
860
+ complex_dtype = _resolve_complex_dtype(*normalized_fields.values())
861
+ u_coords = _as_1d_coords(u, "u", device=device, dtype=real_dtype)
862
+ v_coords = _as_1d_coords(v, "v", device=device, dtype=real_dtype)
863
+ shape = (u_coords.numel(), v_coords.numel())
864
+ tangential = _tangential_indices(axis_name)
865
+
866
+ required = [
867
+ f"E{_INDEX_TO_AXIS[index].upper()}"
868
+ for index in tangential
869
+ ] + [
870
+ f"H{_INDEX_TO_AXIS[index].upper()}"
871
+ for index in tangential
872
+ ]
873
+ missing = [name for name in required if name not in normalized_fields]
874
+ if missing:
875
+ missing_list = ", ".join(missing)
876
+ raise ValueError(
877
+ f"Missing tangential field components for {axis_name}-normal plane: {missing_list}."
878
+ )
879
+
880
+ e_field = torch.zeros(shape + (3,), device=device, dtype=complex_dtype)
881
+ h_field = torch.zeros(shape + (3,), device=device, dtype=complex_dtype)
882
+ for index, axis_label in enumerate(_INDEX_TO_AXIS):
883
+ e_name = f"E{axis_label.upper()}"
884
+ h_name = f"H{axis_label.upper()}"
885
+ if e_name in normalized_fields:
886
+ e_field[..., index] = _as_plane_component(
887
+ normalized_fields[e_name],
888
+ e_name,
889
+ shape,
890
+ device=device,
891
+ dtype=complex_dtype,
892
+ )
893
+ if h_name in normalized_fields:
894
+ h_field[..., index] = _as_plane_component(
895
+ normalized_fields[h_name],
896
+ h_name,
897
+ shape,
898
+ device=device,
899
+ dtype=complex_dtype,
900
+ )
901
+
902
+ normal = torch.zeros((1, 1, 3), device=device, dtype=real_dtype)
903
+ normal[..., _AXIS_TO_INDEX[axis_name]] = _normalize_normal_direction(normal_direction)
904
+ normal_complex = normal.to(dtype=complex_dtype).expand_as(e_field)
905
+ j_field = torch.cross(normal_complex, h_field, dim=-1)
906
+ m_field = -torch.cross(normal_complex, e_field, dim=-1)
907
+
908
+ currents = PlanarEquivalentCurrents(
909
+ axis=axis_name,
910
+ position=position,
911
+ frequency=frequency,
912
+ u=u_coords,
913
+ v=v_coords,
914
+ J=j_field,
915
+ M=m_field,
916
+ background_eps_r=background_eps_r,
917
+ background_mu_r=background_mu_r,
918
+ )
919
+ return currents.windowed(window_size)
920
+
921
+
922
+ def equivalent_surface_currents_from_monitor(
923
+ result,
924
+ monitor_name: str,
925
+ *,
926
+ frequency: float | None = None,
927
+ freq_index: int | None = None,
928
+ tangential_bounds: Mapping[str, tuple[float, float]] | None = None,
929
+ normal_direction: str | int | float | None = None,
930
+ window_size: tuple[float, float] | None = None,
931
+ ) -> PlanarEquivalentCurrents | EquivalentCurrentsSurface:
932
+ monitor = result.monitor(monitor_name, frequency=frequency, freq_index=freq_index)
933
+ monitor_frequencies = _payload_monitor_frequencies(monitor)
934
+ if len(monitor_frequencies) > 1:
935
+ monitor = result.monitor(monitor_name, freq_index=0)
936
+ resolved_frequency = float(monitor.get("frequency", getattr(result, "frequency", None)))
937
+ background = _validate_homogeneous_exterior(result, monitor, resolved_frequency)
938
+ if background is None:
939
+ background = (1.0, 1.0)
940
+ return _equivalent_surface_currents_from_payload(
941
+ monitor,
942
+ tangential_bounds=tangential_bounds,
943
+ normal_direction=normal_direction,
944
+ window_size=window_size,
945
+ background=background,
946
+ )
947
+
948
+
949
+ def equivalent_surface_currents_from_monitors(
950
+ result,
951
+ monitor_names,
952
+ *,
953
+ frequency: float | None = None,
954
+ freq_index: int | None = None,
955
+ tangential_bounds: Mapping[str, tuple[float, float]] | None = None,
956
+ monitor_tangential_bounds: Mapping[str, Mapping[str, tuple[float, float]]] | None = None,
957
+ normal_directions: Mapping[str, str | int | float] | None = None,
958
+ window_size: tuple[float, float] | None = None,
959
+ ) -> EquivalentCurrentsSurface:
960
+ names = tuple(str(name) for name in monitor_names)
961
+ if not names:
962
+ raise ValueError("monitor_names must contain at least one monitor.")
963
+
964
+ surfaces = []
965
+ for name in names:
966
+ override_direction = None if normal_directions is None else normal_directions.get(name)
967
+ override_bounds = tangential_bounds if monitor_tangential_bounds is None else monitor_tangential_bounds.get(
968
+ name,
969
+ tangential_bounds,
970
+ )
971
+ resolved = equivalent_surface_currents_from_monitor(
972
+ result,
973
+ name,
974
+ frequency=frequency,
975
+ freq_index=freq_index,
976
+ tangential_bounds=override_bounds,
977
+ normal_direction=override_direction,
978
+ window_size=window_size,
979
+ )
980
+ if isinstance(resolved, EquivalentCurrentsSurface):
981
+ surfaces.extend(resolved.surfaces)
982
+ else:
983
+ surfaces.append(resolved)
984
+ return EquivalentCurrentsSurface(tuple(surfaces))
985
+
986
+
987
+ class StrattonChuPropagator:
988
+ def __init__(
989
+ self,
990
+ currents: PlanarEquivalentCurrents | EquivalentCurrentsSurface | SurfaceEquivalentCurrents,
991
+ *,
992
+ solver=None,
993
+ c: float | None = None,
994
+ eps0: float | None = None,
995
+ mu0: float | None = None,
996
+ background_eps_r: complex | float | None = None,
997
+ background_mu_r: complex | float | None = None,
998
+ device: str | torch.device | None = None,
999
+ ):
1000
+ self.currents = currents
1001
+ self._surfaces = _normalize_currents_collection(currents)
1002
+ self.device = _resolve_device(device)
1003
+ self.c, self.eps0, self.mu0 = _resolve_physical_constants(
1004
+ solver=solver,
1005
+ c=c,
1006
+ eps0=eps0,
1007
+ mu0=mu0,
1008
+ )
1009
+ self.background_eps_r, self.background_mu_r = _resolve_currents_background(
1010
+ self._surfaces, background_eps_r, background_mu_r
1011
+ )
1012
+ self.frequency = float(self._surfaces[0].frequency)
1013
+ self.coord_dtype = self._surfaces[0].coord_dtype
1014
+ self.field_dtype = _resolve_complex_dtype(*(surface.J for surface in self._surfaces), *(surface.M for surface in self._surfaces))
1015
+ self.omega = 2.0 * math.pi * self.frequency
1016
+ self.eta0 = math.sqrt(self.mu0 / self.eps0)
1017
+ self.k, self.eta = _background_wavenumber_and_impedance(
1018
+ self.background_eps_r,
1019
+ self.background_mu_r,
1020
+ omega=self.omega,
1021
+ c=self.c,
1022
+ eta_vacuum=self.eta0,
1023
+ )
1024
+ self.k_sq = self.k**2
1025
+ # Radiation into the homogeneous exterior scales i*omega with the
1026
+ # background permeability/permittivity: mu -> mu0*mu_r, eps -> eps0*eps_r.
1027
+ self.i_omega_mu = 1j * self.omega * self.mu0 * self.background_mu_r
1028
+ self.i_omega_eps = 1j * self.omega * self.eps0 * self.background_eps_r
1029
+
1030
+ src_points = []
1031
+ j_weighted = []
1032
+ m_weighted = []
1033
+ for surface in self._surfaces:
1034
+ points, surface_j, surface_m = surface.quadrature()
1035
+ src_points.append(points.reshape(-1, 3))
1036
+ j_weighted.append(surface_j.reshape(-1, 3))
1037
+ m_weighted.append(surface_m.reshape(-1, 3))
1038
+ self._src_points = torch.cat(src_points, dim=0).to(device=self.device, dtype=self.coord_dtype)
1039
+ self._J_w = torch.cat(j_weighted, dim=0).to(device=self.device, dtype=self.field_dtype)
1040
+ self._M_w = torch.cat(m_weighted, dim=0).to(device=self.device, dtype=self.field_dtype)
1041
+
1042
+ def _plane_reference_surface(self, axis: str | None) -> tuple[str, PlanarEquivalentCurrents]:
1043
+ if axis is not None:
1044
+ axis_name = _normalize_axis(axis)
1045
+ for surface in self._surfaces:
1046
+ if getattr(surface, "axis", None) == axis_name:
1047
+ return axis_name, surface
1048
+ raise ValueError(f"No source surface is aligned with axis {axis_name!r}. Pass explicit u/v coordinates.")
1049
+
1050
+ axes = [getattr(surface, "axis", None) for surface in self._surfaces]
1051
+ if axes[0] is None or any(surface_axis != axes[0] for surface_axis in axes[1:]):
1052
+ raise ValueError(
1053
+ "axis must be provided when propagating a multi-plane or general surface to a plane. "
1054
+ "The current surface has no single source-plane orientation; pass explicit axis, u, and v."
1055
+ )
1056
+ return axes[0], self._surfaces[0]
1057
+
1058
+ def _compute_batch(self, observation_points: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
1059
+ observation = observation_points.to(device=self.device, dtype=self.coord_dtype)
1060
+ displacement = observation[:, None, :] - self._src_points[None, :, :]
1061
+ distance = torch.linalg.norm(displacement, dim=-1)
1062
+ if torch.any(distance <= 1e-15):
1063
+ raise ValueError("Observation points must not coincide with source quadrature points.")
1064
+
1065
+ direction = displacement / distance[..., None]
1066
+ direction_complex = direction.to(dtype=self.field_dtype)
1067
+ distance_complex = distance.to(dtype=self.field_dtype)
1068
+
1069
+ green = torch.exp(1j * self.k * distance_complex) * (1.0 / (4.0 * math.pi)) / distance_complex
1070
+ d_green = green * ((1j * self.k * distance_complex) - 1.0) / distance_complex
1071
+ d2_green = d_green * ((1j * self.k * distance_complex) - 1.0) / distance_complex + green / distance_complex.square()
1072
+ a_coeff = d_green / distance_complex
1073
+ b_coeff = d2_green - a_coeff
1074
+
1075
+ j_weighted = self._J_w[None, :, :]
1076
+ m_weighted = self._M_w[None, :, :]
1077
+ r_dot_j = torch.sum(direction_complex * j_weighted, dim=-1)
1078
+ r_dot_m = torch.sum(direction_complex * m_weighted, dim=-1)
1079
+
1080
+ e_integrand = self.i_omega_mu * (
1081
+ j_weighted * green[..., None]
1082
+ + (
1083
+ j_weighted * a_coeff[..., None]
1084
+ + direction_complex * (r_dot_j * b_coeff)[..., None]
1085
+ )
1086
+ / self.k_sq
1087
+ ) - torch.cross(direction_complex, m_weighted, dim=-1) * d_green[..., None]
1088
+ h_integrand = self.i_omega_eps * (
1089
+ m_weighted * green[..., None]
1090
+ + (
1091
+ m_weighted * a_coeff[..., None]
1092
+ + direction_complex * (r_dot_m * b_coeff)[..., None]
1093
+ )
1094
+ / self.k_sq
1095
+ ) + torch.cross(direction_complex, j_weighted, dim=-1) * d_green[..., None]
1096
+
1097
+ return e_integrand.sum(dim=1), h_integrand.sum(dim=1)
1098
+
1099
+ def propagate_points(self, points, *, batch_size: int = 256) -> dict[str, torch.Tensor]:
1100
+ observation = _to_real_tensor(points, device=self.device, dtype=self.coord_dtype)
1101
+ if observation.ndim == 1:
1102
+ if observation.shape[0] != 3:
1103
+ raise ValueError("points must have shape (3,) or (N, 3).")
1104
+ observation = observation[None, :]
1105
+ if observation.ndim != 2 or observation.shape[1] != 3:
1106
+ raise ValueError("points must have shape (N, 3).")
1107
+ if batch_size <= 0:
1108
+ raise ValueError("batch_size must be positive.")
1109
+
1110
+ e_batches = []
1111
+ h_batches = []
1112
+ for start in range(0, observation.shape[0], batch_size):
1113
+ stop = min(start + batch_size, observation.shape[0])
1114
+ e_batch, h_batch = self._compute_batch(observation[start:stop])
1115
+ e_batches.append(e_batch)
1116
+ h_batches.append(h_batch)
1117
+
1118
+ e_field = torch.cat(e_batches, dim=0)
1119
+ h_field = torch.cat(h_batches, dim=0)
1120
+ return {
1121
+ "points": observation,
1122
+ "E": e_field,
1123
+ "H": h_field,
1124
+ "Ex": e_field[:, 0],
1125
+ "Ey": e_field[:, 1],
1126
+ "Ez": e_field[:, 2],
1127
+ "Hx": h_field[:, 0],
1128
+ "Hy": h_field[:, 1],
1129
+ "Hz": h_field[:, 2],
1130
+ }
1131
+
1132
+ def propagate_plane(
1133
+ self,
1134
+ *,
1135
+ axis: str | None = None,
1136
+ position: float,
1137
+ u=None,
1138
+ v=None,
1139
+ batch_size: int = 256,
1140
+ ) -> dict[str, torch.Tensor | float]:
1141
+ if u is not None and v is not None:
1142
+ plane_axis = _normalize_axis(axis) if axis is not None else self._plane_reference_surface(None)[0]
1143
+ u_coords = _as_1d_coords(u, "u", device=self.device, dtype=self.coord_dtype)
1144
+ v_coords = _as_1d_coords(v, "v", device=self.device, dtype=self.coord_dtype)
1145
+ else:
1146
+ plane_axis, reference_surface = self._plane_reference_surface(axis)
1147
+ u_coords = reference_surface.u if u is None else _as_1d_coords(u, "u", device=self.device, dtype=self.coord_dtype)
1148
+ v_coords = reference_surface.v if v is None else _as_1d_coords(v, "v", device=self.device, dtype=self.coord_dtype)
1149
+ grid_points = build_plane_points(plane_axis, position, u_coords, v_coords).to(device=self.device, dtype=self.coord_dtype)
1150
+ propagated = self.propagate_points(grid_points.reshape(-1, 3), batch_size=batch_size)
1151
+ shape = (int(u_coords.numel()), int(v_coords.numel()))
1152
+ tangential_axes = tuple(_INDEX_TO_AXIS[index] for index in _tangential_indices(plane_axis))
1153
+
1154
+ result = {
1155
+ "axis": plane_axis,
1156
+ "position": float(position),
1157
+ "frequency": self.frequency,
1158
+ "u": u_coords,
1159
+ "v": v_coords,
1160
+ tangential_axes[0]: u_coords,
1161
+ tangential_axes[1]: v_coords,
1162
+ plane_axis: float(position),
1163
+ }
1164
+
1165
+ e_field = propagated["E"].reshape(shape + (3,))
1166
+ h_field = propagated["H"].reshape(shape + (3,))
1167
+ result["E"] = e_field
1168
+ result["H"] = h_field
1169
+ result["Ex"] = e_field[..., 0]
1170
+ result["Ey"] = e_field[..., 1]
1171
+ result["Ez"] = e_field[..., 2]
1172
+ result["Hx"] = h_field[..., 0]
1173
+ result["Hy"] = h_field[..., 1]
1174
+ result["Hz"] = h_field[..., 2]
1175
+ result["E_intensity"] = torch.sum(torch.abs(e_field).square(), dim=-1)
1176
+ return result
1177
+
1178
+ def propagate(
1179
+ self,
1180
+ obs_u,
1181
+ obs_v,
1182
+ obs_position: float,
1183
+ *,
1184
+ axis: str | None = None,
1185
+ batch_size: int = 256,
1186
+ ) -> dict[str, torch.Tensor | float]:
1187
+ return self.propagate_plane(
1188
+ axis=axis,
1189
+ position=obs_position,
1190
+ u=obs_u,
1191
+ v=obs_v,
1192
+ batch_size=batch_size,
1193
+ )