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,967 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import numpy as np
8
+ import torch
9
+
10
+ from .monitors import ClosedSurfaceMonitor, FinitePlaneMonitor, MediumMonitor, PermittivityMonitor
11
+ from .scene import prepare_scene
12
+ from .visualization import extract_orthogonal_slice, plot_slice_image
13
+
14
+ _UNSET = object()
15
+
16
+
17
+ def _clone_mapping(data: dict[str, Any]) -> dict[str, Any]:
18
+ return dict(data) if data is not None else {}
19
+
20
+
21
+ def _cpu_serializable(value: Any):
22
+ if isinstance(value, torch.Tensor):
23
+ return value.detach().cpu()
24
+ if isinstance(value, dict):
25
+ return {key: _cpu_serializable(item) for key, item in value.items()}
26
+ if isinstance(value, list):
27
+ return [_cpu_serializable(item) for item in value]
28
+ if isinstance(value, tuple):
29
+ return tuple(_cpu_serializable(item) for item in value)
30
+ return value
31
+
32
+
33
+ def _resolve_result_frequencies(*, frequency: float | None, frequencies) -> tuple[float, ...]:
34
+ if frequencies is not None:
35
+ resolved = tuple(float(freq) for freq in frequencies)
36
+ elif frequency is not None:
37
+ resolved = (float(frequency),)
38
+ else:
39
+ raise ValueError("Result requires frequency or frequencies.")
40
+ if not resolved:
41
+ raise ValueError("Result frequencies must not be empty.")
42
+ return resolved
43
+
44
+
45
+ def _resolve_frequency_index(
46
+ frequencies: tuple[float, ...],
47
+ *,
48
+ frequency: float | None = None,
49
+ freq_index: int | None = None,
50
+ ) -> int | None:
51
+ if frequency is not None and freq_index is not None:
52
+ raise ValueError("Pass either frequency or freq_index, not both.")
53
+ if not frequencies:
54
+ return None
55
+ if freq_index is not None:
56
+ index = int(freq_index)
57
+ if index < 0 or index >= len(frequencies):
58
+ raise IndexError(f"freq_index={index} is out of range for {len(frequencies)} frequencies.")
59
+ return index
60
+ if frequency is None:
61
+ return None
62
+
63
+ target = float(frequency)
64
+ for index, candidate in enumerate(frequencies):
65
+ if np.isclose(candidate, target, rtol=1e-9, atol=1e-12):
66
+ return index
67
+ raise KeyError(f"Frequency {target!r} is not available. Choices: {frequencies}.")
68
+
69
+
70
+ def _slice_frequency_axis(value: Any, freq_count: int, index: int):
71
+ if isinstance(value, torch.Tensor) and value.ndim > 0 and value.shape[0] == freq_count:
72
+ return value[index]
73
+ if isinstance(value, np.ndarray) and value.ndim > 0 and value.shape[0] == freq_count:
74
+ return value[index]
75
+ return value
76
+
77
+
78
+ def _slice_monitor_samples(samples: Any, freq_count: int, index: int):
79
+ if isinstance(samples, torch.Tensor) and samples.ndim > 0 and samples.shape[0] == freq_count:
80
+ return int(samples[index].item())
81
+ if isinstance(samples, np.ndarray) and samples.ndim > 0 and samples.shape[0] == freq_count:
82
+ return int(samples[index])
83
+ if isinstance(samples, (list, tuple)) and len(samples) == freq_count:
84
+ return int(samples[index])
85
+ return samples
86
+
87
+
88
+ def _monitor_frequencies(payload: dict[str, Any]) -> tuple[float, ...]:
89
+ if "frequencies" in payload:
90
+ return tuple(float(freq) for freq in payload["frequencies"])
91
+ if "frequency" in payload:
92
+ return (float(payload["frequency"]),)
93
+ return ()
94
+
95
+
96
+ def _select_monitor_frequency(payload: dict[str, Any], frequencies: tuple[float, ...], index: int) -> dict[str, Any]:
97
+ selected = dict(payload)
98
+ selected_frequency = frequencies[index]
99
+ selected["frequencies"] = (selected_frequency,)
100
+ selected["frequency"] = selected_frequency
101
+
102
+ if "samples" in payload:
103
+ selected["samples"] = _slice_monitor_samples(payload["samples"], len(frequencies), index)
104
+ if "data" in payload:
105
+ selected["data"] = _slice_frequency_axis(payload["data"], len(frequencies), index)
106
+ if "flux" in payload:
107
+ selected["flux"] = _slice_frequency_axis(payload["flux"], len(frequencies), index)
108
+ if "power" in payload:
109
+ selected["power"] = _slice_frequency_axis(payload["power"], len(frequencies), index)
110
+ if payload.get("power_delivered") is not None:
111
+ selected["power_delivered"] = _slice_frequency_axis(payload["power_delivered"], len(frequencies), index)
112
+ if payload.get("current_spectrum") is not None:
113
+ selected["current_spectrum"] = _slice_frequency_axis(payload["current_spectrum"], len(frequencies), index)
114
+
115
+ if _monitor_payload_is_point(payload):
116
+ components = {}
117
+ for component_name, component_value in payload.get("components", {}).items():
118
+ sliced = _slice_frequency_axis(component_value, len(frequencies), index)
119
+ components[component_name] = sliced
120
+ if component_name in payload:
121
+ selected[component_name] = _slice_frequency_axis(payload[component_name], len(frequencies), index)
122
+ selected["components"] = components
123
+ return selected
124
+
125
+ components = {}
126
+ for component_name, component_payload in payload.get("components", {}).items():
127
+ updated_payload = dict(component_payload)
128
+ updated_payload["data"] = _slice_frequency_axis(component_payload["data"], len(frequencies), index)
129
+ components[component_name] = updated_payload
130
+ if component_name in payload:
131
+ selected[component_name] = _slice_frequency_axis(payload[component_name], len(frequencies), index)
132
+ selected["components"] = components
133
+ return selected
134
+
135
+
136
+ _FIELD_NORMAL_AXIS = {
137
+ "EX": 0,
138
+ "EY": 1,
139
+ "EZ": 2,
140
+ "HX": 0,
141
+ "HY": 1,
142
+ "HZ": 2,
143
+ }
144
+
145
+
146
+ def _mirror_sign(component: str | None, symmetry: str, axis: int) -> int:
147
+ if component is None:
148
+ return 1
149
+ normal_axis = _FIELD_NORMAL_AXIS.get(component.upper())
150
+ if normal_axis is None:
151
+ return 1
152
+ is_normal = normal_axis == axis
153
+ if symmetry == "PEC":
154
+ return 1 if is_normal else -1
155
+ if symmetry == "PMC":
156
+ return -1 if is_normal else 1
157
+ return 1
158
+
159
+
160
+ def _expand_tensor_with_symmetry(tensor: torch.Tensor, scene, component: str | None = None) -> torch.Tensor:
161
+ expanded = tensor
162
+ half_sizes = (int(scene.Nx), int(scene.Ny), int(scene.Nz))
163
+ spatial_axis_offset = expanded.ndim - 3
164
+ if spatial_axis_offset < 0:
165
+ raise ValueError(f"Expected tensor with at least 3 dimensions, got shape {tuple(expanded.shape)}.")
166
+ for axis, entry in enumerate(getattr(scene, "symmetry", (None, None, None))):
167
+ if entry is None:
168
+ continue
169
+ mode, face = entry
170
+ tensor_axis = spatial_axis_offset + axis
171
+ half_size = half_sizes[axis]
172
+ dim_size = int(expanded.shape[tensor_axis])
173
+ # Components with a grid node on the symmetry plane have ``half_size``
174
+ # samples; that shared plane must not be duplicated when mirroring.
175
+ # Components staggered off the plane have ``half_size - 1`` samples and
176
+ # are mirrored in full. The shared node sits at index 0 for a low-face
177
+ # plane and at the last index for a high-face plane.
178
+ if dim_size == half_size:
179
+ if face == "low":
180
+ mirrored_source = expanded.narrow(tensor_axis, 1, max(dim_size - 1, 0))
181
+ else:
182
+ mirrored_source = expanded.narrow(tensor_axis, 0, max(dim_size - 1, 0))
183
+ elif dim_size == half_size - 1:
184
+ mirrored_source = expanded
185
+ else:
186
+ raise ValueError(
187
+ f"Cannot expand symmetry axis {axis} for tensor shape {tuple(expanded.shape)} and half-domain size {half_size}."
188
+ )
189
+ mirrored = torch.flip(mirrored_source, dims=(tensor_axis,))
190
+ sign = _mirror_sign(component, mode, axis)
191
+ if sign < 0:
192
+ mirrored = -mirrored
193
+ if face == "low":
194
+ expanded = torch.cat((mirrored, expanded), dim=tensor_axis)
195
+ else:
196
+ expanded = torch.cat((expanded, mirrored), dim=tensor_axis)
197
+ return expanded
198
+
199
+
200
+ def _monitor_payload_is_point(payload: dict[str, Any]) -> bool:
201
+ if "field_indices" in payload:
202
+ return True
203
+ if "axis" in payload:
204
+ return False
205
+ kind = payload.get("kind")
206
+ if kind is not None:
207
+ return kind == "point"
208
+ return False
209
+
210
+
211
+ def _monitor_payload_is_mode(payload: dict[str, Any]) -> bool:
212
+ if payload.get("mode_spec") is not None:
213
+ return True
214
+ monitor_type = payload.get("monitor_type")
215
+ if monitor_type is not None:
216
+ return monitor_type == "mode"
217
+ return False
218
+
219
+
220
+ def _monitor_payload_is_diffraction(payload: dict[str, Any]) -> bool:
221
+ return payload.get("monitor_type") == "diffraction"
222
+
223
+
224
+ def _monitor_payload_is_dipole_emission(payload: dict[str, Any]) -> bool:
225
+ return payload.get("monitor_type") == "dipole_emission"
226
+
227
+
228
+ def _monitor_payload_is_closed_surface(payload: dict[str, Any]) -> bool:
229
+ return payload.get("kind") == "closed_surface"
230
+
231
+
232
+ def _find_scene_monitor(scene, name: str):
233
+ for monitor in getattr(scene, "monitors", ()):
234
+ if getattr(monitor, "name", None) == name:
235
+ return monitor
236
+ return None
237
+
238
+
239
+ def _find_resolved_scene_monitor(scene, name: str):
240
+ if not hasattr(scene, "resolved_monitors"):
241
+ return None
242
+ for monitor in scene.resolved_monitors():
243
+ if getattr(monitor, "name", None) == name:
244
+ return monitor
245
+ return None
246
+
247
+
248
+ def _plane_coord_names(axis: str) -> tuple[str, str]:
249
+ axis_name = str(axis).lower()
250
+ if axis_name == "x":
251
+ return "y", "z"
252
+ if axis_name == "y":
253
+ return "x", "z"
254
+ return "x", "y"
255
+
256
+
257
+ def _select_coord_indices(coords, lower: float, upper: float):
258
+ if isinstance(coords, torch.Tensor):
259
+ coord_tensor = coords.to(dtype=coords.real.dtype)
260
+ tolerance = 1e-12 * max(
261
+ abs(lower),
262
+ abs(upper),
263
+ float(torch.max(torch.abs(coord_tensor)).item()),
264
+ 1.0,
265
+ )
266
+ mask = (coord_tensor >= lower - tolerance) & (coord_tensor <= upper + tolerance)
267
+ indices = torch.nonzero(mask, as_tuple=False).reshape(-1)
268
+ if indices.numel() < 2:
269
+ raise ValueError(f"Finite monitor bounds [{lower}, {upper}] select fewer than two samples.")
270
+ if indices.numel() == coord_tensor.numel():
271
+ return None
272
+ return indices.to(device=coords.device)
273
+
274
+ coord_array = np.asarray(coords, dtype=float)
275
+ tolerance = 1e-12 * max(abs(lower), abs(upper), float(np.max(np.abs(coord_array))), 1.0)
276
+ indices = np.nonzero((coord_array >= lower - tolerance) & (coord_array <= upper + tolerance))[0]
277
+ if indices.size < 2:
278
+ raise ValueError(f"Finite monitor bounds [{lower}, {upper}] select fewer than two samples.")
279
+ if indices.size == coord_array.size:
280
+ return None
281
+ return indices
282
+
283
+
284
+ def _slice_plane_samples(values, u_indices, v_indices):
285
+ if isinstance(values, torch.Tensor):
286
+ sliced = values
287
+ if u_indices is not None:
288
+ if not isinstance(u_indices, torch.Tensor):
289
+ u_indices = torch.as_tensor(u_indices, device=values.device, dtype=torch.long)
290
+ sliced = sliced.index_select(sliced.ndim - 2, u_indices)
291
+ if v_indices is not None:
292
+ if not isinstance(v_indices, torch.Tensor):
293
+ v_indices = torch.as_tensor(v_indices, device=values.device, dtype=torch.long)
294
+ sliced = sliced.index_select(sliced.ndim - 1, v_indices)
295
+ return sliced
296
+
297
+ array = np.asarray(values)
298
+ if u_indices is not None:
299
+ array = np.take(array, u_indices, axis=array.ndim - 2)
300
+ if v_indices is not None:
301
+ array = np.take(array, v_indices, axis=array.ndim - 1)
302
+ return array
303
+
304
+
305
+ def _crop_plane_monitor_payload(payload: dict[str, Any], monitor: FinitePlaneMonitor) -> dict[str, Any]:
306
+ coord_u_name, coord_v_name = _plane_coord_names(monitor.axis)
307
+ if coord_u_name not in payload or coord_v_name not in payload:
308
+ return dict(payload)
309
+
310
+ u_indices = _select_coord_indices(payload[coord_u_name], *monitor.tangential_bounds[coord_u_name])
311
+ v_indices = _select_coord_indices(payload[coord_v_name], *monitor.tangential_bounds[coord_v_name])
312
+ if u_indices is None and v_indices is None:
313
+ selected = dict(payload)
314
+ selected["monitor_type"] = "finite_plane"
315
+ selected["center"] = monitor.position
316
+ selected["size"] = monitor.size
317
+ selected["tangential_bounds"] = dict(monitor.tangential_bounds)
318
+ if monitor.face_label is not None:
319
+ selected["face_label"] = monitor.face_label
320
+ if monitor.surface_name is not None:
321
+ selected["surface_name"] = monitor.surface_name
322
+ return selected
323
+
324
+ coord_u = payload[coord_u_name]
325
+ coord_v = payload[coord_v_name]
326
+ selected_u = coord_u if u_indices is None else (
327
+ coord_u.index_select(0, u_indices) if isinstance(coord_u, torch.Tensor) else np.asarray(coord_u)[u_indices]
328
+ )
329
+ selected_v = coord_v if v_indices is None else (
330
+ coord_v.index_select(0, v_indices) if isinstance(coord_v, torch.Tensor) else np.asarray(coord_v)[v_indices]
331
+ )
332
+
333
+ selected = dict(payload)
334
+ selected[coord_u_name] = selected_u
335
+ selected[coord_v_name] = selected_v
336
+ selected["coords"] = (selected_u, selected_v)
337
+ selected["monitor_type"] = "finite_plane"
338
+ selected["center"] = monitor.position
339
+ selected["size"] = monitor.size
340
+ selected["tangential_bounds"] = dict(monitor.tangential_bounds)
341
+ if monitor.face_label is not None:
342
+ selected["face_label"] = monitor.face_label
343
+ if monitor.surface_name is not None:
344
+ selected["surface_name"] = monitor.surface_name
345
+
346
+ if "data" in payload:
347
+ selected["data"] = _slice_plane_samples(payload["data"], u_indices, v_indices)
348
+
349
+ components = {}
350
+ for component_name, component_payload in payload.get("components", {}).items():
351
+ updated_component = dict(component_payload)
352
+ updated_component["data"] = _slice_plane_samples(component_payload["data"], u_indices, v_indices)
353
+ updated_component["coords"] = (selected_u, selected_v)
354
+ components[component_name] = updated_component
355
+ if component_name in payload:
356
+ selected[component_name] = _slice_plane_samples(payload[component_name], u_indices, v_indices)
357
+ selected["components"] = components
358
+
359
+ if selected.get("compute_flux"):
360
+ from .fdtd.observers import _compute_plane_flux
361
+
362
+ flux = _compute_plane_flux(selected)
363
+ selected["flux"] = flux
364
+ selected["power"] = flux
365
+ return selected
366
+
367
+
368
+ def _material_monitor_axis_indices(coord: torch.Tensor, bounds: tuple[float, float]) -> torch.Tensor:
369
+ lower, upper = float(bounds[0]), float(bounds[1])
370
+ center = 0.5 * (lower + upper)
371
+ if abs(upper - lower) <= 1e-12:
372
+ nearest = int(torch.argmin(torch.abs(coord - center)).item())
373
+ return torch.tensor([nearest], device=coord.device, dtype=torch.long)
374
+ scale = max(abs(lower), abs(upper), float(torch.max(torch.abs(coord)).item()), 1.0)
375
+ tolerance = 1e-9 * scale
376
+ mask = (coord >= lower - tolerance) & (coord <= upper + tolerance)
377
+ indices = torch.nonzero(mask, as_tuple=False).reshape(-1)
378
+ if indices.numel() == 0:
379
+ nearest = int(torch.argmin(torch.abs(coord - center)).item())
380
+ return torch.tensor([nearest], device=coord.device, dtype=torch.long)
381
+ return indices.to(device=coord.device, dtype=torch.long)
382
+
383
+
384
+ def _crop_material_grid(tensor: torch.Tensor, ix: torch.Tensor, iy: torch.Tensor, iz: torch.Tensor) -> torch.Tensor:
385
+ return tensor.index_select(0, ix).index_select(1, iy).index_select(2, iz)
386
+
387
+
388
+ def _stack_material_frequencies(items: list[torch.Tensor]) -> torch.Tensor:
389
+ if len(items) == 1:
390
+ return items[0]
391
+ return torch.stack(items, dim=0)
392
+
393
+
394
+ def _resolve_material_monitor_frequencies(
395
+ result: "Result",
396
+ monitor,
397
+ *,
398
+ frequency,
399
+ freq_index,
400
+ ) -> tuple[float, ...]:
401
+ if frequency is not None and freq_index is not None:
402
+ raise ValueError("Pass either frequency or freq_index, not both.")
403
+ if freq_index is not None:
404
+ index = _resolve_frequency_index(result.frequencies, freq_index=freq_index)
405
+ return (result.frequencies[index],)
406
+ if frequency is not None:
407
+ return (float(frequency),)
408
+ if monitor.frequencies is not None:
409
+ return tuple(float(freq) for freq in monitor.frequencies)
410
+ return tuple(result.frequencies)
411
+
412
+
413
+ def _build_material_monitor_payload(result: "Result", monitor, *, frequency, freq_index):
414
+ prepared = result.prepared_scene
415
+ ix = _material_monitor_axis_indices(prepared.x, monitor.bounds[0])
416
+ iy = _material_monitor_axis_indices(prepared.y, monitor.bounds[1])
417
+ iz = _material_monitor_axis_indices(prepared.z, monitor.bounds[2])
418
+
419
+ eval_frequencies = _resolve_material_monitor_frequencies(
420
+ result,
421
+ monitor,
422
+ frequency=frequency,
423
+ freq_index=freq_index,
424
+ )
425
+ is_medium = isinstance(monitor, MediumMonitor)
426
+
427
+ eps_x_list, eps_y_list, eps_z_list = [], [], []
428
+ mu_x_list, mu_y_list, mu_z_list = [], [], []
429
+ for freq in eval_frequencies:
430
+ eps_components, mu_components = prepared.compile_material_components(frequency=freq)
431
+ eps_x_list.append(_crop_material_grid(eps_components["x"], ix, iy, iz))
432
+ eps_y_list.append(_crop_material_grid(eps_components["y"], ix, iy, iz))
433
+ eps_z_list.append(_crop_material_grid(eps_components["z"], ix, iy, iz))
434
+ if is_medium:
435
+ mu_x_list.append(_crop_material_grid(mu_components["x"], ix, iy, iz))
436
+ mu_y_list.append(_crop_material_grid(mu_components["y"], ix, iy, iz))
437
+ mu_z_list.append(_crop_material_grid(mu_components["z"], ix, iy, iz))
438
+
439
+ eps_x = _stack_material_frequencies(eps_x_list)
440
+ eps_y = _stack_material_frequencies(eps_y_list)
441
+ eps_z = _stack_material_frequencies(eps_z_list)
442
+
443
+ payload: dict[str, Any] = {
444
+ "kind": monitor.kind,
445
+ "monitor_type": monitor.kind,
446
+ "name": monitor.name,
447
+ "bounds": monitor.bounds,
448
+ "x": prepared.x.index_select(0, ix),
449
+ "y": prepared.y.index_select(0, iy),
450
+ "z": prepared.z.index_select(0, iz),
451
+ "eps_x": eps_x,
452
+ "eps_y": eps_y,
453
+ "eps_z": eps_z,
454
+ "eps": (eps_x + eps_y + eps_z) / 3.0,
455
+ }
456
+
457
+ if is_medium:
458
+ mu_x = _stack_material_frequencies(mu_x_list)
459
+ mu_y = _stack_material_frequencies(mu_y_list)
460
+ mu_z = _stack_material_frequencies(mu_z_list)
461
+ sigma_components = prepared.compile_materials()["sigma_e_components"]
462
+ sigma_x = _crop_material_grid(sigma_components["x"], ix, iy, iz)
463
+ sigma_y = _crop_material_grid(sigma_components["y"], ix, iy, iz)
464
+ sigma_z = _crop_material_grid(sigma_components["z"], ix, iy, iz)
465
+ # sigma_e is real and frequency-independent; broadcast it across evaluated frequencies.
466
+ sigma_x = _stack_material_frequencies([sigma_x] * len(eval_frequencies))
467
+ sigma_y = _stack_material_frequencies([sigma_y] * len(eval_frequencies))
468
+ sigma_z = _stack_material_frequencies([sigma_z] * len(eval_frequencies))
469
+ payload["mu_x"] = mu_x
470
+ payload["mu_y"] = mu_y
471
+ payload["mu_z"] = mu_z
472
+ payload["mu"] = (mu_x + mu_y + mu_z) / 3.0
473
+ payload["sigma_e_x"] = sigma_x
474
+ payload["sigma_e_y"] = sigma_y
475
+ payload["sigma_e_z"] = sigma_z
476
+ payload["sigma_e"] = (sigma_x + sigma_y + sigma_z) / 3.0
477
+
478
+ if len(eval_frequencies) == 1:
479
+ payload["frequency"] = eval_frequencies[0]
480
+ payload["frequencies"] = eval_frequencies
481
+ return payload
482
+
483
+
484
+ def _build_closed_surface_payload(result: "Result", monitor: ClosedSurfaceMonitor, *, frequency, freq_index):
485
+ faces = {}
486
+ for face in monitor.faces:
487
+ face_payload = result.raw_monitor(face.name, frequency=frequency, freq_index=freq_index)
488
+ faces[face.face_label or face.name] = face_payload
489
+
490
+ first_face = next(iter(faces.values()))
491
+ frequencies = tuple(first_face.get("frequencies", (first_face.get("frequency"),)))
492
+ payload = {
493
+ "kind": "closed_surface",
494
+ "monitor_type": "closed_surface",
495
+ "name": monitor.name,
496
+ "faces": faces,
497
+ "face_monitor_names": monitor.face_monitor_names,
498
+ "frequency": first_face.get("frequency"),
499
+ "frequencies": frequencies,
500
+ "bounds": monitor.bounds,
501
+ }
502
+ return payload
503
+
504
+
505
+ @dataclass(frozen=True)
506
+ class ResultFieldAccessor:
507
+ selection: "ResultSelection"
508
+ family: str
509
+
510
+ @property
511
+ def x(self) -> torch.Tensor:
512
+ return self.selection.tensor(f"{self.family}x")
513
+
514
+ @property
515
+ def y(self) -> torch.Tensor:
516
+ return self.selection.tensor(f"{self.family}y")
517
+
518
+ @property
519
+ def z(self) -> torch.Tensor:
520
+ return self.selection.tensor(f"{self.family}z")
521
+
522
+ def as_dict(self) -> dict[str, torch.Tensor]:
523
+ tensors: dict[str, torch.Tensor] = {}
524
+ for axis in ("x", "y", "z"):
525
+ field_name = f"{self.family}{axis}".upper()
526
+ if field_name not in self.selection.result._fields:
527
+ continue
528
+ tensors[axis] = self.selection.tensor(field_name)
529
+ return tensors
530
+
531
+
532
+ @dataclass(frozen=True)
533
+ class ResultMaterialTensorAccessor:
534
+ selection: "ResultSelection"
535
+ family: str
536
+
537
+ @property
538
+ def scalar(self) -> torch.Tensor:
539
+ suffix = "eps_r" if self.family == "eps" else "mu_r"
540
+ return self.selection.material(suffix)
541
+
542
+ @property
543
+ def r(self) -> torch.Tensor:
544
+ return self.scalar
545
+
546
+ @property
547
+ def x(self) -> torch.Tensor:
548
+ return self.selection.material(f"{self.family}_x")
549
+
550
+ @property
551
+ def y(self) -> torch.Tensor:
552
+ return self.selection.material(f"{self.family}_y")
553
+
554
+ @property
555
+ def z(self) -> torch.Tensor:
556
+ return self.selection.material(f"{self.family}_z")
557
+
558
+ def as_dict(self) -> dict[str, torch.Tensor]:
559
+ return {
560
+ "scalar": self.scalar,
561
+ "x": self.x,
562
+ "y": self.y,
563
+ "z": self.z,
564
+ }
565
+
566
+
567
+ @dataclass(frozen=True)
568
+ class ResultMaterialsAccessor:
569
+ selection: "ResultSelection"
570
+
571
+ @property
572
+ def eps(self) -> ResultMaterialTensorAccessor:
573
+ return ResultMaterialTensorAccessor(self.selection, "eps")
574
+
575
+ @property
576
+ def mu(self) -> ResultMaterialTensorAccessor:
577
+ return ResultMaterialTensorAccessor(self.selection, "mu")
578
+
579
+ @property
580
+ def permittivity(self) -> ResultMaterialTensorAccessor:
581
+ return self.eps
582
+
583
+ @property
584
+ def permeability(self) -> ResultMaterialTensorAccessor:
585
+ return self.mu
586
+
587
+
588
+ @dataclass(frozen=True)
589
+ class ResultSelection:
590
+ result: "Result"
591
+ frequency: float | None = None
592
+ freq_index: int | None = None
593
+ expand_symmetry: bool = False
594
+ resolve_modal: bool = True
595
+
596
+ def __post_init__(self):
597
+ if self.frequency is not None and self.freq_index is not None:
598
+ raise ValueError("Pass either frequency or freq_index, not both.")
599
+
600
+ @property
601
+ def E(self) -> ResultFieldAccessor:
602
+ return ResultFieldAccessor(self, "E")
603
+
604
+ @property
605
+ def H(self) -> ResultFieldAccessor:
606
+ return ResultFieldAccessor(self, "H")
607
+
608
+ @property
609
+ def materials(self) -> ResultMaterialsAccessor:
610
+ return ResultMaterialsAccessor(self)
611
+
612
+ def field(self, name: str = "E"):
613
+ return self.result.field(
614
+ name,
615
+ expand_symmetry=self.expand_symmetry,
616
+ frequency=self.frequency,
617
+ freq_index=self.freq_index,
618
+ )
619
+
620
+ def tensor(self, name: str) -> torch.Tensor:
621
+ return self.result.tensor(
622
+ name,
623
+ expand_symmetry=self.expand_symmetry,
624
+ frequency=self.frequency,
625
+ freq_index=self.freq_index,
626
+ )
627
+
628
+ def material(self, name: str = "eps_r") -> torch.Tensor:
629
+ return self.result.material(
630
+ name,
631
+ expand_symmetry=self.expand_symmetry,
632
+ )
633
+
634
+ def raw_monitor(self, name: str):
635
+ return self.result.raw_monitor(
636
+ name,
637
+ frequency=self.frequency,
638
+ freq_index=self.freq_index,
639
+ )
640
+
641
+ def monitor(self, name: str, *, resolve_modal: bool | None = None):
642
+ should_resolve_modal = self.resolve_modal if resolve_modal is None else bool(resolve_modal)
643
+ return self.result.monitor(
644
+ name,
645
+ frequency=self.frequency,
646
+ freq_index=self.freq_index,
647
+ resolve_modal=should_resolve_modal,
648
+ )
649
+
650
+ def at(
651
+ self,
652
+ *,
653
+ frequency: float | None | object = _UNSET,
654
+ freq_index: int | None | object = _UNSET,
655
+ expand_symmetry: bool | object = _UNSET,
656
+ resolve_modal: bool | object = _UNSET,
657
+ ) -> "ResultSelection":
658
+ selected_frequency = self.frequency if frequency is _UNSET else frequency
659
+ selected_freq_index = self.freq_index if freq_index is _UNSET else freq_index
660
+ selected_expand_symmetry = self.expand_symmetry if expand_symmetry is _UNSET else bool(expand_symmetry)
661
+ selected_resolve_modal = self.resolve_modal if resolve_modal is _UNSET else bool(resolve_modal)
662
+ return ResultSelection(
663
+ self.result,
664
+ frequency=selected_frequency,
665
+ freq_index=selected_freq_index,
666
+ expand_symmetry=selected_expand_symmetry,
667
+ resolve_modal=selected_resolve_modal,
668
+ )
669
+
670
+ select = at
671
+
672
+
673
+ @dataclass
674
+ class ResultPlotter:
675
+ result: "Result"
676
+
677
+ def field(self, axis: str = "z", position: float = 0.0, component: str = "abs", **kwargs):
678
+ solver = self.result.solver
679
+ if solver is None or not hasattr(solver, "plot_cross_section"):
680
+ raise RuntimeError("Plotting is only available for results created from a solver run.")
681
+ return solver.plot_cross_section(
682
+ axis=axis,
683
+ position=position,
684
+ component=component,
685
+ **kwargs,
686
+ )
687
+
688
+ def material(
689
+ self,
690
+ name: str = "eps_r",
691
+ axis: str = "z",
692
+ position: float = 0.0,
693
+ figsize: tuple[int, int] = (8, 6),
694
+ cmap: str = "viridis",
695
+ ):
696
+ material = self.result.material(name)
697
+ scene = self.result.prepared_scene
698
+ slice_info = extract_orthogonal_slice(
699
+ material.detach().cpu().numpy(),
700
+ axis,
701
+ position,
702
+ scene.x.detach().cpu().numpy(),
703
+ scene.y.detach().cpu().numpy(),
704
+ scene.z.detach().cpu().numpy(),
705
+ )
706
+ return plot_slice_image(
707
+ slice_info["slice"],
708
+ extent=slice_info["extent"],
709
+ xlabel=slice_info["xlabel"],
710
+ ylabel=slice_info["ylabel"],
711
+ title=f"{name} at {axis}={position:.3f}m",
712
+ colorbar_label=name,
713
+ figsize=figsize,
714
+ cmap=cmap,
715
+ )
716
+
717
+
718
+ class Result:
719
+ def __init__(
720
+ self,
721
+ *,
722
+ method: str,
723
+ scene,
724
+ prepared_scene=None,
725
+ frequency: float | None = None,
726
+ frequencies=None,
727
+ solver=None,
728
+ fields: dict[str, torch.Tensor] | None = None,
729
+ monitors: dict[str, Any] | None = None,
730
+ metadata: dict[str, Any] | None = None,
731
+ solver_stats: dict[str, Any] | None = None,
732
+ raw_output: Any = None,
733
+ ):
734
+ self.method = method
735
+ self.scene = scene
736
+ self._prepared_scene = prepared_scene
737
+ self.frequencies = _resolve_result_frequencies(frequency=frequency, frequencies=frequencies)
738
+ self.frequency = self.frequencies[0]
739
+ self.solver = solver
740
+ self._fields = _clone_mapping(fields)
741
+ self._monitors = _clone_mapping(monitors)
742
+ self._metadata = _clone_mapping(metadata)
743
+ self._solver_stats = _clone_mapping(solver_stats)
744
+ self.raw_output = raw_output
745
+ self.plot = ResultPlotter(self)
746
+
747
+ @property
748
+ def prepared_scene(self):
749
+ if self._prepared_scene is None:
750
+ self._prepared_scene = prepare_scene(self.scene)
751
+ return self._prepared_scene
752
+
753
+ def at(
754
+ self,
755
+ *,
756
+ frequency: float | None = None,
757
+ freq_index: int | None = None,
758
+ expand_symmetry: bool = False,
759
+ resolve_modal: bool = True,
760
+ ) -> ResultSelection:
761
+ return ResultSelection(
762
+ self,
763
+ frequency=frequency,
764
+ freq_index=freq_index,
765
+ expand_symmetry=expand_symmetry,
766
+ resolve_modal=resolve_modal,
767
+ )
768
+
769
+ select = at
770
+
771
+ @property
772
+ def fields(self) -> dict[str, torch.Tensor]:
773
+ return dict(self._fields)
774
+
775
+ @property
776
+ def monitors(self) -> dict[str, Any]:
777
+ return dict(self._monitors)
778
+
779
+ @property
780
+ def E(self) -> ResultFieldAccessor:
781
+ return self.at().E
782
+
783
+ @property
784
+ def H(self) -> ResultFieldAccessor:
785
+ return self.at().H
786
+
787
+ @property
788
+ def materials(self) -> ResultMaterialsAccessor:
789
+ return self.at().materials
790
+
791
+ def field(
792
+ self,
793
+ name: str = "E",
794
+ *,
795
+ expand_symmetry: bool = False,
796
+ frequency: float | None = None,
797
+ freq_index: int | None = None,
798
+ ):
799
+ key = name.upper()
800
+ if key in {"E", "H"}:
801
+ component_names = tuple(field_name for field_name in (f"{key}X", f"{key}Y", f"{key}Z") if field_name in self._fields)
802
+ if not expand_symmetry:
803
+ return {
804
+ field_name: self.tensor(field_name, frequency=frequency, freq_index=freq_index)
805
+ for field_name in component_names
806
+ }
807
+ return {
808
+ field_name: self.tensor(
809
+ field_name,
810
+ expand_symmetry=True,
811
+ frequency=frequency,
812
+ freq_index=freq_index,
813
+ )
814
+ for field_name in component_names
815
+ }
816
+ return self.tensor(key, expand_symmetry=expand_symmetry, frequency=frequency, freq_index=freq_index)
817
+
818
+ def tensor(
819
+ self,
820
+ name: str,
821
+ *,
822
+ expand_symmetry: bool = False,
823
+ frequency: float | None = None,
824
+ freq_index: int | None = None,
825
+ ) -> torch.Tensor:
826
+ key = name.upper()
827
+ if key not in self._fields:
828
+ raise KeyError(f"Field {name!r} is not available in this result.")
829
+ tensor = self._fields[key]
830
+ selected_index = _resolve_frequency_index(self.frequencies, frequency=frequency, freq_index=freq_index)
831
+ if selected_index is not None and len(self.frequencies) > 1 and tensor.ndim > 0 and tensor.shape[0] == len(self.frequencies):
832
+ tensor = tensor[selected_index]
833
+ if not expand_symmetry:
834
+ return tensor
835
+ return _expand_tensor_with_symmetry(tensor, self.prepared_scene, component=key)
836
+
837
+ def raw_monitor(self, name: str, *, frequency: float | None = None, freq_index: int | None = None):
838
+ public_monitor = _find_scene_monitor(self.scene, name)
839
+ if isinstance(public_monitor, ClosedSurfaceMonitor):
840
+ return _build_closed_surface_payload(self, public_monitor, frequency=frequency, freq_index=freq_index)
841
+ if isinstance(public_monitor, (PermittivityMonitor, MediumMonitor)):
842
+ return _build_material_monitor_payload(self, public_monitor, frequency=frequency, freq_index=freq_index)
843
+
844
+ if name not in self._monitors:
845
+ raise KeyError(f"Monitor {name!r} is not available in this result.")
846
+
847
+ payload = self._monitors[name]
848
+ monitor_frequencies = _monitor_frequencies(payload)
849
+ selected_index = _resolve_frequency_index(
850
+ monitor_frequencies,
851
+ frequency=frequency,
852
+ freq_index=freq_index,
853
+ )
854
+ if selected_index is None or len(monitor_frequencies) <= 1:
855
+ selected = dict(payload)
856
+ else:
857
+ selected = _select_monitor_frequency(payload, monitor_frequencies, selected_index)
858
+
859
+ resolved_monitor = _find_resolved_scene_monitor(self.scene, name)
860
+ if isinstance(resolved_monitor, FinitePlaneMonitor):
861
+ return _crop_plane_monitor_payload(selected, resolved_monitor)
862
+ return selected
863
+
864
+ def monitor(
865
+ self,
866
+ name: str,
867
+ *,
868
+ frequency: float | None = None,
869
+ freq_index: int | None = None,
870
+ resolve_modal: bool = True,
871
+ ):
872
+ payload = self.raw_monitor(name, frequency=frequency, freq_index=freq_index)
873
+ if not resolve_modal or _monitor_payload_is_closed_surface(payload):
874
+ return payload
875
+
876
+ if _monitor_payload_is_diffraction(payload):
877
+ from .postprocess.diffraction import compute_diffraction_from_payload
878
+
879
+ return compute_diffraction_from_payload(self, name, payload)
880
+
881
+ if _monitor_payload_is_dipole_emission(payload):
882
+ emission = {
883
+ "kind": "dipole_emission",
884
+ "monitor_type": "dipole_emission",
885
+ "power_delivered": payload.get("power_delivered"),
886
+ "current_spectrum": payload.get("current_spectrum"),
887
+ "frequencies": tuple(payload.get("frequencies", (payload.get("frequency"),))),
888
+ "frequency": payload.get("frequency"),
889
+ "polarization": payload.get("dipole_polarization"),
890
+ "position": payload.get("dipole_position", payload.get("position")),
891
+ "source_name": payload.get("source_name"),
892
+ # The Purcell factor requires a vacuum reference run; combine two
893
+ # runs with postprocess.purcell_factor(structured, vacuum, name).
894
+ "purcell_factor": None,
895
+ }
896
+ return emission
897
+
898
+ if not _monitor_payload_is_mode(payload):
899
+ return payload
900
+
901
+ from .postprocess.modal import _compute_mode_overlap_from_payload
902
+
903
+ modal = _compute_mode_overlap_from_payload(
904
+ self,
905
+ name,
906
+ payload,
907
+ mode_source=None,
908
+ direction=None,
909
+ )
910
+ modal["kind"] = "mode"
911
+ modal["fields"] = tuple(payload.get("fields", ()))
912
+ modal["frequencies"] = tuple(payload.get("frequencies", (payload.get("frequency"),)))
913
+ modal["frequency"] = payload.get("frequency")
914
+ modal["normal_direction"] = payload.get("normal_direction", "+")
915
+ modal["plane"] = payload
916
+ return modal
917
+
918
+ def material(self, name: str = "eps_r", *, expand_symmetry: bool = False) -> torch.Tensor:
919
+ prepared_scene = self.prepared_scene
920
+ key = name.lower()
921
+ if key in {"eps", "eps_r", "permittivity"}:
922
+ tensor = prepared_scene.permittivity
923
+ elif key in {"eps_x", "epsilon_x", "permittivity_x"}:
924
+ tensor = prepared_scene.permittivity_components["x"]
925
+ elif key in {"eps_y", "epsilon_y", "permittivity_y"}:
926
+ tensor = prepared_scene.permittivity_components["y"]
927
+ elif key in {"eps_z", "epsilon_z", "permittivity_z"}:
928
+ tensor = prepared_scene.permittivity_components["z"]
929
+ elif key in {"mu", "mu_r", "permeability"}:
930
+ tensor = prepared_scene.permeability
931
+ elif key in {"mu_x", "permeability_x"}:
932
+ tensor = prepared_scene.permeability_components["x"]
933
+ elif key in {"mu_y", "permeability_y"}:
934
+ tensor = prepared_scene.permeability_components["y"]
935
+ elif key in {"mu_z", "permeability_z"}:
936
+ tensor = prepared_scene.permeability_components["z"]
937
+ else:
938
+ raise KeyError(f"Material {name!r} is not supported.")
939
+ if not expand_symmetry:
940
+ return tensor
941
+ return _expand_tensor_with_symmetry(tensor, prepared_scene, component=None)
942
+
943
+ def stats(self) -> dict[str, Any]:
944
+ stats = dict(self._solver_stats)
945
+ stats["method"] = self.method
946
+ stats["frequency"] = self.frequency
947
+ stats["frequencies"] = self.frequencies
948
+ stats["num_frequencies"] = len(self.frequencies)
949
+ stats["num_fields"] = len(self._fields)
950
+ stats["num_monitors"] = len(self._monitors)
951
+ return stats
952
+
953
+ def save(self, path: str | Path):
954
+ output_path = Path(path)
955
+ output_path.parent.mkdir(parents=True, exist_ok=True)
956
+ torch.save(
957
+ {
958
+ "method": self.method,
959
+ "frequency": self.frequency,
960
+ "frequencies": self.frequencies,
961
+ "fields": {name: tensor.detach().cpu() for name, tensor in self._fields.items()},
962
+ "monitors": _cpu_serializable(self._monitors),
963
+ "metadata": self._metadata,
964
+ "solver_stats": self._solver_stats,
965
+ },
966
+ output_path,
967
+ )