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