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,1102 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+
5
+ import numpy as np
6
+ import torch
7
+
8
+ from ...sources import POINT_DIPOLE_REFERENCE_WIDTH
9
+ from .spatial import (
10
+ beam_profile_from_source,
11
+ plane_center,
12
+ plane_wave_profile,
13
+ resolve_injection_axis,
14
+ soft_plane_wave_index,
15
+ soft_plane_wave_region_spacing,
16
+ source_plane_index,
17
+ )
18
+ from .modes import sample_mode_source_component, solve_mode_source_profile
19
+ from .temporal import append_source_term, apply_compiled_source_terms, apply_generic_source_terms
20
+ from .tfsf_common import build_term_from_profile, build_terms_from_specs, slice_coeff_patch, solve_numerical_wavenumber
21
+ from .tfsf_specs import E_CURL_ATTR, H_CURL_ATTR, build_discrete_tfsf_specs, magnetic_physical_vector
22
+
23
+
24
+ _FACE_SPEC_RANGES = {
25
+ ("x", "low"): slice(0, 2),
26
+ ("x", "high"): slice(2, 4),
27
+ ("y", "low"): slice(4, 6),
28
+ ("y", "high"): slice(6, 8),
29
+ ("z", "low"): slice(8, 10),
30
+ ("z", "high"): slice(10, 12),
31
+ }
32
+ _AXIS_TO_INDEX = {"x": 0, "y": 1, "z": 2}
33
+ _ETA0 = 376.730313668
34
+
35
+
36
+ def _normalized_point_dipole_profile(
37
+ dist_sq: torch.Tensor, width: float, control_volumes: torch.Tensor
38
+ ) -> torch.Tensor:
39
+ width_sq = 2.0 * float(width) ** 2
40
+ profile = torch.exp(-dist_sq / width_sq)
41
+ integrated_mass = torch.sum(profile * control_volumes)
42
+ return profile / torch.clamp(integrated_mass, min=torch.finfo(profile.dtype).eps)
43
+
44
+
45
+ def _sample_control_widths(coords: torch.Tensor) -> torch.Tensor:
46
+ count = int(coords.numel())
47
+ if count <= 1:
48
+ return torch.ones_like(coords)
49
+ diffs = coords[1:] - coords[:-1]
50
+ widths = torch.empty_like(coords)
51
+ widths[0] = 0.5 * diffs[0]
52
+ widths[-1] = 0.5 * diffs[-1]
53
+ if count > 2:
54
+ widths[1:-1] = 0.5 * (diffs[:-1] + diffs[1:])
55
+ return widths
56
+
57
+
58
+ def _yee_component_control_volumes(solver, field_name: str) -> torch.Tensor:
59
+ coords = _yee_component_coords(solver, field_name)
60
+ widths = tuple(_sample_control_widths(axis) for axis in coords)
61
+ return widths[0][:, None, None] * widths[1][None, :, None] * widths[2][None, None, :]
62
+
63
+
64
+ def _yee_component_coords(solver, field_name: str) -> tuple[torch.Tensor, ...]:
65
+ scene = solver.scene
66
+ coords = {
67
+ "Ex": (scene.x_half, scene.y, scene.z),
68
+ "Ey": (scene.x, scene.y_half, scene.z),
69
+ "Ez": (scene.x, scene.y, scene.z_half),
70
+ }[field_name]
71
+ return tuple(axis.to(device=solver.device, dtype=solver.Ex.dtype) for axis in coords)
72
+
73
+
74
+ def _symmetry_plane_source_scale(scene, field_name: str, position) -> float:
75
+ """Scale a source that lies exactly on an image plane to its full-grid weight."""
76
+ field_axis = {"Ex": 0, "Ey": 1, "Ez": 2}[field_name]
77
+ scale = 1.0
78
+ for axis, symmetry in enumerate(scene.symmetry):
79
+ if symmetry is None:
80
+ continue
81
+ _mode, face = symmetry
82
+ plane = float(scene.domain.bounds[axis][0 if face == "low" else 1])
83
+ span = float(scene.domain.bounds[axis][1] - scene.domain.bounds[axis][0])
84
+ if abs(float(position[axis]) - plane) > 1e-9 * max(span, 1.0):
85
+ continue
86
+ # A tangential E component is sampled on the image plane: the half-cell
87
+ # control volume is half of its full-grid counterpart. A normal component
88
+ # is staggered off the plane as well, so the full-grid source interpolation
89
+ # also splits equally between the two mirrored edges.
90
+ scale *= 0.25 if field_axis == axis else 0.5
91
+ return scale
92
+
93
+
94
+ def _axis_control_overlap(coords: torch.Tensor, lo: float, hi: float) -> torch.Tensor:
95
+ """Fraction of every sample's dual control interval covered by ``[lo, hi]``."""
96
+ count = int(coords.numel())
97
+ if count <= 1:
98
+ return torch.ones_like(coords) if lo <= float(coords[0]) <= hi else torch.zeros_like(coords)
99
+ midpoints = 0.5 * (coords[:-1] + coords[1:])
100
+ left = torch.cat((coords[:1] - 0.5 * (coords[1:2] - coords[:1]), midpoints))
101
+ right = torch.cat((midpoints, coords[-1:] + 0.5 * (coords[-1:] - coords[-2:-1])))
102
+ overlap = torch.clamp(
103
+ torch.minimum(right, torch.as_tensor(hi, device=coords.device, dtype=coords.dtype))
104
+ - torch.maximum(left, torch.as_tensor(lo, device=coords.device, dtype=coords.dtype)),
105
+ min=0.0,
106
+ )
107
+ return overlap / (right - left)
108
+
109
+
110
+ def _yee_box_overlap(solver, field_name: str, lo, hi):
111
+ """Return a compact Yee patch containing volume-averaged box indicator weights."""
112
+ axis_weights = tuple(
113
+ _axis_control_overlap(coords, float(lo[axis]), float(hi[axis]))
114
+ for axis, coords in enumerate(_yee_component_coords(solver, field_name))
115
+ )
116
+ nonzero = tuple(torch.nonzero(weights > 0.0, as_tuple=False).flatten() for weights in axis_weights)
117
+ if any(int(indices.numel()) == 0 for indices in nonzero):
118
+ return None
119
+ start = tuple(int(indices[0].item()) for indices in nonzero)
120
+ stop = tuple(int(indices[-1].item()) + 1 for indices in nonzero)
121
+ slices = tuple(axis_weights[axis][start[axis] : stop[axis]] for axis in range(3))
122
+ weights = slices[0][:, None, None] * slices[1][None, :, None] * slices[2][None, None, :]
123
+ return start, stop, weights
124
+
125
+
126
+ def _ideal_axis_weights(coords: torch.Tensor, position: float) -> tuple[list[int], list[float]]:
127
+ if coords.ndim != 1 or coords.numel() == 0:
128
+ raise ValueError("coords must be a non-empty 1D tensor.")
129
+ if coords.numel() == 1:
130
+ return [0], [1.0]
131
+
132
+ probe = coords.new_tensor(float(position))
133
+ upper = int(torch.searchsorted(coords, probe, right=False).item())
134
+ if upper <= 0:
135
+ return [0], [1.0]
136
+ if upper >= coords.numel():
137
+ return [int(coords.numel() - 1)], [1.0]
138
+
139
+ lower = upper - 1
140
+ lower_coord = float(coords[lower].item())
141
+ upper_coord = float(coords[upper].item())
142
+ span = upper_coord - lower_coord
143
+ if span <= torch.finfo(coords.dtype).eps:
144
+ return [lower], [1.0]
145
+
146
+ fraction = (float(position) - lower_coord) / span
147
+ if fraction <= 1e-12:
148
+ return [lower], [1.0]
149
+ if fraction >= 1.0 - 1e-12:
150
+ return [upper], [1.0]
151
+ return [lower, upper], [1.0 - fraction, fraction]
152
+
153
+
154
+ def _ideal_point_dipole_term(
155
+ px: torch.Tensor,
156
+ py: torch.Tensor,
157
+ pz: torch.Tensor,
158
+ source_position,
159
+ *,
160
+ eps_tensor: torch.Tensor,
161
+ offsets,
162
+ dt: float,
163
+ polarization_component: float,
164
+ control_volumes: torch.Tensor,
165
+ ):
166
+ x_indices, x_weights = _ideal_axis_weights(px, source_position[0])
167
+ y_indices, y_weights = _ideal_axis_weights(py, source_position[1])
168
+ z_indices, z_weights = _ideal_axis_weights(pz, source_position[2])
169
+
170
+ local_start = (x_indices[0], y_indices[0], z_indices[0])
171
+ local_stop = (x_indices[-1] + 1, y_indices[-1] + 1, z_indices[-1] + 1)
172
+ global_offsets = (
173
+ offsets[0] + local_start[0],
174
+ offsets[1] + local_start[1],
175
+ offsets[2] + local_start[2],
176
+ )
177
+ eps_slice = eps_tensor[
178
+ global_offsets[0] : offsets[0] + local_stop[0],
179
+ global_offsets[1] : offsets[1] + local_stop[1],
180
+ global_offsets[2] : offsets[2] + local_stop[2],
181
+ ]
182
+ volume_slice = control_volumes[
183
+ global_offsets[0] : offsets[0] + local_stop[0],
184
+ global_offsets[1] : offsets[1] + local_stop[1],
185
+ global_offsets[2] : offsets[2] + local_stop[2],
186
+ ]
187
+ source_patch = torch.zeros_like(eps_slice)
188
+ source_scale = -float(dt) * float(polarization_component)
189
+
190
+ for ix, wx in zip(x_indices, x_weights):
191
+ for iy, wy in zip(y_indices, y_weights):
192
+ for iz, wz in zip(z_indices, z_weights):
193
+ local_index = (ix - local_start[0], iy - local_start[1], iz - local_start[2])
194
+ source_patch[local_index] += (
195
+ source_scale
196
+ * float(wx)
197
+ * float(wy)
198
+ * float(wz)
199
+ / (eps_slice[local_index] * volume_slice[local_index])
200
+ )
201
+
202
+ return global_offsets, source_patch
203
+
204
+
205
+ def _axis_index_window(nodes64, lo_value, hi_value, size_cap):
206
+ """Node-index window [lo, hi) covering [lo_value, hi_value] on an axis."""
207
+ if float(hi_value) < float(nodes64[0]) or float(lo_value) > float(nodes64[-1]):
208
+ return 0, 0
209
+ lo_index = max(0, int(np.searchsorted(nodes64, float(lo_value), side="right")) - 1)
210
+ hi_index = min(int(size_cap), int(np.searchsorted(nodes64, float(hi_value), side="left")) + 1)
211
+ return lo_index, hi_index
212
+
213
+
214
+ def _prepare_point_dipole_source(solver, source, *, source_index):
215
+ scene = solver.scene
216
+ width = float(source["width"])
217
+ polarization = source["polarization"]
218
+ profile_kind = source.get("profile", "gaussian")
219
+ source_time = source["source_time"]
220
+ source_omega = 2.0 * np.pi * float(source_time["frequency"])
221
+ if profile_kind == "ideal":
222
+ cutoff = 3.0 * POINT_DIPOLE_REFERENCE_WIDTH
223
+ else:
224
+ cutoff = 3.0 * max(width, 0.5 * POINT_DIPOLE_REFERENCE_WIDTH)
225
+ coord_dtype = solver.Ex.dtype
226
+ control_volumes = {
227
+ field_name: _yee_component_control_volumes(solver, field_name)
228
+ for field_name, component in zip(("Ex", "Ey", "Ez"), polarization)
229
+ if not np.isclose(component, 0.0)
230
+ }
231
+
232
+ for image_position, phase_real, phase_imag in solver._iter_source_images(source["position"], cutoff):
233
+ src_x, src_y, src_z = image_position
234
+ ix_start, ix_end = _axis_index_window(scene.x_nodes64, src_x - cutoff, src_x + cutoff, solver.Nx)
235
+ iy_start, iy_end = _axis_index_window(scene.y_nodes64, src_y - cutoff, src_y + cutoff, solver.Ny)
236
+ iz_start, iz_end = _axis_index_window(scene.z_nodes64, src_z - cutoff, src_z + cutoff, solver.Nz)
237
+
238
+ if ix_end <= ix_start or iy_end <= iy_start or iz_end <= iz_start:
239
+ continue
240
+
241
+ if polarization[2] != 0:
242
+ iz_end_ez = min(iz_end, solver.Nz - 1)
243
+ if iz_end_ez > iz_start:
244
+ px = scene.x[ix_start:ix_end].to(dtype=coord_dtype)
245
+ py = scene.y[iy_start:iy_end].to(dtype=coord_dtype)
246
+ pz = scene.z_half[iz_start:iz_end_ez].to(dtype=coord_dtype)
247
+ dist_sq = (
248
+ (px[:, None, None] - src_x) ** 2
249
+ + (py[None, :, None] - src_y) ** 2
250
+ + (pz[None, None, :] - src_z) ** 2
251
+ )
252
+ if profile_kind == "ideal":
253
+ patch_offsets, source_patch = _ideal_point_dipole_term(
254
+ px,
255
+ py,
256
+ pz,
257
+ image_position,
258
+ eps_tensor=solver.eps_Ez,
259
+ offsets=(ix_start, iy_start, iz_start),
260
+ dt=solver.dt,
261
+ polarization_component=polarization[2],
262
+ control_volumes=control_volumes["Ez"],
263
+ )
264
+ else:
265
+ volume_slice = control_volumes["Ez"][
266
+ ix_start:ix_end, iy_start:iy_end, iz_start:iz_end_ez
267
+ ]
268
+ profile = _normalized_point_dipole_profile(dist_sq, width, volume_slice)
269
+ source_patch = (
270
+ (-solver.dt * polarization[2] / solver.eps_Ez[ix_start:ix_end, iy_start:iy_end, iz_start:iz_end_ez])
271
+ * profile
272
+ )
273
+ patch_offsets = (ix_start, iy_start, iz_start)
274
+ source_patch *= _symmetry_plane_source_scale(scene, "Ez", image_position)
275
+ append_source_term(
276
+ solver._source_terms,
277
+ solver,
278
+ field_name="Ez",
279
+ offsets=patch_offsets,
280
+ patch=source_patch,
281
+ phase_real=phase_real,
282
+ phase_imag=phase_imag,
283
+ source_index=source_index,
284
+ source_time=source_time,
285
+ omega=source_omega,
286
+ )
287
+
288
+ if polarization[0] != 0:
289
+ ix_end_ex = min(ix_end, solver.Nx - 1)
290
+ if ix_end_ex > ix_start:
291
+ px = scene.x_half[ix_start:ix_end_ex].to(dtype=coord_dtype)
292
+ py = scene.y[iy_start:iy_end].to(dtype=coord_dtype)
293
+ pz = scene.z[iz_start:iz_end].to(dtype=coord_dtype)
294
+ dist_sq = (
295
+ (px[:, None, None] - src_x) ** 2
296
+ + (py[None, :, None] - src_y) ** 2
297
+ + (pz[None, None, :] - src_z) ** 2
298
+ )
299
+ if profile_kind == "ideal":
300
+ patch_offsets, source_patch = _ideal_point_dipole_term(
301
+ px,
302
+ py,
303
+ pz,
304
+ image_position,
305
+ eps_tensor=solver.eps_Ex,
306
+ offsets=(ix_start, iy_start, iz_start),
307
+ dt=solver.dt,
308
+ polarization_component=polarization[0],
309
+ control_volumes=control_volumes["Ex"],
310
+ )
311
+ else:
312
+ volume_slice = control_volumes["Ex"][
313
+ ix_start:ix_end_ex, iy_start:iy_end, iz_start:iz_end
314
+ ]
315
+ profile = _normalized_point_dipole_profile(dist_sq, width, volume_slice)
316
+ source_patch = (
317
+ (-solver.dt * polarization[0] / solver.eps_Ex[ix_start:ix_end_ex, iy_start:iy_end, iz_start:iz_end])
318
+ * profile
319
+ )
320
+ patch_offsets = (ix_start, iy_start, iz_start)
321
+ source_patch *= _symmetry_plane_source_scale(scene, "Ex", image_position)
322
+ append_source_term(
323
+ solver._source_terms,
324
+ solver,
325
+ field_name="Ex",
326
+ offsets=patch_offsets,
327
+ patch=source_patch,
328
+ phase_real=phase_real,
329
+ phase_imag=phase_imag,
330
+ source_index=source_index,
331
+ source_time=source_time,
332
+ omega=source_omega,
333
+ )
334
+
335
+ if polarization[1] != 0:
336
+ iy_end_ey = min(iy_end, solver.Ny - 1)
337
+ if iy_end_ey > iy_start:
338
+ px = scene.x[ix_start:ix_end].to(dtype=coord_dtype)
339
+ py = scene.y_half[iy_start:iy_end_ey].to(dtype=coord_dtype)
340
+ pz = scene.z[iz_start:iz_end].to(dtype=coord_dtype)
341
+ dist_sq = (
342
+ (px[:, None, None] - src_x) ** 2
343
+ + (py[None, :, None] - src_y) ** 2
344
+ + (pz[None, None, :] - src_z) ** 2
345
+ )
346
+ if profile_kind == "ideal":
347
+ patch_offsets, source_patch = _ideal_point_dipole_term(
348
+ px,
349
+ py,
350
+ pz,
351
+ image_position,
352
+ eps_tensor=solver.eps_Ey,
353
+ offsets=(ix_start, iy_start, iz_start),
354
+ dt=solver.dt,
355
+ polarization_component=polarization[1],
356
+ control_volumes=control_volumes["Ey"],
357
+ )
358
+ else:
359
+ volume_slice = control_volumes["Ey"][
360
+ ix_start:ix_end, iy_start:iy_end_ey, iz_start:iz_end
361
+ ]
362
+ profile = _normalized_point_dipole_profile(dist_sq, width, volume_slice)
363
+ source_patch = (
364
+ (-solver.dt * polarization[1] / solver.eps_Ey[ix_start:ix_end, iy_start:iy_end_ey, iz_start:iz_end])
365
+ * profile
366
+ )
367
+ patch_offsets = (ix_start, iy_start, iz_start)
368
+ source_patch *= _symmetry_plane_source_scale(scene, "Ey", image_position)
369
+ append_source_term(
370
+ solver._source_terms,
371
+ solver,
372
+ field_name="Ey",
373
+ offsets=patch_offsets,
374
+ patch=source_patch,
375
+ phase_real=phase_real,
376
+ phase_imag=phase_imag,
377
+ source_index=source_index,
378
+ source_time=source_time,
379
+ omega=source_omega,
380
+ )
381
+
382
+
383
+ _UNIFORM_CURRENT_COMPONENTS = (("Ex", 0), ("Ey", 1), ("Ez", 2))
384
+ _CURRENT_ELECTRIC_MAP = {"Jx": "Ex", "Jy": "Ey", "Jz": "Ez"}
385
+ _CURRENT_MAGNETIC_MAP = {"Mx": "Hx", "My": "Hy", "Mz": "Hz"}
386
+
387
+
388
+ def _region_index_range(solver, field_name, lo, hi):
389
+ scene = solver.scene
390
+ axis_nodes = (scene.x_nodes64, scene.y_nodes64, scene.z_nodes64)
391
+ axis_half = (scene.x_half64, scene.y_half64, scene.z_half64)
392
+ sizes = tuple(int(dim) for dim in getattr(solver, field_name).shape)
393
+ half_axes = solver._COMPONENT_HALF_OFFSET_AXES[field_name]
394
+ start = []
395
+ stop = []
396
+ for axis in range(3):
397
+ if float(lo[axis]) == float(hi[axis]):
398
+ component_coords = axis_half[axis] if half_axes[axis] else axis_nodes[axis]
399
+ component_coords = component_coords[: sizes[axis]]
400
+ nearest = int(np.argmin(np.abs(component_coords - float(lo[axis]))))
401
+ start.append(nearest)
402
+ stop.append(nearest + 1)
403
+ continue
404
+ lo_index, hi_index = _axis_index_window(axis_nodes[axis], lo[axis], hi[axis], sizes[axis])
405
+ if half_axes[axis]:
406
+ # ``_axis_index_window`` returns the bounding-node window. A Yee
407
+ # component centered between nodes has one fewer sample inside the
408
+ # same discretized box along that axis.
409
+ hi_index = max(lo_index, hi_index - 1)
410
+ start.append(lo_index)
411
+ stop.append(hi_index)
412
+ return tuple(start), tuple(stop)
413
+
414
+
415
+ def _prepare_uniform_current_source(solver, source, *, source_index):
416
+ center = source["center"]
417
+ size = source["size"]
418
+ lo = tuple(center[axis] - 0.5 * size[axis] for axis in range(3))
419
+ hi = tuple(center[axis] + 0.5 * size[axis] for axis in range(3))
420
+ source_time = source["source_time"]
421
+ source_omega = 2.0 * np.pi * float(source_time["frequency"])
422
+ polarization = source["polarization"]
423
+
424
+ for field_name, axis in _UNIFORM_CURRENT_COMPONENTS:
425
+ pol_component = float(polarization[axis])
426
+ if np.isclose(pol_component, 0.0):
427
+ continue
428
+ overlap = _yee_box_overlap(solver, field_name, lo, hi)
429
+ if overlap is None:
430
+ continue
431
+ start, stop, weights = overlap
432
+ field_eps = getattr(solver, f"eps_{field_name}")
433
+ eps_slice = field_eps[start[0]:stop[0], start[1]:stop[1], start[2]:stop[2]]
434
+ source_patch = (-solver.dt * pol_component / eps_slice) * weights
435
+ append_source_term(
436
+ solver._source_terms,
437
+ solver,
438
+ field_name=field_name,
439
+ offsets=start,
440
+ patch=source_patch,
441
+ source_index=source_index,
442
+ source_time=source_time,
443
+ omega=source_omega,
444
+ )
445
+
446
+
447
+ def _axis_interp_weights(coords, query):
448
+ count = int(coords.numel())
449
+ if count == 1:
450
+ index = torch.zeros_like(query, dtype=torch.long)
451
+ return index, index, torch.zeros_like(query)
452
+ upper = torch.searchsorted(coords, query.contiguous(), right=True).clamp(1, count - 1)
453
+ lower = upper - 1
454
+ lower_coord = coords[lower]
455
+ upper_coord = coords[upper]
456
+ fraction = (query - lower_coord) / (upper_coord - lower_coord)
457
+ return lower, upper, fraction.clamp(0.0, 1.0)
458
+
459
+
460
+ def _trilinear_sample(values, axes, positions):
461
+ x = positions[..., 0]
462
+ y = positions[..., 1]
463
+ z = positions[..., 2]
464
+ ix0, ix1, wx = _axis_interp_weights(axes[0], x)
465
+ iy0, iy1, wy = _axis_interp_weights(axes[1], y)
466
+ iz0, iz1, wz = _axis_interp_weights(axes[2], z)
467
+
468
+ def gather(i, j, k):
469
+ return values[i, j, k]
470
+
471
+ c00 = gather(ix0, iy0, iz0) * (1.0 - wz) + gather(ix0, iy0, iz1) * wz
472
+ c01 = gather(ix0, iy1, iz0) * (1.0 - wz) + gather(ix0, iy1, iz1) * wz
473
+ c10 = gather(ix1, iy0, iz0) * (1.0 - wz) + gather(ix1, iy0, iz1) * wz
474
+ c11 = gather(ix1, iy1, iz0) * (1.0 - wz) + gather(ix1, iy1, iz1) * wz
475
+ c0 = c00 * (1.0 - wy) + c01 * wy
476
+ c1 = c10 * (1.0 - wy) + c11 * wy
477
+ return c0 * (1.0 - wx) + c1 * wx
478
+
479
+
480
+ def _dataset_axes_tensors(solver, dataset):
481
+ return tuple(
482
+ torch.tensor(axis, device=solver.device, dtype=solver.Ex.dtype)
483
+ for axis in dataset.coords
484
+ )
485
+
486
+
487
+ def _dataset_region_bounds(dataset):
488
+ lo = tuple(float(axis[0]) for axis in dataset.coords)
489
+ hi = tuple(float(axis[-1]) for axis in dataset.coords)
490
+ return lo, hi
491
+
492
+
493
+ def _append_interpolated_current(
494
+ solver,
495
+ *,
496
+ field_name,
497
+ values,
498
+ axes,
499
+ denom_tensor,
500
+ region_lo,
501
+ region_hi,
502
+ source_time,
503
+ source_omega,
504
+ source_index,
505
+ term_list,
506
+ ):
507
+ start, stop = _region_index_range(solver, field_name, region_lo, region_hi)
508
+ if any(stop[a] <= start[a] for a in range(3)):
509
+ return
510
+ shape = tuple(stop[a] - start[a] for a in range(3))
511
+ positions = solver._component_positions(field_name, start, shape, dtype=solver.Ex.dtype)
512
+ sampled = _trilinear_sample(values, axes, positions)
513
+ if torch.max(torch.abs(sampled)).item() <= 1e-30:
514
+ return
515
+ denom_slice = denom_tensor[start[0]:stop[0], start[1]:stop[1], start[2]:stop[2]]
516
+ source_patch = (-solver.dt / denom_slice) * sampled
517
+ append_source_term(
518
+ term_list,
519
+ solver,
520
+ field_name=field_name,
521
+ offsets=start,
522
+ patch=source_patch,
523
+ source_index=source_index,
524
+ source_time=source_time,
525
+ omega=source_omega,
526
+ )
527
+
528
+
529
+ def _prepare_custom_current_source(solver, source, *, source_index):
530
+ dataset = source["dataset"]
531
+ source_time = source["source_time"]
532
+ source_omega = 2.0 * np.pi * float(source_time["frequency"])
533
+ axes = _dataset_axes_tensors(solver, dataset)
534
+ region_lo, region_hi = _dataset_region_bounds(dataset)
535
+
536
+ for key, array in dataset.components.items():
537
+ values = torch.tensor(array, device=solver.device, dtype=solver.Ex.dtype)
538
+ if key in _CURRENT_ELECTRIC_MAP:
539
+ field_name = _CURRENT_ELECTRIC_MAP[key]
540
+ denom_tensor = getattr(solver, f"eps_{field_name}")
541
+ term_list = solver._source_terms
542
+ else:
543
+ field_name = _CURRENT_MAGNETIC_MAP[key]
544
+ denom_tensor = getattr(solver, f"mu_{field_name}")
545
+ term_list = solver._magnetic_source_terms
546
+ _append_interpolated_current(
547
+ solver,
548
+ field_name=field_name,
549
+ values=values,
550
+ axes=axes,
551
+ denom_tensor=denom_tensor,
552
+ region_lo=region_lo,
553
+ region_hi=region_hi,
554
+ source_time=source_time,
555
+ source_omega=source_omega,
556
+ source_index=source_index,
557
+ term_list=term_list,
558
+ )
559
+
560
+
561
+ def _prepare_custom_field_source(solver, source, *, source_index):
562
+ dataset = source["dataset"]
563
+ normal_axis = source["normal_axis"]
564
+ source_time = source["source_time"]
565
+ source_omega = 2.0 * np.pi * float(source_time["frequency"])
566
+ axes = _dataset_axes_tensors(solver, dataset)
567
+ region_lo, region_hi = _dataset_region_bounds(dataset)
568
+ axis_index = _AXIS_TO_INDEX[normal_axis]
569
+ plane_coord = 0.5 * (float(region_lo[axis_index]) + float(region_hi[axis_index]))
570
+ plane_coords = getattr(solver.scene, normal_axis)
571
+ plane_index = int(torch.argmin(torch.abs(plane_coords - plane_coord)).item())
572
+
573
+ lower = [0, 0, 0]
574
+ upper = [0, 0, 0]
575
+ for index, axis in enumerate("xyz"):
576
+ if axis == normal_axis:
577
+ lower[index] = plane_index
578
+ upper[index] = plane_index + 1
579
+ continue
580
+ nodes64 = (solver.scene.x_nodes64, solver.scene.y_nodes64, solver.scene.z_nodes64)[index]
581
+ size_cap = (solver.Nx, solver.Ny, solver.Nz)[index]
582
+ start, stop = _axis_index_window(
583
+ nodes64,
584
+ region_lo[index],
585
+ region_hi[index],
586
+ size_cap,
587
+ )
588
+ lower[index] = start
589
+ upper[index] = stop - 1
590
+
591
+ electric_specs, magnetic_specs = build_discrete_tfsf_specs(tuple(lower), tuple(upper))
592
+ face_side = "low"
593
+ electric_specs = electric_specs[_FACE_SPEC_RANGES[(normal_axis, face_side)]]
594
+ magnetic_specs = magnetic_specs[_FACE_SPEC_RANGES[(normal_axis, face_side)]]
595
+ component_values = {
596
+ name: torch.tensor(values, device=solver.device, dtype=solver.Ex.dtype)
597
+ for name, values in dataset.components.items()
598
+ }
599
+
600
+ def build_profile_term(spec, coeff_patch, _component_scale):
601
+ values = component_values[spec["incident_name"]]
602
+ positions = _surface_plane_spec_positions(solver, spec)
603
+ sampled = _trilinear_sample(values, axes, positions)
604
+ if spec["incident_name"].startswith("H"):
605
+ # The discrete face specification stores incident magnetic fields
606
+ # with the update-equation sign (``-k x E``), while FieldDataset
607
+ # exposes the physical ``H = k x E / eta`` convention.
608
+ sampled = -sampled
609
+ scale = spec["sign"] * sampled * coeff_patch
610
+ return build_term_from_profile(
611
+ solver,
612
+ field_name=spec["field_name"],
613
+ offsets=spec["offsets"],
614
+ scale=scale,
615
+ delay_patch=torch.zeros_like(scale),
616
+ activation_delay_patch=None,
617
+ source_time=source_time,
618
+ omega=source_omega,
619
+ source_index=source_index,
620
+ )
621
+
622
+ solver._electric_source_terms.extend(build_terms_from_specs(
623
+ solver,
624
+ electric_specs,
625
+ {name: (1.0 if name in component_values else 0.0) for name in ("Hx", "Hy", "Hz")},
626
+ E_CURL_ATTR,
627
+ build_profile_term,
628
+ ))
629
+ solver._magnetic_source_terms.extend(build_terms_from_specs(
630
+ solver,
631
+ magnetic_specs,
632
+ {name: (1.0 if name in component_values else 0.0) for name in ("Ex", "Ey", "Ez")},
633
+ H_CURL_ATTR,
634
+ build_profile_term,
635
+ ))
636
+
637
+
638
+ def _prepare_surface_source(solver, source, *, source_index):
639
+ if solver.scene.boundary.uses_kind("periodic") or solver.scene.boundary.uses_kind("bloch"):
640
+ raise NotImplementedError(
641
+ f"{source['kind']} currently supports only none, pml, pec, or pmc boundaries."
642
+ )
643
+
644
+ source_time = source["source_time"]
645
+ source_frequency = float(source_time["frequency"])
646
+ source_omega = 2.0 * np.pi * source_frequency
647
+ direction = source["direction"]
648
+ axis = resolve_injection_axis(direction, source.get("injection_axis"))
649
+ axis_index = {"x": 0, "y": 1, "z": 2}[axis]
650
+ plane_index = source_plane_index(solver.scene, axis, float(direction[axis_index]))
651
+ plane_coordinate = solver._plane_coordinate(axis, plane_index)
652
+ reference_point = plane_center(
653
+ solver.scene,
654
+ axis,
655
+ plane_coordinate,
656
+ device=solver.device,
657
+ dtype=solver.Ex.dtype,
658
+ )
659
+
660
+ for field_name, polarization_component in zip(("Ex", "Ey", "Ez"), source["polarization"]):
661
+ if np.isclose(polarization_component, 0.0):
662
+ continue
663
+ offsets, shape = solver._component_plane_spec(field_name, axis, plane_index)
664
+ positions = solver._component_positions(field_name, offsets, shape, dtype=solver.Ex.dtype)
665
+ if source["kind"] == "plane_wave":
666
+ spatial_amplitude, delay_patch = plane_wave_profile(
667
+ positions,
668
+ direction=direction,
669
+ reference_point=reference_point,
670
+ propagation_speed=solver.c,
671
+ )
672
+ else:
673
+ spatial_amplitude, delay_patch = beam_profile_from_source(
674
+ positions,
675
+ source,
676
+ frequency=source_frequency,
677
+ propagation_speed=solver.c,
678
+ )
679
+
680
+ field_eps = getattr(solver, f"eps_{field_name}")
681
+ eps_slice = field_eps[
682
+ offsets[0] : offsets[0] + shape[0],
683
+ offsets[1] : offsets[1] + shape[1],
684
+ offsets[2] : offsets[2] + shape[2],
685
+ ]
686
+ source_patch = (-solver.dt * polarization_component / eps_slice) * spatial_amplitude
687
+ if source_time["kind"] == "cw":
688
+ phase_shift = source_omega * delay_patch
689
+ append_source_term(
690
+ solver._source_terms,
691
+ solver,
692
+ field_name=field_name,
693
+ offsets=offsets,
694
+ cw_cos_patch=source_patch * torch.cos(phase_shift),
695
+ cw_sin_patch=source_patch * torch.sin(phase_shift),
696
+ source_index=source_index,
697
+ source_time=source_time,
698
+ omega=source_omega,
699
+ )
700
+ continue
701
+ append_source_term(
702
+ solver._source_terms,
703
+ solver,
704
+ field_name=field_name,
705
+ offsets=offsets,
706
+ patch=source_patch,
707
+ delay_patch=delay_patch,
708
+ source_index=source_index,
709
+ source_time=source_time,
710
+ omega=source_omega,
711
+ )
712
+
713
+
714
+ def _surface_plane_spec_positions(solver, spec):
715
+ return solver._component_positions(
716
+ spec["incident_name"],
717
+ spec["sample_offsets"],
718
+ spec["sample_shape"],
719
+ dtype=solver.Ex.dtype,
720
+ )
721
+
722
+
723
+ def _plane_wave_power_scale(source, aperture_bounds, injection_axis: str) -> float:
724
+ """Absolute surface-current scale for unit time-averaged incident power.
725
+
726
+ The soft ``PlaneWave`` face injects the surface-equivalent currents of the
727
+ target incident wave (``J_s = n x H_inc`` on the electric face, ``M_s =
728
+ -n x E_inc`` on the magnetic face). By the surface-equivalence principle
729
+ these radiate the incident field forward with unit gain, so the injected
730
+ incident amplitude equals the specified ``E`` amplitude scaled here. On the
731
+ Yee grid the numerical wave impedance of a plane wave that satisfies the
732
+ discrete dispersion relation is exactly ``eta0`` -- the leapfrog identity
733
+ ``sin(omega*dt/2)/(c*dt) = sin(k~*d/2)/d`` makes ``H0/E0 = 1/eta0`` -- so the
734
+ physical-impedance magnetic current the injector uses (``magnetic_physical_
735
+ vector(..., eta0)``) is the correct numerical amplitude and the forward
736
+ radiation gain is unity. No empirical calibration is therefore required.
737
+
738
+ Normalizing to unit incident power over the illuminated aperture, the
739
+ time-averaged forward power ``0.5*|E0|^2*A*cos(theta)/eta0`` equals one when
740
+ ``|E0| = 1/sqrt(unit_power)`` with ``unit_power = A*cos(theta)/(2*eta0)``.
741
+ The residual discrete-injection error is below the 2% absolute-power
742
+ acceptance across frequencies and spacings (see
743
+ ``tests/sources/incident/test_soft_planewave_absolute_power.py``).
744
+ """
745
+ axis_index = _AXIS_TO_INDEX[injection_axis]
746
+ tangential_extents = [
747
+ float(axis_bounds[1] - axis_bounds[0])
748
+ for index, axis_bounds in enumerate(aperture_bounds)
749
+ if index != axis_index
750
+ ]
751
+ aperture_area = tangential_extents[0] * tangential_extents[1]
752
+ incidence_cosine = abs(float(source["direction"][axis_index]))
753
+ unit_power = aperture_area * incidence_cosine / (2.0 * _ETA0)
754
+ if unit_power <= 0.0:
755
+ raise ValueError("PlaneWave source requires a positive aperture power for normalization.")
756
+ return 1.0 / np.sqrt(unit_power)
757
+
758
+
759
+ def _beam_power_scale(source, injection_axis: str) -> float:
760
+ """Peak electric-field scale for a unit-power Gaussian beam.
761
+
762
+ The astigmatic profile uses ``exp(-u^2 / wu^2 - v^2 / wv^2)`` with the
763
+ longitudinal prefactor that preserves its transverse L2 mass. Therefore
764
+ ``integral |E/E0|^2 dA = pi*wu*wv/2`` at every propagation plane.
765
+ """
766
+ if source["kind"] == "astigmatic_gaussian_beam":
767
+ waist_u = float(source["beam_waist_u"])
768
+ waist_v = float(source["beam_waist_v"])
769
+ else:
770
+ waist_u = waist_v = float(source["beam_waist"])
771
+ axis_index = _AXIS_TO_INDEX[injection_axis]
772
+ incidence_cosine = abs(float(source["direction"][axis_index]))
773
+ unit_power = math.pi * waist_u * waist_v * incidence_cosine / (4.0 * _ETA0)
774
+ if unit_power <= 0.0:
775
+ raise ValueError("Gaussian beam requires a positive transverse power integral.")
776
+ return 1.0 / math.sqrt(unit_power)
777
+
778
+
779
+ def _validate_soft_surface_source_boundary(boundary, direction, injection_axis: str) -> None:
780
+ if boundary.uses_kind("bloch"):
781
+ raise NotImplementedError(
782
+ "PlaneWave soft injection does not support Bloch boundaries; use a TFSF source."
783
+ )
784
+ incompatible_periodic_axes = [
785
+ axis
786
+ for index, axis in enumerate("xyz")
787
+ if boundary.axis_kind(axis) == "periodic"
788
+ and (axis == injection_axis or abs(float(direction[index])) > 1.0e-12)
789
+ ]
790
+ if incompatible_periodic_axes:
791
+ axes = ", ".join(incompatible_periodic_axes)
792
+ raise NotImplementedError(
793
+ "PlaneWave soft injection supports periodic boundaries only on zero-phase "
794
+ f"transverse axes; incompatible axes: {axes}."
795
+ )
796
+
797
+
798
+ def _prepare_power_normalized_surface_source(solver, source, *, source_index):
799
+ source_time = source["source_time"]
800
+ source_frequency = float(source_time["frequency"])
801
+ source_omega = 2.0 * np.pi * source_frequency
802
+ direction = source["direction"]
803
+ injection_axis = resolve_injection_axis(direction, source.get("injection_axis"))
804
+ _validate_soft_surface_source_boundary(
805
+ solver.scene.boundary,
806
+ direction,
807
+ injection_axis,
808
+ )
809
+ axis_index = _AXIS_TO_INDEX[injection_axis]
810
+ direction_sign = 1 if float(direction[axis_index]) >= 0.0 else -1
811
+ plane_index = soft_plane_wave_index(solver.scene, injection_axis, float(direction[axis_index]))
812
+
813
+ lower = []
814
+ upper = []
815
+ aperture_bounds = []
816
+ computational_range = solver.scene.domain_range
817
+ for axis in "xyz":
818
+ if axis == injection_axis:
819
+ if direction_sign > 0:
820
+ lo = plane_index
821
+ hi = plane_index + 1
822
+ else:
823
+ lo = plane_index - 1
824
+ hi = plane_index
825
+ axis_coords = {"x": solver.scene.x, "y": solver.scene.y, "z": solver.scene.z}[axis]
826
+ plane_coord = float(axis_coords[plane_index].item())
827
+ aperture_bounds.append((plane_coord, plane_coord))
828
+ else:
829
+ lo = 0
830
+ hi = {"x": solver.Nx, "y": solver.Ny, "z": solver.Nz}[axis] - 1
831
+ range_offset = 2 * _AXIS_TO_INDEX[axis]
832
+ aperture_bounds.append(
833
+ (
834
+ float(computational_range[range_offset]),
835
+ float(computational_range[range_offset + 1]),
836
+ )
837
+ )
838
+ lower.append(int(lo))
839
+ upper.append(int(hi))
840
+
841
+ lower = tuple(lower)
842
+ upper = tuple(upper)
843
+ face_side = "low" if direction_sign > 0 else "high"
844
+ electric_specs, magnetic_specs = build_discrete_tfsf_specs(lower, upper)
845
+ electric_specs = electric_specs[_FACE_SPEC_RANGES[(injection_axis, face_side)]]
846
+ magnetic_specs = magnetic_specs[_FACE_SPEC_RANGES[(injection_axis, face_side)]]
847
+
848
+ phase_speed = solver.c
849
+ # Numerical-dispersion phase correction with the grid spacing local to the
850
+ # launch plane and aperture. On a uniform grid this is exactly the per-axis
851
+ # spacing (bit-for-bit the previous global-minimum value); on a graded grid it
852
+ # tracks the launch-region cells the wavefront actually crosses instead of the
853
+ # global-minimum spacing, whose finest cell can lie far from the source and
854
+ # mis-tune the injected phase velocity.
855
+ deltas = soft_plane_wave_region_spacing(
856
+ solver.scene,
857
+ injection_axis=injection_axis,
858
+ plane_index=plane_index,
859
+ direction_sign=direction_sign,
860
+ )
861
+ k_numeric = solve_numerical_wavenumber(solver, direction, deltas)
862
+ if k_numeric > 1e-12:
863
+ phase_speed = source_omega / k_numeric
864
+
865
+ if source["kind"] == "plane_wave":
866
+ power_scale = _plane_wave_power_scale(source, tuple(aperture_bounds), injection_axis)
867
+ else:
868
+ power_scale = _beam_power_scale(source, injection_axis)
869
+ # A native Yee-plane Poynting monitor co-locates the tangential E/H pair by
870
+ # interpolating across their half-cell separation along the injection axis.
871
+ # For the injected discrete plane wave this reduces the measured normal power
872
+ # by cos(k_axis * delta_axis / 2). Normalize by that derived factor so a unit
873
+ # PlaneWave carries one watt through the full computational aperture, matching
874
+ # Tidy3D's infinite-plane source convention without an empirical multiplier.
875
+ k_axis = k_numeric * float(direction[axis_index])
876
+ discrete_power_factor = math.cos(0.5 * k_axis * float(deltas[injection_axis]))
877
+ if discrete_power_factor <= 0.0:
878
+ raise ValueError(
879
+ "PlaneWave grid is too coarse for positive discrete normal power; "
880
+ "refine the injection-axis spacing."
881
+ )
882
+ power_scale /= math.sqrt(discrete_power_factor)
883
+ electric_vector = {
884
+ "Ex": power_scale * float(source["polarization"][0]),
885
+ "Ey": power_scale * float(source["polarization"][1]),
886
+ "Ez": power_scale * float(source["polarization"][2]),
887
+ }
888
+ magnetic_vector = {
889
+ name: power_scale * value
890
+ for name, value in magnetic_physical_vector(direction, source["polarization"], _ETA0).items()
891
+ }
892
+
893
+ plane_coordinate = solver._plane_coordinate(injection_axis, plane_index)
894
+ reference_point = plane_center(
895
+ solver.scene,
896
+ injection_axis,
897
+ plane_coordinate,
898
+ device=solver.device,
899
+ dtype=solver.Ex.dtype,
900
+ )
901
+ beam_reference_delay = None
902
+ if source["kind"] != "plane_wave":
903
+ _, beam_reference_delay = beam_profile_from_source(
904
+ reference_point.reshape(1, 1, 1, 3),
905
+ source,
906
+ frequency=source_frequency,
907
+ propagation_speed=phase_speed,
908
+ )
909
+ beam_reference_delay = beam_reference_delay.reshape(())
910
+
911
+ def build_profile_term(spec, coeff_patch, component_scale):
912
+ positions = _surface_plane_spec_positions(solver, spec)
913
+ if source["kind"] == "plane_wave":
914
+ spatial_amplitude, delay_patch = plane_wave_profile(
915
+ positions,
916
+ direction=direction,
917
+ reference_point=reference_point,
918
+ propagation_speed=phase_speed,
919
+ )
920
+ else:
921
+ spatial_amplitude, delay_patch = beam_profile_from_source(
922
+ positions,
923
+ source,
924
+ frequency=source_frequency,
925
+ propagation_speed=phase_speed,
926
+ )
927
+ # The analytic beam phase is expressed relative to its waist. A
928
+ # soft source, however, starts on this launch plane: retaining the
929
+ # large negative waist-to-plane delay would place most of a pulsed
930
+ # waveform before t=0. Remove only the common launch-plane delay;
931
+ # transverse curvature and Gouy phase differences remain intact.
932
+ delay_patch = delay_patch - beam_reference_delay
933
+ scale = spec["sign"] * component_scale * spatial_amplitude * coeff_patch
934
+ return build_term_from_profile(
935
+ solver,
936
+ field_name=spec["field_name"],
937
+ offsets=spec["offsets"],
938
+ scale=scale,
939
+ delay_patch=delay_patch,
940
+ activation_delay_patch=None,
941
+ source_time=source_time,
942
+ omega=source_omega,
943
+ source_index=source_index,
944
+ )
945
+
946
+ solver._electric_source_terms.extend(build_terms_from_specs(
947
+ solver,
948
+ electric_specs,
949
+ magnetic_vector,
950
+ E_CURL_ATTR,
951
+ build_profile_term,
952
+ ))
953
+ solver._magnetic_source_terms.extend(build_terms_from_specs(
954
+ solver,
955
+ magnetic_specs,
956
+ electric_vector,
957
+ H_CURL_ATTR,
958
+ build_profile_term,
959
+ ))
960
+
961
+
962
+ def _prepare_mode_surface_source(solver, source, *, source_index):
963
+ mode_data = solve_mode_source_profile(solver, source)
964
+ source["effective_index"] = mode_data["effective_index"]
965
+ source["beta"] = mode_data["beta"]
966
+ source["effective_index_complex"] = mode_data.get("effective_index_complex")
967
+ source["beta_complex"] = mode_data.get("beta_complex")
968
+ source["mode_solver_kind"] = mode_data.get("mode_solver_kind")
969
+
970
+ face_side = "low" if int(source["direction_sign"]) > 0 else "high"
971
+ electric_specs, magnetic_specs = build_discrete_tfsf_specs(
972
+ mode_data["box_lower"],
973
+ mode_data["box_upper"],
974
+ )
975
+ electric_specs = electric_specs[_FACE_SPEC_RANGES[(source["normal_axis"], face_side)]]
976
+ magnetic_specs = magnetic_specs[_FACE_SPEC_RANGES[(source["normal_axis"], face_side)]]
977
+ source_time = source["source_time"]
978
+ source_omega = 2.0 * np.pi * float(source_time["frequency"])
979
+ normal_axis_index = _AXIS_TO_INDEX[source["normal_axis"]]
980
+ plane_coordinate = solver._plane_coordinate(source["normal_axis"], mode_data["plane_index"])
981
+ direction_sign = int(source["direction_sign"])
982
+ phase_speed = source_omega / max(abs(float(mode_data["beta"])), 1.0e-30)
983
+
984
+ def build_profile_term(spec, coeff_patch):
985
+ positions = _surface_plane_spec_positions(solver, spec)
986
+ component_profile = sample_mode_source_component(mode_data, positions, spec["incident_name"])
987
+ if spec["incident_name"].startswith("H"):
988
+ # TFSF face specifications store the magnetic incident component
989
+ # with the update-equation ``-k x E`` sign. Mode profiles expose
990
+ # the physical ``H = k x E / eta`` field, so convert conventions.
991
+ component_profile = -component_profile
992
+ if torch.max(torch.abs(component_profile)).item() <= 1e-12:
993
+ return None
994
+ delay_patch = (
995
+ float(direction_sign)
996
+ * (positions[..., normal_axis_index] - float(plane_coordinate))
997
+ / float(phase_speed)
998
+ )
999
+ scale = spec["sign"] * component_profile * coeff_patch
1000
+ return build_term_from_profile(
1001
+ solver,
1002
+ field_name=spec["field_name"],
1003
+ offsets=spec["offsets"],
1004
+ scale=scale,
1005
+ delay_patch=delay_patch,
1006
+ activation_delay_patch=None,
1007
+ source_time=source_time,
1008
+ omega=source_omega,
1009
+ source_index=source_index,
1010
+ )
1011
+
1012
+ for spec in electric_specs:
1013
+ coeff_patch = slice_coeff_patch(solver, spec, E_CURL_ATTR)
1014
+ term = build_profile_term(spec, coeff_patch)
1015
+ if term is not None:
1016
+ solver._electric_source_terms.append(term)
1017
+ for spec in magnetic_specs:
1018
+ coeff_patch = slice_coeff_patch(solver, spec, H_CURL_ATTR)
1019
+ term = build_profile_term(spec, coeff_patch)
1020
+ if term is not None:
1021
+ solver._magnetic_source_terms.append(term)
1022
+
1023
+
1024
+ def inject_magnetic_surface_source_terms(solver, *, time_value):
1025
+ if not getattr(solver, "_magnetic_source_terms", None):
1026
+ return
1027
+ apply_generic_source_terms(
1028
+ solver,
1029
+ solver._magnetic_source_terms,
1030
+ source_time=solver._source_time,
1031
+ omega=solver.source_omega,
1032
+ time_value=time_value,
1033
+ clamp_pec=False,
1034
+ )
1035
+
1036
+
1037
+ def inject_electric_surface_source_terms(solver, *, time_value):
1038
+ if not getattr(solver, "_electric_source_terms", None):
1039
+ return
1040
+ apply_generic_source_terms(
1041
+ solver,
1042
+ solver._electric_source_terms,
1043
+ source_time=solver._source_time,
1044
+ omega=solver.source_omega,
1045
+ time_value=time_value,
1046
+ clamp_pec=False,
1047
+ )
1048
+
1049
+
1050
+ def initialize_source_terms(solver):
1051
+ solver._source_terms = []
1052
+ solver._magnetic_source_terms = []
1053
+ solver._electric_source_terms = []
1054
+ compiled_sources = tuple(getattr(solver, "_compiled_sources", ()) or ())
1055
+ if not compiled_sources and getattr(solver, "_compiled_source", None) is not None:
1056
+ compiled_sources = (solver._compiled_source,)
1057
+ if not compiled_sources:
1058
+ return
1059
+
1060
+ for source_index, source in enumerate(compiled_sources):
1061
+ if source.get("injection", {}).get("kind") == "tfsf":
1062
+ continue
1063
+ if source["kind"] == "point_dipole":
1064
+ _prepare_point_dipole_source(solver, source, source_index=source_index)
1065
+ continue
1066
+ if source["kind"] == "mode_source":
1067
+ _prepare_mode_surface_source(solver, source, source_index=source_index)
1068
+ continue
1069
+ if source["kind"] in {"plane_wave", "gaussian_beam", "astigmatic_gaussian_beam"}:
1070
+ _prepare_power_normalized_surface_source(solver, source, source_index=source_index)
1071
+ continue
1072
+ if source["kind"] == "uniform_current":
1073
+ _prepare_uniform_current_source(solver, source, source_index=source_index)
1074
+ continue
1075
+ if source["kind"] == "custom_current":
1076
+ _prepare_custom_current_source(solver, source, source_index=source_index)
1077
+ continue
1078
+ if source["kind"] == "custom_field":
1079
+ _prepare_custom_field_source(solver, source, source_index=source_index)
1080
+ continue
1081
+ _prepare_surface_source(solver, source, source_index=source_index)
1082
+
1083
+
1084
+ def inject_source_terms(solver, n=None, signal=None, time_value=None):
1085
+ if solver._source_terms is None:
1086
+ initialize_source_terms(solver)
1087
+ if not solver._source_terms:
1088
+ return
1089
+
1090
+ if time_value is None:
1091
+ if n is None:
1092
+ raise ValueError("Either n or time_value must be provided for source injection.")
1093
+ time_value = n * solver.dt
1094
+
1095
+ apply_compiled_source_terms(
1096
+ solver,
1097
+ solver._source_terms,
1098
+ source_time=solver._source_time,
1099
+ omega=solver.source_omega,
1100
+ time_value=time_value,
1101
+ signal=signal,
1102
+ )