witwin-maxwell 0.3.0__py3-none-win_amd64.whl

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