witwin-maxwell 0.3.0__py3-none-win_amd64.whl

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