[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

@ -94,6 +94,59 @@ python3 -m pytest -rsxX | tee test_results.txt
## Use ## Use
See `examples/` for some simple examples; you may need additional `meanas` is organized around a few core workflows:
packages such as [gridlock](https://mpxd.net/code/jan/gridlock)
to run the examples. - `meanas.fdfd`: frequency-domain wave equations, sparse operators, SCPML, and
iterative solves for driven problems.
- `meanas.fdfd.waveguide_2d` / `meanas.fdfd.waveguide_3d`: waveguide mode
solvers, mode-source construction, and overlap windows for port-based
excitation and analysis.
- `meanas.fdtd`: Yee-step updates, CPML boundaries, flux/energy accounting, and
on-the-fly phasor extraction for comparing time-domain runs against FDFD.
- `meanas.fdmath`: low-level finite-difference operators, vectorization helpers,
and derivations shared by the FDTD and FDFD layers.
The most mature user-facing workflows are:
1. Build an FDFD operator or waveguide port source, then solve a driven
frequency-domain problem.
2. Run an FDTD simulation, extract one or more frequency-domain phasors with
`meanas.fdtd.accumulate_phasor(...)`, and compare those phasors against an
FDFD reference on the same Yee grid.
Tracked examples under `examples/` are the intended starting points:
- `examples/fdtd.py`: broadband FDTD pulse excitation, phasor extraction, and a
residual check against the matching FDFD operator.
- `examples/waveguide.py`: waveguide mode solving, unidirectional mode-source
construction, overlap readout, and FDTD/FDFD comparison on a guided structure.
- `examples/fdfd.py`: direct frequency-domain waveguide excitation and overlap /
Poynting analysis without a time-domain run.
Several examples rely on optional packages such as
[gridlock](https://mpxd.net/code/jan/gridlock).
### Frequency-domain waveguide workflow
For a structure with a constant cross-section in one direction:
1. Build `dxes` and the diagonal `epsilon` / `mu` distributions on the Yee grid.
2. Solve the port mode with `meanas.fdfd.waveguide_3d.solve_mode(...)`.
3. Build a unidirectional source with `compute_source(...)`.
4. Build a matching overlap window with `compute_overlap_e(...)`.
5. Solve the full FDFD problem and project the result onto the overlap window or
evaluate plane flux with `meanas.fdfd.functional.poynting_e_cross_h(...)`.
### Time-domain phasor workflow
For a broadband or continuous-wave FDTD run:
1. Advance the fields with `meanas.fdtd.maxwell_e/maxwell_h` or
`updates_with_cpml(...)`.
2. Inject electric current using the same sign convention used throughout the
examples and library: `E -= dt * J / epsilon`.
3. Accumulate the desired phasor with `accumulate_phasor(...)` or the Yee-aware
wrappers `accumulate_phasor_e/h/j(...)`.
4. Build the matching FDFD operator on the stretched `dxes` if CPML/SCPML is
part of the simulation, and compare the extracted phasor to the FDFD field or
residual.

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 import sys
@ -150,7 +155,8 @@ def main():
# print(f'Save time interval would be {sample_interval} * dt = {sample_interval * dt:3g}') # 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) source_phasor, delay = gaussian_packet(wl=wl, dwl=100, dt=dt, turn_on=1e-5)
aa, cc, ss = source_phasor(numpy.arange(max_t)) aa, cc, ss = source_phasor(numpy.arange(max_t))
srca_real = aa * cc srca_real = aa * cc
@ -170,7 +176,8 @@ def main():
update_E(ee, hh, epsilon) update_E(ee, hh, epsilon)
if tt < src_maxt: 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] ee[1, *(grid.shape // 2)] -= srca_real[tt]
update_H(ee, hh) update_H(ee, hh)
@ -193,9 +200,11 @@ def main():
dt, dt,
ee, ee,
tt, 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, 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, weight=phasor_norm / dt,
) )
@ -205,6 +214,8 @@ def main():
for pp in (-1, +1): for pp in (-1, +1):
for dd in range(3): for dd in range(3):
stretch_with_scpml(dxes_fdfd, axis=dd, polarity=pp, omega=omega, epsilon_effective=n_air ** 2, thickness=pml_thickness) 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) A = e_full(omega=omega, dxes=dxes_fdfd, epsilon=epsilon)
residual = norm(A @ vec(Eph) - vec(b)) / norm(vec(b)) residual = norm(A @ vec(Eph) - vec(b)) / norm(vec(b))
print(f'FDFD residual is {residual}') 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 from typing import Callable
import logging import logging
@ -78,7 +82,7 @@ def get_waveguide_mode(
omega: float, omega: float,
epsilon: fdfield_t, epsilon: fdfield_t,
) -> tuple[vcfdfield_t, vcfdfield_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], dims = numpy.array([[-10, -20, -15],
[-10, 20, 15]]) * [[numpy.median(numpy.real(dx)) for dx in dxes[0]]] [-10, 20, 15]]) * [[numpy.median(numpy.real(dx)) for dx in dxes[0]]]
ind_dims = (grid.pos2ind(dims[0], which_shifts=None).astype(int), 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'], J = waveguide_3d.compute_source(E=wg_results['E'], wavenumber=wg_results['wavenumber'],
omega=omega, epsilon=epsilon, **wg_args) 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) e_overlap = waveguide_3d.compute_overlap_e(E=wg_results['E'], wavenumber=wg_results['wavenumber'], **wg_args, omega=omega)
return J, e_overlap return J, e_overlap
@ -111,7 +117,8 @@ def main(
grid, epsilon = draw_grid(dx=dx, pml_thickness=pml_thickness) 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()] dxes = [grid.dxyz, grid.autoshifted_dxyz()]
for a in (0, 1, 2): for a in (0, 1, 2):
for p in (-1, 1): 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}') # 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) source_phasor, delay = gaussian_packet(wl=wl, dwl=100, dt=dt, turn_on=1e-5)
aa, cc, ss = source_phasor(numpy.arange(max_t)) aa, cc, ss = source_phasor(numpy.arange(max_t))
srca_real = aa * cc srca_real = aa * cc
@ -295,7 +303,8 @@ def main2():
update_E(ee, hh, epsilon) update_E(ee, hh, epsilon)
if tt < src_maxt: 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] ee[1, *(grid.shape // 2)] -= srca_real[tt]
update_H(ee, hh) update_H(ee, hh)
@ -317,9 +326,11 @@ def main2():
dt, dt,
ee, ee,
tt, 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, 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, weight=phasor_norm / dt,
) )
@ -329,6 +340,8 @@ def main2():
for pp in (-1, +1): for pp in (-1, +1):
for dd in range(3): for dd in range(3):
stretch_with_scpml(dxes_fdfd, axis=dd, polarity=pp, omega=omega, epsilon_effective=n_cladding ** 2, thickness=pml_thickness) 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) A = e_full(omega=omega, dxes=dxes_fdfd, epsilon=epsilon)
residual = norm(A @ vec(Eph) - vec(b)) / norm(vec(b)) residual = norm(A @ vec(Eph) - vec(b)) / norm(vec(b))
print(f'FDFD residual is {residual}') print(f'FDFD residual is {residual}')

View file

@ -9,9 +9,12 @@ Submodules:
- `operators`, `functional`: General FDFD problem setup. - `operators`, `functional`: General FDFD problem setup.
- `solvers`: Solver interface and reference implementation. - `solvers`: Solver interface and reference implementation.
- `scpml`: Stretched-coordinate perfectly matched layer (scpml) boundary conditions - `scpml`: Stretched-coordinate perfectly matched layer (SCPML) boundary conditions.
- `waveguide_2d`: Operators and mode-solver for waveguides with constant cross-section. - `waveguide_2d`: Operators and mode-solver for waveguides with constant cross-section.
- `waveguide_3d`: Functions for transforming `waveguide_2d` results into 3D. - `waveguide_3d`: Functions for transforming `waveguide_2d` results into 3D,
including mode-source and overlap-window construction.
- `farfield`, `bloch`, `eme`: specialized helper modules for near/far transforms,
Bloch-periodic problems, and eigenmode expansion.
================================================================ ================================================================
@ -86,10 +89,6 @@ $$
-\omega^2 \epsilon_{\vec{r}} \cdot \tilde{E}_{\vec{r}} = -\imath \omega \tilde{J}_{\vec{r}} \\ -\omega^2 \epsilon_{\vec{r}} \cdot \tilde{E}_{\vec{r}} = -\imath \omega \tilde{J}_{\vec{r}} \\
$$ $$
# TODO FDFD?
# TODO PML
""" """
from . import ( from . import (
solvers as solvers, solvers as solvers,

View file

@ -158,10 +158,23 @@ def e_tfsf_source(
epsilon: fdfield, epsilon: fdfield,
mu: fdfield | None = None, mu: fdfield | None = None,
) -> cfdfield_updater_t: ) -> cfdfield_updater_t:
""" r"""
Operator that turns an E-field distribution into a total-field/scattered-field Operator that turns an E-field distribution into a total-field/scattered-field
(TFSF) source. (TFSF) source.
If `A` is the full wave operator from `e_full(...)` and `Q` is the diagonal
mask selecting the total-field region, then the TFSF source is the commutator
$$
\frac{A Q - Q A}{-i \omega} E.
$$
This vanishes in the interior of the total-field and scattered-field regions
and is supported only at their shared boundary, where the mask discontinuity
makes `A` and `Q` fail to commute. The returned current is therefore the
distributed source needed to inject the desired total field without also
forcing the scattered-field region.
Args: Args:
TF_region: mask which is set to 1 in the total-field region, and 0 elsewhere TF_region: mask which is set to 1 in the total-field region, and 0 elsewhere
(i.e. in the scattered-field region). (i.e. in the scattered-field region).
@ -175,7 +188,6 @@ def e_tfsf_source(
Function `f` which takes an E field and returns a current distribution, Function `f` which takes an E field and returns a current distribution,
`f(E)` -> `J` `f(E)` -> `J`
""" """
# TODO documentation
A = e_full(omega, dxes, epsilon, mu) A = e_full(omega, dxes, epsilon, mu)
def op(e: cfdfield) -> cfdfield_t: def op(e: cfdfield) -> cfdfield_t:
@ -188,7 +200,13 @@ def poynting_e_cross_h(dxes: dx_lists_t) -> Callable[[cfdfield, cfdfield], cfdfi
r""" r"""
Generates a function that takes the single-frequency `E` and `H` fields Generates a function that takes the single-frequency `E` and `H` fields
and calculates the cross product `E` x `H` = $E \times H$ as required and calculates the cross product `E` x `H` = $E \times H$ as required
for the Poynting vector, $S = E \times H$ for the Poynting vector, $S = E \times H$.
On the Yee grid, the electric and magnetic components are not stored at the
same locations. This helper therefore applies the same one-cell electric-field
shifts used by the sparse `operators.poynting_e_cross(...)` construction so
that the discrete cross product matches the face-centered energy flux used in
`meanas.fdtd.energy.poynting(...)`.
Note: Note:
This function also shifts the input `E` field by one cell as required This function also shifts the input `E` field by one cell as required
@ -204,7 +222,8 @@ def poynting_e_cross_h(dxes: dx_lists_t) -> Callable[[cfdfield, cfdfield], cfdfi
dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types` dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types`
Returns: Returns:
Function `f` that returns E x H as required for the poynting vector. Function `f` that returns the staggered-grid cross product `E \times H`.
For time-average power, call it as `f(E, H.conj())` and take `Re(...) / 2`.
""" """
def exh(e: cfdfield, h: cfdfield) -> cfdfield_t: def exh(e: cfdfield, h: cfdfield) -> cfdfield_t:
s = numpy.empty_like(e) s = numpy.empty_like(e)

View file

@ -310,16 +310,22 @@ def m2j(
def poynting_e_cross(e: vcfdfield, dxes: dx_lists_t) -> sparse.sparray: def poynting_e_cross(e: vcfdfield, dxes: dx_lists_t) -> sparse.sparray:
""" r"""
Operator for computing the Poynting vector, containing the Operator for computing the staggered-grid `(E \times)` part of the Poynting vector.
(E x) portion of the Poynting vector.
On the Yee grid the E and H components live on different edges, so the
electric field must be shifted by one cell in the appropriate direction
before forming the discrete cross product. This sparse operator encodes that
shifted cross product directly and is the matrix equivalent of
`functional.poynting_e_cross_h(...)`.
Args: Args:
e: Vectorized E-field for the ExH cross product e: Vectorized E-field for the ExH cross product
dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types` dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types`
Returns: Returns:
Sparse matrix containing (E x) portion of Poynting cross product. Sparse matrix containing the `(E \times)` part of the staggered Poynting
cross product.
""" """
shape = [len(dx) for dx in dxes[0]] shape = [len(dx) for dx in dxes[0]]
@ -339,15 +345,26 @@ def poynting_e_cross(e: vcfdfield, dxes: dx_lists_t) -> sparse.sparray:
def poynting_h_cross(h: vcfdfield, dxes: dx_lists_t) -> sparse.sparray: def poynting_h_cross(h: vcfdfield, dxes: dx_lists_t) -> sparse.sparray:
""" r"""
Operator for computing the Poynting vector, containing the (H x) portion of the Poynting vector. Operator for computing the staggered-grid `(H \times)` part of the Poynting vector.
Together with `poynting_e_cross(...)`, this provides the matrix form of the
Yee-grid cross product used in the flux helpers. The two are related by the
usual antisymmetry of the cross product,
$$
H \times E = -(E \times H),
$$
once the same staggered field placement is used on both sides.
Args: Args:
h: Vectorized H-field for the HxE cross product h: Vectorized H-field for the HxE cross product
dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types` dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types`
Returns: Returns:
Sparse matrix containing (H x) portion of Poynting cross product. Sparse matrix containing the `(H \times)` part of the staggered Poynting
cross product.
""" """
shape = [len(dx) for dx in dxes[0]] shape = [len(dx) for dx in dxes[0]]
@ -372,11 +389,23 @@ def e_tfsf_source(
epsilon: vfdfield, epsilon: vfdfield,
mu: vfdfield | None = None, mu: vfdfield | None = None,
) -> sparse.sparray: ) -> sparse.sparray:
""" r"""
Operator that turns a desired E-field distribution into a Operator that turns a desired E-field distribution into a
total-field/scattered-field (TFSF) source. total-field/scattered-field (TFSF) source.
TODO: Reference Rumpf paper Let `A` be the full wave operator from `e_full(...)`, and let
`Q = \mathrm{diag}(TF_region)` be the projector onto the total-field region.
Then the TFSF current operator is the commutator
$$
\frac{A Q - Q A}{-i \omega}.
$$
Inside regions where `Q` is locally constant, `A` and `Q` commute and the
source vanishes. Only cells at the TF/SF boundary contribute nonzero current,
which is exactly the desired distributed source for injecting the chosen
field into the total-field region without directly forcing the
scattered-field region.
Args: Args:
TF_region: Mask, which is set to 1 inside the total-field region and 0 in the TF_region: Mask, which is set to 1 inside the total-field region and 0 in the
@ -388,9 +417,7 @@ def e_tfsf_source(
Returns: Returns:
Sparse matrix that turns an E-field into a current (J) distribution. Sparse matrix that turns an E-field into a current (J) distribution.
""" """
# TODO documentation
A = e_full(omega, dxes, epsilon, mu) A = e_full(omega, dxes, epsilon, mu)
Q = sparse.diags_array(TF_region) Q = sparse.diags_array(TF_region)
return (A @ Q - Q @ A) / (-1j * omega) return (A @ Q - Q @ A) / (-1j * omega)
@ -404,11 +431,17 @@ def e_boundary_source(
mu: vfdfield | None = None, mu: vfdfield | None = None,
periodic_mask_edges: bool = False, periodic_mask_edges: bool = False,
) -> sparse.sparray: ) -> sparse.sparray:
""" r"""
Operator that turns an E-field distrubtion into a current (J) distribution Operator that turns an E-field distrubtion into a current (J) distribution
along the edges (external and internal) of the provided mask. This is just an along the edges (external and internal) of the provided mask. This is just an
`e_tfsf_source()` with an additional masking step. `e_tfsf_source()` with an additional masking step.
Equivalently, this helper first constructs the TFSF commutator source for the
full mask and then zeroes out all cells except the mask boundary. The
boundary is defined as the set of cells whose mask value changes under a
one-cell shift in any Cartesian direction. With `periodic_mask_edges=False`
the shifts mirror at the domain boundary; with `True` they wrap periodically.
Args: Args:
mask: The current distribution is generated at the edges of the mask, mask: The current distribution is generated at the edges of the mask,
i.e. any points where shifting the mask by one cell in any direction i.e. any points where shifting the mask by one cell in any direction

View file

@ -175,8 +175,6 @@ if the result is introduced into a space with a discretized z-axis.
""" """
# TODO update module docs
from typing import Any from typing import Any
from collections.abc import Sequence from collections.abc import Sequence
import numpy import numpy
@ -339,7 +337,7 @@ def normalized_fields_e(
mu: vfdslice | None = None, mu: vfdslice | None = None,
prop_phase: float = 0, prop_phase: float = 0,
) -> tuple[vcfdslice_t, vcfdslice_t]: ) -> tuple[vcfdslice_t, vcfdslice_t]:
""" r"""
Given a vector `e_xy` containing the vectorized E_x and E_y fields, Given a vector `e_xy` containing the vectorized E_x and E_y fields,
returns normalized, vectorized E and H fields for the system. returns normalized, vectorized E and H fields for the system.
@ -357,6 +355,21 @@ def normalized_fields_e(
Returns: Returns:
`(e, h)`, where each field is vectorized, normalized, `(e, h)`, where each field is vectorized, normalized,
and contains all three vector components. and contains all three vector components.
Notes:
`e_xy` is only the transverse electric eigenvector. This helper first
reconstructs the full three-component `E` and `H` fields with `exy2e(...)`
and `exy2h(...)`, then normalizes them to unit forward power using
`_normalized_fields(...)`.
The normalization target is
$$
\Re\left[\mathrm{inner\_product}(e, h, \mathrm{conj\_h}=True)\right] = 1,
$$
so the returned fields represent a unit-power forward mode under the
discrete Yee-grid Poynting inner product.
""" """
e = exy2e(wavenumber=wavenumber, dxes=dxes, epsilon=epsilon) @ e_xy e = exy2e(wavenumber=wavenumber, dxes=dxes, epsilon=epsilon) @ e_xy
h = exy2h(wavenumber=wavenumber, omega=omega, dxes=dxes, epsilon=epsilon, mu=mu) @ e_xy h = exy2h(wavenumber=wavenumber, omega=omega, dxes=dxes, epsilon=epsilon, mu=mu) @ e_xy
@ -374,7 +387,7 @@ def normalized_fields_h(
mu: vfdslice | None = None, mu: vfdslice | None = None,
prop_phase: float = 0, prop_phase: float = 0,
) -> tuple[vcfdslice_t, vcfdslice_t]: ) -> tuple[vcfdslice_t, vcfdslice_t]:
""" r"""
Given a vector `h_xy` containing the vectorized H_x and H_y fields, Given a vector `h_xy` containing the vectorized H_x and H_y fields,
returns normalized, vectorized E and H fields for the system. returns normalized, vectorized E and H fields for the system.
@ -392,6 +405,13 @@ def normalized_fields_h(
Returns: Returns:
`(e, h)`, where each field is vectorized, normalized, `(e, h)`, where each field is vectorized, normalized,
and contains all three vector components. and contains all three vector components.
Notes:
This is the `H_x/H_y` analogue of `normalized_fields_e(...)`. The final
normalized mode should describe the same physical solution, but because
the overall complex phase and sign are chosen heuristically,
`normalized_fields_e(...)` and `normalized_fields_h(...)` need not return
identical representatives for nearly symmetric modes.
""" """
e = hxy2e(wavenumber=wavenumber, omega=omega, dxes=dxes, epsilon=epsilon, mu=mu) @ h_xy e = hxy2e(wavenumber=wavenumber, omega=omega, dxes=dxes, epsilon=epsilon, mu=mu) @ h_xy
h = hxy2h(wavenumber=wavenumber, dxes=dxes, mu=mu) @ h_xy h = hxy2h(wavenumber=wavenumber, dxes=dxes, mu=mu) @ h_xy
@ -409,7 +429,25 @@ def _normalized_fields(
mu: vfdslice | None = None, mu: vfdslice | None = None,
prop_phase: float = 0, prop_phase: float = 0,
) -> tuple[vcfdslice_t, vcfdslice_t]: ) -> tuple[vcfdslice_t, vcfdslice_t]:
# TODO documentation r"""
Normalize a reconstructed waveguide mode to unit forward power.
The eigenproblem solved by `solve_mode(s)` determines only the mode shape and
propagation constant. The overall complex amplitude and sign are still free.
This helper fixes those remaining degrees of freedom in two steps:
1. Compute the discrete longitudinal Poynting flux with
`inner_product(e, h, conj_h=True)`, including the half-cell longitudinal
phase adjustment controlled by `prop_phase`.
2. Multiply both fields by a scalar chosen so that the real forward power is
`1`, then choose a reproducible phase/sign representative by making a
dominant-energy sample real and using a weighted quadrant sum to break
mirror-symmetry ties.
The sign heuristic is intentionally pragmatic rather than fundamental: it is
only there to make downstream tests and source/overlap construction choose a
consistent representative when the physical mode is symmetric.
"""
shape = [s.size for s in dxes[0]] shape = [s.size for s in dxes[0]]
# Find time-averaged Sz and normalize to it # Find time-averaged Sz and normalize to it
@ -921,7 +959,7 @@ def solve_mode(
return vcfdfield2_t(e_xys[0]), wavenumbers[0] return vcfdfield2_t(e_xys[0]), wavenumbers[0]
def inner_product( # TODO documentation def inner_product(
e1: vcfdfield2, e1: vcfdfield2,
h2: vcfdfield2, h2: vcfdfield2,
dxes: dx_lists2_t, dxes: dx_lists2_t,
@ -929,6 +967,36 @@ def inner_product( # TODO documentation
conj_h: bool = False, conj_h: bool = False,
trapezoid: bool = False, trapezoid: bool = False,
) -> complex: ) -> complex:
r"""
Compute the discrete waveguide overlap / Poynting inner product.
This is the 2D transverse integral corresponding to the time-averaged
longitudinal Poynting flux,
$$
\frac{1}{2}\int (E_x H_y - E_y H_x) \, dx \, dy
$$
with the Yee-grid staggering and optional propagation-phase adjustment used
by the waveguide helpers in this module.
Args:
e1: Vectorized electric field, typically from `exy2e(...)` or
`normalized_fields_e(...)`.
h2: Vectorized magnetic field, typically from `hxy2h(...)`,
`exy2h(...)`, or one of the normalization helpers.
dxes: Two-dimensional Yee-grid spacing lists `[dx_e, dx_h]`.
prop_phase: Phase advance over one propagation cell. This is used to
shift the H field into the same longitudinal reference plane as the
E field.
conj_h: Whether to conjugate `h2` before forming the overlap. Use
`True` for the usual time-averaged power normalization.
trapezoid: Whether to use trapezoidal quadrature instead of the default
rectangular Yee-cell sum.
Returns:
Complex overlap / longitudinal power integral.
"""
shape = [s.size for s in dxes[0]] shape = [s.size for s in dxes[0]]
@ -951,5 +1019,3 @@ def inner_product( # TODO documentation
Sz_b = E1[1] * H2[0] * dxes_real[0][0][:, None] * dxes_real[1][1][None, :] Sz_b = E1[1] * H2[0] * dxes_real[0][0][:, None] * dxes_real[1][1][None, :]
Sz = 0.5 * (Sz_a.sum() - Sz_b.sum()) Sz = 0.5 * (Sz_a.sum() - Sz_b.sum())
return Sz return Sz

View file

@ -3,6 +3,21 @@ Tools for working with waveguide modes in 3D domains.
This module relies heavily on `waveguide_2d` and mostly just transforms This module relies heavily on `waveguide_2d` and mostly just transforms
its parameters into 2D equivalents and expands the results back into 3D. its parameters into 2D equivalents and expands the results back into 3D.
The intended workflow is:
1. Select a single-cell slice normal to the propagation axis.
2. Solve the corresponding 2D mode problem with `solve_mode(...)`.
3. Turn that mode into a one-sided source with `compute_source(...)`.
4. Build an overlap window with `compute_overlap_e(...)` for port readout.
`polarity` is part of the public convention throughout this module:
- `+1` means forward propagation toward increasing index along `axis`
- `-1` means backward propagation toward decreasing index along `axis`
That same convention controls which side of the selected slice is used for the
overlap window and how the expanded field is phased.
""" """
from typing import Any, cast from typing import Any, cast
import warnings import warnings
@ -26,9 +41,9 @@ def solve_mode(
epsilon: fdfield, epsilon: fdfield,
mu: fdfield | None = None, mu: fdfield | None = None,
) -> dict[str, complex | NDArray[complexfloating]]: ) -> dict[str, complex | NDArray[complexfloating]]:
""" r"""
Given a 3D grid, selects a slice from the grid and attempts to Given a 3D grid, selects a slice from the grid and attempts to
solve for an eigenmode propagating through that slice. solve for an eigenmode propagating through that slice.
Args: Args:
mode_number: Number of the mode, 0-indexed mode_number: Number of the mode, 0-indexed
@ -37,19 +52,22 @@ def solve_mode(
axis: Propagation axis (0=x, 1=y, 2=z) axis: Propagation axis (0=x, 1=y, 2=z)
polarity: Propagation direction (+1 for +ve, -1 for -ve) polarity: Propagation direction (+1 for +ve, -1 for -ve)
slices: `epsilon[tuple(slices)]` is used to select the portion of the grid to use slices: `epsilon[tuple(slices)]` is used to select the portion of the grid to use
as the waveguide cross-section. `slices[axis]` should select only one item. as the waveguide cross-section. `slices[axis]` must select exactly one item.
epsilon: Dielectric constant epsilon: Dielectric constant
mu: Magnetic permeability (default 1 everywhere) mu: Magnetic permeability (default 1 everywhere)
Returns: Returns:
``` Dictionary containing:
{
'E': NDArray[complexfloating], - `E`: full-grid electric field for the solved mode
'H': NDArray[complexfloating], - `H`: full-grid magnetic field for the solved mode
'wavenumber': complex, - `wavenumber`: propagation constant corrected for the discretized
'wavenumber_2d': complex, propagation axis
} - `wavenumber_2d`: propagation constant of the reduced 2D eigenproblem
```
Notes:
The returned fields are normalized through the `waveguide_2d`
normalization convention before being expanded back to 3D.
""" """
if mu is None: if mu is None:
mu = numpy.ones_like(epsilon) mu = numpy.ones_like(epsilon)
@ -139,7 +157,14 @@ def compute_source(
mu: Magnetic permeability (default 1 everywhere) mu: Magnetic permeability (default 1 everywhere)
Returns: Returns:
J distribution for the unidirectional source `J` distribution for a one-sided electric-current source.
Notes:
The source is built from the expanded mode field and a boundary-source
operator. The resulting current is intended to be injected with the
same sign convention used elsewhere in the package:
`E -= dt * J / epsilon`
""" """
E_expanded = expand_e(E=E, dxes=dxes, wavenumber=wavenumber, axis=axis, E_expanded = expand_e(E=E, dxes=dxes, wavenumber=wavenumber, axis=axis,
polarity=polarity, slices=slices) polarity=polarity, slices=slices)
@ -167,10 +192,33 @@ def compute_overlap_e(
slices: Sequence[slice], slices: Sequence[slice],
omega: float, omega: float,
) -> cfdfield_t: ) -> cfdfield_t:
""" r"""
Given an eigenmode obtained by `solve_mode`, calculates an overlap_e for the Build an overlap field for projecting another 3D electric field onto a mode.
mode orthogonality relation Integrate(((E x H_mode) + (E_mode x H)) dot dn)
[assumes reflection symmetry]. The returned field is intended for the discrete overlap expression
$$
\sum \mathrm{overlap\_e} \; E_\mathrm{other}^*
$$
where the sum is over the full Yee-grid field storage.
The construction uses a two-cell window immediately upstream of the selected
slice:
- for `polarity=+1`, the two cells just before `slices[axis].start`
- for `polarity=-1`, the two cells just after `slices[axis].stop`
The window is clipped to the simulation domain if necessary. A clipped but
non-empty window raises `RuntimeWarning`; an empty window raises
`ValueError`.
The derivation below assumes reflection symmetry and the standard waveguide
overlap relation involving
$$
\int ((E \times H_\mathrm{mode}) + (E_\mathrm{mode} \times H)) \cdot dn.
$$
E x H_mode + E_mode x H E x H_mode + E_mode x H
-> Ex Hmy - EyHmx + Emx Hy - Emy Hx (Z-prop) -> Ex Hmy - EyHmx + Emx Hy - Emy Hx (Z-prop)
@ -183,8 +231,6 @@ def compute_overlap_e(
B/wu (Ex Emx + Ey Emy) - j/wu (Ex dx Emz + Ey dy Emz) B/wu (Ex Emx + Ey Emy) - j/wu (Ex dx Emz + Ey dy Emz)
TODO: add reference
Args: Args:
E: E-field of the mode E: E-field of the mode
wavenumber: Wavenumber of the mode wavenumber: Wavenumber of the mode
@ -195,7 +241,8 @@ def compute_overlap_e(
as the waveguide cross-section. slices[axis] should select only one item. as the waveguide cross-section. slices[axis] should select only one item.
Returns: Returns:
overlap_e such that `numpy.sum(overlap_e * other_e.conj())` computes the overlap integral `overlap_e` normalized so that `numpy.sum(overlap_e * E.conj()) == 1`
over the retained overlap window.
""" """
slices = tuple(slices) slices = tuple(slices)
@ -240,7 +287,7 @@ def expand_e(
polarity: int, polarity: int,
slices: Sequence[slice], slices: Sequence[slice],
) -> cfdfield_t: ) -> cfdfield_t:
""" r"""
Given an eigenmode obtained by `solve_mode`, expands the E-field from the 2D Given an eigenmode obtained by `solve_mode`, expands the E-field from the 2D
slice where the mode was calculated to the entire domain (along the propagation slice where the mode was calculated to the entire domain (along the propagation
axis). This assumes the epsilon cross-section remains constant throughout the axis). This assumes the epsilon cross-section remains constant throughout the
@ -258,6 +305,16 @@ def expand_e(
Returns: Returns:
`E`, with the original field expanded along the specified `axis`. `E`, with the original field expanded along the specified `axis`.
Notes:
This helper assumes that the waveguide cross-section remains constant
along the propagation axis and applies the phase factor
$$
e^{-i \, \mathrm{polarity} \, wavenumber \, \Delta z}
$$
to each copied slice.
""" """
slices = tuple(slices) slices = tuple(slices)

View file

@ -2,63 +2,125 @@ r"""
Operators and helper functions for cylindrical waveguides with unchanging cross-section. Operators and helper functions for cylindrical waveguides with unchanging cross-section.
Waveguide operator is derived according to 10.1364/OL.33.001848. Waveguide operator is derived according to 10.1364/OL.33.001848.
The curl equations in the complex coordinate system become
As in `waveguide_2d`, the propagation dependence is separated from the
transverse solve. Here the propagation coordinate is the bend angle `\theta`,
and the fields are assumed to have the form
$$
\vec{E}(r, y, \theta), \vec{H}(r, y, \theta) \propto e^{-\imath m \theta},
$$
where `m` is the angular wavenumber returned by `solve_mode(s)`. It is often
convenient to introduce the corresponding linear wavenumber
$$
\beta = \frac{m}{r_{\min}},
$$
so that the cylindrical problem resembles the straight-waveguide problem with
additional metric factors.
Those metric factors live on the staggered radial Yee grids. If the left edge of
the computational window is at `r = r_{\min}`, define the electric-grid and
magnetic-grid radial sample locations by
$$ $$
\begin{aligned} \begin{aligned}
-\imath \omega \mu_{xx} H_x &= \tilde{\partial}_y E_z + \imath \beta frac{E_y}{\tilde{t}_x} \\ r_a(n) &= r_{\min} + \sum_{j \le n} \Delta r_{e, j}, \\
-\imath \omega \mu_{yy} H_y &= -\imath \beta E_x - \frac{1}{\hat{t}_x} \tilde{\partial}_x \tilde{t}_x E_z \\ r_b\!\left(n + \tfrac{1}{2}\right) &= r_{\min} + \tfrac{1}{2}\Delta r_{e, n}
-\imath \omega \mu_{zz} H_z &= \tilde{\partial}_x E_y - \tilde{\partial}_y E_x \\ + \sum_{j < n} \Delta r_{h, j},
\imath \omega \epsilon_{xx} E_x &= \hat{\partial}_y H_z + \imath \beta \frac{H_y}{\hat{T}} \\
\imath \omega \epsilon_{yy} E_y &= -\imath \beta H_x - \{1}{\tilde{t}_x} \hat{\partial}_x \hat{t}_x} H_z \\
\imath \omega \epsilon_{zz} E_z &= \hat{\partial}_x H_y - \hat{\partial}_y H_x \\
\end{aligned} \end{aligned}
$$ $$
where $t_x = 1 + \frac{\Delta_{x, m}}{R_0}$ is the grid spacing adjusted by the nominal radius $R0$. and from them the diagonal metric matrices
Rewrite the last three equations as
$$ $$
\begin{aligned} \begin{aligned}
\imath \beta H_y &= \imath \omega \hat{t}_x \epsilon_{xx} E_x - \hat{t}_x \hat{\partial}_y H_z \\ T_a &= \operatorname{diag}(r_a / r_{\min}), \\
\imath \beta H_x &= -\imath \omega \hat{t}_x \epsilon_{yy} E_y - \hat{t}_x \hat{\partial}_x H_z \\ T_b &= \operatorname{diag}(r_b / r_{\min}).
\imath \omega E_z &= \frac{1}{\epsilon_{zz}} \hat{\partial}_x H_y - \frac{1}{\epsilon_{zz}} \hat{\partial}_y H_x \\
\end{aligned} \end{aligned}
$$ $$
The derivation then follows the same steps as the straight waveguide, leading to the eigenvalue problem With the same forward/backward derivative notation used in `waveguide_2d`, the
coordinate-transformed discrete curl equations used here are
$$
\beta^2 \begin{bmatrix} E_x \\
E_y \end{bmatrix} =
(\omega^2 \begin{bmatrix} T_b T_b \mu_{yy} \epsilon_{xx} & 0 \\
0 & T_a T_a \mu_{xx} \epsilon_{yy} \end{bmatrix} +
\begin{bmatrix} -T_b \mu_{yy} \hat{\partial}_y \\
T_a \mu_{xx} \hat{\partial}_x \end{bmatrix} T_b \mu_{zz}^{-1}
\begin{bmatrix} -\tilde{\partial}_y & \tilde{\partial}_x \end{bmatrix} +
\begin{bmatrix} \tilde{\partial}_x \\
\tilde{\partial}_y \end{bmatrix} T_a \epsilon_{zz}^{-1}
\begin{bmatrix} \hat{\partial}_x T_b \epsilon_{xx} & \hat{\partial}_y T_a \epsilon_{yy} \end{bmatrix})
\begin{bmatrix} E_x \\
E_y \end{bmatrix}
$$
which resembles the straight waveguide eigenproblem with additonal $T_a$ and $T_b$ terms. These
are diagonal matrices containing the $t_x$ values:
$$ $$
\begin{aligned} \begin{aligned}
T_a &= 1 + \frac{\Delta_{x, m }}{R_0} -\imath \omega \mu_{rr} H_r &= \tilde{\partial}_y E_z + \imath \beta T_a^{-1} E_y, \\
T_b &= 1 + \frac{\Delta_{x, m + \frac{1}{2} }}{R_0} -\imath \omega \mu_{yy} H_y &= -\imath \beta T_b^{-1} E_r
- T_b^{-1} \tilde{\partial}_r (T_a E_z), \\
-\imath \omega \mu_{zz} H_z &= \tilde{\partial}_r E_y - \tilde{\partial}_y E_r, \\
\imath \beta H_y &= -\imath \omega T_b \epsilon_{rr} E_r - T_b \hat{\partial}_y H_z, \\
\imath \beta H_r &= \imath \omega T_a \epsilon_{yy} E_y
- T_b T_a^{-1} \hat{\partial}_r (T_b H_z), \\
\imath \omega E_z &= T_a \epsilon_{zz}^{-1}
\left(\hat{\partial}_r H_y - \hat{\partial}_y H_r\right).
\end{aligned} \end{aligned}
TODO: consider 10.1364/OE.20.021583 for an alternate approach
$$ $$
As in the straight waveguide case, all the functions in this file assume a 2D grid The first three equations are the cylindrical analogue of the straight-guide
(i.e. `dxes = [[[dr_e_0, dx_e_1, ...], [dy_e_0, ...]], [[dr_h_0, ...], [dy_h_0, ...]]]`). relations for `H_r`, `H_y`, and `H_z`. The next two are the metric-weighted
versions of the straight-guide identities for `\imath \beta H_y` and
`\imath \beta H_r`, and the last equation plays the same role as the
longitudinal `E_z` reconstruction in `waveguide_2d`.
Following the same elimination steps as in `waveguide_2d`, apply
`\imath \beta \tilde{\partial}_r` and `\imath \beta \tilde{\partial}_y` to the
equation for `E_z`, substitute for `\imath \beta H_r` and `\imath \beta H_y`,
and then eliminate `H_z` with
$$
H_z = \frac{1}{-\imath \omega \mu_{zz}}
\left(\tilde{\partial}_r E_y - \tilde{\partial}_y E_r\right).
$$
This yields the transverse electric eigenproblem implemented by
`cylindrical_operator(...)`:
$$
\beta^2
\begin{bmatrix} E_r \\ E_y \end{bmatrix}
=
\left(
\omega^2
\begin{bmatrix}
T_b^2 \mu_{yy} \epsilon_{xx} & 0 \\
0 & T_a^2 \mu_{xx} \epsilon_{yy}
\end{bmatrix}
+
\begin{bmatrix}
-T_b \mu_{yy} \hat{\partial}_y \\
T_a \mu_{xx} \hat{\partial}_x
\end{bmatrix}
T_b \mu_{zz}^{-1}
\begin{bmatrix}
-\tilde{\partial}_y & \tilde{\partial}_x
\end{bmatrix}
+
\begin{bmatrix}
\tilde{\partial}_x \\
\tilde{\partial}_y
\end{bmatrix}
T_a \epsilon_{zz}^{-1}
\begin{bmatrix}
\hat{\partial}_x T_b \epsilon_{xx} &
\hat{\partial}_y T_a \epsilon_{yy}
\end{bmatrix}
\right)
\begin{bmatrix} E_r \\ E_y \end{bmatrix}.
$$
Since `\beta = m / r_{\min}`, the solver implemented in this file returns the
angular wavenumber `m`, while the operator itself is most naturally written in
terms of the linear quantity `\beta`. The helpers below reconstruct the full
field components from the solved transverse eigenvector and then normalize the
mode to unit forward power with the same discrete longitudinal Poynting inner
product used by `waveguide_2d`.
As in the straight-waveguide case, all functions here assume a 2D grid:
`dxes = [[[dr_e_0, dr_e_1, ...], [dy_e_0, ...]], [[dr_h_0, ...], [dy_h_0, ...]]]`.
""" """
from typing import Any, cast from typing import Any, cast
from collections.abc import Sequence from collections.abc import Sequence
@ -94,17 +156,18 @@ def cylindrical_operator(
\begin{bmatrix} \tilde{\partial}_x \\ \begin{bmatrix} \tilde{\partial}_x \\
\tilde{\partial}_y \end{bmatrix} T_a \epsilon_{zz}^{-1} \tilde{\partial}_y \end{bmatrix} T_a \epsilon_{zz}^{-1}
\begin{bmatrix} \hat{\partial}_x T_b \epsilon_{xx} & \hat{\partial}_y T_a \epsilon_{yy} \end{bmatrix}) \begin{bmatrix} \hat{\partial}_x T_b \epsilon_{xx} & \hat{\partial}_y T_a \epsilon_{yy} \end{bmatrix})
\begin{bmatrix} E_x \\ \begin{bmatrix} E_r \\
E_y \end{bmatrix} E_y \end{bmatrix}
$$ $$
for use with a field vector of the form `[E_r, E_y]`. for use with a field vector of the form `[E_r, E_y]`.
This operator can be used to form an eigenvalue problem of the form This operator can be used to form an eigenvalue problem of the form
A @ [E_r, E_y] = wavenumber**2 * [E_r, E_y] A @ [E_r, E_y] = beta**2 * [E_r, E_y]
which can then be solved for the eigenmodes of the system which can then be solved for the eigenmodes of the system
(an `exp(-i * wavenumber * theta)` theta-dependence is assumed for the fields). (an `exp(-i * angular_wavenumber * theta)` theta-dependence is assumed for
the fields, with `beta = angular_wavenumber / rmin`).
(NOTE: See module docs and 10.1364/OL.33.001848) (NOTE: See module docs and 10.1364/OL.33.001848)
@ -270,7 +333,7 @@ def exy2h(
mu: vfdslice | None = None mu: vfdslice | None = None
) -> sparse.sparray: ) -> sparse.sparray:
""" """
Operator which transforms the vector `e_xy` containing the vectorized E_x and E_y fields, Operator which transforms the vector `e_xy` containing the vectorized E_r and E_y fields,
into a vectorized H containing all three H components into a vectorized H containing all three H components
Args: Args:
@ -298,11 +361,11 @@ def exy2e(
epsilon: vfdslice, epsilon: vfdslice,
) -> sparse.sparray: ) -> sparse.sparray:
""" """
Operator which transforms the vector `e_xy` containing the vectorized E_x and E_y fields, Operator which transforms the vector `e_xy` containing the vectorized E_r and E_y fields,
into a vectorized E containing all three E components into a vectorized E containing all three E components
Unlike the straight waveguide case, the H_z components do not cancel and must be calculated Unlike the straight waveguide case, the H_z components do not cancel and must be calculated
from E_x and E_y in order to then calculate E_z. from E_r and E_y in order to then calculate E_z.
Args: Args:
angular_wavenumber: Wavenumber assuming fields have theta-dependence of angular_wavenumber: Wavenumber assuming fields have theta-dependence of
@ -360,9 +423,10 @@ def e2h(
This operator is created directly from the initial coordinate-transformed equations: This operator is created directly from the initial coordinate-transformed equations:
$$ $$
\begin{aligned} \begin{aligned}
\imath \omega \epsilon_{xx} E_x &= \hat{\partial}_y H_z + \imath \beta \frac{H_y}{\hat{T}} \\ -\imath \omega \mu_{rr} H_r &= \tilde{\partial}_y E_z + \imath \beta T_a^{-1} E_y, \\
\imath \omega \epsilon_{yy} E_y &= -\imath \beta H_x - \{1}{\tilde{t}_x} \hat{\partial}_x \hat{t}_x} H_z \\ -\imath \omega \mu_{yy} H_y &= -\imath \beta T_b^{-1} E_r
\imath \omega \epsilon_{zz} E_z &= \hat{\partial}_x H_y - \hat{\partial}_y H_x \\ - T_b^{-1} \tilde{\partial}_r (T_a E_z), \\
-\imath \omega \mu_{zz} H_z &= \tilde{\partial}_r E_y - \tilde{\partial}_y E_r,
\end{aligned} \end{aligned}
$$ $$
@ -397,15 +461,18 @@ def dxes2T(
rmin: float, rmin: float,
) -> tuple[NDArray[numpy.float64], NDArray[numpy.float64]]: ) -> tuple[NDArray[numpy.float64], NDArray[numpy.float64]]:
r""" r"""
Returns the $T_a$ and $T_b$ diagonal matrices which are used to apply the cylindrical Construct the cylindrical metric matrices $T_a$ and $T_b$.
coordinate transformation in various operators.
`T_a` is sampled on the E-grid radial locations, while `T_b` is sampled on
the staggered H-grid radial locations. These are the diagonal matrices that
convert the straight-waveguide algebra into its cylindrical counterpart.
Args: Args:
dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types` (2D) dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types` (2D)
rmin: Radius at the left edge of the simulation domain (at minimum 'x') rmin: Radius at the left edge of the simulation domain (at minimum 'x')
Returns: Returns:
Sparse matrix representations of the operators Ta and Tb Sparse diagonal matrices `(T_a, T_b)`.
""" """
ra = rmin + numpy.cumsum(dxes[0][0]) # Radius at Ey points ra = rmin + numpy.cumsum(dxes[0][0]) # Radius at Ey points
rb = rmin + dxes[0][0] / 2.0 + numpy.cumsum(dxes[1][0]) # Radius at Ex points rb = rmin + dxes[0][0] / 2.0 + numpy.cumsum(dxes[1][0]) # Radius at Ex points
@ -427,12 +494,12 @@ def normalized_fields_e(
mu: vfdslice | None = None, mu: vfdslice | None = None,
prop_phase: float = 0, prop_phase: float = 0,
) -> tuple[vcfdslice_t, vcfdslice_t]: ) -> tuple[vcfdslice_t, vcfdslice_t]:
""" r"""
Given a vector `e_xy` containing the vectorized E_x and E_y fields, Given a vector `e_xy` containing the vectorized E_r and E_y fields,
returns normalized, vectorized E and H fields for the system. returns normalized, vectorized E and H fields for the system.
Args: Args:
e_xy: Vector containing E_x and E_y fields e_xy: Vector containing E_r and E_y fields
angular_wavenumber: Wavenumber assuming fields have theta-dependence of angular_wavenumber: Wavenumber assuming fields have theta-dependence of
`exp(-i * angular_wavenumber * theta)`. It should satisfy `exp(-i * angular_wavenumber * theta)`. It should satisfy
`operator_e() @ e_xy == (angular_wavenumber / rmin) ** 2 * e_xy` `operator_e() @ e_xy == (angular_wavenumber / rmin) ** 2 * e_xy`
@ -447,6 +514,18 @@ def normalized_fields_e(
Returns: Returns:
`(e, h)`, where each field is vectorized, normalized, `(e, h)`, where each field is vectorized, normalized,
and contains all three vector components. and contains all three vector components.
Notes:
The normalization step is delegated to `_normalized_fields(...)`, which
enforces unit forward power under the discrete inner product
$$
\frac{1}{2}\int (E_r H_y^* - E_y H_r^*) \, dr \, dy.
$$
The angular wavenumber `m` is first converted into the full three-component
fields, then the overall complex phase and sign are fixed so the result is
reproducible for symmetric modes.
""" """
e = exy2e(angular_wavenumber=angular_wavenumber, omega=omega, dxes=dxes, rmin=rmin, epsilon=epsilon) @ e_xy e = exy2e(angular_wavenumber=angular_wavenumber, omega=omega, dxes=dxes, rmin=rmin, epsilon=epsilon) @ e_xy
h = exy2h(angular_wavenumber=angular_wavenumber, omega=omega, dxes=dxes, rmin=rmin, epsilon=epsilon, mu=mu) @ e_xy h = exy2h(angular_wavenumber=angular_wavenumber, omega=omega, dxes=dxes, rmin=rmin, epsilon=epsilon, mu=mu) @ e_xy
@ -465,8 +544,30 @@ def _normalized_fields(
mu: vfdslice | None = None, mu: vfdslice | None = None,
prop_phase: float = 0, prop_phase: float = 0,
) -> tuple[vcfdslice_t, vcfdslice_t]: ) -> tuple[vcfdslice_t, vcfdslice_t]:
r"""
Normalize a cylindrical waveguide mode to unit forward power.
The cylindrical helpers reuse the straight-waveguide inner product after the
field reconstruction step. The extra metric factors have already been folded
into the reconstructed `e`/`h` fields through `dxes2T(...)` and the
cylindrical `exy2e(...)` / `exy2h(...)` operators, so the same discrete
longitudinal Poynting integral can be used here.
The normalization procedure is:
1. Flip the magnetic field sign so the reconstructed `(e, h)` pair follows
the same forward-power convention as `waveguide_2d`.
2. Compute the time-averaged forward power with
`waveguide_2d.inner_product(..., conj_h=True)`.
3. Scale by `1 / sqrt(S_z)` so the resulting mode has unit forward power.
4. Remove the arbitrary complex phase and apply a quadrant-sum sign heuristic
so symmetric modes choose a stable representative.
`prop_phase` has the same meaning as in `waveguide_2d`: it compensates for
the half-cell longitudinal staggering between the E and H fields when the
propagation direction is itself discretized.
"""
h *= -1 h *= -1
# TODO documentation for normalized_fields
shape = [s.size for s in dxes[0]] shape = [s.size for s in dxes[0]]
# Find time-averaged Sz and normalize to it # Find time-averaged Sz and normalize to it

View file

@ -168,7 +168,44 @@ t=0 (assuming the source is off for t<0 this gives $\sim 10^{-3}$ error at t=0).
Boundary conditions Boundary conditions
=================== ===================
# TODO notes about boundaries / PMLs
`meanas.fdtd` exposes two boundary-related building blocks:
- `conducting_boundary(...)` for simple perfect-electric-conductor style field
clamping at one face of the domain.
- `cpml_params(...)` and `updates_with_cpml(...)` for convolutional perfectly
matched layers (CPMLs) attached to one or more faces of the Yee grid.
`updates_with_cpml(...)` accepts a three-by-two table of CPML parameter blocks:
```
cpml_params[axis][polarity_index]
```
where `axis` is `0`, `1`, or `2` and `polarity_index` corresponds to `(-1, +1)`.
Passing `None` for one entry disables CPML on that face while leaving the other
directions unchanged. This is how mixed boundary setups such as "absorbing in x,
periodic in y/z" are expressed.
When comparing an FDTD run against an FDFD solve, use the same stretched
coordinate system in both places:
1. Build the FDTD update with the desired CPML parameters.
2. Stretch the FDFD `dxes` with the matching SCPML transform.
3. Compare the extracted phasor against the FDFD field or residual on those
stretched `dxes`.
The electric-current sign convention used throughout the examples and tests is
$$
E \leftarrow E - \Delta_t J / \epsilon
$$
which matches the FDFD right-hand side
$$
-i \omega J.
$$
""" """
from .base import ( from .base import (

View file

@ -4,7 +4,21 @@ from ..fdmath import dx_lists_t, fdfield_t, fdfield
from ..fdmath.functional import deriv_back from ..fdmath.functional import deriv_back
# TODO documentation """
Energy- and flux-accounting helpers for Yee-grid FDTD fields.
These functions complement the derivation in `meanas.fdtd`:
- `poynting(...)` and `poynting_divergence(...)` evaluate the discrete flux terms
from the exact time-domain Poynting identity.
- `energy_hstep(...)` / `energy_estep(...)` evaluate the two staggered energy
expressions.
- `delta_energy_*` helpers evaluate the corresponding energy changes between
adjacent half-steps.
The helpers are intended for diagnostics, regression tests, and consistency
checks between source work, field energy, and flux through cell faces.
"""
def poynting( def poynting(
@ -252,13 +266,23 @@ def delta_energy_j(
e1: fdfield, e1: fdfield,
dxes: dx_lists_t | None = None, dxes: dx_lists_t | None = None,
) -> fdfield_t: ) -> fdfield_t:
""" r"""
Calculate Calculate the electric-current work term $J \cdot E$ on the Yee grid.
Note that each value of $J$ contributes to the energy twice (i.e. once per field update) This is the source contribution that appears beside the flux divergence in
despite only causing the value of $E$ to change once (same for $M$ and $H$). the discrete Poynting identities documented in `meanas.fdtd`.
Note that each value of `J` contributes twice in a full Yee cycle (once per
half-step energy balance) even though it directly changes `E` only once.
Args:
j0: Electric-current density sampled at the same half-step as the
current work term.
e1: Electric field sampled at the matching integer timestep.
dxes: Grid description; defaults to unit spacing.
Returns:
Per-cell source-work contribution with the scalar field shape.
""" """
if dxes is None: if dxes is None:
dxes = tuple(tuple(numpy.ones(1) for _ in range(3)) for _ in range(2)) dxes = tuple(tuple(numpy.ones(1) for _ in range(3)) for _ in range(2))
@ -277,6 +301,20 @@ def dxmul(
mu: fdfield | float | None = None, mu: fdfield | float | None = None,
dxes: dx_lists_t | None = None, dxes: dx_lists_t | None = None,
) -> fdfield_t: ) -> fdfield_t:
"""
Multiply E- and H-like field products by material weights and cell volumes.
Args:
ee: Three-component electric-field product, such as `e0 * e2`.
hh: Three-component magnetic-field product, such as `h1 * h1`.
epsilon: Electric material weight; defaults to `1`.
mu: Magnetic material weight; defaults to `1`.
dxes: Grid description; defaults to unit spacing.
Returns:
Scalar field containing the weighted electric plus magnetic contribution
for each Yee cell.
"""
if epsilon is None: if epsilon is None:
epsilon = 1 epsilon = 1
if mu is None: if mu is None:

View file

@ -1,9 +1,19 @@
""" """
PML implementations Convolutional perfectly matched layer (CPML) support for FDTD updates.
#TODO discussion of PMLs The helpers in this module construct per-face CPML parameters and then wrap the
#TODO cpml documentation standard Yee updates with the additional auxiliary `psi` fields needed by the
CPML recurrence.
The intended call pattern is:
1. Build a `cpml_params[axis][polarity_index]` table with `cpml_params(...)`.
2. Pass that table into `updates_with_cpml(...)` together with `dt`, `dxes`, and
`epsilon`.
3. Advance the returned `update_E` / `update_H` closures in the simulation loop.
Each face can be enabled or disabled independently by replacing one table entry
with `None`.
""" """
# TODO retest pmls! # TODO retest pmls!
@ -32,6 +42,29 @@ def cpml_params(
ma: float = 1, ma: float = 1,
cfs_alpha: float = 0, cfs_alpha: float = 0,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""
Construct the parameter block for one CPML face.
Args:
axis: Which Cartesian axis the CPML is normal to (`0`, `1`, or `2`).
polarity: Which face along that axis (`-1` for the low-index face,
`+1` for the high-index face).
dt: Timestep used by the Yee update.
thickness: Number of Yee cells occupied by the CPML region.
ln_R_per_layer: Logarithmic attenuation target per layer.
epsilon_eff: Effective permittivity used when choosing the CPML scaling.
mu_eff: Effective permeability used when choosing the CPML scaling.
m: Polynomial grading exponent for `sigma` and `kappa`.
ma: Polynomial grading exponent for the complex-frequency shift `alpha`.
cfs_alpha: Maximum complex-frequency shift parameter.
Returns:
Dictionary with:
- `param_e`: `(p0, p1, p2)` arrays for the E update
- `param_h`: `(p0, p1, p2)` arrays for the H update
- `region`: slice tuple selecting the CPML cells on that face
"""
if axis not in range(3): if axis not in range(3):
raise Exception(f'Invalid axis: {axis}') raise Exception(f'Invalid axis: {axis}')
@ -98,6 +131,27 @@ def updates_with_cpml(
dtype: DTypeLike = numpy.float32, dtype: DTypeLike = numpy.float32,
) -> tuple[Callable[[fdfield_t, fdfield_t, fdfield_t], None], ) -> tuple[Callable[[fdfield_t, fdfield_t, fdfield_t], None],
Callable[[fdfield_t, fdfield_t, fdfield_t], None]]: Callable[[fdfield_t, fdfield_t, fdfield_t], None]]:
"""
Build Yee-step update closures augmented with CPML terms.
Args:
cpml_params: Three-by-two sequence indexed as `[axis][polarity_index]`.
Entries are the dictionaries returned by `cpml_params(...)`; use
`None` to disable CPML on one face.
dt: Timestep.
dxes: Yee-grid spacing lists `[dx_e, dx_h]`.
epsilon: Electric material distribution used by the E update.
dtype: Storage dtype for the auxiliary CPML state arrays.
Returns:
`(update_E, update_H)` closures with the same call shape as the basic
Yee updates:
- `update_E(e, h, epsilon)`
- `update_H(e, h, mu)`
The closures retain the CPML auxiliary state internally.
"""
Dfx, Dfy, Dfz = deriv_forward(dxes[1]) Dfx, Dfy, Dfz = deriv_forward(dxes[1])
Dbx, Dby, Dbz = deriv_back(dxes[1]) Dbx, Dby, Dbz = deriv_back(dxes[1])