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,3614 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from dataclasses import dataclass
5
+ from types import SimpleNamespace
6
+ from typing import Any
7
+
8
+ import torch
9
+
10
+ from ...sources import evaluate_source_time
11
+ from ..boundary import (
12
+ BOUNDARY_BLOCH,
13
+ BOUNDARY_NONE,
14
+ BOUNDARY_PEC,
15
+ BOUNDARY_PML,
16
+ has_complex_fields,
17
+ )
18
+ from .profiler import _ReverseStepResult
19
+ from ..checkpoint import (
20
+ FDTDCheckpointSchema,
21
+ checkpoint_schema,
22
+ clone_checkpoint_tensors,
23
+ dispersive_state_name,
24
+ iter_dispersive_state_specs,
25
+ iter_magnetic_dispersive_state_specs,
26
+ validate_checkpoint_state,
27
+ )
28
+ from ..excitation.injection import initialize_source_terms
29
+ from ..excitation.temporal import _resolve_term_source
30
+ from ..excitation.tfsf_specs import reference_sample_axis_code
31
+ from ..observers import (
32
+ _align_plane_monitor_payload,
33
+ _compute_plane_flux,
34
+ _monitor_payload_is_point,
35
+ _plane_coord_names,
36
+ )
37
+
38
+ _TFSF_REFERENCE_PROVIDERS = {"plane_wave_ref_x_ez", "plane_wave_axis_aligned"}
39
+ _TFSF_AUXILIARY_PROVIDERS = {"plane_wave_ref_x_ez", "plane_wave_axis_aligned", "plane_wave_aux"}
40
+
41
+
42
+ def _monitor_template_is_point(template):
43
+ if "field_indices" in template:
44
+ return True
45
+ if "axis" in template:
46
+ return False
47
+ kind = template.get("kind")
48
+ if kind is not None:
49
+ return kind == "point"
50
+ return False
51
+
52
+
53
+ def _prepare_point_monitor_template(payload, start_index):
54
+ component_indices = {}
55
+ next_index = start_index
56
+ for component_name in payload["fields"]:
57
+ component_indices[component_name] = next_index
58
+ next_index += 1
59
+ template = {
60
+ "kind": "point",
61
+ "fields": tuple(payload["fields"]),
62
+ "field_indices": dict(payload.get("field_indices", {})),
63
+ "samples": payload.get("samples"),
64
+ "frequency": payload.get("frequency"),
65
+ "frequencies": tuple(payload.get("frequencies", (payload.get("frequency"),))),
66
+ "position": payload.get("position"),
67
+ "component_indices": component_indices,
68
+ }
69
+ return template, next_index
70
+
71
+
72
+ def _prepare_plane_monitor_template(payload, start_index):
73
+ component_templates = {}
74
+ next_index = start_index
75
+ for component_name in payload["fields"]:
76
+ component_payload = payload["components"][component_name]
77
+ component_templates[component_name] = {
78
+ "output_index": next_index,
79
+ "coords": component_payload["coords"],
80
+ "plane_index": component_payload["plane_index"],
81
+ "plane_indices": tuple(component_payload.get("plane_indices", (component_payload["plane_index"],))),
82
+ "plane_weights": tuple(component_payload.get("plane_weights", (1.0,))),
83
+ "plane_positions": tuple(component_payload.get("plane_positions", (float(payload["position"]),))),
84
+ }
85
+ next_index += 1
86
+ template = {
87
+ "kind": "plane",
88
+ "monitor_type": payload.get("monitor_type", "plane"),
89
+ "fields": tuple(payload["fields"]),
90
+ "plane_indices": dict(payload.get("plane_indices", {})),
91
+ "samples": payload.get("samples"),
92
+ "frequency": payload.get("frequency"),
93
+ "frequencies": tuple(payload.get("frequencies", (payload.get("frequency"),))),
94
+ "axis": payload["axis"],
95
+ "position": payload["position"],
96
+ "compute_flux": bool(payload.get("compute_flux", False)),
97
+ "normal_direction": payload.get("normal_direction", "+"),
98
+ "mode_spec": payload.get("mode_spec"),
99
+ "components": component_templates,
100
+ }
101
+ return template, next_index
102
+
103
+
104
+ def _prepare_forward_pack(raw_output):
105
+ output_tensors = []
106
+ field_names = []
107
+ for field_name in ("Ex", "Ey", "Ez"):
108
+ value = raw_output.get(field_name)
109
+ if isinstance(value, torch.Tensor):
110
+ field_names.append(field_name)
111
+ output_tensors.append(value)
112
+
113
+ monitor_templates = {}
114
+ next_index = len(output_tensors)
115
+ for monitor_name, payload in raw_output.get("observers", {}).items():
116
+ if _monitor_payload_is_point(payload):
117
+ template, next_index = _prepare_point_monitor_template(payload, next_index)
118
+ else:
119
+ template, next_index = _prepare_plane_monitor_template(payload, next_index)
120
+ monitor_templates[monitor_name] = template
121
+ if _monitor_template_is_point(template):
122
+ for component_name in template["fields"]:
123
+ output_tensors.append(payload["components"][component_name])
124
+ else:
125
+ for component_name in template["fields"]:
126
+ output_tensors.append(payload["components"][component_name]["data"])
127
+
128
+ return _ForwardPack(
129
+ field_names=tuple(field_names),
130
+ monitor_templates=monitor_templates,
131
+ output_tensors=tuple(output_tensors),
132
+ )
133
+
134
+
135
+ def _finalize_point_monitor_payload(payload):
136
+ primary_component = payload["fields"][0]
137
+ for component_name, value in payload["components"].items():
138
+ payload[component_name] = value
139
+ if len(payload["components"]) == 1:
140
+ payload["component"] = primary_component.lower()
141
+ payload["data"] = payload["components"][primary_component]
142
+ payload["field_index"] = payload["field_indices"][primary_component]
143
+ return payload
144
+
145
+
146
+ def _finalize_plane_monitor_payload(payload):
147
+ axis = payload["axis"]
148
+ coord_names = _plane_coord_names(axis)
149
+ primary_component = payload["fields"][0]
150
+
151
+ if len(payload["components"]) == 1:
152
+ component_payload = payload["components"][primary_component]
153
+ payload["component"] = primary_component.lower()
154
+ payload["data"] = component_payload["data"]
155
+ payload["coords"] = component_payload["coords"]
156
+ payload["plane_index"] = component_payload["plane_index"]
157
+ payload["plane_sample_indices"] = component_payload["plane_indices"]
158
+ payload["plane_sample_weights"] = component_payload["plane_weights"]
159
+ payload["plane_sample_positions"] = component_payload["plane_positions"]
160
+ payload[coord_names[0]] = component_payload["coords"][0]
161
+ payload[coord_names[1]] = component_payload["coords"][1]
162
+ payload[primary_component] = component_payload["data"]
163
+ if payload.get("compute_flux"):
164
+ flux = _compute_plane_flux(payload)
165
+ payload["flux"] = flux
166
+ payload["power"] = flux
167
+ return payload
168
+
169
+ aligned = _align_plane_monitor_payload(axis, payload["components"])
170
+ if aligned is None:
171
+ if payload.get("compute_flux"):
172
+ raise ValueError(
173
+ f"Monitor {payload!r} is missing aligned tangential fields required for flux integration."
174
+ )
175
+ return payload
176
+
177
+ payload[coord_names[0]] = aligned[coord_names[0]]
178
+ payload[coord_names[1]] = aligned[coord_names[1]]
179
+ payload["coords"] = (aligned[coord_names[0]], aligned[coord_names[1]])
180
+ for component_name, value in aligned["fields"].items():
181
+ payload[component_name] = value
182
+ if payload.get("compute_flux"):
183
+ flux = _compute_plane_flux(payload)
184
+ payload["flux"] = flux
185
+ payload["power"] = flux
186
+ return payload
187
+
188
+
189
+ def _rebuild_monitors(monitor_templates, output_tensors, field_offset):
190
+ monitors = {}
191
+ cursor = field_offset
192
+ for monitor_name, template in monitor_templates.items():
193
+ if _monitor_template_is_point(template):
194
+ payload = {
195
+ "kind": "point",
196
+ "fields": template["fields"],
197
+ "components": {},
198
+ "field_indices": dict(template["field_indices"]),
199
+ "samples": template["samples"],
200
+ "frequency": template["frequency"],
201
+ "frequencies": template["frequencies"],
202
+ "position": template["position"],
203
+ }
204
+ for component_name in template["fields"]:
205
+ payload["components"][component_name] = output_tensors[cursor]
206
+ cursor += 1
207
+ monitors[monitor_name] = _finalize_point_monitor_payload(payload)
208
+ continue
209
+
210
+ payload = {
211
+ "kind": "plane",
212
+ "monitor_type": template.get("monitor_type", "plane"),
213
+ "fields": template["fields"],
214
+ "components": {},
215
+ "plane_indices": dict(template["plane_indices"]),
216
+ "samples": template["samples"],
217
+ "frequency": template["frequency"],
218
+ "frequencies": template["frequencies"],
219
+ "axis": template["axis"],
220
+ "position": template["position"],
221
+ "compute_flux": template["compute_flux"],
222
+ "normal_direction": template["normal_direction"],
223
+ "mode_spec": template.get("mode_spec"),
224
+ }
225
+ for component_name in template["fields"]:
226
+ component_template = template["components"][component_name]
227
+ payload["components"][component_name] = {
228
+ "data": output_tensors[cursor],
229
+ "coords": component_template["coords"],
230
+ "plane_index": component_template["plane_index"],
231
+ "plane_indices": component_template["plane_indices"],
232
+ "plane_weights": component_template["plane_weights"],
233
+ "plane_positions": component_template["plane_positions"],
234
+ }
235
+ cursor += 1
236
+ monitors[monitor_name] = _finalize_plane_monitor_payload(payload)
237
+ return monitors
238
+
239
+
240
+ def _term_grid_tensor(term):
241
+ for key in ("patch", "delay_patch", "cw_cos_patch", "cw_sin_patch"):
242
+ value = term.get(key)
243
+ if value is not None:
244
+ return value
245
+ raise ValueError("source term does not include a tensor payload.")
246
+
247
+
248
+ def _slice_from_offsets_shape(offsets, shape):
249
+ return tuple(slice(int(offset), int(offset) + int(size)) for offset, size in zip(offsets, shape))
250
+
251
+
252
+ def _safe_grad(grad, reference):
253
+ """Normalize optional autograd output while constructing monitor seeds."""
254
+ if grad is None:
255
+ return torch.zeros_like(reference)
256
+ return grad.to(device=reference.device, dtype=reference.dtype)
257
+
258
+
259
+ def _validate_checkpoint_sequence(checkpoints) -> FDTDCheckpointSchema:
260
+ if not checkpoints:
261
+ raise RuntimeError("Adjoint replay requires at least one checkpoint.")
262
+ schema = validate_checkpoint_state(checkpoints[0])
263
+ for checkpoint in checkpoints[1:]:
264
+ validate_checkpoint_state(checkpoint, expected_schema=schema)
265
+ return schema
266
+
267
+
268
+ def _checkpoint_stride(simulation, time_steps: int) -> int:
269
+ configured = getattr(simulation.config, "adjoint_checkpoint_stride", None)
270
+ if configured is not None:
271
+ return max(1, int(configured))
272
+ return max(1, int(math.sqrt(max(time_steps, 1))))
273
+
274
+
275
+ def _source_time_kind_code(source_time) -> int:
276
+ return int(source_time["kind_code"])
277
+
278
+
279
+ def _custom_source_time_tensor(source_time, sample_time: torch.Tensor) -> torch.Tensor:
280
+ amplitude = float(source_time["amplitude"])
281
+ times = source_time.get("times")
282
+ if times is None:
283
+ raise NotImplementedError(
284
+ "Callable CustomSourceTime(fn) is not supported in the FDTD adjoint; "
285
+ "provide a sampled (times, amplitudes) table instead."
286
+ )
287
+ xp = sample_time.new_tensor(times)
288
+ fp = sample_time.new_tensor(source_time["amplitudes"])
289
+ upper = torch.searchsorted(xp, sample_time, right=True).clamp(1, xp.numel() - 1)
290
+ lower = upper - 1
291
+ x0 = xp[lower]
292
+ x1 = xp[upper]
293
+ weight = (sample_time - x0) / (x1 - x0)
294
+ interpolated = fp[lower] + weight * (fp[upper] - fp[lower])
295
+ inside = (sample_time >= xp[0]) & (sample_time <= xp[-1])
296
+ interpolated = torch.where(inside, interpolated, torch.zeros_like(interpolated))
297
+ return amplitude * interpolated
298
+
299
+
300
+ def _evaluate_source_time_tensor(source_time, sample_time: torch.Tensor) -> torch.Tensor:
301
+ kind_code = _source_time_kind_code(source_time)
302
+ two_pi = 2.0 * math.pi
303
+ amplitude = float(source_time["amplitude"])
304
+ frequency = float(source_time["frequency"])
305
+ phase = float(source_time.get("phase", 0.0))
306
+ delay = float(source_time.get("delay", 0.0))
307
+
308
+ if kind_code == 3:
309
+ return _custom_source_time_tensor(source_time, sample_time)
310
+
311
+ if kind_code == 0:
312
+ return amplitude * torch.cos(two_pi * frequency * sample_time + phase)
313
+
314
+ if kind_code == 1:
315
+ fwidth = float(source_time["fwidth"])
316
+ sigma_t = 1.0 / max(two_pi * fwidth, 1.0e-30)
317
+ tau = sample_time - delay
318
+ envelope = torch.exp(-0.5 * (tau / sigma_t) ** 2)
319
+ return amplitude * envelope * torch.cos(two_pi * frequency * tau + phase)
320
+
321
+ tau = sample_time - delay
322
+ alpha = math.pi * frequency * tau
323
+ alpha_sq = alpha * alpha
324
+ return amplitude * (1.0 - 2.0 * alpha_sq) * torch.exp(-alpha_sq)
325
+
326
+
327
+ def _resolve_cw_signal(source_time, omega, time_value):
328
+ phase = float(omega) * float(time_value) + float(source_time["phase"])
329
+ amplitude = float(source_time["amplitude"])
330
+ return amplitude * math.cos(phase), amplitude * math.sin(phase)
331
+
332
+
333
+ def _dynamic_electric_curl(base_curl: torch.Tensor, base_eps: torch.Tensor, eps: torch.Tensor) -> torch.Tensor:
334
+ return ((base_curl * base_eps) / eps).contiguous()
335
+
336
+
337
+ def _build_source_replay_solver(solver, compiled_sources, eps_ex, eps_ey, eps_ez):
338
+ compiled_sources = tuple(compiled_sources)
339
+ primary = compiled_sources[0]
340
+ return SimpleNamespace(
341
+ scene=solver.scene,
342
+ min_dx=solver.min_dx,
343
+ min_dy=solver.min_dy,
344
+ min_dz=solver.min_dz,
345
+ Nx=solver.Nx,
346
+ Ny=solver.Ny,
347
+ Nz=solver.Nz,
348
+ device=solver.device,
349
+ Ex=eps_ex,
350
+ Ey=eps_ey,
351
+ Ez=eps_ez,
352
+ Hx=solver.Hx,
353
+ Hy=solver.Hy,
354
+ Hz=solver.Hz,
355
+ dt=solver.dt,
356
+ c=solver.c,
357
+ eps0=solver.eps0,
358
+ source_frequency=solver.source_frequency,
359
+ source_omega=solver.source_omega,
360
+ eps_Ex=eps_ex,
361
+ eps_Ey=eps_ey,
362
+ eps_Ez=eps_ez,
363
+ cex_curl=_dynamic_electric_curl(solver.cex_curl, solver.eps_Ex, eps_ex),
364
+ cey_curl=_dynamic_electric_curl(solver.cey_curl, solver.eps_Ey, eps_ey),
365
+ cez_curl=_dynamic_electric_curl(solver.cez_curl, solver.eps_Ez, eps_ez),
366
+ chx_curl=solver.chx_curl,
367
+ chy_curl=solver.chy_curl,
368
+ chz_curl=solver.chz_curl,
369
+ boundary_kind=solver.boundary_kind,
370
+ tfsf_enabled=bool(getattr(solver, "tfsf_enabled", False)),
371
+ _compiled_sources=compiled_sources,
372
+ _compiled_source=primary,
373
+ _compiled_material_model=getattr(solver, "_compiled_material_model", None),
374
+ _mode_source_rebuild_from_fields=True,
375
+ _source_time=primary["source_time"],
376
+ _iter_source_images=solver._iter_source_images,
377
+ _component_plane_spec=solver._component_plane_spec,
378
+ _component_positions=solver._component_positions,
379
+ _plane_coordinate=solver._plane_coordinate,
380
+ )
381
+
382
+
383
+ # Source kinds whose eps-leaf-dependent patches are re-derived on the replay
384
+ # solver so the mode-source objective and the torch-VJP reverse can flow
385
+ # gradients into eps. Other kinds keep their forward-built terms.
386
+ _SOURCE_REPLAY_REBUILD_KINDS = frozenset(
387
+ {"point_dipole", "plane_wave", "gaussian_beam", "mode_source"}
388
+ )
389
+
390
+
391
+ def _compiled_sources_for_replay(solver):
392
+ compiled_sources = tuple(getattr(solver, "_compiled_sources", ()) or ())
393
+ if compiled_sources:
394
+ return compiled_sources
395
+ compiled_source = getattr(solver, "_compiled_source", None)
396
+ return (compiled_source,) if compiled_source is not None else ()
397
+
398
+
399
+ def _scene_has_mode_source(solver) -> bool:
400
+ return any(
401
+ source.get("kind") == "mode_source"
402
+ for source in _compiled_sources_for_replay(solver)
403
+ )
404
+
405
+
406
+ def _resolved_source_term_lists(solver, eps_ex, eps_ey, eps_ez):
407
+ default_source_terms = getattr(solver, "_source_terms", ())
408
+ default_electric_source_terms = getattr(solver, "_electric_source_terms", ())
409
+ default_magnetic_source_terms = getattr(solver, "_magnetic_source_terms", ())
410
+ compiled_sources = _compiled_sources_for_replay(solver)
411
+ if not compiled_sources:
412
+ return default_source_terms, default_electric_source_terms, default_magnetic_source_terms
413
+ # A kind outside the rebuild set (e.g. astigmatic beams) keeps the exact
414
+ # forward-built terms, which already reproduce the injection for the explicit
415
+ # reverse path; if any source in the scene is such a kind, fall back to the
416
+ # forward terms for the whole scene so the per-source term indexing stays
417
+ # consistent with the forward solve.
418
+ if not all(source.get("kind") in _SOURCE_REPLAY_REBUILD_KINDS for source in compiled_sources):
419
+ return default_source_terms, default_electric_source_terms, default_magnetic_source_terms
420
+
421
+ has_mode_source = any(source.get("kind") == "mode_source" for source in compiled_sources)
422
+ temp_solver = _build_source_replay_solver(solver, compiled_sources, eps_ex, eps_ey, eps_ez)
423
+ if has_mode_source:
424
+ # Build the mode-source patches under grad tracking so the eigensolve
425
+ # profile keeps its eps-graph; the FDTD backward runs under no_grad, and
426
+ # _mode_source_profile_pullback needs that graph for its once-per-pass VJP.
427
+ with torch.enable_grad():
428
+ initialize_source_terms(temp_solver)
429
+ else:
430
+ initialize_source_terms(temp_solver)
431
+ return (
432
+ tuple(temp_solver._source_terms),
433
+ tuple(temp_solver._electric_source_terms),
434
+ tuple(temp_solver._magnetic_source_terms),
435
+ )
436
+
437
+
438
+ def _has_resolved_source_terms(resolved_source_terms) -> bool:
439
+ if resolved_source_terms is None:
440
+ return False
441
+ source_terms, electric_source_terms, magnetic_source_terms = resolved_source_terms
442
+ return bool(source_terms or electric_source_terms or magnetic_source_terms)
443
+
444
+
445
+ def _can_use_explicit_source_term_reverse_step(solver, resolved_source_terms) -> bool:
446
+ if not _has_resolved_source_terms(resolved_source_terms):
447
+ return True
448
+ if getattr(solver, "tfsf_enabled", False):
449
+ return False
450
+ if has_complex_fields(solver):
451
+ # The complex (Bloch) source replay does not carry the terminal PEC-face
452
+ # clamp on the source patch, so keep PEC + complex on the reject path.
453
+ if bool(getattr(solver, "has_pec_faces", False)):
454
+ return False
455
+ source_terms, electric_source_terms, magnetic_source_terms = resolved_source_terms
456
+ codes = {
457
+ axis: (
458
+ int(getattr(solver, f"boundary_{axis}_low_code")),
459
+ int(getattr(solver, f"boundary_{axis}_high_code")),
460
+ )
461
+ for axis in ("x", "y", "z")
462
+ }
463
+ mixed_bloch_cpml = (
464
+ sum(pair == (BOUNDARY_PML, BOUNDARY_PML) for pair in codes.values()) == 1
465
+ and sum(pair == (BOUNDARY_BLOCH, BOUNDARY_BLOCH) for pair in codes.values()) == 2
466
+ )
467
+ if getattr(solver, "boundary_kind", None) != "bloch" and not mixed_bloch_cpml:
468
+ return False
469
+ if electric_source_terms or magnetic_source_terms:
470
+ return False
471
+ return all(
472
+ term["cw_cos_patch"] is None
473
+ and term["delay_patch"] is None
474
+ and term.get("activation_delay_patch") is None
475
+ for term in source_terms
476
+ )
477
+ # Real fields: PEC domain faces are supported. The source-term eps-gradient
478
+ # in _accumulate_source_term_gradients applies the electric PEC mask so a
479
+ # source coinciding with a clamped face contributes zero.
480
+ return True
481
+
482
+
483
+ def _apply_resolved_magnetic_source_terms(
484
+ magnetic_fields,
485
+ *,
486
+ solver,
487
+ time_value,
488
+ resolved_source_terms,
489
+ ):
490
+ if resolved_source_terms is None:
491
+ return magnetic_fields
492
+ _source_terms, _electric_source_terms, magnetic_source_terms = resolved_source_terms
493
+ if not magnetic_source_terms:
494
+ return magnetic_fields
495
+ return _apply_source_term_list(
496
+ magnetic_fields,
497
+ terms=magnetic_source_terms,
498
+ source_time=solver._source_time,
499
+ omega=solver.source_omega,
500
+ time_value=time_value,
501
+ solver=None,
502
+ )
503
+
504
+
505
+ def _source_term_component_patches(term):
506
+ """Ordered eps-dependent patches whose signal-weighted sum is the injected payload.
507
+
508
+ Each patch carries the term's permittivity dependence (the ``1/eps`` injection
509
+ coefficient and, for mode sources, the eigensolve profile). The time-signal
510
+ weights come from :func:`_source_term_signal_factors` in the same order.
511
+ """
512
+ literal_patch = term.get("literal_patch")
513
+ if literal_patch is not None:
514
+ return (literal_patch,)
515
+ if term["cw_cos_patch"] is not None:
516
+ return (term["cw_cos_patch"], term["cw_sin_patch"])
517
+ return (term["patch"],)
518
+
519
+
520
+ def _source_term_signal_factors(term, *, source_time, omega, time_value):
521
+ """Time-signal weights (scalar or per-cell tensor) for the term's patches.
522
+
523
+ The weights are permittivity-independent, so the injected payload is
524
+ ``sum_k patch_k * factor_k`` and its eps-derivative is carried entirely by the
525
+ patches. Order matches :func:`_source_term_component_patches`.
526
+ """
527
+ # Each compiled source carries its own source-time waveform / CW frequency;
528
+ # resolve per-term so a second source at a different frequency is not driven
529
+ # by the primary source's spectrum.
530
+ source_time, omega = _resolve_term_source(term, source_time, omega)
531
+ if term.get("literal_patch") is not None:
532
+ return (1.0,)
533
+ if term["cw_cos_patch"] is not None:
534
+ signal_cos, signal_sin = _resolve_cw_signal(source_time, omega, time_value)
535
+ return (float(signal_cos), float(signal_sin))
536
+ if term["delay_patch"] is not None:
537
+ sample_time = float(time_value) - term["delay_patch"]
538
+ factor = _evaluate_source_time_tensor(source_time, sample_time)
539
+ activation_delay_patch = term.get("activation_delay_patch")
540
+ if activation_delay_patch is not None:
541
+ factor = torch.where(
542
+ activation_delay_patch > float(time_value),
543
+ torch.zeros_like(factor),
544
+ factor,
545
+ )
546
+ return (factor,)
547
+ scalar_signal = evaluate_source_time(source_time, float(time_value))
548
+ return (float(scalar_signal) * float(term["phase_real"]),)
549
+
550
+
551
+ def _source_term_signal_patch(term, *, source_time, omega, time_value):
552
+ patches = _source_term_component_patches(term)
553
+ factors = _source_term_signal_factors(term, source_time=source_time, omega=omega, time_value=time_value)
554
+ payload = None
555
+ for patch, factor in zip(patches, factors):
556
+ contribution = patch * factor
557
+ payload = contribution if payload is None else payload + contribution
558
+ return payload
559
+
560
+
561
+ def _bloch_source_term_signal_patch(term, *, source_time, omega, time_value) -> torch.Tensor:
562
+ source_time, omega = _resolve_term_source(term, source_time, omega)
563
+ if term["cw_cos_patch"] is not None or term["delay_patch"] is not None:
564
+ raise NotImplementedError("Bloch source-term reverse currently supports uniform point-source patches only.")
565
+ scalar_signal = float(evaluate_source_time(source_time, float(time_value)))
566
+ patch = term["patch"]
567
+ patch_real = patch.to(dtype=torch.float32 if not torch.is_floating_point(patch) else patch.dtype)
568
+ return torch.complex(
569
+ patch_real * (scalar_signal * float(term["phase_real"])),
570
+ patch_real * (scalar_signal * float(term["phase_imag"])),
571
+ )
572
+
573
+
574
+ def _accumulate_mode_source_cotangents(
575
+ solver,
576
+ *,
577
+ electric_terms,
578
+ magnetic_terms,
579
+ electric_adjoint,
580
+ magnetic_adjoint,
581
+ source_time,
582
+ omega,
583
+ time_value,
584
+ ) -> None:
585
+ """Accumulate the time-weighted adjoint cotangent for profile source terms.
586
+
587
+ Mode-source patches carry an eigensolve eps-graph: the aperture mode profile
588
+ (and its normalization) depends on the source-plane permittivity. The per-step
589
+ source objective ``sum_t sum_k <patch_k, signal_k(t) * adjoint(t)>`` is linear
590
+ in the detached adjoint, and grad_eps is summed over the backward sweep anyway,
591
+ so accumulate ``sum_t signal_k(t) * adjoint(t)`` per patch here with a native
592
+ scatter-add and defer the single eigensolve VJP to
593
+ :func:`_mode_source_profile_pullback`. This keeps ``torch.autograd.grad`` off
594
+ the per-step hot path while reproducing the exact profile eps-dependence.
595
+ """
596
+ accum = getattr(solver, "_mode_source_cotangent_accum", None)
597
+ if accum is None:
598
+ accum = {}
599
+ solver._mode_source_cotangent_accum = accum
600
+ groups = (
601
+ ("E", electric_terms, electric_adjoint),
602
+ ("H", magnetic_terms, magnetic_adjoint),
603
+ )
604
+ for group_name, terms, adjoint_by_field in groups:
605
+ for term_pos, term in enumerate(terms):
606
+ field_name = term["field_name"]
607
+ if field_name not in adjoint_by_field:
608
+ continue
609
+ offsets = term["offsets"]
610
+ patches = _source_term_component_patches(term)
611
+ factors = _source_term_signal_factors(
612
+ term, source_time=source_time, omega=omega, time_value=time_value
613
+ )
614
+ for comp_idx, (patch, factor) in enumerate(zip(patches, factors)):
615
+ region = _slice_from_offsets_shape(offsets, patch.shape)
616
+ adjoint_patch = adjoint_by_field[field_name][region].detach().to(
617
+ device=patch.device, dtype=patch.dtype
618
+ )
619
+ contribution = (adjoint_patch * factor).detach()
620
+ key = (group_name, term_pos, comp_idx)
621
+ existing = accum.get(key)
622
+ if existing is None:
623
+ accum[key] = contribution.clone()
624
+ else:
625
+ existing.add_(contribution)
626
+
627
+
628
+ def _mode_source_profile_pullback(solver, resolved_source_terms, eps_ex, eps_ey, eps_ez):
629
+ """Apply the accumulated profile cotangent through the eigensolve eps-graph once.
630
+
631
+ Runs a single ``torch.autograd.grad`` over the retained source-term graph at the
632
+ end of the backward pass (once-per-run material-pullback stage), returning the
633
+ per-axis eps gradients, or ``None`` when there is nothing to pull back.
634
+ """
635
+ accum = getattr(solver, "_mode_source_cotangent_accum", None)
636
+ if not accum:
637
+ return None
638
+ source_terms, electric_source_terms, magnetic_source_terms = resolved_source_terms
639
+ grouped = (
640
+ ("E", tuple(electric_source_terms) + tuple(source_terms)),
641
+ ("H", tuple(magnetic_source_terms)),
642
+ )
643
+ with torch.enable_grad():
644
+ objective = None
645
+ for group_name, terms in grouped:
646
+ for term_pos, term in enumerate(terms):
647
+ patches = _source_term_component_patches(term)
648
+ for comp_idx, patch in enumerate(patches):
649
+ cotangent = accum.get((group_name, term_pos, comp_idx))
650
+ if cotangent is None:
651
+ continue
652
+ contribution = torch.sum(
653
+ patch * cotangent.to(device=patch.device, dtype=patch.dtype)
654
+ )
655
+ objective = contribution if objective is None else objective + contribution
656
+ if objective is None:
657
+ return None
658
+ return torch.autograd.grad(objective, (eps_ex, eps_ey, eps_ez), allow_unused=True)
659
+
660
+
661
+ def _accumulate_source_term_gradients(
662
+ step_result: _ReverseStepResult,
663
+ *,
664
+ solver,
665
+ adjoint_state,
666
+ time_value,
667
+ eps_ex,
668
+ eps_ey,
669
+ eps_ez,
670
+ resolved_source_terms,
671
+ ) -> _ReverseStepResult:
672
+ if not _has_resolved_source_terms(resolved_source_terms):
673
+ return step_result
674
+ if not _can_use_explicit_source_term_reverse_step(solver, resolved_source_terms):
675
+ return step_result
676
+
677
+ source_terms, electric_source_terms, magnetic_source_terms = resolved_source_terms
678
+ source_adjoint_state = step_result.source_adjoint_state or adjoint_state
679
+ if _scene_has_mode_source(solver):
680
+ # Mode-source patches (and any other rebuilt patches sharing the scene)
681
+ # carry an eigensolve eps-graph. Instead of running torch.autograd.grad on
682
+ # the per-step hot path, accumulate the time-weighted adjoint cotangent with
683
+ # a native scatter-add here; the single eigensolve VJP is applied once per
684
+ # backward pass by _mode_source_profile_pullback (bridge material-pullback
685
+ # stage). The per-step step_result eps-grad is returned unchanged.
686
+ _accumulate_mode_source_cotangents(
687
+ solver,
688
+ electric_terms=tuple(electric_source_terms) + tuple(source_terms),
689
+ magnetic_terms=tuple(magnetic_source_terms),
690
+ electric_adjoint=source_adjoint_state,
691
+ magnetic_adjoint=step_result.magnetic_output_adjoint or {},
692
+ source_time=solver._source_time,
693
+ omega=solver.source_omega,
694
+ time_value=time_value,
695
+ )
696
+ return step_result
697
+
698
+ grad_eps_by_field = {
699
+ "Ex": step_result.grad_eps_ex.detach().clone(),
700
+ "Ey": step_result.grad_eps_ey.detach().clone(),
701
+ "Ez": step_result.grad_eps_ez.detach().clone(),
702
+ }
703
+ eps_by_field = {
704
+ "Ex": eps_ex,
705
+ "Ey": eps_ey,
706
+ "Ez": eps_ez,
707
+ }
708
+
709
+ for term in tuple(electric_source_terms) + tuple(source_terms):
710
+ field_name = term["field_name"]
711
+ if field_name not in grad_eps_by_field:
712
+ continue
713
+ if has_complex_fields(solver):
714
+ payload = _bloch_source_term_signal_patch(
715
+ term,
716
+ source_time=solver._source_time,
717
+ omega=solver.source_omega,
718
+ time_value=time_value,
719
+ )
720
+ offsets = term["offsets"]
721
+ region = _slice_from_offsets_shape(offsets, payload.shape)
722
+ adjoint_patch = _bloch_source_term_adjoint_patch(
723
+ source_adjoint_state,
724
+ solver=solver,
725
+ field_name=field_name,
726
+ offsets=offsets,
727
+ patch_shape=payload.shape,
728
+ )
729
+ eps_patch = eps_by_field[field_name][region]
730
+ grad_eps_by_field[field_name][region] = (
731
+ grad_eps_by_field[field_name][region] - _complex_inner_real(adjoint_patch, payload) / eps_patch
732
+ )
733
+ continue
734
+ payload = _source_term_signal_patch(
735
+ term,
736
+ source_time=solver._source_time,
737
+ omega=solver.source_omega,
738
+ time_value=time_value,
739
+ )
740
+ offsets = term["offsets"]
741
+ region = _slice_from_offsets_shape(offsets, payload.shape)
742
+ adjoint_field = source_adjoint_state[field_name]
743
+ adjoint_patch = adjoint_field[region]
744
+ # A source cell on a PEC domain face is erased by the terminal forward
745
+ # PEC clamp, so its eps-gradient must be zero; mask the incoming adjoint
746
+ # exactly as the forward clamp zeroes the injected field (no-op interior).
747
+ pec_mask = _electric_component_pec_mask(
748
+ solver, field_name, adjoint_field.shape, adjoint_field.device
749
+ )
750
+ if pec_mask is not None:
751
+ adjoint_patch = torch.where(
752
+ pec_mask[region], torch.zeros_like(adjoint_patch), adjoint_patch
753
+ )
754
+ eps_patch = eps_by_field[field_name][region]
755
+ grad_eps_by_field[field_name][region] = (
756
+ grad_eps_by_field[field_name][region] - (adjoint_patch * payload) / eps_patch
757
+ )
758
+
759
+ return _ReverseStepResult(
760
+ pre_step_adjoint=step_result.pre_step_adjoint,
761
+ grad_eps_ex=grad_eps_by_field["Ex"],
762
+ grad_eps_ey=grad_eps_by_field["Ey"],
763
+ grad_eps_ez=grad_eps_by_field["Ez"],
764
+ backend=step_result.backend,
765
+ source_adjoint_state=step_result.source_adjoint_state,
766
+ magnetic_output_adjoint=step_result.magnetic_output_adjoint,
767
+ # Preserve the nonlinear gradient channels across the source-term eps
768
+ # accumulation (the source injection carries no chi2/chi3/tpa dependence,
769
+ # so these pass through untouched); the analytic Kerr / general-nonlinear
770
+ # reverses populate them and the bridge requires them per step.
771
+ grad_chi3_ex=step_result.grad_chi3_ex,
772
+ grad_chi3_ey=step_result.grad_chi3_ey,
773
+ grad_chi3_ez=step_result.grad_chi3_ez,
774
+ grad_chi2_ex=step_result.grad_chi2_ex,
775
+ grad_chi2_ey=step_result.grad_chi2_ey,
776
+ grad_chi2_ez=step_result.grad_chi2_ez,
777
+ grad_tpa_ex=step_result.grad_tpa_ex,
778
+ grad_tpa_ey=step_result.grad_tpa_ey,
779
+ grad_tpa_ez=step_result.grad_tpa_ez,
780
+ )
781
+
782
+
783
+ @dataclass(frozen=True)
784
+ class _ForwardPack:
785
+ field_names: tuple[str, ...]
786
+ monitor_templates: dict[str, dict[str, Any]]
787
+ output_tensors: tuple[torch.Tensor, ...]
788
+
789
+
790
+ def _add_patch(field: torch.Tensor, offsets, patch: torch.Tensor) -> torch.Tensor:
791
+ region = _slice_from_offsets_shape(offsets, patch.shape)
792
+ updated = field.clone()
793
+ updated[region] = updated[region] + patch.to(device=field.device, dtype=field.dtype)
794
+ return updated
795
+
796
+
797
+ def _complex_phase_positive(value: torch.Tensor, phase_cos: float, phase_sin: float) -> torch.Tensor:
798
+ return torch.complex(
799
+ float(phase_cos) * value.real - float(phase_sin) * value.imag,
800
+ float(phase_sin) * value.real + float(phase_cos) * value.imag,
801
+ )
802
+
803
+
804
+ def _complex_phase_negative(value: torch.Tensor, phase_cos: float, phase_sin: float) -> torch.Tensor:
805
+ return torch.complex(
806
+ float(phase_cos) * value.real + float(phase_sin) * value.imag,
807
+ float(phase_cos) * value.imag - float(phase_sin) * value.real,
808
+ )
809
+
810
+
811
+ def _complex_inner_real(lhs: torch.Tensor, rhs: torch.Tensor) -> torch.Tensor:
812
+ return lhs.real * rhs.real + lhs.imag * rhs.imag
813
+
814
+
815
+ def _axis_uses_complex_source_wrap(solver, axis: int) -> bool:
816
+ scene = getattr(solver, "scene", None)
817
+ boundary = None if scene is None else getattr(scene, "boundary", None)
818
+ if boundary is None:
819
+ return getattr(solver, "boundary_kind", None) == "bloch"
820
+ return boundary.axis_kind("xyz"[int(axis)]) in {"bloch", "periodic"}
821
+
822
+
823
+ def _bloch_phase_specs(solver, field_name: str):
824
+ component = field_name[-1]
825
+ if component == "x":
826
+ return (
827
+ (1, float(solver.boundary_phase_cos[1]), float(solver.boundary_phase_sin[1])),
828
+ (2, float(solver.boundary_phase_cos[2]), float(solver.boundary_phase_sin[2])),
829
+ )
830
+ if component == "y":
831
+ return (
832
+ (0, float(solver.boundary_phase_cos[0]), float(solver.boundary_phase_sin[0])),
833
+ (2, float(solver.boundary_phase_cos[2]), float(solver.boundary_phase_sin[2])),
834
+ )
835
+ if component == "z":
836
+ return (
837
+ (0, float(solver.boundary_phase_cos[0]), float(solver.boundary_phase_sin[0])),
838
+ (1, float(solver.boundary_phase_cos[1]), float(solver.boundary_phase_sin[1])),
839
+ )
840
+ raise ValueError(f"Unsupported Bloch source field {field_name!r}.")
841
+
842
+
843
+ def _bloch_source_boundary_terms(solver, field_name: str, offsets, patch_shape, field_shape):
844
+ boundary_terms = []
845
+ for axis, phase_cos, phase_sin in _bloch_phase_specs(solver, field_name):
846
+ if not _axis_uses_complex_source_wrap(solver, axis):
847
+ continue
848
+ axis_size = int(field_shape[axis])
849
+ start = int(offsets[axis])
850
+ end = start + int(patch_shape[axis])
851
+ if start == 0:
852
+ boundary_terms.append((axis, "low", axis_size - 1, phase_cos, phase_sin))
853
+ elif end == axis_size:
854
+ boundary_terms.append((axis, "high", 0, phase_cos, phase_sin))
855
+ return boundary_terms
856
+
857
+
858
+ def _bloch_source_term_adjoint_patch(adjoint_state, *, solver, field_name: str, offsets, patch_shape):
859
+ adjoint_field = torch.complex(adjoint_state[field_name], adjoint_state[f"{field_name}_imag"])
860
+ region = _slice_from_offsets_shape(offsets, patch_shape)
861
+ adjoint_patch = adjoint_field[region].clone()
862
+ boundary_terms = _bloch_source_boundary_terms(
863
+ solver,
864
+ field_name,
865
+ offsets,
866
+ patch_shape,
867
+ adjoint_field.shape,
868
+ )
869
+
870
+ for axis, side, pair_index, phase_cos, phase_sin in boundary_terms:
871
+ paired_offsets = list(offsets)
872
+ paired_offsets[axis] = pair_index
873
+ paired_shape = list(patch_shape)
874
+ paired_shape[axis] = 1
875
+ paired_patch = adjoint_field[_slice_from_offsets_shape(tuple(paired_offsets), tuple(paired_shape))]
876
+ boundary_adjoint = (
877
+ _complex_phase_negative(paired_patch, phase_cos, phase_sin)
878
+ if side == "low"
879
+ else _complex_phase_positive(paired_patch, phase_cos, phase_sin)
880
+ )
881
+ boundary_index = 0 if side == "low" else int(adjoint_patch.shape[axis] - 1)
882
+ adjoint_patch.narrow(axis, boundary_index, 1).add_(boundary_adjoint)
883
+
884
+ if len(boundary_terms) == 2:
885
+ (axis_a, side_a, pair_a, phase_cos_a, phase_sin_a), (axis_b, side_b, pair_b, phase_cos_b, phase_sin_b) = boundary_terms
886
+ paired_offsets = list(offsets)
887
+ paired_offsets[axis_a] = pair_a
888
+ paired_offsets[axis_b] = pair_b
889
+ paired_shape = list(patch_shape)
890
+ paired_shape[axis_a] = 1
891
+ paired_shape[axis_b] = 1
892
+ corner_patch = adjoint_field[_slice_from_offsets_shape(tuple(paired_offsets), tuple(paired_shape))]
893
+ corner_patch = (
894
+ _complex_phase_negative(corner_patch, phase_cos_b, phase_sin_b)
895
+ if side_b == "low"
896
+ else _complex_phase_positive(corner_patch, phase_cos_b, phase_sin_b)
897
+ )
898
+ corner_patch = (
899
+ _complex_phase_negative(corner_patch, phase_cos_a, phase_sin_a)
900
+ if side_a == "low"
901
+ else _complex_phase_positive(corner_patch, phase_cos_a, phase_sin_a)
902
+ )
903
+ corner_index_a = 0 if side_a == "low" else int(adjoint_patch.shape[axis_a] - 1)
904
+ corner_index_b = 0 if side_b == "low" else int(adjoint_patch.shape[axis_b] - 1)
905
+ adjoint_patch.narrow(axis_a, corner_index_a, 1).narrow(axis_b, corner_index_b, 1).add_(corner_patch)
906
+
907
+ return adjoint_patch
908
+
909
+
910
+ def _add_complex_bloch_source_patch(field_mapping, *, solver, field_name: str, offsets, delta: torch.Tensor):
911
+ real_name = field_name
912
+ imag_name = f"{field_name}_imag"
913
+ if imag_name not in field_mapping:
914
+ raise KeyError(f"Bloch source injection requires {imag_name!r} in the replay state.")
915
+
916
+ field = torch.complex(field_mapping[real_name], field_mapping[imag_name])
917
+ delta = delta.to(device=field.device, dtype=field.dtype)
918
+ field = _add_patch(field, offsets, delta)
919
+
920
+ boundary_terms = _bloch_source_boundary_terms(
921
+ solver,
922
+ field_name,
923
+ offsets,
924
+ delta.shape,
925
+ field.shape,
926
+ )
927
+ for axis, side, pair_index, phase_cos, phase_sin in boundary_terms:
928
+ boundary_patch = _boundary_slice(delta, axis, side)
929
+ phased_patch = (
930
+ _complex_phase_positive(boundary_patch, phase_cos, phase_sin)
931
+ if side == "low"
932
+ else _complex_phase_negative(boundary_patch, phase_cos, phase_sin)
933
+ )
934
+ paired_offsets = list(offsets)
935
+ paired_offsets[axis] = pair_index
936
+ field = _add_patch(field, tuple(paired_offsets), phased_patch)
937
+
938
+ if len(boundary_terms) == 2:
939
+ (axis_a, side_a, pair_a, phase_cos_a, phase_sin_a), (axis_b, side_b, pair_b, phase_cos_b, phase_sin_b) = boundary_terms
940
+ corner_patch = _boundary_slice(_boundary_slice(delta, axis_a, side_a), axis_b, side_b)
941
+ corner_patch = (
942
+ _complex_phase_positive(corner_patch, phase_cos_a, phase_sin_a)
943
+ if side_a == "low"
944
+ else _complex_phase_negative(corner_patch, phase_cos_a, phase_sin_a)
945
+ )
946
+ corner_patch = (
947
+ _complex_phase_positive(corner_patch, phase_cos_b, phase_sin_b)
948
+ if side_b == "low"
949
+ else _complex_phase_negative(corner_patch, phase_cos_b, phase_sin_b)
950
+ )
951
+ paired_offsets = list(offsets)
952
+ paired_offsets[axis_a] = pair_a
953
+ paired_offsets[axis_b] = pair_b
954
+ field = _add_patch(field, tuple(paired_offsets), corner_patch)
955
+
956
+ updated = dict(field_mapping)
957
+ updated[real_name] = field.real
958
+ updated[imag_name] = field.imag
959
+ return updated
960
+
961
+
962
+ def _boundary_slice(patch: torch.Tensor, axis: int, side: str) -> torch.Tensor:
963
+ index = 0 if side == "low" else int(patch.shape[axis] - 1)
964
+ return patch.narrow(axis, index, 1)
965
+
966
+
967
+ def _apply_bloch_uniform_source_term(
968
+ field_mapping,
969
+ *,
970
+ solver,
971
+ field_name: str,
972
+ offsets,
973
+ patch: torch.Tensor,
974
+ signal_real: float,
975
+ signal_imag: float,
976
+ ):
977
+ real_name = field_name
978
+ imag_name = f"{field_name}_imag"
979
+ if imag_name not in field_mapping:
980
+ raise KeyError(f"Bloch source injection requires {imag_name!r} in the replay state.")
981
+
982
+ patch_real = patch.to(device=field_mapping[real_name].device, dtype=field_mapping[real_name].dtype)
983
+ delta = torch.complex(patch_real * float(signal_real), patch_real * float(signal_imag))
984
+ return _add_complex_bloch_source_patch(
985
+ field_mapping,
986
+ solver=solver,
987
+ field_name=field_name,
988
+ offsets=offsets,
989
+ delta=delta,
990
+ )
991
+
992
+
993
+ def _apply_bloch_cw_source_term(
994
+ field_mapping,
995
+ *,
996
+ solver,
997
+ field_name: str,
998
+ offsets,
999
+ cw_cos_patch: torch.Tensor,
1000
+ cw_sin_patch: torch.Tensor,
1001
+ signal_cos: float,
1002
+ signal_sin: float,
1003
+ ):
1004
+ patch_cos = cw_cos_patch.to(device=field_mapping[field_name].device, dtype=field_mapping[field_name].dtype)
1005
+ patch_sin = cw_sin_patch.to(device=field_mapping[field_name].device, dtype=field_mapping[field_name].dtype)
1006
+ delta = torch.complex(
1007
+ float(signal_cos) * patch_cos + float(signal_sin) * patch_sin,
1008
+ float(signal_sin) * patch_cos - float(signal_cos) * patch_sin,
1009
+ )
1010
+ return _add_complex_bloch_source_patch(
1011
+ field_mapping,
1012
+ solver=solver,
1013
+ field_name=field_name,
1014
+ offsets=offsets,
1015
+ delta=delta,
1016
+ )
1017
+
1018
+
1019
+ def _sample_auxiliary_field(field: torch.Tensor, *, origin: float, ds: float, positions: torch.Tensor) -> torch.Tensor:
1020
+ coord = torch.clamp((positions.to(device=field.device, dtype=field.dtype) - float(origin)) / float(ds), min=0.0)
1021
+ coord = torch.clamp(coord, max=max(field.numel() - 1, 0))
1022
+ lower = torch.floor(coord).to(torch.int64)
1023
+ upper = torch.clamp(lower + 1, max=max(field.numel() - 1, 0))
1024
+ frac = coord - lower.to(dtype=field.dtype)
1025
+ return field[lower] + (field[upper] - field[lower]) * frac
1026
+
1027
+
1028
+ def _apply_tfsf_reference_terms(field_mapping, terms, *, auxiliary_field: torch.Tensor):
1029
+ updated = dict(field_mapping)
1030
+ for term in terms:
1031
+ if term["scalar_sample_index"] is not None:
1032
+ incident = auxiliary_field[int(term["scalar_sample_index"])]
1033
+ patch = term["coeff_patch"] * incident
1034
+ else:
1035
+ incident_line = auxiliary_field[term["sample_indices"].to(dtype=torch.long)]
1036
+ patch = term["coeff_patch"] * incident_line.view(term["sample_view"])
1037
+ patch = patch * float(term["component_scale"])
1038
+ updated[term["field_name"]] = _add_patch(updated[term["field_name"]], term["offsets"], patch)
1039
+ return updated
1040
+
1041
+
1042
+ def _apply_tfsf_aux_terms(field_mapping, terms, *, auxiliary_field: torch.Tensor, origin: float, ds: float):
1043
+ updated = dict(field_mapping)
1044
+ for term in terms:
1045
+ incident = _sample_auxiliary_field(auxiliary_field, origin=origin, ds=ds, positions=term["sample_positions"])
1046
+ patch = term["coeff_patch"] * incident
1047
+ patch = patch * float(term["component_scale"])
1048
+ updated[term["field_name"]] = _add_patch(updated[term["field_name"]], term["offsets"], patch)
1049
+ return updated
1050
+
1051
+
1052
+ def _extract_tfsf_auxiliary_state(state):
1053
+ if "tfsf_aux_electric" not in state or "tfsf_aux_magnetic" not in state:
1054
+ return None
1055
+ return {
1056
+ "electric": state["tfsf_aux_electric"],
1057
+ "magnetic": state["tfsf_aux_magnetic"],
1058
+ }
1059
+
1060
+
1061
+ def _apply_tfsf_terms(field_mapping, *, solver, auxiliary_state, term_key: str, sample_kind: str, time_value):
1062
+ if not getattr(solver, "tfsf_enabled", False):
1063
+ return field_mapping
1064
+
1065
+ tfsf_state = getattr(solver, "_tfsf_state", None)
1066
+ if tfsf_state is None:
1067
+ return field_mapping
1068
+
1069
+ provider = tfsf_state.get("provider")
1070
+ terms = tfsf_state.get(term_key, ())
1071
+ if provider in _TFSF_REFERENCE_PROVIDERS:
1072
+ if auxiliary_state is None:
1073
+ return field_mapping
1074
+ auxiliary_field = auxiliary_state["electric" if sample_kind == "electric" else "magnetic"]
1075
+ return _apply_tfsf_reference_terms(field_mapping, terms, auxiliary_field=auxiliary_field)
1076
+
1077
+ if provider == "plane_wave_aux":
1078
+ if auxiliary_state is None:
1079
+ return field_mapping
1080
+ auxiliary_grid = tfsf_state["auxiliary_grid"]
1081
+ if sample_kind == "electric":
1082
+ auxiliary_field = auxiliary_state["electric"]
1083
+ origin = float(auxiliary_grid.s_min)
1084
+ else:
1085
+ auxiliary_field = auxiliary_state["magnetic"]
1086
+ origin = float(auxiliary_grid.s_min + 0.5 * auxiliary_grid.ds)
1087
+ return _apply_tfsf_aux_terms(
1088
+ field_mapping,
1089
+ terms,
1090
+ auxiliary_field=auxiliary_field,
1091
+ origin=origin,
1092
+ ds=float(auxiliary_grid.ds),
1093
+ )
1094
+
1095
+ return _apply_source_term_list(
1096
+ field_mapping,
1097
+ terms=terms,
1098
+ source_time=solver._source_time,
1099
+ omega=solver.source_omega,
1100
+ time_value=time_value,
1101
+ solver=solver if has_complex_fields(solver) else None,
1102
+ )
1103
+
1104
+
1105
+ def _advance_tfsf_auxiliary_magnetic_state(solver, auxiliary_state):
1106
+ if auxiliary_state is None:
1107
+ return None
1108
+ auxiliary_grid = getattr(solver, "_tfsf_state", {}).get("auxiliary_grid")
1109
+ if auxiliary_grid is None:
1110
+ return auxiliary_state
1111
+ electric = auxiliary_state["electric"]
1112
+ magnetic = auxiliary_state["magnetic"]
1113
+ if magnetic.numel() == 0:
1114
+ return {"electric": electric, "magnetic": magnetic}
1115
+ curl_e = electric[1:] - electric[:-1]
1116
+ next_magnetic = auxiliary_grid.magnetic_decay * magnetic - auxiliary_grid.magnetic_curl * curl_e
1117
+ return {"electric": electric, "magnetic": next_magnetic}
1118
+
1119
+
1120
+ def _advance_tfsf_auxiliary_electric_state(solver, auxiliary_state, *, time_value):
1121
+ if auxiliary_state is None:
1122
+ return None
1123
+ auxiliary_grid = getattr(solver, "_tfsf_state", {}).get("auxiliary_grid")
1124
+ if auxiliary_grid is None:
1125
+ return auxiliary_state
1126
+ electric = auxiliary_state["electric"]
1127
+ magnetic = auxiliary_state["magnetic"]
1128
+ next_electric = electric.clone()
1129
+ if electric.numel() > 2:
1130
+ curl_h = magnetic[1:] - magnetic[:-1]
1131
+ next_electric[1:-1] = (
1132
+ auxiliary_grid.electric_decay[1:-1] * electric[1:-1]
1133
+ - auxiliary_grid.electric_curl[1:-1] * curl_h
1134
+ )
1135
+ next_electric[int(auxiliary_grid.source_index)] = evaluate_source_time(auxiliary_grid.source_time, float(time_value))
1136
+ next_electric[-1] = 0.0
1137
+ return {"electric": next_electric, "magnetic": magnetic}
1138
+
1139
+
1140
+ def _literal_source_term(*, field_name: str, offsets, patch: torch.Tensor) -> dict[str, Any]:
1141
+ return {
1142
+ "field_name": field_name,
1143
+ "offsets": offsets,
1144
+ "patch": None,
1145
+ "grid": None,
1146
+ "phase_real": 1.0,
1147
+ "phase_imag": 0.0,
1148
+ "delay_patch": None,
1149
+ "activation_delay_patch": None,
1150
+ "cw_cos_patch": None,
1151
+ "cw_sin_patch": None,
1152
+ "literal_patch": patch.detach().contiguous(),
1153
+ }
1154
+
1155
+
1156
+ def _materialize_tfsf_term_patch(
1157
+ solver,
1158
+ term,
1159
+ *,
1160
+ auxiliary_state,
1161
+ sample_kind: str,
1162
+ time_value,
1163
+ ) -> torch.Tensor:
1164
+ if "coeff_patch" not in term:
1165
+ return _source_term_signal_patch(
1166
+ term,
1167
+ source_time=solver._source_time,
1168
+ omega=solver.source_omega,
1169
+ time_value=time_value,
1170
+ )
1171
+
1172
+ if auxiliary_state is None:
1173
+ raise RuntimeError("TFSF auxiliary terms require frozen auxiliary checkpoint state.")
1174
+
1175
+ auxiliary_field = auxiliary_state[sample_kind]
1176
+ if "sample_positions" in term:
1177
+ auxiliary_grid = getattr(solver, "_tfsf_state", {}).get("auxiliary_grid")
1178
+ if auxiliary_grid is None:
1179
+ raise RuntimeError("Auxiliary-position TFSF terms require an auxiliary grid.")
1180
+ if sample_kind == "electric":
1181
+ origin = float(auxiliary_grid.s_min)
1182
+ else:
1183
+ origin = float(auxiliary_grid.s_min + 0.5 * auxiliary_grid.ds)
1184
+ incident = _sample_auxiliary_field(
1185
+ auxiliary_field,
1186
+ origin=origin,
1187
+ ds=float(auxiliary_grid.ds),
1188
+ positions=term["sample_positions"],
1189
+ )
1190
+ patch = term["coeff_patch"] * incident
1191
+ elif term["scalar_sample_index"] is not None:
1192
+ patch = term["coeff_patch"] * auxiliary_field[int(term["scalar_sample_index"])]
1193
+ else:
1194
+ incident_line = auxiliary_field[term["sample_indices"].to(dtype=torch.long)]
1195
+ patch = term["coeff_patch"] * incident_line.view(term["sample_view"])
1196
+ return patch * float(term["component_scale"])
1197
+
1198
+
1199
+ def _materialize_tfsf_source_terms(
1200
+ solver,
1201
+ *,
1202
+ terms,
1203
+ auxiliary_state,
1204
+ sample_kind: str,
1205
+ time_value,
1206
+ ) -> list[dict[str, Any]]:
1207
+ materialized_terms = []
1208
+ for term in terms:
1209
+ patch = _materialize_tfsf_term_patch(
1210
+ solver,
1211
+ term,
1212
+ auxiliary_state=auxiliary_state,
1213
+ sample_kind=sample_kind,
1214
+ time_value=time_value,
1215
+ )
1216
+ materialized_terms.append(
1217
+ _literal_source_term(
1218
+ field_name=term["field_name"],
1219
+ offsets=term["offsets"],
1220
+ patch=patch,
1221
+ )
1222
+ )
1223
+ return materialized_terms
1224
+
1225
+
1226
+ def _merge_resolved_source_terms(
1227
+ resolved_source_terms,
1228
+ *,
1229
+ source_terms=(),
1230
+ electric_source_terms=(),
1231
+ magnetic_source_terms=(),
1232
+ ):
1233
+ if resolved_source_terms is None:
1234
+ base_source_terms = ()
1235
+ base_electric_source_terms = ()
1236
+ base_magnetic_source_terms = ()
1237
+ else:
1238
+ base_source_terms, base_electric_source_terms, base_magnetic_source_terms = resolved_source_terms
1239
+ return (
1240
+ tuple(base_source_terms) + tuple(source_terms),
1241
+ tuple(base_electric_source_terms) + tuple(electric_source_terms),
1242
+ tuple(base_magnetic_source_terms) + tuple(magnetic_source_terms),
1243
+ )
1244
+
1245
+
1246
+ def _tfsf_magnetic_source_terms(solver, forward_state, *, time_value, resolved_source_terms=None):
1247
+ tfsf_state = getattr(solver, "_tfsf_state", None)
1248
+ if tfsf_state is None:
1249
+ return resolved_source_terms
1250
+ auxiliary_state = _extract_tfsf_auxiliary_state(forward_state)
1251
+ magnetic_terms = _materialize_tfsf_source_terms(
1252
+ solver,
1253
+ terms=tfsf_state.get("magnetic_terms", ()),
1254
+ auxiliary_state=auxiliary_state,
1255
+ sample_kind="electric",
1256
+ time_value=time_value,
1257
+ )
1258
+ return _merge_resolved_source_terms(
1259
+ resolved_source_terms,
1260
+ magnetic_source_terms=magnetic_terms,
1261
+ )
1262
+
1263
+
1264
+ def _accumulate_tfsf_sample_adjoint_torch(adj_aux, terms, adjoint_fields, *, origin, ds):
1265
+ """Transpose of the per-step TFSF incident injection into ``adj_aux``.
1266
+
1267
+ For every injection term this reads the adjoint of the field the forward step
1268
+ wrote the incident patch into (``adjoint_fields[field_name]`` at the term
1269
+ offsets), contracts it with the term coefficient patch and component scale,
1270
+ and scatters the result back onto the sampled 1D auxiliary grid. This mirrors
1271
+ the native ``accumulateTfsf*SampleAdjoint3D`` kernels: scalar terms fold onto a
1272
+ single index, line terms reduce the two non-sample axes onto their index line,
1273
+ and interpolated terms distribute linearly onto the two straddling indices.
1274
+ """
1275
+ for term in terms:
1276
+ field = adjoint_fields[term["field_name"]]
1277
+ offset_i, offset_j, offset_k = (int(offset) for offset in term["offsets"])
1278
+ coeff_patch = term["coeff_patch"]
1279
+ shape_i, shape_j, shape_k = (int(length) for length in coeff_patch.shape)
1280
+ adj_field_patch = field[
1281
+ offset_i : offset_i + shape_i,
1282
+ offset_j : offset_j + shape_j,
1283
+ offset_k : offset_k + shape_k,
1284
+ ]
1285
+ component_scale = float(term["component_scale"])
1286
+ weighted = component_scale * adj_field_patch * coeff_patch
1287
+ if "sample_positions" in term:
1288
+ last_index = max(adj_aux.numel() - 1, 0)
1289
+ inv_ds = 1.0 / ds if ds > 0.0 else 0.0
1290
+ coord = torch.clamp(
1291
+ (term["sample_positions"] - origin) * inv_ds, min=0.0, max=float(last_index)
1292
+ )
1293
+ lower = torch.floor(coord).to(torch.int64)
1294
+ upper = torch.clamp(lower + 1, max=last_index)
1295
+ frac = coord - lower.to(coord.dtype)
1296
+ value = weighted.reshape(-1)
1297
+ lower_flat = lower.reshape(-1)
1298
+ upper_flat = upper.reshape(-1)
1299
+ frac_flat = frac.reshape(-1)
1300
+ adj_aux.index_add_(0, lower_flat, value * (1.0 - frac_flat))
1301
+ distinct = upper_flat != lower_flat
1302
+ if bool(distinct.any()):
1303
+ adj_aux.index_add_(0, upper_flat[distinct], (value * frac_flat)[distinct])
1304
+ continue
1305
+ if term["scalar_sample_index"] is not None:
1306
+ adj_aux[int(term["scalar_sample_index"])] += torch.sum(weighted)
1307
+ continue
1308
+ axis = int(reference_sample_axis_code(term))
1309
+ reduce_axes = tuple(other for other in (0, 1, 2) if other != axis)
1310
+ per_index = weighted.sum(dim=reduce_axes)
1311
+ sample_indices = term["sample_indices"].to(device=adj_aux.device, dtype=torch.int64).reshape(-1)
1312
+ adj_aux.index_add_(0, sample_indices, per_index)
1313
+
1314
+
1315
+ def _reverse_tfsf_auxiliary_electric_state_adjoint(
1316
+ adj_electric_prev,
1317
+ adj_magnetic_after,
1318
+ adj_electric_post,
1319
+ electric_decay,
1320
+ electric_curl,
1321
+ source_index,
1322
+ ):
1323
+ """Analytic transpose of ``_advance_tfsf_auxiliary_electric_state``.
1324
+
1325
+ Accumulates the pre-step electric adjoint (decay pullback on the interior, an
1326
+ identity passthrough at index 0) and the advanced-magnetic adjoint (the curl-H
1327
+ difference) exactly as ``reverse_tfsf_auxiliary_electric_kernel`` does; the
1328
+ source-driven and clamped-tail entries carry no state dependence.
1329
+ """
1330
+ electric_total = adj_electric_prev.numel()
1331
+ magnetic_total = adj_magnetic_after.numel()
1332
+ index = torch.arange(electric_total, device=adj_electric_prev.device)
1333
+ overwritten = (index == int(source_index)) | (index == electric_total - 1)
1334
+ interior = (index >= 1) & (index + 1 < electric_total) & (~overwritten)
1335
+ passthrough = (index == 0) & (~overwritten)
1336
+ electric_coeff = torch.where(interior, electric_decay, torch.zeros_like(electric_decay))
1337
+ electric_coeff = torch.where(passthrough, torch.ones_like(electric_decay), electric_coeff)
1338
+ adj_electric_prev.add_(electric_coeff * adj_electric_post)
1339
+
1340
+ if magnetic_total == 0:
1341
+ return
1342
+ lower = torch.arange(magnetic_total, device=adj_electric_prev.device)
1343
+ upper = lower + 1
1344
+ lower_valid = (lower > 0) & (lower + 1 < electric_total) & (lower != int(source_index))
1345
+ upper_valid = (upper > 0) & (upper + 1 < electric_total) & (upper != int(source_index))
1346
+ zeros = torch.zeros(magnetic_total, device=adj_electric_prev.device, dtype=adj_electric_prev.dtype)
1347
+ lower_term = torch.where(
1348
+ lower_valid, electric_curl[:magnetic_total] * adj_electric_post[:magnetic_total], zeros
1349
+ )
1350
+ upper_term = torch.where(
1351
+ upper_valid, electric_curl[1 : magnetic_total + 1] * adj_electric_post[1 : magnetic_total + 1], zeros
1352
+ )
1353
+ adj_magnetic_after.add_(upper_term - lower_term)
1354
+
1355
+
1356
+ def _reverse_tfsf_auxiliary_magnetic_state_adjoint(
1357
+ adj_electric_prev,
1358
+ adj_magnetic_prev,
1359
+ adj_magnetic_after,
1360
+ magnetic_decay,
1361
+ magnetic_curl,
1362
+ ):
1363
+ """Analytic transpose of ``_advance_tfsf_auxiliary_magnetic_state``.
1364
+
1365
+ Assigns the pre-step magnetic adjoint (decay pullback) and folds the curl-E
1366
+ difference into the pre-step electric adjoint, matching
1367
+ ``reverse_tfsf_auxiliary_magnetic_kernel``.
1368
+ """
1369
+ magnetic_total = adj_magnetic_after.numel()
1370
+ adj_magnetic_prev.copy_(magnetic_decay * adj_magnetic_after)
1371
+ if magnetic_total == 0:
1372
+ return
1373
+ curl_flux = magnetic_curl * adj_magnetic_after
1374
+ adj_electric_prev[:magnetic_total].add_(curl_flux)
1375
+ adj_electric_prev[1:].add_(-curl_flux)
1376
+
1377
+
1378
+ def _bloch_backward_diff(field: torch.Tensor, *, axis: int, inv_delta: torch.Tensor, phase_cos: float, phase_sin: float):
1379
+ # inv_delta: 1D dual spacing reciprocals, length == field size + 1 along
1380
+ # axis; wrap entries live at [0] and [-1] (equal on a Bloch axis).
1381
+ shape = list(field.shape)
1382
+ shape[axis] += 1
1383
+ diff = field.new_zeros(shape)
1384
+ if field.shape[axis] == 0:
1385
+ return diff
1386
+
1387
+ low = field.select(axis, 0)
1388
+ high = field.select(axis, field.shape[axis] - 1)
1389
+ low_value = (low - _complex_phase_negative(high, phase_cos, phase_sin)) * inv_delta[0]
1390
+ low_region = [slice(None)] * field.ndim
1391
+ low_region[axis] = 0
1392
+ diff[tuple(low_region)] = low_value
1393
+
1394
+ if field.shape[axis] == 1:
1395
+ return diff
1396
+
1397
+ high_value = (_complex_phase_positive(low, phase_cos, phase_sin) - high) * inv_delta[-1]
1398
+ high_region = [slice(None)] * diff.ndim
1399
+ high_region[axis] = -1
1400
+ diff[tuple(high_region)] = high_value
1401
+
1402
+ if field.shape[axis] > 1:
1403
+ interior = [slice(None)] * diff.ndim
1404
+ interior[axis] = slice(1, -1)
1405
+ current = [slice(None)] * field.ndim
1406
+ current[axis] = slice(1, None)
1407
+ previous = [slice(None)] * field.ndim
1408
+ previous[axis] = slice(0, -1)
1409
+ diff[tuple(interior)] = (field[tuple(current)] - field[tuple(previous)]) * _broadcast_vector(inv_delta[1:-1], axis)
1410
+ return diff
1411
+
1412
+
1413
+ def _apply_source_term_list(field_mapping, *, terms, source_time, omega, time_value, solver=None):
1414
+ if not terms:
1415
+ return field_mapping
1416
+
1417
+ updated = dict(field_mapping)
1418
+ signal_cache = {}
1419
+ cw_cache = {}
1420
+ for term in terms:
1421
+ term_source_time = term.get("source_time") or source_time
1422
+ term_omega = float(omega if term.get("omega") is None else term["omega"])
1423
+ cache_key = term.get("source_index")
1424
+ if cache_key is None:
1425
+ cache_key = (id(term_source_time), term_omega)
1426
+ literal_patch = term.get("literal_patch")
1427
+ if literal_patch is not None:
1428
+ updated[term["field_name"]] = _add_patch(updated[term["field_name"]], term["offsets"], literal_patch)
1429
+ continue
1430
+ if term["cw_cos_patch"] is not None:
1431
+ if cache_key not in cw_cache:
1432
+ cw_cache[cache_key] = _resolve_cw_signal(term_source_time, term_omega, time_value)
1433
+ signal_cos, signal_sin = cw_cache[cache_key]
1434
+ if solver is not None and has_complex_fields(solver):
1435
+ updated = _apply_bloch_cw_source_term(
1436
+ updated,
1437
+ solver=solver,
1438
+ field_name=term["field_name"],
1439
+ offsets=term["offsets"],
1440
+ cw_cos_patch=term["cw_cos_patch"],
1441
+ cw_sin_patch=term["cw_sin_patch"],
1442
+ signal_cos=signal_cos,
1443
+ signal_sin=signal_sin,
1444
+ )
1445
+ continue
1446
+ patch = float(signal_cos) * term["cw_cos_patch"] + float(signal_sin) * term["cw_sin_patch"]
1447
+ elif term["delay_patch"] is not None:
1448
+ sample_time = time_value - term["delay_patch"]
1449
+ patch = _evaluate_source_time_tensor(term_source_time, sample_time) * term["patch"]
1450
+ activation_delay_patch = term.get("activation_delay_patch")
1451
+ if activation_delay_patch is not None:
1452
+ patch = torch.where(
1453
+ time_value < activation_delay_patch,
1454
+ torch.zeros_like(patch),
1455
+ patch,
1456
+ )
1457
+ if solver is not None and has_complex_fields(solver):
1458
+ # The delayed pulse is a real current; scatter it into the split
1459
+ # real/imag Bloch field with the same wrap-phase rule the forward
1460
+ # ``addSourcePatchBloch3D`` kernel uses, so the reverse-replay
1461
+ # reconstruction (and the eps VJP flowing through it) matches the
1462
+ # forward injection exactly.
1463
+ real_patch = patch.to(
1464
+ device=updated[term["field_name"]].device,
1465
+ dtype=updated[term["field_name"]].dtype,
1466
+ )
1467
+ updated = _add_complex_bloch_source_patch(
1468
+ updated,
1469
+ solver=solver,
1470
+ field_name=term["field_name"],
1471
+ offsets=term["offsets"],
1472
+ delta=torch.complex(real_patch, torch.zeros_like(real_patch)),
1473
+ )
1474
+ continue
1475
+ else:
1476
+ if cache_key not in signal_cache:
1477
+ signal_cache[cache_key] = evaluate_source_time(term_source_time, float(time_value))
1478
+ scalar_signal = signal_cache[cache_key]
1479
+ signal_real = float(scalar_signal) * float(term["phase_real"])
1480
+ signal_imag = float(scalar_signal) * float(term["phase_imag"])
1481
+ if solver is not None and has_complex_fields(solver):
1482
+ updated = _apply_bloch_uniform_source_term(
1483
+ updated,
1484
+ solver=solver,
1485
+ field_name=term["field_name"],
1486
+ offsets=term["offsets"],
1487
+ patch=term["patch"],
1488
+ signal_real=signal_real,
1489
+ signal_imag=signal_imag,
1490
+ )
1491
+ continue
1492
+ patch = signal_real * term["patch"]
1493
+ field_name = term["field_name"]
1494
+ updated[field_name] = _add_patch(updated[field_name], term["offsets"], patch)
1495
+ return updated
1496
+
1497
+
1498
+ def _update_debye_state(field, polarization, *, drive, decay, dt):
1499
+ next_polarization = polarization * float(decay) + drive * field
1500
+ next_current = (next_polarization - polarization) / float(dt)
1501
+ return next_polarization, next_current
1502
+
1503
+
1504
+ def _update_drude_state(field, current, *, drive, decay):
1505
+ return current * float(decay) + drive * field
1506
+
1507
+
1508
+ def _update_lorentz_state(field, polarization, current, *, drive, decay, restoring, dt):
1509
+ next_current = current * float(decay) - polarization * float(restoring) + drive * field
1510
+ next_polarization = polarization + float(dt) * next_current
1511
+ return next_polarization, next_current
1512
+
1513
+
1514
+ def _advance_ade_state(solver, state, specs, *, imag=False):
1515
+ # ``imag`` advances the imaginary-field ADE replica the forward solver keeps
1516
+ # for Bloch (complex-field) runs: same real pole coefficients, but driven by
1517
+ # the imaginary field component and stored under ``*_imag`` state names.
1518
+ suffix = "_imag" if imag else ""
1519
+ updated = {}
1520
+ for component_name, model_name, index, _tensor_names, entry in specs or ():
1521
+ field = state[component_name + suffix]
1522
+ if model_name == "debye":
1523
+ polarization_name = dispersive_state_name(component_name, model_name, index, "polarization") + suffix
1524
+ current_name = dispersive_state_name(component_name, model_name, index, "current") + suffix
1525
+ polarization, current = _update_debye_state(
1526
+ field,
1527
+ state[polarization_name],
1528
+ drive=entry["drive"],
1529
+ decay=entry["decay"],
1530
+ dt=solver.dt,
1531
+ )
1532
+ updated[polarization_name] = polarization
1533
+ updated[current_name] = current
1534
+ continue
1535
+ if model_name == "drude":
1536
+ current_name = dispersive_state_name(component_name, model_name, index, "current") + suffix
1537
+ updated[current_name] = _update_drude_state(
1538
+ field,
1539
+ state[current_name],
1540
+ drive=entry["drive"],
1541
+ decay=entry["decay"],
1542
+ )
1543
+ continue
1544
+
1545
+ polarization_name = dispersive_state_name(component_name, model_name, index, "polarization") + suffix
1546
+ current_name = dispersive_state_name(component_name, model_name, index, "current") + suffix
1547
+ polarization, current = _update_lorentz_state(
1548
+ field,
1549
+ state[polarization_name],
1550
+ state[current_name],
1551
+ drive=entry["drive"],
1552
+ decay=entry["decay"],
1553
+ restoring=entry["restoring"],
1554
+ dt=solver.dt,
1555
+ )
1556
+ updated[polarization_name] = polarization
1557
+ updated[current_name] = current
1558
+ return updated
1559
+
1560
+
1561
+ def _advance_dispersive_state(solver, state):
1562
+ updated = _advance_ade_state(solver, state, iter_dispersive_state_specs(solver))
1563
+ if has_complex_fields(solver):
1564
+ # Bloch runs propagate a second real FDTD copy (the imaginary field);
1565
+ # the electric ADE poles advance on it identically, so mirror the real
1566
+ # pass with the imaginary drive to keep the replay bit-consistent with
1567
+ # the forward kernels.
1568
+ updated.update(
1569
+ _advance_ade_state(solver, state, iter_dispersive_state_specs(solver), imag=True)
1570
+ )
1571
+ return updated
1572
+
1573
+
1574
+ def _advance_magnetic_dispersive_state(solver, state):
1575
+ updated = _advance_ade_state(solver, state, iter_magnetic_dispersive_state_specs(solver))
1576
+ if has_complex_fields(solver):
1577
+ updated.update(
1578
+ _advance_ade_state(
1579
+ solver, state, iter_magnetic_dispersive_state_specs(solver), imag=True
1580
+ )
1581
+ )
1582
+ return updated
1583
+
1584
+
1585
+ def _magnetic_inverse_permeabilities(solver):
1586
+ templates = getattr(solver, "_magnetic_dispersive_templates", {}) or {}
1587
+ return {
1588
+ component_name: templates[component_name]["inv_mu"]
1589
+ for component_name in ("Hx", "Hy", "Hz")
1590
+ if component_name in templates
1591
+ }
1592
+
1593
+
1594
+ def _apply_magnetic_dispersive_corrections(solver, magnetic_fields, magnetic_dispersive_state):
1595
+ """H -= dt * J_m / mu with the same edge-averaged inv_mu the runtime kernels use.
1596
+
1597
+ mu carries no gradient channel (it is a constant coefficient tensor in the
1598
+ adjoint), matching the forward `applyPolarizationCurrent3D` launches exactly.
1599
+ """
1600
+ if not getattr(solver, "magnetic_dispersive_enabled", False):
1601
+ return magnetic_fields
1602
+
1603
+ inv_mu = _magnetic_inverse_permeabilities(solver)
1604
+ updated = dict(magnetic_fields)
1605
+ suffixes = ("", "_imag") if has_complex_fields(solver) else ("",)
1606
+ for suffix in suffixes:
1607
+ for component_name, model_name, index, _tensor_names, _entry in iter_magnetic_dispersive_state_specs(solver) or ():
1608
+ current_name = dispersive_state_name(component_name, model_name, index, "current") + suffix
1609
+ field_name = component_name + suffix
1610
+ updated[field_name] = updated[field_name] - float(solver.dt) * magnetic_dispersive_state[
1611
+ current_name
1612
+ ] * inv_mu[component_name]
1613
+ return updated
1614
+
1615
+
1616
+ def _apply_dispersive_corrections(solver, electric_fields, dispersive_state, *, eps_ex, eps_ey, eps_ez):
1617
+ if not getattr(solver, "dispersive_enabled", False):
1618
+ return electric_fields
1619
+
1620
+ inv_eps = {
1621
+ "Ex": 1.0 / eps_ex,
1622
+ "Ey": 1.0 / eps_ey,
1623
+ "Ez": 1.0 / eps_ez,
1624
+ }
1625
+ updated = dict(electric_fields)
1626
+ dt = float(solver.dt)
1627
+ # On Bloch runs the imaginary field carries its own polarization current, so
1628
+ # subtract dt * J_imag / eps from the imaginary component too (matching the
1629
+ # forward apply_component_dispersive_currents(imag=True) launches).
1630
+ complex_fields = has_complex_fields(solver) and all(
1631
+ f"{component_name}_imag" in electric_fields for component_name in ("Ex", "Ey", "Ez")
1632
+ )
1633
+ for component_name, model_name, index, _tensor_names, _entry in iter_dispersive_state_specs(solver) or ():
1634
+ current_name = dispersive_state_name(component_name, model_name, index, "current")
1635
+ updated[component_name] = updated[component_name] - dt * dispersive_state[current_name] * inv_eps[
1636
+ component_name
1637
+ ]
1638
+ if complex_fields:
1639
+ imag_field = f"{component_name}_imag"
1640
+ updated[imag_field] = updated[imag_field] - dt * dispersive_state[current_name + "_imag"] * inv_eps[
1641
+ component_name
1642
+ ]
1643
+ return updated
1644
+
1645
+
1646
+ def _reverse_dispersive_corrections(
1647
+ solver,
1648
+ adjoint_state,
1649
+ dispersive_state,
1650
+ *,
1651
+ eps_ex,
1652
+ eps_ey,
1653
+ eps_ez,
1654
+ suffix="",
1655
+ ):
1656
+ # ``suffix`` selects the field/current half the ``E -= dt * J / eps``
1657
+ # correction VJP runs on. "" is the real field (unchanged); Bloch runs also
1658
+ # correct the imaginary field with ``suffix="_imag"``. Both halves share the
1659
+ # (real) eps, so both accumulate into the same eps gradient at the call site.
1660
+ electric_source_adjoint = {
1661
+ "Ex": adjoint_state["Ex" + suffix].detach().clone(),
1662
+ "Ey": adjoint_state["Ey" + suffix].detach().clone(),
1663
+ "Ez": adjoint_state["Ez" + suffix].detach().clone(),
1664
+ }
1665
+ dispersive_output_adjoint = {
1666
+ name + suffix: adjoint_state[name + suffix].detach().clone()
1667
+ for name in _electric_dispersive_real_state_names(solver)
1668
+ }
1669
+ grad_eps_by_field = {
1670
+ "Ex": torch.zeros_like(eps_ex),
1671
+ "Ey": torch.zeros_like(eps_ey),
1672
+ "Ez": torch.zeros_like(eps_ez),
1673
+ }
1674
+ eps_by_field = {
1675
+ "Ex": eps_ex,
1676
+ "Ey": eps_ey,
1677
+ "Ez": eps_ez,
1678
+ }
1679
+ dt = float(solver.dt)
1680
+
1681
+ for component_name, model_name, index, _tensor_names, _entry in iter_dispersive_state_specs(solver) or ():
1682
+ current_name = dispersive_state_name(component_name, model_name, index, "current") + suffix
1683
+ electric_adjoint = electric_source_adjoint[component_name]
1684
+ eps_field = eps_by_field[component_name]
1685
+ dispersive_output_adjoint[current_name] = (
1686
+ dispersive_output_adjoint[current_name] - dt * electric_adjoint / eps_field
1687
+ )
1688
+ grad_eps_by_field[component_name] = (
1689
+ grad_eps_by_field[component_name] + dt * dispersive_state[current_name] * electric_adjoint / (eps_field * eps_field)
1690
+ )
1691
+
1692
+ source_adjoint_state = dict(adjoint_state)
1693
+ source_adjoint_state.update({name + suffix: value for name, value in electric_source_adjoint.items()})
1694
+ return electric_source_adjoint, dispersive_output_adjoint, grad_eps_by_field, source_adjoint_state
1695
+
1696
+
1697
+ def _electric_dispersive_real_state_names(solver):
1698
+ """Canonical (unsuffixed) electric-dispersive state names, in spec order.
1699
+
1700
+ Bloch runs append ``_imag`` replicas to ``dispersive_state_names``; the
1701
+ suffix-parameterized ADE-state VJP needs the real names alone so it can apply
1702
+ the requested half's suffix without double-suffixing the ``_imag`` copies.
1703
+ """
1704
+ names = []
1705
+ for component_name, model_name, index, tensor_names, _entry in iter_dispersive_state_specs(solver) or ():
1706
+ for tensor_name in tensor_names:
1707
+ names.append(dispersive_state_name(component_name, model_name, index, tensor_name))
1708
+ return tuple(names)
1709
+
1710
+
1711
+ def _reverse_magnetic_dispersive_corrections(solver, adjoint_state, magnetic_field_adjoint):
1712
+ """VJP of ``H -= dt * J_m * inv_mu`` given the adjoint of the corrected H.
1713
+
1714
+ ``inv_mu`` is a constant coefficient tensor (there is no magnetic material
1715
+ gradient channel), so the correction only seeds the post-step magnetic
1716
+ dispersive-state adjoint; the field adjoint itself passes through unchanged.
1717
+ """
1718
+ output_adjoint = {
1719
+ name: adjoint_state[name].detach().clone()
1720
+ for name in checkpoint_schema(solver).magnetic_dispersive_state_names
1721
+ }
1722
+ inv_mu = _magnetic_inverse_permeabilities(solver)
1723
+ dt = float(solver.dt)
1724
+ for component_name, model_name, index, _tensor_names, _entry in iter_magnetic_dispersive_state_specs(solver) or ():
1725
+ current_name = dispersive_state_name(component_name, model_name, index, "current")
1726
+ output_adjoint[current_name] = (
1727
+ output_adjoint[current_name] - dt * magnetic_field_adjoint[component_name] * inv_mu[component_name]
1728
+ )
1729
+ return output_adjoint
1730
+
1731
+
1732
+ def _broadcast_vector(vector: torch.Tensor, axis: int) -> torch.Tensor:
1733
+ shape = [1, 1, 1]
1734
+ shape[axis] = int(vector.shape[0])
1735
+ return vector.view(*shape)
1736
+
1737
+
1738
+ def _forward_diff(field: torch.Tensor, axis: int, inv_delta: torch.Tensor) -> torch.Tensor:
1739
+ # inv_delta: 1D primal spacing reciprocals, length == field size - 1 along axis.
1740
+ slicer_lo = [slice(None)] * field.ndim
1741
+ slicer_hi = [slice(None)] * field.ndim
1742
+ slicer_lo[axis] = slice(0, -1)
1743
+ slicer_hi[axis] = slice(1, None)
1744
+ return (field[tuple(slicer_hi)] - field[tuple(slicer_lo)]) * _broadcast_vector(inv_delta, axis)
1745
+
1746
+
1747
+ def _backward_diff(field: torch.Tensor, axis: int, inv_delta: torch.Tensor) -> torch.Tensor:
1748
+ # inv_delta: 1D dual spacing reciprocals, length == field size + 1 along axis.
1749
+ shape = list(field.shape)
1750
+ shape[axis] += 1
1751
+ diff = field.new_zeros(shape)
1752
+ interior = [slice(None)] * diff.ndim
1753
+ field_hi = [slice(None)] * field.ndim
1754
+ field_lo = [slice(None)] * field.ndim
1755
+ interior[axis] = slice(1, -1)
1756
+ field_hi[axis] = slice(1, None)
1757
+ field_lo[axis] = slice(0, -1)
1758
+ diff[tuple(interior)] = (field[tuple(field_hi)] - field[tuple(field_lo)]) * _broadcast_vector(inv_delta[1:-1], axis)
1759
+ return diff
1760
+
1761
+
1762
+ def _scatter_diff_adjoint(field_grad: torch.Tensor, scaled_grad: torch.Tensor, axis: int):
1763
+ field_lo = [slice(None)] * field_grad.ndim
1764
+ field_hi = [slice(None)] * field_grad.ndim
1765
+ field_lo[axis] = slice(0, -1)
1766
+ field_hi[axis] = slice(1, None)
1767
+ field_grad[tuple(field_lo)] = field_grad[tuple(field_lo)] - scaled_grad
1768
+ field_grad[tuple(field_hi)] = field_grad[tuple(field_hi)] + scaled_grad
1769
+
1770
+
1771
+ def _accumulate_forward_diff_adjoint(field_grad: torch.Tensor, diff_grad: torch.Tensor, *, axis: int, inv_delta: torch.Tensor):
1772
+ _scatter_diff_adjoint(field_grad, _broadcast_vector(inv_delta, axis) * diff_grad, axis)
1773
+
1774
+
1775
+ def _accumulate_backward_diff_adjoint(field_grad: torch.Tensor, diff_grad: torch.Tensor, *, axis: int, inv_delta: torch.Tensor):
1776
+ interior = [slice(None)] * diff_grad.ndim
1777
+ interior[axis] = slice(1, -1)
1778
+ _scatter_diff_adjoint(field_grad, _broadcast_vector(inv_delta[1:-1], axis) * diff_grad[tuple(interior)], axis)
1779
+
1780
+
1781
+ def _accumulate_bloch_backward_diff_adjoint(
1782
+ field_grad: torch.Tensor,
1783
+ diff_grad: torch.Tensor,
1784
+ *,
1785
+ axis: int,
1786
+ inv_delta: torch.Tensor,
1787
+ phase_cos: float,
1788
+ phase_sin: float,
1789
+ ):
1790
+ _accumulate_backward_diff_adjoint(field_grad, diff_grad, axis=axis, inv_delta=inv_delta)
1791
+ # Wrap terms transpose the forward wrap differences: contributions from the
1792
+ # low diff entry carry inv_delta[0], those from the high entry inv_delta[-1].
1793
+ low_scale = inv_delta[0]
1794
+ high_scale = inv_delta[-1]
1795
+ low_grad = diff_grad.select(axis, 0)
1796
+ high_grad = diff_grad.select(axis, int(diff_grad.shape[axis] - 1))
1797
+ field_grad.select(axis, 0).add_(low_scale * low_grad + high_scale * _complex_phase_negative(high_grad, phase_cos, phase_sin))
1798
+ field_grad.select(axis, int(field_grad.shape[axis] - 1)).add_(
1799
+ -low_scale * _complex_phase_positive(low_grad, phase_cos, phase_sin) - high_scale * high_grad
1800
+ )
1801
+
1802
+
1803
+ # The face codes and grid shape are fixed for the whole reverse pass, so the
1804
+ # boolean boundary masks the per-step electric-adjoint update reads (six rebuilds
1805
+ # per reverse step) are config-level constants. Memoize them: the returned masks
1806
+ # are consumed read-only (|, &, ~, torch.where) at every call site, so a shared
1807
+ # cached tensor is safe, and the key (shape/axis/face-codes/device) recurs
1808
+ # identically across every step and every optimization iteration on a scene.
1809
+ _BOUNDARY_AXIS_MASK_CACHE: dict = {}
1810
+
1811
+
1812
+ def _boundary_axis_masks(shape, axis: int, low_mode: int, high_mode: int, device) -> tuple[torch.Tensor, torch.Tensor]:
1813
+ key = (tuple(int(dim) for dim in shape), int(axis), int(low_mode), int(high_mode), str(device))
1814
+ cached = _BOUNDARY_AXIS_MASK_CACHE.get(key)
1815
+ if cached is not None:
1816
+ return cached
1817
+ inactive = torch.zeros(shape, device=device, dtype=torch.bool)
1818
+ pec = torch.zeros(shape, device=device, dtype=torch.bool)
1819
+ low_face = [slice(None)] * len(shape)
1820
+ low_face[axis] = 0
1821
+ high_face = [slice(None)] * len(shape)
1822
+ high_face[axis] = -1
1823
+ if low_mode in {BOUNDARY_NONE, BOUNDARY_PML}:
1824
+ inactive[tuple(low_face)] = True
1825
+ elif low_mode == BOUNDARY_PEC:
1826
+ pec[tuple(low_face)] = True
1827
+ if high_mode in {BOUNDARY_NONE, BOUNDARY_PML}:
1828
+ inactive[tuple(high_face)] = True
1829
+ elif high_mode == BOUNDARY_PEC:
1830
+ pec[tuple(high_face)] = True
1831
+ _BOUNDARY_AXIS_MASK_CACHE[key] = (inactive, pec)
1832
+ return inactive, pec
1833
+
1834
+
1835
+ # Per-electric-component tangential axes and their domain-face boundary codes,
1836
+ # matching the Yee electric update (_update_electric_component) and the terminal
1837
+ # _enforce_pec_boundaries clamp.
1838
+ _ELECTRIC_COMPONENT_PEC_AXES = {
1839
+ "Ex": ((1, "boundary_y_low_code", "boundary_y_high_code"), (2, "boundary_z_low_code", "boundary_z_high_code")),
1840
+ "Ey": ((2, "boundary_z_low_code", "boundary_z_high_code"), (0, "boundary_x_low_code", "boundary_x_high_code")),
1841
+ "Ez": ((0, "boundary_x_low_code", "boundary_x_high_code"), (1, "boundary_y_low_code", "boundary_y_high_code")),
1842
+ }
1843
+
1844
+
1845
+ def _electric_component_pec_mask(solver, field_name, shape, device):
1846
+ """Mask of PEC-clamped cells for an electric component, or ``None`` when the
1847
+ scene has no PEC faces.
1848
+
1849
+ A soft source injected onto a PEC domain face is erased by the terminal
1850
+ ``_enforce_pec_boundaries`` clamp in the forward step, so its eps-gradient
1851
+ must be zero there. The mask is the union of the two tangential domain faces'
1852
+ PEC cells, exactly the cells the forward clamp zeroes; on an interior region
1853
+ it is all-``False`` so masking is a no-op.
1854
+ """
1855
+ if not bool(getattr(solver, "has_pec_faces", False)):
1856
+ return None
1857
+ spec = _ELECTRIC_COMPONENT_PEC_AXES.get(field_name)
1858
+ if spec is None:
1859
+ return None
1860
+ (axis_pos, low_pos, high_pos), (axis_neg, low_neg, high_neg) = spec
1861
+ _inactive_pos, pec_pos = _boundary_axis_masks(
1862
+ shape, axis_pos, int(getattr(solver, low_pos)), int(getattr(solver, high_pos)), device
1863
+ )
1864
+ _inactive_neg, pec_neg = _boundary_axis_masks(
1865
+ shape, axis_neg, int(getattr(solver, low_neg)), int(getattr(solver, high_neg)), device
1866
+ )
1867
+ return pec_pos | pec_neg
1868
+
1869
+
1870
+ def _update_magnetic_component(field, *, d_pos, d_neg, decay, curl, psi_pos=None, psi_neg=None, b_pos=None, c_pos=None, inv_kappa_pos=None, b_neg=None, c_neg=None, inv_kappa_neg=None, axis_pos=None, axis_neg=None):
1871
+ if psi_pos is not None:
1872
+ psi_pos_new = _broadcast_vector(b_pos, axis_pos) * psi_pos + _broadcast_vector(c_pos, axis_pos) * d_pos
1873
+ psi_neg_new = _broadcast_vector(b_neg, axis_neg) * psi_neg + _broadcast_vector(c_neg, axis_neg) * d_neg
1874
+ curl_term = (
1875
+ d_pos * _broadcast_vector(inv_kappa_pos, axis_pos) + psi_pos_new
1876
+ ) - (
1877
+ d_neg * _broadcast_vector(inv_kappa_neg, axis_neg) + psi_neg_new
1878
+ )
1879
+ updated = field * decay - curl * curl_term
1880
+ return updated, psi_pos_new, psi_neg_new
1881
+
1882
+ updated = field * decay - curl * (d_pos - d_neg)
1883
+ return updated, None, None
1884
+
1885
+
1886
+ def _update_electric_component(field, *, d_pos, d_neg, decay, curl_prefactor, eps, low_mode_pos, high_mode_pos, low_mode_neg, high_mode_neg, axis_pos, axis_neg, psi_pos=None, psi_neg=None, b_pos=None, c_pos=None, inv_kappa_pos=None, b_neg=None, c_neg=None, inv_kappa_neg=None):
1887
+ curl = curl_prefactor / eps
1888
+ if psi_pos is not None:
1889
+ psi_pos_candidate = _broadcast_vector(b_pos, axis_pos) * psi_pos + _broadcast_vector(c_pos, axis_pos) * d_pos
1890
+ psi_neg_candidate = _broadcast_vector(b_neg, axis_neg) * psi_neg + _broadcast_vector(c_neg, axis_neg) * d_neg
1891
+ curl_term = (
1892
+ d_pos * _broadcast_vector(inv_kappa_pos, axis_pos) + psi_pos_candidate
1893
+ ) - (
1894
+ d_neg * _broadcast_vector(inv_kappa_neg, axis_neg) + psi_neg_candidate
1895
+ )
1896
+ else:
1897
+ psi_pos_candidate = None
1898
+ psi_neg_candidate = None
1899
+ curl_term = d_pos - d_neg
1900
+
1901
+ inactive_pos, pec_pos = _boundary_axis_masks(field.shape, axis_pos, low_mode_pos, high_mode_pos, field.device)
1902
+ inactive_neg, pec_neg = _boundary_axis_masks(field.shape, axis_neg, low_mode_neg, high_mode_neg, field.device)
1903
+ pec_mask = pec_pos | pec_neg
1904
+ inactive_mask = (~pec_mask) & (inactive_pos | inactive_neg)
1905
+
1906
+ candidate = field * decay + curl * curl_term
1907
+ updated = torch.where(pec_mask, torch.zeros_like(candidate), torch.where(inactive_mask, field, candidate))
1908
+ if psi_pos_candidate is None:
1909
+ return updated, None, None
1910
+
1911
+ keep_mask = inactive_mask | pec_mask
1912
+ psi_pos_new = torch.where(keep_mask, psi_pos, psi_pos_candidate)
1913
+ psi_neg_new = torch.where(keep_mask, psi_neg, psi_neg_candidate)
1914
+ return updated, psi_pos_new, psi_neg_new
1915
+
1916
+
1917
+ def _reverse_magnetic_component_standard(adj_updated, *, decay, curl):
1918
+ adj_field = adj_updated * decay
1919
+ adj_d_pos = -curl * adj_updated
1920
+ adj_d_neg = curl * adj_updated
1921
+ return adj_field, adj_d_pos, adj_d_neg
1922
+
1923
+
1924
+ def _reverse_electric_component_standard(
1925
+ adj_updated,
1926
+ field,
1927
+ *,
1928
+ d_pos,
1929
+ d_neg,
1930
+ decay,
1931
+ curl_prefactor,
1932
+ eps,
1933
+ low_mode_pos,
1934
+ high_mode_pos,
1935
+ low_mode_neg,
1936
+ high_mode_neg,
1937
+ axis_pos,
1938
+ axis_neg,
1939
+ ):
1940
+ inactive_pos, pec_pos = _boundary_axis_masks(field.shape, axis_pos, low_mode_pos, high_mode_pos, field.device)
1941
+ inactive_neg, pec_neg = _boundary_axis_masks(field.shape, axis_neg, low_mode_neg, high_mode_neg, field.device)
1942
+ pec_mask = pec_pos | pec_neg
1943
+ inactive_mask = (~pec_mask) & (inactive_pos | inactive_neg)
1944
+ active_mask = (~pec_mask) & (~inactive_mask)
1945
+ active = active_mask.to(device=field.device, dtype=field.dtype)
1946
+ inactive = inactive_mask.to(device=field.device, dtype=field.dtype)
1947
+
1948
+ adj_field = inactive * adj_updated + active * (adj_updated * decay)
1949
+ curl_scale = active * (curl_prefactor / eps)
1950
+ adj_curl_term = adj_updated * curl_scale
1951
+ grad_eps = -adj_updated * active * curl_prefactor * (d_pos - d_neg) / (eps * eps)
1952
+ adj_d_pos = adj_curl_term
1953
+ adj_d_neg = -adj_curl_term
1954
+ return adj_field, adj_d_pos, adj_d_neg, grad_eps
1955
+
1956
+
1957
+ def _reverse_electric_component_bloch(
1958
+ adj_updated: torch.Tensor,
1959
+ *,
1960
+ d_pos: torch.Tensor,
1961
+ d_neg: torch.Tensor,
1962
+ decay: torch.Tensor,
1963
+ curl_prefactor: torch.Tensor,
1964
+ eps: torch.Tensor,
1965
+ ):
1966
+ adj_field = adj_updated * decay
1967
+ curl_scale = curl_prefactor / eps
1968
+ adj_curl_term = adj_updated * curl_scale
1969
+ grad_eps = -curl_prefactor * _complex_inner_real(adj_updated, d_pos - d_neg) / (eps * eps)
1970
+ adj_d_pos = adj_curl_term
1971
+ adj_d_neg = -adj_curl_term
1972
+ return adj_field, adj_d_pos, adj_d_neg, grad_eps
1973
+
1974
+
1975
+ def _reverse_magnetic_component_cpml(
1976
+ adj_updated,
1977
+ adj_psi_pos_post,
1978
+ adj_psi_neg_post,
1979
+ *,
1980
+ decay,
1981
+ curl,
1982
+ b_pos,
1983
+ c_pos,
1984
+ inv_kappa_pos,
1985
+ b_neg,
1986
+ c_neg,
1987
+ inv_kappa_neg,
1988
+ axis_pos,
1989
+ axis_neg,
1990
+ ):
1991
+ adj_curl_term = -curl * adj_updated
1992
+ adj_psi_pos_candidate = adj_psi_pos_post + adj_curl_term
1993
+ adj_psi_neg_candidate = adj_psi_neg_post - adj_curl_term
1994
+ adj_field = adj_updated * decay
1995
+ adj_psi_pos = _broadcast_vector(b_pos, axis_pos) * adj_psi_pos_candidate
1996
+ adj_psi_neg = _broadcast_vector(b_neg, axis_neg) * adj_psi_neg_candidate
1997
+ adj_d_pos = (
1998
+ _broadcast_vector(inv_kappa_pos, axis_pos) * adj_curl_term
1999
+ + _broadcast_vector(c_pos, axis_pos) * adj_psi_pos_candidate
2000
+ )
2001
+ adj_d_neg = (
2002
+ -_broadcast_vector(inv_kappa_neg, axis_neg) * adj_curl_term
2003
+ + _broadcast_vector(c_neg, axis_neg) * adj_psi_neg_candidate
2004
+ )
2005
+ return adj_field, adj_d_pos, adj_d_neg, adj_psi_pos, adj_psi_neg
2006
+
2007
+
2008
+ def _reverse_electric_component_cpml(
2009
+ adj_updated,
2010
+ adj_psi_pos_post,
2011
+ adj_psi_neg_post,
2012
+ field,
2013
+ *,
2014
+ d_pos,
2015
+ d_neg,
2016
+ decay,
2017
+ curl_prefactor,
2018
+ eps,
2019
+ low_mode_pos,
2020
+ high_mode_pos,
2021
+ low_mode_neg,
2022
+ high_mode_neg,
2023
+ axis_pos,
2024
+ axis_neg,
2025
+ psi_pos,
2026
+ psi_neg,
2027
+ b_pos,
2028
+ c_pos,
2029
+ inv_kappa_pos,
2030
+ b_neg,
2031
+ c_neg,
2032
+ inv_kappa_neg,
2033
+ ):
2034
+ inactive_pos, pec_pos = _boundary_axis_masks(field.shape, axis_pos, low_mode_pos, high_mode_pos, field.device)
2035
+ inactive_neg, pec_neg = _boundary_axis_masks(field.shape, axis_neg, low_mode_neg, high_mode_neg, field.device)
2036
+ pec_mask = pec_pos | pec_neg
2037
+ inactive_mask = (~pec_mask) & (inactive_pos | inactive_neg)
2038
+ active_mask = (~pec_mask) & (~inactive_mask)
2039
+ keep_mask = inactive_mask | pec_mask
2040
+
2041
+ active = active_mask.to(device=field.device, dtype=field.dtype)
2042
+ inactive = inactive_mask.to(device=field.device, dtype=field.dtype)
2043
+ keep = keep_mask.to(device=field.device, dtype=field.dtype)
2044
+
2045
+ b_pos_term = _broadcast_vector(b_pos, axis_pos)
2046
+ c_pos_term = _broadcast_vector(c_pos, axis_pos)
2047
+ inv_kappa_pos_term = _broadcast_vector(inv_kappa_pos, axis_pos)
2048
+ b_neg_term = _broadcast_vector(b_neg, axis_neg)
2049
+ c_neg_term = _broadcast_vector(c_neg, axis_neg)
2050
+ inv_kappa_neg_term = _broadcast_vector(inv_kappa_neg, axis_neg)
2051
+
2052
+ psi_pos_candidate = b_pos_term * psi_pos + c_pos_term * d_pos
2053
+ psi_neg_candidate = b_neg_term * psi_neg + c_neg_term * d_neg
2054
+ curl_term = (d_pos * inv_kappa_pos_term + psi_pos_candidate) - (d_neg * inv_kappa_neg_term + psi_neg_candidate)
2055
+
2056
+ adj_field = inactive * adj_updated + active * (adj_updated * decay)
2057
+ adj_curl_term = active * adj_updated * (curl_prefactor / eps)
2058
+ grad_eps = -adj_updated * active * curl_prefactor * curl_term / (eps * eps)
2059
+ adj_psi_pos_candidate = active * adj_psi_pos_post + adj_curl_term
2060
+ adj_psi_neg_candidate = active * adj_psi_neg_post - adj_curl_term
2061
+ adj_psi_pos = keep * adj_psi_pos_post + active * (b_pos_term * adj_psi_pos_candidate)
2062
+ adj_psi_neg = keep * adj_psi_neg_post + active * (b_neg_term * adj_psi_neg_candidate)
2063
+ adj_d_pos = inv_kappa_pos_term * adj_curl_term + c_pos_term * adj_psi_pos_candidate
2064
+ adj_d_neg = -inv_kappa_neg_term * adj_curl_term + c_neg_term * adj_psi_neg_candidate
2065
+ return adj_field, adj_d_pos, adj_d_neg, grad_eps, adj_psi_pos, adj_psi_neg
2066
+
2067
+
2068
+ def _enforce_pec_boundaries(solver, fields):
2069
+ updated = dict(fields)
2070
+ face_specs = (
2071
+ (solver.boundary_x_low_code, "low", (("Ey", 0), ("Ez", 0))),
2072
+ (solver.boundary_x_high_code, "high", (("Ey", 0), ("Ez", 0))),
2073
+ (solver.boundary_y_low_code, "low", (("Ex", 1), ("Ez", 1))),
2074
+ (solver.boundary_y_high_code, "high", (("Ex", 1), ("Ez", 1))),
2075
+ (solver.boundary_z_low_code, "low", (("Ex", 2), ("Ey", 2))),
2076
+ (solver.boundary_z_high_code, "high", (("Ex", 2), ("Ey", 2))),
2077
+ )
2078
+ for boundary_code, side, targets in face_specs:
2079
+ if boundary_code != BOUNDARY_PEC:
2080
+ continue
2081
+ face_index = 0 if side == "low" else -1
2082
+ for field_name, axis in targets:
2083
+ tensor = updated[field_name].clone()
2084
+ region = [slice(None)] * tensor.ndim
2085
+ region[axis] = face_index
2086
+ tensor[tuple(region)] = 0.0
2087
+ updated[field_name] = tensor
2088
+ return updated
2089
+
2090
+
2091
+ def _complex_state_field(mapping, field_name: str) -> torch.Tensor:
2092
+ return torch.complex(mapping[field_name], mapping[f"{field_name}_imag"])
2093
+
2094
+
2095
+ def _apply_complex_axis_boundary(candidate, previous, *, axis: int, low_mode: int, high_mode: int):
2096
+ inactive_mask, pec_mask = _boundary_axis_masks(candidate.shape, axis, low_mode, high_mode, candidate.device)
2097
+ return torch.where(
2098
+ pec_mask,
2099
+ torch.zeros_like(candidate),
2100
+ torch.where(inactive_mask, previous, candidate),
2101
+ )
2102
+
2103
+
2104
+ def _cpml_correction_mask(shape, *, normal_axis: int, tangent_axis: int, tangent_low_mode: int, tangent_high_mode: int, device):
2105
+ mask = torch.ones(shape, device=device, dtype=torch.bool)
2106
+ low_face = [slice(None)] * len(shape)
2107
+ high_face = [slice(None)] * len(shape)
2108
+ low_face[normal_axis] = 0
2109
+ high_face[normal_axis] = -1
2110
+ mask[tuple(low_face)] = False
2111
+ mask[tuple(high_face)] = False
2112
+ inactive_tangent, pec_tangent = _boundary_axis_masks(
2113
+ shape,
2114
+ tangent_axis,
2115
+ tangent_low_mode,
2116
+ tangent_high_mode,
2117
+ device,
2118
+ )
2119
+ return mask & (~inactive_tangent) & (~pec_tangent)
2120
+
2121
+
2122
+ def _apply_complex_cpml_correction(
2123
+ field: torch.Tensor,
2124
+ psi: torch.Tensor,
2125
+ derivative: torch.Tensor,
2126
+ *,
2127
+ curl: torch.Tensor,
2128
+ inv_kappa: torch.Tensor,
2129
+ b: torch.Tensor,
2130
+ c: torch.Tensor,
2131
+ normal_axis: int,
2132
+ tangent_axis: int,
2133
+ tangent_low_mode: int,
2134
+ tangent_high_mode: int,
2135
+ sign: float,
2136
+ ):
2137
+ b_term = _broadcast_vector(b, normal_axis)
2138
+ c_term = _broadcast_vector(c, normal_axis)
2139
+ inv_kappa_term = _broadcast_vector(inv_kappa, normal_axis)
2140
+ psi_candidate = b_term * psi + c_term * derivative
2141
+ correction = curl * (derivative * (inv_kappa_term - 1.0) + psi_candidate)
2142
+ candidate = field + float(sign) * correction
2143
+ mask = _cpml_correction_mask(
2144
+ field.shape,
2145
+ normal_axis=normal_axis,
2146
+ tangent_axis=tangent_axis,
2147
+ tangent_low_mode=tangent_low_mode,
2148
+ tangent_high_mode=tangent_high_mode,
2149
+ device=field.device,
2150
+ )
2151
+ return torch.where(mask, candidate, field), torch.where(mask, psi_candidate, psi)
2152
+
2153
+
2154
+ def _update_mixed_bloch_cpml_electric_fields(solver, state, magnetic_fields, *, ex_curl, ey_curl, ez_curl):
2155
+ hx_complex = _complex_state_field(magnetic_fields, "Hx")
2156
+ hy_complex = _complex_state_field(magnetic_fields, "Hy")
2157
+ hz_complex = _complex_state_field(magnetic_fields, "Hz")
2158
+
2159
+ previous_ex = _complex_state_field(state, "Ex")
2160
+ d_hz_dy = _bloch_backward_diff(
2161
+ hz_complex,
2162
+ axis=1,
2163
+ inv_delta=solver.inv_dy_e,
2164
+ phase_cos=float(solver.boundary_phase_cos[1]),
2165
+ phase_sin=float(solver.boundary_phase_sin[1]),
2166
+ )
2167
+ d_hy_dz = _backward_diff(hy_complex, axis=2, inv_delta=solver.inv_dz_e)
2168
+ ex_complex = previous_ex * solver.cex_decay + ex_curl * (d_hz_dy - d_hy_dz)
2169
+ ex_complex = _apply_complex_axis_boundary(
2170
+ ex_complex,
2171
+ previous_ex,
2172
+ axis=2,
2173
+ low_mode=solver.boundary_z_low_code,
2174
+ high_mode=solver.boundary_z_high_code,
2175
+ )
2176
+ psi_ex_z = torch.complex(state["psi_ex_z"], state["psi_ex_z_imag"])
2177
+ ex_complex, psi_ex_z = _apply_complex_cpml_correction(
2178
+ ex_complex,
2179
+ psi_ex_z,
2180
+ d_hy_dz,
2181
+ curl=ex_curl,
2182
+ inv_kappa=solver.cpml_inv_kappa_e_z,
2183
+ b=solver.cpml_b_e_z,
2184
+ c=solver.cpml_c_e_z,
2185
+ normal_axis=2,
2186
+ tangent_axis=1,
2187
+ tangent_low_mode=solver.boundary_y_low_code,
2188
+ tangent_high_mode=solver.boundary_y_high_code,
2189
+ sign=-1.0,
2190
+ )
2191
+
2192
+ previous_ey = _complex_state_field(state, "Ey")
2193
+ d_hx_dz = _backward_diff(hx_complex, axis=2, inv_delta=solver.inv_dz_e)
2194
+ d_hz_dx = _bloch_backward_diff(
2195
+ hz_complex,
2196
+ axis=0,
2197
+ inv_delta=solver.inv_dx_e,
2198
+ phase_cos=float(solver.boundary_phase_cos[0]),
2199
+ phase_sin=float(solver.boundary_phase_sin[0]),
2200
+ )
2201
+ ey_complex = previous_ey * solver.cey_decay + ey_curl * (d_hx_dz - d_hz_dx)
2202
+ ey_complex = _apply_complex_axis_boundary(
2203
+ ey_complex,
2204
+ previous_ey,
2205
+ axis=2,
2206
+ low_mode=solver.boundary_z_low_code,
2207
+ high_mode=solver.boundary_z_high_code,
2208
+ )
2209
+ psi_ey_z = torch.complex(state["psi_ey_z"], state["psi_ey_z_imag"])
2210
+ ey_complex, psi_ey_z = _apply_complex_cpml_correction(
2211
+ ey_complex,
2212
+ psi_ey_z,
2213
+ d_hx_dz,
2214
+ curl=ey_curl,
2215
+ inv_kappa=solver.cpml_inv_kappa_e_z,
2216
+ b=solver.cpml_b_e_z,
2217
+ c=solver.cpml_c_e_z,
2218
+ normal_axis=2,
2219
+ tangent_axis=0,
2220
+ tangent_low_mode=solver.boundary_x_low_code,
2221
+ tangent_high_mode=solver.boundary_x_high_code,
2222
+ sign=1.0,
2223
+ )
2224
+
2225
+ previous_ez = _complex_state_field(state, "Ez")
2226
+ d_hy_dx = _bloch_backward_diff(
2227
+ hy_complex,
2228
+ axis=0,
2229
+ inv_delta=solver.inv_dx_e,
2230
+ phase_cos=float(solver.boundary_phase_cos[0]),
2231
+ phase_sin=float(solver.boundary_phase_sin[0]),
2232
+ )
2233
+ d_hx_dy = _bloch_backward_diff(
2234
+ hx_complex,
2235
+ axis=1,
2236
+ inv_delta=solver.inv_dy_e,
2237
+ phase_cos=float(solver.boundary_phase_cos[1]),
2238
+ phase_sin=float(solver.boundary_phase_sin[1]),
2239
+ )
2240
+ ez_complex = previous_ez * solver.cez_decay + ez_curl * (d_hy_dx - d_hx_dy)
2241
+
2242
+ electric_fields = {
2243
+ "Ex": ex_complex.real,
2244
+ "Ey": ey_complex.real,
2245
+ "Ez": ez_complex.real,
2246
+ "Ex_imag": ex_complex.imag,
2247
+ "Ey_imag": ey_complex.imag,
2248
+ "Ez_imag": ez_complex.imag,
2249
+ }
2250
+ electric_cpml_state = {
2251
+ "psi_ex_y": state["psi_ex_y"],
2252
+ "psi_ex_z": psi_ex_z.real,
2253
+ "psi_ey_x": state["psi_ey_x"],
2254
+ "psi_ey_z": psi_ey_z.real,
2255
+ "psi_ez_x": state["psi_ez_x"],
2256
+ "psi_ez_y": state["psi_ez_y"],
2257
+ "psi_ex_y_imag": state["psi_ex_y_imag"],
2258
+ "psi_ex_z_imag": psi_ex_z.imag,
2259
+ "psi_ey_x_imag": state["psi_ey_x_imag"],
2260
+ "psi_ey_z_imag": psi_ey_z.imag,
2261
+ "psi_ez_x_imag": state["psi_ez_x_imag"],
2262
+ "psi_ez_y_imag": state["psi_ez_y_imag"],
2263
+ }
2264
+ return electric_fields, electric_cpml_state
2265
+
2266
+
2267
+ def _update_general_bloch_cpml_electric_fields(
2268
+ solver, state, magnetic_fields, *, pml_axis, ex_curl, ey_curl, ez_curl
2269
+ ):
2270
+ """Reverse replay of the general single-PML-axis (x or y) Bloch+CPML electric
2271
+ update. The forward runs the full-Bloch base (``update_electric_fields_bloch``)
2272
+ with the true wrap phase on the two periodic axes and the natural unit phase on
2273
+ the absorbing axis, then folds the recursive-convolution stretch into the two
2274
+ components with an absorbing-axis derivative. This mirrors that step exactly so
2275
+ the field, eps, and source VJPs stay consistent with the forward kernels."""
2276
+ hx = _complex_state_field(magnetic_fields, "Hx")
2277
+ hy = _complex_state_field(magnetic_fields, "Hy")
2278
+ hz = _complex_state_field(magnetic_fields, "Hz")
2279
+ phase_cos = solver.boundary_phase_cos
2280
+ phase_sin = solver.boundary_phase_sin
2281
+
2282
+ def _bdiff(field, axis, inv_delta):
2283
+ return _bloch_backward_diff(
2284
+ field,
2285
+ axis=axis,
2286
+ inv_delta=inv_delta,
2287
+ phase_cos=float(phase_cos[axis]),
2288
+ phase_sin=float(phase_sin[axis]),
2289
+ )
2290
+
2291
+ d_hz_dy = _bdiff(hz, 1, solver.inv_dy_e)
2292
+ d_hy_dz = _bdiff(hy, 2, solver.inv_dz_e)
2293
+ ex = _complex_state_field(state, "Ex") * solver.cex_decay + ex_curl * (d_hz_dy - d_hy_dz)
2294
+
2295
+ d_hx_dz = _bdiff(hx, 2, solver.inv_dz_e)
2296
+ d_hz_dx = _bdiff(hz, 0, solver.inv_dx_e)
2297
+ ey = _complex_state_field(state, "Ey") * solver.cey_decay + ey_curl * (d_hx_dz - d_hz_dx)
2298
+
2299
+ d_hy_dx = _bdiff(hy, 0, solver.inv_dx_e)
2300
+ d_hx_dy = _bdiff(hx, 1, solver.inv_dy_e)
2301
+ ez = _complex_state_field(state, "Ez") * solver.cez_decay + ez_curl * (d_hy_dx - d_hx_dy)
2302
+
2303
+ psi = {
2304
+ name: torch.complex(state[name], state[f"{name}_imag"])
2305
+ for name in ("psi_ex_y", "psi_ex_z", "psi_ey_x", "psi_ey_z", "psi_ez_x", "psi_ez_y")
2306
+ }
2307
+
2308
+ if pml_axis == "y":
2309
+ ex, psi["psi_ex_y"] = _apply_complex_cpml_correction(
2310
+ ex,
2311
+ psi["psi_ex_y"],
2312
+ d_hz_dy,
2313
+ curl=ex_curl,
2314
+ inv_kappa=solver.cpml_inv_kappa_e_y,
2315
+ b=solver.cpml_b_e_y,
2316
+ c=solver.cpml_c_e_y,
2317
+ normal_axis=1,
2318
+ tangent_axis=2,
2319
+ tangent_low_mode=solver.boundary_z_low_code,
2320
+ tangent_high_mode=solver.boundary_z_high_code,
2321
+ sign=1.0,
2322
+ )
2323
+ ez, psi["psi_ez_y"] = _apply_complex_cpml_correction(
2324
+ ez,
2325
+ psi["psi_ez_y"],
2326
+ d_hx_dy,
2327
+ curl=ez_curl,
2328
+ inv_kappa=solver.cpml_inv_kappa_e_y,
2329
+ b=solver.cpml_b_e_y,
2330
+ c=solver.cpml_c_e_y,
2331
+ normal_axis=1,
2332
+ tangent_axis=0,
2333
+ tangent_low_mode=solver.boundary_x_low_code,
2334
+ tangent_high_mode=solver.boundary_x_high_code,
2335
+ sign=-1.0,
2336
+ )
2337
+ else: # pml_axis == "x"
2338
+ ey, psi["psi_ey_x"] = _apply_complex_cpml_correction(
2339
+ ey,
2340
+ psi["psi_ey_x"],
2341
+ d_hz_dx,
2342
+ curl=ey_curl,
2343
+ inv_kappa=solver.cpml_inv_kappa_e_x,
2344
+ b=solver.cpml_b_e_x,
2345
+ c=solver.cpml_c_e_x,
2346
+ normal_axis=0,
2347
+ tangent_axis=2,
2348
+ tangent_low_mode=solver.boundary_z_low_code,
2349
+ tangent_high_mode=solver.boundary_z_high_code,
2350
+ sign=-1.0,
2351
+ )
2352
+ ez, psi["psi_ez_x"] = _apply_complex_cpml_correction(
2353
+ ez,
2354
+ psi["psi_ez_x"],
2355
+ d_hy_dx,
2356
+ curl=ez_curl,
2357
+ inv_kappa=solver.cpml_inv_kappa_e_x,
2358
+ b=solver.cpml_b_e_x,
2359
+ c=solver.cpml_c_e_x,
2360
+ normal_axis=0,
2361
+ tangent_axis=1,
2362
+ tangent_low_mode=solver.boundary_y_low_code,
2363
+ tangent_high_mode=solver.boundary_y_high_code,
2364
+ sign=1.0,
2365
+ )
2366
+
2367
+ electric_fields = {
2368
+ "Ex": ex.real,
2369
+ "Ey": ey.real,
2370
+ "Ez": ez.real,
2371
+ "Ex_imag": ex.imag,
2372
+ "Ey_imag": ey.imag,
2373
+ "Ez_imag": ez.imag,
2374
+ }
2375
+ electric_cpml_state = {}
2376
+ for name, value in psi.items():
2377
+ electric_cpml_state[name] = value.real
2378
+ electric_cpml_state[f"{name}_imag"] = value.imag
2379
+ return electric_fields, electric_cpml_state
2380
+
2381
+
2382
+ def _clamped_center_pair_sum(field: torch.Tensor, axis: int) -> torch.Tensor:
2383
+ """Sum of ``field[clamp(i-1)] + field[clamp(i)]`` for ``i in 0..size`` along ``axis``.
2384
+
2385
+ Replicates the index-clamped two-point gather of the CUDA collocation helper:
2386
+ the output is one element longer than the input along ``axis``.
2387
+ """
2388
+ first = field.narrow(axis, 0, 1)
2389
+ last = field.narrow(axis, field.shape[axis] - 1, 1)
2390
+ padded = torch.cat([first, field, last], dim=axis)
2391
+ length = field.shape[axis] + 1
2392
+ return padded.narrow(axis, 0, length) + padded.narrow(axis, 1, length)
2393
+
2394
+
2395
+ def _forward_pair_sum(field: torch.Tensor, axis: int) -> torch.Tensor:
2396
+ """Sum of ``field[i] + field[i+1]`` for ``i in 0..size-2`` along ``axis``."""
2397
+ length = field.shape[axis] - 1
2398
+ return field.narrow(axis, 0, length) + field.narrow(axis, 1, length)
2399
+
2400
+
2401
+ def _collocated_field_square(ex, ey, ez):
2402
+ """|E|^2 collocated onto each electric Yee edge, matching the CUDA
2403
+ ``collocate_electric_components`` stencil (4-point off-axis average with
2404
+ index clamping at the domain faces)."""
2405
+ ey_on_ex = 0.25 * _forward_pair_sum(_clamped_center_pair_sum(ey, axis=1), axis=0)
2406
+ ez_on_ex = 0.25 * _forward_pair_sum(_clamped_center_pair_sum(ez, axis=2), axis=0)
2407
+ ex_on_ey = 0.25 * _forward_pair_sum(_clamped_center_pair_sum(ex, axis=0), axis=1)
2408
+ ez_on_ey = 0.25 * _forward_pair_sum(_clamped_center_pair_sum(ez, axis=2), axis=1)
2409
+ ex_on_ez = 0.25 * _forward_pair_sum(_clamped_center_pair_sum(ex, axis=0), axis=2)
2410
+ ey_on_ez = 0.25 * _forward_pair_sum(_clamped_center_pair_sum(ey, axis=1), axis=2)
2411
+ return {
2412
+ "Ex": ex * ex + ey_on_ex * ey_on_ex + ez_on_ex * ez_on_ex,
2413
+ "Ey": ex_on_ey * ex_on_ey + ey * ey + ez_on_ey * ez_on_ey,
2414
+ "Ez": ex_on_ez * ex_on_ez + ey_on_ez * ey_on_ez + ez * ez,
2415
+ }
2416
+
2417
+
2418
+ def _kerr_dynamic_electric_curls(solver, state, *, eps_ex, eps_ey, eps_ez, chi3_ex, chi3_ey, chi3_ez):
2419
+ """Differentiable replica of ``updateKerrElectricField*Curl3D``:
2420
+ ``curl = dt / max(eps + eps0 * chi3 * |E|^2, floor) * decay``."""
2421
+ field_square = _collocated_field_square(state["Ex"], state["Ey"], state["Ez"])
2422
+ eps0 = float(solver.eps0)
2423
+ floor = 1.0e-12 * eps0
2424
+ dt = float(solver.dt)
2425
+ curls = {}
2426
+ for component_name, eps, chi3, decay in (
2427
+ ("Ex", eps_ex, chi3_ex, solver.cex_decay),
2428
+ ("Ey", eps_ey, chi3_ey, solver.cey_decay),
2429
+ ("Ez", eps_ez, chi3_ez, solver.cez_decay),
2430
+ ):
2431
+ effective = torch.clamp_min(eps + eps0 * chi3 * field_square[component_name], floor)
2432
+ curls[component_name] = (dt / effective) * decay
2433
+ return curls
2434
+
2435
+
2436
+ def _conductive_electric_coefficients(solver, *, eps_ex, eps_ey, eps_ez):
2437
+ """Differentiable replica of the semi-implicit lossy ``_electric_update_coefficients``.
2438
+
2439
+ The forward bakes ``decay = (1 - h)/(1 + h)`` and ``curl = (dt/eps)/(1 + h)``
2440
+ (with ``h = 0.5*sigma_e*dt/eps``, times an eps-independent PML/PEC factor)
2441
+ into ``c*_decay`` / ``c*_curl``. Both carry an ``eps`` dependence that the
2442
+ linear-dielectric reverse rule drops, so recompute them from the
2443
+ differentiable ``eps`` leaves and the frozen per-edge conductivity. The
2444
+ eps-independent factor (PML split-field decay and any PEC open fraction) is
2445
+ recovered from the frozen forward curl so those scalings are preserved
2446
+ exactly. Returns per-component ``(decay, curl)`` where ``curl`` is the full
2447
+ curl coefficient (fed with ``eps = 1`` in the update, mirroring the Kerr
2448
+ dynamic-curl path)."""
2449
+ dt = float(solver.dt)
2450
+ coeffs = {}
2451
+ for name, eps, eps_fwd, sigma, curl_fwd in (
2452
+ ("Ex", eps_ex, solver.eps_Ex, solver.sigma_e_Ex, solver.cex_curl),
2453
+ ("Ey", eps_ey, solver.eps_Ey, solver.sigma_e_Ey, solver.cey_curl),
2454
+ ("Ez", eps_ez, solver.eps_Ez, solver.sigma_e_Ez, solver.cez_curl),
2455
+ ):
2456
+ half_fwd = 0.5 * sigma * dt / eps_fwd
2457
+ # curl_fwd = (dt / eps_fwd) / (1 + half_fwd) * factor -> solve for factor.
2458
+ factor = curl_fwd * eps_fwd * (1.0 + half_fwd) / dt
2459
+ half = 0.5 * sigma * dt / eps
2460
+ denom = 1.0 + half
2461
+ decay = ((1.0 - half) / denom) * factor
2462
+ curl = (dt / eps / denom) * factor
2463
+ coeffs[name] = (decay, curl)
2464
+ return coeffs
2465
+
2466
+
2467
+ def _conductive_reverse_coefficients(solver, *, eps_ex, eps_ey, eps_ez):
2468
+ """Per-component ``(decay, curl, half)`` for the static-conductive reverse.
2469
+
2470
+ Identical bake to :func:`_conductive_electric_coefficients` but also returns
2471
+ the semi-implicit ``half = 0.5*sigma_e*dt/eps`` (recomputed from the
2472
+ differentiable ``eps`` leaf) so the reverse can form the analytic
2473
+ ``d(decay)/d(eps)`` and ``d(curl)/d(eps)`` sensitivities without an autograd
2474
+ VJP: ``d(decay)/d(eps) = 2*half*curl/(dt*(1+half))`` and
2475
+ ``d(curl)/d(eps) = -curl/(eps*(1+half))``. The eps-independent PML/PEC
2476
+ ``factor`` is recovered from the frozen forward curl so those scalings are
2477
+ preserved exactly."""
2478
+ dt = float(solver.dt)
2479
+ coeffs = {}
2480
+ for name, eps, eps_fwd, sigma, curl_fwd in (
2481
+ ("Ex", eps_ex, solver.eps_Ex, solver.sigma_e_Ex, solver.cex_curl),
2482
+ ("Ey", eps_ey, solver.eps_Ey, solver.sigma_e_Ey, solver.cey_curl),
2483
+ ("Ez", eps_ez, solver.eps_Ez, solver.sigma_e_Ez, solver.cez_curl),
2484
+ ):
2485
+ half_fwd = 0.5 * sigma * dt / eps_fwd
2486
+ factor = curl_fwd * eps_fwd * (1.0 + half_fwd) / dt
2487
+ half = 0.5 * sigma * dt / eps
2488
+ denom = 1.0 + half
2489
+ decay = ((1.0 - half) / denom) * factor
2490
+ curl = (dt / eps / denom) * factor
2491
+ coeffs[name] = (decay, curl, half)
2492
+ return coeffs
2493
+
2494
+
2495
+ def _reverse_electric_component_cpml_conductive(
2496
+ adj_updated,
2497
+ adj_psi_pos_post,
2498
+ adj_psi_neg_post,
2499
+ field,
2500
+ *,
2501
+ d_pos,
2502
+ d_neg,
2503
+ decay,
2504
+ curl,
2505
+ half,
2506
+ eps,
2507
+ dt,
2508
+ low_mode_pos,
2509
+ high_mode_pos,
2510
+ low_mode_neg,
2511
+ high_mode_neg,
2512
+ axis_pos,
2513
+ axis_neg,
2514
+ psi_pos,
2515
+ psi_neg,
2516
+ b_pos,
2517
+ c_pos,
2518
+ inv_kappa_pos,
2519
+ b_neg,
2520
+ c_neg,
2521
+ inv_kappa_neg,
2522
+ ):
2523
+ """Static-conductive analytic reverse of the semi-implicit CPML electric update.
2524
+
2525
+ Structurally identical to :func:`_reverse_electric_component_cpml` (the psi
2526
+ recursion, ``adj_d_pos``/``adj_d_neg`` folds, and pre-step field pullback all
2527
+ reuse the CPML stretch unchanged, because the conductive update only rescales
2528
+ the ``decay``/``curl`` coefficient pair). The one difference is the eps
2529
+ gradient: the semi-implicit ``decay`` and ``curl`` both depend on ``eps``
2530
+ through the loss denominator, so the linear rule ``-curl*curl_term/eps^2`` is
2531
+ replaced by the exact
2532
+ ``adj_updated*(E_prev*d(decay)/d(eps) + curl_term*d(curl)/d(eps))``."""
2533
+ inactive_pos, pec_pos = _boundary_axis_masks(field.shape, axis_pos, low_mode_pos, high_mode_pos, field.device)
2534
+ inactive_neg, pec_neg = _boundary_axis_masks(field.shape, axis_neg, low_mode_neg, high_mode_neg, field.device)
2535
+ pec_mask = pec_pos | pec_neg
2536
+ inactive_mask = (~pec_mask) & (inactive_pos | inactive_neg)
2537
+ active_mask = (~pec_mask) & (~inactive_mask)
2538
+ keep_mask = inactive_mask | pec_mask
2539
+
2540
+ active = active_mask.to(device=field.device, dtype=field.dtype)
2541
+ inactive = inactive_mask.to(device=field.device, dtype=field.dtype)
2542
+ keep = keep_mask.to(device=field.device, dtype=field.dtype)
2543
+
2544
+ b_pos_term = _broadcast_vector(b_pos, axis_pos)
2545
+ c_pos_term = _broadcast_vector(c_pos, axis_pos)
2546
+ inv_kappa_pos_term = _broadcast_vector(inv_kappa_pos, axis_pos)
2547
+ b_neg_term = _broadcast_vector(b_neg, axis_neg)
2548
+ c_neg_term = _broadcast_vector(c_neg, axis_neg)
2549
+ inv_kappa_neg_term = _broadcast_vector(inv_kappa_neg, axis_neg)
2550
+
2551
+ psi_pos_candidate = b_pos_term * psi_pos + c_pos_term * d_pos
2552
+ psi_neg_candidate = b_neg_term * psi_neg + c_neg_term * d_neg
2553
+ curl_term = (d_pos * inv_kappa_pos_term + psi_pos_candidate) - (d_neg * inv_kappa_neg_term + psi_neg_candidate)
2554
+
2555
+ denom = 1.0 + half
2556
+ d_decay_d_eps = 2.0 * half * curl / (float(dt) * denom)
2557
+ d_curl_d_eps = -curl / (eps * denom)
2558
+
2559
+ adj_field = inactive * adj_updated + active * (adj_updated * decay)
2560
+ adj_curl_term = active * adj_updated * curl
2561
+ grad_eps = active * adj_updated * (field * d_decay_d_eps + curl_term * d_curl_d_eps)
2562
+ adj_psi_pos_candidate = active * adj_psi_pos_post + adj_curl_term
2563
+ adj_psi_neg_candidate = active * adj_psi_neg_post - adj_curl_term
2564
+ adj_psi_pos = keep * adj_psi_pos_post + active * (b_pos_term * adj_psi_pos_candidate)
2565
+ adj_psi_neg = keep * adj_psi_neg_post + active * (b_neg_term * adj_psi_neg_candidate)
2566
+ adj_d_pos = inv_kappa_pos_term * adj_curl_term + c_pos_term * adj_psi_pos_candidate
2567
+ adj_d_neg = -inv_kappa_neg_term * adj_curl_term + c_neg_term * adj_psi_neg_candidate
2568
+ return adj_field, adj_d_pos, adj_d_neg, grad_eps, adj_psi_pos, adj_psi_neg
2569
+
2570
+
2571
+ def _reverse_electric_component_cpml_nonlinear(
2572
+ adj_updated,
2573
+ adj_psi_pos_post,
2574
+ adj_psi_neg_post,
2575
+ field,
2576
+ *,
2577
+ d_pos,
2578
+ d_neg,
2579
+ decay,
2580
+ curl,
2581
+ low_mode_pos,
2582
+ high_mode_pos,
2583
+ low_mode_neg,
2584
+ high_mode_neg,
2585
+ axis_pos,
2586
+ axis_neg,
2587
+ psi_pos,
2588
+ psi_neg,
2589
+ b_pos,
2590
+ c_pos,
2591
+ inv_kappa_pos,
2592
+ b_neg,
2593
+ c_neg,
2594
+ inv_kappa_neg,
2595
+ ):
2596
+ """Reverse of the CPML electric update with GIVEN per-edge decay/curl pair.
2597
+
2598
+ Unlike :func:`_reverse_electric_component_cpml` (which bakes the linear
2599
+ ``curl_prefactor/eps`` rule and returns ``grad_eps``), this returns the raw
2600
+ cotangents ``adj_decay`` and ``adj_curl`` to the field-dependent coefficient
2601
+ pair so a nonlinear coefficient reverse (Kerr / general) can push them onto the
2602
+ ``eps`` / ``chi2`` / ``chi3`` / ``tpa`` leaves and (via the collocation
2603
+ transpose) the pre-step fields. The psi recursion, the ``adj_d_pos``/
2604
+ ``adj_d_neg`` curl(H) folds, and the pre-step field pullback are the CPML
2605
+ machinery unchanged (a field-dependent coefficient only rescales the update)."""
2606
+ inactive_pos, pec_pos = _boundary_axis_masks(field.shape, axis_pos, low_mode_pos, high_mode_pos, field.device)
2607
+ inactive_neg, pec_neg = _boundary_axis_masks(field.shape, axis_neg, low_mode_neg, high_mode_neg, field.device)
2608
+ pec_mask = pec_pos | pec_neg
2609
+ inactive_mask = (~pec_mask) & (inactive_pos | inactive_neg)
2610
+ active_mask = (~pec_mask) & (~inactive_mask)
2611
+ keep_mask = inactive_mask | pec_mask
2612
+
2613
+ active = active_mask.to(device=field.device, dtype=field.dtype)
2614
+ inactive = inactive_mask.to(device=field.device, dtype=field.dtype)
2615
+ keep = keep_mask.to(device=field.device, dtype=field.dtype)
2616
+
2617
+ b_pos_term = _broadcast_vector(b_pos, axis_pos)
2618
+ c_pos_term = _broadcast_vector(c_pos, axis_pos)
2619
+ inv_kappa_pos_term = _broadcast_vector(inv_kappa_pos, axis_pos)
2620
+ b_neg_term = _broadcast_vector(b_neg, axis_neg)
2621
+ c_neg_term = _broadcast_vector(c_neg, axis_neg)
2622
+ inv_kappa_neg_term = _broadcast_vector(inv_kappa_neg, axis_neg)
2623
+
2624
+ psi_pos_candidate = b_pos_term * psi_pos + c_pos_term * d_pos
2625
+ psi_neg_candidate = b_neg_term * psi_neg + c_neg_term * d_neg
2626
+ curl_term = (d_pos * inv_kappa_pos_term + psi_pos_candidate) - (d_neg * inv_kappa_neg_term + psi_neg_candidate)
2627
+
2628
+ adj_field = inactive * adj_updated + active * (adj_updated * decay)
2629
+ adj_decay = active * (adj_updated * field)
2630
+ adj_curl = active * (adj_updated * curl_term)
2631
+ adj_curl_term = active * (adj_updated * curl)
2632
+ adj_psi_pos_candidate = active * adj_psi_pos_post + adj_curl_term
2633
+ adj_psi_neg_candidate = active * adj_psi_neg_post - adj_curl_term
2634
+ adj_psi_pos = keep * adj_psi_pos_post + active * (b_pos_term * adj_psi_pos_candidate)
2635
+ adj_psi_neg = keep * adj_psi_neg_post + active * (b_neg_term * adj_psi_neg_candidate)
2636
+ adj_d_pos = inv_kappa_pos_term * adj_curl_term + c_pos_term * adj_psi_pos_candidate
2637
+ adj_d_neg = -inv_kappa_neg_term * adj_curl_term + c_neg_term * adj_psi_neg_candidate
2638
+ return adj_field, adj_d_pos, adj_d_neg, adj_decay, adj_curl, adj_psi_pos, adj_psi_neg
2639
+
2640
+
2641
+ def _kerr_reverse_coefficients(solver, state, *, eps_ex, eps_ey, eps_ez, chi3_ex, chi3_ey, chi3_ez):
2642
+ """Per-component ``(decay, curl, eff, fsq, clamp_mask)`` for the Kerr reverse.
2643
+
2644
+ Mirrors :func:`_kerr_dynamic_electric_curls` (the differentiable forward
2645
+ replica already parity-tested against ``updateKerrElectricField*Curl3D``) but
2646
+ also returns the effective permittivity ``eff``, the collocated ``|E|^2``, and
2647
+ the ``clamp_min`` gradient mask so the reverse can form the analytic coefficient
2648
+ sensitivities without an autograd VJP. ``decay`` is the frozen linear PML decay
2649
+ (constant in the Kerr update); the eps/chi3/field dependence lives entirely in
2650
+ ``curl = (dt / eff) * decay`` through ``eff = eps + eps0 * chi3 * |E|^2``."""
2651
+ field_square = _collocated_field_square(state["Ex"], state["Ey"], state["Ez"])
2652
+ eps0 = float(solver.eps0)
2653
+ floor = 1.0e-12 * eps0
2654
+ dt = float(solver.dt)
2655
+ coeffs = {}
2656
+ for name, eps, chi3, decay in (
2657
+ ("Ex", eps_ex, chi3_ex, solver.cex_decay),
2658
+ ("Ey", eps_ey, chi3_ey, solver.cey_decay),
2659
+ ("Ez", eps_ez, chi3_ez, solver.cez_decay),
2660
+ ):
2661
+ fsq = field_square[name]
2662
+ raw = eps + eps0 * chi3 * fsq
2663
+ eff = torch.clamp_min(raw, floor)
2664
+ clamp_mask = (raw >= floor).to(dtype=eff.dtype)
2665
+ curl = (dt / eff) * decay
2666
+ coeffs[name] = (decay, curl, eff, fsq, clamp_mask)
2667
+ return coeffs
2668
+
2669
+
2670
+ def _general_nonlinear_electric_coefficients(
2671
+ solver,
2672
+ state,
2673
+ *,
2674
+ eps_ex,
2675
+ eps_ey,
2676
+ eps_ez,
2677
+ chi2_ex,
2678
+ chi2_ey,
2679
+ chi2_ez,
2680
+ chi3_ex,
2681
+ chi3_ey,
2682
+ chi3_ez,
2683
+ tpa_ex,
2684
+ tpa_ey,
2685
+ tpa_ez,
2686
+ ):
2687
+ """Differentiable replica of ``updateNonlinearElectricCoefficients3D``.
2688
+
2689
+ The general nonlinear kernel rewrites both the decay and the curl coefficient
2690
+ every step from the pre-update fields, exactly as in the CUDA source::
2691
+
2692
+ eps_eff = max(eps_lin + eps0 * (chi2 * E_own + chi3 * |E|^2), floor)
2693
+ sigma = max(sigma_static + tpa_sigma * |E|^2, 0)
2694
+ half = 0.5 * sigma * dt / eps_eff
2695
+ decay = external * (1 - half) / (1 + half)
2696
+ curl = external * (dt / eps_eff) / (1 + half)
2697
+
2698
+ ``external`` (``c*_decay_external``) carries the PML split-field decay and the
2699
+ PEC open fraction. ``E_own`` is the component's own edge field (the CUDA
2700
+ collocation returns the direct value for the own axis), while ``|E|^2`` is the
2701
+ 4-point off-axis collocation shared with the Kerr replica. Flowing the VJP
2702
+ through the ``eps`` / ``chi2`` / ``chi3`` / ``tpa`` leaves and the pre-step
2703
+ ``state`` fields is what makes chi2 and two-photon absorption differentiable.
2704
+ Returns per-component ``(decay, curl)`` with ``dt / eps_eff`` already folded
2705
+ into ``curl`` (fed with ``eps = 1`` in the update, like the Kerr path).
2706
+ """
2707
+ field_square = _collocated_field_square(state["Ex"], state["Ey"], state["Ez"])
2708
+ eps0 = float(solver.eps0)
2709
+ floor = 1.0e-12 * eps0
2710
+ dt = float(solver.dt)
2711
+ coeffs = {}
2712
+ for name, own_field, eps, chi2, chi3, tpa, sigma_static, external in (
2713
+ ("Ex", state["Ex"], eps_ex, chi2_ex, chi3_ex, tpa_ex, solver.sigma_e_Ex, solver.cex_decay_external),
2714
+ ("Ey", state["Ey"], eps_ey, chi2_ey, chi3_ey, tpa_ey, solver.sigma_e_Ey, solver.cey_decay_external),
2715
+ ("Ez", state["Ez"], eps_ez, chi2_ez, chi3_ez, tpa_ez, solver.sigma_e_Ez, solver.cez_decay_external),
2716
+ ):
2717
+ effective = torch.clamp_min(
2718
+ eps + eps0 * (chi2 * own_field + chi3 * field_square[name]), floor
2719
+ )
2720
+ sigma = torch.clamp_min(sigma_static + tpa * field_square[name], 0.0)
2721
+ half = 0.5 * sigma * dt / effective
2722
+ inv_denom = 1.0 / (1.0 + half)
2723
+ decay = external * (1.0 - half) * inv_denom
2724
+ curl = external * (dt / effective) * inv_denom
2725
+ coeffs[name] = (decay, curl)
2726
+ return coeffs
2727
+
2728
+
2729
+ def _forward_magnetic_fields(solver, state, *, time_value, resolved_source_terms):
2730
+ """Recompute the post-source real magnetic fields of one forward step.
2731
+
2732
+ Mirrors the magnetic half of ``_step_state`` for real fields (standard or
2733
+ CPML, selected by the solver's psi/coefficient attributes) and applies the
2734
+ resolved magnetic source terms. Used by the reverse steps, which need the
2735
+ same mid-step H the electric update consumed.
2736
+ """
2737
+ uses_cpml = getattr(solver, "uses_cpml", False)
2738
+
2739
+ d_ez_dy = _forward_diff(state["Ez"], axis=1, inv_delta=solver.inv_dy_h)
2740
+ d_ey_dz = _forward_diff(state["Ey"], axis=2, inv_delta=solver.inv_dz_h)
2741
+ hx, _, _ = _update_magnetic_component(
2742
+ state["Hx"],
2743
+ d_pos=d_ez_dy,
2744
+ d_neg=d_ey_dz,
2745
+ decay=solver.chx_decay,
2746
+ curl=solver.chx_curl,
2747
+ psi_pos=state.get("psi_hx_y") if uses_cpml else None,
2748
+ psi_neg=state.get("psi_hx_z") if uses_cpml else None,
2749
+ b_pos=getattr(solver, "cpml_b_h_y", None),
2750
+ c_pos=getattr(solver, "cpml_c_h_y", None),
2751
+ inv_kappa_pos=getattr(solver, "cpml_inv_kappa_h_y", None),
2752
+ b_neg=getattr(solver, "cpml_b_h_z", None),
2753
+ c_neg=getattr(solver, "cpml_c_h_z", None),
2754
+ inv_kappa_neg=getattr(solver, "cpml_inv_kappa_h_z", None),
2755
+ axis_pos=1,
2756
+ axis_neg=2,
2757
+ )
2758
+
2759
+ d_ex_dz = _forward_diff(state["Ex"], axis=2, inv_delta=solver.inv_dz_h)
2760
+ d_ez_dx = _forward_diff(state["Ez"], axis=0, inv_delta=solver.inv_dx_h)
2761
+ hy, _, _ = _update_magnetic_component(
2762
+ state["Hy"],
2763
+ d_pos=d_ex_dz,
2764
+ d_neg=d_ez_dx,
2765
+ decay=solver.chy_decay,
2766
+ curl=solver.chy_curl,
2767
+ psi_pos=state.get("psi_hy_z") if uses_cpml else None,
2768
+ psi_neg=state.get("psi_hy_x") if uses_cpml else None,
2769
+ b_pos=getattr(solver, "cpml_b_h_z", None),
2770
+ c_pos=getattr(solver, "cpml_c_h_z", None),
2771
+ inv_kappa_pos=getattr(solver, "cpml_inv_kappa_h_z", None),
2772
+ b_neg=getattr(solver, "cpml_b_h_x", None),
2773
+ c_neg=getattr(solver, "cpml_c_h_x", None),
2774
+ inv_kappa_neg=getattr(solver, "cpml_inv_kappa_h_x", None),
2775
+ axis_pos=2,
2776
+ axis_neg=0,
2777
+ )
2778
+
2779
+ d_ey_dx = _forward_diff(state["Ey"], axis=0, inv_delta=solver.inv_dx_h)
2780
+ d_ex_dy = _forward_diff(state["Ex"], axis=1, inv_delta=solver.inv_dy_h)
2781
+ hz, _, _ = _update_magnetic_component(
2782
+ state["Hz"],
2783
+ d_pos=d_ey_dx,
2784
+ d_neg=d_ex_dy,
2785
+ decay=solver.chz_decay,
2786
+ curl=solver.chz_curl,
2787
+ psi_pos=state.get("psi_hz_x") if uses_cpml else None,
2788
+ psi_neg=state.get("psi_hz_y") if uses_cpml else None,
2789
+ b_pos=getattr(solver, "cpml_b_h_x", None),
2790
+ c_pos=getattr(solver, "cpml_c_h_x", None),
2791
+ inv_kappa_pos=getattr(solver, "cpml_inv_kappa_h_x", None),
2792
+ b_neg=getattr(solver, "cpml_b_h_y", None),
2793
+ c_neg=getattr(solver, "cpml_c_h_y", None),
2794
+ inv_kappa_neg=getattr(solver, "cpml_inv_kappa_h_y", None),
2795
+ axis_pos=0,
2796
+ axis_neg=1,
2797
+ )
2798
+
2799
+ return _apply_resolved_magnetic_source_terms(
2800
+ {"Hx": hx, "Hy": hy, "Hz": hz},
2801
+ solver=solver,
2802
+ time_value=time_value,
2803
+ resolved_source_terms=resolved_source_terms,
2804
+ )
2805
+
2806
+
2807
+ def _forward_magnetic_fields_complex(solver, state, *, time_value, resolved_source_terms):
2808
+ """Recompute the post-source complex (split-field) magnetic fields of one Bloch step.
2809
+
2810
+ Mirrors the magnetic half of the split-field Bloch ``_step_state``: the real and
2811
+ imaginary field halves each run the plain (non-CPML) magnetic update with the
2812
+ shared decay/curl coefficients, and the resolved magnetic source terms apply once
2813
+ to the combined six-key dict. Both the analytic Bloch reference and the native
2814
+ Bloch reverse runner consume this to recover the mid-step H the electric update
2815
+ read (the reverse *math* is native; this replay stays Torch, same bar as the
2816
+ standard/CPML runners).
2817
+ """
2818
+
2819
+ def _magnetic_half(suffix):
2820
+ ex = state["Ex" + suffix]
2821
+ ey = state["Ey" + suffix]
2822
+ ez = state["Ez" + suffix]
2823
+ d_ez_dy = _forward_diff(ez, axis=1, inv_delta=solver.inv_dy_h)
2824
+ d_ey_dz = _forward_diff(ey, axis=2, inv_delta=solver.inv_dz_h)
2825
+ hx, _, _ = _update_magnetic_component(
2826
+ state["Hx" + suffix],
2827
+ d_pos=d_ez_dy,
2828
+ d_neg=d_ey_dz,
2829
+ decay=solver.chx_decay,
2830
+ curl=solver.chx_curl,
2831
+ axis_pos=1,
2832
+ axis_neg=2,
2833
+ )
2834
+ d_ex_dz = _forward_diff(ex, axis=2, inv_delta=solver.inv_dz_h)
2835
+ d_ez_dx = _forward_diff(ez, axis=0, inv_delta=solver.inv_dx_h)
2836
+ hy, _, _ = _update_magnetic_component(
2837
+ state["Hy" + suffix],
2838
+ d_pos=d_ex_dz,
2839
+ d_neg=d_ez_dx,
2840
+ decay=solver.chy_decay,
2841
+ curl=solver.chy_curl,
2842
+ axis_pos=2,
2843
+ axis_neg=0,
2844
+ )
2845
+ d_ey_dx = _forward_diff(ey, axis=0, inv_delta=solver.inv_dx_h)
2846
+ d_ex_dy = _forward_diff(ex, axis=1, inv_delta=solver.inv_dy_h)
2847
+ hz, _, _ = _update_magnetic_component(
2848
+ state["Hz" + suffix],
2849
+ d_pos=d_ey_dx,
2850
+ d_neg=d_ex_dy,
2851
+ decay=solver.chz_decay,
2852
+ curl=solver.chz_curl,
2853
+ axis_pos=0,
2854
+ axis_neg=1,
2855
+ )
2856
+ return hx, hy, hz
2857
+
2858
+ hx, hy, hz = _magnetic_half("")
2859
+ hx_imag, hy_imag, hz_imag = _magnetic_half("_imag")
2860
+ magnetic_fields = {
2861
+ "Hx": hx,
2862
+ "Hy": hy,
2863
+ "Hz": hz,
2864
+ "Hx_imag": hx_imag,
2865
+ "Hy_imag": hy_imag,
2866
+ "Hz_imag": hz_imag,
2867
+ }
2868
+ return _apply_resolved_magnetic_source_terms(
2869
+ magnetic_fields,
2870
+ solver=solver,
2871
+ time_value=time_value,
2872
+ resolved_source_terms=resolved_source_terms,
2873
+ )
2874
+
2875
+
2876
+ def _sum_adjacent(field: torch.Tensor, axis: int) -> torch.Tensor:
2877
+ """Sum of each adjacent pair along ``axis`` (length shrinks by one)."""
2878
+ lo = [slice(None)] * field.ndim
2879
+ hi = [slice(None)] * field.ndim
2880
+ lo[axis] = slice(0, -1)
2881
+ hi[axis] = slice(1, None)
2882
+ return field[tuple(lo)] + field[tuple(hi)]
2883
+
2884
+
2885
+ def _spread_to_nodes(edge: torch.Tensor, axis: int) -> torch.Tensor:
2886
+ """Transpose of two-point averaging along ``axis`` (length grows by one).
2887
+
2888
+ Scatters each edge value onto its two bounding node positions with zero-fill
2889
+ at the ends, matching the four-neighbor collocation the full-anisotropy
2890
+ kernel performs when it maps an off-axis curl(H) sample onto the target edge
2891
+ (out-of-range neighbours contribute zero while the fixed 1/4 weight stays).
2892
+ """
2893
+ zero_shape = [1 if i == axis else size for i, size in enumerate(edge.shape)]
2894
+ zero = edge.new_zeros(zero_shape)
2895
+ below = torch.cat((edge, zero), dim=axis)
2896
+ above = torch.cat((zero, edge), dim=axis)
2897
+ return below + above
2898
+
2899
+
2900
+ def _collocate_curl(curl: torch.Tensor, *, edge_axis: int, curl_axis: int) -> torch.Tensor:
2901
+ """Neighbor-average an off-axis curl(H) component onto a target E edge.
2902
+
2903
+ ``edge_axis`` is the Yee-edge axis of the *target* E component (summed over
2904
+ the two straddling cells) and ``curl_axis`` is the edge axis of the source
2905
+ curl component (spread onto the target's two bounding nodes).
2906
+ """
2907
+ return _spread_to_nodes(_sum_adjacent(curl, edge_axis), curl_axis)
2908
+
2909
+
2910
+ def _full_aniso_electric_correction(solver, magnetic_fields):
2911
+ """Off-diagonal (full-tensor) anisotropic coupling added to the E update.
2912
+
2913
+ Differentiable Torch replica of ``apply_full_aniso_corrections`` (the native
2914
+ ``updateElectricFieldE{x,y,z}FullAniso3D`` kernels): each off-axis curl(H)
2915
+ component is four-neighbor-averaged onto the target Yee edge and scaled by
2916
+ the off-diagonal inverse-permittivity coefficient. It consumes the same
2917
+ post-source magnetic fields the diagonal update used, so the adjoint VJP
2918
+ flows the cotangent through the coupling into the H (and thence E) fields.
2919
+ """
2920
+ hx = magnetic_fields["Hx"]
2921
+ hy = magnetic_fields["Hy"]
2922
+ hz = magnetic_fields["Hz"]
2923
+ # curl(H) on the native Yee E edges, identical backward differences to the
2924
+ # diagonal update (curl_x -> Ex edge, curl_y -> Ey edge, curl_z -> Ez edge).
2925
+ curl_x = _backward_diff(hz, axis=1, inv_delta=solver.inv_dy_e) - _backward_diff(hy, axis=2, inv_delta=solver.inv_dz_e)
2926
+ curl_y = _backward_diff(hx, axis=2, inv_delta=solver.inv_dz_e) - _backward_diff(hz, axis=0, inv_delta=solver.inv_dx_e)
2927
+ curl_z = _backward_diff(hy, axis=0, inv_delta=solver.inv_dx_e) - _backward_diff(hx, axis=1, inv_delta=solver.inv_dy_e)
2928
+
2929
+ ex = 0.25 * (
2930
+ solver.cex_aniso_y * _collocate_curl(curl_y, edge_axis=0, curl_axis=1)
2931
+ + solver.cex_aniso_z * _collocate_curl(curl_z, edge_axis=0, curl_axis=2)
2932
+ )
2933
+ ey = 0.25 * (
2934
+ solver.cey_aniso_x * _collocate_curl(curl_x, edge_axis=1, curl_axis=0)
2935
+ + solver.cey_aniso_z * _collocate_curl(curl_z, edge_axis=1, curl_axis=2)
2936
+ )
2937
+ ez = 0.25 * (
2938
+ solver.cez_aniso_x * _collocate_curl(curl_x, edge_axis=2, curl_axis=0)
2939
+ + solver.cez_aniso_y * _collocate_curl(curl_y, edge_axis=2, curl_axis=1)
2940
+ )
2941
+ return {"Ex": ex, "Ey": ey, "Ez": ez}
2942
+
2943
+
2944
+ def _step_state(
2945
+ solver,
2946
+ state,
2947
+ *,
2948
+ time_value,
2949
+ eps_ex,
2950
+ eps_ey,
2951
+ eps_ez,
2952
+ chi3_ex=None,
2953
+ chi3_ey=None,
2954
+ chi3_ez=None,
2955
+ chi2_ex=None,
2956
+ chi2_ey=None,
2957
+ chi2_ez=None,
2958
+ tpa_ex=None,
2959
+ tpa_ey=None,
2960
+ tpa_ez=None,
2961
+ capture_magnetic=None,
2962
+ ):
2963
+ if getattr(solver, "modulation_enabled", False):
2964
+ raise NotImplementedError("FDTD adjoint replay does not support time-modulated media.")
2965
+ source_terms, electric_source_terms, magnetic_source_terms = _resolved_source_term_lists(
2966
+ solver,
2967
+ eps_ex,
2968
+ eps_ey,
2969
+ eps_ez,
2970
+ )
2971
+ auxiliary_state = _extract_tfsf_auxiliary_state(state)
2972
+ complex_fields = has_complex_fields(solver)
2973
+ psi_hx_y_imag = psi_hx_z_imag = psi_hy_x_imag = psi_hy_z_imag = psi_hz_x_imag = psi_hz_y_imag = None
2974
+
2975
+ d_ez_dy = _forward_diff(state["Ez"], axis=1, inv_delta=solver.inv_dy_h)
2976
+ d_ey_dz = _forward_diff(state["Ey"], axis=2, inv_delta=solver.inv_dz_h)
2977
+ hx, psi_hx_y, psi_hx_z = _update_magnetic_component(
2978
+ state["Hx"],
2979
+ d_pos=d_ez_dy,
2980
+ d_neg=d_ey_dz,
2981
+ decay=solver.chx_decay,
2982
+ curl=solver.chx_curl,
2983
+ psi_pos=state.get("psi_hx_y"),
2984
+ psi_neg=state.get("psi_hx_z"),
2985
+ b_pos=getattr(solver, "cpml_b_h_y", None),
2986
+ c_pos=getattr(solver, "cpml_c_h_y", None),
2987
+ inv_kappa_pos=getattr(solver, "cpml_inv_kappa_h_y", None),
2988
+ b_neg=getattr(solver, "cpml_b_h_z", None),
2989
+ c_neg=getattr(solver, "cpml_c_h_z", None),
2990
+ inv_kappa_neg=getattr(solver, "cpml_inv_kappa_h_z", None),
2991
+ axis_pos=1,
2992
+ axis_neg=2,
2993
+ )
2994
+
2995
+ d_ex_dz = _forward_diff(state["Ex"], axis=2, inv_delta=solver.inv_dz_h)
2996
+ d_ez_dx = _forward_diff(state["Ez"], axis=0, inv_delta=solver.inv_dx_h)
2997
+ hy, psi_hy_x, psi_hy_z = _update_magnetic_component(
2998
+ state["Hy"],
2999
+ d_pos=d_ex_dz,
3000
+ d_neg=d_ez_dx,
3001
+ decay=solver.chy_decay,
3002
+ curl=solver.chy_curl,
3003
+ psi_pos=state.get("psi_hy_z"),
3004
+ psi_neg=state.get("psi_hy_x"),
3005
+ b_pos=getattr(solver, "cpml_b_h_z", None),
3006
+ c_pos=getattr(solver, "cpml_c_h_z", None),
3007
+ inv_kappa_pos=getattr(solver, "cpml_inv_kappa_h_z", None),
3008
+ b_neg=getattr(solver, "cpml_b_h_x", None),
3009
+ c_neg=getattr(solver, "cpml_c_h_x", None),
3010
+ inv_kappa_neg=getattr(solver, "cpml_inv_kappa_h_x", None),
3011
+ axis_pos=2,
3012
+ axis_neg=0,
3013
+ )
3014
+
3015
+ d_ey_dx = _forward_diff(state["Ey"], axis=0, inv_delta=solver.inv_dx_h)
3016
+ d_ex_dy = _forward_diff(state["Ex"], axis=1, inv_delta=solver.inv_dy_h)
3017
+ hz, psi_hz_x, psi_hz_y = _update_magnetic_component(
3018
+ state["Hz"],
3019
+ d_pos=d_ey_dx,
3020
+ d_neg=d_ex_dy,
3021
+ decay=solver.chz_decay,
3022
+ curl=solver.chz_curl,
3023
+ psi_pos=state.get("psi_hz_x"),
3024
+ psi_neg=state.get("psi_hz_y"),
3025
+ b_pos=getattr(solver, "cpml_b_h_x", None),
3026
+ c_pos=getattr(solver, "cpml_c_h_x", None),
3027
+ inv_kappa_pos=getattr(solver, "cpml_inv_kappa_h_x", None),
3028
+ b_neg=getattr(solver, "cpml_b_h_y", None),
3029
+ c_neg=getattr(solver, "cpml_c_h_y", None),
3030
+ inv_kappa_neg=getattr(solver, "cpml_inv_kappa_h_y", None),
3031
+ axis_pos=0,
3032
+ axis_neg=1,
3033
+ )
3034
+
3035
+ magnetic_fields = {
3036
+ "Hx": hx,
3037
+ "Hy": hy,
3038
+ "Hz": hz,
3039
+ }
3040
+ if complex_fields:
3041
+ d_ez_imag_dy = _forward_diff(state["Ez_imag"], axis=1, inv_delta=solver.inv_dy_h)
3042
+ d_ey_imag_dz = _forward_diff(state["Ey_imag"], axis=2, inv_delta=solver.inv_dz_h)
3043
+ hx_imag, psi_hx_y_imag, psi_hx_z_imag = _update_magnetic_component(
3044
+ state["Hx_imag"],
3045
+ d_pos=d_ez_imag_dy,
3046
+ d_neg=d_ey_imag_dz,
3047
+ decay=solver.chx_decay,
3048
+ curl=solver.chx_curl,
3049
+ psi_pos=state.get("psi_hx_y_imag") if getattr(solver, "uses_cpml", False) else None,
3050
+ psi_neg=state.get("psi_hx_z_imag") if getattr(solver, "uses_cpml", False) else None,
3051
+ b_pos=getattr(solver, "cpml_b_h_y", None),
3052
+ c_pos=getattr(solver, "cpml_c_h_y", None),
3053
+ inv_kappa_pos=getattr(solver, "cpml_inv_kappa_h_y", None),
3054
+ b_neg=getattr(solver, "cpml_b_h_z", None),
3055
+ c_neg=getattr(solver, "cpml_c_h_z", None),
3056
+ inv_kappa_neg=getattr(solver, "cpml_inv_kappa_h_z", None),
3057
+ axis_pos=1,
3058
+ axis_neg=2,
3059
+ )
3060
+
3061
+ d_ex_imag_dz = _forward_diff(state["Ex_imag"], axis=2, inv_delta=solver.inv_dz_h)
3062
+ d_ez_imag_dx = _forward_diff(state["Ez_imag"], axis=0, inv_delta=solver.inv_dx_h)
3063
+ hy_imag, psi_hy_z_imag, psi_hy_x_imag = _update_magnetic_component(
3064
+ state["Hy_imag"],
3065
+ d_pos=d_ex_imag_dz,
3066
+ d_neg=d_ez_imag_dx,
3067
+ decay=solver.chy_decay,
3068
+ curl=solver.chy_curl,
3069
+ psi_pos=state.get("psi_hy_z_imag") if getattr(solver, "uses_cpml", False) else None,
3070
+ psi_neg=state.get("psi_hy_x_imag") if getattr(solver, "uses_cpml", False) else None,
3071
+ b_pos=getattr(solver, "cpml_b_h_z", None),
3072
+ c_pos=getattr(solver, "cpml_c_h_z", None),
3073
+ inv_kappa_pos=getattr(solver, "cpml_inv_kappa_h_z", None),
3074
+ b_neg=getattr(solver, "cpml_b_h_x", None),
3075
+ c_neg=getattr(solver, "cpml_c_h_x", None),
3076
+ inv_kappa_neg=getattr(solver, "cpml_inv_kappa_h_x", None),
3077
+ axis_pos=2,
3078
+ axis_neg=0,
3079
+ )
3080
+
3081
+ d_ey_imag_dx = _forward_diff(state["Ey_imag"], axis=0, inv_delta=solver.inv_dx_h)
3082
+ d_ex_imag_dy = _forward_diff(state["Ex_imag"], axis=1, inv_delta=solver.inv_dy_h)
3083
+ hz_imag, psi_hz_x_imag, psi_hz_y_imag = _update_magnetic_component(
3084
+ state["Hz_imag"],
3085
+ d_pos=d_ey_imag_dx,
3086
+ d_neg=d_ex_imag_dy,
3087
+ decay=solver.chz_decay,
3088
+ curl=solver.chz_curl,
3089
+ psi_pos=state.get("psi_hz_x_imag") if getattr(solver, "uses_cpml", False) else None,
3090
+ psi_neg=state.get("psi_hz_y_imag") if getattr(solver, "uses_cpml", False) else None,
3091
+ b_pos=getattr(solver, "cpml_b_h_x", None),
3092
+ c_pos=getattr(solver, "cpml_c_h_x", None),
3093
+ inv_kappa_pos=getattr(solver, "cpml_inv_kappa_h_x", None),
3094
+ b_neg=getattr(solver, "cpml_b_h_y", None),
3095
+ c_neg=getattr(solver, "cpml_c_h_y", None),
3096
+ inv_kappa_neg=getattr(solver, "cpml_inv_kappa_h_y", None),
3097
+ axis_pos=0,
3098
+ axis_neg=1,
3099
+ )
3100
+ magnetic_fields.update(
3101
+ {
3102
+ "Hx_imag": hx_imag,
3103
+ "Hy_imag": hy_imag,
3104
+ "Hz_imag": hz_imag,
3105
+ }
3106
+ )
3107
+
3108
+ if getattr(solver, "tfsf_enabled", False):
3109
+ magnetic_fields = _apply_tfsf_terms(
3110
+ magnetic_fields,
3111
+ solver=solver,
3112
+ auxiliary_state=auxiliary_state,
3113
+ term_key="magnetic_terms",
3114
+ sample_kind="electric",
3115
+ time_value=time_value,
3116
+ )
3117
+ auxiliary_state = _advance_tfsf_auxiliary_magnetic_state(solver, auxiliary_state)
3118
+ else:
3119
+ magnetic_fields = _apply_source_term_list(
3120
+ magnetic_fields,
3121
+ terms=magnetic_source_terms,
3122
+ source_time=solver._source_time,
3123
+ omega=solver.source_omega,
3124
+ time_value=time_value,
3125
+ solver=None,
3126
+ )
3127
+ # Magnetic ADE state advances from the pre-step H; its correction lands on
3128
+ # the post-source H (before the electric update consumes it), matching the
3129
+ # forward per-step kernel order.
3130
+ magnetic_dispersive_state = _advance_magnetic_dispersive_state(solver, state)
3131
+ if magnetic_dispersive_state:
3132
+ magnetic_fields = _apply_magnetic_dispersive_corrections(
3133
+ solver,
3134
+ magnetic_fields,
3135
+ magnetic_dispersive_state,
3136
+ )
3137
+ dispersive_state = _advance_dispersive_state(solver, state)
3138
+
3139
+ if capture_magnetic is not None:
3140
+ # Post-magnetic, post-source real H that the electric update consumes this
3141
+ # step: the checkpoint replay hands it to the reverse reference backend so
3142
+ # it does not recompute the magnetic half-step (see ``_forward_magnetic_fields``).
3143
+ # The replay only requests capture for the pure real standard/CPML path
3144
+ # (see ``_replay_can_capture_mid_magnetic``), where this equals the reverse
3145
+ # recompute bit-for-bit; the tensors are never mutated after this point.
3146
+ capture_magnetic.append({name: tensor.detach() for name, tensor in magnetic_fields.items()})
3147
+
3148
+ if complex_fields:
3149
+ ex_curl = _dynamic_electric_curl(solver.cex_curl, solver.eps_Ex, eps_ex)
3150
+ ey_curl = _dynamic_electric_curl(solver.cey_curl, solver.eps_Ey, eps_ey)
3151
+ ez_curl = _dynamic_electric_curl(solver.cez_curl, solver.eps_Ez, eps_ez)
3152
+ if getattr(solver, "uses_cpml", False):
3153
+ from ..runtime.stepping import _bloch_cpml_pml_axis
3154
+
3155
+ pml_axis = _bloch_cpml_pml_axis(solver)
3156
+ if pml_axis is None:
3157
+ raise NotImplementedError(
3158
+ "Complex-field CPML adjoint replay requires exactly one PML axis and two Bloch axes: "
3159
+ "the reverse split-field update carries the transverse wrap phase on the two periodic "
3160
+ "axes and the recursive-convolution stretch on the single absorbing axis, so a face "
3161
+ "layout mixing Bloch and PML on one axis or leaving more than one absorbing axis is "
3162
+ "not expressible on that split."
3163
+ )
3164
+ if pml_axis == "z":
3165
+ electric_fields, electric_cpml_state = _update_mixed_bloch_cpml_electric_fields(
3166
+ solver,
3167
+ state,
3168
+ magnetic_fields,
3169
+ ex_curl=ex_curl,
3170
+ ey_curl=ey_curl,
3171
+ ez_curl=ez_curl,
3172
+ )
3173
+ else:
3174
+ electric_fields, electric_cpml_state = _update_general_bloch_cpml_electric_fields(
3175
+ solver,
3176
+ state,
3177
+ magnetic_fields,
3178
+ pml_axis=pml_axis,
3179
+ ex_curl=ex_curl,
3180
+ ey_curl=ey_curl,
3181
+ ez_curl=ez_curl,
3182
+ )
3183
+ psi_ex_y = electric_cpml_state["psi_ex_y"]
3184
+ psi_ex_z = electric_cpml_state["psi_ex_z"]
3185
+ psi_ey_x = electric_cpml_state["psi_ey_x"]
3186
+ psi_ey_z = electric_cpml_state["psi_ey_z"]
3187
+ psi_ez_x = electric_cpml_state["psi_ez_x"]
3188
+ psi_ez_y = electric_cpml_state["psi_ez_y"]
3189
+ psi_ex_y_imag = electric_cpml_state["psi_ex_y_imag"]
3190
+ psi_ex_z_imag = electric_cpml_state["psi_ex_z_imag"]
3191
+ psi_ey_x_imag = electric_cpml_state["psi_ey_x_imag"]
3192
+ psi_ey_z_imag = electric_cpml_state["psi_ey_z_imag"]
3193
+ psi_ez_x_imag = electric_cpml_state["psi_ez_x_imag"]
3194
+ psi_ez_y_imag = electric_cpml_state["psi_ez_y_imag"]
3195
+ else:
3196
+ hy_complex = torch.complex(magnetic_fields["Hy"], magnetic_fields["Hy_imag"])
3197
+ hz_complex = torch.complex(magnetic_fields["Hz"], magnetic_fields["Hz_imag"])
3198
+ d_hz_dy = _bloch_backward_diff(
3199
+ hz_complex,
3200
+ axis=1,
3201
+ inv_delta=solver.inv_dy_e,
3202
+ phase_cos=float(solver.boundary_phase_cos[1]),
3203
+ phase_sin=float(solver.boundary_phase_sin[1]),
3204
+ )
3205
+ d_hy_dz = _bloch_backward_diff(
3206
+ hy_complex,
3207
+ axis=2,
3208
+ inv_delta=solver.inv_dz_e,
3209
+ phase_cos=float(solver.boundary_phase_cos[2]),
3210
+ phase_sin=float(solver.boundary_phase_sin[2]),
3211
+ )
3212
+ ex_complex = torch.complex(state["Ex"], state["Ex_imag"]) * solver.cex_decay + ex_curl * (d_hz_dy - d_hy_dz)
3213
+
3214
+ hx_complex = torch.complex(magnetic_fields["Hx"], magnetic_fields["Hx_imag"])
3215
+ d_hx_dz = _bloch_backward_diff(
3216
+ hx_complex,
3217
+ axis=2,
3218
+ inv_delta=solver.inv_dz_e,
3219
+ phase_cos=float(solver.boundary_phase_cos[2]),
3220
+ phase_sin=float(solver.boundary_phase_sin[2]),
3221
+ )
3222
+ d_hz_dx = _bloch_backward_diff(
3223
+ hz_complex,
3224
+ axis=0,
3225
+ inv_delta=solver.inv_dx_e,
3226
+ phase_cos=float(solver.boundary_phase_cos[0]),
3227
+ phase_sin=float(solver.boundary_phase_sin[0]),
3228
+ )
3229
+ ey_complex = torch.complex(state["Ey"], state["Ey_imag"]) * solver.cey_decay + ey_curl * (d_hx_dz - d_hz_dx)
3230
+
3231
+ d_hy_dx = _bloch_backward_diff(
3232
+ hy_complex,
3233
+ axis=0,
3234
+ inv_delta=solver.inv_dx_e,
3235
+ phase_cos=float(solver.boundary_phase_cos[0]),
3236
+ phase_sin=float(solver.boundary_phase_sin[0]),
3237
+ )
3238
+ d_hx_dy = _bloch_backward_diff(
3239
+ hx_complex,
3240
+ axis=1,
3241
+ inv_delta=solver.inv_dy_e,
3242
+ phase_cos=float(solver.boundary_phase_cos[1]),
3243
+ phase_sin=float(solver.boundary_phase_sin[1]),
3244
+ )
3245
+ ez_complex = torch.complex(state["Ez"], state["Ez_imag"]) * solver.cez_decay + ez_curl * (d_hy_dx - d_hx_dy)
3246
+
3247
+ electric_fields = {
3248
+ "Ex": ex_complex.real,
3249
+ "Ey": ey_complex.real,
3250
+ "Ez": ez_complex.real,
3251
+ "Ex_imag": ex_complex.imag,
3252
+ "Ey_imag": ey_complex.imag,
3253
+ "Ez_imag": ez_complex.imag,
3254
+ }
3255
+ psi_ex_y = psi_ex_z = psi_ey_x = psi_ey_z = psi_ez_x = psi_ez_y = None
3256
+ else:
3257
+ general_coeffs = None
3258
+ kerr_curls = None
3259
+ conductive_coeffs = None
3260
+ if getattr(solver, "nonlinear_general_enabled", False):
3261
+ # chi2 / two-photon absorption (and any static conduction) rewrite
3262
+ # BOTH the decay and curl coefficients from the pre-update E fields
3263
+ # each step; replicate the general coefficient kernel differentiably
3264
+ # so the VJP flows into the fields, eps, chi2, chi3, and the TPA
3265
+ # conductivity. This branch matches the forward dispatch order, which
3266
+ # prefers the general kernel over the curl-only Kerr kernel when a
3267
+ # chi2 or TPA channel is present.
3268
+ general_coeffs = _general_nonlinear_electric_coefficients(
3269
+ solver,
3270
+ state,
3271
+ eps_ex=eps_ex,
3272
+ eps_ey=eps_ey,
3273
+ eps_ez=eps_ez,
3274
+ chi2_ex=chi2_ex if chi2_ex is not None else solver.nonlinear_chi2_Ex,
3275
+ chi2_ey=chi2_ey if chi2_ey is not None else solver.nonlinear_chi2_Ey,
3276
+ chi2_ez=chi2_ez if chi2_ez is not None else solver.nonlinear_chi2_Ez,
3277
+ chi3_ex=chi3_ex if chi3_ex is not None else solver.kerr_chi3_Ex,
3278
+ chi3_ey=chi3_ey if chi3_ey is not None else solver.kerr_chi3_Ey,
3279
+ chi3_ez=chi3_ez if chi3_ez is not None else solver.kerr_chi3_Ez,
3280
+ tpa_ex=tpa_ex if tpa_ex is not None else solver.tpa_sigma_Ex,
3281
+ tpa_ey=tpa_ey if tpa_ey is not None else solver.tpa_sigma_Ey,
3282
+ tpa_ez=tpa_ez if tpa_ez is not None else solver.tpa_sigma_Ez,
3283
+ )
3284
+ elif getattr(solver, "kerr_enabled", False):
3285
+ # The Kerr coefficient kernel recomputes the curl factor from the
3286
+ # pre-update E fields each step; replicate it differentiably so the
3287
+ # VJP flows into the fields, eps, and chi3.
3288
+ kerr_curls = _kerr_dynamic_electric_curls(
3289
+ solver,
3290
+ state,
3291
+ eps_ex=eps_ex,
3292
+ eps_ey=eps_ey,
3293
+ eps_ez=eps_ez,
3294
+ chi3_ex=chi3_ex if chi3_ex is not None else solver.kerr_chi3_Ex,
3295
+ chi3_ey=chi3_ey if chi3_ey is not None else solver.kerr_chi3_Ey,
3296
+ chi3_ez=chi3_ez if chi3_ez is not None else solver.kerr_chi3_Ez,
3297
+ )
3298
+ elif getattr(solver, "conductive_enabled", False):
3299
+ # Static conduction makes both decay and curl eps-dependent through
3300
+ # the semi-implicit denominator; recompute them differentiably so the
3301
+ # VJP carries that dependence (the linear rule drops it).
3302
+ conductive_coeffs = _conductive_electric_coefficients(
3303
+ solver, eps_ex=eps_ex, eps_ey=eps_ey, eps_ez=eps_ez
3304
+ )
3305
+ if general_coeffs is not None:
3306
+ ex_decay_c, ex_curl_c, ex_eps_c = general_coeffs["Ex"][0], general_coeffs["Ex"][1], 1.0
3307
+ ey_decay_c, ey_curl_c, ey_eps_c = general_coeffs["Ey"][0], general_coeffs["Ey"][1], 1.0
3308
+ ez_decay_c, ez_curl_c, ez_eps_c = general_coeffs["Ez"][0], general_coeffs["Ez"][1], 1.0
3309
+ elif kerr_curls is not None:
3310
+ ex_decay_c, ex_curl_c, ex_eps_c = solver.cex_decay, kerr_curls["Ex"], 1.0
3311
+ ey_decay_c, ey_curl_c, ey_eps_c = solver.cey_decay, kerr_curls["Ey"], 1.0
3312
+ ez_decay_c, ez_curl_c, ez_eps_c = solver.cez_decay, kerr_curls["Ez"], 1.0
3313
+ elif conductive_coeffs is not None:
3314
+ ex_decay_c, ex_curl_c, ex_eps_c = conductive_coeffs["Ex"][0], conductive_coeffs["Ex"][1], 1.0
3315
+ ey_decay_c, ey_curl_c, ey_eps_c = conductive_coeffs["Ey"][0], conductive_coeffs["Ey"][1], 1.0
3316
+ ez_decay_c, ez_curl_c, ez_eps_c = conductive_coeffs["Ez"][0], conductive_coeffs["Ez"][1], 1.0
3317
+ else:
3318
+ ex_decay_c, ex_curl_c, ex_eps_c = solver.cex_decay, solver.cex_curl * solver.eps_Ex, eps_ex
3319
+ ey_decay_c, ey_curl_c, ey_eps_c = solver.cey_decay, solver.cey_curl * solver.eps_Ey, eps_ey
3320
+ ez_decay_c, ez_curl_c, ez_eps_c = solver.cez_decay, solver.cez_curl * solver.eps_Ez, eps_ez
3321
+ d_hz_dy = _backward_diff(magnetic_fields["Hz"], axis=1, inv_delta=solver.inv_dy_e)
3322
+ d_hy_dz = _backward_diff(magnetic_fields["Hy"], axis=2, inv_delta=solver.inv_dz_e)
3323
+ ex, psi_ex_y, psi_ex_z = _update_electric_component(
3324
+ state["Ex"],
3325
+ d_pos=d_hz_dy,
3326
+ d_neg=d_hy_dz,
3327
+ decay=ex_decay_c,
3328
+ curl_prefactor=ex_curl_c,
3329
+ eps=ex_eps_c,
3330
+ low_mode_pos=solver.boundary_y_low_code,
3331
+ high_mode_pos=solver.boundary_y_high_code,
3332
+ low_mode_neg=solver.boundary_z_low_code,
3333
+ high_mode_neg=solver.boundary_z_high_code,
3334
+ axis_pos=1,
3335
+ axis_neg=2,
3336
+ psi_pos=state.get("psi_ex_y"),
3337
+ psi_neg=state.get("psi_ex_z"),
3338
+ b_pos=getattr(solver, "cpml_b_e_y", None),
3339
+ c_pos=getattr(solver, "cpml_c_e_y", None),
3340
+ inv_kappa_pos=getattr(solver, "cpml_inv_kappa_e_y", None),
3341
+ b_neg=getattr(solver, "cpml_b_e_z", None),
3342
+ c_neg=getattr(solver, "cpml_c_e_z", None),
3343
+ inv_kappa_neg=getattr(solver, "cpml_inv_kappa_e_z", None),
3344
+ )
3345
+
3346
+ d_hx_dz = _backward_diff(magnetic_fields["Hx"], axis=2, inv_delta=solver.inv_dz_e)
3347
+ d_hz_dx = _backward_diff(magnetic_fields["Hz"], axis=0, inv_delta=solver.inv_dx_e)
3348
+ ey, psi_ey_x, psi_ey_z = _update_electric_component(
3349
+ state["Ey"],
3350
+ d_pos=d_hx_dz,
3351
+ d_neg=d_hz_dx,
3352
+ decay=ey_decay_c,
3353
+ curl_prefactor=ey_curl_c,
3354
+ eps=ey_eps_c,
3355
+ low_mode_pos=solver.boundary_z_low_code,
3356
+ high_mode_pos=solver.boundary_z_high_code,
3357
+ low_mode_neg=solver.boundary_x_low_code,
3358
+ high_mode_neg=solver.boundary_x_high_code,
3359
+ axis_pos=2,
3360
+ axis_neg=0,
3361
+ psi_pos=state.get("psi_ey_z"),
3362
+ psi_neg=state.get("psi_ey_x"),
3363
+ b_pos=getattr(solver, "cpml_b_e_z", None),
3364
+ c_pos=getattr(solver, "cpml_c_e_z", None),
3365
+ inv_kappa_pos=getattr(solver, "cpml_inv_kappa_e_z", None),
3366
+ b_neg=getattr(solver, "cpml_b_e_x", None),
3367
+ c_neg=getattr(solver, "cpml_c_e_x", None),
3368
+ inv_kappa_neg=getattr(solver, "cpml_inv_kappa_e_x", None),
3369
+ )
3370
+
3371
+ d_hy_dx = _backward_diff(magnetic_fields["Hy"], axis=0, inv_delta=solver.inv_dx_e)
3372
+ d_hx_dy = _backward_diff(magnetic_fields["Hx"], axis=1, inv_delta=solver.inv_dy_e)
3373
+ ez, psi_ez_x, psi_ez_y = _update_electric_component(
3374
+ state["Ez"],
3375
+ d_pos=d_hy_dx,
3376
+ d_neg=d_hx_dy,
3377
+ decay=ez_decay_c,
3378
+ curl_prefactor=ez_curl_c,
3379
+ eps=ez_eps_c,
3380
+ low_mode_pos=solver.boundary_x_low_code,
3381
+ high_mode_pos=solver.boundary_x_high_code,
3382
+ low_mode_neg=solver.boundary_y_low_code,
3383
+ high_mode_neg=solver.boundary_y_high_code,
3384
+ axis_pos=0,
3385
+ axis_neg=1,
3386
+ psi_pos=state.get("psi_ez_x"),
3387
+ psi_neg=state.get("psi_ez_y"),
3388
+ b_pos=getattr(solver, "cpml_b_e_x", None),
3389
+ c_pos=getattr(solver, "cpml_c_e_x", None),
3390
+ inv_kappa_pos=getattr(solver, "cpml_inv_kappa_e_x", None),
3391
+ b_neg=getattr(solver, "cpml_b_e_y", None),
3392
+ c_neg=getattr(solver, "cpml_c_e_y", None),
3393
+ inv_kappa_neg=getattr(solver, "cpml_inv_kappa_e_y", None),
3394
+ )
3395
+ if getattr(solver, "full_aniso_enabled", False):
3396
+ # Full (off-diagonal) anisotropy couples each E component to the two
3397
+ # off-axis curl(H) components; the diagonal entry is already carried
3398
+ # by the base curl above, so add the neighbor-averaged off-diagonal
3399
+ # correction here to match the forward per-step kernel order.
3400
+ aniso_correction = _full_aniso_electric_correction(solver, magnetic_fields)
3401
+ ex = ex + aniso_correction["Ex"]
3402
+ ey = ey + aniso_correction["Ey"]
3403
+ ez = ez + aniso_correction["Ez"]
3404
+ electric_fields = {"Ex": ex, "Ey": ey, "Ez": ez}
3405
+
3406
+ if getattr(solver, "tfsf_enabled", False):
3407
+ electric_fields = _apply_tfsf_terms(
3408
+ electric_fields,
3409
+ solver=solver,
3410
+ auxiliary_state=auxiliary_state,
3411
+ term_key="electric_terms",
3412
+ sample_kind="magnetic",
3413
+ time_value=time_value,
3414
+ )
3415
+ auxiliary_state = _advance_tfsf_auxiliary_electric_state(
3416
+ solver,
3417
+ auxiliary_state,
3418
+ time_value=time_value,
3419
+ )
3420
+ else:
3421
+ electric_fields = _apply_source_term_list(
3422
+ electric_fields,
3423
+ terms=electric_source_terms,
3424
+ source_time=solver._source_time,
3425
+ omega=solver.source_omega,
3426
+ time_value=time_value,
3427
+ solver=None,
3428
+ )
3429
+ electric_fields = _apply_source_term_list(
3430
+ electric_fields,
3431
+ terms=source_terms,
3432
+ source_time=solver._source_time,
3433
+ omega=solver.source_omega,
3434
+ time_value=time_value,
3435
+ solver=solver if complex_fields else None,
3436
+ )
3437
+
3438
+ dispersive_input_fields = {name: electric_fields[name] for name in ("Ex", "Ey", "Ez")}
3439
+ if complex_fields:
3440
+ # Bloch dispersive replay corrects the imaginary field with its own
3441
+ # polarization current; hand both halves to the shared correction.
3442
+ dispersive_input_fields.update(
3443
+ {name: electric_fields[name] for name in ("Ex_imag", "Ey_imag", "Ez_imag")}
3444
+ )
3445
+ corrected_electric_fields = _apply_dispersive_corrections(
3446
+ solver,
3447
+ dispersive_input_fields,
3448
+ dispersive_state,
3449
+ eps_ex=eps_ex,
3450
+ eps_ey=eps_ey,
3451
+ eps_ez=eps_ez,
3452
+ )
3453
+ electric_fields.update(corrected_electric_fields)
3454
+ if not getattr(solver, "tfsf_enabled", False) and getattr(solver, "has_pec_faces", False):
3455
+ real_electric_fields = _enforce_pec_boundaries(
3456
+ solver,
3457
+ {name: electric_fields[name] for name in ("Ex", "Ey", "Ez")},
3458
+ )
3459
+ electric_fields.update(real_electric_fields)
3460
+
3461
+ next_state = {
3462
+ "Ex": electric_fields["Ex"],
3463
+ "Ey": electric_fields["Ey"],
3464
+ "Ez": electric_fields["Ez"],
3465
+ "Hx": magnetic_fields["Hx"],
3466
+ "Hy": magnetic_fields["Hy"],
3467
+ "Hz": magnetic_fields["Hz"],
3468
+ }
3469
+ if complex_fields:
3470
+ next_state.update(
3471
+ {
3472
+ "Ex_imag": electric_fields["Ex_imag"],
3473
+ "Ey_imag": electric_fields["Ey_imag"],
3474
+ "Ez_imag": electric_fields["Ez_imag"],
3475
+ "Hx_imag": magnetic_fields["Hx_imag"],
3476
+ "Hy_imag": magnetic_fields["Hy_imag"],
3477
+ "Hz_imag": magnetic_fields["Hz_imag"],
3478
+ }
3479
+ )
3480
+ if solver.uses_cpml:
3481
+ next_state.update(
3482
+ {
3483
+ "psi_ex_y": psi_ex_y,
3484
+ "psi_ex_z": psi_ex_z,
3485
+ "psi_ey_x": psi_ey_x,
3486
+ "psi_ey_z": psi_ey_z,
3487
+ "psi_ez_x": psi_ez_x,
3488
+ "psi_ez_y": psi_ez_y,
3489
+ "psi_hx_y": psi_hx_y,
3490
+ "psi_hx_z": psi_hx_z,
3491
+ "psi_hy_x": psi_hy_x,
3492
+ "psi_hy_z": psi_hy_z,
3493
+ "psi_hz_x": psi_hz_x,
3494
+ "psi_hz_y": psi_hz_y,
3495
+ }
3496
+ )
3497
+ if complex_fields:
3498
+ next_state.update(
3499
+ {
3500
+ "psi_ex_y_imag": psi_ex_y_imag,
3501
+ "psi_ex_z_imag": psi_ex_z_imag,
3502
+ "psi_ey_x_imag": psi_ey_x_imag,
3503
+ "psi_ey_z_imag": psi_ey_z_imag,
3504
+ "psi_ez_x_imag": psi_ez_x_imag,
3505
+ "psi_ez_y_imag": psi_ez_y_imag,
3506
+ "psi_hx_y_imag": psi_hx_y_imag,
3507
+ "psi_hx_z_imag": psi_hx_z_imag,
3508
+ "psi_hy_x_imag": psi_hy_x_imag,
3509
+ "psi_hy_z_imag": psi_hy_z_imag,
3510
+ "psi_hz_x_imag": psi_hz_x_imag,
3511
+ "psi_hz_y_imag": psi_hz_y_imag,
3512
+ }
3513
+ )
3514
+ if auxiliary_state is not None:
3515
+ next_state["tfsf_aux_electric"] = auxiliary_state["electric"]
3516
+ next_state["tfsf_aux_magnetic"] = auxiliary_state["magnetic"]
3517
+ next_state.update(dispersive_state)
3518
+ next_state.update(magnetic_dispersive_state)
3519
+ return next_state
3520
+
3521
+
3522
+ def _build_spectral_weight_schedule(entries, *, time_steps, window_type):
3523
+ schedules = []
3524
+ for entry in entries:
3525
+ phase_cos = 1.0
3526
+ phase_sin = 0.0
3527
+ start_step = int(entry["start_step"])
3528
+ end_step = None if entry["end_step"] is None else int(entry["end_step"])
3529
+ weights = []
3530
+ for n in range(time_steps):
3531
+ if n < start_step or (end_step is not None and n >= end_step):
3532
+ weights.append((0.0, 0.0))
3533
+ else:
3534
+ if window_type == "none" or start_step is None or end_step is None:
3535
+ window_weight = 1.0
3536
+ else:
3537
+ total_samples = max(end_step - start_step, 1)
3538
+ position = (n - start_step) / total_samples
3539
+ if window_type == "hanning":
3540
+ window_weight = 0.5 * (1.0 - math.cos(2.0 * math.pi * position))
3541
+ elif window_type == "ramp":
3542
+ ramp_fraction = 0.1
3543
+ if position < ramp_fraction:
3544
+ window_weight = 0.5 * (1.0 - math.cos(math.pi * position / ramp_fraction))
3545
+ else:
3546
+ window_weight = 1.0
3547
+ else:
3548
+ window_weight = 1.0
3549
+ weights.append((window_weight * phase_cos, window_weight * phase_sin))
3550
+ next_cos = phase_cos * entry["phase_step_cos"] - phase_sin * entry["phase_step_sin"]
3551
+ next_sin = phase_sin * entry["phase_step_cos"] + phase_cos * entry["phase_step_sin"]
3552
+ phase_cos = next_cos
3553
+ phase_sin = next_sin
3554
+ schedules.append(tuple(weights))
3555
+ return tuple(schedules)
3556
+
3557
+
3558
+ def _replay_can_capture_mid_magnetic(solver) -> bool:
3559
+ """Whether the replay may hand its mid-step H to the reverse reference backend.
3560
+
3561
+ True only for the pure real standard / CPML configuration whose magnetic
3562
+ half-step is a plain ``_update_magnetic_component`` (no TFSF injection, no
3563
+ magnetic ADE correction, no magnetic surface source, no complex split field,
3564
+ no electric dispersion / nonlinear / conductive / full-anisotropic coupling).
3565
+ In that regime the H captured on the way to the electric update equals the
3566
+ reverse backend's ``_forward_magnetic_fields`` recompute bit-for-bit, and the
3567
+ per-step magnetic update carries no dependence on the (leaf) permittivity, so
3568
+ threading the captured tensor changes nothing numerically while removing one
3569
+ torch magnetic half-step per reverse step.
3570
+ """
3571
+ if has_complex_fields(solver):
3572
+ return False
3573
+ if getattr(solver, "tfsf_enabled", False):
3574
+ return False
3575
+ if getattr(solver, "dispersive_enabled", False):
3576
+ return False
3577
+ if getattr(solver, "magnetic_dispersive_enabled", False):
3578
+ return False
3579
+ if getattr(solver, "nonlinear_enabled", False):
3580
+ return False
3581
+ if getattr(solver, "conductive_enabled", False):
3582
+ return False
3583
+ if getattr(solver, "full_aniso_enabled", False):
3584
+ return False
3585
+ if getattr(solver, "_magnetic_source_terms", ()):
3586
+ return False
3587
+ return True
3588
+
3589
+
3590
+ def _replay_segment_states(solver, checkpoint, start_step, end_step, *, mid_magnetic_out=None):
3591
+ """Replay the forward field states of one checkpoint segment.
3592
+
3593
+ ``mid_magnetic_out``, when a list, collects the post-magnetic real H of each
3594
+ replayed step (one entry per step, aligned with ``states[offset]``) so the
3595
+ reverse pass can consume the mid-step H instead of recomputing it. It is only
3596
+ populated for configurations ``_replay_can_capture_mid_magnetic`` accepts.
3597
+ """
3598
+ validate_checkpoint_state(checkpoint)
3599
+ capture = mid_magnetic_out is not None and _replay_can_capture_mid_magnetic(solver)
3600
+ with torch.no_grad():
3601
+ current = clone_checkpoint_tensors(checkpoint)
3602
+ states = [current]
3603
+ for step_index in range(start_step, end_step):
3604
+ current = _step_state(
3605
+ solver,
3606
+ current,
3607
+ time_value=step_index * solver.dt,
3608
+ eps_ex=solver.eps_Ex,
3609
+ eps_ey=solver.eps_Ey,
3610
+ eps_ez=solver.eps_Ez,
3611
+ capture_magnetic=mid_magnetic_out if capture else None,
3612
+ )
3613
+ states.append({name: tensor.detach() for name, tensor in current.items()})
3614
+ return states