Skip to content

Commit 2dbbef2

Browse files
committed
Enhance documentation and validation across core modules
- Re-export public API in `__init__.py` for user convenience. - Add detailed comments in `constants.py`, `models.py`, `raster.py`, `result.py`, `scipy_reference.py`, and `sweep.py` to clarify functionality and improve maintainability. - Ensure explicit handling of public interfaces and validation checks in data classes and solver functions.
1 parent 37d5d88 commit 2dbbef2

7 files changed

Lines changed: 459 additions & 0 deletions

File tree

python/micromode/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,16 @@
22

33
from __future__ import annotations
44

5+
# Re-export the small public API from package root so users do not need to know
6+
# the internal module split.
57
from .constants import C_0, EPSILON_0
68
from .models import BoundarySpec, Grid, Materials, PmlSpec, Spec
79
from .raster import solve_grid, solve_modes, solve_slice
810
from .result import Result, overlap
911
from .sweep import Sweep, track_modes_by_overlap
1012

13+
# Keep __all__ explicit so documentation and static analysis show the intended
14+
# public surface.
1115
__all__ = [
1216
"C_0",
1317
"EPSILON_0",

python/micromode/constants.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
"""Physical constants used by MicroMode."""
22

3+
# Speed of light in microns per second, matching the coordinate unit used by the
4+
# raster API.
35
C_0 = 2.997_924_58e14
6+
7+
# Vacuum permittivity in solver units.
48
EPSILON_0 = 8.854_187_812_800_384e-18

python/micromode/models.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
BoundaryCondition = Literal["pec", "pmc"]
1515

1616

17+
# These dataclasses are intentionally lightweight: they validate user-facing
18+
# arrays and options before the numerical code receives them.
1719
@dataclass(frozen=True)
1820
class PmlSpec:
1921
"""Perfectly matched layer settings for mode solves."""
@@ -26,18 +28,24 @@ class PmlSpec:
2628

2729
def __post_init__(self) -> None:
2830
"""Normalize constructor inputs and enforce model invariants."""
31+
# First normalize the two edge counts because they control PML indexing.
2932
if len(self.num_cells) != 2:
3033
raise ValueError("num_cells must contain two non-negative integers")
3134
num_cells = (
3235
_coerce_integral("num_cells", self.num_cells[0], minimum=0),
3336
_coerce_integral("num_cells", self.num_cells[1], minimum=0),
3437
)
3538
object.__setattr__(self, "num_cells", num_cells)
39+
40+
# Stretch parameters must be positive scalars; zero would remove the
41+
# damping profile or create invalid divisions in the PML factors.
3642
for name in ("sigma_max", "kappa_min", "kappa_max"):
3743
value = float(getattr(self, name))
3844
if not np.isfinite(value) or value <= 0.0:
3945
raise ValueError(f"{name} must be finite and positive")
4046
object.__setattr__(self, name, value)
47+
48+
# The outer PML stretch should never be weaker than the inner stretch.
4149
if self.kappa_max < self.kappa_min:
4250
raise ValueError("kappa_max must be greater than or equal to kappa_min")
4351
object.__setattr__(self, "order", _coerce_integral("order", self.order, minimum=1))
@@ -75,9 +83,13 @@ class BoundarySpec:
7583

7684
def __post_init__(self) -> None:
7785
"""Normalize constructor inputs and enforce model invariants."""
86+
# Keep only the low-edge symmetry flags; high edges are handled by the
87+
# open-domain derivative/PML construction.
7888
if len(self.low) != 2:
7989
raise ValueError("low must contain two boundary conditions")
8090
normalized = tuple(str(value).lower() for value in self.low)
91+
92+
# Reject misspellings early so they do not silently become PEC walls.
8193
unknown = set(normalized).difference({"pec", "pmc"})
8294
if unknown:
8395
raise ValueError("boundary conditions must be 'pec' or 'pmc'")
@@ -114,12 +126,20 @@ class Grid:
114126

115127
def __post_init__(self) -> None:
116128
"""Normalize constructor inputs and enforce model invariants."""
129+
# Store immutable float tuples so downstream validation and metadata
130+
# serialization see one consistent representation.
117131
x_edges = tuple(float(value) for value in self.x_edges)
118132
y_edges = tuple(float(value) for value in self.y_edges)
119133
object.__setattr__(self, "x_edges", x_edges)
120134
object.__setattr__(self, "y_edges", y_edges)
135+
136+
# The normal axis determines how local solver fields map back to global
137+
# x/y/z components, so it must be one of the Cartesian axes.
121138
if self.normal_axis not in {0, 1, 2}:
122139
raise ValueError("normal_axis must be 0, 1, or 2")
140+
141+
# Edge arrays define cell widths; non-monotonic values would produce
142+
# negative or zero finite-difference spacings.
123143
for name, values in {"x_edges": x_edges, "y_edges": y_edges}.items():
124144
if len(values) < 2:
125145
raise ValueError(f"{name} must contain at least two values")
@@ -148,9 +168,13 @@ class Materials:
148168

149169
def __post_init__(self) -> None:
150170
"""Normalize constructor inputs and enforce model invariants."""
171+
# Epsilon is required and must already be sampled on the solver cells.
151172
eps_tensor = np.asarray(self.eps_tensor, dtype=np.complex128)
152173
if eps_tensor.shape != (3, 3, *self.grid.shape):
153174
raise ValueError("eps_tensor must have shape (3, 3, nx, ny) matching the grid")
175+
176+
# Permeability defaults to identity so non-magnetic material grids stay
177+
# concise at call sites while the solver always receives full tensors.
154178
if self.mu_tensor is None:
155179
mu_tensor = np.zeros_like(eps_tensor)
156180
for axis in range(3):
@@ -159,6 +183,9 @@ def __post_init__(self) -> None:
159183
mu_tensor = np.asarray(self.mu_tensor, dtype=np.complex128)
160184
if mu_tensor.shape != eps_tensor.shape:
161185
raise ValueError("mu_tensor must have the same shape as eps_tensor")
186+
187+
# NaNs and infinities poison sparse assembly, so reject them at the data
188+
# model boundary instead of deep inside ARPACK.
162189
if not np.all(np.isfinite(eps_tensor)) or not np.all(np.isfinite(mu_tensor)):
163190
raise ValueError("material tensors must contain finite values")
164191
object.__setattr__(self, "eps_tensor", eps_tensor)
@@ -180,12 +207,16 @@ def from_diagonal(
180207
normal_coordinate: float = 0.0,
181208
) -> Materials:
182209
"""Build a material grid from scalar or diagonal tensor components."""
210+
# Build and validate grid metadata before interpreting any component
211+
# arrays, because the grid shape drives every component check.
183212
grid = Grid(
184213
tuple(float(value) for value in x_edges),
185214
tuple(float(value) for value in y_edges),
186215
normal_axis=normal_axis,
187216
normal_coordinate=normal_coordinate,
188217
)
218+
219+
# Missing yy/zz components inherit xx, matching isotropic scalar input.
189220
eps_diag = _stack_diagonal_components("eps", grid.shape, eps_xx, eps_yy, eps_zz)
190221
mu_diag = _stack_diagonal_components(
191222
"mu",
@@ -194,6 +225,9 @@ def from_diagonal(
194225
np.ones(grid.shape, dtype=np.complex128) if mu_yy is None else mu_yy,
195226
np.ones(grid.shape, dtype=np.complex128) if mu_zz is None else mu_zz,
196227
)
228+
229+
# Store the result in the same full-tensor layout used by tensorial
230+
# inputs; diagonal detection later chooses the faster solver path.
197231
return cls(
198232
grid=grid, eps_tensor=_diagonal_to_full_tensor(eps_diag), mu_tensor=_diagonal_to_full_tensor(mu_diag)
199233
)
@@ -226,12 +260,17 @@ def from_components(
226260
normal_coordinate: float = 0.0,
227261
) -> Materials:
228262
"""Build a material grid from diagonal and off-diagonal tensor components."""
263+
# Full component construction shares the same grid validation as the
264+
# diagonal constructor, then overlays optional off-diagonal entries.
229265
grid = Grid(
230266
tuple(float(value) for value in x_edges),
231267
tuple(float(value) for value in y_edges),
232268
normal_axis=normal_axis,
233269
normal_coordinate=normal_coordinate,
234270
)
271+
272+
# Start with diagonal tensors so scalar and anisotropic grids follow one
273+
# predictable path before optional coupling components are assigned.
235274
eps_diag = _stack_diagonal_components("eps", grid.shape, eps_xx, eps_yy, eps_zz)
236275
eps_tensor = _diagonal_to_full_tensor(eps_diag)
237276
_assign_tensor_offdiagonal(
@@ -245,6 +284,9 @@ def from_components(
245284
zx=eps_zx,
246285
zy=eps_zy,
247286
)
287+
288+
# Magnetic coupling components are optional; absent mu entries represent
289+
# identity permeability on the diagonal and zero off-diagonal coupling.
248290
mu_diag = _stack_diagonal_components(
249291
"mu",
250292
grid.shape,
@@ -264,6 +306,9 @@ def from_components(
264306
zx=mu_zx,
265307
zy=mu_zy,
266308
)
309+
310+
# The dataclass constructor performs the final finite-value and shape
311+
# validation on the assembled full tensors.
267312
return cls(grid=grid, eps_tensor=eps_tensor, mu_tensor=mu_tensor)
268313

269314
@classmethod
@@ -306,6 +351,9 @@ def from_slice(
306351

307352
axis_index = _normalize_slice_axis(axis)
308353
edge_values = tuple(float(value) for value in coord_edges)
354+
355+
# A slice is still represented as a 2D mode plane, so one axis gets the
356+
# user coordinates and the other gets a single finite-width cell.
309357
if len(edge_values) < 2:
310358
raise ValueError("coord_edges must contain at least two values")
311359
if invariant_width <= 0.0 or not np.isfinite(invariant_width):
@@ -320,16 +368,24 @@ def from_slice(
320368

321369
def expand(label: str, values: np.ndarray | None) -> np.ndarray | None:
322370
"""Expand a one-dimensional component onto the padded mode-plane grid."""
371+
# Optional components stay absent so from_components can apply its
372+
# diagonal/permeability defaults in one place.
323373
if values is None:
324374
return None
325375
array = np.asarray(values, dtype=np.complex128)
326376
if array.shape != (cell_count,):
327377
raise ValueError(f"{label} must have shape {(cell_count,)} for a one-dimensional slice")
378+
379+
# Insert the singleton invariant axis on the side implied by the
380+
# requested varying axis.
328381
return array[:, None] if axis_index == 0 else array[None, :]
329382

330383
expanded_eps_xx = expand("eps_xx", eps_xx)
331384
if expanded_eps_xx is None:
332385
raise ValueError("eps_xx is required")
386+
387+
# Delegate to the full component constructor so slice and grid inputs
388+
# share tensor assembly and validation behavior.
333389
return cls.from_components(
334390
x_edges=x_edges,
335391
y_edges=y_edges,
@@ -383,6 +439,9 @@ def from_subpixel_diagonal(
383439
x_edge_values = tuple(float(value) for value in x_edges)
384440
y_edge_values = tuple(float(value) for value in y_edges)
385441
shape = (len(x_edge_values) - 1, len(y_edge_values) - 1)
442+
443+
# Average each supplied high-resolution component independently before
444+
# reusing the ordinary diagonal tensor constructor.
386445
averaged = {
387446
"eps_xx": cls.average_subpixels(eps_xx, shape=shape, subpixel_shape=subpixel_shape, method=average),
388447
"eps_yy": None
@@ -427,18 +486,26 @@ def average_subpixels(
427486

428487
nx, ny = (int(shape[0]), int(shape[1]))
429488
sx, sy = (int(subpixel_shape[0]), int(subpixel_shape[1]))
489+
490+
# Validate the target grouping before reshaping so shape errors are
491+
# reported in terms of solver cells and subpixel samples.
430492
if nx <= 0 or ny <= 0:
431493
raise ValueError("shape must contain positive cell counts")
432494
if sx <= 0 or sy <= 0:
433495
raise ValueError("subpixel_shape must contain positive sample counts")
434496
array = np.asarray(values, dtype=np.complex128)
497+
498+
# Accept either a dense raster or an already grouped raster; both become
499+
# (nx, ny, sx, sy) before the selected averaging rule is applied.
435500
if array.shape == (nx * sx, ny * sy):
436501
grouped = array.reshape(nx, sx, ny, sy).transpose(0, 2, 1, 3)
437502
elif array.shape == (nx, ny, sx, sy):
438503
grouped = array
439504
else:
440505
raise ValueError(f"subpixel values must have shape {(nx * sx, ny * sy)} or {(nx, ny, sx, sy)}")
441506

507+
# Choose the averaging rule explicitly so interface-sensitive callers can
508+
# request harmonic/geometric behavior without changing solver code.
442509
if method == "arithmetic":
443510
return grouped.mean(axis=(2, 3))
444511
if method == "harmonic":
@@ -463,6 +530,8 @@ def shape(self) -> tuple[int, int]:
463530
@property
464531
def is_diagonal(self) -> bool:
465532
"""Return whether epsilon and mu contain only diagonal components."""
533+
# A boolean mask lets the check stay independent of grid shape and
534+
# catches off-diagonal entries across every solver cell.
466535
off_diagonal = np.ones((3, 3), dtype=bool)
467536
np.fill_diagonal(off_diagonal, False)
468537
mu_tensor = self._resolved_mu_tensor()
@@ -497,24 +566,33 @@ def _stack_diagonal_components(
497566
zz: np.ndarray | None,
498567
) -> np.ndarray:
499568
"""Validate and stack x/y/z diagonal tensor components."""
569+
# xx is the required scalar/diagonal component; yy and zz inherit it unless
570+
# anisotropic values are provided.
500571
xx_array = np.asarray(xx, dtype=np.complex128)
501572
if xx_array.shape != shape:
502573
raise ValueError(f"{label}_xx must have shape {shape}")
503574
yy_array = xx_array if yy is None else np.asarray(yy, dtype=np.complex128)
504575
zz_array = xx_array if zz is None else np.asarray(zz, dtype=np.complex128)
576+
577+
# All diagonal components must be cell-centered arrays matching the grid.
505578
if yy_array.shape != shape or zz_array.shape != shape:
506579
raise ValueError(f"{label}_xx, {label}_yy, and {label}_zz must have shape {shape}")
507580
return np.stack([xx_array, yy_array, zz_array], axis=0)
508581

509582

510583
def _coerce_integral(name: str, value: object, *, minimum: int) -> int:
511584
"""Coerce index-like values while rejecting floats and booleans."""
585+
# bool implements the integer protocol, but accepting it would make option
586+
# typos like num_cells=(True, False) look valid.
512587
if isinstance(value, (bool, np.bool_)):
513588
raise ValueError(f"{name} must contain integers")
514589
try:
515590
integer = index(cast(SupportsIndex, value))
516591
except TypeError as exc:
517592
raise ValueError(f"{name} must contain integers") from exc
593+
594+
# Keep the minimum check here so every PML/order caller reports consistent
595+
# validation messages.
518596
if integer < minimum:
519597
if minimum == 0:
520598
raise ValueError(f"{name} must contain non-negative integers")
@@ -533,6 +611,8 @@ def _normalize_slice_axis(axis: SliceAxis) -> int:
533611

534612
def _diagonal_to_full_tensor(diagonal: np.ndarray) -> np.ndarray:
535613
"""Expand diagonal components into a full 3x3 tensor grid."""
614+
# The solver consumes a full tensor layout even for diagonal materials; all
615+
# off-diagonal entries remain zero.
536616
tensor = np.zeros((3, 3, *diagonal.shape[1:]), dtype=np.complex128)
537617
for axis in range(3):
538618
tensor[axis, axis, :, :] = diagonal[axis]
@@ -552,6 +632,8 @@ def _assign_tensor_offdiagonal(
552632
zy: np.ndarray | None,
553633
) -> None:
554634
"""Validate and assign optional off-diagonal tensor components."""
635+
# Iterate through physical tensor suffixes instead of open-coding each
636+
# assignment, which keeps validation messages and row/column mapping aligned.
555637
for (row, col), suffix, values in [
556638
((0, 1), "xy", xy),
557639
((0, 2), "xz", xz),
@@ -563,6 +645,9 @@ def _assign_tensor_offdiagonal(
563645
if values is None:
564646
continue
565647
array = np.asarray(values, dtype=np.complex128)
648+
649+
# Off-diagonal components are cell-centered just like the diagonal
650+
# components; no broadcasting is allowed because that can hide mistakes.
566651
if array.shape != shape:
567652
raise ValueError(f"{label}_{suffix} must have shape {shape}")
568653
tensor[row, col, :, :] = array
@@ -583,10 +668,14 @@ class Spec:
583668

584669
def __post_init__(self) -> None:
585670
"""Normalize constructor inputs and enforce model invariants."""
671+
# Validate simple scalar controls before expanding nested model objects.
586672
if self.num_modes <= 0:
587673
raise ValueError("num_modes must be positive")
588674
if self.target_neff is not None and self.target_neff <= 0:
589675
raise ValueError("target_neff must be positive")
676+
677+
# Accept compact tuple inputs at the API boundary, but store canonical
678+
# model objects so solve_modes can unpack a Spec without extra branches.
590679
pml = self.pml
591680
pml = PmlSpec() if pml is None else pml
592681
if not isinstance(pml, PmlSpec):
@@ -596,6 +685,9 @@ def __post_init__(self) -> None:
596685
if not isinstance(boundary, BoundarySpec):
597686
boundary = BoundarySpec(low=boundary)
598687
object.__setattr__(self, "boundary", boundary)
688+
689+
# Bend transforms need both a radius and an axis because the Jacobian
690+
# depends on which transverse coordinate measures curvature.
599691
if self.bend_radius is not None and np.isclose(self.bend_radius, 0.0):
600692
raise ValueError("bend_radius magnitude must be larger than 0")
601693
if self.bend_radius is not None and self.bend_axis is None:

0 commit comments

Comments
 (0)