[docs] expand API and derivation docs

This commit is contained in:
Jan Petykiewicz 2026-04-18 14:24:18 -07:00
commit 5e95d66a7e
12 changed files with 608 additions and 127 deletions

View file

@ -1,7 +1,12 @@
"""
Example code for running an FDTD simulation
Example code for a broadband FDTD run with phasor extraction.
See main() for simulation setup.
This script shows the intended low-level workflow for:
1. building a Yee-grid simulation with CPML on all faces,
2. driving it with an electric-current pulse,
3. extracting a single-frequency phasor on the fly, and
4. checking that phasor against the matching stretched-grid FDFD operator.
"""
import sys
@ -150,7 +155,8 @@ def main():
# print(f'Save time interval would be {sample_interval} * dt = {sample_interval * dt:3g}')
# Source parameters and function
# Source parameters and function. The pulse normalization is kept outside
# accumulate_phasor(); the helper only performs the Fourier sum.
source_phasor, delay = gaussian_packet(wl=wl, dwl=100, dt=dt, turn_on=1e-5)
aa, cc, ss = source_phasor(numpy.arange(max_t))
srca_real = aa * cc
@ -170,7 +176,8 @@ def main():
update_E(ee, hh, epsilon)
if tt < src_maxt:
# This codebase uses E -= dt * J / epsilon for electric-current injection.
# Electric-current injection uses E -= dt * J / epsilon, which is
# the same sign convention used by the matching FDFD right-hand side.
ee[1, *(grid.shape // 2)] -= srca_real[tt]
update_H(ee, hh)
@ -193,9 +200,11 @@ def main():
dt,
ee,
tt,
# The pulse is delayed relative to t=0, so the readout needs the same phase shift.
# The pulse is delayed relative to t=0, so the extracted phasor
# needs the same phase offset in its sample times.
offset_steps=0.5 - delay / dt,
# accumulate_phasor() already includes dt, so undo the dt in phasor_norm here.
# accumulate_phasor() already multiplies by dt, so pass the
# discrete-sum normalization without its extra dt factor.
weight=phasor_norm / dt,
)
@ -205,6 +214,8 @@ def main():
for pp in (-1, +1):
for dd in range(3):
stretch_with_scpml(dxes_fdfd, axis=dd, polarity=pp, omega=omega, epsilon_effective=n_air ** 2, thickness=pml_thickness)
# Compare the extracted phasor to the FDFD operator on the stretched grid,
# not the unstretched Yee spacings used by the raw time-domain update.
A = e_full(omega=omega, dxes=dxes_fdfd, epsilon=epsilon)
residual = norm(A @ vec(Eph) - vec(b)) / norm(vec(b))
print(f'FDFD residual is {residual}')

View file

@ -1,7 +1,11 @@
"""
Example code for running an OpenCL FDTD simulation
Example code for guided-wave FDFD and FDTD comparison.
See main() for simulation setup.
This example is the reference workflow for:
1. solving a waveguide port mode,
2. turning that mode into a one-sided source and overlap window,
3. comparing a direct FDFD solve against a time-domain phasor extracted from FDTD.
"""
from typing import Callable
import logging
@ -78,7 +82,7 @@ def get_waveguide_mode(
omega: float,
epsilon: fdfield_t,
) -> tuple[vcfdfield_t, vcfdfield_t]:
""" Create a mode source and overlap window """
"""Create a mode source and overlap window for one forward-going port."""
dims = numpy.array([[-10, -20, -15],
[-10, 20, 15]]) * [[numpy.median(numpy.real(dx)) for dx in dxes[0]]]
ind_dims = (grid.pos2ind(dims[0], which_shifts=None).astype(int),
@ -94,6 +98,8 @@ def get_waveguide_mode(
J = waveguide_3d.compute_source(E=wg_results['E'], wavenumber=wg_results['wavenumber'],
omega=omega, epsilon=epsilon, **wg_args)
# compute_overlap_e() returns the normalized upstream overlap window used to
# project another field onto this same guided mode.
e_overlap = waveguide_3d.compute_overlap_e(E=wg_results['E'], wavenumber=wg_results['wavenumber'], **wg_args, omega=omega)
return J, e_overlap
@ -111,7 +117,8 @@ def main(
grid, epsilon = draw_grid(dx=dx, pml_thickness=pml_thickness)
# Add PML
# Add SCPML stretching to the FDFD grid; this matches the CPML-backed FDTD
# run below so the two solvers see the same absorbing boundary profile.
dxes = [grid.dxyz, grid.autoshifted_dxyz()]
for a in (0, 1, 2):
for p in (-1, 1):
@ -275,7 +282,8 @@ def main2():
# print(f'Save time interval would be {sample_interval} * dt = {sample_interval * dt:3g}')
# Source parameters and function
# Source parameters and function. The phasor helper only performs the
# Fourier accumulation; the pulse normalization stays explicit here.
source_phasor, delay = gaussian_packet(wl=wl, dwl=100, dt=dt, turn_on=1e-5)
aa, cc, ss = source_phasor(numpy.arange(max_t))
srca_real = aa * cc
@ -295,7 +303,8 @@ def main2():
update_E(ee, hh, epsilon)
if tt < src_maxt:
# This codebase uses E -= dt * J / epsilon for electric-current injection.
# Electric-current injection uses E -= dt * J / epsilon, which is
# the sign convention matched by the FDFD source term -1j * omega * J.
ee[1, *(grid.shape // 2)] -= srca_real[tt]
update_H(ee, hh)
@ -317,9 +326,11 @@ def main2():
dt,
ee,
tt,
# The pulse is delayed relative to t=0, so the readout needs the same phase shift.
# The pulse is delayed relative to t=0, so the extracted phasor must
# apply the same delay to its sample times.
offset_steps=0.5 - delay / dt,
# accumulate_phasor() already includes dt, so undo the dt in phasor_norm here.
# accumulate_phasor() already contributes dt, so remove the extra dt
# from the externally computed normalization.
weight=phasor_norm / dt,
)
@ -329,6 +340,8 @@ def main2():
for pp in (-1, +1):
for dd in range(3):
stretch_with_scpml(dxes_fdfd, axis=dd, polarity=pp, omega=omega, epsilon_effective=n_cladding ** 2, thickness=pml_thickness)
# Residuals must be checked on the stretched FDFD grid, because the FDTD run
# already includes those same absorbing layers through CPML.
A = e_full(omega=omega, dxes=dxes_fdfd, epsilon=epsilon)
residual = norm(A @ vec(Eph) - vec(b)) / norm(vec(b))
print(f'FDFD residual is {residual}')