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,1675 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import torch
|
|
7
|
+
from scipy import linalg as scipy_linalg
|
|
8
|
+
from scipy import sparse
|
|
9
|
+
from scipy.sparse import linalg as scipy_sparse_linalg
|
|
10
|
+
from witwin.core.material import VACUUM_PERMITTIVITY
|
|
11
|
+
|
|
12
|
+
from ...compiler.materials import evaluate_material_components
|
|
13
|
+
from .spatial import physical_interior_indices
|
|
14
|
+
from .tfsf_common import nearest_index
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_AXIS_TO_INDEX = {"x": 0, "y": 1, "z": 2}
|
|
18
|
+
_FIELD_COMPONENTS = ("Ex", "Ey", "Ez")
|
|
19
|
+
_ETA0 = 376.730313668
|
|
20
|
+
_DENSE_EIGEN_LIMIT = 4096
|
|
21
|
+
_FULL_VECTOR_DENSE_LIMIT = 384
|
|
22
|
+
_LOBPCG_REQUEST_PADDING = 4
|
|
23
|
+
_LOBPCG_MAX_ITER = 200
|
|
24
|
+
_LOBPCG_TOL = 1.0e-8
|
|
25
|
+
_IMPLICIT_EIGEN_CG_MAX_ITER = 256
|
|
26
|
+
_IMPLICIT_EIGEN_CG_TOL = 1.0e-8
|
|
27
|
+
_VECTOR_EIGEN_REQUEST_PADDING = 4
|
|
28
|
+
_VECTOR_EIGS_MAX_ITER = 600
|
|
29
|
+
_VECTOR_EIGS_TOL = 1.0e-8
|
|
30
|
+
_RIGHT_HANDED_TANGENTIAL_AXES = {
|
|
31
|
+
"x": ("y", "z"),
|
|
32
|
+
"y": ("z", "x"),
|
|
33
|
+
"z": ("x", "y"),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _cross(a, b):
|
|
38
|
+
return (
|
|
39
|
+
float(a[1]) * float(b[2]) - float(a[2]) * float(b[1]),
|
|
40
|
+
float(a[2]) * float(b[0]) - float(a[0]) * float(b[2]),
|
|
41
|
+
float(a[0]) * float(b[1]) - float(a[1]) * float(b[0]),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _mode_source_axes(source) -> tuple[str, tuple[str, str]]:
|
|
46
|
+
normal_axis = str(source["normal_axis"])
|
|
47
|
+
tangential_axes = _RIGHT_HANDED_TANGENTIAL_AXES[normal_axis]
|
|
48
|
+
return normal_axis, tangential_axes
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _mode_source_field_name(source) -> str:
|
|
52
|
+
return f"E{str(source['polarization_axis']).lower()}"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _validate_mode_source_position(scene, source, *, plane_index: int) -> None:
|
|
56
|
+
normal_axis = str(source["normal_axis"])
|
|
57
|
+
direction_sign = int(source["direction_sign"])
|
|
58
|
+
physical_lo, physical_hi = physical_interior_indices(scene, normal_axis)
|
|
59
|
+
if direction_sign > 0:
|
|
60
|
+
if plane_index <= physical_lo or plane_index >= physical_hi:
|
|
61
|
+
raise ValueError(
|
|
62
|
+
"ModeSource position must lie at least one cell inside the non-PML region "
|
|
63
|
+
"along the propagation axis."
|
|
64
|
+
)
|
|
65
|
+
return
|
|
66
|
+
if plane_index <= physical_lo or plane_index >= physical_hi:
|
|
67
|
+
raise ValueError(
|
|
68
|
+
"ModeSource position must lie at least one cell inside the non-PML region "
|
|
69
|
+
"along the propagation axis."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _resolve_tangential_bounds(scene, source, axis_coords_by_axis=None) -> tuple[tuple[int, int], tuple[str, str]]:
|
|
74
|
+
_, tangential_axes = _mode_source_axes(source)
|
|
75
|
+
bounds = []
|
|
76
|
+
for axis in tangential_axes:
|
|
77
|
+
axis_index = _AXIS_TO_INDEX[axis]
|
|
78
|
+
axis_coords = getattr(scene, axis) if axis_coords_by_axis is None else axis_coords_by_axis[axis]
|
|
79
|
+
half_span = 0.5 * float(source["size"][axis_index])
|
|
80
|
+
lower_coord = float(source["position"][axis_index]) - half_span
|
|
81
|
+
upper_coord = float(source["position"][axis_index]) + half_span
|
|
82
|
+
lower_index = nearest_index(axis_coords, lower_coord)
|
|
83
|
+
upper_index = nearest_index(axis_coords, upper_coord)
|
|
84
|
+
if upper_index < lower_index:
|
|
85
|
+
lower_index, upper_index = upper_index, lower_index
|
|
86
|
+
physical_lo, physical_hi = physical_interior_indices(scene, axis)
|
|
87
|
+
lower_limit = float(getattr(scene, axis)[physical_lo].item())
|
|
88
|
+
upper_limit = float(getattr(scene, axis)[physical_hi].item())
|
|
89
|
+
if lower_coord < lower_limit - 1e-12 or upper_coord > upper_limit + 1e-12:
|
|
90
|
+
raise ValueError("ModeSource aperture must remain inside the non-PML region.")
|
|
91
|
+
if upper_index - lower_index < 2:
|
|
92
|
+
raise ValueError(
|
|
93
|
+
"ModeSource aperture must span at least three grid nodes along each tangential axis."
|
|
94
|
+
)
|
|
95
|
+
bounds.append((int(lower_index), int(upper_index)))
|
|
96
|
+
return (bounds[0], bounds[1]), tangential_axes
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# Relative transverse-spacing spread at or below which the mode plane is treated
|
|
100
|
+
# as exactly uniform and the legacy scalar spacing is returned bit-for-bit.
|
|
101
|
+
_UNIFORM_SPACING_RTOL = 1e-6
|
|
102
|
+
# Maximum fractional transverse-spacing variation tolerated across a mode-plane
|
|
103
|
+
# aperture. The 2D finite-difference mode operator is assembled from a single
|
|
104
|
+
# du/dv; on a graded transverse grid a centered stencil evaluated with the mean
|
|
105
|
+
# spacing loses one order of accuracy and its leading relative error scales with
|
|
106
|
+
# the fractional spacing spread (d_max - d_min)/d_mean. 1e-2 keeps the induced
|
|
107
|
+
# effective-index / operator error at roughly the one-percent level.
|
|
108
|
+
_MODE_PLANE_SPACING_SPREAD_BOUND = 1e-2
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _local_uniform_plane_spacing(scene, axis: str, lower_index: int, upper_index: int) -> float:
|
|
112
|
+
"""Effective transverse spacing across a mode-plane aperture window.
|
|
113
|
+
|
|
114
|
+
The 2D mode solver assembles its first/second-difference operators from a
|
|
115
|
+
single transverse spacing. A perfectly uniform window (spread <=
|
|
116
|
+
``_UNIFORM_SPACING_RTOL`` relative) returns its exact spacing, bit-for-bit as
|
|
117
|
+
before. A mildly graded window is accepted when its fractional spacing
|
|
118
|
+
variation stays below ``_MODE_PLANE_SPACING_SPREAD_BOUND`` and the region-mean
|
|
119
|
+
spacing is used; beyond that the finite-difference operator error is no longer
|
|
120
|
+
controlled and the window is rejected with the predicted variation.
|
|
121
|
+
"""
|
|
122
|
+
primal = {"x": scene.dx_primal64, "y": scene.dy_primal64, "z": scene.dz_primal64}[axis]
|
|
123
|
+
window = primal[max(int(lower_index) - 1, 0) : min(int(upper_index) + 1, len(primal))]
|
|
124
|
+
d_min = float(window.min())
|
|
125
|
+
d_max = float(window.max())
|
|
126
|
+
if (d_max - d_min) <= _UNIFORM_SPACING_RTOL * d_max:
|
|
127
|
+
return d_min
|
|
128
|
+
|
|
129
|
+
d_eff = float(window.mean())
|
|
130
|
+
fractional_spread = (d_max - d_min) / d_eff
|
|
131
|
+
if fractional_spread > _MODE_PLANE_SPACING_SPREAD_BOUND:
|
|
132
|
+
raise ValueError(
|
|
133
|
+
"ModeSource/ModeMonitor mode solving is too graded across the mode "
|
|
134
|
+
f"plane along axis '{axis}': the fractional transverse-spacing "
|
|
135
|
+
f"variation is {fractional_spread:.3e} (min={d_min:g}, max={d_max:g}), "
|
|
136
|
+
f"above the bound {_MODE_PLANE_SPACING_SPREAD_BOUND:.0e}. Refine "
|
|
137
|
+
f"GridSpec.custom for locally uniform grid spacing along axis '{axis}' "
|
|
138
|
+
"across the mode plane, or move the plane."
|
|
139
|
+
)
|
|
140
|
+
return d_eff
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _field_component_axis_coords(scene, field_name: str, axis: str) -> torch.Tensor:
|
|
144
|
+
if field_name == "Ex":
|
|
145
|
+
mapping = {
|
|
146
|
+
"x": scene.x_half,
|
|
147
|
+
"y": scene.y,
|
|
148
|
+
"z": scene.z,
|
|
149
|
+
}
|
|
150
|
+
elif field_name == "Ey":
|
|
151
|
+
mapping = {
|
|
152
|
+
"x": scene.x,
|
|
153
|
+
"y": scene.y_half,
|
|
154
|
+
"z": scene.z,
|
|
155
|
+
}
|
|
156
|
+
elif field_name == "Ez":
|
|
157
|
+
mapping = {
|
|
158
|
+
"x": scene.x,
|
|
159
|
+
"y": scene.y,
|
|
160
|
+
"z": scene.z_half,
|
|
161
|
+
}
|
|
162
|
+
else:
|
|
163
|
+
raise ValueError(f"Unsupported ModeSource field component {field_name!r}.")
|
|
164
|
+
return mapping[axis]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _average_node_tensor_to_component(node_tensor: torch.Tensor, field_name: str) -> torch.Tensor:
|
|
168
|
+
if field_name == "Ex":
|
|
169
|
+
return (0.5 * (node_tensor[:-1, :, :] + node_tensor[1:, :, :])).contiguous()
|
|
170
|
+
if field_name == "Ey":
|
|
171
|
+
return (0.5 * (node_tensor[:, :-1, :] + node_tensor[:, 1:, :])).contiguous()
|
|
172
|
+
if field_name == "Ez":
|
|
173
|
+
return (0.5 * (node_tensor[:, :, :-1] + node_tensor[:, :, 1:])).contiguous()
|
|
174
|
+
raise ValueError(f"Unsupported ModeSource field component {field_name!r}.")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _mode_source_node_axis_coords(scene, axis: str) -> torch.Tensor:
|
|
178
|
+
return getattr(scene, axis)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _mode_source_relative_material_slices(
|
|
182
|
+
solver,
|
|
183
|
+
*,
|
|
184
|
+
frequency: float,
|
|
185
|
+
normal_axis: str,
|
|
186
|
+
plane_index: int,
|
|
187
|
+
tangential_bounds,
|
|
188
|
+
):
|
|
189
|
+
"""Per-axis diagonal eps/mu aperture slices for the full-vector mode solve.
|
|
190
|
+
|
|
191
|
+
Returns ``(eps_by_axis, mu_by_axis)`` where each maps ``"x"/"y"/"z"`` to the
|
|
192
|
+
diagonal permittivity/permeability component sliced on the source plane. The
|
|
193
|
+
full-vector operator consumes the three components separately so a
|
|
194
|
+
diagonal-anisotropic aperture is resolved with its true per-axis tensor
|
|
195
|
+
instead of an isotropic average.
|
|
196
|
+
"""
|
|
197
|
+
compiled_material_model = getattr(solver, "_compiled_material_model", None)
|
|
198
|
+
if compiled_material_model is None:
|
|
199
|
+
raise RuntimeError("Full-vector ModeSource currently requires solver._compiled_material_model.")
|
|
200
|
+
|
|
201
|
+
eps_components, mu_components = evaluate_material_components(compiled_material_model, float(frequency))
|
|
202
|
+
eps_by_axis = {
|
|
203
|
+
axis: _mode_slice(eps_components[axis], axis=normal_axis, plane_index=plane_index, tangential_bounds=tangential_bounds)
|
|
204
|
+
for axis in ("x", "y", "z")
|
|
205
|
+
}
|
|
206
|
+
mu_by_axis = {
|
|
207
|
+
axis: _mode_slice(mu_components[axis], axis=normal_axis, plane_index=plane_index, tangential_bounds=tangential_bounds)
|
|
208
|
+
for axis in ("x", "y", "z")
|
|
209
|
+
}
|
|
210
|
+
return eps_by_axis, mu_by_axis
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _max_component_imag(components: dict) -> float:
|
|
214
|
+
"""Largest imaginary magnitude across the per-axis diagonal aperture slices."""
|
|
215
|
+
worst = 0.0
|
|
216
|
+
for component in components.values():
|
|
217
|
+
if torch.is_complex(component):
|
|
218
|
+
worst = max(worst, float(torch.max(torch.abs(torch.imag(component))).item()))
|
|
219
|
+
return worst
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _min_component_real(components: dict) -> float:
|
|
223
|
+
"""Smallest real part across the per-axis diagonal aperture slices."""
|
|
224
|
+
return min(
|
|
225
|
+
float(torch.min(torch.real(component) if torch.is_complex(component) else component).item())
|
|
226
|
+
for component in components.values()
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _build_first_difference_sparse(count: int, spacing: float):
|
|
231
|
+
if count <= 0:
|
|
232
|
+
raise ValueError("count must be > 0 for first-difference assembly.")
|
|
233
|
+
off = np.full((max(count - 1, 0),), 0.5 / float(spacing), dtype=np.float64)
|
|
234
|
+
return sparse.diags((-off, off), offsets=(-1, 1), shape=(count, count), format="csr")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _build_vector_operator_sparse(eps_planes, mu_planes, *, k0: float, du: float, dv: float):
|
|
238
|
+
"""Full-vector transverse mode operator for a diagonal-anisotropic plane.
|
|
239
|
+
|
|
240
|
+
``eps_planes`` / ``mu_planes`` are ``(uu, vv, ww)`` triples of 2D aperture
|
|
241
|
+
slices, with ``uu``/``vv`` the in-plane (tangential) diagonal components and
|
|
242
|
+
``ww`` the plane-normal (propagation-axis) component. The placement follows
|
|
243
|
+
the first-order transverse Maxwell system eliminated for the longitudinal
|
|
244
|
+
fields: the normal permittivity ``eps_ww`` enters every H-block coupling
|
|
245
|
+
through the eliminated ``E_w = (i / (omega eps0 eps_ww)) (d_u H_v - d_v H_u)``,
|
|
246
|
+
the normal permeability ``mu_ww`` enters every E-block coupling through the
|
|
247
|
+
eliminated ``H_w``, and each transverse self-term carries the matching in-plane
|
|
248
|
+
component (``E_u`` sees ``eps_uu``, ``E_v`` sees ``eps_vv``, ``H_u`` sees
|
|
249
|
+
``mu_uu``, ``H_v`` sees ``mu_vv``). For an isotropic plane the three components
|
|
250
|
+
coincide and this reduces to the scalar-``eps`` form bit-for-bit.
|
|
251
|
+
"""
|
|
252
|
+
eps_uu, eps_vv, eps_ww = eps_planes
|
|
253
|
+
mu_uu, mu_vv, mu_ww = mu_planes
|
|
254
|
+
nu = int(eps_uu.shape[0])
|
|
255
|
+
nv = int(eps_uu.shape[1])
|
|
256
|
+
interior_u = nu - 2
|
|
257
|
+
interior_v = nv - 2
|
|
258
|
+
unknowns = interior_u * interior_v
|
|
259
|
+
if unknowns <= 0:
|
|
260
|
+
raise ValueError(
|
|
261
|
+
"ModeSource aperture must contain at least one interior node after applying zero boundary conditions."
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
d1_u = _build_first_difference_sparse(interior_u, du)
|
|
265
|
+
d1_v = _build_first_difference_sparse(interior_v, dv)
|
|
266
|
+
identity_u = sparse.eye(interior_u, format="csr", dtype=np.float64)
|
|
267
|
+
identity_v = sparse.eye(interior_v, format="csr", dtype=np.float64)
|
|
268
|
+
derivative_u = sparse.kron(d1_u, identity_v, format="csr")
|
|
269
|
+
derivative_v = sparse.kron(identity_u, d1_v, format="csr")
|
|
270
|
+
|
|
271
|
+
def _interior_diag(values: np.ndarray):
|
|
272
|
+
return sparse.diags(
|
|
273
|
+
np.asarray(values, dtype=np.float64)[1:-1, 1:-1].reshape(-1), offsets=0, format="csr"
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
eps_w_inv = _interior_diag(1.0 / np.asarray(eps_ww, dtype=np.float64))
|
|
277
|
+
mu_w_inv = _interior_diag(1.0 / np.asarray(mu_ww, dtype=np.float64))
|
|
278
|
+
eps_u_diag = _interior_diag(eps_uu)
|
|
279
|
+
eps_v_diag = _interior_diag(eps_vv)
|
|
280
|
+
mu_u_diag = _interior_diag(mu_uu)
|
|
281
|
+
mu_v_diag = _interior_diag(mu_vv)
|
|
282
|
+
k0_sq = float(k0) * float(k0)
|
|
283
|
+
|
|
284
|
+
a_hu_hu = derivative_v @ eps_w_inv @ derivative_v + k0_sq * mu_u_diag
|
|
285
|
+
a_hu_hv = -derivative_v @ eps_w_inv @ derivative_u
|
|
286
|
+
a_hv_hu = -derivative_u @ eps_w_inv @ derivative_v
|
|
287
|
+
a_hv_hv = derivative_u @ eps_w_inv @ derivative_u + k0_sq * mu_v_diag
|
|
288
|
+
|
|
289
|
+
a_eu_eu = -derivative_v @ mu_w_inv @ derivative_v - k0_sq * eps_u_diag
|
|
290
|
+
a_eu_ev = derivative_v @ mu_w_inv @ derivative_u
|
|
291
|
+
a_ev_eu = derivative_u @ mu_w_inv @ derivative_v
|
|
292
|
+
a_ev_ev = -derivative_u @ mu_w_inv @ derivative_u - k0_sq * eps_v_diag
|
|
293
|
+
|
|
294
|
+
electric_scale = float(k0) / _ETA0
|
|
295
|
+
magnetic_scale = float(k0) * _ETA0
|
|
296
|
+
operator = sparse.bmat(
|
|
297
|
+
[
|
|
298
|
+
[None, None, a_ev_eu / magnetic_scale, a_ev_ev / magnetic_scale],
|
|
299
|
+
[None, None, -a_eu_eu / magnetic_scale, -a_eu_ev / magnetic_scale],
|
|
300
|
+
[a_hv_hu / electric_scale, a_hv_hv / electric_scale, None, None],
|
|
301
|
+
[-a_hu_hu / electric_scale, -a_hu_hv / electric_scale, None, None],
|
|
302
|
+
],
|
|
303
|
+
format="csr",
|
|
304
|
+
)
|
|
305
|
+
return operator, interior_u, interior_v
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _vector_mode_request_count(matrix_size: int, *, mode_index: int) -> int:
|
|
309
|
+
requested = max(2 * (int(mode_index) + _VECTOR_EIGEN_REQUEST_PADDING), 4)
|
|
310
|
+
return min(requested, max(1, int(matrix_size) - 2))
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _select_vector_mode_numpy(
|
|
314
|
+
eigenvalues: np.ndarray,
|
|
315
|
+
eigenvectors: np.ndarray,
|
|
316
|
+
*,
|
|
317
|
+
interior_u: int,
|
|
318
|
+
interior_v: int,
|
|
319
|
+
mode_index: int,
|
|
320
|
+
):
|
|
321
|
+
order = np.lexsort((np.abs(np.imag(eigenvalues)), -np.real(eigenvalues)))
|
|
322
|
+
positive = [index for index in order if np.isfinite(eigenvalues[index]) and np.real(eigenvalues[index]) > 0.0]
|
|
323
|
+
if len(positive) <= int(mode_index):
|
|
324
|
+
raise ValueError(
|
|
325
|
+
f"ModeSource requested mode_index={mode_index}, but only {len(positive)} positive-beta modes were found."
|
|
326
|
+
)
|
|
327
|
+
selected = int(positive[int(mode_index)])
|
|
328
|
+
beta = eigenvalues[selected]
|
|
329
|
+
vector = eigenvectors[:, selected]
|
|
330
|
+
if np.max(np.abs(np.imag(beta))) <= 1e-7:
|
|
331
|
+
beta = float(np.real(beta))
|
|
332
|
+
return beta, vector
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _select_vector_mode_torch(
|
|
336
|
+
eigenvalues: torch.Tensor,
|
|
337
|
+
eigenvectors: torch.Tensor,
|
|
338
|
+
*,
|
|
339
|
+
mode_index: int,
|
|
340
|
+
):
|
|
341
|
+
order = torch.argsort(torch.real(eigenvalues), descending=True)
|
|
342
|
+
positive = [int(index) for index in order.tolist() if float(torch.real(eigenvalues[index]).item()) > 0.0]
|
|
343
|
+
if len(positive) <= int(mode_index):
|
|
344
|
+
raise ValueError(
|
|
345
|
+
f"ModeSource requested mode_index={mode_index}, but only {len(positive)} positive-beta modes were found."
|
|
346
|
+
)
|
|
347
|
+
selected = positive[int(mode_index)]
|
|
348
|
+
return eigenvalues[selected], eigenvectors[:, selected]
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _split_vector_mode_components(
|
|
352
|
+
eigenvector,
|
|
353
|
+
*,
|
|
354
|
+
interior_u: int,
|
|
355
|
+
interior_v: int,
|
|
356
|
+
backend: str,
|
|
357
|
+
):
|
|
358
|
+
block = interior_u * interior_v
|
|
359
|
+
if backend == "torch":
|
|
360
|
+
hu = eigenvector[0:block].reshape((interior_u, interior_v))
|
|
361
|
+
hv = eigenvector[block : 2 * block].reshape((interior_u, interior_v))
|
|
362
|
+
eu = eigenvector[2 * block : 3 * block].reshape((interior_u, interior_v))
|
|
363
|
+
ev = eigenvector[3 * block : 4 * block].reshape((interior_u, interior_v))
|
|
364
|
+
else:
|
|
365
|
+
hu = np.asarray(eigenvector[0:block]).reshape((interior_u, interior_v))
|
|
366
|
+
hv = np.asarray(eigenvector[block : 2 * block]).reshape((interior_u, interior_v))
|
|
367
|
+
eu = np.asarray(eigenvector[2 * block : 3 * block]).reshape((interior_u, interior_v))
|
|
368
|
+
ev = np.asarray(eigenvector[3 * block : 4 * block]).reshape((interior_u, interior_v))
|
|
369
|
+
return hu, hv, eu, ev
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _vector_preferred_component_name(source) -> str:
|
|
373
|
+
return f"E{str(source['polarization_axis']).lower()}"
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _normalize_vector_mode_profiles_numpy(component_profiles: dict[str, np.ndarray], *, preferred_field_name: str):
|
|
377
|
+
preferred = component_profiles.get(preferred_field_name)
|
|
378
|
+
if preferred is None:
|
|
379
|
+
raise RuntimeError(f"ModeSource full-vector solve did not produce preferred field {preferred_field_name}.")
|
|
380
|
+
peak = float(np.max(np.abs(preferred)))
|
|
381
|
+
if peak <= 0.0:
|
|
382
|
+
electric_norm = np.sqrt(sum(np.abs(component_profiles[name]) ** 2 for name in component_profiles if name.startswith("E")))
|
|
383
|
+
peak = float(np.max(electric_norm))
|
|
384
|
+
if peak <= 0.0:
|
|
385
|
+
raise RuntimeError("ModeSource full-vector eigenmode solve returned a zero electric field profile.")
|
|
386
|
+
peak_index = np.unravel_index(np.argmax(electric_norm), electric_norm.shape)
|
|
387
|
+
preferred_value = preferred[peak_index]
|
|
388
|
+
else:
|
|
389
|
+
peak_index = np.unravel_index(np.argmax(np.abs(preferred)), preferred.shape)
|
|
390
|
+
preferred_value = preferred[peak_index]
|
|
391
|
+
sign = 1.0 if float(np.real(preferred_value)) >= 0.0 else -1.0
|
|
392
|
+
for name in tuple(component_profiles):
|
|
393
|
+
component_profiles[name] = sign * component_profiles[name] / peak
|
|
394
|
+
return component_profiles
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _vector_mode_power_sign_numpy(component_profiles: dict[str, np.ndarray]) -> float:
|
|
398
|
+
tangential_e = [component_profiles[name] for name in component_profiles if name.startswith("E")]
|
|
399
|
+
tangential_h = [component_profiles[name] for name in component_profiles if name.startswith("H")]
|
|
400
|
+
if len(tangential_e) != 2 or len(tangential_h) != 2:
|
|
401
|
+
return 0.0
|
|
402
|
+
eu, ev = tangential_e
|
|
403
|
+
hu, hv = tangential_h
|
|
404
|
+
return float(np.sum(np.real(eu * np.conj(hv) - ev * np.conj(hu))))
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _normalize_mode_profiles_to_unit_power(
|
|
408
|
+
component_profiles: dict[str, torch.Tensor],
|
|
409
|
+
*,
|
|
410
|
+
coords_u: torch.Tensor,
|
|
411
|
+
coords_v: torch.Tensor,
|
|
412
|
+
normal_axis: str,
|
|
413
|
+
) -> dict[str, torch.Tensor]:
|
|
414
|
+
"""Scale a common-grid modal E/H profile to one watt of forward power."""
|
|
415
|
+
ex, ey, ez = (component_profiles[name] for name in ("Ex", "Ey", "Ez"))
|
|
416
|
+
hx, hy, hz = (component_profiles[name] for name in ("Hx", "Hy", "Hz"))
|
|
417
|
+
normal_poynting = {
|
|
418
|
+
"x": ey * torch.conj(hz) - ez * torch.conj(hy),
|
|
419
|
+
"y": ez * torch.conj(hx) - ex * torch.conj(hz),
|
|
420
|
+
"z": ex * torch.conj(hy) - ey * torch.conj(hx),
|
|
421
|
+
}[normal_axis]
|
|
422
|
+
power_density = 0.5 * torch.real(normal_poynting)
|
|
423
|
+
if int(coords_u.numel()) != int(power_density.shape[0]):
|
|
424
|
+
offset = max((int(coords_u.numel()) - int(power_density.shape[0])) // 2, 0)
|
|
425
|
+
coords_u = coords_u[offset : offset + int(power_density.shape[0])]
|
|
426
|
+
if int(coords_v.numel()) != int(power_density.shape[1]):
|
|
427
|
+
offset = max((int(coords_v.numel()) - int(power_density.shape[1])) // 2, 0)
|
|
428
|
+
coords_v = coords_v[offset : offset + int(power_density.shape[1])]
|
|
429
|
+
power_v = torch.trapezoid(power_density, x=coords_v, dim=1)
|
|
430
|
+
power = torch.trapezoid(power_v, x=coords_u, dim=0)
|
|
431
|
+
abs_power = torch.abs(power)
|
|
432
|
+
if float(abs_power.detach().item()) <= torch.finfo(abs_power.dtype).eps:
|
|
433
|
+
raise RuntimeError("ModeSource eigenmode profile has zero integrated Poynting power.")
|
|
434
|
+
scale = torch.rsqrt(abs_power)
|
|
435
|
+
return {name: profile * scale for name, profile in component_profiles.items()}
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _select_and_normalize_vector_mode_numpy(
|
|
439
|
+
eigenvalues: np.ndarray,
|
|
440
|
+
eigenvectors: np.ndarray,
|
|
441
|
+
*,
|
|
442
|
+
interior_u: int,
|
|
443
|
+
interior_v: int,
|
|
444
|
+
mode_index: int,
|
|
445
|
+
field_names,
|
|
446
|
+
preferred_field_name: str,
|
|
447
|
+
):
|
|
448
|
+
order = np.lexsort((np.abs(np.imag(eigenvalues)), -np.real(eigenvalues)))
|
|
449
|
+
raw_candidates = []
|
|
450
|
+
for index in order:
|
|
451
|
+
value = eigenvalues[index]
|
|
452
|
+
if not np.isfinite(value) or np.real(value) <= 0.0:
|
|
453
|
+
continue
|
|
454
|
+
hu, hv, eu, ev = _split_vector_mode_components(
|
|
455
|
+
eigenvectors[:, index],
|
|
456
|
+
interior_u=interior_u,
|
|
457
|
+
interior_v=interior_v,
|
|
458
|
+
backend="numpy",
|
|
459
|
+
)
|
|
460
|
+
component_profiles = {
|
|
461
|
+
field_names[0]: eu,
|
|
462
|
+
field_names[1]: ev,
|
|
463
|
+
field_names[2]: hu,
|
|
464
|
+
field_names[3]: hv,
|
|
465
|
+
}
|
|
466
|
+
raw_candidates.append((index, value, component_profiles))
|
|
467
|
+
|
|
468
|
+
# A symmetric cross-section can produce an exactly degenerate polarization
|
|
469
|
+
# pair. Eigensolvers may return an arbitrary rotation of that subspace, so
|
|
470
|
+
# selecting one raw vector makes the launched polarization jump with tiny
|
|
471
|
+
# frequency or grid changes. Diagonalize the requested-polarization energy
|
|
472
|
+
# inside each degenerate subspace to obtain a deterministic polarized basis.
|
|
473
|
+
positive_candidates = []
|
|
474
|
+
cursor = 0
|
|
475
|
+
while cursor < len(raw_candidates):
|
|
476
|
+
group_stop = cursor + 1
|
|
477
|
+
reference_value = raw_candidates[cursor][1]
|
|
478
|
+
tolerance = 1.0e-9 * max(abs(reference_value), 1.0)
|
|
479
|
+
while (
|
|
480
|
+
group_stop < len(raw_candidates)
|
|
481
|
+
and abs(raw_candidates[group_stop][1] - reference_value) <= tolerance
|
|
482
|
+
):
|
|
483
|
+
group_stop += 1
|
|
484
|
+
group = raw_candidates[cursor:group_stop]
|
|
485
|
+
|
|
486
|
+
if len(group) == 1:
|
|
487
|
+
rotated = [(group[0][0], group[0][1], eigenvectors[:, group[0][0]], group[0][2])]
|
|
488
|
+
else:
|
|
489
|
+
count = len(group)
|
|
490
|
+
preferred_gram = np.empty((count, count), dtype=np.complex128)
|
|
491
|
+
electric_gram = np.empty((count, count), dtype=np.complex128)
|
|
492
|
+
for row, (_, _, row_profiles) in enumerate(group):
|
|
493
|
+
for col, (_, _, col_profiles) in enumerate(group):
|
|
494
|
+
preferred_gram[row, col] = np.vdot(
|
|
495
|
+
row_profiles[preferred_field_name], col_profiles[preferred_field_name]
|
|
496
|
+
)
|
|
497
|
+
electric_gram[row, col] = sum(
|
|
498
|
+
np.vdot(row_profiles[name], col_profiles[name])
|
|
499
|
+
for name in row_profiles
|
|
500
|
+
if name.startswith("E")
|
|
501
|
+
)
|
|
502
|
+
electric_scale = max(float(np.max(np.abs(np.diag(electric_gram)))), 1.0)
|
|
503
|
+
electric_gram += np.eye(count) * (np.finfo(np.float64).eps * electric_scale)
|
|
504
|
+
fractions, rotations = scipy_linalg.eigh(preferred_gram, electric_gram)
|
|
505
|
+
rotated = []
|
|
506
|
+
for rotation_index in np.argsort(fractions)[::-1]:
|
|
507
|
+
coefficients = rotations[:, rotation_index]
|
|
508
|
+
combined_vector = sum(
|
|
509
|
+
coefficient * eigenvectors[:, index]
|
|
510
|
+
for coefficient, (index, _, _) in zip(coefficients, group)
|
|
511
|
+
)
|
|
512
|
+
combined_profiles = {
|
|
513
|
+
name: sum(
|
|
514
|
+
coefficient * profiles[name]
|
|
515
|
+
for coefficient, (_, _, profiles) in zip(coefficients, group)
|
|
516
|
+
)
|
|
517
|
+
for name in group[0][2]
|
|
518
|
+
}
|
|
519
|
+
rotated.append((group[0][0], reference_value, combined_vector, combined_profiles))
|
|
520
|
+
|
|
521
|
+
for selected_index, value, selected_vector, component_profiles in rotated:
|
|
522
|
+
component_profiles = _normalize_vector_mode_profiles_numpy(
|
|
523
|
+
component_profiles,
|
|
524
|
+
preferred_field_name=preferred_field_name,
|
|
525
|
+
)
|
|
526
|
+
power_sign = _vector_mode_power_sign_numpy(component_profiles)
|
|
527
|
+
positive_candidates.append(
|
|
528
|
+
(selected_index, value, selected_vector, component_profiles, power_sign)
|
|
529
|
+
)
|
|
530
|
+
cursor = group_stop
|
|
531
|
+
|
|
532
|
+
preferred_candidates = [entry for entry in positive_candidates if entry[4] > 0.0]
|
|
533
|
+
selected_candidates = preferred_candidates if len(preferred_candidates) > int(mode_index) else positive_candidates
|
|
534
|
+
if len(selected_candidates) <= int(mode_index):
|
|
535
|
+
raise ValueError(
|
|
536
|
+
f"ModeSource requested mode_index={mode_index}, but only {len(selected_candidates)} forward modes were found."
|
|
537
|
+
)
|
|
538
|
+
_, selected_beta, selected_vector, selected_profiles, _ = selected_candidates[int(mode_index)]
|
|
539
|
+
return selected_beta, selected_vector, selected_profiles
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _full_field_component_profiles(component_profiles, shape, *, tangential_field_names):
|
|
543
|
+
profiles = {name: torch.zeros(shape) for name in _FIELD_COMPONENTS + ("Hx", "Hy", "Hz")}
|
|
544
|
+
for name in tangential_field_names:
|
|
545
|
+
profiles[name] = component_profiles[name]
|
|
546
|
+
return profiles
|
|
547
|
+
|
|
548
|
+
def _build_scalar_operator(index_sq: np.ndarray, du: float, dv: float):
|
|
549
|
+
nu = int(index_sq.shape[0])
|
|
550
|
+
nv = int(index_sq.shape[1])
|
|
551
|
+
interior_u = nu - 2
|
|
552
|
+
interior_v = nv - 2
|
|
553
|
+
unknowns = interior_u * interior_v
|
|
554
|
+
if unknowns <= 0:
|
|
555
|
+
raise ValueError(
|
|
556
|
+
"ModeSource aperture must contain at least one interior node after applying zero boundary conditions."
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
main_u = np.full((interior_u,), -2.0 / (du * du), dtype=np.float64)
|
|
560
|
+
off_u = np.full((max(interior_u - 1, 0),), 1.0 / (du * du), dtype=np.float64)
|
|
561
|
+
d2_u = sparse.diags((off_u, main_u, off_u), offsets=(-1, 0, 1), format="csr")
|
|
562
|
+
|
|
563
|
+
main_v = np.full((interior_v,), -2.0 / (dv * dv), dtype=np.float64)
|
|
564
|
+
off_v = np.full((max(interior_v - 1, 0),), 1.0 / (dv * dv), dtype=np.float64)
|
|
565
|
+
d2_v = sparse.diags((off_v, main_v, off_v), offsets=(-1, 0, 1), format="csr")
|
|
566
|
+
|
|
567
|
+
laplacian = sparse.kron(sparse.eye(interior_v, format="csr"), d2_u, format="csr")
|
|
568
|
+
laplacian = laplacian + sparse.kron(d2_v, sparse.eye(interior_u, format="csr"), format="csr")
|
|
569
|
+
potential = sparse.diags(index_sq[1:-1, 1:-1].reshape(-1), offsets=0, format="csr")
|
|
570
|
+
return laplacian + potential, interior_u, interior_v
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _build_scalar_operator_torch_dense(index_sq: torch.Tensor, du: float, dv: float):
|
|
574
|
+
nu = int(index_sq.shape[0])
|
|
575
|
+
nv = int(index_sq.shape[1])
|
|
576
|
+
interior_u = nu - 2
|
|
577
|
+
interior_v = nv - 2
|
|
578
|
+
unknowns = interior_u * interior_v
|
|
579
|
+
if unknowns <= 0:
|
|
580
|
+
raise ValueError(
|
|
581
|
+
"ModeSource aperture must contain at least one interior node after applying zero boundary conditions."
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
dtype = index_sq.dtype
|
|
585
|
+
device = index_sq.device
|
|
586
|
+
|
|
587
|
+
main_u = torch.full((interior_u,), -2.0 / (du * du), device=device, dtype=dtype)
|
|
588
|
+
off_u = torch.full((max(interior_u - 1, 0),), 1.0 / (du * du), device=device, dtype=dtype)
|
|
589
|
+
d2_u = torch.diag(main_u)
|
|
590
|
+
if off_u.numel() > 0:
|
|
591
|
+
d2_u = d2_u + torch.diag(off_u, diagonal=1) + torch.diag(off_u, diagonal=-1)
|
|
592
|
+
|
|
593
|
+
main_v = torch.full((interior_v,), -2.0 / (dv * dv), device=device, dtype=dtype)
|
|
594
|
+
off_v = torch.full((max(interior_v - 1, 0),), 1.0 / (dv * dv), device=device, dtype=dtype)
|
|
595
|
+
d2_v = torch.diag(main_v)
|
|
596
|
+
if off_v.numel() > 0:
|
|
597
|
+
d2_v = d2_v + torch.diag(off_v, diagonal=1) + torch.diag(off_v, diagonal=-1)
|
|
598
|
+
|
|
599
|
+
identity_u = torch.eye(interior_u, device=device, dtype=dtype)
|
|
600
|
+
identity_v = torch.eye(interior_v, device=device, dtype=dtype)
|
|
601
|
+
laplacian = torch.kron(identity_v, d2_u) + torch.kron(d2_v, identity_u)
|
|
602
|
+
potential = torch.diag(index_sq[1:-1, 1:-1].reshape(-1))
|
|
603
|
+
return laplacian + potential, interior_u, interior_v
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _build_scalar_operator_torch_sparse(index_sq: torch.Tensor, du: float, dv: float):
|
|
607
|
+
nu = int(index_sq.shape[0])
|
|
608
|
+
nv = int(index_sq.shape[1])
|
|
609
|
+
interior_u = nu - 2
|
|
610
|
+
interior_v = nv - 2
|
|
611
|
+
unknowns = interior_u * interior_v
|
|
612
|
+
if unknowns <= 0:
|
|
613
|
+
raise ValueError(
|
|
614
|
+
"ModeSource aperture must contain at least one interior node after applying zero boundary conditions."
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
device = index_sq.device
|
|
618
|
+
dtype = index_sq.dtype
|
|
619
|
+
flat_indices = torch.arange(unknowns, device=device, dtype=torch.int64).reshape((interior_v, interior_u))
|
|
620
|
+
center = flat_indices.reshape(-1)
|
|
621
|
+
|
|
622
|
+
row_chunks = [center]
|
|
623
|
+
col_chunks = [center]
|
|
624
|
+
value_chunks = [
|
|
625
|
+
(
|
|
626
|
+
index_sq[1:-1, 1:-1].reshape(-1)
|
|
627
|
+
+ float(-2.0 / (du * du) - 2.0 / (dv * dv))
|
|
628
|
+
).to(device=device, dtype=dtype)
|
|
629
|
+
]
|
|
630
|
+
|
|
631
|
+
if interior_u > 1:
|
|
632
|
+
lower = flat_indices[:, :-1].reshape(-1)
|
|
633
|
+
upper = flat_indices[:, 1:].reshape(-1)
|
|
634
|
+
coupling = torch.full((lower.numel(),), 1.0 / (du * du), device=device, dtype=dtype)
|
|
635
|
+
row_chunks.extend((lower, upper))
|
|
636
|
+
col_chunks.extend((upper, lower))
|
|
637
|
+
value_chunks.extend((coupling, coupling))
|
|
638
|
+
|
|
639
|
+
if interior_v > 1:
|
|
640
|
+
lower = flat_indices[:-1, :].reshape(-1)
|
|
641
|
+
upper = flat_indices[1:, :].reshape(-1)
|
|
642
|
+
coupling = torch.full((lower.numel(),), 1.0 / (dv * dv), device=device, dtype=dtype)
|
|
643
|
+
row_chunks.extend((lower, upper))
|
|
644
|
+
col_chunks.extend((upper, lower))
|
|
645
|
+
value_chunks.extend((coupling, coupling))
|
|
646
|
+
|
|
647
|
+
indices = torch.stack((torch.cat(row_chunks), torch.cat(col_chunks)), dim=0)
|
|
648
|
+
values = torch.cat(value_chunks)
|
|
649
|
+
operator = torch.sparse_coo_tensor(indices, values, (unknowns, unknowns), device=device, dtype=dtype)
|
|
650
|
+
return operator.coalesce(), interior_u, interior_v
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _solve_mode_eigenpair(operator, *, mode_index: int):
|
|
654
|
+
dense = operator.toarray()
|
|
655
|
+
eigenvalues, eigenvectors = scipy_linalg.eigh(dense)
|
|
656
|
+
|
|
657
|
+
order = np.argsort(eigenvalues)[::-1]
|
|
658
|
+
eigenvalues = np.asarray(eigenvalues[order], dtype=np.float64)
|
|
659
|
+
eigenvectors = np.asarray(eigenvectors[:, order], dtype=np.float64)
|
|
660
|
+
valid = np.where(eigenvalues > 0.0)[0]
|
|
661
|
+
if valid.size <= int(mode_index):
|
|
662
|
+
raise ValueError(
|
|
663
|
+
f"ModeSource requested mode_index={mode_index}, but only {valid.size} positive-beta modes were found."
|
|
664
|
+
)
|
|
665
|
+
mode_slot = int(valid[int(mode_index)])
|
|
666
|
+
return float(eigenvalues[mode_slot]), eigenvectors[:, mode_slot]
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _solve_mode_eigenpair_complex(operator, *, mode_index: int):
|
|
670
|
+
"""Fundamental-first eigenpair of the complex (lossy) scalar mode operator.
|
|
671
|
+
|
|
672
|
+
When the plane permittivity carries loss, ``d2 + k0**2 eps`` is complex
|
|
673
|
+
symmetric rather than Hermitian, so the guided spectrum ``beta**2`` is
|
|
674
|
+
complex. Guided modes have the largest real part of ``beta**2`` (the
|
|
675
|
+
fundamental has the largest of all), so the modes are ranked by descending
|
|
676
|
+
``Re(beta**2)`` and the ``mode_index``-th positive-real-part eigenvalue is
|
|
677
|
+
returned. A dense general eigensolve is used up to ``_DENSE_EIGEN_LIMIT``
|
|
678
|
+
unknowns and a largest-real-part Arnoldi solve above it.
|
|
679
|
+
"""
|
|
680
|
+
size = int(operator.shape[0])
|
|
681
|
+
if size <= _DENSE_EIGEN_LIMIT:
|
|
682
|
+
eigenvalues, eigenvectors = scipy_linalg.eig(operator.toarray())
|
|
683
|
+
else:
|
|
684
|
+
requested = min(max(int(mode_index) + _VECTOR_EIGEN_REQUEST_PADDING, 4), max(1, size - 2))
|
|
685
|
+
eigenvalues, eigenvectors = scipy_sparse_linalg.eigs(
|
|
686
|
+
operator,
|
|
687
|
+
k=requested,
|
|
688
|
+
which="LR",
|
|
689
|
+
tol=_VECTOR_EIGS_TOL,
|
|
690
|
+
maxiter=_VECTOR_EIGS_MAX_ITER,
|
|
691
|
+
)
|
|
692
|
+
order = np.argsort(np.real(eigenvalues))[::-1]
|
|
693
|
+
positive = [int(index) for index in order if np.isfinite(eigenvalues[index]) and np.real(eigenvalues[index]) > 0.0]
|
|
694
|
+
if len(positive) <= int(mode_index):
|
|
695
|
+
raise ValueError(
|
|
696
|
+
f"ModeSource requested mode_index={mode_index}, but only {len(positive)} positive-beta modes were found."
|
|
697
|
+
)
|
|
698
|
+
mode_slot = positive[int(mode_index)]
|
|
699
|
+
return complex(eigenvalues[mode_slot]), np.asarray(eigenvectors[:, mode_slot])
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def _real_profile_from_complex_eigenvector(eigenvector, *, interior_u: int, interior_v: int, shape):
|
|
703
|
+
"""Real transverse amplitude of a complex eigenmode, phase-aligned at its peak.
|
|
704
|
+
|
|
705
|
+
A complex eigenvector carries an arbitrary global phase; removing the phase at
|
|
706
|
+
the amplitude peak yields the real standing transverse shape used for soft
|
|
707
|
+
injection (the complex propagation constant, not the transverse phase, carries
|
|
708
|
+
the loss). Returns a peak-normalized, sign-oriented float64 numpy array on the
|
|
709
|
+
full aperture grid with zeroed Dirichlet borders.
|
|
710
|
+
"""
|
|
711
|
+
peak = int(np.argmax(np.abs(eigenvector)))
|
|
712
|
+
peak_value = eigenvector[peak]
|
|
713
|
+
if abs(peak_value) <= 0.0:
|
|
714
|
+
raise RuntimeError("ModeSource complex eigenmode solve returned a zero profile.")
|
|
715
|
+
aligned = eigenvector * (np.conjugate(peak_value) / abs(peak_value))
|
|
716
|
+
profile = np.zeros(tuple(int(dim) for dim in shape), dtype=np.float64)
|
|
717
|
+
profile[1:-1, 1:-1] = np.real(aligned).reshape((int(interior_u), int(interior_v)))
|
|
718
|
+
scale = float(np.max(np.abs(profile)))
|
|
719
|
+
if scale <= 0.0:
|
|
720
|
+
raise RuntimeError("ModeSource complex eigenmode solve returned a zero profile.")
|
|
721
|
+
profile /= scale
|
|
722
|
+
peak_index = np.unravel_index(np.argmax(np.abs(profile)), profile.shape)
|
|
723
|
+
if profile[peak_index] < 0.0:
|
|
724
|
+
profile *= -1.0
|
|
725
|
+
return profile
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def _solve_mode_eigenpair_torch(operator: torch.Tensor, *, mode_index: int):
|
|
729
|
+
eigenvalues, eigenvectors = torch.linalg.eigh(operator)
|
|
730
|
+
order = torch.argsort(eigenvalues, descending=True)
|
|
731
|
+
eigenvalues = eigenvalues[order]
|
|
732
|
+
eigenvectors = eigenvectors[:, order]
|
|
733
|
+
valid = torch.nonzero(eigenvalues > 0.0, as_tuple=False).flatten()
|
|
734
|
+
if valid.numel() <= int(mode_index):
|
|
735
|
+
raise ValueError(
|
|
736
|
+
f"ModeSource requested mode_index={mode_index}, but only {int(valid.numel())} positive-beta modes were found."
|
|
737
|
+
)
|
|
738
|
+
mode_slot = int(valid[int(mode_index)].item())
|
|
739
|
+
return eigenvalues[mode_slot], eigenvectors[:, mode_slot]
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def _lobpcg_request_count(unknowns: int, *, mode_index: int) -> int:
|
|
743
|
+
requested = max(int(mode_index) + _LOBPCG_REQUEST_PADDING, 1)
|
|
744
|
+
max_requested = max(1, (int(unknowns) - 1) // 3)
|
|
745
|
+
requested = min(requested, max_requested)
|
|
746
|
+
if requested <= int(mode_index):
|
|
747
|
+
raise ValueError(
|
|
748
|
+
"ModeSource mode_index is too large for this aperture's resolution: the sparse "
|
|
749
|
+
"LOBPCG eigensolver needs at least three interior aperture unknowns per requested "
|
|
750
|
+
f"mode to span a valid iteration subspace, but mode_index={mode_index} on an "
|
|
751
|
+
f"aperture with {unknowns} interior unknowns leaves fewer. Refine the aperture grid "
|
|
752
|
+
"or request a lower mode_index."
|
|
753
|
+
)
|
|
754
|
+
return requested
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def _lobpcg_initial_guess(interior_u: int, interior_v: int, *, k: int, device, dtype) -> torch.Tensor:
|
|
758
|
+
coord_u = torch.arange(1, interior_u + 1, device=device, dtype=dtype)
|
|
759
|
+
coord_v = torch.arange(1, interior_v + 1, device=device, dtype=dtype)
|
|
760
|
+
basis_pairs: list[tuple[int, int]] = []
|
|
761
|
+
total_order = 2
|
|
762
|
+
while len(basis_pairs) < int(k):
|
|
763
|
+
for order_u in range(1, total_order):
|
|
764
|
+
order_v = total_order - order_u
|
|
765
|
+
basis_pairs.append((order_u, order_v))
|
|
766
|
+
if len(basis_pairs) >= int(k):
|
|
767
|
+
break
|
|
768
|
+
total_order += 1
|
|
769
|
+
|
|
770
|
+
vectors = []
|
|
771
|
+
for order_u, order_v in basis_pairs:
|
|
772
|
+
candidate = torch.sin(math.pi * float(order_v) * coord_v / float(interior_v + 1))[:, None]
|
|
773
|
+
candidate = candidate * torch.sin(math.pi * float(order_u) * coord_u / float(interior_u + 1))[None, :]
|
|
774
|
+
vectors.append(candidate.reshape(-1))
|
|
775
|
+
return torch.stack(vectors, dim=1).contiguous()
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _solve_mode_eigenpair_torch_sparse(operator: torch.Tensor, *, interior_u: int, interior_v: int, mode_index: int):
|
|
779
|
+
unknowns = int(operator.shape[0])
|
|
780
|
+
requested = _lobpcg_request_count(unknowns, mode_index=int(mode_index))
|
|
781
|
+
initial_guess = _lobpcg_initial_guess(
|
|
782
|
+
interior_u,
|
|
783
|
+
interior_v,
|
|
784
|
+
k=requested,
|
|
785
|
+
device=operator.device,
|
|
786
|
+
dtype=operator.dtype,
|
|
787
|
+
)
|
|
788
|
+
eigenvalues, eigenvectors = torch.lobpcg(
|
|
789
|
+
operator,
|
|
790
|
+
k=requested,
|
|
791
|
+
X=initial_guess,
|
|
792
|
+
niter=max(_LOBPCG_MAX_ITER, requested * 8),
|
|
793
|
+
tol=_LOBPCG_TOL,
|
|
794
|
+
largest=True,
|
|
795
|
+
)
|
|
796
|
+
order = torch.argsort(eigenvalues, descending=True)
|
|
797
|
+
eigenvalues = eigenvalues[order]
|
|
798
|
+
eigenvectors = eigenvectors[:, order]
|
|
799
|
+
valid = torch.nonzero(eigenvalues > 0.0, as_tuple=False).flatten()
|
|
800
|
+
if valid.numel() <= int(mode_index):
|
|
801
|
+
raise ValueError(
|
|
802
|
+
f"ModeSource requested mode_index={mode_index}, but only {int(valid.numel())} positive-beta modes were found."
|
|
803
|
+
)
|
|
804
|
+
mode_slot = int(valid[int(mode_index)].item())
|
|
805
|
+
return eigenvalues[mode_slot], eigenvectors[:, mode_slot]
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def _orient_unit_eigenvector(eigenvector: torch.Tensor) -> torch.Tensor:
|
|
809
|
+
norm = torch.linalg.vector_norm(eigenvector)
|
|
810
|
+
if float(norm.item()) <= 0.0:
|
|
811
|
+
raise RuntimeError("ModeSource eigenmode solve returned a zero eigenvector.")
|
|
812
|
+
normalized = eigenvector / norm
|
|
813
|
+
peak_index = int(torch.argmax(torch.abs(normalized)).item())
|
|
814
|
+
if float(normalized[peak_index].item()) < 0.0:
|
|
815
|
+
normalized = -normalized
|
|
816
|
+
return normalized
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def _sparse_matvec(operator: torch.Tensor, vector: torch.Tensor) -> torch.Tensor:
|
|
820
|
+
if vector.ndim != 1:
|
|
821
|
+
raise ValueError(f"Expected a 1D vector for sparse matvec, got shape {tuple(vector.shape)}.")
|
|
822
|
+
return torch.sparse.mm(operator, vector[:, None]).squeeze(1)
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def _project_orthogonal(vector: torch.Tensor, eigenvector: torch.Tensor) -> torch.Tensor:
|
|
826
|
+
return vector - eigenvector * torch.dot(eigenvector, vector)
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def _apply_shifted_square_operator(
|
|
830
|
+
operator: torch.Tensor,
|
|
831
|
+
vector: torch.Tensor,
|
|
832
|
+
*,
|
|
833
|
+
eigenvalue: torch.Tensor,
|
|
834
|
+
eigenvector: torch.Tensor,
|
|
835
|
+
) -> torch.Tensor:
|
|
836
|
+
shifted = _sparse_matvec(operator, vector) - eigenvalue * vector
|
|
837
|
+
shifted = _sparse_matvec(operator, shifted) - eigenvalue * shifted
|
|
838
|
+
return shifted + eigenvector * torch.dot(eigenvector, vector)
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def _conjugate_gradient_solve(
|
|
842
|
+
apply_operator,
|
|
843
|
+
rhs: torch.Tensor,
|
|
844
|
+
*,
|
|
845
|
+
tol: float,
|
|
846
|
+
max_iter: int,
|
|
847
|
+
) -> torch.Tensor:
|
|
848
|
+
x = torch.zeros_like(rhs)
|
|
849
|
+
r = rhs.clone()
|
|
850
|
+
p = r.clone()
|
|
851
|
+
rr = torch.dot(r, r)
|
|
852
|
+
rhs_norm = torch.sqrt(rr)
|
|
853
|
+
threshold = max(float(rhs_norm.item()) * float(tol), float(tol))
|
|
854
|
+
if float(rhs_norm.item()) <= threshold:
|
|
855
|
+
return x
|
|
856
|
+
|
|
857
|
+
for _ in range(int(max_iter)):
|
|
858
|
+
ap = apply_operator(p)
|
|
859
|
+
denom = torch.dot(p, ap)
|
|
860
|
+
if abs(float(denom.item())) <= 1.0e-30:
|
|
861
|
+
break
|
|
862
|
+
alpha = rr / denom
|
|
863
|
+
x = x + alpha * p
|
|
864
|
+
r = r - alpha * ap
|
|
865
|
+
r_norm = torch.linalg.vector_norm(r)
|
|
866
|
+
if float(r_norm.item()) <= threshold:
|
|
867
|
+
return x
|
|
868
|
+
rr_new = torch.dot(r, r)
|
|
869
|
+
beta = rr_new / rr
|
|
870
|
+
p = r + beta * p
|
|
871
|
+
rr = rr_new
|
|
872
|
+
return x
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
class _SparseModeEigenpairFunction(torch.autograd.Function):
|
|
876
|
+
@staticmethod
|
|
877
|
+
def forward(ctx, index_sq: torch.Tensor, du: float, dv: float, mode_index: int):
|
|
878
|
+
operator, interior_u, interior_v = _build_scalar_operator_torch_sparse(index_sq, float(du), float(dv))
|
|
879
|
+
beta_sq, eigenvector = _solve_mode_eigenpair_torch_sparse(
|
|
880
|
+
operator,
|
|
881
|
+
interior_u=interior_u,
|
|
882
|
+
interior_v=interior_v,
|
|
883
|
+
mode_index=int(mode_index),
|
|
884
|
+
)
|
|
885
|
+
eigenvector = _orient_unit_eigenvector(eigenvector.to(dtype=index_sq.dtype))
|
|
886
|
+
beta_sq = beta_sq.to(device=index_sq.device, dtype=index_sq.dtype)
|
|
887
|
+
ctx.du = float(du)
|
|
888
|
+
ctx.dv = float(dv)
|
|
889
|
+
ctx.save_for_backward(index_sq.detach(), beta_sq.detach(), eigenvector.detach())
|
|
890
|
+
return beta_sq, eigenvector
|
|
891
|
+
|
|
892
|
+
@staticmethod
|
|
893
|
+
def backward(ctx, grad_beta_sq, grad_eigenvector):
|
|
894
|
+
if not ctx.needs_input_grad[0]:
|
|
895
|
+
return None, None, None, None
|
|
896
|
+
|
|
897
|
+
index_sq, beta_sq, eigenvector = ctx.saved_tensors
|
|
898
|
+
operator, interior_u, interior_v = _build_scalar_operator_torch_sparse(index_sq, ctx.du, ctx.dv)
|
|
899
|
+
|
|
900
|
+
gradient_vector = torch.zeros_like(eigenvector)
|
|
901
|
+
if grad_eigenvector is not None:
|
|
902
|
+
gradient_vector = grad_eigenvector.to(device=eigenvector.device, dtype=eigenvector.dtype)
|
|
903
|
+
gradient_vector = _project_orthogonal(gradient_vector, eigenvector)
|
|
904
|
+
|
|
905
|
+
if float(torch.linalg.vector_norm(gradient_vector).item()) > 0.0:
|
|
906
|
+
solve_rhs = gradient_vector
|
|
907
|
+
solve_y = _conjugate_gradient_solve(
|
|
908
|
+
lambda vector: _apply_shifted_square_operator(
|
|
909
|
+
operator,
|
|
910
|
+
vector,
|
|
911
|
+
eigenvalue=beta_sq,
|
|
912
|
+
eigenvector=eigenvector,
|
|
913
|
+
),
|
|
914
|
+
solve_rhs,
|
|
915
|
+
tol=_IMPLICIT_EIGEN_CG_TOL,
|
|
916
|
+
max_iter=_IMPLICIT_EIGEN_CG_MAX_ITER,
|
|
917
|
+
)
|
|
918
|
+
implicit_vector = beta_sq * solve_y - _sparse_matvec(operator, solve_y)
|
|
919
|
+
else:
|
|
920
|
+
implicit_vector = torch.zeros_like(eigenvector)
|
|
921
|
+
|
|
922
|
+
grad_lambda = torch.zeros((), device=eigenvector.device, dtype=eigenvector.dtype)
|
|
923
|
+
if grad_beta_sq is not None:
|
|
924
|
+
grad_lambda = grad_beta_sq.to(device=eigenvector.device, dtype=eigenvector.dtype)
|
|
925
|
+
|
|
926
|
+
interior_grad = grad_lambda * (eigenvector * eigenvector) + eigenvector * implicit_vector
|
|
927
|
+
grad_index_sq = torch.zeros_like(index_sq)
|
|
928
|
+
grad_index_sq[1:-1, 1:-1] = interior_grad.reshape((interior_u, interior_v))
|
|
929
|
+
return grad_index_sq, None, None, None
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def _solve_mode_eigenpair_torch_sparse_implicit(index_sq: torch.Tensor, *, du: float, dv: float, mode_index: int):
|
|
933
|
+
return _SparseModeEigenpairFunction.apply(index_sq, float(du), float(dv), int(mode_index))
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def _solve_vector_mode_eigenpair_sparse(
|
|
937
|
+
eps_planes,
|
|
938
|
+
mu_planes,
|
|
939
|
+
*,
|
|
940
|
+
k0: float,
|
|
941
|
+
du: float,
|
|
942
|
+
dv: float,
|
|
943
|
+
mode_index: int,
|
|
944
|
+
field_names,
|
|
945
|
+
preferred_field_name: str,
|
|
946
|
+
):
|
|
947
|
+
operator, interior_u, interior_v = _build_vector_operator_sparse(eps_planes, mu_planes, k0=k0, du=du, dv=dv)
|
|
948
|
+
matrix_size = int(operator.shape[0])
|
|
949
|
+
requested = _vector_mode_request_count(matrix_size, mode_index=int(mode_index))
|
|
950
|
+
eigenvalues, eigenvectors = scipy_sparse_linalg.eigs(
|
|
951
|
+
operator,
|
|
952
|
+
k=requested,
|
|
953
|
+
which="LR",
|
|
954
|
+
tol=_VECTOR_EIGS_TOL,
|
|
955
|
+
maxiter=_VECTOR_EIGS_MAX_ITER,
|
|
956
|
+
)
|
|
957
|
+
return _select_and_normalize_vector_mode_numpy(
|
|
958
|
+
eigenvalues,
|
|
959
|
+
eigenvectors,
|
|
960
|
+
interior_u=interior_u,
|
|
961
|
+
interior_v=interior_v,
|
|
962
|
+
mode_index=int(mode_index),
|
|
963
|
+
field_names=field_names,
|
|
964
|
+
preferred_field_name=preferred_field_name,
|
|
965
|
+
)
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def _solve_vector_mode_eigenpair_dense(
|
|
969
|
+
eps_planes,
|
|
970
|
+
mu_planes,
|
|
971
|
+
*,
|
|
972
|
+
k0: float,
|
|
973
|
+
du: float,
|
|
974
|
+
dv: float,
|
|
975
|
+
mode_index: int,
|
|
976
|
+
field_names,
|
|
977
|
+
preferred_field_name: str,
|
|
978
|
+
):
|
|
979
|
+
operator, interior_u, interior_v = _build_vector_operator_sparse(eps_planes, mu_planes, k0=k0, du=du, dv=dv)
|
|
980
|
+
dense = operator.toarray()
|
|
981
|
+
eigenvalues, eigenvectors = scipy_linalg.eig(dense)
|
|
982
|
+
return _select_and_normalize_vector_mode_numpy(
|
|
983
|
+
eigenvalues,
|
|
984
|
+
eigenvectors,
|
|
985
|
+
interior_u=interior_u,
|
|
986
|
+
interior_v=interior_v,
|
|
987
|
+
mode_index=int(mode_index),
|
|
988
|
+
field_names=field_names,
|
|
989
|
+
preferred_field_name=preferred_field_name,
|
|
990
|
+
)
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
def _mode_slice(tensor: torch.Tensor, *, axis: str, plane_index: int, tangential_bounds):
|
|
994
|
+
(u_lo, u_hi), (v_lo, v_hi) = tangential_bounds
|
|
995
|
+
if axis == "x":
|
|
996
|
+
return tensor[plane_index, u_lo : u_hi + 1, v_lo : v_hi + 1]
|
|
997
|
+
if axis == "y":
|
|
998
|
+
return tensor[u_lo : u_hi + 1, plane_index, v_lo : v_hi + 1]
|
|
999
|
+
return tensor[u_lo : u_hi + 1, v_lo : v_hi + 1, plane_index]
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def _bend_conformal_factor(source, *, tangential_axes, tangential_bounds, coord_map):
|
|
1003
|
+
"""Heiblum-Harris conformal index factor over a bent-port mode plane.
|
|
1004
|
+
|
|
1005
|
+
Returns ``None`` for a straight port. A curved (bent) waveguide keeps its
|
|
1006
|
+
cross-section plane axis-aligned but its guided mode is solved on the
|
|
1007
|
+
equivalent straight guide produced by the conformal map: for a bend of signed
|
|
1008
|
+
radius ``R`` about the cylinder axis ``bend_axis``, the transformed
|
|
1009
|
+
permittivity is ``eps_eq(u, v) = eps(u, v) * (1 + r/R)**2`` where ``r`` is the
|
|
1010
|
+
signed offset of each node from the port centre along the in-plane radial axis
|
|
1011
|
+
(the tangential axis that is not the cylinder axis). ``R > 0`` grades the index
|
|
1012
|
+
up on the ``+r`` side, shifting the mode outward and raising the effective
|
|
1013
|
+
index, exactly as a physical bend does. The returned float64 tensor has shape
|
|
1014
|
+
``(nu, 1)`` or ``(1, nv)`` so it broadcasts against a mode-plane aperture slice.
|
|
1015
|
+
"""
|
|
1016
|
+
bend_radius = source.get("bend_radius")
|
|
1017
|
+
if bend_radius is None:
|
|
1018
|
+
return None
|
|
1019
|
+
bend_axis = str(source["bend_axis"])
|
|
1020
|
+
axis_u, axis_v = tangential_axes
|
|
1021
|
+
radial_is_u = axis_v == bend_axis
|
|
1022
|
+
radial_axis = axis_u if radial_is_u else axis_v
|
|
1023
|
+
(u_lo, u_hi), (v_lo, v_hi) = tangential_bounds
|
|
1024
|
+
lo, hi = (u_lo, u_hi) if radial_is_u else (v_lo, v_hi)
|
|
1025
|
+
coords_radial = coord_map[radial_axis][lo : hi + 1].to(dtype=torch.float64)
|
|
1026
|
+
radial_center = float(source["position"][_AXIS_TO_INDEX[radial_axis]])
|
|
1027
|
+
ratio = 1.0 + (coords_radial - radial_center) / float(bend_radius)
|
|
1028
|
+
if float(torch.min(ratio).item()) <= 0.0:
|
|
1029
|
+
raise ValueError(
|
|
1030
|
+
"Bent mode-plane aperture spans the bend centre of curvature "
|
|
1031
|
+
f"(1 + r/R <= 0 for bend_radius={float(bend_radius):g} about axis {bend_axis!r}); "
|
|
1032
|
+
"the conformal transform is singular there. Increase |bend_radius| or shrink the aperture."
|
|
1033
|
+
)
|
|
1034
|
+
factor_1d = ratio * ratio
|
|
1035
|
+
return factor_1d[:, None] if radial_is_u else factor_1d[None, :]
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
def _apply_bend_factor(tensor: torch.Tensor, factor) -> torch.Tensor:
|
|
1039
|
+
"""Multiply a mode-plane eps slice by the conformal bend factor (identity if None)."""
|
|
1040
|
+
if factor is None:
|
|
1041
|
+
return tensor
|
|
1042
|
+
real_dtype = torch.real(tensor).dtype if torch.is_complex(tensor) else tensor.dtype
|
|
1043
|
+
return tensor * factor.to(device=tensor.device, dtype=real_dtype)
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def _regular_grid_sample(coords: torch.Tensor, values: torch.Tensor):
|
|
1047
|
+
if values.numel() < 2:
|
|
1048
|
+
raise ValueError("ModeSource profile sampling requires at least two grid points per axis.")
|
|
1049
|
+
spacing = float(coords[1].item() - coords[0].item())
|
|
1050
|
+
if spacing <= 0.0:
|
|
1051
|
+
raise ValueError("ModeSource tangential coordinates must be strictly increasing.")
|
|
1052
|
+
return float(coords[0].item()), spacing
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
def _bilinear_sample(profile: torch.Tensor, coords_u: torch.Tensor, coords_v: torch.Tensor, positions: torch.Tensor):
|
|
1056
|
+
u_axis = int(profile.shape[0])
|
|
1057
|
+
v_axis = int(profile.shape[1])
|
|
1058
|
+
u0, du = _regular_grid_sample(coords_u, profile[:, 0])
|
|
1059
|
+
v0, dv = _regular_grid_sample(coords_v, profile[0, :])
|
|
1060
|
+
|
|
1061
|
+
pos_u = positions[..., 0]
|
|
1062
|
+
pos_v = positions[..., 1]
|
|
1063
|
+
mask = (
|
|
1064
|
+
(pos_u >= float(coords_u[0].item()))
|
|
1065
|
+
& (pos_u <= float(coords_u[-1].item()))
|
|
1066
|
+
& (pos_v >= float(coords_v[0].item()))
|
|
1067
|
+
& (pos_v <= float(coords_v[-1].item()))
|
|
1068
|
+
)
|
|
1069
|
+
|
|
1070
|
+
u_coord = torch.clamp((pos_u - u0) / du, min=0.0, max=max(u_axis - 1, 0))
|
|
1071
|
+
v_coord = torch.clamp((pos_v - v0) / dv, min=0.0, max=max(v_axis - 1, 0))
|
|
1072
|
+
u_lower = torch.floor(u_coord).to(torch.int64)
|
|
1073
|
+
v_lower = torch.floor(v_coord).to(torch.int64)
|
|
1074
|
+
u_upper = torch.clamp(u_lower + 1, max=u_axis - 1)
|
|
1075
|
+
v_upper = torch.clamp(v_lower + 1, max=v_axis - 1)
|
|
1076
|
+
u_frac = u_coord - u_lower.to(dtype=profile.dtype)
|
|
1077
|
+
v_frac = v_coord - v_lower.to(dtype=profile.dtype)
|
|
1078
|
+
|
|
1079
|
+
f00 = profile[u_lower, v_lower]
|
|
1080
|
+
f10 = profile[u_upper, v_lower]
|
|
1081
|
+
f01 = profile[u_lower, v_upper]
|
|
1082
|
+
f11 = profile[u_upper, v_upper]
|
|
1083
|
+
sampled = (
|
|
1084
|
+
(1.0 - u_frac) * (1.0 - v_frac) * f00
|
|
1085
|
+
+ u_frac * (1.0 - v_frac) * f10
|
|
1086
|
+
+ (1.0 - u_frac) * v_frac * f01
|
|
1087
|
+
+ u_frac * v_frac * f11
|
|
1088
|
+
)
|
|
1089
|
+
return torch.where(mask, sampled, torch.zeros_like(sampled))
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
def _mode_source_component_permittivity(solver, source, *, frequency: float) -> torch.Tensor:
|
|
1093
|
+
field_name = _mode_source_field_name(source)
|
|
1094
|
+
component_name = f"eps_{field_name}"
|
|
1095
|
+
field_tensor = getattr(solver, component_name, None)
|
|
1096
|
+
use_component_fields = bool(getattr(solver, "_mode_source_rebuild_from_fields", False))
|
|
1097
|
+
use_component_fields = use_component_fields or (
|
|
1098
|
+
isinstance(field_tensor, torch.Tensor) and bool(field_tensor.requires_grad)
|
|
1099
|
+
)
|
|
1100
|
+
|
|
1101
|
+
if use_component_fields:
|
|
1102
|
+
if field_tensor is None:
|
|
1103
|
+
raise RuntimeError(
|
|
1104
|
+
f"ModeSource differentiable rebuild requires {component_name} on the temporary solver context."
|
|
1105
|
+
)
|
|
1106
|
+
eps0 = float(getattr(solver, "eps0", VACUUM_PERMITTIVITY))
|
|
1107
|
+
return field_tensor / eps0
|
|
1108
|
+
|
|
1109
|
+
compiled_material_model = getattr(solver, "_compiled_material_model", None)
|
|
1110
|
+
if compiled_material_model is not None:
|
|
1111
|
+
# A diagonal-anisotropic aperture must be solved with the permittivity
|
|
1112
|
+
# component that matches the injected polarization (E_p sees eps_pp), the
|
|
1113
|
+
# same per-axis component the forward Yee update uses for that field
|
|
1114
|
+
# (solver.eps_E{p}). Averaging the three diagonal components would inject a
|
|
1115
|
+
# mode computed for an isotropic medium the forward solve never sees. For an
|
|
1116
|
+
# isotropic plane the three components coincide and this is unchanged.
|
|
1117
|
+
polarization_axis = field_name[1].lower()
|
|
1118
|
+
eps_components, _ = evaluate_material_components(compiled_material_model, float(frequency))
|
|
1119
|
+
return _average_node_tensor_to_component(eps_components[polarization_axis], field_name)
|
|
1120
|
+
|
|
1121
|
+
if field_tensor is None:
|
|
1122
|
+
raise RuntimeError(
|
|
1123
|
+
"ModeSource requires either solver._compiled_material_model or component permittivity tensors."
|
|
1124
|
+
)
|
|
1125
|
+
eps0 = float(getattr(solver, "eps0", VACUUM_PERMITTIVITY))
|
|
1126
|
+
return field_tensor / eps0
|
|
1127
|
+
|
|
1128
|
+
|
|
1129
|
+
def _normalize_profile_torch(profile: torch.Tensor) -> torch.Tensor:
|
|
1130
|
+
peak = torch.max(torch.abs(profile))
|
|
1131
|
+
if float(peak.item()) <= 0.0:
|
|
1132
|
+
raise RuntimeError("ModeSource eigenmode solve returned a zero profile.")
|
|
1133
|
+
normalized = profile / peak
|
|
1134
|
+
peak_index = int(torch.argmax(torch.abs(normalized).reshape(-1)).item())
|
|
1135
|
+
if float(normalized.reshape(-1)[peak_index].item()) < 0.0:
|
|
1136
|
+
normalized = -normalized
|
|
1137
|
+
return normalized
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
def _vector_mode_supported(
|
|
1141
|
+
solver,
|
|
1142
|
+
*,
|
|
1143
|
+
eps_by_axis: dict,
|
|
1144
|
+
mu_by_axis: dict,
|
|
1145
|
+
unknowns: int,
|
|
1146
|
+
) -> bool:
|
|
1147
|
+
if getattr(solver, "_mode_source_rebuild_from_fields", False):
|
|
1148
|
+
return False
|
|
1149
|
+
if getattr(solver, "_compiled_material_model", None) is None:
|
|
1150
|
+
return False
|
|
1151
|
+
if any(component.requires_grad for component in eps_by_axis.values()):
|
|
1152
|
+
return False
|
|
1153
|
+
if any(component.requires_grad for component in mu_by_axis.values()):
|
|
1154
|
+
return False
|
|
1155
|
+
if unknowns <= 0:
|
|
1156
|
+
return False
|
|
1157
|
+
return True
|
|
1158
|
+
|
|
1159
|
+
|
|
1160
|
+
def _real_plane_numpy(component: torch.Tensor) -> np.ndarray:
|
|
1161
|
+
real = torch.real(component) if torch.is_complex(component) else component
|
|
1162
|
+
return real.detach().cpu().numpy().astype(np.float64, copy=False)
|
|
1163
|
+
|
|
1164
|
+
|
|
1165
|
+
def _assemble_vector_mode_data(
|
|
1166
|
+
solver,
|
|
1167
|
+
source,
|
|
1168
|
+
*,
|
|
1169
|
+
normal_axis: str,
|
|
1170
|
+
tangential_axes,
|
|
1171
|
+
tangential_bounds,
|
|
1172
|
+
tangential_coord_map,
|
|
1173
|
+
plane_index: int,
|
|
1174
|
+
eps_by_axis: dict,
|
|
1175
|
+
mu_by_axis: dict,
|
|
1176
|
+
frequency: float,
|
|
1177
|
+
):
|
|
1178
|
+
axis_u, axis_v = tangential_axes
|
|
1179
|
+
field_names = (
|
|
1180
|
+
f"E{axis_u.lower()}",
|
|
1181
|
+
f"E{axis_v.lower()}",
|
|
1182
|
+
f"H{axis_u.lower()}",
|
|
1183
|
+
f"H{axis_v.lower()}",
|
|
1184
|
+
)
|
|
1185
|
+
preferred_field_name = _vector_preferred_component_name(source)
|
|
1186
|
+
if preferred_field_name not in field_names:
|
|
1187
|
+
raise ValueError("ModeSource polarization must be tangential to the source plane.")
|
|
1188
|
+
|
|
1189
|
+
(u_lo, u_hi), (v_lo, v_hi) = tangential_bounds
|
|
1190
|
+
bend_factor = _bend_conformal_factor(
|
|
1191
|
+
source,
|
|
1192
|
+
tangential_axes=tangential_axes,
|
|
1193
|
+
tangential_bounds=tangential_bounds,
|
|
1194
|
+
coord_map=tangential_coord_map,
|
|
1195
|
+
)
|
|
1196
|
+
if bend_factor is not None:
|
|
1197
|
+
eps_by_axis = {axis: _apply_bend_factor(component, bend_factor) for axis, component in eps_by_axis.items()}
|
|
1198
|
+
du = _local_uniform_plane_spacing(solver.scene, axis_u, u_lo, u_hi)
|
|
1199
|
+
dv = _local_uniform_plane_spacing(solver.scene, axis_v, v_lo, v_hi)
|
|
1200
|
+
k0 = 2.0 * math.pi * float(frequency) / float(solver.c)
|
|
1201
|
+
# Order the diagonal components as (in-plane u, in-plane v, plane-normal w) so
|
|
1202
|
+
# the vector operator threads eps_ww/mu_ww through the eliminated longitudinal
|
|
1203
|
+
# fields and eps_uu/eps_vv (mu_uu/mu_vv) through the transverse self-terms.
|
|
1204
|
+
eps_planes = (
|
|
1205
|
+
_real_plane_numpy(eps_by_axis[axis_u]),
|
|
1206
|
+
_real_plane_numpy(eps_by_axis[axis_v]),
|
|
1207
|
+
_real_plane_numpy(eps_by_axis[normal_axis]),
|
|
1208
|
+
)
|
|
1209
|
+
mu_planes = (
|
|
1210
|
+
_real_plane_numpy(mu_by_axis[axis_u]),
|
|
1211
|
+
_real_plane_numpy(mu_by_axis[axis_v]),
|
|
1212
|
+
_real_plane_numpy(mu_by_axis[normal_axis]),
|
|
1213
|
+
)
|
|
1214
|
+
|
|
1215
|
+
unknowns = max((int(eps_planes[0].shape[0]) - 2) * (int(eps_planes[0].shape[1]) - 2), 0)
|
|
1216
|
+
if unknowns <= _FULL_VECTOR_DENSE_LIMIT:
|
|
1217
|
+
beta_value, _eigenvector, component_arrays = _solve_vector_mode_eigenpair_dense(
|
|
1218
|
+
eps_planes,
|
|
1219
|
+
mu_planes,
|
|
1220
|
+
k0=k0,
|
|
1221
|
+
du=du,
|
|
1222
|
+
dv=dv,
|
|
1223
|
+
mode_index=int(source["mode_index"]),
|
|
1224
|
+
field_names=field_names,
|
|
1225
|
+
preferred_field_name=preferred_field_name,
|
|
1226
|
+
)
|
|
1227
|
+
solver_kind = "vector_dense"
|
|
1228
|
+
else:
|
|
1229
|
+
beta_value, _eigenvector, component_arrays = _solve_vector_mode_eigenpair_sparse(
|
|
1230
|
+
eps_planes,
|
|
1231
|
+
mu_planes,
|
|
1232
|
+
k0=k0,
|
|
1233
|
+
du=du,
|
|
1234
|
+
dv=dv,
|
|
1235
|
+
mode_index=int(source["mode_index"]),
|
|
1236
|
+
field_names=field_names,
|
|
1237
|
+
preferred_field_name=preferred_field_name,
|
|
1238
|
+
)
|
|
1239
|
+
solver_kind = "vector_sparse"
|
|
1240
|
+
|
|
1241
|
+
beta_real = float(np.real(beta_value))
|
|
1242
|
+
effective_index_value = beta_real / max(k0, 1e-30)
|
|
1243
|
+
target_device = torch.device(getattr(solver, "device", solver.scene.device))
|
|
1244
|
+
target_dtype = solver.Ex.dtype
|
|
1245
|
+
component_profiles = {}
|
|
1246
|
+
for name, array in component_arrays.items():
|
|
1247
|
+
resolved_array = np.asarray(array)
|
|
1248
|
+
if np.iscomplexobj(resolved_array):
|
|
1249
|
+
imag_max = float(np.max(np.abs(np.imag(resolved_array))))
|
|
1250
|
+
if imag_max > 1e-6:
|
|
1251
|
+
raise RuntimeError("Full-vector ModeSource forward solve returned a materially complex field profile.")
|
|
1252
|
+
resolved_array = np.real(resolved_array)
|
|
1253
|
+
component_profiles[name] = torch.as_tensor(
|
|
1254
|
+
resolved_array,
|
|
1255
|
+
device=target_device,
|
|
1256
|
+
dtype=target_dtype,
|
|
1257
|
+
).contiguous()
|
|
1258
|
+
reference_profile = next(iter(component_profiles.values()))
|
|
1259
|
+
for field_name in ("Ex", "Ey", "Ez", "Hx", "Hy", "Hz"):
|
|
1260
|
+
component_profiles.setdefault(field_name, torch.zeros_like(reference_profile))
|
|
1261
|
+
if int(source["direction_sign"]) < 0:
|
|
1262
|
+
for field_name in field_names[2:]:
|
|
1263
|
+
component_profiles[field_name] = -component_profiles[field_name]
|
|
1264
|
+
component_profiles = _normalize_mode_profiles_to_unit_power(
|
|
1265
|
+
component_profiles,
|
|
1266
|
+
coords_u=tangential_coord_map[axis_u][u_lo : u_hi + 1].to(
|
|
1267
|
+
device=target_device, dtype=target_dtype
|
|
1268
|
+
),
|
|
1269
|
+
coords_v=tangential_coord_map[axis_v][v_lo : v_hi + 1].to(
|
|
1270
|
+
device=target_device, dtype=target_dtype
|
|
1271
|
+
),
|
|
1272
|
+
normal_axis=normal_axis,
|
|
1273
|
+
)
|
|
1274
|
+
|
|
1275
|
+
lower = [0, 0, 0]
|
|
1276
|
+
upper = [0, 0, 0]
|
|
1277
|
+
normal_axis_index = _AXIS_TO_INDEX[normal_axis]
|
|
1278
|
+
lower[_AXIS_TO_INDEX[axis_u]] = int(u_lo)
|
|
1279
|
+
upper[_AXIS_TO_INDEX[axis_u]] = int(u_hi)
|
|
1280
|
+
lower[_AXIS_TO_INDEX[axis_v]] = int(v_lo)
|
|
1281
|
+
upper[_AXIS_TO_INDEX[axis_v]] = int(v_hi)
|
|
1282
|
+
if int(source["direction_sign"]) > 0:
|
|
1283
|
+
lower[normal_axis_index] = int(plane_index)
|
|
1284
|
+
upper[normal_axis_index] = int(plane_index) + 1
|
|
1285
|
+
else:
|
|
1286
|
+
lower[normal_axis_index] = int(plane_index) - 1
|
|
1287
|
+
upper[normal_axis_index] = int(plane_index)
|
|
1288
|
+
|
|
1289
|
+
return {
|
|
1290
|
+
"normal_axis": normal_axis,
|
|
1291
|
+
"tangential_axes": tangential_axes,
|
|
1292
|
+
"coords_u": tangential_coord_map[axis_u][u_lo : u_hi + 1].to(device=target_device, dtype=target_dtype).contiguous(),
|
|
1293
|
+
"coords_v": tangential_coord_map[axis_v][v_lo : v_hi + 1].to(device=target_device, dtype=target_dtype).contiguous(),
|
|
1294
|
+
"profile": component_profiles[preferred_field_name],
|
|
1295
|
+
"profile_component": preferred_field_name,
|
|
1296
|
+
"component_profiles": component_profiles,
|
|
1297
|
+
"mode_solver_kind": solver_kind,
|
|
1298
|
+
"effective_index": float(effective_index_value),
|
|
1299
|
+
"beta": float(beta_real),
|
|
1300
|
+
"effective_index_tensor": None,
|
|
1301
|
+
"beta_tensor": None,
|
|
1302
|
+
"plane_index": int(plane_index),
|
|
1303
|
+
"box_lower": tuple(int(value) for value in lower),
|
|
1304
|
+
"box_upper": tuple(int(value) for value in upper),
|
|
1305
|
+
"bend_radius": source.get("bend_radius"),
|
|
1306
|
+
"bend_axis": source.get("bend_axis"),
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
|
|
1310
|
+
def solve_mode_source_profile(solver, source) -> dict[str, object]:
|
|
1311
|
+
if source.get("injection", {}).get("kind") != "soft":
|
|
1312
|
+
raise ValueError("ModeSource currently supports soft injection only.")
|
|
1313
|
+
source_time = source["source_time"]
|
|
1314
|
+
if source_time["kind"] == "custom":
|
|
1315
|
+
raise ValueError(
|
|
1316
|
+
"ModeSource does not support CustomSourceTime; the native time-shifted surface "
|
|
1317
|
+
"kernel evaluates only the analytic CW/Gaussian/Ricker forms. Use PointDipole "
|
|
1318
|
+
"for arbitrary custom waveforms."
|
|
1319
|
+
)
|
|
1320
|
+
if solver.scene.boundary.uses_kind("periodic") or solver.scene.boundary.uses_kind("bloch"):
|
|
1321
|
+
raise NotImplementedError(
|
|
1322
|
+
"ModeSource mode solving is undefined under a periodic/Bloch transverse boundary: "
|
|
1323
|
+
"the aperture wraps with the Bloch phase, so the guided profile is an eigenfunction "
|
|
1324
|
+
"of a complex-Hermitian Bloch-phase transverse operator, whereas this solver builds "
|
|
1325
|
+
"the real, zero-boundary transverse operator. Use none/pml/pec/pmc on the mode plane."
|
|
1326
|
+
)
|
|
1327
|
+
|
|
1328
|
+
normal_axis, tangential_axes = _mode_source_axes(source)
|
|
1329
|
+
normal_axis_index = _AXIS_TO_INDEX[normal_axis]
|
|
1330
|
+
plane_index = nearest_index(getattr(solver.scene, normal_axis), float(source["position"][normal_axis_index]))
|
|
1331
|
+
_validate_mode_source_position(solver.scene, source, plane_index=plane_index)
|
|
1332
|
+
|
|
1333
|
+
field_name = _mode_source_field_name(source)
|
|
1334
|
+
frequency = float(source_time["frequency"])
|
|
1335
|
+
|
|
1336
|
+
if (
|
|
1337
|
+
getattr(solver, "_compiled_material_model", None) is not None
|
|
1338
|
+
and not getattr(solver, "_mode_source_rebuild_from_fields", False)
|
|
1339
|
+
):
|
|
1340
|
+
node_coord_map = {
|
|
1341
|
+
axis: _mode_source_node_axis_coords(solver.scene, axis)
|
|
1342
|
+
for axis in tangential_axes
|
|
1343
|
+
}
|
|
1344
|
+
node_tangential_bounds, tangential_axes = _resolve_tangential_bounds(
|
|
1345
|
+
solver.scene,
|
|
1346
|
+
source,
|
|
1347
|
+
axis_coords_by_axis=node_coord_map,
|
|
1348
|
+
)
|
|
1349
|
+
eps_node_by_axis, mu_node_by_axis = _mode_source_relative_material_slices(
|
|
1350
|
+
solver,
|
|
1351
|
+
frequency=frequency,
|
|
1352
|
+
normal_axis=normal_axis,
|
|
1353
|
+
plane_index=plane_index,
|
|
1354
|
+
tangential_bounds=node_tangential_bounds,
|
|
1355
|
+
)
|
|
1356
|
+
eps_node_imag = _max_component_imag(eps_node_by_axis)
|
|
1357
|
+
mu_node_imag = _max_component_imag(mu_node_by_axis)
|
|
1358
|
+
# A lossy (complex) permittivity makes the full-vector operator non-Hermitian and
|
|
1359
|
+
# its forward path solves only the real part, which would silently drop the loss.
|
|
1360
|
+
# Route those planes to the scalar complex-mode solve below, which resolves the
|
|
1361
|
+
# complex propagation constant directly.
|
|
1362
|
+
if eps_node_imag <= 1e-7 and mu_node_imag <= 1e-7:
|
|
1363
|
+
if _min_component_real(eps_node_by_axis) <= 0.0 or _min_component_real(mu_node_by_axis) <= 0.0:
|
|
1364
|
+
raise ValueError("ModeSource requires positive epsilon and mu on the source plane.")
|
|
1365
|
+
reference_slice = eps_node_by_axis[normal_axis]
|
|
1366
|
+
node_unknowns = max((int(reference_slice.shape[0]) - 2) * (int(reference_slice.shape[1]) - 2), 0)
|
|
1367
|
+
if _vector_mode_supported(
|
|
1368
|
+
solver,
|
|
1369
|
+
eps_by_axis=eps_node_by_axis,
|
|
1370
|
+
mu_by_axis=mu_node_by_axis,
|
|
1371
|
+
unknowns=node_unknowns,
|
|
1372
|
+
):
|
|
1373
|
+
return _assemble_vector_mode_data(
|
|
1374
|
+
solver,
|
|
1375
|
+
source,
|
|
1376
|
+
normal_axis=normal_axis,
|
|
1377
|
+
tangential_axes=tangential_axes,
|
|
1378
|
+
tangential_bounds=node_tangential_bounds,
|
|
1379
|
+
tangential_coord_map=node_coord_map,
|
|
1380
|
+
plane_index=plane_index,
|
|
1381
|
+
eps_by_axis=eps_node_by_axis,
|
|
1382
|
+
mu_by_axis=mu_node_by_axis,
|
|
1383
|
+
frequency=frequency,
|
|
1384
|
+
)
|
|
1385
|
+
|
|
1386
|
+
tangential_coord_map = {
|
|
1387
|
+
axis: _field_component_axis_coords(solver.scene, field_name, axis)
|
|
1388
|
+
for axis in tangential_axes
|
|
1389
|
+
}
|
|
1390
|
+
tangential_bounds, tangential_axes = _resolve_tangential_bounds(
|
|
1391
|
+
solver.scene,
|
|
1392
|
+
source,
|
|
1393
|
+
axis_coords_by_axis=tangential_coord_map,
|
|
1394
|
+
)
|
|
1395
|
+
|
|
1396
|
+
eps_component = _mode_source_component_permittivity(solver, source, frequency=frequency)
|
|
1397
|
+
eps_slice = _mode_slice(
|
|
1398
|
+
eps_component,
|
|
1399
|
+
axis=normal_axis,
|
|
1400
|
+
plane_index=plane_index,
|
|
1401
|
+
tangential_bounds=tangential_bounds,
|
|
1402
|
+
)
|
|
1403
|
+
bend_factor = _bend_conformal_factor(
|
|
1404
|
+
source,
|
|
1405
|
+
tangential_axes=tangential_axes,
|
|
1406
|
+
tangential_bounds=tangential_bounds,
|
|
1407
|
+
coord_map=tangential_coord_map,
|
|
1408
|
+
)
|
|
1409
|
+
eps_slice = _apply_bend_factor(eps_slice, bend_factor)
|
|
1410
|
+
if (
|
|
1411
|
+
getattr(solver, "_compiled_material_model", None) is not None
|
|
1412
|
+
and not getattr(solver, "_mode_source_rebuild_from_fields", False)
|
|
1413
|
+
):
|
|
1414
|
+
mu_slice = _mode_slice(
|
|
1415
|
+
solver._compiled_material_model["mu_r"],
|
|
1416
|
+
axis=normal_axis,
|
|
1417
|
+
plane_index=plane_index,
|
|
1418
|
+
tangential_bounds=tangential_bounds,
|
|
1419
|
+
)
|
|
1420
|
+
else:
|
|
1421
|
+
mu_slice = torch.ones_like(eps_slice)
|
|
1422
|
+
|
|
1423
|
+
eps_max_imag = float(torch.max(torch.abs(torch.imag(eps_slice))).item()) if torch.is_complex(eps_slice) else 0.0
|
|
1424
|
+
mu_max_imag = float(torch.max(torch.abs(torch.imag(mu_slice))).item()) if torch.is_complex(mu_slice) else 0.0
|
|
1425
|
+
eps_is_lossy = eps_max_imag > 1e-7 or mu_max_imag > 1e-7
|
|
1426
|
+
|
|
1427
|
+
eps_real = torch.real(eps_slice) if torch.is_complex(eps_slice) else eps_slice
|
|
1428
|
+
mu_real = torch.real(mu_slice) if torch.is_complex(mu_slice) else mu_slice
|
|
1429
|
+
if torch.min(eps_real).item() <= 0.0 or torch.min(mu_real).item() <= 0.0:
|
|
1430
|
+
raise ValueError("ModeSource requires positive epsilon and mu on the source plane.")
|
|
1431
|
+
if not torch.allclose(mu_real, torch.ones_like(mu_real), atol=1e-6, rtol=0.0):
|
|
1432
|
+
raise NotImplementedError(
|
|
1433
|
+
"ModeSource scalar eigenmode solve is undefined for a magnetic (mu_r != 1) plane: "
|
|
1434
|
+
"the scalar Helmholtz reduction folds mu into a single index and scales the magnetic "
|
|
1435
|
+
"profile by n_eff/eta0, both of which assume a non-magnetic medium. A magnetic plane "
|
|
1436
|
+
"must be solved with the full-vector operator, which carries mu per component."
|
|
1437
|
+
)
|
|
1438
|
+
if eps_is_lossy and eps_real.requires_grad:
|
|
1439
|
+
raise NotImplementedError(
|
|
1440
|
+
"Differentiable ModeSource gradients through a lossy (complex) permittivity require a "
|
|
1441
|
+
"non-Hermitian eigen-adjoint; the differentiable path solves the Hermitian "
|
|
1442
|
+
"real-permittivity problem."
|
|
1443
|
+
)
|
|
1444
|
+
|
|
1445
|
+
axis_u, axis_v = tangential_axes
|
|
1446
|
+
(u_lo, u_hi), (v_lo, v_hi) = tangential_bounds
|
|
1447
|
+
coords_u = tangential_coord_map[axis_u][u_lo : u_hi + 1]
|
|
1448
|
+
coords_v = tangential_coord_map[axis_v][v_lo : v_hi + 1]
|
|
1449
|
+
du = _local_uniform_plane_spacing(solver.scene, axis_u, u_lo, u_hi)
|
|
1450
|
+
dv = _local_uniform_plane_spacing(solver.scene, axis_v, v_lo, v_hi)
|
|
1451
|
+
k0 = 2.0 * math.pi * frequency / float(solver.c)
|
|
1452
|
+
unknowns = max((int(eps_real.shape[0]) - 2) * (int(eps_real.shape[1]) - 2), 0)
|
|
1453
|
+
|
|
1454
|
+
effective_index_complex = None
|
|
1455
|
+
beta_complex = None
|
|
1456
|
+
if eps_is_lossy:
|
|
1457
|
+
eps_complex_np = eps_slice.detach().cpu().numpy().astype(np.complex128, copy=False)
|
|
1458
|
+
mu_complex_np = (
|
|
1459
|
+
mu_slice.detach().cpu().numpy().astype(np.complex128, copy=False)
|
|
1460
|
+
if torch.is_complex(mu_slice)
|
|
1461
|
+
else mu_real.detach().cpu().numpy().astype(np.complex128, copy=False)
|
|
1462
|
+
)
|
|
1463
|
+
index_sq_complex = (k0 * k0) * (eps_complex_np * mu_complex_np)
|
|
1464
|
+
operator, interior_u, interior_v = _build_scalar_operator(index_sq_complex, du, dv)
|
|
1465
|
+
beta_sq_complex, eigenvector = _solve_mode_eigenpair_complex(operator, mode_index=int(source["mode_index"]))
|
|
1466
|
+
profile_np = _real_profile_from_complex_eigenvector(
|
|
1467
|
+
eigenvector,
|
|
1468
|
+
interior_u=interior_u,
|
|
1469
|
+
interior_v=interior_v,
|
|
1470
|
+
shape=tuple(int(dim) for dim in eps_real.shape),
|
|
1471
|
+
)
|
|
1472
|
+
profile = torch.as_tensor(profile_np, device=eps_real.device, dtype=torch.float64)
|
|
1473
|
+
beta_complex = complex(np.sqrt(beta_sq_complex))
|
|
1474
|
+
if beta_complex.real < 0.0:
|
|
1475
|
+
beta_complex = -beta_complex
|
|
1476
|
+
effective_index_complex = beta_complex / max(k0, 1e-30)
|
|
1477
|
+
beta_value = float(beta_complex.real)
|
|
1478
|
+
effective_index_value = float(effective_index_complex.real)
|
|
1479
|
+
beta_tensor = None
|
|
1480
|
+
effective_index_tensor = None
|
|
1481
|
+
scalar_solver_kind = "scalar_complex_dense" if unknowns <= _DENSE_EIGEN_LIMIT else "scalar_complex_sparse"
|
|
1482
|
+
elif eps_real.requires_grad:
|
|
1483
|
+
index_sq = ((k0 * k0) * (eps_real * mu_real)).to(dtype=torch.float64)
|
|
1484
|
+
if unknowns > _DENSE_EIGEN_LIMIT:
|
|
1485
|
+
beta_sq, eigenvector = _solve_mode_eigenpair_torch_sparse_implicit(
|
|
1486
|
+
index_sq,
|
|
1487
|
+
du=du,
|
|
1488
|
+
dv=dv,
|
|
1489
|
+
mode_index=int(source["mode_index"]),
|
|
1490
|
+
)
|
|
1491
|
+
interior_u = int(index_sq.shape[0]) - 2
|
|
1492
|
+
interior_v = int(index_sq.shape[1]) - 2
|
|
1493
|
+
else:
|
|
1494
|
+
operator, interior_u, interior_v = _build_scalar_operator_torch_dense(index_sq, du, dv)
|
|
1495
|
+
beta_sq, eigenvector = _solve_mode_eigenpair_torch(operator, mode_index=int(source["mode_index"]))
|
|
1496
|
+
profile = torch.zeros_like(index_sq)
|
|
1497
|
+
profile[1:-1, 1:-1] = eigenvector.reshape((interior_u, interior_v))
|
|
1498
|
+
profile = _normalize_profile_torch(profile)
|
|
1499
|
+
beta_tensor = torch.sqrt(torch.clamp(beta_sq, min=0.0))
|
|
1500
|
+
effective_index_tensor = beta_tensor / max(k0, 1e-30)
|
|
1501
|
+
beta_value = float(beta_tensor.detach().item())
|
|
1502
|
+
effective_index_value = float(effective_index_tensor.detach().item())
|
|
1503
|
+
else:
|
|
1504
|
+
index_sq_torch = ((k0 * k0) * (eps_real * mu_real)).detach().to(dtype=torch.float64)
|
|
1505
|
+
if unknowns > _DENSE_EIGEN_LIMIT:
|
|
1506
|
+
operator, interior_u, interior_v = _build_scalar_operator_torch_sparse(index_sq_torch, du, dv)
|
|
1507
|
+
beta_sq_tensor, eigenvector = _solve_mode_eigenpair_torch_sparse(
|
|
1508
|
+
operator,
|
|
1509
|
+
interior_u=interior_u,
|
|
1510
|
+
interior_v=interior_v,
|
|
1511
|
+
mode_index=int(source["mode_index"]),
|
|
1512
|
+
)
|
|
1513
|
+
profile = torch.zeros_like(index_sq_torch)
|
|
1514
|
+
profile[1:-1, 1:-1] = eigenvector.reshape((interior_u, interior_v))
|
|
1515
|
+
profile = _normalize_profile_torch(profile)
|
|
1516
|
+
beta_value = math.sqrt(max(float(beta_sq_tensor.detach().item()), 0.0))
|
|
1517
|
+
scalar_solver_kind = "scalar_sparse"
|
|
1518
|
+
else:
|
|
1519
|
+
index_sq = index_sq_torch.cpu().numpy().astype(np.float64, copy=False)
|
|
1520
|
+
operator, interior_u, interior_v = _build_scalar_operator(index_sq, du, dv)
|
|
1521
|
+
beta_sq_value, eigenvector = _solve_mode_eigenpair(operator, mode_index=int(source["mode_index"]))
|
|
1522
|
+
profile_np = np.zeros_like(index_sq, dtype=np.float64)
|
|
1523
|
+
profile_np[1:-1, 1:-1] = eigenvector.reshape((interior_u, interior_v))
|
|
1524
|
+
peak = float(np.max(np.abs(profile_np)))
|
|
1525
|
+
if peak <= 0.0:
|
|
1526
|
+
raise RuntimeError("ModeSource eigenmode solve returned a zero profile.")
|
|
1527
|
+
profile_np /= peak
|
|
1528
|
+
peak_index = np.unravel_index(np.argmax(np.abs(profile_np)), profile_np.shape)
|
|
1529
|
+
if profile_np[peak_index] < 0.0:
|
|
1530
|
+
profile_np *= -1.0
|
|
1531
|
+
profile = torch.as_tensor(profile_np, device=eps_real.device, dtype=torch.float64)
|
|
1532
|
+
beta_value = math.sqrt(max(beta_sq_value, 0.0))
|
|
1533
|
+
scalar_solver_kind = "scalar_dense"
|
|
1534
|
+
effective_index_value = beta_value / max(k0, 1e-30)
|
|
1535
|
+
beta_tensor = None
|
|
1536
|
+
effective_index_tensor = None
|
|
1537
|
+
if not eps_is_lossy and eps_real.requires_grad:
|
|
1538
|
+
scalar_solver_kind = "scalar_sparse_implicit" if unknowns > _DENSE_EIGEN_LIMIT else "scalar_dense_torch"
|
|
1539
|
+
|
|
1540
|
+
direction_vector = tuple(float(component) for component in source["direction_vector"])
|
|
1541
|
+
electric_vector = {
|
|
1542
|
+
name: float(component)
|
|
1543
|
+
for name, component in zip(_FIELD_COMPONENTS, source["polarization"])
|
|
1544
|
+
}
|
|
1545
|
+
magnetic_tuple = tuple(-value for value in _cross(direction_vector, source["polarization"]))
|
|
1546
|
+
magnetic_scale = (
|
|
1547
|
+
effective_index_tensor / _ETA0
|
|
1548
|
+
if effective_index_tensor is not None
|
|
1549
|
+
else effective_index_value / _ETA0
|
|
1550
|
+
)
|
|
1551
|
+
|
|
1552
|
+
lower = [0, 0, 0]
|
|
1553
|
+
upper = [0, 0, 0]
|
|
1554
|
+
lower[_AXIS_TO_INDEX[axis_u]] = int(u_lo)
|
|
1555
|
+
upper[_AXIS_TO_INDEX[axis_u]] = int(u_hi)
|
|
1556
|
+
lower[_AXIS_TO_INDEX[axis_v]] = int(v_lo)
|
|
1557
|
+
upper[_AXIS_TO_INDEX[axis_v]] = int(v_hi)
|
|
1558
|
+
if int(source["direction_sign"]) > 0:
|
|
1559
|
+
lower[normal_axis_index] = int(plane_index)
|
|
1560
|
+
upper[normal_axis_index] = int(plane_index) + 1
|
|
1561
|
+
else:
|
|
1562
|
+
lower[normal_axis_index] = int(plane_index) - 1
|
|
1563
|
+
upper[normal_axis_index] = int(plane_index)
|
|
1564
|
+
|
|
1565
|
+
target_device = torch.device(getattr(solver, "device", solver.scene.device))
|
|
1566
|
+
target_dtype = solver.Ex.dtype
|
|
1567
|
+
resolved_magnetic_vector = {}
|
|
1568
|
+
for name, component in zip(("Hx", "Hy", "Hz"), magnetic_tuple):
|
|
1569
|
+
value = magnetic_scale * float(component)
|
|
1570
|
+
if isinstance(value, torch.Tensor):
|
|
1571
|
+
resolved_magnetic_vector[name] = value.to(device=target_device, dtype=target_dtype)
|
|
1572
|
+
else:
|
|
1573
|
+
resolved_magnetic_vector[name] = float(value)
|
|
1574
|
+
component_profiles = {
|
|
1575
|
+
name: torch.zeros_like(profile.to(device=target_device, dtype=target_dtype))
|
|
1576
|
+
for name in ("Ex", "Ey", "Ez", "Hx", "Hy", "Hz")
|
|
1577
|
+
}
|
|
1578
|
+
scalar_profile = profile.to(device=target_device, dtype=target_dtype).contiguous()
|
|
1579
|
+
for name, component in electric_vector.items():
|
|
1580
|
+
if abs(float(component)) > 1e-12:
|
|
1581
|
+
component_profiles[name] = float(component) * scalar_profile
|
|
1582
|
+
for name, component in resolved_magnetic_vector.items():
|
|
1583
|
+
if isinstance(component, torch.Tensor):
|
|
1584
|
+
component_profiles[name] = component * scalar_profile
|
|
1585
|
+
elif abs(float(component)) > 1e-12:
|
|
1586
|
+
component_profiles[name] = float(component) * scalar_profile
|
|
1587
|
+
|
|
1588
|
+
component_profiles = _normalize_mode_profiles_to_unit_power(
|
|
1589
|
+
component_profiles,
|
|
1590
|
+
coords_u=coords_u.to(device=target_device, dtype=target_dtype),
|
|
1591
|
+
coords_v=coords_v.to(device=target_device, dtype=target_dtype),
|
|
1592
|
+
normal_axis=normal_axis,
|
|
1593
|
+
)
|
|
1594
|
+
scalar_profile = component_profiles[field_name]
|
|
1595
|
+
|
|
1596
|
+
return {
|
|
1597
|
+
"normal_axis": normal_axis,
|
|
1598
|
+
"tangential_axes": tangential_axes,
|
|
1599
|
+
"coords_u": coords_u.to(device=target_device, dtype=target_dtype).contiguous(),
|
|
1600
|
+
"coords_v": coords_v.to(device=target_device, dtype=target_dtype).contiguous(),
|
|
1601
|
+
"profile": scalar_profile,
|
|
1602
|
+
"profile_component": field_name,
|
|
1603
|
+
"component_profiles": component_profiles,
|
|
1604
|
+
"electric_vector": electric_vector,
|
|
1605
|
+
"magnetic_vector": resolved_magnetic_vector,
|
|
1606
|
+
"mode_solver_kind": scalar_solver_kind,
|
|
1607
|
+
"effective_index": float(effective_index_value),
|
|
1608
|
+
"beta": float(beta_value),
|
|
1609
|
+
"effective_index_complex": None if effective_index_complex is None else complex(effective_index_complex),
|
|
1610
|
+
"beta_complex": None if beta_complex is None else complex(beta_complex),
|
|
1611
|
+
"effective_index_tensor": (
|
|
1612
|
+
None
|
|
1613
|
+
if effective_index_tensor is None
|
|
1614
|
+
else effective_index_tensor.to(device=target_device, dtype=target_dtype)
|
|
1615
|
+
),
|
|
1616
|
+
"beta_tensor": (
|
|
1617
|
+
None
|
|
1618
|
+
if beta_tensor is None
|
|
1619
|
+
else beta_tensor.to(device=target_device, dtype=target_dtype)
|
|
1620
|
+
),
|
|
1621
|
+
"plane_index": int(plane_index),
|
|
1622
|
+
"box_lower": tuple(int(value) for value in lower),
|
|
1623
|
+
"box_upper": tuple(int(value) for value in upper),
|
|
1624
|
+
"bend_radius": source.get("bend_radius"),
|
|
1625
|
+
"bend_axis": source.get("bend_axis"),
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
|
|
1629
|
+
def sample_mode_source_component(mode_data, positions: torch.Tensor, field_name: str) -> torch.Tensor:
|
|
1630
|
+
axis_u, axis_v = mode_data["tangential_axes"]
|
|
1631
|
+
sample_positions = torch.stack(
|
|
1632
|
+
(
|
|
1633
|
+
positions[..., _AXIS_TO_INDEX[axis_u]],
|
|
1634
|
+
positions[..., _AXIS_TO_INDEX[axis_v]],
|
|
1635
|
+
),
|
|
1636
|
+
dim=-1,
|
|
1637
|
+
)
|
|
1638
|
+
component_profiles = mode_data.get("component_profiles", {})
|
|
1639
|
+
profile = component_profiles.get(field_name)
|
|
1640
|
+
if profile is None:
|
|
1641
|
+
reference = mode_data["profile"]
|
|
1642
|
+
return torch.zeros_like(
|
|
1643
|
+
_bilinear_sample(
|
|
1644
|
+
reference,
|
|
1645
|
+
mode_data["coords_u"],
|
|
1646
|
+
mode_data["coords_v"],
|
|
1647
|
+
sample_positions,
|
|
1648
|
+
)
|
|
1649
|
+
)
|
|
1650
|
+
return _bilinear_sample(
|
|
1651
|
+
profile,
|
|
1652
|
+
mode_data["coords_u"],
|
|
1653
|
+
mode_data["coords_v"],
|
|
1654
|
+
sample_positions,
|
|
1655
|
+
)
|
|
1656
|
+
|
|
1657
|
+
|
|
1658
|
+
def sample_mode_source_profile(mode_data, positions: torch.Tensor) -> torch.Tensor:
|
|
1659
|
+
field_name = mode_data.get("profile_component")
|
|
1660
|
+
if field_name is None:
|
|
1661
|
+
axis_u, axis_v = mode_data["tangential_axes"]
|
|
1662
|
+
sample_positions = torch.stack(
|
|
1663
|
+
(
|
|
1664
|
+
positions[..., _AXIS_TO_INDEX[axis_u]],
|
|
1665
|
+
positions[..., _AXIS_TO_INDEX[axis_v]],
|
|
1666
|
+
),
|
|
1667
|
+
dim=-1,
|
|
1668
|
+
)
|
|
1669
|
+
return _bilinear_sample(
|
|
1670
|
+
mode_data["profile"],
|
|
1671
|
+
mode_data["coords_u"],
|
|
1672
|
+
mode_data["coords_v"],
|
|
1673
|
+
sample_positions,
|
|
1674
|
+
)
|
|
1675
|
+
return sample_mode_source_component(mode_data, positions, field_name)
|