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