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,264 @@
1
+ from __future__ import annotations
2
+
3
+ import torch
4
+
5
+ from .tfsf_specs import reference_sample_axis_code
6
+ from .temporal import apply_generic_source_terms
7
+
8
+
9
+ _REFERENCE_PROVIDERS = {"plane_wave_ref_x_ez", "plane_wave_axis_aligned"}
10
+ _AUXILIARY_PROVIDERS = {"plane_wave_ref_x_ez", "plane_wave_axis_aligned", "plane_wave_aux"}
11
+
12
+ _MAGNETIC_FIELD_NAMES = ("Hx", "Hy", "Hz")
13
+ _ELECTRIC_FIELD_NAMES = ("Ex", "Ey", "Ez")
14
+
15
+
16
+ def _field_names_for_terms(term_key):
17
+ return _MAGNETIC_FIELD_NAMES if term_key == "magnetic_terms" else _ELECTRIC_FIELD_NAMES
18
+
19
+
20
+ def _apply_batched_aux_terms(solver, batch, *, sample_kind, field_names):
21
+ if batch is None:
22
+ return
23
+
24
+ aux = solver._tfsf_state["auxiliary_grid"]
25
+ sample_origin = float(aux.s_min if sample_kind == "electric" else aux.s_min + 0.5 * aux.ds)
26
+ sample_field = aux.electric if sample_kind == "electric" else aux.magnetic
27
+ solver.fdtd_module.addBatchedInterpolatedSourcePatches3D(
28
+ fieldX=getattr(solver, field_names[0]),
29
+ fieldY=getattr(solver, field_names[1]),
30
+ fieldZ=getattr(solver, field_names[2]),
31
+ coeffData=batch["coeff_data"],
32
+ incidentField=sample_field,
33
+ samplePositions=batch["sample_positions"],
34
+ termStarts=batch["term_starts"],
35
+ termShapes=batch["term_shapes"],
36
+ termOffsets=batch["term_offsets"],
37
+ fieldCodes=batch["field_codes"],
38
+ fieldCodesPerCoeff=batch["field_codes_per_coeff"],
39
+ fieldOffsets=batch["field_offsets"],
40
+ origin=sample_origin,
41
+ ds=float(aux.ds),
42
+ ).launchRaw()
43
+
44
+
45
+ def _apply_batched_reference_terms(solver, batch, *, sample_kind, field_names):
46
+ if batch is None:
47
+ return
48
+
49
+ aux = solver._tfsf_state["auxiliary_grid"]
50
+ sample_field = aux.electric if sample_kind == "electric" else aux.magnetic
51
+ solver.fdtd_module.addBatchedReferenceSourcePatches3D(
52
+ fieldX=getattr(solver, field_names[0]),
53
+ fieldY=getattr(solver, field_names[1]),
54
+ fieldZ=getattr(solver, field_names[2]),
55
+ coeffData=batch["coeff_data"],
56
+ incidentField=sample_field,
57
+ termStarts=batch["term_starts"],
58
+ termShapes=batch["term_shapes"],
59
+ termOffsets=batch["term_offsets"],
60
+ fieldCodes=batch["field_codes"],
61
+ sampleAxisCodes=batch["sample_axis_codes"],
62
+ sampleIndexStarts=batch["sample_index_starts"],
63
+ sampleIndices=batch["sample_indices"],
64
+ fieldCodesPerCoeff=batch["field_codes_per_coeff"],
65
+ fieldOffsets=batch["field_offsets"],
66
+ sampleIndicesPerCoeff=batch["sample_indices_per_coeff"],
67
+ ).launchRaw()
68
+
69
+
70
+ def _apply_aux_terms(solver, terms, *, sample_kind):
71
+ if not terms:
72
+ return
73
+ aux = solver._tfsf_state["auxiliary_grid"]
74
+ sample_origin = float(aux.s_min if sample_kind == "electric" else aux.s_min + 0.5 * aux.ds)
75
+ sample_field = aux.electric if sample_kind == "electric" else aux.magnetic
76
+ for term in terms:
77
+ offset_i, offset_j, offset_k = term["offsets"]
78
+ solver.fdtd_module.addInterpolatedSourcePatch3D(
79
+ field=getattr(solver, term["field_name"]),
80
+ coeffPatch=term["coeff_patch"],
81
+ incidentField=sample_field,
82
+ samplePositions=term["sample_positions"],
83
+ origin=sample_origin,
84
+ ds=float(aux.ds),
85
+ offsetI=int(offset_i),
86
+ offsetJ=int(offset_j),
87
+ offsetK=int(offset_k),
88
+ scale=float(term["component_scale"]),
89
+ ).launchRaw()
90
+
91
+
92
+ def _apply_reference_terms(solver, terms):
93
+ if not terms:
94
+ return
95
+
96
+ aux = solver._tfsf_state["auxiliary_grid"]
97
+ for term in terms:
98
+ sample_field = aux.electric if term["sample_kind"] == "electric" else aux.magnetic
99
+ if term["scalar_sample_index"] is not None:
100
+ offset_i, offset_j, offset_k = term["offsets"]
101
+ solver.fdtd_module.addScaledSliceSourcePatch3D(
102
+ field=getattr(solver, term["field_name"]),
103
+ sourcePatch=term["coeff_patch"],
104
+ incidentField=sample_field,
105
+ sampleIndex=int(term["scalar_sample_index"]),
106
+ offsetI=int(offset_i),
107
+ offsetJ=int(offset_j),
108
+ offsetK=int(offset_k),
109
+ scale=float(term["component_scale"]),
110
+ ).launchRaw()
111
+ continue
112
+ sample_indices_i32 = term.get("_sample_indices_i32")
113
+ if (
114
+ sample_indices_i32 is None
115
+ or not torch.is_tensor(sample_indices_i32)
116
+ or sample_indices_i32.device != sample_field.device
117
+ ):
118
+ sample_indices_i32 = term["sample_indices"].to(device=sample_field.device, dtype=torch.int32)
119
+ term["_sample_indices_i32"] = sample_indices_i32
120
+ sample_axis_code = int(term.get("_sample_axis_code", reference_sample_axis_code(term)))
121
+ term["_sample_axis_code"] = sample_axis_code
122
+ offset_i, offset_j, offset_k = term["offsets"]
123
+ solver.fdtd_module.addScaledLineSourcePatch3D(
124
+ field=getattr(solver, term["field_name"]),
125
+ coeffPatch=term["coeff_patch"],
126
+ incidentField=sample_field,
127
+ sampleIndices=sample_indices_i32,
128
+ sampleAxisCode=sample_axis_code,
129
+ offsetI=int(offset_i),
130
+ offsetJ=int(offset_j),
131
+ offsetK=int(offset_k),
132
+ scale=float(term["component_scale"]),
133
+ ).launchRaw()
134
+
135
+
136
+ def _apply_tfsf_terms(solver, *, term_key, sample_kind, time_value):
137
+ if not getattr(solver, "tfsf_enabled", False):
138
+ return
139
+
140
+ provider = solver._tfsf_state.get("provider")
141
+ terms = solver._tfsf_state[term_key]
142
+ batch = solver._tfsf_state.get("magnetic_batch" if term_key == "magnetic_terms" else "electric_batch")
143
+ field_names = _field_names_for_terms(term_key)
144
+ if provider in _REFERENCE_PROVIDERS:
145
+ if batch is not None:
146
+ _apply_batched_reference_terms(
147
+ solver,
148
+ batch,
149
+ sample_kind=sample_kind,
150
+ field_names=field_names,
151
+ )
152
+ return
153
+ _apply_reference_terms(solver, terms)
154
+ return
155
+ if provider == "plane_wave_aux":
156
+ if batch is not None:
157
+ _apply_batched_aux_terms(
158
+ solver,
159
+ batch,
160
+ sample_kind=sample_kind,
161
+ field_names=field_names,
162
+ )
163
+ return
164
+ _apply_aux_terms(solver, terms, sample_kind=sample_kind)
165
+ return
166
+ apply_generic_source_terms(
167
+ solver,
168
+ terms,
169
+ source_time=solver._source_time,
170
+ omega=solver.source_omega,
171
+ time_value=time_value,
172
+ clamp_pec=False,
173
+ )
174
+
175
+
176
+ # TFSF providers whose in-block correction both (1) reads the incident field from
177
+ # the device-resident auxiliary 1D grid -- carrying no per-step host input -- and
178
+ # (2) accumulates through the integer-indexed *reference* injection kernel, whose
179
+ # warp-aggregated writes are collision-free for the axis-aligned face layout and
180
+ # therefore bit-reproducible. This covers both injection modes of the axis-aligned
181
+ # reference providers: the six-face box TFSF and the two-face (slab) TFSF share the
182
+ # same auxiliary-line provider and reference kernels, so the non-periodic
183
+ # normal-incidence slab captures identically to the box. The oblique auxiliary-line
184
+ # provider (``plane_wave_aux``) is deliberately excluded: it accumulates through the
185
+ # batched *interpolated* kernel, whose cross-warp atomic adds at the overlapping
186
+ # oblique faces are not bit-reproducible run to run, so a captured graph could not
187
+ # be proven bit-identical to the eager path. The grating-slab (``plane_wave_grating_
188
+ # slab_cw``) and box-CW (``plane_wave_discrete_cw``) providers evaluate the incident
189
+ # CW phasor on the host each step through ``apply_generic_source_terms`` and inject
190
+ # it as a kernel launch scalar a captured graph would freeze, so they are excluded
191
+ # too.
192
+ _CAPTURABLE_TFSF_PROVIDERS = frozenset(_REFERENCE_PROVIDERS)
193
+
194
+
195
+ def tfsf_incident_is_gpu_driven(solver) -> bool:
196
+ """True when an active TFSF source couples into the 3D field-update block only
197
+ by *reading* the device-resident auxiliary 1D incident grid through the
198
+ bit-reproducible reference injection kernel, so the block can be captured into a
199
+ CUDA graph and replayed bit-for-bit.
200
+
201
+ The reference providers apply their in-block H correction with the batched /
202
+ per-term reference kernels, which read ``aux.electric`` / ``aux.magnetic``
203
+ (persistent device tensors) at fixed integer sample indices and carry no
204
+ per-step host input. The 1D source waveform is evaluated on the host only in
205
+ ``advance_tfsf_auxiliary_electric`` (the E-side auxiliary advance), which runs
206
+ eagerly *after* the field-update block, outside the captured region. So for
207
+ these providers the block is a fixed, deterministic kernel sequence. This holds
208
+ for both the six-face box TFSF and the two-face (slab) TFSF, which share the
209
+ axis-aligned reference provider, so the non-periodic normal-incidence slab is
210
+ capturable exactly like the box.
211
+
212
+ The oblique auxiliary-line provider (``plane_wave_aux``) is excluded because its
213
+ batched interpolated injection kernel accumulates through non-deterministic
214
+ cross-warp atomic adds (bit-identical capture parity is impossible). The
215
+ grating-slab provider (``plane_wave_grating_slab_cw``, the Bloch-transverse
216
+ oblique slab) and the box-CW provider (``plane_wave_discrete_cw``) each evaluate
217
+ the incident CW phasor on the host every step via ``apply_generic_source_terms``
218
+ and inject it as a kernel launch scalar a captured graph would freeze at its
219
+ capture-time value, so they too stay on the eager path (capturing them would
220
+ require a GPU-driven generic source table, which is out of scope here).
221
+ """
222
+ state = getattr(solver, "_tfsf_state", None)
223
+ if not state:
224
+ return False
225
+ return state.get("provider") in _CAPTURABLE_TFSF_PROVIDERS
226
+
227
+
228
+ def apply_tfsf_h_correction(solver, time_value):
229
+ _apply_tfsf_terms(
230
+ solver,
231
+ term_key="magnetic_terms",
232
+ sample_kind="electric",
233
+ time_value=time_value,
234
+ )
235
+
236
+
237
+ def apply_tfsf_e_correction(solver, time_value):
238
+ _apply_tfsf_terms(
239
+ solver,
240
+ term_key="electric_terms",
241
+ sample_kind="magnetic",
242
+ time_value=time_value + 0.5 * float(solver.dt),
243
+ )
244
+
245
+
246
+ def _advance_auxiliary_grid(solver, *, magnetic):
247
+ if not getattr(solver, "tfsf_enabled", False):
248
+ return None
249
+ if solver._tfsf_state.get("provider") not in _AUXILIARY_PROVIDERS:
250
+ return None
251
+ aux = solver._tfsf_state["auxiliary_grid"]
252
+ if magnetic:
253
+ aux.advance_magnetic()
254
+ else:
255
+ aux.advance_electric()
256
+ return None
257
+
258
+
259
+ def advance_tfsf_auxiliary_magnetic(solver):
260
+ return _advance_auxiliary_grid(solver, magnetic=True)
261
+
262
+
263
+ def advance_tfsf_auxiliary_electric(solver):
264
+ return _advance_auxiliary_grid(solver, magnetic=False)
@@ -0,0 +1,421 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+
5
+ import torch
6
+
7
+ from ..dispersion import solve_numerical_wavenumber
8
+ from .spatial import beam_profile_from_source, plane_wave_profile
9
+ from .tfsf_specs import AXIS_INDEX, reference_sample_axis_code
10
+ from .temporal import build_source_term
11
+
12
+
13
+ def nearest_index(axis_coords, value: float) -> int:
14
+ return int(torch.argmin(torch.abs(axis_coords - float(value))).item())
15
+
16
+
17
+ def resolve_bounds_indices(scene, bounds):
18
+ lower = []
19
+ upper = []
20
+ for axis_coords, axis_bounds in zip((scene.x, scene.y, scene.z), bounds):
21
+ lower.append(nearest_index(axis_coords, axis_bounds[0]))
22
+ upper.append(nearest_index(axis_coords, axis_bounds[1]))
23
+ return tuple(lower), tuple(upper)
24
+
25
+
26
+ # Relative spacing spread at or below which the injection region is treated as
27
+ # exactly uniform: the legacy scalar spacing is returned bit-for-bit and no new
28
+ # numerics run, so uniform grids stay unchanged.
29
+ _UNIFORM_SPACING_RTOL = 1e-6
30
+ # Maximum tolerated numerical phase-velocity spread across a TFSF injection
31
+ # region before the single auxiliary-grid spacing can no longer match the graded
32
+ # main grid. The leading FDTD dispersion relation gives a relative
33
+ # numerical-wavenumber excess of (k0*d)^2/24 for a vacuum wave of wavenumber
34
+ # k0 = omega/c (the TFSF background is enforced to be vacuum); the spread of that
35
+ # excess between the finest and coarsest cell in the region is the residual the
36
+ # total/scattered-field faces cannot cancel and thus leaks into the scattered
37
+ # region. 1e-3 keeps that residual at or below the uniform-grid leakage floor the
38
+ # axis-aligned null tests already assert.
39
+ _TFSF_DISPERSION_SPREAD_BOUND = 1e-3
40
+
41
+
42
+ def require_locally_uniform_axis(solver, axis, start, stop, *, context):
43
+ """Return the effective primal spacing over cells [start, stop) for the
44
+ auxiliary-grid dispersion match, accepting a bounded amount of grading.
45
+
46
+ A perfectly uniform region (spread <= ``_UNIFORM_SPACING_RTOL`` relative)
47
+ returns its exact cell spacing, bit-for-bit as before. A mildly graded region
48
+ is accepted when the numerical phase-velocity spread it induces across the
49
+ region stays below ``_TFSF_DISPERSION_SPREAD_BOUND``, in which case the
50
+ region-mean spacing is used as the single effective delta. A region too
51
+ graded for that bound raises with the predicted error, because a single
52
+ auxiliary spacing would inject a spurious incident field on the TFSF faces.
53
+ """
54
+ primal = {
55
+ "x": solver.scene.dx_primal64,
56
+ "y": solver.scene.dy_primal64,
57
+ "z": solver.scene.dz_primal64,
58
+ }[axis]
59
+ window = primal[max(int(start), 0) : min(int(stop), len(primal))]
60
+ d_min = float(window.min())
61
+ d_max = float(window.max())
62
+ if (d_max - d_min) <= _UNIFORM_SPACING_RTOL * d_max:
63
+ return d_min
64
+
65
+ # Region-mean effective spacing minimizes the mean phase mismatch; the
66
+ # residual is the numerical-dispersion wavenumber spread it leaves across the
67
+ # region, evaluated at the source (center) frequency.
68
+ d_eff = float(window.mean())
69
+ k0 = abs(float(solver.source_omega)) / float(solver.c)
70
+ dispersion_spread = (k0 * k0) * (d_max * d_max - d_min * d_min) / 24.0
71
+ if dispersion_spread > _TFSF_DISPERSION_SPREAD_BOUND:
72
+ raise ValueError(
73
+ f"{context} along axis '{axis}' is too graded for the auxiliary-grid "
74
+ "dispersion match: the numerical phase-velocity spread across the "
75
+ f"injection region (+1 cell) is {dispersion_spread:.3e} "
76
+ f"(min={d_min:g}, max={d_max:g}, k0={k0:g}), above the bound "
77
+ f"{_TFSF_DISPERSION_SPREAD_BOUND:.0e}. Refine GridSpec.custom for "
78
+ f"locally uniform grid spacing along axis '{axis}' over the injection "
79
+ "region, or move the region."
80
+ )
81
+ return d_eff
82
+
83
+
84
+ def validate_bounds(solver, lower, upper):
85
+ shape = (solver.Nx, solver.Ny, solver.Nz)
86
+ for axis, lo, hi, size in zip("xyz", lower, upper, shape):
87
+ low_margin = solver.scene.pml_thickness_for_face(axis, "low") + 2
88
+ high_margin = solver.scene.pml_thickness_for_face(axis, "high") + 2
89
+ if hi <= lo + 1:
90
+ raise ValueError(f"TFSF bounds must span at least two cells along {axis}.")
91
+ if lo < low_margin or hi > size - high_margin - 1:
92
+ raise ValueError("TFSF bounds must lie strictly inside the non-PML simulation region.")
93
+
94
+
95
+ def validate_background_is_vacuum(solver, lower, upper, tol=1e-5):
96
+ eps = solver.epsilon_r
97
+ mu = solver.mu_r
98
+ slices = (
99
+ (slice(lower[0], lower[0] + 1), slice(lower[1], upper[1] + 1), slice(lower[2], upper[2] + 1)),
100
+ (slice(upper[0], upper[0] + 1), slice(lower[1], upper[1] + 1), slice(lower[2], upper[2] + 1)),
101
+ (slice(lower[0], upper[0] + 1), slice(lower[1], lower[1] + 1), slice(lower[2], upper[2] + 1)),
102
+ (slice(lower[0], upper[0] + 1), slice(upper[1], upper[1] + 1), slice(lower[2], upper[2] + 1)),
103
+ (slice(lower[0], upper[0] + 1), slice(lower[1], upper[1] + 1), slice(lower[2], lower[2] + 1)),
104
+ (slice(lower[0], upper[0] + 1), slice(lower[1], upper[1] + 1), slice(upper[2], upper[2] + 1)),
105
+ )
106
+ for face_slice in slices:
107
+ eps_face = eps[face_slice]
108
+ mu_face = mu[face_slice]
109
+ if not torch.allclose(eps_face, torch.ones_like(eps_face), atol=tol, rtol=0.0):
110
+ raise ValueError("TFSF boundary must remain in vacuum on all faces.")
111
+ if not torch.allclose(mu_face, torch.ones_like(mu_face), atol=tol, rtol=0.0):
112
+ raise ValueError("TFSF boundary must remain in vacuum on all faces.")
113
+
114
+
115
+ def solve_auxiliary_step(solver, k_numeric: float) -> float:
116
+ omega = float(solver.source_omega)
117
+ if omega <= 0.0 or k_numeric <= 1e-12:
118
+ return float(solver.c) * float(solver.dt)
119
+
120
+ rhs = math.sin(0.5 * omega * float(solver.dt)) / (float(solver.c) * float(solver.dt))
121
+ low = float(solver.c) * float(solver.dt) * (1.0 + 1e-6)
122
+ high = 0.999 * math.pi / k_numeric
123
+ if high <= low:
124
+ high = low * 1.5
125
+
126
+ def residual(ds):
127
+ return math.sin(0.5 * k_numeric * ds) / ds - rhs
128
+
129
+ low_residual = residual(low)
130
+ high_residual = residual(high)
131
+ if low_residual < 0.0:
132
+ return low
133
+ if high_residual > 0.0:
134
+ return high
135
+
136
+ for _ in range(80):
137
+ mid = 0.5 * (low + high)
138
+ if residual(mid) >= 0.0:
139
+ low = mid
140
+ else:
141
+ high = mid
142
+ return 0.5 * (low + high)
143
+
144
+
145
+ def entry_projection(bounds, direction):
146
+ x_bounds, y_bounds, z_bounds = bounds
147
+ min_projection = None
148
+ for x in x_bounds:
149
+ for y in y_bounds:
150
+ for z in z_bounds:
151
+ projection = x * float(direction[0]) + y * float(direction[1]) + z * float(direction[2])
152
+ if min_projection is None or projection < min_projection:
153
+ min_projection = projection
154
+ return float(min_projection)
155
+
156
+
157
+ def projection_extrema(bounds, direction):
158
+ x_bounds, y_bounds, z_bounds = bounds
159
+ min_projection = None
160
+ max_projection = None
161
+ for x in x_bounds:
162
+ for y in y_bounds:
163
+ for z in z_bounds:
164
+ projection = x * float(direction[0]) + y * float(direction[1]) + z * float(direction[2])
165
+ if min_projection is None or projection < min_projection:
166
+ min_projection = projection
167
+ if max_projection is None or projection > max_projection:
168
+ max_projection = projection
169
+ return float(min_projection), float(max_projection)
170
+
171
+
172
+ def box_center_tensor(solver, bounds):
173
+ return torch.tensor(
174
+ [
175
+ 0.5 * (bounds[0][0] + bounds[0][1]),
176
+ 0.5 * (bounds[1][0] + bounds[1][1]),
177
+ 0.5 * (bounds[2][0] + bounds[2][1]),
178
+ ],
179
+ device=solver.device,
180
+ dtype=solver.Ex.dtype,
181
+ )
182
+
183
+
184
+ def make_aux_term(solver, *, field_name, offsets, coeff_patch, sample_positions, component_scale):
185
+ return {
186
+ "field_name": field_name,
187
+ "offsets": offsets,
188
+ "coeff_patch": coeff_patch.contiguous(),
189
+ "sample_positions": sample_positions.contiguous(),
190
+ "component_scale": float(component_scale),
191
+ }
192
+
193
+
194
+ def _field_component_code(field_name: str) -> int:
195
+ component = field_name[-1].lower()
196
+ if component == "x":
197
+ return 0
198
+ if component == "y":
199
+ return 1
200
+ return 2
201
+
202
+
203
+ def _pack_batched_term_metadata(solver, terms):
204
+ coeff_cursor = 0
205
+ term_starts = []
206
+ term_shapes = []
207
+ term_offsets = []
208
+ field_codes = []
209
+ coeff_chunks = []
210
+ field_code_chunks = []
211
+ field_offset_chunks = []
212
+ field_offset_dtype = torch.int32
213
+ for term in terms:
214
+ field_shape = tuple(int(length) for length in getattr(solver, term["field_name"]).shape)
215
+ if math.prod(field_shape) > torch.iinfo(torch.int32).max:
216
+ field_offset_dtype = torch.int64
217
+ break
218
+
219
+ for term_index, term in enumerate(terms):
220
+ coeff_flat = (term["coeff_patch"] * float(term["component_scale"])).reshape(-1)
221
+ shape = tuple(int(length) for length in term["coeff_patch"].shape)
222
+ offsets = tuple(int(offset) for offset in term["offsets"])
223
+ local_linear = torch.arange(int(coeff_flat.numel()), device=solver.device, dtype=torch.int32)
224
+ stride_i = shape[1] * shape[2]
225
+ local_i = local_linear // stride_i
226
+ remainder = local_linear - local_i * stride_i
227
+ local_j = remainder // shape[2]
228
+ local_k = remainder - local_j * shape[2]
229
+ field_code = _field_component_code(term["field_name"])
230
+ field_shape = tuple(int(length) for length in getattr(solver, term["field_name"]).shape)
231
+ field_i = local_i + offsets[0]
232
+ field_j = local_j + offsets[1]
233
+ field_k = local_k + offsets[2]
234
+ coeff_chunks.append(coeff_flat)
235
+ field_code_chunks.append(
236
+ torch.full(
237
+ (int(coeff_flat.numel()),),
238
+ field_code,
239
+ device=solver.device,
240
+ dtype=torch.int32,
241
+ )
242
+ )
243
+ field_offset_chunks.append(
244
+ (
245
+ field_i.to(dtype=torch.int64) * (field_shape[1] * field_shape[2])
246
+ + field_j.to(dtype=torch.int64) * field_shape[2]
247
+ + field_k.to(dtype=torch.int64)
248
+ ).to(dtype=field_offset_dtype).contiguous()
249
+ )
250
+ term_starts.append(coeff_cursor)
251
+ term_shapes.append(list(shape))
252
+ term_offsets.append(list(offsets))
253
+ field_codes.append(field_code)
254
+ coeff_cursor += int(coeff_flat.numel())
255
+
256
+ if not coeff_chunks:
257
+ return None
258
+
259
+ coeff_data = torch.cat(coeff_chunks).contiguous()
260
+ return {
261
+ "coeff_data": coeff_data,
262
+ "term_starts": torch.tensor(term_starts, device=solver.device, dtype=torch.int32),
263
+ "term_shapes": torch.tensor(term_shapes, device=solver.device, dtype=torch.int32),
264
+ "term_offsets": torch.tensor(term_offsets, device=solver.device, dtype=torch.int32),
265
+ "field_codes": torch.tensor(field_codes, device=solver.device, dtype=torch.int32),
266
+ "field_codes_per_coeff": torch.cat(field_code_chunks).contiguous(),
267
+ "field_offsets": torch.cat(field_offset_chunks).contiguous(),
268
+ }
269
+
270
+
271
+ def build_batched_reference_terms(solver, terms):
272
+ metadata = _pack_batched_term_metadata(solver, terms)
273
+ if metadata is None:
274
+ return None
275
+
276
+ sample_cursor = 0
277
+ sample_axis_codes = []
278
+ sample_index_starts = []
279
+ sample_chunks = []
280
+ sample_index_per_coeff_chunks = []
281
+ for term in terms:
282
+ sample_indices = term["sample_indices"].to(device=solver.device, dtype=torch.int32).reshape(-1)
283
+ shape = tuple(int(length) for length in term["coeff_patch"].shape)
284
+ local_linear = torch.arange(int(term["coeff_patch"].numel()), device=solver.device, dtype=torch.int32)
285
+ stride_i = shape[1] * shape[2]
286
+ local_i = local_linear // stride_i
287
+ remainder = local_linear - local_i * stride_i
288
+ local_j = remainder // shape[2]
289
+ local_k = remainder - local_j * shape[2]
290
+ axis = int(reference_sample_axis_code(term))
291
+ sample_linear = local_i if axis == 0 else (local_j if axis == 1 else local_k)
292
+ sample_chunks.append(sample_indices)
293
+ sample_index_per_coeff_chunks.append(sample_indices[sample_linear.to(dtype=torch.long)])
294
+ sample_axis_codes.append(axis)
295
+ sample_index_starts.append(sample_cursor)
296
+ sample_cursor += int(sample_indices.numel())
297
+
298
+ metadata["sample_axis_codes"] = torch.tensor(sample_axis_codes, device=solver.device, dtype=torch.int32)
299
+ metadata["sample_index_starts"] = torch.tensor(sample_index_starts, device=solver.device, dtype=torch.int32)
300
+ metadata["sample_indices"] = torch.cat(sample_chunks).contiguous()
301
+ metadata["sample_indices_per_coeff"] = torch.cat(sample_index_per_coeff_chunks).contiguous()
302
+ return metadata
303
+
304
+
305
+ def build_batched_aux_terms(solver, terms):
306
+ metadata = _pack_batched_term_metadata(solver, terms)
307
+ if metadata is None:
308
+ return None
309
+
310
+ metadata["sample_positions"] = torch.cat(
311
+ [term["sample_positions"].reshape(-1) for term in terms]
312
+ ).contiguous()
313
+ return metadata
314
+
315
+
316
+ def slice_coeff_patch(solver, spec, curl_attr_map):
317
+ offsets = spec["offsets"]
318
+ shape = spec["shape"]
319
+ axis = spec["delta_axis"]
320
+ scene = solver.scene
321
+ # The spec is a one-cell curl correction: E-face terms divide by the dual
322
+ # spacing at the E element's coordinate, H-face terms by the primal
323
+ # spacing at the H element's coordinate (the spacing their field update
324
+ # uses along the derivative axis).
325
+ if spec["field_name"].startswith("E"):
326
+ spacing64 = {"x": scene.dx_dual64, "y": scene.dy_dual64, "z": scene.dz_dual64}[axis]
327
+ else:
328
+ spacing64 = {"x": scene.dx_primal64, "y": scene.dy_primal64, "z": scene.dz_primal64}[axis]
329
+ delta = float(spacing64[int(offsets[AXIS_INDEX[axis]])])
330
+ return getattr(solver, curl_attr_map[spec["field_name"]])[
331
+ offsets[0] : offsets[0] + shape[0],
332
+ offsets[1] : offsets[1] + shape[1],
333
+ offsets[2] : offsets[2] + shape[2],
334
+ ] / delta
335
+
336
+
337
+ def build_terms_from_specs(solver, specs, vector, curl_attr_map, term_factory):
338
+ terms = []
339
+ for spec in specs:
340
+ component_scale = vector[spec["incident_name"]]
341
+ coeff = float(spec["sign"]) * component_scale
342
+ if isinstance(coeff, torch.Tensor):
343
+ if torch.max(torch.abs(coeff)).item() <= 1e-12:
344
+ continue
345
+ elif abs(float(coeff)) <= 1e-12:
346
+ continue
347
+ coeff_patch = slice_coeff_patch(solver, spec, curl_attr_map)
348
+ term = term_factory(spec, coeff_patch, component_scale)
349
+ if term is not None:
350
+ terms.append(term)
351
+ return terms
352
+
353
+
354
+ def build_term_from_profile(
355
+ solver,
356
+ *,
357
+ field_name,
358
+ offsets,
359
+ scale,
360
+ delay_patch,
361
+ activation_delay_patch,
362
+ source_time,
363
+ omega,
364
+ source_index=None,
365
+ ):
366
+ if torch.max(torch.abs(scale)).item() <= 1e-12:
367
+ return None
368
+ if source_time["kind"] == "cw" and activation_delay_patch is None:
369
+ phase_shift = float(omega) * delay_patch
370
+ return build_source_term(
371
+ solver,
372
+ field_name=field_name,
373
+ offsets=offsets,
374
+ cw_cos_patch=scale * torch.cos(phase_shift),
375
+ cw_sin_patch=scale * torch.sin(phase_shift),
376
+ source_index=source_index,
377
+ source_time=source_time,
378
+ omega=omega,
379
+ )
380
+ return build_source_term(
381
+ solver,
382
+ field_name=field_name,
383
+ offsets=offsets,
384
+ patch=scale,
385
+ delay_patch=delay_patch,
386
+ activation_delay_patch=activation_delay_patch,
387
+ source_index=source_index,
388
+ source_time=source_time,
389
+ omega=omega,
390
+ )
391
+
392
+
393
+ def incident_profile(
394
+ solver,
395
+ source,
396
+ positions,
397
+ reference_point,
398
+ *,
399
+ phase_speed,
400
+ source_frequency=None,
401
+ polarization_override=None,
402
+ ):
403
+ if source["kind"] == "plane_wave":
404
+ return plane_wave_profile(
405
+ positions,
406
+ direction=source["direction"],
407
+ reference_point=reference_point,
408
+ propagation_speed=phase_speed,
409
+ )
410
+ return beam_profile_from_source(
411
+ positions,
412
+ source,
413
+ frequency=solver.source_frequency if source_frequency is None else float(source_frequency),
414
+ propagation_speed=phase_speed,
415
+ polarization=polarization_override,
416
+ )
417
+
418
+
419
+ def project_positions(positions, direction):
420
+ direction_tensor = positions.new_tensor(direction)
421
+ return torch.sum(positions * direction_tensor, dim=-1)