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,1453 @@
|
|
|
1
|
+
import warnings
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import torch
|
|
5
|
+
|
|
6
|
+
from ..monitors import normalize_axis, normalize_component
|
|
7
|
+
from .coords import component_coords
|
|
8
|
+
from .boundary import combine_complex_spectral_components, has_complex_fields
|
|
9
|
+
|
|
10
|
+
_AXIS_CODES = {"x": 0, "y": 1, "z": 2}
|
|
11
|
+
|
|
12
|
+
_PLANE_COORD_NAMES = {
|
|
13
|
+
"x": ("y", "z"),
|
|
14
|
+
"y": ("x", "z"),
|
|
15
|
+
"z": ("x", "y"),
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
_PLANE_ALIGNMENT_RULES = {
|
|
19
|
+
"x": {
|
|
20
|
+
"coord_sources": {"y": ("Ey", 0, "Hz", 0), "z": ("Ez", 1, "Hy", 1)},
|
|
21
|
+
"average_axis": {"Ex": (0, 1), "Ey": 1, "Ez": 0, "Hy": 0, "Hz": 1},
|
|
22
|
+
},
|
|
23
|
+
"y": {
|
|
24
|
+
"coord_sources": {"x": ("Ex", 0, "Hz", 0), "z": ("Ez", 1, "Hx", 1)},
|
|
25
|
+
"average_axis": {"Ex": 1, "Ey": (0, 1), "Ez": 0, "Hx": 0, "Hz": 1},
|
|
26
|
+
},
|
|
27
|
+
"z": {
|
|
28
|
+
"coord_sources": {"x": ("Ex", 0, "Hy", 0), "y": ("Ey", 1, "Hx", 1)},
|
|
29
|
+
"average_axis": {"Ex": 1, "Ey": 0, "Ez": (0, 1), "Hx": 0, "Hy": 1},
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
# Tangential (Ea, Eb, Ha, Hb) ordering for the plane-normal Poynting component
|
|
34
|
+
# (E x H)_n = Ea*Hb - Eb*Ha, used by the native flux-time reduction kernel.
|
|
35
|
+
_FLUX_KERNEL_COMPONENTS = {
|
|
36
|
+
"x": ("Ey", "Ez", "Hy", "Hz"),
|
|
37
|
+
"y": ("Ez", "Ex", "Hz", "Hx"),
|
|
38
|
+
"z": ("Ex", "Ey", "Hx", "Hy"),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _field_name(component):
|
|
43
|
+
return normalize_component(component).capitalize()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _plane_coord_names(axis):
|
|
47
|
+
return _PLANE_COORD_NAMES[normalize_axis(axis)]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _observer_is_point(observer):
|
|
51
|
+
if "field_index" in observer:
|
|
52
|
+
return True
|
|
53
|
+
if "axis" in observer:
|
|
54
|
+
return False
|
|
55
|
+
kind = observer.get("kind")
|
|
56
|
+
if kind is not None:
|
|
57
|
+
return kind == "point"
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _monitor_payload_is_point(payload):
|
|
62
|
+
if "field_indices" in payload:
|
|
63
|
+
return True
|
|
64
|
+
if "axis" in payload:
|
|
65
|
+
return False
|
|
66
|
+
kind = payload.get("kind")
|
|
67
|
+
if kind is not None:
|
|
68
|
+
return kind == "point"
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _point_observer_record(name, position, component="Ez"):
|
|
73
|
+
if len(position) != 3:
|
|
74
|
+
raise ValueError("Point observer position must have three coordinates.")
|
|
75
|
+
return {
|
|
76
|
+
"name": str(name),
|
|
77
|
+
"kind": "point",
|
|
78
|
+
"position": tuple(float(value) for value in position),
|
|
79
|
+
"component": normalize_component(component),
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _plane_observer_record(name, axis="z", position=0.0, component="Ez"):
|
|
84
|
+
return {
|
|
85
|
+
"name": str(name),
|
|
86
|
+
"kind": "plane",
|
|
87
|
+
"axis": normalize_axis(axis),
|
|
88
|
+
"position": float(position),
|
|
89
|
+
"component": normalize_component(component),
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _average_adjacent(data, axis):
|
|
94
|
+
if data.ndim == 3:
|
|
95
|
+
axis += 1
|
|
96
|
+
if data.shape[axis] < 2:
|
|
97
|
+
raise ValueError("Cannot average adjacent Yee samples on an axis with fewer than two points.")
|
|
98
|
+
slicer_lo = [slice(None)] * data.ndim
|
|
99
|
+
slicer_hi = [slice(None)] * data.ndim
|
|
100
|
+
slicer_lo[axis] = slice(0, -1)
|
|
101
|
+
slicer_hi[axis] = slice(1, None)
|
|
102
|
+
return 0.5 * (data[tuple(slicer_lo)] + data[tuple(slicer_hi)])
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _average_adjacent_axes(data, axes):
|
|
106
|
+
if isinstance(axes, int):
|
|
107
|
+
return _average_adjacent(data, axes)
|
|
108
|
+
|
|
109
|
+
averaged = data
|
|
110
|
+
for axis in axes:
|
|
111
|
+
averaged = _average_adjacent(averaged, axis)
|
|
112
|
+
return averaged
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _resolve_coord_source(component_payloads, *candidates):
|
|
116
|
+
for component_name, coord_index in zip(candidates[::2], candidates[1::2]):
|
|
117
|
+
payload = component_payloads.get(component_name)
|
|
118
|
+
if payload is not None:
|
|
119
|
+
return payload["coords"][coord_index]
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _align_plane_monitor_payload(axis, component_payloads):
|
|
124
|
+
axis_name = normalize_axis(axis)
|
|
125
|
+
rules = _PLANE_ALIGNMENT_RULES[axis_name]
|
|
126
|
+
coord_names = _plane_coord_names(axis_name)
|
|
127
|
+
|
|
128
|
+
aligned = {}
|
|
129
|
+
for component_name, payload in component_payloads.items():
|
|
130
|
+
average_axis = rules["average_axis"].get(component_name)
|
|
131
|
+
if average_axis is None:
|
|
132
|
+
continue
|
|
133
|
+
else:
|
|
134
|
+
aligned[component_name] = _average_adjacent_axes(payload["data"], average_axis)
|
|
135
|
+
|
|
136
|
+
coord_a = _resolve_coord_source(
|
|
137
|
+
component_payloads,
|
|
138
|
+
*rules["coord_sources"][coord_names[0]],
|
|
139
|
+
)
|
|
140
|
+
coord_b = _resolve_coord_source(
|
|
141
|
+
component_payloads,
|
|
142
|
+
*rules["coord_sources"][coord_names[1]],
|
|
143
|
+
)
|
|
144
|
+
if coord_a is None or coord_b is None:
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
coord_names[0]: coord_a,
|
|
149
|
+
coord_names[1]: coord_b,
|
|
150
|
+
"fields": aligned,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _physical_plane_masks(solver, axis, coord_a, coord_b):
|
|
155
|
+
coord_names = _plane_coord_names(axis)
|
|
156
|
+
domain_range = getattr(solver.scene, "physical_domain_range", solver.scene.domain_range)
|
|
157
|
+
bounds = {
|
|
158
|
+
"x": (domain_range[0], domain_range[1]),
|
|
159
|
+
"y": (domain_range[2], domain_range[3]),
|
|
160
|
+
"z": (domain_range[4], domain_range[5]),
|
|
161
|
+
}
|
|
162
|
+
arrays = (np.asarray(coord_a, dtype=np.float64), np.asarray(coord_b, dtype=np.float64))
|
|
163
|
+
masks = []
|
|
164
|
+
for name, values in zip(coord_names, arrays):
|
|
165
|
+
lo, hi = bounds[name]
|
|
166
|
+
tolerance = 1e-7 * max(1.0, float(np.max(np.abs(values))), abs(lo), abs(hi))
|
|
167
|
+
masks.append((values >= lo - tolerance) & (values <= hi + tolerance))
|
|
168
|
+
return tuple(masks)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _crop_aligned_plane_to_physical_bounds(solver, axis, aligned):
|
|
172
|
+
coord_names = _plane_coord_names(axis)
|
|
173
|
+
mask_a, mask_b = _physical_plane_masks(
|
|
174
|
+
solver,
|
|
175
|
+
axis,
|
|
176
|
+
aligned[coord_names[0]],
|
|
177
|
+
aligned[coord_names[1]],
|
|
178
|
+
)
|
|
179
|
+
indices_a = np.flatnonzero(mask_a)
|
|
180
|
+
indices_b = np.flatnonzero(mask_b)
|
|
181
|
+
if indices_a.size == 0 or indices_b.size == 0:
|
|
182
|
+
raise ValueError("FluxMonitor plane has no samples inside the physical domain.")
|
|
183
|
+
|
|
184
|
+
cropped_fields = {}
|
|
185
|
+
for component_name, values in aligned["fields"].items():
|
|
186
|
+
if isinstance(values, torch.Tensor):
|
|
187
|
+
index_a = torch.as_tensor(indices_a, device=values.device, dtype=torch.long)
|
|
188
|
+
index_b = torch.as_tensor(indices_b, device=values.device, dtype=torch.long)
|
|
189
|
+
cropped = torch.index_select(values, values.ndim - 2, index_a)
|
|
190
|
+
cropped = torch.index_select(cropped, cropped.ndim - 1, index_b)
|
|
191
|
+
else:
|
|
192
|
+
cropped = np.take(np.take(values, indices_a, axis=-2), indices_b, axis=-1)
|
|
193
|
+
cropped_fields[component_name] = cropped
|
|
194
|
+
return {
|
|
195
|
+
coord_names[0]: np.asarray(aligned[coord_names[0]])[indices_a],
|
|
196
|
+
coord_names[1]: np.asarray(aligned[coord_names[1]])[indices_b],
|
|
197
|
+
"fields": cropped_fields,
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _plane_shape(field, axis):
|
|
202
|
+
if axis == "x":
|
|
203
|
+
return (field.shape[1], field.shape[2])
|
|
204
|
+
if axis == "y":
|
|
205
|
+
return (field.shape[0], field.shape[2])
|
|
206
|
+
return (field.shape[0], field.shape[1])
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _normalize_frequency_list(frequencies):
|
|
210
|
+
if frequencies is None:
|
|
211
|
+
return ()
|
|
212
|
+
if isinstance(frequencies, (tuple, list, np.ndarray)):
|
|
213
|
+
values = tuple(float(freq) for freq in frequencies)
|
|
214
|
+
else:
|
|
215
|
+
values = (float(frequencies),)
|
|
216
|
+
if not values:
|
|
217
|
+
raise ValueError("At least one observer frequency is required.")
|
|
218
|
+
return values
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _merge_frequency_lists(*frequency_lists):
|
|
222
|
+
ordered = []
|
|
223
|
+
seen = set()
|
|
224
|
+
for frequency_list in frequency_lists:
|
|
225
|
+
for frequency in _normalize_frequency_list(frequency_list):
|
|
226
|
+
if frequency in seen:
|
|
227
|
+
continue
|
|
228
|
+
ordered.append(frequency)
|
|
229
|
+
seen.add(frequency)
|
|
230
|
+
return tuple(ordered)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _spectral_scale(entry):
|
|
234
|
+
if entry["window_normalization"] > 0:
|
|
235
|
+
return 2.0 / entry["window_normalization"]
|
|
236
|
+
if entry["sample_count"] > 0:
|
|
237
|
+
return 2.0 / entry["sample_count"]
|
|
238
|
+
return 0.0
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _contains_torch_tensor(value):
|
|
242
|
+
if isinstance(value, torch.Tensor):
|
|
243
|
+
return True
|
|
244
|
+
if isinstance(value, dict):
|
|
245
|
+
return any(_contains_torch_tensor(item) for item in value.values())
|
|
246
|
+
if isinstance(value, (list, tuple)):
|
|
247
|
+
return any(_contains_torch_tensor(item) for item in value)
|
|
248
|
+
return False
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _infer_tensor_device_and_dtype(*values):
|
|
252
|
+
for value in values:
|
|
253
|
+
if isinstance(value, torch.Tensor):
|
|
254
|
+
return value.device, value.real.dtype
|
|
255
|
+
return None, torch.float32
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _to_torch_scalar_or_tensor(value, *, device, dtype):
|
|
259
|
+
if isinstance(value, torch.Tensor):
|
|
260
|
+
return value.to(device=device)
|
|
261
|
+
return torch.as_tensor(value, device=device, dtype=dtype)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _cell_center_weights_1d(points):
|
|
265
|
+
"""Control-volume widths for samples located at Yee cell centers."""
|
|
266
|
+
if isinstance(points, torch.Tensor):
|
|
267
|
+
coords = points.to(dtype=points.real.dtype)
|
|
268
|
+
count = int(coords.numel())
|
|
269
|
+
if count <= 1:
|
|
270
|
+
return torch.ones((count,), device=coords.device, dtype=coords.dtype)
|
|
271
|
+
diffs = coords[1:] - coords[:-1]
|
|
272
|
+
weights = torch.empty((count,), device=coords.device, dtype=coords.dtype)
|
|
273
|
+
weights[0] = diffs[0]
|
|
274
|
+
weights[-1] = diffs[-1]
|
|
275
|
+
if count > 2:
|
|
276
|
+
weights[1:-1] = (diffs[:-1] + diffs[1:]) / 2.0
|
|
277
|
+
return weights
|
|
278
|
+
|
|
279
|
+
coords = np.asarray(points, dtype=float)
|
|
280
|
+
count = coords.size
|
|
281
|
+
if count <= 1:
|
|
282
|
+
return np.ones((count,), dtype=float)
|
|
283
|
+
diffs = np.diff(coords)
|
|
284
|
+
weights = np.empty((count,), dtype=float)
|
|
285
|
+
weights[0] = diffs[0]
|
|
286
|
+
weights[-1] = diffs[-1]
|
|
287
|
+
if count > 2:
|
|
288
|
+
weights[1:-1] = (diffs[:-1] + diffs[1:]) / 2.0
|
|
289
|
+
return weights
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _compute_plane_flux(result):
|
|
293
|
+
axis = normalize_axis(result["axis"])
|
|
294
|
+
axis_index = _AXIS_CODES[axis]
|
|
295
|
+
coord_names = _plane_coord_names(axis)
|
|
296
|
+
frequencies = tuple(float(freq) for freq in result.get("frequencies", (result["frequency"],)))
|
|
297
|
+
has_multi_frequency = len(frequencies) > 1
|
|
298
|
+
|
|
299
|
+
if _contains_torch_tensor(result):
|
|
300
|
+
device, coord_dtype = _infer_tensor_device_and_dtype(
|
|
301
|
+
result.get(coord_names[0]),
|
|
302
|
+
result.get(coord_names[1]),
|
|
303
|
+
result.get("Ex"),
|
|
304
|
+
result.get("Ey"),
|
|
305
|
+
result.get("Ez"),
|
|
306
|
+
result.get("Hx"),
|
|
307
|
+
result.get("Hy"),
|
|
308
|
+
result.get("Hz"),
|
|
309
|
+
)
|
|
310
|
+
coord_a = _to_torch_scalar_or_tensor(result[coord_names[0]], device=device, dtype=coord_dtype)
|
|
311
|
+
coord_b = _to_torch_scalar_or_tensor(result[coord_names[1]], device=device, dtype=coord_dtype)
|
|
312
|
+
weights = _cell_center_weights_1d(coord_a)[:, None] * _cell_center_weights_1d(coord_b)[None, :]
|
|
313
|
+
field_shape = ((len(frequencies),) if has_multi_frequency else ()) + (coord_a.numel(), coord_b.numel())
|
|
314
|
+
complex_dtype = None
|
|
315
|
+
for component_name in ("Ex", "Ey", "Ez", "Hx", "Hy", "Hz"):
|
|
316
|
+
value = result.get(component_name)
|
|
317
|
+
if isinstance(value, torch.Tensor):
|
|
318
|
+
complex_dtype = value.dtype
|
|
319
|
+
break
|
|
320
|
+
if complex_dtype is None:
|
|
321
|
+
complex_dtype = torch.complex64
|
|
322
|
+
e_field = torch.zeros(field_shape + (3,), device=device, dtype=complex_dtype)
|
|
323
|
+
h_field = torch.zeros(field_shape + (3,), device=device, dtype=complex_dtype)
|
|
324
|
+
for component_name in ("Ex", "Ey", "Ez"):
|
|
325
|
+
if component_name not in result:
|
|
326
|
+
continue
|
|
327
|
+
component_index = "xyz".index(component_name[1].lower())
|
|
328
|
+
e_field[..., component_index] = _to_torch_scalar_or_tensor(
|
|
329
|
+
result[component_name],
|
|
330
|
+
device=device,
|
|
331
|
+
dtype=coord_dtype,
|
|
332
|
+
)
|
|
333
|
+
for component_name in ("Hx", "Hy", "Hz"):
|
|
334
|
+
if component_name not in result:
|
|
335
|
+
continue
|
|
336
|
+
component_index = "xyz".index(component_name[1].lower())
|
|
337
|
+
h_field[..., component_index] = _to_torch_scalar_or_tensor(
|
|
338
|
+
result[component_name],
|
|
339
|
+
device=device,
|
|
340
|
+
dtype=coord_dtype,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
direction = 1.0 if result.get("normal_direction", "+") == "+" else -1.0
|
|
344
|
+
poynting = 0.5 * torch.real(torch.cross(e_field, torch.conj(h_field), dim=-1)[..., axis_index]) * direction
|
|
345
|
+
flux = torch.sum(poynting * weights, dim=(-2, -1))
|
|
346
|
+
if not has_multi_frequency:
|
|
347
|
+
return flux.reshape(())
|
|
348
|
+
return flux
|
|
349
|
+
|
|
350
|
+
coord_a = np.asarray(result[coord_names[0]], dtype=float)
|
|
351
|
+
coord_b = np.asarray(result[coord_names[1]], dtype=float)
|
|
352
|
+
weights = _cell_center_weights_1d(coord_a)[:, None] * _cell_center_weights_1d(coord_b)[None, :]
|
|
353
|
+
leading_shape = (len(frequencies),) if has_multi_frequency else ()
|
|
354
|
+
field_shape = leading_shape + (coord_a.size, coord_b.size)
|
|
355
|
+
|
|
356
|
+
e_field = np.zeros(field_shape + (3,), dtype=np.complex128)
|
|
357
|
+
h_field = np.zeros(field_shape + (3,), dtype=np.complex128)
|
|
358
|
+
for component_name in ("Ex", "Ey", "Ez"):
|
|
359
|
+
if component_name not in result:
|
|
360
|
+
continue
|
|
361
|
+
component_index = "xyz".index(component_name[1].lower())
|
|
362
|
+
values = np.asarray(result[component_name], dtype=np.complex128)
|
|
363
|
+
e_field[..., component_index] = values
|
|
364
|
+
for component_name in ("Hx", "Hy", "Hz"):
|
|
365
|
+
if component_name not in result:
|
|
366
|
+
continue
|
|
367
|
+
component_index = "xyz".index(component_name[1].lower())
|
|
368
|
+
values = np.asarray(result[component_name], dtype=np.complex128)
|
|
369
|
+
h_field[..., component_index] = values
|
|
370
|
+
|
|
371
|
+
direction = 1.0 if result.get("normal_direction", "+") == "+" else -1.0
|
|
372
|
+
poynting = 0.5 * np.real(np.cross(e_field, np.conj(h_field), axis=-1)[..., axis_index]) * direction
|
|
373
|
+
flux = np.sum(poynting * weights, axis=(-2, -1))
|
|
374
|
+
if not has_multi_frequency:
|
|
375
|
+
return float(flux)
|
|
376
|
+
return flux
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def clear_observers(solver):
|
|
380
|
+
solver.observers = []
|
|
381
|
+
solver.observers_enabled = False
|
|
382
|
+
solver.observer_frequency = None
|
|
383
|
+
solver.observer_frequencies = ()
|
|
384
|
+
solver.observer_start_step = None
|
|
385
|
+
solver.observer_start_steps = ()
|
|
386
|
+
solver.observer_end_step = None
|
|
387
|
+
solver.observer_end_steps = ()
|
|
388
|
+
solver.observer_window_normalization = 0.0
|
|
389
|
+
solver.observer_sample_count = 0
|
|
390
|
+
solver.observer_sample_counts = ()
|
|
391
|
+
solver._point_observer_groups = {}
|
|
392
|
+
solver._plane_observer_groups = {}
|
|
393
|
+
solver._observer_spectral_entries = []
|
|
394
|
+
solver._observer_point_groups_by_frequency = []
|
|
395
|
+
solver._observer_plane_groups_by_frequency = []
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def add_point_observer(solver, name, position, component="Ez"):
|
|
399
|
+
solver.observers.append(_point_observer_record(name, position, component=component))
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def add_plane_observer(solver, name, axis="z", position=0.0, component="Ez"):
|
|
403
|
+
solver.observers.append(_plane_observer_record(name, axis=axis, position=position, component=component))
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def get_component_coords(solver, component):
|
|
407
|
+
return component_coords(solver.scene, component)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def resolve_point_observer(solver, observer):
|
|
411
|
+
x_coords, y_coords, z_coords = get_component_coords(solver, observer["component"])
|
|
412
|
+
px, py, pz = observer["position"]
|
|
413
|
+
return (
|
|
414
|
+
int(np.argmin(np.abs(x_coords - px))),
|
|
415
|
+
int(np.argmin(np.abs(y_coords - py))),
|
|
416
|
+
int(np.argmin(np.abs(z_coords - pz))),
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _plane_field_slice(axis, plane_index):
|
|
421
|
+
if axis == "x":
|
|
422
|
+
return (slice(plane_index, plane_index + 1), slice(None), slice(None))
|
|
423
|
+
if axis == "y":
|
|
424
|
+
return (slice(None), slice(plane_index, plane_index + 1), slice(None))
|
|
425
|
+
return (slice(None), slice(None), slice(plane_index, plane_index + 1))
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _resolve_plane_sample_positions(coords, position):
|
|
429
|
+
axis_coords = np.asarray(coords, dtype=np.float64)
|
|
430
|
+
if axis_coords.ndim != 1 or axis_coords.size == 0:
|
|
431
|
+
raise ValueError("Plane observer axis coordinates must be a non-empty 1D array.")
|
|
432
|
+
|
|
433
|
+
target = float(position)
|
|
434
|
+
if axis_coords.size == 1:
|
|
435
|
+
return (
|
|
436
|
+
{
|
|
437
|
+
"plane_index": 0,
|
|
438
|
+
"plane_position": float(axis_coords[0]),
|
|
439
|
+
"weight": 1.0,
|
|
440
|
+
},
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
upper = int(np.searchsorted(axis_coords, target, side="left"))
|
|
444
|
+
if upper <= 0:
|
|
445
|
+
return (
|
|
446
|
+
{
|
|
447
|
+
"plane_index": 0,
|
|
448
|
+
"plane_position": float(axis_coords[0]),
|
|
449
|
+
"weight": 1.0,
|
|
450
|
+
},
|
|
451
|
+
)
|
|
452
|
+
if upper >= axis_coords.size:
|
|
453
|
+
last = int(axis_coords.size - 1)
|
|
454
|
+
return (
|
|
455
|
+
{
|
|
456
|
+
"plane_index": last,
|
|
457
|
+
"plane_position": float(axis_coords[last]),
|
|
458
|
+
"weight": 1.0,
|
|
459
|
+
},
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
lower = upper - 1
|
|
463
|
+
lower_coord = float(axis_coords[lower])
|
|
464
|
+
upper_coord = float(axis_coords[upper])
|
|
465
|
+
tolerance = 1e-12 * max(abs(target), abs(lower_coord), abs(upper_coord), 1.0)
|
|
466
|
+
if abs(target - lower_coord) <= tolerance:
|
|
467
|
+
return (
|
|
468
|
+
{
|
|
469
|
+
"plane_index": lower,
|
|
470
|
+
"plane_position": lower_coord,
|
|
471
|
+
"weight": 1.0,
|
|
472
|
+
},
|
|
473
|
+
)
|
|
474
|
+
if abs(target - upper_coord) <= tolerance:
|
|
475
|
+
return (
|
|
476
|
+
{
|
|
477
|
+
"plane_index": upper,
|
|
478
|
+
"plane_position": upper_coord,
|
|
479
|
+
"weight": 1.0,
|
|
480
|
+
},
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
span = upper_coord - lower_coord
|
|
484
|
+
if span <= tolerance:
|
|
485
|
+
nearest = lower if abs(target - lower_coord) <= abs(target - upper_coord) else upper
|
|
486
|
+
return (
|
|
487
|
+
{
|
|
488
|
+
"plane_index": nearest,
|
|
489
|
+
"plane_position": float(axis_coords[nearest]),
|
|
490
|
+
"weight": 1.0,
|
|
491
|
+
},
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
upper_weight = (target - lower_coord) / span
|
|
495
|
+
lower_weight = 1.0 - upper_weight
|
|
496
|
+
return (
|
|
497
|
+
{
|
|
498
|
+
"plane_index": lower,
|
|
499
|
+
"plane_position": lower_coord,
|
|
500
|
+
"weight": float(lower_weight),
|
|
501
|
+
},
|
|
502
|
+
{
|
|
503
|
+
"plane_index": upper,
|
|
504
|
+
"plane_position": upper_coord,
|
|
505
|
+
"weight": float(upper_weight),
|
|
506
|
+
},
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def resolve_plane_observer(solver, observer):
|
|
511
|
+
axis = normalize_axis(observer["axis"])
|
|
512
|
+
x_coords, y_coords, z_coords = get_component_coords(solver, observer["component"])
|
|
513
|
+
coord_map = {"x": x_coords, "y": y_coords, "z": z_coords}
|
|
514
|
+
if axis not in coord_map:
|
|
515
|
+
raise ValueError("Observer axis must be 'x', 'y', or 'z'")
|
|
516
|
+
|
|
517
|
+
plane_samples = _resolve_plane_sample_positions(coord_map[axis], observer["position"])
|
|
518
|
+
if axis == "x":
|
|
519
|
+
return plane_samples, (y_coords, z_coords)
|
|
520
|
+
if axis == "y":
|
|
521
|
+
return plane_samples, (x_coords, z_coords)
|
|
522
|
+
return plane_samples, (x_coords, y_coords)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _flux_yee_plane_position(solver, axis, requested_position):
|
|
526
|
+
"""Snap a flux plane to the nearest tangential-E Yee plane.
|
|
527
|
+
|
|
528
|
+
Tangential electric components live on primal nodes along the plane normal;
|
|
529
|
+
magnetic components then straddle that node symmetrically and are averaged by
|
|
530
|
+
the ordinary plane sampler. Sampling both families independently at an
|
|
531
|
+
arbitrary coordinate would attenuate each complex phasor by a position-
|
|
532
|
+
dependent interpolation factor.
|
|
533
|
+
"""
|
|
534
|
+
axis_name = normalize_axis(axis)
|
|
535
|
+
nodes = {
|
|
536
|
+
"x": solver.scene.x_nodes64,
|
|
537
|
+
"y": solver.scene.y_nodes64,
|
|
538
|
+
"z": solver.scene.z_nodes64,
|
|
539
|
+
}[axis_name]
|
|
540
|
+
index = int(np.argmin(np.abs(np.asarray(nodes, dtype=np.float64) - float(requested_position))))
|
|
541
|
+
return float(nodes[index])
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def prepare_observers(solver, frequencies, window_type, time_steps):
|
|
545
|
+
default_frequencies = _normalize_frequency_list(frequencies)
|
|
546
|
+
if not solver.observers:
|
|
547
|
+
solver.observers_enabled = False
|
|
548
|
+
solver._point_observer_groups = {}
|
|
549
|
+
solver._plane_observer_groups = {}
|
|
550
|
+
solver._observer_spectral_entries = []
|
|
551
|
+
solver._observer_point_groups_by_frequency = []
|
|
552
|
+
solver._observer_plane_groups_by_frequency = []
|
|
553
|
+
solver._sync_observer_legacy_state()
|
|
554
|
+
return
|
|
555
|
+
|
|
556
|
+
solver.observers_enabled = True
|
|
557
|
+
solver.observer_window_type = solver._resolve_spectral_window_type(window_type)
|
|
558
|
+
# DipoleEmissionMonitor needs the source current spectrum (the running DFT of
|
|
559
|
+
# the injected source signal) to form the delivered power, independently of
|
|
560
|
+
# the source-field normalization pass.
|
|
561
|
+
solver._accumulate_source_spectrum = any(
|
|
562
|
+
observer.get("monitor_type") == "dipole_emission" for observer in solver.observers
|
|
563
|
+
)
|
|
564
|
+
solver._point_observer_groups = {}
|
|
565
|
+
solver._plane_observer_groups = {}
|
|
566
|
+
requested_frequencies = _merge_frequency_lists(
|
|
567
|
+
default_frequencies,
|
|
568
|
+
*(
|
|
569
|
+
observer.get("monitor_frequencies")
|
|
570
|
+
for observer in solver.observers
|
|
571
|
+
if observer.get("monitor_frequencies") is not None
|
|
572
|
+
),
|
|
573
|
+
)
|
|
574
|
+
solver._observer_spectral_entries = []
|
|
575
|
+
for frequency in requested_frequencies:
|
|
576
|
+
omega_dt = 2 * np.pi * frequency * solver.dt
|
|
577
|
+
solver._observer_spectral_entries.append(
|
|
578
|
+
{
|
|
579
|
+
"frequency": float(frequency),
|
|
580
|
+
"start_step": solver._compute_spectral_start_step(
|
|
581
|
+
frequency,
|
|
582
|
+
window_type=solver.observer_window_type,
|
|
583
|
+
),
|
|
584
|
+
"end_step": time_steps,
|
|
585
|
+
"window_normalization": 0.0,
|
|
586
|
+
"sample_count": 0,
|
|
587
|
+
"phase_cos": 1.0,
|
|
588
|
+
"phase_sin": 0.0,
|
|
589
|
+
"phase_step_cos": np.cos(omega_dt),
|
|
590
|
+
"phase_step_sin": np.sin(omega_dt),
|
|
591
|
+
"source_dft_real": 0.0,
|
|
592
|
+
"source_dft_imag": 0.0,
|
|
593
|
+
}
|
|
594
|
+
)
|
|
595
|
+
frequency_to_index = {entry["frequency"]: index for index, entry in enumerate(solver._observer_spectral_entries)}
|
|
596
|
+
|
|
597
|
+
for observer in solver.observers:
|
|
598
|
+
observer_frequencies = _normalize_frequency_list(observer.get("monitor_frequencies")) or default_frequencies
|
|
599
|
+
observer["frequencies"] = observer_frequencies
|
|
600
|
+
observer["global_freq_indices"] = tuple(frequency_to_index[freq] for freq in observer_frequencies)
|
|
601
|
+
component = normalize_component(observer["component"])
|
|
602
|
+
observer["component"] = component
|
|
603
|
+
field = getattr(solver, _field_name(component))
|
|
604
|
+
if _observer_is_point(observer):
|
|
605
|
+
field_index = resolve_point_observer(solver, observer)
|
|
606
|
+
observer["field_index"] = field_index
|
|
607
|
+
group = solver._point_observer_groups.setdefault(
|
|
608
|
+
component,
|
|
609
|
+
{
|
|
610
|
+
"field_name": _field_name(component),
|
|
611
|
+
"point_i": [],
|
|
612
|
+
"point_j": [],
|
|
613
|
+
"point_k": [],
|
|
614
|
+
"observers": [],
|
|
615
|
+
"requested_global_freq_indices": [],
|
|
616
|
+
},
|
|
617
|
+
)
|
|
618
|
+
observer["group_component"] = component
|
|
619
|
+
observer["group_offset"] = len(group["observers"])
|
|
620
|
+
group["observers"].append(observer)
|
|
621
|
+
group["point_i"].append(field_index[0])
|
|
622
|
+
group["point_j"].append(field_index[1])
|
|
623
|
+
group["point_k"].append(field_index[2])
|
|
624
|
+
group["requested_global_freq_indices"].extend(observer["global_freq_indices"])
|
|
625
|
+
else:
|
|
626
|
+
sampling_observer = observer
|
|
627
|
+
if observer.get("compute_flux", False):
|
|
628
|
+
sampling_observer = dict(observer)
|
|
629
|
+
sampling_observer["position"] = _flux_yee_plane_position(
|
|
630
|
+
solver,
|
|
631
|
+
observer["axis"],
|
|
632
|
+
observer["position"],
|
|
633
|
+
)
|
|
634
|
+
observer["yee_plane_position"] = sampling_observer["position"]
|
|
635
|
+
plane_samples, plane_coords = resolve_plane_observer(solver, sampling_observer)
|
|
636
|
+
axis = normalize_axis(observer["axis"])
|
|
637
|
+
observer["plane_samples"] = []
|
|
638
|
+
for sample in plane_samples:
|
|
639
|
+
plane_index = sample["plane_index"]
|
|
640
|
+
group_key = (component, axis, plane_index)
|
|
641
|
+
group = solver._plane_observer_groups.get(group_key)
|
|
642
|
+
if group is None:
|
|
643
|
+
group = {
|
|
644
|
+
"field_name": _field_name(component),
|
|
645
|
+
"axis": axis,
|
|
646
|
+
"axis_code": _AXIS_CODES[axis],
|
|
647
|
+
"plane_index": plane_index,
|
|
648
|
+
"plane_shape": _plane_shape(field, axis),
|
|
649
|
+
"plane_coords": plane_coords,
|
|
650
|
+
"field_slice": _plane_field_slice(axis, plane_index),
|
|
651
|
+
"observers": [],
|
|
652
|
+
"requested_global_freq_indices": [],
|
|
653
|
+
}
|
|
654
|
+
solver._plane_observer_groups[group_key] = group
|
|
655
|
+
group["observers"].append(observer)
|
|
656
|
+
group["requested_global_freq_indices"].extend(observer["global_freq_indices"])
|
|
657
|
+
observer["plane_samples"].append(
|
|
658
|
+
{
|
|
659
|
+
"group_key": group_key,
|
|
660
|
+
"plane_index": plane_index,
|
|
661
|
+
"plane_position": sample["plane_position"],
|
|
662
|
+
"weight": sample["weight"],
|
|
663
|
+
}
|
|
664
|
+
)
|
|
665
|
+
primary_sample = max(
|
|
666
|
+
observer["plane_samples"],
|
|
667
|
+
key=lambda sample: (sample["weight"], -abs(sample["plane_position"] - float(observer["position"]))),
|
|
668
|
+
)
|
|
669
|
+
observer["group_key"] = primary_sample["group_key"]
|
|
670
|
+
observer["plane_index"] = primary_sample["plane_index"]
|
|
671
|
+
observer["plane_indices"] = tuple(sample["plane_index"] for sample in observer["plane_samples"])
|
|
672
|
+
observer["plane_weights"] = tuple(float(sample["weight"]) for sample in observer["plane_samples"])
|
|
673
|
+
observer["plane_positions"] = tuple(float(sample["plane_position"]) for sample in observer["plane_samples"])
|
|
674
|
+
observer["field_slice"] = _plane_field_slice(axis, observer["plane_index"])
|
|
675
|
+
observer["plane_coords"] = plane_coords
|
|
676
|
+
|
|
677
|
+
solver._observer_point_groups_by_frequency = [[] for _ in solver._observer_spectral_entries]
|
|
678
|
+
for group in solver._point_observer_groups.values():
|
|
679
|
+
point_count = len(group["observers"])
|
|
680
|
+
field = getattr(solver, group["field_name"])
|
|
681
|
+
group["global_freq_indices"] = tuple(dict.fromkeys(group.pop("requested_global_freq_indices")))
|
|
682
|
+
group["frequencies"] = tuple(
|
|
683
|
+
solver._observer_spectral_entries[index]["frequency"]
|
|
684
|
+
for index in group["global_freq_indices"]
|
|
685
|
+
)
|
|
686
|
+
group["freq_local_lookup"] = {
|
|
687
|
+
global_index: local_index
|
|
688
|
+
for local_index, global_index in enumerate(group["global_freq_indices"])
|
|
689
|
+
}
|
|
690
|
+
group["point_i"] = torch.tensor(group["point_i"], device=solver.device, dtype=torch.int32)
|
|
691
|
+
group["point_j"] = torch.tensor(group["point_j"], device=solver.device, dtype=torch.int32)
|
|
692
|
+
group["point_k"] = torch.tensor(group["point_k"], device=solver.device, dtype=torch.int32)
|
|
693
|
+
group["real"] = torch.zeros((len(group["global_freq_indices"]), point_count), device=solver.device, dtype=field.dtype)
|
|
694
|
+
group["imag"] = torch.zeros((len(group["global_freq_indices"]), point_count), device=solver.device, dtype=field.dtype)
|
|
695
|
+
if has_complex_fields(solver):
|
|
696
|
+
group["aux_real"] = torch.zeros((len(group["global_freq_indices"]), point_count), device=solver.device, dtype=field.dtype)
|
|
697
|
+
group["aux_imag"] = torch.zeros((len(group["global_freq_indices"]), point_count), device=solver.device, dtype=field.dtype)
|
|
698
|
+
for global_index, local_index in group["freq_local_lookup"].items():
|
|
699
|
+
solver._observer_point_groups_by_frequency[global_index].append((group, local_index))
|
|
700
|
+
|
|
701
|
+
solver._observer_plane_groups_by_frequency = [[] for _ in solver._observer_spectral_entries]
|
|
702
|
+
for group in solver._plane_observer_groups.values():
|
|
703
|
+
field = getattr(solver, group["field_name"])
|
|
704
|
+
group["global_freq_indices"] = tuple(dict.fromkeys(group.pop("requested_global_freq_indices")))
|
|
705
|
+
group["frequencies"] = tuple(
|
|
706
|
+
solver._observer_spectral_entries[index]["frequency"]
|
|
707
|
+
for index in group["global_freq_indices"]
|
|
708
|
+
)
|
|
709
|
+
group["freq_local_lookup"] = {
|
|
710
|
+
global_index: local_index
|
|
711
|
+
for local_index, global_index in enumerate(group["global_freq_indices"])
|
|
712
|
+
}
|
|
713
|
+
group["real"] = torch.zeros((len(group["global_freq_indices"]),) + group["plane_shape"], device=solver.device, dtype=field.dtype)
|
|
714
|
+
group["imag"] = torch.zeros((len(group["global_freq_indices"]),) + group["plane_shape"], device=solver.device, dtype=field.dtype)
|
|
715
|
+
if has_complex_fields(solver):
|
|
716
|
+
group["aux_real"] = torch.zeros((len(group["global_freq_indices"]),) + group["plane_shape"], device=solver.device, dtype=field.dtype)
|
|
717
|
+
group["aux_imag"] = torch.zeros((len(group["global_freq_indices"]),) + group["plane_shape"], device=solver.device, dtype=field.dtype)
|
|
718
|
+
for global_index, local_index in group["freq_local_lookup"].items():
|
|
719
|
+
solver._observer_plane_groups_by_frequency[global_index].append((group, local_index))
|
|
720
|
+
|
|
721
|
+
solver._sync_observer_legacy_state()
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def accumulate_observers(solver, n, phase_cos=None, phase_sin=None):
|
|
725
|
+
if not solver.observers_enabled:
|
|
726
|
+
return
|
|
727
|
+
|
|
728
|
+
source_signal = None
|
|
729
|
+
accumulate_source = getattr(solver, '_normalize_source', False) or getattr(solver, '_accumulate_source_spectrum', False)
|
|
730
|
+
if accumulate_source and getattr(solver, '_source_time', None) is not None:
|
|
731
|
+
from ..sources import evaluate_source_time_normalization
|
|
732
|
+
|
|
733
|
+
source_signal = evaluate_source_time_normalization(solver._source_time, n * solver.dt)
|
|
734
|
+
|
|
735
|
+
for global_index, entry in enumerate(solver._observer_spectral_entries):
|
|
736
|
+
if n < entry["start_step"]:
|
|
737
|
+
continue
|
|
738
|
+
if entry["end_step"] is not None and n >= entry["end_step"]:
|
|
739
|
+
continue
|
|
740
|
+
|
|
741
|
+
window_weight = solver._compute_window_weight(
|
|
742
|
+
n,
|
|
743
|
+
start_step=entry["start_step"],
|
|
744
|
+
end_step=entry["end_step"],
|
|
745
|
+
window_type=solver.observer_window_type,
|
|
746
|
+
)
|
|
747
|
+
weighted_cos = window_weight * entry["phase_cos"]
|
|
748
|
+
weighted_sin = window_weight * entry["phase_sin"]
|
|
749
|
+
|
|
750
|
+
if source_signal is not None:
|
|
751
|
+
entry["source_dft_real"] += source_signal * weighted_cos
|
|
752
|
+
entry["source_dft_imag"] += source_signal * weighted_sin
|
|
753
|
+
|
|
754
|
+
for group, local_index in solver._observer_point_groups_by_frequency[global_index]:
|
|
755
|
+
solver.fdtd_module.accumulatePointObservers3D(
|
|
756
|
+
field=getattr(solver, group["field_name"]),
|
|
757
|
+
pointI=group["point_i"],
|
|
758
|
+
pointJ=group["point_j"],
|
|
759
|
+
pointK=group["point_k"],
|
|
760
|
+
realAccum=group["real"][local_index],
|
|
761
|
+
imagAccum=group["imag"][local_index],
|
|
762
|
+
weightedCos=weighted_cos,
|
|
763
|
+
weightedSin=weighted_sin,
|
|
764
|
+
).launchRaw()
|
|
765
|
+
if has_complex_fields(solver):
|
|
766
|
+
solver.fdtd_module.accumulatePointObservers3D(
|
|
767
|
+
field=getattr(solver, f"{group['field_name']}_imag"),
|
|
768
|
+
pointI=group["point_i"],
|
|
769
|
+
pointJ=group["point_j"],
|
|
770
|
+
pointK=group["point_k"],
|
|
771
|
+
realAccum=group["aux_real"][local_index],
|
|
772
|
+
imagAccum=group["aux_imag"][local_index],
|
|
773
|
+
weightedCos=weighted_cos,
|
|
774
|
+
weightedSin=weighted_sin,
|
|
775
|
+
).launchRaw()
|
|
776
|
+
|
|
777
|
+
for group, local_index in solver._observer_plane_groups_by_frequency[global_index]:
|
|
778
|
+
solver.fdtd_module.accumulatePlaneObserver3D(
|
|
779
|
+
field=getattr(solver, group["field_name"]),
|
|
780
|
+
planeRealAccum=group["real"][local_index],
|
|
781
|
+
planeImagAccum=group["imag"][local_index],
|
|
782
|
+
axisCode=group["axis_code"],
|
|
783
|
+
planeIndex=group["plane_index"],
|
|
784
|
+
weightedCos=weighted_cos,
|
|
785
|
+
weightedSin=weighted_sin,
|
|
786
|
+
).launchRaw()
|
|
787
|
+
if has_complex_fields(solver):
|
|
788
|
+
solver.fdtd_module.accumulatePlaneObserver3D(
|
|
789
|
+
field=getattr(solver, f"{group['field_name']}_imag"),
|
|
790
|
+
planeRealAccum=group["aux_real"][local_index],
|
|
791
|
+
planeImagAccum=group["aux_imag"][local_index],
|
|
792
|
+
axisCode=group["axis_code"],
|
|
793
|
+
planeIndex=group["plane_index"],
|
|
794
|
+
weightedCos=weighted_cos,
|
|
795
|
+
weightedSin=weighted_sin,
|
|
796
|
+
).launchRaw()
|
|
797
|
+
|
|
798
|
+
entry["window_normalization"] += window_weight
|
|
799
|
+
entry["sample_count"] += 1
|
|
800
|
+
|
|
801
|
+
for entry in solver._observer_spectral_entries:
|
|
802
|
+
entry["phase_cos"], entry["phase_sin"] = solver._advance_phase(
|
|
803
|
+
entry["phase_cos"],
|
|
804
|
+
entry["phase_sin"],
|
|
805
|
+
entry["phase_step_cos"],
|
|
806
|
+
entry["phase_step_sin"],
|
|
807
|
+
)
|
|
808
|
+
solver._sync_observer_primary_state()
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
# ---------------------------------------------------------------------------
|
|
812
|
+
# Time-domain observers (raw time-series fields and instantaneous flux).
|
|
813
|
+
#
|
|
814
|
+
# These are intentionally kept separate from the running-DFT spectral path:
|
|
815
|
+
# they preallocate GPU buffers and copy the current real field slice on a fixed
|
|
816
|
+
# sampling schedule, rather than accumulating phase-weighted spectral sums.
|
|
817
|
+
# ---------------------------------------------------------------------------
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def _time_sample_steps(record, time_steps):
|
|
821
|
+
start = int(record["start"])
|
|
822
|
+
stop = time_steps if record["stop"] is None else int(record["stop"])
|
|
823
|
+
stop = min(stop, int(time_steps))
|
|
824
|
+
interval = int(record["interval"])
|
|
825
|
+
return [n for n in range(start, stop) if (n - start) % interval == 0]
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def _primary_plane_index(plane_samples):
|
|
829
|
+
primary = max(plane_samples, key=lambda sample: sample["weight"])
|
|
830
|
+
return int(primary["plane_index"])
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def _resolve_time_plane_component(solver, axis, position, component):
|
|
834
|
+
observer = {"axis": axis, "position": float(position), "component": component}
|
|
835
|
+
plane_samples, plane_coords = resolve_plane_observer(solver, observer)
|
|
836
|
+
axis_name = normalize_axis(axis)
|
|
837
|
+
plane_index = _primary_plane_index(plane_samples)
|
|
838
|
+
field_name = _field_name(component)
|
|
839
|
+
field = getattr(solver, field_name)
|
|
840
|
+
return {
|
|
841
|
+
"field_name": field_name,
|
|
842
|
+
"axis_code": _AXIS_CODES[axis_name],
|
|
843
|
+
"plane_index": plane_index,
|
|
844
|
+
"field_slice": _plane_field_slice(axis_name, plane_index),
|
|
845
|
+
"plane_coords": plane_coords,
|
|
846
|
+
"plane_shape": _plane_shape(field, axis_name),
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def _resolve_time_volume_box(solver, component, position, size):
|
|
851
|
+
x_coords, y_coords, z_coords = get_component_coords(solver, component)
|
|
852
|
+
axis_coords = (
|
|
853
|
+
np.asarray(x_coords, dtype=np.float64),
|
|
854
|
+
np.asarray(y_coords, dtype=np.float64),
|
|
855
|
+
np.asarray(z_coords, dtype=np.float64),
|
|
856
|
+
)
|
|
857
|
+
slices = []
|
|
858
|
+
coords_out = []
|
|
859
|
+
for axis_index in range(3):
|
|
860
|
+
coords = axis_coords[axis_index]
|
|
861
|
+
center = float(position[axis_index])
|
|
862
|
+
half = 0.5 * float(size[axis_index])
|
|
863
|
+
if half <= 1e-12:
|
|
864
|
+
index = int(np.argmin(np.abs(coords - center)))
|
|
865
|
+
slices.append(slice(index, index + 1))
|
|
866
|
+
coords_out.append(coords[index:index + 1])
|
|
867
|
+
continue
|
|
868
|
+
tolerance = 1e-12 * max(abs(center - half), abs(center + half), 1.0)
|
|
869
|
+
mask = (coords >= center - half - tolerance) & (coords <= center + half + tolerance)
|
|
870
|
+
indices = np.nonzero(mask)[0]
|
|
871
|
+
if indices.size == 0:
|
|
872
|
+
index = int(np.argmin(np.abs(coords - center)))
|
|
873
|
+
slices.append(slice(index, index + 1))
|
|
874
|
+
coords_out.append(coords[index:index + 1])
|
|
875
|
+
continue
|
|
876
|
+
lower = int(indices[0])
|
|
877
|
+
upper = int(indices[-1]) + 1
|
|
878
|
+
slices.append(slice(lower, upper))
|
|
879
|
+
coords_out.append(coords[lower:upper])
|
|
880
|
+
return tuple(slices), tuple(coords_out)
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def clear_time_observers(solver):
|
|
884
|
+
solver.time_observers = []
|
|
885
|
+
solver.time_observers_enabled = False
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def prepare_time_observers(solver, time_steps):
|
|
889
|
+
records = getattr(solver, "time_observers", None)
|
|
890
|
+
if not records:
|
|
891
|
+
solver.time_observers_enabled = False
|
|
892
|
+
return
|
|
893
|
+
|
|
894
|
+
for record in records:
|
|
895
|
+
sample_steps = _time_sample_steps(record, time_steps)
|
|
896
|
+
record["sample_steps"] = sample_steps
|
|
897
|
+
record["next_index"] = 0
|
|
898
|
+
num_samples = len(sample_steps)
|
|
899
|
+
|
|
900
|
+
if record["kind"] == "field_time":
|
|
901
|
+
region_kind = record["region_kind"]
|
|
902
|
+
record["buffers"] = {}
|
|
903
|
+
record["component_meta"] = {}
|
|
904
|
+
if region_kind == "point":
|
|
905
|
+
for component in record["components"]:
|
|
906
|
+
field = getattr(solver, _field_name(component))
|
|
907
|
+
index = resolve_point_observer(
|
|
908
|
+
solver,
|
|
909
|
+
{"component": component, "position": record["position"]},
|
|
910
|
+
)
|
|
911
|
+
record["component_meta"][component] = {"field_index": index}
|
|
912
|
+
record["buffers"][component] = torch.zeros(
|
|
913
|
+
(num_samples,), device=solver.device, dtype=field.dtype
|
|
914
|
+
)
|
|
915
|
+
elif region_kind == "plane":
|
|
916
|
+
for component in record["components"]:
|
|
917
|
+
meta = _resolve_time_plane_component(
|
|
918
|
+
solver,
|
|
919
|
+
record["axis"],
|
|
920
|
+
record["plane_position"],
|
|
921
|
+
component,
|
|
922
|
+
)
|
|
923
|
+
record["component_meta"][component] = meta
|
|
924
|
+
field = getattr(solver, meta["field_name"])
|
|
925
|
+
record["buffers"][component] = torch.zeros(
|
|
926
|
+
(num_samples,) + meta["plane_shape"], device=solver.device, dtype=field.dtype
|
|
927
|
+
)
|
|
928
|
+
else:
|
|
929
|
+
warnings.warn(
|
|
930
|
+
f"FieldTimeMonitor {record['name']!r} records a volume time series; "
|
|
931
|
+
"this preallocates a dense (num_samples, Nx, Ny, Nz) GPU buffer and can be memory-heavy.",
|
|
932
|
+
stacklevel=2,
|
|
933
|
+
)
|
|
934
|
+
for component in record["components"]:
|
|
935
|
+
field = getattr(solver, _field_name(component))
|
|
936
|
+
slices, coords = _resolve_time_volume_box(
|
|
937
|
+
solver, component, record["position"], record["size"]
|
|
938
|
+
)
|
|
939
|
+
sub_shape = tuple(field[slices].shape)
|
|
940
|
+
record["component_meta"][component] = {
|
|
941
|
+
"field_name": _field_name(component),
|
|
942
|
+
"slices": slices,
|
|
943
|
+
"coords": coords,
|
|
944
|
+
}
|
|
945
|
+
record["buffers"][component] = torch.zeros(
|
|
946
|
+
(num_samples,) + sub_shape, device=solver.device, dtype=field.dtype
|
|
947
|
+
)
|
|
948
|
+
else:
|
|
949
|
+
record["flux_components"] = {
|
|
950
|
+
component: _resolve_time_plane_component(
|
|
951
|
+
solver, record["axis"], record["position"], component
|
|
952
|
+
)
|
|
953
|
+
for component in record["fields"]
|
|
954
|
+
}
|
|
955
|
+
record["buffer"] = torch.zeros((num_samples,), device=solver.device, dtype=torch.float32)
|
|
956
|
+
# Precompute the constant trapezoidal area weights on the aligned
|
|
957
|
+
# plane grid plus the kernel's field mapping / sign, so the per-step
|
|
958
|
+
# flux is a single native reduction with no host-side work. The plane
|
|
959
|
+
# coordinates depend only on the grid, not on the evolving fields.
|
|
960
|
+
axis_name = normalize_axis(record["axis"])
|
|
961
|
+
rules = _PLANE_ALIGNMENT_RULES[axis_name]
|
|
962
|
+
coord_names = _plane_coord_names(axis_name)
|
|
963
|
+
coord_payloads = {
|
|
964
|
+
component: {"coords": meta["plane_coords"]}
|
|
965
|
+
for component, meta in record["flux_components"].items()
|
|
966
|
+
}
|
|
967
|
+
coord_a = _resolve_coord_source(coord_payloads, *rules["coord_sources"][coord_names[0]])
|
|
968
|
+
coord_b = _resolve_coord_source(coord_payloads, *rules["coord_sources"][coord_names[1]])
|
|
969
|
+
weights = (
|
|
970
|
+
_cell_center_weights_1d(np.asarray(coord_a, dtype=float))[:, None]
|
|
971
|
+
* _cell_center_weights_1d(np.asarray(coord_b, dtype=float))[None, :]
|
|
972
|
+
)
|
|
973
|
+
physical_a, physical_b = _physical_plane_masks(
|
|
974
|
+
solver,
|
|
975
|
+
axis_name,
|
|
976
|
+
coord_a,
|
|
977
|
+
coord_b,
|
|
978
|
+
)
|
|
979
|
+
weights *= physical_a[:, None] * physical_b[None, :]
|
|
980
|
+
record["flux_weights"] = torch.as_tensor(
|
|
981
|
+
weights, device=solver.device, dtype=torch.float32
|
|
982
|
+
).contiguous()
|
|
983
|
+
record["flux_kernel_components"] = _FLUX_KERNEL_COMPONENTS[axis_name]
|
|
984
|
+
# scale = 2.0 (undo the 0.5 time-average) * 0.5 (Poynting) * direction.
|
|
985
|
+
record["flux_scale"] = 1.0 if record["normal_direction"] == "+" else -1.0
|
|
986
|
+
|
|
987
|
+
solver.time_observers_enabled = any(records)
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
def accumulate_time_observers(solver, n):
|
|
991
|
+
if not getattr(solver, "time_observers_enabled", False):
|
|
992
|
+
return
|
|
993
|
+
|
|
994
|
+
for record in solver.time_observers:
|
|
995
|
+
sample_steps = record["sample_steps"]
|
|
996
|
+
next_index = record["next_index"]
|
|
997
|
+
if next_index >= len(sample_steps) or n != sample_steps[next_index]:
|
|
998
|
+
continue
|
|
999
|
+
k = next_index
|
|
1000
|
+
record["next_index"] = next_index + 1
|
|
1001
|
+
|
|
1002
|
+
if record["kind"] == "field_time":
|
|
1003
|
+
region_kind = record["region_kind"]
|
|
1004
|
+
for component, meta in record["component_meta"].items():
|
|
1005
|
+
field = getattr(solver, _field_name(component))
|
|
1006
|
+
buffer = record["buffers"][component]
|
|
1007
|
+
if region_kind == "point":
|
|
1008
|
+
i, j, l = meta["field_index"]
|
|
1009
|
+
buffer[k] = field[i, j, l]
|
|
1010
|
+
elif region_kind == "plane":
|
|
1011
|
+
buffer[k] = field[meta["field_slice"]].squeeze(meta["axis_code"])
|
|
1012
|
+
else:
|
|
1013
|
+
buffer[k] = field[meta["slices"]]
|
|
1014
|
+
continue
|
|
1015
|
+
|
|
1016
|
+
axis = record["axis"]
|
|
1017
|
+
component_payloads = {}
|
|
1018
|
+
for component, meta in record["flux_components"].items():
|
|
1019
|
+
field = getattr(solver, meta["field_name"])
|
|
1020
|
+
plane = field[meta["field_slice"]].squeeze(meta["axis_code"])
|
|
1021
|
+
component_payloads[component] = {"data": plane, "coords": meta["plane_coords"]}
|
|
1022
|
+
# Yee-average the tangential slices onto the common plane grid, then
|
|
1023
|
+
# reduce the instantaneous Poynting flux in a single native kernel that
|
|
1024
|
+
# writes the scalar straight into this step's buffer slot.
|
|
1025
|
+
aligned = _align_plane_monitor_payload(axis, component_payloads)["fields"]
|
|
1026
|
+
ea_name, eb_name, ha_name, hb_name = record["flux_kernel_components"]
|
|
1027
|
+
solver.fdtd_module.planeFluxReduce(
|
|
1028
|
+
ea=aligned[ea_name].contiguous(),
|
|
1029
|
+
eb=aligned[eb_name].contiguous(),
|
|
1030
|
+
ha=aligned[ha_name].contiguous(),
|
|
1031
|
+
hb=aligned[hb_name].contiguous(),
|
|
1032
|
+
weights=record["flux_weights"],
|
|
1033
|
+
out=record["buffer"],
|
|
1034
|
+
outIndex=k,
|
|
1035
|
+
scale=record["flux_scale"],
|
|
1036
|
+
).launchRaw()
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def get_time_observer_results(solver):
|
|
1040
|
+
results = {}
|
|
1041
|
+
for record in getattr(solver, "time_observers", ()):
|
|
1042
|
+
sample_steps = record.get("sample_steps", [])
|
|
1043
|
+
# An early auto-shutoff break freezes next_index below the planned sample
|
|
1044
|
+
# count; truncate t and the buffers to what was actually recorded so the
|
|
1045
|
+
# trace never carries zeros at time steps that were never simulated.
|
|
1046
|
+
count = int(record.get("next_index", len(sample_steps)))
|
|
1047
|
+
t = torch.tensor(sample_steps[:count], device=solver.device, dtype=torch.float64) * solver.dt
|
|
1048
|
+
if record["kind"] == "field_time":
|
|
1049
|
+
components = {name: buffer[:count] for name, buffer in record["buffers"].items()}
|
|
1050
|
+
payload = {
|
|
1051
|
+
"kind": "field_time",
|
|
1052
|
+
"name": record["name"],
|
|
1053
|
+
"t": t,
|
|
1054
|
+
"components": dict(components),
|
|
1055
|
+
"fields": tuple(record["components"]),
|
|
1056
|
+
"start": record["start"],
|
|
1057
|
+
"stop": record["stop"],
|
|
1058
|
+
"interval": record["interval"],
|
|
1059
|
+
"position": record["position"],
|
|
1060
|
+
"size": record["size"],
|
|
1061
|
+
}
|
|
1062
|
+
first_component = record["components"][0]
|
|
1063
|
+
first_meta = record["component_meta"][first_component]
|
|
1064
|
+
if record["region_kind"] == "plane":
|
|
1065
|
+
payload["coords"] = first_meta["plane_coords"]
|
|
1066
|
+
elif record["region_kind"] == "volume":
|
|
1067
|
+
payload["coords"] = first_meta["coords"]
|
|
1068
|
+
if len(record["components"]) == 1:
|
|
1069
|
+
buffer = components[first_component]
|
|
1070
|
+
payload["data"] = buffer
|
|
1071
|
+
payload["field"] = buffer
|
|
1072
|
+
results[record["name"]] = payload
|
|
1073
|
+
else:
|
|
1074
|
+
payload = {
|
|
1075
|
+
"kind": "flux_time",
|
|
1076
|
+
"name": record["name"],
|
|
1077
|
+
"t": t,
|
|
1078
|
+
"flux": record["buffer"][:count],
|
|
1079
|
+
"axis": record["axis"],
|
|
1080
|
+
"position": record["position"],
|
|
1081
|
+
"normal_direction": record["normal_direction"],
|
|
1082
|
+
"start": record["start"],
|
|
1083
|
+
"stop": record["stop"],
|
|
1084
|
+
"interval": record["interval"],
|
|
1085
|
+
}
|
|
1086
|
+
results[record["name"]] = payload
|
|
1087
|
+
return results
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
def _compute_source_spectrum(entries, global_freq_indices, scale_fn):
|
|
1091
|
+
"""Compute complex source DFT values for the given frequency indices."""
|
|
1092
|
+
device, _ = _infer_tensor_device_and_dtype()
|
|
1093
|
+
use_torch = False
|
|
1094
|
+
for idx in global_freq_indices:
|
|
1095
|
+
entry = entries[idx]
|
|
1096
|
+
if isinstance(entry.get("source_dft_real"), torch.Tensor) or isinstance(entry.get("source_dft_imag"), torch.Tensor):
|
|
1097
|
+
use_torch = True
|
|
1098
|
+
device = entry.get("source_dft_real", entry.get("source_dft_imag")).device
|
|
1099
|
+
break
|
|
1100
|
+
|
|
1101
|
+
if use_torch:
|
|
1102
|
+
spectrum = torch.empty((len(global_freq_indices),), device=device, dtype=torch.complex64)
|
|
1103
|
+
for i, idx in enumerate(global_freq_indices):
|
|
1104
|
+
entry = entries[idx]
|
|
1105
|
+
scale = float(scale_fn(entry))
|
|
1106
|
+
real = _to_torch_scalar_or_tensor(entry.get("source_dft_real", 0.0), device=device, dtype=torch.float32)
|
|
1107
|
+
imag = _to_torch_scalar_or_tensor(entry.get("source_dft_imag", 0.0), device=device, dtype=torch.float32)
|
|
1108
|
+
spectrum[i] = (real.to(dtype=torch.complex64) + 1j * imag.to(dtype=torch.complex64)) * scale
|
|
1109
|
+
return spectrum
|
|
1110
|
+
|
|
1111
|
+
spectrum = np.empty(len(global_freq_indices), dtype=np.complex128)
|
|
1112
|
+
for i, idx in enumerate(global_freq_indices):
|
|
1113
|
+
entry = entries[idx]
|
|
1114
|
+
scale = scale_fn(entry)
|
|
1115
|
+
spectrum[i] = (entry["source_dft_real"] + 1j * entry["source_dft_imag"]) * scale
|
|
1116
|
+
return spectrum
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
def _combine_plane_sample_data(sample_data):
|
|
1120
|
+
if sample_data and any(isinstance(data, torch.Tensor) for _, data in sample_data):
|
|
1121
|
+
combined = torch.zeros_like(sample_data[0][1])
|
|
1122
|
+
for weight, data in sample_data:
|
|
1123
|
+
combined = combined + float(weight) * data
|
|
1124
|
+
return combined
|
|
1125
|
+
if len(sample_data) == 1:
|
|
1126
|
+
return sample_data[0][1]
|
|
1127
|
+
|
|
1128
|
+
combined = np.zeros_like(np.asarray(sample_data[0][1]), dtype=np.complex128)
|
|
1129
|
+
for weight, data in sample_data:
|
|
1130
|
+
combined += float(weight) * np.asarray(data)
|
|
1131
|
+
return combined
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
def _safe_source_spectrum(spectrum):
|
|
1135
|
+
if isinstance(spectrum, torch.Tensor):
|
|
1136
|
+
spectrum_array = spectrum.to(dtype=torch.complex64)
|
|
1137
|
+
if spectrum_array.ndim == 0 or spectrum_array.numel() == 1:
|
|
1138
|
+
scalar = spectrum_array.reshape(-1)[0] if spectrum_array.numel() else spectrum_array.new_zeros(())
|
|
1139
|
+
abs_scalar = torch.abs(scalar)
|
|
1140
|
+
floor = max(float(abs_scalar.item()) * 1e-10, 1e-30)
|
|
1141
|
+
return scalar if float(abs_scalar.item()) > floor else torch.as_tensor(complex(floor, 0.0), device=spectrum_array.device, dtype=spectrum_array.dtype)
|
|
1142
|
+
|
|
1143
|
+
abs_spectrum = torch.abs(spectrum_array)
|
|
1144
|
+
peak = float(torch.max(abs_spectrum).item()) if abs_spectrum.numel() > 0 else 0.0
|
|
1145
|
+
floor = 1e-10 * peak if peak > 0.0 else 1e-30
|
|
1146
|
+
floor_tensor = torch.full_like(spectrum_array, complex(floor, 0.0))
|
|
1147
|
+
return torch.where(abs_spectrum > floor, spectrum_array, floor_tensor)
|
|
1148
|
+
|
|
1149
|
+
spectrum_array = np.asarray(spectrum, dtype=np.complex128)
|
|
1150
|
+
if spectrum_array.ndim == 0 or spectrum_array.size == 1:
|
|
1151
|
+
scalar = complex(spectrum_array.reshape(-1)[0]) if spectrum_array.size else 0.0j
|
|
1152
|
+
abs_scalar = abs(scalar)
|
|
1153
|
+
floor = max(abs_scalar * 1e-10, 1e-30)
|
|
1154
|
+
return scalar if abs_scalar > floor else complex(floor, 0.0)
|
|
1155
|
+
|
|
1156
|
+
abs_spectrum = np.abs(spectrum_array)
|
|
1157
|
+
peak = float(np.max(abs_spectrum)) if abs_spectrum.size > 0 else 0.0
|
|
1158
|
+
floor = 1e-10 * peak if peak > 0.0 else 1e-30
|
|
1159
|
+
return np.where(abs_spectrum > floor, spectrum_array, complex(floor, 0.0))
|
|
1160
|
+
|
|
1161
|
+
|
|
1162
|
+
def _safe_divide_source_spectrum(field_data, spectrum):
|
|
1163
|
+
"""Divide field data by source spectrum with near-zero clamping."""
|
|
1164
|
+
safe_spectrum = _safe_source_spectrum(spectrum)
|
|
1165
|
+
if isinstance(field_data, torch.Tensor) or isinstance(safe_spectrum, torch.Tensor):
|
|
1166
|
+
field_tensor = field_data if isinstance(field_data, torch.Tensor) else torch.as_tensor(field_data)
|
|
1167
|
+
if not isinstance(safe_spectrum, torch.Tensor):
|
|
1168
|
+
safe_spectrum = torch.as_tensor(safe_spectrum, device=field_tensor.device, dtype=field_tensor.dtype)
|
|
1169
|
+
else:
|
|
1170
|
+
safe_spectrum = safe_spectrum.to(device=field_tensor.device, dtype=field_tensor.dtype)
|
|
1171
|
+
if safe_spectrum.ndim == 0:
|
|
1172
|
+
return field_tensor / safe_spectrum
|
|
1173
|
+
if field_tensor.ndim == 0 or (field_tensor.ndim == 1 and field_tensor.shape[0] == safe_spectrum.shape[0]):
|
|
1174
|
+
return field_tensor / safe_spectrum
|
|
1175
|
+
shape = (safe_spectrum.shape[0],) + (1,) * (field_tensor.ndim - 1)
|
|
1176
|
+
return field_tensor / safe_spectrum.reshape(shape)
|
|
1177
|
+
|
|
1178
|
+
if np.isscalar(safe_spectrum) or np.asarray(safe_spectrum).ndim == 0:
|
|
1179
|
+
return field_data / safe_spectrum
|
|
1180
|
+
if field_data.ndim == 0 or (field_data.ndim == 1 and field_data.shape[0] == safe_spectrum.shape[0]):
|
|
1181
|
+
return field_data / safe_spectrum
|
|
1182
|
+
# Multi-dimensional: spectrum broadcasts over spatial dims
|
|
1183
|
+
shape = (safe_spectrum.shape[0],) + (1,) * (field_data.ndim - 1)
|
|
1184
|
+
return field_data / safe_spectrum.reshape(shape)
|
|
1185
|
+
|
|
1186
|
+
|
|
1187
|
+
def _normalize_flux_by_source_spectrum(flux_data, spectrum):
|
|
1188
|
+
safe_spectrum = _safe_source_spectrum(spectrum)
|
|
1189
|
+
if isinstance(flux_data, torch.Tensor) or isinstance(safe_spectrum, torch.Tensor):
|
|
1190
|
+
flux_tensor = flux_data if isinstance(flux_data, torch.Tensor) else torch.as_tensor(flux_data)
|
|
1191
|
+
if not isinstance(safe_spectrum, torch.Tensor):
|
|
1192
|
+
safe_spectrum = torch.as_tensor(safe_spectrum, device=flux_tensor.device, dtype=torch.complex64)
|
|
1193
|
+
else:
|
|
1194
|
+
safe_spectrum = safe_spectrum.to(device=flux_tensor.device)
|
|
1195
|
+
normalized = flux_tensor / (torch.abs(safe_spectrum) ** 2)
|
|
1196
|
+
if normalized.ndim == 0:
|
|
1197
|
+
return normalized.reshape(())
|
|
1198
|
+
return normalized
|
|
1199
|
+
|
|
1200
|
+
flux_array = np.asarray(flux_data, dtype=np.float64)
|
|
1201
|
+
if np.isscalar(safe_spectrum) or np.asarray(safe_spectrum).ndim == 0:
|
|
1202
|
+
normalized = flux_array / (abs(safe_spectrum) ** 2)
|
|
1203
|
+
else:
|
|
1204
|
+
normalized = flux_array / (np.abs(safe_spectrum) ** 2)
|
|
1205
|
+
if normalized.ndim == 0:
|
|
1206
|
+
return float(normalized)
|
|
1207
|
+
return normalized
|
|
1208
|
+
|
|
1209
|
+
|
|
1210
|
+
def _normalize_monitor_result_inplace(result, spectrum):
|
|
1211
|
+
for comp_name in ("Ex", "Ey", "Ez", "Hx", "Hy", "Hz"):
|
|
1212
|
+
if comp_name in result:
|
|
1213
|
+
result[comp_name] = _safe_divide_source_spectrum(result[comp_name], spectrum)
|
|
1214
|
+
|
|
1215
|
+
components = result.get("components", {})
|
|
1216
|
+
if _monitor_payload_is_point(result):
|
|
1217
|
+
for comp_name, value in list(components.items()):
|
|
1218
|
+
components[comp_name] = _safe_divide_source_spectrum(value, spectrum)
|
|
1219
|
+
else:
|
|
1220
|
+
for payload in components.values():
|
|
1221
|
+
if isinstance(payload, dict) and "data" in payload:
|
|
1222
|
+
payload["data"] = _safe_divide_source_spectrum(payload["data"], spectrum)
|
|
1223
|
+
|
|
1224
|
+
primary_component = result.get("component")
|
|
1225
|
+
if primary_component and primary_component in result:
|
|
1226
|
+
result["data"] = result[primary_component]
|
|
1227
|
+
elif "data" in result:
|
|
1228
|
+
result["data"] = _safe_divide_source_spectrum(result["data"], spectrum)
|
|
1229
|
+
|
|
1230
|
+
if "flux" in result:
|
|
1231
|
+
result["flux"] = _normalize_flux_by_source_spectrum(result["flux"], spectrum)
|
|
1232
|
+
if "power" in result:
|
|
1233
|
+
result["power"] = _normalize_flux_by_source_spectrum(result["power"], spectrum)
|
|
1234
|
+
|
|
1235
|
+
|
|
1236
|
+
def get_observer_results(solver):
|
|
1237
|
+
if not solver.observers_enabled or not any(entry["sample_count"] > 0 for entry in solver._observer_spectral_entries):
|
|
1238
|
+
raise ValueError("No observer samples recorded. Configure observers and run solve().")
|
|
1239
|
+
|
|
1240
|
+
point_cache = {}
|
|
1241
|
+
plane_cache = {}
|
|
1242
|
+
monitor_source_indices = {}
|
|
1243
|
+
results = {}
|
|
1244
|
+
for observer in solver.observers:
|
|
1245
|
+
monitor_name = observer.get("monitor_name", observer["name"])
|
|
1246
|
+
monitor_source_indices.setdefault(monitor_name, observer["global_freq_indices"])
|
|
1247
|
+
monitor_fields = tuple(_field_name(field) for field in observer.get("monitor_fields", (observer["component"],)))
|
|
1248
|
+
public_component = _field_name(observer["component"])
|
|
1249
|
+
monitor_frequencies = tuple(float(freq) for freq in observer["frequencies"])
|
|
1250
|
+
observer_samples = np.asarray(
|
|
1251
|
+
[solver._observer_spectral_entries[index]["sample_count"] for index in observer["global_freq_indices"]],
|
|
1252
|
+
dtype=int,
|
|
1253
|
+
)
|
|
1254
|
+
|
|
1255
|
+
if _observer_is_point(observer):
|
|
1256
|
+
cache_key = (observer["group_component"], observer["group_offset"])
|
|
1257
|
+
if cache_key not in point_cache:
|
|
1258
|
+
group = solver._point_observer_groups[observer["group_component"]]
|
|
1259
|
+
offset = observer["group_offset"]
|
|
1260
|
+
scales = torch.as_tensor(
|
|
1261
|
+
[_spectral_scale(solver._observer_spectral_entries[index]) for index in group["global_freq_indices"]],
|
|
1262
|
+
device=group["real"].device,
|
|
1263
|
+
dtype=group["real"].dtype,
|
|
1264
|
+
)
|
|
1265
|
+
point_cache[cache_key] = combine_complex_spectral_components(
|
|
1266
|
+
group["real"][:, offset],
|
|
1267
|
+
group["imag"][:, offset],
|
|
1268
|
+
group["aux_real"][:, offset] if "aux_real" in group else None,
|
|
1269
|
+
group["aux_imag"][:, offset] if "aux_imag" in group else None,
|
|
1270
|
+
) * scales
|
|
1271
|
+
group = solver._point_observer_groups[observer["group_component"]]
|
|
1272
|
+
local_indices = [group["freq_local_lookup"][index] for index in observer["global_freq_indices"]]
|
|
1273
|
+
data = point_cache[cache_key][local_indices]
|
|
1274
|
+
if len(monitor_frequencies) == 1:
|
|
1275
|
+
data = data[0]
|
|
1276
|
+
else:
|
|
1277
|
+
plane_sample_data = []
|
|
1278
|
+
for sample in observer["plane_samples"]:
|
|
1279
|
+
cache_key = sample["group_key"]
|
|
1280
|
+
if cache_key not in plane_cache:
|
|
1281
|
+
group = solver._plane_observer_groups[cache_key]
|
|
1282
|
+
scales = torch.as_tensor(
|
|
1283
|
+
[_spectral_scale(solver._observer_spectral_entries[index]) for index in group["global_freq_indices"]],
|
|
1284
|
+
device=group["real"].device,
|
|
1285
|
+
dtype=group["real"].dtype,
|
|
1286
|
+
)
|
|
1287
|
+
plane_cache[cache_key] = combine_complex_spectral_components(
|
|
1288
|
+
group["real"],
|
|
1289
|
+
group["imag"],
|
|
1290
|
+
group["aux_real"] if "aux_real" in group else None,
|
|
1291
|
+
group["aux_imag"] if "aux_imag" in group else None,
|
|
1292
|
+
) * scales[:, None, None]
|
|
1293
|
+
group = solver._plane_observer_groups[cache_key]
|
|
1294
|
+
local_indices = [group["freq_local_lookup"][index] for index in observer["global_freq_indices"]]
|
|
1295
|
+
sample_values = plane_cache[cache_key][local_indices]
|
|
1296
|
+
if len(monitor_frequencies) == 1:
|
|
1297
|
+
sample_values = sample_values[0]
|
|
1298
|
+
plane_sample_data.append((sample["weight"], sample_values))
|
|
1299
|
+
data = _combine_plane_sample_data(plane_sample_data)
|
|
1300
|
+
|
|
1301
|
+
if _observer_is_point(observer):
|
|
1302
|
+
entry = results.setdefault(
|
|
1303
|
+
monitor_name,
|
|
1304
|
+
{
|
|
1305
|
+
"kind": "point",
|
|
1306
|
+
"monitor_type": observer.get("monitor_type", "point"),
|
|
1307
|
+
"fields": monitor_fields,
|
|
1308
|
+
"components": {},
|
|
1309
|
+
"field_indices": {},
|
|
1310
|
+
"samples": observer_samples if len(monitor_frequencies) > 1 else int(observer_samples[0]),
|
|
1311
|
+
"frequency": monitor_frequencies[0],
|
|
1312
|
+
"frequencies": monitor_frequencies,
|
|
1313
|
+
"position": observer["position"],
|
|
1314
|
+
"dipole_polarization": observer.get("dipole_polarization"),
|
|
1315
|
+
"dipole_position": observer.get("dipole_position"),
|
|
1316
|
+
"source_name": observer.get("source_name"),
|
|
1317
|
+
},
|
|
1318
|
+
)
|
|
1319
|
+
entry["components"][public_component] = data
|
|
1320
|
+
entry["field_indices"][public_component] = observer["field_index"]
|
|
1321
|
+
else:
|
|
1322
|
+
entry = results.setdefault(
|
|
1323
|
+
monitor_name,
|
|
1324
|
+
{
|
|
1325
|
+
"kind": "plane",
|
|
1326
|
+
"monitor_type": observer.get("monitor_type", "plane"),
|
|
1327
|
+
"fields": monitor_fields,
|
|
1328
|
+
"components": {},
|
|
1329
|
+
"plane_indices": {},
|
|
1330
|
+
"samples": observer_samples if len(monitor_frequencies) > 1 else int(observer_samples[0]),
|
|
1331
|
+
"frequency": monitor_frequencies[0],
|
|
1332
|
+
"frequencies": monitor_frequencies,
|
|
1333
|
+
"axis": observer["axis"],
|
|
1334
|
+
"position": observer["position"],
|
|
1335
|
+
"compute_flux": bool(observer.get("compute_flux", False)),
|
|
1336
|
+
"normal_direction": observer.get("normal_direction", "+"),
|
|
1337
|
+
"mode_spec": observer.get("mode_spec"),
|
|
1338
|
+
"diffraction_spec": observer.get("diffraction_spec"),
|
|
1339
|
+
},
|
|
1340
|
+
)
|
|
1341
|
+
entry["components"][public_component] = {
|
|
1342
|
+
"data": data,
|
|
1343
|
+
"coords": observer["plane_coords"],
|
|
1344
|
+
"plane_index": observer["plane_index"],
|
|
1345
|
+
"plane_indices": observer.get("plane_indices", (observer["plane_index"],)),
|
|
1346
|
+
"plane_weights": observer.get("plane_weights", (1.0,)),
|
|
1347
|
+
"plane_positions": observer.get("plane_positions", (float(observer["position"]),)),
|
|
1348
|
+
}
|
|
1349
|
+
entry["plane_indices"][public_component] = observer["plane_index"]
|
|
1350
|
+
|
|
1351
|
+
for result in results.values():
|
|
1352
|
+
primary_component = result["fields"][0]
|
|
1353
|
+
if _monitor_payload_is_point(result):
|
|
1354
|
+
for component_name, value in result["components"].items():
|
|
1355
|
+
result[component_name] = value
|
|
1356
|
+
if len(result["components"]) == 1:
|
|
1357
|
+
result["component"] = normalize_component(primary_component)
|
|
1358
|
+
result["data"] = result["components"][primary_component]
|
|
1359
|
+
result["field_index"] = result["field_indices"][primary_component]
|
|
1360
|
+
continue
|
|
1361
|
+
|
|
1362
|
+
axis = result["axis"]
|
|
1363
|
+
coord_names = _plane_coord_names(axis)
|
|
1364
|
+
if len(result["components"]) == 1:
|
|
1365
|
+
payload = result["components"][primary_component]
|
|
1366
|
+
result["component"] = normalize_component(primary_component)
|
|
1367
|
+
result["data"] = payload["data"]
|
|
1368
|
+
result["coords"] = payload["coords"]
|
|
1369
|
+
result["plane_index"] = payload["plane_index"]
|
|
1370
|
+
result["plane_sample_indices"] = payload.get("plane_indices", (payload["plane_index"],))
|
|
1371
|
+
result["plane_sample_weights"] = payload.get("plane_weights", (1.0,))
|
|
1372
|
+
result["plane_sample_positions"] = payload.get("plane_positions", ())
|
|
1373
|
+
result[coord_names[0]] = payload["coords"][0]
|
|
1374
|
+
result[coord_names[1]] = payload["coords"][1]
|
|
1375
|
+
result[primary_component] = payload["data"]
|
|
1376
|
+
continue
|
|
1377
|
+
|
|
1378
|
+
aligned = _align_plane_monitor_payload(axis, result["components"])
|
|
1379
|
+
if aligned is None:
|
|
1380
|
+
if result.get("compute_flux"):
|
|
1381
|
+
raise ValueError(
|
|
1382
|
+
f"Monitor {result!r} is missing aligned tangential fields required for flux integration."
|
|
1383
|
+
)
|
|
1384
|
+
continue
|
|
1385
|
+
if result.get("compute_flux"):
|
|
1386
|
+
aligned = _crop_aligned_plane_to_physical_bounds(solver, axis, aligned)
|
|
1387
|
+
result[coord_names[0]] = aligned[coord_names[0]]
|
|
1388
|
+
result[coord_names[1]] = aligned[coord_names[1]]
|
|
1389
|
+
result["coords"] = (aligned[coord_names[0]], aligned[coord_names[1]])
|
|
1390
|
+
for component_name, value in aligned["fields"].items():
|
|
1391
|
+
result[component_name] = value
|
|
1392
|
+
if result.get("compute_flux"):
|
|
1393
|
+
flux = _compute_plane_flux(result)
|
|
1394
|
+
result["flux"] = flux
|
|
1395
|
+
result["power"] = flux
|
|
1396
|
+
|
|
1397
|
+
# Dipole-emission power pass: P = -(1/2) Re(conj(J) . E) at the dipole cell.
|
|
1398
|
+
# The delivered power is formed from the co-located E DFT and the known
|
|
1399
|
+
# source current spectrum, then excluded from the field-normalization pass.
|
|
1400
|
+
for monitor_name in list(results.keys()):
|
|
1401
|
+
result = results[monitor_name]
|
|
1402
|
+
if result.get("monitor_type") != "dipole_emission":
|
|
1403
|
+
continue
|
|
1404
|
+
global_freq_indices = monitor_source_indices.pop(monitor_name, None)
|
|
1405
|
+
if global_freq_indices is None:
|
|
1406
|
+
continue
|
|
1407
|
+
current_spectrum = _compute_source_spectrum(
|
|
1408
|
+
solver._observer_spectral_entries,
|
|
1409
|
+
global_freq_indices,
|
|
1410
|
+
_spectral_scale,
|
|
1411
|
+
)
|
|
1412
|
+
polarization = result.get("dipole_polarization") or (0.0, 0.0, 0.0)
|
|
1413
|
+
components = result.get("components", {})
|
|
1414
|
+
e_projected = None
|
|
1415
|
+
for (comp_name, weight) in (("Ex", polarization[0]), ("Ey", polarization[1]), ("Ez", polarization[2])):
|
|
1416
|
+
if weight == 0.0 or comp_name not in components:
|
|
1417
|
+
continue
|
|
1418
|
+
term = float(weight) * components[comp_name]
|
|
1419
|
+
e_projected = term if e_projected is None else e_projected + term
|
|
1420
|
+
if e_projected is None:
|
|
1421
|
+
result["power_delivered"] = None
|
|
1422
|
+
continue
|
|
1423
|
+
if isinstance(e_projected, torch.Tensor):
|
|
1424
|
+
current = current_spectrum
|
|
1425
|
+
if not isinstance(current, torch.Tensor):
|
|
1426
|
+
current = torch.as_tensor(current, device=e_projected.device, dtype=e_projected.dtype)
|
|
1427
|
+
else:
|
|
1428
|
+
current = current.to(device=e_projected.device, dtype=e_projected.dtype)
|
|
1429
|
+
if current.ndim == 0 or e_projected.ndim == 0:
|
|
1430
|
+
current = current.reshape(e_projected.shape)
|
|
1431
|
+
power = -0.5 * torch.real(torch.conj(current) * e_projected)
|
|
1432
|
+
else:
|
|
1433
|
+
current = np.asarray(current_spectrum, dtype=np.complex128).reshape(np.asarray(e_projected).shape)
|
|
1434
|
+
power = -0.5 * np.real(np.conj(current) * np.asarray(e_projected))
|
|
1435
|
+
result["power_delivered"] = power
|
|
1436
|
+
result["current_spectrum"] = current_spectrum
|
|
1437
|
+
|
|
1438
|
+
# Source-spectrum normalization pass
|
|
1439
|
+
if getattr(solver, '_normalize_source', False) and getattr(solver, '_source_time', None) is not None:
|
|
1440
|
+
for monitor_name, global_freq_indices in monitor_source_indices.items():
|
|
1441
|
+
result = results.get(monitor_name)
|
|
1442
|
+
if result is None:
|
|
1443
|
+
continue
|
|
1444
|
+
spectrum = _compute_source_spectrum(
|
|
1445
|
+
solver._observer_spectral_entries,
|
|
1446
|
+
global_freq_indices,
|
|
1447
|
+
_spectral_scale,
|
|
1448
|
+
)
|
|
1449
|
+
if spectrum.size == 1:
|
|
1450
|
+
spectrum = spectrum[0]
|
|
1451
|
+
_normalize_monitor_result_inplace(result, spectrum)
|
|
1452
|
+
|
|
1453
|
+
return results
|