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,1563 @@
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+ import torch
5
+
6
+ from ...compiler.materials import (
7
+ evaluate_material_permeability,
8
+ evaluate_material_permittivity,
9
+ material_model_has_full_anisotropy,
10
+ material_model_has_modulation,
11
+ material_model_has_nonlinearity,
12
+ )
13
+ from ..boundary import BOUNDARY_PERIODIC, has_complex_fields
14
+
15
+
16
+ def _any_component_nonzero(components: dict[str, torch.Tensor]) -> bool:
17
+ return any(torch.any(components[axis] != 0).item() for axis in ("x", "y", "z"))
18
+
19
+
20
+ def build_materials(solver, scene):
21
+ material_model = scene.compile_materials()
22
+ # A Bloch-periodic run carries complex phase-shifted field state. The
23
+ # instantaneous |E|^2 nonlinearity, the additive off-diagonal anisotropic
24
+ # curl correction, and the per-step modulation factor are all implemented on
25
+ # the real-valued Yee update kernels, and their coefficients are undefined on
26
+ # a complex field (|E|^2 is not analytic, and the real additive/scaling
27
+ # corrections would break the Bloch phase). Each needs a complex-field kernel
28
+ # variant before it can compose with Bloch periodicity.
29
+ if scene.boundary.uses_kind("bloch") and material_model_has_nonlinearity(material_model):
30
+ raise NotImplementedError(
31
+ "FDTD nonlinear media require the real-valued update kernels: a Bloch-periodic run carries "
32
+ "complex phase-shifted fields, for which the instantaneous |E|^2 Kerr/chi2/TPA coefficient is "
33
+ "not defined. Use a real-field boundary (PML/PEC/PMC/periodic) with the nonlinearity."
34
+ )
35
+ if scene.boundary.uses_kind("bloch") and material_model_has_full_anisotropy(material_model):
36
+ raise NotImplementedError(
37
+ "FDTD full (off-diagonal) anisotropic media use a real additive curl(H) correction: a "
38
+ "Bloch-periodic run carries complex phase-shifted fields, and applying the real off-diagonal "
39
+ "correction to a complex field would break the Bloch phase relation. Use a real-field boundary "
40
+ "(PML/PEC/PMC/periodic) or an axis-aligned DiagonalTensor3."
41
+ )
42
+ if scene.boundary.uses_kind("bloch") and material_model_has_modulation(material_model):
43
+ raise NotImplementedError(
44
+ "FDTD time-modulated media scale the real-valued update coefficients by a per-step modulation "
45
+ "factor: a Bloch-periodic run carries complex phase-shifted fields, and the real scaling does "
46
+ "not track the complex Bloch phase. Use a real-field boundary (PML/PEC/PMC/periodic) with the "
47
+ "modulation."
48
+ )
49
+
50
+ solver._compiled_material_model = material_model
51
+ eps_components = material_model["eps_components"]
52
+ mu_components = material_model["mu_components"]
53
+ solver.epsilon_r_components = eps_components
54
+ solver.mu_r_components = mu_components
55
+ solver.epsilon_r = material_model["eps_r"]
56
+ solver.mu_r = material_model["mu_r"]
57
+ solver.material_eps_r = evaluate_material_permittivity(material_model, solver.source_frequency)
58
+ solver.material_mu_r = evaluate_material_permeability(material_model, solver.source_frequency)
59
+
60
+ eps_x_node = eps_components["x"] * solver.eps0
61
+ eps_y_node = eps_components["y"] * solver.eps0
62
+ eps_z_node = eps_components["z"] * solver.eps0
63
+ mu_x_node = mu_components["x"] * solver.mu0
64
+ mu_y_node = mu_components["y"] * solver.mu0
65
+ mu_z_node = mu_components["z"] * solver.mu0
66
+
67
+ solver.eps_Ex = average_node_to_component(solver, eps_x_node, "Ex")
68
+ solver.eps_Ey = average_node_to_component(solver, eps_y_node, "Ey")
69
+ solver.eps_Ez = average_node_to_component(solver, eps_z_node, "Ez")
70
+ sigma_e_components = material_model["sigma_e_components"]
71
+ solver.sigma_e_Ex = average_node_to_component(solver, sigma_e_components["x"], "Ex")
72
+ solver.sigma_e_Ey = average_node_to_component(solver, sigma_e_components["y"], "Ey")
73
+ solver.sigma_e_Ez = average_node_to_component(solver, sigma_e_components["z"], "Ez")
74
+ solver.conductive_enabled = bool(_any_component_nonzero(sigma_e_components))
75
+ solver.mu_Hx = average_node_to_magnetic_component(solver, mu_x_node, "Hx")
76
+ solver.mu_Hy = average_node_to_magnetic_component(solver, mu_y_node, "Hy")
77
+ solver.mu_Hz = average_node_to_magnetic_component(solver, mu_z_node, "Hz")
78
+ # Static magnetic conductivity averaged onto the H components (same node->face
79
+ # stencil as mu): folds semi-implicitly into the H-update decay/curl exactly as
80
+ # sigma_e folds into the E-update, giving a magnetic conduction loss on Faraday's law.
81
+ sigma_m_components = material_model["sigma_m_components"]
82
+ solver.sigma_m_Hx = average_node_to_magnetic_component(solver, sigma_m_components["x"], "Hx")
83
+ solver.sigma_m_Hy = average_node_to_magnetic_component(solver, sigma_m_components["y"], "Hy")
84
+ solver.sigma_m_Hz = average_node_to_magnetic_component(solver, sigma_m_components["z"], "Hz")
85
+ solver.magnetically_conductive_enabled = bool(_any_component_nonzero(sigma_m_components))
86
+
87
+ build_nonlinear_channels(solver, material_model)
88
+
89
+ build_full_anisotropy(solver, material_model)
90
+
91
+ build_modulation(solver, material_model)
92
+
93
+ build_dispersive_templates(solver, material_model)
94
+ build_magnetic_dispersive_templates(solver, material_model)
95
+ solver.dispersive_enabled = solver.electric_dispersive_enabled or solver.magnetic_dispersive_enabled
96
+ # Full (off-diagonal) anisotropic permittivity now composes with dispersion:
97
+ # electric poles enter isotropically and the ADE polarization current is folded
98
+ # through the same per-edge inverse permittivity tensor as curl(H) (see
99
+ # _apply_aniso_dispersive_corrections); magnetic dispersion is an independent
100
+ # H-side channel and does not touch the electric tensor.
101
+ # Space-time modulation now composes with electric/magnetic dispersion and with
102
+ # the instantaneous nonlinear channels: the modulation scales the eps_inf
103
+ # background (the modulated E-update coefficients) while the ADE polarization
104
+ # current and the field-dependent nonlinear coefficients are folded through the
105
+ # same per-step 1/m_next factor, so a modulated dispersive/nonlinear cell stays
106
+ # mutually consistent (electro-optic-modulator physics). The full off-diagonal
107
+ # anisotropic tensor is the one edge left out: modulating the crystal-frame
108
+ # tensor would require a per-step re-inversion of the coupled 3x3 permittivity,
109
+ # which the current diagonalized effective-eps + additive off-diagonal split
110
+ # cannot express.
111
+ if solver.modulation_enabled and solver.full_aniso_enabled:
112
+ raise NotImplementedError(
113
+ "FDTD time-modulated media cannot be combined with full (off-diagonal) anisotropic "
114
+ "media: modulating the crystal-frame permittivity tensor needs a per-step re-inversion "
115
+ "of the coupled 3x3 tensor, which the diagonalized effective-permittivity update path "
116
+ "does not provide. Use a diagonal (axis-aligned) tensor or drop the modulation."
117
+ )
118
+
119
+
120
+ def build_nonlinear_channels(solver, material_model):
121
+ """Set the nonlinear feature flags and edge-averaged nonlinear channel fields.
122
+
123
+ ``kerr_enabled`` marks the instantaneous chi3 (Kerr) channel and keeps the
124
+ existing dynamic-curl fast path when it is the only nonlinearity.
125
+ ``nonlinear_general_enabled`` switches to the general nonlinear coefficient
126
+ kernel, which additionally handles the chi2 channel and a field-dependent
127
+ conductivity and therefore rewrites both the decay and curl coefficients
128
+ every step.
129
+ """
130
+ solver.kerr_enabled = bool(torch.any(material_model["kerr_chi3"] != 0).item())
131
+ solver.chi2_enabled = bool(torch.any(material_model["chi2"] != 0).item())
132
+ solver.tpa_enabled = bool(torch.any(material_model["tpa_sigma"] != 0).item())
133
+ solver.nonlinear_enabled = solver.kerr_enabled or solver.chi2_enabled or solver.tpa_enabled
134
+ solver.nonlinear_general_enabled = solver.chi2_enabled or solver.tpa_enabled
135
+ solver.kerr_chi3 = material_model["kerr_chi3"]
136
+ if solver.nonlinear_enabled:
137
+ solver.kerr_chi3_Ex = average_node_to_component(solver, solver.kerr_chi3, "Ex")
138
+ solver.kerr_chi3_Ey = average_node_to_component(solver, solver.kerr_chi3, "Ey")
139
+ solver.kerr_chi3_Ez = average_node_to_component(solver, solver.kerr_chi3, "Ez")
140
+ solver.cex_curl_dynamic = torch.empty_like(solver.eps_Ex)
141
+ solver.cey_curl_dynamic = torch.empty_like(solver.eps_Ey)
142
+ solver.cez_curl_dynamic = torch.empty_like(solver.eps_Ez)
143
+ else:
144
+ solver.kerr_chi3_Ex = None
145
+ solver.kerr_chi3_Ey = None
146
+ solver.kerr_chi3_Ez = None
147
+ solver.cex_curl_dynamic = None
148
+ solver.cey_curl_dynamic = None
149
+ solver.cez_curl_dynamic = None
150
+ if solver.nonlinear_general_enabled:
151
+ chi2 = material_model["chi2"]
152
+ solver.nonlinear_chi2_Ex = average_node_to_component(solver, chi2, "Ex")
153
+ solver.nonlinear_chi2_Ey = average_node_to_component(solver, chi2, "Ey")
154
+ solver.nonlinear_chi2_Ez = average_node_to_component(solver, chi2, "Ez")
155
+ # Field-dependent conductivity channel of the general nonlinear kernel:
156
+ # sigma_NL = tpa_sigma * |E|^2, from TwoPhotonAbsorption descriptors.
157
+ tpa_sigma = material_model["tpa_sigma"]
158
+ solver.tpa_sigma_Ex = average_node_to_component(solver, tpa_sigma, "Ex")
159
+ solver.tpa_sigma_Ey = average_node_to_component(solver, tpa_sigma, "Ey")
160
+ solver.tpa_sigma_Ez = average_node_to_component(solver, tpa_sigma, "Ez")
161
+ solver.cex_decay_dynamic = torch.empty_like(solver.eps_Ex)
162
+ solver.cey_decay_dynamic = torch.empty_like(solver.eps_Ey)
163
+ solver.cez_decay_dynamic = torch.empty_like(solver.eps_Ez)
164
+ else:
165
+ solver.nonlinear_chi2_Ex = None
166
+ solver.nonlinear_chi2_Ey = None
167
+ solver.nonlinear_chi2_Ez = None
168
+ solver.tpa_sigma_Ex = None
169
+ solver.tpa_sigma_Ey = None
170
+ solver.tpa_sigma_Ez = None
171
+ solver.cex_decay_dynamic = None
172
+ solver.cey_decay_dynamic = None
173
+ solver.cez_decay_dynamic = None
174
+
175
+
176
+ def build_modulation(solver, material_model):
177
+ """Set the space-time modulation flag and its per-edge quadrature fields.
178
+
179
+ The compiled ``modulation_cos`` / ``modulation_sin`` node fields
180
+ (``amplitude*cos(phase)`` and ``amplitude*sin(phase)``) are averaged onto the
181
+ Yee E edges once here; the per-step modulation factor
182
+ ``m(t) = 1 + cos_field*cos(Omega*t) - sin_field*sin(Omega*t)`` is evaluated
183
+ inside the modulated E-update kernels from these static fields and a persistent
184
+ two-element device clock, so no coefficient tensor or host phase is rebuilt per
185
+ step.
186
+ """
187
+ solver.modulation_enabled = bool(material_model_has_modulation(material_model))
188
+ if not solver.modulation_enabled:
189
+ solver._modulation_time = None
190
+ solver.mod_cos_Ex = None
191
+ solver.mod_cos_Ey = None
192
+ solver.mod_cos_Ez = None
193
+ solver.mod_sin_Ex = None
194
+ solver.mod_sin_Ey = None
195
+ solver.mod_sin_Ez = None
196
+ solver.mod_omega_Ex = None
197
+ solver.mod_omega_Ey = None
198
+ solver.mod_omega_Ez = None
199
+ return
200
+ solver._modulation_time = torch.full(
201
+ (2,), -float(solver.dt), device=solver.device, dtype=solver.Ex.dtype
202
+ )
203
+ modulation_cos = material_model["modulation_cos"]
204
+ modulation_sin = material_model["modulation_sin"]
205
+ modulation_omega = material_model["modulation_omega"]
206
+ solver.mod_cos_Ex = average_node_to_component(solver, modulation_cos, "Ex")
207
+ solver.mod_cos_Ey = average_node_to_component(solver, modulation_cos, "Ey")
208
+ solver.mod_cos_Ez = average_node_to_component(solver, modulation_cos, "Ez")
209
+ solver.mod_sin_Ex = average_node_to_component(solver, modulation_sin, "Ex")
210
+ solver.mod_sin_Ey = average_node_to_component(solver, modulation_sin, "Ey")
211
+ solver.mod_sin_Ez = average_node_to_component(solver, modulation_sin, "Ez")
212
+ # Frequency is a per-cell label, not an amplitude: reduce it onto each Yee edge
213
+ # with a MAX over the averaging stencil so a modulated/unmodulated interface
214
+ # edge keeps the modulated cell's full angular frequency (its quadrature depth
215
+ # already fades through the arithmetic cos/sin blend), letting disjoint
216
+ # structures modulate at independent frequencies in one run.
217
+ solver.mod_omega_Ex = _max_node_to_electric_component(modulation_omega, "Ex")
218
+ solver.mod_omega_Ey = _max_node_to_electric_component(modulation_omega, "Ey")
219
+ solver.mod_omega_Ez = _max_node_to_electric_component(modulation_omega, "Ez")
220
+
221
+
222
+ _ANISO_ROW_PAIRS = {
223
+ # Inverse-tensor row entries coupling each E component to the two off-axis
224
+ # curl(H) components, in (first, second) order matching the update
225
+ # E_i += dt/eps0 * (inv_ij * <curlH_j> + inv_ik * <curlH_k>).
226
+ "Ex": ("xy", "xz"),
227
+ "Ey": ("xy", "yz"),
228
+ "Ez": ("xz", "yz"),
229
+ }
230
+
231
+
232
+ def _symmetric3_inverse(a, b, c, d, e, f):
233
+ """Closed-form inverse of the symmetric 3x3 tensor [[a,d,e],[d,b,f],[e,f,c]].
234
+
235
+ Returns the six independent entries of the (symmetric) inverse as a dict
236
+ keyed by ``xx, yy, zz, xy, xz, yz`` plus the determinant for validation.
237
+ """
238
+ det = a * (b * c - f * f) - d * (d * c - f * e) + e * (d * f - b * e)
239
+ inv_det = 1.0 / det
240
+ return {
241
+ "xx": (b * c - f * f) * inv_det,
242
+ "yy": (a * c - e * e) * inv_det,
243
+ "zz": (a * b - d * d) * inv_det,
244
+ "xy": (e * f - d * c) * inv_det,
245
+ "xz": (d * f - b * e) * inv_det,
246
+ "yz": (d * e - a * f) * inv_det,
247
+ }, det
248
+
249
+
250
+ def build_full_anisotropy(solver, material_model):
251
+ """Precompute per-edge inverse-permittivity tensor rows for full anisotropy.
252
+
253
+ For each E component the six relative-permittivity node grids (diagonal +
254
+ off-diagonal) are averaged onto that component's Yee edges, the symmetric
255
+ 3x3 tensor is inverted per edge in closed form, and the row belonging to the
256
+ component is stored: the diagonal entry as an effective scalar permittivity
257
+ (consumed by ``build_update_coefficients`` for the base curl coefficient)
258
+ and the two off-diagonal entries as coupling coefficients for the
259
+ neighbor-averaged off-axis curl(H) correction kernel.
260
+ """
261
+ solver.full_aniso_enabled = bool(material_model_has_full_anisotropy(material_model))
262
+ solver._aniso_eps_eff = None
263
+ solver._aniso_inverse_rows = None
264
+ # Effective inverse permittivity (1/eps_eff) reused for the diagonal ADE
265
+ # polarization-current subtraction, and the accumulated per-component
266
+ # polarization current buffers used by the off-diagonal tensor subtraction.
267
+ solver._aniso_disp_inv_eps = None
268
+ solver._aniso_disp_current = None
269
+ # Per-component conduction-current buffers (sigma . E at the pre-update field),
270
+ # subtracted through the off-diagonal inverse tensor when the anisotropic
271
+ # medium is also conductive; None otherwise.
272
+ solver._aniso_cond_current = None
273
+ solver.cex_aniso_y = None
274
+ solver.cex_aniso_z = None
275
+ solver.cey_aniso_x = None
276
+ solver.cey_aniso_z = None
277
+ solver.cez_aniso_x = None
278
+ solver.cez_aniso_y = None
279
+ # Per-direction CPML psi accumulators for the off-diagonal coupling, allocated
280
+ # only when the anisotropic structure runs under a CPML absorber; kept as
281
+ # attributes so a graph capture snapshots them like the base CPML psi.
282
+ for component in ("ex", "ey", "ez"):
283
+ for axis in ("x", "y", "z"):
284
+ setattr(solver, f"psi_{component}_aniso_{axis}", None)
285
+ solver._full_aniso_cpml_overlap = False
286
+ if not solver.full_aniso_enabled:
287
+ return
288
+
289
+ if solver.nonlinear_enabled:
290
+ raise NotImplementedError(
291
+ "FDTD full (off-diagonal) anisotropic media cannot be combined with nonlinear media."
292
+ )
293
+
294
+ eps_components = material_model["eps_components"]
295
+ offdiag = material_model["eps_offdiag_components"]
296
+ sigma_components = material_model["sigma_e_components"]
297
+ node = {
298
+ "xx": eps_components["x"],
299
+ "yy": eps_components["y"],
300
+ "zz": eps_components["z"],
301
+ "xy": offdiag["xy"],
302
+ "xz": offdiag["xz"],
303
+ "yz": offdiag["yz"],
304
+ }
305
+ diagonal_key = {"Ex": "xx", "Ey": "yy", "Ez": "zz"}
306
+ # Semi-implicit conductive fold: invert (eps_inf + dt/2 * diag(sigma)) instead of
307
+ # eps_inf so the same per-edge inverse tensor B = dt * (...)^-1 that couples
308
+ # curl(H) also carries the exact tensor conduction damping. In relative units
309
+ # the diagonal shift is (dt / (2 eps0)) * sigma_abs; adding a nonnegative
310
+ # diagonal to an SPD tensor keeps it SPD, so the inverse stays well defined.
311
+ conductive = solver.conductive_enabled
312
+ conductive_shift = solver.dt / (2.0 * solver.eps0)
313
+ solver._aniso_eps_eff = {}
314
+ solver._aniso_inverse_rows = {}
315
+ for component_name in ("Ex", "Ey", "Ez"):
316
+ edge = {
317
+ key: average_node_to_component(solver, tensor, component_name)
318
+ for key, tensor in node.items()
319
+ }
320
+ if conductive:
321
+ for diag_key, axis in (("xx", "x"), ("yy", "y"), ("zz", "z")):
322
+ sigma_edge = average_node_to_component(solver, sigma_components[axis], component_name)
323
+ edge[diag_key] = edge[diag_key] + conductive_shift * sigma_edge
324
+ inverse, det = _symmetric3_inverse(
325
+ edge["xx"], edge["yy"], edge["zz"], edge["xy"], edge["xz"], edge["yz"]
326
+ )
327
+ diagonal_inverse = inverse[diagonal_key[component_name]]
328
+ if bool(torch.any(det <= 0).item()) or bool(torch.any(diagonal_inverse <= 0).item()):
329
+ raise ValueError(
330
+ "The blended anisotropic permittivity tensor is not positive-definite on the "
331
+ f"{component_name} Yee edges; check overlapping structures and material regions."
332
+ )
333
+ # Effective scalar permittivity reproducing the diagonal inverse entry:
334
+ # the base curl coefficient dt/eps_eff == dt * inv_diag / eps0 == B_ii, and
335
+ # (when conductive) the diagonal decay 1 - sigma * B_ii is folded in by
336
+ # _electric_update_coefficients through the aniso_shifted branch.
337
+ solver._aniso_eps_eff[component_name] = (solver.eps0 / diagonal_inverse).contiguous()
338
+ first_pair, second_pair = _ANISO_ROW_PAIRS[component_name]
339
+ solver._aniso_inverse_rows[component_name] = (
340
+ inverse[first_pair].contiguous(),
341
+ inverse[second_pair].contiguous(),
342
+ )
343
+
344
+ if conductive:
345
+ # Buffers for the pre-update conduction current sigma . E^n, subtracted
346
+ # through the off-diagonal inverse tensor each step (the diagonal part is
347
+ # already folded into the decay coefficient).
348
+ solver._aniso_cond_current = {
349
+ "Ex": torch.zeros_like(solver.Ex),
350
+ "Ey": torch.zeros_like(solver.Ey),
351
+ "Ez": torch.zeros_like(solver.Ez),
352
+ }
353
+
354
+
355
+ def average_node_to_component(solver, node_tensor, component_name):
356
+ del solver
357
+ if component_name == "Ex":
358
+ return (0.5 * (node_tensor[:-1, :, :] + node_tensor[1:, :, :])).contiguous()
359
+ if component_name == "Ey":
360
+ return (0.5 * (node_tensor[:, :-1, :] + node_tensor[:, 1:, :])).contiguous()
361
+ if component_name == "Ez":
362
+ return (0.5 * (node_tensor[:, :, :-1] + node_tensor[:, :, 1:])).contiguous()
363
+ raise ValueError(f"Unsupported electric field component {component_name!r}.")
364
+
365
+
366
+ def _max_node_to_electric_component(node_tensor, component_name):
367
+ """Reduce a per-node field onto a Yee E edge with a MAX over the two-node
368
+ stencil that ``average_node_to_component`` averages. Used for the modulation
369
+ angular-frequency label, where the arithmetic mean would halve the frequency at
370
+ an interface edge; the max keeps the modulated cell's frequency there."""
371
+ if component_name == "Ex":
372
+ return torch.maximum(node_tensor[:-1, :, :], node_tensor[1:, :, :]).contiguous()
373
+ if component_name == "Ey":
374
+ return torch.maximum(node_tensor[:, :-1, :], node_tensor[:, 1:, :]).contiguous()
375
+ if component_name == "Ez":
376
+ return torch.maximum(node_tensor[:, :, :-1], node_tensor[:, :, 1:]).contiguous()
377
+ raise ValueError(f"Unsupported electric field component {component_name!r}.")
378
+
379
+
380
+ def average_node_to_magnetic_component(solver, node_tensor, component_name):
381
+ del solver
382
+ if component_name == "Hx":
383
+ return (
384
+ 0.25
385
+ * (
386
+ node_tensor[:, :-1, :-1]
387
+ + node_tensor[:, :-1, 1:]
388
+ + node_tensor[:, 1:, :-1]
389
+ + node_tensor[:, 1:, 1:]
390
+ )
391
+ ).contiguous()
392
+ if component_name == "Hy":
393
+ return (
394
+ 0.25
395
+ * (
396
+ node_tensor[:-1, :, :-1]
397
+ + node_tensor[:-1, :, 1:]
398
+ + node_tensor[1:, :, :-1]
399
+ + node_tensor[1:, :, 1:]
400
+ )
401
+ ).contiguous()
402
+ if component_name == "Hz":
403
+ return (
404
+ 0.25
405
+ * (
406
+ node_tensor[:-1, :-1, :]
407
+ + node_tensor[:-1, 1:, :]
408
+ + node_tensor[1:, :-1, :]
409
+ + node_tensor[1:, 1:, :]
410
+ )
411
+ ).contiguous()
412
+ raise ValueError(f"Unsupported magnetic field component {component_name!r}.")
413
+
414
+
415
+ _ELECTRIC_COMPONENT_AXIS = {"Ex": "x", "Ey": "y", "Ez": "z"}
416
+
417
+
418
+ def _electric_pole_components(entry):
419
+ """The E components a pole entry drives (sheet poles restrict to tangential axes)."""
420
+ axes = entry.get("axes")
421
+ if axes is None:
422
+ return ("Ex", "Ey", "Ez")
423
+ return tuple(
424
+ component_name
425
+ for component_name, axis in _ELECTRIC_COMPONENT_AXIS.items()
426
+ if axis in axes
427
+ )
428
+
429
+
430
+ def build_dispersive_templates(solver, material_model):
431
+ templates = {
432
+ "Ex": {"inv_eps": (1.0 / solver.eps_Ex).contiguous(), "debye": [], "drude": [], "lorentz": []},
433
+ "Ey": {"inv_eps": (1.0 / solver.eps_Ey).contiguous(), "debye": [], "drude": [], "lorentz": []},
434
+ "Ez": {"inv_eps": (1.0 / solver.eps_Ez).contiguous(), "debye": [], "drude": [], "lorentz": []},
435
+ }
436
+
437
+ for entry in material_model["debye_poles"]:
438
+ pole = entry["pole"]
439
+ decay = (2.0 * pole.tau - solver.dt) / (2.0 * pole.tau + solver.dt)
440
+ base_scale = 2.0 * solver.eps0 * pole.delta_eps * solver.dt / (2.0 * pole.tau + solver.dt)
441
+ for component_name in _electric_pole_components(entry):
442
+ weight = average_node_to_component(solver, entry["weight"], component_name)
443
+ templates[component_name]["debye"].append(
444
+ {
445
+ "decay": float(decay),
446
+ "drive": (weight * base_scale).contiguous(),
447
+ }
448
+ )
449
+
450
+ for entry in material_model["drude_poles"]:
451
+ pole = entry["pole"]
452
+ omega_p = 2.0 * np.pi * pole.plasma_frequency
453
+ gamma = 2.0 * np.pi * pole.gamma
454
+ denom = 2.0 + gamma * solver.dt
455
+ decay = (2.0 - gamma * solver.dt) / denom
456
+ base_scale = 2.0 * solver.eps0 * omega_p * omega_p * solver.dt / denom
457
+ for component_name in _electric_pole_components(entry):
458
+ weight = average_node_to_component(solver, entry["weight"], component_name)
459
+ templates[component_name]["drude"].append(
460
+ {
461
+ "decay": float(decay),
462
+ "drive": (weight * base_scale).contiguous(),
463
+ }
464
+ )
465
+
466
+ for entry in material_model["lorentz_poles"]:
467
+ pole = entry["pole"]
468
+ omega_0 = 2.0 * np.pi * pole.resonance_frequency
469
+ gamma = 2.0 * np.pi * pole.gamma
470
+ denom = 2.0 + gamma * solver.dt
471
+ decay = (2.0 - gamma * solver.dt) / denom
472
+ restoring = 2.0 * omega_0 * omega_0 * solver.dt / denom
473
+ base_scale = 2.0 * solver.eps0 * pole.delta_eps * omega_0 * omega_0 * solver.dt / denom
474
+ for component_name in _electric_pole_components(entry):
475
+ weight = average_node_to_component(solver, entry["weight"], component_name)
476
+ templates[component_name]["lorentz"].append(
477
+ {
478
+ "decay": float(decay),
479
+ "restoring": float(restoring),
480
+ "drive": (weight * base_scale).contiguous(),
481
+ }
482
+ )
483
+
484
+ solver._dispersive_templates = templates
485
+ solver.electric_dispersive_enabled = any(
486
+ templates[component_name][model_name]
487
+ for component_name in ("Ex", "Ey", "Ez")
488
+ for model_name in ("debye", "drude", "lorentz")
489
+ )
490
+
491
+
492
+ def build_magnetic_dispersive_templates(solver, material_model):
493
+ templates = {
494
+ "Hx": {"inv_mu": (1.0 / solver.mu_Hx).contiguous(), "debye": [], "drude": [], "lorentz": []},
495
+ "Hy": {"inv_mu": (1.0 / solver.mu_Hy).contiguous(), "debye": [], "drude": [], "lorentz": []},
496
+ "Hz": {"inv_mu": (1.0 / solver.mu_Hz).contiguous(), "debye": [], "drude": [], "lorentz": []},
497
+ }
498
+
499
+ for entry in material_model["mu_debye_poles"]:
500
+ pole = entry["pole"]
501
+ decay = (2.0 * pole.tau - solver.dt) / (2.0 * pole.tau + solver.dt)
502
+ base_scale = 2.0 * solver.mu0 * pole.delta_eps * solver.dt / (2.0 * pole.tau + solver.dt)
503
+ for component_name in ("Hx", "Hy", "Hz"):
504
+ weight = average_node_to_magnetic_component(solver, entry["weight"], component_name)
505
+ templates[component_name]["debye"].append(
506
+ {
507
+ "decay": float(decay),
508
+ "drive": (weight * base_scale).contiguous(),
509
+ }
510
+ )
511
+
512
+ for entry in material_model["mu_drude_poles"]:
513
+ pole = entry["pole"]
514
+ omega_p = 2.0 * np.pi * pole.plasma_frequency
515
+ gamma = 2.0 * np.pi * pole.gamma
516
+ denom = 2.0 + gamma * solver.dt
517
+ decay = (2.0 - gamma * solver.dt) / denom
518
+ base_scale = 2.0 * solver.mu0 * omega_p * omega_p * solver.dt / denom
519
+ for component_name in ("Hx", "Hy", "Hz"):
520
+ weight = average_node_to_magnetic_component(solver, entry["weight"], component_name)
521
+ templates[component_name]["drude"].append(
522
+ {
523
+ "decay": float(decay),
524
+ "drive": (weight * base_scale).contiguous(),
525
+ }
526
+ )
527
+
528
+ for entry in material_model["mu_lorentz_poles"]:
529
+ pole = entry["pole"]
530
+ omega_0 = 2.0 * np.pi * pole.resonance_frequency
531
+ gamma = 2.0 * np.pi * pole.gamma
532
+ denom = 2.0 + gamma * solver.dt
533
+ decay = (2.0 - gamma * solver.dt) / denom
534
+ restoring = 2.0 * omega_0 * omega_0 * solver.dt / denom
535
+ base_scale = 2.0 * solver.mu0 * pole.delta_eps * omega_0 * omega_0 * solver.dt / denom
536
+ for component_name in ("Hx", "Hy", "Hz"):
537
+ weight = average_node_to_magnetic_component(solver, entry["weight"], component_name)
538
+ templates[component_name]["lorentz"].append(
539
+ {
540
+ "decay": float(decay),
541
+ "restoring": float(restoring),
542
+ "drive": (weight * base_scale).contiguous(),
543
+ }
544
+ )
545
+
546
+ solver._magnetic_dispersive_templates = templates
547
+ solver.magnetic_dispersive_enabled = any(
548
+ templates[component_name][model_name]
549
+ for component_name in ("Hx", "Hy", "Hz")
550
+ for model_name in ("debye", "drude", "lorentz")
551
+ )
552
+
553
+
554
+ def initialize_dispersive_state(solver):
555
+ if not solver._dispersive_templates:
556
+ solver.electric_dispersive_enabled = False
557
+ return
558
+
559
+ if getattr(solver, "full_aniso_enabled", False) and solver.electric_dispersive_enabled:
560
+ # Accumulator for the total per-component polarization current, summed over
561
+ # all electric poles, consumed by the coupled tensor ADE subtraction.
562
+ solver._aniso_disp_current = {
563
+ "Ex": torch.zeros_like(solver.Ex),
564
+ "Ey": torch.zeros_like(solver.Ey),
565
+ "Ez": torch.zeros_like(solver.Ez),
566
+ }
567
+
568
+ for component_name, field in (("Ex", solver.Ex), ("Ey", solver.Ey), ("Ez", solver.Ez)):
569
+ component_templates = solver._dispersive_templates[component_name]
570
+ for entry in component_templates["debye"]:
571
+ entry["polarization"] = torch.zeros_like(field)
572
+ entry["current"] = torch.zeros_like(field)
573
+ entry["polarization_imag"] = torch.zeros_like(field) if has_complex_fields(solver) else None
574
+ entry["current_imag"] = torch.zeros_like(field) if has_complex_fields(solver) else None
575
+ for entry in component_templates["drude"]:
576
+ entry["current"] = torch.zeros_like(field)
577
+ entry["current_imag"] = torch.zeros_like(field) if has_complex_fields(solver) else None
578
+ for entry in component_templates["lorentz"]:
579
+ entry["polarization"] = torch.zeros_like(field)
580
+ entry["current"] = torch.zeros_like(field)
581
+ if has_complex_fields(solver):
582
+ entry["polarization_imag"] = torch.zeros_like(field)
583
+ entry["current_imag"] = torch.zeros_like(field)
584
+ else:
585
+ entry["polarization_imag"] = None
586
+ entry["current_imag"] = None
587
+
588
+
589
+ def initialize_magnetic_dispersive_state(solver):
590
+ if not solver._magnetic_dispersive_templates:
591
+ solver.magnetic_dispersive_enabled = False
592
+ return
593
+
594
+ for component_name, field in (("Hx", solver.Hx), ("Hy", solver.Hy), ("Hz", solver.Hz)):
595
+ component_templates = solver._magnetic_dispersive_templates[component_name]
596
+ for entry in component_templates["debye"]:
597
+ entry["polarization"] = torch.zeros_like(field)
598
+ entry["current"] = torch.zeros_like(field)
599
+ entry["polarization_imag"] = torch.zeros_like(field) if has_complex_fields(solver) else None
600
+ entry["current_imag"] = torch.zeros_like(field) if has_complex_fields(solver) else None
601
+ for entry in component_templates["drude"]:
602
+ entry["current"] = torch.zeros_like(field)
603
+ entry["current_imag"] = torch.zeros_like(field) if has_complex_fields(solver) else None
604
+ for entry in component_templates["lorentz"]:
605
+ entry["polarization"] = torch.zeros_like(field)
606
+ entry["current"] = torch.zeros_like(field)
607
+ if has_complex_fields(solver):
608
+ entry["polarization_imag"] = torch.zeros_like(field)
609
+ entry["current_imag"] = torch.zeros_like(field)
610
+ else:
611
+ entry["polarization_imag"] = None
612
+ entry["current_imag"] = None
613
+
614
+
615
+ def advance_component_dispersive_state(solver, component_name, field, *, imag=False):
616
+ if not solver.electric_dispersive_enabled:
617
+ return
618
+
619
+ component_templates = solver._dispersive_templates.get(component_name)
620
+ if not component_templates:
621
+ return
622
+
623
+ for entry in component_templates["debye"]:
624
+ polarization = entry["polarization_imag"] if imag else entry["polarization"]
625
+ current = entry["current_imag"] if imag else entry["current"]
626
+ solver.fdtd_module.updateDebyeCurrent3D(
627
+ ElectricField=field,
628
+ Polarization=polarization,
629
+ PolarizationCurrent=current,
630
+ DebyeDrive=entry["drive"],
631
+ decay=entry["decay"],
632
+ dt=solver.dt,
633
+ ).launchRaw()
634
+
635
+ for entry in component_templates["drude"]:
636
+ current = entry["current_imag"] if imag else entry["current"]
637
+ solver.fdtd_module.updateDrudeCurrent3D(
638
+ ElectricField=field,
639
+ PolarizationCurrent=current,
640
+ DrudeDrive=entry["drive"],
641
+ decay=entry["decay"],
642
+ ).launchRaw()
643
+
644
+ for entry in component_templates["lorentz"]:
645
+ polarization = entry["polarization_imag"] if imag else entry["polarization"]
646
+ current = entry["current_imag"] if imag else entry["current"]
647
+ solver.fdtd_module.updateLorentzCurrent3D(
648
+ ElectricField=field,
649
+ Polarization=polarization,
650
+ PolarizationCurrent=current,
651
+ LorentzDrive=entry["drive"],
652
+ decay=entry["decay"],
653
+ restoring=entry["restoring"],
654
+ dt=solver.dt,
655
+ ).launchRaw()
656
+
657
+
658
+ def advance_dispersive_state(solver):
659
+ if not solver.electric_dispersive_enabled:
660
+ return
661
+
662
+ advance_component_dispersive_state(solver, "Ex", solver.Ex, imag=False)
663
+ advance_component_dispersive_state(solver, "Ey", solver.Ey, imag=False)
664
+ advance_component_dispersive_state(solver, "Ez", solver.Ez, imag=False)
665
+ if has_complex_fields(solver):
666
+ advance_component_dispersive_state(solver, "Ex", solver.Ex_imag, imag=True)
667
+ advance_component_dispersive_state(solver, "Ey", solver.Ey_imag, imag=True)
668
+ advance_component_dispersive_state(solver, "Ez", solver.Ez_imag, imag=True)
669
+
670
+
671
+ def advance_magnetic_component_dispersive_state(solver, component_name, field, *, imag=False):
672
+ if not solver.magnetic_dispersive_enabled:
673
+ return
674
+
675
+ component_templates = solver._magnetic_dispersive_templates.get(component_name)
676
+ if not component_templates:
677
+ return
678
+
679
+ for entry in component_templates["debye"]:
680
+ polarization = entry["polarization_imag"] if imag else entry["polarization"]
681
+ current = entry["current_imag"] if imag else entry["current"]
682
+ solver.fdtd_module.updateDebyeCurrent3D(
683
+ ElectricField=field,
684
+ Polarization=polarization,
685
+ PolarizationCurrent=current,
686
+ DebyeDrive=entry["drive"],
687
+ decay=entry["decay"],
688
+ dt=solver.dt,
689
+ ).launchRaw()
690
+
691
+ for entry in component_templates["drude"]:
692
+ current = entry["current_imag"] if imag else entry["current"]
693
+ solver.fdtd_module.updateDrudeCurrent3D(
694
+ ElectricField=field,
695
+ PolarizationCurrent=current,
696
+ DrudeDrive=entry["drive"],
697
+ decay=entry["decay"],
698
+ ).launchRaw()
699
+
700
+ for entry in component_templates["lorentz"]:
701
+ polarization = entry["polarization_imag"] if imag else entry["polarization"]
702
+ current = entry["current_imag"] if imag else entry["current"]
703
+ solver.fdtd_module.updateLorentzCurrent3D(
704
+ ElectricField=field,
705
+ Polarization=polarization,
706
+ PolarizationCurrent=current,
707
+ LorentzDrive=entry["drive"],
708
+ decay=entry["decay"],
709
+ restoring=entry["restoring"],
710
+ dt=solver.dt,
711
+ ).launchRaw()
712
+
713
+
714
+ def advance_magnetic_dispersive_state(solver):
715
+ if not solver.magnetic_dispersive_enabled:
716
+ return
717
+
718
+ advance_magnetic_component_dispersive_state(solver, "Hx", solver.Hx, imag=False)
719
+ advance_magnetic_component_dispersive_state(solver, "Hy", solver.Hy, imag=False)
720
+ advance_magnetic_component_dispersive_state(solver, "Hz", solver.Hz, imag=False)
721
+ if has_complex_fields(solver):
722
+ advance_magnetic_component_dispersive_state(solver, "Hx", solver.Hx_imag, imag=True)
723
+ advance_magnetic_component_dispersive_state(solver, "Hy", solver.Hy_imag, imag=True)
724
+ advance_magnetic_component_dispersive_state(solver, "Hz", solver.Hz_imag, imag=True)
725
+
726
+
727
+ def _dispersive_current_coefficient(solver, component_name, component_templates):
728
+ """The ``(coefficient, dt)`` pair for the subtraction ``E -= dt * J_p * coefficient``.
729
+
730
+ In the linear path the polarization current is divided by the static
731
+ permittivity (``coefficient = 1/eps_lin``, ``dt = solver.dt``). When an
732
+ instantaneous nonlinearity is active the same Yee edge's displacement-current
733
+ term in the E update is scaled by the field-dependent ``c*_curl_dynamic``
734
+ coefficient (``external * (dt / eps_eff) / (1 + half)``); reusing that
735
+ coefficient here (with ``dt = 1``) subtracts the ADE current against the SAME
736
+ effective permittivity the ``curl(H)`` term saw this step, so the
737
+ instantaneous-nonlinear index shift and the dispersive response stay mutually
738
+ consistent (dividing the ADE current by ``eps_lin`` instead would detune the
739
+ dispersion by the nonlinear index shift). In a purely dispersive cell the
740
+ dynamic curl reduces to ``dt / eps_lin``, so the linear path is unchanged.
741
+ """
742
+ if getattr(solver, "nonlinear_enabled", False):
743
+ dynamic_curl = {
744
+ "Ex": solver.cex_curl_dynamic,
745
+ "Ey": solver.cey_curl_dynamic,
746
+ "Ez": solver.cez_curl_dynamic,
747
+ }[component_name]
748
+ return dynamic_curl, 1.0
749
+ return component_templates["inv_eps"], solver.dt
750
+
751
+
752
+ _MODULATION_QUADRATURE_FIELDS = {
753
+ "Ex": ("mod_cos_Ex", "mod_sin_Ex", "mod_omega_Ex"),
754
+ "Ey": ("mod_cos_Ey", "mod_sin_Ey", "mod_omega_Ey"),
755
+ "Ez": ("mod_cos_Ez", "mod_sin_Ez", "mod_omega_Ez"),
756
+ }
757
+
758
+
759
+ def apply_component_dispersive_currents(solver, component_name, field, *, imag=False):
760
+ if not solver.electric_dispersive_enabled:
761
+ return
762
+
763
+ component_templates = solver._dispersive_templates.get(component_name)
764
+ if not component_templates:
765
+ return
766
+ inv_eps, apply_dt = _dispersive_current_coefficient(solver, component_name, component_templates)
767
+
768
+ # When the electric dispersion co-exists with a space-time modulation, the ADE
769
+ # polarization current is divided by the same instantaneous eps_inf * m_next as
770
+ # the modulated curl(H) term (see apply_polarization_modulated_kernel); in a
771
+ # purely dispersive cell (m_next == 1) this reduces to the plain subtraction.
772
+ modulated = bool(getattr(solver, "modulation_enabled", False)) and not imag
773
+ if modulated:
774
+ cos_name, sin_name, omega_name = _MODULATION_QUADRATURE_FIELDS[component_name]
775
+ mod_cos = getattr(solver, cos_name)
776
+ mod_sin = getattr(solver, sin_name)
777
+ mod_omega = getattr(solver, omega_name)
778
+
779
+ def _subtract(current):
780
+ if modulated:
781
+ solver.fdtd_module.applyPolarizationCurrentModulated3D(
782
+ ElectricField=field,
783
+ PolarizationCurrent=current,
784
+ InvPermittivity=inv_eps,
785
+ ModCos=mod_cos,
786
+ ModSin=mod_sin,
787
+ ModOmega=mod_omega,
788
+ ModulationTime=solver._modulation_time,
789
+ dt=apply_dt,
790
+ ).launchRaw()
791
+ else:
792
+ solver.fdtd_module.applyPolarizationCurrent3D(
793
+ ElectricField=field,
794
+ PolarizationCurrent=current,
795
+ InvPermittivity=inv_eps,
796
+ dt=apply_dt,
797
+ ).launchRaw()
798
+
799
+ for model_name in ("debye", "drude", "lorentz"):
800
+ for entry in component_templates[model_name]:
801
+ _subtract(entry["current_imag"] if imag else entry["current"])
802
+
803
+
804
+ def _aniso_periodic_flags(solver):
805
+ return (
806
+ int(solver.boundary_x_low_code == BOUNDARY_PERIODIC and solver.boundary_x_high_code == BOUNDARY_PERIODIC),
807
+ int(solver.boundary_y_low_code == BOUNDARY_PERIODIC and solver.boundary_y_high_code == BOUNDARY_PERIODIC),
808
+ int(solver.boundary_z_low_code == BOUNDARY_PERIODIC and solver.boundary_z_high_code == BOUNDARY_PERIODIC),
809
+ )
810
+
811
+
812
+ def _apply_aniso_dispersive_corrections(solver):
813
+ """Subtract the ADE polarization current through the full inverse permittivity tensor.
814
+
815
+ For a full (off-diagonal) anisotropic permittivity the instantaneous
816
+ constitutive relation is ``D = eps_inf . E + P``, so the Ampere update is
817
+ ``E += dt * eps_inf^-1 . (curl H - J_p)``. The base and off-diagonal curl
818
+ kernels already apply ``eps_inf^-1`` (diagonal via ``dt/eps_eff`` and
819
+ off-diagonal via ``c*_aniso_*``) to ``curl H``; this applies the same operator
820
+ to the accumulated polarization current ``J_p``. Electric poles enter
821
+ isotropically (``P_i`` is driven by ``E_i``), so the per-component ADE state
822
+ advance is unchanged and only the subtraction becomes a tensor contraction.
823
+ Full anisotropy runs on real fields only (Bloch/complex is rejected upstream).
824
+ """
825
+ templates = solver._dispersive_templates
826
+ totals = solver._aniso_disp_current
827
+ for component_name in ("Ex", "Ey", "Ez"):
828
+ buffer = totals[component_name]
829
+ buffer.zero_()
830
+ component_templates = templates[component_name]
831
+ for model_name in ("debye", "drude", "lorentz"):
832
+ for entry in component_templates[model_name]:
833
+ buffer.add_(entry["current"])
834
+
835
+ inv_eps = solver._aniso_disp_inv_eps
836
+ for component_name, field in (("Ex", solver.Ex), ("Ey", solver.Ey), ("Ez", solver.Ez)):
837
+ solver.fdtd_module.applyPolarizationCurrent3D(
838
+ ElectricField=field,
839
+ PolarizationCurrent=totals[component_name],
840
+ InvPermittivity=inv_eps[component_name],
841
+ dt=solver.dt,
842
+ ).launchRaw()
843
+
844
+ periodic_x, periodic_y, periodic_z = _aniso_periodic_flags(solver)
845
+ solver.fdtd_module.applyAnisoOffdiagCurrentEx3D(
846
+ Ex=solver.Ex,
847
+ Jy=totals["Ey"],
848
+ Jz=totals["Ez"],
849
+ CoeffY=solver.cex_aniso_y,
850
+ CoeffZ=solver.cex_aniso_z,
851
+ periodicX=periodic_x,
852
+ periodicY=periodic_y,
853
+ periodicZ=periodic_z,
854
+ ).launchRaw()
855
+ solver.fdtd_module.applyAnisoOffdiagCurrentEy3D(
856
+ Ey=solver.Ey,
857
+ Jx=totals["Ex"],
858
+ Jz=totals["Ez"],
859
+ CoeffX=solver.cey_aniso_x,
860
+ CoeffZ=solver.cey_aniso_z,
861
+ periodicX=periodic_x,
862
+ periodicY=periodic_y,
863
+ periodicZ=periodic_z,
864
+ ).launchRaw()
865
+ solver.fdtd_module.applyAnisoOffdiagCurrentEz3D(
866
+ Ez=solver.Ez,
867
+ Jx=totals["Ex"],
868
+ Jy=totals["Ey"],
869
+ CoeffX=solver.cez_aniso_x,
870
+ CoeffY=solver.cez_aniso_y,
871
+ periodicX=periodic_x,
872
+ periodicY=periodic_y,
873
+ periodicZ=periodic_z,
874
+ ).launchRaw()
875
+
876
+
877
+ def apply_dispersive_corrections(solver):
878
+ if not solver.electric_dispersive_enabled:
879
+ return
880
+
881
+ if getattr(solver, "full_aniso_enabled", False):
882
+ _apply_aniso_dispersive_corrections(solver)
883
+ return
884
+
885
+ apply_component_dispersive_currents(solver, "Ex", solver.Ex, imag=False)
886
+ apply_component_dispersive_currents(solver, "Ey", solver.Ey, imag=False)
887
+ apply_component_dispersive_currents(solver, "Ez", solver.Ez, imag=False)
888
+ if has_complex_fields(solver):
889
+ apply_component_dispersive_currents(solver, "Ex", solver.Ex_imag, imag=True)
890
+ apply_component_dispersive_currents(solver, "Ey", solver.Ey_imag, imag=True)
891
+ apply_component_dispersive_currents(solver, "Ez", solver.Ez_imag, imag=True)
892
+
893
+
894
+ def apply_magnetic_component_dispersive_currents(solver, component_name, field, *, imag=False):
895
+ if not solver.magnetic_dispersive_enabled:
896
+ return
897
+
898
+ component_templates = solver._magnetic_dispersive_templates.get(component_name)
899
+ if not component_templates:
900
+ return
901
+ inv_mu = component_templates["inv_mu"]
902
+
903
+ for entry in component_templates["debye"]:
904
+ current = entry["current_imag"] if imag else entry["current"]
905
+ solver.fdtd_module.applyPolarizationCurrent3D(
906
+ ElectricField=field,
907
+ PolarizationCurrent=current,
908
+ InvPermittivity=inv_mu,
909
+ dt=solver.dt,
910
+ ).launchRaw()
911
+
912
+ for entry in component_templates["drude"]:
913
+ current = entry["current_imag"] if imag else entry["current"]
914
+ solver.fdtd_module.applyPolarizationCurrent3D(
915
+ ElectricField=field,
916
+ PolarizationCurrent=current,
917
+ InvPermittivity=inv_mu,
918
+ dt=solver.dt,
919
+ ).launchRaw()
920
+
921
+ for entry in component_templates["lorentz"]:
922
+ current = entry["current_imag"] if imag else entry["current"]
923
+ solver.fdtd_module.applyPolarizationCurrent3D(
924
+ ElectricField=field,
925
+ PolarizationCurrent=current,
926
+ InvPermittivity=inv_mu,
927
+ dt=solver.dt,
928
+ ).launchRaw()
929
+
930
+
931
+ def apply_magnetic_dispersive_corrections(solver):
932
+ if not solver.magnetic_dispersive_enabled:
933
+ return
934
+
935
+ apply_magnetic_component_dispersive_currents(solver, "Hx", solver.Hx, imag=False)
936
+ apply_magnetic_component_dispersive_currents(solver, "Hy", solver.Hy, imag=False)
937
+ apply_magnetic_component_dispersive_currents(solver, "Hz", solver.Hz, imag=False)
938
+ if has_complex_fields(solver):
939
+ apply_magnetic_component_dispersive_currents(solver, "Hx", solver.Hx_imag, imag=True)
940
+ apply_magnetic_component_dispersive_currents(solver, "Hy", solver.Hy_imag, imag=True)
941
+ apply_magnetic_component_dispersive_currents(solver, "Hz", solver.Hz_imag, imag=True)
942
+
943
+
944
+ def _electric_update_coefficients(solver, eps, sigma_e, pml_decay, *, aniso_shifted=False):
945
+ """Compose the semi-implicit lossy-dielectric decay/curl factors for one E component.
946
+
947
+ ``eps`` is the absolute permittivity (eps_r * eps0) and ``sigma_e`` the static
948
+ electric conductivity averaged onto the same Yee component. When conductivity is
949
+ inactive the material factors reduce to (decay=1, curl=dt/eps), so non-conductive
950
+ scenes keep the exact existing coefficients. ``pml_decay`` folds the CPML decay in
951
+ multiplicatively when present.
952
+
953
+ ``aniso_shifted`` selects the full-tensor conductive path: ``eps`` is then the
954
+ effective permittivity ``eps0 / [(eps_inf + dt/2 diag(sigma))^-1]_ii`` whose
955
+ reciprocal is the diagonal of the conductively-shifted inverse tensor, so the
956
+ diagonal curl is already the semi-implicit ``B_ii = dt/eps`` and the decay is
957
+ ``1 - sigma * B_ii`` (the same ``decay = 1 - sigma * curl`` identity the scalar
958
+ lossy fold satisfies). The off-diagonal conduction coupling is applied separately
959
+ through ``B_ij`` by the conduction-current subtraction.
960
+ """
961
+ if solver.conductive_enabled:
962
+ if aniso_shifted:
963
+ curl = solver.dt / eps
964
+ material_decay = 1.0 - sigma_e * curl
965
+ else:
966
+ half = 0.5 * sigma_e * solver.dt / eps
967
+ denom = 1.0 + half
968
+ material_decay = (1.0 - half) / denom
969
+ curl = (solver.dt / eps) / denom
970
+ else:
971
+ material_decay = None
972
+ curl = solver.dt / eps
973
+
974
+ if pml_decay is None:
975
+ decay = material_decay if material_decay is not None else torch.ones_like(eps)
976
+ else:
977
+ decay = pml_decay if material_decay is None else material_decay * pml_decay
978
+ curl = curl * pml_decay
979
+ return decay.contiguous(), curl.contiguous()
980
+
981
+
982
+ def _magnetic_update_coefficients(solver, mu, sigma_m, pml_decay):
983
+ """Compose the semi-implicit magnetically-lossy decay/curl factors for one H component.
984
+
985
+ This is the exact magnetic dual of ``_electric_update_coefficients``: ``mu`` is
986
+ the absolute permeability (mu_r * mu0) and ``sigma_m`` the static magnetic
987
+ conductivity averaged onto the same Yee H component. A magnetic conduction
988
+ current ``sigma_m * H`` on Faraday's law, discretized semi-implicitly with H
989
+ averaged over the two half steps, gives the fold ``decay = (1 - h)/(1 + h)`` and
990
+ ``curl = (dt/mu)/(1 + h)`` with ``h = 0.5 * sigma_m * dt / mu``. When magnetic
991
+ conductivity is inactive the factors reduce to ``(decay=1, curl=dt/mu)``, so a
992
+ non-magnetically-conductive scene keeps the exact existing coefficients.
993
+ ``pml_decay`` folds the split-field CPML decay in multiplicatively when present,
994
+ matching the electric path (the per-cell decay tensor the H kernels already read).
995
+ """
996
+ if solver.magnetically_conductive_enabled:
997
+ half = 0.5 * sigma_m * solver.dt / mu
998
+ denom = 1.0 + half
999
+ material_decay = (1.0 - half) / denom
1000
+ curl = (solver.dt / mu) / denom
1001
+ else:
1002
+ material_decay = None
1003
+ curl = solver.dt / mu
1004
+
1005
+ if pml_decay is None:
1006
+ decay = material_decay if material_decay is not None else torch.ones_like(mu)
1007
+ else:
1008
+ decay = pml_decay if material_decay is None else material_decay * pml_decay
1009
+ curl = curl * pml_decay
1010
+ return decay.contiguous(), curl.contiguous()
1011
+
1012
+
1013
+ def _pec_edge_open_fractions(solver):
1014
+ """Per-E-edge open fractions ``1 - fill`` from the PEC occupancy, or ``None``.
1015
+
1016
+ The PEC fill on each E edge is the two-endpoint node->edge average of the PEC
1017
+ occupancy (same stencil as eps, keeping it consistent and differentiable). In
1018
+ ``staircase`` mode the fill is hard-thresholded at 0.5; in ``conformal`` mode the
1019
+ fractional fill is kept so the effective PEC wall sits at the sub-cell crossing.
1020
+ """
1021
+ model = getattr(solver, "_compiled_material_model", None)
1022
+ pec_occupancy = None if model is None else model.get("pec_occupancy")
1023
+ if pec_occupancy is None:
1024
+ return None
1025
+ mode = model.get("pec_mode", "staircase")
1026
+ open_fractions = {}
1027
+ for component_name in ("Ex", "Ey", "Ez"):
1028
+ fill = average_node_to_component(solver, pec_occupancy, component_name)
1029
+ if mode == "staircase":
1030
+ fill = (fill >= 0.5).to(fill.dtype)
1031
+ open_fractions[component_name] = 1.0 - fill
1032
+ return open_fractions
1033
+
1034
+
1035
+ def _apply_pec_edge_suppression(solver):
1036
+ """Fold the PEC open fraction into the electric decay/curl coefficients.
1037
+
1038
+ Scaling both ``decay`` and ``curl`` by ``open = 1 - fill`` turns the update into
1039
+ ``E_new = open * (decay*E_old + curl*(curlH - J))``, so a fully covered edge
1040
+ (``fill = 1``) keeps tangential E exactly zero while a fractional edge acts as a
1041
+ soft short with sub-cell wall placement. Never amplifies (``open <= 1``), so the
1042
+ scheme is unconditionally stable and needs no area floor.
1043
+ """
1044
+ open_fractions = _pec_edge_open_fractions(solver)
1045
+ if open_fractions is None:
1046
+ return
1047
+ solver.cex_decay = (solver.cex_decay * open_fractions["Ex"]).contiguous()
1048
+ solver.cex_curl = (solver.cex_curl * open_fractions["Ex"]).contiguous()
1049
+ solver.cey_decay = (solver.cey_decay * open_fractions["Ey"]).contiguous()
1050
+ solver.cey_curl = (solver.cey_curl * open_fractions["Ey"]).contiguous()
1051
+ solver.cez_decay = (solver.cez_decay * open_fractions["Ez"]).contiguous()
1052
+ solver.cez_curl = (solver.cez_curl * open_fractions["Ez"]).contiguous()
1053
+ if getattr(solver, "cex_decay_external", None) is not None:
1054
+ # The general nonlinear kernel recomposes decay/curl from this external
1055
+ # factor each step, so the PEC open fraction must fold into it too.
1056
+ solver.cex_decay_external = (solver.cex_decay_external * open_fractions["Ex"]).contiguous()
1057
+ solver.cey_decay_external = (solver.cey_decay_external * open_fractions["Ey"]).contiguous()
1058
+ solver.cez_decay_external = (solver.cez_decay_external * open_fractions["Ez"]).contiguous()
1059
+ if getattr(solver, "full_aniso_enabled", False):
1060
+ # The off-diagonal coupling terms are additive corrections to the same
1061
+ # E edges, so PEC-covered edges must suppress them identically.
1062
+ solver.cex_aniso_y = (solver.cex_aniso_y * open_fractions["Ex"]).contiguous()
1063
+ solver.cex_aniso_z = (solver.cex_aniso_z * open_fractions["Ex"]).contiguous()
1064
+ solver.cey_aniso_x = (solver.cey_aniso_x * open_fractions["Ey"]).contiguous()
1065
+ solver.cey_aniso_z = (solver.cey_aniso_z * open_fractions["Ey"]).contiguous()
1066
+ solver.cez_aniso_x = (solver.cez_aniso_x * open_fractions["Ez"]).contiguous()
1067
+ solver.cez_aniso_y = (solver.cez_aniso_y * open_fractions["Ez"]).contiguous()
1068
+
1069
+
1070
+ _E_COMPONENTS = ("Ex", "Ey", "Ez")
1071
+ _H_COMPONENTS = ("Hx", "Hy", "Hz")
1072
+
1073
+
1074
+ def _axis_index_tuple(ndim, axis, index_or_slice):
1075
+ selector = [slice(None)] * ndim
1076
+ selector[axis] = index_or_slice
1077
+ return tuple(selector)
1078
+
1079
+
1080
+ def _configure_sibc(solver):
1081
+ """Set up the surface-impedance (Leontovich) boundary from the compiled descriptor.
1082
+
1083
+ The good-conductor SIBC is realized by (a) masking the metal interior so its
1084
+ tangential E edges stay zero (a zero open-fraction folded into the update
1085
+ coefficients, exactly as PEC does) and (b) overriding the two tangential E
1086
+ faces on the surface node plane each step from the vacuum-side tangential H,
1087
+ which ``apply_sibc_surface`` in ``stepping.py`` performs. The surface
1088
+ impedance is evaluated at the operating frequency as a narrowband series R-L,
1089
+ ``Zs(omega0) = R + j*omega0*Ls`` with ``R = sqrt(omega0*mu0/(2*sigma))`` and
1090
+ ``Ls = R/omega0``, so ``Zs(omega0)`` reproduces the exact Leontovich value at
1091
+ the source frequency.
1092
+ """
1093
+ solver.sibc_enabled = False
1094
+ solver._sibc = None
1095
+ model = getattr(solver, "_compiled_material_model", None)
1096
+ descriptor = None if model is None else model.get("sibc")
1097
+ if descriptor is None:
1098
+ return
1099
+ axis = int(descriptor["axis"])
1100
+ metal_side = descriptor["metal_side"]
1101
+ surface_node = int(descriptor["surface_node"])
1102
+ sigma = float(descriptor["conductivity"])
1103
+ omega0 = 2.0 * np.pi * float(solver.source_frequency)
1104
+ if omega0 <= 0.0:
1105
+ raise ValueError("LossyMetalMedium SIBC requires a positive operating frequency.")
1106
+ surface_r = float(np.sqrt(omega0 * solver.mu0 / (2.0 * sigma)))
1107
+ surface_l = surface_r / omega0
1108
+
1109
+ # Mask the metal interior: zero the update coefficients of the tangential
1110
+ # E edges strictly inside the metal (node index on the metal side of the
1111
+ # surface) and the normal E edges from the surface inward, so those edges
1112
+ # stay at their zero initial value and the surface behaves as a termination.
1113
+ if metal_side == "high":
1114
+ tangential_interior = slice(surface_node + 1, None)
1115
+ normal_interior = slice(surface_node, None)
1116
+ vacuum_h_index = surface_node - 1
1117
+ else:
1118
+ tangential_interior = slice(None, surface_node)
1119
+ normal_interior = slice(None, surface_node)
1120
+ vacuum_h_index = surface_node
1121
+ for component in _E_COMPONENTS:
1122
+ component_axis = _E_COMPONENTS.index(component)
1123
+ interior = normal_interior if component_axis == axis else tangential_interior
1124
+ decay = getattr(solver, f"c{component.lower()}_decay", None)
1125
+ if decay is None:
1126
+ continue
1127
+ # Open-fraction mask (1 outside the metal, 0 inside), multiplied into the
1128
+ # decay and curl coefficients out-of-place, mirroring the PEC suppression so
1129
+ # a covered edge keeps E exactly zero without perturbing autograd.
1130
+ mask = torch.ones_like(decay)
1131
+ mask[_axis_index_tuple(mask.dim(), axis, interior)] = 0.0
1132
+ for suffix in ("decay", "curl"):
1133
+ name = f"c{component.lower()}_{suffix}"
1134
+ setattr(solver, name, (getattr(solver, name) * mask).contiguous())
1135
+
1136
+ # Tangential components (b, c) in cyclic order after the normal axis, each
1137
+ # paired with the transverse H one cell out on the vacuum side. The Leontovich
1138
+ # relation E_t = Zs * (n_hat x H) with the metal outward normal n_hat gives
1139
+ # E_b = +sn*(R*H_c + Ls*dH_c/dt), E_c = -sn*(R*H_b + Ls*dH_b/dt); sn = +1 when
1140
+ # the metal fills the high side of the surface (n_hat = -axis) and -1 for the
1141
+ # low side. This is the passive (energy-absorbing) branch: the opposite sign is
1142
+ # a negative surface resistance and grows without bound.
1143
+ b = (axis + 1) % 3
1144
+ c = (axis + 2) % 3
1145
+ sn = 1.0 if metal_side == "high" else -1.0
1146
+ faces = (
1147
+ (_E_COMPONENTS[b], _H_COMPONENTS[c], +sn),
1148
+ (_E_COMPONENTS[c], _H_COMPONENTS[b], -sn),
1149
+ )
1150
+ face_state = []
1151
+ for e_name, h_name, sign in faces:
1152
+ h_tensor = getattr(solver, h_name)
1153
+ h_selector = _axis_index_tuple(h_tensor.dim(), axis, vacuum_h_index)
1154
+ e_selector = _axis_index_tuple(getattr(solver, e_name).dim(), axis, surface_node)
1155
+ face_state.append(
1156
+ {
1157
+ "e_name": e_name,
1158
+ "h_name": h_name,
1159
+ "sign": float(sign),
1160
+ "axis": axis,
1161
+ "electric_index": surface_node,
1162
+ "magnetic_index": vacuum_h_index,
1163
+ "e_selector": e_selector,
1164
+ "h_selector": h_selector,
1165
+ "h_prev": torch.zeros_like(h_tensor[h_selector]),
1166
+ }
1167
+ )
1168
+ solver._sibc = {
1169
+ "surface_r": surface_r,
1170
+ "surface_l": surface_l,
1171
+ "faces": tuple(face_state),
1172
+ }
1173
+ solver.sibc_enabled = True
1174
+
1175
+
1176
+ def _store_nonlinear_external_decay(solver, ex_decay, ey_decay, ez_decay):
1177
+ """Keep the material-independent (PML) decay factors for the nonlinear kernel.
1178
+
1179
+ The general nonlinear coefficient kernel recomposes the semi-implicit lossy
1180
+ decay/curl factors from the linear permittivity, the static conductivity, and
1181
+ the field-dependent nonlinear terms every step, so it needs the external
1182
+ (PML split-field) decay separately from the static material decay that
1183
+ ``_electric_update_coefficients`` folds into ``c*_decay``. The PEC open
1184
+ fractions are folded in afterwards by ``_apply_pec_edge_suppression``.
1185
+ """
1186
+ if not getattr(solver, "nonlinear_general_enabled", False):
1187
+ solver.cex_decay_external = None
1188
+ solver.cey_decay_external = None
1189
+ solver.cez_decay_external = None
1190
+ return
1191
+ solver.cex_decay_external = (
1192
+ torch.ones_like(solver.eps_Ex) if ex_decay is None else ex_decay.clone()
1193
+ ).contiguous()
1194
+ solver.cey_decay_external = (
1195
+ torch.ones_like(solver.eps_Ey) if ey_decay is None else ey_decay.clone()
1196
+ ).contiguous()
1197
+ solver.cez_decay_external = (
1198
+ torch.ones_like(solver.eps_Ez) if ez_decay is None else ez_decay.clone()
1199
+ ).contiguous()
1200
+
1201
+
1202
+ def _electric_epsilon_tensors(solver):
1203
+ """The absolute permittivity tensors feeding the electric curl coefficients.
1204
+
1205
+ Full anisotropy swaps in the effective scalar permittivity that reproduces
1206
+ the diagonal entry of the per-edge inverse tensor; the plain per-axis
1207
+ average is kept otherwise (byte-identical to the base path).
1208
+ """
1209
+ if getattr(solver, "full_aniso_enabled", False):
1210
+ eps_eff = solver._aniso_eps_eff
1211
+ return eps_eff["Ex"], eps_eff["Ey"], eps_eff["Ez"]
1212
+ return solver.eps_Ex, solver.eps_Ey, solver.eps_Ez
1213
+
1214
+
1215
+ def _build_full_aniso_curl_coefficients(solver):
1216
+ """Materialize the off-diagonal coupling coefficient tensors.
1217
+
1218
+ ``coeff = dt * inv_ij / eps0`` multiplies the neighbor-averaged off-axis
1219
+ curl(H) component in the correction kernel. The coefficients are zero
1220
+ outside the anisotropic structures, so the correction is a no-op there.
1221
+ """
1222
+ if not getattr(solver, "full_aniso_enabled", False):
1223
+ return
1224
+ scale = solver.dt / solver.eps0
1225
+ rows = solver._aniso_inverse_rows
1226
+ solver.cex_aniso_y = (scale * rows["Ex"][0]).contiguous()
1227
+ solver.cex_aniso_z = (scale * rows["Ex"][1]).contiguous()
1228
+ solver.cey_aniso_x = (scale * rows["Ey"][0]).contiguous()
1229
+ solver.cey_aniso_z = (scale * rows["Ey"][1]).contiguous()
1230
+ solver.cez_aniso_x = (scale * rows["Ez"][0]).contiguous()
1231
+ solver.cez_aniso_y = (scale * rows["Ez"][1]).contiguous()
1232
+ # The diagonal ADE polarization-current subtraction must divide by the same
1233
+ # effective permittivity the base curl coefficient uses (dt/eps_eff), not the
1234
+ # plain per-axis average, so the dispersive response and the tensor curl stay
1235
+ # consistent on off-diagonal edges (they coincide for a diagonal tensor).
1236
+ if getattr(solver, "electric_dispersive_enabled", False):
1237
+ eps_eff = solver._aniso_eps_eff
1238
+ solver._aniso_disp_inv_eps = {
1239
+ component_name: (1.0 / eps_eff[component_name]).contiguous()
1240
+ for component_name in ("Ex", "Ey", "Ez")
1241
+ }
1242
+ _handle_full_aniso_absorber_overlap(solver)
1243
+
1244
+
1245
+ def _absorbing_region_node_mask(solver):
1246
+ """Boolean node mask of the absorbing-boundary region, or ``None``.
1247
+
1248
+ Covers both the graded-sigma split-field absorbers (3D ``sigma_*`` node
1249
+ profiles) and CPML/stable-PML (1D stretching profiles per axis).
1250
+ """
1251
+ if solver.active_absorber_type in ("pml", "absorber"):
1252
+ return (solver.sigma_x != 0) | (solver.sigma_y != 0) | (solver.sigma_z != 0)
1253
+ if getattr(solver, "uses_cpml", False):
1254
+ masks = []
1255
+ for axis_name, view in (("x", (-1, 1, 1)), ("y", (1, -1, 1)), ("z", (1, 1, -1))):
1256
+ c_e = getattr(solver, f"cpml_c_e_{axis_name}", None)
1257
+ inv_kappa = getattr(solver, f"cpml_inv_kappa_e_{axis_name}", None)
1258
+ axis_mask = None
1259
+ if c_e is not None:
1260
+ axis_mask = c_e != 0
1261
+ if inv_kappa is not None:
1262
+ kappa_mask = inv_kappa != 1
1263
+ axis_mask = kappa_mask if axis_mask is None else (axis_mask | kappa_mask)
1264
+ if axis_mask is not None:
1265
+ masks.append(axis_mask.reshape(view))
1266
+ if not masks:
1267
+ return None
1268
+ mask = masks[0]
1269
+ for axis_mask in masks[1:]:
1270
+ mask = mask | axis_mask
1271
+ return mask.expand(solver.Nx, solver.Ny, solver.Nz)
1272
+ return None
1273
+
1274
+
1275
+ def _full_aniso_offdiag_overlaps_absorber(solver):
1276
+ """Whether any off-diagonal coupling coefficient reaches into the absorber."""
1277
+ mask = _absorbing_region_node_mask(solver)
1278
+ if mask is None:
1279
+ return False
1280
+ mask = mask.to(dtype=torch.float32)
1281
+ component_coefficients = (
1282
+ ("Ex", (solver.cex_aniso_y, solver.cex_aniso_z)),
1283
+ ("Ey", (solver.cey_aniso_x, solver.cey_aniso_z)),
1284
+ ("Ez", (solver.cez_aniso_x, solver.cez_aniso_y)),
1285
+ )
1286
+ # The soft SDF occupancy leaves a vanishing (denormal-scale) tail far outside
1287
+ # the structure, so compare against a relative threshold instead of != 0.
1288
+ peak = max(
1289
+ float(coefficient.abs().max())
1290
+ for _, coefficients in component_coefficients
1291
+ for coefficient in coefficients
1292
+ )
1293
+ if peak == 0.0:
1294
+ return False
1295
+ threshold = 1.0e-6 * peak
1296
+ for component_name, coefficients in component_coefficients:
1297
+ edge_mask = average_node_to_component(solver, mask, component_name) > 0
1298
+ for coefficient in coefficients:
1299
+ if bool(torch.any(edge_mask & (coefficient.abs() > threshold)).item()):
1300
+ return True
1301
+ return False
1302
+
1303
+
1304
+ def _initialize_full_aniso_cpml_psi(solver):
1305
+ """Allocate the per-direction off-diagonal CPML psi accumulators.
1306
+
1307
+ The CPML off-diagonal correction kernel coordinate-stretches each of the
1308
+ three spatial derivative directions with an independent recursive-convolution
1309
+ memory owned by the target E edge. The buffers share the E-field shapes and
1310
+ start at zero, so a structure that never reaches the absorber leaves them at
1311
+ zero and the correction is byte-consistent with the raw (no-stretch) update.
1312
+ """
1313
+ for field_name, component in (("Ex", "ex"), ("Ey", "ey"), ("Ez", "ez")):
1314
+ field = getattr(solver, field_name)
1315
+ for axis in ("x", "y", "z"):
1316
+ setattr(solver, f"psi_{component}_aniso_{axis}", torch.zeros_like(field))
1317
+
1318
+
1319
+ def _handle_full_aniso_absorber_overlap(solver):
1320
+ """Route an anisotropic structure that overlaps the absorbing boundary.
1321
+
1322
+ Under CPML the dedicated off-diagonal kernel coordinate-stretches the tensor
1323
+ coupling with its own psi memory (allocated here), so the overlap is
1324
+ supported; the overlap is recorded because the reverse adjoint replay does
1325
+ not yet stretch the off-diagonal, and the split-field graded-sigma absorbers
1326
+ ('pml'/'absorber') carry no per-direction auxiliary memory to stretch the
1327
+ coupling, so an overlap there is rejected with a physical reason.
1328
+ """
1329
+ solver._full_aniso_cpml_overlap = False
1330
+ if getattr(solver, "uses_cpml", False):
1331
+ _initialize_full_aniso_cpml_psi(solver)
1332
+ solver._full_aniso_cpml_overlap = _full_aniso_offdiag_overlaps_absorber(solver)
1333
+ return
1334
+ if _full_aniso_offdiag_overlaps_absorber(solver):
1335
+ raise NotImplementedError(
1336
+ "FDTD full (off-diagonal) anisotropic media overlapping a split-field "
1337
+ "PML/absorber layer are not supported: the graded-sigma absorber carries no "
1338
+ "per-direction auxiliary memory to coordinate-stretch the off-diagonal tensor "
1339
+ "coupling. Use the default CPML absorber for anisotropic structures that reach "
1340
+ "the boundary region."
1341
+ )
1342
+
1343
+
1344
+ def _uniform_coefficient_value(tensor: torch.Tensor) -> float | None:
1345
+ minimum, maximum = torch.aminmax(tensor)
1346
+ if not bool(torch.eq(minimum, maximum).item()):
1347
+ return None
1348
+ return float(minimum.item())
1349
+
1350
+
1351
+ def _store_coefficient_uniformity(solver) -> None:
1352
+ solver._coefficient_uniformity = {
1353
+ name: _uniform_coefficient_value(getattr(solver, name))
1354
+ for name in (
1355
+ "cex_decay",
1356
+ "cex_curl",
1357
+ "cey_decay",
1358
+ "cey_curl",
1359
+ "cez_decay",
1360
+ "cez_curl",
1361
+ "chx_decay",
1362
+ "chx_curl",
1363
+ "chy_decay",
1364
+ "chy_curl",
1365
+ "chz_decay",
1366
+ "chz_curl",
1367
+ )
1368
+ }
1369
+
1370
+
1371
+ def build_update_coefficients(solver):
1372
+ eps_ex, eps_ey, eps_ez = _electric_epsilon_tensors(solver)
1373
+ # Under full anisotropy eps_e* is the conductively-shifted effective permittivity,
1374
+ # so the diagonal decay/curl must use the aniso_shifted semi-implicit identity
1375
+ # rather than re-applying the scalar (1 + half) denominator.
1376
+ aniso_shifted = getattr(solver, "full_aniso_enabled", False)
1377
+ if solver.active_absorber_type not in ("pml", "absorber"):
1378
+ solver.cex_decay, solver.cex_curl = _electric_update_coefficients(solver, eps_ex, solver.sigma_e_Ex, None, aniso_shifted=aniso_shifted)
1379
+ solver.cey_decay, solver.cey_curl = _electric_update_coefficients(solver, eps_ey, solver.sigma_e_Ey, None, aniso_shifted=aniso_shifted)
1380
+ solver.cez_decay, solver.cez_curl = _electric_update_coefficients(solver, eps_ez, solver.sigma_e_Ez, None, aniso_shifted=aniso_shifted)
1381
+ solver.chx_decay, solver.chx_curl = _magnetic_update_coefficients(solver, solver.mu_Hx, solver.sigma_m_Hx, None)
1382
+ solver.chy_decay, solver.chy_curl = _magnetic_update_coefficients(solver, solver.mu_Hy, solver.sigma_m_Hy, None)
1383
+ solver.chz_decay, solver.chz_curl = _magnetic_update_coefficients(solver, solver.mu_Hz, solver.sigma_m_Hz, None)
1384
+ _store_nonlinear_external_decay(solver, None, None, None)
1385
+ _build_full_aniso_curl_coefficients(solver)
1386
+ _apply_pec_edge_suppression(solver)
1387
+ _configure_sibc(solver)
1388
+ _store_coefficient_uniformity(solver)
1389
+ return
1390
+
1391
+ ex_sigma_y = 0.5 * (solver.sigma_y[:-1, :, :] + solver.sigma_y[1:, :, :])
1392
+ ex_sigma_z = 0.5 * (solver.sigma_z[:-1, :, :] + solver.sigma_z[1:, :, :])
1393
+ ex_decay = 1.0 / (1.0 + solver.dt * (ex_sigma_y + ex_sigma_z))
1394
+ solver.cex_decay, solver.cex_curl = _electric_update_coefficients(solver, eps_ex, solver.sigma_e_Ex, ex_decay, aniso_shifted=aniso_shifted)
1395
+
1396
+ ey_sigma_x = 0.5 * (solver.sigma_x[:, :-1, :] + solver.sigma_x[:, 1:, :])
1397
+ ey_sigma_z = 0.5 * (solver.sigma_z[:, :-1, :] + solver.sigma_z[:, 1:, :])
1398
+ ey_decay = 1.0 / (1.0 + solver.dt * (ey_sigma_x + ey_sigma_z))
1399
+ solver.cey_decay, solver.cey_curl = _electric_update_coefficients(solver, eps_ey, solver.sigma_e_Ey, ey_decay, aniso_shifted=aniso_shifted)
1400
+
1401
+ ez_sigma_x = 0.5 * (solver.sigma_x[:, :, :-1] + solver.sigma_x[:, :, 1:])
1402
+ ez_sigma_y = 0.5 * (solver.sigma_y[:, :, :-1] + solver.sigma_y[:, :, 1:])
1403
+ ez_decay = 1.0 / (1.0 + solver.dt * (ez_sigma_x + ez_sigma_y))
1404
+ solver.cez_decay, solver.cez_curl = _electric_update_coefficients(solver, eps_ez, solver.sigma_e_Ez, ez_decay, aniso_shifted=aniso_shifted)
1405
+
1406
+ hx_sigma_y = 0.25 * (
1407
+ solver.sigma_y[:, :-1, :-1]
1408
+ + solver.sigma_y[:, 1:, :-1]
1409
+ + solver.sigma_y[:, :-1, 1:]
1410
+ + solver.sigma_y[:, 1:, 1:]
1411
+ )
1412
+ hx_sigma_z = 0.25 * (
1413
+ solver.sigma_z[:, :-1, :-1]
1414
+ + solver.sigma_z[:, 1:, :-1]
1415
+ + solver.sigma_z[:, :-1, 1:]
1416
+ + solver.sigma_z[:, 1:, 1:]
1417
+ )
1418
+ hx_decay = 1.0 / (1.0 + solver.dt * (hx_sigma_y + hx_sigma_z))
1419
+ solver.chx_decay, solver.chx_curl = _magnetic_update_coefficients(solver, solver.mu_Hx, solver.sigma_m_Hx, hx_decay)
1420
+
1421
+ hy_sigma_x = 0.25 * (
1422
+ solver.sigma_x[:-1, :, :-1]
1423
+ + solver.sigma_x[1:, :, :-1]
1424
+ + solver.sigma_x[:-1, :, 1:]
1425
+ + solver.sigma_x[1:, :, 1:]
1426
+ )
1427
+ hy_sigma_z = 0.25 * (
1428
+ solver.sigma_z[:-1, :, :-1]
1429
+ + solver.sigma_z[1:, :, :-1]
1430
+ + solver.sigma_z[:-1, :, 1:]
1431
+ + solver.sigma_z[1:, :, 1:]
1432
+ )
1433
+ hy_decay = 1.0 / (1.0 + solver.dt * (hy_sigma_x + hy_sigma_z))
1434
+ solver.chy_decay, solver.chy_curl = _magnetic_update_coefficients(solver, solver.mu_Hy, solver.sigma_m_Hy, hy_decay)
1435
+
1436
+ hz_sigma_x = 0.25 * (
1437
+ solver.sigma_x[:-1, :-1, :]
1438
+ + solver.sigma_x[1:, :-1, :]
1439
+ + solver.sigma_x[:-1, 1:, :]
1440
+ + solver.sigma_x[1:, 1:, :]
1441
+ )
1442
+ hz_sigma_y = 0.25 * (
1443
+ solver.sigma_y[:-1, :-1, :]
1444
+ + solver.sigma_y[1:, :-1, :]
1445
+ + solver.sigma_y[:-1, 1:, :]
1446
+ + solver.sigma_y[1:, 1:, :]
1447
+ )
1448
+ hz_decay = 1.0 / (1.0 + solver.dt * (hz_sigma_x + hz_sigma_y))
1449
+ solver.chz_decay, solver.chz_curl = _magnetic_update_coefficients(solver, solver.mu_Hz, solver.sigma_m_Hz, hz_decay)
1450
+
1451
+ _store_nonlinear_external_decay(solver, ex_decay, ey_decay, ez_decay)
1452
+ _build_full_aniso_curl_coefficients(solver)
1453
+ _apply_pec_edge_suppression(solver)
1454
+ _configure_sibc(solver)
1455
+ _store_coefficient_uniformity(solver)
1456
+
1457
+
1458
+ def update_nonlinear_electric_coefficients(solver):
1459
+ """Recompute the field-dependent electric update coefficients for this step.
1460
+
1461
+ Pure-chi3 (Kerr) scenes keep the existing curl-only kernel; scenes with a
1462
+ chi2 channel or field-dependent conductivity use the general nonlinear
1463
+ kernel that rewrites both the decay and curl coefficients.
1464
+ """
1465
+ if not getattr(solver, "nonlinear_enabled", False):
1466
+ return
1467
+ if getattr(solver, "nonlinear_general_enabled", False):
1468
+ update_general_nonlinear_coefficients(solver)
1469
+ return
1470
+ update_kerr_electric_curls(solver)
1471
+
1472
+
1473
+ def update_general_nonlinear_coefficients(solver):
1474
+ for component_name, decay_dynamic, curl_dynamic, eps, external, sigma_static, chi2, chi3, tpa in (
1475
+ (
1476
+ "Ex",
1477
+ solver.cex_decay_dynamic,
1478
+ solver.cex_curl_dynamic,
1479
+ solver.eps_Ex,
1480
+ solver.cex_decay_external,
1481
+ solver.sigma_e_Ex,
1482
+ solver.nonlinear_chi2_Ex,
1483
+ solver.kerr_chi3_Ex,
1484
+ solver.tpa_sigma_Ex,
1485
+ ),
1486
+ (
1487
+ "Ey",
1488
+ solver.cey_decay_dynamic,
1489
+ solver.cey_curl_dynamic,
1490
+ solver.eps_Ey,
1491
+ solver.cey_decay_external,
1492
+ solver.sigma_e_Ey,
1493
+ solver.nonlinear_chi2_Ey,
1494
+ solver.kerr_chi3_Ey,
1495
+ solver.tpa_sigma_Ey,
1496
+ ),
1497
+ (
1498
+ "Ez",
1499
+ solver.cez_decay_dynamic,
1500
+ solver.cez_curl_dynamic,
1501
+ solver.eps_Ez,
1502
+ solver.cez_decay_external,
1503
+ solver.sigma_e_Ez,
1504
+ solver.nonlinear_chi2_Ez,
1505
+ solver.kerr_chi3_Ez,
1506
+ solver.tpa_sigma_Ez,
1507
+ ),
1508
+ ):
1509
+ solver.fdtd_module.updateNonlinearElectricCoefficients3D(
1510
+ DynamicDecay=decay_dynamic,
1511
+ DynamicCurl=curl_dynamic,
1512
+ Ex=solver.Ex,
1513
+ Ey=solver.Ey,
1514
+ Ez=solver.Ez,
1515
+ LinearPermittivity=eps,
1516
+ ExternalDecay=external,
1517
+ SigmaStatic=sigma_static,
1518
+ Chi2=chi2,
1519
+ Chi3=chi3,
1520
+ TpaSigma=tpa,
1521
+ component=component_name,
1522
+ dt=solver.dt,
1523
+ eps0=solver.eps0,
1524
+ ).launchRaw()
1525
+
1526
+
1527
+ def update_kerr_electric_curls(solver):
1528
+ if not solver.kerr_enabled:
1529
+ return
1530
+
1531
+ solver.fdtd_module.updateKerrElectricFieldExCurl3D(
1532
+ DynamicCurl=solver.cex_curl_dynamic,
1533
+ Ex=solver.Ex,
1534
+ Ey=solver.Ey,
1535
+ Ez=solver.Ez,
1536
+ LinearPermittivity=solver.eps_Ex,
1537
+ ExDecay=solver.cex_decay,
1538
+ KerrChi3=solver.kerr_chi3_Ex,
1539
+ dt=solver.dt,
1540
+ eps0=solver.eps0,
1541
+ ).launchRaw()
1542
+ solver.fdtd_module.updateKerrElectricFieldEyCurl3D(
1543
+ DynamicCurl=solver.cey_curl_dynamic,
1544
+ Ex=solver.Ex,
1545
+ Ey=solver.Ey,
1546
+ Ez=solver.Ez,
1547
+ LinearPermittivity=solver.eps_Ey,
1548
+ EyDecay=solver.cey_decay,
1549
+ KerrChi3=solver.kerr_chi3_Ey,
1550
+ dt=solver.dt,
1551
+ eps0=solver.eps0,
1552
+ ).launchRaw()
1553
+ solver.fdtd_module.updateKerrElectricFieldEzCurl3D(
1554
+ DynamicCurl=solver.cez_curl_dynamic,
1555
+ Ex=solver.Ex,
1556
+ Ey=solver.Ey,
1557
+ Ez=solver.Ez,
1558
+ LinearPermittivity=solver.eps_Ez,
1559
+ EzDecay=solver.cez_decay,
1560
+ KerrChi3=solver.kerr_chi3_Ez,
1561
+ dt=solver.dt,
1562
+ eps0=solver.eps0,
1563
+ ).launchRaw()