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,1057 @@
1
+ import torch
2
+ import numpy as np
3
+ import matplotlib.pyplot as plt
4
+ import cupy as cp
5
+ import cupyx.cusparse as cupy_cusparse
6
+ import cupyx.scipy.sparse as cpsp
7
+ from cupyx.scipy.sparse.linalg import cg as cupy_cg, gmres as cupy_gmres
8
+ import time
9
+ import cupyx.scipy.sparse.linalg as cupy_linalg
10
+ from tqdm import tqdm
11
+
12
+ from ..compiler.materials import _scene_has_dispersive_material
13
+ from ..compiler.sources import compile_fdfd_sources
14
+ from ..scene import prepare_scene
15
+ from ..sources import CW, POINT_DIPOLE_REFERENCE_WIDTH, PointDipole
16
+ from .plotting import (
17
+ plot_cross_section as plot_cross_section_impl,
18
+ plot_isotropic_3views as plot_isotropic_3views_impl,
19
+ )
20
+ from .postprocess import (
21
+ get_field_raw as get_field_raw_impl,
22
+ interpolate_yee_to_center as interpolate_yee_to_center_impl,
23
+ )
24
+
25
+
26
+ # The FDFD ideal-delta path is calibrated against its Gaussian reference
27
+ # profile independently of the FDTD source-injection normalization.
28
+ _FDFD_IDEAL_POINT_DIPOLE_PROFILE_SCALE = 0.75
29
+
30
+ # Iterative-refinement steps for the cuDSS direct solver. The complex64 LU of
31
+ # the strongly PML-graded operator loses several digits; refinement recovers
32
+ # them at ~one matvec + one reuse-solve per step.
33
+ FDFD_DIRECT_IR_STEPS = 10
34
+
35
+
36
+ def print_gpu_memory(prefix=""):
37
+ """Print current GPU memory usage."""
38
+ import cupy as cp
39
+ free, total = cp.cuda.Device(0).mem_info
40
+ used = total - free
41
+ print(f"{prefix} GPU Memory Usage: Used {used/1024**3:.2f} GB / Total {total/1024**3:.2f} GB")
42
+
43
+
44
+ def _normalize_point_dipole_profile_gpu(profile, dist_sq, width, mask=None):
45
+ if np.isclose(width, POINT_DIPOLE_REFERENCE_WIDTH):
46
+ return cp.where(mask, profile, 0.0) if mask is not None else profile
47
+
48
+ reference_width_sq = 2.0 * POINT_DIPOLE_REFERENCE_WIDTH ** 2
49
+ if mask is not None:
50
+ profile = cp.where(mask, profile, 0.0)
51
+ reference_profile = cp.where(mask, cp.exp(-dist_sq / reference_width_sq), 0.0)
52
+ else:
53
+ reference_profile = cp.exp(-dist_sq / reference_width_sq)
54
+ current_sum = cp.maximum(profile.sum(), np.finfo(np.float32).eps)
55
+ return profile * (reference_profile.sum() / current_sum)
56
+
57
+
58
+ def _nearest_axis_index_gpu(coords, center):
59
+ return int(cp.abs(coords - center).argmin())
60
+
61
+
62
+ def _support_slice_gpu(coords, center, cutoff):
63
+ indices = cp.where(cp.abs(coords - center) <= cutoff)[0]
64
+ if int(indices.size) == 0:
65
+ nearest = _nearest_axis_index_gpu(coords, center)
66
+ return nearest, nearest + 1
67
+ return int(indices[0]), int(indices[-1]) + 1
68
+
69
+
70
+ def _add_point_dipole_component_gpu(b_component, x_coords, y_coords, z_coords,
71
+ position, amplitude, width, profile_kind):
72
+ """Accumulate one polarization component of a point dipole into the 3D view
73
+ of the source vector, restricted to the cutoff support slice."""
74
+ if profile_kind == 'ideal':
75
+ ix = _nearest_axis_index_gpu(x_coords, position[0])
76
+ iy = _nearest_axis_index_gpu(y_coords, position[1])
77
+ iz = _nearest_axis_index_gpu(z_coords, position[2])
78
+ b_component[ix, iy, iz] += amplitude * _ideal_point_dipole_mass_gpu(
79
+ x_coords, y_coords, z_coords, position
80
+ )
81
+ return
82
+
83
+ cutoff = 3 * max(width, 0.5 * POINT_DIPOLE_REFERENCE_WIDTH)
84
+ bounds = []
85
+ for coords, center in ((x_coords, position[0]), (y_coords, position[1]), (z_coords, position[2])):
86
+ start, end = _support_slice_gpu(coords, center, cutoff)
87
+ if end - start == 1 and float(cp.abs(coords[start] - center)) > cutoff:
88
+ return # no grid point within the cutoff: zero contribution
89
+ bounds.append((start, end))
90
+ (ix0, ix1), (iy0, iy1), (iz0, iz1) = bounds
91
+
92
+ local_x = x_coords[ix0:ix1]
93
+ local_y = y_coords[iy0:iy1]
94
+ local_z = z_coords[iz0:iz1]
95
+ dist_sq = (
96
+ (local_x[:, None, None] - position[0]) ** 2
97
+ + (local_y[None, :, None] - position[1]) ** 2
98
+ + (local_z[None, None, :] - position[2]) ** 2
99
+ )
100
+ profile = cp.exp(-dist_sq / (2.0 * width ** 2))
101
+ profile = _normalize_point_dipole_profile_gpu(profile, dist_sq, width)
102
+ b_component[ix0:ix1, iy0:iy1, iz0:iz1] += amplitude * profile
103
+
104
+
105
+ def _ideal_point_dipole_mass_gpu(x_coords, y_coords, z_coords, position):
106
+ cutoff = 3.0 * POINT_DIPOLE_REFERENCE_WIDTH
107
+ ix_start, ix_end = _support_slice_gpu(x_coords, position[0], cutoff)
108
+ iy_start, iy_end = _support_slice_gpu(y_coords, position[1], cutoff)
109
+ iz_start, iz_end = _support_slice_gpu(z_coords, position[2], cutoff)
110
+ local_x = x_coords[ix_start:ix_end]
111
+ local_y = y_coords[iy_start:iy_end]
112
+ local_z = z_coords[iz_start:iz_end]
113
+ dist_sq = (
114
+ (local_x[:, None, None] - position[0]) ** 2
115
+ + (local_y[None, :, None] - position[1]) ** 2
116
+ + (local_z[None, None, :] - position[2]) ** 2
117
+ )
118
+ reference_width_sq = 2.0 * POINT_DIPOLE_REFERENCE_WIDTH ** 2
119
+ return _FDFD_IDEAL_POINT_DIPOLE_PROFILE_SCALE * cp.exp(-dist_sq / reference_width_sq).sum()
120
+
121
+
122
+ def _require_cuda_scene(scene):
123
+ device = torch.device(scene.device)
124
+ if device.type != "cuda":
125
+ raise ValueError(f"FDFD requires scene.device to be CUDA, got {device}.")
126
+ if not cp.cuda.is_available():
127
+ raise RuntimeError("FDFD requires CUDA/CuPy, but no CUDA-capable device is available.")
128
+
129
+
130
+ def _require_uniform_grid(scene):
131
+ if getattr(scene, "grid", None) is None:
132
+ return
133
+ if not bool(getattr(scene.grid, "is_uniform", False)):
134
+ raise ValueError("FDFD currently requires a uniform GridSpec with dx == dy == dz.")
135
+
136
+
137
+ def _is_nonvacuum_magnetic_material(material) -> bool:
138
+ if bool(getattr(material, "mu_debye_poles", ()) or getattr(material, "mu_drude_poles", ()) or getattr(material, "mu_lorentz_poles", ())):
139
+ return True
140
+ if getattr(material, "mu_tensor", None) is not None:
141
+ return True
142
+ # Static magnetic conductivity makes the permeability complex (mu_c = mu0 -
143
+ # i sigma_m/omega); it is a magnetically-lossy medium the FDFD system does not
144
+ # model, so it must be rejected rather than silently dropped.
145
+ if float(getattr(material, "sigma_m", 0.0)) != 0.0:
146
+ return True
147
+ return not np.isclose(float(getattr(material, "mu_r", 1.0)), 1.0)
148
+
149
+
150
+ def _validate_supported_fdfd_materials(scene):
151
+ for structure in getattr(scene, "structures", ()):
152
+ material = getattr(structure, "material", None)
153
+ if material is None:
154
+ continue
155
+ if bool(getattr(material, "is_nonlinear", False)):
156
+ raise NotImplementedError(
157
+ "FDFD static parity for Kerr nonlinear media is deferred (user decision 2026-07-11): a "
158
+ "frequency-domain solve has no single linear operator for a field-amplitude-dependent "
159
+ "permittivity. Use FDTD, which models the nonlinearity directly."
160
+ )
161
+ if bool(getattr(material, "has_full_epsilon_tensor", False)):
162
+ raise NotImplementedError(
163
+ "FDFD static parity for full (off-diagonal Tensor3x3) anisotropic permittivity is deferred "
164
+ "(user decision 2026-07-11): the curl-curl operator assembly here forms only per-axis "
165
+ "diagonal material components. Use FDTD, which models the coupled tensor."
166
+ )
167
+ if _is_nonvacuum_magnetic_material(material):
168
+ raise NotImplementedError(
169
+ "FDFD static parity for magnetic media and magnetic dispersion is deferred (user decision "
170
+ "2026-07-11): this solver assembles an electric-only operator with mu_r = 1. Use FDTD, "
171
+ "which models magnetic response."
172
+ )
173
+
174
+
175
+
176
+ class FDFD:
177
+ """
178
+ Full-vector 3D FDFD solver based on the Yee staggered grid.
179
+ Runs on CUDA via CuPy sparse linear algebra and iterative solvers.
180
+ """
181
+ def __init__(self, scene, frequency=2.0e9, solver_type='gmres', preconditioner='jacobi',
182
+ precision='single', ssor_omega=0.8,
183
+ enable_plot=True, tqdm_position=None, verbose=False):
184
+ """
185
+ Initialize the 3D FDFD solver.
186
+
187
+ Args:
188
+ scene: Scene instance
189
+ frequency: Operating frequency (Hz)
190
+ solver_type: Solver type ('cg', 'gmres', 'direct', 'bicgstab',
191
+ 'tfqmr', 'idr', 'sqmr'). The short-recurrence engines live in
192
+ fdfd/krylov.py; 'sqmr' relies on the complex-symmetric
193
+ system produced by the symmetrized UPML assembly.
194
+ preconditioner: Preconditioner for the iterative solvers
195
+ ('none', 'jacobi', 'ssor', 'ilu'). 'ilu' is ILU(0) and is
196
+ known to be unstable on the indefinite curl-curl operator;
197
+ see benchmark/FDFD_PERFORMANCE.md.
198
+ precision: Working precision of the iterative solve ('single' or
199
+ 'double'). Assembly and returned fields stay complex64;
200
+ 'double' casts the system and preconditioner to complex128
201
+ for the Krylov recurrences, which removes the float32
202
+ round-off stagnation (e.g. sqmr+ssor converges to 1e-7 at
203
+ 48^3 where single precision stalls at ~2e-2). Ignored by
204
+ the 'direct' backend, which has its own iterative refinement.
205
+ ssor_omega: Relaxation factor for the 'ssor' preconditioner
206
+ (measured plateau of best convergence at 0.6-0.8).
207
+ enable_plot: Whether to enable plotting (set False for batch data generation)
208
+ tqdm_position: tqdm progress bar position (for multi-GPU parallelism, set different values per GPU)
209
+ verbose: Whether to print detailed debug information
210
+ """
211
+ self.scene = prepare_scene(scene)
212
+ self.frequency = frequency
213
+ self.enable_plot = enable_plot
214
+ self.tqdm_position = tqdm_position
215
+ self.verbose = verbose
216
+
217
+ unsupported_face_kinds = sorted(
218
+ {
219
+ self.scene.boundary.face_kind(axis, side)
220
+ for axis in ("x", "y", "z")
221
+ for side in ("low", "high")
222
+ if self.scene.boundary.face_kind(axis, side) not in {"none", "pml"}
223
+ }
224
+ )
225
+ if unsupported_face_kinds:
226
+ raise ValueError(
227
+ "FDFD currently supports only per-face 'none' and 'pml' boundaries."
228
+ )
229
+ if getattr(self.scene, "has_symmetry", False):
230
+ raise ValueError("FDFD currently does not support Scene(symmetry=...).")
231
+ _require_cuda_scene(self.scene)
232
+ _require_uniform_grid(self.scene)
233
+ self.use_gpu = True
234
+
235
+ if solver_type not in ['cg', 'gmres', 'direct', 'bicgstab', 'tfqmr', 'idr', 'sqmr']:
236
+ raise ValueError(
237
+ "solver_type must be one of 'cg', 'gmres', 'direct', 'bicgstab', 'tfqmr', 'idr', 'sqmr'"
238
+ )
239
+ self.solver_type = solver_type
240
+ if preconditioner not in ['none', 'jacobi', 'ssor', 'ilu', 'ams']:
241
+ raise ValueError("preconditioner must be 'none', 'jacobi', 'ssor', 'ilu', or 'ams'")
242
+ self.preconditioner = preconditioner
243
+ if precision not in ['single', 'double']:
244
+ raise ValueError("precision must be 'single' or 'double'")
245
+ self.precision = precision
246
+ self.ssor_omega = float(ssor_omega)
247
+
248
+ # Physical constants
249
+ self.c0 = 299792458.0
250
+ self.mu0 = 4 * np.pi * 1e-7
251
+ self.eps0 = 1 / (self.mu0 * self.c0 ** 2)
252
+
253
+ self.omega = 2 * np.pi * frequency
254
+ self.k0 = self.omega / self.c0
255
+
256
+ self.E_field = None # Stores (Ex, Ey, Ez) as torch tensors
257
+ self.E_field_raw = None # Raw Yee grid data generated on demand {'Ex': np.ndarray, 'Ey': np.ndarray, 'Ez': np.ndarray}
258
+ self.A_matrix = None
259
+ self._matrix_frequency = None # Frequency the cached A_matrix was built for
260
+ self._preconditioner_op = None # Cached preconditioner for the cached A_matrix
261
+ self._direct_solver = None # Cached cuDSS factorization for the cached A_matrix
262
+ self._sym_scale = None # Symmetrizing diagonal d = sqrt(sx*sy*sz), grid/PML-only
263
+ self._iteration_matrix_cache = None # complex128 copy of A for precision='double'
264
+ self.material_eps_r = None
265
+ self.material_mu_r = None
266
+ self.material_eps_components = None
267
+ self.material_mu_components = None
268
+
269
+ _validate_supported_fdfd_materials(self.scene)
270
+
271
+ if self.verbose:
272
+ print("Initializing full-vector FDFD solver based on Yee grid")
273
+ print(f"Using solver: {solver_type.upper()}")
274
+ print("Using device: GPU")
275
+
276
+ def set_frequency(self, frequency):
277
+ """Switch operating frequency, invalidating the cached system matrix.
278
+
279
+ Material components are reused when all scene materials are
280
+ non-dispersive; dispersive scenes recompile them on the next solve.
281
+ """
282
+ frequency = float(frequency)
283
+ if frequency == self.frequency:
284
+ return
285
+ self.frequency = frequency
286
+ self.omega = 2 * np.pi * frequency
287
+ self.k0 = self.omega / self.c0
288
+ self.A_matrix = None
289
+ self._matrix_frequency = None
290
+ self._preconditioner_op = None
291
+ self._iteration_matrix_cache = None
292
+ self._release_direct_solver()
293
+ if _scene_has_dispersive_material(self.scene):
294
+ self.material_eps_components = None
295
+ self.material_mu_components = None
296
+ self.material_eps_r = None
297
+ self.material_mu_r = None
298
+
299
+ def _ensure_material_components(self):
300
+ if self.material_eps_components is not None:
301
+ return
302
+ for structure in self.scene.structures:
303
+ material = getattr(structure, "material", None)
304
+ if material is not None and bool(getattr(material, "is_pec", False)):
305
+ raise NotImplementedError(
306
+ "FDFD static parity for in-domain PEC materials is deferred (user decision 2026-07-11): "
307
+ "the in-domain Dirichlet/mask handling for a perfect conductor is unimplemented here. Use "
308
+ "FDTD for PEC-material scenes."
309
+ )
310
+ self.material_eps_components, self.material_mu_components = self.scene.compile_material_components(
311
+ frequency=self.frequency
312
+ )
313
+ self.material_eps_r = (
314
+ self.material_eps_components["x"]
315
+ + self.material_eps_components["y"]
316
+ + self.material_eps_components["z"]
317
+ ) / 3.0
318
+ self.material_mu_r = (
319
+ self.material_mu_components["x"]
320
+ + self.material_mu_components["y"]
321
+ + self.material_mu_components["z"]
322
+ ) / 3.0
323
+
324
+ def _ensure_system_matrix(self):
325
+ """Build the system matrix for the current frequency, reusing the
326
+ cached one when available. Only b changes across sources, so repeated
327
+ solves and frequency-swept non-dispersive runs skip reassembly."""
328
+ if self.A_matrix is not None and self._matrix_frequency == self.frequency:
329
+ return
330
+ self._ensure_material_components()
331
+ self.scene.release_meshgrid()
332
+ self.A_matrix = self._build_matrix_gpu_yee_3d()
333
+ self._matrix_frequency = self.frequency
334
+ self._preconditioner_op = None
335
+ self._iteration_matrix_cache = None
336
+ self._release_direct_solver()
337
+
338
+ def _iteration_matrix(self):
339
+ """System matrix in the working precision of the iterative solve."""
340
+ if self.precision == 'single':
341
+ return self.A_matrix
342
+ if self._iteration_matrix_cache is None:
343
+ self._iteration_matrix_cache = self.A_matrix.astype(cp.complex128)
344
+ return self._iteration_matrix_cache
345
+
346
+ def _iteration_dtype(self):
347
+ return cp.complex64 if self.precision == 'single' else cp.complex128
348
+
349
+ def _release_direct_solver(self):
350
+ if self._direct_solver is not None:
351
+ self._direct_solver.free()
352
+ self._direct_solver = None
353
+
354
+ def _build_direct_solver(self, matrix, rhs):
355
+ """Plan and factorize a cuDSS direct solver for the given matrix.
356
+
357
+ The LU is complex64; iterative refinement recovers most of the
358
+ accuracy lost to the strongly graded PML scaling. Hybrid memory mode
359
+ keeps factor storage partially in host memory: it sidesteps
360
+ device-allocation failures for the large factor blocks and measures
361
+ faster than pure-device factorization on Windows/WDDM.
362
+ """
363
+ from nvmath.sparse.advanced import DirectSolver, ExecutionCUDA, HybridMemoryModeOptions
364
+
365
+ execution = ExecutionCUDA(
366
+ hybrid_memory_mode_options=HybridMemoryModeOptions(hybrid_memory_mode=True)
367
+ )
368
+ direct = DirectSolver(matrix, rhs, execution=execution)
369
+ direct.plan()
370
+ direct.factorize()
371
+ direct.solution_config.ir_num_steps = FDFD_DIRECT_IR_STEPS
372
+ return direct
373
+
374
+ def _solve_direct(self, b):
375
+ """Solve Ax = b with cuDSS, factorizing once and reusing across RHS."""
376
+ if self._direct_solver is None:
377
+ self._direct_solver = self._build_direct_solver(self.A_matrix, b)
378
+ else:
379
+ self._direct_solver.reset_operands(b=b)
380
+ return self._direct_solver.solve()
381
+
382
+ def solve_adjoint(self, rhs, max_iter=1000, tol=1e-6, restart=100):
383
+ """Solve the adjoint system A^T lam = rhs for a flat complex64 RHS.
384
+
385
+ The assembled scaled system S = D A D^-1 is complex-symmetric
386
+ (S^T = S), so A^T lam = rhs reduces to S (lam/d) = rhs/d and the
387
+ adjoint reuses the forward operator, preconditioner, and (direct
388
+ backend) factorization. rhs and the returned lam are in physical
389
+ (unscaled) space.
390
+ """
391
+ self._ensure_system_matrix()
392
+ scale = self._ensure_symmetrization_scale()
393
+ rhs_scaled = rhs / scale
394
+ if self.solver_type == 'direct':
395
+ y = self._solve_direct(rhs_scaled)
396
+ else:
397
+ M = self._ensure_preconditioner()
398
+ A_iter = self._iteration_matrix()
399
+ rhs_iter = rhs_scaled.astype(self._iteration_dtype(), copy=False)
400
+ if self.solver_type == 'cg':
401
+ y, _info = cupy_cg(A_iter, rhs_iter, M=M, maxiter=max_iter, tol=tol)
402
+ elif self.solver_type in ('bicgstab', 'tfqmr', 'idr', 'sqmr'):
403
+ from . import krylov
404
+ y, _info = krylov.solve(self.solver_type, A_iter, rhs_iter, M=M,
405
+ tol=tol, maxiter=max_iter)
406
+ else:
407
+ y, _info = cupy_gmres(A_iter, rhs_iter, M=M, maxiter=max_iter, tol=tol, restart=restart)
408
+ y = y.astype(cp.complex64, copy=False)
409
+ return y * scale
410
+
411
+ def _ensure_preconditioner(self):
412
+ if self.preconditioner == 'none':
413
+ return None
414
+ if self._preconditioner_op is None:
415
+ self._preconditioner_op = self._build_preconditioner(self._iteration_matrix())
416
+ return self._preconditioner_op
417
+
418
+ def _build_preconditioner(self, A):
419
+ """Build the preconditioner operator for the given CSR matrix.
420
+
421
+ All variants construct and apply on GPU. The cuSPARSE triangular
422
+ solves upcast complex64 to complex128, so results are cast back
423
+ after each solve.
424
+ """
425
+ if self.preconditioner == 'jacobi':
426
+ inv_diagonal = cp.reciprocal(A.diagonal())
427
+
428
+ def apply_jacobi(v):
429
+ return inv_diagonal * v
430
+
431
+ operator = cupy_linalg.LinearOperator(A.shape, matvec=apply_jacobi, dtype=A.dtype)
432
+ elif self.preconditioner == 'ssor':
433
+ # SSOR(omega): M = (D + wL) [w(2-w)]^-1 D^-1 (D + wU),
434
+ # applied as M^-1 v via two triangular solves on A's own triangles
435
+ omega = self.ssor_omega
436
+ diagonal = A.diagonal()
437
+ lower = cpsp.tril(A, k=-1).tocsr() * omega + cpsp.diags(diagonal).tocsr()
438
+ upper = cpsp.triu(A, k=1).tocsr() * omega + cpsp.diags(diagonal).tocsr()
439
+ scale_back = omega * (2.0 - omega)
440
+
441
+ def apply_ssor(v):
442
+ y = cupy_linalg.spsolve_triangular(lower, v, lower=True)
443
+ y = y.astype(A.dtype, copy=False) * diagonal
444
+ y = cupy_linalg.spsolve_triangular(upper, y, lower=False)
445
+ return y.astype(A.dtype, copy=False) * scale_back
446
+
447
+ operator = cupy_linalg.LinearOperator(A.shape, matvec=apply_ssor, dtype=A.dtype)
448
+ elif self.preconditioner == 'ams':
449
+ from .ams import build_ams_preconditioner
450
+
451
+ operator = build_ams_preconditioner(self)
452
+ else: # 'ilu'
453
+ factors = A.copy()
454
+ cupy_cusparse.csrilu02(factors)
455
+ lower = cpsp.tril(factors, k=0).tocsr()
456
+ upper = cpsp.triu(factors, k=0).tocsr()
457
+
458
+ def apply_ilu(v):
459
+ y = cupy_linalg.spsolve_triangular(lower, v, lower=True, unit_diagonal=True)
460
+ y = cupy_linalg.spsolve_triangular(upper, y.astype(A.dtype, copy=False), lower=False)
461
+ return y.astype(A.dtype, copy=False)
462
+
463
+ operator = cupy_linalg.LinearOperator(A.shape, matvec=apply_ilu, dtype=A.dtype)
464
+
465
+ return operator
466
+
467
+ def _create_pml_3d(self):
468
+ """Create PML complex stretching factors on main grid nodes"""
469
+ Nx, Ny, Nz = self.scene.Nx, self.scene.Ny, self.scene.Nz
470
+ s_x = torch.ones(Nx, dtype=torch.complex64, device=self.scene.device)
471
+ s_y = torch.ones(Ny, dtype=torch.complex64, device=self.scene.device)
472
+ s_z = torch.ones(Nz, dtype=torch.complex64, device=self.scene.device)
473
+
474
+ if self.scene.boundary.uses_kind("pml"):
475
+ pml_thickness = self.scene.pml_thickness
476
+ pml_strength = self.scene.pml_strength
477
+ # Quadratic grading profile
478
+ profile = torch.linspace(0, 1, pml_thickness, device=self.scene.device)**2 * pml_strength
479
+ if self.scene.boundary_face_kind("x", "low") == "pml":
480
+ width = min(pml_thickness, Nx)
481
+ s_x[:width] = 1 + 1j * torch.flip(profile[:width], [0])
482
+ if self.scene.boundary_face_kind("x", "high") == "pml":
483
+ width = min(pml_thickness, Nx)
484
+ s_x[-width:] = 1 + 1j * profile[:width]
485
+ if self.scene.boundary_face_kind("y", "low") == "pml":
486
+ width = min(pml_thickness, Ny)
487
+ s_y[:width] = 1 + 1j * torch.flip(profile[:width], [0])
488
+ if self.scene.boundary_face_kind("y", "high") == "pml":
489
+ width = min(pml_thickness, Ny)
490
+ s_y[-width:] = 1 + 1j * profile[:width]
491
+ if self.scene.boundary_face_kind("z", "low") == "pml":
492
+ width = min(pml_thickness, Nz)
493
+ s_z[:width] = 1 + 1j * torch.flip(profile[:width], [0])
494
+ if self.scene.boundary_face_kind("z", "high") == "pml":
495
+ width = min(pml_thickness, Nz)
496
+ s_z[-width:] = 1 + 1j * profile[:width]
497
+
498
+ return s_x, s_y, s_z
499
+
500
+ def _build_matrix_gpu_yee_3d(self):
501
+ """
502
+ Build the 3D full-vector coefficient matrix for the Yee grid using CuPy on GPU.
503
+
504
+ Uses the standard symmetric UPML discretization: same-field couplings
505
+ take the mixed form 1/(s_row * s_half) so the operator is a diagonal
506
+ similarity D A D^-1 (D = sqrt(sx*sy*sz) per unknown) away from
507
+ complex-symmetric. The returned matrix IS the scaled, complex-symmetric
508
+ system; solves must scale b -> d*b and unscale x -> x/d (see
509
+ _ensure_symmetrization_scale).
510
+ """
511
+ ds = float(self.scene.dx)
512
+ k0 = self.k0
513
+
514
+ Nx_ex, Ny_ex, Nz_ex = self.scene.Nx_ex, self.scene.Ny_ex, self.scene.Nz_ex
515
+ Nx_ey, Ny_ey, Nz_ey = self.scene.Nx_ey, self.scene.Ny_ey, self.scene.Nz_ey
516
+ Nx_ez, Ny_ez, Nz_ez = self.scene.Nx_ez, self.scene.Ny_ez, self.scene.Nz_ez
517
+ N_ex, N_ey = self.scene.N_ex, self.scene.N_ey
518
+ N_vec = self.scene.N_vector_total
519
+
520
+ if self.verbose:
521
+ print("Preparing PML and permittivity for Yee grid...")
522
+ s_x_main, s_y_main, s_z_main = self._create_pml_3d()
523
+ s_x_main, s_y_main, s_z_main = cp.asarray(s_x_main), cp.asarray(s_y_main), cp.asarray(s_z_main)
524
+ s_x_half = (s_x_main[:-1] + s_x_main[1:]) / 2
525
+ s_y_half = (s_y_main[:-1] + s_y_main[1:]) / 2
526
+ s_z_half = (s_z_main[:-1] + s_z_main[1:]) / 2
527
+ # Half-grid stretch factors padded at the walls so boundary rows reuse
528
+ # the edge value; index m addresses position (m - 1/2), m+1 -> (m + 1/2)
529
+ s_x_half_pad = cp.concatenate([s_x_half[:1], s_x_half, s_x_half[-1:]])
530
+ s_y_half_pad = cp.concatenate([s_y_half[:1], s_y_half, s_y_half[-1:]])
531
+ s_z_half_pad = cp.concatenate([s_z_half[:1], s_z_half, s_z_half[-1:]])
532
+ self._ensure_material_components()
533
+ eps_x_main = cp.asarray(self.material_eps_components["x"])
534
+ eps_y_main = cp.asarray(self.material_eps_components["y"])
535
+ eps_z_main = cp.asarray(self.material_eps_components["z"])
536
+ eps_r_x = (eps_x_main[:-1, :, :] + eps_x_main[1:, :, :]) / 2
537
+ eps_r_y = (eps_y_main[:, :-1, :] + eps_y_main[:, 1:, :]) / 2
538
+ eps_r_z = (eps_z_main[:, :, :-1] + eps_z_main[:, :, 1:]) / 2
539
+
540
+ if self.verbose:
541
+ print("Building 3D Yee grid sparse matrix...")
542
+ # nnz estimate: up to 13 nonzero elements per equation
543
+ # (1 diagonal + 4 same-field neighbors + 4 Ey/Ex coupling + 4 Ez/Ex coupling)
544
+ max_nnz = N_vec * 13
545
+ rows = cp.zeros(max_nnz, dtype=cp.int32)
546
+ cols = cp.zeros(max_nnz, dtype=cp.int32)
547
+ data = cp.zeros(max_nnz, dtype=cp.complex64)
548
+ nnz = 0
549
+
550
+ ds2 = ds**2
551
+ k02 = k0**2
552
+
553
+ def add_entries(row_idx, col_idx, vals):
554
+ nonlocal nnz
555
+ # Use broadcast_arrays to ensure all arrays have consistent shapes
556
+ row_idx, col_idx, vals = cp.broadcast_arrays(row_idx, col_idx, vals)
557
+ count = vals.size
558
+ if nnz + count > max_nnz:
559
+ raise ValueError(f"max_nnz exceeded: need {nnz + count}, have {max_nnz}")
560
+ rows[nnz:nnz+count] = row_idx.ravel()
561
+ cols[nnz:nnz+count] = col_idx.ravel()
562
+ data[nnz:nnz+count] = vals.ravel()
563
+ nnz += count
564
+
565
+ # --- 3. Fill Ex equations --- (using broadcasting instead of meshgrid to save memory)
566
+ ix_1d = cp.arange(Nx_ex, dtype=cp.int32)
567
+ iy_1d = cp.arange(Ny_ex, dtype=cp.int32)
568
+ iz_1d = cp.arange(Nz_ex, dtype=cp.int32)
569
+ # Broadcasting: (Nx_ex,1,1), (1,Ny_ex,1), (1,1,Nz_ex)
570
+ idx_ex = (ix_1d[:, None, None] * Ny_ex + iy_1d[None, :, None]) * Nz_ex + iz_1d[None, None, :]
571
+ sy = s_y_main[iy_1d][None, :, None]
572
+ sz = s_z_main[iz_1d][None, None, :]
573
+ sx_half = s_x_half[ix_1d][:, None, None]
574
+ sy_lo = s_y_half_pad[iy_1d][None, :, None] # s_y at (j - 1/2)
575
+ sy_hi = s_y_half_pad[iy_1d + 1][None, :, None] # s_y at (j + 1/2)
576
+ sz_lo = s_z_half_pad[iz_1d][None, None, :]
577
+ sz_hi = s_z_half_pad[iz_1d + 1][None, None, :]
578
+ diag_val = (k02 * eps_r_x
579
+ - (1 / sy_lo + 1 / sy_hi) / (sy * ds2)
580
+ - (1 / sz_lo + 1 / sz_hi) / (sz * ds2))
581
+ add_entries(idx_ex, idx_ex, diag_val)
582
+
583
+ # Neighbor indices (using slicing to avoid creating masks)
584
+ # iy > 0: idx_ex[:, 1:, :]
585
+ add_entries(idx_ex[:, 1:, :], (ix_1d[:, None, None] * Ny_ex + (iy_1d[None, 1:, None]-1)) * Nz_ex + iz_1d[None, None, :], 1 / (sy[:, 1:, :] * sy_lo[:, 1:, :] * ds2))
586
+ # iy < Ny_ex - 1: idx_ex[:, :-1, :]
587
+ add_entries(idx_ex[:, :-1, :], (ix_1d[:, None, None] * Ny_ex + (iy_1d[None, :-1, None]+1)) * Nz_ex + iz_1d[None, None, :], 1 / (sy[:, :-1, :] * sy_hi[:, :-1, :] * ds2))
588
+ # iz > 0: idx_ex[:, :, 1:]
589
+ add_entries(idx_ex[:, :, 1:], (ix_1d[:, None, None] * Ny_ex + iy_1d[None, :, None]) * Nz_ex + (iz_1d[None, None, 1:]-1), 1 / (sz[:, :, 1:] * sz_lo[:, :, 1:] * ds2))
590
+ # iz < Nz_ex - 1: idx_ex[:, :, :-1]
591
+ add_entries(idx_ex[:, :, :-1], (ix_1d[:, None, None] * Ny_ex + iy_1d[None, :, None]) * Nz_ex + (iz_1d[None, None, :-1]+1), 1 / (sz[:, :, :-1] * sz_hi[:, :, :-1] * ds2))
592
+
593
+ # Coupling to Ey terms (d^2/dxdy)
594
+ val = 1 / (sx_half * sy * ds2)
595
+ # iy > 0
596
+ add_entries(idx_ex[:, 1:, :], N_ex + (ix_1d[:, None, None] * Ny_ey + (iy_1d[None, 1:, None]-1)) * Nz_ey + iz_1d[None, None, :], -val[:, 1:, :])
597
+ add_entries(idx_ex[:, 1:, :], N_ex + ((ix_1d[:, None, None]+1) * Ny_ey + (iy_1d[None, 1:, None]-1)) * Nz_ey + iz_1d[None, None, :], val[:, 1:, :])
598
+ # iy < Ny_ey
599
+ add_entries(idx_ex[:, :Ny_ey, :], N_ex + (ix_1d[:, None, None] * Ny_ey + iy_1d[None, :Ny_ey, None]) * Nz_ey + iz_1d[None, None, :], val[:, :Ny_ey, :])
600
+ add_entries(idx_ex[:, :Ny_ey, :], N_ex + ((ix_1d[:, None, None]+1) * Ny_ey + iy_1d[None, :Ny_ey, None]) * Nz_ey + iz_1d[None, None, :], -val[:, :Ny_ey, :])
601
+
602
+ # Coupling to Ez terms (d^2/dxdz)
603
+ val = 1 / (sx_half * sz * ds2)
604
+ # iz > 0
605
+ add_entries(idx_ex[:, :, 1:], N_ex + N_ey + (ix_1d[:, None, None] * Ny_ez + iy_1d[None, :, None]) * Nz_ez + (iz_1d[None, None, 1:]-1), -val[:, :, 1:])
606
+ add_entries(idx_ex[:, :, 1:], N_ex + N_ey + ((ix_1d[:, None, None]+1) * Ny_ez + iy_1d[None, :, None]) * Nz_ez + (iz_1d[None, None, 1:]-1), val[:, :, 1:])
607
+ # iz < Nz_ez
608
+ add_entries(idx_ex[:, :, :Nz_ez], N_ex + N_ey + (ix_1d[:, None, None] * Ny_ez + iy_1d[None, :, None]) * Nz_ez + iz_1d[None, None, :Nz_ez], val[:, :, :Nz_ez])
609
+ add_entries(idx_ex[:, :, :Nz_ez], N_ex + N_ey + ((ix_1d[:, None, None]+1) * Ny_ez + iy_1d[None, :, None]) * Nz_ez + iz_1d[None, None, :Nz_ez], -val[:, :, :Nz_ez])
610
+ del ix_1d, iy_1d, iz_1d, idx_ex, sy, sz, sx_half, sy_lo, sy_hi, sz_lo, sz_hi, val # Free memory
611
+
612
+ # --- 4. Fill Ey equations --- (using broadcasting instead of meshgrid to save memory)
613
+ ix_1d = cp.arange(Nx_ey, dtype=cp.int32)
614
+ iy_1d = cp.arange(Ny_ey, dtype=cp.int32)
615
+ iz_1d = cp.arange(Nz_ey, dtype=cp.int32)
616
+ idx_ey = N_ex + (ix_1d[:, None, None] * Ny_ey + iy_1d[None, :, None]) * Nz_ey + iz_1d[None, None, :]
617
+ sx = s_x_main[ix_1d][:, None, None]
618
+ sz = s_z_main[iz_1d][None, None, :]
619
+ sy_half = s_y_half[iy_1d][None, :, None]
620
+ sx_lo = s_x_half_pad[ix_1d][:, None, None] # s_x at (i - 1/2)
621
+ sx_hi = s_x_half_pad[ix_1d + 1][:, None, None] # s_x at (i + 1/2)
622
+ sz_lo = s_z_half_pad[iz_1d][None, None, :]
623
+ sz_hi = s_z_half_pad[iz_1d + 1][None, None, :]
624
+ diag_val = (k02 * eps_r_y
625
+ - (1 / sx_lo + 1 / sx_hi) / (sx * ds2)
626
+ - (1 / sz_lo + 1 / sz_hi) / (sz * ds2))
627
+ add_entries(idx_ey, idx_ey, diag_val)
628
+
629
+ # Neighbor indices
630
+ add_entries(idx_ey[1:, :, :], N_ex + ((ix_1d[1:, None, None]-1) * Ny_ey + iy_1d[None, :, None]) * Nz_ey + iz_1d[None, None, :], 1 / (sx[1:, :, :] * sx_lo[1:, :, :] * ds2))
631
+ add_entries(idx_ey[:-1, :, :], N_ex + ((ix_1d[:-1, None, None]+1) * Ny_ey + iy_1d[None, :, None]) * Nz_ey + iz_1d[None, None, :], 1 / (sx[:-1, :, :] * sx_hi[:-1, :, :] * ds2))
632
+ add_entries(idx_ey[:, :, 1:], N_ex + (ix_1d[:, None, None] * Ny_ey + iy_1d[None, :, None]) * Nz_ey + (iz_1d[None, None, 1:]-1), 1 / (sz[:, :, 1:] * sz_lo[:, :, 1:] * ds2))
633
+ add_entries(idx_ey[:, :, :-1], N_ex + (ix_1d[:, None, None] * Ny_ey + iy_1d[None, :, None]) * Nz_ey + (iz_1d[None, None, :-1]+1), 1 / (sz[:, :, :-1] * sz_hi[:, :, :-1] * ds2))
634
+
635
+ # Coupling to Ex (d^2/dydx)
636
+ val = 1 / (sy_half * sx * ds2)
637
+ # ix > 0
638
+ add_entries(idx_ey[1:, :, :], ((ix_1d[1:, None, None]-1) * Ny_ex + iy_1d[None, :, None]) * Nz_ex + iz_1d[None, None, :], -val[1:, :, :])
639
+ add_entries(idx_ey[1:, :, :], ((ix_1d[1:, None, None]-1) * Ny_ex + (iy_1d[None, :, None]+1)) * Nz_ex + iz_1d[None, None, :], val[1:, :, :])
640
+ # ix < Nx_ex
641
+ add_entries(idx_ey[:Nx_ex, :, :], (ix_1d[:Nx_ex, None, None] * Ny_ex + iy_1d[None, :, None]) * Nz_ex + iz_1d[None, None, :], val[:Nx_ex, :, :])
642
+ add_entries(idx_ey[:Nx_ex, :, :], (ix_1d[:Nx_ex, None, None] * Ny_ex + (iy_1d[None, :, None]+1)) * Nz_ex + iz_1d[None, None, :], -val[:Nx_ex, :, :])
643
+
644
+ # Coupling to Ez (d^2/dydz)
645
+ val = 1 / (sy_half * sz * ds2)
646
+ # iz > 0
647
+ add_entries(idx_ey[:, :, 1:], N_ex + N_ey + (ix_1d[:, None, None] * Ny_ez + iy_1d[None, :, None]) * Nz_ez + (iz_1d[None, None, 1:]-1), -val[:, :, 1:])
648
+ add_entries(idx_ey[:, :, 1:], N_ex + N_ey + (ix_1d[:, None, None] * Ny_ez + (iy_1d[None, :, None]+1)) * Nz_ez + (iz_1d[None, None, 1:]-1), val[:, :, 1:])
649
+ # iz < Nz_ez
650
+ add_entries(idx_ey[:, :, :Nz_ez], N_ex + N_ey + (ix_1d[:, None, None] * Ny_ez + iy_1d[None, :, None]) * Nz_ez + iz_1d[None, None, :Nz_ez], val[:, :, :Nz_ez])
651
+ add_entries(idx_ey[:, :, :Nz_ez], N_ex + N_ey + (ix_1d[:, None, None] * Ny_ez + (iy_1d[None, :, None]+1)) * Nz_ez + iz_1d[None, None, :Nz_ez], -val[:, :, :Nz_ez])
652
+ del ix_1d, iy_1d, iz_1d, idx_ey, sx, sz, sy_half, sx_lo, sx_hi, sz_lo, sz_hi, val
653
+
654
+ # --- 5. Fill Ez equations --- (using broadcasting instead of meshgrid to save memory)
655
+ ix_1d = cp.arange(Nx_ez, dtype=cp.int32)
656
+ iy_1d = cp.arange(Ny_ez, dtype=cp.int32)
657
+ iz_1d = cp.arange(Nz_ez, dtype=cp.int32)
658
+ idx_ez = N_ex + N_ey + (ix_1d[:, None, None] * Ny_ez + iy_1d[None, :, None]) * Nz_ez + iz_1d[None, None, :]
659
+ sx = s_x_main[ix_1d][:, None, None]
660
+ sy = s_y_main[iy_1d][None, :, None]
661
+ sz_half = s_z_half[iz_1d][None, None, :]
662
+ sx_lo = s_x_half_pad[ix_1d][:, None, None]
663
+ sx_hi = s_x_half_pad[ix_1d + 1][:, None, None]
664
+ sy_lo = s_y_half_pad[iy_1d][None, :, None]
665
+ sy_hi = s_y_half_pad[iy_1d + 1][None, :, None]
666
+ diag_val = (k02 * eps_r_z
667
+ - (1 / sx_lo + 1 / sx_hi) / (sx * ds2)
668
+ - (1 / sy_lo + 1 / sy_hi) / (sy * ds2))
669
+ add_entries(idx_ez, idx_ez, diag_val)
670
+
671
+ # Neighbor indices
672
+ add_entries(idx_ez[1:, :, :], N_ex + N_ey + ((ix_1d[1:, None, None]-1) * Ny_ez + iy_1d[None, :, None]) * Nz_ez + iz_1d[None, None, :], 1 / (sx[1:, :, :] * sx_lo[1:, :, :] * ds2))
673
+ add_entries(idx_ez[:-1, :, :], N_ex + N_ey + ((ix_1d[:-1, None, None]+1) * Ny_ez + iy_1d[None, :, None]) * Nz_ez + iz_1d[None, None, :], 1 / (sx[:-1, :, :] * sx_hi[:-1, :, :] * ds2))
674
+ add_entries(idx_ez[:, 1:, :], N_ex + N_ey + (ix_1d[:, None, None] * Ny_ez + (iy_1d[None, 1:, None]-1)) * Nz_ez + iz_1d[None, None, :], 1 / (sy[:, 1:, :] * sy_lo[:, 1:, :] * ds2))
675
+ add_entries(idx_ez[:, :-1, :], N_ex + N_ey + (ix_1d[:, None, None] * Ny_ez + (iy_1d[None, :-1, None]+1)) * Nz_ez + iz_1d[None, None, :], 1 / (sy[:, :-1, :] * sy_hi[:, :-1, :] * ds2))
676
+
677
+ # Coupling to Ex (d^2/dzdx)
678
+ val = 1 / (sz_half * sx * ds2)
679
+ # ix > 0
680
+ add_entries(idx_ez[1:, :, :], ((ix_1d[1:, None, None]-1) * Ny_ex + iy_1d[None, :, None]) * Nz_ex + iz_1d[None, None, :], -val[1:, :, :])
681
+ add_entries(idx_ez[1:, :, :], ((ix_1d[1:, None, None]-1) * Ny_ex + iy_1d[None, :, None]) * Nz_ex + (iz_1d[None, None, :]+1), val[1:, :, :])
682
+ # ix < Nx_ex
683
+ add_entries(idx_ez[:Nx_ex, :, :], (ix_1d[:Nx_ex, None, None] * Ny_ex + iy_1d[None, :, None]) * Nz_ex + iz_1d[None, None, :], val[:Nx_ex, :, :])
684
+ add_entries(idx_ez[:Nx_ex, :, :], (ix_1d[:Nx_ex, None, None] * Ny_ex + iy_1d[None, :, None]) * Nz_ex + (iz_1d[None, None, :]+1), -val[:Nx_ex, :, :])
685
+
686
+ # Coupling to Ey (d^2/dzdy)
687
+ val = 1 / (sz_half * sy * ds2)
688
+ # iy > 0
689
+ add_entries(idx_ez[:, 1:, :], N_ex + (ix_1d[:, None, None] * Ny_ey + (iy_1d[None, 1:, None]-1)) * Nz_ey + iz_1d[None, None, :], -val[:, 1:, :])
690
+ add_entries(idx_ez[:, 1:, :], N_ex + (ix_1d[:, None, None] * Ny_ey + (iy_1d[None, 1:, None]-1)) * Nz_ey + (iz_1d[None, None, :]+1), val[:, 1:, :])
691
+ # iy < Ny_ey
692
+ add_entries(idx_ez[:, :Ny_ey, :], N_ex + (ix_1d[:, None, None] * Ny_ey + iy_1d[None, :Ny_ey, None]) * Nz_ey + iz_1d[None, None, :], val[:, :Ny_ey, :])
693
+ add_entries(idx_ez[:, :Ny_ey, :], N_ex + (ix_1d[:, None, None] * Ny_ey + iy_1d[None, :Ny_ey, None]) * Nz_ey + (iz_1d[None, None, :]+1), -val[:, :Ny_ey, :])
694
+ del ix_1d, iy_1d, iz_1d, idx_ez, sx, sy, sz_half, sx_lo, sx_hi, sy_lo, sy_hi, val
695
+
696
+ if self.verbose:
697
+ print(f"Sparse matrix nonzero count: {nnz:,}")
698
+ print(f"Sparsity: {nnz / (N_vec * N_vec) * 100:.6f}%")
699
+
700
+ # Apply the symmetrizing diagonal similarity: entry (m, n) -> d_m a_mn / d_n
701
+ scale = self._ensure_symmetrization_scale()
702
+ data[:nnz] *= scale[rows[:nnz]]
703
+ data[:nnz] /= scale[cols[:nnz]]
704
+
705
+ A = cpsp.coo_matrix((data[:nnz], (rows[:nnz], cols[:nnz])), shape=(N_vec, N_vec))
706
+ return A.tocsr()
707
+
708
+ def _ensure_symmetrization_scale(self):
709
+ """Diagonal similarity weight d = sqrt(sx*sy*sz) at each unknown's Yee
710
+ location. With the symmetric UPML stencil, D A D^-1 is exactly
711
+ complex-symmetric; the solver works on the scaled system throughout
712
+ (b -> d*b at source build, x -> x/d when fields are extracted). The
713
+ weight depends only on grid + PML, not frequency or materials."""
714
+ if self._sym_scale is not None:
715
+ return self._sym_scale
716
+ s_x, s_y, s_z = self._create_pml_3d()
717
+ s_x, s_y, s_z = cp.asarray(s_x), cp.asarray(s_y), cp.asarray(s_z)
718
+ s_x_half = (s_x[:-1] + s_x[1:]) / 2
719
+ s_y_half = (s_y[:-1] + s_y[1:]) / 2
720
+ s_z_half = (s_z[:-1] + s_z[1:]) / 2
721
+ d_ex = cp.sqrt(s_x_half[:, None, None] * s_y[None, :, None] * s_z[None, None, :])
722
+ d_ey = cp.sqrt(s_x[:, None, None] * s_y_half[None, :, None] * s_z[None, None, :])
723
+ d_ez = cp.sqrt(s_x[:, None, None] * s_y[None, :, None] * s_z_half[None, None, :])
724
+ self._sym_scale = cp.concatenate(
725
+ [d_ex.ravel(), d_ey.ravel(), d_ez.ravel()]
726
+ ).astype(cp.complex64)
727
+ return self._sym_scale
728
+
729
+ def _build_source_vector_yee(self):
730
+ """Build the total b vector for the Yee grid from the scene source list. Uses broadcasting instead of meshgrid to save memory."""
731
+ b = cp.zeros(self.scene.N_vector_total, dtype=cp.complex64)
732
+ compiled_sources = compile_fdfd_sources(self.scene, default_frequency=self.frequency)
733
+ if not compiled_sources:
734
+ return b
735
+
736
+ dx, dy, dz = self.scene.grid_spacing
737
+ const = -1j * self.omega * self.mu0
738
+
739
+ # Prepare coordinate data on GPU (1D arrays, using broadcasting)
740
+ x_ex = cp.asarray(self.scene.x[:-1] + dx / 2.0, dtype=cp.float32)
741
+ y_ex = cp.asarray(self.scene.y, dtype=cp.float32)
742
+ z_ex = cp.asarray(self.scene.z, dtype=cp.float32)
743
+
744
+ x_ey = cp.asarray(self.scene.x, dtype=cp.float32)
745
+ y_ey = cp.asarray(self.scene.y[:-1] + dy / 2.0, dtype=cp.float32)
746
+ z_ey = cp.asarray(self.scene.z, dtype=cp.float32)
747
+
748
+ x_ez = cp.asarray(self.scene.x, dtype=cp.float32)
749
+ y_ez = cp.asarray(self.scene.y, dtype=cp.float32)
750
+ z_ez = cp.asarray(self.scene.z[:-1] + dz / 2.0, dtype=cp.float32)
751
+
752
+ # 3D views into b, one per field component (reshape of contiguous slices)
753
+ N_ex, N_ey = self.scene.N_ex, self.scene.N_ey
754
+ b_ex = b[:N_ex].reshape(self.scene.Nx_ex, self.scene.Ny_ex, self.scene.Nz_ex)
755
+ b_ey = b[N_ex:N_ex + N_ey].reshape(self.scene.Nx_ey, self.scene.Ny_ey, self.scene.Nz_ey)
756
+ b_ez = b[N_ex + N_ey:].reshape(self.scene.Nx_ez, self.scene.Ny_ez, self.scene.Nz_ez)
757
+
758
+ for source in compiled_sources:
759
+ pos = source['position']
760
+ width = float(source['width'])
761
+ profile_kind = source.get('profile', 'gaussian')
762
+ pol = source['polarization']
763
+
764
+ if pol[0] != 0:
765
+ _add_point_dipole_component_gpu(b_ex, x_ex, y_ex, z_ex, pos,
766
+ const * pol[0], width, profile_kind)
767
+ if pol[1] != 0:
768
+ _add_point_dipole_component_gpu(b_ey, x_ey, y_ey, z_ey, pos,
769
+ const * pol[1], width, profile_kind)
770
+ if pol[2] != 0:
771
+ _add_point_dipole_component_gpu(b_ez, x_ez, y_ez, z_ez, pos,
772
+ const * pol[2], width, profile_kind)
773
+
774
+ # The assembled system is the symmetrized D A D^-1; scale the RHS to match
775
+ b *= self._ensure_symmetrization_scale()
776
+ return b
777
+
778
+ def solve(self, max_iter=1000, tol=1e-6, restart=100):
779
+ start_time = time.time()
780
+
781
+ self._ensure_system_matrix()
782
+ b = self._build_source_vector_yee()
783
+ matrix_time = time.time() - start_time
784
+ if self.verbose:
785
+ print(f"GPU matrix and source build time: {matrix_time:.2f}s")
786
+ print(f"Solving linear system with GPU {self.solver_type.upper()} solver...")
787
+ print_gpu_memory("Before solve")
788
+ solve_start = time.time()
789
+
790
+ try:
791
+ if self.solver_type != 'direct':
792
+ M = self._ensure_preconditioner()
793
+ A_iter = self._iteration_matrix()
794
+ b_iter = b.astype(self._iteration_dtype(), copy=False)
795
+ if self.solver_type == 'cg':
796
+ x_gpu, info = cupy_cg(A_iter, b_iter, M=M, maxiter=max_iter, tol=tol)
797
+ elif self.solver_type in ('bicgstab', 'tfqmr', 'idr', 'sqmr'):
798
+ from . import krylov
799
+ x_gpu, info = krylov.solve(self.solver_type, A_iter, b_iter, M=M,
800
+ tol=tol, maxiter=max_iter)
801
+ self.final_residual = float(cp.linalg.norm(A_iter @ x_gpu - b_iter) / cp.linalg.norm(b_iter))
802
+ self.solver_residuals = [self.final_residual]
803
+ elif self.solver_type == 'gmres':
804
+ residuals = []
805
+ # GMRES(m) params: restart is the number of Arnoldi iterations before each restart
806
+ # callback is called at each restart, so total restarts = max_iter // restart
807
+ num_restarts = max(1, max_iter // restart)
808
+
809
+ # In multi-GPU mode (position!=None), use leave=False to avoid inter-process interference
810
+ pbar = tqdm(total=num_restarts, desc="GMRES", unit="restart",
811
+ bar_format='{l_bar}{bar}| {n_fmt}/{total_fmt} [{elapsed}<{remaining}, {postfix}]',
812
+ position=self.tqdm_position,
813
+ leave=(self.tqdm_position is None))
814
+
815
+ def callback(rk):
816
+ res_val = float(cp.asnumpy(rk))
817
+ residuals.append(res_val)
818
+ pbar.update(1)
819
+ pbar.set_postfix({'residual': f'{res_val:.2e}'})
820
+
821
+ x_gpu, info = cupy_gmres(A_iter, b_iter, M=M, maxiter=max_iter, tol=tol, restart=restart, callback=callback)
822
+ pbar.close()
823
+
824
+ # Save residual info
825
+ self.solver_residuals = residuals
826
+ self.final_residual = residuals[-1] if residuals else None
827
+
828
+ if self.enable_plot:
829
+ plt.figure(figsize=(6, 4))
830
+ plt.semilogy(residuals, marker='o')
831
+ plt.grid(True, which='both', linestyle='--', linewidth=0.5)
832
+ plt.xlabel("Iteration")
833
+ plt.ylabel("Residual ||r||")
834
+ plt.title("GMRES Convergence")
835
+ plt.tight_layout()
836
+ plt.show()
837
+ x_gpu = x_gpu # Keep consistent
838
+ elif self.solver_type == 'direct':
839
+ # cuDSS sparse direct solve; factorization cached across RHS
840
+ if self.verbose:
841
+ reuse = "cached factorization" if self._direct_solver is not None else "factorize + solve"
842
+ print(f"Direct solve ({reuse}, matrix size: {self.A_matrix.shape[0]:,})...")
843
+ x_gpu = self._solve_direct(b)
844
+ info = 0
845
+ residual = cp.linalg.norm(self.A_matrix @ x_gpu - b) / cp.linalg.norm(b)
846
+ self.final_residual = float(cp.asnumpy(residual))
847
+ self.solver_residuals = [self.final_residual]
848
+ if self.verbose:
849
+ print(f"Direct solve complete, relative residual: {self.final_residual:.2e}")
850
+ solve_time = time.time() - solve_start
851
+ if self.verbose:
852
+ print(f"GPU solve complete, elapsed: {solve_time:.2f}s")
853
+ print(f"Solver convergence info: {info}")
854
+ except Exception as e:
855
+ print(f"GPU solve failed: {e}. Note that memory may be the limiting factor for large problems.")
856
+ return
857
+
858
+ total_time = time.time() - start_time
859
+ if self.verbose:
860
+ print(f"Total solve time: {total_time:.2f}s")
861
+
862
+ # Back to storage precision, then undo the symmetrization scaling
863
+ x_gpu = x_gpu.astype(cp.complex64, copy=False)
864
+ x_gpu = x_gpu / self._ensure_symmetrization_scale()
865
+
866
+ # Reshape results into 3D field components on the Yee grid
867
+ N_ex, N_ey = self.scene.N_ex, self.scene.N_ey
868
+ Nx_ex, Ny_ex, Nz_ex = self.scene.Nx_ex, self.scene.Ny_ex, self.scene.Nz_ex
869
+ Nx_ey, Ny_ey, Nz_ey = self.scene.Nx_ey, self.scene.Ny_ey, self.scene.Nz_ey
870
+ Nx_ez, Ny_ez, Nz_ez = self.scene.Nx_ez, self.scene.Ny_ez, self.scene.Nz_ez
871
+
872
+ # Zero-copy CuPy -> torch via DLPack; slice/reshape stay on device
873
+ x = torch.from_dlpack(x_gpu).to(dtype=torch.complex64)
874
+ Ex = x[:N_ex].reshape((Nx_ex, Ny_ex, Nz_ex))
875
+ Ey = x[N_ex:N_ex+N_ey].reshape((Nx_ey, Ny_ey, Nz_ey))
876
+ Ez = x[N_ex+N_ey:].reshape((Nx_ez, Ny_ez, Nz_ez))
877
+ self.E_field = (Ex, Ey, Ez)
878
+
879
+ # E_field_raw is generated on demand from E_field to avoid duplicate storage
880
+ # Use get_field_raw() for scenarios requiring numpy arrays
881
+ self.E_field_raw = None # Lazy generation
882
+
883
+ # Save convergence info
884
+ self.solver_info = info
885
+ self.converged = (info == 0)
886
+
887
+ if self.verbose:
888
+ if info == 0:
889
+ print("Solver converged successfully")
890
+ else:
891
+ print(f"Solver did not fully converge, exit code: {info}")
892
+
893
+ def get_field_raw(self):
894
+ return get_field_raw_impl(self)
895
+
896
+ def _interpolate_yee_to_center(self):
897
+ return interpolate_yee_to_center_impl(self)
898
+
899
+ def plot_cross_section(self, axis='z', position=0.0, component='abs', field_log_scale=False, figsize=(12, 5), save_path=None, verbose=None):
900
+ plot_cross_section_impl(
901
+ self,
902
+ axis=axis,
903
+ position=position,
904
+ component=component,
905
+ field_log_scale=field_log_scale,
906
+ figsize=figsize,
907
+ save_path=save_path,
908
+ verbose=verbose,
909
+ )
910
+
911
+ def plot_isotropic_3views(self, position=0.0, field_log_scale=True, figsize=(18, 5), save_path=None, vmin_db=-60, verbose=None):
912
+ plot_isotropic_3views_impl(
913
+ self,
914
+ position=position,
915
+ field_log_scale=field_log_scale,
916
+ figsize=figsize,
917
+ save_path=save_path,
918
+ vmin_db=vmin_db,
919
+ verbose=verbose,
920
+ )
921
+
922
+ def solve_isotropic(self, source_pos, source_width, source_amplitude, max_iter=1500, tol=1e-6, restart=50, verbose=None):
923
+ """
924
+ Solve for an isotropic source field by running three orthogonal polarization
925
+ simulations and performing power superposition.
926
+ This function is optimized for the Yee staggered grid.
927
+
928
+ Args:
929
+ source_pos (list/tuple): Source center coordinates [x, y, z].
930
+ source_width (float): Gaussian source width.
931
+ source_amplitude (float): Source amplitude.
932
+ max_iter (int): Maximum iterations for the iterative solver.
933
+ tol (float): Solver convergence tolerance.
934
+ restart (int): GMRES solver restart parameter.
935
+ verbose: Whether to print detailed information.
936
+ """
937
+ if verbose is None:
938
+ verbose = self.verbose
939
+
940
+ self.scene.release_meshgrid()
941
+
942
+ if verbose:
943
+ print("\n==============================================")
944
+ print("=== Starting Isotropic Source Simulation ===")
945
+ print("==============================================")
946
+
947
+ # Step 1/4: Pre-build system matrix A (if not already built)
948
+ if self.A_matrix is None:
949
+ if verbose:
950
+ print("Step 1/4: Pre-building system matrix A...")
951
+ t0 = time.time()
952
+ self._ensure_system_matrix()
953
+ if verbose:
954
+ print(f"Matrix build complete, elapsed: {time.time() - t0:.2f}s")
955
+ else:
956
+ if verbose:
957
+ print("Step 1/4: Using cached system matrix A.")
958
+
959
+ if verbose:
960
+ print_gpu_memory("After matrix build")
961
+
962
+ # Initialize an accumulator on GPU for total field intensity squared
963
+ # We interpolate to cell centers, so grid dimensions are reduced by 1
964
+ # Using float32 to save memory; precision is sufficient
965
+ total_intensity_gpu = cp.zeros(
966
+ (self.scene.Nx - 1, self.scene.Ny - 1, self.scene.Nz - 1),
967
+ dtype=cp.float32
968
+ )
969
+
970
+ # Define three orthogonal polarizations
971
+ polarizations = {
972
+ 'X': (1.0, 0.0, 0.0),
973
+ 'Y': (0.0, 1.0, 0.0),
974
+ 'Z': (0.0, 0.0, 1.0)
975
+ }
976
+
977
+ # Steps 2 & 3: Loop and solve for each polarization direction
978
+ for i, (pol_name, pol_vector) in enumerate(polarizations.items()):
979
+ if verbose:
980
+ print(f"\nStep {i+2}/4: Solving {pol_name}-polarization...")
981
+
982
+ # a. Clear old sources and set new source for the current scene
983
+ self.scene.sources = [
984
+ PointDipole(
985
+ position=tuple(source_pos),
986
+ polarization=pol_vector,
987
+ width=source_width,
988
+ source_time=CW(frequency=self.frequency, amplitude=source_amplitude),
989
+ name=f"iso_{pol_name.lower()}",
990
+ )
991
+ ]
992
+
993
+ # b. Rebuild only the b vector (Yee grid version)
994
+ b_gpu = self._build_source_vector_yee()
995
+
996
+ # c. Solve the linear system Ax = b
997
+ solve_start = time.time()
998
+ # GMRES callback is called at each restart, so progress bar total should be the number of restarts
999
+ num_restarts = max_iter // restart
1000
+ pbar = tqdm(total=num_restarts, desc=f"GMRES-{pol_name}", unit="restart",
1001
+ bar_format='{l_bar}{bar}| {n_fmt}/{total_fmt} [{elapsed}<{remaining}, {postfix}]',
1002
+ position=self.tqdm_position,
1003
+ leave=(self.tqdm_position is None))
1004
+
1005
+ def iso_callback(rk):
1006
+ res_val = float(cp.asnumpy(rk))
1007
+ pbar.update(1)
1008
+ pbar.set_postfix({'residual': f'{res_val:.2e}'})
1009
+
1010
+ x_gpu, info = cupy_gmres(self._iteration_matrix(),
1011
+ b_gpu.astype(self._iteration_dtype(), copy=False),
1012
+ M=self._ensure_preconditioner(),
1013
+ maxiter=max_iter, tol=tol, restart=restart, callback=iso_callback)
1014
+ x_gpu = x_gpu.astype(cp.complex64, copy=False) / self._ensure_symmetrization_scale()
1015
+ pbar.close()
1016
+ if verbose:
1017
+ print(f"Solve complete, elapsed: {time.time() - solve_start:.2f}s. Convergence info: {info}")
1018
+ if info != 0:
1019
+ print(f" Warning: {pol_name}-polarization solver may not have fully converged.")
1020
+
1021
+ # d. Reshape solution vector into field components on the Yee grid
1022
+ N_ex, N_ey = self.scene.N_ex, self.scene.N_ey
1023
+ Nx_ex, Ny_ex, Nz_ex = self.scene.Nx_ex, self.scene.Ny_ex, self.scene.Nz_ex
1024
+ Nx_ey, Ny_ey, Nz_ey = self.scene.Nx_ey, self.scene.Ny_ey, self.scene.Nz_ey
1025
+ Nx_ez, Ny_ez, Nz_ez = self.scene.Nx_ez, self.scene.Ny_ez, self.scene.Nz_ez
1026
+
1027
+ Ex_gpu = x_gpu[:N_ex].reshape((Nx_ex, Ny_ex, Nz_ex))
1028
+ Ey_gpu = x_gpu[N_ex:N_ex+N_ey].reshape((Nx_ey, Ny_ey, Nz_ey))
1029
+ Ez_gpu = x_gpu[N_ex+N_ey:].reshape((Nx_ez, Ny_ez, Nz_ez))
1030
+
1031
+ # e. [CORRECTED] Interpolate staggered field components to cell centers and accumulate power
1032
+ # Ex @ (i+0.5, j, k) -> avg in Y and Z
1033
+ Ex_int = 0.5 * (Ex_gpu[:, :-1, :] + Ex_gpu[:, 1:, :])
1034
+ Ex_int = 0.5 * (Ex_int[:, :, :-1] + Ex_int[:, :, 1:])
1035
+
1036
+ # Ey @ (i, j+0.5, k) -> avg in X and Z
1037
+ Ey_int = 0.5 * (Ey_gpu[:-1, :, :] + Ey_gpu[1:, :, :])
1038
+ Ey_int = 0.5 * (Ey_int[:, :, :-1] + Ey_int[:, :, 1:])
1039
+
1040
+ # Ez @ (i, j, k+0.5) -> avg in X and Y
1041
+ Ez_temp = 0.5 * (Ez_gpu[:-1, :, :] + Ez_gpu[1:, :, :]) # First, average along X
1042
+ Ez_int = 0.5 * (Ez_temp[:, :-1, :] + Ez_temp[:, 1:, :]) # Then, average the result along Y
1043
+
1044
+ # Now all _int arrays have matching shapes and can be safely summed
1045
+ current_intensity_gpu = cp.abs(Ex_int)**2 + cp.abs(Ey_int)**2 + cp.abs(Ez_int)**2
1046
+ total_intensity_gpu += current_intensity_gpu
1047
+
1048
+ # Step 4/4: Merge final results
1049
+ if verbose:
1050
+ print("\nStep 4/4: Merging results from all polarizations...")
1051
+ final_E_magnitude_gpu = cp.sqrt(total_intensity_gpu)
1052
+
1053
+ final_E_magnitude = torch.from_dlpack(final_E_magnitude_gpu)
1054
+
1055
+ self.E_field = (final_E_magnitude, final_E_magnitude, final_E_magnitude)
1056
+ if verbose:
1057
+ print("=== Isotropic source simulation complete! ===")