1414BoundaryCondition = 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 )
1820class 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
510583def _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
534612def _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